@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.
- package/README.md +126 -59
- package/assets/skill/SKILL.md +8 -1
- package/assets/skill/cli-reference.md +18 -7
- package/assets/skill/mcp-reference.md +22 -3
- package/package.json +3 -1
- package/src/app/constants.ts +43 -10
- package/src/app/index-name.ts +127 -0
- package/src/cli/commands/doctor-activation.ts +151 -0
- package/src/cli/commands/doctor.ts +41 -16
- package/src/cli/commands/get.ts +18 -0
- package/src/cli/commands/mcp/atomic-config-write.ts +118 -0
- package/src/cli/commands/mcp/config-discovery.ts +42 -0
- package/src/cli/commands/mcp/config-editors.ts +432 -0
- package/src/cli/commands/mcp/config.ts +63 -160
- package/src/cli/commands/mcp/install.ts +75 -37
- package/src/cli/commands/mcp/paths.ts +141 -136
- package/src/cli/commands/mcp/server-entry.ts +66 -0
- package/src/cli/commands/mcp/status.ts +189 -57
- package/src/cli/commands/mcp/target-display.ts +30 -0
- package/src/cli/commands/mcp/uninstall.ts +29 -31
- package/src/cli/commands/mcp/yaml-config-editor.ts +257 -0
- package/src/cli/commands/mcp/yaml-layout-scanner.ts +447 -0
- package/src/cli/commands/multi-get.ts +31 -6
- package/src/cli/commands/status.ts +107 -11
- package/src/cli/program.ts +66 -20
- package/src/core/activation-connector-health.ts +19 -0
- package/src/core/activation-probe-plan.ts +321 -0
- package/src/core/activation-probe.ts +138 -0
- package/src/core/activation-receipt-store.ts +39 -0
- package/src/core/activation-status.ts +513 -0
- package/src/core/activation-verifier.ts +416 -0
- package/src/core/connector-environment.ts +68 -0
- package/src/core/connector-policy.ts +233 -0
- package/src/core/connector-verification-target.ts +150 -0
- package/src/core/connector-verifier.ts +497 -0
- package/src/core/indexed-reference.ts +33 -8
- package/src/core/runtime-entrypoint.ts +24 -0
- package/src/mcp/activation-verification-mode.ts +4 -0
- package/src/mcp/server.ts +9 -2
- package/src/sdk/client.ts +7 -0
- package/src/sdk/types.ts +1 -0
- package/src/serve/activation-health.ts +91 -0
- package/src/serve/background-runtime.ts +11 -1
- package/src/serve/connectors.ts +164 -19
- package/src/serve/public/components/BootstrapStatus.tsx +94 -1
- package/src/serve/public/components/FirstRunWizard.tsx +13 -51
- package/src/serve/public/components/HealthCenter.tsx +8 -2
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Connectors.tsx +216 -55
- package/src/serve/public/pages/Dashboard.tsx +1 -0
- package/src/serve/routes/api.ts +152 -8
- package/src/serve/server.ts +44 -9
- package/src/serve/status-model.ts +4 -0
- package/src/serve/status.ts +79 -35
- package/src/store/activation-receipts.ts +390 -0
- package/src/store/index.ts +8 -0
- package/src/store/migrations/012-activation-receipts.ts +38 -0
- package/src/store/migrations/013-fts-sync-marker.ts +39 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +313 -53
- 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
|
-
|
|
316
|
-
const llm = new LlmAdapter(config);
|
|
332
|
+
function checkNodeLlamaCpp(): DoctorCheck {
|
|
317
333
|
try {
|
|
318
|
-
|
|
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
|
|
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(
|
|
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
|
|
544
|
+
const hasErrors = hasCriticalDoctorErrors(checks);
|
|
524
545
|
|
|
525
546
|
return {
|
|
526
|
-
healthy:
|
|
547
|
+
healthy:
|
|
548
|
+
!hasErrors &&
|
|
549
|
+
activation.healthy &&
|
|
550
|
+
isConnectorActivationComplete(activation),
|
|
527
551
|
checks,
|
|
552
|
+
activation,
|
|
528
553
|
};
|
|
529
554
|
}
|
|
530
555
|
|
package/src/cli/commands/get.ts
CHANGED
|
@@ -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
|
+
}
|