@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.
Files changed (3) hide show
  1. package/README.md +10 -2
  2. package/bin/zerct.js +446 -34
  3. 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
- Zerct expects `Cargo.toml`, `Cargo.lock`, and `zerct.toml`. The app must listen
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.12'
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 <app_id> [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
75
- zerct deploys [--app <app_id>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
76
- zerct builds [--app <app_id>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
77
- zerct logs --app <app_id> [--deploy <deploy_id>] [--build <build_id>] [--limit <n>] [--cursor <cursor>] [--api <url>] [--json]
78
- zerct status --app <app_id> [--api <url>] [--json]
79
- zerct inspect --app <app_id> [--api <url>] [--json]
80
- zerct db --app <app_id> [--api <url>] [--json]
81
- zerct env list --app <app_id> [--api <url>] [--json]
82
- zerct env set --app <app_id> KEY=value [--api <url>] [--json]
83
- zerct env delete --app <app_id> KEY [--api <url>] [--json]
84
- zerct domains list --app <app_id> [--api <url>] [--json]
85
- zerct domains add --app <app_id> <domain> [--api <url>] [--json]
86
- zerct domains verify --app <app_id> <domain> [--api <url>] [--json]
87
- zerct domains delete --app <app_id> <domain> [--api <url>] [--json]
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 name = serviceNameFromDir(projectDir)
278
- const source = `name = "${name}"
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
- writeFileSync(configPath, source, { mode: 0o644 })
295
- console.log(`created ${path.relative(process.cwd(), configPath)}`)
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 = runDoctor(projectDir)
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
- for (const check of report.checks) {
314
- console.log(`${check.ok ? 'ok' : 'fail'} ${check.name}${check.message ? ` - ${check.message}` : ''}`)
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 firstFailure = report.checks.find((check) => !check.ok)
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 <app_id> KEY`.', cli.json)
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 <app_id> api.example.com`.', cli.json)
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 id is required.', 'Pass `--app <app_id>`. Use the app id printed by `npx @zerct/zerct deploy`.', cli.json)
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 (!['rust_backend', 'static_frontend'].includes(config.kind)) {
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).toLowerCase().replace(/[^a-z0-9-]+/gu, '-').replace(/^-+|-+$/gu, '')
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerct/zerct",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Deploy Rust backends and static frontends to Zerct.",
5
5
  "type": "module",
6
6
  "bin": {