@unscientificjszhai/howto 1.0.0 → 1.0.2
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.CN.md +74 -8
- package/README.md +74 -8
- package/THIRD_PARTY_NOTICES.md +6 -4
- package/dist/ai/command-schema.js +7 -1
- package/dist/ai/errors.js +7 -23
- package/dist/ai/gemini.js +67 -27
- package/dist/ai/openai.js +35 -27
- package/dist/cli.js +12 -1
- package/dist/config-file.js +97 -51
- package/dist/config.js +8 -7
- package/dist/errors.js +19 -17
- package/dist/execute.js +12 -34
- package/dist/index.js +100 -98
- package/dist/init/InitializationApp.js +177 -32
- package/dist/init/index.js +35 -33
- package/dist/init/state.js +46 -23
- package/dist/prompt.js +24 -6
- package/dist/safety/command-risk.js +11 -0
- package/dist/safety/dangerous-command.js +470 -92
- package/dist/shell/command-analysis.js +373 -0
- package/dist/shell/execution-environment.js +25 -0
- package/dist/shell/runtime-environment.js +61 -0
- package/dist/terminal-text.js +33 -0
- package/dist/ui/App.js +67 -28
- package/dist/ui/ConfirmView.js +163 -67
- package/dist/ui/InteractiveSessionProvider.js +19 -0
- package/dist/ui/ResolvePlaceholdersView.js +107 -27
- package/dist/ui/SelectCommandView.js +73 -23
- package/dist/ui/SelectedCommandDisplay.js +11 -9
- package/dist/ui/interactive-session.js +476 -0
- package/dist/ui/paste-framing.js +105 -0
- package/dist/ui/placeholder-logic.js +28 -16
- package/dist/ui/resize-safe-output.js +73 -0
- package/dist/ui/run-interactive-command.js +55 -0
- package/dist/ui/single-line-preview.js +28 -0
- package/dist/ui/text-input.js +53 -0
- package/dist/ui/use-keyboard-input.js +22 -0
- package/dist/ui/use-paste-aware-input.js +11 -0
- package/dist/user-visible-error.js +19 -0
- package/dist/validation/ai-response.js +34 -6
- package/dist/validation/command-tool.js +31 -132
- package/dist/validation/generated-commands.js +5 -16
- package/dist/version.js +38 -0
- package/package.json +31 -16
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
3
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "fish", "csh", "tcsh", "ksh", "dash", "ash", "hush"]);
|
|
4
|
+
const REINTERPRETING_PREFIXES = new Set([
|
|
5
|
+
"command",
|
|
6
|
+
"exec",
|
|
7
|
+
"builtin",
|
|
8
|
+
"time",
|
|
9
|
+
"noglob",
|
|
10
|
+
"nocorrect",
|
|
11
|
+
"eval",
|
|
12
|
+
"source",
|
|
13
|
+
".",
|
|
14
|
+
]);
|
|
15
|
+
const RESERVED_WORDS = new Set([
|
|
16
|
+
"if",
|
|
17
|
+
"then",
|
|
18
|
+
"elif",
|
|
19
|
+
"else",
|
|
20
|
+
"fi",
|
|
21
|
+
"for",
|
|
22
|
+
"while",
|
|
23
|
+
"until",
|
|
24
|
+
"do",
|
|
25
|
+
"done",
|
|
26
|
+
"case",
|
|
27
|
+
"esac",
|
|
28
|
+
"select",
|
|
29
|
+
"function",
|
|
30
|
+
"coproc",
|
|
31
|
+
"repeat",
|
|
32
|
+
"and",
|
|
33
|
+
"or",
|
|
34
|
+
"not",
|
|
35
|
+
"!",
|
|
36
|
+
]);
|
|
37
|
+
const WRAPPER_PATHS = new Set([
|
|
38
|
+
"sudo",
|
|
39
|
+
"env",
|
|
40
|
+
"/usr/bin/sudo",
|
|
41
|
+
"/bin/sudo",
|
|
42
|
+
"/usr/bin/env",
|
|
43
|
+
"/bin/env",
|
|
44
|
+
]);
|
|
45
|
+
const SUDO_ZERO_SHORT = new Set("ABbEHknPS");
|
|
46
|
+
const SUDO_ONE_SHORT = new Set("CDghpRTu");
|
|
47
|
+
const SUDO_ZERO_LONG = new Set([
|
|
48
|
+
"--askpass",
|
|
49
|
+
"--bell",
|
|
50
|
+
"--background",
|
|
51
|
+
"--preserve-env",
|
|
52
|
+
"--set-home",
|
|
53
|
+
"--reset-timestamp",
|
|
54
|
+
"--non-interactive",
|
|
55
|
+
"--preserve-groups",
|
|
56
|
+
"--stdin",
|
|
57
|
+
]);
|
|
58
|
+
const SUDO_ONE_LONG = new Set([
|
|
59
|
+
"--close-from",
|
|
60
|
+
"--chdir",
|
|
61
|
+
"--group",
|
|
62
|
+
"--host",
|
|
63
|
+
"--prompt",
|
|
64
|
+
"--chroot",
|
|
65
|
+
"--command-timeout",
|
|
66
|
+
"--user",
|
|
67
|
+
]);
|
|
68
|
+
const ENV_ZERO_SHORT = new Set("iv");
|
|
69
|
+
const ENV_ONE_SHORT = new Set("uCP");
|
|
70
|
+
const ENV_ZERO_LONG = new Set(["--ignore-environment", "--debug"]);
|
|
71
|
+
const ENV_ONE_LONG = new Set(["--unset", "--chdir"]);
|
|
72
|
+
export function isShellExecutable(value) {
|
|
73
|
+
return SHELLS.has(posix.basename(value));
|
|
74
|
+
}
|
|
75
|
+
export function parseShellCommand(command) {
|
|
76
|
+
const lexed = lexShellCommand(command);
|
|
77
|
+
if (lexed.kind === "unsupported")
|
|
78
|
+
return lexed;
|
|
79
|
+
const commands = [];
|
|
80
|
+
let words = [];
|
|
81
|
+
let needsCommand = false;
|
|
82
|
+
let hasRedirection = false;
|
|
83
|
+
for (let index = 0; index < lexed.tokens.length; index += 1) {
|
|
84
|
+
const token = lexed.tokens[index];
|
|
85
|
+
if (token.kind === "word") {
|
|
86
|
+
words.push(token.word);
|
|
87
|
+
needsCommand = false;
|
|
88
|
+
}
|
|
89
|
+
else if (token.kind === "redirect") {
|
|
90
|
+
hasRedirection = true;
|
|
91
|
+
if (token.needsTarget) {
|
|
92
|
+
const target = lexed.tokens[index + 1];
|
|
93
|
+
if (target?.kind !== "word")
|
|
94
|
+
return unsupported("syntax");
|
|
95
|
+
index += 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
if (words.length === 0) {
|
|
100
|
+
if (hasRedirection)
|
|
101
|
+
return unsupported("syntax");
|
|
102
|
+
if (token.separator === "\n")
|
|
103
|
+
continue;
|
|
104
|
+
return unsupported("syntax");
|
|
105
|
+
}
|
|
106
|
+
commands.push({ words, separator: token.separator });
|
|
107
|
+
words = [];
|
|
108
|
+
hasRedirection = false;
|
|
109
|
+
needsCommand =
|
|
110
|
+
token.separator === "&&" || token.separator === "||" || token.separator === "|";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (needsCommand)
|
|
114
|
+
return unsupported("syntax");
|
|
115
|
+
if (words.length === 0 && hasRedirection)
|
|
116
|
+
return unsupported("syntax");
|
|
117
|
+
if (words.length > 0)
|
|
118
|
+
commands.push({ words });
|
|
119
|
+
return commands.length > 0 ? { kind: "parsed", commands } : unsupported("missing-executable");
|
|
120
|
+
}
|
|
121
|
+
export function resolveCommandPrefix(words) {
|
|
122
|
+
let index = 0;
|
|
123
|
+
while (words[index]?.assignmentOperator === "=")
|
|
124
|
+
index += 1;
|
|
125
|
+
while (index < words.length) {
|
|
126
|
+
const word = words[index];
|
|
127
|
+
// += 在 Bash/zsh 与 dash 中语义不同;无法确定 shell 时不能跳过它证明工具身份。
|
|
128
|
+
if (word.assignmentOperator === "+=" ||
|
|
129
|
+
word.hasExpansion ||
|
|
130
|
+
word.value === "" ||
|
|
131
|
+
word.value.startsWith("-"))
|
|
132
|
+
return unsupported("prefix");
|
|
133
|
+
const name = posix.basename(word.value);
|
|
134
|
+
if (REINTERPRETING_PREFIXES.has(name) || RESERVED_WORDS.has(word.value))
|
|
135
|
+
return unsupported("prefix");
|
|
136
|
+
if (name !== "sudo" && name !== "env") {
|
|
137
|
+
return { kind: "parsed", executable: word, args: words.slice(index + 1) };
|
|
138
|
+
}
|
|
139
|
+
if (!WRAPPER_PATHS.has(word.value))
|
|
140
|
+
return unsupported("prefix");
|
|
141
|
+
const skipped = skipWrapperOptions(words, index + 1, name);
|
|
142
|
+
if (typeof skipped !== "number")
|
|
143
|
+
return skipped;
|
|
144
|
+
index = skipped;
|
|
145
|
+
// wrapper 按 argv 识别赋值;env 的非空名称不受 shell 标识符规则限制。
|
|
146
|
+
// sudo 保留 NAME= 白名单,其他含等号形式及动态赋值不推测为工具。
|
|
147
|
+
while (words[index] !== undefined) {
|
|
148
|
+
const assignment = words[index];
|
|
149
|
+
const equals = assignment.value.indexOf("=");
|
|
150
|
+
if (equals < 0)
|
|
151
|
+
break;
|
|
152
|
+
if (assignment.hasExpansion ||
|
|
153
|
+
(name === "env" ? equals === 0 : !ASSIGNMENT.test(assignment.value)))
|
|
154
|
+
return unsupported("prefix");
|
|
155
|
+
index += 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return unsupported("missing-executable");
|
|
159
|
+
}
|
|
160
|
+
function skipWrapperOptions(words, start, wrapper) {
|
|
161
|
+
const zeroShort = wrapper === "sudo" ? SUDO_ZERO_SHORT : ENV_ZERO_SHORT;
|
|
162
|
+
const oneShort = wrapper === "sudo" ? SUDO_ONE_SHORT : ENV_ONE_SHORT;
|
|
163
|
+
const zeroLong = wrapper === "sudo" ? SUDO_ZERO_LONG : ENV_ZERO_LONG;
|
|
164
|
+
const oneLong = wrapper === "sudo" ? SUDO_ONE_LONG : ENV_ONE_LONG;
|
|
165
|
+
let index = start;
|
|
166
|
+
while (index < words.length) {
|
|
167
|
+
const word = words[index];
|
|
168
|
+
if (word.hasExpansion)
|
|
169
|
+
return unsupported("prefix");
|
|
170
|
+
const option = word.value;
|
|
171
|
+
if (option === "--")
|
|
172
|
+
return index + 1;
|
|
173
|
+
if (option === "-" && wrapper === "env")
|
|
174
|
+
return index + 1;
|
|
175
|
+
if (!option.startsWith("-") || option === "-")
|
|
176
|
+
return index;
|
|
177
|
+
let needsArgument = false;
|
|
178
|
+
if (option.startsWith("--")) {
|
|
179
|
+
const equals = option.indexOf("=");
|
|
180
|
+
const name = equals < 0 ? option : option.slice(0, equals);
|
|
181
|
+
if (zeroLong.has(name)) {
|
|
182
|
+
if (equals >= 0 && !(wrapper === "sudo" && name === "--preserve-env"))
|
|
183
|
+
return unsupported("option");
|
|
184
|
+
}
|
|
185
|
+
else if (oneLong.has(name)) {
|
|
186
|
+
if (equals >= 0) {
|
|
187
|
+
if (option.slice(equals + 1) === "")
|
|
188
|
+
return unsupported("missing-argument");
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
needsArgument = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
return unsupported("option");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
for (let offset = 1; offset < option.length; offset += 1) {
|
|
200
|
+
const flag = option[offset];
|
|
201
|
+
if (zeroShort.has(flag))
|
|
202
|
+
continue;
|
|
203
|
+
if (!oneShort.has(flag))
|
|
204
|
+
return unsupported("option");
|
|
205
|
+
needsArgument = offset === option.length - 1;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
index += 1;
|
|
210
|
+
if (needsArgument) {
|
|
211
|
+
const argument = words[index];
|
|
212
|
+
if (argument === undefined || argument.value === "")
|
|
213
|
+
return unsupported("missing-argument");
|
|
214
|
+
if (argument.hasExpansion)
|
|
215
|
+
return unsupported("prefix");
|
|
216
|
+
index += 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return index;
|
|
220
|
+
}
|
|
221
|
+
function lexShellCommand(command) {
|
|
222
|
+
const tokens = [];
|
|
223
|
+
let index = 0;
|
|
224
|
+
while (index < command.length) {
|
|
225
|
+
const char = command[index];
|
|
226
|
+
if (char === " " || char === "\t") {
|
|
227
|
+
index += 1;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (char === "\\" && command[index + 1] === "\n") {
|
|
231
|
+
index += 2;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (char === "#") {
|
|
235
|
+
while (index < command.length && command[index] !== "\n")
|
|
236
|
+
index += 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (";\n&|".includes(char)) {
|
|
240
|
+
if (char === "&" && command[index + 1] === ">")
|
|
241
|
+
return unsupported("syntax");
|
|
242
|
+
const pair = command.slice(index, index + 2);
|
|
243
|
+
const separator = pair === "&&" || pair === "||" ? pair : char;
|
|
244
|
+
tokens.push({ kind: "separator", separator });
|
|
245
|
+
index += separator.length;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if ("(){}".includes(char))
|
|
249
|
+
return unsupported("syntax");
|
|
250
|
+
const redirect = /^(?:\d+)?(?:>>|[<>])/.exec(command.slice(index));
|
|
251
|
+
if (redirect !== null) {
|
|
252
|
+
index += redirect[0].length;
|
|
253
|
+
if (command[index] === "<" || command[index] === ">" || command[index] === "(")
|
|
254
|
+
return unsupported("syntax");
|
|
255
|
+
if (command[index] === "&") {
|
|
256
|
+
const descriptor = /^&(?:\d+|-)(?=$|[ \t\n;&|<>])/.exec(command.slice(index));
|
|
257
|
+
if (descriptor === null)
|
|
258
|
+
return unsupported("syntax");
|
|
259
|
+
index += descriptor[0].length;
|
|
260
|
+
tokens.push({ kind: "redirect", needsTarget: false });
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
tokens.push({ kind: "redirect", needsTarget: true });
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const word = readWord(command, index);
|
|
268
|
+
if (word.kind === "unsupported")
|
|
269
|
+
return word;
|
|
270
|
+
const rawWord = command.slice(index, word.word.end);
|
|
271
|
+
if ((command[word.word.end] === "<" || command[word.word.end] === ">") &&
|
|
272
|
+
/^\d+$/.test(word.word.value) &&
|
|
273
|
+
rawWord.includes("\\\n") &&
|
|
274
|
+
rawWord.replaceAll("\\\n", "") === word.word.value) {
|
|
275
|
+
// 数字 fd 内的续行在 sh/bash 与 zsh 中分词不同,不能把它当作确定的工具名。
|
|
276
|
+
return unsupported("syntax");
|
|
277
|
+
}
|
|
278
|
+
tokens.push({ kind: "word", word: word.word });
|
|
279
|
+
index = word.word.end;
|
|
280
|
+
}
|
|
281
|
+
return { kind: "parsed", tokens };
|
|
282
|
+
}
|
|
283
|
+
function readWord(command, start) {
|
|
284
|
+
let index = start;
|
|
285
|
+
let value = "";
|
|
286
|
+
let quote;
|
|
287
|
+
let hasExpansion = false;
|
|
288
|
+
let assignmentEligible = true;
|
|
289
|
+
let assignmentOperator;
|
|
290
|
+
while (index < command.length) {
|
|
291
|
+
const char = command[index];
|
|
292
|
+
if (quote === "'") {
|
|
293
|
+
if (char === "'")
|
|
294
|
+
quote = undefined;
|
|
295
|
+
else
|
|
296
|
+
value += char;
|
|
297
|
+
index += 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (char === "\\") {
|
|
301
|
+
const next = command[index + 1];
|
|
302
|
+
if (next === undefined)
|
|
303
|
+
return unsupported("syntax");
|
|
304
|
+
if (next === "\n") {
|
|
305
|
+
index += 2;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (quote === undefined || '$`"\\'.includes(next)) {
|
|
309
|
+
value += next;
|
|
310
|
+
assignmentEligible = false;
|
|
311
|
+
index += 2;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
value += char;
|
|
315
|
+
index += 1;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (quote === '"' && char === '"') {
|
|
319
|
+
quote = undefined;
|
|
320
|
+
index += 1;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (quote === undefined && (char === "'" || char === '"')) {
|
|
324
|
+
quote = char;
|
|
325
|
+
assignmentEligible = false;
|
|
326
|
+
index += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (char === "`")
|
|
330
|
+
return unsupported("expansion");
|
|
331
|
+
if (char === "$") {
|
|
332
|
+
const next = command[index + 1];
|
|
333
|
+
if (next === "(" || next === "'" || next === '"')
|
|
334
|
+
return unsupported("expansion");
|
|
335
|
+
if (next === "{") {
|
|
336
|
+
const parameter = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}/.exec(command.slice(index));
|
|
337
|
+
if (parameter === null)
|
|
338
|
+
return unsupported("expansion");
|
|
339
|
+
value += parameter[0];
|
|
340
|
+
hasExpansion = true;
|
|
341
|
+
assignmentEligible = false;
|
|
342
|
+
index += parameter[0].length;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
// 未引用或转义的美元字符保守视为动态,不能用不完整白名单排除 shell 扩展。
|
|
346
|
+
hasExpansion = true;
|
|
347
|
+
}
|
|
348
|
+
if (quote === undefined) {
|
|
349
|
+
if (" \t\n;&|<>".includes(char))
|
|
350
|
+
break;
|
|
351
|
+
if ("(){}".includes(char))
|
|
352
|
+
return unsupported("syntax");
|
|
353
|
+
if ("*?[".includes(char) ||
|
|
354
|
+
(char === "~" && value === "") ||
|
|
355
|
+
(char === "=" && value === "" && assignmentEligible))
|
|
356
|
+
hasExpansion = true;
|
|
357
|
+
if (char === "=" && assignmentEligible) {
|
|
358
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
|
|
359
|
+
assignmentOperator = "=";
|
|
360
|
+
else if (/^[A-Za-z_][A-Za-z0-9_]*\+$/.test(value))
|
|
361
|
+
assignmentOperator = "+=";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
value += char;
|
|
365
|
+
index += 1;
|
|
366
|
+
}
|
|
367
|
+
if (quote !== undefined)
|
|
368
|
+
return unsupported("syntax");
|
|
369
|
+
return { kind: "parsed", word: { value, start, end: index, hasExpansion, assignmentOperator } };
|
|
370
|
+
}
|
|
371
|
+
function unsupported(reason) {
|
|
372
|
+
return { kind: "unsupported", reason };
|
|
373
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
const SUPPORTED_SHELLS = new Set(["sh", "bash", "zsh"]);
|
|
3
|
+
export class UnsupportedEnvironmentError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "UnsupportedEnvironmentError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function assertSupportedPlatform(platform = process.platform) {
|
|
10
|
+
if (platform !== "darwin" && platform !== "linux") {
|
|
11
|
+
throw new UnsupportedEnvironmentError("howto only supports macOS and Linux. Native Windows is not supported; use Linux Node.js inside WSL.");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function resolveExecutionShell(env = process.env, platform = process.platform) {
|
|
15
|
+
assertSupportedPlatform(platform);
|
|
16
|
+
const shell = env.SHELL && env.SHELL.trim() !== "" ? env.SHELL : "/bin/sh";
|
|
17
|
+
const name = posix.basename(shell);
|
|
18
|
+
// 按调用名称检查,保留 Linux /bin/sh 指向 dash 等系统实现的兼容性。
|
|
19
|
+
// 只接受裸名称或绝对路径,不解释附加参数,也不使用相对路径查找解释器。
|
|
20
|
+
if (!SUPPORTED_SHELLS.has(name) ||
|
|
21
|
+
(shell !== name && (!posix.isAbsolute(shell) || !shell.endsWith(`/${name}`)))) {
|
|
22
|
+
throw new UnsupportedEnvironmentError("howto only executes commands with sh, bash, or zsh. Set SHELL to a supported shell name or absolute executable path.");
|
|
23
|
+
}
|
|
24
|
+
return shell;
|
|
25
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { release } from "node:os";
|
|
3
|
+
import { resolveExecutionShell, UnsupportedEnvironmentError } from "./execution-environment.js";
|
|
4
|
+
export function detectRuntimeEnvironment(options = {}) {
|
|
5
|
+
const platform = options.platform ?? process.platform;
|
|
6
|
+
let executionShell = null;
|
|
7
|
+
try {
|
|
8
|
+
executionShell = resolveExecutionShell(options.env ?? process.env, platform);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
// --print 不要求 shell 受支持;交互和执行入口仍使用原有的强校验。
|
|
12
|
+
if (!(error instanceof UnsupportedEnvironmentError))
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
operatingSystem: platform === "darwin" ? "macOS" : platform === "linux" ? "Linux" : platform,
|
|
17
|
+
kernelRelease: options.kernelRelease ?? release(),
|
|
18
|
+
distribution: platform === "linux"
|
|
19
|
+
? readLinuxDistribution(options.readOsRelease ?? ((path) => readFileSync(path, "utf8")))
|
|
20
|
+
: null,
|
|
21
|
+
executionShell,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function readLinuxDistribution(readOsRelease) {
|
|
25
|
+
for (const path of ["/etc/os-release", "/usr/lib/os-release"]) {
|
|
26
|
+
let content;
|
|
27
|
+
try {
|
|
28
|
+
content = readOsRelease(path);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT")
|
|
32
|
+
return null;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
// 只读取发行版标识,不 source 文件、不展开变量,也不发送其他系统配置。
|
|
36
|
+
const fields = new Map();
|
|
37
|
+
for (const line of content.split(/\r?\n/)) {
|
|
38
|
+
const match = /^(PRETTY_NAME|NAME|ID|VERSION_ID)=(.*)$/.exec(line.trim());
|
|
39
|
+
if (match === null)
|
|
40
|
+
continue;
|
|
41
|
+
const value = parseOsReleaseValue(match[2]);
|
|
42
|
+
if (value !== null)
|
|
43
|
+
fields.set(match[1], value);
|
|
44
|
+
}
|
|
45
|
+
const prettyName = fields.get("PRETTY_NAME");
|
|
46
|
+
if (prettyName)
|
|
47
|
+
return prettyName;
|
|
48
|
+
const name = fields.get("NAME") || fields.get("ID");
|
|
49
|
+
// /etc/os-release 可读时不混入 /usr/lib/os-release 的字段。
|
|
50
|
+
return name ? [name, fields.get("VERSION_ID")].filter(Boolean).join(" ") : null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function parseOsReleaseValue(value) {
|
|
55
|
+
if (/^'[^']*'$/.test(value))
|
|
56
|
+
return value.slice(1, -1);
|
|
57
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(value)) {
|
|
58
|
+
return value.slice(1, -1).replace(/\\(["\\$`])/g, "$1");
|
|
59
|
+
}
|
|
60
|
+
return /^[a-zA-Z0-9._-]+$/.test(value) ? value : null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function hasUnsafeTerminalControlCharacters(value) {
|
|
2
|
+
for (let index = 0; index < value.length; index++) {
|
|
3
|
+
if (isUnsafeTerminalControlCodeUnit(value.charCodeAt(index))) {
|
|
4
|
+
return true;
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
export function renderTerminalSafeText(value) {
|
|
10
|
+
let rendered = "";
|
|
11
|
+
for (let index = 0; index < value.length; index++) {
|
|
12
|
+
const codeUnit = value.charCodeAt(index);
|
|
13
|
+
if (codeUnit === 0x0d) {
|
|
14
|
+
rendered += "␍";
|
|
15
|
+
}
|
|
16
|
+
else if (codeUnit === 0x0a) {
|
|
17
|
+
rendered += "␊";
|
|
18
|
+
}
|
|
19
|
+
else if (isUnsafeTerminalControlCodeUnit(codeUnit)) {
|
|
20
|
+
rendered += "�";
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
rendered += value[index];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return rendered;
|
|
27
|
+
}
|
|
28
|
+
function isUnsafeTerminalControlCodeUnit(codeUnit) {
|
|
29
|
+
return (codeUnit <= 0x09 ||
|
|
30
|
+
(codeUnit >= 0x0b && codeUnit <= 0x0c) ||
|
|
31
|
+
(codeUnit >= 0x0e && codeUnit <= 0x1f) ||
|
|
32
|
+
(codeUnit >= 0x7f && codeUnit <= 0x9f));
|
|
33
|
+
}
|
package/dist/ui/App.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import { Box, Text, useWindowSize } from "ink";
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Box, Text, usePaste, useWindowSize } from "ink";
|
|
3
|
+
import { useKeyboardInput } from "./use-keyboard-input.js";
|
|
4
|
+
import { useInteractiveSession } from "./InteractiveSessionProvider.js";
|
|
3
5
|
import { LoadingView } from "./LoadingView.js";
|
|
4
6
|
import { SelectCommandView } from "./SelectCommandView.js";
|
|
5
7
|
import { ResolvePlaceholdersView } from "./ResolvePlaceholdersView.js";
|
|
6
8
|
import { ConfirmView } from "./ConfirmView.js";
|
|
7
9
|
import { generateValidatedCommandCandidates } from "../validation/generated-commands.js";
|
|
8
|
-
import {
|
|
10
|
+
import { resolveCommandDanger } from "../safety/command-risk.js";
|
|
9
11
|
import { resolveCandidatePlaceholders } from "./placeholder-logic.js";
|
|
10
12
|
import { InteractionCancelledError } from "./tty.js";
|
|
13
|
+
import { usePhysicalStdoutRows } from "./resize-safe-output.js";
|
|
14
|
+
import { getUserVisibleErrorMessage } from "../errors.js";
|
|
11
15
|
export const App = ({ provider, request, onSuccess, onError }) => {
|
|
12
|
-
const
|
|
16
|
+
const session = useInteractiveSession();
|
|
17
|
+
const rows = usePhysicalStdoutRows();
|
|
18
|
+
const { columns } = useWindowSize();
|
|
13
19
|
const [status, setStatus] = useState("loading");
|
|
14
20
|
const [candidates, setCandidates] = useState([]);
|
|
15
21
|
const [selectedCandidate, setSelectedCandidate] = useState(null);
|
|
@@ -17,20 +23,56 @@ export const App = ({ provider, request, onSuccess, onError }) => {
|
|
|
17
23
|
const [finalCommand, setFinalCommand] = useState("");
|
|
18
24
|
const [danger, setDanger] = useState(undefined);
|
|
19
25
|
const [errorMessage, setErrorMessage] = useState("");
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
const activeRequestControllerRef = useRef(null);
|
|
27
|
+
const cancellationReportedRef = useRef(false);
|
|
28
|
+
const frameRows = Math.max(0, rows - 1);
|
|
29
|
+
const isFrameVisible = frameRows > 0;
|
|
30
|
+
usePaste(() => { }, { isActive: isFrameVisible });
|
|
31
|
+
const handleCancel = useCallback(() => {
|
|
32
|
+
session.suspendViewInput();
|
|
33
|
+
activeRequestControllerRef.current?.abort();
|
|
34
|
+
if (cancellationReportedRef.current)
|
|
35
|
+
return;
|
|
36
|
+
cancellationReportedRef.current = true;
|
|
37
|
+
onError(new InteractionCancelledError());
|
|
38
|
+
}, [onError, session]);
|
|
39
|
+
const inputReady = useKeyboardInput((input, key) => {
|
|
40
|
+
if (frameRows === 0)
|
|
41
|
+
return;
|
|
42
|
+
if (status !== "loading")
|
|
43
|
+
return;
|
|
44
|
+
if (key.ctrl && input === "c") {
|
|
45
|
+
handleCancel();
|
|
33
46
|
}
|
|
47
|
+
});
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (status !== "loading")
|
|
50
|
+
return;
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
let isCurrent = true;
|
|
53
|
+
activeRequestControllerRef.current = controller;
|
|
54
|
+
void generateValidatedCommandCandidates(provider, request, controller.signal)
|
|
55
|
+
.then((generatedCandidates) => {
|
|
56
|
+
if (!isCurrent || controller.signal.aborted)
|
|
57
|
+
return;
|
|
58
|
+
setCandidates(generatedCandidates);
|
|
59
|
+
setStatus("selecting");
|
|
60
|
+
})
|
|
61
|
+
.catch((error) => {
|
|
62
|
+
if (!isCurrent || controller.signal.aborted)
|
|
63
|
+
return;
|
|
64
|
+
const appError = normalizeError(error);
|
|
65
|
+
setErrorMessage(getUserVisibleErrorMessage(appError));
|
|
66
|
+
setStatus("error");
|
|
67
|
+
onError(appError);
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
isCurrent = false;
|
|
71
|
+
controller.abort();
|
|
72
|
+
if (activeRequestControllerRef.current === controller) {
|
|
73
|
+
activeRequestControllerRef.current = null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
34
76
|
}, [status, provider, request, onError]);
|
|
35
77
|
const handleSelect = (candidate) => {
|
|
36
78
|
setSelectedCandidate(candidate);
|
|
@@ -43,12 +85,12 @@ export const App = ({ provider, request, onSuccess, onError }) => {
|
|
|
43
85
|
const resolved = resolveCandidatePlaceholders(candidate, new Map());
|
|
44
86
|
setFinalCommand(resolved.command);
|
|
45
87
|
setResolvedValues(resolved.values);
|
|
46
|
-
setDanger(
|
|
88
|
+
setDanger(resolveCommandDanger(resolved.command, candidate));
|
|
47
89
|
setStatus("confirming");
|
|
48
90
|
}
|
|
49
91
|
catch (error) {
|
|
50
92
|
const appError = normalizeError(error);
|
|
51
|
-
setErrorMessage(appError
|
|
93
|
+
setErrorMessage(getUserVisibleErrorMessage(appError));
|
|
52
94
|
setStatus("error");
|
|
53
95
|
onError(appError);
|
|
54
96
|
}
|
|
@@ -59,7 +101,7 @@ export const App = ({ provider, request, onSuccess, onError }) => {
|
|
|
59
101
|
return;
|
|
60
102
|
setResolvedValues(resolved.values);
|
|
61
103
|
setFinalCommand(resolved.command);
|
|
62
|
-
setDanger(
|
|
104
|
+
setDanger(resolveCommandDanger(resolved.command, selectedCandidate));
|
|
63
105
|
setStatus("confirming");
|
|
64
106
|
};
|
|
65
107
|
useEffect(() => {
|
|
@@ -68,20 +110,17 @@ export const App = ({ provider, request, onSuccess, onError }) => {
|
|
|
68
110
|
}
|
|
69
111
|
}, [status, finalCommand, onSuccess]);
|
|
70
112
|
const handleConfirm = () => {
|
|
113
|
+
session.suspendViewInput();
|
|
71
114
|
setStatus("done");
|
|
72
115
|
};
|
|
73
|
-
const handleCancel = () => {
|
|
74
|
-
onError(new InteractionCancelledError());
|
|
75
|
-
};
|
|
76
116
|
const handleBackToSelection = () => {
|
|
77
117
|
setStatus("selecting");
|
|
78
118
|
};
|
|
79
|
-
|
|
80
|
-
return (React.createElement(Box, { flexDirection: "column", maxHeight: maxFrameRows, overflowY: "hidden" },
|
|
119
|
+
return (React.createElement(Box, { flexDirection: "column", display: isFrameVisible && inputReady ? "flex" : "none", maxHeight: frameRows, overflowY: "hidden" },
|
|
81
120
|
status === "loading" && React.createElement(LoadingView, null),
|
|
82
|
-
status === "selecting" && (React.createElement(SelectCommandView, { candidates: candidates, onSelect: handleSelect, onCancel: handleCancel })),
|
|
83
|
-
status === "resolving" && selectedCandidate && (React.createElement(ResolvePlaceholdersView, { candidate: selectedCandidate, onResolve: handleResolve, onBack: handleBackToSelection, onCancel: handleCancel })),
|
|
84
|
-
(status === "confirming" || status === "done") && selectedCandidate && (React.createElement(ConfirmView, { candidate: selectedCandidate, command: finalCommand, resolvedValues: resolvedValues, danger: danger, onConfirm: handleConfirm, onCancel: handleCancel, isDone: status === "done" })),
|
|
121
|
+
status === "selecting" && (React.createElement(SelectCommandView, { candidates: candidates, availableRows: frameRows, isInputActive: isFrameVisible, onSelect: handleSelect, onCancel: handleCancel })),
|
|
122
|
+
status === "resolving" && selectedCandidate && (React.createElement(ResolvePlaceholdersView, { candidate: selectedCandidate, availableRows: frameRows, availableColumns: columns, isInputActive: isFrameVisible, onResolve: handleResolve, onBack: handleBackToSelection, onCancel: handleCancel })),
|
|
123
|
+
(status === "confirming" || status === "done") && selectedCandidate && (React.createElement(ConfirmView, { candidate: selectedCandidate, command: finalCommand, resolvedValues: resolvedValues, danger: danger, availableRows: frameRows, availableColumns: columns, isInputActive: isFrameVisible, onConfirm: handleConfirm, onCancel: handleCancel, isDone: status === "done" })),
|
|
85
124
|
status === "error" && React.createElement(Text, { color: "red" },
|
|
86
125
|
"Error: ",
|
|
87
126
|
errorMessage)));
|