@drawcall/market 0.3.0 → 0.5.0

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 (49) hide show
  1. package/dist/commands/list.d.ts +4 -2
  2. package/dist/commands/list.d.ts.map +1 -1
  3. package/dist/commands/list.js +1 -1
  4. package/dist/commands/list.js.map +1 -1
  5. package/dist/commands/upload.d.ts.map +1 -1
  6. package/dist/commands/upload.js +4 -1
  7. package/dist/commands/upload.js.map +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/install.d.ts +2 -1
  13. package/dist/install.d.ts.map +1 -1
  14. package/dist/install.js +148 -35
  15. package/dist/install.js.map +1 -1
  16. package/dist/output.d.ts +6 -0
  17. package/dist/output.d.ts.map +1 -1
  18. package/dist/output.js +24 -2
  19. package/dist/output.js.map +1 -1
  20. package/dist/pack.d.ts +49 -9
  21. package/dist/pack.d.ts.map +1 -1
  22. package/dist/pack.js +82 -77
  23. package/dist/pack.js.map +1 -1
  24. package/dist/project-walk.d.ts +18 -0
  25. package/dist/project-walk.d.ts.map +1 -0
  26. package/dist/project-walk.js +83 -0
  27. package/dist/project-walk.js.map +1 -0
  28. package/dist/resolve.d.ts +2 -2
  29. package/dist/resolve.d.ts.map +1 -1
  30. package/dist/resolve.js.map +1 -1
  31. package/dist/schemas.d.ts +16 -14
  32. package/dist/schemas.d.ts.map +1 -1
  33. package/dist/schemas.js +9 -3
  34. package/dist/schemas.js.map +1 -1
  35. package/dist/v1/contract.d.ts +5 -5
  36. package/dist/v1/contract.d.ts.map +1 -1
  37. package/dist/v1/contract.js +6 -4
  38. package/dist/v1/contract.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/commands/list.ts +8 -3
  41. package/src/commands/upload.ts +3 -1
  42. package/src/index.ts +4 -2
  43. package/src/install.ts +218 -47
  44. package/src/output.ts +33 -10
  45. package/src/pack.ts +125 -95
  46. package/src/project-walk.ts +95 -0
  47. package/src/resolve.ts +4 -3
  48. package/src/schemas.ts +24 -11
  49. package/src/v1/contract.ts +8 -6
package/src/install.ts CHANGED
@@ -11,16 +11,23 @@
11
11
  */
12
12
 
13
13
  import { execFile } from 'child_process'
14
- import { createHash } from 'crypto'
15
14
  import * as fs from 'fs/promises'
16
15
  import * as path from 'path'
17
16
  import { detectPackageManager, installDependencies } from 'nypm'
18
17
  import type { MarketClient } from './client.js'
19
18
  import type { AssetFilesResult, AssetInstallMetadata } from './contract.js'
20
19
  import { withProjectInstallLock } from './install-lock.js'
20
+ import { md5, resolveDependencyLayout } from './pack.js'
21
+ import { findInstallRoot, walkWithGitignore } from './project-walk.js'
21
22
  import { packageJsonAssetDependencies, parsePackageJson, type PackageJson } from './package-json.js'
22
23
  import type { ResolveResult } from './resolve.js'
23
- import { assetDependencyAlias, assetDependencyRange, assetDependencyValue } from './schemas.js'
24
+ export { findInstallRoot }
25
+ import {
26
+ assetDependencyAlias,
27
+ assetDependencyRange,
28
+ assetDependencyValue,
29
+ type AssetDependencyAlias,
30
+ } from './schemas.js'
24
31
 
