upstream-radar 0.32.0 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +616 -43
  2. package/cordis.patch.yml +1 -0
  3. package/dist/src/cli.js +1123 -34
  4. package/dist/src/cli.js.map +1 -1
  5. package/dist/src/compatibility.d.ts +3 -1
  6. package/dist/src/compatibility.d.ts.map +1 -1
  7. package/dist/src/compatibility.js +146 -12
  8. package/dist/src/compatibility.js.map +1 -1
  9. package/dist/src/demo.d.ts +13 -0
  10. package/dist/src/demo.d.ts.map +1 -0
  11. package/dist/src/demo.js +85 -0
  12. package/dist/src/demo.js.map +1 -0
  13. package/dist/src/doctor.d.ts.map +1 -1
  14. package/dist/src/doctor.js +62 -0
  15. package/dist/src/doctor.js.map +1 -1
  16. package/dist/src/dsh-analysis.d.ts.map +1 -1
  17. package/dist/src/dsh-analysis.js +2 -1
  18. package/dist/src/dsh-analysis.js.map +1 -1
  19. package/dist/src/dsh-plugin.d.ts +9 -3
  20. package/dist/src/dsh-plugin.d.ts.map +1 -1
  21. package/dist/src/dsh-plugin.js +87 -11
  22. package/dist/src/dsh-plugin.js.map +1 -1
  23. package/dist/src/dsh-profile-check.d.ts +79 -0
  24. package/dist/src/dsh-profile-check.d.ts.map +1 -0
  25. package/dist/src/dsh-profile-check.js +600 -0
  26. package/dist/src/dsh-profile-check.js.map +1 -0
  27. package/dist/src/dsh-runtime.d.ts +7 -0
  28. package/dist/src/dsh-runtime.d.ts.map +1 -1
  29. package/dist/src/dsh-runtime.js +21 -0
  30. package/dist/src/dsh-runtime.js.map +1 -1
  31. package/dist/src/github-advisory.d.ts +25 -0
  32. package/dist/src/github-advisory.d.ts.map +1 -0
  33. package/dist/src/github-advisory.js +340 -0
  34. package/dist/src/github-advisory.js.map +1 -0
  35. package/dist/src/graph.d.ts +2 -0
  36. package/dist/src/graph.d.ts.map +1 -1
  37. package/dist/src/graph.js +600 -4
  38. package/dist/src/graph.js.map +1 -1
  39. package/dist/src/index.d.ts +16 -6
  40. package/dist/src/index.d.ts.map +1 -1
  41. package/dist/src/index.js +15 -5
  42. package/dist/src/index.js.map +1 -1
  43. package/dist/src/init.d.ts +44 -2
  44. package/dist/src/init.d.ts.map +1 -1
  45. package/dist/src/init.js +62 -5
  46. package/dist/src/init.js.map +1 -1
  47. package/dist/src/installed-graph.d.ts +3 -1
  48. package/dist/src/installed-graph.d.ts.map +1 -1
  49. package/dist/src/installed-graph.js +101 -2
  50. package/dist/src/installed-graph.js.map +1 -1
  51. package/dist/src/inventory.d.ts.map +1 -1
  52. package/dist/src/inventory.js +76 -2
  53. package/dist/src/inventory.js.map +1 -1
  54. package/dist/src/notification-policy.d.ts +20 -0
  55. package/dist/src/notification-policy.d.ts.map +1 -0
  56. package/dist/src/notification-policy.js +87 -0
  57. package/dist/src/notification-policy.js.map +1 -0
  58. package/dist/src/npm-release.d.ts.map +1 -1
  59. package/dist/src/npm-release.js +18 -7
  60. package/dist/src/npm-release.js.map +1 -1
  61. package/dist/src/npm.d.ts.map +1 -1
  62. package/dist/src/npm.js +9 -1
  63. package/dist/src/npm.js.map +1 -1
  64. package/dist/src/osv.d.ts.map +1 -1
  65. package/dist/src/osv.js +1 -0
  66. package/dist/src/osv.js.map +1 -1
  67. package/dist/src/quickstart.d.ts +31 -0
  68. package/dist/src/quickstart.d.ts.map +1 -0
  69. package/dist/src/quickstart.js +266 -0
  70. package/dist/src/quickstart.js.map +1 -0
  71. package/dist/src/radar-history.d.ts +22 -0
  72. package/dist/src/radar-history.d.ts.map +1 -0
  73. package/dist/src/radar-history.js +80 -0
  74. package/dist/src/radar-history.js.map +1 -0
  75. package/dist/src/radar-render.d.ts.map +1 -1
  76. package/dist/src/radar-render.js +106 -1
  77. package/dist/src/radar-render.js.map +1 -1
  78. package/dist/src/radar-state.d.ts.map +1 -1
  79. package/dist/src/radar-state.js +264 -6
  80. package/dist/src/radar-state.js.map +1 -1
  81. package/dist/src/radar-status.d.ts +36 -1
  82. package/dist/src/radar-status.d.ts.map +1 -1
  83. package/dist/src/radar-status.js +199 -16
  84. package/dist/src/radar-status.js.map +1 -1
  85. package/dist/src/radar-types.d.ts +128 -3
  86. package/dist/src/radar-types.d.ts.map +1 -1
  87. package/dist/src/radar-types.js +4 -0
  88. package/dist/src/radar-types.js.map +1 -1
  89. package/dist/src/radar.d.ts +7 -1
  90. package/dist/src/radar.d.ts.map +1 -1
  91. package/dist/src/radar.js +432 -70
  92. package/dist/src/radar.js.map +1 -1
  93. package/dist/src/render.d.ts.map +1 -1
  94. package/dist/src/render.js +27 -2
  95. package/dist/src/render.js.map +1 -1
  96. package/dist/src/scan.d.ts.map +1 -1
  97. package/dist/src/scan.js +39 -0
  98. package/dist/src/scan.js.map +1 -1
  99. package/dist/src/threat-intel.d.ts +46 -0
  100. package/dist/src/threat-intel.d.ts.map +1 -0
  101. package/dist/src/threat-intel.js +243 -0
  102. package/dist/src/threat-intel.js.map +1 -0
  103. package/dist/src/upstream-observer.d.ts +224 -0
  104. package/dist/src/upstream-observer.d.ts.map +1 -0
  105. package/dist/src/upstream-observer.js +1342 -0
  106. package/dist/src/upstream-observer.js.map +1 -0
  107. package/dist/src/version.d.ts +1 -1
  108. package/dist/src/version.js +1 -1
  109. package/dist/src/vulnerability-priority.d.ts +16 -0
  110. package/dist/src/vulnerability-priority.d.ts.map +1 -0
  111. package/dist/src/vulnerability-priority.js +20 -0
  112. package/dist/src/vulnerability-priority.js.map +1 -0
  113. package/dist/src/webhook.d.ts +84 -0
  114. package/dist/src/webhook.d.ts.map +1 -0
  115. package/dist/src/webhook.js +432 -0
  116. package/dist/src/webhook.js.map +1 -0
  117. package/docs/README.zh-CN.md +593 -40
  118. package/package.json +30 -3
  119. package/schemas/quickstart.schema.json +56 -0
  120. package/schemas/webhook.schema.json +53 -0
package/dist/src/cli.js CHANGED
@@ -1,72 +1,453 @@
1
1
  #!/usr/bin/env node
2
2
  import process from 'node:process';
3
3
  import { spawnSync } from 'node:child_process';
4
- import { access, readFile } from 'node:fs/promises';
5
- import { resolve } from 'node:path';
4
+ import { access, readFile, writeFile } from 'node:fs/promises';
5
+ import { dirname, join, resolve } from 'node:path';
6
6
  import { renderCompatibilityBenchmark, runCompatibilityBenchmark } from './compatibility-benchmark.js';
7
7
  import { assessCompatibilityChange } from './compatibility.js';
8
8
  import { probeDshLoad, probeDshLoadMatrix, renderDshLoadMatrix, renderDshLoadProbe } from './dsh-probe.js';
9
9
  import { createAnalysisTask, renderAgentAnalysisPrompt } from './dsh-analysis.js';
10
10
  import { createDoctorReport, renderDoctorReport } from './doctor.js';
11
+ import { checkDshProfile, renderDshProfileCheck, renderDshProfileCheckSummary } from './dsh-profile-check.js';
12
+ import { createDemoReport, renderDemo } from './demo.js';
11
13
  import { GitHubReleaseClient } from './github-release.js';
12
- import { createRadarConfigFromDshProfile, discoverDshProfiles, refreshRadarConfigFromConfiguredProfile, resolveDshProfileDirectory, writeDshPatch, writeRadarConfig } from './init.js';
14
+ import { parseNpmLockGraph, parsePnpmLockGraph } from './graph.js';
15
+ import { createRadarConfigFromDshProfile, createRadarConfigFromNpmLock, createRadarConfigFromPnpmLock, discoverDshProfiles, refreshRadarConfigFromConfiguredProfile, resolveDshProfileDirectory, writeDshPatch, writeRadarConfig } from './init.js';
13
16
  import { parsePackageManifestSnapshot, parseRadarConfig } from './inventory.js';
14
17
  import { inspectNpmPackage } from './npm.js';
15
18
  import { NpmCandidateGraphClient } from './npm-candidate.js';
16
19
  import { NpmReleaseClient } from './npm-release.js';
20
+ import { createNotificationPolicyMap, filterNotifiableRadarEvents } from './notification-policy.js';
17
21
  import { OsvClient } from './osv.js';
18
22
  import { verdictAtLeast } from './policy.js';
23
+ import { createQuickstartReport, renderQuickstartReport } from './quickstart.js';
24
+ import { GitHubAdvisoryClient } from './github-advisory.js';
19
25
  import { emptyRadarState, pollRadar } from './radar.js';
20
26
  import { evaluateRadarPolicy, RADAR_COMPATIBILITY_FAIL_THRESHOLDS, RADAR_FAIL_THRESHOLDS, renderRadarPolicy, } from './radar-policy.js';
21
27
  import { renderRadarEvents } from './radar-render.js';
28
+ import { createRadarHistory, renderRadarHistory } from './radar-history.js';
22
29
  import { loadRadarState, saveRadarState } from './radar-state.js';
23
- import { createRadarStatus, renderRadarStatus } from './radar-status.js';
30
+ import { createRadarNext, createRadarStatus, renderRadarNext, renderRadarStatus } from './radar-status.js';
24
31
  import { renderTextReport } from './render.js';
25
32
  import { scanDirectory } from './scan.js';
33
+ import { CisaKevClient, EpssClient } from './threat-intel.js';
34
+ import { loadObservationState, parseObserverConfigText, renderObserverReport, runDshAgentCommand, runOpenAiCompatibleAgent, runObserver, saveObservationState, UpstreamObserverClient, } from './upstream-observer.js';
26
35
  import { TOOL_VERSION } from './version.js';
36
+ import { eventsForRadarWebhookTarget, markRadarWebhookEventsDelivered, markRadarWebhookEventsDeliveredForRoute, normalizeRadarWebhookUrl, queueRadarWebhookEvents, queueRadarWebhookEventsForRoute, resolveRadarWebhookTargets, sendRadarWebhook, undeliveredRadarWebhookEvents, undeliveredRadarWebhookEventsForRoute, } from './webhook.js';
27
37
  const VALID_THRESHOLDS = new Set(['warn', 'review', 'block', 'never']);
28
38
  const VALID_RADAR_THRESHOLDS = new Set(RADAR_FAIL_THRESHOLDS);
29
39
  const VALID_RADAR_COMPATIBILITY_THRESHOLDS = new Set(RADAR_COMPATIBILITY_FAIL_THRESHOLDS);
