@myapihq/cli 2.15.2 → 2.16.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/adopt-sole-org.test.js +105 -0
- package/dist/commands/container.js +5 -3
- package/dist/commands/domain.js +5 -3
- package/dist/commands/feedback.js +19 -0
- package/dist/commands/funnel.js +4 -2
- package/dist/commands/keys.js +11 -1
- package/dist/helpers.d.ts +9 -8
- package/dist/helpers.js +148 -18
- package/dist/index.js +7 -2
- package/dist/org-line.test.d.ts +1 -0
- package/dist/org-line.test.js +36 -0
- package/dist/sdk-feedback-widgets.test.d.ts +1 -0
- package/dist/sdk-feedback-widgets.test.js +59 -0
- package/dist/skills/my-api-hq/SKILL.md +21 -10
- package/package.json +2 -2
- package/dist/org-notice.test.js +0 -37
- /package/dist/{org-notice.test.d.ts → adopt-sole-org.test.d.ts} +0 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// adoptSoleOrg: choose the org for someone only when they had no choice.
|
|
2
|
+
//
|
|
3
|
+
// An org-locked key can reach exactly one org and the raw key encodes nothing,
|
|
4
|
+
// so a developer handed one cannot learn which org it is for. Backend #124
|
|
5
|
+
// narrows `GET /hq/orgs` to that single org, which is what makes adopting it
|
|
6
|
+
// safe rather than presumptuous.
|
|
7
|
+
//
|
|
8
|
+
// Every guard here is a way this could go wrong quietly:
|
|
9
|
+
// - adopting when 2+ orgs exist picks a tenant on the user's behalf;
|
|
10
|
+
// - adopting on a refusal or a network blip invents an org from an error;
|
|
11
|
+
// - probing when --org was passed spends a round-trip to ignore the answer;
|
|
12
|
+
// - and the whole thing must degrade to today's behaviour on any failure,
|
|
13
|
+
// because it is a convenience and must never be the reason a command dies.
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as os from 'node:os';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
const KEY = 'hq_live_org_locked';
|
|
19
|
+
let tmpHome;
|
|
20
|
+
let realHome;
|
|
21
|
+
function writeConfig(accounts) {
|
|
22
|
+
fs.writeFileSync(path.join(tmpHome, '.myapi', 'config.json'), JSON.stringify({ active: 0, accounts }));
|
|
23
|
+
}
|
|
24
|
+
function readAccounts() {
|
|
25
|
+
return JSON.parse(fs.readFileSync(path.join(tmpHome, '.myapi', 'config.json'), 'utf-8')).accounts;
|
|
26
|
+
}
|
|
27
|
+
async function freshHelpers() {
|
|
28
|
+
vi.resetModules();
|
|
29
|
+
return import('./helpers.js');
|
|
30
|
+
}
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'myapi-adopt-'));
|
|
33
|
+
fs.mkdirSync(path.join(tmpHome, '.myapi'), { recursive: true });
|
|
34
|
+
realHome = process.env.HOME;
|
|
35
|
+
process.env.HOME = tmpHome;
|
|
36
|
+
process.env.MYAPI_API_KEY = KEY;
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
if (realHome !== undefined)
|
|
40
|
+
process.env.HOME = realHome;
|
|
41
|
+
delete process.env.MYAPI_API_KEY;
|
|
42
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
describe('adoptSoleOrg', () => {
|
|
45
|
+
it('adopts and persists when the key reaches exactly one org', async () => {
|
|
46
|
+
writeConfig([{ api_key: KEY, account_id: '' }]);
|
|
47
|
+
const { adoptSoleOrg } = await freshHelpers();
|
|
48
|
+
await adoptSoleOrg('database', {}, async () => [{ id: 'org-only', name: 'ImmoPilot' }]);
|
|
49
|
+
const entry = readAccounts().find(a => a.api_key === KEY);
|
|
50
|
+
expect(entry.default_org).toBe('org-only');
|
|
51
|
+
expect(entry.org_names['org-only']).toBe('ImmoPilot');
|
|
52
|
+
});
|
|
53
|
+
it('does NOT adopt when the key reaches more than one', async () => {
|
|
54
|
+
// Two orgs is an account-wide key with a real choice to make. Picking one
|
|
55
|
+
// is exactly the silent wrong-tenant selection the rest of this file fights.
|
|
56
|
+
writeConfig([{ api_key: KEY, account_id: '' }]);
|
|
57
|
+
const { adoptSoleOrg } = await freshHelpers();
|
|
58
|
+
await adoptSoleOrg('database', {}, async () => [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }]);
|
|
59
|
+
expect(readAccounts()[0].default_org).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
it('does NOT adopt when the call is refused', async () => {
|
|
62
|
+
// What a pre-#124 backend answers, and what a network failure looks like.
|
|
63
|
+
writeConfig([{ api_key: KEY, account_id: '' }]);
|
|
64
|
+
const { adoptSoleOrg } = await freshHelpers();
|
|
65
|
+
await adoptSoleOrg('database', {}, async () => { throw new Error('SCOPE_FORBIDDEN'); });
|
|
66
|
+
expect(readAccounts()[0].default_org).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
it('does not probe at all when --org was given', async () => {
|
|
69
|
+
writeConfig([{ api_key: KEY, account_id: '' }]);
|
|
70
|
+
const { adoptSoleOrg } = await freshHelpers();
|
|
71
|
+
const listOrgs = vi.fn(async () => [{ id: 'org-only' }]);
|
|
72
|
+
await adoptSoleOrg('database', { org: 'org-explicit' }, listOrgs);
|
|
73
|
+
expect(listOrgs).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
it('does not probe when a default org is already configured', async () => {
|
|
76
|
+
writeConfig([{ api_key: KEY, account_id: '', default_org: 'org-known' }]);
|
|
77
|
+
const { adoptSoleOrg } = await freshHelpers();
|
|
78
|
+
const listOrgs = vi.fn(async () => [{ id: 'org-only' }]);
|
|
79
|
+
await adoptSoleOrg('database', {}, listOrgs);
|
|
80
|
+
expect(listOrgs).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
it('does not probe for commands that never need an org', async () => {
|
|
83
|
+
writeConfig([{ api_key: KEY, account_id: '' }]);
|
|
84
|
+
const { adoptSoleOrg } = await freshHelpers();
|
|
85
|
+
const listOrgs = vi.fn(async () => [{ id: 'org-only' }]);
|
|
86
|
+
for (const cmd of ['account', 'login', 'billing', 'status', 'org']) {
|
|
87
|
+
await adoptSoleOrg(cmd, {}, listOrgs);
|
|
88
|
+
}
|
|
89
|
+
expect(listOrgs).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
it('writes to its own key, never another account entry', async () => {
|
|
92
|
+
// The cross-tenant guard. saveConfig upserting by api_key is what makes
|
|
93
|
+
// this hold; writing by active index would put this org on the signed-in
|
|
94
|
+
// account instead.
|
|
95
|
+
writeConfig([
|
|
96
|
+
{ api_key: 'hq_live_signed_in', account_id: 'acct-real', default_org: 'org-real' },
|
|
97
|
+
{ api_key: KEY, account_id: '' },
|
|
98
|
+
]);
|
|
99
|
+
const { adoptSoleOrg } = await freshHelpers();
|
|
100
|
+
await adoptSoleOrg('database', {}, async () => [{ id: 'org-only', name: 'ImmoPilot' }]);
|
|
101
|
+
const accounts = readAccounts();
|
|
102
|
+
expect(accounts.find(a => a.api_key === 'hq_live_signed_in').default_org).toBe('org-real');
|
|
103
|
+
expect(accounts.find(a => a.api_key === KEY).default_org).toBe('org-only');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
-
import { container as sdkContainer } from '@myapihq/sdk';
|
|
3
|
+
import { container as sdkContainer, hq } from '@myapihq/sdk';
|
|
4
4
|
import { requireConfig } from '../config.js';
|
|
5
5
|
import { success, error, printTable, info, printJson, banner } from '../output.js';
|
|
6
6
|
import { formatDate, pollJob } from '../utils.js';
|
|
7
|
-
import { requireOrg, confirmDestructive } from '../helpers.js';
|
|
7
|
+
import { requireOrg, requireExplicitOrg, confirmDestructive } from '../helpers.js';
|
|
8
8
|
export const EXPOSES = [
|
|
9
9
|
'GET /container/orgs/{org_id}/containers/{id}/build-logs',
|
|
10
10
|
'POST /container/orgs/{org_id}/containers',
|
|
@@ -528,7 +528,9 @@ export async function logs(id, flags) {
|
|
|
528
528
|
// container. The parent domain must be MyAPI-managed.
|
|
529
529
|
export async function domain(id, domainArg, flags) {
|
|
530
530
|
const config = requireConfig();
|
|
531
|
-
|
|
531
|
+
// Binds a live hostname to a container; the wrong org points a real domain
|
|
532
|
+
// at the wrong app.
|
|
533
|
+
const orgId = await requireExplicitOrg(flags, config, 'myapi container domain <id> <domain> [--org <id>]', 'bind this domain', key => hq.listOrgs(key));
|
|
532
534
|
if (!id)
|
|
533
535
|
error('Missing id.\nUsage: myapi container domain <id> <domain> (or --remove to unbind)');
|
|
534
536
|
if (flags.remove) {
|
package/dist/commands/domain.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { domain as sdkDomain, email as sdkEmail } from '@myapihq/sdk';
|
|
1
|
+
import { domain as sdkDomain, email as sdkEmail, hq } from '@myapihq/sdk';
|
|
2
2
|
import { requireConfig } from '../config.js';
|
|
3
3
|
import { success, error, info, printTable, printJson } from '../output.js';
|
|
4
4
|
import { formatDate, retryFunds } from '../utils.js';
|
|
5
5
|
import { resolveRegistrantForRegister } from '../registrant.js';
|
|
6
6
|
import { confirm, isNonInteractive } from '../prompt.js';
|
|
7
|
-
import { requireOrg, requireDomain } from '../helpers.js';
|
|
7
|
+
import { requireOrg, requireExplicitOrg, requireDomain } from '../helpers.js';
|
|
8
8
|
export const EXPOSES = [
|
|
9
9
|
'GET /domain/orgs/{org_id}/list',
|
|
10
10
|
'GET /domain/orgs/{org_id}/check/available/{name}',
|
|
@@ -234,7 +234,9 @@ current, targetOrgId, force) {
|
|
|
234
234
|
}
|
|
235
235
|
export async function assign(domainArg, flags) {
|
|
236
236
|
const config = requireConfig();
|
|
237
|
-
|
|
237
|
+
// assign is also the REASSIGN path — re-running moves a domain already
|
|
238
|
+
// serving another org. Hit on x80security.com once already.
|
|
239
|
+
const orgId = await requireExplicitOrg(flags, config, 'myapi domain assign <domain> [--no-www] [--org <id>]', 'assign (or move) this domain', key => hq.listOrgs(key));
|
|
238
240
|
if (!domainArg)
|
|
239
241
|
error('Missing required arguments.\nUsage: myapi domain assign <domain> [--no-www] [--org <id>]');
|
|
240
242
|
// Assign is also the reassign path: if the domain is currently bound to a
|
|
@@ -20,6 +20,11 @@ export const EXPOSES = [
|
|
|
20
20
|
'DELETE /feedback/orgs/{org_id}/widgets/{id}',
|
|
21
21
|
];
|
|
22
22
|
export const SCHEMA = {
|
|
23
|
+
// Declared because `list` reads flags.limit/offset, --help documents both,
|
|
24
|
+
// and a truncated page prints "raise --limit" — while the parser refused
|
|
25
|
+
// them as unknown. Reported by ImmoPilot on 2026-08-19.
|
|
26
|
+
limit: 'number',
|
|
27
|
+
offset: 'number',
|
|
23
28
|
trace: 'boolean',
|
|
24
29
|
routes: 'string',
|
|
25
30
|
accent: 'string',
|
|
@@ -109,6 +114,12 @@ export async function list(flags) {
|
|
|
109
114
|
info(' screenshot yes');
|
|
110
115
|
else if (i.screenshot_asset_id)
|
|
111
116
|
info(' screenshot yes (link could not be signed)');
|
|
117
|
+
// A missing picture used to be silent, and silence read as "nobody
|
|
118
|
+
// attached one" when it often meant "the renderer threw on this page".
|
|
119
|
+
// The backend records which it was; say so rather than printing nothing.
|
|
120
|
+
else if (i.screenshot_status === 'failed') {
|
|
121
|
+
info(` screenshot FAILED${i.screenshot_error ? ` — ${i.screenshot_error}` : ''}`);
|
|
122
|
+
}
|
|
112
123
|
if (flags.trace && trace.length) {
|
|
113
124
|
info('');
|
|
114
125
|
for (const e of trace) {
|
|
@@ -219,6 +230,14 @@ export async function widget(sub, arg, flags) {
|
|
|
219
230
|
// the URL from a key and a base by hand.
|
|
220
231
|
info(` ${w.script_tag || `<script src="https://api.myapihq.com/feedback/in/${w.key}/widget.js" async></script>`}`);
|
|
221
232
|
info(` routes: ${w.routes?.length ? w.routes.join(', ') : '(everywhere)'}`);
|
|
233
|
+
// The API returns these inside `theme`; printing only name/key/routes
|
|
234
|
+
// meant the only way to read back a widget's position or label was to
|
|
235
|
+
// fetch widget.js and read the JavaScript.
|
|
236
|
+
const th = w.theme ?? {};
|
|
237
|
+
if (th.accent || th.position || th.label) {
|
|
238
|
+
info(` theme: ${[th.label && `label "${th.label}"`, th.position, th.accent]
|
|
239
|
+
.filter(Boolean).join(' · ')}`);
|
|
240
|
+
}
|
|
222
241
|
info(` origins: ${w.allowed_origins?.length ? w.allowed_origins.join(', ') : '(any — anyone can post with this key)'}`);
|
|
223
242
|
info('');
|
|
224
243
|
}
|
package/dist/commands/funnel.js
CHANGED
|
@@ -4,7 +4,7 @@ import { funnel as sdkFunnel, hq } from '@myapihq/sdk';
|
|
|
4
4
|
import { requireConfig } from '../config.js';
|
|
5
5
|
import { success, error, printTable, info, printJson } from '../output.js';
|
|
6
6
|
import { formatDate } from '../utils.js';
|
|
7
|
-
import { requireOrg, confirmDestructive } from '../helpers.js';
|
|
7
|
+
import { requireOrg, requireExplicitOrg, confirmDestructive } from '../helpers.js';
|
|
8
8
|
export const EXPOSES = [
|
|
9
9
|
'POST /funnel/orgs/{org_id}/funnels',
|
|
10
10
|
'GET /funnel/orgs/{org_id}/funnels',
|
|
@@ -251,7 +251,9 @@ export async function pages(funnelArg, flags) {
|
|
|
251
251
|
}
|
|
252
252
|
export async function push(slug, flags) {
|
|
253
253
|
const config = requireConfig();
|
|
254
|
-
|
|
254
|
+
// Overwrites whatever is live at the target. The demo-onto-a-live-site
|
|
255
|
+
// failure this repo documents starts exactly here, with an inherited default.
|
|
256
|
+
const orgId = await requireExplicitOrg(flags, config, 'myapi funnel push [slug] [--funnel <id>] [--org <id>]', 'overwrite a published page', key => hq.listOrgs(key));
|
|
255
257
|
const rawSlug = slug || flags.slug || '/';
|
|
256
258
|
const finalSlug = rawSlug.startsWith('/') ? rawSlug : `/${rawSlug}`;
|
|
257
259
|
let funnelId = flags.funnel || config.default_funnel;
|
package/dist/commands/keys.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { formatDate } from '../utils.js';
|
|
2
2
|
import { hq } from '@myapihq/sdk';
|
|
3
3
|
import { requireConfig } from '../config.js';
|
|
4
|
-
import { success, error, printTable, info, printJson } from '../output.js';
|
|
4
|
+
import { success, error, printTable, info, printJson, banner } from '../output.js';
|
|
5
5
|
import { ask, confirm, isNonInteractive } from '../prompt.js';
|
|
6
6
|
import { requireArg } from '../helpers.js';
|
|
7
7
|
export const SCHEMA = {
|
|
@@ -115,6 +115,16 @@ export async function createNew(flags) {
|
|
|
115
115
|
info(`Scope: ${describeKey(key)}`);
|
|
116
116
|
info('');
|
|
117
117
|
info("Copy the key now — you won't be able to see it again.");
|
|
118
|
+
// An account-wide key is the default and is almost never what a workload
|
|
119
|
+
// wants. Org is otherwise an ambient setting, so a stale one on an
|
|
120
|
+
// account-wide key is a wrong-tenant write with nothing to stop it; locking
|
|
121
|
+
// the key makes that a 403 instead. Said here because this is the one moment
|
|
122
|
+
// the choice is being made, and `--org` cannot be added to a key later.
|
|
123
|
+
if (!opts.orgId) {
|
|
124
|
+
banner('› This key can reach EVERY org on the account. For an agent or CI job that');
|
|
125
|
+
banner(' works in one org, lock it: myapi keys create --name <n> --org <id> --grant \'*\'');
|
|
126
|
+
banner(' A locked key turns a wrong-org write into a 403 instead of a silent success.');
|
|
127
|
+
}
|
|
118
128
|
}
|
|
119
129
|
export async function list(flags) {
|
|
120
130
|
const config = requireConfig();
|
package/dist/helpers.d.ts
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
import { type Config } from './config.js';
|
|
2
2
|
export type Flags = Record<string, string | boolean | number>;
|
|
3
3
|
export declare function requireOrg(flags: Flags, config: Config, usage: string): string;
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* Pure and exported because both failure directions are silent in production:
|
|
7
|
-
* return a string always and it becomes noise everyone filters out; return
|
|
8
|
-
* undefined always and the wrong-org write it exists to catch goes through
|
|
9
|
-
* unannounced. Neither shows up as a broken build, so it is pinned by a test.
|
|
10
|
-
*/
|
|
11
|
-
export declare function orgChangeNotice(previous: string | undefined, next: string, names?: Record<string, string>): string | undefined;
|
|
4
|
+
export declare function orgLine(orgId: string, name: string | undefined, changedFrom?: string): string;
|
|
12
5
|
/** The org this invocation resolved to, for `--json` consumers. */
|
|
13
6
|
export declare function currentOrg(): {
|
|
14
7
|
org_id: string;
|
|
@@ -20,6 +13,14 @@ export declare function rememberOrgNames(config: Config, orgs: Array<{
|
|
|
20
13
|
id?: string;
|
|
21
14
|
name?: string;
|
|
22
15
|
}>): void;
|
|
16
|
+
export declare function adoptSoleOrg(command: string | undefined, flags: Flags, listOrgs: (apiKey: string) => Promise<Array<{
|
|
17
|
+
id?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
}>>): Promise<void>;
|
|
20
|
+
export declare function requireExplicitOrg(flags: Flags, config: Config, usage: string, action: string, listOrgs?: (apiKey: string) => Promise<Array<{
|
|
21
|
+
id?: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
}>>): Promise<string>;
|
|
23
24
|
export declare function requireDomain(arg: string | undefined, flags: Flags, config: Config, usage: string): string;
|
|
24
25
|
export declare function requireArg(value: string | undefined, name: string, usage: string): string;
|
|
25
26
|
export declare function confirmDestructive(flags: Flags, description: string, usage: string): Promise<void>;
|
package/dist/helpers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { error, banner } from './output.js';
|
|
2
|
-
import { saveConfig } from './config.js';
|
|
2
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
3
3
|
import { confirm, isNonInteractive } from './prompt.js';
|
|
4
4
|
// error() returns `never`, so after `if (!x) error(...)` TS narrows x to a
|
|
5
5
|
// non-falsy value and the casts disappear.
|
|
@@ -14,7 +14,7 @@ export function requireOrg(flags, config, usage) {
|
|
|
14
14
|
if (!orgId) {
|
|
15
15
|
error(`Missing required arguments.\nUsage: ${usage}\n(Or set default: myapi config set-org <id>)`);
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
announceOrg(orgId, config);
|
|
18
18
|
recordResolvedOrg(orgId, config);
|
|
19
19
|
return orgId;
|
|
20
20
|
}
|
|
@@ -35,23 +35,35 @@ export function requireOrg(flags, config, usage) {
|
|
|
35
35
|
// clean.
|
|
36
36
|
let resolvedOrgId;
|
|
37
37
|
let resolvedOrgName;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
38
|
+
// Announced ONCE per process, always, on stderr.
|
|
39
|
+
//
|
|
40
|
+
// This started as change-only, on the reasoning that a line which is always
|
|
41
|
+
// there becomes noise. That holds for a human and fails for an agent: silence
|
|
42
|
+
// then means "same org" OR "old CLI" OR "notice broke" OR "no org resolved",
|
|
43
|
+
// and a signal whose absence has four meanings cannot be acted on. An agent
|
|
44
|
+
// re-reads context cold every turn and never learns the convention.
|
|
45
|
+
//
|
|
46
|
+
// So it always fires, in a stable key=value shape that greps and parses without
|
|
47
|
+
// being JSON, and costs ~10 tokens. Now silence carries information too: no
|
|
48
|
+
// line means no org was resolved.
|
|
49
|
+
//
|
|
50
|
+
// stderr, so `--json | jq` and piped stdout are untouched.
|
|
51
|
+
let announced = false;
|
|
52
|
+
export function orgLine(orgId, name, changedFrom) {
|
|
53
|
+
const parts = [`myapi: org=${orgId}`];
|
|
54
|
+
if (name)
|
|
55
|
+
parts.push(`name=${JSON.stringify(name)}`);
|
|
56
|
+
if (changedFrom)
|
|
57
|
+
parts.push(`changed_from=${changedFrom}`);
|
|
58
|
+
return parts.join(' ');
|
|
50
59
|
}
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
function announceOrg(orgId, config) {
|
|
61
|
+
if (announced)
|
|
62
|
+
return; // one line per invocation, however many times requireOrg runs
|
|
63
|
+
announced = true;
|
|
64
|
+
const previous = config.last_org_used;
|
|
65
|
+
const changed = previous && previous !== orgId ? previous : undefined;
|
|
66
|
+
banner(orgLine(orgId, config.org_names?.[orgId], changed));
|
|
55
67
|
}
|
|
56
68
|
function recordResolvedOrg(orgId, config) {
|
|
57
69
|
resolvedOrgId = orgId;
|
|
@@ -93,6 +105,124 @@ export function rememberOrgNames(config, orgs) {
|
|
|
93
105
|
}
|
|
94
106
|
catch { /* advisory only */ }
|
|
95
107
|
}
|
|
108
|
+
// ── Adopting the org a key can reach ─────────────────────────────────────────
|
|
109
|
+
//
|
|
110
|
+
// An org-locked key (`keys create --org <id>`) can reach exactly one org, and
|
|
111
|
+
// the raw key encodes nothing — so a developer handed one has no way to learn
|
|
112
|
+
// which org it is for, and ends up pasting a UUID onto every command. Backend
|
|
113
|
+
// #124 made `GET /hq/orgs` answer such a key with a list narrowed to its own
|
|
114
|
+
// org, which is what makes the answer discoverable.
|
|
115
|
+
//
|
|
116
|
+
// So: when a command needs an org and nothing is configured, ask. Exactly one
|
|
117
|
+
// result means there is no choice to make, and choosing for someone is only
|
|
118
|
+
// safe when they had no alternative.
|
|
119
|
+
//
|
|
120
|
+
// Runs once, before dispatch, rather than inside requireOrg — requireOrg is
|
|
121
|
+
// synchronous and called from dozens of sites, and making it async to serve a
|
|
122
|
+
// first-run convenience would be a poor trade.
|
|
123
|
+
const ORGLESS_COMMANDS = new Set([
|
|
124
|
+
// Commands that never need an org, or that exist to establish one. Probing
|
|
125
|
+
// here would add a round-trip to `myapi --help`-shaped work for nothing.
|
|
126
|
+
'account', 'login', 'setup', 'update', 'keys', 'billing', 'status', 'config', 'org', 'doctor',
|
|
127
|
+
]);
|
|
128
|
+
export async function adoptSoleOrg(command, flags, listOrgs) {
|
|
129
|
+
if (!command || ORGLESS_COMMANDS.has(command))
|
|
130
|
+
return;
|
|
131
|
+
if (flags.help || typeof flags.org === 'string')
|
|
132
|
+
return;
|
|
133
|
+
const config = loadConfig();
|
|
134
|
+
if (!config?.api_key || config.default_org)
|
|
135
|
+
return; // nothing to do, or already answered
|
|
136
|
+
let orgs;
|
|
137
|
+
try {
|
|
138
|
+
orgs = await listOrgs(config.api_key);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// An account-wide key on a backend without #124 gets a refusal here, and a
|
|
142
|
+
// network failure looks the same. Either way this is a convenience: fall
|
|
143
|
+
// through and let the command ask for --org as it always did.
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Two or more means an account-wide key with a real choice to make, and the
|
|
147
|
+
// existing "which org?" error is the right answer. Zero means nothing to adopt.
|
|
148
|
+
if (!Array.isArray(orgs) || orgs.length !== 1)
|
|
149
|
+
return;
|
|
150
|
+
const only = orgs[0];
|
|
151
|
+
if (!only?.id)
|
|
152
|
+
return;
|
|
153
|
+
try {
|
|
154
|
+
config.default_org = only.id;
|
|
155
|
+
if (only.name)
|
|
156
|
+
config.org_names = { ...(config.org_names ?? {}), [only.id]: only.name };
|
|
157
|
+
// Safe to persist against the right entry only because saveConfig upserts
|
|
158
|
+
// by api_key — writing by active index would put this org on somebody
|
|
159
|
+
// else's account. See the comment there.
|
|
160
|
+
saveConfig(config);
|
|
161
|
+
}
|
|
162
|
+
catch { /* advisory; the command can still run with the in-memory value */ }
|
|
163
|
+
banner(`› Using org ${only.name ? `${only.name} ` : ''}(${short(only.id)}) — the only org this key can reach`);
|
|
164
|
+
}
|
|
165
|
+
// A displacing write must NAME the org it displaces something in.
|
|
166
|
+
//
|
|
167
|
+
// `--org` cannot be required everywhere: a read that inherits the default is
|
|
168
|
+
// harmless, and an agent that must pass it on all fifty verbs will get it wrong
|
|
169
|
+
// on the fifty-first. So the requirement is placed exactly where being wrong
|
|
170
|
+
// costs something — a write that replaces or moves what is already live.
|
|
171
|
+
//
|
|
172
|
+
// Same shape as confirmDestructive, deliberately: non-interactive callers must
|
|
173
|
+
// be explicit, humans get a prompt naming the org. An agent already passes
|
|
174
|
+
// --yes to those verbs, so --org is the same habit rather than a new one.
|
|
175
|
+
//
|
|
176
|
+
// An org-locked key is exempt. It can only reach one org, so there is no wrong
|
|
177
|
+
// org to reach and the friction would buy nothing — which is also the strongest
|
|
178
|
+
// practical argument for handing agents one.
|
|
179
|
+
export async function requireExplicitOrg(flags, config, usage, action, listOrgs) {
|
|
180
|
+
const orgId = requireOrg(flags, config, usage);
|
|
181
|
+
if (typeof flags.org === 'string' && flags.org)
|
|
182
|
+
return orgId; // stated outright
|
|
183
|
+
// Exactly one reachable org means the default cannot be the wrong one.
|
|
184
|
+
//
|
|
185
|
+
// `org_names` alone cannot answer this. It is populated only by `org list`
|
|
186
|
+
// and `status`, so an ordinary single-org user who has run neither has an
|
|
187
|
+
// EMPTY cache — and treating empty as "several" refused a write that carried
|
|
188
|
+
// no risk whatever. That is most users, on the least dangerous case.
|
|
189
|
+
//
|
|
190
|
+
// So an empty cache means unknown, not many, and unknown is worth one GET:
|
|
191
|
+
// a displacing write is rare and deliberate, the answer is cached onto the
|
|
192
|
+
// config for next time, and being wrong in either direction here is worse
|
|
193
|
+
// than a round-trip.
|
|
194
|
+
const cached = Object.keys(config.org_names ?? {});
|
|
195
|
+
if (cached.length === 1 && cached[0] === orgId)
|
|
196
|
+
return orgId;
|
|
197
|
+
if (cached.length === 0 && listOrgs) {
|
|
198
|
+
try {
|
|
199
|
+
const orgs = await listOrgs(config.api_key);
|
|
200
|
+
if (Array.isArray(orgs)) {
|
|
201
|
+
rememberOrgNames(config, orgs); // self-heals the cache
|
|
202
|
+
if (orgs.length === 1 && orgs[0]?.id === orgId)
|
|
203
|
+
return orgId;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Could not determine. Fall through and require --org: for a write that
|
|
208
|
+
// replaces something live, "I do not know which orgs this key reaches"
|
|
209
|
+
// is not a reason to proceed silently.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const label = config.org_names?.[orgId] ? `${config.org_names[orgId]} (${orgId})` : orgId;
|
|
213
|
+
if (isNonInteractive()) {
|
|
214
|
+
error(`Refusing to ${action} using an inherited default org.\n` +
|
|
215
|
+
`This would act on ${label}.\n\n` +
|
|
216
|
+
`→ Name it: --org ${orgId}\n` +
|
|
217
|
+
` A displacing write should say which org it displaces something in — a stale\n` +
|
|
218
|
+
` default looks exactly like a correct one.\n` +
|
|
219
|
+
`Usage: ${usage}`);
|
|
220
|
+
}
|
|
221
|
+
const ok = await confirm(`› ${action} in ${label}? (y/N) `, false);
|
|
222
|
+
if (!ok)
|
|
223
|
+
error('Aborted.');
|
|
224
|
+
return orgId;
|
|
225
|
+
}
|
|
96
226
|
export function requireDomain(arg, flags, config, usage) {
|
|
97
227
|
const fromFlag = typeof flags.domain === 'string' ? flags.domain : '';
|
|
98
228
|
const fromConfig = typeof config.default_domain === 'string' ? config.default_domain : '';
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { error, info, success, banner, setResolvedContextSource } from './output.js';
|
|
3
3
|
import { loadConfig } from './config.js';
|
|
4
|
-
import { MyApiError, setUserAgent } from '@myapihq/sdk';
|
|
4
|
+
import { MyApiError, setUserAgent, hq } from '@myapihq/sdk';
|
|
5
5
|
import { friendlyError } from './errors.js';
|
|
6
|
-
import { currentOrg } from './helpers.js';
|
|
6
|
+
import { currentOrg, adoptSoleOrg } from './helpers.js';
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import { parseFlags } from './flags.js';
|
|
9
9
|
const pkgPath = new URL('../package.json', import.meta.url);
|
|
@@ -187,6 +187,11 @@ async function main() {
|
|
|
187
187
|
process.exit(1);
|
|
188
188
|
}
|
|
189
189
|
const [command, subcommand, ...restArgs] = args;
|
|
190
|
+
// Before dispatch: if this key can reach exactly one org and nothing is
|
|
191
|
+
// configured, adopt it. Costs one GET on the first command a fresh
|
|
192
|
+
// org-locked key runs, and nothing afterwards — the answer is persisted.
|
|
193
|
+
// Never fatal; see adoptSoleOrg.
|
|
194
|
+
await adoptSoleOrg(command, flags, key => hq.listOrgs(key));
|
|
190
195
|
try {
|
|
191
196
|
switch (command) {
|
|
192
197
|
case 'auth':
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The org line is emitted on EVERY command that resolves an org.
|
|
2
|
+
//
|
|
3
|
+
// It began as change-only, which is wrong for an agent: silence then meant
|
|
4
|
+
// "same org" OR "old CLI" OR "broken notice" OR "no org resolved", and a signal
|
|
5
|
+
// whose absence has four meanings cannot be acted on. Always-on makes silence
|
|
6
|
+
// mean exactly one thing — no org was resolved.
|
|
7
|
+
//
|
|
8
|
+
// The shape is load-bearing too. Agents parse this, so `key=value` has to stay
|
|
9
|
+
// stable and the id has to appear in full: a truncated id is unusable as an
|
|
10
|
+
// assertion, which is the whole point of emitting it.
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { orgLine } from './helpers.js';
|
|
13
|
+
const ID = '7f1aa7f1-f6eb-4a6d-87b7-1bbe6584f5d6';
|
|
14
|
+
describe('orgLine', () => {
|
|
15
|
+
it('always produces a line — there is no silent case', () => {
|
|
16
|
+
expect(orgLine(ID, undefined)).toContain(`org=${ID}`);
|
|
17
|
+
});
|
|
18
|
+
it('carries the FULL org id, not a shortened one', () => {
|
|
19
|
+
// A human reads the name; a machine needs the id it can compare against.
|
|
20
|
+
expect(orgLine(ID, 'Acme')).toContain(ID);
|
|
21
|
+
});
|
|
22
|
+
it('includes the name when one is cached', () => {
|
|
23
|
+
expect(orgLine(ID, 'Acme')).toContain('name="Acme"');
|
|
24
|
+
});
|
|
25
|
+
it('omits name rather than emitting an empty value when unknown', () => {
|
|
26
|
+
expect(orgLine(ID, undefined)).not.toContain('name=');
|
|
27
|
+
});
|
|
28
|
+
it('flags a move without hiding the current org', () => {
|
|
29
|
+
const line = orgLine(ID, 'Acme', 'org-previous');
|
|
30
|
+
expect(line).toContain(`org=${ID}`);
|
|
31
|
+
expect(line).toContain('changed_from=org-previous');
|
|
32
|
+
});
|
|
33
|
+
it('quotes names so a space cannot break key=value parsing', () => {
|
|
34
|
+
expect(orgLine(ID, 'Demo Corp')).toContain('name="Demo Corp"');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SDK-level unit tests for feedback.listWidgets — the unwrap that made
|
|
2
|
+
// `myapi feedback widget list` report "No widgets yet" to orgs that had
|
|
3
|
+
// widgets. Mocks global fetch — does NOT hit the network.
|
|
4
|
+
//
|
|
5
|
+
// Reported by ImmoPilot four times before it was traced. The HTTP API was
|
|
6
|
+
// answering correctly throughout: GET /feedback/orgs/{org}/widgets returns
|
|
7
|
+
// { widgets, total }, the same envelope shape as the item list. The SDK typed
|
|
8
|
+
// it Promise<Widget[]> and returned the object unwrapped, so the CLI's
|
|
9
|
+
// `!ws.length` was always true. Nothing tested the SDK against a real response
|
|
10
|
+
// shape, which is the only reason a four-time report was possible.
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
12
|
+
import { feedback } from '@myapihq/sdk';
|
|
13
|
+
const API_KEY = 'myapi_test_abc';
|
|
14
|
+
const ORG = '7f1aa7f1-f6eb-4a6d-87b7-1bbe6584f5d6';
|
|
15
|
+
let fetchMock;
|
|
16
|
+
function ok(data) {
|
|
17
|
+
return new Response(JSON.stringify({ success: true, data, meta: {} }), {
|
|
18
|
+
status: 200,
|
|
19
|
+
headers: { 'content-type': 'application/json' },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const WIDGET = {
|
|
23
|
+
id: '27de19f6-1a18-4074-a347-a14e737c3f47',
|
|
24
|
+
org_id: ORG,
|
|
25
|
+
name: 'app',
|
|
26
|
+
key: 'fw_0123456789abcdef',
|
|
27
|
+
routes: ['/app/**'],
|
|
28
|
+
theme: { accent: '#2563eb', position: 'bottom-left', label: 'Feedback' },
|
|
29
|
+
};
|
|
30
|
+
describe('feedback.listWidgets', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
fetchMock = vi.fn();
|
|
33
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
36
|
+
it('unwraps the { widgets, total } envelope the API actually returns', async () => {
|
|
37
|
+
fetchMock.mockResolvedValueOnce(ok({ widgets: [WIDGET, { ...WIDGET, name: 'marketing' }], total: 2 }));
|
|
38
|
+
const ws = await feedback.listWidgets(API_KEY, ORG);
|
|
39
|
+
expect(Array.isArray(ws)).toBe(true);
|
|
40
|
+
expect(ws).toHaveLength(2);
|
|
41
|
+
expect(ws[0].key).toBe(WIDGET.key);
|
|
42
|
+
});
|
|
43
|
+
it('carries theme through, so position and label can be read back', async () => {
|
|
44
|
+
// Without this the only way to see what --position and --label were set to
|
|
45
|
+
// was to fetch widget.js and read the JavaScript.
|
|
46
|
+
fetchMock.mockResolvedValueOnce(ok({ widgets: [WIDGET], total: 1 }));
|
|
47
|
+
const [w] = await feedback.listWidgets(API_KEY, ORG);
|
|
48
|
+
expect(w.theme?.position).toBe('bottom-left');
|
|
49
|
+
expect(w.theme?.label).toBe('Feedback');
|
|
50
|
+
});
|
|
51
|
+
it('returns [] rather than undefined when the org has none', async () => {
|
|
52
|
+
fetchMock.mockResolvedValueOnce(ok({ widgets: [], total: 0 }));
|
|
53
|
+
await expect(feedback.listWidgets(API_KEY, ORG)).resolves.toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
it('tolerates a bare array, in case the endpoint is ever simplified', async () => {
|
|
56
|
+
fetchMock.mockResolvedValueOnce(ok([WIDGET]));
|
|
57
|
+
await expect(feedback.listWidgets(API_KEY, ORG)).resolves.toHaveLength(1);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: my-api-hq
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.2.0
|
|
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-ef7057aa3584c43f852415f25ca5d023ee372c8d00f915a5da68a5e70634e2b2
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# MyApiHQ
|
|
@@ -81,7 +81,10 @@ myapi org sync-brand acme.com
|
|
|
81
81
|
myapi account switch 2
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
`402 INSUFFICIENT_FUNDS` → top up, or enable auto-recharge. With it on, billable
|
|
85
|
+
commands wait out a refill-in-flight and retry by themselves — **do not add your
|
|
86
|
+
own retry loop**. `402 SPEND_CAP_EXCEEDED` is a ceiling you set, not an empty
|
|
87
|
+
wallet: raise it with `myapi billing spend-cap`.
|
|
85
88
|
|
|
86
89
|
Each org gets a free preview subdomain (`*.makeautonomous.com`) usable before registering a custom domain.
|
|
87
90
|
<!-- llm:end -->
|
|
@@ -99,9 +102,7 @@ you can hand work a key that cannot exceed its job:
|
|
|
99
102
|
|
|
100
103
|
```bash
|
|
101
104
|
myapi keys create --name agent --org <id> --grant '*' # locked to ONE org
|
|
102
|
-
myapi keys create --name ci --grant funnel:write,storage:read
|
|
103
|
-
myapi keys create --name readonly --grant '*:read' # read anything, write nothing
|
|
104
|
-
myapi keys create --name billing-fn --org <id> --grant email --spend-cap 25
|
|
105
|
+
myapi keys create --name ci --grant funnel:write,storage:read --spend-cap 25
|
|
105
106
|
```
|
|
106
107
|
|
|
107
108
|
- `--org <id>` — **lock it to one org.** Omit for account-wide.
|
|
@@ -116,10 +117,20 @@ exactly like a correct one. That is how a demo lands on a live site. Locking
|
|
|
116
117
|
turns a wrong-org write into a `403`. Keep your *own* key account-wide though:
|
|
117
118
|
an org-locked one cannot create orgs or manage billing.
|
|
118
119
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
**Every command that resolves an org says so on stderr** — always, not only on
|
|
121
|
+
change, so **no line means no org was resolved**:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
myapi: org=7f1aa7f1-f6eb-4a6d-87b7-1bbe6584f5d6 name="Acme" [changed_from=…]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Stable `key=value`, full id, stderr only so `--json | jq` is untouched. Assert
|
|
128
|
+
on it — object `--json` responses also carry `_resolved`, but arrays cannot.
|
|
129
|
+
|
|
130
|
+
**Displacing writes refuse an inherited default.** `funnel push`,
|
|
131
|
+
`domain assign` and `container domain` require `--org` in non-interactive runs,
|
|
132
|
+
the same shape as `--yes`. A key that reaches one org is exempt — there is no
|
|
133
|
+
wrong org for it to reach.
|
|
123
134
|
|
|
124
135
|
## Org profile fields
|
|
125
136
|
|
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.16.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.16.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^25.6.0",
|
package/dist/org-notice.test.js
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// The org-change notice has two silent failure modes and no build breaks for
|
|
2
|
-
// either: always-speak turns it into noise that gets filtered out within a day
|
|
3
|
-
// (and costs tokens on every call in an agent loop), always-silent lets the
|
|
4
|
-
// wrong-org write it exists to catch go through unannounced. Pin both edges.
|
|
5
|
-
import { describe, it, expect } from 'vitest';
|
|
6
|
-
import { orgChangeNotice } from './helpers.js';
|
|
7
|
-
const NAMES = { 'org-a': 'Acme', 'org-b': 'Demo Corp' };
|
|
8
|
-
describe('orgChangeNotice', () => {
|
|
9
|
-
it('says nothing in steady state — the same org twice', () => {
|
|
10
|
-
expect(orgChangeNotice('org-a', 'org-a', NAMES)).toBeUndefined();
|
|
11
|
-
});
|
|
12
|
-
it('says nothing on the first ever resolution', () => {
|
|
13
|
-
// No prior means nothing has moved; announcing here would fire on every
|
|
14
|
-
// fresh config and train the reader to ignore the line.
|
|
15
|
-
expect(orgChangeNotice(undefined, 'org-a', NAMES)).toBeUndefined();
|
|
16
|
-
});
|
|
17
|
-
it('speaks when the org moves, naming both sides', () => {
|
|
18
|
-
const msg = orgChangeNotice('org-a', 'org-b', NAMES);
|
|
19
|
-
expect(msg).toBeDefined();
|
|
20
|
-
expect(msg).toContain('Acme');
|
|
21
|
-
expect(msg).toContain('Demo Corp');
|
|
22
|
-
});
|
|
23
|
-
it('still speaks when no name is cached, falling back to the id', () => {
|
|
24
|
-
// The cache is populated opportunistically by `org list` / `status`, so an
|
|
25
|
-
// agent that never ran either still has to be told the target moved.
|
|
26
|
-
const msg = orgChangeNotice('org-a', 'org-b', {});
|
|
27
|
-
expect(msg).toBeDefined();
|
|
28
|
-
expect(msg).toContain('org-a');
|
|
29
|
-
expect(msg).toContain('org-b');
|
|
30
|
-
});
|
|
31
|
-
it('shortens long ids so the line stays scannable', () => {
|
|
32
|
-
const long = 'org-7f1aa7f1-f6eb-4a6d-87b7-1bbe6584f5d6';
|
|
33
|
-
const msg = orgChangeNotice(long, 'org-b', {});
|
|
34
|
-
expect(msg).not.toContain(long);
|
|
35
|
-
expect(msg).toContain('org-7f1a');
|
|
36
|
-
});
|
|
37
|
-
});
|
|
File without changes
|