@myapihq/cli 2.25.0 → 2.25.2

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.
@@ -786,6 +786,7 @@ Subcommands:
786
786
  domain <id> <domain> Bind a custom domain (--remove to unbind)
787
787
  env <id> Change environment variables (merge; rolls a new revision)
788
788
  get <id> Inspect a container
789
+ health-check <id> Set or clear the startup probe (--clear); rolls a new revision
789
790
  list List containers in your org
790
791
  logs <id> Show recent runtime logs (--tail <n>, --scope all) — see build-logs for build failures
791
792
  promote <id> <rev> Move all traffic to a revision (seconds, no rebuild)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,146 @@
1
+ // A verb you cannot find is a verb you do not have.
2
+ //
3
+ // `container health-check` shipped dispatched, documented in SUBCOMMAND_USAGE,
4
+ // covered by tests and named in the skill — and absent from the list that
5
+ // `myapi container --help` prints. The only way to discover it was to already
6
+ // know it existed. It was found by running the published binary after release,
7
+ // which is not a mechanism.
8
+ //
9
+ // The existing help linter checks that listings are ALPHABETICAL. Nothing
10
+ // checked that they are COMPLETE, so a new verb could be sorted correctly into
11
+ // a list it was not in.
12
+ //
13
+ // This drives each command's own help output — the exact string the user sees,
14
+ // not a listing block matched by a regex, because two earlier attempts at this
15
+ // measured the wrong thing: one required a `Subcommands:` header that `task`
16
+ // does not use, and one accepted an entry in SUBCOMMAND_USAGE, which is what
17
+ // health-check already had while remaining invisible.
18
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
19
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ const COMMANDS_DIR = fileURLToPath(new URL('.', import.meta.url));
23
+ // Verbs deliberately absent from the printed list, each with a reason.
24
+ // A hidden verb is a decision; an accidentally hidden one is the bug above.
25
+ const HIDDEN_OK = {
26
+ 'database.ts': {
27
+ keys: 'undocumented alias of `entries`, kept so existing scripts keep working; ' +
28
+ 'the code says it goes no earlier than the next minor',
29
+ },
30
+ 'org.ts': {
31
+ import: 'deprecated alias of `sync-brand`; still dispatches and prints a ' +
32
+ 'deprecation notice, deliberately absent from the listing',
33
+ },
34
+ };
35
+ function moduleFiles() {
36
+ const out = [];
37
+ for (const entry of readdirSync(COMMANDS_DIR).sort()) {
38
+ const p = path.join(COMMANDS_DIR, entry);
39
+ if (statSync(p).isDirectory()) {
40
+ const idx = path.join(p, 'index.ts');
41
+ try {
42
+ statSync(idx);
43
+ out.push(`${entry}/index.ts`);
44
+ }
45
+ catch { /* no dispatcher */ }
46
+ }
47
+ else if (entry.endsWith('.ts') && !entry.includes('.test.')) {
48
+ out.push(entry);
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+ // Only the TOP-LEVEL dispatcher's cases. A nested switch — `account registrant
54
+ // set`, `domain records create`, `llm cache list` — belongs to a sub-verb that
55
+ // the parent line documents inline, and counting those made the check report
56
+ // three commands that are fine.
57
+ function dispatchedVerbs(src) {
58
+ const start = src.indexOf('export async function run(');
59
+ if (start === -1)
60
+ return [];
61
+ let i = src.indexOf('{', start);
62
+ if (i === -1)
63
+ return [];
64
+ let depth = 0, end = src.length;
65
+ for (let j = i; j < src.length; j++) {
66
+ if (src[j] === '{')
67
+ depth++;
68
+ else if (src[j] === '}') {
69
+ depth--;
70
+ if (depth === 0) {
71
+ end = j;
72
+ break;
73
+ }
74
+ }
75
+ }
76
+ const body = src.slice(i, end);
77
+ return [...new Set([...body.matchAll(/case\s+'([a-z][a-z0-9-]*)'\s*:/g)].map(m => m[1]))];
78
+ }
79
+ let printed;
80
+ beforeEach(async () => {
81
+ printed = [];
82
+ const output = await import('../output.js');
83
+ const capture = ((m) => { printed.push(String(m ?? '')); });
84
+ vi.spyOn(output, 'info').mockImplementation(capture);
85
+ vi.spyOn(output, 'success').mockImplementation(capture);
86
+ vi.spyOn(output, 'printJson').mockImplementation(capture);
87
+ vi.spyOn(output, 'error').mockImplementation(((m) => {
88
+ printed.push(String(m));
89
+ throw new Error('__EXIT__');
90
+ }));
91
+ });
92
+ afterEach(() => vi.restoreAllMocks());
93
+ async function helpTextOf(file) {
94
+ const mod = await import(`./${file}`);
95
+ if (typeof mod.run !== 'function')
96
+ return '';
97
+ try {
98
+ await mod.run(undefined, [], {});
99
+ }
100
+ catch (e) {
101
+ if (e?.message !== '__EXIT__')
102
+ throw e;
103
+ }
104
+ return printed.join('\n');
105
+ }
106
+ describe('every dispatched verb appears in its command help', () => {
107
+ const files = moduleFiles();
108
+ it('no waiver claims to hide a verb that is listed anyway', async () => {
109
+ const stale = [];
110
+ for (const [file, waived] of Object.entries(HIDDEN_OK)) {
111
+ const help = await helpTextOf(file);
112
+ for (const verb of Object.keys(waived)) {
113
+ if (new RegExp(`^\\s+\`?${verb}\\b`, 'm').test(help))
114
+ stale.push(`${file}: ${verb}`);
115
+ }
116
+ printed = [];
117
+ }
118
+ expect(stale, 'these are in the help now, so the waiver claims a gap that is ' +
119
+ 'closed — delete the entry.').toEqual([]);
120
+ });
121
+ it('found command modules to check (or this test proves nothing)', () => {
122
+ expect(files.length).toBeGreaterThan(10);
123
+ });
124
+ for (const file of files) {
125
+ const src = readFileSync(path.join(COMMANDS_DIR, file), 'utf8');
126
+ const verbs = dispatchedVerbs(src);
127
+ if (!verbs.length)
128
+ continue;
129
+ it(`${file} lists all ${verbs.length} of its verbs`, async () => {
130
+ const help = await helpTextOf(file);
131
+ // A module whose bare `run()` does not print help has nothing to check
132
+ // here; its verbs are reachable from the parent listing.
133
+ if (!help.trim())
134
+ return;
135
+ const waived = HIDDEN_OK[file] ?? {};
136
+ const missing = verbs
137
+ .filter(v => !(v in waived))
138
+ // Listed means: named at the start of an indented line, the shape
139
+ // every command uses for its entries.
140
+ .filter(v => !new RegExp(`^\\s+\`?${v}\\b`, 'm').test(help));
141
+ expect(missing, `${file}: ${missing.join(', ')} dispatch but are not in the ` +
142
+ 'help this command prints. A verb nobody can find is a verb nobody has — ' +
143
+ 'add it to the listing, or to HIDDEN_OK with a reason.').toEqual([]);
144
+ });
145
+ }
146
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,121 @@
1
+ // `myapi org delete --yes` could not delete any org that owned anything.
2
+ //
3
+ // The API hard-deletes an org across 36 tables with no undo, so it refuses one
4
+ // that still owns resources unless the caller names it back: `?confirm=<id>`.
5
+ // That design is right. The SDK never sent the parameter, and the CLI's `--yes`
6
+ // only skipped its own local prompt — so the call came back 409 CONFIRM_REQUIRED
7
+ // with a message telling the user to "repeat the call with ?confirm=", which is
8
+ // HTTP-shaped advice a CLI user cannot act on.
9
+ //
10
+ // Creating an org provisions a funnel, so an org is non-empty from birth. This
11
+ // was not an edge case: the verb could not delete anything, while its own help
12
+ // said `--yes` was all a non-interactive run needed.
13
+ //
14
+ // Found by the probe, on its own cleanup step, on its first run — which is the
15
+ // argument for having a probe. It is invisible to the reverse request-field
16
+ // linter because `confirm` is a query parameter, and that linter reads request
17
+ // BODIES only; it says so in its own limits.
18
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
19
+ const ORG = '11111111-1111-4111-8111-111111111111';
20
+ const sdk = vi.hoisted(() => ({
21
+ hq: { getOrg: vi.fn(), deleteOrg: vi.fn() },
22
+ MyApiError: class MyApiError extends Error {
23
+ code = '';
24
+ status = 0;
25
+ },
26
+ }));
27
+ vi.mock('@myapihq/sdk', () => sdk);
28
+ vi.mock('../config.js', () => ({
29
+ requireConfig: () => ({ api_key: 'hq_live_test', default_org: ORG }),
30
+ loadConfig: () => ({ api_key: 'hq_live_test', default_org: ORG }),
31
+ CONFIG_DIR: '/tmp/nowhere',
32
+ }));
33
+ // Non-interactive, so `--yes` is the only way through and no prompt can hang.
34
+ vi.mock('../prompt.js', () => ({
35
+ confirm: vi.fn(async () => false),
36
+ isNonInteractive: () => true,
37
+ }));
38
+ function confirmRequired(owns) {
39
+ const e = new Error('CONFIRM_REQUIRED');
40
+ e.code = 'CONFIRM_REQUIRED';
41
+ e.status = 409;
42
+ e.body = { code: 'CONFIRM_REQUIRED', owns };
43
+ return e;
44
+ }
45
+ let printed;
46
+ let exitError;
47
+ beforeEach(async () => {
48
+ printed = [];
49
+ exitError = null;
50
+ // resetAllMocks, not clearAllMocks: `clear` resets call counts but leaves
51
+ // queued mockRejectedValueOnce values in place. The "refuses without --yes"
52
+ // case never consumes its queued rejection — it stops at the local guard —
53
+ // so with `clear` that rejection leaked into the next test and made it see a
54
+ // retry that its own setup never asked for.
55
+ vi.resetAllMocks();
56
+ sdk.hq.getOrg.mockResolvedValue({ id: ORG, name: 'Probe' });
57
+ const output = await import('../output.js');
58
+ vi.spyOn(output, 'info').mockImplementation(((m) => { printed.push(String(m)); }));
59
+ vi.spyOn(output, 'success').mockImplementation(((m) => { printed.push(String(m)); }));
60
+ vi.spyOn(output, 'error').mockImplementation(((m) => {
61
+ exitError = String(m);
62
+ throw new Error('__EXIT__');
63
+ }));
64
+ });
65
+ afterEach(() => vi.restoreAllMocks());
66
+ async function run(fn) {
67
+ try {
68
+ await fn();
69
+ }
70
+ catch (e) {
71
+ if (e?.message !== '__EXIT__')
72
+ throw e;
73
+ }
74
+ }
75
+ describe('org delete — the confirm gate', () => {
76
+ it('retries with confirm when the API asks, so --yes can actually delete', async () => {
77
+ sdk.hq.deleteOrg
78
+ .mockRejectedValueOnce(confirmRequired(['1 funnel', '1 CRM contact']))
79
+ .mockResolvedValueOnce(undefined);
80
+ const { del } = await import('./org.js');
81
+ await run(() => del(ORG, { yes: true }));
82
+ expect(sdk.hq.deleteOrg).toHaveBeenCalledTimes(2);
83
+ expect(sdk.hq.deleteOrg).toHaveBeenNthCalledWith(1, 'hq_live_test', ORG);
84
+ expect(sdk.hq.deleteOrg).toHaveBeenNthCalledWith(2, 'hq_live_test', ORG, { confirm: true });
85
+ expect(printed.join('\n')).toMatch(/Org .*deleted/);
86
+ });
87
+ it('shows what would be destroyed before destroying it', async () => {
88
+ // The refusal carries the inventory, and it is the ONLY place it can be
89
+ // seen — after the delete there is nothing left to look at.
90
+ sdk.hq.deleteOrg
91
+ .mockRejectedValueOnce(confirmRequired(['3 containers', '1 domain']))
92
+ .mockResolvedValueOnce(undefined);
93
+ const { del } = await import('./org.js');
94
+ await run(() => del(ORG, { yes: true }));
95
+ expect(printed.join('\n')).toContain('3 containers, 1 domain');
96
+ });
97
+ it('does not send confirm on the first attempt', async () => {
98
+ // An empty org deletes without ceremony; sending confirm unconditionally
99
+ // would turn the gate into decoration for every org.
100
+ sdk.hq.deleteOrg.mockResolvedValueOnce(undefined);
101
+ const { del } = await import('./org.js');
102
+ await run(() => del(ORG, { yes: true }));
103
+ expect(sdk.hq.deleteOrg).toHaveBeenCalledTimes(1);
104
+ expect(sdk.hq.deleteOrg).toHaveBeenCalledWith('hq_live_test', ORG);
105
+ });
106
+ it('refuses without --yes rather than confirming on the user behalf', async () => {
107
+ sdk.hq.deleteOrg.mockRejectedValueOnce(confirmRequired(['1 funnel']));
108
+ const { del } = await import('./org.js');
109
+ await run(() => del(ORG, {}));
110
+ // Non-interactive without --yes stops at the local guard, before any call.
111
+ expect(exitError).toMatch(/--yes/);
112
+ });
113
+ it('passes other errors through instead of retrying blind', async () => {
114
+ const boom = new Error('NOT_FOUND');
115
+ boom.code = 'NOT_FOUND';
116
+ sdk.hq.deleteOrg.mockRejectedValueOnce(boom);
117
+ const { del } = await import('./org.js');
118
+ await expect(del(ORG, { yes: true })).rejects.toThrow('NOT_FOUND');
119
+ expect(sdk.hq.deleteOrg).toHaveBeenCalledTimes(1);
120
+ });
121
+ });
@@ -135,7 +135,35 @@ export async function del(id, flags) {
135
135
  if (!ok)
136
136
  error('Aborted.');
137
137
  }
138
- await hq.deleteOrg(config.api_key, id);
138
+ // First without confirm. The API refuses a non-empty org with an inventory of
139
+ // what would be destroyed, and that inventory is the only place it can be
140
+ // seen — after the delete there is nothing left to look at. So the refusal is
141
+ // not an error to route around; it is the thing worth showing.
142
+ try {
143
+ await hq.deleteOrg(config.api_key, id);
144
+ }
145
+ catch (e) {
146
+ const err = e;
147
+ if (err?.code !== 'CONFIRM_REQUIRED')
148
+ throw e;
149
+ // `body` is the backend's error object verbatim; CONFIRM_REQUIRED carries
150
+ // the inventory in `owns`.
151
+ const owns = Array.isArray(err.body?.owns) ? err.body.owns : undefined;
152
+ info(owns?.length
153
+ ? `This org still owns ${owns.join(', ')}. Deleting it destroys all of that permanently.`
154
+ : 'This org still owns resources. Deleting it destroys all of them permanently.');
155
+ if (!flags.yes) {
156
+ const ok = await confirm(`› Delete ${label} and everything it owns? (y/N) `, false);
157
+ if (!ok)
158
+ error('Aborted. Nothing was deleted.');
159
+ }
160
+ // --yes already means "I am not being asked again", and the inventory has
161
+ // been printed either way. Previously this path did not exist at all: the
162
+ // API told the caller to "repeat the call with ?confirm=", advice a CLI
163
+ // user could not act on, so `org delete --yes` could not delete any org
164
+ // that owned anything — which is every org, since create provisions a funnel.
165
+ await hq.deleteOrg(config.api_key, id, { confirm: true });
166
+ }
139
167
  success(`Org ${label} deleted`);
140
168
  }
141
169
  // Update one or more fields of an org. At least one --flag must be supplied;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@myapihq/cli",
3
3
  "license": "Apache-2.0",
4
- "version": "2.25.0",
4
+ "version": "2.25.2",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -46,7 +46,7 @@
46
46
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
47
47
  },
48
48
  "dependencies": {
49
- "@myapihq/sdk": "^2.25.0"
49
+ "@myapihq/sdk": "^2.25.2"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",