@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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
full.accounts[
|
|
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.
|
|
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.
|
|
49
|
+
"@myapihq/sdk": "^2.15.2"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^25.6.0",
|