@zuvo/cli 0.1.3 → 0.1.6
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 +20 -1
- package/dist/api.js +42 -24
- package/dist/argv.js +33 -0
- package/dist/hosting.js +58 -0
- package/dist/index.js +374 -7
- package/dist/secrets.js +3 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zuvo/cli
|
|
2
2
|
|
|
3
|
-
Login, link a project, deploy Edge Functions, manage secrets,
|
|
3
|
+
Login, link a project, deploy Edge Functions / App Hosting, manage secrets & hosting env, `db push`, and fetch logs against [Zuvo](https://studio.zuvodev.com).
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm i -g @zuvo/cli
|
|
@@ -13,7 +13,26 @@ zuvo secrets set --env-file ./supabase/.env.local
|
|
|
13
13
|
zuvo secrets list
|
|
14
14
|
zuvo secrets unset OPENAI_API_KEY
|
|
15
15
|
zuvo db push
|
|
16
|
+
|
|
17
|
+
# App Hosting
|
|
18
|
+
zuvo hosting apps
|
|
19
|
+
zuvo hosting deploy --wait
|
|
20
|
+
zuvo hosting deploy --commit abc1234 --app my-app
|
|
21
|
+
zuvo hosting deployments
|
|
22
|
+
zuvo hosting logs --since 6h
|
|
23
|
+
zuvo hosting logs -f
|
|
24
|
+
zuvo hosting deploy-logs
|
|
25
|
+
zuvo hosting env list
|
|
26
|
+
zuvo hosting env set API_KEY=secret FOO=bar
|
|
27
|
+
zuvo hosting env set --env-file ./.env.hosting
|
|
28
|
+
zuvo hosting env unset API_KEY
|
|
29
|
+
zuvo hosting secrets list # alias for hosting env
|
|
16
30
|
```
|
|
17
31
|
|
|
18
32
|
Token: Studio → Account → Access Tokens, or `zuvo login --token zpat_…`.
|
|
19
33
|
API override: `ZUVO_API_URL` / `--api-url` (default `https://api.zuvodev.com`).
|
|
34
|
+
|
|
35
|
+
**Notes**
|
|
36
|
+
- Hosting env keys must be `UPPER_SNAKE` (`^[A-Z][A-Z0-9_]{0,127}$`).
|
|
37
|
+
- After `hosting env set|unset`, run `zuvo hosting deploy --wait` so the container picks up new vars.
|
|
38
|
+
- `zuvo secrets` = project Edge Function secrets; `zuvo hosting env` = App Hosting container env.
|
package/dist/api.js
CHANGED
|
@@ -7,6 +7,12 @@ export class ApiError extends Error {
|
|
|
7
7
|
this.name = 'ApiError';
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
|
+
function sleep(ms) {
|
|
11
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
13
|
+
function shouldRetry(status) {
|
|
14
|
+
return status === 429 || status === 502 || status === 503 || status === 504;
|
|
15
|
+
}
|
|
10
16
|
export async function apiRequest(apiUrl, method, pathname, opts = {}) {
|
|
11
17
|
const token = opts.token ?? (await loadAccessToken());
|
|
12
18
|
const url = new URL(pathname.replace(/^\//, ''), `${apiUrl}/`);
|
|
@@ -14,34 +20,46 @@ export async function apiRequest(apiUrl, method, pathname, opts = {}) {
|
|
|
14
20
|
if (value)
|
|
15
21
|
url.searchParams.set(key, value);
|
|
16
22
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
const response = await fetch(url, { method, headers, body });
|
|
30
|
-
const text = await response.text();
|
|
31
|
-
let parsed = text;
|
|
32
|
-
if (text) {
|
|
33
|
-
try {
|
|
34
|
-
parsed = JSON.parse(text);
|
|
23
|
+
// Control-plane GoTrue/PostgREST rate-limits batch deploys; retry longer.
|
|
24
|
+
const maxAttempts = Math.max(1, (opts.retries ?? 8) + 1);
|
|
25
|
+
let lastError;
|
|
26
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
27
|
+
const headers = {
|
|
28
|
+
authorization: `Bearer ${token}`,
|
|
29
|
+
accept: 'application/json',
|
|
30
|
+
};
|
|
31
|
+
let body;
|
|
32
|
+
if (opts.form) {
|
|
33
|
+
body = opts.form;
|
|
35
34
|
}
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
else if (opts.json !== undefined) {
|
|
36
|
+
headers['content-type'] = 'application/json';
|
|
37
|
+
body = JSON.stringify(opts.json);
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const response = await fetch(url, { method, headers, body });
|
|
40
|
+
const text = await response.text();
|
|
41
|
+
let parsed = text;
|
|
42
|
+
if (text) {
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(text);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
parsed = text;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (response.ok)
|
|
51
|
+
return parsed;
|
|
41
52
|
const message = parsed && typeof parsed === 'object' && parsed !== null && 'message' in parsed
|
|
42
53
|
? String(parsed.message)
|
|
43
54
|
: text || `HTTP ${response.status}`;
|
|
44
|
-
|
|
55
|
+
lastError = new ApiError(response.status, message);
|
|
56
|
+
if (!shouldRetry(response.status) || attempt === maxAttempts)
|
|
57
|
+
break;
|
|
58
|
+
const retryAfter = Number(response.headers.get('retry-after') || '');
|
|
59
|
+
const backoffMs = Number.isFinite(retryAfter) && retryAfter > 0
|
|
60
|
+
? Math.max(retryAfter * 1000, 2_000)
|
|
61
|
+
: Math.min(20_000, 1_000 * 2 ** (attempt - 1));
|
|
62
|
+
await sleep(backoffMs);
|
|
45
63
|
}
|
|
46
|
-
|
|
64
|
+
throw lastError || new ApiError(500, 'Request failed');
|
|
47
65
|
}
|
package/dist/argv.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const GLOBAL_VALUE_FLAGS = new Set(['--api-url']);
|
|
2
|
+
const GLOBAL_BOOL_FLAGS = new Set(['-h', '--help']);
|
|
3
|
+
/**
|
|
4
|
+
* Slice argv after `zuvo <verbs…>`, keeping subcommand flags.
|
|
5
|
+
*
|
|
6
|
+
* Global `parseArgs({ strict: false })` drops unknown options like
|
|
7
|
+
* `--env-file` but leaves the path as a positional — so secrets set
|
|
8
|
+
* thought the file path was a secret name. Rebuild from the raw argv
|
|
9
|
+
* instead of relying on global positionals for flag-bearing commands.
|
|
10
|
+
*/
|
|
11
|
+
export function argvAfterCommand(argv, ...verbs) {
|
|
12
|
+
const stripped = [];
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const arg = argv[i];
|
|
15
|
+
if (GLOBAL_BOOL_FLAGS.has(arg))
|
|
16
|
+
continue;
|
|
17
|
+
if (GLOBAL_VALUE_FLAGS.has(arg)) {
|
|
18
|
+
i += 1;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (arg.startsWith('--api-url='))
|
|
22
|
+
continue;
|
|
23
|
+
stripped.push(arg);
|
|
24
|
+
}
|
|
25
|
+
let idx = 0;
|
|
26
|
+
for (const verb of verbs) {
|
|
27
|
+
if (stripped[idx] === verb)
|
|
28
|
+
idx += 1;
|
|
29
|
+
else
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
return stripped.slice(idx);
|
|
33
|
+
}
|
package/dist/hosting.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Parse `--since 30m|2h|1d` or an ISO timestamp into a Date. */
|
|
2
|
+
export function parseSince(input, now = Date.now()) {
|
|
3
|
+
const trimmed = input.trim();
|
|
4
|
+
const relative = trimmed.match(/^(\d+)\s*(s|m|h|d)$/i);
|
|
5
|
+
if (relative) {
|
|
6
|
+
const amount = Number(relative[1]);
|
|
7
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
8
|
+
throw new Error(`Invalid --since: ${input}`);
|
|
9
|
+
}
|
|
10
|
+
const unit = relative[2].toLowerCase();
|
|
11
|
+
const ms = unit === 's'
|
|
12
|
+
? amount * 1_000
|
|
13
|
+
: unit === 'm'
|
|
14
|
+
? amount * 60_000
|
|
15
|
+
: unit === 'h'
|
|
16
|
+
? amount * 3_600_000
|
|
17
|
+
: amount * 86_400_000;
|
|
18
|
+
return new Date(now - ms);
|
|
19
|
+
}
|
|
20
|
+
const absolute = new Date(trimmed);
|
|
21
|
+
if (Number.isNaN(absolute.getTime())) {
|
|
22
|
+
throw new Error(`Invalid --since: ${input} (use 30m, 2h, 1d, or ISO timestamp)`);
|
|
23
|
+
}
|
|
24
|
+
return absolute;
|
|
25
|
+
}
|
|
26
|
+
export function formatLogTimestamp(value) {
|
|
27
|
+
if (value == null || value === '')
|
|
28
|
+
return '';
|
|
29
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
30
|
+
let ms;
|
|
31
|
+
if (Number.isFinite(n)) {
|
|
32
|
+
// Studio analytics returns microseconds when numeric.
|
|
33
|
+
ms = n > 1e14 ? Math.floor(n / 1000) : n;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
ms = new Date(String(value)).getTime();
|
|
37
|
+
}
|
|
38
|
+
if (!Number.isFinite(ms))
|
|
39
|
+
return String(value);
|
|
40
|
+
return new Date(ms).toISOString();
|
|
41
|
+
}
|
|
42
|
+
/** Oldest → newest for terminal reading. */
|
|
43
|
+
export function sortLogsChronological(rows) {
|
|
44
|
+
return [...rows].sort((a, b) => {
|
|
45
|
+
const ta = formatLogTimestamp(a.timestamp);
|
|
46
|
+
const tb = formatLogTimestamp(b.timestamp);
|
|
47
|
+
return ta.localeCompare(tb);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function formatHostingLogLine(row) {
|
|
51
|
+
const ts = formatLogTimestamp(row.timestamp);
|
|
52
|
+
const msg = row.event_message ?? '';
|
|
53
|
+
return ts ? `${ts} ${msg}` : msg;
|
|
54
|
+
}
|
|
55
|
+
export function hostingLogsSql(limit) {
|
|
56
|
+
const safe = Math.min(Math.max(1, Math.floor(limit)), 5_000);
|
|
57
|
+
return `select id, timestamp, event_message from hosting_logs order by timestamp desc limit ${safe}`;
|
|
58
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { apiRequest, ApiError } from './api.js';
|
|
4
|
+
import { argvAfterCommand } from './argv.js';
|
|
4
5
|
import { apiUrlFromEnv, deleteAccessToken, isAccessToken, requireLinkedRef, saveLinkedRef, } from './config.js';
|
|
5
6
|
import { encodeFunctionForm, listFunctionSlugs, loadFunctionBundle } from './functions.js';
|
|
7
|
+
import { formatHostingLogLine, formatLogTimestamp, hostingLogsSql, parseSince, sortLogsChronological, } from './hosting.js';
|
|
6
8
|
import { loginBrowser, loginWithToken } from './login.js';
|
|
7
9
|
import { loadLocalMigrations, pendingMigrations } from './migrations.js';
|
|
8
10
|
import { loadSecretsEnvFile, parseSecretArgs } from './secrets.js';
|
|
@@ -21,6 +23,15 @@ Commands:
|
|
|
21
23
|
secrets set --env-file <path>
|
|
22
24
|
secrets unset NAME [NAME ...]
|
|
23
25
|
db push
|
|
26
|
+
hosting apps
|
|
27
|
+
hosting deployments [--app <appId|slug>]
|
|
28
|
+
hosting deploy [--app <appId|slug>] [--commit <sha>] [--wait]
|
|
29
|
+
hosting logs [--limit N] [--since 1h|30m|ISO] [-f|--follow]
|
|
30
|
+
hosting deploy-logs [deploymentId] [--app <appId|slug>]
|
|
31
|
+
hosting env list [--app <appId|slug>]
|
|
32
|
+
hosting env set NAME=VALUE [...] [--env-file <path>] [--app <appId|slug>]
|
|
33
|
+
hosting env unset NAME [...] [--app <appId|slug>]
|
|
34
|
+
hosting secrets … Alias for hosting env …
|
|
24
35
|
|
|
25
36
|
Global:
|
|
26
37
|
--api-url <url> Default https://api.zuvodev.com (or ZUVO_API_URL)
|
|
@@ -147,8 +158,28 @@ async function cmdFunctionsDeploy(apiUrl, argv) {
|
|
|
147
158
|
if (!slugs.length) {
|
|
148
159
|
throw new Error('No functions to deploy (expected supabase/functions/<slug>).');
|
|
149
160
|
}
|
|
150
|
-
|
|
151
|
-
|
|
161
|
+
const failures = [];
|
|
162
|
+
for (let i = 0; i < slugs.length; i++) {
|
|
163
|
+
const name = slugs[i];
|
|
164
|
+
try {
|
|
165
|
+
await deployOne(apiUrl, ref, name);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
169
|
+
failures.push({ slug: name, message });
|
|
170
|
+
console.error(`Failed ${name}: ${message}`);
|
|
171
|
+
if (slug)
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
// Space out multi-deploys so PostgREST/GoTrue do not rate-limit the next auth.
|
|
175
|
+
if (i < slugs.length - 1) {
|
|
176
|
+
await new Promise((r) => setTimeout(r, 1_500));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (failures.length) {
|
|
180
|
+
throw new Error(`Deploy finished with ${failures.length} failure(s): ${failures
|
|
181
|
+
.map((f) => f.slug)
|
|
182
|
+
.join(', ')}`);
|
|
152
183
|
}
|
|
153
184
|
}
|
|
154
185
|
async function cmdSecretsList(apiUrl) {
|
|
@@ -222,6 +253,330 @@ async function cmdDbPush(apiUrl) {
|
|
|
222
253
|
console.log(`Applied ${migration.filename}`);
|
|
223
254
|
}
|
|
224
255
|
}
|
|
256
|
+
async function listHostingApps(apiUrl, ref) {
|
|
257
|
+
const data = await apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps`);
|
|
258
|
+
return Array.isArray(data?.apps) ? data.apps : [];
|
|
259
|
+
}
|
|
260
|
+
async function resolveHostingApp(apiUrl, ref, appId) {
|
|
261
|
+
const apps = await listHostingApps(apiUrl, ref);
|
|
262
|
+
if (!apps.length)
|
|
263
|
+
throw new Error('No hosting apps on this project.');
|
|
264
|
+
if (appId) {
|
|
265
|
+
const match = apps.find((app) => app.id === appId || app.slug === appId || app.name === appId);
|
|
266
|
+
if (!match)
|
|
267
|
+
throw new Error(`Hosting app not found: ${appId}`);
|
|
268
|
+
return match;
|
|
269
|
+
}
|
|
270
|
+
if (apps.length === 1)
|
|
271
|
+
return apps[0];
|
|
272
|
+
throw new Error(`Multiple hosting apps — pass --app <id|slug>. Available: ${apps
|
|
273
|
+
.map((app) => app.slug || app.id)
|
|
274
|
+
.join(', ')}`);
|
|
275
|
+
}
|
|
276
|
+
async function listHostingDeployments(apiUrl, ref, appId) {
|
|
277
|
+
const data = await apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps/${appId}/deployments`);
|
|
278
|
+
return Array.isArray(data?.deployments) ? data.deployments : [];
|
|
279
|
+
}
|
|
280
|
+
async function cmdHostingApps(apiUrl) {
|
|
281
|
+
const ref = await requireLinkedRef();
|
|
282
|
+
const apps = await listHostingApps(apiUrl, ref);
|
|
283
|
+
if (!apps.length) {
|
|
284
|
+
console.log('No hosting apps.');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
for (const app of apps) {
|
|
288
|
+
console.log([app.id, app.slug || app.name || '', app.status || '', app.branch || '', app.public_url || '']
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.join('\t'));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async function cmdHostingDeployments(apiUrl, argv) {
|
|
294
|
+
const { values } = parseArgs({
|
|
295
|
+
args: argv,
|
|
296
|
+
options: {
|
|
297
|
+
app: { type: 'string' },
|
|
298
|
+
'api-url': { type: 'string' },
|
|
299
|
+
},
|
|
300
|
+
allowPositionals: true,
|
|
301
|
+
strict: false,
|
|
302
|
+
});
|
|
303
|
+
const ref = await requireLinkedRef();
|
|
304
|
+
const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
|
|
305
|
+
const deployments = await listHostingDeployments(apiUrl, ref, app.id);
|
|
306
|
+
if (!deployments.length) {
|
|
307
|
+
console.log('No deployments.');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
for (const dep of deployments) {
|
|
311
|
+
console.log([
|
|
312
|
+
dep.id,
|
|
313
|
+
dep.status || '',
|
|
314
|
+
dep.commit_sha ? String(dep.commit_sha).slice(0, 12) : '',
|
|
315
|
+
dep.created_at || '',
|
|
316
|
+
]
|
|
317
|
+
.filter(Boolean)
|
|
318
|
+
.join('\t'));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async function fetchHostingRuntimeLogs(apiUrl, ref, opts) {
|
|
322
|
+
const body = {
|
|
323
|
+
sql: hostingLogsSql(opts.limit),
|
|
324
|
+
};
|
|
325
|
+
if (opts.since)
|
|
326
|
+
body.iso_timestamp_start = opts.since.toISOString();
|
|
327
|
+
const data = await apiRequest(apiUrl, 'POST', `/platform/projects/${ref}/analytics/endpoints/logs.all`, { json: body });
|
|
328
|
+
return Array.isArray(data?.result) ? data.result : [];
|
|
329
|
+
}
|
|
330
|
+
async function cmdHostingLogs(apiUrl, argv) {
|
|
331
|
+
const { values } = parseArgs({
|
|
332
|
+
args: argv,
|
|
333
|
+
options: {
|
|
334
|
+
limit: { type: 'string' },
|
|
335
|
+
since: { type: 'string' },
|
|
336
|
+
follow: { type: 'boolean', short: 'f' },
|
|
337
|
+
'api-url': { type: 'string' },
|
|
338
|
+
},
|
|
339
|
+
allowPositionals: true,
|
|
340
|
+
strict: false,
|
|
341
|
+
});
|
|
342
|
+
const ref = await requireLinkedRef();
|
|
343
|
+
const limitRaw = typeof values.limit === 'string' ? Number(values.limit) : 100;
|
|
344
|
+
const limit = Number.isFinite(limitRaw) ? limitRaw : 100;
|
|
345
|
+
const since = typeof values.since === 'string' && values.since.trim()
|
|
346
|
+
? parseSince(values.since)
|
|
347
|
+
: parseSince('1h');
|
|
348
|
+
const follow = Boolean(values.follow);
|
|
349
|
+
const seen = new Set();
|
|
350
|
+
const printNew = (rows) => {
|
|
351
|
+
for (const row of sortLogsChronological(rows)) {
|
|
352
|
+
const key = row.id || `${formatHostingLogLine(row)}`;
|
|
353
|
+
if (seen.has(key))
|
|
354
|
+
continue;
|
|
355
|
+
seen.add(key);
|
|
356
|
+
console.log(formatHostingLogLine(row));
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const initial = await fetchHostingRuntimeLogs(apiUrl, ref, { limit, since });
|
|
360
|
+
if (!initial.length && !follow) {
|
|
361
|
+
console.log('No hosting logs in range.');
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
printNew(initial);
|
|
365
|
+
if (!follow)
|
|
366
|
+
return;
|
|
367
|
+
let cursor = since;
|
|
368
|
+
for (const row of sortLogsChronological(initial)) {
|
|
369
|
+
const iso = formatLogTimestamp(row.timestamp);
|
|
370
|
+
const parsed = iso ? new Date(iso) : null;
|
|
371
|
+
if (parsed && !Number.isNaN(parsed.getTime()) && parsed > cursor)
|
|
372
|
+
cursor = parsed;
|
|
373
|
+
}
|
|
374
|
+
console.error('Following hosting logs… (Ctrl+C to stop)');
|
|
375
|
+
for (;;) {
|
|
376
|
+
await new Promise((r) => setTimeout(r, 2_500));
|
|
377
|
+
const nextSince = new Date(Math.max(0, cursor.getTime() - 1_000));
|
|
378
|
+
const rows = await fetchHostingRuntimeLogs(apiUrl, ref, {
|
|
379
|
+
limit: Math.max(limit, 200),
|
|
380
|
+
since: nextSince,
|
|
381
|
+
});
|
|
382
|
+
printNew(rows);
|
|
383
|
+
for (const row of sortLogsChronological(rows)) {
|
|
384
|
+
const iso = formatLogTimestamp(row.timestamp);
|
|
385
|
+
const parsed = iso ? new Date(iso) : null;
|
|
386
|
+
if (parsed && !Number.isNaN(parsed.getTime()) && parsed > cursor)
|
|
387
|
+
cursor = parsed;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function cmdHostingDeployLogs(apiUrl, argv) {
|
|
392
|
+
const { values, positionals } = parseArgs({
|
|
393
|
+
args: argv,
|
|
394
|
+
options: {
|
|
395
|
+
app: { type: 'string' },
|
|
396
|
+
'api-url': { type: 'string' },
|
|
397
|
+
},
|
|
398
|
+
allowPositionals: true,
|
|
399
|
+
strict: false,
|
|
400
|
+
});
|
|
401
|
+
const ref = await requireLinkedRef();
|
|
402
|
+
const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
|
|
403
|
+
let deploymentId = positionals.find((arg) => !arg.startsWith('-')) || '';
|
|
404
|
+
if (!deploymentId) {
|
|
405
|
+
const deployments = await listHostingDeployments(apiUrl, ref, app.id);
|
|
406
|
+
if (!deployments.length)
|
|
407
|
+
throw new Error('No deployments found.');
|
|
408
|
+
deploymentId = deployments[0].id;
|
|
409
|
+
console.error(`Using latest deployment ${deploymentId} (${deployments[0].status || 'unknown'})`);
|
|
410
|
+
}
|
|
411
|
+
const data = await apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps/${app.id}/deployments/${deploymentId}/logs`);
|
|
412
|
+
const log = data?.log || '';
|
|
413
|
+
if (!log) {
|
|
414
|
+
console.log(`(empty build log) status=${data?.status || 'unknown'}`);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
process.stdout.write(log.endsWith('\n') ? log : `${log}\n`);
|
|
418
|
+
}
|
|
419
|
+
const HOSTING_ENV_KEY = /^[A-Z][A-Z0-9_]{0,127}$/;
|
|
420
|
+
function assertHostingEnvKey(key) {
|
|
421
|
+
if (!HOSTING_ENV_KEY.test(key)) {
|
|
422
|
+
throw new Error(`Invalid env key: ${key} (must match /^[A-Z][A-Z0-9_]{0,127}$/)`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async function getDeploymentStatus(apiUrl, ref, appId, deploymentId) {
|
|
426
|
+
return apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps/${appId}/deployments/${deploymentId}/logs`);
|
|
427
|
+
}
|
|
428
|
+
async function cmdHostingDeploy(apiUrl, argv) {
|
|
429
|
+
const { values } = parseArgs({
|
|
430
|
+
args: argv,
|
|
431
|
+
options: {
|
|
432
|
+
app: { type: 'string' },
|
|
433
|
+
commit: { type: 'string' },
|
|
434
|
+
wait: { type: 'boolean' },
|
|
435
|
+
'api-url': { type: 'string' },
|
|
436
|
+
},
|
|
437
|
+
allowPositionals: true,
|
|
438
|
+
strict: false,
|
|
439
|
+
});
|
|
440
|
+
const ref = await requireLinkedRef();
|
|
441
|
+
const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
|
|
442
|
+
const commitSha = typeof values.commit === 'string' && values.commit.trim()
|
|
443
|
+
? values.commit.trim()
|
|
444
|
+
: undefined;
|
|
445
|
+
const data = await apiRequest(apiUrl, 'POST', `/platform/projects/${ref}/hosting/apps/${app.id}/deploy`, {
|
|
446
|
+
json: commitSha ? { commit_sha: commitSha } : {},
|
|
447
|
+
});
|
|
448
|
+
const deployment = data?.deployment;
|
|
449
|
+
if (!deployment?.id)
|
|
450
|
+
throw new Error('Deploy enqueue failed (no deployment id).');
|
|
451
|
+
console.log(`Queued deploy ${deployment.id} for ${app.slug || app.id} (${deployment.status || 'queued'})`);
|
|
452
|
+
if (!values.wait) {
|
|
453
|
+
console.error(`Tip: zuvo hosting deploy-logs ${deployment.id} --app ${app.slug || app.id}`);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
console.error('Waiting for deploy…');
|
|
457
|
+
const started = Date.now();
|
|
458
|
+
const timeoutMs = 20 * 60_000;
|
|
459
|
+
let lastStatus = deployment.status || 'queued';
|
|
460
|
+
for (;;) {
|
|
461
|
+
if (Date.now() - started > timeoutMs) {
|
|
462
|
+
throw new Error(`Timed out waiting for deploy (last status: ${lastStatus})`);
|
|
463
|
+
}
|
|
464
|
+
await new Promise((r) => setTimeout(r, 3_000));
|
|
465
|
+
const statusRes = await getDeploymentStatus(apiUrl, ref, app.id, deployment.id);
|
|
466
|
+
const status = statusRes.status || 'unknown';
|
|
467
|
+
if (status !== lastStatus) {
|
|
468
|
+
console.error(`status: ${status}`);
|
|
469
|
+
lastStatus = status;
|
|
470
|
+
}
|
|
471
|
+
if (status === 'ready') {
|
|
472
|
+
console.log(`Deploy ready: ${deployment.id}`);
|
|
473
|
+
if (app.public_url)
|
|
474
|
+
console.log(app.public_url);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (status === 'failed') {
|
|
478
|
+
const log = statusRes.log || '';
|
|
479
|
+
if (log)
|
|
480
|
+
process.stderr.write(log.endsWith('\n') ? log : `${log}\n`);
|
|
481
|
+
throw new Error(`Deploy failed: ${deployment.id}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async function cmdHostingEnvList(apiUrl, argv) {
|
|
486
|
+
const { values } = parseArgs({
|
|
487
|
+
args: argv,
|
|
488
|
+
options: {
|
|
489
|
+
app: { type: 'string' },
|
|
490
|
+
'api-url': { type: 'string' },
|
|
491
|
+
},
|
|
492
|
+
allowPositionals: true,
|
|
493
|
+
strict: false,
|
|
494
|
+
});
|
|
495
|
+
const ref = await requireLinkedRef();
|
|
496
|
+
const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
|
|
497
|
+
const data = await apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps/${app.id}/env`);
|
|
498
|
+
const auto = Array.isArray(data?.auto) ? data.auto : [];
|
|
499
|
+
const custom = Array.isArray(data?.custom) ? data.custom : [];
|
|
500
|
+
if (!auto.length && !custom.length) {
|
|
501
|
+
console.log('No env vars.');
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
for (const row of auto) {
|
|
505
|
+
console.log([row.key, row.value || '(auto)', 'auto'].filter(Boolean).join('\t'));
|
|
506
|
+
}
|
|
507
|
+
for (const row of custom) {
|
|
508
|
+
console.log([row.key, row.value || '********', 'custom'].filter(Boolean).join('\t'));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async function cmdHostingEnvSet(apiUrl, argv) {
|
|
512
|
+
const { values, positionals } = parseArgs({
|
|
513
|
+
args: argv,
|
|
514
|
+
options: {
|
|
515
|
+
app: { type: 'string' },
|
|
516
|
+
'env-file': { type: 'string' },
|
|
517
|
+
'api-url': { type: 'string' },
|
|
518
|
+
},
|
|
519
|
+
allowPositionals: true,
|
|
520
|
+
strict: false,
|
|
521
|
+
});
|
|
522
|
+
const ref = await requireLinkedRef();
|
|
523
|
+
const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
|
|
524
|
+
const fromFile = typeof values['env-file'] === 'string' && values['env-file'].trim()
|
|
525
|
+
? await loadSecretsEnvFile(values['env-file'].trim())
|
|
526
|
+
: [];
|
|
527
|
+
const fromArgs = parseSecretArgs(positionals);
|
|
528
|
+
const pairs = [...fromFile, ...fromArgs];
|
|
529
|
+
if (!pairs.length) {
|
|
530
|
+
throw new Error('Usage: zuvo hosting env set NAME=VALUE [...] | --env-file <path> [--app <id|slug>]');
|
|
531
|
+
}
|
|
532
|
+
for (const pair of pairs) {
|
|
533
|
+
assertHostingEnvKey(pair.name);
|
|
534
|
+
}
|
|
535
|
+
await apiRequest(apiUrl, 'PUT', `/platform/projects/${ref}/hosting/apps/${app.id}/env`, {
|
|
536
|
+
json: { vars: pairs.map((p) => ({ key: p.name, value: p.value })) },
|
|
537
|
+
});
|
|
538
|
+
console.log(`Set ${pairs.map((p) => p.name).join(', ')} on ${app.slug || app.id}`);
|
|
539
|
+
console.error('Redeploy to apply env changes: zuvo hosting deploy --wait');
|
|
540
|
+
}
|
|
541
|
+
async function cmdHostingEnvUnset(apiUrl, argv) {
|
|
542
|
+
const { values, positionals } = parseArgs({
|
|
543
|
+
args: argv,
|
|
544
|
+
options: {
|
|
545
|
+
app: { type: 'string' },
|
|
546
|
+
'api-url': { type: 'string' },
|
|
547
|
+
},
|
|
548
|
+
allowPositionals: true,
|
|
549
|
+
strict: false,
|
|
550
|
+
});
|
|
551
|
+
const names = positionals
|
|
552
|
+
.filter((arg) => !arg.startsWith('-'))
|
|
553
|
+
.map((n) => n.trim())
|
|
554
|
+
.filter(Boolean);
|
|
555
|
+
if (!names.length) {
|
|
556
|
+
throw new Error('Usage: zuvo hosting env unset NAME [NAME ...] [--app <id|slug>]');
|
|
557
|
+
}
|
|
558
|
+
for (const name of names)
|
|
559
|
+
assertHostingEnvKey(name);
|
|
560
|
+
const ref = await requireLinkedRef();
|
|
561
|
+
const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
|
|
562
|
+
await apiRequest(apiUrl, 'DELETE', `/platform/projects/${ref}/hosting/apps/${app.id}/env`, {
|
|
563
|
+
json: { keys: names },
|
|
564
|
+
});
|
|
565
|
+
console.log(`Unset ${names.join(', ')} on ${app.slug || app.id}`);
|
|
566
|
+
console.error('Redeploy to apply env changes: zuvo hosting deploy --wait');
|
|
567
|
+
}
|
|
568
|
+
async function cmdHostingEnv(apiUrl, argv) {
|
|
569
|
+
const [action, ...rest] = argv;
|
|
570
|
+
if (action === 'list' || !action)
|
|
571
|
+
await cmdHostingEnvList(apiUrl, rest);
|
|
572
|
+
else if (action === 'set')
|
|
573
|
+
await cmdHostingEnvSet(apiUrl, rest);
|
|
574
|
+
else if (action === 'unset' || action === 'delete')
|
|
575
|
+
await cmdHostingEnvUnset(apiUrl, rest);
|
|
576
|
+
else {
|
|
577
|
+
throw new Error('Usage: zuvo hosting env list|set|unset … (alias: zuvo hosting secrets …)');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
225
580
|
async function main() {
|
|
226
581
|
const argv = process.argv.slice(2);
|
|
227
582
|
const global = parseGlobal(argv);
|
|
@@ -229,7 +584,7 @@ async function main() {
|
|
|
229
584
|
console.log(usage());
|
|
230
585
|
return;
|
|
231
586
|
}
|
|
232
|
-
const [command, sub
|
|
587
|
+
const [command, sub] = global.positionals;
|
|
233
588
|
const apiUrl = global.apiUrl;
|
|
234
589
|
try {
|
|
235
590
|
if (command === 'login')
|
|
@@ -243,15 +598,27 @@ async function main() {
|
|
|
243
598
|
else if (command === 'functions' && sub === 'list')
|
|
244
599
|
await cmdFunctionsList(apiUrl);
|
|
245
600
|
else if (command === 'functions' && sub === 'deploy')
|
|
246
|
-
await cmdFunctionsDeploy(apiUrl,
|
|
601
|
+
await cmdFunctionsDeploy(apiUrl, argvAfterCommand(argv, 'functions', 'deploy'));
|
|
247
602
|
else if (command === 'secrets' && (sub === 'list' || !sub))
|
|
248
603
|
await cmdSecretsList(apiUrl);
|
|
249
604
|
else if (command === 'secrets' && sub === 'set')
|
|
250
|
-
await cmdSecretsSet(apiUrl,
|
|
605
|
+
await cmdSecretsSet(apiUrl, argvAfterCommand(argv, 'secrets', 'set'));
|
|
251
606
|
else if (command === 'secrets' && (sub === 'unset' || sub === 'delete'))
|
|
252
|
-
await cmdSecretsUnset(apiUrl,
|
|
607
|
+
await cmdSecretsUnset(apiUrl, argvAfterCommand(argv, 'secrets', sub));
|
|
253
608
|
else if (command === 'db' && sub === 'push')
|
|
254
609
|
await cmdDbPush(apiUrl);
|
|
610
|
+
else if (command === 'hosting' && (sub === 'apps' || sub === 'list'))
|
|
611
|
+
await cmdHostingApps(apiUrl);
|
|
612
|
+
else if (command === 'hosting' && sub === 'deployments')
|
|
613
|
+
await cmdHostingDeployments(apiUrl, argvAfterCommand(argv, 'hosting', 'deployments'));
|
|
614
|
+
else if (command === 'hosting' && sub === 'deploy')
|
|
615
|
+
await cmdHostingDeploy(apiUrl, argvAfterCommand(argv, 'hosting', 'deploy'));
|
|
616
|
+
else if (command === 'hosting' && sub === 'logs')
|
|
617
|
+
await cmdHostingLogs(apiUrl, argvAfterCommand(argv, 'hosting', 'logs'));
|
|
618
|
+
else if (command === 'hosting' && (sub === 'deploy-logs' || sub === 'build-logs'))
|
|
619
|
+
await cmdHostingDeployLogs(apiUrl, argvAfterCommand(argv, 'hosting', sub));
|
|
620
|
+
else if (command === 'hosting' && (sub === 'env' || sub === 'secrets'))
|
|
621
|
+
await cmdHostingEnv(apiUrl, argvAfterCommand(argv, 'hosting', sub));
|
|
255
622
|
else {
|
|
256
623
|
console.error(usage());
|
|
257
624
|
process.exit(command ? 1 : 0);
|
|
@@ -259,7 +626,7 @@ async function main() {
|
|
|
259
626
|
}
|
|
260
627
|
catch (error) {
|
|
261
628
|
if (error instanceof ApiError && error.status === 403) {
|
|
262
|
-
fail('Forbidden. Functions, secrets,
|
|
629
|
+
fail('Forbidden. Functions, secrets, db push, and hosting require owner, admin, or developer.');
|
|
263
630
|
}
|
|
264
631
|
fail(error);
|
|
265
632
|
}
|
package/dist/secrets.js
CHANGED
|
@@ -17,6 +17,9 @@ export function parseSecretArgs(args) {
|
|
|
17
17
|
}
|
|
18
18
|
const next = args[i + 1];
|
|
19
19
|
if (!next || next.startsWith('-') || next.includes('=')) {
|
|
20
|
+
if (/[./]/.test(arg) || arg.endsWith('.env') || arg.includes('.env.')) {
|
|
21
|
+
throw new Error(`Missing value for ${arg}. To load a file use: zuvo secrets set --env-file ${arg}`);
|
|
22
|
+
}
|
|
20
23
|
throw new Error(`Missing value for secret ${arg} (use NAME=VALUE)`);
|
|
21
24
|
}
|
|
22
25
|
out.push({ name: arg.trim(), value: next });
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zuvo/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Zuvo CLI — login, link, functions deploy, secrets,
|
|
5
|
+
"description": "Zuvo CLI — login, link, functions/hosting deploy, secrets, env, db push, and logs",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
8
|
"zuvo": "bin/zuvo.js"
|