From 1a9970b354952e0f78ce19e1b491f371113f6fdb Mon Sep 17 00:00:00 2001 From: jkrperson Date: Sat, 25 Apr 2026 03:00:59 +0800 Subject: [PATCH] feat(installer): project-only (vendored) install mode Adds a third install scope: `gstack install --local` vendors gstack into /.claude/skills/gstack/ instead of ~/.claude/skills/gstack/. Surfaces ./setup --local, which upstream deprecated in favor of team mode but still supports. Guarded with an explicit deprecation notice in both the CLI output and the interactive wizard confirmation step. Why expose a deprecated mode: - Some users genuinely want vendored installs (offline machines, strict "one project = one dir" policies, air-gapped CI, forked gstack per repo) - ./setup already supports it, so the CLI would be lying by omission - The wizard makes the deprecation cost visible before commit Changes: - paths.ts: resolveProjectInstallPaths(dir), findLocalInstall(startDir) walking up to find /.claude/skills/gstack, and a resolveActiveInstall helper that returns {paths, mode: "global" | "project-local" | "none"} so status/doctor/list/upgrade all detect both install kinds - install.ts: accepts local + projectDir, routes to project paths, passes --local through to ./setup, restricts hosts to claude (matching setup behavior), prints deprecation warning - uninstall.ts: new `gstack uninstall --local` removes the project-local install (searches cwd upward), distinct from `--project` which removes team-mode config - wizard.ts: fourth top-level option "Install inside this project only (vendored)" with a confirm-before-commit step spelling out the tradeoff - cli.ts: --local flag on install and uninstall, help text updated - status: shows "Mode: project-local (vendored)" when applicable, yellow Tests: +8 (85 total, still green) - resolveProjectInstallPaths roots correctly - findLocalInstall at cwd + walks up to parent - status shows project-local mode when only vendored install exists - list discovers skills in vendored install - uninstall --local removes the vendored dir - uninstall --local exits 1 with clear message when no install present --- installer/README.md | 4 ++ installer/src/cli.ts | 9 +++- installer/src/commands/doctor.ts | 10 +++-- installer/src/commands/install.ts | 41 +++++++++++++++--- installer/src/commands/list.ts | 6 +-- installer/src/commands/status.ts | 26 ++++++++++-- installer/src/commands/uninstall.ts | 43 ++++++++++++++++++- installer/src/commands/upgrade.ts | 9 ++-- installer/src/lib/paths.ts | 39 +++++++++++++++++ installer/src/lib/setup.ts | 2 + installer/src/wizard.ts | 48 ++++++++++++++++++--- installer/test/integration/cli.test.ts | 59 ++++++++++++++++++++++++++ installer/test/unit/paths.test.ts | 47 +++++++++++++++++++- 13 files changed, 314 insertions(+), 29 deletions(-) diff --git a/installer/README.md b/installer/README.md index 653c077b5..5c5ee5c0e 100644 --- a/installer/README.md +++ b/installer/README.md @@ -10,9 +10,11 @@ npx @garrytan/gstack # Scripted: verb-based subcommands npx @garrytan/gstack install --host claude,codex +npx @garrytan/gstack install --local # vendored (deprecated — prefer team mode) npx @garrytan/gstack init --tier required npx @garrytan/gstack upgrade npx @garrytan/gstack uninstall --project --yes +npx @garrytan/gstack uninstall --local --yes # remove vendored project install npx @garrytan/gstack doctor npx @garrytan/gstack status npx @garrytan/gstack list @@ -26,6 +28,8 @@ Works with `npx`, `bunx`, and `pnpm dlx`. **`install`** — clones gstack into `~/.claude/skills/gstack`, builds the browse/design binaries via `bun`, registers with your chosen AI hosts (Claude Code, Codex, Factory Droid, OpenCode, Kiro), and inserts a `` / `` block into `~/.claude/CLAUDE.md` documenting the available skills. +**`install --local`** — vendored mode: installs gstack into `/.claude/skills/gstack` instead of the home directory. Everything stays inside the project. **Deprecated upstream** in favor of team mode (`init`) because vendoring means no cross-project auto-update and ~100MB duplicated per project. Exposed here because `./setup --local` still supports it. Claude Code only (other hosts skipped). + **`init`** — runs inside a git repo. Installs globally if needed, enables team mode (the SessionStart auto-update hook), runs `gstack-team-init ` to bootstrap the repo, and stages/commits the changes. Teammates get gstack automatically on their next session. **`uninstall`** — removes the install and walks every host's skills directory (`~/.claude/skills`, `~/.codex/skills`, `~/.factory/skills`, `~/.config/opencode/skills`, `~/.kiro/skills`) removing any symlink or directory whose `SKILL.md` points into the gstack install. Cleans the CLAUDE.md block and scrubs the PreToolUse hook from project `settings.json`. `~/.gstack/` (session state) is preserved. diff --git a/installer/src/cli.ts b/installer/src/cli.ts index e58e7bbe5..af18e6f73 100644 --- a/installer/src/cli.ts +++ b/installer/src/cli.ts @@ -109,8 +109,9 @@ ${colors.bold("Usage:")} ${colors.bold("Commands:")} install Install gstack globally (~/.claude/skills/gstack) + install --local Install gstack inside this project (vendored, deprecated) init Add gstack to the current project (team mode) - uninstall Remove gstack (global; add --project for just this repo) + uninstall Remove gstack (global; add --project or --local for per-repo) upgrade Pull latest gstack and rebuild doctor Diagnose install issues status Show install version, hosts, and settings @@ -129,7 +130,9 @@ ${colors.bold("Common options:")} --quiet, -q Suppress non-essential output --tier init only: "required" or "optional" (default: required) --no-commit init only: stage but don't commit changes - --project uninstall only: remove from current project, not global + --local install only: vendor into /.claude/skills/gstack + uninstall only: remove vendored project-local install + --project uninstall only: remove team-mode config from current repo --keep-claude-md uninstall only: leave CLAUDE.md section in place ${colors.bold("Examples:")} @@ -173,6 +176,7 @@ async function main(): Promise { writeClaudeMd, quiet, reinstall, + local: bool(args, "local", false), }); break; case "init": { @@ -199,6 +203,7 @@ async function main(): Promise { case "uninstall": await uninstall({ project: bool(args, "project", false), + local: bool(args, "local", false), yes, keepClaudeMd: bool(args, "keep-claude-md", false), quiet, diff --git a/installer/src/commands/doctor.ts b/installer/src/commands/doctor.ts index 596efd380..1a5d60495 100644 --- a/installer/src/commands/doctor.ts +++ b/installer/src/commands/doctor.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveInstallPaths, isInstalled, readVersion } from "../lib/paths.js"; +import { resolveActiveInstall, readVersion } from "../lib/paths.js"; import { checkRequirements, getBunVersion, @@ -24,7 +24,7 @@ interface Check { export async function doctor(args: DoctorArgs): Promise { const log = createLogger(args.quiet); - const paths = resolveInstallPaths(); + const { paths, mode } = resolveActiveInstall(); const checks: Check[] = []; const sys = await checkRequirements(); @@ -39,11 +39,13 @@ export async function doctor(args: DoctorArgs): Promise { detail: (await getBunVersion()) ?? "not found (required to build binaries)", }); - const installed = isInstalled(paths); + const installed = mode !== "none"; checks.push({ name: "install", status: installed ? "ok" : "fail", - detail: installed ? paths.gstackDir : `missing (run \`gstack install\`)`, + detail: installed + ? `${paths.gstackDir}${mode === "project-local" ? " (project-local)" : ""}` + : `missing (run \`gstack install\`)`, }); if (installed) { diff --git a/installer/src/commands/install.ts b/installer/src/commands/install.ts index 1e0802ca0..f300a45ea 100644 --- a/installer/src/commands/install.ts +++ b/installer/src/commands/install.ts @@ -1,12 +1,16 @@ import fs from "node:fs"; import * as p from "@clack/prompts"; -import { resolveInstallPaths, isInstalled } from "../lib/paths.js"; +import { + resolveInstallPaths, + resolveProjectInstallPaths, + isInstalled, +} from "../lib/paths.js"; import { checkRequirements } from "../lib/system.js"; import { cloneGstack, pullGstack } from "../lib/git.js"; import { runSetupForHosts } from "../lib/setup.js"; import { buildGstackBlock, upsertClaudeMd } from "../lib/claude-md.js"; import { HOSTS, type HostId } from "../lib/hosts.js"; -import { createLogger } from "../lib/logger.js"; +import { createLogger, colors } from "../lib/logger.js"; export interface InstallArgs { hosts: HostId[]; @@ -14,12 +18,26 @@ export interface InstallArgs { writeClaudeMd: boolean; quiet: boolean; reinstall: boolean; + local?: boolean; + projectDir?: string; } export async function installGlobal(args: InstallArgs): Promise { - const paths = resolveInstallPaths(); + const isLocal = args.local === true; + const projectDir = args.projectDir ?? process.cwd(); + const paths = isLocal + ? resolveProjectInstallPaths(projectDir) + : resolveInstallPaths(); const log = createLogger(args.quiet); + if (isLocal) { + log.warn( + `Project-only install is ${colors.yellow("deprecated upstream")}. You give up cross-project auto-update and vendor ~100MB per project.`, + ); + log.dim("For shared repos, team mode (`gstack init`) is strictly better."); + log.plain(""); + } + const sys = await checkRequirements(); if (!sys.ok) { log.error(`Missing required tools: ${sys.missing.join(", ")}`); @@ -59,10 +77,18 @@ export async function installGlobal(args: InstallArgs): Promise { } } - const hosts = args.hosts.length > 0 ? args.hosts : (["claude"] as HostId[]); + const hosts = isLocal + ? (["claude"] as HostId[]) + : args.hosts.length > 0 + ? args.hosts + : (["claude"] as HostId[]); + if (isLocal && args.hosts.length > 1) { + log.warn("Project-only install supports Claude Code only; other hosts skipped."); + } log.info(`Registering with ${hosts.map((h) => HOSTS.find((x) => x.id === h)?.label ?? h).join(", ")}`); await runSetupForHosts(paths, hosts, { prefix: args.prefix, + local: isLocal, quiet: args.quiet, }); @@ -77,9 +103,14 @@ export async function installGlobal(args: InstallArgs): Promise { } log.plain(""); - log.success("gstack installed."); + log.success(isLocal ? "gstack installed in this project." : "gstack installed."); log.bullet(`Location: ${paths.gstackDir}`); log.bullet(`Hosts: ${hosts.join(", ")}`); + if (isLocal) { + log.bullet("Mode: project-only (vendored)"); + log.dim("Teammates who clone this repo get the same install."); + log.dim("Remember to add .claude/skills/gstack/node_modules/ to .gitignore if you commit the checkout."); + } log.plain(""); log.plain("Next: open Claude Code and try /office-hours, /review, or /qa"); } diff --git a/installer/src/commands/list.ts b/installer/src/commands/list.ts index 9cdb338f1..11f3b08fe 100644 --- a/installer/src/commands/list.ts +++ b/installer/src/commands/list.ts @@ -1,4 +1,4 @@ -import { resolveInstallPaths, isInstalled } from "../lib/paths.js"; +import { resolveActiveInstall } from "../lib/paths.js"; import { scanSkills } from "../lib/skills.js"; import { createLogger, colors } from "../lib/logger.js"; @@ -8,9 +8,9 @@ export interface ListArgs { export async function list(args: ListArgs): Promise { const log = createLogger(args.quiet); - const paths = resolveInstallPaths(); + const { paths, mode } = resolveActiveInstall(); - if (!isInstalled(paths)) { + if (mode === "none") { log.error("gstack is not installed. Run `gstack install` first."); process.exit(1); } diff --git a/installer/src/commands/status.ts b/installer/src/commands/status.ts index 353128c9b..f2086424f 100644 --- a/installer/src/commands/status.ts +++ b/installer/src/commands/status.ts @@ -1,6 +1,12 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveInstallPaths, isInstalled, readVersion, findGitRoot } from "../lib/paths.js"; +import { + resolveInstallPaths, + isInstalled, + readVersion, + findGitRoot, + findLocalInstall, +} from "../lib/paths.js"; import { getInstalledCommit } from "../lib/git.js"; import { readGstackConfig } from "../lib/setup.js"; import { listDisabledSkills } from "../lib/project-config.js"; @@ -14,11 +20,22 @@ export interface StatusArgs { export async function status(args: StatusArgs): Promise { const log = createLogger(args.quiet); - const paths = resolveInstallPaths(); + const globalPaths = resolveInstallPaths(); + const localPaths = findLocalInstall(process.cwd()); - if (!isInstalled(paths)) { + const paths = isInstalled(globalPaths) + ? globalPaths + : localPaths ?? globalPaths; + const mode = + paths === globalPaths && isInstalled(globalPaths) + ? "global" + : localPaths + ? "project-local" + : "none"; + + if (mode === "none") { log.plain(colors.bold("gstack:") + " " + colors.red("not installed")); - log.dim("Run `gstack install` to install globally."); + log.dim("Run `gstack install` to install globally, or `gstack install --local` for project-only."); return; } @@ -30,6 +47,7 @@ export async function status(args: StatusArgs): Promise { log.plain(colors.bold("gstack") + " " + colors.dim(`(${version ?? "unversioned"}${commit ? ` @ ${commit}` : ""})`)); log.plain(""); + log.plain(` ${colors.dim("Mode:")} ${mode === "project-local" ? colors.yellow("project-local (vendored)") : "global"}`); log.plain(` ${colors.dim("Install:")} ${paths.gstackDir}`); log.plain(` ${colors.dim("Team mode:")} ${teamMode === "true" ? colors.green("on") : "off"}`); log.plain(` ${colors.dim("Auto-upgrade:")} ${autoUpgrade === "true" ? colors.green("on") : "off"}`); diff --git a/installer/src/commands/uninstall.ts b/installer/src/commands/uninstall.ts index 8fdd8b22c..f3e494fb4 100644 --- a/installer/src/commands/uninstall.ts +++ b/installer/src/commands/uninstall.ts @@ -1,7 +1,12 @@ import fs from "node:fs"; import path from "node:path"; import * as p from "@clack/prompts"; -import { resolveInstallPaths, isInstalled, findGitRoot } from "../lib/paths.js"; +import { + resolveInstallPaths, + isInstalled, + findGitRoot, + findLocalInstall, +} from "../lib/paths.js"; import { cleanupHostSymlinks, removeGstackInstall, @@ -15,19 +20,53 @@ import { run } from "../lib/exec.js"; export interface UninstallArgs { project: boolean; + local: boolean; yes: boolean; keepClaudeMd: boolean; quiet: boolean; } export async function uninstall(args: UninstallArgs): Promise { - if (args.project) { + if (args.local) { + await uninstallLocal(args); + } else if (args.project) { await uninstallProject(args); } else { await uninstallGlobal(args); } } +async function uninstallLocal(args: UninstallArgs): Promise { + const log = createLogger(args.quiet); + const paths = findLocalInstall(process.cwd()); + if (!paths) { + log.error("No project-local gstack install found (looked for .claude/skills/gstack in cwd and parents)."); + process.exit(1); + } + + if (!args.yes) { + const proceed = await p.confirm({ + message: `Remove project-local gstack at ${paths.gstackDir}?`, + initialValue: false, + }); + if (p.isCancel(proceed) || !proceed) { + log.dim("Aborted."); + return; + } + } + + fs.rmSync(paths.gstackDir, { recursive: true, force: true }); + log.bullet(`removed ${paths.gstackDir}`); + + if (!args.keepClaudeMd) { + const { removeGstackBlock: remove } = await import("../lib/claude-md.js"); + if (remove(paths.claudeMd)) log.bullet(`removed gstack block from ${paths.claudeMd}`); + } + + log.plain(""); + log.success("Project-local gstack uninstalled."); +} + async function uninstallGlobal(args: UninstallArgs): Promise { const log = createLogger(args.quiet); const paths = resolveInstallPaths(); diff --git a/installer/src/commands/upgrade.ts b/installer/src/commands/upgrade.ts index 3033c850b..9976a8ab5 100644 --- a/installer/src/commands/upgrade.ts +++ b/installer/src/commands/upgrade.ts @@ -1,5 +1,5 @@ import * as p from "@clack/prompts"; -import { resolveInstallPaths, isInstalled, readVersion } from "../lib/paths.js"; +import { resolveActiveInstall, readVersion } from "../lib/paths.js"; import { pullGstack, getInstalledCommit } from "../lib/git.js"; import { runSetup } from "../lib/setup.js"; import { createLogger } from "../lib/logger.js"; @@ -10,12 +10,15 @@ export interface UpgradeArgs { export async function upgrade(args: UpgradeArgs): Promise { const log = createLogger(args.quiet); - const paths = resolveInstallPaths(); + const { paths, mode } = resolveActiveInstall(); - if (!isInstalled(paths)) { + if (mode === "none") { log.error("gstack is not installed. Run `gstack install` first."); process.exit(1); } + if (mode === "project-local") { + log.info(`Upgrading project-local install at ${paths.gstackDir}`); + } const beforeVersion = readVersion(paths); const beforeCommit = await getInstalledCommit(paths); diff --git a/installer/src/lib/paths.ts b/installer/src/lib/paths.ts index d6c1bab98..9ea367510 100644 --- a/installer/src/lib/paths.ts +++ b/installer/src/lib/paths.ts @@ -27,6 +27,45 @@ export function resolveInstallPaths(): InstallPaths { }; } +export function resolveProjectInstallPaths(projectDir: string): InstallPaths { + const home = os.homedir(); + const claudeDir = path.join(projectDir, ".claude"); + return { + home, + claudeDir, + claudeSkillsDir: path.join(claudeDir, "skills"), + gstackDir: path.join(claudeDir, "skills", "gstack"), + gstackStateDir: path.join(home, ".gstack"), + claudeMd: path.join(projectDir, "CLAUDE.md"), + }; +} + +export function findLocalInstall(startDir: string): InstallPaths | null { + let cur = path.resolve(startDir); + while (true) { + const candidate = path.join(cur, ".claude", "skills", "gstack"); + if (fs.existsSync(candidate)) { + return resolveProjectInstallPaths(cur); + } + const parent = path.dirname(cur); + if (parent === cur) return null; + cur = parent; + } +} + +export interface ResolvedInstall { + paths: InstallPaths; + mode: "global" | "project-local" | "none"; +} + +export function resolveActiveInstall(cwd: string = process.cwd()): ResolvedInstall { + const globalPaths = resolveInstallPaths(); + if (isInstalled(globalPaths)) return { paths: globalPaths, mode: "global" }; + const local = findLocalInstall(cwd); + if (local && isInstalled(local)) return { paths: local, mode: "project-local" }; + return { paths: globalPaths, mode: "none" }; +} + export function isInstalled(paths: InstallPaths): boolean { try { const stat = fs.lstatSync(paths.gstackDir); diff --git a/installer/src/lib/setup.ts b/installer/src/lib/setup.ts index 5e1e2623a..a6c7da55d 100644 --- a/installer/src/lib/setup.ts +++ b/installer/src/lib/setup.ts @@ -8,6 +8,7 @@ export interface SetupOptions { prefix?: boolean; team?: boolean; noTeam?: boolean; + local?: boolean; quiet?: boolean; } @@ -17,6 +18,7 @@ export async function runSetup(paths: InstallPaths, opts: SetupOptions): Promise if (opts.prefix === false) args.push("--no-prefix"); if (opts.team) args.push("--team"); if (opts.noTeam) args.push("--no-team"); + if (opts.local) args.push("--local"); if (opts.quiet) args.push("-q"); const setupScript = path.join(paths.gstackDir, "setup"); diff --git a/installer/src/wizard.ts b/installer/src/wizard.ts index a4f884ad1..beee301b4 100644 --- a/installer/src/wizard.ts +++ b/installer/src/wizard.ts @@ -37,7 +37,7 @@ export async function runWizard(): Promise { ); } - type Mode = "install" | "init" | "uninstall" | "doctor"; + type Mode = "install" | "init" | "local" | "uninstall" | "doctor"; const mode = await p.select<{ value: Mode; label: string; hint?: string }[], Mode>({ message: "What do you want to do?", options: [ @@ -53,6 +53,11 @@ export async function runWizard(): Promise { ? "global install + commits team-sync config to this repo so teammates auto-update" : "must be inside a git repo", }, + { + value: "local", + label: "Install inside this project only (vendored)", + hint: "installs to /.claude/skills/gstack — deprecated, prefer team mode", + }, { value: "uninstall", label: "Uninstall", hint: "remove gstack" }, { value: "doctor", label: "Doctor", hint: "diagnose install issues" }, ], @@ -67,15 +72,23 @@ export async function runWizard(): Promise { } if (mode === "uninstall") { - type UninstallTarget = "global" | "project"; - const target = await p.select<{ value: UninstallTarget; label: string; hint?: string }[], UninstallTarget>({ + type UninstallTarget = "global" | "project" | "local"; + const target = await p.select< + { value: UninstallTarget; label: string; hint?: string }[], + UninstallTarget + >({ message: "Uninstall from where?", options: [ { value: "global", label: "Global (~/.claude/skills/gstack)" }, { value: "project", - label: "This project only", - hint: inRepo ? "" : "not in a git repo", + label: "Team-mode config in this repo", + hint: "removes .claude/hooks/check-gstack.sh, settings.json hook, CLAUDE.md block", + }, + { + value: "local", + label: "Project-local (vendored) install", + hint: "removes /.claude/skills/gstack", }, ], initialValue: "global", @@ -84,6 +97,7 @@ export async function runWizard(): Promise { const { uninstall } = await import("./commands/uninstall.js"); await uninstall({ project: target === "project", + local: target === "local", yes: false, keepClaudeMd: false, quiet: false, @@ -126,6 +140,30 @@ export async function runWizard(): Promise { return; } + if (mode === "local") { + const confirmLocal = await p.confirm({ + message: + "Project-only install is deprecated upstream. You give up cross-project auto-update and vendor ~100MB into this project. Continue?", + initialValue: false, + }); + assertValue(confirmLocal); + if (!confirmLocal) { + p.outro("Aborted. Use `gstack install` (global) or `gstack init` (team mode) instead."); + return; + } + await installGlobal({ + hosts: ["claude"], + prefix: prefixChoice === "prefixed", + writeClaudeMd: writeClaudeMdChoice, + quiet: false, + reinstall: false, + local: true, + projectDir: process.cwd(), + }); + p.outro("Done. Project-local install complete."); + return; + } + if (mode === "init") { if (!inRepo) { p.log.error("Not inside a git repository. Run `git init` first."); diff --git a/installer/test/integration/cli.test.ts b/installer/test/integration/cli.test.ts index 771b63c49..44b46e422 100644 --- a/installer/test/integration/cli.test.ts +++ b/installer/test/integration/cli.test.ts @@ -332,6 +332,65 @@ describe("cli: uninstall --project", () => { }); }); +describe("cli: status detects project-local install", () => { + let tmp: string; + let homeTmp: string; + + beforeEach(() => { + tmp = makeTmpDir(); + homeTmp = makeTmpDir("gstack-home-"); + const gstackDir = path.join(tmp, ".claude", "skills", "gstack"); + fs.mkdirSync(gstackDir, { recursive: true }); + fs.writeFileSync(path.join(gstackDir, "VERSION"), "0.0.0-local-test"); + fs.mkdirSync(path.join(gstackDir, "qa"), { recursive: true }); + fs.writeFileSync( + path.join(gstackDir, "qa", "SKILL.md"), + "---\nname: qa\ndescription: Local QA\n---\n", + ); + }); + + afterEach(() => { + rmTmpDir(tmp); + rmTmpDir(homeTmp); + }); + + test("status shows project-local mode when only a local install exists", () => { + const r = runCli(["status"], { cwd: tmp, env: { HOME: homeTmp } }); + expect(r.code).toBe(0); + expect(r.stdout).toContain("project-local"); + expect(r.stdout).toContain("0.0.0-local-test"); + }); + + test("list discovers skills from project-local install", () => { + const r = runCli(["list"], { cwd: tmp, env: { HOME: homeTmp } }); + expect(r.code).toBe(0); + expect(r.stdout).toContain("/qa"); + }); + + test("uninstall --local --yes removes the project-local install", () => { + const r = runCli(["uninstall", "--local", "--yes"], { + cwd: tmp, + env: { HOME: homeTmp }, + }); + expect(r.code).toBe(0); + expect(fs.existsSync(path.join(tmp, ".claude", "skills", "gstack"))).toBe(false); + }); + + test("uninstall --local fails cleanly when no project-local install exists", () => { + const nowhere = makeTmpDir(); + try { + const r = runCli(["uninstall", "--local", "--yes"], { + cwd: nowhere, + env: { HOME: homeTmp }, + }); + expect(r.code).toBe(1); + expect(r.stderr).toContain("No project-local gstack install"); + } finally { + rmTmpDir(nowhere); + } + }); +}); + describe("cli: no args launches wizard", () => { let homeTmp: string; diff --git a/installer/test/unit/paths.test.ts b/installer/test/unit/paths.test.ts index 4db756181..105612d3c 100644 --- a/installer/test/unit/paths.test.ts +++ b/installer/test/unit/paths.test.ts @@ -3,7 +3,14 @@ import fs from "node:fs"; import path from "node:path"; import { execSync } from "node:child_process"; import { makeTmpDir, rmTmpDir } from "../helpers.js"; -import { findGitRoot, isGitRepo, isInstalled, readVersion } from "../../src/lib/paths.js"; +import { + findGitRoot, + findLocalInstall, + isGitRepo, + isInstalled, + readVersion, + resolveProjectInstallPaths, +} from "../../src/lib/paths.js"; import type { InstallPaths } from "../../src/lib/paths.js"; describe("findGitRoot", () => { @@ -82,6 +89,44 @@ describe("isInstalled", () => { }); }); +describe("resolveProjectInstallPaths", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("roots install inside the project, not home", () => { + const p = resolveProjectInstallPaths(tmp); + expect(p.gstackDir).toBe(path.join(tmp, ".claude", "skills", "gstack")); + expect(p.claudeMd).toBe(path.join(tmp, "CLAUDE.md")); + }); +}); + +describe("findLocalInstall", () => { + let tmp: string; + beforeEach(() => (tmp = makeTmpDir())); + afterEach(() => rmTmpDir(tmp)); + + test("returns null when no local install is present", () => { + expect(findLocalInstall(tmp)).toBeNull(); + }); + + test("finds install at cwd", () => { + fs.mkdirSync(path.join(tmp, ".claude", "skills", "gstack"), { recursive: true }); + const found = findLocalInstall(tmp); + expect(found).toBeTruthy(); + expect(fs.realpathSync(found!.gstackDir)).toBe( + fs.realpathSync(path.join(tmp, ".claude", "skills", "gstack")), + ); + }); + + test("walks up to find install in parent", () => { + fs.mkdirSync(path.join(tmp, ".claude", "skills", "gstack"), { recursive: true }); + const sub = path.join(tmp, "a", "b"); + fs.mkdirSync(sub, { recursive: true }); + expect(findLocalInstall(sub)).toBeTruthy(); + }); +}); + describe("readVersion", () => { let tmp: string; beforeEach(() => (tmp = makeTmpDir()));