@drawcall/market 0.1.5 → 0.1.7

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 (99) hide show
  1. package/dist/asset-implementation.d.ts +88 -0
  2. package/dist/asset-implementation.d.ts.map +1 -0
  3. package/dist/asset-implementation.js +2 -0
  4. package/dist/asset-implementation.js.map +1 -0
  5. package/dist/cli-client.d.ts +2 -2
  6. package/dist/cli-client.d.ts.map +1 -1
  7. package/dist/cli-client.js +4 -4
  8. package/dist/cli-client.js.map +1 -1
  9. package/dist/cli.js +106 -22
  10. package/dist/cli.js.map +1 -1
  11. package/dist/client.d.ts +1 -3
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/client.js +4 -12
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands/generate.d.ts.map +1 -1
  16. package/dist/commands/generate.js +9 -4
  17. package/dist/commands/generate.js.map +1 -1
  18. package/dist/commands/install.d.ts.map +1 -1
  19. package/dist/commands/install.js +30 -28
  20. package/dist/commands/install.js.map +1 -1
  21. package/dist/commands/logout.js +3 -3
  22. package/dist/commands/logout.js.map +1 -1
  23. package/dist/commands/preview.d.ts +9 -0
  24. package/dist/commands/preview.d.ts.map +1 -0
  25. package/dist/commands/preview.js +50 -0
  26. package/dist/commands/preview.js.map +1 -0
  27. package/dist/commands/search.d.ts +4 -1
  28. package/dist/commands/search.d.ts.map +1 -1
  29. package/dist/commands/search.js +20 -10
  30. package/dist/commands/search.js.map +1 -1
  31. package/dist/commands/upload.d.ts +9 -0
  32. package/dist/commands/upload.d.ts.map +1 -0
  33. package/dist/commands/upload.js +220 -0
  34. package/dist/commands/upload.js.map +1 -0
  35. package/dist/config.d.ts +4 -7
  36. package/dist/config.d.ts.map +1 -1
  37. package/dist/config.js +8 -4
  38. package/dist/config.js.map +1 -1
  39. package/dist/constants.d.ts.map +1 -1
  40. package/dist/constants.js +2 -10
  41. package/dist/constants.js.map +1 -1
  42. package/dist/contract.d.ts +37 -134
  43. package/dist/contract.d.ts.map +1 -1
  44. package/dist/contract.js +10 -48
  45. package/dist/contract.js.map +1 -1
  46. package/dist/generate.d.ts +0 -2
  47. package/dist/generate.d.ts.map +1 -1
  48. package/dist/generate.js +5 -22
  49. package/dist/generate.js.map +1 -1
  50. package/dist/index.d.ts +5 -6
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +2 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/install.d.ts +1 -1
  55. package/dist/install.d.ts.map +1 -1
  56. package/dist/install.js +14 -11
  57. package/dist/install.js.map +1 -1
  58. package/dist/output.d.ts +26 -0
  59. package/dist/output.d.ts.map +1 -0
  60. package/dist/output.js +52 -0
  61. package/dist/output.js.map +1 -0
  62. package/dist/resolve.d.ts.map +1 -1
  63. package/dist/resolve.js +15 -26
  64. package/dist/resolve.js.map +1 -1
  65. package/dist/schemas.d.ts +31 -39
  66. package/dist/schemas.d.ts.map +1 -1
  67. package/dist/schemas.js +23 -25
  68. package/dist/schemas.js.map +1 -1
  69. package/package.json +13 -4
  70. package/src/asset-implementation.ts +114 -0
  71. package/src/cli-client.ts +7 -6
  72. package/src/cli.ts +146 -29
  73. package/src/client.ts +5 -15
  74. package/src/commands/generate.ts +9 -6
  75. package/src/commands/install.ts +34 -31
  76. package/src/commands/logout.ts +3 -3
  77. package/src/commands/preview.ts +69 -0
  78. package/src/commands/search.ts +27 -11
  79. package/src/commands/upload.ts +262 -0
  80. package/src/config.ts +11 -5
  81. package/src/constants.ts +2 -10
  82. package/src/contract.ts +22 -120
  83. package/src/generate.ts +5 -29
  84. package/src/index.ts +23 -14
  85. package/src/install.ts +24 -14
  86. package/src/output.ts +76 -0
  87. package/src/resolve.ts +22 -38
  88. package/src/schemas.ts +26 -27
  89. package/tsconfig.json +2 -1
  90. package/dist/commands/login.d.ts +0 -10
  91. package/dist/commands/login.d.ts.map +0 -1
  92. package/dist/commands/login.js +0 -92
  93. package/dist/commands/login.js.map +0 -1
  94. package/dist/internal-contract.d.ts +0 -19
  95. package/dist/internal-contract.d.ts.map +0 -1
  96. package/dist/internal-contract.js +0 -19
  97. package/dist/internal-contract.js.map +0 -1
  98. package/src/commands/login.ts +0 -113
  99. package/src/internal-contract.ts +0 -26
