@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 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
- 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);
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
- catch {
37
- parsed = text;
35
+ else if (opts.json !== undefined) {
36
+ headers['content-type'] = 'application/json';
37
+ body = JSON.stringify(opts.json);
38
38
  }
39
- }
40
- if (!response.ok) {
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
- throw new ApiError(response.status, message);
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
- return parsed;
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
- for (const name of slugs) {
146
- await deployOne(apiUrl, ref, name);
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, ...rest] = global.positionals;
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, rest);
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. Deploy and db push require owner, admin, or developer.');
283
+ fail('Forbidden. Functions, secrets, and db push require owner, admin, or developer.');
205
284
  }
206
285
  fail(error);
207
286
  }
@@ -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.2",
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"