@myapihq/cli 2.15.2 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- const orgId = requireOrg(flags, config, 'myapi container domain <id> <domain> [--org <id>]');
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) {
@@ -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
- const orgId = requireOrg(flags, config, 'myapi domain assign <domain> [--no-www] [--org <id>]');
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
@@ -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
- const orgId = requireOrg(flags, config, 'myapi funnel push [slug] [--funnel <id>] [--org <id>]');
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;
@@ -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
- /** The notice for a move, or undefined when there is nothing to say.
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
- noticeOrgChanged(orgId, config);
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
- /** The notice for a move, or undefined when there is nothing to say.
39
- *
40
- * Pure and exported because both failure directions are silent in production:
41
- * return a string always and it becomes noise everyone filters out; return
42
- * undefined always and the wrong-org write it exists to catch goes through
43
- * unannounced. Neither shows up as a broken build, so it is pinned by a test.
44
- */
45
- export function orgChangeNotice(previous, next, names = {}) {
46
- if (!previous || previous === next)
47
- return undefined;
48
- const label = (id) => (names[id] ? `${names[id]} ` : '') + short(id);
49
- return `→ org changed: ${label(previous)} → ${label(next)}`;
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 noticeOrgChanged(orgId, config) {
52
- const msg = orgChangeNotice(config.last_org_used, orgId, config.org_names ?? {});
53
- if (msg)
54
- banner(msg);
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
+ });
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: my-api-hq
3
- version: 1.1.0
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-6f54c719facde1ddcdee04ffcf9527ed42550cbfc67679ced5aea55fc7adba7d
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
- If any service returns `402 INSUFFICIENT_FUNDS`, top up (`myapi billing topup`) — or enable `myapi billing auto-recharge` so it refills itself. With auto-recharge enabled, billable CLI commands (llm, image, email send, domain register/renew, git commit) handle a refill-in-flight automatically: they wait the server-hinted interval and retry instead of failing, so don't add your own retry loop. A `402 SPEND_CAP_EXCEEDED` is different: you hit a spend ceiling you set, so raise it with `myapi billing spend-cap` rather than topping up.
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
- Two more signals, no flags needed: resolving to a different org than last time
120
- prints `→ org changed: <from> <to>` on stderr, once — silence means it has
121
- not moved. Under `--json`, object responses carry `_resolved: {org_id,
122
- org_name}`; assert on that rather than assuming.
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.15.2",
4
+ "version": "2.16.0",
5
5
  "description": "MyAPI command-line interface",
6
6
  "repository": {
7
7
  "type": "git",
@@ -46,7 +46,7 @@
46
46
  "lint:skills:strict": "node scripts/copy-skills.js && node scripts/lint-skills.js --strict"
47
47
  },
48
48
  "dependencies": {
49
- "@myapihq/sdk": "^2.15.2"
49
+ "@myapihq/sdk": "^2.16.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",
@@ -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
- });