@bibobonking/opensol 3.1.4 → 3.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 (72) hide show
  1. package/CHANGELOG.md +10 -12
  2. package/desktop/src-tauri/icons/128x128.png +0 -0
  3. package/desktop/src-tauri/icons/128x128@2x.png +0 -0
  4. package/desktop/src-tauri/icons/32x32.png +0 -0
  5. package/desktop/src-tauri/icons/64x64.png +0 -0
  6. package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
  7. package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
  8. package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
  9. package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
  10. package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
  11. package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
  12. package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
  13. package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
  14. package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
  15. package/desktop/src-tauri/icons/StoreLogo.png +0 -0
  16. package/desktop/src-tauri/icons/icon.icns +0 -0
  17. package/desktop/src-tauri/icons/icon.ico +0 -0
  18. package/desktop/src-tauri/icons/icon.png +0 -0
  19. package/dist/assets/index-C0yvs6Vl.js +281 -0
  20. package/dist/assets/index-CxNPLBg7.css +1 -0
  21. package/dist/favicon.svg +18 -15
  22. package/dist/index.html +2 -2
  23. package/package.json +1 -1
  24. package/public/favicon.svg +18 -15
  25. package/server/daemon.mjs +66 -49
  26. package/server/lib/deploy.mjs +222 -121
  27. package/server/lib/providers.mjs +23 -1
  28. package/server/lib/skills.mjs +385 -100
  29. package/server/lib/tools.mjs +0 -40
  30. package/src/agent/adapters/openai-responses.ts +1 -5
  31. package/src/agent/adapters/openai.ts +4 -11
  32. package/src/agent/prompt.ts +7 -14
  33. package/src/agent/subagent.ts +3 -14
  34. package/src/agent/tools.ts +2 -0
  35. package/src/components/ChatView.tsx +2 -1
  36. package/src/components/Composer.tsx +2 -1
  37. package/src/components/ContextBar.tsx +10 -0
  38. package/src/components/DeployModal.tsx +266 -252
  39. package/src/components/FileIcon.tsx +391 -99
  40. package/src/components/HomeDashboard.tsx +5 -10
  41. package/src/components/Logo.tsx +9 -11
  42. package/src/components/ModelDeck.tsx +8 -11
  43. package/src/components/SettingsModal.tsx +160 -122
  44. package/src/components/Sidebar.tsx +4 -3
  45. package/src/components/SystemPromptButton.tsx +198 -0
  46. package/src/components/ToolCallCard.tsx +2 -0
  47. package/src/components/TopBar.tsx +2 -19
  48. package/src/components/WorkspacePanel.tsx +76 -93
  49. package/src/core/plugins/tools/skill.ts +19 -76
  50. package/src/core/services/agent-loop.ts +16 -31
  51. package/src/core/services/subagents.ts +2 -2
  52. package/src/core/services/system-prompt.ts +16 -23
  53. package/src/core/services/tools.ts +0 -1
  54. package/src/lib/boot.ts +2 -0
  55. package/src/lib/credits.ts +33 -0
  56. package/src/lib/daemon.ts +40 -19
  57. package/src/lib/permissions.ts +1 -1
  58. package/src/lib/retry.test.mjs +40 -12
  59. package/src/lib/retry.ts +41 -8
  60. package/src/lib/system-prompt.ts +23 -0
  61. package/src/lib/usage.ts +8 -13
  62. package/src/store/chats.ts +0 -1
  63. package/src/store/settings.ts +12 -44
  64. package/src/store/skills.ts +79 -0
  65. package/src/store/ui.ts +3 -1
  66. package/src/types.ts +30 -15
  67. package/tsconfig.tsbuildinfo +1 -1
  68. package/dist/assets/index-B1pQC9Fe.js +0 -288
  69. package/dist/assets/index-qLr4aZVC.css +0 -1
  70. package/src/components/SystemPromptModal.tsx +0 -168
  71. package/src/lib/skills.test.mjs +0 -22
  72. package/src/lib/skills.ts +0 -93
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { readFile, realpath, stat } from 'node:fs/promises'
2
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { HttpError } from './http.mjs'
@@ -13,7 +13,7 @@ const CLIS = {
13
13
  cloudflare: { bin: 'wrangler', pkg: 'wrangler@latest', label: 'Wrangler' },
14
14
  }
