@drawcall/market 0.3.0 → 0.5.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/dist/commands/list.d.ts +4 -2
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +1 -1
- package/dist/commands/list.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/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +2 -1
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +148 -35
- 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 +24 -2
- 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 +82 -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/resolve.d.ts +2 -2
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js.map +1 -1
- package/dist/schemas.d.ts +16 -14
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +9 -3
- package/dist/schemas.js.map +1 -1
- package/dist/v1/contract.d.ts +5 -5
- 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/commands/list.ts +8 -3
- package/src/commands/upload.ts +3 -1
- package/src/index.ts +4 -2
- package/src/install.ts +218 -47
- package/src/output.ts +33 -10
- package/src/pack.ts +125 -95
- package/src/project-walk.ts +95 -0
- package/src/resolve.ts +4 -3
- package/src/schemas.ts +24 -11
- package/src/v1/contract.ts +8 -6
package/src/install.ts
CHANGED
|
@@ -11,16 +11,23 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { execFile } from 'child_process'
|
|
14
|
-
import { createHash } from 'crypto'
|
|
15
14
|
import * as fs from 'fs/promises'
|
|
16
15
|
import * as path from 'path'
|
|
17
16
|
import { detectPackageManager, installDependencies } from 'nypm'
|
|
18
17
|
import type { MarketClient } from './client.js'
|
|
19
18
|
import type { AssetFilesResult, AssetInstallMetadata } from './contract.js'
|
|
20
19
|
import { withProjectInstallLock } from './install-lock.js'
|
|
20
|
+
import { md5, resolveDependencyLayout } from './pack.js'
|
|
21
|
+
import { findInstallRoot, walkWithGitignore } from './project-walk.js'
|
|
21
22
|
import { packageJsonAssetDependencies, parsePackageJson, type PackageJson } from './package-json.js'
|
|
22
23
|
import type { ResolveResult } from './resolve.js'
|
|
23
|
-
|
|
24
|
+
export { findInstallRoot }
|
|
25
|
+
import {
|
|
26
|
+
assetDependencyAlias,
|
|
27
|
+
assetDependencyRange,
|
|
28
|
+
assetDependencyValue,
|
|
29
|
+
type AssetDependencyAlias,
|
|
30
|
+
} from './schemas.js'
|
|
24
31
|
|
|
25
32
|
export interface InstallOptions {
|
|
26
33
|
/** Directory to start project root discovery from (default: cwd) */
|
|
@@ -72,14 +79,25 @@ interface DownloadResult {
|
|
|
72
79
|
assets: InstalledAsset[]
|
|
73
80
|
wrotePackageJson: boolean
|
|
74
81
|
warnings: string[]
|
|
82
|
+
/** Renames adopted during this install (per asset: canonical path → adopted project path). */
|
|
83
|
+
adoptedAliases: Record<string, Record<string, string>>
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
interface DownloadOptions {
|
|
78
87
|
force: boolean
|
|
79
88
|
log: (message: string) => void
|
|
80
89
|
key?: string
|
|
81
|
-
/** File aliases per asset name: canonical install path → the project's current path for it
|
|
82
|
-
|
|
90
|
+
/** File aliases per asset name: canonical install path → the project's current path for it,
|
|
91
|
+
* or `false` for a tombstone (the file is intentionally not installed). */
|
|
92
|
+
aliases: Record<string, AssetDependencyAlias>
|
|
93
|
+
/** md5 of every project file, computed at most once per install (rename adoption). */
|
|
94
|
+
projectHashes: () => Promise<Map<string, string>>
|
|
95
|
+
/**
|
|
96
|
+
* Etags across an asset's published versions — the "recognized bytes" set for content-aware
|
|
97
|
+
* updates. `null` when the server cannot answer: fail-open in the conservative direction
|
|
98
|
+
* (never overwrite on uncertainty).
|
|
99
|
+
*/
|
|
100
|
+
knownHashes: (name: string) => Promise<Set<string> | null>
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
export async function install(
|
|
@@ -97,6 +115,8 @@ export async function install(
|
|
|
97
115
|
log,
|
|
98
116
|
key: opts.key,
|
|
99
117
|
aliases,
|
|
118
|
+
projectHashes: memoizedProjectHashes(installRoot),
|
|
119
|
+
knownHashes: memoizedKnownHashes(client),
|
|
100
120
|
})
|
|
101
121
|
const integration = await withProjectInstallLock(
|
|
102
122
|
installRoot,
|
|
@@ -105,6 +125,7 @@ export async function install(
|
|
|
105
125
|
rootRequests: opts.rootRequests ?? [],
|
|
106
126
|
installMetadata: metadata,
|
|
107
127
|
packageManagerNeeded: download.wrotePackageJson,
|
|
128
|
+
adoptedAliases: download.adoptedAliases,
|
|
108
129
|
})
|
|
109
130
|
if (packageJsonUpdate.packageManagerNeeded) {
|
|
110
131
|
await (opts.runPackageManagerInstall ?? runPackageManagerInstall)(installRoot, log)
|
|
@@ -137,19 +158,6 @@ export async function install(
|
|
|
137
158
|
}
|
|
138
159
|
}
|
|
139
160
|
|
|
140
|
-
export async function findInstallRoot(cwd: string = process.cwd()): Promise<string> {
|
|
141
|
-
const start = path.resolve(cwd)
|
|
142
|
-
|
|
143
|
-
for (let dir = start; ; dir = path.dirname(dir)) {
|
|
144
|
-
if (await isFile(path.join(dir, 'package.json'))) {
|
|
145
|
-
return dir
|
|
146
|
-
}
|
|
147
|
-
if (path.dirname(dir) === dir) {
|
|
148
|
-
return start
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
161
|
/** Max asset downloads in flight at once. Assets write disjoint file trees, so downloading them
|
|
154
162
|
* concurrently is safe and turns a ~40-asset install from serial round-trips into a few batched
|
|
155
163
|
* waves. Kept modest so one install does not itself overwhelm the worker's per-request memory. */
|
|
@@ -175,6 +183,11 @@ async function downloadAssets(
|
|
|
175
183
|
// the package manager to bring its npm deps.
|
|
176
184
|
wrotePackageJson: perAsset.some((r) => r.wrotePackageJson),
|
|
177
185
|
warnings: perAsset.flatMap((r) => r.warnings),
|
|
186
|
+
adoptedAliases: Object.fromEntries(
|
|
187
|
+
perAsset
|
|
188
|
+
.filter((r) => Object.keys(r.adopted).length > 0)
|
|
189
|
+
.map((r) => [r.asset.name, r.adopted]),
|
|
190
|
+
),
|
|
178
191
|
}
|
|
179
192
|
}
|
|
180
193
|
|
|
@@ -183,53 +196,81 @@ async function downloadOneAsset(
|
|
|
183
196
|
asset: ResolveResult['assets'][number],
|
|
184
197
|
projectRoot: string,
|
|
185
198
|
opts: DownloadOptions,
|
|
186
|
-
): Promise<{
|
|
199
|
+
): Promise<{
|
|
200
|
+
asset: InstalledAsset
|
|
201
|
+
wrotePackageJson: boolean
|
|
202
|
+
warnings: string[]
|
|
203
|
+
adopted: Record<string, string>
|
|
204
|
+
}> {
|
|
187
205
|
opts.log(`Downloading ${asset.name}@${asset.version}...`)
|
|
188
206
|
|
|
189
207
|
// Only the network fetches are retried — downloads are the sole transient step; file writes are
|
|
190
208
|
// deterministic local work and a failure there is a real error, not worth re-attempting.
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
const installedFiles: string[] = []
|
|
194
|
-
const warnings: string[] = []
|
|
195
|
-
let wrotePackageJson = false
|
|
196
|
-
|
|
197
|
-
for (const [relativePath, content] of files) {
|
|
209
|
+
const entries = await fetchAssetEntries(client, asset, opts)
|
|
210
|
+
const planned = entries.flatMap((entry) => {
|
|
198
211
|
const normalizedPath = safeRelativePath(
|
|
199
|
-
|
|
200
|
-
() => `Zip contains an unsafe path: ${
|
|
212
|
+
entry.path,
|
|
213
|
+
() => `Zip contains an unsafe path: ${entry.path}`,
|
|
201
214
|
)
|
|
202
215
|
if (
|
|
203
216
|
normalizedPath === '.' ||
|
|
204
217
|
(normalizedPath === 'README.md' && asset.type !== 'template') ||
|
|
205
218
|
normalizedPath.endsWith('/')
|
|
206
219
|
) {
|
|
207
|
-
|
|
220
|
+
return []
|
|
208
221
|
}
|
|
222
|
+
return [{ ...entry, normalizedPath }]
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// Alias targets come from metadata; reject traversal before any path is stat'd or resolved —
|
|
226
|
+
// adoption must never quietly route around unsafe metadata.
|
|
227
|
+
const declaredAlias = opts.aliases[asset.name] ?? {}
|
|
228
|
+
for (const [canonical, target] of Object.entries(declaredAlias)) {
|
|
229
|
+
if (target === false) continue
|
|
230
|
+
safeRelativePath(
|
|
231
|
+
target,
|
|
232
|
+
() => `Alias for ${canonical} in ${asset.name} is an unsafe path: ${target}`,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
const adopted = await adoptRenamedFiles(planned, asset.name, declaredAlias, projectRoot, opts)
|
|
236
|
+
const alias = { ...declaredAlias, ...adopted }
|
|
237
|
+
|
|
238
|
+
const installedFiles: string[] = []
|
|
239
|
+
const warnings: string[] = []
|
|
240
|
+
let wrotePackageJson = false
|
|
209
241
|
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
242
|
+
for (const entry of planned) {
|
|
243
|
+
// An alias redirects the file to where this project keeps it (a rename recorded by pack, a
|
|
244
|
+
// dependent asset, or adoption above); `false` is a tombstone — a deliberate deletion that
|
|
245
|
+
// must not resurrect on reinstall. Alias targets pass the same traversal guard.
|
|
246
|
+
const aliased = alias[entry.normalizedPath]
|
|
247
|
+
if (aliased === false) continue
|
|
213
248
|
const targetPath =
|
|
214
249
|
aliased === undefined
|
|
215
|
-
? normalizedPath
|
|
250
|
+
? entry.normalizedPath
|
|
216
251
|
: safeRelativePath(
|
|
217
252
|
aliased,
|
|
218
|
-
() =>
|
|
253
|
+
() =>
|
|
254
|
+
`Alias for ${entry.normalizedPath} in ${asset.name} is an unsafe path: ${aliased}`,
|
|
219
255
|
)
|
|
220
256
|
|
|
221
257
|
const filePath = path.join(projectRoot, targetPath)
|
|
222
258
|
const existing = await maybeReadFile(filePath)
|
|
223
|
-
if (existing && !bytesEqual(existing,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
)
|
|
227
|
-
|
|
259
|
+
if (existing && !bytesEqual(existing, entry.bytes)) {
|
|
260
|
+
// Recognized bytes — a published version of this same dependency file — are replaceable
|
|
261
|
+
// without --force; unrecognized bytes are the user's and keep the skip-and-warn behavior.
|
|
262
|
+
const known = opts.force ? null : await opts.knownHashes(asset.name)
|
|
263
|
+
if (!opts.force && !known?.has(md5(existing))) {
|
|
264
|
+
warnings.push(
|
|
265
|
+
`Skipped ${targetPath} from ${asset.name}@${asset.version}; file already exists. Re-run with --force to overwrite.`,
|
|
266
|
+
)
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
228
269
|
}
|
|
229
270
|
|
|
230
|
-
if (!existing || !bytesEqual(existing,
|
|
271
|
+
if (!existing || !bytesEqual(existing, entry.bytes)) {
|
|
231
272
|
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
232
|
-
await fs.writeFile(filePath,
|
|
273
|
+
await fs.writeFile(filePath, entry.bytes)
|
|
233
274
|
if (targetPath === 'package.json') {
|
|
234
275
|
wrotePackageJson = true
|
|
235
276
|
}
|
|
@@ -249,6 +290,108 @@ async function downloadOneAsset(
|
|
|
249
290
|
},
|
|
250
291
|
wrotePackageJson,
|
|
251
292
|
warnings,
|
|
293
|
+
adopted,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
interface PlannedEntry {
|
|
298
|
+
normalizedPath: string
|
|
299
|
+
etag: string
|
|
300
|
+
bytes: Uint8Array
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Install-time adoption of unsynced renames: when a file's write target is absent but identical
|
|
305
|
+
* bytes already live elsewhere in the project, that location is used instead of creating a
|
|
306
|
+
* duplicate — an implicit `sync` before writing. Uses the same layout resolution as pack/sync
|
|
307
|
+
* (recorded alias → canonical → first match), so the selection rule stays identical everywhere.
|
|
308
|
+
* The project is only hashed when some target is actually absent; a reinstall with every file in
|
|
309
|
+
* place never scans.
|
|
310
|
+
*/
|
|
311
|
+
async function adoptRenamedFiles(
|
|
312
|
+
planned: PlannedEntry[],
|
|
313
|
+
name: string,
|
|
314
|
+
declaredAlias: AssetDependencyAlias,
|
|
315
|
+
projectRoot: string,
|
|
316
|
+
opts: DownloadOptions,
|
|
317
|
+
): Promise<Record<string, string>> {
|
|
318
|
+
const installable = planned.filter((entry) => declaredAlias[entry.normalizedPath] !== false)
|
|
319
|
+
const provisionalTargets = installable.map((entry) => {
|
|
320
|
+
const target = declaredAlias[entry.normalizedPath]
|
|
321
|
+
return typeof target === 'string' ? target : entry.normalizedPath
|
|
322
|
+
})
|
|
323
|
+
if (!(await anyPathMissing(projectRoot, provisionalTargets))) return {}
|
|
324
|
+
|
|
325
|
+
const layout = resolveDependencyLayout(
|
|
326
|
+
{ [name]: assetDependencyValue('*', declaredAlias) },
|
|
327
|
+
{
|
|
328
|
+
files: installable.map((entry) => ({
|
|
329
|
+
name,
|
|
330
|
+
canonicalPath: entry.normalizedPath,
|
|
331
|
+
hash: entry.etag,
|
|
332
|
+
})),
|
|
333
|
+
fetched: new Set([name]),
|
|
334
|
+
},
|
|
335
|
+
await opts.projectHashes(),
|
|
336
|
+
)
|
|
337
|
+
const resolved = assetDependencyAlias(layout.assetDependencies[name])
|
|
338
|
+
return Object.fromEntries(
|
|
339
|
+
Object.entries(resolved).filter(
|
|
340
|
+
(pair): pair is [string, string] =>
|
|
341
|
+
typeof pair[1] === 'string' && declaredAlias[pair[0]] !== pair[1],
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function anyPathMissing(projectRoot: string, targets: string[]): Promise<boolean> {
|
|
347
|
+
for (const target of targets) {
|
|
348
|
+
if (!(await isFile(path.join(projectRoot, target)))) return true
|
|
349
|
+
}
|
|
350
|
+
return false
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** md5 of every project file (gitignored trees excluded), computed at most once per install. */
|
|
354
|
+
function memoizedProjectHashes(projectRoot: string): () => Promise<Map<string, string>> {
|
|
355
|
+
let cached: Promise<Map<string, string>> | undefined
|
|
356
|
+
return () =>
|
|
357
|
+
(cached ??= (async () => {
|
|
358
|
+
const walk = await walkWithGitignore(projectRoot)
|
|
359
|
+
const hashes = new Map<string, string>()
|
|
360
|
+
for (const relative of walk.kept) {
|
|
361
|
+
hashes.set(
|
|
362
|
+
relative,
|
|
363
|
+
md5(new Uint8Array(await fs.readFile(path.join(projectRoot, relative)))),
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
return hashes
|
|
367
|
+
})())
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Newest published versions contributing to the recognized-bytes set (same bound as pack). */
|
|
371
|
+
const KNOWN_HASH_VERSION_CAP = 20
|
|
372
|
+
|
|
373
|
+
function memoizedKnownHashes(client: MarketClient): (name: string) => Promise<Set<string> | null> {
|
|
374
|
+
const cache = new Map<string, Promise<Set<string> | null>>()
|
|
375
|
+
return (name) => {
|
|
376
|
+
const hit = cache.get(name)
|
|
377
|
+
if (hit) return hit
|
|
378
|
+
const result = (async () => {
|
|
379
|
+
try {
|
|
380
|
+
const { versions } = await client.asset.versions({ name })
|
|
381
|
+
const recent = versions.map((entry) => entry.version).slice(-KNOWN_HASH_VERSION_CAP)
|
|
382
|
+
const etags = await Promise.all(
|
|
383
|
+
recent.map(async (version) =>
|
|
384
|
+
(await client.asset.files({ name, version })).files.map((file) => file.etag),
|
|
385
|
+
),
|
|
386
|
+
)
|
|
387
|
+
return new Set(etags.flat())
|
|
388
|
+
} catch {
|
|
389
|
+
// Offline or not visible — the conservative fail-open: the caller keeps skip-and-warn.
|
|
390
|
+
return null
|
|
391
|
+
}
|
|
392
|
+
})()
|
|
393
|
+
cache.set(name, result)
|
|
394
|
+
return result
|
|
252
395
|
}
|
|
253
396
|
}
|
|
254
397
|
|
|
@@ -273,8 +416,8 @@ function safeRelativePath(candidate: string, message: () => string): string {
|
|
|
273
416
|
async function projectAssetAliases(
|
|
274
417
|
projectRoot: string,
|
|
275
418
|
resolution: ResolveResult,
|
|
276
|
-
): Promise<Record<string,
|
|
277
|
-
const merged: Record<string,
|
|
419
|
+
): Promise<Record<string, AssetDependencyAlias>> {
|
|
420
|
+
const merged: Record<string, AssetDependencyAlias> = {}
|
|
278
421
|
for (const [name, alias] of Object.entries(resolution.assetAliases ?? {})) {
|
|
279
422
|
merged[name] = { ...alias }
|
|
280
423
|
}
|
|
@@ -304,7 +447,7 @@ async function fetchAssetEntries(
|
|
|
304
447
|
client: MarketClient,
|
|
305
448
|
asset: ResolveResult['assets'][number],
|
|
306
449
|
opts: DownloadOptions,
|
|
307
|
-
): Promise<Array<
|
|
450
|
+
): Promise<Array<{ path: string; etag: string; bytes: Uint8Array }>> {
|
|
308
451
|
const index = await client.asset.files({
|
|
309
452
|
name: asset.name,
|
|
310
453
|
version: asset.version,
|
|
@@ -317,7 +460,7 @@ async function fetchAssetEntries(
|
|
|
317
460
|
attempts: DOWNLOAD_ATTEMPTS,
|
|
318
461
|
onRetry: (msg) => opts.log(`Downloading ${label} ${file.path}: ${msg}`),
|
|
319
462
|
})
|
|
320
|
-
return
|
|
463
|
+
return { path: file.path, etag: file.etag, bytes }
|
|
321
464
|
})
|
|
322
465
|
}
|
|
323
466
|
|
|
@@ -329,9 +472,9 @@ async function fetchVerifiedFile(file: AssetFilesResult['files'][number]): Promi
|
|
|
329
472
|
const bytes = new Uint8Array(await res.arrayBuffer())
|
|
330
473
|
// A digest mismatch is a corrupt transfer; thrown without a status so withRetry treats it as
|
|
331
474
|
// transient and re-fetches.
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
334
|
-
throw new Error(`md5 mismatch for ${file.path}: expected ${file.etag}, got ${
|
|
475
|
+
const digest = md5(bytes)
|
|
476
|
+
if (digest !== file.etag) {
|
|
477
|
+
throw new Error(`md5 mismatch for ${file.path}: expected ${file.etag}, got ${digest}`)
|
|
335
478
|
}
|
|
336
479
|
return bytes
|
|
337
480
|
}
|
|
@@ -424,6 +567,7 @@ async function updatePackageJson(
|
|
|
424
567
|
rootRequests: InstallRootRequest[]
|
|
425
568
|
installMetadata: Record<string, AssetInstallMetadata>
|
|
426
569
|
packageManagerNeeded: boolean
|
|
570
|
+
adoptedAliases: Record<string, Record<string, string>>
|
|
427
571
|
},
|
|
428
572
|
): Promise<{ packageManagerNeeded: boolean }> {
|
|
429
573
|
const pkgPath = path.join(projectRoot, 'package.json')
|
|
@@ -448,6 +592,9 @@ async function updatePackageJson(
|
|
|
448
592
|
if (mergeAssetDependencyRanges(pkg, assetDependencies)) {
|
|
449
593
|
changed = true
|
|
450
594
|
}
|
|
595
|
+
if (mergeAdoptedAliases(pkg, opts.adoptedAliases)) {
|
|
596
|
+
changed = true
|
|
597
|
+
}
|
|
451
598
|
|
|
452
599
|
if (changed) {
|
|
453
600
|
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
@@ -456,6 +603,30 @@ async function updatePackageJson(
|
|
|
456
603
|
return { packageManagerNeeded }
|
|
457
604
|
}
|
|
458
605
|
|
|
606
|
+
/** Record renames adopted during this install on the entries package.json already declares —
|
|
607
|
+
* adoption is thereby persisted, not just applied. Undeclared (transitive) deps stay out. */
|
|
608
|
+
function mergeAdoptedAliases(
|
|
609
|
+
pkg: PackageJson,
|
|
610
|
+
adopted: Record<string, Record<string, string>>,
|
|
611
|
+
): boolean {
|
|
612
|
+
const current = pkg.assetDependencies
|
|
613
|
+
if (!current) return false
|
|
614
|
+
|
|
615
|
+
let changed = false
|
|
616
|
+
for (const [name, alias] of Object.entries(adopted)) {
|
|
617
|
+
const existing = current[name]
|
|
618
|
+
if (existing === undefined) continue
|
|
619
|
+
const next = assetDependencyValue(assetDependencyRange(existing), {
|
|
620
|
+
...assetDependencyAlias(existing),
|
|
621
|
+
...alias,
|
|
622
|
+
})
|
|
623
|
+
if (JSON.stringify(existing) === JSON.stringify(next)) continue
|
|
624
|
+
current[name] = next
|
|
625
|
+
changed = true
|
|
626
|
+
}
|
|
627
|
+
return changed
|
|
628
|
+
}
|
|
629
|
+
|
|
459
630
|
async function runPackageManagerInstall(projectRoot: string, log: (msg: string) => void) {
|
|
460
631
|
log('Installing npm dependencies...')
|
|
461
632
|
const pm = await detectPackageManager(projectRoot)
|
package/src/output.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { AssetInstallMetadata, AssetSearchResult } from './contract.js'
|
|
|
2
2
|
import type { ListInstalledAssetsResult } from './commands/list.js'
|
|
3
3
|
import type { InstallResult } from './install.js'
|
|
4
4
|
import type { AssetDependencySync, PackedAsset } from './pack.js'
|
|
5
|
-
import { assetDependencyAlias, assetDependencyRange } from './schemas.js'
|
|
5
|
+
import { assetDependencyAlias, assetDependencyRange, type AssetDependencyValue } from './schemas.js'
|
|
6
6
|
|
|
7
7
|
export function assetVersionRef(name: string, version?: string): string {
|
|
8
8
|
return version ? `${name}@${version}` : name
|
|
@@ -90,7 +90,7 @@ export function listResult(result: ListInstalledAssetsResult): string {
|
|
|
90
90
|
for (const asset of result.assets) {
|
|
91
91
|
lines.push(`- ${assetVersionRef(asset.name, asset.range)}`)
|
|
92
92
|
const aliases = Object.entries(asset.alias).sort(([a], [b]) => a.localeCompare(b))
|
|
93
|
-
lines.push(...aliases.map(([canonical, current]) => ` ${canonical
|
|
93
|
+
lines.push(...aliases.map(([canonical, current]) => ` ${aliasLine(canonical, current)}`))
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
return lines.join('\n')
|
|
@@ -198,7 +198,7 @@ export function syncResult(result: AssetDependencySync): string {
|
|
|
198
198
|
const aliases = Object.entries(assetDependencyAlias(value)).sort(([a], [b]) =>
|
|
199
199
|
a.localeCompare(b),
|
|
200
200
|
)
|
|
201
|
-
lines.push(...aliases.map(([canonical, current]) => ` ${canonical
|
|
201
|
+
lines.push(...aliases.map(([canonical, current]) => ` ${aliasLine(canonical, current)}`))
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
if (result.missing.length > 0) {
|
|
@@ -219,12 +219,40 @@ export function packResult(out: string, packed: PackedAsset): string {
|
|
|
219
219
|
if (packed.omittedUnchangedInstalledFiles) {
|
|
220
220
|
lines.push('Omitted unchanged installed dependency files.')
|
|
221
221
|
}
|
|
222
|
+
lines.push(...packWarnings(packed))
|
|
222
223
|
lines.push(...dependencyLines('npm dependencies', packed.npmDependencies))
|
|
223
224
|
lines.push(...dependencyLines('asset dependencies', packed.assetDependencies))
|
|
224
225
|
lines.push(...dependencyLines('skill dependencies', packed.skillDependencies))
|
|
225
226
|
return lines.join('\n')
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
/**
|
|
230
|
+
* What pack could not check — the silent-fail-open cases with real consequences: an unfetchable
|
|
231
|
+
* dependency ships its files inside the zip, and a missing dependency file means a local edit or
|
|
232
|
+
* deletion the packer should know about.
|
|
233
|
+
*/
|
|
234
|
+
export function packWarnings(packed: PackedAsset): string[] {
|
|
235
|
+
const lines: string[] = []
|
|
236
|
+
if (packed.skippedDependencies.length > 0) {
|
|
237
|
+
lines.push(
|
|
238
|
+
`Dependency file indexes unavailable (their files ship in the zip): ${[...packed.skippedDependencies].sort().join(', ')}`,
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
if (packed.missingDependencyFiles.length > 0) {
|
|
242
|
+
lines.push('Dependency files not found (locally modified or deleted):')
|
|
243
|
+
lines.push(
|
|
244
|
+
...packed.missingDependencyFiles.map((file) => `- ${file.name}: ${file.path}`).sort(),
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
return lines
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function aliasLine(canonical: string, current: string | false): string {
|
|
251
|
+
return current === false
|
|
252
|
+
? `${canonical} -> (tombstoned: not installed)`
|
|
253
|
+
: `${canonical} -> ${current}`
|
|
254
|
+
}
|
|
255
|
+
|
|
228
256
|
export function previewResult(name: string, version: string, out: string): string {
|
|
229
257
|
return `Saved preview for ${assetVersionRef(name, version)}: ${out}`
|
|
230
258
|
}
|
|
@@ -254,10 +282,7 @@ function unique<T>(values: T[]): T[] {
|
|
|
254
282
|
return [...new Set(values)]
|
|
255
283
|
}
|
|
256
284
|
|
|
257
|
-
function dependencyLines(
|
|
258
|
-
label: string,
|
|
259
|
-
deps: Record<string, string | { version: string; alias?: Record<string, string> }>,
|
|
260
|
-
): string[] {
|
|
285
|
+
function dependencyLines(label: string, deps: Record<string, AssetDependencyValue>): string[] {
|
|
261
286
|
const entries = Object.entries(deps)
|
|
262
287
|
if (entries.length === 0) return []
|
|
263
288
|
return [
|
|
@@ -268,9 +293,7 @@ function dependencyLines(
|
|
|
268
293
|
]
|
|
269
294
|
}
|
|
270
295
|
|
|
271
|
-
function dependencyValueLabel(
|
|
272
|
-
value: string | { version: string; alias?: Record<string, string> },
|
|
273
|
-
): string {
|
|
296
|
+
function dependencyValueLabel(value: AssetDependencyValue): string {
|
|
274
297
|
if (typeof value === 'string') return value
|
|
275
298
|
const aliased = Object.keys(value.alias ?? {}).length
|
|
276
299
|
return aliased > 0 ? `${value.version} (${aliased} aliased file(s))` : value.version
|