@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,159 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { routedHarness, tempConfigEnv } from '@antasphere/chassis-cli/testing';
3
+ import { cli, run } from '@chassis-cli-test/host';
4
+ import {
5
+ cloudInstanceRoute,
6
+ exchangeRoutes,
7
+ key,
8
+ meBody,
9
+ meRoute,
10
+ ORG,
11
+ seedHubLogin,
12
+ wsRow
13
+ } from './signin-fixtures.js';
14
+
15
+ /**
16
+ * `whoami` says who, where, and WITH WHAT (PRDCT-2947): the credential the
17
+ * run resolved, and what became of the Antasphere login.
18
+ */
19
+
20
+ const { saveConfig } = cli;
21
+ const { bin, envPrefix: P, cloudUrl } = cli.identity;
22
+ const CLOUD = new URL(cloudUrl).origin;
23
+
24
+ function lines(out: string, prefix: string): string[] {
25
+ return out.split('\n').filter((l) => l.startsWith(prefix));
26
+ }
27
+
28
+ describe('whoami', () => {
29
+ it('prints every line, in order', async () => {
30
+ const env = await tempConfigEnv();
31
+ seedHubLogin(env);
32
+ const flagKey = key('flag');
33
+ const rows = [wsRow('w1', 'Acme', { default: true, hubOrigin: true, centralAccountId: ORG })];
34
+ const h = routedHarness([meRoute(meBody({ workspaces: rows }))], env);
35
+ expect(await run(['whoami', '--api-url', 'http://inst', '--api-key', flagKey], h.io)).toBe(0);
36
+ expect(h.out()).toBe(
37
+ 'Ada <ada@x.co>\n' +
38
+ ' instance: http://inst (profile "inst")\n' +
39
+ ' workspace: Acme (w1)\n' +
40
+ ` organization: ${ORG}\n` +
41
+ " chosen by: the server's default (nothing selected)\n" +
42
+ ' signed in with: the --api-key flag\n' +
43
+ ' Antasphere login: ada@x.co (not used: the --api-key flag wins)\n' +
44
+ ' role: owner (via api_key)\n' +
45
+ ' scopes: a:read\n' +
46
+ ' key expires: never\n'
47
+ );
48
+ });
49
+
50
+ it('the key variable', async () => {
51
+ const env = await tempConfigEnv();
52
+ seedHubLogin(env);
53
+ const h = routedHarness([meRoute()], {
54
+ ...env,
55
+ [`${P}_URL`]: 'http://inst',
56
+ [`${P}_API_KEY`]: key('env')
57
+ });
58
+ expect(await run(['whoami'], h.io)).toBe(0);
59
+ expect(lines(h.out(), ' signed in with:')).toEqual([' signed in with: the API key variable']);
60
+ expect(lines(h.out(), ' Antasphere login:')).toEqual([
61
+ ' Antasphere login: ada@x.co (not used: the API key variable wins)'
62
+ ]);
63
+ expect(h.out()).not.toContain('organization:');
64
+ });
65
+
66
+ it("the profile's own key; no Antasphere line off the cloud with no hub login", async () => {
67
+ const env = await tempConfigEnv();
68
+ saveConfig(env, {
69
+ activeProfile: 'work',
70
+ profiles: { work: { apiKey: key('own'), baseUrl: 'http://inst' } }
71
+ });
72
+ const h = routedHarness([meRoute()], env);
73
+ expect(await run(['whoami'], h.io)).toBe(0);
74
+ expect(lines(h.out(), ' instance:')).toEqual([' instance: http://inst (profile "work")']);
75
+ expect(lines(h.out(), ' signed in with:')).toEqual([' signed in with: the key of profile "work"']);
76
+ expect(h.out()).not.toContain('Antasphere login');
77
+ });
78
+
79
+ it("the profile's own key, with a hub login stored: not used", async () => {
80
+ const env = await tempConfigEnv();
81
+ seedHubLogin(env);
82
+ saveConfig(env, {
83
+ activeProfile: 'work',
84
+ profiles: { work: { apiKey: key('own'), baseUrl: 'http://inst' } }
85
+ });
86
+ const h = routedHarness([meRoute()], env);
87
+ expect(await run(['whoami'], h.io)).toBe(0);
88
+ expect(lines(h.out(), ' Antasphere login:')).toEqual([
89
+ ' Antasphere login: ada@x.co (not used: the key of profile "work" wins)'
90
+ ]);
91
+ });
92
+
93
+ it('the cached key of the Antasphere login', async () => {
94
+ const env = await tempConfigEnv();
95
+ seedHubLogin(env);
96
+ saveConfig(env, {
97
+ activeProfile: 'work',
98
+ profiles: { work: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: key('cached') } } } }
99
+ });
100
+ const h = routedHarness([meRoute()], env);
101
+ expect(await run(['whoami'], h.io)).toBe(0);
102
+ expect(h.wire[0]?.auth).toBe(`Bearer ${key('cached')}`);
103
+ expect(lines(h.out(), ' signed in with:')).toEqual([
104
+ ' signed in with: the Antasphere login (cached key)'
105
+ ]);
106
+ expect(lines(h.out(), ' Antasphere login:')).toEqual([' Antasphere login: ada@x.co']);
107
+ });
108
+
109
+ it('a fresh exchange', async () => {
110
+ const env = await tempConfigEnv();
111
+ seedHubLogin(env);
112
+ const h = routedHarness([cloudInstanceRoute, ...exchangeRoutes([key('fresh')]), meRoute()], env);
113
+ expect(await run(['whoami', '--api-url', 'http://tool'], h.io)).toBe(0);
114
+ expect(lines(h.out(), ' signed in with:')).toEqual([
115
+ ' signed in with: the Antasphere login (fresh exchange)'
116
+ ]);
117
+ expect(lines(h.out(), ' Antasphere login:')).toEqual([' Antasphere login: ada@x.co']);
118
+ });
119
+
120
+ it('on the cloud with no Antasphere login: not connected', async () => {
121
+ const env = await tempConfigEnv();
122
+ const h = routedHarness([meRoute()], env);
123
+ expect(await run(['whoami', '--api-key', key('flag')], h.io)).toBe(0);
124
+ expect(h.wire[0]?.origin).toBe(CLOUD);
125
+ expect(lines(h.out(), ' instance:')).toEqual([` instance: ${CLOUD} (profile "cloud")`]);
126
+ expect(lines(h.out(), ' Antasphere login:')).toEqual([
127
+ ` Antasphere login: not connected (run \`${bin} login\`)`
128
+ ]);
129
+ });
130
+
131
+ it('--json adds the sources, the profile and the hub login', async () => {
132
+ const env = await tempConfigEnv();
133
+ seedHubLogin(env);
134
+ saveConfig(env, {
135
+ activeProfile: 'work',
136
+ profiles: { work: { baseUrl: 'http://tool', connectKeys: { default: { apiKey: key('cached') } } } }
137
+ });
138
+ const h = routedHarness([meRoute()], env);
139
+ expect(await run(['whoami', '--json', '--workspace', 'w1'], h.io)).toBe(0);
140
+ expect(JSON.parse(h.out())).toMatchObject({
141
+ user: { id: 'u1', name: 'Ada', email: 'ada@x.co' },
142
+ workspaceSource: 'flag',
143
+ credentialSource: 'hub-cache',
144
+ profile: 'work',
145
+ hubLogin: { profile: 'default', email: 'ada@x.co' }
146
+ });
147
+
148
+ const bare = routedHarness([meRoute()], await tempConfigEnv());
149
+ expect(
150
+ await run(['whoami', '--json', '--api-url', 'http://inst', '--api-key', key('flag')], bare.io)
151
+ ).toBe(0);
152
+ expect(JSON.parse(bare.out())).toMatchObject({
153
+ workspaceSource: 'default',
154
+ credentialSource: 'flag',
155
+ profile: 'inst',
156
+ hubLogin: null
157
+ });
158
+ });
159
+ });
@@ -0,0 +1,402 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ findWorkspace,
4
+ isWorkspaceId,
5
+ matchWorkspace,
6
+ type MeResponse,
7
+ type MeWorkspace
8
+ } from '@antasphere/chassis-cli';
9
+ import { routedHarness, tempConfigEnv } from '@antasphere/chassis-cli/testing';
10
+ import { cli, run } from '@chassis-cli-test/host';
11
+ import { filesRoute, key, meBody, meRoute, wsRow } from './signin-fixtures.js';
12
+
13
+ // The kit's workspace functions, and the literals that spell the tool (its identity).
14
+ const { describeSelection, describeSource, explainRefusal, pickWorkspaceSelection } = cli.workspace;
15
+ const { bin } = cli.identity;
16
+ const WS_ENV = `${cli.identity.envPrefix}_WORKSPACE`;
17
+ const ORG_ENV = `${cli.identity.envPrefix}_ORG`;
18
+
19
+ /**
20
+ * PRDCT-2419, the pure half: which value selects the workspace (the
21
+ * resolution order), and which membership a value names (the matching).
22
+ * The wire half is workspaces-cli.test.ts.
23
+ */
24
+
25
+ const A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
26
+ const B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
27
+ const C = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
28
+ const URL = 'https://slides.example.com';
29
+
30
+ function ws(id: string, name: string, over: Partial<MeWorkspace> = {}): MeWorkspace {
31
+ return {
32
+ id,
33
+ name,
34
+ role: 'member',
35
+ hubOrigin: false,
36
+ look: { theme: null, pattern: null, field: null, grain: null },
37
+ suspended: false,
38
+ default: false,
39
+ centralAccountId: null,
40
+ ...over
41
+ };
42
+ }
43
+
44
+ const pick = (over: Partial<Parameters<typeof pickWorkspaceSelection>[0]>) =>
45
+ pickWorkspaceSelection({ workspaceFlag: undefined, orgFlag: undefined, env: {}, ...over });
46
+
47
+ describe('the selection: --org or --workspace, then ORG or WORKSPACE, then nothing', () => {
48
+ it('a flag wins over a variable of either kind', () => {
49
+ expect(pick({ workspaceFlag: 'Acme', env: { [WS_ENV]: B } })).toEqual({
50
+ value: 'Acme',
51
+ source: 'flag',
52
+ kind: 'workspace'
53
+ });
54
+ expect(pick({ workspaceFlag: 'Acme', env: { [ORG_ENV]: B } })).toEqual({
55
+ value: 'Acme',
56
+ source: 'flag',
57
+ kind: 'workspace'
58
+ });
59
+ expect(pick({ orgFlag: 'Acme', env: { [WS_ENV]: B } })).toEqual({
60
+ value: 'Acme',
61
+ source: 'flag',
62
+ kind: 'org'
63
+ });
64
+ });
65
+
66
+ it('a flag wins even over two variables at once', () => {
67
+ expect(pick({ orgFlag: 'Acme', env: { [WS_ENV]: B, [ORG_ENV]: C } })).toEqual({
68
+ value: 'Acme',
69
+ source: 'flag',
70
+ kind: 'org'
71
+ });
72
+ });
73
+
74
+ it('each variable selects its own kind', () => {
75
+ expect(pick({ env: { [WS_ENV]: B } })).toEqual({ value: B, source: 'env', kind: 'workspace' });
76
+ expect(pick({ env: { [ORG_ENV]: B } })).toEqual({ value: B, source: 'env', kind: 'org' });
77
+ });
78
+
79
+ it('nothing selected is undefined: no header, the server picks', () => {
80
+ expect(pick({})).toBeUndefined();
81
+ });
82
+
83
+ it('values are trimmed', () => {
84
+ expect(pick({ workspaceFlag: ' Acme ' })?.value).toBe('Acme');
85
+ expect(pick({ orgFlag: ' Acme ' })?.value).toBe('Acme');
86
+ expect(pick({ env: { [WS_ENV]: ` ${B}\n` } })?.value).toBe(B);
87
+ expect(pick({ env: { [ORG_ENV]: ` ${B}\n` } })?.value).toBe(B);
88
+ });
89
+
90
+ it('both flags at once is a usage error', () => {
91
+ expect(() => pick({ orgFlag: 'Acme', workspaceFlag: 'Beta' })).toThrow(
92
+ 'Pass --org or --workspace, not both.'
93
+ );
94
+ });
95
+
96
+ it('both variables at once is a usage error', () => {
97
+ expect(() => pick({ env: { [ORG_ENV]: A, [WS_ENV]: B } })).toThrow(
98
+ `Set ${ORG_ENV} or ${WS_ENV}, not both.`
99
+ );
100
+ });
101
+
102
+ it('an empty flag is a usage error, never a silent fall-through', () => {
103
+ expect(() => pick({ workspaceFlag: ' ', env: { [WS_ENV]: B } })).toThrow(/--workspace needs/);
104
+ expect(() => pick({ orgFlag: ' ', env: { [ORG_ENV]: B } })).toThrow(
105
+ '--org needs an organization id or name.'
106
+ );
107
+ });
108
+
109
+ it('an empty environment variable is an unset one and falls through', () => {
110
+ expect(pick({ env: { [WS_ENV]: '' } })).toBeUndefined();
111
+ expect(pick({ env: { [WS_ENV]: ' ' } })).toBeUndefined();
112
+ expect(pick({ env: { [ORG_ENV]: ' ', [WS_ENV]: B } })).toEqual({
113
+ value: B,
114
+ source: 'env',
115
+ kind: 'workspace'
116
+ });
117
+ expect(pick({ env: { [ORG_ENV]: B, [WS_ENV]: '' } })).toEqual({ value: B, source: 'env', kind: 'org' });
118
+ });
119
+ });
120
+
121
+ describe('matching a value against the memberships', () => {
122
+ const list = [ws(A, 'Acme'), ws(B, 'Atelier Nord'), ws(C, 'atelier sud')];
123
+
124
+ it('an exact id matches', () => {
125
+ expect(matchWorkspace(list, B).name).toBe('Atelier Nord');
126
+ });
127
+
128
+ it('a name matches without regard to case, and around spaces', () => {
129
+ expect(matchWorkspace(list, 'acme').id).toBe(A);
130
+ expect(matchWorkspace(list, ' ATELIER NORD ').id).toBe(B);
131
+ });
132
+
133
+ it('a name is matched whole, never as a prefix', () => {
134
+ expect(() => matchWorkspace(list, 'Atelier')).toThrow(/not one of your workspaces/);
135
+ });
136
+
137
+ it('the id wins over a workspace that carries that id as its name', () => {
138
+ const tricky = [ws(A, B), ws(B, 'Real')];
139
+ expect(matchWorkspace(tricky, B).name).toBe('Real');
140
+ });
141
+
142
+ it('an ambiguous name errors and lists only the candidates, by id', () => {
143
+ const twins = [ws(A, 'Acme'), ws(B, 'ACME'), ws(C, 'Other')];
144
+ let message = '';
145
+ try {
146
+ matchWorkspace(twins, 'acme');
147
+ } catch (e) {
148
+ message = (e as Error).message;
149
+ }
150
+ expect(message).toContain('Several of your workspaces are named "acme"');
151
+ expect(message).toContain(A);
152
+ expect(message).toContain(B);
153
+ expect(message).not.toContain(C);
154
+ });
155
+
156
+ it('an unknown value errors and lists every workspace', () => {
157
+ let message = '';
158
+ try {
159
+ matchWorkspace(list, 'Nowhere');
160
+ } catch (e) {
161
+ message = (e as Error).message;
162
+ }
163
+ expect(message).toContain('"Nowhere" is not one of your workspaces');
164
+ for (const w of list) expect(message).toContain(`${w.id} member ${w.name}`);
165
+ });
166
+
167
+ it('an unknown value with no membership at all still reads', () => {
168
+ expect(() => matchWorkspace([], 'x')).toThrow(/\(none\)/);
169
+ });
170
+
171
+ it('findWorkspace reports the ambiguity without throwing', () => {
172
+ expect(findWorkspace([ws(A, 'x'), ws(B, 'X')], 'x')).toMatchObject({ match: null });
173
+ expect(findWorkspace([ws(A, 'x'), ws(B, 'X')], 'x').ambiguous).toHaveLength(2);
174
+ expect(findWorkspace(list, 'nope')).toEqual({ match: null, ambiguous: [] });
175
+ });
176
+
177
+ it('a hub organization id matches the row that projects it, without regard to case', () => {
178
+ const O = '99999999-9999-4999-8999-999999999999';
179
+ const withOrg = [ws(A, 'Acme', { centralAccountId: O }), ws(B, 'Beta')];
180
+ expect(findWorkspace(withOrg, O).match?.id).toBe(A);
181
+ expect(findWorkspace(withOrg, O.toUpperCase()).match?.id).toBe(A);
182
+ });
183
+
184
+ it('the workspace id wins over a row carrying that id as its organization id', () => {
185
+ const tricky = [ws(A, 'First', { centralAccountId: B }), ws(B, 'Second')];
186
+ expect(findWorkspace(tricky, B).match?.name).toBe('Second');
187
+ });
188
+
189
+ it('the organization id wins over a row carrying it as its name', () => {
190
+ const O = '99999999-9999-4999-8999-999999999999';
191
+ const tricky = [ws(A, O), ws(B, 'Real', { centralAccountId: O })];
192
+ expect(findWorkspace(tricky, O).match?.name).toBe('Real');
193
+ });
194
+
195
+ it('a row from an older server, with no organization id, still matches by id and name', () => {
196
+ const older = { ...ws(A, 'Acme') } as Partial<MeWorkspace>;
197
+ delete older.centralAccountId;
198
+ expect(findWorkspace([older as MeWorkspace], 'acme').match?.id).toBe(A);
199
+ expect(findWorkspace([older as MeWorkspace], A).match?.id).toBe(A);
200
+ });
201
+
202
+ it('the candidate lines name the organization of a row that has one', () => {
203
+ const O = '99999999-9999-4999-8999-999999999999';
204
+ let message = '';
205
+ try {
206
+ matchWorkspace([ws(A, 'Acme', { centralAccountId: O }), ws(B, 'Beta')], 'Nowhere');
207
+ } catch (e) {
208
+ message = (e as Error).message;
209
+ }
210
+ expect(message).toContain(`${A} member Acme (organization ${O})`);
211
+ expect(message).toContain(`${B} member Beta\n`.trimEnd());
212
+ expect(message).not.toContain(`Beta (organization`);
213
+ });
214
+
215
+ it('only the server selector shape is an id', () => {
216
+ expect(isWorkspaceId(A)).toBe(true);
217
+ expect(isWorkspaceId(A.toUpperCase())).toBe(true);
218
+ expect(isWorkspaceId('Acme')).toBe(false);
219
+ expect(isWorkspaceId(`${A} `)).toBe(false);
220
+ expect(isWorkspaceId(A.replace(/-/g, ''))).toBe(false);
221
+ });
222
+ });
223
+
224
+ describe('the words for a source', () => {
225
+ it('names each flag, each variable and the default', () => {
226
+ expect(describeSelection({ source: 'flag', kind: 'org' })).toBe('the --org flag');
227
+ expect(describeSelection({ source: 'flag', kind: 'workspace' })).toBe('the --workspace flag');
228
+ expect(describeSelection({ source: 'env', kind: 'org' })).toBe(ORG_ENV);
229
+ expect(describeSelection({ source: 'env', kind: 'workspace' })).toBe(WS_ENV);
230
+ expect(describeSource('default', undefined)).toBe("the server's default (nothing selected)");
231
+ expect(describeSource('default', 'org')).toBe("the server's default (nothing selected)");
232
+ expect(describeSource('flag', undefined)).toBe("the server's default (nothing selected)");
233
+ expect(describeSource('flag', 'org')).toBe('the --org flag');
234
+ expect(describeSource('env', 'workspace')).toBe(WS_ENV);
235
+ });
236
+ });
237
+
238
+ describe('explaining a refusal the selection caused', () => {
239
+ const me = {
240
+ user: { id: 'u1', email: 'ada@x.co', name: 'Ada' },
241
+ workspace: { id: A, name: 'Acme', hubOrigin: false },
242
+ workspaces: [ws(A, 'Acme', { default: true }), ws(B, 'Atelier Nord')],
243
+ activeWorkspaceId: A
244
+ } as unknown as MeResponse;
245
+
246
+ it('403 workspace_mismatch names the pin and the selected workspace', () => {
247
+ const text = explainRefusal({
248
+ code: 'workspace_mismatch',
249
+ status: 403,
250
+ selection: { value: 'atelier nord', source: 'flag', kind: 'workspace' },
251
+ workspaceId: B,
252
+ me,
253
+ baseUrl: URL
254
+ });
255
+ expect(text).toContain(`pinned to the workspace "Acme" (${A})`);
256
+ expect(text).toContain(`the --workspace flag selects "Atelier Nord" (${B})`);
257
+ });
258
+
259
+ it('401 on a workspace that is not yours says the key works and lists yours', () => {
260
+ const text = explainRefusal({
261
+ code: 'invalid_api_key',
262
+ status: 401,
263
+ selection: { value: C, source: 'env', kind: 'workspace' },
264
+ workspaceId: C,
265
+ me,
266
+ baseUrl: URL
267
+ });
268
+ expect(text).toBe(
269
+ `The workspace "${C}" (selected by ${WS_ENV}) is not one of yours on ${URL}; the API key itself works. ` +
270
+ `Yours:\n ${A} member Acme\n ${B} member Atelier Nord`
271
+ );
272
+ expect(text).not.toContain('--clear');
273
+ expect(text).not.toContain(`${bin} workspace use`);
274
+ });
275
+
276
+ it('401 on an organization that is not yours says organization, and names the --org flag', () => {
277
+ const text = explainRefusal({
278
+ code: 'invalid_api_key',
279
+ status: 401,
280
+ selection: { value: 'Nowhere', source: 'flag', kind: 'org' },
281
+ workspaceId: C,
282
+ me,
283
+ baseUrl: URL
284
+ });
285
+ expect(text).toBe(
286
+ `The organization "Nowhere" (selected by the --org flag) is not one of yours on ${URL}; the API key itself works. ` +
287
+ `Yours:\n ${A} member Acme\n ${B} member Atelier Nord`
288
+ );
289
+ });
290
+
291
+ it('403 workspace_mismatch finds the selected row by its organization id too', () => {
292
+ const O = '99999999-9999-4999-8999-999999999999';
293
+ const withOrg = {
294
+ ...me,
295
+ workspaces: [ws(A, 'Acme', { default: true }), ws(B, 'Atelier Nord', { centralAccountId: O })]
296
+ } as unknown as MeResponse;
297
+ const text = explainRefusal({
298
+ code: 'workspace_mismatch',
299
+ status: 403,
300
+ selection: { value: O, source: 'flag', kind: 'org' },
301
+ workspaceId: O,
302
+ me: withOrg,
303
+ baseUrl: URL
304
+ });
305
+ expect(text).toContain(`pinned to the workspace "Acme" (${A})`);
306
+ expect(text).toContain(`the --org flag selects "Atelier Nord" (${B})`);
307
+ });
308
+
309
+ it('401 on an organization id that IS yours (by its hub id) is not explained away', () => {
310
+ const O = '99999999-9999-4999-8999-999999999999';
311
+ const withOrg = {
312
+ ...me,
313
+ workspaces: [ws(A, 'Acme'), ws(B, 'Atelier Nord', { centralAccountId: O })]
314
+ } as unknown as MeResponse;
315
+ expect(
316
+ explainRefusal({
317
+ code: 'invalid_api_key',
318
+ status: 401,
319
+ selection: { value: O, source: 'flag', kind: 'org' },
320
+ workspaceId: O,
321
+ me: withOrg,
322
+ baseUrl: URL
323
+ })
324
+ ).toBeNull();
325
+ });
326
+
327
+ it('401 on a workspace that IS yours is not explained away: the plain error stands', () => {
328
+ expect(
329
+ explainRefusal({
330
+ code: 'invalid_api_key',
331
+ status: 401,
332
+ selection: { value: B, source: 'flag', kind: 'workspace' },
333
+ workspaceId: B,
334
+ me,
335
+ baseUrl: URL
336
+ })
337
+ ).toBeNull();
338
+ });
339
+
340
+ it('G8: workspace_mismatch is the pin only at 403; any other status keeps the plain error', () => {
341
+ expect(
342
+ explainRefusal({
343
+ code: 'workspace_mismatch',
344
+ status: 500,
345
+ selection: { value: B, source: 'flag', kind: 'workspace' },
346
+ workspaceId: B,
347
+ me,
348
+ baseUrl: URL
349
+ })
350
+ ).toBeNull();
351
+ });
352
+
353
+ it('any other refusal is not the selection to explain', () => {
354
+ expect(
355
+ explainRefusal({
356
+ code: 'forbidden',
357
+ status: 403,
358
+ selection: { value: C, source: 'flag', kind: 'workspace' },
359
+ workspaceId: C,
360
+ me,
361
+ baseUrl: URL
362
+ })
363
+ ).toBeNull();
364
+ });
365
+ });
366
+
367
+ describe('the selection on the wire: a name is looked up, an id is sent as it is (verifier round 1, N3)', () => {
368
+ const ORG_OF_B = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
369
+ const ROWS = [
370
+ wsRow(A, 'Acme', { default: true }),
371
+ wsRow(B, 'Beta', { hubOrigin: true, centralAccountId: ORG_OF_B })
372
+ ];
373
+
374
+ async function signedIn(): Promise<Record<string, string>> {
375
+ const env = await tempConfigEnv();
376
+ cli.saveConfig(env, {
377
+ activeProfile: 'inst',
378
+ profiles: { inst: { apiKey: key('work'), baseUrl: 'http://inst' } }
379
+ });
380
+ return env;
381
+ }
382
+
383
+ it.each([
384
+ ['--workspace <name>', ['--workspace', 'beta']],
385
+ ['--org <name>', ['--org', 'Beta']]
386
+ ] as const)('%s: one bare /me, then the LOCAL id as x-workspace-id', async (_label, args) => {
387
+ const h = routedHarness([meRoute(meBody({ workspaces: ROWS })), filesRoute], await signedIn());
388
+ expect(await run(['files', 'list', ...args], h.io)).toBe(0);
389
+ expect(h.wire.map((c) => `${c.method} ${c.path} ${c.workspace ?? '-'}`)).toEqual([
390
+ 'GET /api/v1/me -',
391
+ `GET /api/v1/files ${B}`
392
+ ]);
393
+ });
394
+
395
+ it('--org <hub organization id>: sent as it is, no lookup (the server maps it)', async () => {
396
+ const h = routedHarness([meRoute(meBody({ workspaces: ROWS })), filesRoute], await signedIn());
397
+ expect(await run(['files', 'list', '--org', ORG_OF_B], h.io)).toBe(0);
398
+ expect(h.wire.map((c) => `${c.method} ${c.path} ${c.workspace ?? '-'}`)).toEqual([
399
+ `GET /api/v1/files ${ORG_OF_B}`
400
+ ]);
401
+ });
402
+ });
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.