upstream-radar 0.32.0 → 0.33.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.
Files changed (106) hide show
  1. package/README.md +396 -43
  2. package/cordis.patch.yml +1 -0
  3. package/dist/src/cli.js +950 -33
  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-runtime.d.ts +7 -0
  24. package/dist/src/dsh-runtime.d.ts.map +1 -1
  25. package/dist/src/dsh-runtime.js +21 -0
  26. package/dist/src/dsh-runtime.js.map +1 -1
  27. package/dist/src/github-advisory.d.ts +25 -0
  28. package/dist/src/github-advisory.d.ts.map +1 -0
  29. package/dist/src/github-advisory.js +335 -0
  30. package/dist/src/github-advisory.js.map +1 -0
  31. package/dist/src/graph.d.ts +2 -0
  32. package/dist/src/graph.d.ts.map +1 -1
  33. package/dist/src/graph.js +600 -4
  34. package/dist/src/graph.js.map +1 -1
  35. package/dist/src/index.d.ts +14 -6
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +13 -5
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/init.d.ts +44 -2
  40. package/dist/src/init.d.ts.map +1 -1
  41. package/dist/src/init.js +62 -5
  42. package/dist/src/init.js.map +1 -1
  43. package/dist/src/installed-graph.d.ts +3 -1
  44. package/dist/src/installed-graph.d.ts.map +1 -1
  45. package/dist/src/installed-graph.js +101 -2
  46. package/dist/src/installed-graph.js.map +1 -1
  47. package/dist/src/inventory.d.ts.map +1 -1
  48. package/dist/src/inventory.js +76 -2
  49. package/dist/src/inventory.js.map +1 -1
  50. package/dist/src/notification-policy.d.ts +20 -0
  51. package/dist/src/notification-policy.d.ts.map +1 -0
  52. package/dist/src/notification-policy.js +87 -0
  53. package/dist/src/notification-policy.js.map +1 -0
  54. package/dist/src/npm-release.d.ts.map +1 -1
  55. package/dist/src/npm-release.js +18 -7
  56. package/dist/src/npm-release.js.map +1 -1
  57. package/dist/src/osv.d.ts.map +1 -1
  58. package/dist/src/osv.js +1 -0
  59. package/dist/src/osv.js.map +1 -1
  60. package/dist/src/quickstart.d.ts +31 -0
  61. package/dist/src/quickstart.d.ts.map +1 -0
  62. package/dist/src/quickstart.js +266 -0
  63. package/dist/src/quickstart.js.map +1 -0
  64. package/dist/src/radar-history.d.ts +22 -0
  65. package/dist/src/radar-history.d.ts.map +1 -0
  66. package/dist/src/radar-history.js +80 -0
  67. package/dist/src/radar-history.js.map +1 -0
  68. package/dist/src/radar-render.d.ts.map +1 -1
  69. package/dist/src/radar-render.js +106 -1
  70. package/dist/src/radar-render.js.map +1 -1
  71. package/dist/src/radar-state.d.ts.map +1 -1
  72. package/dist/src/radar-state.js +264 -6
  73. package/dist/src/radar-state.js.map +1 -1
  74. package/dist/src/radar-status.d.ts +36 -1
  75. package/dist/src/radar-status.d.ts.map +1 -1
  76. package/dist/src/radar-status.js +199 -16
  77. package/dist/src/radar-status.js.map +1 -1
  78. package/dist/src/radar-types.d.ts +128 -3
  79. package/dist/src/radar-types.d.ts.map +1 -1
  80. package/dist/src/radar-types.js +4 -0
  81. package/dist/src/radar-types.js.map +1 -1
  82. package/dist/src/radar.d.ts +7 -1
  83. package/dist/src/radar.d.ts.map +1 -1
  84. package/dist/src/radar.js +432 -70
  85. package/dist/src/radar.js.map +1 -1
  86. package/dist/src/render.d.ts.map +1 -1
  87. package/dist/src/render.js +10 -0
  88. package/dist/src/render.js.map +1 -1
  89. package/dist/src/threat-intel.d.ts +46 -0
  90. package/dist/src/threat-intel.d.ts.map +1 -0
  91. package/dist/src/threat-intel.js +243 -0
  92. package/dist/src/threat-intel.js.map +1 -0
  93. package/dist/src/version.d.ts +1 -1
  94. package/dist/src/version.js +1 -1
  95. package/dist/src/vulnerability-priority.d.ts +16 -0
  96. package/dist/src/vulnerability-priority.d.ts.map +1 -0
  97. package/dist/src/vulnerability-priority.js +20 -0
  98. package/dist/src/vulnerability-priority.js.map +1 -0
  99. package/dist/src/webhook.d.ts +84 -0
  100. package/dist/src/webhook.d.ts.map +1 -0
  101. package/dist/src/webhook.js +432 -0
  102. package/dist/src/webhook.js.map +1 -0
  103. package/docs/README.zh-CN.md +394 -40
  104. package/package.json +22 -3
  105. package/schemas/quickstart.schema.json +56 -0
  106. package/schemas/webhook.schema.json +53 -0
package/dist/src/cli.js CHANGED
@@ -2,71 +2,414 @@
2
2
  import process from 'node:process';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { access, readFile } from 'node:fs/promises';
5
- import { resolve } from 'node:path';
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 { createDemoReport, renderDemo } from './demo.js';
11
12
  import { GitHubReleaseClient } from './github-release.js';
12
- import { createRadarConfigFromDshProfile, discoverDshProfiles, refreshRadarConfigFromConfiguredProfile, resolveDshProfileDirectory, writeDshPatch, writeRadarConfig } from './init.js';
13
+ import { parseNpmLockGraph, parsePnpmLockGraph } from './graph.js';
14
+ import { createRadarConfigFromDshProfile, createRadarConfigFromNpmLock, createRadarConfigFromPnpmLock, discoverDshProfiles, refreshRadarConfigFromConfiguredProfile, resolveDshProfileDirectory, writeDshPatch, writeRadarConfig } from './init.js';
13
15
  import { parsePackageManifestSnapshot, parseRadarConfig } from './inventory.js';
14
16
  import { inspectNpmPackage } from './npm.js';
15
17
  import { NpmCandidateGraphClient } from './npm-candidate.js';
16
18
  import { NpmReleaseClient } from './npm-release.js';
19
+ import { createNotificationPolicyMap, filterNotifiableRadarEvents } from './notification-policy.js';
17
20
  import { OsvClient } from './osv.js';
18
21
  import { verdictAtLeast } from './policy.js';
22
+ import { createQuickstartReport, renderQuickstartReport } from './quickstart.js';
23
+ import { GitHubAdvisoryClient } from './github-advisory.js';
19
24
  import { emptyRadarState, pollRadar } from './radar.js';
20
25
  import { evaluateRadarPolicy, RADAR_COMPATIBILITY_FAIL_THRESHOLDS, RADAR_FAIL_THRESHOLDS, renderRadarPolicy, } from './radar-policy.js';
21
26
  import { renderRadarEvents } from './radar-render.js';
27
+ import { createRadarHistory, renderRadarHistory } from './radar-history.js';
22
28
  import { loadRadarState, saveRadarState } from './radar-state.js';
23
- import { createRadarStatus, renderRadarStatus } from './radar-status.js';
29
+ import { createRadarNext, createRadarStatus, renderRadarNext, renderRadarStatus } from './radar-status.js';
24
30
  import { renderTextReport } from './render.js';
25
31
  import { scanDirectory } from './scan.js';
32
+ import { CisaKevClient, EpssClient } from './threat-intel.js';
26
33
  import { TOOL_VERSION } from './version.js';
34
+ import { eventsForRadarWebhookTarget, markRadarWebhookEventsDelivered, markRadarWebhookEventsDeliveredForRoute, normalizeRadarWebhookUrl, queueRadarWebhookEvents, queueRadarWebhookEventsForRoute, resolveRadarWebhookTargets, sendRadarWebhook, undeliveredRadarWebhookEvents, undeliveredRadarWebhookEventsForRoute, } from './webhook.js';
27
35
  const VALID_THRESHOLDS = new Set(['warn', 'review', 'block', 'never']);
28
36
  const VALID_RADAR_THRESHOLDS = new Set(RADAR_FAIL_THRESHOLDS);
29
37
  const VALID_RADAR_COMPATIBILITY_THRESHOLDS = new Set(RADAR_COMPATIBILITY_FAIL_THRESHOLDS);
38
+ const VALID_NOTIFICATION_SEVERITIES = new Set(['info', 'low', 'medium', 'high', 'critical']);
30
39
  function safeErrorMessage(value, maxLength = 2_048) {
31
40
  return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, character => (`\\u${character.codePointAt(0)?.toString(16).padStart(4, '0') ?? '0000'}`)).slice(0, maxLength);
32
41
  }
33
42
  function shellQuote(value) {
34
43
  return `'${value.replaceAll("'", "'\\''")}'`;
35
44
  }
