@myapihq/cli 2.4.3 → 2.5.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 (49) hide show
  1. package/dist/commands/authproduct.js +1 -1
  2. package/dist/commands/billing.js +8 -2
  3. package/dist/commands/config.js +16 -0
  4. package/dist/commands/container.d.ts +2 -1
  5. package/dist/commands/container.js +30 -5
  6. package/dist/commands/fn.d.ts +1 -1
  7. package/dist/commands/fn.js +51 -9
  8. package/dist/commands/keys.js +5 -0
  9. package/dist/commands/login.d.ts +1 -0
  10. package/dist/commands/login.js +62 -2
  11. package/dist/commands/payments.js +6 -2
  12. package/dist/commands/pixel.d.ts +2 -1
  13. package/dist/commands/pixel.js +28 -1
  14. package/dist/commands/queue.d.ts +1 -1
  15. package/dist/commands/queue.js +15 -2
  16. package/dist/commands/setup.js +6 -2
  17. package/dist/commands/storage.js +13 -2
  18. package/dist/commands/webhook.d.ts +1 -0
  19. package/dist/commands/webhook.js +74 -2
  20. package/dist/errors.js +6 -0
  21. package/dist/flags.d.ts +1 -1
  22. package/dist/flags.js +21 -0
  23. package/dist/flags.test.js +35 -0
  24. package/dist/index.js +60 -0
  25. package/dist/output.js +8 -0
  26. package/dist/sdk-container.test.js +5 -1
  27. package/dist/skills/my-audience-api/README.md +1 -1
  28. package/dist/skills/my-auth-api/SKILL.md +3 -1
  29. package/dist/skills/my-company-api/README.md +1 -1
  30. package/dist/skills/my-container-api/README.md +1 -1
  31. package/dist/skills/my-container-api/SKILL.md +3 -1
  32. package/dist/skills/my-crm-api/README.md +1 -1
  33. package/dist/skills/my-crm-api/SKILL.md +6 -4
  34. package/dist/skills/my-database-api/README.md +1 -1
  35. package/dist/skills/my-database-api/SKILL.md +3 -1
  36. package/dist/skills/my-email-api/README.md +1 -1
  37. package/dist/skills/my-email-verify-api/README.md +1 -1
  38. package/dist/skills/my-git-api/README.md +1 -1
  39. package/dist/skills/my-image-api/README.md +1 -1
  40. package/dist/skills/my-llm-api/README.md +1 -1
  41. package/dist/skills/my-people-api/README.md +1 -1
  42. package/dist/skills/my-pixel-api/README.md +1 -1
  43. package/dist/skills/my-queue-api/README.md +1 -1
  44. package/dist/skills/my-storage-api/README.md +1 -1
  45. package/dist/skills/my-task-api/README.md +1 -1
  46. package/dist/skills/my-webhook-api/README.md +1 -1
  47. package/dist/skills/my-webhook-api/SKILL.md +7 -3
  48. package/dist/skills/my-workflow-api/README.md +1 -1
  49. package/package.json +3 -2
@@ -11,7 +11,7 @@ import { requireOrg } from '../helpers.js';
11
11
  export const SCHEMA = {
12
12
  name: 'string',
13
13
  type: 'string', // spa | web
14
- redirect: 'string', // comma-separated redirect URIs
14
+ redirect: 'list', // comma-separated OR repeated; occurrences accumulate
15
15
  theme: 'string', // JSON for the hosted login page
16
16
  connections: 'string', // comma-separated sign-in methods: google,password,magic
17
17
  domain: 'string', // custom auth domain, e.g. auth.acme.com
@@ -1,6 +1,6 @@
1
1
  import { hq } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../config.js';
3
- import { success, error, printTable, info, printJson } from '../output.js';
3
+ import { success, error, printTable, info, printJson, banner } from '../output.js';
4
4
  import { confirm, isNonInteractive } from '../prompt.js';
5
5
  import { formatDate } from '../utils.js';
6
6
  export const SCHEMA = {
@@ -185,7 +185,13 @@ export async function topup(amountStr, flags) {
185
185
  }
186
186
  }
187
187
  const config = requireConfig();
188
- const result = await hq.topUp(config.api_key, amount);
188
+ // A replay means our retry collapsed into the original request rather than
189
+ // topping up again. Say so — an agent that reads "topped up" twice and adds
190
+ // the amounts up gets the balance wrong.
191
+ let replayed = false;
192
+ const result = await hq.topUp(config.api_key, amount, { onReplay: () => { replayed = true; } });
193
+ if (replayed)
194
+ banner('› This was a replay of an identical earlier top-up — your balance was charged once, not twice.');
189
195
  success(`Top up successful! New balance: ${result.new_balance_display}`);
190
196
  }
