@liumir/lmcode 0.5.13
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/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/app-BrCDSMM8.mjs +139435 -0
- package/dist/assets/tokenizers.win32-x64-msvc-7FuPBfvC.node +0 -0
- package/dist/chunk-apG1qJts.mjs +41 -0
- package/dist/dist-0bMQWc-B.mjs +1258 -0
- package/dist/esm-CmfJNv9s.mjs +8590 -0
- package/dist/from-CKE2n10i.mjs +3849 -0
- package/dist/main.d.mts +2 -0
- package/dist/main.mjs +15 -0
- package/dist/multipart-parser-BxHsVgPe.mjs +299 -0
- package/dist/src-BMbLXrAA.mjs +1182 -0
- package/dist/suppress-sqlite-warning-C2VB0doZ.mjs +52 -0
- package/icon.ico +0 -0
- package/package.json +80 -0
- package/scripts/postinstall/migrate.mjs +351 -0
- package/scripts/postinstall/reach.mjs +457 -0
- package/scripts/postinstall/shortcut.mjs +74 -0
- package/scripts/postinstall/ui.mjs +458 -0
- package/scripts/postinstall.mjs +276 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath as __cjsShimFileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname as __cjsShimDirname } from 'node:path';
|
|
4
|
+
const __filename = __cjsShimFileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = __cjsShimDirname(__filename);
|
|
6
|
+
//#region src/utils/suppress-sqlite-warning.ts
|
|
7
|
+
/**
|
|
8
|
+
* Suppress Node's ExperimentalWarning for the built-in `node:sqlite` module.
|
|
9
|
+
*
|
|
10
|
+
* Node emits this warning while `node:sqlite` is loaded as a transitive
|
|
11
|
+
* dependency during ESM module evaluation, before any app code can intercept
|
|
12
|
+
* it via `process.emitWarning` or `process.on('warning')`. We filter the known
|
|
13
|
+
* warning text from stderr to keep startup output clean.
|
|
14
|
+
*/
|
|
15
|
+
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
16
|
+
let expectingTraceSuggestion = false;
|
|
17
|
+
function isSQLiteExperimentalWarning(line) {
|
|
18
|
+
return /^\(node:\d+\) ExperimentalWarning: SQLite is an experimental feature/.test(line);
|
|
19
|
+
}
|
|
20
|
+
function isTraceSuggestion(line) {
|
|
21
|
+
return line.startsWith("(Use `node --trace-warnings");
|
|
22
|
+
}
|
|
23
|
+
process.stderr.write = (chunk, ...args) => {
|
|
24
|
+
const lines = (typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")).split("\n");
|
|
25
|
+
let changed = false;
|
|
26
|
+
const kept = [];
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (isSQLiteExperimentalWarning(line)) {
|
|
29
|
+
expectingTraceSuggestion = true;
|
|
30
|
+
changed = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (expectingTraceSuggestion && isTraceSuggestion(line)) {
|
|
34
|
+
expectingTraceSuggestion = false;
|
|
35
|
+
changed = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
expectingTraceSuggestion = false;
|
|
39
|
+
kept.push(line);
|
|
40
|
+
}
|
|
41
|
+
if (changed) {
|
|
42
|
+
const filtered = kept.join("\n");
|
|
43
|
+
if (filtered.length === 0) {
|
|
44
|
+
args.find((a) => typeof a === "function")?.();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return originalWrite(filtered, ...args);
|
|
48
|
+
}
|
|
49
|
+
return originalWrite(chunk, ...args);
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export {};
|
package/icon.ico
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@liumir/lmcode",
|
|
3
|
+
"version": "0.5.13",
|
|
4
|
+
"description": "A terminal-native AI agent for builders",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "liumir",
|
|
7
|
+
"homepage": "https://github.com/Lyin01/LMcode-cli/tree/main/apps/lmcode#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Lyin01/LMcode-cli.git",
|
|
11
|
+
"directory": "apps/lmcode"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Lyin01/LMcode-cli/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"lmcode",
|
|
18
|
+
"cli",
|
|
19
|
+
"agent",
|
|
20
|
+
"coding-agent",
|
|
21
|
+
"ai",
|
|
22
|
+
"tui"
|
|
23
|
+
],
|
|
24
|
+
"bin": {
|
|
25
|
+
"lm": "dist/main.mjs"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"icon.ico",
|
|
30
|
+
"scripts/postinstall.mjs",
|
|
31
|
+
"scripts/postinstall"
|
|
32
|
+
],
|
|
33
|
+
"type": "module",
|
|
34
|
+
"imports": {
|
|
35
|
+
"#/*": [
|
|
36
|
+
"./src/*.ts",
|
|
37
|
+
"./src/*/index.ts"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsdown",
|
|
45
|
+
"dev": "node scripts/dev.mjs",
|
|
46
|
+
"dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
|
|
47
|
+
"dev:prod": "node dist/main.mjs",
|
|
48
|
+
"clean": "rm -rf dist",
|
|
49
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
50
|
+
"test": "pnpm -w run build:packages && vitest run",
|
|
51
|
+
"e2e": "pnpm -w run build:packages && LMCODE_E2E=1 vitest run test/e2e",
|
|
52
|
+
"e2e:real": "pnpm -w run build:packages && LMCODE_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts",
|
|
53
|
+
"preinstall": "node -e \"console.log('\\n📦 正在安装 lmcode,请稍候...\\n')\"",
|
|
54
|
+
"postinstall": "node scripts/postinstall.mjs",
|
|
55
|
+
"smoke": "node dist/main.mjs --version"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@earendil-works/pi-tui": "^0.78.1",
|
|
59
|
+
"@mariozechner/clipboard": "^0.3.2",
|
|
60
|
+
"chalk": "^5.4.1",
|
|
61
|
+
"cli-highlight": "^2.1.11",
|
|
62
|
+
"commander": "^13.1.0",
|
|
63
|
+
"semver": "^7.7.4",
|
|
64
|
+
"smol-toml": "^1.6.1",
|
|
65
|
+
"zod": "^4.3.6"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
69
|
+
"@lmcode-cli/agent-core": "workspace:^",
|
|
70
|
+
"@lmcode-cli/config": "workspace:^",
|
|
71
|
+
"@lmcode-cli/migration-legacy": "workspace:^",
|
|
72
|
+
"@lmcode-cli/lmcode-sdk": "workspace:^",
|
|
73
|
+
"@lmcode/memory": "workspace:*",
|
|
74
|
+
"@types/semver": "^7.7.0",
|
|
75
|
+
"tsx": "^4.21.0"
|
|
76
|
+
},
|
|
77
|
+
"engines": {
|
|
78
|
+
"node": ">=22.19.0"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection of the previous Python `scream-cli` shim and the actual
|
|
3
|
+
* filesystem operations that perform (or refuse) the rename.
|
|
4
|
+
*
|
|
5
|
+
* Detection:
|
|
6
|
+
* - {@link detectLegacyShims}: walks the caller-supplied
|
|
7
|
+
* `pathString` and returns every legacy `lm` shim along the
|
|
8
|
+
* way (in PATH order). A shim qualifies when it realpath-resolves
|
|
9
|
+
* outside our own installed package root and its head 4 KiB
|
|
10
|
+
* contains the `scream_cli` module marker — the setuptools
|
|
11
|
+
* entry-point format produced by `uv tool install`,
|
|
12
|
+
* `pipx install`, `pip install`, etc. Returning all hits (not
|
|
13
|
+
* just the first) matters because a user with both uv- and
|
|
14
|
+
* pipx-installed `scream-cli` has two legacy shims in different
|
|
15
|
+
* dirs, and renaming only the earlier one leaves the later one
|
|
16
|
+
* shadowing our new CLI. Callers should pass the `detection`
|
|
17
|
+
* field from `postinstallPaths()` so detection sees the union of
|
|
18
|
+
* the shell PATH and the installer's PATH.
|
|
19
|
+
* - {@link isLegacyShim}: same criterion in standalone form, used to
|
|
20
|
+
* decide whether an existing `scream-legacy` is itself a legacy CLI
|
|
21
|
+
* (safe to consolidate over) or a user-managed file (preserve).
|
|
22
|
+
*
|
|
23
|
+
* Classify + execute (two-phase):
|
|
24
|
+
* - {@link classifyShim}: pre-flight inspection that says what we
|
|
25
|
+
* COULD do to a given shim. Returns one of `renameable`,
|
|
26
|
+
* `consolidate`, `delete-only`, `blocked`. No filesystem writes.
|
|
27
|
+
* - {@link renameInPlace} / {@link deleteShim}: the primitive
|
|
28
|
+
* operations that actually mutate the filesystem. Run after the
|
|
29
|
+
* orchestrator has looked at the full set of classifications and
|
|
30
|
+
* decided to proceed.
|
|
31
|
+
*
|
|
32
|
+
* Splitting classification from execution lets the orchestrator make
|
|
33
|
+
* the abort-or-proceed decision once, against the whole detected set,
|
|
34
|
+
* rather than discovering mid-loop that something failed and ending up
|
|
35
|
+
* with a misleading "scream now launches the new CLI" notice in front of
|
|
36
|
+
* a "permission denied" notice. Uses `fs.lstat` (not `fs.access`) to
|
|
37
|
+
* detect dangling symlinks at the target so we don't clobber them.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { constants as fsConstants, promises as fs } from 'node:fs';
|
|
41
|
+
import { delimiter, dirname, extname, join, sep } from 'node:path';
|
|
42
|
+
|
|
43
|
+
const LEGACY_BIN = 'scream';
|
|
44
|
+
const LEGACY_RENAME = 'scream-legacy';
|
|
45
|
+
const PYTHON_MARKER = 'scream_cli';
|
|
46
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
47
|
+
|
|
48
|
+
// Read window for the marker sniff.
|
|
49
|
+
// POSIX: setuptools entry-point scripts are a few hundred bytes —
|
|
50
|
+
// 4 KiB is generous.
|
|
51
|
+
// Windows: `uv tool install` produces a Rust-built launcher .exe
|
|
52
|
+
// (~45 KiB) and the `scream_cli` module name sits embedded
|
|
53
|
+
// near the END of the file (verified offset ≈ 44103 on
|
|
54
|
+
// uv 0.11). Cap reads at 256 KiB so a hostile or
|
|
55
|
+
// unexpectedly large file can't make us hold a lot of
|
|
56
|
+
// memory.
|
|
57
|
+
const SHIM_SNIFF_BYTES_POSIX = 4096;
|
|
58
|
+
const SHIM_SNIFF_BYTES_WINDOWS_MAX = 256 * 1024;
|
|
59
|
+
|
|
60
|
+
function pathEntries(pathString) {
|
|
61
|
+
if (!pathString) return [];
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const entry of pathString.split(delimiter)) {
|
|
65
|
+
if (!entry || seen.has(entry)) continue;
|
|
66
|
+
seen.add(entry);
|
|
67
|
+
out.push(entry);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Expand `lm` into the set of filenames that resolve as executables
|
|
74
|
+
* on this platform. POSIX → just `[,]`. Windows → adds every
|
|
75
|
+
* `PATHEXT` extension (so we find `scream.exe`, `scream.cmd`, etc).
|
|
76
|
+
*/
|
|
77
|
+
function executableCandidates(basename) {
|
|
78
|
+
if (!IS_WINDOWS) return [basename];
|
|
79
|
+
const pathext = (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM')
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.split(';')
|
|
82
|
+
.map((e) => e.trim())
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
return [basename, ...pathext.map((ext) => basename + ext)];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function isExecutableFile(filePath) {
|
|
88
|
+
try {
|
|
89
|
+
const info = await fs.stat(filePath);
|
|
90
|
+
if (!info.isFile()) return false;
|
|
91
|
+
// Windows: stat().mode doesn't reflect ACLs in any useful way.
|
|
92
|
+
// Callers already restrict to PATHEXT candidates, so existence
|
|
93
|
+
// suffices.
|
|
94
|
+
if (IS_WINDOWS) return true;
|
|
95
|
+
return (info.mode & 0o111) !== 0;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function readShimHead(filePath) {
|
|
102
|
+
let handle;
|
|
103
|
+
try {
|
|
104
|
+
handle = await fs.open(filePath, 'r');
|
|
105
|
+
const stat = await handle.stat();
|
|
106
|
+
const limit = IS_WINDOWS ? SHIM_SNIFF_BYTES_WINDOWS_MAX : SHIM_SNIFF_BYTES_POSIX;
|
|
107
|
+
const target = Math.min(stat.size, limit);
|
|
108
|
+
const buffer = Buffer.alloc(target);
|
|
109
|
+
const { bytesRead } = await handle.read(buffer, 0, target, 0);
|
|
110
|
+
// `latin1` is a 1-to-1 byte→char mapping; we're searching for an
|
|
111
|
+
// ASCII substring, so we don't want UTF-8 decoding to mangle the
|
|
112
|
+
// bytes around the marker.
|
|
113
|
+
return buffer.subarray(0, bytesRead).toString('latin1');
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
} finally {
|
|
117
|
+
if (handle) await handle.close().catch(() => {});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Walk `pathString` and return every legacy `lm` shim along the
|
|
123
|
+
* way, in PATH order. The orchestrator renames each in turn — a
|
|
124
|
+
* single rename is insufficient when the user has multiple legacy
|
|
125
|
+
* installs (e.g. one from `uv tool install` and another from `pipx
|
|
126
|
+
* install`) in different directories, because the survivor still
|
|
127
|
+
* shadows the new CLI.
|
|
128
|
+
*
|
|
129
|
+
* Each entry has the same shape as the previous single-return
|
|
130
|
+
* value: `{ shimPath, realPath }`. The empty array means
|
|
131
|
+
* "fresh-install / no-op".
|
|
132
|
+
*/
|
|
133
|
+
export async function detectLegacyShims(ownRoot, pathString) {
|
|
134
|
+
const ownRootPrefix = ownRoot ? ownRoot + sep : null;
|
|
135
|
+
const candidates = executableCandidates(LEGACY_BIN);
|
|
136
|
+
const results = [];
|
|
137
|
+
const seenShims = new Set();
|
|
138
|
+
|
|
139
|
+
for (const dir of pathEntries(pathString)) {
|
|
140
|
+
for (const name of candidates) {
|
|
141
|
+
const shimPath = join(dir, name);
|
|
142
|
+
if (seenShims.has(shimPath)) continue;
|
|
143
|
+
if (!(await isExecutableFile(shimPath))) continue;
|
|
144
|
+
|
|
145
|
+
let realPath;
|
|
146
|
+
try {
|
|
147
|
+
realPath = await fs.realpath(shimPath);
|
|
148
|
+
} catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Defence-in-depth: never touch a `lm` that resolves into our
|
|
153
|
+
// own installed package. The `scream_cli` marker check below
|
|
154
|
+
// already excludes the manager's generated wrapper today, but
|
|
155
|
+
// this layer keeps us safe if anything in our bundle ever
|
|
156
|
+
// happens to contain the marker substring.
|
|
157
|
+
if (
|
|
158
|
+
ownRootPrefix !== null &&
|
|
159
|
+
(realPath === ownRoot || realPath.startsWith(ownRootPrefix))
|
|
160
|
+
) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const head = await readShimHead(realPath);
|
|
165
|
+
if (!head || !head.includes(PYTHON_MARKER)) continue;
|
|
166
|
+
|
|
167
|
+
seenShims.add(shimPath);
|
|
168
|
+
results.push({ shimPath, realPath });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return results;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Does the file at `p` look like the legacy Python `scream-cli`?
|
|
176
|
+
*
|
|
177
|
+
* Same criterion as {@link detectLegacyShim} uses to recognize the
|
|
178
|
+
* original shim: realpath-resolvable and the first 4 KiB of the
|
|
179
|
+
* resolved file contains the `scream_cli` module name. Used to decide
|
|
180
|
+
* whether an existing `scream-legacy` is itself a legacy shim (safe to
|
|
181
|
+
* drop the duplicate `lm`) or a user-managed file we must not
|
|
182
|
+
* clobber.
|
|
183
|
+
*/
|
|
184
|
+
export async function isLegacyShim(p) {
|
|
185
|
+
let real;
|
|
186
|
+
try {
|
|
187
|
+
real = await fs.realpath(p);
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
const head = await readShimHead(real);
|
|
192
|
+
return Boolean(head && head.includes(PYTHON_MARKER));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function pathExists(p) {
|
|
196
|
+
try {
|
|
197
|
+
// lstat (not access/stat) so a dangling symlink at `p` still
|
|
198
|
+
// reports as existing — `fs.access` follows symlinks and would
|
|
199
|
+
// return ENOENT for a broken link, after which `fs.rename` would
|
|
200
|
+
// silently replace it.
|
|
201
|
+
await fs.lstat(p);
|
|
202
|
+
return true;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Compute where `shimPath` should be renamed to. Preserves the file
|
|
210
|
+
* extension so a Windows `C:\…\scream.exe` ends up at `scream-legacy.exe`
|
|
211
|
+
* rather than an extension-less `scream-legacy` that `scream.exe -- legacy`
|
|
212
|
+
* shells won't run.
|
|
213
|
+
*/
|
|
214
|
+
function renameTargetFor(shimPath) {
|
|
215
|
+
const ext = extname(shimPath); // "" on POSIX, ".exe" on Windows
|
|
216
|
+
return join(dirname(shimPath), LEGACY_RENAME + ext);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Is the directory containing `shimPath` a system-managed location
|
|
221
|
+
* the current user can't write to?
|
|
222
|
+
*
|
|
223
|
+
* POSIX: dir owned by uid 0 (root) — captures
|
|
224
|
+
* `sudo pip install scream-cli` → `/usr/local/bin/`.
|
|
225
|
+
*
|
|
226
|
+
* Windows: dir under one of the well-known system roots
|
|
227
|
+
* (`C:\Program Files`, `C:\ProgramData`, `C:\Windows`). uv tool
|
|
228
|
+
* installs land in user space (`%USERPROFILE%\.local\bin`) so this
|
|
229
|
+
* path almost never fires on Windows in practice, but the heuristic
|
|
230
|
+
* correctly classifies the rare admin-prefix install case.
|
|
231
|
+
*
|
|
232
|
+
* The dedicated permission-denied notice (`logPermissionDenied`)
|
|
233
|
+
* uses this to switch from a bare "rename it manually" message to a
|
|
234
|
+
* sudo-aware / admin-aware explanation.
|
|
235
|
+
*/
|
|
236
|
+
async function isSystemOwnedDir(shimPath) {
|
|
237
|
+
if (IS_WINDOWS) {
|
|
238
|
+
const dir = dirname(shimPath).toLowerCase();
|
|
239
|
+
const systemRoots = [
|
|
240
|
+
'c:\\program files',
|
|
241
|
+
'c:\\program files (x86)',
|
|
242
|
+
'c:\\programdata',
|
|
243
|
+
'c:\\windows',
|
|
244
|
+
];
|
|
245
|
+
return systemRoots.some(
|
|
246
|
+
(root) => dir === root || dir.startsWith(root + '\\'),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const info = await fs.stat(dirname(shimPath));
|
|
251
|
+
return info.uid === 0;
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function canWriteDir(dir) {
|
|
258
|
+
try {
|
|
259
|
+
// For `fs.rename` and `fs.unlink` the parent dir needs to be both
|
|
260
|
+
// writable and executable (POSIX `wx`). `fs.access` follows the
|
|
261
|
+
// same semantics. On Windows ACL details are coarse but
|
|
262
|
+
// `fs.access(W_OK)` is a reasonable best-effort.
|
|
263
|
+
await fs.access(dir, fsConstants.W_OK | fsConstants.X_OK);
|
|
264
|
+
return true;
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Pre-flight inspection of a single legacy shim. Returns what action
|
|
272
|
+
* we could take WITHOUT executing it. The orchestrator uses these
|
|
273
|
+
* classifications to decide the whole-set strategy (abort vs proceed,
|
|
274
|
+
* which shim becomes scream-legacy) before any filesystem writes
|
|
275
|
+
* happen.
|
|
276
|
+
*
|
|
277
|
+
* Result shapes (all carry `shimPath` and `target` so the renderer
|
|
278
|
+
* has the paths):
|
|
279
|
+
* - `renameable` : can `fs.rename(shim → target)` cleanly;
|
|
280
|
+
* target slot is free.
|
|
281
|
+
* - `consolidate` : target already exists and is itself a legacy
|
|
282
|
+
* shim; we'd `fs.unlink(shim)` and leave the
|
|
283
|
+
* existing `scream-legacy` as the canonical
|
|
284
|
+
* fallback (functionally equivalent — same
|
|
285
|
+
* upstream package).
|
|
286
|
+
* - `delete-only` : target exists but is a user-managed file we
|
|
287
|
+
* won't clobber. We can still `fs.unlink(shim)`
|
|
288
|
+
* to stop the shadowing; the
|
|
289
|
+
* "preserve original scream" invariant fails for
|
|
290
|
+
* THIS dir (we tell the user).
|
|
291
|
+
* - `blocked` : we can't write to the parent dir. Carries
|
|
292
|
+
* `isSystemPath` so the renderer can suggest
|
|
293
|
+
* sudo (POSIX) or admin PowerShell (Windows).
|
|
294
|
+
*/
|
|
295
|
+
export async function classifyShim(shimPath) {
|
|
296
|
+
const target = renameTargetFor(shimPath);
|
|
297
|
+
const dir = dirname(shimPath);
|
|
298
|
+
|
|
299
|
+
if (!(await canWriteDir(dir))) {
|
|
300
|
+
return {
|
|
301
|
+
kind: 'blocked',
|
|
302
|
+
shimPath,
|
|
303
|
+
target,
|
|
304
|
+
isSystemPath: await isSystemOwnedDir(shimPath),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (await pathExists(target)) {
|
|
309
|
+
if (await isLegacyShim(target)) {
|
|
310
|
+
return { kind: 'consolidate', shimPath, target };
|
|
311
|
+
}
|
|
312
|
+
return { kind: 'delete-only', shimPath, target };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { kind: 'renameable', shimPath, target };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Execute an `fs.rename`. Pre-flight classification establishes
|
|
320
|
+
* whether this is expected to succeed; this primitive just runs the
|
|
321
|
+
* call and wraps the error.
|
|
322
|
+
*/
|
|
323
|
+
export async function renameInPlace(shimPath, target) {
|
|
324
|
+
try {
|
|
325
|
+
await fs.rename(shimPath, target);
|
|
326
|
+
return { success: true };
|
|
327
|
+
} catch (err) {
|
|
328
|
+
const code = err && typeof err === 'object' ? err.code : undefined;
|
|
329
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
330
|
+
return { success: false, code, message };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Execute an `fs.unlink`. Used when:
|
|
336
|
+
* - we're consolidating onto an existing legacy `scream-legacy`, or
|
|
337
|
+
* - we couldn't rename (foreign target) but can still remove the
|
|
338
|
+
* shadow, or
|
|
339
|
+
* - this is a non-first shim and we just want to clear it out so
|
|
340
|
+
* PATH order resolves to our new CLI.
|
|
341
|
+
*/
|
|
342
|
+
export async function deleteShim(shimPath) {
|
|
343
|
+
try {
|
|
344
|
+
await fs.unlink(shimPath);
|
|
345
|
+
return { success: true };
|
|
346
|
+
} catch (err) {
|
|
347
|
+
const code = err && typeof err === 'object' ? err.code : undefined;
|
|
348
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
349
|
+
return { success: false, code, message };
|
|
350
|
+
}
|
|
351
|
+
}
|