guardlink 1.3.0 → 1.4.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 (53) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +43 -1
  3. package/dist/agents/launcher.d.ts +1 -1
  4. package/dist/agents/launcher.js +1 -1
  5. package/dist/cli/index.d.ts +2 -0
  6. package/dist/cli/index.d.ts.map +1 -1
  7. package/dist/cli/index.js +300 -54
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp/server.d.ts +1 -0
  14. package/dist/mcp/server.d.ts.map +1 -1
  15. package/dist/mcp/server.js +38 -1
  16. package/dist/mcp/server.js.map +1 -1
  17. package/dist/mcp/suggest.d.ts +1 -0
  18. package/dist/mcp/suggest.d.ts.map +1 -1
  19. package/dist/mcp/suggest.js +1 -0
  20. package/dist/mcp/suggest.js.map +1 -1
  21. package/dist/parser/parse-project.d.ts.map +1 -1
  22. package/dist/parser/parse-project.js +103 -0
  23. package/dist/parser/parse-project.js.map +1 -1
  24. package/dist/tui/commands.d.ts +3 -0
  25. package/dist/tui/commands.d.ts.map +1 -1
  26. package/dist/tui/commands.js +297 -39
  27. package/dist/tui/commands.js.map +1 -1
  28. package/dist/tui/index.d.ts.map +1 -1
  29. package/dist/tui/index.js +17 -1
  30. package/dist/tui/index.js.map +1 -1
  31. package/dist/types/index.d.ts +39 -0
  32. package/dist/types/index.d.ts.map +1 -1
  33. package/dist/workspace/index.d.ts +12 -0
  34. package/dist/workspace/index.d.ts.map +1 -0
  35. package/dist/workspace/index.js +9 -0
  36. package/dist/workspace/index.js.map +1 -0
  37. package/dist/workspace/link.d.ts +91 -0
  38. package/dist/workspace/link.d.ts.map +1 -0
  39. package/dist/workspace/link.js +581 -0
  40. package/dist/workspace/link.js.map +1 -0
  41. package/dist/workspace/merge.d.ts +104 -0
  42. package/dist/workspace/merge.d.ts.map +1 -0
  43. package/dist/workspace/merge.js +752 -0
  44. package/dist/workspace/merge.js.map +1 -0
  45. package/dist/workspace/metadata.d.ts +34 -0
  46. package/dist/workspace/metadata.d.ts.map +1 -0
  47. package/dist/workspace/metadata.js +181 -0
  48. package/dist/workspace/metadata.js.map +1 -0
  49. package/dist/workspace/types.d.ts +134 -0
  50. package/dist/workspace/types.d.ts.map +1 -0
  51. package/dist/workspace/types.js +12 -0
  52. package/dist/workspace/types.js.map +1 -0
  53. package/package.json +1 -1
@@ -21,7 +21,7 @@
21
21
  * @handles secrets on #tui -- "Processes and stores API keys via /model"
22
22
  */
23
23
  import { resolve, basename } from 'node:path';
24
- import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
24
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
25
25
  import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, clearAnnotations } from '../parser/index.js';
26
26
  import { initProject, detectProject, promptAgentSelection, syncAgentFiles } from '../init/index.js';
27
27
  import { generateReport } from '../report/index.js';
@@ -35,6 +35,7 @@ import { resolveLLMConfig, saveTuiConfig, loadTuiConfig } from './config.js';
35
35
  import { AGENTS, parseAgentFlag, launchAgent, launchAgentInline, copyToClipboard, buildAnnotatePrompt } from '../agents/index.js';
36
36
  import { describeConfigSource } from '../agents/config.js';
37
37
  import { getReviewableExposures, applyReviewAction, summarizeReview } from '../review/index.js';
38
+ import { loadWorkspaceConfig, linkProject, addToWorkspace, removeFromWorkspace, mergeReports, formatMergeSummary, diffMergedReports } from '../workspace/index.js';
38
39
  // ─── Shared context ──────────────────────────────────────────────────
39
40
  /** Prompt user to pick an agent interactively (TUI only) */