package/src/cli.ts CHANGED
@@ -1,57 +1,69 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createRequire } from 'node:module'
3
4
  import { Command, Option } from 'commander'
4
5
  import chalk from 'chalk'
6
+ import open from 'open'
7
+ import * as oauthClient from 'openid-client'
5
8
  import { ASSET_TYPES, type AssetType } from './schemas.js'
6
9
  import { installCommand } from './commands/install.js'
7
10
  import { searchCommand } from './commands/search.js'
8
11
  import { generateCommand } from './commands/generate.js'
9
- import { login } from './commands/login.js'
12
+ import { previewCommand } from './commands/preview.js'
13
+ import { uploadCommand } from './commands/upload.js'
10
14
  import { logout } from './commands/logout.js'
11
- import { NotLoggedInError } from './cli-client.js'
15
+ import { createMarketClient } from './client.js'
16
+ import { saveConfig, getConfigPath } from './config.js'
17
+ import { errorResult, loginResult } from './output.js'
12
18
 
19
+ const packageJson = createRequire(import.meta.url)('../package.json') as { version: string }
13
20
  const program = new Command()
14
21
 
15
22
  const DEFAULT_BASE_URL = 'https://api.market.drawcall.ai'
23
+ const AUTH_ISSUER_URL = 'https://auth.drawcall.ai/api/auth'
24
+ const DEVICE_CLIENT_ID = 'market-cli'
16
25
 
