@myapihq/cli 2.25.2 → 2.26.1
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/init.d.ts +7 -0
- package/dist/commands/init.js +134 -0
- 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/completion.js +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.js +34 -0
- package/dist/exposes.test.js +1 -0
- package/dist/helpers.d.ts +16 -0
- package/dist/helpers.js +50 -5
- package/dist/index.js +6 -1
- package/dist/org-resolution.test.d.ts +1 -0
- package/dist/org-resolution.test.js +157 -0
- package/dist/project.d.ts +20 -0
- package/dist/project.js +59 -0
- 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 +14 -10
- package/package.json +2 -2
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Flags } from '../helpers.js';
|
|
2
|
+
import type { FlagSchema } from '../flags.js';
|
|
3
|
+
import type { Exposes } from '../exposes.js';
|
|
4
|
+
export declare const SCHEMA: FlagSchema;
|
|
5
|
+
export declare const EXPOSES: Exposes;
|
|
6
|
+
export declare const HELP = "Usage: myapi init --org <org_id> [--name <label>] [--spend-cap <usd>]\n\nBind THIS DIRECTORY to one org, so commands run here can never target another.\n\nIt mints an API key locked to that org, stores it, and writes .myapi.json\nnaming the org. The file holds the org id and never the key.\n\n --org <id> The org this project works in (required)\n --name <label> Key name; defaults to the directory name\n --spend-cap <usd> Ceiling for this key, e.g. --spend-cap 50\n --grant <list> Narrow what it can touch, e.g. --grant crm:write,email:read\n --force Re-bind a directory that already names an org\n\nWhy this exists: `myapi config set-org` writes a default into ~/.myapi/config.json,\none file for the whole machine. Two projects on one laptop overwrite each other's,\nand commands then land in the wrong org while still succeeding. A key that the\nserver has locked to one org cannot do that \u2014 a wrong target is refused (403),\nnot silently accepted.";
|
|
7
|
+
export declare function run(_sub: string | undefined, _args: string[], flags: Flags): Promise<void>;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { hq } from '@myapihq/sdk';
|
|
2
|
+
import { requireConfig, saveConfig, accountLockedTo, loadConfig } from '../config.js';
|
|
3
|
+
import { success, error, info, printJson } from '../output.js';
|
|
4
|
+
import { writeProjectOrg, findProjectOrg, PROJECT_FILE } from '../project.js';
|
|
5
|
+
import { _parseGrants, _dollarsToCents } from './keys.js';
|
|
6
|
+
export const SCHEMA = {
|
|
7
|
+
org: 'string',
|
|
8
|
+
name: 'string',
|
|
9
|
+
// string + _dollarsToCents, matching `keys create` exactly.
|
|
10
|
+
'spend-cap': 'string',
|
|
11
|
+
grant: 'string',
|
|
12
|
+
force: 'boolean',
|
|
13
|
+
};
|
|
14
|
+
export const EXPOSES = ['POST /hq/account/create/key', 'GET /hq/orgs/{id}'];
|
|
15
|
+
export const HELP = `Usage: myapi init --org <org_id> [--name <label>] [--spend-cap <usd>]
|
|
16
|
+
|
|
17
|
+
Bind THIS DIRECTORY to one org, so commands run here can never target another.
|
|
18
|
+
|
|
19
|
+
It mints an API key locked to that org, stores it, and writes ${PROJECT_FILE}
|
|
20
|
+
naming the org. The file holds the org id and never the key.
|
|
21
|
+
|
|
22
|
+
--org <id> The org this project works in (required)
|
|
23
|
+
--name <label> Key name; defaults to the directory name
|
|
24
|
+
--spend-cap <usd> Ceiling for this key, e.g. --spend-cap 50
|
|
25
|
+
--grant <list> Narrow what it can touch, e.g. --grant crm:write,email:read
|
|
26
|
+
--force Re-bind a directory that already names an org
|
|
27
|
+
|
|
28
|
+
Why this exists: \`myapi config set-org\` writes a default into ~/.myapi/config.json,
|
|
29
|
+
one file for the whole machine. Two projects on one laptop overwrite each other's,
|
|
30
|
+
and commands then land in the wrong org while still succeeding. A key that the
|
|
31
|
+
server has locked to one org cannot do that — a wrong target is refused (403),
|
|
32
|
+
not silently accepted.`;
|
|
33
|
+
// keys.ts owns the grant syntax; this reuses its pure parser rather than
|
|
34
|
+
// writing a second one. Two parsers for one flag is exactly how `--org` and
|
|
35
|
+
// the campaign source flags drifted into disagreeing.
|
|
36
|
+
function centsOrDie(raw) {
|
|
37
|
+
const parsed = _dollarsToCents(raw);
|
|
38
|
+
if (typeof parsed === 'string')
|
|
39
|
+
error(parsed);
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
function grantsOrDie(raw) {
|
|
43
|
+
const parsed = _parseGrants(raw);
|
|
44
|
+
if (typeof parsed === 'string')
|
|
45
|
+
error(parsed);
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
export async function run(_sub, _args, flags) {
|
|
49
|
+
if (flags.help) {
|
|
50
|
+
info(HELP);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const config = requireConfig();
|
|
54
|
+
const orgId = typeof flags.org === 'string' ? flags.org : '';
|
|
55
|
+
if (!orgId) {
|
|
56
|
+
error('Missing --org <org_id>.\nWhich org does this project work in? List them with: myapi org list\n\n' + HELP);
|
|
57
|
+
}
|
|
58
|
+
const existing = findProjectOrg();
|
|
59
|
+
if (existing && !flags.force) {
|
|
60
|
+
if (existing.org === orgId) {
|
|
61
|
+
info(`Already bound: ${existing.file} names org ${orgId}.`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
error(`${existing.file} already binds this directory to org ${existing.org}.\n` +
|
|
65
|
+
`Re-bind to ${orgId} with --force, or edit the file directly.`);
|
|
66
|
+
}
|
|
67
|
+
// Name the org before touching anything, so a mistyped id fails here rather
|
|
68
|
+
// than after a key has been minted. It also puts the NAME in front of the
|
|
69
|
+
// user: a wrong UUID is unverifiable at a glance, a wrong name is obvious.
|
|
70
|
+
let orgName;
|
|
71
|
+
try {
|
|
72
|
+
orgName = (await hq.getOrg(config.api_key, orgId))?.name;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
error(`Could not read org ${orgId}. Check the id with: myapi org list`);
|
|
76
|
+
}
|
|
77
|
+
// Reuse a key already locked to this org rather than minting another. Keys
|
|
78
|
+
// are a revocation surface; one per project per org is enough, and silently
|
|
79
|
+
// accumulating them makes the list unreadable exactly when someone is trying
|
|
80
|
+
// to revoke the right one in a hurry.
|
|
81
|
+
const already = accountLockedTo(orgId);
|
|
82
|
+
if (already) {
|
|
83
|
+
const file = writeProjectOrg(process.cwd(), orgId);
|
|
84
|
+
success(`Bound this directory to ${orgName ?? orgId}`);
|
|
85
|
+
info(` ${file}`);
|
|
86
|
+
info(` using the key already locked to this org (${already.email ?? already.account_id})`);
|
|
87
|
+
if (flags.json)
|
|
88
|
+
printJson({ org_id: orgId, org_name: orgName, project_file: file, minted: false });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const label = typeof flags.name === 'string' && flags.name
|
|
92
|
+
? flags.name
|
|
93
|
+
: (process.cwd().split('/').filter(Boolean).pop() ?? 'project');
|
|
94
|
+
// Same type and same parser as `keys create --spend-cap`. When one command
|
|
95
|
+
// declares a flag a string and another a number, the merged schema silently
|
|
96
|
+
// picks one and the loser is parsed wrong — that is how `--ttl` broke.
|
|
97
|
+
const capUsd = typeof flags['spend-cap'] === 'string' && flags['spend-cap']
|
|
98
|
+
? centsOrDie(flags['spend-cap'])
|
|
99
|
+
: undefined;
|
|
100
|
+
const grant = typeof flags.grant === 'string' ? flags.grant : undefined;
|
|
101
|
+
const minted = await hq.createApiKey(config.api_key, label, {
|
|
102
|
+
orgId,
|
|
103
|
+
...(capUsd !== undefined ? { spendCapCents: capUsd } : {}),
|
|
104
|
+
...(grant ? { grants: grantsOrDie(grant) } : {}),
|
|
105
|
+
});
|
|
106
|
+
const value = minted.api_key
|
|
107
|
+
?? minted.key;
|
|
108
|
+
if (!value) {
|
|
109
|
+
error('The API was asked for a key and returned none. Nothing was written; no project file was created.');
|
|
110
|
+
}
|
|
111
|
+
// Stored as its own account entry — saveConfig upserts by api_key, and
|
|
112
|
+
// deliberately does NOT make it active. A project credential must not become
|
|
113
|
+
// the identity every other terminal on this machine signs in as.
|
|
114
|
+
saveConfig({
|
|
115
|
+
...(loadConfig() ?? { api_key: value, account_id: config.account_id }),
|
|
116
|
+
api_key: value,
|
|
117
|
+
account_id: config.account_id,
|
|
118
|
+
email: config.email,
|
|
119
|
+
key_org_id: orgId,
|
|
120
|
+
default_org: orgId,
|
|
121
|
+
org_names: { ...(config.org_names ?? {}), ...(orgName ? { [orgId]: orgName } : {}) },
|
|
122
|
+
});
|
|
123
|
+
const file = writeProjectOrg(process.cwd(), orgId);
|
|
124
|
+
if (flags.json) {
|
|
125
|
+
printJson({ org_id: orgId, org_name: orgName, project_file: file, key_id: minted.id, minted: true });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
success(`Bound this directory to ${orgName ?? orgId}`);
|
|
129
|
+
info(` ${file} names the org (no key in it — safe to commit)`);
|
|
130
|
+
info(` key "${label}" locked to this org${capUsd !== undefined ? `, capped at $${(capUsd / 100).toFixed(2)}` : ''}`);
|
|
131
|
+
info('');
|
|
132
|
+
info('Commands run here now target this org without --org, and a command that');
|
|
133
|
+
info('names a different one is refused before it is sent.');
|
|
134
|
+
}
|
|
@@ -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/completion.js
CHANGED
|
@@ -27,7 +27,7 @@ const BLOCK_END = `# end ${PROGRAM} completion`;
|
|
|
27
27
|
// is missing here.
|
|
28
28
|
export const COMMANDS = [
|
|
29
29
|
'account', 'audience', 'auth', 'billing', 'company', 'completion', 'config', 'container',
|
|
30
|
-
'crm', 'database', 'domain', 'email', 'fn', 'funnel', 'git', 'help', 'image',
|
|
30
|
+
'crm', 'database', 'domain', 'email', 'fn', 'funnel', 'git', 'help', 'image', 'init',
|
|
31
31
|
'doctor', 'install-skills', 'keys', 'llm', 'login', 'org', 'payments', 'people', 'pixel',
|
|
32
32
|
'feedback', 'queue', 'setup', 'status', 'storage', 'task', 'update', 'url', 'webhook', 'whoami',
|
|
33
33
|
'workflow',
|
package/dist/config.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface AccountEntry {
|
|
|
9
9
|
registrant?: sdkDomain.Registrant;
|
|
10
10
|
is_anonymous?: boolean;
|
|
11
11
|
skills_installed?: boolean;
|
|
12
|
+
key_org_id?: string;
|
|
12
13
|
last_org_used?: string;
|
|
13
14
|
org_names?: Record<string, string>;
|
|
14
15
|
}
|
|
@@ -24,6 +25,16 @@ export declare const CONFIG_DIR: string;
|
|
|
24
25
|
export declare const CONFIG_FILE: string;
|
|
25
26
|
export declare function loadFullConfig(): FullConfig | null;
|
|
26
27
|
export declare function loadConfig(): Config | null;
|
|
28
|
+
/**
|
|
29
|
+
* The stored account whose key is locked to `orgId`, if there is one.
|
|
30
|
+
*
|
|
31
|
+
* This is what lets a project directory name an org and get the right
|
|
32
|
+
* credential without keeping a secret in the repo: the project file says which
|
|
33
|
+
* org, the config says which key belongs to it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function accountLockedTo(orgId: string): AccountEntry | undefined;
|
|
36
|
+
/** Every distinct org this machine holds an org-locked key for. */
|
|
37
|
+
export declare function lockedOrgs(): string[];
|
|
27
38
|
export declare function saveConfig(config: Config): void;
|
|
28
39
|
export declare function addAccount(account: AccountEntry): number;
|
|
29
40
|
export declare function switchAccount(index: number): boolean;
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import * as os from 'os';
|
|
4
|
+
import { findProjectOrg } from './project.js';
|
|
4
5
|
// MYAPI_CONFIG_DIR redirects the whole config surface — key, accounts,
|
|
5
6
|
// defaults — somewhere other than ~/.myapi.
|
|
6
7
|
//
|
|
@@ -79,11 +80,44 @@ export function loadConfig() {
|
|
|
79
80
|
}
|
|
80
81
|
if (!active)
|
|
81
82
|
return null;
|
|
83
|
+
// The directory picks the CREDENTIAL, not just the org.
|
|
84
|
+
//
|
|
85
|
+
// Resolving the org from .myapi.json while still authenticating with the
|
|
86
|
+
// account-wide key gets the right answer by client-side convention only —
|
|
87
|
+
// the server has nothing to enforce, because an account-wide key may touch
|
|
88
|
+
// every org. Selecting the key that is LOCKED to this project's org is what
|
|
89
|
+
// turns the whole thing from a default into a boundary: from here on a
|
|
90
|
+
// mistake is a 403, not a write into the wrong tenant.
|
|
91
|
+
//
|
|
92
|
+
// Falls through to the active account when the directory names no org, or
|
|
93
|
+
// names one this machine holds no locked key for.
|
|
94
|
+
const dirOrg = process.env.MYAPI_ORG || findProjectOrg()?.org;
|
|
95
|
+
if (dirOrg) {
|
|
96
|
+
const locked = full.accounts.find(a => a.key_org_id === dirOrg);
|
|
97
|
+
if (locked)
|
|
98
|
+
return { ...locked, autocomplete_setup: full.autocomplete_setup };
|
|
99
|
+
}
|
|
82
100
|
return {
|
|
83
101
|
...active,
|
|
84
102
|
autocomplete_setup: full.autocomplete_setup,
|
|
85
103
|
};
|
|
86
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* The stored account whose key is locked to `orgId`, if there is one.
|
|
107
|
+
*
|
|
108
|
+
* This is what lets a project directory name an org and get the right
|
|
109
|
+
* credential without keeping a secret in the repo: the project file says which
|
|
110
|
+
* org, the config says which key belongs to it.
|
|
111
|
+
*/
|
|
112
|
+
export function accountLockedTo(orgId) {
|
|
113
|
+
const full = loadFullConfig();
|
|
114
|
+
return full?.accounts.find(a => a.key_org_id === orgId);
|
|
115
|
+
}
|
|
116
|
+
/** Every distinct org this machine holds an org-locked key for. */
|
|
117
|
+
export function lockedOrgs() {
|
|
118
|
+
const full = loadFullConfig();
|
|
119
|
+
return [...new Set((full?.accounts ?? []).map(a => a.key_org_id).filter(Boolean))];
|
|
120
|
+
}
|
|
87
121
|
// Single point of truth for writes. Ensures CONFIG_DIR exists and that the
|
|
88
122
|
// file is created with mode 0o600 (owner read/write only) — no caller has
|
|
89
123
|
// to remember either.
|
package/dist/exposes.test.js
CHANGED
package/dist/helpers.d.ts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { type Config } from './config.js';
|
|
2
2
|
export type Flags = Record<string, string | boolean | number>;
|
|
3
|
+
export type OrgSource = 'flag' | 'env' | 'project' | 'key' | 'default';
|
|
4
|
+
export interface ResolvedOrg {
|
|
5
|
+
org: string;
|
|
6
|
+
source: OrgSource;
|
|
7
|
+
/** For 'project', the file it came from, so output can name it. */
|
|
8
|
+
from?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolves the org without side effects, so it can be tested and reported on
|
|
12
|
+
* (`myapi status`) rather than only observed by running a real command.
|
|
13
|
+
*
|
|
14
|
+
* Precedence: --org > MYAPI_ORG > nearest .myapi.json > the key's own lock >
|
|
15
|
+
* default_org. The first four are per-invocation or per-directory. The last is
|
|
16
|
+
* the machine-global one that two projects fight over.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveOrg(flags: Flags, config: Config): ResolvedOrg | undefined;
|
|
3
19
|
export declare function requireOrg(flags: Flags, config: Config, usage: string): string;
|
|
4
20
|
export declare function orgLine(orgId: string, name: string | undefined, changedFrom?: string): string;
|
|
5
21
|
/** The org this invocation resolved to, for `--json` consumers. */
|
package/dist/helpers.js
CHANGED
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
import { error, banner } from './output.js';
|
|
2
|
+
import { findProjectOrg } from './project.js';
|
|
2
3
|
import { loadConfig, saveConfig } from './config.js';
|
|
3
4
|
import { confirm, isNonInteractive } from './prompt.js';
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the org without side effects, so it can be tested and reported on
|
|
7
|
+
* (`myapi status`) rather than only observed by running a real command.
|
|
8
|
+
*
|
|
9
|
+
* Precedence: --org > MYAPI_ORG > nearest .myapi.json > the key's own lock >
|
|
10
|
+
* default_org. The first four are per-invocation or per-directory. The last is
|
|
11
|
+
* the machine-global one that two projects fight over.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveOrg(flags, config) {
|
|
14
|
+
if (typeof flags.org === 'string' && flags.org)
|
|
15
|
+
return { org: flags.org, source: 'flag' };
|
|
16
|
+
const env = process.env.MYAPI_ORG;
|
|
17
|
+
if (env)
|
|
18
|
+
return { org: env, source: 'env' };
|
|
19
|
+
const project = findProjectOrg();
|
|
20
|
+
if (project)
|
|
21
|
+
return { org: project.org, source: 'project', from: project.file };
|
|
22
|
+
// A locked key does not need to be told which org it is for — the server will
|
|
23
|
+
// only accept one. Ranking it above default_org means an org-locked key makes
|
|
24
|
+
// the machine-global default irrelevant, which is the whole point.
|
|
25
|
+
if (config.key_org_id)
|
|
26
|
+
return { org: config.key_org_id, source: 'key' };
|
|
27
|
+
if (config.default_org)
|
|
28
|
+
return { org: config.default_org, source: 'default' };
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
6
31
|
export function requireOrg(flags, config, usage) {
|
|
7
32
|
// A present-but-empty --org (e.g. --org "$ORG" with $ORG unset, or a bare
|
|
8
33
|
// --org that swallowed no value) must never degrade to the default org —
|
|
@@ -10,14 +35,34 @@ export function requireOrg(flags, config, usage) {
|
|
|
10
35
|
if ('org' in flags && (flags.org === '' || flags.org === true)) {
|
|
11
36
|
error(`--org was passed without a value. Refusing to fall back to the default org.\nUsage: ${usage}`);
|
|
12
37
|
}
|
|
13
|
-
const
|
|
14
|
-
if (!
|
|
15
|
-
error(`Missing required arguments.\nUsage: ${usage}\n
|
|
38
|
+
const resolved = resolveOrg(flags, config);
|
|
39
|
+
if (!resolved) {
|
|
40
|
+
error(`Missing required arguments.\nUsage: ${usage}\n` +
|
|
41
|
+
`(This directory has no org. Set one up with: myapi init --org <id>)`);
|
|
42
|
+
}
|
|
43
|
+
const orgId = resolved.org;
|
|
44
|
+
// The key is locked server-side; a mismatch is a guaranteed 403. Saying so
|
|
45
|
+
// here costs nothing and can explain WHICH org the key belongs to, which the
|
|
46
|
+
// server's refusal cannot: it will not name another tenant's id back to a
|
|
47
|
+
// caller that has no authority over it.
|
|
48
|
+
if (config.key_org_id && orgId !== config.key_org_id) {
|
|
49
|
+
error(`This API key is locked to org ${config.key_org_id}, but ${describeSource(resolved)} says ${orgId}.\n` +
|
|
50
|
+
`The server would refuse this (403 SCOPE_FORBIDDEN), so nothing was sent.\n` +
|
|
51
|
+
`Use a key for ${orgId}, or drop the override to work in ${config.key_org_id}.`);
|
|
16
52
|
}
|
|
17
53
|
announceOrg(orgId, config);
|
|
18
54
|
recordResolvedOrg(orgId, config);
|
|
19
55
|
return orgId;
|
|
20
56
|
}
|
|
57
|
+
function describeSource(r) {
|
|
58
|
+
switch (r.source) {
|
|
59
|
+
case 'flag': return '--org';
|
|
60
|
+
case 'env': return 'MYAPI_ORG';
|
|
61
|
+
case 'project': return r.from ?? '.myapi.json';
|
|
62
|
+
case 'key': return 'the key';
|
|
63
|
+
case 'default': return 'your machine-wide default org';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
21
66
|
// ── Which org am I in? ───────────────────────────────────────────────────────
|
|
22
67
|
//
|
|
23
68
|
// The org is ambient, sticky and invisible: it comes from a saved default that
|
package/dist/index.js
CHANGED
|
@@ -35,6 +35,7 @@ import * as urlCmd from './commands/url.js';
|
|
|
35
35
|
import * as authProductCmd from './commands/authproduct.js';
|
|
36
36
|
import * as configCmd from './commands/config.js';
|
|
37
37
|
import * as statusCmd from './commands/status.js';
|
|
38
|
+
import * as initCmd from './commands/init.js';
|
|
38
39
|
import * as peopleCmd from './commands/people.js';
|
|
39
40
|
import * as companyCmd from './commands/company.js';
|
|
40
41
|
import * as audienceCmd from './commands/audience.js';
|
|
@@ -317,6 +318,9 @@ async function main() {
|
|
|
317
318
|
case 'status':
|
|
318
319
|
await statusCmd.run(subcommand, restArgs, flags);
|
|
319
320
|
break;
|
|
321
|
+
case 'init':
|
|
322
|
+
await initCmd.run(subcommand, restArgs, flags);
|
|
323
|
+
break;
|
|
320
324
|
case 'keys':
|
|
321
325
|
await keysCmd.run(subcommand, restArgs, flags);
|
|
322
326
|
break;
|
|
@@ -510,10 +514,11 @@ Commands:
|
|
|
510
514
|
funnel Manage websites (publish pages, custom domains, funnels)
|
|
511
515
|
git Hosted git repositories — repos, commits, branches, history
|
|
512
516
|
image Generate AI images and manage them in storage
|
|
517
|
+
init Bind this directory to one org (locked key + .myapi.json)
|
|
513
518
|
install-skills Install or update the MyAPI skills pack for AI agents
|
|
514
519
|
llm Run LLM completions and embeddings (chat + embed, with usage/cost)
|
|
515
520
|
login Sign in via your browser — Google or email code (preview: --mock)
|
|
516
|
-
org Manage organizations (
|
|
521
|
+
org Manage organizations (create, list, delete; --set-default to re-point the machine)
|
|
517
522
|
payments Take payments with Stripe Checkout (connect, charge, refund)
|
|
518
523
|
people Search the contact database (filter by industry, seniority, country, ...)
|
|
519
524
|
pixel Read pixel analytics: visits, events, identity resolution
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Which org does a command target, and can a second project on the same
|
|
2
|
+
// machine change the answer?
|
|
3
|
+
//
|
|
4
|
+
// `myapi config set-org` writes `default_org` into ~/.myapi/config.json — one
|
|
5
|
+
// file per MACHINE. Two checkouts share it: project A sets A, project B sets B,
|
|
6
|
+
// and every later bare command in A targets B. It succeeds, against the wrong
|
|
7
|
+
// tenant. That is the most-reported problem with the CLI and it is a scope
|
|
8
|
+
// mismatch, not a bug: the org is a property of the work, stored per machine.
|
|
9
|
+
//
|
|
10
|
+
// The chain here fixes it by putting four per-invocation or per-directory
|
|
11
|
+
// sources ahead of the machine-global one, and by refusing outright when the
|
|
12
|
+
// key's server-side lock disagrees with any of them.
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as os from 'node:os';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import { resolveOrg } from './helpers.js';
|
|
18
|
+
import { findProjectOrg, writeProjectOrg } from './project.js';
|
|
19
|
+
const A = 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa';
|
|
20
|
+
const B = 'bbbbbbbb-2222-4222-8222-bbbbbbbbbbbb';
|
|
21
|
+
const base = { api_key: 'hq_live_test', account_id: 'acct' };
|
|
22
|
+
let tmp;
|
|
23
|
+
let cwdSpy;
|
|
24
|
+
function chdirTo(dir) {
|
|
25
|
+
cwdSpy?.mockRestore();
|
|
26
|
+
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(dir);
|
|
27
|
+
}
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'myapi-org-'));
|
|
30
|
+
delete process.env.MYAPI_ORG;
|
|
31
|
+
chdirTo(tmp);
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
cwdSpy?.mockRestore();
|
|
35
|
+
delete process.env.MYAPI_ORG;
|
|
36
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
describe('org resolution order', () => {
|
|
39
|
+
it('--org beats everything', () => {
|
|
40
|
+
process.env.MYAPI_ORG = B;
|
|
41
|
+
writeProjectOrg(tmp, B);
|
|
42
|
+
const r = resolveOrg({ org: A }, { ...base, key_org_id: B, default_org: B });
|
|
43
|
+
expect(r).toMatchObject({ org: A, source: 'flag' });
|
|
44
|
+
});
|
|
45
|
+
it('MYAPI_ORG beats the project file and the default', () => {
|
|
46
|
+
process.env.MYAPI_ORG = A;
|
|
47
|
+
writeProjectOrg(tmp, B);
|
|
48
|
+
expect(resolveOrg({}, { ...base, default_org: B })).toMatchObject({ org: A, source: 'env' });
|
|
49
|
+
});
|
|
50
|
+
it('the project file beats the machine-wide default — the whole point', () => {
|
|
51
|
+
// Project B set the global default to B. This directory is project A.
|
|
52
|
+
writeProjectOrg(tmp, A);
|
|
53
|
+
expect(resolveOrg({}, { ...base, default_org: B })).toMatchObject({ org: A, source: 'project' });
|
|
54
|
+
});
|
|
55
|
+
it("a locked key beats the machine-wide default, so the default stops mattering", () => {
|
|
56
|
+
expect(resolveOrg({}, { ...base, key_org_id: A, default_org: B }))
|
|
57
|
+
.toMatchObject({ org: A, source: 'key' });
|
|
58
|
+
});
|
|
59
|
+
it('falls back to the default when nothing else says', () => {
|
|
60
|
+
expect(resolveOrg({}, { ...base, default_org: B })).toMatchObject({ org: B, source: 'default' });
|
|
61
|
+
});
|
|
62
|
+
it('resolves to nothing when there is nothing to resolve', () => {
|
|
63
|
+
expect(resolveOrg({}, base)).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('the project file', () => {
|
|
67
|
+
it('is found by walking up, like .git', () => {
|
|
68
|
+
const nested = path.join(tmp, 'src', 'deep', 'nested');
|
|
69
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
70
|
+
writeProjectOrg(tmp, A);
|
|
71
|
+
chdirTo(nested);
|
|
72
|
+
expect(findProjectOrg()).toMatchObject({ org: A });
|
|
73
|
+
});
|
|
74
|
+
it('takes the NEAREST one, so a nested project wins over its parent', () => {
|
|
75
|
+
const inner = path.join(tmp, 'inner');
|
|
76
|
+
fs.mkdirSync(inner);
|
|
77
|
+
writeProjectOrg(tmp, A);
|
|
78
|
+
writeProjectOrg(inner, B);
|
|
79
|
+
chdirTo(inner);
|
|
80
|
+
expect(findProjectOrg()).toMatchObject({ org: B });
|
|
81
|
+
});
|
|
82
|
+
it('never carries a key', () => {
|
|
83
|
+
// A file named in a repo gets committed eventually. Trading a wrong-org
|
|
84
|
+
// write for a leaked credential would be a worse deal than the bug.
|
|
85
|
+
const file = writeProjectOrg(tmp, A);
|
|
86
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
87
|
+
expect(Object.keys(raw)).toEqual(['org']);
|
|
88
|
+
expect(JSON.stringify(raw)).not.toMatch(/hq_live|api_key|key/i);
|
|
89
|
+
});
|
|
90
|
+
it('preserves keys it does not own', () => {
|
|
91
|
+
const file = path.join(tmp, '.myapi.json');
|
|
92
|
+
fs.writeFileSync(file, JSON.stringify({ note: 'mine', org: B }));
|
|
93
|
+
writeProjectOrg(tmp, A);
|
|
94
|
+
expect(JSON.parse(fs.readFileSync(file, 'utf-8'))).toEqual({ note: 'mine', org: A });
|
|
95
|
+
});
|
|
96
|
+
it('ignores a malformed file rather than bricking every command below it', () => {
|
|
97
|
+
fs.writeFileSync(path.join(tmp, '.myapi.json'), '{ not json');
|
|
98
|
+
expect(findProjectOrg()).toBeUndefined();
|
|
99
|
+
expect(resolveOrg({}, { ...base, default_org: B })).toMatchObject({ org: B, source: 'default' });
|
|
100
|
+
});
|
|
101
|
+
it('ignores a file that names no org', () => {
|
|
102
|
+
fs.writeFileSync(path.join(tmp, '.myapi.json'), JSON.stringify({ note: 'hi' }));
|
|
103
|
+
expect(findProjectOrg()).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('a locked key refuses a mismatched target', () => {
|
|
107
|
+
// The server enforces this (403 SCOPE_FORBIDDEN, verified against production
|
|
108
|
+
// 2026-08-23). Refusing locally costs nothing and can say WHICH org the key
|
|
109
|
+
// belongs to — which the server's refusal deliberately will not, since it
|
|
110
|
+
// must not name one tenant's id to a caller with no authority over it.
|
|
111
|
+
let exitError;
|
|
112
|
+
beforeEach(async () => {
|
|
113
|
+
exitError = null;
|
|
114
|
+
const output = await import('./output.js');
|
|
115
|
+
vi.spyOn(output, 'error').mockImplementation(((m) => {
|
|
116
|
+
exitError = String(m);
|
|
117
|
+
throw new Error('__EXIT__');
|
|
118
|
+
}));
|
|
119
|
+
vi.spyOn(output, 'banner').mockImplementation(() => { });
|
|
120
|
+
});
|
|
121
|
+
afterEach(() => vi.restoreAllMocks());
|
|
122
|
+
async function requireOrgSafely(flags, config) {
|
|
123
|
+
const { requireOrg } = await import('./helpers.js');
|
|
124
|
+
try {
|
|
125
|
+
return requireOrg(flags, config, 'usage');
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
if (e?.message !== '__EXIT__')
|
|
129
|
+
throw e;
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
it('refuses --org pointing somewhere the key cannot go, before sending', async () => {
|
|
134
|
+
await requireOrgSafely({ org: B }, { ...base, key_org_id: A });
|
|
135
|
+
expect(exitError).toContain(A);
|
|
136
|
+
expect(exitError).toContain(B);
|
|
137
|
+
expect(exitError).toMatch(/nothing was sent/i);
|
|
138
|
+
});
|
|
139
|
+
it('names the source of the conflicting org, not just the conflict', async () => {
|
|
140
|
+
writeProjectOrg(tmp, B);
|
|
141
|
+
await requireOrgSafely({}, { ...base, key_org_id: A });
|
|
142
|
+
expect(exitError).toContain('.myapi.json');
|
|
143
|
+
});
|
|
144
|
+
it('allows an --org that agrees with the lock', async () => {
|
|
145
|
+
const got = await requireOrgSafely({ org: A }, { ...base, key_org_id: A });
|
|
146
|
+
expect(got).toBe(A);
|
|
147
|
+
expect(exitError).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
it('needs no --org at all when the key is locked', async () => {
|
|
150
|
+
const got = await requireOrgSafely({}, { ...base, key_org_id: A });
|
|
151
|
+
expect(got).toBe(A);
|
|
152
|
+
});
|
|
153
|
+
it('still refuses a --org that arrived empty', async () => {
|
|
154
|
+
await requireOrgSafely({ org: '' }, { ...base, key_org_id: A, default_org: A });
|
|
155
|
+
expect(exitError).toMatch(/without a value/);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const PROJECT_FILE = ".myapi.json";
|
|
2
|
+
export interface ProjectConfig {
|
|
3
|
+
org?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface FoundProject {
|
|
6
|
+
org: string;
|
|
7
|
+
/** Absolute path of the file it came from, so output can name it. */
|
|
8
|
+
file: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Walks up from `startDir` looking for the nearest `.myapi.json` that names an
|
|
12
|
+
* org. Stops at the filesystem root.
|
|
13
|
+
*
|
|
14
|
+
* A malformed file is ignored rather than fatal: it must not be able to brick
|
|
15
|
+
* every command in a directory tree, and the resolution chain has other rungs.
|
|
16
|
+
* It is reported by `myapi status` instead.
|
|
17
|
+
*/
|
|
18
|
+
export declare function findProjectOrg(startDir?: string): FoundProject | undefined;
|
|
19
|
+
/** Writes `.myapi.json` in `dir`, preserving any keys we do not own. */
|
|
20
|
+
export declare function writeProjectOrg(dir: string, orgId: string): string;
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
// Which org does this directory belong to?
|
|
4
|
+
//
|
|
5
|
+
// `myapi config set-org` writes `default_org` into ~/.myapi/config.json — one
|
|
6
|
+
// file per MACHINE. Two checkouts on one laptop share it, so project A sets it
|
|
7
|
+
// to A, project B sets it to B, and every later bare command in A quietly
|
|
8
|
+
// targets B. The command succeeds; it just lands in the wrong tenant. That is
|
|
9
|
+
// the single most-reported problem with the CLI, and it is a scope mismatch:
|
|
10
|
+
// the org is a property of the work, stored per machine.
|
|
11
|
+
//
|
|
12
|
+
// So: the nearest `.myapi.json` walking up from cwd, the way git finds .git and
|
|
13
|
+
// npm finds package.json. Two projects get two files and stop fighting.
|
|
14
|
+
//
|
|
15
|
+
// It holds the ORG ID AND NEVER A KEY. A file named in a repo gets committed
|
|
16
|
+
// eventually — that is not a prediction, it is what happens — and trading a
|
|
17
|
+
// wrong-org write for a leaked credential would be a worse deal than the bug it
|
|
18
|
+
// fixes. The org id is not a secret: it is useless without a key that the
|
|
19
|
+
// server has already locked to it.
|
|
20
|
+
export const PROJECT_FILE = '.myapi.json';
|
|
21
|
+
/**
|
|
22
|
+
* Walks up from `startDir` looking for the nearest `.myapi.json` that names an
|
|
23
|
+
* org. Stops at the filesystem root.
|
|
24
|
+
*
|
|
25
|
+
* A malformed file is ignored rather than fatal: it must not be able to brick
|
|
26
|
+
* every command in a directory tree, and the resolution chain has other rungs.
|
|
27
|
+
* It is reported by `myapi status` instead.
|
|
28
|
+
*/
|
|
29
|
+
export function findProjectOrg(startDir = process.cwd()) {
|
|
30
|
+
let dir = path.resolve(startDir);
|
|
31
|
+
for (;;) {
|
|
32
|
+
const candidate = path.join(dir, PROJECT_FILE);
|
|
33
|
+
try {
|
|
34
|
+
if (fs.existsSync(candidate)) {
|
|
35
|
+
const parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
|
|
36
|
+
if (typeof parsed?.org === 'string' && parsed.org) {
|
|
37
|
+
return { org: parsed.org, file: candidate };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch { /* unreadable or malformed — keep walking */ }
|
|
42
|
+
const parent = path.dirname(dir);
|
|
43
|
+
if (parent === dir)
|
|
44
|
+
return undefined;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Writes `.myapi.json` in `dir`, preserving any keys we do not own. */
|
|
49
|
+
export function writeProjectOrg(dir, orgId) {
|
|
50
|
+
const file = path.join(path.resolve(dir), PROJECT_FILE);
|
|
51
|
+
let existing = {};
|
|
52
|
+
try {
|
|
53
|
+
if (fs.existsSync(file))
|
|
54
|
+
existing = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
55
|
+
}
|
|
56
|
+
catch { /* replace an unreadable file rather than fail the command */ }
|
|
57
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...existing, org: orgId }, null, 2)}\n`);
|
|
58
|
+
return file;
|
|
59
|
+
}
|
|
@@ -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
|
|
@@ -13,20 +13,20 @@ The root service. It manages accounts, API keys, organizations, and billing. No
|
|
|
13
13
|
|
|
14
14
|
## Capabilities
|
|
15
15
|
<!-- llm:start -->
|
|
16
|
-
MyApiHQ is the platform's foundation
|
|
16
|
+
MyApiHQ is the platform's foundation: every other service needs an `api_key` and (for org-scoped resources) an `org_id` minted here. `myapi account setup` provisions an account, key and first org into `~/.myapi/config.json`, and later commands pick those up.
|
|
17
17
|
|
|
18
18
|
### Anonymous vs registered accounts
|
|
19
19
|
|
|
20
20
|
Two tiers, chosen at setup time:
|
|
21
21
|
|
|
22
|
-
- **Anonymous** (`myapi account setup --anonymous`): zero
|
|
23
|
-
- **Registered** (
|
|
22
|
+
- **Anonymous** (`myapi account setup --anonymous`): zero friction, **$0 credit.** Fine for browsing the catalog and reading help.
|
|
23
|
+
- **Registered** (verify email via `myapi account link <email>`): unlocks $5 free credit and the paid surface (LLM, image, email, domain register).
|
|
24
24
|
|
|
25
|
-
An anonymous account
|
|
25
|
+
An anonymous account upgrades any time with `myapi account link <email>`; the credit grants on verification.
|
|
26
26
|
|
|
27
27
|
### Health check
|
|
28
28
|
|
|
29
|
-
`myapi doctor` runs an org-wide consistency check across every slot
|
|
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
|
|
@@ -55,7 +55,9 @@ An anonymous account can upgrade at any time via `myapi account link <email>`
|
|
|
55
55
|
| `myapi billing auto-recharge [show \| set \| disable]` | Keep the wallet funded — off-session refill when balance drops below a threshold, capped monthly |
|
|
56
56
|
| `myapi account sending` / `myapi account resume-sending` | Is sending paused (bounces), and turn it on |
|
|
57
57
|
| `myapi account mailing-address ["<address>"]` | Get or set the account's CAN-SPAM mailing address (required for email send) |
|
|
58
|
-
| `myapi
|
|
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
|
+
| `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) |
|
|
59
61
|
| `myapi install-skills` | Install agent skills into ~/.claude/, ~/.gemini/, ~/.cursor/ |
|
|
60
62
|
| `myapi doctor [--verbose] [--json]` | Org-wide health check: config/integrity findings + DNS/HTTP probes |
|
|
61
63
|
<!-- generated:end -->
|
|
@@ -94,7 +96,7 @@ Each org gets a free preview subdomain (`*.makeautonomous.com`) usable before re
|
|
|
94
96
|
|
|
95
97
|
## Notes
|
|
96
98
|
|
|
97
|
-
-
|
|
99
|
+
- **Bind a directory to an org with `myapi init --org <id>`**, not `config set-org` — `set-org` writes ONE default for the whole machine, so two projects overwrite each other's and commands land in the wrong org while still succeeding. `init` writes `.myapi.json` (org id, never a key) plus a key the server locks to that org.
|
|
98
100
|
- API keys have format `hq_live_...` and are sent as `Authorization: Bearer <key>`.
|
|
99
101
|
- `org sync-brand` is async (scrapes the site, polls the job).
|
|
100
102
|
|
|
@@ -108,7 +110,9 @@ myapi keys create --name agent --org <id> --grant '*' # locked to ONE org
|
|
|
108
110
|
myapi keys create --name ci --grant funnel:write,storage:read --spend-cap 25
|
|
109
111
|
```
|
|
110
112
|
|
|
111
|
-
- `--org <id>` — **lock it to one org.** Omit for account-wide.
|
|
113
|
+
- `--org <id>` — **lock it to one org.** Omit for account-wide. A locked key is
|
|
114
|
+
refused (403) on every other org — the only scoping a stale default cannot
|
|
115
|
+
talk past. `myapi init` sets it up per directory.
|
|
112
116
|
- `--grant <list>` — `slot:read` / `slot:write`; a bare slot means write, `*`
|
|
113
117
|
means all. **Omitting `--grant` mints an unrestricted key.**
|
|
114
118
|
- `--spend-cap <usd>` — hard ceiling; `0` means the key cannot spend at all.
|
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.26.1",
|
|
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.26.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^25.6.0",
|