@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.
Files changed (57) hide show
  1. package/dist/commands/account.d.ts +7 -1
  2. package/dist/commands/account.js +356 -18
  3. package/dist/commands/authproduct.d.ts +7 -0
  4. package/dist/commands/authproduct.js +286 -0
  5. package/dist/commands/billing-auto-recharge.test.d.ts +1 -0
  6. package/dist/commands/billing-auto-recharge.test.js +103 -0
  7. package/dist/commands/billing.d.ts +3 -0
  8. package/dist/commands/billing.js +116 -1
  9. package/dist/commands/config.js +1 -1
  10. package/dist/commands/container.d.ts +1 -0
  11. package/dist/commands/container.js +115 -11
  12. package/dist/commands/doctor-setup.test.js +86 -1
  13. package/dist/commands/doctor.d.ts +8 -0
  14. package/dist/commands/doctor.js +70 -15
  15. package/dist/commands/domain.js +2 -2
  16. package/dist/commands/email/index.js +0 -13
  17. package/dist/commands/fn.d.ts +1 -0
  18. package/dist/commands/fn.js +64 -12
  19. package/dist/commands/keys.js +3 -3
  20. package/dist/commands/queue.d.ts +1 -0
  21. package/dist/commands/queue.js +10 -0
  22. package/dist/commands/setup.js +8 -8
  23. package/dist/commands/status.d.ts +1 -1
  24. package/dist/commands/status.js +23 -27
  25. package/dist/commands/storage.js +3 -1
  26. package/dist/commands/workflow.js +21 -6
  27. package/dist/completion.js +5 -4
  28. package/dist/config.js +1 -1
  29. package/dist/exposes.test.js +1 -2
  30. package/dist/index.js +57 -33
  31. package/dist/registrant.js +5 -5
  32. package/dist/sdk-queue.test.js +3 -2
  33. package/dist/skills/my-api-hq/README.md +1 -0
  34. package/dist/skills/my-api-hq/SKILL.md +27 -14
  35. package/dist/skills/my-auth-api/README.md +33 -0
  36. package/dist/skills/my-auth-api/SKILL.md +112 -0
  37. package/dist/skills/my-auth-api/claude/.claude-plugin/plugin.json +6 -0
  38. package/dist/skills/my-auth-api/openapi/.gitkeep +0 -0
  39. package/dist/skills/my-crm-api/SKILL.md +1 -1
  40. package/dist/skills/my-domain-api/SKILL.md +5 -5
  41. package/dist/skills/my-email-api/README.md +2 -3
  42. package/dist/skills/my-email-api/SKILL.md +7 -18
  43. package/dist/skills/my-email-api/claude/.claude-plugin/plugin.json +1 -1
  44. package/dist/skills/my-email-verify-api/SKILL.md +1 -1
  45. package/dist/skills/my-email-verify-api/claude/.claude-plugin/plugin.json +1 -1
  46. package/dist/skills/my-funnel-api/SKILL.md +1 -1
  47. package/dist/skills/my-git-api/README.md +43 -0
  48. package/dist/skills/my-git-api/SKILL.md +115 -0
  49. package/dist/skills/my-git-api/claude/.claude-plugin/plugin.json +6 -0
  50. package/dist/skills/my-git-api/openapi/.gitkeep +0 -0
  51. package/dist/skills/my-llm-api/README.md +1 -1
  52. package/dist/skills/my-llm-api/claude/.claude-plugin/plugin.json +1 -1
  53. package/package.json +2 -2
  54. package/dist/commands/auth.d.ts +0 -11
  55. package/dist/commands/auth.js +0 -345
  56. package/dist/commands/email/campaign.d.ts +0 -4
  57. 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
- // deploy ships a pre-built image to Cloud Run. The scoped API key is
166
- // rotated on every deploy the fresh value is shown once here.
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
- Ships a pre-built container image to the runtime. The scoped API key is
252
- rotated on every deploy the fresh value is printed once.
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
- Example:
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
  }
@@ -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 head = ` ${MARK[i.severity] ?? '·'} ${label}${i.message}`;
61
- return i.hint ? `${head}\n ${C.dim} ${i.hint}${C.reset}` : head;
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.issues.length === 0) {
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.issues.length === 0) {
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
- const totals = { ok: 0, warn: 0, crit: 0 };
288
- for (const s of report.sections) {
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
  }
@@ -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.email_subdomain} (state: ${res.email_infra})`);
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 auth registrant set\` once. The CLI stores
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>).
@@ -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>;
@@ -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
- info(` (id: ${result.scoped_api_key_id}; scopes: slot_call; rejected at /hq/*, /admin/*, /internal/*)`);
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
- // env sets a Worker Secret (Stripe key, etc.) on a deployed function.
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 a secret (Stripe key, API token, ...) as a Cloudflare Worker Secret on
223
- a deployed function. The value is encrypted at rest and never stored in
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
- Example:
227
- myapi fn env <id> STRIPE_KEY sk_live_...`,
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