@drawcall/market 0.1.36 → 0.1.38

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.
@@ -7,6 +7,12 @@ import type {
7
7
  } from './contract.js'
8
8
  import type { AssetType } from './schemas.js'
9
9
 
10
+ export const ASSET_RELEVANCE_SCORE: unique symbol = Symbol('assetRelevanceScore')
11
+
12
+ export interface RankedAssetSearchResult extends AssetSearchResult {
13
+ [ASSET_RELEVANCE_SCORE]?: number
14
+ }
15
+
10
16
  export interface AssetSearchInput {
11
17
  page: number
12
18
  limit: number
@@ -63,6 +69,10 @@ export interface AssetProvider {
63
69
 
64
70
  export type AssetImplementation = AssetProvider
65
71
 
72
+ export function assetRelevanceScore(asset: AssetSearchResult): number | undefined {
73
+ return (asset as RankedAssetSearchResult)[ASSET_RELEVANCE_SCORE]
74
+ }
75
+
66
76
  export function installMetadataForProvider(provider: AssetProvider): AssetInstallMetadata {
67
77
  return {
68
78
  installMessage: provider.installMessage,
@@ -71,3 +81,15 @@ export function installMetadataForProvider(provider: AssetProvider): AssetInstal
71
81
  omitUnchangedInstalledFilesOnUpload: provider.omitUnchangedInstalledFilesOnUpload ?? false,
72
82
  }
73
83
  }
84
+
85
+ export function withAssetRelevanceScore(
86
+ asset: AssetSearchResult,
87
+ score: number,
88
+ ): RankedAssetSearchResult {
89
+ const ranked = { ...asset } as RankedAssetSearchResult
90
+ Object.defineProperty(ranked, ASSET_RELEVANCE_SCORE, {
91
+ value: score,
92
+ enumerable: false,
93
+ })
94
+ return ranked
95
+ }
package/src/cli.ts CHANGED
@@ -9,6 +9,7 @@ import { ASSET_TYPES, type AssetType } from './schemas.js'
9
9
  import { installCommand } from './commands/install.js'
10
10
  import { searchCommand } from './commands/search.js'
11
11
  import { generateCommand } from './commands/generate.js'
12
+ import { listCommand } from './commands/list.js'
12
13
  import { previewCommand } from './commands/preview.js'
13
14
  import { uploadCommand } from './commands/upload.js'
14
15
  import { logout } from './commands/logout.js'
@@ -95,6 +96,16 @@ program
95
96
  },
96
97
  )
97
98
 
99
+ program
100
+ .command('list')
101
+ .description('List locally installed assets')
102
+ .option('--cwd <dir>', 'Project directory')
103
+ .action(async (opts: { cwd?: string }) => {
104
+ await listCommand({
105
+ cwd: opts.cwd,
106
+ })
107
+ })
108
+
98
109
  program
99
110
  .command('search')
100
111
  .description('Find assets')
@@ -0,0 +1,47 @@
1
+ import * as path from 'path'
2
+ import { findInstallRoot } from '../install.js'
3
+ import { MARKET_LOCK_PATH, readMarketLock } from '../market-lock.js'
4
+ import { listResult } from '../output.js'
5
+
6
+ export interface ListCommandOptions {
7
+ cwd?: string
8
+ }
9
+
10
+ export interface InstalledAssetListing {
11
+ name: string
12
+ version: string
13
+ type: string
14
+ files: string[]
15
+ }
16
+
17
+ export interface ListInstalledAssetsResult {
18
+ projectRoot: string
19
+ lockPath: string
20
+ assets: InstalledAssetListing[]
21
+ }
22
+
23
+ export async function listCommand(opts: ListCommandOptions): Promise<void> {
24
+ const result = await listInstalledAssets(opts.cwd)
25
+ console.log(listResult(result))
26
+ }
27
+
28
+ export async function listInstalledAssets(
29
+ cwd: string | undefined,
30
+ ): Promise<ListInstalledAssetsResult> {
31
+ const projectRoot = await findInstallRoot(cwd ?? process.cwd())
32
+ const lock = await readMarketLock(projectRoot)
33
+ const assets = Object.entries(lock.assets)
34
+ .map(([name, asset]) => ({
35
+ name,
36
+ version: asset.version,
37
+ type: asset.type,
38
+ files: Object.keys(asset.files).sort((a, b) => a.localeCompare(b)),
39
+ }))
40
+ .sort((a, b) => a.name.localeCompare(b.name))
41
+
42
+ return {
43
+ projectRoot,
44
+ lockPath: path.join(projectRoot, MARKET_LOCK_PATH),
45
+ assets,
46
+ }
47
+ }
package/src/contract.ts CHANGED
@@ -19,6 +19,7 @@ export interface AssetVersion {
19
19
  assetDependencies: string
20
20
  skillDependencies: string
21
21
  sourceKey: string
22
+ sourceSizeBytes: number | null
22
23
  createdAt: Date
23
24
  }
