@unscientificjszhai/howto 1.0.0 → 1.0.1

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.
Files changed (40) hide show
  1. package/README.CN.md +32 -3
  2. package/README.md +32 -3
  3. package/THIRD_PARTY_NOTICES.md +5 -4
  4. package/dist/ai/errors.js +7 -23
  5. package/dist/ai/gemini.js +67 -27
  6. package/dist/ai/openai.js +35 -27
  7. package/dist/cli.js +12 -1
  8. package/dist/config-file.js +97 -51
  9. package/dist/config.js +7 -6
  10. package/dist/errors.js +17 -16
  11. package/dist/execute.js +12 -25
  12. package/dist/index.js +84 -95
  13. package/dist/init/InitializationApp.js +175 -32
  14. package/dist/init/index.js +35 -33
  15. package/dist/init/state.js +46 -23
  16. package/dist/prompt.js +4 -2
  17. package/dist/safety/dangerous-command.js +262 -93
  18. package/dist/shell/command-analysis.js +373 -0
  19. package/dist/terminal-text.js +33 -0
  20. package/dist/ui/App.js +64 -25
  21. package/dist/ui/ConfirmView.js +163 -67
  22. package/dist/ui/InteractiveSessionProvider.js +19 -0
  23. package/dist/ui/ResolvePlaceholdersView.js +107 -27
  24. package/dist/ui/SelectCommandView.js +73 -23
  25. package/dist/ui/SelectedCommandDisplay.js +11 -9
  26. package/dist/ui/interactive-session.js +407 -0
  27. package/dist/ui/paste-framing.js +105 -0
  28. package/dist/ui/placeholder-logic.js +28 -16
  29. package/dist/ui/resize-safe-output.js +73 -0
  30. package/dist/ui/run-interactive-command.js +55 -0
  31. package/dist/ui/single-line-preview.js +28 -0
  32. package/dist/ui/text-input.js +53 -0
  33. package/dist/ui/use-keyboard-input.js +12 -0
  34. package/dist/ui/use-paste-aware-input.js +7 -0
  35. package/dist/user-visible-error.js +19 -0
  36. package/dist/validation/ai-response.js +11 -4
  37. package/dist/validation/command-tool.js +25 -127
  38. package/dist/validation/generated-commands.js +5 -16
  39. package/dist/version.js +38 -0
  40. package/package.json +18 -15
@@ -1,37 +1,30 @@
1
- var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
2
- function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
3
- return new (P || (P = Promise))(function (resolve, reject) {
4
- function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
5
- function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
6
- function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
7
- step((generator = generator.apply(thisArg, _arguments || [])).next());
8
- });
9
- };
10
1
  import React from "react";
11
2
  import { render } from "ink";
12
3
  import { loadConfig } from "../config.js";
13
4
  import { getConfigFilePath, writeUserConfigFile } from "../config-file.js";
5
+ import { InteractiveSessionProvider } from "../ui/InteractiveSessionProvider.js";
14
6
  import { InteractionCancelledError } from "../ui/tty.js";
15
7
  import { InitializationApp } from "./InitializationApp.js";
