@myapihq/cli 1.3.13 → 2.1.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/account.d.ts +7 -1
- package/dist/commands/account.js +356 -18
- package/dist/commands/authproduct.d.ts +7 -0
- package/dist/commands/authproduct.js +286 -0
- package/dist/commands/billing-auto-recharge.test.d.ts +1 -0
- package/dist/commands/billing-auto-recharge.test.js +103 -0
- package/dist/commands/billing.d.ts +3 -0
- package/dist/commands/billing.js +116 -1
- package/dist/commands/config.js +1 -1
- package/dist/commands/container.d.ts +1 -0
- package/dist/commands/container.js +115 -11
- package/dist/commands/doctor-setup.test.js +86 -1
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.js +70 -15
- package/dist/commands/domain.js +2 -2
- package/dist/commands/email/index.js +0 -13
- package/dist/commands/fn.d.ts +1 -0
- package/dist/commands/fn.js +64 -12
- package/dist/commands/keys.js +3 -3
- package/dist/commands/queue.d.ts +1 -0
- package/dist/commands/queue.js +10 -0
- package/dist/commands/setup.js +8 -8
- package/dist/commands/status.d.ts +1 -1
- package/dist/commands/status.js +23 -27
- package/dist/commands/storage.js +3 -1
- package/dist/commands/workflow.js +21 -6
- package/dist/completion.js +5 -4
- package/dist/config.js +1 -1
- package/dist/exposes.test.js +1 -2
- package/dist/index.js +57 -33
- package/dist/registrant.js +5 -5
- package/dist/sdk-queue.test.js +3 -2
- package/dist/skills/my-api-hq/README.md +1 -0
- package/dist/skills/my-api-hq/SKILL.md +27 -14
- package/dist/skills/my-auth-api/README.md +33 -0
- package/dist/skills/my-auth-api/SKILL.md +112 -0
- package/dist/skills/my-auth-api/claude/.claude-plugin/plugin.json +6 -0
- package/dist/skills/my-auth-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-crm-api/SKILL.md +1 -1
- package/dist/skills/my-domain-api/SKILL.md +5 -5
- package/dist/skills/my-email-api/README.md +2 -3
- package/dist/skills/my-email-api/SKILL.md +7 -18
- package/dist/skills/my-email-api/claude/.claude-plugin/plugin.json +1 -1
- package/dist/skills/my-email-verify-api/SKILL.md +1 -1
- package/dist/skills/my-email-verify-api/claude/.claude-plugin/plugin.json +1 -1
- package/dist/skills/my-funnel-api/SKILL.md +1 -1
- package/dist/skills/my-git-api/README.md +43 -0
- package/dist/skills/my-git-api/SKILL.md +115 -0
- package/dist/skills/my-git-api/claude/.claude-plugin/plugin.json +6 -0
- package/dist/skills/my-git-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-llm-api/README.md +1 -1
- package/dist/skills/my-llm-api/claude/.claude-plugin/plugin.json +1 -1
- package/package.json +2 -2
- package/dist/commands/auth.d.ts +0 -11
- package/dist/commands/auth.js +0 -345
- package/dist/commands/email/campaign.d.ts +0 -4
- package/dist/commands/email/campaign.js +0 -200
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
1
3
|
import { container as sdkContainer } from '@myapihq/sdk';
|
|
2
4
|
import { requireConfig } from '../config.js';
|
|
3
5
|
import { success, error, printTable, info, printJson, banner } from '../output.js';
|
|
4
|
-
import { formatDate } from '../utils.js';
|
|
6
|
+
import { formatDate, pollJob } from '../utils.js';
|
|
5
7
|
import { requireOrg } from '../helpers.js';
|
|
6
8
|
export const EXPOSES = [
|
|
7
9
|
'POST /container/orgs/{org_id}/containers',
|
|
@@ -25,6 +27,8 @@ export const SCHEMA = {
|
|
|
25
27
|
env: 'string',
|
|
26
28
|
tail: 'number',
|
|
27
29
|
remove: 'boolean',
|
|
30
|
+
source: 'string',
|
|
31
|
+
image: 'string',
|
|
28
32
|
};
|
|
29
33
|
const CONTAINER_TYPES = ['service', 'worker', 'job'];
|
|
30
34
|
// Mirrors validateName in myapi-hq/internal/routes/container/crud.go —
|
|
@@ -149,6 +153,8 @@ export async function get(id, flags) {
|
|
|
149
153
|
if (c.port)
|
|
150
154
|
info(`Port: ${c.port}`);
|
|
151
155
|
info(`URL: ${c.url || '(not deployed)'}`);
|
|
156
|
+
if (c.egress)
|
|
157
|
+
info(`Egress: ${c.egress}`);
|
|
152
158
|
if (c.custom_domain)
|
|
153
159
|
info(`Custom domain: ${c.custom_domain}`);
|
|
154
160
|
info(`Created: ${c.created_at}`);
|
|
@@ -162,15 +168,106 @@ export async function del(id, flags) {
|
|
|
162
168
|
await sdkContainer.deleteContainer(config.api_key, orgId, id);
|
|
163
169
|
success(`Deleted container ${id}`);
|
|
164
170
|
}
|
|
165
|
-
//
|
|
166
|
-
//
|
|
171
|
+
// _isTarball returns true if the path looks like an already-built tar archive
|
|
172
|
+
// (so we upload it as-is instead of tarring a directory).
|
|
173
|
+
export function _isTarball(p) {
|
|
174
|
+
return /\.(tar|tar\.gz|tgz)$/i.test(p);
|
|
175
|
+
}
|
|
176
|
+
// tarDirectory builds a gzipped tarball of a build-context directory using the
|
|
177
|
+
// system `tar`. Returns the archive bytes. We tar the directory contents (-C
|
|
178
|
+
// <dir> .) so the Dockerfile sits at the root of the build context.
|
|
179
|
+
async function tarDirectory(dir) {
|
|
180
|
+
const res = spawnSync('tar', ['-czf', '-', '-C', dir, '.'], {
|
|
181
|
+
maxBuffer: 200 * 1024 * 1024,
|
|
182
|
+
});
|
|
183
|
+
if (res.error) {
|
|
184
|
+
if (res.error.code === 'ENOENT') {
|
|
185
|
+
error('`tar` was not found on PATH. Install tar, or pass a pre-built .tar.gz to --source.');
|
|
186
|
+
}
|
|
187
|
+
error(`Failed to create build-context archive: ${res.error.message}`);
|
|
188
|
+
}
|
|
189
|
+
if (res.status !== 0) {
|
|
190
|
+
error(`tar exited ${res.status}: ${res.stderr?.toString().trim() || 'unknown error'}`);
|
|
191
|
+
}
|
|
192
|
+
return res.stdout;
|
|
193
|
+
}
|
|
194
|
+
// deploy ships either a pre-built image (--image / positional ref, synchronous)
|
|
195
|
+
// or a source build context (--source <dir-or-tar>, asynchronous Cloud Build).
|
|
196
|
+
// The scoped API key is rotated on every (image) deploy and shown once.
|
|
167
197
|
export async function deploy(id, image, flags) {
|
|
168
198
|
const config = requireConfig();
|
|
169
|
-
const orgId = requireOrg(flags, config, 'myapi container deploy <id> <image-ref> [--org <id>]');
|
|
199
|
+
const orgId = requireOrg(flags, config, 'myapi container deploy <id> <image-ref> | --source <dir|tar> [--org <id>]');
|
|
170
200
|
if (!id)
|
|
171
|
-
error('Missing id.\nUsage: myapi container deploy <id> <image-ref>');
|
|
201
|
+
error('Missing id.\nUsage: myapi container deploy <id> <image-ref> (or --source <dir|tar>)');
|
|
202
|
+
const source = typeof flags.source === 'string' ? flags.source : undefined;
|
|
203
|
+
// --image is an alias for the positional image ref.
|
|
204
|
+
if (!image && typeof flags.image === 'string')
|
|
205
|
+
image = flags.image;
|
|
206
|
+
if (source && image) {
|
|
207
|
+
error('Pass either an image ref or --source, not both.');
|
|
208
|
+
}
|
|
209
|
+
// ── Source-build path (async) ───────────────────────────────────────────
|
|
210
|
+
if (source) {
|
|
211
|
+
let tarball;
|
|
212
|
+
let filename = 'source.tar.gz';
|
|
213
|
+
let info_st;
|
|
214
|
+
try {
|
|
215
|
+
info_st = await stat(source);
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
if (e?.code === 'ENOENT')
|
|
219
|
+
error(`Source not found: ${source}`);
|
|
220
|
+
error(`Could not read ${source}: ${e?.message ?? e}`);
|
|
221
|
+
}
|
|
222
|
+
if (info_st.isDirectory()) {
|
|
223
|
+
tarball = await tarDirectory(source);
|
|
224
|
+
}
|
|
225
|
+
else if (_isTarball(source)) {
|
|
226
|
+
tarball = await readFile(source);
|
|
227
|
+
filename = source.split('/').pop() || filename;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
error(`--source must be a directory or a .tar/.tar.gz/.tgz archive — got ${source}`);
|
|
231
|
+
}
|
|
232
|
+
const start = await sdkContainer.deployContainerSource(config.api_key, orgId, id, tarball, filename);
|
|
233
|
+
if (flags.json && start.status !== 'building') {
|
|
234
|
+
printJson(start);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Async: poll the container until it leaves the building state.
|
|
238
|
+
if (start.status === 'building') {
|
|
239
|
+
info(`Build started (revision ${start.revision_id}). Building from source via Cloud Build…`);
|
|
240
|
+
const final = await pollJob({
|
|
241
|
+
label: 'Building & deploying',
|
|
242
|
+
check: () => sdkContainer.getContainer(config.api_key, orgId, id),
|
|
243
|
+
isDone: (c) => c.status === 'active',
|
|
244
|
+
isFailed: (c) => c.status === 'build_error' || c.status === 'deploy_error',
|
|
245
|
+
failedMessage: 'Build/deploy failed — check `myapi container logs ' + id + '`.',
|
|
246
|
+
timeoutMs: 600_000,
|
|
247
|
+
intervalMs: 5_000,
|
|
248
|
+
});
|
|
249
|
+
if (flags.json) {
|
|
250
|
+
printJson(final);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
success(`Deployed container ${id} from source`);
|
|
254
|
+
info(`Status: ${final.status}`);
|
|
255
|
+
info(`URL: ${final.url || '(none — job container)'}`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Backend deployed synchronously after all (unexpected for source) —
|
|
259
|
+
// surface whatever it returned.
|
|
260
|
+
if (flags.json) {
|
|
261
|
+
printJson(start);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
success(`Deployed container ${id} (revision ${start.revision_id})`);
|
|
265
|
+
info(`Status: ${start.status}`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// ── Pre-built image path (sync) ─────────────────────────────────────────
|
|
172
269
|
if (!image)
|
|
173
|
-
error('Missing image ref.\nUsage: myapi container deploy <id> <image-ref>\n\n→ <image-ref> is a pre-built container image (e.g. a registry path).');
|
|
270
|
+
error('Missing image ref.\nUsage: myapi container deploy <id> <image-ref>\n or: myapi container deploy <id> --source <dir|tar>\n\n→ <image-ref> is a pre-built container image (e.g. a registry path).');
|
|
174
271
|
const result = await sdkContainer.deployContainer(config.api_key, orgId, id, image);
|
|
175
272
|
if (flags.json) {
|
|
176
273
|
printJson(result);
|
|
@@ -247,12 +344,19 @@ Examples:
|
|
|
247
344
|
myapi container create --name nightly --type job --cron "0 3 * * *"
|
|
248
345
|
myapi container create --name queue-worker --type worker --memory 1Gi`,
|
|
249
346
|
'deploy': `myapi container deploy <id> <image-ref> [--org <id>] [--json]
|
|
347
|
+
myapi container deploy <id> --source <dir|tar.gz> [--org <id>] [--json]
|
|
250
348
|
|
|
251
|
-
|
|
252
|
-
|
|
349
|
+
Two ways to deploy:
|
|
350
|
+
image Ship a pre-built image (positional ref or --image). Synchronous;
|
|
351
|
+
the scoped API key is rotated and printed once.
|
|
352
|
+
source Upload a build context with --source <dir> (tarred locally) or a
|
|
353
|
+
pre-built --source <archive.tar.gz>. MyAPI builds it via Cloud
|
|
354
|
+
Build, then deploys. Asynchronous — the CLI polls until it's live.
|
|
253
355
|
|
|
254
|
-
|
|
255
|
-
myapi container deploy <id> registry.example.com/my-app:v2
|
|
356
|
+
Examples:
|
|
357
|
+
myapi container deploy <id> registry.example.com/my-app:v2
|
|
358
|
+
myapi container deploy <id> --source ./my-app
|
|
359
|
+
myapi container deploy <id> --source ./context.tar.gz`,
|
|
256
360
|
'list': 'myapi container list [--org <id>] [--json]',
|
|
257
361
|
'get': 'myapi container get <id> [--org <id>] [--json]',
|
|
258
362
|
'logs': `myapi container logs <id> [--tail <n>] [--org <id>] [--json]
|
|
@@ -281,7 +385,7 @@ dependencies and long execution.
|
|
|
281
385
|
Subcommands:
|
|
282
386
|
create Register a container and get its scoped API key (returned once)
|
|
283
387
|
delete <id> Soft-delete and revoke its scoped API key
|
|
284
|
-
deploy <id> <image> Ship a pre-built image and go live
|
|
388
|
+
deploy <id> <image> Ship a pre-built image (or --source <dir|tar> to build) and go live
|
|
285
389
|
domain <id> <domain> Bind a custom domain (--remove to unbind)
|
|
286
390
|
get <id> Inspect a container
|
|
287
391
|
list List containers in your org
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// `domains` or `emails` sections come back empty (zero-state), the
|
|
4
4
|
// `setup` augmentation should escalate to actionable warns.
|
|
5
5
|
import { describe, it, expect } from 'vitest';
|
|
6
|
-
import { _setupSection } from './doctor.js';
|
|
6
|
+
import { _setupSection, sanitizeHint, _tallyTotals } from './doctor.js';
|
|
7
7
|
function mkReport(sections) {
|
|
8
8
|
return {
|
|
9
9
|
org_id: 'org-test',
|
|
@@ -20,6 +20,12 @@ function populated(name) {
|
|
|
20
20
|
id: 'x', severity: 'ok', scope: name, message: 'something',
|
|
21
21
|
}] };
|
|
22
22
|
}
|
|
23
|
+
// A section the backend reports as having resources, but with no issues
|
|
24
|
+
// emitted (all healthy, no per-resource ok rows). The issue-count proxy
|
|
25
|
+
// would mis-read this as zero-state; `resource_count` must win.
|
|
26
|
+
function healthyQuiet(name, count) {
|
|
27
|
+
return { name, summary: 'all checks passed', issues: [], resource_count: count };
|
|
28
|
+
}
|
|
23
29
|
describe('_setupSection', () => {
|
|
24
30
|
it('returns null when both domains + emails sections are populated', () => {
|
|
25
31
|
expect(_setupSection(mkReport([populated('domains'), populated('emails')]))).toBeNull();
|
|
@@ -71,4 +77,83 @@ describe('_setupSection', () => {
|
|
|
71
77
|
const s = _setupSection(mkReport([populated('domains'), populated('emails')]), { mailingAddress: undefined });
|
|
72
78
|
expect(s).toBeNull();
|
|
73
79
|
});
|
|
80
|
+
// resource_count is authoritative — it must override the fragile
|
|
81
|
+
// issues.length proxy in both directions.
|
|
82
|
+
it('does NOT flag a healthy-but-quiet domains section (resource_count > 0, no issues)', () => {
|
|
83
|
+
// The bug class this prevents: backend stops emitting per-domain `ok`
|
|
84
|
+
// rows → issues:[] → proxy would falsely say "No domain registered".
|
|
85
|
+
const s = _setupSection(mkReport([healthyQuiet('domains', 2), healthyQuiet('emails', 1)]));
|
|
86
|
+
expect(s).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
it('flags zero-state via resource_count even if an issue happens to be present', () => {
|
|
89
|
+
const domains = {
|
|
90
|
+
name: 'domains', summary: 'x', resource_count: 0,
|
|
91
|
+
issues: [{ id: 'y', severity: 'ok', scope: 'domains', message: 'stale ok row' }],
|
|
92
|
+
};
|
|
93
|
+
const s = _setupSection(mkReport([domains, populated('emails')]));
|
|
94
|
+
expect(s).not.toBeNull();
|
|
95
|
+
expect(s.issues).toHaveLength(1);
|
|
96
|
+
expect(s.issues[0].scope).toBe('setup/domain');
|
|
97
|
+
});
|
|
98
|
+
it('falls back to the issue-count proxy when resource_count is absent', () => {
|
|
99
|
+
// Older backend: no resource_count → empty section still reads as zero-state.
|
|
100
|
+
const s = _setupSection(mkReport([empty('domains'), populated('emails')]));
|
|
101
|
+
expect(s.issues[0].scope).toBe('setup/domain');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('sanitizeHint', () => {
|
|
105
|
+
it('drops a hint referencing an internal docs/ artifact', () => {
|
|
106
|
+
expect(sanitizeHint('see docs/security-debt-pixel-hypersearch-2026-05-27.md item #2 for the fix')).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
it('drops a hint referencing an internal code path', () => {
|
|
109
|
+
expect(sanitizeHint('fix in internal/routes/billing/billing.go')).toBeUndefined();
|
|
110
|
+
expect(sanitizeHint('see packages/cli/src/commands/doctor.ts')).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
it('keeps actionable command hints unchanged', () => {
|
|
113
|
+
const cmd = 'Create one with: myapi email mailbox create <username>@<your-domain>';
|
|
114
|
+
expect(sanitizeHint(cmd)).toBe(cmd);
|
|
115
|
+
expect(sanitizeHint('attach a workflow if you want a notification on each submission'))
|
|
116
|
+
.toBe('attach a workflow if you want a notification on each submission');
|
|
117
|
+
});
|
|
118
|
+
it('does not false-match a bare word with a dotted suffix (no slash)', () => {
|
|
119
|
+
// "node.js" / "data.object" have no path slash → not an internal artifact.
|
|
120
|
+
expect(sanitizeHint('deploy your node.js app')).toBe('deploy your node.js app');
|
|
121
|
+
});
|
|
122
|
+
it('passes through undefined', () => {
|
|
123
|
+
expect(sanitizeHint(undefined)).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('_tallyTotals', () => {
|
|
127
|
+
function sec(name, issues) {
|
|
128
|
+
return {
|
|
129
|
+
name, summary: '', issues: issues.map((i, n) => ({
|
|
130
|
+
id: `${name}-${n}`, severity: 'warn', scope: name, message: 'm', ...i,
|
|
131
|
+
})),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
it('counts plain severities', () => {
|
|
135
|
+
const t = _tallyTotals([sec('a', [{ severity: 'crit' }, { severity: 'warn' }, { severity: 'ok' }])]);
|
|
136
|
+
expect(t).toEqual({ ok: 1, warn: 1, crit: 1, operator: 0 });
|
|
137
|
+
});
|
|
138
|
+
it('pulls an operator_only critical OUT of crit and into operator', () => {
|
|
139
|
+
// The pixel case: a platform-side critical must not count as a customer
|
|
140
|
+
// critical (so it won't set exit code 1).
|
|
141
|
+
const t = _tallyTotals([sec('pixel', [{ severity: 'crit', operator_only: true }])]);
|
|
142
|
+
expect(t.crit).toBe(0);
|
|
143
|
+
expect(t.operator).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
it('pulls an operator_only warning into operator too', () => {
|
|
146
|
+
const t = _tallyTotals([sec('x', [{ severity: 'warn', operator_only: true }, { severity: 'warn' }])]);
|
|
147
|
+
expect(t.warn).toBe(1);
|
|
148
|
+
expect(t.operator).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
it('still counts a real customer critical alongside an operator_only one', () => {
|
|
151
|
+
const t = _tallyTotals([sec('x', [{ severity: 'crit' }, { severity: 'crit', operator_only: true }])]);
|
|
152
|
+
expect(t.crit).toBe(1); // exit code WILL be 1 — there's a real one
|
|
153
|
+
expect(t.operator).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
it('treats an operator_only ok row as a passing check, not an operator issue', () => {
|
|
156
|
+
const t = _tallyTotals([sec('x', [{ severity: 'ok', operator_only: true }])]);
|
|
157
|
+
expect(t).toEqual({ ok: 1, warn: 0, crit: 0, operator: 0 });
|
|
158
|
+
});
|
|
74
159
|
});
|
|
@@ -4,6 +4,14 @@ import { type Flags } from '../helpers.js';
|
|
|
4
4
|
import type { Exposes } from '../exposes.js';
|
|
5
5
|
export declare const EXPOSES: Exposes;
|
|
6
6
|
export declare const SCHEMA: FlagSchema;
|
|
7
|
+
export interface DoctorTotals {
|
|
8
|
+
ok: number;
|
|
9
|
+
warn: number;
|
|
10
|
+
crit: number;
|
|
11
|
+
operator: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function _tallyTotals(sections: sdkHq.DoctorSection[]): DoctorTotals;
|
|
14
|
+
export declare function sanitizeHint(hint: string | undefined): string | undefined;
|
|
7
15
|
export interface SetupContext {
|
|
8
16
|
mailingAddress?: string | null;
|
|
9
17
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -41,6 +41,33 @@ const MARK = {
|
|
|
41
41
|
warn: `${C.warn}⚠${C.reset}`,
|
|
42
42
|
crit: `${C.err}✗${C.reset}`,
|
|
43
43
|
};
|
|
44
|
+
// Operator-only issues are platform-side and not the customer's to fix. Render
|
|
45
|
+
// them with a distinct, non-alarming marker so they don't read as a failure the
|
|
46
|
+
// customer must act on — regardless of the backend severity.
|
|
47
|
+
const OPERATOR_MARK = `${C.dim}ℹ${C.reset}`;
|
|
48
|
+
// Tally issues for the exit code + summary. `operator_only` warn/crit issues
|
|
49
|
+
// are pulled into their own `operator` bucket and OUT of crit/warn: a
|
|
50
|
+
// platform-side problem the customer can't fix must not fail their `doctor`
|
|
51
|
+
// run (exit 1) or inflate their critical count. Everything else tallies by
|
|
52
|
+
// severity as before; any ok row (operator or not) counts as ok.
|
|
53
|
+
export function _tallyTotals(sections) {
|
|
54
|
+
const t = { ok: 0, warn: 0, crit: 0, operator: 0 };
|
|
55
|
+
for (const s of sections) {
|
|
56
|
+
for (const i of s.issues) {
|
|
57
|
+
if (i.severity === 'crit' || i.severity === 'warn') {
|
|
58
|
+
if (i.operator_only)
|
|
59
|
+
t.operator++;
|
|
60
|
+
else
|
|
61
|
+
t[i.severity]++;
|
|
62
|
+
}
|
|
63
|
+
else if (i.severity === 'ok') {
|
|
64
|
+
t.ok++;
|
|
65
|
+
}
|
|
66
|
+
// Unknown severities the backend might invent are ignored (no NaN).
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return t;
|
|
70
|
+
}
|
|
44
71
|
function rule(width = 60) {
|
|
45
72
|
return `${C.dim}${'─'.repeat(width)}${C.reset}`;
|
|
46
73
|
}
|
|
@@ -51,14 +78,43 @@ function rule(width = 60) {
|
|
|
51
78
|
function localIssueId(kind, key) {
|
|
52
79
|
return `${kind}/${createHash('sha256').update(key).digest('hex').slice(0, 16)}`;
|
|
53
80
|
}
|
|
81
|
+
// Backend hints are sometimes written for operators, not customers — e.g.
|
|
82
|
+
// "see docs/security-debt-pixel-hypersearch-2026-05-27.md item #2 for the fix".
|
|
83
|
+
// A reference to an internal repo artifact (a file path with a doc/code
|
|
84
|
+
// extension, or anything under `docs/`) is useless to — and leaks internals
|
|
85
|
+
// at — the customer running `myapi doctor`. Drop the hint entirely in that
|
|
86
|
+
// case: the issue's `message` still conveys what's wrong; a hint that points
|
|
87
|
+
// nowhere the user can go is worse than no hint. (Root fix is backend-side
|
|
88
|
+
// hint hygiene — see docs/cross-repo-prompts/backend-doctor-quality-*.md.)
|
|
89
|
+
const INTERNAL_ARTIFACT_RE = /\bdocs\/[\w.\-/]+|\b[\w.\-]+\/[\w.\-/]*\.(?:md|go|tsx?|jsx?|json|ya?ml|sql)\b/i;
|
|
90
|
+
export function sanitizeHint(hint) {
|
|
91
|
+
if (!hint)
|
|
92
|
+
return undefined;
|
|
93
|
+
return INTERNAL_ARTIFACT_RE.test(hint) ? undefined : hint;
|
|
94
|
+
}
|
|
54
95
|
function fmtIssue(i) {
|
|
55
96
|
// Prefix the entity name so sibling issues with identical messages
|
|
56
97
|
// (e.g. four "domain is active" rows) are distinguishable. Skip it when
|
|
57
98
|
// the message already names the entity — the local DNS section does.
|
|
58
99
|
const name = i.entity?.name;
|
|
59
100
|
const label = name && !i.message.includes(name) ? `${C.bold}${name}${C.reset} — ` : '';
|
|
60
|
-
const
|
|
61
|
-
|
|
101
|
+
const mark = i.operator_only ? OPERATOR_MARK : (MARK[i.severity] ?? '·');
|
|
102
|
+
const head = ` ${mark} ${label}${i.message}`;
|
|
103
|
+
const hint = sanitizeHint(i.hint);
|
|
104
|
+
return hint ? `${head}\n ${C.dim}→ ${hint}${C.reset}` : head;
|
|
105
|
+
}
|
|
106
|
+
// "Does this section's org have zero resources of its kind?" Prefer the
|
|
107
|
+
// backend's authoritative `resource_count` when present; fall back to the
|
|
108
|
+
// issue-count proxy only for older backends that omit it. The proxy is
|
|
109
|
+
// fragile — it reads "section emitted no issues" as "no resources", which
|
|
110
|
+
// holds today only because the backend emits an `ok` row per healthy
|
|
111
|
+
// resource. The moment a section returns `issues: []` for an all-healthy
|
|
112
|
+
// org, the proxy would mis-fire (e.g. "No domain registered" for an org
|
|
113
|
+
// that has a live domain). `resource_count` removes that coupling.
|
|
114
|
+
function isZeroState(section) {
|
|
115
|
+
if (typeof section.resource_count === 'number')
|
|
116
|
+
return section.resource_count === 0;
|
|
117
|
+
return section.issues.length === 0;
|
|
62
118
|
}
|
|
63
119
|
export function _setupSection(report, ctx = {}) {
|
|
64
120
|
const byName = new Map();
|
|
@@ -66,7 +122,7 @@ export function _setupSection(report, ctx = {}) {
|
|
|
66
122
|
byName.set(s.name, s);
|
|
67
123
|
const issues = [];
|
|
68
124
|
const dom = byName.get('domains');
|
|
69
|
-
if (dom && dom
|
|
125
|
+
if (dom && isZeroState(dom)) {
|
|
70
126
|
issues.push({
|
|
71
127
|
id: localIssueId('setup_no_domain', report.org_id),
|
|
72
128
|
severity: 'warn',
|
|
@@ -77,7 +133,7 @@ export function _setupSection(report, ctx = {}) {
|
|
|
77
133
|
});
|
|
78
134
|
}
|
|
79
135
|
const em = byName.get('emails');
|
|
80
|
-
if (em && em
|
|
136
|
+
if (em && isZeroState(em)) {
|
|
81
137
|
issues.push({
|
|
82
138
|
id: localIssueId('setup_no_mailbox', report.org_id),
|
|
83
139
|
severity: 'warn',
|
|
@@ -283,18 +339,12 @@ export async function run(_subcommand, _args, flags) {
|
|
|
283
339
|
const reachSection = await httpProbeSection(apiKey, orgId);
|
|
284
340
|
if (reachSection)
|
|
285
341
|
report.sections.push(reachSection);
|
|
286
|
-
// Re-tally totals after local augmentation.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
for (const i of s.issues) {
|
|
290
|
-
// Ignore any severity the backend invents that we don't model — better
|
|
291
|
-
// a missed count than a NaN poisoning the whole tally.
|
|
292
|
-
if (i.severity in totals)
|
|
293
|
-
totals[i.severity]++;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
342
|
+
// Re-tally totals after local augmentation. `operator_only` issues land in
|
|
343
|
+
// their own bucket and out of crit/warn (see _tallyTotals).
|
|
344
|
+
const totals = _tallyTotals(report.sections);
|
|
296
345
|
// Set the exit code before any output branch — CI relies on it in both
|
|
297
|
-
// the human and the --json path.
|
|
346
|
+
// the human and the --json path. Only customer-actionable criticals fail
|
|
347
|
+
// the run; a platform-side (operator_only) critical does not.
|
|
298
348
|
if (totals.crit)
|
|
299
349
|
process.exitCode = 1;
|
|
300
350
|
if (wantJson) {
|
|
@@ -322,6 +372,11 @@ export async function run(_subcommand, _args, flags) {
|
|
|
322
372
|
else {
|
|
323
373
|
info(`${MARK.ok} ${totals.ok} check${totals.ok === 1 ? '' : 's'} passed`);
|
|
324
374
|
}
|
|
375
|
+
// Platform-side issues are surfaced for transparency but don't count against
|
|
376
|
+
// the org or fail the run — make that explicit.
|
|
377
|
+
if (totals.operator) {
|
|
378
|
+
info(`${OPERATOR_MARK} ${C.dim}${totals.operator} platform-side issue${totals.operator === 1 ? '' : 's'} the MyAPI team is handling (not counted against your org)${C.reset}`);
|
|
379
|
+
}
|
|
325
380
|
if (!verbose)
|
|
326
381
|
info(`${C.dim}(--verbose to show passing checks · --json for machine-readable)${C.reset}`);
|
|
327
382
|
}
|
package/dist/commands/domain.js
CHANGED
|
@@ -299,7 +299,7 @@ export async function emailSetup(domainArg, flags) {
|
|
|
299
299
|
printJson(res);
|
|
300
300
|
return;
|
|
301
301
|
}
|
|
302
|
-
success(`Email infra provisioning on ${res.
|
|
302
|
+
success(`Email infra provisioning on ${res.subdomain} (state: ${res.email_infra})`);
|
|
303
303
|
if (res.next_step)
|
|
304
304
|
info(res.next_step);
|
|
305
305
|
info(`Track with: myapi domain status ${domain} --watch`);
|
|
@@ -533,7 +533,7 @@ Prerequisites:
|
|
|
533
533
|
- WHOIS registrant info (ICANN requirement — see below)
|
|
534
534
|
|
|
535
535
|
Registrant (required at every register call):
|
|
536
|
-
Cheapest path: run \`myapi
|
|
536
|
+
Cheapest path: run \`myapi account registrant set\` once. The CLI stores
|
|
537
537
|
your name/email/phone/address and auto-injects it on every register.
|
|
538
538
|
|
|
539
539
|
Per-call override (agent-friendly):
|
|
@@ -10,7 +10,6 @@ import * as mailbox from './mailbox.js';
|
|
|
10
10
|
import * as message from './message.js';
|
|
11
11
|
import * as warmup from './warmup.js';
|
|
12
12
|
import * as template from './template.js';
|
|
13
|
-
import * as campaign from './campaign.js';
|
|
14
13
|
import * as verify from './verify.js';
|
|
15
14
|
export const SCHEMA = {
|
|
16
15
|
org: 'string',
|
|
@@ -53,15 +52,6 @@ const DEPRECATED_ALIASES = {
|
|
|
53
52
|
'edit-template': ['template', 'edit'],
|
|
54
53
|
'send-test': ['template', 'send-test'],
|
|
55
54
|
'delete-template': ['template', 'delete'],
|
|
56
|
-
'create-campaign': ['campaign', 'create'],
|
|
57
|
-
'list-campaigns': ['campaign', 'list'],
|
|
58
|
-
'get-campaign': ['campaign', 'get'],
|
|
59
|
-
'update-campaign': ['campaign', 'update'],
|
|
60
|
-
'upload-contacts': ['campaign', 'upload-contacts'],
|
|
61
|
-
'start-campaign': ['campaign', 'start'],
|
|
62
|
-
'pause-campaign': ['campaign', 'pause'],
|
|
63
|
-
'resume-campaign': ['campaign', 'resume'],
|
|
64
|
-
'campaign-stats': ['campaign', 'stats'],
|
|
65
55
|
};
|
|
66
56
|
async function dispatchNamespace(ns, sub, restArgs, flags) {
|
|
67
57
|
switch (ns) {
|
|
@@ -69,7 +59,6 @@ async function dispatchNamespace(ns, sub, restArgs, flags) {
|
|
|
69
59
|
case 'message': return message.run(sub, restArgs, flags);
|
|
70
60
|
case 'warmup': return warmup.run(sub, restArgs, flags);
|
|
71
61
|
case 'template': return template.run(sub, restArgs, flags);
|
|
72
|
-
case 'campaign': return campaign.run(sub, restArgs, flags);
|
|
73
62
|
case 'verify': return verify.run(sub, restArgs, flags);
|
|
74
63
|
default: error(`Unknown namespace: ${ns}. Run "myapi email --help" for the list.`);
|
|
75
64
|
}
|
|
@@ -79,7 +68,6 @@ export async function run(subcommand, args, flags) {
|
|
|
79
68
|
info(`Usage: myapi email <namespace> <subcommand>
|
|
80
69
|
|
|
81
70
|
Namespaces:
|
|
82
|
-
campaign Email campaigns (org-scoped) — create, list, get, update, upload-contacts, start, pause, resume, stats
|
|
83
71
|
mailbox Mailboxes (account-scoped) — create, list, activate-sending
|
|
84
72
|
message Send & read mail (account-scoped) — send, status, sent, inbox, outbox, get
|
|
85
73
|
template Email templates (org-scoped) — generate, list, edit, send-test, delete
|
|
@@ -90,7 +78,6 @@ Examples:
|
|
|
90
78
|
myapi email mailbox create hello@example.com
|
|
91
79
|
myapi email message send --from a@x --to b@y --subject hi --body "hello"
|
|
92
80
|
myapi email template generate welcome --prompt "Welcome email"
|
|
93
|
-
myapi email campaign create "Q2 Launch" --template-id <id> --from a@x
|
|
94
81
|
myapi email verify alice@example.com
|
|
95
82
|
|
|
96
83
|
Org-scoped commands accept --org <id> (or set default: myapi config set-org <id>).
|
package/dist/commands/fn.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export declare function list(flags: Flags): Promise<void>;
|
|
|
11
11
|
export declare function get(id: string, flags: Flags): Promise<void>;
|
|
12
12
|
export declare function del(id: string, flags: Flags): Promise<void>;
|
|
13
13
|
export declare function deploy(id: string, bundlePath: string, flags: Flags): Promise<void>;
|
|
14
|
+
export declare function _parseSetPairs(raw: string | string[]): Record<string, string> | string;
|
|
14
15
|
export declare function setEnv(id: string, name: string, value: string, flags: Flags): Promise<void>;
|
|
15
16
|
export declare function runs(id: string, flags: Flags): Promise<void>;
|
|
16
17
|
export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
|
package/dist/commands/fn.js
CHANGED
|
@@ -15,6 +15,8 @@ export const EXPOSES = [
|
|
|
15
15
|
];
|
|
16
16
|
export const SCHEMA = {
|
|
17
17
|
cron: 'string',
|
|
18
|
+
scope: 'string',
|
|
19
|
+
set: 'string',
|
|
18
20
|
};
|
|
19
21
|
// Backend: Story 1 (function CRUD + scoped key) and Story 2/4/5 (deploy a
|
|
20
22
|
// JS bundle to Cloudflare Workers, list runs, set env secrets).
|
|
@@ -67,6 +69,14 @@ export async function create(flags) {
|
|
|
67
69
|
};
|
|
68
70
|
if (cron)
|
|
69
71
|
payload.cron_schedule = cron;
|
|
72
|
+
// --scope narrows the minted key's slot grants. Repeatable, but the flag
|
|
73
|
+
// parser keeps only the last occurrence, so we also accept a comma-joined
|
|
74
|
+
// list (e.g. --scope email,storage). Grants can never exceed the caller's.
|
|
75
|
+
if (typeof flags.scope === 'string') {
|
|
76
|
+
const scopes = flags.scope.split(',').map(s => s.trim()).filter(Boolean);
|
|
77
|
+
if (scopes.length > 0)
|
|
78
|
+
payload.scopes = scopes;
|
|
79
|
+
}
|
|
70
80
|
const result = await sdkFn.createFunction(config.api_key, orgId, payload);
|
|
71
81
|
success(`Function created: ${result.function.id}`);
|
|
72
82
|
info(`Name: ${result.function.name}`);
|
|
@@ -77,7 +87,8 @@ export async function create(flags) {
|
|
|
77
87
|
info('');
|
|
78
88
|
info(`Scoped API key (returned once — save it if you need it):`);
|
|
79
89
|
info(` ${result.scoped_api_key}`);
|
|
80
|
-
|
|
90
|
+
const scopeNote = payload.scopes && payload.scopes.length > 0 ? `slots: ${payload.scopes.join(', ')}` : 'scopes: slot_call';
|
|
91
|
+
info(` (id: ${result.scoped_api_key_id}; ${scopeNote}; rejected at /hq/*, /admin/*, /internal/*)`);
|
|
81
92
|
if (!result.function.invocation_url) {
|
|
82
93
|
info('');
|
|
83
94
|
banner(`Next: deploy code with myapi fn deploy ${result.function.id} <bundle.js>`);
|
|
@@ -153,14 +164,48 @@ export async function deploy(id, bundlePath, flags) {
|
|
|
153
164
|
info(`Scoped API key was rotated. New value (returned once — save it if you need it):`);
|
|
154
165
|
info(` ${result.scoped_api_key}`);
|
|
155
166
|
}
|
|
156
|
-
//
|
|
167
|
+
// _parseSetPairs parses `--set K=V` entries into a map. Accepts a single
|
|
168
|
+
// string (comma-joined: K=V,K2=V2) or an array of strings (when the flag is
|
|
169
|
+
// repeated and the parser preserves them). Returns the map or an error
|
|
170
|
+
// message string (pure form, for tests).
|
|
171
|
+
export function _parseSetPairs(raw) {
|
|
172
|
+
const entries = [];
|
|
173
|
+
for (const chunk of Array.isArray(raw) ? raw : [raw]) {
|
|
174
|
+
for (const pair of chunk.split(',').map(s => s.trim()).filter(Boolean))
|
|
175
|
+
entries.push(pair);
|
|
176
|
+
}
|
|
177
|
+
const env = {};
|
|
178
|
+
for (const pair of entries) {
|
|
179
|
+
const eq = pair.indexOf('=');
|
|
180
|
+
if (eq < 1)
|
|
181
|
+
return `Invalid --set entry "${pair}". Use KEY=VALUE.`;
|
|
182
|
+
env[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
183
|
+
}
|
|
184
|
+
return env;
|
|
185
|
+
}
|
|
186
|
+
// env sets Worker Secret(s) (Stripe key, etc.) on a deployed function.
|
|
187
|
+
// Single form: myapi fn env <id> <name> <value>
|
|
188
|
+
// Bulk form: myapi fn env <id> --set K=V[,K2=V2 ...]
|
|
157
189
|
export async function setEnv(id, name, value, flags) {
|
|
158
190
|
const config = requireConfig();
|
|
159
|
-
const orgId = requireOrg(flags, config, 'myapi fn env <id> <name> <value> [--org <id>]');
|
|
191
|
+
const orgId = requireOrg(flags, config, 'myapi fn env <id> <name> <value> | --set K=V [--org <id>]');
|
|
160
192
|
if (!id)
|
|
161
|
-
error('Missing id.\nUsage: myapi fn env <id> <name> <value>');
|
|
193
|
+
error('Missing id.\nUsage: myapi fn env <id> <name> <value> (or --set K=V for bulk)');
|
|
194
|
+
// Bulk path: one --set string (comma-joined) or repeated --set occurrences.
|
|
195
|
+
if (flags.set !== undefined && flags.set !== true) {
|
|
196
|
+
const env = _parseSetPairs(flags.set);
|
|
197
|
+
if (typeof env === 'string')
|
|
198
|
+
error(env);
|
|
199
|
+
if (Object.keys(env).length === 0)
|
|
200
|
+
error('No secrets given. Usage: myapi fn env <id> --set KEY=VALUE');
|
|
201
|
+
const result = await sdkFn.setFunctionEnvBulk(config.api_key, orgId, id, env);
|
|
202
|
+
success(`Set ${result.set} secret${result.set === 1 ? '' : 's'} on function ${id}`);
|
|
203
|
+
info('Values are encrypted at rest by Cloudflare and never stored or echoed by MyAPI.');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Single-secret path (original form).
|
|
162
207
|
if (!name)
|
|
163
|
-
error('Missing secret name.\nUsage: myapi fn env <id> <name> <value>');
|
|
208
|
+
error('Missing secret name.\nUsage: myapi fn env <id> <name> <value> (or --set K=V for bulk)');
|
|
164
209
|
if (value === undefined)
|
|
165
210
|
error('Missing secret value.\nUsage: myapi fn env <id> <name> <value>');
|
|
166
211
|
await sdkFn.setFunctionEnv(config.api_key, orgId, id, name, value);
|
|
@@ -191,7 +236,7 @@ export async function runs(id, flags) {
|
|
|
191
236
|
}
|
|
192
237
|
// ── Dispatcher ───────────────────────────────────────────────────────────────
|
|
193
238
|
const SUBCOMMAND_USAGE = {
|
|
194
|
-
'create': `myapi fn create --name <name> [--cron <expr>] [--org <id>]
|
|
239
|
+
'create': `myapi fn create --name <name> [--cron <expr>] [--scope <slot>[,<slot>...]] [--org <id>]
|
|
195
240
|
|
|
196
241
|
Persists a function record + issues a scoped API key. Deploy code separately
|
|
197
242
|
with "myapi fn deploy".
|
|
@@ -200,9 +245,15 @@ Triggers:
|
|
|
200
245
|
(default) HTTP — function gets a public invocation URL once deployed.
|
|
201
246
|
--cron <expr> Cron — function runs on the schedule (e.g. "0 8 * * *").
|
|
202
247
|
|
|
248
|
+
Scoping:
|
|
249
|
+
--scope <slot> Narrow the minted key's slot grants (comma-separated list,
|
|
250
|
+
e.g. --scope email,storage). Omit to inherit your grants.
|
|
251
|
+
The key can never out-reach the credential that created it.
|
|
252
|
+
|
|
203
253
|
Examples:
|
|
204
254
|
myapi fn create --name my-app-api
|
|
205
255
|
myapi fn create --name daily-report --cron "0 8 * * *"
|
|
256
|
+
myapi fn create --name mailer --scope email,storage
|
|
206
257
|
|
|
207
258
|
The response returns the scoped API key ONCE. It lets the function call
|
|
208
259
|
other MyAPI slots with its own permissions (scopes=slot_call; rejected at
|
|
@@ -218,13 +269,14 @@ once. After deploy the function has a live invocation URL.
|
|
|
218
269
|
Example:
|
|
219
270
|
myapi fn deploy <id> ./dist/bundle.js`,
|
|
220
271
|
'env': `myapi fn env <id> <name> <value> [--org <id>]
|
|
272
|
+
myapi fn env <id> --set KEY=VALUE[,KEY2=VALUE2 ...] [--org <id>]
|
|
221
273
|
|
|
222
|
-
Sets
|
|
223
|
-
a deployed function.
|
|
224
|
-
MyAPI or echoed back. The function must already be deployed.
|
|
274
|
+
Sets one or more secrets (Stripe key, API token, ...) as Cloudflare Worker
|
|
275
|
+
Secrets on a deployed function. Values are encrypted at rest and never stored
|
|
276
|
+
in MyAPI or echoed back. The function must already be deployed.
|
|
225
277
|
|
|
226
|
-
|
|
227
|
-
myapi fn env <id> STRIPE_KEY
|
|
278
|
+
Single: myapi fn env <id> STRIPE_KEY sk_live_...
|
|
279
|
+
Bulk: myapi fn env <id> --set STRIPE_KEY=sk_live_...,WEBHOOK_SECRET=whsec_...`,
|
|
228
280
|
'runs': `myapi fn runs <id> [--org <id>] [--json]
|
|
229
281
|
|
|
230
282
|
Lists recent invocation records (most recent first, up to 100).`,
|
|
@@ -242,7 +294,7 @@ Subcommands:
|
|
|
242
294
|
create Register a function and get its scoped API key (returned once)
|
|
243
295
|
delete <id> Soft-delete and revoke its scoped API key
|
|
244
296
|
deploy <id> <file> Upload a JS bundle and go live
|
|
245
|
-
env <id> <k> <v> Set a Worker Secret on a deployed function
|
|
297
|
+
env <id> <k> <v> Set a Worker Secret (or --set K=V for bulk) on a deployed function
|
|
246
298
|
get <id> Inspect a function
|
|
247
299
|
list List functions in your org
|
|
248
300
|
runs <id> List recent invocation records
|