24
25
 
@@ -45,6 +46,7 @@ export interface AssetSearchResult {
45
46
  npmDependencies: string
46
47
  assetDependencies: string
47
48
  skillDependencies: string
49
+ sourceSizeBytes: number | null
48
50
  /** Public URL of the preview image, or null for types without one. */
49
51
  previewUrl: string | null
50
52
  }
package/src/index.ts CHANGED
@@ -23,10 +23,16 @@ export type {
23
23
  AssetGenerateInput,
24
24
  AssetImplementation,
25
25
  AssetProvider,
26
+ RankedAssetSearchResult,
26
27
  AssetSearchInput,
27
28
  AssetUploadZipInput,
28
29
  } from './asset-implementation.js'
29
- export { installMetadataForProvider } from './asset-implementation.js'
30
+ export {
31
+ ASSET_RELEVANCE_SCORE,
32
+ assetRelevanceScore,
33
+ installMetadataForProvider,
34
+ withAssetRelevanceScore,
35
+ } from './asset-implementation.js'
30
36
 
31
37
  // Resolve
32
38
  export { resolve, ResolutionError } from './resolve.js'
package/src/output.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AssetInstallMetadata, AssetSearchResult } from './contract.js'
2
+ import type { ListInstalledAssetsResult } from './commands/list.js'
2
3
  import type { InstallResult } from './install.js'
3
4
 
