@drawcall/market 0.1.98 → 0.2.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 (104) hide show
  1. package/README.md +7 -4
  2. package/dist/asset-implementation.d.ts +3 -3
  3. package/dist/asset-implementation.d.ts.map +1 -1
  4. package/dist/cli.js +15 -3
  5. package/dist/cli.js.map +1 -1
  6. package/dist/client.d.ts +14 -0
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +18 -0
  9. package/dist/client.js.map +1 -1
  10. package/dist/commands/install.js +10 -7
  11. package/dist/commands/install.js.map +1 -1
  12. package/dist/commands/list.d.ts +5 -5
  13. package/dist/commands/list.d.ts.map +1 -1
  14. package/dist/commands/list.js +22 -9
  15. package/dist/commands/list.js.map +1 -1
  16. package/dist/commands/pack.d.ts.map +1 -1
  17. package/dist/commands/pack.js +2 -2
  18. package/dist/commands/pack.js.map +1 -1
  19. package/dist/commands/search.d.ts +2 -0
  20. package/dist/commands/search.d.ts.map +1 -1
  21. package/dist/commands/search.js +14 -2
  22. package/dist/commands/search.js.map +1 -1
  23. package/dist/commands/sync.d.ts +6 -0
  24. package/dist/commands/sync.d.ts.map +1 -0
  25. package/dist/commands/sync.js +22 -0
  26. package/dist/commands/sync.js.map +1 -0
  27. package/dist/commands/upload.d.ts.map +1 -1
  28. package/dist/commands/upload.js +20 -10
  29. package/dist/commands/upload.js.map +1 -1
  30. package/dist/contract.d.ts +4 -6
  31. package/dist/contract.d.ts.map +1 -1
  32. package/dist/contract.js +1 -2
  33. package/dist/contract.js.map +1 -1
  34. package/dist/index.d.ts +5 -4
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/install.d.ts.map +1 -1
  39. package/dist/install.js +69 -27
  40. package/dist/install.js.map +1 -1
  41. package/dist/output.d.ts +3 -4
  42. package/dist/output.d.ts.map +1 -1
  43. package/dist/output.js +40 -7
  44. package/dist/output.js.map +1 -1
  45. package/dist/pack.d.ts +36 -6
  46. package/dist/pack.d.ts.map +1 -1
  47. package/dist/pack.js +230 -57
  48. package/dist/pack.js.map +1 -1
  49. package/dist/package-json.d.ts +4 -3
  50. package/dist/package-json.d.ts.map +1 -1
  51. package/dist/package-json.js +3 -1
  52. package/dist/package-json.js.map +1 -1
  53. package/dist/resolve.d.ts +8 -1
  54. package/dist/resolve.d.ts.map +1 -1
  55. package/dist/resolve.js +35 -3
  56. package/dist/resolve.js.map +1 -1
  57. package/dist/schemas.d.ts +19 -7
  58. package/dist/schemas.d.ts.map +1 -1
  59. package/dist/schemas.js +22 -7
  60. package/dist/schemas.js.map +1 -1
  61. package/dist/skill.d.ts +1 -1
  62. package/dist/skill.d.ts.map +1 -1
  63. package/dist/skill.js +4 -2
  64. package/dist/skill.js.map +1 -1
  65. package/dist/v1/client.d.ts +15 -0
  66. package/dist/v1/client.d.ts.map +1 -0
  67. package/dist/v1/client.js +18 -0
  68. package/dist/v1/client.js.map +1 -0
  69. package/dist/v1/contract.d.ts +206 -0
  70. package/dist/v1/contract.d.ts.map +1 -0
  71. package/dist/v1/contract.js +77 -0
  72. package/dist/v1/contract.js.map +1 -0
  73. package/dist/v1/index.d.ts +3 -0
  74. package/dist/v1/index.d.ts.map +1 -0
  75. package/dist/v1/index.js +3 -0
  76. package/dist/v1/index.js.map +1 -0
  77. package/package.json +2 -1
  78. package/skills/market/SKILL.md +4 -2
  79. package/src/asset-implementation.ts +3 -3
  80. package/src/cli.ts +20 -4
  81. package/src/client.ts +26 -0
  82. package/src/commands/install.ts +11 -8
  83. package/src/commands/list.ts +28 -14
  84. package/src/commands/pack.ts +7 -2
  85. package/src/commands/search.ts +21 -2
  86. package/src/commands/sync.ts +30 -0
  87. package/src/commands/upload.ts +26 -11
  88. package/src/contract.ts +0 -3
  89. package/src/index.ts +13 -3
  90. package/src/install.ts +89 -35
  91. package/src/output.ts +52 -11
  92. package/src/pack.ts +317 -70
  93. package/src/package-json.ts +7 -4
  94. package/src/resolve.ts +47 -4
  95. package/src/schemas.ts +32 -8
  96. package/src/skill.ts +4 -2
  97. package/src/v1/client.ts +28 -0
  98. package/src/v1/contract.ts +140 -0
  99. package/src/v1/index.ts +2 -0
  100. package/dist/market-lock.d.ts +0 -16
  101. package/dist/market-lock.d.ts.map +0 -1
  102. package/dist/market-lock.js +0 -48
  103. package/dist/market-lock.js.map +0 -1
  104. package/src/market-lock.ts +0 -68
