@zuvo/cli 0.1.8 → 0.1.11
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 +12 -2
- package/dist/functions.js +30 -0
- package/dist/hosting-apps.js +75 -0
- package/dist/hosting.js +28 -3
- package/dist/index.js +261 -29
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ zuvo login
|
|
|
8
8
|
zuvo projects list
|
|
9
9
|
zuvo link --project <ref>
|
|
10
10
|
zuvo functions deploy
|
|
11
|
+
# also uploads supabase/functions/_shared (and other _* modules) with each deploy
|
|
12
|
+
zuvo functions deploy hello
|
|
11
13
|
zuvo functions logs
|
|
12
14
|
zuvo functions logs hello --since 6h
|
|
13
15
|
zuvo functions logs -f
|
|
@@ -18,12 +20,16 @@ zuvo secrets unset OPENAI_API_KEY
|
|
|
18
20
|
zuvo db push
|
|
19
21
|
|
|
20
22
|
# App Hosting
|
|
23
|
+
zuvo github repos [--search my-app]
|
|
24
|
+
zuvo hosting create --repo owner/my-app --name admin --deploy --wait
|
|
25
|
+
zuvo hosting create # interactive repo picker (TTY)
|
|
21
26
|
zuvo hosting apps
|
|
22
27
|
zuvo hosting deploy --wait
|
|
23
28
|
zuvo hosting deploy --commit abc1234 --app my-app
|
|
24
29
|
zuvo hosting deployments
|
|
25
|
-
zuvo hosting logs --since 6h
|
|
26
|
-
zuvo hosting logs -f
|
|
30
|
+
zuvo hosting logs --app sklive-events --since 6h
|
|
31
|
+
zuvo hosting logs --app sklive-super-admin -f
|
|
32
|
+
zuvo hosting logs --app all
|
|
27
33
|
zuvo hosting deploy-logs
|
|
28
34
|
zuvo hosting env list
|
|
29
35
|
zuvo hosting env set API_KEY=secret FOO=bar
|
|
@@ -39,6 +45,10 @@ zuvo hosting domains rm app.example.com
|
|
|
39
45
|
Token: Studio → Account → Access Tokens, or `zuvo login --token zpat_…`.
|
|
40
46
|
API override: `ZUVO_API_URL` / `--api-url` (default `https://api.zuvodev.com`).
|
|
41
47
|
|
|
48
|
+
**Documentation:** [docs.zuvodev.com](https://docs.zuvodev.com)
|
|
49
|
+
|
|
50
|
+
**App runtime clients** (not this CLI): [@zuvo/js](https://docs.zuvodev.com/guides/client-libraries/javascript), [Flutter / Swift / Kotlin](https://docs.zuvodev.com/guides/client-libraries) — or official Supabase SDKs against your Zuvo project URL.
|
|
51
|
+
|
|
42
52
|
**Notes**
|
|
43
53
|
- Hosting env keys must be `UPPER_SNAKE` (`^[A-Z][A-Z0-9_]{0,127}$`).
|
|
44
54
|
- After `hosting env set|unset`, run `zuvo hosting deploy --wait` so the container picks up new vars.
|
package/dist/functions.js
CHANGED
|
@@ -30,6 +30,31 @@ export async function listFunctionSlugs(cwd = process.cwd()) {
|
|
|
30
30
|
return [];
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
/** Underscore-prefixed dirs (e.g. `_shared`) — not deployable functions, shared imports. */
|
|
34
|
+
export async function loadSharedModules(cwd = process.cwd()) {
|
|
35
|
+
const root = path.join(cwd, 'supabase', 'functions');
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const dirs = entries
|
|
44
|
+
.filter((ent) => ent.isDirectory() &&
|
|
45
|
+
ent.name.startsWith('_') &&
|
|
46
|
+
!ent.name.startsWith('.') &&
|
|
47
|
+
/^_[a-z0-9][a-z0-9_-]{0,62}$/i.test(ent.name))
|
|
48
|
+
.map((ent) => ent.name)
|
|
49
|
+
.sort();
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const name of dirs) {
|
|
52
|
+
const files = await walkFiles(path.join(root, name));
|
|
53
|
+
if (files.length)
|
|
54
|
+
out.push({ name, files });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
33
58
|
export async function loadFunctionBundle(cwd, slug) {
|
|
34
59
|
const dir = path.join(cwd, 'supabase', 'functions', slug);
|
|
35
60
|
const info = await stat(dir).catch(() => null);
|
|
@@ -68,5 +93,10 @@ export function encodeFunctionForm(input) {
|
|
|
68
93
|
for (const file of input.files) {
|
|
69
94
|
form.append('file', new Blob([file.content], { type: 'text/plain' }), file.name);
|
|
70
95
|
}
|
|
96
|
+
for (const mod of input.sharedModules || []) {
|
|
97
|
+
for (const file of mod.files) {
|
|
98
|
+
form.append('shared', new Blob([file.content], { type: 'text/plain' }), `${mod.name}/${file.name}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
71
101
|
return form;
|
|
72
102
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Normalize `owner/repo`, URL, or `.git` suffix into `owner/repo`. */
|
|
2
|
+
export function normalizeRepoSelector(input) {
|
|
3
|
+
let value = String(input || '').trim();
|
|
4
|
+
if (!value)
|
|
5
|
+
return '';
|
|
6
|
+
value = value.replace(/^git@github\.com:/i, '');
|
|
7
|
+
value = value.replace(/^https?:\/\/(?:www\.)?github\.com\//i, '');
|
|
8
|
+
value = value.replace(/\.git$/i, '');
|
|
9
|
+
value = value.replace(/\/+$/, '');
|
|
10
|
+
return value.toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
export function parseRepoSelector(input) {
|
|
13
|
+
const full = normalizeRepoSelector(input);
|
|
14
|
+
const parts = full.split('/').filter(Boolean);
|
|
15
|
+
if (parts.length < 2) {
|
|
16
|
+
throw new Error(`Invalid repo: ${input} (use owner/repo)`);
|
|
17
|
+
}
|
|
18
|
+
const repo = parts.pop();
|
|
19
|
+
const owner = parts.join('/');
|
|
20
|
+
if (!owner || !repo)
|
|
21
|
+
throw new Error(`Invalid repo: ${input} (use owner/repo)`);
|
|
22
|
+
return { owner, repo, full: `${owner}/${repo}` };
|
|
23
|
+
}
|
|
24
|
+
export function matchGithubRepo(repos, selector) {
|
|
25
|
+
const want = normalizeRepoSelector(selector);
|
|
26
|
+
if (!want)
|
|
27
|
+
return undefined;
|
|
28
|
+
const exact = repos.find((repo) => normalizeRepoSelector(repo.name) === want);
|
|
29
|
+
if (exact)
|
|
30
|
+
return exact;
|
|
31
|
+
const tail = want.split('/').pop() || want;
|
|
32
|
+
const byTail = repos.filter((repo) => {
|
|
33
|
+
const name = normalizeRepoSelector(repo.name);
|
|
34
|
+
return name === tail || name.endsWith(`/${tail}`);
|
|
35
|
+
});
|
|
36
|
+
if (byTail.length === 1)
|
|
37
|
+
return byTail[0];
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
export function findProjectConnectionForRepo(connections, projectRef, repoFullName) {
|
|
41
|
+
const want = normalizeRepoSelector(repoFullName);
|
|
42
|
+
return connections.find((row) => {
|
|
43
|
+
const repoName = normalizeRepoSelector(String(row.repository?.name || ''));
|
|
44
|
+
const ref = String(row.project?.ref || '');
|
|
45
|
+
return repoName === want && (!ref || ref === projectRef);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export function defaultAppNameFromRepo(repoFullName) {
|
|
49
|
+
const parsed = parseRepoSelector(repoFullName);
|
|
50
|
+
return parsed.repo.replace(/[^a-zA-Z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'app';
|
|
51
|
+
}
|
|
52
|
+
export function assertSafeAppName(name) {
|
|
53
|
+
const trimmed = name.trim();
|
|
54
|
+
if (!trimmed)
|
|
55
|
+
throw new Error('App name is required');
|
|
56
|
+
if (trimmed.length > 64)
|
|
57
|
+
throw new Error('App name is too long (max 64 characters)');
|
|
58
|
+
return trimmed;
|
|
59
|
+
}
|
|
60
|
+
export function buildHostingAppCreateBody(input) {
|
|
61
|
+
const body = {
|
|
62
|
+
name: assertSafeAppName(input.name),
|
|
63
|
+
github_connection_id: input.github_connection_id,
|
|
64
|
+
branch: input.branch.trim() || 'main',
|
|
65
|
+
};
|
|
66
|
+
if (input.slug?.trim())
|
|
67
|
+
body.slug = input.slug.trim();
|
|
68
|
+
if (input.root_dir != null)
|
|
69
|
+
body.root_dir = input.root_dir.trim();
|
|
70
|
+
return body;
|
|
71
|
+
}
|
|
72
|
+
export function formatGithubRepoLine(repo, index) {
|
|
73
|
+
const prefix = index == null ? '' : `${index + 1}. `;
|
|
74
|
+
return `${prefix}${repo.name} (${repo.default_branch || 'main'})`;
|
|
75
|
+
}
|
package/dist/hosting.js
CHANGED
|
@@ -47,14 +47,39 @@ export function sortLogsChronological(rows) {
|
|
|
47
47
|
return ta.localeCompare(tb);
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
|
+
export function hostingAppSlugFromLog(row) {
|
|
51
|
+
const meta = row.metadata && typeof row.metadata.app_slug === 'string' ? row.metadata.app_slug : '';
|
|
52
|
+
if (meta)
|
|
53
|
+
return meta;
|
|
54
|
+
const container = row.metadata && typeof row.metadata.app_container === 'string' ? row.metadata.app_container : '';
|
|
55
|
+
const match = String(container)
|
|
56
|
+
.replace(/^\//, '')
|
|
57
|
+
.match(/^app_([a-z0-9][a-z0-9-]*)(?:_[a-z0-9-]+)?$/);
|
|
58
|
+
return match?.[1] || '';
|
|
59
|
+
}
|
|
50
60
|
export function formatHostingLogLine(row) {
|
|
51
61
|
const ts = formatLogTimestamp(row.timestamp);
|
|
62
|
+
const slug = hostingAppSlugFromLog(row);
|
|
52
63
|
const msg = row.event_message ?? '';
|
|
53
|
-
|
|
64
|
+
const bits = [ts, slug ? `[${slug}]` : '', msg].filter(Boolean);
|
|
65
|
+
return bits.join(' ');
|
|
54
66
|
}
|
|
55
|
-
export function
|
|
67
|
+
export function assertSafeHostingSlug(key) {
|
|
68
|
+
const trimmed = key.trim();
|
|
69
|
+
if (trimmed === 'all')
|
|
70
|
+
return trimmed;
|
|
71
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(trimmed)) {
|
|
72
|
+
throw new Error(`Invalid app slug: ${key} (use letters, digits, hyphen)`);
|
|
73
|
+
}
|
|
74
|
+
return trimmed;
|
|
75
|
+
}
|
|
76
|
+
export function hostingLogsSql(limit, appSlug) {
|
|
56
77
|
const safe = Math.min(Math.max(1, Math.floor(limit)), 5_000);
|
|
57
|
-
|
|
78
|
+
if (!appSlug || appSlug === 'all') {
|
|
79
|
+
return `select id, timestamp, event_message, metadata from hosting_logs order by timestamp desc limit ${safe}`;
|
|
80
|
+
}
|
|
81
|
+
const slug = assertSafeHostingSlug(appSlug);
|
|
82
|
+
return `select id, timestamp, event_message, metadata from hosting_logs where metadata.app_slug = '${slug}' order by timestamp desc limit ${safe}`;
|
|
58
83
|
}
|
|
59
84
|
/** Slug or UUID safe to embed in analytics SQL. */
|
|
60
85
|
export function assertSafeFunctionKey(key) {
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { stdin as input, stderr as output } from 'node:process';
|
|
3
5
|
import { apiRequest, ApiError } from './api.js';
|
|
4
6
|
import { argvAfterCommand } from './argv.js';
|
|
5
7
|
import { apiUrlFromEnv, deleteAccessToken, isAccessToken, requireLinkedRef, saveLinkedRef, } from './config.js';
|
|
6
|
-
import { encodeFunctionForm, listFunctionSlugs, loadFunctionBundle } from './functions.js';
|
|
8
|
+
import { encodeFunctionForm, listFunctionSlugs, loadFunctionBundle, loadSharedModules, } from './functions.js';
|
|
9
|
+
import { buildHostingAppCreateBody, defaultAppNameFromRepo, findProjectConnectionForRepo, formatGithubRepoLine, matchGithubRepo, parseRepoSelector, } from './hosting-apps.js';
|
|
7
10
|
import { formatDomainDnsInstructions, formatDomainListLine, formatFunctionLogLine, formatHostingLogLine, formatLogTimestamp, functionLogsSql, hostingLogsSql, normalizeHostnameInput, parseSince, sortLogsChronological, } from './hosting.js';
|
|
8
11
|
import { loginBrowser, loginWithToken } from './login.js';
|
|
9
12
|
import { loadLocalMigrations, pendingMigrations } from './migrations.js';
|
|
@@ -24,10 +27,12 @@ Commands:
|
|
|
24
27
|
secrets set --env-file <path>
|
|
25
28
|
secrets unset NAME [NAME ...]
|
|
26
29
|
db push
|
|
30
|
+
github repos [--search <q>]
|
|
31
|
+
hosting create [--repo owner/name] [--name <app>] [--slug <slug>] [--branch main] [--root-dir <path>] [--connection-id <uuid>] [--deploy] [--wait] [--yes]
|
|
27
32
|
hosting apps
|
|
28
33
|
hosting deployments [--app <appId|slug>]
|
|
29
34
|
hosting deploy [--app <appId|slug>] [--commit <sha>] [--wait]
|
|
30
|
-
hosting logs [--limit N] [--since 1h|30m|ISO] [-f|--follow]
|
|
35
|
+
hosting logs [--app <appId|slug|all>] [--limit N] [--since 1h|30m|ISO] [-f|--follow]
|
|
31
36
|
hosting deploy-logs [deploymentId] [--app <appId|slug>]
|
|
32
37
|
hosting env list [--app <appId|slug>]
|
|
33
38
|
hosting env set NAME=VALUE [...] [--env-file <path>] [--app <appId|slug>]
|
|
@@ -133,7 +138,7 @@ async function cmdLink(apiUrl, argv) {
|
|
|
133
138
|
if (!ref)
|
|
134
139
|
throw new Error('Missing --project <ref>');
|
|
135
140
|
const project = await apiRequest(apiUrl, 'GET', `/v1/projects/${ref}`);
|
|
136
|
-
const linked = project.ref || project.id || ref;
|
|
141
|
+
const linked = String(project.ref || project.id || ref);
|
|
137
142
|
await saveLinkedRef(linked);
|
|
138
143
|
console.log(`Linked to project ${linked}`);
|
|
139
144
|
}
|
|
@@ -150,11 +155,15 @@ async function cmdFunctionsList(apiUrl) {
|
|
|
150
155
|
.join('\t'));
|
|
151
156
|
}
|
|
152
157
|
}
|
|
153
|
-
async function deployOne(apiUrl, ref, slug) {
|
|
158
|
+
async function deployOne(apiUrl, ref, slug, sharedModules) {
|
|
154
159
|
const bundle = await loadFunctionBundle(process.cwd(), slug);
|
|
155
|
-
const
|
|
160
|
+
const shared = sharedModules ?? (await loadSharedModules());
|
|
161
|
+
const form = encodeFunctionForm({ ...bundle, sharedModules: shared });
|
|
156
162
|
const meta = await apiRequest(apiUrl, 'POST', `/v1/projects/${ref}/functions/deploy`, { form, query: { slug: bundle.slug } });
|
|
157
|
-
|
|
163
|
+
const sharedNote = shared.length
|
|
164
|
+
? ` (+${shared.map((m) => m.name).join(', ')})`
|
|
165
|
+
: '';
|
|
166
|
+
console.log(`Deployed ${meta.slug || bundle.slug}${meta.version != null ? ` (v${meta.version})` : ''}${sharedNote}`);
|
|
158
167
|
}
|
|
159
168
|
async function fetchFunctionLogs(apiUrl, ref, opts) {
|
|
160
169
|
const body = {
|
|
@@ -241,11 +250,15 @@ async function cmdFunctionsDeploy(apiUrl, argv) {
|
|
|
241
250
|
if (!slugs.length) {
|
|
242
251
|
throw new Error('No functions to deploy (expected supabase/functions/<slug>).');
|
|
243
252
|
}
|
|
253
|
+
const sharedModules = await loadSharedModules();
|
|
254
|
+
if (sharedModules.length) {
|
|
255
|
+
console.log(`Including shared modules: ${sharedModules.map((m) => m.name).join(', ')}`);
|
|
256
|
+
}
|
|
244
257
|
const failures = [];
|
|
245
258
|
for (let i = 0; i < slugs.length; i++) {
|
|
246
259
|
const name = slugs[i];
|
|
247
260
|
try {
|
|
248
|
-
await deployOne(apiUrl, ref, name);
|
|
261
|
+
await deployOne(apiUrl, ref, name, sharedModules);
|
|
249
262
|
}
|
|
250
263
|
catch (error) {
|
|
251
264
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -336,6 +349,80 @@ async function cmdDbPush(apiUrl) {
|
|
|
336
349
|
console.log(`Applied ${migration.filename}`);
|
|
337
350
|
}
|
|
338
351
|
}
|
|
352
|
+
async function loadLinkedProject(apiUrl) {
|
|
353
|
+
const ref = await requireLinkedRef();
|
|
354
|
+
const project = await apiRequest(apiUrl, 'GET', `/v1/projects/${ref}`);
|
|
355
|
+
const orgId = Number(project.organization_id || 0);
|
|
356
|
+
if (!orgId)
|
|
357
|
+
throw new Error('Could not resolve organization for linked project');
|
|
358
|
+
return { ref, project, orgId };
|
|
359
|
+
}
|
|
360
|
+
async function listGithubRepos(apiUrl, search) {
|
|
361
|
+
const data = await apiRequest(apiUrl, 'GET', '/integrations/github/repositories', {
|
|
362
|
+
query: search?.trim() ? { q: search.trim() } : undefined,
|
|
363
|
+
});
|
|
364
|
+
if (data.needs_reauthorization) {
|
|
365
|
+
throw new Error('GitHub is not authorized for your account. Connect GitHub in Studio → Integrations, then retry.');
|
|
366
|
+
}
|
|
367
|
+
return Array.isArray(data.repositories) ? data.repositories : [];
|
|
368
|
+
}
|
|
369
|
+
async function listGithubConnections(apiUrl, organizationId, projectRef) {
|
|
370
|
+
const data = await apiRequest(apiUrl, 'GET', '/integrations/github/connections', { query: { organization_id: String(organizationId), project_ref: projectRef } });
|
|
371
|
+
return Array.isArray(data.connections) ? data.connections : [];
|
|
372
|
+
}
|
|
373
|
+
async function pickGithubRepo(repos) {
|
|
374
|
+
if (!repos.length)
|
|
375
|
+
throw new Error('No GitHub repositories found for your account.');
|
|
376
|
+
if (repos.length === 1)
|
|
377
|
+
return repos[0];
|
|
378
|
+
for (let i = 0; i < repos.length; i += 1) {
|
|
379
|
+
console.error(formatGithubRepoLine(repos[i], i));
|
|
380
|
+
}
|
|
381
|
+
const rl = createInterface({ input, output });
|
|
382
|
+
try {
|
|
383
|
+
const answer = (await rl.question('Select repo number: ')).trim();
|
|
384
|
+
const index = Number(answer);
|
|
385
|
+
if (!Number.isFinite(index) || index < 1 || index > repos.length) {
|
|
386
|
+
throw new Error('Invalid selection');
|
|
387
|
+
}
|
|
388
|
+
return repos[index - 1];
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
rl.close();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async function resolveGithubRepo(apiUrl, selector, searchHint) {
|
|
395
|
+
if (selector?.trim()) {
|
|
396
|
+
const parsed = parseRepoSelector(selector);
|
|
397
|
+
const repos = await listGithubRepos(apiUrl, parsed.repo);
|
|
398
|
+
const match = matchGithubRepo(repos, parsed.full);
|
|
399
|
+
if (!match) {
|
|
400
|
+
throw new Error(`Repository not found or not accessible: ${parsed.full}`);
|
|
401
|
+
}
|
|
402
|
+
return match;
|
|
403
|
+
}
|
|
404
|
+
if (!input.isTTY) {
|
|
405
|
+
throw new Error('Pass --repo owner/name (non-interactive shell).');
|
|
406
|
+
}
|
|
407
|
+
const repos = await listGithubRepos(apiUrl, searchHint);
|
|
408
|
+
return pickGithubRepo(repos.slice(0, 30));
|
|
409
|
+
}
|
|
410
|
+
async function ensureGithubConnection(apiUrl, projectRef, organizationId, repo) {
|
|
411
|
+
const connections = await listGithubConnections(apiUrl, organizationId, projectRef);
|
|
412
|
+
const existing = findProjectConnectionForRepo(connections, projectRef, repo.name);
|
|
413
|
+
if (existing?.id)
|
|
414
|
+
return existing.id;
|
|
415
|
+
const created = await apiRequest(apiUrl, 'POST', '/integrations/github/connections', {
|
|
416
|
+
json: {
|
|
417
|
+
project_ref: projectRef,
|
|
418
|
+
repository_id: repo.id,
|
|
419
|
+
installation_id: repo.installation_id,
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
if (!created?.id)
|
|
423
|
+
throw new Error('Failed to attach GitHub repository to project');
|
|
424
|
+
return created.id;
|
|
425
|
+
}
|
|
339
426
|
async function listHostingApps(apiUrl, ref) {
|
|
340
427
|
const data = await apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps`);
|
|
341
428
|
return Array.isArray(data?.apps) ? data.apps : [];
|
|
@@ -360,6 +447,125 @@ async function listHostingDeployments(apiUrl, ref, appId) {
|
|
|
360
447
|
const data = await apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps/${appId}/deployments`);
|
|
361
448
|
return Array.isArray(data?.deployments) ? data.deployments : [];
|
|
362
449
|
}
|
|
450
|
+
async function cmdGithubRepos(apiUrl, argv) {
|
|
451
|
+
const { values } = parseArgs({
|
|
452
|
+
args: argv,
|
|
453
|
+
options: {
|
|
454
|
+
search: { type: 'string' },
|
|
455
|
+
q: { type: 'string' },
|
|
456
|
+
'api-url': { type: 'string' },
|
|
457
|
+
},
|
|
458
|
+
allowPositionals: true,
|
|
459
|
+
strict: false,
|
|
460
|
+
});
|
|
461
|
+
const search = (typeof values.search === 'string' && values.search) ||
|
|
462
|
+
(typeof values.q === 'string' && values.q) ||
|
|
463
|
+
undefined;
|
|
464
|
+
const repos = await listGithubRepos(apiUrl, search);
|
|
465
|
+
if (!repos.length) {
|
|
466
|
+
console.log('No repositories found.');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
for (let i = 0; i < repos.length; i += 1) {
|
|
470
|
+
console.log([repos[i].id, repos[i].name, repos[i].default_branch || 'main'].join('\t'));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
async function cmdHostingCreate(apiUrl, argv) {
|
|
474
|
+
const { values, positionals } = parseArgs({
|
|
475
|
+
args: argv,
|
|
476
|
+
options: {
|
|
477
|
+
repo: { type: 'string' },
|
|
478
|
+
name: { type: 'string' },
|
|
479
|
+
slug: { type: 'string' },
|
|
480
|
+
branch: { type: 'string' },
|
|
481
|
+
'root-dir': { type: 'string' },
|
|
482
|
+
'connection-id': { type: 'string' },
|
|
483
|
+
deploy: { type: 'boolean' },
|
|
484
|
+
wait: { type: 'boolean' },
|
|
485
|
+
yes: { type: 'boolean', short: 'y' },
|
|
486
|
+
'api-url': { type: 'string' },
|
|
487
|
+
},
|
|
488
|
+
allowPositionals: true,
|
|
489
|
+
strict: false,
|
|
490
|
+
});
|
|
491
|
+
const { ref, orgId } = await loadLinkedProject(apiUrl);
|
|
492
|
+
const connectionId = typeof values['connection-id'] === 'string' ? values['connection-id'].trim() : '';
|
|
493
|
+
const repoSelector = typeof values.repo === 'string' ? values.repo.trim() : '';
|
|
494
|
+
const appName = (typeof values.name === 'string' && values.name.trim()) ||
|
|
495
|
+
positionals[0]?.trim() ||
|
|
496
|
+
'';
|
|
497
|
+
const slug = typeof values.slug === 'string' ? values.slug.trim() : undefined;
|
|
498
|
+
const rootDir = typeof values['root-dir'] === 'string' ? values['root-dir'] : undefined;
|
|
499
|
+
let githubConnectionId = connectionId;
|
|
500
|
+
let repo;
|
|
501
|
+
let branch = typeof values.branch === 'string' && values.branch.trim()
|
|
502
|
+
? values.branch.trim()
|
|
503
|
+
: 'main';
|
|
504
|
+
if (!githubConnectionId) {
|
|
505
|
+
repo = await resolveGithubRepo(apiUrl, repoSelector || undefined);
|
|
506
|
+
branch =
|
|
507
|
+
typeof values.branch === 'string' && values.branch.trim()
|
|
508
|
+
? values.branch.trim()
|
|
509
|
+
: repo.default_branch || 'main';
|
|
510
|
+
githubConnectionId = await ensureGithubConnection(apiUrl, ref, orgId, repo);
|
|
511
|
+
}
|
|
512
|
+
else if (repoSelector) {
|
|
513
|
+
throw new Error('Pass either --repo or --connection-id, not both.');
|
|
514
|
+
}
|
|
515
|
+
const resolvedName = appName || (repo ? defaultAppNameFromRepo(repo.name) : 'app');
|
|
516
|
+
const body = buildHostingAppCreateBody({
|
|
517
|
+
name: resolvedName,
|
|
518
|
+
slug,
|
|
519
|
+
github_connection_id: githubConnectionId,
|
|
520
|
+
branch,
|
|
521
|
+
root_dir: rootDir,
|
|
522
|
+
});
|
|
523
|
+
const slugCheck = await apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/check-slug`, {
|
|
524
|
+
query: { slug: body.slug || resolvedName, name: body.name },
|
|
525
|
+
});
|
|
526
|
+
if (slugCheck.available === false) {
|
|
527
|
+
throw new Error(slugCheck.message || `Slug "${slugCheck.slug || body.slug}" is not available`);
|
|
528
|
+
}
|
|
529
|
+
const previewRepo = repo?.name || '(existing connection)';
|
|
530
|
+
const previewUrl = slugCheck.public_url || '';
|
|
531
|
+
if (input.isTTY && !values.yes) {
|
|
532
|
+
console.error([
|
|
533
|
+
`Create hosting app "${body.name}"`,
|
|
534
|
+
`Repo: ${previewRepo} @ ${branch}`,
|
|
535
|
+
previewUrl ? `URL: ${previewUrl}` : '',
|
|
536
|
+
'',
|
|
537
|
+
'Proceed? [y/N]',
|
|
538
|
+
]
|
|
539
|
+
.filter(Boolean)
|
|
540
|
+
.join('\n'));
|
|
541
|
+
const rl = createInterface({ input, output });
|
|
542
|
+
let answer = '';
|
|
543
|
+
try {
|
|
544
|
+
answer = (await rl.question('> ')).trim().toLowerCase();
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
rl.close();
|
|
548
|
+
}
|
|
549
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
550
|
+
console.error('Cancelled.');
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const created = await apiRequest(apiUrl, 'POST', `/platform/projects/${ref}/hosting/apps`, { json: body });
|
|
555
|
+
const app = created?.app;
|
|
556
|
+
if (!app?.id)
|
|
557
|
+
throw new Error('App creation failed (no app id).');
|
|
558
|
+
console.log(`Created app ${app.slug || app.name || app.id}`);
|
|
559
|
+
if (app.public_url)
|
|
560
|
+
console.log(app.public_url);
|
|
561
|
+
console.error(`Repo: ${previewRepo} @ ${branch}`);
|
|
562
|
+
if (values.deploy || values.wait) {
|
|
563
|
+
await deployHostingApp(apiUrl, ref, app, { wait: Boolean(values.wait) });
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
console.error(`Deploy: zuvo hosting deploy --app ${app.slug || app.id} --wait`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
363
569
|
async function cmdHostingApps(apiUrl) {
|
|
364
570
|
const ref = await requireLinkedRef();
|
|
365
571
|
const apps = await listHostingApps(apiUrl, ref);
|
|
@@ -403,7 +609,7 @@ async function cmdHostingDeployments(apiUrl, argv) {
|
|
|
403
609
|
}
|
|
404
610
|
async function fetchHostingRuntimeLogs(apiUrl, ref, opts) {
|
|
405
611
|
const body = {
|
|
406
|
-
sql: hostingLogsSql(opts.limit),
|
|
612
|
+
sql: hostingLogsSql(opts.limit, opts.appSlug),
|
|
407
613
|
};
|
|
408
614
|
if (opts.since)
|
|
409
615
|
body.iso_timestamp_start = opts.since.toISOString();
|
|
@@ -414,6 +620,7 @@ async function cmdHostingLogs(apiUrl, argv) {
|
|
|
414
620
|
const { values } = parseArgs({
|
|
415
621
|
args: argv,
|
|
416
622
|
options: {
|
|
623
|
+
app: { type: 'string' },
|
|
417
624
|
limit: { type: 'string' },
|
|
418
625
|
since: { type: 'string' },
|
|
419
626
|
follow: { type: 'boolean', short: 'f' },
|
|
@@ -429,6 +636,19 @@ async function cmdHostingLogs(apiUrl, argv) {
|
|
|
429
636
|
? parseSince(values.since)
|
|
430
637
|
: parseSince('1h');
|
|
431
638
|
const follow = Boolean(values.follow);
|
|
639
|
+
const appArg = typeof values.app === 'string' ? values.app.trim() : '';
|
|
640
|
+
let appSlug;
|
|
641
|
+
if (appArg === 'all') {
|
|
642
|
+
appSlug = 'all';
|
|
643
|
+
}
|
|
644
|
+
else if (appArg) {
|
|
645
|
+
const app = await resolveHostingApp(apiUrl, ref, appArg);
|
|
646
|
+
appSlug = app.slug || app.id;
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
const app = await resolveHostingApp(apiUrl, ref, undefined);
|
|
650
|
+
appSlug = app.slug || app.id;
|
|
651
|
+
}
|
|
432
652
|
const seen = new Set();
|
|
433
653
|
const printNew = (rows) => {
|
|
434
654
|
for (const row of sortLogsChronological(rows)) {
|
|
@@ -439,9 +659,9 @@ async function cmdHostingLogs(apiUrl, argv) {
|
|
|
439
659
|
console.log(formatHostingLogLine(row));
|
|
440
660
|
}
|
|
441
661
|
};
|
|
442
|
-
const initial = await fetchHostingRuntimeLogs(apiUrl, ref, { limit, since });
|
|
662
|
+
const initial = await fetchHostingRuntimeLogs(apiUrl, ref, { limit, since, appSlug });
|
|
443
663
|
if (!initial.length && !follow) {
|
|
444
|
-
console.log(
|
|
664
|
+
console.log(`No hosting logs in range${appSlug && appSlug !== 'all' ? ` for ${appSlug}` : ''}.`);
|
|
445
665
|
return;
|
|
446
666
|
}
|
|
447
667
|
printNew(initial);
|
|
@@ -454,13 +674,14 @@ async function cmdHostingLogs(apiUrl, argv) {
|
|
|
454
674
|
if (parsed && !Number.isNaN(parsed.getTime()) && parsed > cursor)
|
|
455
675
|
cursor = parsed;
|
|
456
676
|
}
|
|
457
|
-
console.error(
|
|
677
|
+
console.error(`Following hosting logs${appSlug && appSlug !== 'all' ? ` for ${appSlug}` : ''}… (Ctrl+C to stop)`);
|
|
458
678
|
for (;;) {
|
|
459
679
|
await new Promise((r) => setTimeout(r, 2_500));
|
|
460
680
|
const nextSince = new Date(Math.max(0, cursor.getTime() - 1_000));
|
|
461
681
|
const rows = await fetchHostingRuntimeLogs(apiUrl, ref, {
|
|
462
682
|
limit: Math.max(limit, 200),
|
|
463
683
|
since: nextSince,
|
|
684
|
+
appSlug,
|
|
464
685
|
});
|
|
465
686
|
printNew(rows);
|
|
466
687
|
for (const row of sortLogsChronological(rows)) {
|
|
@@ -508,23 +729,8 @@ function assertHostingEnvKey(key) {
|
|
|
508
729
|
async function getDeploymentStatus(apiUrl, ref, appId, deploymentId) {
|
|
509
730
|
return apiRequest(apiUrl, 'GET', `/platform/projects/${ref}/hosting/apps/${appId}/deployments/${deploymentId}/logs`);
|
|
510
731
|
}
|
|
511
|
-
async function
|
|
512
|
-
const
|
|
513
|
-
args: argv,
|
|
514
|
-
options: {
|
|
515
|
-
app: { type: 'string' },
|
|
516
|
-
commit: { type: 'string' },
|
|
517
|
-
wait: { type: 'boolean' },
|
|
518
|
-
'api-url': { type: 'string' },
|
|
519
|
-
},
|
|
520
|
-
allowPositionals: true,
|
|
521
|
-
strict: false,
|
|
522
|
-
});
|
|
523
|
-
const ref = await requireLinkedRef();
|
|
524
|
-
const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
|
|
525
|
-
const commitSha = typeof values.commit === 'string' && values.commit.trim()
|
|
526
|
-
? values.commit.trim()
|
|
527
|
-
: undefined;
|
|
732
|
+
async function deployHostingApp(apiUrl, ref, app, opts = {}) {
|
|
733
|
+
const commitSha = opts.commit?.trim() || undefined;
|
|
528
734
|
const data = await apiRequest(apiUrl, 'POST', `/platform/projects/${ref}/hosting/apps/${app.id}/deploy`, {
|
|
529
735
|
json: commitSha ? { commit_sha: commitSha } : {},
|
|
530
736
|
});
|
|
@@ -532,7 +738,7 @@ async function cmdHostingDeploy(apiUrl, argv) {
|
|
|
532
738
|
if (!deployment?.id)
|
|
533
739
|
throw new Error('Deploy enqueue failed (no deployment id).');
|
|
534
740
|
console.log(`Queued deploy ${deployment.id} for ${app.slug || app.id} (${deployment.status || 'queued'})`);
|
|
535
|
-
if (!
|
|
741
|
+
if (!opts.wait) {
|
|
536
742
|
console.error(`Tip: zuvo hosting deploy-logs ${deployment.id} --app ${app.slug || app.id}`);
|
|
537
743
|
return;
|
|
538
744
|
}
|
|
@@ -565,6 +771,28 @@ async function cmdHostingDeploy(apiUrl, argv) {
|
|
|
565
771
|
}
|
|
566
772
|
}
|
|
567
773
|
}
|
|
774
|
+
async function cmdHostingDeploy(apiUrl, argv) {
|
|
775
|
+
const { values } = parseArgs({
|
|
776
|
+
args: argv,
|
|
777
|
+
options: {
|
|
778
|
+
app: { type: 'string' },
|
|
779
|
+
commit: { type: 'string' },
|
|
780
|
+
wait: { type: 'boolean' },
|
|
781
|
+
'api-url': { type: 'string' },
|
|
782
|
+
},
|
|
783
|
+
allowPositionals: true,
|
|
784
|
+
strict: false,
|
|
785
|
+
});
|
|
786
|
+
const ref = await requireLinkedRef();
|
|
787
|
+
const app = await resolveHostingApp(apiUrl, ref, typeof values.app === 'string' ? values.app : undefined);
|
|
788
|
+
const commitSha = typeof values.commit === 'string' && values.commit.trim()
|
|
789
|
+
? values.commit.trim()
|
|
790
|
+
: undefined;
|
|
791
|
+
await deployHostingApp(apiUrl, ref, app, {
|
|
792
|
+
commit: commitSha,
|
|
793
|
+
wait: Boolean(values.wait),
|
|
794
|
+
});
|
|
795
|
+
}
|
|
568
796
|
async function cmdHostingEnvList(apiUrl, argv) {
|
|
569
797
|
const { values } = parseArgs({
|
|
570
798
|
args: argv,
|
|
@@ -810,6 +1038,10 @@ async function main() {
|
|
|
810
1038
|
await cmdSecretsUnset(apiUrl, argvAfterCommand(argv, 'secrets', sub));
|
|
811
1039
|
else if (command === 'db' && sub === 'push')
|
|
812
1040
|
await cmdDbPush(apiUrl);
|
|
1041
|
+
else if (command === 'github' && (sub === 'repos' || sub === 'list'))
|
|
1042
|
+
await cmdGithubRepos(apiUrl, argvAfterCommand(argv, 'github', sub));
|
|
1043
|
+
else if (command === 'hosting' && sub === 'create')
|
|
1044
|
+
await cmdHostingCreate(apiUrl, argvAfterCommand(argv, 'hosting', 'create'));
|
|
813
1045
|
else if (command === 'hosting' && (sub === 'apps' || sub === 'list'))
|
|
814
1046
|
await cmdHostingApps(apiUrl);
|
|
815
1047
|
else if (command === 'hosting' && sub === 'deployments')
|