4
5
  export function assetVersionRef(name: string, version?: string): string {
@@ -40,14 +41,11 @@ export function installResult(
40
41
  const lines = ['Installed:']
41
42
 
42
43
  for (const asset of result.assets) {
43
- const files = unique(asset.files)
44
- lines.push(`- ${assetVersionRef(asset.name, asset.version)} (${asset.type})`)
44
+ lines.push(assetLine(asset))
45
45
  if (asset.description) {
46
46
  lines.push(...block(' description: ', asset.description))
47
47
  }
48
- if (files.length > 0) {
49
- lines.push(' files:', ...indent(fileTree(files), ' '))
50
- }
48
+ lines.push(...assetFileLines(asset.files))
51
49
  const message = installMetadata[asset.type]?.installMessage
52
50
  if (message) {
53
51
  lines.push(...block(' note: ', message))
@@ -78,6 +76,32 @@ export function installResult(
78
76
  return lines.join('\n')
79
77
  }
80
78
 
79
+ export function listResult(result: ListInstalledAssetsResult): string {
80
+ if (result.assets.length === 0) {
81
+ return 'No installed assets found.'
82
+ }
83
+
84
+ const lines = [`Installed assets: ${result.assets.length}`]
85
+ for (const asset of result.assets) {
86
+ lines.push(assetLine(asset), ...assetFileLines(asset.files))
87
+ }
88
+
89
+ return lines.join('\n')
90
+ }
91
+
92
+ function assetLine(asset: { name: string; version: string; type: string }): string {
93
+ return `- ${assetVersionRef(asset.name, asset.version)} (${asset.type})`
94
+ }
95
+
96
+ function assetFileLines(paths: string[]): string[] {
97
+ const lines: string[] = []
98
+ const files = unique(paths)
99
+ if (files.length > 0) {
100
+ lines.push(' files:', ...indent(fileTree(files), ' '))
101
+ }
102
+ return lines
103
+ }
104
+
81
105
  function block(label: string, message: string, width = 68): string[] {
82
106
  const hang = ' '.repeat(label.length)
83
107
  const words = normalizeInline(message).split(' ').filter(Boolean)
package/src/skill.ts CHANGED
@@ -12,20 +12,22 @@ Use the \`market\` CLI. Keep commands short and read the summary lines.
12
12
  \`\`\`sh
13
13
  market search "wooden chair" --type model --limit 3
14
14
  market install wooden-chair --cwd "$PWD"
15
+ market list --cwd "$PWD"
15
16
  market preview wooden-chair --out /tmp/wooden-chair.png
16
17
  \`\`\`
17
18
 
18
19
  ## Workflow
19
20
 
20
- 1. Search first unless the user already gave an exact asset name. \`search\` requires \`--type\`; use \`model\` unless the user names another supported type: \`humanoid-model\`, \`texture\`, \`humanoid-animation\`, \`template\`, \`sound-effect\`, \`background-music\`, \`environment\`, or \`flipbook\`.
21
- 2. Use \`--limit 1\` for lookup, \`--limit 3\` for choice. Search caps at 5 and prints full descriptions.
22
- 3. \`install\` takes zero or more exact asset names (optionally \`name@range\`). With names, it installs those assets; with no names, it installs \`assetDependencies\` from the nearest \`package.json\`. It does not search or generate. Find names with \`search\` first. No \`--type\` is needed — asset names are unique. Use \`--force\` only when the user agrees to overwrite changed local files.
23
- 4. \`preview <name>\` saves the preview image; no \`--type\` is needed. Not every type has previews (e.g. \`humanoid-animation\`, \`template\`, \`sound-effect\`, \`background-music\`); the CLI reports when one is unavailable.
24
- 5. Use \`--unapproved\` only when the user asks for unapproved/private/admin assets. Do not install unapproved assets without explicit acceptance.
25
- 6. \`generate --type <type> "<prompt>"\` creates a new asset; it requires login and a type that supports generation. No asset type currently supports generation.
26
- 7. Upload only when publishing is requested: \`market upload <name> <zip> "<description>" --type <type>\`. Declare dependencies with repeatable flags: \`--npm name@range\`, \`--asset name@range\`, \`--skill label=source\`. Template uploads also read root \`package.json.assetDependencies\`; \`--asset\` flags are additive and must not conflict. Template upload omits installed dependency files whose hashes still match \`.drawcall/market-lock.json\`, so edited local files stay in the template. A skill source is a \`skills add\` argument: a whole repo (\`owner/repo\` or a git URL), a single skill via the full URL form \`https://github.com/owner/repo/tree/<branch>/<subpath>\` (the \`tree/<branch>/<subpath>\` shorthand needs the full URL, not \`owner/repo\`), or a local path to a skill directory inside the zip. Example: \`market upload my-scene scene.zip "A scene" --type model --npm three@^0.178.0 --skill web-design=https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines\`.
27
- 8. Installed \`environment\` assets contain \`public/environment/<name>.hdr\` for Three.js IBL lighting and \`public/environment/<name>-background.webp\` for the visible equirectangular background. Use \`market preview\` to fetch the preview image separately.
28
- 9. Installed \`flipbook\` assets contain \`public/flipbook/<name>.ktx2\`. Render them with \`@drawcall/flipbook\`'s \`Flipbook\` class and Three.js \`KTX2Loader\` for Basis-compressed files; \`market preview\` fetches the middle frame from the flipbook.
21
+ 1. In an existing repo, run \`list --cwd "$PWD"\` first to see installed local assets from \`.drawcall/market-lock.json\`. Use the listed names with \`preview <name>\` when you want preview images.
22
+ 2. Search first unless the user already gave an exact asset name. \`search\` requires \`--type\`; use \`model\` unless the user names another supported type: \`humanoid-model\`, \`texture\`, \`humanoid-animation\`, \`template\`, \`sound-effect\`, \`background-music\`, \`environment\`, or \`flipbook\`.
23
+ 3. Use \`--limit 1\` for lookup, \`--limit 3\` for choice. Search caps at 5 and prints full descriptions.
24
+ 4. \`install\` takes zero or more exact asset names (optionally \`name@range\`). With names, it installs those assets; with no names, it installs \`assetDependencies\` from the nearest \`package.json\`. It does not search or generate. Find names with \`search\` first. No \`--type\` is needed — asset names are unique. Use \`--force\` only when the user agrees to overwrite changed local files.
25
+ 5. \`preview <name>\` saves the preview image; no \`--type\` is needed. Not every type has previews (e.g. \`humanoid-animation\`, \`template\`, \`sound-effect\`, \`background-music\`); the CLI reports when one is unavailable.
26
+ 6. Use \`--unapproved\` only when the user asks for unapproved/private/admin assets. Do not install unapproved assets without explicit acceptance.
27
+ 7. \`generate --type <type> "<prompt>"\` creates a new asset; it requires login and a type that supports generation. No asset type currently supports generation.
28
+ 8. Upload only when publishing is requested: \`market upload <name> <zip> "<description>" --type <type>\`. Declare dependencies with repeatable flags: \`--npm name@range\`, \`--asset name@range\`, \`--skill label=source\`. Template uploads also read root \`package.json.assetDependencies\`; \`--asset\` flags are additive and must not conflict. Template upload omits installed dependency files whose hashes still match \`.drawcall/market-lock.json\`, so edited local files stay in the template. A skill source is a \`skills add\` argument: a whole repo (\`owner/repo\` or a git URL), a single skill via the full URL form \`https://github.com/owner/repo/tree/<branch>/<subpath>\` (the \`tree/<branch>/<subpath>\` shorthand needs the full URL, not \`owner/repo\`), or a local path to a skill directory inside the zip. Example: \`market upload my-scene scene.zip "A scene" --type model --npm three@^0.178.0 --skill web-design=https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines\`.
29
+ 9. Installed \`environment\` assets contain \`public/environment/<name>.hdr\` for Three.js IBL lighting and \`public/environment/<name>-background.webp\` for the visible equirectangular background. Use \`market preview\` to fetch the preview image separately.
30
+ 10. Installed \`flipbook\` assets contain \`public/flipbook/<name>.ktx2\`. Render them with \`@drawcall/flipbook\`'s \`Flipbook\` class and Three.js \`KTX2Loader\` for Basis-compressed files; \`market preview\` fetches the middle frame from the flipbook.
29
31
 
30
32
  ## Humanoid animations
31
33
 
@@ -48,6 +50,11 @@ Installed:
48
50
  └─ wooden-chair.glb
49
51
  - three@^0.178.0 (npm)
50
52
  - web-design ← https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines (skill)
53
+ Installed assets: 1
54
+ - wooden-chair@1.0.0 (model)
55
+ files:
56
+ public/model
57
+ └─ wooden-chair.glb
51
58
  Saved preview for wooden-chair@1.0.0: /tmp/wooden-chair.png
52
59
  \`\`\`
53
60
 
@@ -55,5 +62,7 @@ Assets may also declare \`skill\` dependencies, installed for you via the \`skil
55
62
 
56
63
  Installed non-template assets are saved to \`package.json.assetDependencies\`; templates are scaffolds and are not saved as project asset dependencies. Exact installed versions and file hashes are recorded in \`.drawcall/market-lock.json\`.
57
64
 
65
+ \`list\` is offline: it reads \`.drawcall/market-lock.json\` from the nearest package root and prints exact installed names, versions, types, and installed file paths.
66
+
58
67
  If search returns no results, try one broader noun phrase. If a command returns \`Error: Not logged in...\`, ask before running \`market login\`.
59
68
  `
@@ -1,67 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import test from 'node:test'
3
- import { resolveArg } from '../src/commands/install.js'
4
-
5
- test('resolveArg reports unapproved explicit assets clearly', async () => {
6
- const client = {
7
- asset: {
8
- exact: async ({ includeUnapproved }: { name: string; includeUnapproved: boolean }) =>
9
- includeUnapproved
10
- ? {
11
- name: 'qwantani-moon-noon-puresky',
12
- latestVersion: '1.0.2',
13
- approved: false,
14
- }
15
- : null,
16
- },
17
- } as never
18
-
19
- await assert.rejects(
20
- resolveArg(client, 'qwantani-moon-noon-puresky@1.0.2', false),
21
- /has no approved versions/u,
22
- )
23
- })
24
-
25
- test('resolveArg keeps explicit versioned refs exact when the asset exists', async () => {
26
- const client = {
27
- asset: {
28
- exact: async () => ({ name: 'fresh-sky', latestVersion: '1.2.3', approved: true }),
29
- },
30
- } as never
31
-
32
- const request = await resolveArg(client, 'fresh-sky@1.2.3', false)
33
-
34
- assert.deepEqual(request, {
35
- name: 'fresh-sky',
36
- range: '1.2.3',
37
- saveRange: '1.2.3',
38
- save: true,
39
- })
40
- })
41
-
42
- test('resolveArg saves an implicit range from the selected latest version', async () => {
43
- const client = {
44
- asset: {
45
- exact: async () => ({ name: 'fresh-sky', latestVersion: '1.2.3', approved: true }),
46
- },
47
- } as never
48
-
49
- const request = await resolveArg(client, 'fresh-sky', false)
50
-
51
- assert.deepEqual(request, {
52
- name: 'fresh-sky',
53
- range: '*',
54
- saveRange: '^1.2.3',
55
- save: true,
56
- })
57
- })
58
-
59
- test('resolveArg errors when the asset does not exist instead of deferring', async () => {
60
- const client = {
61
- asset: {
62
- exact: async () => null,
63
- },
64
- } as never
65
-
66
- await assert.rejects(resolveArg(client, 'does-not-exist@1.2.3', false), /not found/u)
67
- })
@@ -1,307 +0,0 @@
1
- import assert from 'node:assert/strict'
2
- import * as fs from 'node:fs/promises'
3
- import * as os from 'node:os'
4
- import * as path from 'node:path'
5
- import test from 'node:test'
6
- import { zipSync } from 'fflate'
7
- import { findInstallRoot, install } from '../src/install.js'
8
- import { MARKET_LOCK_PATH } from '../src/market-lock.js'
9
-
10
- const textEncoder = new TextEncoder()
11
-
12
- test('install writes zip files into the package root', async () => {
13
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'market-install-'))
14
- const appRoot = path.join(tempDir, 'app')
15
- const cwd = path.join(appRoot, 'src', 'feature')
16
- await fs.mkdir(path.join(appRoot, 'public'), { recursive: true })
17
- await fs.mkdir(cwd, { recursive: true })
18
- await fs.writeFile(path.join(appRoot, 'package.json'), '{}\n')
19
- await fs.writeFile(path.join(appRoot, 'README.md'), 'project readme\n')
20
-
21
- const result = await install(
22
- clientWithZip({
23
- 'public/humanoid-animation/idle-loop.glb': textEncoder.encode('glb'),
24
- 'src/generated/idle-loop.ts': textEncoder.encode('export const idleLoop = true\n'),
25
- 'README.md': textEncoder.encode('asset readme'),
26
- }),
27
- {
28
- assets: [
29
- {
30
- name: 'idle-loop',
31
- type: 'humanoid-animation',
32
- version: '1.0.0',
33
- description: 'Idle loop animation',
34
- npmDependencies: {},
35
- assetDependencies: {},
36
- skillDependencies: {},
37
- },
38
- ],
39
- npmDependencies: {},
40
- skillDependencies: {},
41
- },
42
- { cwd },
43
- )
44
-
45
- assert.deepEqual(result, {
46
- assets: [
47
- {
48
- name: 'idle-loop',
49
- type: 'humanoid-animation',
50
- version: '1.0.0',
51
- description: 'Idle loop animation',
52
- files: ['public/humanoid-animation/idle-loop.glb', 'src/generated/idle-loop.ts'],
53
- },
54
- ],
55
- npmDependencies: {},
56
- skillDependencies: {},
57
- warnings: [],
58
- })
59
- assert.equal(
60
- await fs.readFile(path.join(appRoot, 'public', 'humanoid-animation', 'idle-loop.glb'), 'utf-8'),
61
- 'glb',
62
- )
63
- assert.equal(
64
- await fs.readFile(path.join(appRoot, 'src', 'generated', 'idle-loop.ts'), 'utf-8'),
65
- 'export const idleLoop = true\n',
66
- )
67
- assert.equal(await fs.readFile(path.join(appRoot, 'README.md'), 'utf-8'), 'project readme\n')
68
- assert.equal(await exists(path.join(cwd, 'src', 'idle-loop', 'public')), false)
69
-
70
- const lock = JSON.parse(await fs.readFile(path.join(appRoot, MARKET_LOCK_PATH), 'utf-8')) as {
71
- assets: Record<string, { version: string; files: Record<string, { sha256: string }> }>
72
- }
73
- assert.equal(lock.assets['idle-loop'].version, '1.0.0')
74
- assert.equal(
75
- typeof lock.assets['idle-loop'].files['public/humanoid-animation/idle-loop.glb'].sha256,
76
- 'string',
77
- )
78
- })
79
-
80
- test('install rejects zip paths that escape through parent segments', async () => {
81
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'market-install-'))
82
- await fs.writeFile(path.join(tempDir, 'package.json'), '{}\n')
83
-
84
- await assert.rejects(
85
- install(
86
- clientWithZip({
87
- 'public/../package.json': textEncoder.encode('nope\n'),
88
- }),
89
- {
90
- assets: [
91
- {
92
- name: 'bad-path',
93
- type: 'template',
94
- version: '1.0.0',
95
- description: null,
96
- npmDependencies: {},
97
- assetDependencies: {},
98
- skillDependencies: {},
99
- },
100
- ],
101
- npmDependencies: {},
102
- skillDependencies: {},
103
- },
104
- { cwd: tempDir },
105
- ),
106
- /unsafe path/u,
107
- )
108
- })
109
-
110
- test('install runs the skills runner per dependency, resolving local paths against the root', async () => {
111
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'market-skills-'))
112
- const appRoot = path.join(tempDir, 'app')
113
- const cwd = path.join(appRoot, 'src')
114
- await fs.mkdir(cwd, { recursive: true })
115
- await fs.writeFile(path.join(appRoot, 'package.json'), '{}\n')
116
-
117
- const calls: Array<{ source: string; cwd: string }> = []
118
-
119
- const result = await install(
120
- clientWithZip({
121
- 'public/skills/local-skill/SKILL.md': textEncoder.encode('---\nname: local-skill\n---\n'),
122
- }),
123
- {
124
- assets: [
125
- {
126
- name: 'with-skills',
127
- type: 'template',
128
- version: '1.0.0',
129
- description: null,
130
- npmDependencies: {},
131
- assetDependencies: {},
132
- skillDependencies: {},
133
- },
134
- ],
135
- npmDependencies: {},
136
- skillDependencies: {
137
- 'web-design-guidelines': 'vercel-labs/agent-skills/tree/main/skills/web-design-guidelines',
138
- 'local-skill': './public/skills/local-skill',
139
- },
140
- },
141
- {
142
- cwd,
143
- runSkillAdd: async (source, runnerCwd) => {
144
- calls.push({ source, cwd: runnerCwd })
145
- },
146
- },
147
- )
148
-
149
- // Every runner call uses the install root (the dir holding package.json).
150
- assert.deepEqual(new Set(calls.map((c) => c.cwd)), new Set([appRoot]))
151
-
152
- const bySource = new Map(calls.map((c) => [c.source, c]))
153
- // Remote refs are passed through untouched.
154
- assert.ok(bySource.has('vercel-labs/agent-skills/tree/main/skills/web-design-guidelines'))
155
- // Local paths are resolved against the install root, not the cwd.
156
- assert.ok(bySource.has(path.join(appRoot, 'public', 'skills', 'local-skill')))
157
- assert.equal(calls.length, 2)
158
-
159
- assert.deepEqual(result.skillDependencies, {
160
- 'web-design-guidelines': 'vercel-labs/agent-skills/tree/main/skills/web-design-guidelines',
161
- 'local-skill': './public/skills/local-skill',
162
- })
163
- assert.deepEqual(result.warnings, [])
164
- })
165
-
166
- test('install saves top-level assets according to provider policy', async () => {
167
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'market-save-policy-'))
168
- await fs.writeFile(path.join(tempDir, 'package.json'), '{}\n')
169
-
170
- await install(
171
- clientWithZip({
172
- 'public/model/chair.glb': textEncoder.encode('chair'),
173
- 'src/template.ts': textEncoder.encode('export {}\n'),
174
- }),
175
- {
176
- assets: [
177
- {
178
- name: 'chair',
179
- type: 'model',
180
- version: '1.2.0',
181
- description: null,
182
- npmDependencies: {},
183
- assetDependencies: {},
184
- skillDependencies: {},
185
- },
186
- {
187
- name: 'starter-template',
188
- type: 'template',
189
- version: '2.0.0',
190
- description: null,
191
- npmDependencies: {},
192
- assetDependencies: {},
193
- skillDependencies: {},
194
- },
195
- ],
196
- npmDependencies: {},
197
- skillDependencies: {},
198
- },
199
- {
200
- cwd: tempDir,
201
- rootRequests: [
202
- { name: 'chair', range: '*', saveRange: '^1.2.0', save: true },
203
- { name: 'starter-template', range: '*', saveRange: '^2.0.0', save: true },
204
- ],
205
- installMetadata: {
206
- template: {
207
- saveOnInstall: false,
208
- readAssetDependenciesFromPackageJson: true,
209
- omitUnchangedInstalledFilesOnUpload: true,
210
- },
211
- },
212
- },
213
- )
214
-
215
- const pkg = JSON.parse(await fs.readFile(path.join(tempDir, 'package.json'), 'utf-8')) as {
216
- assetDependencies: Record<string, string>
217
- }
218
- assert.deepEqual(pkg.assetDependencies, { chair: '^1.2.0' })
219
- })
220
-
221
- test('install skips existing changed files unless force is set', async () => {
222
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'market-conflict-'))
223
- await fs.mkdir(path.join(tempDir, 'public', 'model'), { recursive: true })
224
- await fs.writeFile(path.join(tempDir, 'package.json'), '{}\n')
225
- await fs.writeFile(path.join(tempDir, 'public', 'model', 'chair.glb'), 'local edit')
226
-
227
- const resolution = {
228
- assets: [
229
- {
230
- name: 'chair',
231
- type: 'model',
232
- version: '1.0.0',
233
- description: null,
234
- npmDependencies: {},
235
- assetDependencies: {},
236
- skillDependencies: {},
237
- },
238
- ],
239
- npmDependencies: {},
240
- skillDependencies: {},
241
- }
242
-
243
- const skipped = await install(
244
- clientWithZip({
245
- 'public/model/chair.glb': textEncoder.encode('market bytes'),
246
- }),
247
- resolution,
248
- { cwd: tempDir },
249
- )
250
-
251
- assert.equal(await fs.readFile(path.join(tempDir, 'public', 'model', 'chair.glb'), 'utf-8'), 'local edit')
252
- assert.match(skipped.warnings[0], /Skipped public\/model\/chair\.glb/u)
253
- assert.deepEqual(skipped.assets[0].files, [])
254
-
255
- const forced = await install(
256
- clientWithZip({
257
- 'public/model/chair.glb': textEncoder.encode('market bytes'),
258
- }),
259
- resolution,
260
- { cwd: tempDir, force: true },
261
- )
262
-
263
- assert.equal(
264
- await fs.readFile(path.join(tempDir, 'public', 'model', 'chair.glb'), 'utf-8'),
265
- 'market bytes',
266
- )
267
- assert.deepEqual(forced.warnings, [])
268
- assert.deepEqual(forced.assets[0].files, ['public/model/chair.glb'])
269
- })
270
-
271
- test('findInstallRoot uses the nearest package.json, not the monorepo root', async () => {
272
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'market-root-'))
273
- const repoRoot = path.join(tempDir, 'repo')
274
- const appRoot = path.join(repoRoot, 'packages', 'app')
275
- const cwd = path.join(appRoot, 'src', 'routes')
276
- await fs.mkdir(path.join(appRoot, 'public'), { recursive: true })
277
- await fs.mkdir(cwd, { recursive: true })
278
- await fs.writeFile(path.join(repoRoot, 'package.json'), '{}\n')
279
- await fs.writeFile(path.join(appRoot, 'package.json'), '{}\n')
280
-
281
- assert.equal(await findInstallRoot(cwd), appRoot)
282
- })
283
-
284
- test('findInstallRoot falls back to cwd when no package.json exists', async () => {
285
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'market-root-'))
286
- const cwd = path.join(tempDir, 'repo', 'src')
287
- await fs.mkdir(cwd, { recursive: true })
288
-
289
- assert.equal(await findInstallRoot(cwd), cwd)
290
- })
291
-
292
- function clientWithZip(files: Record<string, Uint8Array>) {
293
- return {
294
- asset: {
295
- downloadZip: async () => new Blob([zipSync(files)]),
296
- },
297
- } as never
298
- }
299
-
300
- async function exists(file: string): Promise<boolean> {
301
- try {
302
- await fs.stat(file)
303
- return true
304
- } catch {
305
- return false
306
- }
307
- }