@gmickel/gno 1.24.0 → 1.26.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 -4
- package/assets/skill/SKILL.md +40 -17
- package/assets/skill/recipes/capture-and-file.md +20 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
- package/browser-extension/dist/PRIVACY.md +55 -0
- package/browser-extension/dist/chunk-vn5f663b.js +50 -0
- package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
- package/browser-extension/dist/content.js +1 -0
- package/browser-extension/dist/manifest.json +25 -0
- package/browser-extension/dist/preview.html +13 -0
- package/browser-extension/dist/service-worker.js +40 -0
- package/package.json +13 -3
- package/spec/cli.md +196 -4
- package/spec/db/schema.sql +102 -1
- package/spec/mcp.md +10 -0
- package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
- package/spec/output-schemas/browser-clip.schema.json +586 -0
- package/spec/output-schemas/capture-receipt.schema.json +22 -1
- package/spec/output-schemas/clipper-csrf.schema.json +12 -0
- package/spec/output-schemas/clipper-error.schema.json +46 -0
- package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
- package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
- package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
- package/spec/output-schemas/clipper-revoke.schema.json +28 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
- package/spec/output-schemas/project-profile-apply.schema.json +209 -0
- package/spec/output-schemas/project-profile-command.schema.json +160 -0
- package/spec/output-schemas/query-diagnose.schema.json +7 -1
- package/spec/output-schemas/setup-profile-result.schema.json +87 -0
- package/spec/project-profile.schema.json +301 -0
- package/src/cli/commands/collection/add.ts +39 -45
- package/src/cli/commands/collection/remove.ts +28 -28
- package/src/cli/commands/collection/rename.ts +55 -73
- package/src/cli/commands/context/add.ts +37 -26
- package/src/cli/commands/context/rm.ts +47 -20
- package/src/cli/commands/init.ts +55 -125
- package/src/cli/commands/models/use.ts +43 -38
- package/src/cli/commands/profile-apply.ts +334 -0
- package/src/cli/commands/profile.ts +409 -0
- package/src/cli/commands/setup-activation.ts +205 -54
- package/src/cli/commands/setup-profile.ts +223 -0
- package/src/cli/commands/setup.ts +3 -0
- package/src/cli/program.ts +110 -9
- package/src/config/index.ts +3 -0
- package/src/config/project-profile.ts +367 -0
- package/src/config/saver.ts +16 -7
- package/src/config/types.ts +42 -0
- package/src/core/browser-clip-provenance.ts +139 -0
- package/src/core/browser-clip.ts +473 -0
- package/src/core/capture-write.ts +5 -0
- package/src/core/capture.ts +75 -18
- package/src/core/config-mutation.ts +138 -76
- package/src/core/config-write-lock.ts +89 -0
- package/src/core/context-identity.ts +16 -0
- package/src/core/context-resolver.ts +2 -12
- package/src/core/file-lock.ts +20 -6
- package/src/core/folder-setup-planning.ts +6 -21
- package/src/core/folder-setup.ts +30 -2
- package/src/core/path-rules.ts +53 -0
- package/src/core/project-affinity-surface.ts +102 -7
- package/src/core/project-profile-apply-state.ts +268 -0
- package/src/core/project-profile-apply-validation.ts +95 -0
- package/src/core/project-profile-apply.ts +408 -0
- package/src/core/project-profile-canonical.ts +71 -0
- package/src/core/project-profile-diff.ts +302 -0
- package/src/core/project-profile-discovery.ts +519 -0
- package/src/core/project-profile-file.ts +37 -0
- package/src/core/project-profile-parser.ts +98 -0
- package/src/core/project-profile.ts +490 -0
- package/src/ingestion/walker.ts +85 -44
- package/src/llm/cache.ts +21 -0
- package/src/serve/capture-service.ts +420 -0
- package/src/serve/clipper-body.ts +62 -0
- package/src/serve/clipper-capture.ts +248 -0
- package/src/serve/clipper-contract.ts +57 -0
- package/src/serve/clipper-idempotency.ts +35 -0
- package/src/serve/clipper-pairing.ts +297 -0
- package/src/serve/clipper-security-errors.ts +23 -0
- package/src/serve/clipper-security.ts +449 -0
- package/src/serve/config-sync.ts +2 -2
- package/src/serve/public/app.tsx +8 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/index.html +1 -0
- package/src/serve/public/lib/clipper-approval.ts +206 -0
- package/src/serve/public/pages/ClipperPairing.tsx +210 -0
- package/src/serve/resident-runtime.ts +1 -0
- package/src/serve/routes/api.ts +20 -115
- package/src/serve/routes/clipper.ts +394 -0
- package/src/serve/server.ts +22 -0
- package/src/store/migrations/020-browser-clipper-security.ts +128 -0
- package/src/store/migrations/021-multi-context-identity.ts +37 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +83 -0
- package/src/store/sqlite/clipper-store-types.ts +104 -0
- package/src/store/sqlite/clipper-store.ts +496 -0
package/src/core/capture.ts
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
import { posix as pathPosix } from "node:path";
|
|
11
11
|
|
|
12
12
|
import { buildUri } from "../app/constants";
|
|
13
|
+
import {
|
|
14
|
+
browserClipProvenanceSchema,
|
|
15
|
+
type BrowserClipProvenance,
|
|
16
|
+
} from "./browser-clip-provenance";
|
|
13
17
|
import {
|
|
14
18
|
resolveNoteCreatePlan,
|
|
15
19
|
type NoteCollisionPolicy,
|
|
@@ -59,9 +63,13 @@ export interface CaptureSource {
|
|
|
59
63
|
mime?: string;
|
|
60
64
|
ext?: string;
|
|
61
65
|
author?: string;
|
|
66
|
+
canonicalUrl?: string;
|
|
67
|
+
site?: string;
|
|
68
|
+
publishedAt?: string;
|
|
62
69
|
observedAt?: string;
|
|
63
70
|
capturedAt: string;
|
|
64
71
|
externalId?: string;
|
|
72
|
+
browserClip?: BrowserClipProvenance;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
export interface CaptureIndexStatus {
|
|
@@ -117,6 +125,7 @@ export interface CapturePlan {
|
|
|
117
125
|
source: CaptureSource;
|
|
118
126
|
openedExisting: boolean;
|
|
119
127
|
createdWithSuffix: boolean;
|
|
128
|
+
provenanceConflict: boolean;
|
|
120
129
|
collisionPolicy: NoteCollisionPolicy;
|
|
121
130
|
collisionPolicyResult: CaptureCollisionPolicyResult;
|
|
122
131
|
overwrite: boolean;
|
|
@@ -126,6 +135,7 @@ export interface PlanCaptureOptions {
|
|
|
126
135
|
input: CaptureInput;
|
|
127
136
|
existingRelPaths: Iterable<string>;
|
|
128
137
|
diskRelPaths?: Iterable<string>;
|
|
138
|
+
existingProvenanceByRelPath?: ReadonlyMap<string, string>;
|
|
129
139
|
now?: Date;
|
|
130
140
|
}
|
|
131
141
|
|
|
@@ -145,7 +155,7 @@ const VALID_COLLISION_POLICIES = new Set<NoteCollisionPolicy>([
|
|
|
145
155
|
"open_existing",
|
|
146
156
|
"create_with_suffix",
|
|
147
157
|
]);
|
|
148
|
-
const URL_SOURCE_FIELDS = new Set(["url", "uri"]);
|
|
158
|
+
const URL_SOURCE_FIELDS = new Set(["url", "uri", "canonicalUrl"]);
|
|
149
159
|
const LEGACY_SOURCE_FIELD_MAP: Record<string, keyof CaptureSource> = {
|
|
150
160
|
gno_source_docid: "docid",
|
|
151
161
|
gno_source_uri: "uri",
|
|
@@ -160,6 +170,9 @@ const CAPTURE_SOURCE_STRING_KEYS = new Set([
|
|
|
160
170
|
"mime",
|
|
161
171
|
"ext",
|
|
162
172
|
"author",
|
|
173
|
+
"canonicalUrl",
|
|
174
|
+
"site",
|
|
175
|
+
"publishedAt",
|
|
163
176
|
"externalId",
|
|
164
177
|
]);
|
|
165
178
|
|
|
@@ -264,27 +277,46 @@ function normalizeSource(
|
|
|
264
277
|
continue;
|
|
265
278
|
}
|
|
266
279
|
if (key === "capturedAt") {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
);
|
|
280
|
+
if (typeof value !== "string") {
|
|
281
|
+
throw new Error("source.capturedAt must be a string.");
|
|
282
|
+
}
|
|
283
|
+
normalized.capturedAt = normalizeIsoDate(value, "source.capturedAt");
|
|
271
284
|
continue;
|
|
272
285
|
}
|
|
273
286
|
if (key === "observedAt") {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
);
|
|
287
|
+
if (typeof value !== "string") {
|
|
288
|
+
throw new Error("source.observedAt must be a string.");
|
|
289
|
+
}
|
|
290
|
+
normalized.observedAt = normalizeIsoDate(value, "source.observedAt");
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (key === "publishedAt") {
|
|
294
|
+
if (typeof value !== "string") {
|
|
295
|
+
throw new Error("source.publishedAt must be a string.");
|
|
296
|
+
}
|
|
297
|
+
normalized.publishedAt = /^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
298
|
+
? value
|
|
299
|
+
: normalizeIsoDate(value, "source.publishedAt");
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (key === "browserClip") {
|
|
303
|
+
normalized.browserClip = browserClipProvenanceSchema.parse(value);
|
|
278
304
|
continue;
|
|
279
305
|
}
|
|
280
306
|
if (URL_SOURCE_FIELDS.has(key)) {
|
|
307
|
+
if (typeof value !== "string") {
|
|
308
|
+
throw new Error(`source.${key} must be a string.`);
|
|
309
|
+
}
|
|
281
310
|
try {
|
|
282
|
-
new URL(
|
|
311
|
+
new URL(value);
|
|
283
312
|
} catch {
|
|
284
313
|
throw new Error(`source.${key} must be a valid URL.`);
|
|
285
314
|
}
|
|
286
315
|
}
|
|
287
316
|
if (CAPTURE_SOURCE_STRING_KEYS.has(key)) {
|
|
317
|
+
if (typeof value !== "string") {
|
|
318
|
+
throw new Error(`source.${key} must be a string.`);
|
|
319
|
+
}
|
|
288
320
|
normalized[
|
|
289
321
|
key as keyof Pick<
|
|
290
322
|
CaptureSource,
|
|
@@ -295,9 +327,11 @@ function normalizeSource(
|
|
|
295
327
|
| "mime"
|
|
296
328
|
| "ext"
|
|
297
329
|
| "author"
|
|
330
|
+
| "canonicalUrl"
|
|
331
|
+
| "site"
|
|
298
332
|
| "externalId"
|
|
299
333
|
>
|
|
300
|
-
] =
|
|
334
|
+
] = value;
|
|
301
335
|
}
|
|
302
336
|
}
|
|
303
337
|
|
|
@@ -489,6 +523,16 @@ export function extractCaptureSourceFromFrontmatter(
|
|
|
489
523
|
.trim() as keyof CaptureSource;
|
|
490
524
|
const nestedValue = nested.slice(nestedColon + 1).trim();
|
|
491
525
|
if (nestedValue) {
|
|
526
|
+
if (nestedKey === "browserClip") {
|
|
527
|
+
try {
|
|
528
|
+
const parsed = JSON.parse(nestedValue) as unknown;
|
|
529
|
+
const provenance = browserClipProvenanceSchema.safeParse(parsed);
|
|
530
|
+
if (provenance.success) source.browserClip = provenance.data;
|
|
531
|
+
} catch {
|
|
532
|
+
// Ignore malformed optional browser provenance.
|
|
533
|
+
}
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
492
536
|
source[nestedKey] = stripYamlString(nestedValue) as never;
|
|
493
537
|
}
|
|
494
538
|
}
|
|
@@ -664,6 +708,13 @@ export function planCapture(options: PlanCaptureOptions): CapturePlan {
|
|
|
664
708
|
overwrite ? [] : existing
|
|
665
709
|
);
|
|
666
710
|
const overwritten = overwrite && existing.has(createPlan.relPath);
|
|
711
|
+
const clipIdentity = source.browserClip?.clipIdentity;
|
|
712
|
+
const provenanceConflict =
|
|
713
|
+
createPlan.openedExisting &&
|
|
714
|
+
clipIdentity !== undefined &&
|
|
715
|
+
options.existingProvenanceByRelPath?.get(createPlan.relPath) !==
|
|
716
|
+
clipIdentity;
|
|
717
|
+
const openedExisting = createPlan.openedExisting && !provenanceConflict;
|
|
667
718
|
|
|
668
719
|
return {
|
|
669
720
|
collection: options.input.collection,
|
|
@@ -675,16 +726,19 @@ export function planCapture(options: PlanCaptureOptions): CapturePlan {
|
|
|
675
726
|
title,
|
|
676
727
|
tags: contentTags,
|
|
677
728
|
source,
|
|
678
|
-
openedExisting
|
|
729
|
+
openedExisting,
|
|
679
730
|
createdWithSuffix: createPlan.createdWithSuffix,
|
|
731
|
+
provenanceConflict,
|
|
680
732
|
collisionPolicy,
|
|
681
733
|
collisionPolicyResult: overwritten
|
|
682
734
|
? "overwritten"
|
|
683
|
-
:
|
|
684
|
-
? "
|
|
685
|
-
:
|
|
686
|
-
? "
|
|
687
|
-
:
|
|
735
|
+
: provenanceConflict
|
|
736
|
+
? "conflict"
|
|
737
|
+
: openedExisting
|
|
738
|
+
? "opened_existing"
|
|
739
|
+
: createPlan.createdWithSuffix
|
|
740
|
+
? "created_with_suffix"
|
|
741
|
+
: "created",
|
|
688
742
|
overwrite,
|
|
689
743
|
};
|
|
690
744
|
}
|
|
@@ -705,7 +759,10 @@ export function buildCaptureReceipt(input: {
|
|
|
705
759
|
collection: input.plan.collection,
|
|
706
760
|
relPath: input.plan.relPath,
|
|
707
761
|
absPath: input.absPath,
|
|
708
|
-
created:
|
|
762
|
+
created:
|
|
763
|
+
!input.plan.openedExisting &&
|
|
764
|
+
!input.plan.provenanceConflict &&
|
|
765
|
+
!overwritten,
|
|
709
766
|
openedExisting: input.plan.openedExisting,
|
|
710
767
|
createdWithSuffix: input.plan.createdWithSuffix,
|
|
711
768
|
overwritten,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Canonical config mutation helper shared by every config writer.
|
|
3
3
|
*
|
|
4
4
|
* @module src/core/config-mutation
|
|
5
5
|
*/
|
|
@@ -9,27 +9,42 @@ import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
formatConfigWarnings,
|
|
12
|
+
getConfigPaths,
|
|
12
13
|
loadConfig,
|
|
13
14
|
normalizeConfigContentTypes,
|
|
14
15
|
saveConfig,
|
|
15
16
|
} from "../config";
|
|
17
|
+
import { resolveConfigWriteTarget } from "./config-write-lock";
|
|
16
18
|
import { withWriteLock } from "./file-lock";
|
|
17
19
|
|
|
18
|
-
export interface
|
|
19
|
-
store: SqliteAdapter;
|
|
20
|
+
export interface ConfigFileMutationContext {
|
|
20
21
|
configPath?: string;
|
|
21
|
-
onConfigUpdated: (config: Config) => void;
|
|
22
22
|
/**
|
|
23
|
-
* Optional
|
|
24
|
-
*
|
|
25
|
-
|
|
23
|
+
* Optional first-run config factory. Callers must opt in explicitly; existing
|
|
24
|
+
* mutation surfaces keep treating a missing config as an error.
|
|
25
|
+
*/
|
|
26
|
+
createConfigIfMissing?: () => Config;
|
|
27
|
+
onConfigUpdated?: (config: Config) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ConfigMutationContext extends ConfigFileMutationContext {
|
|
31
|
+
store: SqliteAdapter;
|
|
32
|
+
/**
|
|
33
|
+
* Optional targeted store projection. The default reconciles the complete
|
|
34
|
+
* config. Create/update-only callers can project a bounded subset without
|
|
35
|
+
* deleting DB-only recovery state.
|
|
26
36
|
*/
|
|
27
|
-
|
|
37
|
+
projectStore?: (
|
|
38
|
+
store: SqliteAdapter,
|
|
39
|
+
config: Config
|
|
40
|
+
) => Promise<{ ok: true } | { ok: false; error: string }>;
|
|
28
41
|
/**
|
|
29
42
|
* Runs after the selected config is durably present and before store projection.
|
|
30
43
|
* Setup recovery uses this boundary to persist a truthful resumable receipt.
|
|
31
44
|
*/
|
|
32
45
|
afterConfigSaved?: (config: Config) => Promise<void> | void;
|
|
46
|
+
/** Runs after config projection succeeds while the write lock is still held. */
|
|
47
|
+
afterStoreSynced?: (config: Config) => Promise<void> | void;
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
export type MutationResult<T = void> =
|
|
@@ -40,68 +55,130 @@ export type ApplyConfigResult<T = void> =
|
|
|
40
55
|
| { ok: true; config: Config; value?: T }
|
|
41
56
|
| { ok: false; error: string; code: string };
|
|
42
57
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*/
|
|
47
|
-
let configMutex: Promise<void> = Promise.resolve();
|
|
58
|
+
export interface ConfigMutationState {
|
|
59
|
+
created: boolean;
|
|
60
|
+
}
|
|
48
61
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
type PersistedConfigHook = (
|
|
63
|
+
config: Config
|
|
64
|
+
) => Promise<{ ok: true } | { ok: false; error: string; code: string }>;
|
|
65
|
+
|
|
66
|
+
async function applySerializedConfigChange<T>(
|
|
67
|
+
ctx: ConfigFileMutationContext,
|
|
68
|
+
mutate: (
|
|
69
|
+
config: Config,
|
|
70
|
+
state: ConfigMutationState
|
|
71
|
+
) => Promise<MutationResult<T>> | MutationResult<T>,
|
|
72
|
+
afterPersist?: PersistedConfigHook
|
|
52
73
|
): Promise<ApplyConfigResult<T>> {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
ok: false,
|
|
70
|
-
error: loadResult.error.message,
|
|
71
|
-
code: "LOAD_ERROR",
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
+
const requestedConfigPath = ctx.configPath ?? getConfigPaths().configFile;
|
|
75
|
+
const writeTarget = await resolveConfigWriteTarget(requestedConfigPath);
|
|
76
|
+
const selectedConfigPath = writeTarget.configPath;
|
|
77
|
+
const applyFreshConfigChange = async (): Promise<ApplyConfigResult<T>> => {
|
|
78
|
+
const loadResult = await loadConfig(selectedConfigPath);
|
|
79
|
+
if (
|
|
80
|
+
!loadResult.ok &&
|
|
81
|
+
!(loadResult.error.code === "NOT_FOUND" && ctx.createConfigIfMissing)
|
|
82
|
+
) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: loadResult.error.message,
|
|
86
|
+
code: "LOAD_ERROR",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (loadResult.ok) {
|
|
74
90
|
for (const warning of formatConfigWarnings(loadResult.warnings)) {
|
|
75
91
|
console.warn(warning);
|
|
76
92
|
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const currentConfig = loadResult.ok
|
|
96
|
+
? loadResult.value
|
|
97
|
+
: ctx.createConfigIfMissing?.();
|
|
98
|
+
if (!currentConfig) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: "Config file is missing",
|
|
102
|
+
code: "LOAD_ERROR",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const mutationResult = await mutate(currentConfig, {
|
|
106
|
+
created: !loadResult.ok,
|
|
107
|
+
});
|
|
108
|
+
if (!mutationResult.ok) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: mutationResult.error,
|
|
112
|
+
code: mutationResult.code,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
77
115
|
|
|
78
|
-
|
|
79
|
-
|
|
116
|
+
const normalized = normalizeConfigContentTypes(mutationResult.config);
|
|
117
|
+
for (const warning of formatConfigWarnings(normalized.warnings)) {
|
|
118
|
+
console.warn(warning);
|
|
119
|
+
}
|
|
120
|
+
const newConfig = normalized.config;
|
|
121
|
+
if (!mutationResult.skipSave) {
|
|
122
|
+
const saveResult = await saveConfig(newConfig, selectedConfigPath);
|
|
123
|
+
if (!saveResult.ok) {
|
|
80
124
|
return {
|
|
81
125
|
ok: false,
|
|
82
|
-
error:
|
|
83
|
-
code:
|
|
126
|
+
error: saveResult.error.message,
|
|
127
|
+
code: "SAVE_ERROR",
|
|
84
128
|
};
|
|
85
129
|
}
|
|
130
|
+
}
|
|
86
131
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
132
|
+
const persisted = await afterPersist?.(newConfig);
|
|
133
|
+
if (persisted && !persisted.ok) return persisted;
|
|
134
|
+
ctx.onConfigUpdated?.(newConfig);
|
|
135
|
+
|
|
136
|
+
return { ok: true, config: newConfig, value: mutationResult.value };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
return await withWriteLock(writeTarget.lockPath, applyFreshConfigChange);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error instanceof Error && error.message.startsWith("LOCKED:")) {
|
|
143
|
+
return { ok: false, error: error.message, code: "LOCKED" };
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
102
148
|
|
|
103
|
-
|
|
149
|
+
/** Mutate only the selected config file under the canonical shared lock. */
|
|
150
|
+
export async function applyConfigFileChange<T = void>(
|
|
151
|
+
ctx: ConfigFileMutationContext,
|
|
152
|
+
mutate: (
|
|
153
|
+
config: Config,
|
|
154
|
+
state: ConfigMutationState
|
|
155
|
+
) => Promise<MutationResult<T>> | MutationResult<T>
|
|
156
|
+
): Promise<ApplyConfigResult<T>> {
|
|
157
|
+
return applySerializedConfigChange(ctx, mutate);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Mutate config and project it to the selected store in one locked boundary. */
|
|
161
|
+
export async function applyConfigChange<T = void>(
|
|
162
|
+
ctx: ConfigMutationContext,
|
|
163
|
+
mutate: (
|
|
164
|
+
config: Config,
|
|
165
|
+
state: ConfigMutationState
|
|
166
|
+
) => Promise<MutationResult<T>> | MutationResult<T>
|
|
167
|
+
): Promise<ApplyConfigResult<T>> {
|
|
168
|
+
return applySerializedConfigChange(ctx, mutate, async (newConfig) => {
|
|
169
|
+
await ctx.afterConfigSaved?.(newConfig);
|
|
104
170
|
|
|
171
|
+
if (ctx.projectStore) {
|
|
172
|
+
const projection = await ctx.projectStore(ctx.store, newConfig);
|
|
173
|
+
if (!projection.ok) {
|
|
174
|
+
console.warn(`Config saved but DB sync failed: ${projection.error}`);
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: `DB sync failed: ${projection.error}`,
|
|
178
|
+
code: "SYNC_ERROR",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
105
182
|
const syncCollResult = await ctx.store.syncCollections(
|
|
106
183
|
newConfig.collections
|
|
107
184
|
);
|
|
@@ -129,24 +206,9 @@ export async function applyConfigChange<T = void>(
|
|
|
129
206
|
code: "SYNC_ERROR",
|
|
130
207
|
};
|
|
131
208
|
}
|
|
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;
|
|
148
209
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
210
|
+
|
|
211
|
+
await ctx.afterStoreSynced?.(newConfig);
|
|
212
|
+
return { ok: true };
|
|
213
|
+
});
|
|
152
214
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical cross-process lock identity for all config writers.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/config-write-lock
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:fs/promises provides symlink-aware path operations; Bun has no equivalent for canonical path identity.
|
|
8
|
+
import { lstat, readlink, realpath } from "node:fs/promises";
|
|
9
|
+
// node:path provides structural path operations; Bun has no path utilities.
|
|
10
|
+
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { expandPath } from "../config/paths";
|
|
13
|
+
|
|
14
|
+
const MISSING_PATH_ERROR_CODES = new Set(["ENOENT", "ENOTDIR"]);
|
|
15
|
+
|
|
16
|
+
function isMissingPathError(cause: unknown): boolean {
|
|
17
|
+
return (
|
|
18
|
+
cause instanceof Error &&
|
|
19
|
+
"code" in cause &&
|
|
20
|
+
typeof cause.code === "string" &&
|
|
21
|
+
MISSING_PATH_ERROR_CODES.has(cause.code)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function canonicalizeProspectivePath(
|
|
26
|
+
absolutePath: string,
|
|
27
|
+
seenLinks: Set<string>
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
const unresolved: string[] = [];
|
|
30
|
+
let candidate = absolutePath;
|
|
31
|
+
|
|
32
|
+
while (true) {
|
|
33
|
+
try {
|
|
34
|
+
const info = await lstat(candidate);
|
|
35
|
+
if (info.isSymbolicLink()) {
|
|
36
|
+
if (seenLinks.has(candidate)) {
|
|
37
|
+
throw new Error(`Config path contains a symlink loop: ${candidate}`);
|
|
38
|
+
}
|
|
39
|
+
seenLinks.add(candidate);
|
|
40
|
+
|
|
41
|
+
const linkTarget = await readlink(candidate);
|
|
42
|
+
const absoluteTarget = isAbsolute(linkTarget)
|
|
43
|
+
? linkTarget
|
|
44
|
+
: resolve(dirname(candidate), linkTarget);
|
|
45
|
+
const canonicalTarget = await canonicalizeProspectivePath(
|
|
46
|
+
absoluteTarget,
|
|
47
|
+
seenLinks
|
|
48
|
+
);
|
|
49
|
+
return resolve(canonicalTarget, ...unresolved.reverse());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const canonicalAncestor = await realpath(candidate);
|
|
53
|
+
return resolve(canonicalAncestor, ...unresolved.reverse());
|
|
54
|
+
} catch (cause) {
|
|
55
|
+
if (!isMissingPathError(cause)) throw cause;
|
|
56
|
+
|
|
57
|
+
const parent = dirname(candidate);
|
|
58
|
+
if (parent === candidate) return absolutePath;
|
|
59
|
+
unresolved.push(basename(candidate));
|
|
60
|
+
candidate = parent;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve an existing path, or resolve its nearest existing ancestor while
|
|
67
|
+
* retaining unresolved path components. This makes aliases of the same config
|
|
68
|
+
* file converge before the file exists and after it is created.
|
|
69
|
+
*/
|
|
70
|
+
export async function canonicalOperationalPath(path: string): Promise<string> {
|
|
71
|
+
return canonicalizeProspectivePath(resolve(expandPath(path)), new Set());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One stable sibling lock for every writer of the selected config file. */
|
|
75
|
+
export async function resolveConfigWriteTarget(
|
|
76
|
+
configPath: string
|
|
77
|
+
): Promise<{ configPath: string; lockPath: string }> {
|
|
78
|
+
const canonicalConfigPath = await canonicalOperationalPath(configPath);
|
|
79
|
+
return {
|
|
80
|
+
configPath: canonicalConfigPath,
|
|
81
|
+
lockPath: `${canonicalConfigPath}.write.lock`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getConfigWriteLockPath(
|
|
86
|
+
configPath: string
|
|
87
|
+
): Promise<string> {
|
|
88
|
+
return (await resolveConfigWriteTarget(configPath)).lockPath;
|
|
89
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical identity helpers for persisted context records.
|
|
3
|
+
*
|
|
4
|
+
* @module src/core/context-identity
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const BYTE_ORDER_MARK_PATTERN = /^\uFEFF/u;
|
|
8
|
+
const CARRIAGE_RETURN_PATTERN = /\r\n?/g;
|
|
9
|
+
|
|
10
|
+
export function normalizePersistedContextText(text: string): string {
|
|
11
|
+
return text
|
|
12
|
+
.replace(BYTE_ORDER_MARK_PATTERN, "")
|
|
13
|
+
.replace(CARRIAGE_RETURN_PATTERN, "\n")
|
|
14
|
+
.normalize("NFC")
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import type { ContextRow, StorePort } from "../store/types";
|
|
2
2
|
|
|
3
3
|
import { parseUri } from "../app/constants";
|
|
4
|
-
|
|
5
|
-
const CARRIAGE_RETURN_PATTERN = /\r\n?/g;
|
|
6
|
-
const BYTE_ORDER_MARK_PATTERN = /^\uFEFF/u;
|
|
4
|
+
import { normalizePersistedContextText } from "./context-identity";
|
|
7
5
|
|
|
8
6
|
export interface ContextDocumentIdentity {
|
|
9
7
|
collection: string;
|
|
@@ -73,14 +71,6 @@ function normalizeIdentity(
|
|
|
73
71
|
return { collection, relPath };
|
|
74
72
|
}
|
|
75
73
|
|
|
76
|
-
function normalizeText(text: string): string {
|
|
77
|
-
return text
|
|
78
|
-
.replace(BYTE_ORDER_MARK_PATTERN, "")
|
|
79
|
-
.replace(CARRIAGE_RETURN_PATTERN, "\n")
|
|
80
|
-
.normalize("NFC")
|
|
81
|
-
.trim();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
74
|
function byteKey(text: string): string {
|
|
85
75
|
return [...new TextEncoder().encode(text)].join(",");
|
|
86
76
|
}
|
|
@@ -95,7 +85,7 @@ function normalizeContext(
|
|
|
95
85
|
context: ContextRow,
|
|
96
86
|
identity: NormalizedIdentity
|
|
97
87
|
): MatchingContext | null {
|
|
98
|
-
const text =
|
|
88
|
+
const text = normalizePersistedContextText(context.text);
|
|
99
89
|
if (!text) {
|
|
100
90
|
return null;
|
|
101
91
|
}
|
package/src/core/file-lock.ts
CHANGED
|
@@ -36,6 +36,7 @@ function resolveLockCommand(): LockCommand | null {
|
|
|
36
36
|
return {
|
|
37
37
|
path: lockfPath,
|
|
38
38
|
args: (lockPath, timeoutSeconds, holdCommand) => [
|
|
39
|
+
"-k",
|
|
39
40
|
"-t",
|
|
40
41
|
String(timeoutSeconds),
|
|
41
42
|
lockPath,
|
|
@@ -51,6 +52,7 @@ function resolveLockCommand(): LockCommand | null {
|
|
|
51
52
|
return {
|
|
52
53
|
path: flockPath,
|
|
53
54
|
args: (lockPath, timeoutSeconds, holdCommand) => [
|
|
55
|
+
"--no-fork",
|
|
54
56
|
"-w",
|
|
55
57
|
String(timeoutSeconds),
|
|
56
58
|
lockPath,
|
|
@@ -93,6 +95,21 @@ async function waitForReady(
|
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
async function terminateLockProcess(
|
|
99
|
+
proc: ReturnType<typeof Bun.spawn>
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
if (process.platform === "win32") {
|
|
102
|
+
if (proc.exitCode === null) proc.kill();
|
|
103
|
+
} else {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(-proc.pid, "SIGTERM");
|
|
106
|
+
} catch {
|
|
107
|
+
if (proc.exitCode === null) proc.kill();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
await proc.exited.catch(() => undefined);
|
|
111
|
+
}
|
|
112
|
+
|
|
96
113
|
function sqliteLockPath(lockPath: string): string {
|
|
97
114
|
return `${lockPath}${SQLITE_LOCK_SUFFIX}`;
|
|
98
115
|
}
|
|
@@ -163,6 +180,7 @@ export async function acquireWriteLock(
|
|
|
163
180
|
const proc = Bun.spawn(
|
|
164
181
|
[cmd.path, ...cmd.args(lockPath, timeoutSeconds, holdCommand)],
|
|
165
182
|
{
|
|
183
|
+
detached: true,
|
|
166
184
|
stdout: "pipe",
|
|
167
185
|
stderr: "pipe",
|
|
168
186
|
}
|
|
@@ -170,16 +188,12 @@ export async function acquireWriteLock(
|
|
|
170
188
|
|
|
171
189
|
const ready = await waitForReady(proc);
|
|
172
190
|
if (!ready) {
|
|
173
|
-
proc
|
|
174
|
-
await proc.exited.catch(() => undefined);
|
|
191
|
+
await terminateLockProcess(proc);
|
|
175
192
|
return null;
|
|
176
193
|
}
|
|
177
194
|
|
|
178
195
|
return {
|
|
179
|
-
release:
|
|
180
|
-
proc.kill();
|
|
181
|
-
await proc.exited.catch(() => undefined);
|
|
182
|
-
},
|
|
196
|
+
release: () => terminateLockProcess(proc),
|
|
183
197
|
};
|
|
184
198
|
}
|
|
185
199
|
|