@drawcall/market 0.1.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.
Files changed (47) hide show
  1. package/dist/cli.d.ts +3 -0
  2. package/dist/cli.d.ts.map +1 -0
  3. package/dist/cli.js +61 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/client.d.ts +12 -0
  6. package/dist/client.d.ts.map +1 -0
  7. package/dist/client.js +26 -0
  8. package/dist/client.js.map +1 -0
  9. package/dist/constants.d.ts +5 -0
  10. package/dist/constants.d.ts.map +1 -0
  11. package/dist/constants.js +16 -0
  12. package/dist/constants.js.map +1 -0
  13. package/dist/contract.d.ts +198 -0
  14. package/dist/contract.d.ts.map +1 -0
  15. package/dist/contract.js +64 -0
  16. package/dist/contract.js.map +1 -0
  17. package/dist/index.d.ts +13 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +12 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/install.d.ts +20 -0
  22. package/dist/install.d.ts.map +1 -0
  23. package/dist/install.js +67 -0
  24. package/dist/install.js.map +1 -0
  25. package/dist/internal-contract.d.ts +19 -0
  26. package/dist/internal-contract.d.ts.map +1 -0
  27. package/dist/internal-contract.js +19 -0
  28. package/dist/internal-contract.js.map +1 -0
  29. package/dist/resolve.d.ts +32 -0
  30. package/dist/resolve.d.ts.map +1 -0
  31. package/dist/resolve.js +145 -0
  32. package/dist/resolve.js.map +1 -0
  33. package/dist/schemas.d.ts +65 -0
  34. package/dist/schemas.d.ts.map +1 -0
  35. package/dist/schemas.js +53 -0
  36. package/dist/schemas.js.map +1 -0
  37. package/package.json +31 -0
  38. package/src/cli.ts +72 -0
  39. package/src/client.ts +38 -0
  40. package/src/constants.ts +19 -0
  41. package/src/contract.ts +188 -0
  42. package/src/index.ts +46 -0
  43. package/src/install.ts +101 -0
  44. package/src/internal-contract.ts +26 -0
  45. package/src/resolve.ts +215 -0
  46. package/src/schemas.ts +70 -0
  47. package/tsconfig.json +8 -0
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ // Client
2
+ export { createMarketClient, createInternalClient } from './client.js'
3
+ export type { MarketClient, InternalClient, MarketClientOptions } from './client.js'
4
+
5
+ // Contracts
6
+ export { contract } from './contract.js'
7
+ export type { AppContract } from './contract.js'
8
+ export { internalContract } from './internal-contract.js'
9
+ export type { InternalContract } from './internal-contract.js'
10
+
11
+ // Contract output types
12
+ export type {
13
+ Asset,
14
+ AssetVersion,
15
+ AssetWithVersionsAndTags,
16
+ AssetWithVersions,
17
+ AssetListItem,
18
+ PaginatedList,
19
+ User,
20
+ UnapprovedItem,
21
+ TagWithCount,
22
+ FileTreeEntry,
23
+ } from './contract.js'
24
+
25
+ // Resolve
26
+ export { resolve, ResolutionError } from './resolve.js'
27
+ export type { ResolvedAsset, ResolveResult } from './resolve.js'
28
+
29
+ // Schemas
30
+ export {
31
+ ASSET_TYPES,
32
+ assetTypeSchema,
33
+ semverSchema,
34
+ assetNameSchema,
35
+ npmDependenciesSchema,
36
+ assetDependenciesSchema,
37
+ uploadGenericSchema,
38
+ uploadTypedSchema,
39
+ uploadMaterialSchema,
40
+ updateProfileSchema,
41
+ listAssetsSchema,
42
+ } from './schemas.js'
43
+ export type { AssetType } from './schemas.js'
44
+
45
+ // Constants
46
+ export { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, ASSET_TYPE_LABELS } from './constants.js'
package/src/install.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Install resolved assets into a local project.
3
+ *
4
+ * 1. Downloads asset files into ./src/{assetName}/ via the oRPC client.
5
+ * 2. Merges npm dependencies into package.json.
6
+ * 3. Runs the package manager to install npm deps.
7
+ *
8
+ * NOTE: This module uses Node.js APIs (fs, path, nypm) and is only
9
+ * used by the CLI binary — it is NOT exported from the package index.
10
+ */
11
+
12
+ import * as fs from 'fs/promises'
13
+ import * as path from 'path'
14
+ import { detectPackageManager, installDependencies } from 'nypm'
15
+ import type { MarketClient } from './client.js'
16
+ import type { ResolveResult } from './resolve.js'
17
+
18
+ export interface InstallOptions {
19
+ /** Project root directory (default: cwd) */
20
+ cwd?: string
21
+ /** Log progress */
22
+ onProgress?: (message: string) => void
23
+ }
24
+
25
+ export async function install(
26
+ client: MarketClient,
27
+ resolution: ResolveResult,
28
+ opts: InstallOptions = {},
29
+ ): Promise<void> {
30
+ const cwd = opts.cwd ?? process.cwd()
31
+ const log = opts.onProgress ?? (() => {})
32
+
33
+ // Run asset file downloads and npm dep installation in parallel
34
+ await Promise.all([
35
+ downloadAssets(client, resolution, cwd, log),
36
+ installNpmDeps(resolution, cwd, log),
37
+ ])
38
+ }
39
+
40
+ async function downloadAssets(
41
+ client: MarketClient,
42
+ resolution: ResolveResult,
43
+ cwd: string,
44
+ log: (msg: string) => void,
45
+ ): Promise<void> {
46
+ for (const asset of resolution.assets) {
47
+ const destDir = path.join(cwd, 'src', asset.name)
48
+ await fs.mkdir(destDir, { recursive: true })
49
+
50
+ log(`Downloading ${asset.name}@${asset.version}...`)
51
+
52
+ const tree = await client.asset.getVersionTree({ name: asset.name, version: asset.version })
53
+
54
+ for (const file of tree) {
55
+ const filePath = path.join(destDir, file.path)
56
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
57
+
58
+ const blob = await client.asset.getRawFile({
59
+ name: asset.name,
60
+ version: asset.version,
61
+ path: file.path,
62
+ })
63
+ const content = Buffer.from(await blob.arrayBuffer())
64
+ await fs.writeFile(filePath, content)
65
+ }
66
+
67
+ log(` ${tree.length} files → src/${asset.name}/`)
68
+ }
69
+ }
70
+
71
+ async function installNpmDeps(
72
+ resolution: ResolveResult,
73
+ cwd: string,
74
+ log: (msg: string) => void,
75
+ ): Promise<void> {
76
+ const deps = resolution.npmDependencies
77
+ if (Object.keys(deps).length === 0) return
78
+
79
+ // Read existing package.json
80
+ const pkgPath = path.join(cwd, 'package.json')
81
+ let pkg: any
82
+ try {
83
+ pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'))
84
+ } catch {
85
+ pkg = { name: 'my-project', private: true, dependencies: {} }
86
+ }
87
+
88
+ // Merge dependencies
89
+ pkg.dependencies = { ...pkg.dependencies, ...deps }
90
+ await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
91
+
92
+ log(`Installing npm dependencies: ${Object.keys(deps).join(', ')}`)
93
+
94
+ // Detect package manager, fall back to npm
95
+ const pm = await detectPackageManager(cwd).catch(() => null)
96
+ const pmName = pm?.name ?? 'npm'
97
+
98
+ log(`Using ${pmName}...`)
99
+ await installDependencies({ cwd, packageManager: { name: pmName, command: pmName } })
100
+ log('npm dependencies installed.')
101
+ }
@@ -0,0 +1,26 @@
1
+ import { oc } from '@orpc/contract'
2
+ import { z } from 'zod'
3
+
4
+ export const internalContract = {
5
+ buildUpload: oc
6
+ .input(
7
+ z.object({
8
+ key: z.string(),
9
+ content: z.string(), // base64-encoded
10
+ contentType: z.string(),
11
+ }),
12
+ )
13
+ .output(z.object({ ok: z.boolean() })),
14
+
15
+ buildComplete: oc
16
+ .input(
17
+ z.object({
18
+ assetName: z.string(),
19
+ version: z.string(),
20
+ buildOutputKey: z.string(),
21
+ }),
22
+ )
23
+ .output(z.object({ ok: z.boolean() })),
24
+ }
25
+
26
+ export type InternalContract = typeof internalContract
package/src/resolve.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Dependency resolver for market assets.
3
+ *
4
+ * 1. Collects the target assets and all transitive asset dependencies.
5
+ * 2. For each asset, finds a version that satisfies ALL constraints from
6
+ * every dependent.
7
+ * 3. Merges npm dependency ranges across all resolved assets and checks
8
+ * that they are compatible.
9
+ */
10
+
11
+ import * as semver from 'semver'
12
+ import type { MarketClient } from './client.js'
13
+ import type { AssetWithVersionsAndTags } from './contract.js'
14
+
15
+ export interface ResolvedAsset {
16
+ name: string
17
+ version: string
18
+ npmDependencies: Record<string, string>
19
+ assetDependencies: Record<string, string>
20
+ }
21
+
22
+ export interface ResolveResult {
23
+ assets: ResolvedAsset[]
24
+ npmDependencies: Record<string, string>
25
+ }
26
+
27
+ interface AssetRequest {
28
+ name: string
29
+ range: string // semver range, e.g. "^1.0.0" or "*"
30
+ }
31
+
32
+ export class ResolutionError extends Error {
33
+ constructor(message: string) {
34
+ super(message)
35
+ this.name = 'ResolutionError'
36
+ }
37
+ }
38
+
39
+ export async function resolve(
40
+ client: MarketClient['asset'],
41
+ requests: AssetRequest[],
42
+ opts: { includeUnapproved?: boolean } = {},
43
+ ): Promise<ResolveResult> {
44
+ // constraints[assetName] = list of { range, from } constraints
45
+ const constraints: Map<string, { range: string; from: string }[]> = new Map()
46
+ // resolved[assetName] = ResolvedAsset
47
+ const resolved: Map<string, ResolvedAsset> = new Map()
48
+ // cache of fetched metadata
49
+ const metaCache: Map<string, AssetWithVersionsAndTags> = new Map()
50
+
51
+ // Seed constraints from the requested assets
52
+ for (const req of requests) {
53
+ addConstraint(constraints, req.name, req.range, '<root>')
54
+ }
55
+
56
+ // Iteratively resolve until stable
57
+ let unresolved = getUnresolved(constraints, resolved)
58
+ while (unresolved.length > 0) {
59
+ for (const assetName of unresolved) {
60
+ const meta = await fetchMeta(client, metaCache, assetName)
61
+ if (!meta) {
62
+ throw new ResolutionError(`Asset "${assetName}" not found.`)
63
+ }
64
+
65
+ // Filter to approved versions (unless --unapproved)
66
+ const candidates = meta.versions.filter((v) => opts.includeUnapproved || v.approved)
67
+
68
+ if (candidates.length === 0) {
69
+ throw new ResolutionError(
70
+ `Asset "${assetName}" has no ${opts.includeUnapproved ? '' : 'approved '}versions.`,
71
+ )
72
+ }
73
+
74
+ // Collect all constraints for this asset
75
+ const assetConstraints = constraints.get(assetName) ?? []
76
+
77
+ // Find the best (highest) version that satisfies ALL constraints
78
+ const candidateVersions = candidates.map((c) => c.version)
79
+ const satisfying = candidateVersions.filter((v) =>
80
+ assetConstraints.every((c) => semver.satisfies(v, c.range)),
81
+ )
82
+
83
+ if (satisfying.length === 0) {
84
+ const constraintDesc = assetConstraints
85
+ .map((c) => ` ${c.range} (from ${c.from})`)
86
+ .join('\n')
87
+ throw new ResolutionError(
88
+ `No version of "${assetName}" satisfies all constraints:\n${constraintDesc}\n` +
89
+ `Available${opts.includeUnapproved ? '' : ' approved'}: ${candidateVersions.join(', ')}`,
90
+ )
91
+ }
92
+
93
+ const best = semver.maxSatisfying(satisfying, '*')!
94
+ const versionMeta = candidates.find((c) => c.version === best)!
95
+
96
+ const npmDeps: Record<string, string> = JSON.parse(versionMeta.npmDependencies)
97
+ const assetDeps: Record<string, string> = JSON.parse(versionMeta.assetDependencies)
98
+
99
+ resolved.set(assetName, {
100
+ name: assetName,
101
+ version: best,
102
+ npmDependencies: npmDeps,
103
+ assetDependencies: assetDeps,
104
+ })
105
+
106
+ // Add transitive asset dependency constraints
107
+ for (const [depName, depRange] of Object.entries(assetDeps)) {
108
+ addConstraint(constraints, depName, depRange, `${assetName}@${best}`)
109
+ }
110
+ }
111
+
112
+ unresolved = getUnresolved(constraints, resolved)
113
+ }
114
+
115
+ // Verify all resolved versions still satisfy constraints (transitive deps
116
+ // may have added new constraints after we resolved a version)
117
+ for (const [assetName, asset] of resolved) {
118
+ const assetConstraints = constraints.get(assetName) ?? []
119
+ for (const c of assetConstraints) {
120
+ if (!semver.satisfies(asset.version, c.range)) {
121
+ throw new ResolutionError(
122
+ `Conflict: "${assetName}@${asset.version}" does not satisfy ` +
123
+ `${c.range} (required by ${c.from}).`,
124
+ )
125
+ }
126
+ }
127
+ }
128
+
129
+ // Merge npm dependencies across all resolved assets
130
+ const mergedNpm = mergeNpmDependencies(Array.from(resolved.values()))
131
+
132
+ return {
133
+ assets: Array.from(resolved.values()),
134
+ npmDependencies: mergedNpm,
135
+ }
136
+ }
137
+
138
+ function addConstraint(
139
+ constraints: Map<string, { range: string; from: string }[]>,
140
+ name: string,
141
+ range: string,
142
+ from: string,
143
+ ) {
144
+ const existing = constraints.get(name) ?? []
145
+ existing.push({ range, from })
146
+ constraints.set(name, existing)
147
+ }
148
+
149
+ function getUnresolved(
150
+ constraints: Map<string, { range: string; from: string }[]>,
151
+ resolved: Map<string, ResolvedAsset>,
152
+ ): string[] {
153
+ return Array.from(constraints.keys()).filter((name) => !resolved.has(name))
154
+ }
155
+
156
+ async function fetchMeta(
157
+ client: MarketClient['asset'],
158
+ cache: Map<string, AssetWithVersionsAndTags>,
159
+ name: string,
160
+ ): Promise<AssetWithVersionsAndTags | null> {
161
+ if (cache.has(name)) return cache.get(name)!
162
+ const meta = await client.getByName({ name })
163
+ if (meta) cache.set(name, meta)
164
+ return meta
165
+ }
166
+
167
+ /**
168
+ * Merge npm dependency ranges from all resolved assets.
169
+ * For each package, check that all declared ranges are compatible
170
+ * (using semver.intersects). Return the narrowest range.
171
+ */
172
+ function mergeNpmDependencies(assets: ResolvedAsset[]): Record<string, string> {
173
+ // Collect all ranges per package
174
+ const rangesPerPkg: Map<string, { range: string; from: string }[]> = new Map()
175
+
176
+ for (const asset of assets) {
177
+ for (const [pkg, range] of Object.entries(asset.npmDependencies)) {
178
+ const existing = rangesPerPkg.get(pkg) ?? []
179
+ existing.push({ range, from: `${asset.name}@${asset.version}` })
180
+ rangesPerPkg.set(pkg, existing)
181
+ }
182
+ }
183
+
184
+ const merged: Record<string, string> = {}
185
+
186
+ for (const [pkg, ranges] of rangesPerPkg) {
187
+ // Check pairwise compatibility
188
+ for (let i = 0; i < ranges.length; i++) {
189
+ for (let j = i + 1; j < ranges.length; j++) {
190
+ if (!semver.intersects(ranges[i].range, ranges[j].range)) {
191
+ throw new ResolutionError(
192
+ `npm dependency conflict for "${pkg}":\n` +
193
+ ` ${ranges[i].range} (from ${ranges[i].from})\n` +
194
+ ` ${ranges[j].range} (from ${ranges[j].from})`,
195
+ )
196
+ }
197
+ }
198
+ }
199
+
200
+ // Use the narrowest (most constrained) range.
201
+ // Simple heuristic: pick the range with the highest minimum version.
202
+ let narrowest = ranges[0].range
203
+ for (const r of ranges.slice(1)) {
204
+ const minCurrent = semver.minVersion(narrowest)
205
+ const minNew = semver.minVersion(r.range)
206
+ if (minCurrent && minNew && semver.gt(minNew, minCurrent)) {
207
+ narrowest = r.range
208
+ }
209
+ }
210
+
211
+ merged[pkg] = narrowest
212
+ }
213
+
214
+ return merged
215
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod'
2
+
3
+ export const ASSET_TYPES = ['generic', 'model', 'hdri', 'material', 'music'] as const
4
+ export type AssetType = (typeof ASSET_TYPES)[number]
5
+
6
+ export const assetTypeSchema = z.enum(ASSET_TYPES)
7
+
8
+ export const semverSchema = z
9
+ .string()
10
+ .regex(
11
+ /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$/,
12
+ 'Must be a valid semver version (e.g. 1.0.0)',
13
+ )
14
+
15
+ export const assetNameSchema = z
16
+ .string()
17
+ .min(1)
18
+ .max(128)
19
+ .regex(
20
+ /^[a-z0-9][a-z0-9-]*[a-z0-9]$/,
21
+ 'Must be lowercase alphanumeric with hyphens, no leading/trailing hyphens',
22
+ )
23
+ .refine((name) => !name.endsWith('-example'), {
24
+ message: "Asset names cannot end with '-example' (reserved for generated examples)",
25
+ })
26
+
27
+ export const npmDependenciesSchema = z.record(z.string(), z.string()).default({})
28
+
29
+ export const assetDependenciesSchema = z.record(z.string(), z.string()).default({})
30
+
31
+ export const uploadGenericSchema = z.object({
32
+ name: assetNameSchema,
33
+ version: semverSchema,
34
+ description: z.string().max(1000).optional(),
35
+ npmDependencies: npmDependenciesSchema,
36
+ assetDependencies: assetDependenciesSchema,
37
+ tags: z.array(z.string()).default([]),
38
+ })
39
+
40
+ export const uploadTypedSchema = z.object({
41
+ name: assetNameSchema,
42
+ version: semverSchema,
43
+ description: z.string().max(1000).optional(),
44
+ tags: z.array(z.string()).default([]),
45
+ })
46
+
47
+ export const uploadMaterialSchema = uploadTypedSchema.extend({
48
+ properties: z.object({
49
+ color: z.string().default('#ffffff'),
50
+ roughness: z.number().min(0).max(1).default(0.5),
51
+ metalness: z.number().min(0).max(1).default(0),
52
+ normalScale: z.number().min(0).max(2).default(1),
53
+ emissive: z.string().default('#000000'),
54
+ emissiveIntensity: z.number().min(0).max(10).default(0),
55
+ }),
56
+ })
57
+
58
+ export const updateProfileSchema = z.object({
59
+ name: z.string().min(1).max(100).optional(),
60
+ image: z.string().url().optional(),
61
+ })
62
+
63
+ export const listAssetsSchema = z.object({
64
+ page: z.number().int().min(1).default(1),
65
+ limit: z.number().int().min(1).max(100).default(20),
66
+ type: assetTypeSchema.optional(),
67
+ tag: z.string().optional(),
68
+ search: z.string().max(200).optional(),
69
+ sort: z.enum(['newest', 'alphabetical', 'relevance']).default('newest'),
70
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }