@myapihq/cli 1.2.6 → 1.2.7
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 +33 -0
- package/dist/commands/container-validation.test.d.ts +1 -0
- package/dist/commands/container-validation.test.js +45 -0
- package/dist/commands/container.d.ts +15 -0
- package/dist/commands/container.js +242 -0
- package/dist/commands/email/campaign.js +16 -1
- package/dist/commands/email/message.js +26 -3
- package/dist/commands/email/template.js +8 -1
- package/dist/commands/pixel.js +15 -2
- package/dist/commands/update.d.ts +1 -1
- package/dist/commands/update.js +68 -45
- package/dist/commands/webhook.js +10 -1
- package/dist/completion.d.ts +3 -1
- package/dist/completion.js +160 -55
- package/dist/exposes.test.js +1 -0
- package/dist/index.js +26 -12
- package/dist/sdk-billing-usage.test.d.ts +1 -0
- package/dist/sdk-billing-usage.test.js +74 -0
- package/dist/sdk-container.test.d.ts +1 -0
- package/dist/sdk-container.test.js +115 -0
- package/package.json +2 -4
|
@@ -6,6 +6,7 @@ export declare const EXPOSES: Exposes;
|
|
|
6
6
|
export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
|
|
7
7
|
export declare function balance(flags: Flags): Promise<void>;
|
|
8
8
|
export declare function history(flags: Flags): Promise<void>;
|
|
9
|
+
export declare function usage(flags: Flags): Promise<void>;
|
|
9
10
|
export declare function topup(amountStr: string, flags: Flags): Promise<void>;
|
|
10
11
|
export declare function setup(_flags: Flags): Promise<void>;
|
|
11
12
|
export declare function spendCap(arg: string | undefined, flags: Flags): Promise<void>;
|
package/dist/commands/billing.js
CHANGED
|
@@ -9,6 +9,7 @@ export const SCHEMA = {
|
|
|
9
9
|
export const EXPOSES = [
|
|
10
10
|
'GET /hq/billing/balance',
|
|
11
11
|
'GET /hq/billing/history',
|
|
12
|
+
'GET /hq/billing/usage',
|
|
12
13
|
'POST /hq/billing/setup-payment',
|
|
13
14
|
'POST /hq/billing/topup',
|
|
14
15
|
'GET /hq/account/me',
|
|
@@ -17,6 +18,11 @@ export const EXPOSES = [
|
|
|
17
18
|
const SUBCOMMAND_USAGE = {
|
|
18
19
|
'balance': 'myapi billing balance [--json]',
|
|
19
20
|
'history': 'myapi billing history [--json]',
|
|
21
|
+
'usage': `myapi billing usage [--period month|30d] [--json]
|
|
22
|
+
|
|
23
|
+
Spend rolled up by service — the accurate "where is my money going" view.
|
|
24
|
+
Aggregates every billing event, unlike the flat history log. Defaults to
|
|
25
|
+
the current calendar month; --period 30d gives the trailing 30 days.`,
|
|
20
26
|
'topup': `myapi billing topup <amount> [--yes]
|
|
21
27
|
|
|
22
28
|
Amount is in whole dollars (e.g. "10" charges $10).
|
|
@@ -44,6 +50,7 @@ export async function run(subcommand, args, flags) {
|
|
|
44
50
|
Subcommands:
|
|
45
51
|
balance Check balance, credits, and payment method status
|
|
46
52
|
history View recent transactions and top-ups
|
|
53
|
+
usage Spend rolled up by service (this month, or --period 30d)
|
|
47
54
|
topup Top up your balance (whole dollars)
|
|
48
55
|
setup Open a checkout link to add or update payment method
|
|
49
56
|
spend-cap Set/show/clear the account-level spend ceiling (IAM Layer 2)`);
|
|
@@ -60,6 +67,7 @@ Subcommands:
|
|
|
60
67
|
switch (subcommand) {
|
|
61
68
|
case 'balance': return balance(flags);
|
|
62
69
|
case 'history': return history(flags);
|
|
70
|
+
case 'usage': return usage(flags);
|
|
63
71
|
case 'topup': return topup(args[0], flags);
|
|
64
72
|
case 'setup': return setup(flags);
|
|
65
73
|
case 'spend-cap': return spendCap(args[0], flags);
|
|
@@ -105,6 +113,31 @@ export async function history(flags) {
|
|
|
105
113
|
empty: 'No transactions yet.',
|
|
106
114
|
});
|
|
107
115
|
}
|
|
116
|
+
// Spend rolled up by service. Aggregates every billing event over the
|
|
117
|
+
// window (current calendar month, or trailing 30 days with --period 30d) —
|
|
118
|
+
// the accurate "where is my money going" view, distinct from `history`.
|
|
119
|
+
export async function usage(flags) {
|
|
120
|
+
const config = requireConfig();
|
|
121
|
+
const period = flags.period || 'month';
|
|
122
|
+
if (period !== 'month' && period !== '30d') {
|
|
123
|
+
error(`Invalid --period "${period}". Use month or 30d.`);
|
|
124
|
+
}
|
|
125
|
+
const res = await hq.getBillingUsage(config.api_key, period);
|
|
126
|
+
if (flags.json) {
|
|
127
|
+
printJson(res);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
info(`Spend by service — ${res.period === '30d' ? 'last 30 days' : 'this month'} (since ${formatDate(res.since)})`);
|
|
131
|
+
printTable(res.services.map(s => ({
|
|
132
|
+
Service: s.service,
|
|
133
|
+
Requests: s.requests,
|
|
134
|
+
Cost: s.cost_display,
|
|
135
|
+
})), {
|
|
136
|
+
flags,
|
|
137
|
+
empty: 'No usage recorded in this window.',
|
|
138
|
+
});
|
|
139
|
+
info(`Total: ${res.total_display}`);
|
|
140
|
+
}
|
|
108
141
|
export async function topup(amountStr, flags) {
|
|
109
142
|
const amount = Math.round(parseFloat(amountStr));
|
|
110
143
|
if (!amountStr || isNaN(amount) || amount <= 0) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Unit tests for container.ts CLI's pure helpers — name validation
|
|
2
|
+
// (mirrors the backend regex/reserved set) and the --env K=V parser.
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { _validateName, _parseEnv, NAME_RE, RESERVED_NAMES } from './container.js';
|
|
5
|
+
describe('_validateName — pure helper', () => {
|
|
6
|
+
it.each(['a', 'api-server', 'worker-2', '0', 'a'.repeat(50)])('accepts %s', (name) => {
|
|
7
|
+
expect(_validateName(name)).toBeNull();
|
|
8
|
+
});
|
|
9
|
+
it.each([
|
|
10
|
+
['empty', ''],
|
|
11
|
+
['UPPERCASE', 'BAD'],
|
|
12
|
+
['underscore', 'my_app'],
|
|
13
|
+
['leading hyphen', '-app'],
|
|
14
|
+
['too long', 'a'.repeat(51)],
|
|
15
|
+
['space', 'my app'],
|
|
16
|
+
])('rejects %s', (_label, name) => {
|
|
17
|
+
expect(_validateName(name)).toMatch(/Invalid --name/);
|
|
18
|
+
});
|
|
19
|
+
it.each([...RESERVED_NAMES])('rejects reserved name %s', (name) => {
|
|
20
|
+
expect(_validateName(name)).toMatch(/is reserved/);
|
|
21
|
+
});
|
|
22
|
+
it('matches the backend regex exactly', () => {
|
|
23
|
+
expect(NAME_RE.source).toBe('^[a-z0-9][a-z0-9-]{0,49}$');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('_parseEnv — pure helper', () => {
|
|
27
|
+
it('parses a single KEY=VALUE pair', () => {
|
|
28
|
+
expect(_parseEnv('FOO=bar')).toEqual({ FOO: 'bar' });
|
|
29
|
+
});
|
|
30
|
+
it('parses comma-separated pairs', () => {
|
|
31
|
+
expect(_parseEnv('FOO=bar,BAZ=qux')).toEqual({ FOO: 'bar', BAZ: 'qux' });
|
|
32
|
+
});
|
|
33
|
+
it('keeps = inside the value', () => {
|
|
34
|
+
expect(_parseEnv('URL=https://x?a=1')).toEqual({ URL: 'https://x?a=1' });
|
|
35
|
+
});
|
|
36
|
+
it('tolerates surrounding whitespace and empty segments', () => {
|
|
37
|
+
expect(_parseEnv(' FOO=bar , ')).toEqual({ FOO: 'bar' });
|
|
38
|
+
});
|
|
39
|
+
it.each([
|
|
40
|
+
['no equals', 'FOObar'],
|
|
41
|
+
['leading equals (empty key)', '=bar'],
|
|
42
|
+
])('rejects %s', (_label, raw) => {
|
|
43
|
+
expect(_parseEnv(raw)).toMatch(/Invalid --env entry/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
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 const NAME_RE: RegExp;
|
|
7
|
+
export declare const RESERVED_NAMES: Set<string>;
|
|
8
|
+
export declare function _validateName(name: string): string | null;
|
|
9
|
+
export declare function _parseEnv(raw: string): Record<string, string> | string;
|
|
10
|
+
export declare function create(flags: Flags): Promise<void>;
|
|
11
|
+
export declare function list(flags: Flags): Promise<void>;
|
|
12
|
+
export declare function get(id: string, flags: Flags): Promise<void>;
|
|
13
|
+
export declare function del(id: string, flags: Flags): Promise<void>;
|
|
14
|
+
export declare function deploy(id: string, image: string, flags: Flags): Promise<void>;
|
|
15
|
+
export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { container as sdkContainer } from '@myapihq/sdk';
|
|
2
|
+
import { requireConfig } from '../config.js';
|
|
3
|
+
import { success, error, printTable, info, printJson, banner } from '../output.js';
|
|
4
|
+
import { formatDate } from '../utils.js';
|
|
5
|
+
import { requireOrg } from '../helpers.js';
|
|
6
|
+
export const EXPOSES = [
|
|
7
|
+
'POST /container/orgs/{org_id}/containers',
|
|
8
|
+
'GET /container/orgs/{org_id}/containers',
|
|
9
|
+
'GET /container/orgs/{org_id}/containers/{id}',
|
|
10
|
+
'DELETE /container/orgs/{org_id}/containers/{id}',
|
|
11
|
+
'POST /container/orgs/{org_id}/containers/{id}/deploy',
|
|
12
|
+
];
|
|
13
|
+
export const SCHEMA = {
|
|
14
|
+
name: 'string',
|
|
15
|
+
type: 'string',
|
|
16
|
+
cron: 'string',
|
|
17
|
+
cpu: 'string',
|
|
18
|
+
memory: 'string',
|
|
19
|
+
'min-instances': 'number',
|
|
20
|
+
'max-instances': 'number',
|
|
21
|
+
port: 'number',
|
|
22
|
+
env: 'string',
|
|
23
|
+
};
|
|
24
|
+
const CONTAINER_TYPES = ['service', 'worker', 'job'];
|
|
25
|
+
// Mirrors validateName in myapi-hq/internal/routes/container/crud.go —
|
|
26
|
+
// identical to functions. Client-side rejection so typos fail before the
|
|
27
|
+
// network call; the backend runs the same regex as defence in depth.
|
|
28
|
+
export const NAME_RE = /^[a-z0-9][a-z0-9-]{0,49}$/;
|
|
29
|
+
export const RESERVED_NAMES = new Set(['www', 'api', 'admin', 'system', 'default']);
|
|
30
|
+
// Returns an error message on failure, or null on success. Pure — no I/O,
|
|
31
|
+
// no process.exit. Tests use this form; the caller wraps it in error().
|
|
32
|
+
export function _validateName(name) {
|
|
33
|
+
if (!NAME_RE.test(name)) {
|
|
34
|
+
return `Invalid --name "${name}". Lowercase letters, digits, hyphens; 1-50 chars; starts with a letter or digit.`;
|
|
35
|
+
}
|
|
36
|
+
if (RESERVED_NAMES.has(name)) {
|
|
37
|
+
return `Name "${name}" is reserved. Pick a different one.`;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// Parses `--env K=V,K2=V2` into an object. Returns the map or an error
|
|
42
|
+
// message string (pure form, for tests).
|
|
43
|
+
export function _parseEnv(raw) {
|
|
44
|
+
const env = {};
|
|
45
|
+
for (const pair of raw.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
46
|
+
const eq = pair.indexOf('=');
|
|
47
|
+
if (eq < 1) {
|
|
48
|
+
return `Invalid --env entry "${pair}". Use KEY=VALUE, comma-separated.`;
|
|
49
|
+
}
|
|
50
|
+
env[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
51
|
+
}
|
|
52
|
+
return env;
|
|
53
|
+
}
|
|
54
|
+
function summarizeContainer(c) {
|
|
55
|
+
return {
|
|
56
|
+
id: c.id,
|
|
57
|
+
name: c.name,
|
|
58
|
+
type: c.type,
|
|
59
|
+
status: c.status,
|
|
60
|
+
url: c.url || '(not deployed)',
|
|
61
|
+
updated_at: c.updated_at ? formatDate(c.updated_at) : '',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export async function create(flags) {
|
|
65
|
+
const config = requireConfig();
|
|
66
|
+
const orgId = requireOrg(flags, config, 'myapi container create --name <name> [--type service|worker|job] [--org <id>]');
|
|
67
|
+
const name = flags.name;
|
|
68
|
+
if (!name) {
|
|
69
|
+
error('Missing --name.\nUsage: myapi container create --name <name> [--type service|worker|job] [--org <id>]\n\n→ Name is a kebab-case slug, 1-50 chars (e.g. "my-worker").');
|
|
70
|
+
}
|
|
71
|
+
const nameErr = _validateName(name);
|
|
72
|
+
if (nameErr)
|
|
73
|
+
error(nameErr);
|
|
74
|
+
const type = flags.type ?? 'service';
|
|
75
|
+
if (!CONTAINER_TYPES.includes(type)) {
|
|
76
|
+
error(`Invalid --type "${type}". Use one of: ${CONTAINER_TYPES.join(', ')}.`);
|
|
77
|
+
}
|
|
78
|
+
const cron = flags.cron;
|
|
79
|
+
if (cron && type !== 'job') {
|
|
80
|
+
error('--cron is only valid for --type job (services and workers are always-on).');
|
|
81
|
+
}
|
|
82
|
+
const payload = { name, type: type };
|
|
83
|
+
if (cron)
|
|
84
|
+
payload.cron_schedule = cron;
|
|
85
|
+
if (typeof flags.cpu === 'string')
|
|
86
|
+
payload.cpu = flags.cpu;
|
|
87
|
+
if (typeof flags.memory === 'string')
|
|
88
|
+
payload.memory = flags.memory;
|
|
89
|
+
if (typeof flags['min-instances'] === 'number')
|
|
90
|
+
payload.min_instances = flags['min-instances'];
|
|
91
|
+
if (typeof flags['max-instances'] === 'number')
|
|
92
|
+
payload.max_instances = flags['max-instances'];
|
|
93
|
+
if (typeof flags.port === 'number')
|
|
94
|
+
payload.port = flags.port;
|
|
95
|
+
if (typeof flags.env === 'string') {
|
|
96
|
+
const env = _parseEnv(flags.env);
|
|
97
|
+
if (typeof env === 'string')
|
|
98
|
+
error(env);
|
|
99
|
+
payload.env = env;
|
|
100
|
+
}
|
|
101
|
+
const result = await sdkContainer.createContainer(config.api_key, orgId, payload);
|
|
102
|
+
success(`Container created: ${result.container.id}`);
|
|
103
|
+
info(`Name: ${result.container.name}`);
|
|
104
|
+
info(`Type: ${result.container.type}${result.container.cron_schedule ? ` (${result.container.cron_schedule})` : ''}`);
|
|
105
|
+
info(`Resources: ${result.container.cpu} CPU, ${result.container.memory}, instances ${result.container.min_instances}-${result.container.max_instances}`);
|
|
106
|
+
// The scoped key is returned ONCE — it's delivered to the running
|
|
107
|
+
// container as the MYAPI_KEY env var. Deploy rotates it.
|
|
108
|
+
info('');
|
|
109
|
+
info(`Scoped API key (returned once — save it if you need it):`);
|
|
110
|
+
info(` ${result.scoped_api_key}`);
|
|
111
|
+
if (!result.container.url) {
|
|
112
|
+
info('');
|
|
113
|
+
banner(`Next: deploy an image with myapi container deploy ${result.container.id} <image-ref>`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export async function list(flags) {
|
|
117
|
+
const config = requireConfig();
|
|
118
|
+
const orgId = requireOrg(flags, config, 'myapi container list [--org <id>]');
|
|
119
|
+
const containers = await sdkContainer.listContainers(config.api_key, orgId);
|
|
120
|
+
if (flags.json) {
|
|
121
|
+
printJson(containers);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
printTable(containers.map(summarizeContainer), {
|
|
125
|
+
flags,
|
|
126
|
+
empty: 'No containers yet. Create one with: myapi container create --name <name>',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
export async function get(id, flags) {
|
|
130
|
+
const config = requireConfig();
|
|
131
|
+
const orgId = requireOrg(flags, config, 'myapi container get <id> [--org <id>]');
|
|
132
|
+
if (!id)
|
|
133
|
+
error('Missing id.\nUsage: myapi container get <id>');
|
|
134
|
+
const c = await sdkContainer.getContainer(config.api_key, orgId, id);
|
|
135
|
+
if (flags.json) {
|
|
136
|
+
printJson(c);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
info(`ID: ${c.id}`);
|
|
140
|
+
info(`Name: ${c.name}`);
|
|
141
|
+
info(`Type: ${c.type}${c.cron_schedule ? ` (${c.cron_schedule})` : ''}`);
|
|
142
|
+
info(`Status: ${c.status}`);
|
|
143
|
+
info(`Resources: ${c.cpu} CPU, ${c.memory}, instances ${c.min_instances}-${c.max_instances}`);
|
|
144
|
+
if (c.port)
|
|
145
|
+
info(`Port: ${c.port}`);
|
|
146
|
+
info(`URL: ${c.url || '(not deployed)'}`);
|
|
147
|
+
info(`Created: ${c.created_at}`);
|
|
148
|
+
info(`Updated: ${c.updated_at}`);
|
|
149
|
+
}
|
|
150
|
+
export async function del(id, flags) {
|
|
151
|
+
const config = requireConfig();
|
|
152
|
+
const orgId = requireOrg(flags, config, 'myapi container delete <id> [--org <id>]');
|
|
153
|
+
if (!id)
|
|
154
|
+
error('Missing id.\nUsage: myapi container delete <id>');
|
|
155
|
+
await sdkContainer.deleteContainer(config.api_key, orgId, id);
|
|
156
|
+
success(`Deleted container ${id}`);
|
|
157
|
+
}
|
|
158
|
+
// deploy ships a pre-built image to Cloud Run. The scoped API key is
|
|
159
|
+
// rotated on every deploy — the fresh value is shown once here.
|
|
160
|
+
export async function deploy(id, image, flags) {
|
|
161
|
+
const config = requireConfig();
|
|
162
|
+
const orgId = requireOrg(flags, config, 'myapi container deploy <id> <image-ref> [--org <id>]');
|
|
163
|
+
if (!id)
|
|
164
|
+
error('Missing id.\nUsage: myapi container deploy <id> <image-ref>');
|
|
165
|
+
if (!image)
|
|
166
|
+
error('Missing image ref.\nUsage: myapi container deploy <id> <image-ref>\n\n→ <image-ref> is a pre-built container image (e.g. a registry path).');
|
|
167
|
+
const result = await sdkContainer.deployContainer(config.api_key, orgId, id, image);
|
|
168
|
+
if (flags.json) {
|
|
169
|
+
printJson(result);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
success(`Deployed container ${id} (revision ${result.revision_id})`);
|
|
173
|
+
info(`Status: ${result.status}`);
|
|
174
|
+
info(`URL: ${result.url}`);
|
|
175
|
+
info('');
|
|
176
|
+
info(`Scoped API key was rotated. New value (returned once — save it if you need it):`);
|
|
177
|
+
info(` ${result.scoped_api_key}`);
|
|
178
|
+
}
|
|
179
|
+
// ── Dispatcher ───────────────────────────────────────────────────────────────
|
|
180
|
+
const SUBCOMMAND_USAGE = {
|
|
181
|
+
'create': `myapi container create --name <name> [--type service|worker|job] [--cron <expr>]
|
|
182
|
+
[--cpu <n>] [--memory <size>] [--min-instances <n>]
|
|
183
|
+
[--max-instances <n>] [--port <n>] [--env K=V,...] [--org <id>]
|
|
184
|
+
|
|
185
|
+
Persists a container record + issues a scoped API key. Deploy an image
|
|
186
|
+
separately with "myapi container deploy".
|
|
187
|
+
|
|
188
|
+
Types:
|
|
189
|
+
service HTTP server (default) — scales to zero
|
|
190
|
+
worker always-on background process (min 1 instance)
|
|
191
|
+
job runs to completion — the only type that accepts --cron
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
myapi container create --name api --port 8080
|
|
195
|
+
myapi container create --name nightly --type job --cron "0 3 * * *"
|
|
196
|
+
myapi container create --name queue-worker --type worker --memory 1Gi`,
|
|
197
|
+
'deploy': `myapi container deploy <id> <image-ref> [--org <id>] [--json]
|
|
198
|
+
|
|
199
|
+
Ships a pre-built container image to the runtime. The scoped API key is
|
|
200
|
+
rotated on every deploy — the fresh value is printed once.
|
|
201
|
+
|
|
202
|
+
Example:
|
|
203
|
+
myapi container deploy <id> registry.example.com/my-app:v2`,
|
|
204
|
+
'list': 'myapi container list [--org <id>] [--json]',
|
|
205
|
+
'get': 'myapi container get <id> [--org <id>] [--json]',
|
|
206
|
+
'delete': 'myapi container delete <id> [--org <id>]',
|
|
207
|
+
};
|
|
208
|
+
export async function run(subcommand, args, flags) {
|
|
209
|
+
if (!subcommand || (flags.help && !subcommand)) {
|
|
210
|
+
info(`Usage: myapi container <subcommand>
|
|
211
|
+
|
|
212
|
+
Run containers — long-running services, background workers, and scheduled
|
|
213
|
+
jobs. The heavier-duty sibling of edge functions (myapi fn), for native
|
|
214
|
+
dependencies and long execution.
|
|
215
|
+
|
|
216
|
+
Subcommands:
|
|
217
|
+
create Register a container and get its scoped API key (returned once)
|
|
218
|
+
deploy <id> <image> Ship a pre-built image and go live
|
|
219
|
+
list List containers in your org
|
|
220
|
+
get <id> Inspect a container
|
|
221
|
+
delete <id> Soft-delete and revoke its scoped API key
|
|
222
|
+
|
|
223
|
+
All commands accept --org <id> (or set default: myapi config set-org <id>).`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (flags.help) {
|
|
227
|
+
const usage = SUBCOMMAND_USAGE[subcommand];
|
|
228
|
+
if (usage)
|
|
229
|
+
info(`Usage: ${usage}`);
|
|
230
|
+
else
|
|
231
|
+
info(`Unknown subcommand: ${subcommand}. Run "myapi container --help" for the list.`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
switch (subcommand) {
|
|
235
|
+
case 'create': return create(flags);
|
|
236
|
+
case 'deploy': return deploy(args[0], args[1], flags);
|
|
237
|
+
case 'list': return list(flags);
|
|
238
|
+
case 'get': return get(args[0], flags);
|
|
239
|
+
case 'delete': return del(args[0], flags);
|
|
240
|
+
default: error(`Unknown subcommand: ${subcommand}. Run "myapi container --help" for a list of valid subcommands.`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -35,7 +35,22 @@ async function list(flags) {
|
|
|
35
35
|
const config = requireConfig();
|
|
36
36
|
const orgId = requireOrg(flags, config, 'myapi email campaign list [--org <id>]');
|
|
37
37
|
const campaigns = await sdkEmail.listCampaigns(config.api_key, orgId);
|
|
38
|
-
|
|
38
|
+
if (flags.json) {
|
|
39
|
+
printJson(campaigns);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// Drops warmup_config — a nested object that renders as "[object Object]"
|
|
43
|
+
// in a table. The scalar fields below are what a listing needs; the full
|
|
44
|
+
// config is in `myapi email campaign get <id>` and `--json`.
|
|
45
|
+
printTable(campaigns.map(c => ({
|
|
46
|
+
id: c.id,
|
|
47
|
+
name: c.name,
|
|
48
|
+
status: c.status,
|
|
49
|
+
template_id: c.template_id,
|
|
50
|
+
from_address: c.from_address,
|
|
51
|
+
per_day_limit: c.per_day_limit,
|
|
52
|
+
created_at: c.created_at,
|
|
53
|
+
})), {
|
|
39
54
|
flags,
|
|
40
55
|
empty: 'No campaigns yet. Create one with: myapi email campaign create',
|
|
41
56
|
});
|
|
@@ -9,6 +9,17 @@ export const EXPOSES = [
|
|
|
9
9
|
'GET /email/outbox/{address}',
|
|
10
10
|
'GET /email/message/{message_id}',
|
|
11
11
|
];
|
|
12
|
+
// Table columns for a message listing. Drops `body` — the full message
|
|
13
|
+
// content is large and belongs in `myapi email message get <id>`, not in a
|
|
14
|
+
// list row. `--json` still returns the complete record.
|
|
15
|
+
function summarizeMessage(m) {
|
|
16
|
+
return {
|
|
17
|
+
message_id: m.message_id,
|
|
18
|
+
from: m.from,
|
|
19
|
+
subject: m.subject,
|
|
20
|
+
received_at: m.received_at,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
12
23
|
async function send(flags) {
|
|
13
24
|
const config = requireConfig();
|
|
14
25
|
if (!flags.from || !flags.to || !flags.subject) {
|
|
@@ -50,7 +61,11 @@ async function sent(flags) {
|
|
|
50
61
|
const limit = flags.limit || 50;
|
|
51
62
|
const offset = flags.offset || 0;
|
|
52
63
|
const emails = await sdkEmail.getSentEmails(config.api_key, limit, offset);
|
|
53
|
-
|
|
64
|
+
if (flags.json) {
|
|
65
|
+
printJson(emails);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
printTable(emails.map(summarizeMessage), {
|
|
54
69
|
flags,
|
|
55
70
|
empty: 'No sent emails yet.',
|
|
56
71
|
});
|
|
@@ -60,7 +75,11 @@ async function inbox(address, flags) {
|
|
|
60
75
|
if (!address)
|
|
61
76
|
error('Missing required arguments.\nUsage: myapi email message inbox <address>');
|
|
62
77
|
const messages = await sdkEmail.getInbox(config.api_key, address);
|
|
63
|
-
|
|
78
|
+
if (flags.json) {
|
|
79
|
+
printJson(messages);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
printTable(messages.map(summarizeMessage), {
|
|
64
83
|
flags,
|
|
65
84
|
empty: `No messages in ${address}.`,
|
|
66
85
|
});
|
|
@@ -70,7 +89,11 @@ async function outbox(address, flags) {
|
|
|
70
89
|
if (!address)
|
|
71
90
|
error('Missing required arguments.\nUsage: myapi email message outbox <address>');
|
|
72
91
|
const messages = await sdkEmail.getOutbox(config.api_key, address);
|
|
73
|
-
|
|
92
|
+
if (flags.json) {
|
|
93
|
+
printJson(messages);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
printTable(messages.map(summarizeMessage), {
|
|
74
97
|
flags,
|
|
75
98
|
empty: `No outbound messages from ${address}.`,
|
|
76
99
|
});
|
|
@@ -44,7 +44,14 @@ async function list(flags) {
|
|
|
44
44
|
printJson(templates);
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
// Drops preview_url (a long URL, available via `template get`) and
|
|
48
|
+
// created_at (updated_at is the field a listing cares about).
|
|
49
|
+
printTable(templates.map(t => ({
|
|
50
|
+
id: t.id,
|
|
51
|
+
name: t.name,
|
|
52
|
+
subject: t.subject,
|
|
53
|
+
updated_at: t.updated_at,
|
|
54
|
+
})), {
|
|
48
55
|
flags,
|
|
49
56
|
empty: 'No templates yet. Generate one with: myapi email template generate --prompt <p> --name <n>',
|
|
50
57
|
});
|
package/dist/commands/pixel.js
CHANGED
|
@@ -75,7 +75,13 @@ export async function visits(flags) {
|
|
|
75
75
|
printJson(res);
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
// `type` is constant ('visit') across this list — omitted from the table.
|
|
79
|
+
printTable(res.visits.map(v => ({
|
|
80
|
+
pixel_id: v.pixel_id,
|
|
81
|
+
from_url: v.from_url,
|
|
82
|
+
to_url: v.to_url,
|
|
83
|
+
ts: v.ts,
|
|
84
|
+
})));
|
|
79
85
|
info(`Total: ${res.total} | Showing: ${res.limit} | Offset: ${res.offset}`);
|
|
80
86
|
}
|
|
81
87
|
// Engagement events (open / click / page_visit / sent) — filterable by
|
|
@@ -106,7 +112,14 @@ export async function events(flags) {
|
|
|
106
112
|
printJson(res);
|
|
107
113
|
return;
|
|
108
114
|
}
|
|
109
|
-
|
|
115
|
+
// `type` is constant ('event') across this list — omitted from the table.
|
|
116
|
+
printTable(res.events.map(e => ({
|
|
117
|
+
pixel_id: e.pixel_id,
|
|
118
|
+
event_type: e.event_type,
|
|
119
|
+
url: e.url ?? '',
|
|
120
|
+
campaign_id: e.campaign_id ?? '',
|
|
121
|
+
ts: e.ts,
|
|
122
|
+
})));
|
|
110
123
|
info(`Total: ${res.total} | Showing: ${res.limit} | Offset: ${res.offset}`);
|
|
111
124
|
}
|
|
112
125
|
// Geographic distribution sample of the org's pixel audience.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Flags } from '../helpers.js';
|
|
2
2
|
import type { Exposes } from '../exposes.js';
|
|
3
3
|
export declare const EXPOSES: Exposes;
|
|
4
|
+
export declare function cachedLatestVersion(): string | null;
|
|
4
5
|
export declare function checkForUpdate(currentVersion: string): Promise<void>;
|
|
5
6
|
export declare function update(flags?: Flags): Promise<void>;
|
|
6
|
-
export declare function latestVersion(): Promise<string | null>;
|
|
7
7
|
export declare function isNewer(latest: string, current: string): boolean;
|
package/dist/commands/update.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
|
-
import { existsSync } from 'fs';
|
|
3
|
-
import {
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { loadConfig, CONFIG_DIR } from '../config.js';
|
|
4
5
|
import { info, success, banner } from '../output.js';
|
|
5
6
|
import { installSkills } from './setup.js';
|
|
6
7
|
// `myapi update` only talks to the npm registry to check for new CLI versions
|
|
@@ -8,6 +9,33 @@ import { installSkills } from './setup.js';
|
|
|
8
9
|
export const EXPOSES = [];
|
|
9
10
|
const REGISTRY_URL = 'https://registry.npmjs.org/@myapihq/cli/latest';
|
|
10
11
|
const PACKUMENT_URL = 'https://registry.npmjs.org/@myapihq/cli';
|
|
12
|
+
// The update check hits the npm registry. Caching its result means at most
|
|
13
|
+
// one network round-trip per TTL window instead of one on every command —
|
|
14
|
+
// the dominant cost in CLI startup latency.
|
|
15
|
+
const UPDATE_CACHE_FILE = join(CONFIG_DIR, 'update-check.json');
|
|
16
|
+
const CHECK_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
17
|
+
function readUpdateCache() {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(UPDATE_CACHE_FILE, 'utf-8'));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function writeUpdateCache(cache) {
|
|
26
|
+
try {
|
|
27
|
+
if (!existsSync(CONFIG_DIR))
|
|
28
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
29
|
+
writeFileSync(UPDATE_CACHE_FILE, JSON.stringify(cache));
|
|
30
|
+
}
|
|
31
|
+
catch { /* best-effort — a write failure just means we re-check sooner */ }
|
|
32
|
+
}
|
|
33
|
+
// The last-known published version, read from cache with no network call.
|
|
34
|
+
// `myapi --version` uses this so it can flag an available update without the
|
|
35
|
+
// ~1s registry round-trip blocking the output.
|
|
36
|
+
export function cachedLatestVersion() {
|
|
37
|
+
return readUpdateCache()?.latest ?? null;
|
|
38
|
+
}
|
|
11
39
|
// Verify the version is listed in the full packument — the same data source
|
|
12
40
|
// `npm install` consults. The /latest endpoint and the tarball URL are on
|
|
13
41
|
// different CDN caches and can update before the packument does, causing
|
|
@@ -48,42 +76,48 @@ function npmInstallArgs(version) {
|
|
|
48
76
|
}
|
|
49
77
|
}
|
|
50
78
|
// checkForUpdate runs silently in the background on every command.
|
|
51
|
-
// Auto-installs if a newer version is available.
|
|
79
|
+
// Auto-installs if a newer version is available. The registry is consulted
|
|
80
|
+
// at most once per CHECK_TTL_MS; runs within that window reuse the cache.
|
|
52
81
|
// Suppress entirely with MYAPI_NO_UPDATE=1 or when stdout is not a TTY.
|
|
53
82
|
export async function checkForUpdate(currentVersion) {
|
|
54
83
|
if (process.env.MYAPI_NO_UPDATE === '1' || !process.stdout.isTTY)
|
|
55
84
|
return;
|
|
85
|
+
const cache = readUpdateCache();
|
|
86
|
+
let latest = cache?.latest;
|
|
87
|
+
if (!cache || Date.now() - cache.checkedAt >= CHECK_TTL_MS) {
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(3000) });
|
|
90
|
+
if (res.ok)
|
|
91
|
+
latest = (await res.json()).version ?? latest;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Network errors are silently ignored — keep the last known `latest`.
|
|
95
|
+
}
|
|
96
|
+
// Record the attempt regardless of outcome so a slow or unreachable
|
|
97
|
+
// registry isn't re-hit on the very next command.
|
|
98
|
+
writeUpdateCache({ checkedAt: Date.now(), latest });
|
|
99
|
+
}
|
|
100
|
+
if (!latest || !isNewer(latest, currentVersion))
|
|
101
|
+
return;
|
|
102
|
+
// Verify the version is actually installable before attempting install —
|
|
103
|
+
// the /latest endpoint can report a new version before the packument
|
|
104
|
+
// (which `npm install` consults) has propagated through the CDN.
|
|
105
|
+
if (!(await isVersionInstallable(latest)))
|
|
106
|
+
return;
|
|
107
|
+
banner(`\n› New version available (${currentVersion} → ${latest}) — installing…`);
|
|
56
108
|
try {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (latest && isNewer(latest, currentVersion)) {
|
|
63
|
-
// Verify the version is actually installable before attempting install —
|
|
64
|
-
// the /latest endpoint can report a new version before the packument
|
|
65
|
-
// (which `npm install` consults) has propagated through the CDN.
|
|
66
|
-
if (!(await isVersionInstallable(latest)))
|
|
67
|
-
return;
|
|
68
|
-
banner(`\n› New version available (${currentVersion} → ${latest}) — installing…`);
|
|
69
|
-
try {
|
|
70
|
-
const { bin, args } = npmInstallArgs(latest);
|
|
71
|
-
execSync(`"${bin}" ${args.map(a => `"${a}"`).join(' ')}`, { stdio: 'pipe' });
|
|
72
|
-
const config = loadConfig();
|
|
73
|
-
if (config?.skills_installed) {
|
|
74
|
-
await installSkills();
|
|
75
|
-
}
|
|
76
|
-
banner(`› Updated to ${latest} — active after this command completes.\n`);
|
|
77
|
-
}
|
|
78
|
-
catch (installErr) {
|
|
79
|
-
const msg = installErr?.stderr?.toString?.() || installErr?.message || String(installErr);
|
|
80
|
-
banner(`› Auto-update failed: ${msg.trim()}`);
|
|
81
|
-
banner(`› Run manually: npm install -g @myapihq/cli@latest`);
|
|
82
|
-
}
|
|
109
|
+
const { bin, args } = npmInstallArgs(latest);
|
|
110
|
+
execSync(`"${bin}" ${args.map(a => `"${a}"`).join(' ')}`, { stdio: 'pipe' });
|
|
111
|
+
const config = loadConfig();
|
|
112
|
+
if (config?.skills_installed) {
|
|
113
|
+
await installSkills();
|
|
83
114
|
}
|
|
115
|
+
banner(`› Updated to ${latest} — active after this command completes.\n`);
|
|
84
116
|
}
|
|
85
|
-
catch {
|
|
86
|
-
|
|
117
|
+
catch (installErr) {
|
|
118
|
+
const msg = installErr?.stderr?.toString?.() || installErr?.message || String(installErr);
|
|
119
|
+
banner(`› Auto-update failed: ${msg.trim()}`);
|
|
120
|
+
banner(`› Run manually: npm install -g @myapihq/cli@latest`);
|
|
87
121
|
}
|
|
88
122
|
}
|
|
89
123
|
// myapi update — explicit update, same logic as auto-update.
|
|
@@ -100,8 +134,10 @@ export async function update(flags = {}) {
|
|
|
100
134
|
throw new Error(`registry returned ${res.status}`);
|
|
101
135
|
const data = await res.json();
|
|
102
136
|
latest = data.version ?? '';
|
|
103
|
-
if (latest)
|
|
137
|
+
if (latest) {
|
|
138
|
+
writeUpdateCache({ checkedAt: Date.now(), latest });
|
|
104
139
|
info(`› Installing @myapihq/cli@${latest}…`);
|
|
140
|
+
}
|
|
105
141
|
}
|
|
106
142
|
catch { /* proceed anyway */ }
|
|
107
143
|
try {
|
|
@@ -116,19 +152,6 @@ export async function update(flags = {}) {
|
|
|
116
152
|
await installSkills();
|
|
117
153
|
success('Up to date.');
|
|
118
154
|
}
|
|
119
|
-
// latestVersion fetches the current published version for --version display.
|
|
120
|
-
export async function latestVersion() {
|
|
121
|
-
try {
|
|
122
|
-
const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(3000) });
|
|
123
|
-
if (!res.ok)
|
|
124
|
-
return null;
|
|
125
|
-
const data = await res.json();
|
|
126
|
-
return data.version ?? null;
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
155
|
// Stable-only semver comparison (1.2.3). Prerelease versions (1.2.3-wip.0)
|
|
133
156
|
// produce NaN in the patch slot, and `n > NaN` is always false — which is
|
|
134
157
|
// what we want: a prerelease user should not be auto-rolled back to stable.
|
package/dist/commands/webhook.js
CHANGED
|
@@ -25,7 +25,16 @@ export async function list(flags) {
|
|
|
25
25
|
printJson(endpoints);
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
// Curated columns: org_id is the query scope (identical every row), and
|
|
29
|
+
// url is mechanically derivable from slug — both omitted here to keep the
|
|
30
|
+
// table token-dense. `myapi webhook get <id>` shows the full record.
|
|
31
|
+
printTable(endpoints.map(e => ({
|
|
32
|
+
id: e.id,
|
|
33
|
+
name: e.name,
|
|
34
|
+
slug: e.slug,
|
|
35
|
+
crm_email_path: e.crm_email_path ?? '',
|
|
36
|
+
created_at: e.created_at,
|
|
37
|
+
})), {
|
|
29
38
|
flags,
|
|
30
39
|
empty: 'No webhook endpoints yet. Create one with: myapi webhook create --name <name>',
|
|
31
40
|
});
|
package/dist/completion.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
export declare
|
|
1
|
+
export declare const COMMANDS: string[];
|
|
2
|
+
export declare const SUBCOMMANDS: Record<string, string[]>;
|
|
3
|
+
export declare function handleCompletionRequest(): void;
|
|
2
4
|
export declare function installCompletion(): void;
|
|
3
5
|
export declare function uninstallCompletion(): void;
|
package/dist/completion.js
CHANGED
|
@@ -1,24 +1,46 @@
|
|
|
1
|
-
// Shell
|
|
2
|
-
// the shell invokes us with the magic completion env vars, omelette's
|
|
3
|
-
// init() handles the request and exits before we hit normal command
|
|
4
|
-
// dispatch. On any other run, init() returns immediately.
|
|
1
|
+
// Shell tab-completion — hand-rolled, zero third-party dependencies.
|
|
5
2
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// This previously used the `omelette` package. It was removed for
|
|
4
|
+
// supply-chain safety: `myapi` is installed globally and reads the user's
|
|
5
|
+
// live API key from ~/.myapi/config.json on every run, so an unmaintained
|
|
6
|
+
// dependency on this path is an unacceptable compromise vector — one bad
|
|
7
|
+
// release would exfiltrate every user's key. This module reproduces only
|
|
8
|
+
// what we used (completion-request handling + shell init-file wiring) as
|
|
9
|
+
// audited first-party code.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
// Protocol: the shell init snippet generated below calls back
|
|
12
|
+
// myapi --comp{bash,zsh,fish} --compgen <cword> <prev> <line>
|
|
13
|
+
// We parse <line>, compute the candidate list, print it newline-separated,
|
|
14
|
+
// and exit. The shell itself narrows the list to the partial word.
|
|
15
|
+
//
|
|
16
|
+
// This module is imported lazily (see index.ts) — only when argv carries a
|
|
17
|
+
// --comp* marker — so it never loads on a normal command run.
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
import * as os from 'os';
|
|
21
|
+
const PROGRAM = 'myapi';
|
|
22
|
+
const COMPLETION_DIR = path.join(os.homedir(), '.myapi');
|
|
23
|
+
const BLOCK_BEGIN = `# begin ${PROGRAM} completion`;
|
|
24
|
+
const BLOCK_END = `# end ${PROGRAM} completion`;
|
|
25
|
+
// Top-level commands — what `myapi <TAB>` offers. Kept honest by
|
|
26
|
+
// test/smoke/completion.test.ts, which fails if a command in `myapi --help`
|
|
27
|
+
// is missing here.
|
|
28
|
+
export const COMMANDS = [
|
|
29
|
+
'audience', 'auth', 'billing', 'company', 'completion', 'config', 'container',
|
|
30
|
+
'crm', 'database', 'domain', 'email', 'fn', 'funnel', 'help', 'image',
|
|
31
|
+
'install-skills', 'keys', 'llm', 'org', 'payments', 'people', 'pixel',
|
|
32
|
+
'setup', 'status', 'storage', 'update', 'url', 'webhook', 'whoami',
|
|
33
|
+
'workflow',
|
|
34
|
+
];
|
|
35
|
+
// command → subcommands, for `myapi <command> <TAB>`. Mirrors each
|
|
36
|
+
// command's dispatcher; commands absent here take no subcommand.
|
|
37
|
+
export const SUBCOMMANDS = {
|
|
38
|
+
auth: ['setup', 'import-key', 'whoami', 'link', 'switch', 'install-skills', 'config', 'registrant', 'api-keys'],
|
|
17
39
|
org: ['create', 'delete', 'get', 'import', 'list', 'sync-brand', 'update'],
|
|
18
|
-
billing: ['balance', 'history', 'setup', 'topup'],
|
|
19
|
-
domain: ['assign', 'check', '
|
|
20
|
-
funnel: ['create', 'delete', 'get', 'list', 'pages', 'push', 'verify'],
|
|
21
|
-
webhook: ['create', 'delete', 'delivery', 'list'],
|
|
40
|
+
billing: ['balance', 'history', 'usage', 'setup', 'topup', 'spend-cap'],
|
|
41
|
+
domain: ['assign', 'check', 'email-setup', 'import', 'list', 'records', 'register', 'renew', 'retry-provisioning', 'settings', 'status'],
|
|
42
|
+
funnel: ['create', 'delete', 'get', 'list', 'pages', 'publish', 'push', 'verify'],
|
|
43
|
+
webhook: ['create', 'delete', 'delivery', 'list', 'update'],
|
|
22
44
|
workflow: ['create', 'delete', 'disable', 'enable', 'get', 'get-run', 'list', 'runs', 'update'],
|
|
23
45
|
email: ['mailbox', 'message', 'warmup', 'template', 'campaign', 'verify'],
|
|
24
46
|
image: ['delete', 'generate', 'get', 'get-url', 'list', 'models'],
|
|
@@ -30,49 +52,132 @@ const TREE = {
|
|
|
30
52
|
llm: ['complete', 'embed', 'models'],
|
|
31
53
|
database: ['namespaces', 'create', 'delete-namespace', 'keys', 'get', 'set', 'del'],
|
|
32
54
|
crm: ['contacts', 'companies'],
|
|
33
|
-
contacts: ['list', 'search', 'create', 'get', 'update', 'delete', 'restore', 'promote', 'events'],
|
|
34
|
-
companies: ['list', 'search', 'create', 'get', 'update', 'delete', 'restore', 'promote'],
|
|
35
55
|
url: ['shorten'],
|
|
36
|
-
keys: ['create', 'list', 'revoke'],
|
|
37
|
-
'api-keys': ['create', 'list', 'revoke'],
|
|
56
|
+
keys: ['create', 'list', 'revoke', 'revoke-all'],
|
|
38
57
|
config: ['view', 'set-org', 'set-funnel', 'set-domain'],
|
|
39
|
-
|
|
40
|
-
'
|
|
41
|
-
|
|
42
|
-
status: [],
|
|
43
|
-
'install-skills': [],
|
|
44
|
-
update: [],
|
|
58
|
+
fn: ['create', 'deploy', 'env', 'runs', 'list', 'get', 'delete'],
|
|
59
|
+
payments: ['connect', 'status', 'charge', 'list', 'get', 'refund'],
|
|
60
|
+
container: ['create', 'deploy', 'list', 'get', 'delete'],
|
|
45
61
|
completion: ['install', 'uninstall'],
|
|
46
|
-
help: [],
|
|
47
62
|
};
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
// ── Completion request handling ──────────────────────────────────────────────
|
|
64
|
+
// Entry point for a shell-initiated completion request. index.ts calls this
|
|
65
|
+
// (via a lazy import) whenever argv carries a --comp* marker. Always exits.
|
|
66
|
+
export function handleCompletionRequest() {
|
|
67
|
+
const argv = process.argv;
|
|
68
|
+
// `myapi --completion[-fish]` — emit the shell init script (zsh/fish
|
|
69
|
+
// source it live; bash sources the copy written by installCompletion).
|
|
70
|
+
if (argv.includes('--completion')) {
|
|
71
|
+
process.stdout.write(shellScript());
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
if (argv.includes('--completion-fish')) {
|
|
75
|
+
process.stdout.write(fishScript());
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
// `myapi --comp{bash,zsh} --compgen <cword> <prev> <line...>`
|
|
79
|
+
const gen = argv.indexOf('--compgen');
|
|
80
|
+
if (gen < 0)
|
|
81
|
+
process.exit(0);
|
|
82
|
+
// zsh's cursor index is one higher than bash's for the same position.
|
|
83
|
+
const isZsh = argv.includes('--compzsh');
|
|
84
|
+
const cword = (parseInt(argv[gen + 1], 10) || 0) - (isZsh ? 1 : 0);
|
|
85
|
+
const line = argv.slice(gen + 3).join(' ');
|
|
86
|
+
const words = line.trim().split(/\s+/);
|
|
87
|
+
let candidates = [];
|
|
88
|
+
if (cword <= 1)
|
|
89
|
+
candidates = COMMANDS;
|
|
90
|
+
else if (cword === 2)
|
|
91
|
+
candidates = SUBCOMMANDS[words[1]] ?? [];
|
|
92
|
+
// Print the full list; the shell narrows it to the partial word.
|
|
93
|
+
process.stdout.write(candidates.join(os.EOL) + os.EOL);
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
// ── Shell init scripts ───────────────────────────────────────────────────────
|
|
97
|
+
// bash + zsh completion function. The `if compdef / elif complete` guard
|
|
98
|
+
// picks the right registration at source time, so one script serves both.
|
|
99
|
+
function shellScript() {
|
|
100
|
+
return `### ${PROGRAM} completion ###
|
|
101
|
+
if type compdef &>/dev/null; then
|
|
102
|
+
_${PROGRAM}_completion() {
|
|
103
|
+
compadd -- \`${PROGRAM} --compzsh --compgen "$CURRENT" "\${words[CURRENT-1]}" "$BUFFER"\`
|
|
104
|
+
}
|
|
105
|
+
compdef _${PROGRAM}_completion ${PROGRAM}
|
|
106
|
+
elif type complete &>/dev/null; then
|
|
107
|
+
_${PROGRAM}_completion() {
|
|
108
|
+
local cur="\${COMP_WORDS[COMP_CWORD]}" prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
109
|
+
COMPREPLY=( $(compgen -W '$(${PROGRAM} --compbash --compgen "$COMP_CWORD" "$prev" "$COMP_LINE")' -- "$cur") )
|
|
110
|
+
}
|
|
111
|
+
complete -F _${PROGRAM}_completion ${PROGRAM}
|
|
112
|
+
fi
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
function fishScript() {
|
|
116
|
+
return `### ${PROGRAM} completion ###
|
|
117
|
+
function _${PROGRAM}_completion
|
|
118
|
+
${PROGRAM} --compfish --compgen (count (commandline -poc)) (commandline -pt) (commandline -pb)
|
|
119
|
+
end
|
|
120
|
+
complete -f -c ${PROGRAM} -a '(_${PROGRAM}_completion)'
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
// ── Install / uninstall ──────────────────────────────────────────────────────
|
|
124
|
+
function activeShell() {
|
|
125
|
+
const s = process.env.SHELL ?? '';
|
|
126
|
+
if (s.includes('zsh'))
|
|
127
|
+
return 'zsh';
|
|
128
|
+
if (s.includes('fish'))
|
|
129
|
+
return 'fish';
|
|
130
|
+
if (s.includes('bash'))
|
|
131
|
+
return 'bash';
|
|
132
|
+
throw new Error(`Could not detect a supported shell (SHELL="${s}"). Supported: bash, zsh, fish.`);
|
|
133
|
+
}
|
|
134
|
+
function initFile(shell) {
|
|
135
|
+
const home = os.homedir();
|
|
136
|
+
if (shell === 'zsh')
|
|
137
|
+
return path.join(home, '.zshrc');
|
|
138
|
+
if (shell === 'fish')
|
|
139
|
+
return path.join(home, '.config', 'fish', 'config.fish');
|
|
140
|
+
return path.join(home, process.platform === 'darwin' ? '.bash_profile' : '.bashrc');
|
|
141
|
+
}
|
|
142
|
+
// The source line appended to the shell's rc file.
|
|
143
|
+
function initBlock(shell) {
|
|
144
|
+
let cmd;
|
|
145
|
+
if (shell === 'bash')
|
|
146
|
+
cmd = `source "${path.join(COMPLETION_DIR, 'completion.sh')}"`;
|
|
147
|
+
else if (shell === 'zsh')
|
|
148
|
+
cmd = `. <(${PROGRAM} --completion)`;
|
|
149
|
+
else
|
|
150
|
+
cmd = `${PROGRAM} --completion-fish | source`;
|
|
151
|
+
return `\n${BLOCK_BEGIN}\n${cmd}\n${BLOCK_END}\n`;
|
|
152
|
+
}
|
|
153
|
+
function escapeRe(s) {
|
|
154
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
70
155
|
}
|
|
71
|
-
// Wire/unwire the shell rc snippet. Omelette writes a single eval line
|
|
72
|
-
// into ~/.bashrc / ~/.zshrc / ~/.config/fish/config.fish.
|
|
73
156
|
export function installCompletion() {
|
|
74
|
-
|
|
157
|
+
const shell = activeShell();
|
|
158
|
+
// bash can't reliably source a process substitution from .bashrc, so the
|
|
159
|
+
// script is written to a file the rc line sources.
|
|
160
|
+
if (shell === 'bash') {
|
|
161
|
+
fs.mkdirSync(COMPLETION_DIR, { recursive: true });
|
|
162
|
+
fs.writeFileSync(path.join(COMPLETION_DIR, 'completion.sh'), shellScript());
|
|
163
|
+
}
|
|
164
|
+
const file = initFile(shell);
|
|
165
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
166
|
+
if (existing.includes(BLOCK_BEGIN))
|
|
167
|
+
return; // idempotent — already installed
|
|
168
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
169
|
+
fs.appendFileSync(file, initBlock(shell));
|
|
75
170
|
}
|
|
76
171
|
export function uninstallCompletion() {
|
|
77
|
-
|
|
172
|
+
const shell = activeShell();
|
|
173
|
+
const file = initFile(shell);
|
|
174
|
+
if (fs.existsSync(file)) {
|
|
175
|
+
const cleaned = fs.readFileSync(file, 'utf8').replace(new RegExp(`\\n?${escapeRe(BLOCK_BEGIN)}[\\s\\S]*?${escapeRe(BLOCK_END)}\\n?`, 'g'), '');
|
|
176
|
+
fs.writeFileSync(file, cleaned);
|
|
177
|
+
}
|
|
178
|
+
if (shell === 'bash') {
|
|
179
|
+
const sh = path.join(COMPLETION_DIR, 'completion.sh');
|
|
180
|
+
if (fs.existsSync(sh))
|
|
181
|
+
fs.unlinkSync(sh);
|
|
182
|
+
}
|
|
78
183
|
}
|
package/dist/exposes.test.js
CHANGED
|
@@ -39,6 +39,7 @@ const COMMAND_MODULES = [
|
|
|
39
39
|
'./commands/workflow.js',
|
|
40
40
|
'./commands/fn.js',
|
|
41
41
|
'./commands/payments.js',
|
|
42
|
+
'./commands/container.js',
|
|
42
43
|
];
|
|
43
44
|
const ENDPOINT_PATTERN = /^(GET|POST|PATCH|PUT|DELETE) \/[A-Za-z0-9_\-./{}]*$/;
|
|
44
45
|
describe('every CLI command exports a typed EXPOSES array (S-101)', () => {
|
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ import * as databaseCmd from './commands/database.js';
|
|
|
31
31
|
import * as crmCmd from './commands/crm/index.js';
|
|
32
32
|
import * as fnCmd from './commands/fn.js';
|
|
33
33
|
import * as paymentsCmd from './commands/payments.js';
|
|
34
|
-
import
|
|
34
|
+
import * as containerCmd from './commands/container.js';
|
|
35
35
|
// Each command file declares the value flags it understands. We union them
|
|
36
36
|
// into a single schema for the upfront parse, so adding a new value flag in
|
|
37
37
|
// one command means editing one file (its SCHEMA), not a global allowlist.
|
|
@@ -59,6 +59,7 @@ const COMBINED_SCHEMA = {
|
|
|
59
59
|
...workflowCmd.SCHEMA,
|
|
60
60
|
...fnCmd.SCHEMA,
|
|
61
61
|
...paymentsCmd.SCHEMA,
|
|
62
|
+
...containerCmd.SCHEMA,
|
|
62
63
|
// Top-level flags
|
|
63
64
|
version: 'boolean',
|
|
64
65
|
V: 'boolean',
|
|
@@ -114,12 +115,20 @@ function friendlyError(err) {
|
|
|
114
115
|
return base;
|
|
115
116
|
}
|
|
116
117
|
async function main() {
|
|
117
|
-
// Shell
|
|
118
|
-
//
|
|
119
|
-
|
|
118
|
+
// Shell tab-completion: when the shell invokes us for completion it passes
|
|
119
|
+
// a --comp* marker. Handle it via a lazily-imported module so completion
|
|
120
|
+
// code (and its file I/O) never loads on a normal command run.
|
|
121
|
+
if (process.argv.some(a => a === '--compgen' || a === '--completion' || a === '--completion-fish')) {
|
|
122
|
+
const { handleCompletionRequest } = await import('./completion.js');
|
|
123
|
+
handleCompletionRequest(); // computes candidates / emits the script, then exits
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
120
126
|
const { args, flags } = parseFlags(process.argv.slice(2), COMBINED_SCHEMA);
|
|
121
127
|
if (flags.version || flags.v || flags.V) {
|
|
122
|
-
|
|
128
|
+
// Read the last-known published version from cache — no network call, so
|
|
129
|
+
// `myapi --version` stays instant. The cache is refreshed by the
|
|
130
|
+
// background update check on regular commands.
|
|
131
|
+
const latest = updateCmd.cachedLatestVersion();
|
|
123
132
|
const updateNote = latest && updateCmd.isNewer(latest, pkg.version)
|
|
124
133
|
? ` (update available: ${latest})`
|
|
125
134
|
: '';
|
|
@@ -207,6 +216,9 @@ async function main() {
|
|
|
207
216
|
case 'payments':
|
|
208
217
|
await paymentsCmd.run(subcommand, restArgs, flags);
|
|
209
218
|
break;
|
|
219
|
+
case 'container':
|
|
220
|
+
await containerCmd.run(subcommand, restArgs, flags);
|
|
221
|
+
break;
|
|
210
222
|
// Convenience aliases
|
|
211
223
|
case 'setup':
|
|
212
224
|
await setupCmd.setup(flags);
|
|
@@ -231,20 +243,20 @@ async function main() {
|
|
|
231
243
|
await setupCmd.installSkills();
|
|
232
244
|
success('› Skills installed.');
|
|
233
245
|
break;
|
|
234
|
-
case 'completion':
|
|
235
|
-
|
|
236
|
-
// unconditionally, so anything we want to show has to print first.
|
|
246
|
+
case 'completion': {
|
|
247
|
+
const { installCompletion, uninstallCompletion } = await import('./completion.js');
|
|
237
248
|
if (subcommand === 'uninstall') {
|
|
238
|
-
success('› Removing completion from shell init file…');
|
|
239
|
-
info(' Restart your shell (or `source ~/.bashrc` / `~/.zshrc`) to take effect.');
|
|
240
249
|
uninstallCompletion();
|
|
250
|
+
success('› Completion removed from your shell init file.');
|
|
251
|
+
info(' Restart your shell (or re-source your rc file) for it to take effect.');
|
|
241
252
|
}
|
|
242
253
|
else {
|
|
243
|
-
success('› Installing tab completion…');
|
|
244
|
-
info(' Restart your shell (or `source ~/.bashrc` / `~/.zshrc`) to enable.');
|
|
245
254
|
installCompletion();
|
|
255
|
+
success('› Tab completion installed.');
|
|
256
|
+
info(' Restart your shell (or re-source your rc file) to enable it.');
|
|
246
257
|
}
|
|
247
258
|
break;
|
|
259
|
+
}
|
|
248
260
|
case 'help':
|
|
249
261
|
await dispatchHelp(subcommand);
|
|
250
262
|
break;
|
|
@@ -329,6 +341,7 @@ const HELP_TARGETS = {
|
|
|
329
341
|
crm: f => crmCmd.run(undefined, [], f),
|
|
330
342
|
fn: f => fnCmd.run(undefined, [], f),
|
|
331
343
|
payments: f => paymentsCmd.run(undefined, [], f),
|
|
344
|
+
container: f => containerCmd.run(undefined, [], f),
|
|
332
345
|
org: f => orgCmd.run(undefined, [], f),
|
|
333
346
|
billing: f => billingCmd.run(undefined, [], f),
|
|
334
347
|
keys: f => keysCmd.run(undefined, [], f),
|
|
@@ -371,6 +384,7 @@ Commands:
|
|
|
371
384
|
domain Manage domain configurations
|
|
372
385
|
funnel Manage websites (publish pages, custom domains, funnels)
|
|
373
386
|
fn Create and deploy functions on the edge runtime
|
|
387
|
+
container Run containers — services, workers, and scheduled jobs
|
|
374
388
|
payments Take payments with Stripe Checkout (connect, charge, refund)
|
|
375
389
|
webhook Manage inbound webhook endpoints and inspect deliveries
|
|
376
390
|
email Manage mailboxes, send/read email, templates, and campaigns
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SDK-level unit tests for hq.getBillingUsage — the spend-by-service
|
|
2
|
+
// rollup backed by the backend's detailed metering
|
|
3
|
+
// (GET /hq/billing/usage). Mocks global fetch — does NOT hit the network.
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
5
|
+
import { hq } from '@myapihq/sdk';
|
|
6
|
+
const API_KEY = 'myapi_test_abc';
|
|
7
|
+
let fetchMock;
|
|
8
|
+
function ok(data, status = 200) {
|
|
9
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
10
|
+
status,
|
|
11
|
+
headers: { 'content-type': 'application/json' },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function fail(code, message, status = 400) {
|
|
15
|
+
return new Response(JSON.stringify({ success: false, error: { code, message }, meta: {} }), {
|
|
16
|
+
status,
|
|
17
|
+
headers: { 'content-type': 'application/json' },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const SAMPLE = {
|
|
21
|
+
period: 'month',
|
|
22
|
+
since: '2026-05-01T00:00:00Z',
|
|
23
|
+
services: [
|
|
24
|
+
{ service: 'llm', requests: 1240, cost_display: '$3.10' },
|
|
25
|
+
{ service: 'image', requests: 22, cost_display: '$0.44' },
|
|
26
|
+
],
|
|
27
|
+
total_display: '$3.54',
|
|
28
|
+
};
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
fetchMock = vi.fn();
|
|
31
|
+
globalThis.fetch = fetchMock;
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
});
|
|
36
|
+
describe('hq.getBillingUsage', () => {
|
|
37
|
+
it('GETs /hq/billing/usage with bearer auth and no query by default', async () => {
|
|
38
|
+
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
|
|
39
|
+
const res = await hq.getBillingUsage(API_KEY);
|
|
40
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
41
|
+
expect(url).toMatch(/\/hq\/billing\/usage$/);
|
|
42
|
+
expect(init.method).toBe('GET');
|
|
43
|
+
expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`);
|
|
44
|
+
expect(res.total_display).toBe('$3.54');
|
|
45
|
+
expect(res.services).toHaveLength(2);
|
|
46
|
+
expect(res.services[0]).toEqual({ service: 'llm', requests: 1240, cost_display: '$3.10' });
|
|
47
|
+
});
|
|
48
|
+
it('appends ?period=30d when the trailing-30-days window is requested', async () => {
|
|
49
|
+
fetchMock.mockResolvedValueOnce(ok({ ...SAMPLE, period: '30d' }));
|
|
50
|
+
await hq.getBillingUsage(API_KEY, '30d');
|
|
51
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\/hq\/billing\/usage\?period=30d$/);
|
|
52
|
+
});
|
|
53
|
+
it('sends period=month explicitly when asked', async () => {
|
|
54
|
+
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
|
|
55
|
+
await hq.getBillingUsage(API_KEY, 'month');
|
|
56
|
+
expect(fetchMock.mock.calls[0][0]).toMatch(/\?period=month$/);
|
|
57
|
+
});
|
|
58
|
+
it('returns an empty services list cleanly (no spend in the window)', async () => {
|
|
59
|
+
fetchMock.mockResolvedValueOnce(ok({ period: 'month', since: '2026-05-01T00:00:00Z', services: [], total_display: '$0.00' }));
|
|
60
|
+
const res = await hq.getBillingUsage(API_KEY);
|
|
61
|
+
expect(res.services).toEqual([]);
|
|
62
|
+
expect(res.total_display).toBe('$0.00');
|
|
63
|
+
});
|
|
64
|
+
it('surfaces a bad period (400) as a typed MyApiError', async () => {
|
|
65
|
+
fetchMock.mockResolvedValueOnce(fail('bad_request', "period must be 'month' or '30d'", 400));
|
|
66
|
+
await expect(hq.getBillingUsage(API_KEY, 'month'))
|
|
67
|
+
.rejects.toMatchObject({ name: 'MyApiError', status: 400 });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('hq.EXPOSES', () => {
|
|
71
|
+
it('lists GET /hq/billing/usage', () => {
|
|
72
|
+
expect(hq.EXPOSES).toContain('GET /hq/billing/usage');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SDK-level unit tests for the container module. Verifies request
|
|
2
|
+
// URL/body shape, response parsing, and error envelopes against
|
|
3
|
+
// myapi-hq/internal/routes/container/. Mocks global fetch — no network.
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
5
|
+
import { container } from '@myapihq/sdk';
|
|
6
|
+
const API_KEY = 'myapi_test_abc';
|
|
7
|
+
const ORG_ID = '11111111-1111-4111-8111-111111111111';
|
|
8
|
+
const C_ID = '55555555-5555-4555-8555-555555555555';
|
|
9
|
+
let fetchMock;
|
|
10
|
+
function ok(data, status = 200) {
|
|
11
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
12
|
+
status,
|
|
13
|
+
headers: { 'content-type': 'application/json' },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function fail(code, message, status = 422) {
|
|
17
|
+
return new Response(JSON.stringify({ success: false, error: { code, message }, meta: {} }), {
|
|
18
|
+
status,
|
|
19
|
+
headers: { 'content-type': 'application/json' },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const SAMPLE = {
|
|
23
|
+
id: C_ID, org_id: ORG_ID, name: 'api', type: 'service', env: {},
|
|
24
|
+
cpu: '1', memory: '512Mi', min_instances: 0, max_instances: 3, port: 8080,
|
|
25
|
+
status: 'created', created_at: 't', updated_at: 't',
|
|
26
|
+
};
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
fetchMock = vi.fn();
|
|
29
|
+
globalThis.fetch = fetchMock;
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
describe('container.createContainer', () => {
|
|
35
|
+
it('POSTs the payload with bearer auth + JSON body', async () => {
|
|
36
|
+
fetchMock.mockResolvedValueOnce(ok({ container: SAMPLE, scoped_api_key: 'hq_live_x', scoped_api_key_id: 'key_x' }, 201));
|
|
37
|
+
const res = await container.createContainer(API_KEY, ORG_ID, { name: 'api', type: 'service', port: 8080 });
|
|
38
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
39
|
+
expect(url).toContain(`/container/orgs/${ORG_ID}/containers`);
|
|
40
|
+
expect(init.method).toBe('POST');
|
|
41
|
+
expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`);
|
|
42
|
+
expect(JSON.parse(init.body)).toEqual({ name: 'api', type: 'service', port: 8080 });
|
|
43
|
+
expect(res.container.id).toBe(C_ID);
|
|
44
|
+
expect(res.scoped_api_key).toBe('hq_live_x');
|
|
45
|
+
});
|
|
46
|
+
it('surfaces INVALID_TYPE (422) and NAME_TAKEN (409)', async () => {
|
|
47
|
+
fetchMock.mockResolvedValueOnce(fail('INVALID_TYPE', 'cron_schedule is only valid for type=job', 422));
|
|
48
|
+
await expect(container.createContainer(API_KEY, ORG_ID, { name: 'x', type: 'service', cron_schedule: '* * * * *' }))
|
|
49
|
+
.rejects.toMatchObject({ code: 'INVALID_TYPE', status: 422 });
|
|
50
|
+
fetchMock.mockResolvedValueOnce(fail('NAME_TAKEN', 'already used', 409));
|
|
51
|
+
await expect(container.createContainer(API_KEY, ORG_ID, { name: 'x' }))
|
|
52
|
+
.rejects.toMatchObject({ code: 'NAME_TAKEN', status: 409 });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('container.listContainers / getContainer', () => {
|
|
56
|
+
it('GETs the container list', async () => {
|
|
57
|
+
fetchMock.mockResolvedValueOnce(ok([SAMPLE]));
|
|
58
|
+
const list = await container.listContainers(API_KEY, ORG_ID);
|
|
59
|
+
expect(list).toHaveLength(1);
|
|
60
|
+
expect(fetchMock.mock.calls[0][1].method).toBe('GET');
|
|
61
|
+
});
|
|
62
|
+
it('GETs a single container', async () => {
|
|
63
|
+
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
|
|
64
|
+
const c = await container.getContainer(API_KEY, ORG_ID, C_ID);
|
|
65
|
+
expect(c.name).toBe('api');
|
|
66
|
+
expect(fetchMock.mock.calls[0][0]).toContain(`/container/orgs/${ORG_ID}/containers/${C_ID}`);
|
|
67
|
+
});
|
|
68
|
+
it('surfaces container_not_found (404)', async () => {
|
|
69
|
+
fetchMock.mockResolvedValueOnce(fail('container_not_found', 'not found', 404));
|
|
70
|
+
await expect(container.getContainer(API_KEY, ORG_ID, C_ID))
|
|
71
|
+
.rejects.toMatchObject({ code: 'container_not_found', status: 404 });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('container.deleteContainer', () => {
|
|
75
|
+
it('DELETEs and resolves on 204', async () => {
|
|
76
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
77
|
+
await container.deleteContainer(API_KEY, ORG_ID, C_ID);
|
|
78
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
79
|
+
expect(url).toContain(`/container/orgs/${ORG_ID}/containers/${C_ID}`);
|
|
80
|
+
expect(init.method).toBe('DELETE');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('container.deployContainer', () => {
|
|
84
|
+
it('POSTs {image} and returns the rotated key + url', async () => {
|
|
85
|
+
fetchMock.mockResolvedValueOnce(ok({
|
|
86
|
+
container_id: C_ID, revision_id: 'rev_1', url: 'https://c.run.app', status: 'active', scoped_api_key: 'hq_live_rotated',
|
|
87
|
+
}));
|
|
88
|
+
const res = await container.deployContainer(API_KEY, ORG_ID, C_ID, 'registry/app:v2');
|
|
89
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
90
|
+
expect(url).toContain(`/container/orgs/${ORG_ID}/containers/${C_ID}/deploy`);
|
|
91
|
+
expect(init.method).toBe('POST');
|
|
92
|
+
expect(JSON.parse(init.body)).toEqual({ image: 'registry/app:v2' });
|
|
93
|
+
expect(res.scoped_api_key).toBe('hq_live_rotated');
|
|
94
|
+
expect(res.url).toBe('https://c.run.app');
|
|
95
|
+
});
|
|
96
|
+
it('surfaces RUNTIME_UNAVAILABLE (503) and IMAGE_REQUIRED (422)', async () => {
|
|
97
|
+
fetchMock.mockResolvedValueOnce(fail('RUNTIME_UNAVAILABLE', 'Cloud Run not configured', 503));
|
|
98
|
+
await expect(container.deployContainer(API_KEY, ORG_ID, C_ID, 'img'))
|
|
99
|
+
.rejects.toMatchObject({ code: 'RUNTIME_UNAVAILABLE', status: 503 });
|
|
100
|
+
fetchMock.mockResolvedValueOnce(fail('IMAGE_REQUIRED', 'image ref required', 422));
|
|
101
|
+
await expect(container.deployContainer(API_KEY, ORG_ID, C_ID, ''))
|
|
102
|
+
.rejects.toMatchObject({ code: 'IMAGE_REQUIRED', status: 422 });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('container.EXPOSES', () => {
|
|
106
|
+
it('covers the 5 container endpoints', () => {
|
|
107
|
+
expect(container.EXPOSES).toEqual([
|
|
108
|
+
'POST /container/orgs/{org_id}/containers',
|
|
109
|
+
'GET /container/orgs/{org_id}/containers',
|
|
110
|
+
'GET /container/orgs/{org_id}/containers/{id}',
|
|
111
|
+
'DELETE /container/orgs/{org_id}/containers/{id}',
|
|
112
|
+
'POST /container/orgs/{org_id}/containers/{id}/deploy',
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myapihq/cli",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.7",
|
|
5
5
|
"description": "MyAPI command-line interface",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"files": [
|
|
@@ -29,12 +29,10 @@
|
|
|
29
29
|
"lint:changelog": "node ../../scripts/lint-changelog.js"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@myapihq/sdk": "^1.2.
|
|
33
|
-
"omelette": "^0.4.17"
|
|
32
|
+
"@myapihq/sdk": "^1.2.7"
|
|
34
33
|
},
|
|
35
34
|
"devDependencies": {
|
|
36
35
|
"@types/node": "^25.6.0",
|
|
37
|
-
"@types/omelette": "^0.4.5",
|
|
38
36
|
"typescript": "^5.4.0",
|
|
39
37
|
"vitest": "^4.1.5"
|
|
40
38
|
}
|