@myapihq/cli 2.15.1 → 2.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ // saveConfig() writes by api_key, not by the active index.
2
+ //
3
+ // The bug this pins is data loss, not misconfiguration. saveConfig wrote to
4
+ // accounts[active] on the assumption that the Config handed to it belongs to
5
+ // the active account. It does not when MYAPI_API_KEY names a key the file has
6
+ // never seen: loadConfig deliberately returns a bare `{ api_key, account_id:'' }`
7
+ // for an unknown key — inheriting the active account's defaults is the
8
+ // cross-tenant bug config-envkey.test.ts pins — and saving that stub then
9
+ // overwrote the signed-in entry with it. api_key, account_id, default_org and
10
+ // default_funnel, gone from disk.
11
+ //
12
+ // It was reachable from any org-scoped command once requireOrg began persisting
13
+ // `last_org_used`, which turned a config-command bug into a data-loss one on an
14
+ // ordinary `database namespaces`. Reported by the backend team 2026-08-17 while
15
+ // setting up a developer with an org-locked key; reproduced before fixing.
16
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
17
+ import * as fs from 'node:fs';
18
+ import * as os from 'node:os';
19
+ import * as path from 'node:path';
20
+ const SIGNED_IN = 'hq_live_signed_in_account';
21
+ const ORG_LOCKED = 'hq_live_org_locked_key';
22
+ let tmpHome;
23
+ let realHome;
24
+ const FULL_CONFIG = {
25
+ active: 0,
26
+ accounts: [
27
+ {
28
+ api_key: SIGNED_IN,
29
+ account_id: 'acct-real',
30
+ email: 'operator@example.com',
31
+ default_org: 'org-real',
32
+ default_funnel: 'fun-real',
33
+ },
34
+ ],
35
+ };
36
+ async function freshConfigModule() {
37
+ vi.resetModules();
38
+ return import('./config.js');
39
+ }
40
+ function readAccounts() {
41
+ return JSON.parse(fs.readFileSync(path.join(tmpHome, '.myapi', 'config.json'), 'utf-8')).accounts;
42
+ }
43
+ beforeEach(() => {
44
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'myapi-save-'));
45
+ fs.mkdirSync(path.join(tmpHome, '.myapi'), { recursive: true });
46
+ fs.writeFileSync(path.join(tmpHome, '.myapi', 'config.json'), JSON.stringify(FULL_CONFIG));
47
+ realHome = process.env.HOME;
48
+ process.env.HOME = tmpHome;
49
+ delete process.env.MYAPI_API_KEY;
50
+ delete process.env.MYAPI_KEY;
51
+ });
52
+ afterEach(() => {
53
+ if (realHome !== undefined)
54
+ process.env.HOME = realHome;
55
+ delete process.env.MYAPI_API_KEY;
56
+ fs.rmSync(tmpHome, { recursive: true, force: true });
57
+ });
58
+ describe('saveConfig upserts by api_key', () => {
59
+ it('does not destroy the signed-in account when saving an unknown env key', async () => {
60
+ process.env.MYAPI_API_KEY = ORG_LOCKED;
61
+ const { loadConfig, saveConfig } = await freshConfigModule();
62
+ const cfg = loadConfig();
63
+ expect(cfg.api_key).toBe(ORG_LOCKED);
64
+ expect(cfg.account_id).toBe(''); // the stub loadConfig hands back on purpose
65
+ // What any org-scoped command now does via requireOrg.
66
+ saveConfig({ ...cfg, last_org_used: 'org-customer' });
67
+ const accounts = readAccounts();
68
+ const original = accounts.find((a) => a.api_key === SIGNED_IN);
69
+ expect(original, 'the signed-in account must still exist').toBeDefined();
70
+ expect(original.account_id).toBe('acct-real');
71
+ expect(original.default_org).toBe('org-real');
72
+ expect(original.default_funnel).toBe('fun-real');
73
+ });
74
+ it('gives the env key its own entry so its defaults can persist', async () => {
75
+ process.env.MYAPI_API_KEY = ORG_LOCKED;
76
+ const { loadConfig, saveConfig } = await freshConfigModule();
77
+ saveConfig({ ...loadConfig(), default_org: 'org-customer' });
78
+ const entry = readAccounts().find((a) => a.api_key === ORG_LOCKED);
79
+ expect(entry, 'an unknown key should be appended, not merged into another').toBeDefined();
80
+ expect(entry.default_org).toBe('org-customer');
81
+ });
82
+ it('leaves `active` alone — an env key must not become the signed-in account', async () => {
83
+ process.env.MYAPI_API_KEY = ORG_LOCKED;
84
+ const { loadConfig, saveConfig } = await freshConfigModule();
85
+ saveConfig({ ...loadConfig(), default_org: 'org-customer' });
86
+ const full = JSON.parse(fs.readFileSync(path.join(tmpHome, '.myapi', 'config.json'), 'utf-8'));
87
+ expect(full.active).toBe(0);
88
+ expect(full.accounts[full.active].api_key).toBe(SIGNED_IN);
89
+ });
90
+ it('still updates in place for a key the config already knows', async () => {
91
+ const { loadConfig, saveConfig } = await freshConfigModule();
92
+ saveConfig({ ...loadConfig(), default_org: 'org-moved' });
93
+ const accounts = readAccounts();
94
+ expect(accounts).toHaveLength(1); // updated, not duplicated
95
+ expect(accounts[0].default_org).toBe('org-moved');
96
+ expect(accounts[0].account_id).toBe('acct-real');
97
+ });
98
+ });
package/dist/config.js CHANGED
@@ -94,13 +94,37 @@ function writeFullConfig(full) {
94
94
  // configs written by older CLI versions (pre-0o600) that are still 0644.
95
95
  fs.chmodSync(CONFIG_FILE, 0o600);
96
96
  }
