@functional-systems/lambdadb-cli 0.1.7-dev.23 → 0.1.7-dev.25

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 (50) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/CONTRIBUTING.md +29 -0
  3. package/DESIGN.md +9 -3
  4. package/ONBOARDING.md +417 -0
  5. package/README.md +401 -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 +58 -33
  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 +6 -2
  38. package/skills/lambdadb/SKILL.md +43 -24
  39. package/skills/lambdadb/references/usage.md +98 -0
  40. package/src/account.ts +351 -0
  41. package/src/agent-config.ts +14 -8
  42. package/src/agents.ts +8 -8
  43. package/src/cli.ts +46 -6
  44. package/src/config.ts +43 -10
  45. package/src/credentials.ts +138 -0
  46. package/src/device-auth.ts +200 -0
  47. package/src/errors.ts +31 -2
  48. package/src/init.ts +50 -31
  49. package/src/mcp.ts +7 -1
  50. package/src/projects.ts +439 -0
package/src/account.ts ADDED
@@ -0,0 +1,351 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile, rename, rm, open } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ import { configPath, endpoint, fileIdentity, timeoutMs, type Options } from './config.js';
5
+ import { CredentialVault, credentialReference, type CredentialReference } from './credentials.js';
6
+ import { authorizeAccount } from './device-auth.js';
7
+ import { InputError, ManagementError } from './errors.js';
8
+ import type { Output } from './output.js';
9
+
10
+ export type AccountOptions = Options & { apiUrl?: string; browser?: boolean; signal?: AbortSignal };
11
+ export type Account = CredentialReference & {
12
+ apiUrl: string;
13
+ consoleUrl: string;
14
+ memberId: string;
15
+ email: string;
16
+ expiresAt: string;
17
+ };
18
+ export const accountVault = () => new CredentialVault(undefined, process.platform, 'account');
19
+ export const accountPath = async (options: Options) =>
20
+ `${await fileIdentity(configPath(options))}.account.json`;
21
+ export const accountTarget = (a: Account) => ({ ...a, endpoint: a.apiUrl, project: 'account' });
22
+
23
+ export async function readOptional(path: string): Promise<string | undefined> {
24
+ try {
25
+ return await readFile(path, 'utf8');
26
+ } catch (error) {
27
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
28
+ throw new InputError(`Cannot read ${path}. Check file permissions.`);
29
+ }
30
+ }
31
+ export async function writeMetadata(path: string, value: unknown, before: string | undefined) {
32
+ const destination = await fileIdentity(path);
33
+ if ((await readOptional(path)) !== before)
34
+ throw new InputError(`Configuration changed: ${path}. Retry after reviewing it.`);
35
+ const temp = `${destination}.${randomUUID()}.tmp`;
36
+ try {
37
+ await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
38
+ await writeFile(temp, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
39
+ await rename(temp, destination);
40
+ } finally {
41
+ await rm(temp, { force: true }).catch(() => {});
42
+ }
43
+ }
44
+ export async function withManagementLock<T>(options: Options, action: () => Promise<T>): Promise<T> {
45
+ const path = `${await fileIdentity(configPath(options))}.management.lock`;
46
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
47
+ let handle;
48
+ try {
49
+ handle = await open(path, 'wx', 0o600);
50
+ } catch {
51
+ throw new InputError(
52
+ `Another management operation may be running. After confirming it has stopped, remove ${path} and retry.`,
53
+ );
54
+ }
55
+ try {
56
+ await handle.writeFile(String(process.pid));
57
+ return await action();
58
+ } finally {
59
+ await handle.close();
60
+ await rm(path, { force: true });
61
+ }
62
+ }
63
+ function parseAccount(text: string): Account {
64
+ let a: Account;
65
+ try {
66
+ a = JSON.parse(text);
67
+ credentialReference(a.credentialStore, a.credentialId);
68
+ if (
69
+ Object.keys(a).some(
70
+ (k) =>
71
+ ![
72
+ 'apiUrl',
73
+ 'consoleUrl',
74
+ 'memberId',
75
+ 'email',
76
+ 'expiresAt',
77
+ 'credentialStore',
78
+ 'credentialId',
79
+ ].includes(k),
80
+ ) ||
81
+ typeof a.memberId !== 'string' ||
82
+ !/^[0-9a-f-]{36}$/.test(a.memberId) ||
83
+ typeof a.email !== 'string' ||
84
+ typeof a.expiresAt !== 'string' ||
85
+ !Number.isFinite(Date.parse(a.expiresAt))
86
+ )
87
+ throw new Error();
88
+ if (endpoint(a.apiUrl) !== a.apiUrl || endpoint(a.consoleUrl) !== a.consoleUrl) throw new Error();
89
+ } catch {
90
+ throw new InputError(
91
+ 'Invalid account metadata. Never store tokens in configuration; restore the file or run login.',
92
+ );
93
+ }
94
+ return a;
95
+ }
96
+ export async function loadAccount(options: AccountOptions): Promise<Account> {
97
+ const text = await readOptional(await accountPath(options));
98
+ if (!text)
99
+ throw new InputError(
100
+ 'Run lambdadb login first. Project-key environment setup remains available without account login.',
101
+ );
102
+ const a = parseAccount(text);
103
+ if (options.apiUrl && endpoint(options.apiUrl) !== a.apiUrl)
104
+ throw new InputError(
105
+ 'This login belongs to a different API origin. Run login with --api-url for that server.',
106
+ );
107
+ if (Date.parse(a.expiresAt) <= Date.now())
108
+ throw new InputError(
109
+ 'CLI login expired. Run lambdadb login again; existing project connections are preserved.',
110
+ );
111
+ return a;
112
+ }
113
+
114
+ export async function accountRequest(
115
+ account: Account,
116
+ token: string,
117
+ path: string,
118
+ method = 'GET',
119
+ body?: object,
120
+ org?: string,
121
+ signal?: AbortSignal,
122
+ requestTimeoutMs = 30_000,
123
+ ): Promise<Record<string, unknown>> {
124
+ let response: Response | undefined;
125
+ let value: Record<string, unknown>;
126
+ try {
127
+ response = await fetch(`${account.apiUrl}/auth/cli/${path}`, {
128
+ method,
129
+ redirect: 'error',
130
+ headers: {
131
+ Authorization: `Bearer ${token}`,
132
+ 'Content-Type': 'application/json',
133
+ ...(org ? { 'X-Organization-ID': org } : {}),
134
+ },
135
+ ...(body ? { body: JSON.stringify(body) } : {}),
136
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(requestTimeoutMs)]) : AbortSignal.timeout(requestTimeoutMs),
137
+ });
138
+ if (response.status === 204) return {};
139
+ const reader = response.body?.getReader();
140
+ if (!reader) throw new Error();
141
+ const chunks: Uint8Array[] = [];
142
+ let size = 0;
143
+ try {
144
+ for (;;) {
145
+ const next = await reader.read();
146
+ if (next.done) break;
147
+ size += next.value.length;
148
+ if (size > 2_000_000) throw new Error();
149
+ chunks.push(next.value);
150
+ }
151
+ } finally {
152
+ await reader.cancel().catch(() => {});
153
+ }
154
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
155
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
156
+ } catch {
157
+ // A rejection remains definite without a JSON body. A connect conflict may
158
+ // represent an already claimed issuance, whose result cannot be recovered.
159
+ const status = response?.status;
160
+ if (status !== undefined && status >= 400 && status < 500 && status !== 408 &&
161
+ !(path === 'connect' && status === 409)) {
162
+ throw new ManagementError('management_request_failed', status);
163
+ }
164
+ throw new ManagementError(
165
+ method === 'GET' ? 'management_unavailable' : 'write_outcome_unknown',
166
+ status,
167
+ );
168
+ }
169
+ if (!response.ok) {
170
+ const known = [
171
+ 'login_required',
172
+ 'access_denied',
173
+ 'project_limit_reached',
174
+ 'issuance_unconfirmed',
175
+ 'project_not_ready_or_forbidden',
176
+ 'project_request_rejected',
177
+ ];
178
+ const code =
179
+ method !== 'GET' && (response.status >= 500 || response.status === 408) && value.error !== 'issuance_unconfirmed'
180
+ ? 'write_outcome_unknown'
181
+ : typeof value.error === 'string' && known.includes(value.error)
182
+ ? value.error
183
+ : 'management_request_failed';
184
+ // Only a small allowlist of non-secret fields may reach diagnostics.
185
+ const details: Record<string, unknown> = {};
186
+ if (typeof value.invitation_id === 'string' && /^[0-9a-f-]{36}$/.test(value.invitation_id)) {
187
+ details.invitation_id = value.invitation_id;
188
+ details.invitation_url = `${account.consoleUrl}/invites/${value.invitation_id}`;
189
+ }
190
+ if (code === 'project_limit_reached')
191
+ details.organization = parseOrganization(value.organization, account);
192
+ throw new ManagementError(code, response.status, details);
193
+ }
194
+ return value;
195
+ }
196
+ export type Organization = {
197
+ id: string;
198
+ name: string;
199
+ role: string;
200
+ plan: string;
201
+ max_projects: number;
202
+ projects_count: number;
203
+ billing_url: string;
204
+ can_upgrade: boolean;
205
+ };
206
+ export function parseOrganization(value: unknown, a: Account): Organization {
207
+ const v = value as Organization;
208
+ if (
209
+ !v ||
210
+ typeof v.id !== 'string' ||
211
+ !/^[0-9a-f-]{36}$/.test(v.id) ||
212
+ typeof v.name !== 'string' ||
213
+ !['owner', 'admin', 'member'].includes(v.role) ||
214
+ typeof v.plan !== 'string' ||
215
+ !Number.isInteger(v.max_projects) ||
216
+ v.max_projects < 0 ||
217
+ !Number.isInteger(v.projects_count) ||
218
+ v.projects_count < 0
219
+ )
220
+ throw new InputError('Invalid Organization response. Check server compatibility.');
221
+ return {
222
+ id: v.id,
223
+ name: v.name,
224
+ role: v.role,
225
+ plan: v.plan,
226
+ max_projects: v.max_projects,
227
+ projects_count: v.projects_count,
228
+ billing_url: `${a.consoleUrl}/organization/billing/open?org_id=${encodeURIComponent(v.id)}`,
229
+ can_upgrade: v.role === 'owner',
230
+ };
231
+ }
232
+ export async function management(options: AccountOptions, output: Output) {
233
+ const requestTimeoutMs = timeoutMs(options);
234
+ const account = await loadAccount(options);
235
+ let token: string;
236
+ try {
237
+ token = await accountVault().read(accountTarget(account));
238
+ } catch {
239
+ throw new InputError(
240
+ 'Cannot read the CLI account session. Unlock the recorded OS store or run login explicitly. No account credential is forwarded to MCP.',
241
+ );
242
+ }
243
+ output.protect(token);
244
+ return {
245
+ account,
246
+ request: (path: string, method = 'GET', body?: object, org?: string) =>
247
+ accountRequest(account, token, path, method, body, org, options.signal, requestTimeoutMs),
248
+ };
249
+ }
250
+ export async function login(options: AccountOptions, output: Output, vault = accountVault()) {
251
+ const requestTimeoutMs = timeoutMs(options);
252
+ return withManagementLock(options, async () => {
253
+ const path = await accountPath(options),
254
+ before = await readOptional(path);
255
+ let previousOrigin: string | undefined;
256
+ if (before) {
257
+ try {
258
+ previousOrigin = parseAccount(before).apiUrl;
259
+ } catch {
260
+ /* Explicit login can replace malformed non-secret metadata. */
261
+ }
262
+ }
263
+ const apiUrl = endpoint(options.apiUrl ?? previousOrigin ?? 'https://api.lambdadb.ai');
264
+ const store = await vault.select(options.credentialStore ?? 'auto');
265
+ output.diagnostic(
266
+ `Account session: ${store}; non-secret metadata: ${path}. Login lasts up to seven days. Project credentials are separate.`,
267
+ );
268
+ if (store === 'keyutils')
269
+ output.diagnostic(
270
+ 'Kernel credentials can expire or disappear after reboot. Run login again explicitly if unavailable.',
271
+ );
272
+ const cancellation = new AbortController();
273
+ const cancel = () => cancellation.abort();
274
+ process.once('SIGINT', cancel);
275
+ process.once('SIGTERM', cancel);
276
+ try {
277
+ const granted = await authorizeAccount(
278
+ apiUrl,
279
+ options.browser !== false,
280
+ output,
281
+ options.signal ? AbortSignal.any([options.signal, cancellation.signal]) : cancellation.signal,
282
+ requestTimeoutMs,
283
+ );
284
+ const account: Account = {
285
+ apiUrl,
286
+ consoleUrl: granted.consoleUrl,
287
+ memberId: granted.memberId,
288
+ email: granted.email,
289
+ expiresAt: granted.expiresAt,
290
+ credentialStore: store,
291
+ credentialId: randomUUID(),
292
+ };
293
+ try {
294
+ await vault.save(accountTarget(account), granted.token);
295
+ await writeMetadata(path, account, before);
296
+ } catch (error) {
297
+ await accountRequest(account, granted.token, 'logout', 'POST', {}, undefined, options.signal, requestTimeoutMs).catch(() =>
298
+ output.diagnostic('Server-session cleanup could not be confirmed; it expires within seven days.'),
299
+ );
300
+ await vault.remove(accountTarget(account)).catch(() => {});
301
+ throw error;
302
+ }
303
+ if (before) {
304
+ // Retire only a parseable previous account slot after the replacement is durable.
305
+ try {
306
+ const old = parseAccount(before);
307
+ const token = await vault.read(accountTarget(old));
308
+ output.protect(token);
309
+ await revokeAccount(old, token, options);
310
+ await vault.remove(accountTarget(old));
311
+ } catch {
312
+ output.diagnostic(
313
+ 'Previous login cleanup could not be confirmed. It expires within seven days; project keys are unaffected.',
314
+ );
315
+ }
316
+ }
317
+ return account;
318
+ } finally {
319
+ process.removeListener('SIGINT', cancel);
320
+ process.removeListener('SIGTERM', cancel);
321
+ }
322
+ });
323
+ }
324
+ export async function logout(options: AccountOptions, output: Output, vault = accountVault()) {
325
+ timeoutMs(options);
326
+ return withManagementLock(options, async () => {
327
+ const path = await accountPath(options),
328
+ before = await readOptional(path);
329
+ if (!before) return;
330
+ // Expired sessions may still be removed locally, without opening a browser.
331
+ const account = parseAccount(before);
332
+ if (options.apiUrl && endpoint(options.apiUrl) !== account.apiUrl)
333
+ throw new InputError('The account belongs to another API origin.');
334
+ const token = await vault.read(accountTarget(account));
335
+ output.protect(token);
336
+ await revokeAccount(account, token, options);
337
+ await vault.remove(accountTarget(account));
338
+ if ((await readOptional(path)) !== before)
339
+ throw new InputError('Account metadata changed. Review it before removing it.');
340
+ await rm(await fileIdentity(path));
341
+ });
342
+ }
343
+
344
+ async function revokeAccount(account: Account, token: string, options: AccountOptions): Promise<void> {
345
+ try {
346
+ await accountRequest(account, token, 'logout', 'POST', {}, undefined, options.signal, timeoutMs(options));
347
+ } catch (error) {
348
+ // An expired or already revoked session is safe to remove locally.
349
+ if (!(error instanceof ManagementError && error.status === 401)) throw error;
350
+ }
351
+ }
@@ -1,9 +1,10 @@
1
1
  import { applyEdits, modify, parse as parseJson, type ParseError } from 'jsonc-parser';
