@myapihq/cli 2.4.1 → 2.4.3

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 (89) 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/payments.js +12 -2
  12. package/dist/commands/setup.d.ts +1 -0
  13. package/dist/commands/setup.js +51 -2
  14. package/dist/commands/skill-checksum.test.d.ts +1 -0
  15. package/dist/commands/skill-checksum.test.js +48 -0
  16. package/dist/config-envkey.test.d.ts +1 -0
  17. package/dist/config-envkey.test.js +145 -0
  18. package/dist/config.js +41 -5
  19. package/dist/errors.js +11 -0
  20. package/dist/index.js +6 -1
  21. package/dist/sdk-transport.test.d.ts +1 -0
  22. package/dist/sdk-transport.test.js +138 -0
  23. package/dist/sdk-version.test.d.ts +1 -0
  24. package/dist/sdk-version.test.js +24 -0
  25. package/dist/skills/my-api-hq/SKILL.md +1 -1
  26. package/dist/skills/my-api-hq/claude/.claude-plugin/plugin.json +1 -0
  27. package/dist/skills/my-audience-api/README.md +40 -0
  28. package/dist/skills/my-audience-api/SKILL.md +139 -0
  29. package/dist/skills/my-audience-api/claude/.claude-plugin/plugin.json +7 -0
  30. package/dist/skills/my-audience-api/openapi/.gitkeep +0 -0
  31. package/dist/skills/my-auth-api/SKILL.md +1 -1
  32. package/dist/skills/my-auth-api/claude/.claude-plugin/plugin.json +1 -0
  33. package/dist/skills/my-company-api/README.md +35 -0
  34. package/dist/skills/my-company-api/SKILL.md +110 -0
  35. package/dist/skills/my-company-api/claude/.claude-plugin/plugin.json +7 -0
  36. package/dist/skills/my-company-api/openapi/.gitkeep +0 -0
  37. package/dist/skills/my-container-api/README.md +34 -0
  38. package/dist/skills/my-container-api/SKILL.md +77 -0
  39. package/dist/skills/my-container-api/claude/.claude-plugin/plugin.json +7 -0
  40. package/dist/skills/my-container-api/openapi/.gitkeep +0 -0
  41. package/dist/skills/my-crm-api/SKILL.md +3 -3
  42. package/dist/skills/my-crm-api/claude/.claude-plugin/plugin.json +1 -0
  43. package/dist/skills/my-database-api/SKILL.md +1 -1
  44. package/dist/skills/my-database-api/claude/.claude-plugin/plugin.json +1 -0
  45. package/dist/skills/my-domain-api/SKILL.md +1 -1
  46. package/dist/skills/my-domain-api/claude/.claude-plugin/plugin.json +1 -0
  47. package/dist/skills/my-email-api/SKILL.md +1 -1
  48. package/dist/skills/my-email-api/claude/.claude-plugin/plugin.json +1 -0
  49. package/dist/skills/my-email-verify-api/SKILL.md +1 -1
  50. package/dist/skills/my-email-verify-api/claude/.claude-plugin/plugin.json +1 -0
  51. package/dist/skills/my-function-api/SKILL.md +104 -0
  52. package/dist/skills/my-function-api/claude/.claude-plugin/plugin.json +7 -0
  53. package/dist/skills/my-funnel-api/SKILL.md +2 -2
  54. package/dist/skills/my-funnel-api/claude/.claude-plugin/plugin.json +1 -0
  55. package/dist/skills/my-git-api/SKILL.md +1 -1
  56. package/dist/skills/my-git-api/claude/.claude-plugin/plugin.json +1 -0
  57. package/dist/skills/my-image-api/SKILL.md +1 -1
  58. package/dist/skills/my-image-api/claude/.claude-plugin/plugin.json +1 -0
  59. package/dist/skills/my-llm-api/SKILL.md +6 -11
  60. package/dist/skills/my-llm-api/claude/.claude-plugin/plugin.json +1 -0
  61. package/dist/skills/my-payments-api/SKILL.md +85 -0
  62. package/dist/skills/my-payments-api/claude/.claude-plugin/plugin.json +7 -0
  63. package/dist/skills/my-people-api/README.md +35 -0
  64. package/dist/skills/my-people-api/SKILL.md +107 -0
  65. package/dist/skills/my-people-api/claude/.claude-plugin/plugin.json +7 -0
  66. package/dist/skills/my-people-api/openapi/.gitkeep +0 -0
  67. package/dist/skills/my-pixel-api/README.md +38 -0
  68. package/dist/skills/my-pixel-api/SKILL.md +90 -0
  69. package/dist/skills/my-pixel-api/claude/.claude-plugin/plugin.json +7 -0
  70. package/dist/skills/my-pixel-api/openapi/.gitkeep +0 -0
  71. package/dist/skills/my-queue-api/README.md +34 -0
  72. package/dist/skills/my-queue-api/SKILL.md +78 -0
  73. package/dist/skills/my-queue-api/claude/.claude-plugin/plugin.json +7 -0
  74. package/dist/skills/my-queue-api/openapi/.gitkeep +0 -0
  75. package/dist/skills/my-storage-api/SKILL.md +1 -1
  76. package/dist/skills/my-storage-api/claude/.claude-plugin/plugin.json +1 -0
  77. package/dist/skills/my-task-api/README.md +36 -0
  78. package/dist/skills/my-task-api/SKILL.md +74 -0
  79. package/dist/skills/my-task-api/claude/.claude-plugin/plugin.json +7 -0
  80. package/dist/skills/my-task-api/openapi/.gitkeep +0 -0
  81. package/dist/skills/my-url-to/README.md +32 -0
  82. package/dist/skills/my-url-to/SKILL.md +55 -0
  83. package/dist/skills/my-url-to/claude/.claude-plugin/plugin.json +7 -0
  84. package/dist/skills/my-url-to/openapi/.gitkeep +0 -0
  85. package/dist/skills/my-webhook-api/SKILL.md +1 -1
  86. package/dist/skills/my-webhook-api/claude/.claude-plugin/plugin.json +1 -0
  87. package/dist/skills/my-workflow-api/SKILL.md +1 -1
  88. package/dist/skills/my-workflow-api/claude/.claude-plugin/plugin.json +1 -0
  89. 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
