@bibobonking/opensol 0.0.0-stage → 3.0.2

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 (127) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/server/README.md +75 -0
  14. package/server/daemon.mjs +1082 -0
  15. package/server/lib/checkpoints.mjs +124 -0
  16. package/server/lib/checkpoints.test.mjs +110 -0
  17. package/server/lib/deploy.mjs +436 -0
  18. package/server/lib/http.mjs +237 -0
  19. package/server/lib/plugins.mjs +321 -0
  20. package/server/lib/plugins.test.mjs +140 -0
  21. package/server/lib/providers.mjs +594 -0
  22. package/server/lib/store.mjs +491 -0
  23. package/server/lib/tools.mjs +990 -0
  24. package/server/opensol.mjs +202 -0
  25. package/server/package.json +42 -0
  26. package/shared/plugin-builder.test.mjs +43 -0
  27. package/shared/plugin-package.d.mts +34 -0
  28. package/shared/plugin-package.mjs +197 -0
  29. package/shared/plugin-package.test.mjs +94 -0
  30. package/src/App.tsx +152 -0
  31. package/src/agent/adapters/anthropic.ts +233 -0
  32. package/src/agent/adapters/gemini.ts +225 -0
  33. package/src/agent/adapters/openai-responses.ts +215 -0
  34. package/src/agent/adapters/openai.ts +200 -0
  35. package/src/agent/adapters/shared.ts +96 -0
  36. package/src/agent/approval.ts +20 -0
  37. package/src/agent/compact.ts +234 -0
  38. package/src/agent/index.ts +36 -0
  39. package/src/agent/model.ts +20 -0
  40. package/src/agent/prompt.ts +107 -0
  41. package/src/agent/queue.test.mjs +111 -0
  42. package/src/agent/queue.ts +84 -0
  43. package/src/agent/subagent.ts +109 -0
  44. package/src/agent/tools.ts +541 -0
  45. package/src/components/ChatView.tsx +403 -0
  46. package/src/components/CommandPalette.tsx +416 -0
  47. package/src/components/Composer.tsx +678 -0
  48. package/src/components/ContextBar.tsx +58 -0
  49. package/src/components/DeployModal.tsx +512 -0
  50. package/src/components/ErrorBoundary.tsx +75 -0
  51. package/src/components/HomeDashboard.tsx +189 -0
  52. package/src/components/Logo.tsx +41 -0
  53. package/src/components/Markdown.tsx +232 -0
  54. package/src/components/MessageItem.tsx +655 -0
  55. package/src/components/ModelPicker.tsx +487 -0
  56. package/src/components/PixleSol.tsx +243 -0
  57. package/src/components/PluginBuilder.tsx +285 -0
  58. package/src/components/PluginManager.tsx +341 -0
  59. package/src/components/PreviewPane.tsx +166 -0
  60. package/src/components/SettingsModal.tsx +2052 -0
  61. package/src/components/Sidebar.tsx +504 -0
  62. package/src/components/Splash.tsx +104 -0
  63. package/src/components/ToolCallCard.tsx +177 -0
  64. package/src/components/TopBar.tsx +182 -0
  65. package/src/components/TrajectoryView.tsx +178 -0
  66. package/src/components/WorkspacePanel.tsx +710 -0
  67. package/src/components/ui.tsx +311 -0
  68. package/src/core/kernel.ts +45 -0
  69. package/src/core/plugins/tools/fs.ts +219 -0
  70. package/src/core/plugins/tools/git.ts +83 -0
  71. package/src/core/plugins/tools/grep.ts +98 -0
  72. package/src/core/plugins/tools/shell.ts +88 -0
  73. package/src/core/plugins/tools/subagent.ts +36 -0
  74. package/src/core/plugins/tools/web.ts +97 -0
  75. package/src/core/services/agent-loop.ts +417 -0
  76. package/src/core/services/approval.ts +66 -0
  77. package/src/core/services/compaction.ts +45 -0
  78. package/src/core/services/llm.ts +32 -0
  79. package/src/core/services/sessions.ts +47 -0
  80. package/src/core/services/subagents.ts +15 -0
  81. package/src/core/services/system-prompt.ts +177 -0
  82. package/src/core/services/tools.ts +135 -0
  83. package/src/core/services/ui.ts +109 -0
  84. package/src/core/types.ts +139 -0
  85. package/src/data/providers.ts +125 -0
  86. package/src/data/skills.ts +68 -0
  87. package/src/index.css +776 -0
  88. package/src/lib/boot.ts +94 -0
  89. package/src/lib/context.ts +75 -0
  90. package/src/lib/conversation.test.mjs +64 -0
  91. package/src/lib/conversation.ts +59 -0
  92. package/src/lib/daemon.ts +475 -0
  93. package/src/lib/hooks.ts +33 -0
  94. package/src/lib/permissions.ts +60 -0
  95. package/src/lib/pixlesol.test.mjs +51 -0
  96. package/src/lib/pixlesol.ts +55 -0
  97. package/src/lib/plugin-folder.test.mjs +145 -0
  98. package/src/lib/plugin-folder.ts +96 -0
  99. package/src/lib/reasoning.ts +87 -0
  100. package/src/lib/sse.ts +75 -0
  101. package/src/lib/themes.test.mjs +72 -0
  102. package/src/lib/themes.ts +52 -0
  103. package/src/lib/usage.test.mjs +72 -0
  104. package/src/lib/usage.ts +155 -0
  105. package/src/lib/utils.ts +73 -0
  106. package/src/main.tsx +16 -0
  107. package/src/plugins/api.test.mjs +77 -0
  108. package/src/plugins/api.ts +113 -0
  109. package/src/plugins/bridge.d.mts +1 -0
  110. package/src/plugins/bridge.mjs +26 -0
  111. package/src/plugins/bridge.test.mjs +17 -0
  112. package/src/plugins/index.ts +52 -0
  113. package/src/plugins/preview.test.mjs +18 -0
  114. package/src/plugins/preview.ts +34 -0
  115. package/src/plugins/runtime.ts +194 -0
  116. package/src/plugins/types.ts +40 -0
  117. package/src/store/chats.ts +278 -0
  118. package/src/store/models.ts +120 -0
  119. package/src/store/settings.ts +445 -0
  120. package/src/store/ui.ts +132 -0
  121. package/src/store/workspace.ts +299 -0
  122. package/src/types.ts +337 -0
  123. package/src/vite-env.d.ts +1 -0
  124. package/tailwind.config.js +83 -0
  125. package/tsconfig.json +22 -0
  126. package/tsconfig.tsbuildinfo +1 -0
  127. package/vite.config.ts +19 -0
