@functional-systems/lambdadb-cli 0.1.6 → 0.1.7-dev.24

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 (49) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/CONTRIBUTING.md +29 -0
  3. package/DESIGN.md +9 -3
  4. package/ONBOARDING.md +417 -0
  5. package/README.md +390 -138
  6. package/dist/account.d.ts +50 -0
  7. package/dist/account.js +301 -0
  8. package/dist/account.js.map +1 -0
  9. package/dist/agent-config.d.ts +4 -3
  10. package/dist/agent-config.js +12 -5
  11. package/dist/agent-config.js.map +1 -1
  12. package/dist/agents.d.ts +3 -3
  13. package/dist/agents.js +8 -8
  14. package/dist/agents.js.map +1 -1
  15. package/dist/cli.js +44 -6
  16. package/dist/cli.js.map +1 -1
  17. package/dist/config.d.ts +10 -4
  18. package/dist/config.js +43 -6
  19. package/dist/config.js.map +1 -1
  20. package/dist/credentials.d.ts +29 -0
  21. package/dist/credentials.js +116 -0
  22. package/dist/credentials.js.map +1 -0
  23. package/dist/device-auth.d.ts +16 -0
  24. package/dist/device-auth.js +160 -0
  25. package/dist/device-auth.js.map +1 -0
  26. package/dist/errors.d.ts +7 -0
  27. package/dist/errors.js +35 -1
  28. package/dist/errors.js.map +1 -1
  29. package/dist/init.d.ts +5 -2
  30. package/dist/init.js +50 -31
  31. package/dist/init.js.map +1 -1
  32. package/dist/mcp.js +8 -1
  33. package/dist/mcp.js.map +1 -1
  34. package/dist/projects.d.ts +29 -0
  35. package/dist/projects.js +323 -0
  36. package/dist/projects.js.map +1 -0
  37. package/package.json +7 -2
  38. package/skills/lambdadb/SKILL.md +9 -6
  39. package/src/account.ts +351 -0
  40. package/src/agent-config.ts +14 -8
  41. package/src/agents.ts +8 -8
  42. package/src/cli.ts +46 -6
  43. package/src/config.ts +43 -10
  44. package/src/credentials.ts +138 -0
  45. package/src/device-auth.ts +200 -0
  46. package/src/errors.ts +31 -2
  47. package/src/init.ts +42 -29
  48. package/src/mcp.ts +7 -1
  49. package/src/projects.ts +439 -0
