@myapihq/cli 1.2.6 → 1.2.8

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.
@@ -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', 'logs', '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 {};