@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.
- package/dist/application.d.ts +5 -0
- package/dist/application.js +3 -0
- package/dist/changelog/data.js +20 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +6 -2
- package/dist/plugins/host.d.ts +5 -0
- package/dist/plugins/host.js +15 -1
- package/dist/plugins/install.d.ts +50 -2
- package/dist/plugins/install.js +102 -12
- package/dist/plugins/local-install.d.ts +33 -0
- package/dist/plugins/local-install.js +121 -0
- package/dist/plugins/npm-runner.d.ts +30 -0
- package/dist/plugins/npm-runner.js +66 -0
- package/dist/plugins/tarball.d.ts +39 -0
- package/dist/plugins/tarball.js +277 -0
- package/dist/plugins/version-compat.d.ts +20 -0
- package/dist/plugins/version-compat.js +154 -0
- package/package.json +2 -2
package/dist/application.d.ts
CHANGED
|
@@ -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;
|
package/dist/application.js
CHANGED
|
@@ -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
|
}
|
package/dist/changelog/data.js
CHANGED
|
@@ -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";
|
package/dist/plugins/host.d.ts
CHANGED
|
@@ -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;
|
package/dist/plugins/host.js
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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;
|
package/dist/plugins/install.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
/**
|
|
128
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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 ??
|
|
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 ??
|
|
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.
|
|
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.
|
|
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",
|