@@ -0,0 +1,124 @@
1
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
2
+ import { randomBytes } from 'node:crypto'
3
+ import path from 'node:path'
4
+ import { DATA_DIR } from './store.mjs'
5
+
6
+ /**
7
+ * Checkpoints remember what a file looked like the first time the agent touched it
8
+ * during one turn, so the whole turn's file edits can be undone. They only cover
9
+ * the file tools (write, rewrite, edit); shell commands are not tracked.
10
+ */
11
+
12
+ const DIR = path.join(DATA_DIR, 'checkpoints')
13
+ const ID = /^[A-Za-z0-9_-]{1,120}$/
14
+ const MAX_FILE_BYTES = 1_000_000
15
+ const MAX_FILES = 200
16
+ const KEEP = 30
17
+
18
+ const chains = new Map()
19
+
20
+ export function isCheckpointId(value) {
21
+ return typeof value === 'string' && ID.test(value)
22
+ }
23
+
24
+ function fileFor(id) {
25
+ return path.join(DIR, `${id}.json`)
26
+ }
27
+
28
+ function serial(id, task) {
29
+ const previous = chains.get(id) ?? Promise.resolve()
30
+ const next = previous.then(task, task)
31
+ const tail = next.then(
32
+ () => undefined,
33
+ () => undefined,
34
+ )
35
+ chains.set(id, tail)
36
+ void tail.then(() => {
37
+ if (chains.get(id) === tail) chains.delete(id)
38
+ })
39
+ return next
40
+ }
41
+
42
+ async function load(id) {
43
+ try {
44
+ const parsed = JSON.parse(await readFile(fileFor(id), 'utf8'))
45
+ if (parsed && typeof parsed === 'object' && parsed.files && typeof parsed.files === 'object') return parsed
46
+ return null
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ async function save(checkpoint) {
53
+ await mkdir(DIR, { recursive: true, mode: 0o700 })
54
+ const target = fileFor(checkpoint.id)
55
+ const temp = `${target}.${randomBytes(4).toString('hex')}.tmp`
56
+ await writeFile(temp, JSON.stringify(checkpoint), { mode: 0o600 })
57
+ await rename(temp, target)
58
+ }
59
+
60
+ async function prune() {
61
+ let names
62
+ try {
63
+ names = (await readdir(DIR)).filter((name) => name.endsWith('.json'))
64
+ } catch {
65
+ return
66
+ }
67
+ if (names.length <= KEEP) return
68
+ const dated = []
69
+ for (const name of names) {
70
+ const info = await stat(path.join(DIR, name)).catch(() => null)
71
+ if (info) dated.push({ name, time: info.mtimeMs })
72
+ }
73
+ dated.sort((a, b) => b.time - a.time)
74
+ for (const old of dated.slice(KEEP)) await rm(path.join(DIR, old.name), { force: true })
75
+ }
76
+
77
+ /** Remember the pre-change state of one file. Only the first record per turn counts. */
78
+ export function recordBefore(id, root, relative, absolute) {
79
+ if (!isCheckpointId(id)) return Promise.resolve(false)
80
+ return serial(id, async () => {
81
+ const checkpoint = (await load(id)) ?? { id, root, createdAt: Date.now(), files: {} }
82
+ if (checkpoint.root !== root) return false
83
+ if (relative in checkpoint.files) return false
84
+ if (Object.keys(checkpoint.files).length >= MAX_FILES) return false
85
+
86
+ const info = await stat(absolute).catch(() => null)
87
+ if (!info) {
88
+ checkpoint.files[relative] = { existed: false }
89
+ } else if (!info.isFile() || info.size > MAX_FILE_BYTES) {
90
+ checkpoint.files[relative] = { existed: true, skipped: true }
91
+ } else {
92
+ const buffer = await readFile(absolute)
93
+ checkpoint.files[relative] = buffer.includes(0)
94
+ ? { existed: true, skipped: true }
95
+ : { existed: true, content: buffer.toString('utf8'), mode: info.mode & 0o777 }
96
+ }
97
+ const isNew = Object.keys(checkpoint.files).length === 1
98
+ await save(checkpoint)
99
+ if (isNew) await prune()
100
+ return true
101
+ })
102
+ }
103
+
104
+ export async function readCheckpoint(id) {
105
+ if (!isCheckpointId(id)) return null
106
+ return load(id)
107
+ }
108
+
109
+ export function describeCheckpoint(checkpoint) {
110
+ return {
111
+ id: checkpoint.id,
112
+ createdAt: checkpoint.createdAt,
113
+ files: Object.entries(checkpoint.files).map(([file, entry]) => ({
114
+ path: file,
115
+ existed: entry.existed === true,
116
+ skipped: entry.skipped === true,
117
+ })),
118
+ }
119
+ }
120
+
121
+ export async function dropCheckpoint(id) {
122
+ if (!isCheckpointId(id)) return
123
+ await serial(id, () => rm(fileFor(id), { force: true }))
124
+ }
@@ -0,0 +1,110 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, mkdir, readFile, writeFile, stat } from 'node:fs/promises'
4
+ import os from 'node:os'
5
+ import http from 'node:http'
6
+ import path from 'node:path'
7
+
8
+ // The data directory is read when store.mjs loads, so set it before any import.
9
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), 'maxcode-data-'))
10
+ process.env.MAXCODE_DATA = dataDir
11
+ const workspace = await mkdtemp(path.join(os.tmpdir(), 'maxcode-ws-'))
12
+
13
+ const store = await import('./store.mjs')
14
+ const tools = await import('./tools.mjs')
15
+ const { startServer } = await import('../daemon.mjs')
16
+
17
+ await store.addWorkspace(workspace)
18
+
19
+ test('edit_file keeps $ patterns in the replacement literal', async () => {
20
+ await writeFile(path.join(workspace, 'price.txt'), 'cost: X\n')
21
+ await tools.executeTool('edit_file', { path: 'price.txt', oldText: 'X', newText: '$& and $1' })
22
+ assert.equal(await readFile(path.join(workspace, 'price.txt'), 'utf8'), 'cost: $& and $1\n')
23
+ })
24
+
25
+ test('a checkpoint restores edited files and removes files the turn created', async () => {
26
+ await writeFile(path.join(workspace, 'keep.txt'), 'original')
27
+ const id = 'turn_one'
28
+ await tools.executeTool('write_file', { path: 'keep.txt', content: 'changed' }, { checkpoint: id })
29
+ await tools.executeTool('edit_file', { path: 'keep.txt', oldText: 'changed', newText: 'changed twice' }, { checkpoint: id })
30
+ await tools.executeTool('write_file', { path: 'made/new.txt', content: 'fresh' }, { checkpoint: id })
31
+
32
+ const summary = await tools.checkpointSummary(id)
33
+ assert.deepEqual(summary.files.map((f) => f.path).sort(), ['keep.txt', 'made/new.txt'])
34
+
35
+ const result = await tools.restoreCheckpoint(id)
36
+ assert.deepEqual(result.restored, ['keep.txt'])
37
+ assert.deepEqual(result.removed, ['made/new.txt'])
38
+ assert.equal(await readFile(path.join(workspace, 'keep.txt'), 'utf8'), 'original')
39
+ assert.equal(await stat(path.join(workspace, 'made/new.txt')).catch(() => null), null)
40
+
41
+ await assert.rejects(() => tools.restoreCheckpoint(id), /expired|does not exist/)
42
+ })
43
+
44
+ test('plan mode never records a checkpoint', async () => {
45
+ await assert.rejects(() =>
46
+ tools.executeTool('write_file', { path: 'x.txt', content: 'a' }, { mode: 'plan', checkpoint: 'turn_plan' }),
47
+ )
48
+ assert.equal(await tools.checkpointSummary('turn_plan'), null)
49
+ })
50
+
51
+ test('project instructions load the first matching file', async () => {
52
+ assert.equal((await tools.readProjectInstructions()).file, '')
53
+ await writeFile(path.join(workspace, 'CLAUDE.md'), 'use tabs')
54
+ assert.equal((await tools.readProjectInstructions()).file, 'CLAUDE.md')
55
+ await writeFile(path.join(workspace, 'MAXCODE.md'), 'use spaces')
56
+ const found = await tools.readProjectInstructions()
57
+ assert.equal(found.file, 'MAXCODE.md')
58
+ assert.equal(found.content, 'use spaces')
59
+ })
60
+
61
+ test('the preview route serves workspace files in a sandbox and refuses everything else', async () => {
62
+ await writeFile(path.join(workspace, 'index.html'), '<h1>hi</h1>')
63
+ await mkdir(path.join(workspace, '.git'), { recursive: true })
64
+ await writeFile(path.join(workspace, '.git', 'config'), 'secret')
65
+
66
+ const { server, port } = await startServer({ port: 0 })
67
+ const base = `http://127.0.0.1:${port}`
68
+ try {
69
+ const session = await fetch(`${base}/api/session`, { method: 'POST' })
70
+ const cookie = session.headers.get('set-cookie').split(';')[0]
71
+ const { csrf } = await session.json()
72
+ const open = await fetch(`${base}/api/workspaces`, {
73
+ method: 'POST',
74
+ headers: { cookie, 'x-maxcode-csrf': csrf, 'content-type': 'application/json' },
75
+ body: JSON.stringify({ path: workspace }),
76
+ })
77
+ assert.equal(open.status, 200)
78
+ const { token } = await (await fetch(`${base}/api/preview/token`, { headers: { cookie } })).json()
79
+ assert.ok(token.length >= 16)
80
+
81
+ // No cookie on purpose: a sandboxed iframe is a different origin.
82
+ const page = await fetch(`${base}/preview/${token}/`)
83
+ assert.equal(page.status, 200)
84
+ assert.equal(await page.text(), '<h1>hi</h1>')
85
+ assert.match(page.headers.get('content-security-policy'), /^sandbox allow-scripts/)
86
+ assert.equal(page.headers.get('x-frame-options'), 'SAMEORIGIN')
87
+ assert.match(page.headers.get('content-type'), /text\/html/)
88
+
89
+ assert.equal((await fetch(`${base}/preview/${token}/missing.html`)).status, 404)
90
+ assert.equal((await fetch(`${base}/preview/${token}/.git/config`)).status, 403)
91
+ assert.equal((await fetch(`${base}/preview/${token}/..%2f..%2fetc%2fpasswd`)).status, 403)
92
+ assert.equal((await fetch(`${base}/preview/not-a-real-token-1234567890/`)).status, 401)
93
+ // fetch() ignores a custom Host header, so use a raw request to prove DNS-rebinding hosts are refused.
94
+ const rebound = await new Promise((resolve, reject) => {
95
+ const request = http.request(
96
+ { host: '127.0.0.1', port, path: `/preview/${token}/`, headers: { host: 'evil.example' } },
97
+ (response) => {
98
+ response.resume()
99
+ resolve(response.statusCode)
100
+ },
101
+ )
102
+ request.on('error', reject)
103
+ request.end()
104
+ })
105
+ assert.equal(rebound, 403)
106
+ } finally {
107
+ server.closeAllConnections()
108
+ server.close()
109
+ }
110
+ })
@@ -0,0 +1,436 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { realpath, stat } from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { HttpError } from './http.mjs'
6
+ import { resolveWorkspacePath } from './tools.mjs'
7
+ import { deploySecrets } from './providers.mjs'
8
+ import { setDeploySecret } from './store.mjs'
9
+
10
+ const CLIS = {
11
+ vercel: { bin: 'vercel', pkg: 'vercel@latest', label: 'Vercel CLI' },
12
+ netlify: { bin: 'netlify', pkg: 'netlify-cli@latest', label: 'Netlify CLI' },
13
+ cloudflare: { bin: 'wrangler', pkg: 'wrangler@latest', label: 'Wrangler' },
14
+ }
15
+
16
+ function slugify(value, fallback = 'maxcode-site') {
17
+ const slug = String(value ?? '')
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9-]+/g, '-')
20
+ .replace(/^-+|-+$/g, '')
21
+ .slice(0, 50)
22
+ return slug || fallback
23
+ }
24
+
25
+ function suffix() {
26
+ return Math.random().toString(36).slice(2, 7)
27
+ }
28
+
29
+ async function commandExists(bin) {
30
+ const result = await runCapture('bash', ['-lc', `command -v ${bin} >/dev/null 2>&1 && ${bin} --version`], {
31
+ timeoutMs: 20_000,
32
+ })
33
+ return { installed: result.code === 0, version: result.stdout.split('\n')[0]?.trim() ?? '' }
34
+ }
35
+
36
+ export async function deployToolStatus() {
37
+ const entries = await Promise.all(
38
+ Object.entries(CLIS).map(async ([id, def]) => {
39
+ const check = await commandExists(def.bin)
40
+ return [id, { ...def, installed: check.installed, version: check.version }]
41
+ }),
42
+ )
43
+ return Object.fromEntries(entries)
44
+ }
45
+
46
+ function runCapture(command, args, { cwd, env, timeoutMs = 120_000 } = {}) {
47
+ return new Promise((resolve) => {
48
+ let stdout = ''
49
+ let stderr = ''
50
+ const child = spawn(command, args, {
51
+ cwd,
52
+ env: { ...process.env, ...(env ?? {}) },
53
+ stdio: ['ignore', 'pipe', 'pipe'],
54
+ detached: process.platform !== 'win32',
55
+ })
56
+ const timer = setTimeout(() => {
57
+ try {
58
+ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL')
59
+ else child.kill('SIGKILL')
60
+ } catch {
61
+ child.kill('SIGKILL')
62
+ }
63
+ }, timeoutMs)
64
+ child.stdout.on('data', (chunk) => {
65
+ if (stdout.length < 300_000) stdout += chunk.toString('utf8')
66
+ })
67
+ child.stderr.on('data', (chunk) => {
68
+ if (stderr.length < 300_000) stderr += chunk.toString('utf8')
69
+ })
70
+ child.on('error', (error) => {
71
+ clearTimeout(timer)
72
+ resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` })
73
+ })
74
+ child.on('close', (code) => {
75
+ clearTimeout(timer)
76
+ resolve({ code: typeof code === 'number' ? code : -1, stdout, stderr })
77
+ })
78
+ })
79
+ }
80
+
81
+ function runStreaming(command, args, { cwd, env, onLog, timeoutMs = 15 * 60 * 1000, signal }) {
82
+ return new Promise((resolve, reject) => {
83
+ let stdout = ''
84
+ let stderr = ''
85
+ 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
+ })
92
+ const finish = (result) => {
93
+ if (settled) return
94
+ settled = true
95
+ clearTimeout(timer)
96
+ resolve({ ...result, stdout, stderr })
97
+ }
98
+ const fail = (error) => {
99
+ if (settled) return
100
+ settled = true
101
+ clearTimeout(timer)
102
+ reject(error)
103
+ }
104
+ const kill = () => {
105
+ try {
106
+ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGTERM')
107
+ else child.kill('SIGTERM')
108
+ } catch {
109
+ child.kill('SIGTERM')
110
+ }
111
+ setTimeout(() => {
112
+ try {
113
+ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL')
114
+ else child.kill('SIGKILL')
115
+ } catch {
116
+ child.kill('SIGKILL')
117
+ }
118
+ }, 5000).unref()
119
+ }
120
+ const timer = setTimeout(() => {
121
+ onLog({ stream: 'stderr', text: 'Deployment timed out after 15 minutes.\n' })
122
+ kill()
123
+ }, timeoutMs)
124
+ if (signal) {
125
+ if (signal.aborted) kill()
126
+ else signal.addEventListener('abort', kill, { once: true })
127
+ }
128
+ const handle = (stream) => (chunk) => {
129
+ const text = chunk.toString('utf8')
130
+ if (stream === 'stdout') {
131
+ if (stdout.length < 400_000) stdout += text
132
+ } else if (stderr.length < 400_000) stderr += text
133
+ for (const line of text.split('\n')) {
134
+ if (line.trim()) onLog({ stream, text: line })
135
+ }
136
+ }
137
+ child.stdout.on('data', handle('stdout'))
138
+ child.stderr.on('data', handle('stderr'))
139
+ child.on('error', (error) => fail(error))
140
+ child.on('close', (code) => finish({ code: typeof code === 'number' ? code : -1 }))
141
+ })
142
+ }
143
+
144
+ async function fetchJson(url, options = {}) {
145
+ const response = await fetch(url, {
146
+ ...options,
147
+ signal: AbortSignal.timeout(30_000),
148
+ })
149
+ const raw = await response.text()
150
+ let payload = {}
151
+ try {
152
+ payload = raw ? JSON.parse(raw) : {}
153
+ } catch {
154
+ payload = { raw }
155
+ }
156
+ if (!response.ok) {
157
+ const message =
158
+ payload?.errors?.[0]?.message ||
159
+ payload?.error?.message ||
160
+ (typeof payload?.message === 'string' ? payload.message : '') ||
161
+ payload?.error ||
162
+ raw.slice(0, 300)
163
+ throw new HttpError(
164
+ response.status,
165
+ `Deploy API returned ${response.status}: ${typeof message === 'string' ? message : JSON.stringify(message)}`,
166
+ 'deploy_api_failed',
167
+ )
168
+ }
169
+ return payload
170
+ }
171
+
172
+ function firstUrl(text) {
173
+ const matches = text.match(/https:\/\/[^\s"'<>)\]]+/g) ?? []
174
+ const filtered = matches.filter((url) => !/vercel\.com\/(docs|cli|pricing)/i.test(url))
175
+ return filtered[filtered.length - 1] ?? ''
176
+ }
177
+
178
+ async function resolveCli(provider) {
179
+ const def = CLIS[provider]
180
+ if (!def) throw new HttpError(400, `Unknown deploy provider: ${provider}`, 'bad_deploy_provider')
181
+ const direct = await commandExists(def.bin)
182
+ if (direct.installed) {
183
+ return { command: def.bin, prefix: [], label: def.label }
184
+ }
185
+ const npx = await commandExists('npx')
186
+ if (!npx.installed) {
187
+ throw new HttpError(
188
+ 500,
189
+ `${def.label} is not installed. Install Node.js (which includes npx), then try again.`,
190
+ 'cli_missing',
191
+ )
192
+ }
193
+ return { command: 'npx', prefix: ['--yes', def.pkg], label: def.label }
194
+ }
195
+
196
+ async function buildIfNeeded(cwd, buildCommand, onLog, signal) {
197
+ const command = typeof buildCommand === 'string' ? buildCommand.trim() : ''
198
+ if (!command) return
199
+ onLog({ stream: 'meta', text: `Running build: ${command}` })
200
+ const result = await runStreaming('/bin/bash', ['-lc', command], {
201
+ cwd,
202
+ onLog,
203
+ signal,
204
+ env: { MAXCODE_DEPLOY: '1' },
205
+ })
206
+ if (result.code !== 0) {
207
+ throw new HttpError(500, `Build failed with exit code ${result.code}.`, 'build_failed')
208
+ }
209
+ }
210
+
211
+ function outputDirFor(cwd, outputDir) {
212
+ const clean = typeof outputDir === 'string' ? outputDir.trim() : ''
213
+ if (!clean) return cwd
214
+ const resolved = path.resolve(cwd, clean)
215
+ if (!resolved.startsWith(cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`) && resolved !== cwd) {
216
+ throw new HttpError(400, 'The output folder must stay inside the deploy folder.', 'bad_output')
217
+ }
218
+ return resolved
219
+ }
220
+
221
+ async function ensureDir(target) {
222
+ const info = await stat(target).catch(() => null)
223
+ if (!info?.isDirectory()) throw new HttpError(400, 'The folder to deploy does not exist.', 'bad_deploy_path')
224
+ }
225
+
226
+ /**
227
+ * The deploy panel can target the open workspace or another project folder.
228
+ * Absolute paths are accepted only when they resolve to a real directory, so
229
+ * a typo can never create or deploy an arbitrary path.
230
+ */
231
+ async function resolveDeployPath(raw) {
232
+ const input = typeof raw === 'string' ? raw.trim() : ''
233
+ if (!input) return (await resolveWorkspacePath('', { allowRoot: true })).absolute
234
+ const expanded = input === '~' ? os.homedir() : input.startsWith('~/') ? path.join(os.homedir(), input.slice(2)) : input
235
+ if (path.isAbsolute(expanded)) {
236
+ const info = await stat(expanded).catch(() => null)
237
+ if (!info?.isDirectory()) {
238
+ throw new HttpError(400, 'That project folder does not exist.', 'bad_deploy_path')
239
+ }
240
+ return realpath(expanded)
241
+ }
242
+ return (await resolveWorkspacePath(expanded, { allowRoot: true })).absolute
243
+ }
244
+
245
+ async function netlifyDeploy({ cwd, targetDir, projectName, siteId, onLog, signal }) {
246
+ const secrets = (await deploySecrets()).netlify ?? {}
247
+ const token = secrets.authToken
248
+ if (!token) {
249
+ throw new HttpError(400, 'Add a Netlify personal access token in Settings → Deploy.', 'missing_deploy_token')
250
+ }
251
+ let site = typeof siteId === 'string' ? siteId.trim() : secrets.siteId ?? ''
252
+ let siteUrl = ''
253
+ if (!site) {
254
+ const name = slugify(projectName || path.basename(cwd), `maxcode-${suffix()}`)
255
+ onLog({ stream: 'meta', text: `Creating Netlify site "${name}"…` })
256
+ let created
257
+ try {
258
+ created = await fetchJson('https://api.netlify.com/api/v1/sites', {
259
+ method: 'POST',
260
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
261
+ body: JSON.stringify({ name }),
262
+ })
263
+ } catch (error) {
264
+ if (error instanceof HttpError && (error.status === 422 || error.status === 409)) {
265
+ const retryName = `${name}-${suffix()}`
266
+ onLog({ stream: 'meta', text: `Site name was taken, retrying as "${retryName}".` })
267
+ created = await fetchJson('https://api.netlify.com/api/v1/sites', {
268
+ method: 'POST',
269
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
270
+ body: JSON.stringify({ name: retryName }),
271
+ })
272
+ } else {
273
+ throw error
274
+ }
275
+ }
276
+ site = String(created.id ?? '')
277
+ siteUrl = String(created.ssl_url ?? created.url ?? '')
278
+ if (!site) throw new HttpError(502, 'Netlify did not return a site id.', 'deploy_api_failed')
279
+ onLog({ stream: 'meta', text: `Created site ${siteUrl || site}` })
280
+ } else {
281
+ const info = await fetchJson(`https://api.netlify.com/api/v1/sites/${encodeURIComponent(site)}`, {
282
+ headers: { Authorization: `Bearer ${token}` },
283
+ }).catch(() => null)
284
+ if (info) siteUrl = String(info.ssl_url ?? info.url ?? '')
285
+ }
286
+
287
+ await setDeploySecret('netlify', { siteId: site })
288
+
289
+ const cli = await resolveCli('netlify')
290
+ onLog({ stream: 'meta', text: `Deploying ${targetDir} with ${cli.label}…` })
291
+ const result = await runStreaming(
292
+ cli.command,
293
+ [
294
+ ...cli.prefix,
295
+ 'deploy',
296
+ '--prod',
297
+ '--dir',
298
+ targetDir,
299
+ '--site',
300
+ site,
301
+ '--message',
302
+ `OpenSol deploy ${new Date().toISOString()}`,
303
+ ],
304
+ { cwd, onLog, signal, env: { NETLIFY_AUTH_TOKEN: token } },
305
+ )
306
+ if (result.code !== 0) {
307
+ throw new HttpError(500, `Netlify deploy failed (exit ${result.code}).`, 'deploy_failed')
308
+ }
309
+ const url = firstUrl(result.stdout) || siteUrl
310
+ if (url) await setDeploySecret('netlify', { lastUrl: url })
311
+ return { provider: 'netlify', url, siteId: site }
312
+ }
313
+
314
+ async function vercelDeploy({ cwd, targetDir, projectName, onLog, signal }) {
315
+ const secrets = (await deploySecrets()).vercel ?? {}
316
+ const token = secrets.token
317
+ if (!token) {
318
+ throw new HttpError(400, 'Add a Vercel access token in Settings → Deploy.', 'missing_deploy_token')
319
+ }
320
+ const cli = await resolveCli('vercel')
321
+ const teamId = (typeof secrets.teamId === 'string' && secrets.teamId) || ''
322
+ const args = [...cli.prefix, 'deploy', targetDir, '--prod', '--yes']
323
+ if (teamId) args.push('--scope', teamId)
324
+ const name = slugify(projectName || path.basename(cwd), '')
325
+ if (name) args.push('--name', name)
326
+ onLog({ stream: 'meta', text: `Deploying ${targetDir} with ${cli.label}${name ? ` as "${name}"` : ''}…` })
327
+ const result = await runStreaming(cli.command, args, {
328
+ cwd,
329
+ onLog,
330
+ signal,
331
+ env: { VERCEL_TOKEN: token, VERCEL_ORG_ID: teamId || undefined },
332
+ })
333
+ if (result.code !== 0) {
334
+ throw new HttpError(500, `Vercel deploy failed (exit ${result.code}).`, 'deploy_failed')
335
+ }
336
+ const url = firstUrl(`${result.stdout}\n${result.stderr}`)
337
+ if (!url) throw new HttpError(502, 'Vercel did not report a deployment URL.', 'deploy_url_missing')
338
+ await setDeploySecret('vercel', { projectName: name || projectName || '', lastUrl: url })
339
+ return { provider: 'vercel', url, project: name || projectName || '' }
340
+ }
341
+
342
+ async function cloudflareDeploy({ cwd, targetDir, projectName, onLog, signal }) {
343
+ const secrets = (await deploySecrets()).cloudflare ?? {}
344
+ const token = secrets.apiToken
345
+ const accountId = secrets.accountId
346
+ if (!token || !accountId) {
347
+ throw new HttpError(
348
+ 400,
349
+ 'Add a Cloudflare API token and account id in Settings → Deploy.',
350
+ 'missing_deploy_token',
351
+ )
352
+ }
353
+ const name = slugify(projectName || path.basename(cwd), `maxcode-${suffix()}`)
354
+ let exists = false
355
+ try {
356
+ await fetchJson(
357
+ `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/pages/projects/${encodeURIComponent(name)}`,
358
+ { headers: { Authorization: `Bearer ${token}` } },
359
+ )
360
+ exists = true
361
+ } catch (error) {
362
+ if (!(error instanceof HttpError) || error.status !== 404) {
363
+ const message = error instanceof Error ? error.message : ''
364
+ if (!/8000007|not found/i.test(message)) throw error
365
+ }
366
+ }
367
+ if (!exists) {
368
+ onLog({ stream: 'meta', text: `Creating Cloudflare Pages project "${name}"…` })
369
+ try {
370
+ await fetchJson(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/pages/projects`, {
371
+ method: 'POST',
372
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
373
+ body: JSON.stringify({ name, production_branch: 'main' }),
374
+ })
375
+ } catch (error) {
376
+ const message = error instanceof Error ? error.message : ''
377
+ if (!/already exists|8000011/i.test(message)) throw error
378
+ }
379
+ }
380
+ await setDeploySecret('cloudflare', { projectName: name })
381
+
382
+ const cli = await resolveCli('cloudflare')
383
+ onLog({ stream: 'meta', text: `Deploying ${targetDir} to Cloudflare Pages…` })
384
+ const result = await runStreaming(
385
+ cli.command,
386
+ [
387
+ ...cli.prefix,
388
+ 'pages',
389
+ 'deploy',
390
+ targetDir,
391
+ `--project-name=${name}`,
392
+ '--branch=main',
393
+ '--commit-dirty=true',
394
+ ],
395
+ {
396
+ cwd,
397
+ onLog,
398
+ signal,
399
+ env: {
400
+ CLOUDFLARE_API_TOKEN: token,
401
+ CLOUDFLARE_ACCOUNT_ID: accountId,
402
+ WRANGLER_SEND_METRICS: 'false',
403
+ },
404
+ },
405
+ )
406
+ if (result.code !== 0) {
407
+ throw new HttpError(500, `Cloudflare Pages deploy failed (exit ${result.code}).`, 'deploy_failed')
408
+ }
409
+ const url = firstUrl(`${result.stdout}\n${result.stderr}`) || `https://${name}.pages.dev`
410
+ await setDeploySecret('cloudflare', { lastUrl: url })
411
+ return { provider: 'cloudflare', url, project: name }
412
+ }
413
+
414
+ export async function runDeploy(input, { onLog = () => undefined, signal } = {}) {
415
+ const provider = String(input?.provider ?? '')
416
+ if (!['cloudflare', 'netlify', 'vercel'].includes(provider)) {
417
+ throw new HttpError(400, 'Choose Cloudflare Pages, Netlify or Vercel.', 'bad_deploy_provider')
418
+ }
419
+ const absolute = await resolveDeployPath(input?.path)
420
+ await ensureDir(absolute)
421
+ const targetDir = outputDirFor(absolute, input?.outputDir)
422
+ await buildIfNeeded(absolute, input?.buildCommand, onLog, signal)
423
+ await ensureDir(targetDir)
424
+
425
+ const common = {
426
+ cwd: absolute,
427
+ targetDir,
428
+ projectName: String(input?.projectName ?? ''),
429
+ siteId: String(input?.siteId ?? ''),
430
+ onLog,
431
+ signal,
432
+ }
433
+ if (provider === 'netlify') return netlifyDeploy(common)
434
+ if (provider === 'vercel') return vercelDeploy(common)
435
+ return cloudflareDeploy(common)
436
+ }