@event4u/agent-config 9.7.0 → 9.8.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +144 -0
- package/README.md +5 -5
- package/dist/agent-src/commands/commit/in-chunks.md +8 -0
- package/dist/agent-src/commands/commit.md +17 -0
- package/dist/agent-src/commands/pr/create.md +4 -0
- package/dist/agent-src/contexts/override-system.md +75 -5
- package/dist/agent-src/rules/context-hygiene.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +97 -0
- package/dist/agent-src/rules/domain-safety-pii.md +1 -0
- package/dist/agent-src/rules/external-code-graph-interop.md +13 -3
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
- package/dist/agent-src/rules/git-history-discipline.md +2 -0
- package/dist/agent-src/rules/language-and-tone.md +2 -0
- package/dist/agent-src/rules/lethal-trifecta-guard.md +3 -0
- package/dist/agent-src/rules/media-governance-routing.md +2 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/no-roadmap-references.md +3 -0
- package/dist/agent-src/rules/non-destructive-by-default.md +20 -0
- package/dist/agent-src/rules/onboarding-gate.md +2 -0
- package/dist/agent-src/rules/output-discipline.md +2 -0
- package/dist/agent-src/rules/persona-governance.md +2 -0
- package/dist/agent-src/rules/preservation-guard.md +3 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +106 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +1 -1
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +2 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/telegraph-speak.md +2 -0
- package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
- package/dist/agent-src/rules/tool-safety.md +1 -0
- package/dist/agent-src/rules/verify-before-complete.md +2 -0
- package/dist/agent-src/skills/adr-create/SKILL.md +17 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +94 -0
- package/dist/agent-src/skills/gated-reach/SKILL.md +185 -0
- package/dist/agent-src/skills/gated-reach/evals/triggers.json +72 -0
- package/dist/agent-src/skills/image-analyser/SKILL.md +14 -12
- package/dist/agent-src/skills/image-editing/SKILL.md +1 -0
- package/dist/agent-src/skills/readme-writing/SKILL.md +6 -1
- package/dist/agent-src/skills/screenshot-hygiene/SKILL.md +171 -0
- package/dist/agent-src/skills/secrets-management/SKILL.md +44 -1
- package/dist/agent-src/skills/subagent-orchestration/prompts/adversarial-verification-council.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge-two-stage.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-and-judge.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-competitively.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-parallel.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-steps.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/do-in-worktrees.md +29 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/judge-with-debate.md +29 -0
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +4 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/github-workflows/secret-scan.yml +65 -0
- package/dist/cli/configRoot.js +112 -0
- package/dist/cli/configRoot.js.map +1 -0
- package/dist/cli/configRoot.test.js +97 -0
- package/dist/cli/configRoot.test.js.map +1 -0
- package/dist/cli/help.js +4 -1
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/main.js +19 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +161 -44
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +13 -8
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +11 -6
- package/dist/install/install.mjs +16 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/router.json +1 -1
- package/dist/server/app.js +10 -0
- package/dist/server/app.js.map +1 -1
- package/dist/server/routes/ping.js +18 -0
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/server/schemas/settings.js +8 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/server/serverInfo.js +6 -2
- package/dist/server/serverInfo.js.map +1 -1
- package/dist/server/token.js +11 -4
- package/dist/server/token.js.map +1 -1
- package/dist/server/writeRoot.js +15 -1
- package/dist/server/writeRoot.js.map +1 -1
- package/dist/shared/capabilities.js +33 -0
- package/dist/shared/capabilities.js.map +1 -0
- package/dist/shared/capabilities.test.js +39 -0
- package/dist/shared/capabilities.test.js.map +1 -0
- package/dist/ui/assets/{index-BiEZiZ31.js → index-CrmZ34Rg.js} +16 -16
- package/dist/ui/assets/index-CrmZ34Rg.js.map +1 -0
- package/dist/ui/index.html +14 -5
- package/docs/CLAIMS.md +62 -37
- package/docs/architecture.md +2 -2
- package/docs/benchmark.md +137 -0
- package/docs/catalog.md +8 -3
- package/docs/comparison.yaml +44 -2
- package/docs/contracts/local-server-ports.md +53 -0
- package/docs/contracts/no-runtime-boundary.md +1 -1
- package/docs/contracts/subagent-boundary.md +15 -0
- package/docs/customization.md +14 -3
- package/docs/decisions/ADR-016-installer-architecture.md +2 -0
- package/docs/decisions/ADR-088-no-external-runtime-federation.md +1 -1
- package/docs/decisions/ADR-094-agent-memory-layer-removal.md +1 -1
- package/docs/decisions/ADR-109-subagent-v1-contract.md +2 -0
- package/docs/decisions/ADR-110-discipline-profile-resolution-locus.md +2 -0
- package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +11 -3
- package/docs/decisions/ADR-124-embedded-engine-doctrine.md +255 -0
- package/docs/decisions/ADR-125-doc-screenshot-anonymization.md +131 -0
- package/docs/decisions/ADR-126-internet-reach-operator-tooling.md +254 -0
- package/docs/decisions/ADR-127-enforcement-claims-must-resolve.md +150 -0
- package/docs/decisions/ADR-128-executable-evidence-and-measured-escalation.md +201 -0
- package/docs/decisions/INDEX.md +21 -0
- package/docs/decisions/engine-reclassification-2026-07.md +104 -0
- package/docs/distribution/awesome-list-submission.md +1 -1
- package/docs/distribution/registries.md +36 -27
- package/docs/enforcement-by-host.md +7 -0
- package/docs/featured-skills.md +2 -2
- package/docs/getting-started-by-role.md +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/governance-advantage.md +1 -1
- package/docs/guides/gated-platform-reads.md +221 -0
- package/docs/positioning-evidence.md +1 -1
- package/docs/proof.md +92 -16
- package/docs/threat-model.md +1 -0
- package/package.json +5 -2
- package/src/config/agent-settings.template.yml +43 -0
- package/src/config/reach-channels.yml +217 -0
- package/src/config/reach-prescriptions-intake.yml +200 -0
- package/src/scripts/_dispatch.bash +15 -0
- package/src/scripts/_lib/exec_evidence.ts +251 -0
- package/src/scripts/_lib/fixtures/secret-detector/negatives.txt +10 -0
- package/src/scripts/_lib/fixtures/secret-detector/positives.txt +11 -0
- package/src/scripts/_lib/kernel_rules.ts +46 -0
- package/src/scripts/_lib/reddit_thread_parse.ts +598 -0
- package/src/scripts/_lib/risk_paths.ts +160 -0
- package/src/scripts/_lib/secret_detector.test.ts +103 -0
- package/src/scripts/_lib/secret_detector.ts +319 -0
- package/src/scripts/_lib/tool_probe.ts +359 -0
- package/src/scripts/adr/regenerate_index.ts +81 -6
- package/src/scripts/audit_adr_coverage.ts +33 -0
- package/src/scripts/build_proof.ts +162 -0
- package/src/scripts/check_adr_frontmatter.ts +195 -0
- package/src/scripts/check_artefact_count_messaging.ts +4 -0
- package/src/scripts/check_backstop_debt.ts +200 -0
- package/src/scripts/check_claims.ts +188 -8
- package/src/scripts/check_comparison.ts +12 -0
- package/src/scripts/check_enforcement_coverage.ts +592 -0
- package/src/scripts/check_kernel_rule_bundle.ts +16 -12
- package/src/scripts/check_reach_channels.ts +407 -0
- package/src/scripts/check_reach_staleness.ts +364 -0
- package/src/scripts/check_secret_leak.test.ts +47 -0
- package/src/scripts/check_secret_leak.ts +290 -0
- package/src/scripts/code_graph/build.ts +0 -0
- package/src/scripts/code_graph/cli.ts +207 -0
- package/src/scripts/code_graph/detect.ts +134 -0
- package/src/scripts/code_graph/extract.ts +386 -0
- package/src/scripts/code_graph/loader.ts +123 -0
- package/src/scripts/code_graph/query.ts +176 -0
- package/src/scripts/code_graph/sanitize.ts +30 -0
- package/src/scripts/code_graph/types.ts +83 -0
- package/src/scripts/code_graph/validate.ts +63 -0
- package/src/scripts/external_sources_denylist.json +3 -1
- package/src/scripts/generate_subagent_floor.ts +194 -0
- package/src/scripts/hook_manifest.yaml +13 -3
- package/src/scripts/hooks/code_graph_nudge_hook.ts +184 -0
- package/src/scripts/iron_law_sha.ts +3 -12
- package/src/scripts/lint_override_kernel_guard.ts +219 -0
- package/src/scripts/measure_rule_budget.ts +3 -12
- package/src/scripts/postinstall_gui.ts +129 -0
- package/src/scripts/reach_doctor.ts +1651 -0
- package/src/scripts/release.ts +24 -3
- package/src/scripts/risk_escalation_shadow.ts +160 -0
- package/src/scripts/schemas/agent-settings.schema.json +23 -0
- package/src/scripts/schemas/reach-channels.schema.json +134 -0
- package/src/scripts/schemas/reach-doctor-payload.schema.json +220 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/scripts/skill_linter.ts +55 -0
- package/src/scripts/validate_reach_prescriptions.ts +751 -0
- package/src/server/schemas/settings.ts +16 -0
- package/src/shared/capabilities.test.ts +43 -0
- package/src/shared/capabilities.ts +75 -0
- package/src/templates/minimal/agents-overrides-readme.md +20 -0
- package/dist/ui/assets/index-BiEZiZ31.js.map +0 -1
|
@@ -0,0 +1,1651 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* reach:doctor — read-only health report over the reach channel registry
|
|
4
|
+
* (road-to-internet-reach Phase 2, steps 4-7; `--deep` from Phase 7 step 4).
|
|
5
|
+
*
|
|
6
|
+
* WHAT THIS ANSWERS. Two operator questions, nothing more: *is the upstream
|
|
7
|
+
* tool I already chose to install healthy, and is its install command
|
|
8
|
+
* pinned?* — and, for a backend that declares one, *is it actually able to do
|
|
9
|
+
* the retrieval, or merely installed?* Per channel it reports the health of the
|
|
10
|
+
* ordered backend candidates, which one is currently active, and — when the
|
|
11
|
+
* first candidate is `missing` or `broken` — the exact pinned command **a
|
|
12
|
+
* human** runs for the current platform, echoed verbatim from
|
|
13
|
+
* `src/config/reach-channels.yml`.
|
|
14
|
+
*
|
|
15
|
+
* THE SECOND QUESTION EXISTS BECAUSE A PASSING PROBE CAN LIE. `yt-dlp
|
|
16
|
+
* --version` exits 0 as soon as the binary is on PATH, while YouTube extraction
|
|
17
|
+
* additionally needs an external JavaScript runtime that the tool must be
|
|
18
|
+
* configured to use. A channel reported `ok` on that evidence alone is a false
|
|
19
|
+
* green. The readiness layer (§ READINESS LAYER) closes it locally and
|
|
20
|
+
* read-only, and its verdict is surfaced as the distinct `not-ready` channel
|
|
21
|
+
* status so `missing` (nothing installed) can never be confused with
|
|
22
|
+
* `not-ready` (installed, cannot retrieve).
|
|
23
|
+
*
|
|
24
|
+
* WHAT THIS IS NOT. Not a router, not a recommendation, not an agent-facing
|
|
25
|
+
* surface. The Phase 0 pre-registered benchmark returned `band: stop` (native
|
|
26
|
+
* arm 12/12, reach arm 0 outright wins), so no router skill ships and no
|
|
27
|
+
* channel in the registry is routed, preferred, or suggested to an agent.
|
|
28
|
+
* A channel appearing here means "a recipe an operator wrote needs this
|
|
29
|
+
* backend", never "the agent should reach for this".
|
|
30
|
+
*
|
|
31
|
+
* READ-ONLY BY CONSTRUCTION.
|
|
32
|
+
* - Zero writes. No file is created, truncated, appended to, or chmod'ed;
|
|
33
|
+
* `--deep` prints the `last_verified` lines an operator MAY commit and
|
|
34
|
+
* writes none of them. Asserted mechanically by
|
|
35
|
+
* `tests/scripts/witness/reach_doctor_readonly.test.ts`.
|
|
36
|
+
* - Zero installs. Install prescriptions are strings this script selects
|
|
37
|
+
* and echoes; it never executes one and never invents one.
|
|
38
|
+
* - Zero network in the default (shallow) mode. The only child processes
|
|
39
|
+
* are the registry's side-effect-free probes, run through `probeTool()`
|
|
40
|
+
* (which hardens the child env per ADR-123). `--deep` is the single
|
|
41
|
+
* opt-in exception and refuses to run in CI.
|
|
42
|
+
*
|
|
43
|
+
* SCHEMA-ENFORCED INPUT — the registry is NOT trusted input. `collect()`
|
|
44
|
+
* validates the file (including `--registry <path>`) against
|
|
45
|
+
* `reach-channels.schema.json` plus the cross-field `probe_cmd === id` rule,
|
|
46
|
+
* and refuses the whole run with exit 2 on any error-severity finding. This is
|
|
47
|
+
* a security boundary, not tidiness: `probe_cmd` + `probe_args` become a child
|
|
48
|
+
* process, so an unvalidated registry was an arbitrary-execution primitive
|
|
49
|
+
* (`probe_cmd: sh`, `probe_args: ['-c', '<payload>']` ran and reported `ok`).
|
|
50
|
+
*
|
|
51
|
+
* PER-CHANNEL ISOLATION. A malformed channel becomes that channel's `error`
|
|
52
|
+
* row; every other channel is still reported. One bad entry can never take
|
|
53
|
+
* the report down — the same guarantee `probeTools()` gives per backend. Since
|
|
54
|
+
* the schema gate above refuses malformed files outright, this layer is now
|
|
55
|
+
* defense in depth: it is exercised directly through `buildChannelRow()` /
|
|
56
|
+
* `errorRow()`, which are exported for exactly that reason.
|
|
57
|
+
*
|
|
58
|
+
* Payload / flag / exit-code conventions follow `hooks_doctor.ts`:
|
|
59
|
+
* `schema_version` on a JSON-serialisable payload, `--format json|table`
|
|
60
|
+
* (table default), `--strict` for CI, exit 2 for an unusable input. The
|
|
61
|
+
* JSON payload validates against
|
|
62
|
+
* `src/scripts/schemas/reach-doctor-payload.schema.json`.
|
|
63
|
+
*
|
|
64
|
+
* Exit codes:
|
|
65
|
+
* 0 — report produced (and, under `--strict`, every admitted channel is ok).
|
|
66
|
+
* 1 — `--strict` and at least one admitted channel is not `ok`.
|
|
67
|
+
* 2 — usage error, unusable OR schema-invalid registry, or `--deep`
|
|
68
|
+
* requested inside CI.
|
|
69
|
+
*
|
|
70
|
+
* Invocation (from the project root):
|
|
71
|
+
* ./agent-config reach:doctor [--format json|table] [--strict]
|
|
72
|
+
* [--channel <id>] [--registry <path>] [--deep]
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
import { spawnSync } from 'node:child_process';
|
|
76
|
+
import * as fs from 'node:fs';
|
|
77
|
+
import * as os from 'node:os';
|
|
78
|
+
import * as path from 'node:path';
|
|
79
|
+
import process from 'node:process';
|
|
80
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
81
|
+
|
|
82
|
+
import { hardenedSpawnEnv } from './_lib/spawn_env.js';
|
|
83
|
+
import { probeTool } from './_lib/tool_probe.js';
|
|
84
|
+
import type { FixPrescription, ToolProbeStatus } from './_lib/tool_probe.js';
|
|
85
|
+
import {
|
|
86
|
+
REGISTRY_PATH,
|
|
87
|
+
RegistryLoadError,
|
|
88
|
+
SCHEMA_MESSAGE_MAX_CHARS,
|
|
89
|
+
excerpt_for_finding,
|
|
90
|
+
load_registry,
|
|
91
|
+
sanitizeParseError,
|
|
92
|
+
validate_file,
|
|
93
|
+
} from './check_reach_channels.js';
|
|
94
|
+
|
|
95
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
96
|
+
/** Repo root — two dirs up from src/scripts, mirroring the sibling scripts. */
|
|
97
|
+
const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
98
|
+
|
|
99
|
+
/** Payload format version. Bumped only on a breaking payload shape change. */
|
|
100
|
+
export const SCHEMA_VERSION = 1;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `removed` extends the probe taxonomy: past `removal_after`, never probed.
|
|
104
|
+
* `not-ready` extends it the other way: a candidate IS installed and answering
|
|
105
|
+
* its probe, but a declared readiness requirement is not confirmed satisfied —
|
|
106
|
+
* the "installed, unverified" ceiling (see § READINESS LAYER). It exists so an
|
|
107
|
+
* operator can tell `missing` (nothing to run) from `not-ready` (the binary
|
|
108
|
+
* runs, the retrieval still will not work).
|
|
109
|
+
*/
|
|
110
|
+
export type ChannelStatus = ToolProbeStatus | 'removed' | 'not-ready';
|
|
111
|
+
|
|
112
|
+
/** Thrown for the exit-2 class: bad flags, unknown channel, `--deep` in CI. */
|
|
113
|
+
export class ReachDoctorUsageError extends Error {}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Deep probes — the ONLY network in this script, and only under `--deep`.
|
|
117
|
+
*
|
|
118
|
+
* Declared here rather than in the registry on purpose: the registry's schema
|
|
119
|
+
* describes *install pinning and local probes*, and a network endpoint is not
|
|
120
|
+
* derivable from it. A backend with no entry below reports `not-declared`
|
|
121
|
+
* instead of getting an endpoint invented for it — `yt-dlp` is exactly that
|
|
122
|
+
* case (any real video URL would be both an invention and a terms-of-service
|
|
123
|
+
* surface). Every declared probe is read-only: a rate-limit read, or a HEAD
|
|
124
|
+
* against `example.com`, the domain IANA reserves for documentation use.
|
|
125
|
+
*/
|
|
126
|
+
interface DeepProbeSpec {
|
|
127
|
+
/** Args appended to the backend's own `probe_cmd`. */
|
|
128
|
+
readonly args: readonly string[];
|
|
129
|
+
/** The endpoint actually contacted, surfaced in the report. */
|
|
130
|
+
readonly endpoint: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const DEEP_PROBES: Readonly<Record<string, DeepProbeSpec>> = {
|
|
134
|
+
// GitHub's own rate-limit read — the canonical "is my token live" call.
|
|
135
|
+
gh: { args: ['api', 'rate_limit'], endpoint: 'https://api.github.com/rate_limit' },
|
|
136
|
+
curl: {
|
|
137
|
+
args: [
|
|
138
|
+
'--silent',
|
|
139
|
+
'--show-error',
|
|
140
|
+
'--max-time',
|
|
141
|
+
'10',
|
|
142
|
+
'--head',
|
|
143
|
+
'--output',
|
|
144
|
+
os.devNull,
|
|
145
|
+
'https://example.com',
|
|
146
|
+
],
|
|
147
|
+
endpoint: 'https://example.com',
|
|
148
|
+
},
|
|
149
|
+
node: {
|
|
150
|
+
args: [
|
|
151
|
+
'-e',
|
|
152
|
+
"fetch('https://example.com', { method: 'HEAD' }).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))",
|
|
153
|
+
],
|
|
154
|
+
endpoint: 'https://example.com',
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Per-backend deadline for a deep probe — network, so looser than a probe. */
|
|
159
|
+
const DEEP_TIMEOUT_MS = 15_000;
|
|
160
|
+
|
|
161
|
+
export interface DeepRow {
|
|
162
|
+
status: 'ok' | 'failed' | 'skipped' | 'not-declared';
|
|
163
|
+
endpoint: string | null;
|
|
164
|
+
exit_code: number | null;
|
|
165
|
+
detail: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface BackendRow {
|
|
169
|
+
id: string;
|
|
170
|
+
probe_cmd: string;
|
|
171
|
+
status: ToolProbeStatus;
|
|
172
|
+
path: string | null;
|
|
173
|
+
exit_code: number | null;
|
|
174
|
+
diagnostic: string;
|
|
175
|
+
/** Pinned prescription for the CURRENT platform; `missing`/`broken` only. */
|
|
176
|
+
fix: string | null;
|
|
177
|
+
deep: DeepRow | null;
|
|
178
|
+
/**
|
|
179
|
+
* Config-semantic readiness verdict, present ONLY for a backend that
|
|
180
|
+
* declares a readiness requirement (see `READINESS_REQUIREMENTS`). ABSENT —
|
|
181
|
+
* not `null` — for every other backend, so "this backend has no such
|
|
182
|
+
* requirement" is expressed by the field not existing rather than by a
|
|
183
|
+
* value a reader has to interpret.
|
|
184
|
+
*/
|
|
185
|
+
readiness?: ReadinessRow;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Credential-file permission metadata. Deliberately carries the path and the
|
|
190
|
+
* mode ONLY — never the contents, and never a hash or fingerprint of the
|
|
191
|
+
* secret (a fingerprint is a confirmation oracle, so it is not a safe
|
|
192
|
+
* "redacted" form).
|
|
193
|
+
*/
|
|
194
|
+
export interface CredentialRow {
|
|
195
|
+
path: string;
|
|
196
|
+
present: boolean;
|
|
197
|
+
/** Octal permission bits, e.g. `0600`. `null` when not checked/absent. */
|
|
198
|
+
mode: string | null;
|
|
199
|
+
group_or_world_readable: boolean;
|
|
200
|
+
/** POSIX modes are not meaningful on win32; the check is skipped there. */
|
|
201
|
+
checked: boolean;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ChannelRow {
|
|
205
|
+
id: string;
|
|
206
|
+
status: ChannelStatus;
|
|
207
|
+
/** First backend that probes `ok`; `null` when none does. */
|
|
208
|
+
active_backend: string | null;
|
|
209
|
+
tier: string;
|
|
210
|
+
lifecycle: string;
|
|
211
|
+
last_verified: string;
|
|
212
|
+
/** Pinned fix for the first candidate when it is `missing`/`broken`. */
|
|
213
|
+
fix: string | null;
|
|
214
|
+
warnings: string[];
|
|
215
|
+
credential: CredentialRow | null;
|
|
216
|
+
backends: BackendRow[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface ReachDoctorPayload {
|
|
220
|
+
schema_version: number;
|
|
221
|
+
/** `process.platform` — the key the install prescription was chosen by. */
|
|
222
|
+
platform: string;
|
|
223
|
+
/** Registry actually read, repo-relative when it lives inside the repo. */
|
|
224
|
+
registry: string;
|
|
225
|
+
/** True only when `--deep` ran (network was contacted). */
|
|
226
|
+
deep: boolean;
|
|
227
|
+
channels: ChannelRow[];
|
|
228
|
+
/** `--deep` only: `last_verified` lines the operator MAY commit. */
|
|
229
|
+
last_verified_suggestions: string[];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface CollectOptions {
|
|
233
|
+
registryPath?: string;
|
|
234
|
+
/** Single-channel filter (`--channel`). */
|
|
235
|
+
channel?: string;
|
|
236
|
+
deep?: boolean;
|
|
237
|
+
/** Injectable clock for the `removal_after` comparison. */
|
|
238
|
+
now?: Date;
|
|
239
|
+
/**
|
|
240
|
+
* Injectable readiness observer (defaults to the live one). TEST-FACING,
|
|
241
|
+
* exactly like `now`: no CLI flag reaches it.
|
|
242
|
+
*/
|
|
243
|
+
readiness?: ReadinessProbeFn;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function toRecord(value: unknown): Record<string, unknown> | null {
|
|
247
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return value as Record<string, unknown>;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function readString(record: Record<string, unknown>, key: string): string | null {
|
|
254
|
+
const value = record[key];
|
|
255
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `YYYY-MM-DD` in local time — the form `last_verified` / `removal_after` use. */
|
|
259
|
+
function isoDate(now: Date): string {
|
|
260
|
+
const year = now.getFullYear();
|
|
261
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
262
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
263
|
+
return `${year}-${month}-${day}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function expandHome(target: string): string {
|
|
267
|
+
if (target === '~') return os.homedir();
|
|
268
|
+
if (target.startsWith('~/')) return path.join(os.homedir(), target.slice(2));
|
|
269
|
+
return path.resolve(target);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Roots a declared `credential_path` may point into: the operator's home (where
|
|
274
|
+
* `~/.config`, `~/.netrc` and token files live), this repo, and the system temp
|
|
275
|
+
* dir (the fixture case — a temp path reveals nothing its author does not already
|
|
276
|
+
* control).
|
|
277
|
+
*/
|
|
278
|
+
function credentialRoots(): string[] {
|
|
279
|
+
const roots = [os.homedir(), ROOT, os.tmpdir()];
|
|
280
|
+
// BOTH the symlinked and the resolved form of every root, because macOS hands
|
|
281
|
+
// out `/var/folders/…` from `os.tmpdir()` while its realpath is
|
|
282
|
+
// `/private/var/folders/…`. Comparing only one form refused the legitimate
|
|
283
|
+
// fixture case — caught by running the check, not by reading it.
|
|
284
|
+
for (const root of [...roots]) {
|
|
285
|
+
try {
|
|
286
|
+
roots.push(fs.realpathSync(root));
|
|
287
|
+
} catch {
|
|
288
|
+
// A root that cannot be resolved simply contributes its literal form.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return [...new Set(roots.map((root) => path.resolve(root)))];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* PATH CONFINEMENT — the registry is untrusted input, so `credential_path` is an
|
|
296
|
+
* arbitrary-path primitive until it is bounded.
|
|
297
|
+
*
|
|
298
|
+
* Found by an adversarial council pass on this file, and reproduced before being
|
|
299
|
+
* believed: a `--registry` file declaring
|
|
300
|
+
* `credential_path: ../../../../../../../etc/passwd` made the doctor `stat` that
|
|
301
|
+
* path and report, at exit 0 inside a normal-looking health row,
|
|
302
|
+
* `{"path":"/etc/passwd","present":true,"mode":"0644","group_or_world_readable":true,"checked":true}`.
|
|
303
|
+
* That is an existence-and-permission oracle over the whole filesystem wearing the
|
|
304
|
+
* costume of a credential warning. The schema cannot express the bound (it only
|
|
305
|
+
* pins the value to a single line), so the bound lives here.
|
|
306
|
+
*
|
|
307
|
+
* Honest severity: whoever runs this CLI already has a shell and could `stat` the
|
|
308
|
+
* file themselves, so locally it buys an attacker nothing. It matters because this
|
|
309
|
+
* is a *diagnostic others may invoke* — a CI job, an agent surface with a path
|
|
310
|
+
* parameter — and a tool whose whole contract is "read-only and boring" should not
|
|
311
|
+
* ship a primitive that reports arbitrary path metadata.
|
|
312
|
+
*
|
|
313
|
+
* Refusal is reported, never silent: `checked: false` plus a warning naming it, so
|
|
314
|
+
* an operator with a legitimately unusual credential location sees why nothing was
|
|
315
|
+
* checked rather than seeing a pass.
|
|
316
|
+
*/
|
|
317
|
+
export function confineCredentialPath(resolved: string): boolean {
|
|
318
|
+
const roots = credentialRoots();
|
|
319
|
+
const candidates = [path.resolve(resolved)];
|
|
320
|
+
try {
|
|
321
|
+
// Realpath the target too, so a symlink cannot point out of the roots and
|
|
322
|
+
// so the macOS `/var` → `/private/var` case matches from either side.
|
|
323
|
+
candidates.push(fs.realpathSync(candidates[0] as string));
|
|
324
|
+
} catch {
|
|
325
|
+
// Not resolvable (usually: does not exist) — the literal form decides.
|
|
326
|
+
}
|
|
327
|
+
return candidates.every((candidate) =>
|
|
328
|
+
roots.some((root) => candidate === root || candidate.startsWith(`${root}${path.sep}`)),
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Group- or world-readability check on a declared credential file.
|
|
334
|
+
*
|
|
335
|
+
* No channel in the shipped registry declares `credential_path` — the
|
|
336
|
+
* login-tier channels that would have are cancelled with Phase 5 — so on the
|
|
337
|
+
* real registry this check is inert. It ships anyway (and is covered by
|
|
338
|
+
* fixture tests) because the failure it catches is silent: a `0644` token file
|
|
339
|
+
* is readable by every process on the box, and nothing else in the pipeline
|
|
340
|
+
* looks. Adding the field to the shipped registry requires a
|
|
341
|
+
* `reach-channels.schema.json` addition first; the schema is `additionalProperties: false`.
|
|
342
|
+
*/
|
|
343
|
+
function inspectCredential(declared: string): CredentialRow {
|
|
344
|
+
const resolved = expandHome(declared);
|
|
345
|
+
|
|
346
|
+
// Confinement runs BEFORE any syscall, so an out-of-bounds path is never
|
|
347
|
+
// stat'ed and not even its existence leaks.
|
|
348
|
+
if (!confineCredentialPath(resolved)) {
|
|
349
|
+
return {
|
|
350
|
+
path: resolved,
|
|
351
|
+
present: false,
|
|
352
|
+
mode: null,
|
|
353
|
+
group_or_world_readable: false,
|
|
354
|
+
checked: false,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
// WIN32: NO ALTERNATIVE CONTROL, AND THE GAP IS REPORTED RATHER THAN HIDDEN.
|
|
358
|
+
// Windows does not express file access as a `stat.mode` bitmask at all — it
|
|
359
|
+
// uses ACLs, so there is no POSIX permission triple here to read and no
|
|
360
|
+
// narrower check available. Reading the real ACL would need either a native
|
|
361
|
+
// binding or spawning `icacls`, and this doctor refuses to add a child
|
|
362
|
+
// process for a diagnostic (its read-only contract is asserted by the
|
|
363
|
+
// witness test). So the honest answer is `checked: false`: `present` is
|
|
364
|
+
// still reported, `mode` is `null`, and `group_or_world_readable` is `false`
|
|
365
|
+
// as "not determined" — never as "determined safe". `checked` is a REQUIRED
|
|
366
|
+
// field of the payload schema and both output formats surface it (the table
|
|
367
|
+
// prints "not checked on this platform"), so an operator sees an unevaluated
|
|
368
|
+
// check instead of a passing one.
|
|
369
|
+
if (process.platform === 'win32') {
|
|
370
|
+
return {
|
|
371
|
+
path: resolved,
|
|
372
|
+
present: fs.existsSync(resolved),
|
|
373
|
+
mode: null,
|
|
374
|
+
group_or_world_readable: false,
|
|
375
|
+
checked: false,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
let stat: fs.Stats;
|
|
379
|
+
try {
|
|
380
|
+
stat = fs.statSync(resolved);
|
|
381
|
+
} catch {
|
|
382
|
+
return {
|
|
383
|
+
path: resolved,
|
|
384
|
+
present: false,
|
|
385
|
+
mode: null,
|
|
386
|
+
group_or_world_readable: false,
|
|
387
|
+
checked: true,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
// A directory is not a credential file, and its 0755 is normal rather than a
|
|
391
|
+
// finding — reporting one as group-readable would manufacture a warning an
|
|
392
|
+
// operator cannot act on (council finding: the mode was read without checking
|
|
393
|
+
// the file type). Same for a socket / device / fifo: `checked: false` says the
|
|
394
|
+
// question was not answered, which is the truth.
|
|
395
|
+
if (!stat.isFile()) {
|
|
396
|
+
return {
|
|
397
|
+
path: resolved,
|
|
398
|
+
present: true,
|
|
399
|
+
mode: null,
|
|
400
|
+
group_or_world_readable: false,
|
|
401
|
+
checked: false,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const bits = stat.mode & 0o777;
|
|
405
|
+
// Group-read (0o040) or other-read (0o004) — write bits are a superset
|
|
406
|
+
// concern the mode string surfaces without needing its own field.
|
|
407
|
+
const exposed = (bits & 0o044) !== 0;
|
|
408
|
+
return {
|
|
409
|
+
path: resolved,
|
|
410
|
+
present: true,
|
|
411
|
+
mode: `0${bits.toString(8).padStart(3, '0')}`,
|
|
412
|
+
group_or_world_readable: exposed,
|
|
413
|
+
checked: true,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Run the declared deep probe for one backend.
|
|
419
|
+
*
|
|
420
|
+
* Reached from `buildChannelRow`, so the same boundary note applies: the schema
|
|
421
|
+
* gate is `collect()`, which validates the registry before `--deep` can turn any
|
|
422
|
+
* `probe_cmd` into a child process. This export is TEST-FACING; a direct caller
|
|
423
|
+
* is responsible for validating its input first.
|
|
424
|
+
*/
|
|
425
|
+
export function runDeepProbe(probeCmd: string, localStatus: ToolProbeStatus): DeepRow {
|
|
426
|
+
// `Object.hasOwn`, never a bare index. THE GUARD IS WHAT MAKES A
|
|
427
|
+
// PROTOTYPE-CHAIN KEY IMPOSSIBLE HERE — reviewers have flagged this area
|
|
428
|
+
// twice, so the mechanism is recorded rather than left to be re-derived:
|
|
429
|
+
// `DEEP_PROBES[probeCmd]` walks the prototype chain, so `probe_cmd:
|
|
430
|
+
// constructor` resolved to `Object` and `probe_cmd: __proto__` to
|
|
431
|
+
// `Object.prototype`. Either one skipped the `not-declared` branch and
|
|
432
|
+
// emitted a `deep` block with `endpoint: undefined` — a payload that fails
|
|
433
|
+
// its own schema. `Object.hasOwn` consults only own properties, so every
|
|
434
|
+
// inherited key now takes the `not-declared` path by construction, and no
|
|
435
|
+
// endpoint is ever invented for a backend that declared none.
|
|
436
|
+
const spec = Object.hasOwn(DEEP_PROBES, probeCmd) ? DEEP_PROBES[probeCmd] : undefined;
|
|
437
|
+
if (spec === undefined) {
|
|
438
|
+
return {
|
|
439
|
+
status: 'not-declared',
|
|
440
|
+
endpoint: null,
|
|
441
|
+
exit_code: null,
|
|
442
|
+
detail: `no deep probe is declared for '${probeCmd}' — an endpoint is never invented for a backend`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
if (localStatus !== 'ok') {
|
|
446
|
+
return {
|
|
447
|
+
status: 'skipped',
|
|
448
|
+
endpoint: spec.endpoint,
|
|
449
|
+
exit_code: null,
|
|
450
|
+
detail: `local probe is ${localStatus} — a real request would only re-report that`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
const result = spawnSync(probeCmd, [...spec.args], {
|
|
454
|
+
env: hardenedSpawnEnv(),
|
|
455
|
+
timeout: DEEP_TIMEOUT_MS,
|
|
456
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
457
|
+
windowsHide: true,
|
|
458
|
+
});
|
|
459
|
+
if (result.error !== undefined) {
|
|
460
|
+
return {
|
|
461
|
+
status: 'failed',
|
|
462
|
+
endpoint: spec.endpoint,
|
|
463
|
+
exit_code: null,
|
|
464
|
+
detail: `deep request failed to run: ${result.error.message}`,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
const code = result.status ?? null;
|
|
468
|
+
if (code === 0) {
|
|
469
|
+
return {
|
|
470
|
+
status: 'ok',
|
|
471
|
+
endpoint: spec.endpoint,
|
|
472
|
+
exit_code: 0,
|
|
473
|
+
detail: `one real read-only request to ${spec.endpoint} succeeded`,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
status: 'failed',
|
|
478
|
+
endpoint: spec.endpoint,
|
|
479
|
+
exit_code: code,
|
|
480
|
+
detail: `request to ${spec.endpoint} exited ${code === null ? 'without a status' : String(code)}`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* ====================================================================== *
|
|
485
|
+
* READINESS LAYER — a passing `--version` is not a working backend
|
|
486
|
+
* (road-to-gated-reach Phase 2, steps 2/3/6)
|
|
487
|
+
* ====================================================================== */
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* THE INVARIANT THIS WHOLE LAYER DEPENDS ON (Phase 2 step 6 — recorded here
|
|
491
|
+
* because this is the file that would break it).
|
|
492
|
+
*
|
|
493
|
+
* 1. **Probes stay `--version`-only.** Every argument list this script hands
|
|
494
|
+
* to a child process is either the registry's schema-constrained,
|
|
495
|
+
* flag-shaped `probe_args`, or a literal `['--version']` written here. No
|
|
496
|
+
* registry value ever selects a *subcommand*.
|
|
497
|
+
* 2. **The doctor NEVER runs an upstream tool's own status subcommand when
|
|
498
|
+
* that subcommand has side effects.** `gh auth status` refreshes and can
|
|
499
|
+
* rewrite the stored credential; `yt-dlp -U` / `--update-to` replaces the
|
|
500
|
+
* binary; a `login`/`doctor`/`serve` subcommand can start a daemon or
|
|
501
|
+
* mint a token. Any of those would make this *diagnostic* mutating — and
|
|
502
|
+
* the read-only claim is asserted mechanically by
|
|
503
|
+
* `tests/scripts/witness/reach_doctor_readonly.test.ts`, which fails on the
|
|
504
|
+
* mere presence of a filesystem write primitive in this file.
|
|
505
|
+
* 3. **Therefore an "installed, unverified" ceiling is preferred to a false
|
|
506
|
+
* green.** Where readiness cannot be settled without a side-effectful call
|
|
507
|
+
* or a network round-trip, the honest answer is `unknown` plus the
|
|
508
|
+
* `not-ready` channel status — never `ok`. The one exception is `--deep`,
|
|
509
|
+
* which is opt-in, CI-refusing, and limited to the endpoints declared in
|
|
510
|
+
* `DEEP_PROBES`.
|
|
511
|
+
*
|
|
512
|
+
* The readiness check below stays inside that invariant by construction: it
|
|
513
|
+
* reads a LOCAL config file and consults PATH. It contacts no network, and the
|
|
514
|
+
* only new child process it introduces is one more `--version`.
|
|
515
|
+
*/
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* DECISION (Phase 2 step 3): readiness requirements live HERE, in doctor-side
|
|
519
|
+
* logic keyed on the backend id — NOT as an optional per-backend field in
|
|
520
|
+
* `reach-channels.schema.json`.
|
|
521
|
+
*
|
|
522
|
+
* Why this shape:
|
|
523
|
+
*
|
|
524
|
+
* - **It keeps the schema honest for the backends that have no such
|
|
525
|
+
* requirement.** With a schema field, the four other channels' backends
|
|
526
|
+
* would each have to *declare an absence* (omit an optional key that a
|
|
527
|
+
* reader must then interpret); with a code table they declare nothing at
|
|
528
|
+
* all, and the payload simply carries no `readiness` object for them.
|
|
529
|
+
* - **The requirement is a property of the upstream tool, not of the
|
|
530
|
+
* operator's registry entry.** "yt-dlp needs an external JS runtime" is a
|
|
531
|
+
* fact about yt-dlp; a registry that could omit it would hand the operator
|
|
532
|
+
* back the exact false green this layer exists to close.
|
|
533
|
+
* - **The registry is untrusted input** (see `collect()`), and a schema field
|
|
534
|
+
* of kind "check this config file" would widen that input's influence into
|
|
535
|
+
* *which local path the doctor reads*. Keyed here, the resolved path
|
|
536
|
+
* depends only on the operator's own environment.
|
|
537
|
+
* - **Precedent in this same file:** `DEEP_PROBES` is declared code-side for
|
|
538
|
+
* the same reason — the registry's schema describes install pinning and
|
|
539
|
+
* local probes, and neither a network endpoint nor a config-semantic rule
|
|
540
|
+
* is derivable from it. A backend absent from the table gets `undefined`,
|
|
541
|
+
* never an invented requirement.
|
|
542
|
+
*
|
|
543
|
+
* The rejected trade-off, stated: a schema field would make the requirement
|
|
544
|
+
* visible in the registry an operator reads, and would let a *new* backend with
|
|
545
|
+
* the same need be added by editing config instead of code. That is a real
|
|
546
|
+
* cost, and it is paid here — adding a second js-runtime-needing backend means
|
|
547
|
+
* a code edit plus a test, not a YAML line. It was judged the cheaper cost,
|
|
548
|
+
* because the alternative's failure mode (a registry silently declaring no
|
|
549
|
+
* requirement, or declaring one for a backend where it is meaningless) is a
|
|
550
|
+
* wrong *health verdict*, while this one is only extra work for a maintainer.
|
|
551
|
+
* A short form of this reasoning also lives in the `$comment` of
|
|
552
|
+
* `src/scripts/schemas/reach-channels.schema.json`, where a reader looking for
|
|
553
|
+
* the absent field will actually be standing.
|
|
554
|
+
*/
|
|
555
|
+
export type ReadinessKind = 'js-runtime';
|
|
556
|
+
|
|
557
|
+
const READINESS_REQUIREMENTS: Readonly<Record<string, ReadinessKind>> = {
|
|
558
|
+
// `yt-dlp --version` exits 0 as soon as the binary exists, but YouTube
|
|
559
|
+
// extraction additionally needs an external JavaScript runtime. Only Deno
|
|
560
|
+
// is enabled by default; with Node the user config must opt in.
|
|
561
|
+
'yt-dlp': 'js-runtime',
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
/** The literal flag whose PRESENCE is the signal — any runtime value counts. */
|
|
565
|
+
export const JS_RUNTIME_FLAG = '--js-runtimes';
|
|
566
|
+
|
|
567
|
+
/** Runtime the flag names when the doctor prescribes it. */
|
|
568
|
+
const JS_RUNTIME_PRESCRIBED = 'node';
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* External JavaScript runtimes, in the order checked. `deno` first because
|
|
572
|
+
* yt-dlp enables it with no config at all, so finding it settles the question.
|
|
573
|
+
*/
|
|
574
|
+
const JS_RUNTIME_CANDIDATES: readonly string[] = ['deno', 'node'];
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* VERSION GATE — `2025.11.12`, the yt-dlp release that introduced
|
|
578
|
+
* `--js-runtimes`. Older builds reject the option outright, so prescribing it
|
|
579
|
+
* there would tell an operator to write a config the tool then errors on: a
|
|
580
|
+
* fix command that breaks a working install is worse than no fix command.
|
|
581
|
+
* Below the gate the remedy is therefore "upgrade first", never the flag.
|
|
582
|
+
*
|
|
583
|
+
* Provenance: taken from the roadmap step that specified this gate, NOT
|
|
584
|
+
* re-derived here — this command reaches no network and must not pretend to
|
|
585
|
+
* have checked a release feed. An unparseable version is treated as
|
|
586
|
+
* *unconfirmed* rather than assumed new enough (see `assessJsRuntimeReadiness`),
|
|
587
|
+
* so a wrong gate value can only make the doctor more conservative.
|
|
588
|
+
*/
|
|
589
|
+
const JS_RUNTIME_MIN_VERSION: readonly [number, number, number] = [2025, 11, 12];
|
|
590
|
+
|
|
591
|
+
/** Bounded read: a user config is a handful of lines; 64 KiB is already absurd. */
|
|
592
|
+
export const READINESS_CONFIG_MAX_BYTES = 64 * 1024;
|
|
593
|
+
|
|
594
|
+
/** Deadline for the extra `--version` capture — same order as a probe. */
|
|
595
|
+
const VERSION_TIMEOUT_MS = 5_000;
|
|
596
|
+
|
|
597
|
+
/** `ready` = confirmed usable · `not-ready` = confirmed unusable · `unknown` = not settled. */
|
|
598
|
+
export type ReadinessStatus = 'ready' | 'not-ready' | 'unknown';
|
|
599
|
+
|
|
600
|
+
export interface ReadinessRow {
|
|
601
|
+
kind: ReadinessKind;
|
|
602
|
+
status: ReadinessStatus;
|
|
603
|
+
/** Config path resolved the way the upstream tool itself resolves it. */
|
|
604
|
+
config_path: string;
|
|
605
|
+
/** True only when that path is a real, readable regular file. */
|
|
606
|
+
config_present: boolean;
|
|
607
|
+
/** True when the literal flag was found in it (any runtime value). */
|
|
608
|
+
config_flag: boolean;
|
|
609
|
+
/** Runtimes found on PATH, in `JS_RUNTIME_CANDIDATES` order. */
|
|
610
|
+
runtimes: string[];
|
|
611
|
+
/** Parsed backend version (`YYYY.M.D`), or null when it could not be parsed. */
|
|
612
|
+
version: string | null;
|
|
613
|
+
/** Idempotent, OS-specific remedy the OPERATOR runs. Never executed here. */
|
|
614
|
+
fix: string | null;
|
|
615
|
+
detail: string;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* The three facts about the live machine that a fixture cannot supply. Split
|
|
620
|
+
* out so the whole verdict — path resolution, the bounded config read, the
|
|
621
|
+
* version gate — is exercisable against real files with only these injected.
|
|
622
|
+
*/
|
|
623
|
+
export interface JsRuntimeObservation {
|
|
624
|
+
/** The backend binary's own probe verdict; readiness is decidable only at `ok`. */
|
|
625
|
+
backend_status: ToolProbeStatus;
|
|
626
|
+
/**
|
|
627
|
+
* First line of the backend's `--version` output, or null when none was
|
|
628
|
+
* read. NEVER reaches the payload: it is parsed against a strict numeric
|
|
629
|
+
* shape and only the parsed form is reported, so raw child output cannot
|
|
630
|
+
* travel into a report (the same discipline `probeTool` keeps by ignoring
|
|
631
|
+
* stdio entirely).
|
|
632
|
+
*/
|
|
633
|
+
version_raw: string | null;
|
|
634
|
+
/** JS runtimes found on PATH. */
|
|
635
|
+
runtimes: readonly string[];
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Resolve the yt-dlp USER config path the way yt-dlp itself resolves it:
|
|
640
|
+
* `$XDG_CONFIG_HOME/yt-dlp/config` when `XDG_CONFIG_HOME` is set and non-empty,
|
|
641
|
+
* otherwise `~/.config/yt-dlp/config`.
|
|
642
|
+
*
|
|
643
|
+
* THIS IS THE FAILURE WORTH PREVENTING. If the doctor read `~/.config/...`
|
|
644
|
+
* while the tool read `$XDG_CONFIG_HOME/...`, the doctor and the operator would
|
|
645
|
+
* agree with each other — "the flag is not there", "I added it, still broken" —
|
|
646
|
+
* while the real tool was reading a different file the whole time. A diagnostic
|
|
647
|
+
* that resolves a path differently from the tool it diagnoses is worse than no
|
|
648
|
+
* diagnostic, because it is confidently wrong.
|
|
649
|
+
*
|
|
650
|
+
* KNOWN GAP, not silently absorbed: on Windows yt-dlp ALSO honours
|
|
651
|
+
* `%APPDATA%\yt-dlp\config`. This resolver implements the XDG pair only, so on
|
|
652
|
+
* a win32 host with no `XDG_CONFIG_HOME` it inspects `~/.config/yt-dlp/config`
|
|
653
|
+
* — a location yt-dlp does read, but not the only one. The emitted fix targets
|
|
654
|
+
* the same path this check read, so the two never disagree with each other;
|
|
655
|
+
* they can, on win32, both be looking somewhere the operator's real config is
|
|
656
|
+
* not. Stated rather than papered over.
|
|
657
|
+
*/
|
|
658
|
+
export function resolveYtDlpConfigPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
659
|
+
const xdg = env['XDG_CONFIG_HOME'];
|
|
660
|
+
const base =
|
|
661
|
+
typeof xdg === 'string' && xdg.trim() !== ''
|
|
662
|
+
? expandHome(xdg)
|
|
663
|
+
: path.join(os.homedir(), '.config');
|
|
664
|
+
return path.join(base, 'yt-dlp', 'config');
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export interface ConfigFlagRead {
|
|
668
|
+
present: boolean;
|
|
669
|
+
flag: boolean;
|
|
670
|
+
/** Non-null when the path exists but was deliberately NOT read. */
|
|
671
|
+
refused: string | null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Bounded, symlink-refusing read for the literal flag.
|
|
676
|
+
*
|
|
677
|
+
* - `lstatSync`, never `statSync`: a symlink is REFUSED rather than followed.
|
|
678
|
+
* The doctor is asked to inspect a config file, and following a link would
|
|
679
|
+
* let something other than that file answer for it (the same reasoning as
|
|
680
|
+
* `confineCredentialPath` above, applied to a read instead of a stat).
|
|
681
|
+
* Non-regular files (directory, fifo, socket, device) are refused for the
|
|
682
|
+
* same reason — reading one can block forever.
|
|
683
|
+
* - The read is CAPPED at `READINESS_CONFIG_MAX_BYTES` via
|
|
684
|
+
* `openSync(path, 'r')` + `readSync`, not `readFileSync`: a config path that
|
|
685
|
+
* is really a 5 GB file must not become an OOM in a health report.
|
|
686
|
+
* - Only the flag's PRESENCE is extracted. The file's contents never reach the
|
|
687
|
+
* payload, so an operator's config (which can legitimately carry cookies
|
|
688
|
+
* paths, proxies, credentials) cannot leak through a diagnostic.
|
|
689
|
+
*/
|
|
690
|
+
export function readConfigFlag(
|
|
691
|
+
configPath: string,
|
|
692
|
+
maxBytes: number = READINESS_CONFIG_MAX_BYTES,
|
|
693
|
+
): ConfigFlagRead {
|
|
694
|
+
let stat: fs.Stats;
|
|
695
|
+
try {
|
|
696
|
+
stat = fs.lstatSync(configPath);
|
|
697
|
+
} catch {
|
|
698
|
+
return { present: false, flag: false, refused: null };
|
|
699
|
+
}
|
|
700
|
+
if (stat.isSymbolicLink()) {
|
|
701
|
+
return {
|
|
702
|
+
present: false,
|
|
703
|
+
flag: false,
|
|
704
|
+
refused: `${configPath} is a symlink — refused rather than followed, so nothing but that file itself can answer for it`,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
if (!stat.isFile()) {
|
|
708
|
+
return {
|
|
709
|
+
present: false,
|
|
710
|
+
flag: false,
|
|
711
|
+
refused: `${configPath} is not a regular file — not read`,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
let descriptor: number | null = null;
|
|
715
|
+
try {
|
|
716
|
+
descriptor = fs.openSync(configPath, 'r');
|
|
717
|
+
const buffer = Buffer.alloc(Math.min(maxBytes, Math.max(stat.size, 1)));
|
|
718
|
+
const read = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
719
|
+
const text = buffer.subarray(0, read).toString('utf-8');
|
|
720
|
+
return { present: true, flag: text.includes(JS_RUNTIME_FLAG), refused: null };
|
|
721
|
+
} catch {
|
|
722
|
+
return {
|
|
723
|
+
present: true,
|
|
724
|
+
flag: false,
|
|
725
|
+
refused: `${configPath} exists but could not be read`,
|
|
726
|
+
};
|
|
727
|
+
} finally {
|
|
728
|
+
if (descriptor !== null) {
|
|
729
|
+
try {
|
|
730
|
+
fs.closeSync(descriptor);
|
|
731
|
+
} catch {
|
|
732
|
+
// A descriptor that cannot be closed changes no verdict.
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Parse the `YYYY.M.D` release shape yt-dlp reports, tolerating the nightly
|
|
740
|
+
* fourth component (`2025.11.12.232946`). ANYTHING else returns null, which the
|
|
741
|
+
* caller reads as "cannot confirm" — never as "new enough". The nightly suffix
|
|
742
|
+
* is dropped from the reported version on purpose: the gate compares releases.
|
|
743
|
+
*/
|
|
744
|
+
const YT_DLP_VERSION_RE = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:\.\d+)?$/;
|
|
745
|
+
|
|
746
|
+
export function parseYtDlpVersion(raw: string | null): readonly [number, number, number] | null {
|
|
747
|
+
if (raw === null) return null;
|
|
748
|
+
const match = YT_DLP_VERSION_RE.exec(raw.trim());
|
|
749
|
+
if (match === null) return null;
|
|
750
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
751
|
+
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
return [year, month, day];
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function compareVersions(
|
|
758
|
+
left: readonly [number, number, number],
|
|
759
|
+
right: readonly [number, number, number],
|
|
760
|
+
): number {
|
|
761
|
+
for (let index = 0; index < 3; index += 1) {
|
|
762
|
+
const delta = (left[index] as number) - (right[index] as number);
|
|
763
|
+
if (delta !== 0) return delta;
|
|
764
|
+
}
|
|
765
|
+
return 0;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function shellQuote(value: string): string {
|
|
769
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function powershellQuote(value: string): string {
|
|
773
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* The IDEMPOTENT, OS-specific remedy. The doctor prints it; it never runs it
|
|
778
|
+
* (no install, no write — `non-destructive-by-default` plus the read-only
|
|
779
|
+
* witness).
|
|
780
|
+
*
|
|
781
|
+
* Idempotence is the whole design constraint: an operator may paste this twice,
|
|
782
|
+
* or into a provisioning script that runs on every boot, so it must create the
|
|
783
|
+
* directory if needed and append the flag ONLY when the line is not already
|
|
784
|
+
* there. On POSIX the `grep … || printf …` pair is wrapped in `{ …; }` because
|
|
785
|
+
* `A && B || C` would run C when A fails — the grouping is what keeps a failed
|
|
786
|
+
* `mkdir` from silently becoming an append attempt. The appended text is
|
|
787
|
+
* byte-identical to what `grep -qxF` matches, so the second run is a no-op.
|
|
788
|
+
*
|
|
789
|
+
* The path is the SAME one `readConfigFlag` inspected, so the check and the
|
|
790
|
+
* remedy can never point at different files.
|
|
791
|
+
*/
|
|
792
|
+
export function jsRuntimeFixCommand(
|
|
793
|
+
configPath: string,
|
|
794
|
+
platform: string = process.platform,
|
|
795
|
+
): string {
|
|
796
|
+
const line = `${JS_RUNTIME_FLAG} ${JS_RUNTIME_PRESCRIBED}`;
|
|
797
|
+
const directory = path.dirname(configPath);
|
|
798
|
+
if (platform === 'win32') {
|
|
799
|
+
return (
|
|
800
|
+
`New-Item -ItemType Directory -Force -Path ${powershellQuote(directory)} | Out-Null; ` +
|
|
801
|
+
`if (-not (Test-Path -LiteralPath ${powershellQuote(configPath)}) -or ` +
|
|
802
|
+
`-not (Select-String -LiteralPath ${powershellQuote(configPath)} -SimpleMatch ` +
|
|
803
|
+
`-Pattern ${powershellQuote(JS_RUNTIME_FLAG)} -Quiet)) ` +
|
|
804
|
+
`{ Add-Content -LiteralPath ${powershellQuote(configPath)} -Value ${powershellQuote(line)} }`
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
return (
|
|
808
|
+
`mkdir -p ${shellQuote(directory)} && ` +
|
|
809
|
+
`{ grep -qxF -- ${shellQuote(line)} ${shellQuote(configPath)} 2>/dev/null || ` +
|
|
810
|
+
`printf '%s\\n' ${shellQuote(line)} >> ${shellQuote(configPath)}; }`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Settle the js-runtime readiness question from one observation plus the real,
|
|
816
|
+
* locally-resolved config file. Pure with respect to the network; the only I/O
|
|
817
|
+
* is the bounded config read.
|
|
818
|
+
*
|
|
819
|
+
* Order of the branches is the contract:
|
|
820
|
+
*
|
|
821
|
+
* 1. backend not `ok` → `unknown`. Nothing about a runtime can make an
|
|
822
|
+
* uninstalled extractor work, so no readiness verdict is claimed; the
|
|
823
|
+
* channel's own `missing` / `broken` status and its pinned install
|
|
824
|
+
* prescription are the actionable output. The runtime and config
|
|
825
|
+
* observations are still reported — they were really made, and they tell
|
|
826
|
+
* the operator what will ALSO be needed after the install.
|
|
827
|
+
* 2. Deno on PATH → `ready`. yt-dlp enables Deno by default, so no config
|
|
828
|
+
* flag is required and the version gate is irrelevant.
|
|
829
|
+
* 3. No JS runtime at all → `not-ready`. No config edit fixes that, so no fix
|
|
830
|
+
* command is emitted for it — installing a runtime is the operator's
|
|
831
|
+
* decision and is not a prescription this registry pins.
|
|
832
|
+
* 4. Node-only: the version gate runs BEFORE the flag is considered, so a
|
|
833
|
+
* build that cannot support the flag is never told to add it, and an
|
|
834
|
+
* unparseable version can never read as `ready`.
|
|
835
|
+
*/
|
|
836
|
+
export function assessJsRuntimeReadiness(
|
|
837
|
+
observation: JsRuntimeObservation,
|
|
838
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
839
|
+
): ReadinessRow {
|
|
840
|
+
const configPath = resolveYtDlpConfigPath(env);
|
|
841
|
+
const read = readConfigFlag(configPath);
|
|
842
|
+
const runtimes = [...observation.runtimes];
|
|
843
|
+
const parsed = parseYtDlpVersion(observation.version_raw);
|
|
844
|
+
const version = parsed === null ? null : parsed.join('.');
|
|
845
|
+
const suffix = read.refused === null ? '' : ` (${read.refused})`;
|
|
846
|
+
|
|
847
|
+
const base = {
|
|
848
|
+
kind: 'js-runtime' as const,
|
|
849
|
+
config_path: configPath,
|
|
850
|
+
config_present: read.present,
|
|
851
|
+
config_flag: read.flag,
|
|
852
|
+
runtimes,
|
|
853
|
+
version,
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
if (observation.backend_status !== 'ok') {
|
|
857
|
+
return {
|
|
858
|
+
...base,
|
|
859
|
+
status: 'unknown',
|
|
860
|
+
version: null,
|
|
861
|
+
fix: null,
|
|
862
|
+
detail:
|
|
863
|
+
`the backend itself is ${observation.backend_status}, so extraction readiness is not evaluated yet — ` +
|
|
864
|
+
`install it first (the channel's pinned prescription above), then re-run; ` +
|
|
865
|
+
`the runtime and config facts below were still observed and will still apply${suffix}`,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (runtimes.includes('deno')) {
|
|
870
|
+
return {
|
|
871
|
+
...base,
|
|
872
|
+
status: 'ready',
|
|
873
|
+
fix: null,
|
|
874
|
+
detail: `deno is on PATH and yt-dlp enables it by default — no ${JS_RUNTIME_FLAG} entry is required${suffix}`,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (runtimes.length === 0) {
|
|
879
|
+
return {
|
|
880
|
+
...base,
|
|
881
|
+
status: 'not-ready',
|
|
882
|
+
fix: null,
|
|
883
|
+
detail:
|
|
884
|
+
`no external JavaScript runtime is on PATH (checked: ${JS_RUNTIME_CANDIDATES.join(', ')}) — ` +
|
|
885
|
+
`YouTube extraction needs one; installing Deno needs no yt-dlp config change${suffix}`,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (parsed === null) {
|
|
890
|
+
return {
|
|
891
|
+
...base,
|
|
892
|
+
status: 'unknown',
|
|
893
|
+
fix: null,
|
|
894
|
+
detail:
|
|
895
|
+
`cannot confirm this yt-dlp version supports ${JS_RUNTIME_FLAG}: --version reported no ` +
|
|
896
|
+
`YYYY.M.D release, so the ${JS_RUNTIME_MIN_VERSION.join('.')} gate cannot be applied — ` +
|
|
897
|
+
`upgrade first (the channel's pinned prescription above), then re-run${suffix}`,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (compareVersions(parsed, JS_RUNTIME_MIN_VERSION) < 0) {
|
|
902
|
+
return {
|
|
903
|
+
...base,
|
|
904
|
+
status: 'not-ready',
|
|
905
|
+
fix: null,
|
|
906
|
+
detail:
|
|
907
|
+
`yt-dlp ${version} is older than ${JS_RUNTIME_MIN_VERSION.join('.')}, the release that added ` +
|
|
908
|
+
`${JS_RUNTIME_FLAG} — this build would reject the flag, so upgrade first ` +
|
|
909
|
+
`(the channel's pinned prescription above) rather than editing the config${suffix}`,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (read.flag) {
|
|
914
|
+
return {
|
|
915
|
+
...base,
|
|
916
|
+
status: 'ready',
|
|
917
|
+
fix: null,
|
|
918
|
+
detail: `node is on PATH and ${configPath} carries ${JS_RUNTIME_FLAG}${suffix}`,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
...base,
|
|
924
|
+
status: 'not-ready',
|
|
925
|
+
fix: jsRuntimeFixCommand(configPath),
|
|
926
|
+
detail:
|
|
927
|
+
`node is the only JavaScript runtime on PATH and ${configPath} ` +
|
|
928
|
+
`${read.present ? `does not carry ${JS_RUNTIME_FLAG}` : `does not exist, so nothing carries ${JS_RUNTIME_FLAG}`} — ` +
|
|
929
|
+
`yt-dlp will not use node until it does${suffix}`,
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Probe the JS runtimes through the EXISTING probe machinery rather than a new
|
|
935
|
+
* spawn path: `probeTool` already hardens the child env, bounds the deadline,
|
|
936
|
+
* ignores stdio and never throws, so presence-on-PATH needs no second
|
|
937
|
+
* implementation.
|
|
938
|
+
*/
|
|
939
|
+
export function probeJsRuntimes(): string[] {
|
|
940
|
+
return JS_RUNTIME_CANDIDATES.filter(
|
|
941
|
+
(bin) =>
|
|
942
|
+
probeTool({ name: `js-runtime/${bin}`, bin, probe_args: ['--version'] }).status ===
|
|
943
|
+
'ok',
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Capture a backend's `--version` line.
|
|
949
|
+
*
|
|
950
|
+
* This is the ONE place readiness cannot reuse `probeTool`: that function
|
|
951
|
+
* deliberately ignores child stdio (its verdict is the exit status), and the
|
|
952
|
+
* version gate needs the string. So it is a separate `spawnSync` — routed
|
|
953
|
+
* through `hardenedSpawnEnv()` like every other spawn in this file (ADR-123),
|
|
954
|
+
* with the same bounded deadline, `stdio` piped for stdout ONLY, and a capped
|
|
955
|
+
* buffer. The argument list is the literal `['--version']` written here, never a
|
|
956
|
+
* registry value, so this cannot become a channel for a chosen subcommand.
|
|
957
|
+
*/
|
|
958
|
+
export function probeBackendVersion(probeCmd: string): string | null {
|
|
959
|
+
const result = spawnSync(probeCmd, ['--version'], {
|
|
960
|
+
env: hardenedSpawnEnv(),
|
|
961
|
+
timeout: VERSION_TIMEOUT_MS,
|
|
962
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
963
|
+
encoding: 'utf-8',
|
|
964
|
+
maxBuffer: 64 * 1024,
|
|
965
|
+
windowsHide: true,
|
|
966
|
+
});
|
|
967
|
+
if (result.error !== undefined || result.status !== 0) return null;
|
|
968
|
+
const first = (result.stdout ?? '').split('\n')[0]?.trim() ?? '';
|
|
969
|
+
return first === '' ? null : first;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Readiness verdict for one backend, or `null` when that backend declares no
|
|
974
|
+
* requirement. `Object.hasOwn`, never a bare index — same prototype-chain
|
|
975
|
+
* reasoning as `runDeepProbe`: `backendId: 'constructor'` must take the
|
|
976
|
+
* no-requirement path rather than resolving to an inherited value.
|
|
977
|
+
*/
|
|
978
|
+
export function liveReadinessRow(
|
|
979
|
+
backendId: string,
|
|
980
|
+
probedStatus: ToolProbeStatus,
|
|
981
|
+
): ReadinessRow | null {
|
|
982
|
+
const kind = Object.hasOwn(READINESS_REQUIREMENTS, backendId)
|
|
983
|
+
? READINESS_REQUIREMENTS[backendId]
|
|
984
|
+
: undefined;
|
|
985
|
+
if (kind === undefined) return null;
|
|
986
|
+
return assessJsRuntimeReadiness({
|
|
987
|
+
backend_status: probedStatus,
|
|
988
|
+
// Only worth a spawn when the binary answered its own probe.
|
|
989
|
+
version_raw: probedStatus === 'ok' ? probeBackendVersion(backendId) : null,
|
|
990
|
+
runtimes: probeJsRuntimes(),
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Injectable readiness observer. The default is `liveReadinessRow`; the option
|
|
996
|
+
* exists for the same reason `now` does — a fixture cannot install yt-dlp, and
|
|
997
|
+
* a machine without it could otherwise only ever exercise the `unknown` branch.
|
|
998
|
+
* TEST-FACING: nothing on the CLI surface can set it.
|
|
999
|
+
*/
|
|
1000
|
+
export type ReadinessProbeFn = (
|
|
1001
|
+
backendId: string,
|
|
1002
|
+
probedStatus: ToolProbeStatus,
|
|
1003
|
+
) => ReadinessRow | null;
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Build one channel row. Throws on a malformed channel — the caller turns
|
|
1007
|
+
* that into this channel's `error` row so the rest of the report survives.
|
|
1008
|
+
*
|
|
1009
|
+
* THE SCHEMA GATE IS `collect()`, NOT THIS FUNCTION. `collect()` runs
|
|
1010
|
+
* `validate_file` (schema + the cross-field `probe_cmd === id` rule) and refuses
|
|
1011
|
+
* the whole run before `load_registry`, this function, or any probe is reached.
|
|
1012
|
+
* This export exists for the tests that exercise the per-channel isolation layer
|
|
1013
|
+
* directly — no registry can get past that gate to reach it otherwise — so it is
|
|
1014
|
+
* TEST-FACING, not a public API. A direct caller is responsible for validating
|
|
1015
|
+
* its own input first; the entry gate and the `probe_cmd` check below are
|
|
1016
|
+
* defense in depth for exactly that case, not a substitute for the schema.
|
|
1017
|
+
*/
|
|
1018
|
+
export function buildChannelRow(
|
|
1019
|
+
raw: unknown,
|
|
1020
|
+
options: { deep: boolean; now: Date; readiness?: ReadinessProbeFn },
|
|
1021
|
+
): ChannelRow {
|
|
1022
|
+
const readinessProbe = options.readiness ?? liveReadinessRow;
|
|
1023
|
+
// Entry gate — defense in depth behind `collect()`'s schema validation. Each
|
|
1024
|
+
// shape a validated registry cannot produce is refused with a named reason
|
|
1025
|
+
// rather than read speculatively: a row built from an entry whose `id` or
|
|
1026
|
+
// `backends` had to be guessed would attribute probe results to a channel
|
|
1027
|
+
// nobody declared, which is worse than no row at all.
|
|
1028
|
+
const record = toRecord(raw);
|
|
1029
|
+
if (record === null) {
|
|
1030
|
+
throw new Error('channel entry is not a mapping');
|
|
1031
|
+
}
|
|
1032
|
+
const id = readString(record, 'id');
|
|
1033
|
+
if (id === null) {
|
|
1034
|
+
throw new Error('channel entry carries no id — a row can only be attributed by id');
|
|
1035
|
+
}
|
|
1036
|
+
const backendsRaw = record['backends'];
|
|
1037
|
+
if (!Array.isArray(backendsRaw)) {
|
|
1038
|
+
throw new Error(`channel '${id}': backends must be a non-empty list, got a non-list`);
|
|
1039
|
+
}
|
|
1040
|
+
if (backendsRaw.length === 0) {
|
|
1041
|
+
throw new Error(`channel '${id}': backends must be a non-empty list, got an empty one`);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const tier = readString(record, 'tier') ?? '(unset)';
|
|
1045
|
+
const lifecycle = readString(record, 'lifecycle') ?? '(unset)';
|
|
1046
|
+
const lastVerified = readString(record, 'last_verified') ?? '(unset)';
|
|
1047
|
+
const removalAfter = readString(record, 'removal_after');
|
|
1048
|
+
const replacement = readString(record, 'replacement');
|
|
1049
|
+
const credentialPath = readString(record, 'credential_path');
|
|
1050
|
+
|
|
1051
|
+
const warnings: string[] = [];
|
|
1052
|
+
const credential = credentialPath === null ? null : inspectCredential(credentialPath);
|
|
1053
|
+
if (credential !== null && credential.group_or_world_readable) {
|
|
1054
|
+
warnings.push(
|
|
1055
|
+
`credential file ${credential.path} is mode ${credential.mode ?? '?'} ` +
|
|
1056
|
+
`(group- or world-readable) — run: chmod 600 ${credential.path}`,
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
// A refusal is louder than a silent skip: an operator whose credential really
|
|
1060
|
+
// does live outside the permitted roots must see WHY nothing was checked,
|
|
1061
|
+
// instead of reading an unevaluated check as a passing one.
|
|
1062
|
+
if (
|
|
1063
|
+
credential !== null &&
|
|
1064
|
+
!credential.checked &&
|
|
1065
|
+
process.platform !== 'win32' &&
|
|
1066
|
+
!confineCredentialPath(credential.path)
|
|
1067
|
+
) {
|
|
1068
|
+
warnings.push(
|
|
1069
|
+
`credential_path ${credential.path} is outside the permitted roots ` +
|
|
1070
|
+
`(your home directory, this repo, or the system temp dir) — not inspected. ` +
|
|
1071
|
+
`Move the credential, or drop the field.`,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Past `removal_after` the channel is not probed at all: the migration
|
|
1076
|
+
// window closed, so its backends' health is no longer a question.
|
|
1077
|
+
//
|
|
1078
|
+
// This comparison IS string-wise, and that is sound rather than a shortcut:
|
|
1079
|
+
// `reach-channels.schema.json` pins `removal_after` (line 57) to
|
|
1080
|
+
// `^[0-9]{4}-[0-9]{2}-[0-9]{2}$` and `collect()` refuses the registry before
|
|
1081
|
+
// this line on any schema error, so both operands are fixed-width
|
|
1082
|
+
// zero-padded ISO dates — a form whose lexicographic order IS its
|
|
1083
|
+
// chronological order. `isoDate()` emits the same shape. (The staleness lint
|
|
1084
|
+
// takes the other route for the same field, parsing to epoch numbers; see
|
|
1085
|
+
// the note at `check_reach_staleness.ts` § (c).)
|
|
1086
|
+
const today = isoDate(options.now);
|
|
1087
|
+
const removed = removalAfter !== null && today > removalAfter;
|
|
1088
|
+
if (removed) {
|
|
1089
|
+
warnings.push(
|
|
1090
|
+
`removed: past removal_after ${removalAfter} — probe skipped` +
|
|
1091
|
+
(replacement === null
|
|
1092
|
+
? ' (no replacement declared)'
|
|
1093
|
+
: `; superseded by channel '${replacement}'`),
|
|
1094
|
+
);
|
|
1095
|
+
return {
|
|
1096
|
+
id,
|
|
1097
|
+
status: 'removed',
|
|
1098
|
+
active_backend: null,
|
|
1099
|
+
tier,
|
|
1100
|
+
lifecycle,
|
|
1101
|
+
last_verified: lastVerified,
|
|
1102
|
+
fix: null,
|
|
1103
|
+
warnings,
|
|
1104
|
+
credential,
|
|
1105
|
+
backends: [],
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
if (lifecycle === 'deprecated') {
|
|
1109
|
+
warnings.push(
|
|
1110
|
+
'deprecated: still probed for one migration window' +
|
|
1111
|
+
(replacement === null
|
|
1112
|
+
? ' (no replacement declared)'
|
|
1113
|
+
: `; superseded by channel '${replacement}'`) +
|
|
1114
|
+
(removalAfter === null ? '' : `, removal_after ${removalAfter}`),
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const backends: BackendRow[] = [];
|
|
1119
|
+
for (const backendRaw of backendsRaw) {
|
|
1120
|
+
const backend = toRecord(backendRaw);
|
|
1121
|
+
if (backend === null) {
|
|
1122
|
+
throw new Error(`channel '${id}': backend entry is not a mapping`);
|
|
1123
|
+
}
|
|
1124
|
+
const backendId = readString(backend, 'id');
|
|
1125
|
+
const probeCmd = readString(backend, 'probe_cmd');
|
|
1126
|
+
if (backendId === null || probeCmd === null) {
|
|
1127
|
+
throw new Error(`channel '${id}': backend entry needs id + probe_cmd`);
|
|
1128
|
+
}
|
|
1129
|
+
// Defense in depth behind the schema gate in `collect()`: even if a
|
|
1130
|
+
// registry reached here unvalidated, a row labelled `backendId` may
|
|
1131
|
+
// never carry the health of a different executable. Refusing is the
|
|
1132
|
+
// only safe answer — relabelling would hide which binary ran.
|
|
1133
|
+
if (backendId !== probeCmd) {
|
|
1134
|
+
throw new Error(
|
|
1135
|
+
`channel '${id}': backend '${backendId}' declares probe_cmd '${probeCmd}' — ` +
|
|
1136
|
+
`a backend may only probe its own id`,
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
const probeArgsRaw = backend['probe_args'];
|
|
1140
|
+
const probeArgs = Array.isArray(probeArgsRaw)
|
|
1141
|
+
? probeArgsRaw.filter((arg): arg is string => typeof arg === 'string')
|
|
1142
|
+
: [];
|
|
1143
|
+
// The `install` map IS the prescription: `probeTool` selects the
|
|
1144
|
+
// current platform's string (falling back to `default`) and echoes it.
|
|
1145
|
+
// Nothing here composes or edits an install command.
|
|
1146
|
+
const install = toRecord(backend['install']);
|
|
1147
|
+
const fixPrescription: FixPrescription | undefined =
|
|
1148
|
+
install === null ? undefined : (install as Record<string, string>);
|
|
1149
|
+
|
|
1150
|
+
const probed = probeTool({
|
|
1151
|
+
name: `${id}/${backendId}`,
|
|
1152
|
+
bin: probeCmd,
|
|
1153
|
+
probe_args: probeArgs,
|
|
1154
|
+
...(fixPrescription === undefined ? {} : { fix: fixPrescription }),
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
const readiness = readinessProbe(backendId, probed.status);
|
|
1158
|
+
|
|
1159
|
+
backends.push({
|
|
1160
|
+
id: backendId,
|
|
1161
|
+
probe_cmd: probeCmd,
|
|
1162
|
+
status: probed.status,
|
|
1163
|
+
path: probed.path,
|
|
1164
|
+
exit_code: probed.exit_code,
|
|
1165
|
+
diagnostic: probed.diagnostic,
|
|
1166
|
+
fix: probed.fix,
|
|
1167
|
+
deep: options.deep ? runDeepProbe(probeCmd, probed.status) : null,
|
|
1168
|
+
// Omitted entirely (not null) when the backend declares no
|
|
1169
|
+
// requirement — the four other channels gain no field at all.
|
|
1170
|
+
...(readiness === null ? {} : { readiness }),
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// Backend order is the switch: the first USABLE candidate wins. A backend
|
|
1175
|
+
// whose probe is `ok` but whose declared readiness requirement is not
|
|
1176
|
+
// confirmed satisfied is NOT usable — that is the whole point of the
|
|
1177
|
+
// readiness layer, and treating it as active would reintroduce the false
|
|
1178
|
+
// green (`yt-dlp --version` exits 0, the transcript pull still fails).
|
|
1179
|
+
const usable = (backend: BackendRow): boolean =>
|
|
1180
|
+
backend.status === 'ok' &&
|
|
1181
|
+
(backend.readiness === undefined || backend.readiness.status === 'ready');
|
|
1182
|
+
const active = backends.find(usable) ?? null;
|
|
1183
|
+
// With no healthy candidate the channel takes the FIRST candidate's
|
|
1184
|
+
// verdict — that is the one whose fix the operator is expected to run.
|
|
1185
|
+
const first = backends[0] as BackendRow;
|
|
1186
|
+
// `not-ready` = "installed and answering, but not confirmed able to satisfy
|
|
1187
|
+
// this channel". It covers BOTH readiness verdicts that are not `ready`:
|
|
1188
|
+
// `not-ready` (confirmed unusable) and `unknown` (not settled). One enum
|
|
1189
|
+
// value, because the distinction between them is already carried precisely
|
|
1190
|
+
// by `backends[].readiness.status` — and because reporting `ok` for an
|
|
1191
|
+
// unsettled readiness is exactly the false green this layer closes (see the
|
|
1192
|
+
// invariant block: an "installed, unverified" ceiling over a false green).
|
|
1193
|
+
const unready = backends.some(
|
|
1194
|
+
(backend) =>
|
|
1195
|
+
backend.status === 'ok' &&
|
|
1196
|
+
backend.readiness !== undefined &&
|
|
1197
|
+
backend.readiness.status !== 'ready',
|
|
1198
|
+
);
|
|
1199
|
+
const status: ChannelStatus =
|
|
1200
|
+
active !== null ? 'ok' : unready ? 'not-ready' : first.status;
|
|
1201
|
+
// Unchanged meaning: `fix` is the pinned INSTALL prescription. A readiness
|
|
1202
|
+
// remedy is a different kind of instruction (it edits an operator's config
|
|
1203
|
+
// rather than installing a pin) and lives in `backends[].readiness.fix`, so
|
|
1204
|
+
// one field never carries two meanings.
|
|
1205
|
+
const fix =
|
|
1206
|
+
active === null && (first.status === 'missing' || first.status === 'broken')
|
|
1207
|
+
? first.fix
|
|
1208
|
+
: null;
|
|
1209
|
+
|
|
1210
|
+
return {
|
|
1211
|
+
id,
|
|
1212
|
+
status,
|
|
1213
|
+
active_backend: active === null ? null : active.id,
|
|
1214
|
+
tier,
|
|
1215
|
+
lifecycle,
|
|
1216
|
+
last_verified: lastVerified,
|
|
1217
|
+
fix,
|
|
1218
|
+
warnings,
|
|
1219
|
+
credential,
|
|
1220
|
+
backends,
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
export function errorRow(id: string, err: unknown): ChannelRow {
|
|
1225
|
+
// `sanitizeParseError`, not `String(err)`: this was the one error→output path
|
|
1226
|
+
// in the reach scripts that did not go through it, and a YAMLParseError
|
|
1227
|
+
// stringifies with the offending source line plus a caret — no error message
|
|
1228
|
+
// reaching this payload may carry a line of the registry it came from.
|
|
1229
|
+
const message = sanitizeParseError(err);
|
|
1230
|
+
return {
|
|
1231
|
+
id,
|
|
1232
|
+
status: 'error',
|
|
1233
|
+
active_backend: null,
|
|
1234
|
+
tier: '(unknown)',
|
|
1235
|
+
lifecycle: '(unknown)',
|
|
1236
|
+
last_verified: '(unknown)',
|
|
1237
|
+
fix: null,
|
|
1238
|
+
warnings: [`channel could not be read: ${message}`],
|
|
1239
|
+
credential: null,
|
|
1240
|
+
backends: [],
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** Build the report payload. Throws only for the exit-2 class. */
|
|
1245
|
+
export function collect(options: CollectOptions = {}): ReachDoctorPayload {
|
|
1246
|
+
const registryPath = path.resolve(options.registryPath ?? REGISTRY_PATH);
|
|
1247
|
+
const deep = options.deep ?? false;
|
|
1248
|
+
const now = options.now ?? new Date();
|
|
1249
|
+
|
|
1250
|
+
// The CI refusal lives HERE, not only in the CLI: `collect` is exported and
|
|
1251
|
+
// takes `deep`, so an importer or a test calling `collect({ deep: true })`
|
|
1252
|
+
// would otherwise perform real network requests inside CI. `main()` keeps
|
|
1253
|
+
// its own check purely to phrase the operator-facing message.
|
|
1254
|
+
if (deep) {
|
|
1255
|
+
const ciMarker = detectCi();
|
|
1256
|
+
if (ciMarker !== null) {
|
|
1257
|
+
throw new ReachDoctorUsageError(
|
|
1258
|
+
`--deep performs real network requests and never runs in CI (${ciMarker} is set)`,
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// Enforce the registry schema BEFORE anything is spawned. Without this the
|
|
1264
|
+
// `--registry <path>` flag was an arbitrary-execution primitive: an
|
|
1265
|
+
// operator-supplied file declaring `probe_cmd: sh` + `probe_args: ['-c',
|
|
1266
|
+
// '<payload>']` was loaded straight into `probeTool()` and executed, and
|
|
1267
|
+
// the report printed `✅ ok`. Any error-severity finding — including the
|
|
1268
|
+
// cross-field `probe_cmd-binding` rule — refuses the whole run (exit 2);
|
|
1269
|
+
// a report is not worth producing from a file we cannot vouch for.
|
|
1270
|
+
const findings = validate_file(registryPath).filter(
|
|
1271
|
+
(finding) => finding.severity === 'error',
|
|
1272
|
+
);
|
|
1273
|
+
if (findings.length > 0) {
|
|
1274
|
+
// Redacted like every other echo of registry content. This was the FOURTH
|
|
1275
|
+
// site of the same class and the last one found: the schema validator
|
|
1276
|
+
// quotes the offending value in its message, so composing this refusal
|
|
1277
|
+
// detail raw meant `--registry <any file>` printed that file's matching
|
|
1278
|
+
// values straight back — the exact oracle the other three sites had
|
|
1279
|
+
// already closed. Found by re-running the leak probe against EVERY entry
|
|
1280
|
+
// point rather than assuming that fixing three had fixed the class.
|
|
1281
|
+
const detail = findings
|
|
1282
|
+
.slice(0, 10)
|
|
1283
|
+
.map(
|
|
1284
|
+
(finding) =>
|
|
1285
|
+
`${finding.path}: ${finding.rule}: ` +
|
|
1286
|
+
`${excerpt_for_finding(finding.message, SCHEMA_MESSAGE_MAX_CHARS)}`,
|
|
1287
|
+
)
|
|
1288
|
+
.join('; ');
|
|
1289
|
+
const more = findings.length > 10 ? ` (+${findings.length - 10} more)` : '';
|
|
1290
|
+
throw new RegistryLoadError(
|
|
1291
|
+
`registry fails its schema, refusing to probe it: ${registryPath}: ` +
|
|
1292
|
+
`${findings.length} violation(s): ${detail}${more}`,
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const data = toRecord(load_registry(registryPath));
|
|
1297
|
+
if (data === null) {
|
|
1298
|
+
throw new RegistryLoadError(`registry is not a mapping: ${registryPath}`);
|
|
1299
|
+
}
|
|
1300
|
+
const channelsRaw = data['channels'];
|
|
1301
|
+
if (!Array.isArray(channelsRaw)) {
|
|
1302
|
+
throw new RegistryLoadError(`registry declares no channels list: ${registryPath}`);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
let selected = channelsRaw;
|
|
1306
|
+
if (options.channel !== undefined) {
|
|
1307
|
+
const wanted = options.channel;
|
|
1308
|
+
selected = channelsRaw.filter((entry) => {
|
|
1309
|
+
const record = toRecord(entry);
|
|
1310
|
+
return record !== null && record['id'] === wanted;
|
|
1311
|
+
});
|
|
1312
|
+
if (selected.length === 0) {
|
|
1313
|
+
const known = channelsRaw
|
|
1314
|
+
.map((entry) => toRecord(entry)?.['id'])
|
|
1315
|
+
.filter((value): value is string => typeof value === 'string');
|
|
1316
|
+
throw new ReachDoctorUsageError(
|
|
1317
|
+
`unknown channel '${wanted}' — registry declares: ${known.join(', ')}`,
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const channels: ChannelRow[] = [];
|
|
1323
|
+
for (const [index, raw] of selected.entries()) {
|
|
1324
|
+
// Attribution before parsing: an unreadable entry still gets a row.
|
|
1325
|
+
const declaredId = toRecord(raw)?.['id'];
|
|
1326
|
+
const label = typeof declaredId === 'string' && declaredId !== ''
|
|
1327
|
+
? declaredId
|
|
1328
|
+
: `(channel #${index})`;
|
|
1329
|
+
try {
|
|
1330
|
+
channels.push(
|
|
1331
|
+
buildChannelRow(raw, {
|
|
1332
|
+
deep,
|
|
1333
|
+
now,
|
|
1334
|
+
...(options.readiness === undefined ? {} : { readiness: options.readiness }),
|
|
1335
|
+
}),
|
|
1336
|
+
);
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
channels.push(errorRow(label, err));
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const suggestions: string[] = [];
|
|
1343
|
+
if (deep) {
|
|
1344
|
+
const today = isoDate(now);
|
|
1345
|
+
for (const channel of channels) {
|
|
1346
|
+
const verified = channel.backends.filter((b) => b.deep?.status === 'ok');
|
|
1347
|
+
if (channel.status === 'ok' && verified.length > 0) {
|
|
1348
|
+
suggestions.push(
|
|
1349
|
+
`channels[${channel.id}].last_verified: "${today}" ` +
|
|
1350
|
+
`(deep-verified: ${verified.map((b) => b.id).join(', ')})`,
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const rel = path.relative(ROOT, registryPath);
|
|
1357
|
+
return {
|
|
1358
|
+
schema_version: SCHEMA_VERSION,
|
|
1359
|
+
platform: process.platform,
|
|
1360
|
+
registry: rel !== '' && !rel.startsWith('..') ? rel : registryPath,
|
|
1361
|
+
deep,
|
|
1362
|
+
channels,
|
|
1363
|
+
last_verified_suggestions: suggestions,
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const STATUS_MARKER: Readonly<Record<ChannelStatus, string>> = {
|
|
1368
|
+
ok: '✅ ',
|
|
1369
|
+
missing: '❌ ',
|
|
1370
|
+
broken: '❌ ',
|
|
1371
|
+
timeout: '⚠️ ',
|
|
1372
|
+
error: '⚠️ ',
|
|
1373
|
+
removed: '· ',
|
|
1374
|
+
// Distinct from `missing`'s ❌ on purpose: the binary IS installed, so the
|
|
1375
|
+
// operator's next action is a config/upgrade step, not an install.
|
|
1376
|
+
'not-ready': '⚠️ ',
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
export function renderTable(payload: ReachDoctorPayload): string {
|
|
1380
|
+
const lines: string[] = [];
|
|
1381
|
+
lines.push('Reach channels — read-only health report');
|
|
1382
|
+
lines.push('-'.repeat(72));
|
|
1383
|
+
lines.push(`registry: ${payload.registry}`);
|
|
1384
|
+
lines.push(`platform: ${payload.platform}`);
|
|
1385
|
+
lines.push(
|
|
1386
|
+
`mode: ${payload.deep ? 'deep (one real network request per declared backend)' : 'shallow (local probes only, no network)'}`,
|
|
1387
|
+
);
|
|
1388
|
+
lines.push('');
|
|
1389
|
+
lines.push('Not a routing table: no channel below is routed, preferred, or');
|
|
1390
|
+
lines.push('suggested to an agent (Phase 0 benchmark band: stop).');
|
|
1391
|
+
lines.push('');
|
|
1392
|
+
for (const channel of payload.channels) {
|
|
1393
|
+
lines.push(
|
|
1394
|
+
`${STATUS_MARKER[channel.status]}${channel.id.padEnd(14)} ${channel.status.padEnd(8)} ` +
|
|
1395
|
+
`backend ${(channel.active_backend ?? '—').padEnd(8)} ` +
|
|
1396
|
+
`tier ${channel.tier.padEnd(11)} ${channel.lifecycle.padEnd(12)} ` +
|
|
1397
|
+
`verified ${channel.last_verified}`,
|
|
1398
|
+
);
|
|
1399
|
+
for (const backend of channel.backends) {
|
|
1400
|
+
const mark = backend.status === 'ok' ? '✅' : '❌';
|
|
1401
|
+
lines.push(` ${mark} ${backend.id.padEnd(10)} ${backend.diagnostic}`);
|
|
1402
|
+
if (backend.deep !== null) {
|
|
1403
|
+
lines.push(` deep: ${backend.deep.status} — ${backend.deep.detail}`);
|
|
1404
|
+
}
|
|
1405
|
+
// Rendered for BOTH the ready and the not-ready case: an operator
|
|
1406
|
+
// reading "installed" needs to see that the second question was
|
|
1407
|
+
// asked at all, otherwise a silent pass is indistinguishable from
|
|
1408
|
+
// a check that never ran.
|
|
1409
|
+
if (backend.readiness !== undefined) {
|
|
1410
|
+
const readiness = backend.readiness;
|
|
1411
|
+
lines.push(
|
|
1412
|
+
` readiness (${readiness.kind}): ${readiness.status} — ${readiness.detail}`,
|
|
1413
|
+
);
|
|
1414
|
+
lines.push(
|
|
1415
|
+
` readiness config: ${readiness.config_path} ` +
|
|
1416
|
+
`(${readiness.config_present ? 'present' : 'not a readable file'}, ` +
|
|
1417
|
+
`${JS_RUNTIME_FLAG}: ${readiness.config_flag ? 'yes' : 'no'}) · ` +
|
|
1418
|
+
`runtimes on PATH: ${readiness.runtimes.length === 0 ? 'none' : readiness.runtimes.join(', ')} · ` +
|
|
1419
|
+
// `null` covers both "never probed" and "unparseable",
|
|
1420
|
+
// so the label claims neither — `detail` says which.
|
|
1421
|
+
`version: ${readiness.version ?? 'not confirmed'}`,
|
|
1422
|
+
);
|
|
1423
|
+
if (readiness.fix !== null) {
|
|
1424
|
+
lines.push(` readiness fix (${payload.platform}): ${readiness.fix}`);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
if (channel.fix !== null) {
|
|
1429
|
+
lines.push(` fix (${payload.platform}): ${channel.fix}`);
|
|
1430
|
+
}
|
|
1431
|
+
if (channel.credential !== null) {
|
|
1432
|
+
const cred = channel.credential;
|
|
1433
|
+
const state = !cred.checked
|
|
1434
|
+
? 'not checked on this platform'
|
|
1435
|
+
: !cred.present
|
|
1436
|
+
? 'declared but not present'
|
|
1437
|
+
: `mode ${cred.mode ?? '?'}`;
|
|
1438
|
+
lines.push(` credential: ${cred.path} (${state})`);
|
|
1439
|
+
}
|
|
1440
|
+
for (const warning of channel.warnings) {
|
|
1441
|
+
lines.push(` ⚠️ ${warning}`);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
if (payload.last_verified_suggestions.length > 0) {
|
|
1445
|
+
lines.push('');
|
|
1446
|
+
lines.push('last_verified lines you MAY commit (nothing was written):');
|
|
1447
|
+
lines.push('-'.repeat(72));
|
|
1448
|
+
for (const suggestion of payload.last_verified_suggestions) {
|
|
1449
|
+
lines.push(` ${suggestion}`);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
return lines.join('\n');
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* `--strict` fails on any **admitted** channel that is not `ok`. A `removed`
|
|
1457
|
+
* channel is not admitted — its migration window closed, so it is excluded by
|
|
1458
|
+
* design rather than treated as a failure.
|
|
1459
|
+
*
|
|
1460
|
+
* A credential-permission warning does NOT change the exit code: the flag's
|
|
1461
|
+
* contract is "non-zero when a channel is not ok", and widening it silently
|
|
1462
|
+
* would make `--strict` mean something the roadmap never specified. The
|
|
1463
|
+
* warning is surfaced in both output formats instead.
|
|
1464
|
+
*/
|
|
1465
|
+
export function finalExitCode(payload: ReachDoctorPayload, strict: boolean): number {
|
|
1466
|
+
if (!strict) return 0;
|
|
1467
|
+
const failing = payload.channels.filter(
|
|
1468
|
+
(channel) => channel.status !== 'ok' && channel.status !== 'removed',
|
|
1469
|
+
);
|
|
1470
|
+
return failing.length > 0 ? 1 : 0;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const USAGE = `reach:doctor — read-only health report over the reach channel registry
|
|
1474
|
+
|
|
1475
|
+
Answers two operator questions per channel: is the upstream backend I already
|
|
1476
|
+
chose to install healthy and pinned — and, where the backend declares a
|
|
1477
|
+
readiness requirement, is it actually able to retrieve, or merely installed?
|
|
1478
|
+
A channel reported not-ready is installed and answering its probe while a
|
|
1479
|
+
config-semantic requirement is unsatisfied or unconfirmed; the remedy is
|
|
1480
|
+
printed, never run. It is NOT a router and NOT an agent-facing
|
|
1481
|
+
recommendation — no channel it prints is routed, preferred, or suggested to
|
|
1482
|
+
an agent.
|
|
1483
|
+
|
|
1484
|
+
Usage:
|
|
1485
|
+
agent-config reach:doctor [options]
|
|
1486
|
+
|
|
1487
|
+
Options:
|
|
1488
|
+
--format json|table Output shape (default: table). JSON validates against
|
|
1489
|
+
src/scripts/schemas/reach-doctor-payload.schema.json.
|
|
1490
|
+
--strict Exit 1 when any admitted channel is not ok (for CI).
|
|
1491
|
+
--channel <id> Report a single channel.
|
|
1492
|
+
--registry <path> Read an alternate registry file. It is validated
|
|
1493
|
+
against reach-channels.schema.json first (plus
|
|
1494
|
+
probe_cmd === id); any violation refuses the run with
|
|
1495
|
+
exit 2 rather than probing an unvouched-for file.
|
|
1496
|
+
--deep ⚠️ NETWORK. Opt-in only. Sends ONE real, read-only
|
|
1497
|
+
request per backend that declares a deep probe (a
|
|
1498
|
+
rate-limit read, or a HEAD to example.com) to confirm
|
|
1499
|
+
the backend really reaches its endpoint. Refuses to
|
|
1500
|
+
run in CI. Writes NOTHING: it prints the
|
|
1501
|
+
last_verified lines you may choose to commit.
|
|
1502
|
+
WHAT YOU ARE CONSENTING TO: a backend's own rate-limit
|
|
1503
|
+
read authenticates with whatever credential that tool
|
|
1504
|
+
already holds, so running --deep presents your existing
|
|
1505
|
+
token to that third party and appears in their logs.
|
|
1506
|
+
Nothing new is stored and no credential is read by this
|
|
1507
|
+
command — but the request is made on your behalf, which
|
|
1508
|
+
is why it is opt-in and never automatic.
|
|
1509
|
+
--help Show this help.
|
|
1510
|
+
|
|
1511
|
+
Without --deep the command touches no network at all: the only child
|
|
1512
|
+
processes are the registry's side-effect-free local probes. It never installs
|
|
1513
|
+
anything and never writes a file, in either mode.
|
|
1514
|
+
|
|
1515
|
+
Exit codes: 0 ok · 1 --strict violation · 2 usage / unusable or schema-invalid
|
|
1516
|
+
registry / --deep inside CI.`;
|
|
1517
|
+
|
|
1518
|
+
interface ParsedArgs {
|
|
1519
|
+
format: 'table' | 'json';
|
|
1520
|
+
strict: boolean;
|
|
1521
|
+
deep: boolean;
|
|
1522
|
+
help: boolean;
|
|
1523
|
+
channel: string | undefined;
|
|
1524
|
+
registry: string | undefined;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
export function parseArgs(argv: readonly string[]): ParsedArgs {
|
|
1528
|
+
const parsed: ParsedArgs = {
|
|
1529
|
+
format: 'table',
|
|
1530
|
+
strict: false,
|
|
1531
|
+
deep: false,
|
|
1532
|
+
help: false,
|
|
1533
|
+
channel: undefined,
|
|
1534
|
+
registry: undefined,
|
|
1535
|
+
};
|
|
1536
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1537
|
+
const arg = argv[index];
|
|
1538
|
+
if (arg === '--format') {
|
|
1539
|
+
const value = argv[index + 1];
|
|
1540
|
+
if (value !== 'json' && value !== 'table') {
|
|
1541
|
+
throw new ReachDoctorUsageError('--format expects json|table');
|
|
1542
|
+
}
|
|
1543
|
+
parsed.format = value;
|
|
1544
|
+
index += 1;
|
|
1545
|
+
} else if (arg === '--channel') {
|
|
1546
|
+
const value = argv[index + 1];
|
|
1547
|
+
if (value === undefined || value.startsWith('-')) {
|
|
1548
|
+
throw new ReachDoctorUsageError('--channel expects a channel id');
|
|
1549
|
+
}
|
|
1550
|
+
parsed.channel = value;
|
|
1551
|
+
index += 1;
|
|
1552
|
+
} else if (arg === '--registry') {
|
|
1553
|
+
const value = argv[index + 1];
|
|
1554
|
+
if (value === undefined || value.startsWith('-')) {
|
|
1555
|
+
throw new ReachDoctorUsageError('--registry expects a path');
|
|
1556
|
+
}
|
|
1557
|
+
parsed.registry = value;
|
|
1558
|
+
index += 1;
|
|
1559
|
+
} else if (arg === '--strict') {
|
|
1560
|
+
parsed.strict = true;
|
|
1561
|
+
} else if (arg === '--deep') {
|
|
1562
|
+
parsed.deep = true;
|
|
1563
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
1564
|
+
parsed.help = true;
|
|
1565
|
+
} else {
|
|
1566
|
+
throw new ReachDoctorUsageError(`unknown argument '${String(arg)}'`);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
return parsed;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/** CI markers that make `--deep` refuse: network in CI is never wanted here. */
|
|
1573
|
+
export function detectCi(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
1574
|
+
for (const key of ['CI', 'GITHUB_ACTIONS', 'AGENT_CONFIG_CI']) {
|
|
1575
|
+
const value = env[key];
|
|
1576
|
+
if (value !== undefined && value !== '' && value !== '0' && value !== 'false') {
|
|
1577
|
+
return key;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
return null;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
1584
|
+
let args: ParsedArgs;
|
|
1585
|
+
try {
|
|
1586
|
+
args = parseArgs(argv);
|
|
1587
|
+
} catch (err) {
|
|
1588
|
+
process.stderr.write(`reach_doctor: ${(err as Error).message}\n\n${USAGE}\n`);
|
|
1589
|
+
return 2;
|
|
1590
|
+
}
|
|
1591
|
+
if (args.help) {
|
|
1592
|
+
process.stdout.write(`${USAGE}\n`);
|
|
1593
|
+
return 0;
|
|
1594
|
+
}
|
|
1595
|
+
if (args.deep) {
|
|
1596
|
+
const ciMarker = detectCi();
|
|
1597
|
+
if (ciMarker !== null) {
|
|
1598
|
+
process.stderr.write(
|
|
1599
|
+
`reach_doctor: --deep performs real network requests and never runs in CI ` +
|
|
1600
|
+
`(${ciMarker} is set). Run it locally, as an operator.\n`,
|
|
1601
|
+
);
|
|
1602
|
+
return 2;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
let payload: ReachDoctorPayload;
|
|
1607
|
+
try {
|
|
1608
|
+
payload = collect({
|
|
1609
|
+
deep: args.deep,
|
|
1610
|
+
...(args.channel === undefined ? {} : { channel: args.channel }),
|
|
1611
|
+
...(args.registry === undefined ? {} : { registryPath: args.registry }),
|
|
1612
|
+
});
|
|
1613
|
+
} catch (err) {
|
|
1614
|
+
if (err instanceof RegistryLoadError || err instanceof ReachDoctorUsageError) {
|
|
1615
|
+
process.stderr.write(`reach_doctor: ${err.message}\n`);
|
|
1616
|
+
return 2;
|
|
1617
|
+
}
|
|
1618
|
+
throw err;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (args.format === 'json') {
|
|
1622
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
1623
|
+
} else {
|
|
1624
|
+
process.stdout.write(`${renderTable(payload)}\n`);
|
|
1625
|
+
}
|
|
1626
|
+
return finalExitCode(payload, args.strict);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function _isCliEntry(): boolean {
|
|
1630
|
+
if (!process.argv[1]) {
|
|
1631
|
+
return false;
|
|
1632
|
+
}
|
|
1633
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
1634
|
+
if (import.meta.url === argvUrl) {
|
|
1635
|
+
return true;
|
|
1636
|
+
}
|
|
1637
|
+
// A symlinked invocation makes the raw URLs differ (import.meta.url is the
|
|
1638
|
+
// resolved real path while argv[1] keeps the symlink path) — compare
|
|
1639
|
+
// realpaths so the CLI still fires when run through a projection symlink.
|
|
1640
|
+
try {
|
|
1641
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
1642
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
1643
|
+
return here === argv;
|
|
1644
|
+
} catch {
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
if (_isCliEntry()) {
|
|
1650
|
+
process.exit(main());
|
|
1651
|
+
}
|