2
2
  import { parse as parseToml } from 'smol-toml';
3
- import type { Settings } from './config.js';
3
+ import type { SelectedSettings } from './config.js';
4
+ import { credentialReference } from './credentials.js';
4
5
  import { InputError } from './errors.js';
5
6
 
6
- export type McpLaunch = { command: string; args: string[]; apiKeyEnv: string };
7
+ export type McpLaunch = { command: string; args: string[]; apiKeyEnv?: string; runtimeEnv?: string[] };
7
8
  type Server = { command: string | string[]; args?: string[]; env_vars?: string[];
8
9
  env?: Record<string, string>; environment?: Record<string, string>; type?: string };
9
10
  type ServerFactory = (launch: McpLaunch) => Server;
@@ -11,10 +12,13 @@ type JsonMapPath = readonly string[] | ((value: Record<string, unknown>) => read
11
12
  const begin = '# BEGIN lambdadb init';
12
13
  const end = '# END lambdadb init';
13
14
 
14
- export function mcpLaunch(selected: Required<Settings>, writeTools: boolean, cliVersion: string): McpLaunch {
15
- return { command: 'npx', apiKeyEnv: selected.apiKeyEnv,
15
+ export function mcpLaunch(selected: SelectedSettings, writeTools: boolean, cliVersion: string): McpLaunch {
16
+ const stored = selected.credentialStore ? credentialReference(selected.credentialStore, selected.credentialId) : undefined;
17
+ return { command: 'npx', apiKeyEnv: selected.credentialStore ? undefined : selected.apiKeyEnv,
18
+ runtimeEnv: selected.credentialStore === 'secret-service' ? ['DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR'] : [],
16
19
  args: ['-y', `@functional-systems/lambdadb-cli@${cliVersion}`, 'mcp',
17
- '--endpoint', selected.endpoint, '--project', selected.project, '--api-key-env', selected.apiKeyEnv,
20
+ '--endpoint', selected.endpoint, '--project', selected.project,
21
+ ...(stored ? ['--credential-store', stored.credentialStore, '--credential-id', stored.credentialId] : ['--api-key-env', selected.apiKeyEnv]),
18
22
  ...(writeTools ? ['--write-tools'] : [])] };
19
23
  }
20
24
 
@@ -30,9 +34,11 @@ function managedServer(value: unknown, createServer: ServerFactory): boolean {
30
34
  const a = Array.isArray(value.command) ? value.command.slice(1) : value.args;
31
35
  if (command !== 'npx' || !Array.isArray(a)) return false;
32
36
  if (a.some(v => typeof v !== 'string') || !/^@functional-systems\/lambdadb-cli@[\w.+-]+$/.test(a[1] ?? '')
33
- || a[0] !== '-y' || a[2] !== 'mcp' || a[3] !== '--endpoint' || a[5] !== '--project'
34
- || a[7] !== '--api-key-env' || ![9, 10].includes(a.length) || (a.length === 10 && a[9] !== '--write-tools')) return false;
35
- const expected = createServer({ command: 'npx', args: a, apiKeyEnv: a[8] });
37
+ || a[0] !== '-y' || a[2] !== 'mcp' || a[3] !== '--endpoint' || a[5] !== '--project') return false;
38
+ const stored = a[7] === '--credential-store' && ['keychain', 'windows', 'secret-service', 'keyutils'].includes(a[8]) && a[9] === '--credential-id';
39
+ const length = stored ? 11 : 9;
40
+ if ((!stored && a[7] !== '--api-key-env') || ![length, length + 1].includes(a.length) || (a.length > length && a[length] !== '--write-tools')) return false;
41
+ const expected = createServer({ command: 'npx', args: a, apiKeyEnv: stored ? undefined : a[8], runtimeEnv: stored && a[8] === 'secret-service' ? ['DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR'] : [] });
36
42
  return Object.keys(value).length === Object.keys(expected).length
37
43
  && Object.entries(expected).every(([key, item]) => JSON.stringify(value[key]) === JSON.stringify(item));
38
44
  }
package/src/agents.ts CHANGED
@@ -17,18 +17,18 @@ export type AgentAdapter = {
17
17
  instructions: string;
18
18
  };
19
19
 
20
- const codexServer = ({ command, args, apiKeyEnv }: McpLaunch) => ({ command, args, env_vars: [apiKeyEnv] });
20
+ const codexServer = ({ command, args, apiKeyEnv, runtimeEnv }: McpLaunch) => ({ command, args, env_vars: apiKeyEnv ? [apiKeyEnv] : runtimeEnv ?? [] });
21
21
  const claudeServer = ({ command, args, apiKeyEnv }: McpLaunch) => ({
22
- type: 'stdio', command, args, env: { [apiKeyEnv]: `\${${apiKeyEnv}}` },
22
+ type: 'stdio', command, args, env: apiKeyEnv ? { [apiKeyEnv]: `\${${apiKeyEnv}}` } : {},
23
23
  });
24
24
  const cursorServer = ({ command, args, apiKeyEnv }: McpLaunch) => ({
25
- type: 'stdio', command, args, env: { [apiKeyEnv]: `\${env:${apiKeyEnv}}` },
25
+ type: 'stdio', command, args, env: apiKeyEnv ? { [apiKeyEnv]: `\${env:${apiKeyEnv}}` } : {},
26
26
  });
27
27
  const geminiServer = ({ command, args, apiKeyEnv }: McpLaunch) => ({
28
- command, args, env: { [apiKeyEnv]: `\${${apiKeyEnv}}` },
28
+ command, args, env: apiKeyEnv ? { [apiKeyEnv]: `\${${apiKeyEnv}}` } : {},
29
29
  });
30
30
  const opencodeServer = ({ command, args, apiKeyEnv }: McpLaunch) => ({
31
- type: 'local', command: [command, ...args], environment: { [apiKeyEnv]: `{env:${apiKeyEnv}}` },
31
+ type: 'local', command: [command, ...args], environment: apiKeyEnv ? { [apiKeyEnv]: `{env:${apiKeyEnv}}` } : {},
32
32
  });
33
33
 
34
34
  function vscodeUserDirectory({ cwd, home, env, platform = process.platform }: AgentContext): string {
@@ -164,7 +164,7 @@ export const agentAdapters = [
164
164
  detectionDirectories: context => [copilotDirectory(context), join(context.cwd, '.github', 'mcp.json')],
165
165
  paths: copilotPaths,
166
166
  configure: (path, text, launch) => strictJsonConfig(path, text, launch, claudeServer, copilotMapPath),
167
- instructions: 'GitHub Copilot CLI: restart from the environment holding the selected key, trust the repository, then check /mcp and run /skills reload. Project MCP settings override user settings.',
167
+ instructions: 'GitHub Copilot CLI: restart with the configured credential source available, trust the repository, then check /mcp and run /skills reload. Project MCP settings override user settings.',
168
168
  },
169
169
  {
170
170
  id: 'kiro', executable: ['kiro', 'kiro-cli'],
@@ -174,7 +174,7 @@ export const agentAdapters = [
174
174
  return { mcp: join(directory, 'settings', 'mcp.json'), skill: join(directory, 'skills', 'lambdadb', 'SKILL.md') };
175
175
  },
176
176
  configure: (path, text, launch) => jsonConfig(path, text, launch, geminiServer),
177
- instructions: 'Kiro IDE/CLI: restart from the environment holding the selected key and check /mcp. Approve the selected variable in Mcp Approved Env Vars when prompted. Custom agents must include the MCP JSON and skill resources; init does not alter agent permissions or definitions.',
177
+ instructions: 'Kiro IDE/CLI: restart with the configured credential source available and check /mcp. For environment mode, approve the selected variable in Mcp Approved Env Vars when prompted. Custom agents must include the MCP JSON and skill resources; init does not alter agent permissions or definitions.',
178
178
  },
179
179
  {
180
180
  id: 'cline', executable: [], scopes: ['user'],
@@ -184,7 +184,7 @@ export const agentAdapters = [
184
184
  skill: join(context.home, '.cline', 'skills', 'lambdadb', 'SKILL.md') };
185
185
  },
186
186
  configure: (path, text, launch) => strictJsonConfig(path, text, launch, cursorServer),
187
- instructions: 'Cline VS Code extension: reload VS Code from the environment holding the selected key and check MCP Servers and the Skills menu. Init targets the default stable VS Code profile, not Cline CLI or other editors/profiles. No tools are auto-approved.',
187
+ instructions: 'Cline VS Code extension: reload VS Code with the configured credential source available and check MCP Servers and the Skills menu. Init targets the default stable VS Code profile, not Cline CLI or other editors/profiles. No tools are auto-approved.',
188
188
  },
189
189
  ] as const satisfies readonly AgentAdapter[];
190
190
 
package/src/cli.ts CHANGED
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, CommanderError, Option } from 'commander';
3
3
  import { readFileSync } from 'node:fs';
