@zuvo/cli 0.1.4 → 0.1.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zuvo/cli
2
2
 
3
- Login, link a project, deploy Edge Functions, manage secrets, and `db push` against [Zuvo](https://studio.zuvodev.com).
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
@@ -8,12 +8,34 @@ zuvo login
8
8
  zuvo projects list
9
9
  zuvo link --project <ref>
10
10
  zuvo functions deploy
11
+ zuvo functions logs
12
+ zuvo functions logs hello --since 6h
13
+ zuvo functions logs -f
11
14
  zuvo secrets set OPENAI_API_KEY=sk-…
12
15
  zuvo secrets set --env-file ./supabase/.env.local
13
16
  zuvo secrets list
14
17
  zuvo secrets unset OPENAI_API_KEY
15
18
  zuvo db push
19
+
20
+ # App Hosting
21
+ zuvo hosting apps
22
+ zuvo hosting deploy --wait
23
+ zuvo hosting deploy --commit abc1234 --app my-app
24
+ zuvo hosting deployments
25
+ zuvo hosting logs --since 6h
26
+ zuvo hosting logs -f
27
+ zuvo hosting deploy-logs
28
+ zuvo hosting env list
29
+ zuvo hosting env set API_KEY=secret FOO=bar
30
+ zuvo hosting env set --env-file ./.env.hosting
31
+ zuvo hosting env unset API_KEY
32
+ zuvo hosting secrets list # alias for hosting env
16
33
  ```
17
34
 
18
35
  Token: Studio → Account → Access Tokens, or `zuvo login --token zpat_…`.
19
36
  API override: `ZUVO_API_URL` / `--api-url` (default `https://api.zuvodev.com`).
37
+
38
+ **Notes**
39
+ - Hosting env keys must be `UPPER_SNAKE` (`^[A-Z][A-Z0-9_]{0,127}$`).
40
+ - After `hosting env set|unset`, run `zuvo hosting deploy --wait` so the container picks up new vars.
41
+ - `zuvo secrets` = project Edge Function secrets; `zuvo hosting env` = App Hosting container env.
@@ -0,0 +1,88 @@
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
+ }
59
+ /** Slug or UUID safe to embed in analytics SQL. */
60
+ export function assertSafeFunctionKey(key) {
61
+ const trimmed = key.trim();
62
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(trimmed)) {
63
+ throw new Error(`Invalid function name: ${key} (use slug or UUID: letters, digits, _, -)`);
64
+ }
65
+ return trimmed;
66
+ }
67
+ export function functionLogsSql(limit, functionKey) {
68
+ const safe = Math.min(Math.max(1, Math.floor(limit)), 5_000);
69
+ const where = functionKey
70
+ ? ` where metadata.function_id = '${assertSafeFunctionKey(functionKey)}'`
71
+ : '';
72
+ return `select id, timestamp, event_message from function_logs${where} order by timestamp desc limit ${safe}`;
73
+ }
74
+ export function formatFunctionLogLine(row) {
75
+ const ts = formatLogTimestamp(row.timestamp);
76
+ const metaFn = row.metadata && typeof row.metadata.function_id === 'string'
77
+ ? row.metadata.function_id
78
+ : '';
79
+ const fn = row.function_id || metaFn;
80
+ const bits = [
81
+ ts,
82
+ fn ? `[${fn}]` : '',
83
+ row.method || '',
84
+ row.status_code != null ? String(row.status_code) : '',
85
+ row.event_message ?? '',
86
+ ].filter(Boolean);
87
+ return bits.join(' ');
88
+ }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { apiRequest, ApiError } from './api.js';
4
4
  import { argvAfterCommand } from './argv.js';
5
5
  import { apiUrlFromEnv, deleteAccessToken, isAccessToken, requireLinkedRef, saveLinkedRef, } from './config.js';
6
6
  import { encodeFunctionForm, listFunctionSlugs, loadFunctionBundle } from './functions.js';
