@drawcall/market 0.1.34 → 0.1.36

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.
Files changed (57) hide show
  1. package/README.md +2 -2
  2. package/dist/asset-implementation.d.ts +10 -7
  3. package/dist/asset-implementation.d.ts.map +1 -1
  4. package/dist/asset-implementation.js +8 -1
  5. package/dist/asset-implementation.js.map +1 -1
  6. package/dist/cli.js +4 -2
  7. package/dist/cli.js.map +1 -1
  8. package/dist/commands/install.d.ts +3 -4
  9. package/dist/commands/install.d.ts.map +1 -1
  10. package/dist/commands/install.js +57 -11
  11. package/dist/commands/install.js.map +1 -1
  12. package/dist/commands/upload.d.ts.map +1 -1
  13. package/dist/commands/upload.js +64 -2
  14. package/dist/commands/upload.js.map +1 -1
  15. package/dist/contract.d.ts +7 -1
  16. package/dist/contract.d.ts.map +1 -1
  17. package/dist/contract.js +1 -1
  18. package/dist/contract.js.map +1 -1
  19. package/dist/index.d.ts +4 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/install.d.ts +14 -0
  24. package/dist/install.d.ts.map +1 -1
  25. package/dist/install.js +133 -23
  26. package/dist/install.js.map +1 -1
  27. package/dist/market-lock.d.ts +17 -0
  28. package/dist/market-lock.d.ts.map +1 -0
  29. package/dist/market-lock.js +44 -0
  30. package/dist/market-lock.js.map +1 -0
  31. package/dist/output.d.ts +2 -2
  32. package/dist/output.d.ts.map +1 -1
  33. package/dist/output.js +5 -2
  34. package/dist/output.js.map +1 -1
  35. package/dist/package-json.d.ts +11 -0
  36. package/dist/package-json.d.ts.map +1 -0
  37. package/dist/package-json.js +23 -0
  38. package/dist/package-json.js.map +1 -0
  39. package/dist/skill.d.ts +1 -1
  40. package/dist/skill.d.ts.map +1 -1
  41. package/dist/skill.js +5 -3
  42. package/dist/skill.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/asset-implementation.ts +18 -6
  45. package/src/cli.ts +16 -9
  46. package/src/commands/install.ts +76 -16
  47. package/src/commands/upload.ts +84 -2
  48. package/src/contract.ts +8 -1
  49. package/src/index.ts +9 -0
  50. package/src/install.ts +197 -35
  51. package/src/market-lock.ts +65 -0
  52. package/src/output.ts +7 -3
  53. package/src/package-json.ts +37 -0
  54. package/src/skill.ts +5 -3
  55. package/tests/install-command.test.ts +23 -1
  56. package/tests/install-layout.test.ts +117 -0
  57. package/tests/output.test.ts +19 -2
@@ -1,6 +1,9 @@
1
+ import * as fs from 'fs/promises'
2
+ import * as path from 'path'
1
3
  import ora from 'ora'
2
4
  import { resolve } from '../resolve.js'
3
- import { install as runInstall } from '../install.js'
5
+ import { findInstallRoot, install as runInstall, type InstallRootRequest } from '../install.js'
6
+ import { packageJsonAssetDependencies } from '../package-json.js'
4
7
  import { assetNameSchema } from '../schemas.js'
5
8
  import { getCliClient } from '../cli-client.js'
6
9
  import { installResult } from '../output.js'
@@ -10,42 +13,41 @@ export interface InstallCommandOptions {
10
13
  unapproved?: boolean
11
14
  cwd?: string
12
15
  baseUrl?: string
16
+ force?: boolean
13
17
  }
14
18
 
15
- interface AssetRequest {
16
- name: string
17
- range: string
18
- }
19
+ type AssetRequest = InstallRootRequest
19
20
 
