@simonfestl/husky-cli 1.6.5 → 1.7.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 +42 -0
- package/dist/commands/agent.js +43 -0
- package/dist/commands/chat.js +16 -1
- package/dist/commands/e2e.js +254 -9
- package/dist/commands/task.js +28 -5
- package/dist/commands/vm.js +272 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -111,6 +111,34 @@ husky vm update <session-id> --status approved
|
|
|
111
111
|
husky vm delete <session-id>
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
+
### E2E Testing (E2E Agent)
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Run E2E tests for a task
|
|
118
|
+
husky e2e run <task-id>
|
|
119
|
+
husky e2e run <task-id> --secret ADMIN_PASSWORD --retries 2
|
|
120
|
+
husky e2e run <task-id> --env TEST_USER=admin --headed
|
|
121
|
+
|
|
122
|
+
# E2E Inbox (pending test requests)
|
|
123
|
+
husky e2e inbox # List all inbox messages
|
|
124
|
+
husky e2e inbox --status pending # Filter by status
|
|
125
|
+
husky e2e inbox --task <task-id> # Filter by task
|
|
126
|
+
|
|
127
|
+
# Watch for new E2E requests (auto-processing)
|
|
128
|
+
husky e2e watch --interval 30
|
|
129
|
+
husky e2e watch --once # Process once and exit
|
|
130
|
+
|
|
131
|
+
# Browser automation utilities
|
|
132
|
+
husky e2e screenshot <url> # Take screenshot
|
|
133
|
+
husky e2e screenshot <url> --upload # Upload to GCS
|
|
134
|
+
husky e2e record <url> # Record browser session
|
|
135
|
+
|
|
136
|
+
# Artifact management
|
|
137
|
+
husky e2e upload <file> --task <id> # Upload to GCS
|
|
138
|
+
husky e2e list --task <id> # List artifacts
|
|
139
|
+
husky e2e clean --older-than 7 # Clean old artifacts
|
|
140
|
+
```
|
|
141
|
+
|
|
114
142
|
### Business Strategy
|
|
115
143
|
|
|
116
144
|
```bash
|
|
@@ -296,6 +324,20 @@ husky --version
|
|
|
296
324
|
|
|
297
325
|
## Changelog
|
|
298
326
|
|
|
327
|
+
### v1.7.0 (2026-01-11) - E2E Agent Production Ready
|
|
328
|
+
|
|
329
|
+
**New Features:**
|
|
330
|
+
- `husky e2e inbox` - List E2E test requests from API
|
|
331
|
+
- `husky e2e watch` - Watch for and auto-process E2E requests
|
|
332
|
+
- `husky e2e run --secret` - Inject secrets from GCP Secret Manager
|
|
333
|
+
- `husky e2e run --env` - Set environment variables for tests
|
|
334
|
+
- `husky e2e run --retries` - Retry failed tests automatically
|
|
335
|
+
|
|
336
|
+
**Improvements:**
|
|
337
|
+
- All artifact URLs now use HTTPS
|
|
338
|
+
- Better error handling in E2E commands
|
|
339
|
+
- Updated permissions for e2e_agent role
|
|
340
|
+
|
|
299
341
|
### v1.1.0 (2026-01-09) - Unified Reply System
|
|
300
342
|
|
|
301
343
|
**New Features:**
|
package/dist/commands/agent.js
CHANGED
|
@@ -376,6 +376,49 @@ agentCommand
|
|
|
376
376
|
process.exit(1);
|
|
377
377
|
}
|
|
378
378
|
});
|
|
379
|
+
// husky agent qa-review
|
|
380
|
+
agentCommand
|
|
381
|
+
.command("qa-review <taskId>")
|
|
382
|
+
.description("Trigger QA review for a task")
|
|
383
|
+
.option("--pr <url>", "PR URL to review")
|
|
384
|
+
.option("--json", "Output as JSON")
|
|
385
|
+
.action(async (taskId, options) => {
|
|
386
|
+
const config = getConfig();
|
|
387
|
+
if (!config.apiUrl) {
|
|
388
|
+
console.error("Error: API URL not configured. Run: husky config set api-url <url>");
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const res = await fetch(`${config.apiUrl}/api/tasks/${taskId}/assign-reviewer`, {
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers: {
|
|
395
|
+
"Content-Type": "application/json",
|
|
396
|
+
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
397
|
+
},
|
|
398
|
+
body: JSON.stringify({
|
|
399
|
+
prUrl: options.pr,
|
|
400
|
+
}),
|
|
401
|
+
});
|
|
402
|
+
if (!res.ok) {
|
|
403
|
+
const error = await res.text();
|
|
404
|
+
console.error(`Error assigning reviewer: ${res.status} - ${error}`);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
const data = await res.json();
|
|
408
|
+
if (options.json) {
|
|
409
|
+
console.log(JSON.stringify(data, null, 2));
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
console.log(`\n✓ Task ${taskId} assigned to reviewer`);
|
|
413
|
+
console.log(` Inbox ID: ${data.inboxId}`);
|
|
414
|
+
console.log(` Message: ${data.message}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
console.error("Error triggering QA review:", error);
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
379
422
|
// husky agent register
|
|
380
423
|
agentCommand
|
|
381
424
|
.command("register")
|
package/dist/commands/chat.js
CHANGED
|
@@ -446,7 +446,22 @@ chatCommand
|
|
|
446
446
|
method: "POST",
|
|
447
447
|
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
448
448
|
});
|
|
449
|
-
|
|
449
|
+
// Add reaction to original message if messageName is available
|
|
450
|
+
if (msg.messageName) {
|
|
451
|
+
try {
|
|
452
|
+
await fetch(`${huskyApiUrl}/api/google-chat/messages/${encodeURIComponent(msg.messageName)}/react`, {
|
|
453
|
+
method: "POST",
|
|
454
|
+
headers: {
|
|
455
|
+
"Content-Type": "application/json",
|
|
456
|
+
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
457
|
+
},
|
|
458
|
+
body: JSON.stringify({ emoji: "✅" }),
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
// Reaction is optional - don't fail if it doesn't work
|
|
463
|
+
}
|
|
464
|
+
}
|
|
450
465
|
}
|
|
451
466
|
}
|
|
452
467
|
catch (error) {
|
package/dist/commands/e2e.js
CHANGED
|
@@ -60,6 +60,21 @@ function uploadToGCS(localPath, gcsPath) {
|
|
|
60
60
|
console.error("GCS upload failed:", result.stderr);
|
|
61
61
|
return { success: false, url: "" };
|
|
62
62
|
}
|
|
63
|
+
// Helper: Fetch secret from GCP Secret Manager
|
|
64
|
+
async function fetchSecret(secretName) {
|
|
65
|
+
const result = runCommand("gcloud", [
|
|
66
|
+
"secrets", "versions", "access", "latest",
|
|
67
|
+
"--secret", secretName,
|
|
68
|
+
]);
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
throw new Error(`Failed to fetch secret ${secretName}: ${result.stderr}`);
|
|
71
|
+
}
|
|
72
|
+
return result.stdout.trim();
|
|
73
|
+
}
|
|
74
|
+
// Helper: Sleep function
|
|
75
|
+
function sleep(ms) {
|
|
76
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
77
|
+
}
|
|
63
78
|
// husky e2e run <task-id>
|
|
64
79
|
e2eCommand
|
|
65
80
|
.command("run <taskId>")
|
|
@@ -69,16 +84,48 @@ e2eCommand
|
|
|
69
84
|
.option("--browser <browser>", "Browser to use (chromium, firefox, webkit)", "chromium")
|
|
70
85
|
.option("--timeout <ms>", "Test timeout in milliseconds", "60000")
|
|
71
86
|
.option("--output <dir>", "Output directory for results")
|
|
87
|
+
.option("--retries <count>", "Number of retries for failed tests", "0")
|
|
88
|
+
.option("--secret <names...>", "Secrets to fetch from GCP Secret Manager")
|
|
89
|
+
.option("--env <values...>", "Environment variables (KEY=value)")
|
|
72
90
|
.option("--json", "Output as JSON")
|
|
73
91
|
.action(async (taskId, options) => {
|
|
74
|
-
requireAnyPermission(["task:
|
|
92
|
+
requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
|
|
75
93
|
const config = ensureConfig();
|
|
76
94
|
const timestamp = generateTimestamp();
|
|
77
95
|
const outputDir = options.output || `/tmp/e2e-${taskId}-${timestamp}`;
|
|
96
|
+
const maxRetries = parseInt(options.retries, 10);
|
|
97
|
+
// Inject secrets from GCP Secret Manager
|
|
98
|
+
if (options.secret && options.secret.length > 0) {
|
|
99
|
+
console.log(`\n Loading secrets from GCP...`);
|
|
100
|
+
for (const secretName of options.secret) {
|
|
101
|
+
try {
|
|
102
|
+
const value = await fetchSecret(secretName);
|
|
103
|
+
process.env[secretName] = value;
|
|
104
|
+
console.log(` ✓ ${secretName} loaded`);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.error(` ✗ Failed to load ${secretName}:`, error.message);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Inject environment variables
|
|
113
|
+
if (options.env && options.env.length > 0) {
|
|
114
|
+
console.log(`\n Setting environment variables...`);
|
|
115
|
+
for (const envPair of options.env) {
|
|
116
|
+
const [key, ...valueParts] = envPair.split("=");
|
|
117
|
+
const value = valueParts.join("=");
|
|
118
|
+
process.env[key] = value;
|
|
119
|
+
console.log(` ✓ ${key} set`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
78
122
|
console.log(`\n E2E Tests for Task: ${taskId}\n`);
|
|
79
123
|
console.log(` Output directory: ${outputDir}`);
|
|
80
124
|
console.log(` Browser: ${options.browser}`);
|
|
81
125
|
console.log(` Headless: ${!options.headed}`);
|
|
126
|
+
if (maxRetries > 0) {
|
|
127
|
+
console.log(` Retries: ${maxRetries}`);
|
|
128
|
+
}
|
|
82
129
|
console.log("");
|
|
83
130
|
// Create output directory
|
|
84
131
|
if (!fs.existsSync(outputDir)) {
|
|
@@ -129,9 +176,28 @@ e2eCommand
|
|
|
129
176
|
if (options.browser) {
|
|
130
177
|
playwrightArgs.push(`--project=${options.browser}`);
|
|
131
178
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
179
|
+
// Run tests with retry logic
|
|
180
|
+
let result = { stdout: "", stderr: "", success: false };
|
|
181
|
+
let attempts = 0;
|
|
182
|
+
while (attempts <= maxRetries) {
|
|
183
|
+
attempts++;
|
|
184
|
+
if (attempts > 1) {
|
|
185
|
+
console.log(`\n Retry ${attempts - 1}/${maxRetries}...\n`);
|
|
186
|
+
await sleep(5000); // Wait 5 seconds before retry
|
|
187
|
+
}
|
|
188
|
+
result = runCommand("npx", ["playwright", ...playwrightArgs], {
|
|
189
|
+
timeout: parseInt(options.timeout, 10) * 2, // Allow 2x timeout for test suite
|
|
190
|
+
});
|
|
191
|
+
if (result.success) {
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
// Capture failure screenshot on last attempt
|
|
195
|
+
if (attempts > maxRetries) {
|
|
196
|
+
console.log(" Capturing failure screenshot...");
|
|
197
|
+
const failScreenshot = path.join(outputDir, `failure-${timestamp}.png`);
|
|
198
|
+
// Note: Playwright already captures screenshots on failure with --screenshot=on
|
|
199
|
+
}
|
|
200
|
+
}
|
|
135
201
|
const testResult = {
|
|
136
202
|
taskId,
|
|
137
203
|
taskName,
|
|
@@ -183,7 +249,7 @@ e2eCommand
|
|
|
183
249
|
.option("--video", "Record video of the session")
|
|
184
250
|
.option("--json", "Output as JSON")
|
|
185
251
|
.action(async (url, options) => {
|
|
186
|
-
requireAnyPermission(["task:
|
|
252
|
+
requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
|
|
187
253
|
const timestamp = generateTimestamp();
|
|
188
254
|
const outputFile = options.output || `/tmp/e2e-recording-${timestamp}.ts`;
|
|
189
255
|
const videoDir = `/tmp/e2e-video-${timestamp}`;
|
|
@@ -260,7 +326,7 @@ e2eCommand
|
|
|
260
326
|
.option("--type <type>", "File type (screenshot, video, recording, report)", "file")
|
|
261
327
|
.option("--json", "Output as JSON")
|
|
262
328
|
.action(async (file, options) => {
|
|
263
|
-
requireAnyPermission(["task:
|
|
329
|
+
requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
|
|
264
330
|
if (!fs.existsSync(file)) {
|
|
265
331
|
console.error(`Error: File not found: ${file}`);
|
|
266
332
|
process.exit(1);
|
|
@@ -312,7 +378,7 @@ e2eCommand
|
|
|
312
378
|
.option("--upload", "Upload screenshot to GCS")
|
|
313
379
|
.option("--json", "Output as JSON")
|
|
314
380
|
.action(async (url, options) => {
|
|
315
|
-
requireAnyPermission(["task:
|
|
381
|
+
requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
|
|
316
382
|
const timestamp = generateTimestamp();
|
|
317
383
|
const defaultFilename = `screenshot-${timestamp}.png`;
|
|
318
384
|
const outputFile = options.output || `/tmp/${defaultFilename}`;
|
|
@@ -415,7 +481,7 @@ e2eCommand
|
|
|
415
481
|
.option("--limit <n>", "Limit number of results", "20")
|
|
416
482
|
.option("--json", "Output as JSON")
|
|
417
483
|
.action(async (options) => {
|
|
418
|
-
requireAnyPermission(["task:
|
|
484
|
+
requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
|
|
419
485
|
let gcsPath = `gs://${GCS_BUCKET}/e2e/`;
|
|
420
486
|
if (options.task) {
|
|
421
487
|
gcsPath += `${options.task}/`;
|
|
@@ -483,7 +549,7 @@ e2eCommand
|
|
|
483
549
|
.option("--older-than <days>", "Remove artifacts older than N days", "7")
|
|
484
550
|
.option("--dry-run", "Show what would be deleted without deleting")
|
|
485
551
|
.action(async (options) => {
|
|
486
|
-
requireAnyPermission(["task:
|
|
552
|
+
requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
|
|
487
553
|
console.log(`\n Cleaning E2E Artifacts\n`);
|
|
488
554
|
const tmpDir = "/tmp";
|
|
489
555
|
const e2ePatterns = ["e2e-", "screenshot-", "recording-"];
|
|
@@ -526,3 +592,182 @@ e2eCommand
|
|
|
526
592
|
console.log(` ${options.dryRun ? "Would clean" : "Cleaned"}: ${cleaned} items (${(totalSize / 1024 / 1024).toFixed(2)} MB)`);
|
|
527
593
|
console.log("");
|
|
528
594
|
});
|
|
595
|
+
// husky e2e inbox - List E2E inbox messages
|
|
596
|
+
e2eCommand
|
|
597
|
+
.command("inbox")
|
|
598
|
+
.description("List E2E inbox messages from API")
|
|
599
|
+
.option("--status <status>", "Filter by status (pending, running, completed, failed)")
|
|
600
|
+
.option("--task <taskId>", "Filter by task ID")
|
|
601
|
+
.option("--limit <n>", "Limit number of results", "50")
|
|
602
|
+
.option("--json", "Output as JSON")
|
|
603
|
+
.action(async (options) => {
|
|
604
|
+
requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
|
|
605
|
+
const config = ensureConfig();
|
|
606
|
+
const params = new URLSearchParams();
|
|
607
|
+
if (options.status)
|
|
608
|
+
params.set("status", options.status);
|
|
609
|
+
if (options.task)
|
|
610
|
+
params.set("taskId", options.task);
|
|
611
|
+
if (options.limit)
|
|
612
|
+
params.set("limit", options.limit);
|
|
613
|
+
const url = `${config.apiUrl}/api/e2e/inbox?${params.toString()}`;
|
|
614
|
+
try {
|
|
615
|
+
const res = await fetch(url, {
|
|
616
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
617
|
+
});
|
|
618
|
+
if (!res.ok) {
|
|
619
|
+
console.error(`Error: ${res.status} ${res.statusText}`);
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
const inbox = await res.json();
|
|
623
|
+
if (options.json) {
|
|
624
|
+
console.log(JSON.stringify(inbox, null, 2));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
console.log(`\n E2E Inbox (${inbox.length} items)\n`);
|
|
628
|
+
if (inbox.length === 0) {
|
|
629
|
+
console.log(" No E2E inbox messages found.");
|
|
630
|
+
console.log("");
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
console.log(" " + "-".repeat(80));
|
|
634
|
+
console.log(` ${"STATUS".padEnd(12)} ${"TASK ID".padEnd(24)} TITLE`);
|
|
635
|
+
console.log(" " + "-".repeat(80));
|
|
636
|
+
for (const item of inbox) {
|
|
637
|
+
const statusIcons = {
|
|
638
|
+
pending: "⏳",
|
|
639
|
+
running: "🔄",
|
|
640
|
+
completed: "✅",
|
|
641
|
+
failed: "❌",
|
|
642
|
+
};
|
|
643
|
+
const statusIcon = statusIcons[item.status] || "❓";
|
|
644
|
+
console.log(` ${statusIcon} ${item.status.padEnd(10)} ${item.taskId.padEnd(24)} ${item.taskTitle?.slice(0, 40) || "N/A"}`);
|
|
645
|
+
}
|
|
646
|
+
console.log("");
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
console.error("Error fetching E2E inbox:", error.message);
|
|
650
|
+
process.exit(1);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
// husky e2e watch - Watch E2E inbox for new test requests
|
|
654
|
+
e2eCommand
|
|
655
|
+
.command("watch")
|
|
656
|
+
.description("Watch E2E inbox for new test requests")
|
|
657
|
+
.option("--interval <seconds>", "Poll interval in seconds", "30")
|
|
658
|
+
.option("--once", "Process inbox once and exit")
|
|
659
|
+
.action(async (options) => {
|
|
660
|
+
requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
|
|
661
|
+
const config = ensureConfig();
|
|
662
|
+
const interval = parseInt(options.interval, 10) * 1000;
|
|
663
|
+
console.log(`\n E2E Inbox Watcher\n`);
|
|
664
|
+
console.log(` API: ${config.apiUrl}`);
|
|
665
|
+
console.log(` Poll interval: ${options.interval}s`);
|
|
666
|
+
console.log("");
|
|
667
|
+
const processInbox = async () => {
|
|
668
|
+
try {
|
|
669
|
+
// Fetch E2E inbox from API
|
|
670
|
+
const res = await fetch(`${config.apiUrl}/api/e2e/inbox`, {
|
|
671
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
672
|
+
});
|
|
673
|
+
if (!res.ok) {
|
|
674
|
+
console.error(` Error fetching inbox: ${res.status}`);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const inbox = await res.json();
|
|
678
|
+
const pending = inbox.filter((item) => item.status === "pending");
|
|
679
|
+
if (pending.length === 0) {
|
|
680
|
+
console.log(` [${new Date().toISOString()}] No pending E2E requests`);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
console.log(` [${new Date().toISOString()}] Found ${pending.length} pending E2E request(s)`);
|
|
684
|
+
for (const item of pending) {
|
|
685
|
+
console.log(`\n Processing: ${item.taskId} - ${item.taskTitle}`);
|
|
686
|
+
// Update status to running
|
|
687
|
+
await fetch(`${config.apiUrl}/api/e2e/inbox/${item.id}`, {
|
|
688
|
+
method: "PATCH",
|
|
689
|
+
headers: {
|
|
690
|
+
"Content-Type": "application/json",
|
|
691
|
+
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
692
|
+
},
|
|
693
|
+
body: JSON.stringify({ status: "running" }),
|
|
694
|
+
});
|
|
695
|
+
// Run E2E tests for this task
|
|
696
|
+
const timestamp = generateTimestamp();
|
|
697
|
+
const outputDir = `/tmp/e2e-${item.taskId}-${timestamp}`;
|
|
698
|
+
console.log(` Running tests in ${outputDir}...`);
|
|
699
|
+
const playwrightArgs = [
|
|
700
|
+
"test",
|
|
701
|
+
"--reporter=list",
|
|
702
|
+
`--output=${outputDir}`,
|
|
703
|
+
"--timeout=60000",
|
|
704
|
+
];
|
|
705
|
+
const result = runCommand("npx", ["playwright", ...playwrightArgs], {
|
|
706
|
+
timeout: 300000, // 5 min
|
|
707
|
+
});
|
|
708
|
+
// Upload results
|
|
709
|
+
const resultsPath = path.join(outputDir, "results.json");
|
|
710
|
+
const testResult = {
|
|
711
|
+
taskId: item.taskId,
|
|
712
|
+
success: result.success,
|
|
713
|
+
timestamp,
|
|
714
|
+
stdout: result.stdout,
|
|
715
|
+
stderr: result.stderr,
|
|
716
|
+
};
|
|
717
|
+
if (!fs.existsSync(outputDir)) {
|
|
718
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
719
|
+
}
|
|
720
|
+
fs.writeFileSync(resultsPath, JSON.stringify(testResult, null, 2));
|
|
721
|
+
const gcsPath = `e2e/${item.taskId}/${timestamp}/results.json`;
|
|
722
|
+
const uploadResult = uploadToGCS(resultsPath, gcsPath);
|
|
723
|
+
// Update inbox status
|
|
724
|
+
await fetch(`${config.apiUrl}/api/e2e/inbox/${item.id}`, {
|
|
725
|
+
method: "PATCH",
|
|
726
|
+
headers: {
|
|
727
|
+
"Content-Type": "application/json",
|
|
728
|
+
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
729
|
+
},
|
|
730
|
+
body: JSON.stringify({
|
|
731
|
+
status: result.success ? "completed" : "failed",
|
|
732
|
+
completedAt: new Date().toISOString(),
|
|
733
|
+
result: {
|
|
734
|
+
passed: result.success,
|
|
735
|
+
gcsUrl: uploadResult.url,
|
|
736
|
+
},
|
|
737
|
+
}),
|
|
738
|
+
});
|
|
739
|
+
// Update task status via API
|
|
740
|
+
const taskEndpoint = result.success
|
|
741
|
+
? `${config.apiUrl}/api/tasks/${item.taskId}/e2e/pass`
|
|
742
|
+
: `${config.apiUrl}/api/tasks/${item.taskId}/e2e/fail`;
|
|
743
|
+
await fetch(taskEndpoint, {
|
|
744
|
+
method: "POST",
|
|
745
|
+
headers: {
|
|
746
|
+
"Content-Type": "application/json",
|
|
747
|
+
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
748
|
+
},
|
|
749
|
+
body: JSON.stringify({
|
|
750
|
+
notes: result.success ? "E2E tests passed" : "E2E tests failed",
|
|
751
|
+
testResults: testResult,
|
|
752
|
+
screenshots: uploadResult.url ? [uploadResult.url] : [],
|
|
753
|
+
}),
|
|
754
|
+
});
|
|
755
|
+
console.log(` ${result.success ? "✓" : "✗"} Task ${item.taskId}: ${result.success ? "PASSED" : "FAILED"}`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
console.error(` Error processing inbox:`, error.message);
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
// Process once or continuously
|
|
763
|
+
if (options.once) {
|
|
764
|
+
await processInbox();
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
console.log(" Watching for E2E requests... (Ctrl+C to stop)\n");
|
|
768
|
+
while (true) {
|
|
769
|
+
await processInbox();
|
|
770
|
+
await sleep(interval);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
});
|
package/dist/commands/task.js
CHANGED
|
@@ -928,37 +928,60 @@ taskCommand
|
|
|
928
928
|
process.exit(1);
|
|
929
929
|
}
|
|
930
930
|
});
|
|
931
|
-
// husky task qa-reject [--id <id>] [--notes <text>]
|
|
931
|
+
// husky task qa-reject [--id <id>] [--notes <text>] [--issues <json>]
|
|
932
932
|
taskCommand
|
|
933
933
|
.command("qa-reject")
|
|
934
|
-
.description("
|
|
934
|
+
.description("Reject QA and request fixes")
|
|
935
935
|
.option("--id <id>", "Task ID (or set HUSKY_TASK_ID)")
|
|
936
936
|
.option("--notes <text>", "Rejection notes")
|
|
937
|
+
.option("--issues <json>", "Issues as JSON array")
|
|
937
938
|
.action(async (options) => {
|
|
938
939
|
const config = ensureConfig();
|
|
939
940
|
const taskId = getTaskId(options);
|
|
941
|
+
let issues = [];
|
|
942
|
+
if (options.issues) {
|
|
943
|
+
try {
|
|
944
|
+
issues = JSON.parse(options.issues);
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
console.error("Error: --issues must be valid JSON");
|
|
948
|
+
process.exit(1);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
940
951
|
try {
|
|
941
|
-
const res = await fetch(`${config.apiUrl}/api/tasks/${taskId}/qa/
|
|
952
|
+
const res = await fetch(`${config.apiUrl}/api/tasks/${taskId}/qa/reject`, {
|
|
942
953
|
method: "POST",
|
|
943
954
|
headers: {
|
|
944
955
|
"Content-Type": "application/json",
|
|
945
956
|
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
946
957
|
},
|
|
947
958
|
body: JSON.stringify({
|
|
948
|
-
approved: false,
|
|
949
959
|
notes: options.notes,
|
|
960
|
+
issues,
|
|
950
961
|
}),
|
|
951
962
|
});
|
|
952
963
|
if (!res.ok) {
|
|
953
964
|
throw new Error(`API error: ${res.status}`);
|
|
954
965
|
}
|
|
955
|
-
console.log(`✗ QA
|
|
966
|
+
console.log(`✗ QA rejected for task ${taskId}`);
|
|
967
|
+
console.log(` Task returned to worker for fixes`);
|
|
956
968
|
}
|
|
957
969
|
catch (error) {
|
|
958
970
|
console.error("Error rejecting QA:", error);
|
|
959
971
|
process.exit(1);
|
|
960
972
|
}
|
|
961
973
|
});
|
|
974
|
+
// husky task qa-request-fix (alias for qa-reject with specific focus)
|
|
975
|
+
taskCommand
|
|
976
|
+
.command("qa-request-fix")
|
|
977
|
+
.description("Request fixes from the worker")
|
|
978
|
+
.option("--id <id>", "Task ID (or set HUSKY_TASK_ID)")
|
|
979
|
+
.requiredOption("--notes <text>", "Fix instructions")
|
|
980
|
+
.option("--issues <json>", "Issues as JSON array")
|
|
981
|
+
.action(async (options) => {
|
|
982
|
+
// Re-use qa-reject logic
|
|
983
|
+
await taskCommand.parseAsync(["node", "husky", "task", "qa-reject", "--id", getTaskId(options), "--notes", options.notes, ...(options.issues ? ["--issues", options.issues] : [])], { from: "user" });
|
|
984
|
+
});
|
|
962
985
|
// husky task qa-iteration [--id <id>] --iteration <n> --status <status> [--issues <json>] [--duration <seconds>]
|
|
963
986
|
taskCommand
|
|
964
987
|
.command("qa-iteration")
|
package/dist/commands/vm.js
CHANGED
|
@@ -1183,3 +1183,275 @@ function printLog(log) {
|
|
|
1183
1183
|
}
|
|
1184
1184
|
console.log(`${prefix}${timestamp} ${level} ${source} ${log.message}`);
|
|
1185
1185
|
}
|
|
1186
|
+
// ============================================================================
|
|
1187
|
+
// Anthropic OAuth Commands (for subscription auth)
|
|
1188
|
+
// ============================================================================
|
|
1189
|
+
// husky vm auth-status <sessionId>
|
|
1190
|
+
vmCommand
|
|
1191
|
+
.command("auth-status <sessionId>")
|
|
1192
|
+
.description("Check Anthropic OAuth status for a VM session")
|
|
1193
|
+
.option("--wait", "Wait for authentication to complete (polling)")
|
|
1194
|
+
.option("--timeout <seconds>", "Timeout for waiting (default: 300)", "300")
|
|
1195
|
+
.option("--json", "Output as JSON")
|
|
1196
|
+
.action(async (sessionId, options) => {
|
|
1197
|
+
const config = ensureConfig();
|
|
1198
|
+
const checkStatus = async () => {
|
|
1199
|
+
const res = await fetch(`${config.apiUrl}/api/vm-sessions/${sessionId}/auth-status`, {
|
|
1200
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
1201
|
+
});
|
|
1202
|
+
if (!res.ok) {
|
|
1203
|
+
const error = await res.text();
|
|
1204
|
+
throw new Error(`API error: ${res.status} - ${error}`);
|
|
1205
|
+
}
|
|
1206
|
+
return await res.json();
|
|
1207
|
+
};
|
|
1208
|
+
try {
|
|
1209
|
+
let data = await checkStatus();
|
|
1210
|
+
if (options.wait && ["none", "awaiting_url", "awaiting_code"].includes(data.authStatus)) {
|
|
1211
|
+
console.log("Waiting for Anthropic authentication...");
|
|
1212
|
+
const timeoutMs = parseInt(options.timeout, 10) * 1000;
|
|
1213
|
+
const startTime = Date.now();
|
|
1214
|
+
const pollInterval = 3000;
|
|
1215
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
1216
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
1217
|
+
data = await checkStatus();
|
|
1218
|
+
if (!["none", "awaiting_url", "awaiting_code"].includes(data.authStatus)) {
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
1221
|
+
process.stdout.write(".");
|
|
1222
|
+
}
|
|
1223
|
+
console.log("");
|
|
1224
|
+
}
|
|
1225
|
+
if (options.json) {
|
|
1226
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
const statusIcon = {
|
|
1230
|
+
none: "⚪",
|
|
1231
|
+
awaiting_url: "🔗",
|
|
1232
|
+
awaiting_code: "⏳",
|
|
1233
|
+
code_submitted: "📤",
|
|
1234
|
+
authenticated: "✅",
|
|
1235
|
+
failed: "❌",
|
|
1236
|
+
};
|
|
1237
|
+
const icon = statusIcon[data.authStatus] || "❓";
|
|
1238
|
+
console.log(`\n ${icon} Auth Status: ${data.authStatus.toUpperCase()}`);
|
|
1239
|
+
console.log(" " + "-".repeat(40));
|
|
1240
|
+
console.log(` VM Status: ${data.vmStatus}`);
|
|
1241
|
+
if (data.authUrl) {
|
|
1242
|
+
console.log(` Auth URL: ${data.authUrl}`);
|
|
1243
|
+
}
|
|
1244
|
+
if (data.authRequestedAt) {
|
|
1245
|
+
console.log(` Requested: ${new Date(data.authRequestedAt).toLocaleString()}`);
|
|
1246
|
+
}
|
|
1247
|
+
if (data.authCodeSubmittedAt) {
|
|
1248
|
+
console.log(` Code sent: ${new Date(data.authCodeSubmittedAt).toLocaleString()}`);
|
|
1249
|
+
}
|
|
1250
|
+
console.log("");
|
|
1251
|
+
// Exit with error code if not authenticated
|
|
1252
|
+
if (data.authStatus === "failed") {
|
|
1253
|
+
process.exit(1);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
console.error("Error checking auth status:", error);
|
|
1258
|
+
process.exit(1);
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
// husky vm submit-auth-code <sessionId> <code>
|
|
1262
|
+
vmCommand
|
|
1263
|
+
.command("submit-auth-code <sessionId> <code>")
|
|
1264
|
+
.description("Submit Anthropic auth code to a VM session (fallback if GChat routing fails)")
|
|
1265
|
+
.option("--submitted-by <name>", "Name of person submitting the code")
|
|
1266
|
+
.option("--json", "Output as JSON")
|
|
1267
|
+
.action(async (sessionId, code, options) => {
|
|
1268
|
+
const config = ensureConfig();
|
|
1269
|
+
// Validate code format (same as server-side)
|
|
1270
|
+
const sanitizedCode = code.replace(/[^A-Za-z0-9-_]/g, "");
|
|
1271
|
+
if (sanitizedCode !== code || code.length < 4) {
|
|
1272
|
+
console.error("Error: Invalid auth code format.");
|
|
1273
|
+
console.error(" Code must be at least 4 characters and contain only:");
|
|
1274
|
+
console.error(" - Letters (A-Z, a-z)");
|
|
1275
|
+
console.error(" - Numbers (0-9)");
|
|
1276
|
+
console.error(" - Dashes (-) and underscores (_)");
|
|
1277
|
+
process.exit(1);
|
|
1278
|
+
}
|
|
1279
|
+
try {
|
|
1280
|
+
const res = await fetch(`${config.apiUrl}/api/vm-sessions/${sessionId}/submit-auth-code`, {
|
|
1281
|
+
method: "POST",
|
|
1282
|
+
headers: {
|
|
1283
|
+
"Content-Type": "application/json",
|
|
1284
|
+
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
1285
|
+
},
|
|
1286
|
+
body: JSON.stringify({
|
|
1287
|
+
code: code,
|
|
1288
|
+
submittedBy: options.submittedBy || "CLI",
|
|
1289
|
+
}),
|
|
1290
|
+
});
|
|
1291
|
+
if (!res.ok) {
|
|
1292
|
+
const error = await res.text();
|
|
1293
|
+
throw new Error(`API error: ${res.status} - ${error}`);
|
|
1294
|
+
}
|
|
1295
|
+
const data = await res.json();
|
|
1296
|
+
if (options.json) {
|
|
1297
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
console.log(`\n ✅ ${data.message}`);
|
|
1301
|
+
console.log("");
|
|
1302
|
+
}
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
console.error("Error submitting auth code:", error);
|
|
1305
|
+
process.exit(1);
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
// husky vm claude-code <sessionId>
|
|
1309
|
+
vmCommand
|
|
1310
|
+
.command("claude-code <sessionId>")
|
|
1311
|
+
.description("Connect to Claude Code on a VM via SSH (for debugging/interaction)")
|
|
1312
|
+
.option("--zone <zone>", "GCP zone", "europe-west1-b")
|
|
1313
|
+
.option("--attach", "Attach to existing tmux session instead of new shell")
|
|
1314
|
+
.action(async (sessionId, options) => {
|
|
1315
|
+
const config = ensureConfig();
|
|
1316
|
+
try {
|
|
1317
|
+
// Get session details
|
|
1318
|
+
const res = await fetch(`${config.apiUrl}/api/vm-sessions/${sessionId}`, {
|
|
1319
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
1320
|
+
});
|
|
1321
|
+
if (!res.ok) {
|
|
1322
|
+
const error = await res.text();
|
|
1323
|
+
throw new Error(`API error: ${res.status} - ${error}`);
|
|
1324
|
+
}
|
|
1325
|
+
const session = await res.json();
|
|
1326
|
+
if (session.vmStatus !== "running" && session.vmStatus !== "awaiting_auth") {
|
|
1327
|
+
console.error(`Error: VM is not running (status: ${session.vmStatus})`);
|
|
1328
|
+
process.exit(1);
|
|
1329
|
+
}
|
|
1330
|
+
const zone = options.zone || session.vmZone || "europe-west1-b";
|
|
1331
|
+
const vmName = session.vmName;
|
|
1332
|
+
console.log(`\n 🔗 Connecting to Claude Code on ${vmName}...`);
|
|
1333
|
+
console.log(" " + "-".repeat(40));
|
|
1334
|
+
console.log(` VM: ${vmName}`);
|
|
1335
|
+
console.log(` Zone: ${zone}`);
|
|
1336
|
+
console.log(` Status: ${session.vmStatus}`);
|
|
1337
|
+
if (session.authStatus) {
|
|
1338
|
+
console.log(` Auth: ${session.authStatus}`);
|
|
1339
|
+
}
|
|
1340
|
+
console.log("");
|
|
1341
|
+
// Build SSH command
|
|
1342
|
+
const sshArgs = [
|
|
1343
|
+
"compute", "ssh", vmName,
|
|
1344
|
+
`--zone=${zone}`,
|
|
1345
|
+
];
|
|
1346
|
+
if (options.attach) {
|
|
1347
|
+
// Attach to existing tmux session
|
|
1348
|
+
sshArgs.push("--command=tmux attach-session -t main || tmux new-session -s main");
|
|
1349
|
+
console.log(" Attaching to tmux session 'main'...\n");
|
|
1350
|
+
}
|
|
1351
|
+
else {
|
|
1352
|
+
// Just SSH in
|
|
1353
|
+
console.log(" Opening SSH shell...\n");
|
|
1354
|
+
console.log(" Tip: Run 'tmux attach -t main' to see Claude Code output");
|
|
1355
|
+
console.log("");
|
|
1356
|
+
}
|
|
1357
|
+
// Execute SSH
|
|
1358
|
+
const result = spawnSync("gcloud", sshArgs, {
|
|
1359
|
+
stdio: "inherit",
|
|
1360
|
+
});
|
|
1361
|
+
if (result.status !== 0) {
|
|
1362
|
+
console.error("\nSSH connection failed");
|
|
1363
|
+
process.exit(1);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
catch (error) {
|
|
1367
|
+
console.error("Error connecting to VM:", error);
|
|
1368
|
+
process.exit(1);
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
// husky vm send-message <sessionId> <message>
|
|
1372
|
+
vmCommand
|
|
1373
|
+
.command("send-message <sessionId> <message>")
|
|
1374
|
+
.description("Send a message directly to a VM agent")
|
|
1375
|
+
.option("--sender <name>", "Sender name", "CLI User")
|
|
1376
|
+
.option("--email <email>", "Sender email")
|
|
1377
|
+
.option("--thread <name>", "Thread name for context")
|
|
1378
|
+
.option("--json", "Output as JSON")
|
|
1379
|
+
.action(async (sessionId, message, options) => {
|
|
1380
|
+
const config = ensureConfig();
|
|
1381
|
+
try {
|
|
1382
|
+
const res = await fetch(`${config.apiUrl}/api/vm-sessions/${sessionId}/send-message`, {
|
|
1383
|
+
method: "POST",
|
|
1384
|
+
headers: {
|
|
1385
|
+
"Content-Type": "application/json",
|
|
1386
|
+
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
1387
|
+
},
|
|
1388
|
+
body: JSON.stringify({
|
|
1389
|
+
content: message,
|
|
1390
|
+
senderName: options.sender,
|
|
1391
|
+
senderEmail: options.email,
|
|
1392
|
+
threadName: options.thread,
|
|
1393
|
+
}),
|
|
1394
|
+
});
|
|
1395
|
+
if (!res.ok) {
|
|
1396
|
+
const error = await res.text();
|
|
1397
|
+
throw new Error(`API error: ${res.status} - ${error}`);
|
|
1398
|
+
}
|
|
1399
|
+
const data = await res.json();
|
|
1400
|
+
if (options.json) {
|
|
1401
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
console.log(`✅ ${data.message}`);
|
|
1405
|
+
console.log(` Message ID: ${data.messageId}`);
|
|
1406
|
+
}
|
|
1407
|
+
catch (error) {
|
|
1408
|
+
console.error("Error sending message:", error);
|
|
1409
|
+
process.exit(1);
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
// husky vm messages <sessionId>
|
|
1413
|
+
vmCommand
|
|
1414
|
+
.command("messages <sessionId>")
|
|
1415
|
+
.description("List messages sent to a VM session")
|
|
1416
|
+
.option("--limit <n>", "Number of messages", "20")
|
|
1417
|
+
.option("--status <status>", "Filter by status (pending, delivered, failed)")
|
|
1418
|
+
.option("--json", "Output as JSON")
|
|
1419
|
+
.action(async (sessionId, options) => {
|
|
1420
|
+
const config = ensureConfig();
|
|
1421
|
+
try {
|
|
1422
|
+
const params = new URLSearchParams();
|
|
1423
|
+
if (options.limit)
|
|
1424
|
+
params.set("limit", options.limit);
|
|
1425
|
+
if (options.status)
|
|
1426
|
+
params.set("status", options.status);
|
|
1427
|
+
const res = await fetch(`${config.apiUrl}/api/vm-sessions/${sessionId}/messages?${params}`, {
|
|
1428
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
1429
|
+
});
|
|
1430
|
+
if (!res.ok) {
|
|
1431
|
+
const error = await res.text();
|
|
1432
|
+
throw new Error(`API error: ${res.status} - ${error}`);
|
|
1433
|
+
}
|
|
1434
|
+
const data = await res.json();
|
|
1435
|
+
if (options.json) {
|
|
1436
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
if (!data.messages || data.messages.length === 0) {
|
|
1440
|
+
console.log("\n No messages found for this VM session.");
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
console.log("\n VM Session Messages");
|
|
1444
|
+
console.log(" " + "-".repeat(60));
|
|
1445
|
+
for (const msg of data.messages) {
|
|
1446
|
+
const time = new Date(msg.createdAt).toLocaleTimeString();
|
|
1447
|
+
const statusIcon = msg.status === "delivered" ? "✅" : msg.status === "failed" ? "❌" : "⏳";
|
|
1448
|
+
console.log(` ${statusIcon} [${time}] ${msg.senderName}`);
|
|
1449
|
+
console.log(` ${msg.content.substring(0, 60)}${msg.content.length > 60 ? "..." : ""}`);
|
|
1450
|
+
console.log("");
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
catch (error) {
|
|
1454
|
+
console.error("Error fetching messages:", error);
|
|
1455
|
+
process.exit(1);
|
|
1456
|
+
}
|
|
1457
|
+
});
|