@drawcall/market 0.1.99 → 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 (95) hide show
  1. package/dist/asset-implementation.d.ts +2 -2
  2. package/dist/asset-implementation.d.ts.map +1 -1
  3. package/dist/cli.js +15 -3
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/install.js +10 -7
  6. package/dist/commands/install.js.map +1 -1
  7. package/dist/commands/list.d.ts +5 -5
  8. package/dist/commands/list.d.ts.map +1 -1
  9. package/dist/commands/list.js +22 -9
  10. package/dist/commands/list.js.map +1 -1
  11. package/dist/commands/pack.d.ts.map +1 -1
  12. package/dist/commands/pack.js +2 -2
  13. package/dist/commands/pack.js.map +1 -1
  14. package/dist/commands/search.d.ts +2 -0
  15. package/dist/commands/search.d.ts.map +1 -1
  16. package/dist/commands/search.js +14 -2
  17. package/dist/commands/search.js.map +1 -1
  18. package/dist/commands/sync.d.ts +6 -0
  19. package/dist/commands/sync.d.ts.map +1 -0
  20. package/dist/commands/sync.js +22 -0
  21. package/dist/commands/sync.js.map +1 -0
  22. package/dist/commands/upload.d.ts.map +1 -1
  23. package/dist/commands/upload.js +12 -7
  24. package/dist/commands/upload.js.map +1 -1
  25. package/dist/contract.d.ts +4 -1
  26. package/dist/contract.d.ts.map +1 -1
  27. package/dist/index.d.ts +3 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/install.d.ts.map +1 -1
  32. package/dist/install.js +69 -27
  33. package/dist/install.js.map +1 -1
  34. package/dist/output.d.ts +3 -4
  35. package/dist/output.d.ts.map +1 -1
  36. package/dist/output.js +40 -7
  37. package/dist/output.js.map +1 -1
  38. package/dist/pack.d.ts +36 -6
  39. package/dist/pack.d.ts.map +1 -1
  40. package/dist/pack.js +230 -57
  41. package/dist/pack.js.map +1 -1
  42. package/dist/package-json.d.ts +4 -3
  43. package/dist/package-json.d.ts.map +1 -1
  44. package/dist/package-json.js +3 -1
  45. package/dist/package-json.js.map +1 -1
  46. package/dist/resolve.d.ts +8 -1
  47. package/dist/resolve.d.ts.map +1 -1
  48. package/dist/resolve.js +35 -3
  49. package/dist/resolve.js.map +1 -1
  50. package/dist/schemas.d.ts +19 -2
  51. package/dist/schemas.d.ts.map +1 -1
  52. package/dist/schemas.js +21 -1
  53. package/dist/schemas.js.map +1 -1
  54. package/dist/skill.d.ts +1 -1
  55. package/dist/skill.d.ts.map +1 -1
  56. package/dist/skill.js +4 -2
  57. package/dist/skill.js.map +1 -1
  58. package/dist/v1/client.d.ts +15 -0
  59. package/dist/v1/client.d.ts.map +1 -0
  60. package/dist/v1/client.js +18 -0
  61. package/dist/v1/client.js.map +1 -0
  62. package/dist/v1/contract.d.ts +206 -0
  63. package/dist/v1/contract.d.ts.map +1 -0
  64. package/dist/v1/contract.js +77 -0
  65. package/dist/v1/contract.js.map +1 -0
  66. package/dist/v1/index.d.ts +3 -0
  67. package/dist/v1/index.d.ts.map +1 -0
  68. package/dist/v1/index.js +3 -0
  69. package/dist/v1/index.js.map +1 -0
  70. package/package.json +2 -1
  71. package/skills/market/SKILL.md +4 -2
  72. package/src/asset-implementation.ts +2 -2
  73. package/src/cli.ts +20 -4
  74. package/src/commands/install.ts +11 -8
  75. package/src/commands/list.ts +28 -14
  76. package/src/commands/pack.ts +7 -2
  77. package/src/commands/search.ts +21 -2
  78. package/src/commands/sync.ts +30 -0
  79. package/src/commands/upload.ts +18 -8
  80. package/src/index.ts +11 -0
  81. package/src/install.ts +89 -35
  82. package/src/output.ts +52 -11
  83. package/src/pack.ts +317 -70
  84. package/src/package-json.ts +7 -4
  85. package/src/resolve.ts +47 -4
  86. package/src/schemas.ts +31 -1
  87. package/src/skill.ts +4 -2
  88. package/src/v1/client.ts +28 -0
  89. package/src/v1/contract.ts +140 -0
  90. package/src/v1/index.ts +2 -0
  91. package/dist/market-lock.d.ts +0 -16
  92. package/dist/market-lock.d.ts.map +0 -1
  93. package/dist/market-lock.js +0 -48
  94. package/dist/market-lock.js.map +0 -1
  95. package/src/market-lock.ts +0 -68
