@primitive.ai/prim 0.1.0-alpha.60 → 0.1.0-alpha.62
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/dist/chunk-DA4BMHEB.js +77 -0
- package/dist/{chunk-B4SHJYIN.js → chunk-GF5QII7T.js} +83 -110
- package/dist/{chunk-QY75BQMF.js → chunk-JQFN6LBO.js} +8 -0
- package/dist/{chunk-YIMUIPMD.js → chunk-KBFFVYBC.js} +7 -1
- package/dist/chunk-LOGW54W5.js +1244 -0
- package/dist/chunk-QGVHLP5J.js +117 -0
- package/dist/{chunk-IMAIBPUC.js → chunk-UVIWZWM6.js} +1 -0
- package/dist/chunk-XTNMSS4C.js +283 -0
- package/dist/daemon/server.js +1 -0
- package/dist/hooks/post-commit.js +82 -16
- package/dist/hooks/post-tool-use.js +41 -25
- package/dist/hooks/pre-tool-use.js +103 -11
- package/dist/hooks/prim-hook.js +30 -9
- package/dist/hooks/session-start.js +22 -5
- package/dist/index.js +730 -179
- package/dist/statusline-main.js +7 -2
- package/package.json +1 -1
- package/dist/chunk-GUXBUBQ7.js +0 -84
- package/dist/chunk-LDQZASIF.js +0 -166
- package/dist/chunk-MZQO7K2P.js +0 -616
- package/dist/chunk-YDBZTFW5.js +0 -67
package/dist/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
bold,
|
|
4
|
+
boundedHealthError,
|
|
4
5
|
color,
|
|
5
6
|
colorForArea,
|
|
6
7
|
dim,
|
|
7
8
|
requireDurableIngestAcknowledgement,
|
|
8
9
|
stripAnsi,
|
|
9
10
|
stripControlChars
|
|
10
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-JQFN6LBO.js";
|
|
11
12
|
import {
|
|
12
13
|
checkAffectedDecisions,
|
|
13
14
|
daemonOrDirectGet,
|
|
@@ -24,15 +25,22 @@ import {
|
|
|
24
25
|
} from "./chunk-S4LFRVGD.js";
|
|
25
26
|
import {
|
|
26
27
|
bindRepository,
|
|
28
|
+
compareSemver,
|
|
29
|
+
ensureEffectivePostCommitHook,
|
|
30
|
+
ensurePostCommitHookAtPath,
|
|
31
|
+
inspectEffectivePostCommitHook,
|
|
32
|
+
postCommitHookBlock,
|
|
27
33
|
printJson,
|
|
28
|
-
registerSkillCommands
|
|
29
|
-
|
|
34
|
+
registerSkillCommands,
|
|
35
|
+
uninstallPostCommitHookAtPath,
|
|
36
|
+
uninstallProjectPostCommitHook
|
|
37
|
+
} from "./chunk-LOGW54W5.js";
|
|
30
38
|
import {
|
|
31
39
|
fetchFeedbackCapability
|
|
32
40
|
} from "./chunk-R4CUX6FY.js";
|
|
33
41
|
import {
|
|
34
42
|
inspectWorkspaceId
|
|
35
|
-
} from "./chunk-
|
|
43
|
+
} from "./chunk-UVIWZWM6.js";
|
|
36
44
|
import {
|
|
37
45
|
binFile,
|
|
38
46
|
commandMatchesBin,
|
|
@@ -40,16 +48,17 @@ import {
|
|
|
40
48
|
packageVersion,
|
|
41
49
|
pinnedHookCommand,
|
|
42
50
|
warmBinCache
|
|
43
|
-
} from "./chunk-
|
|
51
|
+
} from "./chunk-KBFFVYBC.js";
|
|
44
52
|
import {
|
|
45
53
|
canonicalGitRoot,
|
|
46
54
|
canonicalRepositoryPath,
|
|
47
55
|
decisionIngestionStatus,
|
|
48
56
|
gitToplevel,
|
|
49
57
|
isRepoActiveForCapture,
|
|
58
|
+
isValidRepoSyncId,
|
|
50
59
|
repoSyncId,
|
|
51
60
|
setRepoActive
|
|
52
|
-
} from "./chunk-
|
|
61
|
+
} from "./chunk-XTNMSS4C.js";
|
|
53
62
|
import {
|
|
54
63
|
HttpError,
|
|
55
64
|
REFRESH_TOKEN_PATH,
|
|
@@ -89,17 +98,19 @@ async function applyActivation(active) {
|
|
|
89
98
|
}
|
|
90
99
|
try {
|
|
91
100
|
let bound;
|
|
101
|
+
let postCommitHook;
|
|
92
102
|
if (active) {
|
|
103
|
+
postCommitHook = ensureEffectivePostCommitHook(root).path;
|
|
93
104
|
bound = await bindRepository(root);
|
|
94
105
|
}
|
|
95
106
|
setRepoActive(root, active);
|
|
96
107
|
process.stderr.write(`[prim] prim ${active ? "enabled" : "disabled"} in ${root}
|
|
97
108
|
`);
|
|
98
|
-
printJson({ active, repo: root, ...bound });
|
|
109
|
+
printJson({ active, repo: root, ...bound, ...postCommitHook ? { postCommitHook } : {} });
|
|
99
110
|
} catch (err) {
|
|
100
111
|
const detail = err instanceof Error ? err.message : String(err);
|
|
101
112
|
process.stderr.write(
|
|
102
|
-
`[prim] failed to ${active ? "bind and enable" : "disable"} prim: ${detail}
|
|
113
|
+
`[prim] failed to ${active ? "cover, bind, and enable" : "disable"} prim: ${detail}
|
|
103
114
|
`
|
|
104
115
|
);
|
|
105
116
|
process.exit(1);
|
|
@@ -636,28 +647,58 @@ import { dirname as dirname2, join as join2 } from "path";
|
|
|
636
647
|
import { spawnSync } from "child_process";
|
|
637
648
|
import { createHash as createHash2 } from "crypto";
|
|
638
649
|
import {
|
|
650
|
+
constants,
|
|
651
|
+
accessSync,
|
|
639
652
|
chmodSync,
|
|
640
653
|
copyFileSync,
|
|
641
654
|
existsSync as existsSync2,
|
|
642
655
|
mkdirSync,
|
|
643
656
|
mkdtempSync,
|
|
644
657
|
readFileSync as readFileSync2,
|
|
658
|
+
realpathSync,
|
|
645
659
|
renameSync,
|
|
646
660
|
rmSync,
|
|
647
661
|
symlinkSync,
|
|
648
662
|
writeFileSync
|
|
649
663
|
} from "fs";
|
|
650
664
|
import { homedir } from "os";
|
|
651
|
-
import { dirname, isAbsolute, join, resolve } from "path";
|
|
665
|
+
import { basename, dirname, isAbsolute, join, resolve } from "path";
|
|
666
|
+
|
|
667
|
+
// src/daemon/env-binding.ts
|
|
668
|
+
function normalizeApiUrl(url) {
|
|
669
|
+
return url.trim().replace(/\/+$/, "");
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// src/daemon/launchd.ts
|
|
652
673
|
var LAUNCHD_LABEL = "ai.getprimitive.prim-daemon";
|
|
653
|
-
var RUNTIME_SCHEMA_VERSION =
|
|
674
|
+
var RUNTIME_SCHEMA_VERSION = 2;
|
|
654
675
|
var RUNTIME_DIR_MODE = 448;
|
|
655
676
|
var RUNTIME_FILE_MODE = 384;
|
|
656
677
|
var RUNTIME_LAUNCHER_MODE = 448;
|
|
657
678
|
var PLIST_FILE_MODE = 384;
|
|
658
|
-
var
|
|
679
|
+
var LAUNCHER_SCHEMA_VERSION = 1;
|
|
680
|
+
var TRANSITION_TIMEOUT_MS = 15e3;
|
|
681
|
+
var COMMAND_TIMEOUT_MS = 5e3;
|
|
659
682
|
var READY_POLL_MS = 100;
|
|
660
683
|
var READY_PROBE_TIMEOUT_MS = 250;
|
|
684
|
+
var RETRY_MAX_MS = 500;
|
|
685
|
+
var SERVICE_NOT_FOUND = 113;
|
|
686
|
+
var TRANSITION_IN_PROGRESS = 5;
|
|
687
|
+
var OUTPUT_LIMIT = 4096;
|
|
688
|
+
var LaunchctlError = class extends Error {
|
|
689
|
+
status;
|
|
690
|
+
timedOut;
|
|
691
|
+
constructor(operation, result) {
|
|
692
|
+
const stdout = result.stdout.slice(0, OUTPUT_LIMIT);
|
|
693
|
+
const stderr = result.stderr.slice(0, OUTPUT_LIMIT);
|
|
694
|
+
const raw = stderr.trim() || stdout.trim() || `exit ${String(result.status)}`;
|
|
695
|
+
const detail = raw.replace(/\n?Try re-running the command as root for richer errors\.?/giu, "").trim() || `exit ${String(result.status)}`;
|
|
696
|
+
super(`launchctl ${operation} failed: ${detail}`);
|
|
697
|
+
this.name = "LaunchctlError";
|
|
698
|
+
this.status = result.status;
|
|
699
|
+
this.timedOut = result.timedOut === true;
|
|
700
|
+
}
|
|
701
|
+
};
|
|
661
702
|
function xdgDataHome(options) {
|
|
662
703
|
const home = options.homeDir ?? homedir();
|
|
663
704
|
const configured = (options.env ?? process.env).XDG_DATA_HOME;
|
|
@@ -672,14 +713,22 @@ function runtimePaths(options = {}) {
|
|
|
672
713
|
runtimeDir,
|
|
673
714
|
releasesDir: join(runtimeDir, "releases"),
|
|
674
715
|
currentLink,
|
|
675
|
-
daemonEntry: join(currentLink, "prim-daemon-server"),
|
|
676
|
-
statuslineEntry: join(currentLink, "prim-statusline"),
|
|
677
716
|
statuslineLauncher: join(runtimeDir, "prim-statusline"),
|
|
678
|
-
manifestPath: join(currentLink, "manifest.json"),
|
|
679
717
|
disabledMarker: join(dataDir, "daemon.disabled"),
|
|
680
718
|
lifecycleLockDir: join(dataDir, "daemon.lifecycle.lock")
|
|
681
719
|
};
|
|
682
720
|
}
|
|
721
|
+
function daemonControlPaths(options = {}) {
|
|
722
|
+
const configDir = join(options.homeDir ?? homedir(), ".config", "prim");
|
|
723
|
+
const legacy = runtimePaths(options);
|
|
724
|
+
return {
|
|
725
|
+
launcher: join(configDir, `prim-daemon-launcher-v${LAUNCHER_SCHEMA_VERSION}`),
|
|
726
|
+
disabledMarker: join(configDir, "daemon.disabled"),
|
|
727
|
+
lifecycleLockDir: join(configDir, "daemon.lifecycle.lock"),
|
|
728
|
+
legacyDisabledMarker: legacy.disabledMarker,
|
|
729
|
+
legacyLifecycleLockDir: legacy.lifecycleLockDir
|
|
730
|
+
};
|
|
731
|
+
}
|
|
683
732
|
function findPackageVersion(entryFile) {
|
|
684
733
|
let dir = dirname(resolve(entryFile));
|
|
685
734
|
for (let depth = 0; depth < 6; depth++) {
|
|
@@ -702,7 +751,7 @@ function findPackageVersion(entryFile) {
|
|
|
702
751
|
function readRuntimeManifest(path) {
|
|
703
752
|
try {
|
|
704
753
|
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
705
|
-
if (parsed.schemaVersion !== RUNTIME_SCHEMA_VERSION || typeof parsed.version !== "string" || typeof parsed.nodePath !== "string" || parsed.daemonFile !== "prim-daemon-server") {
|
|
754
|
+
if (parsed.schemaVersion !== RUNTIME_SCHEMA_VERSION || typeof parsed.version !== "string" || typeof parsed.nodePath !== "string" || parsed.daemonFile !== "prim-daemon-server" || typeof parsed.daemonSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(parsed.daemonSha256) || parsed.statuslineFile !== void 0 && parsed.statuslineFile !== "prim-statusline" || parsed.statuslineFile === void 0 !== (parsed.statuslineSha256 === void 0) || parsed.statuslineSha256 !== void 0 && !/^[a-f0-9]{64}$/u.test(parsed.statuslineSha256)) {
|
|
706
755
|
return null;
|
|
707
756
|
}
|
|
708
757
|
return parsed;
|
|
@@ -710,10 +759,18 @@ function readRuntimeManifest(path) {
|
|
|
710
759
|
return null;
|
|
711
760
|
}
|
|
712
761
|
}
|
|
713
|
-
function sameRuntime(current, desired,
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
762
|
+
function sameRuntime(current, desired, daemonPath, statuslinePath) {
|
|
763
|
+
if (!current || current.version !== desired.version || current.nodePath !== desired.nodePath || current.daemonSha256 !== desired.daemonSha256 || current.statuslineFile !== desired.statuslineFile || current.statuslineSha256 !== desired.statuslineSha256) {
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
try {
|
|
767
|
+
return sha256File(daemonPath) === desired.daemonSha256 && (desired.statuslineSha256 === void 0 || sha256File(statuslinePath) === desired.statuslineSha256);
|
|
768
|
+
} catch {
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
function sha256File(path) {
|
|
773
|
+
return createHash2("sha256").update(readFileSync2(path)).digest("hex");
|
|
717
774
|
}
|
|
718
775
|
function atomicSymlink(target, linkPath) {
|
|
719
776
|
const tempLink = `${linkPath}.tmp-${process.pid}-${createHash2("sha256").update(`${target}\0${Date.now()}\0${Math.random()}`).digest("hex").slice(0, 12)}`;
|
|
@@ -740,35 +797,60 @@ function stageRuntime(options = {}) {
|
|
|
740
797
|
version,
|
|
741
798
|
nodePath,
|
|
742
799
|
daemonFile: "prim-daemon-server",
|
|
743
|
-
|
|
800
|
+
daemonSha256: sha256File(daemonSource),
|
|
801
|
+
...statuslineSource ? {
|
|
802
|
+
statuslineFile: "prim-statusline",
|
|
803
|
+
statuslineSha256: sha256File(statuslineSource)
|
|
804
|
+
} : {},
|
|
744
805
|
stagedAt: (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString()
|
|
745
806
|
};
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
807
|
+
let currentRelease = null;
|
|
808
|
+
try {
|
|
809
|
+
currentRelease = realpathSync(paths.currentLink);
|
|
810
|
+
} catch {
|
|
811
|
+
}
|
|
812
|
+
const current = currentRelease ? readRuntimeManifest(join(currentRelease, "manifest.json")) : null;
|
|
813
|
+
const currentDaemon = currentRelease ? join(currentRelease, desired.daemonFile) : "";
|
|
814
|
+
const currentStatusline = currentRelease ? join(currentRelease, "prim-statusline") : "";
|
|
815
|
+
if (currentRelease && sameRuntime(current, desired, currentDaemon, currentStatusline)) {
|
|
816
|
+
const manifest = current;
|
|
817
|
+
const statuslinePath = manifest.statuslineFile ? currentStatusline : void 0;
|
|
818
|
+
if (statuslinePath) {
|
|
749
819
|
atomicWrite(
|
|
750
820
|
paths.statuslineLauncher,
|
|
751
|
-
statuslineLauncherContent(
|
|
821
|
+
statuslineLauncherContent(statuslinePath, manifest.nodePath),
|
|
752
822
|
RUNTIME_LAUNCHER_MODE
|
|
753
823
|
);
|
|
754
824
|
} else {
|
|
755
825
|
rmSync(paths.statuslineLauncher, { force: true });
|
|
756
826
|
}
|
|
757
|
-
return {
|
|
827
|
+
return {
|
|
828
|
+
changed: false,
|
|
829
|
+
manifest,
|
|
830
|
+
paths,
|
|
831
|
+
daemonPath: currentDaemon
|
|
832
|
+
};
|
|
758
833
|
}
|
|
759
834
|
mkdirSync(paths.releasesDir, { recursive: true, mode: RUNTIME_DIR_MODE });
|
|
760
835
|
chmodSync(paths.runtimeDir, RUNTIME_DIR_MODE);
|
|
761
836
|
chmodSync(paths.releasesDir, RUNTIME_DIR_MODE);
|
|
762
837
|
const stagingDir = mkdtempSync(join(paths.releasesDir, ".stage-"));
|
|
763
838
|
chmodSync(stagingDir, RUNTIME_DIR_MODE);
|
|
839
|
+
let releaseDir;
|
|
764
840
|
try {
|
|
765
841
|
const daemonTarget = join(stagingDir, desired.daemonFile);
|
|
766
842
|
copyFileSync(daemonSource, daemonTarget);
|
|
767
843
|
chmodSync(daemonTarget, RUNTIME_FILE_MODE);
|
|
844
|
+
if (sha256File(daemonTarget) !== desired.daemonSha256) {
|
|
845
|
+
throw new Error("cannot stage runtime: daemon bundle changed while it was copied");
|
|
846
|
+
}
|
|
768
847
|
if (statuslineSource && desired.statuslineFile) {
|
|
769
848
|
const statuslineTarget = join(stagingDir, desired.statuslineFile);
|
|
770
849
|
copyFileSync(statuslineSource, statuslineTarget);
|
|
771
850
|
chmodSync(statuslineTarget, RUNTIME_FILE_MODE);
|
|
851
|
+
if (sha256File(statuslineTarget) !== desired.statuslineSha256) {
|
|
852
|
+
throw new Error("cannot stage runtime: statusline bundle changed while it was copied");
|
|
853
|
+
}
|
|
772
854
|
}
|
|
773
855
|
const manifestTarget = join(stagingDir, "manifest.json");
|
|
774
856
|
writeFileSync(manifestTarget, `${JSON.stringify(desired, null, 2)}
|
|
@@ -780,13 +862,15 @@ function stageRuntime(options = {}) {
|
|
|
780
862
|
const releaseName = `release-${createHash2("sha256").update(
|
|
781
863
|
`${version}\0${nodePath}\0${desired.statuslineFile ?? ""}\0${desired.stagedAt}\0${process.pid}\0${Math.random()}`
|
|
782
864
|
).digest("hex").slice(0, 16)}`;
|
|
783
|
-
|
|
865
|
+
releaseDir = join(paths.releasesDir, releaseName);
|
|
784
866
|
renameSync(stagingDir, releaseDir);
|
|
867
|
+
releaseDir = realpathSync(releaseDir);
|
|
785
868
|
atomicSymlink(join("releases", releaseName), paths.currentLink);
|
|
786
869
|
if (desired.statuslineFile) {
|
|
870
|
+
const statuslinePath = join(releaseDir, desired.statuslineFile);
|
|
787
871
|
atomicWrite(
|
|
788
872
|
paths.statuslineLauncher,
|
|
789
|
-
statuslineLauncherContent(
|
|
873
|
+
statuslineLauncherContent(statuslinePath, desired.nodePath),
|
|
790
874
|
RUNTIME_LAUNCHER_MODE
|
|
791
875
|
);
|
|
792
876
|
} else {
|
|
@@ -796,36 +880,91 @@ function stageRuntime(options = {}) {
|
|
|
796
880
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
797
881
|
throw error;
|
|
798
882
|
}
|
|
799
|
-
return {
|
|
883
|
+
return {
|
|
884
|
+
changed: true,
|
|
885
|
+
manifest: desired,
|
|
886
|
+
paths,
|
|
887
|
+
daemonPath: join(releaseDir, desired.daemonFile)
|
|
888
|
+
};
|
|
800
889
|
}
|
|
801
890
|
function shellQuote(value) {
|
|
802
891
|
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
803
892
|
}
|
|
804
|
-
function statuslineLauncherContent(
|
|
893
|
+
function statuslineLauncherContent(statuslinePath, nodePath) {
|
|
805
894
|
return `#!/bin/sh
|
|
806
|
-
exec ${shellQuote(nodePath)} ${shellQuote(
|
|
895
|
+
exec ${shellQuote(nodePath)} ${shellQuote(statuslinePath)}
|
|
807
896
|
`;
|
|
808
897
|
}
|
|
809
898
|
function runtimeStatuslineCommand(options = {}) {
|
|
810
899
|
return shellQuote(runtimePaths(options).statuslineLauncher);
|
|
811
900
|
}
|
|
901
|
+
function generateDaemonLauncher(config) {
|
|
902
|
+
const apiUrl = config.apiUrl ? normalizeApiUrl(config.apiUrl) || void 0 : void 0;
|
|
903
|
+
const metadata = {
|
|
904
|
+
schemaVersion: LAUNCHER_SCHEMA_VERSION,
|
|
905
|
+
nodePath: config.nodePath,
|
|
906
|
+
daemonPath: config.daemonPath,
|
|
907
|
+
runtimeVersion: config.runtimeVersion,
|
|
908
|
+
apiUrl: apiUrl ?? null
|
|
909
|
+
};
|
|
910
|
+
const revision = createHash2("sha256").update(JSON.stringify(metadata)).digest("hex");
|
|
911
|
+
const encoded = Buffer.from(JSON.stringify({ ...metadata, revision })).toString("base64url");
|
|
912
|
+
return {
|
|
913
|
+
revision,
|
|
914
|
+
content: `#!/bin/sh
|
|
915
|
+
# prim-daemon-launcher: ${encoded}
|
|
916
|
+
export PRIM_RUNTIME_VERSION=${shellQuote(config.runtimeVersion)}
|
|
917
|
+
export PRIM_LAUNCH_REVISION=${shellQuote(revision)}
|
|
918
|
+
${apiUrl ? `export PRIM_API_URL=${shellQuote(apiUrl)}` : "unset PRIM_API_URL"}
|
|
919
|
+
exec ${shellQuote(config.nodePath)} ${shellQuote(config.daemonPath)}
|
|
920
|
+
`
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
function readDaemonLauncher(path) {
|
|
924
|
+
try {
|
|
925
|
+
const content = readFileSync2(path, "utf8");
|
|
926
|
+
const encoded = /^# prim-daemon-launcher: ([A-Za-z0-9_-]+)$/mu.exec(content)?.[1];
|
|
927
|
+
if (!encoded) return null;
|
|
928
|
+
const value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
929
|
+
if (value.schemaVersion !== LAUNCHER_SCHEMA_VERSION || typeof value.nodePath !== "string" || typeof value.daemonPath !== "string" || typeof value.runtimeVersion !== "string" || value.apiUrl !== null && typeof value.apiUrl !== "string" || typeof value.revision !== "string") {
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
const config = {
|
|
933
|
+
nodePath: value.nodePath,
|
|
934
|
+
daemonPath: value.daemonPath,
|
|
935
|
+
runtimeVersion: value.runtimeVersion,
|
|
936
|
+
...value.apiUrl === null ? {} : { apiUrl: value.apiUrl }
|
|
937
|
+
};
|
|
938
|
+
const expected = generateDaemonLauncher(config);
|
|
939
|
+
return expected.revision === value.revision && expected.content === content ? config : null;
|
|
940
|
+
} catch {
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
function readUsableDaemonConfig(path) {
|
|
945
|
+
const config = readDaemonLauncher(path);
|
|
946
|
+
if (!config) return null;
|
|
947
|
+
const manifest = readRuntimeManifest(join(dirname(config.daemonPath), "manifest.json"));
|
|
948
|
+
try {
|
|
949
|
+
accessSync(config.nodePath, constants.X_OK);
|
|
950
|
+
return manifest?.version === config.runtimeVersion && manifest.nodePath === config.nodePath && manifest.daemonSha256 === sha256File(config.daemonPath) ? config : null;
|
|
951
|
+
} catch {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
812
955
|
function xmlEscape(value) {
|
|
813
956
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
814
957
|
}
|
|
815
958
|
function generateLaunchAgentPlist(config) {
|
|
816
|
-
const apiUrl = config.apiUrl ? `
|
|
817
|
-
<key>PRIM_API_URL</key>
|
|
818
|
-
<string>${xmlEscape(config.apiUrl)}</string>` : "";
|
|
819
959
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
820
960
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
821
961
|
<plist version="1.0">
|
|
822
962
|
<dict>
|
|
823
963
|
<key>Label</key>
|
|
824
|
-
<string>${LAUNCHD_LABEL}</string>
|
|
964
|
+
<string>${xmlEscape(config.label ?? LAUNCHD_LABEL)}</string>
|
|
825
965
|
<key>ProgramArguments</key>
|
|
826
966
|
<array>
|
|
827
|
-
<string>${xmlEscape(config.
|
|
828
|
-
<string>${xmlEscape(config.daemonPath)}</string>
|
|
967
|
+
<string>${xmlEscape(config.launcherPath)}</string>
|
|
829
968
|
</array>
|
|
830
969
|
<key>RunAtLoad</key>
|
|
831
970
|
<true/>
|
|
@@ -835,6 +974,8 @@ function generateLaunchAgentPlist(config) {
|
|
|
835
974
|
<integer>10</integer>
|
|
836
975
|
<key>ProcessType</key>
|
|
837
976
|
<string>Background</string>
|
|
977
|
+
<key>ExitTimeOut</key>
|
|
978
|
+
<integer>5</integer>
|
|
838
979
|
<key>Umask</key>
|
|
839
980
|
<integer>63</integer>
|
|
840
981
|
<key>WorkingDirectory</key>
|
|
@@ -843,11 +984,6 @@ function generateLaunchAgentPlist(config) {
|
|
|
843
984
|
<string>${xmlEscape(config.logPath)}</string>
|
|
844
985
|
<key>StandardErrorPath</key>
|
|
845
986
|
<string>${xmlEscape(config.logPath)}</string>
|
|
846
|
-
<key>EnvironmentVariables</key>
|
|
847
|
-
<dict>
|
|
848
|
-
<key>PRIM_RUNTIME_VERSION</key>
|
|
849
|
-
<string>${xmlEscape(config.runtimeVersion)}</string>${apiUrl}
|
|
850
|
-
</dict>
|
|
851
987
|
</dict>
|
|
852
988
|
</plist>
|
|
853
989
|
`;
|
|
@@ -855,45 +991,57 @@ function generateLaunchAgentPlist(config) {
|
|
|
855
991
|
function launchdPaths(options = {}) {
|
|
856
992
|
const home = options.homeDir ?? homedir();
|
|
857
993
|
const uid = options.uid ?? process.getuid?.();
|
|
994
|
+
const label = options.label ?? LAUNCHD_LABEL;
|
|
858
995
|
if (uid === void 0) throw new Error("cannot determine uid for launchd user domain");
|
|
859
996
|
return {
|
|
860
997
|
domainTarget: `gui/${uid}`,
|
|
861
|
-
serviceTarget: `gui/${uid}/${
|
|
862
|
-
plistPath: join(home, "Library", "LaunchAgents", `${
|
|
998
|
+
serviceTarget: `gui/${uid}/${label}`,
|
|
999
|
+
plistPath: join(home, "Library", "LaunchAgents", `${label}.plist`),
|
|
863
1000
|
logPath: join(home, ".config", "prim", "daemon.log")
|
|
864
1001
|
};
|
|
865
1002
|
}
|
|
866
|
-
var runLaunchctl = (args) => {
|
|
867
|
-
const result = spawnSync("/bin/launchctl", args, { encoding: "utf8" });
|
|
1003
|
+
var runLaunchctl = (args, timeoutMs2 = COMMAND_TIMEOUT_MS) => {
|
|
1004
|
+
const result = spawnSync("/bin/launchctl", args, { encoding: "utf8", timeout: timeoutMs2 });
|
|
1005
|
+
const error = result.error;
|
|
868
1006
|
return {
|
|
869
1007
|
status: result.status,
|
|
870
1008
|
stdout: result.stdout ?? "",
|
|
871
|
-
stderr:
|
|
1009
|
+
stderr: error?.message ?? result.stderr ?? "",
|
|
1010
|
+
timedOut: error?.code === "ETIMEDOUT"
|
|
872
1011
|
};
|
|
873
1012
|
};
|
|
874
1013
|
function parseLaunchdService(result) {
|
|
875
|
-
if (result.status
|
|
1014
|
+
if (result.status === SERVICE_NOT_FOUND) return { loaded: false };
|
|
1015
|
+
if (result.status !== 0) throw new LaunchctlError("print", result);
|
|
876
1016
|
const state = /^\s*state = (.+?)\s*$/m.exec(result.stdout)?.[1];
|
|
877
1017
|
const rawPid = /^\s*pid = (\d+)\s*$/m.exec(result.stdout)?.[1];
|
|
1018
|
+
const program2 = /^\s*program = (.+?)\s*$/m.exec(result.stdout)?.[1];
|
|
1019
|
+
if (!program2) throw new Error("launchctl print did not report the registered program");
|
|
878
1020
|
const pid = rawPid ? Number(rawPid) : void 0;
|
|
879
|
-
return { loaded: true, state, pid,
|
|
1021
|
+
return { loaded: true, state, pid, program: program2 };
|
|
880
1022
|
}
|
|
881
1023
|
function getLaunchdService(options = {}) {
|
|
882
|
-
|
|
883
|
-
|
|
1024
|
+
return parseLaunchdService(
|
|
1025
|
+
(options.runner ?? runLaunchctl)(
|
|
1026
|
+
["print", launchdPaths(options).serviceTarget],
|
|
1027
|
+
options.timeoutMs ?? COMMAND_TIMEOUT_MS
|
|
1028
|
+
)
|
|
1029
|
+
);
|
|
884
1030
|
}
|
|
885
|
-
function atomicWrite(path, content, mode) {
|
|
1031
|
+
function atomicWrite(path, content, mode, validate) {
|
|
886
1032
|
try {
|
|
887
1033
|
if (readFileSync2(path, "utf8") === content) {
|
|
888
1034
|
chmodSync(path, mode);
|
|
1035
|
+
validate?.(path, COMMAND_TIMEOUT_MS);
|
|
889
1036
|
return false;
|
|
890
1037
|
}
|
|
891
1038
|
} catch {
|
|
892
1039
|
}
|
|
893
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1040
|
+
mkdirSync(dirname(path), { recursive: true, mode: RUNTIME_DIR_MODE });
|
|
894
1041
|
const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
895
1042
|
writeFileSync(temp, content, { encoding: "utf8", mode, flag: "wx" });
|
|
896
1043
|
try {
|
|
1044
|
+
validate?.(temp, COMMAND_TIMEOUT_MS);
|
|
897
1045
|
renameSync(temp, path);
|
|
898
1046
|
} catch (error) {
|
|
899
1047
|
rmSync(temp, { force: true });
|
|
@@ -901,20 +1049,25 @@ function atomicWrite(path, content, mode) {
|
|
|
901
1049
|
}
|
|
902
1050
|
return true;
|
|
903
1051
|
}
|
|
904
|
-
function launchctlOrThrow(runner, args, operation) {
|
|
905
|
-
const result = runner(args);
|
|
906
|
-
if (result.status !== 0) {
|
|
907
|
-
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${String(result.status)}`;
|
|
908
|
-
throw new Error(`launchctl ${operation} failed: ${detail}`);
|
|
909
|
-
}
|
|
910
|
-
return result;
|
|
911
|
-
}
|
|
912
1052
|
function defaultSleep(ms) {
|
|
913
1053
|
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
914
1054
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1055
|
+
function validatePlist(path, timeoutMs2) {
|
|
1056
|
+
if (process.platform !== "darwin") return;
|
|
1057
|
+
const result = spawnSync("/usr/bin/plutil", ["-lint", path], {
|
|
1058
|
+
encoding: "utf8",
|
|
1059
|
+
timeout: timeoutMs2
|
|
1060
|
+
});
|
|
1061
|
+
if (result.status !== 0) {
|
|
1062
|
+
throw new Error(
|
|
1063
|
+
`invalid launchd plist: ${result.error?.message ?? result.stderr?.trim() ?? result.stdout?.trim() ?? `exit ${String(result.status)}`}`
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
async function inspectDaemon(timeoutMs2) {
|
|
1068
|
+
const value = await daemonRequest("status_snapshot", {}, { timeoutMs: timeoutMs2 });
|
|
1069
|
+
if (!value || !Number.isInteger(value.pid) || (value.pid ?? 0) <= 0) return null;
|
|
1070
|
+
return value;
|
|
918
1071
|
}
|
|
919
1072
|
function processIsAlive(pid) {
|
|
920
1073
|
try {
|
|
@@ -924,11 +1077,31 @@ function processIsAlive(pid) {
|
|
|
924
1077
|
return error.code === "EPERM";
|
|
925
1078
|
}
|
|
926
1079
|
}
|
|
1080
|
+
function canonicalProspectivePath(path) {
|
|
1081
|
+
const suffix = [];
|
|
1082
|
+
let ancestor = resolve(path);
|
|
1083
|
+
while (!existsSync2(ancestor)) {
|
|
1084
|
+
const parent = dirname(ancestor);
|
|
1085
|
+
if (parent === ancestor) return resolve(path);
|
|
1086
|
+
suffix.unshift(basename(ancestor));
|
|
1087
|
+
ancestor = parent;
|
|
1088
|
+
}
|
|
1089
|
+
try {
|
|
1090
|
+
return join(realpathSync(ancestor), ...suffix);
|
|
1091
|
+
} catch {
|
|
1092
|
+
return resolve(path);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
927
1095
|
async function withDaemonLifecycleLock(operation, options = {}) {
|
|
928
|
-
const
|
|
929
|
-
|
|
1096
|
+
const paths = daemonControlPaths(options);
|
|
1097
|
+
const sameLock = canonicalProspectivePath(paths.lifecycleLockDir) === canonicalProspectivePath(paths.legacyLifecycleLockDir);
|
|
1098
|
+
return withFileLock(
|
|
1099
|
+
paths.lifecycleLockDir,
|
|
1100
|
+
() => sameLock ? operation() : withFileLock(paths.legacyLifecycleLockDir, operation, options.lifecycleLock),
|
|
1101
|
+
options.lifecycleLock
|
|
1102
|
+
);
|
|
930
1103
|
}
|
|
931
|
-
async function stopVerifiedLegacyDaemon(homeDir) {
|
|
1104
|
+
async function stopVerifiedLegacyDaemon(homeDir, deadlineMs, nowMs, sleep2) {
|
|
932
1105
|
const pidPath = join(homeDir, ".config", "prim", "daemon.pid");
|
|
933
1106
|
let pid;
|
|
934
1107
|
try {
|
|
@@ -936,122 +1109,314 @@ async function stopVerifiedLegacyDaemon(homeDir) {
|
|
|
936
1109
|
} catch {
|
|
937
1110
|
throw new Error("an unsupervised daemon answered, but it has no verifiable pidfile");
|
|
938
1111
|
}
|
|
939
|
-
|
|
1112
|
+
if (nowMs() >= deadlineMs) throw new Error("legacy verification exceeded the lifecycle deadline");
|
|
1113
|
+
const snapshot = await daemonRequest(
|
|
1114
|
+
"status_snapshot",
|
|
1115
|
+
{},
|
|
1116
|
+
{
|
|
1117
|
+
timeoutMs: timeoutMs(nowMs, deadlineMs, 500)
|
|
1118
|
+
}
|
|
1119
|
+
);
|
|
940
1120
|
if (!Number.isInteger(pid) || pid <= 0 || !processIsAlive(pid) || snapshot?.pid !== pid) {
|
|
941
1121
|
throw new Error("refusing to signal an unsupervised daemon whose pid cannot be verified");
|
|
942
1122
|
}
|
|
1123
|
+
if (nowMs() >= deadlineMs) throw new Error("legacy verification exceeded the lifecycle deadline");
|
|
943
1124
|
process.kill(pid, "SIGTERM");
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
await defaultSleep(100);
|
|
1125
|
+
while (nowMs() < deadlineMs && processIsAlive(pid)) {
|
|
1126
|
+
await sleep2(Math.min(READY_POLL_MS, deadlineMs - nowMs()));
|
|
947
1127
|
}
|
|
948
1128
|
if (processIsAlive(pid)) {
|
|
949
|
-
throw new Error(`verified legacy daemon pid=${pid} did not stop
|
|
1129
|
+
throw new Error(`verified legacy daemon pid=${pid} did not stop before the lifecycle deadline`);
|
|
950
1130
|
}
|
|
951
1131
|
return true;
|
|
952
1132
|
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1133
|
+
function timeoutMs(nowMs, deadlineMs, cap = COMMAND_TIMEOUT_MS) {
|
|
1134
|
+
return Math.max(1, Math.min(cap, Math.ceil(deadlineMs - nowMs())));
|
|
1135
|
+
}
|
|
1136
|
+
function desiredDaemonIsRunning(transition, observed) {
|
|
1137
|
+
const { service, identity } = observed;
|
|
1138
|
+
return Boolean(
|
|
1139
|
+
service.loaded && service.program === transition.launcherPath && service.pid !== void 0 && identity?.pid === service.pid && identity.version === transition.version && identity.launchRevision === transition.revision
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
function getService(transition) {
|
|
1143
|
+
return parseLaunchdService(
|
|
1144
|
+
transition.runner(
|
|
1145
|
+
["print", transition.paths.serviceTarget],
|
|
1146
|
+
timeoutMs(transition.nowMs, transition.deadlineMs)
|
|
1147
|
+
)
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
async function observe(transition) {
|
|
1151
|
+
const service = getService(transition);
|
|
1152
|
+
const identity = service.loaded && service.program === transition.launcherPath ? await transition.inspect(
|
|
1153
|
+
timeoutMs(transition.nowMs, transition.deadlineMs, READY_PROBE_TIMEOUT_MS)
|
|
1154
|
+
) : null;
|
|
1155
|
+
return { service, identity };
|
|
1156
|
+
}
|
|
1157
|
+
async function tryObserve(transition) {
|
|
1158
|
+
try {
|
|
1159
|
+
return await observe(transition);
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
if (!(error instanceof LaunchctlError)) throw error;
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
async function bootstrap(transition, oldProgram) {
|
|
1166
|
+
let retryMs = READY_POLL_MS;
|
|
1167
|
+
let lastError = null;
|
|
1168
|
+
while (transition.nowMs() < transition.deadlineMs) {
|
|
1169
|
+
const result = transition.runner(
|
|
1170
|
+
["bootstrap", transition.paths.domainTarget, transition.paths.plistPath],
|
|
1171
|
+
timeoutMs(transition.nowMs, transition.deadlineMs)
|
|
1172
|
+
);
|
|
1173
|
+
if (result.status === 0) return;
|
|
1174
|
+
const error = new LaunchctlError("bootstrap", result);
|
|
1175
|
+
lastError = error;
|
|
1176
|
+
const observed = await tryObserve(transition);
|
|
1177
|
+
const retryable = error.status === TRANSITION_IN_PROGRESS || error.timedOut;
|
|
1178
|
+
if (observed?.service.loaded) {
|
|
1179
|
+
if (observed.service.program === transition.launcherPath) {
|
|
1180
|
+
if (desiredDaemonIsRunning(transition, observed)) return;
|
|
1181
|
+
if (retryable) return;
|
|
1182
|
+
} else if (observed.service.program !== oldProgram) {
|
|
1183
|
+
throw new Error(
|
|
1184
|
+
`refusing to replace unexpected launchd program ${observed.service.program}`
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
if (!retryable) throw error;
|
|
1189
|
+
const remaining = transition.deadlineMs - transition.nowMs();
|
|
1190
|
+
if (remaining <= 0) break;
|
|
1191
|
+
await transition.sleep(Math.min(retryMs, remaining));
|
|
1192
|
+
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
|
|
1193
|
+
}
|
|
1194
|
+
throw new Error(
|
|
1195
|
+
`launchctl bootstrap did not converge within ${TRANSITION_TIMEOUT_MS}ms${lastError ? `: ${lastError.message}` : ""}`
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
async function reloadDefinition(transition, oldProgram) {
|
|
1199
|
+
const result = transition.runner(
|
|
1200
|
+
["bootout", transition.paths.serviceTarget],
|
|
1201
|
+
timeoutMs(transition.nowMs, transition.deadlineMs)
|
|
1202
|
+
);
|
|
1203
|
+
if (result.status !== 0) {
|
|
1204
|
+
const error = new LaunchctlError("bootout", result);
|
|
1205
|
+
const observed = await tryObserve(transition);
|
|
1206
|
+
const retryable = error.status === TRANSITION_IN_PROGRESS || error.timedOut;
|
|
1207
|
+
if (observed?.service.loaded) {
|
|
1208
|
+
if (observed.service.program === transition.launcherPath) {
|
|
1209
|
+
if (desiredDaemonIsRunning(transition, observed)) return;
|
|
1210
|
+
if (retryable) return;
|
|
1211
|
+
}
|
|
1212
|
+
if (observed.service.program !== oldProgram || !retryable) throw error;
|
|
1213
|
+
} else if (!observed && !retryable) {
|
|
1214
|
+
throw error;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
await bootstrap(transition, oldProgram);
|
|
1218
|
+
}
|
|
1219
|
+
async function waitForDesired(transition, repairMismatch = false) {
|
|
1220
|
+
let observed = { service: { loaded: false }, identity: null };
|
|
1221
|
+
let repaired = false;
|
|
1222
|
+
do {
|
|
1223
|
+
const current = await tryObserve(transition);
|
|
1224
|
+
if (current) {
|
|
1225
|
+
observed = current;
|
|
1226
|
+
if (desiredDaemonIsRunning(transition, observed)) return observed;
|
|
1227
|
+
if (observed.service.loaded && observed.service.program !== transition.launcherPath) {
|
|
1228
|
+
throw new Error(`launchd registered unexpected program ${observed.service.program}`);
|
|
1229
|
+
}
|
|
1230
|
+
if (repairMismatch && !repaired && observed.service.loaded && observed.service.program === transition.launcherPath && observed.identity) {
|
|
1231
|
+
repaired = true;
|
|
1232
|
+
await kickstart(transition);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
const remaining = transition.deadlineMs - transition.nowMs();
|
|
1236
|
+
if (remaining <= 0) break;
|
|
1237
|
+
await transition.sleep(Math.min(READY_POLL_MS, remaining));
|
|
1238
|
+
} while (transition.nowMs() < transition.deadlineMs);
|
|
1239
|
+
return observed;
|
|
1240
|
+
}
|
|
1241
|
+
async function kickstart(transition) {
|
|
1242
|
+
const result = transition.runner(
|
|
1243
|
+
["kickstart", "-k", transition.paths.serviceTarget],
|
|
1244
|
+
timeoutMs(transition.nowMs, transition.deadlineMs)
|
|
1245
|
+
);
|
|
1246
|
+
if (result.status === 0) return;
|
|
1247
|
+
const error = new LaunchctlError("kickstart", result);
|
|
1248
|
+
const observed = await tryObserve(transition);
|
|
1249
|
+
if (observed && desiredDaemonIsRunning(transition, observed)) return;
|
|
1250
|
+
if (!error.timedOut) throw error;
|
|
1251
|
+
const ready = await waitForDesired(transition);
|
|
1252
|
+
if (!desiredDaemonIsRunning(transition, ready)) throw error;
|
|
1253
|
+
}
|
|
1254
|
+
async function waitForAbsence(context) {
|
|
1255
|
+
let lastError = null;
|
|
1256
|
+
while (context.nowMs() < context.deadlineMs) {
|
|
1257
|
+
try {
|
|
1258
|
+
const service = getLaunchdService({
|
|
1259
|
+
...context.options,
|
|
1260
|
+
runner: context.runner,
|
|
1261
|
+
timeoutMs: timeoutMs(context.nowMs, context.deadlineMs)
|
|
1262
|
+
});
|
|
1263
|
+
if (!service.loaded) {
|
|
1264
|
+
const identity = await context.inspect(
|
|
1265
|
+
timeoutMs(context.nowMs, context.deadlineMs, READY_PROBE_TIMEOUT_MS)
|
|
1266
|
+
);
|
|
1267
|
+
const socketReleased = context.previousPid === void 0 || identity?.pid !== context.previousPid;
|
|
1268
|
+
const processExited = context.previousPid === void 0 || !processIsAlive(context.previousPid);
|
|
1269
|
+
if (socketReleased && processExited) return;
|
|
1270
|
+
}
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
if (!(error instanceof LaunchctlError)) throw error;
|
|
1273
|
+
lastError = error;
|
|
1274
|
+
}
|
|
1275
|
+
const remaining = context.deadlineMs - context.nowMs();
|
|
1276
|
+
if (remaining <= 0) break;
|
|
1277
|
+
await context.sleep(Math.min(READY_POLL_MS, remaining));
|
|
958
1278
|
}
|
|
959
|
-
|
|
1279
|
+
throw new Error(
|
|
1280
|
+
`launchctl bootout did not converge within ${TRANSITION_TIMEOUT_MS}ms${lastError ? `: ${lastError.message}` : ""}`
|
|
1281
|
+
);
|
|
960
1282
|
}
|
|
961
1283
|
function daemonExplicitlyDisabled(options = {}) {
|
|
962
|
-
|
|
1284
|
+
const paths = daemonControlPaths(options);
|
|
1285
|
+
return existsSync2(paths.disabledMarker) || existsSync2(paths.legacyDisabledMarker);
|
|
963
1286
|
}
|
|
964
1287
|
function setDaemonExplicitlyDisabled(disabled, options = {}) {
|
|
965
|
-
const
|
|
1288
|
+
const paths = daemonControlPaths(options);
|
|
966
1289
|
if (!disabled) {
|
|
967
|
-
rmSync(
|
|
1290
|
+
rmSync(paths.disabledMarker, { force: true });
|
|
1291
|
+
rmSync(paths.legacyDisabledMarker, { force: true });
|
|
968
1292
|
return;
|
|
969
1293
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
chmodSync(marker, RUNTIME_FILE_MODE);
|
|
1294
|
+
const content = "disabled by `prim daemon stop`\n";
|
|
1295
|
+
if (paths.legacyDisabledMarker !== paths.disabledMarker) {
|
|
1296
|
+
atomicWrite(paths.legacyDisabledMarker, content, RUNTIME_FILE_MODE);
|
|
1297
|
+
}
|
|
1298
|
+
atomicWrite(paths.disabledMarker, content, RUNTIME_FILE_MODE);
|
|
976
1299
|
}
|
|
977
1300
|
async function ensureMacDaemon(options = {}) {
|
|
978
1301
|
return withDaemonLifecycleLock(() => ensureMacDaemonLocked(options), options);
|
|
979
1302
|
}
|
|
980
1303
|
async function ensureMacDaemonLocked(options) {
|
|
1304
|
+
const runner = options.runner ?? runLaunchctl;
|
|
1305
|
+
let service = getLaunchdService({ ...options, runner });
|
|
981
1306
|
if (!options.explicitlyStarted && daemonExplicitlyDisabled(options)) {
|
|
1307
|
+
const control2 = daemonControlPaths(options);
|
|
1308
|
+
if (!existsSync2(control2.disabledMarker)) {
|
|
1309
|
+
atomicWrite(control2.disabledMarker, "disabled by `prim daemon stop`\n", RUNTIME_FILE_MODE);
|
|
1310
|
+
}
|
|
982
1311
|
return {
|
|
983
1312
|
state: "disabled",
|
|
984
1313
|
action: "none",
|
|
985
1314
|
runtimeChanged: false,
|
|
986
1315
|
plistChanged: false,
|
|
987
1316
|
responding: false,
|
|
988
|
-
service
|
|
1317
|
+
service
|
|
989
1318
|
};
|
|
990
1319
|
}
|
|
991
1320
|
if (options.explicitlyStarted) setDaemonExplicitlyDisabled(false, options);
|
|
992
|
-
const
|
|
1321
|
+
const control = daemonControlPaths(options);
|
|
1322
|
+
const requestedSource = options.daemonSource ?? binFile("prim-daemon-server");
|
|
1323
|
+
if (!options.version && !requestedSource) {
|
|
1324
|
+
throw new Error("cannot determine runtime version: daemon is unavailable");
|
|
1325
|
+
}
|
|
1326
|
+
const requestedVersion = options.version ?? findPackageVersion(requestedSource);
|
|
1327
|
+
const selected = options.explicitlyStarted ? null : readDaemonLauncher(control.launcher);
|
|
1328
|
+
const precedence = selected ? compareSemver(selected.runtimeVersion, requestedVersion) : null;
|
|
1329
|
+
const retainSelected = selected !== null && (precedence === 1 || precedence === void 0 && selected.runtimeVersion !== requestedVersion);
|
|
1330
|
+
let runtime;
|
|
1331
|
+
let daemonConfig;
|
|
1332
|
+
const configuredApiUrl = (options.env ?? process.env).PRIM_API_URL;
|
|
1333
|
+
const apiUrl = configuredApiUrl ? normalizeApiUrl(configuredApiUrl) || void 0 : void 0;
|
|
1334
|
+
if (retainSelected && selected) {
|
|
1335
|
+
const usable = readUsableDaemonConfig(control.launcher);
|
|
1336
|
+
if (!usable) {
|
|
1337
|
+
throw new Error(
|
|
1338
|
+
`selected daemon runtime ${selected.runtimeVersion} is unavailable or corrupt; refusing automatic downgrade to ${requestedVersion}`
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
daemonConfig = {
|
|
1342
|
+
nodePath: usable.nodePath,
|
|
1343
|
+
daemonPath: usable.daemonPath,
|
|
1344
|
+
runtimeVersion: usable.runtimeVersion,
|
|
1345
|
+
...apiUrl ? { apiUrl } : {}
|
|
1346
|
+
};
|
|
1347
|
+
} else {
|
|
1348
|
+
runtime = stageRuntime({ ...options, version: requestedVersion });
|
|
1349
|
+
daemonConfig = {
|
|
1350
|
+
nodePath: runtime.manifest.nodePath,
|
|
1351
|
+
daemonPath: runtime.daemonPath,
|
|
1352
|
+
runtimeVersion: runtime.manifest.version,
|
|
1353
|
+
...apiUrl ? { apiUrl } : {}
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
993
1356
|
const servicePaths = launchdPaths(options);
|
|
994
1357
|
mkdirSync(dirname(servicePaths.logPath), { recursive: true, mode: RUNTIME_DIR_MODE });
|
|
995
1358
|
writeFileSync(servicePaths.logPath, "", { mode: RUNTIME_FILE_MODE, flag: "a" });
|
|
996
1359
|
chmodSync(servicePaths.logPath, RUNTIME_FILE_MODE);
|
|
1360
|
+
const launcher = generateDaemonLauncher(daemonConfig);
|
|
1361
|
+
atomicWrite(control.launcher, launcher.content, RUNTIME_LAUNCHER_MODE);
|
|
997
1362
|
const plist = generateLaunchAgentPlist({
|
|
998
|
-
|
|
999
|
-
daemonPath: runtime.paths.daemonEntry,
|
|
1000
|
-
runtimeVersion: runtime.manifest.version,
|
|
1363
|
+
launcherPath: control.launcher,
|
|
1001
1364
|
logPath: servicePaths.logPath,
|
|
1002
1365
|
workingDirectory: options.homeDir ?? homedir(),
|
|
1003
|
-
|
|
1366
|
+
label: options.label
|
|
1004
1367
|
});
|
|
1005
|
-
const plistChanged = atomicWrite(
|
|
1006
|
-
|
|
1007
|
-
|
|
1368
|
+
const plistChanged = atomicWrite(
|
|
1369
|
+
servicePaths.plistPath,
|
|
1370
|
+
plist,
|
|
1371
|
+
PLIST_FILE_MODE,
|
|
1372
|
+
options.validatePlist ?? validatePlist
|
|
1373
|
+
);
|
|
1374
|
+
const sleep2 = options.sleep ?? defaultSleep;
|
|
1375
|
+
const nowMs = options.nowMs ?? performance.now.bind(performance);
|
|
1376
|
+
const deadlineMs = nowMs() + TRANSITION_TIMEOUT_MS;
|
|
1377
|
+
const inspect = options.inspectDaemon ?? inspectDaemon;
|
|
1378
|
+
const transition = {
|
|
1379
|
+
paths: servicePaths,
|
|
1380
|
+
runner,
|
|
1381
|
+
inspect,
|
|
1382
|
+
sleep: sleep2,
|
|
1383
|
+
nowMs,
|
|
1384
|
+
deadlineMs,
|
|
1385
|
+
launcherPath: control.launcher,
|
|
1386
|
+
version: daemonConfig.runtimeVersion,
|
|
1387
|
+
revision: launcher.revision
|
|
1388
|
+
};
|
|
1389
|
+
const migrateLegacy = options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir(), deadlineMs, nowMs, sleep2));
|
|
1008
1390
|
let action = "none";
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
"bootstrap"
|
|
1015
|
-
);
|
|
1016
|
-
action = "reload";
|
|
1017
|
-
} else if (!service.loaded) {
|
|
1018
|
-
if (await (options.probe ?? daemonIsLive)(READY_PROBE_TIMEOUT_MS)) {
|
|
1019
|
-
await (options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir())))();
|
|
1391
|
+
const observed = await observe(transition);
|
|
1392
|
+
service = observed.service;
|
|
1393
|
+
if (!service.loaded) {
|
|
1394
|
+
if (await inspect(timeoutMs(nowMs, deadlineMs, READY_PROBE_TIMEOUT_MS))) {
|
|
1395
|
+
await migrateLegacy();
|
|
1020
1396
|
}
|
|
1021
|
-
|
|
1022
|
-
runner,
|
|
1023
|
-
["bootstrap", servicePaths.domainTarget, servicePaths.plistPath],
|
|
1024
|
-
"bootstrap"
|
|
1025
|
-
);
|
|
1397
|
+
await bootstrap(transition);
|
|
1026
1398
|
action = "bootstrap";
|
|
1027
|
-
} else {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
const responding = await waitForReady(
|
|
1040
|
-
options.probe ?? daemonIsLive,
|
|
1041
|
-
options.sleep ?? defaultSleep
|
|
1042
|
-
);
|
|
1043
|
-
service = getLaunchdService({ ...options, runner });
|
|
1044
|
-
const socketPid = responding ? await (options.identify ?? identifyDaemon)(READY_PROBE_TIMEOUT_MS) : null;
|
|
1045
|
-
const running = responding && service.loaded && service.pid !== void 0 && socketPid !== null && service.pid === socketPid;
|
|
1399
|
+
} else if (service.program !== control.launcher) {
|
|
1400
|
+
if (!service.program) throw new Error("launchd did not report the registered program");
|
|
1401
|
+
await reloadDefinition(transition, service.program);
|
|
1402
|
+
action = "reload";
|
|
1403
|
+
} else if (options.forceRestart || !desiredDaemonIsRunning(transition, observed)) {
|
|
1404
|
+
await kickstart(transition);
|
|
1405
|
+
action = "kickstart";
|
|
1406
|
+
}
|
|
1407
|
+
const ready = await waitForDesired(transition, action === "bootstrap" || action === "reload");
|
|
1408
|
+
service = ready.service;
|
|
1409
|
+
const responding = ready.identity !== null;
|
|
1410
|
+
const running = desiredDaemonIsRunning(transition, ready);
|
|
1046
1411
|
return {
|
|
1047
1412
|
state: running ? "running" : "unhealthy",
|
|
1048
1413
|
action,
|
|
1049
|
-
runtimeChanged: runtime
|
|
1414
|
+
runtimeChanged: runtime?.changed ?? false,
|
|
1050
1415
|
plistChanged,
|
|
1051
1416
|
responding,
|
|
1052
|
-
...
|
|
1417
|
+
...ready.identity ? { socketPid: ready.identity.pid } : {},
|
|
1053
1418
|
service,
|
|
1054
|
-
runtime
|
|
1419
|
+
...runtime ? { runtime } : {}
|
|
1055
1420
|
};
|
|
1056
1421
|
}
|
|
1057
1422
|
async function bootoutMacDaemon(options = {}) {
|
|
@@ -1060,14 +1425,39 @@ async function bootoutMacDaemon(options = {}) {
|
|
|
1060
1425
|
async function bootoutMacDaemonLocked(options) {
|
|
1061
1426
|
setDaemonExplicitlyDisabled(true, options);
|
|
1062
1427
|
const runner = options.runner ?? runLaunchctl;
|
|
1428
|
+
const inspect = options.inspectDaemon ?? inspectDaemon;
|
|
1429
|
+
const sleep2 = options.sleep ?? defaultSleep;
|
|
1430
|
+
const nowMs = options.nowMs ?? performance.now.bind(performance);
|
|
1431
|
+
const deadlineMs = nowMs() + TRANSITION_TIMEOUT_MS;
|
|
1063
1432
|
const paths = launchdPaths(options);
|
|
1433
|
+
const migrateLegacy = options.migrateLegacy ?? (() => stopVerifiedLegacyDaemon(options.homeDir ?? homedir(), deadlineMs, nowMs, sleep2));
|
|
1064
1434
|
const service = getLaunchdService({ ...options, runner });
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1435
|
+
const wasLoaded = service.loaded;
|
|
1436
|
+
if (wasLoaded) {
|
|
1437
|
+
const result = runner(["bootout", paths.serviceTarget], timeoutMs(nowMs, deadlineMs));
|
|
1438
|
+
if (result.status !== 0) {
|
|
1439
|
+
const error = new LaunchctlError("bootout", result);
|
|
1440
|
+
const after = getLaunchdService({
|
|
1441
|
+
...options,
|
|
1442
|
+
runner,
|
|
1443
|
+
timeoutMs: timeoutMs(nowMs, deadlineMs)
|
|
1444
|
+
});
|
|
1445
|
+
if (!error.timedOut && error.status !== TRANSITION_IN_PROGRESS && after.loaded) {
|
|
1446
|
+
throw error;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
await waitForAbsence({
|
|
1450
|
+
options,
|
|
1451
|
+
runner,
|
|
1452
|
+
inspect,
|
|
1453
|
+
sleep: sleep2,
|
|
1454
|
+
nowMs,
|
|
1455
|
+
deadlineMs,
|
|
1456
|
+
previousPid: service.pid
|
|
1457
|
+
});
|
|
1068
1458
|
}
|
|
1069
|
-
const legacyStopped = await (
|
|
1070
|
-
return { wasLoaded
|
|
1459
|
+
const legacyStopped = await inspect(timeoutMs(nowMs, deadlineMs, READY_PROBE_TIMEOUT_MS)) ? await migrateLegacy() : false;
|
|
1460
|
+
return { wasLoaded, legacyStopped, service };
|
|
1071
1461
|
}
|
|
1072
1462
|
|
|
1073
1463
|
// src/commands/claude-install.ts
|
|
@@ -1703,7 +2093,7 @@ var LOG_FILE_MODE = 384;
|
|
|
1703
2093
|
var STOP_TIMEOUT_MS = 5e3;
|
|
1704
2094
|
var STOP_POLL_MS = 100;
|
|
1705
2095
|
var STATUS_PROBE_TIMEOUT_MS = 500;
|
|
1706
|
-
var
|
|
2096
|
+
var READY_TIMEOUT_MS = 5e3;
|
|
1707
2097
|
var READY_POLL_MS2 = 100;
|
|
1708
2098
|
var READY_PROBE_TIMEOUT_MS2 = 250;
|
|
1709
2099
|
var HEALTHY_TIMEOUT_MS = 3e4;
|
|
@@ -1717,13 +2107,6 @@ function formatDaemonLifecycleMessage(message, decisionIngestion) {
|
|
|
1717
2107
|
function formatCurrentDaemonLifecycleMessage(message) {
|
|
1718
2108
|
return formatDaemonLifecycleMessage(message, decisionIngestionStatus(process.cwd()));
|
|
1719
2109
|
}
|
|
1720
|
-
var MAX_HEALTH_ERROR_LENGTH = 240;
|
|
1721
|
-
function boundedHealthError(value) {
|
|
1722
|
-
if (!value) return void 0;
|
|
1723
|
-
const clean = stripControlChars(value).replace(/\s+/g, " ").trim();
|
|
1724
|
-
if (!clean) return void 0;
|
|
1725
|
-
return clean.length <= MAX_HEALTH_ERROR_LENGTH ? clean : `${clean.slice(0, MAX_HEALTH_ERROR_LENGTH - 1)}\u2026`;
|
|
1726
|
-
}
|
|
1727
2110
|
function daemonDegradedReason(snapshot) {
|
|
1728
2111
|
if (!snapshot || snapshot.healthy !== false) return void 0;
|
|
1729
2112
|
if (snapshot.needsReauth) {
|
|
@@ -1783,8 +2166,8 @@ function openDaemonLog(configDir = CONFIG_DIR) {
|
|
|
1783
2166
|
}
|
|
1784
2167
|
return openSync2(join4(configDir, "daemon.log"), "a", LOG_FILE_MODE);
|
|
1785
2168
|
}
|
|
1786
|
-
async function
|
|
1787
|
-
const deadline = Date.now() +
|
|
2169
|
+
async function waitForReady() {
|
|
2170
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
1788
2171
|
while (Date.now() < deadline) {
|
|
1789
2172
|
if (await daemonIsLive(READY_PROBE_TIMEOUT_MS2)) {
|
|
1790
2173
|
return true;
|
|
@@ -1888,7 +2271,7 @@ async function detachedDaemonStart(opts) {
|
|
|
1888
2271
|
if (logFd !== void 0) {
|
|
1889
2272
|
closeSync2(logFd);
|
|
1890
2273
|
}
|
|
1891
|
-
const live = await
|
|
2274
|
+
const live = await waitForReady();
|
|
1892
2275
|
if (live) {
|
|
1893
2276
|
const after = readPidfile();
|
|
1894
2277
|
process.stderr.write(
|
|
@@ -1899,7 +2282,7 @@ async function detachedDaemonStart(opts) {
|
|
|
1899
2282
|
return;
|
|
1900
2283
|
}
|
|
1901
2284
|
process.stderr.write(
|
|
1902
|
-
`[prim] \u2717 daemon start: spawned but the socket did not respond within ${
|
|
2285
|
+
`[prim] \u2717 daemon start: spawned but the socket did not respond within ${READY_TIMEOUT_MS}ms (check that \`${DAEMON_BIN}\` resolves, and see ${LOG_PATH})
|
|
1903
2286
|
`
|
|
1904
2287
|
);
|
|
1905
2288
|
console.log(JSON.stringify({ started: false }, null, 2));
|
|
@@ -1981,8 +2364,9 @@ async function macDaemonStart(forceRestart = false) {
|
|
|
1981
2364
|
const healthy = daemonStartIsHealthy(serviceReady, snapshot, expectedVersion);
|
|
1982
2365
|
if (healthy) {
|
|
1983
2366
|
const verb = result.action === "none" ? "already running" : "started";
|
|
2367
|
+
const servicePid = result.service.loaded ? result.service.pid : void 0;
|
|
1984
2368
|
process.stderr.write(
|
|
1985
|
-
`${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ${verb} under launchd (pid=${snapshot?.pid ??
|
|
2369
|
+
`${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ${verb} under launchd (pid=${snapshot?.pid ?? servicePid ?? "?"})`)}
|
|
1986
2370
|
`
|
|
1987
2371
|
);
|
|
1988
2372
|
} else {
|
|
@@ -1999,7 +2383,7 @@ async function macDaemonStart(forceRestart = false) {
|
|
|
1999
2383
|
started: healthy,
|
|
2000
2384
|
supervised: true,
|
|
2001
2385
|
action: result.action,
|
|
2002
|
-
pid: snapshot?.pid ?? result.service.pid,
|
|
2386
|
+
pid: snapshot?.pid ?? (result.service.loaded ? result.service.pid : void 0),
|
|
2003
2387
|
loaded: result.service.loaded,
|
|
2004
2388
|
responding: result.responding,
|
|
2005
2389
|
healthy,
|
|
@@ -2071,6 +2455,19 @@ function classifyStatus(pidAlive, responding, snapshot, pid) {
|
|
|
2071
2455
|
return { json: { running: true, responding: true, ...snapshot }, exitCode: EXIT_OK2 };
|
|
2072
2456
|
}
|
|
2073
2457
|
function classifyLaunchdStatus(service, responding, snapshot, disabled) {
|
|
2458
|
+
if (!service) {
|
|
2459
|
+
return {
|
|
2460
|
+
json: {
|
|
2461
|
+
running: responding,
|
|
2462
|
+
responding,
|
|
2463
|
+
supervised: true,
|
|
2464
|
+
state: "unknown",
|
|
2465
|
+
disabled,
|
|
2466
|
+
...snapshot ?? {}
|
|
2467
|
+
},
|
|
2468
|
+
exitCode: EXIT_BOOTING
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2074
2471
|
if (!service.loaded) {
|
|
2075
2472
|
if (responding) {
|
|
2076
2473
|
return {
|
|
@@ -2198,7 +2595,13 @@ async function detachedDaemonStatus() {
|
|
|
2198
2595
|
}
|
|
2199
2596
|
}
|
|
2200
2597
|
async function macDaemonStatus() {
|
|
2201
|
-
|
|
2598
|
+
let service;
|
|
2599
|
+
let launchdError;
|
|
2600
|
+
try {
|
|
2601
|
+
service = getLaunchdService();
|
|
2602
|
+
} catch (error) {
|
|
2603
|
+
launchdError = error;
|
|
2604
|
+
}
|
|
2202
2605
|
const responding = await daemonIsLive(STATUS_PROBE_TIMEOUT_MS);
|
|
2203
2606
|
const snapshot = responding ? await daemonRequest(
|
|
2204
2607
|
"status_snapshot",
|
|
@@ -2207,7 +2610,13 @@ async function macDaemonStatus() {
|
|
|
2207
2610
|
) : null;
|
|
2208
2611
|
const disabled = daemonExplicitlyDisabled();
|
|
2209
2612
|
const { json, exitCode } = classifyLaunchdStatus(service, responding, snapshot, disabled);
|
|
2210
|
-
if (!service
|
|
2613
|
+
if (!service) {
|
|
2614
|
+
const detail = boundedHealthError(
|
|
2615
|
+
launchdError instanceof Error ? launchdError.message : String(launchdError)
|
|
2616
|
+
);
|
|
2617
|
+
process.stderr.write(`[prim] \u2717 launchd status unavailable${detail ? `: ${detail}` : ""}
|
|
2618
|
+
`);
|
|
2619
|
+
} else if (!service.loaded && responding) {
|
|
2211
2620
|
process.stderr.write(
|
|
2212
2621
|
`[prim] \u25CC daemon pid=${snapshot?.pid ?? "?"} is live but not supervised by launchd
|
|
2213
2622
|
`
|
|
@@ -3347,8 +3756,13 @@ async function checkDaemon() {
|
|
|
3347
3756
|
if (process.platform === "darwin") {
|
|
3348
3757
|
try {
|
|
3349
3758
|
service = getLaunchdService();
|
|
3350
|
-
} catch {
|
|
3351
|
-
|
|
3759
|
+
} catch (error) {
|
|
3760
|
+
const detail = boundedHealthError(error instanceof Error ? error.message : String(error));
|
|
3761
|
+
return {
|
|
3762
|
+
name: "daemon",
|
|
3763
|
+
status: "fail",
|
|
3764
|
+
detail: `launchd status unavailable${detail ? `: ${detail}` : ""}`
|
|
3765
|
+
};
|
|
3352
3766
|
}
|
|
3353
3767
|
}
|
|
3354
3768
|
const snapshot = await daemonRequest(
|
|
@@ -3443,6 +3857,46 @@ function checkWorkspaceIdentity() {
|
|
|
3443
3857
|
};
|
|
3444
3858
|
}
|
|
3445
3859
|
}
|
|
3860
|
+
function classifyRepositoryBinding(value) {
|
|
3861
|
+
return isValidRepoSyncId(value) ? {
|
|
3862
|
+
name: "repo-binding",
|
|
3863
|
+
status: "ok",
|
|
3864
|
+
detail: "valid local repository binding ready"
|
|
3865
|
+
} : {
|
|
3866
|
+
name: "repo-binding",
|
|
3867
|
+
status: "fail",
|
|
3868
|
+
detail: "missing or invalid local prim.repoSyncId \u2014 run `prim enable`"
|
|
3869
|
+
};
|
|
3870
|
+
}
|
|
3871
|
+
function checkRepositoryBinding() {
|
|
3872
|
+
return classifyRepositoryBinding(repoSyncId(process.cwd()));
|
|
3873
|
+
}
|
|
3874
|
+
function classifyPostCommitHook(inspection) {
|
|
3875
|
+
if (inspection.covered) {
|
|
3876
|
+
return {
|
|
3877
|
+
name: "post-commit",
|
|
3878
|
+
status: "ok",
|
|
3879
|
+
detail: `effective and executable \xB7 ${inspection.kind} \xB7 ${inspection.hookPath}`
|
|
3880
|
+
};
|
|
3881
|
+
}
|
|
3882
|
+
return {
|
|
3883
|
+
name: "post-commit",
|
|
3884
|
+
status: "fail",
|
|
3885
|
+
detail: `${inspection.reason ?? "uncovered"} \xB7 ${inspection.hookPath}`
|
|
3886
|
+
};
|
|
3887
|
+
}
|
|
3888
|
+
function checkPostCommitHook() {
|
|
3889
|
+
try {
|
|
3890
|
+
return classifyPostCommitHook(inspectEffectivePostCommitHook());
|
|
3891
|
+
} catch (error) {
|
|
3892
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
3893
|
+
return {
|
|
3894
|
+
name: "post-commit",
|
|
3895
|
+
status: /not a git repository/iu.test(detail) ? "warn" : "fail",
|
|
3896
|
+
detail: detail.slice(0, 120)
|
|
3897
|
+
};
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3446
3900
|
function checkFeedbackHooks() {
|
|
3447
3901
|
try {
|
|
3448
3902
|
const status = performStatus();
|
|
@@ -3472,6 +3926,9 @@ function parseMovesStatus(value) {
|
|
|
3472
3926
|
if (status.captureState !== "enabled" && status.captureState !== "disabled" || !nullableNumber(status.latestIngestAt) || !nullableNumber(status.latestClassificationAt) || !nullableNumber(status.highWaterMark) || typeof status.pendingSessionCount !== "number" || !Number.isInteger(status.pendingSessionCount) || status.pendingSessionCount < 0 || typeof status.sampled !== "boolean") {
|
|
3473
3927
|
throw new Error("moves status returned an invalid response");
|
|
3474
3928
|
}
|
|
3929
|
+
if (status.oldestPendingAt !== void 0 && !nullableNumber(status.oldestPendingAt) || status.oldestPendingAgeMs !== void 0 && !nullableNumber(status.oldestPendingAgeMs) || typeof status.oldestPendingAgeMs === "number" && status.oldestPendingAgeMs < 0 || status.pendingCommitCorrelationCount !== void 0 && (!Number.isInteger(status.pendingCommitCorrelationCount) || status.pendingCommitCorrelationCount < 0)) {
|
|
3930
|
+
throw new Error("moves status returned invalid pending-age fields");
|
|
3931
|
+
}
|
|
3475
3932
|
return status;
|
|
3476
3933
|
}
|
|
3477
3934
|
function classifyMovesStatus(status) {
|
|
@@ -3482,10 +3939,11 @@ function classifyMovesStatus(status) {
|
|
|
3482
3939
|
};
|
|
3483
3940
|
let classification;
|
|
3484
3941
|
if (status.pendingSessionCount > 0) {
|
|
3942
|
+
const oldest = typeof status.oldestPendingAgeMs === "number" ? ` \xB7 oldest ${String(Math.round(status.oldestPendingAgeMs / MS_PER_SECOND))}s` : typeof status.oldestPendingAt === "number" ? ` \xB7 oldest ${String(Math.max(0, Math.round((Date.now() - status.oldestPendingAt) / MS_PER_SECOND)))}s` : "";
|
|
3485
3943
|
classification = {
|
|
3486
3944
|
name: "classification",
|
|
3487
3945
|
status: "warn",
|
|
3488
|
-
detail: `${String(status.pendingSessionCount)} session(s) pending${status.sampled ? " in a bounded sample" : ""}`
|
|
3946
|
+
detail: `${String(status.pendingSessionCount)} session(s) pending${oldest}${status.sampled ? " in a bounded sample" : ""}`
|
|
3489
3947
|
};
|
|
3490
3948
|
} else if (status.sampled) {
|
|
3491
3949
|
classification = {
|
|
@@ -3500,7 +3958,16 @@ function classifyMovesStatus(status) {
|
|
|
3500
3958
|
detail: status.latestClassificationAt === null ? "no sessions awaiting classification" : `caught up \xB7 last ${new Date(status.latestClassificationAt).toISOString()}`
|
|
3501
3959
|
};
|
|
3502
3960
|
}
|
|
3503
|
-
|
|
3961
|
+
const correlation = status.pendingCommitCorrelationCount === void 0 ? void 0 : status.pendingCommitCorrelationCount > 0 ? {
|
|
3962
|
+
name: "commit-correlation",
|
|
3963
|
+
status: "warn",
|
|
3964
|
+
detail: `${String(status.pendingCommitCorrelationCount)} commit(s) awaiting evidence correlation`
|
|
3965
|
+
} : {
|
|
3966
|
+
name: "commit-correlation",
|
|
3967
|
+
status: "ok",
|
|
3968
|
+
detail: "caught up"
|
|
3969
|
+
};
|
|
3970
|
+
return correlation ? [capture, classification, correlation] : [capture, classification];
|
|
3504
3971
|
}
|
|
3505
3972
|
async function checkBackend() {
|
|
3506
3973
|
try {
|
|
@@ -3549,6 +4016,8 @@ async function collectChecks() {
|
|
|
3549
4016
|
checkStranded(),
|
|
3550
4017
|
checkFeedbackHooks(),
|
|
3551
4018
|
checkWorkspaceIdentity(),
|
|
4019
|
+
checkRepositoryBinding(),
|
|
4020
|
+
checkPostCommitHook(),
|
|
3552
4021
|
...backend,
|
|
3553
4022
|
await checkFeedbackCapability()
|
|
3554
4023
|
];
|
|
@@ -3932,6 +4401,7 @@ import { Option as Option2 } from "commander";
|
|
|
3932
4401
|
var PRE_COMMIT = { hookName: "pre-commit", binName: "prim-pre-commit" };
|
|
3933
4402
|
var POST_COMMIT = { hookName: "post-commit", binName: "prim-post-commit" };
|
|
3934
4403
|
var HOOKS = [PRE_COMMIT, POST_COMMIT];
|
|
4404
|
+
var GIT_TIMEOUT_MS = 1e3;
|
|
3935
4405
|
function blockMarkers(spec) {
|
|
3936
4406
|
return {
|
|
3937
4407
|
start: `# >>> prim ${spec.hookName} hook >>>`,
|
|
@@ -3958,12 +4428,14 @@ ${hookShim(spec.binName)}
|
|
|
3958
4428
|
`;
|
|
3959
4429
|
}
|
|
3960
4430
|
function huskyBlock(spec) {
|
|
4431
|
+
if (spec.hookName === POST_COMMIT.hookName) return postCommitHookBlock();
|
|
3961
4432
|
const { start, end } = blockMarkers(spec);
|
|
3962
4433
|
return `${start}
|
|
3963
4434
|
${hookShim(spec.binName)}
|
|
3964
4435
|
${end}`;
|
|
3965
4436
|
}
|
|
3966
4437
|
function gatedBlock(spec) {
|
|
4438
|
+
if (spec.hookName === POST_COMMIT.hookName) return postCommitHookBlock();
|
|
3967
4439
|
const { start, end } = blockMarkers(spec);
|
|
3968
4440
|
return `${start}
|
|
3969
4441
|
${gatedShim(spec.binName)}
|
|
@@ -3972,6 +4444,21 @@ ${end}`;
|
|
|
3972
4444
|
function mergePrimBlock(hookPath, block, binName) {
|
|
3973
4445
|
if (existsSync7(hookPath)) {
|
|
3974
4446
|
const existing = readFileSync6(hookPath, "utf-8");
|
|
4447
|
+
const start = block.slice(0, block.indexOf("\n"));
|
|
4448
|
+
const end = block.slice(block.lastIndexOf("\n") + 1);
|
|
4449
|
+
const starts = existing.split(start).length - 1;
|
|
4450
|
+
const ends = existing.split(end).length - 1;
|
|
4451
|
+
if (starts !== 0 || ends !== 0) {
|
|
4452
|
+
if (starts !== 1 || ends !== 1 || existing.indexOf(end) < existing.indexOf(start)) {
|
|
4453
|
+
throw new Error(`malformed Prim hook markers in ${hookPath}`);
|
|
4454
|
+
}
|
|
4455
|
+
const from = existing.indexOf(start);
|
|
4456
|
+
const through = existing.indexOf(end) + end.length;
|
|
4457
|
+
const refreshed = existing.slice(0, from) + block + existing.slice(through);
|
|
4458
|
+
if (refreshed === existing) return false;
|
|
4459
|
+
writeFileSync4(hookPath, refreshed, { mode: 493 });
|
|
4460
|
+
return true;
|
|
4461
|
+
}
|
|
3975
4462
|
if (containsPrimHook(existing, binName)) return false;
|
|
3976
4463
|
const separator = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
3977
4464
|
writeFileSync4(hookPath, `${existing}${separator}${block}
|
|
@@ -4027,7 +4514,7 @@ function installToHusky(gitRoot, spec = PRE_COMMIT) {
|
|
|
4027
4514
|
}
|
|
4028
4515
|
}
|
|
4029
4516
|
function installToDotGit(gitRoot, spec = PRE_COMMIT) {
|
|
4030
|
-
const hooksDir =
|
|
4517
|
+
const hooksDir = projectHooksDir(gitRoot);
|
|
4031
4518
|
const hookPath = resolve2(hooksDir, spec.hookName);
|
|
4032
4519
|
if (!existsSync7(hooksDir)) {
|
|
4033
4520
|
mkdirSync5(hooksDir, { recursive: true });
|
|
@@ -4045,6 +4532,18 @@ function installToDotGit(gitRoot, spec = PRE_COMMIT) {
|
|
|
4045
4532
|
writeFileSync4(hookPath, dotGitScript(spec), { mode: 493 });
|
|
4046
4533
|
console.log(`Installed ${spec.hookName} hook at ${hookPath}`);
|
|
4047
4534
|
}
|
|
4535
|
+
function projectHooksDir(gitRoot) {
|
|
4536
|
+
const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
|
4537
|
+
cwd: gitRoot,
|
|
4538
|
+
encoding: "utf-8",
|
|
4539
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
4540
|
+
timeout: GIT_TIMEOUT_MS
|
|
4541
|
+
}).trim();
|
|
4542
|
+
if (commonDir === "") {
|
|
4543
|
+
throw new Error("git returned an empty common directory");
|
|
4544
|
+
}
|
|
4545
|
+
return resolve2(gitRoot, commonDir, "hooks");
|
|
4546
|
+
}
|
|
4048
4547
|
var PRIM_GIT_HOOKS_DIR = join6(homedir6(), ".config", "prim", "git-hooks");
|
|
4049
4548
|
function expandTilde(p) {
|
|
4050
4549
|
return p.startsWith("~/") ? join6(homedir6(), p.slice(2)) : p;
|
|
@@ -4056,7 +4555,8 @@ function gitConfigGet(level) {
|
|
|
4056
4555
|
try {
|
|
4057
4556
|
return execFileSync("git", ["config", level, "--get", "core.hooksPath"], {
|
|
4058
4557
|
encoding: "utf-8",
|
|
4059
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
4558
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
4559
|
+
timeout: GIT_TIMEOUT_MS
|
|
4060
4560
|
}).trim();
|
|
4061
4561
|
} catch {
|
|
4062
4562
|
return "";
|
|
@@ -4084,14 +4584,18 @@ var PASSTHROUGH_HOOKS = [
|
|
|
4084
4584
|
];
|
|
4085
4585
|
function globalHookScript(spec) {
|
|
4086
4586
|
const chainExit = spec.hookName === "pre-commit" ? "|| exit $?" : "|| true";
|
|
4587
|
+
const invocation = spec.hookName === POST_COMMIT.hookName ? postCommitHookBlock() : gatedShim(spec.binName);
|
|
4588
|
+
const beforeComments = spec.hookName === POST_COMMIT.hookName ? `${invocation}
|
|
4589
|
+
` : "";
|
|
4590
|
+
const afterComments = spec.hookName === POST_COMMIT.hookName ? "" : `${invocation}
|
|
4591
|
+
`;
|
|
4087
4592
|
return `#!/bin/sh
|
|
4088
|
-
# prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
|
|
4593
|
+
${beforeComments}# prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
|
|
4089
4594
|
# Install/uninstall: prim hooks install|uninstall --scope user
|
|
4090
4595
|
# Runs prim only where activated \u2014 'prim enable' (this repo) or
|
|
4091
4596
|
# 'git config --global prim.active true' (every repo). Chains to the repo's own
|
|
4092
4597
|
# hook regardless, so inactive repos are unaffected.
|
|
4093
|
-
${
|
|
4094
|
-
common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
|
|
4598
|
+
${afterComments}common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
|
|
4095
4599
|
repo_hook="$common_dir/hooks/${spec.hookName}"
|
|
4096
4600
|
if [ -x "$repo_hook" ] && ! grep -q '${PRIM_MANAGED_MARK}' "$repo_hook" 2>/dev/null; then
|
|
4097
4601
|
"$repo_hook" "$@" ${chainExit}
|
|
@@ -4116,18 +4620,29 @@ function writeOwnHooks() {
|
|
|
4116
4620
|
mkdirSync5(PRIM_GIT_HOOKS_DIR, { recursive: true });
|
|
4117
4621
|
}
|
|
4118
4622
|
for (const spec of HOOKS) {
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4623
|
+
const path = resolve2(PRIM_GIT_HOOKS_DIR, spec.hookName);
|
|
4624
|
+
if (spec.hookName === POST_COMMIT.hookName) {
|
|
4625
|
+
ensurePostCommitHookAtPath(path, globalHookScript(spec));
|
|
4626
|
+
} else {
|
|
4627
|
+
writeFileSync4(path, globalHookScript(spec), { mode: 493 });
|
|
4628
|
+
}
|
|
4122
4629
|
}
|
|
4123
4630
|
for (const name of PASSTHROUGH_HOOKS) {
|
|
4124
4631
|
writeFileSync4(resolve2(PRIM_GIT_HOOKS_DIR, name), passThroughScript(name), { mode: 493 });
|
|
4125
4632
|
}
|
|
4126
4633
|
}
|
|
4127
4634
|
function appendPrimBlock(hookPath, spec) {
|
|
4635
|
+
if (spec.hookName === POST_COMMIT.hookName) {
|
|
4636
|
+
ensurePostCommitHookAtPath(hookPath);
|
|
4637
|
+
return;
|
|
4638
|
+
}
|
|
4128
4639
|
mergePrimBlock(hookPath, gatedBlock(spec), spec.binName);
|
|
4129
4640
|
}
|
|
4130
4641
|
function stripPrimBlock(hookPath, spec) {
|
|
4642
|
+
if (spec.hookName === POST_COMMIT.hookName) {
|
|
4643
|
+
uninstallPostCommitHookAtPath(hookPath);
|
|
4644
|
+
return;
|
|
4645
|
+
}
|
|
4131
4646
|
if (!existsSync7(hookPath)) return;
|
|
4132
4647
|
const existing = readFileSync6(hookPath, "utf-8");
|
|
4133
4648
|
const primCreated = existing.includes(PRIM_CREATED_MARK);
|
|
@@ -4159,7 +4674,10 @@ function installGlobalHooks(opts = {}) {
|
|
|
4159
4674
|
);
|
|
4160
4675
|
}
|
|
4161
4676
|
writeOwnHooks();
|
|
4162
|
-
execFileSync("git", ["config", "--global", "core.hooksPath", PRIM_GIT_HOOKS_DIR]
|
|
4677
|
+
execFileSync("git", ["config", "--global", "core.hooksPath", PRIM_GIT_HOOKS_DIR], {
|
|
4678
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
4679
|
+
timeout: GIT_TIMEOUT_MS
|
|
4680
|
+
});
|
|
4163
4681
|
console.log(
|
|
4164
4682
|
`Installed prim global git hooks; set core.hooksPath to ${PRIM_GIT_HOOKS_DIR}. Repos are opt-in: run \`prim enable\` in each repo to capture, or \`git config --global prim.active true\` for all.`
|
|
4165
4683
|
);
|
|
@@ -4186,7 +4704,10 @@ function uninstallGlobalHooks() {
|
|
|
4186
4704
|
const p = resolve2(PRIM_GIT_HOOKS_DIR, name);
|
|
4187
4705
|
if (existsSync7(p)) unlinkSync2(p);
|
|
4188
4706
|
}
|
|
4189
|
-
execFileSync("git", ["config", "--global", "--unset", "core.hooksPath"]
|
|
4707
|
+
execFileSync("git", ["config", "--global", "--unset", "core.hooksPath"], {
|
|
4708
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
4709
|
+
timeout: GIT_TIMEOUT_MS
|
|
4710
|
+
});
|
|
4190
4711
|
console.log("Removed prim global git hooks and unset core.hooksPath.");
|
|
4191
4712
|
return;
|
|
4192
4713
|
}
|
|
@@ -4201,13 +4722,17 @@ function uninstallGlobalHooks() {
|
|
|
4201
4722
|
console.log("No prim global git hooks found.");
|
|
4202
4723
|
}
|
|
4203
4724
|
function installHooks(gitRoot, target) {
|
|
4204
|
-
for (const spec of HOOKS) {
|
|
4725
|
+
for (const spec of HOOKS.filter((candidate) => candidate !== POST_COMMIT)) {
|
|
4205
4726
|
if (target === "husky") {
|
|
4206
4727
|
installToHusky(gitRoot, spec);
|
|
4207
4728
|
} else {
|
|
4208
4729
|
installToDotGit(gitRoot, spec);
|
|
4209
4730
|
}
|
|
4210
4731
|
}
|
|
4732
|
+
const postCommit = ensureEffectivePostCommitHook(gitRoot);
|
|
4733
|
+
console.log(
|
|
4734
|
+
`${postCommit.changed ? "Installed" : "Refreshed"} effective post-commit hook at ${postCommit.path}.`
|
|
4735
|
+
);
|
|
4211
4736
|
}
|
|
4212
4737
|
function registerHooksCommands(program2) {
|
|
4213
4738
|
const hooks = program2.command("hooks").description("Manage git hooks");
|
|
@@ -4269,8 +4794,9 @@ function registerHooksCommands(program2) {
|
|
|
4269
4794
|
return;
|
|
4270
4795
|
}
|
|
4271
4796
|
const gitRoot = getGitRoot();
|
|
4272
|
-
|
|
4273
|
-
|
|
4797
|
+
const hooksDir = projectHooksDir(gitRoot);
|
|
4798
|
+
for (const spec of HOOKS.filter((candidate) => candidate !== POST_COMMIT)) {
|
|
4799
|
+
const hookPath = resolve2(hooksDir, spec.hookName);
|
|
4274
4800
|
if (!existsSync7(hookPath)) {
|
|
4275
4801
|
console.log(`No ${spec.hookName} hook found.`);
|
|
4276
4802
|
continue;
|
|
@@ -4282,6 +4808,14 @@ function registerHooksCommands(program2) {
|
|
|
4282
4808
|
console.log(`Left ${spec.hookName} hook at ${hookPath} untouched (not a prim hook).`);
|
|
4283
4809
|
}
|
|
4284
4810
|
}
|
|
4811
|
+
const postCommit = uninstallProjectPostCommitHook(gitRoot);
|
|
4812
|
+
if (!postCommit.changed) {
|
|
4813
|
+
console.log(`No Prim post-commit block found at ${postCommit.path}.`);
|
|
4814
|
+
} else if (postCommit.removedFile) {
|
|
4815
|
+
console.log(`Removed Prim-created post-commit hook at ${postCommit.path}.`);
|
|
4816
|
+
} else {
|
|
4817
|
+
console.log(`Removed the Prim post-commit block from ${postCommit.path}.`);
|
|
4818
|
+
}
|
|
4285
4819
|
});
|
|
4286
4820
|
}
|
|
4287
4821
|
|
|
@@ -4725,12 +5259,6 @@ function planSetupSteps(opts) {
|
|
|
4725
5259
|
args: ["daemon", "start"],
|
|
4726
5260
|
required: true
|
|
4727
5261
|
});
|
|
4728
|
-
steps.push({
|
|
4729
|
-
key: "health",
|
|
4730
|
-
label: "Capture health",
|
|
4731
|
-
args: ["doctor"],
|
|
4732
|
-
required: true
|
|
4733
|
-
});
|
|
4734
5262
|
} else {
|
|
4735
5263
|
steps.push({
|
|
4736
5264
|
key: "daemon-opt-out",
|
|
@@ -4749,10 +5277,18 @@ function planSetupSteps(opts) {
|
|
|
4749
5277
|
steps.push({ key: "skill", label: "Agent skill", args: skillArgs, required: true });
|
|
4750
5278
|
steps.push({
|
|
4751
5279
|
key: "enable",
|
|
4752
|
-
label: "
|
|
5280
|
+
label: "Activate this repo",
|
|
4753
5281
|
args: ["enable"],
|
|
4754
5282
|
required: true
|
|
4755
5283
|
});
|
|
5284
|
+
if (opts.daemon) {
|
|
5285
|
+
steps.push({
|
|
5286
|
+
key: "health",
|
|
5287
|
+
label: "Capture health",
|
|
5288
|
+
args: ["doctor"],
|
|
5289
|
+
required: true
|
|
5290
|
+
});
|
|
5291
|
+
}
|
|
4756
5292
|
return steps;
|
|
4757
5293
|
}
|
|
4758
5294
|
var CONFLICT_SESSION = "session";
|
|
@@ -4890,7 +5426,10 @@ function registerSetupCommand(program2, dependencies = {}) {
|
|
|
4890
5426
|
note("pre-authorize \xB7 writing prim allow-rule (user scope)\u2026");
|
|
4891
5427
|
results.preauth = run(["claude", "preauth", "--scope", "user"]).code === 0 ? "ok" : "skipped";
|
|
4892
5428
|
}
|
|
4893
|
-
|
|
5429
|
+
const setupSteps = planSetupSteps({ agent, daemon: opts.daemon, scope });
|
|
5430
|
+
for (const step of setupSteps.filter(
|
|
5431
|
+
(candidate) => candidate.key !== "enable" && candidate.key !== "health"
|
|
5432
|
+
)) {
|
|
4894
5433
|
note(`${step.label} \xB7 installing\u2026`);
|
|
4895
5434
|
const { code } = run(step.args);
|
|
4896
5435
|
results[step.key] = code === 0 ? "ok" : step.required ? "failed" : "skipped";
|
|
@@ -4907,6 +5446,18 @@ function registerSetupCommand(program2, dependencies = {}) {
|
|
|
4907
5446
|
);
|
|
4908
5447
|
}
|
|
4909
5448
|
}
|
|
5449
|
+
const enableStep = setupSteps.find((candidate) => candidate.key === "enable");
|
|
5450
|
+
if (enableStep) {
|
|
5451
|
+
note(`${enableStep.label} \xB7 installing\u2026`);
|
|
5452
|
+
const { code } = run(enableStep.args);
|
|
5453
|
+
results[enableStep.key] = code === 0 ? "ok" : enableStep.required ? "failed" : "skipped";
|
|
5454
|
+
}
|
|
5455
|
+
const healthStep = setupSteps.find((candidate) => candidate.key === "health");
|
|
5456
|
+
if (healthStep) {
|
|
5457
|
+
note(`${healthStep.label} \xB7 verifying\u2026`);
|
|
5458
|
+
const { code } = run(healthStep.args);
|
|
5459
|
+
results[healthStep.key] = code === 0 ? "ok" : healthStep.required ? "failed" : "skipped";
|
|
5460
|
+
}
|
|
4910
5461
|
note("welcome");
|
|
4911
5462
|
results.welcome = run(["welcome", "--agent", agent]).code === 0 ? "ok" : "failed";
|
|
4912
5463
|
const failed = Object.entries(results).filter(([, v]) => v === "failed").map(([k]) => k);
|