@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10-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 +158 -0
- package/README.md +161 -11
- package/package.json +6 -5
- package/src/bridge/protocol.ts +8 -3
- package/src/bridge/reserved.ts +2 -0
- package/src/bridges/eval-environment-schemas.ts +48 -0
- package/src/bridges/eval-virtual-schemas.ts +11 -4
- package/src/bridges/reserved-dispatch.ts +13 -1
- package/src/config/executable-settings.json +5 -0
- package/src/config/project-trust.ts +51 -0
- package/src/config/settings-overrides.ts +2 -0
- package/src/environments/js-environments.ts +208 -0
- package/src/environments/js-installer.ts +268 -0
- package/src/environments/js-revision-files.ts +91 -0
- package/src/environments/no-links.ts +132 -0
- package/src/environments/packages-install.ts +98 -0
- package/src/environments/project-resolves.ts +54 -0
- package/src/environments/py-environment.ts +7 -3
- package/src/environments/py-installer.ts +5 -1
- package/src/environments/python-environments.ts +1 -1
- package/src/environments/revision-store.ts +20 -4
- package/src/extension/bridge-tool-call.ts +5 -1
- package/src/extension/eval-notifier.ts +13 -2
- package/src/extension/kernel-registration.ts +7 -2
- package/src/extension/runtime-factory.ts +51 -13
- package/src/extension/runtime-info.ts +6 -2
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager.ts +16 -2
- package/src/index.ts +3 -2
- package/src/interpreters/detect.ts +34 -1
- package/src/kernels/AGENTS.md +2 -0
- package/src/kernels/js/cell-dispatch.ts +44 -0
- package/src/kernels/js/cell-ownership.js +268 -0
- package/src/kernels/js/cell-run-context.js +89 -0
- package/src/kernels/js/context-manager.ts +46 -51
- package/src/kernels/js/control-frames.ts +24 -0
- package/src/kernels/js/host-entries.ts +62 -0
- package/src/kernels/js/kernel-contract.ts +16 -1
- package/src/kernels/js/kernel-memory-bridge.ts +1 -1
- package/src/kernels/js/kernel-memory.ts +79 -0
- package/src/kernels/js/kernel-tools-registry.js +1 -0
- package/src/kernels/js/local-module-loader.ts +17 -6
- package/src/kernels/js/process-drain-scan.d.ts +8 -0
- package/src/kernels/js/process-drain-scan.js +28 -0
- package/src/kernels/js/process-entry.js +466 -0
- package/src/kernels/js/process-memory-host.ts +83 -0
- package/src/kernels/js/process-worker.ts +298 -0
- package/src/kernels/js/rejection-reports.js +61 -0
- package/src/kernels/js/run-queue.ts +7 -0
- package/src/kernels/js/worker-core.js +53 -8
- package/src/kernels/js/worker-cwd.js +5 -2
- package/src/kernels/js/worker-host.ts +9 -2
- package/src/kernels/js/worker-indirect-eval.js +37 -13
- package/src/kernels/js/worker-package-resolve.js +153 -0
- package/src/kernels/js/worker-require.d.ts +19 -0
- package/src/kernels/js/worker-require.js +52 -0
- package/src/kernels/js/worker-runtime.js +43 -9
- package/src/kernels/js/worker-shadow-guard.js +2 -6
- package/src/kernels/js/worker-shell-capture.js +5 -0
- package/src/kernels/js/worker-slot.ts +9 -0
- package/src/kernels/js/worker-startup.ts +49 -1
- package/src/kernels/js/worker-webview.js +15 -1
- package/src/kernels/py/host-cell.ts +8 -31
- package/src/kernels/py/prelude.py +33 -2
- package/src/kernels/py/process.ts +4 -0
- package/src/kernels/sandbox/sandbox-cell.ts +141 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
- package/src/kernels/shared/host-cell.ts +57 -0
- package/src/kernels/shared/subprocess-process.ts +14 -0
- package/src/prompt/eval-prompt.ts +8 -1
- package/src/tool/AGENTS.md +1 -0
- package/src/tool/cell-handler.ts +12 -11
- package/src/tool/cell-reserved-dispatch.ts +1 -0
- package/src/tool/cell-runtime.ts +4 -2
- package/src/tool/detached-cell-contract.ts +3 -0
- package/src/tool/detached-cell-manager.ts +4 -8
- package/src/tool/detached-cell-notification.ts +11 -63
- package/src/tool/detached-cell-snapshot.ts +17 -0
- package/src/tool/detached-eval-result.ts +13 -7
- package/src/tool/detached-notification-queue.ts +1 -2
- package/src/tool/eval-request.ts +23 -1
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/eval-tool.ts +17 -4
- package/src/tool/interrupt-note.ts +4 -5
- package/src/tool/live-headline.ts +60 -0
- package/src/tool/load-cell.ts +18 -3
- package/src/tool/magic-cell-host.ts +60 -2
- package/src/tool/magic-cells.ts +12 -2
- package/src/tool/managed-cell.ts +0 -4
- package/src/tool/render.ts +74 -15
- package/src/tool/run-eval-cell.ts +46 -4
- package/src/tool/runtime-label.ts +2 -1
- package/src/tool/types.ts +23 -1
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { AgentToolResult, EvalHandleHost } from "@code-yeongyu/senpi";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
RESERVED_AGENT_TOOL,
|
|
4
|
+
RESERVED_OUTPUT_TOOL,
|
|
5
|
+
RESERVED_PACKAGES_INSTALL_TOOL,
|
|
6
|
+
RESERVED_SCHEMA_TOOL,
|
|
7
|
+
} from "../bridge/reserved.ts";
|
|
8
|
+
import { type PackagesInstallEnvironments, runPackagesInstall } from "../environments/packages-install.ts";
|
|
3
9
|
import type { HandleRegistry } from "../handles/handle-registry.ts";
|
|
4
10
|
import type { EvalStatusEvent, ExecuteTool } from "../tool/types.ts";
|
|
5
11
|
import { type AgentExecuteTool, runEvalAgent } from "./agent-bridge.ts";
|
|
@@ -21,6 +27,8 @@ export interface ReservedDispatchContext {
|
|
|
21
27
|
readonly handles?: HandleRegistry;
|
|
22
28
|
/** `ctx.evalHandleHost` at dispatch time; agent/workpool refs need it, completion refs do not. */
|
|
23
29
|
readonly evalHandleHost?: EvalHandleHost;
|
|
30
|
+
/** The calling cell's session environments; `packages.install()` fails with environment_installer_unavailable without one. */
|
|
31
|
+
readonly environments?: PackagesInstallEnvironments;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
class SchemaUnavailableError extends Error {
|
|
@@ -36,6 +44,7 @@ export function isReservedToolName(toolName: string): boolean {
|
|
|
36
44
|
toolName === RESERVED_AGENT_TOOL ||
|
|
37
45
|
toolName === RESERVED_OUTPUT_TOOL ||
|
|
38
46
|
toolName === RESERVED_SCHEMA_TOOL ||
|
|
47
|
+
toolName === RESERVED_PACKAGES_INSTALL_TOOL ||
|
|
39
48
|
isHandleToolName(toolName)
|
|
40
49
|
);
|
|
41
50
|
}
|
|
@@ -66,6 +75,9 @@ export async function runReservedTool(toolName: string, context: ReservedDispatc
|
|
|
66
75
|
marshalToolResult: context.marshalToolResult,
|
|
67
76
|
});
|
|
68
77
|
}
|
|
78
|
+
if (toolName === RESERVED_PACKAGES_INSTALL_TOOL) {
|
|
79
|
+
return await runPackagesInstall(context.args, context.environments ?? {}, context.signal);
|
|
80
|
+
}
|
|
69
81
|
if (toolName === RESERVED_SCHEMA_TOOL) {
|
|
70
82
|
const listTools = context.listTools;
|
|
71
83
|
if (listTools === undefined) throw new SchemaUnavailableError();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import executableSettings from "./executable-settings.json" with { type: "json" };
|
|
3
|
+
import type { CodemodeSettings } from "./settings.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Settings that name an executable run at session start (an interpreter's `--version` probe, then the kernel). The
|
|
7
|
+
* one list of them lives in `executable-settings.json`, which senpi's project-trust check also reads: a project
|
|
8
|
+
* codemode file that sets any of them asks for trust. Without trust they are dropped with a warning and never run.
|
|
9
|
+
* Trust is asked only when the project's own file sets one, so a host that cannot answer still starts sessions;
|
|
10
|
+
* such a host counts as not trusting the project.
|
|
11
|
+
*/
|
|
12
|
+
export const PROJECT_EXECUTABLE_SETTINGS: readonly string[] = executableSettings.projectExecutableSettings;
|
|
13
|
+
|
|
14
|
+
export function withoutUntrustedInterpreter<Settings extends CodemodeSettings>(
|
|
15
|
+
settings: Settings,
|
|
16
|
+
source: string | null,
|
|
17
|
+
cwd: string,
|
|
18
|
+
isProjectTrusted: (() => boolean) | undefined,
|
|
19
|
+
): { readonly settings: Settings; readonly warning?: string } {
|
|
20
|
+
const projectFile = join(cwd, ".senpi", "codemode.json");
|
|
21
|
+
if (source !== projectFile) return { settings };
|
|
22
|
+
const named = PROJECT_EXECUTABLE_SETTINGS.filter((path) => valueAt(settings, path) !== undefined);
|
|
23
|
+
if (named.length === 0 || isProjectTrusted?.() === true) return { settings };
|
|
24
|
+
let trimmed: Settings = settings;
|
|
25
|
+
for (const path of named) trimmed = withoutPath(trimmed, path);
|
|
26
|
+
const list = named.map((path) => `${path} "${String(valueAt(settings, path))}"`).join(", ");
|
|
27
|
+
return {
|
|
28
|
+
settings: trimmed,
|
|
29
|
+
warning: `${list} in ${projectFile} is ignored because this project is not trusted; it was not run`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function valueAt(value: unknown, path: string): unknown {
|
|
34
|
+
let current: unknown = value;
|
|
35
|
+
for (const key of path.split(".")) {
|
|
36
|
+
if (typeof current !== "object" || current === null) return undefined;
|
|
37
|
+
current = Reflect.get(current, key);
|
|
38
|
+
}
|
|
39
|
+
return current;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function withoutPath<T>(value: T, path: string): T {
|
|
43
|
+
const copy = structuredClone(value);
|
|
44
|
+
const keys = path.split(".");
|
|
45
|
+
const last = keys.pop();
|
|
46
|
+
let parent: unknown = copy;
|
|
47
|
+
for (const key of keys)
|
|
48
|
+
parent = typeof parent === "object" && parent !== null ? Reflect.get(parent, key) : undefined;
|
|
49
|
+
if (last !== undefined && typeof parent === "object" && parent !== null) Reflect.deleteProperty(parent, last);
|
|
50
|
+
return copy;
|
|
51
|
+
}
|
|
@@ -20,6 +20,8 @@ export function resolveEnabledLanguages(
|
|
|
20
20
|
env: Environment = process.env,
|
|
21
21
|
): CodemodeSettings["languages"] {
|
|
22
22
|
return {
|
|
23
|
+
// Keep the non-flag keys (languages.pyInterpreter); only the four enable flags take env overrides.
|
|
24
|
+
...settings.languages,
|
|
23
25
|
py: resolveLanguage(settings.languages.py, env[languageEnvironmentFlags.py]),
|
|
24
26
|
js: resolveLanguage(settings.languages.js, env[languageEnvironmentFlags.js]),
|
|
25
27
|
rb: resolveLanguage(settings.languages.rb, env[languageEnvironmentFlags.rb]),
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { mkdir, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import type { ResolvedCodemodeSettings } from "../config/settings.ts";
|
|
4
|
+
import { withRootLock } from "./install-lock.ts";
|
|
5
|
+
import {
|
|
6
|
+
absoluteSpec,
|
|
7
|
+
type JsInstallerChoice,
|
|
8
|
+
parseJsPackages,
|
|
9
|
+
requestedPackageName,
|
|
10
|
+
resolveJsInstaller,
|
|
11
|
+
runJsInstall,
|
|
12
|
+
withoutHostPaths,
|
|
13
|
+
} from "./js-installer.ts";
|
|
14
|
+
import { carryNpmrcSettings, recordedFileSpecs, seedPackageJson } from "./js-revision-files.ts";
|
|
15
|
+
import { assertBuiltRevisionLinks, assertNoLinksBelow, recordedInstallSources } from "./no-links.ts";
|
|
16
|
+
import { projectResolves } from "./project-resolves.ts";
|
|
17
|
+
import type { EnvironmentMode } from "./py-environment.ts";
|
|
18
|
+
import { EnvironmentError } from "./py-installer.ts";
|
|
19
|
+
import { mkdirPrivate, publishNextRevision, readActiveRevision } from "./revision-store.ts";
|
|
20
|
+
|
|
21
|
+
/** The paths in a revision that the installer writes. */
|
|
22
|
+
const INSTALLER_WRITES = [
|
|
23
|
+
"package.json",
|
|
24
|
+
"package-lock.json",
|
|
25
|
+
"bun.lock",
|
|
26
|
+
"bun.lockb",
|
|
27
|
+
"node_modules",
|
|
28
|
+
".npmrc",
|
|
29
|
+
"bunfig.toml",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export interface JsInstallReceipt {
|
|
33
|
+
readonly installer: "bun" | "npm";
|
|
34
|
+
readonly mode: EnvironmentMode;
|
|
35
|
+
readonly revision: number | undefined;
|
|
36
|
+
readonly added: readonly string[];
|
|
37
|
+
readonly shadowed: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface JsEnvironmentsOptions {
|
|
41
|
+
readonly artifactsDir: string;
|
|
42
|
+
readonly cwd: string;
|
|
43
|
+
readonly runtime: string;
|
|
44
|
+
readonly env: NodeJS.ProcessEnv;
|
|
45
|
+
readonly settings: Pick<ResolvedCodemodeSettings, "environments">;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class JsEnvironments {
|
|
49
|
+
readonly #options: JsEnvironmentsOptions;
|
|
50
|
+
#mode: EnvironmentMode = "managed";
|
|
51
|
+
#packageRoot: string | undefined;
|
|
52
|
+
|
|
53
|
+
constructor(options: JsEnvironmentsOptions) {
|
|
54
|
+
this.#options = options;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get mode(): EnvironmentMode {
|
|
58
|
+
return this.#mode;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get packageRoot(): string | undefined {
|
|
62
|
+
return this.#mode === "managed" ? this.#packageRoot : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async setMode(mode: EnvironmentMode): Promise<string> {
|
|
66
|
+
this.#mode = mode;
|
|
67
|
+
if (mode === "project") return this.#options.cwd;
|
|
68
|
+
this.#packageRoot = (await readActiveRevision(this.#managedBase()))?.dir;
|
|
69
|
+
return this.#managedBase();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async install(
|
|
73
|
+
requested: string,
|
|
74
|
+
signal: AbortSignal,
|
|
75
|
+
onOutput?: (stream: "stdout" | "stderr", data: string) => void,
|
|
76
|
+
requestedInstaller?: "bun" | "npm",
|
|
77
|
+
): Promise<JsInstallReceipt> {
|
|
78
|
+
const environments = this.#options.settings.environments;
|
|
79
|
+
if (environments?.autoProvision === false) {
|
|
80
|
+
throw new EnvironmentError(
|
|
81
|
+
"environment_installer_unavailable",
|
|
82
|
+
"installs are turned off for this project (environments.autoProvision is false)",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const packages = parseJsPackages(requested).map((spec) => absoluteSpec(spec, this.#options.cwd));
|
|
86
|
+
// `%bun add` and `%npm add` name their installer; the setting applies only where the magic does not.
|
|
87
|
+
const choice: JsInstallerChoice = requestedInstaller ?? environments?.js?.installer ?? "auto";
|
|
88
|
+
const { installer, command } = resolveJsInstaller(choice, this.#options.env);
|
|
89
|
+
let recordedSpecs: readonly string[] = [];
|
|
90
|
+
const run = (root: string, remove?: string) =>
|
|
91
|
+
runJsInstall({
|
|
92
|
+
installer,
|
|
93
|
+
command,
|
|
94
|
+
root,
|
|
95
|
+
packages,
|
|
96
|
+
...(remove === undefined ? {} : { remove }),
|
|
97
|
+
recordedSpecs,
|
|
98
|
+
cwd: this.#options.cwd,
|
|
99
|
+
env: this.#options.env,
|
|
100
|
+
signal,
|
|
101
|
+
...(onOutput === undefined ? {} : { onOutput }),
|
|
102
|
+
});
|
|
103
|
+
// The lock and the revision store fail with raw file system errors; they reach the cell redacted.
|
|
104
|
+
const redacted =
|
|
105
|
+
(...roots: string[]) =>
|
|
106
|
+
(error: unknown) => {
|
|
107
|
+
const raw =
|
|
108
|
+
error instanceof EnvironmentError
|
|
109
|
+
? error.message.slice(error.code.length + 2)
|
|
110
|
+
: String(error instanceof Error ? error.message : error);
|
|
111
|
+
const text = roots.reduce(
|
|
112
|
+
(message, root) => withoutHostPaths(message, { root, cwd: this.#options.cwd, packages, recordedSpecs }),
|
|
113
|
+
raw,
|
|
114
|
+
);
|
|
115
|
+
if (error instanceof EnvironmentError) throw new EnvironmentError(error.code, text);
|
|
116
|
+
// An abort while waiting for the lock or before the publish is a cancel, not a failure.
|
|
117
|
+
if (signal.aborted)
|
|
118
|
+
throw new EnvironmentError("environment_install_cancelled", `the install was cancelled: ${text}`);
|
|
119
|
+
throw new EnvironmentError("environment_install_failed", text);
|
|
120
|
+
};
|
|
121
|
+
if (this.#mode === "project") {
|
|
122
|
+
const before = await dependencyNames(this.#options.cwd);
|
|
123
|
+
const lockRoot = join(this.#options.cwd, ".senpi", "js-packages");
|
|
124
|
+
await mkdir(lockRoot, { recursive: true });
|
|
125
|
+
await withRootLock(lockRoot, async () => await run(this.#options.cwd), signal).catch(redacted(lockRoot));
|
|
126
|
+
const added = (await dependencyNames(this.#options.cwd)).filter((name) => !before.includes(name));
|
|
127
|
+
return { installer, mode: "project", revision: undefined, added, shadowed: [] };
|
|
128
|
+
}
|
|
129
|
+
let added: string[] = [];
|
|
130
|
+
const requestedBase = this.#managedBase();
|
|
131
|
+
const base = await canonicalBase(this.#managedRoot(), requestedBase).catch(redacted(requestedBase));
|
|
132
|
+
const { revision } = await publishNextRevision(
|
|
133
|
+
base,
|
|
134
|
+
async (staging) => {
|
|
135
|
+
await carryNpmrcSettings(staging);
|
|
136
|
+
await rm(join(staging, "bunfig.toml"), { recursive: true, force: true });
|
|
137
|
+
await seedPackageJson(staging);
|
|
138
|
+
// Every path the installer writes must be the revision's own, never a link out of it.
|
|
139
|
+
for (const entry of INSTALLER_WRITES) await assertNoLinksBelow(base, join(staging, entry));
|
|
140
|
+
const before = await dependencyNames(staging);
|
|
141
|
+
recordedSpecs = await recordedFileSpecs(staging);
|
|
142
|
+
// A package whose source changes kind (a directory to a tarball or registry version, or back) is removed
|
|
143
|
+
// first: bun keeps its per-file links into the old directory otherwise (#2758).
|
|
144
|
+
for (const name of await sourceKindChanges(staging, packages)) await run(staging, name);
|
|
145
|
+
await run(staging);
|
|
146
|
+
await assertBuiltRevisionLinks(staging);
|
|
147
|
+
added = (await dependencyNames(staging)).filter((name) => !before.includes(name));
|
|
148
|
+
},
|
|
149
|
+
signal,
|
|
150
|
+
).catch(redacted(base, requestedBase));
|
|
151
|
+
this.#packageRoot = revision.dir;
|
|
152
|
+
const shadowed = added.filter((name) => projectResolves(this.#options.cwd, name));
|
|
153
|
+
return { installer, mode: "managed", revision: revision.number, added, shadowed };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#managedRoot(): string {
|
|
157
|
+
return this.#options.settings.environments?.managedRoot ?? this.#options.artifactsDir;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#managedBase(): string {
|
|
161
|
+
return join(this.#managedRoot(), "environments", "js", this.#options.runtime);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function dependencyNames(root: string): Promise<string[]> {
|
|
166
|
+
try {
|
|
167
|
+
const manifest: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
168
|
+
if (typeof manifest !== "object" || manifest === null || !("dependencies" in manifest)) return [];
|
|
169
|
+
const dependencies = manifest.dependencies;
|
|
170
|
+
return typeof dependencies === "object" && dependencies !== null ? Object.keys(dependencies) : [];
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Creates the managed base, checks that nothing below the managed root is a link, and returns its realpath: npm keys
|
|
179
|
+
* a linked package in its lockfile by a path relative to the prefix, and a prefix under a linked directory (macOS
|
|
180
|
+
* `/var` is a link to `/private/var`) gives keys that stop resolving once the staged revision is renamed.
|
|
181
|
+
*/
|
|
182
|
+
async function canonicalBase(managedRoot: string, base: string): Promise<string> {
|
|
183
|
+
await assertNoLinksBelow(managedRoot, base);
|
|
184
|
+
await mkdirPrivate(base);
|
|
185
|
+
await assertNoLinksBelow(managedRoot, base);
|
|
186
|
+
return await realpath(base);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Names among `packages` whose recorded source is a directory and is not the same directory now, or the reverse. */
|
|
190
|
+
export async function sourceKindChanges(revision: string, packages: readonly string[]): Promise<string[]> {
|
|
191
|
+
const installed = new Set(await dependencyNames(revision));
|
|
192
|
+
const sources = await recordedInstallSources(revision);
|
|
193
|
+
const changed: string[] = [];
|
|
194
|
+
for (const spec of packages) {
|
|
195
|
+
const name = await requestedPackageName(spec);
|
|
196
|
+
if (name === undefined || !installed.has(name)) continue;
|
|
197
|
+
const directory = await localDirectory(spec);
|
|
198
|
+
if (directory !== sources.get(name)) changed.push(name);
|
|
199
|
+
}
|
|
200
|
+
return changed;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function localDirectory(spec: string): Promise<string | undefined> {
|
|
204
|
+
const path = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
|
|
205
|
+
if (!isAbsolute(path)) return undefined;
|
|
206
|
+
const resolved = await realpath(path).catch(() => undefined);
|
|
207
|
+
return resolved !== undefined && (await stat(resolved)).isDirectory() ? resolved : undefined;
|
|
208
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { basename, delimiter, isAbsolute, join, resolve, sep } from "node:path";
|
|
6
|
+
import { EnvironmentError } from "./py-installer.ts";
|
|
7
|
+
|
|
8
|
+
export type JsInstallerChoice = "auto" | "bun" | "npm";
|
|
9
|
+
export type JsInstaller = "bun" | "npm";
|
|
10
|
+
|
|
11
|
+
const STDERR_TAIL_BYTES = 4_096;
|
|
12
|
+
|
|
13
|
+
function onPath(command: string, env: NodeJS.ProcessEnv): string | undefined {
|
|
14
|
+
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
|
15
|
+
const extensions = process.platform === "win32" ? [".exe", ".cmd", ""] : [""];
|
|
16
|
+
for (const directory of (env[pathKey] ?? "").split(delimiter)) {
|
|
17
|
+
if (directory === "") continue;
|
|
18
|
+
for (const extension of extensions) {
|
|
19
|
+
const candidate = join(directory, `${command}${extension}`);
|
|
20
|
+
if (existsSync(candidate)) return candidate;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveJsInstaller(
|
|
27
|
+
choice: JsInstallerChoice,
|
|
28
|
+
env: NodeJS.ProcessEnv,
|
|
29
|
+
): { readonly installer: JsInstaller; readonly command: string } {
|
|
30
|
+
const order: readonly JsInstaller[] = choice === "auto" ? ["bun", "npm"] : [choice];
|
|
31
|
+
for (const installer of order) {
|
|
32
|
+
const command = onPath(installer, env);
|
|
33
|
+
if (command !== undefined) return { installer, command };
|
|
34
|
+
}
|
|
35
|
+
throw new EnvironmentError(
|
|
36
|
+
"environment_installer_unavailable",
|
|
37
|
+
choice === "auto" ? "neither bun nor npm is on PATH" : `${choice} is not on PATH`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseJsPackages(text: string): string[] {
|
|
42
|
+
if (/[\u0000-\u0008\u000e-\u001f\u007f]/.test(text)) {
|
|
43
|
+
throw new EnvironmentError("environment_install_failed", "package names cannot contain control characters");
|
|
44
|
+
}
|
|
45
|
+
const packages = text.split(/\s+/).filter((token) => token !== "");
|
|
46
|
+
const flag = packages.find((token) => token.startsWith("-"));
|
|
47
|
+
if (flag !== undefined) {
|
|
48
|
+
throw new EnvironmentError(
|
|
49
|
+
"environment_install_failed",
|
|
50
|
+
`installer flags are chosen by the host; name packages only (got ${flag})`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (packages.length === 0)
|
|
54
|
+
throw new EnvironmentError("environment_install_failed", "name at least one package to add");
|
|
55
|
+
return packages;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A path-like spec (`./pkg`, `../x.tgz`, `file:./pkg`) means a path from the session directory; both installers get it
|
|
60
|
+
* absolute, because bun resolves a relative spec from the revision it builds and npm from its own cwd.
|
|
61
|
+
*/
|
|
62
|
+
export function absoluteSpec(spec: string, cwd: string): string {
|
|
63
|
+
const file = spec.startsWith("file:") ? spec.slice("file:".length) : undefined;
|
|
64
|
+
const path = file ?? spec;
|
|
65
|
+
const pathLike = path.startsWith("./") || path.startsWith("../") || path === "." || path === "..";
|
|
66
|
+
if (!pathLike) return spec;
|
|
67
|
+
const absolute = resolve(cwd, path);
|
|
68
|
+
return file === undefined ? absolute : `file:${absolute}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function jsInstallArgv(installer: JsInstaller, root: string, packages: readonly string[]): string[] {
|
|
72
|
+
return installer === "bun"
|
|
73
|
+
? ["add", "--ignore-scripts", "--cwd", root, ...packages]
|
|
74
|
+
: ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--prefix", root, ...packages];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function jsRemoveArgv(installer: JsInstaller, root: string, name: string): string[] {
|
|
78
|
+
return installer === "bun"
|
|
79
|
+
? ["remove", "--ignore-scripts", "--cwd", root, name]
|
|
80
|
+
: ["uninstall", "--ignore-scripts", "--no-audit", "--no-fund", "--prefix", root, name];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const REGISTRY_NAME = /^((?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*)(?:@[^/]*)?$/i;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The package name a spec installs, when it can be known before the install: a local directory's or tarball's own
|
|
87
|
+
* `package.json` name, or the name of a registry spec (`name`, `name@range`, `@scope/name@range`). Undefined for URL
|
|
88
|
+
* and git specs.
|
|
89
|
+
*/
|
|
90
|
+
export async function requestedPackageName(spec: string): Promise<string | undefined> {
|
|
91
|
+
const path = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
|
|
92
|
+
if (isAbsolute(path)) {
|
|
93
|
+
try {
|
|
94
|
+
const manifest: unknown = JSON.parse(
|
|
95
|
+
(await stat(path)).isDirectory()
|
|
96
|
+
? await readFile(join(path, "package.json"), "utf8")
|
|
97
|
+
: await tarballManifest(path),
|
|
98
|
+
);
|
|
99
|
+
return typeof manifest === "object" &&
|
|
100
|
+
manifest !== null &&
|
|
101
|
+
"name" in manifest &&
|
|
102
|
+
typeof manifest.name === "string"
|
|
103
|
+
? manifest.name
|
|
104
|
+
: undefined;
|
|
105
|
+
} catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return REGISTRY_NAME.exec(spec)?.[1];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* An archive's own `package.json`, read from its single top-level directory as both installers unpack it: npm's
|
|
114
|
+
* `package/`, or a GitHub-style `<repo>-<sha>/`. tar detects the compression itself, so a plain `.tar` works too.
|
|
115
|
+
*/
|
|
116
|
+
async function tarballManifest(path: string): Promise<string> {
|
|
117
|
+
const entries = (await tarOutput(["-tf", path])).split("\n").filter((entry) => entry !== "");
|
|
118
|
+
const normalized = (entry: string) => entry.replace(/^(\.\/)+/, "");
|
|
119
|
+
const tops = new Set(entries.map((entry) => normalized(entry).split("/")[0] ?? "").filter((top) => top !== ""));
|
|
120
|
+
// An archive whose entries do not all sit under one directory has no single package to name: judging it by
|
|
121
|
+
// whichever entry comes first could name the wrong one.
|
|
122
|
+
const [top, ...others] = [...tops];
|
|
123
|
+
if (top === undefined || others.length > 0 || top === "." || top === "..") {
|
|
124
|
+
throw new Error("the archive does not hold one top-level directory");
|
|
125
|
+
}
|
|
126
|
+
// Extract by the member's own name: GNU tar matches it exactly, so a leading "./" must be kept.
|
|
127
|
+
const manifest = entries.find((entry) => normalized(entry) === `${top}/package.json`);
|
|
128
|
+
if (manifest === undefined) throw new Error("the archive holds no package.json in its top-level directory");
|
|
129
|
+
return await tarOutput(["-xOf", path, manifest]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function tarOutput(args: readonly string[]): Promise<string> {
|
|
133
|
+
return new Promise((resolveText, reject) => {
|
|
134
|
+
execFile("tar", [...args], { maxBuffer: 64 << 20 }, (error, stdout) =>
|
|
135
|
+
error === null ? resolveText(stdout) : reject(error),
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function runJsInstall(input: {
|
|
141
|
+
readonly installer: JsInstaller;
|
|
142
|
+
readonly command: string;
|
|
143
|
+
readonly root: string;
|
|
144
|
+
readonly packages: readonly string[];
|
|
145
|
+
/** Remove this package instead of adding `packages`. */
|
|
146
|
+
readonly remove?: string;
|
|
147
|
+
/** Absolute specs already recorded in the revision; the installer echoes them, so they are redacted too. */
|
|
148
|
+
readonly recordedSpecs?: readonly string[];
|
|
149
|
+
readonly cwd: string;
|
|
150
|
+
readonly env: NodeJS.ProcessEnv;
|
|
151
|
+
readonly signal: AbortSignal;
|
|
152
|
+
readonly onOutput?: (stream: "stdout" | "stderr", data: string) => void;
|
|
153
|
+
}): Promise<void> {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
if (input.signal.aborted) {
|
|
156
|
+
reject(new EnvironmentError("environment_install_cancelled", "the install was cancelled before it started"));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const argv =
|
|
160
|
+
input.remove === undefined
|
|
161
|
+
? jsInstallArgv(input.installer, input.root, input.packages)
|
|
162
|
+
: jsRemoveArgv(input.installer, input.root, input.remove);
|
|
163
|
+
const child = spawn(input.command, argv, {
|
|
164
|
+
cwd: input.cwd,
|
|
165
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
166
|
+
// npm reads `global`/`location` from any .npmrc and the env; a global install would land where the import never looks.
|
|
167
|
+
env: {
|
|
168
|
+
...input.env,
|
|
169
|
+
npm_config_ignore_scripts: "true",
|
|
170
|
+
npm_config_global: "false",
|
|
171
|
+
npm_config_location: "project",
|
|
172
|
+
},
|
|
173
|
+
detached: process.platform !== "win32",
|
|
174
|
+
});
|
|
175
|
+
let output = "";
|
|
176
|
+
const record = (stream: "stdout" | "stderr") => (data: string) => {
|
|
177
|
+
output = (output + data).slice(-STDERR_TAIL_BYTES);
|
|
178
|
+
input.onOutput?.(stream, withoutHostPaths(data, input));
|
|
179
|
+
};
|
|
180
|
+
child.stdout.setEncoding("utf8").on("data", record("stdout"));
|
|
181
|
+
child.stderr.setEncoding("utf8").on("data", record("stderr"));
|
|
182
|
+
const onAbort = () => {
|
|
183
|
+
if (child.pid !== undefined && process.platform !== "win32") {
|
|
184
|
+
try {
|
|
185
|
+
process.kill(-child.pid, "SIGKILL");
|
|
186
|
+
return;
|
|
187
|
+
} catch {
|
|
188
|
+
// ESRCH (the group is gone) or EPERM (macOS refuses a group whose leader already exited): stop the
|
|
189
|
+
// installer itself. An abort listener must never throw: nothing above it can catch the error.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
child.kill("SIGKILL");
|
|
193
|
+
};
|
|
194
|
+
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
195
|
+
child.once("error", (error) => {
|
|
196
|
+
input.signal.removeEventListener("abort", onAbort);
|
|
197
|
+
reject(
|
|
198
|
+
new EnvironmentError(
|
|
199
|
+
"environment_installer_unavailable",
|
|
200
|
+
`${input.installer}: ${withoutHostPaths(error.message, input)}`,
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
child.once("close", (code, signal) => {
|
|
205
|
+
input.signal.removeEventListener("abort", onAbort);
|
|
206
|
+
if (input.signal.aborted) {
|
|
207
|
+
reject(
|
|
208
|
+
new EnvironmentError(
|
|
209
|
+
"environment_install_cancelled",
|
|
210
|
+
`the install was cancelled${cancelReason(input.signal, input)}; ${input.installer} was stopped`,
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
} else if (code === 0) resolve();
|
|
214
|
+
else {
|
|
215
|
+
reject(
|
|
216
|
+
new EnvironmentError(
|
|
217
|
+
"environment_install_failed",
|
|
218
|
+
withoutHostPaths(output.trim(), input) || `${input.installer} exited with ${code ?? signal}`,
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function cancelReason(signal: AbortSignal, input: Parameters<typeof withoutHostPaths>[1]): string {
|
|
227
|
+
const reason: unknown = signal.reason;
|
|
228
|
+
return reason instanceof Error && reason.name !== "AbortError" ? `: ${withoutHostPaths(reason.message, input)}` : "";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Installer output names the session's own roots; error text says `<root>`/`<cwd>`/`~` instead of absolute paths. */
|
|
232
|
+
export function withoutHostPaths(
|
|
233
|
+
text: string,
|
|
234
|
+
input: {
|
|
235
|
+
readonly root: string;
|
|
236
|
+
readonly cwd: string;
|
|
237
|
+
readonly packages: readonly string[];
|
|
238
|
+
readonly recordedSpecs?: readonly string[];
|
|
239
|
+
},
|
|
240
|
+
): string {
|
|
241
|
+
const home = homedir();
|
|
242
|
+
// A spec that is an absolute file path names the user's file system; it is shown by its file name only.
|
|
243
|
+
// This install's absolute specs, and every path an earlier install recorded (npm records a relative `file:` path).
|
|
244
|
+
const requested = input.packages
|
|
245
|
+
.map((spec) => (spec.startsWith("file:") ? spec.slice("file:".length) : spec))
|
|
246
|
+
.filter((path) => isAbsolute(path));
|
|
247
|
+
const specPaths = [...requested, ...(input.recordedSpecs ?? [])]
|
|
248
|
+
.sort((a, b) => b.length - a.length)
|
|
249
|
+
.map((path) => [path, `<path>/${basename(path)}`] as const);
|
|
250
|
+
return [
|
|
251
|
+
...specPaths,
|
|
252
|
+
[input.root, "<root>"],
|
|
253
|
+
[input.cwd, "<cwd>"],
|
|
254
|
+
[realpathOrSelf(tmpdir()), "<tmp>"],
|
|
255
|
+
[tmpdir(), "<tmp>"],
|
|
256
|
+
[home, "~"],
|
|
257
|
+
]
|
|
258
|
+
.filter(([path]) => path !== "" && path !== sep)
|
|
259
|
+
.reduce((current, [path, label]) => current.replaceAll(path, label), text);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function realpathOrSelf(path: string): string {
|
|
263
|
+
try {
|
|
264
|
+
return realpathSync(path);
|
|
265
|
+
} catch {
|
|
266
|
+
return path;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const NPMRC_CARRIED_KEY = /^(?:registry|@[^\s=:/]+:registry|strict-ssl|ca|cafile)$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A revision carries forward only the registry settings of its `.npmrc`: `registry`, `@scope:registry`, `strict-ssl`,
|
|
8
|
+
* `ca` and `cafile`. Every other key, credentials included, is dropped by default, and the file is rewritten as a
|
|
9
|
+
* regular file: a symlinked `.npmrc` is replaced, never written through, so the file it pointed at stays untouched.
|
|
10
|
+
*/
|
|
11
|
+
export async function carryNpmrcSettings(root: string): Promise<void> {
|
|
12
|
+
const path = join(root, ".npmrc");
|
|
13
|
+
let text: string;
|
|
14
|
+
try {
|
|
15
|
+
text = await readFile(path, "utf8");
|
|
16
|
+
} catch {
|
|
17
|
+
await rm(path, { recursive: true, force: true });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const kept = text
|
|
21
|
+
.split(/\r\n|\r|\n/)
|
|
22
|
+
.map((line) => line.trim())
|
|
23
|
+
.filter((line) => {
|
|
24
|
+
const key = line.split("=", 1)[0]?.trim() ?? "";
|
|
25
|
+
return line.includes("=") && NPMRC_CARRIED_KEY.test(key);
|
|
26
|
+
})
|
|
27
|
+
.flatMap((line) => {
|
|
28
|
+
const carried = withoutUrlCredentials(line);
|
|
29
|
+
return carried === undefined ? [] : [carried];
|
|
30
|
+
});
|
|
31
|
+
await rm(path, { recursive: true, force: true });
|
|
32
|
+
if (kept.length > 0) await writeFile(path, `${kept.join("\n")}\n`, { mode: 0o600, flag: "wx" });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The `file:` and absolute path specs a revision's `package.json` already records, as written (npm writes a relative
|
|
37
|
+
* `file:../..` path) and resolved against the revision: the installer echoes them on every later install.
|
|
38
|
+
*/
|
|
39
|
+
export async function recordedFileSpecs(root: string): Promise<string[]> {
|
|
40
|
+
try {
|
|
41
|
+
const manifest: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
42
|
+
if (typeof manifest !== "object" || manifest === null || !("dependencies" in manifest)) return [];
|
|
43
|
+
const dependencies = manifest.dependencies;
|
|
44
|
+
if (typeof dependencies !== "object" || dependencies === null) return [];
|
|
45
|
+
return Object.values(dependencies).flatMap((spec) => {
|
|
46
|
+
if (typeof spec !== "string") return [];
|
|
47
|
+
if (spec.startsWith("file:")) {
|
|
48
|
+
const path = spec.slice("file:".length);
|
|
49
|
+
return isAbsolute(path) ? [path] : [path, resolve(root, path)];
|
|
50
|
+
}
|
|
51
|
+
return isAbsolute(spec) ? [spec] : [];
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A carried `registry`/`@scope:registry` value keeps only its scheme, host and path: user info, a query and a fragment
|
|
60
|
+
* can each carry a credential, so they are cleared with the WHATWG URL parser npm itself uses. The path is kept, since
|
|
61
|
+
* registries are addressed by it, so a registry that embeds a token in its path keeps it in the revision's private
|
|
62
|
+
* (0600, in a 0700 directory) copy. A value that does not parse is dropped, and so is one that names an environment
|
|
63
|
+
* variable (`${...}`): npm expands it at install time, and a parsed copy would be a different URL. Other carried keys
|
|
64
|
+
* pass unchanged.
|
|
65
|
+
*/
|
|
66
|
+
function withoutUrlCredentials(line: string): string | undefined {
|
|
67
|
+
const separator = line.indexOf("=");
|
|
68
|
+
const key = line.slice(0, separator).trim();
|
|
69
|
+
if (!key.endsWith("registry")) return line;
|
|
70
|
+
const raw = line.slice(separator + 1).trim();
|
|
71
|
+
if (raw.includes("${")) return undefined;
|
|
72
|
+
const quote = raw.length >= 2 && (raw[0] === '"' || raw[0] === "'") && raw.endsWith(raw[0]) ? raw[0] : "";
|
|
73
|
+
let url: URL;
|
|
74
|
+
try {
|
|
75
|
+
url = new URL(quote === "" ? raw : raw.slice(1, -1));
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
url.username = "";
|
|
80
|
+
url.password = "";
|
|
81
|
+
url.search = "";
|
|
82
|
+
url.hash = "";
|
|
83
|
+
return `${key}=${quote}${url.href}${quote}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Without its own `package.json`, bun walks up from the revision and installs into the nearest one above it. */
|
|
87
|
+
export async function seedPackageJson(root: string): Promise<void> {
|
|
88
|
+
await writeFile(join(root, "package.json"), '{"private":true}\n', { flag: "wx" }).catch((error: unknown) => {
|
|
89
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
90
|
+
});
|
|
91
|
+
}
|