@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10-11

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.
Files changed (127) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +161 -11
  3. package/package.json +6 -5
  4. package/src/bridge/memory-protocol.ts +8 -1
  5. package/src/bridge/protocol.ts +9 -4
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/bridges/eval-environment-schemas.ts +52 -0
  8. package/src/bridges/eval-virtual-schemas.ts +11 -4
  9. package/src/bridges/reserved-dispatch.ts +13 -1
  10. package/src/config/executable-settings.json +5 -0
  11. package/src/config/project-trust.ts +51 -0
  12. package/src/config/settings-overrides.ts +2 -0
  13. package/src/environments/editable-check.ts +1 -1
  14. package/src/environments/js-environments.ts +208 -0
  15. package/src/environments/js-installer.ts +268 -0
  16. package/src/environments/js-revision-files.ts +91 -0
  17. package/src/environments/no-links.ts +132 -0
  18. package/src/environments/packages-install.ts +98 -0
  19. package/src/environments/project-resolves.ts +54 -0
  20. package/src/environments/py-environment.ts +7 -3
  21. package/src/environments/py-installer.ts +5 -1
  22. package/src/environments/python-environments.ts +1 -1
  23. package/src/environments/revision-store.ts +20 -4
  24. package/src/extension/bridge-tool-call.ts +5 -1
  25. package/src/extension/eval-notifier.ts +13 -2
  26. package/src/extension/kernel-registration.ts +7 -2
  27. package/src/extension/runtime-factory.ts +51 -13
  28. package/src/extension/runtime-info.ts +6 -2
  29. package/src/extension/session-cwd.ts +25 -9
  30. package/src/extension/session-manager-contract.ts +3 -0
  31. package/src/extension/session-manager.ts +20 -6
  32. package/src/index.ts +3 -2
  33. package/src/interpreters/detect.ts +34 -1
  34. package/src/kernels/AGENTS.md +2 -0
  35. package/src/kernels/jl/runner.jl +11 -22
  36. package/src/kernels/js/cell-dispatch.ts +44 -0
  37. package/src/kernels/js/cell-ownership.js +268 -0
  38. package/src/kernels/js/cell-run-context.js +89 -0
  39. package/src/kernels/js/context-manager.ts +46 -51
  40. package/src/kernels/js/control-frames.ts +24 -0
  41. package/src/kernels/js/group-signal-notice.js +117 -0
  42. package/src/kernels/js/host-entries.ts +62 -0
  43. package/src/kernels/js/inline-worker.ts +2 -0
  44. package/src/kernels/js/kernel-contract.ts +16 -1
  45. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  46. package/src/kernels/js/kernel-memory.ts +79 -0
  47. package/src/kernels/js/kernel-tools-registry.js +1 -0
  48. package/src/kernels/js/local-module-loader.ts +25 -6
  49. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  50. package/src/kernels/js/process-drain-scan.js +28 -0
  51. package/src/kernels/js/process-entry.js +466 -0
  52. package/src/kernels/js/process-memory-host.ts +83 -0
  53. package/src/kernels/js/process-tree-host.ts +4 -8
  54. package/src/kernels/js/process-tree.d.ts +11 -0
  55. package/src/kernels/js/process-tree.js +55 -10
  56. package/src/kernels/js/process-worker.ts +298 -0
  57. package/src/kernels/js/rejection-reports.js +61 -0
  58. package/src/kernels/js/run-queue.ts +7 -0
  59. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  60. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  61. package/src/kernels/js/windows-tree-kill.js +100 -0
  62. package/src/kernels/js/worker-core.js +53 -8
  63. package/src/kernels/js/worker-cwd.js +46 -12
  64. package/src/kernels/js/worker-host.ts +9 -2
  65. package/src/kernels/js/worker-indirect-eval.js +37 -13
  66. package/src/kernels/js/worker-package-resolve.js +153 -0
  67. package/src/kernels/js/worker-require.d.ts +19 -0
  68. package/src/kernels/js/worker-require.js +52 -0
  69. package/src/kernels/js/worker-runtime.js +66 -13
  70. package/src/kernels/js/worker-shadow-guard.js +2 -6
  71. package/src/kernels/js/worker-shell-capture.js +9 -0
  72. package/src/kernels/js/worker-slot.ts +8 -0
  73. package/src/kernels/js/worker-startup.ts +49 -1
  74. package/src/kernels/js/worker-webview.js +15 -1
  75. package/src/kernels/py/host-cell.ts +8 -31
  76. package/src/kernels/py/prelude.py +69 -27
  77. package/src/kernels/py/process.ts +4 -0
  78. package/src/kernels/py/transport.ts +4 -0
  79. package/src/kernels/rb/runner.rb +9 -19
  80. package/src/kernels/sandbox/sandbox-cell.ts +141 -0
  81. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  82. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  83. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  84. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  85. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  86. package/src/kernels/shared/bun-ffi.ts +24 -0
  87. package/src/kernels/shared/host-cell.ts +57 -0
  88. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  89. package/src/kernels/shared/process-cpu.ts +7 -3
  90. package/src/kernels/shared/process-group-cpu.ts +6 -3
  91. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  92. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  93. package/src/kernels/shared/subprocess-process.ts +24 -2
  94. package/src/kernels/shared/subprocess-queue.ts +1 -0
  95. package/src/prompt/eval-prompt.ts +8 -1
  96. package/src/tool/AGENTS.md +1 -0
  97. package/src/tool/cell-handler.ts +12 -11
  98. package/src/tool/cell-reserved-dispatch.ts +1 -0
  99. package/src/tool/cell-runtime.ts +4 -2
  100. package/src/tool/detached-cell-contract.ts +3 -0
  101. package/src/tool/detached-cell-manager.ts +4 -8
  102. package/src/tool/detached-cell-notification.ts +11 -63
  103. package/src/tool/detached-cell-snapshot.ts +17 -0
  104. package/src/tool/detached-eval-result.ts +13 -7
  105. package/src/tool/detached-notification-queue.ts +1 -2
  106. package/src/tool/eval-request.ts +23 -1
  107. package/src/tool/eval-tool-options.ts +3 -0
  108. package/src/tool/eval-tool.ts +19 -5
  109. package/src/tool/interrupt-note.ts +4 -5
  110. package/src/tool/live-headline.ts +60 -0
  111. package/src/tool/load-cell.ts +18 -3
  112. package/src/tool/magic-cell-host.ts +60 -2
  113. package/src/tool/magic-cells.ts +12 -2
  114. package/src/tool/managed-cell.ts +0 -4
  115. package/src/tool/render-agent.ts +110 -0
  116. package/src/tool/render-blocks.ts +252 -0
  117. package/src/tool/render-call.ts +130 -0
  118. package/src/tool/render-cell.ts +165 -0
  119. package/src/tool/render-detail.ts +152 -0
  120. package/src/tool/render-live.ts +288 -0
  121. package/src/tool/render-result.ts +202 -0
  122. package/src/tool/render-status.ts +166 -0
  123. package/src/tool/render-tail.ts +139 -0
  124. package/src/tool/render.ts +3 -1069
  125. package/src/tool/run-eval-cell.ts +53 -16
  126. package/src/tool/runtime-label.ts +2 -1
  127. package/src/tool/types.ts +34 -3