191
197
  export async function setup(_flags) {
@@ -5,6 +5,7 @@ export const SCHEMA = {
5
5
  _via: 'string',
6
6
  };
7
7
  export const EXPOSES = [
8
+ 'PATCH /hq/account/default-org',
8
9
  'GET /hq/orgs/{org_id}',
9
10
  'GET /funnel/orgs/{org_id}/funnels/{funnel_id}',
10
11
  ];
@@ -34,10 +35,25 @@ export async function setOrg(id, _flags, via = 'auth config') {
34
35
  }
35
36
  catch { /* swallow — clearing alone is still correct */ }
36
37
  saveConfig(config);
38
+ // Also pin it account-side. The local default lives in this machine's
39
+ // ~/.myapi/config.json, so signing in anywhere else used to land on whatever
40
+ // org the backend picked ("most recently created") — which is how an agent
41
+ // on a fresh machine ends up writing to the wrong namespace. Best-effort:
42
+ // the local default is already saved and correct, so a failure here is a
43
+ // smaller default not following you, never a broken local setup.
44
+ let pinned = false;
45
+ try {
46
+ await hq.setAccountDefaultOrg(config.api_key, org.id);
47
+ pinned = true;
48
+ }
49
+ catch { /* local default stands */ }
37
50
  success(`Default organization set to: ${org.name || org.id}`);
38
51
  if (config.default_funnel) {
39
52
  info(` Default funnel auto-resolved to ${config.default_funnel}.`);
40
53
  }
54
+ info(pinned
55
+ ? ' Pinned account-wide — new logins on other machines get this org too.'
56
+ : ' Set on this machine only (could not pin it account-wide).');
41
57
  }
