@antasphere/chassis-cli 0.0.0-stage → 1.0.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.
Files changed (117) hide show
  1. package/LICENSE +82 -0
  2. package/dist/commands/auth.d.ts +6 -0
  3. package/dist/commands/auth.d.ts.map +1 -0
  4. package/dist/commands/auth.js +582 -0
  5. package/dist/commands/auth.js.map +1 -0
  6. package/dist/commands/completion.d.ts +5 -0
  7. package/dist/commands/completion.d.ts.map +1 -0
  8. package/dist/commands/completion.js +85 -0
  9. package/dist/commands/completion.js.map +1 -0
  10. package/dist/commands/demo.d.ts +12 -0
  11. package/dist/commands/demo.d.ts.map +1 -0
  12. package/dist/commands/demo.js +210 -0
  13. package/dist/commands/demo.js.map +1 -0
  14. package/dist/commands/files.d.ts +21 -0
  15. package/dist/commands/files.d.ts.map +1 -0
  16. package/dist/commands/files.js +178 -0
  17. package/dist/commands/files.js.map +1 -0
  18. package/dist/commands/projects.d.ts +38 -0
  19. package/dist/commands/projects.d.ts.map +1 -0
  20. package/dist/commands/projects.js +386 -0
  21. package/dist/commands/projects.js.map +1 -0
  22. package/dist/commands/teams.d.ts +46 -0
  23. package/dist/commands/teams.d.ts.map +1 -0
  24. package/dist/commands/teams.js +265 -0
  25. package/dist/commands/teams.js.map +1 -0
  26. package/dist/commands/workspaces.d.ts +22 -0
  27. package/dist/commands/workspaces.d.ts.map +1 -0
  28. package/dist/commands/workspaces.js +116 -0
  29. package/dist/commands/workspaces.js.map +1 -0
  30. package/dist/config.d.ts +29 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +54 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/context.d.ts +152 -0
  35. package/dist/context.d.ts.map +1 -0
  36. package/dist/context.js +456 -0
  37. package/dist/context.js.map +1 -0
  38. package/dist/identity.d.ts +36 -0
  39. package/dist/identity.d.ts.map +1 -0
  40. package/dist/identity.js +18 -0
  41. package/dist/identity.js.map +1 -0
  42. package/dist/index.d.ts +24 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +31 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/kit.d.ts +46 -0
  47. package/dist/kit.d.ts.map +1 -0
  48. package/dist/kit.js +2 -0
  49. package/dist/kit.js.map +1 -0
  50. package/dist/open.d.ts +27 -0
  51. package/dist/open.d.ts.map +1 -0
  52. package/dist/open.js +45 -0
  53. package/dist/open.js.map +1 -0
  54. package/dist/owner-session.d.ts +34 -0
  55. package/dist/owner-session.d.ts.map +1 -0
  56. package/dist/owner-session.js +106 -0
  57. package/dist/owner-session.js.map +1 -0
  58. package/dist/program.d.ts +5 -0
  59. package/dist/program.d.ts.map +1 -0
  60. package/dist/program.js +150 -0
  61. package/dist/program.js.map +1 -0
  62. package/dist/safe-write.d.ts +46 -0
  63. package/dist/safe-write.d.ts.map +1 -0
  64. package/dist/safe-write.js +127 -0
  65. package/dist/safe-write.js.map +1 -0
  66. package/dist/stdin.d.ts +4 -0
  67. package/dist/stdin.d.ts.map +1 -0
  68. package/dist/stdin.js +36 -0
  69. package/dist/stdin.js.map +1 -0
  70. package/dist/testing/index.d.ts +70 -0
  71. package/dist/testing/index.d.ts.map +1 -0
  72. package/dist/testing/index.js +67 -0
  73. package/dist/testing/index.js.map +1 -0
  74. package/dist/workspace.d.ts +79 -0
  75. package/dist/workspace.d.ts.map +1 -0
  76. package/dist/workspace.js +139 -0
  77. package/dist/workspace.js.map +1 -0
  78. package/package.json +43 -3
  79. package/src/commands/auth.ts +687 -0
  80. package/src/commands/completion.ts +102 -0
  81. package/src/commands/demo.ts +271 -0
  82. package/src/commands/files.ts +197 -0
  83. package/src/commands/projects.ts +460 -0
  84. package/src/commands/teams.ts +320 -0
  85. package/src/commands/workspaces.ts +140 -0
  86. package/src/config.ts +81 -0
  87. package/src/context.ts +616 -0
  88. package/src/identity.ts +48 -0
  89. package/src/index.ts +70 -0
  90. package/src/kit.ts +50 -0
  91. package/src/open.ts +48 -0
  92. package/src/owner-session.ts +152 -0
  93. package/src/program.ts +183 -0
  94. package/src/safe-write.ts +136 -0
  95. package/src/stdin.ts +39 -0
  96. package/src/testing/index.ts +129 -0
  97. package/src/workspace.ts +229 -0
  98. package/test/host/identity.test.ts +229 -0
  99. package/test/host/minimal-host.ts +16 -0
  100. package/test/host/minimal-tool.ts +40 -0
  101. package/test/suite/cli.test.ts +301 -0
  102. package/test/suite/config.test.ts +59 -0
  103. package/test/suite/demo.test.ts +467 -0
  104. package/test/suite/hub-profile-shared.test.ts +123 -0
  105. package/test/suite/login.test.ts +743 -0
  106. package/test/suite/logout.test.ts +247 -0
  107. package/test/suite/migration.test.ts +109 -0
  108. package/test/suite/open.test.ts +59 -0
  109. package/test/suite/profiles.test.ts +380 -0
  110. package/test/suite/projects.test.ts +550 -0
  111. package/test/suite/recovery.test.ts +348 -0
  112. package/test/suite/security.test.ts +199 -0
  113. package/test/suite/signin-fixtures.ts +204 -0
  114. package/test/suite/teams.test.ts +354 -0
  115. package/test/suite/whoami.test.ts +159 -0
  116. package/test/suite/workspace.test.ts +402 -0
  117. package/README.md +0 -3
