@myapihq/cli 2.25.1 → 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.
|
@@ -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",
|