@drawcall/market 0.1.3 → 0.1.5

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 (59) hide show
  1. package/dist/cli-client.d.ts +17 -0
  2. package/dist/cli-client.d.ts.map +1 -0
  3. package/dist/cli-client.js +23 -0
  4. package/dist/cli-client.js.map +1 -0
  5. package/dist/cli.js +63 -48
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts +1 -0
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +5 -0
  10. package/dist/client.js.map +1 -1
  11. package/dist/commands/generate.d.ts +9 -0
  12. package/dist/commands/generate.d.ts.map +1 -0
  13. package/dist/commands/generate.js +31 -0
  14. package/dist/commands/generate.js.map +1 -0
  15. package/dist/commands/install.d.ts +9 -0
  16. package/dist/commands/install.d.ts.map +1 -0
  17. package/dist/commands/install.js +89 -0
  18. package/dist/commands/install.js.map +1 -0
  19. package/dist/commands/login.d.ts +10 -0
  20. package/dist/commands/login.d.ts.map +1 -0
  21. package/dist/commands/login.js +92 -0
  22. package/dist/commands/login.js.map +1 -0
  23. package/dist/commands/logout.d.ts +2 -0
  24. package/dist/commands/logout.d.ts.map +1 -0
  25. package/dist/commands/logout.js +12 -0
  26. package/dist/commands/logout.js.map +1 -0
  27. package/dist/commands/search.d.ts +7 -0
  28. package/dist/commands/search.d.ts.map +1 -0
  29. package/dist/commands/search.js +32 -0
  30. package/dist/commands/search.js.map +1 -0
  31. package/dist/config.d.ts +12 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +47 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/contract.d.ts +25 -0
  36. package/dist/contract.d.ts.map +1 -1
  37. package/dist/contract.js +20 -24
  38. package/dist/contract.js.map +1 -1
  39. package/dist/generate.d.ts +20 -0
  40. package/dist/generate.d.ts.map +1 -0
  41. package/dist/generate.js +33 -0
  42. package/dist/generate.js.map +1 -0
  43. package/dist/index.d.ts +3 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -0
  46. package/dist/index.js.map +1 -1
  47. package/package.json +3 -1
  48. package/src/cli-client.ts +40 -0
  49. package/src/cli.ts +78 -57
  50. package/src/client.ts +7 -0
  51. package/src/commands/generate.ts +55 -0
  52. package/src/commands/install.ts +126 -0
  53. package/src/commands/login.ts +113 -0
  54. package/src/commands/logout.ts +11 -0
  55. package/src/commands/search.ts +41 -0
  56. package/src/config.ts +53 -0
  57. package/src/contract.ts +33 -23
  58. package/src/generate.ts +62 -0
  59. package/src/index.ts +5 -0
