@gmickel/gno 1.12.4 → 1.14.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.
Files changed (61) hide show
  1. package/README.md +126 -59
  2. package/assets/skill/SKILL.md +8 -1
  3. package/assets/skill/cli-reference.md +18 -7
  4. package/assets/skill/mcp-reference.md +22 -3
  5. package/package.json +3 -1
  6. package/src/app/constants.ts +43 -10
  7. package/src/app/index-name.ts +127 -0
  8. package/src/cli/commands/doctor-activation.ts +151 -0
  9. package/src/cli/commands/doctor.ts +41 -16
  10. package/src/cli/commands/get.ts +18 -0
  11. package/src/cli/commands/mcp/atomic-config-write.ts +118 -0
  12. package/src/cli/commands/mcp/config-discovery.ts +42 -0
  13. package/src/cli/commands/mcp/config-editors.ts +432 -0
  14. package/src/cli/commands/mcp/config.ts +63 -160
  15. package/src/cli/commands/mcp/install.ts +75 -37
  16. package/src/cli/commands/mcp/paths.ts +141 -136
  17. package/src/cli/commands/mcp/server-entry.ts +66 -0
  18. package/src/cli/commands/mcp/status.ts +189 -57
  19. package/src/cli/commands/mcp/target-display.ts +30 -0
  20. package/src/cli/commands/mcp/uninstall.ts +29 -31
  21. package/src/cli/commands/mcp/yaml-config-editor.ts +257 -0
  22. package/src/cli/commands/mcp/yaml-layout-scanner.ts +447 -0
  23. package/src/cli/commands/multi-get.ts +31 -6
  24. package/src/cli/commands/status.ts +107 -11
  25. package/src/cli/program.ts +66 -20
  26. package/src/core/activation-connector-health.ts +19 -0
  27. package/src/core/activation-probe-plan.ts +321 -0
  28. package/src/core/activation-probe.ts +138 -0
  29. package/src/core/activation-receipt-store.ts +39 -0
  30. package/src/core/activation-status.ts +513 -0
  31. package/src/core/activation-verifier.ts +416 -0
  32. package/src/core/connector-environment.ts +68 -0
  33. package/src/core/connector-policy.ts +233 -0
  34. package/src/core/connector-verification-target.ts +150 -0
  35. package/src/core/connector-verifier.ts +497 -0
  36. package/src/core/indexed-reference.ts +33 -8
  37. package/src/core/runtime-entrypoint.ts +24 -0
  38. package/src/mcp/activation-verification-mode.ts +4 -0
  39. package/src/mcp/server.ts +9 -2
  40. package/src/sdk/client.ts +7 -0
  41. package/src/sdk/types.ts +1 -0
  42. package/src/serve/activation-health.ts +91 -0
  43. package/src/serve/background-runtime.ts +11 -1
  44. package/src/serve/connectors.ts +164 -19
  45. package/src/serve/public/components/BootstrapStatus.tsx +94 -1
  46. package/src/serve/public/components/FirstRunWizard.tsx +13 -51
  47. package/src/serve/public/components/HealthCenter.tsx +8 -2
  48. package/src/serve/public/globals.built.css +1 -1
  49. package/src/serve/public/pages/Connectors.tsx +216 -55
  50. package/src/serve/public/pages/Dashboard.tsx +1 -0
  51. package/src/serve/routes/api.ts +152 -8
  52. package/src/serve/server.ts +44 -9
  53. package/src/serve/status-model.ts +4 -0
  54. package/src/serve/status.ts +79 -35
  55. package/src/store/activation-receipts.ts +390 -0
  56. package/src/store/index.ts +8 -0
  57. package/src/store/migrations/012-activation-receipts.ts +38 -0
  58. package/src/store/migrations/013-fts-sync-marker.ts +39 -0
  59. package/src/store/migrations/index.ts +4 -0
  60. package/src/store/sqlite/adapter.ts +313 -53
  61. package/src/store/types.ts +118 -0
