dsh-file-activity 0.5.7 → 0.5.10
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/CHANGELOG.md +17 -161
- package/README.md +23 -67
- package/lib/api-route.js +72 -76
- package/lib/bash-ops.js +65 -54
- package/lib/bash-parse.js +154 -162
- package/lib/client.js +767 -362
- package/lib/client.src.js +28 -21
- package/lib/cwd.js +6 -6
- package/lib/index.js +76 -83
- package/lib/media-route.js +139 -90
- package/lib/observer.js +30 -24
- package/lib/parts/api.part.js +1 -5
- package/lib/parts/apply.part.js +91 -82
- package/lib/parts/auto-open.part.js +83 -60
- package/lib/parts/document-previews.part.js +62 -0
- package/lib/parts/format.part.js +1 -2
- package/lib/parts/i18n.part.js +15 -1
- package/lib/parts/interceptor.part.js +16 -27
- package/lib/parts/preview-data.part.js +111 -0
- package/lib/parts/preview-float.part.js +276 -0
- package/lib/parts/rows.part.js +1 -11
- package/lib/parts/settings.part.js +71 -0
- package/lib/parts/store.part.js +19 -0
- package/lib/parts/styles.part.js +49 -3
- package/lib/parts/tree.part.js +1 -2
- package/lib/parts/view.part.js +73 -19
- package/lib/persist.js +137 -0
- package/lib/quota-trim.js +121 -0
- package/lib/quota.js +164 -0
- package/lib/state.js +333 -136
- package/lib/store.js +262 -76
- package/package.json +11 -7
- package/lib/parts/preview.part.js +0 -308
package/lib/bash-ops.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { homedir } from 'node:os'
|
|
2
|
-
import { isAbsolute, join, normalize, resolve } from 'node:path'
|
|
3
|
-
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
4
3
|
/**
|
|
5
4
|
* bash-ops.js — command → file-operation mapping for the bash intent parser
|
|
6
5
|
* (see bash-parse.js for the segment/token layer).
|
|
@@ -11,89 +10,101 @@ import { isAbsolute, join, normalize, resolve } from 'node:path'
|
|
|
11
10
|
*/
|
|
12
11
|
/** Options of a command that consume the following argument (never a path). */
|
|
13
12
|
function optionArgsOf(cmd) {
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
if (cmd === 'touch')
|
|
14
|
+
return new Set(['-d', '-t', '-r', '--date', '--time', '--reference']);
|
|
15
|
+
return new Set();
|
|
16
16
|
}
|
|
17
|
-
|
|
18
17
|
/**
|
|
19
18
|
* Path arguments with options stripped: `-rf` / `--force` / `--` are skipped;
|
|
20
19
|
* options listed in `optionArgs` consume their following argument too.
|
|
20
|
+
*
|
|
21
|
+
* `optionArgs` 可选:`cd` 的目标解析(bash-parse.js)只取第一个位置参数,
|
|
22
|
+
* 不传选项表——与迁移前逐字节一致(该路径遇到 `-` 开头的 token 仍会抛错)。
|
|
21
23
|
*/
|
|
22
24
|
export function positionalArgs(args, optionArgs) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
25
|
+
const paths = [];
|
|
26
|
+
let afterDashDash = false;
|
|
27
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
28
|
+
const token = args[i];
|
|
29
|
+
if (!afterDashDash && token.text === '--') {
|
|
30
|
+
afterDashDash = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!afterDashDash && token.text.startsWith('-') && token.text !== '-') {
|
|
34
|
+
if (optionArgs.has(token.text))
|
|
35
|
+
i += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
paths.push(token);
|
|
34
39
|
}
|
|
35
|
-
paths
|
|
36
|
-
}
|
|
37
|
-
return paths
|
|
40
|
+
return paths;
|
|
38
41
|
}
|
|
39
|
-
|
|
40
42
|
/**
|
|
41
43
|
* -t / --target-directory variants (cp/mv/install) relocate sources into a
|
|
42
44
|
* directory; statically resolving the real destination is unreliable, so the
|
|
43
45
|
* whole segment is skipped (conservative: never record a wrong path).
|
|
44
46
|
*/
|
|
45
47
|
function hasTargetDirOption(cmd, args) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
if (cmd !== 'mv' && cmd !== 'cp' && cmd !== 'install')
|
|
49
|
+
return false;
|
|
50
|
+
return args.some((token) => token.text === '-t' || token.text === '--target-directory');
|
|
48
51
|
}
|
|
49
|
-
|
|
50
52
|
/** Dispatch one segment's command to the matching file-op mapping. */
|
|
51
53
|
export function pushCommandOps(ops, cmd, args, cwd) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
if (hasTargetDirOption(cmd, args))
|
|
55
|
+
return;
|
|
56
|
+
const optionArgs = optionArgsOf(cmd);
|
|
57
|
+
const paths = positionalArgs(args, optionArgs);
|
|
58
|
+
if (cmd === 'rm')
|
|
59
|
+
return pushAll(ops, 'delete', paths, cwd);
|
|
60
|
+
if (cmd === 'touch' || cmd === 'tee')
|
|
61
|
+
return pushAll(ops, 'write', paths, cwd);
|
|
62
|
+
if (cmd === 'mv')
|
|
63
|
+
return pushMv(ops, paths, cwd);
|
|
64
|
+
if (cmd === 'cp' || cmd === 'install')
|
|
65
|
+
return pushLast(ops, paths, cwd);
|
|
66
|
+
if (cmd === 'sed' && hasInPlace(args))
|
|
67
|
+
pushLast(ops, positionalArgs(args, optionArgs), cwd);
|
|
60
68
|
}
|
|
61
|
-
|
|
62
69
|
/** All positional paths get the same op (rm / touch / tee). */
|
|
63
70
|
function pushAll(ops, op, paths, cwd) {
|
|
64
|
-
|
|
71
|
+
for (const token of paths)
|
|
72
|
+
pushOp(ops, op, token, cwd);
|
|
65
73
|
}
|
|
66
|
-
|
|
67
74
|
/** Only the last path is the write destination (cp / install). */
|
|
68
75
|
function pushLast(ops, paths, cwd) {
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
if (paths.length === 0)
|
|
77
|
+
return;
|
|
78
|
+
pushOp(ops, 'write', paths[paths.length - 1], cwd);
|
|
71
79
|
}
|
|
72
|
-
|
|
73
80
|
/** mv: all but the last are sources (delete), the last is the destination. */
|
|
74
81
|
function pushMv(ops, paths, cwd) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
if (paths.length === 0)
|
|
83
|
+
return;
|
|
84
|
+
for (let i = 0; i < paths.length - 1; i += 1)
|
|
85
|
+
pushOp(ops, 'delete', paths[i], cwd);
|
|
86
|
+
pushOp(ops, 'write', paths[paths.length - 1], cwd);
|
|
78
87
|
}
|
|
79
|
-
|
|
80
88
|
/** sed writes files only with -i / --in-place (also -i.bak style). */
|
|
81
89
|
function hasInPlace(args) {
|
|
82
|
-
|
|
90
|
+
return args.some((token) => /^-i($|[A-Za-z0-9_.])/.test(token.text) || token.text === '--in-place');
|
|
83
91
|
}
|
|
84
|
-
|
|
85
92
|
export function pushOp(ops, op, token, cwd) {
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
const path = resolveSafe(token, cwd);
|
|
94
|
+
if (path !== '')
|
|
95
|
+
ops.push({ op, path });
|
|
88
96
|
}
|
|
89
|
-
|
|
90
97
|
/** Resolve a token to an absolute path; '' when unsafe or unusable. */
|
|
91
98
|
export function resolveSafe(token, cwd) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
if (!token.safe || token.text === '')
|
|
100
|
+
return '';
|
|
101
|
+
let path = token.text;
|
|
102
|
+
if (path === '~')
|
|
103
|
+
return homedir();
|
|
104
|
+
if (path.startsWith('~/'))
|
|
105
|
+
path = join(homedir(), path.slice(2));
|
|
106
|
+
if (path.includes('\0'))
|
|
107
|
+
return '';
|
|
108
|
+
const abs = isAbsolute(path) ? normalize(path) : resolve(cwd, path);
|
|
109
|
+
return normalize(abs);
|
|
99
110
|
}
|
package/lib/bash-parse.js
CHANGED
|
@@ -18,34 +18,32 @@
|
|
|
18
18
|
* ('write' is classified create/modify by the known-file registry, 'delete'
|
|
19
19
|
* removes the file from stats), so the view stays consistent with the disk.
|
|
20
20
|
*/
|
|
21
|
-
import { pushCommandOps, positionalArgs, resolveSafe, pushOp } from './bash-ops.js'
|
|
22
|
-
|
|
21
|
+
import { pushCommandOps, positionalArgs, resolveSafe, pushOp } from './bash-ops.js';
|
|
23
22
|
/** Prefix wrappers that do not touch files themselves. */
|
|
24
|
-
const PREFIX_CMDS = new Set(['sudo', 'nohup', 'command', 'time', 'env'])
|
|
25
|
-
|
|
23
|
+
const PREFIX_CMDS = new Set(['sudo', 'nohup', 'command', 'time', 'env']);
|
|
26
24
|
/** Parse one bash command into [{ op: 'write'|'delete', path }] (deduped). */
|
|
27
25
|
export function parseBashFileOps(command, baseDir) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
26
|
+
const ops = [];
|
|
27
|
+
let cwd = baseDir;
|
|
28
|
+
for (const segment of splitSegments(command)) {
|
|
29
|
+
const tokens = tokenize(segment);
|
|
30
|
+
const start = commandStartOf(tokens);
|
|
31
|
+
if (start < 0)
|
|
32
|
+
continue;
|
|
33
|
+
const cmd = basenameOf(tokens[start].text);
|
|
34
|
+
const args = tokens.slice(start + 1);
|
|
35
|
+
if (cmd === 'cd') {
|
|
36
|
+
const target = cdTargetOf(args);
|
|
37
|
+
if (target !== '')
|
|
38
|
+
cwd = resolveSafe({ text: target, safe: true }, cwd);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
pushCommandOps(ops, cmd, args, cwd);
|
|
42
|
+
pushRedirectOps(ops, tokens, cwd);
|
|
40
43
|
}
|
|
41
|
-
|
|
42
|
-
pushRedirectOps(ops, tokens, cwd)
|
|
43
|
-
}
|
|
44
|
-
return dedupeOps(ops)
|
|
44
|
+
return dedupeOps(ops);
|
|
45
45
|
}
|
|
46
|
-
|
|
47
46
|
// ── quoting pre-pass ───────────────────────────────────────────────────────
|
|
48
|
-
|
|
49
47
|
/**
|
|
50
48
|
* Per-char quote state: 0 = unquoted, 1 = single-quoted, 2 = double-quoted,
|
|
51
49
|
* -1 = the quote character itself (kept in segment text, stripped by
|
|
@@ -54,209 +52,203 @@ export function parseBashFileOps(command, baseDir) {
|
|
|
54
52
|
* literal (bash). The output array has one entry per input character.
|
|
55
53
|
*/
|
|
56
54
|
function quoteMarks(text) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
55
|
+
const marks = [];
|
|
56
|
+
let quote = 0;
|
|
57
|
+
let i = 0;
|
|
58
|
+
while (i < text.length) {
|
|
59
|
+
const step = quoteStep(text, i, quote);
|
|
60
|
+
marks.push(...step.marks);
|
|
61
|
+
quote = step.quote;
|
|
62
|
+
i += step.advance;
|
|
63
|
+
}
|
|
64
|
+
return marks;
|
|
67
65
|
}
|
|
68
|
-
|
|
69
66
|
/** One character's quote transition: { marks, quote, advance }. */
|
|
70
67
|
function quoteStep(text, i, quote) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
const ch = text[i];
|
|
69
|
+
if (ch === '\\' && quote !== 1 && i + 1 < text.length)
|
|
70
|
+
return { marks: [quote, quote], quote, advance: 2 };
|
|
71
|
+
if (quote !== 0 && ch === quoteCharOf(quote))
|
|
72
|
+
return { marks: [-1], quote: 0, advance: 1 };
|
|
73
|
+
if (quote === 0 && isQuoteChar(ch))
|
|
74
|
+
return { marks: [-1], quote: quoteOf(ch), advance: 1 };
|
|
75
|
+
return { marks: [quote], quote, advance: 1 };
|
|
76
76
|
}
|
|
77
|
-
|
|
78
77
|
function quoteCharOf(quote) {
|
|
79
|
-
|
|
78
|
+
return quote === 1 ? "'" : '"';
|
|
80
79
|
}
|
|
81
|
-
|
|
82
80
|
function quoteOf(ch) {
|
|
83
|
-
|
|
81
|
+
return ch === "'" ? 1 : 2;
|
|
84
82
|
}
|
|
85
|
-
|
|
86
83
|
function isQuoteChar(ch) {
|
|
87
|
-
|
|
84
|
+
return ch === "'" || ch === '"';
|
|
88
85
|
}
|
|
89
|
-
|
|
90
86
|
// ── segment splitting ──────────────────────────────────────────────────────
|
|
91
|
-
|
|
92
87
|
/**
|
|
93
88
|
* Split a command on the top-level separators && || ; | & and newlines.
|
|
94
89
|
* Quoted separators never split; a bare `&` (background marker) splits only
|
|
95
90
|
* when followed by whitespace/end — `2>&1` stays one word.
|
|
96
91
|
*/
|
|
97
92
|
export function splitSegments(command) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
93
|
+
const marks = quoteMarks(command);
|
|
94
|
+
const segments = [];
|
|
95
|
+
let current = '';
|
|
96
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
97
|
+
if (marks[i] !== 0 || !isSeparator(command, i)) {
|
|
98
|
+
current += command[i];
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
flushSegment(segments, current);
|
|
102
|
+
current = '';
|
|
103
|
+
i += separatorLength(command, i) - 1;
|
|
105
104
|
}
|
|
106
|
-
flushSegment(segments, current)
|
|
107
|
-
|
|
108
|
-
i += separatorLength(command, i) - 1
|
|
109
|
-
}
|
|
110
|
-
flushSegment(segments, current)
|
|
111
|
-
return segments
|
|
105
|
+
flushSegment(segments, current);
|
|
106
|
+
return segments;
|
|
112
107
|
}
|
|
113
|
-
|
|
114
108
|
function isSeparator(command, i) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
109
|
+
const ch = command[i];
|
|
110
|
+
if (ch === '&')
|
|
111
|
+
return command[i + 1] === '&' || i + 1 >= command.length || /\s/.test(command[i + 1]);
|
|
112
|
+
return ch === '|' || ch === ';' || ch === '\n' || ch === '\r';
|
|
118
113
|
}
|
|
119
|
-
|
|
120
114
|
function separatorLength(command, i) {
|
|
121
|
-
|
|
115
|
+
return command[i] === '&' && command[i + 1] === '&' ? 2 : 1;
|
|
122
116
|
}
|
|
123
|
-
|
|
124
117
|
function flushSegment(segments, current) {
|
|
125
|
-
|
|
126
|
-
|
|
118
|
+
const trimmed = current.trim();
|
|
119
|
+
if (trimmed !== '')
|
|
120
|
+
segments.push(trimmed);
|
|
127
121
|
}
|
|
128
|
-
|
|
129
122
|
// ── tokenizing ─────────────────────────────────────────────────────────────
|
|
130
|
-
|
|
131
123
|
/**
|
|
132
124
|
* Split one segment into words. Tokens keep their quoted content (quotes
|
|
133
125
|
* stripped) and a `safe` flag: false when the word contains variables /
|
|
134
126
|
* command substitution / globs — such paths must never be recorded.
|
|
135
127
|
*/
|
|
136
128
|
export function tokenize(segment) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
129
|
+
const marks = quoteMarks(segment);
|
|
130
|
+
const tokens = [];
|
|
131
|
+
let current = '';
|
|
132
|
+
let safe = true;
|
|
133
|
+
const push = () => {
|
|
134
|
+
if (current !== '') {
|
|
135
|
+
tokens.push({ text: current, safe });
|
|
136
|
+
current = '';
|
|
137
|
+
safe = true;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
141
|
+
const ch = segment[i];
|
|
142
|
+
if (marks[i] === -1)
|
|
143
|
+
continue; // quote characters are stripped
|
|
144
|
+
if (marks[i] === 0 && /\s/.test(ch)) {
|
|
145
|
+
push();
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (isEscape(segment, i, marks[i])) {
|
|
149
|
+
current += segment[i + 1];
|
|
150
|
+
i += 1;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (isUnsafeChar(ch, marks[i])) {
|
|
154
|
+
safe = false;
|
|
155
|
+
current += ch;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
current += ch;
|
|
159
159
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
current += ch
|
|
163
|
-
continue
|
|
164
|
-
}
|
|
165
|
-
current += ch
|
|
166
|
-
}
|
|
167
|
-
push()
|
|
168
|
-
return tokens
|
|
160
|
+
push();
|
|
161
|
+
return tokens;
|
|
169
162
|
}
|
|
170
|
-
|
|
171
163
|
/** Backslash escapes the next char outside quotes and inside double quotes. */
|
|
172
164
|
function isEscape(segment, i, quote) {
|
|
173
|
-
|
|
174
|
-
|
|
165
|
+
if (segment[i] !== '\\' || quote === 1)
|
|
166
|
+
return false;
|
|
167
|
+
return i + 1 < segment.length;
|
|
175
168
|
}
|
|
176
|
-
|
|
177
169
|
/** `$` expands everywhere except single quotes; globs only outside quotes. */
|
|
178
170
|
function isUnsafeChar(ch, quote) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
171
|
+
if (ch === '$')
|
|
172
|
+
return quote !== 1;
|
|
173
|
+
if (quote !== 0)
|
|
174
|
+
return false;
|
|
175
|
+
return ch === '`' || ch === '*' || ch === '?' || ch === '[' || ch === '{';
|
|
182
176
|
}
|
|
183
|
-
|
|
184
177
|
// ── command head ───────────────────────────────────────────────────────────
|
|
185
|
-
|
|
186
178
|
/** Index of the first real command token (skipping VAR=x and wrappers). */
|
|
187
179
|
function findStartOf(tokens, index) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
180
|
+
while (index < tokens.length) {
|
|
181
|
+
const token = tokens[index];
|
|
182
|
+
if (isAssignment(token.text) || PREFIX_CMDS.has(basenameOf(token.text))) {
|
|
183
|
+
index += 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
return index;
|
|
193
187
|
}
|
|
194
|
-
return
|
|
195
|
-
}
|
|
196
|
-
return -1
|
|
188
|
+
return -1;
|
|
197
189
|
}
|
|
198
|
-
|
|
199
190
|
function commandStartOf(tokens) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
191
|
+
const start = findStartOf(tokens, 0);
|
|
192
|
+
if (start < 0)
|
|
193
|
+
return -1;
|
|
194
|
+
if (basenameOf(tokens[start].text) === 'env') {
|
|
195
|
+
// env VAR=value cmd: assignments after env are skipped too.
|
|
196
|
+
return findStartOf(tokens, start + 1);
|
|
197
|
+
}
|
|
198
|
+
return start;
|
|
207
199
|
}
|
|
208
|
-
|
|
209
200
|
function isAssignment(text) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
201
|
+
const eq = text.indexOf('=');
|
|
202
|
+
if (eq <= 0)
|
|
203
|
+
return false;
|
|
204
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(text);
|
|
213
205
|
}
|
|
214
|
-
|
|
215
206
|
function basenameOf(text) {
|
|
216
|
-
|
|
217
|
-
|
|
207
|
+
const slash = text.lastIndexOf('/');
|
|
208
|
+
return slash === -1 ? text : text.slice(slash + 1);
|
|
218
209
|
}
|
|
219
|
-
|
|
220
210
|
// ── paths & redirects ──────────────────────────────────────────────────────
|
|
221
|
-
|
|
222
211
|
/** `> file` / `>> file` / `2> file` redirects write the target file. */
|
|
223
212
|
function pushRedirectOps(ops, tokens, cwd) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
213
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
214
|
+
const match = /^(\d*)>{1,2}(.*)$/.exec(tokens[i].text);
|
|
215
|
+
if (match === null)
|
|
216
|
+
continue;
|
|
217
|
+
const target = redirectTarget(tokens, i, match[2]);
|
|
218
|
+
if (target === null)
|
|
219
|
+
continue;
|
|
220
|
+
pushOp(ops, 'write', { text: target.text, safe: target.safe }, cwd);
|
|
221
|
+
}
|
|
231
222
|
}
|
|
232
|
-
|
|
233
223
|
/** The file a redirect writes; null when it is an fd / /dev/null / missing. */
|
|
234
224
|
function redirectTarget(tokens, i, inline) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
225
|
+
if (inline !== '')
|
|
226
|
+
return usable(inline, tokens[i].safe) ? { text: inline, safe: tokens[i].safe } : null;
|
|
227
|
+
const next = tokens[i + 1];
|
|
228
|
+
if (next === undefined || !next.safe)
|
|
229
|
+
return null;
|
|
230
|
+
return usable(next.text, true) ? { text: next.text, safe: true } : null;
|
|
239
231
|
}
|
|
240
|
-
|
|
241
232
|
function usable(text, safe) {
|
|
242
|
-
|
|
243
|
-
|
|
233
|
+
if (!safe || text === '')
|
|
234
|
+
return false;
|
|
235
|
+
return !text.startsWith('&') && !text.startsWith('/dev/');
|
|
244
236
|
}
|
|
245
|
-
|
|
246
237
|
/** `cd` target: the first positional argument ('' when missing/unsafe). */
|
|
247
238
|
function cdTargetOf(args) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
239
|
+
const paths = positionalArgs(args);
|
|
240
|
+
if (paths.length === 0)
|
|
241
|
+
return '';
|
|
242
|
+
return paths[0].safe ? paths[0].text : '';
|
|
251
243
|
}
|
|
252
|
-
|
|
253
244
|
/** Dedupe by op+path, keeping the first occurrence. */
|
|
254
245
|
function dedupeOps(ops) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
246
|
+
const seen = new Set();
|
|
247
|
+
return ops.filter((op) => {
|
|
248
|
+
const key = `${op.op}\u0000${op.path}`;
|
|
249
|
+
if (seen.has(key))
|
|
250
|
+
return false;
|
|
251
|
+
seen.add(key);
|
|
252
|
+
return true;
|
|
253
|
+
});
|
|
262
254
|
}
|