@myapihq/cli 2.30.1 → 2.31.1
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-deploy-failure.test.d.ts +1 -0
- package/dist/commands/container-deploy-failure.test.js +49 -0
- package/dist/commands/container.d.ts +1 -0
- package/dist/commands/container.js +34 -1
- package/dist/dispatch-in-process.test.d.ts +1 -0
- package/dist/dispatch-in-process.test.js +83 -0
- package/dist/output.d.ts +15 -0
- package/dist/output.js +48 -9
- package/dist/utils.d.ts +9 -2
- package/dist/utils.js +4 -1
- package/package.json +2 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A failed deploy has to say which half failed.
|
|
2
|
+
//
|
|
3
|
+
// One fixed sentence covered every failure: "Build/deploy failed — check
|
|
4
|
+
// `myapi container logs <id>`". ImmoPilot read thirty lines of healthy nginx
|
|
5
|
+
// after a ROLLOUT failure — the image had built, served two 200s to our own
|
|
6
|
+
// startup probes, then taken a SIGTERM — and a second deploy of the same code
|
|
7
|
+
// succeeded unchanged. The sentence sent them looking for a defect in code that
|
|
8
|
+
// was fine.
|
|
9
|
+
//
|
|
10
|
+
// I hit the same message the day before, on a container the probe had already
|
|
11
|
+
// deleted, so the logs it named did not exist either.
|
|
12
|
+
//
|
|
13
|
+
// The platform now returns error, error_stage and error_retryable, so the CLI
|
|
14
|
+
// can stop guessing.
|
|
15
|
+
import { describe, it, expect } from 'vitest';
|
|
16
|
+
import { describeDeployFailure } from './container.js';
|
|
17
|
+
const ID = 'c1';
|
|
18
|
+
const base = { id: ID };
|
|
19
|
+
describe('describeDeployFailure', () => {
|
|
20
|
+
it('leads with what the platform said, not our paraphrase', () => {
|
|
21
|
+
const out = describeDeployFailure({ ...base, error: 'Deployment failed after the image built.', error_stage: 'deploy' }, ID);
|
|
22
|
+
expect(out.split('\n')[0]).toBe('Deployment failed after the image built.');
|
|
23
|
+
});
|
|
24
|
+
it('on a rollout failure, says their application logs are the wrong place', () => {
|
|
25
|
+
// The whole point. "Check the logs" is what cost a customer a deploy.
|
|
26
|
+
const out = describeDeployFailure({ ...base, error_stage: 'deploy' }, ID);
|
|
27
|
+
expect(out).toMatch(/not the place to look/i);
|
|
28
|
+
expect(out).not.toContain(`myapi container logs ${ID}`);
|
|
29
|
+
});
|
|
30
|
+
it('on a build failure, sends them to the build logs', () => {
|
|
31
|
+
const out = describeDeployFailure({ ...base, error_stage: 'build' }, ID);
|
|
32
|
+
expect(out).toContain(`myapi container build-logs ${ID}`);
|
|
33
|
+
});
|
|
34
|
+
it('says so when the failure is ours and retrying is reasonable', () => {
|
|
35
|
+
const out = describeDeployFailure({ ...base, error_stage: 'deploy', error_retryable: true }, ID);
|
|
36
|
+
expect(out).toMatch(/retrying is reasonable/i);
|
|
37
|
+
});
|
|
38
|
+
it('does not invite a retry when the failure is theirs', () => {
|
|
39
|
+
// Telling someone to retry a broken build wastes four minutes per attempt.
|
|
40
|
+
const out = describeDeployFailure({ ...base, error_stage: 'build', error_retryable: false }, ID);
|
|
41
|
+
expect(out).not.toMatch(/retrying is reasonable/i);
|
|
42
|
+
});
|
|
43
|
+
it('offers both logs when the platform did not say which stage', () => {
|
|
44
|
+
// Older backends, and any future status that predates the field.
|
|
45
|
+
const out = describeDeployFailure(base, ID);
|
|
46
|
+
expect(out).toContain(`build-logs ${ID}`);
|
|
47
|
+
expect(out).toContain(`logs ${ID}`);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -13,6 +13,7 @@ export declare function create(nameArg: string | undefined, flags: Flags): Promi
|
|
|
13
13
|
export declare function list(flags: Flags): Promise<void>;
|
|
14
14
|
export declare function get(id: string, flags: Flags): Promise<void>;
|
|
15
15
|
export declare function healthCheck(id: string, flags: Flags): Promise<void>;
|
|
16
|
+
export declare function describeDeployFailure(c: sdkContainer.Container, id: string): string;
|
|
16
17
|
export declare function del(id: string, flags: Flags): Promise<void>;
|
|
17
18
|
export declare function _checkProbePath(hc: string): string;
|
|
18
19
|
export declare function _isTarball(p: string): boolean;
|
|
@@ -277,6 +277,39 @@ export async function healthCheck(id, flags) {
|
|
|
277
277
|
? ` The runtime now waits for ${value} to answer before sending traffic to a new revision.`
|
|
278
278
|
: ' No startup probe: a new revision takes traffic as soon as the runtime reports it started.');
|
|
279
279
|
}
|
|
280
|
+
// What to say when a deploy fails, and where to send them.
|
|
281
|
+
//
|
|
282
|
+
// One fixed sentence used to cover every failure: "Build/deploy failed — check
|
|
283
|
+
// `myapi container logs <id>`". A customer read thirty lines of healthy nginx
|
|
284
|
+
// after a ROLLOUT failure — the image had built, served two 200s to our own
|
|
285
|
+
// startup probes, then taken a SIGTERM — and a second deploy of the same code
|
|
286
|
+
// succeeded unchanged. The message sent them to look for a defect in code that
|
|
287
|
+
// was fine.
|
|
288
|
+
//
|
|
289
|
+
// The platform now says which stage failed and whether it is worth retrying,
|
|
290
|
+
// so the CLI can stop guessing. `build` is their image; `deploy` is our rollout
|
|
291
|
+
// of an image that built, which is the case where their application logs are
|
|
292
|
+
// the wrong place to look.
|
|
293
|
+
export function describeDeployFailure(c, id) {
|
|
294
|
+
const lines = [c.error?.trim() || 'The deploy failed.'];
|
|
295
|
+
if (c.error_stage === 'build') {
|
|
296
|
+
lines.push(` Your image did not build: myapi container build-logs ${id}`);
|
|
297
|
+
}
|
|
298
|
+
else if (c.error_stage === 'deploy') {
|
|
299
|
+
// Said explicitly, because "check the logs" is what cost the customer a
|
|
300
|
+
// deploy: the image built and ran, so the application logs look healthy.
|
|
301
|
+
lines.push(' The image built — this failed while we rolled it out, so your');
|
|
302
|
+
lines.push(' application logs will look healthy and are not the place to look.');
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
lines.push(` Build logs: myapi container build-logs ${id}`);
|
|
306
|
+
lines.push(` Runtime logs: myapi container logs ${id}`);
|
|
307
|
+
}
|
|
308
|
+
if (c.error_retryable) {
|
|
309
|
+
lines.push(' This one is ours and retrying is reasonable: myapi container deploy ' + id + ' --source <dir>');
|
|
310
|
+
}
|
|
311
|
+
return lines.join('\n');
|
|
312
|
+
}
|
|
280
313
|
export async function del(id, flags) {
|
|
281
314
|
const config = requireConfig();
|
|
282
315
|
const orgId = requireOrg(flags, config, 'myapi container delete <id> [--yes] [--org <id>]');
|
|
@@ -421,7 +454,7 @@ export async function deploy(id, image, flags) {
|
|
|
421
454
|
check: () => sdkContainer.getContainer(config.api_key, orgId, id),
|
|
422
455
|
isDone: (c) => c.status === 'active',
|
|
423
456
|
isFailed: (c) => c.status === 'build_error' || c.status === 'deploy_error',
|
|
424
|
-
failedMessage:
|
|
457
|
+
failedMessage: (c) => describeDeployFailure(c, id),
|
|
425
458
|
timeoutMs: 600_000,
|
|
426
459
|
intervalMs: 5_000,
|
|
427
460
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// A command can be dispatched and its output read, without a subprocess.
|
|
2
|
+
//
|
|
3
|
+
// Everything in output.ts wrote to console and error() called process.exit(1),
|
|
4
|
+
// so the only way to run a command and see what it produced was to spawn the
|
|
5
|
+
// binary. That cost ~1-2s of node startup per call, and it is why the probe
|
|
6
|
+
// shells out.
|
|
7
|
+
//
|
|
8
|
+
// It also decided the shape of anything embedding this CLI. An MCP server that
|
|
9
|
+
// wanted to expose `myapi …` had two options: spawn a process per tool call, or
|
|
10
|
+
// reimplement the dispatcher. The first is slow and hands a command string to a
|
|
11
|
+
// shell; the second is a third client that drifts from the other two.
|
|
12
|
+
//
|
|
13
|
+
// Output is now a sink. The default is byte-for-byte the old behaviour — the
|
|
14
|
+
// binary does not know this changed — and `withSink` lets an in-process caller
|
|
15
|
+
// collect instead, with a failure thrown rather than the host killed.
|
|
16
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
17
|
+
import { withSink, CommandFailed, success, error, info, banner, printJson } from './output.js';
|
|
18
|
+
function collector() {
|
|
19
|
+
const out = [];
|
|
20
|
+
const err = [];
|
|
21
|
+
const sink = {
|
|
22
|
+
out: (l) => { out.push(l); },
|
|
23
|
+
err: (l) => { err.push(l); },
|
|
24
|
+
fail: (m) => { throw new CommandFailed(m); },
|
|
25
|
+
};
|
|
26
|
+
return { sink, out, err };
|
|
27
|
+
}
|
|
28
|
+
describe('output can be collected instead of printed', () => {
|
|
29
|
+
it('captures stdout writers', async () => {
|
|
30
|
+
const c = collector();
|
|
31
|
+
await withSink(c.sink, () => { success('done'); info('detail'); });
|
|
32
|
+
expect(c.out.join('\n')).toContain('done');
|
|
33
|
+
expect(c.out.join('\n')).toContain('detail');
|
|
34
|
+
});
|
|
35
|
+
it('keeps stderr separate, so --json output stays parseable', async () => {
|
|
36
|
+
// The org banner goes to stderr precisely so `--json | jq` works. An
|
|
37
|
+
// embedder needs that split preserved, not flattened.
|
|
38
|
+
const c = collector();
|
|
39
|
+
await withSink(c.sink, () => { printJson({ a: 1 }); banner('myapi: org=x'); });
|
|
40
|
+
expect(JSON.parse(c.out.join('\n'))).toEqual({ a: 1 });
|
|
41
|
+
expect(c.err.join('\n')).toContain('org=x');
|
|
42
|
+
});
|
|
43
|
+
it('turns a fatal into a throw instead of killing the host', async () => {
|
|
44
|
+
// error() is typed `never` and used to reach process.exit. In an embedder
|
|
45
|
+
// that would take the server down on a bad argument.
|
|
46
|
+
const c = collector();
|
|
47
|
+
await expect(withSink(c.sink, () => { error('bad argument'); }))
|
|
48
|
+
.rejects.toBeInstanceOf(CommandFailed);
|
|
49
|
+
expect(c.err.join('\n')).toContain('bad argument');
|
|
50
|
+
});
|
|
51
|
+
it('restores the previous sink even when the command throws', async () => {
|
|
52
|
+
// Otherwise one failed embedded call leaves the binary mute for the rest of
|
|
53
|
+
// its life — every later write lands in a collector nobody is reading.
|
|
54
|
+
//
|
|
55
|
+
// The assertion has to be about output written OUTSIDE any withSink. A
|
|
56
|
+
// first version compared two nested collectors, which passes whether or not
|
|
57
|
+
// the sink is restored, and a mutation that deleted the restore did not
|
|
58
|
+
// fail it.
|
|
59
|
+
const c = collector();
|
|
60
|
+
await withSink(c.sink, () => { error('boom'); }).catch(() => { });
|
|
61
|
+
const spy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
62
|
+
info('outside any sink');
|
|
63
|
+
expect(spy, 'after a failed embedded call, output must go back to the process')
|
|
64
|
+
.toHaveBeenCalledWith('outside any sink');
|
|
65
|
+
expect(c.out, 'the abandoned collector must receive nothing more').toEqual([]);
|
|
66
|
+
spy.mockRestore();
|
|
67
|
+
});
|
|
68
|
+
it('dispatches a real command handler in-process', async () => {
|
|
69
|
+
// The point of all of it: run a handler, read what it produced, no process.
|
|
70
|
+
vi.doMock('@myapihq/sdk', () => ({
|
|
71
|
+
hq: { listOrgs: vi.fn(async () => [{ id: 'o1', name: 'Acme', created_at: '2026-01-01' }]) },
|
|
72
|
+
}));
|
|
73
|
+
vi.doMock('./config.js', () => ({
|
|
74
|
+
requireConfig: () => ({ api_key: 'k', account_id: 'a' }),
|
|
75
|
+
loadConfig: () => ({ api_key: 'k', account_id: 'a' }),
|
|
76
|
+
CONFIG_DIR: '/tmp/nowhere',
|
|
77
|
+
}));
|
|
78
|
+
const org = await import('./commands/org.js');
|
|
79
|
+
const c = collector();
|
|
80
|
+
await withSink(c.sink, () => org.list({ json: true }));
|
|
81
|
+
expect(c.out.join('\n')).toContain('Acme');
|
|
82
|
+
});
|
|
83
|
+
});
|
package/dist/output.d.ts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
export declare class CommandFailed extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export interface Sink {
|
|
5
|
+
out(line: string): void;
|
|
6
|
+
err(line: string): void;
|
|
7
|
+
/** Called by error(). The process sink exits; an embedded sink throws. */
|
|
8
|
+
fail(message: string): never;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Runs `fn` with output collected instead of printed, and a failure thrown
|
|
12
|
+
* instead of exiting. Restores the previous sink even if `fn` throws, so a
|
|
13
|
+
* caller cannot leave the binary mute.
|
|
14
|
+
*/
|
|
15
|
+
export declare function withSink<T>(custom: Sink, fn: () => Promise<T> | T): Promise<T>;
|
|
1
16
|
export type TableFlags = {
|
|
2
17
|
json?: boolean | string | number;
|
|
3
18
|
} & Record<string, unknown>;
|
package/dist/output.js
CHANGED
|
@@ -1,16 +1,55 @@
|
|
|
1
|
+
// Where output goes, and what "fail" does.
|
|
2
|
+
//
|
|
3
|
+
// Every writer here went straight to console and `error()` called
|
|
4
|
+
// process.exit(1). That is right for a CLI process and it is the single thing
|
|
5
|
+
// welding the command layer to one: a caller that wants to DISPATCH a command
|
|
6
|
+
// and read the result — an MCP server, a test that drives a handler rather than
|
|
7
|
+
// its parser — has to spawn a subprocess to do it.
|
|
8
|
+
//
|
|
9
|
+
// So the destination is a sink, swapped by the embedder. The default is exactly
|
|
10
|
+
// the old behaviour, so nothing changes for the binary; `withSink` gives an
|
|
11
|
+
// in-process caller somewhere to collect, and turns a fatal into a thrown
|
|
12
|
+
// CommandFailed instead of killing the host.
|
|
13
|
+
export class CommandFailed extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'CommandFailed';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const processSink = {
|
|
20
|
+
out: (l) => console.log(l),
|
|
21
|
+
err: (l) => process.stderr.write(l + '\n'),
|
|
22
|
+
fail: (m) => { process.exit(1); },
|
|
23
|
+
};
|
|
24
|
+
let sink = processSink;
|
|
25
|
+
/**
|
|
26
|
+
* Runs `fn` with output collected instead of printed, and a failure thrown
|
|
27
|
+
* instead of exiting. Restores the previous sink even if `fn` throws, so a
|
|
28
|
+
* caller cannot leave the binary mute.
|
|
29
|
+
*/
|
|
30
|
+
export async function withSink(custom, fn) {
|
|
31
|
+
const previous = sink;
|
|
32
|
+
sink = custom;
|
|
33
|
+
try {
|
|
34
|
+
return await fn();
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
sink = previous;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
1
40
|
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
2
41
|
export function success(message) {
|
|
3
|
-
|
|
42
|
+
sink.out(useColor ? `\x1b[32m✓\x1b[0m ${message}` : `✓ ${message}`);
|
|
4
43
|
}
|
|
5
44
|
export function error(message) {
|
|
6
|
-
|
|
7
|
-
|
|
45
|
+
sink.err(useColor ? `\x1b[31m✗\x1b[0m ${message}` : `✗ ${message}`);
|
|
46
|
+
return sink.fail(message);
|
|
8
47
|
}
|
|
9
48
|
export function info(message) {
|
|
10
|
-
|
|
49
|
+
sink.out(message);
|
|
11
50
|
}
|
|
12
51
|
export function banner(message) {
|
|
13
|
-
|
|
52
|
+
sink.err(message);
|
|
14
53
|
}
|
|
15
54
|
let resolvedContext;
|
|
16
55
|
export function setResolvedContextSource(fn) {
|
|
@@ -20,7 +59,7 @@ export function printJson(data) {
|
|
|
20
59
|
const ctx = resolvedContext?.();
|
|
21
60
|
const stampable = ctx && data !== null && typeof data === 'object' && !Array.isArray(data);
|
|
22
61
|
const payload = stampable ? { ...data, _resolved: ctx } : data;
|
|
23
|
-
|
|
62
|
+
sink.out(JSON.stringify(payload, null, 2));
|
|
24
63
|
}
|
|
25
64
|
// Spinner / line-clear primitives. Used by polling helpers (utils.pollJob)
|
|
26
65
|
// and any handler that wants its own progress UI.
|
|
@@ -55,16 +94,16 @@ export function printTable(rows, opts = {}) {
|
|
|
55
94
|
return;
|
|
56
95
|
}
|
|
57
96
|
if (rows.length === 0) {
|
|
58
|
-
|
|
97
|
+
sink.out(opts.empty ?? 'No data found.');
|
|
59
98
|
return;
|
|
60
99
|
}
|
|
61
100
|
const columns = Object.keys(rows[0]);
|
|
62
101
|
const colWidths = columns.map(col => Math.max(col.length, ...rows.map(row => String(row[col] ?? '').length)));
|
|
63
102
|
const printRow = (row) => {
|
|
64
|
-
|
|
103
|
+
sink.out(row.map((cell, i) => cell.padEnd(colWidths[i] + 2)).join(''));
|
|
65
104
|
};
|
|
66
105
|
printRow(columns);
|
|
67
|
-
|
|
106
|
+
sink.out(colWidths.map(w => '-'.repeat(w + 2)).join(''));
|
|
68
107
|
for (const row of rows) {
|
|
69
108
|
printRow(columns.map(col => String(row[col] ?? '')));
|
|
70
109
|
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -20,8 +20,15 @@ export interface PollOptions<T> {
|
|
|
20
20
|
intervalMs?: number;
|
|
21
21
|
/** Message shown via error() on timeout. Defaults to a generic phrase. */
|
|
22
22
|
timeoutMessage?: string;
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Message shown via error() when isFailed returns true.
|
|
25
|
+
*
|
|
26
|
+
* A function when the useful message depends on WHY it failed. `container
|
|
27
|
+
* deploy` printed one fixed sentence for every failure, so a rollout problem
|
|
28
|
+
* and a broken build read identically — a customer spent a deploy reading
|
|
29
|
+
* healthy nginx logs because the sentence sent them there.
|
|
30
|
+
*/
|
|
31
|
+
failedMessage?: string | ((state: T) => string);
|
|
25
32
|
}
|
|
26
33
|
/**
|
|
27
34
|
* Generic spinner+poll helper. Used wherever the CLI kicks off a long-running
|
package/dist/utils.js
CHANGED
|
@@ -46,7 +46,10 @@ export async function pollJob(opts) {
|
|
|
46
46
|
}
|
|
47
47
|
if (opts.isFailed && opts.isFailed(state)) {
|
|
48
48
|
clearLine();
|
|
49
|
-
|
|
49
|
+
const msg = typeof opts.failedMessage === 'function'
|
|
50
|
+
? opts.failedMessage(state)
|
|
51
|
+
: opts.failedMessage;
|
|
52
|
+
error(msg ?? `${opts.label} failed`);
|
|
50
53
|
}
|
|
51
54
|
spinnerWrite(`\r${opts.label} ${spinnerFrame(i++)}`);
|
|
52
55
|
await sleep(intervalMs);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myapihq/cli",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.31.1",
|
|
5
5
|
"description": "MyAPI command-line interface",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@myapihq/sdk": "^2.
|
|
50
|
+
"@myapihq/sdk": "^2.31.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^25.6.0",
|