@zerct/zerct 0.1.12 → 0.1.14
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 +10 -2
- package/bin/zerct.js +446 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Deploy Rust backends and static frontends to Zerct.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
+
npx @zerct/zerct init my-app --template fullstack-rust-tanstack
|
|
7
|
+
cd my-app/web && bun install && cd ..
|
|
6
8
|
npx @zerct/zerct deploy
|
|
7
9
|
```
|
|
8
10
|
|
|
@@ -14,12 +16,18 @@ npx zerct doctor
|
|
|
14
16
|
npx zerct deploy
|
|
15
17
|
```
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
on `0.0.0.0:$PORT` and expose the configured health endpoint.
|
|
19
|
+
Rust backends expect `Cargo.toml`, `Cargo.lock`, and `zerct.toml`. They must
|
|
20
|
+
listen on `0.0.0.0:$PORT` and expose the configured health endpoint.
|
|
19
21
|
|
|
20
22
|
From a full-stack repo root, the same deploy command discovers nested
|
|
21
23
|
`zerct.toml` files and deploys the whole workspace in one command.
|
|
22
24
|
|
|
25
|
+
Preview before deploying:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npx @zerct/zerct preview
|
|
29
|
+
```
|
|
30
|
+
|
|
23
31
|
Managed Postgres apps receive `DATABASE_URL`, `ZERCT_DATABASE_URL`, and
|
|
24
32
|
`ZERCT_DATABASE_CONNECTION_LIMIT`. Use that limit as the max size for your
|
|
25
33
|
database pool.
|
package/bin/zerct.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from 'node:child_process'
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { createServer } from 'node:http'
|
|
4
5
|
import { homedir } from 'node:os'
|
|
5
6
|
import path from 'node:path'
|
|
6
7
|
|
|
7
|
-
const VERSION = '0.1.
|
|
8
|
+
const VERSION = '0.1.14'
|
|
8
9
|
const DEFAULT_API_URL = 'https://api.zerct.com'
|
|
9
10
|
const ARCHIVE_LIMIT_BYTES = 48 * 1024 * 1024
|
|
10
11
|
const DEFAULT_DEPLOY_WAIT_TIMEOUT_SECONDS = 900
|
|
@@ -18,6 +19,18 @@ const DEFAULT_LOGIN_INTERVAL_SECONDS = 5
|
|
|
18
19
|
const DEFAULT_RUST_CHECK_COMMAND = 'cargo check --locked && cargo clippy --locked --all-targets --all-features -- -D warnings'
|
|
19
20
|
const DEFAULT_NPM_FRONTEND_CHECK_COMMAND = 'npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint'
|
|
20
21
|
const DEFAULT_BUN_FRONTEND_CHECK_COMMAND = 'bun ci && bun run typecheck && bun run lint'
|
|
22
|
+
const PROJECT_KINDS = new Set(['rust_backend', 'static_frontend'])
|
|
23
|
+
const PROJECT_TEMPLATES = new Set(['rust-api', 'tanstack-static-frontend', 'fullstack-rust-tanstack'])
|
|
24
|
+
const FRONTEND_TEMPLATE_FILES = new Set([
|
|
25
|
+
'index.html',
|
|
26
|
+
'package.json',
|
|
27
|
+
'src/main.tsx',
|
|
28
|
+
'src/styles.css',
|
|
29
|
+
'src/vite-env.d.ts',
|
|
30
|
+
'tsconfig.json',
|
|
31
|
+
'vite.config.ts',
|
|
32
|
+
'zerct.toml'
|
|
33
|
+
])
|
|
21
34
|
const ARCHIVE_EXCLUDES = [
|
|
22
35
|
'.git',
|
|
23
36
|
'target',
|
|
@@ -61,9 +74,10 @@ const WORKSPACE_EXCLUDED_DIRS = new Set([
|
|
|
61
74
|
const HELP = `Zerct ${VERSION}
|
|
62
75
|
|
|
63
76
|
Usage:
|
|
64
|
-
zerct init [path]
|
|
77
|
+
zerct init [path] [--template rust-api|tanstack-static-frontend|fullstack-rust-tanstack]
|
|
65
78
|
zerct install [path]
|
|
66
79
|
zerct doctor [path] [--json]
|
|
80
|
+
zerct preview [path] [--port <port>]
|
|
67
81
|
zerct login [--token <token>] [--api <url>]
|
|
68
82
|
zerct deploy [path] [--database] [--wait] [--wait-timeout <seconds>] [--api <url>] [--json]
|
|
69
83
|
zerct capabilities [--api <url>] [--json]
|
|
@@ -71,26 +85,28 @@ Usage:
|
|
|
71
85
|
zerct usage [--api <url>] [--json]
|
|
72
86
|
zerct activity [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
|
|
73
87
|
zerct apps [--api <url>] [--json]
|
|
74
|
-
zerct overview --app <
|
|
75
|
-
zerct deploys [--app <
|
|
76
|
-
zerct builds [--app <
|
|
77
|
-
zerct logs --app <
|
|
78
|
-
zerct status --app <
|
|
79
|
-
zerct inspect --app <
|
|
80
|
-
zerct db --app <
|
|
81
|
-
zerct env list --app <
|
|
82
|
-
zerct env set --app <
|
|
83
|
-
zerct env delete --app <
|
|
84
|
-
zerct domains list --app <
|
|
85
|
-
zerct domains add --app <
|
|
86
|
-
zerct domains verify --app <
|
|
87
|
-
zerct domains delete --app <
|
|
88
|
+
zerct overview --app <app> [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
|
|
89
|
+
zerct deploys [--app <app>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
|
|
90
|
+
zerct builds [--app <app>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
|
|
91
|
+
zerct logs --app <app> [--deploy <deploy_id>] [--build <build_id>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
|
|
92
|
+
zerct status --app <app> [--api <url>] [--json]
|
|
93
|
+
zerct inspect --app <app> [--api <url>] [--json]
|
|
94
|
+
zerct db --app <app> [--api <url>] [--json]
|
|
95
|
+
zerct env list --app <app> [--api <url>] [--json]
|
|
96
|
+
zerct env set --app <app> KEY=value [--api <url>] [--json]
|
|
97
|
+
zerct env delete --app <app> KEY [--api <url>] [--json]
|
|
98
|
+
zerct domains list --app <app> [--api <url>] [--json]
|
|
99
|
+
zerct domains add --app <app> <domain> [--api <url>] [--json]
|
|
100
|
+
zerct domains verify --app <app> <domain> [--api <url>] [--json]
|
|
101
|
+
zerct domains delete --app <app> <domain> [--api <url>] [--json]
|
|
88
102
|
zerct billing [portal] [--api <url>] [--json]
|
|
89
103
|
|
|
90
104
|
Agent contract:
|
|
91
105
|
- Rust backends keep Cargo.lock committed, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
|
|
92
106
|
- Static frontends set kind = "static_frontend", keep TypeScript source, a package lockfile, and typecheck + lint scripts.
|
|
107
|
+
- Frontends call Rust backends for APIs, managed Postgres, and server-side logic.
|
|
93
108
|
- Run deploy from a repo root with nested zerct.toml files to deploy the whole workspace in one command.
|
|
109
|
+
- When a frontend calls a backend on another hostname, configure backend CORS or use a same-origin custom domain.
|
|
94
110
|
- Keep direct unsafe out of Rust source.
|
|
95
111
|
`
|
|
96
112
|
|
|
@@ -109,14 +125,17 @@ async function main() {
|
|
|
109
125
|
|
|
110
126
|
switch (cli.command) {
|
|
111
127
|
case 'init':
|
|
112
|
-
initProject(projectPath(cli.args[0]))
|
|
128
|
+
initProject(projectPath(cli.args[0]), cli.template)
|
|
113
129
|
break
|
|
114
130
|
case 'install':
|
|
115
|
-
installProject(projectPath(cli.args[0]))
|
|
131
|
+
installProject(projectPath(cli.args[0]), cli.template)
|
|
116
132
|
break
|
|
117
133
|
case 'doctor':
|
|
118
134
|
doctorProject(projectPath(cli.args[0]), cli.json)
|
|
119
135
|
break
|
|
136
|
+
case 'preview':
|
|
137
|
+
previewProject(projectPath(cli.args[0]), cli.port)
|
|
138
|
+
break
|
|
120
139
|
case 'login':
|
|
121
140
|
await login(cli)
|
|
122
141
|
break
|
|
@@ -185,6 +204,8 @@ function parseArgs(argv) {
|
|
|
185
204
|
limit: '',
|
|
186
205
|
cursor: '',
|
|
187
206
|
token: '',
|
|
207
|
+
template: '',
|
|
208
|
+
port: 0,
|
|
188
209
|
waitTimeoutSeconds: DEFAULT_DEPLOY_WAIT_TIMEOUT_SECONDS,
|
|
189
210
|
json: false,
|
|
190
211
|
database: false,
|
|
@@ -232,6 +253,12 @@ function parseArgs(argv) {
|
|
|
232
253
|
} else if (arg === '--token') {
|
|
233
254
|
cli.token = requireValue(argv, index, '--token')
|
|
234
255
|
index += 1
|
|
256
|
+
} else if (arg === '--template') {
|
|
257
|
+
cli.template = requireValue(argv, index, '--template')
|
|
258
|
+
index += 1
|
|
259
|
+
} else if (arg === '--port') {
|
|
260
|
+
cli.port = parsePositiveInteger(requireValue(argv, index, '--port'), '--port')
|
|
261
|
+
index += 1
|
|
235
262
|
} else {
|
|
236
263
|
positional.push(arg)
|
|
237
264
|
}
|
|
@@ -266,18 +293,128 @@ function projectPath(value) {
|
|
|
266
293
|
return path.resolve(value || process.cwd())
|
|
267
294
|
}
|
|
268
295
|
|
|
269
|
-
function initProject(projectDir) {
|
|
296
|
+
function initProject(projectDir, template = '') {
|
|
297
|
+
if (template) {
|
|
298
|
+
mkdirSync(projectDir, { recursive: true, mode: 0o755 })
|
|
299
|
+
createTemplate(projectDir, template)
|
|
300
|
+
return
|
|
301
|
+
}
|
|
270
302
|
ensureDirectory(projectDir)
|
|
303
|
+
|
|
271
304
|
const configPath = path.join(projectDir, 'zerct.toml')
|
|
272
305
|
if (existsSync(configPath)) {
|
|
273
306
|
console.log('zerct.toml already exists')
|
|
274
307
|
return
|
|
275
308
|
}
|
|
276
309
|
|
|
277
|
-
const
|
|
278
|
-
const source =
|
|
310
|
+
const kind = inferProjectKind(projectDir)
|
|
311
|
+
const source = kind === 'static_frontend'
|
|
312
|
+
? frontendConfig(projectDir)
|
|
313
|
+
: rustBackendConfig(projectDir)
|
|
314
|
+
|
|
315
|
+
writeFileSync(configPath, source, { mode: 0o644 })
|
|
316
|
+
console.log(`created ${path.relative(process.cwd(), configPath)}`)
|
|
317
|
+
console.log(`detected ${kind}`)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function createTemplate(projectDir, template) {
|
|
321
|
+
if (!PROJECT_TEMPLATES.has(template)) {
|
|
322
|
+
throw agentError('invalid_template', 'Zerct template is unknown.', `Use one of: ${[...PROJECT_TEMPLATES].join(', ')}.`, false)
|
|
323
|
+
}
|
|
324
|
+
if (template === 'rust-api') {
|
|
325
|
+
writeRustApiTemplate(projectDir, serviceNameFromDir(projectDir))
|
|
326
|
+
} else if (template === 'tanstack-static-frontend') {
|
|
327
|
+
writeFrontendTemplate(projectDir, serviceNameFromDir(projectDir), '/api')
|
|
328
|
+
} else {
|
|
329
|
+
const apiDir = path.join(projectDir, 'api')
|
|
330
|
+
const webDir = path.join(projectDir, 'web')
|
|
331
|
+
writeRustApiTemplate(apiDir, 'api')
|
|
332
|
+
writeFrontendTemplate(webDir, 'web', 'http://localhost:3000')
|
|
333
|
+
}
|
|
334
|
+
console.log(`created ${template} template`)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function writeRustApiTemplate(projectDir, name) {
|
|
338
|
+
mkdirSync(path.join(projectDir, 'src'), { recursive: true, mode: 0o755 })
|
|
339
|
+
writeNewFile(path.join(projectDir, 'Cargo.toml'), `[package]
|
|
340
|
+
name = "${name}"
|
|
341
|
+
version = "0.1.0"
|
|
342
|
+
edition = "2024"
|
|
343
|
+
publish = false
|
|
344
|
+
|
|
345
|
+
[lints.rust]
|
|
346
|
+
unsafe_code = "forbid"
|
|
347
|
+
warnings = "deny"
|
|
348
|
+
`)
|
|
349
|
+
writeNewFile(path.join(projectDir, 'Cargo.lock'), `# This file is automatically @generated by Cargo.
|
|
350
|
+
version = 4
|
|
351
|
+
|
|
352
|
+
[[package]]
|
|
353
|
+
name = "${name}"
|
|
354
|
+
version = "0.1.0"
|
|
355
|
+
`)
|
|
356
|
+
writeNewFile(path.join(projectDir, 'src', 'main.rs'), rustApiSource())
|
|
357
|
+
writeNewFile(path.join(projectDir, 'zerct.toml'), rustBackendConfig(projectDir))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function writeFrontendTemplate(projectDir, name, apiBaseUrl) {
|
|
361
|
+
mkdirSync(path.join(projectDir, 'src'), { recursive: true, mode: 0o755 })
|
|
362
|
+
writeNewFile(path.join(projectDir, 'package.json'), `{
|
|
363
|
+
"name": "${name}",
|
|
364
|
+
"private": true,
|
|
365
|
+
"type": "module",
|
|
366
|
+
"scripts": {
|
|
367
|
+
"typecheck": "tsgo --noEmit",
|
|
368
|
+
"lint": "oxlint src vite.config.ts --deny-warnings",
|
|
369
|
+
"build": "vite build",
|
|
370
|
+
"preview": "vite preview --host 0.0.0.0"
|
|
371
|
+
},
|
|
372
|
+
"dependencies": {
|
|
373
|
+
"react": "^19.2.1",
|
|
374
|
+
"react-dom": "^19.2.1",
|
|
375
|
+
"@tanstack/react-router": "^1.140.0"
|
|
376
|
+
},
|
|
377
|
+
"devDependencies": {
|
|
378
|
+
"@types/react": "^19.2.7",
|
|
379
|
+
"@types/react-dom": "^19.2.3",
|
|
380
|
+
"@typescript/native-preview": "^7.0.0-dev.20251126.1",
|
|
381
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
382
|
+
"oxlint": "^1.30.0",
|
|
383
|
+
"typescript": "^5.9.3",
|
|
384
|
+
"vite": "^7.2.4"
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
`)
|
|
388
|
+
writeFrontendTemplateFile(projectDir, 'index.html', '<div id="root"></div><script type="module" src="/src/main.tsx"></script>\n')
|
|
389
|
+
writeFrontendTemplateFile(projectDir, 'src/styles.css', 'body{margin:0;font-family:system-ui,sans-serif}main{min-height:100svh;display:grid;place-items:center;padding:2rem}code{font-family:ui-monospace,monospace}\n')
|
|
390
|
+
writeFrontendTemplateFile(projectDir, 'src/vite-env.d.ts', '/// <reference types="vite/client" />\n')
|
|
391
|
+
writeFrontendTemplateFile(projectDir, 'src/main.tsx', frontendSource(apiBaseUrl))
|
|
392
|
+
writeFrontendTemplateFile(projectDir, 'tsconfig.json', '{"compilerOptions":{"strict":true,"jsx":"react-jsx","module":"ESNext","moduleResolution":"Bundler","target":"ES2022","noEmit":true,"skipLibCheck":true},"include":["src","vite.config.ts"]}\n')
|
|
393
|
+
writeFrontendTemplateFile(projectDir, 'vite.config.ts', 'import react from "@vitejs/plugin-react";\nimport { defineConfig } from "vite";\n\nexport default defineConfig({ plugins: [react()] });\n')
|
|
394
|
+
writeFrontendTemplateFile(projectDir, 'zerct.toml', frontendConfig(projectDir))
|
|
395
|
+
console.log('run package install in the frontend directory before doctor: bun install or npm install')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function writeFrontendTemplateFile(projectDir, relative, source) {
|
|
399
|
+
if (!FRONTEND_TEMPLATE_FILES.has(relative)) {
|
|
400
|
+
throw new Error(`unexpected template file: ${relative}`)
|
|
401
|
+
}
|
|
402
|
+
writeNewFile(path.join(projectDir, relative), source)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function writeNewFile(file, source) {
|
|
406
|
+
if (existsSync(file)) {
|
|
407
|
+
throw agentError('file_exists', `Refusing to overwrite ${path.relative(process.cwd(), file)}.`, 'Move the existing file or choose an empty directory, then retry.', false)
|
|
408
|
+
}
|
|
409
|
+
writeFileSync(file, source, { mode: 0o644 })
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function rustBackendConfig(projectDir) {
|
|
413
|
+
const name = serviceNameFromCargo(projectDir) || serviceNameFromDir(projectDir)
|
|
414
|
+
return `name = "${name}"
|
|
279
415
|
|
|
280
416
|
[build]
|
|
417
|
+
check = "${DEFAULT_RUST_CHECK_COMMAND}"
|
|
281
418
|
command = "cargo build --release"
|
|
282
419
|
|
|
283
420
|
[run]
|
|
@@ -290,18 +427,27 @@ memory = "512mb"
|
|
|
290
427
|
cpu = "0.25"
|
|
291
428
|
idle_timeout_minutes = 15
|
|
292
429
|
`
|
|
430
|
+
}
|
|
293
431
|
|
|
294
|
-
|
|
295
|
-
|
|
432
|
+
function frontendConfig(projectDir) {
|
|
433
|
+
const name = serviceNameFromPackage(projectDir) || serviceNameFromDir(projectDir)
|
|
434
|
+
return `name = "${name}"
|
|
435
|
+
kind = "static_frontend"
|
|
436
|
+
|
|
437
|
+
[build]
|
|
438
|
+
check = "${frontendCheckCommand(projectDir)}"
|
|
439
|
+
command = "${frontendBuildCommand(projectDir)}"
|
|
440
|
+
output = "dist"
|
|
441
|
+
`
|
|
296
442
|
}
|
|
297
443
|
|
|
298
|
-
function installProject(projectDir) {
|
|
299
|
-
initProject(projectDir)
|
|
444
|
+
function installProject(projectDir, template = '') {
|
|
445
|
+
initProject(projectDir, template)
|
|
300
446
|
doctorProject(projectDir, false)
|
|
301
447
|
}
|
|
302
448
|
|
|
303
449
|
function doctorProject(projectDir, json) {
|
|
304
|
-
const report =
|
|
450
|
+
const report = runDoctorWorkspace(projectDir)
|
|
305
451
|
if (json) {
|
|
306
452
|
console.log(JSON.stringify(report, null, 2))
|
|
307
453
|
if (!report.ok) {
|
|
@@ -310,16 +456,149 @@ function doctorProject(projectDir, json) {
|
|
|
310
456
|
return
|
|
311
457
|
}
|
|
312
458
|
|
|
313
|
-
|
|
314
|
-
|
|
459
|
+
if (Array.isArray(report.projects)) {
|
|
460
|
+
for (const project of report.projects) {
|
|
461
|
+
console.log(`project ${project.relative}`)
|
|
462
|
+
for (const check of project.checks) {
|
|
463
|
+
console.log(`${check.ok ? 'ok' : 'fail'} ${check.name}${check.message ? ` - ${check.message}` : ''}`)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
for (const check of report.checks) {
|
|
468
|
+
console.log(`${check.ok ? 'ok' : 'fail'} ${check.name}${check.message ? ` - ${check.message}` : ''}`)
|
|
469
|
+
}
|
|
315
470
|
}
|
|
316
471
|
|
|
317
472
|
if (!report.ok) {
|
|
318
|
-
const
|
|
473
|
+
const checks = Array.isArray(report.projects)
|
|
474
|
+
? report.projects.flatMap((project) => project.checks)
|
|
475
|
+
: report.checks
|
|
476
|
+
const firstFailure = checks.find((check) => !check.ok)
|
|
319
477
|
throw agentError('doctor_failed', 'Zerct doctor failed.', firstFailure?.agent_instruction || 'Fix the failed checks and retry `npx @zerct/zerct doctor`.', json)
|
|
320
478
|
}
|
|
321
479
|
}
|
|
322
480
|
|
|
481
|
+
function previewProject(projectDir, port) {
|
|
482
|
+
const report = runDoctorWorkspace(projectDir)
|
|
483
|
+
if (Array.isArray(report.projects)) {
|
|
484
|
+
throw agentError('workspace_preview_unsupported', 'Preview one project at a time.', 'Run `npx @zerct/zerct preview api` or `npx @zerct/zerct preview web` from the workspace root.', false)
|
|
485
|
+
}
|
|
486
|
+
if (!report.ok) {
|
|
487
|
+
const firstFailure = report.checks.find((check) => !check.ok)
|
|
488
|
+
throw agentError('doctor_failed', 'Zerct doctor failed.', firstFailure?.agent_instruction || 'Fix the failed checks and retry `npx @zerct/zerct preview`.', false)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const config = parseZerctToml(readFileSync(path.join(projectDir, 'zerct.toml'), 'utf8'), projectDir)
|
|
492
|
+
validateConfig(config)
|
|
493
|
+
runShell(config.build.command, projectDir, 'Build failed before preview.')
|
|
494
|
+
if (config.kind === 'static_frontend') {
|
|
495
|
+
serveStatic(path.join(projectDir, config.build.output), port || 4173)
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const runtimePort = port || config.run.port
|
|
500
|
+
console.log(`preview http://127.0.0.1:${runtimePort}`)
|
|
501
|
+
const result = spawnSync(config.run.command, {
|
|
502
|
+
cwd: projectDir,
|
|
503
|
+
env: { ...process.env, PORT: String(runtimePort) },
|
|
504
|
+
shell: true,
|
|
505
|
+
stdio: 'inherit'
|
|
506
|
+
})
|
|
507
|
+
if (result.error) {
|
|
508
|
+
throw agentError('preview_failed', 'Preview command failed.', result.error.message, false)
|
|
509
|
+
}
|
|
510
|
+
if (result.status !== 0) {
|
|
511
|
+
throw agentError('preview_failed', 'Preview command exited with an error.', 'Fix the local runtime command and retry `npx @zerct/zerct preview`.', false)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function runShell(command, projectDir, failureMessage) {
|
|
516
|
+
console.log(command)
|
|
517
|
+
const result = spawnSync(command, {
|
|
518
|
+
cwd: projectDir,
|
|
519
|
+
env: process.env,
|
|
520
|
+
shell: true,
|
|
521
|
+
stdio: 'inherit'
|
|
522
|
+
})
|
|
523
|
+
if (result.error) {
|
|
524
|
+
throw agentError('command_failed', failureMessage, result.error.message, false)
|
|
525
|
+
}
|
|
526
|
+
if (result.status !== 0) {
|
|
527
|
+
throw agentError('command_failed', failureMessage, 'Fix the command output above, then retry.', false)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function serveStatic(root, port) {
|
|
532
|
+
ensureDirectory(root)
|
|
533
|
+
const server = createServer((request, response) => {
|
|
534
|
+
const pathname = decodeURIComponent(new URL(request.url || '/', `http://127.0.0.1:${port}`).pathname)
|
|
535
|
+
const target = staticTarget(root, pathname)
|
|
536
|
+
if (!target) {
|
|
537
|
+
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
538
|
+
response.end('not found')
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
response.writeHead(200, { 'content-type': contentType(target) })
|
|
542
|
+
response.end(readFileSync(target))
|
|
543
|
+
})
|
|
544
|
+
server.listen(port, '127.0.0.1', () => {
|
|
545
|
+
console.log(`preview http://127.0.0.1:${port}`)
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function staticTarget(root, pathname) {
|
|
550
|
+
const safePath = pathname.replace(/^\/+/u, '')
|
|
551
|
+
const candidate = path.resolve(root, safePath || 'index.html')
|
|
552
|
+
if (!candidate.startsWith(path.resolve(root) + path.sep) && candidate !== path.resolve(root)) {
|
|
553
|
+
return ''
|
|
554
|
+
}
|
|
555
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
|
556
|
+
return candidate
|
|
557
|
+
}
|
|
558
|
+
const index = path.join(root, 'index.html')
|
|
559
|
+
return existsSync(index) ? index : ''
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function contentType(file) {
|
|
563
|
+
if (file.endsWith('.html')) {
|
|
564
|
+
return 'text/html; charset=utf-8'
|
|
565
|
+
}
|
|
566
|
+
if (file.endsWith('.css')) {
|
|
567
|
+
return 'text/css; charset=utf-8'
|
|
568
|
+
}
|
|
569
|
+
if (file.endsWith('.js') || file.endsWith('.mjs')) {
|
|
570
|
+
return 'text/javascript; charset=utf-8'
|
|
571
|
+
}
|
|
572
|
+
if (file.endsWith('.json')) {
|
|
573
|
+
return 'application/json; charset=utf-8'
|
|
574
|
+
}
|
|
575
|
+
if (file.endsWith('.svg')) {
|
|
576
|
+
return 'image/svg+xml'
|
|
577
|
+
}
|
|
578
|
+
return 'application/octet-stream'
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function runDoctorWorkspace(projectDir) {
|
|
582
|
+
if (existsSync(path.join(projectDir, 'zerct.toml'))) {
|
|
583
|
+
return runDoctor(projectDir)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const projects = discoverDeployProjects(projectDir)
|
|
587
|
+
if (projects.length === 0) {
|
|
588
|
+
return runDoctor(projectDir)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const reports = projects.map((project) => ({
|
|
592
|
+
relative: project.relative,
|
|
593
|
+
...runDoctor(project.dir)
|
|
594
|
+
}))
|
|
595
|
+
return {
|
|
596
|
+
ok: reports.every((report) => report.ok),
|
|
597
|
+
workspace: projectDir,
|
|
598
|
+
projects: reports
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
323
602
|
function runDoctor(projectDir) {
|
|
324
603
|
const checks = []
|
|
325
604
|
let config = null
|
|
@@ -740,7 +1019,7 @@ async function envCommand(cli) {
|
|
|
740
1019
|
if (cli.args[0] === 'delete') {
|
|
741
1020
|
const name = cli.args[1] || ''
|
|
742
1021
|
if (!name) {
|
|
743
|
-
throw agentError('invalid_env', 'Environment variable name is required.', 'Use `npx @zerct/zerct env delete --app <
|
|
1022
|
+
throw agentError('invalid_env', 'Environment variable name is required.', 'Use `npx @zerct/zerct env delete --app <app> KEY`.', cli.json)
|
|
744
1023
|
}
|
|
745
1024
|
const token = await readOrLoginToken(process.cwd(), cli)
|
|
746
1025
|
const app = requireApp(cli)
|
|
@@ -777,7 +1056,7 @@ async function domainsCommand(cli) {
|
|
|
777
1056
|
|
|
778
1057
|
const domain = cli.args[1] || ''
|
|
779
1058
|
if (!domain) {
|
|
780
|
-
throw agentError('missing_domain', 'Domain is required.', 'Use `npx @zerct/zerct domains add --app <
|
|
1059
|
+
throw agentError('missing_domain', 'Domain is required.', 'Use `npx @zerct/zerct domains add --app <app> api.example.com`.', cli.json)
|
|
781
1060
|
}
|
|
782
1061
|
|
|
783
1062
|
const token = await readOrLoginToken(process.cwd(), cli)
|
|
@@ -891,7 +1170,7 @@ async function pollLogin(cli, start) {
|
|
|
891
1170
|
|
|
892
1171
|
function requireApp(cli) {
|
|
893
1172
|
if (!cli.app) {
|
|
894
|
-
throw agentError('missing_app', 'App
|
|
1173
|
+
throw agentError('missing_app', 'App is required.', 'Pass `--app <app>` using either the app name from zerct.toml or the app id printed by deploy.', cli.json)
|
|
895
1174
|
}
|
|
896
1175
|
return cli.app
|
|
897
1176
|
}
|
|
@@ -1173,7 +1452,7 @@ function validateConfig(config) {
|
|
|
1173
1452
|
if (!/^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/u.test(config.name || '')) {
|
|
1174
1453
|
throw new Error('name must be lowercase DNS-safe text up to 48 characters')
|
|
1175
1454
|
}
|
|
1176
|
-
if (!
|
|
1455
|
+
if (!PROJECT_KINDS.has(config.kind)) {
|
|
1177
1456
|
throw new Error('kind must be rust_backend or static_frontend')
|
|
1178
1457
|
}
|
|
1179
1458
|
if (typeof config.build.command !== 'string' || !config.build.command.trim()) {
|
|
@@ -1189,6 +1468,9 @@ function validateConfig(config) {
|
|
|
1189
1468
|
}
|
|
1190
1469
|
return
|
|
1191
1470
|
}
|
|
1471
|
+
if (config.build.output) {
|
|
1472
|
+
throw new Error('[build].output is only valid for static_frontend')
|
|
1473
|
+
}
|
|
1192
1474
|
if (!config.run.command || typeof config.run.command !== 'string') {
|
|
1193
1475
|
throw new Error('[run].command is required')
|
|
1194
1476
|
}
|
|
@@ -1463,10 +1745,140 @@ function ensureDirectory(dir) {
|
|
|
1463
1745
|
}
|
|
1464
1746
|
|
|
1465
1747
|
function serviceNameFromDir(projectDir) {
|
|
1466
|
-
const name = path.basename(projectDir)
|
|
1748
|
+
const name = serviceNameFromValue(path.basename(projectDir))
|
|
1467
1749
|
return name || 'api'
|
|
1468
1750
|
}
|
|
1469
1751
|
|
|
1752
|
+
function serviceNameFromCargo(projectDir) {
|
|
1753
|
+
try {
|
|
1754
|
+
const source = readFileSync(path.join(projectDir, 'Cargo.toml'), 'utf8')
|
|
1755
|
+
return serviceNameFromValue(source.match(/^\s*name\s*=\s*"([^"]+)"/mu)?.[1] || '')
|
|
1756
|
+
} catch (_error) {
|
|
1757
|
+
return ''
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function serviceNameFromPackage(projectDir) {
|
|
1762
|
+
const manifest = readPackageJson(projectDir)
|
|
1763
|
+
return serviceNameFromValue(typeof manifest?.name === 'string' ? manifest.name : '')
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function serviceNameFromValue(value) {
|
|
1767
|
+
return value.toLowerCase().replace(/[^a-z0-9-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 48)
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
function inferProjectKind(projectDir) {
|
|
1771
|
+
if (existsSync(path.join(projectDir, 'Cargo.toml'))) {
|
|
1772
|
+
return 'rust_backend'
|
|
1773
|
+
}
|
|
1774
|
+
if (existsSync(path.join(projectDir, 'package.json'))) {
|
|
1775
|
+
return 'static_frontend'
|
|
1776
|
+
}
|
|
1777
|
+
return 'rust_backend'
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function rustApiSource() {
|
|
1781
|
+
return `use std::{
|
|
1782
|
+
io::{Read, Write},
|
|
1783
|
+
net::{TcpListener, TcpStream},
|
|
1784
|
+
};
|
|
1785
|
+
|
|
1786
|
+
fn main() -> std::io::Result<()> {
|
|
1787
|
+
let port = std::env::var("PORT").unwrap_or_else(|_error| "3000".to_owned());
|
|
1788
|
+
let listener = TcpListener::bind(format!("0.0.0.0:{port}"))?;
|
|
1789
|
+
|
|
1790
|
+
for stream in listener.incoming() {
|
|
1791
|
+
handle(stream?)?;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
Ok(())
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
fn handle(mut stream: TcpStream) -> std::io::Result<()> {
|
|
1798
|
+
let mut buffer = [0_u8; 2048];
|
|
1799
|
+
let size = stream.read(&mut buffer)?;
|
|
1800
|
+
let request = String::from_utf8_lossy(&buffer[..size]);
|
|
1801
|
+
let mut parts = request
|
|
1802
|
+
.lines()
|
|
1803
|
+
.next()
|
|
1804
|
+
.unwrap_or_default()
|
|
1805
|
+
.split_whitespace();
|
|
1806
|
+
let method = parts.next().unwrap_or_default();
|
|
1807
|
+
let path = parts.next().unwrap_or("/");
|
|
1808
|
+
let origin = request
|
|
1809
|
+
.lines()
|
|
1810
|
+
.find_map(|line| line.strip_prefix("Origin: "))
|
|
1811
|
+
.unwrap_or("*");
|
|
1812
|
+
let cors_origin = allowed_origin(origin);
|
|
1813
|
+
|
|
1814
|
+
if method == "OPTIONS" {
|
|
1815
|
+
return write_response(&mut stream, "204 No Content", "", &cors_origin);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
let body = if path == "/healthz" {
|
|
1819
|
+
r#"{"ok":true}"#
|
|
1820
|
+
} else {
|
|
1821
|
+
r#"{"message":"hello from zerct","backend":"rust"}"#
|
|
1822
|
+
};
|
|
1823
|
+
write_response(&mut stream, "200 OK", body, &cors_origin)
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
fn allowed_origin(request_origin: &str) -> String {
|
|
1827
|
+
let configured = std::env::var("FRONTEND_ORIGIN").unwrap_or_else(|_error| request_origin.to_owned());
|
|
1828
|
+
if configured == "*" || configured == request_origin {
|
|
1829
|
+
configured
|
|
1830
|
+
} else {
|
|
1831
|
+
"null".to_owned()
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
fn write_response(
|
|
1836
|
+
stream: &mut TcpStream,
|
|
1837
|
+
status: &str,
|
|
1838
|
+
body: &str,
|
|
1839
|
+
origin: &str,
|
|
1840
|
+
) -> std::io::Result<()> {
|
|
1841
|
+
write!(
|
|
1842
|
+
stream,
|
|
1843
|
+
"HTTP/1.1 {status}\\r\\ncontent-type: application/json\\r\\ncontent-length: {}\\r\\naccess-control-allow-origin: {origin}\\r\\naccess-control-allow-methods: GET, OPTIONS\\r\\naccess-control-allow-headers: content-type, authorization\\r\\nconnection: close\\r\\n\\r\\n{body}",
|
|
1844
|
+
body.len()
|
|
1845
|
+
)
|
|
1846
|
+
}
|
|
1847
|
+
`
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function frontendSource(apiBaseUrl) {
|
|
1851
|
+
return `import { createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router'
|
|
1852
|
+
import { createRoot } from 'react-dom/client'
|
|
1853
|
+
import './styles.css'
|
|
1854
|
+
|
|
1855
|
+
const apiBaseUrl = import.meta.env.VITE_API_URL ?? '${apiBaseUrl}'
|
|
1856
|
+
|
|
1857
|
+
function App() {
|
|
1858
|
+
return (
|
|
1859
|
+
<main>
|
|
1860
|
+
<section>
|
|
1861
|
+
<h1>Zerct TanStack Frontend</h1>
|
|
1862
|
+
<p>Static runtime, dynamic Rust backend calls.</p>
|
|
1863
|
+
<code>{apiBaseUrl}</code>
|
|
1864
|
+
</section>
|
|
1865
|
+
</main>
|
|
1866
|
+
)
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
const rootRoute = createRootRoute({ component: App })
|
|
1870
|
+
const router = createRouter({ routeTree: rootRoute })
|
|
1871
|
+
|
|
1872
|
+
declare module '@tanstack/react-router' {
|
|
1873
|
+
interface Register {
|
|
1874
|
+
router: typeof router
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />)
|
|
1879
|
+
`
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1470
1882
|
function printJsonOrPretty(cli, value) {
|
|
1471
1883
|
console.log(JSON.stringify(value, null, cli.json ? 2 : 2))
|
|
1472
1884
|
}
|