@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,320 @@
1
+ import type { Command } from 'commander';
2
+ import { PlatformApiError, type ChassisClient } from '@antasphere/chassis-sdk';
3
+ import type { Team, TeamMemberInfo } from '@antasphere/chassis-contract';
4
+ import { CliApiRefusal, CliUsageError, drainPages, printJson, table, type CliIo } from '../context.js';
5
+ import type { CliKit } from '../kit.js';
6
+
7
+ /**
8
+ * The team commands: a team is a named group of the workspace's own members,
9
+ * on both editions. Every non-guest member reads the teams and their members;
10
+ * an owner or an admin creates, renames and deletes them and seats people.
11
+ * In a workspace the Antasphere account site manages, the teams are its
12
+ * projection: read here, every write refused with `hub_managed` and the page
13
+ * where they are managed. The family mirrors the hub's `antasphere teams`
14
+ * with this chassis's conventions: `<team>` is a slug or an id, every human
15
+ * line goes through the sanitizing sinks the runner installed (a team name is
16
+ * somebody else's text), and `--json` is the API payload verbatim.
17
+ */
18
+
19
+ /** What a `member_not_found` was about: the workspace (`add`) or the team (`remove`). */
20
+ export type TeamLookup = 'team' | 'workspace-member' | 'team-member';
21
+
22
+ /** The refusal context only the command knows: the slug it passed, the person it named. */
23
+ export interface TeamRefusalContext {
24
+ lookup: TeamLookup;
25
+ /** The slug the caller passed; absent, the server derived it from the name. */
26
+ slug?: string | undefined;
27
+ /** The person the caller named, for `already_member`. */
28
+ who?: string | undefined;
29
+ }
30
+
31
+ /**
32
+ * The server's team refusals as sentences. Returns the line, or null when the
33
+ * error is not one of them (the runner's generic handling is then the honest
34
+ * answer).
35
+ */
36
+ export function explainTeamRefusal(e: PlatformApiError, ctx: TeamRefusalContext): string | null {
37
+ switch (e.code) {
38
+ case 'forbidden':
39
+ return e.status === 403 ? 'Only an owner or an admin manages teams.' : null;
40
+ case 'hub_managed': {
41
+ const url = (e.details as { manageUrl?: unknown } | undefined)?.manageUrl;
42
+ return typeof url === 'string' && url
43
+ ? `Teams of this workspace are managed on the Antasphere account site: ${url}`
44
+ : 'Teams of this workspace are managed on the Antasphere account site.';
45
+ }
46
+ case 'slug_taken':
47
+ return ctx.slug !== undefined
48
+ ? `A team of this workspace already uses the slug "${ctx.slug}".`
49
+ : 'A team of this workspace already uses the slug this name makes; pass --slug.';
50
+ case 'already_member':
51
+ return `${ctx.who ?? 'That person'} is already in this team.`;
52
+ case 'member_not_found':
53
+ return ctx.lookup === 'team-member'
54
+ ? 'That person is not a member of this team.'
55
+ : 'No active member of this workspace matches — check the user id or email. Only a member of the workspace can join a team.';
56
+ case 'not_found':
57
+ return 'No such team in this workspace.';
58
+ case 'guest_forbidden':
59
+ return 'You are a guest of this workspace, and guests do not take part in its teams.';
60
+ default:
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** Run a team call, turning the known refusals into sentences. */
66
+ export async function explainedTeam<T>(ctx: TeamRefusalContext, run: () => Promise<T>): Promise<T> {
67
+ try {
68
+ return await run();
69
+ } catch (e) {
70
+ if (e instanceof PlatformApiError) {
71
+ const line = explainTeamRefusal(e, ctx);
72
+ if (line) throw new CliApiRefusal(line, e.status);
73
+ }
74
+ throw e;
75
+ }
76
+ }
77
+
78
+ /** Every team of the workspace, page after page. */
79
+ export async function allTeams(client: ChassisClient<string>): Promise<Team[]> {
80
+ const first = await client.teams();
81
+ return drainPages({ rows: first.teams, nextCursor: first.nextCursor }, async (cursor) => {
82
+ const page = await client.teams({ cursor });
83
+ return { rows: page.teams, nextCursor: page.nextCursor };
84
+ });
85
+ }
86
+
87
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
88
+
89
+ /**
90
+ * A team named by its slug or its id (the hub's `findTeam`). An id is read
91
+ * directly; a slug, or an id the server does not know (a slug may look like
92
+ * one), is looked up in the workspace's list, by slug first, then by id.
93
+ */
94
+ export async function resolveTeam(client: ChassisClient<string>, ref: string): Promise<Team> {
95
+ return explainedTeam({ lookup: 'team' }, async () => {
96
+ if (UUID.test(ref)) {
97
+ try {
98
+ return await client.team(ref);
99
+ } catch (e) {
100
+ if (!(e instanceof PlatformApiError && (e.status === 404 || e.status === 400))) throw e;
101
+ }
102
+ }
103
+ const teams = await allTeams(client);
104
+ const team = teams.find((t) => t.slug === ref) ?? teams.find((t) => t.id === ref);
105
+ if (!team) throw new CliApiRefusal(`No team of this workspace has the slug or id "${ref}".`, 404);
106
+ return team;
107
+ });
108
+ }
109
+
110
+ /** An `@` makes the argument an email, anything else a user id (as `projects members add`). */
111
+ function personRef(value: string): { email: string } | { userId: string } {
112
+ return value.includes('@') ? { email: value } : { userId: value };
113
+ }
114
+
115
+ /** The date part of an ISO 8601 timestamp (`2026-10-23`). */
116
+ function fmtDate(iso: string): string {
117
+ return iso.slice(0, 10);
118
+ }
119
+
120
+ /** A team's name, marked when the Antasphere account site owns it. */
121
+ export function teamName(t: Pick<Team, 'name' | 'hubTeamId'>): string {
122
+ return t.hubTeamId ? `${t.name} (Antasphere)` : t.name;
123
+ }
124
+
125
+ /** One team, as the human sees it. */
126
+ function teamLines(t: Team): string {
127
+ return (
128
+ `${teamName(t)}\n` +
129
+ ` id: ${t.id}\n` +
130
+ ` slug: ${t.slug}\n` +
131
+ ` members: ${t.membersCount}\n` +
132
+ ` yours: ${t.isMember ? 'yes' : 'no'}\n` +
133
+ (t.hubTeamId ? ` managed: on the Antasphere account site\n` : '') +
134
+ ` created: ${t.createdAt}\n`
135
+ );
136
+ }
137
+
138
+ /** Every member of one team, page after page. */
139
+ async function allTeamMembers(client: ChassisClient<string>, teamId: string): Promise<TeamMemberInfo[]> {
140
+ const first = await client.teamMembers(teamId);
141
+ return drainPages({ rows: first.members, nextCursor: first.nextCursor }, async (cursor) => {
142
+ const page = await client.teamMembers(teamId, { cursor });
143
+ return { rows: page.members, nextCursor: page.nextCursor };
144
+ });
145
+ }
146
+
147
+ export function registerTeamCommands<TClient extends ChassisClient<string>>(
148
+ kit: CliKit<TClient>,
149
+ program: Command,
150
+ io: CliIo
151
+ ): void {
152
+ const { requireApiKey, resolveContext } = kit;
153
+
154
+ const teams = program
155
+ .command('teams')
156
+ .description('The teams of the workspace (named groups of its members)')
157
+ .addHelpText(
158
+ 'after',
159
+ '\nEvery member reads the teams; an owner or an admin creates, renames and deletes them and\n' +
160
+ 'seats people. In a workspace the Antasphere account site manages, the teams are read here\n' +
161
+ 'and managed there.\n'
162
+ );
163
+
164
+ // ── teams list ────────────────────────────────────────────────────────────
165
+ teams
166
+ .command('list')
167
+ .description("List the workspace's teams (every page)")
168
+ .action(async (_opts, cmd: Command) => {
169
+ const ctx = resolveContext(cmd, io);
170
+ await requireApiKey(ctx);
171
+ const rows = await explainedTeam({ lookup: 'team' }, () => allTeams(ctx.client));
172
+ if (ctx.json) return printJson(io, { teams: rows, nextCursor: null });
173
+ if (rows.length === 0) {
174
+ io.out.write('No teams.\n');
175
+ return;
176
+ }
177
+ io.out.write(
178
+ table([
179
+ ['SLUG', 'NAME', 'MEMBERS', 'YOURS', 'CREATED'],
180
+ ...rows.map((t) => [
181
+ t.slug,
182
+ teamName(t),
183
+ String(t.membersCount),
184
+ t.isMember ? 'yes' : 'no',
185
+ fmtDate(t.createdAt)
186
+ ])
187
+ ])
188
+ );
189
+ });
190
+
191
+ // ── teams get ─────────────────────────────────────────────────────────────
192
+ teams
193
+ .command('get <team>')
194
+ .description('Show one team (<team> is a slug or an id)')
195
+ .action(async (ref: string, _opts, cmd: Command) => {
196
+ const ctx = resolveContext(cmd, io);
197
+ await requireApiKey(ctx);
198
+ const team = await resolveTeam(ctx.client, ref);
199
+ if (ctx.json) return printJson(io, team);
200
+ io.out.write(teamLines(team));
201
+ });
202
+
203
+ // ── teams create ──────────────────────────────────────────────────────────
204
+ teams
205
+ .command('create <name>')
206
+ .description('Create a team (owner or admin; the slug is derived from the name when --slug is absent)')
207
+ .option('--slug <slug>', 'the team slug (lowercase words joined by dashes)')
208
+ .action(async (name: string, opts: { slug?: string }, cmd: Command) => {
209
+ const ctx = resolveContext(cmd, io);
210
+ await requireApiKey(ctx);
211
+ const team = await explainedTeam({ lookup: 'team', slug: opts.slug || undefined }, () =>
212
+ ctx.client.createTeam({ name, ...(opts.slug ? { slug: opts.slug } : {}) })
213
+ );
214
+ if (ctx.json) return printJson(io, team);
215
+ io.out.write(`Created team ${team.slug}.\n${teamLines(team)}`);
216
+ });
217
+
218
+ // ── teams rename ──────────────────────────────────────────────────────────
219
+ teams
220
+ .command('rename <team>')
221
+ .description('Rename a team or change its slug (owner or admin; <team> is a slug or an id)')
222
+ .option('--name <name>', 'the new name')
223
+ .option('--slug <slug>', 'the new slug')
224
+ .action(async (ref: string, opts: { name?: string; slug?: string }, cmd: Command) => {
225
+ if (opts.name === undefined && opts.slug === undefined) {
226
+ throw new CliUsageError('Nothing to change — pass --name <name>, --slug <slug>, or both.');
227
+ }
228
+ const ctx = resolveContext(cmd, io);
229
+ await requireApiKey(ctx);
230
+ const team = await resolveTeam(ctx.client, ref);
231
+ const updated = await explainedTeam({ lookup: 'team', slug: opts.slug }, () =>
232
+ ctx.client.updateTeam(team.id, {
233
+ ...(opts.name !== undefined ? { name: opts.name } : {}),
234
+ ...(opts.slug !== undefined ? { slug: opts.slug } : {})
235
+ })
236
+ );
237
+ if (ctx.json) return printJson(io, updated);
238
+ io.out.write(`Team ${updated.slug}: ${updated.name}\n`);
239
+ });
240
+
241
+ // ── teams delete ──────────────────────────────────────────────────────────
242
+ teams
243
+ .command('delete <team>')
244
+ .description(
245
+ 'Delete a team; its members stay in the workspace (owner or admin; <team> is a slug or an id)'
246
+ )
247
+ .option('--yes', 'confirm the deletion', false)
248
+ .action(async (ref: string, opts: { yes: boolean }, cmd: Command) => {
249
+ if (!opts.yes) {
250
+ throw new CliUsageError(`Deleting a team cannot be undone: rerun with --yes to delete "${ref}".`);
251
+ }
252
+ const ctx = resolveContext(cmd, io);
253
+ await requireApiKey(ctx);
254
+ const team = await resolveTeam(ctx.client, ref);
255
+ const deleted = await explainedTeam({ lookup: 'team' }, () => ctx.client.deleteTeam(team.id));
256
+ if (ctx.json) return printJson(io, deleted);
257
+ io.out.write(`Deleted team ${deleted.slug}. Its members stay in the workspace.\n`);
258
+ });
259
+
260
+ // ── teams members ─────────────────────────────────────────────────────────
261
+ teams
262
+ .command('members <team>')
263
+ .description("List a team's members (every page; <team> is a slug or an id)")
264
+ .action(async (ref: string, _opts, cmd: Command) => {
265
+ const ctx = resolveContext(cmd, io);
266
+ await requireApiKey(ctx);
267
+ const team = await resolveTeam(ctx.client, ref);
268
+ const rows = await explainedTeam({ lookup: 'team' }, () => allTeamMembers(ctx.client, team.id));
269
+ if (ctx.json) return printJson(io, { members: rows, nextCursor: null });
270
+ if (rows.length === 0) {
271
+ io.out.write(`Team ${team.slug} has no members.\n`);
272
+ return;
273
+ }
274
+ io.out.write(
275
+ table([
276
+ ['USER ID', 'EMAIL', 'ROLE', 'ADDED', 'NAME'],
277
+ ...rows.map((m) => [m.userId, m.email, m.role, fmtDate(m.addedAt), m.name || '-'])
278
+ ])
279
+ );
280
+ });
281
+
282
+ // ── teams add ─────────────────────────────────────────────────────────────
283
+ teams
284
+ .command('add <team> <userIdOrEmail>')
285
+ .description('Seat a member of the workspace in a team (owner or admin; an @ makes it an email)')
286
+ .action(async (ref: string, who: string, _opts, cmd: Command) => {
287
+ const ctx = resolveContext(cmd, io);
288
+ await requireApiKey(ctx);
289
+ const team = await resolveTeam(ctx.client, ref);
290
+ const added = await explainedTeam({ lookup: 'workspace-member', who }, () =>
291
+ ctx.client.addTeamMember(team.id, personRef(who))
292
+ );
293
+ if (ctx.json) return printJson(io, added);
294
+ io.out.write(`Added ${added.name} <${added.email}> to team ${team.slug}.\n`);
295
+ });
296
+
297
+ // ── teams remove ──────────────────────────────────────────────────────────
298
+ teams
299
+ .command('remove <team> <userIdOrEmail>')
300
+ .description('Remove a member from a team; they stay in the workspace (owner or admin)')
301
+ .action(async (ref: string, who: string, _opts, cmd: Command) => {
302
+ const ctx = resolveContext(cmd, io);
303
+ await requireApiKey(ctx);
304
+ const team = await resolveTeam(ctx.client, ref);
305
+ // The route takes a user id; an email is looked up among the team's members.
306
+ let userId = who;
307
+ if (who.includes('@')) {
308
+ const wanted = who.trim().toLowerCase();
309
+ const members = await explainedTeam({ lookup: 'team' }, () => allTeamMembers(ctx.client, team.id));
310
+ const hit = members.find((m) => m.email.toLowerCase() === wanted);
311
+ if (!hit) throw new CliApiRefusal('That person is not a member of this team.', 404);
312
+ userId = hit.userId;
313
+ }
314
+ const removed = await explainedTeam({ lookup: 'team-member', who }, () =>
315
+ ctx.client.removeTeamMember(team.id, userId)
316
+ );
317
+ if (ctx.json) return printJson(io, removed);
318
+ io.out.write(`Removed ${removed.name} <${removed.email}> from team ${team.slug}.\n`);
319
+ });
320
+ }
@@ -0,0 +1,140 @@
1
+ import type { Command } from 'commander';
2
+ import { PlatformApiError, type ChassisClient } from '@antasphere/chassis-sdk';
3
+ import { CliApiRefusal, CliUsageError, printJson, type CliIo } from '../context.js';
4
+ import type { CliKit } from '../kit.js';
5
+ import { findWorkspace, matchWorkspace } from '../workspace.js';
6
+
7
+ /**
8
+ * Which workspace the commands run in (PRDCT-2419, PRDCT-2947): `workspaces`
9
+ * lists the memberships with the organization ids `--org` takes, and
10
+ * `workspace default` (PRDCT-2815) sets what the SERVER resolves for the
11
+ * person when a request names none, for every credential of theirs: set
12
+ * here on a self-hosted instance; on the cloud it is a setting of the
13
+ * Antasphere account, and the refusal names the page. Nothing is saved on
14
+ * this machine: a command names its organization with `--org` or its
15
+ * workspace with `--workspace`, or runs in the default (one organization
16
+ * notion, the hub's).
17
+ *
18
+ * Both talk to `/me` WITHOUT the selection (`workspace: false`): they are the
19
+ * commands a person repairs a stale selection with, so a selection the
20
+ * server refuses must never stop them. There is deliberately no
21
+ * `workspaces create`: `POST /workspaces` is a session-only act.
22
+ */
23
+
24
+ export function registerWorkspaceCommands<TClient extends ChassisClient<string>>(
25
+ kit: CliKit<TClient>,
26
+ program: Command,
27
+ io: CliIo
28
+ ): void {
29
+ const { requireApiKey, resolveContext, workspaceSource } = kit;
30
+ const { describeSelection } = kit.workspace;
31
+
32
+ program
33
+ .command('workspaces')
34
+ .description('List your workspaces (the one the commands run in marked *)')
35
+ .action(async (_opts, cmd: Command) => {
36
+ const ctx = resolveContext(cmd, io);
37
+ await requireApiKey(ctx, { workspace: false });
38
+ const me = await ctx.client.me();
39
+ const selection = ctx.workspaceSelection;
40
+ const source = workspaceSource(ctx);
41
+ // With a selection, the mark is the membership it names; without one,
42
+ // the workspace the server resolved (its default, or the key's pin).
43
+ const selected = selection
44
+ ? (findWorkspace(me.workspaces, selection.value).match?.id ?? null)
45
+ : me.activeWorkspaceId;
46
+ if (selection && selected === null) {
47
+ io.err.write(
48
+ `Warning: "${selection.value}" (selected by ${describeSelection(selection)}) names none of these ` +
49
+ 'workspaces, so commands that use it are refused.\n'
50
+ );
51
+ }
52
+ if (ctx.json) {
53
+ return printJson(io, { workspaces: me.workspaces, selectedWorkspaceId: selected, source });
54
+ }
55
+ if (me.workspaces.length === 0) {
56
+ io.out.write('No workspaces.\n');
57
+ return;
58
+ }
59
+ let anyOrg = false;
60
+ for (const w of me.workspaces) {
61
+ const org = (w as { centralAccountId?: string | null }).centralAccountId ?? null;
62
+ if (org) anyOrg = true;
63
+ const flags = [w.default ? '(default)' : null, w.suspended ? '[suspended]' : null]
64
+ .filter(Boolean)
65
+ .join(' ');
66
+ io.out.write(
67
+ `${w.id === selected ? '*' : ' '} ${w.id} ${w.role.padEnd(6)} ${w.name}${flags ? ` ${flags}` : ''}` +
68
+ `${org ? ` organization ${org}` : ''}\n`
69
+ );
70
+ }
71
+ const chosenBy = !selection
72
+ ? 'the server (nothing is selected; --org or --workspace selects one per command)'
73
+ : selected === null
74
+ ? `${describeSelection(selection)}; none is marked, the selection names none of these`
75
+ : describeSelection(selection);
76
+ io.out.write(
77
+ `\n* = the workspace the commands run in, chosen by ${chosenBy}\n` +
78
+ (me.workspaces.some((w) => w.default)
79
+ ? '(default) = what a request naming no workspace resolves to\n'
80
+ : '') +
81
+ (anyOrg ? 'organization = the Antasphere id --org takes (or its name)\n' : '')
82
+ );
83
+ });
84
+
85
+ const workspace = program
86
+ .command('workspace')
87
+ .description('Your default workspace: what the server uses when a command names none');
88
+
89
+ workspace
90
+ .command('default [workspace]')
91
+ .description('Choose the workspace (id or name) the server uses when a command names none')
92
+ .option('--clear', 'remove the choice (back to the workspace you joined first)', false)
93
+ .action(async (value: string | undefined, opts: { clear: boolean }, cmd: Command) => {
94
+ if ((value !== undefined) === opts.clear) {
95
+ throw new CliUsageError('Pass exactly one of <workspace> (an id or a name) or --clear.');
96
+ }
97
+ const ctx = resolveContext(cmd, io);
98
+ await requireApiKey(ctx, { workspace: false });
99
+ const target = opts.clear ? null : matchWorkspace((await ctx.client.me()).workspaces, value!);
100
+ try {
101
+ await ctx.client.setDefaultWorkspace(target?.id ?? null);
102
+ } catch (e) {
103
+ if (e instanceof PlatformApiError && e.code === 'hub_managed') {
104
+ const url = (e.details as { manageUrl?: unknown } | undefined)?.manageUrl;
105
+ throw new CliApiRefusal(
106
+ 'Your default workspace is a setting of your Antasphere account' +
107
+ (typeof url === 'string' && url ? `: choose it on ${url}` : '.') +
108
+ '\nTo run one command elsewhere, pass --org <organization id or name>.',
109
+ e.status
110
+ );
111
+ }
112
+ if (e instanceof PlatformApiError && e.code === 'key_pinned') {
113
+ throw new CliApiRefusal(
114
+ 'This key is pinned to one workspace and cannot change your default workspace. Use a key that is not pinned.',
115
+ e.status
116
+ );
117
+ }
118
+ throw e;
119
+ }
120
+ if (ctx.json) {
121
+ return printJson(io, {
122
+ defaultWorkspaceId: target?.id ?? null,
123
+ ...(target ? { workspace: { id: target.id, name: target.name, role: target.role } } : {})
124
+ });
125
+ }
126
+ io.out.write(
127
+ target
128
+ ? `"${target.name}" (${target.id}) is now your default workspace: a command naming none runs in it.\n`
129
+ : 'No default workspace is chosen: a command naming none runs in the workspace you joined first.\n'
130
+ );
131
+ // A selection still wins over the server's default; say so rather than
132
+ // let the next command land somewhere else in silence.
133
+ const selection = ctx.workspaceSelection;
134
+ if (selection && (!target || findWorkspace([target], selection.value).match === null)) {
135
+ io.err.write(
136
+ `Note: ${describeSelection(selection)} selects another workspace and wins over the default while it is set.\n`
137
+ );
138
+ }
139
+ });
140
+ }
package/src/config.ts ADDED
@@ -0,0 +1,81 @@
1
+ import {
2
+ clearConfig as coreClearConfig,
3
+ configPath as coreConfigPath,
4
+ loadConfig as coreLoadConfig,
5
+ migrateLegacyConfig,
6
+ removeConnectKey as coreRemoveConnectKey,
7
+ saveConfig as coreSaveConfig,
8
+ type CliConfig,
9
+ type CliEnv
10
+ } from '@antasphere/cli-core';
11
+ import type { CliIdentity } from './identity.js';
12
+
13
+ /**
14
+ * The tool CLI's profiles live in the tool's own namespace (`identity.tool`)
15
+ * of the shared Antasphere config home owned by @antasphere/cli-core:
16
+ * `$XDG_CONFIG_HOME/antasphere/tools/<tool>.json` (default
17
+ * `~/.config/antasphere/tools/<tool>.json`), 0700 dirs / 0600 file, fully
18
+ * env-injected. The store's behavior (multi-profile, activeProfile, tolerant
19
+ * parsing) is byte-for-byte the old local one — cli-core was extracted from
20
+ * it.
21
+ *
22
+ * Migration: the pre-cli-core config lived at
23
+ * `$XDG_CONFIG_HOME/<legacyConfigDir>/config.json`. On any config read, if that file
24
+ * still exists, holds at least one profile, and the shared home has no
25
+ * profiles of the tool yet, it is imported once, NON-destructively (the legacy
26
+ * file stays in place for older CLI builds; it just stops being read).
27
+ */
28
+
29
+ export { redactKey } from '@antasphere/cli-core';
30
+ export type { CliConfig, CliConnectKey, CliEnv, CliProfile } from '@antasphere/cli-core';
31
+
32
+ /** The config functions of one tool (its namespace, its legacy directory). */
33
+ export interface CliConfigStore {
34
+ configPath(env: CliEnv): string | null;
35
+ loadConfig(env: CliEnv): CliConfig;
36
+ saveConfig(env: CliEnv, config: CliConfig): string;
37
+ clearConfig(env: CliEnv): void;
38
+ removeConnectKey(env: CliEnv, profileName: string, hubProfileName: string): boolean;
39
+ }
40
+
41
+ export function createConfig(identity: CliIdentity): CliConfigStore {
42
+ const TOOL = identity.tool;
43
+ const LEGACY = { tool: TOOL, legacyDir: identity.legacyConfigDir };
44
+
45
+ /** Where the config lives for this environment; null when no home is known. */
46
+ function configPath(env: CliEnv): string | null {
47
+ return coreConfigPath(env, TOOL);
48
+ }
49
+
50
+ /** Load the config (after the one-shot legacy import); corrupt/missing = empty. */
51
+ function loadConfig(env: CliEnv): CliConfig {
52
+ try {
53
+ migrateLegacyConfig(env, LEGACY);
54
+ } catch {
55
+ // A failed import must never break a read — worst case the user signs
56
+ // in again; the legacy file is untouched either way.
57
+ }
58
+ return coreLoadConfig(env, TOOL);
59
+ }
60
+
61
+ /** Persist the config (0700 dirs / 0600 file). Throws when no home is known. */
62
+ function saveConfig(env: CliEnv, config: CliConfig): string {
63
+ return coreSaveConfig(env, TOOL, config);
64
+ }
65
+
66
+ /** Delete the config file (config clear). No-op when absent. */
67
+ function clearConfig(env: CliEnv): void {
68
+ coreClearConfig(env, TOOL);
69
+ }
70
+
71
+ /**
72
+ * Drop one hub profile's cached connect key from a profile (the hub-connect
73
+ * logout — cli-core config.ts `connectKeys`; the key is USER-scoped, one per
74
+ * hub profile, valid for every org).
75
+ */
76
+ function removeConnectKey(env: CliEnv, profileName: string, hubProfileName: string): boolean {
77
+ return coreRemoveConnectKey(env, TOOL, profileName, hubProfileName);
78
+ }
79
+
80
+ return { configPath, loadConfig, saveConfig, clearConfig, removeConnectKey };
81
+ }