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:
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 可返回)……",
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user