@myapihq/cli 2.9.0 → 2.11.0
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 +35 -14
- package/dist/commands/flag-reachability.test.js +14 -4
- package/dist/commands/import-key.test.d.ts +1 -0
- package/dist/commands/import-key.test.js +69 -0
- package/dist/commands/org.js +5 -1
- package/dist/commands/setup.d.ts +5 -1
- package/dist/commands/setup.js +67 -6
- package/dist/commands/storage.js +151 -14
- package/dist/config.d.ts +2 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +36 -3
- package/dist/helpers.d.ts +20 -1
- package/dist/helpers.js +79 -1
- package/dist/index.js +6 -1
- package/dist/org-not-found.test.d.ts +1 -0
- package/dist/org-not-found.test.js +42 -0
- package/dist/org-notice.test.d.ts +1 -0
- package/dist/org-notice.test.js +37 -0
- package/dist/output.d.ts +6 -0
- package/dist/output.js +8 -1
- package/dist/skills/my-api-hq/SKILL.md +34 -14
- package/dist/skills/my-audience-api/SKILL.md +16 -1
- package/dist/skills/my-auth-api/SKILL.md +17 -2
- package/dist/skills/my-company-api/SKILL.md +16 -1
- package/dist/skills/my-container-api/SKILL.md +45 -24
- package/dist/skills/my-crm-api/SKILL.md +16 -1
- package/dist/skills/my-database-api/SKILL.md +13 -17
- package/dist/skills/my-domain-api/SKILL.md +16 -1
- package/dist/skills/my-email-api/SKILL.md +12 -16
- package/dist/skills/my-email-verify-api/SKILL.md +16 -1
- package/dist/skills/my-feedback-api/README.md +33 -0
- package/dist/skills/my-feedback-api/SKILL.md +118 -0
- package/dist/skills/my-feedback-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-function-api/README.md +34 -0
- package/dist/skills/my-function-api/SKILL.md +16 -1
- package/dist/skills/my-funnel-api/SKILL.md +16 -1
- package/dist/skills/my-git-api/SKILL.md +17 -1
- package/dist/skills/my-image-api/SKILL.md +17 -1
- package/dist/skills/my-llm-api/SKILL.md +20 -6
- package/dist/skills/my-payments-api/README.md +33 -0
- package/dist/skills/my-payments-api/SKILL.md +16 -1
- package/dist/skills/my-people-api/SKILL.md +16 -1
- package/dist/skills/my-pixel-api/SKILL.md +26 -3
- package/dist/skills/my-queue-api/SKILL.md +35 -2
- package/dist/skills/my-storage-api/SKILL.md +37 -29
- package/dist/skills/my-task-api/SKILL.md +35 -2
- package/dist/skills/my-url-to/SKILL.md +16 -1
- package/dist/skills/my-webhook-api/SKILL.md +23 -3
- package/dist/skills/my-workflow-api/SKILL.md +32 -6
- package/package.json +2 -2
|
@@ -239,17 +239,6 @@ export async function deploy(id, image, flags) {
|
|
|
239
239
|
// write their own; a guard that silently passes makes them stop. Restore
|
|
240
240
|
// these the moment the upstream fix lands — see
|
|
241
241
|
// docs/cross-repo-prompts/backend-consolidated-2026-07-28.md.
|
|
242
|
-
// --smoke is still not honoured; --no-promote was fixed upstream on
|
|
243
|
-
// 2026-07-28 and works on both deploy paths. Refusing what does not work
|
|
244
|
-
// beats accepting it, and un-refusing what does beats obstructing.
|
|
245
|
-
if (typeof flags.smoke === 'string') {
|
|
246
|
-
error('--smoke is not honoured yet, so this CLI refuses it rather than letting you believe a deploy was checked.\n\n' +
|
|
247
|
-
'Use --no-promote instead, which now works:\n' +
|
|
248
|
-
' 1. myapi container deploy <id> <image> --no-promote\n' +
|
|
249
|
-
' 2. curl the revision URL it prints, for a string only a real build emits\n' +
|
|
250
|
-
' 3. myapi container promote <id> <revision>\n\n' +
|
|
251
|
-
'That is the same verify-then-promote, done by you rather than the platform.');
|
|
252
|
-
}
|
|
253
242
|
const source = typeof flags.source === 'string' ? flags.source : undefined;
|
|
254
243
|
// --image is an alias for the positional image ref.
|
|
255
244
|
if (!image && typeof flags.image === 'string')
|
|
@@ -262,6 +251,28 @@ export async function deploy(id, image, flags) {
|
|
|
262
251
|
const deployOpts = {};
|
|
263
252
|
if (flags['no-promote'] === true)
|
|
264
253
|
deployOpts.promote = false;
|
|
254
|
+
if (typeof flags.smoke === 'string')
|
|
255
|
+
deployOpts.smoke = _parseSmoke(flags.smoke);
|
|
256
|
+
// On a container's FIRST deploy there is no earlier revision to hold
|
|
257
|
+
// traffic, so the platform runs the assertion against the LIVE url — a
|
|
258
|
+
// failing build is already serving when you are told it failed. Say so
|
|
259
|
+
// before the deploy rather than after, because the remedy differs: on any
|
|
260
|
+
// later deploy a failure is contained, and here it is not.
|
|
261
|
+
if (deployOpts.smoke || deployOpts.promote === false) {
|
|
262
|
+
try {
|
|
263
|
+
const existing = await sdkContainer.listRevisions(config.api_key, orgId, id);
|
|
264
|
+
if (existing.length === 0) {
|
|
265
|
+
info('Note: this container has no earlier revision, so there is nothing to hold');
|
|
266
|
+
info('traffic while the new one is checked. A failing build WILL be serving.');
|
|
267
|
+
info('Deploy to a non-production container first if that matters.');
|
|
268
|
+
info('');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch { /* advisory only — never block a deploy on it */ }
|
|
272
|
+
}
|
|
273
|
+
if (deployOpts.promote === false && deployOpts.smoke) {
|
|
274
|
+
error('--smoke already withholds traffic until the assertion passes, then promotes.\nUse one or the other: --smoke to verify-and-promote, --no-promote to hold the revision back.');
|
|
275
|
+
}
|
|
265
276
|
// ── Source-build path (async) ───────────────────────────────────────────
|
|
266
277
|
if (source) {
|
|
267
278
|
let tarball;
|
|
@@ -593,13 +604,21 @@ Options:
|
|
|
593
604
|
On a container's FIRST deploy there is nothing already
|
|
594
605
|
serving, so traffic is NOT withheld and the output says so.
|
|
595
606
|
|
|
596
|
-
--smoke
|
|
597
|
-
|
|
607
|
+
--smoke '<assertion>'
|
|
608
|
+
Deploy, assert against the new revision, and promote it ONLY
|
|
609
|
+
if the assertion holds. A failure leaves the previous
|
|
610
|
+
revision serving and returns SMOKE_FAILED with a revision URL
|
|
611
|
+
to inspect. Works on both deploy paths.
|
|
612
|
+
|
|
613
|
+
Grammar: [GET|HEAD] [/path] [status N] [contains TEXT]
|
|
614
|
+
Assert on CONTENT — "returns 200" is true of a placeholder
|
|
615
|
+
page too, which is how one reached production and stayed.
|
|
598
616
|
|
|
599
617
|
Examples:
|
|
600
618
|
myapi container deploy <id> registry.example.com/my-app:v2
|
|
601
619
|
myapi container deploy <id> --source ./my-app
|
|
602
|
-
myapi container deploy <id> <image> --no-promote
|
|
620
|
+
myapi container deploy <id> <image> --no-promote
|
|
621
|
+
myapi container deploy <id> --source ./app --smoke 'GET / contains assets/'`,
|
|
603
622
|
'rollback': `myapi container rollback <id> [--org <id>] [--json]
|
|
604
623
|
|
|
605
624
|
Move traffic back to the previous ready revision. Seconds, no rebuild.
|
|
@@ -647,8 +666,10 @@ dependencies and long execution.
|
|
|
647
666
|
Subcommands:
|
|
648
667
|
build-logs <id> Why the last --source build failed (--tail N; default 100)
|
|
649
668
|
create Register a container and get its scoped API key (returned once)
|
|
669
|
+
(--health-check /livez for an HTTP startup probe)
|
|
650
670
|
delete <id> Soft-delete and revoke its scoped API key
|
|
651
671
|
deploy <id> <image> Ship a pre-built image (or --source <dir|tar> to build) and go live
|
|
672
|
+
(--smoke to verify before promoting; --no-promote to hold it back)
|
|
652
673
|
domain <id> <domain> Bind a custom domain (--remove to unbind)
|
|
653
674
|
get <id> Inspect a container
|
|
654
675
|
list List containers in your org
|
|
@@ -122,11 +122,21 @@ describe('container deploy — --no-promote must REACH both paths', () => {
|
|
|
122
122
|
await run(() => deploy('c1', 'img:v1', { org: ORG }));
|
|
123
123
|
expect(sdk.container.deployContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', 'img:v1', {});
|
|
124
124
|
});
|
|
125
|
-
// --smoke
|
|
126
|
-
|
|
125
|
+
// --smoke was refused while the platform ignored it, and is honoured now.
|
|
126
|
+
// The assertion has flipped twice; what has not changed is that it must
|
|
127
|
+
// REACH the call, on whichever path.
|
|
128
|
+
it('sends the parsed assertion on the image path', async () => {
|
|
129
|
+
sdk.container.deployContainer.mockResolvedValue({ ...DEPLOYED, promoted: true });
|
|
130
|
+
const { deploy } = await import('./container.js');
|
|
131
|
+
await run(() => deploy('c1', 'img:v1', { smoke: 'GET / contains assets/', org: ORG }));
|
|
132
|
+
expect(sdk.container.deployContainer).toHaveBeenCalledWith('hq_live_test', ORG, 'c1', 'img:v1', { smoke: { method: 'GET', path: '/', contains: 'assets/' } });
|
|
133
|
+
});
|
|
134
|
+
// Combining them is a contradiction: --smoke promotes on success, and
|
|
135
|
+
// --no-promote withholds regardless. Refused rather than silently picking.
|
|
136
|
+
it('refuses --smoke together with --no-promote', async () => {
|
|
127
137
|
const { deploy } = await import('./container.js');
|
|
128
|
-
await run(() => deploy('c1', 'img:v1', { smoke: 'GET / contains x', org: ORG }));
|
|
129
|
-
expect(exitError).toMatch(
|
|
138
|
+
await run(() => deploy('c1', 'img:v1', { smoke: 'GET / contains x', 'no-promote': true, org: ORG }));
|
|
139
|
+
expect(exitError).toMatch(/one or the other/);
|
|
130
140
|
expect(sdk.container.deployContainer).not.toHaveBeenCalled();
|
|
131
141
|
});
|
|
132
142
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { resolveImportKey } from './setup.js';
|
|
4
|
+
// Where `myapi account import-key` is allowed to take the key from.
|
|
5
|
+
//
|
|
6
|
+
// A secret in argv is readable by any user on the machine — /proc/<pid>/cmdline
|
|
7
|
+
// on Linux, `ps` anywhere — and the shell records it in history. A 2026-07-31
|
|
8
|
+
// security audit confirmed it with an OS-level probe against the syntax the CLI
|
|
9
|
+
// itself documented, so the docs were instructing people to leak their key.
|
|
10
|
+
//
|
|
11
|
+
// The argument still works: breaking every CI pipeline that already calls it
|
|
12
|
+
// would be worse than the exposure. But it is no longer the documented path, and
|
|
13
|
+
// it announces itself.
|
|
14
|
+
const ORIGINAL_STDIN = process.stdin;
|
|
15
|
+
function fakeStdin(piped) {
|
|
16
|
+
const stream = new EventEmitter();
|
|
17
|
+
stream.setEncoding = () => { };
|
|
18
|
+
stream.isTTY = piped === null;
|
|
19
|
+
if (piped !== null) {
|
|
20
|
+
setImmediate(() => { stream.emit('data', piped); stream.emit('end'); });
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(process, 'stdin', { value: stream, configurable: true });
|
|
23
|
+
}
|
|
24
|
+
describe('import-key key sources', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
delete process.env.MYAPI_API_KEY;
|
|
27
|
+
delete process.env.MYAPI_KEY;
|
|
28
|
+
fakeStdin(null); // a TTY with nothing piped, unless a test says otherwise
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
Object.defineProperty(process, 'stdin', { value: ORIGINAL_STDIN, configurable: true });
|
|
32
|
+
});
|
|
33
|
+
test('stdin is accepted, and is not flagged as argv exposure', async () => {
|
|
34
|
+
fakeStdin('hq_live_frompipe\n');
|
|
35
|
+
const got = await resolveImportKey('');
|
|
36
|
+
expect(got.key).toBe('hq_live_frompipe');
|
|
37
|
+
expect(got.fromArgv).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
test('MYAPI_API_KEY is accepted', async () => {
|
|
40
|
+
process.env.MYAPI_API_KEY = 'hq_live_fromenv';
|
|
41
|
+
const got = await resolveImportKey('');
|
|
42
|
+
expect(got.key).toBe('hq_live_fromenv');
|
|
43
|
+
expect(got.fromArgv).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
test('MYAPI_KEY is accepted as the older name', async () => {
|
|
46
|
+
process.env.MYAPI_KEY = 'hq_live_fromoldenv';
|
|
47
|
+
expect((await resolveImportKey('')).key).toBe('hq_live_fromoldenv');
|
|
48
|
+
});
|
|
49
|
+
test('an argument still works, and is reported as argv so it can be warned about', async () => {
|
|
50
|
+
// The warning is the whole point: silently accepting it would leave the
|
|
51
|
+
// caller believing the documented-unsafe form is fine.
|
|
52
|
+
const got = await resolveImportKey('hq_live_fromargv');
|
|
53
|
+
expect(got.key).toBe('hq_live_fromargv');
|
|
54
|
+
expect(got.fromArgv).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
test('nothing anywhere yields no key rather than hanging on a TTY', async () => {
|
|
57
|
+
// Reading stdin unconditionally would make the CLI look frozen when a human
|
|
58
|
+
// runs it with no input, which is how a safe default becomes one people
|
|
59
|
+
// route around.
|
|
60
|
+
const got = await resolveImportKey('');
|
|
61
|
+
expect(got.key).toBe('');
|
|
62
|
+
});
|
|
63
|
+
test('surrounding whitespace is stripped', async () => {
|
|
64
|
+
// `echo "$KEY" |` appends a newline, and a key with a trailing \n fails
|
|
65
|
+
// validation against the API for a reason nobody can see.
|
|
66
|
+
fakeStdin(' hq_live_padded \n');
|
|
67
|
+
expect((await resolveImportKey('')).key).toBe('hq_live_padded');
|
|
68
|
+
});
|
|
69
|
+
});
|
package/dist/commands/org.js
CHANGED
|
@@ -3,7 +3,7 @@ import { requireConfig, saveConfig } from '../config.js';
|
|
|
3
3
|
import { success, error, printTable, printJson, info } from '../output.js';
|
|
4
4
|
import { confirm, isNonInteractive } from '../prompt.js';
|
|
5
5
|
import { formatDate, pollJob } from '../utils.js';
|
|
6
|
-
import { requireOrg, requireArg } from '../helpers.js';
|
|
6
|
+
import { requireOrg, requireArg, rememberOrgNames } from '../helpers.js';
|
|
7
7
|
export const EXPOSES = [
|
|
8
8
|
'POST /hq/orgs',
|
|
9
9
|
'GET /hq/orgs',
|
|
@@ -74,6 +74,10 @@ export async function create(restArgs, flags) {
|
|
|
74
74
|
export async function list(flags) {
|
|
75
75
|
const config = requireConfig();
|
|
76
76
|
const orgs = await hq.listOrgs(config.api_key);
|
|
77
|
+
// The only place the full id → name mapping is in hand. Caching it here is
|
|
78
|
+
// what lets every later "org changed" notice name the org instead of showing
|
|
79
|
+
// a UUID nobody can verify at a glance.
|
|
80
|
+
rememberOrgNames(config, orgs);
|
|
77
81
|
if (flags.json) {
|
|
78
82
|
if (flags.verbose) {
|
|
79
83
|
printJson(orgs);
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -3,5 +3,9 @@ import type { Exposes } from '../exposes.js';
|
|
|
3
3
|
export declare const EXPOSES: Exposes;
|
|
4
4
|
export declare function _isLocallyModified(src: string): boolean;
|
|
5
5
|
export declare function installSkills(): Promise<void>;
|
|
6
|
-
export declare function
|
|
6
|
+
export declare function resolveImportKey(positional: string): Promise<{
|
|
7
|
+
key: string;
|
|
8
|
+
fromArgv: boolean;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function importKey(positional: string, flags: Flags): Promise<void>;
|
|
7
11
|
export declare function setup(flags?: Flags): Promise<void>;
|
package/dist/commands/setup.js
CHANGED
|
@@ -221,11 +221,15 @@ async function anonymousFlow() {
|
|
|
221
221
|
// ---------------------------------------------------------------------------
|
|
222
222
|
// Main setup command
|
|
223
223
|
// ---------------------------------------------------------------------------
|
|
224
|
-
const IMPORT_KEY_HELP = `Usage: myapi account import-key
|
|
224
|
+
const IMPORT_KEY_HELP = `Usage: myapi account import-key [--install-skills] [--no-skills]
|
|
225
225
|
|
|
226
226
|
Imports an existing API key non-interactively. Use this in CI, Docker, or any
|
|
227
227
|
environment where the interactive "myapi account setup" flow is not practical.
|
|
228
228
|
|
|
229
|
+
Give the key on stdin, or in MYAPI_API_KEY. Do NOT pass it as an argument:
|
|
230
|
+
arguments are visible to every user on the machine through the process list
|
|
231
|
+
(ps, /proc/<pid>/cmdline) and are written to your shell history.
|
|
232
|
+
|
|
229
233
|
The key is validated against the API before being saved. Your default org and
|
|
230
234
|
funnel are auto-detected from the account and written to the local config.
|
|
231
235
|
|
|
@@ -234,8 +238,9 @@ Flags:
|
|
|
234
238
|
--no-skills Skip skills installation even if previously installed
|
|
235
239
|
|
|
236
240
|
Examples:
|
|
237
|
-
myapi account import-key
|
|
238
|
-
myapi account import-key
|
|
241
|
+
echo "$MY_KEY" | myapi account import-key
|
|
242
|
+
MYAPI_API_KEY="$MY_KEY" myapi account import-key --install-skills
|
|
243
|
+
myapi account import-key < key.txt`;
|
|
239
244
|
const SETUP_HELP = `Usage: myapi account setup [--anonymous] [--yes] [--install-skills|--no-skills]
|
|
240
245
|
|
|
241
246
|
Configures your account and stores your default org and funnel so you don't
|
|
@@ -246,12 +251,56 @@ Flags:
|
|
|
246
251
|
--yes Skip confirmation prompts
|
|
247
252
|
--install-skills Auto-install skills pack
|
|
248
253
|
--no-skills Skip skills installation`;
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
254
|
+
// Where the key may come from, safest first.
|
|
255
|
+
//
|
|
256
|
+
// A secret passed as a command-line argument is readable by any user on the
|
|
257
|
+
// machine for as long as the process lives — /proc/<pid>/cmdline on Linux, `ps`
|
|
258
|
+
// anywhere — and the shell writes it to history on the way in. A security audit
|
|
259
|
+
// on 2026-07-31 confirmed this with an OS-level probe against the documented
|
|
260
|
+
// syntax, so it was not theoretical: the docs told people to do the unsafe
|
|
261
|
+
// thing.
|
|
262
|
+
//
|
|
263
|
+
// stdin and the environment both avoid the process list. The argument still
|
|
264
|
+
// works, because breaking every CI pipeline that calls it would be a worse
|
|
265
|
+
// outcome than the exposure, but it warns and the help no longer shows it.
|
|
266
|
+
export async function resolveImportKey(positional) {
|
|
267
|
+
if (positional)
|
|
268
|
+
return { key: positional, fromArgv: true };
|
|
269
|
+
const fromEnv = (process.env.MYAPI_API_KEY || process.env.MYAPI_KEY || '').trim();
|
|
270
|
+
if (fromEnv)
|
|
271
|
+
return { key: fromEnv, fromArgv: false };
|
|
272
|
+
// Only read stdin when something is actually piped in. On a TTY this would
|
|
273
|
+
// hang looking like the CLI had frozen.
|
|
274
|
+
if (!process.stdin.isTTY) {
|
|
275
|
+
const data = await new Promise((resolve, reject) => {
|
|
276
|
+
let buf = '';
|
|
277
|
+
process.stdin.setEncoding('utf-8');
|
|
278
|
+
process.stdin.on('data', c => { buf += c; });
|
|
279
|
+
process.stdin.on('end', () => resolve(buf));
|
|
280
|
+
process.stdin.on('error', reject);
|
|
281
|
+
});
|
|
282
|
+
const key = data.trim();
|
|
283
|
+
if (key)
|
|
284
|
+
return { key, fromArgv: false };
|
|
285
|
+
}
|
|
286
|
+
return { key: '', fromArgv: false };
|
|
287
|
+
}
|
|
288
|
+
// myapi account import-key — non-interactively import a raw API key.
|
|
289
|
+
export async function importKey(positional, flags) {
|
|
290
|
+
if (flags.help) {
|
|
252
291
|
info(IMPORT_KEY_HELP);
|
|
253
292
|
return;
|
|
254
293
|
}
|
|
294
|
+
const { key: apiKey, fromArgv } = await resolveImportKey(positional);
|
|
295
|
+
if (!apiKey) {
|
|
296
|
+
info(IMPORT_KEY_HELP);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (fromArgv) {
|
|
300
|
+
info('Warning: passing the key as an argument exposes it in the process list ' +
|
|
301
|
+
'(ps, /proc) and your shell history. Pipe it instead: ' +
|
|
302
|
+
'echo "$MY_KEY" | myapi account import-key');
|
|
303
|
+
}
|
|
255
304
|
let accountId = '';
|
|
256
305
|
let email;
|
|
257
306
|
try {
|
|
@@ -383,4 +432,16 @@ export async function setup(flags = {}) {
|
|
|
383
432
|
else {
|
|
384
433
|
success(`› Setup complete. Org ${defaultOrg} ready · $5.00 free credits added.`);
|
|
385
434
|
}
|
|
435
|
+
// The key just saved is account-wide, and it has to be: an org-locked key
|
|
436
|
+
// cannot create orgs or manage billing, so locking the operator's own
|
|
437
|
+
// credential would break the next thing they do. The scoping belongs on the
|
|
438
|
+
// credential handed to a WORKLOAD — an agent loop or CI job that should only
|
|
439
|
+
// ever touch one org. That is the only defence that survives a stale default,
|
|
440
|
+
// because it moves the org from an ambient setting to a property of the key.
|
|
441
|
+
if (defaultOrg) {
|
|
442
|
+
info('');
|
|
443
|
+
info('› Handing this to an agent or CI? Give it an org-locked key, so a stale');
|
|
444
|
+
info(' default can never reach another org:');
|
|
445
|
+
info(` myapi keys create --name agent --org ${defaultOrg} --grant '*'`);
|
|
446
|
+
}
|
|
386
447
|
}
|
package/dist/commands/storage.js
CHANGED
|
@@ -14,11 +14,16 @@ export const EXPOSES = [
|
|
|
14
14
|
'POST /storage/orgs/{org_id}/assets/upload',
|
|
15
15
|
'GET /storage/orgs/{org_id}/assets',
|
|
16
16
|
'DELETE /storage/orgs/{org_id}/assets/{asset_id}',
|
|
17
|
+
'PATCH /storage/orgs/{org_id}/assets/{asset_id}',
|
|
18
|
+
'POST /storage/orgs/{org_id}/assets/{asset_id}/signed-url',
|
|
19
|
+
'POST /storage/orgs/{org_id}/assets/{asset_id}/revoke-links',
|
|
17
20
|
];
|
|
18
21
|
export const SCHEMA = {
|
|
19
22
|
org: 'string',
|
|
20
23
|
name: 'string',
|
|
21
24
|
'content-type': 'string',
|
|
25
|
+
private: 'boolean',
|
|
26
|
+
ttl: 'string',
|
|
22
27
|
};
|
|
23
28
|
// Mirrors UploadContentType in @myapihq/sdk + backend allowlist.
|
|
24
29
|
const EXT_TO_CT = {
|
|
@@ -36,7 +41,10 @@ function summarizeAsset(a) {
|
|
|
36
41
|
return {
|
|
37
42
|
asset_id: a.asset_id,
|
|
38
43
|
name: a.name || '(unnamed)',
|
|
39
|
-
|
|
44
|
+
// A private asset has no plain URL — the backend deliberately returns none,
|
|
45
|
+
// because that URL does not serve the file and would read as the answer.
|
|
46
|
+
url: a.url || (a.visibility === 'private' ? '— (private: myapi storage sign <id>)' : ''),
|
|
47
|
+
visibility: a.visibility ?? 'public',
|
|
40
48
|
created_at: a.created_at ? formatDate(a.created_at) : '',
|
|
41
49
|
};
|
|
42
50
|
}
|
|
@@ -91,8 +99,103 @@ async function upload(filePath, flags) {
|
|
|
91
99
|
error(`Could not read ${filePath}: ${e?.message ?? e}`);
|
|
92
100
|
}
|
|
93
101
|
const name = flags.name || basename(filePath);
|
|
94
|
-
const
|
|
102
|
+
const visibility = flags.private ? 'private' : undefined;
|
|
103
|
+
const res = await sdkStorage.uploadAsset(config.api_key, orgId, data, contentType, name, visibility);
|
|
104
|
+
// A private upload has no URL to print, and saying nothing here would look
|
|
105
|
+
// like a partial failure. Name the next step instead.
|
|
106
|
+
if (visibility) {
|
|
107
|
+
success(`Asset uploaded (private)! ID: ${res.asset_id}`);
|
|
108
|
+
info('');
|
|
109
|
+
info('Private assets have no public URL. Get a time-limited link with:');
|
|
110
|
+
info(` myapi storage sign ${res.asset_id} --ttl 900`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
95
113
|
success(`Asset uploaded! ID: ${res.asset_id}\nHosted URL: ${res.url}`);
|
|
114
|
+
info('');
|
|
115
|
+
info('This URL is public forever and needs no credential. Holding personal');
|
|
116
|
+
info(`data? Close it now: myapi storage visibility ${res.asset_id} private`);
|
|
117
|
+
}
|
|
118
|
+
// ── Private assets ───────────────────────────────────────────────────────────
|
|
119
|
+
async function visibility(id, value, flags) {
|
|
120
|
+
const usage = 'myapi storage visibility <asset_id> <public|private> [--org <id>]';
|
|
121
|
+
const config = requireConfig();
|
|
122
|
+
const orgId = requireOrg(flags, config, usage);
|
|
123
|
+
requireArg(id, 'asset_id', usage);
|
|
124
|
+
if (value !== 'public' && value !== 'private') {
|
|
125
|
+
error(`Missing or invalid visibility "${value ?? ''}".\nUsage: ${usage}\n\n` +
|
|
126
|
+
`→ private: the plain URL stops serving at once; only signed links work.\n` +
|
|
127
|
+
` public: anyone holding the URL can read it, forever.`);
|
|
128
|
+
}
|
|
129
|
+
const res = await sdkStorage.setAssetVisibility(config.api_key, orgId, id, value);
|
|
130
|
+
if (flags.json) {
|
|
131
|
+
printJson(res);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (value === 'private') {
|
|
135
|
+
success(`Asset ${id} is now private — the plain URL no longer serves it.`);
|
|
136
|
+
info('');
|
|
137
|
+
info('Links you handed out before this still work until they expire.');
|
|
138
|
+
info(`Kill them too: myapi storage revoke-links ${id}`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
success(`Asset ${id} is now public — anyone with the URL can read it, forever.`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function sign(id, flags) {
|
|
145
|
+
const usage = 'myapi storage sign <asset_id> [--ttl <seconds>] [--org <id>]';
|
|
146
|
+
const config = requireConfig();
|
|
147
|
+
const orgId = requireOrg(flags, config, usage);
|
|
148
|
+
requireArg(id, 'asset_id', usage);
|
|
149
|
+
let ttl;
|
|
150
|
+
if (flags.ttl !== undefined) {
|
|
151
|
+
ttl = Number(flags.ttl);
|
|
152
|
+
// Bounds are the backend's (default 900, max 86400). Checking here turns
|
|
153
|
+
// a round-trip and an EXPIRES_TOO_LONG into an instant, specific message.
|
|
154
|
+
if (!Number.isInteger(ttl) || ttl <= 0 || ttl > 86400) {
|
|
155
|
+
error(`--ttl must be a whole number of seconds between 1 and 86400 (24h). Got "${flags.ttl}".`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const res = await sdkStorage.createSignedUrl(config.api_key, orgId, id, ttl);
|
|
159
|
+
if (flags.json) {
|
|
160
|
+
printJson(res);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const url = sdkStorage.signedUrlOf(res);
|
|
164
|
+
if (!url) {
|
|
165
|
+
// Better to hand back what arrived than to claim nothing came: the schema
|
|
166
|
+
// does not describe this response, so an unexpected field name is possible.
|
|
167
|
+
info('Signed link created, but no recognised URL field was in the response:');
|
|
168
|
+
printJson(res);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// Report what the backend returned, not what we asked for — they can differ,
|
|
172
|
+
// and stating the request as if it were the outcome is how a link gets
|
|
173
|
+
// handed out with the wrong lifetime believed of it.
|
|
174
|
+
const lifetime = typeof res.expires_in === 'number' ? `${res.expires_in}s`
|
|
175
|
+
: typeof res.expires_at === 'string' ? `until ${res.expires_at}`
|
|
176
|
+
: ttl !== undefined ? `${ttl}s (requested; backend did not confirm)` : 'the backend default';
|
|
177
|
+
success(`Signed link (expires ${lifetime.startsWith('until') ? '' : 'in '}${lifetime}):`);
|
|
178
|
+
info(url);
|
|
179
|
+
}
|
|
180
|
+
async function revokeLinks(id, flags) {
|
|
181
|
+
const usage = 'myapi storage revoke-links <asset_id> [--org <id>]';
|
|
182
|
+
const config = requireConfig();
|
|
183
|
+
const orgId = requireOrg(flags, config, usage);
|
|
184
|
+
requireArg(id, 'asset_id', usage);
|
|
185
|
+
// Revoking on a public asset succeeds and changes nothing — the plain URL
|
|
186
|
+
// still serves it. Reporting that as done would be the wrong answer during
|
|
187
|
+
// an exposure, which is exactly when this gets run. Only refuse on a
|
|
188
|
+
// definite 'public'; an absent field means proceed rather than guess.
|
|
189
|
+
const all = await sdkStorage.listAssets(config.api_key, orgId);
|
|
190
|
+
const asset = all.find(a => a.asset_id === id);
|
|
191
|
+
if (asset?.visibility === 'public') {
|
|
192
|
+
error(`Asset ${id} is public, so revoking signed links changes nothing —\n` +
|
|
193
|
+
`anyone holding the plain URL can still read it.\n\n` +
|
|
194
|
+
`→ Make it private first: myapi storage visibility ${id} private`);
|
|
195
|
+
}
|
|
196
|
+
await sdkStorage.revokeAssetLinks(config.api_key, orgId, id);
|
|
197
|
+
success(`Every signed link for ${id} is dead, including unexpired ones.`);
|
|
198
|
+
info(`Issue a fresh one for anyone who should still have access: myapi storage sign ${id}`);
|
|
96
199
|
}
|
|
97
200
|
// `get`: round-trip metadata for one asset. Backend has no single-asset
|
|
98
201
|
// GET endpoint today, so we list + filter locally. Switch to a direct
|
|
@@ -109,11 +212,16 @@ async function get(id, flags) {
|
|
|
109
212
|
printJson(asset);
|
|
110
213
|
return;
|
|
111
214
|
}
|
|
112
|
-
|
|
113
|
-
info(`
|
|
114
|
-
info(`
|
|
215
|
+
const vis = asset.visibility ?? 'public';
|
|
216
|
+
info(`ID: ${asset.asset_id}`);
|
|
217
|
+
info(`Name: ${asset.name || '(unnamed)'}`);
|
|
218
|
+
info(`Visibility: ${vis}`);
|
|
219
|
+
if (asset.url)
|
|
220
|
+
info(`URL: ${asset.url}`);
|
|
221
|
+
else if (vis === 'private')
|
|
222
|
+
info(`URL: — private; sign one: myapi storage sign ${asset.asset_id}`);
|
|
115
223
|
if (asset.created_at)
|
|
116
|
-
info(`Created:
|
|
224
|
+
info(`Created: ${formatDate(asset.created_at)}`);
|
|
117
225
|
}
|
|
118
226
|
// `get-url`: cheapest possible — print the public URL pattern. No API
|
|
119
227
|
// call. Curl-friendly. Doesn't even verify the asset exists; that's a
|
|
@@ -136,10 +244,29 @@ const SUBCOMMAND_USAGE = {
|
|
|
136
244
|
|
|
137
245
|
Server fetches the URL and stores the file. Useful for migrating assets that
|
|
138
246
|
already live on a public URL (e.g. importing brand assets from another host).`,
|
|
139
|
-
'upload': `myapi storage upload <file> [--name <name>] [--org <id>]
|
|
247
|
+
'upload': `myapi storage upload <file> [--private] [--name <name>] [--org <id>]
|
|
140
248
|
|
|
141
249
|
Direct multipart upload of a local file. Supported: ${Object.keys(EXT_TO_CT).join(', ')}.
|
|
142
|
-
The display name defaults to the file's basename — override with --name
|
|
250
|
+
The display name defaults to the file's basename — override with --name.
|
|
251
|
+
|
|
252
|
+
--private stores it with no public URL; read it back with a signed link
|
|
253
|
+
(myapi storage sign <id>). Use it for anything holding personal data — the
|
|
254
|
+
default is a permanent, unauthenticated URL.`,
|
|
255
|
+
'visibility': `myapi storage visibility <asset_id> <public|private> [--org <id>]
|
|
256
|
+
|
|
257
|
+
Flip an asset between public and private. Going private takes effect at once:
|
|
258
|
+
the plain URL stops serving and only signed links work. This is the
|
|
259
|
+
close-an-exposure path — follow it with revoke-links to kill links already
|
|
260
|
+
handed out.`,
|
|
261
|
+
'sign': `myapi storage sign <asset_id> [--ttl <seconds>] [--org <id>]
|
|
262
|
+
|
|
263
|
+
Make a time-limited link to a private asset. The link carries its own
|
|
264
|
+
authorisation, so it can go to someone with no MyAPI account.
|
|
265
|
+
--ttl defaults to 900 (15 min), maximum 86400 (24h). Signing is billable.`,
|
|
266
|
+
'revoke-links': `myapi storage revoke-links <asset_id> [--org <id>]
|
|
267
|
+
|
|
268
|
+
Kill every signed link for an asset, including ones that had not expired yet.
|
|
269
|
+
Refused on a public asset, where it would change nothing.`,
|
|
143
270
|
'get': `myapi storage get <asset_id> [--org <id>] [--json]
|
|
144
271
|
|
|
145
272
|
Round-trips the API to fetch the asset's metadata (name, URL, created_at).`,
|
|
@@ -154,12 +281,19 @@ export async function run(subcommand, args, flags) {
|
|
|
154
281
|
info(`Usage: myapi storage <subcommand>
|
|
155
282
|
|
|
156
283
|
Subcommands:
|
|
157
|
-
delete <asset_id>
|
|
158
|
-
get <asset_id>
|
|
159
|
-
get-url <asset_id>
|
|
160
|
-
ingest <url>
|
|
161
|
-
list
|
|
162
|
-
|
|
284
|
+
delete <asset_id> Permanently delete a stored asset
|
|
285
|
+
get <asset_id> Fetch asset metadata (name, URL, visibility)
|
|
286
|
+
get-url <asset_id> Print the public CDN URL (no API call)
|
|
287
|
+
ingest <url> Server pulls a public URL into storage
|
|
288
|
+
list List all your stored assets
|
|
289
|
+
revoke-links <asset_id> Kill every signed link for a private asset
|
|
290
|
+
sign <asset_id> Time-limited link to a private asset
|
|
291
|
+
upload <file> Direct upload of a local file (--private supported)
|
|
292
|
+
visibility <asset_id> Flip an asset public|private
|
|
293
|
+
|
|
294
|
+
Assets are PUBLIC by default — a permanent URL that needs no credential.
|
|
295
|
+
Storing personal data? Upload with --private, or close an existing exposure
|
|
296
|
+
with: myapi storage visibility <asset_id> private
|
|
163
297
|
|
|
164
298
|
All commands accept --org <id> (or set default: myapi config set-org <id>).`);
|
|
165
299
|
return;
|
|
@@ -179,6 +313,9 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
|
|
|
179
313
|
case 'get': return get(args[0], flags);
|
|
180
314
|
case 'get-url': return getUrl(args[0], flags);
|
|
181
315
|
case 'delete': return del(args[0], flags);
|
|
316
|
+
case 'visibility': return visibility(args[0], args[1], flags);
|
|
317
|
+
case 'sign': return sign(args[0], flags);
|
|
318
|
+
case 'revoke-links': return revokeLinks(args[0], flags);
|
|
182
319
|
default: error(`Unknown subcommand: ${subcommand}. Run "myapi storage --help" for available subcommands.`);
|
|
183
320
|
}
|
|
184
321
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface AccountEntry {
|
|
|
9
9
|
registrant?: sdkDomain.Registrant;
|
|
10
10
|
is_anonymous?: boolean;
|
|
11
11
|
skills_installed?: boolean;
|
|
12
|
+
last_org_used?: string;
|
|
13
|
+
org_names?: Record<string, string>;
|
|
12
14
|
}
|
|
13
15
|
export interface Config extends AccountEntry {
|
|
14
16
|
autocomplete_setup?: boolean;
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import { MyApiError } from '@myapihq/sdk';
|
|
2
2
|
export declare const ERROR_MESSAGES: Record<string, string>;
|
|
3
3
|
export declare function friendlyError(err: MyApiError): string;
|
|
4
|
+
export declare function withOrgContext(message: string, err: Pick<MyApiError, 'status' | 'code'>, org?: {
|
|
5
|
+
org_id: string;
|
|
6
|
+
org_name?: string;
|
|
7
|
+
} | undefined): string;
|
package/dist/errors.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { currentOrg } from './helpers.js';
|
|
1
2
|
// Per-code friendly messages for MyApiError.code. Kept in its own module (not
|
|
2
3
|
// index.ts) so it can be unit-tested without importing the CLI entrypoint,
|
|
3
4
|
// which runs main() on import.
|
|
@@ -45,6 +46,12 @@ export const ERROR_MESSAGES = {
|
|
|
45
46
|
// on a backend deploy we do not control the timing of, and a CLI that only
|
|
46
47
|
// knows the new names would print a bare code for anyone on the old build.
|
|
47
48
|
// The old five can go once that deploy is everywhere.
|
|
49
|
+
// Deploy-guard validation, added when the platform started refusing bad
|
|
50
|
+
// assertions BEFORE building — a source build costs minutes you pay for, so
|
|
51
|
+
// catching a typo up front matters more than it looks.
|
|
52
|
+
INVALID_SMOKE: 'The --smoke assertion is not usable. It must assert something: add `contains <text>` or `status <code>`. A check that only requests a path passes on any response, including the placeholder page the flag exists to catch.',
|
|
53
|
+
SMOKE_FAILED: 'The smoke assertion did not pass, so the new revision was NOT promoted. On a container with an earlier revision that one is still serving; on a FIRST deploy there was nothing to hold traffic and the failing build is live. Inspect the revision URL in the error.',
|
|
54
|
+
INVALID_HEALTH_CHECK: 'That health-check path is not usable. /healthz is refused specifically: the runtime answers it before your container does, so a probe on it passes even when your app is down. Use /livez or any other path.',
|
|
48
55
|
DNS_UNAVAILABLE: 'The DNS provider is unavailable. This is platform-side and usually transient — retry shortly rather than changing your request.',
|
|
49
56
|
DNS_ZONE_NOT_FOUND: 'No DNS zone for this domain. Register or import it first: myapi domain register <domain>.',
|
|
50
57
|
DNS_ZONE_UNAVAILABLE: 'The DNS zone exists but could not be reached. Platform-side and transient.',
|
|
@@ -89,7 +96,33 @@ export function friendlyError(err) {
|
|
|
89
96
|
const base = ERROR_MESSAGES[err.code] || err.code;
|
|
90
97
|
// Generic fallback: append the backend's `message` when it adds info beyond
|
|
91
98
|
// the friendly mapping. Future per-service detail fields can be added here.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
const message = err.detail && err.detail !== base ? `${base} — ${err.detail}` : base;
|
|
100
|
+
return withOrgContext(message, err);
|
|
101
|
+
}
|
|
102
|
+
// A resource that exists in another org answers exactly like a typo'd id, and
|
|
103
|
+
// "not found" reads as the id being wrong. So the reader checks the id — which
|
|
104
|
+
// is correct — and never questions the org, which is the actual fault. Naming
|
|
105
|
+
// the org that was searched turns the most common org-drift symptom into its
|
|
106
|
+
// own diagnosis.
|
|
107
|
+
//
|
|
108
|
+
// Only on not-found, and only when an org was actually resolved: appending
|
|
109
|
+
// this to every error would be the always-on banner in a different costume.
|
|
110
|
+
const NOT_FOUND_CODE = /NOT_FOUND|not_found/;
|
|
111
|
+
export function withOrgContext(message, err, org = currentOrg()) {
|
|
112
|
+
if (err.status !== 404 && !NOT_FOUND_CODE.test(err.code))
|
|
113
|
+
return message;
|
|
114
|
+
// ORG_NOT_FOUND is excluded deliberately: there the org id IS the thing that
|
|
115
|
+
// could not be found, so "looked in org <that same id>" restates the failure
|
|
116
|
+
// as if it were a clue.
|
|
117
|
+
if (/ORG_NOT_FOUND|org_not_found/.test(err.code))
|
|
118
|
+
return message;
|
|
119
|
+
if (!org)
|
|
120
|
+
return message;
|
|
121
|
+
const label = org.org_name ? `${org.org_name} (${shortId(org.org_id)})` : org.org_id;
|
|
122
|
+
if (message.includes(org.org_id) || (org.org_name && message.includes(org.org_name)))
|
|
123
|
+
return message;
|
|
124
|
+
return `${message}\n→ Looked in org ${label}. If it lives elsewhere, \`myapi org list\` shows the rest — pass --org <id>.`;
|
|
125
|
+
}
|
|
126
|
+
function shortId(id) {
|
|
127
|
+
return id.length > 12 ? `${id.slice(0, 8)}…` : id;
|
|
95
128
|
}
|
package/dist/helpers.d.ts
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Config } from './config.js';
|
|
2
2
|
export type Flags = Record<string, string | boolean | number>;
|
|
3
3
|
export declare function requireOrg(flags: Flags, config: Config, usage: string): string;
|
|
4
|
+
/** The notice for a move, or undefined when there is nothing to say.
|
|
5
|
+
*
|
|
6
|
+
* Pure and exported because both failure directions are silent in production:
|
|
7
|
+
* return a string always and it becomes noise everyone filters out; return
|
|
8
|
+
* undefined always and the wrong-org write it exists to catch goes through
|
|
9
|
+
* unannounced. Neither shows up as a broken build, so it is pinned by a test.
|
|
10
|
+
*/
|
|
11
|
+
export declare function orgChangeNotice(previous: string | undefined, next: string, names?: Record<string, string>): string | undefined;
|
|
12
|
+
/** The org this invocation resolved to, for `--json` consumers. */
|
|
13
|
+
export declare function currentOrg(): {
|
|
14
|
+
org_id: string;
|
|
15
|
+
org_name?: string;
|
|
16
|
+
} | undefined;
|
|
17
|
+
/** Cache id → name so later output can name an org instead of printing a UUID.
|
|
18
|
+
* Called by the commands that already hold the mapping (`org list`, `status`). */
|
|
19
|
+
export declare function rememberOrgNames(config: Config, orgs: Array<{
|
|
20
|
+
id?: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
}>): void;
|
|
4
23
|
export declare function requireDomain(arg: string | undefined, flags: Flags, config: Config, usage: string): string;
|
|
5
24
|
export declare function requireArg(value: string | undefined, name: string, usage: string): string;
|
|
6
25
|
export declare function confirmDestructive(flags: Flags, description: string, usage: string): Promise<void>;
|