40
41
  async function pickAgent(ctx) {
@@ -86,6 +87,7 @@ export function cmdHelp() {
86
87
  ['/assets', 'Asset tree with threat/control counts'],
87
88
  ['/files', 'Annotated file tree with exposure counts'],
88
89
  ['/view <file>', 'Show all annotations in a file with code context'],
90
+ ['/unannotated', 'List source files with no annotations'],
89
91
  ['', ''],
90
92
  ['/threat-report <fw>', 'AI threat report (stride|dread|pasta|attacker|rapid|general|custom)'],
91
93
  ['/threat-reports', 'List saved AI threat reports'],
@@ -101,6 +103,10 @@ export function cmdHelp() {
101
103
  ['/diff [ref]', 'Compare model against a git ref (default: HEAD~1)'],
102
104
  ['/sarif [-o file]', 'Export SARIF 2.1.0 for GitHub / VS Code'],
103
105
  ['', ''],
106
+ ['/workspace', 'Show workspace config and linked repos'],
107
+ ['/link <repos...>', 'Link repos into a workspace (--add / --remove)'],
108
+ ['/merge <files...>', 'Merge report JSONs into unified dashboard'],
109
+ ['', ''],
104
110
  ['/gal', 'GAL annotation language guide'],
105
111
  ['/help', 'This help'],
106
112
  ['/quit', 'Exit'],
@@ -131,112 +137,130 @@ export function cmdGal() {
131
137
  console.log(D(' Annotations live in source code comments. GuardLink parses'));
132
138
  console.log(D(' them to build a live threat model from your codebase.'));
133
139
  console.log('');
134
- console.log(D(' Syntax: @verb subject [preposition object] [: description]'));
140
+ console.log(D(' Syntax: @verb subject [preposition object] [-- "description"]'));
135
141
  console.log('');
136
142
  // ── DEFINITIONS ──────────────────────────────────────────────────
137
143
  console.log(H(' ── Definitions ─────────────────────────────────────────────'));
138
144
  console.log('');
139
- console.log(` ${V('@asset')} ${K('<path>')} ${D('[: description]')}`);
145
+ console.log(` ${V('@asset')} ${K('<path>')} ${D('[-- "description"]')}`);
140
146
  console.log(D(' Declare a named asset (component, service, data store).'));
141
147
  console.log(D(' Path uses dot notation for hierarchy.'));
142
- console.log(EX(' // @asset api.auth.token_store : Stores JWT refresh tokens'));
148
+ console.log(EX(' // @asset api.auth.token_store -- "Stores JWT refresh tokens"'));
143
149
  console.log(EX(' // @asset db.users'));
144
150
  console.log('');
145
- console.log(` ${V('@threat')} ${K('<name>')} ${D('[severity: critical|high|medium|low] [: description]')}`);
146
- console.log(D(' Declare a named threat. Severity aliases: P0=critical P1=high P2=medium P3=low.'));
147
- console.log(EX(' // @threat SQL Injection severity:high : Unsanitized input reaches DB'));
148
- console.log(EX(' // @threat Token Theft severity:P0'));
151
+ console.log(` ${V('@threat')} ${K('<name>')} ${D('(#id)')} ${D('[critical|high|medium|low]')} ${D('[ext-refs]')} ${D('[-- "description"]')}`);
152
+ console.log(D(' Declare a named threat. Severity in brackets: [P0]=[critical] [P1]=[high] [P2]=[medium] [P3]=[low].'));
153
+ console.log(EX(' // @threat SQL Injection (#sql-inj) [high] cwe:CWE-89 -- "Unsanitized input reaches DB"'));
154
+ console.log(EX(' // @threat Token Theft [P0]'));
149
155
  console.log('');
150
- console.log(` ${V('@control')} ${K('<name>')} ${D('[: description]')}`);
156
+ console.log(` ${V('@control')} ${K('<name>')} ${D('(#id)')} ${D('[-- "description"]')}`);
151
157
  console.log(D(' Declare a security control (mitigation mechanism).'));
152
- console.log(EX(' // @control Input Validation : Sanitize all user-supplied strings'));
158
+ console.log(EX(' // @control Input Validation (#input-val) -- "Sanitize all user-supplied strings"'));
153
159
  console.log(EX(' // @control Rate Limiting'));
154
160
  console.log('');
155
161
  // ── RELATIONSHIPS ─────────────────────────────────────────────────
156
162
  console.log(H(' ── Relationships ───────────────────────────────────────────'));
157
163
  console.log('');
158
- console.log(` ${V('@exposes')} ${K('<asset>')} ${D('to')} ${K('<threat>')} ${D('[severity: ...] [: description]')}`);
164
+ console.log(` ${V('@exposes')} ${K('<asset>')} ${D('to')} ${K('<threat>')} ${D('[severity]')} ${D('[ext-refs]')} ${D('[-- "description"]')}`);
159
165
  console.log(D(' Mark an asset as exposed to a threat at this code location.'));
160
166
  console.log(D(' This is the primary annotation — every exposure creates a finding.'));
161
- console.log(EX(' // @exposes api.auth to SQL Injection severity:high'));
162
- console.log(EX(' // @exposes db.users to Token Theft severity:critical : No token rotation'));
167
+ console.log(EX(' // @exposes api.auth to SQL Injection [high] cwe:CWE-89'));
168
+ console.log(EX(' // @exposes db.users to Token Theft [critical] -- "No token rotation"'));
163
169
  console.log('');
164
- console.log(` ${V('@mitigates')} ${K('<asset>')} ${D('against')} ${K('<threat>')} ${D('[with')} ${K('<control>')}${D('] [: description]')}`);
170
+ console.log(` ${V('@mitigates')} ${K('<asset>')} ${D('against')} ${K('<threat>')} ${D('[using')} ${K('<control>')}${D(']')} ${D('[-- "description"]')}`);
165
171
  console.log(D(' Mark that a control mitigates a threat on an asset.'));
166
172
  console.log(D(' Closes the exposure — removes it from open findings.'));
167
- console.log(EX(' // @mitigates api.auth against SQL Injection with Input Validation'));
168
- console.log(EX(' // @mitigates db.users against Token Theft : Rotation implemented in v2'));
173
+ console.log(D(' "using" is the primary keyword; "with" also accepted.'));
174
+ console.log(EX(' // @mitigates api.auth against SQL Injection using Input Validation'));
175
+ console.log(EX(' // @mitigates db.users against Token Theft -- "Rotation implemented in v2"'));
169
176
  console.log('');
170
- console.log(` ${V('@accepts')} ${K('<threat>')} ${D('on')} ${K('<asset>')} ${D('[: reason]')}`);
177
+ console.log(` ${V('@accepts')} ${K('<threat>')} ${D('on')} ${K('<asset>')} ${D('[-- "reason"]')}`);
171
178
  console.log(D(' Explicitly accept a risk. Removes it from open findings.'));
172
179
  console.log(D(' Use when the risk is known and intentionally not mitigated.'));
173
- console.log(EX(' // @accepts Timing Attack on api.auth : Acceptable for current threat model'));
180
+ console.log(EX(' // @accepts Timing Attack on api.auth -- "Acceptable for current threat model"'));
174
181
  console.log('');
175
- console.log(` ${V('@transfers')} ${K('<threat>')} ${D('from')} ${K('<source>')} ${D('to')} ${K('<target>')} ${D('[: description]')}`);
182
+ console.log(` ${V('@transfers')} ${K('<threat>')} ${D('from')} ${K('<source>')} ${D('to')} ${K('<target>')} ${D('[-- "description"]')}`);
176
183
  console.log(D(' Transfer responsibility for a threat to another asset/team.'));
177
- console.log(EX(' // @transfers DDoS from api.gateway to cdn.cloudflare : Handled by CDN layer'));
184
+ console.log(EX(' // @transfers DDoS from api.gateway to cdn.cloudflare -- "Handled by CDN layer"'));
178
185
  console.log('');
179
186
  // ── DATA FLOWS ────────────────────────────────────────────────────
180
187
  console.log(H(' ── Data Flows & Boundaries ─────────────────────────────────'));
181
188
  console.log('');
182
- console.log(` ${V('@flows')} ${K('<source>')} ${D('to')} ${K('<target>')} ${D('[via')} ${K('<mechanism>')}${D('] [: description]')}`);
189
+ console.log(` ${V('@flows')} ${K('<source>')} ${D('->')} ${K('<target>')} ${D('[via')} ${K('<mechanism>')}${D(']')} ${D('[-- "description"]')}`);
183
190
  console.log(D(' Document data movement between components.'));
184
191
  console.log(D(' Appears in the Data Flow Diagram.'));
185
- console.log(EX(' // @flows api.auth to db.users via TLS 1.3'));
186
- console.log(EX(' // @flows mobile.app to api.gateway via HTTPS : User credentials'));
192
+ console.log(EX(' // @flows api.auth -> db.users via TLS 1.3'));
193
+ console.log(EX(' // @flows mobile.app -> api.gateway via HTTPS -- "User credentials"'));
187
194
  console.log('');
188
- console.log(` ${V('@boundary')} ${K('<asset_a>')} ${D('and')} ${K('<asset_b>')} ${D('[: description]')}`);
195
+ console.log(` ${V('@boundary')} ${K('<asset_a>')} ${D('and')} ${K('<asset_b>')} ${D('(#id)')} ${D('[-- "description"]')}`);
189
196
  console.log(D(' Declare a trust boundary between two assets.'));
190
197
  console.log(D(' Groups assets in the Data Flow Diagram.'));
191
- console.log(EX(' // @boundary internet and api.gateway : Public-facing edge'));
192
- console.log(EX(' // @boundary api.gateway and db.users : Internal network boundary'));
198
+ console.log(D(' Alternate: @boundary between A and B or @boundary A | B'));
199
+ console.log(EX(' // @boundary internet and api.gateway (#edge) -- "Public-facing edge"'));
200
+ console.log(EX(' // @boundary api.gateway | db.users -- "Internal network boundary"'));
193
201
  console.log('');
194
202
  // ── LIFECYCLE ─────────────────────────────────────────────────────
195
203
  console.log(H(' ── Lifecycle & Governance ──────────────────────────────────'));
196
204
  console.log('');
197
- console.log(` ${V('@handles')} ${K('<classification>')} ${D('on')} ${K('<asset>')} ${D('[: description]')}`);
205
+ console.log(` ${V('@handles')} ${K('<classification>')} ${D('on')} ${K('<asset>')} ${D('[-- "description"]')}`);
198
206
  console.log(D(' Declare data classification handled by an asset.'));
199
207
  console.log(D(' Classifications: pii phi financial secrets internal public'));
200
- console.log(EX(' // @handles pii on db.users : Stores name, email, phone'));
208
+ console.log(EX(' // @handles pii on db.users -- "Stores name, email, phone"'));
201
209
  console.log(EX(' // @handles secrets on api.auth.token_store'));
202
210
  console.log('');
203
- console.log(` ${V('@owns')} ${K('<owner>')} ${K('<asset>')} ${D('[: description]')}`);
211
+ console.log(` ${V('@owns')} ${K('<owner>')} ${D('for')} ${K('<asset>')} ${D('[-- "description"]')}`);
204
212
  console.log(D(' Assign ownership of an asset to a team or person.'));
205
- console.log(EX(' // @owns platform-team api.auth'));
213
+ console.log(EX(' // @owns platform-team for api.auth'));
206
214
  console.log('');
207
- console.log(` ${V('@validates')} ${K('<control>')} ${D('on')} ${K('<asset>')} ${D('[: description]')}`);
215
+ console.log(` ${V('@validates')} ${K('<control>')} ${D('for')} ${K('<asset>')} ${D('[-- "description"]')}`);
208
216
  console.log(D(' Assert that a control has been validated/tested on an asset.'));
209
- console.log(EX(' // @validates Input Validation on api.auth : Pen-tested 2024-Q3'));
217
+ console.log(EX(' // @validates Input Validation for api.auth -- "Pen-tested 2024-Q3"'));
210
218
  console.log('');
211
- console.log(` ${V('@audit')} ${K('<asset>')} ${D('[: description]')}`);
219
+ console.log(` ${V('@audit')} ${K('<asset>')} ${D('[-- "description"]')}`);
212
220
  console.log(D(' Mark that this code path is an audit trail point.'));
213
- console.log(EX(' // @audit db.users : All writes logged to audit_log table'));
221
+ console.log(EX(' // @audit db.users -- "All writes logged to audit_log table"'));
214
222
  console.log('');
215
- console.log(` ${V('@assumes')} ${K('<asset>')} ${D('[: description]')}`);
223
+ console.log(` ${V('@assumes')} ${K('<asset>')} ${D('[-- "description"]')}`);
216
224
  console.log(D(' Document a security assumption about an asset.'));
217
- console.log(EX(' // @assumes api.gateway : Upstream WAF filters malformed requests'));
225
+ console.log(EX(' // @assumes api.gateway -- "Upstream WAF filters malformed requests"'));
218
226
  console.log('');
219
- console.log(` ${V('@comment')} ${D('[: description]')}`);
227
+ console.log(` ${V('@comment')} ${D('[-- "description"]')}`);
220
228
  console.log(D(' Free-form developer security note (no structural effect).'));
221
- console.log(EX(' // @comment : TODO — add rate limiting before v2 launch'));
229
+ console.log(EX(' // @comment -- "TODO — add rate limiting before v2 launch"'));
222
230
  console.log('');
223
231
  // ── SHIELD BLOCKS ─────────────────────────────────────────────────
224
232
  console.log(H(' ── Shield Blocks ───────────────────────────────────────────'));
225
233
  console.log('');
234
+ console.log(` ${V('@shield')} ${D('[-- "reason"]')}`);
235
+ console.log(D(' Single-line marker for a security-sensitive code point.'));
236
+ console.log(EX(' // @shield -- "Crypto key derivation — do not refactor without review"'));
237
+ console.log('');
226
238
  console.log(` ${V('@shield:begin')} ${D('/')} ${V('@shield:end')}`);
227
239
  console.log(D(' Wrap a code block to mark it as security-sensitive.'));
228
240
  console.log(D(' GuardLink will flag unannotated symbols inside the block.'));
229
- console.log(EX(' // @shield:begin'));
241
+ console.log(EX(' // @shield:begin -- "Auth verification block"'));
230
242
  console.log(EX(' function verifyToken(token: string) { ... }'));
231
243
  console.log(EX(' // @shield:end'));
232
244
  console.log('');
245
+ // ── EXTERNAL REFERENCES ─────────────────────────────────────────
246
+ console.log(H(' ── External References ─────────────────────────────────────'));
247
+ console.log('');
248
+ console.log(D(' Append space-separated refs after severity on @threat and @exposes:'));
249
+ console.log(EX(' cwe:CWE-89 owasp:A03:2021 capec:CAPEC-66 attack:T1190'));
250
+ console.log('');
251
+ console.log(D(' Example:'));
252
+ console.log(EX(' // @exposes api.auth to SQL Injection [high] cwe:CWE-89 owasp:A03:2021'));
253
+ console.log('');
233
254
  // ── TIPS ──────────────────────────────────────────────────────────
234
255
  console.log(H(' ── Tips ────────────────────────────────────────────────────'));
235
256
  console.log('');
257
+ console.log(D(' • Descriptions use -- "quoted text" format (not : colon)'));
258
+ console.log(D(' • Severity uses brackets: [critical] [high] [medium] [low] or [P0]-[P3]'));
236
259
  console.log(D(' • Annotations work in any comment style: // /* # -- <!-- -->'));
237
260
  console.log(D(' • Place annotations on the line ABOVE the code they describe'));
238
261
  console.log(D(' • Asset names are case-insensitive and normalized (spaces→underscores)'));
239
262
  console.log(D(' • Threat/control names can reference IDs with #id syntax'));
263
+ console.log(D(' • @flows uses -> arrow syntax (not "to")'));
240
264
  console.log(D(' • Run /parse after adding annotations to update the threat model'));
241
265
  console.log(D(' • Run /validate to check for syntax errors and dangling references'));
242
266
  console.log(D(' • Run /annotate to have an AI agent add annotations automatically'));
@@ -1595,4 +1619,238 @@ export async function cmdDashboard(ctx) {
1595
1619
  }
1596
1620
  console.log('');
1597
1621
  }
1622
+ // ─── /workspace ──────────────────────────────────────────────────────
1623
+ export function cmdWorkspace(ctx) {
1624
+ const config = loadWorkspaceConfig(ctx.root);
1625
+ if (!config) {
1626
+ console.log('');
1627
+ console.log(C.warn(' This repo is not part of a workspace.'));
1628
+ console.log(C.dim(' Use /link to create one, or guardlink link-project in the CLI.'));
1629
+ console.log('');
1630
+ return;
1631
+ }
1632
+ console.log('');
1633
+ console.log(` ${C.bold('Workspace:')} ${config.workspace}`);
1634
+ console.log(` ${C.bold('This repo:')} ${config.this_repo}`);
1635
+ console.log('');
1636
+ console.log(` ${C.bold('Linked repos')} (${config.repos.length}):`);
1637
+ for (const r of config.repos) {
1638
+ const isSelf = r.name === config.this_repo ? C.dim(' (this)') : '';
1639
+ const reg = r.registry ? C.dim(` → ${r.registry}`) : '';
1640
+ console.log(` ${r.name === config.this_repo ? C.green('●') : C.cyan('○')} ${r.name}${isSelf}${reg}`);
1641
+ }
1642
+ console.log('');
1643
+ console.log(C.dim(' /merge to combine reports · /link --add to add a repo · /link --remove to remove'));
1644
+ console.log('');
1645
+ }
1646
+ // ─── /link ───────────────────────────────────────────────────────────
1647
+ export async function cmdLink(args, ctx) {
1648
+ const parts = args.trim().split(/\s+/).filter(Boolean);
1649
+ // Parse flags
1650
+ let addPath;
1651
+ let removeName;
1652
+ let workspace = 'workspace';
1653
+ let registry;
1654
+ const repoPaths = [];
1655
+ for (let i = 0; i < parts.length; i++) {
1656
+ const p = parts[i];
1657
+ if (p === '--add' && parts[i + 1]) {
1658
+ addPath = parts[++i];
1659
+ }
1660
+ else if (p === '--remove' && parts[i + 1]) {
1661
+ removeName = parts[++i];
1662
+ }
1663
+ else if ((p === '-w' || p === '--workspace') && parts[i + 1]) {
1664
+ workspace = parts[++i];
1665
+ }
1666
+ else if ((p === '-r' || p === '--registry') && parts[i + 1]) {
1667
+ registry = parts[++i];
1668
+ }
1669
+ else {
1670
+ repoPaths.push(p);
1671
+ }
1672
+ }
1673
+ if (removeName) {
1674
+ // ── Remove mode ──
1675
+ console.log(C.dim(` Removing "${removeName}" from workspace...`));
1676
+ const result = removeFromWorkspace({
1677
+ repoName: removeName,
1678
+ existingRepoPath: ctx.root,
1679
+ });
1680
+ for (const name of result.updated)
1681
+ console.log(` ${C.green('↻')} ${name} — updated`);
1682
+ for (const f of result.agentFilesUpdated) {
1683
+ if (f.includes('(cleaned)'))
1684
+ console.log(` ${C.dim('🧹')} ${f}`);
1685
+ }
1686
+ for (const s of result.skipped)
1687
+ console.log(` ${C.warn('✗')} ${s.name} — ${s.reason}`);
1688
+ if (result.updated.length > 0) {
1689
+ console.log('');
1690
+ console.log(C.success(` ✓ Removed "${removeName}", updated ${result.updated.length} repo(s)`));
1691
+ }
1692
+ }
1693
+ else if (addPath) {
1694
+ // ── Add mode (--from is implicit: ctx.root) ──
1695
+ console.log(C.dim(` Adding ${addPath} to workspace...`));
1696
+ const result = addToWorkspace({
1697
+ newRepoPath: resolve(addPath),
1698
+ existingRepoPath: ctx.root,
1699
+ registry,
1700
+ });
1701
+ for (const name of result.initialized)
1702
+ console.log(` ${C.cyan('⚡')} ${name} — auto-initialized`);
1703
+ for (const name of result.linked)
1704
+ console.log(` ${C.green('✓')} ${name} — linked`);
1705
+ for (const name of result.updated)
1706
+ console.log(` ${C.green('↻')} ${name} — updated`);
1707
+ for (const s of result.skipped)
1708
+ console.log(` ${C.warn('✗')} ${s.name} — ${s.reason}`);
1709
+ if (result.linked.length > 0 || result.updated.length > 0) {
1710
+ console.log('');
1711
+ console.log(C.success(` ✓ ${result.linked.length} added, ${result.updated.length} updated`));
1712
+ }
1713
+ }
1714
+ else if (repoPaths.length >= 2) {
1715
+ // ── Fresh link mode ──
1716
+ console.log(C.dim(` Linking ${repoPaths.length} repos into "${workspace}"...`));
1717
+ const result = linkProject({
1718
+ workspace,
1719
+ repoPaths: repoPaths.map(p => resolve(p)),
1720
+ registry,
1721
+ });
1722
+ for (const name of result.initialized)
1723
+ console.log(` ${C.cyan('⚡')} ${name} — auto-initialized`);
1724
+ for (const name of result.linked)
1725
+ console.log(` ${C.green('✓')} ${name} — linked`);
1726
+ for (const s of result.skipped)
1727
+ console.log(` ${C.warn('✗')} ${s.name} — ${s.reason}`);
1728
+ if (result.linked.length > 0) {
1729
+ console.log('');
1730
+ console.log(C.success(` ✓ Linked ${result.linked.length} repo(s) into "${workspace}"`));
1731
+ }
1732
+ }
1733
+ else {
1734
+ console.log('');
1735
+ console.log(` ${C.bold('Usage:')}`);
1736
+ console.log(` /link <repo1> <repo2> ... ${C.dim('Fresh workspace setup')}`);
1737
+ console.log(` /link --add <repo-path> ${C.dim('Add a repo (uses current repo as reference)')}`);
1738
+ console.log(` /link --remove <repo-name> ${C.dim('Remove a repo by name')}`);
1739
+ console.log(` /link -w <name> -r <registry> ... ${C.dim('Set workspace name and registry')}`);
1740
+ }
1741
+ console.log('');
1742
+ }
1743
+ // ─── /merge ──────────────────────────────────────────────────────────
1744
+ export async function cmdMerge(args, ctx) {
1745
+ const parts = args.trim().split(/\s+/).filter(Boolean);
1746
+ // Parse flags
1747
+ let outputFile;
1748
+ let jsonFile;
1749
+ let diffAgainst;
1750
+ let workspaceName;
1751
+ const files = [];
1752
+ for (let i = 0; i < parts.length; i++) {
1753
+ const p = parts[i];
1754
+ if ((p === '-o' || p === '--output') && parts[i + 1]) {
1755
+ outputFile = parts[++i];
1756
+ }
1757
+ else if (p === '--json' && parts[i + 1]) {
1758
+ jsonFile = parts[++i];
1759
+ }
1760
+ else if (p === '--diff-against' && parts[i + 1]) {
1761
+ diffAgainst = parts[++i];
1762
+ }
1763
+ else if ((p === '-w' || p === '--workspace') && parts[i + 1]) {
1764
+ workspaceName = parts[++i];
1765
+ }
1766
+ else {
1767
+ files.push(resolve(p));
1768
+ }
1769
+ }
1770
+ if (files.length === 0) {
1771
+ console.log('');
1772
+ console.log(` ${C.bold('Usage:')}`);
1773
+ console.log(` /merge <report1.json> <report2.json> ...`);
1774
+ console.log(` /merge *.json -o dashboard.html --json merged.json`);
1775
+ console.log(` /merge *.json --diff-against last-week.json`);
1776
+ console.log('');
1777
+ return;
1778
+ }
1779
+ console.log(C.dim(` Merging ${files.length} report(s)...`));
1780
+ // Load and merge
1781
+ const reportJsons = [];
1782
+ for (const f of files) {
1783
+ if (!existsSync(f)) {
1784
+ console.log(C.warn(` ✗ File not found: ${f}`));
1785
+ continue;
1786
+ }
1787
+ try {
1788
+ reportJsons.push(JSON.parse(readFileSync(f, 'utf-8')));
1789
+ }
1790
+ catch (err) {
1791
+ console.log(C.warn(` ✗ Invalid JSON: ${f}`));
1792
+ }
1793
+ }
1794
+ if (reportJsons.length === 0) {
1795
+ console.log(C.error(' No valid reports to merge.'));
1796
+ console.log('');
1797
+ return;
1798
+ }
1799
+ const merged = await mergeReports(reportJsons, { workspace: workspaceName });
1800
+ // Summary
1801
+ const t = merged.totals;
1802
+ console.log('');
1803
+ console.log(` ${C.bold(merged.workspace)} — ${merged.repo_statuses.filter(r => r.loaded).length}/${merged.repo_statuses.length} repos loaded`);
1804
+ console.log(` ${t.annotations} annotations | ${t.assets} assets | ${t.threats} threats | ${t.controls} controls`);
1805
+ console.log(` ${t.mitigations} mitigations | ${t.exposures} exposures | ${t.unmitigated_exposures} unmitigated`);
1806
+ console.log(` ${t.flows} flows | ${t.external_refs_resolved} refs resolved | ${t.external_refs_unresolved} unresolved`);
1807
+ // Warnings
1808
+ for (const w of merged.warnings) {
1809
+ console.log(` ${C.warn('⚠')} ${w.message}`);
1810
+ }
1811
+ // Write JSON
1812
+ if (jsonFile) {
1813
+ writeFileSync(resolve(jsonFile), JSON.stringify(merged, null, 2));
1814
+ console.log(` ${C.green('✓')} Wrote merged JSON to ${jsonFile}`);
1815
+ }
1816
+ // Diff
1817
+ if (diffAgainst && existsSync(resolve(diffAgainst))) {
1818
+ try {
1819
+ const previous = JSON.parse(readFileSync(resolve(diffAgainst), 'utf-8'));
1820
+ const diff = diffMergedReports(merged, previous);
1821
+ if (diff.risk_delta === 'decreased') {
1822
+ console.log(` ${C.green('🟢')} Risk decreased since last merge`);
1823
+ }
1824
+ else if (diff.risk_delta === 'increased') {
1825
+ console.log(` ${C.red('🔴')} Risk increased since last merge`);
1826
+ }
1827
+ else {
1828
+ console.log(` ${C.dim('⚪')} Risk unchanged`);
1829
+ }
1830
+ if (diff.resolved_unmitigated > 0) {
1831
+ console.log(` ${C.green('🟢')} ${diff.resolved_unmitigated} exposure(s) now mitigated`);
1832
+ }
1833
+ if (diff.new_unmitigated > 0) {
1834
+ console.log(` ${C.red('🔴')} ${diff.new_unmitigated} new unmitigated exposure(s)`);
1835
+ }
1836
+ }
1837
+ catch {
1838
+ console.log(C.warn(` ✗ Could not parse diff file: ${diffAgainst}`));
1839
+ }
1840
+ }
1841
+ // Dashboard
1842
+ if (outputFile || !jsonFile) {
1843
+ const dashPath = resolve(outputFile || 'workspace-dashboard.html');
1844
+ const html = generateDashboardHTML(merged.model);
1845
+ writeFileSync(dashPath, html);
1846
+ console.log(` ${C.green('✓')} Dashboard: ${outputFile || 'workspace-dashboard.html'}`);
1847
+ }
1848
+ // Print summary
1849
+ const summary = formatMergeSummary(merged);
1850
+ console.log('');
1851
+ for (const line of summary.split('\n').slice(0, 15)) {
1852
+ console.log(` ${line}`);
1853
+ }
1854
+ console.log('');
1855
+ }
1598
1856
  //# sourceMappingURL=commands.js.map