@myapihq/cli 1.2.5 → 1.2.6

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.
@@ -8,3 +8,4 @@ export declare function balance(flags: Flags): Promise<void>;
8
8
  export declare function history(flags: Flags): Promise<void>;
9
9
  export declare function topup(amountStr: string, flags: Flags): Promise<void>;
10
10
  export declare function setup(_flags: Flags): Promise<void>;
11
+ export declare function spendCap(arg: string | undefined, flags: Flags): Promise<void>;
@@ -3,12 +3,16 @@ import { requireConfig } from '../config.js';
3
3
  import { success, error, printTable, info, printJson } from '../output.js';
4
4
  import { confirm, isNonInteractive } from '../prompt.js';
5
5
  import { formatDate } from '../utils.js';
6
- export const SCHEMA = {};
6
+ export const SCHEMA = {
7
+ period: 'string', // spend-cap window: month | day
8
+ };
7
9
  export const EXPOSES = [
8
10
  'GET /hq/billing/balance',
9
11
  'GET /hq/billing/history',
10
12
  'POST /hq/billing/setup-payment',
11
13
  'POST /hq/billing/topup',
14
+ 'GET /hq/account/me',
15
+ 'PATCH /hq/account/spend-cap',
12
16
  ];
13
17
  const SUBCOMMAND_USAGE = {
14
18
  'balance': 'myapi billing balance [--json]',
@@ -20,6 +24,18 @@ Confirmation is required for amounts of $50 or more — pass --yes to skip it.
20
24
 
21
25
  Example: myapi billing topup 10`,
22
26
  'setup': 'myapi billing setup',
27
+ 'spend-cap': `myapi billing spend-cap [<amount> | clear] [--period month|day]
28
+
29
+ The account-level spend ceiling (IAM "Layer 2") — a self-imposed limit
30
+ *below* your balance that bounds total spend across every key and function.
31
+
32
+ myapi billing spend-cap Show the current cap + period spend
33
+ myapi billing spend-cap 50 Cap total spend at $50/month
34
+ myapi billing spend-cap 5 --period day Cap at $5/day
35
+ myapi billing spend-cap clear Remove the cap
36
+
37
+ This is distinct from a per-key cap (myapi keys create --spend-cap). The
38
+ account cap is the aggregate backstop; per-key caps bound each credential.`,
23
39
  };
24
40
  export async function run(subcommand, args, flags) {
25
41
  if (!subcommand || (flags.help && !subcommand)) {
@@ -29,7 +45,8 @@ Subcommands:
29
45
  balance Check balance, credits, and payment method status
30
46
  history View recent transactions and top-ups
31
47
  topup Top up your balance (whole dollars)
32
- setup Open a checkout link to add or update payment method`);
48
+ setup Open a checkout link to add or update payment method
49
+ spend-cap Set/show/clear the account-level spend ceiling (IAM Layer 2)`);
33
50
  return;
34
51
  }
35
52
  if (flags.help) {
@@ -45,6 +62,7 @@ Subcommands:
45
62
  case 'history': return history(flags);
46
63
  case 'topup': return topup(args[0], flags);
47
64
  case 'setup': return setup(flags);
65
+ case 'spend-cap': return spendCap(args[0], flags);
48
66
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi billing --help" for available subcommands.`);
49
67
  }
50
68
  }
@@ -114,3 +132,44 @@ export async function setup(_flags) {
114
132
  const result = await hq.setupPayment(config.api_key);
115
133
  success(`Open this URL in your browser to set up payment:\n${result.url}`);
116
134
  }
