Infra: require explicit opt-in for prerelease npm installs (#38117)
* Infra: tighten npm registry spec parsing * Infra: block implicit prerelease npm installs * Plugins: cover prerelease install policy * Infra: add npm registry spec tests * Hooks: cover prerelease install policy * Docs: clarify plugin guide version policy * Docs: clarify plugin install version policy * Docs: clarify hooks install version policy * Docs: clarify hook pack version policy
This commit is contained in:
@@ -103,7 +103,12 @@ Hook packs are standard npm packages that export one or more hooks via `openclaw
|
|||||||
openclaw hooks install <path-or-spec>
|
openclaw hooks install <path-or-spec>
|
||||||
```
|
```
|
||||||
|
|
||||||
Npm specs are registry-only (package name + optional version/tag). Git/URL/file specs are rejected.
|
Npm specs are registry-only (package name + optional exact version or dist-tag).
|
||||||
|
Git/URL/file specs and semver ranges are rejected.
|
||||||
|
|
||||||
|
Bare specs and `@latest` stay on the stable track. If npm resolves either of
|
||||||
|
those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a
|
||||||
|
prerelease tag such as `@beta`/`@rc` or an exact prerelease version.
|
||||||
|
|
||||||
Example `package.json`:
|
Example `package.json`:
|
||||||
|
|
||||||
|
|||||||
@@ -193,8 +193,13 @@ openclaw hooks install <npm-spec> --pin
|
|||||||
|
|
||||||
Install a hook pack from a local folder/archive or npm.
|
Install a hook pack from a local folder/archive or npm.
|
||||||
|
|
||||||
Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
|
Npm specs are **registry-only** (package name + optional **exact version** or
|
||||||
specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
|
**dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency
|
||||||
|
installs run with `--ignore-scripts` for safety.
|
||||||
|
|
||||||
|
Bare specs and `@latest` stay on the stable track. If npm resolves either of
|
||||||
|
those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a
|
||||||
|
prerelease tag such as `@beta`/`@rc` or an exact prerelease version.
|
||||||
|
|
||||||
**What it does:**
|
**What it does:**
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,14 @@ openclaw plugins install <npm-spec> --pin
|
|||||||
|
|
||||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||||
|
|
||||||
Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
|
Npm specs are **registry-only** (package name + optional **exact version** or
|
||||||
specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
|
**dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency
|
||||||
|
installs run with `--ignore-scripts` for safety.
|
||||||
|
|
||||||
|
Bare specs and `@latest` stay on the stable track. If npm resolves either of
|
||||||
|
those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a
|
||||||
|
prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as
|
||||||
|
`@1.2.3-beta.4`.
|
||||||
|
|
||||||
If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw
|
If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw
|
||||||
installs the bundled plugin directly. To install an npm package with the same
|
installs the bundled plugin directly. To install an npm package with the same
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ openclaw plugins list
|
|||||||
openclaw plugins install @openclaw/voice-call
|
openclaw plugins install @openclaw/voice-call
|
||||||
```
|
```
|
||||||
|
|
||||||
Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
|
Npm specs are **registry-only** (package name + optional **exact version** or
|
||||||
specs are rejected.
|
**dist-tag**). Git/URL/file specs and semver ranges are rejected.
|
||||||
|
|
||||||
|
Bare specs and `@latest` stay on the stable track. If npm resolves either of
|
||||||
|
those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a
|
||||||
|
prerelease tag such as `@beta`/`@rc` or an exact prerelease version.
|
||||||
|
|
||||||
3. Restart the Gateway, then configure under `plugins.entries.<id>.config`.
|
3. Restart the Gateway, then configure under `plugins.entries.<id>.config`.
|
||||||
|
|
||||||
|
|||||||
@@ -409,6 +409,28 @@ describe("installHooksFromNpmSpec", () => {
|
|||||||
actualIntegrity: "sha512-new",
|
actualIntegrity: "sha512-new",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects bare npm specs that resolve to prerelease versions", async () => {
|
||||||
|
const run = vi.mocked(runCommandWithTimeout);
|
||||||
|
mockNpmPackMetadataResult(run, {
|
||||||
|
id: "@openclaw/test-hooks@0.0.2-beta.1",
|
||||||
|
name: "@openclaw/test-hooks",
|
||||||
|
version: "0.0.2-beta.1",
|
||||||
|
filename: "test-hooks-0.0.2-beta.1.tgz",
|
||||||
|
integrity: "sha512-beta",
|
||||||
|
shasum: "betashasum",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await installHooksFromNpmSpec({
|
||||||
|
spec: "@openclaw/test-hooks",
|
||||||
|
logger: { info: () => {}, warn: () => {} },
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toContain("prerelease version 0.0.2-beta.1");
|
||||||
|
expect(result.error).toContain('"@openclaw/test-hooks@beta"');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("gmail watcher", () => {
|
describe("gmail watcher", () => {
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
type NpmIntegrityDriftPayload,
|
type NpmIntegrityDriftPayload,
|
||||||
resolveNpmIntegrityDriftWithDefaultMessage,
|
resolveNpmIntegrityDriftWithDefaultMessage,
|
||||||
} from "./npm-integrity.js";
|
} from "./npm-integrity.js";
|
||||||
|
import {
|
||||||
|
formatPrereleaseResolutionError,
|
||||||
|
isPrereleaseResolutionAllowed,
|
||||||
|
parseRegistryNpmSpec,
|
||||||
|
} from "./npm-registry-spec.js";
|
||||||
|
|
||||||
export type NpmSpecArchiveInstallFlowResult<TResult extends { ok: boolean }> =
|
export type NpmSpecArchiveInstallFlowResult<TResult extends { ok: boolean }> =
|
||||||
| {
|
| {
|
||||||
@@ -94,6 +99,13 @@ export async function installFromNpmSpecArchive<TResult extends { ok: boolean }>
|
|||||||
installFromArchive: (params: { archivePath: string }) => Promise<TResult>;
|
installFromArchive: (params: { archivePath: string }) => Promise<TResult>;
|
||||||
}): Promise<NpmSpecArchiveInstallFlowResult<TResult>> {
|
}): Promise<NpmSpecArchiveInstallFlowResult<TResult>> {
|
||||||
return await withTempDir(params.tempDirPrefix, async (tmpDir) => {
|
return await withTempDir(params.tempDirPrefix, async (tmpDir) => {
|
||||||
|
const parsedSpec = parseRegistryNpmSpec(params.spec);
|
||||||
|
if (!parsedSpec) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "unsupported npm spec",
|
||||||
|
};
|
||||||
|
}
|
||||||
const packedResult = await packNpmSpecToArchive({
|
const packedResult = await packNpmSpecToArchive({
|
||||||
spec: params.spec,
|
spec: params.spec,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
@@ -107,6 +119,21 @@ export async function installFromNpmSpecArchive<TResult extends { ok: boolean }>
|
|||||||
...packedResult.metadata,
|
...packedResult.metadata,
|
||||||
resolvedAt: new Date().toISOString(),
|
resolvedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
if (
|
||||||
|
npmResolution.version &&
|
||||||
|
!isPrereleaseResolutionAllowed({
|
||||||
|
spec: parsedSpec,
|
||||||
|
resolvedVersion: npmResolution.version,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: formatPrereleaseResolutionError({
|
||||||
|
spec: parsedSpec,
|
||||||
|
resolvedVersion: npmResolution.version,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({
|
const driftResult = await resolveNpmIntegrityDriftWithDefaultMessage({
|
||||||
spec: params.spec,
|
spec: params.spec,
|
||||||
|
|||||||
69
src/infra/npm-registry-spec.test.ts
Normal file
69
src/infra/npm-registry-spec.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isPrereleaseResolutionAllowed,
|
||||||
|
parseRegistryNpmSpec,
|
||||||
|
validateRegistryNpmSpec,
|
||||||
|
} from "./npm-registry-spec.js";
|
||||||
|
|
||||||
|
describe("npm registry spec validation", () => {
|
||||||
|
it("accepts bare package names, exact versions, and dist-tags", () => {
|
||||||
|
expect(validateRegistryNpmSpec("@openclaw/voice-call")).toBeNull();
|
||||||
|
expect(validateRegistryNpmSpec("@openclaw/voice-call@1.2.3")).toBeNull();
|
||||||
|
expect(validateRegistryNpmSpec("@openclaw/voice-call@1.2.3-beta.4")).toBeNull();
|
||||||
|
expect(validateRegistryNpmSpec("@openclaw/voice-call@latest")).toBeNull();
|
||||||
|
expect(validateRegistryNpmSpec("@openclaw/voice-call@beta")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects semver ranges", () => {
|
||||||
|
expect(validateRegistryNpmSpec("@openclaw/voice-call@^1.2.3")).toContain(
|
||||||
|
"exact version or dist-tag",
|
||||||
|
);
|
||||||
|
expect(validateRegistryNpmSpec("@openclaw/voice-call@~1.2.3")).toContain(
|
||||||
|
"exact version or dist-tag",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("npm prerelease resolution policy", () => {
|
||||||
|
it("blocks prerelease resolutions for bare specs", () => {
|
||||||
|
const spec = parseRegistryNpmSpec("@openclaw/voice-call");
|
||||||
|
expect(spec).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
isPrereleaseResolutionAllowed({
|
||||||
|
spec: spec!,
|
||||||
|
resolvedVersion: "1.2.3-beta.1",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks prerelease resolutions for latest", () => {
|
||||||
|
const spec = parseRegistryNpmSpec("@openclaw/voice-call@latest");
|
||||||
|
expect(spec).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
isPrereleaseResolutionAllowed({
|
||||||
|
spec: spec!,
|
||||||
|
resolvedVersion: "1.2.3-rc.1",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows prerelease resolutions when the user explicitly opted in", () => {
|
||||||
|
const tagSpec = parseRegistryNpmSpec("@openclaw/voice-call@beta");
|
||||||
|
const versionSpec = parseRegistryNpmSpec("@openclaw/voice-call@1.2.3-beta.1");
|
||||||
|
|
||||||
|
expect(tagSpec).not.toBeNull();
|
||||||
|
expect(versionSpec).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
isPrereleaseResolutionAllowed({
|
||||||
|
spec: tagSpec!,
|
||||||
|
resolvedVersion: "1.2.3-beta.4",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isPrereleaseResolutionAllowed({
|
||||||
|
spec: versionSpec!,
|
||||||
|
resolvedVersion: "1.2.3-beta.1",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,41 +1,141 @@
|
|||||||
export function validateRegistryNpmSpec(rawSpec: string): string | null {
|
const EXACT_SEMVER_VERSION_RE =
|
||||||
|
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
||||||
|
const DIST_TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||||
|
|
||||||
|
export type ParsedRegistryNpmSpec = {
|
||||||
|
name: string;
|
||||||
|
raw: string;
|
||||||
|
selector?: string;
|
||||||
|
selectorKind: "none" | "exact-version" | "tag";
|
||||||
|
selectorIsPrerelease: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseRegistryNpmSpecInternal(
|
||||||
|
rawSpec: string,
|
||||||
|
): { ok: true; parsed: ParsedRegistryNpmSpec } | { ok: false; error: string } {
|
||||||
const spec = rawSpec.trim();
|
const spec = rawSpec.trim();
|
||||||
if (!spec) {
|
if (!spec) {
|
||||||
return "missing npm spec";
|
return { ok: false, error: "missing npm spec" };
|
||||||
}
|
}
|
||||||
if (/\s/.test(spec)) {
|
if (/\s/.test(spec)) {
|
||||||
return "unsupported npm spec: whitespace is not allowed";
|
return { ok: false, error: "unsupported npm spec: whitespace is not allowed" };
|
||||||
}
|
}
|
||||||
// Registry-only: no URLs, git, file, or alias protocols.
|
// Registry-only: no URLs, git, file, or alias protocols.
|
||||||
// Keep strict: this runs on the gateway host.
|
// Keep strict: this runs on the gateway host.
|
||||||
if (spec.includes("://")) {
|
if (spec.includes("://")) {
|
||||||
return "unsupported npm spec: URLs are not allowed";
|
return { ok: false, error: "unsupported npm spec: URLs are not allowed" };
|
||||||
}
|
}
|
||||||
if (spec.includes("#")) {
|
if (spec.includes("#")) {
|
||||||
return "unsupported npm spec: git refs are not allowed";
|
return { ok: false, error: "unsupported npm spec: git refs are not allowed" };
|
||||||
}
|
}
|
||||||
if (spec.includes(":")) {
|
if (spec.includes(":")) {
|
||||||
return "unsupported npm spec: protocol specs are not allowed";
|
return { ok: false, error: "unsupported npm spec: protocol specs are not allowed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const at = spec.lastIndexOf("@");
|
const at = spec.lastIndexOf("@");
|
||||||
const hasVersion = at > 0;
|
const hasSelector = at > 0;
|
||||||
const name = hasVersion ? spec.slice(0, at) : spec;
|
const name = hasSelector ? spec.slice(0, at) : spec;
|
||||||
const version = hasVersion ? spec.slice(at + 1) : "";
|
const selector = hasSelector ? spec.slice(at + 1) : "";
|
||||||
|
|
||||||
const unscopedName = /^[a-z0-9][a-z0-9-._~]*$/;
|
const unscopedName = /^[a-z0-9][a-z0-9-._~]*$/;
|
||||||
const scopedName = /^@[a-z0-9][a-z0-9-._~]*\/[a-z0-9][a-z0-9-._~]*$/;
|
const scopedName = /^@[a-z0-9][a-z0-9-._~]*\/[a-z0-9][a-z0-9-._~]*$/;
|
||||||
const isValidName = name.startsWith("@") ? scopedName.test(name) : unscopedName.test(name);
|
const isValidName = name.startsWith("@") ? scopedName.test(name) : unscopedName.test(name);
|
||||||
if (!isValidName) {
|
if (!isValidName) {
|
||||||
return "unsupported npm spec: expected <name> or <name>@<version> from the npm registry";
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "unsupported npm spec: expected <name> or <name>@<version> from the npm registry",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (hasVersion) {
|
if (!hasSelector) {
|
||||||
if (!version) {
|
return {
|
||||||
return "unsupported npm spec: missing version/tag after @";
|
ok: true,
|
||||||
|
parsed: {
|
||||||
|
name,
|
||||||
|
raw: spec,
|
||||||
|
selectorKind: "none",
|
||||||
|
selectorIsPrerelease: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (/[\\/]/.test(version)) {
|
if (!selector) {
|
||||||
return "unsupported npm spec: invalid version/tag";
|
return { ok: false, error: "unsupported npm spec: missing version/tag after @" };
|
||||||
}
|
}
|
||||||
|
if (/[\\/]/.test(selector)) {
|
||||||
|
return { ok: false, error: "unsupported npm spec: invalid version/tag" };
|
||||||
}
|
}
|
||||||
return null;
|
const exactVersionMatch = EXACT_SEMVER_VERSION_RE.exec(selector);
|
||||||
|
if (exactVersionMatch) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
parsed: {
|
||||||
|
name,
|
||||||
|
raw: spec,
|
||||||
|
selector,
|
||||||
|
selectorKind: "exact-version",
|
||||||
|
selectorIsPrerelease: Boolean(exactVersionMatch[4]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!DIST_TAG_RE.test(selector)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "unsupported npm spec: use an exact version or dist-tag (ranges are not allowed)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
parsed: {
|
||||||
|
name,
|
||||||
|
raw: spec,
|
||||||
|
selector,
|
||||||
|
selectorKind: "tag",
|
||||||
|
selectorIsPrerelease: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRegistryNpmSpec(rawSpec: string): ParsedRegistryNpmSpec | null {
|
||||||
|
const parsed = parseRegistryNpmSpecInternal(rawSpec);
|
||||||
|
return parsed.ok ? parsed.parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRegistryNpmSpec(rawSpec: string): string | null {
|
||||||
|
const parsed = parseRegistryNpmSpecInternal(rawSpec);
|
||||||
|
return parsed.ok ? null : parsed.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExactSemverVersion(value: string): boolean {
|
||||||
|
return EXACT_SEMVER_VERSION_RE.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrereleaseSemverVersion(value: string): boolean {
|
||||||
|
const match = EXACT_SEMVER_VERSION_RE.exec(value.trim());
|
||||||
|
return Boolean(match?.[4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrereleaseResolutionAllowed(params: {
|
||||||
|
spec: ParsedRegistryNpmSpec;
|
||||||
|
resolvedVersion?: string;
|
||||||
|
}): boolean {
|
||||||
|
if (!params.resolvedVersion || !isPrereleaseSemverVersion(params.resolvedVersion)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (params.spec.selectorKind === "none") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (params.spec.selectorKind === "exact-version") {
|
||||||
|
return params.spec.selectorIsPrerelease;
|
||||||
|
}
|
||||||
|
return params.spec.selector?.toLowerCase() !== "latest";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrereleaseResolutionError(params: {
|
||||||
|
spec: ParsedRegistryNpmSpec;
|
||||||
|
resolvedVersion: string;
|
||||||
|
}): string {
|
||||||
|
const selectorHint =
|
||||||
|
params.spec.selectorKind === "none" || params.spec.selector?.toLowerCase() === "latest"
|
||||||
|
? `Use "${params.spec.name}@beta" (or another prerelease tag) or an exact prerelease version to opt in explicitly.`
|
||||||
|
: `Use an explicit prerelease tag or exact prerelease version if you want prerelease installs.`;
|
||||||
|
return `Resolved ${params.spec.raw} to prerelease version ${params.resolvedVersion}, but prereleases are only installed when explicitly requested. ${selectorHint}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -858,4 +858,78 @@ describe("installPluginFromNpmSpec", () => {
|
|||||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND);
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects bare npm specs that resolve to prerelease versions", async () => {
|
||||||
|
const run = vi.mocked(runCommandWithTimeout);
|
||||||
|
mockNpmPackMetadataResult(run, {
|
||||||
|
id: "@openclaw/voice-call@0.0.2-beta.1",
|
||||||
|
name: "@openclaw/voice-call",
|
||||||
|
version: "0.0.2-beta.1",
|
||||||
|
filename: "voice-call-0.0.2-beta.1.tgz",
|
||||||
|
integrity: "sha512-beta",
|
||||||
|
shasum: "betashasum",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await installPluginFromNpmSpec({
|
||||||
|
spec: "@openclaw/voice-call",
|
||||||
|
logger: { info: () => {}, warn: () => {} },
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toContain("prerelease version 0.0.2-beta.1");
|
||||||
|
expect(result.error).toContain('"@openclaw/voice-call@beta"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows explicit prerelease npm tags", async () => {
|
||||||
|
const run = vi.mocked(runCommandWithTimeout);
|
||||||
|
let packTmpDir = "";
|
||||||
|
const packedName = "voice-call-0.0.2-beta.1.tgz";
|
||||||
|
const voiceCallArchiveBuffer = VOICE_CALL_ARCHIVE_V1_BUFFER;
|
||||||
|
run.mockImplementation(async (argv, opts) => {
|
||||||
|
if (argv[0] === "npm" && argv[1] === "pack") {
|
||||||
|
packTmpDir = String(typeof opts === "number" ? "" : (opts.cwd ?? ""));
|
||||||
|
fs.writeFileSync(path.join(packTmpDir, packedName), voiceCallArchiveBuffer);
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
stdout: JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "@openclaw/voice-call@0.0.2-beta.1",
|
||||||
|
name: "@openclaw/voice-call",
|
||||||
|
version: "0.0.2-beta.1",
|
||||||
|
filename: packedName,
|
||||||
|
integrity: "sha512-beta",
|
||||||
|
shasum: "betashasum",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
stderr: "",
|
||||||
|
signal: null,
|
||||||
|
killed: false,
|
||||||
|
termination: "exit",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected command: ${argv.join(" ")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { extensionsDir } = await setupVoiceCallArchiveInstall({
|
||||||
|
outName: "voice-call-0.0.2-beta.1.tgz",
|
||||||
|
version: "0.0.1",
|
||||||
|
});
|
||||||
|
const result = await installPluginFromNpmSpec({
|
||||||
|
spec: "@openclaw/voice-call@beta",
|
||||||
|
extensionsDir,
|
||||||
|
logger: { info: () => {}, warn: () => {} },
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(result.npmResolution?.version).toBe("0.0.2-beta.1");
|
||||||
|
expect(result.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.2-beta.1");
|
||||||
|
expectSingleNpmPackIgnoreScriptsCall({
|
||||||
|
calls: run.mock.calls,
|
||||||
|
expectedSpec: "@openclaw/voice-call@beta",
|
||||||
|
});
|
||||||
|
expect(packTmpDir).not.toBe("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user