45
+ function parseNotificationPolicyArguments(minimumSeverity, quietHoursValue) {
46
+ if (minimumSeverity === undefined && quietHoursValue === undefined)
47
+ return undefined;
48
+ if (minimumSeverity !== undefined && !VALID_NOTIFICATION_SEVERITIES.has(minimumSeverity)) {
49
+ throw new Error('--minimum-severity must be info, low, medium, high or critical');
50
+ }
51
+ let quietHours;
52
+ if (quietHoursValue !== undefined) {
53
+ const comma = quietHoursValue.indexOf(',');
54
+ const interval = comma < 0 ? '' : quietHoursValue.slice(comma + 1);
55
+ const dash = interval.indexOf('-');
56
+ const timezone = comma < 1 ? '' : quietHoursValue.slice(0, comma);
57
+ const start = dash < 1 ? '' : interval.slice(0, dash);
58
+ const end = dash < 0 ? '' : interval.slice(dash + 1);
59
+ if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(start)
60
+ || !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(end)
61
+ || start === end
62
+ || timezone.length === 0) {
63
+ throw new Error('--quiet-hours must use <IANA timezone>,<HH:MM>-<HH:MM> with different start and end times');
64
+ }
65
+ try {
66
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format();
67
+ }
68
+ catch {
69
+ throw new Error('--quiet-hours must start with a valid IANA timezone');
70
+ }
71
+ quietHours = { timezone, start, end };
72
+ }
73
+ return {
74
+ ...(minimumSeverity === undefined ? {} : { minimumSeverity: minimumSeverity }),
75
+ ...(quietHours === undefined ? {} : { quietHours }),
76
+ };
77
+ }
78
+ function commandUsage(key) {
79
+ const help = {
80
+ setup: `Upstream Radar — install the exact Radar bundle into DSH and prepare monitoring
81
+
82
+ Usage:
83
+ upstream-radar setup [--profile <name>] [--start] [--project-name <name>]
84
+
85
+ What it does:
86
+ Installs Radar into the selected DSH profile, writes a reviewable config and
87
+ --patch overlay, then runs the local doctor check. By default it does not
88
+ start DSH or execute plugin business actions; --start opts into launching DSH
89
+ only after the doctor check passes.
90
+
91
+ Prerequisite:
92
+ Install DeepSeek Harness first and verify that \`dsh --help\` works.
93
+
94
+ Common options:
95
+ --profile <name> select a DSH profile (auto-selects the only candidate)
96
+ --project-name <name> name shown in incidents and Agent tasks
97
+ --workspace <path> project workspace used to route Agent follow-up
98
+ --repository <url> repository evidence passed to the Agent task
99
+ --webhook-url-env <name> env var containing this project's HTTPS webhook URL
100
+ --webhook-secret-env <name> optional env var for a Feishu/Lark V2 signing secret
101
+ --output <path> Radar config path (default: ./upstream-radar.config.json)
102
+ --dsh-patch <path> DSH overlay path (default: ./upstream-radar.dsh.yml)
103
+ --minimum-severity <level> minimum vulnerability notice level
104
+ --quiet-hours <tz,start-end> e.g. Asia/Shanghai,22:00-08:00
105
+ --start start DSH after the local wiring check passes
106
+ --no-install reuse an already installed Radar bundle
107
+ --no-dsh-patch use legacy environment-variable wiring
108
+
109
+ Notification controls:
110
+ Add an optional \`notificationPolicy\` block to a generated projects[] entry
111
+ to set \`minimumSeverity\` and timezone-aware \`quietHours\`. This changes only
112
+ delivery; active evidence and queued tasks remain durable.
113
+
114
+ Next:
115
+ Review the generated files, then run the printed doctor command and start DSH
116
+ with the printed --patch command. Pass --start only when you explicitly accept
117
+ starting DSH in the same invocation after the doctor check passes.
118
+
119
+ If the profile has no third-party plugin yet:
120
+ dsh plugin --profile <name> add <package>@<exact-version>
121
+ `,
122
+ init: `Upstream Radar — create a reviewable inventory
123
+
124
+ Usage:
125
+ upstream-radar init --profile <name> [options]
126
+ upstream-radar init --pnpm-lock <pnpm-lock.yaml> [options]
127
+ upstream-radar init --npm-lock <package-lock.json> [options]
128
+
129
+ What it does:
130
+ Reads a DSH profile or lockfile without executing plugin code. Lockfile mode
131
+ is the pre-install path; it writes a static graph for a later frozen check.
132
+
133
+ Common options:
134
+ --root <package>@<exact-version> explicit lockfile root when it cannot be inferred
135
+ --project-name <name> name shown in incidents
136
+ --webhook-url-env <name> env var containing this project's HTTPS webhook URL
137
+ --webhook-secret-env <name> optional env var for a Feishu/Lark V2 signing secret
138
+ --output <path> config path (default: ./upstream-radar.config.json)
139
+ --dsh-patch <path> write a self-contained DSH overlay (profile mode)
140
+ --minimum-severity <level> minimum vulnerability notice level
141
+ --quiet-hours <tz,start-end> e.g. Asia/Shanghai,22:00-08:00
142
+ --json print a compact machine-readable summary
143
+ --force replace an existing output file
144
+
145
+ Notification controls:
146
+ Add an optional \`notificationPolicy\` block to a generated projects[] entry
147
+ to set \`minimumSeverity\` and timezone-aware \`quietHours\`. This changes only
148
+ delivery; active evidence and queued tasks remain durable.
149
+
150
+ Project webhook routing:
151
+ Store only environment variable names in the config. Set the URL and optional
152
+ Feishu secret in the DSH process environment; each project's changed events
153
+ then use its own endpoint while the native DSH Agent route remains workspace-based.
154
+
155
+ Next:
156
+ upstream-radar radar check ./upstream-radar.config.json --frozen
157
+ `,
158
+ doctor: `Upstream Radar — verify local wiring without polling upstream sources
159
+
160
+ Usage:
161
+ upstream-radar doctor [config.json] [--profile <name>] [--patch <path>] [--json]
162
+
163
+ Checks:
164
+ config/state readability, Radar registration in DSH, overlay alignment,
165
+ dependency coverage, and local webhook configuration. It does not contact
166
+ OSV, npm, GitHub, DSH, or a model.
167
+ `,
168
+ scan: `Upstream Radar — inspect a local package directory without running it
169
+
170
+ Usage:
171
+ upstream-radar scan <directory> [--json] [--fail-on <warn|review|block|never>]
172
+
173
+ Use this for an unpacked plugin before installation. Lifecycle scripts, remote
174
+ shell patterns, unsafe symlinks, mutable dependencies, and DSH bundle metadata
175
+ are recorded as bounded evidence.
176
+ `,
177
+ inspect: `Upstream Radar — inspect one exact npm artifact before installation
178
+
179
+ Usage:
180
+ upstream-radar inspect npm:<package>@<exact-version> [--deep] [--json]
181
+ [--registry <https-url>] [--fail-on <warn|review|block|never>]
182
+
183
+ --deep downloads the exact tarball, verifies npm integrity/signatures and
184
+ provenance when available, resolves the dependency graph with scripts disabled,
185
+ and queries the implemented vulnerability checks. An empty finding list is not
186
+ a safety certificate; check the coverage verdict before admitting the package.
187
+ The default gate exits 2 for review or block; use --fail-on block when review
188
+ should remain visible without failing CI.
189
+ `,
190
+ graph: `Upstream Radar — read a lockfile into the canonical dependency graph
191
+
192
+ Usage:
193
+ upstream-radar graph pnpm-lock <pnpm-lock.yaml> [--root <package>@<exact-version>] [--json]
194
+ upstream-radar graph npm-lock <package-lock.json> [--root <package>@<exact-version>] [--json]
195
+
196
+ This command is offline and does not install packages, run lifecycle scripts,
197
+ load plugin code, or query vulnerability sources.
198
+ `,
199
+ probe: `Upstream Radar — test whether a DSH bundle loads in disposable profiles
200
+
201
+ Usage:
202
+ upstream-radar probe dsh-load <package.tgz> [--dsh-version <exact-version>] [--json]
203
+ upstream-radar probe dsh-matrix <package.tgz> --dsh-version <v1>,<v2>,... [--json]
204
+
205
+ The probe is bounded and isolated. It is a compatibility/load check, not a
206
+ semantic safety review or a substitute for dependency monitoring.
207
+ `,
208
+ demo: `Upstream Radar — show the exact-path-to-DSH handoff without side effects
209
+
210
+ Usage:
211
+ upstream-radar demo [--json]
212
+
213
+ The demo is network-free and uses only a local fixture. It does not inspect your
214
+ repository, install a plugin, start DSH, or claim that its advisory is real.
215
+ `,
216
+ quickstart: `Upstream Radar — choose the smallest honest first-use path
217
+
218
+ Usage:
219
+ upstream-radar quickstart [directory] [--json]
220
+
221
+ What it does:
222
+ Inspects only the selected directory and local DSH profile metadata. It finds
223
+ an existing Radar config, one supported lockfile, or eligible DSH profiles,
224
+ then prints the next commands in the right order.
225
+
226
+ Safety:
227
+ This command never installs packages, starts DSH, queries vulnerability
228
+ sources, or executes plugin code. It does not choose between two lockfiles or
229
+ multiple DSH profiles on your behalf.
230
+ `,
231
+ benchmark: `Upstream Radar — run offline compatibility-rule contracts
232
+
233
+ Usage:
234
+ upstream-radar benchmark compatibility [--json]
235
+ `,
236
+ radar: `Upstream Radar — monitor a reviewed inventory
237
+
238
+ Usage:
239
+ upstream-radar radar check <config.json> [options]
240
+ upstream-radar radar watch <config.json> [options]
241
+ upstream-radar radar status <config.json> [options]
242
+ upstream-radar radar history <config.json> [options]
243
+ upstream-radar radar compare <config.json> <before.json> <candidate.json> [options]
244
+
245
+ Use 'radar status' and 'radar history' for local, no-network diagnosis. Use
246
+ --frozen for CI or any run that must not read a live DSH profile.
247
+ `,
248
+ 'radar check': `Upstream Radar — run one vulnerability and compatibility check
249
+
250
+ Usage:
251
+ upstream-radar radar check <config.json> [--state <state.json>] [--frozen]
252
+ [--fail-on <severity>] [--fail-on-compatibility <never|breaking|any>]
253
+ [--webhook <https-url>] [--threat-intel] [--json]
254
+
255
+ One cycle queries the configured sources and persists changed incidents. Use
256
+ --frozen when the config is the reviewed graph you want to enforce.
257
+
258
+ --threat-intel adds CISA KEV and FIRST EPSS prioritization signals for matched
259
+ CVEs. It is opt-in here; the native DSH bundle enables it by default.
260
+ `,
261
+ 'radar watch': `Upstream Radar — keep monitoring a reviewed inventory
262
+
263
+ Usage:
264
+ upstream-radar radar watch <config.json> [--interval <seconds>] [--once]
265
+ [--state <state.json>] [--frozen] [--fail-on <severity>]
266
+ [--fail-on-compatibility <never|breaking|any>] [--webhook <https-url>]
267
+ [--threat-intel]
268
+
269
+ Use --once in CI. A long-running watch keeps polling and should not be given a
270
+ policy gate that would make it exit on the first incident.
271
+ `,
272
+ 'radar status': `Upstream Radar — see the local monitoring snapshot
273
+
274
+ Usage:
275
+ upstream-radar radar status <config.json> [--state <state.json>]
276
+ [--fail-on <severity>] [--fail-on-compatibility <never|breaking|any>] [--json]
277
+
278
+ This command never polls OSV, npm, GitHub, or DSH. It shows whether monitoring
279
+ has run, coverage, active incidents, queued Agent tasks, and the next useful
280
+ action.
281
+ `,
282
+ 'radar next': `Upstream Radar — show the one next action for the highest-priority incident
283
+
284
+ Usage:
285
+ upstream-radar radar next <config.json> [--state <state.json>] [--json]
286
+
287
+ This command is read-only and does not poll upstream sources. It selects the
288
+ same first incident as 'radar status', then points to the pending DSH task,
289
+ verified analysis, or the next check command.
290
+ `,
291
+ 'radar history': `Upstream Radar — inspect the durable transition ledger
292
+
293
+ Usage:
294
+ upstream-radar radar history <config.json> [--state <state.json>] [--limit <n>] [--json]
295
+
296
+ This command is local-only and shows new, updated, resolved, and source-health
297
+ transitions, including their exact affected paths.
298
+ `,
299
+ 'radar compare': `Upstream Radar — compare one reviewed release candidate
300
+
301
+ Usage:
302
+ upstream-radar radar compare <config.json> <before.json> <candidate.json>
303
+ [--notes <release-notes.txt>] [--json]
304
+
305
+ The release notes are treated as untrusted evidence. This command does not
306
+ install or execute the candidate.
307
+ `,
308
+ task: `Upstream Radar — inspect or acknowledge the DSH analysis outbox
309
+
310
+ Usage:
311
+ upstream-radar task list <state.json> [--json]
312
+ upstream-radar task show <state.json> [task-id] [--json]
313
+ upstream-radar task ack <state.json> <task-id>
314
+ `,
315
+ analysis: `Upstream Radar — inspect verified DSH conclusions
316
+
317
+ Usage:
318
+ upstream-radar analysis list <state.json> [--json]
319
+ upstream-radar analysis show <state.json> [incident-id] [--json]
320
+ `,
321
+ mute: `Upstream Radar — pause delivery for one exact incident until a fixed expiry
322
+
323
+ Usage:
324
+ upstream-radar mute <state.json> <incident-id> --until <ISO-8601> [--force] [--json]
325
+
326
+ This pauses DSH and webhook delivery only. The active incident, dependency paths,
327
+ history, and status remain visible. The mute expires automatically. Critical and
328
+ malware incidents require --force.
329
+ `,
330
+ unmute: `Upstream Radar — resume delivery for one incident immediately
331
+
332
+ Usage:
333
+ upstream-radar unmute <state.json> <incident-id> [--json]
334
+ `,
335
+ triage: `Upstream Radar — record human follow-up for one active incident
336
+
337
+ Usage:
338
+ upstream-radar triage <state.json> <incident-id>
339
+ --status <open|in-progress|blocked|accepted-risk>
340
+ [--owner <name>] [--note <text>] [--due <ISO-8601>] [--json]
341
+
342
+ This records ownership and work context without resolving, hiding, or changing
343
+ the deterministic incident. A new event version requires a fresh follow-up.
344
+ Blocked and accepted-risk states require a note.
345
+ `,
346
+ };
347
+ return help[key];
348
+ }
349
+ function commandHelp(args) {
350
+ const helpIndex = args.findIndex(argument => argument === '--help' || argument === '-h');
351
+ if (helpIndex < 0)
352
+ return undefined;
353
+ const command = args[0];
354
+ if (command === undefined)
355
+ return undefined;
356
+ const nested = command === 'radar' || command === 'task' || command === 'analysis'
357
+ ? args[1]
358
+ : undefined;
359
+ const key = nested === undefined ? command : `${command} ${nested}`;
360
+ return commandUsage(key) ?? commandUsage(command);
361
+ }
36
362
  function usage() {
37
363
  return `Upstream Radar — always-on dependency and compatibility monitoring for DSH plugins
38
364
 
39
365
  Usage:
40
- upstream-radar setup [--profile <name>] [options]
366
+ upstream-radar <command> --help
367
+ upstream-radar setup [--profile <name>] [--start] [options]
41
368
  upstream-radar init [--profile <name>] [options]
369
+ upstream-radar init --pnpm-lock <pnpm-lock.yaml> [--root <package>@<exact-version>] [options]
370
+ upstream-radar init --npm-lock <package-lock.json> [--root <package>@<exact-version>] [options]
42
371
  upstream-radar doctor [config.json] [options]
43
372
  upstream-radar scan <directory> [--json] [--fail-on <warn|review|block|never>]
44
373
  upstream-radar inspect npm:<package>@<exact-version> [--deep] [--json] [--fail-on <warn|review|block|never>]
374
+ upstream-radar graph <npm-lock|pnpm-lock> <lockfile> [--root <package>@<exact-version>] [--json]
45
375
  upstream-radar probe dsh-load <package.tgz> [--dsh-version <exact-version>] [--timeout <seconds>] [--keep-profile] [--json]
46
376
  upstream-radar probe dsh-matrix <package.tgz> --dsh-version <v1>[,<v2>,...] [--timeout <seconds>] [--keep-profile] [--json]
377
+ upstream-radar demo [--json]
378
+ upstream-radar quickstart [directory] [--json]
47
379
  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]
380
+ 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]
381
+ 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
382
  upstream-radar radar status <config.json> [--state <state.json>] [--fail-on <severity>] [--fail-on-compatibility <never|breaking|any>] [--json]
383
+ upstream-radar radar next <config.json> [--state <state.json>] [--json]
384
+ upstream-radar radar history <config.json> [--state <state.json>] [--limit <n>] [--json]
51
385
  upstream-radar radar compare <config.json> <before.json> <candidate.json> [--notes <release-notes.txt>] [--json]
52
386
  upstream-radar task list <state.json> [--json]
53
387
  upstream-radar task show <state.json> [task-id] [--json]
54
388
  upstream-radar task ack <state.json> <task-id>
55
389
  upstream-radar analysis list <state.json> [--json]
56
390
  upstream-radar analysis show <state.json> [incident-id] [--json]
391
+ upstream-radar mute <state.json> <incident-id> --until <ISO-8601> [--force] [--json]
392
+ upstream-radar unmute <state.json> <incident-id> [--json]
393
+ upstream-radar triage <state.json> <incident-id> --status <open|in-progress|blocked|accepted-risk> [--owner <name>] [--note <text>] [--due <ISO-8601>] [--json]
57
394
  upstream-radar version
58
395
 
59
396
  Commands:
60
397
  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
398
+ init discover third-party bundles in DSH or initialize a reviewable lockfile inventory
62
399
  doctor check local Radar/DSH wiring without polling upstream sources
63
400
  scan bounded, read-only inspection of a local package directory
64
401
  inspect fetch and verify the exact npm artifact before inspecting its contents
402
+ graph read a lockfile into the canonical dependency graph without installing packages
65
403
  probe run a bounded DSH bundle-load check or version matrix in disposable profiles
404
+ demo show the exact-path-to-DSH handoff without network, DSH, or plugin installation
405
+ quickstart choose the smallest first-use path without changing the environment
66
406
  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
407
+ radar monitor vulnerability changes, watch continuously, find the next action, inspect status/history, or assess a candidate compatibility change
68
408
  task inspect or acknowledge the durable DSH analysis outbox
69
409
  analysis inspect verified DSH conclusions stored in the Radar state
410
+ mute pause delivery for one incident with a bounded expiry
411
+ unmute resume delivery for one incident immediately
412
+ triage record owner, work status, and handoff note for one incident
70
413
 
71
414
  Options:
72
415
  --deep resolve the dependency graph with scripts disabled and ask npm to verify signatures/provenance
@@ -74,16 +417,29 @@ Options:
74
417
  --no-deep-candidates skip bounded transitive dependency graph checks for upgrade candidates
75
418
  --state <path> persistent radar state (default: <config.json>.state.json)
76
419
  --osv-base-url <url> alternate HTTPS OSV API base URL
420
+ --no-github-advisories disable the independent GitHub Advisory Database check for radar check/watch
421
+ --threat-intel radar check/watch: add CISA KEV and FIRST EPSS signals for matched CVEs
422
+ --webhook <https-url> radar check/watch: POST changed events to an HTTPS endpoint
423
+ --webhook-url-env <name> init/setup: env var containing a project's HTTPS webhook URL
424
+ --webhook-secret-env <name> init/setup: optional Feishu/Lark V2 signing secret env var
77
425
  --interval <seconds> watch interval from 300 to 86400 seconds (default: 1800)
426
+ --limit <n> radar history: show 1 to 1000 recent transitions (default: 20)
78
427
  --once run one watch cycle and exit (useful for CI and demos)
79
428
  --frozen radar check/watch: use the reviewed graph in config without reading a local DSH profile
80
429
  --fail-on <value> scan/inspect verdict or radar severity: unknown|info|low|medium|high|critical|never
81
430
  --fail-on-compatibility <value> CI gate: never|breaking|any (default: never)
82
431
  --notes <path> release notes used as untrusted compatibility evidence
83
432
  --profile <name> DSH profile for init or doctor (init auto-selects the only candidate when omitted)
433
+ --npm-lock <path> init: build a static inventory from an npm v2/v3 package-lock.json
434
+ --pnpm-lock <path> init: build a static inventory from a pnpm v6/v9 lockfile
435
+ --root <coordinate> init/graph lockfiles: override the root; otherwise read package.json beside the lockfile
84
436
  --no-install setup: reuse an already installed upstream-radar bundle
85
437
  --output <path> init output path (default: ./upstream-radar.config.json)
86
- --dsh-patch <path> write a self-contained DSH --patch overlay (optional)
438
+ --dsh-patch <path> write a self-contained DSH --patch overlay (setup default: ./upstream-radar.dsh.yml)
439
+ --minimum-severity <level> init/setup notification threshold: info|low|medium|high|critical
440
+ --quiet-hours <tz,start-end> init/setup window, e.g. Asia/Shanghai,22:00-08:00
441
+ --start setup: start DSH after the local wiring check passes
442
+ --no-dsh-patch setup: keep the legacy UPSTREAM_RADAR_* environment-variable wiring
87
443
  --patch <path> DSH overlay to verify with doctor
88
444
  --force allow init to replace an existing output file
89
445
  --json emit the canonical JSON report
@@ -94,6 +450,89 @@ Exit codes:
94
450
  2 configured policy threshold was reached
95
451
  `;
96
452
  }