15
15
 
16
- function slugify(value, fallback = 'opensol-site') {
16
+ function slugify(value, fallback = 'maxcode-site') {
17
17
  const slug = String(value ?? '')
18
18
  .toLowerCase()
19
19
  .replace(/[^a-z0-9-]+/g, '-')
@@ -26,7 +26,14 @@ function suffix() {
26
26
  return Math.random().toString(36).slice(2, 7)
27
27
  }
28
28
 
29
+ const IS_WINDOWS = process.platform === 'win32'
30
+
29
31
  async function commandExists(bin) {
32
+ // Direct spawn first: it is fast and works on Windows (.cmd shims). Fall back to a login shell
33
+ // so tools installed through nvm/asdf/Homebrew profiles are still found.
34
+ const direct = await runCapture(bin, ['--version'], { timeoutMs: 15_000, shell: IS_WINDOWS })
35
+ if (direct.code === 0) return { installed: true, version: direct.stdout.split('\n')[0]?.trim() ?? '' }
36
+ if (IS_WINDOWS) return { installed: false, version: '' }
30
37
  const result = await runCapture('bash', ['-lc', `command -v ${bin} >/dev/null 2>&1 && ${bin} --version`], {
31
38
  timeoutMs: 20_000,
32
39
  })
@@ -43,15 +50,16 @@ export async function deployToolStatus() {
43
50
  return Object.fromEntries(entries)
44
51
  }
45
52
 
46
- function runCapture(command, args, { cwd, env, timeoutMs = 120_000 } = {}) {
53
+ function runCapture(command, args, { cwd, env, timeoutMs = 120_000, shell = false } = {}) {
47
54
  return new Promise((resolve) => {
48
55
  let stdout = ''
49
56
  let stderr = ''
50
57
  const child = spawn(command, args, {
51
58
  cwd,
52
- env: { ...process.env, ...(env ?? {}) },
59
+ env: cleanEnv({ ...process.env, ...(env ?? {}) }),
53
60
  stdio: ['ignore', 'pipe', 'pipe'],
54
61
  detached: process.platform !== 'win32',
62
+ shell,
55
63
  })
56
64
  const timer = setTimeout(() => {
57
65
  try {
@@ -78,17 +86,33 @@ function runCapture(command, args, { cwd, env, timeoutMs = 120_000 } = {}) {
78
86
  })
79
87
  }
80
88
 
89
+ /** spawn() rejects undefined env values on some Node versions; drop them. */
90
+ function cleanEnv(env) {
91
+ return Object.fromEntries(Object.entries(env).filter(([, value]) => typeof value === 'string'))
92
+ }
93
+
94
+ function winQuote(arg) {
95
+ return /[\s"&|<>^()]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg
96
+ }
97
+
81
98
  function runStreaming(command, args, { cwd, env, onLog, timeoutMs = 15 * 60 * 1000, signal }) {
82
99
  return new Promise((resolve, reject) => {
83
100
  let stdout = ''
84
101
  let stderr = ''
85
102
  let settled = false
86
- const child = spawn(command, args, {
87
- cwd,
88
- env: { ...process.env, ...(env ?? {}), CI: '1' },
89
- stdio: ['ignore', 'pipe', 'pipe'],
90
- detached: process.platform !== 'win32',
91
- })
103
+ const child = IS_WINDOWS
104
+ ? spawn([command, ...args].map(winQuote).join(' '), {
105
+ cwd,
106
+ env: cleanEnv({ ...process.env, ...(env ?? {}), CI: '1' }),
107
+ stdio: ['ignore', 'pipe', 'pipe'],
108
+ shell: true,
109
+ })
110
+ : spawn(command, args, {
111
+ cwd,
112
+ env: cleanEnv({ ...process.env, ...(env ?? {}), CI: '1' }),
113
+ stdio: ['ignore', 'pipe', 'pipe'],
114
+ detached: true,
115
+ })
92
116
  const finish = (result) => {
93
117
  if (settled) return
94
118
  settled = true
@@ -169,68 +193,18 @@ async function fetchJson(url, options = {}) {
169
193
  return payload
170
194
  }
171
195
 
172
- function firstUrl(text) {
173
- const matches = text.match(/https:\/\/[^\s"'<>)\]]+/g) ?? []
174
- const filtered = matches.filter(
175
- (url) =>
176
- !/vercel\.com\/(docs|cli|pricing)/i.test(url) &&
177
- !/netlify\.com\/(docs|pricing|app)/i.test(url) &&
178
- !/developers\.cloudflare\.com/i.test(url) &&
179
- !/wrangler/i.test(url) &&
180
- !/github\.com/i.test(url),
181
- )
182
- return filtered[filtered.length - 1] ?? ''
183
- }
196
+ const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g
184
197
 
185
- const OUTPUT_CANDIDATES = ['dist', 'build', 'out', '.output/public', 'public']
186
- const FRAMEWORKS = [
187
- { dep: 'next', name: 'Next.js', output: 'out' },
188
- { dep: 'nuxt', name: 'Nuxt', output: '.output/public' },
189
- { dep: 'astro', name: 'Astro', output: 'dist' },
190
- { dep: '@sveltejs/kit', name: 'SvelteKit', output: 'build' },
191
- { dep: 'vite', name: 'Vite', output: 'dist' },
192
- { dep: 'react-scripts', name: 'Create React App', output: 'build' },
193
- ]
194
-
195
- async function packageManager(root) {
196
- for (const [file, command] of [
197
- ['pnpm-lock.yaml', 'pnpm'],
198
- ['yarn.lock', 'yarn'],
199
- ['bun.lockb', 'bun'],
200
- ['bun.lock', 'bun'],
201
- ['package-lock.json', 'npm'],
202
- ]) {
203
- if (await stat(path.join(root, file)).catch(() => null)) return command
204
- }
205
- return 'npm'
198
+ function urlsIn(text) {
199
+ return (String(text).replace(ANSI, '').match(/https:\/\/[^\s"'<>)\]]+/g) ?? []).map((url) => url.replace(/[.,;]+$/, ''))
206
200
  }
207
201
 
208
- /** Read package.json and existing build output so the panel can fill the form correctly. */
209
- export async function inspectDeployProject(rawPath) {
210
- const absolute = await resolveDeployPath(rawPath)
211
- await ensureDir(absolute)
212
- const pkg = await readFile(path.join(absolute, 'package.json'), 'utf8')
213
- .then((text) => JSON.parse(text))
214
- .catch(() => null)
215
- const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) }
216
- const framework = FRAMEWORKS.find((entry) => deps[entry.dep])
217
- const found = []
218
- for (const candidate of OUTPUT_CANDIDATES) {
219
- const info = await stat(path.join(absolute, candidate)).catch(() => null)
220
- if (info?.isDirectory()) found.push(candidate)
221
- }
222
- const manager = await packageManager(absolute)
223
- const script = typeof pkg?.scripts?.build === 'string' ? pkg.scripts.build : ''
224
- const buildCommand = script ? (manager === 'npm' ? 'npm run build' : `${manager} build`) : ''
225
- return {
226
- path: absolute,
227
- name: slugify(path.basename(absolute), ''),
228
- hasPackageJson: Boolean(pkg),
229
- framework: framework?.name ?? (pkg ? 'Node project' : 'Static folder'),
230
- buildCommand,
231
- outputDir: found[0] ?? framework?.output ?? '',
232
- existingOutputs: found,
233
- }
202
+ /** Picks the live site URL from CLI output, ignoring dashboards, docs and build-log links. */
203
+ function liveUrl(text, preferred, ignored = /(app\.netlify\.com|vercel\.com|dash\.cloudflare\.com|developers\.cloudflare\.com|docs\.|github\.com|npmjs\.)/i) {
204
+ const urls = urlsIn(text).filter((url) => !ignored.test(url))
205
+ const preferredMatches = urls.filter((url) => preferred.test(url))
206
+ const pool = preferredMatches.length > 0 ? preferredMatches : urls
207
+ return pool[pool.length - 1] ?? ''
234
208
  }
235
209
 
236
210
  async function resolveCli(provider) {
@@ -251,31 +225,96 @@ async function resolveCli(provider) {
251
225
  return { command: 'npx', prefix: ['--yes', def.pkg], label: def.label }
252
226
  }
253
227
 
254
- async function buildIfNeeded(cwd, buildCommand, onLog, signal) {
255
- const command = typeof buildCommand === 'string' ? buildCommand.trim() : ''
256
- if (!command) return
257
- onLog({ stream: 'meta', text: `Running build: ${command}` })
258
- const result = await runStreaming('/bin/bash', ['-lc', command], {
259
- cwd,
260
- onLog,
261
- signal,
262
- env: { MAXCODE_DEPLOY: '1' },
263
- })
264
- if (result.code !== 0) {
265
- throw new HttpError(500, `Build failed with exit code ${result.code}.`, 'build_failed')
228
+ async function pathInfo(target) {
229
+ return stat(target).catch(() => null)
230
+ }
231
+
232
+ async function readPackageJson(cwd) {
233
+ try {
234
+ const parsed = JSON.parse(await readFile(path.join(cwd, 'package.json'), 'utf8'))
235
+ return parsed && typeof parsed === 'object' ? parsed : null
236
+ } catch {
237
+ return null
266
238
  }
267
239
  }
268
240
 
269
- async function outputDirFor(cwd, outputDir) {
270
- const clean = typeof outputDir === 'string' ? outputDir.trim() : ''
271
- if (!clean || clean === 'auto') {
272
- // With no explicit choice, use a real build folder if one exists instead of dumping the whole project.
273
- for (const candidate of OUTPUT_CANDIDATES) {
274
- const found = await stat(path.join(cwd, candidate)).catch(() => null)
275
- if (found?.isDirectory()) return path.join(cwd, candidate)
241
+ async function packageManager(cwd) {
242
+ if (await pathInfo(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'
243
+ if (await pathInfo(path.join(cwd, 'yarn.lock'))) return 'yarn'
244
+ if ((await pathInfo(path.join(cwd, 'bun.lockb'))) || (await pathInfo(path.join(cwd, 'bun.lock')))) return 'bun'
245
+ return 'npm'
246
+ }
247
+
248
+ const OUTPUT_CANDIDATES = ['dist', 'build', 'out', '.output/public', 'public']
249
+
250
+ async function folderHasFiles(dir) {
251
+ try {
252
+ const entries = await readdir(dir)
253
+ return entries.length > 0
254
+ } catch {
255
+ return false
256
+ }
257
+ }
258
+
259
+ /** Looks at a project folder and suggests how it should be built and which folder to publish. */
260
+ export async function detectProject(raw) {
261
+ const cwd = await resolveDeployPath(raw)
262
+ await ensureDir(cwd)
263
+ const pkg = await readPackageJson(cwd)
264
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) }
265
+ const framework =
266
+ deps.next ? 'Next.js'
267
+ : deps.nuxt ? 'Nuxt'
268
+ : deps['@sveltejs/kit'] ? 'SvelteKit'
269
+ : deps.astro ? 'Astro'
270
+ : deps.vite ? 'Vite'
271
+ : deps['react-scripts'] ? 'Create React App'
272
+ : deps['@angular/core'] ? 'Angular'
273
+ : deps.vue ? 'Vue'
274
+ : pkg ? 'Node project'
275
+ : ''
276
+ const manager = await packageManager(cwd)
277
+ const hasBuild = typeof pkg?.scripts?.build === 'string'
278
+ const buildCommand = hasBuild ? (manager === 'npm' ? 'npm run build' : `${manager} build`) : ''
279
+
280
+ let outputDir = ''
281
+ for (const candidate of OUTPUT_CANDIDATES) {
282
+ const dir = path.join(cwd, ...candidate.split('/'))
283
+ if ((await pathInfo(dir))?.isDirectory() && (await folderHasFiles(dir))) {
284
+ // `public` is only a real output for projects without a build step; otherwise it is source assets.
285
+ if (candidate === 'public' && hasBuild) continue
286
+ outputDir = candidate
287
+ break
276
288
  }
277
- return cwd
278
289
  }
290
+ const staticSite = !hasBuild && Boolean(await pathInfo(path.join(cwd, 'index.html')))
291
+ if (!outputDir && staticSite) outputDir = '.'
292
+ const expected = hasBuild ? (deps['react-scripts'] ? 'build' : deps.nuxt ? '.output/public' : deps.next ? 'out' : 'dist') : ''
293
+ return {
294
+ path: cwd,
295
+ name: slugify(pkg?.name ? String(pkg.name).replace(/^@[^/]+\//, '') : path.basename(cwd), ''),
296
+ framework: framework || (staticSite ? 'Static site' : ''),
297
+ packageManager: pkg ? manager : '',
298
+ hasNodeModules: Boolean(await pathInfo(path.join(cwd, 'node_modules'))),
299
+ buildCommand,
300
+ outputDir,
301
+ expectedOutputDir: outputDir || expected,
302
+ built: Boolean(outputDir),
303
+ }
304
+ }
305
+
306
+ async function runBuild(cwd, command, onLog, signal) {
307
+ const result = IS_WINDOWS
308
+ ? await runStreaming(command, [], { cwd, onLog, signal, env: { MAXCODE_DEPLOY: '1' } })
309
+ : await runStreaming('/bin/bash', ['-lc', command], { cwd, onLog, signal, env: { MAXCODE_DEPLOY: '1' } })
310
+ if (result.code !== 0) {
311
+ throw new HttpError(500, `"${command}" failed with exit code ${result.code}. Fix the error in the log, then deploy again.`, 'build_failed')
312
+ }
313
+ }
314
+
315
+ function outputDirFor(cwd, outputDir) {
316
+ const clean = typeof outputDir === 'string' ? outputDir.trim() : ''
317
+ if (!clean) return cwd
279
318
  const resolved = path.resolve(cwd, clean)
280
319
  if (!resolved.startsWith(cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`) && resolved !== cwd) {
281
320
  throw new HttpError(400, 'The output folder must stay inside the deploy folder.', 'bad_output')
@@ -285,13 +324,7 @@ async function outputDirFor(cwd, outputDir) {
285
324
 
286
325
  async function ensureDir(target) {
287
326
  const info = await stat(target).catch(() => null)
288
- if (!info?.isDirectory()) {
289
- throw new HttpError(
290
- 400,
291
- `The folder "${path.basename(target)}" does not exist. Run a build command or choose the folder that actually contains the site.`,
292
- 'bad_deploy_path',
293
- )
294
- }
327
+ if (!info?.isDirectory()) throw new HttpError(400, 'The folder to deploy does not exist.', 'bad_deploy_path')
295
328
  }
296
329
 
297
330
  /**
@@ -319,8 +352,20 @@ async function netlifyDeploy({ cwd, targetDir, projectName, siteId, onLog, signa
319
352
  if (!token) {
320
353
  throw new HttpError(400, 'Add a Netlify personal access token in Settings → Deploy.', 'missing_deploy_token')
321
354
  }
322
- let site = typeof siteId === 'string' ? siteId.trim() : secrets.siteId ?? ''
355
+ // An explicit site id wins; otherwise reuse the site from the last deploy instead of creating a new one every time.
356
+ const requested = typeof siteId === 'string' ? siteId.trim() : ''
357
+ let site = requested || (typeof secrets.siteId === 'string' ? secrets.siteId : '')
323
358
  let siteUrl = ''
359
+ if (site && !requested) {
360
+ const existing = await fetchJson(`https://api.netlify.com/api/v1/sites/${encodeURIComponent(site)}`, {
361
+ headers: { Authorization: `Bearer ${token}` },
362
+ }).catch(() => null)
363
+ if (existing) siteUrl = String(existing.ssl_url ?? existing.url ?? '')
364
+ else {
365
+ onLog({ stream: 'meta', text: 'The site from the last deploy no longer exists. Creating a new one.' })
366
+ site = ''
367
+ }
368
+ }
324
369
  if (!site) {
325
370
  const name = slugify(projectName || path.basename(cwd), `maxcode-${suffix()}`)
326
371
  onLog({ stream: 'meta', text: `Creating Netlify site "${name}"…` })
@@ -348,11 +393,6 @@ async function netlifyDeploy({ cwd, targetDir, projectName, siteId, onLog, signa
348
393
  siteUrl = String(created.ssl_url ?? created.url ?? '')
349
394
  if (!site) throw new HttpError(502, 'Netlify did not return a site id.', 'deploy_api_failed')
350
395
  onLog({ stream: 'meta', text: `Created site ${siteUrl || site}` })
351
- } else {
352
- const info = await fetchJson(`https://api.netlify.com/api/v1/sites/${encodeURIComponent(site)}`, {
353
- headers: { Authorization: `Bearer ${token}` },
354
- }).catch(() => null)
355
- if (info) siteUrl = String(info.ssl_url ?? info.url ?? '')
356
396
  }
357
397
 
358
398
  await setDeploySecret('netlify', { siteId: site })
@@ -364,6 +404,7 @@ async function netlifyDeploy({ cwd, targetDir, projectName, siteId, onLog, signa
364
404
  [
365
405
  ...cli.prefix,
366
406
  'deploy',
407
+ '--no-build',
367
408
  '--prod',
368
409
  '--dir',
369
410
  targetDir,
@@ -377,7 +418,7 @@ async function netlifyDeploy({ cwd, targetDir, projectName, siteId, onLog, signa
377
418
  if (result.code !== 0) {
378
419
  throw new HttpError(500, `Netlify deploy failed (exit ${result.code}).`, 'deploy_failed')
379
420
  }
380
- const url = firstUrl(result.stdout) || siteUrl
421
+ const url = liveUrl(result.stdout, /\.netlify\.app/i) || siteUrl
381
422
  if (url) await setDeploySecret('netlify', { lastUrl: url })
382
423
  return { provider: 'netlify', url, siteId: site }
383
424
  }
@@ -390,21 +431,24 @@ async function vercelDeploy({ cwd, targetDir, projectName, onLog, signal }) {
390
431
  }
391
432
  const cli = await resolveCli('vercel')
392
433
  const teamId = (typeof secrets.teamId === 'string' && secrets.teamId) || ''
393
- const args = [...cli.prefix, 'deploy', targetDir, '--prod', '--yes']
394
- if (teamId) args.push('--scope', teamId)
395
434
  const name = slugify(projectName || path.basename(cwd), '')
396
- if (name) args.push('--name', name)
397
- onLog({ stream: 'meta', text: `Deploying ${targetDir} with ${cli.label}${name ? ` as "${name}"` : ''}…` })
398
- const result = await runStreaming(cli.command, args, {
399
- cwd,
400
- onLog,
401
- signal,
402
- env: { VERCEL_TOKEN: token, VERCEL_ORG_ID: teamId || undefined },
403
- })
435
+ const base = [...cli.prefix, 'deploy', targetDir, '--prod', '--yes', '--token', token]
436
+ if (teamId) base.push('--scope', teamId)
437
+ const run = (args) => {
438
+ onLog({ stream: 'meta', text: `Deploying ${targetDir} with ${cli.label}${name ? ` as "${name}"` : ''}…` })
439
+ // The token goes through the environment too; never log the argument list.
440
+ return runStreaming(cli.command, args, { cwd, onLog, signal, env: { VERCEL_TOKEN: token } })
441
+ }
442
+ // VERCEL_ORG_ID must not be set alone (the CLI then demands VERCEL_PROJECT_ID), so the team goes through --scope.
443
+ let result = await run(name ? [...base, '--name', name] : base)
444
+ if (result.code !== 0 && name && /(unknown|unexpected|unrecognized).{0,40}--?name|--name.{0,40}(unknown|not)/i.test(`${result.stderr}\n${result.stdout}`)) {
445
+ onLog({ stream: 'meta', text: 'This Vercel CLI no longer accepts --name; retrying without it.' })
446
+ result = await run(base)
447
+ }
404
448
  if (result.code !== 0) {
405
- throw new HttpError(500, `Vercel deploy failed (exit ${result.code}).`, 'deploy_failed')
449
+ throw new HttpError(500, `Vercel deploy failed (exit ${result.code}). See the log above.`, 'deploy_failed')
406
450
  }
407
- const url = firstUrl(`${result.stdout}\n${result.stderr}`)
451
+ const url = liveUrl(`${result.stdout}\n${result.stderr}`, /\.vercel\.app/i)
408
452
  if (!url) throw new HttpError(502, 'Vercel did not report a deployment URL.', 'deploy_url_missing')
409
453
  await setDeploySecret('vercel', { projectName: name || projectName || '', lastUrl: url })
410
454
  return { provider: 'vercel', url, project: name || projectName || '' }
@@ -477,26 +521,83 @@ async function cloudflareDeploy({ cwd, targetDir, projectName, onLog, signal })
477
521
  if (result.code !== 0) {
478
522
  throw new HttpError(500, `Cloudflare Pages deploy failed (exit ${result.code}).`, 'deploy_failed')
479
523
  }
480
- const url = firstUrl(`${result.stdout}\n${result.stderr}`) || `https://${name}.pages.dev`
524
+ const url = `https://${name}.pages.dev`
525
+ const reported = liveUrl(`${result.stdout}\n${result.stderr}`, /\.pages\.dev/i)
526
+ onLog({ stream: 'meta', text: reported && reported !== url ? `This deployment: ${reported}` : `Production alias: ${url}` })
481
527
  await setDeploySecret('cloudflare', { lastUrl: url })
482
528
  return { provider: 'cloudflare', url, project: name }
483
529
  }
484
530
 
485
- export async function runDeploy(input, { onLog = () => undefined, signal } = {}) {
531
+ export async function runDeploy(input, { onLog = () => undefined, onStep = () => undefined, signal } = {}) {
486
532
  const provider = String(input?.provider ?? '')
487
533
  if (!['cloudflare', 'netlify', 'vercel'].includes(provider)) {
488
534
  throw new HttpError(400, 'Choose Cloudflare Pages, Netlify or Vercel.', 'bad_deploy_provider')
489
535
  }
536
+ onStep({ id: 'prepare', label: 'Inspect project' })
490
537
  const absolute = await resolveDeployPath(input?.path)
491
538
  await ensureDir(absolute)
492
- const targetDir = await outputDirFor(absolute, input?.outputDir)
493
- await buildIfNeeded(absolute, input?.buildCommand, onLog, signal)
494
- await ensureDir(targetDir)
539
+ const detected = await detectProject(absolute)
540
+
541
+ const requestedOutput = typeof input?.outputDir === 'string' ? input.outputDir.trim() : ''
542
+ const auto = !requestedOutput || requestedOutput === 'auto'
543
+ let buildCommand = typeof input?.buildCommand === 'string' ? input.buildCommand.trim() : ''
544
+ let outputDir = auto ? detected.outputDir : requestedOutput
545
+
546
+ const outputMissing = async () => {
547
+ const dir = auto ? detected.outputDir : requestedOutput
548
+ if (!dir) return true
549
+ const info = await pathInfo(path.resolve(absolute, dir))
550
+ return !info?.isDirectory()
551
+ }
552
+ if (!buildCommand && (await outputMissing()) && detected.buildCommand) {
553
+ buildCommand = detected.buildCommand
554
+ onLog({ stream: 'meta', text: `No output folder yet. Building first with "${buildCommand}".` })
555
+ }
556
+
557
+ if (buildCommand) {
558
+ const needsInstall = detected.packageManager && !detected.hasNodeModules && /\b(npm|pnpm|yarn|bun)\b/.test(buildCommand)
559
+ if (needsInstall) {
560
+ onStep({ id: 'install', label: 'Install dependencies' })
561
+ const install = detected.packageManager === 'npm' ? 'npm install' : `${detected.packageManager} install`
562
+ onLog({ stream: 'meta', text: `node_modules is missing. Running ${install}.` })
563
+ await runBuild(absolute, install, onLog, signal)
564
+ }
565
+ onStep({ id: 'build', label: 'Build' })
566
+ onLog({ stream: 'meta', text: `Running build: ${buildCommand}` })
567
+ await runBuild(absolute, buildCommand, onLog, signal)
568
+ }
569
+
570
+ if (auto) {
571
+ outputDir = (await detectProject(absolute)).outputDir
572
+ if (!outputDir) {
573
+ throw new HttpError(
574
+ 400,
575
+ `No output folder found in ${path.basename(absolute)}. Add a build command that creates dist/, build/ or out/, or choose the folder to publish.`,
576
+ 'bad_output',
577
+ )
578
+ }
579
+ }
580
+ const targetDir = outputDirFor(absolute, outputDir)
581
+ const info = await pathInfo(targetDir)
582
+ if (!info?.isDirectory()) {
583
+ throw new HttpError(
584
+ 400,
585
+ `The output folder "${outputDir}" does not exist${buildCommand ? ' after the build' : '. Add a build command or pick another folder'}.`,
586
+ 'bad_output',
587
+ )
588
+ }
589
+ if (!(await folderHasFiles(targetDir))) {
590
+ throw new HttpError(400, `The output folder "${outputDir}" is empty, so there is nothing to publish.`, 'bad_output')
591
+ }
592
+ if (!(await pathInfo(path.join(targetDir, 'index.html')))) {
593
+ onLog({ stream: 'meta', text: `Note: ${outputDir} has no index.html, so the site root may show a 404.` })
594
+ }
495
595
 
596
+ onStep({ id: 'publish', label: 'Publish' })
496
597
  const common = {
497
598
  cwd: absolute,
498
599
  targetDir,
499
- projectName: String(input?.projectName ?? ''),
600
+ projectName: String(input?.projectName ?? '').trim() || detected.name,
500
601
  siteId: String(input?.siteId ?? ''),
501
602
  onLog,
502
603
  signal,
@@ -138,6 +138,7 @@ function trimCatalog(raw) {
138
138
  temperature: model.temperature !== false,
139
139
  modalities: record(model.modalities),
140
140
  limit: record(model.limit),
141
+ cost: record(model.cost),
141
142
  provider: record(model.provider),
142
143
  canonical_model_id: text(model.canonical_model_id),
143
144
  }
@@ -220,7 +221,26 @@ function opencodeKindFromId(modelId, catalogEntry) {
220
221
  return 'openai-chat'
221
222
  }
222
223
 
223
- function modelInfo({ providerId, id, name, context, maxOutput, vision, catalogEntry, fallbackKind }) {
224
+ /** USD per 1M tokens, from a live per-token `pricing` block (OpenRouter style) or models.dev `cost`. */
225
+ function pricingOf(livePricing, catalogCost) {
226
+ const live = record(livePricing)
227
+ const perToken = (value) => {
228
+ const parsed = typeof value === 'string' ? Number.parseFloat(value) : typeof value === 'number' ? value : NaN
229
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed * 1_000_000 : undefined
230
+ }
231
+ const liveInput = perToken(live.prompt)
232
+ const liveOutput = perToken(live.completion)
233
+ if (liveInput !== undefined && liveOutput !== undefined && (liveInput > 0 || liveOutput > 0)) {
234
+ return { input: liveInput, output: liveOutput }
235
+ }
236
+ const cost = record(catalogCost)
237
+ const input = number(cost.input)
238
+ const output = number(cost.output)
239
+ if (input !== undefined && output !== undefined && (input > 0 || output > 0)) return { input, output }
240
+ return undefined
241
+ }
242
+
243
+ function modelInfo({ providerId, id, name, context, maxOutput, vision, catalogEntry, fallbackKind, livePricing }) {
224
244
  const catalogDef = catalogEntry ?? {}
225
245
  const modalities = record(catalogDef.modalities)
226
246
  const limit = record(catalogDef.limit)
@@ -255,6 +275,7 @@ function modelInfo({ providerId, id, name, context, maxOutput, vision, catalogEn
255
275
  apiKind,
256
276
  free,
257
277
  canonicalId: text(catalogDef.canonical_model_id) || undefined,
278
+ pricing: pricingOf(livePricing, catalogDef.cost),
258
279
  note:
259
280
  providerId === 'opencode' && free
260
281
  ? 'OpenCode free-tier models only work inside OpenCode. Add credits or use a paid model.'
@@ -296,6 +317,7 @@ function fromOpenAI(payload, providerId, catalog) {
296
317
  context: number(item.context_length) ?? number(item.context_window),
297
318
  vision: strings(architecture.input_modalities).includes('image'),
298
319
  catalogEntry: entry,
320
+ livePricing: item.pricing,
299
321
  }),
300
322
  )
301
323
  }