@@ -31,7 +31,9 @@ Install exact refs together in one call; names need no `--type`:
31
31
 
32
32
  Install prints the exact files it wrote and records the dependency so packing and later rebuilds can restore it. It never searches or generates. With no refs it installs `assetDependencies` from the nearest `package.json`. Assets may install declared skill dependencies with `npx skills add`. Use `--force` only with permission to overwrite changed local files.
33
33
 
34
- `list --cwd "$PWD"` is an offline, compact inventory from `.drawcall/market-lock.json`. Add `--files` when exact paths for previously installed assets are needed; fresh install and generation already print their paths. When selected refs are already installed, read this inventory instead of reinstalling them.
34
+ `list --cwd "$PWD"` is an offline, compact inventory of the `assetDependencies` in the nearest `package.json`, including file aliases. When selected refs are already installed, read this inventory instead of reinstalling them.
35
+
36
+ An `assetDependencies` entry is either a range string or `{ "version": "<range>", "alias": { "<canonical path>": "<current path>" } }`. An alias records where the project keeps a renamed installed file: install writes the file at the aliased path, and packing omits exactly that one copy (extra duplicates ship as project content). Run `sync --cwd "$PWD"` after renaming installed files to record aliases in `package.json`; template packing runs the same update automatically.
35
37
 
36
38
  ## Generate
37
39
 
@@ -47,6 +49,6 @@ Add `--access public` or `--access private` when visibility matters. Omitted acc
47
49
 
48
50
  ## Publish only when asked
49
51
 
50
- Use `pack <source> --out <zip>` to create a Market zip and `upload <name> <source> "<description>" --type <type>` to publish it. Both accept repeatable `--npm name@range`, `--asset name@range`, and `--skill label=source` dependencies. Template packing reads root `assetDependencies` and may use the API to omit unchanged installed dependency files. A skill source is `owner/repo`, a git URL, a full GitHub `tree/<branch>/<subpath>` URL, or a local skill directory inside the zip. Use the command's `--help` for syntax.
52
+ Use `pack <source> --out <zip>` to create a Market zip and `upload <name> <source> "<description>" --type <type>` to publish it. Both accept repeatable `--npm name@range`, `--asset name@range`, and `--skill label=source` dependencies. Template packing reads root `assetDependencies` and may use the API to omit installed dependency files matched by content hash — even when renamed — recording renames as aliases. A skill source is `owner/repo`, a git URL, a full GitHub `tree/<branch>/<subpath>` URL, or a local skill directory inside the zip. Use the command's `--help` for syntax.
51
53
 
52
54
  Use `--unapproved` only when explicitly requested. Do not install an unapproved asset without acceptance. If login is required, ask before running `npx @drawcall/market login`.
@@ -6,7 +6,7 @@ import type {
6
6
  GenerateAssetResult,
7
7
  PaginatedList,
8
8
  } from './contract.js'
9
- import type { AssetAccess, AssetType, GenerateJobStatus } from './schemas.js'
9
+ import type { AssetAccess, AssetDependencies, AssetType, GenerateJobStatus } from './schemas.js'
10
10
 
11
11
  export const ASSET_RELEVANCE_SCORE: unique symbol = Symbol('assetRelevanceScore')
12
12
 
