@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.
- package/README.md +6 -5
- package/bin/zerct.js +497 -53
- 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
|
|
13
|
-
npx
|
|
14
|
-
npx
|
|
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
|
-
|
|
21
|
-
|
|
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.
|
|
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
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
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:
|
|
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(
|
|
290
|
-
console.log('saved Zerct session token
|
|
363
|
+
writeSessionToken(cli.token)
|
|
364
|
+
console.log('saved Zerct session token')
|
|
291
365
|
return
|
|
292
366
|
}
|
|
293
367
|
|
|
294
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
646
|
+
function readTokenFile(filePath) {
|
|
647
|
+
if (!existsSync(filePath)) {
|
|
648
|
+
return ''
|
|
649
|
+
}
|
|
650
|
+
return readFileSync(filePath, 'utf8').trim()
|
|
493
651
|
}
|
|
494
652
|
|
|
495
|
-
function
|
|
496
|
-
const dir = path.
|
|
653
|
+
function writeTokenFile(filePath, token) {
|
|
654
|
+
const dir = path.dirname(filePath)
|
|
497
655
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
498
|
-
writeFileSync(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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.
|
|
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
|
}
|