@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.
- package/dist/commands/crm/companies.js +1 -5
- package/dist/commands/crm/contacts.js +1 -6
- package/dist/commands/domain-validation.test.d.ts +1 -0
- package/dist/commands/domain-validation.test.js +36 -0
- package/dist/commands/domain.d.ts +14 -0
- package/dist/commands/domain.js +20 -1
- package/dist/commands/funnel-validation.test.d.ts +1 -0
- package/dist/commands/funnel-validation.test.js +86 -0
- package/dist/commands/funnel.d.ts +12 -0
- package/dist/commands/funnel.js +59 -15
- package/dist/commands/login-validation.test.js +24 -2
- package/dist/commands/login.d.ts +6 -0
- package/dist/commands/login.js +48 -17
- package/dist/commands/payments.js +12 -2
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +53 -4
- package/dist/commands/skill-checksum.test.d.ts +1 -0
- package/dist/commands/skill-checksum.test.js +48 -0
- package/dist/config-envkey.test.d.ts +1 -0
- package/dist/config-envkey.test.js +145 -0
- package/dist/config.js +41 -5
- package/dist/errors.js +11 -0
- package/dist/index.js +6 -1
- package/dist/sdk-transport.test.d.ts +1 -0
- package/dist/sdk-transport.test.js +138 -0
- package/dist/sdk-version.test.d.ts +1 -0
- package/dist/sdk-version.test.js +24 -0
- package/dist/skills/my-api-hq/SKILL.md +1 -1
- package/dist/skills/my-api-hq/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-audience-api/README.md +40 -0
- package/dist/skills/my-audience-api/SKILL.md +139 -0
- package/dist/skills/my-audience-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-audience-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-auth-api/SKILL.md +1 -1
- package/dist/skills/my-auth-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-company-api/README.md +35 -0
- package/dist/skills/my-company-api/SKILL.md +110 -0
- package/dist/skills/my-company-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-company-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-container-api/README.md +34 -0
- package/dist/skills/my-container-api/SKILL.md +77 -0
- package/dist/skills/my-container-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-container-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-crm-api/SKILL.md +3 -3
- package/dist/skills/my-crm-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-database-api/SKILL.md +1 -1
- package/dist/skills/my-database-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-domain-api/SKILL.md +1 -1
- package/dist/skills/my-domain-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-email-api/SKILL.md +1 -1
- package/dist/skills/my-email-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-email-verify-api/SKILL.md +1 -1
- package/dist/skills/my-email-verify-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-function-api/SKILL.md +104 -0
- package/dist/skills/my-function-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-funnel-api/SKILL.md +2 -2
- package/dist/skills/my-funnel-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-git-api/SKILL.md +1 -1
- package/dist/skills/my-git-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-image-api/SKILL.md +1 -1
- package/dist/skills/my-image-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-llm-api/SKILL.md +6 -11
- package/dist/skills/my-llm-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-payments-api/SKILL.md +85 -0
- package/dist/skills/my-payments-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-people-api/README.md +35 -0
- package/dist/skills/my-people-api/SKILL.md +107 -0
- package/dist/skills/my-people-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-people-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-pixel-api/README.md +38 -0
- package/dist/skills/my-pixel-api/SKILL.md +90 -0
- package/dist/skills/my-pixel-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-pixel-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-queue-api/README.md +34 -0
- package/dist/skills/my-queue-api/SKILL.md +78 -0
- package/dist/skills/my-queue-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-queue-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-storage-api/SKILL.md +1 -1
- package/dist/skills/my-storage-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-task-api/README.md +36 -0
- package/dist/skills/my-task-api/SKILL.md +74 -0
- package/dist/skills/my-task-api/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-task-api/openapi/.gitkeep +0 -0
- package/dist/skills/my-url-to/README.md +32 -0
- package/dist/skills/my-url-to/SKILL.md +55 -0
- package/dist/skills/my-url-to/claude/.claude-plugin/plugin.json +7 -0
- package/dist/skills/my-url-to/openapi/.gitkeep +0 -0
- package/dist/skills/my-webhook-api/SKILL.md +1 -1
- package/dist/skills/my-webhook-api/claude/.claude-plugin/plugin.json +1 -0
- package/dist/skills/my-workflow-api/SKILL.md +1 -1
- package/dist/skills/my-workflow-api/claude/.claude-plugin/plugin.json +1 -0
- 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.
|
|
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.
|
|
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>;
|
package/dist/commands/domain.js
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/commands/funnel.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
185
|
-
if (clash
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 =
|
|
398
|
+
const url = `${formSubmitBase()}/funnel/funnels/${funnelId}/submit/${encodeURIComponent(slug)}`;
|
|
355
399
|
const escAttr = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
356
400
|
const escText = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
});
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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(`›
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/setup.js
CHANGED
|
@@ -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'),
|
|
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(`›
|
|
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(`›
|
|
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 {};
|