@@ -38,7 +38,7 @@ export interface AssetUploadZipInput {
38
38
  version: string
39
39
  description?: string
40
40
  npmDependencies: Record<string, string>
41
- assetDependencies: Record<string, string>
41
+ assetDependencies: AssetDependencies
42
42
  skillDependencies: Record<string, string>
43
43
  tags: string[]
44
44
  zip: File
package/src/cli.ts CHANGED
@@ -13,6 +13,7 @@ import { generateCommand } from './commands/generate.js'
13
13
  import { generateInstallCommand } from './commands/generate-install.js'
14
14
  import { listCommand } from './commands/list.js'
15
15
  import { packCommand } from './commands/pack.js'
16
+ import { syncCommand } from './commands/sync.js'
16
17
  import { previewCommand } from './commands/preview.js'
17
18
  import { uploadCommand } from './commands/upload.js'
18
19
  import { typesCommand } from './commands/types.js'
@@ -125,13 +126,25 @@ program
125
126
 
126
127
  program
127
128
  .command('list')
128
- .description('List locally installed assets')
129
- .option('--files', 'Include installed file paths', false)
129
+ .description('List asset dependencies declared in package.json, including file aliases')
130
130
  .option('--cwd <dir>', 'Project directory')
131
- .action(async (opts: { cwd?: string; files: boolean }) => {
131
+ .action(async (opts: { cwd?: string }) => {
132
132
  await listCommand({
133
133
  cwd: opts.cwd,
134
- files: opts.files,
134
+ })
135
+ })
136
+
137
+ program
138
+ .command('sync')
139
+ .description(
140
+ 'Update package.json assetDependencies aliases to match where installed files live now',
141
+ )
142
+ .addOption(apiOption)
143
+ .option('--cwd <dir>', 'Project directory')
144
+ .action(async (opts: { api?: string; cwd?: string }) => {
145
+ await syncCommand({
146
+ baseUrl: opts.api,
147
+ cwd: opts.cwd,
135
148
  })
136
149
  })
137
150
 
@@ -176,6 +189,7 @@ program
176
189
  .addOption(apiOption)
177
190
  .option('--unapproved', 'Include unapproved versions', false)
178
191
  .option('--limit <n>', 'Max results, 1-5', parseSearchLimit, 5)
192
+ .option('--json', 'Emit a machine-readable JSON array instead of the summary lines', false)
179
193
  .action(
180
194
  async (
181
195
  query: string,
@@ -184,6 +198,7 @@ program
184
198
  api?: string
185
199
  unapproved: boolean
186
200
  limit: number
201
+ json: boolean
187
202
  },
188
203
  ) => {
189
204
  requireType(opts.type, 'Search')
@@ -192,6 +207,7 @@ program
192
207
  baseUrl: opts.api,
193
208
  unapproved: opts.unapproved,
194
209
  limit: opts.limit,
210
+ json: opts.json,
195
211
  })
196
212
  },
197
213
  )
@@ -4,7 +4,7 @@ import ora from 'ora'
4
4
  import { resolve } from '../resolve.js'
5
5
  import { findInstallRoot, install as runInstall, type InstallRootRequest } from '../install.js'
6
6
  import { packageJsonAssetDependencies } from '../package-json.js'
7
- import { assetNameSchema } from '../schemas.js'
7
+ import { assetDependencyRange, assetNameSchema, type AssetDependencies } from '../schemas.js'
8
8
  import { getCliClient } from '../cli-client.js'
9
9
  import { installResult } from '../output.js'
10
10
  import type { MarketClient } from '../client.js'
@@ -125,12 +125,15 @@ async function packageJsonRequests(cwd: string | undefined): Promise<AssetReques
125
125
  const installRoot = await findInstallRoot(cwd ?? process.cwd())
126
126
  const pkgPath = path.join(installRoot, 'package.json')
127
127
  const dependencies = await readPackageJsonAssetDependencies(pkgPath)
128
- const requests = Object.entries(dependencies).map(([name, range]) => ({
129
- name,
130
- range,
131
- saveRange: range,
132
- save: false,
133
- }))
128
+ const requests = Object.entries(dependencies).map(([name, value]) => {
129
+ const range = assetDependencyRange(value)
130
+ return {
131
+ name,
132
+ range,
133
+ saveRange: range,
134
+ save: false,
135
+ }
136
+ })
134
137
 
