@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.
- package/LICENSE +82 -0
- package/dist/commands/auth.d.ts +6 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +582 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/completion.d.ts +5 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +85 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/demo.d.ts +12 -0
- package/dist/commands/demo.d.ts.map +1 -0
- package/dist/commands/demo.js +210 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/files.d.ts +21 -0
- package/dist/commands/files.d.ts.map +1 -0
- package/dist/commands/files.js +178 -0
- package/dist/commands/files.js.map +1 -0
- package/dist/commands/projects.d.ts +38 -0
- package/dist/commands/projects.d.ts.map +1 -0
- package/dist/commands/projects.js +386 -0
- package/dist/commands/projects.js.map +1 -0
- package/dist/commands/teams.d.ts +46 -0
- package/dist/commands/teams.d.ts.map +1 -0
- package/dist/commands/teams.js +265 -0
- package/dist/commands/teams.js.map +1 -0
- package/dist/commands/workspaces.d.ts +22 -0
- package/dist/commands/workspaces.d.ts.map +1 -0
- package/dist/commands/workspaces.js +116 -0
- package/dist/commands/workspaces.js.map +1 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +54 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +152 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +456 -0
- package/dist/context.js.map +1 -0
- package/dist/identity.d.ts +36 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +18 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/kit.d.ts +46 -0
- package/dist/kit.d.ts.map +1 -0
- package/dist/kit.js +2 -0
- package/dist/kit.js.map +1 -0
- package/dist/open.d.ts +27 -0
- package/dist/open.d.ts.map +1 -0
- package/dist/open.js +45 -0
- package/dist/open.js.map +1 -0
- package/dist/owner-session.d.ts +34 -0
- package/dist/owner-session.d.ts.map +1 -0
- package/dist/owner-session.js +106 -0
- package/dist/owner-session.js.map +1 -0
- package/dist/program.d.ts +5 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +150 -0
- package/dist/program.js.map +1 -0
- package/dist/safe-write.d.ts +46 -0
- package/dist/safe-write.d.ts.map +1 -0
- package/dist/safe-write.js +127 -0
- package/dist/safe-write.js.map +1 -0
- package/dist/stdin.d.ts +4 -0
- package/dist/stdin.d.ts.map +1 -0
- package/dist/stdin.js +36 -0
- package/dist/stdin.js.map +1 -0
- package/dist/testing/index.d.ts +70 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +67 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/workspace.d.ts +79 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +139 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +43 -3
- package/src/commands/auth.ts +687 -0
- package/src/commands/completion.ts +102 -0
- package/src/commands/demo.ts +271 -0
- package/src/commands/files.ts +197 -0
- package/src/commands/projects.ts +460 -0
- package/src/commands/teams.ts +320 -0
- package/src/commands/workspaces.ts +140 -0
- package/src/config.ts +81 -0
- package/src/context.ts +616 -0
- package/src/identity.ts +48 -0
- package/src/index.ts +70 -0
- package/src/kit.ts +50 -0
- package/src/open.ts +48 -0
- package/src/owner-session.ts +152 -0
- package/src/program.ts +183 -0
- package/src/safe-write.ts +136 -0
- package/src/stdin.ts +39 -0
- package/src/testing/index.ts +129 -0
- package/src/workspace.ts +229 -0
- package/test/host/identity.test.ts +229 -0
- package/test/host/minimal-host.ts +16 -0
- package/test/host/minimal-tool.ts +40 -0
- package/test/suite/cli.test.ts +301 -0
- package/test/suite/config.test.ts +59 -0
- package/test/suite/demo.test.ts +467 -0
- package/test/suite/hub-profile-shared.test.ts +123 -0
- package/test/suite/login.test.ts +743 -0
- package/test/suite/logout.test.ts +247 -0
- package/test/suite/migration.test.ts +109 -0
- package/test/suite/open.test.ts +59 -0
- package/test/suite/profiles.test.ts +380 -0
- package/test/suite/projects.test.ts +550 -0
- package/test/suite/recovery.test.ts +348 -0
- package/test/suite/security.test.ts +199 -0
- package/test/suite/signin-fixtures.ts +204 -0
- package/test/suite/teams.test.ts +354 -0
- package/test/suite/whoami.test.ts +159 -0
- package/test/suite/workspace.test.ts +402 -0
- package/README.md +0 -3
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { HUB_TOOL, saveConfig as saveCoreConfig } from '@antasphere/cli-core';
|
|
2
|
+
import type { Route } from '@antasphere/chassis-cli/testing';
|
|
3
|
+
import { cli } from '@chassis-cli-test/host';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shared fixtures of the sign-in suites (PRDCT-2947): `login`, `logout`,
|
|
7
|
+
* `whoami`, `profiles`, the profile selection and the stranded-key recovery.
|
|
8
|
+
* Every literal that spells the tool is read from `cli.identity`, so the same
|
|
9
|
+
* files run under the chassis host and the tool's.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { keyPrefix: K, displayName } = cli.identity;
|
|
13
|
+
|
|
14
|
+
export const HUB = 'http://hub';
|
|
15
|
+
export const HUB_KEY = 'ant_hubkey12_secretsecretsecret1234';
|
|
16
|
+
export const ORG = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
|
17
|
+
|
|
18
|
+
/** A tool key of the host's prefix, long enough for `redactKey` to shorten. */
|
|
19
|
+
export const key = (tag: string) =>
|
|
20
|
+
`${K}_${tag.padEnd(8, '0').slice(0, 8)}_0123456789abcdefghijklmnopqrstuvwxyz`;
|
|
21
|
+
|
|
22
|
+
export const INSTANCE_BASE = {
|
|
23
|
+
name: 'Inst',
|
|
24
|
+
instanceId: 'i1',
|
|
25
|
+
version: '1.0.0',
|
|
26
|
+
apiVersion: 'v1',
|
|
27
|
+
setupRequired: false,
|
|
28
|
+
features: { mcp: true, oauth: true, files: true }
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const cloudInstanceRoute: Route = {
|
|
32
|
+
method: 'GET',
|
|
33
|
+
path: /\/api\/v1\/instance$/,
|
|
34
|
+
reply: () => ({
|
|
35
|
+
body: {
|
|
36
|
+
...INSTANCE_BASE,
|
|
37
|
+
edition: 'cloud',
|
|
38
|
+
auth: {
|
|
39
|
+
methods: ['antasphere', 'api-key', 'oauth'],
|
|
40
|
+
passwordReset: false,
|
|
41
|
+
emailChange: false,
|
|
42
|
+
twoFactor: false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const ossInstanceRoute: Route = {
|
|
49
|
+
method: 'GET',
|
|
50
|
+
path: /\/api\/v1\/instance$/,
|
|
51
|
+
reply: () => ({
|
|
52
|
+
body: {
|
|
53
|
+
...INSTANCE_BASE,
|
|
54
|
+
edition: 'oss',
|
|
55
|
+
auth: {
|
|
56
|
+
methods: ['password', 'email-otp', 'api-key', 'oauth'],
|
|
57
|
+
passwordReset: true,
|
|
58
|
+
emailChange: true,
|
|
59
|
+
twoFactor: true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function wsRow(id: string, name: string, over: Record<string, unknown> = {}) {
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
name,
|
|
69
|
+
role: 'owner',
|
|
70
|
+
hubOrigin: false,
|
|
71
|
+
centralAccountId: null as string | null,
|
|
72
|
+
look: { theme: null, pattern: null, field: null, grain: null },
|
|
73
|
+
suspended: false,
|
|
74
|
+
default: false,
|
|
75
|
+
...over
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `/me` as the server answers it, for a person in the given workspaces (the first is active). */
|
|
80
|
+
export function meBody(
|
|
81
|
+
opts: { name?: string; workspaces?: ReturnType<typeof wsRow>[]; via?: string } = {}
|
|
82
|
+
): Record<string, unknown> {
|
|
83
|
+
const workspaces = opts.workspaces ?? [wsRow('w1', 'Acme', { default: true })];
|
|
84
|
+
const active = workspaces[0]!;
|
|
85
|
+
return {
|
|
86
|
+
user: { id: 'u1', name: opts.name ?? 'Ada', email: 'ada@x.co' },
|
|
87
|
+
workspace: { id: active.id, name: active.name, hubOrigin: active.hubOrigin, look: active.look },
|
|
88
|
+
role: 'owner',
|
|
89
|
+
origin: 'local',
|
|
90
|
+
via: opts.via ?? 'api_key',
|
|
91
|
+
scopes: ['a:read'],
|
|
92
|
+
apiKeyExpiresAt: null,
|
|
93
|
+
workspaces,
|
|
94
|
+
activeWorkspaceId: active.id,
|
|
95
|
+
hubManageUrl: null,
|
|
96
|
+
canCreateWorkspace: false
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const meRoute = (body: Record<string, unknown> = meBody()): Route => ({
|
|
101
|
+
method: 'GET',
|
|
102
|
+
path: /\/api\/v1\/me$/,
|
|
103
|
+
reply: () => ({ body })
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export const filesRoute: Route = {
|
|
107
|
+
method: 'GET',
|
|
108
|
+
path: /\/api\/v1\/files$/,
|
|
109
|
+
reply: () => ({ body: { files: [], nextCursor: null } })
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const MINTED_META = {
|
|
113
|
+
id: '44444444-4444-4444-4444-444444444444',
|
|
114
|
+
name: 'CLI login',
|
|
115
|
+
keyId: 'minted12',
|
|
116
|
+
scopes: ['a:read'],
|
|
117
|
+
createdBy: 'u1',
|
|
118
|
+
createdAt: '2026-07-10T00:00:00.000Z',
|
|
119
|
+
lastUsedAt: null,
|
|
120
|
+
revokedAt: null,
|
|
121
|
+
expiresAt: null
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** The email-code pair of an instance (or of the hub), minting `minted`. */
|
|
125
|
+
export function otpRoutes(minted: string, workspaceId: string | null = 'w1'): Route[] {
|
|
126
|
+
return [
|
|
127
|
+
{ method: 'POST', path: /\/api\/v1\/cli\/auth\/request$/, reply: () => ({ body: { sent: true } }) },
|
|
128
|
+
{
|
|
129
|
+
method: 'POST',
|
|
130
|
+
path: /\/api\/v1\/cli\/auth\/complete$/,
|
|
131
|
+
reply: () => ({
|
|
132
|
+
status: 201,
|
|
133
|
+
body: {
|
|
134
|
+
key: minted,
|
|
135
|
+
apiKey: MINTED_META,
|
|
136
|
+
user: { id: 'u1', email: 'ada@x.co', name: 'Ada' },
|
|
137
|
+
workspaceId
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The hub → tool exchange, minting the keys in order (the last one repeats). */
|
|
145
|
+
export function exchangeRoutes(
|
|
146
|
+
minted: string[],
|
|
147
|
+
opts: { toolToken?: (n: number) => { status: number; body: unknown } | undefined } = {}
|
|
148
|
+
): Route[] {
|
|
149
|
+
let tokenSeq = 0;
|
|
150
|
+
let connectSeq = 0;
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
method: 'POST',
|
|
154
|
+
path: /\/api\/v1\/sso\/tool-token$/,
|
|
155
|
+
reply: () => {
|
|
156
|
+
tokenSeq += 1;
|
|
157
|
+
const refusal = opts.toolToken?.(tokenSeq);
|
|
158
|
+
if (refusal) return refusal;
|
|
159
|
+
return {
|
|
160
|
+
body: {
|
|
161
|
+
token: `jwt-${tokenSeq}`,
|
|
162
|
+
expiresAt: '2026-07-13T00:02:00.000Z',
|
|
163
|
+
hubRefreshToken: `hrt-${tokenSeq}`
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
method: 'POST',
|
|
170
|
+
path: /\/api\/v1\/sso\/cli-connect$/,
|
|
171
|
+
reply: () => {
|
|
172
|
+
connectSeq += 1;
|
|
173
|
+
return {
|
|
174
|
+
status: 201,
|
|
175
|
+
body: {
|
|
176
|
+
key: minted[Math.min(connectSeq, minted.length) - 1],
|
|
177
|
+
apiKey: MINTED_META,
|
|
178
|
+
user: { id: 'u1', email: 'ada@x.co', name: 'Ada' },
|
|
179
|
+
workspaceId: null
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The hub's refusal of the person (a restricted tool): exit 3, its own sentence. */
|
|
188
|
+
export const TOOL_ACCESS_DENIED = {
|
|
189
|
+
status: 403,
|
|
190
|
+
body: {
|
|
191
|
+
error: {
|
|
192
|
+
code: 'tool_access_denied',
|
|
193
|
+
message: `This account has no access to ${displayName} in any of its organizations`
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** What `antasphere login` leaves behind: the hub profile, active. */
|
|
199
|
+
export function seedHubLogin(env: Record<string, string>, name = 'default'): void {
|
|
200
|
+
saveCoreConfig(env, HUB_TOOL, {
|
|
201
|
+
activeProfile: name,
|
|
202
|
+
profiles: { [name]: { apiKey: HUB_KEY, baseUrl: HUB, email: 'ada@x.co' } }
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { CliIo } from '@antasphere/chassis-cli';
|
|
3
|
+
import { cli, run } from '@chassis-cli-test/host';
|
|
4
|
+
|
|
5
|
+
// The literals that spell the tool come from the host (its identity).
|
|
6
|
+
const { bin } = cli.identity;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The team commands (PRDCT-2813) and the team side of `projects members`
|
|
10
|
+
* (PRDCT-2794): the method + path each one hits (recording fake fetch), a
|
|
11
|
+
* `<team>` resolved by slug through the list or read directly by id,
|
|
12
|
+
* `--json` byte-exactness, and the refusals read as sentences.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface Canned {
|
|
16
|
+
status?: number;
|
|
17
|
+
body?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Call {
|
|
21
|
+
method: string;
|
|
22
|
+
path: string;
|
|
23
|
+
body: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function harness(responses: Canned[] = [{ status: 200, body: {} }]) {
|
|
27
|
+
const calls: Call[] = [];
|
|
28
|
+
const out: string[] = [];
|
|
29
|
+
const err: string[] = [];
|
|
30
|
+
const queue = [...responses];
|
|
31
|
+
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
32
|
+
const url = new URL(input.toString());
|
|
33
|
+
calls.push({
|
|
34
|
+
method: (init?.method ?? 'GET').toUpperCase(),
|
|
35
|
+
path: url.pathname + url.search,
|
|
36
|
+
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined
|
|
37
|
+
});
|
|
38
|
+
const r = queue.shift() ?? { status: 200, body: {} };
|
|
39
|
+
return new Response(JSON.stringify(r.body ?? {}), {
|
|
40
|
+
status: r.status ?? 200,
|
|
41
|
+
headers: { 'content-type': 'application/json' }
|
|
42
|
+
});
|
|
43
|
+
}) as typeof globalThis.fetch;
|
|
44
|
+
const io: CliIo = {
|
|
45
|
+
env: {},
|
|
46
|
+
out: { write: (s) => out.push(s) },
|
|
47
|
+
err: { write: (s) => err.push(s) },
|
|
48
|
+
fetch
|
|
49
|
+
};
|
|
50
|
+
return { io, calls, out: () => out.join(''), err: () => err.join('') };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const WIRED = ['--url', 'http://x', '--api-key', 'k'];
|
|
54
|
+
|
|
55
|
+
const TEAM_ID = '11111111-1111-4111-8111-111111111111';
|
|
56
|
+
const TEAM = {
|
|
57
|
+
id: TEAM_ID,
|
|
58
|
+
slug: 'design',
|
|
59
|
+
name: 'Design',
|
|
60
|
+
membersCount: 2,
|
|
61
|
+
isMember: true,
|
|
62
|
+
hubTeamId: null,
|
|
63
|
+
createdAt: '2026-09-01T10:00:00.000Z',
|
|
64
|
+
updatedAt: '2026-09-01T10:00:00.000Z'
|
|
65
|
+
};
|
|
66
|
+
const HUB_TEAM = {
|
|
67
|
+
...TEAM,
|
|
68
|
+
id: '22222222-2222-4222-8222-222222222222',
|
|
69
|
+
slug: 'sales',
|
|
70
|
+
name: 'Sales',
|
|
71
|
+
isMember: false,
|
|
72
|
+
hubTeamId: 'hub-t-1'
|
|
73
|
+
};
|
|
74
|
+
const TEAM_MEMBER = {
|
|
75
|
+
userId: 'u2',
|
|
76
|
+
email: 'ada@x.co',
|
|
77
|
+
name: 'Ada',
|
|
78
|
+
role: 'member',
|
|
79
|
+
isActive: true,
|
|
80
|
+
addedAt: '2026-09-02T10:00:00.000Z'
|
|
81
|
+
};
|
|
82
|
+
const PROJECT_TEAM = {
|
|
83
|
+
kind: 'team',
|
|
84
|
+
teamId: TEAM_ID,
|
|
85
|
+
slug: 'design',
|
|
86
|
+
name: 'Design',
|
|
87
|
+
membersCount: 2,
|
|
88
|
+
hubTeamId: null,
|
|
89
|
+
role: 'editor',
|
|
90
|
+
addedBy: 'u1',
|
|
91
|
+
createdAt: '2026-09-03T10:00:00.000Z'
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** A C1 CSI and a DEL: bytes `JSON.stringify` keeps as they are (see projects.test.ts). */
|
|
95
|
+
const RAW_NAME = 'Design\u009b2K\u007fHIDDEN';
|
|
96
|
+
|
|
97
|
+
const LIST = { body: { teams: [TEAM, HUB_TEAM], nextCursor: null } };
|
|
98
|
+
|
|
99
|
+
const refusal = (status: number, code: string, details?: unknown): Canned => ({
|
|
100
|
+
status,
|
|
101
|
+
body: { error: { code, message: 'terse wire message', ...(details ? { details } : {}) } }
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe(`${bin} teams`, () => {
|
|
105
|
+
it('list reads every page and prints the columns, the account site marked', async () => {
|
|
106
|
+
const h = harness([
|
|
107
|
+
{ body: { teams: [TEAM], nextCursor: 'c1' } },
|
|
108
|
+
{ body: { teams: [HUB_TEAM], nextCursor: null } }
|
|
109
|
+
]);
|
|
110
|
+
expect(await run(['teams', 'list', ...WIRED], h.io)).toBe(0);
|
|
111
|
+
expect(h.calls.map((c) => `${c.method} ${c.path}`)).toEqual([
|
|
112
|
+
'GET /api/v1/teams',
|
|
113
|
+
'GET /api/v1/teams?cursor=c1'
|
|
114
|
+
]);
|
|
115
|
+
const lines = h.out().split('\n');
|
|
116
|
+
expect(lines[0]).toMatch(/^SLUG\s+NAME\s+MEMBERS\s+YOURS\s+CREATED$/);
|
|
117
|
+
expect(h.out()).toMatch(/design\s+Design\s+2\s+yes\s+2026-09-01/);
|
|
118
|
+
expect(h.out()).toContain('Sales (Antasphere)');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('list says so when there is nothing', async () => {
|
|
122
|
+
const h = harness([{ body: { teams: [], nextCursor: null } }]);
|
|
123
|
+
expect(await run(['teams', 'list', ...WIRED], h.io)).toBe(0);
|
|
124
|
+
expect(h.out()).toBe('No teams.\n');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('list --json prints the wire shape byte-exactly', async () => {
|
|
128
|
+
const raw = { ...TEAM, name: RAW_NAME };
|
|
129
|
+
const h = harness([{ body: { teams: [raw], nextCursor: null } }]);
|
|
130
|
+
expect(await run(['teams', 'list', '--json', ...WIRED], h.io)).toBe(0);
|
|
131
|
+
expect(h.out()).toBe(`${JSON.stringify({ teams: [raw], nextCursor: null }, null, 2)}\n`);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('get <id> reads the team directly; get <slug> searches the list', async () => {
|
|
135
|
+
const h = harness([{ body: TEAM }]);
|
|
136
|
+
expect(await run(['teams', 'get', TEAM_ID, ...WIRED], h.io)).toBe(0);
|
|
137
|
+
expect(h.calls.map((c) => c.path)).toEqual([`/api/v1/teams/${TEAM_ID}`]);
|
|
138
|
+
expect(h.out()).toContain('slug: design');
|
|
139
|
+
|
|
140
|
+
const h2 = harness([LIST]);
|
|
141
|
+
expect(await run(['teams', 'get', 'sales', ...WIRED], h2.io)).toBe(0);
|
|
142
|
+
expect(h2.calls.map((c) => c.path)).toEqual(['/api/v1/teams']);
|
|
143
|
+
expect(h2.out()).toContain('on the Antasphere account site');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('get of an unknown slug says so', async () => {
|
|
147
|
+
const h = harness([LIST]);
|
|
148
|
+
expect(await run(['teams', 'get', 'nope', ...WIRED], h.io)).toBe(1);
|
|
149
|
+
expect(h.err()).toContain('No team of this workspace has the slug or id "nope".');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('create sends the name alone, the slug derived by the server', async () => {
|
|
153
|
+
const h = harness([{ status: 201, body: TEAM }]);
|
|
154
|
+
expect(await run(['teams', 'create', 'Design', ...WIRED], h.io)).toBe(0);
|
|
155
|
+
expect(h.calls).toEqual([{ method: 'POST', path: '/api/v1/teams', body: { name: 'Design' } }]);
|
|
156
|
+
expect(h.out()).toContain('Created team design.');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('create --slug sends the slug', async () => {
|
|
160
|
+
const h = harness([{ status: 201, body: TEAM }]);
|
|
161
|
+
expect(await run(['teams', 'create', 'Design', '--slug', 'design', ...WIRED], h.io)).toBe(0);
|
|
162
|
+
expect(h.calls[0]!.body).toEqual({ name: 'Design', slug: 'design' });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('rename resolves the slug, then PATCHes only what was passed', async () => {
|
|
166
|
+
const h = harness([LIST, { body: { ...TEAM, name: 'Design Ops' } }]);
|
|
167
|
+
expect(await run(['teams', 'rename', 'design', '--name', 'Design Ops', ...WIRED], h.io)).toBe(0);
|
|
168
|
+
expect(h.calls[1]).toEqual({
|
|
169
|
+
method: 'PATCH',
|
|
170
|
+
path: `/api/v1/teams/${TEAM_ID}`,
|
|
171
|
+
body: { name: 'Design Ops' }
|
|
172
|
+
});
|
|
173
|
+
expect(h.out()).toBe('Team design: Design Ops\n');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('rename with nothing to change never reaches the wire', async () => {
|
|
177
|
+
const h = harness();
|
|
178
|
+
expect(await run(['teams', 'rename', 'design', ...WIRED], h.io)).toBe(1);
|
|
179
|
+
expect(h.calls).toEqual([]);
|
|
180
|
+
expect(h.err()).toContain('Nothing to change');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('delete without --yes is refused before any request', async () => {
|
|
184
|
+
const h = harness();
|
|
185
|
+
expect(await run(['teams', 'delete', 'design', ...WIRED], h.io)).toBe(1);
|
|
186
|
+
expect(h.calls).toEqual([]);
|
|
187
|
+
expect(h.err()).toContain('rerun with --yes');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('delete --yes → DELETE /api/v1/teams/:id', async () => {
|
|
191
|
+
const h = harness([{ body: TEAM }, { body: TEAM }]);
|
|
192
|
+
expect(await run(['teams', 'delete', TEAM_ID, '--yes', ...WIRED], h.io)).toBe(0);
|
|
193
|
+
expect(h.calls[1]).toEqual({ method: 'DELETE', path: `/api/v1/teams/${TEAM_ID}`, body: undefined });
|
|
194
|
+
expect(h.out()).toContain('Deleted team design.');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('members lists every page of the team', async () => {
|
|
198
|
+
const h = harness([
|
|
199
|
+
{ body: TEAM },
|
|
200
|
+
{ body: { members: [TEAM_MEMBER], nextCursor: 'm1' } },
|
|
201
|
+
{ body: { members: [{ ...TEAM_MEMBER, userId: 'u3', email: 'bo@x.co' }], nextCursor: null } }
|
|
202
|
+
]);
|
|
203
|
+
expect(await run(['teams', 'members', TEAM_ID, ...WIRED], h.io)).toBe(0);
|
|
204
|
+
expect(h.calls.map((c) => c.path)).toEqual([
|
|
205
|
+
`/api/v1/teams/${TEAM_ID}`,
|
|
206
|
+
`/api/v1/teams/${TEAM_ID}/members`,
|
|
207
|
+
`/api/v1/teams/${TEAM_ID}/members?cursor=m1`
|
|
208
|
+
]);
|
|
209
|
+
expect(h.out()).toContain('ada@x.co');
|
|
210
|
+
expect(h.out()).toContain('bo@x.co');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('add by email sends { email }', async () => {
|
|
214
|
+
const h = harness([LIST, { status: 201, body: TEAM_MEMBER }]);
|
|
215
|
+
expect(await run(['teams', 'add', 'design', 'ada@x.co', ...WIRED], h.io)).toBe(0);
|
|
216
|
+
expect(h.calls[1]).toEqual({
|
|
217
|
+
method: 'POST',
|
|
218
|
+
path: `/api/v1/teams/${TEAM_ID}/members`,
|
|
219
|
+
body: { email: 'ada@x.co' }
|
|
220
|
+
});
|
|
221
|
+
expect(h.out()).toBe('Added Ada <ada@x.co> to team design.\n');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('add by user id sends { userId }', async () => {
|
|
225
|
+
const h = harness([LIST, { status: 201, body: TEAM_MEMBER }]);
|
|
226
|
+
expect(await run(['teams', 'add', 'design', 'u2', ...WIRED], h.io)).toBe(0);
|
|
227
|
+
expect(h.calls[1]!.body).toEqual({ userId: 'u2' });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('remove by email looks the person up among the team members', async () => {
|
|
231
|
+
const h = harness([LIST, { body: { members: [TEAM_MEMBER], nextCursor: null } }, { body: TEAM_MEMBER }]);
|
|
232
|
+
expect(await run(['teams', 'remove', 'design', 'ADA@x.co', ...WIRED], h.io)).toBe(0);
|
|
233
|
+
expect(h.calls[2]).toEqual({
|
|
234
|
+
method: 'DELETE',
|
|
235
|
+
path: `/api/v1/teams/${TEAM_ID}/members/u2`,
|
|
236
|
+
body: undefined
|
|
237
|
+
});
|
|
238
|
+
expect(h.out()).toContain('Removed Ada <ada@x.co> from team design.');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('add --json prints the member payload byte-exactly', async () => {
|
|
242
|
+
const raw = { ...TEAM_MEMBER, name: RAW_NAME };
|
|
243
|
+
const h = harness([LIST, { status: 201, body: raw }]);
|
|
244
|
+
expect(await run(['teams', 'add', 'design', 'u2', '--json', ...WIRED], h.io)).toBe(0);
|
|
245
|
+
expect(h.out()).toBe(`${JSON.stringify(raw, null, 2)}\n`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ── the refusals ─────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
it('403 forbidden reads as the owner-or-admin sentence', async () => {
|
|
251
|
+
const h = harness([refusal(403, 'forbidden')]);
|
|
252
|
+
expect(await run(['teams', 'create', 'Design', ...WIRED], h.io)).toBe(1);
|
|
253
|
+
expect(h.err()).toContain('Only an owner or an admin manages teams.');
|
|
254
|
+
expect(h.err()).not.toContain('terse wire message');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('403 hub_managed names the account site and prints its url', async () => {
|
|
258
|
+
const h = harness([refusal(403, 'hub_managed', { manageUrl: 'https://account.example/orgs/o1/teams' })]);
|
|
259
|
+
expect(await run(['teams', 'create', 'Design', ...WIRED], h.io)).toBe(1);
|
|
260
|
+
expect(h.err()).toContain(
|
|
261
|
+
'Teams of this workspace are managed on the Antasphere account site: https://account.example/orgs/o1/teams'
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('409 slug_taken names the slug passed, or asks for --slug', async () => {
|
|
266
|
+
const h = harness([refusal(409, 'slug_taken')]);
|
|
267
|
+
expect(await run(['teams', 'create', 'Design', '--slug', 'design', ...WIRED], h.io)).toBe(1);
|
|
268
|
+
expect(h.err()).toContain('A team of this workspace already uses the slug "design".');
|
|
269
|
+
|
|
270
|
+
const h2 = harness([refusal(409, 'slug_taken')]);
|
|
271
|
+
expect(await run(['teams', 'create', 'Design', ...WIRED], h2.io)).toBe(1);
|
|
272
|
+
expect(h2.err()).toContain('already uses the slug this name makes; pass --slug.');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('409 already_member names the person', async () => {
|
|
276
|
+
const h = harness([LIST, refusal(409, 'already_member')]);
|
|
277
|
+
expect(await run(['teams', 'add', 'design', 'ada@x.co', ...WIRED], h.io)).toBe(1);
|
|
278
|
+
expect(h.err()).toContain('ada@x.co is already in this team.');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe(`${bin} projects members, a team as a member`, () => {
|
|
283
|
+
it('add --team resolves the team and sends { teamId, role }', async () => {
|
|
284
|
+
const h = harness([LIST, { status: 201, body: PROJECT_TEAM }]);
|
|
285
|
+
const code = await run(
|
|
286
|
+
['projects', 'members', 'add', 'p-1', '--team', 'design', '--role', 'editor', ...WIRED],
|
|
287
|
+
h.io
|
|
288
|
+
);
|
|
289
|
+
expect(code).toBe(0);
|
|
290
|
+
expect(h.calls[1]).toEqual({
|
|
291
|
+
method: 'POST',
|
|
292
|
+
path: '/api/v1/projects/p-1/members',
|
|
293
|
+
body: { teamId: TEAM_ID, role: 'editor' }
|
|
294
|
+
});
|
|
295
|
+
expect(h.out()).toContain('Added team Design (design) is an editor of this project for its 2 members');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('add refuses both a person and --team, or neither, before any request', async () => {
|
|
299
|
+
const h = harness();
|
|
300
|
+
const both = ['projects', 'members', 'add', 'p-1', 'u2', '--team', 'design', '--role', 'editor'];
|
|
301
|
+
expect(await run([...both, ...WIRED], h.io)).toBe(1);
|
|
302
|
+
expect(h.calls).toEqual([]);
|
|
303
|
+
expect(h.err()).toContain('Name exactly one');
|
|
304
|
+
|
|
305
|
+
const h2 = harness();
|
|
306
|
+
expect(await run(['projects', 'members', 'add', 'p-1', '--role', 'editor', ...WIRED], h2.io)).toBe(1);
|
|
307
|
+
expect(h2.calls).toEqual([]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('list prints both kinds', async () => {
|
|
311
|
+
const person = {
|
|
312
|
+
kind: 'person',
|
|
313
|
+
userId: 'u2',
|
|
314
|
+
email: 'ada@x.co',
|
|
315
|
+
name: 'Ada',
|
|
316
|
+
role: 'viewer',
|
|
317
|
+
addedBy: 'u1',
|
|
318
|
+
createdAt: '2026-09-03T10:00:00.000Z'
|
|
319
|
+
};
|
|
320
|
+
const h = harness([{ body: { members: [person, PROJECT_TEAM], nextCursor: null } }]);
|
|
321
|
+
expect(await run(['projects', 'members', 'list', 'p-1', ...WIRED], h.io)).toBe(0);
|
|
322
|
+
expect(h.out()).toMatch(/person\s+u2\s+viewer\s+ada@x\.co\s+Ada/);
|
|
323
|
+
expect(h.out()).toMatch(/team\s+design\s+editor\s+2 members\s+Design/);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('role --team → PATCH /projects/:id/teams/:teamId', async () => {
|
|
327
|
+
const h = harness([LIST, { body: { ...PROJECT_TEAM, role: 'manager' } }]);
|
|
328
|
+
expect(
|
|
329
|
+
await run(['projects', 'members', 'role', 'p-1', 'design', 'manager', '--team', ...WIRED], h.io)
|
|
330
|
+
).toBe(0);
|
|
331
|
+
expect(h.calls[1]).toEqual({
|
|
332
|
+
method: 'PATCH',
|
|
333
|
+
path: `/api/v1/projects/p-1/teams/${TEAM_ID}`,
|
|
334
|
+
body: { role: 'manager' }
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('remove --team → DELETE /projects/:id/teams/:teamId', async () => {
|
|
339
|
+
const h = harness([{ body: TEAM }, { body: PROJECT_TEAM }]);
|
|
340
|
+
expect(await run(['projects', 'members', 'remove', 'p-1', TEAM_ID, '--team', ...WIRED], h.io)).toBe(0);
|
|
341
|
+
expect(h.calls[1]).toEqual({
|
|
342
|
+
method: 'DELETE',
|
|
343
|
+
path: `/api/v1/projects/p-1/teams/${TEAM_ID}`,
|
|
344
|
+
body: undefined
|
|
345
|
+
});
|
|
346
|
+
expect(h.out()).toContain('Its people keep their own entries.');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('team_not_found on a project team route says the team is not on the project', async () => {
|
|
350
|
+
const h = harness([{ body: TEAM }, refusal(404, 'team_not_found')]);
|
|
351
|
+
expect(await run(['projects', 'members', 'remove', 'p-1', TEAM_ID, '--team', ...WIRED], h.io)).toBe(1);
|
|
352
|
+
expect(h.err()).toContain('That team is not a member of this project.');
|
|
353
|
+
});
|
|
354
|
+
});
|