@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.
- package/README.md +18 -12
- package/assets/skill/SKILL.md +26 -19
- package/package.json +1 -1
- package/spec/cli.md +91 -0
- package/spec/output-schemas/setup-activation-result.schema.json +456 -0
- package/spec/output-schemas/setup-command-result.schema.json +93 -0
- package/spec/output-schemas/setup-receipt.schema.json +258 -0
- package/spec/output-schemas/setup-semantic-receipt.schema.json +195 -0
- package/src/cli/commands/completion/scripts.ts +2 -0
- package/src/cli/commands/embed.ts +7 -2
- package/src/cli/commands/setup-activation.ts +324 -0
- package/src/cli/commands/setup-semantic.ts +591 -0
- package/src/cli/commands/setup.ts +410 -0
- package/src/cli/program.ts +64 -0
- package/src/cli/setup-semantic-worker.ts +177 -0
- package/src/core/config-mutation.ts +94 -64
- package/src/core/file-lock.ts +70 -31
- package/src/core/folder-setup-planning.ts +453 -0
- package/src/core/folder-setup.ts +490 -0
- package/src/core/setup-activation.ts +309 -0
- package/src/core/setup-receipt.ts +321 -0
- package/src/serve/connectors.ts +29 -2
|
@@ -13,15 +13,27 @@ import {
|
|
|
13
13
|
normalizeConfigContentTypes,
|
|
14
14
|
saveConfig,
|
|
15
15
|
} from "../config";
|
|
16
|
+
import { withWriteLock } from "./file-lock";
|
|
16
17
|
|
|
17
18
|
export interface ConfigMutationContext {
|
|
18
19
|
store: SqliteAdapter;
|
|
19
20
|
configPath?: string;
|
|
20
21
|
onConfigUpdated: (config: Config) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Optional cross-process serialization boundary. The in-memory mutex remains
|
|
24
|
+
* authoritative within one process; callers sharing a config across
|
|
25
|
+
* processes must additionally share this OS-backed lock path.
|
|
26
|
+
*/
|
|
27
|
+
writeLockPath?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Runs after the selected config is durably present and before store projection.
|
|
30
|
+
* Setup recovery uses this boundary to persist a truthful resumable receipt.
|
|
31
|
+
*/
|
|
32
|
+
afterConfigSaved?: (config: Config) => Promise<void> | void;
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
export type MutationResult<T = void> =
|
|
24
|
-
| { ok: true; config: Config; value?: T }
|
|
36
|
+
| { ok: true; config: Config; value?: T; skipSave?: boolean }
|
|
25
37
|
| { ok: false; error: string; code: string };
|
|
26
38
|
|
|
27
39
|
export type ApplyConfigResult<T = void> =
|
|
@@ -50,72 +62,90 @@ export async function applyConfigChange<T = void>(
|
|
|
50
62
|
try {
|
|
51
63
|
await previousMutex;
|
|
52
64
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
ok
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
65
|
+
const applyFreshConfigChange = async (): Promise<ApplyConfigResult<T>> => {
|
|
66
|
+
const loadResult = await loadConfig(ctx.configPath);
|
|
67
|
+
if (!loadResult.ok) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
error: loadResult.error.message,
|
|
71
|
+
code: "LOAD_ERROR",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
for (const warning of formatConfigWarnings(loadResult.warnings)) {
|
|
75
|
+
console.warn(warning);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const mutationResult = await mutate(loadResult.value);
|
|
79
|
+
if (!mutationResult.ok) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: mutationResult.error,
|
|
83
|
+
code: mutationResult.code,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const normalized = normalizeConfigContentTypes(mutationResult.config);
|
|
88
|
+
for (const warning of formatConfigWarnings(normalized.warnings)) {
|
|
89
|
+
console.warn(warning);
|
|
90
|
+
}
|
|
91
|
+
const newConfig = normalized.config;
|
|
92
|
+
if (!mutationResult.skipSave) {
|
|
93
|
+
const saveResult = await saveConfig(newConfig, ctx.configPath);
|
|
94
|
+
if (!saveResult.ok) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
error: saveResult.error.message,
|
|
98
|
+
code: "SAVE_ERROR",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await ctx.afterConfigSaved?.(newConfig);
|
|
104
|
+
|
|
105
|
+
const syncCollResult = await ctx.store.syncCollections(
|
|
106
|
+
newConfig.collections
|
|
94
107
|
);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
if (!syncCollResult.ok) {
|
|
109
|
+
console.warn(
|
|
110
|
+
`Config saved but DB sync failed: ${syncCollResult.error.message}`
|
|
111
|
+
);
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: `DB sync failed: ${syncCollResult.error.message}`,
|
|
115
|
+
code: "SYNC_ERROR",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const syncCtxResult = await ctx.store.syncContexts(
|
|
120
|
+
newConfig.contexts ?? []
|
|
108
121
|
);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
122
|
+
if (!syncCtxResult.ok) {
|
|
123
|
+
console.warn(
|
|
124
|
+
`Config saved but context sync failed: ${syncCtxResult.error.message}`
|
|
125
|
+
);
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
error: `Context sync failed: ${syncCtxResult.error.message}`,
|
|
129
|
+
code: "SYNC_ERROR",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
ctx.onConfigUpdated(newConfig);
|
|
134
|
+
|
|
135
|
+
return { ok: true, config: newConfig, value: mutationResult.value };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (!ctx.writeLockPath) {
|
|
139
|
+
return await applyFreshConfigChange();
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return await withWriteLock(ctx.writeLockPath, applyFreshConfigChange);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error instanceof Error && error.message.startsWith("LOCKED:")) {
|
|
145
|
+
return { ok: false, error: error.message, code: "LOCKED" };
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
114
148
|
}
|
|
115
|
-
|
|
116
|
-
ctx.onConfigUpdated(newConfig);
|
|
117
|
-
|
|
118
|
-
return { ok: true, config: newConfig, value: mutationResult.value };
|
|
119
149
|
} finally {
|
|
120
150
|
resolveMutex();
|
|
121
151
|
}
|
package/src/core/file-lock.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* @module src/core/file-lock
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import { Database } from "bun:sqlite";
|
|
8
|
+
// node:fs/promises provides recursive directory creation without a Bun equivalent.
|
|
9
|
+
import { mkdir } from "node:fs/promises";
|
|
9
10
|
// node:path for dirname (no Bun path utils)
|
|
10
11
|
import { dirname } from "node:path";
|
|
11
12
|
|
|
@@ -13,8 +14,8 @@ import { MCP_ERRORS } from "./errors";
|
|
|
13
14
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
14
15
|
const HOLD_SECONDS = 60 * 60 * 24 * 365;
|
|
15
16
|
const READY_TOKEN = "READY";
|
|
16
|
-
const
|
|
17
|
-
const
|
|
17
|
+
const SQLITE_LOCK_SUFFIX = ".sqlite";
|
|
18
|
+
const MAX_BUSY_TIMEOUT_MS = 60_000;
|
|
18
19
|
|
|
19
20
|
export interface WriteLockHandle {
|
|
20
21
|
release: () => Promise<void>;
|
|
@@ -67,10 +68,6 @@ function buildHoldCommand(): string {
|
|
|
67
68
|
return `printf '${READY_TOKEN}\\n'; exec sleep ${HOLD_SECONDS}`;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
function delay(ms: number): Promise<void> {
|
|
71
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
71
|
async function waitForReady(
|
|
75
72
|
proc: ReturnType<typeof Bun.spawn>
|
|
76
73
|
): Promise<boolean> {
|
|
@@ -96,33 +93,58 @@ async function waitForReady(
|
|
|
96
93
|
}
|
|
97
94
|
}
|
|
98
95
|
|
|
99
|
-
|
|
96
|
+
function sqliteLockPath(lockPath: string): string {
|
|
97
|
+
return `${lockPath}${SQLITE_LOCK_SUFFIX}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizedBusyTimeout(timeoutMs: number): number {
|
|
101
|
+
if (!Number.isFinite(timeoutMs)) {
|
|
102
|
+
return DEFAULT_TIMEOUT_MS;
|
|
103
|
+
}
|
|
104
|
+
return Math.min(Math.max(0, Math.floor(timeoutMs)), MAX_BUSY_TIMEOUT_MS);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isSqliteLockContention(cause: unknown): boolean {
|
|
108
|
+
if (cause === null || typeof cause !== "object") {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const code = "code" in cause ? cause.code : undefined;
|
|
112
|
+
return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function acquireSqliteWriteLock(
|
|
100
116
|
lockPath: string,
|
|
101
117
|
timeoutMs: number
|
|
102
118
|
): Promise<WriteLockHandle | null> {
|
|
103
|
-
const
|
|
104
|
-
await mkdir(dirname(
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
};
|
|
115
|
-
} catch (error) {
|
|
116
|
-
const code = (error as { code?: string }).code;
|
|
117
|
-
if (code !== "EEXIST") {
|
|
118
|
-
throw error;
|
|
119
|
-
}
|
|
120
|
-
if (Date.now() >= deadline) {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
await delay(Math.min(DIRECTORY_LOCK_POLL_MS, deadline - Date.now()));
|
|
119
|
+
const databasePath = sqliteLockPath(lockPath);
|
|
120
|
+
await mkdir(dirname(databasePath), { recursive: true });
|
|
121
|
+
|
|
122
|
+
const database = new Database(databasePath, { create: true });
|
|
123
|
+
try {
|
|
124
|
+
database.exec(`PRAGMA busy_timeout = ${normalizedBusyTimeout(timeoutMs)}`);
|
|
125
|
+
database.exec("BEGIN IMMEDIATE");
|
|
126
|
+
} catch (cause) {
|
|
127
|
+
database.close();
|
|
128
|
+
if (isSqliteLockContention(cause)) {
|
|
129
|
+
return null;
|
|
124
130
|
}
|
|
131
|
+
throw cause;
|
|
125
132
|
}
|
|
133
|
+
|
|
134
|
+
let released = false;
|
|
135
|
+
return {
|
|
136
|
+
release: async () => {
|
|
137
|
+
if (released) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
released = true;
|
|
141
|
+
try {
|
|
142
|
+
database.exec("ROLLBACK");
|
|
143
|
+
} finally {
|
|
144
|
+
database.close();
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
};
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
export async function acquireWriteLock(
|
|
@@ -131,7 +153,7 @@ export async function acquireWriteLock(
|
|
|
131
153
|
): Promise<WriteLockHandle | null> {
|
|
132
154
|
const cmd = resolveLockCommand();
|
|
133
155
|
if (!cmd) {
|
|
134
|
-
return
|
|
156
|
+
return acquireSqliteWriteLock(lockPath, timeoutMs);
|
|
135
157
|
}
|
|
136
158
|
|
|
137
159
|
await mkdir(dirname(lockPath), { recursive: true });
|
|
@@ -161,6 +183,23 @@ export async function acquireWriteLock(
|
|
|
161
183
|
};
|
|
162
184
|
}
|
|
163
185
|
|
|
186
|
+
export async function withSqliteWriteLock<T>(
|
|
187
|
+
lockPath: string,
|
|
188
|
+
fn: () => Promise<T>,
|
|
189
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
|
190
|
+
): Promise<T> {
|
|
191
|
+
const lock = await acquireSqliteWriteLock(lockPath, timeoutMs);
|
|
192
|
+
if (!lock) {
|
|
193
|
+
throw new Error(`${MCP_ERRORS.LOCKED.code}: ${MCP_ERRORS.LOCKED.message}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
return await fn();
|
|
198
|
+
} finally {
|
|
199
|
+
await lock.release();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
164
203
|
export async function withWriteLock<T>(
|
|
165
204
|
lockPath: string,
|
|
166
205
|
fn: () => Promise<T>,
|