@antasphere/chassis-cli 0.0.0-stage → 1.0.1

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,301 @@
1
+ import { mkdtemp, readFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { describe, expect, it } from 'vitest';
5
+ import type { CliIo } from '@antasphere/chassis-cli';
6
+ import { cli, run } from '@chassis-cli-test/host';
7
+
8
+ // The literals that spell the tool come from the host (its identity).
9
+ const { bin, envPrefix: P } = cli.identity;
10
+
11
+ /**
12
+ * Unit coverage: each command hits the right method + path (via a recording
13
+ * fake fetch), the no-API-key guard fires, and a 403 maps to a friendly line.
14
+ * The upload/download filesystem paths are exercised by the server-side
15
+ * integration test.
16
+ */
17
+
18
+ interface Canned {
19
+ status?: number;
20
+ body?: unknown;
21
+ }
22
+
23
+ function harness(responses: Canned[] = [{ status: 200, body: {} }]) {
24
+ const calls: Array<{ method: string; path: string }> = [];
25
+ const out: string[] = [];
26
+ const err: string[] = [];
27
+ const queue = [...responses];
28
+ const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
29
+ const url = new URL(input.toString());
30
+ // pathname + search so cursor/limit threading is assertable.
31
+ calls.push({ method: (init?.method ?? 'GET').toUpperCase(), path: url.pathname + url.search });
32
+ const r = queue.shift() ?? { status: 200, body: {} };
33
+ return new Response(JSON.stringify(r.body ?? {}), {
34
+ status: r.status ?? 200,
35
+ headers: { 'content-type': 'application/json' }
36
+ });
37
+ }) as typeof globalThis.fetch;
38
+ const io: CliIo = {
39
+ env: {},
40
+ out: { write: (s) => out.push(s) },
41
+ err: { write: (s) => err.push(s) },
42
+ fetch
43
+ };
44
+ return { io, calls, out: () => out.join(''), err: () => err.join('') };
45
+ }
46
+
47
+ const INSTANCE = {
48
+ name: 'Acme',
49
+ version: '1.0.0',
50
+ edition: 'oss',
51
+ setupRequired: false,
52
+ auth: { methods: ['password', 'api-key', 'oauth'], passwordReset: false }
53
+ };
54
+ const ME = {
55
+ user: { id: 'u1', name: 'Ada', email: 'ada@x.co' },
56
+ workspace: { id: 'w1', name: 'Acme' },
57
+ workspaces: [
58
+ {
59
+ id: 'w1',
60
+ name: 'Acme',
61
+ role: 'owner',
62
+ hubOrigin: false,
63
+ centralAccountId: null,
64
+ look: { theme: null, pattern: null, field: null, grain: null },
65
+ suspended: false,
66
+ default: true
67
+ }
68
+ ],
69
+ activeWorkspaceId: 'w1',
70
+ role: 'owner',
71
+ via: 'api_key',
72
+ scopes: ['items:read']
73
+ };
74
+
75
+ describe(`${bin} CLI`, () => {
76
+ it('instance → GET /api/v1/instance (no key needed)', async () => {
77
+ const h = harness([{ body: INSTANCE }]);
78
+ const code = await run(['instance', '--url', 'http://x'], h.io);
79
+ expect(code).toBe(0);
80
+ expect(h.calls).toEqual([{ method: 'GET', path: '/api/v1/instance' }]);
81
+ expect(h.out()).toContain('Acme');
82
+ });
83
+
84
+ it('whoami → GET /api/v1/me with the key', async () => {
85
+ const h = harness([{ body: ME }]);
86
+ const code = await run(['whoami', '--url', 'http://x', '--api-key', 'key_a_b'], h.io);
87
+ expect(code).toBe(0);
88
+ expect(h.calls).toEqual([{ method: 'GET', path: '/api/v1/me' }]);
89
+ expect(h.out()).toContain('ada@x.co');
90
+ });
91
+
92
+ it(`reads ${P}_URL / ${P}_API_KEY from the environment`, async () => {
93
+ const h = harness([{ body: ME }]);
94
+ h.io.env[`${P}_URL`] = 'http://from-env';
95
+ h.io.env[`${P}_API_KEY`] = 'key_env';
96
+ const code = await run(['whoami'], h.io);
97
+ expect(code).toBe(0);
98
+ expect(h.calls[0]).toEqual({ method: 'GET', path: '/api/v1/me' });
99
+ });
100
+
101
+ it('files list → GET /api/v1/files', async () => {
102
+ const h = harness([{ body: { files: [], nextCursor: null } }]);
103
+ const code = await run(['files', 'list', '--url', 'http://x', '--api-key', 'k'], h.io);
104
+ expect(code).toBe(0);
105
+ expect(h.calls).toEqual([{ method: 'GET', path: '/api/v1/files' }]);
106
+ expect(h.out()).toContain('No files.');
107
+ });
108
+
109
+ it('files list threads --cursor and --limit onto the query string', async () => {
110
+ const h = harness([{ body: { files: [], nextCursor: null } }]);
111
+ const code = await run(
112
+ ['files', 'list', '--limit', '2', '--cursor', 'abc', '--url', 'http://x', '--api-key', 'k'],
113
+ h.io
114
+ );
115
+ expect(code).toBe(0);
116
+ expect(h.calls).toEqual([{ method: 'GET', path: '/api/v1/files?cursor=abc&limit=2' }]);
117
+ });
118
+
119
+ it('files list --all follows nextCursor and outputs every page', async () => {
120
+ const fileA = { id: 'f-a', sizeBytes: 10, originalName: 'a.txt' };
121
+ const fileB = { id: 'f-b', sizeBytes: 20, originalName: 'b.txt' };
122
+ const h = harness([
123
+ { body: { files: [fileA], nextCursor: 'f-a' } },
124
+ { body: { files: [fileB], nextCursor: null } }
125
+ ]);
126
+ const code = await run(['files', 'list', '--all', '--url', 'http://x', '--api-key', 'k'], h.io);
127
+ expect(code).toBe(0);
128
+ expect(h.calls).toEqual([
129
+ { method: 'GET', path: '/api/v1/files' },
130
+ { method: 'GET', path: '/api/v1/files?cursor=f-a' }
131
+ ]);
132
+ expect(h.out()).toContain('a.txt');
133
+ expect(h.out()).toContain('b.txt');
134
+ });
135
+
136
+ it('files list --json prints the wire shape { files, nextCursor }', async () => {
137
+ const fileA = { id: 'f-a', sizeBytes: 10, originalName: 'a.txt' };
138
+ const h = harness([{ body: { files: [fileA], nextCursor: 'f-a' } }]);
139
+ const code = await run(['files', 'list', '--json', '--url', 'http://x', '--api-key', 'k'], h.io);
140
+ expect(code).toBe(0);
141
+ expect(JSON.parse(h.out())).toEqual({ files: [fileA], nextCursor: 'f-a' });
142
+ });
143
+
144
+ it('files rm → DELETE /api/v1/files/:id', async () => {
145
+ const h = harness([{ body: {} }]);
146
+ const code = await run(['files', 'rm', 'abc', '--url', 'http://x', '--api-key', 'k'], h.io);
147
+ expect(code).toBe(0);
148
+ expect(h.calls).toEqual([{ method: 'DELETE', path: '/api/v1/files/abc' }]);
149
+ });
150
+
151
+ it('export → GET /api/v1/workspace/export streamed to disk', async () => {
152
+ const h = harness([{ body: { fake: 'zip-bytes' } }]);
153
+ const out = join(await mkdtemp(join(tmpdir(), 'cli-export-')), 'bundle.zip');
154
+ const code = await run(['export', '-o', out, '--url', 'http://x', '--api-key', 'k'], h.io);
155
+ expect(code).toBe(0);
156
+ expect(h.calls).toEqual([{ method: 'GET', path: '/api/v1/workspace/export' }]);
157
+ expect(await readFile(out, 'utf8')).toBe(JSON.stringify({ fake: 'zip-bytes' }));
158
+ expect(h.out()).toContain(out);
159
+ });
160
+
161
+ it('requires an API key for authenticated commands (exit 1, one discovery probe only)', async () => {
162
+ // With no key at all, the runner asks discovery whether this is a cloud
163
+ // instance (connect-on-demand seam); a non-cloud answer keeps the
164
+ // classic error and no other request is made.
165
+ const h = harness([{ body: INSTANCE }]);
166
+ const code = await run(['whoami', '--url', 'http://x'], h.io);
167
+ expect(code).toBe(1);
168
+ expect(h.calls).toEqual([{ method: 'GET', path: '/api/v1/instance' }]);
169
+ expect(h.err()).toContain('API key is required');
170
+ });
171
+
172
+ it('maps a 403 to a friendly stderr line and exit 1', async () => {
173
+ const h = harness([{ status: 403, body: { error: { code: 'forbidden', message: 'Not allowed here' } } }]);
174
+ const code = await run(['files', 'list', '--url', 'http://x', '--api-key', 'k'], h.io);
175
+ expect(code).toBe(1);
176
+ expect(h.err()).toContain('not allowed to do that');
177
+ });
178
+
179
+ it('a 403 plan_required prints the message and the upgrade link, not the key hint', async () => {
180
+ const h = harness([
181
+ {
182
+ status: 403,
183
+ body: {
184
+ error: {
185
+ code: 'plan_required',
186
+ message: 'This needs the pro plan',
187
+ details: {
188
+ key: 'files.maxBytes',
189
+ plan: 'free',
190
+ requiredPlan: 'pro',
191
+ upgradeUrl: 'https://account.antasphere.com/orgs/acme'
192
+ }
193
+ }
194
+ }
195
+ }
196
+ ]);
197
+ const code = await run(['files', 'list', '--url', 'http://x', '--api-key', 'k'], h.io);
198
+ expect(code).toBe(1);
199
+ expect(h.err()).toBe(
200
+ 'Error: This needs the pro plan — upgrade the plan at https://account.antasphere.com/orgs/acme\n'
201
+ );
202
+ });
203
+
204
+ it('a 402 entitlement_denied prints the price, the balance and the top-up link (PRDCT-2664)', async () => {
205
+ const h = harness([
206
+ {
207
+ status: 402,
208
+ body: {
209
+ error: {
210
+ code: 'entitlement_denied',
211
+ message: 'Not enough credits',
212
+ details: {
213
+ credits: 1500,
214
+ balance: 20,
215
+ topUpUrl: 'https://account.antasphere.com/orgs/acme/billing'
216
+ }
217
+ }
218
+ }
219
+ }
220
+ ]);
221
+ const code = await run(['files', 'list', '--url', 'http://x', '--api-key', 'k'], h.io);
222
+ expect(code).toBe(1);
223
+ expect(h.err()).toBe(
224
+ 'Error: Not enough credits — this needs 1500 credits and the organization holds 20; ' +
225
+ 'top up at https://account.antasphere.com/orgs/acme/billing\n'
226
+ );
227
+ });
228
+
229
+ it('a 402 whose details carry the link alone prints no figure: never "undefined credits"', async () => {
230
+ const h = harness([
231
+ {
232
+ status: 402,
233
+ body: {
234
+ error: {
235
+ code: 'entitlement_denied',
236
+ message: 'Not enough credits',
237
+ details: { topUpUrl: 'https://account.antasphere.com/orgs/acme/billing' }
238
+ }
239
+ }
240
+ }
241
+ ]);
242
+ const code = await run(['files', 'list', '--url', 'http://x', '--api-key', 'k'], h.io);
243
+ expect(code).toBe(1);
244
+ expect(h.err()).toBe(
245
+ 'Error: Not enough credits — top up at https://account.antasphere.com/orgs/acme/billing\n'
246
+ );
247
+ });
248
+
249
+ it('a 402 with the price alone prints the price and the link', async () => {
250
+ const h = harness([
251
+ {
252
+ status: 402,
253
+ body: {
254
+ error: {
255
+ code: 'entitlement_denied',
256
+ message: 'Not enough credits',
257
+ details: { credits: 1500, topUpUrl: 'https://account.antasphere.com/orgs/acme/billing' }
258
+ }
259
+ }
260
+ }
261
+ ]);
262
+ const code = await run(['files', 'list', '--url', 'http://x', '--api-key', 'k'], h.io);
263
+ expect(code).toBe(1);
264
+ expect(h.err()).toBe(
265
+ 'Error: Not enough credits — this needs 1500 credits; top up at https://account.antasphere.com/orgs/acme/billing\n'
266
+ );
267
+ });
268
+
269
+ it('the gate’s own 402 message already carries the link: printed once, never twice (verifier round 1)', async () => {
270
+ const url = 'https://account.antasphere.com/orgs/acme/billing';
271
+ const message = `This needs 1500 credits and the organization holds 20; top up at ${url}`;
272
+ const h = harness([
273
+ {
274
+ status: 402,
275
+ body: {
276
+ error: {
277
+ code: 'entitlement_denied',
278
+ message,
279
+ details: { credits: 1500, balance: 20, topUpUrl: url }
280
+ }
281
+ }
282
+ }
283
+ ]);
284
+ const code = await run(['files', 'list', '--url', 'http://x', '--api-key', 'k'], h.io);
285
+ expect(code).toBe(1);
286
+ expect(h.err()).toBe(`Error: ${message}\n`);
287
+ expect(h.err().split(url).length - 1).toBe(1);
288
+ });
289
+
290
+ it('a 413 entitlement_denied without details (the self-hosted cap) prints the message alone', async () => {
291
+ const h = harness([
292
+ {
293
+ status: 413,
294
+ body: { error: { code: 'entitlement_denied', message: 'File exceeds the 100 MB limit' } }
295
+ }
296
+ ]);
297
+ const code = await run(['files', 'list', '--url', 'http://x', '--api-key', 'k'], h.io);
298
+ expect(code).toBe(1);
299
+ expect(h.err()).toBe('Error: File exceeds the 100 MB limit\n');
300
+ });
301
+ });
@@ -0,0 +1,59 @@
1
+ import { statSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { redactKey } from '@antasphere/chassis-cli';
4
+ import { tempConfigEnv } from '@antasphere/chassis-cli/testing';
5
+ import { cli } from '@chassis-cli-test/host';
6
+
7
+ // The kit's config store, and the literals that spell the tool (its identity).
8
+ const { clearConfig, configPath, loadConfig, saveConfig } = cli;
9
+ const { tool, keyPrefix: K } = cli.identity;
10
+
11
+ describe('CLI config store', () => {
12
+ it(`round-trips profiles through $XDG_CONFIG_HOME/antasphere/tools/${tool}.json`, async () => {
13
+ const env = await tempConfigEnv();
14
+ expect(loadConfig(env)).toEqual({ profiles: {} });
15
+ saveConfig(env, {
16
+ activeProfile: 'dev',
17
+ profiles: { dev: { apiKey: `${K}_a_b`, baseUrl: 'http://localhost:3100' } }
18
+ });
19
+ const loaded = loadConfig(env);
20
+ expect(loaded.activeProfile).toBe('dev');
21
+ expect(loaded.profiles.dev).toEqual({ apiKey: `${K}_a_b`, baseUrl: 'http://localhost:3100' });
22
+ });
23
+
24
+ it('creates the dir 0700 and the file 0600', async () => {
25
+ const env = await tempConfigEnv();
26
+ const path = saveConfig(env, { profiles: { p: { apiKey: `${K}_x_y` } } });
27
+ expect((statSync(path).mode & 0o777).toString(8)).toBe('600');
28
+ const dir = path.slice(0, path.lastIndexOf('/'));
29
+ expect((statSync(dir).mode & 0o777).toString(8)).toBe('700');
30
+ });
31
+
32
+ it('falls back to HOME/.config when XDG_CONFIG_HOME is unset', () => {
33
+ // The tool's namespace of the SHARED antasphere config home
34
+ // (@antasphere/cli-core) — moved there from ~/.config/<legacyConfigDir>/.
35
+ expect(configPath({ HOME: '/home/u' })).toBe(`/home/u/.config/antasphere/tools/${tool}.json`);
36
+ expect(configPath({ XDG_CONFIG_HOME: '/xdg' })).toBe(`/xdg/antasphere/tools/${tool}.json`);
37
+ expect(configPath({})).toBeNull();
38
+ });
39
+
40
+ it('treats a corrupt config file as empty instead of crashing', async () => {
41
+ const env = await tempConfigEnv();
42
+ const path = saveConfig(env, { profiles: {} });
43
+ const { writeFileSync } = await import('node:fs');
44
+ writeFileSync(path, 'not json');
45
+ expect(loadConfig(env)).toEqual({ profiles: {} });
46
+ });
47
+
48
+ it('clear removes the file', async () => {
49
+ const env = await tempConfigEnv();
50
+ saveConfig(env, { profiles: { p: {} } });
51
+ clearConfig(env);
52
+ expect(loadConfig(env)).toEqual({ profiles: {} });
53
+ });
54
+
55
+ it('redacts keys to prefix…suffix', () => {
56
+ expect(redactKey(`${K}_abcdefgh_0123456789abcdefghijklmnop`)).toBe(`${K}_abcdefgh_…mnop`);
57
+ expect(redactKey('short')).toBe('****');
58
+ });
59
+ });