@drawcall/market 0.1.52 → 0.1.54

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 (47) hide show
  1. package/dist/commands/list.js +1 -1
  2. package/dist/commands/list.js.map +1 -1
  3. package/dist/commands/pack.d.ts +1 -0
  4. package/dist/commands/pack.d.ts.map +1 -1
  5. package/dist/commands/pack.js +5 -0
  6. package/dist/commands/pack.js.map +1 -1
  7. package/dist/commands/upload.d.ts.map +1 -1
  8. package/dist/commands/upload.js +1 -0
  9. package/dist/commands/upload.js.map +1 -1
  10. package/dist/contract.d.ts +5 -0
  11. package/dist/contract.d.ts.map +1 -1
  12. package/dist/contract.js +6 -1
  13. package/dist/contract.js.map +1 -1
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/install.d.ts.map +1 -1
  19. package/dist/install.js +2 -5
  20. package/dist/install.js.map +1 -1
  21. package/dist/market-lock.d.ts +2 -3
  22. package/dist/market-lock.d.ts.map +1 -1
  23. package/dist/market-lock.js +7 -3
  24. package/dist/market-lock.js.map +1 -1
  25. package/dist/pack.d.ts +7 -0
  26. package/dist/pack.d.ts.map +1 -1
  27. package/dist/pack.js +71 -46
  28. package/dist/pack.js.map +1 -1
  29. package/dist/schemas.d.ts +10 -0
  30. package/dist/schemas.d.ts.map +1 -1
  31. package/dist/schemas.js +4 -0
  32. package/dist/schemas.js.map +1 -1
  33. package/dist/skill.d.ts +1 -1
  34. package/dist/skill.d.ts.map +1 -1
  35. package/dist/skill.js +2 -2
  36. package/package.json +1 -1
  37. package/skills/market/SKILL.md +2 -2
  38. package/src/commands/list.ts +1 -1
  39. package/src/commands/pack.ts +6 -0
  40. package/src/commands/upload.ts +1 -0
  41. package/src/contract.ts +8 -0
  42. package/src/index.ts +2 -0
  43. package/src/install.ts +11 -20
  44. package/src/market-lock.ts +10 -7
  45. package/src/pack.ts +94 -45
  46. package/src/schemas.ts +12 -0
  47. package/src/skill.ts +2 -2
package/src/pack.ts CHANGED
@@ -28,10 +28,18 @@ export interface PackPolicy {
28
28
  omitUnchangedInstalledFiles: boolean
29
29
  }
30
30
 
31
+ export type FetchFileManifest = (name: string, version: string) => Promise<Record<string, string>>
32
+
31
33
  export interface PackAssetOptions {
32
34
  cwd?: string
33
35
  dependencies: ParsedPackDependencies
34
36
  policy?: PackPolicy
37
+ /**
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.
41
+ */
42
+ fetchFileManifest?: FetchFileManifest
35
43
  }
36
44
 
37
45
  export interface PackedAsset {
@@ -79,23 +87,28 @@ export async function packAsset(zipFilter: string, opts: PackAssetOptions): Prom
79
87
  // Drop anything the zip's own .gitignore excludes (node_modules, build output, secrets…), then —
80
88
  // for templates — omit unchanged installed dependency files. Only removals happen, so a smaller
81
89
  // count means something was dropped; re-zip only then.
82
- const withoutIgnored = applyGitignore(sourceFiles)
90
+ const { kept: withoutIgnored, gitignored } = applyGitignore(sourceFiles)
83
91
  const files = policy.omitUnchangedInstalledFiles
84
- ? await withoutUnchangedInstalledFiles(withoutIgnored, assetDependencies, cwd)
92
+ ? await withoutUnchangedInstalledFiles(
93
+ withoutIgnored,
94
+ assetDependencies,
95
+ cwd,
96
+ opts.fetchFileManifest,
97
+ )
85
98
  : withoutIgnored
86
99
 
87
- const sourceCount = Object.keys(sourceFiles).length
100
+ const originalCount = Object.keys(sourceFiles).length
88
101
  const afterIgnoreCount = Object.keys(withoutIgnored).length
89
102
  const finalCount = Object.keys(files).length
90
103
 
91
104
  return {
92
105
  sourcePath: zipFile,
93
- zip: finalCount < sourceCount ? zipSync(files) : sourceZip,
106
+ zip: finalCount < originalCount ? zipSync(files) : sourceZip,
94
107
  npmDependencies,
95
108
  assetDependencies,
96
109
  skillDependencies: opts.dependencies.skillDependencies,
97
110
  omittedUnchangedInstalledFiles: finalCount < afterIgnoreCount,
98
- gitignoredFiles: sourceCount - afterIgnoreCount,
111
+ gitignoredFiles: gitignored,
99
112
  }
100
113
  }
