@bibobonking/opensol 3.0.1 → 3.0.3
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.
- package/README.md +15 -12
- package/package.json +1 -1
- package/server/README.md +75 -0
- package/server/daemon.mjs +1082 -0
- package/server/lib/checkpoints.mjs +124 -0
- package/server/lib/checkpoints.test.mjs +110 -0
- package/server/lib/deploy.mjs +436 -0
- package/server/lib/http.mjs +237 -0
- package/server/lib/plugins.mjs +321 -0
- package/server/lib/plugins.test.mjs +140 -0
- package/server/lib/providers.mjs +594 -0
- package/server/lib/store.mjs +491 -0
- package/server/lib/tools.mjs +990 -0
- package/server/opensol.mjs +202 -0
- package/server/package.json +42 -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
|
+
}
|