@@ -0,0 +1,127 @@
1
+ /** Shared index-name contract for filesystem paths and connector trust checks. */
2
+
3
+ export const MAX_INDEX_NAME_LENGTH = 64;
4
+
5
+ const SAFE_INDEX_NAME_REGEX = /^[\p{L}\p{N}][\p{L}\p{M}\p{N} ._-]*$/u;
6
+ const INDEX_DB_PREFIX = "index-";
7
+ const INDEX_DB_SUFFIX = ".sqlite";
8
+ const MAX_PORTABLE_FILENAME_COMPONENT_LENGTH = 255;
9
+ const MAX_INDEX_IDENTITY_STORAGE_LENGTH =
10
+ MAX_PORTABLE_FILENAME_COMPONENT_LENGTH -
11
+ INDEX_DB_PREFIX.length -
12
+ INDEX_DB_SUFFIX.length;
13
+ const UTF8_ENCODER = new TextEncoder();
14
+
15
+ export const INDEX_NAME_REQUIREMENTS =
16
+ "use 1-64 letters, marks, numbers, internal spaces, '.', '_' or '-', start with a letter or number, do not end with a space or '.', do not include '..', and fit the portable database filename limit";
17
+
18
+ function hasSafeIndexNameSyntax(value: string): boolean {
19
+ return (
20
+ SAFE_INDEX_NAME_REGEX.test(value) &&
21
+ !/[ .]$/.test(value) &&
22
+ !value.includes("..")
23
+ );
24
+ }
25
+
26
+ function canonicalizeIndexNameUnchecked(value: string): string {
27
+ return value
28
+ .normalize("NFC")
29
+ .toLowerCase()
30
+ .toUpperCase()
31
+ .toLowerCase()
32
+ .normalize("NFC");
33
+ }
34
+
35
+ function fitsIndexIdentityStorage(value: string): boolean {
36
+ return (
37
+ value.length <= MAX_INDEX_IDENTITY_STORAGE_LENGTH &&
38
+ UTF8_ENCODER.encode(value).byteLength <= MAX_INDEX_IDENTITY_STORAGE_LENGTH
39
+ );
40
+ }
41
+
42
+ /** Return whether a value is a canonical, filesystem-safe GNO index name. */
43
+ export function isValidIndexName(value: unknown): value is string {
44
+ if (
45
+ typeof value !== "string" ||
46
+ value.length > MAX_INDEX_NAME_LENGTH ||
47
+ !hasSafeIndexNameSyntax(value)
48
+ ) {
49
+ return false;
50
+ }
51
+ return fitsIndexIdentityStorage(canonicalizeIndexNameUnchecked(value));
52
+ }
53
+
54
+ /** Fail closed before an index name can influence a database path. */
55
+ export function assertValidIndexName(value: unknown): asserts value is string {
56
+ if (!isValidIndexName(value)) {
57
+ throw new TypeError(`Invalid index name: ${INDEX_NAME_REQUIREMENTS}.`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Return the cross-platform logical identity for an index name.
63
+ *
64
+ * APFS and Windows filesystems collapse canonical Unicode and case variants,
65
+ * while common Linux filesystems do not. GNO applies one identity everywhere
66
+ * so URI routing and database selection cannot disagree by platform.
67
+ */
68
+ export function canonicalizeIndexName(value: string): string {
69
+ assertValidIndexName(value);
70
+ // The lower/upper/lower closure covers multi-character folds (ß/SS),
71
+ // compatibility case pairs (ſ/S), and positional forms (ς/Σ) that a plain
72
+ // lowercase pass misses but case-insensitive APFS aliases on disk.
73
+ return canonicalizeIndexNameUnchecked(value);
74
+ }
75
+
76
+ /** Compare two validated names using GNO's cross-platform identity rules. */
77
+ export function indexNamesMatch(left: string, right: string): boolean {
78
+ return canonicalizeIndexName(left) === canonicalizeIndexName(right);
79
+ }
80
+
81
+ function indexNameFromDbFilename(filename: string): string | null {
82
+ if (
83
+ !filename.startsWith(INDEX_DB_PREFIX) ||
84
+ !filename.endsWith(INDEX_DB_SUFFIX)
85
+ ) {
86
+ return null;
87
+ }
88
+ const name = filename.slice(INDEX_DB_PREFIX.length, -INDEX_DB_SUFFIX.length);
89
+ const isCanonicalStoredIdentity =
90
+ hasSafeIndexNameSyntax(name) &&
91
+ fitsIndexIdentityStorage(name) &&
92
+ canonicalizeIndexNameUnchecked(name) === name;
93
+ return isValidIndexName(name) || isCanonicalStoredIdentity ? name : null;
94
+ }
95
+
96
+ /**
97
+ * Select one database filename for a logical index identity.
98
+ *
99
+ * New indexes use the canonical filename. A single existing mixed-case or
100
+ * pre-normalized filename remains addressable for backward compatibility.
101
+ * Multiple legacy files with the same identity are unsafe on case-sensitive
102
+ * filesystems and fail closed instead of selecting one by directory order.
103
+ */
104
+ export function resolveIndexDbFilename(
105
+ indexName: string,
106
+ existingFilenames: Iterable<string> = []
107
+ ): string {
108
+ const identity = canonicalizeIndexName(indexName);
109
+ const matches: string[] = [];
110
+ for (const filename of existingFilenames) {
111
+ const existingName = indexNameFromDbFilename(filename);
112
+ if (
113
+ existingName !== null &&
114
+ canonicalizeIndexNameUnchecked(existingName) === identity
115
+ ) {
116
+ matches.push(filename);
117
+ }
118
+ }
119
+
120
+ const uniqueMatches = [...new Set(matches)].sort();
121
+ if (uniqueMatches.length > 1) {
122
+ throw new TypeError(
123
+ `Ambiguous index name "${indexName}": multiple database files share its canonical identity (${uniqueMatches.join(", ")}).`
124
+ );
125
+ }
126
+ return uniqueMatches[0] ?? `${INDEX_DB_PREFIX}${identity}${INDEX_DB_SUFFIX}`;
127
+ }
@@ -0,0 +1,151 @@
1
+ /** Passive retrieval activation diagnostics shared by `gno doctor`. */
2
+
3
+ import type { Config } from "../../config/types";
4
+ import type { ActivationStatus } from "../../core/activation-status";
5
+ import type { StorePort } from "../../store/types";
6
+ import type { DoctorCheck } from "./doctor";
7
+
8
+ import { getIndexDbPath, getModelsCachePath } from "../../app/constants";
9
+ import { buildActivationStatus } from "../../core/activation-status";
10
+ import { ModelCache } from "../../llm/cache";
11
+ import { getActivePreset } from "../../llm/registry";
12
+ import { getConnectorVerificationTargets } from "../../serve/connectors";
13
+ import { SqliteAdapter } from "../../store/sqlite/adapter";
14
+
15
+ export interface DoctorActivationOptions {
16
+ configPath?: string;
17
+ indexName?: string;
18
+ }
19
+
20
+ async function unavailableActivation(
21
+ config: Config
22
+ ): Promise<ActivationStatus> {
23
+ return buildActivationStatus(
24
+ {} as StorePort,
25
+ config.collections.map(({ name }) => name),
26
+ {
27
+ verifyCollection: async () => ({
28
+ ok: false,
29
+ error: { code: "QUERY_FAILED", message: "Activation unavailable" },
30
+ }),
31
+ }
32
+ );
33
+ }
34
+
35
+ export async function buildDoctorActivation(
36
+ config: Config,
37
+ options: DoctorActivationOptions
38
+ ): Promise<ActivationStatus> {
39
+ const dbPath = getIndexDbPath(options.indexName);
40
+ if (!(await Bun.file(dbPath).exists())) {
41
+ return unavailableActivation(config);
42
+ }
43
+
44
+ const store = new SqliteAdapter();
45
+ store.setConfigPath(options.configPath ?? "");
46
+ const opened = await store.open(dbPath, config.ftsTokenizer);
47
+ if (!opened.ok) {
48
+ return unavailableActivation(config);
49
+ }
50
+
51
+ try {
52
+ const indexStatus = await store.getStatus();
53
+ const embedCached = await new ModelCache(getModelsCachePath()).isCached(
54
+ getActivePreset(config).embed
55
+ );
56
+ return await buildActivationStatus(
57
+ store,
58
+ config.collections.map(({ name }) => name),
59
+ {
60
+ semantic: {
61
+ modelsCached: embedCached,
62
+ embeddingBacklog: indexStatus.ok
63
+ ? indexStatus.value.embeddingBacklog
64
+ : 0,
65
+ },
66
+ connectorTargets: await getConnectorVerificationTargets(),
67
+ }
68
+ );
69
+ } finally {
70
+ await store.close();
71
+ }
72
+ }
73
+
74
+ export function checkRetrievalActivation(
75
+ activation: ActivationStatus
76
+ ): DoctorCheck {
77
+ if (activation.healthy) {
78
+ const semanticStates = [
79
+ ...new Set(
80
+ activation.collections.map(
81
+ ({ semanticAvailability }) => semanticAvailability.code
82
+ )
83
+ ),
84
+ ];
85
+ return {
86
+ name: "retrieval-activation",
87
+ status: "ok",
88
+ message: `${activation.collections.length} collection${activation.collections.length === 1 ? "" : "s"} passed lexical retrieval proof`,
89
+ details: [
90
+ `Semantic retrieval remains separate (${semanticStates.join(", ")}).`,
91
+ ],
92
+ };
93
+ }
94
+
95
+ const failed = activation.collections.filter(({ ready }) => !ready);
96
+ const details = failed.flatMap(({ collection, remediation }) =>
97
+ remediation
98
+ ? [
99
+ `${collection}: ${remediation.stage}/${remediation.code}`,
100
+ `Run: ${remediation.command}`,
101
+ ]
102
+ : [`${collection}: activation unavailable`]
103
+ );
104
+ return {
105
+ name: "retrieval-activation",
106
+ status: "error",
107
+ message:
108
+ activation.collections.length === 0
109
+ ? "No collections configured. Run: gno collection add"
110
+ : activation.usable
111
+ ? `${failed.length} collection${failed.length === 1 ? "" : "s"} failed lexical retrieval proof`
112
+ : "No configured collection passed lexical retrieval proof",
113
+ details,
114
+ };
115
+ }
116
+
117
+ export function checkConnectorActivation(
118
+ activation: ActivationStatus
119
+ ): DoctorCheck | null {
120
+ const { projected, total, truncated } = activation.connectorProjection;
121
+ const omitted = total - projected;
122
+ const observed = activation.connectors.filter(
123
+ ({ code }) =>
124
+ code !== "connector_not_configured" &&
125
+ code !== "target_runtime_unverifiable"
126
+ );
127
+ if (observed.length === 0 && !truncated) {
128
+ return null;
129
+ }
130
+ const incomplete = observed.filter(({ status }) => status !== "passed");
131
+ const details = incomplete.map(
132
+ ({ collection, target, status, code, remediation }) =>
133
+ `${target}/${collection}: ${status}${code ? `/${code}` : ""}${remediation ? `. ${remediation}` : ""}`
134
+ );
135
+ if (truncated) {
136
+ details.unshift(
137
+ `${omitted} target/collection checks were omitted by the bounded status projection; no result is claimed for them.`
138
+ );
139
+ }
140
+ return {
141
+ name: "connector-activation",
142
+ status: incomplete.length > 0 || truncated ? "warn" : "ok",
143
+ message:
144
+ incomplete.length > 0
145
+ ? `${incomplete.length} connector proof${incomplete.length === 1 ? "" : "s"} pending or failed`
146
+ : truncated
147
+ ? `${projected} of ${total} connector target/collection checks projected`
148
+ : `${observed.length} connector proof${observed.length === 1 ? "" : "s"} passed`,
149
+ details,
150
+ };
151
+ }
@@ -11,12 +11,13 @@ import { stat } from "node:fs/promises";
11
11
  import { arch, platform } from "node:os";
12
12
 
13
13
  import type { Config } from "../../config/types";
14
+ import type { ActivationStatus } from "../../core/activation-status";
14
15
 
15
16
  import { getIndexDbPath, getModelsCachePath } from "../../app/constants";
16
17
  import { getConfigPaths, isInitialized, loadConfig } from "../../config";
18
+ import { isConnectorActivationComplete } from "../../core/activation-connector-health";
17
19
  import { getCodeChunkingStatus } from "../../ingestion/chunker";
18
20
  import { ModelCache } from "../../llm/cache";
19
- import { LlmAdapter } from "../../llm/nodeLlamaCpp/adapter";
20
21
  import { getActivePreset, resolveModelUri } from "../../llm/registry";
21
22
  import { SqliteAdapter } from "../../store/sqlite/adapter";
22
23
  import { loadFts5Snowball } from "../../store/sqlite/fts5-snowball";
@@ -26,6 +27,11 @@ import {
26
27
  getLoadAttempts,
27
28
  } from "../../store/sqlite/setup";
28
29
  import { getStoredEmbeddingFingerprint } from "../../store/vector/freshness";
30
+ import {
31
+ buildDoctorActivation,
32
+ checkConnectorActivation,
33
+ checkRetrievalActivation,
34
+ } from "./doctor-activation";
29
35
 
30
36
  // ─────────────────────────────────────────────────────────────────────────────
31
37
  // Types
@@ -63,6 +69,8 @@ export interface EmbeddingFingerprintHealth {
63
69
  export interface DoctorOptions {
64
70
  /** Override config path */
65
71
  configPath?: string;
72
+ /** Index name */
73
+ indexName?: string;
66
74
  /** Output as JSON */
67
75
  json?: boolean;
68
76
  /** Output as Markdown */
@@ -72,6 +80,14 @@ export interface DoctorOptions {
72
80
  export interface DoctorResult {
73
81
  healthy: boolean;
74
82
  checks: DoctorCheck[];
83
+ activation: ActivationStatus;
84
+ }
85
+
86
+ /** Whether doctor found a process-failing check (warnings remain exit-safe). */
87
+ export function hasCriticalDoctorErrors(
88
+ checks: readonly DoctorCheck[]
89
+ ): boolean {
90
+ return checks.some(({ status }) => status === "error");
75
91
  }
76
92
 
77
93
  // ─────────────────────────────────────────────────────────────────────────────
@@ -105,8 +121,8 @@ async function checkConfig(configPath?: string): Promise<DoctorCheck> {
105
121
  };
106
122
  }
107
123
 
108
- async function checkDatabase(): Promise<DoctorCheck> {
109
- const dbPath = getIndexDbPath();
124
+ async function checkDatabase(indexName?: string): Promise<DoctorCheck> {
125
+ const dbPath = getIndexDbPath(indexName);
110
126
 
111
127
  try {
112
128
  await stat(dbPath);
@@ -175,9 +191,10 @@ function describeFingerprintGroup(group: EmbeddingFingerprintGroup): string {
175
191
  }
176
192
 
177
193
  async function checkEmbeddingFingerprints(
178
- config: Config
194
+ config: Config,
195
+ indexName?: string
179
196
  ): Promise<DoctorCheck> {
180
- const dbPath = getIndexDbPath();
197
+ const dbPath = getIndexDbPath(indexName);
181
198
  try {
182
199
  await stat(dbPath);
183
200
  } catch {
@@ -312,14 +329,13 @@ async function checkEmbeddingFingerprints(
312
329
  }
313
330
  }
314
331
 
315
- async function checkNodeLlamaCpp(config: Config): Promise<DoctorCheck> {
316
- const llm = new LlmAdapter(config);
332
+ function checkNodeLlamaCpp(): DoctorCheck {
317
333
  try {
318
- await llm.getManager().getLlama();
334
+ import.meta.resolve("node-llama-cpp");
319
335
  return {
320
336
  name: "node-llama-cpp",
321
337
  status: "ok",
322
- message: "node-llama-cpp loaded successfully",
338
+ message: "node-llama-cpp package available (runtime not initialized)",
323
339
  };
324
340
  } catch (e) {
325
341
  const message = e instanceof Error ? e.message : String(e);
@@ -328,8 +344,6 @@ async function checkNodeLlamaCpp(config: Config): Promise<DoctorCheck> {
328
344
  status: "error",
329
345
  message: `node-llama-cpp failed: ${message}`,
330
346
  };
331
- } finally {
332
- await llm.dispose();
333
347
  }
334
348
  }
335
349
 
@@ -495,7 +509,7 @@ export async function doctor(
495
509
  checks.push(await checkConfig(options.configPath));
496
510
 
497
511
  // Database check
498
- checks.push(await checkDatabase());
512
+ checks.push(await checkDatabase(options.indexName));
499
513
 
500
514
  // Load config for model checks (if available)
501
515
  const { createDefaultConfig } = await import("../../config");
@@ -507,7 +521,7 @@ export async function doctor(
507
521
  checks.push(...modelChecks);
508
522
 
509
523
  // node-llama-cpp check
510
- checks.push(await checkNodeLlamaCpp(config));
524
+ checks.push(checkNodeLlamaCpp());
511
525
 
512
526
  // SQLite extension checks
513
527
  const sqliteChecks = await checkSqliteExtensions();
@@ -517,14 +531,25 @@ export async function doctor(
517
531
  checks.push(checkCodeChunking());
518
532
 
519
533
  // Embedding fingerprint freshness
520
- checks.push(await checkEmbeddingFingerprints(config));
534
+ checks.push(await checkEmbeddingFingerprints(config, options.indexName));
535
+
536
+ const activation = await buildDoctorActivation(config, options);
537
+ checks.push(checkRetrievalActivation(activation));
538
+ const connectorActivation = checkConnectorActivation(activation);
539
+ if (connectorActivation) {
540
+ checks.push(connectorActivation);
541
+ }
521
542
 
522
543
  // Determine overall health
523
- const hasErrors = checks.some((c) => c.status === "error");
544
+ const hasErrors = hasCriticalDoctorErrors(checks);
524
545
 
525
546
  return {
526
- healthy: !hasErrors,
547
+ healthy:
548
+ !hasErrors &&
549
+ activation.healthy &&
550
+ isConnectorActivationComplete(activation),
527
551
  checks,
552
+ activation,
528
553
  };
529
554
  }
530
555
 
@@ -9,6 +9,10 @@ import type { DocumentRow, StorePort, StoreResult } from "../../store/types";
9
9
  import type { ParsedRef } from "./ref-parser";
10
10
 
11
11
  import { decorateUriForIndex, parseUri } from "../../app/constants";
12
+ import {
13
+ INDEX_NAME_REQUIREMENTS,
14
+ isValidIndexName,
15
+ } from "../../app/index-name";
12
16
  import {
13
17
  getDocumentCapabilities,
14
18
  type DocumentCapabilities,
@@ -114,6 +118,13 @@ export async function get(
114
118
  }
115
119
  const explicitIndexName =
116
120
  parsed.type === "uri" ? parseUri(parsed.value)?.indexName : undefined;
121
+ if (explicitIndexName !== undefined && !isValidIndexName(explicitIndexName)) {
122
+ return {
123
+ success: false,
124
+ error: `Invalid index name: ${INDEX_NAME_REQUIREMENTS}.`,
125
+ isValidation: true,
126
+ };
127
+ }
117
128
  const indexName = explicitIndexName ?? options.indexName;
118
129
 
119
130
  const initResult = await initStore({
@@ -134,6 +145,13 @@ export async function get(
134
145
  }
135
146
 
136
147
  function validateOptions(options: GetCommandOptions): GetResult | null {
148
+ if (options.indexName !== undefined && !isValidIndexName(options.indexName)) {
149
+ return {
150
+ success: false,
151
+ error: `Invalid index name: ${INDEX_NAME_REQUIREMENTS}.`,
152
+ isValidation: true,
153
+ };
154
+ }
137
155
  if (options.from !== undefined && options.from <= 0) {
138
156
  return {
139
157
  success: false,
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Symlink-safe atomic writes for MCP configuration files.
3
+ *
4
+ * @module src/cli/commands/mcp/atomic-config-write
5
+ */
6
+
7
+ // node:fs/promises supplies structural and metadata operations that Bun does
8
+ // not expose: symlink inspection/resolution, atomic rename, mode changes, and
9
+ // best-effort temporary-file cleanup.
10
+ import {
11
+ chmod,
12
+ lstat,
13
+ mkdir,
14
+ realpath,
15
+ rename,
16
+ stat,
17
+ unlink,
18
+ } from "node:fs/promises";
19
+ // node:path supplies path manipulation; Bun has no equivalent.
20
+ import { basename, dirname, join } from "node:path";
21
+
22
+ import { CliError } from "../../errors.js";
23
+
24
+ interface ResolvedWriteTarget {
25
+ mode?: number;
26
+ path: string;
27
+ }
28
+
29
+ function isMissingPathError(error: unknown): boolean {
30
+ return (
31
+ error instanceof Error &&
32
+ "code" in error &&
33
+ (error as NodeJS.ErrnoException).code === "ENOENT"
34
+ );
35
+ }
36
+
37
+ function configPathError(configPath: string, detail: string): CliError {
38
+ return new CliError(
39
+ "RUNTIME",
40
+ `Cannot write MCP config ${configPath}: ${detail}`
41
+ );
42
+ }
43
+
44
+ async function resolveWriteTarget(
45
+ configPath: string
46
+ ): Promise<ResolvedWriteTarget> {
47
+ let pathStats: Awaited<ReturnType<typeof lstat>>;
48
+ try {
49
+ pathStats = await lstat(configPath);
50
+ } catch (error) {
51
+ if (isMissingPathError(error)) {
52
+ return { path: configPath };
53
+ }
54
+ throw error;
55
+ }
56
+
57
+ if (!pathStats.isSymbolicLink()) {
58
+ if (!pathStats.isFile()) {
59
+ throw configPathError(configPath, "path exists but is not a file");
60
+ }
61
+ return { mode: pathStats.mode & 0o7777, path: configPath };
62
+ }
63
+
64
+ let resolvedPath: string;
65
+ try {
66
+ resolvedPath = await realpath(configPath);
67
+ } catch (error) {
68
+ if (isMissingPathError(error)) {
69
+ throw configPathError(configPath, "symbolic link target does not exist");
70
+ }
71
+ throw error;
72
+ }
73
+
74
+ const targetStats = await stat(resolvedPath);
75
+ if (!targetStats.isFile()) {
76
+ throw configPathError(configPath, "symbolic link target is not a file");
77
+ }
78
+
79
+ return { mode: targetStats.mode & 0o7777, path: resolvedPath };
80
+ }
81
+
82
+ /**
83
+ * Atomically replace an MCP config file with serialized JSON, YAML, or TOML.
84
+ *
85
+ * Existing file permissions survive replacement. A config-path symlink remains
86
+ * in place: its resolved file is replaced by a temporary sibling. Dangling
87
+ * symlinks fail closed instead of being replaced with regular files.
88
+ */
89
+ export async function writeMcpConfigTextAtomically(
90
+ configPath: string,
91
+ content: string
92
+ ): Promise<void> {
93
+ await mkdir(dirname(configPath), { recursive: true });
94
+
95
+ const target = await resolveWriteTarget(configPath);
96
+ const targetDirectory = dirname(target.path);
97
+ const tempPath = join(
98
+ targetDirectory,
99
+ `.${basename(target.path)}.gno-tmp-${process.pid}-${crypto.randomUUID()}`
100
+ );
101
+
102
+ try {
103
+ await Bun.write(tempPath, content, {
104
+ createPath: false,
105
+ ...(target.mode === undefined ? {} : { mode: target.mode & 0o777 }),
106
+ });
107
+ if (target.mode !== undefined) {
108
+ // Bun 1.3 does not consistently apply Bun.write's mode option on macOS.
109
+ await chmod(tempPath, target.mode);
110
+ }
111
+ await rename(tempPath, target.path);
112
+ } catch (error) {
113
+ await unlink(tempPath).catch(() => {
114
+ // The write may have failed before creating the temporary file.
115
+ });
116
+ throw error;
117
+ }
118
+ }
@@ -0,0 +1,42 @@
1
+ /** Resolve one unambiguous MCP client config filename. */
2
+
3
+ // node:fs/promises has no Bun equivalent for lstat, including dangling links.
4
+ import { lstat } from "node:fs/promises";
5
+
6
+ import type { McpConfigPaths } from "./paths.js";
7
+
8
+ import { CliError } from "../../errors.js";
9
+
10
+ export async function configPathEntryExists(path: string): Promise<boolean> {
11
+ try {
12
+ await lstat(path);
13
+ return true;
14
+ } catch (error) {
15
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
16
+ return false;
17
+ }
18
+ throw error;
19
+ }
20
+ }
21
+
22
+ export async function resolveMcpConfigLocation(
23
+ paths: McpConfigPaths
24
+ ): Promise<string> {
25
+ const candidates = [
26
+ paths.configPath,
27
+ ...(paths.alternativeConfigPaths ?? []),
28
+ ];
29
+ const present: string[] = [];
30
+ for (const candidate of candidates) {
31
+ if (await configPathEntryExists(candidate)) {
32
+ present.push(candidate);
33
+ }
34
+ }
35
+ if (present.length > 1) {
36
+ throw new CliError(
37
+ "RUNTIME",
38
+ `Ambiguous MCP config files: ${present.join(", ")}. Keep exactly one.`
39
+ );
40
+ }
41
+ return present[0] ?? paths.configPath;
42
+ }