@myapihq/cli 2.4.0 → 2.4.2

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 (92) hide show
  1. package/dist/commands/crm/companies.js +1 -5
  2. package/dist/commands/crm/contacts.js +1 -6
  3. package/dist/commands/domain-validation.test.d.ts +1 -0
  4. package/dist/commands/domain-validation.test.js +36 -0
  5. package/dist/commands/domain.d.ts +14 -0
  6. package/dist/commands/domain.js +20 -1
  7. package/dist/commands/funnel-validation.test.d.ts +1 -0
  8. package/dist/commands/funnel-validation.test.js +86 -0
  9. package/dist/commands/funnel.d.ts +12 -0
  10. package/dist/commands/funnel.js +59 -15
  11. package/dist/commands/login-validation.test.js +24 -2
  12. package/dist/commands/login.d.ts +6 -0
  13. package/dist/commands/login.js +48 -17
  14. package/dist/commands/payments.js +12 -2
  15. package/dist/commands/setup.d.ts +1 -0
  16. package/dist/commands/setup.js +53 -4
  17. package/dist/commands/skill-checksum.test.d.ts +1 -0
  18. package/dist/commands/skill-checksum.test.js +48 -0
  19. package/dist/config-envkey.test.d.ts +1 -0
  20. package/dist/config-envkey.test.js +145 -0
  21. package/dist/config.js +41 -5
  22. package/dist/errors.js +11 -0
  23. package/dist/index.js +6 -1
  24. package/dist/sdk-transport.test.d.ts +1 -0
  25. package/dist/sdk-transport.test.js +138 -0
  26. package/dist/sdk-version.test.d.ts +1 -0
  27. package/dist/sdk-version.test.js +24 -0
  28. package/dist/skills/my-api-hq/SKILL.md +1 -1
  29. package/dist/skills/my-api-hq/claude/.claude-plugin/plugin.json +1 -0
  30. package/dist/skills/my-audience-api/README.md +40 -0
  31. package/dist/skills/my-audience-api/SKILL.md +139 -0
  32. package/dist/skills/my-audience-api/claude/.claude-plugin/plugin.json +7 -0
  33. package/dist/skills/my-audience-api/openapi/.gitkeep +0 -0
  34. package/dist/skills/my-auth-api/SKILL.md +1 -1
  35. package/dist/skills/my-auth-api/claude/.claude-plugin/plugin.json +1 -0
  36. package/dist/skills/my-company-api/README.md +35 -0
  37. package/dist/skills/my-company-api/SKILL.md +110 -0
  38. package/dist/skills/my-company-api/claude/.claude-plugin/plugin.json +7 -0
  39. package/dist/skills/my-company-api/openapi/.gitkeep +0 -0
  40. package/dist/skills/my-container-api/README.md +34 -0
  41. package/dist/skills/my-container-api/SKILL.md +77 -0
  42. package/dist/skills/my-container-api/claude/.claude-plugin/plugin.json +7 -0
  43. package/dist/skills/my-container-api/openapi/.gitkeep +0 -0
  44. package/dist/skills/my-crm-api/SKILL.md +3 -3
  45. package/dist/skills/my-crm-api/claude/.claude-plugin/plugin.json +1 -0
  46. package/dist/skills/my-database-api/SKILL.md +1 -1
  47. package/dist/skills/my-database-api/claude/.claude-plugin/plugin.json +1 -0
  48. package/dist/skills/my-domain-api/SKILL.md +1 -1
  49. package/dist/skills/my-domain-api/claude/.claude-plugin/plugin.json +1 -0
  50. package/dist/skills/my-email-api/SKILL.md +1 -1
  51. package/dist/skills/my-email-api/claude/.claude-plugin/plugin.json +1 -0
  52. package/dist/skills/my-email-verify-api/SKILL.md +1 -1
  53. package/dist/skills/my-email-verify-api/claude/.claude-plugin/plugin.json +1 -0
  54. package/dist/skills/my-function-api/SKILL.md +104 -0
  55. package/dist/skills/my-function-api/claude/.claude-plugin/plugin.json +7 -0
  56. package/dist/skills/my-funnel-api/SKILL.md +2 -2
  57. package/dist/skills/my-funnel-api/claude/.claude-plugin/plugin.json +1 -0
  58. package/dist/skills/my-git-api/SKILL.md +1 -1
  59. package/dist/skills/my-git-api/claude/.claude-plugin/plugin.json +1 -0
  60. package/dist/skills/my-image-api/SKILL.md +1 -1
  61. package/dist/skills/my-image-api/claude/.claude-plugin/plugin.json +1 -0
  62. package/dist/skills/my-llm-api/SKILL.md +6 -11
  63. package/dist/skills/my-llm-api/claude/.claude-plugin/plugin.json +1 -0
  64. package/dist/skills/my-payments-api/SKILL.md +85 -0
  65. package/dist/skills/my-payments-api/claude/.claude-plugin/plugin.json +7 -0
  66. package/dist/skills/my-people-api/README.md +35 -0
  67. package/dist/skills/my-people-api/SKILL.md +107 -0
  68. package/dist/skills/my-people-api/claude/.claude-plugin/plugin.json +7 -0
  69. package/dist/skills/my-people-api/openapi/.gitkeep +0 -0
  70. package/dist/skills/my-pixel-api/README.md +38 -0
  71. package/dist/skills/my-pixel-api/SKILL.md +90 -0
  72. package/dist/skills/my-pixel-api/claude/.claude-plugin/plugin.json +7 -0
  73. package/dist/skills/my-pixel-api/openapi/.gitkeep +0 -0
  74. package/dist/skills/my-queue-api/README.md +34 -0
  75. package/dist/skills/my-queue-api/SKILL.md +78 -0
  76. package/dist/skills/my-queue-api/claude/.claude-plugin/plugin.json +7 -0
  77. package/dist/skills/my-queue-api/openapi/.gitkeep +0 -0
  78. package/dist/skills/my-storage-api/SKILL.md +1 -1
  79. package/dist/skills/my-storage-api/claude/.claude-plugin/plugin.json +1 -0
  80. package/dist/skills/my-task-api/README.md +36 -0
  81. package/dist/skills/my-task-api/SKILL.md +74 -0
  82. package/dist/skills/my-task-api/claude/.claude-plugin/plugin.json +7 -0
  83. package/dist/skills/my-task-api/openapi/.gitkeep +0 -0
  84. package/dist/skills/my-url-to/README.md +32 -0
  85. package/dist/skills/my-url-to/SKILL.md +55 -0
  86. package/dist/skills/my-url-to/claude/.claude-plugin/plugin.json +7 -0
  87. package/dist/skills/my-url-to/openapi/.gitkeep +0 -0
  88. package/dist/skills/my-webhook-api/SKILL.md +1 -1
  89. package/dist/skills/my-webhook-api/claude/.claude-plugin/plugin.json +1 -0
  90. package/dist/skills/my-workflow-api/SKILL.md +1 -1
  91. package/dist/skills/my-workflow-api/claude/.claude-plugin/plugin.json +1 -0
  92. package/package.json +8 -2