135
138
  if (requests.length === 0) {
136
139
  throw new Error(
@@ -141,7 +144,7 @@ async function packageJsonRequests(cwd: string | undefined): Promise<AssetReques
141
144
  return requests
142
145
  }
143
146
 
144
- async function readPackageJsonAssetDependencies(pkgPath: string): Promise<Record<string, string>> {
147
+ async function readPackageJsonAssetDependencies(pkgPath: string): Promise<AssetDependencies> {
145
148
  try {
146
149
  return packageJsonAssetDependencies(await fs.readFile(pkgPath, 'utf-8'), pkgPath)
147
150
  } catch (error) {
@@ -1,48 +1,62 @@
1
+ import * as fs from 'fs/promises'
1
2
  import * as path from 'path'
2
3
  import { findInstallRoot } from '../install.js'
3
- import { MARKET_LOCK_PATH, readMarketLock } from '../market-lock.js'
4
+ import { packageJsonAssetDependencies } from '../package-json.js'
5
+ import { assetDependencyAlias, assetDependencyRange } from '../schemas.js'
4
6
  import { listResult } from '../output.js'
5
7
 
6
8
  export interface ListCommandOptions {
7
9
  cwd?: string
8
- files?: boolean
9
10
  }
10
11
 
11
12
  export interface InstalledAssetListing {
12
13
  name: string
13
- version: string
14
- type: string
15
- files: string[]
14
+ range: string
15
+ /** Canonical install path → the project's current path, for files renamed after install. */
16
+ alias: Record<string, string>
16
17
  }
17
18
 
18
19
  export interface ListInstalledAssetsResult {
19
20
  projectRoot: string
20
- lockPath: string
21
+ packageJsonPath: string
21
22
  assets: InstalledAssetListing[]
22
23
  }
23
24
 
24
25
  export async function listCommand(opts: ListCommandOptions): Promise<void> {
25
26
  const result = await listInstalledAssets(opts.cwd)
26
- console.log(listResult(result, { files: opts.files }))
27
+ console.log(listResult(result))
27
28
  }
28
29
 
30
+ /** The project's declared asset dependencies — package.json is the single source of truth. */
29
31
  export async function listInstalledAssets(
30
32
  cwd: string | undefined,
31
33
  ): Promise<ListInstalledAssetsResult> {
32
34
  const projectRoot = await findInstallRoot(cwd ?? process.cwd())
33
- const lock = await readMarketLock(projectRoot)
34
- const assets = Object.entries(lock.assets)
35
- .map(([name, asset]) => ({
35
+ const packageJsonPath = path.join(projectRoot, 'package.json')
36
+ const dependencies = await readAssetDependencies(packageJsonPath)
37
+ const assets = Object.entries(dependencies)
38
+ .map(([name, value]) => ({
36
39
  name,
37
- version: asset.version,
38
- type: asset.type,
39
- files: [...asset.files].sort((a, b) => a.localeCompare(b)),
40
+ range: assetDependencyRange(value),
41
+ alias: assetDependencyAlias(value),
40
42
  }))
41
43
  .sort((a, b) => a.name.localeCompare(b.name))
42
44
 
43
45
  return {
44
46
  projectRoot,
45
- lockPath: path.join(projectRoot, MARKET_LOCK_PATH),
47
+ packageJsonPath,
46
48
  assets,
47
49
  }
48
50
  }
51
+
52
+ async function readAssetDependencies(packageJsonPath: string) {
53
+ try {
54
+ return packageJsonAssetDependencies(
55
+ await fs.readFile(packageJsonPath, 'utf-8'),
56
+ packageJsonPath,
57
+ )
58
+ } catch (error) {
59
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return {}
60
+ throw error
61
+ }
62
+ }
@@ -2,7 +2,12 @@ import * as fs from 'fs/promises'
2
2
  import * as path from 'path'
3
3
  import ora from 'ora'
4
4
  import { getCliClient } from '../cli-client.js'
5
- import { packAsset, packPolicyForType, parsePackDependencies } from '../pack.js'
5
+ import {
6
+ fileManifestForRange,
7
+ packAsset,
8
+ packPolicyForType,
9
+ parsePackDependencies,
10
+ } from '../pack.js'
6
11
  import { packResult } from '../output.js'
7
12
  import type { AssetType } from '../schemas.js'
8
13
 
@@ -39,7 +44,7 @@ export async function packCommand(zipFilter: string, opts: PackCommandOptions):
39
44
  skill: opts.skill,
40
45
  }),
41
46
  policy: packPolicyForType(opts.type),
42
- fetchFileManifest: (name, version) => client.asset.fileManifest({ name, version }),
47
+ fetchFileManifest: fileManifestForRange(client.asset),
43
48
  })
44
49
  const out = opts.out ? path.resolve(cwd, opts.out) : defaultPackPath(packed.sourcePath)
45
50
 
@@ -10,6 +10,8 @@ export interface SearchCommandOptions {
10
10
  unapproved?: boolean
11
11
  baseUrl?: string
12
12
  limit?: number
13
+ /** Emit a machine-readable JSON array instead of the human-readable lines. */
14
+ json?: boolean
13
15
  }
14
16
 
15
17
  export async function searchCommand(query: string, opts: SearchCommandOptions): Promise<void> {
@@ -17,8 +19,8 @@ export async function searchCommand(query: string, opts: SearchCommandOptions):
17
19
 
18
20
  const spinner = ora({
19
21
  text: `Searching "${query}"`,
20
- isEnabled: Boolean(process.stderr.isTTY),
21
- isSilent: !process.stderr.isTTY,
22
+ isEnabled: Boolean(process.stderr.isTTY) && !opts.json,
23
+ isSilent: !process.stderr.isTTY || Boolean(opts.json),
22
24
  }).start()
23
25
  const results = await client.asset
24
26
  .search({
@@ -31,6 +33,23 @@ export async function searchCommand(query: string, opts: SearchCommandOptions):
31
33
  })
32
34
  .finally(() => spinner.stop())
33
35
 
36
+ if (opts.json) {
37
+ // Stable machine-readable shape for scripts; the human-readable lines below may change freely.
38
+ console.log(
39
+ JSON.stringify(
40
+ results.items.map((item) => ({
41
+ name: item.name,
42
+ version: item.latestVersion,
43
+ type: item.type,
44
+ approved: item.approved,
45
+ access: item.access,
46
+ description: item.description,
47
+ })),
48
+ ),
49
+ )
50
+ return
51
+ }
52
+
34
53
  console.log(
35
54
  searchSummary({
36
55
  query,
@@ -0,0 +1,30 @@
1
+ import ora from 'ora'
2
+ import { getCliClient } from '../cli-client.js'
3
+ import { fileManifestForRange, syncAssetDependencies } from '../pack.js'
4
+ import { syncResult } from '../output.js'
5
+
6
+ export interface SyncCommandOptions {
7
+ cwd?: string
8
+ baseUrl?: string
9
+ }
10
+
11
+ export async function syncCommand(opts: SyncCommandOptions): Promise<void> {
12
+ const spinner = ora({
13
+ text: 'Syncing assetDependencies',
14
+ isEnabled: Boolean(process.stderr.isTTY),
15
+ isSilent: !process.stderr.isTTY,
16
+ }).start()
17
+
18
+ try {
19
+ const { client } = await getCliClient({ baseUrl: opts.baseUrl })
20
+ const result = await syncAssetDependencies(
21
+ opts.cwd ?? process.cwd(),
22
+ fileManifestForRange(client.asset),
23
+ )
24
+ spinner.stop()
25
+ console.log(syncResult(result))
26
+ } catch (err) {
27
+ spinner.stop()
28
+ throw err
29
+ }
30
+ }
@@ -2,7 +2,12 @@ import ora from 'ora'
2
2
  import semver from 'semver'
3
3
  import { downloadAssetZipBytes } from '../client.js'
4
4
  import { getCliClient } from '../cli-client.js'
5
- import { packAsset, packPolicyFromInstallMetadata, parsePackDependencies } from '../pack.js'
5
+ import {
6
+ fileManifestForRange,
7
+ packAsset,
8
+ packPolicyFromInstallMetadata,
9
+ parsePackDependencies,
10
+ } from '../pack.js'
6
11
  import { unchangedUploadResult, uploadResult } from '../output.js'
7
12
  import {
8
13
  assetDescriptionSchema,
@@ -61,7 +66,7 @@ export async function uploadCommand(
61
66
  cwd,
62
67
  dependencies,
63
68
  policy: packPolicyFromInstallMetadata(installMetadata[type]),
64
- fetchFileManifest: (name, version) => client.asset.fileManifest({ name, version }),
69
+ fetchFileManifest: fileManifestForRange(client.asset),
65
70
  })
66
71
  const { zip, npmDependencies, assetDependencies, skillDependencies } = packed
67
72
  const profile = await client.user.getProfile()
@@ -133,16 +138,21 @@ export function parseUploadDescription(description: string): string {
133
138
  }
134
139
  }
135
140
 
136
- /** Compare a stored dependency JSON string against a freshly parsed record. */
137
- function depsEqual(storedJson: string, next: Record<string, string>): boolean {
138
- let stored: Record<string, string>
141
+ /** Compare a stored dependency JSON string against a freshly parsed record. Values may be nested
142
+ * (asset dependencies carry `{ version, alias }`), so equality is structural with sorted keys. */
143
+ function depsEqual(storedJson: string, next: Record<string, unknown>): boolean {
139
144
  try {
140
- stored = JSON.parse(storedJson || '{}') as Record<string, string>
145
+ return stableJson(JSON.parse(storedJson || '{}')) === stableJson(next)
141
146
  } catch {
142
147
  return false
143
148
  }
144
- const keys = Object.keys(stored)
145
- return keys.length === Object.keys(next).length && keys.every((key) => stored[key] === next[key])
149
+ }
150
+
151
+ function stableJson(value: unknown): string {
152
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return JSON.stringify(value)
153
+ const record = value as Record<string, unknown>
154
+ const keys = Object.keys(record).sort()
155
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`
146
156
  }
147
157
 
148
158
  function nextVersion(latest?: string): string {
package/src/index.ts CHANGED
@@ -1,3 +1,7 @@
1
+ // The v1 REST surface (`/api/v1`): routed contract + typed client. Prefer this; the legacy RPC
2
+ // client below is deprecated and retires together with `/api/rpc`.
3
+ export * as v1 from './v1/index.js'
4
+
1
5
  // Client
2
6
  export { createMarketClient, downloadAssetZipBytes } from './client.js'
3
7
  export type { MarketClient, MarketClientOptions } from './client.js'
@@ -62,6 +66,11 @@ export {
62
66
  assetAccessSchema,
63
67
  npmDependenciesSchema,
64
68
  assetDependenciesSchema,
69
+ assetDependencyAliasSchema,
70
+ assetDependencyValueSchema,
71
+ assetDependencyRange,
72
+ assetDependencyAlias,
73
+ assetDependencyValue,
65
74
  skillDependenciesSchema,
66
75
  uploadZipSchema,
67
76
  exactAssetSchema,
@@ -82,6 +91,8 @@ export {
82
91
  export type {
83
92
  AssetType,
84
93
  AssetAccess,
94
+ AssetDependencies,
95
+ AssetDependencyValue,
85
96
  AssetFileManifest,
86
97
  AgentResultAsset,
87
98
  AgentResult,
package/src/install.ts CHANGED
@@ -18,9 +18,9 @@ import { detectPackageManager, installDependencies } from 'nypm'
18
18
  import type { MarketClient } from './client.js'
19
19
  import type { AssetFilesResult, AssetInstallMetadata } from './contract.js'
20
20
  import { withProjectInstallLock } from './install-lock.js'
21
- import { readMarketLock, writeMarketLock } from './market-lock.js'
22
- import { parsePackageJson, type PackageJson } from './package-json.js'
21
+ import { packageJsonAssetDependencies, parsePackageJson, type PackageJson } from './package-json.js'
23
22
  import type { ResolveResult } from './resolve.js'
23
+ import { assetDependencyAlias, assetDependencyRange, assetDependencyValue } from './schemas.js'
24
24
 
25
25
  export interface InstallOptions {
26
26
  /** Directory to start project root discovery from (default: cwd) */
@@ -78,6 +78,8 @@ interface DownloadOptions {
78
78
  force: boolean
79
79
  log: (message: string) => void
80
80
  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>>
81
83
  }
82
84
 
83
85
  export async function install(
@@ -89,15 +91,16 @@ export async function install(
89
91
  const installRoot = await findInstallRoot(opts.cwd ?? process.cwd())
90
92
  const metadata = opts.installMetadata ?? {}
91
93
 
94
+ const aliases = await projectAssetAliases(installRoot, resolution)
92
95
  const download = await downloadAssets(client, resolution, installRoot, {
93
96
  force: opts.force ?? false,
94
97
  log,
95
98
  key: opts.key,
99
+ aliases,
96
100
  })
97
101
  const integration = await withProjectInstallLock(
98
102
  installRoot,
99
103
  async () => {
100
- await writeInstalledAssetLock(installRoot, download.assets)
101
104
  const packageJsonUpdate = await updatePackageJson(resolution, installRoot, {
102
105
  rootRequests: opts.rootRequests ?? [],
103
106
  installMetadata: metadata,
@@ -186,21 +189,16 @@ async function downloadOneAsset(
186
189
  // Only the network fetches are retried — downloads are the sole transient step; file writes are
187
190
  // deterministic local work and a failure there is a real error, not worth re-attempting.
188
191
  const files = await fetchAssetEntries(client, asset, opts)
192
+ const alias = opts.aliases[asset.name] ?? {}
189
193
  const installedFiles: string[] = []
190
194
  const warnings: string[] = []
191
195
  let wrotePackageJson = false
192
196
 
193
197
  for (const [relativePath, content] of files) {
194
- const zipPath = relativePath.replace(/\\/g, '/')
195
- if (
196
- zipPath.split('/').includes('..') ||
197
- path.posix.isAbsolute(zipPath) ||
198
- path.win32.isAbsolute(zipPath)
199
- ) {
200
- throw new Error(`Zip contains an unsafe path: ${relativePath}`)
201
- }
202
-
203
- const normalizedPath = path.posix.normalize(zipPath)
198
+ const normalizedPath = safeRelativePath(
199
+ relativePath,
200
+ () => `Zip contains an unsafe path: ${relativePath}`,
201
+ )
204
202
  if (
205
203
  normalizedPath === '.' ||
206
204
  (normalizedPath === 'README.md' && asset.type !== 'template') ||
@@ -209,11 +207,22 @@ async function downloadOneAsset(
209
207
  continue
210
208
  }
211
209
 
212
- const filePath = path.join(projectRoot, normalizedPath)
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]
213
+ const targetPath =
214
+ aliased === undefined
215
+ ? normalizedPath
216
+ : safeRelativePath(
217
+ aliased,
218
+ () => `Alias for ${normalizedPath} in ${asset.name} is an unsafe path: ${aliased}`,
219
+ )
220
+
221
+ const filePath = path.join(projectRoot, targetPath)
213
222
  const existing = await maybeReadFile(filePath)
214
223
  if (existing && !bytesEqual(existing, content) && !opts.force) {
215
224
  warnings.push(
216
- `Skipped ${normalizedPath} from ${asset.name}@${asset.version}; file already exists. Re-run with --force to overwrite.`,
225
+ `Skipped ${targetPath} from ${asset.name}@${asset.version}; file already exists. Re-run with --force to overwrite.`,
217
226
  )
218
227
  continue
219
228
  }
@@ -221,12 +230,12 @@ async function downloadOneAsset(
221
230
  if (!existing || !bytesEqual(existing, content)) {
222
231
  await fs.mkdir(path.dirname(filePath), { recursive: true })
223
232
  await fs.writeFile(filePath, content)
224
- if (normalizedPath === 'package.json') {
233
+ if (targetPath === 'package.json') {
225
234
  wrotePackageJson = true
226
235
  }
227
236
  }
228
237
 
229
- installedFiles.push(normalizedPath)
238
+ installedFiles.push(targetPath)
230
239
  }
231
240
 
232
241
  opts.log(`Downloaded ${installedFiles.length} files from ${asset.name}@${asset.version}.`)
@@ -243,6 +252,46 @@ async function downloadOneAsset(
243
252
  }
244
253
  }
245
254
 
255
+ /** Validate + normalize a posix-relative install path; throws `message()` on traversal/absolute. */
256
+ function safeRelativePath(candidate: string, message: () => string): string {
257
+ const posixPath = candidate.replace(/\\/g, '/')
258
+ if (
259
+ posixPath.split('/').includes('..') ||
260
+ path.posix.isAbsolute(posixPath) ||
261
+ path.win32.isAbsolute(posixPath)
262
+ ) {
263
+ throw new Error(message())
264
+ }
265
+ return path.posix.normalize(posixPath)
266
+ }
267
+
268
+ /**
269
+ * File aliases per asset, merged from the project's own package.json `assetDependencies` and the
270
+ * aliases dependents declared in their metadata. The project's entries win: the user's local
271
+ * layout beats what a template recorded at publish time.
272
+ */
273
+ async function projectAssetAliases(
274
+ projectRoot: string,
275
+ resolution: ResolveResult,
276
+ ): Promise<Record<string, Record<string, string>>> {
277
+ const merged: Record<string, Record<string, string>> = {}
278
+ for (const [name, alias] of Object.entries(resolution.assetAliases ?? {})) {
279
+ merged[name] = { ...alias }
280
+ }
281
+
282
+ const pkgPath = path.join(projectRoot, 'package.json')
283
+ const pkgJson = await maybeReadFile(pkgPath)
284
+ if (!pkgJson) return merged
285
+
286
+ const declared = packageJsonAssetDependencies(new TextDecoder().decode(pkgJson), pkgPath)
287
+ for (const [name, value] of Object.entries(declared)) {
288
+ const alias = assetDependencyAlias(value)
289
+ if (Object.keys(alias).length === 0) continue
290
+ merged[name] = { ...(merged[name] ?? {}), ...alias }
291
+ }
292
+ return merged
293
+ }
294
+
246
295
  /** Files fetched in flight per asset. Assets themselves download 8-wide, so this bounds total
247
296
  * connections at 8×4 against edge-cached objects — cheap for the CDN, fast for the install. */
248
297
  const FILE_DOWNLOAD_CONCURRENCY = 4
@@ -396,7 +445,7 @@ async function updatePackageJson(
396
445
  opts.rootRequests,
397
446
  opts.installMetadata,
398
447
  )
399
- if (mergeRecord(pkg, 'assetDependencies', assetDependencies)) {
448
+ if (mergeAssetDependencyRanges(pkg, assetDependencies)) {
400
449
  changed = true
401
450
  }
402
451
 
@@ -417,28 +466,13 @@ async function runPackageManagerInstall(projectRoot: string, log: (msg: string)
417
466
  log('npm dependencies installed.')
418
467
  }
419
468
 
420
- async function writeInstalledAssetLock(
421
- projectRoot: string,
422
- assets: InstalledAsset[],
423
- ): Promise<void> {
424
- const lock = await readMarketLock(projectRoot)
425
- for (const asset of assets) {
426
- lock.assets[asset.name] = {
427
- version: asset.version,
428
- type: asset.type,
429
- files: asset.files,
430
- }
431
- }
432
- await writeMarketLock(projectRoot, lock)
433
- }
434
-
435
469
  function defaultPackageJson(): PackageJson {
436
470
  return { name: 'my-project', private: true, dependencies: {} }
437
471
  }
438
472
 
439
473
  function mergeRecord(
440
474
  pkg: PackageJson,
441
- field: 'dependencies' | 'assetDependencies',
475
+ field: 'dependencies',
442
476
  values: Record<string, string>,
443
477
  ): boolean {
444
478
  const entries = Object.entries(values)
@@ -448,7 +482,7 @@ function mergeRecord(
448
482
  let changed = false
449
483
 
450
484
  for (const [name, range] of entries) {
451
- if (field === 'dependencies' && name.startsWith('@drawcall/') && current[name] === 'latest') {
485
+ if (name.startsWith('@drawcall/') && current[name] === 'latest') {
452
486
  continue
453
487
  }
454
488
  if (current[name] === range) continue
@@ -460,6 +494,26 @@ function mergeRecord(
460
494
  return changed
461
495
  }
462
496
 
497
+ /** Merge saved ranges into `assetDependencies`, preserving any aliases an entry already carries. */
498
+ function mergeAssetDependencyRanges(pkg: PackageJson, values: Record<string, string>): boolean {
499
+ const entries = Object.entries(values)
500
+ if (entries.length === 0) return false
501
+
502
+ const current = pkg.assetDependencies ?? {}
503
+ let changed = false
504
+
505
+ for (const [name, range] of entries) {
506
+ const existing = current[name]
507
+ if (existing !== undefined && assetDependencyRange(existing) === range) continue
508
+ current[name] =
509
+ existing === undefined ? range : assetDependencyValue(range, assetDependencyAlias(existing))
510
+ changed = true
511
+ }
512
+
513
+ pkg.assetDependencies = current
514
+ return changed
515
+ }
516
+
463
517
  function assetDependenciesToSave(
464
518
  resolution: ResolveResult,
465
519
  rootRequests: InstallRootRequest[],