97
+ // Upsert by api_key, NOT by the active index.
98
+ //
99
+ // Writing to accounts[active] assumed the config being saved is the active
100
+ // account's. It is not when MYAPI_API_KEY names a key the file has never seen:
101
+ // loadConfig deliberately returns a bare `{ api_key, account_id: '' }` for an
102
+ // unknown key (see the comment there — inheriting the active account's defaults
103
+ // is the cross-tenant bug it exists to avoid), and saving that stub then
104
+ // overwrote the signed-in entry with it. One ordinary command wiped the stored
105
+ // api_key, account_id, default_org and default_funnel.
106
+ //
107
+ // It became reachable from any org-scoped command once requireOrg started
108
+ // persisting `last_org_used`, which is what made a config-only bug into a
109
+ // data-loss one. Reproduced 2026-08-17.
110
+ //
111
+ // Matching loadConfig's own lookup makes read and write symmetric, and gives an
112
+ // env-supplied key somewhere of its own to keep defaults — which is what lets a
113
+ // developer holding an org-locked key have a default_org at all.
97
114
  export function saveConfig(config) {
98
115
  const full = loadFullConfig() ?? { active: 0, accounts: [] };
99
116
  const { autocomplete_setup, ...account } = config;
100
- if (full.accounts.length === 0)
101
- full.accounts.push(account);
102
- else
103
- full.accounts[full.active] = account;
117
+ const entry = account;
118
+ const existing = full.accounts.findIndex(a => a.api_key === entry.api_key);
119
+ if (existing >= 0) {
120
+ full.accounts[existing] = entry;
121
+ }
122
+ else {
123
+ // Appended, and `active` is deliberately left alone: a key passed through
124
+ // the environment for one command must not silently become the account the
125
+ // user is signed in as.
126
+ full.accounts.push(entry);
127
+ }
104
128
  if (autocomplete_setup !== undefined)
105
129
  full.autocomplete_setup = autocomplete_setup;
106
130
  writeFullConfig(full);
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.1",
4
+ "version": "2.15.2",
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.1"
49
+ "@myapihq/sdk": "^2.15.2"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^25.6.0",