@repolensai/cli 0.1.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.
- package/README.md +70 -0
- package/bin/repolens.mjs +3518 -0
- package/lib/auth-store.d.ts +21 -0
- package/lib/auth-store.mjs +68 -0
- package/lib/git-history.d.ts +214 -0
- package/lib/git-history.mjs +1064 -0
- package/lib/history-metadata-shared.d.ts +18 -0
- package/lib/history-metadata-shared.mjs +87 -0
- package/lib/history-report-shared.d.ts +6 -0
- package/lib/history-report-shared.mjs +410 -0
- package/lib/protocol-handler.d.ts +15 -0
- package/lib/protocol-handler.mjs +158 -0
- package/lib/publish-shared.d.ts +14 -0
- package/lib/publish-shared.mjs +299 -0
- package/lib/runtime-events.d.ts +31 -0
- package/lib/runtime-events.mjs +80 -0
- package/package.json +38 -0
package/bin/repolens.mjs
ADDED
|
@@ -0,0 +1,3518 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import {
|
|
8
|
+
buildCliKeychainDescriptor,
|
|
9
|
+
normalizeStoredCliAuthConfig,
|
|
10
|
+
} from "../lib/auth-store.mjs";
|
|
11
|
+
import {
|
|
12
|
+
buildDiagramRows,
|
|
13
|
+
buildEvidenceRows,
|
|
14
|
+
collectChangeBriefRelatedFiles,
|
|
15
|
+
collectExplainerRelatedFiles,
|
|
16
|
+
exportChangeBriefToMarkdown,
|
|
17
|
+
exportExplainerToMarkdown,
|
|
18
|
+
} from "../lib/publish-shared.mjs";
|
|
19
|
+
import {
|
|
20
|
+
buildHistoryReportMarkdown,
|
|
21
|
+
buildHistoryReportSummary,
|
|
22
|
+
buildHistoryReportTitle,
|
|
23
|
+
collectHistoryReportRelatedFiles,
|
|
24
|
+
} from "../lib/history-report-shared.mjs";
|
|
25
|
+
import {
|
|
26
|
+
buildHistoryReportMetadata as buildCompactHistoryMetadata,
|
|
27
|
+
} from "../lib/history-metadata-shared.mjs";
|
|
28
|
+
import {
|
|
29
|
+
buildLinuxDesktopEntry,
|
|
30
|
+
buildTerminalLaunchScript,
|
|
31
|
+
buildWindowsTerminalLaunchScript,
|
|
32
|
+
isSafeRepoLensCommand,
|
|
33
|
+
parseRepoLensProtocolUrl,
|
|
34
|
+
} from "../lib/protocol-handler.mjs";
|
|
35
|
+
import {
|
|
36
|
+
reportCliRuntimeEvent,
|
|
37
|
+
} from "../lib/runtime-events.mjs";
|
|
38
|
+
import {
|
|
39
|
+
collectEvolutionReport,
|
|
40
|
+
collectHotspotReport,
|
|
41
|
+
collectOwnershipReport,
|
|
42
|
+
collectStartHereReport,
|
|
43
|
+
collectTraceReport,
|
|
44
|
+
} from "../lib/git-history.mjs";
|
|
45
|
+
|
|
46
|
+
const AGENT_CANDIDATES = {
|
|
47
|
+
codex: ["codex"],
|
|
48
|
+
claude: ["claude", "claude-code"],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const KEY_COMPONENT_SCHEMA = {
|
|
52
|
+
type: "object",
|
|
53
|
+
required: ["name", "role", "files"],
|
|
54
|
+
properties: {
|
|
55
|
+
name: { type: "string" },
|
|
56
|
+
role: { type: "string" },
|
|
57
|
+
files: { type: "array", items: { type: "string" } },
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const DIAGRAM_SCHEMA = {
|
|
62
|
+
type: "object",
|
|
63
|
+
required: ["type", "title", "mermaidSource", "relatedFiles"],
|
|
64
|
+
properties: {
|
|
65
|
+
type: { type: "string" },
|
|
66
|
+
title: { type: "string" },
|
|
67
|
+
mermaidSource: { type: "string" },
|
|
68
|
+
relatedFiles: { type: "array", items: { type: "string" } },
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const EVIDENCE_SCHEMA = {
|
|
73
|
+
type: "object",
|
|
74
|
+
required: ["type", "reference", "description", "lineRange", "codeSnippet", "confidence"],
|
|
75
|
+
properties: {
|
|
76
|
+
type: { type: "string" },
|
|
77
|
+
reference: { type: "string" },
|
|
78
|
+
description: { type: "string" },
|
|
79
|
+
lineRange: {
|
|
80
|
+
type: ["array", "null"],
|
|
81
|
+
items: { type: "number" },
|
|
82
|
+
minItems: 2,
|
|
83
|
+
maxItems: 2,
|
|
84
|
+
},
|
|
85
|
+
codeSnippet: { type: ["string", "null"] },
|
|
86
|
+
confidence: { type: "string", enum: ["explicit", "strongly_inferred", "hypothesis"] },
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const EXPLAINER_SCHEMA = {
|
|
91
|
+
type: "object",
|
|
92
|
+
required: [
|
|
93
|
+
"title",
|
|
94
|
+
"summary",
|
|
95
|
+
"valueProposition",
|
|
96
|
+
"userJourney",
|
|
97
|
+
"keyComponents",
|
|
98
|
+
"businessLogic",
|
|
99
|
+
"diagrams",
|
|
100
|
+
"risksAndQuestions",
|
|
101
|
+
"openQuestions",
|
|
102
|
+
"evidence",
|
|
103
|
+
],
|
|
104
|
+
properties: {
|
|
105
|
+
title: { type: "string" },
|
|
106
|
+
summary: { type: "string" },
|
|
107
|
+
valueProposition: { type: "string" },
|
|
108
|
+
userJourney: { type: "string" },
|
|
109
|
+
keyComponents: { type: "array", items: KEY_COMPONENT_SCHEMA },
|
|
110
|
+
businessLogic: { type: "string" },
|
|
111
|
+
diagrams: { type: "array", items: DIAGRAM_SCHEMA },
|
|
112
|
+
risksAndQuestions: { type: "string" },
|
|
113
|
+
openQuestions: { type: "array", items: { type: "string" } },
|
|
114
|
+
evidence: { type: "array", items: EVIDENCE_SCHEMA },
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const CHANGE_FEATURE_SCHEMA = {
|
|
119
|
+
type: "object",
|
|
120
|
+
required: ["feature", "impactType", "description", "confidence"],
|
|
121
|
+
properties: {
|
|
122
|
+
feature: { type: "string" },
|
|
123
|
+
impactType: { type: "string" },
|
|
124
|
+
description: { type: "string" },
|
|
125
|
+
confidence: { type: "string" },
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const CHANGE_FILE_SCHEMA = {
|
|
130
|
+
type: "object",
|
|
131
|
+
required: ["path", "changeType", "role"],
|
|
132
|
+
properties: {
|
|
133
|
+
path: { type: "string" },
|
|
134
|
+
changeType: { type: "string" },
|
|
135
|
+
role: { type: "string" },
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const CHANGE_BRIEF_SCHEMA = {
|
|
140
|
+
type: "object",
|
|
141
|
+
required: [
|
|
142
|
+
"summary",
|
|
143
|
+
"impactedFeatures",
|
|
144
|
+
"impactedFiles",
|
|
145
|
+
"impactedScreens",
|
|
146
|
+
"rolloutRisks",
|
|
147
|
+
"qaChecklist",
|
|
148
|
+
"communicationBrief",
|
|
149
|
+
"diagrams",
|
|
150
|
+
"evidence",
|
|
151
|
+
],
|
|
152
|
+
properties: {
|
|
153
|
+
summary: { type: "string" },
|
|
154
|
+
impactedFeatures: { type: "array", items: CHANGE_FEATURE_SCHEMA },
|
|
155
|
+
impactedFiles: { type: "array", items: CHANGE_FILE_SCHEMA },
|
|
156
|
+
impactedScreens: { type: "array", items: { type: "string" } },
|
|
157
|
+
rolloutRisks: { type: "string" },
|
|
158
|
+
qaChecklist: { type: "array", items: { type: "string" } },
|
|
159
|
+
communicationBrief: { type: "string" },
|
|
160
|
+
diagrams: { type: "array", items: DIAGRAM_SCHEMA },
|
|
161
|
+
evidence: { type: "array", items: EVIDENCE_SCHEMA },
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const RUN_MODE_PRESETS = {
|
|
166
|
+
review: {
|
|
167
|
+
description: "Structured code review findings ordered by severity.",
|
|
168
|
+
schema: {
|
|
169
|
+
type: "object",
|
|
170
|
+
required: ["summary", "overall_risk", "findings"],
|
|
171
|
+
properties: {
|
|
172
|
+
summary: { type: "string" },
|
|
173
|
+
overall_risk: { type: "string", enum: ["low", "medium", "high"] },
|
|
174
|
+
findings: {
|
|
175
|
+
type: "array",
|
|
176
|
+
items: {
|
|
177
|
+
type: "object",
|
|
178
|
+
required: ["severity", "title", "detail", "files", "recommendation"],
|
|
179
|
+
properties: {
|
|
180
|
+
severity: { type: "string", enum: ["low", "medium", "high"] },
|
|
181
|
+
title: { type: "string" },
|
|
182
|
+
detail: { type: "string" },
|
|
183
|
+
files: { type: "array", items: { type: "string" } },
|
|
184
|
+
recommendation: { type: "string" },
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
summary: {
|
|
192
|
+
description: "Structured high-level summary of the requested area or change.",
|
|
193
|
+
schema: {
|
|
194
|
+
type: "object",
|
|
195
|
+
required: ["summary", "key_points", "affected_files", "open_questions"],
|
|
196
|
+
properties: {
|
|
197
|
+
summary: { type: "string" },
|
|
198
|
+
key_points: { type: "array", items: { type: "string" } },
|
|
199
|
+
affected_files: { type: "array", items: { type: "string" } },
|
|
200
|
+
open_questions: { type: "array", items: { type: "string" } },
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
explainer: {
|
|
205
|
+
description: "App-compatible explainer payload that can be published into a board.",
|
|
206
|
+
publishKind: "feature",
|
|
207
|
+
schema: EXPLAINER_SCHEMA,
|
|
208
|
+
},
|
|
209
|
+
onboarding: {
|
|
210
|
+
description: "App-compatible onboarding pack payload that can be published into a board.",
|
|
211
|
+
publishKind: "onboarding",
|
|
212
|
+
schema: EXPLAINER_SCHEMA,
|
|
213
|
+
},
|
|
214
|
+
"change-brief": {
|
|
215
|
+
description: "App-compatible change brief payload that can be published into a board.",
|
|
216
|
+
publishKind: "change-brief",
|
|
217
|
+
schema: CHANGE_BRIEF_SCHEMA,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
async function main() {
|
|
222
|
+
loadLocalEnv(process.cwd());
|
|
223
|
+
const rawArgs = process.argv.slice(2);
|
|
224
|
+
const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
|
|
225
|
+
const command = args[0];
|
|
226
|
+
|
|
227
|
+
switch (command) {
|
|
228
|
+
case "doctor":
|
|
229
|
+
return handleDoctor(args.slice(1));
|
|
230
|
+
case "login":
|
|
231
|
+
return handleLogin(args.slice(1));
|
|
232
|
+
case "logout":
|
|
233
|
+
return handleLogout(args.slice(1));
|
|
234
|
+
case "whoami":
|
|
235
|
+
return handleWhoAmI(args.slice(1));
|
|
236
|
+
case "boards":
|
|
237
|
+
return handleBoards(args.slice(1));
|
|
238
|
+
case "link":
|
|
239
|
+
return handleLink(args.slice(1));
|
|
240
|
+
case "install-protocol":
|
|
241
|
+
return handleInstallProtocol(args.slice(1));
|
|
242
|
+
case "handle-url":
|
|
243
|
+
return handleHandleUrl(args.slice(1));
|
|
244
|
+
case "brief":
|
|
245
|
+
return handleBrief(args.slice(1));
|
|
246
|
+
case "evolution":
|
|
247
|
+
return handleEvolution(args.slice(1));
|
|
248
|
+
case "hotspots":
|
|
249
|
+
return handleHotspots(args.slice(1));
|
|
250
|
+
case "ownership":
|
|
251
|
+
return handleOwnership(args.slice(1));
|
|
252
|
+
case "start-here":
|
|
253
|
+
return handleStartHere(args.slice(1));
|
|
254
|
+
case "trace":
|
|
255
|
+
return handleTrace(args.slice(1));
|
|
256
|
+
case "run":
|
|
257
|
+
return handleRun(args.slice(1));
|
|
258
|
+
case "publish":
|
|
259
|
+
return handlePublish(args.slice(1));
|
|
260
|
+
case "delegate":
|
|
261
|
+
return handleDelegate(args.slice(1));
|
|
262
|
+
case "context":
|
|
263
|
+
return handleContext(args.slice(1));
|
|
264
|
+
case "help":
|
|
265
|
+
case "--help":
|
|
266
|
+
case "-h":
|
|
267
|
+
case undefined:
|
|
268
|
+
printHelp();
|
|
269
|
+
return;
|
|
270
|
+
default:
|
|
271
|
+
fail(`Unknown command "${command}". Run "repolens help" for usage.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function handleDoctor(args) {
|
|
276
|
+
const options = parseFlags(args, {
|
|
277
|
+
boolean: new Set(["json"]),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const repoRoot = findGitRoot(process.cwd());
|
|
281
|
+
const authConfig = loadCliAuthConfig();
|
|
282
|
+
const linkedBoard = loadRepoLink(process.cwd());
|
|
283
|
+
const publishReady = hasPublishEnv();
|
|
284
|
+
const protocolDescriptor = getProtocolInstallDescriptor();
|
|
285
|
+
const protocolInstalled = isProtocolHandlerInstalled();
|
|
286
|
+
const report = {
|
|
287
|
+
repoRoot,
|
|
288
|
+
auth: authConfig
|
|
289
|
+
? {
|
|
290
|
+
connected: true,
|
|
291
|
+
appUrl: authConfig.appUrl,
|
|
292
|
+
user: authConfig.user ?? null,
|
|
293
|
+
tokenStore: authConfig.tokenStore?.type ?? (authConfig.token ? "file" : null),
|
|
294
|
+
}
|
|
295
|
+
: {
|
|
296
|
+
connected: false,
|
|
297
|
+
appUrl: null,
|
|
298
|
+
user: null,
|
|
299
|
+
tokenStore: null,
|
|
300
|
+
},
|
|
301
|
+
linkedBoard,
|
|
302
|
+
agents: Object.entries(AGENT_CANDIDATES).map(([agent, candidates]) => {
|
|
303
|
+
const resolved = resolveExecutable(candidates);
|
|
304
|
+
return {
|
|
305
|
+
agent,
|
|
306
|
+
available: Boolean(resolved),
|
|
307
|
+
executable: resolved ? resolved.name : null,
|
|
308
|
+
path: resolved ? resolved.path : null,
|
|
309
|
+
};
|
|
310
|
+
}),
|
|
311
|
+
publish: {
|
|
312
|
+
ready: publishReady,
|
|
313
|
+
supabaseUrlPresent: Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL),
|
|
314
|
+
serviceRolePresent: Boolean(process.env.SUPABASE_SERVICE_ROLE_KEY),
|
|
315
|
+
},
|
|
316
|
+
protocol: {
|
|
317
|
+
installed: protocolInstalled,
|
|
318
|
+
platform: protocolDescriptor.platform,
|
|
319
|
+
installPath: protocolInstalled ? protocolDescriptor.installPath : null,
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
if (options.flags.json) {
|
|
324
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
process.stdout.write("RepoLens CLI doctor\n");
|
|
329
|
+
process.stdout.write(`Repo root: ${repoRoot ?? "Not inside a git repository"}\n`);
|
|
330
|
+
process.stdout.write(
|
|
331
|
+
`- auth: ${authConfig ? `connected to ${authConfig.appUrl}${authConfig.user?.email ? ` as ${authConfig.user.email}` : ""}` : "not connected"}\n`
|
|
332
|
+
);
|
|
333
|
+
if (authConfig) {
|
|
334
|
+
process.stdout.write(`- auth storage: ${authConfig.tokenStore?.type ?? (authConfig.token ? "file" : "unknown")}\n`);
|
|
335
|
+
}
|
|
336
|
+
process.stdout.write(
|
|
337
|
+
`- linked board: ${linkedBoard ? `${linkedBoard.boardId}${linkedBoard.boardName ? ` (${linkedBoard.boardName})` : ""}` : "none"}\n`
|
|
338
|
+
);
|
|
339
|
+
for (const agent of report.agents) {
|
|
340
|
+
process.stdout.write(
|
|
341
|
+
`- ${agent.agent}: ${agent.available ? `available via ${agent.executable} (${agent.path})` : "not found on PATH"}\n`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
process.stdout.write(
|
|
345
|
+
`- publish: ${publishReady ? "ready" : "missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY"}\n`
|
|
346
|
+
);
|
|
347
|
+
process.stdout.write(
|
|
348
|
+
`- protocol launch: ${protocolInstalled ? `installed (${protocolDescriptor.installPath})` : protocolDescriptor.supported ? "not installed" : `unsupported on ${protocolDescriptor.platform}`}\n`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function getCliEventContext() {
|
|
353
|
+
const authConfig = loadCliAuthConfig();
|
|
354
|
+
if (!authConfig?.appUrl || !authConfig?.token) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return authConfig;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function reportCliEvent(event) {
|
|
362
|
+
const authConfig = getCliEventContext();
|
|
363
|
+
if (!authConfig) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return await reportCliRuntimeEvent(authConfig, event);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function handleLogin(args) {
|
|
371
|
+
const options = parseFlags(args, {
|
|
372
|
+
string: new Set(["app-url", "token", "label"]),
|
|
373
|
+
boolean: new Set(["json", "no-browser"]),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const appUrl = normalizeAppUrl(options.flags["app-url"]);
|
|
377
|
+
if (!appUrl) {
|
|
378
|
+
fail("Usage: repolens login --app-url URL [--token TOKEN] [--label LABEL] [--no-browser] [--json]");
|
|
379
|
+
}
|
|
380
|
+
const loginResult = options.flags.token
|
|
381
|
+
? await verifyCliTokenLogin(appUrl, options.flags.token)
|
|
382
|
+
: await loginWithBrowserFlow(appUrl, {
|
|
383
|
+
label: options.flags.label ?? defaultCliLoginLabel(),
|
|
384
|
+
noBrowser: Boolean(options.flags["no-browser"]),
|
|
385
|
+
json: Boolean(options.flags.json),
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const config = {
|
|
389
|
+
appUrl,
|
|
390
|
+
token: loginResult.token,
|
|
391
|
+
user: loginResult.user ?? null,
|
|
392
|
+
loggedInAt: new Date().toISOString(),
|
|
393
|
+
};
|
|
394
|
+
saveCliAuthConfig(config);
|
|
395
|
+
let protocolInstalled = false;
|
|
396
|
+
try {
|
|
397
|
+
protocolInstalled = ensureProtocolHandlerInstalled();
|
|
398
|
+
if (protocolInstalled) {
|
|
399
|
+
await reportCliEvent({
|
|
400
|
+
action: "cli_protocol.install_succeeded",
|
|
401
|
+
metadata: {
|
|
402
|
+
platform: process.platform,
|
|
403
|
+
installPath: getProtocolInstallDescriptor().installPath,
|
|
404
|
+
installedDuringLogin: true,
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
} catch (error) {
|
|
409
|
+
await reportCliEvent({
|
|
410
|
+
action: "cli_protocol.install_failed",
|
|
411
|
+
metadata: {
|
|
412
|
+
platform: process.platform,
|
|
413
|
+
installedDuringLogin: true,
|
|
414
|
+
error: error instanceof Error ? error.message : String(error),
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (options.flags.json) {
|
|
421
|
+
process.stdout.write(`${JSON.stringify({ success: true, appUrl, user: loginResult.user, protocolInstalled }, null, 2)}\n`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
process.stdout.write(`Connected RepoLens CLI to ${appUrl}\n`);
|
|
426
|
+
if (loginResult.user?.email) {
|
|
427
|
+
process.stdout.write(`Signed in as ${loginResult.user.email}\n`);
|
|
428
|
+
}
|
|
429
|
+
if (protocolInstalled) {
|
|
430
|
+
process.stdout.write("Installed RepoLens Launcher for one-click browser handoff.\n");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function handleLogout(args) {
|
|
435
|
+
const options = parseFlags(args, {
|
|
436
|
+
boolean: new Set(["json"]),
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
deleteCliAuthConfig();
|
|
440
|
+
|
|
441
|
+
if (options.flags.json) {
|
|
442
|
+
process.stdout.write(`${JSON.stringify({ success: true }, null, 2)}\n`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
process.stdout.write("Removed local RepoLens CLI credentials.\n");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function handleWhoAmI(args) {
|
|
450
|
+
const options = parseFlags(args, {
|
|
451
|
+
boolean: new Set(["json"]),
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const config = requireCliAuthConfig();
|
|
455
|
+
const response = await callCliApi({
|
|
456
|
+
appUrl: config.appUrl,
|
|
457
|
+
token: config.token,
|
|
458
|
+
pathname: "/api/cli/me",
|
|
459
|
+
method: "GET",
|
|
460
|
+
});
|
|
461
|
+
const payload = await parseApiJson(response);
|
|
462
|
+
if (!response.ok) {
|
|
463
|
+
fail(payload.error ?? `Request failed with status ${response.status}.`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (options.flags.json) {
|
|
467
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
process.stdout.write(`RepoLens account: ${payload.user?.email ?? "unknown"}\n`);
|
|
472
|
+
process.stdout.write(`App: ${config.appUrl}\n`);
|
|
473
|
+
process.stdout.write(`Boards: ${payload.user?.boardCount ?? 0}\n`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function handleBoards(args) {
|
|
477
|
+
const options = parseFlags(args, {
|
|
478
|
+
boolean: new Set(["json"]),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const config = requireCliAuthConfig();
|
|
482
|
+
const response = await callCliApi({
|
|
483
|
+
appUrl: config.appUrl,
|
|
484
|
+
token: config.token,
|
|
485
|
+
pathname: "/api/cli/boards",
|
|
486
|
+
method: "GET",
|
|
487
|
+
});
|
|
488
|
+
const payload = await parseApiJson(response);
|
|
489
|
+
if (!response.ok) {
|
|
490
|
+
fail(payload.error ?? `Request failed with status ${response.status}.`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (options.flags.json) {
|
|
494
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (!Array.isArray(payload.boards) || payload.boards.length === 0) {
|
|
499
|
+
process.stdout.write("No RepoLens boards available.\n");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
process.stdout.write("RepoLens boards\n");
|
|
504
|
+
for (const board of payload.boards) {
|
|
505
|
+
process.stdout.write(`- ${board.id} ${board.name} [${board.role}]${board.repoUrl ? ` ${board.repoUrl}` : ""}\n`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function handleLink(args) {
|
|
510
|
+
const options = parseFlags(args, {
|
|
511
|
+
string: new Set(["board-id"]),
|
|
512
|
+
boolean: new Set(["json"]),
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const boardId = options.flags["board-id"];
|
|
516
|
+
if (!boardId) {
|
|
517
|
+
fail("Usage: repolens link --board-id UUID [--json]");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const repoRoot = findGitRoot(process.cwd());
|
|
521
|
+
if (!repoRoot) {
|
|
522
|
+
fail("Run `repolens link` from inside a git repository.");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const config = requireCliAuthConfig();
|
|
526
|
+
const response = await callCliApi({
|
|
527
|
+
appUrl: config.appUrl,
|
|
528
|
+
token: config.token,
|
|
529
|
+
pathname: "/api/cli/boards",
|
|
530
|
+
method: "GET",
|
|
531
|
+
});
|
|
532
|
+
const payload = await parseApiJson(response);
|
|
533
|
+
if (!response.ok) {
|
|
534
|
+
fail(payload.error ?? `Request failed with status ${response.status}.`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const board = Array.isArray(payload.boards)
|
|
538
|
+
? payload.boards.find((entry) => entry.id === boardId)
|
|
539
|
+
: null;
|
|
540
|
+
if (!board) {
|
|
541
|
+
fail(`Board ${boardId} is not accessible from the current RepoLens account.`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const previousLink = loadRepoLink(repoRoot);
|
|
545
|
+
const link = {
|
|
546
|
+
boardId: board.id,
|
|
547
|
+
boardName: board.name,
|
|
548
|
+
appUrl: config.appUrl,
|
|
549
|
+
linkedAt: new Date().toISOString(),
|
|
550
|
+
};
|
|
551
|
+
saveRepoLink(repoRoot, link);
|
|
552
|
+
registerBoardLink(repoRoot, link, previousLink?.boardId ?? null);
|
|
553
|
+
|
|
554
|
+
if (options.flags.json) {
|
|
555
|
+
process.stdout.write(`${JSON.stringify({ success: true, repoRoot, link }, null, 2)}\n`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
process.stdout.write(`Linked ${repoRoot} to board ${board.name} (${board.id})\n`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function handleInstallProtocol(args) {
|
|
563
|
+
const options = parseFlags(args, {
|
|
564
|
+
boolean: new Set(["json"]),
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const descriptor = getProtocolInstallDescriptor();
|
|
568
|
+
if (!descriptor.supported) {
|
|
569
|
+
fail(`Protocol installation is not currently supported on ${descriptor.platform}.`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
const installed = installProtocolHandler();
|
|
574
|
+
await reportCliEvent({
|
|
575
|
+
action: "cli_protocol.install_succeeded",
|
|
576
|
+
metadata: {
|
|
577
|
+
platform: process.platform,
|
|
578
|
+
installPath: installed.installPath,
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
if (options.flags.json) {
|
|
583
|
+
process.stdout.write(`${JSON.stringify({ success: true, install: installed }, null, 2)}\n`);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
process.stdout.write(`Installed RepoLens protocol handler for ${installed.platform} at ${installed.installPath}\n`);
|
|
588
|
+
process.stdout.write("Web pages can now attempt to open repolens-cli:// launch URLs on this machine.\n");
|
|
589
|
+
} catch (error) {
|
|
590
|
+
await reportCliEvent({
|
|
591
|
+
action: "cli_protocol.install_failed",
|
|
592
|
+
metadata: {
|
|
593
|
+
platform: process.platform,
|
|
594
|
+
error: error instanceof Error ? error.message : String(error),
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function handleHandleUrl(args) {
|
|
602
|
+
const rawUrl = args[0];
|
|
603
|
+
if (!rawUrl) {
|
|
604
|
+
fail("Usage: repolens handle-url URL");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const protocolDescriptor = getProtocolInstallDescriptor();
|
|
608
|
+
if (!protocolDescriptor.supported) {
|
|
609
|
+
await reportCliEvent({
|
|
610
|
+
action: "cli_protocol.launch_unsupported_platform",
|
|
611
|
+
metadata: {
|
|
612
|
+
platform: protocolDescriptor.platform,
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
fail(`Protocol launch handling is not currently supported on ${protocolDescriptor.platform}.`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
let launch;
|
|
619
|
+
try {
|
|
620
|
+
launch = parseRepoLensProtocolUrl(rawUrl);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
await reportCliEvent({
|
|
623
|
+
action: "cli_protocol.launch_invalid_url",
|
|
624
|
+
metadata: {
|
|
625
|
+
platform: process.platform,
|
|
626
|
+
error: error instanceof Error ? error.message : String(error),
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
await reportCliEvent({
|
|
633
|
+
action: "cli_protocol.launch_received",
|
|
634
|
+
boardId: launch.boardId,
|
|
635
|
+
metadata: {
|
|
636
|
+
platform: process.platform,
|
|
637
|
+
source: launch.source,
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
if (!isSafeRepoLensCommand(launch.command)) {
|
|
642
|
+
await reportCliEvent({
|
|
643
|
+
action: "cli_protocol.launch_unsafe_command",
|
|
644
|
+
boardId: launch.boardId,
|
|
645
|
+
metadata: {
|
|
646
|
+
platform: process.platform,
|
|
647
|
+
source: launch.source,
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
fail("Refusing to launch an unsafe RepoLens command.");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const repoRoot = launch.boardId ? resolveLinkedRepoRootForBoard(launch.boardId) : null;
|
|
654
|
+
if (launch.boardId && !repoRoot) {
|
|
655
|
+
await reportCliEvent({
|
|
656
|
+
action: "cli_protocol.launch_missing_repo_link",
|
|
657
|
+
boardId: launch.boardId,
|
|
658
|
+
metadata: {
|
|
659
|
+
platform: process.platform,
|
|
660
|
+
source: launch.source,
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
openTerminalForProtocolCommand({
|
|
667
|
+
command: launch.command,
|
|
668
|
+
repoRoot,
|
|
669
|
+
source: launch.source,
|
|
670
|
+
});
|
|
671
|
+
await reportCliEvent({
|
|
672
|
+
action: "cli_protocol.launch_opened",
|
|
673
|
+
boardId: launch.boardId,
|
|
674
|
+
metadata: {
|
|
675
|
+
platform: process.platform,
|
|
676
|
+
source: launch.source,
|
|
677
|
+
repoLinked: Boolean(repoRoot),
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
} catch (error) {
|
|
681
|
+
await reportCliEvent({
|
|
682
|
+
action: "cli_protocol.launch_terminal_failed",
|
|
683
|
+
boardId: launch.boardId,
|
|
684
|
+
metadata: {
|
|
685
|
+
platform: process.platform,
|
|
686
|
+
source: launch.source,
|
|
687
|
+
repoLinked: Boolean(repoRoot),
|
|
688
|
+
error: error instanceof Error ? error.message : String(error),
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
throw error;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function handleDelegate(args) {
|
|
696
|
+
if (args.length === 0) {
|
|
697
|
+
fail('Usage: repolens delegate <codex|claude> [--cwd PATH] [--dry-run] [--] <agent args...>');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const agent = args[0];
|
|
701
|
+
if (!Object.prototype.hasOwnProperty.call(AGENT_CANDIDATES, agent)) {
|
|
702
|
+
fail(`Unsupported agent "${agent}". Expected one of: ${Object.keys(AGENT_CANDIDATES).join(", ")}`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const options = parseFlags(args.slice(1), {
|
|
706
|
+
string: new Set(["cwd"]),
|
|
707
|
+
boolean: new Set(["dry-run"]),
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
const resolved = resolveExecutable(AGENT_CANDIDATES[agent]);
|
|
711
|
+
if (!resolved) {
|
|
712
|
+
fail(`Could not find a local ${agent} CLI on PATH. Run "repolens doctor" to inspect availability.`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const cwd = options.flags.cwd ? path.resolve(options.flags.cwd) : process.cwd();
|
|
716
|
+
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
|
|
717
|
+
fail(`Invalid working directory: ${cwd}`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (options.flags["dry-run"]) {
|
|
721
|
+
process.stdout.write(`${resolved.name} ${options.positionals.join(" ")}\n`);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const child = spawn(resolved.name, options.positionals, {
|
|
726
|
+
cwd,
|
|
727
|
+
stdio: "inherit",
|
|
728
|
+
env: process.env,
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
child.on("exit", (code, signal) => {
|
|
732
|
+
if (signal) {
|
|
733
|
+
process.kill(process.pid, signal);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
process.exit(code ?? 1);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
child.on("error", (error) => {
|
|
741
|
+
fail(`Failed to launch ${agent}: ${error.message}`);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function handleContext(args) {
|
|
746
|
+
const options = parseFlags(args, {
|
|
747
|
+
string: new Set(["cwd", "base", "head", "output"]),
|
|
748
|
+
boolean: new Set(["json"]),
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const cwd = options.flags.cwd ? path.resolve(options.flags.cwd) : process.cwd();
|
|
752
|
+
const repoRoot = findGitRoot(cwd);
|
|
753
|
+
if (!repoRoot) {
|
|
754
|
+
fail(`No git repository found from ${cwd}`);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const branch = runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot }).trim();
|
|
758
|
+
const recentCommits = lines(runCommand("git", ["log", "--oneline", "-n", "8"], { cwd: repoRoot }));
|
|
759
|
+
const statusLines = lines(runCommand("git", ["status", "--short"], { cwd: repoRoot }, { allowFailure: true }));
|
|
760
|
+
const trackedFiles = lines(runCommand("git", ["ls-files"], { cwd: repoRoot }, { allowFailure: true }));
|
|
761
|
+
const rankedFiles = rankPaths(trackedFiles).slice(0, 30);
|
|
762
|
+
|
|
763
|
+
let diffSummary = [];
|
|
764
|
+
let changedFiles = [];
|
|
765
|
+
const base = options.flags.base;
|
|
766
|
+
const head = options.flags.head;
|
|
767
|
+
|
|
768
|
+
if (base && head) {
|
|
769
|
+
diffSummary = lines(
|
|
770
|
+
runCommand("git", ["diff", "--stat", `${base}..${head}`], { cwd: repoRoot }, { allowFailure: true })
|
|
771
|
+
);
|
|
772
|
+
changedFiles = lines(
|
|
773
|
+
runCommand("git", ["diff", "--name-only", `${base}..${head}`], { cwd: repoRoot }, { allowFailure: true })
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const payload = {
|
|
778
|
+
repoRoot,
|
|
779
|
+
branch,
|
|
780
|
+
generatedAt: new Date().toISOString(),
|
|
781
|
+
recentCommits,
|
|
782
|
+
status: statusLines,
|
|
783
|
+
topFiles: rankedFiles,
|
|
784
|
+
compare: base && head ? { base, head, diffSummary, changedFiles } : null,
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
if (options.flags.json) {
|
|
788
|
+
writeOutput(JSON.stringify(payload, null, 2), options.flags.output);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
writeOutput(buildContextMarkdown(payload), options.flags.output);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function handleBrief(args) {
|
|
796
|
+
const options = parseFlags(args, {
|
|
797
|
+
string: new Set(["cwd", "base", "head", "output"]),
|
|
798
|
+
boolean: new Set(["json"]),
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
if (!options.flags.base || !options.flags.head) {
|
|
802
|
+
fail("Usage: repolens brief --base <ref> --head <ref> [--cwd PATH] [--output FILE] [--json]");
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const payload = collectRepoSnapshot({
|
|
806
|
+
cwd: options.flags.cwd ? path.resolve(options.flags.cwd) : process.cwd(),
|
|
807
|
+
base: options.flags.base,
|
|
808
|
+
head: options.flags.head,
|
|
809
|
+
includeRangeCommits: true,
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
if (options.flags.json) {
|
|
813
|
+
writeOutput(JSON.stringify(payload, null, 2), options.flags.output);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
writeOutput(buildBriefMarkdown(payload), options.flags.output);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function handleEvolution(args) {
|
|
821
|
+
const options = parseFlags(args, {
|
|
822
|
+
string: new Set(["cwd", "path", "since", "limit", "output", "publish-board-id"]),
|
|
823
|
+
boolean: new Set(["json", "dry-run"]),
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
const targetPath = options.flags.path;
|
|
827
|
+
if (!targetPath) {
|
|
828
|
+
fail("Usage: repolens evolution --path PATH [--cwd PATH] [--since REF] [--limit N] [--output FILE] [--json] [--publish-board-id UUID] [--dry-run]");
|
|
829
|
+
}
|
|
830
|
+
if (options.flags.json && options.flags["publish-board-id"]) {
|
|
831
|
+
fail("Do not combine --json with --publish-board-id. Use --output if you need a local JSON file before publishing.");
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const cwd = options.flags.cwd ? path.resolve(options.flags.cwd) : process.cwd();
|
|
835
|
+
const repoRoot = findGitRoot(cwd);
|
|
836
|
+
if (!repoRoot) {
|
|
837
|
+
fail(`No git repository found from ${cwd}`);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const limit = parsePositiveInteger(options.flags.limit, 20, "--limit");
|
|
841
|
+
const payload = {
|
|
842
|
+
repoRoot,
|
|
843
|
+
branch: runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot }).trim(),
|
|
844
|
+
...collectEvolutionReport({
|
|
845
|
+
cwd: repoRoot,
|
|
846
|
+
targetPath,
|
|
847
|
+
sinceRef: options.flags.since ?? null,
|
|
848
|
+
limit,
|
|
849
|
+
exec: (gitArgs, runtimeOptions = {}) => runCommand("git", gitArgs, { cwd: repoRoot }, runtimeOptions),
|
|
850
|
+
}),
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
if (options.flags.json) {
|
|
854
|
+
writeOutput(JSON.stringify(payload, null, 2), options.flags.output);
|
|
855
|
+
} else {
|
|
856
|
+
writeOutput(buildHistoryReportMarkdown("evolution", payload), options.flags.output);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (options.flags["publish-board-id"]) {
|
|
860
|
+
await publishHistoryReport({
|
|
861
|
+
boardId: options.flags["publish-board-id"],
|
|
862
|
+
reportKind: "evolution",
|
|
863
|
+
payload,
|
|
864
|
+
dryRun: options.flags["dry-run"],
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async function handleHotspots(args) {
|
|
870
|
+
const options = parseFlags(args, {
|
|
871
|
+
string: new Set(["cwd", "since", "limit", "output", "publish-board-id"]),
|
|
872
|
+
boolean: new Set(["json", "dry-run"]),
|
|
873
|
+
});
|
|
874
|
+
if (options.flags.json && options.flags["publish-board-id"]) {
|
|
875
|
+
fail("Do not combine --json with --publish-board-id. Use --output if you need a local JSON file before publishing.");
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const cwd = options.flags.cwd ? path.resolve(options.flags.cwd) : process.cwd();
|
|
879
|
+
const repoRoot = findGitRoot(cwd);
|
|
880
|
+
if (!repoRoot) {
|
|
881
|
+
fail(`No git repository found from ${cwd}`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const limit = parsePositiveInteger(options.flags.limit, 15, "--limit");
|
|
885
|
+
const payload = {
|
|
886
|
+
repoRoot,
|
|
887
|
+
branch: runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot }).trim(),
|
|
888
|
+
...collectHotspotReport({
|
|
889
|
+
sinceRef: options.flags.since ?? "HEAD~100",
|
|
890
|
+
limit,
|
|
891
|
+
exec: (gitArgs, runtimeOptions = {}) => runCommand("git", gitArgs, { cwd: repoRoot }, runtimeOptions),
|
|
892
|
+
}),
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
if (options.flags.json) {
|
|
896
|
+
writeOutput(JSON.stringify(payload, null, 2), options.flags.output);
|
|
897
|
+
} else {
|
|
898
|
+
writeOutput(buildHistoryReportMarkdown("hotspots", payload), options.flags.output);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (options.flags["publish-board-id"]) {
|
|
902
|
+
await publishHistoryReport({
|
|
903
|
+
boardId: options.flags["publish-board-id"],
|
|
904
|
+
reportKind: "hotspots",
|
|
905
|
+
payload,
|
|
906
|
+
dryRun: options.flags["dry-run"],
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async function handleOwnership(args) {
|
|
912
|
+
const options = parseFlags(args, {
|
|
913
|
+
string: new Set(["cwd", "path", "since", "limit", "output", "publish-board-id"]),
|
|
914
|
+
boolean: new Set(["json", "dry-run"]),
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
const targetPath = options.flags.path;
|
|
918
|
+
if (!targetPath) {
|
|
919
|
+
fail("Usage: repolens ownership --path PATH [--cwd PATH] [--since REF] [--limit N] [--output FILE] [--json] [--publish-board-id UUID] [--dry-run]");
|
|
920
|
+
}
|
|
921
|
+
if (options.flags.json && options.flags["publish-board-id"]) {
|
|
922
|
+
fail("Do not combine --json with --publish-board-id. Use --output if you need a local JSON file before publishing.");
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const cwd = options.flags.cwd ? path.resolve(options.flags.cwd) : process.cwd();
|
|
926
|
+
const repoRoot = findGitRoot(cwd);
|
|
927
|
+
if (!repoRoot) {
|
|
928
|
+
fail(`No git repository found from ${cwd}`);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const limit = parsePositiveInteger(options.flags.limit, 10, "--limit");
|
|
932
|
+
const payload = {
|
|
933
|
+
repoRoot,
|
|
934
|
+
branch: runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot }).trim(),
|
|
935
|
+
...collectOwnershipReport({
|
|
936
|
+
cwd: repoRoot,
|
|
937
|
+
targetPath,
|
|
938
|
+
sinceRef: options.flags.since ?? null,
|
|
939
|
+
limit,
|
|
940
|
+
exec: (gitArgs, runtimeOptions = {}) => runCommand("git", gitArgs, { cwd: repoRoot }, runtimeOptions),
|
|
941
|
+
}),
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
if (options.flags.json) {
|
|
945
|
+
writeOutput(JSON.stringify(payload, null, 2), options.flags.output);
|
|
946
|
+
} else {
|
|
947
|
+
writeOutput(buildHistoryReportMarkdown("ownership", payload), options.flags.output);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (options.flags["publish-board-id"]) {
|
|
951
|
+
await publishHistoryReport({
|
|
952
|
+
boardId: options.flags["publish-board-id"],
|
|
953
|
+
reportKind: "ownership",
|
|
954
|
+
payload,
|
|
955
|
+
dryRun: options.flags["dry-run"],
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function handleStartHere(args) {
|
|
961
|
+
const options = parseFlags(args, {
|
|
962
|
+
string: new Set(["cwd", "path", "since", "limit", "output", "publish-board-id"]),
|
|
963
|
+
boolean: new Set(["json", "dry-run"]),
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
const targetPath = options.flags.path;
|
|
967
|
+
if (!targetPath) {
|
|
968
|
+
fail("Usage: repolens start-here --path PATH [--cwd PATH] [--since REF] [--limit N] [--output FILE] [--json] [--publish-board-id UUID] [--dry-run]");
|
|
969
|
+
}
|
|
970
|
+
if (options.flags.json && options.flags["publish-board-id"]) {
|
|
971
|
+
fail("Do not combine --json with --publish-board-id. Use --output if you need a local JSON file before publishing.");
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const cwd = options.flags.cwd ? path.resolve(options.flags.cwd) : process.cwd();
|
|
975
|
+
const repoRoot = findGitRoot(cwd);
|
|
976
|
+
if (!repoRoot) {
|
|
977
|
+
fail(`No git repository found from ${cwd}`);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const limit = parsePositiveInteger(options.flags.limit, 5, "--limit");
|
|
981
|
+
const payload = {
|
|
982
|
+
repoRoot,
|
|
983
|
+
branch: runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot }).trim(),
|
|
984
|
+
...collectStartHereReport({
|
|
985
|
+
cwd: repoRoot,
|
|
986
|
+
targetPath,
|
|
987
|
+
sinceRef: options.flags.since ?? null,
|
|
988
|
+
limit,
|
|
989
|
+
exec: (gitArgs, runtimeOptions = {}) => runCommand("git", gitArgs, { cwd: repoRoot }, runtimeOptions),
|
|
990
|
+
}),
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
if (options.flags.json) {
|
|
994
|
+
writeOutput(JSON.stringify(payload, null, 2), options.flags.output);
|
|
995
|
+
} else {
|
|
996
|
+
writeOutput(buildHistoryReportMarkdown("start-here", payload), options.flags.output);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (options.flags["publish-board-id"]) {
|
|
1000
|
+
await publishHistoryReport({
|
|
1001
|
+
boardId: options.flags["publish-board-id"],
|
|
1002
|
+
reportKind: "start-here",
|
|
1003
|
+
payload,
|
|
1004
|
+
dryRun: options.flags["dry-run"],
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async function handleTrace(args) {
|
|
1010
|
+
const options = parseFlags(args, {
|
|
1011
|
+
string: new Set(["cwd", "query", "since", "limit", "output", "publish-board-id"]),
|
|
1012
|
+
boolean: new Set(["json", "dry-run"]),
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
const query = options.flags.query;
|
|
1016
|
+
if (!query) {
|
|
1017
|
+
fail("Usage: repolens trace --query TEXT [--cwd PATH] [--since REF] [--limit N] [--output FILE] [--json] [--publish-board-id UUID] [--dry-run]");
|
|
1018
|
+
}
|
|
1019
|
+
if (options.flags.json && options.flags["publish-board-id"]) {
|
|
1020
|
+
fail("Do not combine --json with --publish-board-id. Use --output if you need a local JSON file before publishing.");
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const cwd = options.flags.cwd ? path.resolve(options.flags.cwd) : process.cwd();
|
|
1024
|
+
const repoRoot = findGitRoot(cwd);
|
|
1025
|
+
if (!repoRoot) {
|
|
1026
|
+
fail(`No git repository found from ${cwd}`);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const limit = parsePositiveInteger(options.flags.limit, 5, "--limit");
|
|
1030
|
+
const payload = {
|
|
1031
|
+
repoRoot,
|
|
1032
|
+
branch: runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot }).trim(),
|
|
1033
|
+
...collectTraceReport({
|
|
1034
|
+
cwd: repoRoot,
|
|
1035
|
+
query,
|
|
1036
|
+
sinceRef: options.flags.since ?? null,
|
|
1037
|
+
limit,
|
|
1038
|
+
exec: (gitArgs, runtimeOptions = {}) => runCommand("git", gitArgs, { cwd: repoRoot }, runtimeOptions),
|
|
1039
|
+
}),
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
if (options.flags.json) {
|
|
1043
|
+
writeOutput(JSON.stringify(payload, null, 2), options.flags.output);
|
|
1044
|
+
} else {
|
|
1045
|
+
writeOutput(buildHistoryReportMarkdown("trace", payload), options.flags.output);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
if (options.flags["publish-board-id"]) {
|
|
1049
|
+
await publishHistoryReport({
|
|
1050
|
+
boardId: options.flags["publish-board-id"],
|
|
1051
|
+
reportKind: "trace",
|
|
1052
|
+
payload,
|
|
1053
|
+
dryRun: options.flags["dry-run"],
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async function handleRun(args) {
|
|
1059
|
+
if (args.length === 0) {
|
|
1060
|
+
fail(
|
|
1061
|
+
"Usage: repolens run <codex|claude> --task <text> [--mode review|summary|explainer|onboarding|change-brief] [--base REF --head REF] [--audience VALUE] [--context-file FILE] [--save-packet FILE] [--output FILE] [--publish-board-id UUID] [--publish-as feature|onboarding|change-brief] [--user-id UUID] [--json] [--allow-edit] [--dry-run] [--] <extra agent args...>"
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const agent = args[0];
|
|
1066
|
+
if (!Object.prototype.hasOwnProperty.call(AGENT_CANDIDATES, agent)) {
|
|
1067
|
+
fail(`Unsupported agent "${agent}". Expected one of: ${Object.keys(AGENT_CANDIDATES).join(", ")}`);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const options = parseFlags(args.slice(1), {
|
|
1071
|
+
string: new Set([
|
|
1072
|
+
"cwd",
|
|
1073
|
+
"base",
|
|
1074
|
+
"head",
|
|
1075
|
+
"task",
|
|
1076
|
+
"task-file",
|
|
1077
|
+
"context-file",
|
|
1078
|
+
"save-packet",
|
|
1079
|
+
"output",
|
|
1080
|
+
"model",
|
|
1081
|
+
"mode",
|
|
1082
|
+
"audience",
|
|
1083
|
+
"publish-board-id",
|
|
1084
|
+
"publish-as",
|
|
1085
|
+
"user-id",
|
|
1086
|
+
]),
|
|
1087
|
+
boolean: new Set(["json", "allow-edit", "dry-run"]),
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
const mode = options.flags.mode ?? null;
|
|
1091
|
+
const modePreset = mode ? RUN_MODE_PRESETS[mode] ?? null : null;
|
|
1092
|
+
if (mode && !modePreset) {
|
|
1093
|
+
fail(`Unsupported run mode "${mode}". Expected one of: ${Object.keys(RUN_MODE_PRESETS).join(", ")}`);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (modePreset && options.flags.json) {
|
|
1097
|
+
fail("Do not combine --json with --mode. Structured modes already persist JSON results.");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const publishBoardId = options.flags["publish-board-id"] ?? null;
|
|
1101
|
+
const requestedPublishTarget = options.flags["publish-as"] ?? null;
|
|
1102
|
+
const shouldPublish = Boolean(publishBoardId || requestedPublishTarget);
|
|
1103
|
+
const publishKind = shouldPublish
|
|
1104
|
+
? normalizePublishKind(requestedPublishTarget ?? defaultPublishKindForMode(mode))
|
|
1105
|
+
: null;
|
|
1106
|
+
if (requestedPublishTarget && !publishKind) {
|
|
1107
|
+
fail('Unsupported publish target. Expected one of: "feature", "explainer", "onboarding", "change-brief".');
|
|
1108
|
+
}
|
|
1109
|
+
if (publishBoardId && !publishKind) {
|
|
1110
|
+
fail("Publishing requires a publishable structured mode or an explicit --publish-as target.");
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const task = resolveTask(options.flags.task, options.flags["task-file"]);
|
|
1114
|
+
const cwd = options.flags.cwd ? path.resolve(options.flags.cwd) : process.cwd();
|
|
1115
|
+
const repoRoot = findGitRoot(cwd);
|
|
1116
|
+
if (!repoRoot) {
|
|
1117
|
+
fail(`No git repository found from ${cwd}`);
|
|
1118
|
+
}
|
|
1119
|
+
const linkedBoard = loadRepoLink(repoRoot);
|
|
1120
|
+
const resolvedPublishBoardId = publishBoardId ?? (shouldPublish ? linkedBoard?.boardId ?? null : null);
|
|
1121
|
+
if (publishKind && !resolvedPublishBoardId) {
|
|
1122
|
+
fail("Use --publish-board-id or run `repolens link --board-id <id>` before publishing from this repo.");
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const audience = normalizeAudience(options.flags.audience, publishKind);
|
|
1126
|
+
const snapshot = collectRepoSnapshot({
|
|
1127
|
+
cwd: repoRoot,
|
|
1128
|
+
base: options.flags.base,
|
|
1129
|
+
head: options.flags.head,
|
|
1130
|
+
includeRangeCommits: Boolean(options.flags.base && options.flags.head),
|
|
1131
|
+
});
|
|
1132
|
+
const historyAppendix = buildRunHistoryAppendix({
|
|
1133
|
+
repoRoot,
|
|
1134
|
+
snapshot,
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
const runStamp = buildRunStamp();
|
|
1138
|
+
const packetOutputPath = options.flags["save-packet"]
|
|
1139
|
+
? path.resolve(options.flags["save-packet"])
|
|
1140
|
+
: modePreset
|
|
1141
|
+
? path.resolve(repoRoot, ".repolens", "runs", `${runStamp}-${agent}-${mode}.packet.md`)
|
|
1142
|
+
: null;
|
|
1143
|
+
const structuredResultPath = modePreset
|
|
1144
|
+
? path.resolve(
|
|
1145
|
+
options.flags.output
|
|
1146
|
+
? options.flags.output
|
|
1147
|
+
: path.join(repoRoot, ".repolens", "runs", `${runStamp}-${agent}-${mode}.json`)
|
|
1148
|
+
)
|
|
1149
|
+
: options.flags.output
|
|
1150
|
+
? path.resolve(options.flags.output)
|
|
1151
|
+
: null;
|
|
1152
|
+
const schemaPath = modePreset
|
|
1153
|
+
? path.resolve(repoRoot, ".repolens", "runs", `${runStamp}-${agent}-${mode}.schema.json`)
|
|
1154
|
+
: null;
|
|
1155
|
+
const codexLastMessagePath = modePreset && agent === "codex"
|
|
1156
|
+
? path.resolve(repoRoot, ".repolens", "runs", `${runStamp}-${agent}-${mode}.raw.json`)
|
|
1157
|
+
: null;
|
|
1158
|
+
|
|
1159
|
+
const contextAppendix = options.flags["context-file"]
|
|
1160
|
+
? fs.readFileSync(path.resolve(options.flags["context-file"]), "utf8")
|
|
1161
|
+
: null;
|
|
1162
|
+
|
|
1163
|
+
const packet = buildRunPacket({
|
|
1164
|
+
agent,
|
|
1165
|
+
mode,
|
|
1166
|
+
task,
|
|
1167
|
+
snapshot,
|
|
1168
|
+
historyAppendix,
|
|
1169
|
+
allowEdit: Boolean(options.flags["allow-edit"]),
|
|
1170
|
+
audience,
|
|
1171
|
+
contextAppendix,
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
if (packetOutputPath) {
|
|
1175
|
+
writeOutput(packet, packetOutputPath);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (modePreset && schemaPath) {
|
|
1179
|
+
writeOutput(JSON.stringify(modePreset.schema, null, 2), schemaPath);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const resolved = resolveExecutable(AGENT_CANDIDATES[agent]);
|
|
1183
|
+
if (!resolved) {
|
|
1184
|
+
fail(`Could not find a local ${agent} CLI on PATH. Run "repolens doctor" to inspect availability.`);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const runSpec = buildRunSpec({
|
|
1188
|
+
agent,
|
|
1189
|
+
cwd: repoRoot,
|
|
1190
|
+
model: options.flags.model,
|
|
1191
|
+
json: Boolean(options.flags.json),
|
|
1192
|
+
allowEdit: Boolean(options.flags["allow-edit"]),
|
|
1193
|
+
outputPath: structuredResultPath,
|
|
1194
|
+
schemaPath,
|
|
1195
|
+
codexLastMessagePath,
|
|
1196
|
+
extraArgs: options.positionals,
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
if (options.flags["dry-run"]) {
|
|
1200
|
+
process.stdout.write(`${resolved.name} ${runSpec.args.join(" ")}\n`);
|
|
1201
|
+
if (resolvedPublishBoardId && publishKind) {
|
|
1202
|
+
process.stdout.write(`Would publish structured result to board ${resolvedPublishBoardId} as ${publishKind}\n`);
|
|
1203
|
+
}
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const rawOutput = await executeAgentRun({
|
|
1208
|
+
command: resolved.name,
|
|
1209
|
+
args: runSpec.args,
|
|
1210
|
+
cwd: repoRoot,
|
|
1211
|
+
prompt: packet,
|
|
1212
|
+
outputPath: runSpec.outputPath,
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
if (!modePreset) {
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const rawContent = agent === "codex"
|
|
1220
|
+
? fs.readFileSync(codexLastMessagePath, "utf8")
|
|
1221
|
+
: rawOutput;
|
|
1222
|
+
const structured = parseStructuredJson(rawContent);
|
|
1223
|
+
validateStructuredOutput(modePreset.schema, structured);
|
|
1224
|
+
|
|
1225
|
+
const envelope = {
|
|
1226
|
+
type: "repolens-run",
|
|
1227
|
+
version: 1,
|
|
1228
|
+
mode,
|
|
1229
|
+
agent,
|
|
1230
|
+
audience,
|
|
1231
|
+
generatedAt: snapshot.generatedAt,
|
|
1232
|
+
repoRoot,
|
|
1233
|
+
task,
|
|
1234
|
+
packetPath: packetOutputPath,
|
|
1235
|
+
schemaPath,
|
|
1236
|
+
compare: snapshot.compare
|
|
1237
|
+
? {
|
|
1238
|
+
base: snapshot.compare.base,
|
|
1239
|
+
head: snapshot.compare.head,
|
|
1240
|
+
}
|
|
1241
|
+
: null,
|
|
1242
|
+
result: structured,
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
writeOutput(JSON.stringify(envelope, null, 2), structuredResultPath);
|
|
1246
|
+
|
|
1247
|
+
if (resolvedPublishBoardId && publishKind) {
|
|
1248
|
+
const publishResult = await publishEnvelope({
|
|
1249
|
+
boardId: resolvedPublishBoardId,
|
|
1250
|
+
publishKind,
|
|
1251
|
+
audience,
|
|
1252
|
+
userId: options.flags["user-id"] ?? null,
|
|
1253
|
+
envelope,
|
|
1254
|
+
});
|
|
1255
|
+
process.stdout.write(`${JSON.stringify(publishResult, null, 2)}\n`);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async function handlePublish(args) {
|
|
1260
|
+
const options = parseFlags(args, {
|
|
1261
|
+
string: new Set(["input", "board-id", "publish-as", "audience", "user-id"]),
|
|
1262
|
+
boolean: new Set(["dry-run", "json"]),
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
const inputPath = options.flags.input;
|
|
1266
|
+
const linkedBoard = loadRepoLink(process.cwd());
|
|
1267
|
+
const boardId = options.flags["board-id"] ?? linkedBoard?.boardId ?? null;
|
|
1268
|
+
if (!inputPath || !boardId) {
|
|
1269
|
+
fail(
|
|
1270
|
+
"Usage: repolens publish --input FILE [--board-id UUID] [--publish-as feature|onboarding|change-brief] [--audience VALUE] [--user-id UUID] [--dry-run] [--json]"
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const envelope = parseStructuredJson(fs.readFileSync(path.resolve(inputPath), "utf8"));
|
|
1275
|
+
const publishKind = normalizePublishKind(options.flags["publish-as"] ?? defaultPublishKindForMode(envelope.mode ?? null));
|
|
1276
|
+
if (!publishKind) {
|
|
1277
|
+
fail(
|
|
1278
|
+
"Could not infer a publish target from this run artifact. Use --publish-as feature, onboarding, or change-brief."
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const audience = normalizeAudience(options.flags.audience ?? envelope.audience ?? null, publishKind);
|
|
1283
|
+
|
|
1284
|
+
if (options.flags["dry-run"]) {
|
|
1285
|
+
const preview = buildPublishPreview({
|
|
1286
|
+
boardId,
|
|
1287
|
+
publishKind,
|
|
1288
|
+
audience,
|
|
1289
|
+
userId: options.flags["user-id"] ?? null,
|
|
1290
|
+
envelope,
|
|
1291
|
+
});
|
|
1292
|
+
if (options.flags.json) {
|
|
1293
|
+
process.stdout.write(`${JSON.stringify(preview, null, 2)}\n`);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
process.stdout.write(`Would publish ${publishKind} to board ${boardId}\n`);
|
|
1298
|
+
process.stdout.write(`${JSON.stringify(preview, null, 2)}\n`);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const result = await publishEnvelope({
|
|
1303
|
+
boardId,
|
|
1304
|
+
publishKind,
|
|
1305
|
+
audience,
|
|
1306
|
+
userId: options.flags["user-id"] ?? null,
|
|
1307
|
+
envelope,
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
if (options.flags.json) {
|
|
1311
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
process.stdout.write(`Published ${publishKind} to board ${boardId}\n`);
|
|
1316
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function parseFlags(args, schema) {
|
|
1320
|
+
const flags = {};
|
|
1321
|
+
const positionals = [];
|
|
1322
|
+
let index = 0;
|
|
1323
|
+
let afterDoubleDash = false;
|
|
1324
|
+
|
|
1325
|
+
while (index < args.length) {
|
|
1326
|
+
const token = args[index];
|
|
1327
|
+
|
|
1328
|
+
if (afterDoubleDash) {
|
|
1329
|
+
positionals.push(token);
|
|
1330
|
+
index += 1;
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (token === "--") {
|
|
1335
|
+
afterDoubleDash = true;
|
|
1336
|
+
index += 1;
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if (token.startsWith("--")) {
|
|
1341
|
+
const name = token.slice(2);
|
|
1342
|
+
if (schema.boolean && schema.boolean.has(name)) {
|
|
1343
|
+
flags[name] = true;
|
|
1344
|
+
index += 1;
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (schema.string && schema.string.has(name)) {
|
|
1349
|
+
const value = args[index + 1];
|
|
1350
|
+
if (!value || value.startsWith("--")) {
|
|
1351
|
+
fail(`Flag --${name} requires a value.`);
|
|
1352
|
+
}
|
|
1353
|
+
flags[name] = value;
|
|
1354
|
+
index += 2;
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
fail(`Unknown flag "${token}"`);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
positionals.push(token);
|
|
1362
|
+
index += 1;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
return { flags, positionals };
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function loadLocalEnv(cwd) {
|
|
1369
|
+
const repoRoot = findGitRoot(cwd) ?? cwd;
|
|
1370
|
+
const candidates = [
|
|
1371
|
+
path.join(repoRoot, ".env.local"),
|
|
1372
|
+
path.join(repoRoot, ".env"),
|
|
1373
|
+
path.join(repoRoot, "env"),
|
|
1374
|
+
];
|
|
1375
|
+
|
|
1376
|
+
for (const filePath of candidates) {
|
|
1377
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
1382
|
+
for (const line of content.split(/\r?\n/)) {
|
|
1383
|
+
const trimmed = line.trim();
|
|
1384
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
1389
|
+
if (!match) {
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const [, key, rawValue] = match;
|
|
1394
|
+
if (Object.prototype.hasOwnProperty.call(process.env, key)) {
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
process.env[key] = parseEnvValue(rawValue);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function parseEnvValue(rawValue) {
|
|
1404
|
+
let value = rawValue.trim();
|
|
1405
|
+
if (!value) {
|
|
1406
|
+
return "";
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
1410
|
+
value = value.slice(1, -1);
|
|
1411
|
+
} else {
|
|
1412
|
+
const commentIndex = value.indexOf(" #");
|
|
1413
|
+
if (commentIndex >= 0) {
|
|
1414
|
+
value = value.slice(0, commentIndex).trimEnd();
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
return value.replaceAll("\\n", "\n");
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function getCliConfigDir() {
|
|
1422
|
+
const configured = process.env.REPOLENS_CONFIG_HOME;
|
|
1423
|
+
if (configured) {
|
|
1424
|
+
return path.resolve(configured);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
return path.join(os.homedir(), ".repolens");
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function getCliAuthConfigPath() {
|
|
1431
|
+
return path.join(getCliConfigDir(), "auth.json");
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function loadCliAuthConfig() {
|
|
1435
|
+
const configPath = getCliAuthConfigPath();
|
|
1436
|
+
if (!fs.existsSync(configPath)) {
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
try {
|
|
1441
|
+
const parsed = normalizeStoredCliAuthConfig(JSON.parse(fs.readFileSync(configPath, "utf8")));
|
|
1442
|
+
if (!parsed) {
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if (parsed.tokenStore?.type === "keychain" && parsed.tokenStore.service && parsed.tokenStore.account) {
|
|
1447
|
+
const keychainToken = loadTokenFromKeychain(parsed.tokenStore.service, parsed.tokenStore.account);
|
|
1448
|
+
if (keychainToken) {
|
|
1449
|
+
return {
|
|
1450
|
+
...parsed,
|
|
1451
|
+
token: keychainToken,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
return parsed;
|
|
1457
|
+
} catch {
|
|
1458
|
+
return null;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function requireCliAuthConfig() {
|
|
1463
|
+
const config = loadCliAuthConfig();
|
|
1464
|
+
if (!config?.appUrl || !config?.token) {
|
|
1465
|
+
fail("RepoLens CLI is not connected. Create a CLI token in Settings, then run `repolens login --app-url <url>`.");
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
return config;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function saveCliAuthConfig(config) {
|
|
1472
|
+
const configPath = getCliAuthConfigPath();
|
|
1473
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
1474
|
+
|
|
1475
|
+
const descriptor = shouldUseKeychainForCliAuth()
|
|
1476
|
+
? buildCliKeychainDescriptor(config.appUrl, config.user?.email ?? null)
|
|
1477
|
+
: null;
|
|
1478
|
+
|
|
1479
|
+
if (descriptor && saveTokenToKeychain(descriptor.service, descriptor.account, config.token)) {
|
|
1480
|
+
const metadata = {
|
|
1481
|
+
...config,
|
|
1482
|
+
token: null,
|
|
1483
|
+
tokenStore: {
|
|
1484
|
+
type: "keychain",
|
|
1485
|
+
service: descriptor.service,
|
|
1486
|
+
account: descriptor.account,
|
|
1487
|
+
},
|
|
1488
|
+
};
|
|
1489
|
+
fs.writeFileSync(configPath, `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
fs.writeFileSync(`${configPath}`, `${JSON.stringify({
|
|
1494
|
+
...config,
|
|
1495
|
+
tokenStore: {
|
|
1496
|
+
type: "file",
|
|
1497
|
+
},
|
|
1498
|
+
}, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function deleteCliAuthConfig() {
|
|
1502
|
+
const configPath = getCliAuthConfigPath();
|
|
1503
|
+
const config = loadCliAuthConfig();
|
|
1504
|
+
if (config?.tokenStore?.type === "keychain" && config.tokenStore.service && config.tokenStore.account) {
|
|
1505
|
+
deleteTokenFromKeychain(config.tokenStore.service, config.tokenStore.account);
|
|
1506
|
+
}
|
|
1507
|
+
if (fs.existsSync(configPath)) {
|
|
1508
|
+
fs.unlinkSync(configPath);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function shouldUseKeychainForCliAuth() {
|
|
1513
|
+
if (process.env.REPOLENS_DISABLE_KEYCHAIN === "1") {
|
|
1514
|
+
return false;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
return process.platform === "darwin" && Boolean(resolveSecurityExecutable());
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function resolveSecurityExecutable() {
|
|
1521
|
+
return fs.existsSync("/usr/bin/security") ? "/usr/bin/security" : null;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function loadTokenFromKeychain(service, account) {
|
|
1525
|
+
const securityPath = resolveSecurityExecutable();
|
|
1526
|
+
if (!securityPath) {
|
|
1527
|
+
return null;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const result = spawnSync(securityPath, [
|
|
1531
|
+
"find-generic-password",
|
|
1532
|
+
"-a",
|
|
1533
|
+
account,
|
|
1534
|
+
"-s",
|
|
1535
|
+
service,
|
|
1536
|
+
"-w",
|
|
1537
|
+
], {
|
|
1538
|
+
encoding: "utf8",
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
if (result.status !== 0) {
|
|
1542
|
+
return null;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const token = result.stdout?.trim() ?? "";
|
|
1546
|
+
return token || null;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function saveTokenToKeychain(service, account, token) {
|
|
1550
|
+
const securityPath = resolveSecurityExecutable();
|
|
1551
|
+
if (!securityPath || !token) {
|
|
1552
|
+
return false;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const result = spawnSync(securityPath, [
|
|
1556
|
+
"add-generic-password",
|
|
1557
|
+
"-U",
|
|
1558
|
+
"-a",
|
|
1559
|
+
account,
|
|
1560
|
+
"-s",
|
|
1561
|
+
service,
|
|
1562
|
+
"-w",
|
|
1563
|
+
token,
|
|
1564
|
+
], {
|
|
1565
|
+
encoding: "utf8",
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
return result.status === 0;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function deleteTokenFromKeychain(service, account) {
|
|
1572
|
+
const securityPath = resolveSecurityExecutable();
|
|
1573
|
+
if (!securityPath) {
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
spawnSync(securityPath, [
|
|
1578
|
+
"delete-generic-password",
|
|
1579
|
+
"-a",
|
|
1580
|
+
account,
|
|
1581
|
+
"-s",
|
|
1582
|
+
service,
|
|
1583
|
+
], {
|
|
1584
|
+
encoding: "utf8",
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function getBoardLinkIndexPath() {
|
|
1589
|
+
return path.join(getCliConfigDir(), "linked-boards.json");
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function loadBoardLinkIndex() {
|
|
1593
|
+
const indexPath = getBoardLinkIndexPath();
|
|
1594
|
+
if (!fs.existsSync(indexPath)) {
|
|
1595
|
+
return {};
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
try {
|
|
1599
|
+
return JSON.parse(fs.readFileSync(indexPath, "utf8"));
|
|
1600
|
+
} catch {
|
|
1601
|
+
return {};
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function saveBoardLinkIndex(index) {
|
|
1606
|
+
const indexPath = getBoardLinkIndexPath();
|
|
1607
|
+
fs.mkdirSync(path.dirname(indexPath), { recursive: true, mode: 0o700 });
|
|
1608
|
+
fs.writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function registerBoardLink(repoRoot, link, previousBoardId = null) {
|
|
1612
|
+
const index = loadBoardLinkIndex();
|
|
1613
|
+
|
|
1614
|
+
if (previousBoardId && index[previousBoardId]?.repoRoot === repoRoot) {
|
|
1615
|
+
delete index[previousBoardId];
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
index[link.boardId] = {
|
|
1619
|
+
repoRoot,
|
|
1620
|
+
boardName: link.boardName ?? null,
|
|
1621
|
+
appUrl: link.appUrl ?? null,
|
|
1622
|
+
linkedAt: link.linkedAt ?? new Date().toISOString(),
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1625
|
+
saveBoardLinkIndex(index);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function resolveLinkedRepoRootForBoard(boardId) {
|
|
1629
|
+
const index = loadBoardLinkIndex();
|
|
1630
|
+
const entry = index[boardId];
|
|
1631
|
+
if (!entry?.repoRoot) {
|
|
1632
|
+
return null;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
if (!fs.existsSync(entry.repoRoot) || !fs.statSync(entry.repoRoot).isDirectory()) {
|
|
1636
|
+
delete index[boardId];
|
|
1637
|
+
saveBoardLinkIndex(index);
|
|
1638
|
+
return null;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
return entry.repoRoot;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function getRepoLinkPath(cwd) {
|
|
1645
|
+
const repoRoot = findGitRoot(cwd);
|
|
1646
|
+
if (!repoRoot) {
|
|
1647
|
+
return null;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
return path.join(repoRoot, ".repolens", "link.json");
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
function loadRepoLink(cwd) {
|
|
1654
|
+
const linkPath = getRepoLinkPath(cwd);
|
|
1655
|
+
if (!linkPath || !fs.existsSync(linkPath)) {
|
|
1656
|
+
return null;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
try {
|
|
1660
|
+
return JSON.parse(fs.readFileSync(linkPath, "utf8"));
|
|
1661
|
+
} catch {
|
|
1662
|
+
return null;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function saveRepoLink(repoRoot, link) {
|
|
1667
|
+
const linkPath = path.join(repoRoot, ".repolens", "link.json");
|
|
1668
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
1669
|
+
fs.writeFileSync(linkPath, `${JSON.stringify(link, null, 2)}\n`, "utf8");
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function getProtocolInstallDescriptor() {
|
|
1673
|
+
if (process.platform === "darwin") {
|
|
1674
|
+
const configured = process.env.REPOLENS_PROTOCOL_APP_PATH;
|
|
1675
|
+
const appBundlePath = configured
|
|
1676
|
+
? path.resolve(configured)
|
|
1677
|
+
: path.join(os.homedir(), "Applications", "RepoLens Launcher.app");
|
|
1678
|
+
|
|
1679
|
+
return {
|
|
1680
|
+
supported: true,
|
|
1681
|
+
platform: "darwin",
|
|
1682
|
+
installPath: appBundlePath,
|
|
1683
|
+
appBundlePath,
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
if (process.platform === "linux") {
|
|
1688
|
+
const configured = process.env.REPOLENS_PROTOCOL_DESKTOP_PATH;
|
|
1689
|
+
const desktopEntryPath = configured
|
|
1690
|
+
? path.resolve(configured)
|
|
1691
|
+
: path.join(os.homedir(), ".local", "share", "applications", "repolens-launcher.desktop");
|
|
1692
|
+
const launcherPath = path.join(getCliConfigDir(), "repolens-url-handler");
|
|
1693
|
+
|
|
1694
|
+
return {
|
|
1695
|
+
supported: true,
|
|
1696
|
+
platform: "linux",
|
|
1697
|
+
installPath: desktopEntryPath,
|
|
1698
|
+
desktopEntryPath,
|
|
1699
|
+
launcherPath,
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
if (process.platform === "win32") {
|
|
1704
|
+
const launcherPath = path.join(getCliConfigDir(), "repolens-url-handler.cmd");
|
|
1705
|
+
|
|
1706
|
+
return {
|
|
1707
|
+
supported: true,
|
|
1708
|
+
platform: "win32",
|
|
1709
|
+
installPath: launcherPath,
|
|
1710
|
+
launcherPath,
|
|
1711
|
+
registryRoot: "HKCU\\Software\\Classes\\repolens-cli",
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
return {
|
|
1716
|
+
supported: false,
|
|
1717
|
+
platform: process.platform,
|
|
1718
|
+
installPath: null,
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function isProtocolHandlerInstalled() {
|
|
1723
|
+
const descriptor = getProtocolInstallDescriptor();
|
|
1724
|
+
if (!descriptor.supported) {
|
|
1725
|
+
return false;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
if (descriptor.platform === "darwin") {
|
|
1729
|
+
return fs.existsSync(descriptor.appBundlePath);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if (descriptor.platform === "linux") {
|
|
1733
|
+
return fs.existsSync(descriptor.desktopEntryPath) && fs.existsSync(descriptor.launcherPath);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
if (descriptor.platform === "win32") {
|
|
1737
|
+
return fs.existsSync(descriptor.launcherPath);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
return false;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function ensureProtocolHandlerInstalled() {
|
|
1744
|
+
if (process.env.REPOLENS_SKIP_PROTOCOL_INSTALL === "1") {
|
|
1745
|
+
return false;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
const descriptor = getProtocolInstallDescriptor();
|
|
1749
|
+
if (!descriptor.supported || isProtocolHandlerInstalled()) {
|
|
1750
|
+
return false;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
installProtocolHandler();
|
|
1754
|
+
return true;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
function escapeDesktopExecValue(value) {
|
|
1758
|
+
return String(value)
|
|
1759
|
+
.replaceAll("\\", "\\\\")
|
|
1760
|
+
.replaceAll(" ", "\\ ");
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
function installProtocolHandler() {
|
|
1764
|
+
const cliEntrypoint = fs.realpathSync(process.argv[1]);
|
|
1765
|
+
const nodePath = process.execPath;
|
|
1766
|
+
const descriptor = getProtocolInstallDescriptor();
|
|
1767
|
+
|
|
1768
|
+
if (!descriptor.supported) {
|
|
1769
|
+
throw new Error(`Protocol installation is not supported on ${process.platform}.`);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
if (descriptor.platform === "darwin") {
|
|
1773
|
+
const contentsPath = path.join(descriptor.appBundlePath, "Contents");
|
|
1774
|
+
const macOsPath = path.join(contentsPath, "MacOS");
|
|
1775
|
+
const executablePath = path.join(macOsPath, "repolens-url-handler");
|
|
1776
|
+
const infoPlistPath = path.join(contentsPath, "Info.plist");
|
|
1777
|
+
|
|
1778
|
+
fs.mkdirSync(macOsPath, { recursive: true });
|
|
1779
|
+
|
|
1780
|
+
const infoPlist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1781
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1782
|
+
<plist version="1.0">
|
|
1783
|
+
<dict>
|
|
1784
|
+
<key>CFBundleDisplayName</key>
|
|
1785
|
+
<string>RepoLens Launcher</string>
|
|
1786
|
+
<key>CFBundleExecutable</key>
|
|
1787
|
+
<string>repolens-url-handler</string>
|
|
1788
|
+
<key>CFBundleIdentifier</key>
|
|
1789
|
+
<string>com.repolens.launcher</string>
|
|
1790
|
+
<key>CFBundleName</key>
|
|
1791
|
+
<string>RepoLens Launcher</string>
|
|
1792
|
+
<key>CFBundlePackageType</key>
|
|
1793
|
+
<string>APPL</string>
|
|
1794
|
+
<key>CFBundleShortVersionString</key>
|
|
1795
|
+
<string>0.1.1</string>
|
|
1796
|
+
<key>CFBundleVersion</key>
|
|
1797
|
+
<string>1</string>
|
|
1798
|
+
<key>CFBundleURLTypes</key>
|
|
1799
|
+
<array>
|
|
1800
|
+
<dict>
|
|
1801
|
+
<key>CFBundleURLName</key>
|
|
1802
|
+
<string>RepoLens Launcher</string>
|
|
1803
|
+
<key>CFBundleURLSchemes</key>
|
|
1804
|
+
<array>
|
|
1805
|
+
<string>repolens-cli</string>
|
|
1806
|
+
</array>
|
|
1807
|
+
</dict>
|
|
1808
|
+
</array>
|
|
1809
|
+
<key>LSUIElement</key>
|
|
1810
|
+
<true/>
|
|
1811
|
+
</dict>
|
|
1812
|
+
</plist>
|
|
1813
|
+
`;
|
|
1814
|
+
|
|
1815
|
+
const launcherScript = `#!/bin/zsh
|
|
1816
|
+
exec ${shellEscape(nodePath)} ${shellEscape(cliEntrypoint)} handle-url "$1"
|
|
1817
|
+
`;
|
|
1818
|
+
|
|
1819
|
+
fs.writeFileSync(infoPlistPath, infoPlist, "utf8");
|
|
1820
|
+
fs.writeFileSync(executablePath, launcherScript, { encoding: "utf8", mode: 0o755 });
|
|
1821
|
+
fs.chmodSync(executablePath, 0o755);
|
|
1822
|
+
|
|
1823
|
+
const lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
1824
|
+
if (fs.existsSync(lsregister)) {
|
|
1825
|
+
spawnSync(lsregister, ["-f", descriptor.appBundlePath], { encoding: "utf8" });
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
return descriptor;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
if (descriptor.platform === "linux") {
|
|
1832
|
+
fs.mkdirSync(path.dirname(descriptor.launcherPath), { recursive: true, mode: 0o700 });
|
|
1833
|
+
fs.mkdirSync(path.dirname(descriptor.desktopEntryPath), { recursive: true, mode: 0o755 });
|
|
1834
|
+
|
|
1835
|
+
const launcherScript = `#!/bin/sh
|
|
1836
|
+
exec ${shellEscape(nodePath)} ${shellEscape(cliEntrypoint)} handle-url "$1"
|
|
1837
|
+
`;
|
|
1838
|
+
|
|
1839
|
+
fs.writeFileSync(descriptor.launcherPath, launcherScript, { encoding: "utf8", mode: 0o755 });
|
|
1840
|
+
fs.chmodSync(descriptor.launcherPath, 0o755);
|
|
1841
|
+
fs.writeFileSync(
|
|
1842
|
+
descriptor.desktopEntryPath,
|
|
1843
|
+
buildLinuxDesktopEntry({
|
|
1844
|
+
scriptPath: escapeDesktopExecValue(descriptor.launcherPath),
|
|
1845
|
+
}),
|
|
1846
|
+
"utf8"
|
|
1847
|
+
);
|
|
1848
|
+
|
|
1849
|
+
const xdgMime = resolveOnPath("xdg-mime");
|
|
1850
|
+
if (xdgMime) {
|
|
1851
|
+
spawnSync(xdgMime.name, ["default", path.basename(descriptor.desktopEntryPath), "x-scheme-handler/repolens-cli"], {
|
|
1852
|
+
encoding: "utf8",
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
return descriptor;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
if (descriptor.platform === "win32") {
|
|
1860
|
+
fs.mkdirSync(path.dirname(descriptor.launcherPath), { recursive: true, mode: 0o700 });
|
|
1861
|
+
const launcherScript = `@echo off
|
|
1862
|
+
${JSON.stringify(nodePath)} ${JSON.stringify(cliEntrypoint)} handle-url %1
|
|
1863
|
+
`;
|
|
1864
|
+
|
|
1865
|
+
fs.writeFileSync(descriptor.launcherPath, launcherScript, { encoding: "utf8" });
|
|
1866
|
+
|
|
1867
|
+
const commandValue = `\\"${descriptor.launcherPath}\\" \\"%1\\"`;
|
|
1868
|
+
const registryCommands = [
|
|
1869
|
+
["add", descriptor.registryRoot, "/ve", "/d", "URL:RepoLens CLI Protocol", "/f"],
|
|
1870
|
+
["add", descriptor.registryRoot, "/v", "URL Protocol", "/d", "", "/f"],
|
|
1871
|
+
["add", `${descriptor.registryRoot}\\shell\\open\\command`, "/ve", "/d", commandValue, "/f"],
|
|
1872
|
+
];
|
|
1873
|
+
|
|
1874
|
+
for (const args of registryCommands) {
|
|
1875
|
+
const result = spawnSync("reg", args, { encoding: "utf8" });
|
|
1876
|
+
if (result.status !== 0) {
|
|
1877
|
+
const stderr = result.stderr?.trim() || result.stdout?.trim() || "Failed to update Windows protocol registry.";
|
|
1878
|
+
throw new Error(stderr);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
return descriptor;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
function normalizeAppUrl(value) {
|
|
1887
|
+
if (!value) {
|
|
1888
|
+
return null;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
try {
|
|
1892
|
+
const url = new URL(value);
|
|
1893
|
+
url.pathname = "";
|
|
1894
|
+
url.search = "";
|
|
1895
|
+
url.hash = "";
|
|
1896
|
+
return url.toString().replace(/\/$/, "");
|
|
1897
|
+
} catch {
|
|
1898
|
+
return null;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
async function callCliApi(params) {
|
|
1903
|
+
const url = new URL(params.pathname, `${params.appUrl}/`);
|
|
1904
|
+
const headers = {
|
|
1905
|
+
authorization: `Bearer ${params.token}`,
|
|
1906
|
+
connection: "close",
|
|
1907
|
+
...(params.body ? { "content-type": "application/json" } : {}),
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
return await fetch(url, {
|
|
1911
|
+
method: params.method,
|
|
1912
|
+
headers,
|
|
1913
|
+
body: params.body ? JSON.stringify(params.body) : undefined,
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
async function verifyCliTokenLogin(appUrl, token) {
|
|
1918
|
+
const response = await callCliApi({
|
|
1919
|
+
appUrl,
|
|
1920
|
+
token,
|
|
1921
|
+
pathname: "/api/cli/me",
|
|
1922
|
+
method: "GET",
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
const payload = await parseApiJson(response);
|
|
1926
|
+
if (!response.ok) {
|
|
1927
|
+
fail(payload.error ?? `Login failed with status ${response.status}.`);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
return {
|
|
1931
|
+
token,
|
|
1932
|
+
user: payload.user ?? null,
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
async function loginWithBrowserFlow(appUrl, options) {
|
|
1937
|
+
const response = await fetch(new URL("/api/cli/login/request", `${appUrl}/`), {
|
|
1938
|
+
method: "POST",
|
|
1939
|
+
headers: {
|
|
1940
|
+
"Content-Type": "application/json",
|
|
1941
|
+
Accept: "application/json",
|
|
1942
|
+
Connection: "close",
|
|
1943
|
+
},
|
|
1944
|
+
body: JSON.stringify({
|
|
1945
|
+
label: options.label,
|
|
1946
|
+
appUrl,
|
|
1947
|
+
}),
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
const payload = await parseApiJson(response);
|
|
1951
|
+
if (!response.ok) {
|
|
1952
|
+
fail(payload.error ?? `Browser login failed with status ${response.status}.`);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
const verificationUrl = typeof payload.verificationUrl === "string" ? payload.verificationUrl : null;
|
|
1956
|
+
const requestId = typeof payload.requestId === "string" ? payload.requestId : null;
|
|
1957
|
+
const pollToken = typeof payload.pollToken === "string" ? payload.pollToken : null;
|
|
1958
|
+
const intervalMs = typeof payload.intervalMs === "number" ? payload.intervalMs : 2000;
|
|
1959
|
+
const expiresAt = typeof payload.expiresAt === "string" ? Date.parse(payload.expiresAt) : NaN;
|
|
1960
|
+
|
|
1961
|
+
if (!verificationUrl || !requestId || !pollToken) {
|
|
1962
|
+
fail("CLI login request response was incomplete.");
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
if (!options.json) {
|
|
1966
|
+
process.stdout.write("Approve RepoLens CLI login in your browser.\n");
|
|
1967
|
+
process.stdout.write(`Verification code: ${payload.verificationCode}\n`);
|
|
1968
|
+
process.stdout.write(`Open: ${verificationUrl}\n`);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
if (!options.noBrowser) {
|
|
1972
|
+
const browserOpened = openBrowser(verificationUrl);
|
|
1973
|
+
if (!browserOpened) {
|
|
1974
|
+
await reportCliEvent({
|
|
1975
|
+
action: "cli_login.browser_open_failed",
|
|
1976
|
+
metadata: {
|
|
1977
|
+
platform: process.platform,
|
|
1978
|
+
verificationUrl,
|
|
1979
|
+
},
|
|
1980
|
+
});
|
|
1981
|
+
process.stderr.write("Could not open your browser automatically. Open the verification URL manually.\n");
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
while (true) {
|
|
1986
|
+
if (!Number.isNaN(expiresAt) && Date.now() >= expiresAt) {
|
|
1987
|
+
fail("CLI login request expired before approval completed.");
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
await sleep(intervalMs);
|
|
1991
|
+
|
|
1992
|
+
const pollResponse = await fetch(new URL("/api/cli/login/poll", `${appUrl}/`), {
|
|
1993
|
+
method: "POST",
|
|
1994
|
+
headers: {
|
|
1995
|
+
"Content-Type": "application/json",
|
|
1996
|
+
Accept: "application/json",
|
|
1997
|
+
Connection: "close",
|
|
1998
|
+
},
|
|
1999
|
+
body: JSON.stringify({
|
|
2000
|
+
requestId,
|
|
2001
|
+
pollToken,
|
|
2002
|
+
}),
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
const pollPayload = await parseApiJson(pollResponse);
|
|
2006
|
+
if (!pollResponse.ok && pollResponse.status !== 410) {
|
|
2007
|
+
fail(pollPayload.error ?? `CLI login poll failed with status ${pollResponse.status}.`);
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
if (pollPayload.status === "pending") {
|
|
2011
|
+
continue;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
if (pollPayload.status === "expired") {
|
|
2015
|
+
fail("CLI login request expired before approval completed.");
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
if (pollPayload.status !== "approved" || typeof pollPayload.token !== "string") {
|
|
2019
|
+
fail("CLI login did not complete successfully.");
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
return await verifyCliTokenLogin(appUrl, pollPayload.token);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
async function parseApiJson(response) {
|
|
2027
|
+
try {
|
|
2028
|
+
return await response.json();
|
|
2029
|
+
} catch {
|
|
2030
|
+
return {};
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function findGitRoot(cwd) {
|
|
2035
|
+
const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
2036
|
+
cwd,
|
|
2037
|
+
encoding: "utf8",
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
if (result.status !== 0) {
|
|
2041
|
+
return null;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
return result.stdout.trim() || null;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function defaultCliLoginLabel() {
|
|
2048
|
+
return `${os.hostname()} terminal`;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
function openBrowser(url) {
|
|
2052
|
+
const commands = process.platform === "darwin"
|
|
2053
|
+
? [["open", [url]]]
|
|
2054
|
+
: process.platform === "win32"
|
|
2055
|
+
? [["cmd.exe", ["/c", "start", "", url]]]
|
|
2056
|
+
: [["xdg-open", [url]]];
|
|
2057
|
+
|
|
2058
|
+
for (const [command, args] of commands) {
|
|
2059
|
+
const result = spawnSync(command, args, {
|
|
2060
|
+
encoding: "utf8",
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
if (result.status === 0) {
|
|
2064
|
+
return true;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
return false;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function sleep(ms) {
|
|
2072
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
function resolveExecutable(candidates) {
|
|
2076
|
+
for (const candidate of candidates) {
|
|
2077
|
+
const result = resolveOnPath(candidate);
|
|
2078
|
+
if (result) {
|
|
2079
|
+
return result;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
return null;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
function resolveOnPath(command) {
|
|
2087
|
+
const locator = process.platform === "win32" ? "where" : "which";
|
|
2088
|
+
const result = spawnSync(locator, [command], {
|
|
2089
|
+
encoding: "utf8",
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
if (result.status !== 0) {
|
|
2093
|
+
return null;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
const resolvedPath = lines(result.stdout)[0] ?? null;
|
|
2097
|
+
if (!resolvedPath) {
|
|
2098
|
+
return null;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
return {
|
|
2102
|
+
name: command,
|
|
2103
|
+
path: resolvedPath,
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function shellEscape(value) {
|
|
2108
|
+
return `'${String(value).replaceAll("'", `'\"'\"'`)}'`;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
function createTempProtocolLaunchScript(extension, content, mode = 0o700) {
|
|
2112
|
+
const tempDir = path.join(os.tmpdir(), "repolens-launch");
|
|
2113
|
+
fs.mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
|
2114
|
+
const scriptPath = path.join(tempDir, `launch-${Date.now()}-${Math.random().toString(36).slice(2)}.${extension}`);
|
|
2115
|
+
fs.writeFileSync(scriptPath, content, { encoding: "utf8", mode });
|
|
2116
|
+
if (mode) {
|
|
2117
|
+
fs.chmodSync(scriptPath, mode);
|
|
2118
|
+
}
|
|
2119
|
+
return scriptPath;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function openTerminalForProtocolCommand({ command, repoRoot, source }) {
|
|
2123
|
+
const cliEntrypoint = fs.realpathSync(process.argv[1]);
|
|
2124
|
+
if (process.platform === "darwin") {
|
|
2125
|
+
const script = buildTerminalLaunchScript({
|
|
2126
|
+
command,
|
|
2127
|
+
cliEntrypoint,
|
|
2128
|
+
nodePath: process.execPath,
|
|
2129
|
+
repoRoot,
|
|
2130
|
+
source,
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
const appleScript = [
|
|
2134
|
+
'tell application "Terminal"',
|
|
2135
|
+
" activate",
|
|
2136
|
+
` do script ${JSON.stringify(`/bin/zsh -lc ${shellEscape(script)}`)}`,
|
|
2137
|
+
"end tell",
|
|
2138
|
+
].join("\n");
|
|
2139
|
+
|
|
2140
|
+
const result = spawnSync("/usr/bin/osascript", ["-e", appleScript], {
|
|
2141
|
+
encoding: "utf8",
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
if (result.status !== 0) {
|
|
2145
|
+
const stderr = result.stderr ? result.stderr.trim() : "";
|
|
2146
|
+
throw new Error(stderr || "Failed to open Terminal for RepoLens launch.");
|
|
2147
|
+
}
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
if (process.platform === "linux") {
|
|
2152
|
+
const scriptPath = createTempProtocolLaunchScript("sh", `#!/bin/bash
|
|
2153
|
+
${buildTerminalLaunchScript({
|
|
2154
|
+
command,
|
|
2155
|
+
cliEntrypoint,
|
|
2156
|
+
nodePath: process.execPath,
|
|
2157
|
+
repoRoot,
|
|
2158
|
+
source,
|
|
2159
|
+
})}
|
|
2160
|
+
`);
|
|
2161
|
+
const terminalCandidates = [
|
|
2162
|
+
["x-terminal-emulator", ["-e", "/bin/bash", scriptPath]],
|
|
2163
|
+
["gnome-terminal", ["--", "/bin/bash", scriptPath]],
|
|
2164
|
+
["konsole", ["-e", "/bin/bash", scriptPath]],
|
|
2165
|
+
["xterm", ["-e", "/bin/bash", scriptPath]],
|
|
2166
|
+
];
|
|
2167
|
+
|
|
2168
|
+
for (const [binary, args] of terminalCandidates) {
|
|
2169
|
+
const resolved = resolveOnPath(binary);
|
|
2170
|
+
if (!resolved) {
|
|
2171
|
+
continue;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
const result = spawnSync(resolved.name, args, {
|
|
2175
|
+
encoding: "utf8",
|
|
2176
|
+
});
|
|
2177
|
+
|
|
2178
|
+
if (result.status === 0) {
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
throw new Error("Failed to open a Linux terminal for RepoLens launch. Install a supported terminal emulator or copy the command manually.");
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
if (process.platform === "win32") {
|
|
2187
|
+
const scriptPath = createTempProtocolLaunchScript("cmd", buildWindowsTerminalLaunchScript({
|
|
2188
|
+
command,
|
|
2189
|
+
cliEntrypoint,
|
|
2190
|
+
nodePath: process.execPath,
|
|
2191
|
+
repoRoot,
|
|
2192
|
+
source,
|
|
2193
|
+
}), 0o600);
|
|
2194
|
+
const result = spawnSync("cmd.exe", ["/c", "start", "", "cmd.exe", "/k", scriptPath], {
|
|
2195
|
+
encoding: "utf8",
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
if (result.status !== 0) {
|
|
2199
|
+
const stderr = result.stderr ? result.stderr.trim() : "";
|
|
2200
|
+
throw new Error(stderr || "Failed to open Windows Terminal for RepoLens launch.");
|
|
2201
|
+
}
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
throw new Error(`Protocol launch is not supported on ${process.platform}.`);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function runCommand(command, args, spawnOptions, runtimeOptions = {}) {
|
|
2209
|
+
const result = spawnSync(command, args, {
|
|
2210
|
+
...spawnOptions,
|
|
2211
|
+
encoding: "utf8",
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
if (result.status !== 0 && !runtimeOptions.allowFailure) {
|
|
2215
|
+
const stderr = result.stderr ? result.stderr.trim() : "";
|
|
2216
|
+
fail(stderr || `Command failed: ${command} ${args.join(" ")}`);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
return result.stdout ?? "";
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function rankPaths(paths) {
|
|
2223
|
+
return paths
|
|
2224
|
+
.map((filePath) => ({
|
|
2225
|
+
filePath,
|
|
2226
|
+
score: scorePath(filePath),
|
|
2227
|
+
}))
|
|
2228
|
+
.sort((left, right) => {
|
|
2229
|
+
if (right.score !== left.score) {
|
|
2230
|
+
return right.score - left.score;
|
|
2231
|
+
}
|
|
2232
|
+
return left.filePath.localeCompare(right.filePath);
|
|
2233
|
+
})
|
|
2234
|
+
.map((entry) => entry.filePath);
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
function scorePath(filePath) {
|
|
2238
|
+
const normalized = filePath.toLowerCase();
|
|
2239
|
+
let score = 0;
|
|
2240
|
+
|
|
2241
|
+
if (normalized === "package.json" || normalized.endsWith("/package.json")) score += 400;
|
|
2242
|
+
if (normalized === "readme.md" || normalized.endsWith("/readme.md")) score += 320;
|
|
2243
|
+
if (/(^|\/)(app|pages)\/.+(page|layout|route)\.(t|j)sx?$/.test(normalized)) score += 280;
|
|
2244
|
+
if (/(^|\/)(index|main|server|client)\.(t|j)sx?$/.test(normalized)) score += 260;
|
|
2245
|
+
if (/(^|\/)(api|routes|controllers?)\//.test(normalized)) score += 220;
|
|
2246
|
+
if (/(^|\/)(services?|use-cases?|usecases?|core|lib)\//.test(normalized)) score += 180;
|
|
2247
|
+
if (/(^|\/)(models?|entities?|schema|schemas|prisma|db)\//.test(normalized)) score += 170;
|
|
2248
|
+
if (/(^|\/)(components?|views?|screens?)\//.test(normalized)) score += 140;
|
|
2249
|
+
if (normalized.startsWith("src/")) score += 80;
|
|
2250
|
+
if (/\.(ts|tsx|js|jsx|py|go|rs|java|rb|php|cs|swift)$/.test(normalized)) score += 40;
|
|
2251
|
+
if (/(^|\/)(tests?|specs?|__tests__|stories|fixtures|mocks?)\//.test(normalized)) score -= 120;
|
|
2252
|
+
if (/(^|\/)(docs|examples?)\//.test(normalized)) score -= 80;
|
|
2253
|
+
score -= Math.floor(filePath.length / 10);
|
|
2254
|
+
|
|
2255
|
+
return score;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function buildContextMarkdown(payload) {
|
|
2259
|
+
const sections = [
|
|
2260
|
+
"# RepoLens Context Pack",
|
|
2261
|
+
"",
|
|
2262
|
+
`- Repo root: \`${payload.repoRoot}\``,
|
|
2263
|
+
`- Branch: \`${payload.branch}\``,
|
|
2264
|
+
`- Generated at: ${payload.generatedAt}`,
|
|
2265
|
+
"",
|
|
2266
|
+
"## Recent Commits",
|
|
2267
|
+
payload.recentCommits.length > 0
|
|
2268
|
+
? payload.recentCommits.map((line) => `- ${line}`).join("\n")
|
|
2269
|
+
: "- No commits found.",
|
|
2270
|
+
"",
|
|
2271
|
+
"## Working Tree",
|
|
2272
|
+
payload.status.length > 0
|
|
2273
|
+
? payload.status.map((line) => `- \`${line}\``).join("\n")
|
|
2274
|
+
: "- Clean working tree.",
|
|
2275
|
+
"",
|
|
2276
|
+
"## Top Files",
|
|
2277
|
+
payload.topFiles.length > 0
|
|
2278
|
+
? payload.topFiles.map((line) => `- \`${line}\``).join("\n")
|
|
2279
|
+
: "- No tracked files found.",
|
|
2280
|
+
];
|
|
2281
|
+
|
|
2282
|
+
if (payload.compare) {
|
|
2283
|
+
sections.push(
|
|
2284
|
+
"",
|
|
2285
|
+
`## Compare ${payload.compare.base}..${payload.compare.head}`,
|
|
2286
|
+
payload.compare.diffSummary.length > 0
|
|
2287
|
+
? payload.compare.diffSummary.map((line) => `- ${line}`).join("\n")
|
|
2288
|
+
: "- No diff summary available.",
|
|
2289
|
+
"",
|
|
2290
|
+
"### Changed Files",
|
|
2291
|
+
payload.compare.changedFiles.length > 0
|
|
2292
|
+
? payload.compare.changedFiles.map((line) => `- \`${line}\``).join("\n")
|
|
2293
|
+
: "- No changed files found."
|
|
2294
|
+
);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
sections.push(
|
|
2298
|
+
"",
|
|
2299
|
+
"## Suggested Agent Handoff",
|
|
2300
|
+
"- Use this pack as a local briefing artifact for `codex` or `claude`.",
|
|
2301
|
+
"- Re-run with `repolens context --base <ref> --head <ref>` before change-brief style tasks.",
|
|
2302
|
+
"- Use `repolens delegate <agent> -- --help` to inspect the local agent CLI contract."
|
|
2303
|
+
);
|
|
2304
|
+
|
|
2305
|
+
return `${sections.join("\n")}\n`;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
function buildBriefMarkdown(payload) {
|
|
2309
|
+
const compare = payload.compare;
|
|
2310
|
+
const sections = [
|
|
2311
|
+
"# RepoLens Change Brief Pack",
|
|
2312
|
+
"",
|
|
2313
|
+
`- Repo root: \`${payload.repoRoot}\``,
|
|
2314
|
+
`- Branch: \`${payload.branch}\``,
|
|
2315
|
+
`- Generated at: ${payload.generatedAt}`,
|
|
2316
|
+
compare ? `- Compare: \`${compare.base}..${compare.head}\`` : null,
|
|
2317
|
+
"",
|
|
2318
|
+
"## Commit Range",
|
|
2319
|
+
compare && compare.rangeCommits.length > 0
|
|
2320
|
+
? compare.rangeCommits.map((line) => `- ${line}`).join("\n")
|
|
2321
|
+
: "- No commit range summary available.",
|
|
2322
|
+
"",
|
|
2323
|
+
"## Diff Summary",
|
|
2324
|
+
compare && compare.diffSummary.length > 0
|
|
2325
|
+
? compare.diffSummary.map((line) => `- ${line}`).join("\n")
|
|
2326
|
+
: "- No diff summary available.",
|
|
2327
|
+
"",
|
|
2328
|
+
"## Changed Files",
|
|
2329
|
+
compare && compare.changedFiles.length > 0
|
|
2330
|
+
? compare.changedFiles.map((line) => `- \`${line}\``).join("\n")
|
|
2331
|
+
: "- No changed files found.",
|
|
2332
|
+
"",
|
|
2333
|
+
"## Architectural Context",
|
|
2334
|
+
payload.topFiles.length > 0
|
|
2335
|
+
? payload.topFiles.slice(0, 20).map((line) => `- \`${line}\``).join("\n")
|
|
2336
|
+
: "- No ranked files found.",
|
|
2337
|
+
"",
|
|
2338
|
+
"## Working Tree",
|
|
2339
|
+
payload.status.length > 0
|
|
2340
|
+
? payload.status.map((line) => `- \`${line}\``).join("\n")
|
|
2341
|
+
: "- Clean working tree.",
|
|
2342
|
+
].filter(Boolean);
|
|
2343
|
+
|
|
2344
|
+
return `${sections.join("\n")}\n`;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function buildRunPacket(params) {
|
|
2348
|
+
const compare = params.snapshot.compare;
|
|
2349
|
+
const modePreset = params.mode ? RUN_MODE_PRESETS[params.mode] : null;
|
|
2350
|
+
const sections = [
|
|
2351
|
+
"# RepoLens Agent Task Packet",
|
|
2352
|
+
"",
|
|
2353
|
+
`Agent target: ${params.agent}`,
|
|
2354
|
+
params.mode ? `Structured mode: ${params.mode}` : null,
|
|
2355
|
+
modePreset ? `Mode intent: ${modePreset.description}` : null,
|
|
2356
|
+
params.audience ? `Requested audience: ${params.audience}` : null,
|
|
2357
|
+
`Repo root: ${params.snapshot.repoRoot}`,
|
|
2358
|
+
`Current branch: ${params.snapshot.branch}`,
|
|
2359
|
+
`Generated at: ${params.snapshot.generatedAt}`,
|
|
2360
|
+
"",
|
|
2361
|
+
"## Task",
|
|
2362
|
+
params.task.trim(),
|
|
2363
|
+
"",
|
|
2364
|
+
"## Execution Mode",
|
|
2365
|
+
params.allowEdit
|
|
2366
|
+
? "- You may modify files if the task requires it. Prefer minimal, well-scoped changes."
|
|
2367
|
+
: "- Analyze only. Do not modify files. Focus on findings, plan, and exact recommendations.",
|
|
2368
|
+
"",
|
|
2369
|
+
"## Recent Commits",
|
|
2370
|
+
params.snapshot.recentCommits.length > 0
|
|
2371
|
+
? params.snapshot.recentCommits.map((line) => `- ${line}`).join("\n")
|
|
2372
|
+
: "- No recent commits found.",
|
|
2373
|
+
"",
|
|
2374
|
+
"## Working Tree",
|
|
2375
|
+
params.snapshot.status.length > 0
|
|
2376
|
+
? params.snapshot.status.map((line) => `- \`${line}\``).join("\n")
|
|
2377
|
+
: "- Clean working tree.",
|
|
2378
|
+
"",
|
|
2379
|
+
"## Top Files",
|
|
2380
|
+
params.snapshot.topFiles.length > 0
|
|
2381
|
+
? params.snapshot.topFiles.slice(0, 25).map((line) => `- \`${line}\``).join("\n")
|
|
2382
|
+
: "- No ranked files found.",
|
|
2383
|
+
];
|
|
2384
|
+
|
|
2385
|
+
if (compare) {
|
|
2386
|
+
sections.push(
|
|
2387
|
+
"",
|
|
2388
|
+
`## Compare ${compare.base}..${compare.head}`,
|
|
2389
|
+
compare.rangeCommits.length > 0
|
|
2390
|
+
? compare.rangeCommits.map((line) => `- ${line}`).join("\n")
|
|
2391
|
+
: "- No commit range summary available.",
|
|
2392
|
+
"",
|
|
2393
|
+
"### Diff Summary",
|
|
2394
|
+
compare.diffSummary.length > 0
|
|
2395
|
+
? compare.diffSummary.map((line) => `- ${line}`).join("\n")
|
|
2396
|
+
: "- No diff summary available.",
|
|
2397
|
+
"",
|
|
2398
|
+
"### Changed Files",
|
|
2399
|
+
compare.changedFiles.length > 0
|
|
2400
|
+
? compare.changedFiles.map((line) => `- \`${line}\``).join("\n")
|
|
2401
|
+
: "- No changed files found."
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
if (params.historyAppendix) {
|
|
2406
|
+
sections.push("", "## History Context", params.historyAppendix.trim());
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
if (params.contextAppendix) {
|
|
2410
|
+
sections.push("", "## Additional Context", params.contextAppendix.trim());
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
sections.push(
|
|
2414
|
+
"",
|
|
2415
|
+
"## Response Requirements",
|
|
2416
|
+
params.allowEdit
|
|
2417
|
+
? "- Describe intended changes briefly, then execute them."
|
|
2418
|
+
: "- Return analysis only.",
|
|
2419
|
+
modePreset
|
|
2420
|
+
? `- Respond with JSON only. Match the ${params.mode} schema exactly.`
|
|
2421
|
+
: "- Use a compact structure that is easy to scan.",
|
|
2422
|
+
"- Ground claims in specific files, commits, or diff evidence where possible.",
|
|
2423
|
+
"- Call out uncertainty instead of inferring silently."
|
|
2424
|
+
);
|
|
2425
|
+
|
|
2426
|
+
if (modePreset) {
|
|
2427
|
+
sections.push("", "## Structured Output Shape", "```json", JSON.stringify(modePreset.schema, null, 2), "```");
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
return `${sections.filter(Boolean).join("\n")}\n`;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
function writeOutput(content, outputPath) {
|
|
2434
|
+
if (!outputPath) {
|
|
2435
|
+
process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
const absolutePath = path.resolve(outputPath);
|
|
2440
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
2441
|
+
fs.writeFileSync(absolutePath, content.endsWith(os.EOL) ? content : `${content}${os.EOL}`, "utf8");
|
|
2442
|
+
process.stdout.write(`Wrote ${absolutePath}\n`);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
function lines(value) {
|
|
2446
|
+
return value
|
|
2447
|
+
.split(/\r?\n/)
|
|
2448
|
+
.map((line) => line.trimEnd())
|
|
2449
|
+
.filter(Boolean);
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
function printHelp() {
|
|
2453
|
+
process.stdout.write(`RepoLens CLI
|
|
2454
|
+
|
|
2455
|
+
Usage:
|
|
2456
|
+
repolens doctor [--json]
|
|
2457
|
+
repolens login --app-url URL [--label LABEL] [--no-browser] [--token TOKEN] [--json]
|
|
2458
|
+
repolens logout [--json]
|
|
2459
|
+
repolens whoami [--json]
|
|
2460
|
+
repolens boards [--json]
|
|
2461
|
+
repolens link --board-id UUID [--json]
|
|
2462
|
+
repolens install-protocol [--json]
|
|
2463
|
+
repolens handle-url URL
|
|
2464
|
+
repolens brief --base REF --head REF [--cwd PATH] [--output FILE] [--json]
|
|
2465
|
+
repolens evolution --path PATH [--cwd PATH] [--since REF] [--limit N] [--output FILE] [--json] [--publish-board-id UUID] [--dry-run]
|
|
2466
|
+
repolens hotspots [--cwd PATH] [--since REF] [--limit N] [--output FILE] [--json] [--publish-board-id UUID] [--dry-run]
|
|
2467
|
+
repolens ownership --path PATH [--cwd PATH] [--since REF] [--limit N] [--output FILE] [--json] [--publish-board-id UUID] [--dry-run]
|
|
2468
|
+
repolens start-here --path PATH [--cwd PATH] [--since REF] [--limit N] [--output FILE] [--json] [--publish-board-id UUID] [--dry-run]
|
|
2469
|
+
repolens trace --query TEXT [--cwd PATH] [--since REF] [--limit N] [--output FILE] [--json] [--publish-board-id UUID] [--dry-run]
|
|
2470
|
+
repolens context [--cwd PATH] [--base REF --head REF] [--output FILE] [--json]
|
|
2471
|
+
repolens run <codex|claude> --task TEXT [--mode review|summary|explainer|onboarding|change-brief] [--base REF --head REF] [--audience VALUE] [--context-file FILE] [--save-packet FILE] [--output FILE] [--publish-board-id UUID] [--publish-as feature|onboarding|change-brief] [--user-id UUID] [--json] [--allow-edit] [--dry-run] [--] <extra agent args...>
|
|
2472
|
+
repolens publish --input FILE [--board-id UUID] [--publish-as feature|onboarding|change-brief] [--audience VALUE] [--user-id UUID] [--dry-run] [--json]
|
|
2473
|
+
repolens delegate <codex|claude> [--cwd PATH] [--dry-run] [--] <agent args...>
|
|
2474
|
+
|
|
2475
|
+
Examples:
|
|
2476
|
+
repolens doctor
|
|
2477
|
+
repolens login --app-url https://repolens.ai
|
|
2478
|
+
repolens login --app-url https://repolens.ai --no-browser
|
|
2479
|
+
repolens whoami
|
|
2480
|
+
repolens boards
|
|
2481
|
+
repolens link --board-id <board-id>
|
|
2482
|
+
repolens install-protocol
|
|
2483
|
+
repolens brief --base origin/main --head HEAD --output .repolens/brief.md
|
|
2484
|
+
repolens evolution --path src/lib/auth.ts --since origin/main --output .repolens/evolution.md
|
|
2485
|
+
repolens hotspots --since origin/main --output .repolens/hotspots.md
|
|
2486
|
+
repolens ownership --path src/lib/auth.ts --since origin/main --output .repolens/ownership.md
|
|
2487
|
+
repolens start-here --path src/features/billing --since origin/main --output .repolens/start-here.md
|
|
2488
|
+
repolens trace --query auth --since origin/main --output .repolens/trace.md
|
|
2489
|
+
repolens trace --query BillingService --since origin/main --publish-board-id <board-id> --dry-run
|
|
2490
|
+
repolens context --base origin/main --head HEAD --output .repolens/context.md
|
|
2491
|
+
repolens run codex --task "Review this change" --mode review --base origin/main --head HEAD --dry-run
|
|
2492
|
+
repolens run codex --task "Explain the auth flow" --mode explainer --audience engineer --base origin/main --head HEAD --dry-run
|
|
2493
|
+
repolens run claude --task "Build a release brief" --mode change-brief --audience qa --base origin/main --head HEAD --publish-board-id <board-id>
|
|
2494
|
+
repolens publish --input .repolens/runs/latest.json --board-id <board-id> --publish-as feature --dry-run
|
|
2495
|
+
repolens delegate codex -- --help
|
|
2496
|
+
`);
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
function fail(message) {
|
|
2500
|
+
process.stderr.write(`${message}\n`);
|
|
2501
|
+
process.exit(1);
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
main().catch((error) => {
|
|
2505
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
function collectRepoSnapshot({ cwd, base, head, includeRangeCommits = false }) {
|
|
2509
|
+
const repoRoot = findGitRoot(cwd);
|
|
2510
|
+
if (!repoRoot) {
|
|
2511
|
+
fail(`No git repository found from ${cwd}`);
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
const branch = runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: repoRoot }).trim();
|
|
2515
|
+
const recentCommits = lines(runCommand("git", ["log", "--oneline", "-n", "8"], { cwd: repoRoot }));
|
|
2516
|
+
const status = lines(runCommand("git", ["status", "--short"], { cwd: repoRoot }, { allowFailure: true }));
|
|
2517
|
+
const trackedFiles = lines(runCommand("git", ["ls-files"], { cwd: repoRoot }, { allowFailure: true }));
|
|
2518
|
+
const topFiles = rankPaths(trackedFiles).slice(0, 30);
|
|
2519
|
+
|
|
2520
|
+
let compare = null;
|
|
2521
|
+
if (base && head) {
|
|
2522
|
+
compare = {
|
|
2523
|
+
base,
|
|
2524
|
+
head,
|
|
2525
|
+
diffSummary: lines(
|
|
2526
|
+
runCommand("git", ["diff", "--stat", `${base}..${head}`], { cwd: repoRoot }, { allowFailure: true })
|
|
2527
|
+
),
|
|
2528
|
+
changedFiles: lines(
|
|
2529
|
+
runCommand("git", ["diff", "--name-only", `${base}..${head}`], { cwd: repoRoot }, { allowFailure: true })
|
|
2530
|
+
),
|
|
2531
|
+
rangeCommits: includeRangeCommits
|
|
2532
|
+
? lines(
|
|
2533
|
+
runCommand("git", ["log", "--oneline", "--no-merges", `${base}..${head}`], { cwd: repoRoot }, { allowFailure: true })
|
|
2534
|
+
)
|
|
2535
|
+
: [],
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
return {
|
|
2540
|
+
repoRoot,
|
|
2541
|
+
branch,
|
|
2542
|
+
generatedAt: new Date().toISOString(),
|
|
2543
|
+
recentCommits,
|
|
2544
|
+
status,
|
|
2545
|
+
topFiles,
|
|
2546
|
+
compare,
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
function buildRunHistoryAppendix({ repoRoot, snapshot }) {
|
|
2551
|
+
const sections = [];
|
|
2552
|
+
const sinceRef = snapshot.compare?.base ?? "HEAD~50";
|
|
2553
|
+
const execGit = (gitArgs, runtimeOptions = {}) => runCommand("git", gitArgs, { cwd: repoRoot }, runtimeOptions);
|
|
2554
|
+
const hotspotReport = collectHotspotReport({
|
|
2555
|
+
sinceRef,
|
|
2556
|
+
limit: 5,
|
|
2557
|
+
exec: execGit,
|
|
2558
|
+
});
|
|
2559
|
+
|
|
2560
|
+
if (hotspotReport.files.length > 0) {
|
|
2561
|
+
sections.push(
|
|
2562
|
+
"### Recent Hotspots",
|
|
2563
|
+
hotspotReport.files
|
|
2564
|
+
.slice(0, 5)
|
|
2565
|
+
.map((file) => `- \`${file.filePath}\` — score ${file.score}, ${file.touchedCommits} commits, +${file.additions}/-${file.deletions}`)
|
|
2566
|
+
.join("\n")
|
|
2567
|
+
);
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
const scopePath = inferRunScopePath(snapshot.compare?.changedFiles ?? snapshot.topFiles.slice(0, 6));
|
|
2571
|
+
if (scopePath) {
|
|
2572
|
+
const startHere = collectStartHereReport({
|
|
2573
|
+
cwd: repoRoot,
|
|
2574
|
+
targetPath: scopePath,
|
|
2575
|
+
sinceRef: snapshot.compare?.base ?? null,
|
|
2576
|
+
limit: 4,
|
|
2577
|
+
exec: execGit,
|
|
2578
|
+
});
|
|
2579
|
+
|
|
2580
|
+
sections.push(
|
|
2581
|
+
"### Start Here",
|
|
2582
|
+
`- Scope: \`${startHere.targetPath}\` (${startHere.pathKind})`,
|
|
2583
|
+
`- Why: ${startHere.summary}`,
|
|
2584
|
+
`- Change drivers: ${startHere.whyChanged}`,
|
|
2585
|
+
startHere.commitThemes.length > 0
|
|
2586
|
+
? `- Themes: ${startHere.commitThemes.slice(0, 3).map((theme) => `${theme.label} (${theme.commits})`).join(", ")}`
|
|
2587
|
+
: "- Themes: no recurring commit themes found.",
|
|
2588
|
+
startHere.readingOrder.length > 0
|
|
2589
|
+
? `- Reading order: ${startHere.readingOrder.slice(0, 3).map((entry) => `\`${entry.path}\``).join(" -> ")}`
|
|
2590
|
+
: "- Reading order: no guided reading order derived."
|
|
2591
|
+
);
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
const interestingPaths = snapshot.compare?.changedFiles?.slice(0, 3) ?? snapshot.topFiles.slice(0, 2);
|
|
2595
|
+
if (interestingPaths.length > 0) {
|
|
2596
|
+
const evolutionSections = interestingPaths.map((targetPath) => {
|
|
2597
|
+
const evolution = collectEvolutionReport({
|
|
2598
|
+
cwd: repoRoot,
|
|
2599
|
+
targetPath,
|
|
2600
|
+
sinceRef: snapshot.compare?.base ?? null,
|
|
2601
|
+
limit: 3,
|
|
2602
|
+
exec: execGit,
|
|
2603
|
+
});
|
|
2604
|
+
|
|
2605
|
+
if (evolution.commits.length === 0) {
|
|
2606
|
+
return `- \`${targetPath}\`: no recent history found.`;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
const recent = evolution.commits
|
|
2610
|
+
.slice(0, 3)
|
|
2611
|
+
.map((commit) => `${commit.date} ${commit.sha.slice(0, 7)} ${commit.subject}`)
|
|
2612
|
+
.join("; ");
|
|
2613
|
+
|
|
2614
|
+
const movement = evolution.renameHistory[0]
|
|
2615
|
+
? ` moved from \`${evolution.renameHistory[0].fromPath}\``
|
|
2616
|
+
: "";
|
|
2617
|
+
const themes = evolution.commitThemes.length > 0
|
|
2618
|
+
? ` themes: ${evolution.commitThemes.slice(0, 2).map((theme) => theme.label).join(", ")}`
|
|
2619
|
+
: "";
|
|
2620
|
+
|
|
2621
|
+
return `- \`${targetPath}\`: ${recent}${movement}.${themes} ${evolution.whyChanged}`.trim();
|
|
2622
|
+
});
|
|
2623
|
+
|
|
2624
|
+
sections.push("### Recent Evolution", evolutionSections.join("\n"));
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
if (interestingPaths.length > 0) {
|
|
2628
|
+
const ownershipSections = interestingPaths.map((targetPath) => {
|
|
2629
|
+
const ownership = collectOwnershipReport({
|
|
2630
|
+
cwd: repoRoot,
|
|
2631
|
+
targetPath,
|
|
2632
|
+
sinceRef: snapshot.compare?.base ?? null,
|
|
2633
|
+
limit: 2,
|
|
2634
|
+
exec: execGit,
|
|
2635
|
+
});
|
|
2636
|
+
|
|
2637
|
+
if (ownership.owners.length === 0) {
|
|
2638
|
+
return `- \`${targetPath}\`: no ownership history found.`;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
const leaders = ownership.owners
|
|
2642
|
+
.slice(0, 2)
|
|
2643
|
+
.map((owner) => `${owner.author} (${owner.commits} commits)`)
|
|
2644
|
+
.join(", ");
|
|
2645
|
+
|
|
2646
|
+
return `- \`${targetPath}\`: ${leaders}`;
|
|
2647
|
+
});
|
|
2648
|
+
|
|
2649
|
+
sections.push("### Ownership Signals", ownershipSections.join("\n"));
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
return sections.join("\n\n");
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
function inferRunScopePath(paths) {
|
|
2656
|
+
const normalized = paths
|
|
2657
|
+
.map((filePath) => normalizeScopePath(filePath))
|
|
2658
|
+
.filter(Boolean);
|
|
2659
|
+
|
|
2660
|
+
if (normalized.length === 0) {
|
|
2661
|
+
return null;
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
const prefixes = new Map();
|
|
2665
|
+
for (const filePath of normalized) {
|
|
2666
|
+
const segments = filePath.split("/");
|
|
2667
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
2668
|
+
const prefix = segments.slice(0, index).join("/");
|
|
2669
|
+
const current = prefixes.get(prefix) ?? { count: 0, depth: index };
|
|
2670
|
+
current.count += 1;
|
|
2671
|
+
prefixes.set(prefix, current);
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
const rankedPrefix = Array.from(prefixes.entries())
|
|
2676
|
+
.filter(([, entry]) => entry.count >= 2)
|
|
2677
|
+
.sort((left, right) => (right[1].count * right[1].depth) - (left[1].count * left[1].depth) || right[1].depth - left[1].depth)[0];
|
|
2678
|
+
|
|
2679
|
+
if (rankedPrefix) {
|
|
2680
|
+
return rankedPrefix[0];
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
return normalized[0];
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
function normalizeScopePath(value) {
|
|
2687
|
+
return value.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
function resolveTask(task, taskFile) {
|
|
2691
|
+
if (task && taskFile) {
|
|
2692
|
+
fail("Use either --task or --task-file, not both.");
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
if (taskFile) {
|
|
2696
|
+
return fs.readFileSync(path.resolve(taskFile), "utf8");
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
if (task) {
|
|
2700
|
+
return task;
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
fail("A task is required. Provide --task <text> or --task-file <file>.");
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
function parsePositiveInteger(value, fallback, flagName) {
|
|
2707
|
+
if (!value) {
|
|
2708
|
+
return fallback;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
const parsed = Number.parseInt(value, 10);
|
|
2712
|
+
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
2713
|
+
fail(`${flagName} must be a positive integer.`);
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
return parsed;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
function buildRunSpec(params) {
|
|
2720
|
+
const outputPath = params.outputPath;
|
|
2721
|
+
|
|
2722
|
+
if (params.agent === "codex") {
|
|
2723
|
+
const args = [
|
|
2724
|
+
"exec",
|
|
2725
|
+
"-C",
|
|
2726
|
+
params.cwd,
|
|
2727
|
+
"--sandbox",
|
|
2728
|
+
params.allowEdit ? "workspace-write" : "read-only",
|
|
2729
|
+
];
|
|
2730
|
+
|
|
2731
|
+
if (params.model) {
|
|
2732
|
+
args.push("--model", params.model);
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
if (params.json) {
|
|
2736
|
+
args.push("--json");
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
if (params.schemaPath) {
|
|
2740
|
+
args.push("--output-schema", params.schemaPath);
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
if (params.codexLastMessagePath) {
|
|
2744
|
+
args.push("-o", params.codexLastMessagePath);
|
|
2745
|
+
} else if (outputPath && !params.json) {
|
|
2746
|
+
args.push("-o", outputPath);
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
args.push(...params.extraArgs, "-");
|
|
2750
|
+
return { args, outputPath: params.json ? outputPath : null };
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
const args = ["-p"];
|
|
2754
|
+
if (params.model) {
|
|
2755
|
+
args.push("--model", params.model);
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
if (params.schemaPath) {
|
|
2759
|
+
args.push("--output-format", "json", "--json-schema", fs.readFileSync(params.schemaPath, "utf8"));
|
|
2760
|
+
} else if (params.json) {
|
|
2761
|
+
args.push("--output-format", "json");
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
if (params.allowEdit) {
|
|
2765
|
+
args.push("--permission-mode", "acceptEdits");
|
|
2766
|
+
} else {
|
|
2767
|
+
args.push("--permission-mode", "plan");
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
args.push(...params.extraArgs);
|
|
2771
|
+
return { args, outputPath };
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
async function executeAgentRun(params) {
|
|
2775
|
+
return await new Promise((resolve, reject) => {
|
|
2776
|
+
const child = spawn(params.command, params.args, {
|
|
2777
|
+
cwd: params.cwd,
|
|
2778
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2779
|
+
env: process.env,
|
|
2780
|
+
});
|
|
2781
|
+
|
|
2782
|
+
let stdoutBuffer = "";
|
|
2783
|
+
let stderrBuffer = "";
|
|
2784
|
+
|
|
2785
|
+
child.stdout.on("data", (chunk) => {
|
|
2786
|
+
const text = chunk.toString();
|
|
2787
|
+
stdoutBuffer += text;
|
|
2788
|
+
process.stdout.write(text);
|
|
2789
|
+
});
|
|
2790
|
+
|
|
2791
|
+
child.stderr.on("data", (chunk) => {
|
|
2792
|
+
const text = chunk.toString();
|
|
2793
|
+
stderrBuffer += text;
|
|
2794
|
+
process.stderr.write(text);
|
|
2795
|
+
});
|
|
2796
|
+
|
|
2797
|
+
child.on("error", (error) => reject(error));
|
|
2798
|
+
|
|
2799
|
+
child.on("close", (code) => {
|
|
2800
|
+
if (params.outputPath) {
|
|
2801
|
+
fs.mkdirSync(path.dirname(params.outputPath), { recursive: true });
|
|
2802
|
+
fs.writeFileSync(params.outputPath, stdoutBuffer, "utf8");
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
if (code === 0) {
|
|
2806
|
+
resolve(stdoutBuffer);
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
const errorMessage = stderrBuffer.trim() || `Agent exited with code ${code ?? 1}`;
|
|
2811
|
+
reject(new Error(errorMessage));
|
|
2812
|
+
});
|
|
2813
|
+
|
|
2814
|
+
child.stdin.write(params.prompt);
|
|
2815
|
+
child.stdin.end();
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
function parseStructuredJson(value) {
|
|
2820
|
+
try {
|
|
2821
|
+
return JSON.parse(value);
|
|
2822
|
+
} catch (error) {
|
|
2823
|
+
const message = error instanceof Error ? error.message : "Unknown JSON parse error";
|
|
2824
|
+
fail(`Structured agent output was not valid JSON: ${message}`);
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
function validateStructuredOutput(schema, value, currentPath = "$") {
|
|
2829
|
+
const error = getValidationError(schema, value, currentPath);
|
|
2830
|
+
if (error) {
|
|
2831
|
+
fail(error);
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
function isPlainObject(value) {
|
|
2836
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
function getValidationError(schema, value, currentPath) {
|
|
2840
|
+
const schemaType = schema.type;
|
|
2841
|
+
if (Array.isArray(schemaType)) {
|
|
2842
|
+
const errors = schemaType
|
|
2843
|
+
.map((candidate) => getValidationError({ ...schema, type: candidate }, value, currentPath))
|
|
2844
|
+
.filter(Boolean);
|
|
2845
|
+
return errors.length === schemaType.length ? errors[0] : null;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
2849
|
+
return `Structured output validation failed at ${currentPath}: expected one of ${schema.enum.join(", ")}.`;
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
if (schema.type === "object") {
|
|
2853
|
+
if (!isPlainObject(value)) {
|
|
2854
|
+
return `Structured output validation failed at ${currentPath}: expected object.`;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
for (const key of schema.required ?? []) {
|
|
2858
|
+
if (!(key in value)) {
|
|
2859
|
+
return `Structured output validation failed at ${currentPath}: missing required key "${key}".`;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
for (const [key, propertySchema] of Object.entries(schema.properties ?? {})) {
|
|
2864
|
+
if (key in value) {
|
|
2865
|
+
const nestedError = getValidationError(propertySchema, value[key], `${currentPath}.${key}`);
|
|
2866
|
+
if (nestedError) {
|
|
2867
|
+
return nestedError;
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
return null;
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
if (schema.type === "array") {
|
|
2876
|
+
if (!Array.isArray(value)) {
|
|
2877
|
+
return `Structured output validation failed at ${currentPath}: expected array.`;
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
if (typeof schema.minItems === "number" && value.length < schema.minItems) {
|
|
2881
|
+
return `Structured output validation failed at ${currentPath}: expected at least ${schema.minItems} items.`;
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
|
|
2885
|
+
return `Structured output validation failed at ${currentPath}: expected at most ${schema.maxItems} items.`;
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
for (const [index, item] of value.entries()) {
|
|
2889
|
+
const nestedError = getValidationError(schema.items, item, `${currentPath}[${index}]`);
|
|
2890
|
+
if (nestedError) {
|
|
2891
|
+
return nestedError;
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
return null;
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
if (schema.type === "string") {
|
|
2899
|
+
return typeof value === "string"
|
|
2900
|
+
? null
|
|
2901
|
+
: `Structured output validation failed at ${currentPath}: expected string.`;
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
if (schema.type === "number") {
|
|
2905
|
+
return typeof value === "number" && !Number.isNaN(value)
|
|
2906
|
+
? null
|
|
2907
|
+
: `Structured output validation failed at ${currentPath}: expected number.`;
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
if (schema.type === "boolean") {
|
|
2911
|
+
return typeof value === "boolean"
|
|
2912
|
+
? null
|
|
2913
|
+
: `Structured output validation failed at ${currentPath}: expected boolean.`;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
if (schema.type === "null") {
|
|
2917
|
+
return value === null
|
|
2918
|
+
? null
|
|
2919
|
+
: `Structured output validation failed at ${currentPath}: expected null.`;
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
return null;
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
function buildRunStamp() {
|
|
2926
|
+
return new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
function normalizePublishKind(value) {
|
|
2930
|
+
if (!value) {
|
|
2931
|
+
return null;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
switch (value) {
|
|
2935
|
+
case "explainer":
|
|
2936
|
+
case "feature":
|
|
2937
|
+
return "feature";
|
|
2938
|
+
case "onboarding":
|
|
2939
|
+
return "onboarding";
|
|
2940
|
+
case "change-brief":
|
|
2941
|
+
case "change_brief":
|
|
2942
|
+
return "change-brief";
|
|
2943
|
+
default:
|
|
2944
|
+
return null;
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
function defaultPublishKindForMode(mode) {
|
|
2949
|
+
if (!mode) {
|
|
2950
|
+
return null;
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
const preset = RUN_MODE_PRESETS[mode];
|
|
2954
|
+
return preset?.publishKind ?? null;
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
function normalizeAudience(value, publishKind) {
|
|
2958
|
+
if (!value) {
|
|
2959
|
+
return publishKind === "change-brief" ? "pm" : "pm";
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
if (publishKind === "change-brief") {
|
|
2963
|
+
if (value === "pm" || value === "engineer" || value === "qa") {
|
|
2964
|
+
return value;
|
|
2965
|
+
}
|
|
2966
|
+
fail('Change brief audience must be one of: "pm", "engineer", "qa".');
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
if (value === "pm" || value === "engineer" || value === "technical_deep_dive") {
|
|
2970
|
+
return value;
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
if (value === "qa") {
|
|
2974
|
+
return value;
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
fail('Audience must be one of: "pm", "engineer", "technical_deep_dive", "qa".');
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
function hasPublishEnv() {
|
|
2981
|
+
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY);
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
function buildPublishPreview(params) {
|
|
2985
|
+
const result = params.envelope?.result ?? {};
|
|
2986
|
+
return {
|
|
2987
|
+
boardId: params.boardId,
|
|
2988
|
+
publishKind: params.publishKind,
|
|
2989
|
+
audience: params.audience,
|
|
2990
|
+
userId: params.userId,
|
|
2991
|
+
mode: params.envelope?.mode ?? null,
|
|
2992
|
+
title: readPublishTitle(params.publishKind, result),
|
|
2993
|
+
summary: readPublishSummary(params.publishKind, result),
|
|
2994
|
+
diagramCount: Array.isArray(result.diagrams) ? result.diagrams.length : 0,
|
|
2995
|
+
evidenceCount: Array.isArray(result.evidence) ? result.evidence.length : 0,
|
|
2996
|
+
compare: params.envelope?.compare ?? null,
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
function buildHistoryPublishPreview(params) {
|
|
3001
|
+
return {
|
|
3002
|
+
boardId: params.boardId,
|
|
3003
|
+
reportKind: params.reportKind,
|
|
3004
|
+
title: buildHistoryReportTitle(params.reportKind, params.payload),
|
|
3005
|
+
summary: buildHistoryReportSummary(params.reportKind, params.payload),
|
|
3006
|
+
relatedFiles: collectHistoryReportRelatedFiles(params.reportKind, params.payload).length,
|
|
3007
|
+
};
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
async function publishHistoryReport(params) {
|
|
3011
|
+
if (params.dryRun) {
|
|
3012
|
+
process.stdout.write(`${JSON.stringify(buildHistoryPublishPreview(params), null, 2)}\n`);
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
const authConfig = loadCliAuthConfig();
|
|
3017
|
+
if (authConfig?.appUrl && authConfig?.token) {
|
|
3018
|
+
const response = await callCliApi({
|
|
3019
|
+
appUrl: authConfig.appUrl,
|
|
3020
|
+
token: authConfig.token,
|
|
3021
|
+
pathname: "/api/cli/history-reports",
|
|
3022
|
+
method: "POST",
|
|
3023
|
+
body: {
|
|
3024
|
+
boardId: params.boardId,
|
|
3025
|
+
reportKind: params.reportKind,
|
|
3026
|
+
payload: params.payload,
|
|
3027
|
+
},
|
|
3028
|
+
});
|
|
3029
|
+
const payload = await parseApiJson(response);
|
|
3030
|
+
if (!response.ok) {
|
|
3031
|
+
fail(payload.error ?? `History report publish failed with status ${response.status}.`);
|
|
3032
|
+
}
|
|
3033
|
+
process.stdout.write(`Published ${params.reportKind} history report to board ${params.boardId}\n`);
|
|
3034
|
+
return payload;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
return await publishHistoryReportLocally(params);
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
async function publishEnvelope(params) {
|
|
3041
|
+
const authConfig = loadCliAuthConfig();
|
|
3042
|
+
if (authConfig?.appUrl && authConfig?.token) {
|
|
3043
|
+
const response = await callCliApi({
|
|
3044
|
+
appUrl: authConfig.appUrl,
|
|
3045
|
+
token: authConfig.token,
|
|
3046
|
+
pathname: "/api/cli/publish",
|
|
3047
|
+
method: "POST",
|
|
3048
|
+
body: {
|
|
3049
|
+
boardId: params.boardId,
|
|
3050
|
+
publishAs: params.publishKind,
|
|
3051
|
+
audience: params.audience,
|
|
3052
|
+
envelope: params.envelope,
|
|
3053
|
+
},
|
|
3054
|
+
});
|
|
3055
|
+
const payload = await parseApiJson(response);
|
|
3056
|
+
if (!response.ok) {
|
|
3057
|
+
fail(payload.error ?? `Publish failed with status ${response.status}.`);
|
|
3058
|
+
}
|
|
3059
|
+
return payload;
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
return await publishStructuredRun(params);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
async function publishHistoryReportLocally(params) {
|
|
3066
|
+
if (!hasPublishEnv()) {
|
|
3067
|
+
fail("History report publishing requires repolens login or NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.");
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
const Supabase = await import("@supabase/supabase-js");
|
|
3071
|
+
const supabase = Supabase.createClient(
|
|
3072
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
|
3073
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
3074
|
+
{
|
|
3075
|
+
auth: {
|
|
3076
|
+
autoRefreshToken: false,
|
|
3077
|
+
persistSession: false,
|
|
3078
|
+
},
|
|
3079
|
+
}
|
|
3080
|
+
);
|
|
3081
|
+
|
|
3082
|
+
const { data: board, error: boardError } = await supabase
|
|
3083
|
+
.from("boards")
|
|
3084
|
+
.select("id, owner_id")
|
|
3085
|
+
.eq("id", params.boardId)
|
|
3086
|
+
.maybeSingle();
|
|
3087
|
+
|
|
3088
|
+
if (boardError || !board) {
|
|
3089
|
+
throw new Error(boardError?.message ?? `Board not found: ${params.boardId}`);
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
const now = new Date().toISOString();
|
|
3093
|
+
const analysisMetadata = extractLocalHistoryAnalysisMetadata(params.reportKind, params.payload);
|
|
3094
|
+
const historyReportMeta = buildCompactHistoryMetadata(params.reportKind, params.payload);
|
|
3095
|
+
const { data: analysis, error: analysisError } = await supabase
|
|
3096
|
+
.from("analyses")
|
|
3097
|
+
.insert({
|
|
3098
|
+
board_id: params.boardId,
|
|
3099
|
+
triggered_by: board.owner_id,
|
|
3100
|
+
status: "complete",
|
|
3101
|
+
trigger_type: "manual",
|
|
3102
|
+
commit_sha: null,
|
|
3103
|
+
base_ref: analysisMetadata.baseRef,
|
|
3104
|
+
head_ref: analysisMetadata.headRef,
|
|
3105
|
+
stage_1_result: null,
|
|
3106
|
+
stage_2_config: {
|
|
3107
|
+
source: "repolens_cli",
|
|
3108
|
+
mode: params.reportKind,
|
|
3109
|
+
repo_root: params.payload.repoRoot ?? null,
|
|
3110
|
+
task: analysisMetadata.task,
|
|
3111
|
+
history_report_kind: params.reportKind,
|
|
3112
|
+
history_report_meta: historyReportMeta,
|
|
3113
|
+
generated_at: params.payload.generatedAt ?? null,
|
|
3114
|
+
},
|
|
3115
|
+
completed_at: now,
|
|
3116
|
+
})
|
|
3117
|
+
.select("id")
|
|
3118
|
+
.single();
|
|
3119
|
+
|
|
3120
|
+
if (analysisError || !analysis) {
|
|
3121
|
+
throw new Error(analysisError?.message ?? "Failed to create history report analysis.");
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
const { data: artifact, error: artifactError } = await supabase
|
|
3125
|
+
.from("artifacts")
|
|
3126
|
+
.insert({
|
|
3127
|
+
analysis_id: analysis.id,
|
|
3128
|
+
board_id: params.boardId,
|
|
3129
|
+
type: "history_report",
|
|
3130
|
+
perspective: params.reportKind,
|
|
3131
|
+
audience: "engineer",
|
|
3132
|
+
title: buildHistoryReportTitle(params.reportKind, params.payload),
|
|
3133
|
+
content: buildHistoryReportMarkdown(params.reportKind, params.payload),
|
|
3134
|
+
content_format: "markdown",
|
|
3135
|
+
confidence_score: 0.86,
|
|
3136
|
+
confidence_reason: "local_git_history_analysis",
|
|
3137
|
+
is_inferred: false,
|
|
3138
|
+
related_files: collectHistoryReportRelatedFiles(params.reportKind, params.payload),
|
|
3139
|
+
})
|
|
3140
|
+
.select("id")
|
|
3141
|
+
.single();
|
|
3142
|
+
|
|
3143
|
+
if (artifactError || !artifact) {
|
|
3144
|
+
throw new Error(artifactError?.message ?? "Failed to persist history report artifact.");
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
await supabase
|
|
3148
|
+
.from("boards")
|
|
3149
|
+
.update({ last_analyzed_at: now })
|
|
3150
|
+
.eq("id", params.boardId);
|
|
3151
|
+
|
|
3152
|
+
await supabase.from("audit_logs").insert({
|
|
3153
|
+
board_id: params.boardId,
|
|
3154
|
+
user_id: board.owner_id,
|
|
3155
|
+
action: "history_report.published_from_cli",
|
|
3156
|
+
target_type: "history_report",
|
|
3157
|
+
target_id: artifact.id,
|
|
3158
|
+
metadata: {
|
|
3159
|
+
kind: params.reportKind,
|
|
3160
|
+
summary: buildHistoryReportSummary(params.reportKind, params.payload),
|
|
3161
|
+
analysisId: analysis.id,
|
|
3162
|
+
},
|
|
3163
|
+
});
|
|
3164
|
+
|
|
3165
|
+
process.stdout.write(`Published ${params.reportKind} history report to board ${params.boardId}\n`);
|
|
3166
|
+
return {
|
|
3167
|
+
type: "history_report",
|
|
3168
|
+
reportKind: params.reportKind,
|
|
3169
|
+
analysisId: analysis.id,
|
|
3170
|
+
artifactId: artifact.id,
|
|
3171
|
+
href: `/report/${artifact.id}`,
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
async function publishStructuredRun(params) {
|
|
3176
|
+
if (!hasPublishEnv()) {
|
|
3177
|
+
fail("Publishing requires NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.");
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
const envelope = params.envelope;
|
|
3181
|
+
if (!isPlainObject(envelope) || envelope.type !== "repolens-run") {
|
|
3182
|
+
fail("Publish input must be a RepoLens structured run envelope.");
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
if (!isPlainObject(envelope.result)) {
|
|
3186
|
+
fail("Publish input is missing a structured result payload.");
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
const publishSchema = params.publishKind === "change-brief" ? CHANGE_BRIEF_SCHEMA : EXPLAINER_SCHEMA;
|
|
3190
|
+
validateStructuredOutput(publishSchema, envelope.result);
|
|
3191
|
+
|
|
3192
|
+
const Supabase = await import("@supabase/supabase-js");
|
|
3193
|
+
const supabase = Supabase.createClient(
|
|
3194
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
|
3195
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
3196
|
+
{
|
|
3197
|
+
auth: {
|
|
3198
|
+
autoRefreshToken: false,
|
|
3199
|
+
persistSession: false,
|
|
3200
|
+
},
|
|
3201
|
+
}
|
|
3202
|
+
);
|
|
3203
|
+
|
|
3204
|
+
const { data: board, error: boardError } = await supabase
|
|
3205
|
+
.from("boards")
|
|
3206
|
+
.select("id, owner_id, repo_url, repo_branch")
|
|
3207
|
+
.eq("id", params.boardId)
|
|
3208
|
+
.maybeSingle();
|
|
3209
|
+
|
|
3210
|
+
if (boardError || !board) {
|
|
3211
|
+
throw new Error(boardError?.message ?? `Board not found: ${params.boardId}`);
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
const triggeredBy = params.userId ?? board.owner_id;
|
|
3215
|
+
if (!triggeredBy) {
|
|
3216
|
+
throw new Error("Could not determine which user should own the published artifact.");
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
const compare = isPlainObject(envelope.compare) ? envelope.compare : null;
|
|
3220
|
+
const now = new Date().toISOString();
|
|
3221
|
+
const analysisInsert = {
|
|
3222
|
+
board_id: params.boardId,
|
|
3223
|
+
triggered_by: triggeredBy,
|
|
3224
|
+
status: "complete",
|
|
3225
|
+
trigger_type: "manual",
|
|
3226
|
+
commit_sha: typeof compare?.head === "string" ? compare.head : null,
|
|
3227
|
+
base_ref: typeof compare?.base === "string" ? compare.base : null,
|
|
3228
|
+
head_ref: typeof compare?.head === "string" ? compare.head : board.repo_branch ?? null,
|
|
3229
|
+
stage_1_result: null,
|
|
3230
|
+
stage_2_config: {
|
|
3231
|
+
source: "repolens_cli",
|
|
3232
|
+
mode: envelope.mode ?? null,
|
|
3233
|
+
agent: envelope.agent ?? null,
|
|
3234
|
+
repo_root: envelope.repoRoot ?? null,
|
|
3235
|
+
task: envelope.task ?? null,
|
|
3236
|
+
packet_path: envelope.packetPath ?? null,
|
|
3237
|
+
generated_at: envelope.generatedAt ?? null,
|
|
3238
|
+
},
|
|
3239
|
+
file_count: null,
|
|
3240
|
+
loc_count: null,
|
|
3241
|
+
duration_ms: null,
|
|
3242
|
+
cost_usd: null,
|
|
3243
|
+
completed_at: now,
|
|
3244
|
+
};
|
|
3245
|
+
|
|
3246
|
+
const { data: analysis, error: analysisError } = await supabase
|
|
3247
|
+
.from("analyses")
|
|
3248
|
+
.insert(analysisInsert)
|
|
3249
|
+
.select("id")
|
|
3250
|
+
.single();
|
|
3251
|
+
|
|
3252
|
+
if (analysisError || !analysis) {
|
|
3253
|
+
throw new Error(analysisError?.message ?? "Failed to create analysis row for published artifact.");
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
const result = envelope.result;
|
|
3257
|
+
const evidenceRows = buildEvidenceRows(params.boardId, result.evidence, compare?.head ?? null);
|
|
3258
|
+
let evidenceIds = [];
|
|
3259
|
+
if (evidenceRows.length > 0) {
|
|
3260
|
+
const { data: insertedEvidence, error: evidenceError } = await supabase
|
|
3261
|
+
.from("evidence_cards")
|
|
3262
|
+
.insert(evidenceRows)
|
|
3263
|
+
.select("id");
|
|
3264
|
+
|
|
3265
|
+
if (evidenceError) {
|
|
3266
|
+
throw new Error(evidenceError.message);
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
evidenceIds = (insertedEvidence ?? []).map((item) => item.id);
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
const diagramRows = buildDiagramRows({
|
|
3273
|
+
analysisId: analysis.id,
|
|
3274
|
+
boardId: params.boardId,
|
|
3275
|
+
audience: params.audience,
|
|
3276
|
+
diagrams: Array.isArray(result.diagrams) ? result.diagrams : [],
|
|
3277
|
+
publishKind: params.publishKind,
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
let diagramIds = [];
|
|
3281
|
+
if (diagramRows.length > 0) {
|
|
3282
|
+
const { data: insertedDiagrams, error: diagramError } = await supabase
|
|
3283
|
+
.from("artifacts")
|
|
3284
|
+
.insert(diagramRows)
|
|
3285
|
+
.select("id");
|
|
3286
|
+
|
|
3287
|
+
if (diagramError) {
|
|
3288
|
+
throw new Error(diagramError.message);
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
diagramIds = (insertedDiagrams ?? []).map((item) => item.id);
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
if (params.publishKind === "change-brief") {
|
|
3295
|
+
const artifactContent = exportChangeBriefToMarkdown(result);
|
|
3296
|
+
const { data: mainArtifact, error: mainArtifactError } = await supabase
|
|
3297
|
+
.from("artifacts")
|
|
3298
|
+
.insert({
|
|
3299
|
+
analysis_id: analysis.id,
|
|
3300
|
+
board_id: params.boardId,
|
|
3301
|
+
type: "change_brief",
|
|
3302
|
+
perspective: "change_brief",
|
|
3303
|
+
audience: params.audience,
|
|
3304
|
+
title: "Change Brief",
|
|
3305
|
+
content: artifactContent,
|
|
3306
|
+
content_format: "markdown",
|
|
3307
|
+
confidence_score: 0.72,
|
|
3308
|
+
confidence_reason: "local_agent_structured_output",
|
|
3309
|
+
is_inferred: true,
|
|
3310
|
+
related_files: collectChangeBriefRelatedFiles(result),
|
|
3311
|
+
})
|
|
3312
|
+
.select("id")
|
|
3313
|
+
.single();
|
|
3314
|
+
|
|
3315
|
+
if (mainArtifactError || !mainArtifact) {
|
|
3316
|
+
throw new Error(mainArtifactError?.message ?? "Failed to persist change brief artifact.");
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
const { data: brief, error: briefError } = await supabase
|
|
3320
|
+
.from("change_briefs")
|
|
3321
|
+
.insert({
|
|
3322
|
+
board_id: params.boardId,
|
|
3323
|
+
analysis_id: analysis.id,
|
|
3324
|
+
base_ref: typeof compare?.base === "string" ? compare.base : "local",
|
|
3325
|
+
head_ref: typeof compare?.head === "string" ? compare.head : board.repo_branch ?? "HEAD",
|
|
3326
|
+
audience: params.audience,
|
|
3327
|
+
summary: stringOrFallback(result.summary, "Generated change brief"),
|
|
3328
|
+
impacted_features: Array.isArray(result.impactedFeatures) ? result.impactedFeatures : [],
|
|
3329
|
+
impacted_files: Array.isArray(result.impactedFiles) ? result.impactedFiles : [],
|
|
3330
|
+
impacted_screens: Array.isArray(result.impactedScreens) ? result.impactedScreens : [],
|
|
3331
|
+
rollout_risks: nullableString(result.rolloutRisks),
|
|
3332
|
+
qa_checklist: Array.isArray(result.qaChecklist) ? result.qaChecklist.join("\n") : null,
|
|
3333
|
+
communication_brief: nullableString(result.communicationBrief),
|
|
3334
|
+
diagram_ids: diagramIds,
|
|
3335
|
+
evidence_ids: evidenceIds,
|
|
3336
|
+
confidence_level: "medium",
|
|
3337
|
+
})
|
|
3338
|
+
.select("id")
|
|
3339
|
+
.single();
|
|
3340
|
+
|
|
3341
|
+
if (briefError || !brief) {
|
|
3342
|
+
throw new Error(briefError?.message ?? "Failed to persist change brief.");
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
await finalizePublish({
|
|
3346
|
+
supabase,
|
|
3347
|
+
boardId: params.boardId,
|
|
3348
|
+
userId: triggeredBy,
|
|
3349
|
+
action: "change_brief.published_from_cli",
|
|
3350
|
+
targetType: "change_brief",
|
|
3351
|
+
targetId: brief.id,
|
|
3352
|
+
metadata: {
|
|
3353
|
+
mode: envelope.mode ?? null,
|
|
3354
|
+
agent: envelope.agent ?? null,
|
|
3355
|
+
mainArtifactId: mainArtifact.id,
|
|
3356
|
+
},
|
|
3357
|
+
});
|
|
3358
|
+
|
|
3359
|
+
return {
|
|
3360
|
+
type: "change-brief",
|
|
3361
|
+
boardId: params.boardId,
|
|
3362
|
+
analysisId: analysis.id,
|
|
3363
|
+
briefId: brief.id,
|
|
3364
|
+
artifactId: mainArtifact.id,
|
|
3365
|
+
href: `/brief/${brief.id}`,
|
|
3366
|
+
};
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
const explainerType = params.publishKind === "onboarding" ? "onboarding" : "feature";
|
|
3370
|
+
const artifactContent = exportExplainerToMarkdown(result);
|
|
3371
|
+
const { data: mainArtifact, error: mainArtifactError } = await supabase
|
|
3372
|
+
.from("artifacts")
|
|
3373
|
+
.insert({
|
|
3374
|
+
analysis_id: analysis.id,
|
|
3375
|
+
board_id: params.boardId,
|
|
3376
|
+
type: "explainer",
|
|
3377
|
+
perspective: explainerType,
|
|
3378
|
+
audience: params.audience,
|
|
3379
|
+
title: stringOrFallback(result.title, "Generated Explainer"),
|
|
3380
|
+
content: artifactContent,
|
|
3381
|
+
content_format: "markdown",
|
|
3382
|
+
confidence_score: 0.72,
|
|
3383
|
+
confidence_reason: "local_agent_structured_output",
|
|
3384
|
+
is_inferred: true,
|
|
3385
|
+
related_files: collectExplainerRelatedFiles(result),
|
|
3386
|
+
})
|
|
3387
|
+
.select("id")
|
|
3388
|
+
.single();
|
|
3389
|
+
|
|
3390
|
+
if (mainArtifactError || !mainArtifact) {
|
|
3391
|
+
throw new Error(mainArtifactError?.message ?? "Failed to persist explainer artifact.");
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
const { data: explainer, error: explainerError } = await supabase
|
|
3395
|
+
.from("explainers")
|
|
3396
|
+
.insert({
|
|
3397
|
+
board_id: params.boardId,
|
|
3398
|
+
analysis_id: analysis.id,
|
|
3399
|
+
type: explainerType,
|
|
3400
|
+
audience: params.audience,
|
|
3401
|
+
title: stringOrFallback(result.title, "Generated Explainer"),
|
|
3402
|
+
summary: stringOrFallback(result.summary, "Generated by RepoLens CLI"),
|
|
3403
|
+
value_proposition: nullableString(result.valueProposition),
|
|
3404
|
+
user_journey: nullableString(result.userJourney),
|
|
3405
|
+
key_components: Array.isArray(result.keyComponents) ? result.keyComponents : [],
|
|
3406
|
+
business_logic: nullableString(result.businessLogic),
|
|
3407
|
+
agent_logic: null,
|
|
3408
|
+
risks_and_questions: joinSections([
|
|
3409
|
+
nullableString(result.risksAndQuestions),
|
|
3410
|
+
...readStringArray(result.openQuestions),
|
|
3411
|
+
]),
|
|
3412
|
+
diagram_ids: diagramIds,
|
|
3413
|
+
evidence_ids: evidenceIds,
|
|
3414
|
+
confidence_level: "medium",
|
|
3415
|
+
})
|
|
3416
|
+
.select("id")
|
|
3417
|
+
.single();
|
|
3418
|
+
|
|
3419
|
+
if (explainerError || !explainer) {
|
|
3420
|
+
throw new Error(explainerError?.message ?? "Failed to persist explainer.");
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
await finalizePublish({
|
|
3424
|
+
supabase,
|
|
3425
|
+
boardId: params.boardId,
|
|
3426
|
+
userId: triggeredBy,
|
|
3427
|
+
action: explainerType === "onboarding" ? "onboarding.published_from_cli" : "explainer.published_from_cli",
|
|
3428
|
+
targetType: explainerType,
|
|
3429
|
+
targetId: explainer.id,
|
|
3430
|
+
metadata: {
|
|
3431
|
+
mode: envelope.mode ?? null,
|
|
3432
|
+
agent: envelope.agent ?? null,
|
|
3433
|
+
mainArtifactId: mainArtifact.id,
|
|
3434
|
+
},
|
|
3435
|
+
});
|
|
3436
|
+
|
|
3437
|
+
return {
|
|
3438
|
+
type: explainerType,
|
|
3439
|
+
boardId: params.boardId,
|
|
3440
|
+
analysisId: analysis.id,
|
|
3441
|
+
explainerId: explainer.id,
|
|
3442
|
+
artifactId: mainArtifact.id,
|
|
3443
|
+
href: explainerType === "onboarding" ? `/onboarding/${explainer.id}` : `/explainer/${explainer.id}`,
|
|
3444
|
+
};
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
async function finalizePublish(params) {
|
|
3448
|
+
await params.supabase
|
|
3449
|
+
.from("boards")
|
|
3450
|
+
.update({ last_analyzed_at: new Date().toISOString() })
|
|
3451
|
+
.eq("id", params.boardId);
|
|
3452
|
+
|
|
3453
|
+
await params.supabase.from("audit_logs").insert({
|
|
3454
|
+
board_id: params.boardId,
|
|
3455
|
+
user_id: params.userId,
|
|
3456
|
+
action: params.action,
|
|
3457
|
+
target_type: params.targetType,
|
|
3458
|
+
target_id: params.targetId,
|
|
3459
|
+
metadata: params.metadata,
|
|
3460
|
+
});
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
function readPublishTitle(kind, result) {
|
|
3464
|
+
if (kind === "change-brief") {
|
|
3465
|
+
return stringOrFallback(result.summary, "Change Brief");
|
|
3466
|
+
}
|
|
3467
|
+
return stringOrFallback(result.title, "Generated Explainer");
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
function extractLocalHistoryAnalysisMetadata(reportKind, payload) {
|
|
3471
|
+
switch (reportKind) {
|
|
3472
|
+
case "evolution":
|
|
3473
|
+
case "ownership":
|
|
3474
|
+
case "start-here":
|
|
3475
|
+
return {
|
|
3476
|
+
baseRef: typeof payload.sinceRef === "string" ? payload.sinceRef : null,
|
|
3477
|
+
headRef: typeof payload.branch === "string" ? payload.branch : null,
|
|
3478
|
+
task: `${reportKind}:${typeof payload.targetPath === "string" ? payload.targetPath : "unknown"}`,
|
|
3479
|
+
};
|
|
3480
|
+
case "hotspots":
|
|
3481
|
+
return {
|
|
3482
|
+
baseRef: typeof payload.sinceRef === "string" ? payload.sinceRef : null,
|
|
3483
|
+
headRef: typeof payload.branch === "string" ? payload.branch : null,
|
|
3484
|
+
task: "hotspots",
|
|
3485
|
+
};
|
|
3486
|
+
case "trace":
|
|
3487
|
+
return {
|
|
3488
|
+
baseRef: typeof payload.sinceRef === "string" ? payload.sinceRef : null,
|
|
3489
|
+
headRef: typeof payload.branch === "string" ? payload.branch : null,
|
|
3490
|
+
task: `trace:${typeof payload.query === "string" ? payload.query : "unknown"}`,
|
|
3491
|
+
};
|
|
3492
|
+
default:
|
|
3493
|
+
return { baseRef: null, headRef: null, task: "history-report" };
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
function readPublishSummary(kind, result) {
|
|
3498
|
+
if (kind === "change-brief") {
|
|
3499
|
+
return stringOrFallback(result.summary, "");
|
|
3500
|
+
}
|
|
3501
|
+
return stringOrFallback(result.summary, "");
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
function joinSections(sections) {
|
|
3505
|
+
return sections.map(nullableString).filter(Boolean).join("\n\n");
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
function readStringArray(value) {
|
|
3509
|
+
return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
function stringOrFallback(value, fallback) {
|
|
3513
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
function nullableString(value) {
|
|
3517
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
3518
|
+
}
|