@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.
- package/dist/chunk-5UCSEIJS.js +64 -0
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +534 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +335 -0
- package/dist/schema/pg/index.d.ts +562 -0
- package/dist/schema/pg/index.js +62 -0
- package/dist/schema/sqlite/index.d.ts +604 -0
- package/dist/schema/sqlite/index.js +12 -0
- package/package.json +37 -0
- package/src/__tests__/cli-env.test.ts +158 -0
- package/src/__tests__/cli-org.test.ts +154 -0
- package/src/__tests__/cli-proj.test.ts +157 -0
- package/src/__tests__/cli-ws.test.ts +156 -0
- package/src/__tests__/helpers.ts +29 -0
- package/src/cli.ts +682 -0
- package/src/index.ts +5 -0
- package/src/operations/bootstrap.ts +50 -0
- package/src/repo/environments.ts +82 -0
- package/src/repo/index.ts +9 -0
- package/src/repo/organizations.ts +96 -0
- package/src/repo/projects.ts +106 -0
- package/src/repo/workspaces.ts +87 -0
- package/src/schema/environments.ts +14 -0
- package/src/schema/index.ts +5 -0
- package/src/schema/organizations.ts +11 -0
- package/src/schema/pg/environments.ts +14 -0
- package/src/schema/pg/index.ts +4 -0
- package/src/schema/pg/organizations.ts +11 -0
- package/src/schema/pg/projects.ts +16 -0
- package/src/schema/pg/workspaces.ts +15 -0
- package/src/schema/projects.ts +16 -0
- package/src/schema/sqlite/environments.ts +14 -0
- package/src/schema/sqlite/index.ts +4 -0
- package/src/schema/sqlite/organizations.ts +11 -0
- package/src/schema/sqlite/projects.ts +16 -0
- package/src/schema/sqlite/workspaces.ts +15 -0
- package/src/schema/workspaces.ts +15 -0
- 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
|
+
}
|