@alisio/core 0.9.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.
@@ -148,6 +148,11 @@ export interface PluginCatalogEntry {
148
148
  enabled: boolean;
149
149
  manageable: boolean;
150
150
  diagnostic?: string;
151
+ /**
152
+ * The npm package name an external plugin was loaded from. Never a path: the catalog stays
153
+ * path-safe, so file and directory plugins carry no `spec`.
154
+ */
155
+ spec?: string;
151
156
  }
152
157
  export interface ConfiguredProviderCatalog {
153
158
  profile: string;
@@ -332,6 +332,7 @@ export async function createApplication(options = {}) {
332
332
  status: enabled ? "active" : "inactive",
333
333
  enabled,
334
334
  manageable: entry.manageable,
335
+ ...(isPathSpec(entry.spec) ? {} : { spec: entry.spec }),
335
336
  });
336
337
  runtimePluginEnabled.set(candidate.plugin.id, enabled);
337
338
  }
@@ -349,6 +350,7 @@ export async function createApplication(options = {}) {
349
350
  enabled: true,
350
351
  manageable: entry.manageable,
351
352
  diagnostic: "Activation failed; registrations were rolled back",
353
+ ...(isPathSpec(entry.spec) ? {} : { spec: entry.spec }),
352
354
  });
353
355
  continue;
354
356
  }
@@ -364,6 +366,7 @@ export async function createApplication(options = {}) {
364
366
  enabled: true,
365
367
  manageable: false,
366
368
  diagnostic: "Load failed; no path or credential details are shown here",
369
+ ...(isPathSpec(entry.spec) ? {} : { spec: entry.spec }),
367
370
  });
368
371
  }
369
372
  }