4
- import { integer, name, resolveConfig, saveSettings, type Options } from './config.js';
4
+ import { integer, name, resolveConfig, saveSettings, settings, type Options } from './config.js';
5
5
  import { createInput, parseRef, queryInput, readJson, readJsonl, updateInput } from './input.js';
6
6
  import { importDocuments, planBatches } from './import-workflow.js';
7
- import { definitelyRejected, describeError, InputError, isInputError } from './errors.js';
7
+ import { definitelyRejected, describeError, InputError, ManagementError, isInputError } from './errors.js';
8
8
  import { Output, type Envelope } from './output.js';
9
9
  import { checkConnection, runtime } from './runtime.js';
10
+ import { CredentialVault, credentialReference } from './credentials.js';
10
11
  import { initialize } from './init.js';
11
12
  import { serveMcp } from './mcp.js';
12
13
  import { agentNames } from './agents.js';
14
+ import { login, logout, management } from './account.js';
15
+ import { organizations, projects, createProject, useProject } from './projects.js';
13
16
 
14
17
  const output = new Output();
15
18
  output.protect(process.env.LAMBDADB_API_KEY);
@@ -23,11 +26,14 @@ const program = new Command()
23
26
  .description('Project-scoped LambdaDB CLI. Only init prompts; all other commands are non-interactive.')