135
+ // The account-level spend ceiling. No arg → show; "clear" → remove; a
136
+ // dollar amount → set. Distinct from per-key caps (myapi keys create
137
+ // --spend-cap): this is the aggregate backstop across the whole account.
138
+ export async function spendCap(arg, flags) {
139
+ const config = requireConfig();
140
+ if (!arg) {
141
+ const acct = await hq.getAccount(config.api_key);
142
+ if (flags.json) {
143
+ printJson({
144
+ spend_cap_cents: acct.spend_cap_cents ?? null,
145
+ current_period_spend_cents: acct.current_period_spend_cents ?? null,
146
+ });
147
+ return;
148
+ }
149
+ if (acct.spend_cap_cents == null) {
150
+ info('No account spend cap set.');
151
+ info('Set one with: myapi billing spend-cap <dollars> [--period month|day]');
152
+ }
153
+ else {
154
+ const spent = acct.current_period_spend_cents ?? 0;
155
+ info(`Account spend cap: $${(spent / 100).toFixed(2)} spent / $${(acct.spend_cap_cents / 100).toFixed(2)} this period`);
156
+ }
157
+ return;
158
+ }
159
+ if (arg === 'clear' || arg === 'none') {
160
+ await hq.setAccountSpendCap(config.api_key, null);
161
+ success('Account spend cap cleared.');
162
+ return;
163
+ }
164
+ const dollars = Number(arg);
165
+ if (!Number.isFinite(dollars) || dollars < 0) {
166
+ error(`Invalid amount "${arg}". Use a non-negative dollar amount, or "clear" to remove the cap.\nExample: myapi billing spend-cap 50`);
167
+ }
168
+ const period = flags.period || 'month';
169
+ if (period !== 'month' && period !== 'day') {
170
+ error(`Invalid --period "${period}". Use month or day.`);
171
+ }
172
+ const res = await hq.setAccountSpendCap(config.api_key, Math.round(dollars * 100), period);
173
+ const cents = res.spend_cap_cents ?? Math.round(dollars * 100);
174
+ success(`Account spend cap set: $${(cents / 100).toFixed(2)} per ${res.spend_cap_period}.`);
175
+ }
@@ -16,6 +16,11 @@ export const EXPOSES = [
16
16
  'GET /domain/orgs/{org_id}/{domain}/status',
17
17
  'POST /domain/orgs/{org_id}/{domain}/email-infra',
18
18
  'POST /domain/orgs/{org_id}/{domain}/retry-provisioning',
19
+ 'GET /domain/orgs/{org_id}/{domain}/records',
20
+ 'POST /domain/orgs/{org_id}/{domain}/records',
21
+ 'GET /domain/orgs/{org_id}/{domain}/records/{record_id}',
22
+ 'PATCH /domain/orgs/{org_id}/{domain}/records/{record_id}',
23
+ 'DELETE /domain/orgs/{org_id}/{domain}/records/{record_id}',
19
24
  ];
20
25
  export const SCHEMA = {
21
26
  org: 'string',
@@ -10,4 +10,7 @@ export declare function create(flags: Flags): Promise<void>;
10
10
  export declare function list(flags: Flags): Promise<void>;
11
11
  export declare function get(id: string, flags: Flags): Promise<void>;
12
12
  export declare function del(id: string, flags: Flags): Promise<void>;
13
+ export declare function deploy(id: string, bundlePath: string, flags: Flags): Promise<void>;
14
+ export declare function setEnv(id: string, name: string, value: string, flags: Flags): Promise<void>;
15
+ export declare function runs(id: string, flags: Flags): Promise<void>;
13
16
  export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -1,20 +1,23 @@
1
+ import { readFile } from 'fs/promises';
1
2
  import { fn as sdkFn } from '@myapihq/sdk';
2
3
  import { requireConfig } from '../config.js';
3
4
  import { success, error, printTable, info, printJson, banner } from '../output.js';
5
+ import { formatDate } from '../utils.js';
4
6
  import { requireOrg } from '../helpers.js';
5
7
  export const EXPOSES = [
6
8
  'POST /function/orgs/{org_id}/functions',
7
9
  'GET /function/orgs/{org_id}/functions',
8
10
  'GET /function/orgs/{org_id}/functions/{id}',
9
11
  'DELETE /function/orgs/{org_id}/functions/{id}',
12
+ 'POST /function/orgs/{org_id}/functions/{id}/bundle',
13
+ 'POST /function/orgs/{org_id}/functions/{id}/env',
14
+ 'GET /function/orgs/{org_id}/functions/{id}/runs',
10
15
  ];
11
16
  export const SCHEMA = {
12
17
  cron: 'string',
13
18
  };
14
- // Backend: Story 1. The function CRUD persists metadata + issues a scoped
15
- // API key, but does NOT yet upload a JS bundle to Cloudflare Workers (Story
16
- // 2). So this CLI surface is intentionally narrow — `create` registers a
17
- // function, not "deploy" — keeping verb honesty until bundle upload ships.
19
+ // Backend: Story 1 (function CRUD + scoped key) and Story 2/4/5 (deploy a
20
+ // JS bundle to Cloudflare Workers, list runs, set env secrets).
18
21
  // Mirrors validateName in myapi-hq/internal/routes/function/crud.go. We
19
22
  // pre-validate client-side so typos fail before the network call; backend
20
23
  // runs the same regex as defence in depth.
@@ -45,7 +48,7 @@ function summarizeFn(f) {
45
48
  id: f.id,
46
49
  name: f.name,
47
50
  trigger: f.trigger_type === 'cron' ? `cron ${f.cron_schedule ?? '?'}` : 'http',
48
- url: f.invocation_url || '(pending Story 2)',
51
+ url: f.invocation_url || '(not deployed)',
49
52
  updated_at: f.updated_at,
50
53
  };
51
54
  }
