@vincemakes/kiso-code 0.39.2 → 0.40.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.
- package/README.md +1 -1
- package/dist/attachments.d.ts +1 -1
- package/dist/attachments.js +11 -4
- package/dist/builtin.d.ts +12 -0
- package/dist/builtin.js +22 -1
- package/dist/chat.d.ts +31 -3
- package/dist/chat.js +169 -30
- package/dist/config.d.ts +11 -1
- package/dist/config.js +34 -3
- package/dist/dispatch.d.ts +12 -2
- package/dist/dispatch.js +165 -24
- package/dist/floor.d.ts +80 -0
- package/dist/floor.js +652 -0
- package/dist/index.d.ts +21 -2
- package/dist/index.js +463 -46
- package/dist/mode.d.ts +12 -4
- package/dist/mode.js +32 -10
- package/dist/projects.d.ts +106 -0
- package/dist/projects.js +212 -0
- package/dist/protected-shell.d.ts +88 -0
- package/dist/protected-shell.js +406 -0
- package/dist/protected-writes.d.ts +29 -0
- package/dist/protected-writes.js +54 -0
- package/dist/provider-label.d.ts +32 -0
- package/dist/provider-label.js +53 -0
- package/dist/readonly-shell.d.ts +58 -0
- package/dist/readonly-shell.js +590 -0
- package/dist/replay.d.ts +53 -0
- package/dist/replay.js +158 -0
- package/dist/resume-tail.js +4 -1
- package/dist/resume.js +3 -3
- package/dist/retries.d.ts +9 -0
- package/dist/retries.js +16 -0
- package/dist/session-cards.d.ts +33 -4
- package/dist/session-cards.js +44 -3
- package/dist/session-migration.d.ts +104 -0
- package/dist/session-migration.js +319 -0
- package/dist/shell-words.d.ts +134 -0
- package/dist/shell-words.js +628 -0
- package/dist/skill-invoke.d.ts +58 -0
- package/dist/skill-invoke.js +139 -0
- package/dist/state.d.ts +80 -1
- package/dist/state.js +113 -2
- package/dist/trust-ui.js +29 -5
- package/package.json +15 -15
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.40.0 — the one-time move of the legacy `KISO_HOME/sessions` into the
|
|
3
|
+
* per-project folders (see projects.ts). The lead's conditions, in order:
|
|
4
|
+
*
|
|
5
|
+
* 1. the manifest is written FIRST: every session's old folder, new
|
|
6
|
+
* folder, reason and evidence, before any file moves;
|
|
7
|
+
* 2. a session whose lock a live process holds is skipped, and moved on a
|
|
8
|
+
* later start;
|
|
9
|
+
* 3. a recorded workspace places a session; otherwise the paths its tools
|
|
10
|
+
* touched, mapped to their repository on disk, place it when one root
|
|
11
|
+
* has at least 3 mentions and 80% of them; anything else goes to
|
|
12
|
+
* `_unknown`. A delegated child follows its parent;
|
|
13
|
+
* 4. the move is announced once, naming the reverse command.
|
|
14
|
+
*
|
|
15
|
+
* Each session moves trace → sidecar → lock → log (projects.ts), so a crash
|
|
16
|
+
* leaves the log in the legacy folder, where the next start finds it again.
|
|
17
|
+
* Nothing is deleted: files the move does not own (orphan sidecars, the
|
|
18
|
+
* delegation artifacts, whose paths the parents' logs record) stay where
|
|
19
|
+
* they are.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { readProfile } from "@vincemakes/kiso-runtime/internal";
|
|
25
|
+
import { canonicalPath, claimProjectDir, hasSession, LEGACY_SESSIONS_DIR, lockHeldLive, moveSessionFiles, parentOfChild, projectDirFor, PROJECTS_DIR, REVERSED_MARKER, sessionFiles, unknownDir, } from "./projects.js";
|
|
26
|
+
/** The inference's two thresholds (the lead's ruling). */
|
|
27
|
+
export const INFER_MIN_MENTIONS = 3;
|
|
28
|
+
export const INFER_MIN_SHARE = 0.8;
|
|
29
|
+
/** The legacy sessions still to move: non-empty logs, sorted. */
|
|
30
|
+
export function pendingLegacyIds(home) {
|
|
31
|
+
const dir = join(home, LEGACY_SESSIONS_DIR);
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = readdirSync(dir);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
return entries
|
|
40
|
+
.filter((e) => e.endsWith(".jsonl"))
|
|
41
|
+
.map((e) => e.slice(0, -".jsonl".length))
|
|
42
|
+
.filter((id) => hasSession(dir, id))
|
|
43
|
+
.sort();
|
|
44
|
+
}
|
|
45
|
+
/** An absolute path inside a tool's input or output. The leading guard
|
|
46
|
+
* keeps a URL's path (`https://x/y`) and a relative path's tail out. */
|
|
47
|
+
const ABS_PATH = /(?<![\w.:/~-])\/[^\s'"`;|&<>(){}[\],*?$\\]+/g;
|
|
48
|
+
/**
|
|
49
|
+
* The repository a path belongs to: the nearest ancestor holding `.git`.
|
|
50
|
+
* A linked worktree (`.git` is a file naming `<repo>/.git/worktrees/x`)
|
|
51
|
+
* belongs to its main repository. Walks stop at the home directory, and
|
|
52
|
+
* paths inside KISO_HOME belong to no project.
|
|
53
|
+
*/
|
|
54
|
+
export function repoRootOf(path, cache, stops) {
|
|
55
|
+
if (path === stops.kisoHome || path.startsWith(`${stops.kisoHome}/`))
|
|
56
|
+
return null;
|
|
57
|
+
const walked = [];
|
|
58
|
+
let dir = path;
|
|
59
|
+
let root = null;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const known = cache.get(dir);
|
|
62
|
+
if (known !== undefined) {
|
|
63
|
+
root = known;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
walked.push(dir);
|
|
67
|
+
if (dir === "/" || dir === stops.home)
|
|
68
|
+
break;
|
|
69
|
+
const git = join(dir, ".git");
|
|
70
|
+
if (existsSync(git)) {
|
|
71
|
+
root = dir;
|
|
72
|
+
try {
|
|
73
|
+
if (statSync(git).isFile()) {
|
|
74
|
+
const m = /^gitdir:\s*(.+?)\/\.git\/worktrees\/[^/]+\s*$/m.exec(readFileSync(git, "utf8"));
|
|
75
|
+
if (m !== null)
|
|
76
|
+
root = m[1];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// unreadable `.git` file — the directory itself is the root
|
|
81
|
+
}
|
|
82
|
+
// as the disk spells it: a log that wrote `~/desktop/x` and one
|
|
83
|
+
// that wrote `~/Desktop/x` name one repository
|
|
84
|
+
root = canonicalPath(root);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
dir = dirname(dir);
|
|
88
|
+
}
|
|
89
|
+
for (const d of walked)
|
|
90
|
+
cache.set(d, root);
|
|
91
|
+
return root;
|
|
92
|
+
}
|
|
93
|
+
/** The absolute paths one log's tool calls and tool results name. */
|
|
94
|
+
export function toolPaths(logText) {
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const line of logText.split("\n")) {
|
|
97
|
+
if (line === "")
|
|
98
|
+
continue;
|
|
99
|
+
let event;
|
|
100
|
+
try {
|
|
101
|
+
event = JSON.parse(line).event ?? {};
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue; // a torn line
|
|
105
|
+
}
|
|
106
|
+
if (event.type !== "tool_call_end" && event.type !== "tool_result")
|
|
107
|
+
continue;
|
|
108
|
+
const text = JSON.stringify(event.type === "tool_call_end" ? event.input : event.content) ?? "";
|
|
109
|
+
for (const m of text.replaceAll("\\n", "\n").replaceAll("\\t", "\t").matchAll(ABS_PATH))
|
|
110
|
+
out.push(m[0]);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
/** The inference over one log: the dominant repository and its counts. */
|
|
115
|
+
export function inferWorkspace(logText, cache, stops) {
|
|
116
|
+
const counts = new Map();
|
|
117
|
+
for (const path of toolPaths(logText)) {
|
|
118
|
+
const root = repoRootOf(path, cache, stops);
|
|
119
|
+
if (root !== null)
|
|
120
|
+
counts.set(root, (counts.get(root) ?? 0) + 1);
|
|
121
|
+
}
|
|
122
|
+
let best = null;
|
|
123
|
+
let total = 0;
|
|
124
|
+
for (const entry of counts) {
|
|
125
|
+
total += entry[1];
|
|
126
|
+
if (best === null || entry[1] > best[1])
|
|
127
|
+
best = entry;
|
|
128
|
+
}
|
|
129
|
+
if (best === null)
|
|
130
|
+
return null;
|
|
131
|
+
return { root: best[0], mentions: best[1], total, placed: best[1] >= INFER_MIN_MENTIONS && best[1] / total >= INFER_MIN_SHARE };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The plan: where every pending legacy session goes, and why. A pure read
|
|
135
|
+
* of the legacy folder and the repositories on disk — nothing is written,
|
|
136
|
+
* so the owner's dry run is this function over a copy.
|
|
137
|
+
*/
|
|
138
|
+
export function planMigration(home, opts = {}) {
|
|
139
|
+
const legacy = join(home, LEGACY_SESSIONS_DIR);
|
|
140
|
+
const ids = pendingLegacyIds(home);
|
|
141
|
+
const stops = { home: opts.userHome ?? homedir(), kisoHome: home };
|
|
142
|
+
const cache = new Map();
|
|
143
|
+
// an earlier run's decision stands: that run may have moved the sidecar
|
|
144
|
+
// (which holds the recorded workspace) and crashed before the log, and a
|
|
145
|
+
// re-decision from what is left would send the log somewhere else
|
|
146
|
+
const earlier = earlierDecisions(home);
|
|
147
|
+
const decided = new Map();
|
|
148
|
+
const decide = (id) => {
|
|
149
|
+
const done = decided.get(id);
|
|
150
|
+
if (done !== undefined)
|
|
151
|
+
return done;
|
|
152
|
+
let placement;
|
|
153
|
+
const parent = parentOfChild(id);
|
|
154
|
+
const profile = readProfile(legacy, id);
|
|
155
|
+
const recorded = profile.kind === "ok" ? profile.profile.workspace : null;
|
|
156
|
+
const prior = earlier.get(id);
|
|
157
|
+
if (lockHeldLive(legacy, id)) {
|
|
158
|
+
placement = { id, reason: "skipped-live", workspace: null, from: legacy };
|
|
159
|
+
}
|
|
160
|
+
else if (prior !== undefined) {
|
|
161
|
+
placement = { ...prior, from: legacy };
|
|
162
|
+
}
|
|
163
|
+
else if (parent !== null && ids.includes(parent)) {
|
|
164
|
+
// a child follows its parent — unless the parent is open, and then
|
|
165
|
+
// the child waits with it
|
|
166
|
+
const p = decide(parent);
|
|
167
|
+
placement = { id, reason: p.reason, workspace: p.workspace, from: legacy, parent, ...(p.evidence !== undefined ? { evidence: p.evidence } : {}) };
|
|
168
|
+
}
|
|
169
|
+
else if (recorded !== null) {
|
|
170
|
+
placement = { id, reason: "recorded", workspace: canonicalPath(recorded), from: legacy };
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
let text = "";
|
|
174
|
+
try {
|
|
175
|
+
text = readFileSync(join(legacy, `${id}.jsonl`), "utf8");
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// unreadable — no evidence, so no project
|
|
179
|
+
}
|
|
180
|
+
const inferred = inferWorkspace(text, cache, stops);
|
|
181
|
+
placement =
|
|
182
|
+
inferred !== null && inferred.placed
|
|
183
|
+
? { id, reason: "inferred", workspace: inferred.root, from: legacy, evidence: { root: inferred.root, mentions: inferred.mentions, total: inferred.total } }
|
|
184
|
+
: { id, reason: "unknown", workspace: null, from: legacy, ...(inferred !== null ? { evidence: { root: inferred.root, mentions: inferred.mentions, total: inferred.total } } : {}) };
|
|
185
|
+
}
|
|
186
|
+
decided.set(id, placement);
|
|
187
|
+
return placement;
|
|
188
|
+
};
|
|
189
|
+
// the target folders, assigned in one pass so two workspaces that encode
|
|
190
|
+
// alike get distinct folders before either exists on disk
|
|
191
|
+
const taken = new Map();
|
|
192
|
+
const dirOf = new Map();
|
|
193
|
+
const target = (workspace) => {
|
|
194
|
+
if (workspace === null)
|
|
195
|
+
return unknownDir(home);
|
|
196
|
+
const known = dirOf.get(workspace);
|
|
197
|
+
if (known !== undefined)
|
|
198
|
+
return known;
|
|
199
|
+
const dir = projectDirFor(home, workspace, taken);
|
|
200
|
+
taken.set(dir, workspace);
|
|
201
|
+
dirOf.set(workspace, dir);
|
|
202
|
+
return dir;
|
|
203
|
+
};
|
|
204
|
+
const plan = ids.map((id) => {
|
|
205
|
+
const p = decide(id);
|
|
206
|
+
return { ...p, to: p.reason === "skipped-live" ? null : (p.to ?? target(p.workspace)) };
|
|
207
|
+
});
|
|
208
|
+
// children move before their parent: a crash between them leaves the
|
|
209
|
+
// parent — whose log is the one a person resumes — still listed
|
|
210
|
+
return [...plan.filter((p) => p.parent !== undefined), ...plan.filter((p) => p.parent === undefined)];
|
|
211
|
+
}
|
|
212
|
+
/** Every earlier manifest's placements that were not skips, the latest
|
|
213
|
+
* first-come: id → where it was going. */
|
|
214
|
+
function earlierDecisions(home) {
|
|
215
|
+
const out = new Map();
|
|
216
|
+
const dir = join(home, PROJECTS_DIR);
|
|
217
|
+
let names;
|
|
218
|
+
try {
|
|
219
|
+
names = readdirSync(dir).filter((n) => n.startsWith("migration-") && n.endsWith(".json"));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
for (const name of names.sort().reverse()) {
|
|
225
|
+
try {
|
|
226
|
+
const parsed = JSON.parse(readFileSync(join(dir, name), "utf8"));
|
|
227
|
+
for (const e of parsed.entries ?? []) {
|
|
228
|
+
if (e.to === null || e.reason === "skipped-live" || out.has(e.id))
|
|
229
|
+
continue;
|
|
230
|
+
out.set(e.id, e);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// an unreadable manifest decides nothing
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
export function countPlan(plan) {
|
|
240
|
+
const n = (r) => plan.filter((p) => p.reason === r).length;
|
|
241
|
+
return { recorded: n("recorded"), inferred: n("inferred"), unknown: n("unknown"), skippedLive: n("skipped-live") };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Run the plan. The manifest lands before the first move; after the moves
|
|
245
|
+
* it is rewritten with each entry's outcome. Returns null when there was
|
|
246
|
+
* nothing to move (all skipped, or nothing pending).
|
|
247
|
+
*/
|
|
248
|
+
export function runMigration(home, plan, now = new Date()) {
|
|
249
|
+
const movable = plan.filter((p) => p.to !== null);
|
|
250
|
+
if (movable.length === 0)
|
|
251
|
+
return null;
|
|
252
|
+
const root = join(home, PROJECTS_DIR);
|
|
253
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
254
|
+
const manifest = join(root, `migration-${now.toISOString().replace(/[:.]/g, "-")}.json`);
|
|
255
|
+
const write = (entries, completedAt) => {
|
|
256
|
+
const tmp = `${manifest}.${process.pid}.tmp`;
|
|
257
|
+
writeFileSync(tmp, `${JSON.stringify({ version: 1, startedAt: now.toISOString(), completedAt, entries }, null, 1)}\n`, { mode: 0o600 });
|
|
258
|
+
renameSync(tmp, manifest);
|
|
259
|
+
};
|
|
260
|
+
write(plan, null);
|
|
261
|
+
let moved = 0;
|
|
262
|
+
const outcomes = plan.map((p) => {
|
|
263
|
+
if (p.to === null)
|
|
264
|
+
return { ...p, moved: false };
|
|
265
|
+
claimProjectDir(p.to, p.workspace);
|
|
266
|
+
// a session that became open since the plan was made waits too
|
|
267
|
+
if (lockHeldLive(p.from, p.id))
|
|
268
|
+
return { ...p, moved: false, skipped: "opened during the move" };
|
|
269
|
+
moveSessionFiles(p.from, p.to, p.id);
|
|
270
|
+
const ok = hasSession(p.to, p.id) && !existsSync(join(p.from, `${p.id}.jsonl`));
|
|
271
|
+
if (ok)
|
|
272
|
+
moved += 1;
|
|
273
|
+
return { ...p, moved: ok };
|
|
274
|
+
});
|
|
275
|
+
write(outcomes, new Date().toISOString());
|
|
276
|
+
return { manifest, counts: countPlan(plan), moved };
|
|
277
|
+
}
|
|
278
|
+
/** The announcement, once per migration that moved something. */
|
|
279
|
+
export function migrationNotice(result) {
|
|
280
|
+
const c = result.counts;
|
|
281
|
+
const parts = [`recorded ${c.recorded}`, `inferred ${c.inferred}`, `unknown ${c.unknown}`, ...(c.skippedLive > 0 ? [`still open ${c.skippedLive}, moved later`] : [])];
|
|
282
|
+
return `sessions now live in one folder per project — moved ${result.moved} (${parts.join(" · ")}); undo: kiso sessions --reverse-migration ${result.manifest}`;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* `kiso sessions --reverse-migration <manifest>`: every entry the manifest
|
|
286
|
+
* moved goes back to the legacy folder, the log FIRST (so a crash leaves it
|
|
287
|
+
* listed from its old place), and the marker switches the per-project
|
|
288
|
+
* layout off. Sessions created in project folders after the migration are
|
|
289
|
+
* not in the manifest and stay; the count says so.
|
|
290
|
+
*/
|
|
291
|
+
export function reverseMigration(home, manifestPath) {
|
|
292
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
293
|
+
const entries = (parsed.entries ?? []).filter((e) => e.to !== null);
|
|
294
|
+
let restored = 0;
|
|
295
|
+
// parents first, then children: the reverse of the forward order
|
|
296
|
+
for (const e of [...entries.filter((x) => x.parent === undefined), ...entries.filter((x) => x.parent !== undefined)]) {
|
|
297
|
+
if (e.to === null || !existsSync(join(e.to, `${e.id}.jsonl`)))
|
|
298
|
+
continue;
|
|
299
|
+
moveSessionFiles(e.to, e.from, e.id, [...sessionFiles(e.id)].reverse());
|
|
300
|
+
if (hasSession(e.from, e.id))
|
|
301
|
+
restored += 1;
|
|
302
|
+
}
|
|
303
|
+
mkdirSync(join(home, PROJECTS_DIR), { recursive: true, mode: 0o700 });
|
|
304
|
+
writeFileSync(join(home, PROJECTS_DIR, REVERSED_MARKER), `${new Date().toISOString()} ${manifestPath}\n`, { mode: 0o600 });
|
|
305
|
+
let left = 0;
|
|
306
|
+
const root = join(home, PROJECTS_DIR);
|
|
307
|
+
for (const name of readdirSync(root)) {
|
|
308
|
+
const dir = join(root, name);
|
|
309
|
+
try {
|
|
310
|
+
if (!statSync(dir).isDirectory())
|
|
311
|
+
continue;
|
|
312
|
+
left += readdirSync(dir).filter((f) => f.endsWith(".jsonl") && hasSession(dir, f.slice(0, -".jsonl".length))).length;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// unreadable folder — not counted
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { restored, left };
|
|
319
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell command line, READ — never run — for the approval chain
|
|
3
|
+
* (0.40.0: the read-only allow now, the catastrophe floor next).
|
|
4
|
+
*
|
|
5
|
+
* This is not a shell. It is a lexer for the subset of POSIX sh whose
|
|
6
|
+
* meaning can be known WITHOUT running anything, and it refuses the rest:
|
|
7
|
+
* a command line that uses expansion (`$`, backticks, globs, a leading
|
|
8
|
+
* `~`, braces), grouping or subshells, background jobs, heredocs, or a
|
|
9
|
+
* leading variable assignment does not parse, and a caller that cannot
|
|
10
|
+
* parse a line must treat it as unknown. The refusals are the design —
|
|
11
|
+
* every construct accepted here is one whose words are exactly the words
|
|
12
|
+
* the shell will pass to the program.
|
|
13
|
+
*
|
|
14
|
+
* What it produces is a LIST of pipelines joined by `;`, `&&`, `||` (a
|
|
15
|
+
* newline counts as `;`), each pipeline a series of simple commands joined
|
|
16
|
+
* by `|`, each command an argv plus its redirections.
|
|
17
|
+
*/
|
|
18
|
+
export interface Redirect {
|
|
19
|
+
/** The descriptor written before the operator (`2>`), null when none
|
|
20
|
+
* was written, or "&" for `&>` (stdout and stderr together). */
|
|
21
|
+
readonly fd: number | "&" | null;
|
|
22
|
+
readonly op: ">" | ">>" | "<" | ">&";
|
|
23
|
+
readonly target: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SimpleCommand {
|
|
26
|
+
readonly argv: readonly string[];
|
|
27
|
+
readonly redirects: readonly Redirect[];
|
|
28
|
+
}
|
|
29
|
+
export interface Pipeline {
|
|
30
|
+
/** How this pipeline joins the one before it; null for the first. */
|
|
31
|
+
readonly joinedBy: ";" | "&&" | "||" | null;
|
|
32
|
+
readonly stages: readonly SimpleCommand[];
|
|
33
|
+
}
|
|
34
|
+
export type ParseResult = {
|
|
35
|
+
readonly ok: true;
|
|
36
|
+
readonly list: readonly Pipeline[];
|
|
37
|
+
} | {
|
|
38
|
+
readonly ok: false;
|
|
39
|
+
readonly why: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Parse a command line, or say why not. Pure: no filesystem, no
|
|
43
|
+
* environment.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseShell(src: string): ParseResult;
|
|
46
|
+
/**
|
|
47
|
+
* A path argument, resolved the way read_file resolves one: against the
|
|
48
|
+
* directory the command runs in, with symlinks in the deepest EXISTING
|
|
49
|
+
* ancestor followed, and the not-yet-existing tail re-appended. `inside`
|
|
50
|
+
* is whether that canonical path is the workspace root or under it. One
|
|
51
|
+
* resolver for every consumer — the read-only allow asks "inside?", the
|
|
52
|
+
* floor asks "what is this?" — so the two can never disagree about where
|
|
53
|
+
* a path goes.
|
|
54
|
+
*/
|
|
55
|
+
export interface ResolvedPath {
|
|
56
|
+
readonly canonical: string;
|
|
57
|
+
readonly inside: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** The well-known home subtrees that hold credentials or configuration
|
|
60
|
+
* that runs — one list for both chain members: the read-only allow
|
|
61
|
+
* never reads or lists under them, the floor never deletes them. */
|
|
62
|
+
export declare const HOME_SUBTREES: readonly string[];
|
|
63
|
+
/** A real path in the case the DISK holds it. The JS realpath keeps the
|
|
64
|
+
* case it was given, so on a case-insensitive disk `.ENV` stayed `.ENV`
|
|
65
|
+
* and walked past every name-based predicate (the lead's review, B2). */
|
|
66
|
+
export declare function realCase(p: string): string;
|
|
67
|
+
export declare function resolveShellPath(workspaceRoot: string, cwd: string, word: string): ResolvedPath;
|
|
68
|
+
/**
|
|
69
|
+
* The FLOOR's reading of a command line (0.40.0 item 4) — the opposite
|
|
70
|
+
* contract to `parseShell`. The read-only allow must refuse whatever it
|
|
71
|
+
* cannot read exactly; the floor must never refuse to read, because a
|
|
72
|
+
* line it gave up on is a line it let through. So this reader accepts
|
|
73
|
+
* everything, approximately, and marks where each word stops being known:
|
|
74
|
+
*
|
|
75
|
+
* - every simple command the line would run is returned in order,
|
|
76
|
+
* including the ones inside `$( )`, backticks and `( )` groups;
|
|
77
|
+
* - a word records the index where its first unknown character sits (a
|
|
78
|
+
* variable, a substitution, an unquoted glob), whether it began with
|
|
79
|
+
* an unquoted `~`, and whether it is ONLY a variable (`$DIR/`,
|
|
80
|
+
* `"${X}"/*`: nothing literal but slashes, dots and globs);
|
|
81
|
+
* - redirection targets and leading assignments are not arguments, and
|
|
82
|
+
* are dropped.
|
|
83
|
+
*/
|
|
84
|
+
export interface LooseWord {
|
|
85
|
+
readonly text: string;
|
|
86
|
+
/** Index of the first unknown character, or -1 when all is literal. */
|
|
87
|
+
readonly unknownAt: number;
|
|
88
|
+
/** Whether that first unknown is a glob (`*`), not a variable or a
|
|
89
|
+
* substitution: a glob ranges over a directory's entries, a variable
|
|
90
|
+
* can be anything at all. */
|
|
91
|
+
readonly unknownIsGlob: boolean;
|
|
92
|
+
readonly tilde: boolean;
|
|
93
|
+
readonly variableOnly: boolean;
|
|
94
|
+
}
|
|
95
|
+
/** One command, and how it joins the one before it: after `cd x &&` it
|
|
96
|
+
* runs in x, after `cd x ||` in the old directory, after `;` in either. */
|
|
97
|
+
export interface LooseCommand {
|
|
98
|
+
readonly kind: "cmd";
|
|
99
|
+
readonly argv: readonly LooseWord[];
|
|
100
|
+
readonly joinedBy: "&&" | "||" | ";" | "|";
|
|
101
|
+
/** The words the reader drops — redirection targets and leading
|
|
102
|
+
* assignments — kept only when the caller asks (`keepDropped`): the
|
|
103
|
+
* floor judges what a command DOES, the protected-file check what a
|
|
104
|
+
* line NAMES, and `< file` names a file. */
|
|
105
|
+
readonly dropped?: readonly LooseWord[];
|
|
106
|
+
}
|
|
107
|
+
/** A subshell: a `( … )` group, or the command line inside `$( … )` or
|
|
108
|
+
* backticks (which runs BEFORE the command that holds it). A `cd` inside
|
|
109
|
+
* one does not move the shell around it. */
|
|
110
|
+
export interface LooseGroup {
|
|
111
|
+
readonly kind: "group";
|
|
112
|
+
readonly items: readonly LooseNode[];
|
|
113
|
+
readonly joinedBy: "&&" | "||" | ";" | "|";
|
|
114
|
+
}
|
|
115
|
+
export type LooseNode = LooseCommand | LooseGroup;
|
|
116
|
+
/** B6 (the lead's review): how deep the reader follows nested subshells.
|
|
117
|
+
* A 9 KB `$(` nest overflowed the stack; the runtime turned the throw into
|
|
118
|
+
* an ask, bypass's allow won, and /bin/sh ran the line. Past this depth
|
|
119
|
+
* the word is taken as only a variable — which, as a target, is refused —
|
|
120
|
+
* and nothing deeper is read. */
|
|
121
|
+
export declare const LOOSE_MAX_DEPTH = 64;
|
|
122
|
+
/** Every command the line would run, in order, flattened — for a caller
|
|
123
|
+
* that asks the same question of each one. */
|
|
124
|
+
export declare function looseCommands(nodes: readonly LooseNode[]): LooseCommand[];
|
|
125
|
+
export declare function parseShellLoose(src: string): LooseNode[];
|
|
126
|
+
/** The same, saying whether the reader stopped short: a nest past
|
|
127
|
+
* LOOSE_MAX_DEPTH is not read, and a caller that must see every command
|
|
128
|
+
* (the floor) treats an unread line as one it could not read. */
|
|
129
|
+
export declare function parseShellLooseChecked(src: string, opts?: {
|
|
130
|
+
readonly keepDropped?: boolean;
|
|
131
|
+
}): {
|
|
132
|
+
readonly nodes: LooseNode[];
|
|
133
|
+
readonly truncated: boolean;
|
|
134
|
+
};
|