@@ -0,0 +1,52 @@
1
+ import type { EvalSchemaResult } from "./schema-bridge.ts";
2
+
3
+ export type NamedEvalSchema = Extract<EvalSchemaResult, { readonly name: string }>;
4
+
5
+ /**
6
+ * The on-demand documentation for package environments and isolated cells (`tool_schema("eval:environments")` and
7
+ * `tool_schema("eval:isolation")`). Like the other virtual entries they are never listed, never run anything, and add
8
+ * nothing to the eval prompt or input schema. Every magic, code and message named here is the one the source emits.
9
+ */
10
+ export const environmentsEntry: NamedEvalSchema = {
11
+ name: "eval:environments",
12
+ description: [
13
+ "Host magic cells: a cell whose first code line (blank and comment lines skipped) is one of these runs on the host, in the language kernel's queue, so it orders with ordinary cells and a queued one is removed by stop like any queued cell. A magic followed by more code is refused: put it on its own cell, then run the code that uses it in the next cell.",
14
+ "py: %pip install <requirements ...> installs into the session's environment (a trailing backslash continues the line). Result: installed <packages> into <mode> (revision <n>); already-imported modules stay cached until reset.",
15
+ "js: %bun add <packages ...> or %npm add <packages ...> (install is accepted as a synonym of add). Result: added <packages> with <installer> into <mode>[ (revision <n>)]; already-imported modules stay cached until reset. A package the project's own node_modules still shadows is named with environment_resolution_conflict in the result.",
16
+ "py and js: %environment managed | project selects where installs go. managed (the default) uses per-session revisions under the session's artifacts directory (or environments.managedRoot) and never touches project files; project installs into the session directory (py: .senpi/python-packages; js: the project root).",
17
+ "py and js: %load <file> runs a local file as a cell, read when its turn comes in the queue, with the file's name in tracebacks; definitions persist like any cell. It accepts a path relative to the session directory, local:// or file://, reads files up to 8 MiB and never fetches remote URLs.",
18
+ "Installs pause the run budget like a bridge call, stream the installer's output into the cell, detach past the foreground window like any cell, and are stopped by stop.",
19
+ 'js and py: packages.install(manager, requirements, {timeout?}) runs the same install as a call instead of a magic: manager "pip" in a Python cell, "bun" or "npm" in a JavaScript cell; requirements is a non-empty string or a non-empty list of non-empty strings, and timeout is a positive number of seconds (default 600; py spells it packages.install("pip", [...], timeout=...)). pip receives the requirement list as separate arguments, never re-split; a js package spec cannot contain whitespace. It drives the same session environment the magic uses, returns that install\'s receipt (py: manager, mode, root, revision, requested, resolved, changed; js: installer, mode, revision, added, shadowed) and is cancelled by stop like a magic install. Unlike the magic, the installer\'s output is not streamed into the cell: the receipt is the result, and a failure carries the installer\'s stderr tail.',
20
+ "Install failures are cell errors that start with their code: environment_install_failed (an argument the host rejects: an unknown manager, an empty requirement list, a bun/npm requirement with whitespace or a non-positive timeout; or an installer failure, with the installer's stderr tail. A JS options value that is not an object, or a Python timeout that float() cannot convert, fails earlier in the cell with a plain TypeError or ValueError), environment_install_timeout (a packages.install() still running after its timeout, default 600 s, was stopped), environment_install_cancelled (stopped before or during the install), environment_installer_unavailable (no interpreter or no pip; no JavaScript package environment in this session; neither bun nor npm on PATH, or the chosen one missing; the installer failed to start; installs turned off with environments.autoProvision: false; or pip asked of a session with no Python interpreter, or bun/npm asked of a Python cell), environment_resolution_conflict (py: pip reported a dependency conflict). A %load failure, and a magic refused because more code follows it, are plain cell errors without a code.",
21
+ ].join("\n"),
22
+ parameters: {
23
+ type: "object",
24
+ properties: {
25
+ "%pip": { description: "py: %pip install <requirements ...>" },
26
+ "%bun": { description: "js: %bun add <packages ...>" },
27
+ "%npm": { description: "js: %npm add <packages ...>" },
28
+ "%environment": { enum: ["managed", "project"] },
29
+ "%load": { description: "py and js: %load <path | local://... | file://...>" },
30
+ "packages.install": {
31
+ description: "js and py: packages.install(manager, requirements, {timeout?}) (py: timeout=...)",
32
+ },
33
+ },
34
+ },
35
+ };
36
+
37
+ export const isolationEntry: NamedEvalSchema = {
38
+ name: "eval:isolation",
39
+ description: [
40
+ "isolate: true runs one JavaScript cell in a fresh QuickJS sandbox instead of the persistent kernel. It is available only when sandbox.enabled is on in the codemode settings; only then does the eval input schema carry the isolate field. An older senpi drops the field and would run the code as an ordinary cell, so check this entry before relying on it.",
41
+ 'Requests are refused with eval_isolate_invalid when sandbox cells are turned off, when language is not "js", when isolate is not a boolean, with reset: true (a sandbox cell always starts fresh), or when eval runs through a proxy that cannot isolate a cell.',
42
+ "An isolated cell shares nothing with the kernel. Its globals are print, display, text, image, console, exit, tool.<name>(args) (also reachable as tools), ALL_TOOLS (the names and descriptions of the tools it may call), load and store. Its tool calls go through the host, so permission hooks still apply, and tool calls still pending when the script settles are aborted. The store is rejected: load returns nothing and store fails with eval_isolate_no_state.",
43
+ "Limits: sandbox.timeoutSeconds (default 300) and sandbox.memoryMb (default 64; SENPI_CODEMODE_SANDBOX_MEMORY_MB overrides it). The result's runtime names quickjs and its version, with isolation sandbox.",
44
+ "Failures start with their code: eval_isolate_timeout, eval_isolate_aborted, eval_isolate_memory_limit, eval_isolate_unresolved_promise (the script ended with a promise still pending), eval_isolate_no_state, eval_isolate_unavailable (the sandbox itself failed: it could not load, or its worker crashed, exited or lost its output; if that happened after the script started, tool calls the cell made may already have run, so check their effects before retrying).",
45
+ ].join("\n"),
46
+ parameters: {
47
+ type: "object",
48
+ properties: {
49
+ isolate: { type: "boolean", description: 'true with language: "js" runs the cell in a fresh sandbox' },
50
+ },
51
+ },
52
+ };
@@ -1,3 +1,4 @@
1
+ import { environmentsEntry, isolationEntry, type NamedEvalSchema } from "./eval-environment-schemas.ts";
1
2
  import type { EvalSchemaResult } from "./schema-bridge.ts";
