@zuvo/cli 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 +15 -0
- package/bin/zuvo.js +2 -0
- package/dist/api.js +47 -0
- package/dist/config.js +101 -0
- package/dist/crypto.js +26 -0
- package/dist/functions.js +72 -0
- package/dist/index.js +209 -0
- package/dist/login.js +88 -0
- package/dist/migrations.js +38 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @zuvo/cli
|
|
2
|
+
|
|
3
|
+
Login, link a project, deploy Edge Functions, and `db push` against [Zuvo](https://studio.zuvodev.com).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i -g @zuvo/cli
|
|
7
|
+
zuvo login
|
|
8
|
+
zuvo projects list
|
|
9
|
+
zuvo link --project <ref>
|
|
10
|
+
zuvo functions deploy
|
|
11
|
+
zuvo db push
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Token: Studio → Account → Access Tokens, or `zuvo login --token zpat_…`.
|
|
15
|
+
API override: `ZUVO_API_URL` / `--api-url` (default `https://api.zuvodev.com`).
|
package/bin/zuvo.js
ADDED
package/dist/api.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { loadAccessToken } from './config.js';
|
|
2
|
+
export class ApiError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
constructor(status, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.name = 'ApiError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export async function apiRequest(apiUrl, method, pathname, opts = {}) {
|
|
11
|
+
const token = opts.token ?? (await loadAccessToken());
|
|
12
|
+
const url = new URL(pathname.replace(/^\//, ''), `${apiUrl}/`);
|
|
13
|
+
for (const [key, value] of Object.entries(opts.query || {})) {
|
|
14
|
+
if (value)
|
|
15
|
+
url.searchParams.set(key, value);
|
|
16
|
+
}
|
|
17
|
+
const headers = {
|
|
18
|
+
authorization: `Bearer ${token}`,
|
|
19
|
+
accept: 'application/json',
|
|
20
|
+
};
|
|
21
|
+
let body;
|
|
22
|
+
if (opts.form) {
|
|
23
|
+
body = opts.form;
|
|
24
|
+
}
|
|
25
|
+
else if (opts.json !== undefined) {
|
|
26
|
+
headers['content-type'] = 'application/json';
|
|
27
|
+
body = JSON.stringify(opts.json);
|
|
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);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
parsed = text;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const message = parsed && typeof parsed === 'object' && parsed !== null && 'message' in parsed
|
|
42
|
+
? String(parsed.message)
|
|
43
|
+
: text || `HTTP ${response.status}`;
|
|
44
|
+
throw new ApiError(response.status, message);
|
|
45
|
+
}
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const DEFAULT_API_URL = 'https://api.zuvodev.com';
|
|
5
|
+
export const DEFAULT_STUDIO_URL = 'https://studio.zuvodev.com';
|
|
6
|
+
const TOKEN_RE = /^zpat_[0-9a-fA-F]{64}$/;
|
|
7
|
+
export function homeDir() {
|
|
8
|
+
return process.env.ZUVO_HOME || path.join(homedir(), '.zuvo');
|
|
9
|
+
}
|
|
10
|
+
export function tokenPath() {
|
|
11
|
+
return path.join(homeDir(), 'access-token');
|
|
12
|
+
}
|
|
13
|
+
export function apiUrlFromEnv(flag) {
|
|
14
|
+
return (flag || process.env.ZUVO_API_URL || DEFAULT_API_URL).replace(/\/$/, '');
|
|
15
|
+
}
|
|
16
|
+
export function studioUrlFromEnv(flag) {
|
|
17
|
+
return (flag || process.env.ZUVO_STUDIO_URL || DEFAULT_STUDIO_URL).replace(/\/$/, '');
|
|
18
|
+
}
|
|
19
|
+
export function isAccessToken(value) {
|
|
20
|
+
return TOKEN_RE.test(value.trim());
|
|
21
|
+
}
|
|
22
|
+
export async function saveAccessToken(token) {
|
|
23
|
+
const dir = homeDir();
|
|
24
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
25
|
+
await writeFile(tokenPath(), `${token.trim()}\n`, { mode: 0o600 });
|
|
26
|
+
}
|
|
27
|
+
export async function deleteAccessToken() {
|
|
28
|
+
try {
|
|
29
|
+
await rm(tokenPath());
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const err = error;
|
|
34
|
+
if (err.code === 'ENOENT')
|
|
35
|
+
return false;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function loadAccessToken() {
|
|
40
|
+
const fromEnv = (process.env.ZUVO_ACCESS_TOKEN || '').trim();
|
|
41
|
+
if (fromEnv)
|
|
42
|
+
return fromEnv;
|
|
43
|
+
try {
|
|
44
|
+
return (await readFile(tokenPath(), 'utf8')).trim();
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const err = error;
|
|
48
|
+
if (err.code === 'ENOENT') {
|
|
49
|
+
throw new Error('Not logged in. Run `zuvo login` or set ZUVO_ACCESS_TOKEN.');
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function parseTomlRef(text) {
|
|
55
|
+
const match = text.match(/^\s*(?:project_id|project_ref)\s*=\s*["']([^"']+)["']\s*$/m);
|
|
56
|
+
return match?.[1] || null;
|
|
57
|
+
}
|
|
58
|
+
export async function loadLinkedRef(cwd = process.cwd()) {
|
|
59
|
+
const flag = process.env.ZUVO_PROJECT_REF?.trim();
|
|
60
|
+
if (flag)
|
|
61
|
+
return flag;
|
|
62
|
+
try {
|
|
63
|
+
const fromFile = (await readFile(path.join(cwd, '.zuvo', 'project-ref'), 'utf8')).trim();
|
|
64
|
+
if (fromFile)
|
|
65
|
+
return fromFile;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// fall through
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const toml = await readFile(path.join(cwd, 'zuvo.toml'), 'utf8');
|
|
72
|
+
return parseTomlRef(toml);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function saveLinkedRef(ref, cwd = process.cwd()) {
|
|
79
|
+
const dir = path.join(cwd, '.zuvo');
|
|
80
|
+
await mkdir(dir, { recursive: true });
|
|
81
|
+
await writeFile(path.join(dir, 'project-ref'), `${ref}\n`, 'utf8');
|
|
82
|
+
const tomlPath = path.join(cwd, 'zuvo.toml');
|
|
83
|
+
try {
|
|
84
|
+
const existing = await readFile(tomlPath, 'utf8');
|
|
85
|
+
if (/^\s*(?:project_id|project_ref)\s*=/m.test(existing)) {
|
|
86
|
+
await writeFile(tomlPath, existing.replace(/^\s*(?:project_id|project_ref)\s*=\s*["'][^"']*["']\s*$/m, `project_id = "${ref}"`), 'utf8');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await writeFile(tomlPath, `${existing.trimEnd()}\nproject_id = "${ref}"\n`, 'utf8');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
await writeFile(tomlPath, `project_id = "${ref}"\n`, 'utf8');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function requireLinkedRef(cwd = process.cwd()) {
|
|
96
|
+
const ref = await loadLinkedRef(cwd);
|
|
97
|
+
if (!ref) {
|
|
98
|
+
throw new Error('No project linked. Run `zuvo link --project <ref>`.');
|
|
99
|
+
}
|
|
100
|
+
return ref;
|
|
101
|
+
}
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createDecipheriv, createECDH } from 'node:crypto';
|
|
2
|
+
export function generateLoginKeyPair() {
|
|
3
|
+
const ecdh = createECDH('prime256v1');
|
|
4
|
+
ecdh.generateKeys();
|
|
5
|
+
return { ecdh, publicKeyHex: ecdh.getPublicKey('hex', 'uncompressed') };
|
|
6
|
+
}
|
|
7
|
+
/** Decrypt the Management API CLI login payload (AES-256-GCM, tag appended). */
|
|
8
|
+
export function decryptCliAccessToken(ecdh, payload) {
|
|
9
|
+
const sharedSecret = ecdh.computeSecret(Buffer.from(payload.public_key, 'hex'));
|
|
10
|
+
const ciphertextHex = payload.access_token.slice(0, -32);
|
|
11
|
+
const authTagHex = payload.access_token.slice(-32);
|
|
12
|
+
const decipher = createDecipheriv('aes-256-gcm', sharedSecret, Buffer.from(payload.nonce, 'hex'));
|
|
13
|
+
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
|
|
14
|
+
return Buffer.concat([
|
|
15
|
+
decipher.update(Buffer.from(ciphertextHex, 'hex')),
|
|
16
|
+
decipher.final(),
|
|
17
|
+
]).toString('utf8');
|
|
18
|
+
}
|
|
19
|
+
export function defaultTokenName() {
|
|
20
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
21
|
+
const user = process.env.USER || process.env.USERNAME || '';
|
|
22
|
+
const host = process.env.HOSTNAME || '';
|
|
23
|
+
if (user && host)
|
|
24
|
+
return `cli_${user}@${host}_${ts}`;
|
|
25
|
+
return `cli_${ts}`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
async function walkFiles(dir, prefix = '') {
|
|
4
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const ent of entries) {
|
|
7
|
+
if (ent.name.startsWith('.'))
|
|
8
|
+
continue;
|
|
9
|
+
const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
|
|
10
|
+
const full = path.join(dir, ent.name);
|
|
11
|
+
if (ent.isDirectory()) {
|
|
12
|
+
out.push(...(await walkFiles(full, rel)));
|
|
13
|
+
}
|
|
14
|
+
else if (ent.isFile()) {
|
|
15
|
+
out.push({ name: rel, content: await readFile(full, 'utf8') });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
export async function listFunctionSlugs(cwd = process.cwd()) {
|
|
21
|
+
const root = path.join(cwd, 'supabase', 'functions');
|
|
22
|
+
try {
|
|
23
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
24
|
+
return entries
|
|
25
|
+
.filter((ent) => ent.isDirectory() && !ent.name.startsWith('_') && !ent.name.startsWith('.'))
|
|
26
|
+
.map((ent) => ent.name)
|
|
27
|
+
.sort();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function loadFunctionBundle(cwd, slug) {
|
|
34
|
+
const dir = path.join(cwd, 'supabase', 'functions', slug);
|
|
35
|
+
const info = await stat(dir).catch(() => null);
|
|
36
|
+
if (!info?.isDirectory()) {
|
|
37
|
+
throw new Error(`Function directory not found: supabase/functions/${slug}`);
|
|
38
|
+
}
|
|
39
|
+
const files = await walkFiles(dir);
|
|
40
|
+
if (!files.length)
|
|
41
|
+
throw new Error(`No files in supabase/functions/${slug}`);
|
|
42
|
+
let verifyJwt = true;
|
|
43
|
+
const config = files.find((f) => f.name === 'config.toml' || f.name.endsWith('/config.toml'));
|
|
44
|
+
if (config) {
|
|
45
|
+
const match = config.content.match(/^\s*verify_jwt\s*=\s*(true|false)\s*$/m);
|
|
46
|
+
if (match)
|
|
47
|
+
verifyJwt = match[1] === 'true';
|
|
48
|
+
}
|
|
49
|
+
const entry = files.find((f) => f.name === 'index.ts') ||
|
|
50
|
+
files.find((f) => f.name.endsWith('/index.ts')) ||
|
|
51
|
+
files[0];
|
|
52
|
+
return {
|
|
53
|
+
slug,
|
|
54
|
+
name: slug,
|
|
55
|
+
verifyJwt,
|
|
56
|
+
entrypointPath: entry.name,
|
|
57
|
+
files: files.filter((f) => f.name !== 'config.toml' && !f.name.endsWith('/config.toml')),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function encodeFunctionForm(input) {
|
|
61
|
+
const form = new FormData();
|
|
62
|
+
form.set('metadata', JSON.stringify({
|
|
63
|
+
name: input.name,
|
|
64
|
+
slug: input.slug,
|
|
65
|
+
verify_jwt: input.verifyJwt,
|
|
66
|
+
entrypoint_path: input.entrypointPath,
|
|
67
|
+
}));
|
|
68
|
+
for (const file of input.files) {
|
|
69
|
+
form.append('file', new Blob([file.content], { type: 'text/plain' }), file.name);
|
|
70
|
+
}
|
|
71
|
+
return form;
|
|
72
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { apiRequest, ApiError } from './api.js';
|
|
4
|
+
import { apiUrlFromEnv, deleteAccessToken, isAccessToken, requireLinkedRef, saveLinkedRef, } from './config.js';
|
|
5
|
+
import { encodeFunctionForm, listFunctionSlugs, loadFunctionBundle } from './functions.js';
|
|
6
|
+
import { loginBrowser, loginWithToken } from './login.js';
|
|
7
|
+
import { loadLocalMigrations, pendingMigrations } from './migrations.js';
|
|
8
|
+
function usage() {
|
|
9
|
+
return `Usage: zuvo <command>
|
|
10
|
+
|
|
11
|
+
Commands:
|
|
12
|
+
login [--token <zpat_…>] [--name <token-name>] [--no-browser]
|
|
13
|
+
logout
|
|
14
|
+
projects list
|
|
15
|
+
link --project <ref>
|
|
16
|
+
functions list
|
|
17
|
+
functions deploy [slug]
|
|
18
|
+
db push
|
|
19
|
+
|
|
20
|
+
Global:
|
|
21
|
+
--api-url <url> Default https://api.zuvodev.com (or ZUVO_API_URL)
|
|
22
|
+
`;
|
|
23
|
+
}
|
|
24
|
+
function fail(error) {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
console.error(message);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
function parseGlobal(argv) {
|
|
30
|
+
const { values, positionals } = parseArgs({
|
|
31
|
+
args: argv,
|
|
32
|
+
options: {
|
|
33
|
+
'api-url': { type: 'string' },
|
|
34
|
+
help: { type: 'boolean', short: 'h' },
|
|
35
|
+
},
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
strict: false,
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
apiUrl: apiUrlFromEnv(typeof values['api-url'] === 'string' ? values['api-url'] : undefined),
|
|
41
|
+
help: Boolean(values.help),
|
|
42
|
+
positionals,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async function cmdLogin(apiUrl, argv) {
|
|
46
|
+
const { values } = parseArgs({
|
|
47
|
+
args: argv,
|
|
48
|
+
options: {
|
|
49
|
+
token: { type: 'string' },
|
|
50
|
+
name: { type: 'string' },
|
|
51
|
+
'no-browser': { type: 'boolean' },
|
|
52
|
+
'api-url': { type: 'string' },
|
|
53
|
+
},
|
|
54
|
+
allowPositionals: true,
|
|
55
|
+
strict: false,
|
|
56
|
+
});
|
|
57
|
+
const token = typeof values.token === 'string' ? values.token : '';
|
|
58
|
+
if (token) {
|
|
59
|
+
await loginWithToken(token);
|
|
60
|
+
}
|
|
61
|
+
else if (!process.stdin.isTTY) {
|
|
62
|
+
const chunks = [];
|
|
63
|
+
for await (const chunk of process.stdin)
|
|
64
|
+
chunks.push(chunk);
|
|
65
|
+
const piped = Buffer.concat(chunks).toString('utf8').trim();
|
|
66
|
+
if (!piped || !isAccessToken(piped)) {
|
|
67
|
+
throw new Error('Non-interactive login requires --token or a zpat_ token on stdin.');
|
|
68
|
+
}
|
|
69
|
+
await loginWithToken(piped);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
await loginBrowser({
|
|
73
|
+
apiUrl,
|
|
74
|
+
tokenName: typeof values.name === 'string' ? values.name : undefined,
|
|
75
|
+
openBrowser: values['no-browser'] !== true,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
console.log('You are now logged in. Happy coding!');
|
|
79
|
+
}
|
|
80
|
+
async function cmdLogout() {
|
|
81
|
+
const removed = await deleteAccessToken();
|
|
82
|
+
if (!removed) {
|
|
83
|
+
console.error('You were not logged in, nothing to do.');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
console.log('Logged out.');
|
|
87
|
+
}
|
|
88
|
+
async function cmdProjectsList(apiUrl) {
|
|
89
|
+
const projects = await apiRequest(apiUrl, 'GET', '/v1/projects');
|
|
90
|
+
if (!projects.length) {
|
|
91
|
+
console.log('No projects.');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const project of projects) {
|
|
95
|
+
const ref = project.ref || project.id || '';
|
|
96
|
+
console.log([ref, project.name || '', project.region || '', project.status || '']
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join('\t'));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function cmdLink(apiUrl, argv) {
|
|
102
|
+
const { values } = parseArgs({
|
|
103
|
+
args: argv,
|
|
104
|
+
options: {
|
|
105
|
+
project: { type: 'string' },
|
|
106
|
+
'api-url': { type: 'string' },
|
|
107
|
+
},
|
|
108
|
+
allowPositionals: true,
|
|
109
|
+
strict: false,
|
|
110
|
+
});
|
|
111
|
+
const ref = typeof values.project === 'string' ? values.project.trim() : '';
|
|
112
|
+
if (!ref)
|
|
113
|
+
throw new Error('Missing --project <ref>');
|
|
114
|
+
const project = await apiRequest(apiUrl, 'GET', `/v1/projects/${ref}`);
|
|
115
|
+
const linked = project.ref || project.id || ref;
|
|
116
|
+
await saveLinkedRef(linked);
|
|
117
|
+
console.log(`Linked to project ${linked}`);
|
|
118
|
+
}
|
|
119
|
+
async function cmdFunctionsList(apiUrl) {
|
|
120
|
+
const ref = await requireLinkedRef();
|
|
121
|
+
const fns = await apiRequest(apiUrl, 'GET', `/v1/projects/${ref}/functions`);
|
|
122
|
+
if (!Array.isArray(fns) || !fns.length) {
|
|
123
|
+
console.log('No functions.');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
for (const fn of fns) {
|
|
127
|
+
console.log([fn.slug || fn.name, fn.status, fn.version != null ? `v${fn.version}` : '']
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.join('\t'));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function deployOne(apiUrl, ref, slug) {
|
|
133
|
+
const bundle = await loadFunctionBundle(process.cwd(), slug);
|
|
134
|
+
const form = encodeFunctionForm(bundle);
|
|
135
|
+
const meta = await apiRequest(apiUrl, 'POST', `/v1/projects/${ref}/functions/deploy`, { form, query: { slug: bundle.slug } });
|
|
136
|
+
console.log(`Deployed ${meta.slug || bundle.slug}${meta.version != null ? ` (v${meta.version})` : ''}`);
|
|
137
|
+
}
|
|
138
|
+
async function cmdFunctionsDeploy(apiUrl, argv) {
|
|
139
|
+
const ref = await requireLinkedRef();
|
|
140
|
+
const slug = argv.find((arg) => !arg.startsWith('-'));
|
|
141
|
+
const slugs = slug ? [slug] : await listFunctionSlugs();
|
|
142
|
+
if (!slugs.length) {
|
|
143
|
+
throw new Error('No functions to deploy (expected supabase/functions/<slug>).');
|
|
144
|
+
}
|
|
145
|
+
for (const name of slugs) {
|
|
146
|
+
await deployOne(apiUrl, ref, name);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function cmdDbPush(apiUrl) {
|
|
150
|
+
const ref = await requireLinkedRef();
|
|
151
|
+
const local = await loadLocalMigrations();
|
|
152
|
+
if (!local.length) {
|
|
153
|
+
console.log('No migrations in supabase/migrations.');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const applied = await apiRequest(apiUrl, 'GET', `/v1/projects/${ref}/database/migrations`);
|
|
157
|
+
const pending = pendingMigrations(local, Array.isArray(applied) ? applied : []);
|
|
158
|
+
if (!pending.length) {
|
|
159
|
+
console.log('Remote database is up to date.');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (const migration of pending) {
|
|
163
|
+
await apiRequest(apiUrl, 'PUT', `/v1/projects/${ref}/database/migrations`, {
|
|
164
|
+
json: {
|
|
165
|
+
version: migration.version,
|
|
166
|
+
name: migration.name,
|
|
167
|
+
query: migration.query,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
console.log(`Applied ${migration.filename}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function main() {
|
|
174
|
+
const argv = process.argv.slice(2);
|
|
175
|
+
const global = parseGlobal(argv);
|
|
176
|
+
if (global.help || global.positionals[0] === 'help') {
|
|
177
|
+
console.log(usage());
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const [command, sub, ...rest] = global.positionals;
|
|
181
|
+
const apiUrl = global.apiUrl;
|
|
182
|
+
try {
|
|
183
|
+
if (command === 'login')
|
|
184
|
+
await cmdLogin(apiUrl, argv);
|
|
185
|
+
else if (command === 'logout')
|
|
186
|
+
await cmdLogout();
|
|
187
|
+
else if (command === 'projects' && (sub === 'list' || !sub))
|
|
188
|
+
await cmdProjectsList(apiUrl);
|
|
189
|
+
else if (command === 'link')
|
|
190
|
+
await cmdLink(apiUrl, argv);
|
|
191
|
+
else if (command === 'functions' && sub === 'list')
|
|
192
|
+
await cmdFunctionsList(apiUrl);
|
|
193
|
+
else if (command === 'functions' && sub === 'deploy')
|
|
194
|
+
await cmdFunctionsDeploy(apiUrl, rest);
|
|
195
|
+
else if (command === 'db' && sub === 'push')
|
|
196
|
+
await cmdDbPush(apiUrl);
|
|
197
|
+
else {
|
|
198
|
+
console.error(usage());
|
|
199
|
+
process.exit(command ? 1 : 0);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error instanceof ApiError && error.status === 403) {
|
|
204
|
+
fail('Forbidden. Deploy and db push require owner, admin, or developer.');
|
|
205
|
+
}
|
|
206
|
+
fail(error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
void main();
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { apiRequest } from './api.js';
|
|
7
|
+
import { isAccessToken, saveAccessToken, studioUrlFromEnv, } from './config.js';
|
|
8
|
+
import { decryptCliAccessToken, defaultTokenName, generateLoginKeyPair, } from './crypto.js';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const POLL_MS = 2000;
|
|
11
|
+
const POLL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
12
|
+
async function openBrowser(url) {
|
|
13
|
+
const platform = process.platform;
|
|
14
|
+
if (platform === 'darwin') {
|
|
15
|
+
await execFileAsync('open', [url]);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (platform === 'win32') {
|
|
19
|
+
await execFileAsync('cmd', ['/c', 'start', '', url]);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
await execFileAsync('xdg-open', [url]);
|
|
23
|
+
}
|
|
24
|
+
async function promptDeviceCode() {
|
|
25
|
+
const rl = createInterface({ input, output });
|
|
26
|
+
try {
|
|
27
|
+
const raw = await rl.question('Enter the 8-character code shown in the browser: ');
|
|
28
|
+
return raw.trim().toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
rl.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function pollSession(apiUrl, sessionId, deviceCode) {
|
|
35
|
+
const started = Date.now();
|
|
36
|
+
let lastError = 'timed out waiting for login';
|
|
37
|
+
while (Date.now() - started < POLL_TIMEOUT_MS) {
|
|
38
|
+
const url = `${apiUrl}/platform/cli/login/${sessionId}?device_code=${encodeURIComponent(deviceCode)}`;
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(url, { headers: { accept: 'application/json' } });
|
|
41
|
+
if (response.status === 200) {
|
|
42
|
+
return (await response.json());
|
|
43
|
+
}
|
|
44
|
+
lastError = `HTTP ${response.status}`;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
48
|
+
}
|
|
49
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`Login did not complete (${lastError})`);
|
|
52
|
+
}
|
|
53
|
+
export async function loginWithToken(token) {
|
|
54
|
+
const trimmed = token.trim();
|
|
55
|
+
if (!isAccessToken(trimmed)) {
|
|
56
|
+
throw new Error('Invalid access token format. Expected zpat_ followed by 64 hex characters.');
|
|
57
|
+
}
|
|
58
|
+
await saveAccessToken(trimmed);
|
|
59
|
+
}
|
|
60
|
+
export async function loginBrowser(opts) {
|
|
61
|
+
const { ecdh, publicKeyHex } = generateLoginKeyPair();
|
|
62
|
+
const sessionId = randomUUID();
|
|
63
|
+
const tokenName = opts.tokenName || defaultTokenName();
|
|
64
|
+
const studio = studioUrlFromEnv(opts.studioUrl);
|
|
65
|
+
const loginUrl = `${studio}/cli/login?session_id=${encodeURIComponent(sessionId)}` +
|
|
66
|
+
`&public_key=${encodeURIComponent(publicKeyHex)}` +
|
|
67
|
+
`&token_name=${encodeURIComponent(tokenName)}`;
|
|
68
|
+
console.log('Open this URL to authorize the CLI:');
|
|
69
|
+
console.log(loginUrl);
|
|
70
|
+
if (opts.openBrowser) {
|
|
71
|
+
try {
|
|
72
|
+
await openBrowser(loginUrl);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
console.error('Could not open a browser automatically.');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const deviceCode = await promptDeviceCode();
|
|
79
|
+
if (!/^[0-9a-f]{8}$/.test(deviceCode)) {
|
|
80
|
+
throw new Error('Device code must be the first 8 characters shown in Studio.');
|
|
81
|
+
}
|
|
82
|
+
const payload = await pollSession(opts.apiUrl, sessionId, deviceCode);
|
|
83
|
+
const token = decryptCliAccessToken(ecdh, payload);
|
|
84
|
+
await loginWithToken(token);
|
|
85
|
+
}
|
|
86
|
+
export async function verifyToken(apiUrl) {
|
|
87
|
+
return apiRequest(apiUrl, 'GET', '/v1/projects');
|
|
88
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const FILE_RE = /^(\d+)(?:_(.+))?\.sql$/i;
|
|
4
|
+
export function parseMigrationFilename(filename) {
|
|
5
|
+
const base = filename.split(/[/\\]/).pop() || filename;
|
|
6
|
+
const match = base.match(FILE_RE);
|
|
7
|
+
if (!match)
|
|
8
|
+
return null;
|
|
9
|
+
return {
|
|
10
|
+
version: match[1],
|
|
11
|
+
name: (match[2] || base.replace(/\.sql$/i, '')).replace(/-/g, '_'),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export async function loadLocalMigrations(cwd = process.cwd()) {
|
|
15
|
+
const dir = path.join(cwd, 'supabase', 'migrations');
|
|
16
|
+
let names;
|
|
17
|
+
try {
|
|
18
|
+
names = await readdir(dir);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const filename of names.sort()) {
|
|
25
|
+
const parsed = parseMigrationFilename(filename);
|
|
26
|
+
if (!parsed)
|
|
27
|
+
continue;
|
|
28
|
+
const query = await readFile(path.join(dir, filename), 'utf8');
|
|
29
|
+
if (!query.trim())
|
|
30
|
+
continue;
|
|
31
|
+
out.push({ ...parsed, filename, query });
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
export function pendingMigrations(local, applied) {
|
|
36
|
+
const have = new Set(applied.map((row) => row.version));
|
|
37
|
+
return local.filter((row) => !have.has(row.version));
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zuvo/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Zuvo CLI — login, link, functions deploy, and db push",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"zuvo": "bin/zuvo.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://studio.zuvodev.com",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"zuvo",
|
|
20
|
+
"cli",
|
|
21
|
+
"baas",
|
|
22
|
+
"supabase"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"prepublishOnly": "npm run build",
|
|
27
|
+
"start": "node dist/index.js",
|
|
28
|
+
"dev": "tsx src/index.ts",
|
|
29
|
+
"test": "NODE_ENV=test node --import tsx --test src/**/*.test.ts"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^20",
|
|
36
|
+
"tsx": "^4.20.3",
|
|
37
|
+
"typescript": "^5"
|
|
38
|
+
}
|
|
39
|
+
}
|