@drawcall/market 0.3.0 → 0.4.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.
package/src/pack.ts CHANGED
@@ -3,9 +3,17 @@ import * as fs from 'fs/promises'
3
3
  import * as path from 'path'
4
4
  import { unzipSync, zipSync } from 'fflate'
5
5
  import ignore from 'ignore'
6
+ import semver from 'semver'
7
+ import {
8
+ ALWAYS_IGNORED_DIRS,
9
+ GITIGNORE_FILENAME,
10
+ isGitignored,
11
+ findInstallRoot,
12
+ walkWithGitignore,
13
+ type GitignoreMatcher,
14
+ } from './project-walk.js'
6
15
  import type { MarketClient } from './client.js'
7
16
  import type { AssetInstallMetadata } from './contract.js'
8
- import { findInstallRoot } from './install.js'
9
17
  import {
10
18
  packageJsonAssetDependencies,
11
19
  packageJsonAssetDependenciesFromFiles,
@@ -46,10 +54,10 @@ export interface PackAssetOptions {
46
54
  dependencies: ParsedPackDependencies
47
55
  policy?: PackPolicy
48
56
  /**
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.
57
+ * Fetch `path etag` (md5) for a dependency's files given the declared range (see
58
+ * `fileManifestForRange`). Pack matches local files against these hashes by content —
59
+ * independent of path, so renamed installed files are omitted too and recorded as aliases.
60
+ * Omit this (or a failing fetch) and the dependency's files are kept.
53
61
  */
54
62
  fetchFileManifest?: FetchFileManifest
55
63
  }
@@ -63,36 +71,85 @@ export interface PackedAsset {
63
71
  omittedUnchangedInstalledFiles: boolean
64
72
  /** Number of zip entries dropped because a `.gitignore` inside the zip excluded them. */
65
73
  gitignoredFiles: number
74
+ /** Dependencies whose file index could not be fetched: files kept, aliases untouched. */
75
+ skippedDependencies: string[]
76
+ /** Dependency files whose canonical bytes were found nowhere (locally modified or deleted). */
77
+ missingDependencyFiles: Array<{ name: string; path: string }>
66
78
  }
67
79
 
68
- export function sha256(bytes: Uint8Array): string {
69
- return createHash('sha256').update(bytes).digest('hex')
80
+ /** md5 hex — the same digest R2 serves as every file's etag, so local bytes match server hashes. */
81
+ export function md5(bytes: Uint8Array): string {
82
+ return createHash('md5').update(bytes).digest('hex')
70
83
  }
71
84
 
72
85
  /**
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.
86
+ * Cap on how many in-range versions contribute hashes to one dependency's manifest. High enough
87
+ * that any real project's installed version is covered; bounds the API calls for assets with long
88
+ * histories (an out-of-cap version's files simply stay unmatched and ship the fail-open
89
+ * direction).
90
+ */
91
+ const MANIFEST_VERSION_CAP = 20
92
+
93
+ /**
94
+ * A `FetchFileManifest` backed by the Market API: `path → etag` (md5) per dependency file. Without
95
+ * a local lockfile the installed version is unknown, so a non-exact range merges the file indexes
96
+ * of every published version satisfying it (newest wins per path) — a hash match identifies the
97
+ * file regardless of which version it came from. Content addressing keeps mistakes safe: a stale
98
+ * hash simply fails to match and the file is kept.
77
99
  */
78
100
  export function fileManifestForRange(asset: MarketClient['asset']): FetchFileManifest {
79
101
  return async (name, range) => {
80
102
  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 })
103
+ if (exact) return fileIndexHashes(asset, name, exact)
104
+
105
+ const { versions } = await asset.versions({ name })
106
+ const inRange = versions
107
+ .map((entry) => entry.version)
108
+ .filter((version) => semver.satisfies(version, range))
109
+ .sort(semver.compare)
110
+ .slice(-MANIFEST_VERSION_CAP)
111
+
112
+ // Oldest first, so a newer version's hash wins when a path recurs across versions.
113
+ const merged: Record<string, string> = {}
114
+ for (const hashes of await mapWithConcurrency(inRange, 4, (version) =>
115
+ fileIndexHashes(asset, name, version),
116
+ )) {
117
+ Object.assign(merged, hashes)
118
+ }
119
+ return merged
88
120
  }
89
121
  }