24
27
  .version(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version)
25
28
  .option('--json', 'Emit one versioned JSON result on stdout; diagnostics stay on stderr')
29
+ .option('--api-url <url>', 'Account API origin (login reuses the saved origin, otherwise https://api.lambdadb.ai)')
26
30
  .option('--endpoint <url>', 'API origin (overrides LAMBDADB_ENDPOINT and saved configuration)')
27
31
  .option('--project <name>', 'Existing project (overrides LAMBDADB_PROJECT and saved configuration)')
28
32
  .option('--api-key-env <name>', 'Environment variable holding the key; never pass a key as an argument')
33
+ .option('--credential-store <store>', 'OS credential backend: auto, keychain, windows, secret-service or keyutils (init --connect selects auto)')
34
+ .option('--credential-id <uuid>', 'Non-secret saved credential reference (normally generated by init)')
29
35
  .option('--config <path>', 'Configuration path (overrides LAMBDADB_CONFIG)')
30
- .option('--timeout-ms <ms>', 'Total network budget, 1–3600000 ms (default 30000)')
36
+ .option('--timeout-ms <ms>', 'Data API: total command budget; account/project API: per HTTP request, 1–3600000 ms (default 30000)')
31
37
  .showSuggestionAfterError(false)
32
38
  .exitOverride()
33
39
  .configureOutput({ writeErr: () => {} })
@@ -73,18 +79,42 @@ program.command('configure')
73
79
  });
74
80
 
75
81
  program.command('init')
76
- .description('Connect an existing project and install agent MCP configuration and a usage skill')
82
+ .description('Connect through Console or existing credentials, then install agent MCP configuration and a usage skill')
83
+ .option('--connect', 'Select or create a project through account login; reuse saved project keys when available')
84
+ .option('--no-browser', 'Print the verification URL without opening a local browser (for SSH)')
77
85
  .option('--non-interactive', 'Require explicit agent/scope choices; never prompt')
78
86
  .option('--agents <names>', `Comma-separated ${agentNames} subset, or none`)
79
87
  .option('--scope <scope>', 'Agent configuration scope: project (current directory) or user')
80
88
  .option('--write-tools', 'Explicitly enable MCP write tools (default: read-only)')
81
- .addHelpText('after', '\nObtain endpoint, project and a project API key manually from https://console.lambdadb.ai.\nExport the key securely before running init; no key value is saved.\nAutomation example:\n lambdadb init --non-interactive --agents codex,claude,cursor --scope project --endpoint https://api.lambdadb.ai --project my-project --json')
89
+ .addHelpText('after', '\nBrowser setup: lambdadb init --connect (add --no-browser for SSH).\nThe browser handles signup/login and CLI approval. Select or create the project in your terminal.\nBrowser setup saves the project key only in an OS credential store. Account sessions and project keys are stored separately in the OS store; configuration files contain no secrets.\nManual/CI setup: obtain a project key in Console and export it securely; init saves only the environment-variable name.\nAutomation example:\n lambdadb init --non-interactive --agents codex,claude,cursor --scope project --endpoint https://api.lambdadb.ai --project my-project --json')
82
90
  .action(async (_opts, cmd: Command) => {
83
91
  const data = await initialize(cmd.optsWithGlobals(), output);
84
92
  target = { endpoint: data.settings.endpoint, project: data.settings.project };
85
93
  result(data, 'Initialization complete. Authentication and collection-list access verified; search readiness is not verified.');
86
94
  });
87
95
 
96
+ program.command('login').description('Authorize account management in a browser and save a separate OS session')
97
+ .option('--no-browser', 'Print a URL usable on another device without opening a browser')
98
+ .action(async (_opts, cmd: Command) => result(await login(cmd.optsWithGlobals(), output), 'Account login saved. Project connections and agent settings are unchanged.'));
99
+ program.command('logout').description('Revoke the saved account session and remove it locally; keep project connections')
100
+ .action(async (_opts, cmd: Command) => { await logout(cmd.optsWithGlobals(), output); result({ loggedOut: true, projectKeysRevoked: false }, 'Account logged out. Project keys and agent connections are unchanged.'); });
101
+ program.command('orgs').description('Manage account Organizations').command('list')
102
+ .action(async (_opts, cmd: Command) => result({ organizations: await organizations(cmd.optsWithGlobals(), output) }, 'Accessible Organizations.'));
103
+ const projectCommands = program.command('projects').description('List, create and select projects using the saved account session');
104
+ projectCommands.command('list').requiredOption('--org <uuid>', 'Organization ID')
105
+ .action(async (opts, cmd: Command) => result({ projects: await projects(cmd.optsWithGlobals(), opts.org, output) }, 'Projects in the selected Organization.'));
106
+ projectCommands.command('regions').description('List available regions before project creation')
107
+ .action(async (_opts, cmd: Command) => { const api = await management(cmd.optsWithGlobals(), output); result(await api.request('regions'), 'Project regions.'); });
108
+ projectCommands.command('create').option('--org <uuid>', 'Existing Organization ID').option('--default-org', 'Explicitly prepare the default Organization on submission')
109
+ .requiredOption('--name <name>', 'Project name').requiredOption('--region <region>', 'Region from projects regions')
110
+ .action(async (opts, cmd: Command) => {
111
+ const created = await createProject(cmd.optsWithGlobals(), opts, output);
112
+ result(created, 'Project requested. Check projects list for ACTIVE status; creation does not establish search readiness.');
113
+ });
114
+ projectCommands.command('use').requiredOption('--org <uuid>', 'Organization ID').requiredOption('--project-id <uuid>', 'Project ID')
115
+ .option('--issue-key', 'Explicitly issue a new project key, including replacement of a saved connection')
116
+ .action(async (opts, cmd: Command) => result(await useProject(cmd.optsWithGlobals(), opts.org, opts.projectId, !!opts.issueKey, output), 'CLI project selected. Agent configurations are unchanged.'));
117
+
88
118
  program.command('mcp')
89
119
  .description('Launch the existing stdio MCP package with runtime credential forwarding (used by init)')
90
120
  .option('--write-tools', 'Explicitly enable MCP write tools (default: read-only)')
@@ -99,6 +129,16 @@ program.command('mcp')
99
129
  process.exitCode = await serveMcp(config, !!opts.writeTools);
100
130
  });
101
131
 
132
+ program.command('credentials').description('Manage local OS credential references').command('remove')
133
+ .description('Delete this connection’s local OS credential; keep metadata and do not revoke the server key')
134
+ .action(async (_opts, cmd: Command) => {
135
+ const selected = await settings(cmd.optsWithGlobals());
136
+ if (!selected.credentialStore || !selected.credentialId) throw new InputError('This connection uses an environment variable. Unset it in your shell or secret manager; revoke the project key separately in Console.');
137
+ await new CredentialVault().remove({ ...selected, ...credentialReference(selected.credentialStore, selected.credentialId) });
138
+ result({ credentialStore: selected.credentialStore, credentialKeyId: selected.credentialKeyId, credentialKeyName: selected.credentialKeyName, serverKeyRevoked: false },
139
+ 'Local credential removed. Connection references remain. Revoke the project key in Console if you no longer need it.');
140
+ });
141
+
102
142
  program.command('doctor')
103
143
  .description('Check configuration, authentication and project collection-list access')
