@myapihq/cli 1.2.4 → 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.
Files changed (42) hide show
  1. package/dist/commands/billing.d.ts +1 -0
  2. package/dist/commands/billing.js +61 -2
  3. package/dist/commands/domain.js +31 -7
  4. package/dist/commands/fn-validation.test.d.ts +1 -0
  5. package/dist/commands/fn-validation.test.js +60 -0
  6. package/dist/commands/fn.d.ts +16 -0
  7. package/dist/commands/fn.js +271 -0
  8. package/dist/commands/funnel.d.ts +1 -0
  9. package/dist/commands/funnel.js +101 -5
  10. package/dist/commands/keys-validation.test.d.ts +1 -0
  11. package/dist/commands/keys-validation.test.js +87 -0
  12. package/dist/commands/keys.d.ts +6 -2
  13. package/dist/commands/keys.js +189 -55
  14. package/dist/commands/payments-validation.test.d.ts +1 -0
  15. package/dist/commands/payments-validation.test.js +31 -0
  16. package/dist/commands/payments.d.ts +13 -0
  17. package/dist/commands/payments.js +219 -0
  18. package/dist/commands/webhook-validation.test.d.ts +1 -0
  19. package/dist/commands/webhook-validation.test.js +35 -0
  20. package/dist/commands/webhook.d.ts +2 -0
  21. package/dist/commands/webhook.js +72 -2
  22. package/dist/commands/workflow-validation.test.d.ts +1 -0
  23. package/dist/commands/workflow-validation.test.js +137 -0
  24. package/dist/commands/workflow.d.ts +1 -0
  25. package/dist/commands/workflow.js +58 -17
  26. package/dist/exposes.test.js +2 -0
  27. package/dist/index.js +14 -0
  28. package/dist/sdk-domain-assign.test.d.ts +1 -0
  29. package/dist/sdk-domain-assign.test.js +53 -0
  30. package/dist/sdk-function.test.d.ts +1 -0
  31. package/dist/sdk-function.test.js +257 -0
  32. package/dist/sdk-funnel-name.test.d.ts +1 -0
  33. package/dist/sdk-funnel-name.test.js +48 -0
  34. package/dist/sdk-funnel-publish.test.d.ts +1 -0
  35. package/dist/sdk-funnel-publish.test.js +89 -0
  36. package/dist/sdk-iam.test.d.ts +1 -0
  37. package/dist/sdk-iam.test.js +190 -0
  38. package/dist/sdk-payments.test.d.ts +1 -0
  39. package/dist/sdk-payments.test.js +139 -0
  40. package/dist/sdk-webhook.test.d.ts +1 -0
  41. package/dist/sdk-webhook.test.js +86 -0
  42. package/package.json +4 -2
@@ -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,14 +12,25 @@ 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
- org: 'string',
18
20
  funnel: 'string',
19
21
  slug: 'string',
22
+ env: 'string',
23
+ 'api-fn': 'string',
20
24
  };
