@drawcall/market 0.1.49 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +4 -1
  2. package/dist/cli.js +46 -3
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/generate-install.d.ts +6 -0
  5. package/dist/commands/generate-install.d.ts.map +1 -0
  6. package/dist/commands/generate-install.js +29 -0
  7. package/dist/commands/generate-install.js.map +1 -0
  8. package/dist/commands/generate.d.ts +13 -0
  9. package/dist/commands/generate.d.ts.map +1 -1
  10. package/dist/commands/generate.js +57 -20
  11. package/dist/commands/generate.js.map +1 -1
  12. package/dist/commands/pack.d.ts +14 -0
  13. package/dist/commands/pack.d.ts.map +1 -0
  14. package/dist/commands/pack.js +39 -0
  15. package/dist/commands/pack.js.map +1 -0
  16. package/dist/commands/upload.d.ts +0 -12
  17. package/dist/commands/upload.d.ts.map +1 -1
  18. package/dist/commands/upload.js +14 -187
  19. package/dist/commands/upload.js.map +1 -1
  20. package/dist/generate.d.ts +14 -6
  21. package/dist/generate.d.ts.map +1 -1
  22. package/dist/generate.js +30 -19
  23. package/dist/generate.js.map +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/output.d.ts +4 -0
  29. package/dist/output.d.ts.map +1 -1
  30. package/dist/output.js +31 -0
  31. package/dist/output.js.map +1 -1
  32. package/dist/pack.d.ts +46 -0
  33. package/dist/pack.d.ts.map +1 -0
  34. package/dist/pack.js +220 -0
  35. package/dist/pack.js.map +1 -0
  36. package/dist/skill.d.ts +1 -1
  37. package/dist/skill.d.ts.map +1 -1
  38. package/dist/skill.js +3 -2
  39. package/dist/skill.js.map +1 -1
  40. package/package.json +2 -2
  41. package/skills/market/SKILL.md +3 -2
  42. package/src/cli.ts +61 -4
  43. package/src/commands/agent.ts +1 -1
  44. package/src/commands/generate-install.ts +36 -0
  45. package/src/commands/generate.ts +77 -30
  46. package/src/commands/pack.ts +55 -0
  47. package/src/commands/upload.ts +14 -219
  48. package/src/generate.ts +43 -31
  49. package/src/index.ts +2 -2
  50. package/src/output.ts +35 -0
  51. package/src/pack.ts +294 -0
  52. package/src/skill.ts +3 -2
@@ -0,0 +1,55 @@
1
+ import * as fs from 'fs/promises'
2
+ import * as path from 'path'
3
+ import ora from 'ora'
4
+ import { packAsset, packPolicyForType, parsePackDependencies } from '../pack.js'
5
+ import { packResult } from '../output.js'
6
+ import type { AssetType } from '../schemas.js'
7
+
8
+ export interface PackCommandOptions {
9
+ type?: AssetType
10
+ cwd?: string
11
+ out?: string
12
+ /** npm dependencies, each `name@range` (range defaults to `*`). */
13
+ npm?: string[]
14
+ /** asset dependencies, each `name@range` (range defaults to `*`). */
15
+ asset?: string[]
16
+ /** skill dependencies, each `label=source` passed to `skills add`. */
17
+ skill?: string[]
18
+ }
19
+
20
+ export async function packCommand(zipFilter: string, opts: PackCommandOptions): Promise<void> {
21
+ const spinner = ora({
22
+ text: 'Preparing pack',
23
+ isEnabled: Boolean(process.stderr.isTTY),
24
+ isSilent: !process.stderr.isTTY,
25
+ }).start()
26
+
27
+ try {
28
+ const cwd = opts.cwd ?? process.cwd()
29
+ const packed = await packAsset(zipFilter, {
30
+ cwd,
31
+ dependencies: parsePackDependencies({
32
+ npm: opts.npm,
33
+ asset: opts.asset,
34
+ skill: opts.skill,
35
+ }),
36
+ policy: packPolicyForType(opts.type),
37
+ })
38
+ const out = opts.out ? path.resolve(cwd, opts.out) : defaultPackPath(packed.sourcePath)
39
+
40
+ spinner.text = `Writing ${path.relative(cwd, out) || out}`
41
+ await fs.mkdir(path.dirname(out), { recursive: true })
42
+ await fs.writeFile(out, packed.zip)
43
+
44
+ spinner.stop()
45
+ console.log(packResult(out, packed))
46
+ } catch (err) {
47
+ spinner.stop()
48
+ throw err
49
+ }
50
+ }
51
+
52
+ function defaultPackPath(sourcePath: string): string {
53
+ const ext = path.extname(sourcePath)
54
+ return path.join(path.dirname(sourcePath), `${path.basename(sourcePath, ext)}.packed${ext}`)
55
+ }
@@ -1,21 +1,13 @@
1
- import * as fs from 'fs/promises'
2
- import * as path from 'path'
3
- import { unzipSync, zipSync } from 'fflate'
4
1
  import ora from 'ora'
