@primitive.ai/prim 0.1.0-alpha.92 → 0.1.0-alpha.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -10
- package/SKILL.md +5 -2
- package/dist/{chunk-67VNLLBN.js → chunk-2GP5UFV7.js} +1 -0
- package/dist/{chunk-26P57LAR.js → chunk-AXQQMX2U.js} +203 -9
- package/dist/{chunk-M6RIJVRE.js → chunk-DSWT6BCC.js} +78 -4
- package/dist/{chunk-FBAYCS3X.js → chunk-DXJQR4QI.js} +150 -38
- package/dist/chunk-FXF4JWK2.js +162 -0
- package/dist/{chunk-PCVGBE5B.js → chunk-G2VMA2QO.js} +2 -1
- package/dist/{chunk-NAHHFSLH.js → chunk-ICM2XCXS.js} +2 -2
- package/dist/{chunk-5HRR4AOR.js → chunk-IMK4JKTA.js} +1 -1
- package/dist/{chunk-OAAP2MY6.js → chunk-K7D2EYBS.js} +2 -2
- package/dist/{chunk-ADUTW66W.js → chunk-O7YAW7G6.js} +6 -6
- package/dist/{chunk-5AFRMOPX.js → chunk-OH3EZXYX.js} +1 -1
- package/dist/{chunk-KFXF2V5G.js → chunk-OTHKG76J.js} +1 -1
- package/dist/chunk-OYHYCK4K.js +13 -0
- package/dist/{chunk-FKXFUUWJ.js → chunk-TYAHC25F.js} +1 -1
- package/dist/{chunk-5GYQ4J3D.js → chunk-UKUAAAXE.js} +2 -2
- package/dist/{chunk-LGGMJYY4.js → chunk-WUXFLE7X.js} +720 -369
- package/dist/{chunk-WX52U7Z2.js → chunk-ZU563PQI.js} +58 -2
- package/dist/daemon/server.js +372 -123
- package/dist/hook-runtime/daemon/server.js +372 -123
- package/dist/hook-runtime/hooks/post-commit.js +192 -58
- package/dist/hook-runtime/hooks/post-rewrite.js +58 -40
- package/dist/hook-runtime/hooks/post-tool-use.js +150 -67
- package/dist/hook-runtime/hooks/pre-tool-use.js +87 -15
- package/dist/hook-runtime/hooks/prim-hook.js +197 -107
- package/dist/hook-runtime/hooks/session-end.js +53 -5
- package/dist/hook-runtime/hooks/session-start.js +880 -280
- package/dist/hook-runtime/index.js +4008 -2438
- package/dist/hook-runtime/statusline-main.js +23 -1
- package/dist/hooks/post-commit.js +15 -8
- package/dist/hooks/post-rewrite.js +11 -8
- package/dist/hooks/post-tool-use.js +13 -15
- package/dist/hooks/pre-commit.js +4 -4
- package/dist/hooks/pre-tool-use.js +10 -12
- package/dist/hooks/prim-hook.js +17 -14
- package/dist/hooks/session-end.js +4 -4
- package/dist/hooks/session-start.js +35 -45
- package/dist/index.js +1401 -620
- package/dist/statusline-main.js +23 -1
- package/package.json +1 -1
- package/dist/chunk-BTNNEH2W.js +0 -79
package/dist/index.js
CHANGED
|
@@ -2,57 +2,76 @@
|
|
|
2
2
|
import {
|
|
3
3
|
bindRepository,
|
|
4
4
|
bindRepositoryWithClient,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
ensureEffectiveGitHook,
|
|
6
|
+
ensureGitHookAtPath,
|
|
7
|
+
externalHookRemedy,
|
|
8
|
+
gitHooksMode,
|
|
9
|
+
hasCurrentHookBlock,
|
|
10
|
+
inspectEffectiveGitHook,
|
|
11
|
+
isLegacyOwnedGlobalHook,
|
|
12
|
+
isOwnedStandalonePreCommit,
|
|
13
|
+
kickDaemonEnsure,
|
|
13
14
|
printJson,
|
|
15
|
+
projectGitHookTarget,
|
|
16
|
+
projectHooksDir,
|
|
14
17
|
registerSkillCommands,
|
|
18
|
+
resolveEffectiveGitHook,
|
|
15
19
|
resolveRepositoryBinding,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
uninstallGitHookAtPath,
|
|
21
|
+
uninstallProjectGitHook
|
|
22
|
+
} from "./chunk-WUXFLE7X.js";
|
|
23
|
+
import {
|
|
24
|
+
POST_COMMIT_GRACE_MS,
|
|
25
|
+
clearHooksWired,
|
|
26
|
+
inspectPostCommitFiring,
|
|
27
|
+
recordHooksWired
|
|
28
|
+
} from "./chunk-FXF4JWK2.js";
|
|
21
29
|
import {
|
|
22
30
|
requireDurableIngestAcknowledgement
|
|
23
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-TYAHC25F.js";
|
|
24
32
|
import {
|
|
25
33
|
checkAffectedDecisions,
|
|
26
34
|
daemonOrDirectGet,
|
|
27
35
|
formatDecisionsWarning
|
|
28
|
-
} from "./chunk-
|
|
36
|
+
} from "./chunk-UKUAAAXE.js";
|
|
29
37
|
import {
|
|
30
38
|
fetchFeedbackCapability
|
|
31
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-OH3EZXYX.js";
|
|
40
|
+
import {
|
|
41
|
+
isUnattended,
|
|
42
|
+
unattendedEnv
|
|
43
|
+
} from "./chunk-OYHYCK4K.js";
|
|
32
44
|
import {
|
|
33
45
|
JOURNAL_DIR,
|
|
34
46
|
SESSIONS_DIR,
|
|
35
47
|
bucketStats,
|
|
48
|
+
drainResumeOffset,
|
|
49
|
+
envSlug,
|
|
36
50
|
listBuckets,
|
|
37
51
|
listFlushing,
|
|
38
52
|
pendingJournalStats,
|
|
39
|
-
readMovesFromPath
|
|
40
|
-
|
|
53
|
+
readMovesFromPath,
|
|
54
|
+
removeDrainCheckpoint,
|
|
55
|
+
rotationIdentity,
|
|
56
|
+
sweepOrphanedDrainProgress,
|
|
57
|
+
writeDrainCheckpoint
|
|
58
|
+
} from "./chunk-DXJQR4QI.js";
|
|
41
59
|
import {
|
|
60
|
+
MS_PER_SECOND,
|
|
61
|
+
deliveryBacklogState,
|
|
62
|
+
deliveryBacklogSummary,
|
|
63
|
+
formatPendingBacklog,
|
|
42
64
|
formatStatusline,
|
|
43
65
|
formatTeammates,
|
|
44
66
|
repositoryBindingDiagnosticLabel
|
|
45
|
-
} from "./chunk-
|
|
67
|
+
} from "./chunk-ZU563PQI.js";
|
|
46
68
|
import {
|
|
47
69
|
bold,
|
|
48
70
|
boundedHealthError,
|
|
49
71
|
color,
|
|
50
72
|
colorForArea,
|
|
51
|
-
dim,
|
|
52
|
-
stripAnsi
|
|
53
|
-
} from "./chunk-BTNNEH2W.js";
|
|
54
|
-
import {
|
|
55
73
|
decisionIngestionStatus,
|
|
74
|
+
dim,
|
|
56
75
|
fetchAndCacheCollectScope,
|
|
57
76
|
isCliErrorResponse,
|
|
58
77
|
isDecisionIdRequest,
|
|
@@ -66,10 +85,12 @@ import {
|
|
|
66
85
|
isMoveIngestRequest,
|
|
67
86
|
isRepoActiveForCapture,
|
|
68
87
|
isValidRepoSyncId,
|
|
88
|
+
repoActiveFlag,
|
|
69
89
|
repoSyncId,
|
|
70
90
|
repositoryBindingState,
|
|
71
|
-
setRepoActive
|
|
72
|
-
|
|
91
|
+
setRepoActive,
|
|
92
|
+
stripAnsi
|
|
93
|
+
} from "./chunk-DSWT6BCC.js";
|
|
73
94
|
import {
|
|
74
95
|
CliManagementResponseError,
|
|
75
96
|
HttpError,
|
|
@@ -79,14 +100,16 @@ import {
|
|
|
79
100
|
getPinnedClient,
|
|
80
101
|
getPinnedManagementClient,
|
|
81
102
|
getSiteUrl,
|
|
103
|
+
getSiteUrlForEnvironment,
|
|
82
104
|
getTokenExpiresAt,
|
|
83
105
|
isSessionEnded,
|
|
84
106
|
refreshToken,
|
|
85
107
|
setStoredToken,
|
|
86
108
|
stripControlChars,
|
|
87
109
|
terminalSafeLine
|
|
88
|
-
} from "./chunk-
|
|
110
|
+
} from "./chunk-G2VMA2QO.js";
|
|
89
111
|
import {
|
|
112
|
+
MANAGED_GIT_HOOK_NAMES,
|
|
90
113
|
assertOwnedHookRuntime,
|
|
91
114
|
atomicWrite,
|
|
92
115
|
binFile,
|
|
@@ -96,14 +119,16 @@ import {
|
|
|
96
119
|
entryHasCommand,
|
|
97
120
|
hookCommandResolutions,
|
|
98
121
|
hookRuntimePaths,
|
|
122
|
+
inspectGitHookEntrypoint,
|
|
99
123
|
inspectHookRuntime,
|
|
100
124
|
inspectHookRuntimeResolutions,
|
|
101
125
|
inspectHookRuntimeResolutions2,
|
|
102
126
|
makeRegistration,
|
|
127
|
+
managedHookBlock,
|
|
128
|
+
packageRoot,
|
|
103
129
|
packageVersion,
|
|
104
130
|
performStatus,
|
|
105
131
|
performStatus2,
|
|
106
|
-
pinnedHookCommand,
|
|
107
132
|
pinnedNpxArgs,
|
|
108
133
|
projectRoot,
|
|
109
134
|
readSettings,
|
|
@@ -111,9 +136,10 @@ import {
|
|
|
111
136
|
registerCursorCommands,
|
|
112
137
|
removeHookRuntime,
|
|
113
138
|
stableHookCommand,
|
|
139
|
+
stableNodePath,
|
|
114
140
|
stageHookRuntime,
|
|
115
141
|
stripCommand
|
|
116
|
-
} from "./chunk-
|
|
142
|
+
} from "./chunk-AXQQMX2U.js";
|
|
117
143
|
import {
|
|
118
144
|
canonicalGitRoot,
|
|
119
145
|
canonicalRepositoryPath,
|
|
@@ -122,7 +148,7 @@ import {
|
|
|
122
148
|
import {
|
|
123
149
|
daemonIsLive,
|
|
124
150
|
daemonRequest
|
|
125
|
-
} from "./chunk-
|
|
151
|
+
} from "./chunk-OTHKG76J.js";
|
|
126
152
|
import {
|
|
127
153
|
REFRESH_TOKEN_PATH,
|
|
128
154
|
TOKEN_FILE_PATH,
|
|
@@ -135,11 +161,12 @@ import {
|
|
|
135
161
|
requestWorkosDeviceAuthorization,
|
|
136
162
|
resolveAuthCredential,
|
|
137
163
|
syncDirectory,
|
|
138
|
-
withFileLock
|
|
139
|
-
|
|
164
|
+
withFileLock,
|
|
165
|
+
withFileLockSync
|
|
166
|
+
} from "./chunk-2GP5UFV7.js";
|
|
140
167
|
|
|
141
168
|
// src/index.ts
|
|
142
|
-
import { readFileSync as
|
|
169
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
143
170
|
import { dirname as dirname6, resolve as resolve4 } from "path";
|
|
144
171
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
145
172
|
import { Command } from "commander";
|
|
@@ -749,18 +776,18 @@ function parseAuthBrokerConfig(value) {
|
|
|
749
776
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
750
777
|
throw new Error("broker config must be an object");
|
|
751
778
|
}
|
|
752
|
-
const
|
|
753
|
-
if (typeof
|
|
779
|
+
const record3 = value;
|
|
780
|
+
if (typeof record3.authorization_server !== "string" || !record3.authorization_server.trim()) {
|
|
754
781
|
throw new Error("authorization_server is required");
|
|
755
782
|
}
|
|
756
|
-
if (typeof
|
|
783
|
+
if (typeof record3.client_id !== "string" || !record3.client_id.trim()) {
|
|
757
784
|
throw new Error("client_id is required");
|
|
758
785
|
}
|
|
759
|
-
if (!Array.isArray(
|
|
786
|
+
if (!Array.isArray(record3.default_scopes) || !record3.default_scopes.every((scope) => typeof scope === "string" && scope.length > 0)) {
|
|
760
787
|
throw new Error("default_scopes must be a string array");
|
|
761
788
|
}
|
|
762
|
-
const authorizationServer = parseHttpUrl(
|
|
763
|
-
const rawEndpoint =
|
|
789
|
+
const authorizationServer = parseHttpUrl(record3.authorization_server, "authorization_server");
|
|
790
|
+
const rawEndpoint = record3.authorization_endpoint === void 0 ? DEFAULT_WORKOS_AUTHORIZE_URL : record3.authorization_endpoint;
|
|
764
791
|
if (typeof rawEndpoint !== "string" || !rawEndpoint.trim()) {
|
|
765
792
|
throw new Error("authorization_endpoint must be a URL");
|
|
766
793
|
}
|
|
@@ -771,8 +798,8 @@ function parseAuthBrokerConfig(value) {
|
|
|
771
798
|
return {
|
|
772
799
|
authorization_server: authorizationServer.toString(),
|
|
773
800
|
authorization_endpoint: authorizationEndpoint.toString(),
|
|
774
|
-
client_id:
|
|
775
|
-
default_scopes: [...
|
|
801
|
+
client_id: record3.client_id.trim(),
|
|
802
|
+
default_scopes: [...record3.default_scopes]
|
|
776
803
|
};
|
|
777
804
|
}
|
|
778
805
|
async function fetchAuthBrokerConfig(siteUrl, fetchImpl = fetch) {
|
|
@@ -1335,8 +1362,8 @@ import {
|
|
|
1335
1362
|
writeFileSync
|
|
1336
1363
|
} from "fs";
|
|
1337
1364
|
import { homedir } from "os";
|
|
1338
|
-
import {
|
|
1339
|
-
import { Option } from "commander";
|
|
1365
|
+
import { join, resolve } from "path";
|
|
1366
|
+
import { Argument, Option } from "commander";
|
|
1340
1367
|
var PRE_COMMIT = { hookName: "pre-commit", binName: "prim-pre-commit" };
|
|
1341
1368
|
var POST_COMMIT = { hookName: "post-commit", binName: "prim-post-commit" };
|
|
1342
1369
|
var POST_REWRITE = { hookName: "post-rewrite", binName: "prim-post-rewrite" };
|
|
@@ -1352,84 +1379,15 @@ var PRIM_BLOCK_START = blockMarkers(PRE_COMMIT).start;
|
|
|
1352
1379
|
var PRIM_BLOCK_END = blockMarkers(PRE_COMMIT).end;
|
|
1353
1380
|
var PRIM_MANAGED_MARK = "prim-managed-hook";
|
|
1354
1381
|
var PRIM_CREATED_MARK = "prim-created-hook";
|
|
1355
|
-
function
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
function gatedShim(binName) {
|
|
1359
|
-
return `if [ "$(git config --get prim.active 2>/dev/null)" = "true" ]; then
|
|
1360
|
-
${hookShim(binName)}
|
|
1361
|
-
fi`;
|
|
1362
|
-
}
|
|
1363
|
-
function dotGitScript(spec) {
|
|
1364
|
-
return `#!/bin/sh
|
|
1365
|
-
# prim ${spec.hookName} hook \u2014 installed by: prim hooks install (${PRIM_MANAGED_MARK})
|
|
1366
|
-
|
|
1367
|
-
${hookShim(spec.binName)}
|
|
1368
|
-
`;
|
|
1369
|
-
}
|
|
1370
|
-
function isOwnedStandalonePreCommit(content) {
|
|
1371
|
-
if (content === "#!/bin/sh\nprim-pre-commit\n") return true;
|
|
1372
|
-
const prefix = `#!/bin/sh
|
|
1373
|
-
# prim pre-commit hook \u2014 installed by: prim hooks install (${PRIM_MANAGED_MARK})
|
|
1374
|
-
|
|
1375
|
-
`;
|
|
1376
|
-
const suffix = "; } || true\n";
|
|
1377
|
-
if (!content.startsWith(prefix) || !content.endsWith(suffix)) return false;
|
|
1378
|
-
const body = content.slice(prefix.length, -suffix.length);
|
|
1379
|
-
if (!body.startsWith("{ ") || /[\r\n]/u.test(body)) return false;
|
|
1380
|
-
return commandMatchesBin(body.slice(2), PRE_COMMIT.binName);
|
|
1381
|
-
}
|
|
1382
|
-
function huskyBlock(spec) {
|
|
1383
|
-
if (spec.hookName === POST_COMMIT.hookName) return postCommitHookBlock();
|
|
1384
|
-
if (spec.hookName === POST_REWRITE.hookName) return postRewriteHookBlock();
|
|
1385
|
-
const { start, end } = blockMarkers(spec);
|
|
1386
|
-
return `${start}
|
|
1387
|
-
${hookShim(spec.binName)}
|
|
1388
|
-
${end}`;
|
|
1389
|
-
}
|
|
1390
|
-
function gatedBlock(spec) {
|
|
1391
|
-
if (spec.hookName === POST_COMMIT.hookName) return postCommitHookBlock();
|
|
1392
|
-
if (spec.hookName === POST_REWRITE.hookName) return postRewriteHookBlock();
|
|
1393
|
-
const { start, end } = blockMarkers(spec);
|
|
1394
|
-
return `${start}
|
|
1395
|
-
${gatedShim(spec.binName)}
|
|
1396
|
-
${end}`;
|
|
1397
|
-
}
|
|
1398
|
-
function primBlockRange(existing, spec) {
|
|
1399
|
-
const { start, end } = blockMarkers(spec);
|
|
1400
|
-
const starts = existing.split(start).length - 1;
|
|
1401
|
-
const ends = existing.split(end).length - 1;
|
|
1402
|
-
if (starts === 0 && ends === 0) return null;
|
|
1403
|
-
const from = existing.indexOf(start);
|
|
1404
|
-
const endAt = existing.indexOf(end);
|
|
1405
|
-
if (starts !== 1 || ends !== 1 || endAt < from) {
|
|
1406
|
-
throw new Error(`malformed Prim hook markers for ${spec.hookName}`);
|
|
1407
|
-
}
|
|
1408
|
-
return { start: from, through: endAt + end.length };
|
|
1409
|
-
}
|
|
1410
|
-
function mergePrimBlock(hookPath, block, spec) {
|
|
1411
|
-
if (existsSync2(hookPath)) {
|
|
1412
|
-
const existing = readFileSync2(hookPath, "utf-8");
|
|
1413
|
-
const range = primBlockRange(existing, spec);
|
|
1414
|
-
if (range) {
|
|
1415
|
-
const refreshed = existing.slice(0, range.start) + block + existing.slice(range.through);
|
|
1416
|
-
if (refreshed === existing) return false;
|
|
1417
|
-
writeFileSync(hookPath, refreshed, { mode: 493 });
|
|
1418
|
-
return true;
|
|
1419
|
-
}
|
|
1420
|
-
if (containsPrimHook(existing, spec.binName)) return false;
|
|
1421
|
-
const separator = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
1422
|
-
writeFileSync(hookPath, `${existing}${separator}${block}
|
|
1423
|
-
`, { mode: 493 });
|
|
1382
|
+
function stageGitHookRuntime() {
|
|
1383
|
+
try {
|
|
1384
|
+
stageHookRuntime();
|
|
1424
1385
|
return true;
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1388
|
+
console.error(`[prim] git hooks stay inert until the hook runtime is staged: ${detail}`);
|
|
1389
|
+
return false;
|
|
1425
1390
|
}
|
|
1426
|
-
mkdirSync(dirname(hookPath), { recursive: true });
|
|
1427
|
-
writeFileSync(hookPath, `#!/bin/sh
|
|
1428
|
-
# ${PRIM_CREATED_MARK}
|
|
1429
|
-
|
|
1430
|
-
${block}
|
|
1431
|
-
`, { mode: 493 });
|
|
1432
|
-
return true;
|
|
1433
1391
|
}
|
|
1434
1392
|
function getGitRoot() {
|
|
1435
1393
|
const root = gitToplevel();
|
|
@@ -1456,52 +1414,6 @@ function detectHusky(gitRoot) {
|
|
|
1456
1414
|
}
|
|
1457
1415
|
return false;
|
|
1458
1416
|
}
|
|
1459
|
-
function containsPrimHook(content, binName = PRE_COMMIT.binName) {
|
|
1460
|
-
return content.includes(binName);
|
|
1461
|
-
}
|
|
1462
|
-
function installToHusky(gitRoot, spec = PRE_COMMIT) {
|
|
1463
|
-
const hookPath = resolve(gitRoot, ".husky", spec.hookName);
|
|
1464
|
-
const existed = existsSync2(hookPath);
|
|
1465
|
-
const wrote = mergePrimBlock(hookPath, huskyBlock(spec), spec);
|
|
1466
|
-
if (!wrote) {
|
|
1467
|
-
console.log(`Prim ${spec.hookName} hook is already installed in .husky/${spec.hookName}.`);
|
|
1468
|
-
} else if (existed) {
|
|
1469
|
-
console.log(`Appended prim hook block to .husky/${spec.hookName}.`);
|
|
1470
|
-
} else {
|
|
1471
|
-
console.log(`Created .husky/${spec.hookName} with prim hook block.`);
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
function installToDotGit(gitRoot, spec = PRE_COMMIT) {
|
|
1475
|
-
const hooksDir = projectHooksDir(gitRoot);
|
|
1476
|
-
const hookPath = resolve(hooksDir, spec.hookName);
|
|
1477
|
-
if (!existsSync2(hooksDir)) {
|
|
1478
|
-
mkdirSync(hooksDir, { recursive: true });
|
|
1479
|
-
}
|
|
1480
|
-
if (existsSync2(hookPath)) {
|
|
1481
|
-
const existing = readFileSync2(hookPath, "utf-8");
|
|
1482
|
-
if (containsPrimHook(existing, spec.binName)) {
|
|
1483
|
-
console.log(`Prim ${spec.hookName} hook is already installed at ${hookPath}.`);
|
|
1484
|
-
return;
|
|
1485
|
-
}
|
|
1486
|
-
console.log(`A ${spec.hookName} hook already exists at ${hookPath}.`);
|
|
1487
|
-
console.log("To replace it, run: prim hooks uninstall && prim hooks install");
|
|
1488
|
-
return;
|
|
1489
|
-
}
|
|
1490
|
-
writeFileSync(hookPath, dotGitScript(spec), { mode: 493 });
|
|
1491
|
-
console.log(`Installed ${spec.hookName} hook at ${hookPath}`);
|
|
1492
|
-
}
|
|
1493
|
-
function projectHooksDir(gitRoot) {
|
|
1494
|
-
const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
|
1495
|
-
cwd: gitRoot,
|
|
1496
|
-
encoding: "utf-8",
|
|
1497
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
1498
|
-
timeout: GIT_TIMEOUT_MS
|
|
1499
|
-
}).trim();
|
|
1500
|
-
if (commonDir === "") {
|
|
1501
|
-
throw new Error("git returned an empty common directory");
|
|
1502
|
-
}
|
|
1503
|
-
return resolve(gitRoot, commonDir, "hooks");
|
|
1504
|
-
}
|
|
1505
1417
|
var PRIM_GIT_HOOKS_DIR = join(primConfigDirectory(), "git-hooks");
|
|
1506
1418
|
function expandTilde(p) {
|
|
1507
1419
|
return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
|
|
@@ -1540,21 +1452,17 @@ var PASSTHROUGH_HOOKS = [
|
|
|
1540
1452
|
// with a query protocol, not a lifecycle event, so a bare exec stub is wrong.)
|
|
1541
1453
|
];
|
|
1542
1454
|
function globalHookScript(spec) {
|
|
1543
|
-
const
|
|
1544
|
-
const
|
|
1545
|
-
const
|
|
1546
|
-
const managedRepoGuard = managedBlock ? ` && ! grep -Fq '${blockMarkers(spec).start.slice(0, -3)}'">>>" "$repo_hook" 2>/dev/null` : "";
|
|
1547
|
-
const beforeComments = managedBlock ? `${invocation}
|
|
1548
|
-
` : "";
|
|
1549
|
-
const afterComments = managedBlock ? "" : `${invocation}
|
|
1550
|
-
`;
|
|
1455
|
+
const preCommit = spec.hookName === PRE_COMMIT.hookName;
|
|
1456
|
+
const chainExit = preCommit ? "|| exit $?" : "|| true";
|
|
1457
|
+
const managedRepoGuard = preCommit ? "" : ` && ! grep -Fq '${blockMarkers(spec).start.slice(0, -3)}'">>>" "$repo_hook" 2>/dev/null`;
|
|
1551
1458
|
return `#!/bin/sh
|
|
1552
|
-
${
|
|
1459
|
+
${managedHookBlock(spec.hookName)}
|
|
1460
|
+
# prim global ${spec.hookName} hook (core.hooksPath) \u2014 managed by prim; do not edit.
|
|
1553
1461
|
# Install/uninstall: prim hooks install|uninstall --scope user
|
|
1554
1462
|
# Runs prim only where activated \u2014 'prim enable' (this repo) or
|
|
1555
1463
|
# 'git config --global prim.active true' (every repo). Chains to the repo's own
|
|
1556
1464
|
# hook regardless, so inactive repos are unaffected.
|
|
1557
|
-
|
|
1465
|
+
common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
|
|
1558
1466
|
repo_hook="$common_dir/hooks/${spec.hookName}"
|
|
1559
1467
|
if [ -x "$repo_hook" ] && ! grep -q '${PRIM_MANAGED_MARK}' "$repo_hook" 2>/dev/null${managedRepoGuard}; then
|
|
1560
1468
|
"$repo_hook" "$@" ${chainExit}
|
|
@@ -1579,30 +1487,12 @@ function expectedOwnedHookContent(hookName) {
|
|
|
1579
1487
|
}
|
|
1580
1488
|
return null;
|
|
1581
1489
|
}
|
|
1582
|
-
var PINNED_PACKAGE_VERSION_RE = /@primitive\.ai\/prim@[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/gu;
|
|
1583
|
-
function normalizeOwnedGlobalHook(content, hookName) {
|
|
1584
|
-
if (hookName === PRE_COMMIT.hookName) {
|
|
1585
|
-
const lines = content.split("\n");
|
|
1586
|
-
const invocationIndexes = lines.flatMap(
|
|
1587
|
-
(line2, index) => line2.startsWith("{ ") && line2.endsWith("; } || true") ? [index] : []
|
|
1588
|
-
);
|
|
1589
|
-
if (invocationIndexes.length !== 1) return null;
|
|
1590
|
-
const invocationIndex = invocationIndexes[0];
|
|
1591
|
-
const line = lines[invocationIndex];
|
|
1592
|
-
const command = line.slice(2, -"; } || true".length);
|
|
1593
|
-
if (!commandMatchesBin(command, PRE_COMMIT.binName)) return null;
|
|
1594
|
-
lines[invocationIndex] = "{ <recognized Prim pre-commit invocation>; } || true";
|
|
1595
|
-
return lines.join("\n");
|
|
1596
|
-
}
|
|
1597
|
-
return content.replace(PINNED_PACKAGE_VERSION_RE, "@primitive.ai/prim@<version>");
|
|
1598
|
-
}
|
|
1599
1490
|
function isExpectedOwnedGlobalHook(content, hookName) {
|
|
1600
1491
|
const expected = expectedOwnedHookContent(hookName);
|
|
1601
1492
|
if (expected === null) return false;
|
|
1602
1493
|
if (content === expected) return true;
|
|
1603
|
-
const
|
|
1604
|
-
|
|
1605
|
-
return normalized !== null && normalized === normalizedExpected;
|
|
1494
|
+
const managed = HOOKS.find((spec) => spec.hookName === hookName);
|
|
1495
|
+
return managed !== void 0 && isLegacyOwnedGlobalHook(content, managed.hookName);
|
|
1606
1496
|
}
|
|
1607
1497
|
function lstatIfPresent(path) {
|
|
1608
1498
|
try {
|
|
@@ -1637,6 +1527,12 @@ function assertOwnedHooksDirectorySafeToRemove() {
|
|
|
1637
1527
|
return entries;
|
|
1638
1528
|
}
|
|
1639
1529
|
function writeOwnHooks() {
|
|
1530
|
+
if (inspectGitHookEntrypoint() !== "ready") {
|
|
1531
|
+
console.error(
|
|
1532
|
+
`[prim] did not write prim's global hooks in ${PRIM_GIT_HOOKS_DIR}: the hook runtime they run is not staged yet. Run \`prim setup\` (or an agent install such as \`prim claude install\`) first, then retry.`
|
|
1533
|
+
);
|
|
1534
|
+
return false;
|
|
1535
|
+
}
|
|
1640
1536
|
if (!existsSync2(PRIM_GIT_HOOKS_DIR)) {
|
|
1641
1537
|
mkdirSync(PRIM_GIT_HOOKS_DIR, { recursive: true });
|
|
1642
1538
|
}
|
|
@@ -1648,47 +1544,17 @@ function writeOwnHooks() {
|
|
|
1648
1544
|
for (const name of PASSTHROUGH_HOOKS) {
|
|
1649
1545
|
writeFileSync(resolve(PRIM_GIT_HOOKS_DIR, name), passThroughScript(name), { mode: 493 });
|
|
1650
1546
|
}
|
|
1547
|
+
return true;
|
|
1651
1548
|
}
|
|
1652
1549
|
function refreshOwnedGlobalHooks() {
|
|
1653
1550
|
if (!isOurHooksDir(gitConfigGet("--global"))) return false;
|
|
1654
|
-
writeOwnHooks();
|
|
1655
|
-
return true;
|
|
1551
|
+
return writeOwnHooks();
|
|
1656
1552
|
}
|
|
1657
1553
|
function appendPrimBlock(hookPath, spec) {
|
|
1658
|
-
|
|
1659
|
-
ensurePostCommitHookAtPath(hookPath);
|
|
1660
|
-
return;
|
|
1661
|
-
}
|
|
1662
|
-
if (spec.hookName === POST_REWRITE.hookName) {
|
|
1663
|
-
ensurePostRewriteHookAtPath(hookPath);
|
|
1664
|
-
return;
|
|
1665
|
-
}
|
|
1666
|
-
mergePrimBlock(hookPath, gatedBlock(spec), spec);
|
|
1554
|
+
ensureGitHookAtPath(spec.hookName, hookPath);
|
|
1667
1555
|
}
|
|
1668
1556
|
function stripPrimBlock(hookPath, spec) {
|
|
1669
|
-
|
|
1670
|
-
uninstallPostCommitHookAtPath(hookPath);
|
|
1671
|
-
return;
|
|
1672
|
-
}
|
|
1673
|
-
if (spec.hookName === POST_REWRITE.hookName) {
|
|
1674
|
-
uninstallPostRewriteHookAtPath(hookPath);
|
|
1675
|
-
return;
|
|
1676
|
-
}
|
|
1677
|
-
if (!existsSync2(hookPath)) return;
|
|
1678
|
-
const existing = readFileSync2(hookPath, "utf-8");
|
|
1679
|
-
const primCreated = existing.includes(PRIM_CREATED_MARK);
|
|
1680
|
-
const range = primBlockRange(existing, spec);
|
|
1681
|
-
if (!range) return;
|
|
1682
|
-
const out = (existing.slice(0, range.start) + existing.slice(range.through)).replace(
|
|
1683
|
-
/\n{2,}$/,
|
|
1684
|
-
"\n"
|
|
1685
|
-
);
|
|
1686
|
-
const remainder = out.replaceAll("#!/bin/sh", "").replaceAll(`# ${PRIM_CREATED_MARK}`, "").trim();
|
|
1687
|
-
if (primCreated && remainder === "") {
|
|
1688
|
-
unlinkSync(hookPath);
|
|
1689
|
-
return;
|
|
1690
|
-
}
|
|
1691
|
-
writeFileSync(hookPath, out, { mode: 493 });
|
|
1557
|
+
uninstallGitHookAtPath(spec.hookName, hookPath);
|
|
1692
1558
|
}
|
|
1693
1559
|
function planPreCommitRemoval(hookPath, options) {
|
|
1694
1560
|
if (!existsSync2(hookPath)) return { path: hookPath, action: "missing" };
|
|
@@ -1696,7 +1562,8 @@ function planPreCommitRemoval(hookPath, options) {
|
|
|
1696
1562
|
if (options.allowOwnedStandalone && isOwnedStandalonePreCommit(existing)) {
|
|
1697
1563
|
return { path: hookPath, action: "remove-owned-file" };
|
|
1698
1564
|
}
|
|
1699
|
-
|
|
1565
|
+
const { start, end } = blockMarkers(PRE_COMMIT);
|
|
1566
|
+
if (existing.includes(start) || existing.includes(end)) {
|
|
1700
1567
|
return { path: hookPath, action: "strip-block" };
|
|
1701
1568
|
}
|
|
1702
1569
|
if (existing.includes(PRE_COMMIT.binName) || existing.includes(PRIM_MANAGED_MARK) || existing.includes(PRIM_CREATED_MARK)) {
|
|
@@ -1706,79 +1573,172 @@ function planPreCommitRemoval(hookPath, options) {
|
|
|
1706
1573
|
}
|
|
1707
1574
|
return { path: hookPath, action: "leave-foreign" };
|
|
1708
1575
|
}
|
|
1709
|
-
function applyPreCommitRemoval(plan) {
|
|
1710
|
-
if (plan.action === "
|
|
1711
|
-
|
|
1712
|
-
console.log(`Removed pre-commit hook at ${plan.path}`);
|
|
1713
|
-
} else if (plan.action === "strip-block") {
|
|
1714
|
-
stripPrimBlock(plan.path, PRE_COMMIT);
|
|
1715
|
-
console.log(`Removed the Prim pre-commit block from ${plan.path}.`);
|
|
1716
|
-
} else if (plan.action === "leave-foreign") {
|
|
1576
|
+
function applyPreCommitRemoval(plan, husky) {
|
|
1577
|
+
if (plan.action === "missing") return;
|
|
1578
|
+
if (plan.action === "leave-foreign") {
|
|
1717
1579
|
console.log(`Left pre-commit hook at ${plan.path} untouched (not a Prim hook).`);
|
|
1580
|
+
return;
|
|
1718
1581
|
}
|
|
1582
|
+
const result = uninstallGitHookAtPath(PRE_COMMIT.hookName, plan.path, { husky });
|
|
1583
|
+
console.log(
|
|
1584
|
+
result.removedFile ? `Removed pre-commit hook at ${plan.path}` : `Removed the Prim pre-commit block from ${plan.path}.`
|
|
1585
|
+
);
|
|
1719
1586
|
}
|
|
1720
1587
|
function uninstallProjectHooks(gitRoot) {
|
|
1721
|
-
const
|
|
1722
|
-
|
|
1588
|
+
const configured = projectGitHookTarget(PRE_COMMIT.hookName, gitRoot);
|
|
1589
|
+
const destinations = [
|
|
1590
|
+
{
|
|
1591
|
+
path: resolve(projectHooksDir(gitRoot), PRE_COMMIT.hookName),
|
|
1592
|
+
husky: false,
|
|
1723
1593
|
allowOwnedStandalone: true
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1594
|
+
},
|
|
1595
|
+
{
|
|
1596
|
+
path: resolve(gitRoot, ".husky", PRE_COMMIT.hookName),
|
|
1597
|
+
husky: true,
|
|
1726
1598
|
allowOwnedStandalone: false
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1599
|
+
},
|
|
1600
|
+
// A shared hooks dir is never stripped on this repository's behalf.
|
|
1601
|
+
...configured.location === "external" ? [] : [
|
|
1602
|
+
{
|
|
1603
|
+
path: configured.hookPath,
|
|
1604
|
+
husky: configured.kind === "husky_v9",
|
|
1605
|
+
allowOwnedStandalone: false
|
|
1606
|
+
}
|
|
1607
|
+
]
|
|
1608
|
+
].filter(
|
|
1609
|
+
(destination, index, all) => all.findIndex((other) => other.path === destination.path) === index
|
|
1610
|
+
);
|
|
1611
|
+
const plans = destinations.map(({ path, husky, allowOwnedStandalone }) => ({
|
|
1612
|
+
plan: planPreCommitRemoval(path, { allowOwnedStandalone }),
|
|
1613
|
+
husky
|
|
1614
|
+
}));
|
|
1615
|
+
for (const { plan, husky } of plans) applyPreCommitRemoval(plan, husky);
|
|
1616
|
+
for (const spec of [POST_COMMIT, POST_REWRITE]) {
|
|
1617
|
+
const result = uninstallProjectGitHook(spec.hookName, gitRoot);
|
|
1618
|
+
if (result.skipped === "external") {
|
|
1619
|
+
console.log(
|
|
1620
|
+
`Left ${result.path} alone: it is outside this repository, and other repositories may run it.`
|
|
1621
|
+
);
|
|
1622
|
+
} else if (!result.changed) {
|
|
1623
|
+
console.log(`No Prim ${spec.hookName} block found at ${result.path}.`);
|
|
1737
1624
|
} else if (result.removedFile) {
|
|
1738
|
-
console.log(`Removed Prim-created ${hookName} hook at ${result.path}.`);
|
|
1625
|
+
console.log(`Removed Prim-created ${spec.hookName} hook at ${result.path}.`);
|
|
1739
1626
|
} else {
|
|
1740
|
-
console.log(`Removed the Prim ${hookName} block from ${result.path}.`);
|
|
1627
|
+
console.log(`Removed the Prim ${spec.hookName} block from ${result.path}.`);
|
|
1741
1628
|
}
|
|
1742
1629
|
}
|
|
1743
1630
|
}
|
|
1744
|
-
function
|
|
1631
|
+
function globalHooksPathIsPrims() {
|
|
1632
|
+
return isOurHooksDir(gitConfigGet("--global"));
|
|
1633
|
+
}
|
|
1634
|
+
var EXIT_GLOBAL_HOOKS_NOT_INSTALLED = 3;
|
|
1635
|
+
var INSTALLED_OUTCOMES = /* @__PURE__ */ new Set([
|
|
1636
|
+
"installed",
|
|
1637
|
+
"refreshed",
|
|
1638
|
+
"added",
|
|
1639
|
+
"present"
|
|
1640
|
+
]);
|
|
1641
|
+
function planGlobalHooks(opts = {}) {
|
|
1745
1642
|
const global = gitConfigGet("--global");
|
|
1746
|
-
if (global === "") {
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1643
|
+
if (gitHooksMode({ global: true }) === "manual") return { action: "manual", global };
|
|
1644
|
+
if (isOurHooksDir(global)) return { action: "refresh" };
|
|
1645
|
+
if (global !== "") {
|
|
1646
|
+
const dir = expandTilde(global);
|
|
1647
|
+
return HOOKS.every((spec) => hasCurrentHookBlock(spec.hookName, resolve(dir, spec.hookName))) ? { action: "present_in_dir", global } : { action: "add_to_dir", global };
|
|
1648
|
+
}
|
|
1649
|
+
const system = gitConfigGet("--system");
|
|
1650
|
+
if (system !== "" && !isOurHooksDir(system)) {
|
|
1651
|
+
return opts.force ? { action: "set_pointer", overridesSystem: system } : { action: "system_declined", system };
|
|
1652
|
+
}
|
|
1653
|
+
return { action: "set_pointer" };
|
|
1654
|
+
}
|
|
1655
|
+
function planIsMachineWide(plan) {
|
|
1656
|
+
return plan.action === "set_pointer" || plan.action === "add_to_dir";
|
|
1657
|
+
}
|
|
1658
|
+
function installGlobalHooks(opts = {}) {
|
|
1659
|
+
const plan = planGlobalHooks(opts);
|
|
1660
|
+
switch (plan.action) {
|
|
1661
|
+
case "manual":
|
|
1662
|
+
console.error(
|
|
1663
|
+
isOurHooksDir(plan.global) ? `[prim] prim.gitHooks=manual: left hook files alone, but prim's global hooks in ${PRIM_GIT_HOOKS_DIR} are still active. Run \`prim hooks uninstall --scope user\` to stop them.` : "[prim] prim.gitHooks=manual: left hook files and core.hooksPath untouched. Wire prim yourself with `prim hooks snippet <hook>`."
|
|
1664
|
+
);
|
|
1665
|
+
return "manual";
|
|
1666
|
+
case "system_declined":
|
|
1667
|
+
console.error(
|
|
1668
|
+
`[prim] system core.hooksPath is set to ${plan.system}; a --global set would override it, and prim chains only to .git/hooks (not a system dir), so those hooks would stop firing. Skipped. Either place \`prim hooks snippet <hook>\` in ${plan.system} yourself, or override it with \`prim hooks install --scope user --global-hooks-path --force\`. It changes every repository's hooks: an agent must ask the user before running it.`
|
|
1669
|
+
);
|
|
1670
|
+
return "system_declined";
|
|
1671
|
+
case "refresh":
|
|
1672
|
+
stageGitHookRuntime();
|
|
1673
|
+
if (!writeOwnHooks()) return "runtime_missing";
|
|
1674
|
+
console.log(
|
|
1675
|
+
`Prim global git hooks already active (${PRIM_GIT_HOOKS_DIR}); refreshed scripts.`
|
|
1676
|
+
);
|
|
1677
|
+
return "refreshed";
|
|
1678
|
+
case "present_in_dir":
|
|
1679
|
+
if (!hookRuntimeReady()) {
|
|
1750
1680
|
console.error(
|
|
1751
|
-
`[prim]
|
|
1681
|
+
`[prim] prim's hooks are in ${plan.global}, but the hook runtime they run is not staged, so they do nothing yet. Run \`prim setup\` (or an agent install such as \`prim claude install\`), then retry.`
|
|
1752
1682
|
);
|
|
1753
|
-
return
|
|
1683
|
+
return "runtime_missing";
|
|
1754
1684
|
}
|
|
1755
|
-
console.
|
|
1756
|
-
|
|
1757
|
-
|
|
1685
|
+
console.log(`Prim hooks already present in core.hooksPath dir ${plan.global}.`);
|
|
1686
|
+
return "present";
|
|
1687
|
+
case "set_pointer":
|
|
1688
|
+
case "add_to_dir":
|
|
1689
|
+
break;
|
|
1690
|
+
}
|
|
1691
|
+
if (!hookRuntimeReady()) {
|
|
1692
|
+
console.error(
|
|
1693
|
+
`[prim] did not install prim's global hooks: the hook runtime they run is not staged yet. Run \`prim setup\` (or an agent install such as \`prim claude install\`) first, then retry.`
|
|
1694
|
+
);
|
|
1695
|
+
return "runtime_missing";
|
|
1696
|
+
}
|
|
1697
|
+
if (!opts.machineWide) {
|
|
1698
|
+
console.error(
|
|
1699
|
+
plan.action === "set_pointer" ? plan.overridesSystem !== void 0 ? `[prim] Left git's global hooks alone: setting core.hooksPath would override the system core.hooksPath ${plan.overridesSystem} (its hooks stop firing) and route every repository's hooks through prim. To do that, run \`prim hooks install --scope user --global-hooks-path --force\`. It changes every repository's hooks: an agent must ask the user before running it.` : "[prim] Left git's global hooks alone: setting core.hooksPath would route every repository's hooks through prim. `prim enable` wires each repository you activate. To route every repository through prim instead, run `prim hooks install --scope user --global-hooks-path`. It changes every repository's hooks: an agent must ask the user before running it." : `[prim] Left the hooks in ${plan.global} alone: every repository runs them, so prim edits them only with consent. Repositories that run hooks from there lack prim's hooks until you run \`prim hooks install --scope user --global-hooks-path\`. It changes every repository's hooks: an agent must ask the user before running it.`
|
|
1700
|
+
);
|
|
1701
|
+
return "not_requested";
|
|
1702
|
+
}
|
|
1703
|
+
stageGitHookRuntime();
|
|
1704
|
+
if (plan.action === "add_to_dir") {
|
|
1705
|
+
const dir = expandTilde(plan.global);
|
|
1706
|
+
for (const spec of HOOKS) {
|
|
1707
|
+
appendPrimBlock(resolve(dir, spec.hookName), spec);
|
|
1758
1708
|
}
|
|
1759
|
-
writeOwnHooks();
|
|
1760
|
-
execFileSync("git", ["config", "--global", "core.hooksPath", PRIM_GIT_HOOKS_DIR], {
|
|
1761
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
1762
|
-
timeout: GIT_TIMEOUT_MS
|
|
1763
|
-
});
|
|
1764
1709
|
console.log(
|
|
1765
|
-
`
|
|
1710
|
+
`Added prim hooks into existing core.hooksPath dir ${plan.global} (pointer unchanged).`
|
|
1766
1711
|
);
|
|
1767
|
-
return
|
|
1712
|
+
return "added";
|
|
1768
1713
|
}
|
|
1769
|
-
if (
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
}
|
|
1774
|
-
const dir = expandTilde(global);
|
|
1775
|
-
for (const spec of HOOKS) {
|
|
1776
|
-
appendPrimBlock(resolve(dir, spec.hookName), spec);
|
|
1714
|
+
if (plan.overridesSystem !== void 0) {
|
|
1715
|
+
console.error(
|
|
1716
|
+
`[prim] --force: overriding system core.hooksPath ${plan.overridesSystem}; its hooks will no longer fire (prim chains only to .git/hooks).`
|
|
1717
|
+
);
|
|
1777
1718
|
}
|
|
1719
|
+
if (!writeOwnHooks()) return "runtime_missing";
|
|
1720
|
+
execFileSync("git", ["config", "--global", "core.hooksPath", PRIM_GIT_HOOKS_DIR], {
|
|
1721
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
1722
|
+
timeout: GIT_TIMEOUT_MS
|
|
1723
|
+
});
|
|
1778
1724
|
console.log(
|
|
1779
|
-
`
|
|
1725
|
+
`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.`
|
|
1726
|
+
);
|
|
1727
|
+
return "installed";
|
|
1728
|
+
}
|
|
1729
|
+
function hookRuntimeReady() {
|
|
1730
|
+
if (inspectGitHookEntrypoint() === "ready") return true;
|
|
1731
|
+
stageGitHookRuntime();
|
|
1732
|
+
return inspectGitHookEntrypoint() === "ready";
|
|
1733
|
+
}
|
|
1734
|
+
async function consentToMachineWideHooks(plan, flag, nonInteractive) {
|
|
1735
|
+
if (!planIsMachineWide(plan) || !hookRuntimeReady()) return false;
|
|
1736
|
+
if (flag) return true;
|
|
1737
|
+
if (nonInteractive || !process.stdin.isTTY) return false;
|
|
1738
|
+
return askConfirmation(
|
|
1739
|
+
plan.action === "set_pointer" ? plan.overridesSystem !== void 0 ? `Set git's global core.hooksPath so every repository's hooks run through prim? This overrides the system core.hooksPath ${plan.overridesSystem}, whose hooks stop firing.` : "Set git's global core.hooksPath so every repository's hooks run through prim?" : `Add prim's hooks to ${plan.action === "add_to_dir" ? plan.global : "the global hooks dir"}, which every repository runs?`,
|
|
1740
|
+
process.stderr
|
|
1780
1741
|
);
|
|
1781
|
-
return true;
|
|
1782
1742
|
}
|
|
1783
1743
|
function uninstallGlobalHooks() {
|
|
1784
1744
|
const global = gitConfigGet("--global");
|
|
@@ -1802,26 +1762,75 @@ function uninstallGlobalHooks() {
|
|
|
1802
1762
|
}
|
|
1803
1763
|
console.log("No prim global git hooks found.");
|
|
1804
1764
|
}
|
|
1765
|
+
function reportInstall(result, gitRoot) {
|
|
1766
|
+
const { hookName, path } = result;
|
|
1767
|
+
switch (result.outcome) {
|
|
1768
|
+
case "created":
|
|
1769
|
+
console.log(`Installed prim ${hookName} hook at ${path}.`);
|
|
1770
|
+
return;
|
|
1771
|
+
case "updated":
|
|
1772
|
+
console.log(`Updated prim ${hookName} hook at ${path}.`);
|
|
1773
|
+
return;
|
|
1774
|
+
case "unchanged":
|
|
1775
|
+
console.log(`Already current: prim ${hookName} hook at ${path}.`);
|
|
1776
|
+
return;
|
|
1777
|
+
case "external":
|
|
1778
|
+
console.error(
|
|
1779
|
+
`[prim] ${hookName}: Git runs this repository's hooks from ${path}, outside the repository, so prim left it alone. ${externalHookRemedy(hookName, gitRoot)}.`
|
|
1780
|
+
);
|
|
1781
|
+
return;
|
|
1782
|
+
case "kept":
|
|
1783
|
+
console.error(
|
|
1784
|
+
`[prim] ${hookName}: kept the working pre-v1 prim block at ${path}, outside the repository; it still captures. To upgrade it, ${externalHookRemedy(hookName, gitRoot)}.`
|
|
1785
|
+
);
|
|
1786
|
+
return;
|
|
1787
|
+
case "runtime_missing":
|
|
1788
|
+
console.error(
|
|
1789
|
+
`[prim] ${hookName}: kept the existing prim hook at ${path}; it still works, and its replacement would stay inert until the hook runtime is staged.`
|
|
1790
|
+
);
|
|
1791
|
+
return;
|
|
1792
|
+
default:
|
|
1793
|
+
console.error(`[prim] ${hookName}: left ${path} as it was (${result.outcome}).`);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1805
1796
|
function installHooks(gitRoot, target) {
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1797
|
+
let changed = false;
|
|
1798
|
+
if (target !== "effective") {
|
|
1799
|
+
const effectiveDir = resolveEffectiveGitHook(PRE_COMMIT.hookName, gitRoot).hooksDir;
|
|
1800
|
+
const chosenDir = target === "husky" ? resolve(gitRoot, ".husky") : projectHooksDir(gitRoot);
|
|
1801
|
+
if (resolve(effectiveDir) !== chosenDir && resolve(effectiveDir, "..") !== chosenDir) {
|
|
1802
|
+
console.error(
|
|
1803
|
+
`[prim] note: Git runs this repository's hooks from ${effectiveDir}; hooks in ${chosenDir} will not fire unless that directory chains to them.`
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1810
1806
|
}
|
|
1811
|
-
const
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1807
|
+
for (const spec of HOOKS) {
|
|
1808
|
+
try {
|
|
1809
|
+
const result = target === "effective" ? ensureEffectiveGitHook(spec.hookName, gitRoot, { context: "explicit" }) : ensureGitHookAtPath(
|
|
1810
|
+
spec.hookName,
|
|
1811
|
+
target === "husky" ? resolve(gitRoot, ".husky", spec.hookName) : resolve(projectHooksDir(gitRoot), spec.hookName),
|
|
1812
|
+
{ husky: target === "husky" }
|
|
1813
|
+
);
|
|
1814
|
+
reportInstall(result, gitRoot);
|
|
1815
|
+
if (spec === POST_COMMIT) changed ||= result.changed;
|
|
1816
|
+
if (spec === POST_COMMIT && result.outcome === "external") process.exitCode = 1;
|
|
1817
|
+
} catch (error) {
|
|
1818
|
+
if (spec === POST_COMMIT) throw error;
|
|
1819
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1820
|
+
console.error(
|
|
1821
|
+
`[prim] ${spec.hookName} hook coverage is degraded: ${detail}. To wire it by hand, run \`prim hooks snippet ${spec.hookName}\`.`
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
if (repoActiveFlag(gitRoot) === "true") {
|
|
1826
|
+
recordHooksWired(gitRoot, { onlyIfAbsent: !changed });
|
|
1823
1827
|
}
|
|
1824
1828
|
}
|
|
1829
|
+
function printManualNote(gitRoot) {
|
|
1830
|
+
console.error(
|
|
1831
|
+
`[prim] prim.gitHooks=manual: left the hook files in ${gitRoot} untouched. Wire each hook with \`prim hooks snippet <hook>\`; \`prim doctor\` reports what it finds.`
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1825
1834
|
function registerHooksCommands(program2) {
|
|
1826
1835
|
const hooks = program2.command("hooks").description("Manage git hooks");
|
|
1827
1836
|
hooks.command("install").description(
|
|
@@ -1834,20 +1843,47 @@ function registerHooksCommands(program2) {
|
|
|
1834
1843
|
).addOption(
|
|
1835
1844
|
new Option(
|
|
1836
1845
|
"--scope <scope>",
|
|
1837
|
-
"project (default, this repo) or user (
|
|
1846
|
+
"project (default, this repo) or user (prim's global hooks; needs --global-hooks-path or a yes at a terminal to change git's global config)"
|
|
1838
1847
|
).choices(["project", "user"])
|
|
1839
|
-
).option("--force", "with --scope user, override a system-level core.hooksPath").
|
|
1848
|
+
).option("--force", "with --scope user, override a system-level core.hooksPath").option(
|
|
1849
|
+
"--global-hooks-path",
|
|
1850
|
+
"with --scope user, change git's global hooks so every repository runs prim's hooks"
|
|
1851
|
+
).action(
|
|
1840
1852
|
async (opts, command) => {
|
|
1853
|
+
const globals = command.optsWithGlobals();
|
|
1854
|
+
const nonInteractive = isNonInteractive(globals);
|
|
1841
1855
|
if (opts.scope === "user") {
|
|
1842
|
-
|
|
1856
|
+
const plan = planGlobalHooks({ force: opts.force });
|
|
1857
|
+
const outcome = installGlobalHooks({
|
|
1858
|
+
force: opts.force,
|
|
1859
|
+
machineWide: await consentToMachineWideHooks(
|
|
1860
|
+
plan,
|
|
1861
|
+
opts.globalHooksPath,
|
|
1862
|
+
nonInteractive
|
|
1863
|
+
)
|
|
1864
|
+
});
|
|
1865
|
+
if (outcome === "runtime_missing") {
|
|
1866
|
+
process.exitCode = 1;
|
|
1867
|
+
} else if (opts.globalHooksPath && !INSTALLED_OUTCOMES.has(outcome)) {
|
|
1868
|
+
process.exitCode = EXIT_GLOBAL_HOOKS_NOT_INSTALLED;
|
|
1869
|
+
}
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (opts.globalHooksPath) {
|
|
1873
|
+
console.error("[prim] --global-hooks-path applies only with --scope user");
|
|
1874
|
+
process.exitCode = 2;
|
|
1843
1875
|
return;
|
|
1844
1876
|
}
|
|
1845
|
-
const globals = command.optsWithGlobals();
|
|
1846
|
-
const nonInteractive = isNonInteractive(globals);
|
|
1847
1877
|
const gitRoot = getGitRoot();
|
|
1878
|
+
if (gitHooksMode({ cwd: gitRoot }) === "manual") {
|
|
1879
|
+
printManualNote(gitRoot);
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
stageGitHookRuntime();
|
|
1848
1883
|
if (opts.target === "husky") return installHooks(gitRoot, "husky");
|
|
1849
1884
|
if (opts.target === "git-hooks") return installHooks(gitRoot, "git-hooks");
|
|
1850
|
-
|
|
1885
|
+
const effectiveDir = resolveEffectiveGitHook(PRE_COMMIT.hookName, gitRoot).hooksDir;
|
|
1886
|
+
if (resolve(effectiveDir) === projectHooksDir(gitRoot) && detectHusky(gitRoot)) {
|
|
1851
1887
|
if (globals.yes) return installHooks(gitRoot, "husky");
|
|
1852
1888
|
if (nonInteractive) {
|
|
1853
1889
|
throw new Error(
|
|
@@ -1865,10 +1901,25 @@ function registerHooksCommands(program2) {
|
|
|
1865
1901
|
} else {
|
|
1866
1902
|
console.log("Falling back to .git/hooks install.");
|
|
1867
1903
|
}
|
|
1904
|
+
return installHooks(gitRoot, "git-hooks");
|
|
1868
1905
|
}
|
|
1869
|
-
installHooks(gitRoot, "
|
|
1906
|
+
installHooks(gitRoot, "effective");
|
|
1870
1907
|
}
|
|
1871
1908
|
);
|
|
1909
|
+
hooks.command("snippet").description("Print the block that wires one prim git hook (STDOUT), to place by hand").addArgument(new Argument("<hook>", "git hook").choices(MANAGED_GIT_HOOK_NAMES)).action((hook) => {
|
|
1910
|
+
process.stdout.write(`${managedHookBlock(hook)}
|
|
1911
|
+
`);
|
|
1912
|
+
let entrypoint = "<prim config>/prim-git-hook-v1";
|
|
1913
|
+
try {
|
|
1914
|
+
entrypoint = hookRuntimePaths().gitHookEntrypoint;
|
|
1915
|
+
} catch {
|
|
1916
|
+
}
|
|
1917
|
+
process.stderr.write(
|
|
1918
|
+
`[prim] Place this block right after the shebang of your ${hook} hook (in a Husky v8 hook, right after the husky.sh line), before any exit or exec. It runs prim's ${hook} step where prim is installed and enabled, and does nothing elsewhere.${hook === "post-rewrite" ? " It re-arms stdin, so commands after it still read Git's rewrite pairs." : ""}
|
|
1919
|
+
[prim] From a hook manager, run \`${entrypoint} ${hook} "$@"\`${hook === "post-rewrite" ? " with Git's stdin" : ""} instead. Set \`git config prim.gitHooks manual\` so prim leaves your hook files alone.
|
|
1920
|
+
`
|
|
1921
|
+
);
|
|
1922
|
+
});
|
|
1872
1923
|
hooks.command("uninstall").description(
|
|
1873
1924
|
"Remove the prim git hooks (.git/hooks, or the global core.hooksPath with --scope user)"
|
|
1874
1925
|
).addOption(
|
|
@@ -1888,6 +1939,49 @@ function registerHooksCommands(program2) {
|
|
|
1888
1939
|
// src/commands/activation.ts
|
|
1889
1940
|
var CONNECT_PROMPT = "[prim] GitHub repo connection is required to enable repository-specific file attribution, Conflict Gate verification, and commit correlation. Start the GitHub App connection now?";
|
|
1890
1941
|
var GITHUB_CONNECTION_REQUIRED = "GitHub repo connection is required before using Primitive in this repository. It enables repository-specific file attribution, Conflict Gate verification, and commit correlation. Run `prim github connect` to complete it.";
|
|
1942
|
+
function wireRepositoryHooks(root) {
|
|
1943
|
+
stageGitHookRuntime();
|
|
1944
|
+
refreshOwnedGlobalHooks();
|
|
1945
|
+
const paths = {};
|
|
1946
|
+
let manual = false;
|
|
1947
|
+
let changed = false;
|
|
1948
|
+
for (const hookName of MANAGED_GIT_HOOK_NAMES) {
|
|
1949
|
+
try {
|
|
1950
|
+
const result = ensureEffectiveGitHook(hookName, root, { context: "explicit" });
|
|
1951
|
+
if (result.outcome === "external") {
|
|
1952
|
+
throw new Error(
|
|
1953
|
+
`Git runs this repository's ${hookName} hook from ${result.path}, outside the repository; ${externalHookRemedy(hookName, root)}`
|
|
1954
|
+
);
|
|
1955
|
+
}
|
|
1956
|
+
if (result.outcome === "kept") {
|
|
1957
|
+
process.stderr.write(
|
|
1958
|
+
`[prim] kept the working pre-v1 ${hookName} hook at ${result.path}, outside the repository; to upgrade it, ${externalHookRemedy(hookName, root)}
|
|
1959
|
+
`
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
if (result.outcome === "runtime_missing") {
|
|
1963
|
+
process.stderr.write(
|
|
1964
|
+
`[prim] kept the working pre-v1 ${hookName} hook at ${result.path}; it is replaced once the hook runtime is staged
|
|
1965
|
+
`
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1968
|
+
paths[hookName] = result.path;
|
|
1969
|
+
manual ||= result.outcome === "manual";
|
|
1970
|
+
if (hookName === "post-commit") changed ||= result.changed;
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
if (hookName === "post-commit") throw error;
|
|
1973
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1974
|
+
process.stderr.write(`[prim] ${hookName} hook coverage is degraded: ${detail}
|
|
1975
|
+
`);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
if (manual) {
|
|
1979
|
+
process.stderr.write(
|
|
1980
|
+
"[prim] prim.gitHooks=manual: left hook files untouched; wire them with `prim hooks snippet <hook>`\n"
|
|
1981
|
+
);
|
|
1982
|
+
}
|
|
1983
|
+
return { paths, changed };
|
|
1984
|
+
}
|
|
1891
1985
|
async function maybeConnectRepository(root, globals) {
|
|
1892
1986
|
if (isNonInteractive(globals)) return void 0;
|
|
1893
1987
|
const approved = Boolean(globals.yes) || await askConfirmation(CONNECT_PROMPT, process.stderr);
|
|
@@ -1907,6 +2001,13 @@ async function maybeConnectRepository(root, globals) {
|
|
|
1907
2001
|
}
|
|
1908
2002
|
return void 0;
|
|
1909
2003
|
}
|
|
2004
|
+
function hookPathFields(paths) {
|
|
2005
|
+
return {
|
|
2006
|
+
...paths["pre-commit"] ? { preCommitHook: paths["pre-commit"] } : {},
|
|
2007
|
+
...paths["post-commit"] ? { postCommitHook: paths["post-commit"] } : {},
|
|
2008
|
+
...paths["post-rewrite"] ? { postRewriteHook: paths["post-rewrite"] } : {}
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
1910
2011
|
async function applyActivation(active, globals = {}) {
|
|
1911
2012
|
const root = gitToplevel();
|
|
1912
2013
|
if (!root) {
|
|
@@ -1919,18 +2020,11 @@ async function applyActivation(active, globals = {}) {
|
|
|
1919
2020
|
let phase = active ? "post-commit hook coverage" : "local deactivation";
|
|
1920
2021
|
try {
|
|
1921
2022
|
let binding;
|
|
1922
|
-
let
|
|
1923
|
-
let
|
|
2023
|
+
let hookPaths = {};
|
|
2024
|
+
let hooksChanged = false;
|
|
2025
|
+
const wasActive = repoActiveFlag(root) === "true";
|
|
1924
2026
|
if (active) {
|
|
1925
|
-
|
|
1926
|
-
postCommitHook = ensureEffectivePostCommitHook(root).path;
|
|
1927
|
-
try {
|
|
1928
|
-
postRewriteHook = ensureEffectivePostRewriteHook(root).path;
|
|
1929
|
-
} catch (error) {
|
|
1930
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
1931
|
-
process.stderr.write(`[prim] post-rewrite hook coverage is degraded: ${detail}
|
|
1932
|
-
`);
|
|
1933
|
-
}
|
|
2027
|
+
({ paths: hookPaths, changed: hooksChanged } = wireRepositoryHooks(root));
|
|
1934
2028
|
phase = "GitHub repo connection";
|
|
1935
2029
|
binding = await bindRepository(root);
|
|
1936
2030
|
}
|
|
@@ -1944,8 +2038,7 @@ async function applyActivation(active, globals = {}) {
|
|
|
1944
2038
|
repo: root,
|
|
1945
2039
|
bindingStatus: binding.status,
|
|
1946
2040
|
repositoryFullName: binding.repositoryFullName,
|
|
1947
|
-
...
|
|
1948
|
-
...postRewriteHook ? { postRewriteHook } : {}
|
|
2041
|
+
...hookPathFields(hookPaths)
|
|
1949
2042
|
});
|
|
1950
2043
|
process.exitCode = 1;
|
|
1951
2044
|
return;
|
|
@@ -1960,6 +2053,10 @@ async function applyActivation(active, globals = {}) {
|
|
|
1960
2053
|
}
|
|
1961
2054
|
phase = "local activation";
|
|
1962
2055
|
setRepoActive(root, active);
|
|
2056
|
+
if (!active) clearHooksWired(root);
|
|
2057
|
+
if (active) {
|
|
2058
|
+
recordHooksWired(root, { onlyIfAbsent: wasActive && !hooksChanged });
|
|
2059
|
+
}
|
|
1963
2060
|
await daemonRequest("statusline_invalidate", {}, { timeoutMs: 250 });
|
|
1964
2061
|
process.stderr.write(`[prim] prim ${active ? "enabled" : "disabled"} in ${root}
|
|
1965
2062
|
`);
|
|
@@ -1971,8 +2068,7 @@ async function applyActivation(active, globals = {}) {
|
|
|
1971
2068
|
repositoryFullName: binding.repositoryFullName,
|
|
1972
2069
|
...binding.status === "connected" ? { repoSyncId: binding.repoSyncId } : {}
|
|
1973
2070
|
} : {},
|
|
1974
|
-
...
|
|
1975
|
-
...postRewriteHook ? { postRewriteHook } : {}
|
|
2071
|
+
...hookPathFields(hookPaths)
|
|
1976
2072
|
});
|
|
1977
2073
|
} catch (err) {
|
|
1978
2074
|
const detail = err instanceof Error ? err.message : String(err);
|
|
@@ -2177,7 +2273,7 @@ ${line("project", result.project)}`);
|
|
|
2177
2273
|
|
|
2178
2274
|
// src/commands/daemon.ts
|
|
2179
2275
|
import { spawn as spawn2 } from "child_process";
|
|
2180
|
-
import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
|
|
2276
|
+
import { closeSync as closeSync2, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync as openSync2, readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
|
|
2181
2277
|
import { join as join5 } from "path";
|
|
2182
2278
|
import { Option as Option2 } from "commander";
|
|
2183
2279
|
|
|
@@ -2250,12 +2346,12 @@ async function runLatestDaemonBootstrap(ensureCurrent, options = {}) {
|
|
|
2250
2346
|
import { spawnSync } from "child_process";
|
|
2251
2347
|
import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
|
|
2252
2348
|
import {
|
|
2253
|
-
constants,
|
|
2349
|
+
constants as constants2,
|
|
2254
2350
|
accessSync,
|
|
2255
2351
|
chmodSync,
|
|
2256
2352
|
copyFileSync,
|
|
2257
2353
|
existsSync as existsSync3,
|
|
2258
|
-
lstatSync as
|
|
2354
|
+
lstatSync as lstatSync3,
|
|
2259
2355
|
mkdirSync as mkdirSync2,
|
|
2260
2356
|
mkdtempSync,
|
|
2261
2357
|
readFileSync as readFileSync3,
|
|
@@ -2264,16 +2360,53 @@ import {
|
|
|
2264
2360
|
realpathSync,
|
|
2265
2361
|
renameSync,
|
|
2266
2362
|
rmSync,
|
|
2363
|
+
statSync,
|
|
2267
2364
|
symlinkSync,
|
|
2268
2365
|
writeFileSync as writeFileSync2
|
|
2269
2366
|
} from "fs";
|
|
2270
2367
|
import { homedir as homedir3 } from "os";
|
|
2271
|
-
import { basename, dirname
|
|
2368
|
+
import { basename, dirname, isAbsolute, join as join4, resolve as resolve2 } from "path";
|
|
2369
|
+
|
|
2370
|
+
// src/lib/bounded-file.ts
|
|
2371
|
+
import { constants, closeSync, fstatSync, lstatSync as lstatSync2, openSync, readSync } from "fs";
|
|
2372
|
+
function readBoundedRegularFile(path, maxBytes) {
|
|
2373
|
+
let fd;
|
|
2374
|
+
try {
|
|
2375
|
+
const linked = lstatSync2(path);
|
|
2376
|
+
if (!linked.isFile() || linked.size > maxBytes) return void 0;
|
|
2377
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
2378
|
+
const opened = fstatSync(fd);
|
|
2379
|
+
if (!opened.isFile() || opened.dev !== linked.dev || opened.ino !== linked.ino || opened.size > maxBytes) {
|
|
2380
|
+
return void 0;
|
|
2381
|
+
}
|
|
2382
|
+
const buffer = Buffer.allocUnsafe(opened.size + 1);
|
|
2383
|
+
let length = 0;
|
|
2384
|
+
while (length < buffer.length) {
|
|
2385
|
+
const count = readSync(fd, buffer, length, buffer.length - length, length);
|
|
2386
|
+
if (count === 0) break;
|
|
2387
|
+
length += count;
|
|
2388
|
+
}
|
|
2389
|
+
if (length > opened.size) return void 0;
|
|
2390
|
+
return { text: buffer.subarray(0, length).toString("utf8"), ownerUid: opened.uid };
|
|
2391
|
+
} catch {
|
|
2392
|
+
return void 0;
|
|
2393
|
+
} finally {
|
|
2394
|
+
if (fd !== void 0) {
|
|
2395
|
+
try {
|
|
2396
|
+
closeSync(fd);
|
|
2397
|
+
} catch {
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2272
2402
|
|
|
2273
2403
|
// src/daemon/env-binding.ts
|
|
2274
2404
|
function normalizeApiUrl(url) {
|
|
2275
2405
|
return url.trim().replace(/\/+$/, "");
|
|
2276
2406
|
}
|
|
2407
|
+
function apiUrlsMatch(a, b) {
|
|
2408
|
+
return normalizeApiUrl(a) === normalizeApiUrl(b);
|
|
2409
|
+
}
|
|
2277
2410
|
|
|
2278
2411
|
// src/daemon/launchd.ts
|
|
2279
2412
|
var LAUNCHD_LABEL = "ai.getprimitive.prim-daemon";
|
|
@@ -2292,6 +2425,11 @@ var SERVICE_NOT_FOUND = 113;
|
|
|
2292
2425
|
var TRANSITION_IN_PROGRESS = 5;
|
|
2293
2426
|
var OUTPUT_LIMIT = 4096;
|
|
2294
2427
|
var DAEMON_DISABLED_CONTENT = "disabled by `prim daemon stop`\n";
|
|
2428
|
+
var LAUNCHER_MAX_BYTES = 64 * 1024;
|
|
2429
|
+
var PLIST_MAX_BYTES = 64 * 1024;
|
|
2430
|
+
var DRIFT_HEAL_MARKER = "daemon-drift-heal.json";
|
|
2431
|
+
var DRIFT_HEAL_MARKER_TEMPORARY_RE = /^daemon-drift-heal\.json\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/u;
|
|
2432
|
+
var DRIFT_HEAL_CLAIM_OWNER = "owner.json";
|
|
2295
2433
|
var LaunchctlError = class extends Error {
|
|
2296
2434
|
status;
|
|
2297
2435
|
timedOut;
|
|
@@ -2331,13 +2469,15 @@ function daemonControlPaths(options = {}) {
|
|
|
2331
2469
|
return {
|
|
2332
2470
|
launcher: join4(configDir, `prim-daemon-launcher-v${LAUNCHER_SCHEMA_VERSION}`),
|
|
2333
2471
|
disabledMarker: join4(configDir, "daemon.disabled"),
|
|
2472
|
+
driftHealMarker: join4(configDir, DRIFT_HEAL_MARKER),
|
|
2473
|
+
driftHealClaim: join4(configDir, "daemon-drift-heal.lock"),
|
|
2334
2474
|
lifecycleLockDir: join4(configDir, "daemon.lifecycle.lock"),
|
|
2335
2475
|
legacyDisabledMarker: legacy.disabledMarker,
|
|
2336
2476
|
legacyLifecycleLockDir: legacy.lifecycleLockDir
|
|
2337
2477
|
};
|
|
2338
2478
|
}
|
|
2339
2479
|
function findPackageVersion(entryFile) {
|
|
2340
|
-
let dir =
|
|
2480
|
+
let dir = dirname(resolve2(entryFile));
|
|
2341
2481
|
for (let depth = 0; depth < 6; depth++) {
|
|
2342
2482
|
const packagePath = join4(dir, "package.json");
|
|
2343
2483
|
if (existsSync3(packagePath)) {
|
|
@@ -2349,7 +2489,7 @@ function findPackageVersion(entryFile) {
|
|
|
2349
2489
|
} catch {
|
|
2350
2490
|
}
|
|
2351
2491
|
}
|
|
2352
|
-
const parent =
|
|
2492
|
+
const parent = dirname(dir);
|
|
2353
2493
|
if (parent === dir) break;
|
|
2354
2494
|
dir = parent;
|
|
2355
2495
|
}
|
|
@@ -2391,7 +2531,7 @@ function readOwnedRuntimeManifest(releaseDir) {
|
|
|
2391
2531
|
}
|
|
2392
2532
|
const manifest = parsed;
|
|
2393
2533
|
if (manifest.schemaVersion !== RUNTIME_SCHEMA_VERSION || compareSemver(manifest.version, manifest.version) !== 0 || !isAbsolute(manifest.nodePath) || /[\r\n]/u.test(manifest.nodePath) || manifest.daemonFile !== "prim-daemon-server" || !/^[0-9a-f]{64}$/u.test(manifest.daemonSha256) || new Date(manifest.stagedAt).toISOString() !== manifest.stagedAt || raw !== `${JSON.stringify(manifest, null, 2)}
|
|
2394
|
-
` || !
|
|
2534
|
+
` || !lstatSync3(daemonPath).isFile() || sha256File(daemonPath) !== manifest.daemonSha256) {
|
|
2395
2535
|
return null;
|
|
2396
2536
|
}
|
|
2397
2537
|
const entries = readdirSync2(releaseDir).sort();
|
|
@@ -2400,8 +2540,17 @@ function readOwnedRuntimeManifest(releaseDir) {
|
|
|
2400
2540
|
return null;
|
|
2401
2541
|
}
|
|
2402
2542
|
}
|
|
2543
|
+
function sameNode(recorded, desired) {
|
|
2544
|
+
if (recorded === desired) return true;
|
|
2545
|
+
try {
|
|
2546
|
+
const target = realpathSync(desired);
|
|
2547
|
+
return recorded !== target && realpathSync(recorded) === target;
|
|
2548
|
+
} catch {
|
|
2549
|
+
return false;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2403
2552
|
function sameRuntime(current, desired, daemonPath) {
|
|
2404
|
-
if (!current || current.version !== desired.version || current.nodePath
|
|
2553
|
+
if (!current || current.version !== desired.version || !sameNode(current.nodePath, desired.nodePath) || current.daemonSha256 !== desired.daemonSha256) {
|
|
2405
2554
|
return false;
|
|
2406
2555
|
}
|
|
2407
2556
|
try {
|
|
@@ -2429,7 +2578,7 @@ function stageRuntime(options = {}) {
|
|
|
2429
2578
|
if (!daemonSource || !existsSync3(daemonSource)) {
|
|
2430
2579
|
throw new Error("cannot stage runtime: prim-daemon-server bundle is unavailable");
|
|
2431
2580
|
}
|
|
2432
|
-
const nodePath = resolve2(options.nodePath ??
|
|
2581
|
+
const nodePath = resolve2(options.nodePath ?? stableNodePath());
|
|
2433
2582
|
const version = options.version ?? findPackageVersion(daemonSource);
|
|
2434
2583
|
const desired = {
|
|
2435
2584
|
schemaVersion: RUNTIME_SCHEMA_VERSION,
|
|
@@ -2528,13 +2677,30 @@ function assertRegularFile(path, label) {
|
|
|
2528
2677
|
}
|
|
2529
2678
|
function lstatIfPresent2(path) {
|
|
2530
2679
|
try {
|
|
2531
|
-
return
|
|
2680
|
+
return lstatSync3(path);
|
|
2532
2681
|
} catch (error) {
|
|
2533
2682
|
const code = error.code;
|
|
2534
2683
|
if (code === "ENOENT" || code === "ENOTDIR") return void 0;
|
|
2535
2684
|
throw error;
|
|
2536
2685
|
}
|
|
2537
2686
|
}
|
|
2687
|
+
function driftHealMarkerTemporaries(configDir) {
|
|
2688
|
+
try {
|
|
2689
|
+
return readdirSync2(configDir).filter((entry) => DRIFT_HEAL_MARKER_TEMPORARY_RE.test(entry)).map((entry) => join4(configDir, entry));
|
|
2690
|
+
} catch (error) {
|
|
2691
|
+
const code = error.code;
|
|
2692
|
+
if (code === "ENOENT" || code === "ENOTDIR") return [];
|
|
2693
|
+
throw error;
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
function assertDriftHealClaim(path) {
|
|
2697
|
+
const claim = lstatIfPresent2(path);
|
|
2698
|
+
if (!claim) return;
|
|
2699
|
+
if (!claim.isDirectory() || readdirSync2(path).some((entry) => entry !== DRIFT_HEAL_CLAIM_OWNER)) {
|
|
2700
|
+
throw new Error(`refusing to remove unrecognized daemon drift-heal claim at ${path}`);
|
|
2701
|
+
}
|
|
2702
|
+
assertRegularFile(join4(path, DRIFT_HEAL_CLAIM_OWNER), "daemon drift-heal claim owner");
|
|
2703
|
+
}
|
|
2538
2704
|
function assertExactFile(path, expected, label) {
|
|
2539
2705
|
if (!lstatIfPresent2(path)) return;
|
|
2540
2706
|
assertRegularFile(path, label);
|
|
@@ -2573,17 +2739,16 @@ function assertOwnedDaemonRuntime(options) {
|
|
|
2573
2739
|
);
|
|
2574
2740
|
}
|
|
2575
2741
|
assertRegularFile(logPath, "daemon log");
|
|
2742
|
+
assertRegularFile(control.driftHealMarker, "daemon drift-heal marker");
|
|
2743
|
+
for (const temporary of driftHealMarkerTemporaries(configDir)) {
|
|
2744
|
+
assertRegularFile(temporary, "daemon drift-heal marker temporary");
|
|
2745
|
+
}
|
|
2746
|
+
assertDriftHealClaim(control.driftHealClaim);
|
|
2576
2747
|
assertRegularFile(join4(configDir, "daemon-health.json"), "daemon health snapshot");
|
|
2577
2748
|
assertRegularFile(join4(configDir, "client_instance_id"), "daemon client instance id");
|
|
2578
2749
|
if (service && lstatIfPresent2(service.plistPath)) {
|
|
2579
2750
|
assertRegularFile(service.plistPath, "launchd property list");
|
|
2580
|
-
|
|
2581
|
-
launcherPath: control.launcher,
|
|
2582
|
-
logPath,
|
|
2583
|
-
workingDirectory: options.homeDir ?? homedir3(),
|
|
2584
|
-
label: options.label
|
|
2585
|
-
});
|
|
2586
|
-
if (readFileSync3(service.plistPath, "utf8") !== expected) {
|
|
2751
|
+
if (readFileSync3(service.plistPath, "utf8") !== ownedLaunchAgentPlist(options, logPath)) {
|
|
2587
2752
|
throw new Error(
|
|
2588
2753
|
`refusing to remove unrecognized launchd property list at ${service.plistPath}`
|
|
2589
2754
|
);
|
|
@@ -2660,6 +2825,9 @@ async function removeDaemonRuntime(options = {}) {
|
|
|
2660
2825
|
control.launcher,
|
|
2661
2826
|
control.disabledMarker,
|
|
2662
2827
|
control.legacyDisabledMarker,
|
|
2828
|
+
control.driftHealMarker,
|
|
2829
|
+
...driftHealMarkerTemporaries(configDir),
|
|
2830
|
+
control.driftHealClaim,
|
|
2663
2831
|
...service ? [service.plistPath] : [],
|
|
2664
2832
|
logPath,
|
|
2665
2833
|
join4(configDir, "daemon-health.json"),
|
|
@@ -2671,7 +2839,7 @@ async function removeDaemonRuntime(options = {}) {
|
|
|
2671
2839
|
return withDaemonLifecycleLock(async () => {
|
|
2672
2840
|
assertOwnedDaemonRuntime(options);
|
|
2673
2841
|
for (const path of ownedArtifacts.filter((candidate) => candidate !== runtime.runtimeDir)) {
|
|
2674
|
-
rmSync(path, { force: true });
|
|
2842
|
+
rmSync(path, { force: true, recursive: path === control.driftHealClaim });
|
|
2675
2843
|
}
|
|
2676
2844
|
if (lstatIfPresent2(runtime.runtimeDir)) {
|
|
2677
2845
|
const quarantined = `${runtime.runtimeDir}.uninstall-${String(process.pid)}-${randomBytes3(8).toString("hex")}`;
|
|
@@ -2705,9 +2873,22 @@ ${config.configDir ? `export PRIM_CONFIG_DIR=${shellQuote(config.configDir)}
|
|
|
2705
2873
|
`
|
|
2706
2874
|
};
|
|
2707
2875
|
}
|
|
2876
|
+
function ownedLaunchAgentPlist(options, logPath) {
|
|
2877
|
+
return generateLaunchAgentPlist({
|
|
2878
|
+
launcherPath: daemonControlPaths(options).launcher,
|
|
2879
|
+
logPath,
|
|
2880
|
+
workingDirectory: options.homeDir ?? homedir3(),
|
|
2881
|
+
label: options.label
|
|
2882
|
+
});
|
|
2883
|
+
}
|
|
2708
2884
|
function readDaemonLauncher(path) {
|
|
2885
|
+
return readDaemonLauncherFile(path)?.config ?? null;
|
|
2886
|
+
}
|
|
2887
|
+
function readDaemonLauncherFile(path) {
|
|
2709
2888
|
try {
|
|
2710
|
-
const
|
|
2889
|
+
const file = readBoundedRegularFile(path, LAUNCHER_MAX_BYTES);
|
|
2890
|
+
if (!file) return null;
|
|
2891
|
+
const content = file.text;
|
|
2711
2892
|
const encoded = /^# prim-daemon-launcher: ([A-Za-z0-9_-]+)$/mu.exec(content)?.[1];
|
|
2712
2893
|
if (!encoded) return null;
|
|
2713
2894
|
const value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
@@ -2722,7 +2903,7 @@ function readDaemonLauncher(path) {
|
|
|
2722
2903
|
...typeof value.configDir === "string" ? { configDir: value.configDir } : {}
|
|
2723
2904
|
};
|
|
2724
2905
|
const expected = generateDaemonLauncher(config);
|
|
2725
|
-
return expected.revision === value.revision && expected.content === content ? config : null;
|
|
2906
|
+
return expected.revision === value.revision && expected.content === content ? { config, ownerUid: file.ownerUid } : null;
|
|
2726
2907
|
} catch {
|
|
2727
2908
|
return null;
|
|
2728
2909
|
}
|
|
@@ -2730,9 +2911,9 @@ function readDaemonLauncher(path) {
|
|
|
2730
2911
|
function readUsableDaemonConfig(path) {
|
|
2731
2912
|
const config = readDaemonLauncher(path);
|
|
2732
2913
|
if (!config) return null;
|
|
2733
|
-
const manifest = readRuntimeManifest(join4(
|
|
2914
|
+
const manifest = readRuntimeManifest(join4(dirname(config.daemonPath), "manifest.json"));
|
|
2734
2915
|
try {
|
|
2735
|
-
accessSync(config.nodePath,
|
|
2916
|
+
accessSync(config.nodePath, constants2.X_OK);
|
|
2736
2917
|
return manifest?.version === config.runtimeVersion && manifest.nodePath === config.nodePath && manifest.daemonSha256 === sha256File(config.daemonPath) ? config : null;
|
|
2737
2918
|
} catch {
|
|
2738
2919
|
return null;
|
|
@@ -2824,7 +3005,7 @@ function atomicWrite2(path, content, mode, validate) {
|
|
|
2824
3005
|
}
|
|
2825
3006
|
} catch {
|
|
2826
3007
|
}
|
|
2827
|
-
mkdirSync2(
|
|
3008
|
+
mkdirSync2(dirname(path), { recursive: true, mode: RUNTIME_DIR_MODE });
|
|
2828
3009
|
const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
2829
3010
|
writeFileSync2(temp, content, { encoding: "utf8", mode, flag: "wx" });
|
|
2830
3011
|
try {
|
|
@@ -2860,7 +3041,7 @@ function canonicalProspectivePath(path) {
|
|
|
2860
3041
|
const suffix = [];
|
|
2861
3042
|
let ancestor = resolve2(path);
|
|
2862
3043
|
while (!existsSync3(ancestor)) {
|
|
2863
|
-
const parent =
|
|
3044
|
+
const parent = dirname(ancestor);
|
|
2864
3045
|
if (parent === ancestor) return resolve2(path);
|
|
2865
3046
|
suffix.unshift(basename(ancestor));
|
|
2866
3047
|
ancestor = parent;
|
|
@@ -3076,6 +3257,68 @@ function setDaemonExplicitlyDisabled(disabled, options = {}) {
|
|
|
3076
3257
|
}
|
|
3077
3258
|
atomicWrite2(paths.disabledMarker, content, RUNTIME_FILE_MODE);
|
|
3078
3259
|
}
|
|
3260
|
+
function launcherRunnable(config) {
|
|
3261
|
+
try {
|
|
3262
|
+
accessSync(config.nodePath, constants2.X_OK);
|
|
3263
|
+
return statSync(config.daemonPath).isFile();
|
|
3264
|
+
} catch {
|
|
3265
|
+
return false;
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
function selectedDaemonLauncher(options = {}) {
|
|
3269
|
+
const file = readDaemonLauncherFile(daemonControlPaths(options).launcher);
|
|
3270
|
+
if (!file) return null;
|
|
3271
|
+
const { config, ownerUid } = file;
|
|
3272
|
+
return {
|
|
3273
|
+
runtimeVersion: config.runtimeVersion,
|
|
3274
|
+
...config.apiUrl ? { apiUrl: config.apiUrl } : {},
|
|
3275
|
+
ownerUid,
|
|
3276
|
+
runnable: launcherRunnable(config)
|
|
3277
|
+
};
|
|
3278
|
+
}
|
|
3279
|
+
var XML_ENTITIES = {
|
|
3280
|
+
"&": "&",
|
|
3281
|
+
"<": "<",
|
|
3282
|
+
">": ">",
|
|
3283
|
+
""": '"',
|
|
3284
|
+
"'": "'"
|
|
3285
|
+
};
|
|
3286
|
+
var XML_ENTITY_RE = /&(?:amp|lt|gt|quot|apos);/gu;
|
|
3287
|
+
var PLIST_LABEL_KEY = "<key>Label</key>";
|
|
3288
|
+
var PLIST_PROGRAM_KEY = "<key>ProgramArguments</key>";
|
|
3289
|
+
var PLIST_LABEL_RE = /<key>Label<\/key>\s*<string>([^<]*)<\/string>/u;
|
|
3290
|
+
var PLIST_PROGRAM_RE = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/u;
|
|
3291
|
+
function xmlUnescape(value) {
|
|
3292
|
+
if (value.replace(XML_ENTITY_RE, "").includes("&")) return void 0;
|
|
3293
|
+
return value.replace(XML_ENTITY_RE, (entity) => XML_ENTITIES[entity] ?? entity);
|
|
3294
|
+
}
|
|
3295
|
+
function launchAgentProgram(plist) {
|
|
3296
|
+
if (plist.split(PLIST_LABEL_KEY).length !== 2 || plist.split(PLIST_PROGRAM_KEY).length !== 2) {
|
|
3297
|
+
return void 0;
|
|
3298
|
+
}
|
|
3299
|
+
const label = PLIST_LABEL_RE.exec(plist)?.[1];
|
|
3300
|
+
const program2 = PLIST_PROGRAM_RE.exec(plist)?.[1];
|
|
3301
|
+
if (label === void 0 || program2 === void 0) return void 0;
|
|
3302
|
+
const decodedLabel = xmlUnescape(label);
|
|
3303
|
+
const decodedProgram = xmlUnescape(program2);
|
|
3304
|
+
return decodedLabel === void 0 || decodedProgram === void 0 ? void 0 : { label: decodedLabel, program: decodedProgram };
|
|
3305
|
+
}
|
|
3306
|
+
function launchAgentRunsConfigRoot(options = {}) {
|
|
3307
|
+
try {
|
|
3308
|
+
const service = launchdPaths(options);
|
|
3309
|
+
const plist = readBoundedRegularFile(service.plistPath, PLIST_MAX_BYTES);
|
|
3310
|
+
const agent = plist ? launchAgentProgram(plist.text) : void 0;
|
|
3311
|
+
return agent?.label === (options.label ?? LAUNCHD_LABEL) && agent.program === daemonControlPaths(options).launcher;
|
|
3312
|
+
} catch {
|
|
3313
|
+
return false;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
function daemonDriftHealMarkerPath(options = {}) {
|
|
3317
|
+
return daemonControlPaths(options).driftHealMarker;
|
|
3318
|
+
}
|
|
3319
|
+
function daemonDriftHealClaimPath(options = {}) {
|
|
3320
|
+
return daemonControlPaths(options).driftHealClaim;
|
|
3321
|
+
}
|
|
3079
3322
|
async function ensureMacDaemon(options = {}) {
|
|
3080
3323
|
return withDaemonLifecycleLock(() => ensureMacDaemonLocked(options), options);
|
|
3081
3324
|
}
|
|
@@ -3138,7 +3381,7 @@ async function ensureMacDaemonLocked(options) {
|
|
|
3138
3381
|
};
|
|
3139
3382
|
}
|
|
3140
3383
|
const servicePaths = launchdPaths(options);
|
|
3141
|
-
mkdirSync2(
|
|
3384
|
+
mkdirSync2(dirname(servicePaths.logPath), { recursive: true, mode: RUNTIME_DIR_MODE });
|
|
3142
3385
|
writeFileSync2(servicePaths.logPath, "", { mode: RUNTIME_FILE_MODE, flag: "a" });
|
|
3143
3386
|
chmodSync(servicePaths.logPath, RUNTIME_FILE_MODE);
|
|
3144
3387
|
const launcher = generateDaemonLauncher(daemonConfig);
|
|
@@ -3277,16 +3520,20 @@ var STATUS_PROBE_TIMEOUT_MS = 500;
|
|
|
3277
3520
|
var READY_TIMEOUT_MS = 5e3;
|
|
3278
3521
|
var READY_POLL_MS2 = 100;
|
|
3279
3522
|
var READY_PROBE_TIMEOUT_MS2 = 250;
|
|
3280
|
-
var
|
|
3281
|
-
var
|
|
3523
|
+
var READY_SNAPSHOT_TIMEOUT_MS = 3e4;
|
|
3524
|
+
var READY_SNAPSHOT_POLL_MS = 250;
|
|
3282
3525
|
var EXIT_OK3 = 0;
|
|
3283
3526
|
var EXIT_NOT_RUNNING = 2;
|
|
3284
3527
|
var EXIT_BOOTING = 3;
|
|
3285
|
-
|
|
3286
|
-
|
|
3528
|
+
var VERIFIED_PREFIX = "[prim] \u2713 ";
|
|
3529
|
+
function formatDaemonLifecycleMessage(message, decisionIngestion, snapshot, now = Date.now()) {
|
|
3530
|
+
const backlog = deliveryBacklogSummary(snapshot?.ingestion, now);
|
|
3531
|
+
const state = deliveryBacklogState(snapshot?.ingestion);
|
|
3532
|
+
const verdict = (state === "failing" || state === "retained") && message.startsWith(VERIFIED_PREFIX) ? `[prim] \u26A0 ${message.slice(VERIFIED_PREFIX.length)}` : message;
|
|
3533
|
+
return `${verdict} \xB7 Decision ingestion ${decisionIngestion}${backlog ? ` \xB7 ${backlog}` : ""}`;
|
|
3287
3534
|
}
|
|
3288
|
-
function formatCurrentDaemonLifecycleMessage(message) {
|
|
3289
|
-
return formatDaemonLifecycleMessage(message, decisionIngestionStatus(process.cwd()));
|
|
3535
|
+
function formatCurrentDaemonLifecycleMessage(message, snapshot) {
|
|
3536
|
+
return formatDaemonLifecycleMessage(message, decisionIngestionStatus(process.cwd()), snapshot);
|
|
3290
3537
|
}
|
|
3291
3538
|
function daemonDegradedReason(snapshot) {
|
|
3292
3539
|
if (!snapshot || snapshot.healthy !== false) return void 0;
|
|
@@ -3337,7 +3584,7 @@ function openDaemonLog(configDir = CONFIG_DIR) {
|
|
|
3337
3584
|
if (!existsSync4(configDir)) {
|
|
3338
3585
|
mkdirSync3(configDir, { recursive: true, mode: CONFIG_DIR_MODE });
|
|
3339
3586
|
}
|
|
3340
|
-
return
|
|
3587
|
+
return openSync2(join5(configDir, "daemon.log"), "a", LOG_FILE_MODE);
|
|
3341
3588
|
}
|
|
3342
3589
|
async function waitForReady() {
|
|
3343
3590
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
@@ -3349,17 +3596,23 @@ async function waitForReady() {
|
|
|
3349
3596
|
}
|
|
3350
3597
|
return daemonIsLive(READY_PROBE_TIMEOUT_MS2);
|
|
3351
3598
|
}
|
|
3352
|
-
|
|
3353
|
-
|
|
3599
|
+
function requestStatusSnapshot() {
|
|
3600
|
+
return daemonRequest(
|
|
3601
|
+
"status_snapshot",
|
|
3602
|
+
{},
|
|
3603
|
+
{ timeoutMs: STATUS_PROBE_TIMEOUT_MS }
|
|
3604
|
+
);
|
|
3605
|
+
}
|
|
3606
|
+
async function waitForReadySnapshot(expectedVersion, options = {}) {
|
|
3607
|
+
const request = options.requestSnapshot ?? requestStatusSnapshot;
|
|
3608
|
+
const wait = options.sleep ?? sleep;
|
|
3609
|
+
const nowMs = options.nowMs ?? Date.now;
|
|
3610
|
+
const deadline = nowMs() + READY_SNAPSHOT_TIMEOUT_MS;
|
|
3354
3611
|
let snapshot = null;
|
|
3355
|
-
while (
|
|
3356
|
-
snapshot = await
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
{ timeoutMs: STATUS_PROBE_TIMEOUT_MS }
|
|
3360
|
-
);
|
|
3361
|
-
if (snapshot?.healthy === true && snapshot.version === expectedVersion) return snapshot;
|
|
3362
|
-
await sleep(HEALTHY_POLL_MS);
|
|
3612
|
+
while (nowMs() < deadline) {
|
|
3613
|
+
snapshot = await request();
|
|
3614
|
+
if (daemonStartIsReady(true, snapshot, expectedVersion)) return snapshot;
|
|
3615
|
+
await wait(READY_SNAPSHOT_POLL_MS);
|
|
3363
3616
|
}
|
|
3364
3617
|
return snapshot;
|
|
3365
3618
|
}
|
|
@@ -3372,11 +3625,20 @@ async function verifiedPid(existing) {
|
|
|
3372
3625
|
);
|
|
3373
3626
|
return snapshot?.pid === existing.pid ? snapshot : null;
|
|
3374
3627
|
}
|
|
3375
|
-
function
|
|
3376
|
-
|
|
3628
|
+
function daemonStartIsReady(serviceReady, snapshot, expectedVersion) {
|
|
3629
|
+
if (!serviceReady || !snapshot || expectedVersion === void 0) return false;
|
|
3630
|
+
if (snapshot.version !== expectedVersion || snapshot.needsReauth === true) return false;
|
|
3631
|
+
if (snapshot.healthy === true) return true;
|
|
3632
|
+
return snapshot.heartbeat?.healthy === true && snapshot.ingestion?.healthy === false;
|
|
3633
|
+
}
|
|
3634
|
+
function daemonBacklogFields(snapshot) {
|
|
3635
|
+
const state = deliveryBacklogState(snapshot?.ingestion);
|
|
3636
|
+
if (!state) return {};
|
|
3637
|
+
if (state === "draining") return { draining: true, ingestion: snapshot?.ingestion };
|
|
3638
|
+
return state === "retained" ? { draining: false, deliveryRetained: true, ingestion: snapshot?.ingestion } : { draining: false, deliveryFailing: true, ingestion: snapshot?.ingestion };
|
|
3377
3639
|
}
|
|
3378
|
-
function daemonStartHealthFields(
|
|
3379
|
-
if (
|
|
3640
|
+
function daemonStartHealthFields(ready, snapshot) {
|
|
3641
|
+
if (ready) return daemonBacklogFields(snapshot);
|
|
3380
3642
|
return {
|
|
3381
3643
|
state: "degraded",
|
|
3382
3644
|
needsReauth: snapshot?.needsReauth === true,
|
|
@@ -3384,16 +3646,28 @@ function daemonStartHealthFields(healthy, snapshot) {
|
|
|
3384
3646
|
ingestion: snapshot?.ingestion ?? null
|
|
3385
3647
|
};
|
|
3386
3648
|
}
|
|
3649
|
+
function daemonStartFailureReason(snapshot, expectedVersion) {
|
|
3650
|
+
if (snapshot && !snapshot.needsReauth && expectedVersion !== void 0 && snapshot.version !== expectedVersion) {
|
|
3651
|
+
return `daemon reports ${snapshot.version ? `v${snapshot.version}` : "no version"}, expected v${expectedVersion}`;
|
|
3652
|
+
}
|
|
3653
|
+
return daemonDegradedReason(snapshot);
|
|
3654
|
+
}
|
|
3387
3655
|
async function detachedDaemonStart(opts) {
|
|
3388
3656
|
const existing = readPidfile();
|
|
3389
3657
|
if (existing?.alive) {
|
|
3390
3658
|
const snapshot = await verifiedPid(existing);
|
|
3391
3659
|
if (snapshot) {
|
|
3392
3660
|
process.stderr.write(
|
|
3393
|
-
`${formatCurrentDaemonLifecycleMessage(`[prim] daemon already running (pid=${existing.pid})
|
|
3661
|
+
`${formatCurrentDaemonLifecycleMessage(`[prim] daemon already running (pid=${existing.pid})`, snapshot)}
|
|
3394
3662
|
`
|
|
3395
3663
|
);
|
|
3396
|
-
console.log(
|
|
3664
|
+
console.log(
|
|
3665
|
+
JSON.stringify(
|
|
3666
|
+
{ started: false, pid: existing.pid, ...daemonBacklogFields(snapshot) },
|
|
3667
|
+
null,
|
|
3668
|
+
2
|
|
3669
|
+
)
|
|
3670
|
+
);
|
|
3397
3671
|
return;
|
|
3398
3672
|
}
|
|
3399
3673
|
process.stderr.write(
|
|
@@ -3442,16 +3716,19 @@ async function detachedDaemonStart(opts) {
|
|
|
3442
3716
|
});
|
|
3443
3717
|
child.unref();
|
|
3444
3718
|
if (logFd !== void 0) {
|
|
3445
|
-
|
|
3719
|
+
closeSync2(logFd);
|
|
3446
3720
|
}
|
|
3447
3721
|
const live = await waitForReady();
|
|
3448
3722
|
if (live) {
|
|
3449
3723
|
const after = readPidfile();
|
|
3724
|
+
const snapshot = await requestStatusSnapshot();
|
|
3450
3725
|
process.stderr.write(
|
|
3451
|
-
`${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon started (pid=${after?.pid ?? "?"}, socket=${SOCK_PATH})
|
|
3726
|
+
`${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon started (pid=${after?.pid ?? "?"}, socket=${SOCK_PATH})`, snapshot)}
|
|
3452
3727
|
`
|
|
3453
3728
|
);
|
|
3454
|
-
console.log(
|
|
3729
|
+
console.log(
|
|
3730
|
+
JSON.stringify({ started: true, pid: after?.pid, ...daemonBacklogFields(snapshot) }, null, 2)
|
|
3731
|
+
);
|
|
3455
3732
|
return;
|
|
3456
3733
|
}
|
|
3457
3734
|
process.stderr.write(
|
|
@@ -3574,19 +3851,25 @@ async function macDaemonStart(forceRestart = false) {
|
|
|
3574
3851
|
const result = await ensureMacDaemon({ explicitlyStarted: true, forceRestart });
|
|
3575
3852
|
const serviceReady = result.state === "running";
|
|
3576
3853
|
const expectedVersion = result.runtime?.manifest.version;
|
|
3577
|
-
const snapshot = serviceReady && expectedVersion ? await
|
|
3578
|
-
const
|
|
3579
|
-
if (
|
|
3854
|
+
const snapshot = serviceReady && expectedVersion ? await waitForReadySnapshot(expectedVersion) : null;
|
|
3855
|
+
const ready = daemonStartIsReady(serviceReady, snapshot, expectedVersion);
|
|
3856
|
+
if (ready) {
|
|
3580
3857
|
const verb = result.action === "none" ? "already running" : "started";
|
|
3581
3858
|
const servicePid = result.service.loaded ? result.service.pid : void 0;
|
|
3582
3859
|
process.stderr.write(
|
|
3583
|
-
`${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ${verb} under launchd (pid=${snapshot?.pid ?? servicePid ?? "?"})
|
|
3860
|
+
`${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ${verb} under launchd (pid=${snapshot?.pid ?? servicePid ?? "?"})`, snapshot)}
|
|
3584
3861
|
`
|
|
3585
3862
|
);
|
|
3863
|
+
} else if (!(serviceReady && expectedVersion)) {
|
|
3864
|
+
process.stderr.write(
|
|
3865
|
+
`[prim] \u2717 launchd did not converge on the desired daemon (${result.action}) (see ${LOG_PATH})
|
|
3866
|
+
`
|
|
3867
|
+
);
|
|
3868
|
+
if (!process.exitCode) process.exitCode = EXIT_NOT_RUNNING;
|
|
3586
3869
|
} else {
|
|
3587
|
-
const reason =
|
|
3870
|
+
const reason = daemonStartFailureReason(snapshot, expectedVersion);
|
|
3588
3871
|
process.stderr.write(
|
|
3589
|
-
`[prim] \u2717 launchd daemon did not reach healthy heartbeat
|
|
3872
|
+
`[prim] \u2717 launchd daemon did not reach a healthy heartbeat on the expected version${reason ? ` \xB7 ${reason}` : ""} (see ${LOG_PATH})
|
|
3590
3873
|
`
|
|
3591
3874
|
);
|
|
3592
3875
|
if (!process.exitCode) process.exitCode = EXIT_NOT_RUNNING;
|
|
@@ -3594,14 +3877,20 @@ async function macDaemonStart(forceRestart = false) {
|
|
|
3594
3877
|
console.log(
|
|
3595
3878
|
JSON.stringify(
|
|
3596
3879
|
{
|
|
3597
|
-
started:
|
|
3880
|
+
started: ready,
|
|
3598
3881
|
supervised: true,
|
|
3599
3882
|
action: result.action,
|
|
3600
3883
|
pid: snapshot?.pid ?? (result.service.loaded ? result.service.pid : void 0),
|
|
3601
3884
|
loaded: result.service.loaded,
|
|
3602
3885
|
responding: result.responding,
|
|
3603
|
-
healthy
|
|
3604
|
-
|
|
3886
|
+
// `healthy` keeps meaning "start succeeded" (always equal to `started`
|
|
3887
|
+
// and the exit code) so existing consumers stay correct. It is not the
|
|
3888
|
+
// daemon's own health: `daemon status --json` stays degraded while a
|
|
3889
|
+
// backlog drains, is held back, or fails to deliver, which start
|
|
3890
|
+
// reports additively via `draining`, `deliveryRetained`,
|
|
3891
|
+
// `deliveryFailing`, and `ingestion`.
|
|
3892
|
+
healthy: ready,
|
|
3893
|
+
...daemonStartHealthFields(ready, snapshot),
|
|
3605
3894
|
version: snapshot?.version,
|
|
3606
3895
|
expectedVersion
|
|
3607
3896
|
},
|
|
@@ -3905,11 +4194,12 @@ async function daemonEnsure() {
|
|
|
3905
4194
|
return { disabled };
|
|
3906
4195
|
}
|
|
3907
4196
|
const result = await ensureMacDaemon();
|
|
4197
|
+
const snapshot = result.state === "running" ? await requestStatusSnapshot() : null;
|
|
3908
4198
|
if (result.state === "disabled") {
|
|
3909
4199
|
process.stderr.write("[prim] daemon remains explicitly disabled\n");
|
|
3910
4200
|
} else if (result.state === "running") {
|
|
3911
4201
|
process.stderr.write(
|
|
3912
|
-
`${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ensured under launchd (${result.action})
|
|
4202
|
+
`${formatCurrentDaemonLifecycleMessage(`[prim] \u2713 daemon ensured under launchd (${result.action})`, snapshot)}
|
|
3913
4203
|
`
|
|
3914
4204
|
);
|
|
3915
4205
|
} else {
|
|
@@ -3923,7 +4213,8 @@ async function daemonEnsure() {
|
|
|
3923
4213
|
ensured: result.state === "running",
|
|
3924
4214
|
disabled: result.state === "disabled",
|
|
3925
4215
|
supervised: true,
|
|
3926
|
-
action: result.action
|
|
4216
|
+
action: result.action,
|
|
4217
|
+
...daemonBacklogFields(snapshot)
|
|
3927
4218
|
},
|
|
3928
4219
|
null,
|
|
3929
4220
|
2
|
|
@@ -4958,11 +5249,11 @@ function asRecord(value, path) {
|
|
|
4958
5249
|
}
|
|
4959
5250
|
return value;
|
|
4960
5251
|
}
|
|
4961
|
-
function assertKeys(
|
|
5252
|
+
function assertKeys(record3, path, required, optional = []) {
|
|
4962
5253
|
const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
|
|
4963
|
-
const missing = required.find((key) => !Object.hasOwn(
|
|
5254
|
+
const missing = required.find((key) => !Object.hasOwn(record3, key));
|
|
4964
5255
|
if (missing !== void 0) contractError(path, `is missing ${missing}`);
|
|
4965
|
-
const extra = Object.keys(
|
|
5256
|
+
const extra = Object.keys(record3).find((key) => !allowed.has(key));
|
|
4966
5257
|
if (extra !== void 0) contractError(path, `contains unexpected field ${extra}`);
|
|
4967
5258
|
}
|
|
4968
5259
|
function asString(value, path, options) {
|
|
@@ -4992,97 +5283,97 @@ function asSha(value, path) {
|
|
|
4992
5283
|
if (!SHA_RE.test(sha)) contractError(path, "must be a canonical lowercase Git commit SHA");
|
|
4993
5284
|
return sha;
|
|
4994
5285
|
}
|
|
4995
|
-
function optionalString(
|
|
4996
|
-
if (!Object.hasOwn(
|
|
4997
|
-
return asString(
|
|
5286
|
+
function optionalString(record3, key, path, max) {
|
|
5287
|
+
if (!Object.hasOwn(record3, key)) return void 0;
|
|
5288
|
+
return asString(record3[key], `${path}.${key}`, { max, nonempty: true });
|
|
4998
5289
|
}
|
|
4999
5290
|
function parseProposal(value, path) {
|
|
5000
|
-
const
|
|
5291
|
+
const record3 = asRecord(value, path);
|
|
5001
5292
|
assertKeys(
|
|
5002
|
-
|
|
5293
|
+
record3,
|
|
5003
5294
|
path,
|
|
5004
5295
|
["_id", "repoFullName", "deadCommitSha", "status", "signals", "lastEvaluatedAt"],
|
|
5005
5296
|
["proposedSha", "proposedTier", "nextEvaluateAt"]
|
|
5006
5297
|
);
|
|
5007
|
-
const repoFullName = asString(
|
|
5298
|
+
const repoFullName = asString(record3.repoFullName, `${path}.repoFullName`, {
|
|
5008
5299
|
max: 200,
|
|
5009
5300
|
nonempty: true
|
|
5010
5301
|
});
|
|
5011
5302
|
if (!REPO_FULL_NAME_RE.test(repoFullName)) {
|
|
5012
5303
|
contractError(`${path}.repoFullName`, "must be canonical owner/repository syntax");
|
|
5013
5304
|
}
|
|
5014
|
-
const status =
|
|
5305
|
+
const status = record3.status;
|
|
5015
5306
|
if (status !== "proposed" && status !== "confirmed" && status !== "verification_failed") {
|
|
5016
5307
|
contractError(`${path}.status`, "is not a review-visible proposal status");
|
|
5017
5308
|
}
|
|
5018
|
-
if (!Array.isArray(
|
|
5309
|
+
if (!Array.isArray(record3.signals) || record3.signals.length > MAX_SIGNALS) {
|
|
5019
5310
|
contractError(`${path}.signals`, "must be a bounded array");
|
|
5020
5311
|
}
|
|
5021
|
-
const signals =
|
|
5312
|
+
const signals = record3.signals.map(
|
|
5022
5313
|
(signal, index) => asString(signal, `${path}.signals[${String(index)}]`, {
|
|
5023
5314
|
max: MAX_SIGNAL_LENGTH,
|
|
5024
5315
|
nonempty: true
|
|
5025
5316
|
})
|
|
5026
5317
|
);
|
|
5027
|
-
const proposedSha = Object.hasOwn(
|
|
5318
|
+
const proposedSha = Object.hasOwn(record3, "proposedSha") ? asSha(record3.proposedSha, `${path}.proposedSha`) : void 0;
|
|
5028
5319
|
let proposedTier;
|
|
5029
|
-
if (Object.hasOwn(
|
|
5030
|
-
if (
|
|
5320
|
+
if (Object.hasOwn(record3, "proposedTier")) {
|
|
5321
|
+
if (record3.proposedTier !== "exact_patch_body" && record3.proposedTier !== "exact_file_set") {
|
|
5031
5322
|
contractError(`${path}.proposedTier`, "is not a supported match tier");
|
|
5032
5323
|
}
|
|
5033
|
-
proposedTier =
|
|
5324
|
+
proposedTier = record3.proposedTier;
|
|
5034
5325
|
}
|
|
5035
|
-
const nextEvaluateAt = Object.hasOwn(
|
|
5326
|
+
const nextEvaluateAt = Object.hasOwn(record3, "nextEvaluateAt") ? asTimestamp(record3.nextEvaluateAt, `${path}.nextEvaluateAt`) : void 0;
|
|
5036
5327
|
return {
|
|
5037
|
-
_id: asString(
|
|
5328
|
+
_id: asString(record3._id, `${path}._id`, { max: 256, nonempty: true }),
|
|
5038
5329
|
repoFullName,
|
|
5039
|
-
deadCommitSha: asSha(
|
|
5330
|
+
deadCommitSha: asSha(record3.deadCommitSha, `${path}.deadCommitSha`),
|
|
5040
5331
|
status,
|
|
5041
5332
|
proposedSha,
|
|
5042
5333
|
proposedTier,
|
|
5043
5334
|
signals,
|
|
5044
|
-
lastEvaluatedAt: asTimestamp(
|
|
5335
|
+
lastEvaluatedAt: asTimestamp(record3.lastEvaluatedAt, `${path}.lastEvaluatedAt`),
|
|
5045
5336
|
nextEvaluateAt
|
|
5046
5337
|
};
|
|
5047
5338
|
}
|
|
5048
5339
|
function parseDecision(value, path) {
|
|
5049
|
-
const
|
|
5050
|
-
assertKeys(
|
|
5051
|
-
const status =
|
|
5340
|
+
const record3 = asRecord(value, path);
|
|
5341
|
+
assertKeys(record3, path, ["_id", "intent", "status", "intentTruncated"], ["shortId"]);
|
|
5342
|
+
const status = record3.status;
|
|
5052
5343
|
if (status !== "provisional" && status !== "abandoned") {
|
|
5053
5344
|
contractError(`${path}.status`, "is not a repairable decision status");
|
|
5054
5345
|
}
|
|
5055
5346
|
return {
|
|
5056
|
-
_id: asString(
|
|
5057
|
-
shortId: optionalString(
|
|
5058
|
-
intent: asString(
|
|
5347
|
+
_id: asString(record3._id, `${path}._id`, { max: 256, nonempty: true }),
|
|
5348
|
+
shortId: optionalString(record3, "shortId", path, 128),
|
|
5349
|
+
intent: asString(record3.intent, `${path}.intent`, { max: MAX_INTENT_LENGTH }),
|
|
5059
5350
|
status,
|
|
5060
|
-
intentTruncated: asBoolean(
|
|
5351
|
+
intentTruncated: asBoolean(record3.intentTruncated, `${path}.intentTruncated`)
|
|
5061
5352
|
};
|
|
5062
5353
|
}
|
|
5063
5354
|
function parseListItem(value, path) {
|
|
5064
|
-
const
|
|
5065
|
-
assertKeys(
|
|
5355
|
+
const record3 = asRecord(value, path);
|
|
5356
|
+
assertKeys(record3, path, [
|
|
5066
5357
|
"proposal",
|
|
5067
5358
|
"decisions",
|
|
5068
5359
|
"affectedDecisionCount",
|
|
5069
5360
|
"decisionsTruncated",
|
|
5070
5361
|
"reviewToken"
|
|
5071
5362
|
]);
|
|
5072
|
-
if (!Array.isArray(
|
|
5363
|
+
if (!Array.isArray(record3.decisions) || record3.decisions.length > MAX_REPAIR_DECISIONS_FOR_CONFIRM) {
|
|
5073
5364
|
contractError(`${path}.decisions`, "must be a bounded array");
|
|
5074
5365
|
}
|
|
5075
|
-
const decisions =
|
|
5366
|
+
const decisions = record3.decisions.map(
|
|
5076
5367
|
(decision, index) => parseDecision(decision, `${path}.decisions[${String(index)}]`)
|
|
5077
5368
|
);
|
|
5078
|
-
const proposal = parseProposal(
|
|
5079
|
-
const decisionsTruncated = asBoolean(
|
|
5080
|
-
const affectedDecisionCount =
|
|
5369
|
+
const proposal = parseProposal(record3.proposal, `${path}.proposal`);
|
|
5370
|
+
const decisionsTruncated = asBoolean(record3.decisionsTruncated, `${path}.decisionsTruncated`);
|
|
5371
|
+
const affectedDecisionCount = record3.affectedDecisionCount === null ? null : asCount(record3.affectedDecisionCount, `${path}.affectedDecisionCount`);
|
|
5081
5372
|
let reviewToken;
|
|
5082
|
-
if (
|
|
5373
|
+
if (record3.reviewToken === null) {
|
|
5083
5374
|
reviewToken = null;
|
|
5084
5375
|
} else {
|
|
5085
|
-
reviewToken = asString(
|
|
5376
|
+
reviewToken = asString(record3.reviewToken, `${path}.reviewToken`, {
|
|
5086
5377
|
max: 64,
|
|
5087
5378
|
nonempty: true
|
|
5088
5379
|
});
|
|
@@ -5115,19 +5406,19 @@ function parseListItem(value, path) {
|
|
|
5115
5406
|
}
|
|
5116
5407
|
function parseListPage(value, pageNumber) {
|
|
5117
5408
|
const path = `page ${String(pageNumber)}`;
|
|
5118
|
-
const
|
|
5119
|
-
assertKeys(
|
|
5120
|
-
if (!Array.isArray(
|
|
5409
|
+
const record3 = asRecord(value, path);
|
|
5410
|
+
assertKeys(record3, path, ["repairs", "isDone", "nextCursor", "truncated"]);
|
|
5411
|
+
if (!Array.isArray(record3.repairs) || record3.repairs.length > MAX_REPAIRS_PER_PAGE) {
|
|
5121
5412
|
contractError(`${path}.repairs`, "must be a bounded array");
|
|
5122
5413
|
}
|
|
5123
|
-
const isDone = asBoolean(
|
|
5124
|
-
const truncated = asBoolean(
|
|
5414
|
+
const isDone = asBoolean(record3.isDone, `${path}.isDone`);
|
|
5415
|
+
const truncated = asBoolean(record3.truncated, `${path}.truncated`);
|
|
5125
5416
|
if (truncated === isDone) contractError(path, "has inconsistent isDone/truncated flags");
|
|
5126
5417
|
let nextCursor;
|
|
5127
|
-
if (
|
|
5418
|
+
if (record3.nextCursor === null) {
|
|
5128
5419
|
nextCursor = null;
|
|
5129
5420
|
} else {
|
|
5130
|
-
nextCursor = asString(
|
|
5421
|
+
nextCursor = asString(record3.nextCursor, `${path}.nextCursor`, {
|
|
5131
5422
|
max: MAX_CURSOR_LENGTH,
|
|
5132
5423
|
nonempty: true
|
|
5133
5424
|
});
|
|
@@ -5135,7 +5426,7 @@ function parseListPage(value, pageNumber) {
|
|
|
5135
5426
|
if (isDone && nextCursor !== null) contractError(path, "is done but includes a next cursor");
|
|
5136
5427
|
if (!isDone && nextCursor === null) contractError(path, "is incomplete without a next cursor");
|
|
5137
5428
|
return {
|
|
5138
|
-
repairs:
|
|
5429
|
+
repairs: record3.repairs.map(
|
|
5139
5430
|
(item, index) => parseListItem(item, `${path}.repairs[${String(index)}]`)
|
|
5140
5431
|
),
|
|
5141
5432
|
isDone,
|
|
@@ -5213,23 +5504,23 @@ function normalizedResolutionInput(proposalId, expectedProposedSha) {
|
|
|
5213
5504
|
return { proposalId, expectedProposedSha: expectedProposedSha.toLowerCase() };
|
|
5214
5505
|
}
|
|
5215
5506
|
function parseResolutionOutcome(value, action) {
|
|
5216
|
-
const
|
|
5507
|
+
const record3 = asRecord(value, "resolution response");
|
|
5217
5508
|
const allowed = action === "confirm" ? ["confirmed", "already_applied", "disabled", "no_op", "stale"] : ["rejected", "already_applied", "no_op", "stale"];
|
|
5218
|
-
if (
|
|
5219
|
-
assertKeys(
|
|
5509
|
+
if (record3.status === "backoff") {
|
|
5510
|
+
assertKeys(record3, "resolution response", ["status", "retryAt"]);
|
|
5220
5511
|
if (action !== "confirm") {
|
|
5221
5512
|
contractError("resolution response.status", "backoff is impossible for reject");
|
|
5222
5513
|
}
|
|
5223
5514
|
return {
|
|
5224
5515
|
status: "backoff",
|
|
5225
|
-
retryAt: asTimestamp(
|
|
5516
|
+
retryAt: asTimestamp(record3.retryAt, "resolution response.retryAt")
|
|
5226
5517
|
};
|
|
5227
5518
|
}
|
|
5228
|
-
assertKeys(
|
|
5229
|
-
if (typeof
|
|
5519
|
+
assertKeys(record3, "resolution response", ["status"]);
|
|
5520
|
+
if (typeof record3.status !== "string" || !allowed.includes(record3.status)) {
|
|
5230
5521
|
contractError("resolution response.status", "is not a supported outcome");
|
|
5231
5522
|
}
|
|
5232
|
-
return { status:
|
|
5523
|
+
return { status: record3.status };
|
|
5233
5524
|
}
|
|
5234
5525
|
function normalizedReviewToken(value) {
|
|
5235
5526
|
if (value === void 0 || !REVIEW_TOKEN_INPUT_RE.test(value)) {
|
|
@@ -5240,18 +5531,18 @@ function normalizedReviewToken(value) {
|
|
|
5240
5531
|
return value.toLowerCase();
|
|
5241
5532
|
}
|
|
5242
5533
|
function parseConflictOutcome(value, action) {
|
|
5243
|
-
const
|
|
5244
|
-
assertKeys(
|
|
5534
|
+
const record3 = asRecord(value, "resolution conflict response");
|
|
5535
|
+
assertKeys(record3, "resolution conflict response", ["status"]);
|
|
5245
5536
|
if (action !== "confirm") {
|
|
5246
5537
|
contractError(
|
|
5247
5538
|
"resolution conflict response.status",
|
|
5248
5539
|
"a review conflict is impossible for reject"
|
|
5249
5540
|
);
|
|
5250
5541
|
}
|
|
5251
|
-
if (
|
|
5542
|
+
if (record3.status !== "review_too_large" && record3.status !== "stale_review") {
|
|
5252
5543
|
contractError("resolution conflict response.status", "is not a supported 409 outcome");
|
|
5253
5544
|
}
|
|
5254
|
-
return { status:
|
|
5545
|
+
return { status: record3.status };
|
|
5255
5546
|
}
|
|
5256
5547
|
async function resolveRepair(proposalId, expectedProposedSha, action, expectedReviewToken, deps = defaultDeps6) {
|
|
5257
5548
|
const normalized = normalizedResolutionInput(proposalId, expectedProposedSha);
|
|
@@ -6306,6 +6597,7 @@ function registerDecisionsCommands(program2) {
|
|
|
6306
6597
|
|
|
6307
6598
|
// src/commands/doctor.ts
|
|
6308
6599
|
import { existsSync as existsSync6 } from "fs";
|
|
6600
|
+
import { Option as Option4 } from "commander";
|
|
6309
6601
|
|
|
6310
6602
|
// src/protocol/decision-lifecycle.ts
|
|
6311
6603
|
var DECISION_LIFECYCLE_PROTOCOL_VERSION = 2;
|
|
@@ -6330,28 +6622,35 @@ function parseOrganizationBinding(value) {
|
|
|
6330
6622
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
6331
6623
|
return { state: "server_contract_unavailable" };
|
|
6332
6624
|
}
|
|
6333
|
-
const
|
|
6334
|
-
if (
|
|
6625
|
+
const record3 = value;
|
|
6626
|
+
if (record3.authenticated !== true || record3.organizationBindingVersion !== ORGANIZATION_BINDING_VERSION || !isCaptureAuthorityKind(record3.captureAuthorityKind)) {
|
|
6335
6627
|
return { state: "server_contract_unavailable" };
|
|
6336
6628
|
}
|
|
6337
|
-
if (
|
|
6629
|
+
if (record3.organizationId === null && record3.workosOrganizationId === null) {
|
|
6338
6630
|
return { state: "no_current_organization" };
|
|
6339
6631
|
}
|
|
6340
|
-
if (safeOrganizationId(
|
|
6632
|
+
if (safeOrganizationId(record3.organizationId) && record3.workosOrganizationId === null) {
|
|
6341
6633
|
return { state: "organization_identity_unreconciled" };
|
|
6342
6634
|
}
|
|
6343
|
-
if (!safeOrganizationId(
|
|
6635
|
+
if (!safeOrganizationId(record3.organizationId) || !safeOrganizationId(record3.workosOrganizationId)) {
|
|
6344
6636
|
return { state: "server_contract_unavailable" };
|
|
6345
6637
|
}
|
|
6346
6638
|
return {
|
|
6347
6639
|
state: "current",
|
|
6348
6640
|
binding: {
|
|
6349
|
-
captureAuthorityKind:
|
|
6350
|
-
organizationId:
|
|
6351
|
-
workosOrganizationId:
|
|
6641
|
+
captureAuthorityKind: record3.captureAuthorityKind,
|
|
6642
|
+
organizationId: record3.organizationId,
|
|
6643
|
+
workosOrganizationId: record3.workosOrganizationId
|
|
6352
6644
|
}
|
|
6353
6645
|
};
|
|
6354
6646
|
}
|
|
6647
|
+
function summarizeRetainedBuckets(retained) {
|
|
6648
|
+
const counts = /* @__PURE__ */ new Map();
|
|
6649
|
+
for (const item of retained) {
|
|
6650
|
+
counts.set(item.reason, (counts.get(item.reason) ?? 0) + 1);
|
|
6651
|
+
}
|
|
6652
|
+
return [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([reason, count]) => `${reason}:${String(count)}`).join(", ");
|
|
6653
|
+
}
|
|
6355
6654
|
function classifyJournalBuckets(buckets, binding) {
|
|
6356
6655
|
const deliverableBuckets = /* @__PURE__ */ new Set();
|
|
6357
6656
|
const retainedBuckets = [];
|
|
@@ -6423,7 +6722,7 @@ async function inspectJournalDelivery(buckets, options = {}) {
|
|
|
6423
6722
|
}
|
|
6424
6723
|
|
|
6425
6724
|
// src/commands/hermes-install.ts
|
|
6426
|
-
import { existsSync as existsSync5, readFileSync as readFileSync5, rmSync as rmSync2, statSync } from "fs";
|
|
6725
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, rmSync as rmSync2, statSync as statSync2 } from "fs";
|
|
6427
6726
|
import { homedir as homedir4 } from "os";
|
|
6428
6727
|
import { join as join6 } from "path";
|
|
6429
6728
|
import { Document, isMap, isSeq, parseAllDocuments, stringify, visit } from "yaml";
|
|
@@ -6831,7 +7130,7 @@ function readConfigSnapshot(path) {
|
|
|
6831
7130
|
if (!existsSync5(path)) return { exists: false, mode: CONFIG_MODE, raw: "" };
|
|
6832
7131
|
return {
|
|
6833
7132
|
exists: true,
|
|
6834
|
-
mode:
|
|
7133
|
+
mode: statSync2(path).mode & 511,
|
|
6835
7134
|
raw: readFileSync5(path, "utf8")
|
|
6836
7135
|
};
|
|
6837
7136
|
}
|
|
@@ -6973,7 +7272,6 @@ function registerHermesCommands(program2) {
|
|
|
6973
7272
|
// src/commands/doctor.ts
|
|
6974
7273
|
var DAEMON_PROBE_TIMEOUT_MS = 500;
|
|
6975
7274
|
var CONNECTIVITY_TIMEOUT_MS = 3e3;
|
|
6976
|
-
var MS_PER_SECOND = 1e3;
|
|
6977
7275
|
var STALE_PENDING_MS = 3e4;
|
|
6978
7276
|
var EXIT_UNHEALTHY = 1;
|
|
6979
7277
|
function classifyDoctor(checks) {
|
|
@@ -7095,8 +7393,19 @@ function classifyDaemonHealth(snapshot, options = {}) {
|
|
|
7095
7393
|
};
|
|
7096
7394
|
}
|
|
7097
7395
|
if (!snapshot.ingestion?.healthy) {
|
|
7098
|
-
|
|
7099
|
-
|
|
7396
|
+
if (options.backlogExpected && deliveryBacklogState(snapshot.ingestion) === "draining") {
|
|
7397
|
+
const ingestionStatus2 = options.ingestionStatus ?? decisionIngestionStatus(process.cwd());
|
|
7398
|
+
const live = options.backlog;
|
|
7399
|
+
const progress = live && live.pendingCount === 0 && live.pendingSampled !== true ? "backlog drained; daemon health refreshes on its next sweep" : deliveryBacklogSummary(snapshot.ingestion, options.now, live);
|
|
7400
|
+
return {
|
|
7401
|
+
name: "daemon",
|
|
7402
|
+
status: "warn",
|
|
7403
|
+
detail: `supervised and live${snapshot.version ? ` \xB7 v${snapshot.version}` : ""} \xB7 Decision ingestion ${ingestionStatus2} \xB7 ${progress}`
|
|
7404
|
+
};
|
|
7405
|
+
}
|
|
7406
|
+
const counts = options.backlog ?? snapshot.ingestion;
|
|
7407
|
+
const pending = counts?.pendingCount ?? 0;
|
|
7408
|
+
const pendingLabel = counts?.pendingSampled ? `at least ${String(pending)}` : String(pending);
|
|
7100
7409
|
return {
|
|
7101
7410
|
name: "daemon",
|
|
7102
7411
|
status: "fail",
|
|
@@ -7113,17 +7422,23 @@ function classifyDaemonHealth(snapshot, options = {}) {
|
|
|
7113
7422
|
detail: `supervised and healthy${snapshot.version ? ` \xB7 v${snapshot.version}` : ""} \xB7 Decision ingestion ${ingestionStatus}`
|
|
7114
7423
|
};
|
|
7115
7424
|
}
|
|
7116
|
-
async function
|
|
7425
|
+
async function probeDelivery() {
|
|
7426
|
+
const stats = pendingJournalStats();
|
|
7427
|
+
const now = Date.now();
|
|
7428
|
+
const disabled = daemonExplicitlyDisabled();
|
|
7429
|
+
const expectedVersion = packageVersion();
|
|
7117
7430
|
let service;
|
|
7118
7431
|
if (process.platform === "darwin") {
|
|
7119
7432
|
try {
|
|
7120
7433
|
service = getLaunchdService();
|
|
7121
7434
|
} catch (error) {
|
|
7122
|
-
const detail = boundedHealthError(error instanceof Error ? error.message : String(error));
|
|
7123
7435
|
return {
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7436
|
+
snapshot: null,
|
|
7437
|
+
launchdError: error instanceof Error ? error.message : String(error),
|
|
7438
|
+
disabled,
|
|
7439
|
+
expectedVersion,
|
|
7440
|
+
stats,
|
|
7441
|
+
now
|
|
7127
7442
|
};
|
|
7128
7443
|
}
|
|
7129
7444
|
}
|
|
@@ -7132,53 +7447,74 @@ async function checkDaemon() {
|
|
|
7132
7447
|
{ callerEnv: getSiteUrl() },
|
|
7133
7448
|
{ timeoutMs: DAEMON_PROBE_TIMEOUT_MS }
|
|
7134
7449
|
);
|
|
7135
|
-
return
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7450
|
+
return { snapshot, service, disabled, expectedVersion, stats, now };
|
|
7451
|
+
}
|
|
7452
|
+
function daemonCheckVouchesForDrain(check) {
|
|
7453
|
+
return check.name === "daemon" && check.status !== "fail";
|
|
7454
|
+
}
|
|
7455
|
+
function classifyDelivery(probe, options = {}) {
|
|
7456
|
+
const { stats, now } = probe;
|
|
7457
|
+
const launchdDetail = probe.launchdError === void 0 ? void 0 : boundedHealthError(probe.launchdError);
|
|
7458
|
+
const daemon = probe.launchdError === void 0 ? classifyDaemonHealth(probe.snapshot, {
|
|
7459
|
+
disabled: probe.disabled,
|
|
7460
|
+
service: probe.service,
|
|
7461
|
+
ingestionStatus: probe.ingestionStatus,
|
|
7462
|
+
expectedVersion: probe.expectedVersion,
|
|
7463
|
+
backlogExpected: options.backlogExpected,
|
|
7464
|
+
backlog: {
|
|
7465
|
+
pendingCount: stats.pendingCount,
|
|
7466
|
+
pendingSampled: stats.sampled,
|
|
7467
|
+
oldestPendingAt: stats.oldestPendingAt
|
|
7468
|
+
},
|
|
7469
|
+
now
|
|
7470
|
+
}) : {
|
|
7471
|
+
name: "daemon",
|
|
7472
|
+
status: "fail",
|
|
7473
|
+
detail: `launchd status unavailable${launchdDetail ? `: ${launchdDetail}` : ""}`
|
|
7474
|
+
};
|
|
7475
|
+
const journal = classifyJournal(stats, now, {
|
|
7476
|
+
backlogExpected: options.backlogExpected === true && daemonCheckVouchesForDrain(daemon)
|
|
7139
7477
|
});
|
|
7478
|
+
return [daemon, journal, classifyStranded(stats)];
|
|
7140
7479
|
}
|
|
7141
|
-
function
|
|
7142
|
-
const stats = pendingJournalStats();
|
|
7480
|
+
function classifyJournal(stats, now, options = {}) {
|
|
7143
7481
|
const pending = stats.pendingCount;
|
|
7144
7482
|
const pendingLabel = stats.sampled ? `at least ${String(pending)}` : String(pending);
|
|
7483
|
+
const backlog = (detail) => options.backlogExpected ? {
|
|
7484
|
+
name: "journal",
|
|
7485
|
+
status: "warn",
|
|
7486
|
+
detail: `${formatPendingBacklog(
|
|
7487
|
+
{
|
|
7488
|
+
pendingCount: pending,
|
|
7489
|
+
pendingSampled: stats.sampled,
|
|
7490
|
+
oldestPendingAt: stats.oldestPendingAt
|
|
7491
|
+
},
|
|
7492
|
+
now
|
|
7493
|
+
)} \u2014 draining in the background`
|
|
7494
|
+
} : { name: "journal", status: "fail", detail };
|
|
7145
7495
|
if (pending === 0) {
|
|
7146
7496
|
if (stats.sampled) {
|
|
7147
|
-
return
|
|
7148
|
-
name: "journal",
|
|
7149
|
-
status: "fail",
|
|
7150
|
-
detail: "bounded journal sample could not prove the queue is empty"
|
|
7151
|
-
};
|
|
7497
|
+
return backlog("bounded journal sample could not prove the queue is empty");
|
|
7152
7498
|
}
|
|
7153
7499
|
return { name: "journal", status: "ok", detail: "no pending moves" };
|
|
7154
7500
|
}
|
|
7155
7501
|
if (stats.oldestPendingAt === void 0) {
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
status: "fail",
|
|
7159
|
-
detail: `${pendingLabel} pending with no readable capture timestamp`
|
|
7160
|
-
};
|
|
7502
|
+
const detail = `${pendingLabel} pending with no readable capture timestamp`;
|
|
7503
|
+
return stats.sampled ? backlog(detail) : { name: "journal", status: "fail", detail };
|
|
7161
7504
|
}
|
|
7162
|
-
const oldestMs =
|
|
7505
|
+
const oldestMs = now - stats.oldestPendingAt;
|
|
7163
7506
|
const oldestS = Math.round(oldestMs / MS_PER_SECOND);
|
|
7164
7507
|
if (oldestMs > STALE_PENDING_MS) {
|
|
7165
|
-
return
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
detail: `${pendingLabel} pending, oldest observed ${String(oldestS)}s \u2014 30s delivery SLA missed`
|
|
7169
|
-
};
|
|
7508
|
+
return backlog(
|
|
7509
|
+
`${pendingLabel} pending, oldest observed ${String(oldestS)}s \u2014 30s delivery SLA missed`
|
|
7510
|
+
);
|
|
7170
7511
|
}
|
|
7171
7512
|
if (stats.sampled) {
|
|
7172
|
-
return {
|
|
7173
|
-
name: "journal",
|
|
7174
|
-
status: "fail",
|
|
7175
|
-
detail: `${pendingLabel} pending in bounded sample; 30s delivery SLA cannot be proven`
|
|
7176
|
-
};
|
|
7513
|
+
return backlog(`${pendingLabel} pending in bounded sample; 30s delivery SLA cannot be proven`);
|
|
7177
7514
|
}
|
|
7178
7515
|
return { name: "journal", status: "ok", detail: `${String(pending)} pending, draining` };
|
|
7179
7516
|
}
|
|
7180
|
-
function
|
|
7181
|
-
const stats = pendingJournalStats();
|
|
7517
|
+
function classifyStranded(stats) {
|
|
7182
7518
|
if (stats.strandedFileCount === 0 && !stats.strandedSampled) {
|
|
7183
7519
|
return { name: "stranded", status: "ok", detail: "none" };
|
|
7184
7520
|
}
|
|
@@ -7204,15 +7540,10 @@ function classifyJournalOrganization(bucketCount, retainedBuckets) {
|
|
|
7204
7540
|
detail: "all pending buckets match the active credential"
|
|
7205
7541
|
};
|
|
7206
7542
|
}
|
|
7207
|
-
const reasonCounts = /* @__PURE__ */ new Map();
|
|
7208
|
-
for (const item of retainedBuckets) {
|
|
7209
|
-
reasonCounts.set(item.reason, (reasonCounts.get(item.reason) ?? 0) + 1);
|
|
7210
|
-
}
|
|
7211
|
-
const reasons = [...reasonCounts].sort(([left], [right]) => left.localeCompare(right)).map(([reason, count]) => `${reason}:${String(count)}`).join(", ");
|
|
7212
7543
|
return {
|
|
7213
7544
|
name: "journal-org",
|
|
7214
7545
|
status: "fail",
|
|
7215
|
-
detail: `${String(retainedBuckets.length)} bucket(s) retained (${
|
|
7546
|
+
detail: `${String(retainedBuckets.length)} bucket(s) retained (${summarizeRetainedBuckets(retainedBuckets)})`
|
|
7216
7547
|
};
|
|
7217
7548
|
}
|
|
7218
7549
|
async function checkJournalOrganization() {
|
|
@@ -7282,7 +7613,26 @@ async function checkRepositoryBinding() {
|
|
|
7282
7613
|
};
|
|
7283
7614
|
}
|
|
7284
7615
|
}
|
|
7285
|
-
function
|
|
7616
|
+
function managedHookRemedy(inspection) {
|
|
7617
|
+
switch (inspection.reason) {
|
|
7618
|
+
case "missing":
|
|
7619
|
+
case "missing_block":
|
|
7620
|
+
case "stale_block":
|
|
7621
|
+
case "legacy_block":
|
|
7622
|
+
case "unreachable_block":
|
|
7623
|
+
case "misplaced_block":
|
|
7624
|
+
if (inspection.location === "prim") return "run `prim enable` to refresh prim's global hooks";
|
|
7625
|
+
if (inspection.location === "external") {
|
|
7626
|
+
return `outside this repository \u2014 ${externalHookRemedy(inspection.hookName, inspection.gitRoot)}`;
|
|
7627
|
+
}
|
|
7628
|
+
return "run `prim hooks install`";
|
|
7629
|
+
case "entrypoint_missing":
|
|
7630
|
+
return "run `prim enable` to stage the hook runtime";
|
|
7631
|
+
default:
|
|
7632
|
+
return void 0;
|
|
7633
|
+
}
|
|
7634
|
+
}
|
|
7635
|
+
function classifyManagedHook(hookName, inspection, firing) {
|
|
7286
7636
|
if (inspection.covered) {
|
|
7287
7637
|
return {
|
|
7288
7638
|
name: hookName,
|
|
@@ -7290,15 +7640,78 @@ function classifyManagedHook(hookName, inspection) {
|
|
|
7290
7640
|
detail: `effective and executable \xB7 ${inspection.kind} \xB7 ${inspection.hookPath}`
|
|
7291
7641
|
};
|
|
7292
7642
|
}
|
|
7643
|
+
const reason = inspection.reason ?? "uncovered";
|
|
7644
|
+
if (inspection.mode === "manual") {
|
|
7645
|
+
if (hookName === "post-commit" && firing?.state === "fired") {
|
|
7646
|
+
return {
|
|
7647
|
+
name: hookName,
|
|
7648
|
+
status: "ok",
|
|
7649
|
+
detail: `manual (prim.gitHooks=manual) \xB7 ran for the latest commit \xB7 ${inspection.hookPath}`
|
|
7650
|
+
};
|
|
7651
|
+
}
|
|
7652
|
+
return {
|
|
7653
|
+
name: hookName,
|
|
7654
|
+
status: "warn",
|
|
7655
|
+
detail: `manual (prim.gitHooks=manual) \xB7 ${reason} \xB7 wire with \`prim hooks snippet ${hookName}\` \xB7 ${inspection.hookPath}`
|
|
7656
|
+
};
|
|
7657
|
+
}
|
|
7658
|
+
const remedy = managedHookRemedy(inspection);
|
|
7659
|
+
const stillCaptures = reason === "legacy_block" || reason === "misplaced_block";
|
|
7293
7660
|
return {
|
|
7294
7661
|
name: hookName,
|
|
7295
|
-
status: "fail",
|
|
7296
|
-
detail: `${
|
|
7662
|
+
status: hookName === "pre-commit" || stillCaptures ? "warn" : "fail",
|
|
7663
|
+
detail: `${reason}${remedy ? ` \xB7 ${remedy}` : ""} \xB7 ${inspection.hookPath}`
|
|
7297
7664
|
};
|
|
7298
7665
|
}
|
|
7299
|
-
function
|
|
7666
|
+
function minutesAgo(at, now) {
|
|
7667
|
+
const minutes = Math.max(0, Math.round((now - at) / 6e4));
|
|
7668
|
+
return minutes === 0 ? "just now" : `${String(minutes)}m ago`;
|
|
7669
|
+
}
|
|
7670
|
+
function classifyHookFiring(firing, now = Date.now()) {
|
|
7671
|
+
const name = "hook-fired";
|
|
7672
|
+
switch (firing.state) {
|
|
7673
|
+
case "inactive":
|
|
7674
|
+
return { name, status: "ok", detail: "repository not active \u2014 no post-commit run expected" };
|
|
7675
|
+
case "unverified":
|
|
7676
|
+
return {
|
|
7677
|
+
name,
|
|
7678
|
+
status: "ok",
|
|
7679
|
+
detail: "no local commit since the hooks were wired \u2014 the next commit is checked"
|
|
7680
|
+
};
|
|
7681
|
+
case "pending":
|
|
7682
|
+
return {
|
|
7683
|
+
name,
|
|
7684
|
+
status: "ok",
|
|
7685
|
+
detail: `latest commit is under ${String(POST_COMMIT_GRACE_MS / 1e3)}s old \u2014 capture may still be starting`
|
|
7686
|
+
};
|
|
7687
|
+
case "fired":
|
|
7688
|
+
return {
|
|
7689
|
+
name,
|
|
7690
|
+
status: "ok",
|
|
7691
|
+
detail: `post-commit reached prim for the latest commit (${minutesAgo(firing.firedAt, now)})`
|
|
7692
|
+
};
|
|
7693
|
+
case "not_firing":
|
|
7694
|
+
return {
|
|
7695
|
+
name,
|
|
7696
|
+
status: "fail",
|
|
7697
|
+
detail: `post-commit never reached prim for the commit made ${minutesAgo(firing.commitAt, now)}${firing.firedAt === void 0 ? "" : ` (last run ${minutesAgo(firing.firedAt, now)})`} \xB7 likely an exit/exec before prim's block, hooks turned off for that commit (HUSKY=0, a core.hooksPath override), or a git client without prim's environment \xB7 fix the cause, then commit to re-check \xB7 if prim was off for that commit (set with \`git config prim.active\` directly), run \`prim disable\` then \`prim enable\` to restart the check`
|
|
7698
|
+
};
|
|
7699
|
+
}
|
|
7700
|
+
}
|
|
7701
|
+
function inspectFiringHere() {
|
|
7300
7702
|
try {
|
|
7301
|
-
|
|
7703
|
+
const root = gitToplevel();
|
|
7704
|
+
return root ? inspectPostCommitFiring(root) : void 0;
|
|
7705
|
+
} catch {
|
|
7706
|
+
return void 0;
|
|
7707
|
+
}
|
|
7708
|
+
}
|
|
7709
|
+
function checkHookFiring(firing) {
|
|
7710
|
+
return firing ? classifyHookFiring(firing) : { name: "hook-fired", status: "warn", detail: "not a git repository" };
|
|
7711
|
+
}
|
|
7712
|
+
function checkManagedHook(hookName, firing) {
|
|
7713
|
+
try {
|
|
7714
|
+
return classifyManagedHook(hookName, inspectEffectiveGitHook(hookName), firing);
|
|
7302
7715
|
} catch (error) {
|
|
7303
7716
|
const detail = error instanceof Error ? error.message : String(error);
|
|
7304
7717
|
return {
|
|
@@ -7617,36 +8030,39 @@ async function checkFeedbackCapability() {
|
|
|
7617
8030
|
return { name: "feedback-api", status: "warn", detail: message.slice(0, 80) };
|
|
7618
8031
|
}
|
|
7619
8032
|
}
|
|
7620
|
-
function
|
|
7621
|
-
try {
|
|
7622
|
-
refreshOwnedGlobalHooks();
|
|
7623
|
-
} catch {
|
|
7624
|
-
}
|
|
7625
|
-
}
|
|
7626
|
-
async function collectChecks() {
|
|
7627
|
-
refreshOwnedGlobalHooksForHealth();
|
|
8033
|
+
async function independentChecks() {
|
|
7628
8034
|
const backend = await checkBackend();
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
8035
|
+
const firing = inspectFiringHere();
|
|
8036
|
+
return {
|
|
8037
|
+
before: [checkAuth()],
|
|
8038
|
+
after: [
|
|
8039
|
+
await checkJournalOrganization(),
|
|
8040
|
+
checkFeedbackHooks(),
|
|
8041
|
+
...checkAgentHooks(),
|
|
8042
|
+
checkHookRuntime(),
|
|
8043
|
+
await checkRepositoryBinding(),
|
|
8044
|
+
checkManagedHook("pre-commit"),
|
|
8045
|
+
checkManagedHook("post-commit", firing),
|
|
8046
|
+
checkManagedHook("post-rewrite"),
|
|
8047
|
+
checkHookFiring(firing),
|
|
8048
|
+
...backend,
|
|
8049
|
+
await checkFeedbackCapability()
|
|
8050
|
+
]
|
|
8051
|
+
};
|
|
8052
|
+
}
|
|
8053
|
+
var DEFAULT_DOCTOR_PROBES = {
|
|
8054
|
+
delivery: probeDelivery,
|
|
8055
|
+
independent: independentChecks
|
|
8056
|
+
};
|
|
8057
|
+
async function collectChecks(options, probes) {
|
|
8058
|
+
const { before, after } = await probes.independent();
|
|
8059
|
+
return [...before, ...classifyDelivery(await probes.delivery(), options), ...after];
|
|
7644
8060
|
}
|
|
7645
8061
|
function icon(status) {
|
|
7646
8062
|
return status === "ok" ? "\u2713" : status === "warn" ? "\u26A0" : "\u2717";
|
|
7647
8063
|
}
|
|
7648
|
-
async function runDoctor() {
|
|
7649
|
-
const checks = await collectChecks();
|
|
8064
|
+
async function runDoctor(options, probes) {
|
|
8065
|
+
const checks = await collectChecks(options, probes);
|
|
7650
8066
|
const { json, exitCode } = classifyDoctor(checks);
|
|
7651
8067
|
const headline = json.status === "ok" ? "\u2713 healthy" : json.status === "warn" ? "\u26A0 degraded" : "\u2717 unhealthy";
|
|
7652
8068
|
process.stderr.write(`[prim] doctor: ${headline}
|
|
@@ -7660,11 +8076,14 @@ async function runDoctor() {
|
|
|
7660
8076
|
process.exitCode = exitCode;
|
|
7661
8077
|
}
|
|
7662
8078
|
}
|
|
7663
|
-
function registerDoctorCommands(program2) {
|
|
8079
|
+
function registerDoctorCommands(program2, dependencies = {}) {
|
|
7664
8080
|
program2.command("doctor").description(
|
|
7665
8081
|
"Check capture and feedback health end to end (auth, supervisor, delivery, server)"
|
|
7666
|
-
).action(async () => {
|
|
7667
|
-
await runDoctor(
|
|
8082
|
+
).addOption(new Option4("--expect-backlog").hideHelp()).action(async (opts) => {
|
|
8083
|
+
await runDoctor(
|
|
8084
|
+
{ backlogExpected: opts.expectBacklog === true },
|
|
8085
|
+
dependencies.probes ?? DEFAULT_DOCTOR_PROBES
|
|
8086
|
+
);
|
|
7668
8087
|
});
|
|
7669
8088
|
}
|
|
7670
8089
|
|
|
@@ -7673,30 +8092,30 @@ import { existsSync as existsSync8, mkdirSync as mkdirSync5, unlinkSync as unlin
|
|
|
7673
8092
|
import { join as join9 } from "path";
|
|
7674
8093
|
|
|
7675
8094
|
// src/flusher.ts
|
|
7676
|
-
import { createReadStream, renameSync as renameSync3, unlinkSync as unlinkSync3 } from "fs";
|
|
7677
|
-
import { dirname as
|
|
8095
|
+
import { createReadStream, renameSync as renameSync3, statSync as statSync3, unlinkSync as unlinkSync3 } from "fs";
|
|
8096
|
+
import { dirname as dirname3, join as join8 } from "path";
|
|
7678
8097
|
|
|
7679
8098
|
// src/dead-letter.ts
|
|
7680
8099
|
import { createHash as createHash3, randomBytes as randomBytes4 } from "crypto";
|
|
7681
8100
|
import {
|
|
7682
|
-
constants as
|
|
8101
|
+
constants as constants3,
|
|
7683
8102
|
chmodSync as chmodSync2,
|
|
7684
|
-
closeSync as
|
|
8103
|
+
closeSync as closeSync3,
|
|
7685
8104
|
existsSync as existsSync7,
|
|
7686
8105
|
fchmodSync,
|
|
7687
8106
|
fsyncSync,
|
|
7688
8107
|
mkdirSync as mkdirSync4,
|
|
7689
|
-
openSync as
|
|
8108
|
+
openSync as openSync3,
|
|
7690
8109
|
renameSync as renameSync2,
|
|
7691
8110
|
rmSync as rmSync3,
|
|
7692
8111
|
writeFileSync as writeFileSync3
|
|
7693
8112
|
} from "fs";
|
|
7694
|
-
import { dirname as
|
|
8113
|
+
import { dirname as dirname2, join as join7 } from "path";
|
|
7695
8114
|
var DIRECTORY_MODE = 448;
|
|
7696
8115
|
var FILE_MODE = 384;
|
|
7697
8116
|
var DEAD_LETTER_DIRNAME = "dead-letter";
|
|
7698
8117
|
function deadLetterDirectoryForRotation(flushingPath) {
|
|
7699
|
-
return join7(
|
|
8118
|
+
return join7(dirname2(flushingPath), DEAD_LETTER_DIRNAME);
|
|
7700
8119
|
}
|
|
7701
8120
|
function deadLetterPathForRawLine(flushingPath, rawLine) {
|
|
7702
8121
|
const quarantineId = createHash3("sha256").update(rawLine).digest("hex");
|
|
@@ -7706,10 +8125,10 @@ function ensureDeadLetterDirectory(flushingPath, persistDirectory) {
|
|
|
7706
8125
|
const directory = deadLetterDirectoryForRotation(flushingPath);
|
|
7707
8126
|
mkdirSync4(directory, { recursive: true, mode: DIRECTORY_MODE });
|
|
7708
8127
|
chmodSync2(directory, DIRECTORY_MODE);
|
|
7709
|
-
persistDirectory(
|
|
8128
|
+
persistDirectory(dirname2(directory));
|
|
7710
8129
|
return directory;
|
|
7711
8130
|
}
|
|
7712
|
-
function persistDeadLetter(directory, path, quarantineId,
|
|
8131
|
+
function persistDeadLetter(directory, path, quarantineId, record3, persistDirectory) {
|
|
7713
8132
|
if (existsSync7(path)) {
|
|
7714
8133
|
persistDirectory(directory);
|
|
7715
8134
|
return;
|
|
@@ -7718,16 +8137,16 @@ function persistDeadLetter(directory, path, quarantineId, record2, persistDirect
|
|
|
7718
8137
|
directory,
|
|
7719
8138
|
`.${quarantineId}.${String(process.pid)}.${randomBytes4(8).toString("hex")}.tmp`
|
|
7720
8139
|
);
|
|
7721
|
-
const flags =
|
|
8140
|
+
const flags = constants3.O_CREAT | constants3.O_EXCL | constants3.O_WRONLY | constants3.O_NOFOLLOW;
|
|
7722
8141
|
try {
|
|
7723
|
-
const fd =
|
|
8142
|
+
const fd = openSync3(temporaryPath, flags, FILE_MODE);
|
|
7724
8143
|
try {
|
|
7725
8144
|
fchmodSync(fd, FILE_MODE);
|
|
7726
|
-
writeFileSync3(fd, `${JSON.stringify(
|
|
8145
|
+
writeFileSync3(fd, `${JSON.stringify(record3)}
|
|
7727
8146
|
`, "utf8");
|
|
7728
8147
|
fsyncSync(fd);
|
|
7729
8148
|
} finally {
|
|
7730
|
-
|
|
8149
|
+
closeSync3(fd);
|
|
7731
8150
|
}
|
|
7732
8151
|
renameSync2(temporaryPath, path);
|
|
7733
8152
|
persistDirectory(directory);
|
|
@@ -7739,22 +8158,22 @@ function quarantineMove(flushingPath, move, reason, now = Date.now, options = {}
|
|
|
7739
8158
|
const persistDirectory = options.syncDirectory ?? syncDirectory;
|
|
7740
8159
|
const directory = ensureDeadLetterDirectory(flushingPath, persistDirectory);
|
|
7741
8160
|
const serializedMove = JSON.stringify(move);
|
|
7742
|
-
const
|
|
8161
|
+
const record3 = {
|
|
7743
8162
|
version: 1,
|
|
7744
8163
|
quarantineId: createHash3("sha256").update(serializedMove).digest("hex"),
|
|
7745
8164
|
quarantinedAt: now(),
|
|
7746
8165
|
reason,
|
|
7747
8166
|
move
|
|
7748
8167
|
};
|
|
7749
|
-
const path = join7(directory, `${
|
|
7750
|
-
persistDeadLetter(directory, path,
|
|
7751
|
-
return
|
|
8168
|
+
const path = join7(directory, `${record3.quarantineId}.json`);
|
|
8169
|
+
persistDeadLetter(directory, path, record3.quarantineId, record3, persistDirectory);
|
|
8170
|
+
return record3;
|
|
7752
8171
|
}
|
|
7753
8172
|
function quarantineRawLine(flushingPath, rawLine, now = Date.now, options = {}) {
|
|
7754
8173
|
const persistDirectory = options.syncDirectory ?? syncDirectory;
|
|
7755
8174
|
const directory = ensureDeadLetterDirectory(flushingPath, persistDirectory);
|
|
7756
8175
|
const bytes = Buffer.from(rawLine);
|
|
7757
|
-
const
|
|
8176
|
+
const record3 = {
|
|
7758
8177
|
version: 1,
|
|
7759
8178
|
recordKind: "raw_line_v1",
|
|
7760
8179
|
quarantineId: createHash3("sha256").update(bytes).digest("hex"),
|
|
@@ -7767,18 +8186,22 @@ function quarantineRawLine(flushingPath, rawLine, now = Date.now, options = {})
|
|
|
7767
8186
|
persistDeadLetter(
|
|
7768
8187
|
directory,
|
|
7769
8188
|
deadLetterPathForRawLine(flushingPath, bytes),
|
|
7770
|
-
|
|
7771
|
-
|
|
8189
|
+
record3.quarantineId,
|
|
8190
|
+
record3,
|
|
7772
8191
|
persistDirectory
|
|
7773
8192
|
);
|
|
7774
|
-
return
|
|
8193
|
+
return record3;
|
|
7775
8194
|
}
|
|
7776
8195
|
|
|
7777
8196
|
// src/flusher.ts
|
|
7778
8197
|
var BATCH_SIZE = 500;
|
|
8198
|
+
var BATCH_MAX_BYTES = 1048576;
|
|
7779
8199
|
var HTTP_TIMEOUT_MS = 1e4;
|
|
7780
8200
|
var OPPORTUNISTIC_FLUSH_AFTER_MS = 6e4;
|
|
7781
8201
|
var ORPHAN_QUARANTINE_MS = 6e4;
|
|
8202
|
+
function exceedsBatchBytes(count, bytes, nextBytes, maxBytes) {
|
|
8203
|
+
return count > 0 && bytes + nextBytes > maxBytes;
|
|
8204
|
+
}
|
|
7782
8205
|
function addDrainCounts(left, right) {
|
|
7783
8206
|
return {
|
|
7784
8207
|
flushed: left.flushed + right.flushed,
|
|
@@ -7789,8 +8212,8 @@ function collectionScopeRoots(batch) {
|
|
|
7789
8212
|
return batch.flatMap((move) => {
|
|
7790
8213
|
const env = move.env;
|
|
7791
8214
|
if (typeof env !== "object" || env === null || Array.isArray(env)) return [];
|
|
7792
|
-
const
|
|
7793
|
-
const root = typeof
|
|
8215
|
+
const record3 = env;
|
|
8216
|
+
const root = typeof record3.gitRoot === "string" ? record3.gitRoot : record3.cwd;
|
|
7794
8217
|
return typeof root === "string" && root.length > 0 ? [root] : [];
|
|
7795
8218
|
});
|
|
7796
8219
|
}
|
|
@@ -7798,8 +8221,8 @@ function isVersionedInvalidMoveDisposition(body) {
|
|
|
7798
8221
|
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
7799
8222
|
return false;
|
|
7800
8223
|
}
|
|
7801
|
-
const
|
|
7802
|
-
return Object.keys(
|
|
8224
|
+
const record3 = body;
|
|
8225
|
+
return Object.keys(record3).length === 2 && record3.error === "invalid_move" && record3.errorVersion === 1;
|
|
7803
8226
|
}
|
|
7804
8227
|
function deadLetterReason(error) {
|
|
7805
8228
|
if (error instanceof InvalidJournalEnvelopeError) {
|
|
@@ -7828,23 +8251,25 @@ function mayBeBatchSizeFailure(error) {
|
|
|
7828
8251
|
}
|
|
7829
8252
|
return error instanceof Error && error.name === "TimeoutError";
|
|
7830
8253
|
}
|
|
7831
|
-
async function* rawJournalLines(input) {
|
|
8254
|
+
async function* rawJournalLines(input, offset) {
|
|
7832
8255
|
let pending = [];
|
|
7833
8256
|
let pendingBytes = 0;
|
|
8257
|
+
let end = offset;
|
|
7834
8258
|
for await (const value of input) {
|
|
7835
8259
|
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
7836
8260
|
let start = 0;
|
|
7837
8261
|
let newline = chunk.indexOf(10, start);
|
|
7838
8262
|
while (newline !== -1) {
|
|
7839
8263
|
const tail = chunk.subarray(start, newline + 1);
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
} else {
|
|
8264
|
+
let line = tail;
|
|
8265
|
+
if (pending.length > 0) {
|
|
7843
8266
|
pending.push(tail);
|
|
7844
|
-
|
|
8267
|
+
line = Buffer.concat(pending, pendingBytes + tail.length);
|
|
7845
8268
|
pending = [];
|
|
7846
8269
|
pendingBytes = 0;
|
|
7847
8270
|
}
|
|
8271
|
+
end += line.length;
|
|
8272
|
+
yield { bytes: line, end };
|
|
7848
8273
|
start = newline + 1;
|
|
7849
8274
|
newline = chunk.indexOf(10, start);
|
|
7850
8275
|
}
|
|
@@ -7855,18 +8280,43 @@ async function* rawJournalLines(input) {
|
|
|
7855
8280
|
}
|
|
7856
8281
|
}
|
|
7857
8282
|
if (pendingBytes > 0) {
|
|
7858
|
-
|
|
8283
|
+
const bytes = pending.length === 1 ? pending[0] : Buffer.concat(pending, pendingBytes);
|
|
8284
|
+
yield { bytes, end: end + pendingBytes };
|
|
7859
8285
|
}
|
|
7860
8286
|
}
|
|
8287
|
+
var drainProgressWarned = false;
|
|
7861
8288
|
async function drainFlushingPath(flushingPath, client, binding, options = {}) {
|
|
8289
|
+
const rotation = rotationIdentity(statSync3(flushingPath));
|
|
8290
|
+
const resumeAt = drainResumeOffset(flushingPath, rotation);
|
|
8291
|
+
const recordProgress = (end) => {
|
|
8292
|
+
if (end === void 0 || end >= rotation.size) {
|
|
8293
|
+
return;
|
|
8294
|
+
}
|
|
8295
|
+
try {
|
|
8296
|
+
writeDrainCheckpoint(flushingPath, { v: 2, offset: end, ...rotation });
|
|
8297
|
+
drainProgressWarned = false;
|
|
8298
|
+
} catch (error) {
|
|
8299
|
+
if (!drainProgressWarned) {
|
|
8300
|
+
drainProgressWarned = true;
|
|
8301
|
+
process.stderr.write(
|
|
8302
|
+
`[prim] could not record drain progress: ${error instanceof Error ? error.message : String(error)}
|
|
8303
|
+
`
|
|
8304
|
+
);
|
|
8305
|
+
}
|
|
8306
|
+
}
|
|
8307
|
+
};
|
|
8308
|
+
const reportProgress = (delta) => {
|
|
8309
|
+
options.onProgress?.(delta);
|
|
8310
|
+
return delta;
|
|
8311
|
+
};
|
|
7862
8312
|
const postBatch = async (batch2) => {
|
|
8313
|
+
const moves = batch2.map((entry) => entry.move);
|
|
7863
8314
|
try {
|
|
7864
|
-
const request = buildOrganizationBoundMoveRequest(
|
|
8315
|
+
const request = buildOrganizationBoundMoveRequest(moves, binding);
|
|
7865
8316
|
const response = await client.post("/api/cli/moves/ingest", request, {
|
|
7866
8317
|
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
|
|
7867
8318
|
});
|
|
7868
|
-
requireDurableIngestAcknowledgement(response,
|
|
7869
|
-
return { flushed: batch2.length, quarantined: 0 };
|
|
8319
|
+
requireDurableIngestAcknowledgement(response, moves.length, collectionScopeRoots(moves));
|
|
7870
8320
|
} catch (error) {
|
|
7871
8321
|
const reason = deadLetterReason(error);
|
|
7872
8322
|
if (!(reason || mayBeBatchSizeFailure(error))) {
|
|
@@ -7881,33 +8331,43 @@ async function drainFlushingPath(flushingPath, client, binding, options = {}) {
|
|
|
7881
8331
|
if (!reason) {
|
|
7882
8332
|
throw error;
|
|
7883
8333
|
}
|
|
7884
|
-
const [
|
|
8334
|
+
const [entry] = batch2;
|
|
7885
8335
|
const quarantined = quarantineMove(
|
|
7886
8336
|
flushingPath,
|
|
7887
|
-
move,
|
|
8337
|
+
entry.move,
|
|
7888
8338
|
reason,
|
|
7889
8339
|
Date.now,
|
|
7890
8340
|
options.deadLetterPersistence
|
|
7891
8341
|
);
|
|
8342
|
+
recordProgress(entry.end);
|
|
7892
8343
|
process.stderr.write(
|
|
7893
8344
|
`[prim] quarantined rejected move ${quarantined.quarantineId.slice(0, 12)} (${reason})
|
|
7894
8345
|
`
|
|
7895
8346
|
);
|
|
7896
|
-
return { flushed: 0, quarantined: 1 };
|
|
8347
|
+
return reportProgress({ flushed: 0, quarantined: 1 });
|
|
7897
8348
|
}
|
|
8349
|
+
recordProgress(batch2.at(-1)?.end);
|
|
8350
|
+
return reportProgress({ flushed: batch2.length, quarantined: 0 });
|
|
7898
8351
|
};
|
|
7899
|
-
const input = createReadStream(flushingPath);
|
|
8352
|
+
const input = createReadStream(flushingPath, { start: resumeAt });
|
|
7900
8353
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
7901
8354
|
let batch = [];
|
|
8355
|
+
let batchBytes = 0;
|
|
7902
8356
|
let counts = { flushed: 0, quarantined: 0 };
|
|
8357
|
+
const postPending = async () => {
|
|
8358
|
+
counts = addDrainCounts(counts, await postBatch(batch));
|
|
8359
|
+
batch = [];
|
|
8360
|
+
batchBytes = 0;
|
|
8361
|
+
};
|
|
7903
8362
|
try {
|
|
7904
|
-
for await (const rawLine of rawJournalLines(input)) {
|
|
8363
|
+
for await (const { bytes: rawLine, end } of rawJournalLines(input, resumeAt)) {
|
|
7905
8364
|
if (rawLine.length === 1 && rawLine[0] === 10 || rawLine.length === 2 && rawLine[0] === 13 && rawLine[1] === 10) {
|
|
7906
8365
|
continue;
|
|
7907
8366
|
}
|
|
8367
|
+
let move;
|
|
7908
8368
|
try {
|
|
7909
8369
|
const line = decoder.decode(rawLine);
|
|
7910
|
-
|
|
8370
|
+
move = JSON.parse(line);
|
|
7911
8371
|
} catch {
|
|
7912
8372
|
const quarantined = quarantineRawLine(
|
|
7913
8373
|
flushingPath,
|
|
@@ -7919,24 +8379,35 @@ async function drainFlushingPath(flushingPath, client, binding, options = {}) {
|
|
|
7919
8379
|
`[prim] quarantined invalid journal line ${quarantined.quarantineId.slice(0, 12)} (invalid_move)
|
|
7920
8380
|
`
|
|
7921
8381
|
);
|
|
7922
|
-
counts
|
|
8382
|
+
counts = addDrainCounts(counts, reportProgress({ flushed: 0, quarantined: 1 }));
|
|
8383
|
+
if (batch.length === 0) {
|
|
8384
|
+
recordProgress(end);
|
|
8385
|
+
}
|
|
7923
8386
|
continue;
|
|
7924
8387
|
}
|
|
7925
|
-
if (batch.length
|
|
7926
|
-
|
|
7927
|
-
|
|
8388
|
+
if (exceedsBatchBytes(batch.length, batchBytes, rawLine.length, BATCH_MAX_BYTES)) {
|
|
8389
|
+
await postPending();
|
|
8390
|
+
}
|
|
8391
|
+
batch.push({ move, end });
|
|
8392
|
+
batchBytes += rawLine.length;
|
|
8393
|
+
if (batch.length >= BATCH_SIZE) {
|
|
8394
|
+
await postPending();
|
|
7928
8395
|
}
|
|
7929
8396
|
}
|
|
7930
8397
|
if (batch.length > 0) {
|
|
7931
|
-
|
|
8398
|
+
await postPending();
|
|
7932
8399
|
}
|
|
7933
8400
|
} finally {
|
|
7934
8401
|
input.destroy();
|
|
7935
8402
|
}
|
|
7936
8403
|
unlinkSync3(flushingPath);
|
|
8404
|
+
try {
|
|
8405
|
+
removeDrainCheckpoint(flushingPath);
|
|
8406
|
+
} catch {
|
|
8407
|
+
}
|
|
7937
8408
|
return counts;
|
|
7938
8409
|
}
|
|
7939
|
-
async function drainPath(path, client, binding) {
|
|
8410
|
+
async function drainPath(path, client, binding, onProgress) {
|
|
7940
8411
|
const tmpPath = `${path}.flushing.${String(Date.now())}.${String(process.pid)}`;
|
|
7941
8412
|
try {
|
|
7942
8413
|
renameSync3(path, tmpPath);
|
|
@@ -7946,7 +8417,19 @@ async function drainPath(path, client, binding) {
|
|
|
7946
8417
|
}
|
|
7947
8418
|
throw err;
|
|
7948
8419
|
}
|
|
7949
|
-
return drainFlushingPath(tmpPath, client, binding);
|
|
8420
|
+
return drainFlushingPath(tmpPath, client, binding, { onProgress });
|
|
8421
|
+
}
|
|
8422
|
+
async function settleDrain(drain) {
|
|
8423
|
+
let progress = { flushed: 0, quarantined: 0 };
|
|
8424
|
+
try {
|
|
8425
|
+
return {
|
|
8426
|
+
counts: await drain((delta) => {
|
|
8427
|
+
progress = addDrainCounts(progress, delta);
|
|
8428
|
+
})
|
|
8429
|
+
};
|
|
8430
|
+
} catch (error) {
|
|
8431
|
+
return { counts: progress, failure: { error } };
|
|
8432
|
+
}
|
|
7950
8433
|
}
|
|
7951
8434
|
function selectRecoverable(files, now, opts = {}) {
|
|
7952
8435
|
const quarantineMs = opts.quarantineMs ?? ORPHAN_QUARANTINE_MS;
|
|
@@ -7963,7 +8446,8 @@ async function recoverOrphans(candidates, options) {
|
|
|
7963
8446
|
flushed: 0,
|
|
7964
8447
|
quarantined: 0,
|
|
7965
8448
|
errors: [],
|
|
7966
|
-
failedBuckets: /* @__PURE__ */ new Set()
|
|
8449
|
+
failedBuckets: /* @__PURE__ */ new Set(),
|
|
8450
|
+
failedDrainFlushed: 0
|
|
7967
8451
|
};
|
|
7968
8452
|
const recoverable = selectRecoverable(candidates, options.now ?? Date.now(), {
|
|
7969
8453
|
ownerPid: options.ownerPid ?? process.pid,
|
|
@@ -7974,28 +8458,41 @@ async function recoverOrphans(candidates, options) {
|
|
|
7974
8458
|
if (summary.failedBuckets.has(file.bucket)) {
|
|
7975
8459
|
continue;
|
|
7976
8460
|
}
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
|
|
7980
|
-
|
|
7981
|
-
|
|
7982
|
-
summary.errors.push(
|
|
8461
|
+
const { counts, failure: failure4 } = await settleDrain((onProgress) => drain(file.path, onProgress));
|
|
8462
|
+
summary.flushed += counts.flushed;
|
|
8463
|
+
summary.quarantined += counts.quarantined;
|
|
8464
|
+
if (failure4) {
|
|
8465
|
+
summary.failedDrainFlushed += counts.flushed;
|
|
8466
|
+
summary.errors.push(failure4.error);
|
|
7983
8467
|
summary.failedBuckets.add(file.bucket);
|
|
7984
8468
|
}
|
|
7985
8469
|
}
|
|
7986
8470
|
return summary;
|
|
7987
8471
|
}
|
|
7988
8472
|
var FlushError = class extends Error {
|
|
8473
|
+
/** Moves the whole sweep acknowledged, across every drain it ran. */
|
|
7989
8474
|
flushed;
|
|
7990
8475
|
quarantined;
|
|
7991
|
-
|
|
8476
|
+
/** Buckets the failed sweep held back without sending, as on success. */
|
|
8477
|
+
retained;
|
|
8478
|
+
/**
|
|
8479
|
+
* Of `flushed`, the Moves acknowledged by the drains that failed, before
|
|
8480
|
+
* they failed. Only this shows the failure itself is advancing: `flushed`
|
|
8481
|
+
* also counts other buckets' drains, so a bucket whose drain fails every
|
|
8482
|
+
* time would read as progress while a sibling bucket kept delivering.
|
|
8483
|
+
*/
|
|
8484
|
+
failedDrainFlushed;
|
|
8485
|
+
constructor(cause, flushed, quarantined, retained = [], failedDrainFlushed = 0) {
|
|
7992
8486
|
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
7993
8487
|
this.name = "FlushError";
|
|
7994
8488
|
this.flushed = flushed;
|
|
7995
8489
|
this.quarantined = quarantined;
|
|
8490
|
+
this.retained = retained;
|
|
8491
|
+
this.failedDrainFlushed = failedDrainFlushed;
|
|
7996
8492
|
}
|
|
7997
8493
|
};
|
|
7998
8494
|
async function flushOnce() {
|
|
8495
|
+
sweepOrphanedDrainProgress();
|
|
7999
8496
|
const orphanCandidates = listFlushing({ sampleBytes: 0 });
|
|
8000
8497
|
const liveBuckets = listBuckets();
|
|
8001
8498
|
if (orphanCandidates.length === 0 && liveBuckets.length === 0) {
|
|
@@ -8012,10 +8509,11 @@ async function flushOnce() {
|
|
|
8012
8509
|
const binding = inspection.binding;
|
|
8013
8510
|
const recovered = await recoverOrphans(
|
|
8014
8511
|
orphanCandidates.filter((file) => inspection.deliverableBuckets.has(file.bucket)),
|
|
8015
|
-
{ drain: (path) => drainFlushingPath(path, client, binding) }
|
|
8512
|
+
{ drain: (path, onProgress) => drainFlushingPath(path, client, binding, { onProgress }) }
|
|
8016
8513
|
);
|
|
8017
8514
|
let total = recovered.flushed;
|
|
8018
8515
|
let quarantined = recovered.quarantined;
|
|
8516
|
+
let failedDrainFlushed = recovered.failedDrainFlushed;
|
|
8019
8517
|
const errors = recovered.errors;
|
|
8020
8518
|
for (const { bucket, path } of liveBuckets) {
|
|
8021
8519
|
if (!inspection.deliverableBuckets.has(bucket)) {
|
|
@@ -8024,21 +8522,29 @@ async function flushOnce() {
|
|
|
8024
8522
|
if (recovered.failedBuckets.has(bucket)) {
|
|
8025
8523
|
continue;
|
|
8026
8524
|
}
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8032
|
-
|
|
8525
|
+
const { counts, failure: failure4 } = await settleDrain(
|
|
8526
|
+
(onProgress) => drainPath(path, client, binding, onProgress)
|
|
8527
|
+
);
|
|
8528
|
+
total += counts.flushed;
|
|
8529
|
+
quarantined += counts.quarantined;
|
|
8530
|
+
if (failure4) {
|
|
8531
|
+
failedDrainFlushed += counts.flushed;
|
|
8532
|
+
errors.push(failure4.error);
|
|
8033
8533
|
}
|
|
8034
8534
|
}
|
|
8035
8535
|
if (errors.length > 0) {
|
|
8036
|
-
throw new FlushError(
|
|
8536
|
+
throw new FlushError(
|
|
8537
|
+
errors[0],
|
|
8538
|
+
total,
|
|
8539
|
+
quarantined,
|
|
8540
|
+
inspection.retainedBuckets,
|
|
8541
|
+
failedDrainFlushed
|
|
8542
|
+
);
|
|
8037
8543
|
}
|
|
8038
8544
|
return inspection.retainedBuckets.length > 0 ? { flushed: total, quarantined, retained: inspection.retainedBuckets } : { flushed: total, quarantined };
|
|
8039
8545
|
}
|
|
8040
8546
|
var flushInFlight;
|
|
8041
|
-
var FLUSH_LOCK_PATH = join8(
|
|
8547
|
+
var FLUSH_LOCK_PATH = join8(dirname3(JOURNAL_DIR), ".flush.lock");
|
|
8042
8548
|
var FLUSH_LOCK_TIMEOUT_MS = 250;
|
|
8043
8549
|
function isFlushLockContended(error) {
|
|
8044
8550
|
return error instanceof Error && error.message.startsWith("timed out waiting for file lock");
|
|
@@ -8063,18 +8569,16 @@ function flush() {
|
|
|
8063
8569
|
return attempt;
|
|
8064
8570
|
}
|
|
8065
8571
|
function shouldFlushPending(stats, now, thresholdMs = OPPORTUNISTIC_FLUSH_AFTER_MS) {
|
|
8066
|
-
if (stats.sampled) {
|
|
8572
|
+
if (stats.sampled || stats.strandedFileCount > 0 && stats.strandedCount === 0) {
|
|
8067
8573
|
return true;
|
|
8068
8574
|
}
|
|
8069
8575
|
return stats.pendingCount > 0 && (stats.oldestPendingAt === void 0 || now - stats.oldestPendingAt > thresholdMs);
|
|
8070
8576
|
}
|
|
8071
|
-
|
|
8577
|
+
function journalNeedsFlush(now = Date.now()) {
|
|
8072
8578
|
try {
|
|
8073
|
-
|
|
8074
|
-
if (shouldFlushPending(stats, Date.now())) {
|
|
8075
|
-
await flush();
|
|
8076
|
-
}
|
|
8579
|
+
return shouldFlushPending(pendingJournalStats(), now);
|
|
8077
8580
|
} catch {
|
|
8581
|
+
return false;
|
|
8078
8582
|
}
|
|
8079
8583
|
}
|
|
8080
8584
|
|
|
@@ -8133,7 +8637,7 @@ function registerMovesCommands(program2) {
|
|
|
8133
8637
|
}
|
|
8134
8638
|
if (stranded.length > 0) {
|
|
8135
8639
|
const moveCount = stranded.reduce((n, f) => n + f.lineCount, 0);
|
|
8136
|
-
const byteCount = stranded.reduce((n, f) => n + f.
|
|
8640
|
+
const byteCount = stranded.reduce((n, f) => n + f.pendingBytes, 0);
|
|
8137
8641
|
const sampled = stranded.some((file) => file.sampled);
|
|
8138
8642
|
const qualifier = sampled ? "at least " : "";
|
|
8139
8643
|
console.log(
|
|
@@ -8144,7 +8648,7 @@ function registerMovesCommands(program2) {
|
|
|
8144
8648
|
const owner = f.pid === void 0 ? "no pid" : `pid ${String(f.pid)}`;
|
|
8145
8649
|
const count = `${String(f.lineCount)}${f.sampled ? "+" : ""}`;
|
|
8146
8650
|
console.log(
|
|
8147
|
-
` ${f.bucket.padEnd(BUCKET_COL_WIDTH)} ${count.padStart(5)} stranded, ${String(f.
|
|
8651
|
+
` ${f.bucket.padEnd(BUCKET_COL_WIDTH)} ${count.padStart(5)} stranded, ${String(f.pendingBytes).padStart(8)} bytes, ${String(ageS)}s ago (${owner})`
|
|
8148
8652
|
);
|
|
8149
8653
|
}
|
|
8150
8654
|
}
|
|
@@ -8342,6 +8846,20 @@ import { existsSync as existsSync10, readFileSync as readFileSync7 } from "fs";
|
|
|
8342
8846
|
import { join as join11 } from "path";
|
|
8343
8847
|
var EXIT_INCOMPLETE = 1;
|
|
8344
8848
|
var EXIT_USAGE3 = 2;
|
|
8849
|
+
var SETUP_ORCHESTRATOR_ENV = "PRIM_SETUP_ORCHESTRATOR";
|
|
8850
|
+
var SETUP_DAEMON_DRAINS_ENV = "PRIM_SETUP_DAEMON_DRAINS";
|
|
8851
|
+
function setupStepSpawnOptions(capture, env = process.env, options = {}) {
|
|
8852
|
+
return {
|
|
8853
|
+
env: {
|
|
8854
|
+
...env,
|
|
8855
|
+
[SETUP_ORCHESTRATOR_ENV]: "1",
|
|
8856
|
+
// Unset rather than inherited when this setup starts no daemon.
|
|
8857
|
+
[SETUP_DAEMON_DRAINS_ENV]: options.startsDaemon === true ? "1" : void 0
|
|
8858
|
+
},
|
|
8859
|
+
stdio: capture ? ["inherit", "pipe", "ignore"] : "inherit",
|
|
8860
|
+
encoding: "utf-8"
|
|
8861
|
+
};
|
|
8862
|
+
}
|
|
8345
8863
|
var SESSION_LABELS = {
|
|
8346
8864
|
claude: "Claude Code integration",
|
|
8347
8865
|
codex: "Codex integration",
|
|
@@ -8374,12 +8892,16 @@ function planSetupSteps(opts) {
|
|
|
8374
8892
|
required: true
|
|
8375
8893
|
});
|
|
8376
8894
|
}
|
|
8377
|
-
|
|
8378
|
-
key: "hooks",
|
|
8379
|
-
|
|
8380
|
-
|
|
8381
|
-
|
|
8382
|
-
|
|
8895
|
+
if (opts.scope === "project") {
|
|
8896
|
+
steps.push({ key: "hooks", label: "Git hooks", args: ["hooks", "install"], required: true });
|
|
8897
|
+
} else if (opts.globalHooksPath) {
|
|
8898
|
+
steps.push({
|
|
8899
|
+
key: "hooks",
|
|
8900
|
+
label: "Git hooks (global)",
|
|
8901
|
+
args: ["hooks", "install", "--scope", "user", "--global-hooks-path"],
|
|
8902
|
+
required: true
|
|
8903
|
+
});
|
|
8904
|
+
}
|
|
8383
8905
|
const skillArgs = ["skill", "install", "--agent", opts.agent, ...scopeArgs];
|
|
8384
8906
|
steps.push({ key: "skill", label: "Agent skill", args: skillArgs, required: true });
|
|
8385
8907
|
steps.push({
|
|
@@ -8392,7 +8914,7 @@ function planSetupSteps(opts) {
|
|
|
8392
8914
|
steps.push({
|
|
8393
8915
|
key: "health",
|
|
8394
8916
|
label: "Capture health",
|
|
8395
|
-
args: ["doctor"],
|
|
8917
|
+
args: ["doctor", "--expect-backlog"],
|
|
8396
8918
|
required: true
|
|
8397
8919
|
});
|
|
8398
8920
|
}
|
|
@@ -8410,7 +8932,44 @@ function planCleanupUninstalls(agent, conflicts) {
|
|
|
8410
8932
|
if (conflicts.includes(CONFLICT_SKILL)) steps.push(["skill", "uninstall", "--agent", agent]);
|
|
8411
8933
|
return steps;
|
|
8412
8934
|
}
|
|
8413
|
-
function
|
|
8935
|
+
function preCommitRunsPrim(content) {
|
|
8936
|
+
return content.includes("# >>> prim pre-commit hook >>>") || content.includes("prim-pre-commit");
|
|
8937
|
+
}
|
|
8938
|
+
function projectHooksConflict(globalHooksActive, preCommit) {
|
|
8939
|
+
return globalHooksActive && preCommit !== void 0 && preCommitRunsPrim(preCommit);
|
|
8940
|
+
}
|
|
8941
|
+
function enableWiresRepository(root) {
|
|
8942
|
+
if (!root) return void 0;
|
|
8943
|
+
try {
|
|
8944
|
+
if (gitHooksMode({ cwd: root }) === "manual") return void 0;
|
|
8945
|
+
return resolveEffectiveGitHook("post-commit", root).location !== "external";
|
|
8946
|
+
} catch {
|
|
8947
|
+
return void 0;
|
|
8948
|
+
}
|
|
8949
|
+
}
|
|
8950
|
+
function setupGitHooksNote(plan, wiresThisRepository) {
|
|
8951
|
+
if (wiresThisRepository === true && plan.action === "add_to_dir") {
|
|
8952
|
+
return `git hooks \xB7 Git runs this repository's hooks from inside it, so \`prim enable\` wires it; other repositories may run ${plan.global}, which prim edits only with --global-hooks-path, after asking the user`;
|
|
8953
|
+
}
|
|
8954
|
+
if (wiresThisRepository === true && plan.action === "system_declined") {
|
|
8955
|
+
return `git hooks \xB7 Git runs this repository's hooks from inside it, so \`prim enable\` wires it; other repositories may run the system hooks dir ${plan.system}, which prim never edits`;
|
|
8956
|
+
}
|
|
8957
|
+
switch (plan.action) {
|
|
8958
|
+
case "refresh":
|
|
8959
|
+
return "git hooks \xB7 prim's global hooks stay active; `prim enable` refreshes them";
|
|
8960
|
+
case "present_in_dir":
|
|
8961
|
+
return `git hooks \xB7 prim's hooks are already in your global hooks dir ${plan.global}`;
|
|
8962
|
+
case "add_to_dir":
|
|
8963
|
+
return `git hooks \xB7 your global core.hooksPath (${plan.global}) runs every repository's hooks, so \`prim enable\` cannot wire this repository without it; add prim there with --global-hooks-path, after asking the user`;
|
|
8964
|
+
case "system_declined":
|
|
8965
|
+
return `git hooks \xB7 the system core.hooksPath (${plan.system}) runs every repository's hooks, so \`prim enable\` cannot wire this repository: place \`prim hooks snippet <hook>\` there, or, after asking the user, run \`prim hooks install --scope user --global-hooks-path --force\` (its hooks stop firing)`;
|
|
8966
|
+
case "manual":
|
|
8967
|
+
return "git hooks \xB7 prim.gitHooks=manual: prim writes no hook files; wire them with `prim hooks snippet <hook>`";
|
|
8968
|
+
default:
|
|
8969
|
+
return "git hooks \xB7 wired per repository by `prim enable`; git's global hooks are untouched (opt in with --global-hooks-path)";
|
|
8970
|
+
}
|
|
8971
|
+
}
|
|
8972
|
+
function detectProjectConflicts(agent, run, globalHooksActive) {
|
|
8414
8973
|
const conflicts = [];
|
|
8415
8974
|
if (agent !== "hermes") {
|
|
8416
8975
|
try {
|
|
@@ -8419,14 +8978,14 @@ function detectProjectConflicts(agent, run) {
|
|
|
8419
8978
|
} catch {
|
|
8420
8979
|
}
|
|
8421
8980
|
}
|
|
8981
|
+
let preCommit;
|
|
8422
8982
|
try {
|
|
8423
8983
|
const root = gitToplevel();
|
|
8424
|
-
const
|
|
8425
|
-
if (
|
|
8426
|
-
conflicts.push(CONFLICT_HOOKS);
|
|
8427
|
-
}
|
|
8984
|
+
const path = root && join11(root, ".git", "hooks", "pre-commit");
|
|
8985
|
+
if (path && existsSync10(path)) preCommit = readFileSync7(path, "utf-8");
|
|
8428
8986
|
} catch {
|
|
8429
8987
|
}
|
|
8988
|
+
if (projectHooksConflict(globalHooksActive, preCommit)) conflicts.push(CONFLICT_HOOKS);
|
|
8430
8989
|
try {
|
|
8431
8990
|
const parsed = JSON.parse(
|
|
8432
8991
|
run(["skill", "status", "--agent", agent, "--json"], true).stdout || "{}"
|
|
@@ -8466,8 +9025,11 @@ function registerSetupCommand(program2, dependencies = {}) {
|
|
|
8466
9025
|
"Install everything in one shot (auth, session + git hooks, daemon, skill, welcome)"
|
|
8467
9026
|
).option("--agent <agent>", "claude, codex, cursor, or hermes (auto-detected when omitted)").option(
|
|
8468
9027
|
"--scope <scope>",
|
|
8469
|
-
"user (default \u2014
|
|
9028
|
+
"user (default \u2014 agent integration and skill for every repo; git hooks per repo via enable) or project (this repo only)",
|
|
8470
9029
|
"user"
|
|
9030
|
+
).option(
|
|
9031
|
+
"--global-hooks-path",
|
|
9032
|
+
"with the default user scope, also change git's global hooks so prim's hooks run in every repository"
|
|
8471
9033
|
).option(
|
|
8472
9034
|
"--migrate",
|
|
8473
9035
|
"with the default user scope, remove any project-scoped prim config in this repo (else just warn)"
|
|
@@ -8487,15 +9049,21 @@ function registerSetupCommand(program2, dependencies = {}) {
|
|
|
8487
9049
|
(dependencies.exit ?? process.exit)(EXIT_USAGE3);
|
|
8488
9050
|
return;
|
|
8489
9051
|
}
|
|
9052
|
+
if (opts.globalHooksPath && opts.scope !== "user") {
|
|
9053
|
+
process.stderr.write("[prim] --global-hooks-path applies only with --scope user\n");
|
|
9054
|
+
(dependencies.exit ?? process.exit)(EXIT_USAGE3);
|
|
9055
|
+
return;
|
|
9056
|
+
}
|
|
8490
9057
|
const agent = agentInput;
|
|
8491
9058
|
const scope = opts.scope;
|
|
8492
9059
|
const self = process.argv[1];
|
|
8493
9060
|
const globals = program2.optsWithGlobals();
|
|
8494
9061
|
const run = dependencies.run ?? ((args, capture = false) => {
|
|
8495
|
-
const r = spawnSync2(
|
|
8496
|
-
|
|
8497
|
-
|
|
8498
|
-
|
|
9062
|
+
const r = spawnSync2(
|
|
9063
|
+
process.execPath,
|
|
9064
|
+
[self, ...args],
|
|
9065
|
+
setupStepSpawnOptions(capture, process.env, { startsDaemon: opts.daemon })
|
|
9066
|
+
);
|
|
8499
9067
|
return { code: r.status ?? 1, stdout: capture ? r.stdout ?? "" : "" };
|
|
8500
9068
|
});
|
|
8501
9069
|
const results = {};
|
|
@@ -8537,16 +9105,24 @@ function registerSetupCommand(program2, dependencies = {}) {
|
|
|
8537
9105
|
note("pre-authorize \xB7 writing prim allow-rule (user scope)\u2026");
|
|
8538
9106
|
results.preauth = run(["claude", "preauth", "--scope", "user"]).code === 0 ? "ok" : "skipped";
|
|
8539
9107
|
}
|
|
8540
|
-
const setupSteps = planSetupSteps({
|
|
9108
|
+
const setupSteps = planSetupSteps({
|
|
9109
|
+
agent,
|
|
9110
|
+
daemon: opts.daemon,
|
|
9111
|
+
scope,
|
|
9112
|
+
globalHooksPath: opts.globalHooksPath === true
|
|
9113
|
+
});
|
|
8541
9114
|
for (const step of setupSteps.filter(
|
|
8542
9115
|
(candidate) => candidate.key !== "enable" && candidate.key !== "health"
|
|
8543
9116
|
)) {
|
|
8544
9117
|
note(`${step.label} \xB7 installing\u2026`);
|
|
8545
9118
|
const { code } = run(step.args);
|
|
8546
|
-
results[step.key] = code === 0 ? "ok" : step.required ? "failed" : "skipped";
|
|
9119
|
+
results[step.key] = code === 0 ? "ok" : code === EXIT_GLOBAL_HOOKS_NOT_INSTALLED && step.key === "hooks" ? "skipped" : step.required ? "failed" : "skipped";
|
|
9120
|
+
}
|
|
9121
|
+
if (scope === "user" && !opts.globalHooksPath) {
|
|
9122
|
+
note(setupGitHooksNote(planGlobalHooks(), enableWiresRepository(gitToplevel())));
|
|
8547
9123
|
}
|
|
8548
9124
|
if (scope === "user") {
|
|
8549
|
-
const conflicts = detectProjectConflicts(agent, run);
|
|
9125
|
+
const conflicts = detectProjectConflicts(agent, run, globalHooksPathIsPrims());
|
|
8550
9126
|
if (conflicts.length > 0 && opts.migrate) {
|
|
8551
9127
|
note(`migrate \xB7 removing project-scoped config (${conflicts.join(", ")})\u2026`);
|
|
8552
9128
|
const ok = planCleanupUninstalls(agent, conflicts).map((args) => run(args).code === 0).every(Boolean);
|
|
@@ -8587,12 +9163,12 @@ function registerSetupCommand(program2, dependencies = {}) {
|
|
|
8587
9163
|
|
|
8588
9164
|
// src/commands/statusline.ts
|
|
8589
9165
|
import { readFileSync as readFileSync8 } from "fs";
|
|
8590
|
-
import { dirname as
|
|
9166
|
+
import { dirname as dirname4, resolve as resolve3 } from "path";
|
|
8591
9167
|
import { fileURLToPath } from "url";
|
|
8592
9168
|
var STATUSLINE_TIMEOUT_MS = 200;
|
|
8593
9169
|
function readPackageVersion() {
|
|
8594
9170
|
try {
|
|
8595
|
-
const here =
|
|
9171
|
+
const here = dirname4(fileURLToPath(import.meta.url));
|
|
8596
9172
|
const candidates = [
|
|
8597
9173
|
// Schema-v2 runtimes staged a manifest beside this standalone bundle.
|
|
8598
9174
|
// Keep resolving it while those legacy launchers transition to schema v3.
|
|
@@ -9016,28 +9592,233 @@ function registerWelcomeCommand(program2, deps = { getClient }) {
|
|
|
9016
9592
|
});
|
|
9017
9593
|
}
|
|
9018
9594
|
|
|
9595
|
+
// src/daemon/drift-heal.ts
|
|
9596
|
+
var ATTEMPT_MARKER_MODE = 384;
|
|
9597
|
+
var ATTEMPT_MARKER_MAX_BYTES = 1024;
|
|
9598
|
+
var PATH_SEPARATORS = /[\\/]/u;
|
|
9599
|
+
var DAEMON_DRIFT_HEAL_ENV = "PRIM_DAEMON_DRIFT_HEAL";
|
|
9600
|
+
var DAEMON_DRIFT_HEAL_RETRY_MS = 60 * 60 * 1e3;
|
|
9601
|
+
function readAttempt(path) {
|
|
9602
|
+
const file = readBoundedRegularFile(path, ATTEMPT_MARKER_MAX_BYTES);
|
|
9603
|
+
if (!file) return null;
|
|
9604
|
+
try {
|
|
9605
|
+
const value = JSON.parse(file.text);
|
|
9606
|
+
if (typeof value !== "object" || value === null) return null;
|
|
9607
|
+
const attempt = value;
|
|
9608
|
+
return typeof attempt.attemptedAt === "number" && Number.isFinite(attempt.attemptedAt) && typeof attempt.fromVersion === "string" && typeof attempt.toVersion === "string" ? attempt : null;
|
|
9609
|
+
} catch {
|
|
9610
|
+
return null;
|
|
9611
|
+
}
|
|
9612
|
+
}
|
|
9613
|
+
function claimHeld() {
|
|
9614
|
+
throw new Error("another command holds the daemon drift-heal claim");
|
|
9615
|
+
}
|
|
9616
|
+
function claimAttempt(markerPath2, attempt) {
|
|
9617
|
+
const previous = readAttempt(markerPath2);
|
|
9618
|
+
const elapsed = previous ? attempt.attemptedAt - previous.attemptedAt : Number.POSITIVE_INFINITY;
|
|
9619
|
+
if (elapsed >= 0 && elapsed < DAEMON_DRIFT_HEAL_RETRY_MS) return false;
|
|
9620
|
+
atomicWriteFile(markerPath2, `${JSON.stringify(attempt)}
|
|
9621
|
+
`, { mode: ATTEMPT_MARKER_MODE });
|
|
9622
|
+
return true;
|
|
9623
|
+
}
|
|
9624
|
+
function isInstalledPackage(root) {
|
|
9625
|
+
return root?.split(PATH_SEPARATORS).includes("node_modules") === true;
|
|
9626
|
+
}
|
|
9627
|
+
function needsHeal(launcher, toVersion) {
|
|
9628
|
+
const order = compareSemver(launcher.runtimeVersion, toVersion);
|
|
9629
|
+
return order === -1 || order === 0 && !launcher.runnable;
|
|
9630
|
+
}
|
|
9631
|
+
function startDriftHeal(options) {
|
|
9632
|
+
if ((options.platform ?? process.platform) !== "darwin") return false;
|
|
9633
|
+
const env = options.env ?? process.env;
|
|
9634
|
+
if (env[DAEMON_DRIFT_HEAL_ENV] === "0") return false;
|
|
9635
|
+
const root = options.packageRoot === void 0 ? packageRoot() : options.packageRoot;
|
|
9636
|
+
if (!isInstalledPackage(root)) return false;
|
|
9637
|
+
const euid = options.euid ?? process.geteuid?.();
|
|
9638
|
+
if (euid === void 0 || euid === 0) return false;
|
|
9639
|
+
const paths = { env, homeDir: options.homeDir };
|
|
9640
|
+
if (daemonExplicitlyDisabled(paths)) return false;
|
|
9641
|
+
const launcher = (options.selectedLauncher ?? (() => selectedDaemonLauncher(paths)))();
|
|
9642
|
+
if (!launcher || launcher.ownerUid !== euid) return false;
|
|
9643
|
+
const toVersion = options.cliVersion === void 0 ? packageVersion() : options.cliVersion;
|
|
9644
|
+
if (!(toVersion && needsHeal(launcher, toVersion))) return false;
|
|
9645
|
+
if (!apiUrlsMatch(
|
|
9646
|
+
getSiteUrlForEnvironment(launcher.apiUrl),
|
|
9647
|
+
getSiteUrlForEnvironment(env.PRIM_API_URL)
|
|
9648
|
+
)) {
|
|
9649
|
+
return false;
|
|
9650
|
+
}
|
|
9651
|
+
const runsConfigRoot = options.launchAgentRunsConfigRoot ?? (() => launchAgentRunsConfigRoot(paths));
|
|
9652
|
+
if (!runsConfigRoot()) return false;
|
|
9653
|
+
const primEntry = options.primEntry === void 0 ? binFile("prim") : options.primEntry;
|
|
9654
|
+
if (!primEntry) return false;
|
|
9655
|
+
const attempt = {
|
|
9656
|
+
attemptedAt: (options.nowMs ?? Date.now)(),
|
|
9657
|
+
fromVersion: launcher.runtimeVersion,
|
|
9658
|
+
toVersion
|
|
9659
|
+
};
|
|
9660
|
+
const claimed = withFileLockSync(
|
|
9661
|
+
daemonDriftHealClaimPath(paths),
|
|
9662
|
+
() => claimAttempt(daemonDriftHealMarkerPath(paths), attempt),
|
|
9663
|
+
{ timeoutMs: 0, sleep: claimHeld }
|
|
9664
|
+
);
|
|
9665
|
+
if (!claimed) return false;
|
|
9666
|
+
return kickDaemonEnsure({
|
|
9667
|
+
primEntry,
|
|
9668
|
+
nodeEntry: options.nodeEntry,
|
|
9669
|
+
spawnProcess: options.spawnProcess,
|
|
9670
|
+
latestBootstrap: false
|
|
9671
|
+
});
|
|
9672
|
+
}
|
|
9673
|
+
function healDaemonDrift(options = {}) {
|
|
9674
|
+
try {
|
|
9675
|
+
return startDriftHeal(options);
|
|
9676
|
+
} catch {
|
|
9677
|
+
return false;
|
|
9678
|
+
}
|
|
9679
|
+
}
|
|
9680
|
+
|
|
9681
|
+
// src/lib/background-flush.ts
|
|
9682
|
+
import { spawn as spawn3 } from "child_process";
|
|
9683
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
9684
|
+
|
|
9685
|
+
// src/daemon/health.ts
|
|
9686
|
+
import { chmodSync as chmodSync3, existsSync as existsSync11, mkdirSync as mkdirSync7, renameSync as renameSync4, writeFileSync as writeFileSync6 } from "fs";
|
|
9687
|
+
import { dirname as dirname5, join as join12 } from "path";
|
|
9688
|
+
var HEARTBEAT_FRESH_MS = 9e4;
|
|
9689
|
+
var DAEMON_HEALTH_PATH = join12(primConfigDirectory(), "daemon-health.json");
|
|
9690
|
+
|
|
9691
|
+
// src/lib/background-flush.ts
|
|
9692
|
+
function record2(value) {
|
|
9693
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
9694
|
+
}
|
|
9695
|
+
function daemonOwnsDrain(options = {}) {
|
|
9696
|
+
try {
|
|
9697
|
+
const expectedVersion = options.expectedVersion === void 0 ? packageVersion() : options.expectedVersion;
|
|
9698
|
+
if (!expectedVersion) return false;
|
|
9699
|
+
const health = record2(
|
|
9700
|
+
JSON.parse(readFileSync9(options.healthPath ?? DAEMON_HEALTH_PATH, "utf8"))
|
|
9701
|
+
);
|
|
9702
|
+
const heartbeat = record2(health?.heartbeat);
|
|
9703
|
+
const ingestion = record2(health?.ingestion);
|
|
9704
|
+
if (!(health && heartbeat && ingestion)) return false;
|
|
9705
|
+
if (health.schemaVersion !== 1 || health.version !== expectedVersion) return false;
|
|
9706
|
+
const daemonSiteUrl = health.siteUrl;
|
|
9707
|
+
if (typeof daemonSiteUrl !== "string" || envSlug(daemonSiteUrl) !== envSlug(options.siteUrl ?? getSiteUrl())) {
|
|
9708
|
+
return false;
|
|
9709
|
+
}
|
|
9710
|
+
if (health.needsReauth === true) return false;
|
|
9711
|
+
const pid = health.pid;
|
|
9712
|
+
if (typeof pid !== "number" || !(options.isAlive ?? processIsAlive)(pid)) return false;
|
|
9713
|
+
const lastSuccessAt = heartbeat.lastSuccessAt;
|
|
9714
|
+
const now = options.now ?? Date.now();
|
|
9715
|
+
return heartbeat.consecutiveFailures === 0 && typeof lastSuccessAt === "number" && lastSuccessAt <= now && now - lastSuccessAt < HEARTBEAT_FRESH_MS && ingestion.consecutiveFailures === 0;
|
|
9716
|
+
} catch {
|
|
9717
|
+
return false;
|
|
9718
|
+
}
|
|
9719
|
+
}
|
|
9720
|
+
function startBackgroundFlush(options = {}) {
|
|
9721
|
+
try {
|
|
9722
|
+
if ((options.daemonOwnsDrain ?? daemonOwnsDrain)()) return false;
|
|
9723
|
+
if (!(options.needsFlush ?? journalNeedsFlush)()) return false;
|
|
9724
|
+
const primEntry = options.primEntry === void 0 ? binFile("prim") : options.primEntry;
|
|
9725
|
+
if (!primEntry) return false;
|
|
9726
|
+
const child = (options.spawnProcess ?? spawn3)(
|
|
9727
|
+
options.nodeEntry ?? process.execPath,
|
|
9728
|
+
[primEntry, "moves", "flush"],
|
|
9729
|
+
// Ignored stdio matters as much as detaching: a child holding inherited
|
|
9730
|
+
// pipes would keep a caller that captures them (spawnSync) waiting.
|
|
9731
|
+
// windowsHide keeps the detached child from opening a console window.
|
|
9732
|
+
// The child is unattended even when its parent is not: `doctor` or
|
|
9733
|
+
// `statusline` must not reach the daemon drift heal through its drain.
|
|
9734
|
+
{ detached: true, stdio: "ignore", windowsHide: true, env: unattendedEnv() }
|
|
9735
|
+
);
|
|
9736
|
+
child.once("error", () => {
|
|
9737
|
+
});
|
|
9738
|
+
child.unref();
|
|
9739
|
+
return true;
|
|
9740
|
+
} catch {
|
|
9741
|
+
return false;
|
|
9742
|
+
}
|
|
9743
|
+
}
|
|
9744
|
+
|
|
9019
9745
|
// src/lib/startup-background.ts
|
|
9020
9746
|
var ROOT_OPTIONS = /* @__PURE__ */ new Set(["-y", "--yes", "--non-interactive"]);
|
|
9747
|
+
var HELP_OPTIONS = /* @__PURE__ */ new Set(["-h", "--help"]);
|
|
9748
|
+
var DAEMON_HEAL_COMMANDS = /* @__PURE__ */ new Map([
|
|
9749
|
+
// Credentials; `auth login` is how a person returns to a daemon held for re-auth.
|
|
9750
|
+
["auth", /* @__PURE__ */ new Set(["api-keys", "login", "set-token", "status"])],
|
|
9751
|
+
// Reading and curating the decision graph: the everyday attended surface.
|
|
9752
|
+
[
|
|
9753
|
+
"decisions",
|
|
9754
|
+
/* @__PURE__ */ new Set([
|
|
9755
|
+
"cascade",
|
|
9756
|
+
"check",
|
|
9757
|
+
"confirm",
|
|
9758
|
+
"create",
|
|
9759
|
+
"demote",
|
|
9760
|
+
"link",
|
|
9761
|
+
"promote",
|
|
9762
|
+
"publish",
|
|
9763
|
+
"ratify",
|
|
9764
|
+
"recent",
|
|
9765
|
+
"repairs",
|
|
9766
|
+
"rescope",
|
|
9767
|
+
"restore",
|
|
9768
|
+
"show",
|
|
9769
|
+
"supersede",
|
|
9770
|
+
"unlink",
|
|
9771
|
+
"withdraw"
|
|
9772
|
+
])
|
|
9773
|
+
],
|
|
9774
|
+
["reconcile", "any"],
|
|
9775
|
+
// Repository connection, activation, and journal plumbing a person steers.
|
|
9776
|
+
// Hook-side `moves flush` drains are unattended and never reach this table.
|
|
9777
|
+
["github", /* @__PURE__ */ new Set(["connect"])],
|
|
9778
|
+
["enable", "any"],
|
|
9779
|
+
["moves", /* @__PURE__ */ new Set(["bind", "drop", "flush", "status", "tail"])],
|
|
9780
|
+
["session", /* @__PURE__ */ new Set(["drop", "list", "start"])],
|
|
9781
|
+
// Installing or inspecting an integration, never removing one.
|
|
9782
|
+
["claude", /* @__PURE__ */ new Set(["install", "preauth", "status"])],
|
|
9783
|
+
["codex", /* @__PURE__ */ new Set(["install", "status"])],
|
|
9784
|
+
["cursor", /* @__PURE__ */ new Set(["install", "status"])],
|
|
9785
|
+
["hermes", /* @__PURE__ */ new Set(["install", "status"])],
|
|
9786
|
+
["hooks", /* @__PURE__ */ new Set(["install"])],
|
|
9787
|
+
["skill", /* @__PURE__ */ new Set(["install", "status"])],
|
|
9788
|
+
["welcome", "any"]
|
|
9789
|
+
]);
|
|
9021
9790
|
function commandArguments(argv) {
|
|
9022
9791
|
let index = 0;
|
|
9023
9792
|
while (ROOT_OPTIONS.has(argv[index] ?? "")) index += 1;
|
|
9024
9793
|
return argv.slice(index);
|
|
9025
9794
|
}
|
|
9795
|
+
function mayHealDaemon(argv, env) {
|
|
9796
|
+
if (isUnattended(env) || env[SETUP_ORCHESTRATOR_ENV] === "1") return false;
|
|
9797
|
+
const args = commandArguments(argv);
|
|
9798
|
+
if (args.some((arg) => HELP_OPTIONS.has(arg))) return false;
|
|
9799
|
+
const [command, subcommand] = args;
|
|
9800
|
+
const allowed = command === void 0 ? void 0 : DAEMON_HEAL_COMMANDS.get(command);
|
|
9801
|
+
if (allowed === void 0) return false;
|
|
9802
|
+
return allowed === "any" || subcommand !== void 0 && allowed.has(subcommand);
|
|
9803
|
+
}
|
|
9026
9804
|
function runStartupBackgroundWork(argv, env, work) {
|
|
9027
|
-
const
|
|
9805
|
+
const args = commandArguments(argv);
|
|
9806
|
+
const [command, subcommand] = args;
|
|
9028
9807
|
const uninstall = command === "uninstall" || env[UNINSTALL_ORCHESTRATOR_ENV] === "1";
|
|
9029
9808
|
if (uninstall) return;
|
|
9030
9809
|
work.notify();
|
|
9031
|
-
|
|
9032
|
-
|
|
9810
|
+
const setupDaemonDrains = command === "setup" && !args.includes("--no-daemon") || env[SETUP_DAEMON_DRAINS_ENV] === "1";
|
|
9811
|
+
if (!setupDaemonDrains && (command !== "moves" || subcommand !== "flush")) work.flush();
|
|
9812
|
+
if (mayHealDaemon(argv, env)) work.healDaemon();
|
|
9033
9813
|
}
|
|
9034
9814
|
|
|
9035
9815
|
// src/index.ts
|
|
9036
9816
|
var __dirname = dirname6(fileURLToPath2(import.meta.url));
|
|
9037
|
-
var pkg = JSON.parse(
|
|
9817
|
+
var pkg = JSON.parse(readFileSync10(resolve4(__dirname, "../package.json"), "utf-8"));
|
|
9038
9818
|
runStartupBackgroundWork(process.argv.slice(2), process.env, {
|
|
9039
9819
|
notify: () => updateNotifier({ pkg }).notify(),
|
|
9040
|
-
flush: () =>
|
|
9820
|
+
flush: () => startBackgroundFlush(),
|
|
9821
|
+
healDaemon: () => healDaemonDrift()
|
|
9041
9822
|
});
|
|
9042
9823
|
var program = new Command();
|
|
9043
9824
|
program.name("prim").description("CLI for Primitive's decision graph").version(pkg.version).option("-y, --yes", "auto-confirm prompts").option(
|