@alisio/core 0.8.0 → 0.10.0

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 (58) hide show
  1. package/dist/analysis/data/engine-client.d.ts +2 -0
  2. package/dist/analysis/data/engine-client.js +11 -4
  3. package/dist/application.d.ts +11 -1
  4. package/dist/application.js +46 -0
  5. package/dist/changelog/data.js +42 -0
  6. package/dist/commands/builtins.js +6 -0
  7. package/dist/commands/catalog.d.ts +4 -2
  8. package/dist/commands/catalog.js +23 -1
  9. package/dist/config.d.ts +31 -0
  10. package/dist/config.js +31 -1
  11. package/dist/context/elide.d.ts +50 -0
  12. package/dist/context/elide.js +222 -0
  13. package/dist/context/format.d.ts +21 -0
  14. package/dist/context/format.js +81 -0
  15. package/dist/context/governance.d.ts +58 -0
  16. package/dist/context/governance.js +96 -0
  17. package/dist/context/invariants.d.ts +88 -0
  18. package/dist/context/invariants.js +141 -0
  19. package/dist/context/inventory.d.ts +22 -0
  20. package/dist/context/inventory.js +178 -0
  21. package/dist/context/report.d.ts +35 -0
  22. package/dist/context/report.js +120 -0
  23. package/dist/context/sources.d.ts +20 -0
  24. package/dist/context/sources.js +41 -0
  25. package/dist/context/types.d.ts +43 -0
  26. package/dist/context/types.js +5 -0
  27. package/dist/context/usage.d.ts +96 -0
  28. package/dist/context/usage.js +264 -0
  29. package/dist/context/util.d.ts +22 -0
  30. package/dist/context/util.js +36 -0
  31. package/dist/core/compaction.d.ts +7 -2
  32. package/dist/core/compaction.js +9 -3
  33. package/dist/core/contracts.d.ts +8 -0
  34. package/dist/core/runner.d.ts +67 -0
  35. package/dist/core/runner.js +272 -10
  36. package/dist/eval/compare.d.ts +1 -1
  37. package/dist/eval/compare.js +18 -1
  38. package/dist/eval/engine.js +31 -1
  39. package/dist/eval/report.js +44 -1
  40. package/dist/forge/javascript/hosts/process-host.d.ts +1 -1
  41. package/dist/forge/javascript/hosts/process-host.js +4 -1
  42. package/dist/forge/python/wasm-node.d.ts +1 -1
  43. package/dist/forge/python/wasm-node.js +4 -1
  44. package/dist/index.d.ts +8 -2
  45. package/dist/index.js +8 -2
  46. package/dist/plugins/host.d.ts +5 -0
  47. package/dist/plugins/host.js +15 -1
  48. package/dist/plugins/install.d.ts +50 -2
  49. package/dist/plugins/install.js +102 -12
  50. package/dist/plugins/local-install.d.ts +33 -0
  51. package/dist/plugins/local-install.js +121 -0
  52. package/dist/plugins/npm-runner.d.ts +30 -0
  53. package/dist/plugins/npm-runner.js +66 -0
  54. package/dist/plugins/tarball.d.ts +39 -0
  55. package/dist/plugins/tarball.js +277 -0
  56. package/dist/plugins/version-compat.d.ts +20 -0
  57. package/dist/plugins/version-compat.js +154 -0
  58. package/package.json +2 -2