@@ -69,16 +72,15 @@ export async function create(flags) {
69
72
  info(`Name: ${result.function.name}`);
70
73
  info(`Trigger: ${result.function.trigger_type}${result.function.cron_schedule ? ` (${result.function.cron_schedule})` : ''}`);
71
74
  // The scoped key is returned ONCE — surface it prominently. It's used by
72
- // the function runtime shim (Story 2) to call other slot endpoints
73
- // without a baked-in auth token.
75
+ // the function runtime shim to call other slot endpoints without a
76
+ // baked-in auth token. Deploy rotates this key.
74
77
  info('');
75
78
  info(`Scoped API key (returned once — save it if you need it):`);
76
79
  info(` ${result.scoped_api_key}`);
77
80
  info(` (id: ${result.scoped_api_key_id}; scopes: slot_call; rejected at /hq/*, /admin/*, /internal/*)`);
78
- // Story 2 hasn't landed yet — be explicit about what "create" produces today.
79
81
  if (!result.function.invocation_url) {
80
82
  info('');
81
- banner('Note: bundle upload (Story 2) is not yet shipped — this function has no executable code. The metadata record + scoped key are persisted; the invocation URL will appear once Story 2 lands.');
83
+ banner(`Next: deploy code with myapi fn deploy ${result.function.id} <bundle.js>`);
82
84
  }
83
85
  }
