Skip to content

Commit 2eaf8ad

Browse files
committed
feat(plugins): support npm pack installs
1 parent 54e23b6 commit 2eaf8ad

11 files changed

Lines changed: 702 additions & 138 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- Plugins/install: add `npm-pack:<path.tgz>` installs so local npm pack artifacts run through the same managed npm-root install, lockfile verification, dependency scan, and install-record path as registry npm plugins.
910
- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro.
1011
- MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13.
1112
- Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq.

docs/cli/plugins.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ openclaw plugins search "calendar" # search ClawHub plugins
7474
openclaw plugins install <package> # npm by default
7575
openclaw plugins install clawhub:<package> # ClawHub only
7676
openclaw plugins install npm:<package> # npm only
77+
openclaw plugins install npm-pack:<path.tgz> # local npm pack through npm install semantics
7778
openclaw plugins install git:github.com/<owner>/<repo> # git repo
7879
openclaw plugins install git:github.com/<owner>/<repo>@<ref>
7980
openclaw plugins install <package> --force # overwrite existing install
@@ -150,6 +151,12 @@ is available, then fall back to `latest`.
150151
<Accordion title="Archives">
151152
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. Native OpenClaw plugin archives must contain a valid `openclaw.plugin.json` at the extracted plugin root; archives that only contain `package.json` are rejected before OpenClaw writes install records.
152153

154+
Use `npm-pack:<path.tgz>` when the file is an npm-pack tarball and you want
155+
to test the same managed npm-root install path used by registry installs,
156+
including `package-lock.json` verification, hoisted dependency scanning, and
157+
npm install records. Plain archive paths still install as local archives
158+
under the plugin extensions root.
159+
153160
Claude marketplace installs are also supported.
154161

155162
</Accordion>

docs/plugins/dependency-resolution.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ npm installs run in the npm root with:
4646
npm install --prefix ~/.openclaw/npm <spec> --omit=dev --ignore-scripts --no-audit --no-fund
4747
```
4848

49+
`openclaw plugins install npm-pack:<path.tgz>` uses that same managed npm root
50+
for a local npm-pack tarball. OpenClaw reads the tarball's npm metadata, adds it
51+
to the managed root as a copied `file:` dependency, runs the normal npm install,
52+
and then verifies the installed lockfile metadata before trusting the plugin.
53+
This is intended for package-acceptance and release-candidate proof where a
54+
local pack artifact should behave like the registry artifact it simulates.
55+
4956
npm may hoist transitive dependencies to `~/.openclaw/npm/node_modules` beside
5057
the plugin package. OpenClaw scans the managed npm root before trusting the
5158
install and uses npm to remove npm-managed packages during uninstall, so hoisted

docs/tools/plugin.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ For copy-paste install, list, uninstall, update, and publishing examples, see
3838

3939
# From npm
4040
openclaw plugins install npm:@acme/openclaw-plugin
41+
openclaw plugins install npm-pack:./openclaw-plugin-1.2.3.tgz
4142

4243
# From git
4344
openclaw plugins install git:github.com/acme/openclaw-plugin@v1.0.0
@@ -92,8 +93,8 @@ If you prefer chat-native control, enable `commands.plugins: true` and use:
9293
```
9394

9495
The install path uses the same resolver as the CLI: local path/archive, explicit
95-
`clawhub:<pkg>`, explicit `npm:<pkg>`, explicit `git:<repo>`, or bare package
96-
spec through npm.
96+
`clawhub:<pkg>`, explicit `npm:<pkg>`, explicit `npm-pack:<path.tgz>`,
97+
explicit `git:<repo>`, or bare package spec through npm.
9798

9899
If config is invalid, install normally fails closed and points you at
99100
`openclaw doctor --fix`. The only recovery exception is a narrow bundled-plugin