7
+ import { formatFunctionLogLine, formatHostingLogLine, formatLogTimestamp, functionLogsSql, hostingLogsSql, parseSince, sortLogsChronological, } from './hosting.js';
7
8
  import { loginBrowser, loginWithToken } from './login.js';
8
9
  import { loadLocalMigrations, pendingMigrations } from './migrations.js';
9
10
  import { loadSecretsEnvFile, parseSecretArgs } from './secrets.js';
@@ -17,11 +18,21 @@ Commands:
17
18
  link --project <ref>
18
19
  functions list
19
20
  functions deploy [slug]
21
+ functions logs [slug] [--limit N] [--since 1h|30m|ISO] [-f|--follow]
20
22
  secrets list
21
23
  secrets set NAME=VALUE [NAME=VALUE ...]
22
24
  secrets set --env-file <path>
23
25
  secrets unset NAME [NAME ...]
24
26
  db push
27
+ hosting apps
28
+ hosting deployments [--app <appId|slug>]
29
+ hosting deploy [--app <appId|slug>] [--commit <sha>] [--wait]
30
+ hosting logs [--limit N] [--since 1h|30m|ISO] [-f|--follow]
31
+ hosting deploy-logs [deploymentId] [--app <appId|slug>]
32
+ hosting env list [--app <appId|slug>]
33
+ hosting env set NAME=VALUE [...] [--env-file <path>] [--app <appId|slug>]
34
+ hosting env unset NAME [...] [--app <appId|slug>]
35
+ hosting secrets … Alias for hosting env …
25
36
 
26
37
  Global:
27
38
  --api-url <url> Default https://api.zuvodev.com (or ZUVO_API_URL)
@@ -141,6 +152,84 @@ async function deployOne(apiUrl, ref, slug) {
141
152
  const meta = await apiRequest(apiUrl, 'POST', `/v1/projects/${ref}/functions/deploy`, { form, query: { slug: bundle.slug } });
142
153
  console.log(`Deployed ${meta.slug || bundle.slug}${meta.version != null ? ` (v${meta.version})` : ''}`);
143
154
  }
