etymd 0.2.2 → 0.3.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/CHANGELOG.md +42 -2
- package/README.md +145 -37
- package/dist/{approve-VC4V3OPW.js → approve-JEOEC2MV.js} +6 -4
- package/dist/audit-GYZ5YZ7U.js +12 -0
- package/dist/{brief-TZ45MNHD.js → brief-ZK7TXAC4.js} +6 -4
- package/dist/{chunk-MWIK6ML3.js → chunk-3NYDVB4Z.js} +123 -25
- package/dist/chunk-CRJHIXJH.js +5 -0
- package/dist/chunk-EF6BPBG6.js +22 -0
- package/dist/chunk-LT67FU2Y.js +59 -0
- package/dist/{chunk-BH37LELV.js → chunk-MKYPLNLH.js} +8 -5
- package/dist/{chunk-EUIHYD6F.js → chunk-MMPV67FW.js} +2 -2
- package/dist/{chunk-LVLZLCND.js → chunk-NFENQ3IK.js} +1 -1
- package/dist/{chunk-ANCCGYSH.js → chunk-NMZ3RHWW.js} +31 -59
- package/dist/{chunk-FZSIOCOZ.js → chunk-R74SJ7HS.js} +1 -1
- package/dist/{chunk-LBRNZZZF.js → chunk-TADKOW6P.js} +1 -99
- package/dist/{chunk-KMAJIQHC.js → chunk-V3DPLOOM.js} +7 -5
- package/dist/{chunk-KBF3SW3N.js → chunk-WWBF4Y7K.js} +1 -1
- package/dist/{chunk-IW6K5HLV.js → chunk-XTAX4YNT.js} +2 -2
- package/dist/chunk-YLNQQZ5F.js +102 -0
- package/dist/cli.js +50 -16
- package/dist/config-MX7OYI7F.js +4 -0
- package/dist/{context-GYCLR3TG.js → context-4DGXO7JR.js} +5 -3
- package/dist/doctor-C2RTL2QV.js +19 -0
- package/dist/{fleet-N3G5ZIGR.js → fleet-YZGR4UC2.js} +243 -10
- package/dist/gates-ILE4PXDA.js +272 -0
- package/dist/generate-IYCQETQB.js +4 -0
- package/dist/index.d.ts +113 -33
- package/dist/index.js +713 -257
- package/dist/{init-4CQOLFQG.js → init-OVLO4OBZ.js} +8 -5
- package/dist/ledger-TMIV45AA.js +5 -0
- package/dist/{scan-CYOOGJFG.js → scan-RAJUFTQR.js} +6 -4
- package/dist/scan-ZYZ7XDSJ.js +5 -0
- package/dist/screen-U7JMWZPS.js +134 -0
- package/package.json +2 -2
- package/dist/audit-LZJRJS5V.js +0 -9
- package/dist/doctor-6JDAHGDT.js +0 -16
- package/dist/gates-G5BCMRRV.js +0 -71
- package/dist/ledger-RHQJH5EA.js +0 -4
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from './chunk-XTAX4YNT.js';
|
|
3
|
+
import './chunk-V3DPLOOM.js';
|
|
4
|
+
import './chunk-LT67FU2Y.js';
|
|
5
|
+
import './chunk-WWBF4Y7K.js';
|
|
6
|
+
import './chunk-NMZ3RHWW.js';
|
|
7
|
+
import './chunk-TADKOW6P.js';
|
|
8
|
+
import './chunk-R74SJ7HS.js';
|
|
9
|
+
import './chunk-MKYPLNLH.js';
|
|
10
|
+
import './chunk-NFENQ3IK.js';
|
|
11
|
+
import './chunk-CRJHIXJH.js';
|
|
12
|
+
import './chunk-YLNQQZ5F.js';
|
|
13
|
+
|
|
14
|
+
// src/commands/doctor.ts
|
|
15
|
+
async function run2(opts) {
|
|
16
|
+
await run({ cwd: opts.cwd, json: opts.json, truth: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { run2 as run };
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { dismiss, accept } from './chunk-
|
|
3
|
-
import { parseFailOnTier, meetsFailOn, runAudit } from './chunk-
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import './chunk-
|
|
7
|
-
import './chunk-
|
|
8
|
-
import { ETYMD_DIR } from './chunk-
|
|
9
|
-
import {
|
|
2
|
+
import { dismiss, accept } from './chunk-MMPV67FW.js';
|
|
3
|
+
import { parseFailOnTier, meetsFailOn, runAudit } from './chunk-V3DPLOOM.js';
|
|
4
|
+
import './chunk-LT67FU2Y.js';
|
|
5
|
+
import { readLedger } from './chunk-WWBF4Y7K.js';
|
|
6
|
+
import { DEFAULT_CONFIG } from './chunk-NMZ3RHWW.js';
|
|
7
|
+
import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph } from './chunk-TADKOW6P.js';
|
|
8
|
+
import { ETYMD_DIR } from './chunk-R74SJ7HS.js';
|
|
9
|
+
import { scanProject } from './chunk-MKYPLNLH.js';
|
|
10
|
+
import './chunk-NFENQ3IK.js';
|
|
11
|
+
import './chunk-CRJHIXJH.js';
|
|
12
|
+
import { readText, pathExists, git, isDirectory } from './chunk-YLNQQZ5F.js';
|
|
10
13
|
import { promises } from 'node:fs';
|
|
11
14
|
import path from 'node:path';
|
|
15
|
+
import { select, isCancel, cancel, text, confirm } from '@clack/prompts';
|
|
12
16
|
import os from 'node:os';
|
|
13
17
|
import { execFile } from 'node:child_process';
|
|
14
18
|
import { promisify } from 'node:util';
|
|
15
19
|
|
|
20
|
+
var FLEET_TRUST_VALUES = ["public-repo", "public-bound", "private"];
|
|
21
|
+
function isFleetTrust(value) {
|
|
22
|
+
return typeof value === "string" && FLEET_TRUST_VALUES.includes(value);
|
|
23
|
+
}
|
|
16
24
|
function expandTilde(p) {
|
|
17
25
|
if (p === "~") return os.homedir();
|
|
18
26
|
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
|
@@ -108,7 +116,10 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
|
108
116
|
private: rec.private === true,
|
|
109
117
|
path: asString(rec.path),
|
|
110
118
|
upstream: asString(rec.upstream),
|
|
111
|
-
|
|
119
|
+
// An unknown value is NOT coerced to a default: trust gates content screening, so a typo
|
|
120
|
+
// must surface as "undeclared" (a finding) rather than quietly picking an answer.
|
|
121
|
+
trust: isFleetTrust(rec.trust) ? rec.trust : void 0,
|
|
122
|
+
trustRaw: asString(rec.trust),
|
|
112
123
|
staleAfterDays: typeof rec.staleAfterDays === "number" ? rec.staleAfterDays : void 0,
|
|
113
124
|
stateBudget: typeof rec.stateBudget === "number" ? rec.stateBudget : void 0,
|
|
114
125
|
contract,
|
|
@@ -225,6 +236,24 @@ async function loadFleetManifest(manifestPath) {
|
|
|
225
236
|
manifest.machineProfile = local.data.machineProfile === "corp" || local.data.machineProfile === "personal" ? local.data.machineProfile : void 0;
|
|
226
237
|
const rootRaw = asString(local.data.root) ?? asString(parsed.root);
|
|
227
238
|
if (rootRaw) manifest.root = path.resolve(dir, expandTilde(rootRaw));
|
|
239
|
+
const orientationRec = asRecord(parsed.orientation);
|
|
240
|
+
if (orientationRec) {
|
|
241
|
+
const orientationRoot = asString(orientationRec.root);
|
|
242
|
+
if (orientationRoot) manifest.orientation = { root: orientationRoot };
|
|
243
|
+
else {
|
|
244
|
+
manifest.problems.push({
|
|
245
|
+
kind: "bad-shape",
|
|
246
|
+
file: manifestFile,
|
|
247
|
+
detail: "`orientation` declares no `root` name \u2014 the fleet's orientation cannot be resolved"
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
} else if (parsed.orientation !== void 0) {
|
|
251
|
+
manifest.problems.push({
|
|
252
|
+
kind: "bad-shape",
|
|
253
|
+
file: manifestFile,
|
|
254
|
+
detail: '`orientation` must be an object (`{ "root": "<name>" }`) \u2014 ignored'
|
|
255
|
+
});
|
|
256
|
+
}
|
|
228
257
|
loadRegistryEntries(parsed.projects, manifest, manifestFile);
|
|
229
258
|
} else if (Array.isArray(parsed.sources)) {
|
|
230
259
|
manifest.shape = "corpus";
|
|
@@ -367,6 +396,19 @@ async function checkManifest(manifest) {
|
|
|
367
396
|
);
|
|
368
397
|
}
|
|
369
398
|
seen.add(entry.name);
|
|
399
|
+
if (entry.profile !== "corp" && !entry.private && !entry.trust) {
|
|
400
|
+
const bad = entry.trustRaw;
|
|
401
|
+
findings.push(
|
|
402
|
+
finding(
|
|
403
|
+
`${FLEET_LENS}/${bad ? "unknown-trust" : "undeclared-trust"}:${entry.name}`,
|
|
404
|
+
"risk",
|
|
405
|
+
bad ? `\`${entry.name}\` declares unknown trust \`${bad}\`` : `\`${entry.name}\` declares no \`trust\``,
|
|
406
|
+
[`${manifestFile}: ${entry.name}${bad ? `.trust = ${bad}` : " has no trust field"}`],
|
|
407
|
+
"Trust decides whether content screening applies. An unanswered entry is not a private one \u2014 publishing exposes ALL history, so a repo that went public without ever answering is exactly what a default would hide.",
|
|
408
|
+
`Declare one of ${FLEET_TRUST_VALUES.map((t) => `\`${t}\``).join(", ")}.`
|
|
409
|
+
)
|
|
410
|
+
);
|
|
411
|
+
}
|
|
370
412
|
if (entry.private && entry.path) {
|
|
371
413
|
findings.push(
|
|
372
414
|
finding(
|
|
@@ -421,6 +463,18 @@ async function checkManifest(manifest) {
|
|
|
421
463
|
}
|
|
422
464
|
}
|
|
423
465
|
}
|
|
466
|
+
if (manifest.orientation && !seen.has(manifest.orientation.root)) {
|
|
467
|
+
findings.push(
|
|
468
|
+
finding(
|
|
469
|
+
`${FLEET_LENS}/dangling-orientation:${manifest.orientation.root}`,
|
|
470
|
+
"gap",
|
|
471
|
+
`\`orientation.root\` names \`${manifest.orientation.root}\`, which is not a registered name`,
|
|
472
|
+
[`${manifestFile}: orientation.root = ${manifest.orientation.root}`],
|
|
473
|
+
"Every entry is guided by the orientation root implicitly \u2014 if it resolves to nothing, the whole fleet's orientation is a claim nothing can follow.",
|
|
474
|
+
"Point it at a registered name, or drop the block if this fleet has no orientation root."
|
|
475
|
+
)
|
|
476
|
+
);
|
|
477
|
+
}
|
|
424
478
|
for (const name of Object.keys(manifest.localDirs)) {
|
|
425
479
|
if (manifest.shape === "registry" && !seen.has(name)) {
|
|
426
480
|
findings.push(
|
|
@@ -631,6 +685,52 @@ async function checkCorpEmails(manifest, findings, disclosures) {
|
|
|
631
685
|
}
|
|
632
686
|
}
|
|
633
687
|
}
|
|
688
|
+
async function checkGateDrift(manifest, findings, disclosures) {
|
|
689
|
+
const { planWorkflow } = await import('./generate-IYCQETQB.js');
|
|
690
|
+
const { scanProject: scanProject2 } = await import('./scan-ZYZ7XDSJ.js');
|
|
691
|
+
const { readConfig } = await import('./config-MX7OYI7F.js');
|
|
692
|
+
for (const entry of manifest.entries) {
|
|
693
|
+
const root = entry.resolvedRoot;
|
|
694
|
+
if (!root || !await isDirectory(root)) continue;
|
|
695
|
+
if (entry.profile === "corp" || entry.private) continue;
|
|
696
|
+
const facts = await scanProject2(root);
|
|
697
|
+
if (!facts.git.isRepo) continue;
|
|
698
|
+
if (facts.hooks.source === "husky" || facts.hooks.source === "husky-legacy") {
|
|
699
|
+
disclosures.push(
|
|
700
|
+
`${entry.name}: hooks managed by ${facts.hooks.source} \u2014 gate drift not comparable against the .githooks pack`
|
|
701
|
+
);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const { config } = await readConfig(root);
|
|
705
|
+
const planned = (await planWorkflow(root, facts, { agents: false, gates: true, gateConfig: config.gates })).filter((f) => f.executable);
|
|
706
|
+
const missing = planned.filter((f) => !f.exists).map((f) => f.path);
|
|
707
|
+
const stale = planned.filter((f) => f.exists && f.differs).map((f) => f.path);
|
|
708
|
+
if (missing.length) {
|
|
709
|
+
findings.push(
|
|
710
|
+
finding(
|
|
711
|
+
`${FLEET_LENS}/gate-missing:${entry.name}`,
|
|
712
|
+
"gap",
|
|
713
|
+
`\`${entry.name}\` is missing ${missing.length} gate${missing.length === 1 ? "" : "s"} the pack would install`,
|
|
714
|
+
missing.map((m) => `${entry.name}: no ${m}`),
|
|
715
|
+
"A gate absent in one repo is invisible from inside it \u2014 every commit there skips a check its siblings run.",
|
|
716
|
+
"Run `etymd gates` in that repo."
|
|
717
|
+
)
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
if (stale.length) {
|
|
721
|
+
findings.push(
|
|
722
|
+
finding(
|
|
723
|
+
`${FLEET_LENS}/gate-stale:${entry.name}`,
|
|
724
|
+
"polish",
|
|
725
|
+
`\`${entry.name}\` has ${stale.length} gate${stale.length === 1 ? "" : "s"} that differ from what its own inputs generate`,
|
|
726
|
+
stale.map((s) => `${entry.name}: ${s}`),
|
|
727
|
+
"A hand-edited or outdated hook drifts from the pack silently \u2014 the repo believes it runs checks the file no longer contains.",
|
|
728
|
+
"Re-run `etymd gates` (it asks before overwriting), or record the intent in .etymd/config.json."
|
|
729
|
+
)
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
634
734
|
async function collectWallFindings(manifest) {
|
|
635
735
|
const findings = [];
|
|
636
736
|
const disclosures = [];
|
|
@@ -639,6 +739,7 @@ async function collectWallFindings(manifest) {
|
|
|
639
739
|
await checkManifestRepoMachinePaths(manifest, findings, disclosures);
|
|
640
740
|
await checkHygieneNeedles(manifest, findings, disclosures);
|
|
641
741
|
await checkCorpEmails(manifest, findings, disclosures);
|
|
742
|
+
await checkGateDrift(manifest, findings, disclosures);
|
|
642
743
|
return { findings, disclosures };
|
|
643
744
|
}
|
|
644
745
|
function stateAgeDays(facts) {
|
|
@@ -971,6 +1072,138 @@ async function check(opts) {
|
|
|
971
1072
|
renderFindings(findings);
|
|
972
1073
|
renderFleetNotes([], disclosures);
|
|
973
1074
|
}
|
|
1075
|
+
async function add(opts) {
|
|
1076
|
+
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
1077
|
+
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
1078
|
+
if (manifest.shape !== "registry") {
|
|
1079
|
+
throw new Error("`fleet add` targets the registry shape \u2014 this manifest is a legacy corpus");
|
|
1080
|
+
}
|
|
1081
|
+
const raw = await readText(manifest.manifestPath);
|
|
1082
|
+
if (raw === null) throw new Error(`cannot read ${manifest.manifestPath}`);
|
|
1083
|
+
const absTarget = path.resolve(opts.cwd, opts.target);
|
|
1084
|
+
if (!await pathExists(absTarget)) throw new Error(`no such directory: ${absTarget}`);
|
|
1085
|
+
const name = opts.name ?? path.basename(absTarget);
|
|
1086
|
+
if (manifest.entries.some((e) => e.name === name)) {
|
|
1087
|
+
throw new Error(`\`${name}\` is already registered \u2014 resolution is keyed by name`);
|
|
1088
|
+
}
|
|
1089
|
+
const facts = await scanProject(absTarget);
|
|
1090
|
+
const remote = facts.git.isRepo ? await git(absTarget, ["remote", "get-url", "origin"]) : null;
|
|
1091
|
+
section(`Fleet add ${theme.dim(`\xB7 ${name} \xB7 ${absTarget}`)}`);
|
|
1092
|
+
let profile = opts.profile;
|
|
1093
|
+
if (!profile && !opts.yes) {
|
|
1094
|
+
const picked = await select({
|
|
1095
|
+
message: "Which side of the wall does this belong to?",
|
|
1096
|
+
initialValue: "personal",
|
|
1097
|
+
options: [
|
|
1098
|
+
{ value: "personal", label: "personal \u2014 your own work" },
|
|
1099
|
+
{ value: "corp", label: "corp \u2014 employer work (alias-only, machine-pinned)" }
|
|
1100
|
+
]
|
|
1101
|
+
});
|
|
1102
|
+
if (isCancel(picked)) {
|
|
1103
|
+
cancel("No entry written.");
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
profile = picked;
|
|
1107
|
+
}
|
|
1108
|
+
profile = profile ?? "personal";
|
|
1109
|
+
if (profile !== "personal" && profile !== "corp") {
|
|
1110
|
+
throw new Error(`--profile must be personal or corp, got \`${profile}\``);
|
|
1111
|
+
}
|
|
1112
|
+
const derivedKind = facts.commands.build || facts.commands.test ? "tool" : "repo";
|
|
1113
|
+
let kind = opts.kind;
|
|
1114
|
+
if (!kind && !opts.yes) {
|
|
1115
|
+
const known = [...new Set(manifest.entries.map((e) => e.kind).filter(Boolean))];
|
|
1116
|
+
const choices = [.../* @__PURE__ */ new Set([derivedKind, ...known])];
|
|
1117
|
+
const picked = await select({
|
|
1118
|
+
message: "Kind?",
|
|
1119
|
+
initialValue: derivedKind,
|
|
1120
|
+
options: [
|
|
1121
|
+
...choices.map((k) => ({
|
|
1122
|
+
value: k,
|
|
1123
|
+
label: k === derivedKind ? `${k} \u2014 matches this repo's shape` : k
|
|
1124
|
+
})),
|
|
1125
|
+
{ value: "", label: "something else\u2026" }
|
|
1126
|
+
]
|
|
1127
|
+
});
|
|
1128
|
+
if (isCancel(picked)) {
|
|
1129
|
+
cancel("No entry written.");
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
kind = picked;
|
|
1133
|
+
if (!kind) {
|
|
1134
|
+
const typed = await text({
|
|
1135
|
+
message: "Kind (free text):",
|
|
1136
|
+
validate: (v) => v.trim() ? void 0 : "a kind is required"
|
|
1137
|
+
});
|
|
1138
|
+
if (isCancel(typed)) {
|
|
1139
|
+
cancel("No entry written.");
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
kind = typed.trim();
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
kind = kind ?? derivedKind;
|
|
1146
|
+
let trust;
|
|
1147
|
+
if (profile === "personal") {
|
|
1148
|
+
trust = opts.trust;
|
|
1149
|
+
if (!trust && !opts.yes) {
|
|
1150
|
+
const picked = await select({
|
|
1151
|
+
message: "How exposed is this repo's history? (gates content screening)",
|
|
1152
|
+
options: [
|
|
1153
|
+
{ value: "private", label: "private \u2014 not destined to be published" },
|
|
1154
|
+
{ value: "public-bound", label: "public-bound \u2014 private now, plausibly public later" },
|
|
1155
|
+
{ value: "public-repo", label: "public-repo \u2014 already public" }
|
|
1156
|
+
]
|
|
1157
|
+
});
|
|
1158
|
+
if (isCancel(picked)) {
|
|
1159
|
+
cancel("No entry written.");
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
trust = picked;
|
|
1163
|
+
}
|
|
1164
|
+
if (!trust) {
|
|
1165
|
+
throw new Error(
|
|
1166
|
+
`\`${name}\` needs a trust level: pass --trust <${FLEET_TRUST_VALUES.join("|")}> (non-interactive runs cannot be prompted, and there is deliberately no default \u2014 trust gates content screening)`
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
if (!isFleetTrust(trust)) {
|
|
1170
|
+
throw new Error(`--trust must be one of ${FLEET_TRUST_VALUES.join(", ")}, got \`${trust}\``);
|
|
1171
|
+
}
|
|
1172
|
+
} else if (opts.trust) {
|
|
1173
|
+
throw new Error("corp entries take no `trust` \u2014 `profile: corp` already implies it");
|
|
1174
|
+
}
|
|
1175
|
+
const entry = profile === "corp" ? { name, kind, profile, private: true } : {
|
|
1176
|
+
name,
|
|
1177
|
+
kind,
|
|
1178
|
+
profile,
|
|
1179
|
+
path: path.relative(manifest.root ?? manifest.dir, absTarget),
|
|
1180
|
+
trust,
|
|
1181
|
+
contract: {},
|
|
1182
|
+
links: {},
|
|
1183
|
+
// The scan does not collect remotes (they are not a workflow fact), so read it here —
|
|
1184
|
+
// recorded when present, simply absent when the repo has no origin yet.
|
|
1185
|
+
...remote ? { remote } : {}
|
|
1186
|
+
};
|
|
1187
|
+
print(` ${glyph.bullet} ${theme.dim("entry")}`);
|
|
1188
|
+
print(
|
|
1189
|
+
JSON.stringify(entry, null, 2).split("\n").map((l) => ` ${theme.dim(l)}`).join("\n")
|
|
1190
|
+
);
|
|
1191
|
+
if (!opts.yes) {
|
|
1192
|
+
const ok = await confirm({
|
|
1193
|
+
message: `Write this entry to ${path.basename(manifest.manifestPath)}?`
|
|
1194
|
+
});
|
|
1195
|
+
if (isCancel(ok) || !ok) {
|
|
1196
|
+
cancel("No entry written.");
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const doc = JSON.parse(raw);
|
|
1201
|
+
if (!Array.isArray(doc.projects)) throw new Error("manifest has no `projects` array to append to");
|
|
1202
|
+
doc.projects.push(entry);
|
|
1203
|
+
await promises.writeFile(manifest.manifestPath, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
1204
|
+
print(` ${glyph.ok} ${theme.dim("registered")} ${theme.info(name)}`);
|
|
1205
|
+
print(` ${theme.dim(`verify with \`etymd fleet check --manifest ${manifest.manifestPath}\``)}`);
|
|
1206
|
+
}
|
|
974
1207
|
async function resolveFleetFinding(opts, status) {
|
|
975
1208
|
if (status === "dismissed" && !opts.reason?.trim()) {
|
|
976
1209
|
throw new Error('dismiss needs a reason: etymd fleet dismiss <name> <id> --reason "\u2026"');
|
|
@@ -1001,4 +1234,4 @@ async function accept2(opts) {
|
|
|
1001
1234
|
await resolveFleetFinding(opts, "accepted");
|
|
1002
1235
|
}
|
|
1003
1236
|
|
|
1004
|
-
export { accept2 as accept, check, dismiss2 as dismiss, sweep };
|
|
1237
|
+
export { accept2 as accept, add, check, dismiss2 as dismiss, sweep };
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readConfig, CONFIG_FILE, configPath } from './chunk-NMZ3RHWW.js';
|
|
3
|
+
import { applyFiles } from './chunk-EF6BPBG6.js';
|
|
4
|
+
import { isSafeGateCommand, planWorkflow, runPrefix } from './chunk-3NYDVB4Z.js';
|
|
5
|
+
import { section, print, glyph, theme, renderPlan } from './chunk-TADKOW6P.js';
|
|
6
|
+
import './chunk-R74SJ7HS.js';
|
|
7
|
+
import { scanProject } from './chunk-MKYPLNLH.js';
|
|
8
|
+
import './chunk-NFENQ3IK.js';
|
|
9
|
+
import './chunk-CRJHIXJH.js';
|
|
10
|
+
import { readText, git } from './chunk-YLNQQZ5F.js';
|
|
11
|
+
import { promises } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { confirm, isCancel, cancel, select, multiselect } from '@clack/prompts';
|
|
14
|
+
|
|
15
|
+
function detectIndent(raw) {
|
|
16
|
+
const m = raw.match(/\n(\s+)"/);
|
|
17
|
+
if (!m) return 2;
|
|
18
|
+
const ws = m[1];
|
|
19
|
+
return ws.includes(" ") ? " " : ws.length;
|
|
20
|
+
}
|
|
21
|
+
function mergeScriptInto(raw, key, value) {
|
|
22
|
+
let doc;
|
|
23
|
+
try {
|
|
24
|
+
doc = JSON.parse(raw);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return {
|
|
27
|
+
result: {
|
|
28
|
+
outcome: "unparsable",
|
|
29
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const scripts = doc.scripts ?? {};
|
|
34
|
+
const current = scripts[key];
|
|
35
|
+
if (typeof current === "string") {
|
|
36
|
+
if (current === value) return { result: { outcome: "unchanged" } };
|
|
37
|
+
return { result: { outcome: "conflict", existing: current } };
|
|
38
|
+
}
|
|
39
|
+
const indent = detectIndent(raw);
|
|
40
|
+
const next = { ...doc, scripts: { ...scripts, [key]: value } };
|
|
41
|
+
const trailing = raw.endsWith("\n") ? "\n" : "";
|
|
42
|
+
return { text: JSON.stringify(next, null, indent) + trailing, result: { outcome: "added" } };
|
|
43
|
+
}
|
|
44
|
+
async function ensurePackageScript(pkgPath, key, value) {
|
|
45
|
+
const raw = await readText(pkgPath);
|
|
46
|
+
if (raw === null) return { outcome: "absent" };
|
|
47
|
+
const { text, result } = mergeScriptInto(raw, key, value);
|
|
48
|
+
if (text) await promises.writeFile(pkgPath, text, "utf8");
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/commands/gates.ts
|
|
53
|
+
var PUBLISH_GATE_SCRIPT = "scripts/artifact-check.sh";
|
|
54
|
+
var PUBLISH_GATE_VALUE = `./${PUBLISH_GATE_SCRIPT}`;
|
|
55
|
+
function publishGateKey(route) {
|
|
56
|
+
return route === "vscode" ? "vscode:prepublish" : "prepublishOnly";
|
|
57
|
+
}
|
|
58
|
+
function derivedCommands(facts, existingHook) {
|
|
59
|
+
const c = facts.commands;
|
|
60
|
+
const base = [c.formatCheck, c.typecheck, c.lint].filter(
|
|
61
|
+
(k) => Boolean(k) && isSafeGateCommand(c.raw[k])
|
|
62
|
+
);
|
|
63
|
+
if (c.test && existingHook && new RegExp(`\\b${c.test}\\b`).test(existingHook)) {
|
|
64
|
+
base.push(c.test);
|
|
65
|
+
}
|
|
66
|
+
return base;
|
|
67
|
+
}
|
|
68
|
+
function printGateSummary(gates, facts, source) {
|
|
69
|
+
const run2 = runPrefix(facts.packageManager);
|
|
70
|
+
const cmds = gates.commands.length ? gates.commands.map((c) => `${run2} ${c}`).join(", ") : "none detected";
|
|
71
|
+
print(` ${glyph.bullet} ${theme.dim("pre-push runs")} ${theme.info(cmds)}`);
|
|
72
|
+
print(` ${glyph.bullet} ${theme.dim("audit fails on")} ${theme.info(gates.failOn)}`);
|
|
73
|
+
print(
|
|
74
|
+
` ${glyph.bullet} ${theme.dim("publish screen")} ${theme.info(gates.publishGate ? "yes" : "no")}${gates.publishGate && !facts.publishable ? theme.dim(" (overridden)") : ""}`
|
|
75
|
+
);
|
|
76
|
+
if (source.recorded || source.willRecord) {
|
|
77
|
+
print(` ${theme.dim(`change any of these later in ${CONFIG_FILE}`)}`);
|
|
78
|
+
} else {
|
|
79
|
+
print(
|
|
80
|
+
` ${theme.dim(`derived from this repo \u2014 write ${CONFIG_FILE} (or run without --yes) to pin them`)}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function customize(current, facts) {
|
|
85
|
+
const c = facts.commands;
|
|
86
|
+
const available = [c.formatCheck, c.typecheck, c.lint, c.test].filter(
|
|
87
|
+
(k) => Boolean(k)
|
|
88
|
+
);
|
|
89
|
+
const picked = available.length ? await multiselect({
|
|
90
|
+
message: "Which commands should the pre-push gate run?",
|
|
91
|
+
required: false,
|
|
92
|
+
initialValues: current.commands,
|
|
93
|
+
options: available.map((k) => ({
|
|
94
|
+
value: k,
|
|
95
|
+
label: `${runPrefix(facts.packageManager)} ${k}`,
|
|
96
|
+
hint: isSafeGateCommand(c.raw[k]) ? void 0 : "writes \u2014 allowed only if you pick it"
|
|
97
|
+
}))
|
|
98
|
+
}) : [];
|
|
99
|
+
if (isCancel(picked)) return void 0;
|
|
100
|
+
const tier = await select({
|
|
101
|
+
message: "Fail the push on which finding tier?",
|
|
102
|
+
initialValue: current.failOn,
|
|
103
|
+
options: [
|
|
104
|
+
{ value: "risk", label: "risk \u2014 only what makes an agent do the wrong thing" },
|
|
105
|
+
{ value: "gap", label: "gap \u2014 also dead references and missing safeguards" },
|
|
106
|
+
{ value: "polish", label: "polish \u2014 everything" }
|
|
107
|
+
]
|
|
108
|
+
});
|
|
109
|
+
if (isCancel(tier)) return void 0;
|
|
110
|
+
const publish = await confirm({
|
|
111
|
+
message: "Screen the published artifact? (the only check that sees what actually ships)",
|
|
112
|
+
initialValue: current.publishGate ?? facts.publishable
|
|
113
|
+
});
|
|
114
|
+
if (isCancel(publish)) return void 0;
|
|
115
|
+
const commands = picked;
|
|
116
|
+
return {
|
|
117
|
+
commands,
|
|
118
|
+
failOn: tier,
|
|
119
|
+
publishGate: publish,
|
|
120
|
+
// Picking a writing command IS the override — recorded so the generator honors it.
|
|
121
|
+
allowWriting: commands.filter((k) => !isSafeGateCommand(c.raw[k]))
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function mergeGateSection(previous, gates) {
|
|
125
|
+
const merged = { ...previous, ...gates };
|
|
126
|
+
const why = previous._why;
|
|
127
|
+
if (!why) return merged;
|
|
128
|
+
const next = gates;
|
|
129
|
+
const kept = Object.fromEntries(
|
|
130
|
+
Object.entries(why).filter(
|
|
131
|
+
([field]) => JSON.stringify(previous[field]) === JSON.stringify(next[field])
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
if (Object.keys(kept).length) merged._why = kept;
|
|
135
|
+
else delete merged._why;
|
|
136
|
+
return merged;
|
|
137
|
+
}
|
|
138
|
+
async function writeGateConfig(root, gates) {
|
|
139
|
+
const target = configPath(root);
|
|
140
|
+
const existing = await readText(target);
|
|
141
|
+
let doc = {};
|
|
142
|
+
if (existing) {
|
|
143
|
+
try {
|
|
144
|
+
doc = JSON.parse(existing);
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
doc.gates = mergeGateSection(doc.gates ?? {}, gates);
|
|
150
|
+
await promises.mkdir(path.dirname(target), { recursive: true });
|
|
151
|
+
await promises.writeFile(target, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
152
|
+
}
|
|
153
|
+
async function run(opts) {
|
|
154
|
+
const facts = await scanProject(opts.cwd);
|
|
155
|
+
if (opts.ci) {
|
|
156
|
+
section("CI review gate");
|
|
157
|
+
print(
|
|
158
|
+
` ${glyph.partial} ${theme.dim("The advisory AI-review CI job (bring-your-own-key) ships in a later release \u2014 installing the free local gates below.")}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (!facts.git.isRepo) {
|
|
162
|
+
print(
|
|
163
|
+
` ${glyph.bad} ${theme.dim("Not a git repository \u2014 local hooks need git. Run `git init` first.")}`
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (facts.hooks.source === "husky" || facts.hooks.source === "husky-legacy" || facts.hooks.source === "custom") {
|
|
168
|
+
print(
|
|
169
|
+
` ${glyph.partial} ${theme.warn(`${facts.hooks.source} already manages hooks here.`)} ${theme.dim("git runs ONE hook path \u2014 installing .githooks + core.hooksPath takes over. Fold the existing hook logic in first.")}`
|
|
170
|
+
);
|
|
171
|
+
if (!opts.yes) {
|
|
172
|
+
const go = await confirm({ message: "Proceed with .githooks anyway?", initialValue: false });
|
|
173
|
+
if (isCancel(go) || !go) {
|
|
174
|
+
cancel("No changes made.");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const existingPrePush = await readText(path.join(opts.cwd, ".githooks", "pre-push"));
|
|
180
|
+
const { config, present: hasConfig } = await readConfig(opts.cwd);
|
|
181
|
+
const configSource = { recorded: hasConfig, willRecord: !opts.yes };
|
|
182
|
+
let gateConfig = {
|
|
183
|
+
...config.gates,
|
|
184
|
+
commands: config.gates.commands.length ? config.gates.commands : derivedCommands(facts, existingPrePush ?? void 0),
|
|
185
|
+
publishGate: config.gates.publishGate ?? facts.publishable
|
|
186
|
+
};
|
|
187
|
+
const plan = async () => {
|
|
188
|
+
const files = await planWorkflow(opts.cwd, facts, {
|
|
189
|
+
agents: false,
|
|
190
|
+
gates: true,
|
|
191
|
+
gateConfig
|
|
192
|
+
});
|
|
193
|
+
return files.filter((f) => f.executable);
|
|
194
|
+
};
|
|
195
|
+
let gateFiles = await plan();
|
|
196
|
+
renderPlan(gateFiles);
|
|
197
|
+
printGateSummary(gateConfig, facts, configSource);
|
|
198
|
+
if (!opts.yes) {
|
|
199
|
+
const choice = await select({
|
|
200
|
+
message: "Install these gates?",
|
|
201
|
+
initialValue: "yes",
|
|
202
|
+
options: [
|
|
203
|
+
{ value: "yes", label: "yes \u2014 install as shown" },
|
|
204
|
+
{ value: "customize", label: "customize \u2014 change what the gates run" },
|
|
205
|
+
{ value: "no", label: "cancel" }
|
|
206
|
+
]
|
|
207
|
+
});
|
|
208
|
+
if (isCancel(choice) || choice === "no") {
|
|
209
|
+
cancel("No changes made.");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (choice === "customize") {
|
|
213
|
+
const customized = await customize(gateConfig, facts);
|
|
214
|
+
if (!customized) {
|
|
215
|
+
cancel("No changes made.");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
gateConfig = customized;
|
|
219
|
+
gateFiles = await plan();
|
|
220
|
+
renderPlan(gateFiles);
|
|
221
|
+
printGateSummary(gateConfig, facts, configSource);
|
|
222
|
+
}
|
|
223
|
+
await writeGateConfig(opts.cwd, gateConfig);
|
|
224
|
+
}
|
|
225
|
+
const overwrite = /* @__PURE__ */ new Set();
|
|
226
|
+
for (const f of gateFiles) {
|
|
227
|
+
if (!f.exists) continue;
|
|
228
|
+
if (!f.differs) {
|
|
229
|
+
overwrite.add(f.path);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (opts.yes) continue;
|
|
233
|
+
const ow = await confirm({
|
|
234
|
+
message: `${f.path} exists with different content (hand-edited?). Overwrite with the pack version?`,
|
|
235
|
+
initialValue: false
|
|
236
|
+
});
|
|
237
|
+
if (isCancel(ow)) {
|
|
238
|
+
cancel("No changes made.");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (ow) overwrite.add(f.path);
|
|
242
|
+
}
|
|
243
|
+
const result = await applyFiles(opts.cwd, gateFiles, overwrite);
|
|
244
|
+
await git(opts.cwd, ["config", "core.hooksPath", ".githooks"]);
|
|
245
|
+
section("Done");
|
|
246
|
+
for (const w of result.written) print(` ${glyph.ok} ${theme.dim("wrote")} ${theme.info(w)}`);
|
|
247
|
+
for (const sk of result.skipped)
|
|
248
|
+
print(` ${glyph.bullet} ${theme.dim("kept (hand-edited)")} ${theme.dim(sk)}`);
|
|
249
|
+
print(` ${glyph.ok} ${theme.dim("set")} ${theme.code("core.hooksPath = .githooks")}`);
|
|
250
|
+
const wrotePublishGate = result.written.includes(PUBLISH_GATE_SCRIPT);
|
|
251
|
+
if (wrotePublishGate) {
|
|
252
|
+
const pkgPath = path.join(opts.cwd, "package.json");
|
|
253
|
+
const PUBLISH_GATE_KEY = publishGateKey(facts.publishRoute);
|
|
254
|
+
{
|
|
255
|
+
const merged = await ensurePackageScript(pkgPath, PUBLISH_GATE_KEY, PUBLISH_GATE_VALUE);
|
|
256
|
+
if (merged.outcome === "added")
|
|
257
|
+
print(` ${glyph.ok} ${theme.dim("wired")} ${theme.code(PUBLISH_GATE_KEY)}`);
|
|
258
|
+
else if (merged.outcome === "conflict")
|
|
259
|
+
print(
|
|
260
|
+
` ${glyph.partial} ${theme.warn(`${PUBLISH_GATE_KEY} already runs \`${merged.existing}\``)} ${theme.dim("\u2014 left as is; chain the screen into it by hand if you want both.")}`
|
|
261
|
+
);
|
|
262
|
+
else if (merged.outcome === "unchanged")
|
|
263
|
+
print(` ${glyph.bullet} ${theme.dim(`${PUBLISH_GATE_KEY} already wired`)}`);
|
|
264
|
+
else
|
|
265
|
+
print(
|
|
266
|
+
` ${glyph.partial} ${theme.dim(`package.json ${merged.outcome}${merged.detail ? `: ${merged.detail}` : ""} \u2014 wire ${PUBLISH_GATE_KEY} by hand`)}`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export { derivedCommands, mergeGateSection, run };
|