25
32
  export interface InstallOptions {
26
33
  /** Directory to start project root discovery from (default: cwd) */
@@ -72,14 +79,25 @@ interface DownloadResult {
72
79
  assets: InstalledAsset[]
73
80
  wrotePackageJson: boolean
74
81
  warnings: string[]
82
+ /** Renames adopted during this install (per asset: canonical path → adopted project path). */
83
+ adoptedAliases: Record<string, Record<string, string>>
75
84
  }
76
85
 
77
86
  interface DownloadOptions {
78
87
  force: boolean
79
88
  log: (message: string) => void
80
89
  key?: string
81
- /** File aliases per asset name: canonical install path → the project's current path for it. */
82
- aliases: Record<string, Record<string, string>>
90
+ /** File aliases per asset name: canonical install path → the project's current path for it,
91
+ * or `false` for a tombstone (the file is intentionally not installed). */
92
+ aliases: Record<string, AssetDependencyAlias>
93
+ /** md5 of every project file, computed at most once per install (rename adoption). */
94
+ projectHashes: () => Promise<Map<string, string>>
95
+ /**
96
+ * Etags across an asset's published versions — the "recognized bytes" set for content-aware
97
+ * updates. `null` when the server cannot answer: fail-open in the conservative direction
98
+ * (never overwrite on uncertainty).
99
+ */
100
+ knownHashes: (name: string) => Promise<Set<string> | null>
83
101
  }
84
102
 
85
103
  export async function install(
@@ -97,6 +115,8 @@ export async function install(
97
115
  log,
98
116
  key: opts.key,
99
117
  aliases,
118
+ projectHashes: memoizedProjectHashes(installRoot),
119
+ knownHashes: memoizedKnownHashes(client),
100
120
  })
101
121
  const integration = await withProjectInstallLock(
102
122
  installRoot,
@@ -105,6 +125,7 @@ export async function install(
105
125
  rootRequests: opts.rootRequests ?? [],
106
126
  installMetadata: metadata,
107
127
  packageManagerNeeded: download.wrotePackageJson,
128
+ adoptedAliases: download.adoptedAliases,
108
129
  })
109
130
  if (packageJsonUpdate.packageManagerNeeded) {
110
131
  await (opts.runPackageManagerInstall ?? runPackageManagerInstall)(installRoot, log)
@@ -137,19 +158,6 @@ export async function install(
137
158
  }
138
159
  }
139
160
 
140
- export async function findInstallRoot(cwd: string = process.cwd()): Promise<string> {
141
- const start = path.resolve(cwd)
142
-
143
- for (let dir = start; ; dir = path.dirname(dir)) {
144
- if (await isFile(path.join(dir, 'package.json'))) {
145
- return dir
146
- }
147
- if (path.dirname(dir) === dir) {
148
- return start
149
- }
150
- }
151
- }
152
-
153
161
  /** Max asset downloads in flight at once. Assets write disjoint file trees, so downloading them
154
162
  * concurrently is safe and turns a ~40-asset install from serial round-trips into a few batched
155
163
  * waves. Kept modest so one install does not itself overwhelm the worker's per-request memory. */
@@ -175,6 +183,11 @@ async function downloadAssets(
175
183
  // the package manager to bring its npm deps.
176
184
  wrotePackageJson: perAsset.some((r) => r.wrotePackageJson),
177
185
  warnings: perAsset.flatMap((r) => r.warnings),
186
+ adoptedAliases: Object.fromEntries(
187
+ perAsset
188
+ .filter((r) => Object.keys(r.adopted).length > 0)
189
+ .map((r) => [r.asset.name, r.adopted]),
190
+ ),
178
191
  }
179
192
  }
180
193
 