42
58
  export async function setFunnel(id, _flags, via = 'auth config') {
43
59
  if (!id) {
@@ -7,7 +7,7 @@ export declare const NAME_RE: RegExp;
7
7
  export declare const RESERVED_NAMES: Set<string>;
8
8
  export declare function _validateName(name: string): string | null;
9
9
  export declare function _parseEnv(raw: string): Record<string, string> | string;
10
- export declare function create(flags: Flags): Promise<void>;
10
+ export declare function create(nameArg: string | undefined, flags: Flags): Promise<void>;
11
11
  export declare function list(flags: Flags): Promise<void>;
12
12
  export declare function get(id: string, flags: Flags): Promise<void>;
13
13
  export declare function del(id: string, flags: Flags): Promise<void>;
@@ -15,4 +15,5 @@ export declare function _isTarball(p: string): boolean;
15
15
  export declare function deploy(id: string, image: string, flags: Flags): Promise<void>;
16
16
  export declare function logs(id: string, flags: Flags): Promise<void>;
17
17
  export declare function domain(id: string, domainArg: string | undefined, flags: Flags): Promise<void>;
18
+ export declare function buildLogs(id: string | undefined, flags: Flags): Promise<void>;
18
19
  export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -6,6 +6,7 @@ import { success, error, printTable, info, printJson, banner } from '../output.j
6
6
  import { formatDate, pollJob } from '../utils.js';
7
7
  import { requireOrg, confirmDestructive } from '../helpers.js';
8
8
  export const EXPOSES = [
9
+ 'GET /container/orgs/{org_id}/containers/{id}/build-logs',
9
10
  'POST /container/orgs/{org_id}/containers',
10
11
  'GET /container/orgs/{org_id}/containers',
11
12
  'GET /container/orgs/{org_id}/containers/{id}',
@@ -70,12 +71,15 @@ function summarizeContainer(c) {
70
71
  updated_at: c.updated_at ? formatDate(c.updated_at) : '',
71
72
  };
72
73
  }
73
- export async function create(flags) {
74
+ export async function create(nameArg, flags) {
74
75
  const config = requireConfig();
75
76
  const orgId = requireOrg(flags, config, 'myapi container create --name <name> [--type service|worker|job] [--org <id>]');
76
- const name = flags.name;
77
+ // Positional name is the documented shape across the CLI (`org create`,
78
+ // `queue create`, `git create`, …); `--name` stayed the only accepted form
79
+ // here, which made these two the odd ones out. Both work now.
80
+ const name = nameArg ?? flags.name;
77
81
  if (!name) {
78
- error('Missing --name.\nUsage: myapi container create --name <name> [--type service|worker|job] [--org <id>]\n\n→ Name is a kebab-case slug, 1-50 chars (e.g. "my-worker").');
82
+ error('Missing --name.\nUsage: myapi container create <name> [--type service|worker|job] [--org <id>]\n\n→ Name is a kebab-case slug, 1-50 chars (e.g. "my-worker").');
79
83
  }
80
84
  const nameErr = _validateName(name);
81
85
  if (nameErr)
@@ -326,6 +330,25 @@ export async function domain(id, domainArg, flags) {
326
330
  info(`Origin: ${binding.origin}`);
327
331
  info(`Status: ${binding.status}`);
328
332
  }
333
+ export async function buildLogs(id, flags) {
334
+ const config = requireConfig();
335
+ const orgId = requireOrg(flags, config, 'myapi container build-logs <id> [--tail N] [--org <id>]');
336
+ if (!id)
337
+ error('Missing id.\nUsage: myapi container build-logs <id> [--tail N]');
338
+ const tail = typeof flags.tail === 'number' ? flags.tail : undefined;
339
+ const res = await sdkContainer.buildLogs(config.api_key, orgId, id, tail);
340
+ if (flags.json) {
341
+ printJson(res);
342
+ return;
343
+ }
344
+ const lines = res.logs ?? [];
345
+ if (lines.length === 0) {
346
+ info('No build logs yet — has this container been deployed with --source?');
347
+ return;
348
+ }
349
+ for (const l of lines)
350
+ info(l);
351
+ }
329
352
  // ── Dispatcher ───────────────────────────────────────────────────────────────
330
353
  const SUBCOMMAND_USAGE = {
331
354
  'create': `myapi container create --name <name> [--type service|worker|job] [--cron <expr>]
@@ -384,13 +407,14 @@ jobs. The heavier-duty sibling of edge functions (myapi fn), for native
384
407
  dependencies and long execution.
385
408
 
386
409
  Subcommands:
410
+ build-logs <id> Why the last --source build failed (--tail N; default 100)
387
411
  create Register a container and get its scoped API key (returned once)
388
412
  delete <id> Soft-delete and revoke its scoped API key
389
413
  deploy <id> <image> Ship a pre-built image (or --source <dir|tar> to build) and go live
390
414
  domain <id> <domain> Bind a custom domain (--remove to unbind)
391
415
  get <id> Inspect a container
392
416
  list List containers in your org
393
- logs <id> Show recent runtime logs (--tail <n>)
417
+ logs <id> Show recent runtime logs (--tail <n>) — see build-logs for build failures
394
418
 
395
419
  All commands accept --org <id> (or set default: myapi config set-org <id>).`);
396
420
  return;
@@ -404,7 +428,8 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
404
428
  return;
405
429
  }
406
430
  switch (subcommand) {
407
- case 'create': return create(flags);
431
+ case 'create': return create(args[0], flags);
432
+ case 'build-logs': return buildLogs(args[0], flags);
408
433
  case 'deploy': return deploy(args[0], args[1], flags);
409
434
  case 'list': return list(flags);
410
435
  case 'get': return get(args[0], flags);
@@ -6,7 +6,7 @@ export declare const SCHEMA: FlagSchema;
6
6
  export declare const NAME_RE: RegExp;
7
7
  export declare const RESERVED_NAMES: Set<string>;
8
8
  export declare function _validateName(name: string): string | null;
9
- export declare function create(flags: Flags): Promise<void>;
9
+ export declare function create(nameArg: string | undefined, flags: Flags): Promise<void>;
10
10
  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>;
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'fs/promises';
2
2
  import { fn as sdkFn } from '@myapihq/sdk';
3
3
  import { requireConfig } from '../config.js';
4
- import { success, error, printTable, info, printJson, banner } from '../output.js';
4
+ import { success, error, printTable, info, printJson, banner, spinnerFrame, spinnerWrite, clearLine } from '../output.js';
5
5
  import { formatDate } from '../utils.js';
6
6
  import { requireOrg, confirmDestructive } from '../helpers.js';
7
7
  export const EXPOSES = [
@@ -14,8 +14,12 @@ export const EXPOSES = [
14
14
  'GET /function/orgs/{org_id}/functions/{id}/runs',
15
15
  ];
16
16
  export const SCHEMA = {
17
+ // `create` takes a positional name; --name stays accepted for
18
+ // back-compat (RELEASING.md's stated convention) and must be declared
19
+ // here so the command-aware flag check in index.ts doesn't flag it.
20
+ name: 'string',
17
21
  cron: 'string',
18
- scope: 'string',
22
+ scope: 'list',
19
23
  set: 'string',
20
24
  };
21
25
  // Backend: Story 1 (function CRUD + scoped key) and Story 2/4/5 (deploy a
@@ -54,12 +58,15 @@ function summarizeFn(f) {
54
58
  updated_at: f.updated_at,
55
59
  };
56
60
  }
57
- export async function create(flags) {
61
+ export async function create(nameArg, flags) {
58
62
  const config = requireConfig();
59
63
  const orgId = requireOrg(flags, config, 'myapi fn create --name <name> [--cron <expr>] [--org <id>]');
60
- const name = flags.name;
64
+ // Positional name is the documented shape across the CLI (`org create`,
65
+ // `queue create`, `git create`, …); `--name` stayed the only accepted form
66
+ // here, which made these two the odd ones out. Both work now.
67
+ const name = nameArg ?? flags.name;
61
68
  if (!name) {
62
- error('Missing --name.\nUsage: myapi fn create --name <name> [--cron <expr>] [--org <id>]\n\n→ Name is a kebab-case slug, 1-50 chars (e.g. "my-app-api").');
69
+ error('Missing --name.\nUsage: myapi fn create <name> [--cron <expr>] [--org <id>]\n\n→ Name is a kebab-case slug, 1-50 chars (e.g. "my-app-api").');
63
70
  }
64
71
  validateName(name);
65
72
  const cron = flags.cron;
@@ -69,9 +76,10 @@ export async function create(flags) {
69
76
  };
70
77
  if (cron)
71
78
  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.
79
+ // --scope narrows the minted key's slot grants. Declared 'list', so repeated
80
+ // occurrences accumulate (`--scope email --scope storage`) and a comma-joined
81
+ // form works too (`--scope email,storage`). It used to keep only the last
82
+ // occurrence silently. Grants can never exceed the caller's.
75
83
  if (typeof flags.scope === 'string') {
76
84
  const scopes = flags.scope.split(',').map(s => s.trim()).filter(Boolean);
77
85
  if (scopes.length > 0)
@@ -161,10 +169,44 @@ export async function deploy(id, bundlePath, flags) {
161
169
  }
162
170
  success(`Deployed function ${id}`);
163
171
  info(`Invocation URL: ${result.invocation_url}`);
172
+ // The edge takes ~15-45s to start serving a freshly-deployed bundle, and
173
+ // until it does the invocation URL returns 404. That is a trap for the
174
+ // correct agent behaviour — deploy, then verify — because the obvious
175
+ // reading of a 404 is "the deploy failed", and the obvious recovery is to
176
+ // deploy again. Redeploying ROTATES THE SCOPED KEY, breaking anything
177
+ // already wired to the old one. So: wait for it here, and if it hasn't come
178
+ // up, say explicitly that redeploying is the wrong move.
179
+ if (result.invocation_url) {
180
+ await waitForLive(result.invocation_url);
181
+ }
164
182
  info('');
165
183
  info(`Scoped API key was rotated. New value (returned once — save it if you need it):`);
166
184
  info(` ${result.scoped_api_key}`);
167
185
  }
186
+ // Poll until the edge serves the new bundle. Best-effort and never fatal —
187
+ // the deploy already succeeded, so a slow or unreachable edge must not turn a
188
+ // successful write into a non-zero exit.
189
+ async function waitForLive(url, budgetMs = 75_000) {
190
+ const started = Date.now();
191
+ let frame = 0;
192
+ while (Date.now() - started < budgetMs) {
193
+ try {
194
+ const res = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(5000) });
195
+ if (res.status !== 404) {
196
+ clearLine();
197
+ info(`Status: live (${Math.round((Date.now() - started) / 1000)}s)`);
198
+ return;
199
+ }
200
+ }
201
+ catch { /* edge not answering yet */ }
202
+ spinnerWrite(`\r${spinnerFrame(frame++)} waiting for the edge to serve it…`);
203
+ await new Promise(r => setTimeout(r, 3000));
204
+ }
205
+ clearLine();
206
+ info(`Status: not serving yet after ${Math.round(budgetMs / 1000)}s.`);
207
+ info(` The deploy itself succeeded — give it another minute.`);
208
+ info(` Do NOT redeploy to "fix" it: that rotates the scoped key again.`);
209
+ }
168
210
  // _parseSetPairs parses `--set K=V` entries into a map. Accepts a single
169
211
  // string (comma-joined: K=V,K2=V2) or an array of strings (when the flag is
170
212
  // repeated and the parser preserves them). Returns the map or an error
@@ -312,7 +354,7 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
312
354
  return;
313
355
  }
314
356
  switch (subcommand) {
315
- case 'create': return create(flags);
357
+ case 'create': return create(args[0], flags);
316
358
  case 'deploy': return deploy(args[0], args[1], flags);
317
359
  case 'env': return setEnv(args[0], args[1], args[2], flags);
318
360
  case 'runs': return runs(args[0], flags);
@@ -1,3 +1,4 @@
1
+ import { formatDate } from '../utils.js';
1
2
  import { hq } from '@myapihq/sdk';
2
3
  import { requireConfig } from '../config.js';
3
4
  import { success, error, printTable, info, printJson } from '../output.js';
@@ -132,6 +133,10 @@ export async function list(flags) {
132
133
  Cap: k.spend_cap_cents != null
133
134
  ? `$${((k.current_period_spend_cents ?? 0) / 100).toFixed(2)} / $${(k.spend_cap_cents / 100).toFixed(2)} (${k.spend_cap_period})`
134
135
  : '—',
136
+ Created: k.created_at ? formatDate(k.created_at) : '',
137
+ // A key that has never authenticated a request is the one that's safe to
138
+ // revoke — so "never" is a useful answer here, not a missing value.
139
+ 'Last used': k.last_used_at ? formatDate(k.last_used_at) : 'never',
135
140
  }));
136
141
  printTable(rows, {
137
142
  flags,
@@ -17,4 +17,5 @@ export interface OpenerCommand {
17
17
  }
18
18
  export declare function browserCommand(url: string, platform?: string): OpenerCommand;
19
19
  export declare function openBrowser(url: string, opener?: OpenerCommand): Promise<boolean>;
20
+ export declare function deviceLabel(): string;
20
21
  export declare function login(flags?: Flags): Promise<void>;
@@ -10,11 +10,13 @@
10
10
  // `--mock` still runs the whole browser UX against an in-process fake of the
11
11
  // hosted login page (real loopback server, real PKCE, fake identity, persists
12
12
  // nothing) for offline UX previews and demos.
13
+ import { hq } from '@myapihq/sdk';
13
14
  import * as http from 'http';
15
+ import * as os from 'os';
14
16
  import * as crypto from 'crypto';
15
17
  import { spawn } from 'child_process';
16
18
  import { info, success, error } from '../output.js';
17
- import { addAccount } from '../config.js';
19
+ import { addAccount, loadConfig } from '../config.js';
18
20
  export const EXPOSES = [
19
21
  'POST /hq/account/exchange',
20
22
  ];
@@ -341,12 +343,62 @@ async function exchangeCodeForTokens(code, verifier, redirectUri) {
341
343
  }
342
344
  return data.id_token;
343
345
  }
346
+ // A stable, human-meaningful label for this machine. Signing in on four
347
+ // machines used to leave four indistinguishable account-wide keys with no way
348
+ // to tell which was which — this is the half that makes `keys list`
349
+ // actionable.
350
+ export function deviceLabel() {
351
+ try {
352
+ return os.hostname() || 'cli';
353
+ }
354
+ catch {
355
+ return 'cli';
356
+ }
357
+ }
358
+ // Ids of keys already named for THIS device. Best-effort: if we have no
359
+ // usable key yet (first sign-in on this machine) there is nothing to
360
+ // supersede, and a failed lookup must never block signing in.
361
+ async function priorKeysForThisDevice() {
362
+ const label = `cli@${deviceLabel()}`;
363
+ try {
364
+ const cfg = loadConfig();
365
+ if (!cfg?.api_key)
366
+ return [];
367
+ const keys = await hq.listApiKeys(cfg.api_key);
368
+ return keys.filter(k => k.name === label).map(k => k.id);
369
+ }
370
+ catch {
371
+ return [];
372
+ }
373
+ }
374
+ // Revoke the keys this device superseded. Runs AFTER the new key is saved, so
375
+ // a failure here leaves the user signed in with a working key and one extra
376
+ // stale key — the safe direction. Never fatal.
377
+ async function revokeSuperseded(newKey, ids) {
378
+ if (ids.length === 0)
379
+ return;
380
+ let revoked = 0;
381
+ for (const id of ids) {
382
+ try {
383
+ await hq.revokeApiKey(newKey, id);
384
+ revoked++;
385
+ }
386
+ catch { /* leave it listed */ }
387
+ }
388
+ if (revoked > 0) {
389
+ info(` Replaced ${revoked} previous key${revoked === 1 ? '' : 's'} for this device (cli@${deviceLabel()})`);
390
+ }
391
+ }
344
392
  // exchangeTokenForKey trades the verified id_token for an hq API key + defaults.
345
393
  async function exchangeTokenForKey(idToken) {
346
394
  const r = await fetch(`${hqBase()}/hq/account/exchange`, {
347
395
  method: 'POST',
348
396
  headers: { 'content-type': 'application/json' },
349
- body: JSON.stringify({ id_token: idToken }),
397
+ // `device` names the minted key `cli@<device>` so `keys list` says which
398
+ // machine each key belongs to. Deterministic per machine, which is what
399
+ // lets us revoke the key this device is replacing (below). Backend
400
+ // sanitises it and falls back to plain `cli` when absent.
401
+ body: JSON.stringify({ id_token: idToken, device: deviceLabel() }),
350
402
  signal: AbortSignal.timeout(30_000),
351
403
  });
352
404
  const env = await r.json().catch(() => ({}));
@@ -406,6 +458,13 @@ export async function login(flags = {}) {
406
458
  info('');
407
459
  error('Timed out waiting for the browser sign-in (3 minutes). Run: myapi login');
408
460
  }, CALLBACK_TIMEOUT_MS);
461
+ // Snapshot the keys this device already owns BEFORE signing in. Taking the
462
+ // list first is what makes the revoke safe: the key we are about to mint
463
+ // cannot be in a set captured before it existed, so there is no way to
464
+ // revoke the credential we just saved. (Matching afterwards would mean
465
+ // identifying the new key, and `prefix` is not derivable from the key
466
+ // string — a wrong guess here locks someone out of their own account.)
467
+ const supersededKeyIds = await priorKeysForThisDevice();
409
468
  let auth;
410
469
  let email = '';
411
470
  try {
@@ -430,6 +489,7 @@ export async function login(flags = {}) {
430
489
  default_org: auth.default_org || undefined,
431
490
  default_funnel: auth.default_funnel || undefined,
432
491
  });
492
+ await revokeSuperseded(auth.api_key, supersededKeyIds);
433
493
  success(`› Signed in${email ? ` · ${email}` : ''}`);
434
494
  info(` Account: ${auth.account_id}`);
435
495
  if (auth.default_org)
@@ -1,6 +1,6 @@
1
1
  import { payments as sdkPayments } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../config.js';
3
- import { success, error, printTable, info, printJson } from '../output.js';
3
+ import { success, error, printTable, info, printJson, banner } from '../output.js';
4
4
  import { formatDate } from '../utils.js';
5
5
  import { requireOrg } from '../helpers.js';
6
6
  export const EXPOSES = [
@@ -111,7 +111,11 @@ export async function charge(flags) {
111
111
  payload.success_url = flags['success-url'];
112
112
  if (flags['cancel-url'])
113
113
  payload.cancel_url = flags['cancel-url'];
114
- const res = await sdkPayments.createCharge(config.api_key, orgId, payload);
114
+ // See billing.ts a replayed charge did not execute a second time.
115
+ let replayed = false;
116
+ const res = await sdkPayments.createCharge(config.api_key, orgId, payload, { onReplay: () => { replayed = true; } });
117
+ if (replayed)
118
+ banner('› This was a replay of an identical earlier charge — the customer was charged once, not twice.');
115
119
  if (flags.json) {
116
120
  printJson(res);
117
121
  return;
@@ -1,5 +1,5 @@
1
1
  import type { Exposes } from '../exposes.js';
2
- import type { Flags } from '../helpers.js';
2
+ import { type Flags } from '../helpers.js';
3
3
  import type { FlagSchema } from '../flags.js';
4
4
  export declare const SCHEMA: FlagSchema;
5
5
  export declare const EXPOSES: Exposes;
@@ -8,4 +8,5 @@ export declare function visits(flags: Flags): Promise<void>;
8
8
  export declare function events(flags: Flags): Promise<void>;
9
9
  export declare function audience(flags: Flags): Promise<void>;
10
10
  export declare function identity(pixelId: string, flags: Flags): Promise<void>;
11
+ export declare function identify(pixelId: string | undefined, flags: Flags): Promise<void>;
11
12
  export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;
@@ -1,7 +1,9 @@
1
1
  import { pixel as sdkPixel } from '@myapihq/sdk';
2
2
  import { requireConfig } from '../config.js';
3
- import { error, printTable, info, printJson } from '../output.js';
3
+ import { success, error, printTable, info, printJson } from '../output.js';
4
+ import { requireOrg } from '../helpers.js';
4
5
  export const SCHEMA = {
6
+ 'external-id': 'string',
5
7
  org: 'string',
6
8
  website: 'string',
7
9
  'campaign-id': 'string',
@@ -12,6 +14,7 @@ export const SCHEMA = {
12
14
  offset: 'number',
13
15
  };
14
16
  export const EXPOSES = [
17
+ 'POST /pixel/orgs/{org_id}/identify',
15
18
  'GET /pixel/orgs/{org_id}/interactions',
16
19
  'GET /pixel/orgs/{org_id}/visits',
17
20
  'GET /pixel/orgs/{org_id}/events',
@@ -151,6 +154,28 @@ const SUBCOMMAND_USAGE = {
151
154
  'audience': 'myapi pixel audience [--org <id>]',
152
155
  'identity': 'myapi pixel identity <pixel_id> --website <domain> [--org <id>]',
153
156
  };
157
+ // identify — the WRITE half of identity resolution.
158
+ //
159
+ // `identity` reads the graph; without a call to this, that graph only ever
160
+ // contains anonymous nodes. Call it on form submit or right after sign-in and
161
+ // the visitor's whole prior anonymous history resolves to that person.
162
+ export async function identify(pixelId, flags) {
163
+ const config = requireConfig();
164
+ const orgId = requireOrg(flags, config, 'myapi pixel identify <pixel_id> (--email <e> | --external-id <id>) [--org <id>]');
165
+ if (!pixelId)
166
+ error('Missing pixel_id.\nUsage: myapi pixel identify <pixel_id> --email <email>\n\n→ The pixel id comes from `myapi pixel visits` or `pixel interactions`.');
167
+ const email = typeof flags.email === 'string' ? flags.email : undefined;
168
+ const externalId = typeof flags['external-id'] === 'string' ? flags['external-id'] : undefined;
169
+ if (!email && !externalId) {
170
+ error('Nothing to identify with.\nPass --email <address> or --external-id <your-user-id>.');
171
+ }
172
+ const res = await sdkPixel.identify(config.api_key, orgId, pixelId, { email, externalId });
173
+ if (flags.json) {
174
+ printJson(res);
175
+ return;
176
+ }
177
+ success(`Linked pixel ${pixelId} to ${email ?? externalId}`);
178
+ }
154
179
  export async function run(subcommand, args, flags) {
155
180
  if (!subcommand || (flags.help && !subcommand)) {
156
181
  info(`Usage: myapi pixel <subcommand>
@@ -158,6 +183,7 @@ export async function run(subcommand, args, flags) {
158
183
  Subcommands:
159
184
  audience Geographic distribution sample of your pixel audience
160
185
  events Engagement events (sent / open / click / page_visit) by campaign or domain
186
+ identify Link a known email/user id to an anonymous pixel visitor
161
187
  identity Resolve the identity graph (emails, IPs, profiles) for a pixel ID
162
188
  interactions Get a unified timeline of visits and events
163
189
  (requires at least one filter: --website, --campaign-id, or --domain)
@@ -180,6 +206,7 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
180
206
  case 'events': return events(flags);
181
207
  case 'audience': return audience(flags);
182
208
  case 'identity': return identity(args[0], flags);
209
+ case 'identify': return identify(args[0], flags);
183
210
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi pixel --help" for a list of valid subcommands.`);
184
211
  }
185
212
  }
@@ -8,7 +8,7 @@ export declare function _parseDependsOn(raw: unknown): string[] | undefined;
8
8
  export declare function create(name: string, flags: Flags): Promise<void>;
9
9
  export declare function list(flags: Flags): Promise<void>;
10
10
  export declare function get(name: string, flags: Flags): Promise<void>;
11
- export declare function enqueue(name: string, flags: Flags): Promise<void>;
11
+ export declare function enqueue(name: string, flags: Flags, extraArgs?: string[]): Promise<void>;
12
12
  export declare function jobs(name: string, flags: Flags): Promise<void>;
13
13
  export declare function job(jobId: string, flags: Flags): Promise<void>;
14
14
  export declare function del(name: string, flags: Flags): Promise<void>;
@@ -101,10 +101,23 @@ export async function get(name, flags) {
101
101
  info(`Max concurrency: ${q.max_concurrency}`);
102
102
  }
103
103
  // ── Jobs ─────────────────────────────────────────────────────────────────────
104
- export async function enqueue(name, flags) {
104
+ export async function enqueue(name, flags, extraArgs = []) {
105
105
  const config = requireConfig();
106
106
  const orgId = requireOrg(flags, config, 'myapi queue enqueue <name> --payload <json> [--org <id>]');
107
107
  requireArg(name, 'name', 'myapi queue enqueue <name> --payload <json>');
108
+ // The payload is a flag, not a positional. Passing it positionally —
109
+ // `queue enqueue fulfilment '{"order":1}'` — used to be discarded in
110
+ // silence, and the command still reported "✓ Job enqueued", leaving a job
111
+ // with an empty payload behind. Refuse rather than warn: a job that ran
112
+ // with no payload is worse than one that never ran, and the caller
113
+ // demonstrably meant to send data.
114
+ if (extraArgs.length > 0) {
115
+ const looksJson = extraArgs[0].trim().startsWith('{') || extraArgs[0].trim().startsWith('[');
116
+ error(`Unexpected extra argument "${extraArgs[0].slice(0, 40)}${extraArgs[0].length > 40 ? '…' : ''}".` +
117
+ (looksJson
118
+ ? `\nThe payload is a flag, not a positional: myapi queue enqueue ${name} --payload '<json>'`
119
+ : `\nUsage: myapi queue enqueue <name> --payload <json>`));
120
+ }
108
121
  let payload;
109
122
  if (typeof flags.payload === 'string') {
110
123
  try {
@@ -219,7 +232,7 @@ All commands accept --org <id> (or set default: myapi config set-org <id>).`);
219
232
  case 'list': return list(flags);
220
233
  case 'get': return get(args[0], flags);
221
234
  case 'delete': return del(args[0], flags);
222
- case 'enqueue': return enqueue(args[0], flags);
235
+ case 'enqueue': return enqueue(args[0], flags, args.slice(1));
223
236
  case 'jobs': return jobs(args[0], flags);
224
237
  case 'job': return job(args[0], flags);
225
238
  default: error(`Unknown subcommand: ${subcommand}. Run "myapi queue --help" for a list of valid subcommands.`);
@@ -2,7 +2,7 @@ import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import * as crypto from 'crypto';
5
- import { loadConfig, saveConfig, addAccount, loadFullConfig } from '../config.js';
5
+ import { loadConfig, saveConfig, addAccount, loadFullConfig, CONFIG_FILE } from '../config.js';
6
6
  import { info, success, banner } from '../output.js';
7
7
  import { ask, confirm } from '../prompt.js';
8
8
  import { hq, funnel as sdkFunnel } from '@myapihq/sdk';
@@ -350,7 +350,11 @@ export async function setup(flags = {}) {
350
350
  default_funnel: defaultFunnel,
351
351
  is_anonymous: isAnonymous,
352
352
  }, wantsSkills);
353
- success(`› Saved to ~/.myapi/config.json`);
353
+ // Report where the config ACTUALLY went. This was hardcoded to
354
+ // `~/.myapi/config.json`, which became a lie the moment MYAPI_CONFIG_DIR
355
+ // shipped — a caller running isolated was told its config had landed in the
356
+ // operator's home directory.
357
+ success(`› Saved to ${CONFIG_FILE}`);
354
358
  // Validate key and ensure org/funnel defaults are still correct.
355
359
  // (resolveDefaults is non-fatal if the API call fails.)
356
360
  try {
@@ -18,6 +18,7 @@ export const EXPOSES = [
18
18
  export const SCHEMA = {
19
19
  org: 'string',
20
20
  name: 'string',
21
+ 'content-type': 'string',
21
22
  };
22
23
  // Mirrors UploadContentType in @myapihq/sdk + backend allowlist.
23
24
  const EXT_TO_CT = {
@@ -63,10 +64,20 @@ async function upload(filePath, flags) {
63
64
  const config = requireConfig();
64
65
  const orgId = requireOrg(flags, config, 'myapi storage upload <file> [--name <name>] [--org <id>]');
65
66
  requireArg(filePath, 'file', 'myapi storage upload <file> [--name <name>] [--org <id>]');
67
+ // The extension map is a CLI-side convenience for inferring Content-Type,
68
+ // NOT the API's allowlist — a user found the API happily accepting a CSV the
69
+ // CLI refused. Refusing on the client's behalf for something the server
70
+ // would take is the CLI overreaching, so --content-type is the escape hatch
71
+ // and the message says the limit is ours.
66
72
  const ext = extname(filePath).toLowerCase();
67
- const contentType = EXT_TO_CT[ext];
73
+ const override = typeof flags['content-type'] === 'string' ? flags['content-type'] : undefined;
74
+ const contentType = override ?? EXT_TO_CT[ext];
68
75
  if (!contentType) {
69
- error(`Unsupported file extension "${ext}". Supported: ${Object.keys(EXT_TO_CT).join(', ')}.\nFor other types, host the file and use "myapi storage ingest <url>".`);
76
+ error(`Can't infer a content type for "${ext}".\n` +
77
+ `Known here: ${Object.keys(EXT_TO_CT).join(', ')}.\n\n` +
78
+ `→ This list is the CLI's, not the API's — the API may well accept your file.\n` +
79
+ ` Force it: myapi storage upload ${filePath} --content-type <mime>\n` +
80
+ ` Or host it and use: myapi storage ingest <url>`);
70
81
  }
71
82
  let data;
72
83
  try {
@@ -9,4 +9,5 @@ export declare function update(id: string, flags: Flags): Promise<void>;
9
9
  export declare function _validateForwardUrl(url: string): string | null;
10
10
  export declare function del(id: string, flags: Flags): Promise<void>;
11
11
  export declare function delivery(id: string, flags: Flags): Promise<void>;
12
+ export declare function deliveries(endpointArg: string | undefined, flags: Flags): Promise<void>;
12
13
  export declare function run(subcommand: string | undefined, args: string[], flags: Flags): Promise<void>;