@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.
- package/dist/commands/billing.d.ts +1 -0
- package/dist/commands/billing.js +61 -2
- package/dist/commands/domain.js +31 -7
- package/dist/commands/fn-validation.test.d.ts +1 -0
- package/dist/commands/fn-validation.test.js +60 -0
- package/dist/commands/fn.d.ts +16 -0
- package/dist/commands/fn.js +271 -0
- package/dist/commands/funnel.d.ts +1 -0
- package/dist/commands/funnel.js +101 -5
- package/dist/commands/keys-validation.test.d.ts +1 -0
- package/dist/commands/keys-validation.test.js +87 -0
- package/dist/commands/keys.d.ts +6 -2
- package/dist/commands/keys.js +189 -55
- package/dist/commands/payments-validation.test.d.ts +1 -0
- package/dist/commands/payments-validation.test.js +31 -0
- package/dist/commands/payments.d.ts +13 -0
- package/dist/commands/payments.js +219 -0
- package/dist/commands/webhook-validation.test.d.ts +1 -0
- package/dist/commands/webhook-validation.test.js +35 -0
- package/dist/commands/webhook.d.ts +2 -0
- package/dist/commands/webhook.js +72 -2
- package/dist/commands/workflow-validation.test.d.ts +1 -0
- package/dist/commands/workflow-validation.test.js +137 -0
- package/dist/commands/workflow.d.ts +1 -0
- package/dist/commands/workflow.js +58 -17
- package/dist/exposes.test.js +2 -0
- package/dist/index.js +14 -0
- package/dist/sdk-domain-assign.test.d.ts +1 -0
- package/dist/sdk-domain-assign.test.js +53 -0
- package/dist/sdk-function.test.d.ts +1 -0
- package/dist/sdk-function.test.js +257 -0
- package/dist/sdk-funnel-name.test.d.ts +1 -0
- package/dist/sdk-funnel-name.test.js +48 -0
- package/dist/sdk-funnel-publish.test.d.ts +1 -0
- package/dist/sdk-funnel-publish.test.js +89 -0
- package/dist/sdk-iam.test.d.ts +1 -0
- package/dist/sdk-iam.test.js +190 -0
- package/dist/sdk-payments.test.d.ts +1 -0
- package/dist/sdk-payments.test.js +139 -0
- package/dist/sdk-webhook.test.d.ts +1 -0
- package/dist/sdk-webhook.test.js +86 -0
- package/package.json +4 -2
package/dist/commands/funnel.js
CHANGED
|
@@ -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
|
|
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
|
+
});
|
package/dist/commands/keys.d.ts
CHANGED
|
@@ -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
|
|
7
|
-
export declare function
|
|
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>;
|
package/dist/commands/keys.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
21
|
-
create
|
|
22
|
-
revoke <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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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>]
|
|
39
|
-
|
|
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
|
|
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>;
|