40
+ const VALID_NOTIFICATION_SEVERITIES = new Set(['info', 'low', 'medium', 'high', 'critical']);
30
41
  function safeErrorMessage(value, maxLength = 2_048) {
31
42
  return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, character => (`\\u${character.codePointAt(0)?.toString(16).padStart(4, '0') ?? '0000'}`)).slice(0, maxLength);
32
43
  }
33
44
  function shellQuote(value) {
34
45
  return `'${value.replaceAll("'", "'\\''")}'`;
35
46
  }
47
+ function parseNotificationPolicyArguments(minimumSeverity, quietHoursValue) {
48
+ if (minimumSeverity === undefined && quietHoursValue === undefined)
49
+ return undefined;
50
+ if (minimumSeverity !== undefined && !VALID_NOTIFICATION_SEVERITIES.has(minimumSeverity)) {
51
+ throw new Error('--minimum-severity must be info, low, medium, high or critical');
52
+ }
53
+ let quietHours;
54
+ if (quietHoursValue !== undefined) {
55
+ const comma = quietHoursValue.indexOf(',');
56
+ const interval = comma < 0 ? '' : quietHoursValue.slice(comma + 1);
57
+ const dash = interval.indexOf('-');
58
+ const timezone = comma < 1 ? '' : quietHoursValue.slice(0, comma);
59
+ const start = dash < 1 ? '' : interval.slice(0, dash);
60
+ const end = dash < 0 ? '' : interval.slice(dash + 1);
61
+ if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(start)
62
+ || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(end)
63
+ || start === end
64
+ || timezone.length === 0) {
65
+ throw new Error('--quiet-hours must use <IANA timezone>,<HH:MM>-<HH:MM> with different start and end times');
66
+ }
67
+ try {
68
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format();
69
+ }
70
+ catch {
71
+ throw new Error('--quiet-hours must start with a valid IANA timezone');
72
+ }
73
+ quietHours = { timezone, start, end };
74
+ }
75
+ return {
76
+ ...(minimumSeverity === undefined ? {} : { minimumSeverity: minimumSeverity }),
77
+ ...(quietHours === undefined ? {} : { quietHours }),
78
+ };
79
+ }
80
+ function commandUsage(key) {
81
+ const help = {
82
+ setup: `Upstream Radar — install the exact Radar bundle into DSH and prepare monitoring
83
+
84
+ Usage:
85
+ upstream-radar setup [--profile <name>] [--start] [--project-name <name>]
86
+
87
+ What it does:
88
+ Installs Radar into the selected DSH profile, writes a reviewable config and
89
+ --patch overlay, then runs the local doctor check. By default it does not
90
+ start DSH or execute plugin business actions; --start opts into launching DSH
91
+ only after the doctor check passes.
92
+
93
+ Prerequisite:
94
+ Install DeepSeek Harness first and verify that \`dsh --help\` works.
95
+
96
+ Common options:
97
+ --profile <name> select a DSH profile (auto-selects the only candidate)
98
+ --project-name <name> name shown in incidents and Agent tasks
99
+ --workspace <path> project workspace used to route Agent follow-up
100
+ --repository <url> repository evidence passed to the Agent task
101
+ --webhook-url-env <name> env var containing this project's HTTPS webhook URL
102
+ --webhook-secret-env <name> optional env var for a Feishu/Lark V2 signing secret
103
+ --output <path> Radar config path (default: ./upstream-radar.config.json)
104
+ --dsh-patch <path> DSH overlay path (default: ./upstream-radar.dsh.yml)
105
+ --minimum-severity <level> minimum vulnerability notice level
106
+ --quiet-hours <tz,start-end> e.g. Asia/Shanghai,22:00-08:00
107
+ --start start DSH after the local wiring check passes
108
+ --no-install reuse an already installed Radar bundle
109
+ --no-dsh-patch use legacy environment-variable wiring
110
+
111
+ Notification controls:
112
+ Add an optional \`notificationPolicy\` block to a generated projects[] entry
113
+ to set \`minimumSeverity\` and timezone-aware \`quietHours\`. This changes only
114
+ delivery; active evidence and queued tasks remain durable.
115
+
116
+ Next:
117
+ Review the generated files, then run the printed doctor command and start DSH
118
+ with the printed --patch command. Pass --start only when you explicitly accept
119
+ starting DSH in the same invocation after the doctor check passes.
120
+
121
+ If the profile has no third-party plugin yet:
122
+ dsh plugin --profile <name> add <package>@<exact-version>
123
+ `,
124
+ init: `Upstream Radar — create a reviewable inventory
125
+
126
+ Usage:
127
+ upstream-radar init --profile <name> [options]
128
+ upstream-radar init --pnpm-lock <pnpm-lock.yaml> [options]
129
+ upstream-radar init --npm-lock <package-lock.json> [options]
130
+
131
+ What it does:
132
+ Reads a DSH profile or lockfile without executing plugin code. Lockfile mode
133
+ is the pre-install path; it writes a static graph for a later frozen check.
134
+
135
+ Common options:
136
+ --root <package>@<exact-version> explicit lockfile root when it cannot be inferred
137
+ --project-name <name> name shown in incidents
138
+ --webhook-url-env <name> env var containing this project's HTTPS webhook URL
139
+ --webhook-secret-env <name> optional env var for a Feishu/Lark V2 signing secret
140
+ --output <path> config path (default: ./upstream-radar.config.json)
141
+ --dsh-patch <path> write a self-contained DSH overlay (profile mode)
142
+ --minimum-severity <level> minimum vulnerability notice level
143
+ --quiet-hours <tz,start-end> e.g. Asia/Shanghai,22:00-08:00
144
+ --json print a compact machine-readable summary
145
+ --force replace an existing output file
146
+
147
+ Notification controls:
148
+ Add an optional \`notificationPolicy\` block to a generated projects[] entry
149
+ to set \`minimumSeverity\` and timezone-aware \`quietHours\`. This changes only
150
+ delivery; active evidence and queued tasks remain durable.
151
+
152
+ Project webhook routing:
153
+ Store only environment variable names in the config. Set the URL and optional
154
+ Feishu secret in the DSH process environment; each project's changed events
155
+ then use its own endpoint while the native DSH Agent route remains workspace-based.
156
+
157
+ Next:
158
+ upstream-radar radar check ./upstream-radar.config.json --frozen
159
+ `,
160
+ doctor: `Upstream Radar — verify local wiring without polling upstream sources
161
+
162
+ Usage:
163
+ upstream-radar doctor [config.json] [--profile <name>] [--patch <path>] [--json]
164
+
165
+ Checks:
166
+ config/state readability, Radar registration in DSH, overlay alignment,
167
+ dependency coverage, and local webhook configuration. It does not contact
168
+ OSV, npm, GitHub, DSH, or a model.
169
+ `,
170
+ scan: `Upstream Radar — inspect a local package directory without running it
171
+
172
+ Usage:
173
+ upstream-radar scan <directory> [--json] [--fail-on <warn|review|block|never>]
174
+
175
+ Use this for an unpacked plugin before installation. Lifecycle scripts, remote
176
+ shell patterns, unsafe symlinks, mutable dependencies, and DSH bundle metadata
177
+ are recorded as bounded evidence.
178
+ `,
179
+ inspect: `Upstream Radar — inspect one exact npm artifact before installation
180
+
181
+ Usage:
182
+ upstream-radar inspect npm:<package>@<exact-version> [--deep] [--json]
183
+ [--registry <https-url>] [--fail-on <warn|review|block|never>]
184
+
185
+ --deep downloads the exact tarball, verifies npm integrity/signatures and
186
+ provenance when available, resolves the dependency graph with scripts disabled,
187
+ and queries the implemented vulnerability checks. An empty finding list is not
188
+ a safety certificate; check the coverage verdict before admitting the package.
189
+ The default gate exits 2 for review or block; use --fail-on block when review
190
+ should remain visible without failing CI.
191
+ `,
192
+ observe: `Upstream Radar — watch DSH plugin repositories and packages for meaningful upstream changes
193
+
194
+ Usage:
195
+ upstream-radar observe <targets.yml> [--state <observations.json>]
196
+ [--report <report.md>] [--dsh-agent-command <executable>]
197
+ [--dsh-agent-arg <argument>] [--llm-env-file <path>] [--retry-pending] [--json]
198
+
199
+ The first run creates a baseline. Later runs compare source commits, published
200
+ npm metadata, package manifests, and an optional npm/pnpm dependency graph. A
201
+ DSH Agent is called only for meaningful changes. Safety: does not install packages, run lifecycle scripts, load plugin code, or invoke a shell.
202
+
203
+ The Agent executable receives one read-only task prompt on stdin and should
204
+ return one JSON conclusion on stdout. If it is not configured, the task stays
205
+ in observations.json for a later explicit retry. As a simpler alternative,
206
+ --llm-env-file reads an OpenAI-compatible issue-locator/.env-style file for
207
+ only the model call. It accepts ISSUE_LOCATOR_LLM_*, OPENAI_*, or MODEL/CODEX_MODEL
208
+ keys; it never writes the key or endpoint to observations.json.
209
+ If a ModelBest-style base URL ends in /llm/v1, a 404 also retries the known
210
+ /llm/openai/v1 path.
211
+ `,
212
+ graph: `Upstream Radar — read a lockfile into the canonical dependency graph
213
+
214
+ Usage:
215
+ upstream-radar graph pnpm-lock <pnpm-lock.yaml> [--root <package>@<exact-version>] [--json]
216
+ upstream-radar graph npm-lock <package-lock.json> [--root <package>@<exact-version>] [--json]
217
+
218
+ This command is offline and does not install packages, run lifecycle scripts,
219
+ load plugin code, or query vulnerability sources.
220
+ `,
221
+ 'profile-check': `Upstream Radar — check one DSH profile before starting it
222
+
223
+ Usage:
224
+ upstream-radar profile-check [profile-directory] [--patch <path>] [--report <path>] [--summary] [--json]
225
+
226
+ Reads the profile package manifest, lockfile, node_modules package metadata,
227
+ pnpm release-age policy, and cordis.patch.yml. It reports loader rows that
228
+ refer to missing packages and duplicate loader ids. It never installs packages,
229
+ starts DSH, loads plugin code, contacts a vulnerability source, or invokes a
230
+ DSH Agent/model. When the directory is omitted, the only DSH profile with
231
+ third-party bundles is selected automatically.
232
+ `,
233
+ probe: `Upstream Radar — test whether a DSH bundle loads in disposable profiles
234
+
235
+ Usage:
236
+ upstream-radar probe dsh-load <package.tgz> [--dsh-version <exact-version>] [--json]
237
+ upstream-radar probe dsh-matrix <package.tgz> --dsh-version <v1>,<v2>,... [--json]
238
+
239
+ The probe is bounded and isolated. It is a compatibility/load check, not a
240
+ semantic safety review or a substitute for dependency monitoring.
241
+ `,
242
+ demo: `Upstream Radar — show the exact-path-to-DSH handoff without side effects
243
+
244
+ Usage:
245
+ upstream-radar demo [--json]
246
+
247
+ The demo is network-free and uses only a local fixture. It does not inspect your
248
+ repository, install a plugin, start DSH, or claim that its advisory is real.
249
+ `,
250
+ quickstart: `Upstream Radar — choose the smallest honest first-use path
251
+
252
+ Usage:
253
+ upstream-radar quickstart [directory] [--json]
254
+
255
+ What it does:
256
+ Inspects only the selected directory and local DSH profile metadata. It finds
257
+ an existing Radar config, one supported lockfile, or eligible DSH profiles,
258
+ then prints the next commands in the right order.
259
+
260
+ Safety:
261
+ This command never installs packages, starts DSH, queries vulnerability
262
+ sources, or executes plugin code. It does not choose between two lockfiles or
263
+ multiple DSH profiles on your behalf.
264
+ `,
265
+ benchmark: `Upstream Radar — run offline compatibility-rule contracts
266
+
267
+ Usage:
268
+ upstream-radar benchmark compatibility [--json]
269
+ `,
270
+ radar: `Upstream Radar — monitor a reviewed inventory
271
+
272
+ Usage:
273
+ upstream-radar radar check <config.json> [options]
274
+ upstream-radar radar watch <config.json> [options]
275
+ upstream-radar radar status <config.json> [options]
276
+ upstream-radar radar history <config.json> [options]
277
+ upstream-radar radar compare <config.json> <before.json> <candidate.json> [options]
278
+
279
+ Use 'radar status' and 'radar history' for local, no-network diagnosis. Use
280
+ --frozen for CI or any run that must not read a live DSH profile.
281
+ `,
282
+ 'radar check': `Upstream Radar — run one vulnerability and compatibility check
283
+
284
+ Usage:
285
+ upstream-radar radar check <config.json> [--state <state.json>] [--frozen]
286
+ [--fail-on <severity>] [--fail-on-compatibility <never|breaking|any>]
287
+ [--webhook <https-url>] [--threat-intel] [--json]
288
+
289
+ One cycle queries the configured sources and persists changed incidents. Use
290
+ --frozen when the config is the reviewed graph you want to enforce.
291
+
292
+ --threat-intel adds CISA KEV and FIRST EPSS prioritization signals for matched
293
+ CVEs. It is opt-in here; the native DSH bundle enables it by default.
294
+ `,
295
+ 'radar watch': `Upstream Radar — keep monitoring a reviewed inventory
296
+
297
+ Usage:
298
+ upstream-radar radar watch <config.json> [--interval <seconds>] [--once]
299
+ [--state <state.json>] [--frozen] [--fail-on <severity>]
300
+ [--fail-on-compatibility <never|breaking|any>] [--webhook <https-url>]
301
+ [--threat-intel]
302
+
303
+ Use --once in CI. A long-running watch keeps polling and should not be given a
304
+ policy gate that would make it exit on the first incident.
305
+ `,
306
+ 'radar status': `Upstream Radar — see the local monitoring snapshot
307
+
308
+ Usage:
309
+ upstream-radar radar status <config.json> [--state <state.json>]
310
+ [--fail-on <severity>] [--fail-on-compatibility <never|breaking|any>] [--json]
311
+
312
+ This command never polls OSV, npm, GitHub, or DSH. It shows whether monitoring
313
+ has run, coverage, active incidents, queued Agent tasks, and the next useful
314
+ action.
315
+ `,
316
+ 'radar next': `Upstream Radar — show the one next action for the highest-priority incident
317
+
318
+ Usage:
319
+ upstream-radar radar next <config.json> [--state <state.json>] [--json]
320
+
321
+ This command is read-only and does not poll upstream sources. It selects the
322
+ same first incident as 'radar status', then points to the pending DSH task,
323
+ verified analysis, or the next check command.
324
+ `,
325
+ 'radar history': `Upstream Radar — inspect the durable transition ledger
326
+
327
+ Usage:
328
+ upstream-radar radar history <config.json> [--state <state.json>] [--limit <n>] [--json]
329
+
330
+ This command is local-only and shows new, updated, resolved, and source-health
331
+ transitions, including their exact affected paths.
332
+ `,
333
+ 'radar compare': `Upstream Radar — compare one reviewed release candidate
334
+
335
+ Usage:
336
+ upstream-radar radar compare <config.json> <before.json> <candidate.json>
337
+ [--notes <release-notes.txt>] [--json]
338
+
339
+ The release notes are treated as untrusted evidence. This command does not
340
+ install or execute the candidate.
341
+ `,
342
+ task: `Upstream Radar — inspect or acknowledge the DSH analysis outbox
343
+
344
+ Usage:
345
+ upstream-radar task list <state.json> [--json]
346
+ upstream-radar task show <state.json> [task-id] [--json]
347
+ upstream-radar task ack <state.json> <task-id>
348
+ `,
349
+ analysis: `Upstream Radar — inspect verified DSH conclusions
350
+
351
+ Usage:
352
+ upstream-radar analysis list <state.json> [--json]
353
+ upstream-radar analysis show <state.json> [incident-id] [--json]
354
+ `,
355
+ mute: `Upstream Radar — pause delivery for one exact incident until a fixed expiry
356
+
357
+ Usage:
358
+ upstream-radar mute <state.json> <incident-id> --until <ISO-8601> [--force] [--json]
359
+
360
+ This pauses DSH and webhook delivery only. The active incident, dependency paths,
361
+ history, and status remain visible. The mute expires automatically. Critical and
362
+ malware incidents require --force.
363
+ `,
364
+ unmute: `Upstream Radar — resume delivery for one incident immediately
365
+
366
+ Usage:
367
+ upstream-radar unmute <state.json> <incident-id> [--json]
368
+ `,
369
+ triage: `Upstream Radar — record human follow-up for one active incident
370
+
371
+ Usage:
372
+ upstream-radar triage <state.json> <incident-id>
373
+ --status <open|in-progress|blocked|accepted-risk>
374
+ [--owner <name>] [--note <text>] [--due <ISO-8601>] [--json]
375
+
376
+ This records ownership and work context without resolving, hiding, or changing
377
+ the deterministic incident. A new event version requires a fresh follow-up.
378
+ Blocked and accepted-risk states require a note.
379
+ `,
380
+ };
381
+ return help[key];
382
+ }
383
+ function commandHelp(args) {
384
+ const helpIndex = args.findIndex(argument => argument === '--help' || argument === '-h');
385
+ if (helpIndex < 0)
386
+ return undefined;
387
+ const command = args[0];
388
+ if (command === undefined)
389
+ return undefined;
390
+ const nested = command === 'radar' || command === 'task' || command === 'analysis'
391
+ ? args[1]
392
+ : undefined;
393
+ const key = nested === undefined ? command : `${command} ${nested}`;
394
+ return commandUsage(key) ?? commandUsage(command);
395
+ }
36
396
  function usage() {
37
397
  return `Upstream Radar — always-on dependency and compatibility monitoring for DSH plugins
38
398
 
39
399
  Usage:
40
- upstream-radar setup [--profile <name>] [options]
400
+ upstream-radar <command> --help
401
+ upstream-radar setup [--profile <name>] [--start] [options]
41
402
  upstream-radar init [--profile <name>] [options]
403
+ upstream-radar init --pnpm-lock <pnpm-lock.yaml> [--root <package>@<exact-version>] [options]
404
+ upstream-radar init --npm-lock <package-lock.json> [--root <package>@<exact-version>] [options]
42
405
  upstream-radar doctor [config.json] [options]
43
406
  upstream-radar scan <directory> [--json] [--fail-on <warn|review|block|never>]
44
407
  upstream-radar inspect npm:<package>@<exact-version> [--deep] [--json] [--fail-on <warn|review|block|never>]
408
+ upstream-radar observe <targets.yml> [--state <observations.json>] [--report <report.md>] [--dsh-agent-command <executable>] [--dsh-agent-arg <argument>] [--llm-env-file <path>] [--retry-pending] [--json]
409
+ upstream-radar graph <npm-lock|pnpm-lock> <lockfile> [--root <package>@<exact-version>] [--json]
410
+ upstream-radar profile-check [profile-directory] [--patch <path>] [--report <path>] [--summary] [--json]
45
411
  upstream-radar probe dsh-load <package.tgz> [--dsh-version <exact-version>] [--timeout <seconds>] [--keep-profile] [--json]
46
412
  upstream-radar probe dsh-matrix <package.tgz> --dsh-version <v1>[,<v2>,...] [--timeout <seconds>] [--keep-profile] [--json]
413
+ upstream-radar demo [--json]
414
+ upstream-radar quickstart [directory] [--json]
47
415
  upstream-radar benchmark compatibility [--json]
48
- upstream-radar radar check <config.json> [--state <state.json>] [--frozen] [--fail-on <severity>] [--fail-on-compatibility <never|breaking|any>] [--json]
49
- upstream-radar radar watch <config.json> [--state <state.json>] [--interval <seconds>] [--once] [--frozen] [--fail-on <severity>] [--fail-on-compatibility <never|breaking|any>] [--json]
416
+ upstream-radar radar check <config.json> [--state <state.json>] [--webhook <https-url>] [--threat-intel] [--frozen] [--fail-on <severity>] [--fail-on-compatibility <never|breaking|any>] [--json]
417
+ upstream-radar radar watch <config.json> [--state <state.json>] [--webhook <https-url>] [--threat-intel] [--interval <seconds>] [--once] [--frozen] [--fail-on <severity>] [--fail-on-compatibility <never|breaking|any>] [--json]
50
418
  upstream-radar radar status <config.json> [--state <state.json>] [--fail-on <severity>] [--fail-on-compatibility <never|breaking|any>] [--json]
419
+ upstream-radar radar next <config.json> [--state <state.json>] [--json]
420
+ upstream-radar radar history <config.json> [--state <state.json>] [--limit <n>] [--json]
51
421
  upstream-radar radar compare <config.json> <before.json> <candidate.json> [--notes <release-notes.txt>] [--json]
52
422
  upstream-radar task list <state.json> [--json]
53
423
  upstream-radar task show <state.json> [task-id] [--json]
54
424
  upstream-radar task ack <state.json> <task-id>
55
425
  upstream-radar analysis list <state.json> [--json]
56
426
  upstream-radar analysis show <state.json> [incident-id] [--json]
427
+ upstream-radar mute <state.json> <incident-id> --until <ISO-8601> [--force] [--json]
428
+ upstream-radar unmute <state.json> <incident-id> [--json]
429
+ upstream-radar triage <state.json> <incident-id> --status <open|in-progress|blocked|accepted-risk> [--owner <name>] [--note <text>] [--due <ISO-8601>] [--json]
57
430
  upstream-radar version
58
431
 
59
432
  Commands:
60
433
  setup install the exact Radar bundle, generate DSH wiring, and run doctor
61
- init discover third-party bundles in a DSH profile and write a reviewable inventory
434
+ init discover third-party bundles in DSH or initialize a reviewable lockfile inventory
62
435
  doctor check local Radar/DSH wiring without polling upstream sources
63
436
  scan bounded, read-only inspection of a local package directory
64
437
  inspect fetch and verify the exact npm artifact before inspecting its contents
438
+ observe compare upstream DSH plugin repositories and route only meaningful changes to a DSH Agent
439
+ graph read a lockfile into the canonical dependency graph without installing packages
440
+ profile-check check a DSH profile's lockfile and patch rows without starting DSH
65
441
  probe run a bounded DSH bundle-load check or version matrix in disposable profiles
442
+ demo show the exact-path-to-DSH handoff without network, DSH, or plugin installation
443
+ quickstart choose the smallest first-use path without changing the environment
66
444
  benchmark run offline compatibility-rule contracts without network or plugin execution
67
- radar monitor vulnerability changes, watch continuously, inspect status, or assess a candidate compatibility change
445
+ radar monitor vulnerability changes, watch continuously, find the next action, inspect status/history, or assess a candidate compatibility change
68
446
  task inspect or acknowledge the durable DSH analysis outbox
69
447
  analysis inspect verified DSH conclusions stored in the Radar state
448
+ mute pause delivery for one incident with a bounded expiry
449
+ unmute resume delivery for one incident immediately
450
+ triage record owner, work status, and handoff note for one incident
70
451
 
71
452
  Options:
72
453
  --deep resolve the dependency graph with scripts disabled and ask npm to verify signatures/provenance
@@ -74,16 +455,29 @@ Options:
74
455
  --no-deep-candidates skip bounded transitive dependency graph checks for upgrade candidates
75
456
  --state <path> persistent radar state (default: <config.json>.state.json)
76
457
  --osv-base-url <url> alternate HTTPS OSV API base URL
458
+ --no-github-advisories disable the independent GitHub Advisory Database check for radar check/watch
459
+ --threat-intel radar check/watch: add CISA KEV and FIRST EPSS signals for matched CVEs
460
+ --webhook <https-url> radar check/watch: POST changed events to an HTTPS endpoint
461
+ --webhook-url-env <name> init/setup: env var containing a project's HTTPS webhook URL
462
+ --webhook-secret-env <name> init/setup: optional Feishu/Lark V2 signing secret env var
77
463
  --interval <seconds> watch interval from 300 to 86400 seconds (default: 1800)
464
+ --limit <n> radar history: show 1 to 1000 recent transitions (default: 20)
78
465
  --once run one watch cycle and exit (useful for CI and demos)
79
466
  --frozen radar check/watch: use the reviewed graph in config without reading a local DSH profile
80
467
  --fail-on <value> scan/inspect verdict or radar severity: unknown|info|low|medium|high|critical|never
81
468
  --fail-on-compatibility <value> CI gate: never|breaking|any (default: never)
82
469
  --notes <path> release notes used as untrusted compatibility evidence
83
470
  --profile <name> DSH profile for init or doctor (init auto-selects the only candidate when omitted)
471
+ --npm-lock <path> init: build a static inventory from an npm v2/v3 package-lock.json
472
+ --pnpm-lock <path> init: build a static inventory from a pnpm v6/v9 lockfile
473
+ --root <coordinate> init/graph lockfiles: override the root; otherwise read package.json beside the lockfile
84
474
  --no-install setup: reuse an already installed upstream-radar bundle
85
475
  --output <path> init output path (default: ./upstream-radar.config.json)
86
- --dsh-patch <path> write a self-contained DSH --patch overlay (optional)
476
+ --dsh-patch <path> write a self-contained DSH --patch overlay (setup default: ./upstream-radar.dsh.yml)
477
+ --minimum-severity <level> init/setup notification threshold: info|low|medium|high|critical
478
+ --quiet-hours <tz,start-end> init/setup window, e.g. Asia/Shanghai,22:00-08:00
479
+ --start setup: start DSH after the local wiring check passes
480
+ --no-dsh-patch setup: keep the legacy UPSTREAM_RADAR_* environment-variable wiring
87
481
  --patch <path> DSH overlay to verify with doctor
88
482
  --force allow init to replace an existing output file
89
483
  --json emit the canonical JSON report
@@ -94,6 +488,140 @@ Exit codes:
94
488
  2 configured policy threshold was reached
95
489
  `;
96
490
  }
