@mcpherson-ai/observa-local-node 0.1.3

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 (34) hide show
  1. package/README.md +103 -0
  2. package/SHA256SUMS +33 -0
  3. package/artifacts/mcphersonai-observa-adapter-n8n-0.1.0.tgz +0 -0
  4. package/artifacts/mcphersonai-observa-domain-generic-0.1.0.tgz +0 -0
  5. package/artifacts/mcphersonai-observa-hosted-transport-0.1.0.tgz +0 -0
  6. package/artifacts/mcphersonai-observa-n8n-h1-binding-0.1.0.tgz +0 -0
  7. package/artifacts/mcphersonai-observa-node-0.1.0.tgz +0 -0
  8. package/distribution/observa-cli/bin/observa-n8n-hosted.mjs +114 -0
  9. package/distribution/observa-cli/bin/observa.mjs +331 -0
  10. package/distribution/observa-cli/integrations/n8n/observa-external-hook.cjs +132 -0
  11. package/distribution/observa-cli/keys/observa-beta-1.public.json +7 -0
  12. package/distribution/observa-cli/src/artifact.mjs +143 -0
  13. package/distribution/observa-cli/src/config.mjs +162 -0
  14. package/distribution/observa-cli/src/errors.mjs +21 -0
  15. package/distribution/observa-cli/src/hosted-delivery.mjs +148 -0
  16. package/distribution/observa-cli/src/index.mjs +39 -0
  17. package/distribution/observa-cli/src/install.mjs +443 -0
  18. package/distribution/observa-cli/src/lock.mjs +57 -0
  19. package/distribution/observa-cli/src/manifest-schema.mjs +208 -0
  20. package/distribution/observa-cli/src/manifest-verify.mjs +122 -0
  21. package/distribution/observa-cli/src/n8n-hook.mjs +70 -0
  22. package/distribution/observa-cli/src/pair.mjs +149 -0
  23. package/distribution/observa-cli/src/re-pair.mjs +171 -0
  24. package/distribution/observa-cli/src/service.mjs +218 -0
  25. package/distribution/observa-cli/src/state.mjs +87 -0
  26. package/distribution/observa-cli/src/status.mjs +191 -0
  27. package/distribution/observa-cli/src/vocabulary.mjs +47 -0
  28. package/npm-distribution-provenance.json +1 -0
  29. package/package.json +36 -0
  30. package/runtime-adapters/n8n/src/strict-json.mjs +138 -0
  31. package/sdk/contracts/canonical.mjs +289 -0
  32. package/sdk/contracts/entry-boundary.mjs +417 -0
  33. package/sdk/contracts/errors.mjs +334 -0
  34. package/sdk/contracts/stable-primitives.mjs +141 -0