16
- export function initializeConfig(_a) {
17
- return __awaiter(this, arguments, void 0, function* ({ cliOptions, env, input, output, }) {
18
- return yield new Promise((resolve, reject) => {
19
- let settled = false;
20
- const settle = (result) => {
21
- if (settled) {
22
- return;
23
- }
24
- settled = true;
25
- instance.clear();
26
- instance.unmount();
27
- void instance.waitUntilExit().then(result, result);
28
- };
29
- const instance = render(React.createElement(InitializationApp, { onSubmit: (values) => __awaiter(this, void 0, void 0, function* () {
30
- const fileConfig = createFileConfig(values);
31
- const configFilePath = getConfigFilePath(env);
32
- yield writeUserConfigFile(fileConfig, configFilePath);
33
- return loadConfig(cliOptions, env, fileConfig);
34
- }), onComplete: (config) => {
8
+ export async function initializeConfig({ cliOptions, env, session, }) {
9
+ return await new Promise((resolve, reject) => {
10
+ let settled = false;
11
+ let unsubscribe = () => { };
12
+ const settle = (result) => {
13
+ if (settled) {
14
+ return;
15
+ }
16
+ settled = true;
17
+ session.suspendViewInput();
18
+ unsubscribe();
19
+ const exitPromise = instance.waitUntilExit();
20
+ instance.clear();
21
+ instance.unmount();
22
+ void exitPromise.then(result, result);
23
+ };
24
+ const instance = render(React.createElement(InteractiveSessionProvider, { session: session },
25
+ React.createElement(InitializationApp, { onSubmit: async (values) => {
26
+ return validateAndPersistInitializationConfig(values, cliOptions, env);
27
+ }, onComplete: (config) => {
35
28
  settle(() => {
36
29
  resolve(config);
37
30
  });
@@ -43,14 +36,23 @@ export function initializeConfig(_a) {
43
36
  settle(() => {
44
37
  reject(error);
45
38
  });
46
- } }), {
47
- stdin: input,
48
- stdout: output,
49
- exitOnCtrlC: false,
50
- });
39
+ } })), {
40
+ stdin: session.input,
41
+ stdout: session.output,
42
+ exitOnCtrlC: false,
43
+ // 会话已确认 TTY,避免 CI 环境关闭 Ink 的实时渲染。
44
+ interactive: true,
51
45
  });
46
+ unsubscribe = session.subscribeFailure((error) => settle(() => reject(error)));
52
47
  });
53
48
  }
49
+ export async function validateAndPersistInitializationConfig(values, cliOptions, env) {
50
+ const fileConfig = createFileConfig(values);
51
+ const config = loadConfig(cliOptions, env, fileConfig);
52
+ const configFilePath = getConfigFilePath(env);
53
+ await writeUserConfigFile(fileConfig, configFilePath);
54
+ return config;
55
+ }
54
56
  export function clearInitializationOutput(output, rows) {
55
57
  if (output.isTTY) {
56
58
  output.write(erasePreviousTerminalRows(rows));
@@ -1,4 +1,6 @@
1
1
  import { DEFAULT_GEMINI_MODEL, DEFAULT_OPENAI_MODEL } from "../config.js";
2
+ import { hasUnsafeTerminalControlCharacters } from "../terminal-text.js";
3
+ import { deleteLastGrapheme, isTextInputEvent } from "../ui/text-input.js";
2
4
  const PROVIDERS = ["openai", "gemini"];
3
5
  export function createInitialInitializationState() {
4
6
  return {
@@ -10,6 +12,14 @@ export function getProviderOptions() {
10
12
  return PROVIDERS;
11
13
  }
12
14
  export function applyInitializationInput(state, event) {
15
+ if (event.type === "paste") {
16
+ if (state.step !== "input" || hasUnsafeTerminalControlCharacters(event.input)) {
17
+ return { state };
18
+ }
19
+ return updateCurrentField(state, (value) => value + event.input);
20
+ }
21
+ if (event.key.eventType === "release")
22
+ return { state };
13
23
  if (event.key.ctrl && event.input === "c") {
14
24
  return { state, cancelled: true };
15
25
  }
@@ -22,7 +32,9 @@ function applyProviderInput(state, event) {
22
32
  if (event.key.escape) {
23
33
  return { state, cancelled: true };
24
34
  }
25
- const numericSelection = getNumericProviderSelection(event.input);
35
+ const numericSelection = isTextInputEvent(event.input, event.key)
36
+ ? getNumericProviderSelection(event.input)
37
+ : null;
26
38
  if (numericSelection !== null) {
27
39
  return {
28
40
  state: createInputState(PROVIDERS[numericSelection]),
@@ -30,14 +42,20 @@ function applyProviderInput(state, event) {
30
42
  }
31
43
  if (event.key.upArrow) {
32
44
  return {
33
- state: Object.assign(Object.assign({}, state), { selectedIndex: state.selectedIndex === null
45
+ state: {
46
+ ...state,
47
+ selectedIndex: state.selectedIndex === null
34
48
  ? PROVIDERS.length - 1
35
- : (state.selectedIndex - 1 + PROVIDERS.length) % PROVIDERS.length }),
49
+ : (state.selectedIndex - 1 + PROVIDERS.length) % PROVIDERS.length,
50
+ },
36
51
  };
37
52
  }
38
53
  if (event.key.downArrow) {
39
54
  return {
40
- state: Object.assign(Object.assign({}, state), { selectedIndex: state.selectedIndex === null ? 0 : (state.selectedIndex + 1) % PROVIDERS.length }),
55
+ state: {
56
+ ...state,
57
+ selectedIndex: state.selectedIndex === null ? 0 : (state.selectedIndex + 1) % PROVIDERS.length,
58
+ },
41
59
  };
42
60
  }
43
61
  if (event.key.return && state.selectedIndex !== null) {
@@ -64,7 +82,10 @@ function applyFieldInput(state, event) {
64
82
  const validationError = validateCurrentField(state);
65
83
  if (validationError !== undefined) {
66
84
  return {
67
- state: Object.assign(Object.assign({}, state), { errorMessage: validationError }),
85
+ state: {
86
+ ...state,
87
+ errorMessage: validationError,
88
+ },
68
89
  };
69
90
  }
70
91
  if (state.fieldIndex === state.fields.length - 1) {
@@ -74,26 +95,21 @@ function applyFieldInput(state, event) {
74
95
  };
75
96
  }
76
97
  return {
77
- state: Object.assign(Object.assign({}, state), { fieldIndex: state.fieldIndex + 1, errorMessage: undefined }),
98
+ state: {
99
+ ...state,
100
+ fieldIndex: state.fieldIndex + 1,
101
+ errorMessage: undefined,
102
+ },
78
103
  };
79
104
  }
80
105
  if (event.key.backspace || event.key.delete) {
81
- return updateCurrentField(state, (value) => value.slice(0, -1));
106
+ return updateCurrentField(state, deleteLastGrapheme);
82
107
  }
83
- if (event.input !== "" && !hasControlKey(event)) {
108
+ if (isTextInputEvent(event.input, event.key)) {
84
109
  return updateCurrentField(state, (value) => value + event.input);
85
110
  }
86
111
  return { state };
87
112
  }
88
- function hasControlKey(event) {
89
- return (event.key.upArrow ||
90
- event.key.downArrow ||
91
- event.key.return ||
92
- event.key.escape ||
93
- event.key.ctrl ||
94
- event.key.backspace ||
95
- event.key.delete);
96
- }
97
113
  function createInputState(provider) {
98
114
  return {
99
115
  step: "input",
@@ -140,8 +156,16 @@ function createInputState(provider) {
140
156
  }
141
157
  function updateCurrentField(state, updateValue) {
142
158
  return {
143
- state: Object.assign(Object.assign({}, state), { fields: state.fields.map((field, index) => index === state.fieldIndex
144
- ? Object.assign(Object.assign({}, field), { value: updateValue(field.value) }) : field), errorMessage: undefined }),
159
+ state: {
160
+ ...state,
161
+ fields: state.fields.map((field, index) => index === state.fieldIndex
162
+ ? {
163
+ ...field,
164
+ value: updateValue(field.value),
165
+ }
166
+ : field),
167
+ errorMessage: undefined,
168
+ },
145
169
  };
146
170
  }
147
171
  function validateCurrentField(state) {
@@ -152,20 +176,19 @@ function validateCurrentField(state) {
152
176
  return undefined;
153
177
  }
154
178
  function createInitializationValues(state) {
155
- var _a, _b, _c;
156
179
  const fieldValues = new Map(state.fields.map((field) => [field.name, field.value]));
157
- const modelValue = (_a = fieldValues.get("model")) === null || _a === void 0 ? void 0 : _a.trim();
180
+ const modelValue = fieldValues.get("model")?.trim();
158
181
  if (state.provider === "gemini") {
159
182
  return {
160
183
  provider: "gemini",
161
- apiKey: (_b = fieldValues.get("apiKey")) !== null && _b !== void 0 ? _b : "",
184
+ apiKey: fieldValues.get("apiKey") ?? "",
162
185
  model: modelValue === "" || modelValue === undefined ? DEFAULT_GEMINI_MODEL : modelValue,
163
186
  };
164
187
  }
165
188
  const baseUrl = fieldValues.get("openaiBaseUrl");
166
189
  return {
167
190
  provider: "openai",
168
- apiKey: (_c = fieldValues.get("apiKey")) !== null && _c !== void 0 ? _c : "",
191
+ apiKey: fieldValues.get("apiKey") ?? "",
169
192
  model: modelValue === "" || modelValue === undefined ? DEFAULT_OPENAI_MODEL : modelValue,
170
193
  openaiBaseUrl: baseUrl === undefined || baseUrl.trim() === "" ? undefined : baseUrl,
171
194
  };
package/dist/prompt.js CHANGED
@@ -17,11 +17,13 @@ The JSON object must match this schema:
17
17
  }
18
18
  The commands array must contain 1 to 3 items. Do not return more than 3 candidate commands.
19
19
  Detect the primary natural language of the user's question. Return title, description, and placeholders[].description in that language. Keep placeholder name values English-compatible ASCII using only letters, numbers, underscores, or hyphens.
20
- Use placeholders in commands only as {{name}}, and declare every placeholder in the placeholders array. User may provide argument. If the user's intent is clear, try to use the provided arguments as parameters in the generated commands instead of placeholders. If the intent is unclear, do not fill them.`;
20
+ Do not include terminal control characters in string fields. CR and LF are the only allowed control characters.
21
+ Use placeholders in commands only as {{name}}, and declare every placeholder in the placeholders array. Each placeholder name must be declared exactly once per candidate; multiple references to the same name share one input value. User may provide argument. If the user's intent is clear, try to use the provided arguments as parameters in the generated commands instead of placeholders. If the intent is unclear, do not fill them.`;
21
22
  export const STRUCTURED_OUTPUT_CONTRACT = `Return only the JSON object requested by the response schema. Do not include a natural-language body, markdown, or code fences.
22
23
  The commands array must contain 1 to 3 items. Do not return more than 3 candidate commands.
23
24
  Detect the primary natural language of the user's question. Return title, description, and placeholders[].description in that language. Keep placeholder name values English-compatible ASCII using only letters, numbers, underscores, or hyphens.
24
- Use placeholders in commands only as {{name}}, and declare every placeholder in the placeholders array. User may provide argument. If the user's intent is clear, try to use the provided arguments as parameters in the generated commands instead of placeholders. If the intent is unclear, do not fill them.`;
25
+ Do not include terminal control characters in string fields. CR and LF are the only allowed control characters.
26
+ Use placeholders in commands only as {{name}}, and declare every placeholder in the placeholders array. Each placeholder name must be declared exactly once per candidate; multiple references to the same name share one input value. User may provide argument. If the user's intent is clear, try to use the provided arguments as parameters in the generated commands instead of placeholders. If the intent is unclear, do not fill them.`;
25
27
  export const SAFETY_CONSTRAINTS = `Prefer read-only, reversible, and low-risk commands.
26
28
  When a task could involve deletion, overwrite, privilege escalation, network download, or executing downloaded content, prefer a safer alternative or inspection command when possible.
27
29
  Do not include safety metadata or claim that a command is safe; the local CLI will perform its own validation and dangerous-command checks.`;
@@ -1,104 +1,273 @@
1
- const SHELL_COMMAND_PREFIX = String.raw `(?:^|[;&|]\s*|\|\s*)`;
2
- const SUDO_PREFIX = String.raw `(?:sudo\s+)?`;
3
- const COMMAND_PREFIX = `${SHELL_COMMAND_PREFIX}${SUDO_PREFIX}`;
4
- const RISKY_TARGET_PATTERN = /(^|\s)(?:\/|~(?:\/|\s|$)|\*|\.\.\/|\.\/\*|\/(?:bin|boot|dev|etc|home|lib|opt|private|sbin|System|usr|var)(?:\/|\s|$))/;
5
- const RULES = [
6
- {
7
- name: "destructive-rm",
8
- reason: "recursive or forced rm against a high-risk target",
9
- matches(command) {
10
- return findCommandSegments(command, "rm").some((segment) => {
11
- const hasDangerousFlag = /(^|\s)(?:-[A-Za-z]*[rRfF][A-Za-z]*|--recursive|--force)(\s|$)/.test(segment);
12
- return hasDangerousFlag && RISKY_TARGET_PATTERN.test(segment);
13
- });
14
- },
15
- },
16
- {
17
- name: "disk-filesystem-operation",
18
- reason: "disk, partition, filesystem, or raw block-device operation",
19
- matches(command) {
20
- return (new RegExp(`${COMMAND_PREFIX}(?:mkfs(?:\\.[\\w-]+)?|fdisk|parted)(?:\\s|$)`).test(command) ||
21
- new RegExp(`${COMMAND_PREFIX}diskutil\\s+erase\\w*\\b`).test(command) ||
22
- new RegExp(`${COMMAND_PREFIX}dd\\s+[^;&|]*\\bif=\\S+\\s+[^;&|]*\\bof=/dev/\\S*`).test(command));
23
- },
24
- },
25
- {
26
- name: "recursive-permission-ownership-change",
27
- reason: "recursive chmod or chown against a high-risk target",
28
- matches(command) {
29
- return findCommandSegments(command, "(?:chmod|chown)").some((segment) => {
30
- const hasRecursiveFlag = /(^|\s)(?:-[A-Za-z]*R[A-Za-z]*|--recursive)(\s|$)/.test(segment);
31
- return hasRecursiveFlag && RISKY_TARGET_PATTERN.test(segment);
32
- });
33
- },
34
- },
35
- {
36
- name: "download-and-execute",
37
- reason: "network download piped or redirected directly into a shell",
38
- matches(command) {
39
- return (/\b(?:curl|wget)\b[^|;&]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/.test(command) ||
40
- /\b(?:sh|bash|zsh)\s+<\(\s*(?:curl|wget)\b/.test(command) ||
41
- /\bwget\b[^|;&]*\s+-O-\s*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/.test(command));
42
- },
43
- },
44
- {
45
- name: "package-manager-high-impact-operation",
46
- reason: "package manager upgrade, uninstall, or global install operation",
47
- matches(command) {
48
- return (new RegExp(`${COMMAND_PREFIX}(?:apt|apt-get|yum|dnf)\\s+(?:dist-upgrade|full-upgrade|upgrade|remove|purge|autoremove)\\b`).test(command) ||
49
- new RegExp(`${COMMAND_PREFIX}brew\\s+(?:upgrade|uninstall|remove)\\b`).test(command) ||
50
- new RegExp(`${COMMAND_PREFIX}npm\\s+(?:install|uninstall|remove|rm)\\b[^;&|]*(?:^|\\s)(?:-g|--global)(?:\\s|$)`).test(command) ||
51
- new RegExp(`${COMMAND_PREFIX}(?:pip|pip3)\\s+(?:install|uninstall)\\b[^;&|]*(?:^|\\s)(?:-g|--user|--break-system-packages)(?:\\s|$)`).test(command));
52
- },
53
- },
54
- {
55
- name: "system-service-high-impact-operation",
56
- reason: "system service stop, disable, restart, or configuration operation",
57
- matches(command) {
58
- return new RegExp(`${COMMAND_PREFIX}(?:systemctl|service|launchctl)\\s+(?:stop|disable|restart|reload|unload|bootout|remove)\\b`).test(command);
59
- },
60
- },
61
- ];
1
+ import { posix } from "node:path";
2
+ import { isShellExecutable, parseShellCommand, resolveCommandPrefix, } from "../shell/command-analysis.js";
3
+ const INDETERMINATE = {
4
+ rule: "indeterminate-shell-command",
5
+ reason: "command structure cannot be assessed locally; additional confirmation required",
6
+ };
7
+ const DESTRUCTIVE_RM = {
8
+ rule: "destructive-rm",
9
+ reason: "recursive or forced rm against a high-risk target",
10
+ };
11
+ const DISK_OPERATION = {
12
+ rule: "disk-filesystem-operation",
13
+ reason: "disk, partition, filesystem, or raw block-device operation",
14
+ };
15
+ const PERMISSION_CHANGE = {
16
+ rule: "recursive-permission-ownership-change",
17
+ reason: "recursive chmod or chown against a high-risk target",
18
+ };
19
+ const DOWNLOAD_EXECUTION = {
20
+ rule: "download-and-execute",
21
+ reason: "network download piped or redirected directly into a shell",
22
+ };
23
+ const PACKAGE_OPERATION = {
24
+ rule: "package-manager-high-impact-operation",
25
+ reason: "package manager upgrade, uninstall, or global install operation",
26
+ };
27
+ const SERVICE_OPERATION = {
28
+ rule: "system-service-high-impact-operation",
29
+ reason: "system service stop, disable, restart, or configuration operation",
30
+ };
31
+ const RISKY_TARGET = /^(?:\/|~(?:\/|$)|\*|\.\.(?:\/|$))/;
32
+ const RM_FLAGS = /^(?:-[A-Za-z]*[rRfF][A-Za-z]*|--recursive|--force)$/;
33
+ const RECURSIVE_FLAGS = /^(?:-[A-Za-z]*R[A-Za-z]*|--recursive)$/;
34
+ const PACKAGE_MANAGERS = new Set(["apt", "apt-get", "yum", "dnf", "brew", "npm", "pip", "pip3"]);
35
+ const PACKAGE_ACTIONS = new Set([
36
+ "dist-upgrade",
37
+ "full-upgrade",
38
+ "upgrade",
39
+ "remove",
40
+ "purge",
41
+ "autoremove",
42
+ ]);
43
+ // update 等动作在不同管理器中含义不同,只在对应工具内归一化。
44
+ const PACKAGE_ACTION_ALIASES = new Map([
45
+ [
46
+ "brew",
47
+ new Map([
48
+ ["remove", "uninstall"],
49
+ ["rm", "uninstall"],
50
+ ["uninstal", "uninstall"],
51
+ ]),
52
+ ],
53
+ [
54
+ "yum",
55
+ new Map([
56
+ ["update", "upgrade"],
57
+ ["update-to", "upgrade"],
58
+ ["upgrade-to", "upgrade"],
59
+ ["localupdate", "upgrade"],
60
+ ["erase", "remove"],
61
+ ]),
62
+ ],
63
+ [
64
+ "dnf",
65
+ new Map([
66
+ ["up", "upgrade"],
67
+ ["update", "upgrade"],
68
+ ["upgrade-to", "upgrade"],
69
+ ["update-to", "upgrade"],
70
+ ["localupdate", "upgrade"],
71
+ ["rm", "remove"],
72
+ ["erase", "remove"],
73
+ ["remove-n", "remove"],
74
+ ["remove-na", "remove"],
75
+ ["remove-nevra", "remove"],
76
+ ["erase-n", "remove"],
77
+ ["erase-na", "remove"],
78
+ ["erase-nevra", "remove"],
79
+ ]),
80
+ ],
81
+ ]);
82
+ const SERVICE_ACTIONS = new Set([
83
+ "start",
84
+ "stop",
85
+ "enable",
86
+ "disable",
87
+ "restart",
88
+ "reload",
89
+ "unload",
90
+ "bootout",
91
+ "remove",
92
+ ]);
93
+ const PACKAGE_ZERO_OPTIONS = new Set([
94
+ "-g",
95
+ "--global",
96
+ "--user",
97
+ "--break-system-packages",
98
+ "-y",
99
+ "--yes",
100
+ "-q",
101
+ "--quiet",
102
+ "--verbose",
103
+ ]);
104
+ const SERVICE_ZERO_OPTIONS = new Set(["--user", "--system"]);
105
+ const NPM_ACTION_ALIASES = new Map([
106
+ ["install", "install"],
107
+ ["add", "install"],
108
+ ["i", "install"],
109
+ ["in", "install"],
110
+ ["ins", "install"],
111
+ ["inst", "install"],
112
+ ["insta", "install"],
113
+ ["instal", "install"],
114
+ ["isnt", "install"],
115
+ ["isnta", "install"],
116
+ ["isntal", "install"],
117
+ ["isntall", "install"],
118
+ ["install-test", "install"],
119
+ ["installTest", "install"],
120
+ ["it", "install"],
121
+ ["uninstall", "uninstall"],
122
+ ["unlink", "uninstall"],
123
+ ["remove", "uninstall"],
124
+ ["rm", "uninstall"],
125
+ ["r", "uninstall"],
126
+ ["un", "uninstall"],
127
+ ]);
62
128
  export function detectDangerousCommand(command) {
63
- const normalizedCommand = normalizeCommand(command);
64
- const commandsToCheck = [normalizedCommand, ...extractSudoShellCommands(normalizedCommand)];
65
- for (const commandToCheck of commandsToCheck) {
66
- for (const rule of RULES) {
67
- if (rule.matches(commandToCheck)) {
68
- return {
69
- rule: rule.name,
70
- reason: rule.reason,
71
- };
72
- }
73
- }
74
- }
75
- return undefined;
129
+ return inspectCommand(command, false);
76
130
  }
77
131
  export function isDangerousCommand(command) {
78
132
  return detectDangerousCommand(command) !== undefined;
79
133
  }
80
- function normalizeCommand(command) {
81
- return command.replace(/\s+/g, " ").trim();
134
+ function inspectCommand(command, insideShell) {
135
+ const parsed = parseShellCommand(command);
136
+ if (parsed.kind === "unsupported")
137
+ return INDETERMINATE;
138
+ const resolved = parsed.commands.map((segment) => resolveCommandPrefix(segment.words));
139
+ let uncertain = false;
140
+ for (let index = 0; index < resolved.length; index += 1) {
141
+ const current = resolved[index];
142
+ if (current.kind === "unsupported") {
143
+ uncertain = true;
144
+ continue;
145
+ }
146
+ const name = posix.basename(current.executable.value);
147
+ const next = resolved[index + 1];
148
+ if ((name === "curl" || name === "wget") &&
149
+ parsed.commands[index].separator === "|" &&
150
+ next?.kind === "parsed" &&
151
+ isShellExecutable(next.executable.value))
152
+ return DOWNLOAD_EXECUTION;
153
+ const match = isShellExecutable(current.executable.value)
154
+ ? inspectShellBody(name, current.args, insideShell)
155
+ : inspectSimpleCommand(name, current.args);
156
+ if (match?.rule === INDETERMINATE.rule)
157
+ uncertain = true;
158
+ else if (match !== undefined)
159
+ return match;
160
+ }
161
+ return uncertain ? INDETERMINATE : undefined;
82
162
  }
83
- function extractSudoShellCommands(command) {
84
- var _a, _b;
85
- const matcher = /\bsudo\s+(?:sh|bash|zsh)\s+-c\s+(?:"([^"]*)"|'([^']*)'|(\S+))/g;
86
- const commands = [];
87
- let match;
88
- while ((match = matcher.exec(command)) !== null) {
89
- const shellCommand = (_b = (_a = match[1]) !== null && _a !== void 0 ? _a : match[2]) !== null && _b !== void 0 ? _b : match[3];
90
- if (shellCommand !== undefined) {
91
- commands.push(normalizeCommand(shellCommand));
163
+ function inspectShellBody(shell, args, insideShell) {
164
+ if (insideShell ||
165
+ !["sh", "bash", "zsh"].includes(shell) ||
166
+ args[0]?.hasExpansion ||
167
+ (args[0]?.value !== "-c" && args[0]?.value !== "-lc") ||
168
+ args[1] === undefined ||
169
+ args[1].hasExpansion)
170
+ return INDETERMINATE;
171
+ // 只展开一层已是字面量的 shell 命令体;不模拟变量或嵌套解释器。
172
+ return inspectCommand(args[1].value, true);
173
+ }
174
+ function inspectSimpleCommand(name, args) {
175
+ const values = args.map((word) => word.value);
176
+ const dynamic = args.some((word) => word.hasExpansion);
177
+ if (name === "rm" || name === "chmod" || name === "chown") {
178
+ const flagPattern = name === "rm" ? RM_FLAGS : RECURSIVE_FLAGS;
179
+ const endOptions = values.indexOf("--");
180
+ const flags = endOptions < 0 ? values : values.slice(0, endOptions);
181
+ if (flags.some((value) => flagPattern.test(value)) &&
182
+ values.some((value) => RISKY_TARGET.test(normalizeRiskPath(value)))) {
183
+ return name === "rm" ? DESTRUCTIVE_RM : PERMISSION_CHANGE;
92
184
  }
185
+ if (dynamic ||
186
+ flags.some((value) => value.startsWith("--") && !["--recursive", "--force", "--verbose"].includes(value)))
187
+ return INDETERMINATE;
188
+ return undefined;
189
+ }
190
+ if (/^mkfs(?:\.[\w-]+)?$/.test(name) || name === "fdisk" || name === "parted")
191
+ return DISK_OPERATION;
192
+ if (name === "dd") {
193
+ if (values.some((value) => {
194
+ if (!value.startsWith("of="))
195
+ return false;
196
+ const target = normalizeRiskPath(value.slice(3));
197
+ return target === "/dev" || target.startsWith("/dev/");
198
+ }))
199
+ return DISK_OPERATION;
200
+ return dynamic ? INDETERMINATE : undefined;
201
+ }
202
+ if (name === "diskutil") {
203
+ if (args[0]?.hasExpansion || values[0]?.startsWith("-"))
204
+ return INDETERMINATE;
205
+ return /^erase\w*$/.test(values[0] ?? "") ? DISK_OPERATION : undefined;
206
+ }
207
+ if (PACKAGE_MANAGERS.has(name))
208
+ return inspectPackageCommand(name, args);
209
+ if (name === "service" || name === "systemctl" || name === "launchctl") {
210
+ const action = name === "service" ? serviceAction(args) : leadingAction(args, SERVICE_ZERO_OPTIONS);
211
+ if (action === null)
212
+ return INDETERMINATE;
213
+ return action !== undefined && SERVICE_ACTIONS.has(action) ? SERVICE_OPERATION : undefined;
93
214
  }
94
- return commands;
215
+ return undefined;
216
+ }
217
+ function normalizeRiskPath(value) {
218
+ // 只整理分析副本的分隔符和点段;保留 ..,避免忽略符号链接的实际解析语义。
219
+ const segments = value.split("/").filter((segment) => segment !== "" && segment !== ".");
220
+ return (value.startsWith("/") ? "/" : "") + segments.join("/");
221
+ }
222
+ function inspectPackageCommand(name, args) {
223
+ const parsedAction = leadingAction(args, PACKAGE_ZERO_OPTIONS);
224
+ if (parsedAction === null)
225
+ return INDETERMINATE;
226
+ if (parsedAction === undefined)
227
+ return undefined;
228
+ const action = name === "npm"
229
+ ? resolveNpmAction(parsedAction)
230
+ : (PACKAGE_ACTION_ALIASES.get(name)?.get(parsedAction) ?? parsedAction);
231
+ if (action === null)
232
+ return INDETERMINATE;
233
+ if (name === "brew")
234
+ return ["upgrade", "uninstall"].includes(action) ? PACKAGE_OPERATION : undefined;
235
+ if (name !== "npm" && name !== "pip" && name !== "pip3") {
236
+ return PACKAGE_ACTIONS.has(action) ? PACKAGE_OPERATION : undefined;
237
+ }
238
+ if (!["install", "uninstall", "remove", "rm"].includes(action))
239
+ return undefined;
240
+ const globalFlags = name === "npm" ? ["-g", "--global"] : ["-g", "--user", "--break-system-packages"];
241
+ if (args.some((word) => globalFlags.includes(word.value)))
242
+ return PACKAGE_OPERATION;
243
+ return args.some((word) => word.hasExpansion ||
244
+ (word.value.startsWith("-") && word.value !== "--" && !PACKAGE_ZERO_OPTIONS.has(word.value)))
245
+ ? INDETERMINATE
246
+ : undefined;
95
247
  }
96
- function findCommandSegments(command, commandNamePattern) {
97
- const matcher = new RegExp(`${COMMAND_PREFIX}${commandNamePattern}\\b([^;&|]*)`, "g");
98
- const segments = [];
99
- let match;
100
- while ((match = matcher.exec(command)) !== null) {
101
- segments.push(match[0]);
248
+ function resolveNpmAction(action) {
249
+ const canonical = NPM_ACTION_ALIASES.get(action);
250
+ if (canonical !== undefined)
251
+ return canonical;
252
+ // npm 也接受唯一前缀缩写;不复制完整命令表,相关前缀统一要求额外确认。
253
+ for (const knownAction of NPM_ACTION_ALIASES.keys()) {
254
+ if (knownAction.startsWith(action))
255
+ return null;
102
256
  }
103
- return segments;
257
+ return action;
258
+ }
259
+ function serviceAction(args) {
260
+ if (args[0]?.hasExpansion || args[0]?.value.startsWith("-"))
261
+ return null;
262
+ return args[1]?.hasExpansion || args[1]?.value.startsWith("-") ? null : args[1]?.value;
263
+ }
264
+ function leadingAction(args, zeroOptions) {
265
+ for (const word of args) {
266
+ if (word.hasExpansion)
267
+ return null;
268
+ if (zeroOptions.has(word.value) || word.value === "--")
269
+ continue;
270
+ return word.value.startsWith("-") ? null : word.value;
271
+ }
272
+ return undefined;
104
273
  }