@zerct/zerct 0.1.0
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 +21 -0
- package/bin/zerct.js +652 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# zerct
|
|
2
|
+
|
|
3
|
+
Deploy Rust backends to Zerct.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @zerct/zerct deploy
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Target unscoped command, pending npm approval:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npx zerct init
|
|
13
|
+
npx zerct doctor
|
|
14
|
+
npx zerct deploy
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Zerct expects `Cargo.toml`, `Cargo.lock`, and `zerct.toml`. The app must listen
|
|
18
|
+
on `0.0.0.0:$PORT` and expose the configured health endpoint.
|
|
19
|
+
|
|
20
|
+
Use `ZERCT_TOKEN` or `npx zerct login --token <token>` for authenticated
|
|
21
|
+
commands.
|
package/bin/zerct.js
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const VERSION = '0.1.0'
|
|
8
|
+
const DEFAULT_API_URL = 'https://api.zerct.com'
|
|
9
|
+
const ARCHIVE_LIMIT_BYTES = 48 * 1024 * 1024
|
|
10
|
+
const SESSION_DIR = '.zerct'
|
|
11
|
+
const SESSION_FILE = 'session-token'
|
|
12
|
+
|
|
13
|
+
const HELP = `Zerct ${VERSION}
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
zerct init [path]
|
|
17
|
+
zerct install [path]
|
|
18
|
+
zerct doctor [path] [--json]
|
|
19
|
+
zerct login [--token <token>] [--api <url>]
|
|
20
|
+
zerct deploy [path] [--database] [--api <url>] [--json]
|
|
21
|
+
zerct logs --app <app_id> [--api <url>] [--json]
|
|
22
|
+
zerct status --app <app_id> [--api <url>] [--json]
|
|
23
|
+
zerct inspect --app <app_id> [--api <url>] [--json]
|
|
24
|
+
zerct db --app <app_id> [--api <url>] [--json]
|
|
25
|
+
zerct env set --app <app_id> KEY=value [--api <url>] [--json]
|
|
26
|
+
zerct billing [--api <url>] [--json]
|
|
27
|
+
|
|
28
|
+
Agent contract:
|
|
29
|
+
- Keep Cargo.lock committed.
|
|
30
|
+
- Keep direct unsafe out of workspace source.
|
|
31
|
+
- Listen on 0.0.0.0:$PORT.
|
|
32
|
+
- Return HTTP 200 from the configured health endpoint.
|
|
33
|
+
`
|
|
34
|
+
|
|
35
|
+
main().catch((error) => {
|
|
36
|
+
if (error instanceof ZerctError) {
|
|
37
|
+
printAgentError(error.payload, error.json)
|
|
38
|
+
process.exitCode = error.exitCode
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.error(`zerct failed: ${error.message}`)
|
|
43
|
+
process.exitCode = 1
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
const cli = parseArgs(process.argv.slice(2))
|
|
48
|
+
|
|
49
|
+
if (cli.help) {
|
|
50
|
+
console.log(HELP)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (cli.version) {
|
|
55
|
+
console.log(VERSION)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
switch (cli.command) {
|
|
60
|
+
case 'init':
|
|
61
|
+
initProject(projectPath(cli.args[0]))
|
|
62
|
+
break
|
|
63
|
+
case 'install':
|
|
64
|
+
installProject(projectPath(cli.args[0]))
|
|
65
|
+
break
|
|
66
|
+
case 'doctor':
|
|
67
|
+
doctorProject(projectPath(cli.args[0]), cli.json)
|
|
68
|
+
break
|
|
69
|
+
case 'login':
|
|
70
|
+
await login(cli)
|
|
71
|
+
break
|
|
72
|
+
case 'deploy':
|
|
73
|
+
await deploy(projectPath(cli.args[0]), cli)
|
|
74
|
+
break
|
|
75
|
+
case 'logs':
|
|
76
|
+
await logs(cli)
|
|
77
|
+
break
|
|
78
|
+
case 'status':
|
|
79
|
+
await status(cli)
|
|
80
|
+
break
|
|
81
|
+
case 'inspect':
|
|
82
|
+
await inspect(cli)
|
|
83
|
+
break
|
|
84
|
+
case 'db':
|
|
85
|
+
case 'database':
|
|
86
|
+
await database(cli)
|
|
87
|
+
break
|
|
88
|
+
case 'env':
|
|
89
|
+
await envCommand(cli)
|
|
90
|
+
break
|
|
91
|
+
case 'billing':
|
|
92
|
+
await billing(cli)
|
|
93
|
+
break
|
|
94
|
+
default:
|
|
95
|
+
throw agentError('unknown_command', 'Unknown Zerct command.', 'Run `npx zerct --help` and retry with a supported command.', cli.json)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseArgs(argv) {
|
|
100
|
+
const cli = {
|
|
101
|
+
command: 'help',
|
|
102
|
+
args: [],
|
|
103
|
+
apiUrl: DEFAULT_API_URL,
|
|
104
|
+
app: '',
|
|
105
|
+
token: '',
|
|
106
|
+
json: false,
|
|
107
|
+
database: false,
|
|
108
|
+
help: false,
|
|
109
|
+
version: false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const positional = []
|
|
113
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
114
|
+
const arg = argv[index]
|
|
115
|
+
if (arg === '--help' || arg === '-h') {
|
|
116
|
+
cli.help = true
|
|
117
|
+
} else if (arg === '--version' || arg === '-v') {
|
|
118
|
+
cli.version = true
|
|
119
|
+
} else if (arg === '--json') {
|
|
120
|
+
cli.json = true
|
|
121
|
+
} else if (arg === '--database') {
|
|
122
|
+
cli.database = true
|
|
123
|
+
} else if (arg === '--no-database') {
|
|
124
|
+
cli.database = false
|
|
125
|
+
} else if (arg === '--api') {
|
|
126
|
+
cli.apiUrl = requireValue(argv, index, '--api')
|
|
127
|
+
index += 1
|
|
128
|
+
} else if (arg === '--app') {
|
|
129
|
+
cli.app = requireValue(argv, index, '--app')
|
|
130
|
+
index += 1
|
|
131
|
+
} else if (arg === '--token') {
|
|
132
|
+
cli.token = requireValue(argv, index, '--token')
|
|
133
|
+
index += 1
|
|
134
|
+
} else {
|
|
135
|
+
positional.push(arg)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (positional.length > 0) {
|
|
140
|
+
cli.command = positional[0]
|
|
141
|
+
cli.args = positional.slice(1)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
cli.apiUrl = trimTrailingSlash(cli.apiUrl)
|
|
145
|
+
return cli
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function requireValue(argv, index, name) {
|
|
149
|
+
const value = argv[index + 1]
|
|
150
|
+
if (!value || value.startsWith('--')) {
|
|
151
|
+
throw agentError('missing_argument', `${name} requires a value.`, `Pass a value after ${name}.`, false)
|
|
152
|
+
}
|
|
153
|
+
return value
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function projectPath(value) {
|
|
157
|
+
return path.resolve(value || process.cwd())
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function initProject(projectDir) {
|
|
161
|
+
ensureDirectory(projectDir)
|
|
162
|
+
const configPath = path.join(projectDir, 'zerct.toml')
|
|
163
|
+
if (existsSync(configPath)) {
|
|
164
|
+
console.log('zerct.toml already exists')
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const name = serviceNameFromDir(projectDir)
|
|
169
|
+
const source = `name = "${name}"
|
|
170
|
+
|
|
171
|
+
[build]
|
|
172
|
+
command = "cargo build --release"
|
|
173
|
+
|
|
174
|
+
[run]
|
|
175
|
+
command = "./target/release/${name}"
|
|
176
|
+
port = 3000
|
|
177
|
+
health = "/healthz"
|
|
178
|
+
|
|
179
|
+
[resources]
|
|
180
|
+
memory = "512mb"
|
|
181
|
+
cpu = "0.25"
|
|
182
|
+
idle_timeout_minutes = 15
|
|
183
|
+
`
|
|
184
|
+
|
|
185
|
+
writeFileSync(configPath, source, { mode: 0o644 })
|
|
186
|
+
console.log(`created ${path.relative(process.cwd(), configPath)}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function installProject(projectDir) {
|
|
190
|
+
initProject(projectDir)
|
|
191
|
+
doctorProject(projectDir, false)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function doctorProject(projectDir, json) {
|
|
195
|
+
const report = runDoctor(projectDir)
|
|
196
|
+
if (json) {
|
|
197
|
+
console.log(JSON.stringify(report, null, 2))
|
|
198
|
+
if (!report.ok) {
|
|
199
|
+
process.exitCode = 1
|
|
200
|
+
}
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const check of report.checks) {
|
|
205
|
+
console.log(`${check.ok ? 'ok' : 'fail'} ${check.name}${check.message ? ` - ${check.message}` : ''}`)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!report.ok) {
|
|
209
|
+
const firstFailure = report.checks.find((check) => !check.ok)
|
|
210
|
+
throw agentError('doctor_failed', 'Zerct doctor failed.', firstFailure?.agent_instruction || 'Fix the failed checks and retry `npx zerct doctor`.', json)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function runDoctor(projectDir) {
|
|
215
|
+
const checks = []
|
|
216
|
+
const requiredFiles = ['Cargo.toml', 'Cargo.lock', 'zerct.toml']
|
|
217
|
+
for (const file of requiredFiles) {
|
|
218
|
+
const ok = existsSync(path.join(projectDir, file))
|
|
219
|
+
checks.push({
|
|
220
|
+
name: file,
|
|
221
|
+
ok,
|
|
222
|
+
message: ok ? 'found' : 'missing',
|
|
223
|
+
agent_instruction: `Create and commit ${file}, then retry.`
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let config = null
|
|
228
|
+
const configPath = path.join(projectDir, 'zerct.toml')
|
|
229
|
+
if (existsSync(configPath)) {
|
|
230
|
+
try {
|
|
231
|
+
config = parseZerctToml(readFileSync(configPath, 'utf8'))
|
|
232
|
+
validateConfig(config)
|
|
233
|
+
checks.push({ name: 'zerct.toml', ok: true, message: 'valid' })
|
|
234
|
+
} catch (error) {
|
|
235
|
+
checks.push({
|
|
236
|
+
name: 'zerct.toml',
|
|
237
|
+
ok: false,
|
|
238
|
+
message: error.message,
|
|
239
|
+
agent_instruction: 'Fix zerct.toml so it matches the Zerct deploy contract.'
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const unsafeHits = scanUnsafe(projectDir)
|
|
245
|
+
checks.push({
|
|
246
|
+
name: 'unsafe',
|
|
247
|
+
ok: unsafeHits.length === 0,
|
|
248
|
+
message: unsafeHits.length === 0 ? 'no direct unsafe found' : unsafeHits.slice(0, 5).join(', '),
|
|
249
|
+
agent_instruction: 'Remove direct unsafe usage from workspace Rust source before deploying.'
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
ok: checks.every((check) => check.ok),
|
|
254
|
+
project: projectDir,
|
|
255
|
+
config,
|
|
256
|
+
checks
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function login(cli) {
|
|
261
|
+
if (cli.token) {
|
|
262
|
+
writeSessionToken(process.cwd(), cli.token)
|
|
263
|
+
console.log('saved Zerct session token to .zerct/session-token')
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const response = await apiRequest(cli, 'POST', '/v1/login/device', null, null)
|
|
268
|
+
openUrl(response.login_url)
|
|
269
|
+
console.log(`opened ${response.login_url}`)
|
|
270
|
+
console.log('After login, retry your deploy. If the CLI cannot finish automatically yet, set ZERCT_TOKEN or run `npx zerct login --token <token>`.')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function deploy(projectDir, cli) {
|
|
274
|
+
const report = runDoctor(projectDir)
|
|
275
|
+
if (!report.ok) {
|
|
276
|
+
const firstFailure = report.checks.find((check) => !check.ok)
|
|
277
|
+
throw agentError('doctor_failed', 'Zerct doctor failed.', firstFailure?.agent_instruction || 'Fix the failed checks and retry.', cli.json)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const token = readToken(projectDir, cli)
|
|
281
|
+
const archive = createArchiveBase64(projectDir)
|
|
282
|
+
const commitSha = gitCommitSha(projectDir)
|
|
283
|
+
const body = {
|
|
284
|
+
config: report.config,
|
|
285
|
+
commit_sha: commitSha,
|
|
286
|
+
wants_database: cli.database,
|
|
287
|
+
source_archive_base64: archive
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const response = await apiRequest(cli, 'POST', '/v1/deployments', token, body)
|
|
291
|
+
if (cli.json) {
|
|
292
|
+
console.log(JSON.stringify(response, null, 2))
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
console.log(`queued ${response.build_job.id}`)
|
|
297
|
+
console.log(`app ${response.app.id}`)
|
|
298
|
+
console.log(`url ${response.app.url}`)
|
|
299
|
+
console.log(`next npx zerct logs --app ${response.app.id}`)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function logs(cli) {
|
|
303
|
+
const response = await appGet(cli, 'logs')
|
|
304
|
+
if (cli.json) {
|
|
305
|
+
console.log(JSON.stringify(response, null, 2))
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
for (const line of response.lines || []) {
|
|
309
|
+
console.log(`[${line.timestamp}] ${line.stream}: ${line.message}`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function status(cli) {
|
|
314
|
+
const response = await appGet(cli, 'status')
|
|
315
|
+
printJsonOrPretty(cli, response)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function inspect(cli) {
|
|
319
|
+
const response = await appGet(cli, 'inspect')
|
|
320
|
+
printJsonOrPretty(cli, response)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function database(cli) {
|
|
324
|
+
const response = await appGet(cli, 'database')
|
|
325
|
+
printJsonOrPretty(cli, response)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function envCommand(cli) {
|
|
329
|
+
if (cli.args[0] !== 'set') {
|
|
330
|
+
throw agentError('unknown_command', 'Unknown env command.', 'Use `npx zerct env set --app <app_id> KEY=value`.', cli.json)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const assignment = cli.args[1] || ''
|
|
334
|
+
const separator = assignment.indexOf('=')
|
|
335
|
+
if (separator <= 0) {
|
|
336
|
+
throw agentError('invalid_env', 'Environment assignment must be KEY=value.', 'Pass one uppercase shell-safe environment assignment, for example `API_KEY=value`.', cli.json)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const name = assignment.slice(0, separator)
|
|
340
|
+
const value = assignment.slice(separator + 1)
|
|
341
|
+
const token = readToken(process.cwd(), cli)
|
|
342
|
+
const app = requireApp(cli)
|
|
343
|
+
const response = await apiRequest(cli, 'PUT', `/v1/apps/${encodeURIComponent(app)}/env`, token, { name, value })
|
|
344
|
+
printJsonOrPretty(cli, response)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function billing(cli) {
|
|
348
|
+
const token = readToken(process.cwd(), cli)
|
|
349
|
+
const response = await apiRequest(cli, 'POST', '/v1/billing/checkout', token, {
|
|
350
|
+
target_plan: 'pro',
|
|
351
|
+
reason: 'Upgrade to Zerct Pro.'
|
|
352
|
+
})
|
|
353
|
+
if (cli.json) {
|
|
354
|
+
console.log(JSON.stringify(response, null, 2))
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
console.log(response.checkout.url)
|
|
358
|
+
openUrl(response.checkout.url)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function appGet(cli, kind) {
|
|
362
|
+
const token = readToken(process.cwd(), cli)
|
|
363
|
+
const app = requireApp(cli)
|
|
364
|
+
return apiRequest(cli, 'GET', `/v1/apps/${encodeURIComponent(app)}/${kind}`, token, null)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function requireApp(cli) {
|
|
368
|
+
if (!cli.app) {
|
|
369
|
+
throw agentError('missing_app', 'App id is required.', 'Pass `--app <app_id>`. Use the app id printed by `npx zerct deploy`.', cli.json)
|
|
370
|
+
}
|
|
371
|
+
return cli.app
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function apiRequest(cli, method, route, token, body) {
|
|
375
|
+
const headers = {
|
|
376
|
+
accept: 'application/json'
|
|
377
|
+
}
|
|
378
|
+
if (token) {
|
|
379
|
+
headers.authorization = `Bearer ${token}`
|
|
380
|
+
}
|
|
381
|
+
if (body !== null) {
|
|
382
|
+
headers['content-type'] = 'application/json'
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const response = await fetch(`${cli.apiUrl}${route}`, {
|
|
386
|
+
method,
|
|
387
|
+
headers,
|
|
388
|
+
body: body === null ? undefined : JSON.stringify(body)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
const text = await response.text()
|
|
392
|
+
const data = parseJson(text)
|
|
393
|
+
if (!response.ok) {
|
|
394
|
+
const payload = data && typeof data === 'object' ? data : {
|
|
395
|
+
code: 'api_error',
|
|
396
|
+
message: `Zerct API returned HTTP ${response.status}.`,
|
|
397
|
+
agent_instruction: 'Retry the command. If it keeps failing, check Zerct status before changing your project.',
|
|
398
|
+
docs_url: null,
|
|
399
|
+
checkout_url: null
|
|
400
|
+
}
|
|
401
|
+
throw new ZerctError(payload, cli.json, response.status >= 500 ? 2 : 1)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return data
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function parseJson(text) {
|
|
408
|
+
if (!text.trim()) {
|
|
409
|
+
return null
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
return JSON.parse(text)
|
|
413
|
+
} catch (_error) {
|
|
414
|
+
return null
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function createArchiveBase64(projectDir) {
|
|
419
|
+
const tar = spawnSync('tar', [
|
|
420
|
+
'--exclude=.git',
|
|
421
|
+
'--exclude=target',
|
|
422
|
+
'--exclude=node_modules',
|
|
423
|
+
'--exclude=.zerct',
|
|
424
|
+
'--exclude=.env',
|
|
425
|
+
'--exclude=.env.*',
|
|
426
|
+
'-czf',
|
|
427
|
+
'-',
|
|
428
|
+
'-C',
|
|
429
|
+
projectDir,
|
|
430
|
+
'.'
|
|
431
|
+
], {
|
|
432
|
+
encoding: 'buffer',
|
|
433
|
+
maxBuffer: ARCHIVE_LIMIT_BYTES + 1024 * 1024
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
if (tar.error) {
|
|
437
|
+
throw agentError('archive_failed', 'Could not create source archive.', 'Install `tar`, remove local build outputs, then retry `npx zerct deploy`.', false)
|
|
438
|
+
}
|
|
439
|
+
if (tar.status !== 0) {
|
|
440
|
+
throw agentError('archive_failed', 'Could not create source archive.', String(tar.stderr || 'Check project files and retry.'), false)
|
|
441
|
+
}
|
|
442
|
+
if (tar.stdout.length > ARCHIVE_LIMIT_BYTES) {
|
|
443
|
+
throw agentError('archive_too_large', 'Source archive is too large.', 'Remove build outputs, target directories, logs, and local caches before deploying.', false)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return tar.stdout.toString('base64')
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function gitCommitSha(projectDir) {
|
|
450
|
+
const git = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
451
|
+
cwd: projectDir,
|
|
452
|
+
encoding: 'utf8',
|
|
453
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
454
|
+
})
|
|
455
|
+
return git.status === 0 ? git.stdout.trim() || null : null
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function readToken(projectDir, cli) {
|
|
459
|
+
if (cli.token) {
|
|
460
|
+
return cli.token
|
|
461
|
+
}
|
|
462
|
+
if (process.env.ZERCT_TOKEN) {
|
|
463
|
+
return process.env.ZERCT_TOKEN
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const projectToken = path.join(projectDir, SESSION_DIR, SESSION_FILE)
|
|
467
|
+
if (existsSync(projectToken)) {
|
|
468
|
+
return readFileSync(projectToken, 'utf8').trim()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const homeToken = path.join(homedir(), SESSION_DIR, SESSION_FILE)
|
|
472
|
+
if (existsSync(homeToken)) {
|
|
473
|
+
return readFileSync(homeToken, 'utf8').trim()
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
throw agentError('login_required', 'Zerct login is required.', 'Run `npx zerct login`, set `ZERCT_TOKEN`, or run `npx zerct login --token <token>`, then retry.', cli.json)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function writeSessionToken(projectDir, token) {
|
|
480
|
+
const dir = path.join(projectDir, SESSION_DIR)
|
|
481
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
482
|
+
writeFileSync(path.join(dir, SESSION_FILE), `${token.trim()}\n`, { mode: 0o600 })
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function parseZerctToml(source) {
|
|
486
|
+
const config = {
|
|
487
|
+
build: {},
|
|
488
|
+
run: {},
|
|
489
|
+
resources: {}
|
|
490
|
+
}
|
|
491
|
+
let section = config
|
|
492
|
+
|
|
493
|
+
for (const rawLine of source.split(/\r?\n/u)) {
|
|
494
|
+
const line = rawLine.trim()
|
|
495
|
+
if (!line || line.startsWith('#')) {
|
|
496
|
+
continue
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const sectionMatch = line.match(/^\[([a-z_]+)\]$/u)
|
|
500
|
+
if (sectionMatch) {
|
|
501
|
+
const name = sectionMatch[1]
|
|
502
|
+
if (!['build', 'run', 'resources'].includes(name)) {
|
|
503
|
+
throw new Error(`unsupported section [${name}]`)
|
|
504
|
+
}
|
|
505
|
+
section = config[name]
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const assignment = line.match(/^([a-z_]+)\s*=\s*(.+)$/u)
|
|
510
|
+
if (!assignment) {
|
|
511
|
+
throw new Error(`invalid line: ${line}`)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
section[assignment[1]] = parseTomlValue(assignment[2])
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
config.build.command ||= 'cargo build --release'
|
|
518
|
+
config.run.port ||= 3000
|
|
519
|
+
config.run.health ||= '/healthz'
|
|
520
|
+
config.resources.memory ||= '512mb'
|
|
521
|
+
config.resources.cpu ||= '0.25'
|
|
522
|
+
config.resources.idle_timeout_minutes ||= 15
|
|
523
|
+
return config
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function parseTomlValue(raw) {
|
|
527
|
+
const value = raw.trim()
|
|
528
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
529
|
+
return value.slice(1, -1).replace(/\\"/gu, '"')
|
|
530
|
+
}
|
|
531
|
+
if (value === 'true') {
|
|
532
|
+
return true
|
|
533
|
+
}
|
|
534
|
+
if (value === 'false') {
|
|
535
|
+
return false
|
|
536
|
+
}
|
|
537
|
+
if (/^\d+$/u.test(value)) {
|
|
538
|
+
return Number(value)
|
|
539
|
+
}
|
|
540
|
+
throw new Error(`unsupported TOML value: ${value}`)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function validateConfig(config) {
|
|
544
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/u.test(config.name || '')) {
|
|
545
|
+
throw new Error('name must be lowercase DNS-safe text up to 48 characters')
|
|
546
|
+
}
|
|
547
|
+
if (!config.run.command || typeof config.run.command !== 'string') {
|
|
548
|
+
throw new Error('[run].command is required')
|
|
549
|
+
}
|
|
550
|
+
if (!Number.isInteger(config.run.port) || config.run.port < 1 || config.run.port > 65535) {
|
|
551
|
+
throw new Error('[run].port must be between 1 and 65535')
|
|
552
|
+
}
|
|
553
|
+
if (typeof config.run.health !== 'string' || !config.run.health.startsWith('/')) {
|
|
554
|
+
throw new Error('[run].health must be an absolute path')
|
|
555
|
+
}
|
|
556
|
+
if (!/^\d+\s*(mb|mib|gb|gib)$/iu.test(config.resources.memory)) {
|
|
557
|
+
throw new Error('[resources].memory must look like 512mb or 1gb')
|
|
558
|
+
}
|
|
559
|
+
if (!/^\d+(?:\.\d{1,3})?$/u.test(config.resources.cpu)) {
|
|
560
|
+
throw new Error('[resources].cpu must look like 0.25, 0.5, 1, or 2')
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function scanUnsafe(projectDir) {
|
|
565
|
+
const hits = []
|
|
566
|
+
walk(projectDir, (file) => {
|
|
567
|
+
if (!file.endsWith('.rs')) {
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
const source = readFileSync(file, 'utf8')
|
|
571
|
+
if (/\bunsafe\b/u.test(source)) {
|
|
572
|
+
hits.push(path.relative(projectDir, file))
|
|
573
|
+
}
|
|
574
|
+
})
|
|
575
|
+
return hits
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function walk(dir, visit) {
|
|
579
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
580
|
+
if (['.git', 'target', 'node_modules', '.zerct'].includes(entry.name)) {
|
|
581
|
+
continue
|
|
582
|
+
}
|
|
583
|
+
const fullPath = path.join(dir, entry.name)
|
|
584
|
+
if (entry.isDirectory()) {
|
|
585
|
+
walk(fullPath, visit)
|
|
586
|
+
} else if (entry.isFile()) {
|
|
587
|
+
visit(fullPath)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function ensureDirectory(dir) {
|
|
593
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
594
|
+
throw agentError('missing_project', 'Project directory does not exist.', 'Run Zerct from the root of a Rust project or pass the project path.', false)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function serviceNameFromDir(projectDir) {
|
|
599
|
+
const name = path.basename(projectDir).toLowerCase().replace(/[^a-z0-9-]+/gu, '-').replace(/^-+|-+$/gu, '')
|
|
600
|
+
return name || 'api'
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function printJsonOrPretty(cli, value) {
|
|
604
|
+
console.log(JSON.stringify(value, null, cli.json ? 2 : 2))
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function openUrl(url) {
|
|
608
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
|
|
609
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
610
|
+
spawnSync(command, args, { stdio: 'ignore', detached: true })
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function trimTrailingSlash(value) {
|
|
614
|
+
return value.replace(/\/+$/u, '')
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function agentError(code, message, agentInstruction, json) {
|
|
618
|
+
return new ZerctError({
|
|
619
|
+
code,
|
|
620
|
+
message,
|
|
621
|
+
agent_instruction: agentInstruction,
|
|
622
|
+
docs_url: null,
|
|
623
|
+
checkout_url: null
|
|
624
|
+
}, json, 1)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function printAgentError(payload, json) {
|
|
628
|
+
if (json) {
|
|
629
|
+
console.error(JSON.stringify(payload, null, 2))
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
console.error(payload.message || 'Zerct command failed.')
|
|
634
|
+
if (payload.agent_instruction) {
|
|
635
|
+
console.error(`agent_instruction: ${payload.agent_instruction}`)
|
|
636
|
+
}
|
|
637
|
+
if (payload.docs_url) {
|
|
638
|
+
console.error(`docs: ${payload.docs_url}`)
|
|
639
|
+
}
|
|
640
|
+
if (payload.checkout_url) {
|
|
641
|
+
console.error(`checkout: ${payload.checkout_url}`)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
class ZerctError extends Error {
|
|
646
|
+
constructor(payload, json, exitCode) {
|
|
647
|
+
super(payload.message || 'Zerct command failed.')
|
|
648
|
+
this.payload = payload
|
|
649
|
+
this.json = json
|
|
650
|
+
this.exitCode = exitCode
|
|
651
|
+
}
|
|
652
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerct/zerct",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deploy Rust backends to Zerct.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"zerct": "bin/zerct.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18.17"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"zerct",
|
|
18
|
+
"rust",
|
|
19
|
+
"deploy",
|
|
20
|
+
"backend",
|
|
21
|
+
"hosting"
|
|
22
|
+
],
|
|
23
|
+
"homepage": "https://zerct.com",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/Zerct/zerct.git",
|
|
27
|
+
"directory": "packages/zerct"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/Zerct/zerct/issues"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"scripts": {
|
|
34
|
+
"check": "node --check bin/zerct.js",
|
|
35
|
+
"pack:dry": "npm pack --dry-run"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|