fix(core): address PR review feedback for non-apikey provider support (#2452)
- Strip apiKey from options at streamSimple registration boundary for externalCli/none providers — enforced structurally, not by convention - Add registration-time validation: externalCli/none requires streamSimple, rejects contradictory apiKey, improved error messages mentioning authMode - Cache legacy hook module imports to prevent side-effect double-execution - Add isReady() trust boundary documentation - Add inline comments on compaction-orchestrator apiKey flow - Refactor package-commands.test.ts to use t.after() cleanup - Add lifecycle-hooks.test.ts with 24 unit tests for readManifestRuntimeDeps, collectRuntimeDependencies, verifyRuntimeDependencies, resolveLocalSourcePath - Expand model-registry-auth-mode.test.ts with streamSimple apiKey boundary tests and registration validation tests (80 total tests across all files) - Add afterRemove deleted-directory edge case test - Fix help-text.ts wording: "lifecycle hooks" → "post-install validation" - Fix event.message null check documentation (intentional tightening)
This commit is contained in:
parent
d2f677b268
commit
68902466ac
8 changed files with 783 additions and 212 deletions
|
|
@ -97,6 +97,7 @@ export class CompactionOrchestrator {
|
|||
if (!this._deps.modelRegistry.isProviderRequestReady(model.provider)) {
|
||||
throw new Error(`No API key for ${model.provider}`);
|
||||
}
|
||||
// undefined for externalCli/none providers — stripped at the streamSimple boundary (model-registry.ts)
|
||||
const apiKey = await this._deps.modelRegistry.getApiKey(model, this._deps.getSessionId());
|
||||
|
||||
const pathEntries = this._deps.sessionManager.getBranch();
|
||||
|
|
@ -303,6 +304,7 @@ export class CompactionOrchestrator {
|
|||
this._deps.emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
|
||||
return;
|
||||
}
|
||||
// undefined for externalCli/none providers — stripped at the streamSimple boundary (model-registry.ts)
|
||||
const apiKey = await this._deps.modelRegistry.getApiKey(model, this._deps.getSessionId());
|
||||
|
||||
const pathEntries = this._deps.sessionManager.getBranch();
|
||||
|
|
|
|||
|
|
@ -1242,7 +1242,8 @@ export interface ExtensionAPI {
|
|||
export interface ProviderConfig {
|
||||
/** Auth behavior for provider availability and request key handling. Defaults to "apiKey". */
|
||||
authMode?: "apiKey" | "oauth" | "externalCli" | "none";
|
||||
/** Optional readiness check. Return false if the provider cannot accept requests (e.g., CLI not authenticated, API key invalid). Called before default auth checks. */
|
||||
/** Optional readiness check. Return false if the provider cannot accept requests (e.g., CLI not authenticated, API key invalid).
|
||||
* Called before default auth checks. Trusted at the same level as extension code — extensions already have arbitrary code execution. */
|
||||
isReady?: () => boolean;
|
||||
/** Base URL for the API endpoint. Required when defining models. */
|
||||
baseUrl?: string;
|
||||
|
|
|
|||
227
packages/pi-coding-agent/src/core/lifecycle-hooks.test.ts
Normal file
227
packages/pi-coding-agent/src/core/lifecycle-hooks.test.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
readManifestRuntimeDeps,
|
||||
collectRuntimeDependencies,
|
||||
verifyRuntimeDependencies,
|
||||
resolveLocalSourcePath,
|
||||
} from "./lifecycle-hooks.js";
|
||||
|
||||
function tmpDir(prefix: string, t: { after: (fn: () => void) => void }): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), `pi-lh-${prefix}-`));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ─── readManifestRuntimeDeps ──────────────────────────────────────────────────
|
||||
|
||||
describe("readManifestRuntimeDeps", () => {
|
||||
it("returns empty array when manifest file is missing", (t) => {
|
||||
const dir = tmpDir("no-manifest", t);
|
||||
assert.deepEqual(readManifestRuntimeDeps(dir), []);
|
||||
});
|
||||
|
||||
it("returns empty array for malformed JSON", (t) => {
|
||||
const dir = tmpDir("bad-json", t);
|
||||
writeFileSync(join(dir, "extension-manifest.json"), "not json{{{", "utf-8");
|
||||
assert.deepEqual(readManifestRuntimeDeps(dir), []);
|
||||
});
|
||||
|
||||
it("returns runtime deps from valid manifest", (t) => {
|
||||
const dir = tmpDir("valid", t);
|
||||
writeFileSync(join(dir, "extension-manifest.json"), JSON.stringify({
|
||||
dependencies: { runtime: ["claude", "node"] },
|
||||
}), "utf-8");
|
||||
assert.deepEqual(readManifestRuntimeDeps(dir), ["claude", "node"]);
|
||||
});
|
||||
|
||||
it("returns empty array when dependencies exists but runtime is missing", (t) => {
|
||||
const dir = tmpDir("no-runtime", t);
|
||||
writeFileSync(join(dir, "extension-manifest.json"), JSON.stringify({
|
||||
dependencies: {},
|
||||
}), "utf-8");
|
||||
assert.deepEqual(readManifestRuntimeDeps(dir), []);
|
||||
});
|
||||
|
||||
it("returns empty array when runtime is empty", (t) => {
|
||||
const dir = tmpDir("empty-runtime", t);
|
||||
writeFileSync(join(dir, "extension-manifest.json"), JSON.stringify({
|
||||
dependencies: { runtime: [] },
|
||||
}), "utf-8");
|
||||
assert.deepEqual(readManifestRuntimeDeps(dir), []);
|
||||
});
|
||||
|
||||
it("filters out non-string entries in runtime array", (t) => {
|
||||
const dir = tmpDir("mixed-types", t);
|
||||
writeFileSync(join(dir, "extension-manifest.json"), JSON.stringify({
|
||||
dependencies: { runtime: [123, null, "node", false, "python"] },
|
||||
}), "utf-8");
|
||||
assert.deepEqual(readManifestRuntimeDeps(dir), ["node", "python"]);
|
||||
});
|
||||
|
||||
it("returns empty array when no dependencies field at all", (t) => {
|
||||
const dir = tmpDir("no-deps-field", t);
|
||||
writeFileSync(join(dir, "extension-manifest.json"), JSON.stringify({
|
||||
id: "test",
|
||||
name: "Test",
|
||||
}), "utf-8");
|
||||
assert.deepEqual(readManifestRuntimeDeps(dir), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── collectRuntimeDependencies ───────────────────────────────────────────────
|
||||
|
||||
describe("collectRuntimeDependencies", () => {
|
||||
it("aggregates deps from installedPath manifest", (t) => {
|
||||
const dir = tmpDir("collect-installed", t);
|
||||
writeFileSync(join(dir, "extension-manifest.json"), JSON.stringify({
|
||||
dependencies: { runtime: ["claude"] },
|
||||
}), "utf-8");
|
||||
assert.deepEqual(collectRuntimeDependencies(dir, []), ["claude"]);
|
||||
});
|
||||
|
||||
it("aggregates deps from entry path directory manifests", (t) => {
|
||||
const root = tmpDir("collect-entry", t);
|
||||
const installedDir = join(root, "installed");
|
||||
const entryDir = join(root, "entry");
|
||||
mkdirSync(installedDir, { recursive: true });
|
||||
mkdirSync(entryDir, { recursive: true });
|
||||
writeFileSync(join(entryDir, "extension-manifest.json"), JSON.stringify({
|
||||
dependencies: { runtime: ["python"] },
|
||||
}), "utf-8");
|
||||
const deps = collectRuntimeDependencies(installedDir, [join(entryDir, "index.ts")]);
|
||||
assert.deepEqual(deps, ["python"]);
|
||||
});
|
||||
|
||||
it("deduplicates across multiple directories", (t) => {
|
||||
const root = tmpDir("collect-dedup", t);
|
||||
const dir1 = join(root, "dir1");
|
||||
const dir2 = join(root, "dir2");
|
||||
mkdirSync(dir1, { recursive: true });
|
||||
mkdirSync(dir2, { recursive: true });
|
||||
writeFileSync(join(dir1, "extension-manifest.json"), JSON.stringify({
|
||||
dependencies: { runtime: ["node", "python"] },
|
||||
}), "utf-8");
|
||||
writeFileSync(join(dir2, "extension-manifest.json"), JSON.stringify({
|
||||
dependencies: { runtime: ["python", "claude"] },
|
||||
}), "utf-8");
|
||||
const deps = collectRuntimeDependencies(dir1, [join(dir2, "index.ts")]);
|
||||
assert.equal(deps.length, 3);
|
||||
assert.ok(deps.includes("node"));
|
||||
assert.ok(deps.includes("python"));
|
||||
assert.ok(deps.includes("claude"));
|
||||
});
|
||||
|
||||
it("returns empty when no directories have manifests", (t) => {
|
||||
const dir = tmpDir("collect-empty", t);
|
||||
assert.deepEqual(collectRuntimeDependencies(dir, []), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── verifyRuntimeDependencies ────────────────────────────────────────────────
|
||||
|
||||
describe("verifyRuntimeDependencies", () => {
|
||||
it("does not throw for empty deps array", () => {
|
||||
assert.doesNotThrow(() => verifyRuntimeDependencies([], "test-source", "pi"));
|
||||
});
|
||||
|
||||
it("does not throw when all deps are present", () => {
|
||||
assert.doesNotThrow(() => verifyRuntimeDependencies(["node"], "test-source", "pi"));
|
||||
});
|
||||
|
||||
it("throws for missing dep with 'Missing runtime dependencies' message", () => {
|
||||
assert.throws(
|
||||
() => verifyRuntimeDependencies(["__nonexistent_dep_for_test__"], "test-source", "pi"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("Missing runtime dependencies"));
|
||||
assert.ok(err.message.includes("__nonexistent_dep_for_test__"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("lists all missing deps in error message", () => {
|
||||
assert.throws(
|
||||
() => verifyRuntimeDependencies(["__missing_1__", "__missing_2__"], "test-source", "pi"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("__missing_1__"));
|
||||
assert.ok(err.message.includes("__missing_2__"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("includes appName and source in error for retry hint", () => {
|
||||
assert.throws(
|
||||
() => verifyRuntimeDependencies(["__missing__"], "github:user/repo", "gsd"),
|
||||
(err: Error) => {
|
||||
assert.ok(err.message.includes("gsd"));
|
||||
assert.ok(err.message.includes("github:user/repo"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveLocalSourcePath ───────────────────────────────────────────────────
|
||||
|
||||
describe("resolveLocalSourcePath", () => {
|
||||
it("returns undefined for empty string", () => {
|
||||
assert.equal(resolveLocalSourcePath("", "/tmp"), undefined);
|
||||
});
|
||||
|
||||
it("returns undefined for npm: source", () => {
|
||||
assert.equal(resolveLocalSourcePath("npm:@foo/bar", "/tmp"), undefined);
|
||||
});
|
||||
|
||||
it("returns undefined for git URL", () => {
|
||||
assert.equal(resolveLocalSourcePath("git:github.com/user/repo", "/tmp"), undefined);
|
||||
});
|
||||
|
||||
it("returns undefined for https git URL", () => {
|
||||
assert.equal(resolveLocalSourcePath("https://github.com/user/repo", "/tmp"), undefined);
|
||||
});
|
||||
|
||||
it("resolves ~ to homedir", () => {
|
||||
const result = resolveLocalSourcePath("~", "/tmp");
|
||||
if (existsSync(homedir())) {
|
||||
assert.equal(result, homedir());
|
||||
} else {
|
||||
assert.equal(result, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves ~/path relative to homedir", () => {
|
||||
const result = resolveLocalSourcePath("~/", "/tmp");
|
||||
if (existsSync(homedir())) {
|
||||
assert.equal(result, homedir());
|
||||
} else {
|
||||
assert.equal(result, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves relative path that exists", (t) => {
|
||||
const dir = tmpDir("resolve-rel", t);
|
||||
const sub = join(dir, "myext");
|
||||
mkdirSync(sub, { recursive: true });
|
||||
const result = resolveLocalSourcePath("myext", dir);
|
||||
assert.equal(result, resolve(dir, "myext"));
|
||||
});
|
||||
|
||||
it("returns undefined for relative path that does not exist", (t) => {
|
||||
const dir = tmpDir("resolve-noexist", t);
|
||||
assert.equal(resolveLocalSourcePath("nonexistent", dir), undefined);
|
||||
});
|
||||
|
||||
it("resolves absolute path that exists", (t) => {
|
||||
const dir = tmpDir("resolve-abs", t);
|
||||
assert.equal(resolveLocalSourcePath(dir, "/irrelevant"), dir);
|
||||
});
|
||||
|
||||
it("returns undefined for absolute path that does not exist", () => {
|
||||
assert.equal(resolveLocalSourcePath("/tmp/__nonexistent_path_for_test__", "/tmp"), undefined);
|
||||
});
|
||||
});
|
||||
|
|
@ -62,7 +62,7 @@ function toScope(local: boolean): LifecycleHookScope {
|
|||
return local ? "project" : "user";
|
||||
}
|
||||
|
||||
function readManifestRuntimeDeps(dir: string): string[] {
|
||||
export function readManifestRuntimeDeps(dir: string): string[] {
|
||||
const manifestPath = join(dir, "extension-manifest.json");
|
||||
if (!existsSync(manifestPath)) return [];
|
||||
try {
|
||||
|
|
@ -73,7 +73,7 @@ function readManifestRuntimeDeps(dir: string): string[] {
|
|||
}
|
||||
}
|
||||
|
||||
function collectRuntimeDependencies(installedPath: string, entryPaths: string[]): string[] {
|
||||
export function collectRuntimeDependencies(installedPath: string, entryPaths: string[]): string[] {
|
||||
const deps = new Set<string>();
|
||||
const candidateDirs = new Set<string>([installedPath, ...entryPaths.map((entryPath) => dirname(entryPath))]);
|
||||
for (const dir of candidateDirs) {
|
||||
|
|
@ -84,7 +84,7 @@ function collectRuntimeDependencies(installedPath: string, entryPaths: string[])
|
|||
return Array.from(deps);
|
||||
}
|
||||
|
||||
function verifyRuntimeDependencies(runtimeDeps: string[], source: string, appName: string): void {
|
||||
export function verifyRuntimeDependencies(runtimeDeps: string[], source: string, appName: string): void {
|
||||
const missing: string[] = [];
|
||||
for (const dep of runtimeDeps) {
|
||||
const result = spawnSync(dep, ["--version"], { encoding: "utf-8", timeout: 5000 });
|
||||
|
|
@ -99,7 +99,7 @@ function verifyRuntimeDependencies(runtimeDeps: string[], source: string, appNam
|
|||
);
|
||||
}
|
||||
|
||||
function resolveLocalSourcePath(source: string, cwd: string): string | undefined {
|
||||
export function resolveLocalSourcePath(source: string, cwd: string): string | undefined {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed.startsWith("npm:")) return undefined;
|
||||
|
|
@ -193,13 +193,19 @@ function getLegacyExportCandidates(phase: LifecycleHookPhase): string[] {
|
|||
return [phase];
|
||||
}
|
||||
|
||||
const _legacyModuleCache = new Map<string, Record<string, unknown>>();
|
||||
|
||||
async function runLegacyExportHook(
|
||||
entryPath: string,
|
||||
phase: LifecycleHookPhase,
|
||||
context: LifecycleHookContext,
|
||||
): Promise<LifecycleHookHandler | null> {
|
||||
try {
|
||||
const module = await importExtensionModule<Record<string, unknown>>(import.meta.url, pathToFileURL(entryPath).href);
|
||||
let module = _legacyModuleCache.get(entryPath);
|
||||
if (!module) {
|
||||
module = await importExtensionModule<Record<string, unknown>>(import.meta.url, pathToFileURL(entryPath).href);
|
||||
_legacyModuleCache.set(entryPath, module);
|
||||
}
|
||||
for (const exportName of getLegacyExportCandidates(phase)) {
|
||||
const candidate = module[exportName];
|
||||
if (typeof candidate === "function") {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Api, Model } from "@gsd/pi-ai";
|
||||
import type { Api, Model, SimpleStreamOptions, Context, AssistantMessageEventStream } from "@gsd/pi-ai";
|
||||
import { getApiProvider } from "@gsd/pi-ai";
|
||||
import type { AuthStorage } from "./auth-storage.js";
|
||||
import { ModelRegistry } from "./model-registry.js";
|
||||
|
||||
|
|
@ -17,11 +18,11 @@ function createRegistry(hasAuthFn?: (provider: string) => boolean): ModelRegistr
|
|||
return new ModelRegistry(authStorage, undefined);
|
||||
}
|
||||
|
||||
function createProviderModel(id: string): NonNullable<Parameters<ModelRegistry["registerProvider"]>[1]["models"]>[number] {
|
||||
function createProviderModel(id: string, api?: string): NonNullable<Parameters<ModelRegistry["registerProvider"]>[1]["models"]>[number] {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
api: "openai-completions",
|
||||
api: (api ?? "openai-completions") as Api,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
|
|
@ -34,34 +35,89 @@ function findModel(registry: ModelRegistry, provider: string, id: string): Model
|
|||
return registry.getAvailable().find((m) => m.provider === provider && m.id === id);
|
||||
}
|
||||
|
||||
function makeModel(provider: string, id: string, api: string): Model<Api> {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
api: api as Api,
|
||||
provider,
|
||||
baseUrl: `${provider}:`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(): Context {
|
||||
return {
|
||||
systemPrompt: "test",
|
||||
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
||||
};
|
||||
}
|
||||
|
||||
/** No-op streamSimple for tests that need one to pass validation but don't inspect it. */
|
||||
const noopStreamSimple = (_model: Model<Api>, _context: Context, _options?: SimpleStreamOptions) => {
|
||||
return {
|
||||
[Symbol.asyncIterator]() { return { next: async () => ({ value: undefined, done: true as const }) }; },
|
||||
result: () => Promise.resolve({ role: "assistant" as const, content: [], api: "test" as Api, provider: "test", model: "test", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop" as const, timestamp: Date.now() }),
|
||||
push: () => {},
|
||||
end: () => {},
|
||||
} as unknown as AssistantMessageEventStream;
|
||||
};
|
||||
|
||||
/** Create a spy streamSimple that captures the options it receives and returns a stub stream. */
|
||||
function createStreamSpy(): {
|
||||
streamSimple: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
|
||||
getCapturedOptions: () => SimpleStreamOptions | undefined;
|
||||
} {
|
||||
let capturedOptions: SimpleStreamOptions | undefined;
|
||||
const streamSimple = (_model: Model<Api>, _context: Context, options?: SimpleStreamOptions) => {
|
||||
capturedOptions = options;
|
||||
// Return a minimal stub that satisfies AssistantMessageEventStream
|
||||
return {
|
||||
[Symbol.asyncIterator]() { return { next: async () => ({ value: undefined, done: true as const }) }; },
|
||||
result: () => Promise.resolve({ role: "assistant" as const, content: [], api: "test" as Api, provider: "test", model: "test", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop" as const, timestamp: Date.now() }),
|
||||
push: () => {},
|
||||
end: () => {},
|
||||
} as unknown as AssistantMessageEventStream;
|
||||
};
|
||||
return { streamSimple, getCapturedOptions: () => capturedOptions };
|
||||
}
|
||||
|
||||
// ─── Registration ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ModelRegistry authMode — registration", () => {
|
||||
it("registers externalCli provider without apiKey/oauth", () => {
|
||||
it("registers externalCli provider with streamSimple and without apiKey/oauth", () => {
|
||||
const registry = createRegistry();
|
||||
const spy = createStreamSpy();
|
||||
assert.doesNotThrow(() => {
|
||||
registry.registerProvider("cli-provider", {
|
||||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: "openai-completions",
|
||||
streamSimple: spy.streamSimple,
|
||||
models: [createProviderModel("cli-model")],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("registers none provider without apiKey/oauth", () => {
|
||||
it("registers none provider with streamSimple and without apiKey/oauth", () => {
|
||||
const registry = createRegistry();
|
||||
const spy = createStreamSpy();
|
||||
assert.doesNotThrow(() => {
|
||||
registry.registerProvider("none-provider", {
|
||||
authMode: "none",
|
||||
baseUrl: "http://localhost:11434",
|
||||
api: "openai-completions",
|
||||
streamSimple: spy.streamSimple,
|
||||
models: [createProviderModel("local-model")],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects apiKey provider without apiKey or oauth", () => {
|
||||
it("rejects apiKey provider without apiKey or oauth — message mentions authMode", () => {
|
||||
const registry = createRegistry();
|
||||
assert.throws(() => {
|
||||
registry.registerProvider("apikey-provider", {
|
||||
|
|
@ -70,6 +126,10 @@ describe("ModelRegistry authMode — registration", () => {
|
|||
api: "openai-completions",
|
||||
models: [createProviderModel("model")],
|
||||
});
|
||||
}, (err: Error) => {
|
||||
assert.ok(err.message.includes("authMode"), "error message must mention authMode");
|
||||
assert.ok(err.message.includes("externalCli"), "error message must suggest externalCli");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -81,6 +141,79 @@ describe("ModelRegistry authMode — registration", () => {
|
|||
api: "openai-completions",
|
||||
models: [createProviderModel("model")],
|
||||
});
|
||||
}, (err: Error) => {
|
||||
assert.ok(err.message.includes("authMode"), "error message must mention authMode");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects externalCli provider without streamSimple", () => {
|
||||
const registry = createRegistry();
|
||||
assert.throws(() => {
|
||||
registry.registerProvider("cli-no-stream", {
|
||||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: "openai-completions",
|
||||
models: [createProviderModel("model")],
|
||||
});
|
||||
}, (err: Error) => {
|
||||
assert.ok(err.message.includes("streamSimple"), "error message must mention streamSimple");
|
||||
assert.ok(err.message.includes("externalCli"), "error message must mention authMode");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects none provider without streamSimple", () => {
|
||||
const registry = createRegistry();
|
||||
assert.throws(() => {
|
||||
registry.registerProvider("none-no-stream", {
|
||||
authMode: "none",
|
||||
baseUrl: "http://localhost:11434",
|
||||
api: "openai-completions",
|
||||
models: [createProviderModel("model")],
|
||||
});
|
||||
}, (err: Error) => {
|
||||
assert.ok(err.message.includes("streamSimple"), "error message must mention streamSimple");
|
||||
assert.ok(err.message.includes("none"), "error message must mention authMode");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects externalCli provider that also sets apiKey", () => {
|
||||
const registry = createRegistry();
|
||||
const spy = createStreamSpy();
|
||||
assert.throws(() => {
|
||||
registry.registerProvider("cli-with-key", {
|
||||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: "openai-completions",
|
||||
apiKey: "SHOULD_NOT_EXIST",
|
||||
streamSimple: spy.streamSimple,
|
||||
models: [createProviderModel("model")],
|
||||
});
|
||||
}, (err: Error) => {
|
||||
assert.ok(err.message.includes("apiKey"), "error message must mention apiKey");
|
||||
assert.ok(err.message.includes("externalCli"), "error message must mention authMode");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects none provider that also sets apiKey", () => {
|
||||
const registry = createRegistry();
|
||||
const spy = createStreamSpy();
|
||||
assert.throws(() => {
|
||||
registry.registerProvider("none-with-key", {
|
||||
authMode: "none",
|
||||
baseUrl: "http://localhost:11434",
|
||||
api: "openai-completions",
|
||||
apiKey: "SHOULD_NOT_EXIST",
|
||||
streamSimple: spy.streamSimple,
|
||||
models: [createProviderModel("model")],
|
||||
});
|
||||
}, (err: Error) => {
|
||||
assert.ok(err.message.includes("apiKey"), "error message must mention apiKey");
|
||||
assert.ok(err.message.includes("none"), "error message must mention authMode");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -99,6 +232,7 @@ describe("ModelRegistry authMode — getProviderAuthMode", () => {
|
|||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
models: [createProviderModel("m")],
|
||||
});
|
||||
assert.equal(registry.getProviderAuthMode("cli"), "externalCli");
|
||||
|
|
@ -110,6 +244,7 @@ describe("ModelRegistry authMode — getProviderAuthMode", () => {
|
|||
authMode: "none",
|
||||
baseUrl: "http://localhost:11434",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
models: [createProviderModel("m")],
|
||||
});
|
||||
assert.equal(registry.getProviderAuthMode("local"), "none");
|
||||
|
|
@ -125,6 +260,7 @@ describe("ModelRegistry authMode — isProviderRequestReady", () => {
|
|||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
models: [createProviderModel("m")],
|
||||
});
|
||||
assert.equal(registry.isProviderRequestReady("cli"), true);
|
||||
|
|
@ -136,6 +272,7 @@ describe("ModelRegistry authMode — isProviderRequestReady", () => {
|
|||
authMode: "none",
|
||||
baseUrl: "http://localhost:11434",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
models: [createProviderModel("m")],
|
||||
});
|
||||
assert.equal(registry.isProviderRequestReady("local"), true);
|
||||
|
|
@ -161,6 +298,7 @@ describe("ModelRegistry authMode — isReady callback", () => {
|
|||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
isReady: () => false,
|
||||
models: [createProviderModel("m")],
|
||||
});
|
||||
|
|
@ -185,6 +323,7 @@ describe("ModelRegistry authMode — isReady callback", () => {
|
|||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
isReady: () => true,
|
||||
models: [createProviderModel("m")],
|
||||
});
|
||||
|
|
@ -197,6 +336,7 @@ describe("ModelRegistry authMode — isReady callback", () => {
|
|||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
models: [createProviderModel("m")],
|
||||
});
|
||||
// externalCli without isReady → true (default)
|
||||
|
|
@ -213,6 +353,7 @@ describe("ModelRegistry authMode — getAvailable", () => {
|
|||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
models: [createProviderModel("cli-model")],
|
||||
});
|
||||
assert.ok(findModel(registry, "cli", "cli-model"));
|
||||
|
|
@ -224,6 +365,7 @@ describe("ModelRegistry authMode — getAvailable", () => {
|
|||
authMode: "none",
|
||||
baseUrl: "http://localhost:11434",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
models: [createProviderModel("local-model")],
|
||||
});
|
||||
assert.ok(findModel(registry, "local", "local-model"));
|
||||
|
|
@ -235,6 +377,7 @@ describe("ModelRegistry authMode — getAvailable", () => {
|
|||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
isReady: () => false,
|
||||
models: [createProviderModel("m")],
|
||||
});
|
||||
|
|
@ -243,10 +386,7 @@ describe("ModelRegistry authMode — getAvailable", () => {
|
|||
|
||||
it("excludes apiKey models without stored auth", () => {
|
||||
const registry = createRegistry(() => false);
|
||||
// Built-in providers have no registeredProviders entry, so authMode defaults to apiKey
|
||||
// getAvailable filters by isProviderRequestReady → hasAuth → false
|
||||
const available = registry.getAvailable();
|
||||
// No models should be available since hasAuth returns false for everything
|
||||
assert.equal(available.length, 0);
|
||||
});
|
||||
});
|
||||
|
|
@ -260,6 +400,7 @@ describe("ModelRegistry authMode — getApiKey", () => {
|
|||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
models: [createProviderModel("m")],
|
||||
});
|
||||
const model = registry.getAll().find((m) => m.provider === "cli")!;
|
||||
|
|
@ -272,6 +413,7 @@ describe("ModelRegistry authMode — getApiKey", () => {
|
|||
authMode: "none",
|
||||
baseUrl: "http://localhost:11434",
|
||||
api: "openai-completions",
|
||||
streamSimple: noopStreamSimple,
|
||||
models: [createProviderModel("m")],
|
||||
});
|
||||
const model = registry.getAll().find((m) => m.provider === "local")!;
|
||||
|
|
@ -280,9 +422,153 @@ describe("ModelRegistry authMode — getApiKey", () => {
|
|||
|
||||
it("delegates to authStorage for apiKey provider", async () => {
|
||||
const registry = createRegistry();
|
||||
// authStorage.getApiKey returns undefined (no key configured)
|
||||
// For apiKey providers this is an expected "no key" response, not early exit
|
||||
const key = await registry.getApiKeyForProvider("anthropic");
|
||||
assert.equal(key, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── streamSimple apiKey stripping ────────────────────────────────────────────
|
||||
|
||||
describe("ModelRegistry authMode — streamSimple apiKey boundary", () => {
|
||||
it("strips apiKey from options for externalCli provider", () => {
|
||||
const registry = createRegistry();
|
||||
const spy = createStreamSpy();
|
||||
const apiType = `ext-cli-strip-${Date.now()}`;
|
||||
|
||||
registry.registerProvider("cli-strip", {
|
||||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: apiType as Api,
|
||||
streamSimple: spy.streamSimple,
|
||||
models: [createProviderModel("m", apiType)],
|
||||
});
|
||||
|
||||
const provider = getApiProvider(apiType as Api);
|
||||
assert.ok(provider, "provider must be registered in api registry");
|
||||
|
||||
provider.streamSimple(
|
||||
makeModel("cli-strip", "m", apiType),
|
||||
makeContext(),
|
||||
{ apiKey: "should-be-stripped", maxTokens: 1024 } as SimpleStreamOptions,
|
||||
);
|
||||
|
||||
const captured = spy.getCapturedOptions();
|
||||
assert.ok(captured, "streamSimple must have been called");
|
||||
assert.equal("apiKey" in captured, false, "apiKey must not exist in options for externalCli provider");
|
||||
assert.equal(captured.maxTokens, 1024, "other options must pass through");
|
||||
});
|
||||
|
||||
it("strips apiKey from options for none provider", () => {
|
||||
const registry = createRegistry();
|
||||
const spy = createStreamSpy();
|
||||
const apiType = `none-strip-${Date.now()}`;
|
||||
|
||||
registry.registerProvider("none-strip", {
|
||||
authMode: "none",
|
||||
baseUrl: "http://localhost:11434",
|
||||
api: apiType as Api,
|
||||
streamSimple: spy.streamSimple,
|
||||
models: [createProviderModel("m", apiType)],
|
||||
});
|
||||
|
||||
const provider = getApiProvider(apiType as Api);
|
||||
assert.ok(provider, "provider must be registered in api registry");
|
||||
|
||||
provider.streamSimple(
|
||||
makeModel("none-strip", "m", apiType),
|
||||
makeContext(),
|
||||
{ apiKey: "should-be-stripped", maxTokens: 2048 } as SimpleStreamOptions,
|
||||
);
|
||||
|
||||
const captured = spy.getCapturedOptions();
|
||||
assert.ok(captured, "streamSimple must have been called");
|
||||
assert.equal("apiKey" in captured, false, "apiKey must not exist in options for none provider");
|
||||
assert.equal(captured.maxTokens, 2048, "other options must pass through");
|
||||
});
|
||||
|
||||
it("preserves apiKey in options for apiKey provider", () => {
|
||||
const registry = createRegistry();
|
||||
const spy = createStreamSpy();
|
||||
const apiType = `apikey-preserve-${Date.now()}`;
|
||||
|
||||
registry.registerProvider("apikey-preserve", {
|
||||
apiKey: "MY_KEY",
|
||||
baseUrl: "https://api.local",
|
||||
api: apiType as Api,
|
||||
streamSimple: spy.streamSimple,
|
||||
models: [createProviderModel("m", apiType)],
|
||||
});
|
||||
|
||||
const provider = getApiProvider(apiType as Api);
|
||||
assert.ok(provider, "provider must be registered in api registry");
|
||||
|
||||
provider.streamSimple(
|
||||
makeModel("apikey-preserve", "m", apiType),
|
||||
makeContext(),
|
||||
{ apiKey: "sk-real-key", maxTokens: 4096 } as SimpleStreamOptions,
|
||||
);
|
||||
|
||||
const captured = spy.getCapturedOptions();
|
||||
assert.ok(captured, "streamSimple must have been called");
|
||||
assert.equal(captured.apiKey, "sk-real-key", "apiKey must be preserved for apiKey provider");
|
||||
assert.equal(captured.maxTokens, 4096, "other options must pass through");
|
||||
});
|
||||
|
||||
it("handles undefined options for externalCli provider", () => {
|
||||
const registry = createRegistry();
|
||||
const spy = createStreamSpy();
|
||||
const apiType = `ext-cli-undef-${Date.now()}`;
|
||||
|
||||
registry.registerProvider("cli-undef", {
|
||||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: apiType as Api,
|
||||
streamSimple: spy.streamSimple,
|
||||
models: [createProviderModel("m", apiType)],
|
||||
});
|
||||
|
||||
const provider = getApiProvider(apiType as Api);
|
||||
assert.ok(provider, "provider must be registered in api registry");
|
||||
|
||||
provider.streamSimple(
|
||||
makeModel("cli-undef", "m", apiType),
|
||||
makeContext(),
|
||||
undefined,
|
||||
);
|
||||
|
||||
const captured = spy.getCapturedOptions();
|
||||
assert.ok(captured !== undefined, "streamSimple must have been called");
|
||||
assert.equal("apiKey" in captured, false, "apiKey must not exist even when options is undefined");
|
||||
});
|
||||
|
||||
it("strips apiKey but preserves signal and other fields for externalCli", () => {
|
||||
const registry = createRegistry();
|
||||
const spy = createStreamSpy();
|
||||
const apiType = `ext-cli-fields-${Date.now()}`;
|
||||
const abortController = new AbortController();
|
||||
|
||||
registry.registerProvider("cli-fields", {
|
||||
authMode: "externalCli",
|
||||
baseUrl: "https://cli.local",
|
||||
api: apiType as Api,
|
||||
streamSimple: spy.streamSimple,
|
||||
models: [createProviderModel("m", apiType)],
|
||||
});
|
||||
|
||||
const provider = getApiProvider(apiType as Api);
|
||||
assert.ok(provider, "provider must be registered in api registry");
|
||||
|
||||
provider.streamSimple(
|
||||
makeModel("cli-fields", "m", apiType),
|
||||
makeContext(),
|
||||
{ apiKey: "strip-me", maxTokens: 8192, signal: abortController.signal, reasoning: "high" } as SimpleStreamOptions,
|
||||
);
|
||||
|
||||
const captured = spy.getCapturedOptions();
|
||||
assert.ok(captured, "streamSimple must have been called");
|
||||
assert.equal("apiKey" in captured, false, "apiKey must be stripped");
|
||||
assert.equal(captured.maxTokens, 8192, "maxTokens must pass through");
|
||||
assert.equal(captured.signal, abortController.signal, "signal must pass through");
|
||||
assert.equal((captured as Record<string, unknown>).reasoning, "high", "reasoning must pass through");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -623,7 +623,18 @@ export class ModelRegistry {
|
|||
if (!config.api) {
|
||||
throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`);
|
||||
}
|
||||
const streamSimple = config.streamSimple;
|
||||
const rawStreamSimple = config.streamSimple;
|
||||
const authMode = config.authMode ?? "apiKey";
|
||||
|
||||
// Keyless providers never see apiKey in options — enforced at registration,
|
||||
// not by convention. Prevents undefined from reaching any handler.
|
||||
const streamSimple = (authMode === "externalCli" || authMode === "none")
|
||||
? ((model: Model<Api>, context: Context, options?: SimpleStreamOptions) => {
|
||||
const { apiKey: _, ...opts } = options ?? {};
|
||||
return rawStreamSimple(model, context, opts as SimpleStreamOptions);
|
||||
})
|
||||
: rawStreamSimple;
|
||||
|
||||
registerApiProvider(
|
||||
{
|
||||
api: config.api,
|
||||
|
|
@ -649,7 +660,22 @@ export class ModelRegistry {
|
|||
}
|
||||
const authMode = config.authMode ?? (config.oauth ? "oauth" : config.apiKey ? "apiKey" : "apiKey");
|
||||
if (authMode === "apiKey" && !config.apiKey && !config.oauth) {
|
||||
throw new Error(`Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`);
|
||||
throw new Error(
|
||||
`Provider ${providerName}: "apiKey" or "oauth" is required when authMode is "apiKey" (the default). ` +
|
||||
`Set authMode to "externalCli" or "none" for keyless providers.`,
|
||||
);
|
||||
}
|
||||
if ((authMode === "externalCli" || authMode === "none") && !config.streamSimple) {
|
||||
throw new Error(
|
||||
`Provider ${providerName}: "streamSimple" is required when authMode is "${authMode}". ` +
|
||||
`Keyless providers must supply their own stream handler.`,
|
||||
);
|
||||
}
|
||||
if ((authMode === "externalCli" || authMode === "none") && config.apiKey) {
|
||||
throw new Error(
|
||||
`Provider ${providerName}: "apiKey" cannot be set when authMode is "${authMode}". ` +
|
||||
`Keyless providers should not provide API key credentials.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and add new models
|
||||
|
|
@ -834,7 +860,8 @@ export class ModelRegistry {
|
|||
*/
|
||||
export interface ProviderConfigInput {
|
||||
authMode?: ProviderAuthMode;
|
||||
/** Optional readiness check. Called by isProviderRequestReady() before default auth checks. */
|
||||
/** Optional readiness check. Called by isProviderRequestReady() before default auth checks.
|
||||
* Trusted at the same level as extension code — extensions already have arbitrary code execution. */
|
||||
isReady?: () => boolean;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Writable } from "node:stream";
|
||||
|
|
@ -25,216 +25,238 @@ function writePackage(root: string, files: Record<string, string>): void {
|
|||
}
|
||||
}
|
||||
|
||||
function createTestDirs(prefix: string, t: { after: (fn: () => void) => void }) {
|
||||
const root = mkdtempSync(join(tmpdir(), `pi-lifecycle-${prefix}-`));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const cwd = join(root, "cwd");
|
||||
const agentDir = join(root, "agent");
|
||||
const extensionDir = join(root, `ext-${prefix}`);
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(extensionDir, { recursive: true });
|
||||
return { root, cwd, agentDir, extensionDir };
|
||||
}
|
||||
|
||||
describe("runPackageCommand lifecycle hooks", () => {
|
||||
it("executes registered beforeInstall and afterInstall handlers for local packages", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-lifecycle-install-"));
|
||||
const cwd = join(root, "cwd");
|
||||
const agentDir = join(root, "agent");
|
||||
const extensionDir = join(root, "ext-registered");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(extensionDir, { recursive: true });
|
||||
it("executes registered beforeInstall and afterInstall handlers for local packages", async (t) => {
|
||||
const { cwd, agentDir, extensionDir } = createTestDirs("install", t);
|
||||
|
||||
try {
|
||||
writePackage(extensionDir, {
|
||||
"package.json": JSON.stringify({
|
||||
name: "ext-registered",
|
||||
type: "module",
|
||||
pi: { extensions: ["./index.js"] },
|
||||
}),
|
||||
"index.js": `
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
export default function (pi) {
|
||||
pi.registerBeforeInstall((ctx) => {
|
||||
writeFileSync(join(ctx.installedPath, "before-install-ran.txt"), "ok", "utf-8");
|
||||
});
|
||||
pi.registerAfterInstall((ctx) => {
|
||||
writeFileSync(join(ctx.installedPath, "after-install-ran.txt"), "ok", "utf-8");
|
||||
});
|
||||
}
|
||||
`,
|
||||
});
|
||||
writePackage(extensionDir, {
|
||||
"package.json": JSON.stringify({
|
||||
name: "ext-registered",
|
||||
type: "module",
|
||||
pi: { extensions: ["./index.js"] },
|
||||
}),
|
||||
"index.js": [
|
||||
'import { writeFileSync } from "node:fs";',
|
||||
'import { join } from "node:path";',
|
||||
"export default function (pi) {",
|
||||
" pi.registerBeforeInstall((ctx) => {",
|
||||
' writeFileSync(join(ctx.installedPath, "before-install-ran.txt"), "ok", "utf-8");',
|
||||
" });",
|
||||
" pi.registerAfterInstall((ctx) => {",
|
||||
' writeFileSync(join(ctx.installedPath, "after-install-ran.txt"), "ok", "utf-8");',
|
||||
" });",
|
||||
"}",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const stdout = createCaptureStream();
|
||||
const stderr = createCaptureStream();
|
||||
const result = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["install", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
const stdout = createCaptureStream();
|
||||
const stderr = createCaptureStream();
|
||||
const result = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["install", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
|
||||
assert.equal(result.handled, true);
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(readFileSync(join(extensionDir, "before-install-ran.txt"), "utf-8"), "ok");
|
||||
assert.equal(readFileSync(join(extensionDir, "after-install-ran.txt"), "utf-8"), "ok");
|
||||
assert.ok(stdout.getOutput().includes(`Installed ${extensionDir}`));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
assert.equal(result.handled, true);
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(readFileSync(join(extensionDir, "before-install-ran.txt"), "utf-8"), "ok");
|
||||
assert.equal(readFileSync(join(extensionDir, "after-install-ran.txt"), "utf-8"), "ok");
|
||||
assert.ok(stdout.getOutput().includes(`Installed ${extensionDir}`));
|
||||
});
|
||||
|
||||
it("runs legacy named lifecycle hooks when no registered hooks exist", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-lifecycle-legacy-"));
|
||||
const cwd = join(root, "cwd");
|
||||
const agentDir = join(root, "agent");
|
||||
const extensionDir = join(root, "ext-legacy");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(extensionDir, { recursive: true });
|
||||
it("runs legacy named lifecycle hooks when no registered hooks exist", async (t) => {
|
||||
const { cwd, agentDir, extensionDir } = createTestDirs("legacy", t);
|
||||
|
||||
try {
|
||||
writePackage(extensionDir, {
|
||||
"package.json": JSON.stringify({
|
||||
name: "ext-legacy",
|
||||
type: "module",
|
||||
pi: { extensions: ["./index.js"] },
|
||||
}),
|
||||
"index.js": `
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
export default function () {}
|
||||
export async function beforeInstall(ctx) {
|
||||
writeFileSync(join(ctx.installedPath, "legacy-before-install.txt"), "ok", "utf-8");
|
||||
}
|
||||
export async function afterInstall(ctx) {
|
||||
writeFileSync(join(ctx.installedPath, "legacy-after-install.txt"), "ok", "utf-8");
|
||||
}
|
||||
export async function beforeRemove(ctx) {
|
||||
writeFileSync(join(ctx.installedPath, "legacy-before-remove.txt"), "ok", "utf-8");
|
||||
}
|
||||
export async function afterRemove(ctx) {
|
||||
writeFileSync(join(ctx.installedPath, "legacy-after-remove.txt"), "ok", "utf-8");
|
||||
}
|
||||
`,
|
||||
});
|
||||
writePackage(extensionDir, {
|
||||
"package.json": JSON.stringify({
|
||||
name: "ext-legacy",
|
||||
type: "module",
|
||||
pi: { extensions: ["./index.js"] },
|
||||
}),
|
||||
"index.js": [
|
||||
'import { writeFileSync } from "node:fs";',
|
||||
'import { join } from "node:path";',
|
||||
"export default function () {}",
|
||||
"export async function beforeInstall(ctx) {",
|
||||
' writeFileSync(join(ctx.installedPath, "legacy-before-install.txt"), "ok", "utf-8");',
|
||||
"}",
|
||||
"export async function afterInstall(ctx) {",
|
||||
' writeFileSync(join(ctx.installedPath, "legacy-after-install.txt"), "ok", "utf-8");',
|
||||
"}",
|
||||
"export async function beforeRemove(ctx) {",
|
||||
' writeFileSync(join(ctx.installedPath, "legacy-before-remove.txt"), "ok", "utf-8");',
|
||||
"}",
|
||||
"export async function afterRemove(ctx) {",
|
||||
' writeFileSync(join(ctx.installedPath, "legacy-after-remove.txt"), "ok", "utf-8");',
|
||||
"}",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const stdout = createCaptureStream();
|
||||
const stderr = createCaptureStream();
|
||||
const installResult = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["install", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
const stdout = createCaptureStream();
|
||||
const stderr = createCaptureStream();
|
||||
const installResult = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["install", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
|
||||
assert.equal(installResult.handled, true);
|
||||
assert.equal(installResult.exitCode, 0);
|
||||
assert.equal(readFileSync(join(extensionDir, "legacy-before-install.txt"), "utf-8"), "ok");
|
||||
assert.equal(readFileSync(join(extensionDir, "legacy-after-install.txt"), "utf-8"), "ok");
|
||||
assert.equal(installResult.handled, true);
|
||||
assert.equal(installResult.exitCode, 0);
|
||||
assert.equal(readFileSync(join(extensionDir, "legacy-before-install.txt"), "utf-8"), "ok");
|
||||
assert.equal(readFileSync(join(extensionDir, "legacy-after-install.txt"), "utf-8"), "ok");
|
||||
|
||||
const removeResult = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["remove", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
const removeResult = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["remove", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
|
||||
assert.equal(removeResult.handled, true);
|
||||
assert.equal(removeResult.exitCode, 0);
|
||||
assert.equal(readFileSync(join(extensionDir, "legacy-before-remove.txt"), "utf-8"), "ok");
|
||||
assert.equal(readFileSync(join(extensionDir, "legacy-after-remove.txt"), "utf-8"), "ok");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
assert.equal(removeResult.handled, true);
|
||||
assert.equal(removeResult.exitCode, 0);
|
||||
assert.equal(readFileSync(join(extensionDir, "legacy-before-remove.txt"), "utf-8"), "ok");
|
||||
assert.equal(readFileSync(join(extensionDir, "legacy-after-remove.txt"), "utf-8"), "ok");
|
||||
});
|
||||
|
||||
it("skips lifecycle phases with no hooks declared", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-lifecycle-skip-"));
|
||||
const cwd = join(root, "cwd");
|
||||
const agentDir = join(root, "agent");
|
||||
const extensionDir = join(root, "ext-empty");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(extensionDir, { recursive: true });
|
||||
it("skips lifecycle phases with no hooks declared", async (t) => {
|
||||
const { cwd, agentDir, extensionDir } = createTestDirs("skip", t);
|
||||
|
||||
try {
|
||||
writePackage(extensionDir, {
|
||||
"package.json": JSON.stringify({
|
||||
name: "ext-empty",
|
||||
type: "module",
|
||||
pi: { extensions: ["./index.js"] },
|
||||
}),
|
||||
"index.js": `export default function () {}`,
|
||||
});
|
||||
writePackage(extensionDir, {
|
||||
"package.json": JSON.stringify({
|
||||
name: "ext-empty",
|
||||
type: "module",
|
||||
pi: { extensions: ["./index.js"] },
|
||||
}),
|
||||
"index.js": "export default function () {}",
|
||||
});
|
||||
|
||||
const stdout = createCaptureStream();
|
||||
const stderr = createCaptureStream();
|
||||
const installResult = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["install", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
assert.equal(installResult.handled, true);
|
||||
assert.equal(installResult.exitCode, 0);
|
||||
const stdout = createCaptureStream();
|
||||
const stderr = createCaptureStream();
|
||||
const installResult = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["install", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
assert.equal(installResult.handled, true);
|
||||
assert.equal(installResult.exitCode, 0);
|
||||
|
||||
const removeResult = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["remove", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
assert.equal(removeResult.handled, true);
|
||||
assert.equal(removeResult.exitCode, 0);
|
||||
assert.equal(stderr.getOutput().includes("Hook failed"), false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
const removeResult = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["remove", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
assert.equal(removeResult.handled, true);
|
||||
assert.equal(removeResult.exitCode, 0);
|
||||
assert.equal(stderr.getOutput().includes("Hook failed"), false);
|
||||
});
|
||||
|
||||
it("fails install when manifest runtime dependency is missing", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "pi-lifecycle-deps-"));
|
||||
const cwd = join(root, "cwd");
|
||||
const agentDir = join(root, "agent");
|
||||
const extensionDir = join(root, "ext-runtime-deps");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
mkdirSync(extensionDir, { recursive: true });
|
||||
it("fails install when manifest runtime dependency is missing", async (t) => {
|
||||
const { cwd, agentDir, extensionDir } = createTestDirs("deps", t);
|
||||
|
||||
try {
|
||||
writePackage(extensionDir, {
|
||||
"package.json": JSON.stringify({
|
||||
name: "ext-runtime-deps",
|
||||
type: "module",
|
||||
pi: { extensions: ["./index.js"] },
|
||||
}),
|
||||
"index.js": `export default function () {}`,
|
||||
"extension-manifest.json": JSON.stringify({
|
||||
id: "ext-runtime-deps",
|
||||
name: "Runtime Dep Test",
|
||||
version: "1.0.0",
|
||||
dependencies: { runtime: ["__definitely_missing_command_for_test__"] },
|
||||
}),
|
||||
});
|
||||
writePackage(extensionDir, {
|
||||
"package.json": JSON.stringify({
|
||||
name: "ext-runtime-deps",
|
||||
type: "module",
|
||||
pi: { extensions: ["./index.js"] },
|
||||
}),
|
||||
"index.js": "export default function () {}",
|
||||
"extension-manifest.json": JSON.stringify({
|
||||
id: "ext-runtime-deps",
|
||||
name: "Runtime Dep Test",
|
||||
version: "1.0.0",
|
||||
dependencies: { runtime: ["__definitely_missing_command_for_test__"] },
|
||||
}),
|
||||
});
|
||||
|
||||
const stdout = createCaptureStream();
|
||||
const stderr = createCaptureStream();
|
||||
const result = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["install", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
const stdout = createCaptureStream();
|
||||
const stderr = createCaptureStream();
|
||||
const result = await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["install", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
|
||||
assert.equal(result.handled, true);
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.ok(stderr.getOutput().includes("Missing runtime dependencies"));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
assert.equal(result.handled, true);
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.ok(stderr.getOutput().includes("Missing runtime dependencies"));
|
||||
});
|
||||
|
||||
it("afterRemove hook receives installedPath even when directory is deleted", async (t) => {
|
||||
const { cwd, agentDir, extensionDir } = createTestDirs("after-remove", t);
|
||||
|
||||
writePackage(extensionDir, {
|
||||
"package.json": JSON.stringify({
|
||||
name: "ext-after-remove",
|
||||
type: "module",
|
||||
pi: { extensions: ["./index.js"] },
|
||||
}),
|
||||
"index.js": [
|
||||
'import { writeFileSync, existsSync } from "node:fs";',
|
||||
'import { join } from "node:path";',
|
||||
"export default function () {}",
|
||||
"export async function afterRemove(ctx) {",
|
||||
' const marker = join(ctx.cwd, "after-remove-marker.json");',
|
||||
" writeFileSync(marker, JSON.stringify({",
|
||||
" receivedPath: ctx.installedPath,",
|
||||
" pathExisted: existsSync(ctx.installedPath),",
|
||||
' }), "utf-8");',
|
||||
"}",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const stdout = createCaptureStream();
|
||||
const stderr = createCaptureStream();
|
||||
|
||||
await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["install", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
|
||||
await runPackageCommand({
|
||||
appName: "pi",
|
||||
args: ["remove", extensionDir],
|
||||
cwd,
|
||||
agentDir,
|
||||
stdout: stdout.stream,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
|
||||
const markerPath = join(cwd, "after-remove-marker.json");
|
||||
assert.ok(existsSync(markerPath), "afterRemove hook must have executed and written marker");
|
||||
const marker = JSON.parse(readFileSync(markerPath, "utf-8"));
|
||||
assert.equal(typeof marker.receivedPath, "string", "hook must receive installedPath as string");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
install: [
|
||||
'Usage: gsd install <source> [-l, --local]',
|
||||
'',
|
||||
'Install a package/extension source and run declared lifecycle hooks.',
|
||||
'Install a package/extension source and run post-install validation (dependency checks, setup).',
|
||||
'',
|
||||
'Examples:',
|
||||
' gsd install npm:@foo/bar',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue