@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.
- package/CHANGELOG.md +17 -0
- package/CONTRIBUTING.md +29 -0
- package/DESIGN.md +9 -3
- package/ONBOARDING.md +417 -0
- package/README.md +401 -138
- package/dist/account.d.ts +50 -0
- package/dist/account.js +301 -0
- package/dist/account.js.map +1 -0
- package/dist/agent-config.d.ts +4 -3
- package/dist/agent-config.js +12 -5
- package/dist/agent-config.js.map +1 -1
- package/dist/agents.d.ts +3 -3
- package/dist/agents.js +8 -8
- package/dist/agents.js.map +1 -1
- package/dist/cli.js +44 -6
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +10 -4
- package/dist/config.js +43 -6
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +29 -0
- package/dist/credentials.js +116 -0
- package/dist/credentials.js.map +1 -0
- package/dist/device-auth.d.ts +16 -0
- package/dist/device-auth.js +160 -0
- package/dist/device-auth.js.map +1 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +35 -1
- package/dist/errors.js.map +1 -1
- package/dist/init.d.ts +5 -2
- package/dist/init.js +58 -33
- package/dist/init.js.map +1 -1
- package/dist/mcp.js +8 -1
- package/dist/mcp.js.map +1 -1
- package/dist/projects.d.ts +29 -0
- package/dist/projects.js +323 -0
- package/dist/projects.js.map +1 -0
- package/package.json +6 -2
- package/skills/lambdadb/SKILL.md +43 -24
- package/skills/lambdadb/references/usage.md +98 -0
- package/src/account.ts +351 -0
- package/src/agent-config.ts +14 -8
- package/src/agents.ts +8 -8
- package/src/cli.ts +46 -6
- package/src/config.ts +43 -10
- package/src/credentials.ts +138 -0
- package/src/device-auth.ts +200 -0
- package/src/errors.ts +31 -2
- package/src/init.ts +50 -31
- package/src/mcp.ts +7 -1
- package/src/projects.ts +439 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { InputError } from './errors.js';
|
|
3
|
+
|
|
4
|
+
export const credentialStores = ['keychain', 'windows', 'secret-service', 'keyutils'] as const;
|
|
5
|
+
export type CredentialStore = (typeof credentialStores)[number];
|
|
6
|
+
export type CredentialReference = { credentialStore: CredentialStore; credentialId: string };
|
|
7
|
+
export type CredentialTarget = { endpoint: string; project: string } & CredentialReference;
|
|
8
|
+
type Entry = {
|
|
9
|
+
setSecret(value: Uint8Array, signal?: AbortSignal): Promise<void>;
|
|
10
|
+
getSecret(signal?: AbortSignal): Promise<number[] | null>;
|
|
11
|
+
deleteCredential(signal?: AbortSignal): Promise<boolean>;
|
|
12
|
+
};
|
|
13
|
+
export type EntryFactory = (service: string, id: string, store: CredentialStore) => Promise<Entry>;
|
|
14
|
+
const nativeEntry: EntryFactory = async (service, id, store) => {
|
|
15
|
+
const { AsyncEntry } = await import('@napi-rs/keyring');
|
|
16
|
+
return new AsyncEntry(
|
|
17
|
+
service,
|
|
18
|
+
id,
|
|
19
|
+
store === 'secret-service' || store === 'keyutils' ? { linux: { store } } : undefined,
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function credentialReference(store: unknown, id: unknown): CredentialReference {
|
|
24
|
+
if (
|
|
25
|
+
!credentialStores.includes(store as CredentialStore) ||
|
|
26
|
+
typeof id !== 'string' ||
|
|
27
|
+
!/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(id)
|
|
28
|
+
) {
|
|
29
|
+
throw new InputError(
|
|
30
|
+
'Credential reference must contain a supported store and UUID. Never put a key value in configuration.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return { credentialStore: store as CredentialStore, credentialId: id };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function supported(store: CredentialStore, platform: NodeJS.Platform): boolean {
|
|
37
|
+
return platform === 'darwin'
|
|
38
|
+
? store === 'keychain'
|
|
39
|
+
: platform === 'win32'
|
|
40
|
+
? store === 'windows'
|
|
41
|
+
: platform === 'linux' && ['secret-service', 'keyutils'].includes(store);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class CredentialVault {
|
|
45
|
+
constructor(
|
|
46
|
+
private factory: EntryFactory = nativeEntry,
|
|
47
|
+
private platform: NodeJS.Platform = process.platform,
|
|
48
|
+
private purpose: 'project' | 'account' = 'project',
|
|
49
|
+
) {}
|
|
50
|
+
private async entry(target: CredentialTarget): Promise<Entry> {
|
|
51
|
+
credentialReference(target.credentialStore, target.credentialId);
|
|
52
|
+
if (!supported(target.credentialStore, this.platform))
|
|
53
|
+
throw new InputError(
|
|
54
|
+
'This credential reference belongs to a different OS. Use environment-variable setup or connect on this OS.',
|
|
55
|
+
);
|
|
56
|
+
const identity = createHash('sha256')
|
|
57
|
+
.update(JSON.stringify([target.endpoint, target.project]))
|
|
58
|
+
.digest('hex');
|
|
59
|
+
return this.factory(
|
|
60
|
+
`${this.purpose === 'account' ? 'ai.lambdadb.cli.account.v1' : 'ai.lambdadb.cli.v1'}:${identity}`,
|
|
61
|
+
target.credentialId,
|
|
62
|
+
target.credentialStore,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
async read(target: CredentialTarget): Promise<string> {
|
|
66
|
+
try {
|
|
67
|
+
const value = await (await this.entry(target)).getSecret(AbortSignal.timeout(15_000));
|
|
68
|
+
if (value === null) throw new Error();
|
|
69
|
+
const secret = Buffer.from(value).toString('utf8');
|
|
70
|
+
if (!secret.trim() || /[\r\n]/.test(secret)) throw new Error();
|
|
71
|
+
return secret;
|
|
72
|
+
} catch {
|
|
73
|
+
throw new InputError(
|
|
74
|
+
`Cannot read the saved ${target.credentialStore} credential. Unlock the store or restore the same OS user session. Kernel keys can expire or disappear after reboot. No new key was issued. Reconnect explicitly with init --connect, or use --api-key-env NAME. Review existing CLI keys in Console before reconnecting.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async save(target: CredentialTarget, key: string): Promise<void> {
|
|
79
|
+
if (!key.trim() || /[\r\n]/.test(key) || (this.platform === 'win32' && Buffer.byteLength(key) > 2560))
|
|
80
|
+
throw new InputError('The project key is invalid or exceeds the OS credential-store size limit.');
|
|
81
|
+
let entry: Entry | undefined;
|
|
82
|
+
let owned = false;
|
|
83
|
+
try {
|
|
84
|
+
entry = await this.entry(target);
|
|
85
|
+
// Every new connection uses a fresh slot. Never overwrite an existing key.
|
|
86
|
+
if ((await entry.getSecret(AbortSignal.timeout(15_000))) !== null) throw new Error();
|
|
87
|
+
owned = true;
|
|
88
|
+
await entry.setSecret(Buffer.from(key), AbortSignal.timeout(15_000));
|
|
89
|
+
const saved = await entry.getSecret(AbortSignal.timeout(15_000));
|
|
90
|
+
if (saved === null || !Buffer.from(saved).equals(Buffer.from(key))) throw new Error();
|
|
91
|
+
} catch {
|
|
92
|
+
if (owned && entry) await entry.deleteCredential(AbortSignal.timeout(15_000)).catch(() => {});
|
|
93
|
+
throw new InputError(
|
|
94
|
+
'Cannot verify OS credential storage. No plaintext fallback was used. Check the store, review the named CLI key in Console, and retry explicitly.',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async remove(target: CredentialTarget): Promise<void> {
|
|
99
|
+
try {
|
|
100
|
+
await (await this.entry(target)).deleteCredential(AbortSignal.timeout(15_000));
|
|
101
|
+
} catch {
|
|
102
|
+
throw new InputError(
|
|
103
|
+
'Could not remove the local credential. Unlock the OS store and retry. Local deletion never revokes the server key.',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async select(requested = 'auto'): Promise<CredentialStore> {
|
|
108
|
+
const choices: CredentialStore[] =
|
|
109
|
+
requested === 'auto'
|
|
110
|
+
? this.platform === 'darwin'
|
|
111
|
+
? ['keychain']
|
|
112
|
+
: this.platform === 'win32'
|
|
113
|
+
? ['windows']
|
|
114
|
+
: this.platform === 'linux'
|
|
115
|
+
? ['secret-service', 'keyutils']
|
|
116
|
+
: []
|
|
117
|
+
: credentialStores.filter((store) => store === requested);
|
|
118
|
+
for (const store of choices) {
|
|
119
|
+
if (!supported(store, this.platform)) continue;
|
|
120
|
+
const probe = {
|
|
121
|
+
endpoint: 'https://credential-probe.invalid',
|
|
122
|
+
project: 'probe',
|
|
123
|
+
credentialStore: store,
|
|
124
|
+
credentialId: randomUUID(),
|
|
125
|
+
};
|
|
126
|
+
try {
|
|
127
|
+
await this.save(probe, randomUUID());
|
|
128
|
+
await this.remove(probe);
|
|
129
|
+
return store;
|
|
130
|
+
} catch {
|
|
131
|
+
/* Only a new connection may try another backend. */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
throw new InputError(
|
|
135
|
+
'No usable OS credential store. On Linux, unlock Secret Service or enable kernel keyutils. Otherwise obtain a key in Console and use init --api-key-env NAME with the key exported securely. No key was requested.',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { endpoint } from './config.js';
|
|
4
|
+
import { InputError } from './errors.js';
|
|
5
|
+
import type { Output } from './output.js';
|
|
6
|
+
|
|
7
|
+
async function request(
|
|
8
|
+
origin: string,
|
|
9
|
+
path: string,
|
|
10
|
+
body: object,
|
|
11
|
+
signal: AbortSignal,
|
|
12
|
+
requestTimeoutMs = 30_000,
|
|
13
|
+
): Promise<{ ok: boolean; value: Record<string, unknown> }> {
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(`${origin}/auth/device/${path}`, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
redirect: 'error',
|
|
18
|
+
headers: { 'Content-Type': 'application/json' },
|
|
19
|
+
body: JSON.stringify(body),
|
|
20
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(requestTimeoutMs)]),
|
|
21
|
+
});
|
|
22
|
+
const reader = response.body?.getReader();
|
|
23
|
+
if (!reader) throw new Error();
|
|
24
|
+
const chunks: Uint8Array[] = [];
|
|
25
|
+
let size = 0;
|
|
26
|
+
try {
|
|
27
|
+
for (;;) {
|
|
28
|
+
const { done, value } = await reader.read();
|
|
29
|
+
if (done) break;
|
|
30
|
+
size += value.length;
|
|
31
|
+
if (size > 65_536) throw new Error();
|
|
32
|
+
chunks.push(value);
|
|
33
|
+
}
|
|
34
|
+
} finally {
|
|
35
|
+
await reader.cancel().catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
38
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
|
|
39
|
+
return { ok: response.ok, value };
|
|
40
|
+
} catch {
|
|
41
|
+
throw new InputError(
|
|
42
|
+
'Account login response could not be confirmed. Check connectivity and run login again explicitly. An undelivered session expires within seven days. No project key was requested.',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function openBrowser(url: string, output: Output) {
|
|
48
|
+
const command =
|
|
49
|
+
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32.exe' : 'xdg-open';
|
|
50
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
51
|
+
try {
|
|
52
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
53
|
+
child.on('error', () =>
|
|
54
|
+
output.diagnostic('Could not open a browser. Open the verification URL on any device.'),
|
|
55
|
+
);
|
|
56
|
+
child.unref();
|
|
57
|
+
} catch {
|
|
58
|
+
output.diagnostic('Open the verification URL on any device.');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function pollDevice(
|
|
63
|
+
apiUrl: string,
|
|
64
|
+
browser: boolean,
|
|
65
|
+
output: Output,
|
|
66
|
+
signal: AbortSignal,
|
|
67
|
+
requestTimeoutMs = 30_000,
|
|
68
|
+
): Promise<Record<string, unknown>> {
|
|
69
|
+
const origin = endpoint(apiUrl);
|
|
70
|
+
const start = await request(origin, 'login', {}, signal, requestTimeoutMs);
|
|
71
|
+
const s = start.value;
|
|
72
|
+
if (!start.ok)
|
|
73
|
+
throw new InputError(
|
|
74
|
+
'CLI authorization is unavailable on this server. Check deployment/configuration, or obtain a project key in Console and use --api-key-env NAME.',
|
|
75
|
+
);
|
|
76
|
+
if (
|
|
77
|
+
typeof s.device_code !== 'string' ||
|
|
78
|
+
!/^[0-9a-f]{64}$/.test(s.device_code) ||
|
|
79
|
+
typeof s.user_code !== 'string' ||
|
|
80
|
+
!/^[A-Z2-7]{5}-[A-Z2-7]{5}$/.test(s.user_code) ||
|
|
81
|
+
typeof s.verification_uri !== 'string' ||
|
|
82
|
+
!Number.isInteger(s.expires_in) ||
|
|
83
|
+
Number(s.expires_in) < 1 ||
|
|
84
|
+
Number(s.expires_in) > 900 ||
|
|
85
|
+
!Number.isInteger(s.interval) ||
|
|
86
|
+
Number(s.interval) < 1 ||
|
|
87
|
+
Number(s.interval) > 30
|
|
88
|
+
)
|
|
89
|
+
throw new InputError('Invalid CLI authorization contract. Check client/server versions.');
|
|
90
|
+
output.protect(s.device_code);
|
|
91
|
+
let url: URL;
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(s.verification_uri);
|
|
94
|
+
endpoint(url.origin);
|
|
95
|
+
if (url.username || url.password || url.pathname !== '/auth/cli' || url.search || url.hash)
|
|
96
|
+
throw new Error();
|
|
97
|
+
} catch {
|
|
98
|
+
throw new InputError('Invalid Console verification URL. Check server configuration.');
|
|
99
|
+
}
|
|
100
|
+
url.searchParams.set('user_code', s.user_code);
|
|
101
|
+
output.diagnostic(
|
|
102
|
+
`Open ${url.href} on any device. Confirm code ${s.user_code} and approve the CLI request. Only approve a request you started.`,
|
|
103
|
+
);
|
|
104
|
+
if (browser) openBrowser(url.href, output);
|
|
105
|
+
const deadline = Date.now() + Number(s.expires_in) * 1000;
|
|
106
|
+
let interval = Number(s.interval) * 1000;
|
|
107
|
+
while (Date.now() < deadline) {
|
|
108
|
+
await delay(Math.min(interval, Math.max(0, deadline - Date.now())), undefined, { signal });
|
|
109
|
+
if (Date.now() >= deadline) break;
|
|
110
|
+
const token = await request(
|
|
111
|
+
origin,
|
|
112
|
+
'token',
|
|
113
|
+
{ device_code: s.device_code },
|
|
114
|
+
AbortSignal.any([signal, AbortSignal.timeout(Math.max(1, deadline - Date.now()))]),
|
|
115
|
+
requestTimeoutMs,
|
|
116
|
+
);
|
|
117
|
+
if (token.ok) {
|
|
118
|
+
return token.value;
|
|
119
|
+
}
|
|
120
|
+
if (token.value.error === 'account_session_limit')
|
|
121
|
+
throw new InputError(
|
|
122
|
+
'Too many active CLI logins. Run logout on another device/profile, or wait for unused sessions to expire (at most seven days), then run login again.',
|
|
123
|
+
);
|
|
124
|
+
if (token.value.error === 'authorization_pending') continue;
|
|
125
|
+
if (token.value.error === 'slow_down') {
|
|
126
|
+
interval = Math.min(interval + 5000, 30_000);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (token.value.error === 'access_denied')
|
|
130
|
+
throw new InputError('Authorization was denied or project access changed. No connection was saved.');
|
|
131
|
+
if (token.value.error === 'expired_token') break;
|
|
132
|
+
throw new InputError(
|
|
133
|
+
'Account login could not be confirmed. Run login again explicitly; an undelivered session expires within seven days. No project key was requested.',
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
throw new InputError('Login authorization expired. Run lambdadb login again. No project was created.');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function projectAuthorization(v: Record<string, unknown>, output: Output, fallbackName: string) {
|
|
140
|
+
const credentials = v.credentials as { api_key?: unknown; key?: { key_id?: unknown } } | undefined;
|
|
141
|
+
if (typeof credentials?.api_key === 'string') output.protect(credentials.api_key);
|
|
142
|
+
if (
|
|
143
|
+
typeof v.endpoint !== 'string' ||
|
|
144
|
+
typeof v.project !== 'string' ||
|
|
145
|
+
!/^[a-zA-Z0-9_-]+$/.test(v.project) ||
|
|
146
|
+
typeof credentials?.api_key !== 'string' ||
|
|
147
|
+
!credentials.api_key.trim() ||
|
|
148
|
+
/[\r\n]/.test(credentials.api_key)
|
|
149
|
+
) {
|
|
150
|
+
throw new InputError(
|
|
151
|
+
'Issued key response is invalid. Inspect the named CLI key in Console; no automatic retry was made.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const keyId = credentials.key?.key_id;
|
|
155
|
+
if (typeof keyId !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(keyId))
|
|
156
|
+
throw new InputError(
|
|
157
|
+
'Issued key identity is invalid. Review the named CLI key in Console before reconnecting.',
|
|
158
|
+
);
|
|
159
|
+
return {
|
|
160
|
+
endpoint: endpoint(v.endpoint),
|
|
161
|
+
project: v.project,
|
|
162
|
+
apiKey: credentials.api_key,
|
|
163
|
+
credentialKeyId: keyId,
|
|
164
|
+
credentialKeyName: typeof v.key_name === 'string' ? v.key_name : fallbackName,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function authorizeAccount(
|
|
169
|
+
apiUrl: string,
|
|
170
|
+
browser: boolean,
|
|
171
|
+
output: Output,
|
|
172
|
+
signal: AbortSignal,
|
|
173
|
+
requestTimeoutMs = 30_000,
|
|
174
|
+
) {
|
|
175
|
+
const result = await pollDevice(apiUrl, browser, output, signal, requestTimeoutMs);
|
|
176
|
+
const a = result.account as Record<string, unknown> | undefined;
|
|
177
|
+
if (typeof a?.session_token === 'string') output.protect(a.session_token);
|
|
178
|
+
if (
|
|
179
|
+
!a ||
|
|
180
|
+
typeof a.session_token !== 'string' ||
|
|
181
|
+
!/^[0-9a-f]{64}$/.test(a.session_token) ||
|
|
182
|
+
typeof a.member_id !== 'string' ||
|
|
183
|
+
!/^[0-9a-f-]{36}$/.test(a.member_id) ||
|
|
184
|
+
typeof a.email !== 'string' ||
|
|
185
|
+
typeof a.expires_at !== 'string' ||
|
|
186
|
+
!Number.isFinite(Date.parse(a.expires_at)) ||
|
|
187
|
+
Date.parse(a.expires_at) <= Date.now() ||
|
|
188
|
+
typeof a.console_url !== 'string'
|
|
189
|
+
)
|
|
190
|
+
throw new InputError(
|
|
191
|
+
'Invalid CLI account response. Run login explicitly after checking the server version.',
|
|
192
|
+
);
|
|
193
|
+
return {
|
|
194
|
+
token: a.session_token,
|
|
195
|
+
memberId: a.member_id,
|
|
196
|
+
email: a.email,
|
|
197
|
+
expiresAt: a.expires_at,
|
|
198
|
+
consoleUrl: endpoint(a.console_url),
|
|
199
|
+
};
|
|
200
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import { LambdaDBError, ResponseValidationError, SDKValidationError } from '@functional-systems/lambdadb/models/errors';
|
|
2
2
|
|
|
3
3
|
export class InputError extends Error {}
|
|
4
|
+
export class ManagementError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
public code: string,
|
|
7
|
+
public status: number | undefined,
|
|
8
|
+
public details?: Record<string, unknown>,
|
|
9
|
+
) {
|
|
10
|
+
super(code);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
4
13
|
|
|
5
|
-
export type SafeError = { code: string; message: string; hint: string; httpStatus?: number };
|
|
14
|
+
export type SafeError = { code: string; message: string; hint: string; httpStatus?: number; details?: Record<string, unknown> };
|
|
6
15
|
|
|
7
16
|
// Never serialize SDK errors: they can contain credentials, response bodies and signed URLs.
|
|
8
17
|
export function describeError(error: unknown): SafeError {
|
|
18
|
+
if (error instanceof ManagementError) {
|
|
19
|
+
let hint = 'Check Console before retrying a write. No automatic retry was made; inspect the named key if issuance was uncertain.';
|
|
20
|
+
if (error.code === 'project_limit_reached' && error.details?.organization) {
|
|
21
|
+
const organization = error.details.organization as { billing_url: string };
|
|
22
|
+
hint = `Use an existing project, or review ${organization.billing_url}. Only the owner can activate billing. Recheck server limits afterwards.`;
|
|
23
|
+
} else if (error.status === 401) {
|
|
24
|
+
hint = 'Run lambdadb login again. Project keys are unaffected.';
|
|
25
|
+
} else if (error.code === 'project_limit_reached') {
|
|
26
|
+
hint = 'Use an existing project, or review the indicated Organization billing link. Only the owner can activate billing. Recheck server limits afterwards.';
|
|
27
|
+
} else if (error.details?.invitation_id) {
|
|
28
|
+
hint = `Respond to the pending invitation at ${error.details.invitation_url}, then rerun setup.`;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
code: error.code.toUpperCase(),
|
|
32
|
+
message: `Account management request failed: ${error.code}.`,
|
|
33
|
+
httpStatus: error.status,
|
|
34
|
+
details: error.details,
|
|
35
|
+
hint,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
9
38
|
if (error instanceof InputError) {
|
|
10
39
|
return { code: 'INPUT_ERROR', message: error.message, hint: 'Check --help and the input file or configuration.' };
|
|
11
40
|
}
|
|
@@ -40,6 +69,6 @@ export function isInputError(error: unknown): boolean {
|
|
|
40
69
|
}
|
|
41
70
|
|
|
42
71
|
export function definitelyRejected(error: unknown): boolean {
|
|
43
|
-
return isInputError(error) || (error instanceof LambdaDBError
|
|
72
|
+
return isInputError(error) || (error instanceof ManagementError && error.status !== undefined && error.status >= 400 && error.status < 500 && error.status !== 408 && error.code !== 'issuance_unconfirmed') || (error instanceof LambdaDBError
|
|
44
73
|
&& error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 408);
|
|
45
74
|
}
|
package/src/init.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import {
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline/promises';
|
|
6
|
-
import { configPath, loadSettings, resolveConfig, saveSettings, settings, type Options, type
|
|
6
|
+
import { configPath, fileIdentity, loadSettings, resolveConfig, saveSettings, settings, type Options, type SelectedSettings } from './config.js';
|
|
7
|
+
import { terminalSetup } from './projects.js';
|
|
7
8
|
import { InputError } from './errors.js';
|
|
8
9
|
import type { Output } from './output.js';
|
|
9
10
|
import { checkConnection, runtime } from './runtime.js';
|
|
@@ -11,7 +12,7 @@ import { mcpPackage } from './mcp.js';
|
|
|
11
12
|
import { mcpLaunch } from './agent-config.js';
|
|
12
13
|
import { agentAdapters, agentNames, detectAgents, type Agent, type AgentAdapter, type AgentContext, type AgentScope } from './agents.js';
|
|
13
14
|
|
|
14
|
-
type InitOptions = Options & { nonInteractive?: boolean; agents?: string; scope?: string; writeTools?: boolean; json?: boolean };
|
|
15
|
+
type InitOptions = Options & { connect?: boolean; browser?: boolean; apiUrl?: string; nonInteractive?: boolean; agents?: string; scope?: string; writeTools?: boolean; json?: boolean };
|
|
15
16
|
type Change = { path: string; before: string | undefined; after: string; kind: string };
|
|
16
17
|
type PlannedChange = Change & { destination: string; aliases: string[] };
|
|
17
18
|
const version = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version as string;
|
|
@@ -40,24 +41,12 @@ async function readOptional(path: string): Promise<string | undefined> {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
async function
|
|
44
|
-
try { return await realpath(path); }
|
|
45
|
-
catch (error) {
|
|
46
|
-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new InputError(`Cannot resolve configuration path: ${path}.`);
|
|
47
|
-
const info = await lstat(path).catch(error => {
|
|
48
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
49
|
-
throw new InputError(`Cannot resolve configuration path: ${path}.`);
|
|
50
|
-
});
|
|
51
|
-
if (info?.isSymbolicLink()) throw new InputError(`Configuration symbolic link has a missing target: ${path}. Restore its target before rerunning init.`);
|
|
52
|
-
return join(await fileIdentity(dirname(path)), basename(path));
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function plan(selected: Required<Settings>, chosen: Agent[], scope: AgentScope, options: InitOptions, context: AgentContext): Promise<PlannedChange[]> {
|
|
44
|
+
async function plan(selected: SelectedSettings, chosen: Agent[], scope: AgentScope, options: InitOptions, context: AgentContext): Promise<PlannedChange[]> {
|
|
57
45
|
const changes: Change[] = [];
|
|
58
46
|
const path = configPath(options);
|
|
59
47
|
changes.push({ path, before: await readOptional(path), after: JSON.stringify(selected, null, 2) + '\n', kind: 'CLI settings (no key)' });
|
|
60
48
|
const skill = await readFile(new URL('../skills/lambdadb/SKILL.md', import.meta.url), 'utf8');
|
|
49
|
+
const usage = await readFile(new URL('../skills/lambdadb/references/usage.md', import.meta.url), 'utf8');
|
|
61
50
|
for (const agent of chosen) {
|
|
62
51
|
const adapter: AgentAdapter = agentAdapters.find(adapter => adapter.id === agent)!;
|
|
63
52
|
const paths = await adapter.paths(scope, context);
|
|
@@ -69,6 +58,11 @@ async function plan(selected: Required<Settings>, chosen: Agent[], scope: AgentS
|
|
|
69
58
|
const previousSkill = await readOptional(skillPath);
|
|
70
59
|
// Preserve existing skills, including user edits, rather than taking ownership.
|
|
71
60
|
changes.push({ path: skillPath, before: previousSkill, after: previousSkill ?? skill, kind: `${agent} skill (${scope}${previousSkill && previousSkill !== skill ? ', existing skill preserved' : ''})` });
|
|
61
|
+
if (previousSkill === undefined || previousSkill === skill) {
|
|
62
|
+
const referencePath = join(dirname(skillPath), 'references', 'usage.md');
|
|
63
|
+
const previousReference = await readOptional(referencePath);
|
|
64
|
+
changes.push({ path: referencePath, before: previousReference, after: previousReference ?? usage, kind: `${agent} skill reference (${scope})` });
|
|
65
|
+
}
|
|
72
66
|
}
|
|
73
67
|
const unique = new Map<string, PlannedChange>();
|
|
74
68
|
for (const change of changes) {
|
|
@@ -118,18 +112,20 @@ export async function initialize(options: InitOptions, output: Output) {
|
|
|
118
112
|
}
|
|
119
113
|
const context = { cwd: process.cwd(), home: homedir(), env: process.env };
|
|
120
114
|
const detected = await detectAgents(context);
|
|
121
|
-
output.diagnostic('Manual Console step: open https://console.lambdadb.ai, select an existing project, and obtain its API endpoint, project name and API key. Export the key securely; init never accepts or saves a key value.');
|
|
122
115
|
const prompt = interactive ? createInterface({ input: process.stdin, output: process.stderr }) : undefined;
|
|
116
|
+
let connectionSaved = false;
|
|
123
117
|
const ask = async (label: string, fallback?: string) => {
|
|
124
118
|
const cancellation = new AbortController();
|
|
125
119
|
const cancel = () => cancellation.abort();
|
|
126
120
|
prompt!.once('SIGINT', cancel);
|
|
127
121
|
prompt!.once('close', cancel);
|
|
128
122
|
try {
|
|
123
|
+
// eslint-disable-next-line no-control-regex -- Server names must not inject terminal escapes into prompts.
|
|
124
|
+
label = label.replace(/[\x00-\x09\x0b-\x1f\x7f]/g, ' ');
|
|
129
125
|
const answer = (await prompt!.question(`${label}${fallback ? ` [${fallback}]` : ''}: `, { signal: cancellation.signal })).trim();
|
|
130
126
|
return answer || fallback;
|
|
131
127
|
} catch (error) {
|
|
132
|
-
if (cancellation.signal.aborted) throw new InputError('Init canceled. No files were changed.');
|
|
128
|
+
if (cancellation.signal.aborted) throw new InputError((connectionSaved || options.connect) ? 'Init canceled. Completed login/project setup is retained. Rerun init to reuse a saved connection, or init --connect to resume project selection.' : 'Init canceled. No files were changed.');
|
|
133
129
|
throw error;
|
|
134
130
|
} finally {
|
|
135
131
|
prompt!.removeListener('SIGINT', cancel);
|
|
@@ -137,12 +133,33 @@ export async function initialize(options: InitOptions, output: Output) {
|
|
|
137
133
|
}
|
|
138
134
|
};
|
|
139
135
|
try {
|
|
136
|
+
if (options.connect && (options.apiKeyEnv || options.credentialId || options.endpoint || options.project)) throw new InputError('--connect selects its project in the terminal after account login. Remove --endpoint, --project, --api-key-env and --credential-id, or use the existing-credential setup without --connect.');
|
|
137
|
+
if (options.connect && !interactive) throw new InputError('Browser authorization requires an interactive init session. For automation, use an existing OS credential reference or export a project key and pass --non-interactive --agents ... --scope ... --api-key-env NAME.');
|
|
140
138
|
if (interactive) {
|
|
141
|
-
|
|
142
|
-
options.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
139
|
+
let saved = await loadSettings(configPath(options));
|
|
140
|
+
if (!options.connect && !saved.credentialStore && !options.endpoint && !process.env.LAMBDADB_ENDPOINT && !saved.endpoint) {
|
|
141
|
+
options.connect = (await ask('Connect through Console (signup/login), or use an existing environment key? Enter browser or env', 'browser')) === 'browser';
|
|
142
|
+
}
|
|
143
|
+
if (options.connect) {
|
|
144
|
+
const destination = await fileIdentity(configPath(options));
|
|
145
|
+
output.diagnostic(`Will update ${destination}, ${destination}.account.json and ${destination}.connections.json. Secrets go only to the OS credential store. Agent settings are reviewed afterwards.`);
|
|
146
|
+
if ((await ask('Continue with account login and terminal project setup? Type yes', 'no')) !== 'yes') throw new InputError('Init canceled.');
|
|
147
|
+
const cancellation = new AbortController();
|
|
148
|
+
const cancel = () => cancellation.abort();
|
|
149
|
+
prompt!.once('SIGINT', cancel); prompt!.once('close', cancel);
|
|
150
|
+
let selected: SelectedSettings;
|
|
151
|
+
try { selected = await terminalSetup({ ...options, signal: cancellation.signal }, output, ask); }
|
|
152
|
+
finally { prompt!.removeListener('SIGINT', cancel); prompt!.removeListener('close', cancel); }
|
|
153
|
+
options = { ...options, ...selected };
|
|
154
|
+
saved = selected;
|
|
155
|
+
connectionSaved = true;
|
|
156
|
+
}
|
|
157
|
+
if (!saved.credentialStore || (options.apiKeyEnv && !options.credentialStore)) {
|
|
158
|
+
options.endpoint = await ask('API endpoint', options.endpoint ?? process.env.LAMBDADB_ENDPOINT ?? saved.endpoint);
|
|
159
|
+
options.project = await ask('Existing project name', options.project ?? process.env.LAMBDADB_PROJECT ?? saved.project);
|
|
160
|
+
options.apiKeyEnv = await ask('Environment variable holding your API key (not its value)', options.apiKeyEnv ?? process.env.LAMBDADB_API_KEY_ENV ?? saved.apiKeyEnv ?? 'LAMBDADB_API_KEY');
|
|
161
|
+
output.protect(options.apiKeyEnv ? process.env[options.apiKeyEnv] : undefined);
|
|
162
|
+
}
|
|
146
163
|
output.diagnostic(`Detected agents (heuristic): ${detected.join(', ') || 'none'}. You may also select an undetected agent.`);
|
|
147
164
|
options.agents ??= await ask(`Agents: ${agentNames} (comma-separated), or none`, detected.join(',') || 'none');
|
|
148
165
|
const scopes = availableScopes(selectedAgents(options.agents));
|
|
@@ -156,27 +173,29 @@ export async function initialize(options: InitOptions, output: Output) {
|
|
|
156
173
|
}
|
|
157
174
|
// Use the existing precedence and validation for both saved settings and auth.
|
|
158
175
|
const selected = await settings(options, true);
|
|
176
|
+
if (!selected.credentialStore) output.diagnostic('Manual Console step: obtain an existing project endpoint, name and API key at https://app.lambdadb.ai. Export the key securely; environment setup never saves its value.');
|
|
159
177
|
options = { ...options, ...selected };
|
|
160
178
|
output.protect(process.env[selected.apiKeyEnv]);
|
|
161
|
-
if (!process.env[selected.apiKeyEnv]?.trim()) throw new InputError(`Export ${selected.apiKeyEnv} securely in this shell, then rerun init. Obtain a project API key in the LambdaDB Console; never pass its value as a flag.`);
|
|
179
|
+
if (!selected.credentialStore && !process.env[selected.apiKeyEnv]?.trim()) throw new InputError(`Export ${selected.apiKeyEnv} securely in this shell, then rerun init. Obtain a project API key in the LambdaDB Console; never pass its value as a flag.`);
|
|
162
180
|
const config = await resolveConfig(options, true);
|
|
181
|
+
output.protect(config.apiKey);
|
|
163
182
|
const changes = await plan(selected, chosen, options.scope as AgentScope, options, context);
|
|
164
183
|
const files = changes.map(change => ({ path: change.path, ...(change.aliases.length ? { aliases: change.aliases } : {}), ...(change.destination !== change.path ? { destination: change.destination } : {}), kind: change.kind,
|
|
165
184
|
action: change.before === change.after ? 'unchanged' : change.before === undefined ? 'create' : 'update' }));
|
|
166
185
|
for (const file of files) output.diagnostic(`${file.action}: ${[file.path, ...(file.aliases ?? [])].join(', ')}${file.destination ? ` -> ${file.destination}` : ''} — ${file.kind}`);
|
|
167
|
-
output.diagnostic(`MCP: ${mcpPackage}; write tools ${options.writeTools ? 'enabled (explicit opt-in)' : 'disabled'}. Agents must inherit ${selected.apiKeyEnv}; only its name is stored.`);
|
|
186
|
+
output.diagnostic(`MCP: ${mcpPackage}; write tools ${options.writeTools ? 'enabled (explicit opt-in)' : 'disabled'}. ${selected.credentialStore ? `The bridge reads ${selected.credentialStore} using a non-secret reference.` : `Agents must inherit ${selected.apiKeyEnv}; only its name is stored.`}`);
|
|
168
187
|
if (interactive && (await ask('Apply these changes after verifying collection-list access? Type yes', 'no')) !== 'yes') {
|
|
169
|
-
throw new InputError('Init canceled. No files were changed.');
|
|
188
|
+
throw new InputError((connectionSaved || options.connect) ? 'Init canceled. Completed login/project setup is retained. Rerun init to reuse a saved connection, or init --connect to resume project selection.' : 'Init canceled. No files were changed.');
|
|
170
189
|
}
|
|
171
190
|
const ctx = runtime(config);
|
|
172
191
|
let connection;
|
|
173
192
|
try { connection = await checkConnection(ctx); } finally { ctx.close(); }
|
|
174
193
|
await apply(changes, options);
|
|
175
194
|
const nextSteps = [
|
|
176
|
-
`Restart/reload selected agents from an environment containing ${selected.apiKeyEnv} and npx on PATH. GUI launches may not inherit shell exports.`,
|
|
195
|
+
selected.credentialStore ? `Restart/reload selected agents as the same OS user with ${selected.credentialStore} accessible and npx on PATH. No shell key export is required.` : `Restart/reload selected agents from an environment containing ${selected.apiKeyEnv} and npx on PATH. GUI launches may not inherit shell exports.`,
|
|
177
196
|
agentAdapters.filter(adapter => chosen.includes(adapter.id)).map(adapter => adapter.instructions).join(' '),
|
|
178
|
-
|
|
179
|
-
'CLI
|
|
197
|
+
`Agent prompt: Prefer LambdaDB CLI with --json and configuration file ${JSON.stringify(configPath(options))}; use the matching MCP connection if terminal execution is unavailable or I request MCP. List collections, inspect the collection I choose, then read a small sample at my selected ref (branch:main if none is intended). Help me search an actual indexed field. Do not create or modify data.`,
|
|
198
|
+
'CLI reads: run lambdadb collections list --json and lambdadb collections describe --collection NAME --json. Save {"size":5} in first-query.json, then run lambdadb query --collection NAME --ref branch:main --file first-query.json --json. Pass the configuration file above with --config; replace NAME and ref with the chosen target.',
|
|
180
199
|
'If the project has no collections/data, populate it deliberately outside init. Connection success does not establish search readiness.',
|
|
181
200
|
];
|
|
182
201
|
return { ...connection, settings: selected, detectedAgents: detected, agents: chosen, scope: options.scope,
|
package/src/mcp.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { InputError } from './errors.js';
|
|
|
6
6
|
export const mcpPackage = '@functional-systems/lambdadb-mcp@0.1.5';
|
|
7
7
|
|
|
8
8
|
export function mcpEnvironment(config: Config, writeTools: boolean): NodeJS.ProcessEnv {
|
|
9
|
-
|
|
9
|
+
const env: NodeJS.ProcessEnv = {
|
|
10
10
|
...process.env,
|
|
11
11
|
LAMBDADB_BASE_URL: config.endpoint,
|
|
12
12
|
LAMBDADB_PROJECT_NAME: config.project,
|
|
@@ -14,6 +14,12 @@ export function mcpEnvironment(config: Config, writeTools: boolean): NodeJS.Proc
|
|
|
14
14
|
LAMBDADB_MCP_ENABLE_WRITE_TOOLS: String(writeTools),
|
|
15
15
|
LAMBDADB_DEBUG: 'false',
|
|
16
16
|
};
|
|
17
|
+
// npm exec exports these selectors to its child. They must not override the
|
|
18
|
+
// separate MCP package and command supplied explicitly to the nested npx.
|
|
19
|
+
for (const key of Object.keys(env)) {
|
|
20
|
+
if (/^npm_config_(package|call)$/i.test(key)) delete env[key];
|
|
21
|
+
}
|
|
22
|
+
return env;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export async function serveMcp(config: Config, writeTools: boolean): Promise<number> {
|