90
122
 
123
+ async function fileIndexHashes(
124
+ asset: MarketClient['asset'],
125
+ name: string,
126
+ version: string,
127
+ ): Promise<Record<string, string>> {
128
+ const index = await asset.files({ name, version })
129
+ return Object.fromEntries(index.files.map((file) => [file.path, file.etag]))
130
+ }
131
+
91
132
  function exactRangeVersion(range: string): string | null {
92
133
  const candidate = range.trim().replace(/^=/u, '')
93
134
  return semverSchema.safeParse(candidate).success ? candidate : null
94
135
  }
95
136
 
137
+ async function mapWithConcurrency<T, R>(
138
+ items: readonly T[],
139
+ concurrency: number,
140
+ fn: (item: T) => Promise<R>,
141
+ ): Promise<R[]> {
142
+ const results = new Array<R>(items.length)
143
+ let next = 0
144
+ async function worker(): Promise<void> {
145
+ for (let index = next++; index < items.length; index = next++) {
146
+ results[index] = await fn(items[index])
147
+ }
148
+ }
149
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()))
150
+ return results
151
+ }
152
+
96
153
  export async function packAsset(zipFilter: string, opts: PackAssetOptions): Promise<PackedAsset> {
97
154
  const cwd = opts.cwd ?? process.cwd()
98
155
 
@@ -131,6 +188,8 @@ export async function packAsset(zipFilter: string, opts: PackAssetOptions): Prom
131
188
  const { kept: withoutIgnored, gitignored } = applyGitignore(sourceFiles)
132
189
  let files = withoutIgnored
133
190
  let finalAssetDependencies = assetDependencies
191
+ let skippedDependencies: string[] = []
192
+ let missingDependencyFiles: Array<{ name: string; path: string }> = []
134
193
  if (policy.omitUnchangedInstalledFiles) {
135
194
  const omitted = await withoutInstalledDependencyFiles(
136
195
  withoutIgnored,
@@ -139,6 +198,8 @@ export async function packAsset(zipFilter: string, opts: PackAssetOptions): Prom
139
198
  )
140
199
  files = omitted.kept
141
200
  finalAssetDependencies = omitted.assetDependencies
201
+ skippedDependencies = omitted.skippedDependencies
202
+ missingDependencyFiles = omitted.missingDependencyFiles
142
203
  }
143
204
 
144
205
  const originalCount = Object.keys(sourceFiles).length
@@ -153,6 +214,8 @@ export async function packAsset(zipFilter: string, opts: PackAssetOptions): Prom
153
214
  skillDependencies: opts.dependencies.skillDependencies,
154
215
  omittedUnchangedInstalledFiles: finalCount < afterIgnoreCount,
155
216
  gitignoredFiles: gitignored,
217
+ skippedDependencies,
218
+ missingDependencyFiles,
156
219
  }
157
220
  }
158
221
 
@@ -207,7 +270,7 @@ async function packDirectory(dir: string, opts: PackAssetOptions): Promise<Packe
207
270
  files[relative] = content
208
271
  continue
209
272
  }
210
- const hash = sha256(content)
273
+ const hash = md5(content)
211
274
  hashesByPath.set(relative, hash)
212
275
  if (dependencyHashes.has(hash)) {
213
276
  deferred.push(relative)
@@ -240,57 +303,16 @@ async function packDirectory(dir: string, opts: PackAssetOptions): Promise<Packe
240
303
  skillDependencies: opts.dependencies.skillDependencies,
241
304
  omittedUnchangedInstalledFiles: layout.omit.size > 0,
242
305
  gitignoredFiles: walk.ignored,
306
+ skippedDependencies: policy.omitUnchangedInstalledFiles
307
+ ? Object.keys(assetDependencies).filter((name) => !index.fetched.has(name))
308
+ : [],
309
+ missingDependencyFiles: layout.missing.map((file) => ({
310
+ name: file.name,
311
+ path: file.canonicalPath,
312
+ })),
243
313
  }