package/src/pack.ts CHANGED
@@ -1,15 +1,26 @@
1
+ import { createHash } from 'crypto'
1
2
  import * as fs from 'fs/promises'
2
3
  import * as path from 'path'
3
4
  import { unzipSync, zipSync } from 'fflate'
4
5
  import ignore from 'ignore'
6
+ import type { MarketClient } from './client.js'
5
7
  import type { AssetInstallMetadata } from './contract.js'
6
8
  import { findInstallRoot } from './install.js'
7
- import { readMarketLock, sha256 } from './market-lock.js'
8
9
  import {
10
+ packageJsonAssetDependencies,
9
11
  packageJsonAssetDependenciesFromFiles,
10
12
  packageJsonNpmDependenciesFromFiles,
13
+ parsePackageJson,
11
14
  } from './package-json.js'
12
- import { MAX_UPLOAD_ZIP_SIZE_BYTES, type AssetType } from './schemas.js'
15
+ import {
16
+ MAX_UPLOAD_ZIP_SIZE_BYTES,
17
+ assetDependencyAlias,
18
+ assetDependencyRange,
19
+ assetDependencyValue,
20
+ semverSchema,
21
+ type AssetDependencies,
22
+ type AssetType,
23
+ } from './schemas.js'
13
24
 
14
25
  export interface PackDependencySpecs {
15
26
  npm?: string[]
@@ -28,16 +39,17 @@ export interface PackPolicy {
28
39
  omitUnchangedInstalledFiles: boolean
29
40
  }
30
41
 
31
- export type FetchFileManifest = (name: string, version: string) => Promise<Record<string, string>>
42
+ export type FetchFileManifest = (name: string, range: string) => Promise<Record<string, string>>
32
43
 
33
44
  export interface PackAssetOptions {
34
45
  cwd?: string
35
46
  dependencies: ParsedPackDependencies
36
47
  policy?: PackPolicy
37
48
  /**
38
- * Fetch the server's content hashes for an installed dependency version (see `asset.fileManifest`),
39
- * so a template omits its unchanged installed dependency files. Content hashes are no longer stored
40
- * locally, so omitting needs the API omit this (or a failing fetch) and those files are kept.
49
+ * Fetch the server's content hashes for a dependency's files (see `asset.fileManifest`), given
50
+ * the declared range. Pack matches local files against these hashes by content independent of
51
+ * path, so renamed installed files are omitted too and recorded as aliases. Omit this (or a
52
+ * failing fetch) and the dependency's files are kept.
41
53
  */
42
54
  fetchFileManifest?: FetchFileManifest
43
55
  }
@@ -46,13 +58,41 @@ export interface PackedAsset {
46
58
  sourcePath: string
47
59
  zip: Uint8Array
48
60
  npmDependencies: Record<string, string>
49
- assetDependencies: Record<string, string>
61
+ assetDependencies: AssetDependencies
50
62
  skillDependencies: Record<string, string>
51
63
  omittedUnchangedInstalledFiles: boolean
52
64
  /** Number of zip entries dropped because a `.gitignore` inside the zip excluded them. */
53
65
  gitignoredFiles: number
54
66
  }
55
67
 
68
+ export function sha256(bytes: Uint8Array): string {
69
+ return createHash('sha256').update(bytes).digest('hex')
70
+ }
71
+
72
+ /**
73
+ * A `FetchFileManifest` backed by the Market API. The manifest endpoint needs an exact version, so
74
+ * an exact declared range is used directly and any other range resolves to the latest version —
75
+ * without a local lockfile the installed version is not recorded, and content addressing keeps a
76
+ * mismatch safe: a stale hash simply fails to match and the file is kept.
77
+ */
78
+ export function fileManifestForRange(asset: MarketClient['asset']): FetchFileManifest {
79
+ return async (name, range) => {
80
+ const exact = exactRangeVersion(range)
81
+ const meta = await asset.exact({
82
+ name,
83
+ ...(exact ? { version: exact } : {}),
84
+ includeUnapproved: true,
85
+ })
86
+ if (!meta) return {}
87
+ return asset.fileManifest({ name, version: meta.version ?? meta.latestVersion })
88
+ }
89
+ }
90
+
91
+ function exactRangeVersion(range: string): string | null {
92
+ const candidate = range.trim().replace(/^=/u, '')
93
+ return semverSchema.safeParse(candidate).success ? candidate : null
94
+ }
95
+
56
96
  export async function packAsset(zipFilter: string, opts: PackAssetOptions): Promise<PackedAsset> {
57
97
  const cwd = opts.cwd ?? process.cwd()
58
98
 
@@ -85,17 +125,21 @@ export async function packAsset(zipFilter: string, opts: PackAssetOptions): Prom
85
125
  const npmDependencies = { ...packageJsonNpmDependencies, ...opts.dependencies.npmDependencies }
86
126
 
87
127
  // Drop anything the zip's own .gitignore excludes (node_modules, build output, secrets…), then —
88
- // for templates — omit unchanged installed dependency files. Only removals happen, so a smaller
89
- // count means something was dropped; re-zip only then.
128
+ // for templates — omit each installed dependency file at its resolved location (matched by
129
+ // content hash, so renamed copies are dropped too and recorded as aliases; duplicates ship).
130
+ // Only removals happen, so a smaller count means something was dropped; re-zip only then.
90
131
  const { kept: withoutIgnored, gitignored } = applyGitignore(sourceFiles)
91
- const files = policy.omitUnchangedInstalledFiles
92
- ? await withoutUnchangedInstalledFiles(
93
- withoutIgnored,
94
- assetDependencies,
95
- cwd,
96
- opts.fetchFileManifest,
97
- )
98
- : withoutIgnored
132
+ let files = withoutIgnored
133
+ let finalAssetDependencies = assetDependencies
134
+ if (policy.omitUnchangedInstalledFiles) {
135
+ const omitted = await withoutInstalledDependencyFiles(
136
+ withoutIgnored,
137
+ assetDependencies,
138
+ opts.fetchFileManifest,
139
+ )
140
+ files = omitted.kept
141
+ finalAssetDependencies = omitted.assetDependencies
142
+ }
99
143
 
100
144
  const originalCount = Object.keys(sourceFiles).length
101
145
  const afterIgnoreCount = Object.keys(withoutIgnored).length
@@ -105,7 +149,7 @@ export async function packAsset(zipFilter: string, opts: PackAssetOptions): Prom
105
149
  sourcePath: zipFile,
106
150
  zip: finalCount < originalCount ? zipSync(files) : sourceZip,
107
151
  npmDependencies,
108
- assetDependencies,
152
+ assetDependencies: finalAssetDependencies,
109
153
  skillDependencies: opts.dependencies.skillDependencies,
110
154
  omittedUnchangedInstalledFiles: finalCount < afterIgnoreCount,
111
155
  gitignoredFiles: gitignored,
@@ -113,8 +157,11 @@ export async function packAsset(zipFilter: string, opts: PackAssetOptions): Prom
113
157
  }
114
158
 
115
159
  // Pack straight from a project directory. Walk order matters for speed: `.gitignore` rules prune
116
- // whole subtrees before anything is read, lock-hash checks read only files a dependency owns, and
117
- // only the survivors are ever held in memory / compressed.
160
+ // whole subtrees before anything is read, and only the survivors are ever held in memory /
161
+ // compressed. Installed dependency files are matched by content hash independent of their path —
162
+ // so renamed copies are omitted too (exactly one per dependency file; duplicates ship). The
163
+ // resolved location is recorded as an alias on the dependency entry and written back to
164
+ // package.json, which is how a later install restores the renamed layout.
118
165
  async function packDirectory(dir: string, opts: PackAssetOptions): Promise<PackedAsset> {
119
166
  const hasRootPackageJson = Boolean(await maybeStat(path.join(dir, 'package.json')))
120
167
  const policy = opts.policy ?? {
@@ -143,24 +190,43 @@ async function packDirectory(dir: string, opts: PackAssetOptions): Promise<Packe
143
190
  ...opts.dependencies.npmDependencies,
144
191
  }
145
192
 
146
- // Content hashes of installed dependency files (from the server), so an unchanged one is dropped
147
- // after a single read.
148
- const hashesByPath = policy.omitUnchangedInstalledFiles
149
- ? await installedDependencyHashes(dir, assetDependencies, opts.fetchFileManifest)
150
- : new Map<string, string>()
193
+ const index = policy.omitUnchangedInstalledFiles
194
+ ? await dependencyFileIndex(assetDependencies, opts.fetchFileManifest)
195
+ : emptyDependencyFileIndex()
196
+ const dependencyHashes = new Set(index.files.map((file) => file.hash))
151
197
 
198
+ // Files whose bytes match a dependency file are deferred: whether one is omitted or ships (a
199
+ // duplicate copy) is only known once the whole tree is hashed, and deferring keeps dependency
200
+ // bytes out of memory during the walk.
152
201
  const files: Record<string, Uint8Array> = {}
153
- let omittedInstalled = 0
202
+ const hashesByPath = new Map<string, string>()
203
+ const deferred: string[] = []
154
204
  for (const relative of walk.kept) {
155
205
  const content = new Uint8Array(await fs.readFile(path.join(dir, relative)))
156
- const lockedHash = hashesByPath.get(relative)
157
- if (lockedHash && lockedHash === sha256(content)) {
158
- omittedInstalled += 1
206
+ if (dependencyHashes.size === 0) {
207
+ files[relative] = content
208
+ continue
209
+ }
210
+ const hash = sha256(content)
211
+ hashesByPath.set(relative, hash)
212
+ if (dependencyHashes.has(hash)) {
213
+ deferred.push(relative)
159
214
  continue
160
215
  }
161
216
  files[relative] = content
162
217
  }
163
218
 
219
+ const layout = resolveDependencyLayout(assetDependencies, index, hashesByPath)
220
+ for (const relative of deferred) {
221
+ if (layout.omit.has(relative)) continue
222
+ files[relative] = new Uint8Array(await fs.readFile(path.join(dir, relative)))
223
+ }
224
+
225
+ if (policy.readPackageJsonDependencies && hasRootPackageJson) {
226
+ const rewritten = await writeBackAssetDependencies(dir, layout.assetDependencies)
227
+ if (rewritten && files['package.json']) files['package.json'] = rewritten
228
+ }
229
+
164
230
  const zip = zipSync(files)
165
231
  if (zip.byteLength >= MAX_UPLOAD_ZIP_SIZE_BYTES) {
166
232
  throw new Error('Packed zip must be smaller than 1 GB')
@@ -170,9 +236,9 @@ async function packDirectory(dir: string, opts: PackAssetOptions): Promise<Packe
170
236
  sourcePath: dir,
171
237
  zip,
172
238
  npmDependencies,
173
- assetDependencies,
239
+ assetDependencies: layout.assetDependencies,
174
240
  skillDependencies: opts.dependencies.skillDependencies,
175
- omittedUnchangedInstalledFiles: omittedInstalled > 0,
241
+ omittedUnchangedInstalledFiles: layout.omit.size > 0,
176
242
  gitignoredFiles: walk.ignored,
177
243
  }
178
244
  }
@@ -220,8 +286,9 @@ function toPosix(p: string): string {
220
286
  const GITIGNORE_FILENAME = '.gitignore'
221
287
 
222
288
  // Directories that never belong in a published asset — VCS/tooling metadata, reinstallable
223
- // dependencies, and the market's own lock dir (`.drawcall/market-lock.json`). Excluded structurally
224
- // in every pack path, regardless of any `.gitignore`, so a re-uploaded asset never ships them.
289
+ // dependencies, and the market's own state dir (`.drawcall`, holding the install mutex). Excluded
290
+ // structurally in every pack path, regardless of any `.gitignore`, so a re-uploaded asset never
291
+ // ships them.
225
292
  const ALWAYS_IGNORED_DIRS = new Set(['.git', 'node_modules', '.drawcall'])
226
293
 
227
294
  function hasAlwaysIgnoredSegment(posixPath: string): boolean {
@@ -350,17 +417,21 @@ export function parseSkillDeps(specs: string[]): Record<string, string> {
350
417
  }
351
418
 
352
419
  function mergeAssetDependencies(
353
- fromPackageJson: Record<string, string>,
420
+ fromPackageJson: AssetDependencies,
354
421
  explicit: Record<string, string>,
355
- ): Record<string, string> {
422
+ ): AssetDependencies {
356
423
  const merged = { ...fromPackageJson }
357
424
  for (const [name, range] of Object.entries(explicit)) {
358
- if (name in merged && merged[name] !== range) {
425
+ const existing = merged[name]
426
+ if (existing !== undefined && assetDependencyRange(existing) !== range) {
359
427
  throw new Error(
360
- `Conflicting asset dependency "${name}": package.json has ${merged[name]}, --asset has ${range}`,
428
+ `Conflicting asset dependency "${name}": package.json has ${assetDependencyRange(
429
+ existing,
430
+ )}, --asset has ${range}`,
361
431
  )
362
432
  }
363
- merged[name] = range
433
+ // A matching package.json entry wins: it may carry aliases the flag form cannot express.
434
+ if (existing === undefined) merged[name] = range
364
435
  }
365
436
  return merged
366
437
  }
@@ -373,60 +444,236 @@ function inferPackPolicy(files: Record<string, Uint8Array>): PackPolicy {
373
444
  }
374
445
  }
375
446
 
376
- // Drop dependency files whose bytes still match the installed asset's canonical content, so a
377
- // template ships only its own (edited or new) files. Returns the surviving entries.
378
- async function withoutUnchangedInstalledFiles(
447
+ // Drop each dependency file at its resolved location (one copy per file duplicates ship as the
448
+ // user's own content) so a template ships only its own (edited or new) files. Returns the
449
+ // surviving entries plus the dependencies with freshly resolved aliases applied.
450
+ async function withoutInstalledDependencyFiles(
379
451
  files: Record<string, Uint8Array>,
380
- assetDependencies: Record<string, string>,
381
- cwd: string,
452
+ assetDependencies: AssetDependencies,
382
453
  fetchFileManifest: FetchFileManifest | undefined,
383
- ): Promise<Record<string, Uint8Array>> {
384
- const hashesByPath = await installedDependencyHashes(cwd, assetDependencies, fetchFileManifest)
385
- if (hashesByPath.size === 0) return files
454
+ ): Promise<{ kept: Record<string, Uint8Array>; assetDependencies: AssetDependencies }> {
455
+ const index = await dependencyFileIndex(assetDependencies, fetchFileManifest)
456
+ if (index.files.length === 0) return { kept: files, assetDependencies }
386
457
 
387
- const kept: Record<string, Uint8Array> = {}
458
+ const hashesByPath = new Map<string, string>()
459
+ const entryByNormalized = new Map<string, string>()
388
460
  for (const [file, content] of Object.entries(files)) {
389
461
  const normalizedPath = normalizedZipPath(file)
390
- const canonicalHash = normalizedPath ? hashesByPath.get(normalizedPath) : undefined
391
- if (canonicalHash && canonicalHash === sha256(content)) continue
392
- kept[file] = content
462
+ if (!normalizedPath || normalizedPath.endsWith('/')) continue
463
+ hashesByPath.set(normalizedPath, sha256(content))
464
+ entryByNormalized.set(normalizedPath, file)
393
465
  }
394
466
 
395
- return kept
467
+ const layout = resolveDependencyLayout(assetDependencies, index, hashesByPath)
468
+ const omittedEntries = new Set(
469
+ [...layout.omit].flatMap((omitted) => entryByNormalized.get(omitted) ?? []),
470
+ )
471
+ const kept = Object.fromEntries(
472
+ Object.entries(files).filter(([file]) => !omittedEntries.has(file)),
473
+ )
474
+ return { kept, assetDependencies: layout.assetDependencies }
475
+ }
476
+
477
+ interface DependencyFile {
478
+ name: string
479
+ canonicalPath: string
480
+ }
481
+
482
+ interface DependencyFileEntry extends DependencyFile {
483
+ /** sha256 hex of the file's canonical content, from the server manifest. */
484
+ hash: string
485
+ }
486
+
487
+ interface DependencyFileIndex {
488
+ files: DependencyFileEntry[]
489
+ /** Dependencies whose manifest was fetched — only their aliases are re-derived. */
490
+ fetched: Set<string>
491
+ }
492
+
493
+ function emptyDependencyFileIndex(): DependencyFileIndex {
494
+ return { files: [], fetched: new Set() }
396
495
  }
397
496
 
398
497
  /**
399
- * The canonical content hash of every file belonging to a declared asset dependency, keyed by install
400
- * path. Read the installed version of each dependency from the lock, then fetch that version's hashes
401
- * from the server (`asset.fileManifest`). Returns an empty map when there's nothing to omit or no way
498
+ * The canonical content hash of every file belonging to a declared asset dependency, fetched from
499
+ * the server (`asset.fileManifest`). Returns an empty index when there's nothing to omit or no way
402
500
  * to fetch (offline / no client) — pack then keeps all files rather than guessing.
403
501
  */
404
- async function installedDependencyHashes(
405
- root: string,
406
- assetDependencies: Record<string, string>,
502
+ async function dependencyFileIndex(
503
+ assetDependencies: AssetDependencies,
407
504
  fetchFileManifest: FetchFileManifest | undefined,
408
- ): Promise<Map<string, string>> {
409
- const hashesByPath = new Map<string, string>()
410
- const dependencyNames = Object.keys(assetDependencies)
411
- if (!fetchFileManifest || dependencyNames.length === 0) return hashesByPath
505
+ ): Promise<DependencyFileIndex> {
506
+ const index = emptyDependencyFileIndex()
507
+ const entries = Object.entries(assetDependencies)
508
+ if (!fetchFileManifest || entries.length === 0) return index
412
509
 
413
- const lock = await readMarketLock(await findInstallRoot(root))
414
510
  const manifests = await Promise.all(
415
- dependencyNames.map(async (name) => {
416
- const installed = lock.assets[name]
417
- if (!installed) return {}
511
+ entries.map(async ([name, value]) => {
418
512
  try {
419
- return await fetchFileManifest(name, installed.version)
513
+ return { name, manifest: await fetchFileManifest(name, assetDependencyRange(value)) }
420
514
  } catch {
421
515
  // Can't reach the server for this dependency — keep its files rather than wrongly omitting them.
422
- return {}
516
+ return null
423
517
  }
424
518
  }),
425
519
  )
426
- for (const manifest of manifests) {
427
- for (const [file, hash] of Object.entries(manifest)) hashesByPath.set(file, hash)
520
+ for (const result of manifests) {
521
+ // An empty manifest is "unavailable", not "zero files": a real version always has files, but
522
+ // the server returns {} for an unresolvable asset (deleted / private without access) or one
523
+ // too large for manifest computation. Treating it as fetched would wipe the dep's aliases.
524
+ if (!result || Object.keys(result.manifest).length === 0) continue
525
+ index.fetched.add(result.name)
526
+ for (const [file, hash] of Object.entries(result.manifest)) {
527
+ index.files.push({ name: result.name, canonicalPath: file, hash })
528
+ }
428
529
  }
429
- return hashesByPath
530
+ return index
531
+ }
532
+
533
+ interface DependencyLayout {
534
+ /** Project paths holding a dependency file — exactly one per dependency file found. */
535
+ omit: Set<string>
536
+ /** The declared dependencies with re-resolved aliases (fetched deps only; others untouched). */
537
+ assetDependencies: AssetDependencies
538
+ /** Dependency files whose canonical bytes were found nowhere (locally modified or deleted). */
539
+ missing: DependencyFile[]
540
+ }
541
+
542
+ /**
543
+ * Resolve where each dependency file lives in the project, by content hash. Exactly one project
544
+ * copy is claimed per dependency file: the already-recorded alias target if its bytes still match
545
+ * (stable across runs), else the canonical path, else the lexicographically first matching copy —
546
+ * that copy is omitted from packs and recorded as the alias; further identical copies are the
547
+ * user's own content. Stale aliases drop: a renamed file that was since modified ships as user
548
+ * content and reinstall restores the canonical path.
549
+ */
550
+ function resolveDependencyLayout(
551
+ declared: AssetDependencies,
552
+ index: DependencyFileIndex,
553
+ hashesByPath: Map<string, string>,
554
+ ): DependencyLayout {
555
+ const pathsByHash = new Map<string, string[]>()
556
+ for (const [filePath, hash] of hashesByPath) {
557
+ pathsByHash.set(hash, [...(pathsByHash.get(hash) ?? []), filePath])
558
+ }
559
+ for (const paths of pathsByHash.values()) paths.sort()
560
+
561
+ const omit = new Set<string>()
562
+ const missing: DependencyFile[] = []
563
+ const aliases: Record<string, Record<string, string>> = {}
564
+ for (const file of index.files) {
565
+ const declaredValue = declared[file.name]
566
+ const declaredAlias =
567
+ declaredValue === undefined
568
+ ? undefined
569
+ : assetDependencyAlias(declaredValue)[file.canonicalPath]
570
+ const candidates = (pathsByHash.get(file.hash) ?? []).filter((p) => !omit.has(p))
571
+ if (candidates.length === 0) {
572
+ missing.push({ name: file.name, canonicalPath: file.canonicalPath })
573
+ continue
574
+ }
575
+
576
+ const target =
577
+ declaredAlias !== undefined && candidates.includes(declaredAlias)
578
+ ? declaredAlias
579
+ : candidates.includes(file.canonicalPath)
580
+ ? file.canonicalPath
581
+ : candidates[0]
582
+ omit.add(target)
583
+ if (target !== file.canonicalPath) {
584
+ aliases[file.name] = { ...(aliases[file.name] ?? {}), [file.canonicalPath]: target }
585
+ }
586
+ }
587
+
588
+ const assetDependencies: AssetDependencies = {}
589
+ for (const [name, value] of Object.entries(declared)) {
590
+ assetDependencies[name] = index.fetched.has(name)
591
+ ? assetDependencyValue(assetDependencyRange(value), aliases[name] ?? {})
592
+ : value
593
+ }
594
+ return { omit, assetDependencies, missing }
595
+ }
596
+
597
+ export interface AssetDependencySync {
598
+ projectRoot: string
599
+ /** Whether package.json was rewritten. */
600
+ changed: boolean
601
+ assetDependencies: AssetDependencies
602
+ /** Dependency files whose canonical bytes were found nowhere (locally modified or deleted). */
603
+ missing: Array<{ name: string; path: string }>
604
+ /** Dependencies left untouched because their manifest could not be fetched. */
605
+ skipped: string[]
606
+ }
607
+
608
+ /**
609
+ * Update the aliases in package.json `assetDependencies` to match where each dependency's files
610
+ * currently live — the standalone form of what pack does before omitting (`market sync`). Renamed
611
+ * files gain an alias, files back at their canonical path lose theirs, and dependencies whose
612
+ * manifest cannot be fetched are reported as skipped rather than blindly rewritten.
613
+ */
614
+ export async function syncAssetDependencies(
615
+ cwd: string,
616
+ fetchFileManifest: FetchFileManifest,
617
+ ): Promise<AssetDependencySync> {
618
+ const projectRoot = await findInstallRoot(cwd)
619
+ const pkgPath = path.join(projectRoot, 'package.json')
620
+ if (!(await maybeStat(pkgPath))?.isFile()) {
621
+ throw new Error(`No package.json found at ${projectRoot}. Run sync inside a project.`)
622
+ }
623
+ const declared = packageJsonAssetDependencies(await fs.readFile(pkgPath, 'utf-8'), pkgPath)
624
+ const names = Object.keys(declared)
625
+ if (names.length === 0) {
626
+ return { projectRoot, changed: false, assetDependencies: {}, missing: [], skipped: [] }
627
+ }
628
+
629
+ const index = await dependencyFileIndex(declared, fetchFileManifest)
630
+ const walk = await walkWithGitignore(projectRoot)
631
+ const hashesByPath = new Map<string, string>()
632
+ for (const relative of walk.kept) {
633
+ hashesByPath.set(
634
+ relative,
635
+ sha256(new Uint8Array(await fs.readFile(path.join(projectRoot, relative)))),
636
+ )
637
+ }
638
+
639
+ const layout = resolveDependencyLayout(declared, index, hashesByPath)
640
+ const rewritten = await writeBackAssetDependencies(projectRoot, layout.assetDependencies)
641
+ return {
642
+ projectRoot,
643
+ changed: rewritten !== null,
644
+ assetDependencies: layout.assetDependencies,
645
+ missing: layout.missing.map((file) => ({ name: file.name, path: file.canonicalPath })),
646
+ skipped: names.filter((name) => !index.fetched.has(name)),
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Persist detected aliases into the project's package.json (only for dependencies it already
652
+ * declares — flag-only deps stay out of the file). Returns the serialized bytes when something
653
+ * changed so the packed zip can carry the same content.
654
+ */
655
+ async function writeBackAssetDependencies(
656
+ dir: string,
657
+ assetDependencies: AssetDependencies,
658
+ ): Promise<Uint8Array | null> {
659
+ const pkgPath = path.join(dir, 'package.json')
660
+ const pkg = parsePackageJson(await fs.readFile(pkgPath, 'utf-8'), pkgPath)
661
+ const declared = pkg.assetDependencies
662
+ if (!declared) return null
663
+
664
+ let changed = false
665
+ for (const name of Object.keys(declared)) {
666
+ const next = assetDependencies[name]
667
+ if (next === undefined) continue
668
+ if (JSON.stringify(declared[name]) === JSON.stringify(next)) continue
669
+ declared[name] = next
670
+ changed = true
671
+ }
672
+ if (!changed) return null
673
+
674
+ const serialized = JSON.stringify(pkg, null, 2) + '\n'
675
+ await fs.writeFile(pkgPath, serialized)
676
+ return new TextEncoder().encode(serialized)
430
677
  }
431
678
 
432
679
  function normalizedZipPath(file: string): string | null {
@@ -1,30 +1,33 @@
1
1
  import { z } from 'zod'
2
+ import { assetDependencyValueSchema, type AssetDependencies } from './schemas.js'
2
3
 
3
4
  export interface PackageJson {
4
5
  name?: string
5
6
  private?: boolean
6
7
  dependencies?: Record<string, string>
7
- assetDependencies?: Record<string, string>
8
+ assetDependencies?: AssetDependencies
8
9
  [key: string]: unknown
9
10
  }
10
11
 
11
12
  const stringRecordSchema = z.record(z.string(), z.string())
12
13
 
14
+ const assetDependenciesRecordSchema = z.record(z.string(), assetDependencyValueSchema)
15
+
13
16
  export function packageJsonAssetDependenciesFromFiles(
14
17
  files: Record<string, Uint8Array>,
15
- ): Record<string, string> {
18
+ ): AssetDependencies {
16
19
  const bytes = files['package.json']
17
20
  if (!bytes) return {}
18
21
 
19
22
  return packageJsonAssetDependencies(new TextDecoder().decode(bytes), 'package.json')
20
23
  }
21
24
 
22
- export function packageJsonAssetDependencies(json: string, source: string): Record<string, string> {
25
+ export function packageJsonAssetDependencies(json: string, source: string): AssetDependencies {
23
26
  const parsed = parsePackageJson(json, source)
24
27
  const dependencies = parsed.assetDependencies
25
28
  if (dependencies === undefined) return {}
26
29
 
27
- return stringRecordSchema.parse(dependencies)
30
+ return assetDependenciesRecordSchema.parse(dependencies)
28
31
  }
29
32
 
30
33
  // A template ships a full app, so its package.json `dependencies` ARE its npm dependencies: capture
package/src/resolve.ts CHANGED
@@ -12,7 +12,12 @@
12
12
  import * as semver from 'semver'
13
13
  import type { MarketClient } from './client.js'
14
14
  import type { AssetExactResult } from './contract.js'
15
- import { semverSchema } from './schemas.js'
15
+ import {
16
+ assetDependencyAlias,
17
+ assetDependencyRange,
18
+ semverSchema,
19
+ type AssetDependencies,
20
+ } from './schemas.js'
16
21
 
17
22
  export interface ResolvedAsset {
18
23
  name: string
@@ -20,7 +25,7 @@ export interface ResolvedAsset {
20
25
  version: string
21
26
  description: string | null
22
27
  npmDependencies: Record<string, string>
23
- assetDependencies: Record<string, string>
28
+ assetDependencies: AssetDependencies
24
29
  skillDependencies: Record<string, string>
25
30
  }
26
31
 
@@ -28,6 +33,12 @@ export interface ResolveResult {
28
33
  assets: ResolvedAsset[]
29
34
  npmDependencies: Record<string, string>
30
35
  skillDependencies: Record<string, string>
36
+ /**
37
+ * File aliases declared by dependents, keyed by dependency name: canonical install path → the
38
+ * path the dependent's project keeps the file at. Install writes files through this map (a
39
+ * template whose author renamed a dependency's files reinstalls them at the renamed locations).
40
+ */
41
+ assetAliases?: Record<string, Record<string, string>>
31
42
  }
32
43
 
33
44
  interface AssetRequest {
@@ -96,6 +107,7 @@ export async function resolve(
96
107
  assets,
97
108
  npmDependencies: mergeNpmDependencies(assets),
98
109
  skillDependencies: mergeSkillDependencies(assets),
110
+ assetAliases: mergeAssetAliases(assets),
99
111
  }
100
112
  }
101
113
 
@@ -141,7 +153,7 @@ async function resolvePass(
141
153
  const selectedVersion = exactVersion
142
154
  ? exactMetadataVersion(meta, assetName, exactVersion)
143
155
  : latestMetadataVersion(meta)
144
- const assetDependencies = parseDependencies(meta.assetDependencies, assetName, 'asset')
156
+ const assetDependencies = parseAssetDependencies(meta.assetDependencies, assetName)
145
157
  resolved.set(assetName, {
146
158
  name: assetName,
147
159
  type: meta.type,
@@ -152,7 +164,8 @@ async function resolvePass(
152
164
  skillDependencies: parseDependencies(meta.skillDependencies, assetName, 'skill'),
153
165
  })
154
166
 
155
- for (const [dependencyName, dependencyRange] of Object.entries(assetDependencies)) {
167
+ for (const [dependencyName, dependencyValue] of Object.entries(assetDependencies)) {
168
+ const dependencyRange = assetDependencyRange(dependencyValue)
156
169
  addConstraint(constraints, dependencyName, dependencyRange, `${assetName}@${selectedVersion}`)
157
170
  pending.push(dependencyName)
158
171
  }
@@ -203,6 +216,36 @@ function mergeSkillDependencies(assets: ResolvedAsset[]): Record<string, string>
203
216
  return merged
204
217
  }
205
218
 
219
+ /**
220
+ * Merge file aliases from every resolved asset, keyed by the dependency they apply to. Two
221
+ * dependents rarely alias the same file; when they do, the first resolved asset wins — a
222
+ * deterministic choice that keeps installs reproducible.
223
+ */
224
+ function mergeAssetAliases(assets: ResolvedAsset[]): Record<string, Record<string, string>> {
225
+ const merged: Record<string, Record<string, string>> = {}
226
+ for (const asset of assets) {
227
+ for (const [dependencyName, value] of Object.entries(asset.assetDependencies)) {
228
+ const alias = assetDependencyAlias(value)
229
+ if (Object.keys(alias).length === 0) continue
230
+ merged[dependencyName] = { ...alias, ...(merged[dependencyName] ?? {}) }
231
+ }
232
+ }
233
+ return merged
234
+ }
235
+
236
+ /** Like parseDependencies, but asset dependency values may also be `{ version, alias }` objects. */
237
+ function parseAssetDependencies(value: string, assetName: string): AssetDependencies {
238
+ try {
239
+ const parsed = JSON.parse(value || '{}') as unknown
240
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
241
+ return parsed as AssetDependencies
242
+ }
243
+ } catch {
244
+ // fall through to a clear error below
245
+ }
246
+ throw new ResolutionError(`Asset "${assetName}" has malformed asset dependency metadata.`)
247
+ }
248
+
206
249
  function parseDependencies(
207
250
  value: string,
208
251
  assetName: string,