@zerct/zerct 0.1.2 → 0.1.4

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 -6
  2. package/bin/zerct.js +622 -55
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # zerct
2
2
 
3
- Deploy Rust backends to Zerct.
3
+ Deploy Rust backends and static frontends to Zerct.
4
4
 
5
5
  ```sh
6
6
  npx @zerct/zerct deploy
@@ -9,13 +9,17 @@ npx @zerct/zerct deploy
9
9
  Target unscoped command, pending npm approval:
10
10
 
11
11
  ```sh
12
- npx @zerct/zerct init
13
- npx @zerct/zerct doctor
14
- npx @zerct/zerct deploy
12
+ npx zerct init
13
+ npx zerct doctor
14
+ npx zerct deploy
15
15
  ```
16
16
 
17
17
  Zerct expects `Cargo.toml`, `Cargo.lock`, and `zerct.toml`. The app must listen
18
18
  on `0.0.0.0:$PORT` and expose the configured health endpoint.
19
19
 
20
- Use `ZERCT_TOKEN` or `npx @zerct/zerct login --token <token>` for authenticated
21
- commands.
20
+ From a full-stack repo root, the same deploy command discovers nested
21
+ `zerct.toml` files and deploys the whole workspace in one command.
22
+
23
+ On first deploy, the CLI opens browser login, waits for GitHub or Google, stores
24
+ the Zerct session in the OS credential store when available, and continues the
25
+ deploy. Later commands reuse that session.
package/bin/zerct.js CHANGED
@@ -4,11 +4,19 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy
4
4
  import { homedir } from 'node:os'
5
5
  import path from 'node:path'
6
6
 
7
- const VERSION = '0.1.2'
7
+ const VERSION = '0.1.4'
8
8
  const DEFAULT_API_URL = 'https://api.zerct.com'
9
9
  const ARCHIVE_LIMIT_BYTES = 48 * 1024 * 1024
10
10
  const SESSION_DIR = '.zerct'
11
11
  const SESSION_FILE = 'session-token'
12
+ const SESSION_SERVICE = 'com.zerct.cli'
13
+ const SESSION_ACCOUNT = 'session-token'
14
+ const SESSION_LABEL = 'Zerct session'
15
+ const DEFAULT_LOGIN_EXPIRES_SECONDS = 600
16
+ const DEFAULT_LOGIN_INTERVAL_SECONDS = 5
17
+ const DEFAULT_RUST_CHECK_COMMAND = 'cargo check --locked && cargo clippy --locked --all-targets --all-features -- -D warnings'
18
+ const DEFAULT_NPM_FRONTEND_CHECK_COMMAND = 'npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint'
19
+ const DEFAULT_BUN_FRONTEND_CHECK_COMMAND = 'bun ci && bun run typecheck && bun run lint'
12
20
  const ARCHIVE_EXCLUDES = [
13
21
  '.git',
14
22
  'target',
@@ -34,8 +42,20 @@ const ARCHIVE_EXCLUDES = [
34
42
  '*.sqlite3',
35
43
  '*.db',
36
44
  '*.log',
45
+ '._*',
37
46
  '.DS_Store'
38
47
  ]
48
+ const WALK_EXCLUDED_DIRS = new Set(['.git', 'target', 'node_modules', '.zerct'])
49
+ const WORKSPACE_EXCLUDED_DIRS = new Set([
50
+ ...WALK_EXCLUDED_DIRS,
51
+ '.cache',
52
+ '.next',
53
+ '.turbo',
54
+ 'build',
55
+ 'coverage',
56
+ 'dist',
57
+ 'vendor'
58
+ ])
39
59
 
40
60
  const HELP = `Zerct ${VERSION}
41
61
 
@@ -53,23 +73,12 @@ Usage:
53
73
  zerct billing [--api <url>] [--json]
54
74
 
55
75
  Agent contract:
