@gmickel/gno 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Direct verified-folder setup CLI composition.
3
+ *
4
+ * @module src/cli/commands/setup
5
+ */
6
+
7
+ // node:readline/promises is the platform line-input API; Bun has no equivalent
8
+ // for one default-No terminal confirmation.
9
+ import { createInterface } from "node:readline/promises";
10
+
11
+ import type {
12
+ FolderSetupError,
13
+ FolderSetupOptions,
14
+ FolderSetupResult,
15
+ } from "../../core/folder-setup";
16
+ import type {
17
+ FolderSetupReceipt,
18
+ SetupStageName,
19
+ } from "../../core/setup-receipt";
20
+ import type { SqliteAdapter } from "../../store/sqlite/adapter";
21
+
22
+ import { getIndexDbPath } from "../../app/constants";
23
+ import {
24
+ getConfigPaths,
25
+ isInitialized,
26
+ loadConfig,
27
+ toAbsolutePath,
28
+ } from "../../config";
29
+ import { setupFolder } from "../../core/folder-setup";
30
+ import { persistSetupReceipt } from "../../core/setup-receipt";
31
+ import { SqliteAdapter as DefaultSqliteAdapter } from "../../store/sqlite/adapter";
32
+ import { init } from "./init";
33
+ import {
34
+ scheduleSetupSemantic,
35
+ type SetupSemanticReceipt,
36
+ } from "./setup-semantic";
37
+
38
+ export const SETUP_COMMAND_SCHEMA_VERSION = "1.0" as const;
39
+
40
+ export interface SetupCommandError {
41
+ code: string;
42
+ message: string;
43
+ remediation: string;
44
+ }
45
+
46
+ export interface SetupCommandResult {
47
+ schemaVersion: typeof SETUP_COMMAND_SCHEMA_VERSION;
48
+ status: "completed" | "failed";
49
+ lexical: {
50
+ receipt: FolderSetupReceipt | null;
51
+ error: SetupCommandError | null;
52
+ };
53
+ semantic: SetupSemanticReceipt | null;
54
+ }
55
+
56
+ export interface SetupCommandOptions {
57
+ folder: string;
58
+ name?: string;
59
+ exclude?: string[];
60
+ authorizeSecretRisk?: boolean;
61
+ semantic?: boolean;
62
+ indexName?: string;
63
+ configPath?: string;
64
+ offline?: boolean;
65
+ yes?: boolean;
66
+ json?: boolean;
67
+ quiet?: boolean;
68
+ stdinIsTTY?: boolean;
69
+ stderrIsTTY?: boolean;
70
+ progress?: (stage: SetupStageName, receipt: FolderSetupReceipt) => void;
71
+ confirmSecretRisk?: (receipt: FolderSetupReceipt) => Promise<boolean>;
72
+ setupFolderFn?: (options: FolderSetupOptions) => Promise<FolderSetupResult>;
73
+ scheduleSemanticFn?: typeof scheduleSetupSemantic;
74
+ initFn?: typeof init;
75
+ isInitializedFn?: typeof isInitialized;
76
+ createStore?: () => SqliteAdapter;
77
+ }
78
+
79
+ export interface SetupCommandOutcome {
80
+ result: SetupCommandResult;
81
+ exitCode: 0 | 1 | 2;
82
+ }
83
+
84
+ const VALIDATION_ERROR_CODES = new Set([
85
+ "folder_not_found",
86
+ "folder_not_directory",
87
+ "folder_unreadable",
88
+ "dangerous_root",
89
+ "secret_risk",
90
+ "empty_folder",
91
+ "unsupported_only",
92
+ "no_indexable_lexical_corpus",
93
+ "invalid_collection_name",
94
+ "collection_name_conflict",
95
+ "collection_overlap",
96
+ "collection_filter_disagreement",
97
+ "store_index_mismatch",
98
+ "setup_path_overlap",
99
+ ]);
100
+
101
+ function commandError(
102
+ code: string,
103
+ message: string,
104
+ remediation: string
105
+ ): SetupCommandError {
106
+ return { code, message, remediation };
107
+ }
108
+
109
+ function failureOutcome(
110
+ error: SetupCommandError,
111
+ receipt: FolderSetupReceipt | null,
112
+ exitCode: 1 | 2
113
+ ): SetupCommandOutcome {
114
+ return {
115
+ result: {
116
+ schemaVersion: SETUP_COMMAND_SCHEMA_VERSION,
117
+ status: "failed",
118
+ lexical: { receipt, error },
119
+ semantic: null,
120
+ },
121
+ exitCode,
122
+ };
123
+ }
124
+
125
+ function exitCodeForSetupError(error: FolderSetupError): 1 | 2 {
126
+ return VALIDATION_ERROR_CODES.has(error.code) ? 1 : 2;
127
+ }
128
+
129
+ function firstActiveStage(receipt: FolderSetupReceipt): SetupStageName {
130
+ const stages = Object.entries(receipt.stages);
131
+ const active = stages.find(([, stage]) => stage.status === "in_progress");
132
+ if (active) {
133
+ return active[0] as SetupStageName;
134
+ }
135
+ const failed = stages.find(([, stage]) => stage.status === "failed");
136
+ if (failed) {
137
+ return failed[0] as SetupStageName;
138
+ }
139
+ const lastPassed = stages
140
+ .reverse()
141
+ .find(([, stage]) => stage.status === "passed");
142
+ return (lastPassed?.[0] as SetupStageName | undefined) ?? "preflight";
143
+ }
144
+
145
+ export async function terminalSecretConfirmation(
146
+ receipt: FolderSetupReceipt,
147
+ ask?: (question: string) => Promise<string>
148
+ ): Promise<boolean> {
149
+ process.stderr.write(
150
+ `Potential secret files detected in ${receipt.input.folder}\n`
151
+ );
152
+ process.stderr.write(
153
+ `Effective exclusions: ${receipt.input.excludes.join(", ") || "(none)"}\n`
154
+ );
155
+ const prompt = ask
156
+ ? null
157
+ : createInterface({
158
+ input: process.stdin,
159
+ output: process.stderr,
160
+ });
161
+ try {
162
+ try {
163
+ const answer = await (ask ?? prompt!.question.bind(prompt))(
164
+ "Index this folder despite the secret-file risk? [y/N] "
165
+ );
166
+ return /^(?:y|yes)$/i.test(answer.trim());
167
+ } catch {
168
+ return false;
169
+ }
170
+ } finally {
171
+ prompt?.close();
172
+ }
173
+ }
174
+
175
+ export function lexicalSuccessIsProven(receipt: FolderSetupReceipt): boolean {
176
+ const resultUri = receipt.activation?.evidence.resultUri;
177
+ return (
178
+ receipt.status === "completed" &&
179
+ receipt.activation?.ready === true &&
180
+ typeof resultUri === "string" &&
181
+ resultUri.length > 0
182
+ );
183
+ }
184
+
185
+ function validateSetupArguments(
186
+ options: SetupCommandOptions
187
+ ): SetupCommandError | null {
188
+ if (!options.folder.trim()) {
189
+ return commandError(
190
+ "invalid_folder",
191
+ "Folder is required",
192
+ "Pass a readable local folder to `gno setup`."
193
+ );
194
+ }
195
+ if (options.exclude?.some((value) => value.length === 0)) {
196
+ return commandError(
197
+ "invalid_exclusion",
198
+ "--exclude requires a non-empty literal pattern",
199
+ "Remove the empty occurrence or pass a literal exclusion pattern."
200
+ );
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * Execute the standalone setup transaction. This function returns classified
207
+ * outcomes; the Commander surface owns stdout/stderr rendering.
208
+ */
209
+ async function executeSetup(
210
+ options: SetupCommandOptions
211
+ ): Promise<SetupCommandOutcome> {
212
+ const argumentError = validateSetupArguments(options);
213
+ if (argumentError) {
214
+ return failureOutcome(argumentError, null, 1);
215
+ }
216
+
217
+ const paths = getConfigPaths();
218
+ const configPath = toAbsolutePath(options.configPath ?? paths.configFile);
219
+ const dataDir = paths.dataDir;
220
+ const indexName = options.indexName ?? "default";
221
+ const initialized = await (options.isInitializedFn ?? isInitialized)(
222
+ configPath
223
+ );
224
+ if (!initialized) {
225
+ const initializedResult = await (options.initFn ?? init)({
226
+ configPath,
227
+ yes: true,
228
+ });
229
+ if (!initializedResult.success) {
230
+ return failureOutcome(
231
+ commandError(
232
+ "bootstrap_failed",
233
+ initializedResult.error ?? "Failed to initialize GNO",
234
+ "Fix config/data-directory permissions and rerun setup."
235
+ ),
236
+ null,
237
+ 2
238
+ );
239
+ }
240
+ }
241
+
242
+ const configResult = await loadConfig(configPath);
243
+ if (!configResult.ok) {
244
+ return failureOutcome(
245
+ commandError(
246
+ "config_load_failed",
247
+ configResult.error.message,
248
+ "Repair the selected config and rerun setup."
249
+ ),
250
+ null,
251
+ 2
252
+ );
253
+ }
254
+
255
+ const store =
256
+ options.createStore?.() ?? (new DefaultSqliteAdapter() as SqliteAdapter);
257
+ store.setConfigPath(configPath);
258
+ const opened = await store.open(
259
+ getIndexDbPath(indexName),
260
+ configResult.value.ftsTokenizer
261
+ );
262
+ if (!opened.ok) {
263
+ await store.close();
264
+ return failureOutcome(
265
+ commandError(
266
+ "store_open_failed",
267
+ opened.error.message,
268
+ "Repair the selected index database and rerun setup."
269
+ ),
270
+ null,
271
+ 2
272
+ );
273
+ }
274
+
275
+ const setupFolderFn = options.setupFolderFn ?? setupFolder;
276
+ let lastProgress: string | null = null;
277
+ const receiptWriter = async (receipt: FolderSetupReceipt): Promise<void> => {
278
+ await persistSetupReceipt(receipt);
279
+ if (options.quiet || options.json) {
280
+ return;
281
+ }
282
+ const stage = firstActiveStage(receipt);
283
+ const key = stage;
284
+ if (lastProgress !== key) {
285
+ lastProgress = key;
286
+ options.progress?.(stage, receipt);
287
+ }
288
+ };
289
+
290
+ const runCore = (authorized: boolean): Promise<FolderSetupResult> =>
291
+ setupFolderFn({
292
+ folder: options.folder,
293
+ store,
294
+ configPath,
295
+ dataDir,
296
+ indexName,
297
+ name: options.name,
298
+ exclude: options.exclude,
299
+ secretRiskAuthorized: authorized,
300
+ receiptWriter,
301
+ });
302
+
303
+ try {
304
+ let lexicalResult = await runCore(options.authorizeSecretRisk === true);
305
+ if (
306
+ !lexicalResult.ok &&
307
+ lexicalResult.error.code === "secret_risk" &&
308
+ lexicalResult.receipt &&
309
+ options.authorizeSecretRisk !== true
310
+ ) {
311
+ const mayPrompt =
312
+ options.json !== true &&
313
+ options.yes !== true &&
314
+ (options.stdinIsTTY ?? process.stdin.isTTY ?? false) &&
315
+ (options.stderrIsTTY ?? process.stderr.isTTY ?? false);
316
+ if (mayPrompt) {
317
+ const confirmed = await (
318
+ options.confirmSecretRisk ?? terminalSecretConfirmation
319
+ )(lexicalResult.receipt);
320
+ if (confirmed) {
321
+ lexicalResult = await runCore(true);
322
+ }
323
+ }
324
+ }
325
+
326
+ if (!lexicalResult.ok) {
327
+ return failureOutcome(
328
+ lexicalResult.error,
329
+ lexicalResult.receipt,
330
+ exitCodeForSetupError(lexicalResult.error)
331
+ );
332
+ }
333
+ if (!lexicalSuccessIsProven(lexicalResult.receipt)) {
334
+ return failureOutcome(
335
+ commandError(
336
+ "lexical_success_invariant_failed",
337
+ "Setup completed without an exact lexical retrieval result",
338
+ "Rerun setup after repairing the selected index."
339
+ ),
340
+ lexicalResult.receipt,
341
+ 2
342
+ );
343
+ }
344
+
345
+ const semantic = await (
346
+ options.scheduleSemanticFn ?? scheduleSetupSemantic
347
+ )({
348
+ setupReceipt: lexicalResult.receipt,
349
+ dataDir,
350
+ configPath,
351
+ indexName,
352
+ offline: options.offline ?? false,
353
+ disabled: options.semantic === false,
354
+ });
355
+ return {
356
+ result: {
357
+ schemaVersion: SETUP_COMMAND_SCHEMA_VERSION,
358
+ status: "completed",
359
+ lexical: {
360
+ receipt: lexicalResult.receipt,
361
+ error: null,
362
+ },
363
+ semantic,
364
+ },
365
+ exitCode: 0,
366
+ };
367
+ } finally {
368
+ await store.close();
369
+ }
370
+ }
371
+
372
+ export async function setup(
373
+ options: SetupCommandOptions
374
+ ): Promise<SetupCommandOutcome> {
375
+ try {
376
+ return await executeSetup(options);
377
+ } catch (error) {
378
+ return failureOutcome(
379
+ commandError(
380
+ "setup_runtime_failed",
381
+ error instanceof Error ? error.message : String(error),
382
+ "Fix the reported local setup error and rerun setup."
383
+ ),
384
+ null,
385
+ 2
386
+ );
387
+ }
388
+ }
389
+
390
+ export function formatSetupResult(
391
+ result: SetupCommandResult,
392
+ options: { json: boolean }
393
+ ): string {
394
+ if (options.json) {
395
+ return JSON.stringify(result, null, 2);
396
+ }
397
+ const receipt = result.lexical.receipt;
398
+ if (result.status === "failed") {
399
+ const error = result.lexical.error;
400
+ return `${error?.code ?? "setup_failed"}: ${error?.message ?? "Setup failed"}. ${error?.remediation ?? ""}`.trim();
401
+ }
402
+ const semantic = result.semantic;
403
+ return [
404
+ `Setup ${receipt?.collection.disposition}: ${receipt?.collection.name}`,
405
+ `result=${receipt?.activation?.evidence.resultUri}`,
406
+ `receipt=${receipt?.paths.receipt}`,
407
+ `semantic=${semantic?.status ?? "pending"}`,
408
+ `resume=${semantic?.resumeCommand ?? "gno embed"}`,
409
+ ].join(" ");
410
+ }
@@ -1346,6 +1346,69 @@ function wireOnboardingCommands(program: Command): void {
1346
1346
  }
1347
1347
  );
1348
1348
 
1349
+ // setup - Verify a folder is lexically retrievable, then hand off semantics
1350
+ program
1351
+ .command("setup <folder>")
1352
+ .description("Add and verify a folder with a real lexical retrieval")
1353
+ .option("-n, --name <name>", "collection name")
1354
+ .option(
1355
+ "--exclude <pattern>",
1356
+ "literal exclusion pattern (repeatable)",
1357
+ collectRepeatableValue,
1358
+ []
1359
+ )
1360
+ .option(
1361
+ "--authorize-secret-risk",
1362
+ "explicitly authorize indexing likely secret files"
1363
+ )
1364
+ .option(
1365
+ "--connector <id>",
1366
+ "install and verify one connector (repeatable)",
1367
+ collectRepeatableValue,
1368
+ []
1369
+ )
1370
+ .option("--no-semantic", "skip background semantic indexing")
1371
+ .option("--json", "JSON output")
1372
+ .action(async (folder: string, cmdOpts: Record<string, unknown>) => {
1373
+ const globals = getGlobals();
1374
+ const json = Boolean(cmdOpts.json) || globals.json;
1375
+ const { formatSetupOutputResult, setupWithActivation } =
1376
+ await import("./commands/setup-activation");
1377
+ const exclusions = cmdOpts.exclude as string[];
1378
+ const outcome = await setupWithActivation({
1379
+ folder,
1380
+ name: cmdOpts.name as string | undefined,
1381
+ exclude: exclusions.length > 0 ? exclusions : undefined,
1382
+ authorizeSecretRisk: Boolean(cmdOpts.authorizeSecretRisk),
1383
+ connectorIds: cmdOpts.connector as string[],
1384
+ semantic: cmdOpts.semantic !== false,
1385
+ indexName: globals.index,
1386
+ configPath: globals.config,
1387
+ offline: globals.offline,
1388
+ yes: globals.yes,
1389
+ json,
1390
+ quiet: globals.quiet,
1391
+ progress: (stage) => {
1392
+ process.stderr.write(`setup: ${stage}\n`);
1393
+ },
1394
+ });
1395
+ const output = formatSetupOutputResult(outcome.result, { json });
1396
+ if (json || outcome.exitCode === 0) {
1397
+ process.stdout.write(`${output}\n`);
1398
+ } else {
1399
+ process.stderr.write(`${output}\n`);
1400
+ }
1401
+ if (outcome.exitCode !== 0) {
1402
+ const setupResult =
1403
+ "setup" in outcome.result ? outcome.result.setup : outcome.result;
1404
+ throw new CliError(
1405
+ outcome.exitCode === 1 ? "VALIDATION" : "RUNTIME",
1406
+ setupResult.lexical.error?.message ?? "Setup failed",
1407
+ { silent: true }
1408
+ );
1409
+ }
1410
+ });
1411
+
1349
1412
  // index - Index collections
1350
1413
  program
1351
1414
  .command("index [collection]")
@@ -2398,6 +2461,7 @@ function wireManagementCommands(program: Command): void {
2398
2461
  yes: globals.yes,
2399
2462
  json: format === "json",
2400
2463
  verbose: globals.verbose,
2464
+ offline: globals.offline,
2401
2465
  };
2402
2466
  const result = await embed(opts);
2403
2467
 
@@ -0,0 +1,177 @@
1
+ /**
2
+ * One-shot semantic worker started by `gno setup`.
3
+ *
4
+ * It owns its store/model lifecycle through the existing collection-scoped
5
+ * embed command, updates one durable receipt, and exits.
6
+ *
7
+ * @module src/cli/setup-semantic-worker
8
+ */
9
+
10
+ import { loadSetupReceipt } from "../core/setup-receipt";
11
+ import { embed, type EmbedOptions, type EmbedResult } from "./commands/embed";
12
+ import {
13
+ loadSetupSemanticReceipt,
14
+ type SetupSemanticReceipt,
15
+ setupSemanticSourceFingerprint,
16
+ updateSetupSemanticReceipt,
17
+ } from "./commands/setup-semantic";
18
+
19
+ const PARENT_REGISTRATION_TIMEOUT_MS = 2000;
20
+ const PARENT_REGISTRATION_POLL_MS = 20;
21
+ const MAX_ERROR_LENGTH = 500;
22
+
23
+ export interface SetupSemanticWorkerDependencies {
24
+ embedFn?: (options: EmbedOptions) => Promise<EmbedResult>;
25
+ now?: () => Date;
26
+ }
27
+
28
+ function boundedError(error: unknown): string {
29
+ return (
30
+ (error instanceof Error ? error.message : String(error)).slice(
31
+ 0,
32
+ MAX_ERROR_LENGTH
33
+ ) || "Unknown semantic setup error"
34
+ );
35
+ }
36
+
37
+ async function waitForParentRegistration(
38
+ receiptPath: string,
39
+ jobId: string
40
+ ): Promise<SetupSemanticReceipt> {
41
+ const deadline = Date.now() + PARENT_REGISTRATION_TIMEOUT_MS;
42
+ while (Date.now() < deadline) {
43
+ const receipt = await loadSetupSemanticReceipt(receiptPath);
44
+ if (
45
+ receipt?.jobId === jobId &&
46
+ (receipt.pid === process.pid ||
47
+ receipt.status === "pending" ||
48
+ receipt.status === "skipped")
49
+ ) {
50
+ return receipt;
51
+ }
52
+ await Bun.sleep(PARENT_REGISTRATION_POLL_MS);
53
+ }
54
+ throw new Error("Setup parent did not register the semantic worker");
55
+ }
56
+
57
+ export async function runSetupSemanticWorker(
58
+ receiptPath: string,
59
+ jobId: string,
60
+ dependencies: SetupSemanticWorkerDependencies = {}
61
+ ): Promise<number> {
62
+ try {
63
+ const registered = await waitForParentRegistration(receiptPath, jobId);
64
+ if (registered.status === "pending") {
65
+ return 2;
66
+ }
67
+ if (registered.status === "skipped") {
68
+ return 0;
69
+ }
70
+
71
+ const setupReceipt = await loadSetupReceipt(registered.setupReceiptPath);
72
+ if (
73
+ !setupReceipt ||
74
+ setupReceipt.status !== "completed" ||
75
+ setupReceipt.collection.name !== registered.collection ||
76
+ setupReceipt.input.indexName !== registered.indexName ||
77
+ setupReceipt.paths.receipt !== registered.setupReceiptPath ||
78
+ setupSemanticSourceFingerprint(setupReceipt) !==
79
+ registered.setupReceiptFingerprint
80
+ ) {
81
+ throw new Error("Lexical setup receipt no longer matches semantic job");
82
+ }
83
+
84
+ const startedAt = (dependencies.now ?? (() => new Date()))().toISOString();
85
+ await updateSetupSemanticReceipt(receiptPath, jobId, (current) => ({
86
+ ...current,
87
+ status: "running",
88
+ generatedAt: startedAt,
89
+ startedAt: current.startedAt ?? startedAt,
90
+ completedAt: null,
91
+ pid: process.pid,
92
+ counts: null,
93
+ error: null,
94
+ }));
95
+
96
+ const result = await (dependencies.embedFn ?? embed)({
97
+ configPath: setupReceipt.paths.config,
98
+ indexName: registered.indexName,
99
+ collection: registered.collection,
100
+ yes: true,
101
+ json: true,
102
+ offline: registered.offline,
103
+ });
104
+ if (!result.success) {
105
+ throw new Error(result.error);
106
+ }
107
+ if (result.errors > 0 || result.syncError) {
108
+ const completedAt = (
109
+ dependencies.now ?? (() => new Date())
110
+ )().toISOString();
111
+ const message = result.syncError
112
+ ? `Vector index sync failed: ${result.syncError}`
113
+ : `Embedding completed with ${result.errors} failed chunk${result.errors === 1 ? "" : "s"}`;
114
+ await updateSetupSemanticReceipt(receiptPath, jobId, (current) => ({
115
+ ...current,
116
+ status: "failed",
117
+ generatedAt: completedAt,
118
+ completedAt,
119
+ pid: null,
120
+ counts: {
121
+ embedded: result.embedded,
122
+ errors: result.errors,
123
+ },
124
+ error: {
125
+ message: boundedError(message),
126
+ remediation: `Run: ${current.resumeCommand}`,
127
+ },
128
+ }));
129
+ return 2;
130
+ }
131
+
132
+ const completedAt = (
133
+ dependencies.now ?? (() => new Date())
134
+ )().toISOString();
135
+ await updateSetupSemanticReceipt(receiptPath, jobId, (current) => ({
136
+ ...current,
137
+ status: "completed",
138
+ generatedAt: completedAt,
139
+ completedAt,
140
+ pid: null,
141
+ counts: {
142
+ embedded: result.embedded,
143
+ errors: result.errors,
144
+ },
145
+ error: null,
146
+ }));
147
+ return 0;
148
+ } catch (error) {
149
+ const completedAt = (
150
+ dependencies.now ?? (() => new Date())
151
+ )().toISOString();
152
+ await updateSetupSemanticReceipt(receiptPath, jobId, (current) => ({
153
+ ...current,
154
+ status: "failed",
155
+ generatedAt: completedAt,
156
+ startedAt: current.startedAt ?? completedAt,
157
+ completedAt,
158
+ pid: null,
159
+ counts: null,
160
+ error: {
161
+ message: boundedError(error),
162
+ remediation: `Run: ${current.resumeCommand}`,
163
+ },
164
+ })).catch(() => undefined);
165
+ return 2;
166
+ }
167
+ }
168
+
169
+ if (import.meta.main) {
170
+ const receiptPath = process.argv[2];
171
+ const jobId = process.argv[3];
172
+ if (!(receiptPath && jobId)) {
173
+ process.exitCode = 1;
174
+ } else {
175
+ process.exitCode = await runSetupSemanticWorker(receiptPath, jobId);
176
+ }
177
+ }