@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.
@@ -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>;
@@ -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
- printTable(campaigns, {
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
- printTable(emails, {
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
- printTable(messages, {
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
- printTable(messages, {
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
- printTable(templates, {
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
  });
@@ -75,7 +75,13 @@ export async function visits(flags) {
75
75
  printJson(res);
76
76
  return;
77
77
  }
78
- printTable(res.visits);
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
- printTable(res.events);
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;
@@ -1,6 +1,7 @@
1
1
  import { execSync } from 'child_process';
2
- import { existsSync } from 'fs';
3
- import { loadConfig } from '../config.js';
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 res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(3000) });
58
- if (!res.ok)
59
- return;
60
- const data = await res.json();
61
- const latest = data.version;
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
- // Network errors are silently ignored.
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.
@@ -25,7 +25,16 @@ export async function list(flags) {
25
25
  printJson(endpoints);
26
26
  return;
27
27
  }
28
- printTable(endpoints, {
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
  });
@@ -1,3 +1,5 @@
1
- export declare function initCompletion(): void;
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;
@@ -1,24 +1,46 @@
1
- // Shell autocomplete via omelette. Quietly active on every CLI run — when
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
- // User installs the shell hook with `myapi completion install` (or
7
- // uninstalls with `myapi completion uninstall`). Supports bash, zsh, and
8
- // fish (handled internally by omelette).
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
- // The tree below mirrors the dispatcher in index.ts and the per-command
11
- // switch statements. When you add a new top-level command or subcommand,
12
- // extend it here too there's no automatic discovery.
13
- import omelette from 'omelette';
14
- const TREE = {
15
- auth: ['link', 'switch', 'whoami', 'config', 'registrant'],
16
- registrant: ['set', 'get', 'clear'],
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', 'list', 'register', 'renew', 'settings', 'status', 'unassign', 'update-settings'],
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
- setup: [],
40
- 'import-key': [],
41
- whoami: [],
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
- const TOP_LEVEL = Object.keys(TREE).sort();
49
- const completion = omelette('myapi <command> <subcommand>');
50
- completion.on('command', ({ reply }) => reply(TOP_LEVEL));
51
- completion.on('subcommand', ({ before, reply, line }) => {
52
- // omelette's `before` is the LAST word of the line, not the previous
53
- // word. When the user has started typing a partial subcommand (e.g.
54
- // "myapi audience l"), `before` is "l", not "audience" so a naive
55
- // `TREE[before]` returns undefined and tab completion silently fails.
56
- // Detect the real parent command by checking whether the line ends in
57
- // whitespace (no partial in flight → use `before`) vs. mid-word (partial
58
- // in flight → use the previous word from the line).
59
- const endsInSpace = /\s$/.test(line);
60
- const parent = endsInSpace
61
- ? before
62
- : (line.trim().split(/\s+/).slice(-2, -1)[0] ?? before);
63
- reply(TREE[parent] ?? []);
64
- });
65
- // Quiet no-op when shell isn't asking for completion. The first thing
66
- // index.ts should do is call this — omelette will short-circuit and
67
- // process.exit if a completion request is in flight.
68
- export function initCompletion() {
69
- completion.init();
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
- completion.setupShellInitFile();
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
- completion.cleanupShellInitFile();
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
  }
@@ -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 { initCompletion, installCompletion, uninstallCompletion } from './completion.js';
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 autocomplete: if invoked by the shell with completion env vars,
118
- // omelette short-circuits before normal dispatch. Always cheap on a real run.
119
- initCompletion();
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
- const latest = await updateCmd.latestVersion();
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
- // Order matters: omelette's setup/cleanup helpers call process.exit()
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.6",
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.6",
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
  }