src/cli/plugins-cli-test-helpers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export class PromptInputClosedError extends Error {
8181
}
8282
}
8383
export const installPluginFromNpmSpec: AsyncUnknownMock = vi.fn();
84+
export const installPluginFromNpmPackArchive: AsyncUnknownMock = vi.fn();
8485
export const installPluginFromPath: AsyncUnknownMock = vi.fn();
8586
export const installPluginFromClawHub: AsyncUnknownMock = vi.fn();
8687
export const parseClawHubPluginSpec: Mock<ParseClawHubPluginSpecFn> = vi.fn();
@@ -485,6 +486,16 @@ vi.mock("../plugins/install.js", () => ({
485486
installPluginFromNpmSpec,
486487
...args,
487488
)) as (typeof import("../plugins/install.js"))["installPluginFromNpmSpec"],
489+
installPluginFromNpmPackArchive: ((
490+
...args: Parameters<(typeof import("../plugins/install.js"))["installPluginFromNpmPackArchive"]>
491+
) =>
492+
invokeMock<
493+
Parameters<(typeof import("../plugins/install.js"))["installPluginFromNpmPackArchive"]>,
494+
ReturnType<(typeof import("../plugins/install.js"))["installPluginFromNpmPackArchive"]>
495+
>(
496+
installPluginFromNpmPackArchive,
497+
...args,
498+
)) as (typeof import("../plugins/install.js"))["installPluginFromNpmPackArchive"],
488499
installPluginFromPath: ((
489500
...args: Parameters<(typeof import("../plugins/install.js"))["installPluginFromPath"]>
490501
) =>
@@ -650,6 +661,7 @@ export function resetPluginsCliTestState() {
650661
installPluginFromGitSpec.mockReset();
651662
parseGitPluginSpec.mockReset();
652663
installPluginFromNpmSpec.mockReset();
664+
installPluginFromNpmPackArchive.mockReset();
653665
installPluginFromPath.mockReset();
654666
installPluginFromClawHub.mockReset();
655667
parseClawHubPluginSpec.mockReset();

src/cli/plugins-cli.install.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
findBundledPluginSourceMock,
1717
installHooksFromNpmSpec,
1818
installHooksFromPath,
19+
installPluginFromNpmPackArchive,
1920
installPluginFromClawHub,
2021
installPluginFromGitSpec,
2122
installPluginFromMarketplace,
@@ -127,6 +128,28 @@ function createNpmPluginInstallResult(
127128
};
128129
}
129130

131+
function createNpmPackPluginInstallResult(
132+
pluginId = "demo",
133+
): Awaited<ReturnType<typeof installPluginFromNpmPackArchive>> {
134+
return {
135+
ok: true,
136+
pluginId,
137+
targetDir: cliInstallPath(pluginId),
138+
version: "1.2.3",
139+
extensions: ["dist/index.js"],
140+
manifestName: `@openclaw/${pluginId}`,
141+
npmTarballName: `openclaw-${pluginId}-1.2.3.tgz`,
142+
npmResolution: {
143+
name: `@openclaw/${pluginId}`,
144+
version: "1.2.3",
145+
resolvedSpec: `@openclaw/${pluginId}@1.2.3`,
146+
integrity: "sha512-pack-demo",
147+
shasum: "packdemosha",
148+
resolvedAt: "2026-05-06T00:00:00.000Z",
149+
},
150+
};
151+
}
152+
130153
function createGitPluginInstallResult(
131154
pluginId = "demo",
132155
): Awaited<ReturnType<typeof installPluginFromGitSpec>> {
@@ -909,6 +932,47 @@ describe("plugins cli install", () => {
909932
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
910933
});
911934

935+
it("installs npm-pack archives through npm install semantics", async () => {
936+
const cfg = createEmptyPluginConfig();
937+
const enabledCfg = createEnabledPluginConfig("demo");
938+
const archivePath = "/tmp/openclaw-demo-1.2.3.tgz";
939+
940+
loadConfig.mockReturnValue(cfg);
941+
installPluginFromNpmPackArchive.mockResolvedValue(createNpmPackPluginInstallResult("demo"));
942+
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
943+
recordPluginInstall.mockReturnValue(enabledCfg);
944+
applyExclusiveSlotSelection.mockReturnValue({
945+
config: enabledCfg,
946+
warnings: [],
947+
});
948+
949+
await runPluginsCommand(["plugins", "install", `npm-pack:${archivePath}`]);
950+
951+
expect(installPluginFromNpmPackArchive).toHaveBeenCalledWith(
952+
expect.objectContaining({
953+
archivePath,
954+
mode: "install",
955+
}),
956+
);
957+
expect(installPluginFromPath).not.toHaveBeenCalled();
958+
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
959+
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
960+
demo: expect.objectContaining({
961+
source: "npm",
962+
spec: "@openclaw/demo@1.2.3",
963+
sourcePath: archivePath,
964+
installPath: cliInstallPath("demo"),
965+
version: "1.2.3",
966+
artifactKind: "npm-pack",
967+
artifactFormat: "tgz",
968+
npmIntegrity: "sha512-pack-demo",
969+
npmShasum: "packdemosha",
970+
npmTarballName: "openclaw-demo-1.2.3.tgz",
971+
}),
972+
});
973+
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
974+
});
975+
912976
it("keeps npm-prefixed official plugin ids on explicit npm semantics", async () => {
913977
const cfg = createEmptyPluginConfig();
914978
const enabledCfg = createEnabledPluginConfig("brave");

src/cli/plugins-command-helpers.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,11 @@ export function parseNpmPrefixSpec(raw: string): string | null {
228228
}
229229
return trimmed.slice("npm:".length).trim();
230230
}
231+
232+
export function parseNpmPackPrefixPath(raw: string): string | null {
233+
const trimmed = raw.trim();
234+
if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("npm-pack:")) {
235+
return null;
236+
}
237+
return trimmed.slice("npm-pack:".length).trim();
238+
}

src/cli/plugins-install-command.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { resolveDefaultPluginExtensionsDir } from "../plugins/install-paths.js";
1414
import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js";
1515
import {
1616
PLUGIN_INSTALL_ERROR_CODE,
17+
installPluginFromNpmPackArchive,
1718
installPluginFromNpmSpec,
1819
installPluginFromPath,
1920
} from "../plugins/install.js";
@@ -49,6 +50,7 @@ import {
4950
createHookPackInstallLogger,
5051
createPluginInstallLogger,
5152
formatPluginInstallWithHookFallbackError,
53+
parseNpmPackPrefixPath,
5254
parseNpmPrefixSpec,
5355
} from "./plugins-command-helpers.js";
5456
import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js";
@@ -379,6 +381,54 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: {
379381
return { ok: true };
380382
}
381383

384+
async function tryInstallPluginFromNpmPackArchive(params: {
385+
snapshot: ConfigSnapshotForInstallPersist;
386+
installMode: "install" | "update";
387+
archivePath: string;
388+
safetyOverrides: InstallSafetyOverrides;
389+
extensionsDir: string;
390+
runtime?: RuntimeEnv;
391+
}): Promise<{ ok: true } | { ok: false }> {
392+
const result = await installPluginFromNpmPackArchive({
393+
...params.safetyOverrides,
394+
mode: params.installMode,
395+
archivePath: params.archivePath,
396+
extensionsDir: params.extensionsDir,
397+
logger: createPluginInstallLogger(params.runtime),
398+
});
399+
if (!result.ok) {
400+
(params.runtime ?? defaultRuntime).error(result.error);
401+
return { ok: false };
402+
}
403+
404+
await persistPluginInstall({
405+
snapshot: params.snapshot,
406+
pluginId: result.pluginId,
407+
install: {
408+
source: "npm",
409+
spec: result.npmResolution?.resolvedSpec ?? result.manifestName ?? result.pluginId,
410+
sourcePath: params.archivePath,
411+
installPath: result.targetDir,
412+
...(result.version ? { version: result.version } : {}),
413+
...(result.npmResolution?.name ? { resolvedName: result.npmResolution.name } : {}),
414+
...(result.npmResolution?.version ? { resolvedVersion: result.npmResolution.version } : {}),
415+
...(result.npmResolution?.resolvedSpec
416+
? { resolvedSpec: result.npmResolution.resolvedSpec }
417+
: {}),
418+
...(result.npmResolution?.integrity ? { integrity: result.npmResolution.integrity } : {}),
419+
...(result.npmResolution?.shasum ? { shasum: result.npmResolution.shasum } : {}),
420+
...(result.npmResolution?.resolvedAt ? { resolvedAt: result.npmResolution.resolvedAt } : {}),
421+
artifactKind: "npm-pack",
422+
artifactFormat: "tgz",
423+
...(result.npmResolution?.integrity ? { npmIntegrity: result.npmResolution.integrity } : {}),
424+
...(result.npmResolution?.shasum ? { npmShasum: result.npmResolution.shasum } : {}),
425+
...(result.npmTarballName ? { npmTarballName: result.npmTarballName } : {}),
426+
},
427+
runtime: params.runtime,
428+
});
429+
return { ok: true };
430+
}
431+
382432
async function tryInstallPluginFromGitSpec(params: {
383433
snapshot: ConfigSnapshotForInstallPersist;
384434
installMode: "install" | "update";
@@ -753,6 +803,26 @@ export async function runPluginInstallCommand(params: {
753803
return;
754804
}
755805

806+
const npmPackPath = parseNpmPackPrefixPath(raw);
807+
if (npmPackPath !== null) {
808+
if (!npmPackPath) {
809+
runtime.error("unsupported npm-pack: spec: missing pack archive path");
810+
return runtime.exit(1);
811+
}
812+
const npmPackResult = await tryInstallPluginFromNpmPackArchive({
813+
snapshot,
814+
installMode,
815+
archivePath: npmPackPath,
816+
safetyOverrides,
817+
extensionsDir,
818+
runtime,
819+
});
820+
if (!npmPackResult.ok) {
821+
return runtime.exit(1);
822+
}
823+
return;
824+
}
825+
756826
if (gitSpec) {
757827
const gitResult = await tryInstallPluginFromGitSpec({
758828
snapshot,

src/infra/install-source-utils.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,52 @@ export async function packNpmSpecToArchive(params: {
321321
metadata: parsedJson?.metadata ?? {},
322322
};
323323
}
324+
325+
export async function resolveNpmPackArchiveMetadata(params: {
326+
archivePath: string;
327+
timeoutMs?: number;
328+
}): Promise<
329+
| {
330+
ok: true;
331+
archivePath: string;
332+
tarballName: string;
333+
metadata: NpmSpecResolution;
334+
}
335+
| {
336+
ok: false;
337+
error: string;
338+
}
339+
> {
340+
const archivePathResult = await resolveArchiveSourcePath(params.archivePath);
341+
if (!archivePathResult.ok) {
342+
return archivePathResult;
343+
}
344+
const archivePath = archivePathResult.path;
345+
const res = await runCommandWithTimeout(
346+
["npm", "pack", archivePath, "--ignore-scripts", "--dry-run", "--json"],
347+
{
348+
timeoutMs: Math.max(params.timeoutMs ?? 60_000, 60_000),
349+
env: {
350+
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
351+
NPM_CONFIG_IGNORE_SCRIPTS: "true",
352+
},
353+
},
354+
);
355+
if (res.code !== 0) {
356+
return {
357+
ok: false,
358+
error: `npm pack metadata read failed: ${res.stderr.trim() || res.stdout.trim()}`,
359+
};
360+
}
361+
362+
const parsedJson = parseNpmPackJsonOutput(res.stdout || "");
363+
if (!parsedJson?.metadata.name || !parsedJson.metadata.version) {
364+
return { ok: false, error: "npm pack metadata read produced incomplete package metadata" };
365+
}
366+
return {
367+
ok: true,
368+
archivePath,
369+
tarballName: parsedJson.filename ?? path.basename(archivePath),
370+
metadata: parsedJson.metadata,
371+
};
372+
}

0 commit comments

Comments
 (0)