feat(core,tooling): Windows support — shell selection, install.ps1, win-x64 release package, Windows CI (#79)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-27 22:17:35 +08:00
committed by GitHub
parent c23f0bc2f0
commit c369e089a7
46 changed files with 1315 additions and 217 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run --passWithNoTests",
"build": "tsup",
"penguin": "PENGUIN_HOME=\"${PENGUIN_HOME:-$HOME/.penguin/dev-data}\" tsx src/index.ts"
"penguin": "node ../../scripts/run-with-env.mjs PENGUIN_HOME=~/.penguin/dev-data -- tsx src/index.ts"
},
"dependencies": {
"@prismshadow/agenthub": "^0.4.1",
+7
View File
@@ -325,6 +325,13 @@ export function registerConfigCommand(program: Command, t: Messages): void {
process.exitCode = 1;
return;
}
// Windows has no POSIX shell startup file to write and no /bin shell to restart into;
// refuse with a clear pointer instead of an ENOENT from spawning /bin/zsh.
if (process.platform === "win32") {
process.stderr.write(`${getMessages(lang).langWindowsUnsupported(lang)}\n`);
process.exitCode = 1;
return;
}
const { rcPath } = await applyLanguageToRc(lang, {
shell: process.env.SHELL,
home: homedir(),
+8
View File
@@ -176,6 +176,8 @@ export interface Messages {
/** Example resume command shown when the REPL exits (dim print; only when this session has a resumable record). */
resumeHint(command: string): string;
langInvalid(value: string): string;
/** `config lang` persists via POSIX shell startup files; on Windows it refuses with a pointer to a user env var instead. */
langWindowsUnsupported(lang: string): string;
langSet(lang: string, rcPath: string): string;
langRestartConfirm(): string;
langRestart(): string;
@@ -383,6 +385,9 @@ const en: Messages = {
`[resumed] ${sessionId} · ${messageCount} message${messageCount === 1 ? "" : "s"} in the current context`,
resumeHint: (command) => `To continue this conversation: ${command}`,
langInvalid: (value) => `Invalid language "${value}". Use en or zh.`,
langWindowsUnsupported: (lang) =>
`penguin config lang persists via POSIX shell startup files, which Windows does not have.\n` +
`Set the user environment variable instead: setx PENGUIN_LANG ${lang} (new terminals pick it up).`,
langSet: (lang, rcPath) => `Language set to ${lang}; wrote PENGUIN_LANG to ${rcPath}.`,
langRestartConfirm: () => "Open a new shell now to apply? [y/N] ",
langRestart: () => "Opening a new shell with the new language (type exit to return)…",
@@ -550,6 +555,9 @@ const zh: Messages = {
`[已恢复] ${sessionId} · 当前上下文共 ${messageCount} 条消息`,
resumeHint: (command) => `继续本次对话:${command}`,
langInvalid: (value) => `无效的语言 "${value}"。请使用 en 或 zh。`,
langWindowsUnsupported: (lang) =>
`penguin config lang 通过 POSIX shell 启动文件持久化语言,Windows 上没有对应机制。\n` +
`请改为设置用户环境变量:setx PENGUIN_LANG ${lang}(新终端生效)。`,
langSet: (lang, rcPath) => `语言已设为 ${lang};已将 PENGUIN_LANG 写入 ${rcPath}。`,
langRestartConfirm: () => "现在打开新 shell 使其生效?[y/N] ",
langRestart: () => "正在打开使用新语言的新 shell(输入 exit 可返回)……",
+4 -1
View File
@@ -97,7 +97,10 @@ describe("penguin config model add/list (--root plus provider / model_id stored
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
expect(path.basename(file)).toBe(".project_config.toml");
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
// POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute).
if (process.platform !== "win32") {
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
}
const parsed = parseToml(await fs.readFile(file, "utf8")) as {
models: Array<Record<string, unknown>>;
};
+4 -1
View File
@@ -64,7 +64,10 @@ describe("penguin config vault", () => {
const file = agentVaultPath(tmpRoot, DEFAULT_PROJECT_ID, "default_agent");
expect(path.basename(file)).toBe(".vault.toml");
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
// POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute).
if (process.platform !== "win32") {
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
}
expect(await fs.readFile(file, "utf8")).toContain("vault-secret-9876");
const list = await runVault(["list"]);
+7 -4
View File
@@ -5,26 +5,29 @@ import { afterEach, describe, expect, it } from "vitest";
import { applyLanguageToRc, resolveShellRc, upsertBlock } from "../src/lang-config.js";
describe("resolveShellRc", () => {
// rcPath is built with path.join, so the expectations join too (backslashes on Windows —
// where the `config lang` command refuses before ever calling this, but the pure function
// stays coherent).
it("maps zsh / bash / fish to their startup files and syntax", () => {
const zsh = resolveShellRc("/bin/zsh", "/home/u");
expect(zsh.kind).toBe("zsh");
expect(zsh.rcPath).toBe("/home/u/.zshrc");
expect(zsh.rcPath).toBe(join("/home/u", ".zshrc"));
expect(zsh.body("zh")).toBe("export PENGUIN_LANG=zh");
const bash = resolveShellRc("/usr/bin/bash", "/home/u");
expect(bash.kind).toBe("bash");
expect(bash.rcPath).toBe("/home/u/.bashrc");
expect(bash.rcPath).toBe(join("/home/u", ".bashrc"));
const fish = resolveShellRc("/usr/local/bin/fish", "/home/u");
expect(fish.kind).toBe("fish");
expect(fish.rcPath).toBe("/home/u/.config/fish/config.fish");
expect(fish.rcPath).toBe(join("/home/u", ".config", "fish", "config.fish"));
expect(fish.body("en")).toBe("set -gx PENGUIN_LANG en");
});
it("falls back to ~/.profile for an unknown shell", () => {
const rc = resolveShellRc(undefined, "/home/u");
expect(rc.kind).toBe("unknown");
expect(rc.rcPath).toBe("/home/u/.profile");
expect(rc.rcPath).toBe(join("/home/u", ".profile"));
});
});
+48 -42
View File
@@ -22,58 +22,64 @@ import {
} from "../src/commands/update.js";
import { getMessages } from "../src/i18n.js";
describe("detectInstall (how this CLI was installed, from its own real path)", () => {
it("tarball: <installDir>/lib/dist/index.js, the layout install.sh unpacks", () => {
expect(detectInstall("/home/me/.penguin/lib/dist/index.js")).toEqual({
kind: "tarball",
installDir: "/home/me/.penguin",
// POSIX-only: the fixtures are POSIX install layouts and detectInstall normalizes through
// path.* (backslashes on Windows) — where in-place `penguin update` is refused anyway
// (the documented Windows upgrade path is re-running install.ps1).
describe.skipIf(process.platform === "win32")(
"detectInstall (how this CLI was installed, from its own real path)",
() => {
it("tarball: <installDir>/lib/dist/index.js, the layout install.sh unpacks", () => {
expect(detectInstall("/home/me/.penguin/lib/dist/index.js")).toEqual({
kind: "tarball",
installDir: "/home/me/.penguin",
});
});
});
it("tarball: a non-default PENGUIN_INSTALL_DIR is read off the path, not the environment", () => {
expect(detectInstall("/opt/tools/penguin/lib/dist/index.js")).toEqual({
kind: "tarball",
installDir: "/opt/tools/penguin",
it("tarball: a non-default PENGUIN_INSTALL_DIR is read off the path, not the environment", () => {
expect(detectInstall("/opt/tools/penguin/lib/dist/index.js")).toEqual({
kind: "tarball",
installDir: "/opt/tools/penguin",
});
});
});
it("npm global: npm's own prefix layout", () => {
expect(
detectInstall("/usr/local/lib/node_modules/@prismshadow/penguin-cli/dist/index.js"),
).toEqual({ kind: "npm", globalRoot: "/usr/local/lib/node_modules" });
});
it("npm global: pnpm's global store, through the .pnpm virtual dir", () => {
const p =
"/home/me/.local/share/pnpm/global/5/node_modules/.pnpm/@prismshadow+penguin-cli@0.1.1/node_modules/@prismshadow/penguin-cli/dist/index.js";
const info = detectInstall(p);
expect(info.kind).toBe("npm");
expect(info.globalRoot).toContain(".pnpm");
});
it("source checkout: the built dist inside the monorepo", () => {
expect(detectInstall("/home/me/code/penguin-harness/packages/cli/dist/index.js")).toEqual({
kind: "source",
it("npm global: npm's own prefix layout", () => {
expect(
detectInstall("/usr/local/lib/node_modules/@prismshadow/penguin-cli/dist/index.js"),
).toEqual({ kind: "npm", globalRoot: "/usr/local/lib/node_modules" });
});
});
it("source checkout: tsx running src directly", () => {
expect(detectInstall("/home/me/code/penguin-harness/packages/cli/src/index.ts")).toEqual({
kind: "source",
it("npm global: pnpm's global store, through the .pnpm virtual dir", () => {
const p =
"/home/me/.local/share/pnpm/global/5/node_modules/.pnpm/@prismshadow+penguin-cli@0.1.1/node_modules/@prismshadow/penguin-cli/dist/index.js";
const info = detectInstall(p);
expect(info.kind).toBe("npm");
expect(info.globalRoot).toContain(".pnpm");
});
});
it("a checkout wins over the tarball shape, so a repo under a lib/ dir is never mistaken for an install", () => {
expect(detectInstall("/srv/lib/penguin-harness/packages/cli/dist/index.js")).toEqual({
kind: "source",
it("source checkout: the built dist inside the monorepo", () => {
expect(detectInstall("/home/me/code/penguin-harness/packages/cli/dist/index.js")).toEqual({
kind: "source",
});
});
});
it("anything else is unknown rather than guessed", () => {
expect(detectInstall("/random/place/index.js").kind).toBe("unknown");
expect(detectInstall("/home/me/.penguin/bin/penguin").kind).toBe("unknown");
});
});
it("source checkout: tsx running src directly", () => {
expect(detectInstall("/home/me/code/penguin-harness/packages/cli/src/index.ts")).toEqual({
kind: "source",
});
});
it("a checkout wins over the tarball shape, so a repo under a lib/ dir is never mistaken for an install", () => {
expect(detectInstall("/srv/lib/penguin-harness/packages/cli/dist/index.js")).toEqual({
kind: "source",
});
});
it("anything else is unknown rather than guessed", () => {
expect(detectInstall("/random/place/index.js").kind).toBe("unknown");
expect(detectInstall("/home/me/.penguin/bin/penguin").kind).toBe("unknown");
});
},
);
describe("detectPackageManager (which manager owns a global node_modules root)", () => {
it("pnpm: the global store or the .pnpm virtual dir", () => {