fix(build): an install renders skills only for the hosts installed from it (#1694)

./setup ran gen:skill-docs --host all inside the install, so a global
Claude install at ~/.claude/skills/gstack carried 632 SKILL.md files
(34.7 MB) where 56 are its own; Claude Code and Cursor-agent scan that
tree, and Cursor-agent freezes above roughly 11 MB.

setup now keeps an additive record of the hosts it installs from a
checkout (.gstack-installed-hosts, owned by bin/gstack-host-renders.sh),
seeded from installs that predate it: registry rows, install roots that
resolve to the checkout, host skills that link into its renders, and
legacy skill copies with no runtime root. scripts/build.sh renders claude
plus the recorded hosts and prints which hosts and why. A checkout with no
record (development) or GSTACK_RENDER_HOSTS=all renders every host. The
Codex .agents/ render runs only when Codex is installed or recorded;
bin/dev-setup records Codex because it serves the repo's .agents/.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-08 08:08:35 +00:00
1 parent 34b6d3e4e1
commit f2012f3ab3
7 files changed
+367 -5

No files matched your search

+2
View File
@@ -39,6 +39,8 @@ lib/cso/images/gstack-cso-preparation
.hermes/
.gbrain/
.gbrain-source
# Hosts installed from this checkout (bin/gstack-host-renders.sh, #1694)
.gstack-installed-hosts
.context/
extension/.auth.json
# xterm assets are vendored from npm at build time; not source-of-truth.
+5
View File
@@ -56,6 +56,11 @@ if [ ! -e "$AGENTS_LINK" ]; then
ln -s "$REPO_ROOT" "$AGENTS_LINK"
fi
# 5b. Record Codex as a host this checkout serves (bin/gstack-host-renders.sh),
# so setup and `bun run build` keep rendering .agents/ for it (#1694).
. "$REPO_ROOT/bin/gstack-host-renders.sh"
gstack_render_hosts_add "$REPO_ROOT" codex
# 6. Run setup via the symlink so it detects .claude/skills/ as its parent.
#
# Workspace/dev setup MUST be non-interactive: Conductor runs this under a
+150
View File
@@ -0,0 +1,150 @@
# shellcheck shell=bash
# gstack-host-renders.sh — the one owner of a checkout's installed-hosts record
# and of the prune of skill renders for hosts it does not serve (#1694).
#
# Sourced, never executed: scripts/build.sh, setup and the gstack-upgrade
# migration source it. Bash 3.2 clean.
#
# A checkout renders one skills tree per host (.agents/ for Codex, .factory/,
# .kiro/, ...). Rendering all of them inside an install put ~576 extra SKILL.md
# files (32 MB) under ~/.claude/skills/gstack, a tree Claude Code and
# Cursor-agent scan (#1694). Instead:
# - setup adds each host it installs to $ROOT/.gstack-installed-hosts. The
# record is additive: ./setup --host codex never drops a recorded factory.
# - scripts/build.sh renders claude plus the recorded hosts. A checkout with
# no record (a development checkout) or GSTACK_RENDER_HOSTS=all renders
# every host.
# - every ./setup prunes renders of unrecorded hosts with the
# bin/gstack-relink proof rules: a symlink into this checkout (STRONG) is
# removed; a file proven generated (WEAK: gen-skill-docs' banner, the
# generator's exact openai.yaml shape, or byte identity with this checkout)
# is moved to $GSTACK_STATE_ROOT/backups/host-renders/<ts>-<id>/; anything
# else stays where it is. Only directories left empty are removed.
# host:render-dir for every host whose render lives in its own checkout dir.
# Claude renders into the tracked tree. test/host-renders.test.ts keeps this
# equal to hostSubdir in hosts/*.ts.
GSTACK_HOST_RENDER_DIRS="codex:.agents kiro:.kiro factory:.factory opencode:.opencode cursor:.cursor copilot:.copilot slate:.slate openclaw:.openclaw hermes:.hermes gbrain:.gbrain"
gstack_render_hosts_file() { printf '%s/.gstack-installed-hosts\n' "$1"; }
_gstack_render_host_known() {
local pair
[ "$1" = claude ] && return 0
for pair in $GSTACK_HOST_RENDER_DIRS; do [ "${pair%%:*}" = "$1" ] && return 0; done
return 1
}
# gstack_render_hosts_read ROOT — the recorded hosts, one per line, sorted.
gstack_render_hosts_read() {
local file h
file="$(gstack_render_hosts_file "$1")"
[ -f "$file" ] || return 0
while IFS= read -r h; do
h="${h%%#*}"; h="$(printf '%s' "$h" | tr -d '[:space:]')"
[ -n "$h" ] && _gstack_render_host_known "$h" && printf '%s\n' "$h"
done < "$file" | sort -u
}
# gstack_render_hosts_has ROOT HOST
gstack_render_hosts_has() { gstack_render_hosts_read "$1" | grep -qx "$2"; }
# gstack_render_hosts_add ROOT HOST... — add hosts to the record (never removes
# one). Writes only when the set changed, by atomic rename.
gstack_render_hosts_add() {
local root="$1" file tmp old new h
shift
file="$(gstack_render_hosts_file "$root")"
old="$(gstack_render_hosts_read "$root")"
new="$( { [ -n "$old" ] && printf '%s\n' "$old"; for h in "$@"; do _gstack_render_host_known "$h" && printf '%s\n' "$h"; done; } | sort -u)"
[ -f "$file" ] && [ "$new" = "$old" ] && return 0
tmp="$file.tmp.$$"
if {
echo "# Hosts installed from this gstack checkout, one per line. Written by ./setup;"
echo "# scripts/build.sh renders skills only for these (#1694) and setup prunes the"
echo "# renders of every other host. Add a host by running ./setup --host <name>."
if [ -n "$new" ]; then printf '%s\n' "$new"; fi
} > "$tmp" && mv -f "$tmp" "$file"; then
return 0
fi
rm -f "$tmp"
return 1
}
# gstack_render_hosts_select ROOT — sets GSTACK_RENDER_HOST_LIST ("all", or
# claude followed by the recorded hosts) and GSTACK_RENDER_HOST_REASON.
gstack_render_hosts_select() {
local root="$1" file h
file="$(gstack_render_hosts_file "$root")"
GSTACK_RENDER_HOST_LIST=all
case "${GSTACK_RENDER_HOSTS:-}" in
all) GSTACK_RENDER_HOST_REASON="GSTACK_RENDER_HOSTS=all"; return 0 ;;
'') ;;
*) echo "warning: GSTACK_RENDER_HOSTS accepts only 'all' (got '${GSTACK_RENDER_HOSTS}'); ignoring it" >&2 ;;
esac
if [ ! -f "$file" ]; then
GSTACK_RENDER_HOST_REASON="no installed-hosts record, so this is a development checkout"
return 0
fi
GSTACK_RENDER_HOST_LIST=claude
for h in $(gstack_render_hosts_read "$root"); do
[ "$h" = claude ] || GSTACK_RENDER_HOST_LIST="$GSTACK_RENDER_HOST_LIST $h"
done
GSTACK_RENDER_HOST_REASON="the hosts installed from this checkout ($file); GSTACK_RENDER_HOSTS=all renders every host"
}
# _gstack_hr_link_abs LINK — absolute target of LINK, its directory part
# canonicalized (same rule as bin/gstack-relink's _link_target_abs).
_gstack_hr_link_abs() {
local dest d b d_real
dest="$(readlink "$1" 2>/dev/null || true)"
[ -n "$dest" ] || return 1
case "$dest" in /*) ;; *) dest="${1%/*}/$dest" ;; esac
d="${dest%/*}"; b="${dest##*/}"
if d_real="$(cd "$d" 2>/dev/null && pwd -P)"; then printf '%s/%s\n' "$d_real" "$b"; else printf '%s\n' "$dest"; fi
}
# gstack_render_hosts_seed ROOT — record the hosts this checkout already
# serves, for installs made before the record existed: registry rows whose
# source is ROOT, known install roots that resolve to ROOT (a bin/ link or the
# .source-path a Windows copy root carries), host skills entries that link
# into ROOT's render of that host, and gstack skill copies in a host skills dir
# that has no runtime root at all. Needs bin/gstack-install-registry.sh.
gstack_render_hosts_seed() {
local root="$1" real host dest iroot src pair dir e target found=""
real="$(cd "$root" 2>/dev/null && pwd -P)" || return 0
command -v gstack_install_registry_rows >/dev/null 2>&1 || return 0
while IFS=' ' read -r host _ _ _ _ src _; do
[ -n "$host" ] && [ "$src" = "$real" ] && found="$found $host"
done <<EOF
$(gstack_install_registry_rows 2>/dev/null)
EOF
while IFS=' ' read -r host _ dest iroot; do
[ -n "$host" ] || continue
if { [ -e "$iroot" ] || [ -L "$iroot" ]; }; then
src="$(_gstack_install_source "$iroot")"
[ -z "$src" ] && [ -f "$iroot/.source-path" ] && src="$(head -1 "$iroot/.source-path" 2>/dev/null)"
if [ -n "$src" ] && { [ "$src" = "$real" ] || [ "$(cd "$src" 2>/dev/null && pwd -P)" = "$real" ]; }; then
found="$found $host"; continue
fi
fi
dir=""
for pair in $GSTACK_HOST_RENDER_DIRS; do [ "${pair%%:*}" = "$host" ] && dir="${pair#*:}"; done
[ -n "$dir" ] && [ -d "$dest" ] || continue
for e in "$dest"/gstack*; do
if [ -L "$e" ]; then
target="$(_gstack_hr_link_abs "$e")" || continue
case "$target" in "$real/$dir"/*|"$root/$dir"/*) found="$found $host"; break ;; esac
elif [ ! -e "$iroot" ] && [ -d "$e" ] && { [ -f "$e/.gstack-owned" ] || grep -q '<!-- AUTO-GENERATED from' "$e/SKILL.md" 2>/dev/null; }; then
# Copied skills with no runtime root naming any checkout: a legacy
# copy install. Keep serving it rather than guess it is someone else's.
found="$found $host"; break
fi
done
done <<EOF
$(gstack_install_known_roots 2>/dev/null)
EOF
[ -n "$found" ] || return 0
# shellcheck disable=SC2086 # word-split the host list on purpose
gstack_render_hosts_add "$root" $found
}
+14 -1
View File
@@ -36,7 +36,20 @@ esac
# inside untrusted repos, and dotenv could set security switches such as
# GSTACK_CHROMIUM_NO_SANDBOX while a bunfig preload runs arbitrary code (D0).
"$BUN_CMD" run vendor:xterm
"$BUN_CMD" run gen:skill-docs --host all
# Inside an install, render only the hosts setup installed from this checkout
# (#1694): every host's render under ~/.claude/skills/gstack is scanned by
# Claude Code and Cursor. bin/gstack-host-renders.sh owns the record.
. "$ROOT/bin/gstack-host-renders.sh"
gstack_render_hosts_select "$ROOT"
if [ "$GSTACK_RENDER_HOST_LIST" = all ]; then
echo "Rendering skills for every host: $GSTACK_RENDER_HOST_REASON"
"$BUN_CMD" run gen:skill-docs --host all
else
echo "Rendering skills for: $GSTACK_RENDER_HOST_LIST ($GSTACK_RENDER_HOST_REASON)"
for render_host in $GSTACK_RENDER_HOST_LIST; do
"$BUN_CMD" run gen:skill-docs --host "$render_host"
done
fi
"$BUN_CMD" build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig browse/src/cli.ts --outfile browse/dist/browse
"$BUN_CMD" build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig browse/src/find-browse.ts --outfile browse/dist/find-browse
"$BUN_CMD" build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig design/src/cli.ts --outfile design/dist/design
+23 -3
View File
@@ -316,6 +316,7 @@ SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)"
gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root"
. "$SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh is missing. fix: reinstall from a fresh clone (docs/ADDING_A_HOST.md)" >&2; exit 1; }
. "$SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; }
. "$SOURCE_GSTACK_DIR/bin/gstack-host-renders.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-host-renders.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; }
SETUP_VERSION="$(cat "$SOURCE_GSTACK_DIR/VERSION" 2>/dev/null || echo unknown)"
# --claude-model is validated before anything is written, and persisted once
@@ -1712,6 +1713,23 @@ fi
# (bin/gstack-render-claude.sh).
gstack_claude_overlay "$SOURCE_GSTACK_DIR" "$SOURCE_GSTACK_DIR/bin/gstack-config"
# Installed-hosts record (#1694): the build renders skills only for these
# hosts, and step 7b prunes every other host's render from this checkout. The
# record only grows; installs made before it existed are discovered first.
gstack_render_hosts_seed "$SOURCE_GSTACK_DIR"
_RENDER_HOSTS_SELECTED=""
[ "$INSTALL_CLAUDE" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED claude"
[ "$INSTALL_CODEX" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED codex"
[ "$INSTALL_KIRO" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED kiro"
[ "$INSTALL_FACTORY" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED factory"
[ "$INSTALL_OPENCODE" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED opencode"
[ "$INSTALL_CURSOR" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED cursor"
[ "$INSTALL_COPILOT" -eq 1 ] && _RENDER_HOSTS_SELECTED="$_RENDER_HOSTS_SELECTED copilot"
# shellcheck disable=SC2086 # the host list splits on purpose
if ! gstack_render_hosts_add "$SOURCE_GSTACK_DIR" $_RENDER_HOSTS_SELECTED; then
echo " warning: could not write $(gstack_render_hosts_file "$SOURCE_GSTACK_DIR"); this build renders every host and nothing is pruned" >&2
fi
# Migrate existing wrappers BEFORE build can regenerate/prune shared host trees.
# This also repairs existing Codex/Kiro installs during a Claude-only setup.
# A failed/foreign replacement retains its old render even when build runs
@@ -1911,14 +1929,16 @@ if [ ! -x "$BROWSE_BIN" ]; then
exit 1
fi
# 1b. Generate .agents/ Codex skill docs — always regenerate to prevent stale descriptions.
# 1b. Generate .agents/ Codex skill docs whenever this checkout serves Codex.
# .agents/ is no longer committed — generated at setup time from .tmpl templates.
# bun run build generates the host-default artifact. Always render Codex again
# with the resolved user profile so a build cannot overwrite a Sol-specific render.
# Always regenerate: generation is fast (<2s) and mtime-based staleness checks are fragile
# (miss stale files when timestamps match after clone/checkout/upgrade).
# (miss stale files when timestamps match after clone/checkout/upgrade). A
# checkout with no Codex install renders none (#1694).
AGENTS_DIR="$SOURCE_GSTACK_DIR/.agents/skills"
NEEDS_AGENTS_GEN=1
NEEDS_AGENTS_GEN=0
if [ "$INSTALL_CODEX" -eq 1 ] || gstack_render_hosts_has "$SOURCE_GSTACK_DIR" codex; then NEEDS_AGENTS_GEN=1; fi
if [ "$NEEDS_AGENTS_GEN" -eq 1 ]; then
log "Generating .agents/ skill docs..."
+172
View File
@@ -0,0 +1,172 @@
/**
* #1694: an install renders skills only for the hosts installed from it.
*
* `./setup` used to run `gen:skill-docs --host all` inside the install, so a
* global Claude install at ~/.claude/skills/gstack carried 632 SKILL.md files
* (34.7 MB): every other host's render, all scanned by Claude Code and
* Cursor-agent. setup now keeps an additive record of the hosts it installed
* ($ROOT/.gstack-installed-hosts, owned by bin/gstack-host-renders.sh) and
* scripts/build.sh renders claude plus those hosts. A checkout with no record
* (development) or GSTACK_RENDER_HOSTS=all still renders every host.
*/
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ALL_HOST_CONFIGS } from '../hosts';
import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, owned, registryRows, runSetup } from './helpers/install-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
const HELPER = path.join(ROOT, 'bin/gstack-host-renders.sh');
const BUILD = fs.readFileSync(path.join(ROOT, 'scripts/build.sh'), 'utf8');
const RENDER_BLOCK = BUILD.slice(BUILD.indexOf('. "$ROOT/bin/gstack-host-renders.sh"'), BUILD.indexOf('\n"$BUN_CMD" build --compile'));
const RENDER_DIRS = ALL_HOST_CONFIGS.filter(h => h.name !== 'claude').map(h => h.hostSubdir);
afterEach(cleanupFixtures);
afterAll(cleanupSeed);
function tmpRoot(): string {
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-host-renders-')));
owned.push(dir);
return dir;
}
function sh(script: string, env: Record<string, string> = {}, cwd?: string) {
return spawnSync('bash', ['-c', `set -e\n. "${HELPER}"\n${script}`], {
cwd, encoding: 'utf8', timeout: 60_000, env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: os.tmpdir(), ...env },
});
}
const skillCount = (dir: string): number => !fs.existsSync(dir) ? 0
: fs.readdirSync(dir, { withFileTypes: true, recursive: true }).filter(e => e.isFile() && e.name === 'SKILL.md').length;
describe.skipIf(process.platform === 'win32')('installed-hosts record (bin/gstack-host-renders.sh)', () => {
test('its host -> render dir table matches hostSubdir in hosts/*.ts', () => {
const table = sh('printf "%s\\n" $GSTACK_HOST_RENDER_DIRS').stdout.trim().split('\n').sort();
expect(table).toEqual(ALL_HOST_CONFIGS.filter(h => h.name !== 'claude').map(h => `${h.name}:${h.hostSubdir}`).sort());
});
test('adding is additive, sorted and ignores unknown names; an unchanged set is not rewritten', () => {
const root = tmpRoot();
const file = path.join(root, '.gstack-installed-hosts');
expect(sh(`gstack_render_hosts_add "${root}" codex claude`).status).toBe(0);
expect(sh(`gstack_render_hosts_add "${root}" factory nonsense`).status).toBe(0);
expect(sh(`gstack_render_hosts_read "${root}"`).stdout).toBe('claude\ncodex\nfactory\n');
expect(fs.readFileSync(file, 'utf8')).toContain('# Hosts installed from this gstack checkout');
const stamp = new Date('2001-01-01');
fs.utimesSync(file, stamp, stamp);
expect(sh(`gstack_render_hosts_add "${root}" codex`).status).toBe(0);
expect(fs.statSync(file).mtime.getTime()).toBe(stamp.getTime());
fs.appendFileSync(file, 'hermes # rendered by hand\n\nbogus\n');
expect(sh(`gstack_render_hosts_read "${root}"`).stdout).toBe('claude\ncodex\nfactory\nhermes\n');
});
test('selection: no record renders every host; a record renders claude plus its hosts; GSTACK_RENDER_HOSTS=all overrides', () => {
const root = tmpRoot();
const select = (env: Record<string, string> = {}) => sh(`gstack_render_hosts_select "${root}"; echo "$GSTACK_RENDER_HOST_LIST|$GSTACK_RENDER_HOST_REASON"`, env);
expect(select().stdout).toBe('all|no installed-hosts record, so this is a development checkout\n');
sh(`gstack_render_hosts_add "${root}" codex`);
expect(select().stdout).toStartWith('claude codex|the hosts installed from this checkout');
expect(select({ GSTACK_RENDER_HOSTS: 'all' }).stdout).toBe('all|GSTACK_RENDER_HOSTS=all\n');
const odd = select({ GSTACK_RENDER_HOSTS: 'codex' });
expect(odd.stdout).toStartWith('claude codex|');
expect(odd.stderr).toContain("GSTACK_RENDER_HOSTS accepts only 'all' (got 'codex'); ignoring it");
});
});
describe.skipIf(process.platform === 'win32')('scripts/build.sh renders the recorded hosts (#1694)', () => {
// build.sh's render step with a bun that only records its arguments.
function renderCalls(root: string, env: Record<string, string> = {}) {
fs.mkdirSync(path.join(root, 'bin'), { recursive: true });
fs.copyFileSync(HELPER, path.join(root, 'bin/gstack-host-renders.sh'));
const bun = path.join(root, 'fake-bun');
fs.writeFileSync(bun, `#!/bin/sh\necho "$*" >> "${root}/bun.log"\n`, { mode: 0o755 });
const r = spawnSync('bash', ['-c', `set -e\nROOT="${root}"\nBUN_CMD="${bun}"\n${RENDER_BLOCK}`], {
cwd: root, encoding: 'utf8', timeout: 30_000, env: { PATH: process.env.PATH ?? '/usr/bin:/bin', ...env },
});
const log = fs.existsSync(path.join(root, 'bun.log')) ? fs.readFileSync(path.join(root, 'bun.log'), 'utf8') : '';
return { ...r, calls: log.trim().split('\n') };
}
test('a development checkout (no record) renders every host and says why', () => {
const r = renderCalls(tmpRoot());
expect(r.status, r.stderr).toBe(0);
expect(r.calls).toEqual(['run gen:skill-docs --host all']);
expect(r.stdout).toContain('Rendering skills for every host: no installed-hosts record, so this is a development checkout');
});
test('an install renders claude, then each recorded host, and prints them', () => {
const root = tmpRoot();
fs.writeFileSync(path.join(root, '.gstack-installed-hosts'), 'codex\nfactory\n');
const r = renderCalls(root);
expect(r.status, r.stderr).toBe(0);
expect(r.calls).toEqual(['run gen:skill-docs --host claude', 'run gen:skill-docs --host codex', 'run gen:skill-docs --host factory']);
expect(r.stdout).toContain('Rendering skills for: claude codex factory (the hosts installed from this checkout');
});
test('GSTACK_RENDER_HOSTS=all renders every host inside an install', () => {
const root = tmpRoot();
fs.writeFileSync(path.join(root, '.gstack-installed-hosts'), 'claude\n');
const r = renderCalls(root, { GSTACK_RENDER_HOSTS: 'all' });
expect(r.calls).toEqual(['run gen:skill-docs --host all']);
expect(r.stdout).toContain('Rendering skills for every host: GSTACK_RENDER_HOSTS=all');
});
test('a real render inside a Claude install leaves no other host SKILL.md; a development checkout still renders all', () => {
const f = makeFixture();
const install = makeSource(f, path.join(f.home, '.claude/skills/gstack'));
const dev = makeSource(f, path.join(f.home, 'dev/gstack'));
fs.writeFileSync(path.join(install, '.gstack-installed-hosts'), 'claude\n');
for (const root of [install, dev]) {
const r = spawnSync('bash', ['-c', `set -e\nROOT="${root}"\nBUN_CMD=bun\n${RENDER_BLOCK}`], {
cwd: root, encoding: 'utf8', timeout: 120_000, env: f.env,
});
expect(r.status, r.stdout + r.stderr).toBe(0);
}
for (const dir of RENDER_DIRS) {
expect(skillCount(path.join(install, dir)), dir).toBe(0);
expect(skillCount(path.join(dev, dir)), dir).toBeGreaterThan(40);
}
expect(skillCount(install)).toBeLessThan(100);
expect(skillCount(dev)).toBeGreaterThan(500);
}, 180_000);
});
describe.skipIf(process.platform === 'win32')('setup records each host it installs and never forgets one', () => {
test('claude, then codex, then factory: the record grows and each recorded host keeps its render', () => {
const f = makeFixture();
const src = makeSource(f, path.join(f.home, '.claude/skills/gstack'));
const setup = (args: string[]) => {
const r = runSetup(f, path.join(src, 'setup'), args);
expect(r.status, r.stdout + r.stderr).toBe(0);
return fs.readFileSync(path.join(src, '.gstack-installed-hosts'), 'utf8').split('\n').filter(l => l && !l.startsWith('#'));
};
expect(setup([])).toEqual(['claude']);
expect(fs.existsSync(path.join(src, '.agents'))).toBe(false);
expect(setup(['--host', 'codex'])).toEqual(['claude', 'codex']);
expect(skillCount(path.join(src, '.agents'))).toBeGreaterThan(40);
expect(setup(['--host', 'factory'])).toEqual(['claude', 'codex', 'factory']);
expect(skillCount(path.join(src, '.agents'))).toBeGreaterThan(40);
expect(skillCount(path.join(src, '.factory'))).toBeGreaterThan(40);
// The updater path (/gstack-upgrade and gstack-session-update) refreshes
// every registered install; the record and both renders survive it.
const r = runSetup(f, path.join(src, 'setup'), ['--refresh-registered']);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(registryRows(f).map(row => row[0]).sort()).toEqual(['claude', 'codex', 'factory']);
expect(setup([])).toEqual(['claude', 'codex', 'factory']);
expect(skillCount(path.join(src, '.agents'))).toBeGreaterThan(40);
expect(skillCount(path.join(src, '.factory'))).toBeGreaterThan(40);
for (const dir of RENDER_DIRS.filter(d => d !== '.agents' && d !== '.factory')) expect(fs.existsSync(path.join(src, dir)), dir).toBe(false);
}, 240_000);
test('an install made before the record keeps the hosts the registry says it serves', () => {
const f = makeFixture();
const src = makeSource(f, path.join(f.home, '.claude/skills/gstack'));
expect(runSetup(f, path.join(src, 'setup'), ['--host', 'factory']).status).toBe(0);
fs.rmSync(path.join(src, '.gstack-installed-hosts'));
const r = runSetup(f, path.join(src, 'setup'), []);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(fs.readFileSync(path.join(src, '.gstack-installed-hosts'), 'utf8')).toContain('\nclaude\nfactory\n');
}, 120_000);
});
+1 -1
View File
@@ -30,7 +30,7 @@ function fixture(copy: boolean) {
put(path.join(root, 'VERSION'), '2.0.0.0\n');
put(path.join(root, 'ETHOS.md'), 'Fixture ethos\n');
put(path.join(root, 'package.json'), JSON.stringify({ type: 'module', scripts: { 'gen:skill-docs': 'bun run scripts/gen-skill-docs.ts' } }));
for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh', 'gstack-bun-version.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh']) {
for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-state-root.sh', 'gstack-bun-version.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh', 'gstack-host-renders.sh']) {
const target = path.join(root, 'bin', bin);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'bin', bin), target);