@@ -0,0 +1,126 @@
1
+ import chalk from 'chalk'
2
+ import ora, { type Ora } from 'ora'
3
+ import { resolve } from '../resolve.js'
4
+ import { install as runInstall } from '../install.js'
5
+ import { generateAndWait } from '../generate.js'
6
+ import { assetNameSchema, type AssetType } from '../schemas.js'
7
+ import { getCliClient } from '../cli-client.js'
8
+ import type { MarketClient } from '../client.js'
9
+
10
+ export interface InstallCommandOptions {
11
+ type?: AssetType
12
+ unapproved?: boolean
13
+ cwd?: string
14
+ baseUrl?: string
15
+ }
16
+
17
+ interface AssetRequest {
18
+ name: string
19
+ range: string
20
+ }
21
+
22
+ export async function installCommand(args: string[], opts: InstallCommandOptions): Promise<void> {
23
+ const { client, apiKey } = await getCliClient({ baseUrl: opts.baseUrl })
24
+ const canGenerate = Boolean(apiKey)
25
+
26
+ const spinner = ora().start()
27
+ try {
28
+ const requests: AssetRequest[] = []
29
+ for (const arg of args) {
30
+ spinner.text = `Resolving "${truncate(arg, 40)}"`
31
+ requests.push(await resolveArg(client, arg, opts.type, spinner, canGenerate))
32
+ }
33
+
34
+ spinner.text = 'Resolving dependency tree'
35
+ const resolution = await resolve(client.asset, requests, {
36
+ includeUnapproved: opts.unapproved ?? false,
37
+ })
38
+
39
+ await runInstall(client, resolution, {
40
+ cwd: opts.cwd,
41
+ onProgress: (msg) => {
42
+ spinner.text = msg
43
+ },
44
+ })
45
+
46
+ spinner.succeed(
47
+ `Installed ${resolution.assets.length} asset(s): ` +
48
+ resolution.assets.map((a) => chalk.cyan(a.name) + chalk.dim('@' + a.version)).join(', '),
49
+ )
50
+ } catch (err) {
51
+ spinner.fail(err instanceof Error ? err.message : String(err))
52
+ throw err
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Fallthrough: exact-name → top-1 search hit → auto-generate (auth only).
58
+ */
59
+ async function resolveArg(
60
+ client: MarketClient,
61
+ arg: string,
62
+ type: AssetType | undefined,
63
+ spinner: Ora,
64
+ canGenerate: boolean,
65
+ ): Promise<AssetRequest> {
66
+ // 1. Try parsing as asset-name[@range] and looking up exact
67
+ const parsed = parseNameAndRange(arg)
68
+ if (parsed) {
69
+ const existing = await client.asset.getByName({ name: parsed.name })
70
+ if (existing) return parsed
71
+ }
72
+
73
+ // 2. Semantic search — top-1
74
+ spinner.text = `Searching "${truncate(arg, 40)}"`
75
+ const results = await client.asset.list({
76
+ search: arg,
77
+ type,
78
+ limit: 1,
79
+ page: 1,
80
+ sort: 'relevance',
81
+ })
82
+ if (results.items.length > 0) {
83
+ const hit = results.items[0]
84
+ spinner.info(`Matched "${chalk.cyan(hit.name)}" ${chalk.dim(`(${hit.type})`)} for "${arg}"`)
85
+ spinner.start()
86
+ return { name: hit.name, range: '*' }
87
+ }
88
+
89
+ // 3. Auto-generate (requires auth)
90
+ if (!canGenerate) {
91
+ throw new Error(
92
+ `No matches for "${arg}". Run \`market login\` to enable auto-generation of missing assets.`,
93
+ )
94
+ }
95
+
96
+ spinner.text = `No matches — generating "${truncate(arg, 40)}"`
97
+ const generated = await generateAndWait(
98
+ client,
99
+ { description: arg, type },
100
+ {
101
+ onProgress: (msg) => {
102
+ spinner.text = msg
103
+ },
104
+ },
105
+ )
106
+ spinner.info(
107
+ `Generated ${chalk.cyan(generated.assetName)}${chalk.dim('@' + generated.version)} for "${arg}"`,
108
+ )
109
+ spinner.start()
110
+ return { name: generated.assetName, range: generated.version }
111
+ }
112
+
113
+ /**
114
+ * Returns { name, range } if the arg parses as `<asset-name>[@<range>]`.
115
+ * Returns null if the name portion fails the asset-name schema (e.g. has spaces).
116
+ */
117
+ function parseNameAndRange(arg: string): AssetRequest | null {
118
+ const atIdx = arg.indexOf('@')
119
+ const name = atIdx >= 0 ? arg.slice(0, atIdx) : arg
120
+ const range = atIdx >= 0 ? arg.slice(atIdx + 1) : '*'
121
+ return assetNameSchema.safeParse(name).success ? { name, range } : null
122
+ }
123
+
124
+ function truncate(s: string, max: number): string {
125
+ return s.length > max ? s.slice(0, max - 1) + '…' : s
126
+ }
@@ -0,0 +1,113 @@
1
+ import * as http from 'http'
2
+ import type { AddressInfo } from 'net'
3
+ import chalk from 'chalk'
4
+ import open from 'open'
5
+ import ora from 'ora'
6
+ import { createMarketClient } from '../client.js'
7
+ import { saveConfig, getConfigPath } from '../config.js'
8
+
9
+ export interface LoginOptions {
10
+ baseUrl: string
11
+ timeoutMs?: number
12
+ }
13
+
14
+ /**
15
+ * Spins up a localhost HTTP server, opens the browser to {baseUrl}/cli-auth,
16
+ * waits for the web app to redirect back with the key, saves it to config.
17
+ */
18
+ export async function login(opts: LoginOptions): Promise<void> {
19
+ const timeoutMs = opts.timeoutMs ?? 120_000
20
+ const state = crypto.randomUUID()
21
+
22
+ const { port, waitForKey, close } = await startCallbackServer(state, timeoutMs)
23
+
24
+ const authUrl = new URL('/cli-auth', opts.baseUrl)
25
+ authUrl.searchParams.set('state', state)
26
+ authUrl.searchParams.set('callback', `http://127.0.0.1:${port}/callback`)
27
+
28
+ console.log(chalk.dim(`If your browser doesn't open, visit:\n ${authUrl.toString()}`))
29
+ await open(authUrl.toString()).catch(() => {})
30
+
31
+ const spinner = ora('Waiting for browser approval…').start()
32
+ try {
33
+ const key = await waitForKey
34
+
35
+ spinner.text = 'Verifying key…'
36
+ const verifyClient = createMarketClient({ baseUrl: opts.baseUrl, apiKey: key })
37
+ const profile = await verifyClient.user.getProfile()
38
+ if (!profile) {
39
+ throw new Error('Key was not accepted by the server.')
40
+ }
41
+
42
+ await saveConfig({ apiKey: key, baseUrl: opts.baseUrl })
43
+ spinner.succeed(`Logged in as ${chalk.cyan(profile.email)}`)
44
+ console.log(chalk.dim(` Key saved to ${getConfigPath()}`))
45
+ } catch (err) {
46
+ spinner.fail(err instanceof Error ? err.message : String(err))
47
+ throw err
48
+ } finally {
49
+ close()
50
+ }
51
+ }
52
+
53
+ async function startCallbackServer(
54
+ expectedState: string,
55
+ timeoutMs: number,
56
+ ): Promise<{ port: number; waitForKey: Promise<string>; close: () => void }> {
57
+ let resolveKey!: (k: string) => void
58
+ let rejectKey!: (e: Error) => void
59
+ const waitForKey = new Promise<string>((resolve, reject) => {
60
+ resolveKey = resolve
61
+ rejectKey = reject
62
+ })
63
+
64
+ const server = http.createServer((req, res) => {
65
+ if (!req.url) {
66
+ res.writeHead(400).end()
67
+ return
68
+ }
69
+ const url = new URL(req.url, 'http://localhost')
70
+ if (url.pathname !== '/callback') {
71
+ res.writeHead(404).end()
72
+ return
73
+ }
74
+ const receivedState = url.searchParams.get('state')
75
+ const receivedKey = url.searchParams.get('key')
76
+
77
+ if (receivedState !== expectedState) {
78
+ res.writeHead(400, { 'Content-Type': 'text/html' })
79
+ res.end('<h1>Invalid state</h1><p>You can close this window.</p>')
80
+ rejectKey(new Error('Invalid state parameter from callback.'))
81
+ return
82
+ }
83
+ if (!receivedKey) {
84
+ res.writeHead(400, { 'Content-Type': 'text/html' })
85
+ res.end('<h1>Missing key</h1><p>You can close this window.</p>')
86
+ rejectKey(new Error('Callback did not include a key.'))
87
+ return
88
+ }
89
+
90
+ res.writeHead(200, { 'Content-Type': 'text/html' })
91
+ res.end('<h1>Logged in!</h1><p>You can close this window.</p>')
92
+ resolveKey(receivedKey)
93
+ })
94
+
95
+ await new Promise<void>((resolve, reject) => {
96
+ server.once('error', reject)
97
+ server.listen(0, '127.0.0.1', () => resolve())
98
+ })
99
+
100
+ const address = server.address() as AddressInfo | null
101
+ if (!address) throw new Error('Failed to bind local callback server.')
102
+
103
+ const timer = setTimeout(() => {
104
+ rejectKey(new Error(`Timed out after ${timeoutMs / 1000}s waiting for browser approval.`))
105
+ }, timeoutMs)
106
+
107
+ const close = () => {
108
+ clearTimeout(timer)
109
+ server.close()
110
+ }
111
+
112
+ return { port: address.port, waitForKey, close }
113
+ }
@@ -0,0 +1,11 @@
1
+ import chalk from 'chalk'
2
+ import { clearConfig, getConfigPath } from '../config.js'
3
+
4
+ export async function logout(): Promise<void> {
5
+ const existed = await clearConfig()
6
+ if (existed) {
7
+ console.log(chalk.green('Logged out.') + chalk.dim(` (${getConfigPath()} removed)`))
8
+ } else {
9
+ console.log(chalk.dim('Already logged out.'))
10
+ }
11
+ }
@@ -0,0 +1,41 @@
1
+ import chalk from 'chalk'
2
+ import ora from 'ora'
3
+ import { getCliClient } from '../cli-client.js'
4
+ import type { AssetType } from '../schemas.js'
5
+
6
+ const SEARCH_LIMIT = 10
7
+
8
+ export interface SearchCommandOptions {
9
+ type?: AssetType
10
+ baseUrl?: string
11
+ }
12
+
13
+ export async function searchCommand(query: string, opts: SearchCommandOptions): Promise<void> {
14
+ const { client } = await getCliClient({ baseUrl: opts.baseUrl })
15
+
16
+ const spinner = ora(`Searching "${query}"`).start()
17
+ let results
18
+ try {
19
+ results = await client.asset.list({
20
+ search: query,
21
+ type: opts.type,
22
+ limit: SEARCH_LIMIT,
23
+ page: 1,
24
+ sort: 'relevance',
25
+ })
26
+ } catch (err) {
27
+ spinner.fail(err instanceof Error ? err.message : String(err))
28
+ throw err
29
+ }
30
+ spinner.stop()
31
+
32
+ if (results.items.length === 0) {
33
+ console.log(chalk.dim('No matches.'))
34
+ return
35
+ }
36
+
37
+ for (const item of results.items) {
38
+ const desc = item.description ? chalk.dim(` — ${item.description}`) : ''
39
+ console.log(` ${chalk.cyan(item.name)} ${chalk.dim(`(${item.type})`)}${desc}`)
40
+ }
41
+ }
package/src/config.ts ADDED
@@ -0,0 +1,53 @@
1
+ import * as fs from 'fs/promises'
2
+ import * as os from 'os'
3
+ import * as path from 'path'
4
+ import { z } from 'zod'
5
+
6
+ const configSchema = z.object({
7
+ apiKey: z.string().startsWith('mk_'),
8
+ baseUrl: z.string().url().optional(),
9
+ })
10
+
11
+ export type Config = z.infer<typeof configSchema>
12
+
13
+ function configDir(): string {
14
+ if (process.platform === 'win32' && process.env.APPDATA) {
15
+ return path.join(process.env.APPDATA, 'drawcall-market')
16
+ }
17
+ const base = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config')
18
+ return path.join(base, 'drawcall-market')
19
+ }
20
+
21
+ function configPath(): string {
22
+ return path.join(configDir(), 'config.json')
23
+ }
24
+
25
+ export async function loadConfig(): Promise<Config | null> {
26
+ try {
27
+ const raw = await fs.readFile(configPath(), 'utf-8')
28
+ const parsed = configSchema.safeParse(JSON.parse(raw))
29
+ return parsed.success ? parsed.data : null
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ export async function saveConfig(config: Config): Promise<void> {
36
+ const dir = configDir()
37
+ await fs.mkdir(dir, { recursive: true })
38
+ const file = configPath()
39
+ await fs.writeFile(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 })
40
+ }
41
+
42
+ export async function clearConfig(): Promise<boolean> {
43
+ try {
44
+ await fs.unlink(configPath())
45
+ return true
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
51
+ export function getConfigPath(): string {
52
+ return configPath()
53
+ }
package/src/contract.ts CHANGED
@@ -2,6 +2,7 @@ import { oc } from '@orpc/contract'
2
2
  import { z } from 'zod'
3
3
  import {
4
4
  assetNameSchema,
5
+ assetTypeSchema,
5
6
  semverSchema,
6
7
  listAssetsSchema,
7
8
  updateProfileSchema,
@@ -106,6 +107,15 @@ export interface FileTreeEntry {
106
107
  size: number
107
108
  }
108
109
 
110
+ export interface GenerateJobStatus {
111
+ jobId: string
112
+ state: 'queued' | 'running' | 'done' | 'failed'
113
+ message: string | null
114
+ assetName: string | null
115
+ version: string | null
116
+ error: string | null
117
+ }
118
+
109
119
  // ─── Contract ─────────────────────────────────────────────────────────────────
110
120
 
111
121
  export const contract = {
@@ -114,9 +124,7 @@ export const contract = {
114
124
  .input(z.object({ name: assetNameSchema }))
115
125
  .output(z.custom<AssetWithVersionsAndTags | null>()),
116
126
 
117
- list: oc
118
- .input(listAssetsSchema)
119
- .output(z.custom<PaginatedList<AssetListItem>>()),
127
+ list: oc.input(listAssetsSchema).output(z.custom<PaginatedList<AssetListItem>>()),
120
128
 
121
129
  getVersionTree: oc
122
130
  .input(z.object({ name: z.string(), version: semverSchema }))
@@ -144,44 +152,46 @@ export const contract = {
144
152
  .input(uploadTypedSchema.extend({ file: z.instanceof(File) }))
145
153
  .output(z.custom<AssetVersion>()),
146
154
 
147
- material: oc
148
- .input(uploadMaterialSchema)
149
- .output(z.custom<AssetVersion>()),
155
+ material: oc.input(uploadMaterialSchema).output(z.custom<AssetVersion>()),
150
156
  },
151
157
 
152
158
  admin: {
153
- listUnapproved: oc
154
- .output(z.custom<UnapprovedItem[]>()),
159
+ listUnapproved: oc.output(z.custom<UnapprovedItem[]>()),
155
160
 
156
161
  approve: oc
157
162
  .input(z.object({ assetName: z.string(), version: z.string() }))
158
163
  .output(z.object({ success: z.boolean() })),
159
164
 
160
- backfillEmbeddings: oc
161
- .output(z.object({ indexed: z.number() })),
165
+ backfillEmbeddings: oc.output(z.object({ indexed: z.number() })),
162
166
  },
163
167
 
164
168
  user: {
165
- getProfile: oc
166
- .output(z.custom<User | null>()),
169
+ getProfile: oc.output(z.custom<User | null>()),
167
170
 
168
- updateProfile: oc
169
- .input(updateProfileSchema)
170
- .output(z.custom<User>()),
171
+ updateProfile: oc.input(updateProfileSchema).output(z.custom<User>()),
171
172
 
172
- getApiKey: oc
173
- .output(z.custom<{ prefix: string; createdAt: Date } | null>()),
173
+ getApiKey: oc.output(z.custom<{ prefix: string; createdAt: Date } | null>()),
174
174
 
175
- regenerateApiKey: oc
176
- .output(z.object({ key: z.string(), prefix: z.string() })),
175
+ regenerateApiKey: oc.output(z.object({ key: z.string(), prefix: z.string() })),
177
176
 
178
- myAssets: oc
179
- .output(z.custom<AssetWithVersions[]>()),
177
+ myAssets: oc.output(z.custom<AssetWithVersions[]>()),
180
178
  },
181
179
 
182
180
  tag: {
183
- list: oc
184
- .output(z.custom<TagWithCount[]>()),
181
+ list: oc.output(z.custom<TagWithCount[]>()),
182
+ },
183
+
184
+ generate: {
185
+ start: oc
186
+ .input(
187
+ z.object({
188
+ description: z.string().min(3).max(1000),
189
+ type: assetTypeSchema.optional(),
190
+ }),
191
+ )
192
+ .output(z.object({ jobId: z.string() })),
193
+
194
+ status: oc.input(z.object({ jobId: z.string() })).output(z.custom<GenerateJobStatus>()),
185
195
  },
186
196
  }
187
197
 
@@ -0,0 +1,62 @@
1
+ import type { MarketClient } from './client.js'
2
+ import type { AssetType } from './schemas.js'
3
+
4
+ export class GenerateError extends Error {
5
+ constructor(message: string) {
6
+ super(message)
7
+ this.name = 'GenerateError'
8
+ }
9
+ }
10
+
11
+ export interface GenerateInput {
12
+ description: string
13
+ type?: AssetType
14
+ }
15
+
16
+ export interface GenerateResult {
17
+ assetName: string
18
+ version: string
19
+ }
20
+
21
+ export interface GenerateOptions {
22
+ onProgress?: (message: string) => void
23
+ pollIntervalMs?: number
24
+ timeoutMs?: number
25
+ }
26
+
27
+ export async function generateAndWait(
28
+ client: MarketClient,
29
+ input: GenerateInput,
30
+ opts: GenerateOptions = {},
31
+ ): Promise<GenerateResult> {
32
+ const pollIntervalMs = opts.pollIntervalMs ?? 1500
33
+ const timeoutMs = opts.timeoutMs ?? 60_000
34
+ const report = opts.onProgress ?? (() => {})
35
+
36
+ const { jobId } = await client.generate.start(input)
37
+
38
+ const deadline = Date.now() + timeoutMs
39
+ while (Date.now() < deadline) {
40
+ const status = await client.generate.status({ jobId })
41
+ if (status.message) report(status.message)
42
+
43
+ if (status.state === 'done') {
44
+ if (!status.assetName || !status.version) {
45
+ throw new GenerateError('Generation completed without an asset name.')
46
+ }
47
+ return { assetName: status.assetName, version: status.version }
48
+ }
49
+
50
+ if (status.state === 'failed') {
51
+ throw new GenerateError(status.error ?? 'Generation failed.')
52
+ }
53
+
54
+ await sleep(pollIntervalMs)
55
+ }
56
+
57
+ throw new GenerateError(`Generation timed out after ${timeoutMs}ms.`)
58
+ }
59
+
60
+ function sleep(ms: number): Promise<void> {
61
+ return new Promise((resolve) => setTimeout(resolve, ms))
62
+ }
package/src/index.ts CHANGED
@@ -20,12 +20,17 @@ export type {
20
20
  UnapprovedItem,
21
21
  TagWithCount,
22
22
  FileTreeEntry,
23
+ GenerateJobStatus,
23
24
  } from './contract.js'
24
25
 
25
26
  // Resolve
26
27
  export { resolve, ResolutionError } from './resolve.js'
27
28
  export type { ResolvedAsset, ResolveResult } from './resolve.js'
28
29
 
30
+ // Generate
31
+ export { generateAndWait, GenerateError } from './generate.js'
32
+ export type { GenerateInput, GenerateResult, GenerateOptions } from './generate.js'
33
+
29
34
  // Schemas
30
35
  export {
31
36
  ASSET_TYPES,