@baseworks/organization 0.2.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 (39) hide show
  1. package/dist/chunk-5UCSEIJS.js +64 -0
  2. package/dist/cli.d.ts +18 -0
  3. package/dist/cli.js +534 -0
  4. package/dist/index.d.ts +199 -0
  5. package/dist/index.js +335 -0
  6. package/dist/schema/pg/index.d.ts +562 -0
  7. package/dist/schema/pg/index.js +62 -0
  8. package/dist/schema/sqlite/index.d.ts +604 -0
  9. package/dist/schema/sqlite/index.js +12 -0
  10. package/package.json +37 -0
  11. package/src/__tests__/cli-env.test.ts +158 -0
  12. package/src/__tests__/cli-org.test.ts +154 -0
  13. package/src/__tests__/cli-proj.test.ts +157 -0
  14. package/src/__tests__/cli-ws.test.ts +156 -0
  15. package/src/__tests__/helpers.ts +29 -0
  16. package/src/cli.ts +682 -0
  17. package/src/index.ts +5 -0
  18. package/src/operations/bootstrap.ts +50 -0
  19. package/src/repo/environments.ts +82 -0
  20. package/src/repo/index.ts +9 -0
  21. package/src/repo/organizations.ts +96 -0
  22. package/src/repo/projects.ts +106 -0
  23. package/src/repo/workspaces.ts +87 -0
  24. package/src/schema/environments.ts +14 -0
  25. package/src/schema/index.ts +5 -0
  26. package/src/schema/organizations.ts +11 -0
  27. package/src/schema/pg/environments.ts +14 -0
  28. package/src/schema/pg/index.ts +4 -0
  29. package/src/schema/pg/organizations.ts +11 -0
  30. package/src/schema/pg/projects.ts +16 -0
  31. package/src/schema/pg/workspaces.ts +15 -0
  32. package/src/schema/projects.ts +16 -0
  33. package/src/schema/sqlite/environments.ts +14 -0
  34. package/src/schema/sqlite/index.ts +4 -0
  35. package/src/schema/sqlite/organizations.ts +11 -0
  36. package/src/schema/sqlite/projects.ts +16 -0
  37. package/src/schema/sqlite/workspaces.ts +15 -0
  38. package/src/schema/workspaces.ts +15 -0
  39. package/src/types.ts +88 -0