155
+ async function fetchFunctionLogs(apiUrl, ref, opts) {
156
+ const body = {
157
+ sql: functionLogsSql(opts.limit, opts.functionKey),
158
+ };
159
+ if (opts.since)
160
+ body.iso_timestamp_start = opts.since.toISOString();
161
+ const data = await apiRequest(apiUrl, 'POST', `/platform/projects/${ref}/analytics/endpoints/logs.all`, { json: body });
162
+ return Array.isArray(data?.result) ? data.result : [];
163
+ }
164
+ async function cmdFunctionsLogs(apiUrl, argv) {
165
+ const { values, positionals } = parseArgs({
166
+ args: argv,
167
+ options: {
168
+ limit: { type: 'string' },
169
+ since: { type: 'string' },
170
+ follow: { type: 'boolean', short: 'f' },
171
+ 'api-url': { type: 'string' },
172
+ },
173
+ allowPositionals: true,
174
+ strict: false,
175
+ });
176
+ const ref = await requireLinkedRef();
177
+ const functionKey = positionals.find((arg) => !arg.startsWith('-'));
178
+ const limitRaw = typeof values.limit === 'string' ? Number(values.limit) : 100;
179
+ const limit = Number.isFinite(limitRaw) ? limitRaw : 100;
180
+ const since = typeof values.since === 'string' && values.since.trim()
181
+ ? parseSince(values.since)
182
+ : parseSince('1h');
183
+ const follow = Boolean(values.follow);
184
+ const seen = new Set();
185
+ const printNew = (rows) => {
186
+ for (const row of sortLogsChronological(rows)) {
187
+ const key = row.id || `${formatFunctionLogLine(row)}`;
188
+ if (seen.has(key))
189
+ continue;
190
+ seen.add(key);
191
+ console.log(formatFunctionLogLine(row));
192
+ }
193
+ };
194
+ const initial = await fetchFunctionLogs(apiUrl, ref, {
195
+ limit,
196
+ since,
197
+ functionKey,
198
+ });
199
+ if (!initial.length && !follow) {
200
+ console.log(functionKey
201
+ ? `No edge function logs for ${functionKey} in range.`
202
+ : 'No edge function logs in range.');
203
+ return;
204
+ }
205
+ printNew(initial);
206
+ if (!follow)
207
+ return;
208
+ let cursor = since;
209
+ for (const row of sortLogsChronological(initial)) {
210
+ const iso = formatLogTimestamp(row.timestamp);
211
+ const parsed = iso ? new Date(iso) : null;
212
+ if (parsed && !Number.isNaN(parsed.getTime()) && parsed > cursor)
213
+ cursor = parsed;
214
+ }
215
+ console.error('Following edge function logs… (Ctrl+C to stop)');
216
+ for (;;) {
217
+ await new Promise((r) => setTimeout(r, 2_500));
218
+ const nextSince = new Date(Math.max(0, cursor.getTime() - 1_000));
219
+ const rows = await fetchFunctionLogs(apiUrl, ref, {
220
+ limit: Math.max(limit, 200),
221
+ since: nextSince,
222
+ functionKey,
223
+ });
224
+ printNew(rows);
225
+ for (const row of sortLogsChronological(rows)) {
226
+ const iso = formatLogTimestamp(row.timestamp);
227
+ const parsed = iso ? new Date(iso) : null;
228
+ if (parsed && !Number.isNaN(parsed.getTime()) && parsed > cursor)
229
+ cursor = parsed;
230
+ }
231
+ }
232
+ }
144
233
  async function cmdFunctionsDeploy(apiUrl, argv) {
145
234
  const ref = await requireLinkedRef();
146
235
  const slug = argv.find((arg) => !arg.startsWith('-'));
@@ -243,6 +332,330 @@ async function cmdDbPush(apiUrl) {
243
332
  console.log(`Applied ${migration.filename}`);
244
333
  }
245
334
  }
