@meltstudio/meltctl 4.25.0 → 4.27.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/dist/commands/audit.d.ts +7 -0
- package/dist/commands/audit.js +163 -0
- package/dist/commands/plan.d.ts +6 -0
- package/dist/commands/plan.js +153 -0
- package/dist/index.js +28 -16
- package/dist/utils/api.d.ts +2 -0
- package/dist/utils/api.js +31 -0
- package/dist/utils/git.d.ts +9 -0
- package/dist/utils/git.js +76 -0
- package/package.json +1 -1
- package/dist/commands/report.d.ts +0 -7
- package/dist/commands/report.js +0 -307
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { getToken, tokenFetch } from '../utils/api.js';
|
|
5
|
+
import { getGitBranch, getGitCommit, getGitRepository, getProjectName, findMdFiles, } from '../utils/git.js';
|
|
6
|
+
function detectAuditType(filename) {
|
|
7
|
+
return filename.toLowerCase().includes('ux-audit') ? 'ux-audit' : 'audit';
|
|
8
|
+
}
|
|
9
|
+
async function autoDetectAuditFile() {
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
const auditsDir = path.join(cwd, '.audits');
|
|
12
|
+
const auditFiles = await findMdFiles(auditsDir);
|
|
13
|
+
if (auditFiles.length > 0) {
|
|
14
|
+
return auditFiles[0] ?? null;
|
|
15
|
+
}
|
|
16
|
+
const candidates = ['AUDIT.md', 'UX-AUDIT.md'];
|
|
17
|
+
for (const name of candidates) {
|
|
18
|
+
const filePath = path.join(cwd, name);
|
|
19
|
+
if (await fs.pathExists(filePath)) {
|
|
20
|
+
return filePath;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export async function auditSubmitCommand(file) {
|
|
26
|
+
const token = await getToken();
|
|
27
|
+
let filePath;
|
|
28
|
+
if (file) {
|
|
29
|
+
filePath = path.resolve(file);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
const detected = await autoDetectAuditFile();
|
|
33
|
+
if (!detected) {
|
|
34
|
+
console.error(chalk.red('No audit file found. Provide a file path or create an audit in the .audits/ directory.'));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
filePath = detected;
|
|
38
|
+
console.log(chalk.dim(`Auto-detected audit file: ${path.relative(process.cwd(), filePath)}`));
|
|
39
|
+
}
|
|
40
|
+
if (!(await fs.pathExists(filePath))) {
|
|
41
|
+
console.error(chalk.red(`File not found: ${filePath}`));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
45
|
+
const filename = path.basename(filePath);
|
|
46
|
+
const auditType = detectAuditType(filename);
|
|
47
|
+
const project = getProjectName();
|
|
48
|
+
const branch = getGitBranch();
|
|
49
|
+
const commit = getGitCommit();
|
|
50
|
+
const repo = getGitRepository();
|
|
51
|
+
const payload = {
|
|
52
|
+
type: auditType,
|
|
53
|
+
project,
|
|
54
|
+
repository: repo?.slug ?? null,
|
|
55
|
+
repositoryUrl: repo?.url ?? null,
|
|
56
|
+
branch,
|
|
57
|
+
commit,
|
|
58
|
+
content,
|
|
59
|
+
metadata: { filename },
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
const res = await tokenFetch(token, '/audits', {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { 'Content-Type': 'application/json' },
|
|
65
|
+
body: JSON.stringify(payload),
|
|
66
|
+
});
|
|
67
|
+
if (res.ok) {
|
|
68
|
+
const body = (await res.json());
|
|
69
|
+
console.log(chalk.green(`\n ✓ Audit submitted! ID: ${body.id}\n`));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const body = (await res.json());
|
|
73
|
+
console.error(chalk.red(`\nFailed to submit audit: ${body.error ?? res.statusText}`));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
console.error(chalk.red(`Failed to submit audit: ${error instanceof Error ? error.message : 'Unknown error'}`));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function auditListCommand(options) {
|
|
83
|
+
const token = await getToken();
|
|
84
|
+
const params = new URLSearchParams();
|
|
85
|
+
if (options.type)
|
|
86
|
+
params.set('type', options.type);
|
|
87
|
+
if (options.repository)
|
|
88
|
+
params.set('repository', options.repository);
|
|
89
|
+
if (options.latest)
|
|
90
|
+
params.set('latest', 'true');
|
|
91
|
+
if (options.limit)
|
|
92
|
+
params.set('limit', options.limit);
|
|
93
|
+
const query = params.toString();
|
|
94
|
+
const urlPath = `/audits${query ? `?${query}` : ''}`;
|
|
95
|
+
try {
|
|
96
|
+
const res = await tokenFetch(token, urlPath);
|
|
97
|
+
if (res.status === 403) {
|
|
98
|
+
console.error(chalk.red('Access denied. Only Team Managers can list audits.'));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
const body = (await res.json());
|
|
103
|
+
console.error(chalk.red(`Failed to list audits: ${body.error ?? res.statusText}`));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const body = (await res.json());
|
|
107
|
+
if (body.audits.length === 0) {
|
|
108
|
+
console.log(chalk.dim('\n No audits found.\n'));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const typeLabels = {
|
|
112
|
+
audit: 'Tech Audit',
|
|
113
|
+
'ux-audit': 'UX Audit',
|
|
114
|
+
};
|
|
115
|
+
if (options.latest) {
|
|
116
|
+
console.log(chalk.bold(`\n Latest Audits (${body.count}):\n`));
|
|
117
|
+
console.log(chalk.dim(` ${'TYPE'.padEnd(12)} ${'REPOSITORY'.padEnd(40)} ${'AGE'.padEnd(10)} ${'AUTHOR'.padEnd(30)} DATE`));
|
|
118
|
+
console.log();
|
|
119
|
+
for (const r of body.audits) {
|
|
120
|
+
const createdAt = new Date(r.created_at ?? r.createdAt);
|
|
121
|
+
const daysAgo = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
|
122
|
+
const date = createdAt.toLocaleDateString('en-US', {
|
|
123
|
+
month: 'short',
|
|
124
|
+
day: 'numeric',
|
|
125
|
+
year: 'numeric',
|
|
126
|
+
});
|
|
127
|
+
const repo = r.repository ?? r.project;
|
|
128
|
+
const label = typeLabels[r.type] ?? r.type;
|
|
129
|
+
const typeColor = r.type === 'ux-audit' ? chalk.yellow : chalk.magenta;
|
|
130
|
+
const ageText = daysAgo === 0 ? 'today' : `${daysAgo}d ago`;
|
|
131
|
+
const ageColor = daysAgo <= 7 ? chalk.green : daysAgo <= 30 ? chalk.yellow : chalk.red;
|
|
132
|
+
console.log(` ${typeColor(label.padEnd(12))} ${chalk.white(repo.padEnd(40))} ${ageColor(ageText.padEnd(10))} ${chalk.dim(r.author.padEnd(30))} ${chalk.dim(date)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
console.log(chalk.bold(`\n Audits (${body.count}):\n`));
|
|
137
|
+
const hdr = ` ${'TYPE'.padEnd(12)} ${'REPOSITORY'.padEnd(40)} ${'AUTHOR'.padEnd(30)} DATE`;
|
|
138
|
+
console.log(chalk.dim(hdr));
|
|
139
|
+
console.log();
|
|
140
|
+
for (const r of body.audits) {
|
|
141
|
+
const date = new Date(r.createdAt).toLocaleDateString('en-US', {
|
|
142
|
+
month: 'short',
|
|
143
|
+
day: 'numeric',
|
|
144
|
+
year: 'numeric',
|
|
145
|
+
hour: '2-digit',
|
|
146
|
+
minute: '2-digit',
|
|
147
|
+
});
|
|
148
|
+
const repo = r.repository ?? r.project;
|
|
149
|
+
const label = typeLabels[r.type] ?? r.type;
|
|
150
|
+
const typeColor = r.type === 'ux-audit' ? chalk.yellow : chalk.magenta;
|
|
151
|
+
console.log(` ${typeColor(label.padEnd(12))} ${chalk.white(repo.padEnd(40))} ${chalk.dim(r.author.padEnd(30))} ${chalk.dim(date)}`);
|
|
152
|
+
if (r.branch && r.branch !== 'main') {
|
|
153
|
+
console.log(` ${' '.padEnd(12)} ${chalk.dim(`branch: ${r.branch} commit: ${r.commit ?? 'N/A'}`)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
console.log();
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
console.error(chalk.red(`Failed to list audits: ${error instanceof Error ? error.message : 'Unknown error'}`));
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { getToken, tokenFetch } from '../utils/api.js';
|
|
5
|
+
import { getGitBranch, getGitCommit, getGitRepository, getProjectName, extractTicketId, findMdFiles, } from '../utils/git.js';
|
|
6
|
+
function extractFrontmatterStatus(content) {
|
|
7
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
8
|
+
if (!match)
|
|
9
|
+
return null;
|
|
10
|
+
const statusMatch = match[1].match(/^status:\s*(.+)$/m);
|
|
11
|
+
return statusMatch ? statusMatch[1].trim() : null;
|
|
12
|
+
}
|
|
13
|
+
async function autoDetectPlanFile() {
|
|
14
|
+
const cwd = process.cwd();
|
|
15
|
+
const plansDir = path.join(cwd, '.plans');
|
|
16
|
+
const mdFiles = await findMdFiles(plansDir);
|
|
17
|
+
if (mdFiles.length === 0) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const branch = getGitBranch();
|
|
21
|
+
const ticketId = extractTicketId(branch);
|
|
22
|
+
if (ticketId) {
|
|
23
|
+
const ticketLower = ticketId.toLowerCase();
|
|
24
|
+
const match = mdFiles.find(f => path.basename(f).toLowerCase().includes(ticketLower));
|
|
25
|
+
if (match) {
|
|
26
|
+
return match;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return mdFiles[0] ?? null;
|
|
30
|
+
}
|
|
31
|
+
export async function planSubmitCommand(file) {
|
|
32
|
+
const token = await getToken();
|
|
33
|
+
let filePath;
|
|
34
|
+
if (file) {
|
|
35
|
+
filePath = path.resolve(file);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const detected = await autoDetectPlanFile();
|
|
39
|
+
if (!detected) {
|
|
40
|
+
console.error(chalk.red('No plan file found. Provide a file path or create a plan in the .plans/ directory.'));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
filePath = detected;
|
|
44
|
+
console.log(chalk.dim(`Auto-detected plan file: ${path.relative(process.cwd(), filePath)}`));
|
|
45
|
+
}
|
|
46
|
+
if (!(await fs.pathExists(filePath))) {
|
|
47
|
+
console.error(chalk.red(`File not found: ${filePath}`));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
51
|
+
const filename = path.basename(filePath);
|
|
52
|
+
const project = getProjectName();
|
|
53
|
+
const branch = getGitBranch();
|
|
54
|
+
const commit = getGitCommit();
|
|
55
|
+
const repo = getGitRepository();
|
|
56
|
+
const ticket = extractTicketId(branch) ?? extractTicketId(filename);
|
|
57
|
+
const status = extractFrontmatterStatus(content);
|
|
58
|
+
const payload = {
|
|
59
|
+
project,
|
|
60
|
+
repository: repo?.slug ?? null,
|
|
61
|
+
repositoryUrl: repo?.url ?? null,
|
|
62
|
+
branch,
|
|
63
|
+
commit,
|
|
64
|
+
content,
|
|
65
|
+
metadata: { filename },
|
|
66
|
+
};
|
|
67
|
+
if (ticket)
|
|
68
|
+
payload.ticket = ticket;
|
|
69
|
+
if (status)
|
|
70
|
+
payload.status = status;
|
|
71
|
+
try {
|
|
72
|
+
const res = await tokenFetch(token, '/plans', {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify(payload),
|
|
76
|
+
});
|
|
77
|
+
if (res.ok) {
|
|
78
|
+
const body = (await res.json());
|
|
79
|
+
if (body.created) {
|
|
80
|
+
console.log(chalk.green(`\n ✓ Plan submitted! ID: ${body.id}\n`));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.log(chalk.green(`\n ✓ Plan updated! ID: ${body.id}\n`));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const body = (await res.json());
|
|
88
|
+
console.error(chalk.red(`\nFailed to submit plan: ${body.error ?? res.statusText}`));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
console.error(chalk.red(`Failed to submit plan: ${error instanceof Error ? error.message : 'Unknown error'}`));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function planListCommand(options) {
|
|
98
|
+
const token = await getToken();
|
|
99
|
+
const params = new URLSearchParams();
|
|
100
|
+
if (options.repository)
|
|
101
|
+
params.set('repository', options.repository);
|
|
102
|
+
if (options.author)
|
|
103
|
+
params.set('author', options.author);
|
|
104
|
+
if (options.limit)
|
|
105
|
+
params.set('limit', options.limit);
|
|
106
|
+
const query = params.toString();
|
|
107
|
+
const urlPath = `/plans${query ? `?${query}` : ''}`;
|
|
108
|
+
try {
|
|
109
|
+
const res = await tokenFetch(token, urlPath);
|
|
110
|
+
if (res.status === 403) {
|
|
111
|
+
console.error(chalk.red('Access denied. Only Team Managers can list plans.'));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const body = (await res.json());
|
|
116
|
+
console.error(chalk.red(`Failed to list plans: ${body.error ?? res.statusText}`));
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
const body = (await res.json());
|
|
120
|
+
if (body.plans.length === 0) {
|
|
121
|
+
console.log(chalk.dim('\n No plans found.\n'));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
console.log(chalk.bold(`\n Plans (${body.count}):\n`));
|
|
125
|
+
console.log(chalk.dim(` ${'TICKET'.padEnd(14)} ${'STATUS'.padEnd(12)} ${'REPOSITORY'.padEnd(36)} ${'AUTHOR'.padEnd(30)} UPDATED`));
|
|
126
|
+
console.log();
|
|
127
|
+
const statusColors = {
|
|
128
|
+
submitted: chalk.dim,
|
|
129
|
+
approved: chalk.cyan,
|
|
130
|
+
validated: chalk.green,
|
|
131
|
+
reviewed: chalk.magenta,
|
|
132
|
+
};
|
|
133
|
+
for (const p of body.plans) {
|
|
134
|
+
const date = new Date(p.updatedAt).toLocaleDateString('en-US', {
|
|
135
|
+
month: 'short',
|
|
136
|
+
day: 'numeric',
|
|
137
|
+
year: 'numeric',
|
|
138
|
+
hour: '2-digit',
|
|
139
|
+
minute: '2-digit',
|
|
140
|
+
});
|
|
141
|
+
const repo = p.repository ?? p.project;
|
|
142
|
+
const ticket = p.ticket ?? '-';
|
|
143
|
+
const colorFn = statusColors[p.status] ?? chalk.dim;
|
|
144
|
+
console.log(` ${chalk.white(ticket.padEnd(14))} ${colorFn(p.status.padEnd(12))} ${chalk.white(repo.padEnd(36))} ${chalk.dim(p.author.padEnd(30))} ${chalk.dim(date)}`);
|
|
145
|
+
}
|
|
146
|
+
console.log();
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
150
|
+
console.error(chalk.red(`Failed to list plans: ${msg}`));
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,8 @@ import { versionCheckCommand } from './commands/version.js';
|
|
|
14
14
|
import { standupCommand } from './commands/standup.js';
|
|
15
15
|
import { feedbackCommand } from './commands/feedback.js';
|
|
16
16
|
import { coinsCommand } from './commands/coins.js';
|
|
17
|
-
import {
|
|
17
|
+
import { auditSubmitCommand, auditListCommand } from './commands/audit.js';
|
|
18
|
+
import { planSubmitCommand, planListCommand } from './commands/plan.js';
|
|
18
19
|
// Read version from package.json
|
|
19
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
20
21
|
const __dirname = dirname(__filename);
|
|
@@ -90,29 +91,40 @@ program
|
|
|
90
91
|
.action(async (options) => {
|
|
91
92
|
await coinsCommand(options);
|
|
92
93
|
});
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
.command('
|
|
96
|
-
.description('submit
|
|
97
|
-
.argument('[file]', 'path to the
|
|
94
|
+
const audit = program.command('audit').description('submit and list audits');
|
|
95
|
+
audit
|
|
96
|
+
.command('submit')
|
|
97
|
+
.description('submit an audit report from a markdown file')
|
|
98
|
+
.argument('[file]', 'path to the audit file (auto-detects from .audits/ if omitted)')
|
|
98
99
|
.action(async (file) => {
|
|
99
|
-
await
|
|
100
|
+
await auditSubmitCommand(file);
|
|
100
101
|
});
|
|
101
|
-
|
|
102
|
-
.command('
|
|
103
|
-
.description('
|
|
104
|
-
.
|
|
102
|
+
audit
|
|
103
|
+
.command('list')
|
|
104
|
+
.description('list submitted audits (Team Managers only)')
|
|
105
|
+
.option('--type <type>', 'filter by type (audit, ux-audit)')
|
|
106
|
+
.option('--repository <repo>', 'filter by repository (owner/repo)')
|
|
107
|
+
.option('--latest', 'show only the latest audit per project and type')
|
|
108
|
+
.option('--limit <n>', 'max results (default 50, max 200)')
|
|
109
|
+
.action(async (options) => {
|
|
110
|
+
await auditListCommand(options);
|
|
111
|
+
});
|
|
112
|
+
const plan = program.command('plan').description('submit and list plans');
|
|
113
|
+
plan
|
|
114
|
+
.command('submit')
|
|
115
|
+
.description('submit or update a plan from a markdown file')
|
|
116
|
+
.argument('[file]', 'path to the plan file (auto-detects from .plans/ if omitted)')
|
|
105
117
|
.action(async (file) => {
|
|
106
|
-
await
|
|
118
|
+
await planSubmitCommand(file);
|
|
107
119
|
});
|
|
108
|
-
|
|
120
|
+
plan
|
|
109
121
|
.command('list')
|
|
110
|
-
.description('list submitted
|
|
111
|
-
.option('--type <type>', 'filter by type (plan, audit, ux-audit)')
|
|
122
|
+
.description('list submitted plans (Team Managers only)')
|
|
112
123
|
.option('--repository <repo>', 'filter by repository (owner/repo)')
|
|
124
|
+
.option('--author <email>', 'filter by author email')
|
|
113
125
|
.option('--limit <n>', 'max results (default 50, max 200)')
|
|
114
126
|
.action(async (options) => {
|
|
115
|
-
await
|
|
127
|
+
await planListCommand(options);
|
|
116
128
|
});
|
|
117
129
|
program
|
|
118
130
|
.command('version')
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getStoredAuth, API_BASE } from './auth.js';
|
|
3
|
+
export async function getToken() {
|
|
4
|
+
const envToken = process.env['MELTCTL_TOKEN'];
|
|
5
|
+
if (envToken) {
|
|
6
|
+
return envToken;
|
|
7
|
+
}
|
|
8
|
+
const auth = await getStoredAuth();
|
|
9
|
+
if (!auth) {
|
|
10
|
+
console.error(chalk.red('Not authenticated. Run `npx @meltstudio/meltctl@latest login` or set MELTCTL_TOKEN for CI.'));
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
if (new Date(auth.expiresAt) <= new Date()) {
|
|
14
|
+
console.error(chalk.red('Session expired. Run `npx @meltstudio/meltctl@latest login` to re-authenticate, or set MELTCTL_TOKEN for CI.'));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
return auth.token;
|
|
18
|
+
}
|
|
19
|
+
export async function tokenFetch(token, urlPath, options = {}) {
|
|
20
|
+
const response = await fetch(`${API_BASE}${urlPath}`, {
|
|
21
|
+
...options,
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${token}`,
|
|
24
|
+
...options.headers,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
if (response.status === 401) {
|
|
28
|
+
throw new Error('Authentication failed. Run `npx @meltstudio/meltctl@latest login` or check your MELTCTL_TOKEN.');
|
|
29
|
+
}
|
|
30
|
+
return response;
|
|
31
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function getGitBranch(): string;
|
|
2
|
+
export declare function getGitCommit(): string;
|
|
3
|
+
export declare function getGitRepository(): {
|
|
4
|
+
slug: string;
|
|
5
|
+
url: string;
|
|
6
|
+
} | null;
|
|
7
|
+
export declare function getProjectName(): string;
|
|
8
|
+
export declare function extractTicketId(branch: string): string | null;
|
|
9
|
+
export declare function findMdFiles(dir: string): Promise<string[]>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
export function getGitBranch() {
|
|
5
|
+
try {
|
|
6
|
+
return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return 'unknown';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function getGitCommit() {
|
|
13
|
+
try {
|
|
14
|
+
return execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return 'unknown';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function getGitRepository() {
|
|
21
|
+
try {
|
|
22
|
+
const url = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
|
|
23
|
+
// Extract owner/repo from various URL formats:
|
|
24
|
+
// git@github.com:Owner/Repo.git -> Owner/Repo
|
|
25
|
+
// https://github.com/Owner/Repo.git -> Owner/Repo
|
|
26
|
+
// git@gitlab.com:Owner/Repo.git -> Owner/Repo
|
|
27
|
+
const match = url.match(/[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
|
|
28
|
+
const slug = match ? match[1] : url;
|
|
29
|
+
return { slug, url };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function getProjectName() {
|
|
36
|
+
const cwd = process.cwd();
|
|
37
|
+
try {
|
|
38
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
39
|
+
if (fs.pathExistsSync(pkgPath)) {
|
|
40
|
+
const pkg = fs.readJsonSync(pkgPath);
|
|
41
|
+
if (pkg.name) {
|
|
42
|
+
return pkg.name;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// fall through
|
|
48
|
+
}
|
|
49
|
+
return path.basename(cwd);
|
|
50
|
+
}
|
|
51
|
+
export function extractTicketId(branch) {
|
|
52
|
+
const match = branch.match(/([A-Z]+-\d+)/i);
|
|
53
|
+
return match ? match[1] : null;
|
|
54
|
+
}
|
|
55
|
+
export async function findMdFiles(dir) {
|
|
56
|
+
if (!(await fs.pathExists(dir))) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
const results = [];
|
|
60
|
+
async function walk(current) {
|
|
61
|
+
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const fullPath = path.join(current, entry.name);
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
await walk(fullPath);
|
|
66
|
+
}
|
|
67
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
68
|
+
const stat = await fs.stat(fullPath);
|
|
69
|
+
results.push({ path: fullPath, mtime: stat.mtimeMs });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
await walk(dir);
|
|
74
|
+
results.sort((a, b) => b.mtime - a.mtime);
|
|
75
|
+
return results.map(r => r.path);
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
export declare function reportPlanCommand(file?: string): Promise<void>;
|
|
2
|
-
export declare function reportAuditCommand(file?: string): Promise<void>;
|
|
3
|
-
export declare function reportListCommand(options: {
|
|
4
|
-
type?: string;
|
|
5
|
-
repository?: string;
|
|
6
|
-
limit?: string;
|
|
7
|
-
}): Promise<void>;
|
package/dist/commands/report.js
DELETED
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
|
-
import fs from 'fs-extra';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import { execSync } from 'child_process';
|
|
5
|
-
import { getStoredAuth, API_BASE } from '../utils/auth.js';
|
|
6
|
-
async function getToken() {
|
|
7
|
-
const envToken = process.env['MELTCTL_TOKEN'];
|
|
8
|
-
if (envToken) {
|
|
9
|
-
return envToken;
|
|
10
|
-
}
|
|
11
|
-
const auth = await getStoredAuth();
|
|
12
|
-
if (!auth) {
|
|
13
|
-
console.error(chalk.red('Not authenticated. Run `npx @meltstudio/meltctl@latest login` or set MELTCTL_TOKEN for CI.'));
|
|
14
|
-
process.exit(1);
|
|
15
|
-
}
|
|
16
|
-
if (new Date(auth.expiresAt) <= new Date()) {
|
|
17
|
-
console.error(chalk.red('Session expired. Run `npx @meltstudio/meltctl@latest login` to re-authenticate, or set MELTCTL_TOKEN for CI.'));
|
|
18
|
-
process.exit(1);
|
|
19
|
-
}
|
|
20
|
-
return auth.token;
|
|
21
|
-
}
|
|
22
|
-
async function tokenFetch(token, urlPath, options = {}) {
|
|
23
|
-
const response = await fetch(`${API_BASE}${urlPath}`, {
|
|
24
|
-
...options,
|
|
25
|
-
headers: {
|
|
26
|
-
Authorization: `Bearer ${token}`,
|
|
27
|
-
...options.headers,
|
|
28
|
-
},
|
|
29
|
-
});
|
|
30
|
-
if (response.status === 401) {
|
|
31
|
-
throw new Error('Authentication failed. Run `npx @meltstudio/meltctl@latest login` or check your MELTCTL_TOKEN.');
|
|
32
|
-
}
|
|
33
|
-
return response;
|
|
34
|
-
}
|
|
35
|
-
function getGitBranch() {
|
|
36
|
-
try {
|
|
37
|
-
return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
return 'unknown';
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
function getGitCommit() {
|
|
44
|
-
try {
|
|
45
|
-
return execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim();
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
return 'unknown';
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
function getGitRepository() {
|
|
52
|
-
try {
|
|
53
|
-
const url = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
|
|
54
|
-
// Extract owner/repo from various URL formats:
|
|
55
|
-
// git@github.com:Owner/Repo.git -> Owner/Repo
|
|
56
|
-
// https://github.com/Owner/Repo.git -> Owner/Repo
|
|
57
|
-
// git@gitlab.com:Owner/Repo.git -> Owner/Repo
|
|
58
|
-
const match = url.match(/[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
|
|
59
|
-
const slug = match ? match[1] : url;
|
|
60
|
-
return { slug, url };
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
function getProjectName() {
|
|
67
|
-
const cwd = process.cwd();
|
|
68
|
-
try {
|
|
69
|
-
const pkgPath = path.join(cwd, 'package.json');
|
|
70
|
-
if (fs.pathExistsSync(pkgPath)) {
|
|
71
|
-
const pkg = fs.readJsonSync(pkgPath);
|
|
72
|
-
if (pkg.name) {
|
|
73
|
-
return pkg.name;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
// fall through
|
|
79
|
-
}
|
|
80
|
-
return path.basename(cwd);
|
|
81
|
-
}
|
|
82
|
-
function extractTicketId(branch) {
|
|
83
|
-
const match = branch.match(/([A-Z]+-\d+)/i);
|
|
84
|
-
return match ? match[1] : null;
|
|
85
|
-
}
|
|
86
|
-
async function findMdFiles(dir) {
|
|
87
|
-
if (!(await fs.pathExists(dir))) {
|
|
88
|
-
return [];
|
|
89
|
-
}
|
|
90
|
-
const results = [];
|
|
91
|
-
async function walk(current) {
|
|
92
|
-
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
93
|
-
for (const entry of entries) {
|
|
94
|
-
const fullPath = path.join(current, entry.name);
|
|
95
|
-
if (entry.isDirectory()) {
|
|
96
|
-
await walk(fullPath);
|
|
97
|
-
}
|
|
98
|
-
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
99
|
-
const stat = await fs.stat(fullPath);
|
|
100
|
-
results.push({ path: fullPath, mtime: stat.mtimeMs });
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
await walk(dir);
|
|
105
|
-
results.sort((a, b) => b.mtime - a.mtime);
|
|
106
|
-
return results.map(r => r.path);
|
|
107
|
-
}
|
|
108
|
-
async function autoDetectPlanFile() {
|
|
109
|
-
const cwd = process.cwd();
|
|
110
|
-
const plansDir = path.join(cwd, '.plans');
|
|
111
|
-
const mdFiles = await findMdFiles(plansDir);
|
|
112
|
-
if (mdFiles.length === 0) {
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
|
-
const branch = getGitBranch();
|
|
116
|
-
const ticketId = extractTicketId(branch);
|
|
117
|
-
if (ticketId) {
|
|
118
|
-
const ticketLower = ticketId.toLowerCase();
|
|
119
|
-
const match = mdFiles.find(f => path.basename(f).toLowerCase().includes(ticketLower));
|
|
120
|
-
if (match) {
|
|
121
|
-
return match;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return mdFiles[0] ?? null;
|
|
125
|
-
}
|
|
126
|
-
async function autoDetectAuditFile() {
|
|
127
|
-
const cwd = process.cwd();
|
|
128
|
-
// Check .audits/ directory first (most recent file)
|
|
129
|
-
const auditsDir = path.join(cwd, '.audits');
|
|
130
|
-
const auditFiles = await findMdFiles(auditsDir);
|
|
131
|
-
if (auditFiles.length > 0) {
|
|
132
|
-
return auditFiles[0] ?? null;
|
|
133
|
-
}
|
|
134
|
-
// Fall back to legacy locations
|
|
135
|
-
const candidates = ['AUDIT.md', 'UX-AUDIT.md'];
|
|
136
|
-
for (const name of candidates) {
|
|
137
|
-
const filePath = path.join(cwd, name);
|
|
138
|
-
if (await fs.pathExists(filePath)) {
|
|
139
|
-
return filePath;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
function detectAuditType(filename) {
|
|
145
|
-
return filename.toLowerCase().includes('ux-audit') ? 'ux-audit' : 'audit';
|
|
146
|
-
}
|
|
147
|
-
function buildReportPayload(type, content, filename) {
|
|
148
|
-
const project = getProjectName();
|
|
149
|
-
const branch = getGitBranch();
|
|
150
|
-
const commit = getGitCommit();
|
|
151
|
-
const repo = getGitRepository();
|
|
152
|
-
return {
|
|
153
|
-
type,
|
|
154
|
-
project,
|
|
155
|
-
repository: repo?.slug ?? null,
|
|
156
|
-
repositoryUrl: repo?.url ?? null,
|
|
157
|
-
branch,
|
|
158
|
-
commit,
|
|
159
|
-
content,
|
|
160
|
-
metadata: { filename },
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
export async function reportPlanCommand(file) {
|
|
164
|
-
const token = await getToken();
|
|
165
|
-
let filePath;
|
|
166
|
-
if (file) {
|
|
167
|
-
filePath = path.resolve(file);
|
|
168
|
-
}
|
|
169
|
-
else {
|
|
170
|
-
const detected = await autoDetectPlanFile();
|
|
171
|
-
if (!detected) {
|
|
172
|
-
console.error(chalk.red('No plan file found. Provide a file path or create a plan in the .plans/ directory.'));
|
|
173
|
-
process.exit(1);
|
|
174
|
-
}
|
|
175
|
-
filePath = detected;
|
|
176
|
-
console.log(chalk.dim(`Auto-detected plan file: ${path.relative(process.cwd(), filePath)}`));
|
|
177
|
-
}
|
|
178
|
-
if (!(await fs.pathExists(filePath))) {
|
|
179
|
-
console.error(chalk.red(`File not found: ${filePath}`));
|
|
180
|
-
process.exit(1);
|
|
181
|
-
}
|
|
182
|
-
const content = await fs.readFile(filePath, 'utf-8');
|
|
183
|
-
const filename = path.basename(filePath);
|
|
184
|
-
const payload = buildReportPayload('plan', content, filename);
|
|
185
|
-
try {
|
|
186
|
-
const res = await tokenFetch(token, '/reports', {
|
|
187
|
-
method: 'POST',
|
|
188
|
-
headers: { 'Content-Type': 'application/json' },
|
|
189
|
-
body: JSON.stringify(payload),
|
|
190
|
-
});
|
|
191
|
-
if (res.ok) {
|
|
192
|
-
const body = (await res.json());
|
|
193
|
-
console.log(chalk.green(`\n ✓ Plan report submitted! Report ID: ${body.id}\n`));
|
|
194
|
-
}
|
|
195
|
-
else {
|
|
196
|
-
const body = (await res.json());
|
|
197
|
-
console.error(chalk.red(`\nFailed to submit plan report: ${body.error ?? res.statusText}`));
|
|
198
|
-
process.exit(1);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
catch (error) {
|
|
202
|
-
console.error(chalk.red(`Failed to submit plan report: ${error instanceof Error ? error.message : 'Unknown error'}`));
|
|
203
|
-
process.exit(1);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
export async function reportAuditCommand(file) {
|
|
207
|
-
const token = await getToken();
|
|
208
|
-
let filePath;
|
|
209
|
-
if (file) {
|
|
210
|
-
filePath = path.resolve(file);
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
const detected = await autoDetectAuditFile();
|
|
214
|
-
if (!detected) {
|
|
215
|
-
console.error(chalk.red('No audit file found. Provide a file path or create AUDIT.md or UX-AUDIT.md in the project root.'));
|
|
216
|
-
process.exit(1);
|
|
217
|
-
}
|
|
218
|
-
filePath = detected;
|
|
219
|
-
console.log(chalk.dim(`Auto-detected audit file: ${path.relative(process.cwd(), filePath)}`));
|
|
220
|
-
}
|
|
221
|
-
if (!(await fs.pathExists(filePath))) {
|
|
222
|
-
console.error(chalk.red(`File not found: ${filePath}`));
|
|
223
|
-
process.exit(1);
|
|
224
|
-
}
|
|
225
|
-
const content = await fs.readFile(filePath, 'utf-8');
|
|
226
|
-
const filename = path.basename(filePath);
|
|
227
|
-
const auditType = detectAuditType(filename);
|
|
228
|
-
const payload = buildReportPayload(auditType, content, filename);
|
|
229
|
-
try {
|
|
230
|
-
const res = await tokenFetch(token, '/reports', {
|
|
231
|
-
method: 'POST',
|
|
232
|
-
headers: { 'Content-Type': 'application/json' },
|
|
233
|
-
body: JSON.stringify(payload),
|
|
234
|
-
});
|
|
235
|
-
if (res.ok) {
|
|
236
|
-
const body = (await res.json());
|
|
237
|
-
console.log(chalk.green(`\n ✓ Audit report submitted! Report ID: ${body.id}\n`));
|
|
238
|
-
}
|
|
239
|
-
else {
|
|
240
|
-
const body = (await res.json());
|
|
241
|
-
console.error(chalk.red(`\nFailed to submit audit report: ${body.error ?? res.statusText}`));
|
|
242
|
-
process.exit(1);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
catch (error) {
|
|
246
|
-
console.error(chalk.red(`Failed to submit audit report: ${error instanceof Error ? error.message : 'Unknown error'}`));
|
|
247
|
-
process.exit(1);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
export async function reportListCommand(options) {
|
|
251
|
-
const token = await getToken();
|
|
252
|
-
const params = new URLSearchParams();
|
|
253
|
-
if (options.type)
|
|
254
|
-
params.set('type', options.type);
|
|
255
|
-
if (options.repository)
|
|
256
|
-
params.set('repository', options.repository);
|
|
257
|
-
if (options.limit)
|
|
258
|
-
params.set('limit', options.limit);
|
|
259
|
-
const query = params.toString();
|
|
260
|
-
const urlPath = `/reports${query ? `?${query}` : ''}`;
|
|
261
|
-
try {
|
|
262
|
-
const res = await tokenFetch(token, urlPath);
|
|
263
|
-
if (res.status === 403) {
|
|
264
|
-
console.error(chalk.red('Access denied. Only Team Managers can list reports.'));
|
|
265
|
-
process.exit(1);
|
|
266
|
-
}
|
|
267
|
-
if (!res.ok) {
|
|
268
|
-
const body = (await res.json());
|
|
269
|
-
console.error(chalk.red(`Failed to list reports: ${body.error ?? res.statusText}`));
|
|
270
|
-
process.exit(1);
|
|
271
|
-
}
|
|
272
|
-
const body = (await res.json());
|
|
273
|
-
if (body.reports.length === 0) {
|
|
274
|
-
console.log(chalk.dim('\n No reports found.\n'));
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
console.log(chalk.bold(`\n Reports (${body.count}):\n`));
|
|
278
|
-
console.log(chalk.dim(` ${'TYPE'.padEnd(12)} ${'REPOSITORY'.padEnd(40)} ${'AUTHOR'.padEnd(30)} DATE`));
|
|
279
|
-
console.log();
|
|
280
|
-
const typeLabels = {
|
|
281
|
-
plan: 'Plan',
|
|
282
|
-
audit: 'Tech Audit',
|
|
283
|
-
'ux-audit': 'UX Audit',
|
|
284
|
-
};
|
|
285
|
-
for (const r of body.reports) {
|
|
286
|
-
const date = new Date(r.createdAt).toLocaleDateString('en-US', {
|
|
287
|
-
month: 'short',
|
|
288
|
-
day: 'numeric',
|
|
289
|
-
year: 'numeric',
|
|
290
|
-
hour: '2-digit',
|
|
291
|
-
minute: '2-digit',
|
|
292
|
-
});
|
|
293
|
-
const repo = r.repository ?? r.project;
|
|
294
|
-
const label = typeLabels[r.type] ?? r.type;
|
|
295
|
-
const typeColor = r.type === 'plan' ? chalk.cyan : r.type === 'ux-audit' ? chalk.yellow : chalk.magenta;
|
|
296
|
-
console.log(` ${typeColor(label.padEnd(12))} ${chalk.white(repo.padEnd(40))} ${chalk.dim(r.author.padEnd(30))} ${chalk.dim(date)}`);
|
|
297
|
-
if (r.branch && r.branch !== 'main') {
|
|
298
|
-
console.log(` ${' '.padEnd(12)} ${chalk.dim(`branch: ${r.branch} commit: ${r.commit ?? 'N/A'}`)}`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
console.log();
|
|
302
|
-
}
|
|
303
|
-
catch (error) {
|
|
304
|
-
console.error(chalk.red(`Failed to list reports: ${error instanceof Error ? error.message : 'Unknown error'}`));
|
|
305
|
-
process.exit(1);
|
|
306
|
-
}
|
|
307
|
-
}
|