@drawcall/market 0.1.99 → 0.2.2
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 +2 -2
- package/dist/asset-implementation.d.ts.map +1 -1
- package/dist/cli.js +15 -3
- package/dist/cli.js.map +1 -1
- package/dist/commands/install.js +10 -7
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/list.d.ts +5 -5
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +22 -9
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/pack.d.ts.map +1 -1
- package/dist/commands/pack.js +2 -2
- package/dist/commands/pack.js.map +1 -1
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +14 -2
- package/dist/commands/search.js.map +1 -1
- package/dist/commands/sync.d.ts +6 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +22 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/upload.d.ts.map +1 -1
- package/dist/commands/upload.js +12 -7
- package/dist/commands/upload.js.map +1 -1
- package/dist/contract.d.ts +4 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +69 -27
- package/dist/install.js.map +1 -1
- package/dist/output.d.ts +3 -4
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +40 -7
- package/dist/output.js.map +1 -1
- package/dist/pack.d.ts +36 -6
- package/dist/pack.d.ts.map +1 -1
- package/dist/pack.js +230 -57
- package/dist/pack.js.map +1 -1
- package/dist/package-json.d.ts +4 -3
- package/dist/package-json.d.ts.map +1 -1
- package/dist/package-json.js +3 -1
- package/dist/package-json.js.map +1 -1
- package/dist/resolve.d.ts +8 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +35 -3
- package/dist/resolve.js.map +1 -1
- package/dist/schemas.d.ts +19 -2
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +21 -1
- package/dist/schemas.js.map +1 -1
- package/dist/skill.d.ts +1 -1
- package/dist/skill.d.ts.map +1 -1
- package/dist/skill.js +4 -2
- package/dist/skill.js.map +1 -1
- package/dist/v1/client.d.ts +15 -0
- package/dist/v1/client.d.ts.map +1 -0
- package/dist/v1/client.js +18 -0
- package/dist/v1/client.js.map +1 -0
- package/dist/v1/contract.d.ts +206 -0
- package/dist/v1/contract.d.ts.map +1 -0
- package/dist/v1/contract.js +77 -0
- package/dist/v1/contract.js.map +1 -0
- package/dist/v1/index.d.ts +3 -0
- package/dist/v1/index.d.ts.map +1 -0
- package/dist/v1/index.js +3 -0
- package/dist/v1/index.js.map +1 -0
- package/package.json +2 -1
- package/skills/market/SKILL.md +4 -2
- package/src/asset-implementation.ts +2 -2
- package/src/cli.ts +20 -4
- package/src/commands/install.ts +11 -8
- package/src/commands/list.ts +28 -14
- package/src/commands/pack.ts +7 -2
- package/src/commands/search.ts +21 -2
- package/src/commands/sync.ts +30 -0
- package/src/commands/upload.ts +18 -8
- package/src/index.ts +11 -0
- package/src/install.ts +89 -35
- package/src/output.ts +52 -11
- package/src/pack.ts +317 -70
- package/src/package-json.ts +7 -4
- package/src/resolve.ts +47 -4
- package/src/schemas.ts +31 -1
- package/src/skill.ts +4 -2
- package/src/v1/client.ts +28 -0
- package/src/v1/contract.ts +140 -0
- package/src/v1/index.ts +2 -0
- package/dist/market-lock.d.ts +0 -16
- package/dist/market-lock.d.ts.map +0 -1
- package/dist/market-lock.js +0 -48
- package/dist/market-lock.js.map +0 -1
- package/src/market-lock.ts +0 -68
package/src/output.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
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
|
-
import type { PackedAsset } from './pack.js'
|
|
4
|
+
import type { AssetDependencySync, PackedAsset } from './pack.js'
|
|
5
|
+
import { assetDependencyAlias, assetDependencyRange } from './schemas.js'
|
|
5
6
|
|
|
6
7
|
export function assetVersionRef(name: string, version?: string): string {
|
|
7
8
|
return version ? `${name}@${version}` : name
|
|
@@ -80,18 +81,16 @@ export function installResult(
|
|
|
80
81
|
return lines.join('\n')
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
export function listResult(
|
|
84
|
-
result: ListInstalledAssetsResult,
|
|
85
|
-
opts: { files?: boolean } = {},
|
|
86
|
-
): string {
|
|
84
|
+
export function listResult(result: ListInstalledAssetsResult): string {
|
|
87
85
|
if (result.assets.length === 0) {
|
|
88
|
-
return 'No
|
|
86
|
+
return 'No asset dependencies declared in package.json.'
|
|
89
87
|
}
|
|
90
88
|
|
|
91
|
-
const lines = [`
|
|
89
|
+
const lines = [`Asset dependencies: ${result.assets.length}`]
|
|
92
90
|
for (const asset of result.assets) {
|
|
93
|
-
lines.push(
|
|
94
|
-
|
|
91
|
+
lines.push(`- ${assetVersionRef(asset.name, asset.range)}`)
|
|
92
|
+
const aliases = Object.entries(asset.alias).sort(([a], [b]) => a.localeCompare(b))
|
|
93
|
+
lines.push(...aliases.map(([canonical, current]) => ` ${canonical} -> ${current}`))
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
return lines.join('\n')
|
|
@@ -183,6 +182,35 @@ export function unchangedUploadResult(name: string, version: string): string {
|
|
|
183
182
|
return `No upload needed: ${assetVersionRef(name, version)} is unchanged`
|
|
184
183
|
}
|
|
185
184
|
|
|
185
|
+
export function syncResult(result: AssetDependencySync): string {
|
|
186
|
+
const entries = Object.entries(result.assetDependencies)
|
|
187
|
+
if (entries.length === 0) {
|
|
188
|
+
return 'No asset dependencies declared in package.json.'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const lines = [
|
|
192
|
+
result.changed
|
|
193
|
+
? 'Updated assetDependencies in package.json:'
|
|
194
|
+
: 'assetDependencies already up to date:',
|
|
195
|
+
]
|
|
196
|
+
for (const [name, value] of entries.sort(([a], [b]) => a.localeCompare(b))) {
|
|
197
|
+
lines.push(`- ${assetVersionRef(name, assetDependencyRange(value))}`)
|
|
198
|
+
const aliases = Object.entries(assetDependencyAlias(value)).sort(([a], [b]) =>
|
|
199
|
+
a.localeCompare(b),
|
|
200
|
+
)
|
|
201
|
+
lines.push(...aliases.map(([canonical, current]) => ` ${canonical} -> ${current}`))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (result.missing.length > 0) {
|
|
205
|
+
lines.push('Dependency files not found (locally modified or deleted):')
|
|
206
|
+
lines.push(...result.missing.map((file) => `- ${file.name}: ${file.path}`))
|
|
207
|
+
}
|
|
208
|
+
if (result.skipped.length > 0) {
|
|
209
|
+
lines.push(`Skipped (manifest unavailable): ${[...result.skipped].sort().join(', ')}`)
|
|
210
|
+
}
|
|
211
|
+
return lines.join('\n')
|
|
212
|
+
}
|
|
213
|
+
|
|
186
214
|
export function packResult(out: string, packed: PackedAsset): string {
|
|
187
215
|
const lines = [`Packed: ${out}`]
|
|
188
216
|
if (packed.gitignoredFiles > 0) {
|
|
@@ -226,11 +254,24 @@ function unique<T>(values: T[]): T[] {
|
|
|
226
254
|
return [...new Set(values)]
|
|
227
255
|
}
|
|
228
256
|
|
|
229
|
-
function dependencyLines(
|
|
257
|
+
function dependencyLines(
|
|
258
|
+
label: string,
|
|
259
|
+
deps: Record<string, string | { version: string; alias?: Record<string, string> }>,
|
|
260
|
+
): string[] {
|
|
230
261
|
const entries = Object.entries(deps)
|
|
231
262
|
if (entries.length === 0) return []
|
|
232
263
|
return [
|
|
233
264
|
`${label}:`,
|
|
234
|
-
...entries
|
|
265
|
+
...entries
|
|
266
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
267
|
+
.map(([name, value]) => `- ${name}: ${dependencyValueLabel(value)}`),
|
|
235
268
|
]
|
|
236
269
|
}
|
|
270
|
+
|
|
271
|
+
function dependencyValueLabel(
|
|
272
|
+
value: string | { version: string; alias?: Record<string, string> },
|
|
273
|
+
): string {
|
|
274
|
+
if (typeof value === 'string') return value
|
|
275
|
+
const aliased = Object.keys(value.alias ?? {}).length
|
|
276
|
+
return aliased > 0 ? `${value.version} (${aliased} aliased file(s))` : value.version
|
|
277
|
+
}
|
package/src/pack.ts
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
|
+
import { createHash } from 'crypto'
|
|
1
2
|
import * as fs from 'fs/promises'
|
|
2
3
|
import * as path from 'path'
|
|
3
4
|
import { unzipSync, zipSync } from 'fflate'
|
|
4
5
|
import ignore from 'ignore'
|
|
6
|
+
import type { MarketClient } from './client.js'
|
|
5
7
|
import type { AssetInstallMetadata } from './contract.js'
|
|
6
8
|
import { findInstallRoot } from './install.js'
|
|
7
|
-
import { readMarketLock, sha256 } from './market-lock.js'
|
|
8
9
|
import {
|
|
10
|
+
packageJsonAssetDependencies,
|
|
9
11
|
packageJsonAssetDependenciesFromFiles,
|
|
10
12
|
packageJsonNpmDependenciesFromFiles,
|
|
13
|
+
parsePackageJson,
|
|
11
14
|
} from './package-json.js'
|
|
12
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
MAX_UPLOAD_ZIP_SIZE_BYTES,
|
|
17
|
+
assetDependencyAlias,
|
|
18
|
+
assetDependencyRange,
|
|
19
|
+
assetDependencyValue,
|
|
20
|
+
semverSchema,
|
|
21
|
+
type AssetDependencies,
|
|
22
|
+
type AssetType,
|
|
23
|
+
} from './schemas.js'
|
|
13
24
|
|
|
14
25
|
export interface PackDependencySpecs {
|
|
15
26
|
npm?: string[]
|
|
@@ -28,16 +39,17 @@ export interface PackPolicy {
|
|
|
28
39
|
omitUnchangedInstalledFiles: boolean
|
|
29
40
|
}
|
|
30
41
|
|
|
31
|
-
export type FetchFileManifest = (name: string,
|
|
42
|
+
export type FetchFileManifest = (name: string, range: string) => Promise<Record<string, string>>
|
|
32
43
|
|
|
33
44
|
export interface PackAssetOptions {
|
|
34
45
|
cwd?: string
|
|
35
46
|
dependencies: ParsedPackDependencies
|
|
36
47
|
policy?: PackPolicy
|
|
37
48
|
/**
|
|
38
|
-
* Fetch the server's content hashes for
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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.
|
|
41
53
|
*/
|
|
42
54
|
fetchFileManifest?: FetchFileManifest
|
|
43
55
|
}
|
|
@@ -46,13 +58,41 @@ export interface PackedAsset {
|
|
|
46
58
|
sourcePath: string
|
|
47
59
|
zip: Uint8Array
|
|
48
60
|
npmDependencies: Record<string, string>
|
|
49
|
-
assetDependencies:
|
|
61
|
+
assetDependencies: AssetDependencies
|
|
50
62
|
skillDependencies: Record<string, string>
|
|
51
63
|
omittedUnchangedInstalledFiles: boolean
|
|
52
64
|
/** Number of zip entries dropped because a `.gitignore` inside the zip excluded them. */
|
|
53
65
|
gitignoredFiles: number
|
|
54
66
|
}
|
|
55
67
|
|
|
68
|
+
export function sha256(bytes: Uint8Array): string {
|
|
69
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
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.
|
|
77
|
+
*/
|
|
78
|
+
export function fileManifestForRange(asset: MarketClient['asset']): FetchFileManifest {
|
|
79
|
+
return async (name, range) => {
|
|
80
|
+
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 })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function exactRangeVersion(range: string): string | null {
|
|
92
|
+
const candidate = range.trim().replace(/^=/u, '')
|
|
93
|
+
return semverSchema.safeParse(candidate).success ? candidate : null
|
|
94
|
+
}
|
|
95
|
+
|
|
56
96
|
export async function packAsset(zipFilter: string, opts: PackAssetOptions): Promise<PackedAsset> {
|
|
57
97
|
const cwd = opts.cwd ?? process.cwd()
|
|
58
98
|
|
|
@@ -85,17 +125,21 @@ export async function packAsset(zipFilter: string, opts: PackAssetOptions): Prom
|
|
|
85
125
|
const npmDependencies = { ...packageJsonNpmDependencies, ...opts.dependencies.npmDependencies }
|
|
86
126
|
|
|
87
127
|
// Drop anything the zip's own .gitignore excludes (node_modules, build output, secrets…), then —
|
|
88
|
-
// for templates — omit
|
|
89
|
-
//
|
|
128
|
+
// for templates — omit each installed dependency file at its resolved location (matched by
|
|
129
|
+
// content hash, so renamed copies are dropped too and recorded as aliases; duplicates ship).
|
|
130
|
+
// Only removals happen, so a smaller count means something was dropped; re-zip only then.
|
|
90
131
|
const { kept: withoutIgnored, gitignored } = applyGitignore(sourceFiles)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
132
|
+
let files = withoutIgnored
|
|
133
|
+
let finalAssetDependencies = assetDependencies
|
|
134
|
+
if (policy.omitUnchangedInstalledFiles) {
|
|
135
|
+
const omitted = await withoutInstalledDependencyFiles(
|
|
136
|
+
withoutIgnored,
|
|
137
|
+
assetDependencies,
|
|
138
|
+
opts.fetchFileManifest,
|
|
139
|
+
)
|
|
140
|
+
files = omitted.kept
|
|
141
|
+
finalAssetDependencies = omitted.assetDependencies
|
|
142
|
+
}
|
|
99
143
|
|
|
100
144
|
const originalCount = Object.keys(sourceFiles).length
|
|
101
145
|
const afterIgnoreCount = Object.keys(withoutIgnored).length
|
|
@@ -105,7 +149,7 @@ export async function packAsset(zipFilter: string, opts: PackAssetOptions): Prom
|
|
|
105
149
|
sourcePath: zipFile,
|
|
106
150
|
zip: finalCount < originalCount ? zipSync(files) : sourceZip,
|
|
107
151
|
npmDependencies,
|
|
108
|
-
assetDependencies,
|
|
152
|
+
assetDependencies: finalAssetDependencies,
|
|
109
153
|
skillDependencies: opts.dependencies.skillDependencies,
|
|
110
154
|
omittedUnchangedInstalledFiles: finalCount < afterIgnoreCount,
|
|
111
155
|
gitignoredFiles: gitignored,
|
|
@@ -113,8 +157,11 @@ export async function packAsset(zipFilter: string, opts: PackAssetOptions): Prom
|
|
|
113
157
|
}
|
|
114
158
|
|
|
115
159
|
// Pack straight from a project directory. Walk order matters for speed: `.gitignore` rules prune
|
|
116
|
-
// whole subtrees before anything is read,
|
|
117
|
-
//
|
|
160
|
+
// whole subtrees before anything is read, and only the survivors are ever held in memory /
|
|
161
|
+
// compressed. Installed dependency files are matched by content hash — independent of their path —
|
|
162
|
+
// so renamed copies are omitted too (exactly one per dependency file; duplicates ship). The
|
|
163
|
+
// resolved location is recorded as an alias on the dependency entry and written back to
|
|
164
|
+
// package.json, which is how a later install restores the renamed layout.
|
|
118
165
|
async function packDirectory(dir: string, opts: PackAssetOptions): Promise<PackedAsset> {
|
|
119
166
|
const hasRootPackageJson = Boolean(await maybeStat(path.join(dir, 'package.json')))
|
|
120
167
|
const policy = opts.policy ?? {
|
|
@@ -143,24 +190,43 @@ async function packDirectory(dir: string, opts: PackAssetOptions): Promise<Packe
|
|
|
143
190
|
...opts.dependencies.npmDependencies,
|
|
144
191
|
}
|
|
145
192
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
: new Map<string, string>()
|
|
193
|
+
const index = policy.omitUnchangedInstalledFiles
|
|
194
|
+
? await dependencyFileIndex(assetDependencies, opts.fetchFileManifest)
|
|
195
|
+
: emptyDependencyFileIndex()
|
|
196
|
+
const dependencyHashes = new Set(index.files.map((file) => file.hash))
|
|
151
197
|
|
|
198
|
+
// Files whose bytes match a dependency file are deferred: whether one is omitted or ships (a
|
|
199
|
+
// duplicate copy) is only known once the whole tree is hashed, and deferring keeps dependency
|
|
200
|
+
// bytes out of memory during the walk.
|
|
152
201
|
const files: Record<string, Uint8Array> = {}
|
|
153
|
-
|
|
202
|
+
const hashesByPath = new Map<string, string>()
|
|
203
|
+
const deferred: string[] = []
|
|
154
204
|
for (const relative of walk.kept) {
|
|
155
205
|
const content = new Uint8Array(await fs.readFile(path.join(dir, relative)))
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
206
|
+
if (dependencyHashes.size === 0) {
|
|
207
|
+
files[relative] = content
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
const hash = sha256(content)
|
|
211
|
+
hashesByPath.set(relative, hash)
|
|
212
|
+
if (dependencyHashes.has(hash)) {
|
|
213
|
+
deferred.push(relative)
|
|
159
214
|
continue
|
|
160
215
|
}
|
|
161
216
|
files[relative] = content
|
|
162
217
|
}
|
|
163
218
|
|
|
219
|
+
const layout = resolveDependencyLayout(assetDependencies, index, hashesByPath)
|
|
220
|
+
for (const relative of deferred) {
|
|
221
|
+
if (layout.omit.has(relative)) continue
|
|
222
|
+
files[relative] = new Uint8Array(await fs.readFile(path.join(dir, relative)))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (policy.readPackageJsonDependencies && hasRootPackageJson) {
|
|
226
|
+
const rewritten = await writeBackAssetDependencies(dir, layout.assetDependencies)
|
|
227
|
+
if (rewritten && files['package.json']) files['package.json'] = rewritten
|
|
228
|
+
}
|
|
229
|
+
|
|
164
230
|
const zip = zipSync(files)
|
|
165
231
|
if (zip.byteLength >= MAX_UPLOAD_ZIP_SIZE_BYTES) {
|
|
166
232
|
throw new Error('Packed zip must be smaller than 1 GB')
|
|
@@ -170,9 +236,9 @@ async function packDirectory(dir: string, opts: PackAssetOptions): Promise<Packe
|
|
|
170
236
|
sourcePath: dir,
|
|
171
237
|
zip,
|
|
172
238
|
npmDependencies,
|
|
173
|
-
assetDependencies,
|
|
239
|
+
assetDependencies: layout.assetDependencies,
|
|
174
240
|
skillDependencies: opts.dependencies.skillDependencies,
|
|
175
|
-
omittedUnchangedInstalledFiles:
|
|
241
|
+
omittedUnchangedInstalledFiles: layout.omit.size > 0,
|
|
176
242
|
gitignoredFiles: walk.ignored,
|
|
177
243
|
}
|
|
178
244
|
}
|
|
@@ -220,8 +286,9 @@ function toPosix(p: string): string {
|
|
|
220
286
|
const GITIGNORE_FILENAME = '.gitignore'
|
|
221
287
|
|
|
222
288
|
// Directories that never belong in a published asset — VCS/tooling metadata, reinstallable
|
|
223
|
-
// dependencies, and the market's own
|
|
224
|
-
// in every pack path, regardless of any `.gitignore`, so a re-uploaded asset never
|
|
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.
|
|
225
292
|
const ALWAYS_IGNORED_DIRS = new Set(['.git', 'node_modules', '.drawcall'])
|
|
226
293
|
|
|
227
294
|
function hasAlwaysIgnoredSegment(posixPath: string): boolean {
|
|
@@ -350,17 +417,21 @@ export function parseSkillDeps(specs: string[]): Record<string, string> {
|
|
|
350
417
|
}
|
|
351
418
|
|
|
352
419
|
function mergeAssetDependencies(
|
|
353
|
-
fromPackageJson:
|
|
420
|
+
fromPackageJson: AssetDependencies,
|
|
354
421
|
explicit: Record<string, string>,
|
|
355
|
-
):
|
|
422
|
+
): AssetDependencies {
|
|
356
423
|
const merged = { ...fromPackageJson }
|
|
357
424
|
for (const [name, range] of Object.entries(explicit)) {
|
|
358
|
-
|
|
425
|
+
const existing = merged[name]
|
|
426
|
+
if (existing !== undefined && assetDependencyRange(existing) !== range) {
|
|
359
427
|
throw new Error(
|
|
360
|
-
`Conflicting asset dependency "${name}": package.json has ${
|
|
428
|
+
`Conflicting asset dependency "${name}": package.json has ${assetDependencyRange(
|
|
429
|
+
existing,
|
|
430
|
+
)}, --asset has ${range}`,
|
|
361
431
|
)
|
|
362
432
|
}
|
|
363
|
-
|
|
433
|
+
// A matching package.json entry wins: it may carry aliases the flag form cannot express.
|
|
434
|
+
if (existing === undefined) merged[name] = range
|
|
364
435
|
}
|
|
365
436
|
return merged
|
|
366
437
|
}
|
|
@@ -373,60 +444,236 @@ function inferPackPolicy(files: Record<string, Uint8Array>): PackPolicy {
|
|
|
373
444
|
}
|
|
374
445
|
}
|
|
375
446
|
|
|
376
|
-
// Drop dependency
|
|
377
|
-
// template ships only its own (edited or new) files. Returns the
|
|
378
|
-
|
|
447
|
+
// Drop each dependency file at its resolved location (one copy per file — duplicates ship as the
|
|
448
|
+
// user's own content) so a template ships only its own (edited or new) files. Returns the
|
|
449
|
+
// surviving entries plus the dependencies with freshly resolved aliases applied.
|
|
450
|
+
async function withoutInstalledDependencyFiles(
|
|
379
451
|
files: Record<string, Uint8Array>,
|
|
380
|
-
assetDependencies:
|
|
381
|
-
cwd: string,
|
|
452
|
+
assetDependencies: AssetDependencies,
|
|
382
453
|
fetchFileManifest: FetchFileManifest | undefined,
|
|
383
|
-
): Promise<Record<string, Uint8Array
|
|
384
|
-
const
|
|
385
|
-
if (
|
|
454
|
+
): Promise<{ kept: Record<string, Uint8Array>; assetDependencies: AssetDependencies }> {
|
|
455
|
+
const index = await dependencyFileIndex(assetDependencies, fetchFileManifest)
|
|
456
|
+
if (index.files.length === 0) return { kept: files, assetDependencies }
|
|
386
457
|
|
|
387
|
-
const
|
|
458
|
+
const hashesByPath = new Map<string, string>()
|
|
459
|
+
const entryByNormalized = new Map<string, string>()
|
|
388
460
|
for (const [file, content] of Object.entries(files)) {
|
|
389
461
|
const normalizedPath = normalizedZipPath(file)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
462
|
+
if (!normalizedPath || normalizedPath.endsWith('/')) continue
|
|
463
|
+
hashesByPath.set(normalizedPath, sha256(content))
|
|
464
|
+
entryByNormalized.set(normalizedPath, file)
|
|
393
465
|
}
|
|
394
466
|
|
|
395
|
-
|
|
467
|
+
const layout = resolveDependencyLayout(assetDependencies, index, hashesByPath)
|
|
468
|
+
const omittedEntries = new Set(
|
|
469
|
+
[...layout.omit].flatMap((omitted) => entryByNormalized.get(omitted) ?? []),
|
|
470
|
+
)
|
|
471
|
+
const kept = Object.fromEntries(
|
|
472
|
+
Object.entries(files).filter(([file]) => !omittedEntries.has(file)),
|
|
473
|
+
)
|
|
474
|
+
return { kept, assetDependencies: layout.assetDependencies }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
interface DependencyFile {
|
|
478
|
+
name: string
|
|
479
|
+
canonicalPath: string
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
interface DependencyFileEntry extends DependencyFile {
|
|
483
|
+
/** sha256 hex of the file's canonical content, from the server manifest. */
|
|
484
|
+
hash: string
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
interface DependencyFileIndex {
|
|
488
|
+
files: DependencyFileEntry[]
|
|
489
|
+
/** Dependencies whose manifest was fetched — only their aliases are re-derived. */
|
|
490
|
+
fetched: Set<string>
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function emptyDependencyFileIndex(): DependencyFileIndex {
|
|
494
|
+
return { files: [], fetched: new Set() }
|
|
396
495
|
}
|
|
397
496
|
|
|
398
497
|
/**
|
|
399
|
-
* The canonical content hash of every file belonging to a declared asset dependency,
|
|
400
|
-
*
|
|
401
|
-
* from the server (`asset.fileManifest`). Returns an empty map when there's nothing to omit or no way
|
|
498
|
+
* 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
|
|
402
500
|
* to fetch (offline / no client) — pack then keeps all files rather than guessing.
|
|
403
501
|
*/
|
|
404
|
-
async function
|
|
405
|
-
|
|
406
|
-
assetDependencies: Record<string, string>,
|
|
502
|
+
async function dependencyFileIndex(
|
|
503
|
+
assetDependencies: AssetDependencies,
|
|
407
504
|
fetchFileManifest: FetchFileManifest | undefined,
|
|
408
|
-
): Promise<
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
if (!fetchFileManifest ||
|
|
505
|
+
): Promise<DependencyFileIndex> {
|
|
506
|
+
const index = emptyDependencyFileIndex()
|
|
507
|
+
const entries = Object.entries(assetDependencies)
|
|
508
|
+
if (!fetchFileManifest || entries.length === 0) return index
|
|
412
509
|
|
|
413
|
-
const lock = await readMarketLock(await findInstallRoot(root))
|
|
414
510
|
const manifests = await Promise.all(
|
|
415
|
-
|
|
416
|
-
const installed = lock.assets[name]
|
|
417
|
-
if (!installed) return {}
|
|
511
|
+
entries.map(async ([name, value]) => {
|
|
418
512
|
try {
|
|
419
|
-
return await fetchFileManifest(name,
|
|
513
|
+
return { name, manifest: await fetchFileManifest(name, assetDependencyRange(value)) }
|
|
420
514
|
} catch {
|
|
421
515
|
// Can't reach the server for this dependency — keep its files rather than wrongly omitting them.
|
|
422
|
-
return
|
|
516
|
+
return null
|
|
423
517
|
}
|
|
424
518
|
}),
|
|
425
519
|
)
|
|
426
|
-
for (const
|
|
427
|
-
|
|
520
|
+
for (const result of manifests) {
|
|
521
|
+
// An empty manifest is "unavailable", not "zero files": a real version always has files, but
|
|
522
|
+
// the server returns {} for an unresolvable asset (deleted / private without access) or one
|
|
523
|
+
// too large for manifest computation. Treating it as fetched would wipe the dep's aliases.
|
|
524
|
+
if (!result || Object.keys(result.manifest).length === 0) continue
|
|
525
|
+
index.fetched.add(result.name)
|
|
526
|
+
for (const [file, hash] of Object.entries(result.manifest)) {
|
|
527
|
+
index.files.push({ name: result.name, canonicalPath: file, hash })
|
|
528
|
+
}
|
|
428
529
|
}
|
|
429
|
-
return
|
|
530
|
+
return index
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
interface DependencyLayout {
|
|
534
|
+
/** Project paths holding a dependency file — exactly one per dependency file found. */
|
|
535
|
+
omit: Set<string>
|
|
536
|
+
/** The declared dependencies with re-resolved aliases (fetched deps only; others untouched). */
|
|
537
|
+
assetDependencies: AssetDependencies
|
|
538
|
+
/** Dependency files whose canonical bytes were found nowhere (locally modified or deleted). */
|
|
539
|
+
missing: DependencyFile[]
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Resolve where each dependency file lives in the project, by content hash. Exactly one project
|
|
544
|
+
* copy is claimed per dependency file: the already-recorded alias target if its bytes still match
|
|
545
|
+
* (stable across runs), else the canonical path, else the lexicographically first matching copy —
|
|
546
|
+
* that copy is omitted from packs and recorded as the alias; further identical copies are the
|
|
547
|
+
* user's own content. Stale aliases drop: a renamed file that was since modified ships as user
|
|
548
|
+
* content and reinstall restores the canonical path.
|
|
549
|
+
*/
|
|
550
|
+
function resolveDependencyLayout(
|
|
551
|
+
declared: AssetDependencies,
|
|
552
|
+
index: DependencyFileIndex,
|
|
553
|
+
hashesByPath: Map<string, string>,
|
|
554
|
+
): DependencyLayout {
|
|
555
|
+
const pathsByHash = new Map<string, string[]>()
|
|
556
|
+
for (const [filePath, hash] of hashesByPath) {
|
|
557
|
+
pathsByHash.set(hash, [...(pathsByHash.get(hash) ?? []), filePath])
|
|
558
|
+
}
|
|
559
|
+
for (const paths of pathsByHash.values()) paths.sort()
|
|
560
|
+
|
|
561
|
+
const omit = new Set<string>()
|
|
562
|
+
const missing: DependencyFile[] = []
|
|
563
|
+
const aliases: Record<string, Record<string, string>> = {}
|
|
564
|
+
for (const file of index.files) {
|
|
565
|
+
const declaredValue = declared[file.name]
|
|
566
|
+
const declaredAlias =
|
|
567
|
+
declaredValue === undefined
|
|
568
|
+
? undefined
|
|
569
|
+
: assetDependencyAlias(declaredValue)[file.canonicalPath]
|
|
570
|
+
const candidates = (pathsByHash.get(file.hash) ?? []).filter((p) => !omit.has(p))
|
|
571
|
+
if (candidates.length === 0) {
|
|
572
|
+
missing.push({ name: file.name, canonicalPath: file.canonicalPath })
|
|
573
|
+
continue
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const target =
|
|
577
|
+
declaredAlias !== undefined && candidates.includes(declaredAlias)
|
|
578
|
+
? declaredAlias
|
|
579
|
+
: candidates.includes(file.canonicalPath)
|
|
580
|
+
? file.canonicalPath
|
|
581
|
+
: candidates[0]
|
|
582
|
+
omit.add(target)
|
|
583
|
+
if (target !== file.canonicalPath) {
|
|
584
|
+
aliases[file.name] = { ...(aliases[file.name] ?? {}), [file.canonicalPath]: target }
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const assetDependencies: AssetDependencies = {}
|
|
589
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
590
|
+
assetDependencies[name] = index.fetched.has(name)
|
|
591
|
+
? assetDependencyValue(assetDependencyRange(value), aliases[name] ?? {})
|
|
592
|
+
: value
|
|
593
|
+
}
|
|
594
|
+
return { omit, assetDependencies, missing }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export interface AssetDependencySync {
|
|
598
|
+
projectRoot: string
|
|
599
|
+
/** Whether package.json was rewritten. */
|
|
600
|
+
changed: boolean
|
|
601
|
+
assetDependencies: AssetDependencies
|
|
602
|
+
/** Dependency files whose canonical bytes were found nowhere (locally modified or deleted). */
|
|
603
|
+
missing: Array<{ name: string; path: string }>
|
|
604
|
+
/** Dependencies left untouched because their manifest could not be fetched. */
|
|
605
|
+
skipped: string[]
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Update the aliases in package.json `assetDependencies` to match where each dependency's files
|
|
610
|
+
* currently live — the standalone form of what pack does before omitting (`market sync`). Renamed
|
|
611
|
+
* files gain an alias, files back at their canonical path lose theirs, and dependencies whose
|
|
612
|
+
* manifest cannot be fetched are reported as skipped rather than blindly rewritten.
|
|
613
|
+
*/
|
|
614
|
+
export async function syncAssetDependencies(
|
|
615
|
+
cwd: string,
|
|
616
|
+
fetchFileManifest: FetchFileManifest,
|
|
617
|
+
): Promise<AssetDependencySync> {
|
|
618
|
+
const projectRoot = await findInstallRoot(cwd)
|
|
619
|
+
const pkgPath = path.join(projectRoot, 'package.json')
|
|
620
|
+
if (!(await maybeStat(pkgPath))?.isFile()) {
|
|
621
|
+
throw new Error(`No package.json found at ${projectRoot}. Run sync inside a project.`)
|
|
622
|
+
}
|
|
623
|
+
const declared = packageJsonAssetDependencies(await fs.readFile(pkgPath, 'utf-8'), pkgPath)
|
|
624
|
+
const names = Object.keys(declared)
|
|
625
|
+
if (names.length === 0) {
|
|
626
|
+
return { projectRoot, changed: false, assetDependencies: {}, missing: [], skipped: [] }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const index = await dependencyFileIndex(declared, fetchFileManifest)
|
|
630
|
+
const walk = await walkWithGitignore(projectRoot)
|
|
631
|
+
const hashesByPath = new Map<string, string>()
|
|
632
|
+
for (const relative of walk.kept) {
|
|
633
|
+
hashesByPath.set(
|
|
634
|
+
relative,
|
|
635
|
+
sha256(new Uint8Array(await fs.readFile(path.join(projectRoot, relative)))),
|
|
636
|
+
)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const layout = resolveDependencyLayout(declared, index, hashesByPath)
|
|
640
|
+
const rewritten = await writeBackAssetDependencies(projectRoot, layout.assetDependencies)
|
|
641
|
+
return {
|
|
642
|
+
projectRoot,
|
|
643
|
+
changed: rewritten !== null,
|
|
644
|
+
assetDependencies: layout.assetDependencies,
|
|
645
|
+
missing: layout.missing.map((file) => ({ name: file.name, path: file.canonicalPath })),
|
|
646
|
+
skipped: names.filter((name) => !index.fetched.has(name)),
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Persist detected aliases into the project's package.json (only for dependencies it already
|
|
652
|
+
* declares — flag-only deps stay out of the file). Returns the serialized bytes when something
|
|
653
|
+
* changed so the packed zip can carry the same content.
|
|
654
|
+
*/
|
|
655
|
+
async function writeBackAssetDependencies(
|
|
656
|
+
dir: string,
|
|
657
|
+
assetDependencies: AssetDependencies,
|
|
658
|
+
): Promise<Uint8Array | null> {
|
|
659
|
+
const pkgPath = path.join(dir, 'package.json')
|
|
660
|
+
const pkg = parsePackageJson(await fs.readFile(pkgPath, 'utf-8'), pkgPath)
|
|
661
|
+
const declared = pkg.assetDependencies
|
|
662
|
+
if (!declared) return null
|
|
663
|
+
|
|
664
|
+
let changed = false
|
|
665
|
+
for (const name of Object.keys(declared)) {
|
|
666
|
+
const next = assetDependencies[name]
|
|
667
|
+
if (next === undefined) continue
|
|
668
|
+
if (JSON.stringify(declared[name]) === JSON.stringify(next)) continue
|
|
669
|
+
declared[name] = next
|
|
670
|
+
changed = true
|
|
671
|
+
}
|
|
672
|
+
if (!changed) return null
|
|
673
|
+
|
|
674
|
+
const serialized = JSON.stringify(pkg, null, 2) + '\n'
|
|
675
|
+
await fs.writeFile(pkgPath, serialized)
|
|
676
|
+
return new TextEncoder().encode(serialized)
|
|
430
677
|
}
|
|
431
678
|
|
|
432
679
|
function normalizedZipPath(file: string): string | null {
|
package/src/package-json.ts
CHANGED
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { assetDependencyValueSchema, type AssetDependencies } from './schemas.js'
|
|
2
3
|
|
|
3
4
|
export interface PackageJson {
|
|
4
5
|
name?: string
|
|
5
6
|
private?: boolean
|
|
6
7
|
dependencies?: Record<string, string>
|
|
7
|
-
assetDependencies?:
|
|
8
|
+
assetDependencies?: AssetDependencies
|
|
8
9
|
[key: string]: unknown
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
const stringRecordSchema = z.record(z.string(), z.string())
|
|
12
13
|
|
|
14
|
+
const assetDependenciesRecordSchema = z.record(z.string(), assetDependencyValueSchema)
|
|
15
|
+
|
|
13
16
|
export function packageJsonAssetDependenciesFromFiles(
|
|
14
17
|
files: Record<string, Uint8Array>,
|
|
15
|
-
):
|
|
18
|
+
): AssetDependencies {
|
|
16
19
|
const bytes = files['package.json']
|
|
17
20
|
if (!bytes) return {}
|
|
18
21
|
|
|
19
22
|
return packageJsonAssetDependencies(new TextDecoder().decode(bytes), 'package.json')
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
export function packageJsonAssetDependencies(json: string, source: string):
|
|
25
|
+
export function packageJsonAssetDependencies(json: string, source: string): AssetDependencies {
|
|
23
26
|
const parsed = parsePackageJson(json, source)
|
|
24
27
|
const dependencies = parsed.assetDependencies
|
|
25
28
|
if (dependencies === undefined) return {}
|
|
26
29
|
|
|
27
|
-
return
|
|
30
|
+
return assetDependenciesRecordSchema.parse(dependencies)
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
// A template ships a full app, so its package.json `dependencies` ARE its npm dependencies: capture
|