@@ -183,53 +196,81 @@ async function downloadOneAsset(
183
196
  asset: ResolveResult['assets'][number],
184
197
  projectRoot: string,
185
198
  opts: DownloadOptions,
186
- ): Promise<{ asset: InstalledAsset; wrotePackageJson: boolean; warnings: string[] }> {
199
+ ): Promise<{
200
+ asset: InstalledAsset
201
+ wrotePackageJson: boolean
202
+ warnings: string[]
203
+ adopted: Record<string, string>
204
+ }> {
187
205
  opts.log(`Downloading ${asset.name}@${asset.version}...`)
188
206
 
189
207
  // Only the network fetches are retried — downloads are the sole transient step; file writes are
190
208
  // deterministic local work and a failure there is a real error, not worth re-attempting.
191
- const files = await fetchAssetEntries(client, asset, opts)
192
- const alias = opts.aliases[asset.name] ?? {}
193
- const installedFiles: string[] = []
194
- const warnings: string[] = []
195
- let wrotePackageJson = false
196
-
197
- for (const [relativePath, content] of files) {
209
+ const entries = await fetchAssetEntries(client, asset, opts)
210
+ const planned = entries.flatMap((entry) => {
198
211
  const normalizedPath = safeRelativePath(
199
- relativePath,
200
- () => `Zip contains an unsafe path: ${relativePath}`,
212
+ entry.path,
213
+ () => `Zip contains an unsafe path: ${entry.path}`,
201
214
  )
202
215
  if (
203
216
  normalizedPath === '.' ||
204
217
  (normalizedPath === 'README.md' && asset.type !== 'template') ||
205
218
  normalizedPath.endsWith('/')
206
219
  ) {
207
- continue
220
+ return []
208
221
  }
222
+ return [{ ...entry, normalizedPath }]
223
+ })
224
+
225
+ // Alias targets come from metadata; reject traversal before any path is stat'd or resolved —
226
+ // adoption must never quietly route around unsafe metadata.
227
+ const declaredAlias = opts.aliases[asset.name] ?? {}
228
+ for (const [canonical, target] of Object.entries(declaredAlias)) {
229
+ if (target === false) continue
230
+ safeRelativePath(
231
+ target,
232
+ () => `Alias for ${canonical} in ${asset.name} is an unsafe path: ${target}`,
233
+ )
234
+ }
235
+ const adopted = await adoptRenamedFiles(planned, asset.name, declaredAlias, projectRoot, opts)
236
+ const alias = { ...declaredAlias, ...adopted }
237
+
238
+ const installedFiles: string[] = []
239
+ const warnings: string[] = []
240
+ let wrotePackageJson = false
209
241
 
210
- // An alias redirects the file to where this project keeps it (a rename recorded by pack or a
211
- // dependent asset). Alias targets come from metadata, so they pass the same traversal guard.
212
- const aliased = alias[normalizedPath]
242
+ for (const entry of planned) {
243
+ // An alias redirects the file to where this project keeps it (a rename recorded by pack, a
244
+ // dependent asset, or adoption above); `false` is a tombstone — a deliberate deletion that
245
+ // must not resurrect on reinstall. Alias targets pass the same traversal guard.
246
+ const aliased = alias[entry.normalizedPath]
247
+ if (aliased === false) continue
213
248
  const targetPath =
214
249
  aliased === undefined
215
- ? normalizedPath
250
+ ? entry.normalizedPath
216
251
  : safeRelativePath(
217
252
  aliased,
218
- () => `Alias for ${normalizedPath} in ${asset.name} is an unsafe path: ${aliased}`,
253
+ () =>
254
+ `Alias for ${entry.normalizedPath} in ${asset.name} is an unsafe path: ${aliased}`,
219
255
  )
220
256
 
221
257
  const filePath = path.join(projectRoot, targetPath)
222
258
  const existing = await maybeReadFile(filePath)
223
- if (existing && !bytesEqual(existing, content) && !opts.force) {
224
- warnings.push(
225
- `Skipped ${targetPath} from ${asset.name}@${asset.version}; file already exists. Re-run with --force to overwrite.`,
226
- )
227
- continue
259
+ if (existing && !bytesEqual(existing, entry.bytes)) {
260
+ // Recognized bytes — a published version of this same dependency file — are replaceable
261
+ // without --force; unrecognized bytes are the user's and keep the skip-and-warn behavior.
262
+ const known = opts.force ? null : await opts.knownHashes(asset.name)
263
+ if (!opts.force && !known?.has(md5(existing))) {
264
+ warnings.push(
265
+ `Skipped ${targetPath} from ${asset.name}@${asset.version}; file already exists. Re-run with --force to overwrite.`,
266
+ )
267
+ continue
268
+ }
228
269
  }
229
270
 
230
- if (!existing || !bytesEqual(existing, content)) {
271
+ if (!existing || !bytesEqual(existing, entry.bytes)) {
231
272
  await fs.mkdir(path.dirname(filePath), { recursive: true })
232
- await fs.writeFile(filePath, content)
273
+ await fs.writeFile(filePath, entry.bytes)
233
274
  if (targetPath === 'package.json') {
234
275
  wrotePackageJson = true
235
276
  }
@@ -249,6 +290,108 @@ async function downloadOneAsset(
249
290
  },
250
291
  wrotePackageJson,
251
292
  warnings,
293
+ adopted,
294
+ }
295
+ }
296
+
297
+ interface PlannedEntry {
298
+ normalizedPath: string
299
+ etag: string
300
+ bytes: Uint8Array
301
+ }
302
+
303
+ /**
304
+ * Install-time adoption of unsynced renames: when a file's write target is absent but identical
305
+ * bytes already live elsewhere in the project, that location is used instead of creating a
306
+ * duplicate — an implicit `sync` before writing. Uses the same layout resolution as pack/sync
307
+ * (recorded alias → canonical → first match), so the selection rule stays identical everywhere.
308
+ * The project is only hashed when some target is actually absent; a reinstall with every file in
309
+ * place never scans.
310
+ */
311
+ async function adoptRenamedFiles(
312
+ planned: PlannedEntry[],
313
+ name: string,
314
+ declaredAlias: AssetDependencyAlias,
315
+ projectRoot: string,
316
+ opts: DownloadOptions,
317
+ ): Promise<Record<string, string>> {
318
+ const installable = planned.filter((entry) => declaredAlias[entry.normalizedPath] !== false)
319
+ const provisionalTargets = installable.map((entry) => {
320
+ const target = declaredAlias[entry.normalizedPath]
321
+ return typeof target === 'string' ? target : entry.normalizedPath
322
+ })
323
+ if (!(await anyPathMissing(projectRoot, provisionalTargets))) return {}
324
+
325
+ const layout = resolveDependencyLayout(
326
+ { [name]: assetDependencyValue('*', declaredAlias) },
327
+ {
328
+ files: installable.map((entry) => ({
329
+ name,
330
+ canonicalPath: entry.normalizedPath,
331
+ hash: entry.etag,
332
+ })),
333
+ fetched: new Set([name]),
334
+ },
335
+ await opts.projectHashes(),
336
+ )
337
+ const resolved = assetDependencyAlias(layout.assetDependencies[name])
338
+ return Object.fromEntries(
339
+ Object.entries(resolved).filter(
340
+ (pair): pair is [string, string] =>
341
+ typeof pair[1] === 'string' && declaredAlias[pair[0]] !== pair[1],
342
+ ),
343
+ )
344
+ }
345
+
346
+ async function anyPathMissing(projectRoot: string, targets: string[]): Promise<boolean> {
347
+ for (const target of targets) {
348
+ if (!(await isFile(path.join(projectRoot, target)))) return true
349
+ }
350
+ return false
351
+ }
352
+
353
+ /** md5 of every project file (gitignored trees excluded), computed at most once per install. */
354
+ function memoizedProjectHashes(projectRoot: string): () => Promise<Map<string, string>> {
355
+ let cached: Promise<Map<string, string>> | undefined
356
+ return () =>
357
+ (cached ??= (async () => {
358
+ const walk = await walkWithGitignore(projectRoot)
359
+ const hashes = new Map<string, string>()
360
+ for (const relative of walk.kept) {
361
+ hashes.set(
362
+ relative,
363
+ md5(new Uint8Array(await fs.readFile(path.join(projectRoot, relative)))),
364
+ )
365
+ }
366
+ return hashes
367
+ })())
368
+ }
369
+
370
+ /** Newest published versions contributing to the recognized-bytes set (same bound as pack). */
371
+ const KNOWN_HASH_VERSION_CAP = 20
372
+
373
+ function memoizedKnownHashes(client: MarketClient): (name: string) => Promise<Set<string> | null> {
374
+ const cache = new Map<string, Promise<Set<string> | null>>()
375
+ return (name) => {
376
+ const hit = cache.get(name)
377
+ if (hit) return hit
378
+ const result = (async () => {
379
+ try {
380
+ const { versions } = await client.asset.versions({ name })
381
+ const recent = versions.map((entry) => entry.version).slice(-KNOWN_HASH_VERSION_CAP)
382
+ const etags = await Promise.all(
383
+ recent.map(async (version) =>
384
+ (await client.asset.files({ name, version })).files.map((file) => file.etag),
385
+ ),
386
+ )
387
+ return new Set(etags.flat())
388
+ } catch {
389
+ // Offline or not visible — the conservative fail-open: the caller keeps skip-and-warn.
390
+ return null
391
+ }
392
+ })()
393
+ cache.set(name, result)
394
+ return result
252
395
  }
253
396
  }
254
397
 
@@ -273,8 +416,8 @@ function safeRelativePath(candidate: string, message: () => string): string {
273
416
  async function projectAssetAliases(
274
417
  projectRoot: string,
275
418
  resolution: ResolveResult,
276
- ): Promise<Record<string, Record<string, string>>> {
277
- const merged: Record<string, Record<string, string>> = {}
419
+ ): Promise<Record<string, AssetDependencyAlias>> {
420
+ const merged: Record<string, AssetDependencyAlias> = {}
278
421
  for (const [name, alias] of Object.entries(resolution.assetAliases ?? {})) {
279
422
  merged[name] = { ...alias }
280
423
  }
@@ -304,7 +447,7 @@ async function fetchAssetEntries(
304
447
  client: MarketClient,
305
448
  asset: ResolveResult['assets'][number],
306
449
  opts: DownloadOptions,
307
- ): Promise<Array<[string, Uint8Array]>> {
450
+ ): Promise<Array<{ path: string; etag: string; bytes: Uint8Array }>> {
308
451
  const index = await client.asset.files({
309
452
  name: asset.name,
310
453
  version: asset.version,
@@ -317,7 +460,7 @@ async function fetchAssetEntries(
317
460
  attempts: DOWNLOAD_ATTEMPTS,
318
461
  onRetry: (msg) => opts.log(`Downloading ${label} ${file.path}: ${msg}`),
319
462
  })
320
- return [file.path, bytes] as [string, Uint8Array]
463
+ return { path: file.path, etag: file.etag, bytes }
321
464
  })
322
465
  }
323
466
 
@@ -329,9 +472,9 @@ async function fetchVerifiedFile(file: AssetFilesResult['files'][number]): Promi
329
472
  const bytes = new Uint8Array(await res.arrayBuffer())
330
473
  // A digest mismatch is a corrupt transfer; thrown without a status so withRetry treats it as
331
474
  // transient and re-fetches.
332
- const md5 = createHash('md5').update(bytes).digest('hex')
333
- if (md5 !== file.etag) {
334
- throw new Error(`md5 mismatch for ${file.path}: expected ${file.etag}, got ${md5}`)
475
+ const digest = md5(bytes)
476
+ if (digest !== file.etag) {
477
+ throw new Error(`md5 mismatch for ${file.path}: expected ${file.etag}, got ${digest}`)
335
478
  }
336
479
  return bytes
337
480
  }
@@ -424,6 +567,7 @@ async function updatePackageJson(
424
567
  rootRequests: InstallRootRequest[]
425
568
  installMetadata: Record<string, AssetInstallMetadata>
426
569
  packageManagerNeeded: boolean
570
+ adoptedAliases: Record<string, Record<string, string>>
427
571
  },
428
572
  ): Promise<{ packageManagerNeeded: boolean }> {
429
573
  const pkgPath = path.join(projectRoot, 'package.json')
@@ -448,6 +592,9 @@ async function updatePackageJson(
448
592
  if (mergeAssetDependencyRanges(pkg, assetDependencies)) {
449
593
  changed = true
450
594
  }
595
+ if (mergeAdoptedAliases(pkg, opts.adoptedAliases)) {
596
+ changed = true
597
+ }
451
598
 
452
599
  if (changed) {
453
600
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
@@ -456,6 +603,30 @@ async function updatePackageJson(
456
603
  return { packageManagerNeeded }
457
604
  }
458
605
 
606
+ /** Record renames adopted during this install on the entries package.json already declares —
607
+ * adoption is thereby persisted, not just applied. Undeclared (transitive) deps stay out. */
608
+ function mergeAdoptedAliases(
609
+ pkg: PackageJson,
610
+ adopted: Record<string, Record<string, string>>,
611
+ ): boolean {
612
+ const current = pkg.assetDependencies
613
+ if (!current) return false
614
+
615
+ let changed = false
616
+ for (const [name, alias] of Object.entries(adopted)) {
617
+ const existing = current[name]
618
+ if (existing === undefined) continue
619
+ const next = assetDependencyValue(assetDependencyRange(existing), {
620
+ ...assetDependencyAlias(existing),
621
+ ...alias,
622
+ })
623
+ if (JSON.stringify(existing) === JSON.stringify(next)) continue
624
+ current[name] = next
625
+ changed = true
626
+ }
627
+ return changed
628
+ }
629
+
459
630
  async function runPackageManagerInstall(projectRoot: string, log: (msg: string) => void) {
460
631
  log('Installing npm dependencies...')
461
632
  const pm = await detectPackageManager(projectRoot)
package/src/output.ts CHANGED
@@ -2,7 +2,7 @@ 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
4
  import type { AssetDependencySync, PackedAsset } from './pack.js'
5
- import { assetDependencyAlias, assetDependencyRange } from './schemas.js'
5
+ import { assetDependencyAlias, assetDependencyRange, type AssetDependencyValue } from './schemas.js'
6
6
 
7
7
  export function assetVersionRef(name: string, version?: string): string {
8
8
  return version ? `${name}@${version}` : name
@@ -90,7 +90,7 @@ export function listResult(result: ListInstalledAssetsResult): string {
90
90
  for (const asset of result.assets) {
91
91
  lines.push(`- ${assetVersionRef(asset.name, asset.range)}`)
92
92
  const aliases = Object.entries(asset.alias).sort(([a], [b]) => a.localeCompare(b))
93
- lines.push(...aliases.map(([canonical, current]) => ` ${canonical} -> ${current}`))
93
+ lines.push(...aliases.map(([canonical, current]) => ` ${aliasLine(canonical, current)}`))
94
94
  }
95
95
 
96
96
  return lines.join('\n')
@@ -198,7 +198,7 @@ export function syncResult(result: AssetDependencySync): string {
198
198
  const aliases = Object.entries(assetDependencyAlias(value)).sort(([a], [b]) =>
199
199
  a.localeCompare(b),
200
200
  )
201
- lines.push(...aliases.map(([canonical, current]) => ` ${canonical} -> ${current}`))
201
+ lines.push(...aliases.map(([canonical, current]) => ` ${aliasLine(canonical, current)}`))
202
202
  }
203
203
 
204
204
  if (result.missing.length > 0) {
@@ -219,12 +219,40 @@ export function packResult(out: string, packed: PackedAsset): string {
219
219
  if (packed.omittedUnchangedInstalledFiles) {
220
220
  lines.push('Omitted unchanged installed dependency files.')
221
221
  }
222
+ lines.push(...packWarnings(packed))
222
223
  lines.push(...dependencyLines('npm dependencies', packed.npmDependencies))
223
224
  lines.push(...dependencyLines('asset dependencies', packed.assetDependencies))
224
225
  lines.push(...dependencyLines('skill dependencies', packed.skillDependencies))
225
226
  return lines.join('\n')
226
227
  }
227
228
 
229
+ /**
230
+ * What pack could not check — the silent-fail-open cases with real consequences: an unfetchable
231
+ * dependency ships its files inside the zip, and a missing dependency file means a local edit or
232
+ * deletion the packer should know about.
233
+ */
234
+ export function packWarnings(packed: PackedAsset): string[] {
235
+ const lines: string[] = []
236
+ if (packed.skippedDependencies.length > 0) {
237
+ lines.push(
238
+ `Dependency file indexes unavailable (their files ship in the zip): ${[...packed.skippedDependencies].sort().join(', ')}`,
239
+ )
240
+ }
241
+ if (packed.missingDependencyFiles.length > 0) {
242
+ lines.push('Dependency files not found (locally modified or deleted):')
243
+ lines.push(
244
+ ...packed.missingDependencyFiles.map((file) => `- ${file.name}: ${file.path}`).sort(),
245
+ )
246
+ }
247
+ return lines
248
+ }
249
+
250
+ function aliasLine(canonical: string, current: string | false): string {
251
+ return current === false
252
+ ? `${canonical} -> (tombstoned: not installed)`
253
+ : `${canonical} -> ${current}`
254
+ }
255
+
228
256
  export function previewResult(name: string, version: string, out: string): string {
229
257
  return `Saved preview for ${assetVersionRef(name, version)}: ${out}`
230
258
  }
@@ -254,10 +282,7 @@ function unique<T>(values: T[]): T[] {
254
282
  return [...new Set(values)]
255
283
  }
256
284
 
257
- function dependencyLines(
258
- label: string,
259
- deps: Record<string, string | { version: string; alias?: Record<string, string> }>,
260
- ): string[] {
285
+ function dependencyLines(label: string, deps: Record<string, AssetDependencyValue>): string[] {
261
286
  const entries = Object.entries(deps)
262
287
  if (entries.length === 0) return []
263
288
  return [
@@ -268,9 +293,7 @@ function dependencyLines(
268
293
  ]
269
294
  }
270
295
 
271
- function dependencyValueLabel(
272
- value: string | { version: string; alias?: Record<string, string> },
273
- ): string {
296
+ function dependencyValueLabel(value: AssetDependencyValue): string {
274
297
  if (typeof value === 'string') return value
275
298
  const aliased = Object.keys(value.alias ?? {}).length
276
299
  return aliased > 0 ? `${value.version} (${aliased} aliased file(s))` : value.version