create-religion 0.4.0 → 1.0.0
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 +17 -1
- package/dist/bin/religion.js +89 -48
- package/dist/lib/args.js +108 -0
- package/dist/lib/dashboard.js +30 -7
- package/dist/lib/doctor.js +5 -1
- package/dist/lib/install.js +222 -12
- package/dist/lib/merge.js +23 -0
- package/package.json +2 -2
- package/template/.agents/skills/scout/SKILL.md +82 -0
- package/template/.agents/skills/setup/SKILL.md +117 -12
- package/template/.agents/skills/setup/reference/drafted-plans.md +133 -0
- package/template/.agents/skills/status/SKILL.md +1 -1
- package/template/.claude/hooks/write-handoff.mjs +11 -2
- package/template/.claude/skills/scout/SKILL.md +82 -0
- package/template/.claude/skills/setup/SKILL.md +117 -12
- package/template/.claude/skills/setup/reference/drafted-plans.md +133 -0
- package/template/.claude/skills/status/SKILL.md +1 -1
- package/template/AGENTS.md +1 -0
- package/template/religion/.state/settings-template.json +0 -9
- package/template/.claude/hooks/guard-tier-one.mjs +0 -95
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ every diff before it lands.
|
|
|
12
12
|
|
|
13
13
|
## What it installs
|
|
14
14
|
|
|
15
|
-
- **
|
|
15
|
+
- **25 skills** your AI tool can run: `feature`, `implement`, `check`, `audit`, `complete`,
|
|
16
16
|
and the rest of the loop around them.
|
|
17
17
|
- **A state directory** holding your plans, the active spec, the findings ledger, and the
|
|
18
18
|
archive of everything finished.
|
|
@@ -42,8 +42,24 @@ Installing the package globally shortens these to `religion status` and the rest
|
|
|
42
42
|
`update` will not overwrite a file you have edited. It tells you what conflicts and backs
|
|
43
43
|
up the original if you choose to replace it.
|
|
44
44
|
|
|
45
|
+
### Exit codes
|
|
46
|
+
|
|
47
|
+
The same for every command, so a script can rely on them:
|
|
48
|
+
|
|
49
|
+
| Code | Meaning |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `0` | the command did what it was asked |
|
|
52
|
+
| `1` | it ran and reports failure: no project found, a failing `doctor` check, conflicts, declined merges or linked files left by `install` or `update`, a project installed by a newer version, or an unexpected error |
|
|
53
|
+
| `2` | usage error: nothing was written |
|
|
54
|
+
|
|
45
55
|
## Documentation
|
|
46
56
|
|
|
57
|
+
- [Getting started](https://github.com/foo-stack/religion/blob/main/docs/getting-started.md)
|
|
58
|
+
- [What the loop actually feels like](https://github.com/foo-stack/religion/blob/main/docs/the-loop.md)
|
|
59
|
+
- [What to do when doctor complains](https://github.com/foo-stack/religion/blob/main/docs/doctor.md)
|
|
60
|
+
- [Updating a project](https://github.com/foo-stack/religion/blob/main/docs/upgrading.md)
|
|
61
|
+
- [Stability](https://github.com/foo-stack/religion/blob/main/docs/stability.md)
|
|
62
|
+
|
|
47
63
|
[github.com/foo-stack/religion](https://github.com/foo-stack/religion)
|
|
48
64
|
|
|
49
65
|
MIT.
|
package/dist/bin/religion.js
CHANGED
|
@@ -11,10 +11,11 @@ import fsSync from "node:fs";
|
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import readline from "node:readline/promises";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { parseArgs } from "../lib/args.js";
|
|
14
15
|
import { startDashboard } from "../lib/dashboard.js";
|
|
15
|
-
import { renderDoctor, runDoctor } from "../lib/doctor.js";
|
|
16
|
-
import { ADAPTERS, applyInstall, planInstall, readManifest, wireHooks, writeManifest } from "../lib/install.js";
|
|
17
|
-
import { findProjectRoot
|
|
16
|
+
import { doctorReport, renderDoctor, runDoctor } from "../lib/doctor.js";
|
|
17
|
+
import { ADAPTERS, applyInstall, BACKUPS, manifestRefusal, planInstall, readManifest, wireHooks, writeManifest } from "../lib/install.js";
|
|
18
|
+
import { findProjectRoot } from "../lib/paths.js";
|
|
18
19
|
import { readProjectState } from "../lib/state.js";
|
|
19
20
|
import { computeStatus, renderStatus } from "../lib/status.js";
|
|
20
21
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -43,7 +44,14 @@ function findPackageRoot(from) {
|
|
|
43
44
|
const packageRoot = findPackageRoot(here);
|
|
44
45
|
const templateRoot = path.join(packageRoot, "template");
|
|
45
46
|
async function main(argv) {
|
|
46
|
-
const
|
|
47
|
+
const parsed = parseArgs(argv, { cwd: process.cwd(), isDirectory });
|
|
48
|
+
if (!parsed.ok) {
|
|
49
|
+
console.error(parsed.error);
|
|
50
|
+
console.error("Run `religion help` for usage.");
|
|
51
|
+
process.exitCode = 2;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const options = parsed.options;
|
|
47
55
|
if (options.command === "help")
|
|
48
56
|
return printHelp();
|
|
49
57
|
if (options.command === "install" || options.command === "update")
|
|
@@ -60,9 +68,9 @@ async function main(argv) {
|
|
|
60
68
|
return;
|
|
61
69
|
}
|
|
62
70
|
if (options.command === "doctor") {
|
|
63
|
-
const
|
|
64
|
-
console.log(options.json ? JSON.stringify(
|
|
65
|
-
if (
|
|
71
|
+
const report = doctorReport(await runDoctor(root));
|
|
72
|
+
console.log(options.json ? JSON.stringify(report, null, 2) : renderDoctor(report.checks));
|
|
73
|
+
if (!report.healthy)
|
|
66
74
|
process.exitCode = 1;
|
|
67
75
|
return;
|
|
68
76
|
}
|
|
@@ -77,6 +85,13 @@ async function runInstall(options) {
|
|
|
77
85
|
const target = path.resolve(options.target);
|
|
78
86
|
const updating = options.command === "update";
|
|
79
87
|
const previous = await readManifest(target);
|
|
88
|
+
const packageVersion = await version();
|
|
89
|
+
const refusal = manifestRefusal(previous, packageVersion);
|
|
90
|
+
if (refusal) {
|
|
91
|
+
console.error(refusal);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
80
95
|
if (updating && !previous) {
|
|
81
96
|
console.log("No manifest found. Files matching the template will be adopted; others are conflicts.");
|
|
82
97
|
}
|
|
@@ -88,20 +103,35 @@ async function runInstall(options) {
|
|
|
88
103
|
conflict: plan.filter((p) => p.action === "conflict").length,
|
|
89
104
|
kept: plan.filter((p) => p.action === "seed-skip").length,
|
|
90
105
|
merge: plan.filter((p) => p.action === "merge").length,
|
|
91
|
-
remerge: plan.filter((p) => p.action === "remerge").length
|
|
106
|
+
remerge: plan.filter((p) => p.action === "remerge").length,
|
|
107
|
+
rebuild: plan.filter((p) => p.action === "rebuild").length,
|
|
108
|
+
remove: plan.filter((p) => p.action === "remove").length,
|
|
109
|
+
release: plan.filter((p) => p.action === "release").length,
|
|
110
|
+
linked: plan.filter((p) => p.action === "linked").length
|
|
92
111
|
};
|
|
93
112
|
console.log(`\nAdapters: ${adapters.map((a) => ADAPTERS[a].label).join(", ")}`);
|
|
94
113
|
console.log(` create ${counts.create}`);
|
|
95
114
|
console.log(` update ${counts.update}`);
|
|
96
115
|
console.log(` keep ${counts.kept} (your files, never overwritten)`);
|
|
97
116
|
console.log(` conflict ${counts.conflict}`);
|
|
117
|
+
for (const entry of plan.filter((p) => p.action === "conflict"))
|
|
118
|
+
console.log(` ${entry.relative}`);
|
|
98
119
|
if (counts.merge > 0)
|
|
99
120
|
console.log(` merge ${counts.merge} (your file, awaiting a decision)`);
|
|
100
121
|
if (counts.remerge > 0)
|
|
101
122
|
console.log(` merged ${counts.remerge} (your sections kept)`);
|
|
102
|
-
|
|
123
|
+
if (counts.rebuild > 0)
|
|
124
|
+
console.log(` rebuilt ${counts.rebuild} (your sections kept, original backed up)`);
|
|
125
|
+
if (counts.remove > 0)
|
|
126
|
+
console.log(` remove ${counts.remove} (no longer shipped)`);
|
|
127
|
+
if (counts.release > 0)
|
|
128
|
+
console.log(` release ${counts.release} (no longer shipped, but edited by you, so left alone)`);
|
|
129
|
+
for (const entry of plan.filter((p) => p.action === "release"))
|
|
130
|
+
console.log(` ${entry.relative}`);
|
|
131
|
+
if (counts.linked > 0)
|
|
132
|
+
console.log(` linked ${counts.linked} (reached through a symbolic link, never written)`);
|
|
133
|
+
for (const entry of plan.filter((p) => p.action === "linked"))
|
|
103
134
|
console.log(` ${entry.relative}`);
|
|
104
|
-
}
|
|
105
135
|
const mergeable = plan.filter((p) => p.action === "merge").map((p) => p.relative);
|
|
106
136
|
let merge = false;
|
|
107
137
|
if (mergeable.length > 0) {
|
|
@@ -110,7 +140,7 @@ async function runInstall(options) {
|
|
|
110
140
|
console.log(` ${relative}`);
|
|
111
141
|
console.log("\nReligion can append its own sections to them inside markers, keeping everything" +
|
|
112
142
|
"\nyou wrote exactly where it is. Later updates then replace only what is between" +
|
|
113
|
-
"\nthose markers. The originals are backed up
|
|
143
|
+
"\nthose markers. The originals are backed up first.");
|
|
114
144
|
merge = await confirmMerge(options.yes);
|
|
115
145
|
}
|
|
116
146
|
if (options.dryRun) {
|
|
@@ -121,14 +151,39 @@ async function runInstall(options) {
|
|
|
121
151
|
throw new Error(`No template files found under ${templateRoot}. Nothing would be installed, which is never correct.`);
|
|
122
152
|
}
|
|
123
153
|
const result = await applyInstall(templateRoot, target, plan, { force: options.force, merge });
|
|
124
|
-
await writeManifest(target,
|
|
154
|
+
await writeManifest(target, packageVersion, adapters, templateRoot, previous, [
|
|
155
|
+
...result.conflicts,
|
|
156
|
+
...result.declined,
|
|
157
|
+
...result.linked
|
|
158
|
+
]);
|
|
125
159
|
console.log(`\nWrote ${result.written.length} file(s).`);
|
|
126
160
|
if (result.merged.length > 0) {
|
|
127
161
|
console.log(`Merged ${result.merged.length} entry file(s), keeping what you wrote.`);
|
|
128
162
|
}
|
|
129
|
-
if (result.
|
|
130
|
-
console.log(`
|
|
163
|
+
if (result.rebuilt.length > 0) {
|
|
164
|
+
console.log(`Rebuilt ${result.rebuilt.length} entry file(s) around the sections you wrote. The originals are in ` +
|
|
165
|
+
`${BACKUPS}.`);
|
|
166
|
+
}
|
|
167
|
+
if (result.removed.length > 0)
|
|
168
|
+
console.log(`Removed ${result.removed.length} file(s) this version no longer ships.`);
|
|
169
|
+
if (result.released.length > 0) {
|
|
170
|
+
console.log(`Left ${result.released.length} file(s) this version no longer ships, because you edited them. They are yours now.`);
|
|
171
|
+
}
|
|
172
|
+
if (result.backups.length > 0) {
|
|
173
|
+
console.log(`Backed up ${result.backups.length} file(s) to ${BACKUPS} before changing them.`);
|
|
174
|
+
}
|
|
175
|
+
if (result.linked.length > 0) {
|
|
176
|
+
process.exitCode = 1;
|
|
177
|
+
console.log(`\n${result.linked.length} file(s) are reached through a symbolic link and were left alone, since writing` +
|
|
178
|
+
`\nthem would write wherever the link points. Replace the links with real files and run again.`);
|
|
179
|
+
}
|
|
180
|
+
if (result.declined.length > 0) {
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
console.log(`\n${result.declined.length} file(s) of yours were not merged, so Religion's instructions are not in them.` +
|
|
183
|
+
`\nRun update again and accept the merge, or pass --yes, to add them.`);
|
|
184
|
+
}
|
|
131
185
|
if (result.conflicts.length > 0) {
|
|
186
|
+
process.exitCode = 1;
|
|
132
187
|
console.log(`\n${result.conflicts.length} file(s) were changed locally and left alone.` +
|
|
133
188
|
`\nReview them, then re-run with --force to replace them (originals are backed up).`);
|
|
134
189
|
}
|
|
@@ -137,9 +192,14 @@ async function runInstall(options) {
|
|
|
137
192
|
if (wired === "written") {
|
|
138
193
|
console.log("Wired the enforcement hooks into .claude/settings.json.");
|
|
139
194
|
}
|
|
195
|
+
else if (wired === "linked") {
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
console.log("\n.claude/settings.json is a symbolic link and was left alone." +
|
|
198
|
+
"\nIf a hook is missing, compare the file it points to with a fresh install's .claude/settings.json.");
|
|
199
|
+
}
|
|
140
200
|
else if (wired === "exists") {
|
|
141
201
|
console.log("\n.claude/settings.json already exists and was left alone." +
|
|
142
|
-
|
|
202
|
+
"\nIf a hook is missing, compare it with a fresh install's .claude/settings.json.");
|
|
143
203
|
}
|
|
144
204
|
}
|
|
145
205
|
if (!updating) {
|
|
@@ -187,39 +247,13 @@ async function version() {
|
|
|
187
247
|
return "0.0.0";
|
|
188
248
|
}
|
|
189
249
|
}
|
|
190
|
-
function
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
yes: false,
|
|
198
|
-
target: process.cwd()
|
|
199
|
-
};
|
|
200
|
-
const commands = new Set(["install", "update", "status", "doctor", "dashboard", "help"]);
|
|
201
|
-
const adapters = [];
|
|
202
|
-
for (const arg of argv) {
|
|
203
|
-
if (commands.has(arg))
|
|
204
|
-
options.command = arg;
|
|
205
|
-
else if (arg === "--json")
|
|
206
|
-
options.json = true;
|
|
207
|
-
else if (arg === "--dry-run")
|
|
208
|
-
options.dryRun = true;
|
|
209
|
-
else if (arg === "--force")
|
|
210
|
-
options.force = true;
|
|
211
|
-
else if (arg === "--yes" || arg === "-y")
|
|
212
|
-
options.yes = true;
|
|
213
|
-
else if (arg === "--help" || arg === "-h")
|
|
214
|
-
options.command = "help";
|
|
215
|
-
else if (arg.startsWith("--") && arg.slice(2) in ADAPTERS)
|
|
216
|
-
adapters.push(arg.slice(2));
|
|
217
|
-
else if (!arg.startsWith("-"))
|
|
218
|
-
options.target = arg;
|
|
219
|
-
}
|
|
220
|
-
if (adapters.length > 0)
|
|
221
|
-
options.adapters = adapters;
|
|
222
|
-
return options;
|
|
250
|
+
function isDirectory(candidate) {
|
|
251
|
+
try {
|
|
252
|
+
return fsSync.statSync(path.resolve(candidate)).isDirectory();
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
223
257
|
}
|
|
224
258
|
function printHelp() {
|
|
225
259
|
console.log(`religion - a file-backed, spec-driven AI development workflow
|
|
@@ -239,6 +273,13 @@ Options
|
|
|
239
273
|
--force replace locally-changed managed files, backing them up first
|
|
240
274
|
--json machine-readable output for status and doctor
|
|
241
275
|
--yes no prompts
|
|
276
|
+
|
|
277
|
+
Exit codes
|
|
278
|
+
0 the command did what it was asked
|
|
279
|
+
1 it ran and reports failure: no project found, a failing doctor check,
|
|
280
|
+
conflicts, declined merges or linked files left by install or update,
|
|
281
|
+
a project installed by a newer version, or an unexpected error
|
|
282
|
+
2 usage error: nothing was written
|
|
242
283
|
`);
|
|
243
284
|
}
|
|
244
285
|
main(process.argv.slice(2)).catch((error) => {
|
package/dist/lib/args.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command-line grammar.
|
|
3
|
+
*
|
|
4
|
+
* Pure, so every rule is testable: the working directory and the one filesystem question it
|
|
5
|
+
* needs, whether a bare word names an existing directory, are passed in. Anything it does
|
|
6
|
+
* not understand is refused rather than guessed at, because a guess here is an install into
|
|
7
|
+
* the wrong place.
|
|
8
|
+
*/
|
|
9
|
+
import { ADAPTERS } from "./install.js";
|
|
10
|
+
const COMMANDS = ["install", "update", "status", "doctor", "dashboard", "help"];
|
|
11
|
+
const WRITES = new Set(["install", "update"]);
|
|
12
|
+
const REPORTS = new Set(["status", "doctor"]);
|
|
13
|
+
const EVERY = new Set(COMMANDS);
|
|
14
|
+
/** Every option, the commands it applies to, and what it does. Parsing dispatches on this alone. */
|
|
15
|
+
const FLAGS = {
|
|
16
|
+
"--help": { commands: EVERY, apply: (options) => void (options.command = "help") },
|
|
17
|
+
"-h": { commands: EVERY, apply: (options) => void (options.command = "help") },
|
|
18
|
+
"--dry-run": { commands: WRITES, apply: (options) => void (options.dryRun = true) },
|
|
19
|
+
"--force": { commands: WRITES, apply: (options) => void (options.force = true) },
|
|
20
|
+
"--yes": { commands: WRITES, apply: (options) => void (options.yes = true) },
|
|
21
|
+
"-y": { commands: WRITES, apply: (options) => void (options.yes = true) },
|
|
22
|
+
"--json": { commands: REPORTS, apply: (options) => void (options.json = true) },
|
|
23
|
+
...Object.fromEntries(Object.keys(ADAPTERS).map((adapter) => [
|
|
24
|
+
`--${adapter}`,
|
|
25
|
+
{
|
|
26
|
+
commands: WRITES,
|
|
27
|
+
apply: (options) => {
|
|
28
|
+
const chosen = options.adapters ?? [];
|
|
29
|
+
if (!chosen.includes(adapter))
|
|
30
|
+
options.adapters = [...chosen, adapter];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
]))
|
|
34
|
+
};
|
|
35
|
+
/** The options each command accepts, read from the same table parsing uses. */
|
|
36
|
+
export const GRAMMAR = grammar();
|
|
37
|
+
function grammar() {
|
|
38
|
+
const table = {};
|
|
39
|
+
for (const command of COMMANDS)
|
|
40
|
+
table[command] = Object.keys(FLAGS).filter((flag) => FLAGS[flag].commands.has(command));
|
|
41
|
+
return table;
|
|
42
|
+
}
|
|
43
|
+
export function parseArgs(argv, context) {
|
|
44
|
+
const options = {
|
|
45
|
+
command: "install",
|
|
46
|
+
adapters: null,
|
|
47
|
+
json: false,
|
|
48
|
+
dryRun: false,
|
|
49
|
+
force: false,
|
|
50
|
+
yes: false,
|
|
51
|
+
target: context.cwd
|
|
52
|
+
};
|
|
53
|
+
if (argv.some((arg) => arg === "help" || arg === "--help" || arg === "-h")) {
|
|
54
|
+
return { ok: true, options: { ...options, command: "help" } };
|
|
55
|
+
}
|
|
56
|
+
const [first, ...rest] = argv.filter((arg) => !arg.startsWith("-"));
|
|
57
|
+
let extra = [];
|
|
58
|
+
if (first !== undefined && isCommand(first)) {
|
|
59
|
+
options.command = first;
|
|
60
|
+
if (rest[0] !== undefined)
|
|
61
|
+
options.target = rest[0];
|
|
62
|
+
extra = rest.slice(1);
|
|
63
|
+
}
|
|
64
|
+
else if (first !== undefined) {
|
|
65
|
+
if (!context.isDirectory(first))
|
|
66
|
+
return fail(unknownCommand(first));
|
|
67
|
+
options.target = first;
|
|
68
|
+
extra = rest;
|
|
69
|
+
}
|
|
70
|
+
if (extra.length > 0) {
|
|
71
|
+
return fail(`Unexpected argument '${extra[0]}'. ${options.command} takes at most one directory.`);
|
|
72
|
+
}
|
|
73
|
+
for (const flag of argv.filter((arg) => arg.startsWith("-"))) {
|
|
74
|
+
const known = Object.hasOwn(FLAGS, flag) ? FLAGS[flag] : undefined;
|
|
75
|
+
if (!known)
|
|
76
|
+
return fail(`Unknown option '${flag}'.`);
|
|
77
|
+
if (!known.commands.has(options.command))
|
|
78
|
+
return fail(`Option '${flag}' does not apply to ${options.command}.`);
|
|
79
|
+
known.apply(options);
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, options };
|
|
82
|
+
}
|
|
83
|
+
function isCommand(word) {
|
|
84
|
+
return COMMANDS.includes(word);
|
|
85
|
+
}
|
|
86
|
+
function fail(error) {
|
|
87
|
+
return { ok: false, error };
|
|
88
|
+
}
|
|
89
|
+
function unknownCommand(word) {
|
|
90
|
+
const closest = COMMANDS.map((command) => ({ command, distance: editDistance(word, command) }))
|
|
91
|
+
.filter((candidate) => candidate.distance <= 2)
|
|
92
|
+
.sort((a, b) => a.distance - b.distance)[0];
|
|
93
|
+
return closest
|
|
94
|
+
? `Unknown command '${word}'. Did you mean '${closest.command}'?`
|
|
95
|
+
: `Unknown command '${word}', and no directory of that name exists.`;
|
|
96
|
+
}
|
|
97
|
+
function editDistance(a, b) {
|
|
98
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
99
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
100
|
+
const current = [i];
|
|
101
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
102
|
+
const substitution = previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
|
|
103
|
+
current.push(Math.min(previous[j] + 1, current[j - 1] + 1, substitution));
|
|
104
|
+
}
|
|
105
|
+
previous = current;
|
|
106
|
+
}
|
|
107
|
+
return previous[b.length];
|
|
108
|
+
}
|
package/dist/lib/dashboard.js
CHANGED
|
@@ -10,27 +10,50 @@ import { readIfPresent, statePath } from "./paths.js";
|
|
|
10
10
|
import { readProjectState } from "./state.js";
|
|
11
11
|
import { computeStatus } from "./status.js";
|
|
12
12
|
export async function startDashboard(root) {
|
|
13
|
+
let port = 0;
|
|
13
14
|
const server = http.createServer((request, response) => {
|
|
14
|
-
void handle(root, request, response);
|
|
15
|
+
void handle(root, port, request, response);
|
|
15
16
|
});
|
|
16
17
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
17
18
|
const address = server.address();
|
|
18
|
-
|
|
19
|
+
port = typeof address === "object" && address ? address.port : 0;
|
|
19
20
|
return {
|
|
20
21
|
url: `http://127.0.0.1:${port}`,
|
|
22
|
+
address: typeof address === "object" && address ? address.address : "",
|
|
21
23
|
close: () => new Promise((resolve) => server.close(() => resolve()))
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Whether a request was addressed to this server by name, not only by socket.
|
|
28
|
+
*
|
|
29
|
+
* Binding to loopback keeps other machines out, but not a page elsewhere whose domain has
|
|
30
|
+
* been rebound to 127.0.0.1: the browser connects here and sends that domain as the Host.
|
|
31
|
+
* Answering only our own name refuses it.
|
|
32
|
+
*/
|
|
33
|
+
export function isOwnHost(host, port) {
|
|
34
|
+
const name = host?.toLowerCase();
|
|
35
|
+
return name === `127.0.0.1:${port}` || name === `localhost:${port}`;
|
|
36
|
+
}
|
|
37
|
+
/** What every response allows a browser to load: inline script and style, and its own server. */
|
|
38
|
+
export const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'";
|
|
39
|
+
async function handle(root, port, request, response) {
|
|
40
|
+
if (!isOwnHost(request.headers.host, port)) {
|
|
41
|
+
send(response, 403, "text/plain; charset=utf-8", "Forbidden: this dashboard only answers requests addressed to itself.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
25
44
|
if (request.url === "/state.json") {
|
|
26
45
|
const state = await readProjectState(root);
|
|
27
46
|
const activity = await readActivity(root);
|
|
28
|
-
|
|
29
|
-
response
|
|
47
|
+
const body = JSON.stringify({ status: computeStatus(state), plan: state.plan, findings: state.findings, activity });
|
|
48
|
+
send(response, 200, "application/json", body);
|
|
30
49
|
return;
|
|
31
50
|
}
|
|
32
|
-
response
|
|
33
|
-
|
|
51
|
+
send(response, 200, "text/html; charset=utf-8", PAGE);
|
|
52
|
+
}
|
|
53
|
+
/** The only way this server answers, so no response can leave out the policy. */
|
|
54
|
+
function send(response, status, type, body) {
|
|
55
|
+
response.writeHead(status, { "content-type": type, "content-security-policy": CONTENT_SECURITY_POLICY });
|
|
56
|
+
response.end(body);
|
|
34
57
|
}
|
|
35
58
|
async function readActivity(root) {
|
|
36
59
|
const raw = await readIfPresent(statePath(root, ".state", "run.json"));
|
package/dist/lib/doctor.js
CHANGED
|
@@ -26,7 +26,8 @@ const REQUIRED = [
|
|
|
26
26
|
"history/spikes",
|
|
27
27
|
"reference"
|
|
28
28
|
];
|
|
29
|
-
|
|
29
|
+
/** The values each enumerated setting accepts. Other settings are not checked. */
|
|
30
|
+
export const ALLOWED = {
|
|
30
31
|
"workflow.stepReview": ["every", "item"],
|
|
31
32
|
"git.mode": ["trunk", "branch-per-item", "pull-request"],
|
|
32
33
|
"git.checkpoints": ["none", "every-step", "squash"],
|
|
@@ -147,6 +148,9 @@ function invalidSettings(config) {
|
|
|
147
148
|
}
|
|
148
149
|
return problems;
|
|
149
150
|
}
|
|
151
|
+
export function doctorReport(checks) {
|
|
152
|
+
return { schemaVersion: 1, healthy: checks.every((check) => check.ok), checks: [...checks] };
|
|
153
|
+
}
|
|
150
154
|
export function renderDoctor(results) {
|
|
151
155
|
const failures = results.filter((r) => !r.ok);
|
|
152
156
|
const order = { work: 0, completion: 1, nothing: 2 };
|