clearotron 0.3.3 → 0.4.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/INSTALL.md +1 -14
- package/bin/brandowner.mjs +5 -5
- package/bin/connect.mjs +4 -4
- package/bin/onboard.mjs +5 -7
- package/bin/start.mjs +20 -5
- package/bin/update.mjs +6 -1
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +2 -6
- package/driver/CHANGELOG.md +28 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/band-shape.mjs +8 -8
- package/driver/blind-frame-model.mjs +1 -1
- package/driver/common-law-receipts.mjs +2 -2
- package/driver/commonlaw-carry.mjs +2 -2
- package/driver/company-bundle.mjs +11 -18
- package/driver/connotation-search.mjs +4 -4
- package/driver/contract-e3-backlog.mjs +3 -3
- package/driver/declination-call.mjs +1 -1
- package/driver/declination-tool.mjs +1 -1
- package/driver/dev-portal.mjs +1 -1
- package/driver/door-call-verdict.mjs +27 -0
- package/driver/driver.config.mjs +17 -11
- package/driver/e2e/README.md +1 -1
- package/driver/engine/mcp/clarivate-server.mjs +2 -1
- package/driver/engine/mcp/corsearch-server.mjs +1 -0
- package/driver/engine/mcp/euipo-server.mjs +1 -0
- package/driver/engine/mcp/free-tier-server.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/engine/mcp/perplexity-server.mjs +1 -1
- package/driver/engine/mcp/recording-server.mjs +1 -1
- package/driver/engine/mcp/signa-server.mjs +1 -0
- package/driver/engine/mcp/supplemental.mjs +1 -1
- package/driver/engine/mcp/uspto-local-server.mjs +1 -0
- package/driver/enqueue-schema.mjs +2 -2
- package/driver/feedback-issues.mjs +1 -1
- package/driver/feedback-store.mjs +1 -1
- package/driver/findings-model.mjs +3 -3
- package/driver/flag-snapshot.mjs +1 -1
- package/driver/floor-duty.mjs +2 -2
- package/driver/form-neighbourhood.mjs +47 -15
- package/driver/frame-diff-model.mjs +3 -3
- package/driver/gateway.mjs +5 -5
- package/driver/jx-lanes.mjs +1 -1
- package/driver/known-conflicts.mjs +18 -0
- package/driver/log.mjs +2 -2
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +129 -95
- package/driver/pipeline.mjs +69 -32
- package/driver/placement-carry.mjs +2 -2
- package/driver/placement-form.mjs +1 -1
- package/driver/portal-mcp-client.mjs +1 -1
- package/driver/portal-request-origin.mjs +79 -0
- package/driver/portal-service.mjs +45 -17
- package/driver/predelivery-lint.mjs +10 -10
- package/driver/profile-page.html +9 -13
- package/driver/profile-service.mjs +25 -13
- package/driver/profiles.mjs +17 -4
- package/driver/progress.mjs +1 -1
- package/driver/provider-usage.mjs +24 -1
- package/driver/publish/index.mjs +17 -5
- package/driver/publish/knockout.mjs +3 -2
- package/driver/publish/render-knockout.mjs +1 -1
- package/driver/publish/render.mjs +14 -3
- package/driver/publish/search-depth.mjs +4 -2
- package/driver/recall-reconciliation.mjs +1 -1
- package/driver/record-carry.mjs +6 -6
- package/driver/recording-agreement.mjs +2 -2
- package/driver/reference-score.mjs +27 -27
- package/driver/register-count.mjs +56 -1
- package/driver/register-digest-record.mjs +1 -1
- package/driver/register-plan.mjs +5 -5
- package/driver/register-records.mjs +10 -1
- package/driver/registry-fidelity.mjs +4 -4
- package/driver/repair-composers.mjs +6 -6
- package/driver/run-economics.mjs +8 -19
- package/driver/screen-gate.mjs +1 -1
- package/driver/skills/blind-frame/SKILL.md +2 -2
- package/driver/skills/clearance-common-law/SKILL.md +2 -2
- package/driver/skills/clearance-common-law/perplexity-prompts.md +4 -4
- package/driver/skills/clearance-register/digest.md +1 -1
- package/driver/skills/clearance-register/unit.md +1 -1
- package/driver/skills/clearance-search/report-prose.md +5 -5
- package/driver/skills/clearance-search/synthesis-rules.md +4 -4
- package/driver/skills/clearance-variants/SKILL.md +2 -2
- package/driver/skills/clearance-variants/transliteration-scripts.md +1 -1
- package/driver/skills/frame-diff/SKILL.md +2 -2
- package/driver/skills/knockout-assess/SKILL.md +9 -9
- package/driver/skills/matter-frame/watchlist-reference.md +1 -1
- package/driver/skills/narrative-refutation/SKILL.md +2 -2
- package/driver/skills/placement-inquiry/SKILL.md +1 -1
- package/driver/stage-context.mjs +4 -4
- package/driver/stages.mjs +23 -15
- package/driver/suite-census.json +138 -42
- package/driver/systemd/clearotron-client-mcp.service +24 -0
- package/driver/systemd/clearotron-mcp-face.service +24 -0
- package/driver/systemd/clearotron-portal.service +24 -0
- package/driver/systemd/clearotron-worker.service +24 -0
- package/driver/tokens.mjs +26 -17
- package/driver/turnaround-bands.mjs +1 -1
- package/driver/unit-inventory.mjs +3 -3
- package/driver/variant-manifest-model.mjs +1 -1
- package/driver/verify.mjs +2 -2
- package/driver/whatif-memo-run.mjs +1 -1
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/audit.mjs +9 -2
- package/mcp-server/lib/http-handler.mjs +7 -3
- package/mcp-server/mint-token.mjs +8 -6
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +11 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-D_O_55vK.js} +59 -9
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/_shared/README.md +1 -1
- package/providers/_shared/answer-memory.mjs +199 -0
- package/providers/_shared/ledger-path.mjs +1 -1
- package/providers/_shared/ledger.mjs +47 -5
- package/providers/_shared/script-form.mjs +24 -5
- package/providers/_shared/term-shape.mjs +5 -5
- package/providers/clarivate/src/capabilities.js +11 -0
- package/providers/clarivate/src/core.js +140 -13
- package/providers/jx-subclass/lookup.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/signa/src/capabilities.js +24 -0
- package/providers/signa/src/core.js +66 -0
- package/scripts/deprecate-below.mjs +114 -2
- package/scripts/freeze-example-run.mjs +1 -1
- package/scripts/live-surface-check.mjs +11 -2
- package/scripts/release-entry-catch-up.mjs +211 -0
- package/scripts/release-note-required.mjs +102 -6
- package/scripts/release-rehearsal-version.mjs +60 -0
- package/scripts/release-sbom.mjs +104 -0
- package/scripts/release-visible-check.mjs +7 -5
- package/scripts/score.mjs +3 -3
- package/shared/brand.mjs +1 -1
- package/shared/client-door.mjs +15 -8
- package/shared/driver-dir.mjs +20 -9
- package/shared/names-in-force.mjs +2 -0
- package/shared/scope.mjs +25 -10
- package/shared/store-in-repo.mjs +38 -17
package/shared/client-door.mjs
CHANGED
|
@@ -40,8 +40,10 @@
|
|
|
40
40
|
// a door — which is now the difference between a box that installs and a box that crash-loops one.
|
|
41
41
|
|
|
42
42
|
import { join } from "node:path";
|
|
43
|
+
import { homedir } from "node:os";
|
|
43
44
|
import { challengeVerdict, blockedByAccessChallenge, challengeNote } from "./mcp-challenge.mjs"; // — F57
|
|
44
45
|
import { whatHoldsPort } from "./os-advice.mjs";
|
|
46
|
+
import { defaultDenylistPath } from "./scope.mjs"; // — the verifier owns the path; see the re-export below
|
|
45
47
|
|
|
46
48
|
/** The unit that runs the client door. Installed like any other; started only by this module. */
|
|
47
49
|
export const CLIENT_DOOR_UNIT = "clearotron-client-mcp.service";
|
|
@@ -59,7 +61,9 @@ export const CLIENT_DOOR_UNIT = "clearotron-client-mcp.service";
|
|
|
59
61
|
* file no verifier could read, and a revoked key kept answering 200. The guard existed; one of the two
|
|
60
62
|
* doors was outside it.
|
|
61
63
|
*/
|
|
62
|
-
|
|
64
|
+
// ONE definition, owned by the verifier (scope.mjs): the file a door reads when the setting is unset is,
|
|
65
|
+
// by construction, the file every revoker here writes.
|
|
66
|
+
export { defaultDenylistPath } from "./scope.mjs";
|
|
63
67
|
|
|
64
68
|
/**
|
|
65
69
|
* The denylist path a door should be given — the operator's, if they set one.
|
|
@@ -617,9 +621,10 @@ export function enablePlan({ env = {}, address, identity, accessFile = null, por
|
|
|
617
621
|
// ── THE DENYLIST PATH IS NAMED AND CREATED HERE, NEVER ASSUMED ───────────
|
|
618
622
|
//
|
|
619
623
|
// Measured on production, owner 2026-08-31: no denylist is configured anywhere — the variable is
|
|
620
|
-
// empty in one example env and commented out in the other, and `isRevoked()`
|
|
621
|
-
//
|
|
622
|
-
// `disconnect`
|
|
624
|
+
// empty in one example env and commented out in the other, and `isRevoked()` then returned false for
|
|
625
|
+
// every token. So a key minted by a connect that assumed a denylist was UNREVOKABLE, silently:
|
|
626
|
+
// `disconnect` wrote a jti into a file no verifier read, and every check looked done. `isRevoked()`
|
|
627
|
+
// now reads the default list when the path is unset; naming it here still pins every door to one file.
|
|
623
628
|
//
|
|
624
629
|
// Armed AT CONNECT TIME, deliberately, not at disconnect: a process reads its environment once, at
|
|
625
630
|
// start. A variable first written when someone disconnects is one the already-running door never
|
|
@@ -917,7 +922,7 @@ export function connectKeyReport(grants, { now = Date.now(), revoked = () => fal
|
|
|
917
922
|
* were recorded".
|
|
918
923
|
*/
|
|
919
924
|
export function disablePlan({ env = {}, unitDir, exists, identity = null, recorded = [],
|
|
920
|
-
denylistPath = null } = {}) {
|
|
925
|
+
denylistPath = null, home = homedir() } = {}) {
|
|
921
926
|
const door = clientDoorState({ env, unitDir, exists });
|
|
922
927
|
const jtis = (recorded ?? []).map((r) => r.jti).filter(Boolean);
|
|
923
928
|
// The path the running verifiers were BORN with wins; the caller's default only covers the install
|
|
@@ -926,7 +931,9 @@ export function disablePlan({ env = {}, unitDir, exists, identity = null, record
|
|
|
926
931
|
// a revocation the environment cannot deliver retroactively.
|
|
927
932
|
const existing = String(env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim();
|
|
928
933
|
const armedPath = existing || (jtis.length && denylistPath ? String(denylistPath) : null);
|
|
929
|
-
|
|
934
|
+
// NOT LATE WHEN IT IS THE DEFAULT LIST: a door with the setting unset reads the install's default list
|
|
935
|
+
// (isRevoked, shared/scope.mjs), so writing there reaches the running doors now, not from their next start.
|
|
936
|
+
const lateArm = Boolean(jtis.length && !existing && armedPath && armedPath !== defaultDenylistPath(home));
|
|
930
937
|
|
|
931
938
|
if (!jtis.length) {
|
|
932
939
|
return { possible: false, nothingOpen: true, door,
|
|
@@ -965,7 +972,7 @@ export function disablePlan({ env = {}, unitDir, exists, identity = null, record
|
|
|
965
972
|
* everything. Stopping the unit would additionally break the stdio-free local route for readers who
|
|
966
973
|
* hold no key at all, and would be undone by the next install.
|
|
967
974
|
*/
|
|
968
|
-
export function revokeEveryonePlan({ env = {}, grants = null, denylistPath = null } = {}) {
|
|
975
|
+
export function revokeEveryonePlan({ env = {}, grants = null, denylistPath = null, home = homedir() } = {}) {
|
|
969
976
|
const rows = Object.entries(grants?.connectKeys ?? {}).map(([jti, r]) => ({ jti, sub: r?.sub ?? null }));
|
|
970
977
|
const jtis = rows.map((r) => r.jti);
|
|
971
978
|
const people = [...new Set(rows.map((r) => r.sub).filter(Boolean))];
|
|
@@ -980,7 +987,7 @@ export function revokeEveryonePlan({ env = {}, grants = null, denylistPath = nul
|
|
|
980
987
|
return {
|
|
981
988
|
possible: true, nothingOpen: false, jtis, people,
|
|
982
989
|
denylistPath: armedPath,
|
|
983
|
-
lateArm: Boolean(!existing && armedPath),
|
|
990
|
+
lateArm: Boolean(!existing && armedPath && armedPath !== defaultDenylistPath(home)), // see disablePlan
|
|
984
991
|
// THE COUNT IS THE FIRST THING SAID, and it names both dimensions: five keys held by one person and
|
|
985
992
|
// five keys held by five people are the same number and not the same act.
|
|
986
993
|
says: [`This revokes ${jtis.length} issued key(s) held by ${people.length} ${people.length === 1 ? "person" : "people"}`
|
package/shared/driver-dir.mjs
CHANGED
|
@@ -40,15 +40,23 @@
|
|
|
40
40
|
//
|
|
41
41
|
// ── WHAT THIS DELIBERATELY DOES NOT DO ────────────────────────────────────────────────────────────
|
|
42
42
|
//
|
|
43
|
-
//
|
|
44
|
-
// commit would be a semantic change riding inside a seam, and the seam is the part that has to be
|
|
45
|
-
// reviewable by reading nineteen call sites and agreeing they still mean what they meant. The mode is
|
|
46
|
-
// That business, and when it is taken it is one line in this file.
|
|
43
|
+
// ── THE MODE, TAKEN HERE ONCE (2026-09-23) ────────────────────────────────────────────────────────
|
|
47
44
|
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
45
|
+
// A new run folder is created 0750: its owner, and the group the deployment reads reports through, and
|
|
46
|
+
// no other account. It used to take whatever the umask gave, 775 on a real run, so on a machine whose
|
|
47
|
+
// home or data folder other accounts can enter, any of them could read client matter. The folder that
|
|
48
|
+
// holds a run is created by the call below (measured by tracing a whole clearance: `ensureDriverDir` is
|
|
49
|
+
// the first write into a new run folder, and the archive is a rename that keeps the mode), so this one
|
|
50
|
+
// line closes the live run and its archived copy. The published copy takes the same constant.
|
|
51
|
+
//
|
|
52
|
+
// A MODE GIVEN TO mkdir, NEVER A chmod. The kernel sets a new folder's set-GID bit from its parent
|
|
53
|
+
// whatever mode is asked for, which is what the set-GID pool root relies on; a chmod by an account
|
|
54
|
+
// outside that group strips the bit silently and every report then answers 403. So nothing here ever
|
|
55
|
+
// changes an existing folder: a folder that already exists keeps the mode it has.
|
|
56
|
+
//
|
|
57
|
+
// IT DOES NOT RESTRAIN THE SEAT. The agent runs as the same account that owns these directories, so owner
|
|
58
|
+
// bits apply whatever the group bits say. Said here because a module named for a boundary invites the
|
|
59
|
+
// stronger reading.
|
|
52
60
|
//
|
|
53
61
|
// IT IS NOT `driverDirs`. `scripts/seat-retry-report.mjs` exports `driverDirs(root)`, which WALKS a tree
|
|
54
62
|
// to find every `_driver` beneath it. Discovery, not construction — a different concern that happens to
|
|
@@ -60,6 +68,9 @@ import { join } from "node:path";
|
|
|
60
68
|
/** The directory's name. The one place the string lives, so renaming it is an edit rather than a sweep. */
|
|
61
69
|
export const DRIVER_DIR = "_driver";
|
|
62
70
|
|
|
71
|
+
/** The mode a new run folder is created with: owner and group, no other account. See the header. */
|
|
72
|
+
export const RUN_DIR_MODE = 0o750;
|
|
73
|
+
|
|
63
74
|
/**
|
|
64
75
|
* The path to a run's `_driver/`, or to something inside it.
|
|
65
76
|
*
|
|
@@ -91,6 +102,6 @@ export function driverRel(...parts) {
|
|
|
91
102
|
*/
|
|
92
103
|
export function ensureDriverDir(base, ...parts) {
|
|
93
104
|
const dir = driverDir(base, ...parts);
|
|
94
|
-
mkdirSync(dir, { recursive: true });
|
|
105
|
+
mkdirSync(dir, { recursive: true, mode: RUN_DIR_MODE });
|
|
95
106
|
return dir;
|
|
96
107
|
}
|
|
@@ -32,6 +32,7 @@ export const NAMES_IN_FORCE = Object.freeze([
|
|
|
32
32
|
"CLEAROTRON_BRAND_TAGLINE",
|
|
33
33
|
"CLEAROTRON_CARD_CONCURRENCY",
|
|
34
34
|
"CLEAROTRON_CHECKOUT_DIR",
|
|
35
|
+
"CLEAROTRON_CLARIVATE_ANSWER_MEMORY",
|
|
35
36
|
"CLEAROTRON_CLAUDE_PATH",
|
|
36
37
|
"CLEAROTRON_CLIENT_MCP_URL",
|
|
37
38
|
"CLEAROTRON_CLIENT_OIDC_AUDIENCE",
|
|
@@ -163,6 +164,7 @@ export const NAMES_IN_FORCE = Object.freeze([
|
|
|
163
164
|
"CLEAROTRON_SCREEN_GATE_UNNAMED",
|
|
164
165
|
"CLEAROTRON_SELFTEST_PATHS_ONLY",
|
|
165
166
|
"CLEAROTRON_SEND_TOOL_PREFIX",
|
|
167
|
+
"CLEAROTRON_SIGNA_ANSWER_MEMORY",
|
|
166
168
|
"CLEAROTRON_SKILLS_STORE_MAIN_BRANCH",
|
|
167
169
|
"CLEAROTRON_SKILLS_STORE_STRICT",
|
|
168
170
|
"CLEAROTRON_SPAWN_GRACE_MS",
|
package/shared/scope.mjs
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
import { createHmac, timingSafeEqual, randomBytes } from "node:crypto";
|
|
21
21
|
import { readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { homedir } from "node:os";
|
|
22
24
|
import { envFrom } from "./env-aliases.mjs"; // — resolves EITHER spelling; names the retired one because that is the live-writable half
|
|
23
25
|
|
|
24
26
|
const SECRET = () => process.env.TRADEMARK_MCP_TOKEN_SECRET || "";
|
|
@@ -29,16 +31,29 @@ const SECRETS = () => [process.env.TRADEMARK_MCP_TOKEN_SECRET, process.env.TRADE
|
|
|
29
31
|
.filter((s) => typeof s === "string" && s.length > 0);
|
|
30
32
|
const b64u = (buf) => Buffer.from(buf).toString("base64url");
|
|
31
33
|
|
|
32
|
-
// Emergency revocation: a plain-text denylist file (one `jti` per line, `#` comments)
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// rotation instead.
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
// Emergency revocation: a plain-text denylist file (one `jti` per line, `#` comments). Checked on every
|
|
35
|
+
// token verification, ALWAYS: the path is TRADEMARK_MCP_TOKEN_DENYLIST when it is set, and otherwise the
|
|
36
|
+
// install's default below, the same file every revoker writes when the setting is unset (connect,
|
|
37
|
+
// disconnect, `key`, mint-token's own note). Short TTLs remain the primary control. Legacy tokens carry
|
|
38
|
+
// no jti and are killed by secret rotation instead.
|
|
39
|
+
//
|
|
40
|
+
// REVOCATION USED TO BE OFF WHEN THE SETTING WAS UNSET: this returned "not revoked" for every token, so a
|
|
41
|
+
// door started without the variable (by hand, or by a unit that never loaded it) ignored a revocation
|
|
42
|
+
// written to the default file. Now:
|
|
43
|
+
// - a NAMED list that cannot be read refuses every token (below), as it already did;
|
|
44
|
+
// - a list that EXISTS and cannot be read refuses every token, named or default;
|
|
45
|
+
// - the DEFAULT list being ABSENT means nothing has been revoked. Every revocation creates the file
|
|
46
|
+
// it writes to, and with the setting unset that is this file, so its absence is the proof, not a
|
|
47
|
+
// gap. It is the one absence read as a negative here, and only for that reason.
|
|
48
|
+
export const defaultDenylistPath = (home) => join(home, ".config", "clearotron", "token-denylist");
|
|
49
|
+
export function isRevoked(jti, { denylistPath = undefined, env = process.env, home = homedir() } = {}) {
|
|
50
|
+
if (!jti) return false;
|
|
51
|
+
const named = String(denylistPath ?? env.TRADEMARK_MCP_TOKEN_DENYLIST ?? "").trim();
|
|
52
|
+
const path = named || defaultDenylistPath(home);
|
|
39
53
|
let text;
|
|
40
|
-
try { text = readFileSync(
|
|
54
|
+
try { text = readFileSync(path, "utf8"); }
|
|
41
55
|
catch (e) {
|
|
56
|
+
if (!named && e?.code === "ENOENT") return false; // the default list, never written: nothing revoked
|
|
42
57
|
// FAIL CLOSED (found in review; ruled and recorded for the owner's
|
|
43
58
|
// review with the reversal path, which is this function).
|
|
44
59
|
//
|
|
@@ -50,10 +65,10 @@ export function isRevoked(jti, { denylistPath = process.env.TRADEMARK_MCP_TOKEN_
|
|
|
50
65
|
// The cost is now a VISIBLE outage that names its own cause, rather than an invisible hole. `start`
|
|
51
66
|
// creates this file before any door starts, so reaching this branch means someone removed or
|
|
52
67
|
// unreadable-ified it while the door was running — rare, and worth stopping for.
|
|
53
|
-
const err = new Error(`revocation could not be checked: the denylist at ${
|
|
68
|
+
const err = new Error(`revocation could not be checked: the denylist at ${path} is unreadable `
|
|
54
69
|
+ `(${e.code ?? e.message}). Refusing the token rather than assuming it was never revoked.`);
|
|
55
70
|
err.code = "REVOCATION_UNCHECKABLE";
|
|
56
|
-
err.denylistPath =
|
|
71
|
+
err.denylistPath = path;
|
|
57
72
|
throw err;
|
|
58
73
|
}
|
|
59
74
|
return text.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).includes(jti);
|
package/shared/store-in-repo.mjs
CHANGED
|
@@ -282,6 +282,7 @@ export function commitWithAuditRow({ audit, gitCommit, files, message, by, row }
|
|
|
282
282
|
// That makes the failure resumable, and it makes "a concurrent editor's staged work survives" true by
|
|
283
283
|
// construction rather than by a careful reset that has to guess whose paths are whose.
|
|
284
284
|
import { execFileSync } from "node:child_process";
|
|
285
|
+
import { BRAND } from "./brand.mjs";
|
|
285
286
|
|
|
286
287
|
// Lock contention is TRANSIENT and worth retrying. A hook rejection and a full disk are not: retrying
|
|
287
288
|
// them turns one named failure into several and delays the report. The distinction is the reason this
|
|
@@ -289,17 +290,39 @@ import { execFileSync } from "node:child_process";
|
|
|
289
290
|
export const isTransientGitFault = (detail) =>
|
|
290
291
|
/index\.lock|another git process seems to be running|Unable to create/i.test(String(detail ?? ""));
|
|
291
292
|
|
|
293
|
+
// ── THE PRODUCT NAMES ITS OWN COMMITTER WHEN GIT HAS NONE ───────────────────────────────────────────
|
|
294
|
+
//
|
|
295
|
+
// A save names its author, the person who asked for it. Git also records a COMMITTER, taken from the
|
|
296
|
+
// repository's or the account's configuration, and refuses the commit when it finds none. That is the
|
|
297
|
+
// default state of a service account nobody ran `git config` for, and of a fresh Windows install: every
|
|
298
|
+
// company created in the portal was refused there, with a message telling the person to run git. The
|
|
299
|
+
// person creating a company cannot act on that, and nothing about the save needs them to.
|
|
300
|
+
//
|
|
301
|
+
// So a commit into the store is given the product's own committer whenever git cannot name one, on every
|
|
302
|
+
// commit rather than once: `-c` or an environment variable configures one invocation, not the repository.
|
|
303
|
+
// A store or account that already has an identity keeps it, so a configured install records exactly what
|
|
304
|
+
// it recorded before, signing included.
|
|
305
|
+
export const STORE_COMMITTER = Object.freeze({ name: BRAND.name, email: "store@localhost" });
|
|
306
|
+
|
|
307
|
+
/** The environment a commit into `repoRoot` runs with: `env` itself when git can name a committer, else `env` plus the product's. */
|
|
308
|
+
export function storeCommitEnv(repoRoot, { env = process.env } = {}) {
|
|
309
|
+
requireRepoRootPath(repoRoot, "storeCommitEnv");
|
|
310
|
+
try {
|
|
311
|
+
execFileSync("git", ["-C", repoRoot, "var", "GIT_COMMITTER_IDENT"], { stdio: ["ignore", "pipe", "pipe"], env });
|
|
312
|
+
return env;
|
|
313
|
+
} catch {
|
|
314
|
+
return { ...env, GIT_COMMITTER_NAME: STORE_COMMITTER.name, GIT_COMMITTER_EMAIL: STORE_COMMITTER.email };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
292
318
|
/**
|
|
293
319
|
* Why a commit into `repoRoot` would be refused, asked BEFORE anything is written; null when it would not
|
|
294
|
-
* be. Read-only: `rev-parse`
|
|
295
|
-
*
|
|
320
|
+
* be. Read-only: `rev-parse` changes nothing. `{ code, detail, message }`, where `message` names the store
|
|
321
|
+
* and the command that clears it, in terms an operator acts on rather than git's own words:
|
|
296
322
|
*
|
|
297
|
-
* not-a-repository — the store is not inside a git repository this process can use
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* its author; the COMMITTER is the machine's, and git refuses a commit it cannot name
|
|
301
|
-
* one for. `git -c user.email=…` on a one-off seed commit does not help: `-c` configures
|
|
302
|
-
* that invocation, not the repository, so every save after it fails the same way.
|
|
323
|
+
* not-a-repository — the store is not inside a git repository this process can use.
|
|
324
|
+
*
|
|
325
|
+
* A missing committer identity is not a refusal: every commit into the store supplies one (storeCommitEnv).
|
|
303
326
|
*/
|
|
304
327
|
export function storeCommitRefusal(repoRoot, { env = process.env } = {}) {
|
|
305
328
|
requireRepoRootPath(repoRoot, "storeCommitRefusal");
|
|
@@ -313,18 +336,13 @@ export function storeCommitRefusal(repoRoot, { env = process.env } = {}) {
|
|
|
313
336
|
: `run \`git init\` in ${repoRoot}, or point PROFILE_REPO_ROOT at the repository that holds the store`;
|
|
314
337
|
return { code: "not-a-repository", detail, message: `the store at ${repoRoot} is not a git repository this install can record into (${detail}) — ${fix}` };
|
|
315
338
|
}
|
|
316
|
-
try { ask("var", "GIT_COMMITTER_IDENT"); }
|
|
317
|
-
catch (e) {
|
|
318
|
-
return { code: "no-identity", detail: said(e).pop()?.slice(0, 200) ?? "",
|
|
319
|
-
message: `the store at ${repoRoot} has no git identity, so nothing saved to it can be recorded — run `
|
|
320
|
-
+ `\`git -C ${repoRoot} config user.email "you@example.com"\` and \`git -C ${repoRoot} config user.name "Your Name"\`` };
|
|
321
|
-
}
|
|
322
339
|
return null;
|
|
323
340
|
}
|
|
324
341
|
|
|
325
342
|
export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retries = 3, waitMs = 50 }) {
|
|
326
343
|
requireRepoRootPath(repoRoot, "makeStoreCommit");
|
|
327
|
-
const
|
|
344
|
+
const run = (env, args) => execFileSync("git", ["-C", repoRoot, ...args], { encoding: "utf8", ...(env ? { env } : {}) }).toString().trim();
|
|
345
|
+
const git = (...args) => run(null, args);
|
|
328
346
|
// ── IS THIS A REPOSITORY WE CAN USE? ASKED FIRST ─────────────────────────────
|
|
329
347
|
//
|
|
330
348
|
// Outside a repository git falls back to `--no-index` mode, which has no `--cached` — so the very
|
|
@@ -351,13 +369,16 @@ export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retr
|
|
|
351
369
|
log(`${what}: the store is not a usable git repository, so this save CANNOT be committed — ${refusal}`);
|
|
352
370
|
throw new Error(`the store at ${repoRoot} is not a usable git repository: ${refusal}`);
|
|
353
371
|
}
|
|
372
|
+
// Asked per commit, so an identity somebody configures while the service runs is used from then on.
|
|
373
|
+
const committing = storeCommitEnv(repoRoot);
|
|
374
|
+
const gitCommit = (...args) => run(committing, args);
|
|
354
375
|
// (1) Complete what an earlier failed save left staged. BEST EFFORT: if this fails too — the same
|
|
355
376
|
// hook is still rejecting, the disk is still full — it must not stop the save that is happening now,
|
|
356
377
|
// and it must not touch the index either. The paths simply stay staged for the next attempt.
|
|
357
378
|
try {
|
|
358
379
|
const found = git("diff", "--cached", "--name-only").split("\n").filter(Boolean);
|
|
359
380
|
if (found.length) {
|
|
360
|
-
|
|
381
|
+
gitCommit("commit", "-m", `Complete a store save left staged by an earlier failure (${found.length} path(s))`,
|
|
361
382
|
"--author", `${author} <${author}>`);
|
|
362
383
|
log(`${what}: completed a commit left staged by an earlier failed save — ${found.join(", ")}`);
|
|
363
384
|
}
|
|
@@ -369,7 +390,7 @@ export function makeStoreCommit({ repoRoot, log = () => {}, what = "store", retr
|
|
|
369
390
|
git("add", ...files);
|
|
370
391
|
for (let attempt = 1; ; attempt++) {
|
|
371
392
|
try {
|
|
372
|
-
|
|
393
|
+
gitCommit("commit", "-m", message, "--author", `${author} <${author}>`);
|
|
373
394
|
return git("rev-parse", "HEAD");
|
|
374
395
|
} catch (e) {
|
|
375
396
|
const detail = detailOf(e);
|