@@ -0,0 +1,247 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { HUB_TOOL, loadConfig as loadCoreConfig } from '@antasphere/cli-core';
3
+ import { routedHarness, tempConfigEnv, type Route } from '@antasphere/chassis-cli/testing';
4
+ import { cli, run } from '@chassis-cli-test/host';
5
+ import { HUB, HUB_KEY, key, seedHubLogin } from './signin-fixtures.js';
6
+
7
+ /**
8
+ * `logout` revokes what it forgets (PRDCT-2947): every key the selected
9
+ * profile holds, the tool's own and the ones the hub exchange cached, is
10
+ * self-revoked on the PROFILE's instance, never anywhere else; then the
11
+ * profile keeps its instance and loses its credentials.
12
+ */
13
+
14
+ const { loadConfig, saveConfig } = cli;
15
+ const { envPrefix: P, displayName, bin } = cli.identity;
16
+
17
+ /** What a plain logout adds when an Antasphere login is stored (PRDCT-3032). */
18
+ const STAYS = ` The Antasphere login ada@x.co stays for the whole tool family (\`${bin} logout --all\` ends it too).`;
19
+
20
+ const OWN = key('own');
21
+ const CONN_A = key('conna');
22
+ const CONN_B = key('connb');
23
+
24
+ /** DELETE /cli/auth/key, answering per presented key (default 200). */
25
+ function revokeRoute(answers: Record<string, { status: number; body: unknown }> = {}): Route {
26
+ return {
27
+ method: 'DELETE',
28
+ path: /\/api\/v1\/cli\/auth\/key$/,
29
+ reply: ({ headers }) => {
30
+ const presented = headers.get('authorization')?.replace(/^Bearer\s+/, '') ?? '';
31
+ return answers[presented] ?? { body: { revoked: true, id: 'k1' } };
32
+ }
33
+ };
34
+ }
35
+
36
+ const refusal = (status: number, code: string) => ({ status, body: { error: { code, message: code } } });
37
+
38
+ async function seeded(profile: Record<string, unknown>, extra: Record<string, string> = {}) {
39
+ const env = await tempConfigEnv();
40
+ seedHubLogin(env);
41
+ saveConfig(env, {
42
+ activeProfile: 'work',
43
+ profiles: { work: profile, other: { apiKey: key('other'), baseUrl: 'http://other' } }
44
+ });
45
+ return { ...env, ...extra };
46
+ }
47
+
48
+ const FULL = {
49
+ apiKey: OWN,
50
+ baseUrl: 'http://inst',
51
+ email: 'ada@x.co',
52
+ connectKeys: { default: { apiKey: CONN_A }, personal: { apiKey: CONN_B } }
53
+ };
54
+
55
+ describe('logout', () => {
56
+ it('revokes the cached hub keys, then the profile key, on the profile instance; keeps the profile', async () => {
57
+ const env = await seeded(FULL);
58
+ const h = routedHarness([revokeRoute()], env);
59
+ expect(await run(['logout'], h.io)).toBe(0);
60
+ expect(h.wire).toEqual([
61
+ { method: 'DELETE', origin: 'http://inst', path: '/api/v1/cli/auth/key', auth: `Bearer ${CONN_A}` },
62
+ { method: 'DELETE', origin: 'http://inst', path: '/api/v1/cli/auth/key', auth: `Bearer ${CONN_B}` },
63
+ { method: 'DELETE', origin: 'http://inst', path: '/api/v1/cli/auth/key', auth: `Bearer ${OWN}` }
64
+ ]);
65
+ expect(h.out()).toBe(
66
+ `Logged out of profile "work" on http://inst (3 keys revoked server-side).${STAYS}\n`
67
+ );
68
+ expect(h.err()).toBe('');
69
+ const config = loadConfig(env);
70
+ expect(config.activeProfile).toBe('work');
71
+ expect(config.profiles.work).toEqual({ baseUrl: 'http://inst' });
72
+ // Another profile, and the Antasphere login itself, are untouched.
73
+ expect(config.profiles.other?.apiKey).toBe(key('other'));
74
+ expect(loadCoreConfig(env, HUB_TOOL).profiles.default?.apiKey).toBe(HUB_KEY);
75
+ });
76
+
77
+ it('one key: "1 key"', async () => {
78
+ const env = await seeded({ apiKey: OWN, baseUrl: 'http://inst' });
79
+ const h = routedHarness([revokeRoute()], env);
80
+ expect(await run(['logout'], h.io)).toBe(0);
81
+ expect(h.out()).toBe(
82
+ `Logged out of profile "work" on http://inst (1 key revoked server-side).${STAYS}\n`
83
+ );
84
+ });
85
+
86
+ it(`never sends a key to the URL of --api-url's instance when --profile names the profile, nor to ${P}_URL`, async () => {
87
+ const env = await seeded(FULL, { [`${P}_URL`]: 'http://elsewhere' });
88
+ const h = routedHarness([revokeRoute()], env);
89
+ expect(await run(['logout', '--profile', 'work', '--api-url', 'http://flagged'], h.io)).toBe(0);
90
+ expect(new Set(h.wire.map((c) => c.origin))).toEqual(new Set(['http://inst']));
91
+ expect(h.wire).toHaveLength(3);
92
+ });
93
+
94
+ it('--api-url selects the profile of that instance', async () => {
95
+ const env = await seeded(FULL);
96
+ const h = routedHarness([revokeRoute()], env);
97
+ expect(await run(['logout', '--api-url', 'http://other/'], h.io)).toBe(0);
98
+ expect(h.wire).toEqual([
99
+ {
100
+ method: 'DELETE',
101
+ origin: 'http://other',
102
+ path: '/api/v1/cli/auth/key',
103
+ auth: `Bearer ${key('other')}`
104
+ }
105
+ ]);
106
+ expect(h.out()).toBe(
107
+ `Logged out of profile "other" on http://other (1 key revoked server-side).${STAYS}\n`
108
+ );
109
+ expect(loadConfig(env).profiles.work?.apiKey).toBe(OWN);
110
+ });
111
+
112
+ it('no profile on disk: nothing to log out of, nothing sent', async () => {
113
+ const env = await tempConfigEnv();
114
+ const h = routedHarness([revokeRoute()], env);
115
+ expect(await run(['logout'], h.io)).toBe(1);
116
+ expect(h.err()).toBe('Error: No profile to log out of.\n');
117
+ expect(h.wire).toEqual([]);
118
+ });
119
+
120
+ it('a profile that holds no key says so and sends nothing', async () => {
121
+ const env = await seeded({ baseUrl: 'http://inst' });
122
+ const h = routedHarness([revokeRoute()], env);
123
+ expect(await run(['logout'], h.io)).toBe(0);
124
+ expect(h.out()).toBe(`Profile "work" held no key.${STAYS}\n`);
125
+ expect(h.wire).toEqual([]);
126
+ });
127
+
128
+ it('a 401 on a revoke: already unusable, forgotten', async () => {
129
+ const env = await seeded(FULL);
130
+ const h = routedHarness(
131
+ [
132
+ revokeRoute({
133
+ [CONN_A]: refusal(401, 'invalid_api_key'),
134
+ [OWN]: refusal(401, 'invalid_api_key')
135
+ })
136
+ ],
137
+ env
138
+ );
139
+ expect(await run(['logout'], h.io)).toBe(0);
140
+ expect(h.err()).toBe(
141
+ 'the key of the Antasphere login "default" was already unusable; forgetting it.\n' +
142
+ 'the profile key was already unusable; forgetting it.\n'
143
+ );
144
+ expect(h.out()).toBe(
145
+ `Logged out of profile "work" on http://inst (1 key revoked server-side).${STAYS}\n`
146
+ );
147
+ expect(loadConfig(env).profiles.work).toEqual({ baseUrl: 'http://inst' });
148
+ });
149
+
150
+ it('a 403 on a revoke: the key STAYS VALID, said on stderr and in the summary; the local copy goes', async () => {
151
+ const env = await seeded({ apiKey: OWN, baseUrl: 'http://inst' });
152
+ const h = routedHarness([revokeRoute({ [OWN]: refusal(403, 'endpoint_not_allowed') })], env);
153
+ expect(await run(['logout'], h.io)).toBe(0);
154
+ expect(h.err()).toBe(
155
+ 'The instance refused to revoke the profile key (endpoint_not_allowed): it STAYS VALID server-side; ' +
156
+ 'revoke it from the dashboard. Forgetting the local copy.\n'
157
+ );
158
+ expect(h.out()).toBe(
159
+ `Logged out of profile "work" on http://inst — 1 key could NOT be revoked and stay valid.${STAYS}\n`
160
+ );
161
+ expect(loadConfig(env).profiles.work).toEqual({ baseUrl: 'http://inst' });
162
+ });
163
+
164
+ it('--json: the counts, the hub logins, forgotten', async () => {
165
+ const env = await seeded(FULL);
166
+ const h = routedHarness([revokeRoute({ [CONN_B]: refusal(403, 'endpoint_not_allowed') })], env);
167
+ expect(await run(['logout', '--json'], h.io)).toBe(0);
168
+ expect(JSON.parse(h.out())).toEqual({
169
+ profile: 'work',
170
+ baseUrl: 'http://inst',
171
+ revoked: 2,
172
+ refused: 1,
173
+ hubProfiles: ['default', 'personal'],
174
+ forgotten: true
175
+ });
176
+ });
177
+
178
+ it('no profile of the tool, an Antasphere login stored: names the two commands that end it, exit 1, nothing sent (PRDCT-3032)', async () => {
179
+ const env = await tempConfigEnv();
180
+ seedHubLogin(env);
181
+ const h = routedHarness([revokeRoute()], env);
182
+ expect(await run(['logout'], h.io)).toBe(1);
183
+ expect(h.err()).toBe(
184
+ `Error: Nothing of ${displayName}'s to sign out of on this machine. The Antasphere login ada@x.co ` +
185
+ `stays for the whole tool family: \`${bin} logout --all\` or \`antasphere logout\` ends it.\n`
186
+ );
187
+ expect(h.wire).toEqual([]);
188
+ expect(loadCoreConfig(env, HUB_TOOL).profiles.default?.apiKey).toBe(HUB_KEY);
189
+ });
190
+
191
+ it('no profile of the tool, --all: the hub key revoked on the hub, the hub profile cleared (PRDCT-3032)', async () => {
192
+ const env = await tempConfigEnv();
193
+ seedHubLogin(env);
194
+ const h = routedHarness([revokeRoute()], env);
195
+ expect(await run(['logout', '--all'], h.io)).toBe(0);
196
+ expect(h.wire).toEqual([
197
+ { method: 'DELETE', origin: HUB, path: '/api/v1/cli/auth/key', auth: `Bearer ${HUB_KEY}` }
198
+ ]);
199
+ expect(h.out()).toBe(
200
+ `Nothing of ${displayName}'s to sign out of on this machine. ` +
201
+ 'The Antasphere login ada@x.co is revoked and forgotten.\n'
202
+ );
203
+ const hub = loadCoreConfig(env, HUB_TOOL);
204
+ expect(hub.profiles).toEqual({});
205
+ expect(hub.activeProfile).toBeUndefined();
206
+ });
207
+
208
+ it("--all on a profile with a cached key: the tool's key revoked on the instance, then the hub key on the hub; both cleared (PRDCT-3032)", async () => {
209
+ const env = await seeded({ baseUrl: 'http://inst', connectKeys: { default: { apiKey: CONN_A } } });
210
+ const h = routedHarness([revokeRoute()], env);
211
+ expect(await run(['logout', '--all'], h.io)).toBe(0);
212
+ expect(h.wire).toEqual([
213
+ { method: 'DELETE', origin: 'http://inst', path: '/api/v1/cli/auth/key', auth: `Bearer ${CONN_A}` },
214
+ { method: 'DELETE', origin: HUB, path: '/api/v1/cli/auth/key', auth: `Bearer ${HUB_KEY}` }
215
+ ]);
216
+ expect(h.out()).toBe(
217
+ 'Logged out of profile "work" on http://inst (1 key revoked server-side). ' +
218
+ 'The Antasphere login ada@x.co is revoked and forgotten.\n'
219
+ );
220
+ expect(loadConfig(env).profiles.work).toEqual({ baseUrl: 'http://inst' });
221
+ const hub = loadCoreConfig(env, HUB_TOOL);
222
+ expect(hub.profiles).toEqual({});
223
+ expect(hub.activeProfile).toBeUndefined();
224
+ });
225
+
226
+ it('the same profile, a plain logout: the tool key alone is revoked, the Antasphere login stays and is named (PRDCT-3032)', async () => {
227
+ const env = await seeded({ baseUrl: 'http://inst', connectKeys: { default: { apiKey: CONN_A } } });
228
+ const h = routedHarness([revokeRoute()], env);
229
+ expect(await run(['logout'], h.io)).toBe(0);
230
+ expect(h.wire).toEqual([
231
+ { method: 'DELETE', origin: 'http://inst', path: '/api/v1/cli/auth/key', auth: `Bearer ${CONN_A}` }
232
+ ]);
233
+ expect(h.out()).toBe(
234
+ 'Logged out of profile "work" on http://inst (1 key revoked server-side). ' +
235
+ `The Antasphere login ada@x.co stays for the whole tool family (\`${bin} logout --all\` ends it too).\n`
236
+ );
237
+ expect(loadCoreConfig(env, HUB_TOOL).profiles.default?.apiKey).toBe(HUB_KEY);
238
+ });
239
+
240
+ it('a profile with no instance of its own revokes on the cloud URL', async () => {
241
+ const env = await tempConfigEnv();
242
+ saveConfig(env, { activeProfile: 'mine', profiles: { mine: { apiKey: OWN } } });
243
+ const h = routedHarness([revokeRoute()], env);
244
+ expect(await run(['logout'], h.io)).toBe(0);
245
+ expect(h.wire.map((c) => c.origin)).toEqual([new URL(cli.identity.cloudUrl).origin]);
246
+ });
247
+ });
@@ -0,0 +1,109 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { routedHarness, tempConfigEnv, type Route } from '@antasphere/chassis-cli/testing';
6
+ import { cli, run } from '@chassis-cli-test/host';
7
+
8
+ // The kit's config store, and the literals that spell the tool (its identity).
9
+ const { configPath, loadConfig, saveConfig } = cli;
10
+ const { tool, legacyConfigDir, keyPrefix: K } = cli.identity;
11
+
12
+ /**
13
+ * The one-shot legacy-config import: profiles saved by pre-cli-core builds
14
+ * at `$XDG_CONFIG_HOME/<legacyConfigDir>/config.json` are picked up on first run,
15
+ * NON-destructively (the legacy file stays in place), then never again once
16
+ * the shared home has the tool's profiles.
17
+ */
18
+
19
+ const LEGACY = {
20
+ activeProfile: 'work',
21
+ profiles: {
22
+ work: { apiKey: `${K}_abcdefgh_0123456789abcdef`, baseUrl: 'http://legacy-inst' }
23
+ }
24
+ };
25
+
26
+ const ME = {
27
+ user: { id: 'u1', name: 'Ada', email: 'ada@x.co' },
28
+ workspace: { id: 'w1', name: 'Acme' },
29
+ workspaces: [
30
+ {
31
+ id: 'w1',
32
+ name: 'Acme',
33
+ role: 'owner',
34
+ hubOrigin: false,
35
+ centralAccountId: null,
36
+ look: { theme: null, pattern: null, field: null, grain: null },
37
+ suspended: false,
38
+ default: true
39
+ }
40
+ ],
41
+ activeWorkspaceId: 'w1',
42
+ role: 'owner',
43
+ via: 'api_key',
44
+ scopes: ['items:read'],
45
+ apiKeyExpiresAt: null
46
+ };
47
+
48
+ const meRoute: Route = { method: 'GET', path: /\/api\/v1\/me$/, reply: () => ({ body: ME }) };
49
+
50
+ async function writeLegacy(env: Record<string, string>, content: unknown): Promise<string> {
51
+ const dir = join(env.XDG_CONFIG_HOME!, legacyConfigDir);
52
+ await mkdir(dir, { recursive: true });
53
+ const path = join(dir, 'config.json');
54
+ await writeFile(path, JSON.stringify(content, null, 2));
55
+ return path;
56
+ }
57
+
58
+ describe('legacy config migration', () => {
59
+ it('a first run imports the legacy profile and commands ride it', async () => {
60
+ const env = await tempConfigEnv();
61
+ const legacyPath = await writeLegacy(env, LEGACY);
62
+ const h = routedHarness([meRoute], env);
63
+ const code = await run(['whoami'], h.io);
64
+ expect(h.err()).toBe('');
65
+ expect(code).toBe(0);
66
+ // whoami resolved the migrated profile's baseUrl + key.
67
+ expect(h.out()).toContain('http://legacy-inst');
68
+ // The shared-home file now exists with the imported content…
69
+ expect(loadConfig(env)).toEqual(LEGACY);
70
+ expect(configPath(env)).toBe(join(env.XDG_CONFIG_HOME!, 'antasphere', 'tools', `${tool}.json`));
71
+ // …and the legacy file is untouched (non-destructive).
72
+ expect(existsSync(legacyPath)).toBe(true);
73
+ expect(JSON.parse(await readFile(legacyPath, 'utf8'))).toEqual(LEGACY);
74
+ });
75
+
76
+ it('profiles lists the imported profiles; the import happens once', async () => {
77
+ const env = await tempConfigEnv();
78
+ await writeLegacy(env, LEGACY);
79
+ const h = routedHarness([], env);
80
+ expect(await run(['profiles'], h.io)).toBe(0);
81
+ expect(h.out()).toContain('work');
82
+ expect(h.out()).toContain('http://legacy-inst');
83
+
84
+ // A later login under a new name does NOT get clobbered by a re-import.
85
+ saveConfig(env, {
86
+ activeProfile: 'new',
87
+ profiles: {
88
+ ...loadConfig(env).profiles,
89
+ new: { apiKey: `${K}_new_key_1234567890`, baseUrl: 'http://new' }
90
+ }
91
+ });
92
+ const h2 = routedHarness([], env);
93
+ expect(await run(['profiles'], h2.io)).toBe(0);
94
+ expect(loadConfig(env).activeProfile).toBe('new');
95
+ });
96
+
97
+ it(`the legacy file is ignored once the shared home has ${tool} profiles`, async () => {
98
+ const env = await tempConfigEnv();
99
+ saveConfig(env, {
100
+ activeProfile: 'current',
101
+ profiles: { current: { apiKey: `${K}_current_key_12345`, baseUrl: 'http://current' } }
102
+ });
103
+ await writeLegacy(env, LEGACY);
104
+ const h = routedHarness([meRoute], env);
105
+ expect(await run(['whoami'], h.io)).toBe(0);
106
+ expect(h.out()).toContain('http://current');
107
+ expect(loadConfig(env).profiles.work).toBeUndefined();
108
+ });
109
+ });
@@ -0,0 +1,59 @@
1
+ /* eslint-disable @typescript-eslint/consistent-type-imports -- the `typeof import(…)` casts moved verbatim with their tests (PRDCT-2530) */
2
+ import { EventEmitter } from 'node:events';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { openInBrowser, platformOpener } from '@antasphere/chassis-cli';
5
+
6
+ /**
7
+ * The browser opener, the generic half of PRDCT-2280: the platform command,
8
+ * the URL as its own argv item, the injected `io.openUrl`. What a tool opens
9
+ * and when (the push decision, `open`, `dev`) is the tool's own open.test.ts.
10
+ */
11
+
12
+ describe('the opener', () => {
13
+ it('is the platform command, the URL its own argv item, never a shell string', () => {
14
+ expect(platformOpener('darwin')).toEqual({ command: 'open', args: [] });
15
+ expect(platformOpener('linux')).toEqual({ command: 'xdg-open', args: [] });
16
+ expect(platformOpener('freebsd')).toEqual({ command: 'xdg-open', args: [] });
17
+ // `start` is a cmd.exe builtin spawn cannot run; rundll32 is a real binary.
18
+ expect(platformOpener('win32')).toEqual({ command: 'rundll32', args: ['url.dll,FileProtocolHandler'] });
19
+ });
20
+
21
+ it('spawns with the URL as the last argv item, detached, and never throws', () => {
22
+ const spawned: Array<{ command: string; args: readonly string[]; opts: unknown }> = [];
23
+ const fakeSpawn = ((command: string, args: readonly string[], opts: unknown) => {
24
+ spawned.push({ command, args, opts });
25
+ const child = new EventEmitter() as EventEmitter & { unref: () => void };
26
+ child.unref = () => undefined;
27
+ return child;
28
+ }) as unknown as typeof import('node:child_process').spawn;
29
+ const url = 'http://x/decks/abc/present?a=1&b=$(rm -rf /)';
30
+ openInBrowser(
31
+ { env: {}, out: { write: () => undefined }, err: { write: () => undefined } },
32
+ url,
33
+ fakeSpawn
34
+ );
35
+ expect(spawned).toHaveLength(1);
36
+ expect(spawned[0]!.args[spawned[0]!.args.length - 1]).toBe(url);
37
+ expect(spawned[0]!.opts).toMatchObject({ detached: true, stdio: 'ignore' });
38
+ // `shell` is never set: the URL stays one argument.
39
+ expect((spawned[0]!.opts as { shell?: unknown }).shell).toBeUndefined();
40
+ });
41
+
42
+ it('prefers the injected io.openUrl and spawns nothing', () => {
43
+ const opened: string[] = [];
44
+ const fakeSpawn = (() => {
45
+ throw new Error('must not spawn');
46
+ }) as unknown as typeof import('node:child_process').spawn;
47
+ openInBrowser(
48
+ {
49
+ env: {},
50
+ out: { write: () => undefined },
51
+ err: { write: () => undefined },
52
+ openUrl: (u) => opened.push(u)
53
+ },
54
+ 'http://x/decks/abc/present',
55
+ fakeSpawn
56
+ );
57
+ expect(opened).toEqual(['http://x/decks/abc/present']);
58
+ });
59
+ });