@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.
- package/dist/commands/container.js +1 -0
- package/dist/commands/help-lists-every-verb.test.d.ts +1 -0
- package/dist/commands/help-lists-every-verb.test.js +146 -0
- package/dist/commands/org-delete-confirm.test.d.ts +1 -0
- package/dist/commands/org-delete-confirm.test.js +121 -0
- package/dist/commands/org.js +29 -1
- package/package.json +2 -2
|
@@ -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
|
+
});
|
package/dist/commands/org.js
CHANGED
|
@@ -135,7 +135,35 @@ export async function del(id, flags) {
|
|
|
135
135
|
if (!ok)
|
|
136
136
|
error('Aborted.');
|
|
137
137
|
}
|
|
138
|
-
|
|
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.
|
|
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.
|
|
49
|
+
"@myapihq/sdk": "^2.25.2"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^25.6.0",
|