5
2
  import semver from 'semver'
6
3
  import { getCliClient } from '../cli-client.js'
7
- import { findInstallRoot } from '../install.js'
8
- import { readMarketLock, sha256 } from '../market-lock.js'
9
- import {
10
- packageJsonAssetDependenciesFromFiles,
11
- packageJsonNpmDependenciesFromFiles,
12
- } from '../package-json.js'
13
- import { MAX_ASSET_DESCRIPTION_LENGTH, MAX_UPLOAD_ZIP_SIZE_BYTES } from '../schemas.js'
4
+ import { packAsset, packPolicyFromInstallMetadata, parsePackDependencies } from '../pack.js'
14
5
  import { unchangedUploadResult, uploadResult } from '../output.js'
15
6
  import {
16
7
  assetDescriptionSchema,
17
8
  assetNameSchema,
18
9
  semverSchema,
10
+ MAX_ASSET_DESCRIPTION_LENGTH,
19
11
  type AssetAccess,
20
12
  type AssetType,
21
13
  } from '../schemas.js'
@@ -52,37 +44,21 @@ export async function uploadCommand(
52
44
  const parsedVersion = opts.version ? semverSchema.parse(opts.version) : undefined
53
45
  const parsedDescription = parseUploadDescription(description)
54
46
  // Parse dep flags before any network work so a malformed spec fails fast.
55
- const explicitNpmDependencies = parseVersionedDeps(opts.npm ?? [], 'npm')
56
- const explicitAssetDependencies = parseVersionedDeps(opts.asset ?? [], 'asset')
57
- const skillDependencies = parseSkillDeps(opts.skill ?? [])
47
+ const dependencies = parsePackDependencies({
48
+ npm: opts.npm,
49
+ asset: opts.asset,
50
+ skill: opts.skill,
51
+ })
58
52
  const cwd = opts.cwd ?? process.cwd()
59
53
  const type = opts.type
60
- const zipFile = await resolveOneZipFile(cwd, zipFilter)
61
- const zipStat = await fs.stat(zipFile)
62
- if (zipStat.size >= MAX_UPLOAD_ZIP_SIZE_BYTES) {
63
- throw new Error('Upload zip must be smaller than 1 GB')
64
- }
65
- const sourceZip = new Uint8Array(await fs.readFile(zipFile))
66
54
  const { client } = await getCliClient({ baseUrl: opts.baseUrl, requireAuth: true })
67
55
  const installMetadata = await client.asset.installMetadata()
68
- const typeMetadata = installMetadata[type]
69
- const sourceFiles = unzipSync(sourceZip)
70
- const packageJsonAssetDependencies = typeMetadata?.readAssetDependenciesFromPackageJson
71
- ? packageJsonAssetDependenciesFromFiles(sourceFiles)
72
- : {}
73
- const assetDependencies = mergeAssetDependencies(
74
- packageJsonAssetDependencies,
75
- explicitAssetDependencies,
76
- )
77
- // A type that ships a package.json (templates) also carries its npm dependencies there; capture
78
- // them so an install over an existing package.json brings them. Explicit --npm wins on conflict.
79
- const packageJsonNpmDependencies = typeMetadata?.readAssetDependenciesFromPackageJson
80
- ? packageJsonNpmDependenciesFromFiles(sourceFiles)
81
- : {}
82
- const npmDependencies = { ...packageJsonNpmDependencies, ...explicitNpmDependencies }
83
- const zip = typeMetadata?.omitUnchangedInstalledFilesOnUpload
84
- ? await omitUnchangedInstalledFiles(sourceZip, sourceFiles, assetDependencies, cwd)
85
- : sourceZip
56
+ const packed = await packAsset(zipFilter, {
57
+ cwd,
58
+ dependencies,
59
+ policy: packPolicyFromInstallMetadata(installMetadata[type]),
60
+ })
61
+ const { zip, npmDependencies, assetDependencies, skillDependencies } = packed
86
62
  const profile = await client.user.getProfile()
87
63
  if (!profile) {
88
64
  throw new Error('Not logged in. Run `market login` first.')
@@ -151,118 +127,6 @@ export function parseUploadDescription(description: string): string {
151
127
  }
152
128
  }
153
129
 
154
- /**
155
- * Parse `name@range` specs (npm or asset deps) into a name→range record. The
156
- * range is optional and defaults to `*`. A leading `@` is treated as a scope
157
- * marker, so `@scope/pkg@^1.0.0` splits into `@scope/pkg` and `^1.0.0`.
158
- */
159
- export function parseVersionedDeps(specs: string[], kind: 'npm' | 'asset'): Record<string, string> {
160
- const out: Record<string, string> = {}
161
- for (const spec of specs) {
162
- const at = spec.lastIndexOf('@')
163
- const hasRange = at > 0
164
- const name = hasRange ? spec.slice(0, at) : spec
165
- const range = hasRange ? spec.slice(at + 1) : '*'
166
- if (!name || !range) {
167
- throw new Error(`Invalid ${kind} dependency "${spec}". Use name@range (e.g. three@^0.178.0).`)
168
- }
169
- if (name in out) {
170
- throw new Error(`Duplicate ${kind} dependency "${name}".`)
171
- }
172
- out[name] = range
173
- }
174
- return out
175
- }
176
-
177
- /**
178
- * Parse `label=source` specs into a label→source record. The source is passed
179
- * verbatim to `skills add` (a GitHub/git ref or a local path), so only the
180
- * first `=` is treated as the separator.
181
- */
182
- export function parseSkillDeps(specs: string[]): Record<string, string> {
183
- const out: Record<string, string> = {}
184
- for (const spec of specs) {
185
- const eq = spec.indexOf('=')
186
- if (eq <= 0 || eq === spec.length - 1) {
187
- throw new Error(
188
- `Invalid skill dependency "${spec}". Use label=source ` +
189
- `(e.g. web-design=vercel-labs/agent-skills).`,
190
- )
191
- }
192
- const label = spec.slice(0, eq)
193
- if (label in out) {
194
- throw new Error(`Duplicate skill dependency "${label}".`)
195
- }
196
- out[label] = spec.slice(eq + 1)
197
- }
198
- return out
199
- }
200
-
201
- function mergeAssetDependencies(
202
- fromPackageJson: Record<string, string>,
203
- explicit: Record<string, string>,
204
- ): Record<string, string> {
205
- const merged = { ...fromPackageJson }
206
- for (const [name, range] of Object.entries(explicit)) {
207
- if (name in merged && merged[name] !== range) {
208
- throw new Error(
209
- `Conflicting asset dependency "${name}": package.json has ${merged[name]}, --asset has ${range}`,
210
- )
211
- }
212
- merged[name] = range
213
- }
214
- return merged
215
- }
216
-
217
- async function omitUnchangedInstalledFiles(
218
- sourceZip: Uint8Array,
219
- sourceFiles: Record<string, Uint8Array>,
220
- assetDependencies: Record<string, string>,
221
- cwd: string,
222
- ): Promise<Uint8Array> {
223
- const dependencyNames = new Set(Object.keys(assetDependencies))
224
- if (dependencyNames.size === 0) return sourceZip
225
-
226
- const installRoot = await findInstallRoot(cwd)
227
- const lock = await readMarketLock(installRoot)
228
- const hashesByPath = new Map<string, string>()
229
-
230
- for (const [name, asset] of Object.entries(lock.assets)) {
231
- if (!dependencyNames.has(name)) continue
232
- for (const [file, metadata] of Object.entries(asset.files)) {
233
- hashesByPath.set(file, metadata.sha256)
234
- }
235
- }
236
-
237
- if (hashesByPath.size === 0) return sourceZip
238
-
239
- let omitted = false
240
- const filtered: Record<string, Uint8Array> = {}
241
- for (const [file, content] of Object.entries(sourceFiles)) {
242
- const normalizedPath = normalizedZipPath(file)
243
- const lockedHash = normalizedPath ? hashesByPath.get(normalizedPath) : undefined
244
- if (lockedHash && lockedHash === sha256(content)) {
245
- omitted = true
246
- continue
247
- }
248
- filtered[file] = content
249
- }
250
-
251
- return omitted ? zipSync(filtered) : sourceZip
252
- }
253
-
254
- function normalizedZipPath(file: string): string | null {
255
- const zipPath = file.replace(/\\/g, '/')
256
- if (
257
- zipPath.split('/').includes('..') ||
258
- path.posix.isAbsolute(zipPath) ||
259
- path.win32.isAbsolute(zipPath)
260
- ) {
261
- return null
262
- }
263
- return path.posix.normalize(zipPath)
264
- }
265
-
266
130
  /** Compare a stored dependency JSON string against a freshly parsed record. */
267
131
  function depsEqual(storedJson: string, next: Record<string, string>): boolean {
268
132
  let stored: Record<string, string>
@@ -275,71 +139,6 @@ function depsEqual(storedJson: string, next: Record<string, string>): boolean {
275
139
  return keys.length === Object.keys(next).length && keys.every((key) => stored[key] === next[key])
276
140
  }
277
141
 
278
- async function resolveOneZipFile(cwd: string, zipFilter: string): Promise<string> {
279
- const absolute = path.resolve(cwd, zipFilter)
280
- const stat = await maybeStat(absolute)
281
- if (stat?.isFile()) return assertZipFile(absolute)
282
-
283
- const files = await listFiles(cwd)
284
- const matches = files
285
- .filter((file) => matchesFilter(path.relative(cwd, file), zipFilter))
286
- .filter(isZipFile)
287
- .sort()
288
-
289
- if (matches.length === 0) {
290
- throw new Error(`No .zip files matched "${zipFilter}"`)
291
- }
292
- if (matches.length > 1) {
293
- throw new Error(`File filter matched ${matches.length} zips; upload one asset at a time`)
294
- }
295
-
296
- return matches[0]
297
- }
298
-
299
- function assertZipFile(file: string): string {
300
- if (!isZipFile(file)) throw new Error(`Upload file must be a .zip: ${file}`)
301
- return file
302
- }
303
-
304
- function isZipFile(file: string): boolean {
305
- return /\.zip$/i.test(file)
306
- }
307
-
308
- async function maybeStat(file: string) {
309
- try {
310
- return await fs.stat(file)
311
- } catch {
312
- return null
313
- }
314
- }
315
-
316
- async function listFiles(dir: string): Promise<string[]> {
317
- const entries = await fs.readdir(dir, { withFileTypes: true })
318
- const files: string[] = []
319
- for (const entry of entries) {
320
- if (entry.name === 'node_modules' || entry.name === '.git') continue
321
- const fullPath = path.join(dir, entry.name)
322
- if (entry.isDirectory()) {
323
- files.push(...(await listFiles(fullPath)))
324
- } else if (entry.isFile()) {
325
- files.push(fullPath)
326
- }
327
- }
328
- return files
329
- }
330
-
331
- function matchesFilter(file: string, filter: string): boolean {
332
- const normalizedFile = file.split(path.sep).join('/')
333
- const normalizedFilter = filter.split(path.sep).join('/')
334
- const pattern =
335
- '^' +
336
- escapeRegExp(normalizedFilter)
337
- .replace(/\\\*\\\*/g, '.*')
338
- .replace(/\\\*/g, '[^/]*') +
339
- '$'
340
- return new RegExp(pattern).test(normalizedFile)
341
- }
342
-
343
142
  function nextVersion(latest?: string): string {
344
143
  if (!latest) return '1.0.0'
345
144
  const version = semver.inc(latest, 'patch')
@@ -356,9 +155,5 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
356
155
  }
357
156
 
358
157
  function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
359
- return new Uint8Array(bytes).buffer
360
- }
361
-
362
- function escapeRegExp(value: string): string {
363
- return value.replace(/[|\\{}()[\]^$+?.*]/g, '\\$&')
158
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
364
159
  }
package/src/generate.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { MarketClient } from './client.js'
2
- import type { AssetAccess, AssetType } from './schemas.js'
2
+ import type { AssetAccess, AssetType, GenerateJobStatus, GenerateResponse } from './schemas.js'
3
3
 
4
4
  export class GenerateError extends Error {
5
5
  constructor(message: string) {
@@ -15,49 +15,61 @@ export interface GenerateInput {
15
15
  access?: AssetAccess
16
16
  }
17
17
 
18
- export interface GenerateResult {
19
- assetName: string
20
- version: string
18
+ // How often the CLI polls a running job.
19
+ const POLL_INTERVAL_MS = 4_000
20
+ // How long one `generate` / `generate install` call blocks-and-polls before handing back control.
21
+ // The command does the waiting (the agent never sleeps between checks). When the window elapses the
22
+ // call returns and the caller just runs `generate install <jobId>` again, which picks up the SAME
23
+ // server-side job where this left off — so no asset type needs to be flagged "slow" ahead of time;
24
+ // anything that outlasts one window simply continues on the next call.
25
+ const WINDOW_MS = 120_000
26
+
27
+ // Start a generation. Fast providers answer `completed` inline; slow, job-based providers answer
28
+ // `pending` with a jobId to poll (see `waitForGeneration` / `generate install`).
29
+ export function startGeneration(
30
+ client: MarketClient,
31
+ input: GenerateInput,
32
+ ): Promise<GenerateResponse> {
33
+ return client.asset.generate(input)
21
34
  }
22
35
 
23
- export interface GenerateOptions {
24
- onProgress?: (message: string) => void
36
+ // One poll of a generation job.
37
+ export function pollGeneration(client: MarketClient, jobId: string): Promise<GenerateJobStatus> {
38
+ return client.asset.generateStatus({ jobId })
25
39
  }
26
40
 
27
- // Poll cadence for a job-based generation. Each poll is a short, independently-retriable request;
28
- // the client sleeps between them, so no single request is held open for the length of the job (that
29
- // is exactly the client/proxy-timeout trap the async flow removes). The deadline sits above the
30
- // slowest provider's own budget (the humanoid model's upstream job runs up to ~10 min).
31
- const POLL_INTERVAL_MS = 4_000
32
- const POLL_DEADLINE_MS = 15 * 60_000
41
+ // The bounded outcome of waiting on a job: it settled (completed/failed), or the window elapsed while
42
+ // it was still running.
43
+ export type PollOutcome =
44
+ | { status: 'completed'; assetName: string; version: string }
45
+ | { status: 'failed'; error: string }
46
+ | { status: 'running' }
33
47
 
34
- export async function generateAndWait(
48
+ // Poll a job until it settles or `windowMs` elapses. Bounded so a single call stays under a harness
49
+ // timeout; on timeout it returns `running` (the job keeps going server-side — run again to continue).
50
+ export async function waitForGeneration(
35
51
  client: MarketClient,
36
- input: GenerateInput,
37
- opts: GenerateOptions = {},
38
- ): Promise<GenerateResult> {
52
+ jobId: string,
53
+ opts: { windowMs?: number; onProgress?: (message: string) => void } = {},
54
+ ): Promise<PollOutcome> {
55
+ const windowMs = opts.windowMs ?? WINDOW_MS
39
56
  const report = opts.onProgress ?? (() => {})
40
-
41
- report('Generating asset')
42
- const started = await client.asset.generate(input)
43
- if (started.status === 'completed') {
44
- return { assetName: started.assetName, version: started.version }
45
- }
46
-
47
57
  const startedAt = Date.now()
48
58
  for (;;) {
49
- if (Date.now() - startedAt > POLL_DEADLINE_MS) {
50
- throw new GenerateError('Generation timed out.')
51
- }
52
- await delay(POLL_INTERVAL_MS)
53
- report('Generating asset')
54
- const status = await client.asset.generateStatus({ jobId: started.jobId })
59
+ const status = await pollGeneration(client, jobId)
55
60
  if (status.status === 'completed') {
56
- return { assetName: status.assetName, version: status.version }
61
+ return { status: 'completed', assetName: status.assetName, version: status.version }
57
62
  }
58
63
  if (status.status === 'failed') {
59
- throw new GenerateError(status.error)
64
+ return { status: 'failed', error: status.error }
65
+ }
66
+ const remaining = windowMs - (Date.now() - startedAt)
67
+ if (remaining <= 0) {
68
+ return { status: 'running' }
60
69
  }
70
+ report('Generating asset')
71
+ // Trim the last sleep so the call returns as soon as the window passes, not a poll-interval later.
72
+ await delay(Math.min(POLL_INTERVAL_MS, remaining))
61
73
  }
62
74
  }
63
75
 
package/src/index.ts CHANGED
@@ -40,8 +40,8 @@ export { resolve, ResolutionError } from './resolve.js'
40
40
  export type { ResolvedAsset, ResolveResult } from './resolve.js'
41
41
 
42
42
  // Generate
43
- export { generateAndWait, GenerateError } from './generate.js'
44
- export type { GenerateInput, GenerateResult, GenerateOptions } from './generate.js'
43
+ export { startGeneration, pollGeneration, GenerateError } from './generate.js'
44
+ export type { GenerateInput } from './generate.js'
45
45
 
46
46
  // Agent
47
47
  export { agentAndWait, AgentError } from './agent.js'
package/src/output.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AssetInstallMetadata, AssetSearchResult } from './contract.js'
2
2
  import type { ListInstalledAssetsResult } from './commands/list.js'
3
3
  import type { InstallResult } from './install.js'
4
+ import type { PackedAsset } from './pack.js'
4
5
 
5
6
  export function assetVersionRef(name: string, version?: string): string {
6
7
  return version ? `${name}@${version}` : name
@@ -125,6 +126,20 @@ export function generatedInstallResult(name: string, version: string): string {
125
126
  return `Generated and installed ${assetVersionRef(name, version)}`
126
127
  }
127
128
 
129
+ // Printed when `generate` returns before the job finished: it keeps running server-side and the
130
+ // caller continues it with `generate install`, which installs the asset once it is ready.
131
+ export function generationStartedResult(jobId: string): string {
132
+ return [
133
+ 'Still generating after a couple of minutes — it continues in the background.',
134
+ `Run this to pick up where it left off and install when ready: market generate install ${jobId}`,
135
+ ].join('\n')
136
+ }
137
+
138
+ // Printed by `generate install` when the job is still running after its wait window.
139
+ export function generationRunningResult(jobId: string): string {
140
+ return `Still generating. Run again to continue: market generate install ${jobId}`
141
+ }
142
+
128
143
  export function uploadResult(name: string, version: string): string {
129
144
  return `Uploaded ${assetVersionRef(name, version)}`
130
145
  }
@@ -133,6 +148,17 @@ export function unchangedUploadResult(name: string, version: string): string {
133
148
  return `No upload needed: ${assetVersionRef(name, version)} is unchanged`
134
149
  }
135
150
 
151
+ export function packResult(out: string, packed: PackedAsset): string {
152
+ const lines = [`Packed: ${out}`]
153
+ if (packed.omittedUnchangedInstalledFiles) {
154
+ lines.push('Omitted unchanged installed dependency files.')
155
+ }
156
+ lines.push(...dependencyLines('npm dependencies', packed.npmDependencies))
157
+ lines.push(...dependencyLines('asset dependencies', packed.assetDependencies))
158
+ lines.push(...dependencyLines('skill dependencies', packed.skillDependencies))
159
+ return lines.join('\n')
160
+ }
161
+
136
162
  export function previewResult(name: string, version: string, out: string): string {
137
163
  return `Saved preview for ${assetVersionRef(name, version)}: ${out}`
138
164
  }
@@ -204,3 +230,12 @@ function unique<T>(values: T[]): T[] {
204
230
  function indent(lines: string[], prefix: string): string[] {
205
231
  return lines.map((line) => `${prefix}${line}`)
206
232
  }
233
+
234
+ function dependencyLines(label: string, deps: Record<string, string>): string[] {
235
+ const entries = Object.entries(deps)
236
+ if (entries.length === 0) return []
237
+ return [
238
+ `${label}:`,
239
+ ...entries.sort(([a], [b]) => a.localeCompare(b)).map(([name, value]) => `- ${name}: ${value}`),
240
+ ]
241
+ }