17
- const typeOption = new Option('--type <type>', 'Filter by asset type').choices([...ASSET_TYPES])
18
-
19
- const apiOption = new Option('--api <url>', 'API base URL').default(
26
+ const typeOption = new Option('--type <type>', 'Asset type').choices([...ASSET_TYPES])
27
+ const apiOption = new Option('--api <url>', 'API URL').default(
20
28
  process.env.MARKET_API_URL,
21
29
  'from MARKET_API_URL / config / default',
22
30
  )
23
31
 
24
- program.name('market').description('Install assets from the drawcall.ai market').version('0.1.0')
32
+ program.name('market').description('Find and install Drawcall Market assets').version(packageJson.version)
25
33
 
26
34
  program
27
35
  .command('login')
28
- .description('Authenticate with the market via your browser')
36
+ .description('Sign in')
29
37
  .addOption(apiOption)
30
38
  .action(async (opts: { api?: string }) => {
31
- await login({ baseUrl: opts.api ?? DEFAULT_BASE_URL })
39
+ const baseUrl = opts.api ?? DEFAULT_BASE_URL
40
+ const token = await runDeviceLogin()
41
+ const client = createMarketClient({ baseUrl, authToken: token })
42
+ const profile = await client.user.getProfile()
43
+ if (!profile) throw new Error('The Market API did not accept the auth token.')
44
+ await saveConfig({ authToken: token, baseUrl })
45
+ console.log(loginResult(profile.email, getConfigPath()))
32
46
  })
33
47
 
34
48
  program
35
49
  .command('logout')
36
- .description('Remove the local API key')
50
+ .description('Sign out')
37
51
  .action(async () => {
38
52
  await logout()
39
53
  })
40
54
 
41
55
  program
42
56
  .command('install')
43
- .description(
44
- 'Install assets. Each arg is tried as an exact name, then a semantic search, then auto-generated.',
45
- )
46
- .argument('<assets...>', 'Asset names or natural-language descriptions')
57
+ .description('Install by name or prompt')
58
+ .argument('<assets...>', 'Names or prompts')
47
59
  .addOption(typeOption)
48
60
  .addOption(apiOption)
49
61
  .option('--unapproved', 'Include unapproved versions', false)
50
- .option('--cwd <dir>', 'Project directory', process.cwd())
62
+ .option('--cwd <dir>', 'Project directory')
51
63
  .action(
52
64
  async (
53
65
  args: string[],
54
- opts: { type?: AssetType; api?: string; unapproved: boolean; cwd: string },
66
+ opts: { type?: AssetType; api?: string; unapproved: boolean; cwd?: string },
55
67
  ) => {
56
68
  await installCommand(args, {
57
69
  type: opts.type,
@@ -64,22 +76,98 @@ program
64
76
 
65
77
  program
66
78
  .command('search')
67
- .description('Search the market. Prints up to 10 ranked results.')
68
- .argument('<query>', 'Natural-language query')
79
+ .description('Find assets')
80
+ .argument('<query>', 'Search query')
69
81
  .addOption(typeOption)
70
82
  .addOption(apiOption)
71
- .action(async (query: string, opts: { type?: AssetType; api?: string }) => {
72
- await searchCommand(query, { type: opts.type, baseUrl: opts.api })
73
- })
83
+ .option('--unapproved', 'Include unapproved versions', false)
84
+ .option('--limit <n>', 'Max results, 1-5', parseSearchLimit, 5)
85
+ .option('--verbose', 'Show longer descriptions', false)
86
+ .action(
87
+ async (
88
+ query: string,
89
+ opts: {
90
+ type?: AssetType
91
+ api?: string
92
+ unapproved: boolean
93
+ limit: number
94
+ verbose: boolean
95
+ },
96
+ ) => {
97
+ if (!opts.type) {
98
+ console.error(
99
+ errorResult(`Search requires --type. Available types: ${ASSET_TYPES.join(', ')}`),
100
+ )
101
+ process.exit(1)
102
+ }
103
+ await searchCommand(query, {
104
+ type: opts.type,
105
+ baseUrl: opts.api,
106
+ unapproved: opts.unapproved,
107
+ limit: opts.limit,
108
+ verbose: opts.verbose,
109
+ })
110
+ },
111
+ )
112
+
113
+ program
114
+ .command('upload')
115
+ .description('Publish one model')
116
+ .argument('<name>', 'Asset name')
117
+ .argument('<file-filter>', '.glb/.gltf path or glob')
118
+ .argument('<description>', 'Short description')
119
+ .addOption(typeOption)
120
+ .addOption(apiOption)
121
+ .option('--version <version>', 'Explicit semver version')
122
+ .option('--cwd <dir>', 'Project directory')
123
+ .action(
124
+ async (
125
+ name: string,
126
+ fileFilter: string,
127
+ description: string,
128
+ opts: { type?: AssetType; api?: string; version?: string; cwd?: string },
129
+ ) => {
130
+ await uploadCommand(name, fileFilter, description, {
131
+ type: opts.type,
132
+ version: opts.version,
133
+ baseUrl: opts.api,
134
+ cwd: opts.cwd,
135
+ })
136
+ },
137
+ )
138
+
139
+ program
140
+ .command('preview')
141
+ .description('Save preview image')
142
+ .argument('<name>', 'Asset name')
143
+ .argument('[version]', 'Semver version')
144
+ .addOption(typeOption)
145
+ .addOption(apiOption)
146
+ .option('--unapproved', 'Include unapproved versions', false)
147
+ .option('--out <file>', 'Output PNG path')
148
+ .action(
149
+ async (
150
+ name: string,
151
+ version: string | undefined,
152
+ opts: { type?: AssetType; api?: string; unapproved: boolean; out?: string },
153
+ ) => {
154
+ await previewCommand(name, version, {
155
+ type: opts.type,
156
+ baseUrl: opts.api,
157
+ unapproved: opts.unapproved,
158
+ out: opts.out,
159
+ })
160
+ },
161
+ )
74
162
 
75
163
  program
76
164
  .command('generate')
77
- .description('Generate a new asset from a description and install it.')
78
- .argument('<description>', 'Description of the asset to generate')
165
+ .description('Generate and install')
166
+ .argument('<description>', 'Asset prompt')
79
167
  .addOption(typeOption)
80
168
  .addOption(apiOption)
81
- .option('--cwd <dir>', 'Project directory', process.cwd())
82
- .action(async (description: string, opts: { type?: AssetType; api?: string; cwd: string }) => {
169
+ .option('--cwd <dir>', 'Project directory')
170
+ .action(async (description: string, opts: { type?: AssetType; api?: string; cwd?: string }) => {
83
171
  await generateCommand(description, {
84
172
  type: opts.type,
85
173
  baseUrl: opts.api,
@@ -87,11 +175,40 @@ program
87
175
  })
88
176
  })
89
177
 
178
+ if (process.argv.length <= 2) {
179
+ program.outputHelp()
180
+ process.exit(0)
181
+ }
182
+
90
183
  program.parseAsync().catch((err) => {
91
- // Commands that own a spinner print the error via spinner.fail() and rethrow,
92
- // so we only print here when nothing else did.
93
- if (err instanceof NotLoggedInError) {
94
- console.error(chalk.red(err.message))
95
- }
184
+ const message = err instanceof Error ? err.message : String(err)
185
+ console.error(errorResult(message))
96
186
  process.exit(1)
97
187
  })
188
+
189
+ function parseSearchLimit(value: string): number {
190
+ const parsed = Number.parseInt(value, 10)
191
+ if (!Number.isInteger(parsed) || parsed < 1) {
192
+ throw new Error('--limit must be a positive integer')
193
+ }
194
+ return Math.min(parsed, 5)
195
+ }
196
+
197
+ async function runDeviceLogin(): Promise<string> {
198
+ const config = await oauthClient.discovery(
199
+ new URL(AUTH_ISSUER_URL),
200
+ DEVICE_CLIENT_ID,
201
+ undefined,
202
+ oauthClient.None(),
203
+ )
204
+ const code = await oauthClient.initiateDeviceAuthorization(config, { scope: 'market' })
205
+ const verificationUri = code.verification_uri_complete ?? code.verification_uri
206
+
207
+ console.log(`Open ${chalk.cyan(verificationUri)}`)
208
+ console.log(`Code: ${chalk.bold(code.user_code)}`)
209
+ await open(verificationUri).catch(() => undefined)
210
+
211
+ const tokens = await oauthClient.pollDeviceAuthorizationGrant(config, code)
212
+ if (!tokens.access_token) throw new Error('Device login completed without an access token.')
213
+ return tokens.access_token
214
+ }
package/src/client.ts CHANGED
@@ -2,39 +2,29 @@ import { createORPCClient } from '@orpc/client'
2
2
  import { RPCLink } from '@orpc/client/fetch'
3
3
  import type { ContractRouterClient } from '@orpc/contract'
4
4
  import type { AppContract } from './contract.js'
5
- import type { InternalContract } from './internal-contract.js'
6
5
 
7
6
  export type MarketClient = ContractRouterClient<AppContract>
8
- export type InternalClient = ContractRouterClient<InternalContract>
9
7
 
10
8
  const DEFAULT_BASE_URL = 'https://api.market.drawcall.ai'
11
9
 
12
10
  export interface MarketClientOptions {
13
11
  baseUrl?: string
14
12
  fetch?: typeof globalThis.fetch
13
+ authToken?: string
15
14
  apiKey?: string
16
15
  }
17
16
 
18
- function buildHeaders(apiKey?: string): Record<string, string> | undefined {
19
- return apiKey ? { 'x-api-key': apiKey } : undefined
17
+ function buildHeaders(authToken?: string): Record<string, string> | undefined {
18
+ return authToken ? { authorization: `Bearer ${authToken}` } : undefined
20
19
  }
21
20
 
22
21
  export function createMarketClient(opts: MarketClientOptions = {}): MarketClient {
23
22
  const baseUrl = opts.baseUrl || DEFAULT_BASE_URL
23
+ const authToken = opts.authToken ?? opts.apiKey
24
24
  const link = new RPCLink({
25
25
  url: new URL('/api/rpc', baseUrl).href,
26
26
  fetch: opts.fetch,
27
- headers: buildHeaders(opts.apiKey),
27
+ headers: buildHeaders(authToken),
28
28
  })
29
29
  return createORPCClient<MarketClient>(link)
30
30
  }
31
-
32
- export function createInternalClient(opts: MarketClientOptions = {}): InternalClient {
33
- const baseUrl = opts.baseUrl || DEFAULT_BASE_URL
34
- const link = new RPCLink({
35
- url: new URL('/api/internal-rpc', baseUrl).href,
36
- fetch: opts.fetch,
37
- headers: buildHeaders(opts.apiKey),
38
- })
39
- return createORPCClient<InternalClient>(link)
40
- }
@@ -1,9 +1,9 @@
1
- import chalk from 'chalk'
2
1
  import ora from 'ora'
3
2
  import { getCliClient } from '../cli-client.js'
4
3
  import { generateAndWait } from '../generate.js'
5
4
  import { resolve } from '../resolve.js'
6
5
  import { install as runInstall } from '../install.js'
6
+ import { generatedInstallResult } from '../output.js'
7
7
  import type { AssetType } from '../schemas.js'
8
8
 
9
9
  export interface GenerateCommandOptions {
@@ -19,7 +19,11 @@ export async function generateCommand(
19
19
  ): Promise<void> {
20
20
  const { client } = await getCliClient({ baseUrl: opts.baseUrl, requireAuth: true })
21
21
 
22
- const spinner = ora(`Generating "${description}"`).start()
22
+ const spinner = ora({
23
+ text: `Generating "${description}"`,
24
+ isEnabled: Boolean(process.stderr.isTTY),
25
+ isSilent: !process.stderr.isTTY,
26
+ }).start()
23
27
  try {
24
28
  const generated = await generateAndWait(
25
29
  client,
@@ -45,11 +49,10 @@ export async function generateCommand(
45
49
  },
46
50
  })
47
51
 
48
- spinner.succeed(
49
- `Installed ${chalk.cyan(generated.assetName)}${chalk.dim('@' + generated.version)}`,
50
- )
52
+ spinner.stop()
53
+ console.log(generatedInstallResult(generated.assetName, generated.version))
51
54
  } catch (err) {
52
- spinner.fail(err instanceof Error ? err.message : String(err))
55
+ spinner.stop()
53
56
  throw err
54
57
  }
55
58
  }
@@ -1,10 +1,10 @@
1
- import chalk from 'chalk'
2
1
  import ora, { type Ora } from 'ora'
3
2
  import { resolve } from '../resolve.js'
4
3
  import { install as runInstall } from '../install.js'
5
4
  import { generateAndWait } from '../generate.js'
6
5
  import { assetNameSchema, type AssetType } from '../schemas.js'
7
6
  import { getCliClient } from '../cli-client.js'
7
+ import { compact, generatedInstallResult, installResult } from '../output.js'
8
8
  import type { MarketClient } from '../client.js'
9
9
 
10
10
  export interface InstallCommandOptions {
@@ -20,15 +20,20 @@ interface AssetRequest {
20
20
  }
21
21
 
22
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)
23
+ const { client, authToken } = await getCliClient({ baseUrl: opts.baseUrl })
24
+ const canGenerate = Boolean(authToken)
25
25
 
26
- const spinner = ora().start()
26
+ const spinner = ora({
27
+ isEnabled: Boolean(process.stderr.isTTY),
28
+ isSilent: !process.stderr.isTTY,
29
+ }).start()
27
30
  try {
28
31
  const requests: AssetRequest[] = []
29
32
  for (const arg of args) {
30
33
  spinner.text = `Resolving "${truncate(arg, 40)}"`
31
- requests.push(await resolveArg(client, arg, opts.type, spinner, canGenerate))
34
+ requests.push(
35
+ await resolveArg(client, arg, opts.type, spinner, canGenerate, opts.unapproved ?? false),
36
+ )
32
37
  }
33
38
 
34
39
  spinner.text = 'Resolving dependency tree'
@@ -43,57 +48,57 @@ export async function installCommand(args: string[], opts: InstallCommandOptions
43
48
  },
44
49
  })
45
50
 
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
- )
51
+ spinner.stop()
52
+ console.log(installResult(resolution.assets))
50
53
  } catch (err) {
51
- spinner.fail(err instanceof Error ? err.message : String(err))
54
+ spinner.stop()
52
55
  throw err
53
56
  }
54
57
  }
55
58
 
56
- /**
57
- * Fallthrough: exact-name → top-1 search hit → auto-generate (auth only).
58
- */
59
59
  async function resolveArg(
60
60
  client: MarketClient,
61
61
  arg: string,
62
62
  type: AssetType | undefined,
63
63
  spinner: Ora,
64
64
  canGenerate: boolean,
65
+ includeUnapproved: boolean,
65
66
  ): Promise<AssetRequest> {
66
- // 1. Try parsing as asset-name[@range] and looking up exact
67
67
  const parsed = parseNameAndRange(arg)
68
68
  if (parsed) {
69
- const existing = await client.asset.getByName({ name: parsed.name })
69
+ const existing = await client.asset.exact({
70
+ name: parsed.name,
71
+ type,
72
+ includeUnapproved,
73
+ })
70
74
  if (existing) return parsed
71
75
  }
72
76
 
73
- // 2. Semantic search — top-1
74
77
  spinner.text = `Searching "${truncate(arg, 40)}"`
75
- const results = await client.asset.list({
76
- search: arg,
78
+ const results = await client.asset.search({
79
+ query: arg,
77
80
  type,
81
+ includeUnapproved,
78
82
  limit: 1,
79
83
  page: 1,
80
84
  sort: 'relevance',
81
85
  })
82
86
  if (results.items.length > 0) {
83
87
  const hit = results.items[0]
84
- spinner.info(`Matched "${chalk.cyan(hit.name)}" ${chalk.dim(`(${hit.type})`)} for "${arg}"`)
85
- spinner.start()
88
+ if (process.stderr.isTTY) {
89
+ spinner.info(`Matched "${compact(arg, 48)}" to ${hit.name} (${hit.type})`)
90
+ spinner.start()
91
+ }
86
92
  return { name: hit.name, range: '*' }
87
93
  }
88
94
 
89
- // 3. Auto-generate (requires auth)
90
95
  if (!canGenerate) {
91
96
  throw new Error(
92
97
  `No matches for "${arg}". Run \`market login\` to enable auto-generation of missing assets.`,
93
98
  )
94
99
  }
95
100
 
96
- spinner.text = `No matches generating "${truncate(arg, 40)}"`
101
+ spinner.text = `No matches; generating "${truncate(arg, 40)}"`
97
102
  const generated = await generateAndWait(
98
103
  client,
99
104
  { description: arg, type },
@@ -103,17 +108,15 @@ async function resolveArg(
103
108
  },
104
109
  },
105
110
  )
106
- spinner.info(
107
- `Generated ${chalk.cyan(generated.assetName)}${chalk.dim('@' + generated.version)} for "${arg}"`,
108
- )
109
- spinner.start()
111
+ if (process.stderr.isTTY) {
112
+ spinner.info(
113
+ `${generatedInstallResult(generated.assetName, generated.version)} for "${compact(arg, 48)}"`,
114
+ )
115
+ spinner.start()
116
+ }
110
117
  return { name: generated.assetName, range: generated.version }
111
118
  }
112
119
 
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
120
  function parseNameAndRange(arg: string): AssetRequest | null {
118
121
  const atIdx = arg.indexOf('@')
119
122
  const name = atIdx >= 0 ? arg.slice(0, atIdx) : arg
@@ -122,5 +125,5 @@ function parseNameAndRange(arg: string): AssetRequest | null {
122
125
  }
123
126
 
124
127
  function truncate(s: string, max: number): string {
125
- return s.length > max ? s.slice(0, max - 1) + '' : s
128
+ return s.length > max ? s.slice(0, max - 3) + '...' : s
126
129
  }
@@ -1,11 +1,11 @@
1
- import chalk from 'chalk'
2
1
  import { clearConfig, getConfigPath } from '../config.js'
2
+ import { logoutResult } from '../output.js'
3
3
 
4
4
  export async function logout(): Promise<void> {
5
5
  const existed = await clearConfig()
6
6
  if (existed) {
7
- console.log(chalk.green('Logged out.') + chalk.dim(` (${getConfigPath()} removed)`))
7
+ console.log(logoutResult(getConfigPath()))
8
8
  } else {
9
- console.log(chalk.dim('Already logged out.'))
9
+ console.log(logoutResult())
10
10
  }
11
11
  }
@@ -0,0 +1,69 @@
1
+ import * as fs from 'fs/promises'
2
+ import * as path from 'path'
3
+ import ora from 'ora'
4
+ import { getCliClient } from '../cli-client.js'
5
+ import { previewResult } from '../output.js'
6
+ import { assetNameSchema, semverSchema, type AssetType } from '../schemas.js'
7
+
8
+ export interface PreviewCommandOptions {
9
+ type?: AssetType
10
+ unapproved?: boolean
11
+ out?: string
12
+ baseUrl?: string
13
+ }
14
+
15
+ export async function previewCommand(
16
+ name: string,
17
+ version: string | undefined,
18
+ opts: PreviewCommandOptions,
19
+ ): Promise<void> {
20
+ const parsedName = assetNameSchema.parse(name)
21
+ const parsedVersion = version ? semverSchema.parse(version) : undefined
22
+ const spinner = ora({
23
+ text: `Resolving preview for ${parsedName}`,
24
+ isEnabled: Boolean(process.stderr.isTTY),
25
+ isSilent: !process.stderr.isTTY,
26
+ }).start()
27
+
28
+ try {
29
+ const { client } = await getCliClient({ baseUrl: opts.baseUrl })
30
+ const resolvedVersion =
31
+ parsedVersion ??
32
+ (
33
+ await client.asset.exact({
34
+ name: parsedName,
35
+ type: opts.type ?? 'model',
36
+ includeUnapproved: opts.unapproved ?? false,
37
+ })
38
+ )?.latestVersion
39
+
40
+ if (!resolvedVersion) {
41
+ throw new Error(`Asset "${parsedName}" not found`)
42
+ }
43
+
44
+ spinner.text = `Downloading preview for ${parsedName}@${resolvedVersion}`
45
+ const preview = await client.asset.downloadPreviewImage({
46
+ name: parsedName,
47
+ version: resolvedVersion,
48
+ })
49
+ const bytes = new Uint8Array(await preview.arrayBuffer())
50
+ const out = path.resolve(
51
+ opts.out ?? `${parsedName}-${resolvedVersion}-preview${previewExtension(preview.type)}`,
52
+ )
53
+
54
+ await fs.mkdir(path.dirname(out), { recursive: true })
55
+ await fs.writeFile(out, bytes)
56
+
57
+ spinner.stop()
58
+ console.log(previewResult(parsedName, resolvedVersion, out))
59
+ } catch (err) {
60
+ spinner.stop()
61
+ throw err
62
+ }
63
+ }
64
+
65
+ function previewExtension(contentType: string): string {
66
+ if (contentType === 'image/webp') return '.webp'
67
+ if (contentType === 'image/jpeg') return '.jpg'
68
+ return '.png'
69
+ }
@@ -1,41 +1,57 @@
1
- import chalk from 'chalk'
2
1
  import ora from 'ora'
3
2
  import { getCliClient } from '../cli-client.js'
3
+ import { assetSearchResultLine, searchSummary } from '../output.js'
4
4
  import type { AssetType } from '../schemas.js'
5
5
 
6
- const SEARCH_LIMIT = 10
6
+ const SEARCH_LIMIT = 5
7
7
 
8
8
  export interface SearchCommandOptions {
9
- type?: AssetType
9
+ type: AssetType
10
+ unapproved?: boolean
10
11
  baseUrl?: string
12
+ limit?: number
13
+ verbose?: boolean
11
14
  }
12
15
 
13
16
  export async function searchCommand(query: string, opts: SearchCommandOptions): Promise<void> {
14
17
  const { client } = await getCliClient({ baseUrl: opts.baseUrl })
15
18
 
16
- const spinner = ora(`Searching "${query}"`).start()
19
+ const spinner = ora({
20
+ text: `Searching "${query}"`,
21
+ isEnabled: Boolean(process.stderr.isTTY),
22
+ isSilent: !process.stderr.isTTY,
23
+ }).start()
17
24
  let results
18
25
  try {
19
- results = await client.asset.list({
20
- search: query,
26
+ results = await client.asset.search({
27
+ query,
21
28
  type: opts.type,
22
- limit: SEARCH_LIMIT,
29
+ includeUnapproved: opts.unapproved ?? false,
30
+ limit: opts.limit ?? SEARCH_LIMIT,
23
31
  page: 1,
24
32
  sort: 'relevance',
25
33
  })
26
34
  } catch (err) {
27
- spinner.fail(err instanceof Error ? err.message : String(err))
35
+ spinner.stop()
28
36
  throw err
29
37
  }
30
38
  spinner.stop()
31
39
 
40
+ console.log(
41
+ searchSummary({
42
+ query,
43
+ type: opts.type,
44
+ includeUnapproved: opts.unapproved ?? false,
45
+ count: results.items.length,
46
+ total: results.total,
47
+ }),
48
+ )
49
+
32
50
  if (results.items.length === 0) {
33
- console.log(chalk.dim('No matches.'))
34
51
  return
35
52
  }
36
53
 
37
54
  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}`)
55
+ console.log(assetSearchResultLine(item, { verbose: opts.verbose ?? false }))
40
56
  }
41
57
  }