@zerct/zerct 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -5
  2. package/bin/zerct.js +497 -53
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -9,13 +9,14 @@ 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
+ On first deploy, the CLI opens browser login, waits for GitHub or Google, stores
21
+ the Zerct session in the OS credential store when available, and continues the
22
+ 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.3'
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,10 @@ 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'])
39
49
 
40
50
  const HELP = `Zerct ${VERSION}
41
51
 
@@ -53,23 +63,11 @@ Usage:
53
63
  zerct billing [--api <url>] [--json]
54
64
 
55
65
  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.
66
+ - Rust backends keep Cargo.lock committed, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
67
+ - Static frontends set kind = "static_frontend", keep TypeScript source, a package lockfile, and typecheck + lint scripts.
68
+ - Keep direct unsafe out of Rust source.
60
69
  `
61
70
 
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
71
  async function main() {
74
72
  const cli = parseArgs(process.argv.slice(2))
75
73
 
@@ -240,22 +238,11 @@ function doctorProject(projectDir, json) {
240
238
 
241
239
  function runDoctor(projectDir) {
242
240
  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
241
  let config = null
255
242
  const configPath = path.join(projectDir, 'zerct.toml')
256
243
  if (existsSync(configPath)) {
257
244
  try {
258
- config = parseZerctToml(readFileSync(configPath, 'utf8'))
245
+ config = parseZerctToml(readFileSync(configPath, 'utf8'), projectDir)
259
246
  validateConfig(config)
260
247
  checks.push({ name: 'zerct.toml', ok: true, message: 'valid' })
261
248
  } catch (error) {
@@ -263,9 +250,42 @@ function runDoctor(projectDir) {
263
250
  name: 'zerct.toml',
264
251
  ok: false,
265
252
  message: error.message,
266
- agent_instruction: 'Fix zerct.toml so it matches the Zerct deploy contract.'
253
+ agent_instruction: `Fix zerct.toml: ${error.message}.`
267
254
  })
268
255
  }
256
+ } else {
257
+ checks.push({
258
+ name: 'zerct.toml',
259
+ ok: false,
260
+ message: 'missing',
261
+ agent_instruction: 'Create and commit zerct.toml, then retry.'
262
+ })
263
+ }
264
+
265
+ const kind = config?.kind || 'rust_backend'
266
+ const requiredFiles = kind === 'static_frontend'
267
+ ? ['package.json']
268
+ : ['Cargo.toml', 'Cargo.lock']
269
+ for (const file of requiredFiles) {
270
+ const ok = existsSync(path.join(projectDir, file))
271
+ checks.push({
272
+ name: file,
273
+ ok,
274
+ message: ok ? 'found' : 'missing',
275
+ agent_instruction: `Create and commit ${file}, then retry.`
276
+ })
277
+ }
278
+
279
+ if (kind === 'static_frontend') {
280
+ const hasLockfile = frontendLockfileExists(projectDir)
281
+ checks.push({
282
+ name: 'frontend lockfile',
283
+ ok: hasLockfile,
284
+ message: hasLockfile ? 'found' : 'missing',
285
+ agent_instruction: 'Commit package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock, or bun.lockb, then retry.'
286
+ })
287
+ checks.push(...frontendSourceChecks(projectDir))
288
+ checks.push(...frontendScriptChecks(projectDir))
269
289
  }
270
290
 
271
291
  const unsafeHits = scanUnsafe(projectDir)
@@ -275,6 +295,10 @@ function runDoctor(projectDir) {
275
295
  message: unsafeHits.length === 0 ? 'no direct unsafe found' : unsafeHits.slice(0, 5).join(', '),
276
296
  agent_instruction: 'Remove direct unsafe usage from workspace Rust source before deploying.'
277
297
  })
298
+ if (kind === 'rust_backend') {
299
+ checks.push(cargoCheck(projectDir))
300
+ checks.push(cargoClippy(projectDir))
301
+ }
278
302
 
279
303
  return {
280
304
  ok: checks.every((check) => check.ok),
@@ -284,17 +308,64 @@ function runDoctor(projectDir) {
284
308
  }
285
309
  }
286
310
 
311
+ function cargoCheck(projectDir) {
312
+ const cargo = spawnSync('cargo', ['check', '--locked', '--quiet'], {
313
+ cwd: projectDir,
314
+ encoding: 'utf8',
315
+ env: { ...process.env, CARGO_TERM_COLOR: 'never' },
316
+ stdio: ['ignore', 'pipe', 'pipe']
317
+ })
318
+
319
+ if (cargo.error) {
320
+ return {
321
+ name: 'cargo check',
322
+ ok: false,
323
+ message: cargo.error.message,
324
+ agent_instruction: 'Install Rust and Cargo, then run `cargo check --locked` locally before deploying.'
325
+ }
326
+ }
327
+
328
+ return {
329
+ name: 'cargo check',
330
+ ok: cargo.status === 0,
331
+ message: cargo.status === 0 ? 'passed' : (cargo.stderr || cargo.stdout || 'cargo check failed').trim().slice(0, 240),
332
+ agent_instruction: 'Run `cargo check --locked`, fix every compiler error and warning, then redeploy.'
333
+ }
334
+ }
335
+
336
+ function cargoClippy(projectDir) {
337
+ const cargo = spawnSync('cargo', ['clippy', '--locked', '--all-targets', '--all-features', '--quiet', '--', '-D', 'warnings'], {
338
+ cwd: projectDir,
339
+ encoding: 'utf8',
340
+ env: { ...process.env, CARGO_TERM_COLOR: 'never' },
341
+ stdio: ['ignore', 'pipe', 'pipe']
342
+ })
343
+
344
+ if (cargo.error) {
345
+ return {
346
+ name: 'cargo clippy',
347
+ ok: false,
348
+ message: cargo.error.message,
349
+ agent_instruction: 'Install Rust clippy, then run `cargo clippy --locked --all-targets --all-features -- -D warnings` before deploying.'
350
+ }
351
+ }
352
+
353
+ return {
354
+ name: 'cargo clippy',
355
+ ok: cargo.status === 0,
356
+ message: cargo.status === 0 ? 'passed' : (cargo.stderr || cargo.stdout || 'cargo clippy failed').trim().slice(0, 240),
357
+ agent_instruction: 'Run `cargo clippy --locked --all-targets --all-features -- -D warnings`, fix every warning, then redeploy.'
358
+ }
359
+ }
360
+
287
361
  async function login(cli) {
288
362
  if (cli.token) {
289
- writeSessionToken(process.cwd(), cli.token)
290
- console.log('saved Zerct session token to .zerct/session-token')
363
+ writeSessionToken(cli.token)
364
+ console.log('saved Zerct session token')
291
365
  return
292
366
  }
293
367
 
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>`.')
368
+ await loginAndStore(cli)
298
369
  }
299
370
 
300
371
  async function deploy(projectDir, cli) {
@@ -303,8 +374,11 @@ async function deploy(projectDir, cli) {
303
374
  const firstFailure = report.checks.find((check) => !check.ok)
304
375
  throw agentError('doctor_failed', 'Zerct doctor failed.', firstFailure?.agent_instruction || 'Fix the failed checks and retry.', cli.json)
305
376
  }
377
+ if (report.config?.kind === 'static_frontend' && cli.database) {
378
+ 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)
379
+ }
306
380
 
307
- const token = readToken(projectDir, cli)
381
+ const token = await readOrLoginToken(projectDir, cli)
308
382
  const archive = createArchiveBase64(projectDir)
309
383
  const commitSha = gitCommitSha(projectDir)
310
384
  const body = {
@@ -365,14 +439,14 @@ async function envCommand(cli) {
365
439
 
366
440
  const name = assignment.slice(0, separator)
367
441
  const value = assignment.slice(separator + 1)
368
- const token = readToken(process.cwd(), cli)
442
+ const token = await readOrLoginToken(process.cwd(), cli)
369
443
  const app = requireApp(cli)
370
444
  const response = await apiRequest(cli, 'PUT', `/v1/apps/${encodeURIComponent(app)}/env`, token, { name, value })
371
445
  printJsonOrPretty(cli, response)
372
446
  }
373
447
 
374
448
  async function billing(cli) {
375
- const token = readToken(process.cwd(), cli)
449
+ const token = await readOrLoginToken(process.cwd(), cli)
376
450
  const response = await apiRequest(cli, 'POST', '/v1/billing/checkout', token, {
377
451
  target_plan: 'pro',
378
452
  reason: 'Upgrade to Zerct Pro.'
@@ -386,11 +460,68 @@ async function billing(cli) {
386
460
  }
387
461
 
388
462
  async function appGet(cli, kind) {
389
- const token = readToken(process.cwd(), cli)
463
+ const token = await readOrLoginToken(process.cwd(), cli)
390
464
  const app = requireApp(cli)
391
465
  return apiRequest(cli, 'GET', `/v1/apps/${encodeURIComponent(app)}/${kind}`, token, null)
392
466
  }
393
467
 
468
+ async function readOrLoginToken(projectDir, cli) {
469
+ const token = readStoredToken(projectDir, cli)
470
+ if (token) {
471
+ return token
472
+ }
473
+
474
+ return loginAndStore(cli)
475
+ }
476
+
477
+ async function loginAndStore(cli) {
478
+ const start = await apiRequest(cli, 'POST', '/v1/login/device', null, null)
479
+ const loginUrl = start.loginUrl || start.login_url
480
+ if (!loginUrl) {
481
+ 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)
482
+ }
483
+ openUrl(loginUrl)
484
+ progress(cli, 'opened browser login')
485
+ progress(cli, `waiting for browser login code ${start.userCode || start.user_code || 'ZERCT'}`)
486
+
487
+ const session = await pollLogin(cli, start)
488
+ if (!session.token) {
489
+ 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)
490
+ }
491
+
492
+ writeSessionToken(session.token)
493
+ progress(cli, `logged in as ${session.email || 'Zerct user'}`)
494
+ return session.token
495
+ }
496
+
497
+ async function pollLogin(cli, start) {
498
+ const deviceCode = start.deviceCode || start.device_code
499
+ if (!deviceCode) {
500
+ 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)
501
+ }
502
+
503
+ const expiresMs = Number(start.expiresInSeconds || start.expires_in_seconds || DEFAULT_LOGIN_EXPIRES_SECONDS) * 1000
504
+ const deadline = Date.now() + expiresMs
505
+ let intervalMs = Number(start.intervalSeconds || start.interval_seconds || DEFAULT_LOGIN_INTERVAL_SECONDS) * 1000
506
+
507
+ while (Date.now() < deadline) {
508
+ await sleep(intervalMs)
509
+ const response = await apiRequest(cli, 'GET', `/v1/login/device/${encodeURIComponent(deviceCode)}`, null, null)
510
+ if (response.status === 'complete') {
511
+ return response
512
+ }
513
+ if (response.status === 'expired') {
514
+ 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)
515
+ }
516
+ intervalMs = Math.max(
517
+ DEFAULT_LOGIN_INTERVAL_SECONDS * 1000,
518
+ Number(response.intervalSeconds || response.interval_seconds || DEFAULT_LOGIN_INTERVAL_SECONDS) * 1000
519
+ )
520
+ }
521
+
522
+ 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)
523
+ }
524
+
394
525
  function requireApp(cli) {
395
526
  if (!cli.app) {
396
527
  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 +577,7 @@ function createArchiveBase64(projectDir) {
446
577
  const excludeArgs = ARCHIVE_EXCLUDES.map((pattern) => `--exclude=${pattern}`)
447
578
  const tar = spawnSync('tar', [...excludeArgs, '-czf', '-', '-C', projectDir, '.'], {
448
579
  encoding: 'buffer',
580
+ env: { ...process.env, COPYFILE_DISABLE: '1' },
449
581
  maxBuffer: ARCHIVE_LIMIT_BYTES + 1024 * 1024
450
582
  })
451
583
 
@@ -471,7 +603,7 @@ function gitCommitSha(projectDir) {
471
603
  return git.status === 0 ? git.stdout.trim() || null : null
472
604
  }
473
605
 
474
- function readToken(projectDir, cli) {
606
+ function readStoredToken(projectDir, cli) {
475
607
  if (cli.token) {
476
608
  return cli.token
477
609
  }
@@ -479,26 +611,123 @@ function readToken(projectDir, cli) {
479
611
  return process.env.ZERCT_TOKEN
480
612
  }
481
613
 
614
+ const keychainToken = readKeychainToken()
615
+ if (keychainToken) {
616
+ return keychainToken
617
+ }
618
+
619
+ const userToken = readTokenFile(userSessionPath())
620
+ if (userToken) {
621
+ return userToken
622
+ }
623
+
482
624
  const projectToken = path.join(projectDir, SESSION_DIR, SESSION_FILE)
483
- if (existsSync(projectToken)) {
484
- return readFileSync(projectToken, 'utf8').trim()
625
+ const legacyProjectToken = readTokenFile(projectToken)
626
+ if (legacyProjectToken) {
627
+ return legacyProjectToken
485
628
  }
486
629
 
487
630
  const homeToken = path.join(homedir(), SESSION_DIR, SESSION_FILE)
488
- if (existsSync(homeToken)) {
489
- return readFileSync(homeToken, 'utf8').trim()
631
+ return readTokenFile(homeToken)
632
+ }
633
+
634
+ function writeSessionToken(token) {
635
+ const cleanToken = token.trim()
636
+ if (!cleanToken) {
637
+ throw agentError('login_failed', 'Zerct session token is empty.', 'Run `npx @zerct/zerct login` again and complete the browser login.', false)
490
638
  }
639
+ if (writeKeychainToken(cleanToken)) {
640
+ return
641
+ }
642
+
643
+ writeTokenFile(userSessionPath(), cleanToken)
644
+ }
491
645
 
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)
646
+ function readTokenFile(filePath) {
647
+ if (!existsSync(filePath)) {
648
+ return ''
649
+ }
650
+ return readFileSync(filePath, 'utf8').trim()
493
651
  }
494
652
 
495
- function writeSessionToken(projectDir, token) {
496
- const dir = path.join(projectDir, SESSION_DIR)
653
+ function writeTokenFile(filePath, token) {
654
+ const dir = path.dirname(filePath)
497
655
  mkdirSync(dir, { recursive: true, mode: 0o700 })
498
- writeFileSync(path.join(dir, SESSION_FILE), `${token.trim()}\n`, { mode: 0o600 })
656
+ writeFileSync(filePath, `${token}\n`, { mode: 0o600 })
657
+ }
658
+
659
+ function userSessionPath() {
660
+ if (process.platform === 'win32' && process.env.APPDATA) {
661
+ return path.join(process.env.APPDATA, 'Zerct', SESSION_FILE)
662
+ }
663
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(homedir(), '.config')
664
+ return path.join(configHome, 'zerct', SESSION_FILE)
665
+ }
666
+
667
+ function readKeychainToken() {
668
+ if (process.platform === 'darwin') {
669
+ const result = spawnSync('security', ['find-generic-password', '-s', SESSION_SERVICE, '-a', SESSION_ACCOUNT, '-w'], {
670
+ encoding: 'utf8',
671
+ stdio: ['ignore', 'pipe', 'ignore']
672
+ })
673
+ return result.status === 0 ? result.stdout.trim() : ''
674
+ }
675
+
676
+ if (process.platform === 'linux' && hasCommand('secret-tool')) {
677
+ const result = spawnSync('secret-tool', ['lookup', 'service', SESSION_SERVICE, 'account', SESSION_ACCOUNT], {
678
+ encoding: 'utf8',
679
+ stdio: ['ignore', 'pipe', 'ignore']
680
+ })
681
+ return result.status === 0 ? result.stdout.trim() : ''
682
+ }
683
+
684
+ return ''
499
685
  }
500
686
 
501
- function parseZerctToml(source) {
687
+ function writeKeychainToken(token) {
688
+ if (process.platform === 'darwin') {
689
+ const result = spawnSync('security', [
690
+ 'add-generic-password',
691
+ '-U',
692
+ '-s',
693
+ SESSION_SERVICE,
694
+ '-a',
695
+ SESSION_ACCOUNT,
696
+ '-l',
697
+ SESSION_LABEL,
698
+ '-w',
699
+ token
700
+ ], { stdio: 'ignore' })
701
+ return result.status === 0
702
+ }
703
+
704
+ if (process.platform === 'linux' && hasCommand('secret-tool')) {
705
+ const result = spawnSync('secret-tool', [
706
+ 'store',
707
+ '--label',
708
+ SESSION_LABEL,
709
+ 'service',
710
+ SESSION_SERVICE,
711
+ 'account',
712
+ SESSION_ACCOUNT
713
+ ], {
714
+ input: token,
715
+ stdio: ['pipe', 'ignore', 'ignore']
716
+ })
717
+ return result.status === 0
718
+ }
719
+
720
+ return false
721
+ }
722
+
723
+ function hasCommand(command) {
724
+ return (process.env.PATH || '')
725
+ .split(path.delimiter)
726
+ .filter(Boolean)
727
+ .some((directory) => existsSync(path.join(directory, command)))
728
+ }
729
+
730
+ function parseZerctToml(source, projectDir) {
502
731
  const config = {
503
732
  build: {},
504
733
  run: {},
@@ -530,7 +759,12 @@ function parseZerctToml(source) {
530
759
  section[assignment[1]] = parseTomlValue(assignment[2])
531
760
  }
532
761
 
533
- config.build.command ||= 'cargo build --release'
762
+ config.kind ||= 'rust_backend'
763
+ config.build.check ||= config.kind === 'static_frontend' ? frontendCheckCommand(projectDir) : DEFAULT_RUST_CHECK_COMMAND
764
+ config.build.command ||= config.kind === 'static_frontend' ? frontendBuildCommand(projectDir) : 'cargo build --release'
765
+ if (config.kind === 'static_frontend') {
766
+ config.build.output ||= 'dist'
767
+ }
534
768
  config.run.port ||= 3000
535
769
  config.run.health ||= '/healthz'
536
770
  config.resources.memory ||= '512mb'
@@ -560,6 +794,22 @@ function validateConfig(config) {
560
794
  if (!/^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/u.test(config.name || '')) {
561
795
  throw new Error('name must be lowercase DNS-safe text up to 48 characters')
562
796
  }
797
+ if (!['rust_backend', 'static_frontend'].includes(config.kind)) {
798
+ throw new Error('kind must be rust_backend or static_frontend')
799
+ }
800
+ if (typeof config.build.command !== 'string' || !config.build.command.trim()) {
801
+ throw new Error('[build].command is required')
802
+ }
803
+ if (typeof config.build.check !== 'string' || !config.build.check.trim()) {
804
+ throw new Error('[build].check is required')
805
+ }
806
+ validateCheckCommand(config.kind, config.build.check)
807
+ if (config.kind === 'static_frontend') {
808
+ if (typeof config.build.output !== 'string' || !isSafeRelativePath(config.build.output)) {
809
+ throw new Error('[build].output must be a safe relative directory like dist')
810
+ }
811
+ return
812
+ }
563
813
  if (!config.run.command || typeof config.run.command !== 'string') {
564
814
  throw new Error('[run].command is required')
565
815
  }
@@ -577,23 +827,192 @@ function validateConfig(config) {
577
827
  }
578
828
  }
579
829
 
830
+ function validateCheckCommand(kind, command) {
831
+ if (kind === 'static_frontend' && usesJavascriptLinter(command)) {
832
+ throw new Error('[build].check must not run JavaScript-based linters; use oxlint, biome, or deno lint')
833
+ }
834
+ const required = kind === 'static_frontend'
835
+ ? ['typecheck', 'lint']
836
+ : ['cargo check --locked', 'cargo clippy --locked', '--all-targets', '--all-features', '-D warnings']
837
+ if (required.every((fragment) => command.includes(fragment))) {
838
+ return
839
+ }
840
+ throw new Error(kind === 'static_frontend'
841
+ ? '[build].check must run frontend typecheck and lint'
842
+ : '[build].check must include cargo check --locked and cargo clippy --locked --all-targets --all-features -- -D warnings')
843
+ }
844
+
845
+ function frontendLockfileExists(projectDir) {
846
+ return ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']
847
+ .some((file) => existsSync(path.join(projectDir, file)))
848
+ }
849
+
850
+ function frontendPackageManager(projectDir) {
851
+ return existsSync(path.join(projectDir, 'bun.lock')) || existsSync(path.join(projectDir, 'bun.lockb'))
852
+ ? 'bun'
853
+ : 'npm'
854
+ }
855
+
856
+ function frontendCheckCommand(projectDir) {
857
+ return frontendPackageManager(projectDir) === 'bun'
858
+ ? DEFAULT_BUN_FRONTEND_CHECK_COMMAND
859
+ : DEFAULT_NPM_FRONTEND_CHECK_COMMAND
860
+ }
861
+
862
+ function frontendBuildCommand(projectDir) {
863
+ return frontendPackageManager(projectDir) === 'bun'
864
+ ? 'bun run build'
865
+ : 'npm run build'
866
+ }
867
+
868
+ function frontendScriptChecks(projectDir) {
869
+ const manifest = readPackageJson(projectDir)
870
+ const missing = (script) => !manifest?.scripts || typeof manifest.scripts[script] !== 'string' || !manifest.scripts[script].trim()
871
+ const checks = ['typecheck', 'lint'].map((script) => ({
872
+ name: `package script ${script}`,
873
+ ok: !missing(script),
874
+ message: missing(script) ? 'missing' : 'found',
875
+ agent_instruction: `Add a non-empty "${script}" script to package.json, then retry.`
876
+ }))
877
+ const lintScript = manifest?.scripts?.lint || ''
878
+ const nativeLint = !lintScript || !usesJavascriptLinter(lintScript)
879
+ checks.push({
880
+ name: 'native frontend lint',
881
+ ok: nativeLint,
882
+ message: nativeLint ? 'accepted' : 'JavaScript linter found',
883
+ 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.'
884
+ })
885
+
886
+ if (checks.every((check) => check.ok)) {
887
+ checks.push(packageScriptCheck(projectDir, 'typecheck'))
888
+ checks.push(packageScriptCheck(projectDir, 'lint'))
889
+ }
890
+
891
+ return checks
892
+ }
893
+
894
+ function frontendSourceChecks(projectDir) {
895
+ const report = frontendSourceReport(projectDir)
896
+ return [
897
+ {
898
+ name: 'typescript source',
899
+ ok: report.typescript.length > 0,
900
+ message: report.typescript.length > 0 ? report.typescript.slice(0, 3).join(', ') : 'missing',
901
+ agent_instruction: 'Add browser source as .ts or .tsx under src, app, pages, routes, or components, then retry.'
902
+ },
903
+ {
904
+ name: 'javascript source',
905
+ ok: report.javascript.length === 0,
906
+ message: report.javascript.length === 0 ? 'none found' : report.javascript.slice(0, 5).join(', '),
907
+ agent_instruction: 'Rename browser .js, .jsx, .mjs, or .cjs source files to .ts or .tsx and fix type errors before deploying.'
908
+ }
909
+ ]
910
+ }
911
+
912
+ function frontendSourceReport(projectDir) {
913
+ const report = { typescript: [], javascript: [] }
914
+ walkProjectFiles(projectDir, (file, relative) => {
915
+ if (!isFrontendSourcePath(relative)) {
916
+ return
917
+ }
918
+ if (isFrontendTypescriptSource(relative)) {
919
+ report.typescript.push(relative)
920
+ } else if (isFrontendJavascriptSource(relative)) {
921
+ report.javascript.push(relative)
922
+ }
923
+ })
924
+ return report
925
+ }
926
+
927
+ function isFrontendSourcePath(relative) {
928
+ const [root] = relative.split('/')
929
+ return ['src', 'app', 'pages', 'routes', 'components'].includes(root)
930
+ }
931
+
932
+ function isFrontendTypescriptSource(relative) {
933
+ return !relative.endsWith('.d.ts') && (relative.endsWith('.ts') || relative.endsWith('.tsx'))
934
+ }
935
+
936
+ function isFrontendJavascriptSource(relative) {
937
+ return ['.js', '.jsx', '.mjs', '.cjs'].some((extension) => relative.endsWith(extension))
938
+ }
939
+
940
+ function readPackageJson(projectDir) {
941
+ try {
942
+ return JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8'))
943
+ } catch (_error) {
944
+ return null
945
+ }
946
+ }
947
+
948
+ function usesJavascriptLinter(command) {
949
+ const tokens = command
950
+ .replace(/[&|;()]/gu, ' ')
951
+ .split(/\s+/u)
952
+ .map((token) => token.trim().replace(/^["']|["']$/gu, ''))
953
+ .filter(Boolean)
954
+ return tokens.some((token, index) => {
955
+ const commandName = token.split('/').pop()
956
+ return ['eslint', 'eslint_d', 'standard', 'xo'].includes(commandName)
957
+ || (commandName === 'next' && tokens[index + 1] === 'lint')
958
+ })
959
+ }
960
+
961
+ function packageScriptCheck(projectDir, script) {
962
+ const manager = frontendPackageManager(projectDir)
963
+ const args = manager === 'bun' ? ['run', script] : ['run', '--silent', script]
964
+ const result = spawnSync(manager, args, {
965
+ cwd: projectDir,
966
+ encoding: 'utf8',
967
+ stdio: ['ignore', 'pipe', 'pipe']
968
+ })
969
+ if (result.error) {
970
+ return {
971
+ name: `${manager} run ${script}`,
972
+ ok: false,
973
+ message: result.error.message,
974
+ agent_instruction: `Install ${manager === 'bun' ? 'Bun' : 'Node.js and npm'}, then run \`${manager} run ${script}\` before deploying.`
975
+ }
976
+ }
977
+
978
+ return {
979
+ name: `${manager} run ${script}`,
980
+ ok: result.status === 0,
981
+ message: result.status === 0 ? 'passed' : (result.stderr || result.stdout || `${manager} run ${script} failed`).trim().slice(0, 240),
982
+ agent_instruction: `Run \`${manager} run ${script}\`, fix every error, then redeploy.`
983
+ }
984
+ }
985
+
986
+ function isSafeRelativePath(value) {
987
+ return value
988
+ && !path.isAbsolute(value)
989
+ && !value.includes('\\')
990
+ && value.split('/').every((part) => part && part !== '.' && part !== '..')
991
+ }
992
+
580
993
  function scanUnsafe(projectDir) {
581
994
  const hits = []
582
- walk(projectDir, (file) => {
995
+ walkProjectFiles(projectDir, (file, relative) => {
583
996
  if (!file.endsWith('.rs')) {
584
997
  return
585
998
  }
586
999
  const source = readFileSync(file, 'utf8')
587
1000
  if (/\bunsafe\b/u.test(source)) {
588
- hits.push(path.relative(projectDir, file))
1001
+ hits.push(relative)
589
1002
  }
590
1003
  })
591
1004
  return hits
592
1005
  }
593
1006
 
1007
+ function walkProjectFiles(projectDir, visit) {
1008
+ walk(projectDir, (file) => {
1009
+ visit(file, path.relative(projectDir, file).replace(/\\/gu, '/'))
1010
+ })
1011
+ }
1012
+
594
1013
  function walk(dir, visit) {
595
1014
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
596
- if (['.git', 'target', 'node_modules', '.zerct'].includes(entry.name)) {
1015
+ if (WALK_EXCLUDED_DIRS.has(entry.name)) {
597
1016
  continue
598
1017
  }
599
1018
  const fullPath = path.join(dir, entry.name)
@@ -626,6 +1045,20 @@ function openUrl(url) {
626
1045
  spawnSync(command, args, { stdio: 'ignore', detached: true })
627
1046
  }
628
1047
 
1048
+ function sleep(milliseconds) {
1049
+ return new Promise((resolve) => {
1050
+ setTimeout(resolve, milliseconds)
1051
+ })
1052
+ }
1053
+
1054
+ function progress(cli, message) {
1055
+ if (cli.json) {
1056
+ console.error(message)
1057
+ return
1058
+ }
1059
+ console.log(message)
1060
+ }
1061
+
629
1062
  function trimTrailingSlash(value) {
630
1063
  return value.replace(/\/+$/u, '')
631
1064
  }
@@ -666,3 +1099,14 @@ class ZerctError extends Error {
666
1099
  this.exitCode = exitCode
667
1100
  }
668
1101
  }
1102
+
1103
+ main().catch((error) => {
1104
+ if (error instanceof ZerctError) {
1105
+ printAgentError(error.payload, error.json)
1106
+ process.exitCode = error.exitCode
1107
+ return
1108
+ }
1109
+
1110
+ console.error(`zerct failed: ${error.message}`)
1111
+ process.exitCode = 1
1112
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerct/zerct",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Deploy Rust backends to Zerct.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  }