@tiens.nguyen/gu-cli 1.0.834 → 1.0.836
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 +1 -1
- package/browser-bridge.mjs +72 -11
- package/browser-command.mjs +214 -20
- package/browser-extension/README.md +67 -27
- package/browser-extension/actions.js +319 -0
- package/browser-extension/background.js +100 -322
- package/browser-extension/icons/icon32.png +0 -0
- package/browser-extension/manifest.json +3 -1
- package/browser-extension/popup.html +4 -0
- package/browser-extension/popup.js +23 -0
- package/client-expert-api.mjs +43 -2
- package/gonext_agent_chat.py +424 -59
- package/gu-cli.mjs +45 -15
- package/gu-repl.mjs +185 -26
- package/gu_browser.py +92 -20
- package/gu_powershell.py +675 -0
- package/install-path.mjs +19 -2
- package/package.json +5 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +95 -26
- package/terminal-layout.mjs +1 -0
- package/win-path.mjs +185 -0
package/gu_powershell.py
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"""POWERSHELL FOR run_command ON WINDOWS (task #374, GitHub #155) — what bash is on macOS/Linux.
|
|
2
|
+
|
|
3
|
+
On macOS and Linux a line that needs a shell runs through `bash -c` and every simple command in it
|
|
4
|
+
is checked first (task #359). Windows had Git Bash when Git for Windows was installed and NO shell
|
|
5
|
+
otherwise: a pipe, `;`, `$env:X = …` or any cmdlet (Get-ChildItem, Select-String) could not run,
|
|
6
|
+
and the model spent steps working around it. Claude Code runs PowerShell on Windows; so does gu now.
|
|
7
|
+
|
|
8
|
+
This module is the PURE half — no process is started here, so all of it is tested on any OS:
|
|
9
|
+
|
|
10
|
+
ps_scan(text) the line read the way PowerShell reads it: words, operators, and the bodies
|
|
11
|
+
of $( ), @( ), ( ) and { } (code that RUNS, so it is checked like the rest)
|
|
12
|
+
ps_commands(tokens) the simple commands, in the shape gonext_agent_chat's checks already take
|
|
13
|
+
ps_effects(cmd) what one command deletes, writes, or runs as code
|
|
14
|
+
ps_sequence(text) the line with && / || made explicit — Windows PowerShell 5.1 has neither —
|
|
15
|
+
and the exit status of the LAST statement kept in $__gu_ok
|
|
16
|
+
ps_wrapper(…) the outer script: UTF-8 out, the turn's folder and $env: kept between calls,
|
|
17
|
+
and the exit code written to a file a background shell outlives
|
|
18
|
+
|
|
19
|
+
The agent does the rest: the policy (gonext_agent_chat._ws_ps_command_risk) and the spawn.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
# PowerShell reads both kinds of typographic quotes as quotes.
|
|
25
|
+
_SQ = "'\u2018\u2019\u201a\u201b"
|
|
26
|
+
_DQ = '"\u201c\u201d\u201e'
|
|
27
|
+
|
|
28
|
+
# Separators between commands; `&` is a separator only AFTER a command (background), and the call
|
|
29
|
+
# operator at a command's start.
|
|
30
|
+
PS_SEPARATORS = {"|", "||", "&&", ";", "\n", "&"}
|
|
31
|
+
_REDIRECT_RE = re.compile(r"(?:[1-6*]?>>?)(?:&[12])?") # >, >>, 2>, 2>>, *>, 2>&1, *>&1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ps_scan(command):
|
|
35
|
+
"""Tokenise a PowerShell line. PURE. → dict, the same shape as the bash scan:
|
|
36
|
+
|
|
37
|
+
tokens — ("word", text, start, end, quoted) / ("op", text, start, end, False)
|
|
38
|
+
substs — [{"text", "tokens", "kind"}] for every $( ), @( ), ( ) and { } body
|
|
39
|
+
heredocs — how many here-strings (@' '@ / @" "@) were read
|
|
40
|
+
expands — True when a $variable, wildcard or ~ needs PowerShell to expand it
|
|
41
|
+
error — why the line cannot be run (an unclosed quote, here-string, bracket), or None
|
|
42
|
+
"""
|
|
43
|
+
out = {"tokens": [], "substs": [], "heredocs": 0, "expands": False, "error": None}
|
|
44
|
+
try:
|
|
45
|
+
_scan_into(command or "", 0, out, out["tokens"], closer=None)
|
|
46
|
+
except ValueError as e:
|
|
47
|
+
out["error"] = str(e)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _scan_into(s, i, out, toks, closer):
|
|
52
|
+
"""Scan from `i` into `toks`; with `closer` (")" or "}"), stop past the matching one."""
|
|
53
|
+
n = len(s)
|
|
54
|
+
st = {"word": None, "start": 0, "quoted": False}
|
|
55
|
+
|
|
56
|
+
def start_word(at):
|
|
57
|
+
if st["word"] is None:
|
|
58
|
+
st["word"], st["start"] = [], at
|
|
59
|
+
|
|
60
|
+
def end_word(at):
|
|
61
|
+
if st["word"] is None:
|
|
62
|
+
return
|
|
63
|
+
toks.append(("word", "".join(st["word"]), st["start"], at, st["quoted"]))
|
|
64
|
+
st["word"], st["quoted"] = None, False
|
|
65
|
+
|
|
66
|
+
def nested(j, kind, close): # j = index just past the opener → index past its closer
|
|
67
|
+
inner = []
|
|
68
|
+
k = _scan_into(s, j, out, inner, closer=close)
|
|
69
|
+
out["substs"].append({"text": s[j:k - 1], "tokens": inner, "kind": kind})
|
|
70
|
+
return k
|
|
71
|
+
|
|
72
|
+
def dollar(j): # a `$` at j → index past what it starts, or None for a literal $
|
|
73
|
+
if s.startswith("$(", j):
|
|
74
|
+
out["expands"] = True
|
|
75
|
+
return nested(j + 2, "$(", ")")
|
|
76
|
+
if s.startswith("${", j):
|
|
77
|
+
k = s.find("}", j + 2)
|
|
78
|
+
if k < 0:
|
|
79
|
+
raise ValueError("unclosed ${")
|
|
80
|
+
out["expands"] = True
|
|
81
|
+
return k + 1
|
|
82
|
+
m = re.match(r"\$(?:[A-Za-z_][\w]*:)?[\w?^$]+", s[j:])
|
|
83
|
+
if m:
|
|
84
|
+
out["expands"] = True
|
|
85
|
+
return j + m.end()
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def here_string(j, q): # j = index of "@" in @' or @" at the end of a line
|
|
89
|
+
nl = s.find("\n", j + 2)
|
|
90
|
+
if nl < 0 or s[j + 2:nl].strip(" \t\r"):
|
|
91
|
+
raise ValueError("a here-string's opening @" + q + " must end its line")
|
|
92
|
+
k = nl + 1
|
|
93
|
+
while True:
|
|
94
|
+
if k >= n:
|
|
95
|
+
raise ValueError("here-string never closed (" + q + "@ must start a line)")
|
|
96
|
+
e = s.find("\n", k)
|
|
97
|
+
line = s[k:] if e < 0 else s[k:e]
|
|
98
|
+
if line.rstrip("\r").startswith(q + "@"):
|
|
99
|
+
end = k + 2
|
|
100
|
+
break
|
|
101
|
+
if q == '"': # an expandable here-string runs its $( ) bodies
|
|
102
|
+
p = 0
|
|
103
|
+
while True:
|
|
104
|
+
p = line.find("$(", p)
|
|
105
|
+
if p < 0:
|
|
106
|
+
break
|
|
107
|
+
nested(k + p + 2, "$(", ")")
|
|
108
|
+
p += 2
|
|
109
|
+
k = n if e < 0 else e + 1
|
|
110
|
+
out["heredocs"] += 1
|
|
111
|
+
return end
|
|
112
|
+
|
|
113
|
+
while i < n:
|
|
114
|
+
c = s[i]
|
|
115
|
+
if c in " \t\r\f":
|
|
116
|
+
end_word(i)
|
|
117
|
+
i += 1
|
|
118
|
+
continue
|
|
119
|
+
if c == "\n":
|
|
120
|
+
end_word(i)
|
|
121
|
+
# A line ending with `|` continues on the next one.
|
|
122
|
+
if not (toks and toks[-1][0] == "op" and toks[-1][1] == "|"):
|
|
123
|
+
toks.append(("op", "\n", i, i + 1, False))
|
|
124
|
+
i += 1
|
|
125
|
+
continue
|
|
126
|
+
if c == "#" and st["word"] is None:
|
|
127
|
+
j = s.find("\n", i)
|
|
128
|
+
i = n if j < 0 else j
|
|
129
|
+
continue
|
|
130
|
+
if s.startswith("<#", i) and st["word"] is None:
|
|
131
|
+
j = s.find("#>", i + 2)
|
|
132
|
+
if j < 0:
|
|
133
|
+
raise ValueError("unclosed <# comment")
|
|
134
|
+
i = j + 2
|
|
135
|
+
continue
|
|
136
|
+
if c == "`":
|
|
137
|
+
if i + 1 < n and s[i + 1] in "\r\n": # line continuation
|
|
138
|
+
i += 3 if s.startswith("\r\n", i + 1) else 2
|
|
139
|
+
continue
|
|
140
|
+
start_word(i)
|
|
141
|
+
if i + 1 < n:
|
|
142
|
+
st["word"].append(s[i + 1])
|
|
143
|
+
st["quoted"] = True
|
|
144
|
+
i += 2
|
|
145
|
+
continue
|
|
146
|
+
if c == "@" and i + 1 < n and s[i + 1] in _SQ + _DQ and st["word"] is None:
|
|
147
|
+
# A here-string header: nothing may follow it on its line (PowerShell refuses too).
|
|
148
|
+
start_word(i)
|
|
149
|
+
k = here_string(i, "'" if s[i + 1] in _SQ else '"')
|
|
150
|
+
st["word"].extend(s[i:k])
|
|
151
|
+
st["quoted"] = True
|
|
152
|
+
i = k
|
|
153
|
+
continue
|
|
154
|
+
if c in _SQ:
|
|
155
|
+
start_word(i)
|
|
156
|
+
st["quoted"] = True
|
|
157
|
+
i += 1
|
|
158
|
+
while True:
|
|
159
|
+
if i >= n:
|
|
160
|
+
raise ValueError("unbalanced single quote")
|
|
161
|
+
if s[i] in _SQ:
|
|
162
|
+
if i + 1 < n and s[i + 1] in _SQ: # '' is one quote
|
|
163
|
+
st["word"].append("'")
|
|
164
|
+
i += 2
|
|
165
|
+
continue
|
|
166
|
+
i += 1
|
|
167
|
+
break
|
|
168
|
+
st["word"].append(s[i])
|
|
169
|
+
i += 1
|
|
170
|
+
continue
|
|
171
|
+
if c in _DQ:
|
|
172
|
+
start_word(i)
|
|
173
|
+
st["quoted"] = True
|
|
174
|
+
i += 1
|
|
175
|
+
while True:
|
|
176
|
+
if i >= n:
|
|
177
|
+
raise ValueError("unbalanced double quote")
|
|
178
|
+
d = s[i]
|
|
179
|
+
if d in _DQ:
|
|
180
|
+
if i + 1 < n and s[i + 1] in _DQ:
|
|
181
|
+
st["word"].append('"')
|
|
182
|
+
i += 2
|
|
183
|
+
continue
|
|
184
|
+
i += 1
|
|
185
|
+
break
|
|
186
|
+
if d == "`" and i + 1 < n:
|
|
187
|
+
st["word"].append(s[i + 1])
|
|
188
|
+
i += 2
|
|
189
|
+
continue
|
|
190
|
+
if d == "$":
|
|
191
|
+
k = dollar(i)
|
|
192
|
+
if k is not None:
|
|
193
|
+
st["word"].extend(s[i:k])
|
|
194
|
+
i = k
|
|
195
|
+
continue
|
|
196
|
+
st["word"].append(d)
|
|
197
|
+
i += 1
|
|
198
|
+
continue
|
|
199
|
+
if c == "$":
|
|
200
|
+
k = dollar(i)
|
|
201
|
+
if k is not None:
|
|
202
|
+
start_word(i)
|
|
203
|
+
st["word"].extend(s[i:k])
|
|
204
|
+
i = k
|
|
205
|
+
continue
|
|
206
|
+
if c == "@" and i + 1 < n and s[i + 1] in "({":
|
|
207
|
+
start_word(i)
|
|
208
|
+
k = nested(i + 2, "@" + s[i + 1], ")" if s[i + 1] == "(" else "}")
|
|
209
|
+
st["word"].extend(s[i:k])
|
|
210
|
+
i = k
|
|
211
|
+
continue
|
|
212
|
+
if c in "({":
|
|
213
|
+
start_word(i)
|
|
214
|
+
k = nested(i + 1, c, ")" if c == "(" else "}")
|
|
215
|
+
st["word"].extend(s[i:k])
|
|
216
|
+
i = k
|
|
217
|
+
continue
|
|
218
|
+
if c in ")}":
|
|
219
|
+
if closer == c:
|
|
220
|
+
end_word(i)
|
|
221
|
+
return i + 1
|
|
222
|
+
raise ValueError(f"unbalanced {c}")
|
|
223
|
+
if c == "[" and st["word"] is None: # a type literal: [IO.File]::ReadAllText(…)
|
|
224
|
+
k = s.find("]", i)
|
|
225
|
+
if k > 0:
|
|
226
|
+
start_word(i)
|
|
227
|
+
st["word"].extend(s[i:k + 1])
|
|
228
|
+
i = k + 1
|
|
229
|
+
continue
|
|
230
|
+
op = _op_at(s, i, st["word"])
|
|
231
|
+
if op:
|
|
232
|
+
if op[0] in "123456" and st["word"] is not None:
|
|
233
|
+
op = None # a digit inside a word ("a2>b" is not a redirect of fd 2)
|
|
234
|
+
if op:
|
|
235
|
+
end_word(i)
|
|
236
|
+
toks.append(("op", op, i, i + len(op), False))
|
|
237
|
+
i += len(op)
|
|
238
|
+
continue
|
|
239
|
+
start_word(i)
|
|
240
|
+
if c in "*?" or (c == "~" and not st["word"]):
|
|
241
|
+
out["expands"] = True
|
|
242
|
+
st["word"].append(c)
|
|
243
|
+
i += 1
|
|
244
|
+
end_word(n)
|
|
245
|
+
if closer:
|
|
246
|
+
raise ValueError(f"unbalanced {'(' if closer == ')' else '{'}")
|
|
247
|
+
return n
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _op_at(s, i, word):
|
|
251
|
+
for op in ("&&", "||"):
|
|
252
|
+
if s.startswith(op, i):
|
|
253
|
+
return op
|
|
254
|
+
m = _REDIRECT_RE.match(s, i)
|
|
255
|
+
if m and m.group(0):
|
|
256
|
+
return m.group(0)
|
|
257
|
+
if s[i] in "|;&<":
|
|
258
|
+
return s[i]
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------------ commands -----------------
|
|
263
|
+
|
|
264
|
+
_ASSIGN_RE = re.compile(r"^\$[\w:{}?]+$")
|
|
265
|
+
_ASSIGN_OPS = {"=", "+=", "-=", "*=", "/=", "%=", "??="}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def ps_commands(tokens):
|
|
269
|
+
"""Tokens → simple commands: [{"argv", "spans", "sep", "redirects", "assigns", "call", "background"}].
|
|
270
|
+
|
|
271
|
+
`call` — the command started with the call operator (`& 'C:\\x\\tool.exe' …`);
|
|
272
|
+
`assigns` — `$x = <command>` puts the command in argv and ("$x", "") in assigns;
|
|
273
|
+
`sep` — the separator before it ("" for the first); a trailing `&` sets `background`."""
|
|
274
|
+
cmds, cur, sep, i = [], None, "", 0
|
|
275
|
+
|
|
276
|
+
def new():
|
|
277
|
+
return {"argv": [], "spans": [], "quoted": [], "sep": sep, "redirects": [], "assigns": [],
|
|
278
|
+
"call": False, "background": False}
|
|
279
|
+
|
|
280
|
+
while i < len(tokens):
|
|
281
|
+
kind, text, a, b, quoted = tokens[i]
|
|
282
|
+
if kind == "op" and text == "&" and (cur is None or not cur["argv"]):
|
|
283
|
+
cur = cur or new()
|
|
284
|
+
cur["call"] = True
|
|
285
|
+
i += 1
|
|
286
|
+
continue
|
|
287
|
+
if kind == "op" and text in PS_SEPARATORS:
|
|
288
|
+
if cur and (cur["argv"] or cur["redirects"] or cur["assigns"]):
|
|
289
|
+
if text == "&":
|
|
290
|
+
cur["background"] = True
|
|
291
|
+
cmds.append(cur)
|
|
292
|
+
cur, sep = None, text
|
|
293
|
+
i += 1
|
|
294
|
+
continue
|
|
295
|
+
if cur is None:
|
|
296
|
+
cur = new()
|
|
297
|
+
if kind == "op": # a redirect; `2>&1` has no target
|
|
298
|
+
nxt = tokens[i + 1] if i + 1 < len(tokens) else None
|
|
299
|
+
if "&" in text or text == "<":
|
|
300
|
+
cur["redirects"].append((text, ""))
|
|
301
|
+
i += 1
|
|
302
|
+
continue
|
|
303
|
+
tgt = nxt[1] if nxt and nxt[0] == "word" else ""
|
|
304
|
+
cur["redirects"].append((text, tgt))
|
|
305
|
+
i += 2 if tgt or (nxt and nxt[0] == "word") else 1
|
|
306
|
+
continue
|
|
307
|
+
if not cur["argv"] and not quoted and _ASSIGN_RE.match(text) and i + 1 < len(tokens) \
|
|
308
|
+
and tokens[i + 1][0] == "word" and tokens[i + 1][1] in _ASSIGN_OPS:
|
|
309
|
+
cur["assigns"].append((text, ""))
|
|
310
|
+
i += 2
|
|
311
|
+
continue
|
|
312
|
+
cur["argv"].append(text)
|
|
313
|
+
cur["spans"].append((a, b))
|
|
314
|
+
cur["quoted"].append(bool(quoted))
|
|
315
|
+
i += 1
|
|
316
|
+
if cur and (cur["argv"] or cur["redirects"] or cur["assigns"]):
|
|
317
|
+
cmds.append(cur)
|
|
318
|
+
return cmds
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def ps_all_commands(scan):
|
|
322
|
+
"""(top-level commands, commands inside $( ), @( ), ( ) and { } bodies)."""
|
|
323
|
+
top = ps_commands(scan["tokens"])
|
|
324
|
+
inner = [c for sub in scan["substs"] for c in ps_commands(sub["tokens"])]
|
|
325
|
+
return top, inner
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------------ what a command does ------
|
|
329
|
+
|
|
330
|
+
# Aliases PowerShell ships (5.1 and 7), for the cmdlets the checks care about.
|
|
331
|
+
_ALIASES = {
|
|
332
|
+
"rm": "remove-item", "del": "remove-item", "erase": "remove-item", "rd": "remove-item",
|
|
333
|
+
"rmdir": "remove-item", "ri": "remove-item",
|
|
334
|
+
"cp": "copy-item", "copy": "copy-item", "cpi": "copy-item",
|
|
335
|
+
"mv": "move-item", "move": "move-item", "mi": "move-item",
|
|
336
|
+
"ni": "new-item", "md": "new-item", "mkdir": "new-item",
|
|
337
|
+
"sc": "set-content", "ac": "add-content", "clc": "clear-content",
|
|
338
|
+
"tee": "tee-object", "epcsv": "export-csv",
|
|
339
|
+
"cd": "set-location", "sl": "set-location", "chdir": "set-location", "pushd": "push-location",
|
|
340
|
+
"popd": "pop-location",
|
|
341
|
+
"iex": "invoke-expression", "icm": "invoke-command",
|
|
342
|
+
"iwr": "invoke-webrequest", "curl": "invoke-webrequest", "wget": "invoke-webrequest",
|
|
343
|
+
"irm": "invoke-restmethod",
|
|
344
|
+
"saps": "start-process", "start": "start-process",
|
|
345
|
+
"ls": "get-childitem", "dir": "get-childitem", "gci": "get-childitem",
|
|
346
|
+
"cat": "get-content", "gc": "get-content", "type": "get-content",
|
|
347
|
+
"echo": "write-output", "write": "write-output",
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def ps_program(argv, quoted=False):
|
|
352
|
+
"""The command's program, lower-case: a cmdlet's canonical name, a native program's base name
|
|
353
|
+
without .exe — or "" when the command starts with an expression (a string, a $variable).
|
|
354
|
+
`quoted`: the first word was quoted — a string, unless the call operator runs it."""
|
|
355
|
+
if not argv or quoted:
|
|
356
|
+
return ""
|
|
357
|
+
w = argv[0]
|
|
358
|
+
if not w or w[0] in "$('\"@[{" or re.match(r"^[\d.]+$", w):
|
|
359
|
+
return ""
|
|
360
|
+
base = re.split(r"[\\/]", w)[-1].lower()
|
|
361
|
+
for ext in (".exe", ".cmd", ".bat", ".com"):
|
|
362
|
+
if base.endswith(ext):
|
|
363
|
+
base = base[: -len(ext)]
|
|
364
|
+
break
|
|
365
|
+
return _ALIASES.get(base, base)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# Parameters that take a value, for the cmdlets whose paths are read. A prefix is enough in
|
|
369
|
+
# PowerShell (-Dest = -Destination), and `-Path:value` attaches the value.
|
|
370
|
+
_VALUE_PARAMS = ("path", "literalpath", "filepath", "destination", "destinationpath", "outfile",
|
|
371
|
+
"filter", "include", "exclude", "encoding", "value", "itemtype", "name",
|
|
372
|
+
"credential", "stream", "delimiter", "width", "inputobject", "newname", "target",
|
|
373
|
+
"argumentlist", "workingdirectory", "verb", "uri", "method", "body", "headers",
|
|
374
|
+
"scriptblock", "command", "computername", "type")
|
|
375
|
+
_PATH_POSITIONS = {
|
|
376
|
+
# cmdlet → ({named params that are paths written/removed}, positional indexes that are)
|
|
377
|
+
"remove-item": ({"path", "literalpath"}, "all"),
|
|
378
|
+
"set-content": ({"path", "literalpath"}, (0,)),
|
|
379
|
+
"add-content": ({"path", "literalpath"}, (0,)),
|
|
380
|
+
"clear-content": ({"path", "literalpath"}, "all"),
|
|
381
|
+
"out-file": ({"filepath", "literalpath"}, (0,)),
|
|
382
|
+
"tee-object": ({"filepath", "literalpath"}, (0,)),
|
|
383
|
+
"export-csv": ({"path", "literalpath"}, (0,)),
|
|
384
|
+
"new-item": ({"path"}, (0,)),
|
|
385
|
+
"copy-item": ({"destination"}, (1,)),
|
|
386
|
+
"move-item": ({"destination"}, (1,)),
|
|
387
|
+
"expand-archive": ({"destinationpath"}, (1,)),
|
|
388
|
+
"compress-archive": ({"destinationpath"}, (1,)),
|
|
389
|
+
"invoke-webrequest": ({"outfile"}, ()),
|
|
390
|
+
"invoke-restmethod": ({"outfile"}, ()),
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# The value parameters of the cmdlets whose paths are read — PowerShell resolves an abbreviation
|
|
395
|
+
# among ONE cmdlet's parameters: `-Dest` is Copy-Item's -Destination although Expand-Archive has a
|
|
396
|
+
# -DestinationPath.
|
|
397
|
+
_CMDLET_PARAMS = {
|
|
398
|
+
"remove-item": ("path", "literalpath", "filter", "include", "exclude", "credential", "stream"),
|
|
399
|
+
"set-content": ("path", "literalpath", "value", "filter", "include", "exclude", "encoding", "stream"),
|
|
400
|
+
"add-content": ("path", "literalpath", "value", "filter", "include", "exclude", "encoding", "stream"),
|
|
401
|
+
"clear-content": ("path", "literalpath", "filter", "include", "exclude", "stream"),
|
|
402
|
+
"out-file": ("filepath", "literalpath", "encoding", "inputobject", "width"),
|
|
403
|
+
"tee-object": ("filepath", "literalpath", "inputobject", "variable", "encoding"),
|
|
404
|
+
"export-csv": ("path", "literalpath", "inputobject", "delimiter", "encoding"),
|
|
405
|
+
"new-item": ("path", "name", "itemtype", "value", "credential"),
|
|
406
|
+
"copy-item": ("path", "literalpath", "destination", "filter", "include", "exclude", "credential",
|
|
407
|
+
"fromsession", "tosession"),
|
|
408
|
+
"move-item": ("path", "literalpath", "destination", "filter", "include", "exclude", "credential"),
|
|
409
|
+
"expand-archive": ("path", "literalpath", "destinationpath"),
|
|
410
|
+
"compress-archive": ("path", "literalpath", "destinationpath", "compressionlevel"),
|
|
411
|
+
"invoke-webrequest": ("uri", "outfile", "method", "body", "headers", "infile", "credential",
|
|
412
|
+
"useragent", "contenttype", "timeoutsec"),
|
|
413
|
+
"invoke-restmethod": ("uri", "outfile", "method", "body", "headers", "infile", "credential",
|
|
414
|
+
"useragent", "contenttype", "timeoutsec"),
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _param(word, prog=""):
|
|
419
|
+
"""`-Dest:x` → ("destination", "x", True); `-Recurse` → ("recurse", None, False).
|
|
420
|
+
The third item: the parameter takes a value. Not a parameter → None.
|
|
421
|
+
An abbreviation that fits SEVERAL value parameters still takes a value (PowerShell would
|
|
422
|
+
refuse it; reading the next word as its value never hides a path from the checks)."""
|
|
423
|
+
m = re.match(r"^-([A-Za-z][\w]*)(?::(.*))?$", word or "")
|
|
424
|
+
if not m:
|
|
425
|
+
return None
|
|
426
|
+
name = m.group(1).lower()
|
|
427
|
+
known = _CMDLET_PARAMS.get(prog) or _VALUE_PARAMS
|
|
428
|
+
full = [p for p in known if p == name] or [p for p in known if p.startswith(name)]
|
|
429
|
+
if len(full) == 1:
|
|
430
|
+
return full[0], m.group(2), True
|
|
431
|
+
return name, m.group(2), bool(full)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _split_list(word):
|
|
435
|
+
"""`a,b` / `a,` → ["a", "b"] — PowerShell's array argument."""
|
|
436
|
+
return [p for p in (x.strip() for x in word.split(",")) if p]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def ps_bound(argv, prog=None):
|
|
440
|
+
"""argv[1:] → ({param: [values]}, [positionals], {switches}). `prog` (default: argv[0]'s
|
|
441
|
+
cmdlet) decides what an abbreviated parameter means."""
|
|
442
|
+
named, pos, switches = {}, [], set()
|
|
443
|
+
prog = ps_program(argv) if prog is None else prog
|
|
444
|
+
i = 1
|
|
445
|
+
while i < len(argv):
|
|
446
|
+
w = argv[i]
|
|
447
|
+
p = _param(w, prog)
|
|
448
|
+
if p:
|
|
449
|
+
name, attached, takes = p
|
|
450
|
+
if attached is not None:
|
|
451
|
+
named.setdefault(name, []).extend(_split_list(attached))
|
|
452
|
+
elif takes and i + 1 < len(argv):
|
|
453
|
+
named.setdefault(name, []).extend(_split_list(argv[i + 1]))
|
|
454
|
+
i += 1
|
|
455
|
+
elif not takes:
|
|
456
|
+
switches.add(name)
|
|
457
|
+
i += 1
|
|
458
|
+
continue
|
|
459
|
+
pos.extend(_split_list(w))
|
|
460
|
+
i += 1
|
|
461
|
+
return named, pos, switches
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def ps_effects(cmd):
|
|
465
|
+
"""What one command does that the policy weighs → dict:
|
|
466
|
+
deletes / writes — the paths it removes / writes (redirect targets included);
|
|
467
|
+
runs_text — it runs TEXT as code (Invoke-Expression, [ScriptBlock]::Create, -EncodedCommand);
|
|
468
|
+
dynamic — the program is only known at run time (`& $x`, `& (…)`);
|
|
469
|
+
destructive — wipes disks, powers off, or removes recursively/by force."""
|
|
470
|
+
argv = cmd["argv"]
|
|
471
|
+
prog = ps_program(argv, bool(cmd.get("quoted") and cmd["quoted"][0] and not cmd.get("call")))
|
|
472
|
+
named, pos, switches = ps_bound(argv, prog)
|
|
473
|
+
eff = {"program": prog, "deletes": [], "writes": [], "runs_text": "", "dynamic": False,
|
|
474
|
+
"destructive": False}
|
|
475
|
+
rule = _PATH_POSITIONS.get(prog)
|
|
476
|
+
if rule:
|
|
477
|
+
params, positions = rule
|
|
478
|
+
paths = [v for k in params for v in named.get(k, [])]
|
|
479
|
+
if positions == "all":
|
|
480
|
+
paths += pos
|
|
481
|
+
else:
|
|
482
|
+
paths += [pos[i] for i in positions if i < len(pos)]
|
|
483
|
+
(eff["deletes"] if prog == "remove-item" else eff["writes"]).extend(paths)
|
|
484
|
+
if prog == "move-item": # a move removes its source
|
|
485
|
+
eff["deletes"] += named.get("path", []) + named.get("literalpath", []) + pos[:1]
|
|
486
|
+
eff["writes"] += [t for op, t in cmd["redirects"] if t and op not in ("<",) and "&" not in op]
|
|
487
|
+
joined = " ".join(argv)
|
|
488
|
+
if prog == "invoke-expression":
|
|
489
|
+
eff["runs_text"] = "Invoke-Expression"
|
|
490
|
+
elif re.search(r"\[scriptblock\]::create", joined, re.I):
|
|
491
|
+
eff["runs_text"] = "[ScriptBlock]::Create"
|
|
492
|
+
elif prog in ("powershell", "pwsh") and any(
|
|
493
|
+
re.match(r"^-(e|ec|enc|encodedcommand)$", a, re.I) for a in argv[1:]):
|
|
494
|
+
eff["runs_text"] = "an -EncodedCommand"
|
|
495
|
+
if cmd.get("call") and argv and argv[0][:1] in ("$", "("):
|
|
496
|
+
eff["dynamic"] = True
|
|
497
|
+
if prog in ("format-volume", "clear-disk", "initialize-disk", "remove-partition",
|
|
498
|
+
"stop-computer", "restart-computer", "format", "diskpart", "shutdown"):
|
|
499
|
+
eff["destructive"] = True
|
|
500
|
+
if prog == "remove-item" and switches & {"recurse", "r", "force", "rf", "fr"}:
|
|
501
|
+
eff["destructive"] = True
|
|
502
|
+
return eff
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# Runners that execute their stdin as code when given no script (`… | iex`, `… | pwsh -`).
|
|
506
|
+
PS_INTERPRETERS = {"invoke-expression", "powershell", "pwsh", "cmd", "bash", "sh", "wsl", "python",
|
|
507
|
+
"python3", "py", "pythonw", "node", "perl", "ruby", "php"}
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def ps_is_literal(cmd):
|
|
511
|
+
"""A pipeline stage that is only a string or a here-string the line itself wrote — the
|
|
512
|
+
PowerShell form of a heredoc: `@' … '@ | py -3 -` runs code the model wrote, in plain view."""
|
|
513
|
+
a = cmd["argv"]
|
|
514
|
+
q = cmd.get("quoted") or []
|
|
515
|
+
return len(a) == 1 and not cmd.get("call") and bool(q and q[0]) and "$(" not in a[0]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ------------------------------------------------------------------ running it ---------------
|
|
519
|
+
|
|
520
|
+
_CONTINUES = re.compile(r"^\s*(?:(?:else|elseif|catch|finally|until|while)\b|\{)", re.I)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def ps_sequence(command, scan=None, legacy=False):
|
|
524
|
+
"""The line with its top-level `;`, newlines, && and || made explicit, so that
|
|
525
|
+
Windows PowerShell 5.1 (no && / ||) runs `a && b` as bash would, and $__gu_ok holds whether
|
|
526
|
+
the last statement that ran succeeded — the exit code. Text inside quotes, here-strings and
|
|
527
|
+
brackets is never split; `else`/`catch`/… stays with the statement before it.
|
|
528
|
+
|
|
529
|
+
legacy=True (Windows PowerShell 5.1): there `2>&1` turns a program's stderr lines into error
|
|
530
|
+
records — red boilerplate in the output, and $? false although the program exited 0
|
|
531
|
+
(`git push 2>&1`). On the LAST stage of a pipeline it changes nothing else (gu already merges
|
|
532
|
+
stderr into the output), so it is dropped there; elsewhere a "failure" that is only such a
|
|
533
|
+
record, with exit code 0, counts as the success it was."""
|
|
534
|
+
scan = scan or ps_scan(command)
|
|
535
|
+
s = command or ""
|
|
536
|
+
if legacy:
|
|
537
|
+
toks = scan["tokens"]
|
|
538
|
+
drop = []
|
|
539
|
+
for k, t in enumerate(toks):
|
|
540
|
+
if t[0] == "op" and t[1] in ("2>&1", "*>&1"):
|
|
541
|
+
nxt = next((u for u in toks[k + 1:] if u[0] == "op" and u[1] in PS_SEPARATORS), None)
|
|
542
|
+
if not nxt or nxt[1] != "|":
|
|
543
|
+
drop.append((t[2], t[3]))
|
|
544
|
+
if drop:
|
|
545
|
+
for a, b in sorted(drop, reverse=True):
|
|
546
|
+
s = s[:a] + " " * (b - a) + s[b:] # same length: every other position still holds
|
|
547
|
+
ok = "\n$__gu_ok = $?"
|
|
548
|
+
if legacy:
|
|
549
|
+
ok += ("\nif (-not $__gu_ok -and $LASTEXITCODE -eq 0 -and $Error.Count -gt $__gu_n -and "
|
|
550
|
+
"\"$($Error[0].FullyQualifiedErrorId)\" -like 'NativeCommandError*') { $__gu_ok = $true }")
|
|
551
|
+
cuts = [(t[2], t[3], t[1]) for t in scan["tokens"]
|
|
552
|
+
if t[0] == "op" and t[1] in ("&&", "||", ";", "\n")]
|
|
553
|
+
parts, prev, sep = [], 0, ";"
|
|
554
|
+
for a, b, op in cuts:
|
|
555
|
+
seg = s[prev:a]
|
|
556
|
+
nxt_start = b
|
|
557
|
+
if op in (";", "\n") and _CONTINUES.match(s[nxt_start:]):
|
|
558
|
+
continue # `}` newline `else {` — one statement
|
|
559
|
+
parts.append((sep, seg))
|
|
560
|
+
sep, prev = op, b
|
|
561
|
+
parts.append((sep, s[prev:]))
|
|
562
|
+
lines = []
|
|
563
|
+
for sep, seg in parts:
|
|
564
|
+
if not seg.strip():
|
|
565
|
+
continue
|
|
566
|
+
body = ("$__gu_n = $Error.Count\n" if legacy else "") + seg.strip(" \t\r\n") + ok
|
|
567
|
+
if sep == "&&" and lines:
|
|
568
|
+
lines.append("if ($__gu_ok) {\n" + body + "\n}")
|
|
569
|
+
elif sep == "||" and lines:
|
|
570
|
+
lines.append("if (-not $__gu_ok) {\n" + body + "\n}")
|
|
571
|
+
else:
|
|
572
|
+
lines.append(body)
|
|
573
|
+
return "$__gu_ok = $true\n" + "\n".join(lines) + "\n"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
_VAR_IN_PATH = re.compile(r"\$(?:\{env:([^}]+)\}|env:([A-Za-z_][\w()]*)|([A-Za-z_]\w*))", re.I)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def ps_expand_path(p, env, home, cwd=""):
|
|
580
|
+
"""A path as PowerShell will read it: $env:X / ${env:X}, $HOME, $PWD and a leading ~ filled
|
|
581
|
+
in — or None when it holds anything else that only exists at run time (then the caller must
|
|
582
|
+
treat it as anywhere, i.e. outside the project)."""
|
|
583
|
+
lower_env = {k.lower(): v for k, v in (env or {}).items()}
|
|
584
|
+
unknown = []
|
|
585
|
+
|
|
586
|
+
def sub(m):
|
|
587
|
+
name = m.group(1) or m.group(2)
|
|
588
|
+
if name:
|
|
589
|
+
v = lower_env.get(name.lower())
|
|
590
|
+
else:
|
|
591
|
+
v = {"home": home, "pwd": cwd}.get(m.group(3).lower())
|
|
592
|
+
if v is None or v == "":
|
|
593
|
+
unknown.append(m.group(0))
|
|
594
|
+
return m.group(0)
|
|
595
|
+
return v
|
|
596
|
+
|
|
597
|
+
out = _VAR_IN_PATH.sub(sub, p or "")
|
|
598
|
+
if unknown or "$(" in out:
|
|
599
|
+
return None
|
|
600
|
+
if out == "~" or out.startswith(("~/", "~\\")):
|
|
601
|
+
out = home + out[1:]
|
|
602
|
+
return out
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _psq(s):
|
|
606
|
+
"""A PowerShell single-quoted literal."""
|
|
607
|
+
return "'" + str(s).replace("'", "''") + "'"
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def ps_wrapper(cmd_file, exit_file, state_file):
|
|
611
|
+
"""The outer script PowerShell runs with -File. It reads the (sequenced) line from `cmd_file`
|
|
612
|
+
and dot-sources it — a parse error is then an error this script reports, not one that skips
|
|
613
|
+
the exit file — and, whatever happens, writes the exit code to `exit_file`, the folder to
|
|
614
|
+
`state_file`.cwd and the $env: variables the line changed to `state_file` (JSON), which the
|
|
615
|
+
next call restores: the turn keeps its `cd` and `$env:X = …` the way bash keeps its exports."""
|
|
616
|
+
return "\n".join([
|
|
617
|
+
"$ErrorActionPreference = 'Continue'",
|
|
618
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
619
|
+
"try { $PSStyle.OutputRendering = 'PlainText' } catch {}",
|
|
620
|
+
"try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false } catch {}",
|
|
621
|
+
"$OutputEncoding = New-Object System.Text.UTF8Encoding $false",
|
|
622
|
+
f"$__gu_exit = {_psq(exit_file)}",
|
|
623
|
+
f"$__gu_state = {_psq(state_file)}",
|
|
624
|
+
"if (Test-Path -LiteralPath $__gu_state) {",
|
|
625
|
+
" try {",
|
|
626
|
+
" $__gu_saved = Get-Content -LiteralPath $__gu_state -Raw -Encoding UTF8 | ConvertFrom-Json",
|
|
627
|
+
" foreach ($__gu_p in $__gu_saved.PSObject.Properties) {",
|
|
628
|
+
" if ($null -eq $__gu_p.Value) { Remove-Item -LiteralPath ('env:' + $__gu_p.Name) -ErrorAction SilentlyContinue }",
|
|
629
|
+
" else { Set-Item -LiteralPath ('env:' + $__gu_p.Name) -Value $__gu_p.Value }",
|
|
630
|
+
" }",
|
|
631
|
+
" } catch {}",
|
|
632
|
+
"}",
|
|
633
|
+
"$__gu_env0 = @{}",
|
|
634
|
+
"foreach ($__gu_e in Get-ChildItem env:) { $__gu_env0[$__gu_e.Name] = $__gu_e.Value }",
|
|
635
|
+
"$__gu_rc = 1",
|
|
636
|
+
"$__gu_ok = $false",
|
|
637
|
+
"$global:LASTEXITCODE = 0",
|
|
638
|
+
"try {",
|
|
639
|
+
f" $__gu_text = [System.IO.File]::ReadAllText({_psq(cmd_file)}, [System.Text.Encoding]::UTF8)",
|
|
640
|
+
" . ([ScriptBlock]::Create($__gu_text))",
|
|
641
|
+
" if ($__gu_ok) { $__gu_rc = 0 } elseif ($LASTEXITCODE) { $__gu_rc = $LASTEXITCODE } else { $__gu_rc = 1 }",
|
|
642
|
+
"} catch {",
|
|
643
|
+
" [Console]::Out.Flush()",
|
|
644
|
+
" Write-Output ($_ | Out-String).TrimEnd()",
|
|
645
|
+
" $__gu_rc = 1",
|
|
646
|
+
"} finally {",
|
|
647
|
+
" try { Set-Content -LiteralPath $__gu_exit -Value $__gu_rc -Encoding Ascii } catch {}",
|
|
648
|
+
" try {",
|
|
649
|
+
" $__gu_keep = @{}",
|
|
650
|
+
" if (Test-Path -LiteralPath $__gu_state) {",
|
|
651
|
+
" $__gu_old = Get-Content -LiteralPath $__gu_state -Raw -Encoding UTF8 | ConvertFrom-Json",
|
|
652
|
+
" foreach ($__gu_p in $__gu_old.PSObject.Properties) { $__gu_keep[$__gu_p.Name] = $__gu_p.Value }",
|
|
653
|
+
" }",
|
|
654
|
+
" $__gu_now = @{}",
|
|
655
|
+
" foreach ($__gu_e in Get-ChildItem env:) { $__gu_now[$__gu_e.Name] = $__gu_e.Value }",
|
|
656
|
+
" foreach ($__gu_k in $__gu_now.Keys) { if ($__gu_env0[$__gu_k] -ne $__gu_now[$__gu_k]) { $__gu_keep[$__gu_k] = $__gu_now[$__gu_k] } }",
|
|
657
|
+
" foreach ($__gu_k in $__gu_env0.Keys) { if (-not $__gu_now.ContainsKey($__gu_k)) { $__gu_keep[$__gu_k] = $null } }",
|
|
658
|
+
" $__gu_keep.Remove('PWD') | Out-Null",
|
|
659
|
+
" ConvertTo-Json -InputObject $__gu_keep -Compress | Set-Content -LiteralPath $__gu_state -Encoding UTF8",
|
|
660
|
+
" $__gu_loc = Get-Location",
|
|
661
|
+
" if ($__gu_loc.Provider.Name -eq 'FileSystem') {",
|
|
662
|
+
" Set-Content -LiteralPath ($__gu_state + '.cwd') -Value $__gu_loc.ProviderPath -Encoding UTF8",
|
|
663
|
+
" }",
|
|
664
|
+
" } catch {}",
|
|
665
|
+
"}",
|
|
666
|
+
"exit $__gu_rc",
|
|
667
|
+
"",
|
|
668
|
+
])
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def ps_argv(exe, wrapper_file):
|
|
672
|
+
"""How the wrapper is started: no profile (a user's profile can print, prompt or slow every
|
|
673
|
+
command), no prompts, and this process's execution policy only — never the machine's."""
|
|
674
|
+
return [exe, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
|
|
675
|
+
"-File", wrapper_file]
|