entropy-machines 0.1.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/LICENSE +93 -0
- package/README.md +68 -0
- package/agents/isolated-worker.md +128 -0
- package/agents/verifier.md +158 -0
- package/bin/dispatch +700 -0
- package/bin/doclint +460 -0
- package/bin/drain +507 -0
- package/bin/drain-pick.py +168 -0
- package/bin/drain-prompt.md +67 -0
- package/bin/drain-run.sh +342 -0
- package/bin/entropy-machines-init +285 -0
- package/bin/handoff +1151 -0
- package/bin/init +232 -0
- package/bin/post-fold-audit +377 -0
- package/bin/serve +724 -0
- package/bin/status +208 -0
- package/bin/tracker +153 -0
- package/docs/AGENT-QUICKSTART.md +86 -0
- package/docs/CONFIG.md +68 -0
- package/docs/NPM.md +91 -0
- package/docs/SERVE.md +74 -0
- package/docs/TRACKER-ADAPTER.md +66 -0
- package/doctrine/HANDOFF-PROMPT.md +63 -0
- package/doctrine/README.md +62 -0
- package/doctrine/ROLES.md +27 -0
- package/doctrine/WORKFLOW.md +87 -0
- package/hooks/commit-msg +24 -0
- package/hooks/post-checkout +354 -0
- package/hooks/pre-commit +33 -0
- package/lib/PRD-001-orientation.html +1180 -0
- package/lib/REPORT-TEMPLATE.html +413 -0
- package/lib/changelog-collate.mjs +328 -0
- package/lib/changelog-guard.sh +157 -0
- package/lib/changelog-new.mjs +70 -0
- package/lib/config.mjs +283 -0
- package/lib/config.py +317 -0
- package/lib/doc-template.html +807 -0
- package/lib/entropy-drain.plist.in +59 -0
- package/lib/entropy-drain.service.in +53 -0
- package/lib/entropy-drain.timer.in +36 -0
- package/lib/fail-first.mjs +901 -0
- package/lib/handoff-guard.sh +623 -0
- package/lib/install-hooks.sh +169 -0
- package/lib/notes.py +675 -0
- package/lib/preflight-tree.mjs +82 -0
- package/lib/roots.sh +212 -0
- package/lib/themes/daylight.css +84 -0
- package/lib/themes/high-contrast.css +36 -0
- package/lib/tracker-file +333 -0
- package/lib/tracker-view.py +784 -0
- package/package.json +38 -0
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* config — load config.json once per process, fill in defaults, and refuse
|
|
4
|
+
* with a named key when a key has no safe default and the project did not
|
|
5
|
+
* supply one.
|
|
6
|
+
*
|
|
7
|
+
* Every value the rest of this harness used to hardcode about its host
|
|
8
|
+
* project lives in config.json at the repo root (see docs/CONFIG.md). This
|
|
9
|
+
* is the one place that reads the file; every entry point calls loadConfig()
|
|
10
|
+
* once and passes the object down. A previous version of the tooling this
|
|
11
|
+
* harness grew from read its own state file from four independent places,
|
|
12
|
+
* each with a slightly different idea of what was optional, and reconciling
|
|
13
|
+
* them after the fact was worse than writing one loader up front.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
export const CONFIG_FILENAME = 'config.json';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Defaults for every key that has a safe one. A default here must be inert —
|
|
24
|
+
* "do nothing" or "match everything", never a guess at what a project's
|
|
25
|
+
* toolchain looks like. `suites` defaults to an empty list, not to a set of
|
|
26
|
+
* npm scripts; a consuming Go-only or shell-only project must not see this
|
|
27
|
+
* harness silently assume it is Node.
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULTS = {
|
|
30
|
+
project: { name: null, protectedPaths: [] },
|
|
31
|
+
tracker: {
|
|
32
|
+
backend: 'file',
|
|
33
|
+
file: { path: '.entropy-machines/issues.json' },
|
|
34
|
+
command: { bin: null },
|
|
35
|
+
},
|
|
36
|
+
suites: [],
|
|
37
|
+
generate: { cmd: null, outputs: [] },
|
|
38
|
+
changelog: {
|
|
39
|
+
enabled: true,
|
|
40
|
+
fragmentDir: 'changelog.d',
|
|
41
|
+
collatedFile: 'docs/CHANGELOG.md',
|
|
42
|
+
marker: '<!-- BEGIN COLLATED changelog.d -->',
|
|
43
|
+
// Not in docs/CONFIG.md yet — see lib/changelog-guard.sh's header comment.
|
|
44
|
+
// Paths whose change requires a fragment. Default: everything, i.e. "one
|
|
45
|
+
// fragment per commit" until a project narrows it.
|
|
46
|
+
watchedPathPatterns: ['**'],
|
|
47
|
+
// Not in docs/CONFIG.md yet. Display text only (changelog-guard.sh's
|
|
48
|
+
// failure message) — not shelled out to.
|
|
49
|
+
newFragmentCmd: 'node lib/changelog-new.mjs --',
|
|
50
|
+
// Not in docs/CONFIG.md yet. Display text only (changelog-collate.mjs's
|
|
51
|
+
// "you need to re-collate" message) — not shelled out to.
|
|
52
|
+
collateCmd: 'node lib/changelog-collate.mjs',
|
|
53
|
+
},
|
|
54
|
+
guards: {
|
|
55
|
+
testPathPatterns: ['**/*.test.*', 'tests/**'],
|
|
56
|
+
nonCodePatterns: ['**/*.md', 'docs/**'],
|
|
57
|
+
buildFailureMarkers: [],
|
|
58
|
+
// Not in docs/CONFIG.md yet — see lib/fail-first.mjs's runTest(). Extra
|
|
59
|
+
// environment variables applied to every guard-test invocation. This is
|
|
60
|
+
// how a project that uses Go's test result cache opts into
|
|
61
|
+
// `{ "GOFLAGS": "-count=1" }`; the harness no longer injects it for you.
|
|
62
|
+
testEnv: {},
|
|
63
|
+
},
|
|
64
|
+
worktree: { linkPaths: ['node_modules'] },
|
|
65
|
+
unattended: {
|
|
66
|
+
enabled: false,
|
|
67
|
+
stateHome: '~/.entropy-machines',
|
|
68
|
+
scheduler: 'launchd',
|
|
69
|
+
label: 'com.entropy-machines.drain',
|
|
70
|
+
agent: { cmd: ['claude', '-p'] },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function isPlainObject(v) {
|
|
75
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deepMerge(base, override) {
|
|
79
|
+
if (!isPlainObject(base) || !isPlainObject(override)) return override ?? base;
|
|
80
|
+
const out = { ...base };
|
|
81
|
+
for (const [k, v] of Object.entries(override)) {
|
|
82
|
+
out[k] = isPlainObject(base[k]) && isPlainObject(v) ? deepMerge(base[k], v) : v;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The main checkout of the git repository containing `dir`, or null.
|
|
89
|
+
*
|
|
90
|
+
* `--git-common-dir`, NOT `--show-toplevel`. Mirrors lib/roots.sh's
|
|
91
|
+
* entropy_machines_root(), which is canonical — read its header for why. The short
|
|
92
|
+
* version: from inside a LINKED WORKTREE --show-toplevel prints the worktree,
|
|
93
|
+
* --git-common-dir names the main checkout, and the main checkout is where
|
|
94
|
+
* config.json and .entropy-machines/ live. This loader used to say --show-toplevel,
|
|
95
|
+
* so from a worktree it read a DIFFERENT config.json than the shell scripts
|
|
96
|
+
* calling it did; that was invisible only because the file is tracked and the
|
|
97
|
+
* two copies were identical.
|
|
98
|
+
*
|
|
99
|
+
* WHY THIS IS REIMPLEMENTED RATHER THAN SOURCING roots.sh: shelling out to
|
|
100
|
+
* `sh -c '. roots.sh; entropy_machines_root'` would be a shell process wrapping the
|
|
101
|
+
* same git process — two spawns where there is one — on a loader every entry
|
|
102
|
+
* point calls. The duplication is bounded to these ~8 lines, and the common
|
|
103
|
+
* path avoids the spawn entirely via ENTROPY_MACHINES_ROOT below.
|
|
104
|
+
*/
|
|
105
|
+
function gitRoot(dir) {
|
|
106
|
+
let common;
|
|
107
|
+
try {
|
|
108
|
+
common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
109
|
+
cwd: dir,
|
|
110
|
+
encoding: 'utf8',
|
|
111
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
112
|
+
}).trim();
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (!common) return null;
|
|
117
|
+
// git printed it relative to the directory it ran in.
|
|
118
|
+
if (!path.isAbsolute(common)) common = path.join(dir, common);
|
|
119
|
+
try {
|
|
120
|
+
return fs.realpathSync(path.dirname(path.resolve(common)));
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The ONE root: the repository's main checkout. Agrees with lib/roots.sh.
|
|
128
|
+
*
|
|
129
|
+
* Precedence:
|
|
130
|
+
* 1. an explicit `start` — resolved with git from there. This is a seam
|
|
131
|
+
* (lib/fail-first.mjs's FAIL_FIRST_REPO uses it) for pointing the loader
|
|
132
|
+
* at a repo other than the ambient one, so it must win.
|
|
133
|
+
* 2. $ENTROPY_MACHINES_ROOT — already resolved by lib/roots.sh's
|
|
134
|
+
* entropy_machines_require_root(), which exports it. Honouring it is what makes
|
|
135
|
+
* the shell entry points and this loader agree BY CONSTRUCTION rather
|
|
136
|
+
* than by two implementations happening to compute the same path, and it
|
|
137
|
+
* skips the git spawn on the path every entry point takes. It is not a
|
|
138
|
+
* user knob and not the deleted ENTROPY_PROJECT override: nothing sets it
|
|
139
|
+
* but roots.sh, and it is ignored unless it names an existing directory.
|
|
140
|
+
* 3. the process cwd.
|
|
141
|
+
* Falls back to walking up for config.json when there is no git.
|
|
142
|
+
*/
|
|
143
|
+
export function findRepoRoot(start) {
|
|
144
|
+
if (start === undefined || start === null) {
|
|
145
|
+
const envRoot = process.env.ENTROPY_MACHINES_ROOT;
|
|
146
|
+
if (envRoot) {
|
|
147
|
+
try {
|
|
148
|
+
if (fs.statSync(envRoot).isDirectory()) return fs.realpathSync(envRoot);
|
|
149
|
+
} catch {
|
|
150
|
+
/* not a directory we can see — fall through to resolving it ourselves */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
start = process.cwd();
|
|
154
|
+
}
|
|
155
|
+
const root = gitRoot(start);
|
|
156
|
+
if (root) return root;
|
|
157
|
+
let dir = path.resolve(start);
|
|
158
|
+
for (;;) {
|
|
159
|
+
if (fs.existsSync(path.join(dir, CONFIG_FILENAME))) return dir;
|
|
160
|
+
const parent = path.dirname(dir);
|
|
161
|
+
if (parent === dir) return path.resolve(start);
|
|
162
|
+
dir = parent;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Keys with no safe default: named and refused, per docs/CONFIG.md rule 2. */
|
|
167
|
+
function validate(cfg) {
|
|
168
|
+
const missing = [];
|
|
169
|
+
if (cfg.tracker.backend === 'command' && !cfg.tracker.command?.bin) {
|
|
170
|
+
missing.push('tracker.command.bin (required because tracker.backend is "command")');
|
|
171
|
+
}
|
|
172
|
+
if (cfg.unattended.enabled && !(cfg.unattended.agent?.cmd?.length)) {
|
|
173
|
+
missing.push('unattended.agent.cmd (required because unattended.enabled is true)');
|
|
174
|
+
}
|
|
175
|
+
if (missing.length) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`config.json is missing required configuration:\n${missing
|
|
178
|
+
.map((m) => ` - ${m}`)
|
|
179
|
+
.join('\n')}\nSee docs/CONFIG.md.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let cached = null;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Load and validate config.json. Cached per process (docs/CONFIG.md rule 3)
|
|
188
|
+
* — call this once at each entry point and pass the result down; do not call
|
|
189
|
+
* it again from a library function. `force` is for tests only.
|
|
190
|
+
*/
|
|
191
|
+
export function loadConfig({ cwd, force = false } = {}) {
|
|
192
|
+
if (cached && !force) return cached;
|
|
193
|
+
const root = findRepoRoot(cwd);
|
|
194
|
+
// config.json lives in the harness dir, not at the git root.
|
|
195
|
+
const harnessDir = path.resolve(path.dirname(import.meta.url.replace('file://', '')), '..');
|
|
196
|
+
const file = path.join(harnessDir, CONFIG_FILENAME);
|
|
197
|
+
let user = {};
|
|
198
|
+
if (fs.existsSync(file)) {
|
|
199
|
+
try {
|
|
200
|
+
user = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
201
|
+
} catch (err) {
|
|
202
|
+
throw new Error(`${file}: invalid JSON — ${err.message}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const merged = deepMerge(DEFAULTS, user);
|
|
206
|
+
if (!merged.project.name) merged.project.name = path.basename(root);
|
|
207
|
+
validate(merged);
|
|
208
|
+
Object.defineProperty(merged, '_root', { value: root, enumerable: false });
|
|
209
|
+
Object.defineProperty(merged, '_path', {
|
|
210
|
+
value: fs.existsSync(file) ? file : null,
|
|
211
|
+
enumerable: false,
|
|
212
|
+
});
|
|
213
|
+
cached = merged;
|
|
214
|
+
return merged;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `**`/`*`/`?` glob -> RegExp, anchored to the whole string. Enough for the
|
|
219
|
+
* path-pattern lists in config.json (`guards.testPathPatterns` and
|
|
220
|
+
* friends); not a full minimatch, and not meant to be one.
|
|
221
|
+
*/
|
|
222
|
+
export function globToRegExp(glob) {
|
|
223
|
+
let re = '';
|
|
224
|
+
for (let i = 0; i < glob.length; i++) {
|
|
225
|
+
const c = glob[i];
|
|
226
|
+
if (c === '*') {
|
|
227
|
+
if (glob[i + 1] === '*') {
|
|
228
|
+
re += '.*';
|
|
229
|
+
i++;
|
|
230
|
+
if (glob[i + 1] === '/') i++;
|
|
231
|
+
} else {
|
|
232
|
+
re += '[^/]*';
|
|
233
|
+
}
|
|
234
|
+
} else if (c === '?') {
|
|
235
|
+
re += '[^/]';
|
|
236
|
+
} else if ('.+^${}()|[]\\'.includes(c)) {
|
|
237
|
+
re += `\\${c}`;
|
|
238
|
+
} else {
|
|
239
|
+
re += c;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return new RegExp(`^${re}$`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function matchesAny(patterns, p) {
|
|
246
|
+
return patterns.some((pat) => globToRegExp(pat).test(p));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- CLI: `node lib/config.mjs [root | get <dotted.key>]` — for callers that need one value and don't want to embed Node. ---
|
|
250
|
+
function cliMain(argv) {
|
|
251
|
+
const cfg = loadConfig();
|
|
252
|
+
const [cmd, ...rest] = argv;
|
|
253
|
+
if (cmd === 'root') {
|
|
254
|
+
console.log(cfg._root);
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
if (cmd === 'get') {
|
|
258
|
+
const key = rest[0];
|
|
259
|
+
if (!key) {
|
|
260
|
+
console.error('usage: config.mjs get <dotted.key>');
|
|
261
|
+
return 2;
|
|
262
|
+
}
|
|
263
|
+
let v = cfg;
|
|
264
|
+
for (const part of key.split('.')) v = v?.[part];
|
|
265
|
+
if (v === undefined) {
|
|
266
|
+
console.error(`config.mjs: no such key ${key}`);
|
|
267
|
+
return 1;
|
|
268
|
+
}
|
|
269
|
+
console.log(typeof v === 'string' ? v : JSON.stringify(v));
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
console.error('usage: config.mjs [root | get <dotted.key>]');
|
|
273
|
+
return 2;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
277
|
+
try {
|
|
278
|
+
process.exit(cliMain(process.argv.slice(2)));
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error(`config.mjs: ${err.message}`);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
}
|
package/lib/config.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""config — load config.json once per process, fill in defaults, and refuse
|
|
3
|
+
with a named key when a key has no safe default and the project did not
|
|
4
|
+
supply one.
|
|
5
|
+
|
|
6
|
+
Mirrors lib/config.mjs. Two implementations exist because this harness's own
|
|
7
|
+
tooling is a mix of Node and Python; both read the same config.json and
|
|
8
|
+
apply the same defaults independently, so a consuming project edits exactly
|
|
9
|
+
one file and either loader sees it the same way. Neither reads the file more
|
|
10
|
+
than once per process (docs/CONFIG.md rule 3) — call load_config() once at
|
|
11
|
+
each entry point and pass the result down.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re as _re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
CONFIG_FILENAME = "config.json"
|
|
20
|
+
|
|
21
|
+
# Defaults for every key that has a safe one. A default here must be inert —
|
|
22
|
+
# "do nothing" or "match everything" — never a guess at a project's
|
|
23
|
+
# toolchain. `suites` defaults to an empty list, not to a set of npm scripts.
|
|
24
|
+
DEFAULTS = {
|
|
25
|
+
"project": {"name": None, "protectedPaths": []},
|
|
26
|
+
"tracker": {
|
|
27
|
+
"backend": "file",
|
|
28
|
+
"file": {"path": ".entropy-machines/issues.json"},
|
|
29
|
+
"command": {"bin": None},
|
|
30
|
+
},
|
|
31
|
+
"suites": [],
|
|
32
|
+
"generate": {"cmd": None, "outputs": []},
|
|
33
|
+
"changelog": {
|
|
34
|
+
"enabled": True,
|
|
35
|
+
"fragmentDir": "changelog.d",
|
|
36
|
+
"collatedFile": "docs/CHANGELOG.md",
|
|
37
|
+
"marker": "<!-- BEGIN COLLATED changelog.d -->",
|
|
38
|
+
# Not in docs/CONFIG.md yet — see lib/changelog-guard.sh's header.
|
|
39
|
+
"watchedPathPatterns": ["**"],
|
|
40
|
+
# Not in docs/CONFIG.md yet. Display text only (changelog-guard.sh's
|
|
41
|
+
# failure message) — not shelled out to.
|
|
42
|
+
"newFragmentCmd": "node lib/changelog-new.mjs --",
|
|
43
|
+
# Not in docs/CONFIG.md yet. Display text only (changelog-collate.mjs's
|
|
44
|
+
# "you need to re-collate" message) — not shelled out to.
|
|
45
|
+
"collateCmd": "node lib/changelog-collate.mjs",
|
|
46
|
+
},
|
|
47
|
+
"guards": {
|
|
48
|
+
"testPathPatterns": ["**/*.test.*", "tests/**"],
|
|
49
|
+
"nonCodePatterns": ["**/*.md", "docs/**"],
|
|
50
|
+
"buildFailureMarkers": [],
|
|
51
|
+
# Not in docs/CONFIG.md yet — see lib/fail-first.mjs's runTest().
|
|
52
|
+
"testEnv": {},
|
|
53
|
+
},
|
|
54
|
+
"worktree": {"linkPaths": ["node_modules"]},
|
|
55
|
+
"unattended": {
|
|
56
|
+
"enabled": False,
|
|
57
|
+
"stateHome": "~/.entropy-machines",
|
|
58
|
+
"scheduler": "launchd",
|
|
59
|
+
"label": "com.entropy-machines.drain",
|
|
60
|
+
"agent": {"cmd": ["claude", "-p"]},
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_dict(v):
|
|
66
|
+
return isinstance(v, dict)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _deep_merge(base, override):
|
|
70
|
+
if not _is_dict(base) or not _is_dict(override):
|
|
71
|
+
return override if override is not None else base
|
|
72
|
+
out = dict(base)
|
|
73
|
+
for k, v in override.items():
|
|
74
|
+
if _is_dict(base.get(k)) and _is_dict(v):
|
|
75
|
+
out[k] = _deep_merge(base[k], v)
|
|
76
|
+
else:
|
|
77
|
+
out[k] = v
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _git_root(start):
|
|
82
|
+
"""The main checkout of the git repository containing `start`, or None.
|
|
83
|
+
|
|
84
|
+
`--git-common-dir`, NOT `--show-toplevel`. Mirrors lib/roots.sh's
|
|
85
|
+
entropy_machines_root(), which is canonical — read its header for why. The short
|
|
86
|
+
version: from inside a LINKED WORKTREE --show-toplevel prints the
|
|
87
|
+
worktree, --git-common-dir names the main checkout, and the main checkout
|
|
88
|
+
is where config.json and .entropy-machines/ live. This loader used to say
|
|
89
|
+
--show-toplevel, so from a worktree it read a DIFFERENT config.json than
|
|
90
|
+
the shell scripts calling it did; that was invisible only because the
|
|
91
|
+
file is tracked and the two copies were identical.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
out = subprocess.run(
|
|
95
|
+
["git", "rev-parse", "--git-common-dir"],
|
|
96
|
+
cwd=start,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
timeout=10,
|
|
100
|
+
)
|
|
101
|
+
except (OSError, subprocess.SubprocessError):
|
|
102
|
+
return None
|
|
103
|
+
if out.returncode != 0 or not out.stdout.strip():
|
|
104
|
+
return None
|
|
105
|
+
common = out.stdout.strip()
|
|
106
|
+
if not os.path.isabs(common):
|
|
107
|
+
# git printed it relative to the directory it ran in.
|
|
108
|
+
common = os.path.join(start, common)
|
|
109
|
+
return os.path.realpath(os.path.join(common, os.pardir))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def refuse_nested_clone(tool, harness_dir=None):
|
|
113
|
+
"""Exit 2 if the harness is a git repo nested inside another git repo.
|
|
114
|
+
|
|
115
|
+
The Python mirror of lib/roots.sh's entropy_machines_refuse_nested_clone(). The
|
|
116
|
+
harness is meant to be VENDORED as plain tracked files; a nested .git
|
|
117
|
+
shadows the enclosing repo for every git query, so commands run from
|
|
118
|
+
inside it resolve to the harness and silently operate on the wrong
|
|
119
|
+
repository.
|
|
120
|
+
|
|
121
|
+
Every shell entry point gets this through entropy_machines_require_root(). The
|
|
122
|
+
Python ones resolve the root themselves and so have to ask explicitly --
|
|
123
|
+
which is exactly how bin/init came to be the one command exempt from a
|
|
124
|
+
refusal whose whole purpose is to stop it writing into the harness.
|
|
125
|
+
"""
|
|
126
|
+
if harness_dir is None:
|
|
127
|
+
harness_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
128
|
+
if not os.path.exists(os.path.join(harness_dir, ".git")):
|
|
129
|
+
return
|
|
130
|
+
inner = _git_root(harness_dir)
|
|
131
|
+
parent = os.path.dirname(harness_dir)
|
|
132
|
+
outer = _git_root(parent) if parent and parent != harness_dir else None
|
|
133
|
+
if not outer or not inner or os.path.realpath(outer) == os.path.realpath(inner):
|
|
134
|
+
return
|
|
135
|
+
sys.stderr.write(
|
|
136
|
+
"%s: REFUSED — the harness at %s is a git repository of its own,\n"
|
|
137
|
+
" nested inside %s.\n"
|
|
138
|
+
" A nested .git shadows the enclosing repo, so commands run here\n"
|
|
139
|
+
" resolve to the harness and operate on the wrong repository.\n"
|
|
140
|
+
" Fix: remove %s/.git and commit these files into the project, or\n"
|
|
141
|
+
" re-vendor the harness as plain files.\n"
|
|
142
|
+
% (tool, harness_dir, outer, harness_dir))
|
|
143
|
+
sys.exit(2)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def find_repo_root(start=None):
|
|
147
|
+
"""The ONE root: the repository's main checkout. Agrees with lib/roots.sh.
|
|
148
|
+
|
|
149
|
+
Precedence:
|
|
150
|
+
1. an explicit `start` — resolved with git from there. This is a seam
|
|
151
|
+
(lib/fail-first.mjs's FAIL_FIRST_REPO is the Node twin) for pointing
|
|
152
|
+
the loader at a repo other than the ambient one, so it must win.
|
|
153
|
+
2. $ENTROPY_MACHINES_ROOT — already resolved by lib/roots.sh's
|
|
154
|
+
entropy_machines_require_root(), which exports it. Honouring it is what makes
|
|
155
|
+
the shell entry points and this loader agree BY CONSTRUCTION rather
|
|
156
|
+
than by two implementations happening to compute the same path. It
|
|
157
|
+
is not a user knob and not the deleted ENTROPY_PROJECT override:
|
|
158
|
+
nothing sets it but roots.sh, and it is ignored unless it names an
|
|
159
|
+
existing directory.
|
|
160
|
+
3. the process cwd.
|
|
161
|
+
Falls back to walking up for config.json when there is no git.
|
|
162
|
+
"""
|
|
163
|
+
if start is None:
|
|
164
|
+
env_root = os.environ.get("ENTROPY_MACHINES_ROOT")
|
|
165
|
+
if env_root and os.path.isdir(env_root):
|
|
166
|
+
return os.path.realpath(env_root)
|
|
167
|
+
start = os.getcwd()
|
|
168
|
+
root = _git_root(start)
|
|
169
|
+
if root:
|
|
170
|
+
return root
|
|
171
|
+
d = os.path.abspath(start)
|
|
172
|
+
while True:
|
|
173
|
+
if os.path.exists(os.path.join(d, CONFIG_FILENAME)):
|
|
174
|
+
return d
|
|
175
|
+
parent = os.path.dirname(d)
|
|
176
|
+
if parent == d:
|
|
177
|
+
return os.path.abspath(start)
|
|
178
|
+
d = parent
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _validate(cfg):
|
|
182
|
+
"""Keys with no safe default: named and refused, per docs/CONFIG.md rule 2."""
|
|
183
|
+
missing = []
|
|
184
|
+
if cfg["tracker"]["backend"] == "command" and not cfg["tracker"]["command"].get("bin"):
|
|
185
|
+
missing.append('tracker.command.bin (required because tracker.backend is "command")')
|
|
186
|
+
if cfg["unattended"]["enabled"] and not cfg["unattended"]["agent"].get("cmd"):
|
|
187
|
+
missing.append("unattended.agent.cmd (required because unattended.enabled is true)")
|
|
188
|
+
if missing:
|
|
189
|
+
lines = "\n".join(" - %s" % m for m in missing)
|
|
190
|
+
raise SystemExit(
|
|
191
|
+
"config.json is missing required configuration:\n%s\nSee docs/CONFIG.md." % lines
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_cached = None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_HARNESS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def load_config(cwd=None, force=False):
|
|
202
|
+
"""Load and validate config.json. Cached per process; `force` is for tests only."""
|
|
203
|
+
global _cached
|
|
204
|
+
if _cached is not None and not force:
|
|
205
|
+
return _cached
|
|
206
|
+
root = find_repo_root(cwd)
|
|
207
|
+
# config.json lives in the harness dir, not at the git root.
|
|
208
|
+
file_path = os.path.join(_HARNESS_DIR, CONFIG_FILENAME)
|
|
209
|
+
user = {}
|
|
210
|
+
if os.path.exists(file_path):
|
|
211
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
212
|
+
try:
|
|
213
|
+
user = json.load(f)
|
|
214
|
+
except json.JSONDecodeError as e:
|
|
215
|
+
raise SystemExit("%s: invalid JSON — %s" % (file_path, e))
|
|
216
|
+
merged = _deep_merge(DEFAULTS, user)
|
|
217
|
+
if not merged["project"].get("name"):
|
|
218
|
+
merged["project"]["name"] = os.path.basename(root)
|
|
219
|
+
_validate(merged)
|
|
220
|
+
merged["_root"] = root
|
|
221
|
+
merged["_path"] = file_path if os.path.exists(file_path) else None
|
|
222
|
+
_cached = merged
|
|
223
|
+
return merged
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_ERE_META = set(".^$+()|[]{}\\")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _escape_ere(s):
|
|
230
|
+
return "".join(("\\" + c) if c in _ERE_META else c for c in s)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _glob_to_ere(glob):
|
|
234
|
+
"""`**`/`*`/`?` glob -> POSIX ERE fragment (unanchored). For grep -E, not Python's `re`."""
|
|
235
|
+
out = []
|
|
236
|
+
i = 0
|
|
237
|
+
n = len(glob)
|
|
238
|
+
while i < n:
|
|
239
|
+
c = glob[i]
|
|
240
|
+
if c == "*":
|
|
241
|
+
if i + 1 < n and glob[i + 1] == "*":
|
|
242
|
+
out.append(".*")
|
|
243
|
+
i += 2
|
|
244
|
+
if i < n and glob[i] == "/":
|
|
245
|
+
i += 1
|
|
246
|
+
continue
|
|
247
|
+
out.append("[^/]*")
|
|
248
|
+
i += 1
|
|
249
|
+
continue
|
|
250
|
+
if c == "?":
|
|
251
|
+
out.append(".")
|
|
252
|
+
i += 1
|
|
253
|
+
continue
|
|
254
|
+
out.append(_escape_ere(c) if c in _ERE_META else c)
|
|
255
|
+
i += 1
|
|
256
|
+
return "".join(out)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def glob_to_regex(glob):
|
|
260
|
+
"""`**`/`*`/`?` glob -> compiled Python regex, anchored to the whole string."""
|
|
261
|
+
return _re.compile("^" + _glob_to_ere(glob) + "$")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def matches_any(patterns, p):
|
|
265
|
+
return any(glob_to_regex(pat).match(p) for pat in patterns)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _get_path(cfg, dotted):
|
|
269
|
+
v = cfg
|
|
270
|
+
for part in dotted.split("."):
|
|
271
|
+
if not isinstance(v, dict) or part not in v:
|
|
272
|
+
return None, False
|
|
273
|
+
v = v[part]
|
|
274
|
+
return v, True
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _cli_main(argv):
|
|
278
|
+
if not argv:
|
|
279
|
+
print("usage: config.py [root | get <dotted.key> | glob-re <dotted.key> | changelog-fragment-re]", file=sys.stderr)
|
|
280
|
+
return 2
|
|
281
|
+
cmd = argv[0]
|
|
282
|
+
if cmd == "root":
|
|
283
|
+
print(load_config()["_root"])
|
|
284
|
+
return 0
|
|
285
|
+
if cmd == "get":
|
|
286
|
+
if len(argv) < 2:
|
|
287
|
+
print("usage: config.py get <dotted.key>", file=sys.stderr)
|
|
288
|
+
return 2
|
|
289
|
+
value, found = _get_path(load_config(), argv[1])
|
|
290
|
+
if not found:
|
|
291
|
+
print("config.py: no such key %s" % argv[1], file=sys.stderr)
|
|
292
|
+
return 1
|
|
293
|
+
print(value if isinstance(value, str) else json.dumps(value))
|
|
294
|
+
return 0
|
|
295
|
+
if cmd == "glob-re":
|
|
296
|
+
# A list-of-globs key -> one POSIX ERE alternation, anchored at the
|
|
297
|
+
# start only (a prefix/contains test over a bare path, the shape
|
|
298
|
+
# lib/changelog-guard.sh's `grep -Eq` wants).
|
|
299
|
+
if len(argv) < 2:
|
|
300
|
+
print("usage: config.py glob-re <dotted.key>", file=sys.stderr)
|
|
301
|
+
return 2
|
|
302
|
+
value, found = _get_path(load_config(), argv[1])
|
|
303
|
+
if not found or not isinstance(value, list):
|
|
304
|
+
print("config.py: %s is not a list key" % argv[1], file=sys.stderr)
|
|
305
|
+
return 1
|
|
306
|
+
print("^(" + "|".join(_glob_to_ere(p) for p in value) + ")")
|
|
307
|
+
return 0
|
|
308
|
+
if cmd == "changelog-fragment-re":
|
|
309
|
+
cfg = load_config()
|
|
310
|
+
print("^" + _escape_ere(cfg["changelog"]["fragmentDir"]) + r"/[^_].*\.md$")
|
|
311
|
+
return 0
|
|
312
|
+
print("usage: config.py [root | get <dotted.key> | glob-re <dotted.key> | changelog-fragment-re]", file=sys.stderr)
|
|
313
|
+
return 2
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == "__main__":
|
|
317
|
+
sys.exit(_cli_main(sys.argv[1:]))
|