491
+ function parseExactPackageCoordinate(value) {
492
+ const at = value.lastIndexOf('@');
493
+ if (at <= 0 || at === value.length - 1)
494
+ throw new Error(`--root must be an exact package coordinate: ${value}`);
495
+ const name = value.slice(0, at);
496
+ const version = value.slice(at + 1);
497
+ if (name.startsWith('@') && !name.includes('/'))
498
+ throw new Error(`--root must include a scoped package name: ${value}`);
499
+ return { name, version };
500
+ }
501
+ async function inferLockfileRoot(lockfile, kind) {
502
+ const manifestPath = join(dirname(resolve(lockfile)), 'package.json');
503
+ try {
504
+ const manifest = parsePackageManifestSnapshot(await readJson(manifestPath));
505
+ return { name: manifest.name, version: manifest.version };
506
+ }
507
+ catch (error) {
508
+ if (error.code === 'ENOENT') {
509
+ throw new Error(`could not infer the ${kind} lockfile root from ${manifestPath}; pass --root <package>@<exact-version>`);
510
+ }
511
+ throw error;
512
+ }
513
+ }
514
+ async function runGraph(args) {
515
+ const kind = args[0];
516
+ if (kind !== 'npm-lock' && kind !== 'pnpm-lock')
517
+ throw new Error('graph requires the npm-lock or pnpm-lock subcommand');
518
+ const lockfile = args[1];
519
+ if (lockfile === undefined || lockfile.startsWith('-'))
520
+ throw new Error(`graph ${kind} requires a lockfile path`);
521
+ let rootSpec;
522
+ let json = false;
523
+ for (let index = 2; index < args.length; index += 1) {
524
+ const argument = args[index];
525
+ if (argument === '--json') {
526
+ json = true;
527
+ }
528
+ else if (argument === '--root') {
529
+ const value = args[index + 1];
530
+ if (value === undefined || value.startsWith('-'))
531
+ throw new Error('--root requires a value');
532
+ rootSpec = value;
533
+ index += 1;
534
+ }
535
+ else {
536
+ throw new Error(`unknown option for graph ${kind}: ${argument}`);
537
+ }
538
+ }
539
+ const packageRoot = rootSpec === undefined
540
+ ? await inferLockfileRoot(lockfile, kind === 'npm-lock' ? 'npm' : 'pnpm')
541
+ : parseExactPackageCoordinate(rootSpec);
542
+ const graph = kind === 'npm-lock'
543
+ ? parseNpmLockGraph(await readJson(lockfile), packageRoot)
544
+ : parsePnpmLockGraph(await readBoundedFile(lockfile, 16 * 1024 * 1024), packageRoot);
545
+ if (json) {
546
+ process.stdout.write(`${JSON.stringify(graph, null, 2)}\n`);
547
+ return 0;
548
+ }
549
+ const nodes = new Map(graph.nodes.map(node => [node.id, node]));
550
+ const root = nodes.get(graph.rootNodeId);
551
+ const display = (id) => {
552
+ const node = nodes.get(id);
553
+ return node === undefined ? id : `${node.name}@${node.version}`;
554
+ };
555
+ const unresolved = graph.unresolved ?? [];
556
+ process.stdout.write([
557
+ `Dependency graph: ${root === undefined ? graph.rootNodeId : `${root.name}@${root.version}`}`,
558
+ `Source: ${kind} (read-only; no install, no plugin execution)`,
559
+ `Nodes: ${graph.nodes.length}`,
560
+ `Edges: ${graph.edges.length}`,
561
+ `Unresolved: ${unresolved.length}`,
562
+ `Digest: ${graph.digest ?? '(none)'}`,
563
+ '',
564
+ 'Edges:',
565
+ ...(graph.edges.length === 0 ? [' (none)'] : graph.edges.map(edge => ` ${display(edge.from)} -[${edge.kind}]-> ${display(edge.to)}`)),
566
+ ...(unresolved.length === 0 ? [] : [
567
+ '',
568
+ 'Unresolved dependencies:',
569
+ ...unresolved.map(item => ` ${display(item.from)} -[${item.kind}]-> ${item.name} (${item.spec})`),
570
+ ]),
571
+ ].join('\n') + '\n');
572
+ return 0;
573
+ }
574
+ async function runDshProfileCheck(args) {
575
+ let profileDirectory;
576
+ let firstOption = 0;
577
+ if (args[0] !== undefined && !args[0].startsWith('-')) {
578
+ profileDirectory = args[0];
579
+ firstOption = 1;
580
+ }
581
+ let json = false;
582
+ let summary = false;
583
+ let reportPath;
584
+ let patchPath;
585
+ for (let index = firstOption; index < args.length; index += 1) {
586
+ const argument = args[index];
587
+ if (argument === '--json') {
588
+ json = true;
589
+ }
590
+ else if (argument === '--summary') {
591
+ summary = true;
592
+ }
593
+ else if (argument === '--patch' || argument === '--report') {
594
+ const value = args[index + 1];
595
+ if (value === undefined || value.startsWith('-'))
596
+ throw new Error(`${argument} requires a value`);
597
+ if (argument === '--patch')
598
+ patchPath = value;
599
+ else
600
+ reportPath = value;
601
+ index += 1;
602
+ }
603
+ else {
604
+ throw new Error(`unknown option for profile-check: ${argument}`);
605
+ }
606
+ }
607
+ if (profileDirectory === undefined) {
608
+ const profiles = await discoverDshProfiles();
609
+ if (profiles.length === 0) {
610
+ throw new Error('profile-check could not find a DSH profile with third-party bundles; pass <profile-directory> explicitly');
611
+ }
612
+ if (profiles.length > 1) {
613
+ throw new Error(`profile-check found multiple DSH profiles with third-party bundles (${profiles.join(', ')}); pass <profile-directory> explicitly`);
614
+ }
615
+ profileDirectory = resolveDshProfileDirectory(profiles[0]);
616
+ }
617
+ const report = await checkDshProfile({ profileDirectory, ...(patchPath === undefined ? {} : { patchFile: patchPath }) });
618
+ const jsonText = `${JSON.stringify(report, null, 2)}\n`;
619
+ if (reportPath !== undefined) {
620
+ await writeFile(resolve(reportPath), reportPath.endsWith('.json') ? jsonText : summary ? renderDshProfileCheckSummary(report) : renderDshProfileCheck(report));
621
+ }
622
+ process.stdout.write(json ? jsonText : summary ? renderDshProfileCheckSummary(report) : renderDshProfileCheck(report));
623
+ return report.status === 'blocked' ? 2 : 0;
624
+ }
97
625
  async function runTask(args) {
98
626
  const subcommand = args[0];
99
627
  if (subcommand !== 'list' && subcommand !== 'show' && subcommand !== 'ack') {
@@ -162,6 +690,200 @@ async function runTask(args) {
162
690
  process.stdout.write(`Acknowledged ${safeErrorMessage(taskId)}.\n`);
163
691
  return 0;
164
692
  }
693
+ const MAX_INCIDENT_MUTE_MS = 30 * 24 * 60 * 60 * 1_000;
694
+ const VALID_INCIDENT_TRIAGE_STATUSES = new Set([
695
+ 'open',
696
+ 'in-progress',
697
+ 'blocked',
698
+ 'accepted-risk',
699
+ ]);
700
+ function activeEventForIncident(state, incidentId) {
701
+ return [
702
+ ...Object.values(state.activeVulnerabilities),
703
+ ...Object.values(state.activeCompatibility),
704
+ ...Object.values(state.activeSourceHealth ?? {}),
705
+ ].map(item => item.event).find(event => event.incidentId === incidentId);
706
+ }
707
+ async function runIncidentMute(args) {
708
+ const statePath = args[0];
709
+ const incidentId = args[1];
710
+ if (statePath === undefined || statePath.startsWith('-'))
711
+ throw new Error('mute requires a state file');
712
+ if (incidentId === undefined || incidentId.startsWith('-'))
713
+ throw new Error('mute requires an incident id');
714
+ let untilValue;
715
+ let force = false;
716
+ let json = false;
717
+ for (let index = 2; index < args.length; index += 1) {
718
+ const argument = args[index];
719
+ if (argument === undefined)
720
+ throw new Error('mute received an incomplete option');
721
+ if (argument === '--force')
722
+ force = true;
723
+ else if (argument === '--json')
724
+ json = true;
725
+ else if (argument === '--until') {
726
+ const value = args[index + 1];
727
+ if (value === undefined || value.startsWith('-'))
728
+ throw new Error('--until requires a value');
729
+ untilValue = value;
730
+ index += 1;
731
+ }
732
+ else if (argument.startsWith('-')) {
733
+ throw new Error(`unknown option for mute: ${argument}`);
734
+ }
735
+ else {
736
+ throw new Error(`mute received unexpected argument: ${argument}`);
737
+ }
738
+ }
739
+ if (untilValue === undefined)
740
+ throw new Error('mute requires --until <ISO-8601>');
741
+ const mutedUntilMs = Date.parse(untilValue);
742
+ const nowMs = Date.now();
743
+ if (!Number.isFinite(mutedUntilMs))
744
+ throw new Error('--until must be a valid ISO-8601 timestamp');
745
+ if (mutedUntilMs <= nowMs)
746
+ throw new Error('--until must be in the future');
747
+ if (mutedUntilMs > nowMs + MAX_INCIDENT_MUTE_MS)
748
+ throw new Error('--until cannot be more than 30 days away');
749
+ const state = await loadRadarState(statePath);
750
+ const event = activeEventForIncident(state, incidentId);
751
+ if (event === undefined)
752
+ throw new Error(`active incident not found: ${incidentId}`);
753
+ const forceRequired = event.kind === 'malware'
754
+ || (event.kind === 'vulnerability' && event.advisory.severity === 'critical');
755
+ if (forceRequired && !force)
756
+ throw new Error('critical or malware incidents require --force to mute');
757
+ const mutedUntil = new Date(mutedUntilMs).toISOString();
758
+ await saveRadarState(statePath, {
759
+ ...state,
760
+ incidentMutes: {
761
+ ...(state.incidentMutes ?? {}),
762
+ [incidentId]: { eventId: event.id, mutedUntil },
763
+ },
764
+ });
765
+ const result = { incidentId, eventId: event.id, mutedUntil, forced: force };
766
+ process.stdout.write(json
767
+ ? `${JSON.stringify(result, null, 2)}\n`
768
+ : `Muted delivery for ${safeErrorMessage(incidentId)} until ${mutedUntil}. Active evidence and history remain visible.\n`);
769
+ return 0;
770
+ }
771
+ async function runIncidentUnmute(args) {
772
+ const statePath = args[0];
773
+ const incidentId = args[1];
774
+ if (statePath === undefined || statePath.startsWith('-'))
775
+ throw new Error('unmute requires a state file');
776
+ if (incidentId === undefined || incidentId.startsWith('-'))
777
+ throw new Error('unmute requires an incident id');
778
+ let json = false;
779
+ for (const argument of args.slice(2)) {
780
+ if (argument === '--json')
781
+ json = true;
782
+ else
783
+ throw new Error(`unknown option for unmute: ${argument}`);
784
+ }
785
+ const state = await loadRadarState(statePath);
786
+ if (state.incidentMutes?.[incidentId] === undefined)
787
+ throw new Error(`incident is not muted: ${incidentId}`);
788
+ const nextIncidentMutes = Object.fromEntries(Object.entries(state.incidentMutes).filter(([id]) => id !== incidentId));
789
+ await saveRadarState(statePath, { ...state, incidentMutes: nextIncidentMutes });
790
+ const result = { incidentId, unmuted: true };
791
+ process.stdout.write(json
792
+ ? `${JSON.stringify(result, null, 2)}\n`
793
+ : `Resumed delivery for ${safeErrorMessage(incidentId)}.\n`);
794
+ return 0;
795
+ }
796
+ async function runIncidentTriage(args) {
797
+ const statePath = args[0];
798
+ const incidentId = args[1];
799
+ if (statePath === undefined || statePath.startsWith('-'))
800
+ throw new Error('triage requires a state file');
801
+ if (incidentId === undefined || incidentId.startsWith('-'))
802
+ throw new Error('triage requires an incident id');
803
+ let statusValue;
804
+ let ownerValue;
805
+ let noteValue;
806
+ let dueValue;
807
+ let json = false;
808
+ for (let index = 2; index < args.length; index += 1) {
809
+ const argument = args[index];
810
+ if (argument === undefined)
811
+ throw new Error('triage received an incomplete option');
812
+ if (argument === '--json')
813
+ json = true;
814
+ else if (argument === '--status' || argument === '--owner' || argument === '--note' || argument === '--due') {
815
+ const value = args[index + 1];
816
+ if (value === undefined || value.startsWith('-'))
817
+ throw new Error(`${argument} requires a value`);
818
+ if (argument === '--status')
819
+ statusValue = value;
820
+ else if (argument === '--owner')
821
+ ownerValue = value;
822
+ else if (argument === '--note')
823
+ noteValue = value;
824
+ else
825
+ dueValue = value;
826
+ index += 1;
827
+ }
828
+ else if (argument.startsWith('-')) {
829
+ throw new Error(`unknown option for triage: ${argument}`);
830
+ }
831
+ else {
832
+ throw new Error(`triage received unexpected argument: ${argument}`);
833
+ }
834
+ }
835
+ if (statusValue === undefined)
836
+ throw new Error('triage requires --status <open|in-progress|blocked|accepted-risk>');
837
+ if (!VALID_INCIDENT_TRIAGE_STATUSES.has(statusValue)) {
838
+ throw new Error(`invalid triage --status value: ${statusValue}`);
839
+ }
840
+ const status = statusValue;
841
+ const owner = ownerValue?.trim();
842
+ const note = noteValue?.trim();
843
+ if (ownerValue !== undefined && (owner === undefined || owner.length === 0))
844
+ throw new Error('--owner cannot be empty');
845
+ if (owner !== undefined && owner.length > 512)
846
+ throw new Error('--owner must be 512 characters or fewer');
847
+ if (noteValue !== undefined && (note === undefined || note.length === 0))
848
+ throw new Error('--note cannot be empty');
849
+ if (note !== undefined && note.length > 2_048)
850
+ throw new Error('--note must be 2048 characters or fewer');
851
+ const dueAtMs = dueValue === undefined ? undefined : Date.parse(dueValue);
852
+ if (dueValue !== undefined && !Number.isFinite(dueAtMs))
853
+ throw new Error('--due must be a valid ISO-8601 timestamp');
854
+ const state = await loadRadarState(statePath);
855
+ const event = activeEventForIncident(state, incidentId);
856
+ if (event === undefined)
857
+ throw new Error(`active incident not found: ${incidentId}`);
858
+ const previous = state.incidentTriage?.[incidentId];
859
+ const inherited = previous?.eventId === event.id ? previous : undefined;
860
+ const recordNote = note ?? inherited?.note;
861
+ if ((status === 'blocked' || status === 'accepted-risk') && recordNote === undefined) {
862
+ throw new Error(`triage status ${status} requires --note <text>`);
863
+ }
864
+ const recordOwner = owner ?? inherited?.owner;
865
+ const recordDueAt = dueAtMs === undefined ? inherited?.dueAt : new Date(dueAtMs).toISOString();
866
+ const record = {
867
+ eventId: event.id,
868
+ status,
869
+ updatedAt: new Date().toISOString(),
870
+ ...(recordOwner === undefined ? {} : { owner: recordOwner }),
871
+ ...(recordNote === undefined ? {} : { note: recordNote }),
872
+ ...(recordDueAt === undefined ? {} : { dueAt: recordDueAt }),
873
+ };
874
+ await saveRadarState(statePath, {
875
+ ...state,
876
+ incidentTriage: {
877
+ ...(state.incidentTriage ?? {}),
878
+ [incidentId]: record,
879
+ },
880
+ });
881
+ const result = { incidentId, ...record };
882
+ process.stdout.write(json
883
+ ? `${JSON.stringify(result, null, 2)}\n`
884
+ : `Recorded ${status} follow-up for ${safeErrorMessage(incidentId)}${record.owner === undefined ? '' : ` (owner: ${safeErrorMessage(record.owner)})`}.\n`);
885
+ return 0;
886
+ }
165
887
  async function runAnalysis(args) {
166
888
  const subcommand = args[0];
167
889
  if (subcommand !== 'list' && subcommand !== 'show')
@@ -258,6 +980,18 @@ async function runBenchmark(args) {
258
980
  process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : renderCompatibilityBenchmark(report));
259
981
  return report.summary.failed === 0 ? 0 : 1;
260
982
  }
983
+ function runDemo(args) {
984
+ let json = false;
985
+ for (const argument of args) {
986
+ if (argument === '--json')
987
+ json = true;
988
+ else
989
+ throw new Error(`unknown option for demo: ${argument}`);
990
+ }
991
+ const report = createDemoReport();
992
+ process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : renderDemo(report));
993
+ return 0;
994
+ }
261
995
  async function runProbe(args) {
262
996
  const mode = args[0];
263
997
  if (mode !== 'dsh-load' && mode !== 'dsh-matrix')
@@ -337,8 +1071,8 @@ async function fileExists(path) {
337
1071
  }
338
1072
  async function runRadar(args) {
339
1073
  const subcommand = args[0];
340
- if (subcommand !== 'check' && subcommand !== 'watch' && subcommand !== 'status' && subcommand !== 'compare') {
341
- throw new Error('radar requires check, watch, status or compare');
1074
+ if (subcommand !== 'check' && subcommand !== 'watch' && subcommand !== 'status' && subcommand !== 'next' && subcommand !== 'history' && subcommand !== 'compare') {
1075
+ throw new Error('radar requires check, watch, status, next, history or compare');
342
1076
  }
343
1077
  const configPath = args[1];
344
1078
  if (configPath === undefined || configPath.startsWith('-'))
@@ -348,11 +1082,16 @@ async function runRadar(args) {
348
1082
  let statePath;
349
1083
  let osvBaseUrl;
350
1084
  let registry;
1085
+ let webhookUrl;
351
1086
  let notesPath;
352
1087
  let intervalSeconds = 1_800;
353
1088
  let intervalProvided = false;
1089
+ let historyLimit = 20;
1090
+ let historyLimitProvided = false;
354
1091
  let once = false;
355
1092
  let deepCandidates = true;
1093
+ let githubAdvisories = true;
1094
+ let threatIntel = false;
356
1095
  let frozen = false;
357
1096
  let failOn = 'never';
358
1097
  let failOnCompatibility = 'never';
@@ -367,11 +1106,17 @@ async function runRadar(args) {
367
1106
  else if (argument === '--no-deep-candidates') {
368
1107
  deepCandidates = false;
369
1108
  }
1109
+ else if (argument === '--no-github-advisories') {
1110
+ githubAdvisories = false;
1111
+ }
1112
+ else if (argument === '--threat-intel') {
1113
+ threatIntel = true;
1114
+ }
370
1115
  else if (argument === '--frozen') {
371
1116
  frozen = true;
372
1117
  }
373
- else if (argument === '--state' || argument === '--osv-base-url' || argument === '--registry'
374
- || argument === '--notes' || argument === '--interval' || argument === '--fail-on'
1118
+ else if (argument === '--state' || argument === '--osv-base-url' || argument === '--registry' || argument === '--webhook'
1119
+ || argument === '--notes' || argument === '--interval' || argument === '--limit' || argument === '--fail-on'
375
1120
  || argument === '--fail-on-compatibility') {
376
1121
  const value = args[index + 1];
377
1122
  if (value === undefined || value.startsWith('-'))
@@ -382,6 +1127,8 @@ async function runRadar(args) {
382
1127
  osvBaseUrl = value;
383
1128
  else if (argument === '--registry')
384
1129
  registry = value;
1130
+ else if (argument === '--webhook')
1131
+ webhookUrl = normalizeRadarWebhookUrl(value);
385
1132
  else if (argument === '--notes')
386
1133
  notesPath = value;
387
1134
  else if (argument === '--fail-on') {
@@ -396,6 +1143,14 @@ async function runRadar(args) {
396
1143
  }
397
1144
  failOnCompatibility = value;
398
1145
  }
1146
+ else if (argument === '--limit') {
1147
+ historyLimitProvided = true;
1148
+ const parsed = Number(value);
1149
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 1_000) {
1150
+ throw new Error('--limit must be an integer between 1 and 1000');
1151
+ }
1152
+ historyLimit = parsed;
1153
+ }
399
1154
  else {
400
1155
  intervalProvided = true;
401
1156
  const parsed = Number(value);
@@ -417,10 +1172,13 @@ async function runRadar(args) {
417
1172
  const readConfigForPoll = async () => frozen
418
1173
  ? readConfig()
419
1174
  : refreshRadarConfigFromConfiguredProfile(await readConfig());
420
- if (subcommand === 'status') {
1175
+ if (subcommand === 'status' || subcommand === 'next') {
421
1176
  if (positional.length > 0 || notesPath !== undefined || once || intervalProvided
422
- || osvBaseUrl !== undefined || registry !== undefined || !deepCandidates || frozen || statePath === ':memory:') {
423
- throw new Error('radar status only accepts --state, --fail-on, --fail-on-compatibility and --json options');
1177
+ || historyLimitProvided || osvBaseUrl !== undefined || registry !== undefined || webhookUrl !== undefined || !deepCandidates || !githubAdvisories || threatIntel || frozen || statePath === ':memory:'
1178
+ || (subcommand === 'next' && (failOn !== 'never' || failOnCompatibility !== 'never'))) {
1179
+ throw new Error(subcommand === 'next'
1180
+ ? 'radar next only accepts --state and --json options'
1181
+ : 'radar status only accepts --state, --fail-on, --fail-on-compatibility and --json options');
424
1182
  }
425
1183
  const config = await readConfig();
426
1184
  const stateFile = statePath ?? `${resolve(configPath)}.state.json`;
@@ -433,6 +1191,11 @@ async function runRadar(args) {
433
1191
  });
434
1192
  const policy = evaluateRadarPolicy(state, failOn, failOnCompatibility);
435
1193
  const policyEnabled = failOn !== 'never' || failOnCompatibility !== 'never';
1194
+ if (subcommand === 'next') {
1195
+ const next = createRadarNext(report, state);
1196
+ process.stdout.write(json ? `${JSON.stringify(next, null, 2)}\n` : renderRadarNext(next));
1197
+ return 0;
1198
+ }
436
1199
  if (json) {
437
1200
  process.stdout.write(!policyEnabled
438
1201
  ? `${JSON.stringify(report, null, 2)}\n`
@@ -445,6 +1208,27 @@ async function runRadar(args) {
445
1208
  return 1;
446
1209
  return policy.status === 'fail' ? 2 : 0;
447
1210
  }
1211
+ if (subcommand === 'history') {
1212
+ if (positional.length > 0 || notesPath !== undefined || once || intervalProvided
1213
+ || osvBaseUrl !== undefined || registry !== undefined || webhookUrl !== undefined || !deepCandidates || !githubAdvisories || frozen
1214
+ || failOn !== 'never' || failOnCompatibility !== 'never' || threatIntel) {
1215
+ throw new Error('radar history only accepts --state, --limit and --json options');
1216
+ }
1217
+ // Parse the config as an input check, but deliberately do not refresh it
1218
+ // or contact any upstream source. History is a local diagnosis command.
1219
+ await readConfig();
1220
+ const stateFile = statePath ?? `${resolve(configPath)}.state.json`;
1221
+ const stateExists = await fileExists(stateFile);
1222
+ const state = stateExists ? await loadRadarState(stateFile) : emptyRadarState();
1223
+ const report = createRadarHistory(state, {
1224
+ configFile: resolve(configPath),
1225
+ stateFile: resolve(stateFile),
1226
+ stateExists,
1227
+ limit: historyLimit,
1228
+ });
1229
+ process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : renderRadarHistory(report));
1230
+ return 0;
1231
+ }
448
1232
  const osv = new OsvClient({
449
1233
  ...(osvBaseUrl === undefined ? {} : { baseUrl: osvBaseUrl }),
450
1234
  });
@@ -455,14 +1239,60 @@ async function runRadar(args) {
455
1239
  ? new NpmCandidateGraphClient({ ...(registry === undefined ? {} : { registry }) })
456
1240
  : undefined;
457
1241
  const releaseNotesSource = new GitHubReleaseClient();
1242
+ const githubAdvisorySource = githubAdvisories
1243
+ ? new GitHubAdvisoryClient({ ...(process.env.GITHUB_TOKEN === undefined ? {} : { token: process.env.GITHUB_TOKEN }) })
1244
+ : undefined;
1245
+ const threatIntelSources = threatIntel
1246
+ ? [
1247
+ { name: 'cisa-kev', source: new CisaKevClient() },
1248
+ { name: 'epss', source: new EpssClient() },
1249
+ ]
1250
+ : [];
458
1251
  const stateFile = statePath ?? `${resolve(configPath)}.state.json`;
1252
+ if (webhookUrl !== undefined && statePath === ':memory:') {
1253
+ throw new Error('radar --webhook requires a persistent --state file so successful deliveries can be remembered');
1254
+ }
1255
+ const feishuSecret = process.env.UPSTREAM_RADAR_FEISHU_SECRET?.trim() || undefined;
459
1256
  const runCheck = async () => {
460
1257
  const config = await readConfigForPoll();
1258
+ const webhookTargets = resolveRadarWebhookTargets(config.projects, {
1259
+ ...(webhookUrl === undefined ? {} : { globalUrl: webhookUrl }),
1260
+ ...(feishuSecret === undefined ? {} : { globalFeishuSecret: feishuSecret }),
1261
+ });
1262
+ if (statePath === ':memory:' && webhookTargets.length > 0) {
1263
+ throw new Error('radar webhook routes require a persistent --state file so successful deliveries can be remembered');
1264
+ }
461
1265
  const state = statePath === ':memory:' ? emptyRadarState() : await loadRadarState(stateFile);
462
- const result = await pollRadar(config.projects, state, osv, new Date(), releases, releaseNotesSource, candidateGraphs);
1266
+ const checkedAt = new Date();
1267
+ const result = await pollRadar(config.projects, state, osv, checkedAt, releases, releaseNotesSource, candidateGraphs, githubAdvisorySource === undefined ? [] : [{ name: 'github-advisories', source: githubAdvisorySource }], threatIntelSources);
463
1268
  if (statePath !== ':memory:')
464
1269
  await saveRadarState(stateFile, result.state);
465
- return result;
1270
+ const notificationPolicies = createNotificationPolicyMap(config.projects);
1271
+ let nextState = result.state;
1272
+ for (const target of webhookTargets) {
1273
+ const targetEvents = eventsForRadarWebhookTarget(result.events, target);
1274
+ const isLegacyGlobal = webhookUrl !== undefined && target.projectIds === undefined;
1275
+ const queuedState = isLegacyGlobal
1276
+ ? queueRadarWebhookEvents(nextState, target.endpointHash, targetEvents)
1277
+ : queueRadarWebhookEventsForRoute(nextState, target.endpointHash, targetEvents);
1278
+ if (statePath !== ':memory:')
1279
+ await saveRadarState(stateFile, queuedState);
1280
+ const pendingWebhookEvents = filterNotifiableRadarEvents(isLegacyGlobal
1281
+ ? undeliveredRadarWebhookEvents(queuedState, target.endpointHash, targetEvents)
1282
+ : undeliveredRadarWebhookEventsForRoute(queuedState, target.endpointHash, targetEvents), notificationPolicies, checkedAt, queuedState);
1283
+ if (pendingWebhookEvents.length === 0) {
1284
+ nextState = queuedState;
1285
+ continue;
1286
+ }
1287
+ const payload = await sendRadarWebhook(target.url, pendingWebhookEvents, target.feishuSecret === undefined ? {} : { feishuSecret: target.feishuSecret });
1288
+ const deliveredIds = new Set(payload.events.map(event => event.id));
1289
+ const deliveredEvents = pendingWebhookEvents.filter(event => deliveredIds.has(event.id));
1290
+ nextState = isLegacyGlobal
1291
+ ? markRadarWebhookEventsDelivered(queuedState, target.endpointHash, deliveredEvents)
1292
+ : markRadarWebhookEventsDeliveredForRoute(queuedState, target.endpointHash, deliveredEvents);
1293
+ await saveRadarState(stateFile, nextState);
1294
+ }
1295
+ return { ...result, state: nextState };
466
1296
  };
467
1297
  const writeCheckResult = (result, compactJson = false) => {
468
1298
  const policy = evaluateRadarPolicy(result.state, failOn, failOnCompatibility);
@@ -478,7 +1308,7 @@ async function runRadar(args) {
478
1308
  return policy;
479
1309
  };
480
1310
  if (subcommand === 'check') {
481
- if (positional.length > 0 || notesPath !== undefined || once || intervalProvided) {
1311
+ if (positional.length > 0 || notesPath !== undefined || once || intervalProvided || historyLimitProvided) {
482
1312
  throw new Error('radar check received an option meant for watch or compare');
483
1313
  }
484
1314
  const result = await runCheck();
@@ -488,7 +1318,7 @@ async function runRadar(args) {
488
1318
  return policy.status === 'fail' ? 2 : 0;
489
1319
  }
490
1320
  if (subcommand === 'watch') {
491
- if (positional.length > 0 || notesPath !== undefined)
1321
+ if (positional.length > 0 || notesPath !== undefined || historyLimitProvided)
492
1322
  throw new Error('radar watch received unexpected arguments');
493
1323
  if ((failOn !== 'never' || failOnCompatibility !== 'never') && !once)
494
1324
  throw new Error('radar watch requires --once when a policy gate is used');
@@ -537,7 +1367,7 @@ async function runRadar(args) {
537
1367
  }
538
1368
  return 0;
539
1369
  }
540
- if (statePath !== undefined || osvBaseUrl !== undefined || registry !== undefined || once || intervalProvided || !deepCandidates || frozen || failOn !== 'never' || failOnCompatibility !== 'never') {
1370
+ if (statePath !== undefined || osvBaseUrl !== undefined || registry !== undefined || webhookUrl !== undefined || once || intervalProvided || historyLimitProvided || !deepCandidates || !githubAdvisories || threatIntel || frozen || failOn !== 'never' || failOnCompatibility !== 'never') {
541
1371
  throw new Error('radar compare does not accept check or watch options');
542
1372
  }
543
1373
  const config = await readConfig();
@@ -562,15 +1392,116 @@ async function runRadar(args) {
562
1392
  : `${renderRadarEvents(events)}Prepared ${analysisTasks.length} DSH compatibility analysis task(s).\n`);
563
1393
  return 0;
564
1394
  }
1395
+ async function runObserve(args) {
1396
+ const targetsPath = args[0];
1397
+ if (targetsPath === undefined || targetsPath.startsWith('-'))
1398
+ throw new Error('observe requires a targets.yml file');
1399
+ let statePath = 'observations.json';
1400
+ let reportPath;
1401
+ let agentCommand;
1402
+ let agentArgs = [];
1403
+ let llmEnvFile;
1404
+ let registry;
1405
+ let retryPending = false;
1406
+ let json = false;
1407
+ for (let index = 1; index < args.length; index += 1) {
1408
+ const argument = args[index];
1409
+ if (argument === '--json') {
1410
+ json = true;
1411
+ }
1412
+ else if (argument === '--retry-pending') {
1413
+ retryPending = true;
1414
+ }
1415
+ else if (argument === '--state' || argument === '--report' || argument === '--dsh-agent-command' || argument === '--dsh-agent-arg' || argument === '--llm-env-file' || argument === '--registry') {
1416
+ const value = args[index + 1];
1417
+ if (value === undefined || (value.startsWith('-') && argument !== '--dsh-agent-arg'))
1418
+ throw new Error(`${argument} requires a value`);
1419
+ if (argument === '--state')
1420
+ statePath = value;
1421
+ else if (argument === '--report')
1422
+ reportPath = value;
1423
+ else if (argument === '--dsh-agent-command')
1424
+ agentCommand = value;
1425
+ else if (argument === '--dsh-agent-arg')
1426
+ agentArgs.push(value);
1427
+ else if (argument === '--llm-env-file')
1428
+ llmEnvFile = value;
1429
+ else
1430
+ registry = value;
1431
+ index += 1;
1432
+ }
1433
+ else {
1434
+ throw new Error(`unknown option for observe: ${argument}`);
1435
+ }
1436
+ }
1437
+ const targetText = await readBoundedFile(targetsPath, 256 * 1024);
1438
+ const config = parseObserverConfigText(targetText);
1439
+ const previousState = await loadObservationState(statePath);
1440
+ const source = new UpstreamObserverClient({
1441
+ ...(process.env.GITHUB_TOKEN === undefined ? {} : { githubToken: process.env.GITHUB_TOKEN }),
1442
+ ...(registry === undefined ? {} : { registry }),
1443
+ });
1444
+ let agentOptions;
1445
+ if (agentCommand !== undefined) {
1446
+ agentOptions = {
1447
+ command: agentCommand,
1448
+ ...(agentArgs.length === 0 ? {} : { args: agentArgs }),
1449
+ };
1450
+ }
1451
+ if (agentCommand !== undefined && llmEnvFile !== undefined) {
1452
+ throw new Error('observe accepts either --dsh-agent-command or --llm-env-file, not both');
1453
+ }
1454
+ const result = await runObserver(config, previousState, {
1455
+ source,
1456
+ retryPending,
1457
+ ...(agentOptions === undefined ? {} : {
1458
+ agent: (task, prompt) => runDshAgentCommand(task, prompt, agentOptions),
1459
+ }),
1460
+ ...(llmEnvFile === undefined ? {} : {
1461
+ agent: (task, prompt) => runOpenAiCompatibleAgent(task, prompt, { envFile: resolve(llmEnvFile) }),
1462
+ }),
1463
+ });
1464
+ await saveObservationState(statePath, result.state);
1465
+ const reportJson = `${JSON.stringify(result.report, null, 2)}\n`;
1466
+ if (reportPath !== undefined) {
1467
+ await writeFile(resolve(reportPath), reportPath.endsWith('.json') ? reportJson : renderObserverReport(result.report));
1468
+ }
1469
+ process.stdout.write(json ? reportJson : renderObserverReport(result.report));
1470
+ if (result.report.errors.length > 0 || result.report.agent.failed > 0)
1471
+ return 1;
1472
+ return 0;
1473
+ }
1474
+ async function runQuickstart(args) {
1475
+ let directory = process.cwd();
1476
+ let json = false;
1477
+ let positional = false;
1478
+ for (const argument of args) {
1479
+ if (argument === '--json') {
1480
+ json = true;
1481
+ continue;
1482
+ }
1483
+ if (argument.startsWith('-'))
1484
+ throw new Error(`unknown option for quickstart: ${argument}`);
1485
+ if (positional)
1486
+ throw new Error('quickstart accepts at most one directory');
1487
+ directory = argument;
1488
+ positional = true;
1489
+ }
1490
+ const report = await createQuickstartReport(directory);
1491
+ process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : renderQuickstartReport(report));
1492
+ return 0;
1493
+ }
565
1494
  async function runSetup(args) {
566
1495
  let profile;
567
1496
  let output = 'upstream-radar.config.json';
568
- let patchFile;
1497
+ let patchFile = 'upstream-radar.dsh.yml';
569
1498
  let noInstall = false;
1499
+ let startDsh = false;
570
1500
  const initArgs = [];
571
1501
  const valueOptions = new Set([
572
1502
  '--profile', '--output', '--dsh-patch', '--project-id', '--project-name',
573
- '--repository', '--workspace', '--channel', '--registry',
1503
+ '--repository', '--workspace', '--channel', '--webhook-url-env', '--webhook-secret-env',
1504
+ '--registry', '--minimum-severity', '--quiet-hours',
574
1505
  ]);
575
1506
  for (let index = 0; index < args.length; index += 1) {
576
1507
  const argument = args[index];
@@ -578,6 +1509,14 @@ async function runSetup(args) {
578
1509
  noInstall = true;
579
1510
  continue;
580
1511
  }
1512
+ if (argument === '--no-dsh-patch') {
1513
+ patchFile = undefined;
1514
+ continue;
1515
+ }
1516
+ if (argument === '--start') {
1517
+ startDsh = true;
1518
+ continue;
1519
+ }
581
1520
  if (argument === '--json')
582
1521
  throw new Error('setup does not accept --json; use init --json');
583
1522
  if (argument === '--force') {
@@ -592,9 +1531,10 @@ async function runSetup(args) {
592
1531
  profile = value;
593
1532
  else if (argument === '--output')
594
1533
  output = value;
595
- else if (argument === '--dsh-patch')
1534
+ if (argument === '--dsh-patch')
596
1535
  patchFile = value;
597
- initArgs.push(argument ?? '', value);
1536
+ else
1537
+ initArgs.push(argument ?? '', value);
598
1538
  index += 1;
599
1539
  continue;
600
1540
  }
@@ -604,7 +1544,7 @@ async function runSetup(args) {
604
1544
  if (!noInstall && resolvedProfile === undefined) {
605
1545
  const candidates = await discoverDshProfiles();
606
1546
  if (candidates.length === 0) {
607
- throw new Error('setup could not find a DSH profile with third-party bundles; pass --profile <name>');
1547
+ throw new Error('setup could not find a DSH profile with third-party bundles; install one with `dsh plugin --profile <name> add <package>@<exact-version>`, then rerun setup');
608
1548
  }
609
1549
  if (candidates.length > 1) {
610
1550
  throw new Error(`setup found multiple DSH profiles with third-party bundles (${candidates.join(', ')}); pass --profile <name>`);
@@ -614,6 +1554,12 @@ async function runSetup(args) {
614
1554
  if (resolvedProfile !== undefined && profile === undefined) {
615
1555
  initArgs.unshift('--profile', resolvedProfile);
616
1556
  }
1557
+ // Setup is the beginner-facing path: make the generated DSH overlay the
1558
+ // default so a first run does not fall back to hidden environment variables.
1559
+ // `init` remains available when a user deliberately wants the legacy
1560
+ // environment-variable wiring instead.
1561
+ if (patchFile !== undefined)
1562
+ initArgs.push('--dsh-patch', patchFile);
617
1563
  if (!noInstall) {
618
1564
  if (resolvedProfile === undefined)
619
1565
  throw new Error('setup could not select a DSH profile');
@@ -627,6 +1573,10 @@ async function runSetup(args) {
627
1573
  stdio: 'inherit',
628
1574
  });
629
1575
  if (result.error !== undefined) {
1576
+ const spawnError = result.error;
1577
+ if (spawnError.code === 'ENOENT') {
1578
+ throw new Error('setup could not find the `dsh` command; install DeepSeek Harness, verify `dsh --help` works, then rerun setup');
1579
+ }
630
1580
  throw new Error(`setup could not run ${dshCommand}: ${safeErrorMessage(result.error.message)}`);
631
1581
  }
632
1582
  if (result.status !== 0) {
@@ -650,17 +1600,47 @@ async function runSetup(args) {
650
1600
  doctorOptions.patchFile = patchFile;
651
1601
  const doctor = await createDoctorReport(doctorOptions);
652
1602
  process.stdout.write(`\nLocal wiring check:\n${renderDoctorReport(doctor)}`);
653
- return doctor.status === 'blocked' ? 1 : 0;
1603
+ if (doctor.status === 'blocked')
1604
+ return 1;
1605
+ if (!startDsh)
1606
+ return 0;
1607
+ if (doctorProfile === undefined) {
1608
+ throw new Error('setup --start could not determine the DSH profile; pass --profile <name>');
1609
+ }
1610
+ const dshCommand = process.platform === 'win32' ? 'dsh.cmd' : 'dsh';
1611
+ const dshArgs = ['--profile', doctorProfile];
1612
+ const dshEnvironment = patchFile === undefined
1613
+ ? { ...process.env, UPSTREAM_RADAR_CONFIG: outputPath, UPSTREAM_RADAR_STATE: statePath }
1614
+ : process.env;
1615
+ if (patchFile !== undefined)
1616
+ dshArgs.push('--patch', patchFile);
1617
+ process.stdout.write(`\nStarting DSH profile ${doctorProfile}; press Ctrl-C to stop.\n`);
1618
+ const result = spawnSync(dshCommand, dshArgs, { env: dshEnvironment, stdio: 'inherit' });
1619
+ if (result.error !== undefined) {
1620
+ const spawnError = result.error;
1621
+ if (spawnError.code === 'ENOENT') {
1622
+ throw new Error('setup --start could not find the `dsh` command; install DeepSeek Harness, verify `dsh --help` works, then rerun setup');
1623
+ }
1624
+ throw new Error(`setup --start could not run ${dshCommand}: ${safeErrorMessage(result.error.message)}`);
1625
+ }
1626
+ return result.status ?? 1;
654
1627
  }
655
1628
  async function runInit(args) {
656
1629
  let profile;
1630
+ let npmLockPath;
1631
+ let pnpmLockPath;
1632
+ let rootSpec;
657
1633
  let output = 'upstream-radar.config.json';
658
1634
  let dshPatch;
659
1635
  let projectId;
660
1636
  let projectName;
661
1637
  let repository;
662
1638
  let workspace;
1639
+ let webhookUrlEnv;
1640
+ let webhookSecretEnv;
663
1641
  let registry;
1642
+ let minimumSeverity;
1643
+ let quietHoursValue;
664
1644
  let force = false;
665
1645
  let json = false;
666
1646
  const channels = [];
@@ -670,14 +1650,21 @@ async function runInit(args) {
670
1650
  force = true;
671
1651
  else if (argument === '--json')
672
1652
  json = true;
673
- else if (argument === '--profile' || argument === '--output' || argument === '--dsh-patch' || argument === '--project-id'
1653
+ else if (argument === '--profile' || argument === '--npm-lock' || argument === '--pnpm-lock' || argument === '--root' || argument === '--output' || argument === '--dsh-patch' || argument === '--project-id'
674
1654
  || argument === '--project-name' || argument === '--repository' || argument === '--workspace'
675
- || argument === '--channel' || argument === '--registry') {
1655
+ || argument === '--channel' || argument === '--webhook-url-env' || argument === '--webhook-secret-env'
1656
+ || argument === '--registry' || argument === '--minimum-severity' || argument === '--quiet-hours') {
676
1657
  const value = args[index + 1];
677
1658
  if (value === undefined || value.startsWith('-'))
678
1659
  throw new Error(`${argument} requires a value`);
679
1660
  if (argument === '--profile')
680
1661
  profile = value;
1662
+ else if (argument === '--npm-lock')
1663
+ npmLockPath = value;
1664
+ else if (argument === '--pnpm-lock')
1665
+ pnpmLockPath = value;
1666
+ else if (argument === '--root')
1667
+ rootSpec = value;
681
1668
  else if (argument === '--output')
682
1669
  output = value;
683
1670
  else if (argument === '--dsh-patch')
@@ -692,6 +1679,14 @@ async function runInit(args) {
692
1679
  workspace = value;
693
1680
  else if (argument === '--channel')
694
1681
  channels.push(value);
1682
+ else if (argument === '--webhook-url-env')
1683
+ webhookUrlEnv = value;
1684
+ else if (argument === '--webhook-secret-env')
1685
+ webhookSecretEnv = value;
1686
+ else if (argument === '--minimum-severity')
1687
+ minimumSeverity = value;
1688
+ else if (argument === '--quiet-hours')
1689
+ quietHoursValue = value;
695
1690
  else
696
1691
  registry = value;
697
1692
  index += 1;
@@ -700,6 +1695,68 @@ async function runInit(args) {
700
1695
  throw new Error(`unknown option for init: ${argument}`);
701
1696
  }
702
1697
  }
1698
+ const notificationPolicy = parseNotificationPolicyArguments(minimumSeverity, quietHoursValue);
1699
+ if (npmLockPath !== undefined || pnpmLockPath !== undefined) {
1700
+ if (npmLockPath !== undefined && pnpmLockPath !== undefined) {
1701
+ throw new Error('init accepts only one of --npm-lock or --pnpm-lock');
1702
+ }
1703
+ const lockKind = npmLockPath === undefined ? 'pnpm-lock' : 'npm-lock';
1704
+ const lockfile = npmLockPath ?? pnpmLockPath;
1705
+ if (lockfile === undefined)
1706
+ throw new Error('init lockfile path is missing');
1707
+ if (profile !== undefined || dshPatch !== undefined || registry !== undefined) {
1708
+ throw new Error(`init --${lockKind} does not accept --profile, --dsh-patch or --registry`);
1709
+ }
1710
+ const root = rootSpec === undefined
1711
+ ? await inferLockfileRoot(lockfile, lockKind === 'npm-lock' ? 'npm' : 'pnpm')
1712
+ : parseExactPackageCoordinate(rootSpec);
1713
+ const config = lockKind === 'npm-lock'
1714
+ ? await createRadarConfigFromNpmLock({
1715
+ lockfile,
1716
+ root,
1717
+ ...(projectId === undefined ? {} : { projectId }),
1718
+ ...(projectName === undefined ? {} : { projectName }),
1719
+ ...(repository === undefined ? {} : { repository }),
1720
+ ...(workspace === undefined ? {} : { workspace }),
1721
+ ...(channels.length === 0 ? {} : { channels }),
1722
+ ...(webhookUrlEnv === undefined ? {} : { webhookUrlEnv }),
1723
+ ...(webhookSecretEnv === undefined ? {} : { webhookSecretEnv }),
1724
+ ...(notificationPolicy === undefined ? {} : { notificationPolicy }),
1725
+ })
1726
+ : await createRadarConfigFromPnpmLock({
1727
+ lockfile,
1728
+ root,
1729
+ ...(projectId === undefined ? {} : { projectId }),
1730
+ ...(projectName === undefined ? {} : { projectName }),
1731
+ ...(repository === undefined ? {} : { repository }),
1732
+ ...(workspace === undefined ? {} : { workspace }),
1733
+ ...(channels.length === 0 ? {} : { channels }),
1734
+ ...(webhookUrlEnv === undefined ? {} : { webhookUrlEnv }),
1735
+ ...(webhookSecretEnv === undefined ? {} : { webhookSecretEnv }),
1736
+ ...(notificationPolicy === undefined ? {} : { notificationPolicy }),
1737
+ });
1738
+ const outputPath = await writeRadarConfig(config, { output: resolve(output), force });
1739
+ const plugin = config.projects[0]?.plugins[0];
1740
+ if (plugin === undefined)
1741
+ throw new Error(`${lockKind} initialization produced no plugin`);
1742
+ if (json) {
1743
+ process.stdout.write(`${JSON.stringify({
1744
+ output: outputPath,
1745
+ source: plugin.graph.source,
1746
+ root: plugin.package,
1747
+ nodes: plugin.graph.nodes.length,
1748
+ edges: plugin.graph.edges.length,
1749
+ ...(plugin.graph.unresolved === undefined ? {} : { unresolved: plugin.graph.unresolved.length }),
1750
+ }, null, 2)}\n`);
1751
+ }
1752
+ else {
1753
+ const unresolved = plugin.graph.unresolved?.length ?? 0;
1754
+ process.stdout.write(`Created ${outputPath}\nSource: ${plugin.graph.source ?? lockKind}\nRoot: ${plugin.package.name}@${plugin.package.version}\nGraph: ${plugin.graph.nodes.length} nodes, ${plugin.graph.edges.length} edges, ${unresolved} unresolved\nNext: upstream-radar radar check ${shellQuote(outputPath)} --frozen\n`);
1755
+ }
1756
+ return 0;
1757
+ }
1758
+ if (rootSpec !== undefined)
1759
+ throw new Error('init --root is only valid with --npm-lock or --pnpm-lock');
703
1760
  let autoSelected = false;
704
1761
  let resolvedProfile = profile;
705
1762
  if (resolvedProfile === undefined) {
@@ -742,6 +1799,9 @@ async function runInit(args) {
742
1799
  ...(repository === undefined ? {} : { repository }),
743
1800
  ...(workspace === undefined ? {} : { workspace }),
744
1801
  ...(channels.length === 0 ? {} : { channels }),
1802
+ ...(webhookUrlEnv === undefined ? {} : { webhookUrlEnv }),
1803
+ ...(webhookSecretEnv === undefined ? {} : { webhookSecretEnv }),
1804
+ ...(notificationPolicy === undefined ? {} : { notificationPolicy }),
745
1805
  ...(registry === undefined ? {} : { registry }),
746
1806
  });
747
1807
  config.dshProfile = { name: resolvedProfile };
@@ -756,7 +1816,13 @@ async function runInit(args) {
756
1816
  force,
757
1817
  });
758
1818
  const plugins = config.projects[0]?.plugins ?? [];
759
- const doctorCommand = `pnpm dlx --package=upstream-radar@${TOOL_VERSION} upstream-radar doctor ${shellQuote(outputPath)} --profile ${shellQuote(resolvedProfile)}${patchPath === undefined ? '' : ` --patch ${shellQuote(patchPath)}`}`;
1819
+ // Keep the printed recovery command independent of the package manager that
1820
+ // happened to launch this copy. Node ships with npm/npx, while a user may
1821
+ // have entered setup through pnpm, yarn, or a global binary. The command is
1822
+ // deliberately exact-versioned so the doctor checks the same release that
1823
+ // generated the config.
1824
+ const doctorCommand = `npx --yes upstream-radar@${TOOL_VERSION} doctor ${shellQuote(outputPath)} --profile ${shellQuote(resolvedProfile)}${patchPath === undefined ? '' : ` --patch ${shellQuote(patchPath)}`}`;
1825
+ const statusCommand = `npx --yes upstream-radar@${TOOL_VERSION} radar status ${shellQuote(outputPath)} --state ${shellQuote(statePath)}`;
760
1826
  const startCommand = patchPath === undefined
761
1827
  ? `dsh --profile ${shellQuote(resolvedProfile)}`
762
1828
  : `dsh --profile ${shellQuote(resolvedProfile)} --patch ${shellQuote(patchPath)}`;
@@ -768,6 +1834,7 @@ async function runInit(args) {
768
1834
  edges: plugin.graph.edges.length,
769
1835
  ...(plugin.graph.source === undefined ? {} : { source: plugin.graph.source }),
770
1836
  ...(plugin.graph.hostRuntime === undefined ? {} : { hostRuntimeNodes: plugin.graph.hostRuntime.resolvedNodes }),
1837
+ ...(plugin.graph.hostRuntime?.package === undefined ? {} : { hostRuntimePackage: plugin.graph.hostRuntime.package }),
771
1838
  ...(plugin.graph.unresolved === undefined ? {} : { unresolved: plugin.graph.unresolved.length }),
772
1839
  ...(plugin.graph.unresolved === undefined ? {} : {
773
1840
  requiredUnresolved: plugin.graph.unresolved.filter(item => item.kind !== 'optional').length,
@@ -784,13 +1851,14 @@ async function runInit(args) {
784
1851
  const requiredUnresolved = plugin.graph.unresolved?.filter(item => item.kind !== 'optional').length ?? 0;
785
1852
  const optionalUnresolved = unresolved - requiredUnresolved;
786
1853
  const hostRuntime = plugin.graph.hostRuntime?.resolvedNodes ?? 0;
787
- process.stdout.write(` ${plugin.package.name}@${plugin.package.version} (${plugin.graph.nodes.length} dependency nodes${plugin.graph.source === undefined ? '' : `, ${plugin.graph.source}`}${hostRuntime === 0 ? '' : `, ${hostRuntime} DSH host`}${requiredUnresolved === 0 ? '' : `, ${requiredUnresolved} required unresolved`}${optionalUnresolved === 0 ? '' : `, ${optionalUnresolved} optional absent`})\n`);
1854
+ const hostRuntimePackage = plugin.graph.hostRuntime?.package;
1855
+ process.stdout.write(` ${plugin.package.name}@${plugin.package.version} (${plugin.graph.nodes.length} dependency nodes${plugin.graph.source === undefined ? '' : `, ${plugin.graph.source}`}${hostRuntime === 0 ? '' : `, ${hostRuntime} DSH host`}${hostRuntimePackage === undefined ? '' : `, runtime ${hostRuntimePackage.name}@${hostRuntimePackage.version}`}${requiredUnresolved === 0 ? '' : `, ${requiredUnresolved} required unresolved`}${optionalUnresolved === 0 ? '' : `, ${optionalUnresolved} optional absent`})\n`);
788
1856
  }
789
1857
  if (patchPath === undefined) {
790
- process.stdout.write(`\nReview the generated inventory, then verify the wiring:\n ${doctorCommand}\n\nStart DSH (keep it running):\n export UPSTREAM_RADAR_CONFIG=${shellQuote(outputPath)}\n export UPSTREAM_RADAR_STATE=${shellQuote(statePath)}\n ${startCommand}\n`);
1858
+ process.stdout.write(`\nReview the generated inventory, then verify the wiring:\n ${doctorCommand}\n\nStart DSH (keep it running):\n export UPSTREAM_RADAR_CONFIG=${shellQuote(outputPath)}\n export UPSTREAM_RADAR_STATE=${shellQuote(statePath)}\n ${startCommand}\n\nAfter the first check, inspect the local result:\n ${statusCommand}\n`);
791
1859
  }
792
1860
  else {
793
- process.stdout.write(`Created ${patchPath}\n\nReview the generated inventory and DSH overlay, then verify the wiring:\n ${doctorCommand}\n\nStart DSH (keep it running):\n ${startCommand}\n`);
1861
+ process.stdout.write(`Created ${patchPath}\n\nReview the generated inventory and DSH overlay, then verify the wiring:\n ${doctorCommand}\n\nStart DSH (keep it running):\n ${startCommand}\n\nAfter the first check, inspect the local result:\n ${statusCommand}\n`);
794
1862
  }
795
1863
  }
796
1864
  return 0;
@@ -842,6 +1910,11 @@ async function runDoctor(args) {
842
1910
  return report.status === 'blocked' ? 1 : 0;
843
1911
  }
844
1912
  async function main(args) {
1913
+ const help = commandHelp(args);
1914
+ if (help !== undefined) {
1915
+ process.stdout.write(help);
1916
+ return 0;
1917
+ }
845
1918
  const command = args[0];
846
1919
  if (command === undefined || command === '--help' || command === '-h' || command === 'help') {
847
1920
  process.stdout.write(usage());
@@ -851,14 +1924,24 @@ async function main(args) {
851
1924
  process.stdout.write(`${TOOL_VERSION}\n`);
852
1925
  return 0;
853
1926
  }
1927
+ if (command === 'quickstart')
1928
+ return runQuickstart(args.slice(1));
854
1929
  if (command === 'setup')
855
1930
  return runSetup(args.slice(1));
856
1931
  if (command === 'init')
857
1932
  return runInit(args.slice(1));
858
1933
  if (command === 'doctor')
859
1934
  return runDoctor(args.slice(1));
1935
+ if (command === 'graph')
1936
+ return runGraph(args.slice(1));
1937
+ if (command === 'profile-check')
1938
+ return runDshProfileCheck(args.slice(1));
860
1939
  if (command === 'probe')
861
1940
  return runProbe(args.slice(1));
1941
+ if (command === 'demo')
1942
+ return runDemo(args.slice(1));
1943
+ if (command === 'observe')
1944
+ return runObserve(args.slice(1));
862
1945
  if (command === 'benchmark')
863
1946
  return runBenchmark(args.slice(1));
864
1947
  if (command === 'radar')
@@ -867,6 +1950,12 @@ async function main(args) {
867
1950
  return runTask(args.slice(1));
868
1951
  if (command === 'analysis')
869
1952
  return runAnalysis(args.slice(1));
1953
+ if (command === 'mute')
1954
+ return runIncidentMute(args.slice(1));
1955
+ if (command === 'unmute')
1956
+ return runIncidentUnmute(args.slice(1));
1957
+ if (command === 'triage')
1958
+ return runIncidentTriage(args.slice(1));
870
1959
  if (command !== 'scan' && command !== 'inspect')
871
1960
  throw new Error(`unknown command: ${command}`);
872
1961
  const target = args[1];