56
- - Keep Cargo.lock committed.
57
- - Keep direct unsafe out of workspace source.
58
- - Listen on 0.0.0.0:$PORT.
59
- - Return HTTP 200 from the configured health endpoint.
76
+ - Rust backends keep Cargo.lock committed, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
77
+ - Static frontends set kind = "static_frontend", keep TypeScript source, a package lockfile, and typecheck + lint scripts.
78
+ - Run deploy from a repo root with nested zerct.toml files to deploy the whole workspace in one command.
79
+ - Keep direct unsafe out of Rust source.
60
80
  `
61
81
 
62
- main().catch((error) => {
63
- if (error instanceof ZerctError) {
64
- printAgentError(error.payload, error.json)
65
- process.exitCode = error.exitCode
66
- return
67
- }
68
-
69
- console.error(`zerct failed: ${error.message}`)
70
- process.exitCode = 1
71
- })
72
-
73
82
  async function main() {
74
83
  const cli = parseArgs(process.argv.slice(2))
75
84
 
@@ -240,22 +249,11 @@ function doctorProject(projectDir, json) {
240
249
 
241
250
  function runDoctor(projectDir) {
242
251
  const checks = []
243
- const requiredFiles = ['Cargo.toml', 'Cargo.lock', 'zerct.toml']
244
- for (const file of requiredFiles) {
245
- const ok = existsSync(path.join(projectDir, file))
246
- checks.push({
247
- name: file,
248
- ok,
249
- message: ok ? 'found' : 'missing',
250
- agent_instruction: `Create and commit ${file}, then retry.`
251
- })
252
- }
253
-
254
252
  let config = null
255
253
  const configPath = path.join(projectDir, 'zerct.toml')
256
254
  if (existsSync(configPath)) {
257
255
  try {
258
- config = parseZerctToml(readFileSync(configPath, 'utf8'))
256
+ config = parseZerctToml(readFileSync(configPath, 'utf8'), projectDir)
259
257
  validateConfig(config)
260
258
  checks.push({ name: 'zerct.toml', ok: true, message: 'valid' })
261
259
  } catch (error) {
@@ -263,9 +261,42 @@ function runDoctor(projectDir) {
263
261
  name: 'zerct.toml',
264
262
  ok: false,
265
263
  message: error.message,
266
- agent_instruction: 'Fix zerct.toml so it matches the Zerct deploy contract.'
264
+ agent_instruction: `Fix zerct.toml: ${error.message}.`
267
265
  })
268
266
  }
267
+ } else {
268
+ checks.push({
269
+ name: 'zerct.toml',
270
+ ok: false,
271
+ message: 'missing',
272
+ agent_instruction: 'Create and commit zerct.toml, then retry.'
273
+ })
274
+ }
275
+
276
+ const kind = config?.kind || 'rust_backend'
277
+ const requiredFiles = kind === 'static_frontend'
278
+ ? ['package.json']
279
+ : ['Cargo.toml', 'Cargo.lock']
280
+ for (const file of requiredFiles) {
281
+ const ok = existsSync(path.join(projectDir, file))
282
+ checks.push({
283
+ name: file,
284
+ ok,
285
+ message: ok ? 'found' : 'missing',
286
+ agent_instruction: `Create and commit ${file}, then retry.`
287
+ })
288
+ }
289
+
290
+ if (kind === 'static_frontend') {
291
+ const hasLockfile = frontendLockfileExists(projectDir)
292
+ checks.push({
293
+ name: 'frontend lockfile',
294
+ ok: hasLockfile,
295
+ message: hasLockfile ? 'found' : 'missing',
296
+ agent_instruction: 'Commit package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock, or bun.lockb, then retry.'
297
+ })
298
+ checks.push(...frontendSourceChecks(projectDir))
299
+ checks.push(...frontendScriptChecks(projectDir))
269
300
  }
270
301
 
271
302
  const unsafeHits = scanUnsafe(projectDir)
@@ -275,6 +306,10 @@ function runDoctor(projectDir) {
275
306
  message: unsafeHits.length === 0 ? 'no direct unsafe found' : unsafeHits.slice(0, 5).join(', '),
276
307
  agent_instruction: 'Remove direct unsafe usage from workspace Rust source before deploying.'
277
308
  })
309
+ if (kind === 'rust_backend') {
310
+ checks.push(cargoCheck(projectDir))
311
+ checks.push(cargoClippy(projectDir))
312
+ }
278
313
 
279
314
  return {
280
315
  ok: checks.every((check) => check.ok),
@@ -284,37 +319,125 @@ function runDoctor(projectDir) {
284
319
  }
285
320
  }
286
321
 
322
+ function cargoCheck(projectDir) {
323
+ const cargo = spawnSync('cargo', ['check', '--locked', '--quiet'], {
324
+ cwd: projectDir,
325
+ encoding: 'utf8',
326
+ env: { ...process.env, CARGO_TERM_COLOR: 'never' },
327
+ stdio: ['ignore', 'pipe', 'pipe']
328
+ })
329
+
330
+ if (cargo.error) {
331
+ return {
332
+ name: 'cargo check',
333
+ ok: false,
334
+ message: cargo.error.message,
335
+ agent_instruction: 'Install Rust and Cargo, then run `cargo check --locked` locally before deploying.'
336
+ }
337
+ }
338
+
339
+ return {
340
+ name: 'cargo check',
341
+ ok: cargo.status === 0,
342
+ message: cargo.status === 0 ? 'passed' : (cargo.stderr || cargo.stdout || 'cargo check failed').trim().slice(0, 240),
343
+ agent_instruction: 'Run `cargo check --locked`, fix every compiler error and warning, then redeploy.'
344
+ }
345
+ }
346
+
347
+ function cargoClippy(projectDir) {
348
+ const cargo = spawnSync('cargo', ['clippy', '--locked', '--all-targets', '--all-features', '--quiet', '--', '-D', 'warnings'], {
349
+ cwd: projectDir,
350
+ encoding: 'utf8',
351
+ env: { ...process.env, CARGO_TERM_COLOR: 'never' },
352
+ stdio: ['ignore', 'pipe', 'pipe']
353
+ })
354
+
355
+ if (cargo.error) {
356
+ return {
357
+ name: 'cargo clippy',
358
+ ok: false,
359
+ message: cargo.error.message,
360
+ agent_instruction: 'Install Rust clippy, then run `cargo clippy --locked --all-targets --all-features -- -D warnings` before deploying.'
361
+ }
362
+ }
363
+
364
+ return {
365
+ name: 'cargo clippy',
366
+ ok: cargo.status === 0,
367
+ message: cargo.status === 0 ? 'passed' : (cargo.stderr || cargo.stdout || 'cargo clippy failed').trim().slice(0, 240),
368
+ agent_instruction: 'Run `cargo clippy --locked --all-targets --all-features -- -D warnings`, fix every warning, then redeploy.'
369
+ }
370
+ }
371
+
287
372
  async function login(cli) {
288
373
  if (cli.token) {
289
- writeSessionToken(process.cwd(), cli.token)
290
- console.log('saved Zerct session token to .zerct/session-token')
374
+ writeSessionToken(cli.token)
375
+ console.log('saved Zerct session token')
291
376
  return
292
377
  }
293
378
 
294
- const response = await apiRequest(cli, 'POST', '/v1/login/device', null, null)
295
- openUrl(response.login_url)
296
- console.log(`opened ${response.login_url}`)
297
- console.log('After login, retry your deploy. If the CLI cannot finish automatically yet, set ZERCT_TOKEN or run `npx @zerct/zerct login --token <token>`.')
379
+ await loginAndStore(cli)
298
380
  }
299
381
 
300
382
  async function deploy(projectDir, cli) {
383
+ const projects = discoverDeployProjects(projectDir)
384
+ if (projects.length === 0) {
385
+ throw agentError('missing_project_contract', 'No zerct.toml was found.', 'Run `npx @zerct/zerct init` in each app directory, or pass a project path.', cli.json)
386
+ }
387
+
388
+ if (projects.length === 1) {
389
+ const project = projects[0]
390
+ if (project.kind === 'static_frontend' && cli.database) {
391
+ throw agentError('invalid_database_target', 'Static frontends cannot attach managed Postgres directly.', 'Deploy a Rust backend with managed Postgres and call it from the frontend.', cli.json)
392
+ }
393
+ const token = await readOrLoginToken(project.dir, cli)
394
+ const result = await deployProject(project.dir, cli, token, cli.database)
395
+ printDeployResult(result, cli)
396
+ return
397
+ }
398
+
399
+ const token = await readOrLoginToken(projectDir, cli)
400
+ const results = []
401
+ if (!cli.json) {
402
+ console.log(`deploying ${projects.length} projects`)
403
+ }
404
+
405
+ for (const project of projects) {
406
+ const wantsDatabase = cli.database && project.kind === 'rust_backend'
407
+ if (!cli.json) {
408
+ console.log(`checking ${project.relative}`)
409
+ }
410
+ const response = await deployProject(project.dir, cli, token, wantsDatabase)
411
+ results.push({ project, wantsDatabase, response })
412
+ if (!cli.json) {
413
+ console.log(`${project.relative} queued ${response.build_job.id}`)
414
+ console.log(`${project.relative} url ${response.app.url}`)
415
+ }
416
+ }
417
+
418
+ printWorkspaceDeployResults(projectDir, results, cli)
419
+ }
420
+
421
+ async function deployProject(projectDir, cli, token, wantsDatabase) {
301
422
  const report = runDoctor(projectDir)
302
423
  if (!report.ok) {
303
424
  const firstFailure = report.checks.find((check) => !check.ok)
304
425
  throw agentError('doctor_failed', 'Zerct doctor failed.', firstFailure?.agent_instruction || 'Fix the failed checks and retry.', cli.json)
305
426
  }
306
427
 
307
- const token = readToken(projectDir, cli)
308
428
  const archive = createArchiveBase64(projectDir)
309
429
  const commitSha = gitCommitSha(projectDir)
310
430
  const body = {
311
431
  config: report.config,
312
432
  commit_sha: commitSha,
313
- wants_database: cli.database,
433
+ wants_database: wantsDatabase,
314
434
  source_archive_base64: archive
315
435
  }
316
436
 
317
- const response = await apiRequest(cli, 'POST', '/v1/deployments', token, body)
437
+ return apiRequest(cli, 'POST', '/v1/deploy', token, body)
438
+ }
439
+
440
+ function printDeployResult(response, cli) {
318
441
  if (cli.json) {
319
442
  console.log(JSON.stringify(response, null, 2))
320
443
  return
@@ -326,6 +449,27 @@ async function deploy(projectDir, cli) {
326
449
  console.log(`next npx @zerct/zerct logs --app ${response.app.id}`)
327
450
  }
328
451
 
452
+ function printWorkspaceDeployResults(projectDir, results, cli) {
453
+ if (cli.json) {
454
+ console.log(JSON.stringify({
455
+ workspace: projectDir,
456
+ deploys: results.map((result) => ({
457
+ path: result.project.relative,
458
+ kind: result.project.kind,
459
+ wants_database: result.wantsDatabase,
460
+ app: result.response.app,
461
+ build_job: result.response.build_job
462
+ }))
463
+ }, null, 2))
464
+ return
465
+ }
466
+
467
+ const firstApp = results[0]?.response?.app?.id
468
+ if (firstApp) {
469
+ console.log(`next npx @zerct/zerct logs --app ${firstApp}`)
470
+ }
471
+ }
472
+
329
473
  async function logs(cli) {
330
474
  const response = await appGet(cli, 'logs')
331
475
  if (cli.json) {
@@ -365,14 +509,14 @@ async function envCommand(cli) {
365
509
 
366
510
  const name = assignment.slice(0, separator)
367
511
  const value = assignment.slice(separator + 1)
368
- const token = readToken(process.cwd(), cli)
512
+ const token = await readOrLoginToken(process.cwd(), cli)
369
513
  const app = requireApp(cli)
370
514
  const response = await apiRequest(cli, 'PUT', `/v1/apps/${encodeURIComponent(app)}/env`, token, { name, value })
371
515
  printJsonOrPretty(cli, response)
372
516
  }
373
517
 
374
518
  async function billing(cli) {
375
- const token = readToken(process.cwd(), cli)
519
+ const token = await readOrLoginToken(process.cwd(), cli)
376
520
  const response = await apiRequest(cli, 'POST', '/v1/billing/checkout', token, {
377
521
  target_plan: 'pro',
378
522
  reason: 'Upgrade to Zerct Pro.'
@@ -386,11 +530,68 @@ async function billing(cli) {
386
530
  }
387
531
 
388
532
  async function appGet(cli, kind) {
389
- const token = readToken(process.cwd(), cli)
533
+ const token = await readOrLoginToken(process.cwd(), cli)
390
534
  const app = requireApp(cli)
391
535
  return apiRequest(cli, 'GET', `/v1/apps/${encodeURIComponent(app)}/${kind}`, token, null)
392
536
  }
393
537
 
538
+ async function readOrLoginToken(projectDir, cli) {
539
+ const token = readStoredToken(projectDir, cli)
540
+ if (token) {
541
+ return token
542
+ }
543
+
544
+ return loginAndStore(cli)
545
+ }
546
+
547
+ async function loginAndStore(cli) {
548
+ const start = await apiRequest(cli, 'POST', '/v1/login/device', null, null)
549
+ const loginUrl = start.loginUrl || start.login_url
550
+ if (!loginUrl) {
551
+ throw agentError('login_failed', 'Zerct login did not return a browser URL.', 'Retry `npx @zerct/zerct login`. If it keeps failing, check Zerct status.', cli.json)
552
+ }
553
+ openUrl(loginUrl)
554
+ progress(cli, 'opened browser login')
555
+ progress(cli, `waiting for browser login code ${start.userCode || start.user_code || 'ZERCT'}`)
556
+
557
+ const session = await pollLogin(cli, start)
558
+ if (!session.token) {
559
+ throw agentError('login_failed', 'Zerct login did not return a session token.', 'Run `npx @zerct/zerct login` again and complete the browser login.', cli.json)
560
+ }
561
+
562
+ writeSessionToken(session.token)
563
+ progress(cli, `logged in as ${session.email || 'Zerct user'}`)
564
+ return session.token
565
+ }
566
+
567
+ async function pollLogin(cli, start) {
568
+ const deviceCode = start.deviceCode || start.device_code
569
+ if (!deviceCode) {
570
+ throw agentError('login_failed', 'Zerct login did not return a device code.', 'Retry `npx @zerct/zerct login`. If it keeps failing, check Zerct status.', cli.json)
571
+ }
572
+
573
+ const expiresMs = Number(start.expiresInSeconds || start.expires_in_seconds || DEFAULT_LOGIN_EXPIRES_SECONDS) * 1000
574
+ const deadline = Date.now() + expiresMs
575
+ let intervalMs = Number(start.intervalSeconds || start.interval_seconds || DEFAULT_LOGIN_INTERVAL_SECONDS) * 1000
576
+
577
+ while (Date.now() < deadline) {
578
+ await sleep(intervalMs)
579
+ const response = await apiRequest(cli, 'GET', `/v1/login/device/${encodeURIComponent(deviceCode)}`, null, null)
580
+ if (response.status === 'complete') {
581
+ return response
582
+ }
583
+ if (response.status === 'expired') {
584
+ throw agentError('login_expired', 'Zerct login expired before it completed.', 'Run `npx @zerct/zerct login` again and finish the browser login in the newly opened tab.', cli.json)
585
+ }
586
+ intervalMs = Math.max(
587
+ DEFAULT_LOGIN_INTERVAL_SECONDS * 1000,
588
+ Number(response.intervalSeconds || response.interval_seconds || DEFAULT_LOGIN_INTERVAL_SECONDS) * 1000
589
+ )
590
+ }
591
+
592
+ throw agentError('login_expired', 'Zerct login expired before it completed.', 'Run `npx @zerct/zerct login` again and finish the browser login in the newly opened tab.', cli.json)
593
+ }
594
+
394
595
  function requireApp(cli) {
395
596
  if (!cli.app) {
396
597
  throw agentError('missing_app', 'App id is required.', 'Pass `--app <app_id>`. Use the app id printed by `npx @zerct/zerct deploy`.', cli.json)
@@ -446,6 +647,7 @@ function createArchiveBase64(projectDir) {
446
647
  const excludeArgs = ARCHIVE_EXCLUDES.map((pattern) => `--exclude=${pattern}`)
447
648
  const tar = spawnSync('tar', [...excludeArgs, '-czf', '-', '-C', projectDir, '.'], {
448
649
  encoding: 'buffer',
650
+ env: { ...process.env, COPYFILE_DISABLE: '1' },
449
651
  maxBuffer: ARCHIVE_LIMIT_BYTES + 1024 * 1024
450
652
  })
451
653
 
@@ -471,7 +673,7 @@ function gitCommitSha(projectDir) {
471
673
  return git.status === 0 ? git.stdout.trim() || null : null
472
674
  }
473
675
 
474
- function readToken(projectDir, cli) {
676
+ function readStoredToken(projectDir, cli) {
475
677
  if (cli.token) {
476
678
  return cli.token
477
679
  }
@@ -479,26 +681,123 @@ function readToken(projectDir, cli) {
479
681
  return process.env.ZERCT_TOKEN
480
682
  }
481
683
 
684
+ const keychainToken = readKeychainToken()
685
+ if (keychainToken) {
686
+ return keychainToken
687
+ }
688
+
689
+ const userToken = readTokenFile(userSessionPath())
690
+ if (userToken) {
691
+ return userToken
692
+ }
693
+
482
694
  const projectToken = path.join(projectDir, SESSION_DIR, SESSION_FILE)
483
- if (existsSync(projectToken)) {
484
- return readFileSync(projectToken, 'utf8').trim()
695
+ const legacyProjectToken = readTokenFile(projectToken)
696
+ if (legacyProjectToken) {
697
+ return legacyProjectToken
485
698
  }
486
699
 
487
700
  const homeToken = path.join(homedir(), SESSION_DIR, SESSION_FILE)
488
- if (existsSync(homeToken)) {
489
- return readFileSync(homeToken, 'utf8').trim()
701
+ return readTokenFile(homeToken)
702
+ }
703
+
704
+ function writeSessionToken(token) {
705
+ const cleanToken = token.trim()
706
+ if (!cleanToken) {
707
+ throw agentError('login_failed', 'Zerct session token is empty.', 'Run `npx @zerct/zerct login` again and complete the browser login.', false)
708
+ }
709
+ if (writeKeychainToken(cleanToken)) {
710
+ return
490
711
  }
491
712
 
492
- throw agentError('login_required', 'Zerct login is required.', 'Run `npx @zerct/zerct login`, set `ZERCT_TOKEN`, or run `npx @zerct/zerct login --token <token>`, then retry.', cli.json)
713
+ writeTokenFile(userSessionPath(), cleanToken)
714
+ }
715
+
716
+ function readTokenFile(filePath) {
717
+ if (!existsSync(filePath)) {
718
+ return ''
719
+ }
720
+ return readFileSync(filePath, 'utf8').trim()
493
721
  }
494
722
 
495
- function writeSessionToken(projectDir, token) {
496
- const dir = path.join(projectDir, SESSION_DIR)
723
+ function writeTokenFile(filePath, token) {
724
+ const dir = path.dirname(filePath)
497
725
  mkdirSync(dir, { recursive: true, mode: 0o700 })
498
- writeFileSync(path.join(dir, SESSION_FILE), `${token.trim()}\n`, { mode: 0o600 })
726
+ writeFileSync(filePath, `${token}\n`, { mode: 0o600 })
727
+ }
728
+
729
+ function userSessionPath() {
730
+ if (process.platform === 'win32' && process.env.APPDATA) {
731
+ return path.join(process.env.APPDATA, 'Zerct', SESSION_FILE)
732
+ }
733
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(homedir(), '.config')
734
+ return path.join(configHome, 'zerct', SESSION_FILE)
735
+ }
736
+
737
+ function readKeychainToken() {
738
+ if (process.platform === 'darwin') {
739
+ const result = spawnSync('security', ['find-generic-password', '-s', SESSION_SERVICE, '-a', SESSION_ACCOUNT, '-w'], {
740
+ encoding: 'utf8',
741
+ stdio: ['ignore', 'pipe', 'ignore']
742
+ })
743
+ return result.status === 0 ? result.stdout.trim() : ''
744
+ }
745
+
746
+ if (process.platform === 'linux' && hasCommand('secret-tool')) {
747
+ const result = spawnSync('secret-tool', ['lookup', 'service', SESSION_SERVICE, 'account', SESSION_ACCOUNT], {
748
+ encoding: 'utf8',
749
+ stdio: ['ignore', 'pipe', 'ignore']
750
+ })
751
+ return result.status === 0 ? result.stdout.trim() : ''
752
+ }
753
+
754
+ return ''
755
+ }
756
+
757
+ function writeKeychainToken(token) {
758
+ if (process.platform === 'darwin') {
759
+ const result = spawnSync('security', [
760
+ 'add-generic-password',
761
+ '-U',
762
+ '-s',
763
+ SESSION_SERVICE,
764
+ '-a',
765
+ SESSION_ACCOUNT,
766
+ '-l',
767
+ SESSION_LABEL,
768
+ '-w',
769
+ token
770
+ ], { stdio: 'ignore' })
771
+ return result.status === 0
772
+ }
773
+
774
+ if (process.platform === 'linux' && hasCommand('secret-tool')) {
775
+ const result = spawnSync('secret-tool', [
776
+ 'store',
777
+ '--label',
778
+ SESSION_LABEL,
779
+ 'service',
780
+ SESSION_SERVICE,
781
+ 'account',
782
+ SESSION_ACCOUNT
783
+ ], {
784
+ input: token,
785
+ stdio: ['pipe', 'ignore', 'ignore']
786
+ })
787
+ return result.status === 0
788
+ }
789
+
790
+ return false
791
+ }
792
+
793
+ function hasCommand(command) {
794
+ return (process.env.PATH || '')
795
+ .split(path.delimiter)
796
+ .filter(Boolean)
797
+ .some((directory) => existsSync(path.join(directory, command)))
499
798
  }
500
799
 
501
- function parseZerctToml(source) {
800
+ function parseZerctToml(source, projectDir) {
502
801
  const config = {
503
802
  build: {},
504
803
  run: {},
@@ -530,7 +829,12 @@ function parseZerctToml(source) {
530
829
  section[assignment[1]] = parseTomlValue(assignment[2])
531
830
  }
532
831
 
533
- config.build.command ||= 'cargo build --release'
832
+ config.kind ||= 'rust_backend'
833
+ config.build.check ||= config.kind === 'static_frontend' ? frontendCheckCommand(projectDir) : DEFAULT_RUST_CHECK_COMMAND
834
+ config.build.command ||= config.kind === 'static_frontend' ? frontendBuildCommand(projectDir) : 'cargo build --release'
835
+ if (config.kind === 'static_frontend') {
836
+ config.build.output ||= 'dist'
837
+ }
534
838
  config.run.port ||= 3000
535
839
  config.run.health ||= '/healthz'
536
840
  config.resources.memory ||= '512mb'
@@ -560,6 +864,22 @@ function validateConfig(config) {
560
864
  if (!/^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/u.test(config.name || '')) {
561
865
  throw new Error('name must be lowercase DNS-safe text up to 48 characters')
562
866
  }
867
+ if (!['rust_backend', 'static_frontend'].includes(config.kind)) {
868
+ throw new Error('kind must be rust_backend or static_frontend')
869
+ }
870
+ if (typeof config.build.command !== 'string' || !config.build.command.trim()) {
871
+ throw new Error('[build].command is required')
872
+ }
873
+ if (typeof config.build.check !== 'string' || !config.build.check.trim()) {
874
+ throw new Error('[build].check is required')
875
+ }
876
+ validateCheckCommand(config.kind, config.build.check)
877
+ if (config.kind === 'static_frontend') {
878
+ if (typeof config.build.output !== 'string' || !isSafeRelativePath(config.build.output)) {
879
+ throw new Error('[build].output must be a safe relative directory like dist')
880
+ }
881
+ return
882
+ }
563
883
  if (!config.run.command || typeof config.run.command !== 'string') {
564
884
  throw new Error('[run].command is required')
565
885
  }
@@ -577,23 +897,245 @@ function validateConfig(config) {
577
897
  }
578
898
  }
579
899
 
900
+ function validateCheckCommand(kind, command) {
901
+ if (kind === 'static_frontend' && usesJavascriptLinter(command)) {
902
+ throw new Error('[build].check must not run JavaScript-based linters; use oxlint, biome, or deno lint')
903
+ }
904
+ const required = kind === 'static_frontend'
905
+ ? ['typecheck', 'lint']
906
+ : ['cargo check --locked', 'cargo clippy --locked', '--all-targets', '--all-features', '-D warnings']
907
+ if (required.every((fragment) => command.includes(fragment))) {
908
+ return
909
+ }
910
+ throw new Error(kind === 'static_frontend'
911
+ ? '[build].check must run frontend typecheck and lint'
912
+ : '[build].check must include cargo check --locked and cargo clippy --locked --all-targets --all-features -- -D warnings')
913
+ }
914
+
915
+ function frontendLockfileExists(projectDir) {
916
+ return ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']
917
+ .some((file) => existsSync(path.join(projectDir, file)))
918
+ }
919
+
920
+ function frontendPackageManager(projectDir) {
921
+ return existsSync(path.join(projectDir, 'bun.lock')) || existsSync(path.join(projectDir, 'bun.lockb'))
922
+ ? 'bun'
923
+ : 'npm'
924
+ }
925
+
926
+ function frontendCheckCommand(projectDir) {
927
+ return frontendPackageManager(projectDir) === 'bun'
928
+ ? DEFAULT_BUN_FRONTEND_CHECK_COMMAND
929
+ : DEFAULT_NPM_FRONTEND_CHECK_COMMAND
930
+ }
931
+
932
+ function frontendBuildCommand(projectDir) {
933
+ return frontendPackageManager(projectDir) === 'bun'
934
+ ? 'bun run build'
935
+ : 'npm run build'
936
+ }
937
+
938
+ function frontendScriptChecks(projectDir) {
939
+ const manifest = readPackageJson(projectDir)
940
+ const missing = (script) => !manifest?.scripts || typeof manifest.scripts[script] !== 'string' || !manifest.scripts[script].trim()
941
+ const checks = ['typecheck', 'lint'].map((script) => ({
942
+ name: `package script ${script}`,
943
+ ok: !missing(script),
944
+ message: missing(script) ? 'missing' : 'found',
945
+ agent_instruction: `Add a non-empty "${script}" script to package.json, then retry.`
946
+ }))
947
+ const lintScript = manifest?.scripts?.lint || ''
948
+ const nativeLint = !lintScript || !usesJavascriptLinter(lintScript)
949
+ checks.push({
950
+ name: 'native frontend lint',
951
+ ok: nativeLint,
952
+ message: nativeLint ? 'accepted' : 'JavaScript linter found',
953
+ agent_instruction: 'Replace the lint script with native tooling such as `oxlint src vite.config.ts --deny-warnings`, `biome check .`, or `deno lint`, then retry.'
954
+ })
955
+
956
+ if (checks.every((check) => check.ok)) {
957
+ checks.push(packageScriptCheck(projectDir, 'typecheck'))
958
+ checks.push(packageScriptCheck(projectDir, 'lint'))
959
+ }
960
+
961
+ return checks
962
+ }
963
+
964
+ function frontendSourceChecks(projectDir) {
965
+ const report = frontendSourceReport(projectDir)
966
+ return [
967
+ {
968
+ name: 'typescript source',
969
+ ok: report.typescript.length > 0,
970
+ message: report.typescript.length > 0 ? report.typescript.slice(0, 3).join(', ') : 'missing',
971
+ agent_instruction: 'Add browser source as .ts or .tsx under src, app, pages, routes, or components, then retry.'
972
+ },
973
+ {
974
+ name: 'javascript source',
975
+ ok: report.javascript.length === 0,
976
+ message: report.javascript.length === 0 ? 'none found' : report.javascript.slice(0, 5).join(', '),
977
+ agent_instruction: 'Rename browser .js, .jsx, .mjs, or .cjs source files to .ts or .tsx and fix type errors before deploying.'
978
+ }
979
+ ]
980
+ }
981
+
982
+ function frontendSourceReport(projectDir) {
983
+ const report = { typescript: [], javascript: [] }
984
+ walkProjectFiles(projectDir, (file, relative) => {
985
+ if (!isFrontendSourcePath(relative)) {
986
+ return
987
+ }
988
+ if (isFrontendTypescriptSource(relative)) {
989
+ report.typescript.push(relative)
990
+ } else if (isFrontendJavascriptSource(relative)) {
991
+ report.javascript.push(relative)
992
+ }
993
+ })
994
+ return report
995
+ }
996
+
997
+ function isFrontendSourcePath(relative) {
998
+ const [root] = relative.split('/')
999
+ return ['src', 'app', 'pages', 'routes', 'components'].includes(root)
1000
+ }
1001
+
1002
+ function isFrontendTypescriptSource(relative) {
1003
+ return !relative.endsWith('.d.ts') && (relative.endsWith('.ts') || relative.endsWith('.tsx'))
1004
+ }
1005
+
1006
+ function isFrontendJavascriptSource(relative) {
1007
+ return ['.js', '.jsx', '.mjs', '.cjs'].some((extension) => relative.endsWith(extension))
1008
+ }
1009
+
1010
+ function readPackageJson(projectDir) {
1011
+ try {
1012
+ return JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8'))
1013
+ } catch (_error) {
1014
+ return null
1015
+ }
1016
+ }
1017
+
1018
+ function usesJavascriptLinter(command) {
1019
+ const tokens = command
1020
+ .replace(/[&|;()]/gu, ' ')
1021
+ .split(/\s+/u)
1022
+ .map((token) => token.trim().replace(/^["']|["']$/gu, ''))
1023
+ .filter(Boolean)
1024
+ return tokens.some((token, index) => {
1025
+ const commandName = token.split('/').pop()
1026
+ return ['eslint', 'eslint_d', 'standard', 'xo'].includes(commandName)
1027
+ || (commandName === 'next' && tokens[index + 1] === 'lint')
1028
+ })
1029
+ }
1030
+
1031
+ function packageScriptCheck(projectDir, script) {
1032
+ const manager = frontendPackageManager(projectDir)
1033
+ const args = manager === 'bun' ? ['run', script] : ['run', '--silent', script]
1034
+ const result = spawnSync(manager, args, {
1035
+ cwd: projectDir,
1036
+ encoding: 'utf8',
1037
+ stdio: ['ignore', 'pipe', 'pipe']
1038
+ })
1039
+ if (result.error) {
1040
+ return {
1041
+ name: `${manager} run ${script}`,
1042
+ ok: false,
1043
+ message: result.error.message,
1044
+ agent_instruction: `Install ${manager === 'bun' ? 'Bun' : 'Node.js and npm'}, then run \`${manager} run ${script}\` before deploying.`
1045
+ }
1046
+ }
1047
+
1048
+ return {
1049
+ name: `${manager} run ${script}`,
1050
+ ok: result.status === 0,
1051
+ message: result.status === 0 ? 'passed' : (result.stderr || result.stdout || `${manager} run ${script} failed`).trim().slice(0, 240),
1052
+ agent_instruction: `Run \`${manager} run ${script}\`, fix every error, then redeploy.`
1053
+ }
1054
+ }
1055
+
1056
+ function discoverDeployProjects(rootDir) {
1057
+ ensureDirectory(rootDir)
1058
+ if (existsSync(path.join(rootDir, 'zerct.toml'))) {
1059
+ return [deployProjectInfo(rootDir, rootDir)]
1060
+ }
1061
+
1062
+ const projectDirs = []
1063
+ discoverProjectDirs(rootDir, projectDirs)
1064
+ return projectDirs
1065
+ .map((dir) => deployProjectInfo(dir, rootDir))
1066
+ .sort(compareDeployProjects)
1067
+ }
1068
+
1069
+ function discoverProjectDirs(dir, projectDirs) {
1070
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1071
+ if (!entry.isDirectory() || WORKSPACE_EXCLUDED_DIRS.has(entry.name)) {
1072
+ continue
1073
+ }
1074
+
1075
+ const child = path.join(dir, entry.name)
1076
+ if (existsSync(path.join(child, 'zerct.toml'))) {
1077
+ projectDirs.push(child)
1078
+ continue
1079
+ }
1080
+ discoverProjectDirs(child, projectDirs)
1081
+ }
1082
+ }
1083
+
1084
+ function deployProjectInfo(dir, rootDir) {
1085
+ const relative = path.relative(rootDir, dir).replace(/\\/gu, '/') || '.'
1086
+ try {
1087
+ const config = parseZerctToml(readFileSync(path.join(dir, 'zerct.toml'), 'utf8'), dir)
1088
+ return { dir, relative, kind: config.kind }
1089
+ } catch (_error) {
1090
+ return { dir, relative, kind: 'unknown' }
1091
+ }
1092
+ }
1093
+
1094
+ function compareDeployProjects(left, right) {
1095
+ return kindOrder(left.kind) - kindOrder(right.kind)
1096
+ || left.relative.localeCompare(right.relative)
1097
+ }
1098
+
1099
+ function kindOrder(kind) {
1100
+ if (kind === 'rust_backend') {
1101
+ return 0
1102
+ }
1103
+ if (kind === 'static_frontend') {
1104
+ return 1
1105
+ }
1106
+ return 2
1107
+ }
1108
+
1109
+ function isSafeRelativePath(value) {
1110
+ return value
1111
+ && !path.isAbsolute(value)
1112
+ && !value.includes('\\')
1113
+ && value.split('/').every((part) => part && part !== '.' && part !== '..')
1114
+ }
1115
+
580
1116
  function scanUnsafe(projectDir) {
581
1117
  const hits = []
582
- walk(projectDir, (file) => {
1118
+ walkProjectFiles(projectDir, (file, relative) => {
583
1119
  if (!file.endsWith('.rs')) {
584
1120
  return
585
1121
  }
586
1122
  const source = readFileSync(file, 'utf8')
587
1123
  if (/\bunsafe\b/u.test(source)) {
588
- hits.push(path.relative(projectDir, file))
1124
+ hits.push(relative)
589
1125
  }
590
1126
  })
591
1127
  return hits
592
1128
  }
593
1129
 
1130
+ function walkProjectFiles(projectDir, visit) {
1131
+ walk(projectDir, (file) => {
1132
+ visit(file, path.relative(projectDir, file).replace(/\\/gu, '/'))
1133
+ })
1134
+ }
1135
+
594
1136
  function walk(dir, visit) {
595
1137
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
596
- if (['.git', 'target', 'node_modules', '.zerct'].includes(entry.name)) {
1138
+ if (WALK_EXCLUDED_DIRS.has(entry.name)) {
597
1139
  continue
598
1140
  }
599
1141
  const fullPath = path.join(dir, entry.name)
@@ -626,6 +1168,20 @@ function openUrl(url) {
626
1168
  spawnSync(command, args, { stdio: 'ignore', detached: true })
627
1169
  }
628
1170
 
1171
+ function sleep(milliseconds) {
1172
+ return new Promise((resolve) => {
1173
+ setTimeout(resolve, milliseconds)
1174
+ })
1175
+ }
1176
+
1177
+ function progress(cli, message) {
1178
+ if (cli.json) {
1179
+ console.error(message)
1180
+ return
1181
+ }
1182
+ console.log(message)
1183
+ }
1184
+
629
1185
  function trimTrailingSlash(value) {
630
1186
  return value.replace(/\/+$/u, '')
631
1187
  }
@@ -666,3 +1222,14 @@ class ZerctError extends Error {
666
1222
  this.exitCode = exitCode
667
1223
  }
668
1224
  }
1225
+
1226
+ main().catch((error) => {
1227
+ if (error instanceof ZerctError) {
1228
+ printAgentError(error.payload, error.json)
1229
+ process.exitCode = error.exitCode
1230
+ return
1231
+ }
1232
+
1233
+ console.error(`zerct failed: ${error.message}`)
1234
+ process.exitCode = 1
1235
+ })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zerct/zerct",
3
- "version": "0.1.2",
4
- "description": "Deploy Rust backends to Zerct.",
3
+ "version": "0.1.4",
4
+ "description": "Deploy Rust backends and static frontends to Zerct.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "zerct": "bin/zerct.js"
@@ -10,6 +10,9 @@
10
10
  "bin",
11
11
  "README.md"
12
12
  ],
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
13
16
  "engines": {
14
17
  "node": ">=18.17"
15
18
  },
@@ -33,8 +36,5 @@
33
36
  "scripts": {
34
37
  "check": "node --check bin/zerct.js",
35
38
  "pack:dry": "npm pack --dry-run"
36
- },
37
- "publishConfig": {
38
- "access": "public"
39
39
  }
40
40
  }