101
114
 
@@ -113,27 +126,28 @@ async function packDirectory(dir: string, opts: PackAssetOptions): Promise<Packe
113
126
 
114
127
  const packageJsonFiles: Record<string, Uint8Array> = {}
115
128
  if (policy.readPackageJsonDependencies && hasRootPackageJson) {
116
- packageJsonFiles['package.json'] = new Uint8Array(await fs.readFile(path.join(dir, 'package.json')))
129
+ packageJsonFiles['package.json'] = new Uint8Array(
130
+ await fs.readFile(path.join(dir, 'package.json')),
131
+ )
117
132
  }
118
133
  const assetDependencies = mergeAssetDependencies(
119
- policy.readPackageJsonDependencies ? packageJsonAssetDependenciesFromFiles(packageJsonFiles) : {},
134
+ policy.readPackageJsonDependencies
135
+ ? packageJsonAssetDependenciesFromFiles(packageJsonFiles)
136
+ : {},
120
137
  opts.dependencies.assetDependencies,
121
138
  )
122
139
  const npmDependencies = {
123
- ...(policy.readPackageJsonDependencies ? packageJsonNpmDependenciesFromFiles(packageJsonFiles) : {}),
140
+ ...(policy.readPackageJsonDependencies
141
+ ? packageJsonNpmDependenciesFromFiles(packageJsonFiles)
142
+ : {}),
124
143
  ...opts.dependencies.npmDependencies,
125
144
  }
126
145
 
127
- // Hashes of installed dependency files, so an unchanged one is dropped after a single read.
128
- const hashesByPath = new Map<string, string>()
129
- if (policy.omitUnchangedInstalledFiles && Object.keys(assetDependencies).length > 0) {
130
- const installRoot = await findInstallRoot(dir)
131
- const lock = await readMarketLock(installRoot)
132
- for (const [name, asset] of Object.entries(lock.assets)) {
133
- if (!(name in assetDependencies)) continue
134
- for (const [file, metadata] of Object.entries(asset.files)) hashesByPath.set(file, metadata.sha256)
135
- }
136
- }
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>()
137
151
 
138
152
  const files: Record<string, Uint8Array> = {}
139
153
  let omittedInstalled = 0