@@ -0,0 +1,443 @@
1
+ // The installation transaction.
2
+ //
3
+ // verify manifest signature → validate tenant/install identity →
4
+ // resolve exact artifacts → verify digests → safe staging →
5
+ // validate contents → preflight → atomic activation
6
+ //
7
+ // Nothing becomes current until everything passed; a failure at any step
8
+ // removes the staging directory and leaves the previous installation —
9
+ // including a completely absent one — byte-for-byte untouched. Activation is
10
+ // one symlink rename. The previous known-good manifest, artifacts and release
11
+ // tree are RETAINED for rollback, and rollback re-verifies them rather than
12
+ // trusting that nothing touched them in the meantime.
13
+
14
+ import { execFileSync } from "node:child_process";
15
+ import {
16
+ copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync,
17
+ renameSync, rmSync, symlinkSync, writeFileSync,
18
+ } from "node:fs";
19
+ import { join, resolve } from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+ import { canonicalDigest } from "../../../sdk/contracts/canonical.mjs";
22
+ import { parseStrictJson } from "../../../runtime-adapters/n8n/src/strict-json.mjs";
23
+ import { refuseCli } from "./errors.mjs";
24
+ import {
25
+ contentDigestOfEntries, extractEntries, readArtifactEntries,
26
+ sha256HexOf, wrapperManifestOf,
27
+ } from "./artifact.mjs";
28
+ import { compareExactVersions } from "./manifest-schema.mjs";
29
+ import { findVerifiedRevocation, verifySignedManifestText } from "./manifest-verify.mjs";
30
+ import { acquireLock } from "./lock.mjs";
31
+ import { readState, writeState } from "./state.mjs";
32
+ import { CLI_VERSION, RELEASE_RECORD_SCHEMA_ID } from "./vocabulary.mjs";
33
+ import { serviceIsRunning, stopService } from "./service.mjs";
34
+
35
+ export function homePaths(home) {
36
+ const root = resolve(home);
37
+ return Object.freeze({
38
+ root,
39
+ manifestsDir: join(root, "manifests"),
40
+ revocationsDir: join(root, "manifests", "revocations"),
41
+ artifactsDir: join(root, "artifacts"),
42
+ releasesDir: join(root, "releases"),
43
+ currentLink: join(root, "current"),
44
+ serviceDir: join(root, "service"),
45
+ });
46
+ }
47
+
48
+ export function releaseDirFor(home, manifestId) {
49
+ return join(homePaths(home).releasesDir, manifestId);
50
+ }
51
+
52
+ function readSignedManifestFile(path) {
53
+ let text;
54
+ try {
55
+ text = readFileSync(path, "utf8");
56
+ } catch {
57
+ refuseCli("MANIFEST_FILE_UNAVAILABLE");
58
+ }
59
+ return text;
60
+ }
61
+
62
+ /**
63
+ * Load the state AND the stored current signed manifest, re-verify the
64
+ * signature, and cross-check identity and mode. The manifest, not the state
65
+ * file, is the authority — divergence refuses.
66
+ */
67
+ export function loadCurrentInstallation(home, { trustedKeys }) {
68
+ const state = readState(home);
69
+ if (state === null) return null;
70
+ const paths = homePaths(home);
71
+ const manifestPath = join(paths.manifestsDir, `${state.current_manifest_id}.signed.json`);
72
+ const text = readSignedManifestFile(manifestPath);
73
+ if (sha256HexOf(Buffer.from(text, "utf8")) !== state.current_manifest_sha256) {
74
+ refuseCli("STORED_MANIFEST_DIVERGED_FROM_STATE");
75
+ }
76
+ const manifest = verifySignedManifestText(text, { trustedKeys });
77
+ if (manifest.manifest_id !== state.current_manifest_id
78
+ || manifest.organization_id !== state.organization_id
79
+ || manifest.workspace_id !== state.workspace_id
80
+ || manifest.installation_id !== state.installation_id
81
+ || manifest.mode !== state.mode) {
82
+ refuseCli("STATE_DIVERGED_FROM_SIGNED_MANIFEST");
83
+ }
84
+ return { state, manifest, manifestText: text };
85
+ }
86
+
87
+ function assertNotRevoked(home, { trustedKeys, manifest }) {
88
+ const paths = homePaths(home);
89
+ const revocation = findVerifiedRevocation({
90
+ revocationDir: paths.revocationsDir,
91
+ trustedKeys,
92
+ organizationId: manifest.organization_id,
93
+ workspaceId: manifest.workspace_id,
94
+ installationId: manifest.installation_id,
95
+ });
96
+ if (revocation !== null) refuseCli("INSTALLATION_REVOKED", revocation.reason_code);
97
+ }
98
+
99
+ function resolveArtifacts({ manifest, artifactDir, paths }) {
100
+ mkdirSync(paths.artifactsDir, { recursive: true });
101
+ const resolved = [];
102
+ for (const component of manifest.components) {
103
+ const cached = join(paths.artifactsDir, `${component.artifact_sha256}.tgz`);
104
+ let bytes = null;
105
+ if (existsSync(cached)) {
106
+ bytes = readFileSync(cached);
107
+ if (sha256HexOf(bytes) !== component.artifact_sha256) {
108
+ refuseCli("ARTIFACT_CACHE_TAMPERED", component.component_id);
109
+ }
110
+ } else {
111
+ if (typeof artifactDir !== "string") refuseCli("ARTIFACT_SOURCE_REQUIRED", component.component_id);
112
+ const source = join(artifactDir, component.artifact_filename);
113
+ try {
114
+ bytes = readFileSync(source);
115
+ } catch {
116
+ refuseCli("ARTIFACT_UNAVAILABLE", component.component_id);
117
+ }
118
+ // The digest, not the filename, is the identity: a swapped file under
119
+ // the right name fails here before anything is unpacked.
120
+ if (sha256HexOf(bytes) !== component.artifact_sha256) {
121
+ refuseCli("ARTIFACT_DIGEST_MISMATCH", component.component_id);
122
+ }
123
+ copyFileSync(source, cached);
124
+ }
125
+ resolved.push({ component, bytes });
126
+ }
127
+ return resolved;
128
+ }
129
+
130
+ function stageRelease({ manifest, resolved, paths }) {
131
+ mkdirSync(paths.releasesDir, { recursive: true });
132
+ const staging = join(paths.releasesDir, `.staging-${manifest.manifest_id}-${process.pid}`);
133
+ rmSync(staging, { recursive: true, force: true });
134
+ mkdirSync(staging, { recursive: true });
135
+ const libRoot = join(staging, "lib");
136
+ const componentsRecord = [];
137
+ try {
138
+ for (const { component, bytes } of resolved) {
139
+ const entries = readArtifactEntries(bytes);
140
+ if (contentDigestOfEntries(entries) !== component.content_digest) {
141
+ refuseCli("ARTIFACT_CONTENT_DIGEST_MISMATCH", component.component_id);
142
+ }
143
+ const wrapper = wrapperManifestOf(entries);
144
+ if (wrapper.name !== component.package_name || wrapper.version !== component.package_version) {
145
+ refuseCli("ARTIFACT_IDENTITY_MISMATCH", component.component_id);
146
+ }
147
+ const entryPath = wrapper.exports?.["."];
148
+ if (typeof entryPath !== "string" || !entryPath.startsWith("./")) {
149
+ refuseCli("ARTIFACT_ENTRY_UNDECLARED", component.component_id);
150
+ }
151
+ const installed = extractEntries({ entries, destinationRoot: libRoot });
152
+ componentsRecord.push({
153
+ component_id: component.component_id,
154
+ package_name: component.package_name,
155
+ package_version: component.package_version,
156
+ artifact_sha256: component.artifact_sha256,
157
+ content_digest: component.content_digest,
158
+ entry: entryPath,
159
+ files: installed,
160
+ });
161
+ }
162
+ const record = {
163
+ schema: RELEASE_RECORD_SCHEMA_ID,
164
+ schema_version: 1,
165
+ manifest_id: manifest.manifest_id,
166
+ components: componentsRecord,
167
+ };
168
+ writeFileSync(join(staging, "release-record.json"), `${JSON.stringify({
169
+ ...record,
170
+ record_digest: canonicalDigest(record),
171
+ }, null, 2)}\n`, { mode: 0o600 });
172
+ return { staging, libRoot, componentsRecord };
173
+ } catch (error) {
174
+ rmSync(staging, { recursive: true, force: true });
175
+ throw error;
176
+ }
177
+ }
178
+
179
+ function preflight({ staging, libRoot, componentsRecord }) {
180
+ const [major] = process.versions.node.split(".").map(Number);
181
+ if (!(major >= 22)) refuseCli("PREFLIGHT_NODE_VERSION_UNSUPPORTED");
182
+ for (const component of componentsRecord) {
183
+ const entryFile = resolve(join(libRoot, component.entry));
184
+ if (!entryFile.startsWith(`${resolve(libRoot)}/`)) refuseCli("PREFLIGHT_ENTRY_ESCAPES_RELEASE");
185
+ if (!existsSync(entryFile)) refuseCli("PREFLIGHT_ENTRY_ABSENT", component.component_id);
186
+ try {
187
+ execFileSync(process.execPath, [
188
+ "--input-type=module", "-e",
189
+ `await import(${JSON.stringify(pathToFileURL(entryFile).href)});`,
190
+ ], { stdio: ["ignore", "ignore", "pipe"], timeout: 30000 });
191
+ } catch (error) {
192
+ refuseCli("PREFLIGHT_ENTRY_IMPORT_FAILED",
193
+ `${component.component_id}: ${String(error.stderr ?? "").slice(0, 200)}`);
194
+ }
195
+ }
196
+ const adapterBin = join(libRoot, "runtime-adapters", "n8n", "bin", "observa-n8n.mjs");
197
+ if (existsSync(adapterBin)) {
198
+ // The sealed adapter's own self-verification (no-authority scan, source
199
+ // integrity, provenance self-check) runs over the INSTALLED bytes. A
200
+ // clean environment is passed so the ledger check reports SKIP instead
201
+ // of touching any live configuration.
202
+ try {
203
+ execFileSync(process.execPath, [adapterBin, "verify"], {
204
+ cwd: staging,
205
+ env: { PATH: process.env.PATH },
206
+ stdio: ["ignore", "pipe", "pipe"],
207
+ timeout: 60000,
208
+ });
209
+ } catch {
210
+ refuseCli("PREFLIGHT_ADAPTER_VERIFY_FAILED");
211
+ }
212
+ }
213
+ }
214
+
215
+ function activate({ home, manifest, manifestText, staging, previousManifestId, now }) {
216
+ const paths = homePaths(home);
217
+ const releaseDir = releaseDirFor(home, manifest.manifest_id);
218
+ rmSync(releaseDir, { recursive: true, force: true });
219
+ renameSync(staging, releaseDir);
220
+ mkdirSync(paths.manifestsDir, { recursive: true });
221
+ const manifestPath = join(paths.manifestsDir, `${manifest.manifest_id}.signed.json`);
222
+ writeFileSync(manifestPath, manifestText, { mode: 0o600 });
223
+ const linkTmp = `${paths.currentLink}.tmp-${process.pid}`;
224
+ rmSync(linkTmp, { force: true });
225
+ symlinkSync(join("releases", manifest.manifest_id), linkTmp);
226
+ renameSync(linkTmp, paths.currentLink);
227
+ return writeState(home, {
228
+ cli_version: CLI_VERSION,
229
+ organization_id: manifest.organization_id,
230
+ workspace_id: manifest.workspace_id,
231
+ installation_id: manifest.installation_id,
232
+ current_manifest_id: manifest.manifest_id,
233
+ current_manifest_sha256: sha256HexOf(Buffer.from(manifestText, "utf8")),
234
+ previous_manifest_id: previousManifestId,
235
+ mode: manifest.mode,
236
+ installed_at: now().toISOString(),
237
+ });
238
+ }
239
+
240
+ function cleanStaleStaging(paths) {
241
+ let names = [];
242
+ try {
243
+ names = readdirSync(paths.releasesDir).filter((n) => n.startsWith(".staging-"));
244
+ } catch {
245
+ return;
246
+ }
247
+ for (const name of names) rmSync(join(paths.releasesDir, name), { recursive: true, force: true });
248
+ }
249
+
250
+ /**
251
+ * Install, reinstall or update from a signed manifest file.
252
+ * `expectedInstallationId` (optional) binds a FIRST install to a known
253
+ * identity — founder-assisted pairing writes it ahead of time.
254
+ */
255
+ export function installFromManifestFile({
256
+ home, manifestFile, artifactDir, trustedKeys,
257
+ expectedInstallationId = null, now = () => new Date(),
258
+ }) {
259
+ const paths = homePaths(home);
260
+ mkdirSync(paths.root, { recursive: true, mode: 0o700 });
261
+ const lock = acquireLock(paths.root, { operation: "install" });
262
+ try {
263
+ cleanStaleStaging(paths);
264
+ const manifestText = readSignedManifestFile(manifestFile);
265
+ const manifest = verifySignedManifestText(manifestText, { trustedKeys });
266
+ if (compareExactVersions(CLI_VERSION, manifest.min_cli_version) < 0) {
267
+ refuseCli("CLI_VERSION_BELOW_MANIFEST_MINIMUM");
268
+ }
269
+ assertNotRevoked(paths.root, { trustedKeys, manifest });
270
+ const current = loadCurrentInstallation(paths.root, { trustedKeys });
271
+ let previousManifestId = null;
272
+ let transition = "install";
273
+ if (current !== null) {
274
+ // Tenant/install binding: a manifest for any other organization,
275
+ // workspace or installation refuses — the manifest names ITS
276
+ // installation, it does not get adopted by whoever reads it.
277
+ if (manifest.organization_id !== current.state.organization_id
278
+ || manifest.workspace_id !== current.state.workspace_id
279
+ || manifest.installation_id !== current.state.installation_id) {
280
+ refuseCli("MANIFEST_TENANT_BINDING_MISMATCH");
281
+ }
282
+ if (manifest.manifest_id === current.state.current_manifest_id) {
283
+ transition = "reinstall";
284
+ previousManifestId = current.state.previous_manifest_id;
285
+ } else {
286
+ transition = "update";
287
+ // Updates only move FORWARD along signed issuance order. Moving back
288
+ // is the rollback lane, which targets the retained previous manifest
289
+ // explicitly — a re-presented older manifest is not an update.
290
+ if (manifest.issued_seq <= current.manifest.issued_seq) {
291
+ refuseCli("MANIFEST_NOT_NEWER_THAN_CURRENT");
292
+ }
293
+ previousManifestId = current.state.current_manifest_id;
294
+ }
295
+ } else if (expectedInstallationId !== null
296
+ && manifest.installation_id !== expectedInstallationId) {
297
+ refuseCli("MANIFEST_INSTALLATION_BINDING_MISMATCH");
298
+ }
299
+ const resolved = resolveArtifacts({ manifest, artifactDir, paths });
300
+ const { staging, libRoot, componentsRecord } = stageRelease({ manifest, resolved, paths });
301
+ try {
302
+ preflight({ staging, libRoot, componentsRecord });
303
+ } catch (error) {
304
+ rmSync(staging, { recursive: true, force: true });
305
+ throw error;
306
+ }
307
+ const state = activate({
308
+ home: paths.root, manifest, manifestText, staging, previousManifestId, now,
309
+ });
310
+ pruneReleases(paths, state);
311
+ return { transition, state, manifest };
312
+ } finally {
313
+ lock.release();
314
+ }
315
+ }
316
+
317
+ function pruneReleases(paths, state) {
318
+ const keep = new Set([state.current_manifest_id]);
319
+ if (state.previous_manifest_id !== null) keep.add(state.previous_manifest_id);
320
+ let names = [];
321
+ try {
322
+ names = readdirSync(paths.releasesDir);
323
+ } catch {
324
+ return;
325
+ }
326
+ for (const name of names) {
327
+ if (name.startsWith(".staging-")) continue;
328
+ if (!keep.has(name)) rmSync(join(paths.releasesDir, name), { recursive: true, force: true });
329
+ }
330
+ }
331
+
332
+ /** Re-verify installed bytes of a retained release against its record. */
333
+ export function verifyReleaseIntegrity(home, manifestId) {
334
+ const releaseDir = releaseDirFor(home, manifestId);
335
+ let record;
336
+ try {
337
+ record = parseStrictJson(readFileSync(join(releaseDir, "release-record.json"), "utf8"));
338
+ } catch {
339
+ return { ok: false, problems: ["release record unavailable"] };
340
+ }
341
+ const { record_digest: digest, ...rest } = record;
342
+ if (canonicalDigest(rest) !== digest) return { ok: false, problems: ["release record tampered"] };
343
+ if (rest.manifest_id !== manifestId) return { ok: false, problems: ["release record for wrong manifest"] };
344
+ const problems = [];
345
+ for (const component of rest.components) {
346
+ for (const file of component.files) {
347
+ const path = join(releaseDir, "lib", ...file.path.split("/"));
348
+ let bytes;
349
+ try {
350
+ bytes = readFileSync(path);
351
+ } catch {
352
+ problems.push(`${component.component_id}: ${file.path} missing`);
353
+ continue;
354
+ }
355
+ if (sha256HexOf(bytes) !== file.sha256) {
356
+ problems.push(`${component.component_id}: ${file.path} diverged`);
357
+ }
358
+ }
359
+ }
360
+ return { ok: problems.length === 0, problems, record: rest };
361
+ }
362
+
363
+ /**
364
+ * Rollback: return to the RETAINED previous manifest — no other target
365
+ * exists. Tampered retained bytes are re-staged from the verified artifact
366
+ * cache (offline), and if that cannot be proven either, rollback refuses
367
+ * rather than activating unproven bytes.
368
+ */
369
+ export function rollbackToPrevious({ home, trustedKeys, now = () => new Date() }) {
370
+ const paths = homePaths(home);
371
+ const lock = acquireLock(paths.root, { operation: "rollback" });
372
+ try {
373
+ const current = loadCurrentInstallation(paths.root, { trustedKeys });
374
+ if (current === null) refuseCli("NOT_INSTALLED");
375
+ const previousId = current.state.previous_manifest_id;
376
+ if (previousId === null) refuseCli("ROLLBACK_TARGET_ABSENT");
377
+ const manifestPath = join(paths.manifestsDir, `${previousId}.signed.json`);
378
+ const manifestText = readSignedManifestFile(manifestPath);
379
+ const manifest = verifySignedManifestText(manifestText, { trustedKeys });
380
+ if (manifest.manifest_id !== previousId
381
+ || manifest.organization_id !== current.state.organization_id
382
+ || manifest.workspace_id !== current.state.workspace_id
383
+ || manifest.installation_id !== current.state.installation_id) {
384
+ refuseCli("ROLLBACK_MANIFEST_BINDING_MISMATCH");
385
+ }
386
+ const integrity = verifyReleaseIntegrity(paths.root, previousId);
387
+ if (!integrity.ok) {
388
+ // Retained tree cannot be trusted — rebuild it from the verified
389
+ // artifact cache, fully offline.
390
+ const resolved = resolveArtifacts({ manifest, artifactDir: null, paths });
391
+ const { staging, libRoot, componentsRecord } = stageRelease({ manifest, resolved, paths });
392
+ try {
393
+ preflight({ staging, libRoot, componentsRecord });
394
+ } catch (error) {
395
+ rmSync(staging, { recursive: true, force: true });
396
+ throw error;
397
+ }
398
+ const releaseDir = releaseDirFor(paths.root, previousId);
399
+ rmSync(releaseDir, { recursive: true, force: true });
400
+ renameSync(staging, releaseDir);
401
+ }
402
+ const linkTmp = `${paths.currentLink}.tmp-${process.pid}`;
403
+ rmSync(linkTmp, { force: true });
404
+ symlinkSync(join("releases", previousId), linkTmp);
405
+ renameSync(linkTmp, paths.currentLink);
406
+ const state = writeState(paths.root, {
407
+ cli_version: CLI_VERSION,
408
+ organization_id: manifest.organization_id,
409
+ workspace_id: manifest.workspace_id,
410
+ installation_id: manifest.installation_id,
411
+ current_manifest_id: previousId,
412
+ current_manifest_sha256: sha256HexOf(Buffer.from(manifestText, "utf8")),
413
+ previous_manifest_id: null,
414
+ mode: manifest.mode,
415
+ installed_at: now().toISOString(),
416
+ });
417
+ return { state, manifest };
418
+ } finally {
419
+ lock.release();
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Uninstall Observa-managed state. Never touches n8n, Odoo, or anything
425
+ * outside the Observa home; customer configuration (their credentials)
426
+ * survives unless `purgeConfig` is EXPLICITLY requested.
427
+ */
428
+ export function uninstall({ home, purgeConfig = false }) {
429
+ const paths = homePaths(home);
430
+ const lock = acquireLock(paths.root, { operation: "uninstall" });
431
+ try {
432
+ if (serviceIsRunning(paths.root)) stopService(paths.root);
433
+ for (const dir of [paths.releasesDir, paths.artifactsDir, paths.manifestsDir, paths.serviceDir]) {
434
+ rmSync(dir, { recursive: true, force: true });
435
+ }
436
+ rmSync(paths.currentLink, { force: true });
437
+ rmSync(join(paths.root, "state.json"), { force: true });
438
+ if (purgeConfig) rmSync(join(paths.root, "config"), { recursive: true, force: true });
439
+ return { removed: true, configRetained: !purgeConfig };
440
+ } finally {
441
+ lock.release();
442
+ }
443
+ }
@@ -0,0 +1,57 @@
1
+ // Installation lock. One state-changing operation at a time, per home.
2
+ //
3
+ // `mkdir` is the atomic primitive: it either creates the lock directory or
4
+ // fails, with no read-then-write window. A lock whose owning process is gone
5
+ // is stale and may be reclaimed; a lock whose owner is alive means a
6
+ // CONCURRENT operation and refuses — corruption by two installers is a
7
+ // worse outcome than asking one of them to wait.
8
+
9
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { refuseCli } from "./errors.mjs";
12
+
13
+ export function lockDir(home) {
14
+ return join(home, "lock");
15
+ }
16
+
17
+ function ownerIsAlive(pid) {
18
+ if (!Number.isSafeInteger(pid) || pid < 1) return false;
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch (error) {
23
+ return error.code === "EPERM";
24
+ }
25
+ }
26
+
27
+ export function acquireLock(home, { operation }) {
28
+ const dir = lockDir(home);
29
+ for (let attempt = 0; attempt < 2; attempt += 1) {
30
+ try {
31
+ mkdirSync(dir, { recursive: false });
32
+ writeFileSync(join(dir, "owner.json"), `${JSON.stringify({
33
+ pid: process.pid,
34
+ operation,
35
+ })}\n`, { mode: 0o600 });
36
+ return { release: () => rmSync(dir, { recursive: true, force: true }) };
37
+ } catch (error) {
38
+ if (error.code !== "EEXIST") throw error;
39
+ let owner = null;
40
+ try {
41
+ owner = JSON.parse(readFileSync(join(dir, "owner.json"), "utf8"));
42
+ } catch {
43
+ // Lock directory without a readable owner: a crash between mkdir and
44
+ // write. Treat as stale.
45
+ }
46
+ // A live owner refuses — INCLUDING this same process: two operations
47
+ // in one process are still two operations, and a same-pid leak means
48
+ // a prior operation is genuinely still inside its critical section.
49
+ if (owner !== null && ownerIsAlive(owner.pid)) {
50
+ refuseCli("INSTALLATION_LOCKED", "another observa operation is running");
51
+ }
52
+ rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ }
55
+ refuseCli("INSTALLATION_LOCK_UNAVAILABLE");
56
+ return undefined;
57
+ }