84
86
  export async function list(flags) {
@@ -107,7 +109,7 @@ export async function get(id, flags) {
107
109
  info(`ID: ${fn.id}`);
108
110
  info(`Name: ${fn.name}`);
109
111
  info(`Trigger: ${fn.trigger_type}${fn.cron_schedule ? ` (${fn.cron_schedule})` : ''}`);
110
- info(`Invocation URL: ${fn.invocation_url || '(pending Story 2)'}`);
112
+ info(`Invocation URL: ${fn.invocation_url || '(not deployed)'}`);
111
113
  info(`Created: ${fn.created_at}`);
112
114
  info(`Updated: ${fn.updated_at}`);
113
115
  }
@@ -119,26 +121,113 @@ export async function del(id, flags) {
119
121
  await sdkFn.deleteFunction(config.api_key, orgId, id);
120
122
  success(`Deleted function ${id}`);
121
123
  }
124
+ // deploy uploads a single-file JS bundle. The backend wraps it with the
125
+ // MYAPI shim and ships it to Cloudflare Workers. The scoped API key is
126
+ // rotated on every deploy — the fresh value is shown once here.
127
+ export async function deploy(id, bundlePath, flags) {
128
+ const config = requireConfig();
129
+ const orgId = requireOrg(flags, config, 'myapi fn deploy <id> <bundle.js> [--org <id>]');
130
+ if (!id)
131
+ error('Missing id.\nUsage: myapi fn deploy <id> <bundle.js>');
132
+ if (!bundlePath)
133
+ error('Missing bundle file.\nUsage: myapi fn deploy <id> <bundle.js>\n\n→ <bundle.js> is a single-file JavaScript bundle (≤4MB).');
134
+ let data;
135
+ try {
136
+ data = await readFile(bundlePath);
137
+ }
138
+ catch (e) {
139
+ if (e?.code === 'ENOENT')
140
+ error(`File not found: ${bundlePath}`);
141
+ if (e?.code === 'EACCES')
142
+ error(`Permission denied: ${bundlePath}`);
143
+ error(`Could not read ${bundlePath}: ${e?.message ?? e}`);
144
+ }
145
+ const result = await sdkFn.uploadBundle(config.api_key, orgId, id, data, bundlePath.split('/').pop() || 'bundle.js');
146
+ if (flags.json) {
147
+ printJson(result);
148
+ return;
149
+ }
150
+ success(`Deployed function ${id}`);
151
+ info(`Invocation URL: ${result.invocation_url}`);
152
+ info('');
153
+ info(`Scoped API key was rotated. New value (returned once — save it if you need it):`);
154
+ info(` ${result.scoped_api_key}`);
155
+ }
156
+ // env sets a Worker Secret (Stripe key, etc.) on a deployed function.
157
+ export async function setEnv(id, name, value, flags) {
158
+ const config = requireConfig();
159
+ const orgId = requireOrg(flags, config, 'myapi fn env <id> <name> <value> [--org <id>]');
160
+ if (!id)
161
+ error('Missing id.\nUsage: myapi fn env <id> <name> <value>');
162
+ if (!name)
163
+ error('Missing secret name.\nUsage: myapi fn env <id> <name> <value>');
164
+ if (value === undefined)
165
+ error('Missing secret value.\nUsage: myapi fn env <id> <name> <value>');
166
+ await sdkFn.setFunctionEnv(config.api_key, orgId, id, name, value);
167
+ success(`Set ${name} on function ${id}`);
168
+ info('The value is encrypted at rest by Cloudflare and never stored or echoed by MyAPI.');
169
+ }
170
+ // runs lists recent invocation records, most recent first.
171
+ export async function runs(id, flags) {
172
+ const config = requireConfig();
173
+ const orgId = requireOrg(flags, config, 'myapi fn runs <id> [--org <id>]');
174
+ if (!id)
175
+ error('Missing id.\nUsage: myapi fn runs <id>');
176
+ const records = await sdkFn.listFunctionRuns(config.api_key, orgId, id);
177
+ if (flags.json) {
178
+ printJson(records);
179
+ return;
180
+ }
181
+ printTable(records.map(r => ({
182
+ id: r.id,
183
+ invoked_at: formatDate(r.invoked_at),
184
+ status: r.status || '?',
185
+ duration_ms: r.duration_ms ?? '',
186
+ error: r.error_message || '',
187
+ })), {
188
+ flags,
189
+ empty: 'No runs recorded yet for this function.',
190
+ });
191
+ }
122
192
  // ── Dispatcher ───────────────────────────────────────────────────────────────
123
193
  const SUBCOMMAND_USAGE = {
124
194
  'create': `myapi fn create --name <name> [--cron <expr>] [--org <id>]
125
195
 
126
- Backend Story 1: persists a function record + issues a scoped API key.
127
- Bundle upload (Story 2) is NOT yet shipped — this command does not deploy
128
- JavaScript code today. When Story 2 lands, the CLI surface will grow to
129
- accept --bundle <file.js>.
196
+ Persists a function record + issues a scoped API key. Deploy code separately
197
+ with "myapi fn deploy".
130
198
 
131
199
  Triggers:
132
- (default) HTTP — function will receive a public invocation URL in Story 2.
133
- --cron <expr> Cron — function will run on the schedule (e.g. "0 8 * * *").
200
+ (default) HTTP — function gets a public invocation URL once deployed.
201
+ --cron <expr> Cron — function runs on the schedule (e.g. "0 8 * * *").
134
202
 
135
203
  Examples:
136
204
  myapi fn create --name my-app-api
137
205
  myapi fn create --name daily-report --cron "0 8 * * *"
138
206
 
139
- The response returns the scoped API key ONCE. Save it if you need to call
140
- other MyAPI slots from a script with the function's permissions (scopes=slot_call;
141
- rejected at /hq/*, /admin/*, /internal/*).`,
207
+ The response returns the scoped API key ONCE. It lets the function call
208
+ other MyAPI slots with its own permissions (scopes=slot_call; rejected at
209
+ /hq/*, /admin/*, /internal/*).`,
210
+ 'deploy': `myapi fn deploy <id> <bundle.js> [--org <id>] [--json]
211
+
212
+ Uploads a single-file JavaScript bundle (≤4MB) to the edge runtime. The
213
+ backend wraps it with the MYAPI shim and ships it to Cloudflare Workers.
214
+
215
+ The scoped API key is rotated on every deploy — the fresh value is printed
216
+ once. After deploy the function has a live invocation URL.
217
+
218
+ Example:
219
+ myapi fn deploy <id> ./dist/bundle.js`,
220
+ 'env': `myapi fn env <id> <name> <value> [--org <id>]
221
+
222
+ Sets a secret (Stripe key, API token, ...) as a Cloudflare Worker Secret on
223
+ a deployed function. The value is encrypted at rest and never stored in
224
+ MyAPI or echoed back. The function must already be deployed.
225
+
226
+ Example:
227
+ myapi fn env <id> STRIPE_KEY sk_live_...`,
228
+ 'runs': `myapi fn runs <id> [--org <id>] [--json]
229
+
230
+ Lists recent invocation records (most recent first, up to 100).`,
142
231
  'list': 'myapi fn list [--org <id>] [--json]',
143
232
  'get': 'myapi fn get <id> [--org <id>] [--json]',
144
233
  'delete': 'myapi fn delete <id> [--org <id>]',
@@ -147,14 +236,13 @@ export async function run(subcommand, args, flags) {
147
236
  if (!subcommand || (flags.help && !subcommand)) {
148
237
  info(`Usage: myapi fn <subcommand>
149
238
 
150
- Create and manage functions on the MyAPI edge runtime.
151
-
152
- Backend status: Story 1 shipped (metadata + scoped API key). Story 2 (CF
153
- Workers upload, invocation URL, /logs, /env) lands soon. CLI verbs grow
154
- once Story 2 ships; today only "create" / "list" / "get" / "delete" work.
239
+ Create, deploy, and manage functions on the MyAPI edge runtime.
155
240
 
156
241
  Subcommands:
157
242
  create Register a function and get its scoped API key (returned once)
243
+ deploy <id> <file> Upload a JS bundle and go live
244
+ env <id> <k> <v> Set a Worker Secret on a deployed function
245
+ runs <id> List recent invocation records
158
246
  list List functions in your org
159
247
  get <id> Inspect a function
160
248
  delete <id> Soft-delete and revoke its scoped API key
@@ -172,6 +260,9 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
172
260
  }
173
261
  switch (subcommand) {
174
262
  case 'create': return create(flags);
263
+ case 'deploy': return deploy(args[0], args[1], flags);
264
+ case 'env': return setEnv(args[0], args[1], args[2], flags);
265
+ case 'runs': return runs(args[0], flags);
175
266
  case 'list': return list(flags);
176
267
  case 'get': return get(args[0], flags);
177
268
  case 'delete': return del(args[0], flags);
@@ -10,4 +10,5 @@ export declare function del(id: string, flags: Flags): Promise<void>;
10
10
  export declare function pages(funnelArg: string, flags: Flags): Promise<void>;
11
11
  export declare function push(slug: string, flags: Flags): Promise<void>;
12
12
  export declare function verify(slug: string, flags: Flags): Promise<void>;
13
+ export declare function publish(dir: string, flags: Flags): Promise<void>;
13
14
  export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -1,3 +1,5 @@
1
+ import { readdir, readFile, stat } from 'fs/promises';
2
+ import { join, relative, sep } from 'path';
1
3
  import { funnel as sdkFunnel, hq } from '@myapihq/sdk';
2
4
  import { requireConfig } from '../config.js';
3
5
  import { success, error, printTable, info, printJson } from '../output.js';
@@ -10,12 +12,15 @@ export const EXPOSES = [
10
12
  'DELETE /funnel/orgs/{org_id}/funnels/{funnel_id}',
11
13
  'POST /funnel/orgs/{org_id}/funnels/{funnel_id}/push-page',
12
14
  'POST /funnel/orgs/{org_id}/funnels/{funnel_id}/verify',
15
+ 'POST /funnel/orgs/{org_id}/funnels/{funnel_id}/files',
13
16
  'GET /funnel/orgs/{org_id}/funnels/{funnel_id}/pages',
14
17
  'GET /hq/orgs/{org_id}',
15
18
  ];
16
19
  export const SCHEMA = {
17
20
  funnel: 'string',
18
21
  slug: 'string',
22
+ env: 'string',
23
+ 'api-fn': 'string',
19
24
  };
20
25
  // Backend (2026-05-15): POST /funnels accepts an optional `name` (defaults
21
26
  // to the org's preview_subdomain for back-compat). Mirror the backend's
@@ -193,6 +198,66 @@ export async function verify(slug, flags) {
193
198
  const v = await sdkFunnel.verifyFunnel(config.api_key, orgId, funnelId, { slug: finalSlug });
194
199
  printJson(v);
195
200
  }
201
+ // Recursively collect every file under `dir`, returning site-relative
202
+ // paths (POSIX separators) paired with their contents.
203
+ async function collectFiles(dir, root) {
204
+ const out = [];
205
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
206
+ const abs = join(dir, entry.name);
207
+ if (entry.isDirectory()) {
208
+ out.push(...await collectFiles(abs, root));
209
+ }
210
+ else if (entry.isFile()) {
211
+ out.push({ path: relative(root, abs).split(sep).join('/'), content: await readFile(abs) });
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+ // publish uploads a whole directory as the funnel's site (my-funnel-api v2).
217
+ // Resolves the funnel the same way push/verify do.
218
+ export async function publish(dir, flags) {
219
+ const config = requireConfig();
220
+ const orgId = requireOrg(flags, config, 'myapi funnel publish <dir> [--funnel <id>] [--env dev|prod] [--org <id>]');
221
+ if (!dir)
222
+ error('Missing directory.\nUsage: myapi funnel publish <dir> [--funnel <id>] [--env dev|prod]');
223
+ let dirStat;
224
+ try {
225
+ dirStat = await stat(dir);
226
+ }
227
+ catch {
228
+ error(`Directory not found: ${dir}`);
229
+ }
230
+ if (!dirStat.isDirectory())
231
+ error(`Not a directory: ${dir}. Pass the site's root folder.`);
232
+ const env = flags.env;
233
+ if (env && env !== 'dev' && env !== 'prod')
234
+ error(`Invalid --env "${env}". Use "dev" or "prod" (default prod).`);
235
+ let funnelId = flags.funnel || config.default_funnel;
236
+ if (!funnelId) {
237
+ const existing = await sdkFunnel.listFunnels(config.api_key, orgId);
238
+ if (existing.length === 0)
239
+ error('No funnel found for this org. Create one with: myapi funnel create');
240
+ if (existing.length > 1) {
241
+ error(`Multiple funnels exist for this org and no default is set.\nPick one with --funnel <id>, or set a default:\n myapi config set-funnel <id>\n\nFunnels:\n${existing.map(f => ` ${f.id}`).join('\n')}`);
242
+ }
243
+ funnelId = existing[0].id;
244
+ }
245
+ const files = await collectFiles(dir, dir);
246
+ if (files.length === 0)
247
+ error(`No files found under ${dir}.`);
248
+ const result = await sdkFunnel.publishFiles(config.api_key, orgId, funnelId, files, {
249
+ env: env,
250
+ apiFunctionId: flags['api-fn'],
251
+ });
252
+ if (flags.json) {
253
+ printJson(result);
254
+ return;
255
+ }
256
+ success(`Published ${result.file_count} file(s) to the ${result.channel} channel`);
257
+ info(`Size: ${(result.size_bytes / 1024).toFixed(1)} KB`);
258
+ info(`SPA: ${result.spa_mode ? 'on' : 'off'}`);
259
+ info(`Live: ${result.published_url}`);
260
+ }
196
261
  // ── Dispatcher ───────────────────────────────────────────────────────────────
197
262
  const SUBCOMMAND_USAGE = {
198
263
  'list': 'myapi funnel list [--org <id>] [--json]',
@@ -200,6 +265,22 @@ const SUBCOMMAND_USAGE = {
200
265
  'get': 'myapi funnel get <id> [--org <id>] [--json]',
201
266
  'delete': 'myapi funnel delete <id> [--org <id>]',
202
267
  'pages': 'myapi funnel pages [funnel_id] [--funnel <id>] [--org <id>] [--json]',
268
+ 'publish': `myapi funnel publish <dir> [--funnel <id>] [--env dev|prod] [--api-fn <id>] [--org <id>]
269
+
270
+ Uploads a whole local directory as the funnel's site. Each file's path
271
+ within <dir> becomes its path on the site (e.g. dir/about/index.html →
272
+ /about/index.html). 25MB total cap.
273
+
274
+ --env dev|prod Target channel (default prod). dev publishes to
275
+ <name>-dev.makeautonomous.com for preview.
276
+ --api-fn <id> Bind /api/* on the site to a deployed function.
277
+
278
+ SPA fallback is auto-enabled when the publish has a root index.html.
279
+
280
+ Examples:
281
+ myapi funnel publish ./dist
282
+ myapi funnel publish ./dist --env dev
283
+ myapi funnel publish ./site --api-fn <function_id>`,
203
284
  'push': `myapi funnel push [slug] [--funnel <id>] [--slug <path>] [--org <id>] < page.html
204
285
 
205
286
  Reads HTML from stdin and publishes to <slug> on your funnel. Funnel is resolved
@@ -235,6 +316,7 @@ Subcommands:
235
316
  create Create a funnel (optional --name; backend defaults to org's preview_subdomain)
236
317
  get <id> Get funnel details
237
318
  delete Delete a funnel
319
+ publish Publish a whole local directory as the site (dev or prod)
238
320
  push Push a raw HTML page from stdin to a slug
239
321
  pages List the pages currently published to a funnel
240
322
  verify Verify a published page is reachable
@@ -255,6 +337,7 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
255
337
  case 'create': return create(flags);
256
338
  case 'get': return get(args[0], flags);
257
339
  case 'delete': return del(args[0], flags);
340
+ case 'publish': return publish(args[0], flags);
258
341
  case 'push': return push(args[0], flags);
259
342
  case 'verify': return verify(args[0], flags);
260
343
  case 'pages': return pages(args[0], flags);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ // Unit tests for the keys-command pure validators: `--grant` parsing and
2
+ // dollar→cents conversion. Both mirror server-side rules so a bad value
3
+ // fails before the network call.
4
+ import { describe, it, expect } from 'vitest';
5
+ import { _parseGrants, _dollarsToCents } from './keys.js';
6
+ import { hq } from '@myapihq/sdk';
7
+ describe('_parseGrants', () => {
8
+ describe('accepts', () => {
9
+ it('a bare slot — write is implied', () => {
10
+ expect(_parseGrants('email')).toEqual({ email: 'write' });
11
+ });
12
+ it('an explicit slot:access pair', () => {
13
+ expect(_parseGrants('crm:read')).toEqual({ crm: 'read' });
14
+ });
15
+ it('multiple comma-separated entries, mixed forms', () => {
16
+ expect(_parseGrants('email:write,crm:read,database')).toEqual({
17
+ email: 'write', crm: 'read', database: 'write',
18
+ });
19
+ });
20
+ it('the "*" wildcard slot', () => {
21
+ expect(_parseGrants('*:read')).toEqual({ '*': 'read' });
22
+ expect(_parseGrants('*')).toEqual({ '*': 'write' });
23
+ });
24
+ it('every grantable slot from the SDK vocabulary', () => {
25
+ for (const slot of hq.GRANTABLE_SLOTS) {
26
+ expect(_parseGrants(slot)).toEqual({ [slot]: 'write' });
27
+ }
28
+ });
29
+ it('tolerates surrounding whitespace', () => {
30
+ expect(_parseGrants(' email : write , crm '.replace(/ /g, ''))).toEqual({
31
+ email: 'write', crm: 'write',
32
+ });
33
+ expect(_parseGrants('email, crm')).toEqual({ email: 'write', crm: 'write' });
34
+ });
35
+ });
36
+ describe('rejects (returns an error string)', () => {
37
+ it('an unknown slot', () => {
38
+ const r = _parseGrants('rabbitmq:write');
39
+ expect(typeof r).toBe('string');
40
+ expect(r).toMatch(/Unknown slot "rabbitmq"/);
41
+ });
42
+ it('a management slot — hq is not grantable', () => {
43
+ expect(typeof _parseGrants('hq:write')).toBe('string');
44
+ expect(_parseGrants('hq:write')).toMatch(/Unknown slot "hq"/);
45
+ });
46
+ it('an invalid access level', () => {
47
+ const r = _parseGrants('email:admin');
48
+ expect(typeof r).toBe('string');
49
+ expect(r).toMatch(/Invalid access "admin"/);
50
+ });
51
+ it('an empty string', () => {
52
+ expect(typeof _parseGrants('')).toBe('string');
53
+ expect(_parseGrants('')).toMatch(/--grant was empty/);
54
+ });
55
+ it('a string of only commas/whitespace', () => {
56
+ expect(typeof _parseGrants(' , , ')).toBe('string');
57
+ });
58
+ it('reports the FIRST bad entry when several are present', () => {
59
+ // crm is valid, bogus is not — error names bogus.
60
+ expect(_parseGrants('crm:read,bogus:write')).toMatch(/Unknown slot "bogus"/);
61
+ });
62
+ });
63
+ });
64
+ describe('_dollarsToCents', () => {
65
+ describe('accepts', () => {
66
+ it.each([
67
+ ['50', 5000],
68
+ ['9.99', 999],
69
+ ['0', 0],
70
+ ['0.5', 50],
71
+ ['100', 10000],
72
+ ['0.01', 1],
73
+ ])('%s → %d cents', (input, cents) => {
74
+ expect(_dollarsToCents(input)).toBe(cents);
75
+ });
76
+ it('rounds sub-cent input', () => {
77
+ expect(_dollarsToCents('9.999')).toBe(1000); // 999.9 → 1000
78
+ });
79
+ });
80
+ describe('rejects (returns an error string)', () => {
81
+ it.each(['-5', 'abc', '', 'free', '$50', '10usd'])('rejects %s', (input) => {
82
+ const r = _dollarsToCents(input);
83
+ expect(typeof r).toBe('string');
84
+ expect(r).toMatch(/not a valid dollar amount/);
85
+ });
86
+ });
87
+ });
@@ -1,10 +1,14 @@
1
+ import { hq } from '@myapihq/sdk';
1
2
  import type { FlagSchema } from '../flags.js';
2
3
  import type { Flags } from '../helpers.js';
3
4
  import type { Exposes } from '../exposes.js';
4
5
  export declare const SCHEMA: FlagSchema;
5
6
  export declare const EXPOSES: Exposes;
6
- export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
7
- export declare function runApiKeys(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
7
+ export declare function _parseGrants(raw: string): hq.Grants | string;
8
+ export declare function _dollarsToCents(raw: string): number | string;
8
9
  export declare function createNew(flags: Flags): Promise<void>;
9
10
  export declare function list(flags: Flags): Promise<void>;
10
11
  export declare function revoke(id: string, _flags: Flags): Promise<void>;
12
+ export declare function revokeAll(flags: Flags): Promise<void>;
13
+ export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
14
+ export declare function runApiKeys(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;