335
+ async function listHostingApps(apiUrl, ref) {
336
+ const data = await apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps`);
337
+ return Array.isArray(data?.apps) ? data.apps : [];
338
+ }
339
+ async function resolveHostingApp(apiUrl, ref, appId) {
340
+ const apps = await listHostingApps(apiUrl, ref);
341
+ if (!apps.length)
342
+ throw new Error('No hosting apps on this project.');
343
+ if (appId) {
344
+ const match = apps.find((app) => app.id === appId || app.slug === appId || app.name === appId);
345
+ if (!match)
346
+ throw new Error(`Hosting app not found: ${appId}`);
347
+ return match;
348
+ }
349
+ if (apps.length === 1)
350
+ return apps[0];
351
+ throw new Error(`Multiple hosting apps — pass --app <id|slug>. Available: ${apps
352
+ .map((app) => app.slug || app.id)
353
+ .join(', ')}`);
354
+ }
355
+ async function listHostingDeployments(apiUrl, ref, appId) {
356
+ const data = await apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps/${appId}/deployments`);
357
+ return Array.isArray(data?.deployments) ? data.deployments : [];
358
+ }
359
+ async function cmdHostingApps(apiUrl) {
360
+ const ref = await requireLinkedRef();
361
+ const apps = await listHostingApps(apiUrl, ref);
362
+ if (!apps.length) {
363
+ console.log('No hosting apps.');
364
+ return;
365
+ }
366
+ for (const app of apps) {
367
+ console.log([app.id, app.slug || app.name || '', app.status || '', app.branch || '', app.public_url || '']
368
+ .filter(Boolean)
369
+ .join('\t'));
370
+ }
371
+ }
372
+ async function cmdHostingDeployments(apiUrl, argv) {
373
+ const { values } = parseArgs({
374
+ args: argv,
375
+ options: {
376
+ app: { type: 'string' },
377
+ 'api-url': { type: 'string' },
378
+ },
379
+ allowPositionals: true,
380
+ strict: false,
381
+ });
382
+ const ref = await requireLinkedRef();
383
+ const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
384
+ const deployments = await listHostingDeployments(apiUrl, ref, app.id);
385
+ if (!deployments.length) {
386
+ console.log('No deployments.');
387
+ return;
388
+ }
389
+ for (const dep of deployments) {
390
+ console.log([
391
+ dep.id,
392
+ dep.status || '',
393
+ dep.commit_sha ? String(dep.commit_sha).slice(0, 12) : '',
394
+ dep.created_at || '',
395
+ ]
396
+ .filter(Boolean)
397
+ .join('\t'));
398
+ }
399
+ }
400
+ async function fetchHostingRuntimeLogs(apiUrl, ref, opts) {
401
+ const body = {
402
+ sql: hostingLogsSql(opts.limit),
403
+ };
404
+ if (opts.since)
405
+ body.iso_timestamp_start = opts.since.toISOString();
406
+ const data = await apiRequest(apiUrl, 'POST', `/platform/projects/${ref}/analytics/endpoints/logs.all`, { json: body });
407
+ return Array.isArray(data?.result) ? data.result : [];
408
+ }
409
+ async function cmdHostingLogs(apiUrl, argv) {
410
+ const { values } = parseArgs({
411
+ args: argv,
412
+ options: {
413
+ limit: { type: 'string' },
414
+ since: { type: 'string' },
415
+ follow: { type: 'boolean', short: 'f' },
416
+ 'api-url': { type: 'string' },
417
+ },
418
+ allowPositionals: true,
419
+ strict: false,
420
+ });
421
+ const ref = await requireLinkedRef();
422
+ const limitRaw = typeof values.limit === 'string' ? Number(values.limit) : 100;
423
+ const limit = Number.isFinite(limitRaw) ? limitRaw : 100;
424
+ const since = typeof values.since === 'string' && values.since.trim()
425
+ ? parseSince(values.since)
426
+ : parseSince('1h');
427
+ const follow = Boolean(values.follow);
428
+ const seen = new Set();
429
+ const printNew = (rows) => {
430
+ for (const row of sortLogsChronological(rows)) {
431
+ const key = row.id || `${formatHostingLogLine(row)}`;
432
+ if (seen.has(key))
433
+ continue;
434
+ seen.add(key);
435
+ console.log(formatHostingLogLine(row));
436
+ }
437
+ };
438
+ const initial = await fetchHostingRuntimeLogs(apiUrl, ref, { limit, since });
439
+ if (!initial.length && !follow) {
440
+ console.log('No hosting logs in range.');
441
+ return;
442
+ }
443
+ printNew(initial);
444
+ if (!follow)
445
+ return;
446
+ let cursor = since;
447
+ for (const row of sortLogsChronological(initial)) {
448
+ const iso = formatLogTimestamp(row.timestamp);
449
+ const parsed = iso ? new Date(iso) : null;
450
+ if (parsed && !Number.isNaN(parsed.getTime()) && parsed > cursor)
451
+ cursor = parsed;
452
+ }
453
+ console.error('Following hosting logs… (Ctrl+C to stop)');
454
+ for (;;) {
455
+ await new Promise((r) => setTimeout(r, 2_500));
456
+ const nextSince = new Date(Math.max(0, cursor.getTime() - 1_000));
457
+ const rows = await fetchHostingRuntimeLogs(apiUrl, ref, {
458
+ limit: Math.max(limit, 200),
459
+ since: nextSince,
460
+ });
461
+ printNew(rows);
462
+ for (const row of sortLogsChronological(rows)) {
463
+ const iso = formatLogTimestamp(row.timestamp);
464
+ const parsed = iso ? new Date(iso) : null;
465
+ if (parsed && !Number.isNaN(parsed.getTime()) && parsed > cursor)
466
+ cursor = parsed;
467
+ }
468
+ }
469
+ }
470
+ async function cmdHostingDeployLogs(apiUrl, argv) {
471
+ const { values, positionals } = parseArgs({
472
+ args: argv,
473
+ options: {
474
+ app: { type: 'string' },
475
+ 'api-url': { type: 'string' },
476
+ },
477
+ allowPositionals: true,
478
+ strict: false,
479
+ });
480
+ const ref = await requireLinkedRef();
481
+ const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
482
+ let deploymentId = positionals.find((arg) => !arg.startsWith('-')) || '';
483
+ if (!deploymentId) {
484
+ const deployments = await listHostingDeployments(apiUrl, ref, app.id);
485
+ if (!deployments.length)
486
+ throw new Error('No deployments found.');
487
+ deploymentId = deployments[0].id;
488
+ console.error(`Using latest deployment ${deploymentId} (${deployments[0].status || 'unknown'})`);
489
+ }
490
+ const data = await apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps/${app.id}/deployments/${deploymentId}/logs`);
491
+ const log = data?.log || '';
492
+ if (!log) {
493
+ console.log(`(empty build log) status=${data?.status || 'unknown'}`);
494
+ return;
495
+ }
496
+ process.stdout.write(log.endsWith('\n') ? log : `${log}\n`);
497
+ }
498
+ const HOSTING_ENV_KEY = /^[A-Z][A-Z0-9_]{0,127}$/;
499
+ function assertHostingEnvKey(key) {
500
+ if (!HOSTING_ENV_KEY.test(key)) {
501
+ throw new Error(`Invalid env key: ${key} (must match /^[A-Z][A-Z0-9_]{0,127}$/)`);
502
+ }
503
+ }
504
+ async function getDeploymentStatus(apiUrl, ref, appId, deploymentId) {
505
+ return apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps/${appId}/deployments/${deploymentId}/logs`);
506
+ }
507
+ async function cmdHostingDeploy(apiUrl, argv) {
508
+ const { values } = parseArgs({
509
+ args: argv,
510
+ options: {
511
+ app: { type: 'string' },
512
+ commit: { type: 'string' },
513
+ wait: { type: 'boolean' },
514
+ 'api-url': { type: 'string' },
515
+ },
516
+ allowPositionals: true,
517
+ strict: false,
518
+ });
519
+ const ref = await requireLinkedRef();
520
+ const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
521
+ const commitSha = typeof values.commit === 'string' && values.commit.trim()
522
+ ? values.commit.trim()
523
+ : undefined;
524
+ const data = await apiRequest(apiUrl, 'POST', `/platform/projects/${ref}/hosting/apps/${app.id}/deploy`, {
525
+ json: commitSha ? { commit_sha: commitSha } : {},
526
+ });
527
+ const deployment = data?.deployment;
528
+ if (!deployment?.id)
529
+ throw new Error('Deploy enqueue failed (no deployment id).');
530
+ console.log(`Queued deploy ${deployment.id} for ${app.slug || app.id} (${deployment.status || 'queued'})`);
531
+ if (!values.wait) {
532
+ console.error(`Tip: zuvo hosting deploy-logs ${deployment.id} --app ${app.slug || app.id}`);
533
+ return;
534
+ }
535
+ console.error('Waiting for deploy…');
536
+ const started = Date.now();
537
+ const timeoutMs = 20 * 60_000;
538
+ let lastStatus = deployment.status || 'queued';
539
+ for (;;) {
540
+ if (Date.now() - started > timeoutMs) {
541
+ throw new Error(`Timed out waiting for deploy (last status: ${lastStatus})`);
542
+ }
543
+ await new Promise((r) => setTimeout(r, 3_000));
544
+ const statusRes = await getDeploymentStatus(apiUrl, ref, app.id, deployment.id);
545
+ const status = statusRes.status || 'unknown';
546
+ if (status !== lastStatus) {
547
+ console.error(`status: ${status}`);
548
+ lastStatus = status;
549
+ }
550
+ if (status === 'ready') {
551
+ console.log(`Deploy ready: ${deployment.id}`);
552
+ if (app.public_url)
553
+ console.log(app.public_url);
554
+ return;
555
+ }
556
+ if (status === 'failed') {
557
+ const log = statusRes.log || '';
558
+ if (log)
559
+ process.stderr.write(log.endsWith('\n') ? log : `${log}\n`);
560
+ throw new Error(`Deploy failed: ${deployment.id}`);
561
+ }
562
+ }
563
+ }
564
+ async function cmdHostingEnvList(apiUrl, argv) {
565
+ const { values } = parseArgs({
566
+ args: argv,
567
+ options: {
568
+ app: { type: 'string' },
569
+ 'api-url': { type: 'string' },
570
+ },
571
+ allowPositionals: true,
572
+ strict: false,
573
+ });
574
+ const ref = await requireLinkedRef();
575
+ const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
576
+ const data = await apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps/${app.id}/env`);
577
+ const auto = Array.isArray(data?.auto) ? data.auto : [];
578
+ const custom = Array.isArray(data?.custom) ? data.custom : [];
579
+ if (!auto.length && !custom.length) {
580
+ console.log('No env vars.');
581
+ return;
582
+ }
583
+ for (const row of auto) {
584
+ console.log([row.key, row.value || '(auto)', 'auto'].filter(Boolean).join('\t'));
585
+ }
586
+ for (const row of custom) {
587
+ console.log([row.key, row.value || '********', 'custom'].filter(Boolean).join('\t'));
588
+ }
589
+ }
590
+ async function cmdHostingEnvSet(apiUrl, argv) {
591
+ const { values, positionals } = parseArgs({
592
+ args: argv,
593
+ options: {
594
+ app: { type: 'string' },
595
+ 'env-file': { type: 'string' },
596
+ 'api-url': { type: 'string' },
597
+ },
598
+ allowPositionals: true,
599
+ strict: false,
600
+ });
601
+ const ref = await requireLinkedRef();
602
+ const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
603
+ const fromFile = typeof values['env-file'] === 'string' && values['env-file'].trim()
604
+ ? await loadSecretsEnvFile(values['env-file'].trim())
605
+ : [];
606
+ const fromArgs = parseSecretArgs(positionals);
607
+ const pairs = [...fromFile, ...fromArgs];
608
+ if (!pairs.length) {
609
+ throw new Error('Usage: zuvo hosting env set NAME=VALUE [...] | --env-file <path> [--app <id|slug>]');
610
+ }
611
+ for (const pair of pairs) {
612
+ assertHostingEnvKey(pair.name);
613
+ }
614
+ await apiRequest(apiUrl, 'PUT', `/platform/projects/${ref}/hosting/apps/${app.id}/env`, {
615
+ json: { vars: pairs.map((p) => ({ key: p.name, value: p.value })) },
616
+ });
617
+ console.log(`Set ${pairs.map((p) => p.name).join(', ')} on ${app.slug || app.id}`);
618
+ console.error('Redeploy to apply env changes: zuvo hosting deploy --wait');
619
+ }
620
+ async function cmdHostingEnvUnset(apiUrl, argv) {
621
+ const { values, positionals } = parseArgs({
622
+ args: argv,
623
+ options: {
624
+ app: { type: 'string' },
625
+ 'api-url': { type: 'string' },
626
+ },
627
+ allowPositionals: true,
628
+ strict: false,
629
+ });
630
+ const names = positionals
631
+ .filter((arg) => !arg.startsWith('-'))
632
+ .map((n) => n.trim())
633
+ .filter(Boolean);
634
+ if (!names.length) {
635
+ throw new Error('Usage: zuvo hosting env unset NAME [NAME ...] [--app <id|slug>]');
636
+ }
637
+ for (const name of names)
638
+ assertHostingEnvKey(name);
639
+ const ref = await requireLinkedRef();
640
+ const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
641
+ await apiRequest(apiUrl, 'DELETE', `/platform/projects/${ref}/hosting/apps/${app.id}/env`, {
642
+ json: { keys: names },
643
+ });
644
+ console.log(`Unset ${names.join(', ')} on ${app.slug || app.id}`);
645
+ console.error('Redeploy to apply env changes: zuvo hosting deploy --wait');
646
+ }
647
+ async function cmdHostingEnv(apiUrl, argv) {
648
+ const [action, ...rest] = argv;
649
+ if (action === 'list' || !action)
650
+ await cmdHostingEnvList(apiUrl, rest);
651
+ else if (action === 'set')
652
+ await cmdHostingEnvSet(apiUrl, rest);
653
+ else if (action === 'unset' || action === 'delete')
654
+ await cmdHostingEnvUnset(apiUrl, rest);
655
+ else {
656
+ throw new Error('Usage: zuvo hosting env list|set|unset … (alias: zuvo hosting secrets …)');
657
+ }
658
+ }
246
659
  async function main() {
247
660
  const argv = process.argv.slice(2);
248
661
  const global = parseGlobal(argv);
@@ -265,6 +678,8 @@ async function main() {
265
678
  await cmdFunctionsList(apiUrl);
266
679
  else if (command === 'functions' && sub === 'deploy')
267
680
  await cmdFunctionsDeploy(apiUrl, argvAfterCommand(argv, 'functions', 'deploy'));
681
+ else if (command === 'functions' && sub === 'logs')
682
+ await cmdFunctionsLogs(apiUrl, argvAfterCommand(argv, 'functions', 'logs'));
268
683
  else if (command === 'secrets' && (sub === 'list' || !sub))
269
684
  await cmdSecretsList(apiUrl);
270
685
  else if (command === 'secrets' && sub === 'set')
@@ -273,6 +688,18 @@ async function main() {
273
688
  await cmdSecretsUnset(apiUrl, argvAfterCommand(argv, 'secrets', sub));
274
689
  else if (command === 'db' && sub === 'push')
275
690
  await cmdDbPush(apiUrl);
691
+ else if (command === 'hosting' && (sub === 'apps' || sub === 'list'))
692
+ await cmdHostingApps(apiUrl);
693
+ else if (command === 'hosting' && sub === 'deployments')
694
+ await cmdHostingDeployments(apiUrl, argvAfterCommand(argv, 'hosting', 'deployments'));
695
+ else if (command === 'hosting' && sub === 'deploy')
696
+ await cmdHostingDeploy(apiUrl, argvAfterCommand(argv, 'hosting', 'deploy'));
697
+ else if (command === 'hosting' && sub === 'logs')
698
+ await cmdHostingLogs(apiUrl, argvAfterCommand(argv, 'hosting', 'logs'));
699
+ else if (command === 'hosting' && (sub === 'deploy-logs' || sub === 'build-logs'))
700
+ await cmdHostingDeployLogs(apiUrl, argvAfterCommand(argv, 'hosting', sub));
701
+ else if (command === 'hosting' && (sub === 'env' || sub === 'secrets'))
702
+ await cmdHostingEnv(apiUrl, argvAfterCommand(argv, 'hosting', sub));
276
703
  else {
277
704
  console.error(usage());
278
705
  process.exit(command ? 1 : 0);
@@ -280,7 +707,7 @@ async function main() {
280
707
  }
281
708
  catch (error) {
282
709
  if (error instanceof ApiError && error.status === 403) {
283
- fail('Forbidden. Functions, secrets, and db push require owner, admin, or developer.');
710
+ fail('Forbidden. Functions, secrets, db push, and hosting require owner, admin, or developer.');
284
711
  }
285
712
  fail(error);
286
713
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zuvo/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
- "description": "Zuvo CLI — login, link, functions deploy, secrets, and db push",
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"