@@ -183,7 +197,7 @@ async function walkWithGitignore(root: string): Promise<{ kept: string[]; ignore
183
197
 
184
198
  const entries = await fs.readdir(dir, { withFileTypes: true })
185
199
  for (const entry of entries) {
186
- if (entry.name === '.git' || entry.name === 'node_modules') continue
200
+ if (ALWAYS_IGNORED_DIRS.has(entry.name)) continue
187
201
  const relative = toPosix(path.relative(root, path.join(dir, entry.name)))
188
202
  const candidate = entry.isDirectory() ? `${relative}/` : relative
189
203
  if (isGitignored(candidate, scoped)) {
@@ -205,20 +219,36 @@ function toPosix(p: string): string {
205
219
 
206
220
  const GITIGNORE_FILENAME = '.gitignore'
207
221
 
222
+ // 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.
225
+ const ALWAYS_IGNORED_DIRS = new Set(['.git', 'node_modules', '.drawcall'])
226
+
227
+ function hasAlwaysIgnoredSegment(posixPath: string): boolean {
228
+ return posixPath.split('/').some((segment) => ALWAYS_IGNORED_DIRS.has(segment))
229
+ }
230
+
208
231
  /**
209
- * Drop every zip entry excluded by a `.gitignore` inside the zip. `.gitignore` files are applied
210
- * per-directory (git semantics: a nested `.gitignore` only affects its own subtree), so a naively
211
- * built asset zip never ships `node_modules`, build output, or secrets.
232
+ * Filter zip entries: always drop the structural dirs above, then drop whatever a `.gitignore`
233
+ * inside the zip excludes. `.gitignore` files apply per-directory (git semantics: a nested one only
234
+ * affects its own subtree). Returns the survivors plus the count of `.gitignore`-matched drops.
212
235
  */
213
- function applyGitignore(files: Record<string, Uint8Array>): Record<string, Uint8Array> {
236
+ function applyGitignore(files: Record<string, Uint8Array>): {
237
+ kept: Record<string, Uint8Array>
238
+ gitignored: number
239
+ } {
214
240
  const matchers = gitignoreMatchers(files)
215
- if (matchers.length === 0) return files
216
-
217
241
  const kept: Record<string, Uint8Array> = {}
242
+ let gitignored = 0
218
243
  for (const [name, content] of Object.entries(files)) {
219
- if (!isGitignored(name, matchers)) kept[name] = content
244
+ if (hasAlwaysIgnoredSegment(name)) continue
245
+ if (matchers.length > 0 && isGitignored(name, matchers)) {
246
+ gitignored += 1
247
+ continue
248
+ }
249
+ kept[name] = content
220
250
  }
221
- return kept
251
+ return { kept, gitignored }
222
252
  }
223
253
 
224
254
  interface GitignoreMatcher {
@@ -343,40 +373,59 @@ function inferPackPolicy(files: Record<string, Uint8Array>): PackPolicy {
343
373
  }
344
374
  }
345
375
 
346
- // Drop dependency files whose bytes still match what `.drawcall/market-lock.json` recorded as
347
- // installed, so a template ships only its own (edited or new) files. Returns the surviving entries.
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.
348
378
  async function withoutUnchangedInstalledFiles(
349
379
  files: Record<string, Uint8Array>,
350
380
  assetDependencies: Record<string, string>,
351
381
  cwd: string,
382
+ fetchFileManifest: FetchFileManifest | undefined,
352
383
  ): Promise<Record<string, Uint8Array>> {
353
- const dependencyNames = new Set(Object.keys(assetDependencies))
354
- if (dependencyNames.size === 0) return files
355
-
356
- const installRoot = await findInstallRoot(cwd)
357
- const lock = await readMarketLock(installRoot)
358
- const hashesByPath = new Map<string, string>()
359
-
360
- for (const [name, asset] of Object.entries(lock.assets)) {
361
- if (!dependencyNames.has(name)) continue
362
- for (const [file, metadata] of Object.entries(asset.files)) {
363
- hashesByPath.set(file, metadata.sha256)
364
- }
365
- }
366
-
384
+ const hashesByPath = await installedDependencyHashes(cwd, assetDependencies, fetchFileManifest)
367
385
  if (hashesByPath.size === 0) return files
368
386
 
369
387
  const kept: Record<string, Uint8Array> = {}
370
388
  for (const [file, content] of Object.entries(files)) {
371
389
  const normalizedPath = normalizedZipPath(file)
372
- const lockedHash = normalizedPath ? hashesByPath.get(normalizedPath) : undefined
373
- if (lockedHash && lockedHash === sha256(content)) continue
390
+ const canonicalHash = normalizedPath ? hashesByPath.get(normalizedPath) : undefined
391
+ if (canonicalHash && canonicalHash === sha256(content)) continue
374
392
  kept[file] = content
375
393
  }
376
394
 
377
395
  return kept
378
396
  }
379
397
 
398
+ /**
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
402
+ * to fetch (offline / no client) — pack then keeps all files rather than guessing.
403
+ */
404
+ async function installedDependencyHashes(
405
+ root: string,
406
+ assetDependencies: Record<string, string>,
407
+ 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
412
+
413
+ const lock = await readMarketLock(await findInstallRoot(root))
414
+ for (const name of dependencyNames) {
415
+ const installed = lock.assets[name]
416
+ if (!installed) continue
417
+ let manifest: Record<string, string>
418
+ try {
419
+ manifest = await fetchFileManifest(name, installed.version)
420
+ } catch {
421
+ // Can't reach the server for this dependency — keep its files rather than wrongly omitting them.
422
+ continue
423
+ }
424
+ for (const [file, hash] of Object.entries(manifest)) hashesByPath.set(file, hash)
425
+ }
426
+ return hashesByPath
427
+ }
428
+
380
429
  function normalizedZipPath(file: string): string | null {
381
430
  const zipPath = file.replace(/\\/g, '/')
382
431
  if (
package/src/schemas.ts CHANGED
@@ -93,6 +93,18 @@ export const downloadZipSchema = z.object({
93
93
  version: semverSchema,
94
94
  })
95
95
 
96
+ export const fileManifestSchema = z.object({
97
+ name: assetNameSchema,
98
+ version: semverSchema,
99
+ })
100
+
101
+ /**
102
+ * The content hash of every file in an asset version, keyed by its install path — sha256 hex, the
103
+ * same digest the CLI computes locally. Lets a caller check whether a local file is identical to the
104
+ * canonical asset without keeping hashes itself (see `pack` omit-unchanged).
105
+ */
106
+ export type AssetFileManifest = Record<string, string>
107
+
96
108
  export const generateAssetSchema = z.object({
97
109
  description: assetDescriptionSchema.min(3),
98
110
  type: assetTypeSchema.optional(),
package/src/skill.ts CHANGED
@@ -26,7 +26,7 @@ market pack scene.zip --out scene.packed.zip
26
26
  5. \`preview <name>\` saves the preview image; no \`--type\` is needed. Not every type has previews (e.g. \`humanoid-animation\`, \`template\`, \`sound-effect\`, \`background-music\`); the CLI reports when one is unavailable.
27
27
  6. Use \`--unapproved\` only when the user asks for unapproved/private/admin assets. Do not install unapproved assets without explicit acceptance.
28
28
  7. \`generate --type <type> "<prompt>"\` creates and installs a generated asset when that asset type has a generator; it requires login. Currently supported generated types are \`sound-effect\`, \`background-music\`, \`flipbook\`, \`humanoid-model\`, and \`environment\` (a fitting HDRI sky + equirectangular background, generated in ~1-2 min). Generation is provider-specific: prompt style, generated files, indexing fields, and install layout are owned by the asset type. If a type does not support generation yet, the CLI reports unsupported generation. Add \`--access public\` to publish the generated asset publicly, or \`--access private\` to keep it owner-only; when omitted the server defaults to private if you hold the \`market:private\` entitlement, else public (\`--access private\` requires that entitlement). \`generate\` waits for the asset and installs it — one command for quick types. For a long one (e.g. \`humanoid-model\`, >2 min) the call returns after ~2 min with a job id instead of hanging your shell; run \`market generate install <jobId>\` to continue — it resumes the SAME job where the last call left off and installs when ready. Just re-run \`generate install <jobId>\` until it prints "Generated and installed" (it exits 0 while still generating, 1 on failure). No type is flagged "slow" — anything that outlasts one wait just continues on the next call.
29
- 8. Use \`pack <zip>\` to create the same Market asset zip that \`upload\` sends. \`pack\` runs offline, infers template packing from a root \`package.json\`, and accepts \`--type\` only when you need to override that inference. \`upload\` runs the shared pack step internally, then publishes: \`market upload <name> <zip> "<description>" --type <type>\`. Declare dependencies with repeatable flags on either command: \`--npm name@range\`, \`--asset name@range\`, \`--skill label=source\`. Template pack/upload also reads root \`package.json.assetDependencies\`; \`--asset\` flags are additive and must not conflict. Template pack/upload omits installed dependency files whose hashes still match \`.drawcall/market-lock.json\`, so edited local files stay in the template. A skill source is a \`skills add\` argument: a whole repo (\`owner/repo\` or a git URL), a single skill via the full URL form \`https://github.com/owner/repo/tree/<branch>/<subpath>\` (the \`tree/<branch>/<subpath>\` shorthand needs the full URL, not \`owner/repo\`), or a local path to a skill directory inside the zip. Example: \`market upload my-scene scene.zip "A scene" --type model --npm three@^0.178.0 --skill web-design=https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines\`.
29
+ 8. Use \`pack <zip>\` to create the same Market asset zip that \`upload\` sends. \`pack\` runs offline, infers template packing from a root \`package.json\`, and accepts \`--type\` only when you need to override that inference. \`upload\` runs the shared pack step internally, then publishes: \`market upload <name> <zip> "<description>" --type <type>\`. Declare dependencies with repeatable flags on either command: \`--npm name@range\`, \`--asset name@range\`, \`--skill label=source\`. Template pack/upload also reads root \`package.json.assetDependencies\`; \`--asset\` flags are additive and must not conflict. Template pack/upload omits installed dependency files that still match the installed dependency's canonical content (its file hashes are fetched from the server, not stored locally), so edited local files stay in the template; this omit step needs the API, but simple non-template packs stay offline. A skill source is a \`skills add\` argument: a whole repo (\`owner/repo\` or a git URL), a single skill via the full URL form \`https://github.com/owner/repo/tree/<branch>/<subpath>\` (the \`tree/<branch>/<subpath>\` shorthand needs the full URL, not \`owner/repo\`), or a local path to a skill directory inside the zip. Example: \`market upload my-scene scene.zip "A scene" --type model --npm three@^0.178.0 --skill web-design=https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines\`.
30
30
  9. Installed \`environment\` assets contain \`public/environment/<name>.hdr\` for Three.js IBL lighting and \`public/environment/<name>-background.webp\` for the visible equirectangular background. Use \`market preview\` to fetch the preview image separately.
31
31
  10. Installed \`flipbook\` assets contain \`public/flipbook/<name>.ktx2\`. Render them with \`@drawcall/flipbook\`'s \`Flipbook\` class and Three.js \`KTX2Loader\` for Basis-compressed files; \`market preview\` fetches the middle frame from the flipbook.
32
32
 
@@ -63,7 +63,7 @@ Saved preview for wooden-chair@1.0.0: /tmp/wooden-chair.png
63
63
 
64
64
  Assets may also declare \`skill\` dependencies, installed for you via the \`skills\` CLI (\`skills add\`) during \`install\`. Sources are either a GitHub/git ref or a local path to a skill directory shipped inside the asset. This requires \`npx\` to be available.
65
65
 
66
- Installed non-template assets are saved to \`package.json.assetDependencies\`; templates are scaffolds and are not saved as project asset dependencies. Exact installed versions and file hashes are recorded in \`.drawcall/market-lock.json\`.
66
+ Installed non-template assets are saved to \`package.json.assetDependencies\`; templates are scaffolds and are not saved as project asset dependencies. Exact installed versions and file paths are recorded in \`.drawcall/market-lock.json\`; file content hashes now come from the server (\`asset.fileManifest\`), not the lock.
67
67
 
68
68
  \`list\` is offline: it reads \`.drawcall/market-lock.json\` from the nearest package root and prints exact installed names, versions, types, and installed file paths.
69
69