markupr 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -557
- package/dist/cli/index.mjs +2118 -154
- package/dist/main/index.mjs +24 -13
- package/dist/mcp/index.mjs +1612 -69
- package/package.json +27 -8
package/dist/cli/index.mjs
CHANGED
|
@@ -1,14 +1,724 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
2
4
|
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
5
|
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
6
|
}) : x)(function(x) {
|
|
5
7
|
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
8
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
9
|
});
|
|
10
|
+
var __esm = (fn, res) => function __init() {
|
|
11
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
12
|
+
};
|
|
13
|
+
var __export = (target, all) => {
|
|
14
|
+
for (var name in all)
|
|
15
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/integrations/linear/types.ts
|
|
19
|
+
var SEVERITY_TO_PRIORITY, CATEGORY_TO_LABEL;
|
|
20
|
+
var init_types = __esm({
|
|
21
|
+
"src/integrations/linear/types.ts"() {
|
|
22
|
+
"use strict";
|
|
23
|
+
SEVERITY_TO_PRIORITY = {
|
|
24
|
+
Critical: 1,
|
|
25
|
+
High: 2,
|
|
26
|
+
Medium: 3,
|
|
27
|
+
Low: 4
|
|
28
|
+
};
|
|
29
|
+
CATEGORY_TO_LABEL = {
|
|
30
|
+
"Bug": "Bug",
|
|
31
|
+
"UX Issue": "Improvement",
|
|
32
|
+
"Suggestion": "Feature",
|
|
33
|
+
"Performance": "Bug",
|
|
34
|
+
"Question": "Feature",
|
|
35
|
+
"General": "Feature"
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// src/integrations/linear/LinearIssueCreator.ts
|
|
41
|
+
var LinearIssueCreator_exports = {};
|
|
42
|
+
__export(LinearIssueCreator_exports, {
|
|
43
|
+
LinearIssueCreator: () => LinearIssueCreator,
|
|
44
|
+
parseMarkdownReport: () => parseMarkdownReport
|
|
45
|
+
});
|
|
46
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
47
|
+
function parseMarkdownReport(markdown) {
|
|
48
|
+
const items = [];
|
|
49
|
+
const itemPattern = /^### (FB-\d+): (.+)$/gm;
|
|
50
|
+
const matches = [];
|
|
51
|
+
let match;
|
|
52
|
+
while ((match = itemPattern.exec(markdown)) !== null) {
|
|
53
|
+
matches.push({ index: match.index, id: match[1], title: match[2] });
|
|
54
|
+
}
|
|
55
|
+
for (let i = 0; i < matches.length; i++) {
|
|
56
|
+
const start = matches[i].index;
|
|
57
|
+
const end = i + 1 < matches.length ? matches[i + 1].index : markdown.length;
|
|
58
|
+
const section = markdown.slice(start, end);
|
|
59
|
+
const severity = extractField(section, "Severity") || "Medium";
|
|
60
|
+
const category = extractField(section, "Type") || "General";
|
|
61
|
+
const timestamp = extractField(section, "Timestamp") || "00:00";
|
|
62
|
+
const description = extractBlockquote(section);
|
|
63
|
+
const screenshotPaths = extractScreenshots(section);
|
|
64
|
+
const suggestedAction = extractSuggestedAction(section);
|
|
65
|
+
items.push({
|
|
66
|
+
id: matches[i].id,
|
|
67
|
+
title: matches[i].title,
|
|
68
|
+
severity,
|
|
69
|
+
category,
|
|
70
|
+
timestamp,
|
|
71
|
+
description,
|
|
72
|
+
screenshotPaths,
|
|
73
|
+
suggestedAction
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return items;
|
|
77
|
+
}
|
|
78
|
+
function extractField(section, fieldName) {
|
|
79
|
+
const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, "m");
|
|
80
|
+
const match = section.match(pattern);
|
|
81
|
+
return match ? match[1].trim() : "";
|
|
82
|
+
}
|
|
83
|
+
function extractBlockquote(section) {
|
|
84
|
+
const whatHappened = section.match(/#### What Happened\s*\n([\s\S]*?)(?=\n####|\n---)/);
|
|
85
|
+
if (!whatHappened) return "";
|
|
86
|
+
const lines = whatHappened[1].split("\n").filter((line) => line.startsWith(">")).map((line) => line.replace(/^>\s*/, "").trim());
|
|
87
|
+
return lines.join(" ").trim();
|
|
88
|
+
}
|
|
89
|
+
function extractScreenshots(section) {
|
|
90
|
+
const paths = [];
|
|
91
|
+
const pattern = /!\[.*?\]\((.+?)\)/g;
|
|
92
|
+
let match;
|
|
93
|
+
while ((match = pattern.exec(section)) !== null) {
|
|
94
|
+
paths.push(match[1]);
|
|
95
|
+
}
|
|
96
|
+
return paths;
|
|
97
|
+
}
|
|
98
|
+
function extractSuggestedAction(section) {
|
|
99
|
+
const actionSection = section.match(/#### Suggested Next Step\s*\n-\s*(.+)/);
|
|
100
|
+
return actionSection ? actionSection[1].trim() : "";
|
|
101
|
+
}
|
|
102
|
+
var LINEAR_API_URL, LinearIssueCreator;
|
|
103
|
+
var init_LinearIssueCreator = __esm({
|
|
104
|
+
"src/integrations/linear/LinearIssueCreator.ts"() {
|
|
105
|
+
"use strict";
|
|
106
|
+
init_types();
|
|
107
|
+
LINEAR_API_URL = "https://api.linear.app/graphql";
|
|
108
|
+
LinearIssueCreator = class {
|
|
109
|
+
token;
|
|
110
|
+
constructor(token) {
|
|
111
|
+
this.token = token;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Push a markupr report to Linear, creating one issue per feedback item.
|
|
115
|
+
*/
|
|
116
|
+
async pushReport(reportPath, options) {
|
|
117
|
+
const markdown = await readFile3(reportPath, "utf-8");
|
|
118
|
+
const items = parseMarkdownReport(markdown);
|
|
119
|
+
const team = await this.resolveTeam(options.teamKey);
|
|
120
|
+
const labels = await this.getTeamLabels(team.id);
|
|
121
|
+
const result = {
|
|
122
|
+
teamKey: options.teamKey,
|
|
123
|
+
totalItems: items.length,
|
|
124
|
+
created: 0,
|
|
125
|
+
failed: 0,
|
|
126
|
+
issues: [],
|
|
127
|
+
dryRun: options.dryRun ?? false
|
|
128
|
+
};
|
|
129
|
+
for (const item of items) {
|
|
130
|
+
const labelName = CATEGORY_TO_LABEL[item.category] ?? "Feature";
|
|
131
|
+
const matchingLabel = labels.find(
|
|
132
|
+
(l) => l.name.toLowerCase() === labelName.toLowerCase()
|
|
133
|
+
);
|
|
134
|
+
const issueInput = {
|
|
135
|
+
title: `[${item.id}] ${item.title}`,
|
|
136
|
+
description: this.buildIssueDescription(item),
|
|
137
|
+
teamId: team.id,
|
|
138
|
+
priority: SEVERITY_TO_PRIORITY[item.severity] ?? 3,
|
|
139
|
+
labelIds: matchingLabel ? [matchingLabel.id] : void 0,
|
|
140
|
+
projectId: options.projectName ? await this.resolveProjectId(team.id, options.projectName) : void 0
|
|
141
|
+
};
|
|
142
|
+
if (options.dryRun) {
|
|
143
|
+
result.issues.push({
|
|
144
|
+
success: true,
|
|
145
|
+
issueId: `dry-run-${item.id}`,
|
|
146
|
+
identifier: `DRY-${item.id}`,
|
|
147
|
+
issueUrl: `https://linear.app/dry-run/${item.id}`
|
|
148
|
+
});
|
|
149
|
+
result.created++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const issueResult = await this.createIssue(issueInput);
|
|
153
|
+
result.issues.push(issueResult);
|
|
154
|
+
if (issueResult.success) {
|
|
155
|
+
result.created++;
|
|
156
|
+
} else {
|
|
157
|
+
result.failed++;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Create a single Linear issue via GraphQL.
|
|
164
|
+
*/
|
|
165
|
+
async createIssue(input) {
|
|
166
|
+
const mutation = `
|
|
167
|
+
mutation IssueCreate($input: IssueCreateInput!) {
|
|
168
|
+
issueCreate(input: $input) {
|
|
169
|
+
success
|
|
170
|
+
issue {
|
|
171
|
+
id
|
|
172
|
+
url
|
|
173
|
+
identifier
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
`;
|
|
178
|
+
const variables = {
|
|
179
|
+
input: {
|
|
180
|
+
title: input.title,
|
|
181
|
+
description: input.description,
|
|
182
|
+
teamId: input.teamId,
|
|
183
|
+
priority: input.priority,
|
|
184
|
+
...input.labelIds && { labelIds: input.labelIds },
|
|
185
|
+
...input.projectId && { projectId: input.projectId }
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
try {
|
|
189
|
+
const data = await this.graphql(mutation, variables);
|
|
190
|
+
if (data.issueCreate.success) {
|
|
191
|
+
return {
|
|
192
|
+
success: true,
|
|
193
|
+
issueId: data.issueCreate.issue.id,
|
|
194
|
+
issueUrl: data.issueCreate.issue.url,
|
|
195
|
+
identifier: data.issueCreate.issue.identifier
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { success: false, error: "Linear API returned success: false" };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return {
|
|
201
|
+
success: false,
|
|
202
|
+
error: error instanceof Error ? error.message : String(error)
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Resolve a team key (e.g., "ENG") to a team ID.
|
|
208
|
+
*/
|
|
209
|
+
async resolveTeam(teamKey) {
|
|
210
|
+
const query = `
|
|
211
|
+
query Teams {
|
|
212
|
+
teams {
|
|
213
|
+
nodes {
|
|
214
|
+
id
|
|
215
|
+
key
|
|
216
|
+
name
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
`;
|
|
221
|
+
const data = await this.graphql(query);
|
|
222
|
+
const team = data.teams.nodes.find(
|
|
223
|
+
(t) => t.key.toLowerCase() === teamKey.toLowerCase()
|
|
224
|
+
);
|
|
225
|
+
if (!team) {
|
|
226
|
+
const available = data.teams.nodes.map((t) => t.key).join(", ");
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Team "${teamKey}" not found. Available teams: ${available}`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return team;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Get all labels for a team.
|
|
235
|
+
*/
|
|
236
|
+
async getTeamLabels(teamId) {
|
|
237
|
+
const query = `
|
|
238
|
+
query TeamLabels($teamId: String!) {
|
|
239
|
+
team(id: $teamId) {
|
|
240
|
+
labels {
|
|
241
|
+
nodes {
|
|
242
|
+
id
|
|
243
|
+
name
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
`;
|
|
249
|
+
const data = await this.graphql(query, { teamId });
|
|
250
|
+
return data.team.labels.nodes;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Resolve a project name to a project ID within a team.
|
|
254
|
+
*/
|
|
255
|
+
async resolveProjectId(teamId, projectName) {
|
|
256
|
+
const query = `
|
|
257
|
+
query Projects($teamId: String!) {
|
|
258
|
+
team(id: $teamId) {
|
|
259
|
+
projects {
|
|
260
|
+
nodes {
|
|
261
|
+
id
|
|
262
|
+
name
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
`;
|
|
268
|
+
const data = await this.graphql(query, { teamId });
|
|
269
|
+
const project = data.team.projects.nodes.find(
|
|
270
|
+
(p) => p.name.toLowerCase() === projectName.toLowerCase()
|
|
271
|
+
);
|
|
272
|
+
return project?.id;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Build markdown description for a Linear issue from a feedback item.
|
|
276
|
+
*/
|
|
277
|
+
buildIssueDescription(item) {
|
|
278
|
+
let desc = `## markupr Feedback: ${item.id}
|
|
279
|
+
|
|
280
|
+
`;
|
|
281
|
+
desc += `**Severity:** ${item.severity}
|
|
282
|
+
`;
|
|
283
|
+
desc += `**Category:** ${item.category}
|
|
284
|
+
`;
|
|
285
|
+
desc += `**Timestamp:** ${item.timestamp}
|
|
286
|
+
|
|
287
|
+
`;
|
|
288
|
+
desc += `### Description
|
|
289
|
+
|
|
290
|
+
${item.description}
|
|
291
|
+
|
|
292
|
+
`;
|
|
293
|
+
if (item.suggestedAction) {
|
|
294
|
+
desc += `### Suggested Action
|
|
295
|
+
|
|
296
|
+
${item.suggestedAction}
|
|
297
|
+
|
|
298
|
+
`;
|
|
299
|
+
}
|
|
300
|
+
if (item.screenshotPaths.length > 0) {
|
|
301
|
+
desc += `### Screenshots
|
|
302
|
+
|
|
303
|
+
`;
|
|
304
|
+
desc += `_${item.screenshotPaths.length} screenshot(s) captured during session._
|
|
305
|
+
`;
|
|
306
|
+
for (const path3 of item.screenshotPaths) {
|
|
307
|
+
desc += `- \`${path3}\`
|
|
308
|
+
`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
desc += `
|
|
312
|
+
---
|
|
313
|
+
*Created by [markupr](https://markupr.com)*`;
|
|
314
|
+
return desc;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Execute a GraphQL request against the Linear API.
|
|
318
|
+
*/
|
|
319
|
+
async graphql(query, variables) {
|
|
320
|
+
const response = await fetch(LINEAR_API_URL, {
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: {
|
|
323
|
+
"Content-Type": "application/json",
|
|
324
|
+
Authorization: this.token
|
|
325
|
+
},
|
|
326
|
+
body: JSON.stringify({ query, variables })
|
|
327
|
+
});
|
|
328
|
+
if (!response.ok) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`Linear API error: ${response.status} ${response.statusText}`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const json = await response.json();
|
|
334
|
+
if (json.errors && json.errors.length > 0) {
|
|
335
|
+
throw new Error(`Linear GraphQL error: ${json.errors[0].message}`);
|
|
336
|
+
}
|
|
337
|
+
if (!json.data) {
|
|
338
|
+
throw new Error("Linear API returned no data");
|
|
339
|
+
}
|
|
340
|
+
return json.data;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// src/integrations/github/types.ts
|
|
347
|
+
var CATEGORY_LABELS, SEVERITY_LABELS, MARKUPR_LABEL;
|
|
348
|
+
var init_types2 = __esm({
|
|
349
|
+
"src/integrations/github/types.ts"() {
|
|
350
|
+
"use strict";
|
|
351
|
+
CATEGORY_LABELS = {
|
|
352
|
+
Bug: { name: "bug", color: "d73a4a", description: "Something isn't working" },
|
|
353
|
+
"UX Issue": { name: "ux", color: "e4e669", description: "User experience issue" },
|
|
354
|
+
Suggestion: { name: "enhancement", color: "a2eeef", description: "New feature or request" },
|
|
355
|
+
Performance: { name: "performance", color: "f9d0c4", description: "Performance issue" },
|
|
356
|
+
Question: { name: "question", color: "d876e3", description: "Further information is requested" },
|
|
357
|
+
General: { name: "feedback", color: "c5def5", description: "General feedback" }
|
|
358
|
+
};
|
|
359
|
+
SEVERITY_LABELS = {
|
|
360
|
+
Critical: { name: "priority: critical", color: "b60205", description: "Critical priority" },
|
|
361
|
+
High: { name: "priority: high", color: "d93f0b", description: "High priority" },
|
|
362
|
+
Medium: { name: "priority: medium", color: "fbca04", description: "Medium priority" },
|
|
363
|
+
Low: { name: "priority: low", color: "0e8a16", description: "Low priority" }
|
|
364
|
+
};
|
|
365
|
+
MARKUPR_LABEL = {
|
|
366
|
+
name: "markupr",
|
|
367
|
+
color: "6f42c1",
|
|
368
|
+
description: "Created from markupr feedback session"
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// src/integrations/github/GitHubIssueCreator.ts
|
|
374
|
+
var GitHubIssueCreator_exports = {};
|
|
375
|
+
__export(GitHubIssueCreator_exports, {
|
|
376
|
+
GitHubAPIClient: () => GitHubAPIClient,
|
|
377
|
+
collectRequiredLabels: () => collectRequiredLabels,
|
|
378
|
+
formatIssueBody: () => formatIssueBody,
|
|
379
|
+
getLabelsForItem: () => getLabelsForItem,
|
|
380
|
+
parseMarkuprReport: () => parseMarkuprReport,
|
|
381
|
+
parseRepoString: () => parseRepoString,
|
|
382
|
+
pushToGitHub: () => pushToGitHub,
|
|
383
|
+
resolveAuth: () => resolveAuth
|
|
384
|
+
});
|
|
385
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
386
|
+
async function resolveAuth(explicitToken) {
|
|
387
|
+
if (explicitToken) {
|
|
388
|
+
return { token: explicitToken, source: "flag" };
|
|
389
|
+
}
|
|
390
|
+
const envToken = process.env.GITHUB_TOKEN;
|
|
391
|
+
if (envToken) {
|
|
392
|
+
return { token: envToken, source: "env" };
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
const { execSync } = await import("child_process");
|
|
396
|
+
const ghToken = execSync("gh auth token", { encoding: "utf-8", timeout: 5e3 }).trim();
|
|
397
|
+
if (ghToken) {
|
|
398
|
+
return { token: ghToken, source: "gh-cli" };
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
}
|
|
402
|
+
throw new Error(
|
|
403
|
+
"No GitHub token found. Provide one via:\n --token <token>\n GITHUB_TOKEN environment variable\n gh auth login (GitHub CLI)"
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
function parseMarkuprReport(markdown) {
|
|
407
|
+
const items = [];
|
|
408
|
+
const itemPattern = /### (FB-\d{3}): (.+?)(?=\n)/g;
|
|
409
|
+
let match;
|
|
410
|
+
while ((match = itemPattern.exec(markdown)) !== null) {
|
|
411
|
+
const id = match[1];
|
|
412
|
+
const title = match[2].trim();
|
|
413
|
+
const startIndex = match.index;
|
|
414
|
+
const rest = markdown.slice(startIndex + match[0].length);
|
|
415
|
+
const nextSectionMatch = rest.match(/\n### FB-\d{3}:|(?=\n## [A-Z])/);
|
|
416
|
+
const itemBlock = nextSectionMatch ? rest.slice(0, nextSectionMatch.index) : rest;
|
|
417
|
+
const severity = extractField2(itemBlock, "Severity") || "Medium";
|
|
418
|
+
const category = extractField2(itemBlock, "Type") || "General";
|
|
419
|
+
const timestamp = extractField2(itemBlock, "Timestamp") || "00:00";
|
|
420
|
+
const transcription = extractTranscription(itemBlock);
|
|
421
|
+
const screenshotPaths = extractScreenshots2(itemBlock);
|
|
422
|
+
const suggestedAction = extractSuggestedAction2(itemBlock);
|
|
423
|
+
items.push({
|
|
424
|
+
id,
|
|
425
|
+
title,
|
|
426
|
+
category,
|
|
427
|
+
severity,
|
|
428
|
+
timestamp,
|
|
429
|
+
transcription,
|
|
430
|
+
screenshotPaths,
|
|
431
|
+
suggestedAction
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return items;
|
|
435
|
+
}
|
|
436
|
+
function extractField2(block, fieldName) {
|
|
437
|
+
const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`);
|
|
438
|
+
const match = block.match(pattern);
|
|
439
|
+
return match ? match[1].trim() : void 0;
|
|
440
|
+
}
|
|
441
|
+
function extractTranscription(block) {
|
|
442
|
+
const whatHappenedIdx = block.indexOf("#### What Happened");
|
|
443
|
+
if (whatHappenedIdx === -1) return "";
|
|
444
|
+
const afterHeading = block.slice(whatHappenedIdx);
|
|
445
|
+
const nextHeading = afterHeading.indexOf("\n####", 5);
|
|
446
|
+
const section = nextHeading !== -1 ? afterHeading.slice(0, nextHeading) : afterHeading;
|
|
447
|
+
const lines = section.split("\n");
|
|
448
|
+
const quotedLines = [];
|
|
449
|
+
for (const line of lines) {
|
|
450
|
+
const trimmed = line.trim();
|
|
451
|
+
if (trimmed.startsWith("> ")) {
|
|
452
|
+
quotedLines.push(trimmed.slice(2));
|
|
453
|
+
} else if (trimmed === ">") {
|
|
454
|
+
quotedLines.push("");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return quotedLines.join(" ").trim();
|
|
458
|
+
}
|
|
459
|
+
function extractScreenshots2(block) {
|
|
460
|
+
const paths = [];
|
|
461
|
+
const pattern = /!\[.*?\]\((.+?)\)/g;
|
|
462
|
+
let match;
|
|
463
|
+
while ((match = pattern.exec(block)) !== null) {
|
|
464
|
+
paths.push(match[1]);
|
|
465
|
+
}
|
|
466
|
+
return paths;
|
|
467
|
+
}
|
|
468
|
+
function extractSuggestedAction2(block) {
|
|
469
|
+
const idx = block.indexOf("#### Suggested Next Step");
|
|
470
|
+
if (idx === -1) return "";
|
|
471
|
+
const afterHeading = block.slice(idx + "#### Suggested Next Step".length);
|
|
472
|
+
const nextSection = afterHeading.indexOf("\n---");
|
|
473
|
+
const section = nextSection !== -1 ? afterHeading.slice(0, nextSection) : afterHeading;
|
|
474
|
+
const lines = section.split("\n");
|
|
475
|
+
for (const line of lines) {
|
|
476
|
+
const trimmed = line.trim();
|
|
477
|
+
if (trimmed.startsWith("- ")) {
|
|
478
|
+
return trimmed.slice(2);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return "";
|
|
482
|
+
}
|
|
483
|
+
function formatIssueBody(item, reportPath) {
|
|
484
|
+
let body = `## ${item.id}: ${item.title}
|
|
485
|
+
|
|
486
|
+
`;
|
|
487
|
+
body += `| Field | Value |
|
|
488
|
+
|-------|-------|
|
|
489
|
+
`;
|
|
490
|
+
body += `| **Severity** | ${item.severity} |
|
|
491
|
+
`;
|
|
492
|
+
body += `| **Category** | ${item.category} |
|
|
493
|
+
`;
|
|
494
|
+
body += `| **Timestamp** | ${item.timestamp} |
|
|
495
|
+
|
|
496
|
+
`;
|
|
497
|
+
body += `### What Happened
|
|
498
|
+
|
|
499
|
+
`;
|
|
500
|
+
body += `> ${item.transcription}
|
|
501
|
+
|
|
502
|
+
`;
|
|
503
|
+
if (item.screenshotPaths.length > 0) {
|
|
504
|
+
body += `### Screenshots
|
|
505
|
+
|
|
506
|
+
`;
|
|
507
|
+
body += `_${item.screenshotPaths.length} screenshot(s) captured \u2014 see the markupr report for images._
|
|
508
|
+
|
|
509
|
+
`;
|
|
510
|
+
}
|
|
511
|
+
if (item.suggestedAction) {
|
|
512
|
+
body += `### Suggested Action
|
|
513
|
+
|
|
514
|
+
`;
|
|
515
|
+
body += `${item.suggestedAction}
|
|
516
|
+
|
|
517
|
+
`;
|
|
518
|
+
}
|
|
519
|
+
body += `---
|
|
520
|
+
`;
|
|
521
|
+
if (reportPath) {
|
|
522
|
+
body += `_Source: \`${reportPath}\`_
|
|
523
|
+
`;
|
|
524
|
+
}
|
|
525
|
+
body += `_Created by [markupr](https://markupr.com)_
|
|
526
|
+
`;
|
|
527
|
+
return body;
|
|
528
|
+
}
|
|
529
|
+
function getLabelsForItem(item) {
|
|
530
|
+
const labels = [MARKUPR_LABEL.name];
|
|
531
|
+
const categoryLabel = CATEGORY_LABELS[item.category];
|
|
532
|
+
if (categoryLabel) {
|
|
533
|
+
labels.push(categoryLabel.name);
|
|
534
|
+
}
|
|
535
|
+
const severityLabel = SEVERITY_LABELS[item.severity];
|
|
536
|
+
if (severityLabel) {
|
|
537
|
+
labels.push(severityLabel.name);
|
|
538
|
+
}
|
|
539
|
+
return labels;
|
|
540
|
+
}
|
|
541
|
+
function collectRequiredLabels(items) {
|
|
542
|
+
const seen = /* @__PURE__ */ new Set();
|
|
543
|
+
const labels = [];
|
|
544
|
+
seen.add(MARKUPR_LABEL.name);
|
|
545
|
+
labels.push(MARKUPR_LABEL);
|
|
546
|
+
for (const item of items) {
|
|
547
|
+
const catLabel = CATEGORY_LABELS[item.category];
|
|
548
|
+
if (catLabel && !seen.has(catLabel.name)) {
|
|
549
|
+
seen.add(catLabel.name);
|
|
550
|
+
labels.push(catLabel);
|
|
551
|
+
}
|
|
552
|
+
const sevLabel = SEVERITY_LABELS[item.severity];
|
|
553
|
+
if (sevLabel && !seen.has(sevLabel.name)) {
|
|
554
|
+
seen.add(sevLabel.name);
|
|
555
|
+
labels.push(sevLabel);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return labels;
|
|
559
|
+
}
|
|
560
|
+
async function pushToGitHub(options) {
|
|
561
|
+
const { repo, auth, reportPath, dryRun = false, items: filterIds } = options;
|
|
562
|
+
const markdown = await readFile4(reportPath, "utf-8");
|
|
563
|
+
let items = parseMarkuprReport(markdown);
|
|
564
|
+
if (items.length === 0) {
|
|
565
|
+
throw new Error("No feedback items found in the report. Is this a valid markupr report?");
|
|
566
|
+
}
|
|
567
|
+
if (filterIds && filterIds.length > 0) {
|
|
568
|
+
const filterSet = new Set(filterIds.map((id) => id.toUpperCase()));
|
|
569
|
+
items = items.filter((item) => filterSet.has(item.id));
|
|
570
|
+
if (items.length === 0) {
|
|
571
|
+
throw new Error(`None of the specified items (${filterIds.join(", ")}) found in the report`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const result = {
|
|
575
|
+
created: [],
|
|
576
|
+
labelsCreated: [],
|
|
577
|
+
errors: [],
|
|
578
|
+
dryRun
|
|
579
|
+
};
|
|
580
|
+
if (dryRun) {
|
|
581
|
+
for (const item of items) {
|
|
582
|
+
const labels = getLabelsForItem(item);
|
|
583
|
+
result.created.push({
|
|
584
|
+
number: 0,
|
|
585
|
+
url: "",
|
|
586
|
+
title: `[${item.id}] ${item.title}`
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
result.labelsCreated = collectRequiredLabels(items).map((l) => l.name);
|
|
590
|
+
return result;
|
|
591
|
+
}
|
|
592
|
+
const client = new GitHubAPIClient(auth);
|
|
593
|
+
await client.verifyAccess(repo);
|
|
594
|
+
const requiredLabels = collectRequiredLabels(items);
|
|
595
|
+
for (const label of requiredLabels) {
|
|
596
|
+
try {
|
|
597
|
+
const created = await client.ensureLabel(repo, label);
|
|
598
|
+
if (created) {
|
|
599
|
+
result.labelsCreated.push(label.name);
|
|
600
|
+
}
|
|
601
|
+
} catch (err) {
|
|
602
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
603
|
+
result.errors.push({ itemId: "labels", error: message });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
for (const item of items) {
|
|
607
|
+
try {
|
|
608
|
+
const labels = getLabelsForItem(item);
|
|
609
|
+
const body = formatIssueBody(item, reportPath);
|
|
610
|
+
const issueResult = await client.createIssue(repo, {
|
|
611
|
+
title: `[${item.id}] ${item.title}`,
|
|
612
|
+
body,
|
|
613
|
+
labels
|
|
614
|
+
});
|
|
615
|
+
result.created.push(issueResult);
|
|
616
|
+
} catch (err) {
|
|
617
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
618
|
+
result.errors.push({ itemId: item.id, error: message });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return result;
|
|
622
|
+
}
|
|
623
|
+
function parseRepoString(repoStr) {
|
|
624
|
+
const parts = repoStr.split("/");
|
|
625
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
626
|
+
throw new Error(`Invalid repository format: "${repoStr}". Expected "owner/repo".`);
|
|
627
|
+
}
|
|
628
|
+
return { owner: parts[0], repo: parts[1] };
|
|
629
|
+
}
|
|
630
|
+
var GITHUB_API, GitHubAPIClient;
|
|
631
|
+
var init_GitHubIssueCreator = __esm({
|
|
632
|
+
"src/integrations/github/GitHubIssueCreator.ts"() {
|
|
633
|
+
"use strict";
|
|
634
|
+
init_types2();
|
|
635
|
+
GITHUB_API = "https://api.github.com";
|
|
636
|
+
GitHubAPIClient = class {
|
|
637
|
+
baseUrl;
|
|
638
|
+
headers;
|
|
639
|
+
constructor(auth, baseUrl = GITHUB_API) {
|
|
640
|
+
this.baseUrl = baseUrl;
|
|
641
|
+
this.headers = {
|
|
642
|
+
Authorization: `Bearer ${auth.token}`,
|
|
643
|
+
Accept: "application/vnd.github+json",
|
|
644
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
645
|
+
"Content-Type": "application/json",
|
|
646
|
+
"User-Agent": "markupr-github-integration"
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
async createIssue(repo, input) {
|
|
650
|
+
const url = `${this.baseUrl}/repos/${repo.owner}/${repo.repo}/issues`;
|
|
651
|
+
const response = await fetch(url, {
|
|
652
|
+
method: "POST",
|
|
653
|
+
headers: this.headers,
|
|
654
|
+
body: JSON.stringify({
|
|
655
|
+
title: input.title,
|
|
656
|
+
body: input.body,
|
|
657
|
+
labels: input.labels
|
|
658
|
+
})
|
|
659
|
+
});
|
|
660
|
+
if (!response.ok) {
|
|
661
|
+
const text = await response.text();
|
|
662
|
+
throw new Error(`GitHub API error (${response.status}): ${text}`);
|
|
663
|
+
}
|
|
664
|
+
const data = await response.json();
|
|
665
|
+
return {
|
|
666
|
+
number: data.number,
|
|
667
|
+
url: data.html_url,
|
|
668
|
+
title: data.title
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
async ensureLabel(repo, label) {
|
|
672
|
+
const url = `${this.baseUrl}/repos/${repo.owner}/${repo.repo}/labels`;
|
|
673
|
+
const checkUrl = `${url}/${encodeURIComponent(label.name)}`;
|
|
674
|
+
const checkResponse = await fetch(checkUrl, {
|
|
675
|
+
method: "GET",
|
|
676
|
+
headers: this.headers
|
|
677
|
+
});
|
|
678
|
+
if (checkResponse.ok) {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
const createResponse = await fetch(url, {
|
|
682
|
+
method: "POST",
|
|
683
|
+
headers: this.headers,
|
|
684
|
+
body: JSON.stringify({
|
|
685
|
+
name: label.name,
|
|
686
|
+
color: label.color,
|
|
687
|
+
description: label.description
|
|
688
|
+
})
|
|
689
|
+
});
|
|
690
|
+
if (!createResponse.ok) {
|
|
691
|
+
if (createResponse.status === 422) {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
const text = await createResponse.text();
|
|
695
|
+
throw new Error(`Failed to create label "${label.name}": ${text}`);
|
|
696
|
+
}
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
async verifyAccess(repo) {
|
|
700
|
+
const url = `${this.baseUrl}/repos/${repo.owner}/${repo.repo}`;
|
|
701
|
+
const response = await fetch(url, {
|
|
702
|
+
method: "GET",
|
|
703
|
+
headers: this.headers
|
|
704
|
+
});
|
|
705
|
+
if (!response.ok) {
|
|
706
|
+
if (response.status === 404) {
|
|
707
|
+
throw new Error(`Repository ${repo.owner}/${repo.repo} not found (or no access)`);
|
|
708
|
+
}
|
|
709
|
+
if (response.status === 401) {
|
|
710
|
+
throw new Error("GitHub token is invalid or expired");
|
|
711
|
+
}
|
|
712
|
+
throw new Error(`Failed to access repository (${response.status})`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
});
|
|
8
718
|
|
|
9
719
|
// src/cli/index.ts
|
|
10
|
-
import { existsSync as
|
|
11
|
-
import { resolve } from "path";
|
|
720
|
+
import { existsSync as existsSync7 } from "fs";
|
|
721
|
+
import { resolve as resolve3 } from "path";
|
|
12
722
|
import { Command } from "commander";
|
|
13
723
|
|
|
14
724
|
// src/cli/CLIPipeline.ts
|
|
@@ -435,7 +1145,7 @@ ${REPORT_SUPPORT_LINE}
|
|
|
435
1145
|
const screenshotCount = this.countScreenshots(items);
|
|
436
1146
|
const topThemes = this.extractTopThemes(items);
|
|
437
1147
|
const highImpactCount = (severityCounts.Critical || 0) + (severityCounts.High || 0);
|
|
438
|
-
const
|
|
1148
|
+
const platform2 = session.metadata?.os || process?.platform || "Unknown";
|
|
439
1149
|
let content = `# ${projectName} Feedback Report
|
|
440
1150
|
> Generated by markupr on ${timestamp}
|
|
441
1151
|
> Duration: ${duration} | Items: ${items.length} | Screenshots: ${screenshotCount}
|
|
@@ -443,7 +1153,7 @@ ${REPORT_SUPPORT_LINE}
|
|
|
443
1153
|
## Session Overview
|
|
444
1154
|
- **Session ID:** \`${session.id}\`
|
|
445
1155
|
- **Source:** ${session.metadata?.sourceName || "Unknown"} (${session.metadata?.sourceType || "screen"})
|
|
446
|
-
- **Platform:** ${
|
|
1156
|
+
- **Platform:** ${platform2}
|
|
447
1157
|
- **Segments:** ${items.length}
|
|
448
1158
|
- **High-impact items:** ${highImpactCount}
|
|
449
1159
|
|
|
@@ -1189,7 +1899,7 @@ var WhisperService = class extends EventEmitter {
|
|
|
1189
1899
|
const percent = Math.round((i + 1) / totalChunks * 100);
|
|
1190
1900
|
onProgress?.(percent);
|
|
1191
1901
|
if (i < totalChunks - 1) {
|
|
1192
|
-
await new Promise((
|
|
1902
|
+
await new Promise((resolve4) => setTimeout(resolve4, 0));
|
|
1193
1903
|
}
|
|
1194
1904
|
}
|
|
1195
1905
|
this.log(`Transcription complete: ${results.length} segment(s)`);
|
|
@@ -1462,108 +2172,552 @@ var WhisperService = class extends EventEmitter {
|
|
|
1462
2172
|
};
|
|
1463
2173
|
var whisperService = new WhisperService();
|
|
1464
2174
|
|
|
1465
|
-
// src/
|
|
1466
|
-
var
|
|
1467
|
-
|
|
1468
|
-
var EXIT_SYSTEM_ERROR = 2;
|
|
1469
|
-
var EXIT_SIGINT = 130;
|
|
1470
|
-
var CLIPipeline = class _CLIPipeline {
|
|
1471
|
-
options;
|
|
1472
|
-
log;
|
|
1473
|
-
progress;
|
|
1474
|
-
tempFiles = [];
|
|
1475
|
-
activeProcesses = /* @__PURE__ */ new Set();
|
|
1476
|
-
constructor(options, log, progress) {
|
|
1477
|
-
this.options = options;
|
|
1478
|
-
this.log = log;
|
|
1479
|
-
this.progress = progress ?? (() => {
|
|
1480
|
-
});
|
|
1481
|
-
}
|
|
2175
|
+
// src/main/output/templates/registry.ts
|
|
2176
|
+
var TemplateRegistryImpl = class {
|
|
2177
|
+
templates = /* @__PURE__ */ new Map();
|
|
1482
2178
|
/**
|
|
1483
|
-
*
|
|
1484
|
-
* frame extraction -> markdown generation.
|
|
2179
|
+
* Register a template. Overwrites any existing template with the same name.
|
|
1485
2180
|
*/
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
return await this.runPipeline();
|
|
1489
|
-
} finally {
|
|
1490
|
-
await this.cleanup();
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
async runPipeline() {
|
|
1494
|
-
const startTime = Date.now();
|
|
1495
|
-
await this.validateVideoFile();
|
|
1496
|
-
if (!(this.options.audioPath && this.options.skipFrames)) {
|
|
1497
|
-
await this.checkFfmpegAvailable();
|
|
1498
|
-
}
|
|
1499
|
-
try {
|
|
1500
|
-
if (!existsSync3(this.options.outputDir)) {
|
|
1501
|
-
mkdirSync2(this.options.outputDir, { recursive: true });
|
|
1502
|
-
}
|
|
1503
|
-
} catch (error) {
|
|
1504
|
-
const code = error.code;
|
|
1505
|
-
if (code === "EACCES") {
|
|
1506
|
-
throw new CLIPipelineError(
|
|
1507
|
-
`Permission denied: cannot create output directory: ${this.options.outputDir}`,
|
|
1508
|
-
"user"
|
|
1509
|
-
);
|
|
1510
|
-
}
|
|
1511
|
-
throw new CLIPipelineError(
|
|
1512
|
-
`Cannot create output directory: ${this.options.outputDir} (${code})`,
|
|
1513
|
-
"system"
|
|
1514
|
-
);
|
|
1515
|
-
}
|
|
1516
|
-
this.progress("Extracting audio...");
|
|
1517
|
-
const audioPath = await this.resolveAudioPath();
|
|
1518
|
-
this.progress("Transcribing (this may take a while)...");
|
|
1519
|
-
const segments = await this.transcribe(audioPath);
|
|
1520
|
-
const analyzer = new TranscriptAnalyzer();
|
|
1521
|
-
const keyMoments = analyzer.analyze(segments);
|
|
1522
|
-
this.log(` Found ${keyMoments.length} key moment(s)`);
|
|
1523
|
-
let extractedFrames = [];
|
|
1524
|
-
if (!this.options.skipFrames) {
|
|
1525
|
-
this.progress("Extracting frames...");
|
|
1526
|
-
extractedFrames = await this.extractFrames(keyMoments, segments);
|
|
1527
|
-
} else {
|
|
1528
|
-
this.log(" Frame extraction skipped (--no-frames)");
|
|
1529
|
-
}
|
|
1530
|
-
this.progress("Generating report...");
|
|
1531
|
-
const result = {
|
|
1532
|
-
transcriptSegments: segments,
|
|
1533
|
-
extractedFrames,
|
|
1534
|
-
reportPath: this.options.outputDir
|
|
1535
|
-
};
|
|
1536
|
-
const generator = new MarkdownGeneratorImpl();
|
|
1537
|
-
const markdown = generator.generateFromPostProcess(result, this.options.outputDir);
|
|
1538
|
-
const outputFilename = this.generateOutputFilename();
|
|
1539
|
-
const outputPath = join3(this.options.outputDir, outputFilename);
|
|
1540
|
-
try {
|
|
1541
|
-
await writeFile(outputPath, markdown, "utf-8");
|
|
1542
|
-
} catch (error) {
|
|
1543
|
-
const code = error.code;
|
|
1544
|
-
throw new CLIPipelineError(
|
|
1545
|
-
`Failed to write output file: ${outputPath}
|
|
1546
|
-
Reason: ${code === "ENOSPC" ? "Disk is full" : error.message}`,
|
|
1547
|
-
"system"
|
|
1548
|
-
);
|
|
1549
|
-
}
|
|
1550
|
-
const durationSeconds = (Date.now() - startTime) / 1e3;
|
|
1551
|
-
return {
|
|
1552
|
-
outputPath,
|
|
1553
|
-
transcriptSegments: segments.length,
|
|
1554
|
-
extractedFrames: extractedFrames.length,
|
|
1555
|
-
durationSeconds
|
|
1556
|
-
};
|
|
2181
|
+
register(template) {
|
|
2182
|
+
this.templates.set(template.name, template);
|
|
1557
2183
|
}
|
|
1558
2184
|
/**
|
|
1559
|
-
*
|
|
2185
|
+
* Get a template by name. Returns undefined if not found.
|
|
1560
2186
|
*/
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
2187
|
+
get(name) {
|
|
2188
|
+
return this.templates.get(name);
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Check if a template with the given name exists.
|
|
2192
|
+
*/
|
|
2193
|
+
has(name) {
|
|
2194
|
+
return this.templates.has(name);
|
|
2195
|
+
}
|
|
2196
|
+
/**
|
|
2197
|
+
* List all registered template names.
|
|
2198
|
+
*/
|
|
2199
|
+
list() {
|
|
2200
|
+
return Array.from(this.templates.keys());
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* List all registered templates with their descriptions.
|
|
2204
|
+
*/
|
|
2205
|
+
listWithDescriptions() {
|
|
2206
|
+
return Array.from(this.templates.values()).map((t) => ({
|
|
2207
|
+
name: t.name,
|
|
2208
|
+
description: t.description,
|
|
2209
|
+
fileExtension: t.fileExtension
|
|
2210
|
+
}));
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* Get the default template name.
|
|
2214
|
+
*/
|
|
2215
|
+
getDefault() {
|
|
2216
|
+
return "markdown";
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
var templateRegistry = new TemplateRegistryImpl();
|
|
2220
|
+
|
|
2221
|
+
// src/main/output/templates/helpers.ts
|
|
2222
|
+
import * as path2 from "path";
|
|
2223
|
+
function formatTimestamp(seconds) {
|
|
2224
|
+
const totalSeconds = Math.max(0, Math.floor(seconds));
|
|
2225
|
+
const mins = Math.floor(totalSeconds / 60);
|
|
2226
|
+
const secs = totalSeconds % 60;
|
|
2227
|
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
2228
|
+
}
|
|
2229
|
+
function formatDuration(ms) {
|
|
2230
|
+
const totalSeconds = Math.floor(ms / 1e3);
|
|
2231
|
+
const mins = Math.floor(totalSeconds / 60);
|
|
2232
|
+
const secs = totalSeconds % 60;
|
|
2233
|
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
2234
|
+
}
|
|
2235
|
+
function formatDate(date) {
|
|
2236
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
2237
|
+
const month = months[date.getMonth()];
|
|
2238
|
+
const day = date.getDate();
|
|
2239
|
+
const year = date.getFullYear();
|
|
2240
|
+
const rawHours = date.getHours();
|
|
2241
|
+
const ampm = rawHours >= 12 ? "PM" : "AM";
|
|
2242
|
+
const hours = rawHours % 12 || 12;
|
|
2243
|
+
const minutes = date.getMinutes().toString().padStart(2, "0");
|
|
2244
|
+
return `${month} ${day}, ${year} at ${hours}:${minutes} ${ampm}`;
|
|
2245
|
+
}
|
|
2246
|
+
function generateSegmentTitle(text) {
|
|
2247
|
+
const firstSentence = text.split(/[.!?]/)[0].trim();
|
|
2248
|
+
if (firstSentence.length <= 60) return firstSentence;
|
|
2249
|
+
return firstSentence.slice(0, 57) + "...";
|
|
2250
|
+
}
|
|
2251
|
+
function wrapTranscription(transcription) {
|
|
2252
|
+
if (!transcription.includes(".") && !transcription.includes("!") && !transcription.includes("?")) {
|
|
2253
|
+
return transcription;
|
|
2254
|
+
}
|
|
2255
|
+
const sentences = transcription.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
|
|
2256
|
+
if (sentences.length <= 1) return transcription;
|
|
2257
|
+
return sentences.join("\n> ");
|
|
2258
|
+
}
|
|
2259
|
+
function computeRelativeFramePath(framePath, sessionDir) {
|
|
2260
|
+
if (!path2.isAbsolute(framePath)) {
|
|
2261
|
+
return framePath;
|
|
2262
|
+
}
|
|
2263
|
+
return path2.relative(sessionDir, framePath);
|
|
2264
|
+
}
|
|
2265
|
+
function computeSessionDuration(segments) {
|
|
2266
|
+
if (segments.length === 0) return "0:00";
|
|
2267
|
+
return formatDuration(
|
|
2268
|
+
(segments[segments.length - 1].endTime - segments[0].startTime) * 1e3
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
2271
|
+
function mapFramesToSegments(segments, frames) {
|
|
2272
|
+
const map = /* @__PURE__ */ new Map();
|
|
2273
|
+
for (const frame of frames) {
|
|
2274
|
+
let bestIndex = 0;
|
|
2275
|
+
let bestDistance = Infinity;
|
|
2276
|
+
for (let i = 0; i < segments.length; i++) {
|
|
2277
|
+
const seg = segments[i];
|
|
2278
|
+
if (frame.timestamp >= seg.startTime && frame.timestamp <= seg.endTime) {
|
|
2279
|
+
bestIndex = i;
|
|
2280
|
+
bestDistance = 0;
|
|
2281
|
+
break;
|
|
2282
|
+
}
|
|
2283
|
+
const distance = Math.abs(frame.timestamp - seg.startTime);
|
|
2284
|
+
if (distance < bestDistance) {
|
|
2285
|
+
bestDistance = distance;
|
|
2286
|
+
bestIndex = i;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
const existing = map.get(bestIndex) || [];
|
|
2290
|
+
existing.push(frame);
|
|
2291
|
+
map.set(bestIndex, existing);
|
|
2292
|
+
}
|
|
2293
|
+
for (const [, frameList] of map) {
|
|
2294
|
+
frameList.sort((a, b) => a.timestamp - b.timestamp);
|
|
2295
|
+
}
|
|
2296
|
+
return map;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// src/main/output/templates/markdown.ts
|
|
2300
|
+
var REPORT_SUPPORT_LINE2 = "*If this report saved you time, support development: [Ko-fi](https://ko-fi.com/eddiesanjuan)*";
|
|
2301
|
+
var markdownTemplate = {
|
|
2302
|
+
name: "markdown",
|
|
2303
|
+
description: "Default Markdown format \u2014 AI-ready, llms.txt-inspired structured document",
|
|
2304
|
+
fileExtension: ".md",
|
|
2305
|
+
render(context) {
|
|
2306
|
+
const { result, sessionDir, timestamp } = context;
|
|
2307
|
+
const { transcriptSegments, extractedFrames } = result;
|
|
2308
|
+
const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
|
|
2309
|
+
const sessionDuration = computeSessionDuration(transcriptSegments);
|
|
2310
|
+
let md = `# markupr Session \u2014 ${sessionTimestamp}
|
|
2311
|
+
`;
|
|
2312
|
+
md += `> Segments: ${transcriptSegments.length} | Frames: ${extractedFrames.length} | Duration: ${sessionDuration}
|
|
2313
|
+
|
|
2314
|
+
`;
|
|
2315
|
+
if (transcriptSegments.length === 0) {
|
|
2316
|
+
md += `_No speech was detected during this recording._
|
|
2317
|
+
`;
|
|
2318
|
+
return { content: md, fileExtension: ".md" };
|
|
2319
|
+
}
|
|
2320
|
+
md += `## Transcript
|
|
2321
|
+
|
|
2322
|
+
`;
|
|
2323
|
+
const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
|
|
2324
|
+
for (let i = 0; i < transcriptSegments.length; i++) {
|
|
2325
|
+
const segment = transcriptSegments[i];
|
|
2326
|
+
const formattedTime = formatTimestamp(segment.startTime);
|
|
2327
|
+
const title = generateSegmentTitle(segment.text);
|
|
2328
|
+
md += `### [${formattedTime}] ${title}
|
|
2329
|
+
`;
|
|
2330
|
+
md += `> ${wrapTranscription(segment.text)}
|
|
2331
|
+
|
|
2332
|
+
`;
|
|
2333
|
+
const frames = segmentFrameMap.get(i);
|
|
2334
|
+
if (frames && frames.length > 0) {
|
|
2335
|
+
for (const frame of frames) {
|
|
2336
|
+
const frameTimestamp = formatTimestamp(frame.timestamp);
|
|
2337
|
+
const relativePath = computeRelativeFramePath(frame.path, sessionDir);
|
|
2338
|
+
md += `
|
|
2339
|
+
|
|
2340
|
+
`;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
md += `---
|
|
2345
|
+
*Generated by [markupr](https://markupr.com)*
|
|
2346
|
+
${REPORT_SUPPORT_LINE2}
|
|
2347
|
+
`;
|
|
2348
|
+
return { content: md, fileExtension: ".md" };
|
|
2349
|
+
}
|
|
2350
|
+
};
|
|
2351
|
+
|
|
2352
|
+
// src/main/output/templates/json.ts
|
|
2353
|
+
var jsonTemplate = {
|
|
2354
|
+
name: "json",
|
|
2355
|
+
description: "Structured JSON output for programmatic consumption",
|
|
2356
|
+
fileExtension: ".json",
|
|
2357
|
+
render(context) {
|
|
2358
|
+
const { result, sessionDir, timestamp } = context;
|
|
2359
|
+
const { transcriptSegments, extractedFrames } = result;
|
|
2360
|
+
const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
|
|
2361
|
+
const output = {
|
|
2362
|
+
version: "1.0",
|
|
2363
|
+
generator: "markupr",
|
|
2364
|
+
timestamp: new Date(timestamp ?? Date.now()).toISOString(),
|
|
2365
|
+
summary: {
|
|
2366
|
+
segments: transcriptSegments.length,
|
|
2367
|
+
frames: extractedFrames.length,
|
|
2368
|
+
duration: computeSessionDuration(transcriptSegments)
|
|
2369
|
+
},
|
|
2370
|
+
segments: transcriptSegments.map((segment, i) => {
|
|
2371
|
+
const frames = segmentFrameMap.get(i) || [];
|
|
2372
|
+
return {
|
|
2373
|
+
text: segment.text,
|
|
2374
|
+
startTime: segment.startTime,
|
|
2375
|
+
endTime: segment.endTime,
|
|
2376
|
+
confidence: segment.confidence,
|
|
2377
|
+
frames: frames.map((f) => ({
|
|
2378
|
+
path: computeRelativeFramePath(f.path, sessionDir),
|
|
2379
|
+
timestamp: f.timestamp,
|
|
2380
|
+
reason: f.reason
|
|
2381
|
+
}))
|
|
2382
|
+
};
|
|
2383
|
+
})
|
|
2384
|
+
};
|
|
2385
|
+
return {
|
|
2386
|
+
content: JSON.stringify(output, null, 2),
|
|
2387
|
+
fileExtension: ".json"
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
|
|
2392
|
+
// src/main/output/templates/github-issue.ts
|
|
2393
|
+
var githubIssueTemplate = {
|
|
2394
|
+
name: "github-issue",
|
|
2395
|
+
description: "GitHub-flavored Markdown optimized for issue bodies with task lists and collapsible details",
|
|
2396
|
+
fileExtension: ".md",
|
|
2397
|
+
render(context) {
|
|
2398
|
+
const { result, sessionDir, timestamp } = context;
|
|
2399
|
+
const { transcriptSegments, extractedFrames } = result;
|
|
2400
|
+
const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
|
|
2401
|
+
const duration = computeSessionDuration(transcriptSegments);
|
|
2402
|
+
let md = `## Feedback Report
|
|
2403
|
+
|
|
2404
|
+
`;
|
|
2405
|
+
md += `> Captured by [markupr](https://markupr.com) on ${sessionTimestamp}
|
|
2406
|
+
`;
|
|
2407
|
+
md += `> ${transcriptSegments.length} segments | ${extractedFrames.length} frames | Duration: ${duration}
|
|
2408
|
+
|
|
2409
|
+
`;
|
|
2410
|
+
if (transcriptSegments.length === 0) {
|
|
2411
|
+
md += `_No feedback was captured during this recording._
|
|
2412
|
+
`;
|
|
2413
|
+
return { content: md, fileExtension: ".md" };
|
|
2414
|
+
}
|
|
2415
|
+
md += `### Action Items
|
|
2416
|
+
|
|
2417
|
+
`;
|
|
2418
|
+
for (const segment of transcriptSegments) {
|
|
2419
|
+
const title = generateSegmentTitle(segment.text);
|
|
2420
|
+
md += `- [ ] ${title}
|
|
2421
|
+
`;
|
|
2422
|
+
}
|
|
2423
|
+
md += `
|
|
2424
|
+
`;
|
|
2425
|
+
md += `### Details
|
|
2426
|
+
|
|
2427
|
+
`;
|
|
2428
|
+
const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
|
|
2429
|
+
for (let i = 0; i < transcriptSegments.length; i++) {
|
|
2430
|
+
const segment = transcriptSegments[i];
|
|
2431
|
+
const formattedTime = formatTimestamp(segment.startTime);
|
|
2432
|
+
const title = generateSegmentTitle(segment.text);
|
|
2433
|
+
md += `<details>
|
|
2434
|
+
`;
|
|
2435
|
+
md += `<summary><strong>[${formattedTime}] ${title}</strong></summary>
|
|
2436
|
+
|
|
2437
|
+
`;
|
|
2438
|
+
md += `${segment.text}
|
|
2439
|
+
|
|
2440
|
+
`;
|
|
2441
|
+
const frames = segmentFrameMap.get(i);
|
|
2442
|
+
if (frames && frames.length > 0) {
|
|
2443
|
+
for (const frame of frames) {
|
|
2444
|
+
const relativePath = computeRelativeFramePath(frame.path, sessionDir);
|
|
2445
|
+
md += `
|
|
2446
|
+
|
|
2447
|
+
`;
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
md += `</details>
|
|
2451
|
+
|
|
2452
|
+
`;
|
|
2453
|
+
}
|
|
2454
|
+
md += `---
|
|
2455
|
+
_Generated by [markupr](https://markupr.com)_
|
|
2456
|
+
`;
|
|
2457
|
+
return { content: md, fileExtension: ".md" };
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2460
|
+
|
|
2461
|
+
// src/main/output/templates/linear.ts
|
|
2462
|
+
var linearTemplate = {
|
|
2463
|
+
name: "linear",
|
|
2464
|
+
description: "Linear-compatible Markdown for issue descriptions",
|
|
2465
|
+
fileExtension: ".md",
|
|
2466
|
+
render(context) {
|
|
2467
|
+
const { result, sessionDir, timestamp } = context;
|
|
2468
|
+
const { transcriptSegments, extractedFrames } = result;
|
|
2469
|
+
const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
|
|
2470
|
+
const duration = computeSessionDuration(transcriptSegments);
|
|
2471
|
+
let md = `**Feedback Report** \u2014 ${sessionTimestamp}
|
|
2472
|
+
`;
|
|
2473
|
+
md += `${transcriptSegments.length} segments | ${extractedFrames.length} frames | Duration: ${duration}
|
|
2474
|
+
|
|
2475
|
+
`;
|
|
2476
|
+
if (transcriptSegments.length === 0) {
|
|
2477
|
+
md += `_No feedback was captured during this recording._
|
|
2478
|
+
`;
|
|
2479
|
+
return { content: md, fileExtension: ".md" };
|
|
2480
|
+
}
|
|
2481
|
+
md += `**Action Items**
|
|
2482
|
+
|
|
2483
|
+
`;
|
|
2484
|
+
for (const segment of transcriptSegments) {
|
|
2485
|
+
const title = generateSegmentTitle(segment.text);
|
|
2486
|
+
md += `- [ ] ${title}
|
|
2487
|
+
`;
|
|
2488
|
+
}
|
|
2489
|
+
md += `
|
|
2490
|
+
---
|
|
2491
|
+
|
|
2492
|
+
`;
|
|
2493
|
+
const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
|
|
2494
|
+
for (let i = 0; i < transcriptSegments.length; i++) {
|
|
2495
|
+
const segment = transcriptSegments[i];
|
|
2496
|
+
const formattedTime = formatTimestamp(segment.startTime);
|
|
2497
|
+
const title = generateSegmentTitle(segment.text);
|
|
2498
|
+
md += `### [${formattedTime}] ${title}
|
|
2499
|
+
|
|
2500
|
+
`;
|
|
2501
|
+
md += `> ${segment.text}
|
|
2502
|
+
|
|
2503
|
+
`;
|
|
2504
|
+
const frames = segmentFrameMap.get(i);
|
|
2505
|
+
if (frames && frames.length > 0) {
|
|
2506
|
+
for (const frame of frames) {
|
|
2507
|
+
const relativePath = computeRelativeFramePath(frame.path, sessionDir);
|
|
2508
|
+
md += `
|
|
2509
|
+
|
|
2510
|
+
`;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
md += `---
|
|
2515
|
+
_Captured by [markupr](https://markupr.com)_
|
|
2516
|
+
`;
|
|
2517
|
+
return { content: md, fileExtension: ".md" };
|
|
2518
|
+
}
|
|
2519
|
+
};
|
|
2520
|
+
|
|
2521
|
+
// src/main/output/templates/jira.ts
|
|
2522
|
+
var jiraTemplate = {
|
|
2523
|
+
name: "jira",
|
|
2524
|
+
description: "Jira wiki markup with panels, tables, and {code} blocks",
|
|
2525
|
+
fileExtension: ".jira",
|
|
2526
|
+
render(context) {
|
|
2527
|
+
const { result, sessionDir, timestamp } = context;
|
|
2528
|
+
const { transcriptSegments, extractedFrames } = result;
|
|
2529
|
+
const sessionTimestamp = formatDate(new Date(timestamp ?? Date.now()));
|
|
2530
|
+
const duration = computeSessionDuration(transcriptSegments);
|
|
2531
|
+
let content = `h1. Feedback Report
|
|
2532
|
+
|
|
2533
|
+
`;
|
|
2534
|
+
content += `{panel:title=Session Info|borderStyle=solid|borderColor=#ccc}
|
|
2535
|
+
`;
|
|
2536
|
+
content += `*Captured:* ${sessionTimestamp}
|
|
2537
|
+
`;
|
|
2538
|
+
content += `*Segments:* ${transcriptSegments.length} | *Frames:* ${extractedFrames.length} | *Duration:* ${duration}
|
|
2539
|
+
`;
|
|
2540
|
+
content += `{panel}
|
|
2541
|
+
|
|
2542
|
+
`;
|
|
2543
|
+
if (transcriptSegments.length === 0) {
|
|
2544
|
+
content += `_No feedback was captured during this recording._
|
|
2545
|
+
`;
|
|
2546
|
+
return { content, fileExtension: ".jira" };
|
|
2547
|
+
}
|
|
2548
|
+
content += `h2. Summary
|
|
2549
|
+
|
|
2550
|
+
`;
|
|
2551
|
+
content += `||#||Timestamp||Feedback||
|
|
2552
|
+
`;
|
|
2553
|
+
for (let i = 0; i < transcriptSegments.length; i++) {
|
|
2554
|
+
const segment = transcriptSegments[i];
|
|
2555
|
+
const formattedTime = formatTimestamp(segment.startTime);
|
|
2556
|
+
const title = generateSegmentTitle(segment.text);
|
|
2557
|
+
content += `|${i + 1}|${formattedTime}|${title}|
|
|
2558
|
+
`;
|
|
2559
|
+
}
|
|
2560
|
+
content += `
|
|
2561
|
+
`;
|
|
2562
|
+
content += `h2. Details
|
|
2563
|
+
|
|
2564
|
+
`;
|
|
2565
|
+
const segmentFrameMap = mapFramesToSegments(transcriptSegments, extractedFrames);
|
|
2566
|
+
for (let i = 0; i < transcriptSegments.length; i++) {
|
|
2567
|
+
const segment = transcriptSegments[i];
|
|
2568
|
+
const formattedTime = formatTimestamp(segment.startTime);
|
|
2569
|
+
const title = generateSegmentTitle(segment.text);
|
|
2570
|
+
content += `h3. \\[${formattedTime}\\] ${title}
|
|
2571
|
+
|
|
2572
|
+
`;
|
|
2573
|
+
content += `{quote}
|
|
2574
|
+
${segment.text}
|
|
2575
|
+
{quote}
|
|
2576
|
+
|
|
2577
|
+
`;
|
|
2578
|
+
const frames = segmentFrameMap.get(i);
|
|
2579
|
+
if (frames && frames.length > 0) {
|
|
2580
|
+
for (const frame of frames) {
|
|
2581
|
+
const relativePath = computeRelativeFramePath(frame.path, sessionDir);
|
|
2582
|
+
content += `!${relativePath}|thumbnail!
|
|
2583
|
+
|
|
2584
|
+
`;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
content += `----
|
|
2589
|
+
_Generated by [markupr|https://markupr.com]_
|
|
2590
|
+
`;
|
|
2591
|
+
return { content, fileExtension: ".jira" };
|
|
2592
|
+
}
|
|
2593
|
+
};
|
|
2594
|
+
|
|
2595
|
+
// src/main/output/templates/index.ts
|
|
2596
|
+
templateRegistry.register(markdownTemplate);
|
|
2597
|
+
templateRegistry.register(jsonTemplate);
|
|
2598
|
+
templateRegistry.register(githubIssueTemplate);
|
|
2599
|
+
templateRegistry.register(linearTemplate);
|
|
2600
|
+
templateRegistry.register(jiraTemplate);
|
|
2601
|
+
|
|
2602
|
+
// src/cli/CLIPipeline.ts
|
|
2603
|
+
var EXIT_SUCCESS = 0;
|
|
2604
|
+
var EXIT_USER_ERROR = 1;
|
|
2605
|
+
var EXIT_SYSTEM_ERROR = 2;
|
|
2606
|
+
var EXIT_SIGINT = 130;
|
|
2607
|
+
var CLIPipeline = class _CLIPipeline {
|
|
2608
|
+
options;
|
|
2609
|
+
log;
|
|
2610
|
+
progress;
|
|
2611
|
+
tempFiles = [];
|
|
2612
|
+
activeProcesses = /* @__PURE__ */ new Set();
|
|
2613
|
+
constructor(options, log, progress) {
|
|
2614
|
+
this.options = options;
|
|
2615
|
+
this.log = log;
|
|
2616
|
+
this.progress = progress ?? (() => {
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Run the full pipeline: audio extraction -> transcription -> analysis ->
|
|
2621
|
+
* frame extraction -> markdown generation.
|
|
2622
|
+
*/
|
|
2623
|
+
async run() {
|
|
2624
|
+
try {
|
|
2625
|
+
return await this.runPipeline();
|
|
2626
|
+
} finally {
|
|
2627
|
+
await this.cleanup();
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
async runPipeline() {
|
|
2631
|
+
const startTime = Date.now();
|
|
2632
|
+
await this.validateVideoFile();
|
|
2633
|
+
if (!(this.options.audioPath && this.options.skipFrames)) {
|
|
2634
|
+
await this.checkFfmpegAvailable();
|
|
2635
|
+
}
|
|
2636
|
+
try {
|
|
2637
|
+
if (!existsSync3(this.options.outputDir)) {
|
|
2638
|
+
mkdirSync2(this.options.outputDir, { recursive: true });
|
|
2639
|
+
}
|
|
2640
|
+
} catch (error) {
|
|
2641
|
+
const code = error.code;
|
|
2642
|
+
if (code === "EACCES") {
|
|
2643
|
+
throw new CLIPipelineError(
|
|
2644
|
+
`Permission denied: cannot create output directory: ${this.options.outputDir}`,
|
|
2645
|
+
"user"
|
|
2646
|
+
);
|
|
2647
|
+
}
|
|
2648
|
+
throw new CLIPipelineError(
|
|
2649
|
+
`Cannot create output directory: ${this.options.outputDir} (${code})`,
|
|
2650
|
+
"system"
|
|
2651
|
+
);
|
|
2652
|
+
}
|
|
2653
|
+
this.progress("Extracting audio...");
|
|
2654
|
+
const audioPath = await this.resolveAudioPath();
|
|
2655
|
+
this.progress("Transcribing (this may take a while)...");
|
|
2656
|
+
const segments = await this.transcribe(audioPath);
|
|
2657
|
+
const analyzer = new TranscriptAnalyzer();
|
|
2658
|
+
const keyMoments = analyzer.analyze(segments);
|
|
2659
|
+
this.log(` Found ${keyMoments.length} key moment(s)`);
|
|
2660
|
+
let extractedFrames = [];
|
|
2661
|
+
if (!this.options.skipFrames) {
|
|
2662
|
+
this.progress("Extracting frames...");
|
|
2663
|
+
extractedFrames = await this.extractFrames(keyMoments, segments);
|
|
2664
|
+
} else {
|
|
2665
|
+
this.log(" Frame extraction skipped (--no-frames)");
|
|
2666
|
+
}
|
|
2667
|
+
this.progress("Generating report...");
|
|
2668
|
+
const result = {
|
|
2669
|
+
transcriptSegments: segments,
|
|
2670
|
+
extractedFrames,
|
|
2671
|
+
reportPath: this.options.outputDir
|
|
2672
|
+
};
|
|
2673
|
+
let reportContent;
|
|
2674
|
+
let reportExtension = ".md";
|
|
2675
|
+
const templateName = this.options.template;
|
|
2676
|
+
if (templateName && templateName !== "markdown") {
|
|
2677
|
+
const template = templateRegistry.get(templateName);
|
|
2678
|
+
if (!template) {
|
|
2679
|
+
const available = templateRegistry.list().join(", ");
|
|
2680
|
+
throw new CLIPipelineError(
|
|
2681
|
+
`Unknown template "${templateName}". Available: ${available}`,
|
|
2682
|
+
"user"
|
|
2683
|
+
);
|
|
2684
|
+
}
|
|
2685
|
+
const output = template.render({ result, sessionDir: this.options.outputDir });
|
|
2686
|
+
reportContent = output.content;
|
|
2687
|
+
reportExtension = output.fileExtension;
|
|
2688
|
+
} else {
|
|
2689
|
+
const generator = new MarkdownGeneratorImpl();
|
|
2690
|
+
reportContent = generator.generateFromPostProcess(result, this.options.outputDir);
|
|
2691
|
+
}
|
|
2692
|
+
const outputFilename = this.generateOutputFilename(reportExtension);
|
|
2693
|
+
const outputPath = join3(this.options.outputDir, outputFilename);
|
|
2694
|
+
try {
|
|
2695
|
+
await writeFile(outputPath, reportContent, "utf-8");
|
|
2696
|
+
} catch (error) {
|
|
2697
|
+
const code = error.code;
|
|
2698
|
+
throw new CLIPipelineError(
|
|
2699
|
+
`Failed to write output file: ${outputPath}
|
|
2700
|
+
Reason: ${code === "ENOSPC" ? "Disk is full" : error.message}`,
|
|
2701
|
+
"system"
|
|
2702
|
+
);
|
|
2703
|
+
}
|
|
2704
|
+
const durationSeconds = (Date.now() - startTime) / 1e3;
|
|
2705
|
+
return {
|
|
2706
|
+
outputPath,
|
|
2707
|
+
transcriptSegments: segments.length,
|
|
2708
|
+
extractedFrames: extractedFrames.length,
|
|
2709
|
+
durationSeconds
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
/**
|
|
2713
|
+
* Abort the pipeline: kill active child processes and clean up temp files.
|
|
2714
|
+
*/
|
|
2715
|
+
async abort() {
|
|
2716
|
+
for (const proc of this.activeProcesses) {
|
|
2717
|
+
proc.kill("SIGTERM");
|
|
2718
|
+
}
|
|
2719
|
+
this.activeProcesses.clear();
|
|
2720
|
+
await this.cleanup();
|
|
1567
2721
|
}
|
|
1568
2722
|
/**
|
|
1569
2723
|
* Clean up temp files created during the pipeline run.
|
|
@@ -1592,11 +2746,11 @@ var CLIPipeline = class _CLIPipeline {
|
|
|
1592
2746
|
TEMP: process.env.TEMP
|
|
1593
2747
|
};
|
|
1594
2748
|
execFileTracked(command, args) {
|
|
1595
|
-
return new Promise((
|
|
2749
|
+
return new Promise((resolve4, reject) => {
|
|
1596
2750
|
const child = execFileCb2(command, args, { env: _CLIPipeline.SAFE_CHILD_ENV }, (error, stdout, stderr) => {
|
|
1597
2751
|
this.activeProcesses.delete(child);
|
|
1598
2752
|
if (error) reject(error);
|
|
1599
|
-
else
|
|
2753
|
+
else resolve4({ stdout: stdout?.toString() ?? "", stderr: stderr?.toString() ?? "" });
|
|
1600
2754
|
});
|
|
1601
2755
|
this.activeProcesses.add(child);
|
|
1602
2756
|
});
|
|
@@ -1651,8 +2805,8 @@ var CLIPipeline = class _CLIPipeline {
|
|
|
1651
2805
|
try {
|
|
1652
2806
|
await this.execFileTracked("ffmpeg", ["-version"]);
|
|
1653
2807
|
} catch {
|
|
1654
|
-
const
|
|
1655
|
-
const installHint =
|
|
2808
|
+
const platform2 = process.platform;
|
|
2809
|
+
const installHint = platform2 === "darwin" ? "brew install ffmpeg" : platform2 === "win32" ? "winget install ffmpeg (or download from https://ffmpeg.org)" : "apt install ffmpeg (or your package manager)";
|
|
1656
2810
|
throw new CLIPipelineError(
|
|
1657
2811
|
`ffmpeg is required but not found on your system.
|
|
1658
2812
|
Install via: ${installHint}
|
|
@@ -1812,56 +2966,599 @@ var CLIPipeline = class _CLIPipeline {
|
|
|
1812
2966
|
return extractedFrames;
|
|
1813
2967
|
}
|
|
1814
2968
|
/**
|
|
1815
|
-
* Find the transcript segment closest to a given timestamp.
|
|
2969
|
+
* Find the transcript segment closest to a given timestamp.
|
|
2970
|
+
*/
|
|
2971
|
+
findClosestSegment(timestamp, segments) {
|
|
2972
|
+
if (segments.length === 0) return void 0;
|
|
2973
|
+
for (const segment of segments) {
|
|
2974
|
+
if (timestamp >= segment.startTime && timestamp <= segment.endTime) {
|
|
2975
|
+
return segment;
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
let closest = segments[0];
|
|
2979
|
+
let minDistance = Math.abs(timestamp - closest.startTime);
|
|
2980
|
+
for (let i = 1; i < segments.length; i++) {
|
|
2981
|
+
const distance = Math.abs(timestamp - segments[i].startTime);
|
|
2982
|
+
if (distance < minDistance) {
|
|
2983
|
+
minDistance = distance;
|
|
2984
|
+
closest = segments[i];
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
return closest;
|
|
2988
|
+
}
|
|
2989
|
+
/**
|
|
2990
|
+
* Generate the output filename based on the video filename and current date (UTC).
|
|
2991
|
+
*/
|
|
2992
|
+
generateOutputFilename(extension = ".md") {
|
|
2993
|
+
const videoName = basename2(this.options.videoPath).replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-");
|
|
2994
|
+
const now = /* @__PURE__ */ new Date();
|
|
2995
|
+
const dateStr = [
|
|
2996
|
+
now.getUTCFullYear(),
|
|
2997
|
+
String(now.getUTCMonth() + 1).padStart(2, "0"),
|
|
2998
|
+
String(now.getUTCDate()).padStart(2, "0")
|
|
2999
|
+
].join("");
|
|
3000
|
+
const timeStr = [
|
|
3001
|
+
String(now.getUTCHours()).padStart(2, "0"),
|
|
3002
|
+
String(now.getUTCMinutes()).padStart(2, "0"),
|
|
3003
|
+
String(now.getUTCSeconds()).padStart(2, "0")
|
|
3004
|
+
].join("");
|
|
3005
|
+
const ext = extension.startsWith(".") ? extension : `.${extension}`;
|
|
3006
|
+
return `${videoName}-feedback-${dateStr}-${timeStr}${ext}`;
|
|
3007
|
+
}
|
|
3008
|
+
};
|
|
3009
|
+
var CLIPipelineError = class extends Error {
|
|
3010
|
+
severity;
|
|
3011
|
+
constructor(message, severity) {
|
|
3012
|
+
super(message);
|
|
3013
|
+
this.name = "CLIPipelineError";
|
|
3014
|
+
this.severity = severity;
|
|
3015
|
+
}
|
|
3016
|
+
};
|
|
3017
|
+
|
|
3018
|
+
// src/cli/WatchMode.ts
|
|
3019
|
+
import { watch, existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync } from "fs";
|
|
3020
|
+
import { stat as stat2, readdir, appendFile } from "fs/promises";
|
|
3021
|
+
import { join as join4, resolve, extname, basename as basename3 } from "path";
|
|
3022
|
+
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mov", ".mp4", ".webm"]);
|
|
3023
|
+
var WATCH_LOG_FILENAME = ".markupr-watch.log";
|
|
3024
|
+
var WatchMode = class {
|
|
3025
|
+
options;
|
|
3026
|
+
callbacks;
|
|
3027
|
+
watcher = null;
|
|
3028
|
+
processing = /* @__PURE__ */ new Set();
|
|
3029
|
+
processed = /* @__PURE__ */ new Set();
|
|
3030
|
+
pendingStabilityChecks = /* @__PURE__ */ new Map();
|
|
3031
|
+
stopped = false;
|
|
3032
|
+
stopResolve = null;
|
|
3033
|
+
resolvedOutputDir;
|
|
3034
|
+
constructor(options, callbacks) {
|
|
3035
|
+
this.options = options;
|
|
3036
|
+
this.callbacks = callbacks;
|
|
3037
|
+
this.resolvedOutputDir = options.outputDir ? resolve(options.outputDir) : join4(resolve(options.watchDir), "markupr-output");
|
|
3038
|
+
}
|
|
3039
|
+
/**
|
|
3040
|
+
* Start watching the directory. Returns a promise that resolves when
|
|
3041
|
+
* the watcher is stopped (via stop() or SIGINT).
|
|
3042
|
+
*/
|
|
3043
|
+
async start() {
|
|
3044
|
+
const watchDir = resolve(this.options.watchDir);
|
|
3045
|
+
if (!existsSync4(watchDir)) {
|
|
3046
|
+
throw new Error(`Watch directory does not exist: ${watchDir}`);
|
|
3047
|
+
}
|
|
3048
|
+
if (!existsSync4(this.resolvedOutputDir)) {
|
|
3049
|
+
mkdirSync3(this.resolvedOutputDir, { recursive: true });
|
|
3050
|
+
}
|
|
3051
|
+
await this.scanExistingFiles(watchDir);
|
|
3052
|
+
this.callbacks.onLog(`Watching: ${watchDir}`);
|
|
3053
|
+
this.callbacks.onLog(`Output: ${this.resolvedOutputDir}`);
|
|
3054
|
+
this.callbacks.onLog(`Watching for: ${[...VIDEO_EXTENSIONS].join(", ")}`);
|
|
3055
|
+
this.watcher = watch(watchDir, (eventType, filename) => {
|
|
3056
|
+
if (this.stopped || !filename) return;
|
|
3057
|
+
this.handleFileEvent(watchDir, filename);
|
|
3058
|
+
});
|
|
3059
|
+
this.watcher.on("error", (err) => {
|
|
3060
|
+
this.callbacks.onLog(`Watcher error: ${err.message}`);
|
|
3061
|
+
});
|
|
3062
|
+
return new Promise((resolvePromise) => {
|
|
3063
|
+
if (this.stopped) {
|
|
3064
|
+
resolvePromise();
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
this.stopResolve = resolvePromise;
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
/**
|
|
3071
|
+
* Stop watching and clean up.
|
|
3072
|
+
*/
|
|
3073
|
+
stop() {
|
|
3074
|
+
this.stopped = true;
|
|
3075
|
+
for (const [, timeout] of this.pendingStabilityChecks) {
|
|
3076
|
+
clearTimeout(timeout);
|
|
3077
|
+
}
|
|
3078
|
+
this.pendingStabilityChecks.clear();
|
|
3079
|
+
if (this.watcher) {
|
|
3080
|
+
this.watcher.close();
|
|
3081
|
+
this.watcher = null;
|
|
3082
|
+
}
|
|
3083
|
+
if (this.stopResolve) {
|
|
3084
|
+
this.stopResolve();
|
|
3085
|
+
this.stopResolve = null;
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* Check if the watcher has been stopped.
|
|
3090
|
+
*/
|
|
3091
|
+
isStopped() {
|
|
3092
|
+
return this.stopped;
|
|
3093
|
+
}
|
|
3094
|
+
/**
|
|
3095
|
+
* Get the set of files currently being processed.
|
|
3096
|
+
*/
|
|
3097
|
+
getProcessingFiles() {
|
|
3098
|
+
return this.processing;
|
|
3099
|
+
}
|
|
3100
|
+
/**
|
|
3101
|
+
* Get the set of files that have been processed.
|
|
3102
|
+
*/
|
|
3103
|
+
getProcessedFiles() {
|
|
3104
|
+
return this.processed;
|
|
3105
|
+
}
|
|
3106
|
+
// ==========================================================================
|
|
3107
|
+
// Private Methods
|
|
3108
|
+
// ==========================================================================
|
|
3109
|
+
/**
|
|
3110
|
+
* Scan existing files in the watch directory and mark ones that already
|
|
3111
|
+
* have corresponding output as processed.
|
|
3112
|
+
*/
|
|
3113
|
+
async scanExistingFiles(watchDir) {
|
|
3114
|
+
try {
|
|
3115
|
+
const entries = await readdir(watchDir);
|
|
3116
|
+
for (const entry of entries) {
|
|
3117
|
+
const ext = extname(entry).toLowerCase();
|
|
3118
|
+
if (VIDEO_EXTENSIONS.has(ext)) {
|
|
3119
|
+
const fullPath = join4(watchDir, entry);
|
|
3120
|
+
if (this.hasExistingOutput(entry)) {
|
|
3121
|
+
this.processed.add(fullPath);
|
|
3122
|
+
if (this.options.verbose) {
|
|
3123
|
+
this.callbacks.onLog(`Skipping (already processed): ${entry}`);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
} catch {
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
/**
|
|
3132
|
+
* Check if a video file already has corresponding output in the output directory.
|
|
1816
3133
|
*/
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
3134
|
+
hasExistingOutput(filename) {
|
|
3135
|
+
const videoName = basename3(filename).replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-");
|
|
3136
|
+
if (!existsSync4(this.resolvedOutputDir)) return false;
|
|
3137
|
+
try {
|
|
3138
|
+
const outputFiles = readdirSync(this.resolvedOutputDir);
|
|
3139
|
+
return outputFiles.some(
|
|
3140
|
+
(f) => f.startsWith(videoName) && f.endsWith(".md")
|
|
3141
|
+
);
|
|
3142
|
+
} catch {
|
|
3143
|
+
return false;
|
|
1823
3144
|
}
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
3145
|
+
}
|
|
3146
|
+
/**
|
|
3147
|
+
* Handle a file event from fs.watch.
|
|
3148
|
+
*/
|
|
3149
|
+
handleFileEvent(watchDir, filename) {
|
|
3150
|
+
const ext = extname(filename).toLowerCase();
|
|
3151
|
+
if (!VIDEO_EXTENSIONS.has(ext)) return;
|
|
3152
|
+
const fullPath = join4(watchDir, filename);
|
|
3153
|
+
if (this.processed.has(fullPath) || this.processing.has(fullPath)) return;
|
|
3154
|
+
if (this.pendingStabilityChecks.has(fullPath)) return;
|
|
3155
|
+
this.callbacks.onFileDetected(fullPath);
|
|
3156
|
+
this.startStabilityCheck(fullPath);
|
|
3157
|
+
}
|
|
3158
|
+
/**
|
|
3159
|
+
* Start a stability check for a file. The file is considered stable when
|
|
3160
|
+
* its size doesn't change over the configured interval (default: 2s).
|
|
3161
|
+
*/
|
|
3162
|
+
startStabilityCheck(filePath, previousSize, checks = 0) {
|
|
3163
|
+
const interval = this.options.stabilityInterval ?? 2e3;
|
|
3164
|
+
const maxChecks = this.options.maxStabilityChecks ?? 30;
|
|
3165
|
+
if (this.stopped) return;
|
|
3166
|
+
if (checks >= maxChecks) {
|
|
3167
|
+
this.callbacks.onLog(`Gave up waiting for file to stabilize: ${basename3(filePath)}`);
|
|
3168
|
+
this.pendingStabilityChecks.delete(filePath);
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
3171
|
+
const timeout = setTimeout(async () => {
|
|
3172
|
+
if (this.stopped) {
|
|
3173
|
+
this.pendingStabilityChecks.delete(filePath);
|
|
3174
|
+
return;
|
|
3175
|
+
}
|
|
3176
|
+
try {
|
|
3177
|
+
if (!existsSync4(filePath)) {
|
|
3178
|
+
this.pendingStabilityChecks.delete(filePath);
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
const stats = await stat2(filePath);
|
|
3182
|
+
const currentSize = stats.size;
|
|
3183
|
+
if (currentSize === 0) {
|
|
3184
|
+
this.pendingStabilityChecks.delete(filePath);
|
|
3185
|
+
this.startStabilityCheck(filePath, currentSize, checks + 1);
|
|
3186
|
+
return;
|
|
3187
|
+
}
|
|
3188
|
+
if (previousSize !== void 0 && currentSize === previousSize) {
|
|
3189
|
+
this.pendingStabilityChecks.delete(filePath);
|
|
3190
|
+
this.processFile(filePath);
|
|
3191
|
+
} else {
|
|
3192
|
+
if (this.options.verbose) {
|
|
3193
|
+
this.callbacks.onLog(
|
|
3194
|
+
`File size: ${currentSize} bytes (check ${checks + 1}/${maxChecks}): ${basename3(filePath)}`
|
|
3195
|
+
);
|
|
3196
|
+
}
|
|
3197
|
+
this.pendingStabilityChecks.delete(filePath);
|
|
3198
|
+
this.startStabilityCheck(filePath, currentSize, checks + 1);
|
|
3199
|
+
}
|
|
3200
|
+
} catch {
|
|
3201
|
+
this.pendingStabilityChecks.delete(filePath);
|
|
1831
3202
|
}
|
|
3203
|
+
}, interval);
|
|
3204
|
+
this.pendingStabilityChecks.set(filePath, timeout);
|
|
3205
|
+
}
|
|
3206
|
+
/**
|
|
3207
|
+
* Process a stable video file through CLIPipeline.
|
|
3208
|
+
*/
|
|
3209
|
+
async processFile(filePath) {
|
|
3210
|
+
if (this.stopped || this.processing.has(filePath) || this.processed.has(filePath)) return;
|
|
3211
|
+
this.processing.add(filePath);
|
|
3212
|
+
this.callbacks.onProcessingStart(filePath);
|
|
3213
|
+
const pipelineOptions = {
|
|
3214
|
+
videoPath: filePath,
|
|
3215
|
+
outputDir: this.resolvedOutputDir,
|
|
3216
|
+
whisperModelPath: this.options.whisperModelPath,
|
|
3217
|
+
openaiKey: this.options.openaiKey,
|
|
3218
|
+
skipFrames: this.options.skipFrames,
|
|
3219
|
+
verbose: this.options.verbose
|
|
3220
|
+
};
|
|
3221
|
+
const logFn = this.options.verbose ? this.callbacks.onLog : () => {
|
|
3222
|
+
};
|
|
3223
|
+
const pipeline = new CLIPipeline(pipelineOptions, logFn, this.callbacks.onLog);
|
|
3224
|
+
try {
|
|
3225
|
+
const result = await pipeline.run();
|
|
3226
|
+
this.processed.add(filePath);
|
|
3227
|
+
this.callbacks.onProcessingComplete(filePath, result.outputPath);
|
|
3228
|
+
await this.appendToWatchLog(filePath, result.outputPath);
|
|
3229
|
+
} catch (error) {
|
|
3230
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3231
|
+
this.callbacks.onProcessingError(filePath, err);
|
|
3232
|
+
} finally {
|
|
3233
|
+
this.processing.delete(filePath);
|
|
1832
3234
|
}
|
|
1833
|
-
return closest;
|
|
1834
3235
|
}
|
|
1835
3236
|
/**
|
|
1836
|
-
*
|
|
3237
|
+
* Append a processed file entry to the watch log.
|
|
1837
3238
|
*/
|
|
1838
|
-
|
|
1839
|
-
const
|
|
1840
|
-
const
|
|
1841
|
-
const
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
String(now.getUTCHours()).padStart(2, "0"),
|
|
1848
|
-
String(now.getUTCMinutes()).padStart(2, "0"),
|
|
1849
|
-
String(now.getUTCSeconds()).padStart(2, "0")
|
|
1850
|
-
].join("");
|
|
1851
|
-
return `${videoName}-feedback-${dateStr}-${timeStr}.md`;
|
|
3239
|
+
async appendToWatchLog(inputPath, outputPath) {
|
|
3240
|
+
const logPath = join4(resolve(this.options.watchDir), WATCH_LOG_FILENAME);
|
|
3241
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
3242
|
+
const entry = `${timestamp} ${inputPath} ${outputPath}
|
|
3243
|
+
`;
|
|
3244
|
+
try {
|
|
3245
|
+
await appendFile(logPath, entry, "utf-8");
|
|
3246
|
+
} catch {
|
|
3247
|
+
}
|
|
1852
3248
|
}
|
|
1853
3249
|
};
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
3250
|
+
|
|
3251
|
+
// src/cli/doctor.ts
|
|
3252
|
+
import { existsSync as existsSync5 } from "fs";
|
|
3253
|
+
import { stat as stat3 } from "fs/promises";
|
|
3254
|
+
import { execFile as execFileCb3 } from "child_process";
|
|
3255
|
+
import { join as join5 } from "path";
|
|
3256
|
+
import { homedir as homedir2, platform } from "os";
|
|
3257
|
+
var SAFE_CHILD_ENV2 = {
|
|
3258
|
+
PATH: process.env.PATH,
|
|
3259
|
+
HOME: process.env.HOME || process.env.USERPROFILE,
|
|
3260
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
3261
|
+
LANG: process.env.LANG
|
|
1861
3262
|
};
|
|
3263
|
+
function execQuiet(command, args) {
|
|
3264
|
+
return new Promise((resolve4) => {
|
|
3265
|
+
execFileCb3(command, args, { env: SAFE_CHILD_ENV2 }, (error, stdout) => {
|
|
3266
|
+
if (error) {
|
|
3267
|
+
resolve4(null);
|
|
3268
|
+
} else {
|
|
3269
|
+
resolve4(stdout?.toString().trim() ?? "");
|
|
3270
|
+
}
|
|
3271
|
+
});
|
|
3272
|
+
});
|
|
3273
|
+
}
|
|
3274
|
+
function getWhisperModelsDir() {
|
|
3275
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir2();
|
|
3276
|
+
return join5(home, ".markupr", "whisper-models");
|
|
3277
|
+
}
|
|
3278
|
+
function findWhisperModel(modelsDir) {
|
|
3279
|
+
const modelNames = [
|
|
3280
|
+
"ggml-tiny.bin",
|
|
3281
|
+
"ggml-base.bin",
|
|
3282
|
+
"ggml-small.bin",
|
|
3283
|
+
"ggml-medium.bin",
|
|
3284
|
+
"ggml-large-v3.bin"
|
|
3285
|
+
];
|
|
3286
|
+
for (const name of modelNames) {
|
|
3287
|
+
const modelPath = join5(modelsDir, name);
|
|
3288
|
+
if (existsSync5(modelPath)) {
|
|
3289
|
+
return name;
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
return null;
|
|
3293
|
+
}
|
|
3294
|
+
function parseSemver(version) {
|
|
3295
|
+
const match = version.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
3296
|
+
if (!match) return null;
|
|
3297
|
+
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
|
|
3298
|
+
}
|
|
3299
|
+
async function checkNodeVersion() {
|
|
3300
|
+
const version = process.version;
|
|
3301
|
+
const parsed = parseSemver(version);
|
|
3302
|
+
if (!parsed) {
|
|
3303
|
+
return {
|
|
3304
|
+
name: "Node.js",
|
|
3305
|
+
status: "warn",
|
|
3306
|
+
message: `Unknown version: ${version}`,
|
|
3307
|
+
hint: "markupr requires Node.js >= 18.0.0"
|
|
3308
|
+
};
|
|
3309
|
+
}
|
|
3310
|
+
const [major] = parsed;
|
|
3311
|
+
if (major >= 18) {
|
|
3312
|
+
return {
|
|
3313
|
+
name: "Node.js",
|
|
3314
|
+
status: "pass",
|
|
3315
|
+
message: `${version} (>= 18.0.0)`
|
|
3316
|
+
};
|
|
3317
|
+
}
|
|
3318
|
+
return {
|
|
3319
|
+
name: "Node.js",
|
|
3320
|
+
status: "fail",
|
|
3321
|
+
message: `${version} is too old`,
|
|
3322
|
+
hint: "markupr requires Node.js >= 18.0.0. Upgrade at https://nodejs.org"
|
|
3323
|
+
};
|
|
3324
|
+
}
|
|
3325
|
+
async function checkFfmpeg() {
|
|
3326
|
+
const stdout = await execQuiet("ffmpeg", ["-version"]);
|
|
3327
|
+
if (stdout === null) {
|
|
3328
|
+
const os2 = platform();
|
|
3329
|
+
const installHint = os2 === "darwin" ? "brew install ffmpeg" : os2 === "win32" ? "winget install ffmpeg (or download from https://ffmpeg.org)" : "apt install ffmpeg (or your package manager)";
|
|
3330
|
+
return {
|
|
3331
|
+
name: "ffmpeg",
|
|
3332
|
+
status: "fail",
|
|
3333
|
+
message: "Not found on PATH",
|
|
3334
|
+
hint: `Install via: ${installHint}`
|
|
3335
|
+
};
|
|
3336
|
+
}
|
|
3337
|
+
const versionMatch = stdout.match(/ffmpeg version (\S+)/);
|
|
3338
|
+
const version = versionMatch ? versionMatch[1] : "unknown";
|
|
3339
|
+
return {
|
|
3340
|
+
name: "ffmpeg",
|
|
3341
|
+
status: "pass",
|
|
3342
|
+
message: `Installed (${version})`
|
|
3343
|
+
};
|
|
3344
|
+
}
|
|
3345
|
+
async function checkFfprobe() {
|
|
3346
|
+
const stdout = await execQuiet("ffprobe", ["-version"]);
|
|
3347
|
+
if (stdout === null) {
|
|
3348
|
+
return {
|
|
3349
|
+
name: "ffprobe",
|
|
3350
|
+
status: "fail",
|
|
3351
|
+
message: "Not found on PATH",
|
|
3352
|
+
hint: "ffprobe is usually installed alongside ffmpeg"
|
|
3353
|
+
};
|
|
3354
|
+
}
|
|
3355
|
+
const versionMatch = stdout.match(/ffprobe version (\S+)/);
|
|
3356
|
+
const version = versionMatch ? versionMatch[1] : "unknown";
|
|
3357
|
+
return {
|
|
3358
|
+
name: "ffprobe",
|
|
3359
|
+
status: "pass",
|
|
3360
|
+
message: `Installed (${version})`
|
|
3361
|
+
};
|
|
3362
|
+
}
|
|
3363
|
+
async function checkWhisperModel() {
|
|
3364
|
+
const modelsDir = getWhisperModelsDir();
|
|
3365
|
+
if (!existsSync5(modelsDir)) {
|
|
3366
|
+
return {
|
|
3367
|
+
name: "Whisper model",
|
|
3368
|
+
status: "warn",
|
|
3369
|
+
message: "No models directory found",
|
|
3370
|
+
hint: `Models directory: ${modelsDir}
|
|
3371
|
+
Download a model via the markupr desktop app, or manually place a ggml-*.bin file there`
|
|
3372
|
+
};
|
|
3373
|
+
}
|
|
3374
|
+
const model = findWhisperModel(modelsDir);
|
|
3375
|
+
if (!model) {
|
|
3376
|
+
return {
|
|
3377
|
+
name: "Whisper model",
|
|
3378
|
+
status: "warn",
|
|
3379
|
+
message: "No model files found",
|
|
3380
|
+
hint: `Models directory: ${modelsDir}
|
|
3381
|
+
Download a model via the markupr desktop app, or manually place a ggml-*.bin file there`
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
return {
|
|
3385
|
+
name: "Whisper model",
|
|
3386
|
+
status: "pass",
|
|
3387
|
+
message: `${model} found in ${modelsDir}`
|
|
3388
|
+
};
|
|
3389
|
+
}
|
|
3390
|
+
async function checkAnthropicKey() {
|
|
3391
|
+
const key = process.env.ANTHROPIC_API_KEY;
|
|
3392
|
+
if (!key) {
|
|
3393
|
+
return {
|
|
3394
|
+
name: "Anthropic API key",
|
|
3395
|
+
status: "warn",
|
|
3396
|
+
message: "ANTHROPIC_API_KEY not set",
|
|
3397
|
+
hint: "Optional. Set this env var to enable AI-powered analysis. Get a key at https://console.anthropic.com"
|
|
3398
|
+
};
|
|
3399
|
+
}
|
|
3400
|
+
if (key.startsWith("sk-ant-")) {
|
|
3401
|
+
return {
|
|
3402
|
+
name: "Anthropic API key",
|
|
3403
|
+
status: "pass",
|
|
3404
|
+
message: "ANTHROPIC_API_KEY is set (sk-ant-...)"
|
|
3405
|
+
};
|
|
3406
|
+
}
|
|
3407
|
+
return {
|
|
3408
|
+
name: "Anthropic API key",
|
|
3409
|
+
status: "pass",
|
|
3410
|
+
message: "ANTHROPIC_API_KEY is set"
|
|
3411
|
+
};
|
|
3412
|
+
}
|
|
3413
|
+
async function checkOpenAIKey() {
|
|
3414
|
+
const key = process.env.OPENAI_API_KEY;
|
|
3415
|
+
if (!key) {
|
|
3416
|
+
return {
|
|
3417
|
+
name: "OpenAI API key",
|
|
3418
|
+
status: "warn",
|
|
3419
|
+
message: "OPENAI_API_KEY not set",
|
|
3420
|
+
hint: "Optional. Set this env var for cloud transcription via Whisper-1 API"
|
|
3421
|
+
};
|
|
3422
|
+
}
|
|
3423
|
+
return {
|
|
3424
|
+
name: "OpenAI API key",
|
|
3425
|
+
status: "pass",
|
|
3426
|
+
message: "OPENAI_API_KEY is set"
|
|
3427
|
+
};
|
|
3428
|
+
}
|
|
3429
|
+
async function checkDiskSpace() {
|
|
3430
|
+
try {
|
|
3431
|
+
const tempDir = process.env.TMPDIR || process.env.TEMP || "/tmp";
|
|
3432
|
+
if (platform() !== "win32") {
|
|
3433
|
+
const dfOutput = await execQuiet("df", ["-k", tempDir]);
|
|
3434
|
+
if (dfOutput) {
|
|
3435
|
+
const lines = dfOutput.split("\n");
|
|
3436
|
+
if (lines.length >= 2) {
|
|
3437
|
+
const parts = lines[1].split(/\s+/);
|
|
3438
|
+
if (parts.length >= 4) {
|
|
3439
|
+
const availableKB = parseInt(parts[3], 10);
|
|
3440
|
+
if (!isNaN(availableKB)) {
|
|
3441
|
+
const availableGB = availableKB / (1024 * 1024);
|
|
3442
|
+
if (availableGB < 1) {
|
|
3443
|
+
return {
|
|
3444
|
+
name: "Disk space",
|
|
3445
|
+
status: "warn",
|
|
3446
|
+
message: `${availableGB.toFixed(1)} GB available (low)`,
|
|
3447
|
+
hint: "markupr recordings and output need disk space. Free up some space if you plan to record long sessions"
|
|
3448
|
+
};
|
|
3449
|
+
}
|
|
3450
|
+
return {
|
|
3451
|
+
name: "Disk space",
|
|
3452
|
+
status: "pass",
|
|
3453
|
+
message: `${availableGB.toFixed(1)} GB available`
|
|
3454
|
+
};
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
await stat3(tempDir);
|
|
3461
|
+
return {
|
|
3462
|
+
name: "Disk space",
|
|
3463
|
+
status: "pass",
|
|
3464
|
+
message: "Temp directory accessible"
|
|
3465
|
+
};
|
|
3466
|
+
} catch {
|
|
3467
|
+
return {
|
|
3468
|
+
name: "Disk space",
|
|
3469
|
+
status: "warn",
|
|
3470
|
+
message: "Could not determine available disk space"
|
|
3471
|
+
};
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
async function runDoctorChecks() {
|
|
3475
|
+
const checks = await Promise.all([
|
|
3476
|
+
checkNodeVersion(),
|
|
3477
|
+
checkFfmpeg(),
|
|
3478
|
+
checkFfprobe(),
|
|
3479
|
+
checkWhisperModel(),
|
|
3480
|
+
checkAnthropicKey(),
|
|
3481
|
+
checkOpenAIKey(),
|
|
3482
|
+
checkDiskSpace()
|
|
3483
|
+
]);
|
|
3484
|
+
const passed = checks.filter((c) => c.status === "pass").length;
|
|
3485
|
+
const warned = checks.filter((c) => c.status === "warn").length;
|
|
3486
|
+
const failed = checks.filter((c) => c.status === "fail").length;
|
|
3487
|
+
return { checks, passed, warned, failed };
|
|
3488
|
+
}
|
|
3489
|
+
|
|
3490
|
+
// src/cli/init.ts
|
|
3491
|
+
import { existsSync as existsSync6 } from "fs";
|
|
3492
|
+
import { readFile as readFile2, writeFile as writeFile2, appendFile as appendFile2 } from "fs/promises";
|
|
3493
|
+
import { join as join6, resolve as resolve2 } from "path";
|
|
3494
|
+
var CONFIG_FILENAME = ".markupr.json";
|
|
3495
|
+
function createDefaultConfig(outputDir) {
|
|
3496
|
+
return {
|
|
3497
|
+
outputDir,
|
|
3498
|
+
recording: {
|
|
3499
|
+
skipFrames: false,
|
|
3500
|
+
template: "markdown"
|
|
3501
|
+
},
|
|
3502
|
+
apiKeys: {
|
|
3503
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
3504
|
+
openai: "OPENAI_API_KEY"
|
|
3505
|
+
}
|
|
3506
|
+
};
|
|
3507
|
+
}
|
|
3508
|
+
async function updateGitignore(projectDir, outputDir) {
|
|
3509
|
+
const gitignorePath = join6(projectDir, ".gitignore");
|
|
3510
|
+
const entriesToAdd = [outputDir, ".markupr-watch.log"];
|
|
3511
|
+
let existingContent = "";
|
|
3512
|
+
if (existsSync6(gitignorePath)) {
|
|
3513
|
+
existingContent = await readFile2(gitignorePath, "utf-8");
|
|
3514
|
+
}
|
|
3515
|
+
const lines = existingContent.split("\n");
|
|
3516
|
+
const missingEntries = entriesToAdd.filter(
|
|
3517
|
+
(entry) => !lines.some((line) => line.trim() === entry)
|
|
3518
|
+
);
|
|
3519
|
+
if (missingEntries.length === 0) {
|
|
3520
|
+
return false;
|
|
3521
|
+
}
|
|
3522
|
+
const block = [
|
|
3523
|
+
"",
|
|
3524
|
+
"# markupr output",
|
|
3525
|
+
...missingEntries,
|
|
3526
|
+
""
|
|
3527
|
+
].join("\n");
|
|
3528
|
+
await appendFile2(gitignorePath, block, "utf-8");
|
|
3529
|
+
return true;
|
|
3530
|
+
}
|
|
3531
|
+
async function runInit(options) {
|
|
3532
|
+
const projectDir = resolve2(options.directory);
|
|
3533
|
+
const configPath = join6(projectDir, CONFIG_FILENAME);
|
|
3534
|
+
if (existsSync6(configPath) && !options.force) {
|
|
3535
|
+
return {
|
|
3536
|
+
configPath,
|
|
3537
|
+
created: false,
|
|
3538
|
+
gitignoreUpdated: false,
|
|
3539
|
+
alreadyExists: true
|
|
3540
|
+
};
|
|
3541
|
+
}
|
|
3542
|
+
const config = createDefaultConfig(options.outputDir);
|
|
3543
|
+
const content = JSON.stringify(config, null, 2) + "\n";
|
|
3544
|
+
await writeFile2(configPath, content, "utf-8");
|
|
3545
|
+
let gitignoreUpdated = false;
|
|
3546
|
+
if (!options.skipGitignore) {
|
|
3547
|
+
try {
|
|
3548
|
+
gitignoreUpdated = await updateGitignore(projectDir, options.outputDir);
|
|
3549
|
+
} catch {
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
return {
|
|
3553
|
+
configPath,
|
|
3554
|
+
created: true,
|
|
3555
|
+
gitignoreUpdated,
|
|
3556
|
+
alreadyExists: false
|
|
3557
|
+
};
|
|
3558
|
+
}
|
|
1862
3559
|
|
|
1863
3560
|
// src/cli/index.ts
|
|
1864
|
-
var VERSION = true ? "2.
|
|
3561
|
+
var VERSION = true ? "2.6.0" : "0.0.0-dev";
|
|
1865
3562
|
var SYMBOLS = {
|
|
1866
3563
|
check: "\u2714",
|
|
1867
3564
|
// checkmark
|
|
@@ -1873,8 +3570,10 @@ var SYMBOLS = {
|
|
|
1873
3570
|
// bullet
|
|
1874
3571
|
ellipsis: "\u2026",
|
|
1875
3572
|
// ellipsis
|
|
1876
|
-
line: "\u2500"
|
|
3573
|
+
line: "\u2500",
|
|
1877
3574
|
// horizontal line
|
|
3575
|
+
warn: "\u26A0"
|
|
3576
|
+
// warning sign
|
|
1878
3577
|
};
|
|
1879
3578
|
function banner() {
|
|
1880
3579
|
console.log();
|
|
@@ -1891,6 +3590,9 @@ function success(message) {
|
|
|
1891
3590
|
function fail(message) {
|
|
1892
3591
|
console.log(` ${SYMBOLS.cross} ${message}`);
|
|
1893
3592
|
}
|
|
3593
|
+
function warn(message) {
|
|
3594
|
+
console.log(` ${SYMBOLS.warn} ${message}`);
|
|
3595
|
+
}
|
|
1894
3596
|
var activePipeline = null;
|
|
1895
3597
|
function setupSignalHandlers() {
|
|
1896
3598
|
const handler = async () => {
|
|
@@ -1906,12 +3608,12 @@ function setupSignalHandlers() {
|
|
|
1906
3608
|
setupSignalHandlers();
|
|
1907
3609
|
var program = new Command();
|
|
1908
3610
|
program.name("markupr").description("Analyze screen recordings and generate AI-ready Markdown reports").version(VERSION, "-v, --version").showHelpAfterError("(use --help for available options)");
|
|
1909
|
-
program.command("analyze").description("Analyze a video recording and generate a structured feedback report").argument("<video-file>", "Path to the video file to analyze").option("--audio <file>", "Separate audio file (if not embedded in video)").option("--output <dir>", "Output directory", "./markupr-output").option("--whisper-model <path>", "Path to Whisper model file").option("--openai-key <key>", "OpenAI API key for cloud transcription (prefer OPENAI_API_KEY env var)").option("--no-frames", "Skip frame extraction").option("--verbose", "Verbose output", false).action(async (videoFile, options) => {
|
|
3611
|
+
program.command("analyze").description("Analyze a video recording and generate a structured feedback report").argument("<video-file>", "Path to the video file to analyze").option("--audio <file>", "Separate audio file (if not embedded in video)").option("--output <dir>", "Output directory", "./markupr-output").option("--whisper-model <path>", "Path to Whisper model file").option("--openai-key <key>", "OpenAI API key for cloud transcription (prefer OPENAI_API_KEY env var)").option("--no-frames", "Skip frame extraction").option("--template <name>", `Output template (${templateRegistry.list().join(", ")})`, "markdown").option("--verbose", "Verbose output", false).action(async (videoFile, options) => {
|
|
1910
3612
|
banner();
|
|
1911
|
-
const videoPath =
|
|
1912
|
-
const outputDir =
|
|
1913
|
-
const audioPath = options.audio ?
|
|
1914
|
-
const whisperModelPath = options.whisperModel ?
|
|
3613
|
+
const videoPath = resolve3(videoFile);
|
|
3614
|
+
const outputDir = resolve3(options.output);
|
|
3615
|
+
const audioPath = options.audio ? resolve3(options.audio) : void 0;
|
|
3616
|
+
const whisperModelPath = options.whisperModel ? resolve3(options.whisperModel) : void 0;
|
|
1915
3617
|
let openaiKey;
|
|
1916
3618
|
if (options.openaiKey) {
|
|
1917
3619
|
console.warn(" WARNING: Passing API keys via CLI args is insecure (visible in ps, shell history).");
|
|
@@ -1921,15 +3623,15 @@ program.command("analyze").description("Analyze a video recording and generate a
|
|
|
1921
3623
|
} else if (process.env.OPENAI_API_KEY) {
|
|
1922
3624
|
openaiKey = process.env.OPENAI_API_KEY;
|
|
1923
3625
|
}
|
|
1924
|
-
if (!
|
|
3626
|
+
if (!existsSync7(videoPath)) {
|
|
1925
3627
|
fail(`Video file not found: ${videoPath}`);
|
|
1926
3628
|
process.exit(EXIT_USER_ERROR);
|
|
1927
3629
|
}
|
|
1928
|
-
if (audioPath && !
|
|
3630
|
+
if (audioPath && !existsSync7(audioPath)) {
|
|
1929
3631
|
fail(`Audio file not found: ${audioPath}`);
|
|
1930
3632
|
process.exit(EXIT_USER_ERROR);
|
|
1931
3633
|
}
|
|
1932
|
-
if (whisperModelPath && !
|
|
3634
|
+
if (whisperModelPath && !existsSync7(whisperModelPath)) {
|
|
1933
3635
|
fail(`Whisper model not found: ${whisperModelPath}`);
|
|
1934
3636
|
process.exit(EXIT_USER_ERROR);
|
|
1935
3637
|
}
|
|
@@ -1939,6 +3641,13 @@ program.command("analyze").description("Analyze a video recording and generate a
|
|
|
1939
3641
|
}
|
|
1940
3642
|
step(`Output: ${outputDir}`);
|
|
1941
3643
|
console.log();
|
|
3644
|
+
if (!templateRegistry.has(options.template)) {
|
|
3645
|
+
fail(`Unknown template "${options.template}". Available: ${templateRegistry.list().join(", ")}`);
|
|
3646
|
+
process.exit(EXIT_USER_ERROR);
|
|
3647
|
+
}
|
|
3648
|
+
if (options.template !== "markdown") {
|
|
3649
|
+
step(`Template: ${options.template}`);
|
|
3650
|
+
}
|
|
1942
3651
|
const pipeline = new CLIPipeline(
|
|
1943
3652
|
{
|
|
1944
3653
|
videoPath,
|
|
@@ -1947,12 +3656,12 @@ program.command("analyze").description("Analyze a video recording and generate a
|
|
|
1947
3656
|
whisperModelPath,
|
|
1948
3657
|
openaiKey,
|
|
1949
3658
|
skipFrames: !options.frames,
|
|
3659
|
+
template: options.template,
|
|
1950
3660
|
verbose: options.verbose
|
|
1951
3661
|
},
|
|
1952
3662
|
options.verbose ? step : () => {
|
|
1953
3663
|
},
|
|
1954
3664
|
step
|
|
1955
|
-
// progress — always visible
|
|
1956
3665
|
);
|
|
1957
3666
|
activePipeline = pipeline;
|
|
1958
3667
|
try {
|
|
@@ -1992,6 +3701,261 @@ program.command("analyze").description("Analyze a video recording and generate a
|
|
|
1992
3701
|
activePipeline = null;
|
|
1993
3702
|
}
|
|
1994
3703
|
});
|
|
3704
|
+
program.command("watch").description("Watch a directory for new recordings and auto-process them").argument("[directory]", "Directory to watch for recordings", ".").option("--output <dir>", "Output directory (default: <watched-dir>/markupr-output)").option("--whisper-model <path>", "Path to Whisper model file").option("--openai-key <key>", "OpenAI API key for cloud transcription (prefer OPENAI_API_KEY env var)").option("--no-frames", "Skip frame extraction").option("--verbose", "Verbose output", false).action(async (directory, options) => {
|
|
3705
|
+
banner();
|
|
3706
|
+
const watchDir = resolve3(directory);
|
|
3707
|
+
let openaiKey;
|
|
3708
|
+
if (options.openaiKey) {
|
|
3709
|
+
console.warn(" WARNING: Passing API keys via CLI args is insecure (visible in ps, shell history).");
|
|
3710
|
+
console.warn(" Use OPENAI_API_KEY env var instead.");
|
|
3711
|
+
console.warn();
|
|
3712
|
+
openaiKey = options.openaiKey;
|
|
3713
|
+
} else if (process.env.OPENAI_API_KEY) {
|
|
3714
|
+
openaiKey = process.env.OPENAI_API_KEY;
|
|
3715
|
+
}
|
|
3716
|
+
if (!existsSync7(watchDir)) {
|
|
3717
|
+
fail(`Directory not found: ${watchDir}`);
|
|
3718
|
+
process.exit(EXIT_USER_ERROR);
|
|
3719
|
+
}
|
|
3720
|
+
const watchMode = new WatchMode(
|
|
3721
|
+
{
|
|
3722
|
+
watchDir,
|
|
3723
|
+
outputDir: options.output ? resolve3(options.output) : void 0,
|
|
3724
|
+
whisperModelPath: options.whisperModel ? resolve3(options.whisperModel) : void 0,
|
|
3725
|
+
openaiKey,
|
|
3726
|
+
skipFrames: !options.frames,
|
|
3727
|
+
verbose: options.verbose
|
|
3728
|
+
},
|
|
3729
|
+
{
|
|
3730
|
+
onLog: step,
|
|
3731
|
+
onFileDetected: (filePath) => {
|
|
3732
|
+
step(`Detected: ${filePath}`);
|
|
3733
|
+
},
|
|
3734
|
+
onProcessingStart: (filePath) => {
|
|
3735
|
+
console.log();
|
|
3736
|
+
step(`Processing: ${filePath}`);
|
|
3737
|
+
},
|
|
3738
|
+
onProcessingComplete: (filePath, outputPath) => {
|
|
3739
|
+
success(`Done: ${filePath}`);
|
|
3740
|
+
step(`Output: ${outputPath}`);
|
|
3741
|
+
},
|
|
3742
|
+
onProcessingError: (filePath, error) => {
|
|
3743
|
+
fail(`Failed: ${filePath} \u2014 ${error.message}`);
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
);
|
|
3747
|
+
const shutdown = () => {
|
|
3748
|
+
console.log("\n Stopping watcher...");
|
|
3749
|
+
watchMode.stop();
|
|
3750
|
+
};
|
|
3751
|
+
process.on("SIGINT", shutdown);
|
|
3752
|
+
process.on("SIGTERM", shutdown);
|
|
3753
|
+
step(`Watching for recordings in: ${watchDir}`);
|
|
3754
|
+
step("Press Ctrl+C to stop");
|
|
3755
|
+
console.log();
|
|
3756
|
+
try {
|
|
3757
|
+
await watchMode.start();
|
|
3758
|
+
success("Watch mode stopped.");
|
|
3759
|
+
} catch (error) {
|
|
3760
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3761
|
+
fail(`Watch mode error: ${message}`);
|
|
3762
|
+
process.exit(EXIT_SYSTEM_ERROR);
|
|
3763
|
+
}
|
|
3764
|
+
});
|
|
3765
|
+
program.command("doctor").description("Check your environment for markupr dependencies and configuration").action(async () => {
|
|
3766
|
+
banner();
|
|
3767
|
+
step("Checking environment...");
|
|
3768
|
+
console.log();
|
|
3769
|
+
const result = await runDoctorChecks();
|
|
3770
|
+
for (const check of result.checks) {
|
|
3771
|
+
if (check.status === "pass") {
|
|
3772
|
+
success(`${check.name}: ${check.message}`);
|
|
3773
|
+
} else if (check.status === "warn") {
|
|
3774
|
+
warn(`${check.name}: ${check.message}`);
|
|
3775
|
+
} else {
|
|
3776
|
+
fail(`${check.name}: ${check.message}`);
|
|
3777
|
+
}
|
|
3778
|
+
if (check.hint) {
|
|
3779
|
+
for (const line of check.hint.split("\n")) {
|
|
3780
|
+
console.log(` ${line}`);
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
console.log();
|
|
3785
|
+
console.log(` ${SYMBOLS.line.repeat(40)}`);
|
|
3786
|
+
console.log(` ${result.passed} passed, ${result.warned} warnings, ${result.failed} failed`);
|
|
3787
|
+
console.log();
|
|
3788
|
+
if (result.failed > 0) {
|
|
3789
|
+
fail("Some required checks failed. Fix them to use markupr.");
|
|
3790
|
+
process.exit(EXIT_USER_ERROR);
|
|
3791
|
+
} else if (result.warned > 0) {
|
|
3792
|
+
success("markupr is ready (some optional features are not configured).");
|
|
3793
|
+
} else {
|
|
3794
|
+
success("markupr is fully configured and ready to go!");
|
|
3795
|
+
}
|
|
3796
|
+
console.log();
|
|
3797
|
+
});
|
|
3798
|
+
program.command("init").description("Create a .markupr.json config file in the current project").option("--output <dir>", "Output directory for feedback sessions", "./markupr-output").option("--no-gitignore", "Skip updating .gitignore").option("--force", "Overwrite existing config file", false).action(async (options) => {
|
|
3799
|
+
banner();
|
|
3800
|
+
const result = await runInit({
|
|
3801
|
+
directory: process.cwd(),
|
|
3802
|
+
outputDir: options.output,
|
|
3803
|
+
skipGitignore: !options.gitignore,
|
|
3804
|
+
force: options.force
|
|
3805
|
+
});
|
|
3806
|
+
if (result.alreadyExists) {
|
|
3807
|
+
warn(`${CONFIG_FILENAME} already exists at ${result.configPath}`);
|
|
3808
|
+
console.log(" Use --force to overwrite.");
|
|
3809
|
+
console.log();
|
|
3810
|
+
process.exit(EXIT_USER_ERROR);
|
|
3811
|
+
}
|
|
3812
|
+
success(`Created ${result.configPath}`);
|
|
3813
|
+
if (result.gitignoreUpdated) {
|
|
3814
|
+
success("Updated .gitignore with markupr output directory");
|
|
3815
|
+
}
|
|
3816
|
+
console.log();
|
|
3817
|
+
step("Next steps:");
|
|
3818
|
+
console.log(" 1. Run `markupr doctor` to verify your environment");
|
|
3819
|
+
console.log(" 2. Record a session with the markupr desktop app or screen recorder");
|
|
3820
|
+
console.log(" 3. Run `markupr analyze <video-file>` to generate a feedback report");
|
|
3821
|
+
console.log();
|
|
3822
|
+
});
|
|
3823
|
+
var pushCmd = program.command("push").description("Push feedback to external services");
|
|
3824
|
+
pushCmd.command("linear").description("Create Linear issues from a markupr feedback report").argument("<report>", "Path to the markupr markdown report").requiredOption("--team <key>", "Linear team key (e.g., ENG, DES)").option("--token <token>", "Linear API key (prefer LINEAR_API_KEY env var)").option("--project <name>", "Linear project name to assign issues to").option("--dry-run", "Show what would be created without creating anything", false).action(async (report, options) => {
|
|
3825
|
+
banner();
|
|
3826
|
+
const reportPath = resolve3(report);
|
|
3827
|
+
if (!existsSync7(reportPath)) {
|
|
3828
|
+
fail(`Report file not found: ${reportPath}`);
|
|
3829
|
+
process.exit(EXIT_USER_ERROR);
|
|
3830
|
+
}
|
|
3831
|
+
if (options.token) {
|
|
3832
|
+
console.warn(" WARNING: Passing tokens via CLI args is insecure (visible in ps, shell history).");
|
|
3833
|
+
console.warn(" Use LINEAR_API_KEY env var instead.");
|
|
3834
|
+
console.warn();
|
|
3835
|
+
}
|
|
3836
|
+
const apiToken = options.token || process.env.LINEAR_API_KEY;
|
|
3837
|
+
if (!apiToken) {
|
|
3838
|
+
fail("No Linear API token found.");
|
|
3839
|
+
console.log(" Provide via --token flag or LINEAR_API_KEY env var.");
|
|
3840
|
+
process.exit(EXIT_USER_ERROR);
|
|
3841
|
+
}
|
|
3842
|
+
const { LinearIssueCreator: LinearIssueCreator2 } = await Promise.resolve().then(() => (init_LinearIssueCreator(), LinearIssueCreator_exports));
|
|
3843
|
+
try {
|
|
3844
|
+
step(`Report: ${reportPath}`);
|
|
3845
|
+
step(`Team: ${options.team}`);
|
|
3846
|
+
if (options.project) {
|
|
3847
|
+
step(`Project: ${options.project}`);
|
|
3848
|
+
}
|
|
3849
|
+
if (options.dryRun) {
|
|
3850
|
+
step("Mode: DRY RUN");
|
|
3851
|
+
}
|
|
3852
|
+
console.log();
|
|
3853
|
+
step("Parsing feedback report...");
|
|
3854
|
+
const creator = new LinearIssueCreator2(apiToken);
|
|
3855
|
+
const result = await creator.pushReport(reportPath, {
|
|
3856
|
+
token: apiToken,
|
|
3857
|
+
teamKey: options.team,
|
|
3858
|
+
projectName: options.project,
|
|
3859
|
+
dryRun: options.dryRun
|
|
3860
|
+
});
|
|
3861
|
+
console.log();
|
|
3862
|
+
if (options.dryRun) {
|
|
3863
|
+
success(`Dry run complete \u2014 ${result.created} issue(s) would be created:`);
|
|
3864
|
+
console.log();
|
|
3865
|
+
for (const issue of result.issues) {
|
|
3866
|
+
if (issue.success) {
|
|
3867
|
+
step(`${issue.identifier}: ${issue.issueUrl}`);
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
} else {
|
|
3871
|
+
success(`Created ${result.created} issue(s):`);
|
|
3872
|
+
console.log();
|
|
3873
|
+
for (const issue of result.issues) {
|
|
3874
|
+
if (issue.success) {
|
|
3875
|
+
step(`${issue.identifier}: ${issue.issueUrl}`);
|
|
3876
|
+
}
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
if (result.failed > 0) {
|
|
3880
|
+
console.log();
|
|
3881
|
+
fail(`${result.failed} error(s):`);
|
|
3882
|
+
for (const issue of result.issues) {
|
|
3883
|
+
if (!issue.success) {
|
|
3884
|
+
fail(` ${issue.error}`);
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
console.log();
|
|
3889
|
+
} catch (error) {
|
|
3890
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3891
|
+
fail(message);
|
|
3892
|
+
process.exit(EXIT_USER_ERROR);
|
|
3893
|
+
}
|
|
3894
|
+
});
|
|
3895
|
+
pushCmd.command("github").description("Create GitHub issues from a markupr feedback report").argument("<report>", "Path to the markupr markdown report").requiredOption("--repo <owner/repo>", "Target GitHub repository (e.g., myorg/myapp)").option("--token <token>", "GitHub token (prefer GITHUB_TOKEN env var or gh auth login)").option("--items <ids...>", "Specific FB-XXX item IDs to push (default: all)").option("--dry-run", "Show what would be created without creating anything", false).action(async (report, options) => {
|
|
3896
|
+
banner();
|
|
3897
|
+
const reportPath = resolve3(report);
|
|
3898
|
+
if (!existsSync7(reportPath)) {
|
|
3899
|
+
fail(`Report file not found: ${reportPath}`);
|
|
3900
|
+
process.exit(EXIT_USER_ERROR);
|
|
3901
|
+
}
|
|
3902
|
+
if (options.token) {
|
|
3903
|
+
console.warn(" WARNING: Passing tokens via CLI args is insecure (visible in ps, shell history).");
|
|
3904
|
+
console.warn(" Use GITHUB_TOKEN env var or `gh auth login` instead.");
|
|
3905
|
+
console.warn();
|
|
3906
|
+
}
|
|
3907
|
+
const { resolveAuth: resolveAuth2, parseRepoString: parseRepoString2, pushToGitHub: pushToGitHub2 } = await Promise.resolve().then(() => (init_GitHubIssueCreator(), GitHubIssueCreator_exports));
|
|
3908
|
+
try {
|
|
3909
|
+
const parsedRepo = parseRepoString2(options.repo);
|
|
3910
|
+
const auth = await resolveAuth2(options.token);
|
|
3911
|
+
step(`Report: ${reportPath}`);
|
|
3912
|
+
step(`Repo: ${parsedRepo.owner}/${parsedRepo.repo}`);
|
|
3913
|
+
step(`Auth: ${auth.source}`);
|
|
3914
|
+
if (options.dryRun) {
|
|
3915
|
+
step("Mode: DRY RUN");
|
|
3916
|
+
}
|
|
3917
|
+
console.log();
|
|
3918
|
+
step("Parsing feedback report and creating issues...");
|
|
3919
|
+
const result = await pushToGitHub2({
|
|
3920
|
+
repo: parsedRepo,
|
|
3921
|
+
auth,
|
|
3922
|
+
reportPath,
|
|
3923
|
+
dryRun: options.dryRun,
|
|
3924
|
+
items: options.items
|
|
3925
|
+
});
|
|
3926
|
+
console.log();
|
|
3927
|
+
if (options.dryRun) {
|
|
3928
|
+
success(`Dry run complete \u2014 ${result.created.length} issue(s) would be created:`);
|
|
3929
|
+
console.log();
|
|
3930
|
+
for (const issue of result.created) {
|
|
3931
|
+
step(` ${issue.title}`);
|
|
3932
|
+
}
|
|
3933
|
+
} else {
|
|
3934
|
+
success(`Created ${result.created.length} issue(s):`);
|
|
3935
|
+
console.log();
|
|
3936
|
+
for (const issue of result.created) {
|
|
3937
|
+
step(`#${issue.number}: ${issue.title}`);
|
|
3938
|
+
step(` ${issue.url}`);
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
if (result.labelsCreated.length > 0) {
|
|
3942
|
+
console.log();
|
|
3943
|
+
step(`Labels created: ${result.labelsCreated.join(", ")}`);
|
|
3944
|
+
}
|
|
3945
|
+
if (result.errors.length > 0) {
|
|
3946
|
+
console.log();
|
|
3947
|
+
fail(`${result.errors.length} error(s):`);
|
|
3948
|
+
for (const err of result.errors) {
|
|
3949
|
+
fail(` ${err.itemId}: ${err.error}`);
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
console.log();
|
|
3953
|
+
} catch (error) {
|
|
3954
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3955
|
+
fail(message);
|
|
3956
|
+
process.exit(EXIT_USER_ERROR);
|
|
3957
|
+
}
|
|
3958
|
+
});
|
|
1995
3959
|
if (process.argv.length <= 2) {
|
|
1996
3960
|
banner();
|
|
1997
3961
|
program.outputHelp();
|