package/dist/index.d.ts CHANGED
@@ -32,6 +32,8 @@ export { BackgroundTaskStore, isLive as isTaskLive, isTerminal as isTaskTerminal
32
32
  export { changelogNews, compareVersions, formatChangelogMarkdown, isVersion, loadChangelog, type NewsDecision, parseChangelog, type SelectedEntries, selectEntries, UNRELEASED, } from "./changelog/index.ts";
33
33
  export { BUILTIN_COMMANDS, type BuiltinCommand, CommandCatalog, type CommandExecutionContext, type CommandHost, type CommandResult, type CommandSurface, } from "./commands/catalog.ts";
34
34
  export { type Config, type ConfigLoadResult, type ConfigProvenance, configFile, configHome, configSchema, isSettableSettingKey, type LoadedConfig, loadConfig, loadConfigWithProvenance, type McpServerSource, OCI_IMAGE_PATTERN, overridesSavedProviderProfile, type SettableSettingInfo, type SettableSettingKey, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, settableSettings, stateHome, } from "./config.ts";
35
+ export { formatContextGovernorStatsSection, formatContextReport, notableSegments, } from "./context/format.ts";
36
+ export { buildContextReport, MAX_REPORT_SEGMENTS } from "./context/report.ts";
35
37
  export { type ContextBudget, checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.ts";
36
38
  export type { ApprovalDecision, ApprovalHandler, ApprovalRequest, ArtifactPublisherFactory, BeginRunInput, CapabilityGate, ContextSource, CoreArtifactPublisher, DecisionsFactory, EndRunInput, EventPage, HookFailure, MessagePage, PageOptions, Policy, PublishedArtifactInfo, RunnerExtensions, RunRecord, RunStatus, Session, SessionStore, StoredEvent, TerminalRunStatus, ToolCallMeta, } from "./core/contracts.ts";
37
39
  export { HumanWaits } from "./core/human-wait.ts";
@@ -71,8 +73,12 @@ export { describePermissionStatus, type EffectState, effectState, FULL_ACCESS_WA
71
73
  export { DIAGRAM_MAX_BYTES, DIAGRAM_MAX_NODES, DIAGRAM_SYNTAXES, DIAGRAMS_MAX_ALLOWED, mermaidProblem, type PlanDiagram, validateDiagrams, } from "./plan/diagrams.ts";
72
74
  export { buildPlanManifest, parsePlanHeadings, slugifyHeading, } from "./plan/manifest.ts";
73
75
  export { claimApprovedPlan, currentPlan, discardApprovedPlan, IMPLEMENTATION_AGENT_ID, implementationPrompt, PLAN_CONTEXT_MAX_CHARS, PLAN_CRITERIA_KEY, PLAN_OPTIONS, PLAN_QUESTION_ID, PLAN_REVIEW_TITLE, PLAN_TEXT_KEY, type PlanFollowUp, type PlanState, type PlanStatus, planHash, readPlanState, settlePlanRun, withdrawPendingPlan, } from "./plan/state.ts";
74
- export { discoverPlugins, type PluginEnvironment, PluginHost, type PluginHostOptions, pluginPrefix, type ViewInfo, ViewRunError, } from "./plugins/host.ts";
75
- export { type CliInstallOptions, cliInstall, cliUpdateAll, INSTALL_TIMEOUT_MS, type InstallPluginInput, type InstallPluginResult, type InstallRunner, installedGlobalPluginPackages, installedNpmPlugins, installPlugin, isPeerConflict, npmInstallTarget, type PluginSpec, parsePluginSpec, pluginTrustRemark, printInstallResult, registerPluginInstallTool, sanitizeNpmError, updateAllPlugins, } from "./plugins/install.ts";
76
+ export { discoverPlugins, type PluginEnvironment, PluginHost, type PluginHostOptions, pluginCodeImportedFrom, pluginPrefix, type ViewInfo, ViewRunError, } from "./plugins/host.ts";
77
+ export { type CliInstallOptions, cliInstall, cliUpdateAll, INSTALL_TIMEOUT_MS, type InstallPluginInput, type InstallPluginResult, type InstallRunner, installedGlobalPluginPackages, installedGlobalPlugins, installedNpmPlugins, installPlugin, isPeerConflict, npmInstallTarget, type PluginSpec, parsePluginSpec, pluginDependencySpec, pluginTrustRemark, printInstallResult, registerPluginInstallTool, sanitizeNpmError, type UninstallPluginInput, type UninstallPluginResult, uninstallPlugin, updateAllPlugins, } from "./plugins/install.ts";
78
+ export { type InstallLocalPluginInput, type InstallLocalPluginResult, installLocalPluginTarball, LocalInstallError, type LocalInstallPhase, storedTarballName, } from "./plugins/local-install.ts";
79
+ export { NPM_NOT_FOUND_MESSAGE, type NpmInvocation, resolveNpmInvocation, runNpm, } from "./plugins/npm-runner.ts";
80
+ export { inspectPluginTarball, MAX_TARBALL_BYTES, type PluginTarballInfo, readTarballPackageJson, type TarballLimits, } from "./plugins/tarball.ts";
81
+ export { type CatalogPluginState, type CatalogPluginStateInput, catalogPluginState, comparePluginVersions, satisfiesRange, } from "./plugins/version-compat.ts";
76
82
  export * from "./proof/index.ts";
77
83
  export { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.ts";
78
84
  export { availableProviderModels, type ProviderModelCatalog, resolveProviderModel, } from "./providers/routing.ts";
package/dist/index.js CHANGED
@@ -32,6 +32,8 @@ export { BackgroundTaskStore, isLive as isTaskLive, isTerminal as isTaskTerminal
32
32
  export { changelogNews, compareVersions, formatChangelogMarkdown, isVersion, loadChangelog, parseChangelog, selectEntries, UNRELEASED, } from "./changelog/index.js";
33
33
  export { BUILTIN_COMMANDS, CommandCatalog, } from "./commands/catalog.js";
34
34
  export { configFile, configHome, configSchema, isSettableSettingKey, loadConfig, loadConfigWithProvenance, OCI_IMAGE_PATTERN, overridesSavedProviderProfile, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, settableSettings, stateHome, } from "./config.js";
35
+ export { formatContextGovernorStatsSection, formatContextReport, notableSegments, } from "./context/format.js";
36
+ export { buildContextReport, MAX_REPORT_SEGMENTS } from "./context/report.js";
35
37
  export { checkpointInstructions, completeText, effectiveContextBudget, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, renderCheckpoint, serializeForSummary, shouldCompact, shouldCompactContext, summarize, } from "./core/compaction.js";
36
38
  export { HumanWaits } from "./core/human-wait.js";
37
39
  export { EXIT_PLAN_TOOL, GOAL_TOOLS, OPT_IN_TOOLS } from "./core/opt-in.js";
@@ -70,8 +72,12 @@ export { describePermissionStatus, effectState, FULL_ACCESS_WARNING, modeFromFla
70
72
  export { DIAGRAM_MAX_BYTES, DIAGRAM_MAX_NODES, DIAGRAM_SYNTAXES, DIAGRAMS_MAX_ALLOWED, mermaidProblem, validateDiagrams, } from "./plan/diagrams.js";
71
73
  export { buildPlanManifest, parsePlanHeadings, slugifyHeading, } from "./plan/manifest.js";
72
74
  export { claimApprovedPlan, currentPlan, discardApprovedPlan, IMPLEMENTATION_AGENT_ID, implementationPrompt, PLAN_CONTEXT_MAX_CHARS, PLAN_CRITERIA_KEY, PLAN_OPTIONS, PLAN_QUESTION_ID, PLAN_REVIEW_TITLE, PLAN_TEXT_KEY, planHash, readPlanState, settlePlanRun, withdrawPendingPlan, } from "./plan/state.js";
73
- export { discoverPlugins, PluginHost, pluginPrefix, ViewRunError, } from "./plugins/host.js";
74
- export { cliInstall, cliUpdateAll, INSTALL_TIMEOUT_MS, installedGlobalPluginPackages, installedNpmPlugins, installPlugin, isPeerConflict, npmInstallTarget, parsePluginSpec, pluginTrustRemark, printInstallResult, registerPluginInstallTool, sanitizeNpmError, updateAllPlugins, } from "./plugins/install.js";
75
+ export { discoverPlugins, PluginHost, pluginCodeImportedFrom, pluginPrefix, ViewRunError, } from "./plugins/host.js";
76
+ export { cliInstall, cliUpdateAll, INSTALL_TIMEOUT_MS, installedGlobalPluginPackages, installedGlobalPlugins, installedNpmPlugins, installPlugin, isPeerConflict, npmInstallTarget, parsePluginSpec, pluginDependencySpec, pluginTrustRemark, printInstallResult, registerPluginInstallTool, sanitizeNpmError, uninstallPlugin, updateAllPlugins, } from "./plugins/install.js";
77
+ export { installLocalPluginTarball, LocalInstallError, storedTarballName, } from "./plugins/local-install.js";
78
+ export { NPM_NOT_FOUND_MESSAGE, resolveNpmInvocation, runNpm, } from "./plugins/npm-runner.js";
79
+ export { inspectPluginTarball, MAX_TARBALL_BYTES, readTarballPackageJson, } from "./plugins/tarball.js";
80
+ export { catalogPluginState, comparePluginVersions, satisfiesRange, } from "./plugins/version-compat.js";
75
81
  export * from "./proof/index.js";
76
82
  export { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.js";
77
83
  export { availableProviderModels, resolveProviderModel, } from "./providers/routing.js";
@@ -37,6 +37,11 @@ export interface ViewInfo {
37
37
  description: string;
38
38
  params: Record<string, unknown>;
39
39
  }
40
+ /**
41
+ * Whether any plugin file under `dir` was already imported in this process. Such code stays cached
42
+ * after the package is updated on disk, so the new version only loads after a restart.
43
+ */
44
+ export declare function pluginCodeImportedFrom(dir: string): boolean;
40
45
  export declare class PluginHost implements RunnerExtensions {
41
46
  private registry;
42
47
  private state;
@@ -9,7 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { createHash } from "node:crypto";
10
10
  import { mkdirSync } from "node:fs";
11
11
  import { readdir, stat } from "node:fs/promises";
12
- import { dirname, join, resolve } from "node:path";
12
+ import { dirname, join, resolve, sep } from "node:path";
13
13
  import { pathToFileURL } from "node:url";
14
14
  import Ajv from "ajv";
15
15
  import { z } from "zod";
@@ -53,6 +53,19 @@ export class ViewRunError extends Error {
53
53
  }
54
54
  }
55
55
  const message = (error) => (error instanceof Error ? error.message : String(error));
56
+ /** Entry files of plugins imported by this process (Node caches an ES module for its lifetime). */
57
+ const importedPluginFiles = new Set();
58
+ /**
59
+ * Whether any plugin file under `dir` was already imported in this process. Such code stays cached
60
+ * after the package is updated on disk, so the new version only loads after a restart.
61
+ */
62
+ export function pluginCodeImportedFrom(dir) {
63
+ const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
64
+ for (const file of importedPluginFiles)
65
+ if (file.startsWith(prefix))
66
+ return true;
67
+ return false;
68
+ }
56
69
  export class PluginHost {
57
70
  registry;
58
71
  state;
@@ -222,6 +235,7 @@ export class PluginHost {
222
235
  let module;
223
236
  try {
224
237
  module = await import(__rewriteRelativeImportExtension(pathToFileURL(path).href));
238
+ importedPluginFiles.add(path);
225
239
  }
226
240
  catch (error) {
227
241
  if (error.code === "ERR_UNKNOWN_FILE_EXTENSION")
@@ -1,5 +1,5 @@
1
1
  import type { ToolRegistry } from "../core/registry.ts";
2
- import { type ProcessResult } from "../runtime/process.ts";
2
+ import type { ProcessResult } from "../runtime/process.ts";
3
3
  export interface PluginSpec {
4
4
  name: string;
5
5
  version?: string;
@@ -22,6 +22,7 @@ export type InstallRunner = (command: string, args: string[], options: {
22
22
  env?: Record<string, string>;
23
23
  }) => Promise<ProcessResult>;
24
24
  export declare const INSTALL_TIMEOUT_MS = 300000;
25
+ export declare const defaultInstallRunner: InstallRunner;
25
26
  export interface InstallPluginInput {
26
27
  /** `npm:<package>[@<version>]` or a bare package name. */
27
28
  spec: string;
@@ -29,6 +30,11 @@ export interface InstallPluginInput {
29
30
  configHome?: string;
30
31
  /** Refresh an already-installed package to `@latest`, keeping the npm name in the config. */
31
32
  update?: boolean;
33
+ /**
34
+ * Run npm even when the package is already installed, at exactly `spec`'s version (an explicit
35
+ * reinstall or downgrade from the catalog). `update` instead takes `@latest`.
36
+ */
37
+ force?: boolean;
32
38
  /** Refuse immediately; loading and installing executable plugins is disabled under --read-only. */
33
39
  readOnly?: boolean;
34
40
  signal?: AbortSignal;
@@ -48,7 +54,9 @@ export interface InstallPluginResult {
48
54
  pluginsDir: string;
49
55
  /** Already present in the global plugins directory before this call (no npm run performed). */
50
56
  alreadyInstalled: boolean;
51
- /** Refreshed to `@latest` by this call. */
57
+ /** Version that was installed before this call, when there was one. */
58
+ previousVersion?: string;
59
+ /** Refreshed (to `@latest`, or reinstalled at the requested version) by this call. */
52
60
  updated: boolean;
53
61
  /** Plain-text remark about where the plugin loads and how project trust applies. */
54
62
  trustRemark: string;
@@ -65,8 +73,48 @@ export declare function isPeerConflict(output: string): boolean;
65
73
  * `<pluginsDir>/node_modules` (names only; file/directory entries are ignored).
66
74
  */
67
75
  export declare function installedGlobalPluginPackages(home: string): Promise<string[]>;
76
+ /** Atomically adds `packageName` to the global configuration `plugins` array, preserving all other fields. */
77
+ export declare function persistGlobalPluginEntry(configHomeDir: string, packageName: string): Promise<string>;
78
+ /** Atomically removes `packageName` from the global configuration `plugins` array. */
79
+ export declare function removeGlobalPluginEntry(configHomeDir: string, packageName: string): Promise<boolean>;
80
+ /** The dependency spec npm recorded for `name` in `<pluginsDir>/package.json`, if any. */
81
+ export declare function pluginDependencySpec(pluginsDir: string, name: string): Promise<string | undefined>;
82
+ export declare const LOCAL_PACKAGES_DIR = "local-packages";
83
+ /**
84
+ * The stored tarball a `file:` dependency spec points at, only when it lives inside
85
+ * `<pluginsDir>/local-packages` (anything else, including `..` escapes, is never deleted by us).
86
+ */
87
+ export declare function storedTarballOf(pluginsDir: string, spec: string | undefined): string | undefined;
68
88
  /** The shared install routine used by both the CLI command and the `plugin_install` tool. */
69
89
  export declare function installPlugin(input: InstallPluginInput): Promise<InstallPluginResult>;
90
+ /** Globally installed plugin packages (configured and present on disk) with their versions. */
91
+ export declare function installedGlobalPlugins(home: string): Promise<Array<{
92
+ name: string;
93
+ version: string;
94
+ }>>;
95
+ export interface UninstallPluginInput {
96
+ /** The npm package name (no `npm:` prefix, no version). */
97
+ packageName: string;
98
+ /** Defaults to `configHome()` (ALISIO_CONFIG_HOME, else ~/.config/alisio). */
99
+ configHome?: string;
100
+ readOnly?: boolean;
101
+ signal?: AbortSignal;
102
+ /** Injected npm for tests; defaults to the real (or bundled) npm. */
103
+ runner?: InstallRunner;
104
+ }
105
+ export interface UninstallPluginResult {
106
+ packageName: string;
107
+ /** The package was installed or configured before this call (false = nothing to do). */
108
+ removed: boolean;
109
+ pluginsDir: string;
110
+ configFile: string;
111
+ }
112
+ /**
113
+ * Removes a package from the global plugins directory (`npm uninstall --prefix`) and its name from
114
+ * the global configuration `plugins` array. Idempotent: an absent package only cleans a stale
115
+ * config entry. The config is untouched when npm fails.
116
+ */
117
+ export declare function uninstallPlugin(input: UninstallPluginInput): Promise<UninstallPluginResult>;
70
118
  export interface UpdateAllPluginsInput {
71
119
  configHome?: string;
72
120
  readOnly?: boolean;
@@ -9,13 +9,13 @@
9
9
  * still follows the existing executable-plugin policy: it runs in-process wherever it is loaded and
10
10
  * is disabled entirely under `--read-only`.
11
11
  */
12
- import { chmod, mkdir, open, readdir, rename, unlink } from "node:fs/promises";
12
+ import { chmod, mkdir, open, readdir, rename, rm, unlink } from "node:fs/promises";
13
13
  import { dirname, join, resolve } from "node:path";
14
14
  import { textResult } from "@alisio/sdk";
15
15
  import { configHome } from "../config.js";
16
16
  import { readJson } from "../runtime/fs.js";
17
17
  import { PLUGIN_KEYWORD } from "../runtime/modules.js";
18
- import { runProcess } from "../runtime/process.js";
18
+ import { runNpm } from "./npm-runner.js";
19
19
  /** npm package name: scope (optional) followed by the name; both start with an alphanumeric. */
20
20
  const PACKAGE_PART = "[A-Za-z0-9][A-Za-z0-9._-]*";
21
21
  const NAME_RE = new RegExp(`^(?:@${PACKAGE_PART}\\/)?${PACKAGE_PART}$`);
@@ -67,7 +67,7 @@ export function npmInstallTarget(name, version, update = false) {
67
67
  return update ? `${name}@latest` : version ? `${name}@${version}` : name;
68
68
  }
69
69
  export const INSTALL_TIMEOUT_MS = 300_000;
70
- const defaultRunner = (command, args, options) => runProcess(command, args, { ...options, timeoutMs: INSTALL_TIMEOUT_MS, maxBytes: 64_000 });
70
+ export const defaultInstallRunner = (command, args, options) => runNpm(command, args, options, { timeoutMs: INSTALL_TIMEOUT_MS, maxBytes: 64_000 });
71
71
  /**
72
72
  * Redacts probable secrets from npm output (auth tokens, JWTs, bearer credentials, userinfo in
73
73
  * URLs) and caps the size, so a failing install never echoes credentials to the user.
@@ -124,20 +124,29 @@ single) {
124
124
  return new Error(`${head}Retry with: ${retry}` +
125
125
  (single ? `\n(or: alisio install npm:${single})` : "\n(or: alisio install --update)"));
126
126
  }
127
- /** Atomically adds `packageName` to the global configuration `plugins` array, preserving all other fields. */
128
- async function persistGlobalPluginEntry(configHomeDir, packageName) {
127
+ /**
128
+ * Atomically rewrites the global configuration `plugins` array through `update`, preserving every
129
+ * other field. Returns the file path and whether the array changed (no write when it did not).
130
+ */
131
+ async function updateGlobalPluginEntries(configHomeDir, update, options) {
129
132
  const file = join(configHomeDir, "config.json");
130
133
  const existing = await readJson(file);
131
134
  let raw;
132
- if (existing === undefined)
135
+ if (existing === undefined) {
136
+ if (!options.createIfMissing)
137
+ return { file, changed: false };
133
138
  raw = {};
139
+ }
134
140
  else if (typeof existing === "object" && !Array.isArray(existing))
135
141
  raw = existing;
136
142
  else
137
143
  throw new Error(`Global Alisio configuration must be a JSON object: ${file}`);
138
144
  const current = Array.isArray(raw.plugins) ? raw.plugins : [];
139
- if (!current.includes(packageName))
140
- raw.plugins = [...current, packageName];
145
+ const next = update(current);
146
+ const changed = next.length !== current.length || next.some((entry, i) => entry !== current[i]);
147
+ if (!changed && existing !== undefined)
148
+ return { file, changed: false };
149
+ raw.plugins = next;
141
150
  raw.schemaVersion ??= 1;
142
151
  await mkdir(dirname(file), { recursive: true, mode: 0o700 });
143
152
  const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
@@ -157,8 +166,38 @@ async function persistGlobalPluginEntry(configHomeDir, packageName) {
157
166
  await unlink(temporary).catch(() => { });
158
167
  throw error;
159
168
  }
169
+ return { file, changed: true };
170
+ }
171
+ /** Atomically adds `packageName` to the global configuration `plugins` array, preserving all other fields. */
172
+ export async function persistGlobalPluginEntry(configHomeDir, packageName) {
173
+ const { file } = await updateGlobalPluginEntries(configHomeDir, (current) => (current.includes(packageName) ? current : [...current, packageName]), { createIfMissing: true });
160
174
  return file;
161
175
  }
176
+ /** Atomically removes `packageName` from the global configuration `plugins` array. */
177
+ export async function removeGlobalPluginEntry(configHomeDir, packageName) {
178
+ const { changed } = await updateGlobalPluginEntries(configHomeDir, (current) => current.filter((entry) => entry !== packageName), { createIfMissing: false });
179
+ return changed;
180
+ }
181
+ /** The dependency spec npm recorded for `name` in `<pluginsDir>/package.json`, if any. */
182
+ export async function pluginDependencySpec(pluginsDir, name) {
183
+ const manifest = (await readJson(join(pluginsDir, "package.json")).catch(() => undefined));
184
+ const spec = manifest?.dependencies?.[name];
185
+ return typeof spec === "string" ? spec : undefined;
186
+ }
187
+ export const LOCAL_PACKAGES_DIR = "local-packages";
188
+ /**
189
+ * The stored tarball a `file:` dependency spec points at, only when it lives inside
190
+ * `<pluginsDir>/local-packages` (anything else, including `..` escapes, is never deleted by us).
191
+ */
192
+ export function storedTarballOf(pluginsDir, spec) {
193
+ if (!spec?.startsWith("file:"))
194
+ return undefined;
195
+ const target = resolve(pluginsDir, spec.slice(5));
196
+ const root = join(pluginsDir, LOCAL_PACKAGES_DIR);
197
+ if (dirname(target) !== root || !/\.(tgz|tar\.gz)$/.test(target))
198
+ return undefined;
199
+ return target;
200
+ }
162
201
  /** The shared install routine used by both the CLI command and the `plugin_install` tool. */
163
202
  export async function installPlugin(input) {
164
203
  const parsed = parsePluginSpec(input.spec);
@@ -172,9 +211,9 @@ export async function installPlugin(input) {
172
211
  const installedManifest = (await readJson(join(packageDir, "package.json")));
173
212
  const alreadyInstalled = !!installedManifest?.version;
174
213
  let updated = false;
175
- if (!alreadyInstalled || input.update) {
214
+ if (!alreadyInstalled || input.update || input.force) {
176
215
  const signal = input.signal ?? AbortSignal.timeout(INSTALL_TIMEOUT_MS);
177
- const run = input.runner ?? defaultRunner;
216
+ const run = input.runner ?? defaultInstallRunner;
178
217
  const npmResult = await run("npm", [
179
218
  "install",
180
219
  "--prefix",
@@ -183,7 +222,7 @@ export async function installPlugin(input) {
183
222
  ], { cwd: pluginsDir, signal });
184
223
  if (npmResult.exitCode !== 0)
185
224
  throw npmInstallError(`plugin "${parsed.name}"`, [npmInstallTarget(parsed.name, parsed.version, input.update)], pluginsDir, npmResult, await installedGlobalPluginPackages(home), npmInstallTarget(parsed.name, parsed.version));
186
- updated = !!input.update && alreadyInstalled;
225
+ updated = (!!input.update || !!input.force) && alreadyInstalled;
187
226
  }
188
227
  const manifest = (await readJson(join(packageDir, "package.json")));
189
228
  const installedVersion = manifest?.version ?? "unknown";
@@ -196,10 +235,61 @@ export async function installPlugin(input) {
196
235
  configFile,
197
236
  pluginsDir,
198
237
  alreadyInstalled,
238
+ ...(installedManifest?.version ? { previousVersion: installedManifest.version } : {}),
199
239
  updated,
200
240
  trustRemark: pluginTrustRemark(parsed.name),
201
241
  };
202
242
  }
243
+ /** Globally installed plugin packages (configured and present on disk) with their versions. */
244
+ export async function installedGlobalPlugins(home) {
245
+ const result = [];
246
+ for (const name of await installedGlobalPluginPackages(home)) {
247
+ const manifest = (await readJson(join(home, "plugins", "node_modules", name, "package.json")));
248
+ result.push({ name, version: manifest?.version ?? "unknown" });
249
+ }
250
+ return result;
251
+ }
252
+ /**
253
+ * Removes a package from the global plugins directory (`npm uninstall --prefix`) and its name from
254
+ * the global configuration `plugins` array. Idempotent: an absent package only cleans a stale
255
+ * config entry. The config is untouched when npm fails.
256
+ */
257
+ export async function uninstallPlugin(input) {
258
+ const parsed = parsePluginSpec(input.packageName);
259
+ if (parsed.version !== undefined || input.packageName.trim().startsWith("npm:"))
260
+ throw new Error(`Invalid package name "${input.packageName}": pass the bare npm package name, without "npm:" or a version`);
261
+ if (input.readOnly)
262
+ throw new Error(`Uninstalling npm plugin "${parsed.name}" is unavailable under --read-only: it runs npm and writes your global configuration. Run without --read-only.`);
263
+ const home = resolve(input.configHome ?? configHome());
264
+ const pluginsDir = join(home, "plugins");
265
+ const packageDir = join(pluginsDir, "node_modules", parsed.name);
266
+ const manifest = (await readJson(join(packageDir, "package.json")));
267
+ const present = manifest !== undefined;
268
+ const dependency = await pluginDependencySpec(pluginsDir, parsed.name);
269
+ const stored = storedTarballOf(pluginsDir, dependency);
270
+ if (present || dependency !== undefined) {
271
+ const signal = input.signal ?? AbortSignal.timeout(INSTALL_TIMEOUT_MS);
272
+ const run = input.runner ?? defaultInstallRunner;
273
+ const result = await run("npm", ["uninstall", "--prefix", pluginsDir, parsed.name], {
274
+ cwd: pluginsDir,
275
+ signal,
276
+ });
277
+ if (result.exitCode !== 0) {
278
+ const detail = sanitizeNpmError([result.stdout, result.stderr].filter(Boolean).join("\n"));
279
+ throw new Error(`npm uninstall failed for plugin "${parsed.name}" (exit ${result.exitCode}).\n${detail || "no output"}`);
280
+ }
281
+ }
282
+ const { file, changed } = await updateGlobalPluginEntries(home, (current) => current.filter((entry) => entry !== parsed.name), { createIfMissing: false });
283
+ // The copy kept for a local-file install goes with the package.
284
+ if (stored)
285
+ await rm(stored, { force: true });
286
+ return {
287
+ packageName: parsed.name,
288
+ removed: present || changed || stored !== undefined,
289
+ pluginsDir,
290
+ configFile: file,
291
+ };
292
+ }
203
293
  /**
204
294
  * Refreshes every plugin package installed in the global plugins directory in ONE
205
295
  * `npm install --prefix <plugins> <pkg>@latest ...` call, which lets npm resolve all peer ranges
@@ -214,7 +304,7 @@ export async function updateAllPlugins(input) {
214
304
  if (names.length === 0)
215
305
  return { packages: [], pluginsDir };
216
306
  const signal = input.signal ?? AbortSignal.timeout(INSTALL_TIMEOUT_MS);
217
- const run = input.runner ?? defaultRunner;
307
+ const run = input.runner ?? defaultInstallRunner;
218
308
  const targets = names.map((name) => npmInstallTarget(name, undefined, true));
219
309
  const npmResult = await run("npm", ["install", "--prefix", pluginsDir, ...targets], {
220
310
  cwd: pluginsDir,
@@ -0,0 +1,33 @@
1
+ import { type InstallRunner } from "./install.ts";
2
+ import { type PluginTarballInfo } from "./tarball.ts";
3
+ export type LocalInstallPhase = "validating" | "copying" | "installing" | "registering";
4
+ export interface InstallLocalPluginInput {
5
+ tarballPath: string;
6
+ configHome?: string;
7
+ readOnly?: boolean;
8
+ signal?: AbortSignal;
9
+ runner?: InstallRunner;
10
+ /** Called as each phase starts. */
11
+ onPhase?: (phase: LocalInstallPhase) => void;
12
+ }
13
+ export interface InstallLocalPluginResult {
14
+ packageName: string;
15
+ installedVersion: string;
16
+ previousVersion?: string;
17
+ /** A version of the package was installed before this call. */
18
+ alreadyInstalled: boolean;
19
+ updated: boolean;
20
+ storedPath: string;
21
+ /** The dependency spec npm records, relative to the plugins directory. */
22
+ spec: string;
23
+ pluginsDir: string;
24
+ configFile: string;
25
+ info: PluginTarballInfo;
26
+ }
27
+ /** An install failure that carries the tail of npm's output for the UI. */
28
+ export declare class LocalInstallError extends Error {
29
+ readonly logTail?: string | undefined;
30
+ constructor(message: string, logTail?: string | undefined);
31
+ }
32
+ export declare const storedTarballName: (name: string, version: string) => string;
33
+ export declare function installLocalPluginTarball(input: InstallLocalPluginInput): Promise<InstallLocalPluginResult>;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Installs a plugin from a local `.tgz` (`npm pack` / `pnpm pack`): private plugins that are never
3
+ * published. The validated tarball is copied into `<pluginsDir>/local-packages/` and npm installs
4
+ * FROM THAT COPY, so the `file:` dependency npm records in `<pluginsDir>/package.json` is stable
5
+ * and self-contained: moving or deleting the original file cannot break a later install of
6
+ * another plugin. Any failure rolls back (stored copy, partial package, config entry).
7
+ */
8
+ import { chmod, copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
9
+ import { join, resolve } from "node:path";
10
+ import { configHome } from "../config.js";
11
+ import { readJson } from "../runtime/fs.js";
12
+ import { defaultInstallRunner, INSTALL_TIMEOUT_MS, LOCAL_PACKAGES_DIR, persistGlobalPluginEntry, pluginDependencySpec, removeGlobalPluginEntry, sanitizeNpmError, storedTarballOf, } from "./install.js";
13
+ import { inspectPluginTarball } from "./tarball.js";
14
+ /** An install failure that carries the tail of npm's output for the UI. */
15
+ export class LocalInstallError extends Error {
16
+ logTail;
17
+ constructor(message, logTail) {
18
+ super(message);
19
+ this.logTail = logTail;
20
+ }
21
+ }
22
+ const TAIL_CHARS = 1500;
23
+ const safePart = (text) => text.replace(/[^A-Za-z0-9._-]/g, "_");
24
+ export const storedTarballName = (name, version) => `${safePart(name.replace(/^@/, "").replace(/\//g, "-"))}-${safePart(version)}.tgz`;
25
+ export async function installLocalPluginTarball(input) {
26
+ if (input.readOnly)
27
+ throw new Error("Installing a plugin from a file is unavailable under --read-only: it runs npm and writes your global configuration. Run without --read-only.");
28
+ input.onPhase?.("validating");
29
+ const info = await inspectPluginTarball(input.tarballPath);
30
+ if (!info.isAlisioPlugin || !info.name || !info.version)
31
+ throw new Error(`This file is not a valid Alisio plugin package: ${info.reasons.join("; ")}`);
32
+ const name = info.name;
33
+ const version = info.version;
34
+ const home = resolve(input.configHome ?? configHome());
35
+ const pluginsDir = join(home, "plugins");
36
+ const localDir = join(pluginsDir, LOCAL_PACKAGES_DIR);
37
+ const packageDir = join(pluginsDir, "node_modules", name);
38
+ const run = input.runner ?? defaultInstallRunner;
39
+ const signal = input.signal ?? AbortSignal.timeout(INSTALL_TIMEOUT_MS);
40
+ const previous = (await readJson(join(packageDir, "package.json")));
41
+ const previousSpec = await pluginDependencySpec(pluginsDir, name);
42
+ const previousStored = storedTarballOf(pluginsDir, previousSpec);
43
+ // What to reinstall from if an update fails half-way: our stored copy, or the external file a
44
+ // plugin installed by hand still points at.
45
+ const previousSource = previousStored ??
46
+ (previousSpec?.startsWith("file:") ? resolve(pluginsDir, previousSpec.slice(5)) : undefined);
47
+ const snapshot = async (file) => readFile(file, "utf8").catch(() => undefined);
48
+ const manifestFiles = ["package.json", "package-lock.json"].map((f) => join(pluginsDir, f));
49
+ const snapshots = await Promise.all(manifestFiles.map(snapshot));
50
+ const configBefore = (await readJson(join(home, "config.json")));
51
+ const inConfig = Array.isArray(configBefore?.plugins) && configBefore.plugins.includes(name);
52
+ input.onPhase?.("copying");
53
+ await mkdir(localDir, { recursive: true, mode: 0o700 });
54
+ const storedPath = join(localDir, storedTarballName(name, version));
55
+ const backup = `${storedPath}.bak`;
56
+ const hadStored = await rename(storedPath, backup).then(() => true, () => false);
57
+ try {
58
+ await copyFile(input.tarballPath, storedPath);
59
+ await chmod(storedPath, 0o600);
60
+ input.onPhase?.("installing");
61
+ const result = await run("npm", ["install", "--prefix", pluginsDir, storedPath], {
62
+ cwd: pluginsDir,
63
+ signal,
64
+ });
65
+ if (result.exitCode !== 0) {
66
+ const tail = sanitizeNpmError([result.stdout, result.stderr].filter(Boolean).join("\n"), 100_000).slice(-TAIL_CHARS);
67
+ throw new LocalInstallError(`npm install failed for plugin "${name}" (exit ${result.exitCode}).\n${tail || "no output"}`, tail || undefined);
68
+ }
69
+ input.onPhase?.("registering");
70
+ const configFile = await persistGlobalPluginEntry(home, name);
71
+ await rm(backup, { force: true });
72
+ if (previousStored && previousStored !== storedPath)
73
+ await rm(previousStored, { force: true });
74
+ const manifest = (await readJson(join(packageDir, "package.json")));
75
+ return {
76
+ packageName: name,
77
+ installedVersion: manifest?.version ?? version,
78
+ ...(previous?.version ? { previousVersion: previous.version } : {}),
79
+ alreadyInstalled: previous !== undefined,
80
+ updated: previous !== undefined,
81
+ storedPath,
82
+ spec: `file:${LOCAL_PACKAGES_DIR}/${storedTarballName(name, version)}`,
83
+ pluginsDir,
84
+ configFile,
85
+ info,
86
+ };
87
+ }
88
+ catch (error) {
89
+ await rm(storedPath, { force: true }).catch(() => { });
90
+ if (hadStored)
91
+ await rename(backup, storedPath).catch(() => { });
92
+ if (previous !== undefined && previousSource) {
93
+ // An update that broke the package: put the previous version back.
94
+ const current = (await readJson(join(packageDir, "package.json")).catch(() => undefined));
95
+ if (current?.version !== previous.version)
96
+ await run("npm", ["install", "--prefix", pluginsDir, previousSource], {
97
+ cwd: pluginsDir,
98
+ signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS),
99
+ }).catch(() => undefined);
100
+ }
101
+ // npm rewrites the dependency spec; a spec pointing at the copy we just removed would break
102
+ // every later install.
103
+ await Promise.all(manifestFiles.map((file, i) => {
104
+ const before = snapshots[i];
105
+ return before === undefined ? rm(file, { force: true }) : writeFile(file, before);
106
+ })).catch(() => { });
107
+ if (previous === undefined) {
108
+ // A package that was not there before must not be left half-installed.
109
+ if (await readJson(join(packageDir, "package.json")).catch(() => undefined)) {
110
+ await run("npm", ["uninstall", "--prefix", pluginsDir, name], {
111
+ cwd: pluginsDir,
112
+ signal: AbortSignal.timeout(60_000),
113
+ }).catch(() => undefined);
114
+ await rm(packageDir, { recursive: true, force: true }).catch(() => { });
115
+ }
116
+ if (!inConfig)
117
+ await removeGlobalPluginEntry(home, name).catch(() => false);
118
+ }
119
+ throw error;
120
+ }
121
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * How Alisio spawns npm for plugin install and uninstall.
3
+ *
4
+ * A system npm is the default. The desktop app ships without Node.js on the user's PATH, so it
5
+ * bundles npm and points `ALISIO_NPM_CLI` at its `bin/npm-cli.js`: npm then runs through the
6
+ * executable that is already running (Node, or the Electron binary acting as Node), with no
7
+ * system npm needed.
8
+ */
9
+ import { type ProcessResult } from "../runtime/process.ts";
10
+ export declare const NPM_NOT_FOUND_MESSAGE = "npm was not found: install Node.js, or use the desktop app, which bundles it";
11
+ export interface NpmInvocation {
12
+ command: string;
13
+ args: string[];
14
+ /** Present only when the child needs an explicit environment (bundled npm). */
15
+ env?: Record<string, string>;
16
+ }
17
+ /**
18
+ * Maps `npm <args>` to the process to spawn. With `ALISIO_NPM_CLI` set, that script runs through
19
+ * `execPath`, and `ELECTRON_RUN_AS_NODE=1` is added when that executable is Electron.
20
+ */
21
+ export declare function resolveNpmInvocation(command: string, args: string[], env?: NodeJS.ProcessEnv, versions?: Record<string, string | undefined>, execPath?: string): NpmInvocation;
22
+ /** The default install runner: bounded time and output, bundled npm aware, clear ENOENT error. */
23
+ export declare function runNpm(command: string, args: string[], options: {
24
+ cwd: string;
25
+ signal: AbortSignal;
26
+ env?: Record<string, string>;
27
+ }, limits?: {
28
+ timeoutMs?: number;
29
+ maxBytes?: number;
30
+ }): Promise<ProcessResult>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * How Alisio spawns npm for plugin install and uninstall.
3
+ *
4
+ * A system npm is the default. The desktop app ships without Node.js on the user's PATH, so it
5
+ * bundles npm and points `ALISIO_NPM_CLI` at its `bin/npm-cli.js`: npm then runs through the
6
+ * executable that is already running (Node, or the Electron binary acting as Node), with no
7
+ * system npm needed.
8
+ */
9
+ import { isMissingCommand, runProcess } from "../runtime/process.js";
10
+ export const NPM_NOT_FOUND_MESSAGE = "npm was not found: install Node.js, or use the desktop app, which bundles it";
11
+ /** Env keys the npm child inherits (the default runner env plus what npm needs for its cache/proxy). */
12
+ const NPM_ENV_KEYS = [
13
+ "PATH",
14
+ "HOME",
15
+ "USERPROFILE",
16
+ "TMPDIR",
17
+ "TEMP",
18
+ "TMP",
19
+ "SystemRoot",
20
+ "COMSPEC",
21
+ "LANG",
22
+ "APPDATA",
23
+ "LOCALAPPDATA",
24
+ "HTTP_PROXY",
25
+ "HTTPS_PROXY",
26
+ "NO_PROXY",
27
+ "http_proxy",
28
+ "https_proxy",
29
+ "no_proxy",
30
+ "NODE_EXTRA_CA_CERTS",
31
+ ];
32
+ /**
33
+ * Maps `npm <args>` to the process to spawn. With `ALISIO_NPM_CLI` set, that script runs through
34
+ * `execPath`, and `ELECTRON_RUN_AS_NODE=1` is added when that executable is Electron.
35
+ */
36
+ export function resolveNpmInvocation(command, args, env = process.env, versions = process.versions, execPath = process.execPath) {
37
+ const cli = env.ALISIO_NPM_CLI;
38
+ if (command !== "npm" || !cli)
39
+ return { command, args };
40
+ const childEnv = {};
41
+ for (const key of NPM_ENV_KEYS) {
42
+ const value = env[key];
43
+ if (value)
44
+ childEnv[key] = value;
45
+ }
46
+ if (versions.electron)
47
+ childEnv.ELECTRON_RUN_AS_NODE = "1";
48
+ return { command: execPath, args: [cli, ...args], env: childEnv };
49
+ }
50
+ /** The default install runner: bounded time and output, bundled npm aware, clear ENOENT error. */
51
+ export async function runNpm(command, args, options, limits = {}) {
52
+ const spec = resolveNpmInvocation(command, args);
53
+ try {
54
+ return await runProcess(spec.command, spec.args, {
55
+ cwd: options.cwd,
56
+ signal: options.signal,
57
+ ...(spec.env || options.env ? { env: { ...spec.env, ...options.env } } : {}),
58
+ ...limits,
59
+ });
60
+ }
61
+ catch (error) {
62
+ if (command === "npm" && isMissingCommand(error))
63
+ throw new Error(NPM_NOT_FOUND_MESSAGE);
64
+ throw error;
65
+ }
66
+ }