import type { AssetInstallMetadata, AssetSearchResult } from './contract.js' import type { ListInstalledAssetsResult } from './commands/list.js' import type { AssetUrls } from './commands/urls.js' import type { InstallResult } from './install.js' import type { AssetDependencySync, PackedAsset } from './pack.js' import { assetDependencyAlias, assetDependencyRange, type AssetDependencyValue } from './schemas.js' export function assetVersionRef(name: string, version?: string): string { return version ? `${name}@${version}` : name } function assetApproval(approved: boolean): 'approved' | 'unapproved' { return approved ? 'approved' : 'unapproved' } export function searchSummary(input: { query: string type: string includeUnapproved: boolean count: number total: number }): string { const approval = input.includeUnapproved ? 'approved+unapproved' : 'approved' const scope = `query="${input.query}" type=${input.type} approval=${approval}` if (input.count === 0) { return `No results: ${scope}` } return `Results: ${input.count}/${input.total} ${scope}` } export function assetSearchResultLine(item: AssetSearchResult): string { const size = item.sourceSizeBytes === null ? '' : ` | ${item.sourceSizeBytes} B` const description = item.description ? ` | ${normalizeInline(item.description)}` : '' return `- ${assetVersionRef(item.name, item.latestVersion)} | ${item.type} | ${assetApproval( item.approved, )}${size}${description}` } export function installResult( result: InstallResult, installMetadata: Record = {}, heading = 'Installed:', ): string { const npmDependencies = Object.entries(result.npmDependencies) const lines = [heading] for (const asset of result.assets) { lines.push(assetLine(asset)) lines.push(...assetFileLines(asset.files)) if (asset.redirectedFiles.length > 0) { lines.push(` ↪ ${asset.redirectedFiles.length} files served via _redirects`) } } const usage = unique(result.assets.map((asset) => asset.type)).flatMap((type) => { const message = installMetadata[type]?.installMessage return message ? block(`- ${type}: `, message) : [] }) if (usage.length > 0) { lines.push('Usage:', ...usage) } if (npmDependencies.length > 0) { lines.push( ...npmDependencies .sort(([a], [b]) => a.localeCompare(b)) .map(([name, version]) => `- ${assetVersionRef(name, version)} (npm)`), ) } const skillDependencies = Object.entries(result.skillDependencies ?? {}) if (skillDependencies.length > 0) { lines.push( ...skillDependencies .sort(([a], [b]) => a.localeCompare(b)) .map(([label, source]) => `- ${label} ← ${source} (skill)`), ) } if (result.warnings.length > 0) { lines.push('Warnings:', ...result.warnings.map((warning) => `- ${warning}`)) } return lines.join('\n') } export function assetUrlsResult(result: AssetUrls): string { return [ 'URLs:', `- ${assetVersionRef(result.name, result.version)}`, ` base: ${result.baseUrl}`, ...fileListLines(result.subpaths), ` preview: ${result.previewUrl ?? 'none'}`, ].join('\n') } export function listResult(result: ListInstalledAssetsResult): string { if (result.assets.length === 0) { return 'No asset dependencies declared in package.json.' } const lines = [`Asset dependencies: ${result.assets.length}`] for (const asset of result.assets) { lines.push(`- ${assetVersionRef(asset.name, asset.range)}`) const aliases = Object.entries(asset.alias).sort(([a], [b]) => a.localeCompare(b)) lines.push(...aliases.map(([canonical, current]) => ` ${aliasLine(canonical, current)}`)) } return lines.join('\n') } export function assetTypesResult(metadata: Record): string { const types = Object.entries(metadata).sort(([a], [b]) => a.localeCompare(b)) if (types.length === 0) return 'No asset types available.' const lines = ['Asset types:'] for (const [type, details] of types) { lines.push(`- ${type} (${assetCapabilities(details)})`) if (details.searchMessage) { lines.push(...block(' search: ', details.searchMessage)) } } return lines.join('\n') } function assetCapabilities(details: AssetInstallMetadata): string { if (details.canGenerate === undefined) return 'search, generation unknown' if (!details.canGenerate) return 'search only' return details.acceptsReferenceImages ? 'search, generate (reference images)' : 'search, generate' } function assetLine(asset: { name: string; version: string; type: string }): string { return `- ${assetVersionRef(asset.name, asset.version)} (${asset.type})` } function assetFileLines(paths: string[]): string[] { return fileListLines(paths.map(installedFileLine)) } function fileListLines(paths: string[]): string[] { const files = unique(paths).sort((a, b) => a.localeCompare(b)) if (files.length === 0) return [] return [' files:', ...files.map((file) => ` ${file}`)] } function installedFileLine(file: string): string { return file.startsWith('public/') ? `${file} -> browser: /${file.slice('public/'.length)}` : file } function block(label: string, message: string, width = 68): string[] { const hang = ' '.repeat(label.length) const words = normalizeInline(message).split(' ').filter(Boolean) const wrapped: string[] = [] let line = '' for (const word of words) { if (line && (line + ' ' + word).length > width) { wrapped.push(line) line = word } else { line = line ? `${line} ${word}` : word } } if (line) wrapped.push(line) return wrapped.map((text, index) => `${index === 0 ? label : hang}${text}`) } export function generatedInstallResult( result: InstallResult, installMetadata: Record, ): string { return installResult(result, installMetadata, 'Generated and installed:') } // Printed when `generate` returns before the job finished: it keeps running server-side and the // caller continues it with `generate install`, which installs the asset once it is ready. export function generationStartedResult(jobId: string): string { return [ 'Still generating after the normal wait — it continues in the background.', `Resume this same job and install when ready: ${generationInstallCommand(jobId)}`, ].join('\n') } // Printed by `generate install` when the job is still running after its wait window. export function generationRunningResult(jobId: string): string { return `Still generating. Resume this same job: ${generationInstallCommand(jobId)}` } function generationInstallCommand(jobId: string): string { return `npx @drawcall/market@latest generate install ${jobId} --cwd "$PWD"` } export function uploadResult(name: string, version: string): string { return `Uploaded ${assetVersionRef(name, version)}` } export function unchangedUploadResult(name: string, version: string): string { return `No upload needed: ${assetVersionRef(name, version)} is unchanged` } export function syncResult(result: AssetDependencySync): string { const entries = Object.entries(result.assetDependencies) if (entries.length === 0) { return 'No asset dependencies declared in package.json.' } const lines = [ result.changed ? 'Updated assetDependencies in package.json:' : 'assetDependencies already up to date:', ] for (const [name, value] of entries.sort(([a], [b]) => a.localeCompare(b))) { lines.push(`- ${assetVersionRef(name, assetDependencyRange(value))}`) const aliases = Object.entries(assetDependencyAlias(value)).sort(([a], [b]) => a.localeCompare(b), ) lines.push(...aliases.map(([canonical, current]) => ` ${aliasLine(canonical, current)}`)) } if (result.missing.length > 0) { lines.push('Dependency files not found (locally modified or deleted):') lines.push(...result.missing.map((file) => `- ${file.name}: ${file.path}`)) } if (result.skipped.length > 0) { lines.push(`Skipped (manifest unavailable): ${[...result.skipped].sort().join(', ')}`) } return lines.join('\n') } export function packResult(out: string, packed: PackedAsset): string { const lines = [`Packed: ${out}`] if (packed.gitignoredFiles > 0) { lines.push(`Excluded ${packed.gitignoredFiles} .gitignore'd file(s).`) } if (packed.omittedUnchangedInstalledFiles) { lines.push('Omitted unchanged installed dependency files.') } lines.push(...packWarnings(packed)) lines.push(...dependencyLines('npm dependencies', packed.npmDependencies)) lines.push(...dependencyLines('asset dependencies', packed.assetDependencies)) lines.push(...dependencyLines('skill dependencies', packed.skillDependencies)) return lines.join('\n') } /** * What pack could not check — the silent-fail-open cases with real consequences: an unfetchable * dependency ships its files inside the zip, and a missing dependency file means a local edit or * deletion the packer should know about. */ export function packWarnings(packed: PackedAsset): string[] { const lines: string[] = [] if (packed.skippedDependencies.length > 0) { lines.push( `Dependency file indexes unavailable (their files ship in the zip): ${[...packed.skippedDependencies].sort().join(', ')}`, ) } if (packed.missingDependencyFiles.length > 0) { lines.push('Dependency files not found (locally modified or deleted):') lines.push( ...packed.missingDependencyFiles.map((file) => `- ${file.name}: ${file.path}`).sort(), ) } return lines } function aliasLine(canonical: string, current: string | false): string { return current === false ? `${canonical} -> (tombstoned: not installed)` : `${canonical} -> ${current}` } export function previewResult(name: string, version: string, out: string): string { return `Saved preview for ${assetVersionRef(name, version)}: ${out}` } export function loginResult(email: string, configPath: string): string { return `Logged in as ${email} (config: ${configPath})` } export function logoutResult(configPath?: string): string { return configPath ? `Logged out (removed ${configPath})` : 'Already logged out' } export function errorResult(message: string): string { return `Error: ${message}` } function normalizeInline(value: string): string { return value.replace(/\s+/g, ' ').trim() } function unique(values: T[]): T[] { return [...new Set(values)] } function dependencyLines(label: string, deps: Record): string[] { const entries = Object.entries(deps) if (entries.length === 0) return [] return [ `${label}:`, ...entries .sort(([a], [b]) => a.localeCompare(b)) .map(([name, value]) => `- ${name}: ${dependencyValueLabel(value)}`), ] } function dependencyValueLabel(value: AssetDependencyValue): string { if (typeof value === 'string') return value const aliased = Object.keys(value.alias ?? {}).length return aliased > 0 ? `${value.version} (${aliased} aliased file(s))` : value.version }