@gmickel/gno 1.23.0 → 1.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +26 -12
  2. package/assets/skill/SKILL.md +37 -19
  3. package/assets/skill/recipes/capture-and-file.md +20 -5
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip.sha256 +1 -0
  6. package/browser-extension/dist/PRIVACY.md +55 -0
  7. package/browser-extension/dist/chunk-vn5f663b.js +50 -0
  8. package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
  9. package/browser-extension/dist/content.js +1 -0
  10. package/browser-extension/dist/manifest.json +25 -0
  11. package/browser-extension/dist/preview.html +13 -0
  12. package/browser-extension/dist/service-worker.js +40 -0
  13. package/package.json +13 -3
  14. package/spec/cli.md +141 -0
  15. package/spec/db/schema.sql +101 -0
  16. package/spec/mcp.md +10 -0
  17. package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
  18. package/spec/output-schemas/browser-clip.schema.json +586 -0
  19. package/spec/output-schemas/capture-receipt.schema.json +22 -1
  20. package/spec/output-schemas/clipper-csrf.schema.json +12 -0
  21. package/spec/output-schemas/clipper-error.schema.json +46 -0
  22. package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
  23. package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
  24. package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
  25. package/spec/output-schemas/clipper-revoke.schema.json +28 -0
  26. package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
  27. package/spec/output-schemas/setup-activation-result.schema.json +456 -0
  28. package/spec/output-schemas/setup-command-result.schema.json +93 -0
  29. package/spec/output-schemas/setup-receipt.schema.json +258 -0
  30. package/spec/output-schemas/setup-semantic-receipt.schema.json +195 -0
  31. package/src/cli/commands/completion/scripts.ts +2 -0
  32. package/src/cli/commands/embed.ts +7 -2
  33. package/src/cli/commands/setup-activation.ts +324 -0
  34. package/src/cli/commands/setup-semantic.ts +591 -0
  35. package/src/cli/commands/setup.ts +410 -0
  36. package/src/cli/program.ts +64 -0
  37. package/src/cli/setup-semantic-worker.ts +177 -0
  38. package/src/core/browser-clip-provenance.ts +139 -0
  39. package/src/core/browser-clip.ts +473 -0
  40. package/src/core/capture-write.ts +5 -0
  41. package/src/core/capture.ts +75 -18
  42. package/src/core/config-mutation.ts +94 -64
  43. package/src/core/file-lock.ts +89 -36
  44. package/src/core/folder-setup-planning.ts +453 -0
  45. package/src/core/folder-setup.ts +490 -0
  46. package/src/core/setup-activation.ts +309 -0
  47. package/src/core/setup-receipt.ts +321 -0
  48. package/src/serve/capture-service.ts +420 -0
  49. package/src/serve/clipper-body.ts +62 -0
  50. package/src/serve/clipper-capture.ts +248 -0
  51. package/src/serve/clipper-contract.ts +57 -0
  52. package/src/serve/clipper-idempotency.ts +35 -0
  53. package/src/serve/clipper-pairing.ts +297 -0
  54. package/src/serve/clipper-security-errors.ts +23 -0
  55. package/src/serve/clipper-security.ts +449 -0
  56. package/src/serve/connectors.ts +29 -2
  57. package/src/serve/public/app.tsx +8 -1
  58. package/src/serve/public/globals.built.css +1 -1
  59. package/src/serve/public/index.html +1 -0
  60. package/src/serve/public/lib/clipper-approval.ts +206 -0
  61. package/src/serve/public/pages/ClipperPairing.tsx +210 -0
  62. package/src/serve/routes/api.ts +19 -115
  63. package/src/serve/routes/clipper.ts +394 -0
  64. package/src/serve/server.ts +22 -0
  65. package/src/store/migrations/020-browser-clipper-security.ts +128 -0
  66. package/src/store/migrations/index.ts +2 -0
  67. package/src/store/sqlite/clipper-store-types.ts +104 -0
  68. package/src/store/sqlite/clipper-store.ts +496 -0