25
+ // Backend (2026-05-15): POST /funnels accepts an optional `name` (defaults
26
+ // to the org's preview_subdomain for back-compat). Mirror the backend's
27
+ // validation client-side so typos fail before the network call.
28
+ const FUNNEL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,49}$/;
29
+ function validateFunnelName(name) {
30
+ if (!FUNNEL_NAME_RE.test(name)) {
31
+ error(`Invalid --name "${name}". Lowercase letters, digits, hyphens; 1-50 chars; starts with a letter or digit.`);
32
+ }
33
+ }
21
34
  export async function list(flags) {
22
35
  const config = requireConfig();
23
36
  const orgId = requireOrg(flags, config, 'myapi funnel list [--org <id>]');
@@ -39,9 +52,14 @@ export async function list(flags) {
39
52
  }
40
53
  export async function create(flags) {
41
54
  const config = requireConfig();
42
- const orgId = requireOrg(flags, config, 'myapi funnel create [--org <id>]');
43
- const result = await sdkFunnel.createFunnel(config.api_key, orgId);
55
+ const orgId = requireOrg(flags, config, 'myapi funnel create [--name <name>] [--org <id>]');
56
+ const name = flags.name;
57
+ if (name)
58
+ validateFunnelName(name);
59
+ const result = await sdkFunnel.createFunnel(config.api_key, orgId, name ? { name } : undefined);
44
60
  success(`Funnel created! ID: ${result.funnel.id}`);
61
+ if (result.funnel.name)
62
+ info(`Name: ${result.funnel.name}`);
45
63
  if (result.domain_url)
46
64
  info(`Live: ${result.domain_url}`);
47
65
  else if (result.subdomain_url)
@@ -180,13 +198,89 @@ export async function verify(slug, flags) {
180
198
  const v = await sdkFunnel.verifyFunnel(config.api_key, orgId, funnelId, { slug: finalSlug });
181
199
  printJson(v);
182
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
+ }
183
261
  // ── Dispatcher ───────────────────────────────────────────────────────────────
184
262
  const SUBCOMMAND_USAGE = {
185
263
  'list': 'myapi funnel list [--org <id>] [--json]',
186
- 'create': 'myapi funnel create [--org <id>]',
264
+ 'create': 'myapi funnel create [--name <name>] [--org <id>]',
187
265
  'get': 'myapi funnel get <id> [--org <id>] [--json]',
188
266
  'delete': 'myapi funnel delete <id> [--org <id>]',
189
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>`,
190
284
  'push': `myapi funnel push [slug] [--funnel <id>] [--slug <path>] [--org <id>] < page.html
191
285
 
192
286
  Reads HTML from stdin and publishes to <slug> on your funnel. Funnel is resolved
@@ -219,9 +313,10 @@ export async function run(subcommand, args, flags) {
219
313
 
220
314
  Subcommands:
221
315
  list List funnels
222
- create Create a funnel
316
+ create Create a funnel (optional --name; backend defaults to org's preview_subdomain)
223
317
  get <id> Get funnel details
224
318
  delete Delete a funnel
319
+ publish Publish a whole local directory as the site (dev or prod)
225
320
  push Push a raw HTML page from stdin to a slug
226
321
  pages List the pages currently published to a funnel
227
322
  verify Verify a published page is reachable
@@ -242,6 +337,7 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
242
337
  case 'create': return create(flags);
243
338
  case 'get': return get(args[0], flags);
244
339
  case 'delete': return del(args[0], flags);
340
+ case 'publish': return publish(args[0], flags);
245
341
  case 'push': return push(args[0], flags);
246
342
  case 'verify': return verify(args[0], flags);
247
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>;
@@ -1,47 +1,215 @@
1
1
  import { hq } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../config.js';
3
3
  import { success, error, printTable, info, printJson } from '../output.js';
4
- import { formatDate } from '../utils.js';
5
- import { ask } from '../prompt.js';
4
+ import { ask, confirm, isNonInteractive } from '../prompt.js';
6
5
  import { requireArg } from '../helpers.js';
7
6
  export const SCHEMA = {
8
7
  name: 'string',
8
+ // Capability IAM (design-iam-capability-keys-2026-05-15).
9
+ grant: 'string', // slot:access list, e.g. "email:write,crm:read"
10
+ org: 'string', // lock the key to one org id
11
+ 'spend-cap': 'string', // per-key spend ceiling, in dollars
12
+ kind: 'string', // revoke-all filter: function|manual|account
9
13
  };
10
14
  export const EXPOSES = [
11
15
  'POST /hq/account/create/key',
12
16
  'GET /hq/account/keys',
17
+ 'POST /hq/account/keys/revoke-all',
13
18
  'DELETE /hq/account/delete/key/{key_id}',
14
19
  ];
15
- const KEYS_HEADER = `Usage: myapi keys <subcommand>
20
+ // "*" is a valid grant target (wildcard slot) but not a named slot.
21
+ const VALID_GRANT_TARGETS = new Set([...hq.GRANTABLE_SLOTS, '*']);
22
+ const KEY_KINDS = ['function', 'manual', 'account'];
23
+ // Parses a `--grant` string into a Grants map. Accepts comma-separated
24
+ // `slot` (write implied) or `slot:access` entries. Validates every slot
25
+ // against the closed vocabulary so a typo fails before the network call.
26
+ //
27
+ // `_parseGrants` is the pure form — returns the Grants map on success or an
28
+ // error-message string on failure (suitable for unit tests). `parseGrants`
29
+ // is the thin wrapper that calls `error()` (which exits) on failure.
30
+ export function _parseGrants(raw) {
31
+ const grants = {};
32
+ for (const part of raw.split(',').map(s => s.trim()).filter(Boolean)) {
33
+ const [slot, accessRaw] = part.includes(':') ? part.split(':', 2) : [part, 'write'];
34
+ if (!VALID_GRANT_TARGETS.has(slot)) {
35
+ return `Unknown slot "${slot}" in --grant. Valid slots: ${[...hq.GRANTABLE_SLOTS].join(', ')} (or "*").`;
36
+ }
37
+ if (accessRaw !== 'read' && accessRaw !== 'write') {
38
+ return `Invalid access "${accessRaw}" for "${slot}" in --grant. Use read or write.`;
39
+ }
40
+ grants[slot] = accessRaw;
41
+ }
42
+ if (Object.keys(grants).length === 0) {
43
+ return '--grant was empty. Example: --grant email:write,crm:read (or --grant email for write)';
44
+ }
45
+ return grants;
46
+ }
47
+ function parseGrants(raw) {
48
+ const r = _parseGrants(raw);
49
+ if (typeof r === 'string')
50
+ error(r);
51
+ return r;
52
+ }
53
+ // Dollars → cents. The CLI surface is dollars (matches `billing topup`);
54
+ // the API is cents. Pure form returns cents or an error-message string.
55
+ export function _dollarsToCents(raw) {
56
+ // Number('') and Number(' ') are both 0 — reject empty input explicitly
57
+ // so a missing value never silently becomes a $0 cap.
58
+ const trimmed = raw.trim();
59
+ const n = Number(trimmed);
60
+ if (trimmed === '' || !Number.isFinite(n) || n < 0) {
61
+ return `"${raw}" is not a valid dollar amount — use a non-negative number, e.g. 50 or 9.99.`;
62
+ }
63
+ return Math.round(n * 100);
64
+ }
65
+ function dollarsToCents(raw, flagName) {
66
+ const r = _dollarsToCents(raw);
67
+ if (typeof r === 'string')
68
+ error(`${flagName}: ${r}`);
69
+ return r;
70
+ }
71
+ function grantsSummary(g) {
72
+ const entries = Object.entries(g);
73
+ if (entries.length === 0)
74
+ return 'none';
75
+ // write is the common case — show bare slot; annotate only read.
76
+ return entries.map(([s, a]) => (a === 'write' ? s : `${s}:${a}`)).join(',');
77
+ }
78
+ // Plain-English one-liner — the "what can this key do" the IAM model promises
79
+ // you can answer by reading the key.
80
+ function describeKey(k) {
81
+ const parts = [k.org_id ? `org ${k.org_id}` : 'all orgs', grantsSummary(k.grants)];
82
+ if (k.spend_cap_cents != null) {
83
+ parts.push(`cap $${(k.spend_cap_cents / 100).toFixed(2)}/${k.spend_cap_period}`);
84
+ }
85
+ return parts.join(' · ');
86
+ }
87
+ // ── Subcommands ──────────────────────────────────────────────────────────────
88
+ export async function createNew(flags) {
89
+ const config = requireConfig();
90
+ let name = flags.name || '';
91
+ if (!name)
92
+ name = await ask('Enter a name for the new key: ');
93
+ if (!name.trim())
94
+ error('Key name cannot be empty. Use --name <name>');
95
+ const opts = {};
96
+ if (typeof flags.grant === 'string')
97
+ opts.grants = parseGrants(flags.grant);
98
+ if (typeof flags.org === 'string')
99
+ opts.orgId = flags.org;
100
+ if (typeof flags['spend-cap'] === 'string')
101
+ opts.spendCapCents = dollarsToCents(flags['spend-cap'], '--spend-cap');
102
+ const key = await hq.createApiKey(config.api_key, name.trim(), opts);
103
+ if (flags.json) {
104
+ printJson(key);
105
+ return;
106
+ }
107
+ success('New API key created!');
108
+ info(`Name: ${key.name}`);
109
+ info(`Key: ${key.api_key}`);
110
+ info(`Scope: ${describeKey(key)}`);
111
+ info('');
112
+ info("Copy the key now — you won't be able to see it again.");
113
+ }
114
+ export async function list(flags) {
115
+ const config = requireConfig();
116
+ const keysList = await hq.listApiKeys(config.api_key);
117
+ if (flags.json) {
118
+ printJson(keysList);
119
+ return;
120
+ }
121
+ const rows = keysList.map(k => ({
122
+ Name: k.name || 'Unnamed',
123
+ ID: k.id,
124
+ Kind: k.kind,
125
+ Scope: k.org_id ? 'org' : 'account',
126
+ Grants: grantsSummary(k.grants),
127
+ // "spent / cap" for the current period; "—" when uncapped.
128
+ Cap: k.spend_cap_cents != null
129
+ ? `$${((k.current_period_spend_cents ?? 0) / 100).toFixed(2)} / $${(k.spend_cap_cents / 100).toFixed(2)} (${k.spend_cap_period})`
130
+ : '—',
131
+ }));
132
+ printTable(rows, {
133
+ flags,
134
+ empty: 'No API keys yet. Create one with: myapi keys create',
135
+ });
136
+ }
137
+ export async function revoke(id, _flags) {
138
+ requireArg(id, 'id', 'myapi keys revoke <id>');
139
+ const config = requireConfig();
140
+ await hq.revokeApiKey(config.api_key, id);
141
+ success(`Key ${id} revoked successfully.`);
142
+ }
143
+ export async function revokeAll(flags) {
144
+ const config = requireConfig();
145
+ const kind = flags.kind;
146
+ if (kind && !KEY_KINDS.includes(kind)) {
147
+ error(`Invalid --kind "${kind}". Use one of: ${KEY_KINDS.join(', ')}.`);
148
+ }
149
+ const scope = kind
150
+ ? `all "${kind}" keys`
151
+ : 'EVERY active key in the account — including the key this CLI is using';
152
+ if (!flags.yes && !flags.y) {
153
+ if (isNonInteractive()) {
154
+ error(`revoke-all is destructive and needs confirmation. Re-run with --yes:\n myapi keys revoke-all${kind ? ` --kind ${kind}` : ''} --yes`);
155
+ }
156
+ const ok = await confirm(`Revoke ${scope}? This cannot be undone. (y/N) `, false);
157
+ if (!ok) {
158
+ info('Aborted.');
159
+ return;
160
+ }
161
+ }
162
+ const res = await hq.revokeAllKeys(config.api_key, kind);
163
+ success(`Revoked ${res.revoked} key(s).`);
164
+ if (!kind) {
165
+ info('Your current key was revoked too — re-authenticate with: myapi auth setup');
166
+ }
167
+ }
168
+ // ── Dispatcher ───────────────────────────────────────────────────────────────
169
+ function header(prefix) {
170
+ return `Usage: myapi ${prefix} <subcommand>
16
171
 
17
- Manage programmatic API keys for your account.
172
+ Manage programmatic API keys. A key carries its authority inline — org scope,
173
+ slot grants, and an optional spend cap. A minted key is always a subset of the
174
+ key that minted it (no privilege escalation).
18
175
 
19
176
  Subcommands:
20
- list List all API keys with IDs and creation dates
21
- create Create a new API key (the key value is shown once)
22
- revoke <id> Permanently revoke an API key by ID
177
+ list List keys with kind, scope, grants, and spend cap
178
+ create Mint a new key (value shown once)
179
+ revoke <id> Revoke one key by ID
180
+ revoke-all Kill switch — revoke every key (or --kind function|manual|account)
23
181
 
24
- Alias for: myapi auth api-keys`;
25
- const API_KEYS_HEADER = `Usage: myapi auth api-keys <subcommand>
182
+ create flags:
183
+ --name <name> Key name (prompted if omitted)
184
+ --grant <list> Slot grants, e.g. --grant email:write,crm:read
185
+ (bare slot = write; "*" = all slots). Omit for unrestricted.
186
+ --org <org_id> Lock the key to one org. Omit for account-wide.
187
+ --spend-cap <usd> Per-key spend ceiling in dollars, e.g. --spend-cap 50
26
188
 
27
- Manage programmatic API keys for your account.
28
-
29
- Subcommands:
30
- list List all API keys with IDs and creation dates
31
- create Create a new API key (the key value is shown once)
32
- revoke <id> Permanently revoke an API key by ID
33
-
34
- Alias: myapi keys <subcommand>`;
189
+ ${prefix === 'keys' ? 'Alias for: myapi auth api-keys' : 'Alias: myapi keys <subcommand>'}`;
190
+ }
35
191
  function subcommandUsage(prefix) {
36
192
  return {
37
193
  'list': `myapi ${prefix} list [--json]`,
38
- 'create': `myapi ${prefix} create [--name <name>]\n\nCreates a new API key. If --name is omitted you will be prompted.`,
39
- 'revoke': `myapi ${prefix} revoke <id>\n\nPermanently revokes an API key by ID. The key stops working immediately and the action cannot be undone.`,
194
+ 'create': `myapi ${prefix} create [--name <name>] [--grant <list>] [--org <org_id>] [--spend-cap <usd>]
195
+
196
+ Mints a key whose authority is a subset of the calling key's. Examples:
197
+ myapi ${prefix} create --name ci --grant funnel:write,storage:read
198
+ myapi ${prefix} create --name billing-fn --org <org_id> --grant email --spend-cap 25
199
+ myapi ${prefix} create --name readonly --grant '*:read'
200
+
201
+ Omitting --grant mints an unrestricted key. --grant slots: ${[...hq.GRANTABLE_SLOTS].join(', ')}.`,
202
+ 'revoke': `myapi ${prefix} revoke <id>\n\nRevokes one key by ID. Takes effect immediately; cannot be undone.`,
203
+ 'revoke-all': `myapi ${prefix} revoke-all [--kind function|manual|account] [--yes]
204
+
205
+ Kill switch. With no --kind, revokes EVERY active key in the account — including
206
+ the key this CLI is authenticated with (recovery: myapi auth setup). --kind
207
+ narrows it to one provenance class. Destructive — confirms unless --yes.`,
40
208
  };
41
209
  }
42
210
  async function dispatch(prefix, subcommand, args, flags) {
43
211
  if (!subcommand || (flags.help && !subcommand)) {
44
- info(prefix === 'keys' ? KEYS_HEADER : API_KEYS_HEADER);
212
+ info(header(prefix));
45
213
  return;
46
214
  }
47
215
  if (flags.help) {
@@ -56,6 +224,7 @@ async function dispatch(prefix, subcommand, args, flags) {
56
224
  case 'create': return createNew(flags);
57
225
  case 'list': return list(flags);
58
226
  case 'revoke': return revoke(args[0], flags);
227
+ case 'revoke-all': return revokeAll(flags);
59
228
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi ${prefix} --help" for available subcommands.`);
60
229
  }
61
230
  }
@@ -65,38 +234,3 @@ export async function run(subcommand, args, flags) {
65
234
  export async function runApiKeys(subcommand, args, flags) {
66
235
  return dispatch('auth api-keys', subcommand, args, flags);
67
236
  }
68
- export async function createNew(flags) {
69
- const config = requireConfig();
70
- let name = flags.name || '';
71
- if (!name)
72
- name = await ask('Enter a name for the new key: ');
73
- if (!name.trim())
74
- error('Key name cannot be empty. Use --name <name>');
75
- const keyInfo = await hq.createApiKey(config.api_key, name.trim());
76
- success(`New API key created!\n\nName: ${keyInfo.prefix}...\nKey: ${keyInfo.api_key}\n\nMake sure to copy your new API key now. You won't be able to see it again!`);
77
- }
78
- export async function list(flags) {
79
- const config = requireConfig();
80
- const keysList = await hq.listApiKeys(config.api_key);
81
- if (flags.json) {
82
- printJson(keysList);
83
- return;
84
- }
85
- const formattedKeys = keysList.map(k => ({
86
- Name: k.name || 'Unnamed',
87
- Prefix: k.prefix,
88
- ID: k.id,
89
- 'Created At': formatDate(k.created_at),
90
- 'Last Used': k.last_used_at ? formatDate(k.last_used_at) : 'Never',
91
- }));
92
- printTable(formattedKeys, {
93
- flags,
94
- empty: 'No API keys yet. Create one with: myapi keys create',
95
- });
96
- }
97
- export async function revoke(id, _flags) {
98
- requireArg(id, 'id', 'myapi keys revoke <id>');
99
- const config = requireConfig();
100
- await hq.revokeApiKey(config.api_key, id);
101
- success(`Key ${id} revoked successfully.`);
102
- }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ // Unit tests for payments.ts CLI's pure dollar-amount validator. A charge
2
+ // must be strictly positive — $0 and negatives are rejected (unlike a spend
3
+ // cap, where $0 is a meaningful "block everything" value).
4
+ import { describe, it, expect } from 'vitest';
5
+ import { _amountToCents } from './payments.js';
6
+ describe('_amountToCents — pure helper', () => {
7
+ describe('valid amounts', () => {
8
+ it.each([
9
+ ['19', 1900],
10
+ ['9.99', 999],
11
+ ['0.01', 1],
12
+ [' 25 ', 2500], // surrounding whitespace tolerated
13
+ ['100', 10000],
14
+ ['0.999', 100], // rounds to nearest cent
15
+ ])('converts %s → %d cents', (raw, cents) => {
16
+ expect(_amountToCents(raw)).toBe(cents);
17
+ });
18
+ });
19
+ describe('rejections', () => {
20
+ it.each([
21
+ ['empty string', ''],
22
+ ['whitespace only', ' '],
23
+ ['zero', '0'],
24
+ ['negative', '-5'],
25
+ ['non-numeric', 'abc'],
26
+ ['NaN-ish', 'one dollar'],
27
+ ])('rejects %s', (_label, raw) => {
28
+ expect(_amountToCents(raw)).toMatch(/not a valid amount/);
29
+ });
30
+ });
31
+ });
@@ -0,0 +1,13 @@
1
+ import type { FlagSchema } from '../flags.js';
2
+ import { type Flags } from '../helpers.js';
3
+ import type { Exposes } from '../exposes.js';
4
+ export declare const EXPOSES: Exposes;
5
+ export declare const SCHEMA: FlagSchema;
6
+ export declare function _amountToCents(raw: string): number | string;
7
+ export declare function connect(flags: Flags): Promise<void>;
8
+ export declare function status(flags: Flags): Promise<void>;
9
+ export declare function charge(flags: Flags): Promise<void>;
10
+ export declare function list(flags: Flags): Promise<void>;
11
+ export declare function get(id: string, flags: Flags): Promise<void>;
12
+ export declare function refund(id: string, flags: Flags): Promise<void>;
13
+ export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;