@drawcall/market 0.2.3 → 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/README.md +8 -8
- package/dist/client.d.ts +4 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -16
- package/dist/client.js.map +1 -1
- package/dist/commands/upload.d.ts.map +1 -1
- package/dist/commands/upload.js +4 -1
- package/dist/commands/upload.js.map +1 -1
- package/dist/contract.d.ts +0 -170
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +1 -46
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +3 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -7
- package/dist/index.js.map +1 -1
- package/dist/install-entry.d.ts +0 -1
- package/dist/install-entry.d.ts.map +1 -1
- package/dist/install-entry.js +0 -1
- package/dist/install-entry.js.map +1 -1
- package/dist/install.d.ts +2 -1
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +138 -34
- package/dist/install.js.map +1 -1
- package/dist/output.d.ts +6 -0
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +17 -0
- package/dist/output.js.map +1 -1
- package/dist/pack.d.ts +49 -9
- package/dist/pack.d.ts.map +1 -1
- package/dist/pack.js +77 -77
- package/dist/pack.js.map +1 -1
- package/dist/project-walk.d.ts +18 -0
- package/dist/project-walk.d.ts.map +1 -0
- package/dist/project-walk.js +83 -0
- package/dist/project-walk.js.map +1 -0
- package/dist/schemas.d.ts +9 -8
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -2
- package/dist/schemas.js.map +1 -1
- package/dist/v1/contract.d.ts +4 -4
- package/dist/v1/contract.d.ts.map +1 -1
- package/dist/v1/contract.js +6 -4
- package/dist/v1/contract.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +8 -28
- package/src/commands/upload.ts +3 -1
- package/src/contract.ts +0 -80
- package/src/index.ts +6 -10
- package/src/install-entry.ts +0 -1
- package/src/install.ts +199 -42
- package/src/output.ts +22 -0
- package/src/pack.ts +118 -94
- package/src/project-walk.ts +95 -0
- package/src/schemas.ts +11 -8
- package/src/v1/contract.ts +8 -6
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
|
|
50
|
-
*
|
|
51
|
-
* path, so renamed installed files are omitted too and recorded as aliases.
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 =
|
|
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<{
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
key: accessKeySchema.optional(),
|
|
139
140
|
})
|
|
140
141
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
export
|
|
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).
|
package/src/v1/contract.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { oc } from '@orpc/contract'
|
|
2
2
|
import { z } from 'zod'
|
|
3
|
-
import type {
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
.
|
|
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}' })
|