@myapihq/cli 2.26.0 → 2.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/funnel-pages-content.test.d.ts +1 -0
- package/dist/commands/funnel-pages-content.test.js +75 -0
- package/dist/commands/funnel.js +24 -3
- package/dist/commands/org-create-default.test.d.ts +1 -0
- package/dist/commands/org-create-default.test.js +108 -0
- package/dist/commands/org.js +44 -10
- package/dist/index.js +1 -1
- package/dist/regression-register.test.d.ts +1 -0
- package/dist/regression-register.test.js +71 -0
- package/dist/skills/my-api-hq/SKILL.md +4 -3
- package/dist/skills/my-funnel-api/SKILL.md +5 -5
- package/package.json +2 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// `funnel push` overwrites whatever is live at the target, and nothing could
|
|
2
|
+
// tell you what that was.
|
|
3
|
+
//
|
|
4
|
+
// `funnel pages` listed paths only. The API has supported ?include_content=true
|
|
5
|
+
// all along; no client ever sent it. So an agent asked to change one section of
|
|
6
|
+
// a published page had two options: regenerate the whole page from scratch, or
|
|
7
|
+
// scrape the public URL and hope. Every funnel edit was a blind write.
|
|
8
|
+
//
|
|
9
|
+
// Found by asking the reverse-coverage question of QUERY parameters rather than
|
|
10
|
+
// request bodies — the direction the request-field linter documents as out of
|
|
11
|
+
// scope, and the same blind spot that hid `?confirm=` on org delete.
|
|
12
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
13
|
+
const ORG = '11111111-1111-4111-8111-111111111111';
|
|
14
|
+
const FUNNEL = 'f1';
|
|
15
|
+
const sdk = vi.hoisted(() => ({
|
|
16
|
+
funnel: { listFunnelPages: vi.fn() },
|
|
17
|
+
hq: { listOrgs: vi.fn() },
|
|
18
|
+
}));
|
|
19
|
+
vi.mock('@myapihq/sdk', () => sdk);
|
|
20
|
+
vi.mock('../config.js', () => ({
|
|
21
|
+
requireConfig: () => ({ api_key: 'hq_live_test', default_org: ORG, default_funnel: FUNNEL }),
|
|
22
|
+
loadConfig: () => ({ api_key: 'hq_live_test', default_org: ORG, default_funnel: FUNNEL }),
|
|
23
|
+
CONFIG_DIR: '/tmp/nowhere',
|
|
24
|
+
}));
|
|
25
|
+
let printed;
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
printed = [];
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
const output = await import('../output.js');
|
|
30
|
+
const cap = ((m) => { printed.push(typeof m === 'string' ? m : JSON.stringify(m)); });
|
|
31
|
+
vi.spyOn(output, 'info').mockImplementation(cap);
|
|
32
|
+
vi.spyOn(output, 'printJson').mockImplementation(cap);
|
|
33
|
+
vi.spyOn(output, 'printTable').mockImplementation(cap);
|
|
34
|
+
vi.spyOn(output, 'banner').mockImplementation(() => { });
|
|
35
|
+
vi.spyOn(output, 'error').mockImplementation(((m) => {
|
|
36
|
+
printed.push(String(m));
|
|
37
|
+
throw new Error('__EXIT__');
|
|
38
|
+
}));
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => vi.restoreAllMocks());
|
|
41
|
+
async function run(flags) {
|
|
42
|
+
const { pages } = await import('./funnel.js');
|
|
43
|
+
try {
|
|
44
|
+
await pages(FUNNEL, flags);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
if (e?.message !== '__EXIT__')
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
describe('funnel pages --with-content', () => {
|
|
52
|
+
it('asks the API for content, so an edit can read before it writes', async () => {
|
|
53
|
+
sdk.funnel.listFunnelPages.mockResolvedValue([{ slug: '/', content: '<h1>live</h1>' }]);
|
|
54
|
+
await run({ 'with-content': true, json: true });
|
|
55
|
+
expect(sdk.funnel.listFunnelPages).toHaveBeenCalledWith('hq_live_test', ORG, FUNNEL, { includeContent: true });
|
|
56
|
+
});
|
|
57
|
+
it('does not ask for it otherwise — listing paths must stay cheap', async () => {
|
|
58
|
+
sdk.funnel.listFunnelPages.mockResolvedValue([{ slug: '/' }]);
|
|
59
|
+
await run({ json: true });
|
|
60
|
+
expect(sdk.funnel.listFunnelPages).toHaveBeenCalledWith('hq_live_test', ORG, FUNNEL, undefined);
|
|
61
|
+
});
|
|
62
|
+
it('puts the content in --json, which is what an agent reads', async () => {
|
|
63
|
+
sdk.funnel.listFunnelPages.mockResolvedValue([{ slug: '/', content: '<h1>live</h1>' }]);
|
|
64
|
+
await run({ 'with-content': true, json: true });
|
|
65
|
+
expect(printed.join('\n')).toContain('<h1>live</h1>');
|
|
66
|
+
});
|
|
67
|
+
it('keeps HTML out of the human view, showing sizes instead', async () => {
|
|
68
|
+
// Six pages of markup in a terminal buries the thing being looked for.
|
|
69
|
+
sdk.funnel.listFunnelPages.mockResolvedValue([{ slug: '/', content: '<h1>live</h1>' }]);
|
|
70
|
+
await run({ 'with-content': true });
|
|
71
|
+
const out = printed.join('\n');
|
|
72
|
+
expect(out).not.toContain('<h1>live</h1>');
|
|
73
|
+
expect(out).toContain('13');
|
|
74
|
+
});
|
|
75
|
+
});
|
package/dist/commands/funnel.js
CHANGED
|
@@ -28,6 +28,8 @@ export const SCHEMA = {
|
|
|
28
28
|
slug: 'string',
|
|
29
29
|
env: 'string',
|
|
30
30
|
force: 'boolean',
|
|
31
|
+
// \`funnel pages --with-content\` — read what is live before overwriting it.
|
|
32
|
+
'with-content': 'boolean',
|
|
31
33
|
'api-fn': 'string',
|
|
32
34
|
// form
|
|
33
35
|
fields: 'string',
|
|
@@ -214,7 +216,11 @@ export async function pages(funnelArg, flags) {
|
|
|
214
216
|
const funnelId = funnelArg || flags.funnel || config.default_funnel;
|
|
215
217
|
if (!funnelId)
|
|
216
218
|
error('Missing funnel id. Pass it as a positional arg, --funnel <id>, or set: myapi config set-funnel <id>');
|
|
217
|
-
|
|
219
|
+
// --with-content is what makes an edit a read-then-write instead of a blind
|
|
220
|
+
// overwrite. `funnel push` replaces whatever is live at the target, and until
|
|
221
|
+
// now nothing could tell you what that was: an agent asked to change one
|
|
222
|
+
// section had to regenerate the whole page or scrape the public URL.
|
|
223
|
+
const list = await sdkFunnel.listFunnelPages(config.api_key, orgId, funnelId, flags['with-content'] ? { includeContent: true } : undefined);
|
|
218
224
|
// An empty inventory is not proof the funnel is empty. Verified 2026-07-28:
|
|
219
225
|
// a funnel answering 200 with real content on both its subdomain and a bound
|
|
220
226
|
// custom domain reported `{"pages":[]}`. Someone auditing what is deployed
|
|
@@ -244,10 +250,21 @@ export async function pages(funnelArg, flags) {
|
|
|
244
250
|
printJson(list);
|
|
245
251
|
return;
|
|
246
252
|
}
|
|
247
|
-
|
|
253
|
+
// Human output never dumps the content — six pages of HTML in a terminal
|
|
254
|
+
// buries the thing being looked for. The size is the useful summary; --json
|
|
255
|
+
// carries the content itself, which is the shape an agent reads anyway.
|
|
256
|
+
printTable(flags['with-content']
|
|
257
|
+
? list.map(p => ({
|
|
258
|
+
slug: p.slug,
|
|
259
|
+
bytes: typeof p.content === 'string' ? p.content.length : '—',
|
|
260
|
+
updated_at: p.updated_at ?? '',
|
|
261
|
+
}))
|
|
262
|
+
: list, {
|
|
248
263
|
flags,
|
|
249
264
|
empty: 'No pages published to this funnel yet. Push one with: echo "<h1>Hello</h1>" | myapi funnel push /',
|
|
250
265
|
});
|
|
266
|
+
if (flags['with-content'])
|
|
267
|
+
info(' Content is in --json; this view shows sizes so a terminal is not flooded.');
|
|
251
268
|
}
|
|
252
269
|
export async function push(slug, flags) {
|
|
253
270
|
const config = requireConfig();
|
|
@@ -660,7 +677,11 @@ future platform features (rate-limit, captcha, field validation) for
|
|
|
660
677
|
free.`,
|
|
661
678
|
'get': 'myapi funnel get <id> [--org <id>] [--json]',
|
|
662
679
|
'list': 'myapi funnel list [--org <id>] [--json]',
|
|
663
|
-
'pages':
|
|
680
|
+
'pages': `myapi funnel pages [funnel_id] [--funnel <id>] [--with-content] [--org <id>] [--json]
|
|
681
|
+
|
|
682
|
+
Lists what is published. --with-content also fetches each page's live content, which
|
|
683
|
+
is how you read a page before overwriting it — \`funnel push\` replaces whatever
|
|
684
|
+
is there. Human output shows sizes; --json carries the content.`,
|
|
664
685
|
'publish': `myapi funnel publish <dir> [--funnel <id>] [--env dev|prod] [--api-fn <id>] [--force] [--org <id>]
|
|
665
686
|
|
|
666
687
|
Uploads a whole local directory as the funnel's site. Each file's path
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// `org create --yes` used to re-point the whole machine.
|
|
2
|
+
//
|
|
3
|
+
// --yes meant two things at once: skip the "set as default?" prompt, AND make
|
|
4
|
+
// the new org the default. Since `org create --name X --yes` is the documented
|
|
5
|
+
// non-interactive form, every scripted or agent-driven org creation retargeted
|
|
6
|
+
// the machine's default org — so a throwaway org made by one project became the
|
|
7
|
+
// target of every bare command in every other project on that laptop.
|
|
8
|
+
//
|
|
9
|
+
// That is the cross-project collision customers report, arriving through a door
|
|
10
|
+
// nobody was watching: they were told to stop using `config set-org`, and
|
|
11
|
+
// `org create --yes` kept doing it to them anyway. It moved my own default org
|
|
12
|
+
// twice in one day, once while I was building the fix for the other door.
|
|
13
|
+
//
|
|
14
|
+
// The two meanings are now separate, and the interesting assertions are about
|
|
15
|
+
// what does NOT happen.
|
|
16
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
17
|
+
const ORG_A = 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa';
|
|
18
|
+
const NEW_ORG = 'nnnnnnnn-2222-4222-8222-nnnnnnnnnnnn';
|
|
19
|
+
const sdk = vi.hoisted(() => ({
|
|
20
|
+
hq: { createOrg: vi.fn() },
|
|
21
|
+
funnel: { listFunnels: vi.fn() },
|
|
22
|
+
}));
|
|
23
|
+
vi.mock('@myapihq/sdk', () => sdk);
|
|
24
|
+
const saved = vi.hoisted(() => []);
|
|
25
|
+
let stored;
|
|
26
|
+
vi.mock('../config.js', () => ({
|
|
27
|
+
requireConfig: () => ({ ...stored }),
|
|
28
|
+
loadConfig: () => ({ ...stored }),
|
|
29
|
+
saveConfig: (c) => { saved.push(c); },
|
|
30
|
+
CONFIG_DIR: '/tmp/nowhere',
|
|
31
|
+
}));
|
|
32
|
+
const confirmMock = vi.hoisted(() => vi.fn());
|
|
33
|
+
vi.mock('../prompt.js', () => ({
|
|
34
|
+
confirm: confirmMock,
|
|
35
|
+
isNonInteractive: () => false,
|
|
36
|
+
}));
|
|
37
|
+
let printed;
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
saved.length = 0;
|
|
40
|
+
printed = [];
|
|
41
|
+
vi.clearAllMocks();
|
|
42
|
+
stored = { api_key: 'hq_live_test', account_id: 'acct' };
|
|
43
|
+
sdk.hq.createOrg.mockResolvedValue({ id: NEW_ORG, name: 'Throwaway' });
|
|
44
|
+
sdk.funnel.listFunnels.mockResolvedValue([]);
|
|
45
|
+
const output = await import('../output.js');
|
|
46
|
+
const cap = ((m) => { printed.push(String(m)); });
|
|
47
|
+
vi.spyOn(output, 'info').mockImplementation(cap);
|
|
48
|
+
vi.spyOn(output, 'success').mockImplementation(cap);
|
|
49
|
+
vi.spyOn(output, 'printJson').mockImplementation(cap);
|
|
50
|
+
vi.spyOn(output, 'error').mockImplementation(((m) => {
|
|
51
|
+
printed.push(String(m));
|
|
52
|
+
throw new Error('__EXIT__');
|
|
53
|
+
}));
|
|
54
|
+
});
|
|
55
|
+
afterEach(() => vi.restoreAllMocks());
|
|
56
|
+
async function createOrg(flags) {
|
|
57
|
+
const { create } = await import('./org.js');
|
|
58
|
+
try {
|
|
59
|
+
await create(['Throwaway'], flags);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
if (e?.message !== '__EXIT__')
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
describe('org create and the machine default', () => {
|
|
67
|
+
it('--yes does NOT re-point an existing default', async () => {
|
|
68
|
+
stored.default_org = ORG_A;
|
|
69
|
+
await createOrg({ yes: true });
|
|
70
|
+
expect(saved).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
it('says the default is unchanged, rather than saying nothing', async () => {
|
|
73
|
+
// Silence is what let a throwaway org take over a machine without anyone
|
|
74
|
+
// noticing which command did it.
|
|
75
|
+
stored.default_org = ORG_A;
|
|
76
|
+
stored.org_names = { [ORG_A]: 'Real Work' };
|
|
77
|
+
await createOrg({ yes: true });
|
|
78
|
+
const out = printed.join('\n');
|
|
79
|
+
expect(out).toMatch(/default org is unchanged/i);
|
|
80
|
+
expect(out).toContain('Real Work');
|
|
81
|
+
expect(out).toContain(`myapi init --org ${NEW_ORG}`);
|
|
82
|
+
});
|
|
83
|
+
it('--set-default re-points it, because that was asked for by name', async () => {
|
|
84
|
+
stored.default_org = ORG_A;
|
|
85
|
+
await createOrg({ yes: true, 'set-default': true });
|
|
86
|
+
expect(saved.at(-1)).toMatchObject({ default_org: NEW_ORG });
|
|
87
|
+
});
|
|
88
|
+
it('sets the default when there is none, since nothing can be clobbered', async () => {
|
|
89
|
+
// A user with no default cannot run an org-scoped command at all, so the
|
|
90
|
+
// first org is pure gain.
|
|
91
|
+
await createOrg({ yes: true });
|
|
92
|
+
expect(saved.at(-1)).toMatchObject({ default_org: NEW_ORG });
|
|
93
|
+
expect(printed.join('\n')).toMatch(/no default org/i);
|
|
94
|
+
});
|
|
95
|
+
it('still asks when interactive without --yes, and honours a no', async () => {
|
|
96
|
+
stored.default_org = ORG_A;
|
|
97
|
+
confirmMock.mockResolvedValue(false);
|
|
98
|
+
await createOrg({});
|
|
99
|
+
expect(confirmMock).toHaveBeenCalled();
|
|
100
|
+
expect(saved).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
it('honours a yes at the prompt', async () => {
|
|
103
|
+
stored.default_org = ORG_A;
|
|
104
|
+
confirmMock.mockResolvedValue(true);
|
|
105
|
+
await createOrg({});
|
|
106
|
+
expect(saved.at(-1)).toMatchObject({ default_org: NEW_ORG });
|
|
107
|
+
});
|
|
108
|
+
});
|
package/dist/commands/org.js
CHANGED
|
@@ -21,6 +21,9 @@ export const SCHEMA = {
|
|
|
21
21
|
description: 'string',
|
|
22
22
|
'business-sector': 'string',
|
|
23
23
|
'logo-url': 'string',
|
|
24
|
+
// Setting the machine's default org is now something you ask for, not
|
|
25
|
+
// something --yes does to you on the way past.
|
|
26
|
+
'set-default': 'boolean',
|
|
24
27
|
org: 'string',
|
|
25
28
|
};
|
|
26
29
|
const SYNC_BRAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -45,11 +48,23 @@ export async function create(restArgs, flags) {
|
|
|
45
48
|
info(`Preview domain: https://${org.preview_subdomain}.makeautonomous.com`);
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
// `--yes` used to mean two things: skip the prompt, AND make this the default
|
|
52
|
+
// org. Since `org create --name X --yes` is the documented non-interactive
|
|
53
|
+
// form, every scripted or agent-driven org creation silently re-pointed the
|
|
54
|
+
// machine's default — so a throwaway org created by one project became the
|
|
55
|
+
// target of every bare command in every other project on that machine. That
|
|
56
|
+
// is the cross-project collision customers report, arriving through a door
|
|
57
|
+
// nobody was watching. I did it to my own default org twice in one day.
|
|
58
|
+
//
|
|
59
|
+
// Now the two are separate:
|
|
60
|
+
// - no default yet → set it. First org, nothing to clobber, and a user
|
|
61
|
+
// with no default cannot run an org-scoped command.
|
|
62
|
+
// - --set-default → set it, because it was asked for by name.
|
|
63
|
+
// - interactive → ask, as before. A prompt is consent.
|
|
64
|
+
// - --yes with an existing default → leave it alone and say so.
|
|
65
|
+
const hasDefault = !!config.default_org;
|
|
66
|
+
const setDefault = !!flags['set-default'] || !hasDefault ||
|
|
67
|
+
(!flags.yes && !isNonInteractive() && await confirm('› Set as default org and funnel? (Y/n) ', true));
|
|
53
68
|
let funnelId;
|
|
54
69
|
if (setDefault) {
|
|
55
70
|
config.default_org = org.id;
|
|
@@ -59,8 +74,18 @@ export async function create(restArgs, flags) {
|
|
|
59
74
|
funnelId = funnels[0].id;
|
|
60
75
|
}
|
|
61
76
|
saveConfig(config);
|
|
62
|
-
if (!flags.json)
|
|
77
|
+
if (!flags.json) {
|
|
63
78
|
success(`Default org${funnelId ? ' and funnel' : ''} updated.`);
|
|
79
|
+
if (!hasDefault)
|
|
80
|
+
info(' (you had no default org — set because an org-scoped command needs one)');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else if (!flags.json && hasDefault) {
|
|
84
|
+
// Say what did NOT happen. Silence here is what let a throwaway org take
|
|
85
|
+
// over a machine without anyone noticing which command did it.
|
|
86
|
+
const name = config.org_names?.[config.default_org] ?? config.default_org;
|
|
87
|
+
info(` Your default org is unchanged (${name}).`);
|
|
88
|
+
info(` Work in the new one with: myapi init --org ${org.id} (binds this directory only)`);
|
|
64
89
|
}
|
|
65
90
|
if (flags.json) {
|
|
66
91
|
const result = { id: org.id, name: org.name };
|
|
@@ -232,16 +257,25 @@ Examples:
|
|
|
232
257
|
myapi org update --tagline "Now with even more cowbell"
|
|
233
258
|
myapi org update abc-123 --name "Acme Inc" --description "Updated tagline"
|
|
234
259
|
myapi org update --logo-url https://example.com/logo.png --json`,
|
|
235
|
-
'create': `myapi org create <name> [--tagline <str>] [--description <str>] [--business-sector <str>] [--logo-url <url>] [--yes] [--json]
|
|
260
|
+
'create': `myapi org create <name> [--tagline <str>] [--description <str>] [--business-sector <str>] [--logo-url <url>] [--yes] [--set-default] [--json]
|
|
236
261
|
myapi org create --name <name> [...]
|
|
237
262
|
|
|
238
263
|
Creates a new organization. A funnel (website) is automatically created alongside it.
|
|
239
|
-
|
|
264
|
+
|
|
265
|
+
--yes skips the "set as default?" prompt and leaves your default org ALONE.
|
|
266
|
+
--set-default points your machine's default at the new org.
|
|
267
|
+
|
|
268
|
+
They are separate because they used to be the same flag: every scripted
|
|
269
|
+
\`org create --yes\` re-pointed the whole machine, so a throwaway org created by
|
|
270
|
+
one project became the target of bare commands in every other project.
|
|
271
|
+
|
|
272
|
+
To work in the new org without touching anything else, bind the directory:
|
|
273
|
+
myapi init --org <new-org-id>
|
|
240
274
|
|
|
241
275
|
Examples:
|
|
242
276
|
myapi org create "Acme Inc"
|
|
243
|
-
myapi org create "Acme Inc" --yes
|
|
244
|
-
myapi org create
|
|
277
|
+
myapi org create "Acme Inc" --yes # default org untouched
|
|
278
|
+
myapi org create "Acme Inc" --yes --set-default`,
|
|
245
279
|
'get': 'myapi org get <id> [--json]',
|
|
246
280
|
'delete': `myapi org delete <id> [--yes]
|
|
247
281
|
|
package/dist/index.js
CHANGED
|
@@ -518,7 +518,7 @@ Commands:
|
|
|
518
518
|
install-skills Install or update the MyAPI skills pack for AI agents
|
|
519
519
|
llm Run LLM completions and embeddings (chat + embed, with usage/cost)
|
|
520
520
|
login Sign in via your browser — Google or email code (preview: --mock)
|
|
521
|
-
org Manage organizations (
|
|
521
|
+
org Manage organizations (create, list, delete; --set-default to re-point the machine)
|
|
522
522
|
payments Take payments with Stripe Checkout (connect, charge, refund)
|
|
523
523
|
people Search the contact database (filter by industry, seniority, country, ...)
|
|
524
524
|
pixel Read pixel analytics: visits, events, identity resolution
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Every customer incident names the guard that fails if it comes back — and
|
|
2
|
+
// this checks the guard is really there.
|
|
3
|
+
//
|
|
4
|
+
// A register that nobody verifies is worse than none: it reads as "we checked,
|
|
5
|
+
// we know" while the test it names has been renamed, skipped or deleted. That
|
|
6
|
+
// exact rot has already happened three times in this repo's linters, in the
|
|
7
|
+
// form of waivers claiming gaps that were closed.
|
|
8
|
+
//
|
|
9
|
+
// So the register is only as good as this file. It asserts, for every entry:
|
|
10
|
+
// the guard file exists, it still contains the named assertion, and that
|
|
11
|
+
// assertion is not skipped. Entries with no guard must say why, so a gap is
|
|
12
|
+
// counted rather than quietly absent.
|
|
13
|
+
//
|
|
14
|
+
// What it deliberately does NOT claim: that we have thought of everything. It
|
|
15
|
+
// covers incidents customers have already hit. The unknown ones are the
|
|
16
|
+
// probe's job (scripts/probe/probe.mjs) — this is memory, that is discovery.
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
const REPO_ROOT = fileURLToPath(new URL('../../../', import.meta.url));
|
|
22
|
+
const REGISTER = path.join(REPO_ROOT, 'regressions.json');
|
|
23
|
+
const register = JSON.parse(readFileSync(REGISTER, 'utf-8'));
|
|
24
|
+
describe('the regression register', () => {
|
|
25
|
+
it('has entries (or it is a file that proves nothing)', () => {
|
|
26
|
+
expect(register.incidents.length).toBeGreaterThanOrEqual(9);
|
|
27
|
+
});
|
|
28
|
+
it.each(register.incidents.map(i => [i.id, i]))('%s — its guard exists and still asserts it', (_id, incident) => {
|
|
29
|
+
const guardPath = path.join(REPO_ROOT, incident.guard.file);
|
|
30
|
+
expect(existsSync(guardPath), `${incident.guard.file} is named as the guard for ` +
|
|
31
|
+
`"${incident.id}" and does not exist. Either restore it, or move the incident to ` +
|
|
32
|
+
'"unguarded" with the reason — silently dropping the guard is how a fixed bug ' +
|
|
33
|
+
'comes back.').toBe(true);
|
|
34
|
+
const source = readFileSync(guardPath, 'utf-8');
|
|
35
|
+
expect(source.includes(incident.guard.test), `${incident.guard.file} no longer contains "${incident.guard.test}".\n` +
|
|
36
|
+
`That assertion is the only thing standing between this customer and:\n ` +
|
|
37
|
+
`${incident.symptom}\n` +
|
|
38
|
+
'Renaming it is fine — update regressions.json to match.').toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it.each(register.incidents.map(i => [i.id, i]))('%s — its guard is not skipped', (_id, incident) => {
|
|
41
|
+
const source = readFileSync(path.join(REPO_ROOT, incident.guard.file), 'utf-8');
|
|
42
|
+
// A skipped guard is the worst shape: present, green, and asserting
|
|
43
|
+
// nothing. Cheaper to catch here than to discover from a repeat report.
|
|
44
|
+
const skipped = new RegExp(`(it|test|describe)\\.(skip|todo)\\(\\s*['\`"]${escapeRe(incident.guard.test)}`).test(source) || /describe\.skip\(/.test(source);
|
|
45
|
+
expect(skipped, `the guard for "${incident.id}" is skipped`).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
it('every incident says who it cost and what it cost them', () => {
|
|
48
|
+
// The register is read when someone is deciding whether a behaviour is safe
|
|
49
|
+
// to change. Without the cost it reads as a list of trivia, and gets
|
|
50
|
+
// treated like one.
|
|
51
|
+
const thin = register.incidents.filter(i => !i.symptom?.trim() || !i.cost?.trim() || !i.customer?.trim());
|
|
52
|
+
expect(thin.map(i => i.id)).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
it('every unguarded gap explains itself, so none is silent', () => {
|
|
55
|
+
const unexplained = (register.unguarded ?? []).filter(u => !u.why_no_guard_here?.trim());
|
|
56
|
+
expect(unexplained.map(u => u.id)).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
it('reports how much is NOT guarded here, rather than only what is', () => {
|
|
59
|
+
// Printed on every run. A register that only ever shows its green half
|
|
60
|
+
// teaches the reader it is complete, which is the one thing it is not.
|
|
61
|
+
const gaps = register.unguarded ?? [];
|
|
62
|
+
// eslint-disable-next-line no-console
|
|
63
|
+
console.log(` regression register: ${register.incidents.length} incident(s) guarded here, ` +
|
|
64
|
+
`${gaps.length} not (${gaps.filter(g => g.cross_repo).length} guarded in myapi-hq, ` +
|
|
65
|
+
`${gaps.filter(g => !g.cross_repo).length} not guarded anywhere).`);
|
|
66
|
+
expect(Array.isArray(gaps)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
function escapeRe(s) {
|
|
70
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
71
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: my-api-hq
|
|
3
|
-
version: 1.3.
|
|
3
|
+
version: 1.3.2
|
|
4
4
|
description: >
|
|
5
5
|
Auth, organizations, and billing hub. Start here to get an api_key and org_id — every other service depends on both.
|
|
6
6
|
triggers: [api key, account, organization, org, billing, balance, topup, credits, setup, defaults, brand, sync brand, doctor, health check, is my org healthy]
|
|
7
|
-
checksum: sha256-
|
|
7
|
+
checksum: sha256-d09b77d74b93ca44d7b913fce6b684a6dda21d0dff9efe2686143abffeb217ee
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# MyApiHQ
|
|
@@ -26,7 +26,7 @@ An anonymous account upgrades any time with `myapi account link <email>`; the cr
|
|
|
26
26
|
|
|
27
27
|
### Health check
|
|
28
28
|
|
|
29
|
-
`myapi doctor` runs an org-wide consistency check across every slot, plus customer-perspective DNS/HTTP probes from your machine.
|
|
29
|
+
`myapi doctor` runs an org-wide consistency check across every slot, plus customer-perspective DNS/HTTP probes from your machine. Per-section findings (`✓` / `⚠` / `✗`) with remediation hints; `--json` for machine output. Exit is non-zero **only** on customer-actionable criticals — platform-side issues show as `ℹ` and do not fail the run, so it works as a CI gate.
|
|
30
30
|
<!-- llm:end -->
|
|
31
31
|
|
|
32
32
|
## Commands
|
|
@@ -57,6 +57,7 @@ An anonymous account upgrades any time with `myapi account link <email>`; the cr
|
|
|
57
57
|
| `myapi account mailing-address ["<address>"]` | Get or set the account's CAN-SPAM mailing address (required for email send) |
|
|
58
58
|
| `myapi init --org <id>` | Bind this directory to one org: locked key + `.myapi.json` (`--force` re-binds). Use instead of `config set-org` when a machine has more than one project |
|
|
59
59
|
| `myapi config set-org <id>` / `set-funnel <id>` / `set-domain <name>` | Set machine-wide CLI defaults (single-project machines only) |
|
|
60
|
+
| `myapi org create <name> --yes` | Create an org; your default org is untouched (`--set-default` re-points it) |
|
|
60
61
|
| `myapi install-skills` | Install agent skills into ~/.claude/, ~/.gemini/, ~/.cursor/ |
|
|
61
62
|
| `myapi doctor [--verbose] [--json]` | Org-wide health check: config/integrity findings + DNS/HTTP probes |
|
|
62
63
|
<!-- generated:end -->
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: my-funnel-api
|
|
3
|
-
version: 1.0.
|
|
3
|
+
version: 1.0.1
|
|
4
4
|
description: >
|
|
5
5
|
Create and publish websites (funnels) to the edge. Push raw HTML to any slug and it goes live instantly on your org's domain or preview subdomain.
|
|
6
6
|
triggers: [funnel, landing page, website, page, publish, push, slug, html, edge, preview subdomain, makeautonomous]
|
|
7
|
-
checksum: sha256-
|
|
7
|
+
checksum: sha256-7bcaeef9bd17839aa28c62a8e39f67e29a39967397f9e0ee717e19765983e499
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# MyFunnelAPI
|
|
@@ -19,7 +19,7 @@ Funnels are the publishing surface. You create a funnel under an org (one comman
|
|
|
19
19
|
|
|
20
20
|
By default, your funnel lives on a free preview subdomain (`*.makeautonomous.com`) you get with every org. To serve on a custom domain, register and assign one via **mydomainapi** first.
|
|
21
21
|
|
|
22
|
-
Every funnel auto-provisions a **webhook** at creation (`org_webhook_id`)
|
|
22
|
+
Every funnel auto-provisions a **webhook** at creation (`org_webhook_id`) and exposes two public endpoints your HTML can call without an API key: **form submit** at `POST /funnel/funnels/{id}/submit/{slug}`, which delivers through that webhook (CRM upsert + bound workflows fire automatically), and **event ingest** for pageviews and click tracking. Send a slug elsewhere with `myapi funnel form ... --capture-to webhook:<id>`.
|
|
23
23
|
<!-- llm:end -->
|
|
24
24
|
|
|
25
25
|
## Commands
|
|
@@ -32,7 +32,7 @@ Every funnel auto-provisions a **webhook** at creation (`org_webhook_id`), and e
|
|
|
32
32
|
| `myapi funnel delete <id>` | Delete the funnel and purge its edge pages |
|
|
33
33
|
| `myapi funnel push [slug]` | Push HTML from stdin to a slug (default: `/`). **Overwrites** an existing page — refused without `--force`. `--json` prints `{slug, subdomain_url, overwritten, org_id, funnel_id}` |
|
|
34
34
|
| `myapi funnel publish <dir>` | Upload a whole directory as the funnel's site (`--env dev\|prod`, default prod; `--api-fn <id>`; `--json`). Prod refuses to replace a live site without `--force` |
|
|
35
|
-
| `myapi funnel pages [funnel_id]` |
|
|
35
|
+
| `myapi funnel pages [funnel_id]` | Pages published to a funnel; `--with-content` fetches what is on them |
|
|
36
36
|
| `myapi funnel form [funnel_id]` | Emit canonical form HTML (`--capture-to`, `--cta`, `--success`, `--honeypot`) |
|
|
37
37
|
| `myapi funnel verify [slug]` | Verify a published page is reachable + check links/webhooks |
|
|
38
38
|
<!-- generated:end -->
|
|
@@ -42,7 +42,7 @@ Every funnel auto-provisions a **webhook** at creation (`org_webhook_id`), and e
|
|
|
42
42
|
A write targets a `(org, funnel, slug)` address. Get all three right *before* pushing — the CLI will help, but the thinking is yours:
|
|
43
43
|
|
|
44
44
|
1. **Confirm the org.** `--org` (or `default_org`) decides whose namespace you touch. For a demo or a new project, pass `--org <id>` explicitly every time — don't trust the ambient default. `myapi org list` shows the orgs you can reach.
|
|
45
|
-
2. **Look before you write.** `myapi funnel list --org <id>` shows the org's funnels; `myapi funnel pages --funnel <id>` shows what
|
|
45
|
+
2. **Look before you write.** `myapi funnel list --org <id>` shows the org's funnels; `myapi funnel pages --funnel <id>` shows what is published, and `--with-content` what is on each page (`--json` carries it). `push` replaces a whole page, so edit by reading it first.
|
|
46
46
|
3. **A new demo = a new funnel.** Don't reuse an org's existing funnel for an unrelated demo. `myapi funnel create --name <demo> --org <id>` gives you a clean namespace and its own preview subdomain.
|
|
47
47
|
4. **Pin the funnel.** When an org has exactly one funnel, `push`/`publish` auto-pick it — convenient, but it's how a demo lands on the wrong site. Pass `--funnel <id>` (or set a default) so the target is explicit, not inferred.
|
|
48
48
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myapihq/cli",
|
|
3
3
|
"license": "Apache-2.0",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.27.0",
|
|
5
5
|
"description": "MyAPI command-line interface",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@myapihq/sdk": "^2.
|
|
49
|
+
"@myapihq/sdk": "^2.27.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^25.6.0",
|