@@ -0,0 +1,309 @@
1
+ import type {
2
+ ActivationVerificationCode,
3
+ ActivationVerificationReceipt,
4
+ StorePort,
5
+ StoreResult,
6
+ } from "../store/types";
7
+ import type { ConnectorVerificationCode } from "./connector-policy";
8
+
9
+ import { getConnectorVerificationRemediation } from "./connector-policy";
10
+
11
+ export const SETUP_ACTIVATION_SCHEMA_VERSION = "1.0" as const;
12
+
13
+ export type SetupConnectorInstallation = "installed" | "reused" | "failed";
14
+ export type SetupConnectorVerification =
15
+ | "passed"
16
+ | "failed"
17
+ | "skipped"
18
+ | "not_run";
19
+ export type SetupConnectorCode =
20
+ | "connector_verified"
21
+ | "connector_configuration_unavailable"
22
+ | "connector_install_failed"
23
+ | "connector_verification_failed"
24
+ | ConnectorVerificationCode;
25
+
26
+ export interface SetupConnectorDefinition {
27
+ id: string;
28
+ kind: "skill" | "mcp";
29
+ target: string;
30
+ scope: "user" | "project";
31
+ }
32
+
33
+ export interface SetupConnectorState extends SetupConnectorDefinition {
34
+ installed: boolean;
35
+ configurationError: boolean;
36
+ }
37
+
38
+ export interface SetupConnectorResult {
39
+ connectorId: string;
40
+ kind: "skill" | "mcp";
41
+ target: string;
42
+ scope: "user" | "project";
43
+ installation: SetupConnectorInstallation;
44
+ verification: SetupConnectorVerification;
45
+ code: SetupConnectorCode;
46
+ remediation: string;
47
+ receipt: ActivationVerificationReceipt | null;
48
+ }
49
+
50
+ export interface SetupConnectorComposition {
51
+ status: "completed" | "completed_with_actions";
52
+ connectors: SetupConnectorResult[];
53
+ }
54
+
55
+ export interface SetupConnectorInstallContext {
56
+ indexName: string;
57
+ configPath: string;
58
+ }
59
+
60
+ export interface SetupConnectorCompositionDeps {
61
+ getStates: () => Promise<SetupConnectorState[]>;
62
+ install: (
63
+ connectorId: string,
64
+ context: SetupConnectorInstallContext
65
+ ) => Promise<SetupConnectorState>;
66
+ verify: (
67
+ connectorId: string,
68
+ store: StorePort,
69
+ collection: string
70
+ ) => Promise<StoreResult<ActivationVerificationReceipt>>;
71
+ }
72
+
73
+ export interface ComposeSetupConnectorsOptions {
74
+ connectorIds: string[];
75
+ definitions: SetupConnectorDefinition[];
76
+ collection: string;
77
+ store: StorePort;
78
+ installContext: SetupConnectorInstallContext;
79
+ deps: SetupConnectorCompositionDeps;
80
+ }
81
+
82
+ const completedRemediation = "No action required.";
83
+ const installFailureRemediation =
84
+ "Repair the selected connector configuration or permissions, then rerun the same setup command.";
85
+ const configurationFailureRemediation =
86
+ "Repair the selected connector configuration without overwriting it, then rerun the same setup command.";
87
+ const verificationFailureRemediation =
88
+ "Retry the same setup command; if verification still fails, run gno doctor.";
89
+
90
+ function isConnectorVerificationCode(
91
+ code: ActivationVerificationCode | undefined
92
+ ): code is ConnectorVerificationCode {
93
+ return (
94
+ code === "connector_not_configured" ||
95
+ code === "connector_probe_unavailable" ||
96
+ code === "connector_unsupported_config" ||
97
+ code === "connector_start_failed" ||
98
+ code === "connector_timeout" ||
99
+ code === "connector_missing_tools" ||
100
+ code === "connector_status_failed" ||
101
+ code === "connector_search_failed" ||
102
+ code === "connector_result_mismatch" ||
103
+ code === "target_runtime_unverifiable"
104
+ );
105
+ }
106
+
107
+ function dedupeConnectorIds(connectorIds: string[]): string[] {
108
+ const seen = new Set<string>();
109
+ return connectorIds.filter((connectorId) => {
110
+ if (seen.has(connectorId)) {
111
+ return false;
112
+ }
113
+ seen.add(connectorId);
114
+ return true;
115
+ });
116
+ }
117
+
118
+ function failedResult(
119
+ definition: SetupConnectorDefinition,
120
+ input: {
121
+ installation: SetupConnectorInstallation;
122
+ code:
123
+ | "connector_configuration_unavailable"
124
+ | "connector_install_failed"
125
+ | "connector_verification_failed";
126
+ remediation: string;
127
+ }
128
+ ): SetupConnectorResult {
129
+ return {
130
+ connectorId: definition.id,
131
+ kind: definition.kind,
132
+ target: definition.target,
133
+ scope: definition.scope,
134
+ installation: input.installation,
135
+ verification:
136
+ input.code === "connector_verification_failed" ? "failed" : "not_run",
137
+ code: input.code,
138
+ remediation: input.remediation,
139
+ receipt: null,
140
+ };
141
+ }
142
+
143
+ function receiptResult(
144
+ definition: SetupConnectorDefinition,
145
+ installation: SetupConnectorInstallation,
146
+ receipt: ActivationVerificationReceipt
147
+ ): SetupConnectorResult {
148
+ const connectorStage = receipt.stages.connector;
149
+ const verification =
150
+ connectorStage.status === "passed" ||
151
+ connectorStage.status === "failed" ||
152
+ connectorStage.status === "skipped"
153
+ ? connectorStage.status
154
+ : "failed";
155
+ const code =
156
+ connectorStage.status === "passed"
157
+ ? "connector_verified"
158
+ : isConnectorVerificationCode(connectorStage.code)
159
+ ? connectorStage.code
160
+ : "connector_verification_failed";
161
+ const remediation =
162
+ code === "connector_verified"
163
+ ? completedRemediation
164
+ : code === "connector_verification_failed"
165
+ ? verificationFailureRemediation
166
+ : getConnectorVerificationRemediation(code, definition.target);
167
+ return {
168
+ connectorId: definition.id,
169
+ kind: definition.kind,
170
+ target: definition.target,
171
+ scope: definition.scope,
172
+ installation,
173
+ verification,
174
+ code,
175
+ remediation,
176
+ receipt,
177
+ };
178
+ }
179
+
180
+ export function unavailableSetupConnectorComposition(
181
+ definitions: SetupConnectorDefinition[]
182
+ ): SetupConnectorComposition {
183
+ return {
184
+ status: "completed_with_actions",
185
+ connectors: definitions.map((definition) => ({
186
+ connectorId: definition.id,
187
+ kind: definition.kind,
188
+ target: definition.target,
189
+ scope: definition.scope,
190
+ installation: "failed",
191
+ verification: "not_run",
192
+ code: "connector_verification_failed",
193
+ remediation: verificationFailureRemediation,
194
+ receipt: null,
195
+ })),
196
+ };
197
+ }
198
+
199
+ async function currentStateAfterInstallFailure(
200
+ connectorId: string,
201
+ deps: SetupConnectorCompositionDeps
202
+ ): Promise<SetupConnectorState | null> {
203
+ try {
204
+ const states = await deps.getStates();
205
+ return states.find(({ id }) => id === connectorId) ?? null;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ async function composeOneConnector(
212
+ definition: SetupConnectorDefinition,
213
+ initialState: SetupConnectorState | null,
214
+ options: ComposeSetupConnectorsOptions
215
+ ): Promise<SetupConnectorResult> {
216
+ if (!initialState || initialState.configurationError) {
217
+ return failedResult(definition, {
218
+ installation: "failed",
219
+ code: "connector_configuration_unavailable",
220
+ remediation: configurationFailureRemediation,
221
+ });
222
+ }
223
+
224
+ let installation: SetupConnectorInstallation = "reused";
225
+ if (!initialState.installed) {
226
+ try {
227
+ const installed = await options.deps.install(
228
+ definition.id,
229
+ options.installContext
230
+ );
231
+ if (!installed.installed || installed.configurationError) {
232
+ return failedResult(definition, {
233
+ installation: "failed",
234
+ code: "connector_install_failed",
235
+ remediation: installFailureRemediation,
236
+ });
237
+ }
238
+ installation = "installed";
239
+ } catch {
240
+ const racedState = await currentStateAfterInstallFailure(
241
+ definition.id,
242
+ options.deps
243
+ );
244
+ if (!racedState?.installed || racedState.configurationError) {
245
+ return failedResult(definition, {
246
+ installation: "failed",
247
+ code: "connector_install_failed",
248
+ remediation: installFailureRemediation,
249
+ });
250
+ }
251
+ installation = "reused";
252
+ }
253
+ }
254
+
255
+ try {
256
+ const verification = await options.deps.verify(
257
+ definition.id,
258
+ options.store,
259
+ options.collection
260
+ );
261
+ if (!verification.ok) {
262
+ return failedResult(definition, {
263
+ installation,
264
+ code: "connector_verification_failed",
265
+ remediation: verificationFailureRemediation,
266
+ });
267
+ }
268
+ return receiptResult(definition, installation, verification.value);
269
+ } catch {
270
+ return failedResult(definition, {
271
+ installation,
272
+ code: "connector_verification_failed",
273
+ remediation: verificationFailureRemediation,
274
+ });
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Compose requested connector install/verification after lexical setup.
280
+ * Callers own store lifecycle and validation of connector IDs.
281
+ */
282
+ export async function composeSetupConnectors(
283
+ options: ComposeSetupConnectorsOptions
284
+ ): Promise<SetupConnectorComposition> {
285
+ const requestedIds = dedupeConnectorIds(options.connectorIds);
286
+ let states: SetupConnectorState[] = [];
287
+ try {
288
+ states = await options.deps.getStates();
289
+ } catch {
290
+ // Per-target bounded results below deliberately hide the unbounded error.
291
+ }
292
+
293
+ const results: SetupConnectorResult[] = [];
294
+ for (const connectorId of requestedIds) {
295
+ const definition = options.definitions.find(({ id }) => id === connectorId);
296
+ if (!definition) {
297
+ continue;
298
+ }
299
+ const state = states.find(({ id }) => id === connectorId) ?? null;
300
+ results.push(await composeOneConnector(definition, state, options));
301
+ }
302
+
303
+ return {
304
+ status: results.every(({ verification }) => verification === "passed")
305
+ ? "completed"
306
+ : "completed_with_actions",
307
+ connectors: results,
308
+ };
309
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Canonical, privacy-bounded receipts for resumable folder setup.
3
+ *
4
+ * @module src/core/setup-receipt
5
+ */
6
+
7
+ // node:fs/promises provides private file creation, directory permissions, and atomic rename APIs that Bun does not expose.
8
+ import { chmod, mkdir, open, rename, unlink } from "node:fs/promises";
9
+ // node:path has no Bun equivalent.
10
+ import { dirname, join } from "node:path";
11
+
12
+ import type { ActivationVerificationReceipt } from "../store/types";
13
+
14
+ import { canonicalizeIndexName } from "../app/index-name";
15
+
16
+ export const SETUP_RECEIPT_SCHEMA_VERSION = "1.0" as const;
17
+
18
+ export const SETUP_STAGE_NAMES = [
19
+ "preflight",
20
+ "config_saved",
21
+ "store_synced",
22
+ "lexical_indexed",
23
+ "lexical_proved",
24
+ "completed",
25
+ ] as const;
26
+
27
+ export type SetupStageName = (typeof SETUP_STAGE_NAMES)[number];
28
+ export type SetupStageStatus = "pending" | "in_progress" | "passed" | "failed";
29
+
30
+ export interface SetupStageReceipt {
31
+ status: SetupStageStatus;
32
+ token: string | null;
33
+ startedAt: string | null;
34
+ completedAt: string | null;
35
+ code: string | null;
36
+ remediation: string | null;
37
+ }
38
+
39
+ export interface SetupFailure {
40
+ stage: SetupStageName;
41
+ code: string;
42
+ message: string;
43
+ remediation: string;
44
+ }
45
+
46
+ export interface FolderSetupReceipt {
47
+ schemaVersion: typeof SETUP_RECEIPT_SCHEMA_VERSION;
48
+ status: "in_progress" | "failed" | "completed";
49
+ generatedAt: string;
50
+ input: {
51
+ folder: string;
52
+ folderFingerprint: string;
53
+ indexName: string;
54
+ requestedName: string | null;
55
+ excludes: string[];
56
+ secretRiskAuthorized: boolean;
57
+ };
58
+ fingerprints: {
59
+ input: string;
60
+ config: string | null;
61
+ index: string | null;
62
+ };
63
+ collection: {
64
+ name: string | null;
65
+ path: string;
66
+ disposition: "pending" | "created" | "reused";
67
+ };
68
+ paths: {
69
+ config: string;
70
+ receipt: string;
71
+ };
72
+ stages: Record<SetupStageName, SetupStageReceipt>;
73
+ pending: string[];
74
+ failure: SetupFailure | null;
75
+ activation: ActivationVerificationReceipt | null;
76
+ }
77
+
78
+ type CanonicalJson =
79
+ | boolean
80
+ | null
81
+ | number
82
+ | string
83
+ | CanonicalJson[]
84
+ | { [key: string]: CanonicalJson };
85
+
86
+ function canonicalize(value: unknown): CanonicalJson {
87
+ if (
88
+ value === null ||
89
+ typeof value === "boolean" ||
90
+ typeof value === "number" ||
91
+ typeof value === "string"
92
+ ) {
93
+ return value;
94
+ }
95
+ if (Array.isArray(value)) {
96
+ return value.map((item) => canonicalize(item));
97
+ }
98
+ if (typeof value === "object") {
99
+ const output: Record<string, CanonicalJson> = {};
100
+ for (const key of Object.keys(value).sort()) {
101
+ const item = (value as Record<string, unknown>)[key];
102
+ if (item !== undefined) {
103
+ output[key] = canonicalize(item);
104
+ }
105
+ }
106
+ return output;
107
+ }
108
+ throw new TypeError(`Unsupported canonical JSON value: ${typeof value}`);
109
+ }
110
+
111
+ export function serializeSetupReceipt(receipt: FolderSetupReceipt): string {
112
+ return `${JSON.stringify(canonicalize(receipt), null, 2)}\n`;
113
+ }
114
+
115
+ export function setupFingerprint(value: unknown): string {
116
+ return new Bun.CryptoHasher("sha256")
117
+ .update(JSON.stringify(canonicalize(value)))
118
+ .digest("hex");
119
+ }
120
+
121
+ export function setupRootFingerprint(folderRealpath: string): string {
122
+ return new Bun.CryptoHasher("sha256").update(folderRealpath).digest("hex");
123
+ }
124
+
125
+ export function getSetupReceiptPath(input: {
126
+ dataDir: string;
127
+ indexName: string;
128
+ folderRealpath: string;
129
+ }): string {
130
+ const indexIdentity = canonicalizeIndexName(input.indexName);
131
+ const rootFingerprint = setupRootFingerprint(input.folderRealpath);
132
+ return join(
133
+ input.dataDir,
134
+ "setup-receipts",
135
+ indexIdentity,
136
+ `${rootFingerprint}.json`
137
+ );
138
+ }
139
+
140
+ export function createSetupReceipt(input: {
141
+ now: string;
142
+ folder: string;
143
+ indexName: string;
144
+ requestedName?: string;
145
+ excludes: string[];
146
+ secretRiskAuthorized: boolean;
147
+ configPath: string;
148
+ dataDir: string;
149
+ }): FolderSetupReceipt {
150
+ const indexName = canonicalizeIndexName(input.indexName);
151
+ const folderFingerprint = setupRootFingerprint(input.folder);
152
+ const receiptPath = getSetupReceiptPath({
153
+ dataDir: input.dataDir,
154
+ indexName,
155
+ folderRealpath: input.folder,
156
+ });
157
+ const stages = Object.fromEntries(
158
+ SETUP_STAGE_NAMES.map((stage) => [
159
+ stage,
160
+ {
161
+ status: "pending",
162
+ token: null,
163
+ startedAt: null,
164
+ completedAt: null,
165
+ code: null,
166
+ remediation: null,
167
+ },
168
+ ])
169
+ ) as Record<SetupStageName, SetupStageReceipt>;
170
+ const requestedName = input.requestedName?.trim().toLowerCase() || null;
171
+
172
+ return {
173
+ schemaVersion: SETUP_RECEIPT_SCHEMA_VERSION,
174
+ status: "in_progress",
175
+ generatedAt: input.now,
176
+ input: {
177
+ folder: input.folder,
178
+ folderFingerprint,
179
+ indexName,
180
+ requestedName,
181
+ excludes: [...new Set(input.excludes)].sort(),
182
+ secretRiskAuthorized: input.secretRiskAuthorized,
183
+ },
184
+ fingerprints: {
185
+ input: setupFingerprint({
186
+ folder: input.folder,
187
+ indexName,
188
+ requestedName,
189
+ excludes: [...new Set(input.excludes)].sort(),
190
+ secretRiskAuthorized: input.secretRiskAuthorized,
191
+ }),
192
+ config: null,
193
+ index: null,
194
+ },
195
+ collection: {
196
+ name: null,
197
+ path: input.folder,
198
+ disposition: "pending",
199
+ },
200
+ paths: {
201
+ config: input.configPath,
202
+ receipt: receiptPath,
203
+ },
204
+ stages,
205
+ pending: [],
206
+ failure: null,
207
+ activation: null,
208
+ };
209
+ }
210
+
211
+ export function startSetupStage(
212
+ receipt: FolderSetupReceipt,
213
+ stage: SetupStageName,
214
+ now: string
215
+ ): void {
216
+ receipt.status = "in_progress";
217
+ receipt.generatedAt = now;
218
+ receipt.failure = null;
219
+ receipt.stages[stage] = {
220
+ status: "in_progress",
221
+ token: setupFingerprint({
222
+ receipt: receipt.input.folderFingerprint,
223
+ stage,
224
+ startedAt: now,
225
+ }),
226
+ startedAt: now,
227
+ completedAt: null,
228
+ code: null,
229
+ remediation: null,
230
+ };
231
+ }
232
+
233
+ export function passSetupStage(
234
+ receipt: FolderSetupReceipt,
235
+ stage: SetupStageName,
236
+ now: string
237
+ ): void {
238
+ receipt.generatedAt = now;
239
+ receipt.stages[stage] = {
240
+ ...receipt.stages[stage],
241
+ status: "passed",
242
+ completedAt: now,
243
+ code: null,
244
+ remediation: null,
245
+ };
246
+ }
247
+
248
+ export function failSetupStage(
249
+ receipt: FolderSetupReceipt,
250
+ failure: SetupFailure,
251
+ now: string
252
+ ): void {
253
+ const current = receipt.stages[failure.stage];
254
+ receipt.status = "failed";
255
+ receipt.generatedAt = now;
256
+ receipt.failure = failure;
257
+ if (current.status !== "passed") {
258
+ receipt.stages[failure.stage] = {
259
+ ...current,
260
+ status: "failed",
261
+ token:
262
+ current.token ??
263
+ setupFingerprint({
264
+ receipt: receipt.input.folderFingerprint,
265
+ stage: failure.stage,
266
+ startedAt: now,
267
+ }),
268
+ startedAt: current.startedAt ?? now,
269
+ completedAt: now,
270
+ code: failure.code,
271
+ remediation: failure.remediation,
272
+ };
273
+ }
274
+ }
275
+
276
+ export async function persistSetupReceipt(
277
+ receipt: FolderSetupReceipt
278
+ ): Promise<void> {
279
+ const receiptDir = dirname(receipt.paths.receipt);
280
+ await mkdir(receiptDir, { recursive: true, mode: 0o700 });
281
+ await chmod(receiptDir, 0o700);
282
+
283
+ const tempPath = `${receipt.paths.receipt}.tmp.${crypto.randomUUID()}`;
284
+ let tempFile: Awaited<ReturnType<typeof open>> | null = null;
285
+ try {
286
+ tempFile = await open(tempPath, "wx", 0o600);
287
+ await tempFile.writeFile(serializeSetupReceipt(receipt), "utf8");
288
+ await tempFile.sync();
289
+ await tempFile.close();
290
+ tempFile = null;
291
+ await rename(tempPath, receipt.paths.receipt);
292
+ await chmod(receipt.paths.receipt, 0o600);
293
+ } catch (error) {
294
+ await tempFile?.close().catch(() => {
295
+ /* best-effort temporary receipt handle cleanup */
296
+ });
297
+ await unlink(tempPath).catch(() => {
298
+ /* best-effort temporary receipt cleanup */
299
+ });
300
+ throw error;
301
+ }
302
+ }
303
+
304
+ export async function loadSetupReceipt(
305
+ path: string
306
+ ): Promise<FolderSetupReceipt | null> {
307
+ const file = Bun.file(path);
308
+ if (!(await file.exists())) {
309
+ return null;
310
+ }
311
+ const value: unknown = await file.json();
312
+ if (
313
+ typeof value !== "object" ||
314
+ value === null ||
315
+ !("schemaVersion" in value) ||
316
+ value.schemaVersion !== SETUP_RECEIPT_SCHEMA_VERSION
317
+ ) {
318
+ return null;
319
+ }
320
+ return value as FolderSetupReceipt;
321
+ }