@drawcall/market 0.1.77 → 0.1.80
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/dist/asset-implementation.d.ts +4 -0
- package/dist/asset-implementation.d.ts.map +1 -1
- package/dist/asset-implementation.js.map +1 -1
- package/dist/cli-client.js +1 -1
- package/dist/cli-client.js.map +1 -1
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/client.js +1 -1
- package/dist/client.js.map +1 -1
- package/dist/commands/install.d.ts +3 -1
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +11 -6
- package/dist/commands/install.js.map +1 -1
- package/dist/contract.d.ts +31 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +5 -1
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +4 -2
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +78 -8
- package/dist/install.js.map +1 -1
- package/dist/resolve.d.ts +1 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +7 -4
- package/dist/resolve.js.map +1 -1
- package/dist/schemas.d.ts +8 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +13 -0
- package/dist/schemas.js.map +1 -1
- package/package.json +1 -1
- package/src/asset-implementation.ts +4 -0
- package/src/cli-client.ts +1 -1
- package/src/cli.ts +4 -2
- package/src/client.ts +1 -1
- package/src/commands/install.ts +13 -4
- package/src/contract.ts +32 -0
- package/src/index.ts +2 -0
- package/src/install.ts +101 -11
- package/src/resolve.ts +15 -3
- package/src/schemas.ts +15 -0
package/src/install.ts
CHANGED
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { execFile } from 'child_process'
|
|
14
|
+
import { createHash } from 'crypto'
|
|
14
15
|
import * as fs from 'fs/promises'
|
|
15
16
|
import * as path from 'path'
|
|
16
17
|
import { unzipSync } from 'fflate'
|
|
17
18
|
import { detectPackageManager, installDependencies } from 'nypm'
|
|
18
19
|
import type { MarketClient } from './client.js'
|
|
19
|
-
import type { AssetInstallMetadata } from './contract.js'
|
|
20
|
+
import type { AssetFilesResult, AssetInstallMetadata } from './contract.js'
|
|
20
21
|
import { withProjectInstallLock } from './install-lock.js'
|
|
21
22
|
import { readMarketLock, writeMarketLock } from './market-lock.js'
|
|
22
23
|
import { parsePackageJson, type PackageJson } from './package-json.js'
|
|
@@ -31,11 +32,13 @@ export interface InstallOptions {
|
|
|
31
32
|
rootRequests?: InstallRootRequest[]
|
|
32
33
|
/** Asset-type install policy from the Market API. */
|
|
33
34
|
installMetadata?: Record<string, AssetInstallMetadata>
|
|
34
|
-
/** Market API origin (e.g. https://
|
|
35
|
-
* the Worker's streaming `/download` route instead of the buffering oRPC `downloadZip`. */
|
|
35
|
+
/** Market API origin (e.g. https://market.drawcall.ai). When set, the zip fallback is fetched
|
|
36
|
+
* from the Worker's streaming `/download` route instead of the buffering oRPC `downloadZip`. */
|
|
36
37
|
baseUrl?: string
|
|
37
38
|
/** Bearer token for owner-only assets downloaded through the streaming route. */
|
|
38
39
|
authToken?: string
|
|
40
|
+
/** Capability key of a shared private asset (`market install <asset> --key <key>`). */
|
|
41
|
+
key?: string
|
|
39
42
|
/** Log progress */
|
|
40
43
|
onProgress?: (message: string) => void
|
|
41
44
|
/**
|
|
@@ -82,6 +85,7 @@ interface DownloadOptions {
|
|
|
82
85
|
log: (message: string) => void
|
|
83
86
|
baseUrl?: string
|
|
84
87
|
authToken?: string
|
|
88
|
+
key?: string
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
export async function install(
|
|
@@ -98,6 +102,7 @@ export async function install(
|
|
|
98
102
|
log,
|
|
99
103
|
baseUrl: opts.baseUrl,
|
|
100
104
|
authToken: opts.authToken,
|
|
105
|
+
key: opts.key,
|
|
101
106
|
})
|
|
102
107
|
const integration = await withProjectInstallLock(
|
|
103
108
|
installRoot,
|
|
@@ -188,14 +193,14 @@ async function downloadOneAsset(
|
|
|
188
193
|
): Promise<{ asset: InstalledAsset; wrotePackageJson: boolean; warnings: string[] }> {
|
|
189
194
|
opts.log(`Downloading ${asset.name}@${asset.version}...`)
|
|
190
195
|
|
|
191
|
-
// Only the network
|
|
192
|
-
//
|
|
193
|
-
const files =
|
|
196
|
+
// Only the network fetches are retried — downloads are the sole transient step; file writes are
|
|
197
|
+
// deterministic local work and a failure there is a real error, not worth re-attempting.
|
|
198
|
+
const files = await fetchAssetEntries(client, asset, opts)
|
|
194
199
|
const installedFiles: string[] = []
|
|
195
200
|
const warnings: string[] = []
|
|
196
201
|
let wrotePackageJson = false
|
|
197
202
|
|
|
198
|
-
for (const [relativePath, content] of
|
|
203
|
+
for (const [relativePath, content] of files) {
|
|
199
204
|
const zipPath = relativePath.replace(/\\/g, '/')
|
|
200
205
|
if (
|
|
201
206
|
zipPath.split('/').includes('..') ||
|
|
@@ -248,6 +253,82 @@ async function downloadOneAsset(
|
|
|
248
253
|
}
|
|
249
254
|
}
|
|
250
255
|
|
|
256
|
+
/** Files fetched in flight per asset. Assets themselves download 8-wide, so this bounds total
|
|
257
|
+
* connections at 8×4 against edge-cached objects — cheap for the CDN, fast for the install. */
|
|
258
|
+
const FILE_DOWNLOAD_CONCURRENCY = 4
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A version's files: fetched per file from the data plane (parallel, md5-verified against R2
|
|
262
|
+
* etags, through the same edge cache browsers warm), or by unzipping the zip view when the server
|
|
263
|
+
* or version predates unpacked storage.
|
|
264
|
+
*/
|
|
265
|
+
async function fetchAssetEntries(
|
|
266
|
+
client: MarketClient,
|
|
267
|
+
asset: ResolveResult['assets'][number],
|
|
268
|
+
opts: DownloadOptions,
|
|
269
|
+
): Promise<Array<[string, Uint8Array]>> {
|
|
270
|
+
const index = await fetchFileIndex(client, asset, opts)
|
|
271
|
+
if (!index) {
|
|
272
|
+
const zip = unzipSync(new Uint8Array(await fetchAssetZipBytes(client, asset, opts)))
|
|
273
|
+
return Object.entries(zip)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const label = `${asset.name}@${asset.version}`
|
|
277
|
+
return mapWithConcurrency(index.files, FILE_DOWNLOAD_CONCURRENCY, async (file) => {
|
|
278
|
+
const bytes = await withRetry(() => fetchVerifiedFile(index, file), {
|
|
279
|
+
attempts: DOWNLOAD_ATTEMPTS,
|
|
280
|
+
onRetry: (msg) => opts.log(`Downloading ${label} ${file.path}: ${msg}`),
|
|
281
|
+
})
|
|
282
|
+
return [file.path, bytes] as [string, Uint8Array]
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The derived file index of a version, or null when the zip view must cover it: a NOT_FOUND means
|
|
288
|
+
* the server predates `asset.files` or the version is not yet backfilled — both transitional
|
|
289
|
+
* states the migration's Contract phase retires.
|
|
290
|
+
*/
|
|
291
|
+
async function fetchFileIndex(
|
|
292
|
+
client: MarketClient,
|
|
293
|
+
asset: ResolveResult['assets'][number],
|
|
294
|
+
opts: DownloadOptions,
|
|
295
|
+
): Promise<AssetFilesResult | null> {
|
|
296
|
+
try {
|
|
297
|
+
return await client.asset.files({
|
|
298
|
+
name: asset.name,
|
|
299
|
+
version: asset.version,
|
|
300
|
+
...(opts.key ? { key: opts.key } : {}),
|
|
301
|
+
})
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if ((error as { status?: unknown } | null)?.status === 404) return null
|
|
304
|
+
throw error
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function fetchVerifiedFile(
|
|
309
|
+
index: AssetFilesResult,
|
|
310
|
+
file: AssetFilesResult['files'][number],
|
|
311
|
+
): Promise<Uint8Array> {
|
|
312
|
+
const url = new URL(encodeSubpath(file.path), index.baseUrl)
|
|
313
|
+
if (index.key) url.searchParams.set('key', index.key)
|
|
314
|
+
const res = await fetch(url)
|
|
315
|
+
if (!res.ok) {
|
|
316
|
+
throw Object.assign(new Error(`file download responded ${res.status}`), { status: res.status })
|
|
317
|
+
}
|
|
318
|
+
const bytes = new Uint8Array(await res.arrayBuffer())
|
|
319
|
+
// A digest mismatch is a corrupt transfer; thrown without a status so withRetry treats it as
|
|
320
|
+
// transient and re-fetches.
|
|
321
|
+
const md5 = createHash('md5').update(bytes).digest('hex')
|
|
322
|
+
if (md5 !== file.etag) {
|
|
323
|
+
throw new Error(`md5 mismatch for ${file.path}: expected ${file.etag}, got ${md5}`)
|
|
324
|
+
}
|
|
325
|
+
return bytes
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function encodeSubpath(subpath: string): string {
|
|
329
|
+
return subpath.split('/').map(encodeURIComponent).join('/')
|
|
330
|
+
}
|
|
331
|
+
|
|
251
332
|
/** Fetch an asset's source-zip bytes. The CLI uses the Worker's streaming `/download` route so a
|
|
252
333
|
* large zip never has to fit in Worker memory. Library callers without a base URL use the typed
|
|
253
334
|
* client route. Both paths retry transient failures and surface a final failure unchanged. */
|
|
@@ -258,7 +339,7 @@ async function fetchAssetZipBytes(
|
|
|
258
339
|
): Promise<ArrayBuffer> {
|
|
259
340
|
const label = `${asset.name}@${asset.version}`
|
|
260
341
|
if (opts.baseUrl) {
|
|
261
|
-
const url = assetDownloadUrl(opts.baseUrl, asset)
|
|
342
|
+
const url = assetDownloadUrl(opts.baseUrl, asset, opts.key)
|
|
262
343
|
return withRetry(() => fetchZipFromUrl(url, opts.authToken), {
|
|
263
344
|
attempts: DOWNLOAD_ATTEMPTS,
|
|
264
345
|
onRetry: (msg) => opts.log(`Downloading ${label} (streaming): ${msg}`),
|
|
@@ -267,17 +348,26 @@ async function fetchAssetZipBytes(
|
|
|
267
348
|
return withRetry(
|
|
268
349
|
() =>
|
|
269
350
|
client.asset
|
|
270
|
-
.downloadZip({
|
|
351
|
+
.downloadZip({
|
|
352
|
+
name: asset.name,
|
|
353
|
+
version: asset.version,
|
|
354
|
+
...(opts.key ? { key: opts.key } : {}),
|
|
355
|
+
})
|
|
271
356
|
.then((blob) => blob.arrayBuffer()),
|
|
272
357
|
{ attempts: DOWNLOAD_ATTEMPTS, onRetry: (msg) => opts.log(`Downloading ${label}: ${msg}`) },
|
|
273
358
|
)
|
|
274
359
|
}
|
|
275
360
|
|
|
276
|
-
/** The Worker's streaming-download URL for an asset (`GET /download?name&version`). */
|
|
277
|
-
function assetDownloadUrl(
|
|
361
|
+
/** The Worker's streaming-download URL for an asset (`GET /download?name&version[&key]`). */
|
|
362
|
+
function assetDownloadUrl(
|
|
363
|
+
baseUrl: string,
|
|
364
|
+
asset: ResolveResult['assets'][number],
|
|
365
|
+
key?: string,
|
|
366
|
+
): string {
|
|
278
367
|
const url = new URL('/download', baseUrl)
|
|
279
368
|
url.searchParams.set('name', asset.name)
|
|
280
369
|
url.searchParams.set('version', asset.version)
|
|
370
|
+
if (key) url.searchParams.set('key', key)
|
|
281
371
|
return url.href
|
|
282
372
|
}
|
|
283
373
|
|
package/src/resolve.ts
CHANGED
|
@@ -60,7 +60,7 @@ export class ResolutionError extends Error {
|
|
|
60
60
|
export async function resolve(
|
|
61
61
|
client: MarketClient['asset'],
|
|
62
62
|
requests: AssetRequest[],
|
|
63
|
-
opts: { includeUnapproved?: boolean } = {},
|
|
63
|
+
opts: { includeUnapproved?: boolean; key?: string } = {},
|
|
64
64
|
): Promise<ResolveResult> {
|
|
65
65
|
const metaCache = new Map<string, AssetExactResult>()
|
|
66
66
|
const includeUnapproved = opts.includeUnapproved ?? false
|
|
@@ -76,7 +76,14 @@ export async function resolve(
|
|
|
76
76
|
}
|
|
77
77
|
seenPinSets.add(signature)
|
|
78
78
|
|
|
79
|
-
const result = await resolvePass(
|
|
79
|
+
const result = await resolvePass(
|
|
80
|
+
client,
|
|
81
|
+
requests,
|
|
82
|
+
exactPins,
|
|
83
|
+
includeUnapproved,
|
|
84
|
+
metaCache,
|
|
85
|
+
opts.key,
|
|
86
|
+
)
|
|
80
87
|
const nextPins = exactPinsFor(result.constraints)
|
|
81
88
|
if (!samePins(exactPins, nextPins)) {
|
|
82
89
|
exactPins = nextPins
|
|
@@ -103,6 +110,7 @@ async function resolvePass(
|
|
|
103
110
|
initialPins: ExactPins,
|
|
104
111
|
includeUnapproved: boolean,
|
|
105
112
|
metaCache: Map<string, AssetExactResult>,
|
|
113
|
+
key?: string,
|
|
106
114
|
): Promise<ResolutionPass> {
|
|
107
115
|
const constraints = rootConstraints(requests)
|
|
108
116
|
const resolved = new Map<string, ResolvedAsset>()
|
|
@@ -114,7 +122,7 @@ async function resolvePass(
|
|
|
114
122
|
|
|
115
123
|
const constrainedExact = requiredExactVersion(assetName, constraints.get(assetName) ?? [])
|
|
116
124
|
const exactVersion = constrainedExact ?? initialPins.get(assetName) ?? null
|
|
117
|
-
const meta = await fetchMeta(client, metaCache, assetName, exactVersion, includeUnapproved)
|
|
125
|
+
const meta = await fetchMeta(client, metaCache, assetName, exactVersion, includeUnapproved, key)
|
|
118
126
|
if (!meta) {
|
|
119
127
|
if (exactVersion) {
|
|
120
128
|
throw new ResolutionError(
|
|
@@ -252,14 +260,18 @@ async function fetchMeta(
|
|
|
252
260
|
name: string,
|
|
253
261
|
version: string | null,
|
|
254
262
|
includeUnapproved: boolean,
|
|
263
|
+
key?: string,
|
|
255
264
|
): Promise<AssetExactResult | null> {
|
|
256
265
|
const cacheKey = version ? `${name}@${version}` : name
|
|
257
266
|
const cached = cache.get(cacheKey)
|
|
258
267
|
if (cached) return cached
|
|
268
|
+
// The key is sent with every lookup in the tree; it only unlocks the asset it belongs to and is
|
|
269
|
+
// inert for the rest, so no per-asset routing is needed.
|
|
259
270
|
const meta = await client.exact({
|
|
260
271
|
name,
|
|
261
272
|
...(version ? { version } : {}),
|
|
262
273
|
includeUnapproved,
|
|
274
|
+
...(key ? { key } : {}),
|
|
263
275
|
})
|
|
264
276
|
if (meta) cache.set(cacheKey, meta)
|
|
265
277
|
return meta
|
package/src/schemas.ts
CHANGED
|
@@ -68,11 +68,17 @@ export const listAssetsSchema = z.object({
|
|
|
68
68
|
sort: z.enum(['newest', 'alphabetical', 'relevance']).default('newest'),
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
// An asset's capability key — the `?key=` suffix that unlocks a private (or unapproved) asset's
|
|
72
|
+
// files and preview on the data plane. Accepted by exact reads as an alternative credential to
|
|
73
|
+
// identity, so "sharing an asset" is sharing this one string.
|
|
74
|
+
export const accessKeySchema = z.string().min(1).max(128)
|
|
75
|
+
|
|
71
76
|
export const exactAssetSchema = z.object({
|
|
72
77
|
name: assetNameSchema,
|
|
73
78
|
type: assetTypeSchema.optional(),
|
|
74
79
|
version: semverSchema.optional(),
|
|
75
80
|
includeUnapproved: z.boolean().default(false),
|
|
81
|
+
key: accessKeySchema.optional(),
|
|
76
82
|
})
|
|
77
83
|
|
|
78
84
|
export const uploadZipSchema = z.object({
|
|
@@ -92,6 +98,15 @@ export const uploadZipSchema = z.object({
|
|
|
92
98
|
export const downloadZipSchema = z.object({
|
|
93
99
|
name: assetNameSchema,
|
|
94
100
|
version: semverSchema,
|
|
101
|
+
key: accessKeySchema.optional(),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Input of `asset.files`: the derived file index of a version, for per-file installs from the data
|
|
105
|
+
// plane. Same credential rules as downloadZip.
|
|
106
|
+
export const assetFilesSchema = z.object({
|
|
107
|
+
name: assetNameSchema,
|
|
108
|
+
version: semverSchema,
|
|
109
|
+
key: accessKeySchema.optional(),
|
|
95
110
|
})
|
|
96
111
|
|
|
97
112
|
export const fileManifestSchema = z.object({
|