@supatest/cli 0.0.2 → 0.0.3
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 +58 -315
- package/dist/agent-runner.js +224 -52
- package/dist/commands/login.js +392 -0
- package/dist/commands/setup.js +234 -0
- package/dist/config.js +29 -0
- package/dist/core/agent.js +270 -0
- package/dist/index.js +118 -31
- package/dist/modes/headless.js +117 -0
- package/dist/modes/interactive.js +430 -0
- package/dist/presenters/composite.js +32 -0
- package/dist/presenters/console.js +163 -0
- package/dist/presenters/react.js +220 -0
- package/dist/presenters/types.js +1 -0
- package/dist/presenters/web.js +78 -0
- package/dist/prompts/builder.js +181 -0
- package/dist/prompts/fixer.js +148 -0
- package/dist/prompts/headless.md +97 -0
- package/dist/prompts/index.js +3 -0
- package/dist/prompts/interactive.md +43 -0
- package/dist/prompts/plan.md +41 -0
- package/dist/prompts/planner.js +70 -0
- package/dist/prompts/prompts/builder.md +97 -0
- package/dist/prompts/prompts/fixer.md +100 -0
- package/dist/prompts/prompts/plan.md +41 -0
- package/dist/prompts/prompts/planner.md +41 -0
- package/dist/services/api-client.js +244 -0
- package/dist/services/event-streamer.js +130 -0
- package/dist/ui/App.js +322 -0
- package/dist/ui/components/AuthBanner.js +20 -0
- package/dist/ui/components/AuthDialog.js +32 -0
- package/dist/ui/components/Banner.js +12 -0
- package/dist/ui/components/ExpandableSection.js +17 -0
- package/dist/ui/components/Header.js +49 -0
- package/dist/ui/components/HelpMenu.js +89 -0
- package/dist/ui/components/InputPrompt.js +292 -0
- package/dist/ui/components/MessageList.js +42 -0
- package/dist/ui/components/QueuedMessageDisplay.js +31 -0
- package/dist/ui/components/Scrollable.js +103 -0
- package/dist/ui/components/SessionSelector.js +196 -0
- package/dist/ui/components/StatusBar.js +45 -0
- package/dist/ui/components/messages/AssistantMessage.js +20 -0
- package/dist/ui/components/messages/ErrorMessage.js +26 -0
- package/dist/ui/components/messages/LoadingMessage.js +28 -0
- package/dist/ui/components/messages/ThinkingMessage.js +17 -0
- package/dist/ui/components/messages/TodoMessage.js +44 -0
- package/dist/ui/components/messages/ToolMessage.js +218 -0
- package/dist/ui/components/messages/UserMessage.js +14 -0
- package/dist/ui/contexts/KeypressContext.js +527 -0
- package/dist/ui/contexts/MouseContext.js +98 -0
- package/dist/ui/contexts/SessionContext.js +131 -0
- package/dist/ui/hooks/useAnimatedScrollbar.js +83 -0
- package/dist/ui/hooks/useBatchedScroll.js +22 -0
- package/dist/ui/hooks/useBracketedPaste.js +31 -0
- package/dist/ui/hooks/useFocus.js +50 -0
- package/dist/ui/hooks/useKeypress.js +26 -0
- package/dist/ui/hooks/useModeToggle.js +25 -0
- package/dist/ui/types/auth.js +13 -0
- package/dist/ui/utils/file-completion.js +56 -0
- package/dist/ui/utils/input.js +50 -0
- package/dist/ui/utils/markdown.js +376 -0
- package/dist/ui/utils/mouse.js +189 -0
- package/dist/ui/utils/theme.js +59 -0
- package/dist/utils/banner.js +7 -14
- package/dist/utils/encryption.js +71 -0
- package/dist/utils/events.js +36 -0
- package/dist/utils/keychain-storage.js +120 -0
- package/dist/utils/logger.js +103 -1
- package/dist/utils/node-version.js +1 -3
- package/dist/utils/plan-file.js +75 -0
- package/dist/utils/project-instructions.js +23 -0
- package/dist/utils/rich-logger.js +1 -1
- package/dist/utils/stdio.js +80 -0
- package/dist/utils/summary.js +1 -5
- package/dist/utils/token-storage.js +242 -0
- package/package.json +35 -15
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token encryption utilities using AES-256-GCM with machine-specific key derivation.
|
|
3
|
+
* Inspired by Gemini CLI's file-token-storage implementation.
|
|
4
|
+
*/
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { hostname, userInfo } from "node:os";
|
|
7
|
+
const ALGORITHM = "aes-256-gcm";
|
|
8
|
+
const KEY_LENGTH = 32;
|
|
9
|
+
const IV_LENGTH = 16;
|
|
10
|
+
/**
|
|
11
|
+
* Derive an encryption key from machine-specific identifiers.
|
|
12
|
+
* Uses scrypt for key derivation with a salt based on hostname and username.
|
|
13
|
+
* This means tokens encrypted on one machine cannot be decrypted on another.
|
|
14
|
+
*/
|
|
15
|
+
function deriveEncryptionKey() {
|
|
16
|
+
const salt = `${hostname()}-${userInfo().username}-supatest-cli`;
|
|
17
|
+
return crypto.scryptSync("supatest-cli-token", salt, KEY_LENGTH);
|
|
18
|
+
}
|
|
19
|
+
// Cache the derived key for performance
|
|
20
|
+
let cachedKey = null;
|
|
21
|
+
function getEncryptionKey() {
|
|
22
|
+
if (!cachedKey) {
|
|
23
|
+
cachedKey = deriveEncryptionKey();
|
|
24
|
+
}
|
|
25
|
+
return cachedKey;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Encrypt plaintext using AES-256-GCM with a machine-derived key.
|
|
29
|
+
* Returns a colon-separated string: iv:authTag:ciphertext (all hex encoded)
|
|
30
|
+
*/
|
|
31
|
+
export function encrypt(plaintext) {
|
|
32
|
+
const key = getEncryptionKey();
|
|
33
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
34
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
35
|
+
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
|
36
|
+
encrypted += cipher.final("hex");
|
|
37
|
+
const authTag = cipher.getAuthTag();
|
|
38
|
+
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Decrypt ciphertext that was encrypted with the encrypt() function.
|
|
42
|
+
* Expects format: iv:authTag:ciphertext (all hex encoded)
|
|
43
|
+
*/
|
|
44
|
+
export function decrypt(encryptedData) {
|
|
45
|
+
const parts = encryptedData.split(":");
|
|
46
|
+
if (parts.length !== 3) {
|
|
47
|
+
throw new Error("Invalid encrypted data format");
|
|
48
|
+
}
|
|
49
|
+
const [ivHex, authTagHex, encrypted] = parts;
|
|
50
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
51
|
+
const authTag = Buffer.from(authTagHex, "hex");
|
|
52
|
+
const key = getEncryptionKey();
|
|
53
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
54
|
+
decipher.setAuthTag(authTag);
|
|
55
|
+
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
|
56
|
+
decrypted += decipher.final("utf8");
|
|
57
|
+
return decrypted;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check if a string appears to be in the encrypted format.
|
|
61
|
+
* Encrypted format has 3 hex segments separated by colons.
|
|
62
|
+
*/
|
|
63
|
+
export function isEncrypted(data) {
|
|
64
|
+
const parts = data.split(":");
|
|
65
|
+
if (parts.length !== 3) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// Check each part looks like valid hex
|
|
69
|
+
const hexRegex = /^[0-9a-fA-F]+$/;
|
|
70
|
+
return parts.every((p) => p.length > 0 && hexRegex.test(p));
|
|
71
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple event emitter for app-level events
|
|
3
|
+
*/
|
|
4
|
+
export var AppEvent;
|
|
5
|
+
(function (AppEvent) {
|
|
6
|
+
AppEvent["PasteTimeout"] = "paste-timeout";
|
|
7
|
+
AppEvent["OpenDebugConsole"] = "open-debug-console";
|
|
8
|
+
AppEvent["SelectionWarning"] = "selection-warning";
|
|
9
|
+
})(AppEvent || (AppEvent = {}));
|
|
10
|
+
class EventEmitter {
|
|
11
|
+
events = new Map();
|
|
12
|
+
on(event, callback) {
|
|
13
|
+
if (!this.events.has(event)) {
|
|
14
|
+
this.events.set(event, []);
|
|
15
|
+
}
|
|
16
|
+
this.events.get(event).push(callback);
|
|
17
|
+
}
|
|
18
|
+
emit(event, ...args) {
|
|
19
|
+
const callbacks = this.events.get(event);
|
|
20
|
+
if (callbacks) {
|
|
21
|
+
for (const callback of callbacks) {
|
|
22
|
+
callback(...args);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
off(event, callback) {
|
|
27
|
+
const callbacks = this.events.get(event);
|
|
28
|
+
if (callbacks) {
|
|
29
|
+
const index = callbacks.indexOf(callback);
|
|
30
|
+
if (index > -1) {
|
|
31
|
+
callbacks.splice(index, 1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export const appEvents = new EventEmitter();
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS Keychain token storage using keytar.
|
|
3
|
+
* Inspired by Gemini CLI's keychain-token-storage implementation.
|
|
4
|
+
*/
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
const SERVICE_NAME = "supatest-cli";
|
|
7
|
+
const ACCOUNT_NAME = "cli-token";
|
|
8
|
+
const KEYCHAIN_TEST_PREFIX = "__keychain_test__";
|
|
9
|
+
let keytarModule = null;
|
|
10
|
+
let keytarLoadAttempted = false;
|
|
11
|
+
let keychainAvailable = null;
|
|
12
|
+
/**
|
|
13
|
+
* Dynamically load the keytar module.
|
|
14
|
+
* Returns null if keytar is not available (not installed or failed to load).
|
|
15
|
+
*/
|
|
16
|
+
async function getKeytar() {
|
|
17
|
+
if (keytarLoadAttempted) {
|
|
18
|
+
return keytarModule;
|
|
19
|
+
}
|
|
20
|
+
keytarLoadAttempted = true;
|
|
21
|
+
try {
|
|
22
|
+
const moduleName = "keytar";
|
|
23
|
+
const mod = await import(moduleName);
|
|
24
|
+
keytarModule = mod.default || mod;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Keytar is optional, so we silently fall back to file storage
|
|
28
|
+
keytarModule = null;
|
|
29
|
+
}
|
|
30
|
+
return keytarModule;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Check if OS keychain is available via a set/get/delete test cycle.
|
|
34
|
+
* Respects SUPATEST_FORCE_FILE_STORAGE environment variable.
|
|
35
|
+
*/
|
|
36
|
+
export async function isKeychainAvailable() {
|
|
37
|
+
if (keychainAvailable !== null) {
|
|
38
|
+
return keychainAvailable;
|
|
39
|
+
}
|
|
40
|
+
if (process.env.SUPATEST_FORCE_FILE_STORAGE === "true") {
|
|
41
|
+
keychainAvailable = false;
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const keytar = await getKeytar();
|
|
46
|
+
if (!keytar) {
|
|
47
|
+
keychainAvailable = false;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
// Test with a set/get/delete cycle
|
|
51
|
+
const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString("hex")}`;
|
|
52
|
+
const testPassword = "test";
|
|
53
|
+
await keytar.setPassword(SERVICE_NAME, testAccount, testPassword);
|
|
54
|
+
const retrieved = await keytar.getPassword(SERVICE_NAME, testAccount);
|
|
55
|
+
const deleted = await keytar.deletePassword(SERVICE_NAME, testAccount);
|
|
56
|
+
keychainAvailable = deleted && retrieved === testPassword;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
keychainAvailable = false;
|
|
60
|
+
}
|
|
61
|
+
return keychainAvailable;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Save token payload to OS keychain.
|
|
65
|
+
* Returns true if successful, false otherwise.
|
|
66
|
+
*/
|
|
67
|
+
export async function saveToKeychain(token, expiresAt) {
|
|
68
|
+
const keytar = await getKeytar();
|
|
69
|
+
if (!keytar) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const payload = {
|
|
73
|
+
token,
|
|
74
|
+
expiresAt,
|
|
75
|
+
createdAt: new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, JSON.stringify(payload));
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Load token payload from OS keychain.
|
|
87
|
+
* Returns null if not found or keychain unavailable.
|
|
88
|
+
*/
|
|
89
|
+
export async function loadFromKeychain() {
|
|
90
|
+
const keytar = await getKeytar();
|
|
91
|
+
if (!keytar) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const data = await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
|
|
96
|
+
if (!data) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return JSON.parse(data);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Remove token from OS keychain.
|
|
107
|
+
* Returns true if successfully removed, false otherwise.
|
|
108
|
+
*/
|
|
109
|
+
export async function removeFromKeychain() {
|
|
110
|
+
const keytar = await getKeytar();
|
|
111
|
+
if (!keytar) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/utils/logger.js
CHANGED
|
@@ -1,63 +1,161 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
1
3
|
import chalk from "chalk";
|
|
2
4
|
class Logger {
|
|
3
5
|
verbose = false;
|
|
6
|
+
silent = false;
|
|
7
|
+
logFile = null;
|
|
8
|
+
isDev = false;
|
|
4
9
|
setVerbose(enabled) {
|
|
5
10
|
this.verbose = enabled;
|
|
6
11
|
}
|
|
12
|
+
setSilent(enabled) {
|
|
13
|
+
this.silent = enabled;
|
|
14
|
+
}
|
|
15
|
+
isSilent() {
|
|
16
|
+
return this.silent;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Enable file logging (dev mode only)
|
|
20
|
+
*/
|
|
21
|
+
enableFileLogging(isDev = false) {
|
|
22
|
+
this.isDev = isDev;
|
|
23
|
+
if (!isDev)
|
|
24
|
+
return;
|
|
25
|
+
// Write directly to cli.log in current directory
|
|
26
|
+
this.logFile = path.join(process.cwd(), "cli.log");
|
|
27
|
+
// Add session separator to log file
|
|
28
|
+
const separator = `\n${"=".repeat(80)}\n[${new Date().toISOString()}] New CLI session started\n${"=".repeat(80)}\n`;
|
|
29
|
+
try {
|
|
30
|
+
fs.appendFileSync(this.logFile, separator);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// Silently fail
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Write to log file (dev mode only)
|
|
38
|
+
*/
|
|
39
|
+
writeToFile(level, message, data) {
|
|
40
|
+
if (!this.isDev || !this.logFile)
|
|
41
|
+
return;
|
|
42
|
+
const timestamp = new Date().toISOString();
|
|
43
|
+
const logEntry = data
|
|
44
|
+
? `[${timestamp}] [${level}] ${message} ${JSON.stringify(data, null, 2)}\n`
|
|
45
|
+
: `[${timestamp}] [${level}] ${message}\n`;
|
|
46
|
+
try {
|
|
47
|
+
fs.appendFileSync(this.logFile, logEntry);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
// Silently fail - don't disrupt CLI operation
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Check if an error message is critical and should bypass silent mode
|
|
55
|
+
* Critical errors are those that prevent the CLI from starting or executing
|
|
56
|
+
*/
|
|
57
|
+
isCriticalError(message) {
|
|
58
|
+
const criticalPatterns = [
|
|
59
|
+
/api key/i,
|
|
60
|
+
/authentication/i,
|
|
61
|
+
/node.*version/i,
|
|
62
|
+
/missing.*required/i,
|
|
63
|
+
/failed to install/i,
|
|
64
|
+
/fatal/i,
|
|
65
|
+
];
|
|
66
|
+
return criticalPatterns.some((pattern) => pattern.test(message));
|
|
67
|
+
}
|
|
7
68
|
info(message) {
|
|
69
|
+
if (this.silent)
|
|
70
|
+
return;
|
|
8
71
|
console.log(chalk.blue("ℹ"), message);
|
|
9
72
|
}
|
|
10
73
|
success(message) {
|
|
74
|
+
if (this.silent)
|
|
75
|
+
return;
|
|
11
76
|
console.log(chalk.green("✓"), message);
|
|
12
77
|
}
|
|
13
78
|
error(message) {
|
|
79
|
+
// Allow critical errors through even in silent mode
|
|
80
|
+
if (this.silent && !this.isCriticalError(message))
|
|
81
|
+
return;
|
|
14
82
|
console.error(chalk.red("✗"), message);
|
|
15
83
|
}
|
|
16
84
|
warn(message) {
|
|
85
|
+
if (this.silent)
|
|
86
|
+
return;
|
|
17
87
|
console.warn(chalk.yellow("⚠"), message);
|
|
18
88
|
}
|
|
19
|
-
debug(message) {
|
|
89
|
+
debug(message, data) {
|
|
90
|
+
this.writeToFile("DEBUG", message, data);
|
|
91
|
+
if (this.silent)
|
|
92
|
+
return;
|
|
20
93
|
if (this.verbose) {
|
|
21
94
|
console.log(chalk.gray("→"), message);
|
|
95
|
+
if (data) {
|
|
96
|
+
console.log(chalk.gray(JSON.stringify(data, null, 2)));
|
|
97
|
+
}
|
|
22
98
|
}
|
|
23
99
|
}
|
|
24
100
|
section(title) {
|
|
101
|
+
if (this.silent)
|
|
102
|
+
return;
|
|
25
103
|
console.log("\n" + chalk.bold.red(`━━━ ${title} ━━━`));
|
|
26
104
|
}
|
|
27
105
|
summary(title) {
|
|
106
|
+
if (this.silent)
|
|
107
|
+
return;
|
|
28
108
|
console.log("\n" + chalk.bold.cyan(`╔═══ ${title} ═══╗`));
|
|
29
109
|
}
|
|
30
110
|
raw(message) {
|
|
111
|
+
if (this.silent)
|
|
112
|
+
return;
|
|
31
113
|
console.log(message);
|
|
32
114
|
}
|
|
33
115
|
stream(chunk) {
|
|
116
|
+
if (this.silent)
|
|
117
|
+
return;
|
|
34
118
|
process.stdout.write(chalk.dim(chunk));
|
|
35
119
|
}
|
|
36
120
|
toolRead(filePath) {
|
|
121
|
+
if (this.silent)
|
|
122
|
+
return;
|
|
37
123
|
console.log("");
|
|
38
124
|
console.log(chalk.blue("📖"), chalk.dim("Reading:"), chalk.white(filePath));
|
|
39
125
|
}
|
|
40
126
|
toolWrite(filePath) {
|
|
127
|
+
if (this.silent)
|
|
128
|
+
return;
|
|
41
129
|
console.log("");
|
|
42
130
|
console.log(chalk.green("✏️"), chalk.dim("Writing:"), chalk.white(filePath));
|
|
43
131
|
}
|
|
44
132
|
toolEdit(filePath) {
|
|
133
|
+
if (this.silent)
|
|
134
|
+
return;
|
|
45
135
|
console.log("");
|
|
46
136
|
console.log(chalk.yellow("✏️"), chalk.dim("Editing:"), chalk.white(filePath));
|
|
47
137
|
}
|
|
48
138
|
toolBash(command) {
|
|
139
|
+
if (this.silent)
|
|
140
|
+
return;
|
|
49
141
|
console.log("");
|
|
50
142
|
console.log(chalk.cyan("🔨"), chalk.dim("Running:"), chalk.white(command));
|
|
51
143
|
}
|
|
52
144
|
toolSearch(type, pattern) {
|
|
145
|
+
if (this.silent)
|
|
146
|
+
return;
|
|
53
147
|
console.log("");
|
|
54
148
|
console.log(chalk.cyan("🔍"), chalk.dim(`Searching ${type}:`), chalk.white(pattern));
|
|
55
149
|
}
|
|
56
150
|
toolAgent(agentType) {
|
|
151
|
+
if (this.silent)
|
|
152
|
+
return;
|
|
57
153
|
console.log("");
|
|
58
154
|
console.log(chalk.cyan("🤖"), chalk.dim("Launching agent:"), chalk.white(agentType));
|
|
59
155
|
}
|
|
60
156
|
todoUpdate(todos) {
|
|
157
|
+
if (this.silent)
|
|
158
|
+
return;
|
|
61
159
|
const completed = todos.filter((t) => t.status === "completed");
|
|
62
160
|
const inProgress = todos.filter((t) => t.status === "in_progress");
|
|
63
161
|
const pending = todos.filter((t) => t.status === "pending");
|
|
@@ -88,9 +186,13 @@ class Logger {
|
|
|
88
186
|
}
|
|
89
187
|
}
|
|
90
188
|
divider() {
|
|
189
|
+
if (this.silent)
|
|
190
|
+
return;
|
|
91
191
|
console.log(chalk.gray("─".repeat(60)));
|
|
92
192
|
}
|
|
93
193
|
box(title) {
|
|
194
|
+
if (this.silent)
|
|
195
|
+
return;
|
|
94
196
|
const width = 60;
|
|
95
197
|
const padding = Math.max(0, width - title.length - 2);
|
|
96
198
|
const leftPad = Math.floor(padding / 2);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
|
-
import { logger } from "./logger
|
|
2
|
+
import { logger } from "./logger";
|
|
3
3
|
const MINIMUM_NODE_VERSION = 18;
|
|
4
4
|
/**
|
|
5
5
|
* Parse a version string like "v18.17.0" or "18.17.0" into components
|
|
@@ -76,8 +76,6 @@ export function checkNodeVersion() {
|
|
|
76
76
|
process.exit(1);
|
|
77
77
|
}
|
|
78
78
|
// Success - version is adequate
|
|
79
|
-
// Silent unless verbose mode is enabled
|
|
80
|
-
logger.debug(`✓ Node.js ${nodeVersion.raw} detected (meets minimum requirement of ${MINIMUM_NODE_VERSION}.0.0)`);
|
|
81
79
|
}
|
|
82
80
|
/**
|
|
83
81
|
* Get Node.js version info for display purposes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan file utilities
|
|
3
|
+
* Handles creation and management of plan files for plan mode
|
|
4
|
+
*/
|
|
5
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
const PLAN_DIR = ".supatest/plans";
|
|
8
|
+
/**
|
|
9
|
+
* Generate a unique plan file name with timestamp
|
|
10
|
+
*/
|
|
11
|
+
function generatePlanFileName() {
|
|
12
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
13
|
+
return `plan-${timestamp}.md`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Create a new plan file in the .supatest/plans directory
|
|
17
|
+
* @param cwd - The current working directory (project root)
|
|
18
|
+
* @returns The path to the created plan file
|
|
19
|
+
*/
|
|
20
|
+
export async function createPlanFile(cwd) {
|
|
21
|
+
const planDir = join(cwd, PLAN_DIR);
|
|
22
|
+
// Ensure the plan directory exists
|
|
23
|
+
await mkdir(planDir, { recursive: true });
|
|
24
|
+
const planFileName = generatePlanFileName();
|
|
25
|
+
const planPath = join(planDir, planFileName);
|
|
26
|
+
// Create the initial plan file with a placeholder
|
|
27
|
+
const initialContent = `# Plan
|
|
28
|
+
|
|
29
|
+
_Planning in progress..._
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Summary
|
|
34
|
+
|
|
35
|
+
_To be filled by the agent_
|
|
36
|
+
|
|
37
|
+
## Tasks
|
|
38
|
+
|
|
39
|
+
_To be filled by the agent_
|
|
40
|
+
|
|
41
|
+
## Files to Modify
|
|
42
|
+
|
|
43
|
+
_To be filled by the agent_
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
_Generated by Supatest AI_
|
|
48
|
+
`;
|
|
49
|
+
await writeFile(planPath, initialContent, "utf-8");
|
|
50
|
+
return planPath;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Update the contents of an existing plan file
|
|
54
|
+
* @param planPath - The path to the plan file
|
|
55
|
+
* @param content - The new content to write
|
|
56
|
+
*/
|
|
57
|
+
export async function updatePlanFile(planPath, content) {
|
|
58
|
+
await writeFile(planPath, content, "utf-8");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read the contents of a plan file
|
|
62
|
+
* @param planPath - The path to the plan file
|
|
63
|
+
* @returns The contents of the plan file
|
|
64
|
+
*/
|
|
65
|
+
export async function readPlanFile(planPath) {
|
|
66
|
+
return await readFile(planPath, "utf-8");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get the plan directory path for a given project
|
|
70
|
+
* @param cwd - The current working directory (project root)
|
|
71
|
+
* @returns The path to the plan directory
|
|
72
|
+
*/
|
|
73
|
+
export function getPlanDirectory(cwd) {
|
|
74
|
+
return join(cwd, PLAN_DIR);
|
|
75
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Load project-specific instructions from SUPATEST.md
|
|
5
|
+
* Checks multiple locations in order of precedence
|
|
6
|
+
*/
|
|
7
|
+
export function loadProjectInstructions(cwd) {
|
|
8
|
+
const paths = [
|
|
9
|
+
join(cwd, "SUPATEST.md"),
|
|
10
|
+
join(cwd, ".supatest", "SUPATEST.md"),
|
|
11
|
+
];
|
|
12
|
+
for (const path of paths) {
|
|
13
|
+
if (existsSync(path)) {
|
|
14
|
+
try {
|
|
15
|
+
return readFileSync(path, "utf-8");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Skip if can't read
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stdio utilities for writing to stdout/stderr
|
|
3
|
+
* Based on Gemini CLI's stdio handling
|
|
4
|
+
*/
|
|
5
|
+
// Capture the original stdout and stderr write methods before any monkey patching occurs.
|
|
6
|
+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
7
|
+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
8
|
+
/**
|
|
9
|
+
* Writes to the real stdout, bypassing any monkey patching on process.stdout.write.
|
|
10
|
+
*/
|
|
11
|
+
export function writeToStdout(...args) {
|
|
12
|
+
return originalStdoutWrite(...args);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Writes to the real stderr, bypassing any monkey patching on process.stderr.write.
|
|
16
|
+
*/
|
|
17
|
+
export function writeToStderr(...args) {
|
|
18
|
+
return originalStderrWrite(...args);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Monkey patches process.stdout.write and process.stderr.write to suppress output.
|
|
22
|
+
* This prevents stray output from libraries (or the app itself) from corrupting the UI.
|
|
23
|
+
* Returns a cleanup function that restores the original write methods.
|
|
24
|
+
*/
|
|
25
|
+
export function patchStdio() {
|
|
26
|
+
const previousStdoutWrite = process.stdout.write;
|
|
27
|
+
const previousStderrWrite = process.stderr.write;
|
|
28
|
+
process.stdout.write = (chunk, encodingOrCb, cb) => {
|
|
29
|
+
// Suppress the output (don't write it anywhere)
|
|
30
|
+
const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb;
|
|
31
|
+
if (callback) {
|
|
32
|
+
callback();
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
};
|
|
36
|
+
process.stderr.write = (chunk, encodingOrCb, cb) => {
|
|
37
|
+
// Suppress the output (don't write it anywhere)
|
|
38
|
+
const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb;
|
|
39
|
+
if (callback) {
|
|
40
|
+
callback();
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
};
|
|
44
|
+
return () => {
|
|
45
|
+
process.stdout.write = previousStdoutWrite;
|
|
46
|
+
process.stderr.write = previousStderrWrite;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Creates proxies for process.stdout and process.stderr that use the real write methods
|
|
51
|
+
* (writeToStdout and writeToStderr) bypassing any monkey patching.
|
|
52
|
+
* This is used by Ink to render to the real output.
|
|
53
|
+
*/
|
|
54
|
+
export function createInkStdio() {
|
|
55
|
+
const inkStdout = new Proxy(process.stdout, {
|
|
56
|
+
get(target, prop, receiver) {
|
|
57
|
+
if (prop === 'write') {
|
|
58
|
+
return writeToStdout;
|
|
59
|
+
}
|
|
60
|
+
const value = Reflect.get(target, prop, receiver);
|
|
61
|
+
if (typeof value === 'function') {
|
|
62
|
+
return value.bind(target);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const inkStderr = new Proxy(process.stderr, {
|
|
68
|
+
get(target, prop, receiver) {
|
|
69
|
+
if (prop === 'write') {
|
|
70
|
+
return writeToStderr;
|
|
71
|
+
}
|
|
72
|
+
const value = Reflect.get(target, prop, receiver);
|
|
73
|
+
if (typeof value === 'function') {
|
|
74
|
+
return value.bind(target);
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
return { stdout: inkStdout, stderr: inkStderr };
|
|
80
|
+
}
|
package/dist/utils/summary.js
CHANGED
|
@@ -83,12 +83,8 @@ export function generateSummary(stats, result, verbose = false) {
|
|
|
83
83
|
const summaryLines = result.summary.split("\n");
|
|
84
84
|
let lineCount = 0;
|
|
85
85
|
for (const line of summaryLines) {
|
|
86
|
-
|
|
87
|
-
lines.push(chalk.gray("... (truncated)"));
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
86
|
+
// Removed truncation limit to show full summary
|
|
90
87
|
lines.push(line);
|
|
91
|
-
lineCount++;
|
|
92
88
|
}
|
|
93
89
|
}
|
|
94
90
|
lines.push("");
|