453
+ function parseExactPackageCoordinate(value) {
454
+ const at = value.lastIndexOf('@');
455
+ if (at <= 0 || at === value.length - 1)
456
+ throw new Error(`--root must be an exact package coordinate: ${value}`);
457
+ const name = value.slice(0, at);
458
+ const version = value.slice(at + 1);
459
+ if (name.startsWith('@') && !name.includes('/'))
460
+ throw new Error(`--root must include a scoped package name: ${value}`);
461
+ return { name, version };
462
+ }
463
+ async function inferLockfileRoot(lockfile, kind) {
464
+ const manifestPath = join(dirname(resolve(lockfile)), 'package.json');
465
+ try {
466
+ const manifest = parsePackageManifestSnapshot(await readJson(manifestPath));
467
+ return { name: manifest.name, version: manifest.version };
468
+ }
469
+ catch (error) {
470
+ if (error.code === 'ENOENT') {
471
+ throw new Error(`could not infer the ${kind} lockfile root from ${manifestPath}; pass --root <package>@<exact-version>`);
472
+ }
473
+ throw error;
474
+ }
475
+ }
476
+ async function runGraph(args) {
477
+ const kind = args[0];
478
+ if (kind !== 'npm-lock' && kind !== 'pnpm-lock')
479
+ throw new Error('graph requires the npm-lock or pnpm-lock subcommand');
480
+ const lockfile = args[1];
481
+ if (lockfile === undefined || lockfile.startsWith('-'))
482
+ throw new Error(`graph ${kind} requires a lockfile path`);
483
+ let rootSpec;
484
+ let json = false;
485
+ for (let index = 2; index < args.length; index += 1) {
486
+ const argument = args[index];
487
+ if (argument === '--json') {
488
+ json = true;
489
+ }
490
+ else if (argument === '--root') {
491
+ const value = args[index + 1];
492
+ if (value === undefined || value.startsWith('-'))
493
+ throw new Error('--root requires a value');
494
+ rootSpec = value;
495
+ index += 1;
496
+ }
497
+ else {
498
+ throw new Error(`unknown option for graph ${kind}: ${argument}`);
499
+ }
500
+ }
501
+ const packageRoot = rootSpec === undefined
502
+ ? await inferLockfileRoot(lockfile, kind === 'npm-lock' ? 'npm' : 'pnpm')
503
+ : parseExactPackageCoordinate(rootSpec);
504
+ const graph = kind === 'npm-lock'
505
+ ? parseNpmLockGraph(await readJson(lockfile), packageRoot)
506
+ : parsePnpmLockGraph(await readBoundedFile(lockfile, 16 * 1024 * 1024), packageRoot);
507
+ if (json) {
508
+ process.stdout.write(`${JSON.stringify(graph, null, 2)}\n`);
509
+ return 0;
510
+ }
511
+ const nodes = new Map(graph.nodes.map(node => [node.id, node]));
512
+ const root = nodes.get(graph.rootNodeId);
513
+ const display = (id) => {
514
+ const node = nodes.get(id);
515
+ return node === undefined ? id : `${node.name}@${node.version}`;
516
+ };
517
+ const unresolved = graph.unresolved ?? [];
518
+ process.stdout.write([
519
+ `Dependency graph: ${root === undefined ? graph.rootNodeId : `${root.name}@${root.version}`}`,
520
+ `Source: ${kind} (read-only; no install, no plugin execution)`,
521
+ `Nodes: ${graph.nodes.length}`,
522
+ `Edges: ${graph.edges.length}`,
523
+ `Unresolved: ${unresolved.length}`,
524
+ `Digest: ${graph.digest ?? '(none)'}`,
525
+ '',
526
+ 'Edges:',
527
+ ...(graph.edges.length === 0 ? [' (none)'] : graph.edges.map(edge => ` ${display(edge.from)} -[${edge.kind}]-> ${display(edge.to)}`)),
528
+ ...(unresolved.length === 0 ? [] : [
529
+ '',
530
+ 'Unresolved dependencies:',
531
+ ...unresolved.map(item => ` ${display(item.from)} -[${item.kind}]-> ${item.name} (${item.spec})`),
532
+ ]),
533
+ ].join('\n') + '\n');
534
+ return 0;
535
+ }
97
536
  async function runTask(args) {
98
537
  const subcommand = args[0];
99
538
  if (subcommand !== 'list' && subcommand !== 'show' && subcommand !== 'ack') {
@@ -162,6 +601,200 @@ async function runTask(args) {
162
601
  process.stdout.write(`Acknowledged ${safeErrorMessage(taskId)}.\n`);
163
602
  return 0;
164
603
  }
604
+ const MAX_INCIDENT_MUTE_MS = 30 * 24 * 60 * 60 * 1_000;
605
+ const VALID_INCIDENT_TRIAGE_STATUSES = new Set([
606
+ 'open',
607
+ 'in-progress',
608
+ 'blocked',
609
+ 'accepted-risk',
610
+ ]);
611
+ function activeEventForIncident(state, incidentId) {
612
+ return [
613
+ ...Object.values(state.activeVulnerabilities),
614
+ ...Object.values(state.activeCompatibility),
615
+ ...Object.values(state.activeSourceHealth ?? {}),
616
+ ].map(item => item.event).find(event => event.incidentId === incidentId);
617
+ }
618
+ async function runIncidentMute(args) {
619
+ const statePath = args[0];
620
+ const incidentId = args[1];
621
+ if (statePath === undefined || statePath.startsWith('-'))
622
+ throw new Error('mute requires a state file');
623
+ if (incidentId === undefined || incidentId.startsWith('-'))
624
+ throw new Error('mute requires an incident id');
625
+ let untilValue;
626
+ let force = false;
627
+ let json = false;
628
+ for (let index = 2; index < args.length; index += 1) {
629
+ const argument = args[index];
630
+ if (argument === undefined)
631
+ throw new Error('mute received an incomplete option');
632
+ if (argument === '--force')
633
+ force = true;
634
+ else if (argument === '--json')
635
+ json = true;
636
+ else if (argument === '--until') {
637
+ const value = args[index + 1];
638
+ if (value === undefined || value.startsWith('-'))
639
+ throw new Error('--until requires a value');
640
+ untilValue = value;
641
+ index += 1;
642
+ }
643
+ else if (argument.startsWith('-')) {
644
+ throw new Error(`unknown option for mute: ${argument}`);
645
+ }
646
+ else {
647
+ throw new Error(`mute received unexpected argument: ${argument}`);
648
+ }
649
+ }
650
+ if (untilValue === undefined)
651
+ throw new Error('mute requires --until <ISO-8601>');
652
+ const mutedUntilMs = Date.parse(untilValue);
653
+ const nowMs = Date.now();
654
+ if (!Number.isFinite(mutedUntilMs))
655
+ throw new Error('--until must be a valid ISO-8601 timestamp');
656
+ if (mutedUntilMs <= nowMs)
657
+ throw new Error('--until must be in the future');
658
+ if (mutedUntilMs > nowMs + MAX_INCIDENT_MUTE_MS)
659
+ throw new Error('--until cannot be more than 30 days away');
660
+ const state = await loadRadarState(statePath);
661
+ const event = activeEventForIncident(state, incidentId);
662
+ if (event === undefined)
663
+ throw new Error(`active incident not found: ${incidentId}`);
664
+ const forceRequired = event.kind === 'malware'
665
+ || (event.kind === 'vulnerability' && event.advisory.severity === 'critical');
666
+ if (forceRequired && !force)
667
+ throw new Error('critical or malware incidents require --force to mute');
668
+ const mutedUntil = new Date(mutedUntilMs).toISOString();
669
+ await saveRadarState(statePath, {
670
+ ...state,
671
+ incidentMutes: {
672
+ ...(state.incidentMutes ?? {}),
673
+ [incidentId]: { eventId: event.id, mutedUntil },
674
+ },
675
+ });
676
+ const result = { incidentId, eventId: event.id, mutedUntil, forced: force };
677
+ process.stdout.write(json
678
+ ? `${JSON.stringify(result, null, 2)}\n`
679
+ : `Muted delivery for ${safeErrorMessage(incidentId)} until ${mutedUntil}. Active evidence and history remain visible.\n`);
680
+ return 0;
681
+ }
682
+ async function runIncidentUnmute(args) {
683
+ const statePath = args[0];
684
+ const incidentId = args[1];
685
+ if (statePath === undefined || statePath.startsWith('-'))
686
+ throw new Error('unmute requires a state file');
687
+ if (incidentId === undefined || incidentId.startsWith('-'))
688
+ throw new Error('unmute requires an incident id');
689
+ let json = false;
690
+ for (const argument of args.slice(2)) {
691
+ if (argument === '--json')
692
+ json = true;
693
+ else
694
+ throw new Error(`unknown option for unmute: ${argument}`);
695
+ }
696
+ const state = await loadRadarState(statePath);
697
+ if (state.incidentMutes?.[incidentId] === undefined)
698
+ throw new Error(`incident is not muted: ${incidentId}`);
699
+ const nextIncidentMutes = Object.fromEntries(Object.entries(state.incidentMutes).filter(([id]) => id !== incidentId));
700
+ await saveRadarState(statePath, { ...state, incidentMutes: nextIncidentMutes });
701
+ const result = { incidentId, unmuted: true };
702
+ process.stdout.write(json
703
+ ? `${JSON.stringify(result, null, 2)}\n`
704
+ : `Resumed delivery for ${safeErrorMessage(incidentId)}.\n`);
705
+ return 0;
706
+ }
707
+ async function runIncidentTriage(args) {
708
+ const statePath = args[0];
709
+ const incidentId = args[1];
710
+ if (statePath === undefined || statePath.startsWith('-'))
711
+ throw new Error('triage requires a state file');
712
+ if (incidentId === undefined || incidentId.startsWith('-'))
713
+ throw new Error('triage requires an incident id');
714
+ let statusValue;
715
+ let ownerValue;
716
+ let noteValue;
717
+ let dueValue;
718
+ let json = false;
719
+ for (let index = 2; index < args.length; index += 1) {
720
+ const argument = args[index];
721
+ if (argument === undefined)
722
+ throw new Error('triage received an incomplete option');
723
+ if (argument === '--json')
724
+ json = true;
725
+ else if (argument === '--status' || argument === '--owner' || argument === '--note' || argument === '--due') {
726
+ const value = args[index + 1];
727
+ if (value === undefined || value.startsWith('-'))
728
+ throw new Error(`${argument} requires a value`);
729
+ if (argument === '--status')
730
+ statusValue = value;
731
+ else if (argument === '--owner')
732
+ ownerValue = value;
733
+ else if (argument === '--note')
734
+ noteValue = value;
735
+ else
736
+ dueValue = value;
737
+ index += 1;
738
+ }
739
+ else if (argument.startsWith('-')) {
740
+ throw new Error(`unknown option for triage: ${argument}`);
741
+ }
742
+ else {
743
+ throw new Error(`triage received unexpected argument: ${argument}`);
744
+ }
745
+ }
746
+ if (statusValue === undefined)
747
+ throw new Error('triage requires --status <open|in-progress|blocked|accepted-risk>');
748
+ if (!VALID_INCIDENT_TRIAGE_STATUSES.has(statusValue)) {
749
+ throw new Error(`invalid triage --status value: ${statusValue}`);
750
+ }
751
+ const status = statusValue;
752
+ const owner = ownerValue?.trim();
753
+ const note = noteValue?.trim();
754
+ if (ownerValue !== undefined && (owner === undefined || owner.length === 0))
755
+ throw new Error('--owner cannot be empty');
756
+ if (owner !== undefined && owner.length > 512)
757
+ throw new Error('--owner must be 512 characters or fewer');
758
+ if (noteValue !== undefined && (note === undefined || note.length === 0))
759
+ throw new Error('--note cannot be empty');
760
+ if (note !== undefined && note.length > 2_048)
761
+ throw new Error('--note must be 2048 characters or fewer');
762
+ const dueAtMs = dueValue === undefined ? undefined : Date.parse(dueValue);
763
+ if (dueValue !== undefined && !Number.isFinite(dueAtMs))
764
+ throw new Error('--due must be a valid ISO-8601 timestamp');
765
+ const state = await loadRadarState(statePath);
766
+ const event = activeEventForIncident(state, incidentId);
767
+ if (event === undefined)
768
+ throw new Error(`active incident not found: ${incidentId}`);
769
+ const previous = state.incidentTriage?.[incidentId];
770
+ const inherited = previous?.eventId === event.id ? previous : undefined;
771
+ const recordNote = note ?? inherited?.note;
772
+ if ((status === 'blocked' || status === 'accepted-risk') && recordNote === undefined) {
773
+ throw new Error(`triage status ${status} requires --note <text>`);
774
+ }
775
+ const recordOwner = owner ?? inherited?.owner;
776
+ const recordDueAt = dueAtMs === undefined ? inherited?.dueAt : new Date(dueAtMs).toISOString();
777
+ const record = {
778
+ eventId: event.id,
779
+ status,
780
+ updatedAt: new Date().toISOString(),
781
+ ...(recordOwner === undefined ? {} : { owner: recordOwner }),
782
+ ...(recordNote === undefined ? {} : { note: recordNote }),
783
+ ...(recordDueAt === undefined ? {} : { dueAt: recordDueAt }),
784
+ };
785
+ await saveRadarState(statePath, {
786
+ ...state,
787
+ incidentTriage: {
788
+ ...(state.incidentTriage ?? {}),
789
+ [incidentId]: record,
790
+ },
791
+ });
792
+ const result = { incidentId, ...record };
793
+ process.stdout.write(json
794
+ ? `${JSON.stringify(result, null, 2)}\n`
795
+ : `Recorded ${status} follow-up for ${safeErrorMessage(incidentId)}${record.owner === undefined ? '' : ` (owner: ${safeErrorMessage(record.owner)})`}.\n`);
796
+ return 0;
797
+ }
165
798
  async function runAnalysis(args) {
166
799
  const subcommand = args[0];
167
800
  if (subcommand !== 'list' && subcommand !== 'show')
@@ -258,6 +891,18 @@ async function runBenchmark(args) {
258
891
  process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : renderCompatibilityBenchmark(report));
259
892
  return report.summary.failed === 0 ? 0 : 1;
260
893
  }
894
+ function runDemo(args) {
895
+ let json = false;
896
+ for (const argument of args) {
897
+ if (argument === '--json')
898
+ json = true;
899
+ else
900
+ throw new Error(`unknown option for demo: ${argument}`);
901
+ }
902
+ const report = createDemoReport();
903
+ process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : renderDemo(report));
904
+ return 0;
905
+ }
261
906
  async function runProbe(args) {
262
907
  const mode = args[0];
263
908
  if (mode !== 'dsh-load' && mode !== 'dsh-matrix')
@@ -337,8 +982,8 @@ async function fileExists(path) {
337
982
  }
338
983
  async function runRadar(args) {
339
984
  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');
985
+ if (subcommand !== 'check' && subcommand !== 'watch' && subcommand !== 'status' && subcommand !== 'next' && subcommand !== 'history' && subcommand !== 'compare') {
986
+ throw new Error('radar requires check, watch, status, next, history or compare');
342
987
  }
343
988
  const configPath = args[1];
344
989
  if (configPath === undefined || configPath.startsWith('-'))
@@ -348,11 +993,16 @@ async function runRadar(args) {
348
993
  let statePath;
349
994
  let osvBaseUrl;
350
995
  let registry;
996
+ let webhookUrl;
351
997
  let notesPath;
352
998
  let intervalSeconds = 1_800;
353
999
  let intervalProvided = false;
1000
+ let historyLimit = 20;
1001
+ let historyLimitProvided = false;
354
1002
  let once = false;
355
1003
  let deepCandidates = true;
1004
+ let githubAdvisories = true;
1005
+ let threatIntel = false;
356
1006
  let frozen = false;
357
1007
  let failOn = 'never';
358
1008
  let failOnCompatibility = 'never';
@@ -367,11 +1017,17 @@ async function runRadar(args) {
367
1017
  else if (argument === '--no-deep-candidates') {
368
1018
  deepCandidates = false;
369
1019
  }
1020
+ else if (argument === '--no-github-advisories') {
1021
+ githubAdvisories = false;
1022
+ }
1023
+ else if (argument === '--threat-intel') {
1024
+ threatIntel = true;
1025
+ }
370
1026
  else if (argument === '--frozen') {
371
1027
  frozen = true;
372
1028
  }
373
- else if (argument === '--state' || argument === '--osv-base-url' || argument === '--registry'
374
- || argument === '--notes' || argument === '--interval' || argument === '--fail-on'
1029
+ else if (argument === '--state' || argument === '--osv-base-url' || argument === '--registry' || argument === '--webhook'
1030
+ || argument === '--notes' || argument === '--interval' || argument === '--limit' || argument === '--fail-on'
375
1031
  || argument === '--fail-on-compatibility') {
376
1032
  const value = args[index + 1];
377
1033
  if (value === undefined || value.startsWith('-'))
@@ -382,6 +1038,8 @@ async function runRadar(args) {
382
1038
  osvBaseUrl = value;
383
1039
  else if (argument === '--registry')
384
1040
  registry = value;
1041
+ else if (argument === '--webhook')
1042
+ webhookUrl = normalizeRadarWebhookUrl(value);
385
1043
  else if (argument === '--notes')
386
1044
  notesPath = value;
387
1045
  else if (argument === '--fail-on') {
@@ -396,6 +1054,14 @@ async function runRadar(args) {
396
1054
  }
397
1055
  failOnCompatibility = value;
398
1056
  }
1057
+ else if (argument === '--limit') {
1058
+ historyLimitProvided = true;
1059
+ const parsed = Number(value);
1060
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 1_000) {
1061
+ throw new Error('--limit must be an integer between 1 and 1000');
1062
+ }
1063
+ historyLimit = parsed;
1064
+ }
399
1065
  else {
400
1066
  intervalProvided = true;
401
1067
  const parsed = Number(value);
@@ -417,10 +1083,13 @@ async function runRadar(args) {
417
1083
  const readConfigForPoll = async () => frozen
418
1084
  ? readConfig()
419
1085
  : refreshRadarConfigFromConfiguredProfile(await readConfig());
420
- if (subcommand === 'status') {
1086
+ if (subcommand === 'status' || subcommand === 'next') {
421
1087
  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');
1088
+ || historyLimitProvided || osvBaseUrl !== undefined || registry !== undefined || webhookUrl !== undefined || !deepCandidates || !githubAdvisories || threatIntel || frozen || statePath === ':memory:'
1089
+ || (subcommand === 'next' && (failOn !== 'never' || failOnCompatibility !== 'never'))) {
1090
+ throw new Error(subcommand === 'next'
1091
+ ? 'radar next only accepts --state and --json options'
1092
+ : 'radar status only accepts --state, --fail-on, --fail-on-compatibility and --json options');
424
1093
  }
425
1094
  const config = await readConfig();
426
1095
  const stateFile = statePath ?? `${resolve(configPath)}.state.json`;
@@ -433,6 +1102,11 @@ async function runRadar(args) {
433
1102
  });
434
1103
  const policy = evaluateRadarPolicy(state, failOn, failOnCompatibility);
435
1104
  const policyEnabled = failOn !== 'never' || failOnCompatibility !== 'never';
1105
+ if (subcommand === 'next') {
1106
+ const next = createRadarNext(report, state);
1107
+ process.stdout.write(json ? `${JSON.stringify(next, null, 2)}\n` : renderRadarNext(next));
1108
+ return 0;
1109
+ }
436
1110
  if (json) {
437
1111
  process.stdout.write(!policyEnabled
438
1112
  ? `${JSON.stringify(report, null, 2)}\n`
@@ -445,6 +1119,27 @@ async function runRadar(args) {
445
1119
  return 1;
446
1120
  return policy.status === 'fail' ? 2 : 0;
447
1121
  }
1122
+ if (subcommand === 'history') {
1123
+ if (positional.length > 0 || notesPath !== undefined || once || intervalProvided
1124
+ || osvBaseUrl !== undefined || registry !== undefined || webhookUrl !== undefined || !deepCandidates || !githubAdvisories || frozen
1125
+ || failOn !== 'never' || failOnCompatibility !== 'never' || threatIntel) {
1126
+ throw new Error('radar history only accepts --state, --limit and --json options');
1127
+ }
1128
+ // Parse the config as an input check, but deliberately do not refresh it
1129
+ // or contact any upstream source. History is a local diagnosis command.
1130
+ await readConfig();
1131
+ const stateFile = statePath ?? `${resolve(configPath)}.state.json`;
1132
+ const stateExists = await fileExists(stateFile);
1133
+ const state = stateExists ? await loadRadarState(stateFile) : emptyRadarState();
1134
+ const report = createRadarHistory(state, {
1135
+ configFile: resolve(configPath),
1136
+ stateFile: resolve(stateFile),
1137
+ stateExists,
1138
+ limit: historyLimit,
1139
+ });
1140
+ process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : renderRadarHistory(report));
1141
+ return 0;
1142
+ }
448
1143
  const osv = new OsvClient({
449
1144
  ...(osvBaseUrl === undefined ? {} : { baseUrl: osvBaseUrl }),
450
1145
  });
@@ -455,14 +1150,60 @@ async function runRadar(args) {
455
1150
  ? new NpmCandidateGraphClient({ ...(registry === undefined ? {} : { registry }) })
456
1151
  : undefined;
457
1152
  const releaseNotesSource = new GitHubReleaseClient();
1153
+ const githubAdvisorySource = githubAdvisories
1154
+ ? new GitHubAdvisoryClient({ ...(process.env.GITHUB_TOKEN === undefined ? {} : { token: process.env.GITHUB_TOKEN }) })
1155
+ : undefined;
1156
+ const threatIntelSources = threatIntel
1157
+ ? [
1158
+ { name: 'cisa-kev', source: new CisaKevClient() },
1159
+ { name: 'epss', source: new EpssClient() },
1160
+ ]
1161
+ : [];
458
1162
  const stateFile = statePath ?? `${resolve(configPath)}.state.json`;
1163
+ if (webhookUrl !== undefined && statePath === ':memory:') {
1164
+ throw new Error('radar --webhook requires a persistent --state file so successful deliveries can be remembered');
1165
+ }
1166
+ const feishuSecret = process.env.UPSTREAM_RADAR_FEISHU_SECRET?.trim() || undefined;
459
1167
  const runCheck = async () => {
460
1168
  const config = await readConfigForPoll();
1169
+ const webhookTargets = resolveRadarWebhookTargets(config.projects, {
1170
+ ...(webhookUrl === undefined ? {} : { globalUrl: webhookUrl }),
1171
+ ...(feishuSecret === undefined ? {} : { globalFeishuSecret: feishuSecret }),
1172
+ });
1173
+ if (statePath === ':memory:' && webhookTargets.length > 0) {
1174
+ throw new Error('radar webhook routes require a persistent --state file so successful deliveries can be remembered');
1175
+ }
461
1176
  const state = statePath === ':memory:' ? emptyRadarState() : await loadRadarState(stateFile);
462
- const result = await pollRadar(config.projects, state, osv, new Date(), releases, releaseNotesSource, candidateGraphs);
1177
+ const checkedAt = new Date();
1178
+ const result = await pollRadar(config.projects, state, osv, checkedAt, releases, releaseNotesSource, candidateGraphs, githubAdvisorySource === undefined ? [] : [{ name: 'github-advisories', source: githubAdvisorySource }], threatIntelSources);
463
1179
  if (statePath !== ':memory:')
464
1180
  await saveRadarState(stateFile, result.state);
465
- return result;
1181
+ const notificationPolicies = createNotificationPolicyMap(config.projects);
1182
+ let nextState = result.state;
1183
+ for (const target of webhookTargets) {
1184
+ const targetEvents = eventsForRadarWebhookTarget(result.events, target);
1185
+ const isLegacyGlobal = webhookUrl !== undefined && target.projectIds === undefined;
1186
+ const queuedState = isLegacyGlobal
1187
+ ? queueRadarWebhookEvents(nextState, target.endpointHash, targetEvents)
1188
+ : queueRadarWebhookEventsForRoute(nextState, target.endpointHash, targetEvents);
1189
+ if (statePath !== ':memory:')
1190
+ await saveRadarState(stateFile, queuedState);
1191
+ const pendingWebhookEvents = filterNotifiableRadarEvents(isLegacyGlobal
1192
+ ? undeliveredRadarWebhookEvents(queuedState, target.endpointHash, targetEvents)
1193
+ : undeliveredRadarWebhookEventsForRoute(queuedState, target.endpointHash, targetEvents), notificationPolicies, checkedAt, queuedState);
1194
+ if (pendingWebhookEvents.length === 0) {
1195
+ nextState = queuedState;
1196
+ continue;
1197
+ }
1198
+ const payload = await sendRadarWebhook(target.url, pendingWebhookEvents, target.feishuSecret === undefined ? {} : { feishuSecret: target.feishuSecret });
1199
+ const deliveredIds = new Set(payload.events.map(event => event.id));
1200
+ const deliveredEvents = pendingWebhookEvents.filter(event => deliveredIds.has(event.id));
1201
+ nextState = isLegacyGlobal
1202
+ ? markRadarWebhookEventsDelivered(queuedState, target.endpointHash, deliveredEvents)
1203
+ : markRadarWebhookEventsDeliveredForRoute(queuedState, target.endpointHash, deliveredEvents);
1204
+ await saveRadarState(stateFile, nextState);
1205
+ }
1206
+ return { ...result, state: nextState };
466
1207
  };
467
1208
  const writeCheckResult = (result, compactJson = false) => {
468
1209
  const policy = evaluateRadarPolicy(result.state, failOn, failOnCompatibility);
@@ -478,7 +1219,7 @@ async function runRadar(args) {
478
1219
  return policy;
479
1220
  };
480
1221
  if (subcommand === 'check') {
481
- if (positional.length > 0 || notesPath !== undefined || once || intervalProvided) {
1222
+ if (positional.length > 0 || notesPath !== undefined || once || intervalProvided || historyLimitProvided) {
482
1223
  throw new Error('radar check received an option meant for watch or compare');
483
1224
  }
484
1225
  const result = await runCheck();
@@ -488,7 +1229,7 @@ async function runRadar(args) {
488
1229
  return policy.status === 'fail' ? 2 : 0;
489
1230
  }
490
1231
  if (subcommand === 'watch') {
491
- if (positional.length > 0 || notesPath !== undefined)
1232
+ if (positional.length > 0 || notesPath !== undefined || historyLimitProvided)
492
1233
  throw new Error('radar watch received unexpected arguments');
493
1234
  if ((failOn !== 'never' || failOnCompatibility !== 'never') && !once)
494
1235
  throw new Error('radar watch requires --once when a policy gate is used');
@@ -537,7 +1278,7 @@ async function runRadar(args) {
537
1278
  }
538
1279
  return 0;
539
1280
  }
540
- if (statePath !== undefined || osvBaseUrl !== undefined || registry !== undefined || once || intervalProvided || !deepCandidates || frozen || failOn !== 'never' || failOnCompatibility !== 'never') {
1281
+ if (statePath !== undefined || osvBaseUrl !== undefined || registry !== undefined || webhookUrl !== undefined || once || intervalProvided || historyLimitProvided || !deepCandidates || !githubAdvisories || threatIntel || frozen || failOn !== 'never' || failOnCompatibility !== 'never') {
541
1282
  throw new Error('radar compare does not accept check or watch options');
542
1283
  }
543
1284
  const config = await readConfig();
@@ -562,15 +1303,37 @@ async function runRadar(args) {
562
1303
  : `${renderRadarEvents(events)}Prepared ${analysisTasks.length} DSH compatibility analysis task(s).\n`);
563
1304
  return 0;
564
1305
  }
1306
+ async function runQuickstart(args) {
1307
+ let directory = process.cwd();
1308
+ let json = false;
1309
+ let positional = false;
1310
+ for (const argument of args) {
1311
+ if (argument === '--json') {
1312
+ json = true;
1313
+ continue;
1314
+ }
1315
+ if (argument.startsWith('-'))
1316
+ throw new Error(`unknown option for quickstart: ${argument}`);
1317
+ if (positional)
1318
+ throw new Error('quickstart accepts at most one directory');
1319
+ directory = argument;
1320
+ positional = true;
1321
+ }
1322
+ const report = await createQuickstartReport(directory);
1323
+ process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : renderQuickstartReport(report));
1324
+ return 0;
1325
+ }
565
1326
  async function runSetup(args) {
566
1327
  let profile;
567
1328
  let output = 'upstream-radar.config.json';
568
- let patchFile;
1329
+ let patchFile = 'upstream-radar.dsh.yml';
569
1330
  let noInstall = false;
1331
+ let startDsh = false;
570
1332
  const initArgs = [];
571
1333
  const valueOptions = new Set([
572
1334
  '--profile', '--output', '--dsh-patch', '--project-id', '--project-name',
573
- '--repository', '--workspace', '--channel', '--registry',
1335
+ '--repository', '--workspace', '--channel', '--webhook-url-env', '--webhook-secret-env',
1336
+ '--registry', '--minimum-severity', '--quiet-hours',
574
1337
  ]);
575
1338
  for (let index = 0; index < args.length; index += 1) {
576
1339
  const argument = args[index];
@@ -578,6 +1341,14 @@ async function runSetup(args) {
578
1341
  noInstall = true;
579
1342
  continue;
580
1343
  }
1344
+ if (argument === '--no-dsh-patch') {
1345
+ patchFile = undefined;
1346
+ continue;
1347
+ }
1348
+ if (argument === '--start') {
1349
+ startDsh = true;
1350
+ continue;
1351
+ }
581
1352
  if (argument === '--json')
582
1353
  throw new Error('setup does not accept --json; use init --json');
583
1354
  if (argument === '--force') {
@@ -592,9 +1363,10 @@ async function runSetup(args) {
592
1363
  profile = value;
593
1364
  else if (argument === '--output')
594
1365
  output = value;
595
- else if (argument === '--dsh-patch')
1366
+ if (argument === '--dsh-patch')
596
1367
  patchFile = value;
597
- initArgs.push(argument ?? '', value);
1368
+ else
1369
+ initArgs.push(argument ?? '', value);
598
1370
  index += 1;
599
1371
  continue;
600
1372
  }
@@ -604,7 +1376,7 @@ async function runSetup(args) {
604
1376
  if (!noInstall && resolvedProfile === undefined) {
605
1377
  const candidates = await discoverDshProfiles();
606
1378
  if (candidates.length === 0) {
607
- throw new Error('setup could not find a DSH profile with third-party bundles; pass --profile <name>');
1379
+ 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
1380
  }
609
1381
  if (candidates.length > 1) {
610
1382
  throw new Error(`setup found multiple DSH profiles with third-party bundles (${candidates.join(', ')}); pass --profile <name>`);
@@ -614,6 +1386,12 @@ async function runSetup(args) {
614
1386
  if (resolvedProfile !== undefined && profile === undefined) {
615
1387
  initArgs.unshift('--profile', resolvedProfile);
616
1388
  }
1389
+ // Setup is the beginner-facing path: make the generated DSH overlay the
1390
+ // default so a first run does not fall back to hidden environment variables.
1391
+ // `init` remains available when a user deliberately wants the legacy
1392
+ // environment-variable wiring instead.
1393
+ if (patchFile !== undefined)
1394
+ initArgs.push('--dsh-patch', patchFile);
617
1395
  if (!noInstall) {
618
1396
  if (resolvedProfile === undefined)
619
1397
  throw new Error('setup could not select a DSH profile');
@@ -627,6 +1405,10 @@ async function runSetup(args) {
627
1405
  stdio: 'inherit',
628
1406
  });
629
1407
  if (result.error !== undefined) {
1408
+ const spawnError = result.error;
1409
+ if (spawnError.code === 'ENOENT') {
1410
+ throw new Error('setup could not find the `dsh` command; install DeepSeek Harness, verify `dsh --help` works, then rerun setup');
1411
+ }
630
1412
  throw new Error(`setup could not run ${dshCommand}: ${safeErrorMessage(result.error.message)}`);
631
1413
  }
632
1414
  if (result.status !== 0) {
@@ -650,17 +1432,47 @@ async function runSetup(args) {
650
1432
  doctorOptions.patchFile = patchFile;
651
1433
  const doctor = await createDoctorReport(doctorOptions);
652
1434
  process.stdout.write(`\nLocal wiring check:\n${renderDoctorReport(doctor)}`);
653
- return doctor.status === 'blocked' ? 1 : 0;
1435
+ if (doctor.status === 'blocked')
1436
+ return 1;
1437
+ if (!startDsh)
1438
+ return 0;
1439
+ if (doctorProfile === undefined) {
1440
+ throw new Error('setup --start could not determine the DSH profile; pass --profile <name>');
1441
+ }
1442
+ const dshCommand = process.platform === 'win32' ? 'dsh.cmd' : 'dsh';
1443
+ const dshArgs = ['--profile', doctorProfile];
1444
+ const dshEnvironment = patchFile === undefined
1445
+ ? { ...process.env, UPSTREAM_RADAR_CONFIG: outputPath, UPSTREAM_RADAR_STATE: statePath }
1446
+ : process.env;
1447
+ if (patchFile !== undefined)
1448
+ dshArgs.push('--patch', patchFile);
1449
+ process.stdout.write(`\nStarting DSH profile ${doctorProfile}; press Ctrl-C to stop.\n`);
1450
+ const result = spawnSync(dshCommand, dshArgs, { env: dshEnvironment, stdio: 'inherit' });
1451
+ if (result.error !== undefined) {
1452
+ const spawnError = result.error;
1453
+ if (spawnError.code === 'ENOENT') {
1454
+ throw new Error('setup --start could not find the `dsh` command; install DeepSeek Harness, verify `dsh --help` works, then rerun setup');
1455
+ }
1456
+ throw new Error(`setup --start could not run ${dshCommand}: ${safeErrorMessage(result.error.message)}`);
1457
+ }
1458
+ return result.status ?? 1;
654
1459
  }
655
1460
  async function runInit(args) {
656
1461
  let profile;
1462
+ let npmLockPath;
1463
+ let pnpmLockPath;
1464
+ let rootSpec;
657
1465
  let output = 'upstream-radar.config.json';
658
1466
  let dshPatch;
659
1467
  let projectId;
660
1468
  let projectName;
661
1469
  let repository;
662
1470
  let workspace;
1471
+ let webhookUrlEnv;
1472
+ let webhookSecretEnv;
663
1473
  let registry;
1474
+ let minimumSeverity;
1475
+ let quietHoursValue;
664
1476
  let force = false;
665
1477
  let json = false;
666
1478
  const channels = [];
@@ -670,14 +1482,21 @@ async function runInit(args) {
670
1482
  force = true;
671
1483
  else if (argument === '--json')
672
1484
  json = true;
673
- else if (argument === '--profile' || argument === '--output' || argument === '--dsh-patch' || argument === '--project-id'
1485
+ else if (argument === '--profile' || argument === '--npm-lock' || argument === '--pnpm-lock' || argument === '--root' || argument === '--output' || argument === '--dsh-patch' || argument === '--project-id'
674
1486
  || argument === '--project-name' || argument === '--repository' || argument === '--workspace'
675
- || argument === '--channel' || argument === '--registry') {
1487
+ || argument === '--channel' || argument === '--webhook-url-env' || argument === '--webhook-secret-env'
1488
+ || argument === '--registry' || argument === '--minimum-severity' || argument === '--quiet-hours') {
676
1489
  const value = args[index + 1];
677
1490
  if (value === undefined || value.startsWith('-'))
678
1491
  throw new Error(`${argument} requires a value`);
679
1492
  if (argument === '--profile')
680
1493
  profile = value;
1494
+ else if (argument === '--npm-lock')
1495
+ npmLockPath = value;
1496
+ else if (argument === '--pnpm-lock')
1497
+ pnpmLockPath = value;
1498
+ else if (argument === '--root')
1499
+ rootSpec = value;
681
1500
  else if (argument === '--output')
682
1501
  output = value;
683
1502
  else if (argument === '--dsh-patch')
@@ -692,6 +1511,14 @@ async function runInit(args) {
692
1511
  workspace = value;
693
1512
  else if (argument === '--channel')
694
1513
  channels.push(value);
1514
+ else if (argument === '--webhook-url-env')
1515
+ webhookUrlEnv = value;
1516
+ else if (argument === '--webhook-secret-env')
1517
+ webhookSecretEnv = value;
1518
+ else if (argument === '--minimum-severity')
1519
+ minimumSeverity = value;
1520
+ else if (argument === '--quiet-hours')
1521
+ quietHoursValue = value;
695
1522
  else
696
1523
  registry = value;
697
1524
  index += 1;
@@ -700,6 +1527,68 @@ async function runInit(args) {
700
1527
  throw new Error(`unknown option for init: ${argument}`);
701
1528
  }
702
1529
  }
1530
+ const notificationPolicy = parseNotificationPolicyArguments(minimumSeverity, quietHoursValue);
1531
+ if (npmLockPath !== undefined || pnpmLockPath !== undefined) {
1532
+ if (npmLockPath !== undefined && pnpmLockPath !== undefined) {
1533
+ throw new Error('init accepts only one of --npm-lock or --pnpm-lock');
1534
+ }
1535
+ const lockKind = npmLockPath === undefined ? 'pnpm-lock' : 'npm-lock';
1536
+ const lockfile = npmLockPath ?? pnpmLockPath;
1537
+ if (lockfile === undefined)
1538
+ throw new Error('init lockfile path is missing');
1539
+ if (profile !== undefined || dshPatch !== undefined || registry !== undefined) {
1540
+ throw new Error(`init --${lockKind} does not accept --profile, --dsh-patch or --registry`);
1541
+ }
1542
+ const root = rootSpec === undefined
1543
+ ? await inferLockfileRoot(lockfile, lockKind === 'npm-lock' ? 'npm' : 'pnpm')
1544
+ : parseExactPackageCoordinate(rootSpec);
1545
+ const config = lockKind === 'npm-lock'
1546
+ ? await createRadarConfigFromNpmLock({
1547
+ lockfile,
1548
+ root,
1549
+ ...(projectId === undefined ? {} : { projectId }),
1550
+ ...(projectName === undefined ? {} : { projectName }),
1551
+ ...(repository === undefined ? {} : { repository }),
1552
+ ...(workspace === undefined ? {} : { workspace }),
1553
+ ...(channels.length === 0 ? {} : { channels }),
1554
+ ...(webhookUrlEnv === undefined ? {} : { webhookUrlEnv }),
1555
+ ...(webhookSecretEnv === undefined ? {} : { webhookSecretEnv }),
1556
+ ...(notificationPolicy === undefined ? {} : { notificationPolicy }),
1557
+ })
1558
+ : await createRadarConfigFromPnpmLock({
1559
+ lockfile,
1560
+ root,
1561
+ ...(projectId === undefined ? {} : { projectId }),
1562
+ ...(projectName === undefined ? {} : { projectName }),
1563
+ ...(repository === undefined ? {} : { repository }),
1564
+ ...(workspace === undefined ? {} : { workspace }),
1565
+ ...(channels.length === 0 ? {} : { channels }),
1566
+ ...(webhookUrlEnv === undefined ? {} : { webhookUrlEnv }),
1567
+ ...(webhookSecretEnv === undefined ? {} : { webhookSecretEnv }),
1568
+ ...(notificationPolicy === undefined ? {} : { notificationPolicy }),
1569
+ });
1570
+ const outputPath = await writeRadarConfig(config, { output: resolve(output), force });
1571
+ const plugin = config.projects[0]?.plugins[0];
1572
+ if (plugin === undefined)
1573
+ throw new Error(`${lockKind} initialization produced no plugin`);
1574
+ if (json) {
1575
+ process.stdout.write(`${JSON.stringify({
1576
+ output: outputPath,
1577
+ source: plugin.graph.source,
1578
+ root: plugin.package,
1579
+ nodes: plugin.graph.nodes.length,
1580
+ edges: plugin.graph.edges.length,
1581
+ ...(plugin.graph.unresolved === undefined ? {} : { unresolved: plugin.graph.unresolved.length }),
1582
+ }, null, 2)}\n`);
1583
+ }
1584
+ else {
1585
+ const unresolved = plugin.graph.unresolved?.length ?? 0;
1586
+ 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`);
1587
+ }
1588
+ return 0;
1589
+ }
1590
+ if (rootSpec !== undefined)
1591
+ throw new Error('init --root is only valid with --npm-lock or --pnpm-lock');
703
1592
  let autoSelected = false;
704
1593
  let resolvedProfile = profile;
705
1594
  if (resolvedProfile === undefined) {
@@ -742,6 +1631,9 @@ async function runInit(args) {
742
1631
  ...(repository === undefined ? {} : { repository }),
743
1632
  ...(workspace === undefined ? {} : { workspace }),
744
1633
  ...(channels.length === 0 ? {} : { channels }),
1634
+ ...(webhookUrlEnv === undefined ? {} : { webhookUrlEnv }),
1635
+ ...(webhookSecretEnv === undefined ? {} : { webhookSecretEnv }),
1636
+ ...(notificationPolicy === undefined ? {} : { notificationPolicy }),
745
1637
  ...(registry === undefined ? {} : { registry }),
746
1638
  });
747
1639
  config.dshProfile = { name: resolvedProfile };
@@ -756,7 +1648,13 @@ async function runInit(args) {
756
1648
  force,
757
1649
  });
758
1650
  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)}`}`;
1651
+ // Keep the printed recovery command independent of the package manager that
1652
+ // happened to launch this copy. Node ships with npm/npx, while a user may
1653
+ // have entered setup through pnpm, yarn, or a global binary. The command is
1654
+ // deliberately exact-versioned so the doctor checks the same release that
1655
+ // generated the config.
1656
+ const doctorCommand = `npx --yes upstream-radar@${TOOL_VERSION} doctor ${shellQuote(outputPath)} --profile ${shellQuote(resolvedProfile)}${patchPath === undefined ? '' : ` --patch ${shellQuote(patchPath)}`}`;
1657
+ const statusCommand = `npx --yes upstream-radar@${TOOL_VERSION} radar status ${shellQuote(outputPath)} --state ${shellQuote(statePath)}`;
760
1658
  const startCommand = patchPath === undefined
761
1659
  ? `dsh --profile ${shellQuote(resolvedProfile)}`
762
1660
  : `dsh --profile ${shellQuote(resolvedProfile)} --patch ${shellQuote(patchPath)}`;
@@ -768,6 +1666,7 @@ async function runInit(args) {
768
1666
  edges: plugin.graph.edges.length,
769
1667
  ...(plugin.graph.source === undefined ? {} : { source: plugin.graph.source }),
770
1668
  ...(plugin.graph.hostRuntime === undefined ? {} : { hostRuntimeNodes: plugin.graph.hostRuntime.resolvedNodes }),
1669
+ ...(plugin.graph.hostRuntime?.package === undefined ? {} : { hostRuntimePackage: plugin.graph.hostRuntime.package }),
771
1670
  ...(plugin.graph.unresolved === undefined ? {} : { unresolved: plugin.graph.unresolved.length }),
772
1671
  ...(plugin.graph.unresolved === undefined ? {} : {
773
1672
  requiredUnresolved: plugin.graph.unresolved.filter(item => item.kind !== 'optional').length,
@@ -784,13 +1683,14 @@ async function runInit(args) {
784
1683
  const requiredUnresolved = plugin.graph.unresolved?.filter(item => item.kind !== 'optional').length ?? 0;
785
1684
  const optionalUnresolved = unresolved - requiredUnresolved;
786
1685
  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`);
1686
+ const hostRuntimePackage = plugin.graph.hostRuntime?.package;
1687
+ 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
1688
  }
789
1689
  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`);
1690
+ 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
1691
  }
792
1692
  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`);
1693
+ 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
1694
  }
795
1695
  }
796
1696
  return 0;
@@ -842,6 +1742,11 @@ async function runDoctor(args) {
842
1742
  return report.status === 'blocked' ? 1 : 0;
843
1743
  }
844
1744
  async function main(args) {
1745
+ const help = commandHelp(args);
1746
+ if (help !== undefined) {
1747
+ process.stdout.write(help);
1748
+ return 0;
1749
+ }
845
1750
  const command = args[0];
846
1751
  if (command === undefined || command === '--help' || command === '-h' || command === 'help') {
847
1752
  process.stdout.write(usage());
@@ -851,14 +1756,20 @@ async function main(args) {
851
1756
  process.stdout.write(`${TOOL_VERSION}\n`);
852
1757
  return 0;
853
1758
  }
1759
+ if (command === 'quickstart')
1760
+ return runQuickstart(args.slice(1));
854
1761
  if (command === 'setup')
855
1762
  return runSetup(args.slice(1));
856
1763
  if (command === 'init')
857
1764
  return runInit(args.slice(1));
858
1765
  if (command === 'doctor')
859
1766
  return runDoctor(args.slice(1));
1767
+ if (command === 'graph')
1768
+ return runGraph(args.slice(1));
860
1769
  if (command === 'probe')
861
1770
  return runProbe(args.slice(1));
1771
+ if (command === 'demo')
1772
+ return runDemo(args.slice(1));
862
1773
  if (command === 'benchmark')
863
1774
  return runBenchmark(args.slice(1));
864
1775
  if (command === 'radar')
@@ -867,6 +1778,12 @@ async function main(args) {
867
1778
  return runTask(args.slice(1));
868
1779
  if (command === 'analysis')
869
1780
  return runAnalysis(args.slice(1));
1781
+ if (command === 'mute')
1782
+ return runIncidentMute(args.slice(1));
1783
+ if (command === 'unmute')
1784
+ return runIncidentUnmute(args.slice(1));
1785
+ if (command === 'triage')
1786
+ return runIncidentTriage(args.slice(1));
870
1787
  if (command !== 'scan' && command !== 'inspect')
871
1788
  throw new Error(`unknown command: ${command}`);
872
1789
  const target = args[1];