@@ -5,10 +5,6 @@ import { success, error, info, printTable, printJson } from '../../output.js';
5
5
  import { requireOrg, requireArg } from '../../helpers.js';
6
6
  export const EXPOSES = [
7
7
  'POST /crm/orgs/{org_id}/companies',
8
- // Backend (2026-06-06): bare GET list with query-param filters —
9
- // companion to the POST /search verb. See contacts.ts for the same
10
- // pattern.
11
- 'GET /crm/orgs/{org_id}/companies',
12
8
  'POST /crm/orgs/{org_id}/companies/promote',
13
9
  'POST /crm/orgs/{org_id}/companies/search',
14
10
  'GET /crm/orgs/{org_id}/companies/{id}',
@@ -125,7 +121,7 @@ async function restore(id, flags) {
125
121
  const config = requireConfig();
126
122
  const orgId = requireOrg(flags, config, 'myapi crm companies restore <id> [--org <id>]');
127
123
  requireArg(id, 'id', 'myapi crm companies restore <id>');
128
- const c = await crm.updateCompany(config.api_key, orgId, id, { deleted_at: null });
124
+ const c = await crm.restoreCompany(config.api_key, orgId, id);
129
125
  if (flags.json) {
130
126
  printJson(c);
131
127
  return;
@@ -5,11 +5,6 @@ import { success, error, info, printTable, printJson } from '../../output.js';
5
5
  import { requireOrg, requireArg } from '../../helpers.js';
6
6
  export const EXPOSES = [
7
7
  'POST /crm/orgs/{org_id}/contacts',
8
- // Backend (2026-06-06): bare GET list with query-param filters —
9
- // companion to the POST /search verb (richer filters via body). The
10
- // CLI `list` verb still uses /search for forward-compat; the bare GET
11
- // is declared so it's covered.
12
- 'GET /crm/orgs/{org_id}/contacts',
13
8
  'POST /crm/orgs/{org_id}/contacts/promote',
14
9
  'POST /crm/orgs/{org_id}/contacts/search',
15
10
  'GET /crm/orgs/{org_id}/contacts/{id}',
@@ -138,7 +133,7 @@ async function restore(id, flags) {
138
133
  const config = requireConfig();
139
134
  const orgId = requireOrg(flags, config, 'myapi crm contacts restore <id> [--org <id>]');
140
135
  requireArg(id, 'id', 'myapi crm contacts restore <id>');
141
- const c = await crm.updateContact(config.api_key, orgId, id, { deleted_at: null });
136
+ const c = await crm.restoreContact(config.api_key, orgId, id);
142
137
  if (flags.json) {
143
138
  printJson(c);
144
139
  return;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ // Unit tests for the domain CLI's reassignment guard.
2
+ //
3
+ // `domain assign` is also the reassign path: run it against a domain that is
4
+ // currently bound to another org and it silently MOVES the domain — taking the
5
+ // old org's site offline. That has happened in production. domain.ts was the
6
+ // largest command module in the CLI with no command-level tests; this is the
7
+ // decision that matters most in it.
8
+ import { describe, it, expect } from 'vitest';
9
+ import { _shouldRefuseReassign } from './domain.js';
10
+ const ORG_A = 'org-aaaa';
11
+ const ORG_B = 'org-bbbb';
12
+ describe('_shouldRefuseReassign', () => {
13
+ it('refuses when the domain belongs to a different org', () => {
14
+ expect(_shouldRefuseReassign({ org_id: ORG_B }, ORG_A, false)).toBe(true);
15
+ });
16
+ it('allows a first-time assign — domain not in any org yet', () => {
17
+ // The pre-flight look-up finds nothing; blocking here would make every
18
+ // initial assign impossible.
19
+ expect(_shouldRefuseReassign(undefined, ORG_A, false)).toBe(false);
20
+ });
21
+ it('allows a re-assign to the org that already owns it (idempotent)', () => {
22
+ expect(_shouldRefuseReassign({ org_id: ORG_A }, ORG_A, false)).toBe(false);
23
+ });
24
+ it('allows a record with no org_id — registered but unassigned', () => {
25
+ expect(_shouldRefuseReassign({}, ORG_A, false)).toBe(false);
26
+ expect(_shouldRefuseReassign({ org_id: undefined }, ORG_A, false)).toBe(false);
27
+ expect(_shouldRefuseReassign({ org_id: '' }, ORG_A, false)).toBe(false);
28
+ // The wire shape for an unassigned domain: an explicit null, not a
29
+ // missing field. Treating null as "owned by someone else" would block
30
+ // every assign onto a freshly registered domain.
31
+ expect(_shouldRefuseReassign({ org_id: null }, ORG_A, false)).toBe(false);
32
+ });
33
+ it('--force bypasses the guard — the documented way to move a domain', () => {
34
+ expect(_shouldRefuseReassign({ org_id: ORG_B }, ORG_A, true)).toBe(false);
35
+ });
36
+ });
@@ -8,6 +8,20 @@ export declare function register(domainArg: string, flags: Flags): Promise<void>
8
8
  export declare function importCmd(domainArg: string, flags: Flags): Promise<void>;
9
9
  export declare function renew(domainArg: string, flags: Flags): Promise<void>;
10
10
  export declare function list(flags: Flags): Promise<void>;
11
+ /**
12
+ * The `domain assign` reassignment decision, isolated from the I/O around it.
13
+ * True means: this assign would MOVE the domain away from another org, so
14
+ * refuse unless --force.
15
+ *
16
+ * Exported for tests. Assign is also the reassign path — a bare assign against
17
+ * a domain already bound elsewhere silently moves it, which has bitten us in
18
+ * production. The exact shape of `current` matters: an undefined record (domain
19
+ * not in the org's list) and a record already owned by the target org must both
20
+ * pass, or every first-time assign would be blocked.
21
+ */
22
+ export declare function _shouldRefuseReassign(current: {
23
+ org_id?: string | null;
24
+ } | undefined, targetOrgId: string, force: boolean): boolean;
11
25
  export declare function assign(domainArg: string, flags: Flags): Promise<void>;
12
26
  export declare function unassign(domainArg: string, flags: Flags): Promise<void>;
13
27
  export declare function status(domainArg: string, flags: Flags): Promise<void>;
@@ -202,6 +202,25 @@ function summarizeDomain(d) {
202
202
  const { org_id, ...rest } = d;
203
203
  return { ...rest, assigned_to_org: org_id || '(unassigned)' };
204
204
  }
205
+ /**
206
+ * The `domain assign` reassignment decision, isolated from the I/O around it.
207
+ * True means: this assign would MOVE the domain away from another org, so
208
+ * refuse unless --force.
209
+ *
210
+ * Exported for tests. Assign is also the reassign path — a bare assign against
211
+ * a domain already bound elsewhere silently moves it, which has bitten us in
212
+ * production. The exact shape of `current` matters: an undefined record (domain
213
+ * not in the org's list) and a record already owned by the target org must both
214
+ * pass, or every first-time assign would be blocked.
215
+ */
216
+ export function _shouldRefuseReassign(
217
+ // org_id is nullable on the wire: a registered-but-unassigned domain comes
218
+ // back with an explicit null, not a missing field.
219
+ current, targetOrgId, force) {
220
+ if (force)
221
+ return false;
222
+ return !!current?.org_id && current.org_id !== targetOrgId;
223
+ }
205
224
  export async function assign(domainArg, flags) {
206
225
  const config = requireConfig();
207
226
  const orgId = requireOrg(flags, config, 'myapi domain assign <domain> [--no-www] [--org <id>]');
@@ -220,7 +239,7 @@ export async function assign(domainArg, flags) {
220
239
  catch (e) {
221
240
  error(`Could not verify ${domainArg}'s current assignment (${e instanceof Error ? e.message : e}).\nRefusing to assign blind — retry, or pass --force to skip this check.`);
222
241
  }
223
- if (current?.org_id && current.org_id !== orgId) {
242
+ if (_shouldRefuseReassign(current, orgId, false)) {
224
243
  error(`${domainArg} is currently assigned to org ${current.org_id}.\nAssigning it to org ${orgId} would MOVE it. Re-run with --force to reassign.`);
225
244
  }
226
245
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ // Unit tests for the funnel CLI's overwrite guard.
2
+ //
3
+ // funnel.ts is the largest command module in the CLI and owned the most
4
+ // destructive operation in it — `push` replaces whatever page is live at a
5
+ // slug, with no undo — while having no command-level tests at all. The guard
6
+ // below is the only thing standing between an agent and an org's live
7
+ // homepage, so it gets tested directly rather than through --help smoke.
8
+ import { describe, it, expect, afterEach } from 'vitest';
9
+ import { _slugKey, _findOverwriteClash, formSubmitBase } from './funnel.js';
10
+ describe('formSubmitBase', () => {
11
+ const saved = { f: process.env.MYAPI_FUNNEL_URL, b: process.env.MYAPI_API_BASE };
12
+ afterEach(() => {
13
+ if (saved.f === undefined)
14
+ delete process.env.MYAPI_FUNNEL_URL;
15
+ else
16
+ process.env.MYAPI_FUNNEL_URL = saved.f;
17
+ if (saved.b === undefined)
18
+ delete process.env.MYAPI_API_BASE;
19
+ else
20
+ process.env.MYAPI_API_BASE = saved.b;
21
+ });
22
+ it('defaults to the gateway — the host published pages POST to today', () => {
23
+ delete process.env.MYAPI_FUNNEL_URL;
24
+ delete process.env.MYAPI_API_BASE;
25
+ expect(formSubmitBase()).toBe('https://api.myapihq.com');
26
+ });
27
+ it('honors MYAPI_FUNNEL_URL, so a local build stops emitting a prod form action', () => {
28
+ // The actual bug: this host was a literal, so `funnel form` against a
29
+ // local backend generated a form that posted to production.
30
+ process.env.MYAPI_FUNNEL_URL = 'http://localhost:8080';
31
+ expect(formSubmitBase()).toBe('http://localhost:8080');
32
+ });
33
+ it('falls back to MYAPI_API_BASE', () => {
34
+ delete process.env.MYAPI_FUNNEL_URL;
35
+ process.env.MYAPI_API_BASE = 'http://localhost:9090';
36
+ expect(formSubmitBase()).toBe('http://localhost:9090');
37
+ });
38
+ });
39
+ describe('_slugKey', () => {
40
+ it('treats every wire shape of the homepage as the same page', () => {
41
+ // The backend has returned all of these for the root page.
42
+ expect(_slugKey('/')).toBe('');
43
+ expect(_slugKey('')).toBe('');
44
+ });
45
+ it('normalizes leading slash and trailing slashes', () => {
46
+ expect(_slugKey('/about')).toBe('about');
47
+ expect(_slugKey('about')).toBe('about');
48
+ expect(_slugKey('/about/')).toBe('about');
49
+ expect(_slugKey('about///')).toBe('about');
50
+ });
51
+ it('keeps distinct paths distinct', () => {
52
+ expect(_slugKey('/about')).not.toBe(_slugKey('/about-us'));
53
+ expect(_slugKey('/a/b')).toBe('a/b');
54
+ });
55
+ });
56
+ describe('_findOverwriteClash', () => {
57
+ const pages = [
58
+ { slug: '/', updated_at: '2026-07-01T00:00:00Z' },
59
+ { slug: '/pricing', updated_at: '2026-07-02T00:00:00Z' },
60
+ ];
61
+ it('refuses a push onto an existing page', () => {
62
+ expect(_findOverwriteClash(pages, '/pricing', false)).toEqual(pages[1]);
63
+ });
64
+ it('refuses the homepage case — the one that destroys a live site', () => {
65
+ expect(_findOverwriteClash(pages, '/', false)).toEqual(pages[0]);
66
+ expect(_findOverwriteClash(pages, '', false)).toEqual(pages[0]);
67
+ });
68
+ it('matches across slash shapes, so a typo cannot slip past the guard', () => {
69
+ expect(_findOverwriteClash(pages, 'pricing', false)).toEqual(pages[1]);
70
+ expect(_findOverwriteClash(pages, '/pricing/', false)).toEqual(pages[1]);
71
+ });
72
+ it('allows a push to a genuinely new slug', () => {
73
+ expect(_findOverwriteClash(pages, '/about', false)).toBeUndefined();
74
+ });
75
+ it('allows any push on an empty funnel', () => {
76
+ expect(_findOverwriteClash([], '/', false)).toBeUndefined();
77
+ });
78
+ it('does not confuse a prefix for a match', () => {
79
+ expect(_findOverwriteClash(pages, '/pricing-2', false)).toBeUndefined();
80
+ expect(_findOverwriteClash([{ slug: '/a/b' }], '/a', false)).toBeUndefined();
81
+ });
82
+ it('--force bypasses the guard — that is the documented escape hatch', () => {
83
+ expect(_findOverwriteClash(pages, '/pricing', true)).toBeUndefined();
84
+ expect(_findOverwriteClash(pages, '/', true)).toBeUndefined();
85
+ });
86
+ });
@@ -3,6 +3,18 @@ import { type Flags } from '../helpers.js';
3
3
  import type { Exposes } from '../exposes.js';
4
4
  export declare const EXPOSES: Exposes;
5
5
  export declare const SCHEMA: FlagSchema;
6
+ export declare function formSubmitBase(): string;
7
+ export declare function _slugKey(s: string): string;
8
+ /**
9
+ * The `funnel push` overwrite decision, isolated from the I/O around it.
10
+ * Returns the clashing page when the push must be refused, else undefined.
11
+ *
12
+ * Exported for tests rather than inlined because this is the single call that
13
+ * stands between an agent and silently replacing an org's live homepage.
14
+ */
15
+ export declare function _findOverwriteClash<T extends {
16
+ slug: string;
17
+ }>(existingPages: readonly T[], targetSlug: string, force: boolean): T | undefined;
6
18
  export declare function list(flags: Flags): Promise<void>;
7
19
  export declare function create(flags: Flags): Promise<void>;
8
20
  export declare function get(id: string, flags: Flags): Promise<void>;
@@ -45,12 +45,46 @@ function validateFunnelName(name) {
45
45
  error(`Invalid --name "${name}". Lowercase letters, digits, hyphens; 1-50 chars; starts with a letter or digit.`);
46
46
  }
47
47
  }
48
+ // Host for the form-action URL baked into the generated snippet.
49
+ //
50
+ // This one is NOT the SDK's FUNNEL_BASE, deliberately. The URL is embedded in
51
+ // a published page and POSTed to by a visitor's browser, so it has to be a
52
+ // host that serves the public submit route with the right CORS — today that's
53
+ // the gateway, and moving it to api.myfunnelapi.com is an outward-facing
54
+ // change that can only be verified against prod. What was actually broken is
55
+ // the override: the host was a hardcoded literal, so a funnel built against a
56
+ // local backend emitted a form that posted to production. MYAPI_FUNNEL_URL
57
+ // (the same var the SDK reads) now wins when set, matching how storage.ts and
58
+ // login.ts mirror their bases.
59
+ //
60
+ // Exported for tests.
61
+ export function formSubmitBase() {
62
+ return process.env.MYAPI_FUNNEL_URL
63
+ ?? process.env.MYAPI_API_BASE
64
+ ?? 'https://api.myapihq.com';
65
+ }
48
66
  // Canonical key for comparing slugs across the wire shapes the backend may
49
67
  // return (`/about`, `about`, `/about/`). Home is the empty string whether it
50
68
  // arrives as `/` or ``.
51
- function slugKey(s) {
69
+ // Exported for tests: this normalization IS the overwrite guard. If it
70
+ // under-matches, `push /about` sails past a live `about` page and overwrites
71
+ // it without ever showing the refusal.
72
+ export function _slugKey(s) {
52
73
  return (s.startsWith('/') ? s.slice(1) : s).replace(/\/+$/, '');
53
74
  }
75
+ const slugKey = _slugKey;
76
+ /**
77
+ * The `funnel push` overwrite decision, isolated from the I/O around it.
78
+ * Returns the clashing page when the push must be refused, else undefined.
79
+ *
80
+ * Exported for tests rather than inlined because this is the single call that
81
+ * stands between an agent and silently replacing an org's live homepage.
82
+ */
83
+ export function _findOverwriteClash(existingPages, targetSlug, force) {
84
+ if (force)
85
+ return undefined;
86
+ return existingPages.find(p => slugKey(p.slug) === slugKey(targetSlug));
87
+ }
54
88
  // Resolve human-readable labels for the namespace a destructive write lands
55
89
  // on. The whole point of the funnel-write guardrails is that the agent (and
56
90
  // the human reading the transcript) can SEE which org + funnel it touched —
@@ -65,7 +99,11 @@ async function describeTarget(apiKey, orgId, funnelId) {
65
99
  const orgLabel = org?.name ? `${org.name} (${orgId})` : orgId;
66
100
  const fname = funnelRes?.funnel?.name;
67
101
  const funnelLabel = fname ? `${fname} (${funnelId})` : funnelId;
68
- return { orgLabel, funnelLabel };
102
+ // THIS funnel's own base URL. Every funnel in an org has its own preview
103
+ // subdomain; the org-level `preview_subdomain` is only the first one. See
104
+ // the note in push() for what using the wrong one costs.
105
+ const base = funnelRes?.subdomain_url;
106
+ return { orgLabel, funnelLabel, funnelBaseUrl: base ? base.replace(/\/+$/, '') : undefined };
69
107
  }
70
108
  export async function list(flags) {
71
109
  const config = requireConfig();
@@ -181,8 +219,8 @@ export async function push(slug, flags) {
181
219
  // reading stdin so a refused push never consumes the generated HTML.
182
220
  const force = flags.force === true;
183
221
  const existingPages = await sdkFunnel.listFunnelPages(config.api_key, orgId, funnelId);
184
- const clash = existingPages.find(p => slugKey(p.slug) === slugKey(finalSlug));
185
- if (clash && !force) {
222
+ const clash = _findOverwriteClash(existingPages, finalSlug, force);
223
+ if (clash) {
186
224
  const { orgLabel, funnelLabel } = await describeTarget(config.api_key, orgId, funnelId);
187
225
  const when = clash.updated_at ? `, last updated ${formatDate(clash.updated_at)}` : '';
188
226
  error(`Refusing to overwrite an existing page.
@@ -206,16 +244,22 @@ This funnel already serves a page at ${finalSlug}; pushing would replace it.
206
244
  if (!html.trim())
207
245
  error("No content provided via stdin. Usage: echo '<h1>Hello</h1>' | myapi funnel push [slug]");
208
246
  const result = await sdkFunnel.pushFunnelPage(config.api_key, orgId, funnelId, { slug: finalSlug, html });
209
- const { orgLabel, funnelLabel } = await describeTarget(config.api_key, orgId, funnelId);
210
- let liveUrl = result?.url;
211
- if (!liveUrl) {
212
- // Best-effort URL enrichment — the push already succeeded, so a failed
213
- // org lookup must not turn a successful write into a non-zero exit.
214
- const org = await hq.getOrg(config.api_key, orgId).catch(() => null);
215
- if (org?.preview_subdomain) {
216
- liveUrl = `https://${org.preview_subdomain}.makeautonomous.com${finalSlug}`;
217
- }
218
- }
247
+ const { orgLabel, funnelLabel, funnelBaseUrl } = await describeTarget(config.api_key, orgId, funnelId);
248
+ // Best-effort URL enrichment — the push already succeeded, so a failed
249
+ // lookup must not turn a successful write into a non-zero exit.
250
+ //
251
+ // This used to fall back to the ORG's `preview_subdomain`, which is only
252
+ // correct when the org has exactly one funnel. Push to any other funnel and
253
+ // it printed a sibling funnel's URL: verified against production 2026-07-26,
254
+ // where a push to `write-check-…` reported `fine-vole-…`, and that URL
255
+ // returned 404 while the page was live on the right one. An agent following
256
+ // it concludes its own successful push failed — and the natural recovery is
257
+ // to push again, which the overwrite guard then refuses. Two confusing
258
+ // failures from one wrong string.
259
+ //
260
+ // A wrong URL is strictly worse than no URL, so when the funnel's own base
261
+ // can't be resolved we print nothing rather than guess.
262
+ const liveUrl = result?.url ?? (funnelBaseUrl ? `${funnelBaseUrl}${finalSlug}` : undefined);
219
263
  // Machine output: the live URL is carried as `subdomain_url` — the same field
220
264
  // name `funnel get`/`list`/`create` use — so an agent that pushes has the URL
221
265
  // in hand without a follow-up `funnel get` (and without guessing the field).
@@ -351,7 +395,7 @@ export async function formCmd(funnelArg, flags) {
351
395
  };
352
396
  registeredBinding = await sdkFunnel.createFormBinding(config.api_key, orgId, funnelId, binding);
353
397
  }
354
- const url = `https://api.myapihq.com/funnel/funnels/${funnelId}/submit/${encodeURIComponent(slug)}`;
398
+ const url = `${formSubmitBase()}/funnel/funnels/${funnelId}/submit/${encodeURIComponent(slug)}`;
355
399
  const escAttr = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
356
400
  const escText = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
357
401
  // Per-field labels — Email uses the canonical placeholder; others
@@ -1,7 +1,7 @@
1
- // Unit tests for the login command's pure PKCE/URL helpers.
1
+ // Unit tests for the login command's pure PKCE/URL helpers + the browser opener.
2
2
  import { describe, it, expect } from 'vitest';
3
3
  import * as crypto from 'crypto';
4
- import { generatePkce, buildAuthorizeUrl } from './login.js';
4
+ import { generatePkce, buildAuthorizeUrl, browserCommand, openBrowser } from './login.js';
5
5
  describe('generatePkce', () => {
6
6
  it('challenge is base64url(sha256(verifier)) — S256', () => {
7
7
  const { verifier, challenge } = generatePkce();
@@ -41,3 +41,25 @@ describe('buildAuthorizeUrl', () => {
41
41
  expect(u.searchParams.get('redirect_uri')).toContain(':65530');
42
42
  });
43
43
  });
44
+ describe('browserCommand', () => {
45
+ it('picks the platform opener and passes the URL', () => {
46
+ expect(browserCommand('https://x.test', 'darwin')).toEqual({ cmd: 'open', args: ['https://x.test'] });
47
+ expect(browserCommand('https://x.test', 'linux')).toEqual({ cmd: 'xdg-open', args: ['https://x.test'] });
48
+ expect(browserCommand('https://x.test', 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '', 'https://x.test'] });
49
+ });
50
+ });
51
+ // Regression guard: openBrowser used to return true whenever spawn() didn't
52
+ // throw. Both real failure modes (missing binary, opener exiting non-zero)
53
+ // surface asynchronously, so a "successful" spawn left the user waiting three
54
+ // minutes at "Waiting for sign-in…" with no URL ever printed.
55
+ describe.skipIf(process.platform === 'win32')('openBrowser failure detection', () => {
56
+ it('reports failure when the opener binary does not exist (ENOENT)', async () => {
57
+ expect(await openBrowser('https://x.test', { cmd: 'myapi-no-such-opener-binary', args: [] })).toBe(false);
58
+ });
59
+ it('reports failure when the opener exits non-zero', async () => {
60
+ expect(await openBrowser('https://x.test', { cmd: 'sh', args: ['-c', 'exit 3'] })).toBe(false);
61
+ });
62
+ it('reports success when the opener stays up past the grace window', async () => {
63
+ expect(await openBrowser('https://x.test', { cmd: 'sh', args: ['-c', 'sleep 2'] })).toBe(true);
64
+ });
65
+ });
@@ -11,4 +11,10 @@ export interface Pkce {
11
11
  }
12
12
  export declare function generatePkce(): Pkce;
13
13
  export declare function buildAuthorizeUrl(base: string, redirectUri: string, pkce: Pkce): string;
14
+ export interface OpenerCommand {
15
+ cmd: string;
16
+ args: string[];
17
+ }
18
+ export declare function browserCommand(url: string, platform?: string): OpenerCommand;
19
+ export declare function openBrowser(url: string, opener?: OpenerCommand): Promise<boolean>;
14
20
  export declare function login(flags?: Flags): Promise<void>;
@@ -60,20 +60,51 @@ export function buildAuthorizeUrl(base, redirectUri, pkce) {
60
60
  return u.toString();
61
61
  }
62
62
  // ── Browser opener (zero-dep, best-effort) ──────────────────────────────────
63
- function openBrowser(url) {
64
- const cmd = process.platform === 'darwin' ? 'open'
65
- : process.platform === 'win32' ? 'cmd'
66
- : 'xdg-open';
67
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
68
- try {
69
- const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
70
- child.on('error', () => { });
71
- child.unref();
72
- return true;
73
- }
74
- catch {
75
- return false;
76
- }
63
+ // Resolves false when the opener could not actually launch a browser, so the
64
+ // caller can print the URL instead. spawn() failures (ENOENT — no `xdg-open`
65
+ // on a headless box) and a fast non-zero exit (`xdg-open` with no handler
66
+ // registered) both arrive asynchronously, so returning true the moment spawn()
67
+ // doesn't throw strands the user at "Waiting for sign-in…" with nothing to
68
+ // click. Wait a short beat for those signals before claiming success.
69
+ const BROWSER_LAUNCH_GRACE_MS = 400;
70
+ // Exported so the tests can drive openBrowser with a command whose failure mode
71
+ // is deterministic, instead of whatever browser the test machine happens to have.
72
+ export function browserCommand(url, platform = process.platform) {
73
+ if (platform === 'darwin')
74
+ return { cmd: 'open', args: [url] };
75
+ if (platform === 'win32')
76
+ return { cmd: 'cmd', args: ['/c', 'start', '', url] };
77
+ return { cmd: 'xdg-open', args: [url] };
78
+ }
79
+ export function openBrowser(url, opener = browserCommand(url)) {
80
+ const { cmd, args } = opener;
81
+ return new Promise(resolve => {
82
+ let settled = false;
83
+ let timer;
84
+ const settle = (ok) => {
85
+ if (settled)
86
+ return;
87
+ settled = true;
88
+ if (timer)
89
+ clearTimeout(timer);
90
+ resolve(ok);
91
+ };
92
+ try {
93
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
94
+ // Detach either way — on success the browser outlives us; on failure the
95
+ // child is already gone.
96
+ child.unref();
97
+ child.on('error', () => settle(false));
98
+ child.on('exit', code => { if (code !== 0)
99
+ settle(false); });
100
+ }
101
+ catch {
102
+ settle(false);
103
+ return;
104
+ }
105
+ // Survived the grace window without an error/failed exit → assume it opened.
106
+ timer = setTimeout(() => settle(true), BROWSER_LAUNCH_GRACE_MS);
107
+ });
77
108
  }
78
109
  // ── Loopback callback server ────────────────────────────────────────────────
79
110
  const CALLBACK_TIMEOUT_MS = 180_000;
@@ -365,7 +396,7 @@ export async function login(flags = {}) {
365
396
  const callback = await startCallbackServer(pkce.state);
366
397
  const authorizeUrl = buildAuthorizeUrl(authorizeBase(), callback.redirectUri, pkce);
367
398
  info('› Opening your browser to sign in…');
368
- const opened = flags['no-browser'] ? false : openBrowser(authorizeUrl);
399
+ const opened = flags['no-browser'] ? false : await openBrowser(authorizeUrl);
369
400
  if (!opened) {
370
401
  info('› Open this URL to sign in:');
371
402
  info(` ${authorizeUrl}`);
@@ -399,7 +430,7 @@ export async function login(flags = {}) {
399
430
  default_org: auth.default_org || undefined,
400
431
  default_funnel: auth.default_funnel || undefined,
401
432
  });
402
- success(`› Signed in${email ? ` · ${email}` : ''}`);
433
+ success(`› Signed in${email ? ` · ${email}` : ''}`);
403
434
  info(` Account: ${auth.account_id}`);
404
435
  if (auth.default_org)
405
436
  info(` Org: ${auth.default_org}${auth.default_funnel ? ` · Funnel: ${auth.default_funnel}` : ''}`);
@@ -413,7 +444,7 @@ async function loginMock(flags = {}) {
413
444
  const idp = await startMockIdp();
414
445
  const authorizeUrl = buildAuthorizeUrl(idp.authorizeBase, callback.redirectUri, pkce);
415
446
  info('› Opening your browser to sign in… (mock preview — nothing is saved)');
416
- const opened = flags['no-browser'] ? false : openBrowser(authorizeUrl);
447
+ const opened = flags['no-browser'] ? false : await openBrowser(authorizeUrl);
417
448
  if (!opened) {
418
449
  info('› Open this URL to sign in:');
419
450
  info(` ${authorizeUrl}`);
@@ -69,9 +69,19 @@ export async function status(flags) {
69
69
  printJson(res);
70
70
  return;
71
71
  }
72
- info(`Tier: ${res.tier}`);
72
+ // An org with no Stripe connection comes back with these fields unset, and
73
+ // interpolating them printed the literal string "undefined" three times —
74
+ // which tells an agent nothing and looks like a bug in the CLI rather than
75
+ // an unconfigured account. Say what's true and what to do about it.
76
+ // Observed against production 2026-07-26.
77
+ if (!res.stripe_account_id) {
78
+ info('Stripe: not connected');
79
+ info('Connect yours: myapi payments connect --stripe-key <sk_...>');
80
+ return;
81
+ }
82
+ info(`Tier: ${res.tier ?? 'unknown'}`);
73
83
  info(`Stripe account: ${res.stripe_account_id}`);
74
- info(`Onboarding: ${res.onboarding_status}`);
84
+ info(`Onboarding: ${res.onboarding_status ?? 'unknown'}`);
75
85
  if (res.application_fee_bps != null) {
76
86
  info(`Platform fee: ${res.application_fee_bps} bps`);
77
87
  }
@@ -1,6 +1,7 @@
1
1
  import type { Flags } from '../helpers.js';
2
2
  import type { Exposes } from '../exposes.js';
3
3
  export declare const EXPOSES: Exposes;
4
+ export declare function _isLocallyModified(src: string): boolean;
4
5
  export declare function installSkills(): Promise<void>;
5
6
  export declare function importKey(apiKey: string, flags: Flags): Promise<void>;
6
7
  export declare function setup(flags?: Flags): Promise<void>;
@@ -1,8 +1,9 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
+ import * as crypto from 'crypto';
4
5
  import { loadConfig, saveConfig, addAccount, loadFullConfig } from '../config.js';
5
- import { info, success } from '../output.js';
6
+ import { info, success, banner } from '../output.js';
6
7
  import { ask, confirm } from '../prompt.js';
7
8
  import { hq, funnel as sdkFunnel } from '@myapihq/sdk';
8
9
  import { emitSkillsInstall } from '../telemetry.js';
@@ -45,6 +46,40 @@ const AGENT_DIRS = {
45
46
  const SKILLS_CANONICAL = path.join(os.homedir(), '.agents', 'skills', 'myapi');
46
47
  // Skills are bundled inside the npm package at build time from skills/*/SKILL.md
47
48
  const BUNDLED_SKILLS_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'skills');
49
+ // Does an installed SKILL.md differ from the checksum it declares?
50
+ //
51
+ // The checksum covers the body only (everything after the frontmatter block),
52
+ // which is what makes it self-describing: the digest lives inside the file it
53
+ // describes without being part of what's hashed. Best-effort by design — an
54
+ // unreadable or checksum-less file is simply "not known to be modified", never
55
+ // a reason to block an install.
56
+ export function _isLocallyModified(src) {
57
+ const m = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)([\s\S]*)$/.exec(src);
58
+ if (!m)
59
+ return false;
60
+ const [, frontmatter, body] = m;
61
+ const declared = /^checksum:\s*(\S+)/m.exec(frontmatter)?.[1];
62
+ if (!declared || !declared.startsWith('sha256-'))
63
+ return false;
64
+ // Every skill shipped by CLI <= 2.4.1 carries the literal placeholder
65
+ // `sha256-pending`, which can never equal a real digest. Without this, the
66
+ // first upgrade would report all 14 previously-installed skills as
67
+ // locally-modified and announce "your edits are gone" to every existing
68
+ // user — a false alarm on 100% of upgrades, saying something alarming and
69
+ // untrue. A placeholder means "unknown", which is not the same as "edited".
70
+ if (declared === 'sha256-pending')
71
+ return false;
72
+ const actual = 'sha256-' + crypto.createHash('sha256').update(body, 'utf-8').digest('hex');
73
+ return actual !== declared;
74
+ }
75
+ function isLocallyModified(file) {
76
+ try {
77
+ return _isLocallyModified(fs.readFileSync(file, 'utf-8'));
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
48
83
  export async function installSkills() {
49
84
  if (!fs.existsSync(BUNDLED_SKILLS_DIR)) {
50
85
  info('No bundled skills found — skipping skills install.');
@@ -58,11 +93,25 @@ export async function installSkills() {
58
93
  return;
59
94
  }
60
95
  // Write canonical copies: ~/.agents/skills/myapi/<skill>/SKILL.md (only SKILL.md)
96
+ //
97
+ // This overwrites unconditionally, which is right for the common case (an
98
+ // update should deliver the new skill) but silently destroys local edits.
99
+ // The frontmatter `checksum` — a real sha256 of the body, written by
100
+ // scripts/canonical-sync.mjs — is what lets us tell the two apart, so a user
101
+ // who customized a skill at least learns it was replaced.
61
102
  fs.mkdirSync(SKILLS_CANONICAL, { recursive: true });
103
+ const modified = [];
62
104
  for (const skill of skills) {
63
105
  const skillDir = path.join(SKILLS_CANONICAL, skill);
106
+ const installed = path.join(skillDir, 'SKILL.md');
107
+ if (fs.existsSync(installed) && isLocallyModified(installed))
108
+ modified.push(skill);
64
109
  fs.mkdirSync(skillDir, { recursive: true });
65
- fs.copyFileSync(path.join(BUNDLED_SKILLS_DIR, skill, 'SKILL.md'), path.join(skillDir, 'SKILL.md'));
110
+ fs.copyFileSync(path.join(BUNDLED_SKILLS_DIR, skill, 'SKILL.md'), installed);
111
+ }
112
+ if (modified.length > 0) {
113
+ banner(`› Replaced ${modified.length} locally-modified skill${modified.length === 1 ? '' : 's'}: ${modified.join(', ')}`);
114
+ banner(` Your edits are gone. Keep customizations in a separate skill name — MyAPI skills are overwritten on every install/update.`);
66
115
  }
67
116
  // Symlink each skill into agent config directories. Surface failures so
68
117
  // users can fix permission issues — skills that didn't install will not
@@ -223,7 +272,7 @@ export async function importKey(apiKey, flags) {
223
272
  default_org: defaultOrg, default_funnel: defaultFunnel,
224
273
  skills_installed: wantsSkills,
225
274
  });
226
- success(`› Key imported${email ? ` · ${email}` : ''}`);
275
+ success(`› Key imported${email ? ` · ${email}` : ''}`);
227
276
  if (wantsSkills)
228
277
  await installSkills();
229
278
  else
@@ -301,7 +350,7 @@ export async function setup(flags = {}) {
301
350
  default_funnel: defaultFunnel,
302
351
  is_anonymous: isAnonymous,
303
352
  }, wantsSkills);
304
- success(`› saved to ~/.myapi/config.json`);
353
+ success(`› Saved to ~/.myapi/config.json`);
305
354
  // Validate key and ensure org/funnel defaults are still correct.
306
355
  // (resolveDefaults is non-fatal if the API call fails.)
307
356
  try {
@@ -0,0 +1 @@
1
+ export {};