104
144
  .action(async (_opts, cmd: Command) => connected(cmd, async ({ client, options }) => {
@@ -247,7 +287,7 @@ try {
247
287
  output.json ||= process.argv.includes('--json');
248
288
  const failure = error instanceof CommanderError
249
289
  ? new InputError('Invalid or incomplete command. Run lambdadb --help or the command-specific --help.') : error;
250
- const unknownWrite = mutationStarted && !definitelyRejected(failure);
290
+ const unknownWrite = (mutationStarted && !definitelyRejected(failure)) || (failure instanceof ManagementError && ['write_outcome_unknown', 'issuance_unconfirmed'].includes(failure.code));
251
291
  const exitCode = isInputError(failure) ? 2 : unknownWrite ? 5 : 3;
252
292
  if (commandName === 'mcp') {
253
293
  const safe = describeError(failure);
package/src/config.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  import { homedir } from 'node:os';
2
- import { dirname, join, resolve } from 'node:path';
3
- import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { CredentialVault, credentialReference, type CredentialReference } from './credentials.js';
5
6
  import { InputError } from './errors.js';
6
7
 
7
- export type Settings = { endpoint?: string; project?: string; apiKeyEnv?: string };
8
- export type Config = Required<Settings> & { apiKey: string; timeoutMs: number };
8
+ export type Settings = { endpoint?: string; project?: string; apiKeyEnv?: string; credentialKeyId?: string; credentialKeyName?: string } & Partial<CredentialReference>;
9
+ export type SelectedSettings = Required<Pick<Settings, 'endpoint' | 'project' | 'apiKeyEnv'>> & Partial<CredentialReference> & Pick<Settings, 'credentialKeyId' | 'credentialKeyName'>;
10
+ export type Config = SelectedSettings & { apiKey: string; timeoutMs: number };
9
11
  export type Options = Settings & { config?: string; timeoutMs?: string };
10
12
 
13
+ export function timeoutMs(options: Options): number {
14
+ return integer(options.timeoutMs ?? process.env.LAMBDADB_TIMEOUT_MS ?? '30000', 'Timeout', 1, 3600000);
15
+ }
16
+
11
17
  export function configPath(options: Options): string {
12
18
  return resolve(options.config ?? process.env.LAMBDADB_CONFIG
13
19
  ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'lambdadb', 'config.json'));
@@ -53,20 +59,32 @@ export async function loadSettings(path: string, explicitlySelected = false): Pr
53
59
  try {
54
60
  const value = JSON.parse(text);
55
61
  if (!value || typeof value !== 'object' || Array.isArray(value)
56
- || Object.keys(value).some(k => !['endpoint', 'project', 'apiKeyEnv'].includes(k))
62
+ || Object.keys(value).some(k => !['endpoint', 'project', 'apiKeyEnv', 'credentialStore', 'credentialId', 'credentialKeyId', 'credentialKeyName'].includes(k))
57
63
  || Object.values(value).some(v => typeof v !== 'string')) throw new Error();
64
+ for (const key of ['credentialKeyId', 'credentialKeyName']) {
65
+ if (value[key] !== undefined && (!value.credentialStore || !/^[a-zA-Z0-9_-]{1,128}$/.test(value[key]))) throw new Error();
66
+ }
67
+ if (value.credentialStore !== undefined || value.credentialId !== undefined) credentialReference(value.credentialStore, value.credentialId);
58
68
  return value;
59
- } catch { throw new InputError('Configuration must be a JSON object with only endpoint, project and apiKeyEnv strings. Never store an API key in it.'); }
69
+ } catch { throw new InputError('Configuration must be a JSON object with endpoint, project, apiKeyEnv and optional credentialStore/credentialId/credentialKeyId/credentialKeyName strings. Never store an API key in it.'); }
60
70
  }
61
71
 
62
- export async function settings(options: Options, creating = false): Promise<Required<Settings>> {
72
+ export async function settings(options: Options, creating = false): Promise<SelectedSettings> {
63
73
  const saved = await loadSettings(configPath(options), !creating && !!(options.config ?? process.env.LAMBDADB_CONFIG));
64
74
  const selectedEndpoint = options.endpoint ?? process.env.LAMBDADB_ENDPOINT ?? saved.endpoint;
65
75
  const project = options.project ?? process.env.LAMBDADB_PROJECT ?? saved.project;
66
76
  if (!selectedEndpoint || !project) throw new InputError('Endpoint and project are required. Run configure or set LAMBDADB_ENDPOINT and LAMBDADB_PROJECT.');
67
77
  // Project names are encoded as a single SDK URL segment, with no implicit playground fallback.
68
78
  if (!/^[a-zA-Z0-9_-]+$/.test(project)) throw new InputError('Project must contain only letters, digits, underscores or hyphens.');
79
+ const stored = options.credentialStore || options.credentialId ? credentialReference(options.credentialStore, options.credentialId) :
80
+ saved.credentialStore ? credentialReference(saved.credentialStore, saved.credentialId) : undefined;
81
+ const previousIdentity = stored?.credentialId === saved.credentialId ? saved : {};
82
+ const identity = {
83
+ ...(options.credentialKeyId ?? previousIdentity.credentialKeyId ? { credentialKeyId: options.credentialKeyId ?? previousIdentity.credentialKeyId } : {}),
84
+ ...(options.credentialKeyName ?? previousIdentity.credentialKeyName ? { credentialKeyName: options.credentialKeyName ?? previousIdentity.credentialKeyName } : {}),
85
+ };
69
86
  return {
87
+ ...(stored && (options.credentialStore || !(options.apiKeyEnv ?? process.env.LAMBDADB_API_KEY_ENV)) ? { ...stored, ...identity } : {}),
70
88
  endpoint: endpoint(selectedEndpoint), project,
71
89
  apiKeyEnv: environmentName(options.apiKeyEnv ?? process.env.LAMBDADB_API_KEY_ENV ?? saved.apiKeyEnv ?? 'LAMBDADB_API_KEY'),
72
90
  };
@@ -74,15 +92,17 @@ export async function settings(options: Options, creating = false): Promise<Requ
74
92
 
75
93
  export async function resolveConfig(options: Options, creating = false): Promise<Config> {
76
94
  const selected = await settings(options, creating);
77
- const apiKey = process.env[selected.apiKeyEnv];
95
+ const apiKey = selected.credentialStore && selected.credentialId
96
+ ? await new CredentialVault().read({ ...selected, ...credentialReference(selected.credentialStore, selected.credentialId) })
97
+ : process.env[selected.apiKeyEnv];
78
98
  if (!apiKey || !apiKey.trim() || /[\r\n]/.test(apiKey)) throw new InputError('The selected API-key environment variable is missing or invalid. Supply it through your shell or CI secret manager.');
79
99
  return {
80
100
  ...selected, apiKey,
81
- timeoutMs: integer(options.timeoutMs ?? process.env.LAMBDADB_TIMEOUT_MS ?? '30000', 'Timeout', 1, 3600000),
101
+ timeoutMs: timeoutMs(options),
82
102
  };
83
103
  }
84
104
 
85
- export async function saveSettings(options: Options): Promise<{ path: string; settings: Required<Settings> }> {
105
+ export async function saveSettings(options: Options): Promise<{ path: string; settings: SelectedSettings }> {
86
106
  const value = await settings(options, true);
87
107
  const path = configPath(options);
88
108
  const temp = `${path}.${randomUUID()}.tmp`;
@@ -94,3 +114,16 @@ export async function saveSettings(options: Options): Promise<{ path: string; se
94
114
  finally { await rm(temp, { force: true }).catch(() => {}); }
95
115
  return { path, settings: value };
96
116
  }
117
+
118
+ export async function fileIdentity(path: string): Promise<string> {
119
+ try { return await realpath(path); }
120
+ catch (error) {
121
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new InputError(`Cannot resolve configuration path: ${path}.`);
122
+ const info = await lstat(path).catch(error => {
123
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
124
+ throw new InputError(`Cannot resolve configuration path: ${path}.`);
125
+ });
126
+ if (info?.isSymbolicLink()) throw new InputError(`Configuration symbolic link has a missing target: ${path}. Restore its target before rerunning init.`);
127
+ return join(await fileIdentity(dirname(path)), basename(path));
128
+ }
129
+ }