import * as fs from 'fs/promises' import * as path from 'path' import ora from 'ora' import { resolve } from '../resolve.js' import { findInstallRoot, install as runInstall, type InstallRootRequest } from '../install.js' import { packageJsonAssetDependencies } from '../package-json.js' import { assetDependencyRange, assetNameSchema, type AssetDependencies } from '../schemas.js' import { getCliClient } from '../cli-client.js' import { lookupAssets } from '../refs.js' import { installResult } from '../output.js' import type { MarketClient } from '../client.js' export interface InstallCommandOptions { unapproved?: boolean cwd?: string baseUrl?: string force?: boolean /** Capability key of a shared private asset — install without an account. */ key?: string /** Static roots whose asset files become `_redirects` rules instead of downloads. */ redirect?: string[] } type AssetRequest = InstallRootRequest export async function installCommand(args: string[], opts: InstallCommandOptions): Promise { const { client } = await getCliClient({ baseUrl: opts.baseUrl }) const includeUnapproved = opts.unapproved ?? false // Awaited after resolution — both only need the client, so they overlap. const metadataPromise = client.asset.installMetadata() const spinner = ora({ isEnabled: Boolean(process.stderr.isTTY), isSilent: !process.stderr.isTTY, }).start() try { const requests = args.length > 0 ? await resolveArgs(client, args, includeUnapproved, spinner, opts.key) : await packageJsonRequests(opts.cwd) spinner.text = 'Resolving dependency tree' const resolution = await resolve(client.asset, requests, { includeUnapproved, key: opts.key }) const metadata = await metadataPromise const result = await runInstall(client, resolution, { cwd: opts.cwd, force: opts.force ?? false, rootRequests: requests, installMetadata: metadata, key: opts.key, redirectRoots: opts.redirect, onProgress: (msg) => { spinner.text = msg }, }) spinner.stop() console.log(installResult(result, metadata)) } catch (err) { spinner.stop() throw err } } /** * Resolve a single `name` or `name@range` argument to an exact asset request. * Asset names are globally unique, so the name fully determines the asset — no * `--type` is needed. install never fuzzy-searches or generates: it points the * caller at `market search` / `market generate` instead, which keeps behavior * predictable for agents. */ export async function resolveArg( client: MarketClient, arg: string, includeUnapproved: boolean, key?: string, ): Promise { const parsed = parseNameAndRange(arg) if (!parsed) { throw new Error( `Invalid asset name "${arg}". Use \`market search --type \` to find assets.`, ) } const [existing] = await lookupAssets(client.asset, [{ name: parsed.name }], { includeUnapproved, ...(key ? { key } : {}), }) if (existing) { return { name: parsed.name, range: parsed.range, saveRange: parsed.explicitRange ? parsed.range : `^${existing.version}`, save: true, } } if (!includeUnapproved) { const [hidden] = await lookupAssets(client.asset, [{ name: parsed.name }], { includeUnapproved: true, }) if (hidden) { throw new Error( `Asset "${parsed.name}" exists but has no approved versions. Re-run with \`--unapproved\` if you have access.`, ) } } throw new Error( `Asset "${parsed.name}" not found. Use \`market search\` to find assets or ` + `\`market generate --type \` to create one.`, ) } async function resolveArgs( client: MarketClient, args: string[], includeUnapproved: boolean, spinner: { text: string }, key?: string, ): Promise { const requests: AssetRequest[] = [] for (const arg of args) { spinner.text = `Resolving "${truncate(arg, 40)}"` requests.push(await resolveArg(client, arg, includeUnapproved, key)) } return requests } async function packageJsonRequests(cwd: string | undefined): Promise { const installRoot = await findInstallRoot(cwd ?? process.cwd()) const pkgPath = path.join(installRoot, 'package.json') const dependencies = await readPackageJsonAssetDependencies(pkgPath) const requests = Object.entries(dependencies).map(([name, value]) => { const range = assetDependencyRange(value) return { name, range, saveRange: range, save: false, } }) if (requests.length === 0) { throw new Error( 'No assets specified and package.json has no assetDependencies. Pass asset names or add assetDependencies.', ) } return requests } async function readPackageJsonAssetDependencies(pkgPath: string): Promise { try { return packageJsonAssetDependencies(await fs.readFile(pkgPath, 'utf-8'), pkgPath) } catch (error) { if (isMissingFile(error)) return {} throw error } } function isMissingFile(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } function parseNameAndRange(arg: string): | (Pick & { explicitRange: boolean }) | null { const atIdx = arg.indexOf('@') const name = atIdx >= 0 ? arg.slice(0, atIdx) : arg const range = atIdx >= 0 ? arg.slice(atIdx + 1) : '*' return assetNameSchema.safeParse(name).success ? { name, range, explicitRange: atIdx >= 0 } : null } function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max - 3) + '...' : s }