@@ -2,6 +2,26 @@
2
2
  export const CHANGELOG_DATA = {
3
3
  schema: 1,
4
4
  entries: [
5
+ {
6
+ version: "0.10.0",
7
+ date: "2026-11-07",
8
+ sections: [
9
+ {
10
+ title: "Added",
11
+ items: [
12
+ "Settings → Plugins → **Plugin catalog**: browse the published plugins with search, category and tag filters and pages of 10, and install one with a click. The catalog is cached for 24 hours and only downloaded again when it changed; **Check for updates** checks now.",
13
+ "**Install from file**: install a private plugin from a `.tgz` made with `npm pack` or `pnpm pack`. The file is validated first (an Alisio plugin, its SDK range, install scripts), the install shows its progress step by step, and temporary files are removed. The package is kept under `~/.config/alisio/plugins/local-packages`, so the original file can be moved or deleted.",
14
+ "**Installed plugins**: uninstall a plugin, see when a catalog plugin has a newer version, and update one or all of them. Installing a plugin that is already there updates it in place.",
15
+ ],
16
+ },
17
+ {
18
+ title: "Improved",
19
+ items: [
20
+ "When `npm` is missing, installing a plugin says so clearly instead of failing with a spawn error. Alisio Desktop bundles its own `npm`, so plugins install without Node.js on the system.",
21
+ ],
22
+ },
23
+ ],
24
+ },
5
25
  {
6
26
  version: "0.9.0",
7
27
  date: "2026-11-07",
package/dist/index.d.ts CHANGED
@@ -73,8 +73,12 @@ export { describePermissionStatus, type EffectState, effectState, FULL_ACCESS_WA
73
73
  export { DIAGRAM_MAX_BYTES, DIAGRAM_MAX_NODES, DIAGRAM_SYNTAXES, DIAGRAMS_MAX_ALLOWED, mermaidProblem, type PlanDiagram, validateDiagrams, } from "./plan/diagrams.ts";
74
74
  export { buildPlanManifest, parsePlanHeadings, slugifyHeading, } from "./plan/manifest.ts";
75
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";
76
- export { discoverPlugins, type PluginEnvironment, PluginHost, type PluginHostOptions, pluginPrefix, type ViewInfo, ViewRunError, } from "./plugins/host.ts";
77
- 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";
78
82
  export * from "./proof/index.ts";
79
83
  export { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.ts";
80
84
  export { availableProviderModels, type ProviderModelCatalog, resolveProviderModel, } from "./providers/routing.ts";
package/dist/index.js CHANGED
@@ -72,8 +72,12 @@ export { describePermissionStatus, effectState, FULL_ACCESS_WARNING, modeFromFla
72
72
  export { DIAGRAM_MAX_BYTES, DIAGRAM_MAX_NODES, DIAGRAM_SYNTAXES, DIAGRAMS_MAX_ALLOWED, mermaidProblem, validateDiagrams, } from "./plan/diagrams.js";
73
73
  export { buildPlanManifest, parsePlanHeadings, slugifyHeading, } from "./plan/manifest.js";
74
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";
75
- export { discoverPlugins, PluginHost, pluginPrefix, ViewRunError, } from "./plugins/host.js";
76
- 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";
77
81
  export * from "./proof/index.js";
78
82
  export { ActiveProvider, ProviderRegistry, UnconfiguredProvider } from "./providers/registry.js";
79
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
+ }
@@ -0,0 +1,39 @@
1
+ import type { Readable } from "node:stream";
2
+ export declare const MAX_TARBALL_BYTES: number;
3
+ export declare const MAX_PACKAGE_JSON_BYTES: number;
4
+ export declare const MAX_TARBALL_ENTRIES = 20000;
5
+ export declare const MAX_EXPANDED_BYTES: number;
6
+ export interface TarballLimits {
7
+ maxPackageJsonBytes?: number;
8
+ maxEntries?: number;
9
+ maxExpandedBytes?: number;
10
+ }
11
+ export declare class TarballError extends Error {
12
+ }
13
+ /**
14
+ * Reads `package/package.json` from a gzip ustar stream. Throws `TarballError` with a user-facing
15
+ * reason for anything unusable (not gzip, corrupt, unsafe path, over a limit, no manifest).
16
+ */
17
+ export declare function readTarballPackageJson(source: Readable, limits?: TarballLimits): Promise<{
18
+ packageJson: unknown;
19
+ entries: number;
20
+ }>;
21
+ export interface PluginTarballInfo {
22
+ name?: string;
23
+ version?: string;
24
+ description?: string;
25
+ author?: string;
26
+ license?: string;
27
+ /** `peerDependencies["@alisio/sdk"]`. */
28
+ sdkRange?: string;
29
+ /** True when every check passed; `reasons` lists what failed otherwise. */
30
+ isAlisioPlugin: boolean;
31
+ reasons: string[];
32
+ /** Declared `preinstall`/`install`/`postinstall` scripts (npm runs them on install). */
33
+ lifecycleScripts: string[];
34
+ size: number;
35
+ }
36
+ /** Validates a tarball on disk as an Alisio plugin package; never throws for bad content. */
37
+ export declare function inspectPluginTarball(path: string, limits?: TarballLimits & {
38
+ maxBytes?: number;
39
+ }): Promise<PluginTarballInfo>;
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Inspection of a plugin tarball (`.tgz` from `npm pack`/`pnpm pack`) without extracting it and
3
+ * without any external `tar`: a streaming gzip + ustar reader that only keeps `package/package.json`.
4
+ * Everything is bounded (compressed size, expanded size, entry count, manifest size) and any entry
5
+ * path that could escape the package directory rejects the whole archive.
6
+ */
7
+ import { createReadStream } from "node:fs";
8
+ import { stat } from "node:fs/promises";
9
+ import { createGunzip } from "node:zlib";
10
+ import { PLUGIN_KEYWORD } from "../runtime/modules.js";
11
+ import { parsePluginSpec } from "./install.js";
12
+ import { comparePluginVersions } from "./version-compat.js";
13
+ export const MAX_TARBALL_BYTES = 50 * 1024 * 1024;
14
+ export const MAX_PACKAGE_JSON_BYTES = 1024 * 1024;
15
+ export const MAX_TARBALL_ENTRIES = 20_000;
16
+ export const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
17
+ const MAX_META_BYTES = 64 * 1024;
18
+ const LIFECYCLE = ["preinstall", "install", "postinstall"];
19
+ export class TarballError extends Error {
20
+ }
21
+ const str = (block, start, length) => {
22
+ const slice = block.subarray(start, start + length);
23
+ const end = slice.indexOf(0);
24
+ return slice.subarray(0, end < 0 ? slice.length : end).toString("utf8");
25
+ };
26
+ function parseSize(block) {
27
+ if ((block[124] ?? 0) & 0x80)
28
+ throw new TarballError("The archive has an entry that is too large");
29
+ const value = Number.parseInt(str(block, 124, 12).trim() || "0", 8);
30
+ if (!Number.isFinite(value) || value < 0)
31
+ throw new TarballError("The archive is corrupt");
32
+ return value;
33
+ }
34
+ function checksumOk(block) {
35
+ const stored = Number.parseInt(str(block, 148, 8).trim() || "x", 8);
36
+ let sum = 0;
37
+ for (let i = 0; i < 512; i++)
38
+ sum += i >= 148 && i < 156 ? 0x20 : (block[i] ?? 0);
39
+ return stored === sum;
40
+ }
41
+ /** Path of the `path=` record of a pax extended header, if any. */
42
+ function paxPath(data) {
43
+ let offset = 0;
44
+ while (offset < data.length) {
45
+ const space = data.indexOf(0x20, offset);
46
+ if (space < 0)
47
+ return undefined;
48
+ const length = Number.parseInt(data.subarray(offset, space).toString("utf8"), 10);
49
+ if (!Number.isFinite(length) || length <= 0)
50
+ return undefined;
51
+ const record = data.subarray(space + 1, offset + length - 1).toString("utf8");
52
+ if (record.startsWith("path="))
53
+ return record.slice(5);
54
+ offset += length;
55
+ }
56
+ return undefined;
57
+ }
58
+ /** Normalized entry name; throws for anything that is absolute or climbs out with `..`. */
59
+ function safeName(raw) {
60
+ const name = raw.replace(/\/+$/, "");
61
+ if (name.startsWith("/") || name.includes("\\") || /^[A-Za-z]:/.test(name))
62
+ throw new TarballError(`The archive has an unsafe path: ${raw.slice(0, 80)}`);
63
+ if (name.split("/").some((part) => part === ".."))
64
+ throw new TarballError(`The archive has an unsafe path: ${raw.slice(0, 80)}`);
65
+ return name;
66
+ }
67
+ /**
68
+ * Reads `package/package.json` from a gzip ustar stream. Throws `TarballError` with a user-facing
69
+ * reason for anything unusable (not gzip, corrupt, unsafe path, over a limit, no manifest).
70
+ */
71
+ export async function readTarballPackageJson(source, limits = {}) {
72
+ const maxJson = limits.maxPackageJsonBytes ?? MAX_PACKAGE_JSON_BYTES;
73
+ const maxEntries = limits.maxEntries ?? MAX_TARBALL_ENTRIES;
74
+ const maxExpanded = limits.maxExpandedBytes ?? MAX_EXPANDED_BYTES;
75
+ const gunzip = createGunzip();
76
+ source.on("error", (error) => gunzip.destroy(error));
77
+ source.pipe(gunzip);
78
+ let state = "header";
79
+ let buf = Buffer.alloc(0);
80
+ let remaining = 0;
81
+ let mode = "skip";
82
+ let collected = [];
83
+ let pendingName;
84
+ let entries = 0;
85
+ let expanded = 0;
86
+ let manifest;
87
+ let padding = 0;
88
+ const finishData = () => {
89
+ const data = Buffer.concat(collected);
90
+ collected = [];
91
+ if (mode === "manifest")
92
+ manifest = data;
93
+ else if (mode === "pax")
94
+ pendingName = paxPath(data) ?? pendingName;
95
+ else if (mode === "longname")
96
+ pendingName = str(data, 0, data.length);
97
+ mode = "skip";
98
+ };
99
+ const header = (block) => {
100
+ if (block.every((byte) => byte === 0)) {
101
+ state = "end";
102
+ return;
103
+ }
104
+ if (!checksumOk(block))
105
+ throw new TarballError("The file is not a valid tar archive (corrupt header checksum)");
106
+ const prefix = str(block, 345, 155);
107
+ const own = str(block, 0, 100);
108
+ const size = parseSize(block);
109
+ const type = String.fromCharCode(block[156] || 0x30);
110
+ padding = (512 - (size % 512)) % 512;
111
+ if (type === "x" || type === "L" || type === "g") {
112
+ if (size > MAX_META_BYTES)
113
+ throw new TarballError("The archive has an oversized header record");
114
+ mode = type === "x" ? "pax" : type === "L" ? "longname" : "skip";
115
+ }
116
+ else {
117
+ entries++;
118
+ if (entries > maxEntries)
119
+ throw new TarballError("The archive has too many entries");
120
+ const name = safeName(pendingName ?? (prefix ? `${prefix}/${own}` : own));
121
+ pendingName = undefined;
122
+ mode = "skip";
123
+ if (name === "package/package.json" && (type === "0" || type === "7")) {
124
+ if (size > maxJson)
125
+ throw new TarballError("package.json is too large");
126
+ mode = "manifest";
127
+ }
128
+ }
129
+ remaining = size;
130
+ state = remaining > 0 ? "data" : padding > 0 ? "pad" : "header";
131
+ if (remaining === 0 && mode !== "skip")
132
+ finishData();
133
+ };
134
+ const pump = () => {
135
+ for (;;) {
136
+ if (state === "end")
137
+ return;
138
+ if (state === "header") {
139
+ if (buf.length < 512)
140
+ return;
141
+ const block = buf.subarray(0, 512);
142
+ buf = buf.subarray(512);
143
+ header(block);
144
+ continue;
145
+ }
146
+ const take = Math.min(remaining, buf.length);
147
+ if (take === 0)
148
+ return;
149
+ if (state === "data" && mode !== "skip") {
150
+ collected.push(buf.subarray(0, take));
151
+ }
152
+ buf = buf.subarray(take);
153
+ remaining -= take;
154
+ if (remaining > 0)
155
+ return;
156
+ if (state === "data") {
157
+ if (mode !== "skip")
158
+ finishData();
159
+ remaining = padding;
160
+ state = padding > 0 ? "pad" : "header";
161
+ }
162
+ else
163
+ state = "header";
164
+ }
165
+ };
166
+ try {
167
+ for await (const chunk of gunzip) {
168
+ expanded += chunk.length;
169
+ if (expanded > maxExpanded)
170
+ throw new TarballError("The archive expands beyond the allowed size");
171
+ buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
172
+ pump();
173
+ }
174
+ }
175
+ catch (error) {
176
+ if (error instanceof TarballError)
177
+ throw error;
178
+ throw new TarballError("The file is not a gzip archive (.tgz)");
179
+ }
180
+ finally {
181
+ source.unpipe(gunzip);
182
+ source.destroy();
183
+ }
184
+ const last = state;
185
+ if (last !== "end" && last !== "header")
186
+ throw new TarballError("The archive is truncated");
187
+ if (entries === 0 && last !== "end")
188
+ throw new TarballError("The file is not a valid tar archive");
189
+ if (!manifest)
190
+ throw new TarballError("The archive has no package/package.json");
191
+ let packageJson;
192
+ try {
193
+ packageJson = JSON.parse(manifest.toString("utf8"));
194
+ }
195
+ catch {
196
+ throw new TarballError("package/package.json is not valid JSON");
197
+ }
198
+ return { packageJson, entries };
199
+ }
200
+ const text = (value, max = 500) => typeof value === "string" && value.trim() && value.length <= max ? value.trim() : undefined;
201
+ /** Validates a tarball on disk as an Alisio plugin package; never throws for bad content. */
202
+ export async function inspectPluginTarball(path, limits = {}) {
203
+ const size = (await stat(path)).size;
204
+ const info = {
205
+ isAlisioPlugin: false,
206
+ reasons: [],
207
+ lifecycleScripts: [],
208
+ size,
209
+ };
210
+ const maxBytes = limits.maxBytes ?? MAX_TARBALL_BYTES;
211
+ if (size > maxBytes) {
212
+ info.reasons.push(`The file is larger than ${Math.round(maxBytes / (1024 * 1024))} MB`);
213
+ return info;
214
+ }
215
+ let json;
216
+ try {
217
+ json = (await readTarballPackageJson(createReadStream(path), limits)).packageJson;
218
+ }
219
+ catch (error) {
220
+ info.reasons.push(error instanceof TarballError ? error.message : "The file could not be read");
221
+ return info;
222
+ }
223
+ if (!json || typeof json !== "object" || Array.isArray(json)) {
224
+ info.reasons.push("package/package.json is not an object");
225
+ return info;
226
+ }
227
+ const pkg = json;
228
+ const name = text(pkg.name, 214);
229
+ const version = text(pkg.version, 64);
230
+ let nameOk = false;
231
+ if (name) {
232
+ try {
233
+ const parsed = parsePluginSpec(name);
234
+ nameOk = parsed.version === undefined && parsed.name === name;
235
+ }
236
+ catch {
237
+ nameOk = false;
238
+ }
239
+ }
240
+ if (nameOk)
241
+ info.name = name;
242
+ else
243
+ info.reasons.push("The package name is missing or not a valid npm package name");
244
+ if (version && comparePluginVersions(version, version) !== undefined)
245
+ info.version = version;
246
+ else
247
+ info.reasons.push("The package version is missing or not a valid semver version");
248
+ const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : [];
249
+ if (!keywords.includes(PLUGIN_KEYWORD))
250
+ info.reasons.push(`Not an Alisio plugin: package.json keywords must include "${PLUGIN_KEYWORD}"`);
251
+ const hasExports = (typeof pkg.exports === "string" && pkg.exports.length > 0) ||
252
+ (!!pkg.exports && typeof pkg.exports === "object" && Object.keys(pkg.exports).length > 0);
253
+ if (!hasExports && !text(pkg.main))
254
+ info.reasons.push('The package has no entry point ("exports" or "main" in package.json)');
255
+ const description = text(pkg.description, 600);
256
+ if (description)
257
+ info.description = description;
258
+ const author = text(pkg.author) ??
259
+ (pkg.author && typeof pkg.author === "object"
260
+ ? text(pkg.author.name)
261
+ : undefined);
262
+ if (author)
263
+ info.author = author;
264
+ const license = text(pkg.license, 80);
265
+ if (license)
266
+ info.license = license;
267
+ const peers = pkg.peerDependencies;
268
+ const range = peers && typeof peers === "object"
269
+ ? text(peers["@alisio/sdk"], 80)
270
+ : undefined;
271
+ if (range)
272
+ info.sdkRange = range;
273
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
274
+ info.lifecycleScripts = LIFECYCLE.filter((key) => typeof scripts[key] === "string");
275
+ info.isAlisioPlugin = info.reasons.length === 0;
276
+ return info;
277
+ }
@@ -0,0 +1,20 @@
1
+ /** Negative, zero or positive like a comparator; `undefined` when either version is malformed. */
2
+ export declare function comparePluginVersions(a: string, b: string): number | undefined;
3
+ /** Whether `version` satisfies `range`. An unparseable range or version never satisfies. */
4
+ export declare function satisfiesRange(version: string, range: string): boolean;
5
+ export interface CatalogPluginStateInput {
6
+ catalogVersion: string;
7
+ installedVersion?: string;
8
+ /** The plugin's declared `@alisio/sdk` range. */
9
+ sdkRange?: string;
10
+ /** The installed `@alisio/sdk` version, when known. */
11
+ sdkVersion?: string;
12
+ }
13
+ export interface CatalogPluginState {
14
+ installedVersion?: string;
15
+ /** False only when both the range and the SDK version are known and do not match. */
16
+ compatible: boolean;
17
+ updateAvailable: boolean;
18
+ }
19
+ /** Install state of one catalog entry against what is installed and the running SDK. */
20
+ export declare function catalogPluginState(input: CatalogPluginStateInput): CatalogPluginState;
@@ -0,0 +1,154 @@
1
+ function parse(version) {
2
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
3
+ if (!match)
4
+ return undefined;
5
+ return {
6
+ triple: [Number(match[1]), Number(match[2]), Number(match[3])],
7
+ pre: match[4] ? match[4].split(".") : [],
8
+ };
9
+ }
10
+ function compareTriples(a, b) {
11
+ for (let i = 0; i < 3; i++) {
12
+ const diff = a[i] - b[i];
13
+ if (diff)
14
+ return diff;
15
+ }
16
+ return 0;
17
+ }
18
+ /** Negative, zero or positive like a comparator; `undefined` when either version is malformed. */
19
+ export function comparePluginVersions(a, b) {
20
+ const left = parse(a);
21
+ const right = parse(b);
22
+ if (!left || !right)
23
+ return undefined;
24
+ const core = compareTriples(left.triple, right.triple);
25
+ if (core)
26
+ return core;
27
+ if (!left.pre.length || !right.pre.length)
28
+ return right.pre.length - left.pre.length;
29
+ const length = Math.max(left.pre.length, right.pre.length);
30
+ for (let i = 0; i < length; i++) {
31
+ const x = left.pre[i];
32
+ const y = right.pre[i];
33
+ if (x === undefined)
34
+ return -1;
35
+ if (y === undefined)
36
+ return 1;
37
+ const xn = /^\d+$/.test(x);
38
+ const yn = /^\d+$/.test(y);
39
+ if (xn && yn) {
40
+ if (Number(x) !== Number(y))
41
+ return Number(x) - Number(y);
42
+ }
43
+ else if (xn !== yn)
44
+ return xn ? -1 : 1;
45
+ else if (x !== y)
46
+ return x < y ? -1 : 1;
47
+ }
48
+ return 0;
49
+ }
50
+ /** A partial version (`1`, `1.2`, `1.x`, `*`) as its numeric parts (`undefined` = wildcard). */
51
+ function partial(text) {
52
+ const parts = text.replace(/^v/, "").split(".");
53
+ if (parts.length > 3 || parts[0] === "")
54
+ return undefined;
55
+ const out = [];
56
+ for (const part of parts) {
57
+ if (part === "x" || part === "X" || part === "*")
58
+ out.push(undefined);
59
+ else if (/^\d+$/.test(part))
60
+ out.push(Number(part));
61
+ else
62
+ return undefined;
63
+ }
64
+ while (out.length < 3)
65
+ out.push(undefined);
66
+ return out;
67
+ }
68
+ function comparator(token) {
69
+ const match = /^(\^|~|>=|<=|>|<|=)?\s*(.+)$/.exec(token);
70
+ if (!match)
71
+ return undefined;
72
+ const op = match[1] ?? "";
73
+ const nums = partial(match[2]);
74
+ if (!nums)
75
+ return undefined;
76
+ const [maj, min, pat] = nums;
77
+ const floor = [maj ?? 0, min ?? 0, pat ?? 0];
78
+ const ge = (v) => compareTriples(v, floor) >= 0;
79
+ if (maj === undefined)
80
+ return () => true;
81
+ switch (op) {
82
+ case "^": {
83
+ const upper = maj > 0
84
+ ? [maj + 1, 0, 0]
85
+ : min === undefined
86
+ ? [1, 0, 0]
87
+ : min > 0 || pat === undefined
88
+ ? [0, min + 1, 0]
89
+ : [0, 0, pat + 1];
90
+ return (v) => ge(v) && compareTriples(v, upper) < 0;
91
+ }
92
+ case "~": {
93
+ const upper = min === undefined ? [maj + 1, 0, 0] : [maj, min + 1, 0];
94
+ return (v) => ge(v) && compareTriples(v, upper) < 0;
95
+ }
96
+ case ">=":
97
+ return ge;
98
+ case ">": {
99
+ const next = pat !== undefined
100
+ ? [maj, min ?? 0, pat]
101
+ : min !== undefined
102
+ ? [maj, min + 1, 0]
103
+ : [maj + 1, 0, 0];
104
+ return pat !== undefined
105
+ ? (v) => compareTriples(v, next) > 0
106
+ : (v) => compareTriples(v, next) >= 0;
107
+ }
108
+ case "<":
109
+ return (v) => compareTriples(v, floor) < 0;
110
+ case "<=": {
111
+ const top = pat !== undefined
112
+ ? [maj, min ?? 0, pat + 1]
113
+ : min !== undefined
114
+ ? [maj, min + 1, 0]
115
+ : [maj + 1, 0, 0];
116
+ return (v) => compareTriples(v, top) < 0;
117
+ }
118
+ default: {
119
+ // Exact or wildcard (`1.2.x`, `1`).
120
+ if (pat !== undefined)
121
+ return (v) => compareTriples(v, floor) === 0;
122
+ const upper = min === undefined ? [maj + 1, 0, 0] : [maj, min + 1, 0];
123
+ return (v) => ge(v) && compareTriples(v, upper) < 0;
124
+ }
125
+ }
126
+ }
127
+ /** Whether `version` satisfies `range`. An unparseable range or version never satisfies. */
128
+ export function satisfiesRange(version, range) {
129
+ const parsed = parse(version);
130
+ if (!parsed)
131
+ return false;
132
+ const alternatives = range.split("||").map((part) => part.trim());
133
+ return alternatives.some((alt) => {
134
+ const tokens = alt
135
+ .replace(/(\^|~|>=|<=|>|<|=)\s+/g, "$1")
136
+ .split(/\s+/)
137
+ .filter(Boolean);
138
+ if (tokens.length === 0)
139
+ return false;
140
+ const checks = tokens.map(comparator);
141
+ return checks.every((check) => check?.(parsed.triple) === true);
142
+ });
143
+ }
144
+ /** Install state of one catalog entry against what is installed and the running SDK. */
145
+ export function catalogPluginState(input) {
146
+ const compatible = !input.sdkRange || !input.sdkVersion ? true : satisfiesRange(input.sdkVersion, input.sdkRange);
147
+ const updateAvailable = input.installedVersion !== undefined &&
148
+ (comparePluginVersions(input.catalogVersion, input.installedVersion) ?? 0) > 0;
149
+ return {
150
+ compatible,
151
+ updateAvailable,
152
+ ...(input.installedVersion !== undefined ? { installedVersion: input.installedVersion } : {}),
153
+ };
154
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alisio/core",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Embeddable provider-agnostic Alisio agent core: tool loop, context compaction, local tools, MCP, plugin host and SQLite session store. Runs on Node >=22.16 and Bun.",
5
5
  "author": "Gustavo Gutiérrez",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  "node": ">=22.16"
46
46
  },
47
47
  "dependencies": {
48
- "@alisio/sdk": "0.9.0",
48
+ "@alisio/sdk": "0.10.0",
49
49
  "@mixmark-io/domino": "2.2.0",
50
50
  "@modelcontextprotocol/client": "2.1.0",
51
51
  "ajv": "8.20.0",