@zuvo/cli 0.1.2 → 0.1.4
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 +5 -1
- package/dist/api.js +42 -24
- package/dist/argv.js +33 -0
- package/dist/index.js +84 -5
- package/dist/secrets.js +52 -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, and `db push` against [Zuvo](https://studio.zuvodev.com).
|
|
3
|
+
Login, link a project, deploy Edge Functions, manage secrets, and `db push` against [Zuvo](https://studio.zuvodev.com).
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm i -g @zuvo/cli
|
|
@@ -8,6 +8,10 @@ zuvo login
|
|
|
8
8
|
zuvo projects list
|
|
9
9
|
zuvo link --project <ref>
|
|
10
10
|
zuvo functions deploy
|
|
11
|
+
zuvo secrets set OPENAI_API_KEY=sk-…
|
|
12
|
+
zuvo secrets set --env-file ./supabase/.env.local
|
|
13
|
+
zuvo secrets list
|
|
14
|
+
zuvo secrets unset OPENAI_API_KEY
|
|
11
15
|
zuvo db push
|
|
12
16
|
```
|
|
13
17
|
|
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/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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';
|
|
6
7
|
import { loginBrowser, loginWithToken } from './login.js';
|
|
7
8
|
import { loadLocalMigrations, pendingMigrations } from './migrations.js';
|
|
9
|
+
import { loadSecretsEnvFile, parseSecretArgs } from './secrets.js';
|
|
8
10
|
function usage() {
|
|
9
11
|
return `Usage: zuvo <command>
|
|
10
12
|
|
|
@@ -15,6 +17,10 @@ Commands:
|
|
|
15
17
|
link --project <ref>
|
|
16
18
|
functions list
|
|
17
19
|
functions deploy [slug]
|
|
20
|
+
secrets list
|
|
21
|
+
secrets set NAME=VALUE [NAME=VALUE ...]
|
|
22
|
+
secrets set --env-file <path>
|
|
23
|
+
secrets unset NAME [NAME ...]
|
|
18
24
|
db push
|
|
19
25
|
|
|
20
26
|
Global:
|
|
@@ -142,9 +148,76 @@ async function cmdFunctionsDeploy(apiUrl, argv) {
|
|
|
142
148
|
if (!slugs.length) {
|
|
143
149
|
throw new Error('No functions to deploy (expected supabase/functions/<slug>).');
|
|
144
150
|
}
|
|
145
|
-
|
|
146
|
-
|
|
151
|
+
const failures = [];
|
|
152
|
+
for (let i = 0; i < slugs.length; i++) {
|
|
153
|
+
const name = slugs[i];
|
|
154
|
+
try {
|
|
155
|
+
await deployOne(apiUrl, ref, name);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
159
|
+
failures.push({ slug: name, message });
|
|
160
|
+
console.error(`Failed ${name}: ${message}`);
|
|
161
|
+
if (slug)
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
// Space out multi-deploys so PostgREST/GoTrue do not rate-limit the next auth.
|
|
165
|
+
if (i < slugs.length - 1) {
|
|
166
|
+
await new Promise((r) => setTimeout(r, 1_500));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (failures.length) {
|
|
170
|
+
throw new Error(`Deploy finished with ${failures.length} failure(s): ${failures
|
|
171
|
+
.map((f) => f.slug)
|
|
172
|
+
.join(', ')}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function cmdSecretsList(apiUrl) {
|
|
176
|
+
const ref = await requireLinkedRef();
|
|
177
|
+
const rows = await apiRequest(apiUrl, 'GET', `/v1/projects/${ref}/secrets`);
|
|
178
|
+
if (!Array.isArray(rows) || !rows.length) {
|
|
179
|
+
console.log('No secrets.');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
for (const row of rows) {
|
|
183
|
+
console.log([row.name, row.updated_at].filter(Boolean).join('\t'));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function cmdSecretsSet(apiUrl, argv) {
|
|
187
|
+
const { values, positionals } = parseArgs({
|
|
188
|
+
args: argv,
|
|
189
|
+
options: {
|
|
190
|
+
'env-file': { type: 'string' },
|
|
191
|
+
'api-url': { type: 'string' },
|
|
192
|
+
},
|
|
193
|
+
allowPositionals: true,
|
|
194
|
+
strict: false,
|
|
195
|
+
});
|
|
196
|
+
const ref = await requireLinkedRef();
|
|
197
|
+
const fromFile = typeof values['env-file'] === 'string' && values['env-file'].trim()
|
|
198
|
+
? await loadSecretsEnvFile(values['env-file'].trim())
|
|
199
|
+
: [];
|
|
200
|
+
const fromArgs = parseSecretArgs(positionals);
|
|
201
|
+
const pairs = [...fromFile, ...fromArgs];
|
|
202
|
+
if (!pairs.length) {
|
|
203
|
+
throw new Error('Usage: zuvo secrets set NAME=VALUE [NAME=VALUE ...] | --env-file <path>');
|
|
204
|
+
}
|
|
205
|
+
// API rejects empty values — fail locally with a clear message.
|
|
206
|
+
for (const pair of pairs) {
|
|
207
|
+
if (!pair.value) {
|
|
208
|
+
throw new Error(`Secret ${pair.name} has an empty value`);
|
|
209
|
+
}
|
|
147
210
|
}
|
|
211
|
+
await apiRequest(apiUrl, 'POST', `/v1/projects/${ref}/secrets`, { json: pairs });
|
|
212
|
+
console.log(`Set ${pairs.map((p) => p.name).join(', ')}`);
|
|
213
|
+
}
|
|
214
|
+
async function cmdSecretsUnset(apiUrl, argv) {
|
|
215
|
+
const names = argv.filter((arg) => !arg.startsWith('-')).map((n) => n.trim()).filter(Boolean);
|
|
216
|
+
if (!names.length)
|
|
217
|
+
throw new Error('Usage: zuvo secrets unset NAME [NAME ...]');
|
|
218
|
+
const ref = await requireLinkedRef();
|
|
219
|
+
await apiRequest(apiUrl, 'DELETE', `/v1/projects/${ref}/secrets`, { json: names });
|
|
220
|
+
console.log(`Unset ${names.join(', ')}`);
|
|
148
221
|
}
|
|
149
222
|
async function cmdDbPush(apiUrl) {
|
|
150
223
|
const ref = await requireLinkedRef();
|
|
@@ -177,7 +250,7 @@ async function main() {
|
|
|
177
250
|
console.log(usage());
|
|
178
251
|
return;
|
|
179
252
|
}
|
|
180
|
-
const [command, sub
|
|
253
|
+
const [command, sub] = global.positionals;
|
|
181
254
|
const apiUrl = global.apiUrl;
|
|
182
255
|
try {
|
|
183
256
|
if (command === 'login')
|
|
@@ -191,7 +264,13 @@ async function main() {
|
|
|
191
264
|
else if (command === 'functions' && sub === 'list')
|
|
192
265
|
await cmdFunctionsList(apiUrl);
|
|
193
266
|
else if (command === 'functions' && sub === 'deploy')
|
|
194
|
-
await cmdFunctionsDeploy(apiUrl,
|
|
267
|
+
await cmdFunctionsDeploy(apiUrl, argvAfterCommand(argv, 'functions', 'deploy'));
|
|
268
|
+
else if (command === 'secrets' && (sub === 'list' || !sub))
|
|
269
|
+
await cmdSecretsList(apiUrl);
|
|
270
|
+
else if (command === 'secrets' && sub === 'set')
|
|
271
|
+
await cmdSecretsSet(apiUrl, argvAfterCommand(argv, 'secrets', 'set'));
|
|
272
|
+
else if (command === 'secrets' && (sub === 'unset' || sub === 'delete'))
|
|
273
|
+
await cmdSecretsUnset(apiUrl, argvAfterCommand(argv, 'secrets', sub));
|
|
195
274
|
else if (command === 'db' && sub === 'push')
|
|
196
275
|
await cmdDbPush(apiUrl);
|
|
197
276
|
else {
|
|
@@ -201,7 +280,7 @@ async function main() {
|
|
|
201
280
|
}
|
|
202
281
|
catch (error) {
|
|
203
282
|
if (error instanceof ApiError && error.status === 403) {
|
|
204
|
-
fail('Forbidden.
|
|
283
|
+
fail('Forbidden. Functions, secrets, and db push require owner, admin, or developer.');
|
|
205
284
|
}
|
|
206
285
|
fail(error);
|
|
207
286
|
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
/** Parse `NAME=VALUE` or `NAME VALUE` style CLI args into secret pairs. */
|
|
3
|
+
export function parseSecretArgs(args) {
|
|
4
|
+
const out = [];
|
|
5
|
+
for (let i = 0; i < args.length; i++) {
|
|
6
|
+
const arg = args[i];
|
|
7
|
+
if (arg.startsWith('-'))
|
|
8
|
+
continue;
|
|
9
|
+
const eq = arg.indexOf('=');
|
|
10
|
+
if (eq > 0) {
|
|
11
|
+
const name = arg.slice(0, eq).trim();
|
|
12
|
+
const value = arg.slice(eq + 1);
|
|
13
|
+
if (!name)
|
|
14
|
+
throw new Error(`Invalid secret: ${arg}`);
|
|
15
|
+
out.push({ name, value });
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const next = args[i + 1];
|
|
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
|
+
}
|
|
23
|
+
throw new Error(`Missing value for secret ${arg} (use NAME=VALUE)`);
|
|
24
|
+
}
|
|
25
|
+
out.push({ name: arg.trim(), value: next });
|
|
26
|
+
i++;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
/** Load KEY=VALUE pairs from a dotenv-style file (comments and blanks ignored). */
|
|
31
|
+
export async function loadSecretsEnvFile(filePath) {
|
|
32
|
+
const text = await readFile(filePath, 'utf8');
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
35
|
+
const line = raw.trim();
|
|
36
|
+
if (!line || line.startsWith('#'))
|
|
37
|
+
continue;
|
|
38
|
+
const eq = line.indexOf('=');
|
|
39
|
+
if (eq <= 0)
|
|
40
|
+
throw new Error(`Invalid line in ${filePath}: ${raw}`);
|
|
41
|
+
const name = line.slice(0, eq).trim();
|
|
42
|
+
let value = line.slice(eq + 1).trim();
|
|
43
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
44
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
45
|
+
value = value.slice(1, -1);
|
|
46
|
+
}
|
|
47
|
+
if (!name)
|
|
48
|
+
throw new Error(`Invalid line in ${filePath}: ${raw}`);
|
|
49
|
+
out.push({ name, value });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zuvo/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Zuvo CLI — login, link, functions deploy, and db push",
|
|
5
|
+
"description": "Zuvo CLI — login, link, functions deploy, secrets, and db push",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
8
|
"zuvo": "bin/zuvo.js"
|