20
21
  export async function installCommand(args: string[], opts: InstallCommandOptions): Promise<void> {
21
22
  const { client } = await getCliClient({ baseUrl: opts.baseUrl })
22
23
  const includeUnapproved = opts.unapproved ?? false
24
+ const metadata = await client.asset.installMetadata()
23
25
 
24
26
  const spinner = ora({
25
27
  isEnabled: Boolean(process.stderr.isTTY),
26
28
  isSilent: !process.stderr.isTTY,
27
29
  }).start()
28
30
  try {
29
- const requests: AssetRequest[] = []
30
- for (const arg of args) {
31
- spinner.text = `Resolving "${truncate(arg, 40)}"`
32
- requests.push(await resolveArg(client, arg, includeUnapproved))
33
- }
31
+ const requests =
32
+ args.length > 0
33
+ ? await resolveArgs(client, args, includeUnapproved, spinner)
34
+ : await packageJsonRequests(opts.cwd)
34
35
 
35
36
  spinner.text = 'Resolving dependency tree'
36
37
  const resolution = await resolve(client.asset, requests, { includeUnapproved })
37
38
 
38
39
  const result = await runInstall(client, resolution, {
39
40
  cwd: opts.cwd,
41
+ force: opts.force ?? false,
42
+ rootRequests: requests,
43
+ installMetadata: metadata,
40
44
  onProgress: (msg) => {
41
45
  spinner.text = msg
42
46
  },
43
47
  })
44
48
 
45
- const messages = await client.asset.postInstallMessages()
46
-
47
49
  spinner.stop()
48
- console.log(installResult(result, messages))
50
+ console.log(installResult(result, metadata))
49
51
  } catch (err) {
50
52
  spinner.stop()
51
53
  throw err
@@ -72,7 +74,14 @@ export async function resolveArg(
72
74
  }
73
75
 
74
76
  const existing = await client.asset.exact({ name: parsed.name, includeUnapproved })
75
- if (existing) return parsed
77
+ if (existing) {
78
+ return {
79
+ name: parsed.name,
80
+ range: parsed.range,
81
+ saveRange: parsed.explicitRange ? parsed.range : `^${existing.latestVersion}`,
82
+ save: true,
83
+ }
84
+ }
76
85
 
77
86
  if (!includeUnapproved) {
78
87
  const hidden = await client.asset.exact({ name: parsed.name, includeUnapproved: true })
@@ -89,11 +98,62 @@ export async function resolveArg(
89
98
  )
90
99
  }
91
100
 
92
- function parseNameAndRange(arg: string): AssetRequest | null {
101
+ async function resolveArgs(
102
+ client: MarketClient,
103
+ args: string[],
104
+ includeUnapproved: boolean,
105
+ spinner: { text: string },
106
+ ): Promise<AssetRequest[]> {
107
+ const requests: AssetRequest[] = []
108
+ for (const arg of args) {
109
+ spinner.text = `Resolving "${truncate(arg, 40)}"`
110
+ requests.push(await resolveArg(client, arg, includeUnapproved))
111
+ }
112
+ return requests
113
+ }
114
+
115
+ async function packageJsonRequests(cwd: string | undefined): Promise<AssetRequest[]> {
116
+ const installRoot = await findInstallRoot(cwd ?? process.cwd())
117
+ const pkgPath = path.join(installRoot, 'package.json')
118
+ const dependencies = await readPackageJsonAssetDependencies(pkgPath)
119
+ const requests = Object.entries(dependencies).map(([name, range]) => ({
120
+ name,
121
+ range,
122
+ saveRange: range,
123
+ save: false,
124
+ }))
125
+
126
+ if (requests.length === 0) {
127
+ throw new Error(
128
+ 'No assets specified and package.json has no assetDependencies. Pass asset names or add assetDependencies.',
129
+ )
130
+ }
131
+
132
+ return requests
133
+ }
134
+
135
+ async function readPackageJsonAssetDependencies(pkgPath: string): Promise<Record<string, string>> {
136
+ try {
137
+ return packageJsonAssetDependencies(await fs.readFile(pkgPath, 'utf-8'), pkgPath)
138
+ } catch (error) {
139
+ if (isMissingFile(error)) return {}
140
+ throw error
141
+ }
142
+ }
143
+
144
+ function isMissingFile(error: unknown): boolean {
145
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT'
146
+ }
147
+
148
+ function parseNameAndRange(arg: string):
149
+ | (Pick<AssetRequest, 'name' | 'range'> & {
150
+ explicitRange: boolean
151
+ })
152
+ | null {
93
153
  const atIdx = arg.indexOf('@')
94
154
  const name = atIdx >= 0 ? arg.slice(0, atIdx) : arg
95
155
  const range = atIdx >= 0 ? arg.slice(atIdx + 1) : '*'
96
- return assetNameSchema.safeParse(name).success ? { name, range } : null
156
+ return assetNameSchema.safeParse(name).success ? { name, range, explicitRange: atIdx >= 0 } : null
97
157
  }
98
158
 
99
159
  function truncate(s: string, max: number): string {
@@ -1,8 +1,12 @@
1
1
  import * as fs from 'fs/promises'
2
2
  import * as path from 'path'
3
+ import { unzipSync, zipSync } from 'fflate'
3
4
  import ora from 'ora'
4
5
  import semver from 'semver'
5
6
  import { getCliClient } from '../cli-client.js'
7
+ import { findInstallRoot } from '../install.js'
8
+ import { readMarketLock, sha256 } from '../market-lock.js'
9
+ import { packageJsonAssetDependenciesFromFiles } from '../package-json.js'
6
10
  import { MAX_ASSET_DESCRIPTION_LENGTH, MAX_UPLOAD_ZIP_SIZE_BYTES } from '../schemas.js'
7
11
  import { unchangedUploadResult, uploadResult } from '../output.js'
8
12
  import {
@@ -43,7 +47,7 @@ export async function uploadCommand(
43
47
  const parsedDescription = parseUploadDescription(description)
44
48
  // Parse dep flags before any network work so a malformed spec fails fast.
45
49
  const npmDependencies = parseVersionedDeps(opts.npm ?? [], 'npm')
46
- const assetDependencies = parseVersionedDeps(opts.asset ?? [], 'asset')
50
+ const explicitAssetDependencies = parseVersionedDeps(opts.asset ?? [], 'asset')
47
51
  const skillDependencies = parseSkillDeps(opts.skill ?? [])
48
52
  const cwd = opts.cwd ?? process.cwd()
49
53
  const type = opts.type
@@ -52,8 +56,21 @@ export async function uploadCommand(
52
56
  if (zipStat.size >= MAX_UPLOAD_ZIP_SIZE_BYTES) {
53
57
  throw new Error('Upload zip must be smaller than 1 GB')
54
58
  }
55
- const zip = new Uint8Array(await fs.readFile(zipFile))
59
+ const sourceZip = new Uint8Array(await fs.readFile(zipFile))
56
60
  const { client } = await getCliClient({ baseUrl: opts.baseUrl, requireAuth: true })
61
+ const installMetadata = await client.asset.installMetadata()
62
+ const typeMetadata = installMetadata[type]
63
+ const sourceFiles = unzipSync(sourceZip)
64
+ const packageJsonAssetDependencies = typeMetadata?.readAssetDependenciesFromPackageJson
65
+ ? packageJsonAssetDependenciesFromFiles(sourceFiles)
66
+ : {}
67
+ const assetDependencies = mergeAssetDependencies(
68
+ packageJsonAssetDependencies,
69
+ explicitAssetDependencies,
70
+ )
71
+ const zip = typeMetadata?.omitUnchangedInstalledFilesOnUpload
72
+ ? await omitUnchangedInstalledFiles(sourceZip, sourceFiles, assetDependencies, cwd)
73
+ : sourceZip
57
74
  const profile = await client.user.getProfile()
58
75
  if (!profile) {
59
76
  throw new Error('Not logged in. Run `market login` first.')
@@ -168,6 +185,71 @@ export function parseSkillDeps(specs: string[]): Record<string, string> {
168
185
  return out
169
186
  }
170
187
 
188
+ function mergeAssetDependencies(
189
+ fromPackageJson: Record<string, string>,
190
+ explicit: Record<string, string>,
191
+ ): Record<string, string> {
192
+ const merged = { ...fromPackageJson }
193
+ for (const [name, range] of Object.entries(explicit)) {
194
+ if (name in merged && merged[name] !== range) {
195
+ throw new Error(
196
+ `Conflicting asset dependency "${name}": package.json has ${merged[name]}, --asset has ${range}`,
197
+ )
198
+ }
199
+ merged[name] = range
200
+ }
201
+ return merged
202
+ }
203
+
204
+ async function omitUnchangedInstalledFiles(
205
+ sourceZip: Uint8Array,
206
+ sourceFiles: Record<string, Uint8Array>,
207
+ assetDependencies: Record<string, string>,
208
+ cwd: string,
209
+ ): Promise<Uint8Array> {
210
+ const dependencyNames = new Set(Object.keys(assetDependencies))
211
+ if (dependencyNames.size === 0) return sourceZip
212
+
213
+ const installRoot = await findInstallRoot(cwd)
214
+ const lock = await readMarketLock(installRoot)
215
+ const hashesByPath = new Map<string, string>()
216
+
217
+ for (const [name, asset] of Object.entries(lock.assets)) {
218
+ if (!dependencyNames.has(name)) continue
219
+ for (const [file, metadata] of Object.entries(asset.files)) {
220
+ hashesByPath.set(file, metadata.sha256)
221
+ }
222
+ }
223
+
224
+ if (hashesByPath.size === 0) return sourceZip
225
+
226
+ let omitted = false
227
+ const filtered: Record<string, Uint8Array> = {}
228
+ for (const [file, content] of Object.entries(sourceFiles)) {
229
+ const normalizedPath = normalizedZipPath(file)
230
+ const lockedHash = normalizedPath ? hashesByPath.get(normalizedPath) : undefined
231
+ if (lockedHash && lockedHash === sha256(content)) {
232
+ omitted = true
233
+ continue
234
+ }
235
+ filtered[file] = content
236
+ }
237
+
238
+ return omitted ? zipSync(filtered) : sourceZip
239
+ }
240
+
241
+ function normalizedZipPath(file: string): string | null {
242
+ const zipPath = file.replace(/\\/g, '/')
243
+ if (
244
+ zipPath.split('/').includes('..') ||
245
+ path.posix.isAbsolute(zipPath) ||
246
+ path.win32.isAbsolute(zipPath)
247
+ ) {
248
+ return null
249
+ }
250
+ return path.posix.normalize(zipPath)
251
+ }
252
+
171
253
  /** Compare a stored dependency JSON string against a freshly parsed record. */
172
254
  function depsEqual(storedJson: string, next: Record<string, string>): boolean {
173
255
  let stored: Record<string, string>
package/src/contract.ts CHANGED
@@ -74,6 +74,13 @@ export interface GenerateAssetResult {
74
74
  version: string
75
75
  }
76
76
 
77
+ export interface AssetInstallMetadata {
78
+ installMessage?: string
79
+ saveOnInstall: boolean
80
+ readAssetDependenciesFromPackageJson: boolean
81
+ omitUnchangedInstalledFilesOnUpload: boolean
82
+ }
83
+
77
84
  export const contract = {
78
85
  asset: {
79
86
  search: oc.input(listAssetsSchema).output(z.custom<PaginatedList<AssetSearchResult>>()),
@@ -95,7 +102,7 @@ export const contract = {
95
102
 
96
103
  generate: oc.input(generateAssetSchema).output(z.custom<GenerateAssetResult>()),
97
104
 
98
- postInstallMessages: oc.output(z.custom<Record<string, string>>()),
105
+ installMetadata: oc.output(z.custom<Record<string, AssetInstallMetadata>>()),
99
106
  },
100
107
 
101
108
  user: {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export type { AppContract } from './contract.js'
9
9
  // Contract output types
10
10
  export type {
11
11
  Asset,
12
+ AssetInstallMetadata,
12
13
  AssetVersion,
13
14
  AssetSearchResult,
14
15
  PaginatedList,
@@ -25,6 +26,7 @@ export type {
25
26
  AssetSearchInput,
26
27
  AssetUploadZipInput,
27
28
  } from './asset-implementation.js'
29
+ export { installMetadataForProvider } from './asset-implementation.js'
28
30
 
29
31
  // Resolve
30
32
  export { resolve, ResolutionError } from './resolve.js'
@@ -55,3 +57,10 @@ export type { AssetType } from './schemas.js'
55
57
 
56
58
  // Constants
57
59
  export { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, ASSET_TYPE_LABELS } from './constants.js'
60
+
61
+ export {
62
+ packageJsonAssetDependencies,
63
+ packageJsonAssetDependenciesFromFiles,
64
+ parsePackageJson,
65
+ } from './package-json.js'
66
+ export type { PackageJson } from './package-json.js'
package/src/install.ts CHANGED
@@ -16,18 +16,20 @@ import * as path from 'path'
16
16
  import { unzipSync } from 'fflate'
17
17
  import { detectPackageManager, installDependencies } from 'nypm'
18
18
  import type { MarketClient } from './client.js'
19
+ import type { AssetInstallMetadata } from './contract.js'
20
+ import { readMarketLock, sha256, writeMarketLock } from './market-lock.js'
21
+ import { parsePackageJson, type PackageJson } from './package-json.js'
19
22
  import type { ResolveResult } from './resolve.js'
20
23
 
21
- interface PackageJson {
22
- name?: string
23
- private?: boolean
24
- dependencies?: Record<string, string>
25
- [key: string]: unknown
26
- }
27
-
28
24
  export interface InstallOptions {
29
25
  /** Directory to start project root discovery from (default: cwd) */
30
26
  cwd?: string
27
+ /** Overwrite existing files that differ from the asset zip. */
28
+ force?: boolean
29
+ /** Top-level assets requested by the user, used for package.json assetDependencies. */
30
+ rootRequests?: InstallRootRequest[]
31
+ /** Asset-type install policy from the Market API. */
32
+ installMetadata?: Record<string, AssetInstallMetadata>
31
33
  /** Log progress */
32
34
  onProgress?: (message: string) => void
33
35
  /**
@@ -38,6 +40,13 @@ export interface InstallOptions {
38
40
  runSkillAdd?: (source: string, cwd: string) => Promise<void>
39
41
  }
40
42
 
43
+ export interface InstallRootRequest {
44
+ name: string
45
+ range: string
46
+ saveRange: string
47
+ save: boolean
48
+ }
49
+
41
50
  export interface InstalledAsset {
42
51
  name: string
43
52
  type: string
@@ -51,6 +60,17 @@ export interface InstallResult {
51
60
  npmDependencies: Record<string, string>
52
61
  /** Installed skills, keyed by label, with the source passed to `skills add`. */
53
62
  skillDependencies: Record<string, string>
63
+ warnings: string[]
64
+ }
65
+
66
+ interface DownloadedAsset extends InstalledAsset {
67
+ fileHashes: Record<string, { sha256: string }>
68
+ }
69
+
70
+ interface DownloadResult {
71
+ assets: DownloadedAsset[]
72
+ wrotePackageJson: boolean
73
+ warnings: string[]
54
74
  }
55
75
 
56
76
  export async function install(
@@ -60,11 +80,21 @@ export async function install(
60
80
  ): Promise<InstallResult> {
61
81
  const log = opts.onProgress ?? (() => {})
62
82
  const installRoot = await findInstallRoot(opts.cwd ?? process.cwd())
83
+ const metadata = opts.installMetadata ?? {}
63
84
 
64
- const [assets] = await Promise.all([
65
- downloadAssets(client, resolution, installRoot, log),
66
- installNpmDeps(resolution, installRoot, log),
67
- ])
85
+ const download = await downloadAssets(client, resolution, installRoot, {
86
+ force: opts.force ?? false,
87
+ log,
88
+ })
89
+ await writeInstalledAssetLock(installRoot, download.assets)
90
+ const packageJsonUpdate = await updatePackageJson(resolution, installRoot, {
91
+ rootRequests: opts.rootRequests ?? [],
92
+ installMetadata: metadata,
93
+ packageManagerNeeded: download.wrotePackageJson,
94
+ })
95
+ if (packageJsonUpdate.packageManagerNeeded) {
96
+ await runPackageManagerInstall(installRoot, log)
97
+ }
68
98
 
69
99
  // Skills are installed after asset files land on disk: a skill source may be
70
100
  // a local path pointing at a skill directory shipped inside an installed
@@ -77,9 +107,16 @@ export async function install(
77
107
  )
78
108
 
79
109
  return {
80
- assets,
110
+ assets: download.assets.map((asset) => ({
111
+ name: asset.name,
112
+ type: asset.type,
113
+ version: asset.version,
114
+ description: asset.description,
115
+ files: asset.files,
116
+ })),
81
117
  npmDependencies: resolution.npmDependencies,
82
118
  skillDependencies,
119
+ warnings: download.warnings,
83
120
  }
84
121
  }
85
122
 
@@ -100,16 +137,22 @@ async function downloadAssets(
100
137
  client: MarketClient,
101
138
  resolution: ResolveResult,
102
139
  projectRoot: string,
103
- log: (msg: string) => void,
104
- ): Promise<InstalledAsset[]> {
105
- const installedAssets: InstalledAsset[] = []
140
+ opts: {
141
+ force: boolean
142
+ log: (msg: string) => void
143
+ },
144
+ ): Promise<DownloadResult> {
145
+ const installedAssets: DownloadedAsset[] = []
146
+ const warnings: string[] = []
147
+ let wrotePackageJson = false
106
148
 
107
149
  for (const asset of resolution.assets) {
108
- log(`Downloading ${asset.name}@${asset.version}...`)
150
+ opts.log(`Downloading ${asset.name}@${asset.version}...`)
109
151
 
110
152
  const zip = await client.asset.downloadZip({ name: asset.name, version: asset.version })
111
153
  const files = unzipSync(new Uint8Array(await zip.arrayBuffer()))
112
154
  const installedFiles: string[] = []
155
+ const fileHashes: Record<string, { sha256: string }> = {}
113
156
 
114
157
  for (const [relativePath, content] of Object.entries(files) as [string, Uint8Array][]) {
115
158
  const zipPath = relativePath.replace(/\\/g, '/')
@@ -124,16 +167,31 @@ async function downloadAssets(
124
167
  const normalizedPath = path.posix.normalize(zipPath)
125
168
  if (
126
169
  normalizedPath === '.' ||
127
- normalizedPath === 'README.md' ||
170
+ (normalizedPath === 'README.md' && asset.type !== 'template') ||
128
171
  normalizedPath.endsWith('/')
129
172
  ) {
130
173
  continue
131
174
  }
132
175
 
133
176
  const filePath = path.join(projectRoot, normalizedPath)
134
- await fs.mkdir(path.dirname(filePath), { recursive: true })
135
- await fs.writeFile(filePath, content)
177
+ const existing = await maybeReadFile(filePath)
178
+ if (existing && !bytesEqual(existing, content) && !opts.force) {
179
+ warnings.push(
180
+ `Skipped ${normalizedPath} from ${asset.name}@${asset.version}; file already exists. Re-run with --force to overwrite.`,
181
+ )
182
+ continue
183
+ }
184
+
185
+ if (!existing || !bytesEqual(existing, content)) {
186
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
187
+ await fs.writeFile(filePath, content)
188
+ if (normalizedPath === 'package.json') {
189
+ wrotePackageJson = true
190
+ }
191
+ }
192
+
136
193
  installedFiles.push(normalizedPath)
194
+ fileHashes[normalizedPath] = { sha256: sha256(content) }
137
195
  }
138
196
 
139
197
  installedAssets.push({
@@ -142,12 +200,13 @@ async function downloadAssets(
142
200
  version: asset.version,
143
201
  description: asset.description,
144
202
  files: installedFiles,
203
+ fileHashes,
145
204
  })
146
205
 
147
- log(`Downloaded ${installedFiles.length} files.`)
206
+ opts.log(`Downloaded ${installedFiles.length} files.`)
148
207
  }
149
208
 
150
- return installedAssets
209
+ return { assets: installedAssets, wrotePackageJson, warnings }
151
210
  }
152
211
 
153
212
  async function isFile(file: string): Promise<boolean> {
@@ -158,26 +217,60 @@ async function isFile(file: string): Promise<boolean> {
158
217
  }
159
218
  }
160
219
 
161
- async function installNpmDeps(
220
+ async function maybeReadFile(file: string): Promise<Uint8Array | null> {
221
+ try {
222
+ return await fs.readFile(file)
223
+ } catch (error) {
224
+ if (isMissingFile(error)) return null
225
+ throw error
226
+ }
227
+ }
228
+
229
+ function isMissingFile(error: unknown): boolean {
230
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT'
231
+ }
232
+
233
+ async function updatePackageJson(
162
234
  resolution: ResolveResult,
163
235
  projectRoot: string,
164
- log: (msg: string) => void,
165
- ): Promise<void> {
166
- const deps = resolution.npmDependencies
167
- if (Object.keys(deps).length === 0) return
168
-
236
+ opts: {
237
+ rootRequests: InstallRootRequest[]
238
+ installMetadata: Record<string, AssetInstallMetadata>
239
+ packageManagerNeeded: boolean
240
+ },
241
+ ): Promise<{ packageManagerNeeded: boolean }> {
169
242
  const pkgPath = path.join(projectRoot, 'package.json')
170
- // Only synthesize a package.json when none exists. A malformed existing file
171
- // is a real error that must surface — never silently overwrite it.
172
- const pkg: PackageJson = (await isFile(pkgPath))
173
- ? (JSON.parse(await fs.readFile(pkgPath, 'utf-8')) as PackageJson)
174
- : { name: 'my-project', private: true, dependencies: {} }
243
+ const pkg = (await isFile(pkgPath))
244
+ ? parsePackageJson(await fs.readFile(pkgPath, 'utf-8'), pkgPath)
245
+ : defaultPackageJson()
246
+
247
+ let changed = false
248
+ let packageManagerNeeded = opts.packageManagerNeeded
175
249
 
176
- pkg.dependencies = { ...pkg.dependencies, ...deps }
177
- await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
250
+ const npmChanged = mergeRecord(pkg, 'dependencies', resolution.npmDependencies)
251
+ if (npmChanged) {
252
+ changed = true
253
+ packageManagerNeeded = true
254
+ }
178
255
 
179
- log(`Installing npm dependencies: ${Object.keys(deps).join(', ')}`)
256
+ const assetDependencies = assetDependenciesToSave(
257
+ resolution,
258
+ opts.rootRequests,
259
+ opts.installMetadata,
260
+ )
261
+ if (mergeRecord(pkg, 'assetDependencies', assetDependencies)) {
262
+ changed = true
263
+ }
180
264
 
265
+ if (changed) {
266
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
267
+ }
268
+
269
+ return { packageManagerNeeded }
270
+ }
271
+
272
+ async function runPackageManagerInstall(projectRoot: string, log: (msg: string) => void) {
273
+ log('Installing npm dependencies...')
181
274
  const pm = await detectPackageManager(projectRoot)
182
275
  const pmName = pm?.name ?? 'npm'
183
276
 
@@ -186,6 +279,75 @@ async function installNpmDeps(
186
279
  log('npm dependencies installed.')
187
280
  }
188
281
 
282
+ async function writeInstalledAssetLock(
283
+ projectRoot: string,
284
+ assets: DownloadedAsset[],
285
+ ): Promise<void> {
286
+ const lock = await readMarketLock(projectRoot)
287
+ for (const asset of assets) {
288
+ lock.assets[asset.name] = {
289
+ version: asset.version,
290
+ type: asset.type,
291
+ files: asset.fileHashes,
292
+ }
293
+ }
294
+ await writeMarketLock(projectRoot, lock)
295
+ }
296
+
297
+ function defaultPackageJson(): PackageJson {
298
+ return { name: 'my-project', private: true, dependencies: {} }
299
+ }
300
+
301
+ function mergeRecord(
302
+ pkg: PackageJson,
303
+ field: 'dependencies' | 'assetDependencies',
304
+ values: Record<string, string>,
305
+ ): boolean {
306
+ const entries = Object.entries(values)
307
+ if (entries.length === 0) return false
308
+
309
+ const current = pkg[field] ?? {}
310
+ let changed = false
311
+
312
+ for (const [name, range] of entries) {
313
+ if (current[name] === range) continue
314
+ current[name] = range
315
+ changed = true
316
+ }
317
+
318
+ pkg[field] = current
319
+ return changed
320
+ }
321
+
322
+ function assetDependenciesToSave(
323
+ resolution: ResolveResult,
324
+ rootRequests: InstallRootRequest[],
325
+ metadata: Record<string, AssetInstallMetadata>,
326
+ ): Record<string, string> {
327
+ const resolvedByName = new Map(resolution.assets.map((asset) => [asset.name, asset]))
328
+ const out: Record<string, string> = {}
329
+
330
+ for (const request of rootRequests) {
331
+ if (!request.save) continue
332
+
333
+ const asset = resolvedByName.get(request.name)
334
+ if (!asset) continue
335
+
336
+ if ((metadata[asset.type]?.saveOnInstall ?? true) === false) continue
337
+ out[asset.name] = request.saveRange
338
+ }
339
+
340
+ return out
341
+ }
342
+
343
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
344
+ if (a.length !== b.length) return false
345
+ for (let i = 0; i < a.length; i += 1) {
346
+ if (a[i] !== b[i]) return false
347
+ }
348
+ return true
349
+ }
350
+
189
351
  async function installSkills(
190
352
  resolution: ResolveResult,
191
353
  projectRoot: string,