2
3
 
3
4
  /**
@@ -22,7 +23,7 @@ const handleRefSchema = {
22
23
  required: ["kind", "id", "run_epoch"],
23
24
  } as const;
24
25
 
25
- const waitEntry: EvalSchemaResult = {
26
+ const waitEntry: NamedEvalSchema = {
26
27
  name: "eval:wait",
27
28
  description: [
28
29
  "wait(handles, {timeout?, mode?}) blocks the cell until the given handles settle (js: await wait(...); py/rb: wait(...); jl: wait(handle(node)) or wait([handle(a), handle(b)])).",
@@ -44,7 +45,7 @@ const waitEntry: EvalSchemaResult = {
44
45
  },
45
46
  };
46
47
 
47
- const helpersEntry: EvalSchemaResult = {
48
+ const helpersEntry: NamedEvalSchema = {
48
49
  name: "eval:helpers",
49
50
  description: [
50
51
  "Advanced cell helpers (all four languages; js helpers are awaited).",
@@ -70,7 +71,7 @@ const helpersEntry: EvalSchemaResult = {
70
71
  },
71
72
  };
72
73
 
73
- const kernelToolsEntry: EvalSchemaResult = {
74
+ const kernelToolsEntry: NamedEvalSchema = {
74
75
  name: "eval:kernel-tools",
75
76
  description: [
76
77
  "Kernel tools: functions a cell defines that in-process children can call (js and py; rb/jl answer tools_unavailable).",
@@ -90,12 +91,18 @@ const kernelToolsEntry: EvalSchemaResult = {
90
91
  },
91
92
  };
92
93
 
93
- const VIRTUAL_ENTRIES: ReadonlyMap<string, EvalSchemaResult> = new Map([
94
+ const VIRTUAL_ENTRIES: ReadonlyMap<string, EvalSchemaResult> = new Map<string, EvalSchemaResult>([
94
95
  [waitEntry.name, waitEntry],
95
96
  [helpersEntry.name, helpersEntry],
96
97
  [kernelToolsEntry.name, kernelToolsEntry],
98
+ [environmentsEntry.name, environmentsEntry],
99
+ [isolationEntry.name, isolationEntry],
97
100
  ]);
98
101
 
99
102
  export function virtualEvalSchema(name: string): EvalSchemaResult | undefined {
100
103
  return VIRTUAL_ENTRIES.get(name);
101
104
  }
105
+
106
+ export function virtualEvalSchemaNames(): readonly string[] {
107
+ return [...VIRTUAL_ENTRIES.keys()];
108
+ }
@@ -1,5 +1,11 @@
1
1
  import type { AgentToolResult, EvalHandleHost } from "@code-yeongyu/senpi";
2
- import { RESERVED_AGENT_TOOL, RESERVED_OUTPUT_TOOL, RESERVED_SCHEMA_TOOL } from "../bridge/reserved.ts";
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,5 @@
1
+ {
2
+ "projectExecutableSettings": [
3
+ "languages.pyInterpreter"
4
+ ]
5
+ }
@@ -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]),
@@ -60,7 +60,7 @@ async function refuseOutsideLinks(root: string, dir: string): Promise<void> {
60
60
  export async function assertInstalledInRevision(stdout: string, staging: string): Promise<void> {
61
61
  const line = stdout.split("\n").find((entry) => entry.startsWith("Successfully installed "));
62
62
  if (line === undefined) return;
63
- const present = new Set((await readdir(staging).catch(() => [] as string[])).flatMap(installedName));
63
+ const present = new Set((await readdir(staging).catch((): string[] => [])).flatMap(installedName));
64
64
  for (const token of line.slice("Successfully installed ".length).trim().split(/\s+/u)) {
65
65
  const name = token.slice(0, token.lastIndexOf("-"));
66
66
  if (!present.has(canonicalName(name))) {
@@ -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
+ }