244
314
  }
245
315
 
246
- // Walk a directory applying git semantics as we descend: each directory's `.gitignore` governs its
247
- // own subtree, an ignored directory is pruned without entering it, and `.git`/`node_modules` never
248
- // ship. Returns kept files as posix paths relative to the root, plus the count of ignored entries.
249
- async function walkWithGitignore(root: string): Promise<{ kept: string[]; ignored: number }> {
250
- const kept: string[] = []
251
- let ignored = 0
252
-
253
- async function descend(dir: string, matchers: GitignoreMatcher[]): Promise<void> {
254
- const scoped = [...matchers]
255
- const gitignore = await maybeStat(path.join(dir, GITIGNORE_FILENAME))
256
- if (gitignore?.isFile()) {
257
- const prefix = toPosix(path.relative(root, dir))
258
- scoped.push({
259
- dir: prefix ? `${prefix}/` : '',
260
- filter: ignore().add(await fs.readFile(path.join(dir, GITIGNORE_FILENAME), 'utf8')),
261
- })
262
- }
263
-
264
- const entries = await fs.readdir(dir, { withFileTypes: true })
265
- for (const entry of entries) {
266
- if (ALWAYS_IGNORED_DIRS.has(entry.name)) continue
267
- const relative = toPosix(path.relative(root, path.join(dir, entry.name)))
268
- const candidate = entry.isDirectory() ? `${relative}/` : relative
269
- if (isGitignored(candidate, scoped)) {
270
- ignored += 1
271
- continue
272
- }
273
- if (entry.isDirectory()) await descend(path.join(dir, entry.name), scoped)
274
- else if (entry.isFile()) kept.push(relative)
275
- }
276
- }
277
-
278
- await descend(root, [])
279
- return { kept, ignored }
280
- }
281
-
282
- function toPosix(p: string): string {
283
- return p.split(path.sep).join('/')
284
- }
285
-
286
- const GITIGNORE_FILENAME = '.gitignore'
287
-
288
- // Directories that never belong in a published asset — VCS/tooling metadata, reinstallable
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.
292
- const ALWAYS_IGNORED_DIRS = new Set(['.git', 'node_modules', '.drawcall'])
293
-
294
316
  function hasAlwaysIgnoredSegment(posixPath: string): boolean {
295
317
  return posixPath.split('/').some((segment) => ALWAYS_IGNORED_DIRS.has(segment))
296
318
  }
@@ -318,12 +340,6 @@ function applyGitignore(files: Record<string, Uint8Array>): {
318
340
  return { kept, gitignored }
319
341
  }
320
342
 
