@gmickel/gno 0.8.6 → 0.9.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/package.json +2 -1
- package/src/cli/AGENTS.md +96 -0
- package/src/cli/CLAUDE.md +96 -0
- package/src/cli/commands/mcp/install.ts +3 -1
- package/src/cli/commands/mcp/paths.ts +20 -4
- package/src/cli/commands/mcp.ts +5 -1
- package/src/cli/program.ts +13 -2
- package/src/core/config-mutation.ts +110 -0
- package/src/core/errors.ts +40 -0
- package/src/core/file-lock.ts +144 -0
- package/src/core/file-ops.ts +24 -0
- package/src/core/job-manager.ts +215 -0
- package/src/core/validation.ts +75 -0
- package/src/ingestion/sync.ts +55 -0
- package/src/mcp/AGENTS.md +86 -0
- package/src/mcp/CLAUDE.md +86 -0
- package/src/mcp/server.ts +33 -4
- package/src/mcp/tools/add-collection.ts +190 -0
- package/src/mcp/tools/capture.ts +193 -0
- package/src/mcp/tools/index.ts +101 -0
- package/src/mcp/tools/job-status.ts +87 -0
- package/src/mcp/tools/list-jobs.ts +81 -0
- package/src/mcp/tools/remove-collection.ts +106 -0
- package/src/mcp/tools/sync.ts +153 -0
- package/src/serve/AGENTS.md +180 -0
- package/src/serve/CLAUDE.md +75 -0
- package/src/serve/config-sync.ts +16 -102
- package/src/serve/public/components/GnoLogo.tsx +48 -0
- package/src/serve/public/pages/Dashboard.tsx +2 -2
- package/src/serve/routes/api.ts +14 -54
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gmickel/gno",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"embeddings",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"website:dev": "cd website && make serve",
|
|
55
55
|
"website:build": "cd website && make build",
|
|
56
56
|
"website:demos": "cd website/demos && ./build-demos.sh",
|
|
57
|
+
"sync:agents": "scripts/sync-agents.sh",
|
|
57
58
|
"build:css": "bunx @tailwindcss/cli -i src/serve/public/globals.css -o src/serve/public/globals.built.css --minify",
|
|
58
59
|
"serve": "bun src/index.ts serve",
|
|
59
60
|
"serve:dev": "NODE_ENV=development bun --hot src/index.ts serve",
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# CLI Commands
|
|
2
|
+
|
|
3
|
+
GNO command-line interface using Commander.js.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
src/cli/
|
|
9
|
+
├── program.ts # Main Commander program with all commands
|
|
10
|
+
├── run.ts # Entry point, error handling
|
|
11
|
+
├── context.ts # CliContext with adapters
|
|
12
|
+
├── options.ts # Shared option definitions
|
|
13
|
+
├── errors.ts # Error types and exit codes
|
|
14
|
+
├── colors.ts # Terminal color utilities
|
|
15
|
+
├── progress.ts # Progress indicators
|
|
16
|
+
├── ui.ts # User interaction helpers
|
|
17
|
+
├── format/ # Output formatters (json, csv, md, xml)
|
|
18
|
+
└── commands/ # Command implementations
|
|
19
|
+
├── search.ts
|
|
20
|
+
├── query.ts
|
|
21
|
+
├── ask.ts
|
|
22
|
+
└── ...
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Specification
|
|
26
|
+
|
|
27
|
+
See `spec/cli.md` for full CLI specification including:
|
|
28
|
+
|
|
29
|
+
- Exit codes (0=success, 1=validation, 2=runtime)
|
|
30
|
+
- Global flags (--index, --json, --verbose, etc.)
|
|
31
|
+
- Output format support matrix
|
|
32
|
+
- All command schemas
|
|
33
|
+
|
|
34
|
+
**Always update spec/cli.md first** when adding/modifying commands.
|
|
35
|
+
|
|
36
|
+
## Command Pattern
|
|
37
|
+
|
|
38
|
+
Commands follow this structure in `program.ts`:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
program
|
|
42
|
+
.command("search <query>")
|
|
43
|
+
.description("BM25 keyword search")
|
|
44
|
+
.option("-l, --limit <n>", "Max results", "10")
|
|
45
|
+
.option("-c, --collection <name>", "Filter by collection")
|
|
46
|
+
.addOption(formatOption) // From options.ts
|
|
47
|
+
.action(async (query, opts) => {
|
|
48
|
+
await runSearch(query, opts);
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Exit Codes
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
export const EXIT = {
|
|
56
|
+
SUCCESS: 0, // Command completed successfully
|
|
57
|
+
VALIDATION: 1, // Bad args, missing params
|
|
58
|
+
RUNTIME: 2, // IO, DB, model, network errors
|
|
59
|
+
} as const;
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Output Formats
|
|
63
|
+
|
|
64
|
+
All search/query commands support multiple formats:
|
|
65
|
+
|
|
66
|
+
- `--json` - Machine-readable JSON
|
|
67
|
+
- `--files` - Line protocol for piping
|
|
68
|
+
- `--csv` - Spreadsheet compatible
|
|
69
|
+
- `--md` - Markdown tables
|
|
70
|
+
- `--xml` - XML format
|
|
71
|
+
|
|
72
|
+
Format handlers in `src/cli/format/`.
|
|
73
|
+
|
|
74
|
+
## CliContext
|
|
75
|
+
|
|
76
|
+
Created at command execution, holds adapters:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
interface CliContext {
|
|
80
|
+
store: SqliteAdapter;
|
|
81
|
+
config: Config;
|
|
82
|
+
embedPort?: EmbeddingPort;
|
|
83
|
+
genPort?: GenerationPort;
|
|
84
|
+
rerankPort?: RerankPort;
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Testing
|
|
89
|
+
|
|
90
|
+
CLI tests in `test/cli/`:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bun test test/cli/
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Use `--json` output for assertions in tests.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# CLI Commands
|
|
2
|
+
|
|
3
|
+
GNO command-line interface using Commander.js.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
src/cli/
|
|
9
|
+
├── program.ts # Main Commander program with all commands
|
|
10
|
+
├── run.ts # Entry point, error handling
|
|
11
|
+
├── context.ts # CliContext with adapters
|
|
12
|
+
├── options.ts # Shared option definitions
|
|
13
|
+
├── errors.ts # Error types and exit codes
|
|
14
|
+
├── colors.ts # Terminal color utilities
|
|
15
|
+
├── progress.ts # Progress indicators
|
|
16
|
+
├── ui.ts # User interaction helpers
|
|
17
|
+
├── format/ # Output formatters (json, csv, md, xml)
|
|
18
|
+
└── commands/ # Command implementations
|
|
19
|
+
├── search.ts
|
|
20
|
+
├── query.ts
|
|
21
|
+
├── ask.ts
|
|
22
|
+
└── ...
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Specification
|
|
26
|
+
|
|
27
|
+
See `spec/cli.md` for full CLI specification including:
|
|
28
|
+
|
|
29
|
+
- Exit codes (0=success, 1=validation, 2=runtime)
|
|
30
|
+
- Global flags (--index, --json, --verbose, etc.)
|
|
31
|
+
- Output format support matrix
|
|
32
|
+
- All command schemas
|
|
33
|
+
|
|
34
|
+
**Always update spec/cli.md first** when adding/modifying commands.
|
|
35
|
+
|
|
36
|
+
## Command Pattern
|
|
37
|
+
|
|
38
|
+
Commands follow this structure in `program.ts`:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
program
|
|
42
|
+
.command("search <query>")
|
|
43
|
+
.description("BM25 keyword search")
|
|
44
|
+
.option("-l, --limit <n>", "Max results", "10")
|
|
45
|
+
.option("-c, --collection <name>", "Filter by collection")
|
|
46
|
+
.addOption(formatOption) // From options.ts
|
|
47
|
+
.action(async (query, opts) => {
|
|
48
|
+
await runSearch(query, opts);
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Exit Codes
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
export const EXIT = {
|
|
56
|
+
SUCCESS: 0, // Command completed successfully
|
|
57
|
+
VALIDATION: 1, // Bad args, missing params
|
|
58
|
+
RUNTIME: 2, // IO, DB, model, network errors
|
|
59
|
+
} as const;
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Output Formats
|
|
63
|
+
|
|
64
|
+
All search/query commands support multiple formats:
|
|
65
|
+
|
|
66
|
+
- `--json` - Machine-readable JSON
|
|
67
|
+
- `--files` - Line protocol for piping
|
|
68
|
+
- `--csv` - Spreadsheet compatible
|
|
69
|
+
- `--md` - Markdown tables
|
|
70
|
+
- `--xml` - XML format
|
|
71
|
+
|
|
72
|
+
Format handlers in `src/cli/format/`.
|
|
73
|
+
|
|
74
|
+
## CliContext
|
|
75
|
+
|
|
76
|
+
Created at command execution, holds adapters:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
interface CliContext {
|
|
80
|
+
store: SqliteAdapter;
|
|
81
|
+
config: Config;
|
|
82
|
+
embedPort?: EmbeddingPort;
|
|
83
|
+
genPort?: GenerationPort;
|
|
84
|
+
rerankPort?: RerankPort;
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Testing
|
|
89
|
+
|
|
90
|
+
CLI tests in `test/cli/`:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bun test test/cli/
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Use `--json` output for assertions in tests.
|
|
@@ -35,6 +35,7 @@ export interface InstallOptions {
|
|
|
35
35
|
scope?: McpScope;
|
|
36
36
|
force?: boolean;
|
|
37
37
|
dryRun?: boolean;
|
|
38
|
+
enableWrite?: boolean;
|
|
38
39
|
/** Override cwd (testing) */
|
|
39
40
|
cwd?: string;
|
|
40
41
|
/** Override home dir (testing) */
|
|
@@ -147,6 +148,7 @@ export async function installMcp(opts: InstallOptions = {}): Promise<void> {
|
|
|
147
148
|
const scope = opts.scope ?? "user";
|
|
148
149
|
const force = opts.force ?? false;
|
|
149
150
|
const dryRun = opts.dryRun ?? false;
|
|
151
|
+
const enableWrite = opts.enableWrite ?? false;
|
|
150
152
|
const globals = safeGetGlobals();
|
|
151
153
|
const json = opts.json ?? globals.json;
|
|
152
154
|
const quiet = opts.quiet ?? globals.quiet;
|
|
@@ -160,7 +162,7 @@ export async function installMcp(opts: InstallOptions = {}): Promise<void> {
|
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
// Build server entry (uses process.execPath, always succeeds)
|
|
163
|
-
const serverEntry = buildMcpServerEntry();
|
|
165
|
+
const serverEntry = buildMcpServerEntry({ enableWrite });
|
|
164
166
|
|
|
165
167
|
// Install
|
|
166
168
|
const result = await installToTarget(target, scope, serverEntry, {
|
|
@@ -398,7 +398,11 @@ export function findBunPath(): string {
|
|
|
398
398
|
* Uses absolute paths because Claude Desktop has a limited PATH.
|
|
399
399
|
* Cross-platform: avoids shelling out to `which`.
|
|
400
400
|
*/
|
|
401
|
-
export function buildMcpServerEntry(
|
|
401
|
+
export function buildMcpServerEntry(
|
|
402
|
+
options: {
|
|
403
|
+
enableWrite?: boolean;
|
|
404
|
+
} = {}
|
|
405
|
+
): McpServerEntry {
|
|
402
406
|
const bunPath = findBunPath();
|
|
403
407
|
const home = homedir();
|
|
404
408
|
const isWindows = platform() === "win32";
|
|
@@ -411,7 +415,11 @@ export function buildMcpServerEntry(): McpServerEntry {
|
|
|
411
415
|
) {
|
|
412
416
|
// Dev mode: run the entry script directly with bun
|
|
413
417
|
const entryScript = scriptPath.replace(COMMANDS_PATH_PATTERN, "/index.ts");
|
|
414
|
-
|
|
418
|
+
const args = ["run", entryScript, "mcp"];
|
|
419
|
+
if (options.enableWrite) {
|
|
420
|
+
args.push("--enable-write");
|
|
421
|
+
}
|
|
422
|
+
return { command: bunPath, args };
|
|
415
423
|
}
|
|
416
424
|
|
|
417
425
|
// 2. Check common gno install locations (cross-platform)
|
|
@@ -428,13 +436,21 @@ export function buildMcpServerEntry(): McpServerEntry {
|
|
|
428
436
|
|
|
429
437
|
for (const gnoPath of gnoCandidates) {
|
|
430
438
|
if (existsSync(gnoPath)) {
|
|
431
|
-
|
|
439
|
+
const args = [gnoPath, "mcp"];
|
|
440
|
+
if (options.enableWrite) {
|
|
441
|
+
args.push("--enable-write");
|
|
442
|
+
}
|
|
443
|
+
return { command: bunPath, args };
|
|
432
444
|
}
|
|
433
445
|
}
|
|
434
446
|
|
|
435
447
|
// 3. Fallback to bunx (works if gno is published to npm)
|
|
436
448
|
// Note: This may trigger network access on first run
|
|
437
|
-
|
|
449
|
+
const args = ["x", "@gmickel/gno", "mcp"];
|
|
450
|
+
if (options.enableWrite) {
|
|
451
|
+
args.push("--enable-write");
|
|
452
|
+
}
|
|
453
|
+
return { command: bunPath, args };
|
|
438
454
|
}
|
|
439
455
|
|
|
440
456
|
/**
|
package/src/cli/commands/mcp.ts
CHANGED
|
@@ -10,11 +10,15 @@ import type { GlobalOptions } from "../context";
|
|
|
10
10
|
* Start the MCP server.
|
|
11
11
|
* Reads global options for --index and --config flags.
|
|
12
12
|
*/
|
|
13
|
-
export async function mcpCommand(
|
|
13
|
+
export async function mcpCommand(
|
|
14
|
+
options: GlobalOptions,
|
|
15
|
+
commandOptions: { enableWrite?: boolean } = {}
|
|
16
|
+
): Promise<void> {
|
|
14
17
|
const { startMcpServer } = await import("../../mcp/server.js");
|
|
15
18
|
await startMcpServer({
|
|
16
19
|
indexName: options.index,
|
|
17
20
|
configPath: options.config,
|
|
18
21
|
verbose: options.verbose,
|
|
22
|
+
enableWrite: commandOptions.enableWrite,
|
|
19
23
|
});
|
|
20
24
|
}
|
package/src/cli/program.ts
CHANGED
|
@@ -766,11 +766,17 @@ function wireMcpCommand(program: Command): void {
|
|
|
766
766
|
.command("serve", { isDefault: true })
|
|
767
767
|
.description("Start MCP server (stdio transport)")
|
|
768
768
|
.helpOption(false)
|
|
769
|
-
.
|
|
769
|
+
.option(
|
|
770
|
+
"--enable-write",
|
|
771
|
+
"Enable write operations (capture, add-collection, sync, remove-collection)"
|
|
772
|
+
)
|
|
773
|
+
.action(async (cmdOpts: Record<string, unknown>) => {
|
|
770
774
|
const { mcpCommand } = await import("./commands/mcp.js");
|
|
771
775
|
const globalOpts = program.opts();
|
|
772
776
|
const globals = parseGlobalOptions(globalOpts);
|
|
773
|
-
await mcpCommand(globals
|
|
777
|
+
await mcpCommand(globals, {
|
|
778
|
+
enableWrite: cmdOpts.enableWrite === true ? true : undefined,
|
|
779
|
+
});
|
|
774
780
|
});
|
|
775
781
|
|
|
776
782
|
// install - Install gno MCP server to client configs
|
|
@@ -789,6 +795,10 @@ function wireMcpCommand(program: Command): void {
|
|
|
789
795
|
)
|
|
790
796
|
.option("-f, --force", "overwrite existing configuration")
|
|
791
797
|
.option("--dry-run", "show what would be done without making changes")
|
|
798
|
+
.option(
|
|
799
|
+
"--enable-write",
|
|
800
|
+
"Enable write operations in installed MCP configuration"
|
|
801
|
+
)
|
|
792
802
|
.option("--json", "JSON output")
|
|
793
803
|
.action(async (cmdOpts: Record<string, unknown>) => {
|
|
794
804
|
const target = cmdOpts.target as string;
|
|
@@ -820,6 +830,7 @@ function wireMcpCommand(program: Command): void {
|
|
|
820
830
|
scope: scope as "user" | "project",
|
|
821
831
|
force: Boolean(cmdOpts.force),
|
|
822
832
|
dryRun: Boolean(cmdOpts.dryRun),
|
|
833
|
+
enableWrite: Boolean(cmdOpts.enableWrite),
|
|
823
834
|
// Pass undefined if not set, so global --json can take effect
|
|
824
835
|
json: cmdOpts.json === true ? true : undefined,
|
|
825
836
|
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config mutation helper shared by Web UI and MCP.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/config-mutation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Config } from "../config/types";
|
|
8
|
+
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
9
|
+
|
|
10
|
+
import { loadConfig, saveConfig } from "../config";
|
|
11
|
+
|
|
12
|
+
export interface ConfigMutationContext {
|
|
13
|
+
store: SqliteAdapter;
|
|
14
|
+
configPath?: string;
|
|
15
|
+
onConfigUpdated: (config: Config) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type MutationResult<T = void> =
|
|
19
|
+
| { ok: true; config: Config; value?: T }
|
|
20
|
+
| { ok: false; error: string; code: string };
|
|
21
|
+
|
|
22
|
+
export type ApplyConfigResult<T = void> =
|
|
23
|
+
| { ok: true; config: Config; value?: T }
|
|
24
|
+
| { ok: false; error: string; code: string };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* In-memory mutex for serializing config mutations.
|
|
28
|
+
* Prevents lost updates when multiple requests try to modify config concurrently.
|
|
29
|
+
*/
|
|
30
|
+
let configMutex: Promise<void> = Promise.resolve();
|
|
31
|
+
|
|
32
|
+
export async function applyConfigChange<T = void>(
|
|
33
|
+
ctx: ConfigMutationContext,
|
|
34
|
+
mutate: (config: Config) => Promise<MutationResult<T>> | MutationResult<T>
|
|
35
|
+
): Promise<ApplyConfigResult<T>> {
|
|
36
|
+
const previousMutex = configMutex;
|
|
37
|
+
let resolveMutex: () => void = () => {
|
|
38
|
+
/* no-op until assigned */
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
configMutex = new Promise((resolve) => {
|
|
42
|
+
resolveMutex = resolve;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await previousMutex;
|
|
47
|
+
|
|
48
|
+
const loadResult = await loadConfig(ctx.configPath);
|
|
49
|
+
if (!loadResult.ok) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: loadResult.error.message,
|
|
53
|
+
code: "LOAD_ERROR",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const mutationResult = await mutate(loadResult.value);
|
|
58
|
+
if (!mutationResult.ok) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: mutationResult.error,
|
|
62
|
+
code: mutationResult.code,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const newConfig = mutationResult.config;
|
|
67
|
+
const saveResult = await saveConfig(newConfig, ctx.configPath);
|
|
68
|
+
if (!saveResult.ok) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: saveResult.error.message,
|
|
72
|
+
code: "SAVE_ERROR",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const syncCollResult = await ctx.store.syncCollections(
|
|
77
|
+
newConfig.collections
|
|
78
|
+
);
|
|
79
|
+
if (!syncCollResult.ok) {
|
|
80
|
+
console.warn(
|
|
81
|
+
`Config saved but DB sync failed: ${syncCollResult.error.message}`
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: `DB sync failed: ${syncCollResult.error.message}`,
|
|
86
|
+
code: "SYNC_ERROR",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const syncCtxResult = await ctx.store.syncContexts(
|
|
91
|
+
newConfig.contexts ?? []
|
|
92
|
+
);
|
|
93
|
+
if (!syncCtxResult.ok) {
|
|
94
|
+
console.warn(
|
|
95
|
+
`Config saved but context sync failed: ${syncCtxResult.error.message}`
|
|
96
|
+
);
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: `Context sync failed: ${syncCtxResult.error.message}`,
|
|
100
|
+
code: "SYNC_ERROR",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ctx.onConfigUpdated(newConfig);
|
|
105
|
+
|
|
106
|
+
return { ok: true, config: newConfig, value: mutationResult.value };
|
|
107
|
+
} finally {
|
|
108
|
+
resolveMutex();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standardized error codes/messages for MCP write operations.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/errors
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const MCP_ERRORS = {
|
|
8
|
+
LOCKED: {
|
|
9
|
+
code: "LOCKED",
|
|
10
|
+
message: "Another GNO write operation is running. Try again later.",
|
|
11
|
+
},
|
|
12
|
+
JOB_CONFLICT: {
|
|
13
|
+
code: "JOB_CONFLICT",
|
|
14
|
+
message: "Another job is already running.",
|
|
15
|
+
},
|
|
16
|
+
INVALID_PATH: {
|
|
17
|
+
code: "INVALID_PATH",
|
|
18
|
+
message: "Path violates safety rules.",
|
|
19
|
+
},
|
|
20
|
+
PATH_NOT_FOUND: {
|
|
21
|
+
code: "PATH_NOT_FOUND",
|
|
22
|
+
message: "Path not found.",
|
|
23
|
+
},
|
|
24
|
+
DUPLICATE: {
|
|
25
|
+
code: "DUPLICATE",
|
|
26
|
+
message: "Resource already exists.",
|
|
27
|
+
},
|
|
28
|
+
NOT_FOUND: {
|
|
29
|
+
code: "NOT_FOUND",
|
|
30
|
+
message: "Resource not found.",
|
|
31
|
+
},
|
|
32
|
+
CONFLICT: {
|
|
33
|
+
code: "CONFLICT",
|
|
34
|
+
message: "Conflict with existing resource.",
|
|
35
|
+
},
|
|
36
|
+
HAS_REFERENCES: {
|
|
37
|
+
code: "HAS_REFERENCES",
|
|
38
|
+
message: "Resource has references.",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS-backed advisory file locking for MCP write operations.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/file-lock
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:fs/promises for mkdir (no Bun equivalent for recursive dir creation)
|
|
8
|
+
import { mkdir } from "node:fs/promises";
|
|
9
|
+
// node:path for dirname (no Bun path utils)
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { MCP_ERRORS } from "./errors";
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
14
|
+
const HOLD_SECONDS = 60 * 60 * 24 * 365;
|
|
15
|
+
const READY_TOKEN = "READY";
|
|
16
|
+
|
|
17
|
+
export interface WriteLockHandle {
|
|
18
|
+
release: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface LockCommand {
|
|
22
|
+
path: string;
|
|
23
|
+
args: (
|
|
24
|
+
lockPath: string,
|
|
25
|
+
timeoutSeconds: number,
|
|
26
|
+
holdCommand: string
|
|
27
|
+
) => string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveLockCommand(): LockCommand | null {
|
|
31
|
+
const lockfPath = Bun.which("lockf");
|
|
32
|
+
if (lockfPath) {
|
|
33
|
+
return {
|
|
34
|
+
path: lockfPath,
|
|
35
|
+
args: (lockPath, timeoutSeconds, holdCommand) => [
|
|
36
|
+
"-t",
|
|
37
|
+
String(timeoutSeconds),
|
|
38
|
+
lockPath,
|
|
39
|
+
"sh",
|
|
40
|
+
"-c",
|
|
41
|
+
holdCommand,
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const flockPath = Bun.which("flock");
|
|
47
|
+
if (flockPath) {
|
|
48
|
+
return {
|
|
49
|
+
path: flockPath,
|
|
50
|
+
args: (lockPath, timeoutSeconds, holdCommand) => [
|
|
51
|
+
"-w",
|
|
52
|
+
String(timeoutSeconds),
|
|
53
|
+
lockPath,
|
|
54
|
+
"sh",
|
|
55
|
+
"-c",
|
|
56
|
+
holdCommand,
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildHoldCommand(): string {
|
|
65
|
+
return `printf '${READY_TOKEN}\\n'; exec sleep ${HOLD_SECONDS}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function waitForReady(
|
|
69
|
+
proc: ReturnType<typeof Bun.spawn>
|
|
70
|
+
): Promise<boolean> {
|
|
71
|
+
if (!proc.stdout || typeof proc.stdout === "number") {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const reader = proc.stdout.getReader();
|
|
76
|
+
try {
|
|
77
|
+
const result = await Promise.race([
|
|
78
|
+
reader.read(),
|
|
79
|
+
proc.exited.then(() => null),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
if (!result || result.done || !result.value) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const text = new TextDecoder().decode(result.value);
|
|
87
|
+
return text.includes(READY_TOKEN);
|
|
88
|
+
} finally {
|
|
89
|
+
await reader.cancel().catch(() => undefined);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function acquireWriteLock(
|
|
94
|
+
lockPath: string,
|
|
95
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
|
96
|
+
): Promise<WriteLockHandle | null> {
|
|
97
|
+
const cmd = resolveLockCommand();
|
|
98
|
+
if (!cmd) {
|
|
99
|
+
throw new Error("No lockf/flock available for write locking");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
103
|
+
|
|
104
|
+
const timeoutSeconds = Math.max(0, Math.ceil(timeoutMs / 1000));
|
|
105
|
+
const holdCommand = buildHoldCommand();
|
|
106
|
+
const proc = Bun.spawn(
|
|
107
|
+
[cmd.path, ...cmd.args(lockPath, timeoutSeconds, holdCommand)],
|
|
108
|
+
{
|
|
109
|
+
stdout: "pipe",
|
|
110
|
+
stderr: "pipe",
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const ready = await waitForReady(proc);
|
|
115
|
+
if (!ready) {
|
|
116
|
+
proc.kill();
|
|
117
|
+
await proc.exited.catch(() => undefined);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
release: async () => {
|
|
123
|
+
proc.kill();
|
|
124
|
+
await proc.exited.catch(() => undefined);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function withWriteLock<T>(
|
|
130
|
+
lockPath: string,
|
|
131
|
+
fn: () => Promise<T>,
|
|
132
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
|
133
|
+
): Promise<T> {
|
|
134
|
+
const lock = await acquireWriteLock(lockPath, timeoutMs);
|
|
135
|
+
if (!lock) {
|
|
136
|
+
throw new Error(`${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
return await fn();
|
|
141
|
+
} finally {
|
|
142
|
+
await lock.release();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared file operations.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/file-ops
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:fs/promises for rename/unlink (no Bun equivalent for structure ops)
|
|
8
|
+
import { rename, unlink } from "node:fs/promises";
|
|
9
|
+
|
|
10
|
+
export async function atomicWrite(
|
|
11
|
+
path: string,
|
|
12
|
+
content: string
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
const tempPath = `${path}.tmp.${crypto.randomUUID()}`;
|
|
15
|
+
await Bun.write(tempPath, content);
|
|
16
|
+
try {
|
|
17
|
+
await rename(tempPath, path);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
await unlink(tempPath).catch(() => {
|
|
20
|
+
/* ignore cleanup errors */
|
|
21
|
+
});
|
|
22
|
+
throw e;
|
|
23
|
+
}
|
|
24
|
+
}
|