@@ -0,0 +1,439 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import {
4
+ accountPath,
5
+ loadAccount,
6
+ login,
7
+ management,
8
+ parseOrganization,
9
+ readOptional,
10
+ withManagementLock,
11
+ writeMetadata,
12
+ type AccountOptions,
13
+ type Organization,
14
+ } from './account.js';
15
+ import { configPath, endpoint, fileIdentity, saveSettings, type SelectedSettings } from './config.js';
16
+ import { CredentialVault, credentialReference } from './credentials.js';
17
+ import { openBrowser, projectAuthorization } from './device-auth.js';
18
+ import { InputError, ManagementError } from './errors.js';
19
+ import type { Output } from './output.js';
20
+
21
+ export type Project = {
22
+ id: string;
23
+ name: string;
24
+ status: string;
25
+ organization_id: string;
26
+ base_url?: string;
27
+ };
28
+ export function uuid(value: string | undefined, label: string): string {
29
+ if (!value || !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value))
30
+ throw new InputError(`${label} must be an explicit UUID. Run orgs list or projects list to find it.`);
31
+ return value;
32
+ }
33
+ export async function organizations(options: AccountOptions, output: Output): Promise<Organization[]> {
34
+ const api = await management(options, output),
35
+ response = await api.request('organizations');
36
+ if (!Array.isArray(response.organizations)) throw new InputError('Invalid Organization list response.');
37
+ return response.organizations.map((v) => parseOrganization(v, api.account));
38
+ }
39
+ export async function projects(options: AccountOptions, org: string, output: Output): Promise<Project[]> {
40
+ uuid(org, 'Organization');
41
+ return listProjects(await management(options, output), org);
42
+ }
43
+ async function listProjects(api: Awaited<ReturnType<typeof management>>, org: string): Promise<Project[]> {
44
+ const response = await api.request('projects', 'GET', undefined, org);
45
+ if (!Array.isArray(response.projects)) throw new InputError('Invalid project list response.');
46
+ return response.projects.map((value) => {
47
+ const p = value as Project;
48
+ uuid(p.id, 'Project');
49
+ if (
50
+ typeof p.name !== 'string' ||
51
+ !/^[a-zA-Z0-9_-]+$/.test(p.name) ||
52
+ typeof p.status !== 'string' ||
53
+ p.organization_id !== org
54
+ )
55
+ throw new InputError('Invalid project identity in response.');
56
+ return {
57
+ id: p.id,
58
+ name: p.name,
59
+ status: p.status,
60
+ organization_id: p.organization_id,
61
+ ...(typeof p.base_url === 'string' ? { base_url: endpoint(p.base_url) } : {}),
62
+ };
63
+ });
64
+ }
65
+ export async function createProject(
66
+ options: AccountOptions,
67
+ input: { org?: string; defaultOrg?: boolean; name: string; region: string },
68
+ output: Output,
69
+ ) {
70
+ if (!!input.org === !!input.defaultOrg)
71
+ throw new InputError('Choose exactly one of --org UUID or --default-org.');
72
+ if (input.org) uuid(input.org, 'Organization');
73
+ if (!/^[a-zA-Z0-9_-]{3,45}$/.test(input.name) || !input.region)
74
+ throw new InputError(
75
+ 'Project name must contain 3–45 letters, digits, underscores or hyphens; --region is required.',
76
+ );
77
+ const api = await management(options, output);
78
+ const result = await api.request(
79
+ 'projects',
80
+ 'POST',
81
+ {
82
+ name: input.name,
83
+ cloud_provider: 'aws',
84
+ region: input.region,
85
+ default_organization: !!input.defaultOrg,
86
+ },
87
+ input.org,
88
+ );
89
+ try {
90
+ uuid(result.id as string, 'Created project');
91
+ uuid(result.organization_id as string, 'Organization');
92
+ if (typeof result.status !== 'string') throw new Error();
93
+ } catch {
94
+ throw new ManagementError('write_outcome_unknown', undefined);
95
+ }
96
+ return {
97
+ id: result.id as string,
98
+ organization_id: result.organization_id as string,
99
+ name: input.name,
100
+ status: result.status as string,
101
+ };
102
+ }
103
+ type SavedConnection = {
104
+ account: string;
105
+ org: string;
106
+ projectId: string;
107
+ settings?: SelectedSettings;
108
+ pendingKeyName?: string;
109
+ };
110
+ const connectionsPath = async (options: AccountOptions) =>
111
+ `${await fileIdentity(configPath(options))}.connections.json`;
112
+ async function connections(options: AccountOptions) {
113
+ const path = await connectionsPath(options),
114
+ before = await readOptional(path);
115
+ let items: SavedConnection[] = [];
116
+ if (before) {
117
+ try {
118
+ items = JSON.parse(before);
119
+ if (!Array.isArray(items)) throw new Error();
120
+ for (const item of items) {
121
+ if (
122
+ Object.keys(item).some(
123
+ (k) => !['account', 'org', 'projectId', 'settings', 'pendingKeyName'].includes(k),
124
+ ) ||
125
+ typeof item.account !== 'string'
126
+ )
127
+ throw new Error();
128
+ uuid(item.org, 'Organization');
129
+ uuid(item.projectId, 'Project');
130
+ if (
131
+ item.pendingKeyName !== undefined &&
132
+ (typeof item.pendingKeyName !== 'string' ||
133
+ !/^lambdadb-cli-[0-9a-f-]{36}$/.test(item.pendingKeyName))
134
+ )
135
+ throw new Error();
136
+ if (item.settings) {
137
+ const s = item.settings;
138
+ credentialReference(s.credentialStore, s.credentialId);
139
+ endpoint(s.endpoint);
140
+ if (
141
+ Object.keys(s).some(
142
+ (k) =>
143
+ ![
144
+ 'endpoint',
145
+ 'project',
146
+ 'apiKeyEnv',
147
+ 'credentialStore',
148
+ 'credentialId',
149
+ 'credentialKeyId',
150
+ 'credentialKeyName',
151
+ ].includes(k),
152
+ ) ||
153
+ !/^[a-zA-Z0-9_-]+$/.test(s.project)
154
+ )
155
+ throw new Error();
156
+ }
157
+ }
158
+ } catch {
159
+ throw new InputError(
160
+ 'Invalid saved project connections. Restore the file; never store keys in metadata.',
161
+ );
162
+ }
163
+ }
164
+ return { path, before, items };
165
+ }
166
+ export async function useProject(
167
+ options: AccountOptions,
168
+ org: string,
169
+ projectId: string,
170
+ issueKey: boolean,
171
+ output: Output,
172
+ vault = new CredentialVault(),
173
+ client?: Awaited<ReturnType<typeof management>>,
174
+ ) {
175
+ uuid(org, 'Organization');
176
+ uuid(projectId, 'Project');
177
+ return withManagementLock(options, async () => {
178
+ const api = client ?? (await management(options, output));
179
+ const project = (await listProjects(api, org)).find((p) => p.id === projectId);
180
+ const orgResponse = await api.request('organizations');
181
+ const orgs = Array.isArray(orgResponse.organizations)
182
+ ? orgResponse.organizations.map((v) => parseOrganization(v, api.account))
183
+ : [];
184
+ const organization = orgs.find((o) => o.id === org);
185
+ if (!organization) throw new InputError('The Organization is not available to this account.');
186
+ if (!project) throw new InputError('The project is not available in this Organization.');
187
+ if (project.status !== 'ACTIVE')
188
+ throw new InputError(
189
+ `Project is ${project.status}. Resume with projects use after it is ACTIVE; do not create a replacement.`,
190
+ );
191
+ const cache = await connections(options),
192
+ account = `${api.account.apiUrl}/${api.account.memberId}`;
193
+ let item = cache.items.find((c) => c.account === account && c.org === org && c.projectId === projectId);
194
+ const original = configPath(options),
195
+ destination = await fileIdentity(original),
196
+ before = await readOptional(destination);
197
+ output.diagnostic(`CLI settings: ${destination}; saved project references: ${cache.path}.`);
198
+ if (item?.pendingKeyName)
199
+ output.diagnostic(
200
+ `Previous delivery was uncertain: inspect/revoke ${item.pendingKeyName} in Console before replacing it.`,
201
+ );
202
+ let selected = item?.settings;
203
+ if (!selected || issueKey) {
204
+ if (organization.role === 'member')
205
+ throw new InputError(
206
+ 'Only an Organization owner or admin can issue a project key. Use a saved connection or obtain a key from your admin for environment setup.',
207
+ );
208
+ if (!issueKey)
209
+ throw new InputError(
210
+ `No reusable connection. ${item?.pendingKeyName ? `Inspect/revoke ${item.pendingKeyName} in Console first. ` : ''}Run projects use --org ${org} --project-id ${projectId} --issue-key to explicitly issue a project key.`,
211
+ );
212
+ const store = await vault.select(options.credentialStore ?? api.account.credentialStore);
213
+ output.diagnostic(
214
+ `Project credential store: ${store}. The key retains existing API permissions, including writes; MCP write-tool visibility is configured separately.`,
215
+ );
216
+ if (store === 'keyutils')
217
+ output.diagnostic(
218
+ 'Kernel credentials can expire or disappear after reboot. Metadata is retained; replacement requires explicit key issuance.',
219
+ );
220
+ const requestId = randomUUID(),
221
+ keyName = `lambdadb-cli-${requestId}`;
222
+ output.diagnostic(
223
+ `Issuing ${keyName}. If delivery fails, inspect it in Console; no request will be retried automatically.`,
224
+ );
225
+ if (!item) {
226
+ item = { account, org, projectId };
227
+ cache.items.push(item);
228
+ }
229
+ item.pendingKeyName = keyName;
230
+ await writeMetadata(cache.path, cache.items, cache.before);
231
+ const pending = await readOptional(cache.path);
232
+ const response = await api.request('connect', 'POST', {
233
+ organization_id: org,
234
+ project_id: projectId,
235
+ request_id: requestId,
236
+ });
237
+ let granted: ReturnType<typeof projectAuthorization>;
238
+ try {
239
+ granted = projectAuthorization(response, output, keyName);
240
+ if (granted.project !== project.name) throw new Error();
241
+ } catch {
242
+ throw new ManagementError('issuance_unconfirmed', undefined, { pendingKeyName: keyName });
243
+ }
244
+ selected = {
245
+ endpoint: granted.endpoint,
246
+ project: granted.project,
247
+ apiKeyEnv: 'LAMBDADB_API_KEY',
248
+ credentialStore: store,
249
+ credentialId: randomUUID(),
250
+ credentialKeyId: granted.credentialKeyId,
251
+ credentialKeyName: keyName,
252
+ };
253
+ const target = { ...selected, ...credentialReference(selected.credentialStore, selected.credentialId) };
254
+ await vault.save(target, granted.apiKey);
255
+ try {
256
+ item.settings = selected;
257
+ delete item.pendingKeyName;
258
+ await writeMetadata(cache.path, cache.items, pending);
259
+ } catch (error) {
260
+ await vault.remove(target).catch(() => {});
261
+ throw error;
262
+ }
263
+ } else {
264
+ if (selected.project !== project.name)
265
+ throw new InputError('Saved project identity changed. Reconnect explicitly.');
266
+ await vault.read({
267
+ ...selected,
268
+ ...credentialReference(selected.credentialStore, selected.credentialId),
269
+ });
270
+ }
271
+ if ((await fileIdentity(original)) !== destination || (await readOptional(destination)) !== before)
272
+ throw new InputError(
273
+ 'CLI settings changed. The connection is cached; rerun projects use without --issue-key.',
274
+ );
275
+ await saveSettings({ ...options, ...selected, config: destination });
276
+ output.diagnostic(
277
+ 'CLI project selected. Existing agent entries remain pinned; rerun init to update their connection.',
278
+ );
279
+ return selected;
280
+ });
281
+ }
282
+
283
+ type Ask = (label: string, fallback?: string) => Promise<string | undefined>;
284
+ async function choose<T>(items: T[], label: (item: T) => string, ask: Ask, prompt: string): Promise<T> {
285
+ if (!items.length) throw new InputError('No choices are available.');
286
+ // eslint-disable-next-line no-control-regex -- Keep item names from adding fake choices or terminal escapes.
287
+ const labels = items.map((item, i) => `${i + 1}. ${label(item).replace(/[\x00-\x1f\x7f]/g, ' ')}`);
288
+ const answer = await ask(`${prompt}\n${labels.join('\n')}\nChoose a number`, '1');
289
+ const index = Number(answer) - 1;
290
+ if (!Number.isInteger(index) || index < 0 || index >= items.length)
291
+ throw new InputError('Choose one of the listed numbers.');
292
+ const choice = items[index];
293
+ if (choice === undefined) throw new InputError('Choose one of the listed numbers.');
294
+ return choice;
295
+ }
296
+ export async function terminalSetup(options: AccountOptions, output: Output, ask: Ask) {
297
+ if (!(await readOptional(await accountPath(options)))) await login(options, output);
298
+ else {
299
+ try {
300
+ await loadAccount(options);
301
+ } catch {
302
+ if ((await ask('Login is unavailable or expired. Authorize login again? Type yes', 'no')) !== 'yes')
303
+ throw new InputError('Run login to renew account access.');
304
+ await login(options, output);
305
+ }
306
+ }
307
+ let orgs = await organizations(options, output);
308
+ let org = orgs.length
309
+ ? await choose(
310
+ orgs,
311
+ (o) => `${o.name} (${o.role}, ${o.plan}, projects ${o.projects_count}/${o.max_projects})`,
312
+ ask,
313
+ 'Organization',
314
+ )
315
+ : undefined;
316
+ let available = org ? await projects(options, org.id, output) : [];
317
+ let project: Project | undefined;
318
+ for (;;) {
319
+ const atLimit = org && org.projects_count >= org.max_projects;
320
+ const action = await ask(
321
+ atLimit && org
322
+ ? `Project limit reached (${org.projects_count}/${org.max_projects}). Enter use, billing, or cancel`
323
+ : `Enter use for an existing project${available.length ? '' : ' (none yet)'}, create, or cancel`,
324
+ available.length ? 'use' : atLimit ? 'billing' : 'create',
325
+ );
326
+ if (action === 'cancel')
327
+ throw new InputError('Setup canceled. Saved login and project connections remain available.');
328
+ if (action === 'billing' && org) {
329
+ output.diagnostic(
330
+ `${org.can_upgrade ? 'Review your plan or request a limit increase' : 'Ask the Organization owner to activate billing'}: ${org.billing_url}`,
331
+ );
332
+ if (org.can_upgrade && (await ask('Open billing in your browser? Type yes', 'no')) === 'yes')
333
+ openBrowser(org.billing_url, output);
334
+ if (
335
+ (await ask('After billing is updated, type retry to recheck limits, or cancel', 'cancel')) !== 'retry'
336
+ )
337
+ throw new InputError('Rerun init --connect when ready. No project was created.');
338
+ const id = org.id;
339
+ orgs = await organizations(options, output);
340
+ org = orgs.find((o) => o.id === id);
341
+ if (!org)
342
+ throw new InputError('Organization access changed. Rerun init to select another Organization.');
343
+ available = await projects(options, org.id, output);
344
+ continue;
345
+ }
346
+ if (action === 'use') {
347
+ project = await choose(available, (p) => `${p.name} (${p.status})`, ask, 'Project');
348
+ break;
349
+ }
350
+ if (action !== 'create' || atLimit) throw new InputError('Choose one of the displayed actions.');
351
+ if (org?.role === 'member')
352
+ throw new InputError(
353
+ 'An owner or admin must connect the new project. Choose an existing saved connection or ask your admin for a project key.',
354
+ );
355
+ const api = await management(options, output),
356
+ response = await api.request('regions');
357
+ const regions = response.regions as {
358
+ region: string;
359
+ name: string;
360
+ is_open: boolean;
361
+ cloud_provider: string;
362
+ }[];
363
+ if (!Array.isArray(regions)) throw new InputError('Invalid region list.');
364
+ const region = await choose(
365
+ regions.filter((r) => r.is_open && r.cloud_provider === 'aws'),
366
+ (r) => `${r.name} (${r.region})`,
367
+ ask,
368
+ 'Region',
369
+ );
370
+ const name = await ask('Project name');
371
+ output.diagnostic(
372
+ `Create ${name ?? ''} in ${org?.name ?? 'your default Organization'} (${region.region}).`,
373
+ );
374
+ if ((await ask('Create this project? Type yes', 'no')) !== 'yes')
375
+ throw new InputError('No project was requested.');
376
+ const created = await createProject(
377
+ options,
378
+ { org: org?.id, defaultOrg: !org, name: name ?? '', region: region.region },
379
+ output,
380
+ );
381
+ project = created;
382
+ const deadline = Date.now() + 10 * 60_000;
383
+ while (project.status !== 'ACTIVE' && Date.now() < deadline) {
384
+ output.diagnostic(
385
+ 'Project is provisioning. Waiting; interruption is safe. Rerun init to select this project.',
386
+ );
387
+ await delay(5000, undefined, { signal: options.signal });
388
+ const found = (await projects(options, created.organization_id, output)).find(
389
+ (p) => p.id === created.id,
390
+ );
391
+ if (!found)
392
+ throw new InputError('Created project was not listed. Check Console before creating another.');
393
+ project = found;
394
+ }
395
+ if (project.status !== 'ACTIVE')
396
+ throw new InputError(
397
+ 'Provisioning is still pending. Rerun init --connect later and select the existing project.',
398
+ );
399
+ break;
400
+ }
401
+ const a = await loadAccount(options),
402
+ cache = await connections(options);
403
+ const saved = cache.items.find(
404
+ (c) =>
405
+ c.account === `${a.apiUrl}/${a.memberId}` &&
406
+ c.projectId === project.id &&
407
+ c.org === project.organization_id,
408
+ );
409
+ let issue = !saved?.settings;
410
+ if (saved?.settings) {
411
+ try {
412
+ await new CredentialVault().read({
413
+ ...saved.settings,
414
+ ...credentialReference(saved.settings.credentialStore, saved.settings.credentialId),
415
+ });
416
+ } catch {
417
+ output.diagnostic(
418
+ 'The saved project credential is unavailable. Review its named key in Console before replacement.',
419
+ );
420
+ issue = true;
421
+ }
422
+ }
423
+ if (issue) {
424
+ if (org?.role === 'member')
425
+ throw new InputError(
426
+ 'An owner or admin must issue this project key. Obtain a key from your admin for environment setup.',
427
+ );
428
+ if (saved?.pendingKeyName)
429
+ output.diagnostic(
430
+ `Inspect/revoke ${saved.pendingKeyName} in Console before replacing the uncertain connection.`,
431
+ );
432
+ if (
433
+ (await ask('Issue a new project key and store it in your OS credential store? Type yes', 'no')) !==
434
+ 'yes'
435
+ )
436
+ throw new InputError('No project key was requested.');
437
+ }
438
+ return useProject(options, project.organization_id, project.id, issue, output);
439
+ }