@@ -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
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ // The SKILL.md `checksum:` frontmatter field used to read `sha256-pending` in
2
+ // all 24 skills — required by the linter, computed by nothing, verified by
3
+ // nothing. It's now a real digest (written by scripts/canonical-sync.mjs) and
4
+ // `install-skills` uses it to tell an untouched installed skill from one the
5
+ // user edited before it overwrites.
6
+ //
7
+ // These tests pin the contract both sides depend on: the digest covers the
8
+ // BODY only, so writing it into the frontmatter can't change what it hashes.
9
+ import { describe, it, expect } from 'vitest';
10
+ import * as crypto from 'node:crypto';
11
+ import { _isLocallyModified } from './setup.js';
12
+ function skill(body, checksum) {
13
+ return `---\nname: my-test-api\nversion: 1.0.0\nchecksum: ${checksum}\n---\n${body}`;
14
+ }
15
+ function digest(body) {
16
+ return 'sha256-' + crypto.createHash('sha256').update(body, 'utf-8').digest('hex');
17
+ }
18
+ const BODY = '\n# MyTestAPI\n\nSome capability prose.\n';
19
+ describe('_isLocallyModified', () => {
20
+ it('reports an untouched skill as unmodified', () => {
21
+ expect(_isLocallyModified(skill(BODY, digest(BODY)))).toBe(false);
22
+ });
23
+ it('detects an edited body', () => {
24
+ const edited = skill(BODY + '\nUser added a note.\n', digest(BODY));
25
+ expect(_isLocallyModified(edited)).toBe(true);
26
+ });
27
+ it('ignores frontmatter-only changes — the digest covers the body', () => {
28
+ // This is the fixpoint property that lets the checksum live inside the
29
+ // file it describes. Bumping `version` must not read as tampering.
30
+ const rewritten = `---\nname: my-test-api\nversion: 2.0.0\nchecksum: ${digest(BODY)}\n---\n${BODY}`;
31
+ expect(_isLocallyModified(rewritten)).toBe(false);
32
+ });
33
+ it('treats a checksum-less or unrecognised-algorithm skill as unknown, not modified', () => {
34
+ // Never warn on a file we can't judge.
35
+ expect(_isLocallyModified(`---\nname: x\n---\n${BODY}`)).toBe(false);
36
+ expect(_isLocallyModified(skill(BODY, 'md5-whatever'))).toBe(false);
37
+ });
38
+ it('does not flag the legacy sha256-pending placeholder as an edit', () => {
39
+ // Every skill shipped by CLI <= 2.4.1 carries this literal. It can never
40
+ // equal a real digest, so a naive comparison reports EVERY installed skill
41
+ // as locally modified on the first upgrade — telling every existing user
42
+ // "your edits are gone" when nothing of theirs was touched.
43
+ expect(_isLocallyModified(skill(BODY, 'sha256-pending'))).toBe(false);
44
+ });
45
+ it('treats a file with no frontmatter as unknown', () => {
46
+ expect(_isLocallyModified('# Just a heading\n')).toBe(false);
47
+ });
48
+ });
@@ -0,0 +1 @@
1
+ export {};