321
- interface GitignoreMatcher {
322
- /** Directory the `.gitignore` governs: '' for the zip root, or e.g. 'sub/'. */
323
- dir: string
324
- filter: ReturnType<typeof ignore>
325
- }
326
-
327
343
  function gitignoreMatchers(files: Record<string, Uint8Array>): GitignoreMatcher[] {
328
344
  const matchers: GitignoreMatcher[] = []
329
345
  for (const [name, content] of Object.entries(files)) {
@@ -334,16 +350,6 @@ function gitignoreMatchers(files: Record<string, Uint8Array>): GitignoreMatcher[
334
350
  return matchers
335
351
  }
336
352
 
337
- function isGitignored(name: string, matchers: GitignoreMatcher[]): boolean {
338
- // fflate keeps directory entries with a trailing slash; the `ignore` lib matches directory patterns
339
- // (`dist/`) against exactly that form, so the path is passed through unchanged.
340
- return matchers.some((matcher) => {
341
- if (!name.startsWith(matcher.dir)) return false
342
- const relative = name.slice(matcher.dir.length)
343
- return relative.length > 0 && matcher.filter.ignores(relative)
344
- })
345
- }
346
-
347
353
  export function parsePackDependencies(specs: PackDependencySpecs): ParsedPackDependencies {
348
354
  return {
349
355
  npmDependencies: parseVersionedDeps(specs.npm ?? [], 'npm'),
@@ -451,16 +457,26 @@ async function withoutInstalledDependencyFiles(
451
457
  files: Record<string, Uint8Array>,
452
458
  assetDependencies: AssetDependencies,
453
459
  fetchFileManifest: FetchFileManifest | undefined,
454
- ): Promise<{ kept: Record<string, Uint8Array>; assetDependencies: AssetDependencies }> {
460
+ ): Promise<{
461
+ kept: Record<string, Uint8Array>
462
+ assetDependencies: AssetDependencies
463
+ skippedDependencies: string[]
464
+ missingDependencyFiles: Array<{ name: string; path: string }>
465
+ }> {
455
466
  const index = await dependencyFileIndex(assetDependencies, fetchFileManifest)
456
- if (index.files.length === 0) return { kept: files, assetDependencies }
467
+ const skippedDependencies = Object.keys(assetDependencies).filter(
468
+ (name) => !index.fetched.has(name),
469
+ )
470
+ if (index.files.length === 0) {
471
+ return { kept: files, assetDependencies, skippedDependencies, missingDependencyFiles: [] }
472
+ }
457
473
 
458
474
  const hashesByPath = new Map<string, string>()
459
475
  const entryByNormalized = new Map<string, string>()
460
476
  for (const [file, content] of Object.entries(files)) {
461
477
  const normalizedPath = normalizedZipPath(file)
462
478
  if (!normalizedPath || normalizedPath.endsWith('/')) continue
463
- hashesByPath.set(normalizedPath, sha256(content))
479
+ hashesByPath.set(normalizedPath, md5(content))
464
480
  entryByNormalized.set(normalizedPath, file)
465
481
  }
466
482
 
@@ -471,20 +487,28 @@ async function withoutInstalledDependencyFiles(
471
487
  const kept = Object.fromEntries(
472
488
  Object.entries(files).filter(([file]) => !omittedEntries.has(file)),
473
489
  )
474
- return { kept, assetDependencies: layout.assetDependencies }
490
+ return {
491
+ kept,
492
+ assetDependencies: layout.assetDependencies,
493
+ skippedDependencies,
494
+ missingDependencyFiles: layout.missing.map((file) => ({
495
+ name: file.name,
496
+ path: file.canonicalPath,
497
+ })),
498
+ }
475
499
  }
476
500
 
477
- interface DependencyFile {
501
+ export interface DependencyFile {
478
502
  name: string
479
503
  canonicalPath: string
480
504
  }
481
505
 
482
- interface DependencyFileEntry extends DependencyFile {
483
- /** sha256 hex of the file's canonical content, from the server manifest. */
506
+ export interface DependencyFileEntry extends DependencyFile {
507
+ /** md5 hex of the file's canonical content — its R2 etag, from the file index. */
484
508
  hash: string
485
509
  }
486
510
 
487
- interface DependencyFileIndex {
511
+ export interface DependencyFileIndex {
488
512
  files: DependencyFileEntry[]
489
513
  /** Dependencies whose manifest was fetched — only their aliases are re-derived. */
490
514
  fetched: Set<string>
@@ -496,7 +520,7 @@ function emptyDependencyFileIndex(): DependencyFileIndex {
496
520
 
497
521
  /**
498
522
  * 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
523
+ * the server (the version file indexes' etags). Returns an empty index when there's nothing to omit or no way
500
524
  * to fetch (offline / no client) — pack then keeps all files rather than guessing.
501
525
  */
502
526
  async function dependencyFileIndex(
@@ -547,7 +571,7 @@ interface DependencyLayout {
547
571
  * user's own content. Stale aliases drop: a renamed file that was since modified ships as user
548
572
  * content and reinstall restores the canonical path.
549
573
  */
550
- function resolveDependencyLayout(
574
+ export function resolveDependencyLayout(
551
575
  declared: AssetDependencies,
552
576
  index: DependencyFileIndex,
553
577
  hashesByPath: Map<string, string>,
@@ -632,7 +656,7 @@ export async function syncAssetDependencies(
632
656
  for (const relative of walk.kept) {
633
657
  hashesByPath.set(
634
658
  relative,
635
- sha256(new Uint8Array(await fs.readFile(path.join(projectRoot, relative)))),
659
+ md5(new Uint8Array(await fs.readFile(path.join(projectRoot, relative)))),
636
660
  )
637
661
  }
638
662
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Directory walking with git semantics, shared by pack/sync (dependency-layout resolution) and
3
+ * install (rename adoption): each directory's `.gitignore` governs its own subtree, an ignored
4
+ * directory is pruned without entering it, and `.git`/`node_modules`/`.drawcall` never count.
5
+ */
6
+ import * as fs from 'fs/promises'
7
+ import * as path from 'path'
8
+ import ignore from 'ignore'
9
+
10
+ export const GITIGNORE_FILENAME = '.gitignore'
11
+
12
+ // Directories that never belong in a published asset — VCS/tooling metadata, reinstallable
13
+ // dependencies, and the market's own state dir (`.drawcall`, holding the install mutex). Excluded
14
+ // structurally in every walk, regardless of any `.gitignore`.
15
+ export const ALWAYS_IGNORED_DIRS = new Set(['.git', 'node_modules', '.drawcall'])
16
+
17
+ export interface GitignoreMatcher {
18
+ /** Directory the `.gitignore` governs: '' for the root, or e.g. 'sub/'. */
19
+ dir: string
20
+ filter: ReturnType<typeof ignore>
21
+ }
22
+
23
+ export function isGitignored(name: string, matchers: GitignoreMatcher[]): boolean {
24
+ // Directory candidates carry a trailing slash; the `ignore` lib matches directory patterns
25
+ // (`dist/`) against exactly that form, so the path is passed through unchanged.
26
+ return matchers.some((matcher) => {
27
+ if (!name.startsWith(matcher.dir)) return false
28
+ const relative = name.slice(matcher.dir.length)
29
+ return relative.length > 0 && matcher.filter.ignores(relative)
30
+ })
31
+ }
32
+
33
+ /** Walk a directory tree; returns kept files as posix paths relative to the root, plus the count
34
+ * of ignored entries. */
35
+ export async function walkWithGitignore(
36
+ root: string,
37
+ ): Promise<{ kept: string[]; ignored: number }> {
38
+ const kept: string[] = []
39
+ let ignored = 0
40
+
41
+ async function descend(dir: string, matchers: GitignoreMatcher[]): Promise<void> {
42
+ const scoped = [...matchers]
43
+ const gitignore = await maybeStat(path.join(dir, GITIGNORE_FILENAME))
44
+ if (gitignore?.isFile()) {
45
+ const prefix = toPosix(path.relative(root, dir))
46
+ scoped.push({
47
+ dir: prefix ? `${prefix}/` : '',
48
+ filter: ignore().add(await fs.readFile(path.join(dir, GITIGNORE_FILENAME), 'utf8')),
49
+ })
50
+ }
51
+
52
+ const entries = await fs.readdir(dir, { withFileTypes: true })
53
+ for (const entry of entries) {
54
+ if (ALWAYS_IGNORED_DIRS.has(entry.name)) continue
55
+ const relative = toPosix(path.relative(root, path.join(dir, entry.name)))
56
+ const candidate = entry.isDirectory() ? `${relative}/` : relative
57
+ if (isGitignored(candidate, scoped)) {
58
+ ignored += 1
59
+ continue
60
+ }
61
+ if (entry.isDirectory()) await descend(path.join(dir, entry.name), scoped)
62
+ else if (entry.isFile()) kept.push(relative)
63
+ }
64
+ }
65
+
66
+ await descend(root, [])
67
+ return { kept, ignored }
68
+ }
69
+
70
+ /** The nearest ancestor (including start) holding a package.json — the project root. */
71
+ export async function findInstallRoot(cwd: string = process.cwd()): Promise<string> {
72
+ const start = path.resolve(cwd)
73
+
74
+ for (let dir = start; ; dir = path.dirname(dir)) {
75
+ if ((await maybeStat(path.join(dir, 'package.json')))?.isFile()) {
76
+ return dir
77
+ }
78
+ if (path.dirname(dir) === dir) {
79
+ return start
80
+ }
81
+ }
82
+ }
83
+
84
+ function toPosix(p: string): string {
85
+ return p.split(path.sep).join('/')
86
+ }
87
+
88
+ async function maybeStat(file: string) {
89
+ try {
90
+ return await fs.stat(file)
91
+ } catch (error) {
92
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return null
93
+ throw error
94
+ }
95
+ }
package/src/schemas.ts CHANGED
@@ -133,17 +133,20 @@ export const assetFilesSchema = z.object({
133
133
  key: accessKeySchema.optional(),
134
134
  })
135
135
 
136
- export const fileManifestSchema = z.object({
136
+ // Input of `asset.versions`: every published version of an asset (same credential rules as reads).
137
+ export const assetVersionsSchema = z.object({
137
138
  name: assetNameSchema,
138
- version: semverSchema,
139
+ key: accessKeySchema.optional(),
139
140
  })
140
141
 
141
- /**
142
- * The content hash of every file in an asset version, keyed by its install path — sha256 hex, the
143
- * same digest the CLI computes locally. Lets a caller check whether a local file is identical to the
144
- * canonical asset without keeping hashes itself (see `pack` omit-unchanged).
145
- */
146
- export type AssetFileManifest = Record<string, string>
142
+ export interface AssetVersionListing {
143
+ version: string
144
+ approved: boolean
145
+ }
146
+
147
+ export interface AssetVersionsResult {
148
+ versions: AssetVersionListing[]
149
+ }
147
150
 
148
151
  // A phone-photo data URI is a few MB; this bounds worker and model payloads while staying far
149
152
  // above what providers keep (images are downscaled upstream).
@@ -1,6 +1,6 @@
1
1
  import { oc } from '@orpc/contract'
2
2
  import { z } from 'zod'
3
- import type { AssetFileManifest } from '../schemas.js'
3
+ import type { AssetVersionsResult } from '../schemas.js'
4
4
  import type {
5
5
  AssetExactResult,
6
6
  AssetFileEntry,
@@ -16,8 +16,8 @@ import {
16
16
  agentStartSchema,
17
17
  agentStatusSchema,
18
18
  assetFilesSchema,
19
+ assetVersionsSchema,
19
20
  exactAssetSchema,
20
- fileManifestSchema,
21
21
  generateAssetSchema,
22
22
  generateJobStatusSchema,
23
23
  generateResponseSchema,
@@ -76,10 +76,12 @@ export const contract = {
76
76
  .input(assetFilesSchema)
77
77
  .output(z.custom<{ files: AssetFileEntry[] }>()),
78
78
 
79
- fileManifest: oc
80
- .route({ method: 'GET', path: '/assets/{name}/{version}/file-manifest' })
81
- .input(fileManifestSchema)
82
- .output(z.custom<AssetFileManifest>()),
79
+ // Every published version of an asset. Feeds multi-version content matching: without a local
80
+ // lockfile the installed version is unknown, so clients merge hashes across the range.
81
+ versions: oc
82
+ .route({ method: 'GET', path: '/assets/{name}/versions' })
83
+ .input(assetVersionsSchema)
84
+ .output(z.custom<AssetVersionsResult>()),
83
85
 
84
86
  uploadZip: oc
85
87
  .route({ method: 'PUT', path: '/assets/{name}/{version}' })