refactor: share npm integrity drift handling

This commit is contained in:
Peter Steinberger
2026-02-19 14:36:53 +00:00
parent 72e426be60
commit edf92f1cb0
5 changed files with 274 additions and 94 deletions

View File

@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveNpmIntegrityDrift,
resolveNpmIntegrityDriftWithDefaultMessage,
} from "./npm-integrity.js";
describe("resolveNpmIntegrityDrift", () => {
it("returns proceed=true when integrity is missing or unchanged", async () => {
await expect(
resolveNpmIntegrityDrift({
spec: "@openclaw/test@1.0.0",
expectedIntegrity: "sha512-same",
resolution: { integrity: "sha512-same", resolvedAt: "2026-01-01T00:00:00.000Z" },
createPayload: () => "unused",
}),
).resolves.toEqual({ proceed: true });
await expect(
resolveNpmIntegrityDrift({
spec: "@openclaw/test@1.0.0",
expectedIntegrity: "sha512-same",
resolution: { resolvedAt: "2026-01-01T00:00:00.000Z" },
createPayload: () => "unused",
}),
).resolves.toEqual({ proceed: true });
});
it("uses callback on integrity drift", async () => {
const onIntegrityDrift = vi.fn(async () => false);
const result = await resolveNpmIntegrityDrift({
spec: "@openclaw/test@1.0.0",
expectedIntegrity: "sha512-old",
resolution: {
integrity: "sha512-new",
resolvedAt: "2026-01-01T00:00:00.000Z",
},
createPayload: ({ expectedIntegrity, actualIntegrity }) => ({
expectedIntegrity,
actualIntegrity,
}),
onIntegrityDrift,
});
expect(onIntegrityDrift).toHaveBeenCalledWith({
expectedIntegrity: "sha512-old",
actualIntegrity: "sha512-new",
});
expect(result.proceed).toBe(false);
expect(result.integrityDrift).toEqual({
expectedIntegrity: "sha512-old",
actualIntegrity: "sha512-new",
});
});
it("warns by default when no callback is provided", async () => {
const warn = vi.fn();
const result = await resolveNpmIntegrityDrift({
spec: "@openclaw/test@1.0.0",
expectedIntegrity: "sha512-old",
resolution: {
integrity: "sha512-new",
resolvedAt: "2026-01-01T00:00:00.000Z",
},
createPayload: ({ spec }) => ({ spec }),
warn,
});
expect(warn).toHaveBeenCalledWith({ spec: "@openclaw/test@1.0.0" });
expect(result.proceed).toBe(true);
});
it("formats default warning and abort error messages", async () => {
const warn = vi.fn();
const warningResult = await resolveNpmIntegrityDriftWithDefaultMessage({
spec: "@openclaw/test@1.0.0",
expectedIntegrity: "sha512-old",
resolution: {
integrity: "sha512-new",
resolvedSpec: "@openclaw/test@1.0.0",
resolvedAt: "2026-01-01T00:00:00.000Z",
},
warn,
});
expect(warningResult.error).toBeUndefined();
expect(warn).toHaveBeenCalledWith(
"Integrity drift detected for @openclaw/test@1.0.0: expected sha512-old, got sha512-new",
);
const abortResult = await resolveNpmIntegrityDriftWithDefaultMessage({
spec: "@openclaw/test@1.0.0",
expectedIntegrity: "sha512-old",
resolution: {
integrity: "sha512-new",
resolvedSpec: "@openclaw/test@1.0.0",
resolvedAt: "2026-01-01T00:00:00.000Z",
},
onIntegrityDrift: async () => false,
});
expect(abortResult.error).toBe(
"aborted: npm package integrity drift detected for @openclaw/test@1.0.0",
);
});
});

View File

@@ -0,0 +1,93 @@
import type { NpmIntegrityDrift, NpmSpecResolution } from "./install-source-utils.js";
export type NpmIntegrityDriftPayload = {
spec: string;
expectedIntegrity: string;
actualIntegrity: string;
resolution: NpmSpecResolution;
};
type ResolveNpmIntegrityDriftParams<TPayload> = {
spec: string;
expectedIntegrity?: string;
resolution: NpmSpecResolution;
createPayload: (params: {
spec: string;
expectedIntegrity: string;
actualIntegrity: string;
resolution: NpmSpecResolution;
}) => TPayload;
onIntegrityDrift?: (payload: TPayload) => boolean | Promise<boolean>;
warn?: (payload: TPayload) => void;
};
export type ResolveNpmIntegrityDriftResult<TPayload> = {
integrityDrift?: NpmIntegrityDrift;
proceed: boolean;
payload?: TPayload;
};
export async function resolveNpmIntegrityDrift<TPayload>(
params: ResolveNpmIntegrityDriftParams<TPayload>,
): Promise<ResolveNpmIntegrityDriftResult<TPayload>> {
if (!params.expectedIntegrity || !params.resolution.integrity) {
return { proceed: true };
}
if (params.expectedIntegrity === params.resolution.integrity) {
return { proceed: true };
}
const integrityDrift: NpmIntegrityDrift = {
expectedIntegrity: params.expectedIntegrity,
actualIntegrity: params.resolution.integrity,
};
const payload = params.createPayload({
spec: params.spec,
expectedIntegrity: integrityDrift.expectedIntegrity,
actualIntegrity: integrityDrift.actualIntegrity,
resolution: params.resolution,
});
let proceed = true;
if (params.onIntegrityDrift) {
proceed = await params.onIntegrityDrift(payload);
} else {
params.warn?.(payload);
}
return { integrityDrift, proceed, payload };
}
type ResolveNpmIntegrityDriftWithDefaultMessageParams = {
spec: string;
expectedIntegrity?: string;
resolution: NpmSpecResolution;
onIntegrityDrift?: (payload: NpmIntegrityDriftPayload) => boolean | Promise<boolean>;
warn?: (message: string) => void;
};
export async function resolveNpmIntegrityDriftWithDefaultMessage(
params: ResolveNpmIntegrityDriftWithDefaultMessageParams,
): Promise<{ integrityDrift?: NpmIntegrityDrift; error?: string }> {
const driftResult = await resolveNpmIntegrityDrift<NpmIntegrityDriftPayload>({
spec: params.spec,
expectedIntegrity: params.expectedIntegrity,
resolution: params.resolution,
createPayload: (drift) => ({ ...drift }),
onIntegrityDrift: params.onIntegrityDrift,
warn: (driftPayload) => {
params.warn?.(
`Integrity drift detected for ${driftPayload.resolution.resolvedSpec ?? driftPayload.spec}: expected ${driftPayload.expectedIntegrity}, got ${driftPayload.actualIntegrity}`,
);
},
});
if (!driftResult.proceed && driftResult.payload) {
return {
integrityDrift: driftResult.integrityDrift,
error: `aborted: npm package integrity drift detected for ${driftResult.payload.resolution.resolvedSpec ?? driftResult.payload.spec}`,
};
}
return { integrityDrift: driftResult.integrityDrift };
}