package/src/cli.ts ADDED
@@ -0,0 +1,682 @@
1
+ import { Command } from 'commander';
2
+ import { clr, kv, table, summary, success, warn, fatal, printOutput, outputOption, prompt } from '@baseworks/cli/display';
3
+ import { fmtDate } from '@baseworks/cli/fmt';
4
+ import { tree } from '@baseworks/cli/tree';
5
+ import type { ColDef, TableOpts } from '@baseworks/cli/display';
6
+ import type { TreeNode } from '@baseworks/cli/tree';
7
+ import type { ApiClient } from '@baseworks/cli/client';
8
+ import type { ContextManager } from '@baseworks/cli/context';
9
+
10
+ // ─── Types ────────────────────────────────────────────────────────────────────
11
+
12
+ export interface OrgCliDeps {
13
+ http: ApiClient;
14
+ ctx: ContextManager<Record<string, string | undefined>>;
15
+ appBase: string;
16
+ cliName?: string;
17
+ }
18
+
19
+ type OrgRow = { id: string; shortId: string; slug: string; name: string; createdAt?: number };
20
+ type OrgMeta = OrgRow & { metadata?: Record<string, unknown> };
21
+ type WsRow = { id: string; shortId: string; slug: string; name: string; isDefault?: boolean };
22
+ type ProjRow = { id: string; shortId: string; slug: string; name: string; isDefault?: boolean };
23
+ type EnvRow = { id: string; shortId: string; slug: string; name: string; isDefault?: boolean };
24
+
25
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
26
+
27
+ function activeOrgSlug(ctx: OrgCliDeps['ctx'], cliName: string, flagOrg?: string): string {
28
+ return flagOrg
29
+ ?? ctx.getContext()?.['org']
30
+ ?? (ctx.loadConfig() as Record<string, unknown>)['activeOrg'] as string | undefined
31
+ ?? fatal(`No active org. Run: ${cliName} use <org>`);
32
+ }
33
+
34
+ function activeWsSlug(ctx: OrgCliDeps['ctx'], cliName: string, flagWs?: string): string {
35
+ return flagWs
36
+ ?? ctx.getContext()?.['workspace']
37
+ ?? fatal(`No active workspace. Run: ${cliName} use <org>/<ws>`);
38
+ }
39
+
40
+ function activeProjSlug(ctx: OrgCliDeps['ctx'], cliName: string, flagProj?: string): string {
41
+ return flagProj
42
+ ?? ctx.getContext()?.['project']
43
+ ?? fatal(`No active project. Run: ${cliName} use <org>/<ws>/<proj>`);
44
+ }
45
+
46
+ // ─── buildOrgCommand ──────────────────────────────────────────────────────────
47
+ // Mounts as: program.addCommand(buildOrgCommand(deps))
48
+ // Usage: flect org → list
49
+ // flect org create → create
50
+ // flect org get <slug>
51
+ // flect org update <slug>
52
+ // flect org delete <slug>
53
+ // flect org meta ...
54
+
55
+ export function buildOrgCommand(deps: OrgCliDeps): Command {
56
+ const { http } = deps;
57
+ const cli = deps.cliName ?? 'cli';
58
+
59
+ const cmd = new Command('org')
60
+ .description('Organization management')
61
+ .option(...outputOption())
62
+ .action(async (opts: { output: string }) => {
63
+ const res = await http.get<{ orgs: OrgMeta[] }>('/v1/orgs').catch(e => { console.error(e.message); process.exit(1); });
64
+ printOutput(res.orgs, opts.output, (wide) => {
65
+ table(res.orgs, [
66
+ { key: 'shortId', label: 'ID' },
67
+ { key: 'slug', label: 'SLUG' },
68
+ { key: 'name', label: 'NAME' },
69
+ { key: 'createdAt', label: 'CREATED', wide: true, fmt: v => fmtDate(v as number) },
70
+ { key: 'id', label: 'FULL ID', wide: true },
71
+ ] as ColDef<OrgMeta>[], { wide, emptyHint: `No orgs yet. Run: ${cli} org create --name <n>` });
72
+ summary(`${res.orgs.length} org${res.orgs.length !== 1 ? 's' : ''}`);
73
+ }, 'slug');
74
+ });
75
+
76
+ cmd.addCommand(
77
+ new Command('create')
78
+ .description('Create a new organization')
79
+ .requiredOption('--name <name>', 'Display name')
80
+ .option('--slug <slug>', 'URL slug (auto-generated if omitted)')
81
+ .action(async (opts: { name: string; slug?: string }) => {
82
+ const res = await http.post<{ org: OrgRow }>('/v1/orgs', { name: opts.name, slug: opts.slug }).catch(e => { console.error(e.message); process.exit(1); });
83
+ success(`Org created: ${res.org.slug}`);
84
+ kv([['id', res.org.shortId], ['slug', res.org.slug]]);
85
+ }),
86
+ );
87
+
88
+ cmd.addCommand(
89
+ new Command('get').argument('<slug>')
90
+ .description('Get org details')
91
+ .option(...outputOption())
92
+ .action(async (slug: string, opts: { output: string }) => {
93
+ const res = await http.get<{ org: OrgMeta }>(`/v1/orgs/${slug}`).catch(e => { console.error(e.message); process.exit(1); });
94
+ const meta = res.org.metadata ?? {};
95
+ printOutput(res.org as unknown as Record<string, unknown>, opts.output, () => {
96
+ kv([['id', res.org.shortId], ['slug', res.org.slug], ['name', res.org.name], ['full_id', res.org.id]]);
97
+ if (Object.keys(meta).length) {
98
+ console.log(` ${clr.dim}── metadata ──${clr.reset}`);
99
+ kv(Object.entries(meta).map(([k, v]) => [k, JSON.stringify(v)] as [string, string]));
100
+ }
101
+ }, 'slug');
102
+ }),
103
+ );
104
+
105
+ cmd.addCommand(
106
+ new Command('update').argument('<slug>')
107
+ .description('Update org name or slug')
108
+ .option('--name <name>', 'New display name')
109
+ .option('--slug <new-slug>', 'New URL slug')
110
+ .action(async (slug: string, opts: { name?: string; slug?: string }) => {
111
+ if (!opts.name && !opts.slug) { warn('Provide at least --name or --slug'); return; }
112
+ const body: Record<string, string> = {};
113
+ if (opts.name) body['name'] = opts.name;
114
+ if (opts.slug) body['slug'] = opts.slug;
115
+ const res = await http.patch<{ org: OrgRow }>(`/v1/orgs/${slug}`, body).catch(e => { console.error(e.message); process.exit(1); });
116
+ success('Org updated.');
117
+ kv([['id', res.org.shortId], ['slug', res.org.slug], ['name', res.org.name]]);
118
+ }),
119
+ );
120
+
121
+ cmd.addCommand(
122
+ new Command('delete').argument('<slug>')
123
+ .description('Delete an organization')
124
+ .action(async (slug: string) => {
125
+ await http.del(`/v1/orgs/${slug}`).catch(e => { console.error(e.message); process.exit(1); });
126
+ success(`Org deleted: ${slug}`);
127
+ }),
128
+ );
129
+
130
+ // org meta sub-group
131
+ const meta = new Command('meta').description('Manage org metadata');
132
+
133
+ meta.addCommand(
134
+ new Command('get').argument('<slug>').argument('[key]')
135
+ .description('Show metadata (all or one key)')
136
+ .action(async (slug: string, key?: string) => {
137
+ const res = await http.get<{ org: OrgMeta }>(`/v1/orgs/${slug}`).catch(e => { console.error(e.message); process.exit(1); });
138
+ const m = res.org.metadata ?? {};
139
+ if (key) {
140
+ const val = m[key];
141
+ if (val === undefined) warn(`Key '${key}' not found.`);
142
+ else console.log(` ${clr.dim}${key}${clr.reset} ${JSON.stringify(val)}`);
143
+ } else if (!Object.keys(m).length) {
144
+ console.log(` ${clr.dim}(no metadata)${clr.reset}`);
145
+ } else {
146
+ kv(Object.entries(m).map(([k, v]) => [k, JSON.stringify(v)] as [string, string]));
147
+ }
148
+ }),
149
+ );
150
+
151
+ meta.addCommand(
152
+ new Command('set').argument('<slug>').argument('<key>').argument('<value>')
153
+ .description('Set a metadata key (JSON or string)')
154
+ .action(async (slug: string, key: string, value: string) => {
155
+ let parsed: unknown;
156
+ try { parsed = JSON.parse(value); } catch { parsed = value; }
157
+ await http.patch(`/v1/orgs/${slug}/metadata`, { [key]: parsed }).catch(e => { console.error(e.message); process.exit(1); });
158
+ success(`Metadata key '${key}' set.`);
159
+ }),
160
+ );
161
+
162
+ meta.addCommand(
163
+ new Command('delete-key').argument('<slug>').argument('<key>')
164
+ .description('Remove a metadata key')
165
+ .action(async (slug: string, key: string) => {
166
+ await http.del(`/v1/orgs/${slug}/metadata/${key}`).catch(e => { console.error(e.message); process.exit(1); });
167
+ success(`Metadata key '${key}' deleted.`);
168
+ }),
169
+ );
170
+
171
+ cmd.addCommand(meta);
172
+ return cmd;
173
+ }
174
+
175
+ // ─── buildWsCommand ───────────────────────────────────────────────────────────
176
+ // Usage: flect ws → list (requires active org)
177
+ // flect ws create
178
+ // flect ws delete <slug>
179
+
180
+ export function buildWsCommand(deps: OrgCliDeps): Command {
181
+ const { http, ctx } = deps;
182
+ const cli = deps.cliName ?? 'cli';
183
+
184
+ const cmd = new Command('ws')
185
+ .alias('workspace')
186
+ .description('Workspace management')
187
+ .enablePositionalOptions()
188
+ .option('--org <slug>', 'Override active org')
189
+ .option(...outputOption())
190
+ .action(async (opts: { org?: string; output: string }) => {
191
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
192
+ const res = await http.get<{ workspaces: WsRow[] }>(`/v1/orgs/${orgSlug}/workspaces`).catch(e => { console.error(e.message); process.exit(1); });
193
+ printOutput(res.workspaces, opts.output, (wide) => {
194
+ table(res.workspaces, [
195
+ { key: 'shortId', label: 'ID' },
196
+ { key: 'slug', label: 'SLUG' },
197
+ { key: 'name', label: 'NAME' },
198
+ { key: 'isDefault', label: 'DEFAULT', fmt: v => v ? '✓' : '' },
199
+ { key: 'id', label: 'FULL ID', wide: true },
200
+ ] as ColDef<WsRow>[], { wide, emptyHint: `No workspaces yet. Run: ${cli} ws create --name <n>` } as TableOpts & { wide: boolean });
201
+ summary(`${res.workspaces.length} workspace${res.workspaces.length !== 1 ? 's' : ''}`);
202
+ }, 'slug');
203
+ });
204
+
205
+ cmd.addCommand(
206
+ new Command('create')
207
+ .description('Create a workspace')
208
+ .requiredOption('--name <name>', 'Display name')
209
+ .option('--slug <slug>', 'URL slug (auto-generated if omitted)')
210
+ .option('--org <slug>', 'Override active org')
211
+ .action(async (opts: { name: string; slug?: string; org?: string }) => {
212
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
213
+ const res = await http.post<{ workspace: WsRow }>(`/v1/orgs/${orgSlug}/workspaces`, { name: opts.name, slug: opts.slug }).catch(e => { console.error(e.message); process.exit(1); });
214
+ success(`Workspace created: ${res.workspace.slug}`);
215
+ kv([['id', res.workspace.shortId], ['slug', res.workspace.slug]]);
216
+ }),
217
+ );
218
+
219
+ cmd.addCommand(
220
+ new Command('get').argument('<ref>')
221
+ .description('Get workspace details')
222
+ .option('--org <slug>', 'Override active org')
223
+ .option(...outputOption())
224
+ .action(async (ref: string, opts: { org?: string; output: string }) => {
225
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
226
+ const res = await http.get<{ workspace: WsRow }>(`/v1/orgs/${orgSlug}/workspaces/${ref}`).catch(e => { console.error(e.message); process.exit(1); });
227
+ printOutput(res.workspace as unknown as Record<string, unknown>, opts.output, () => {
228
+ kv([['id', res.workspace.shortId], ['slug', res.workspace.slug], ['name', res.workspace.name], ['full_id', res.workspace.id]]);
229
+ }, 'slug');
230
+ }),
231
+ );
232
+
233
+ cmd.addCommand(
234
+ new Command('update').argument('<ref>')
235
+ .description('Update a workspace')
236
+ .option('--name <name>', 'New display name')
237
+ .option('--slug <new-slug>', 'New URL slug')
238
+ .option('--org <slug>', 'Override active org')
239
+ .action(async (ref: string, opts: { name?: string; slug?: string; org?: string }) => {
240
+ if (!opts.name && !opts.slug) { warn('Provide at least --name or --slug'); return; }
241
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
242
+ const body: Record<string, string> = {};
243
+ if (opts.name) body['name'] = opts.name;
244
+ if (opts.slug) body['slug'] = opts.slug;
245
+ const res = await http.patch<{ workspace: WsRow }>(`/v1/orgs/${orgSlug}/workspaces/${ref}`, body).catch(e => { console.error(e.message); process.exit(1); });
246
+ success('Workspace updated.');
247
+ kv([['id', res.workspace.shortId], ['slug', res.workspace.slug], ['name', res.workspace.name]]);
248
+ }),
249
+ );
250
+
251
+ cmd.addCommand(
252
+ new Command('delete').argument('<ref>')
253
+ .description('Delete a workspace')
254
+ .option('--org <slug>', 'Override active org')
255
+ .action(async (ref: string, opts: { org?: string }) => {
256
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
257
+ await http.del(`/v1/orgs/${orgSlug}/workspaces/${ref}`).catch(e => { console.error(e.message); process.exit(1); });
258
+ success(`Workspace deleted: ${ref}`);
259
+ }),
260
+ );
261
+
262
+ return cmd;
263
+ }
264
+
265
+ // ─── buildProjCommand ─────────────────────────────────────────────────────────
266
+ // Usage: flect proj → list (requires active org + ws)
267
+ // flect proj create
268
+ // flect proj delete <slug>
269
+
270
+ export function buildProjCommand(deps: OrgCliDeps): Command {
271
+ const { http, ctx } = deps;
272
+ const cli = deps.cliName ?? 'cli';
273
+
274
+ const cmd = new Command('proj')
275
+ .alias('project')
276
+ .description('Project management')
277
+ .enablePositionalOptions()
278
+ .option('--org <slug>', 'Override active org')
279
+ .option('--ws <slug>', 'Override active workspace')
280
+ .option(...outputOption())
281
+ .action(async (opts: { org?: string; ws?: string; output: string }) => {
282
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
283
+ const wsSlug = activeWsSlug(ctx, cli, opts.ws);
284
+ const res = await http.get<{ projects: ProjRow[] }>(`/v1/orgs/${orgSlug}/workspaces/${wsSlug}/projects`).catch(e => { console.error(e.message); process.exit(1); });
285
+ printOutput(res.projects, opts.output, (wide) => {
286
+ table(res.projects, [
287
+ { key: 'shortId', label: 'ID' },
288
+ { key: 'slug', label: 'SLUG' },
289
+ { key: 'name', label: 'NAME' },
290
+ { key: 'isDefault', label: 'DEFAULT', fmt: v => v ? '✓' : '' },
291
+ { key: 'id', label: 'FULL ID', wide: true },
292
+ ] as ColDef<ProjRow>[], { wide, emptyHint: `No projects yet. Run: ${cli} proj create --name <n>` } as TableOpts & { wide: boolean });
293
+ summary(`${res.projects.length} project${res.projects.length !== 1 ? 's' : ''}`);
294
+ }, 'slug');
295
+ });
296
+
297
+ cmd.addCommand(
298
+ new Command('create')
299
+ .description('Create a project')
300
+ .requiredOption('--name <name>', 'Display name')
301
+ .option('--slug <slug>', 'URL slug (auto-generated if omitted)')
302
+ .option('--org <slug>', 'Override active org')
303
+ .option('--ws <slug>', 'Override active workspace')
304
+ .action(async (opts: { name: string; slug?: string; org?: string; ws?: string }) => {
305
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
306
+ const wsSlug = activeWsSlug(ctx, cli, opts.ws);
307
+ const res = await http.post<{ project: ProjRow }>(`/v1/orgs/${orgSlug}/workspaces/${wsSlug}/projects`, { name: opts.name, slug: opts.slug }).catch(e => { console.error(e.message); process.exit(1); });
308
+ success(`Project created: ${res.project.slug}`);
309
+ kv([['id', res.project.shortId], ['slug', res.project.slug]]);
310
+ }),
311
+ );
312
+
313
+ cmd.addCommand(
314
+ new Command('get').argument('<ref>')
315
+ .description('Get project details')
316
+ .option('--org <slug>', 'Override active org')
317
+ .option('--ws <slug>', 'Override active workspace')
318
+ .option(...outputOption())
319
+ .action(async (ref: string, opts: { org?: string; ws?: string; output: string }) => {
320
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
321
+ const wsSlug = activeWsSlug(ctx, cli, opts.ws);
322
+ const res = await http.get<{ project: ProjRow }>(`/v1/orgs/${orgSlug}/workspaces/${wsSlug}/projects/${ref}`).catch(e => { console.error(e.message); process.exit(1); });
323
+ printOutput(res.project as unknown as Record<string, unknown>, opts.output, () => {
324
+ kv([['id', res.project.shortId], ['slug', res.project.slug], ['name', res.project.name], ['full_id', res.project.id]]);
325
+ }, 'slug');
326
+ }),
327
+ );
328
+
329
+ cmd.addCommand(
330
+ new Command('update').argument('<ref>')
331
+ .description('Update a project')
332
+ .option('--name <name>', 'New display name')
333
+ .option('--slug <new-slug>', 'New URL slug')
334
+ .option('--org <slug>', 'Override active org')
335
+ .option('--ws <slug>', 'Override active workspace')
336
+ .action(async (ref: string, opts: { name?: string; slug?: string; org?: string; ws?: string }) => {
337
+ if (!opts.name && !opts.slug) { warn('Provide at least --name or --slug'); return; }
338
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
339
+ const wsSlug = activeWsSlug(ctx, cli, opts.ws);
340
+ const body: Record<string, string> = {};
341
+ if (opts.name) body['name'] = opts.name;
342
+ if (opts.slug) body['slug'] = opts.slug;
343
+ const res = await http.patch<{ project: ProjRow }>(`/v1/orgs/${orgSlug}/workspaces/${wsSlug}/projects/${ref}`, body).catch(e => { console.error(e.message); process.exit(1); });
344
+ success('Project updated.');
345
+ kv([['id', res.project.shortId], ['slug', res.project.slug], ['name', res.project.name]]);
346
+ }),
347
+ );
348
+
349
+ cmd.addCommand(
350
+ new Command('delete').argument('<ref>')
351
+ .description('Delete a project')
352
+ .option('--org <slug>', 'Override active org')
353
+ .option('--ws <slug>', 'Override active workspace')
354
+ .action(async (ref: string, opts: { org?: string; ws?: string }) => {
355
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
356
+ const wsSlug = activeWsSlug(ctx, cli, opts.ws);
357
+ await http.del(`/v1/orgs/${orgSlug}/workspaces/${wsSlug}/projects/${ref}`).catch(e => { console.error(e.message); process.exit(1); });
358
+ success(`Project deleted: ${ref}`);
359
+ }),
360
+ );
361
+
362
+ return cmd;
363
+ }
364
+
365
+ // ─── buildEnvCommand ──────────────────────────────────────────────────────────
366
+ // Usage: flect env → list (requires active org + ws + proj)
367
+ // flect env create
368
+ // flect env get <ref>
369
+ // flect env update <ref>
370
+ // flect env delete <ref>
371
+
372
+ export function buildEnvCommand(deps: OrgCliDeps): Command {
373
+ const { http, ctx } = deps;
374
+ const cli = deps.cliName ?? 'cli';
375
+
376
+ function basePath(orgSlug: string, wsSlug: string, projSlug: string) {
377
+ return `/v1/orgs/${orgSlug}/workspaces/${wsSlug}/projects/${projSlug}/envs`;
378
+ }
379
+
380
+ const cmd = new Command('env')
381
+ .description('Environment management')
382
+ .enablePositionalOptions()
383
+ .option('--org <slug>', 'Override active org')
384
+ .option('--ws <slug>', 'Override active workspace')
385
+ .option('--proj <slug>', 'Override active project')
386
+ .option(...outputOption())
387
+ .action(async (opts: { org?: string; ws?: string; proj?: string; output: string }) => {
388
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
389
+ const wsSlug = activeWsSlug(ctx, cli, opts.ws);
390
+ const projSlug = activeProjSlug(ctx, cli, opts.proj);
391
+ const res = await http.get<{ envs: EnvRow[] }>(basePath(orgSlug, wsSlug, projSlug)).catch(e => { console.error(e.message); process.exit(1); });
392
+ printOutput(res.envs, opts.output, (wide) => {
393
+ table(res.envs, [
394
+ { key: 'shortId', label: 'ID' },
395
+ { key: 'slug', label: 'SLUG' },
396
+ { key: 'name', label: 'NAME' },
397
+ { key: 'isDefault', label: 'DEFAULT', fmt: v => v ? '✓' : '' },
398
+ { key: 'id', label: 'FULL ID', wide: true },
399
+ ] as ColDef<EnvRow>[], { wide, emptyHint: `No envs yet. Run: ${cli} env create --name <n>` } as TableOpts & { wide: boolean });
400
+ summary(`${res.envs.length} env${res.envs.length !== 1 ? 's' : ''}`);
401
+ }, 'slug');
402
+ });
403
+
404
+ cmd.addCommand(
405
+ new Command('create')
406
+ .description('Create an environment')
407
+ .requiredOption('--name <name>', 'Display name')
408
+ .option('--slug <slug>', 'URL slug (auto-generated if omitted)')
409
+ .option('--org <slug>', 'Override active org')
410
+ .option('--ws <slug>', 'Override active workspace')
411
+ .option('--proj <slug>', 'Override active project')
412
+ .action(async (opts: { name: string; slug?: string; org?: string; ws?: string; proj?: string }) => {
413
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
414
+ const wsSlug = activeWsSlug(ctx, cli, opts.ws);
415
+ const projSlug = activeProjSlug(ctx, cli, opts.proj);
416
+ const res = await http.post<{ env: EnvRow }>(basePath(orgSlug, wsSlug, projSlug), { name: opts.name, slug: opts.slug }).catch(e => { console.error(e.message); process.exit(1); });
417
+ success(`Env created: ${res.env.slug}`);
418
+ kv([['id', res.env.shortId], ['slug', res.env.slug]]);
419
+ }),
420
+ );
421
+
422
+ cmd.addCommand(
423
+ new Command('get').argument('<ref>')
424
+ .description('Get environment details')
425
+ .option('--org <slug>', 'Override active org')
426
+ .option('--ws <slug>', 'Override active workspace')
427
+ .option('--proj <slug>', 'Override active project')
428
+ .option(...outputOption())
429
+ .action(async (ref: string, opts: { org?: string; ws?: string; proj?: string; output: string }) => {
430
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
431
+ const wsSlug = activeWsSlug(ctx, cli, opts.ws);
432
+ const projSlug = activeProjSlug(ctx, cli, opts.proj);
433
+ const res = await http.get<{ env: EnvRow }>(`${basePath(orgSlug, wsSlug, projSlug)}/${ref}`).catch(e => { console.error(e.message); process.exit(1); });
434
+ printOutput(res.env as unknown as Record<string, unknown>, opts.output, () => {
435
+ kv([['id', res.env.shortId], ['slug', res.env.slug], ['name', res.env.name], ['full_id', res.env.id]]);
436
+ }, 'slug');
437
+ }),
438
+ );
439
+
440
+ cmd.addCommand(
441
+ new Command('update').argument('<ref>')
442
+ .description('Update an environment')
443
+ .option('--name <name>', 'New display name')
444
+ .option('--slug <new-slug>', 'New URL slug')
445
+ .option('--org <slug>', 'Override active org')
446
+ .option('--ws <slug>', 'Override active workspace')
447
+ .option('--proj <slug>', 'Override active project')
448
+ .action(async (ref: string, opts: { name?: string; slug?: string; org?: string; ws?: string; proj?: string }) => {
449
+ if (!opts.name && !opts.slug) { warn('Provide at least --name or --slug'); return; }
450
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
451
+ const wsSlug = activeWsSlug(ctx, cli, opts.ws);
452
+ const projSlug = activeProjSlug(ctx, cli, opts.proj);
453
+ const body: Record<string, string> = {};
454
+ if (opts.name) body['name'] = opts.name;
455
+ if (opts.slug) body['slug'] = opts.slug;
456
+ const res = await http.patch<{ env: EnvRow }>(`${basePath(orgSlug, wsSlug, projSlug)}/${ref}`, body).catch(e => { console.error(e.message); process.exit(1); });
457
+ success('Env updated.');
458
+ kv([['id', res.env.shortId], ['slug', res.env.slug], ['name', res.env.name]]);
459
+ }),
460
+ );
461
+
462
+ cmd.addCommand(
463
+ new Command('delete').argument('<ref>')
464
+ .description('Delete an environment')
465
+ .option('--org <slug>', 'Override active org')
466
+ .option('--ws <slug>', 'Override active workspace')
467
+ .option('--proj <slug>', 'Override active project')
468
+ .action(async (ref: string, opts: { org?: string; ws?: string; proj?: string }) => {
469
+ const orgSlug = activeOrgSlug(ctx, cli, opts.org);
470
+ const wsSlug = activeWsSlug(ctx, cli, opts.ws);
471
+ const projSlug = activeProjSlug(ctx, cli, opts.proj);
472
+ await http.del(`${basePath(orgSlug, wsSlug, projSlug)}/${ref}`).catch(e => { console.error(e.message); process.exit(1); });
473
+ success(`Env deleted: ${ref}`);
474
+ }),
475
+ );
476
+
477
+ return cmd;
478
+ }
479
+
480
+ // ─── addGlobalCommands ────────────────────────────────────────────────────────
481
+ // Adds use / ls / ps directly onto the root program.
482
+ // Usage: flect use [org/ws/proj]
483
+ // flect ls
484
+ // flect ps
485
+
486
+ export function addGlobalCommands(program: Command, deps: OrgCliDeps): void {
487
+ const { http, ctx } = deps;
488
+ const cli = deps.cliName ?? 'cli';
489
+
490
+ program.addCommand(
491
+ new Command('use')
492
+ .alias('context')
493
+ .description('Set active org / workspace / project (interactive if no arg)')
494
+ .argument('[path]', 'org or org/ws or org/ws/proj')
495
+ .action(async (path?: string) => {
496
+ await runUseCommand(http, ctx, path);
497
+ }),
498
+ );
499
+
500
+ program.addCommand(
501
+ new Command('ls')
502
+ .description('Tree view of all orgs, workspaces, and projects')
503
+ .action(async () => {
504
+ const ctxData = ctx.getContext() as Record<string, string | undefined> | undefined;
505
+ const activeOrg = ctxData?.['org'];
506
+ const activeWs = ctxData?.['workspace'];
507
+ const activeProj = ctxData?.['project'];
508
+
509
+ const { orgs } = await http.get<{ orgs: OrgRow[] }>('/v1/orgs').catch(e => { console.error(e.message); process.exit(1); });
510
+
511
+ let totalWs = 0;
512
+ let totalProj = 0;
513
+
514
+ const nodes: TreeNode[] = await Promise.all(
515
+ orgs.map(async (org) => {
516
+ const { workspaces } = await http.get<{ workspaces: WsRow[] }>(`/v1/orgs/${org.slug}/workspaces`);
517
+ totalWs += workspaces.length;
518
+
519
+ const wsNodes: TreeNode[] = await Promise.all(
520
+ workspaces.map(async (ws) => {
521
+ const { projects } = await http.get<{ projects: ProjRow[] }>(`/v1/orgs/${org.slug}/workspaces/${ws.slug}/projects`);
522
+ totalProj += projects.length;
523
+ const isActiveWs = org.slug === activeOrg && ws.slug === activeWs;
524
+ return {
525
+ label: isActiveWs ? clr.bold + ws.slug + clr.reset : ws.slug,
526
+ badge: isActiveWs ? clr.cyan + '●' + clr.reset : undefined,
527
+ children: projects.map(p => {
528
+ const isActiveProj = isActiveWs && p.slug === activeProj;
529
+ return {
530
+ label: isActiveProj ? clr.bold + p.slug + clr.reset : p.slug,
531
+ badge: isActiveProj ? clr.green + '●' + clr.reset : undefined,
532
+ };
533
+ }),
534
+ };
535
+ }),
536
+ );
537
+
538
+ const isActiveOrg = org.slug === activeOrg;
539
+ return {
540
+ label: isActiveOrg ? clr.bold + org.slug + clr.reset : org.slug,
541
+ badge: isActiveOrg ? clr.cyan + '★' + clr.reset : undefined,
542
+ meta: org.name && org.name !== org.slug ? org.name : undefined,
543
+ children: wsNodes,
544
+ };
545
+ }),
546
+ );
547
+
548
+ console.log();
549
+ tree(nodes);
550
+ console.log();
551
+ console.log(
552
+ ` ${clr.dim}${orgs.length} org${orgs.length !== 1 ? 's' : ''}` +
553
+ ` · ${totalWs} workspace${totalWs !== 1 ? 's' : ''}` +
554
+ ` · ${totalProj} project${totalProj !== 1 ? 's' : ''}` +
555
+ (activeOrg ? ` · active: ${clr.bold}${activeOrg}/${activeWs}/${activeProj}${clr.reset}${clr.dim}` : '') +
556
+ clr.reset,
557
+ );
558
+ console.log();
559
+ }),
560
+ );
561
+
562
+ program.addCommand(
563
+ new Command('ps')
564
+ .description('Show active session (context, token, api endpoint)')
565
+ .action(() => {
566
+ const ctxData = ctx.getContext() as Record<string, string | undefined> | undefined;
567
+ const cfg = ctx.loadConfig() as Record<string, unknown>;
568
+ const token = (cfg['token'] as string | undefined)
569
+ ?? Object.values((cfg['orgs'] as Record<string, { token?: string }> | undefined) ?? {})
570
+ .find(e => e?.token)?.token;
571
+ const apiBase = (cfg['apiBase'] as string | undefined) ?? deps.appBase;
572
+
573
+ console.log();
574
+ if (ctxData?.['org']) {
575
+ console.log(
576
+ ` ${clr.dim}context${clr.reset} ` +
577
+ `${clr.bold}${ctxData['org']}${clr.reset}` +
578
+ ` ${clr.dim}/${clr.reset} ${ctxData['workspace'] ?? '—'}` +
579
+ ` ${clr.dim}/${clr.reset} ${ctxData['project'] ?? '—'}` +
580
+ ` ${clr.green}● active${clr.reset}`,
581
+ );
582
+ } else {
583
+ console.log(` ${clr.dim}context${clr.reset} ${clr.yellow}not set${clr.reset} ${clr.dim}(run: ${cli} use)${clr.reset}`);
584
+ }
585
+ if (token) {
586
+ console.log(` ${clr.dim}token ${clr.reset} ${clr.cyan}${token.slice(0, 14)}…${clr.reset}`);
587
+ } else {
588
+ console.log(` ${clr.dim}token ${clr.reset} ${clr.yellow}none${clr.reset} ${clr.dim}(run: ${cli} login)${clr.reset}`);
589
+ }
590
+ console.log(` ${clr.dim}api ${clr.reset} ${clr.dim}${apiBase}${clr.reset}`);
591
+ console.log(` ${clr.dim}config ${clr.reset} ${clr.dim}${ctx.configPath}${clr.reset}`);
592
+ console.log();
593
+ }),
594
+ );
595
+ }
596
+
597
+ // ─── orgCommand (backward compat) ────────────────────────────────────────────
598
+
599
+ export function orgCommand(deps: OrgCliDeps): Command {
600
+ const root = new Command('org').description('Org, workspace, and project management');
601
+ addGlobalCommands(root, deps);
602
+ root.addCommand(buildOrgCommand(deps));
603
+ root.addCommand(buildWsCommand(deps));
604
+ root.addCommand(buildProjCommand(deps));
605
+ return root;
606
+ }
607
+
608
+ // ─── use command implementation ───────────────────────────────────────────────
609
+
610
+ async function runUseCommand(
611
+ http: ApiClient,
612
+ ctx: ContextManager<Record<string, string | undefined>>,
613
+ path?: string,
614
+ ): Promise<void> {
615
+ async function pick<T extends { id: string; slug: string; name?: string }>(label: string, items: T[]): Promise<T> {
616
+ if (!items.length) fatal(`No ${label}s found.`);
617
+ if (items.length === 1) return items[0]!;
618
+ console.log(`\nSelect ${label}:`);
619
+ items.forEach((it, i) => console.log(` ${clr.cyan}${i + 1}${clr.reset} ${it.slug}${it.name ? ` ${clr.dim}${it.name}${clr.reset}` : ''}`));
620
+ const ans = await prompt(`${label} [1-${items.length}]: `);
621
+ const n = parseInt(ans, 10);
622
+ if (!n || n < 1 || n > items.length) fatal('Invalid selection.');
623
+ return items[n - 1]!;
624
+ }
625
+
626
+ const parts = (path ?? '').split('/').map(s => s.trim()).filter(Boolean);
627
+ const orgHint = parts[0];
628
+ const wsHint = parts[1];
629
+ const projHint = parts[2];
630
+
631
+ let orgSlug: string;
632
+ if (orgHint) {
633
+ orgSlug = orgHint;
634
+ } else {
635
+ const { orgs } = await http.get<{ orgs: OrgRow[] }>('/v1/orgs').catch(e => { console.error(e.message); process.exit(1); });
636
+ const org = await pick('org', orgs);
637
+ orgSlug = org.slug;
638
+ }
639
+
640
+ const orgRes = await http.get<{ org: OrgRow }>(`/v1/orgs/${orgSlug}`)
641
+ .catch(() => fatal(`Org not found: ${orgSlug}`));
642
+
643
+ if (parts.length <= 1 && orgHint) {
644
+ const cfg = ctx.loadConfig() as Record<string, unknown>;
645
+ ctx.saveConfig({ ...cfg, activeOrg: orgRes.org.slug, context: { org: orgRes.org.slug, orgId: orgRes.org.id } } as never);
646
+ success('Active org set.');
647
+ kv([['org', orgRes.org.slug]]);
648
+ return;
649
+ }
650
+
651
+ const { workspaces } = await http.get<{ workspaces: WsRow[] }>(`/v1/orgs/${orgSlug}/workspaces`).catch(e => { console.error(e.message); process.exit(1); });
652
+ const ws = wsHint
653
+ ? (workspaces.find(w => w.slug === wsHint) ?? fatal(`Workspace not found: ${wsHint}`))
654
+ : (workspaces.length === 1 ? workspaces[0]! : await pick('workspace', workspaces));
655
+
656
+ if (parts.length === 2) {
657
+ const cfg = ctx.loadConfig() as Record<string, unknown>;
658
+ ctx.saveConfig({ ...cfg, activeOrg: orgRes.org.slug } as never);
659
+ ctx.setContext({ org: orgRes.org.slug, orgId: orgRes.org.id, workspace: ws!.slug, workspaceId: ws!.id });
660
+ success('Active context set.');
661
+ kv([['org', orgRes.org.slug], ['workspace', ws!.slug]]);
662
+ return;
663
+ }
664
+
665
+ const projRes = await http.get<{ projects: ProjRow[] }>(`/v1/orgs/${orgSlug}/workspaces/${ws!.slug}/projects`).catch(e => { console.error(e.message); process.exit(1); });
666
+ const { projects } = projRes!;
667
+ const proj = projHint
668
+ ? (projects.find(p => p.slug === projHint) ?? fatal(`Project not found: ${projHint}`))
669
+ : (projects.length === 1 ? projects[0]! : await pick('project', projects));
670
+
671
+ ctx.setContext({
672
+ org: orgRes.org.slug,
673
+ orgId: orgRes.org.id,
674
+ workspace: ws!.slug,
675
+ workspaceId: ws!.id,
676
+ project: proj!.slug,
677
+ projectId: proj!.id,
678
+ });
679
+
680
+ success('Active context set.');
681
+ kv([['org', orgRes.org.slug], ['workspace', ws!.slug], ['project', proj!.slug]]);
682
+ }