mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
C2: link design/dist and make-pdf/dist into every host runtime root and the agents sidecar
Codex design and make-pdf skills fall back to $GSTACK_DESIGN and $GSTACK_MAKE_PDF under the runtime root, but setup linked only browse/dist, so both preflights reported NOT_AVAILABLE on a fresh install. One helper links dist/ only (make-pdf/ ships a Claude SKILL.md) into the Codex, Factory, OpenCode, Cursor, Copilot and Kiro roots, the .agents sidecar and the Cursor sidecar. Co-authored-by: Mammad M. <mammad@digit.az>
This commit is contained in:
1 parent
ff5fbd09ee
commit
467c7d99cc
7 files changed
+199
-9
No files matched your search
+12
-2
@@ -20,8 +20,9 @@ const codex = defineHost({
|
||||
|
||||
// generateMetadata emits agents/openai.yaml (the format is hardcoded in
|
||||
// gen-skill-docs.ts). Codex also gets a repo-local sidecar at
|
||||
// .agents/skills/gstack (symlinked runtime assets: bin, browse, review, qa,
|
||||
// ETHOS.md) — that behavior lives in setup's create_agents_sidecar, not here.
|
||||
// .agents/skills/gstack (symlinked runtime assets: bin, lib, browse, review,
|
||||
// qa, design/dist, make-pdf/dist, ETHOS.md) — that behavior lives in setup's
|
||||
// create_agents_sidecar, not here.
|
||||
generation: {
|
||||
generateMetadata: true,
|
||||
skipSkills: ['codex'],
|
||||
@@ -38,6 +39,15 @@ const codex = defineHost({
|
||||
{ from: 'CLAUDE.md', to: 'AGENTS.md' },
|
||||
],
|
||||
|
||||
// Mirrors create_codex_runtime_root in setup. design/dist and make-pdf/dist
|
||||
// back $GSTACK_DESIGN and $GSTACK_MAKE_PDF (#2891).
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
|
||||
},
|
||||
},
|
||||
|
||||
// Outside-review resolvers route to Claude Code; Review Army has its own restriction.
|
||||
suppressedResolvers: ['REVIEW_ARMY', ...GBRAIN_RESOLVERS],
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ export function defineHost<const N extends string>(overrides: HostOverrides<N>):
|
||||
toolRewrites,
|
||||
suppressedResolvers = [...GBRAIN_RESOLVERS],
|
||||
runtimeRoot = {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'TODOS-format.md'],
|
||||
},
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ const opencode = defineHost({
|
||||
// OpenCode links a wider runtime asset set than the shared default
|
||||
// (design binary, review specialists, qa templates/references, DX hall of fame).
|
||||
runtimeRoot: {
|
||||
globalSymlinks: ['bin', 'browse/dist', 'browse/bin', 'design/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'],
|
||||
globalSymlinks: ['bin', 'lib', 'browse/dist', 'browse/bin', 'design/dist', 'make-pdf/dist', 'gstack-upgrade', 'ETHOS.md', 'review/specialists', 'qa/templates', 'qa/references', 'plan-devex-review/dx-hall-of-fame.md'],
|
||||
globalFiles: {
|
||||
'review': ['checklist.md', 'design-checklist.md', 'greptile-triage.md', 'TODOS-format.md'],
|
||||
},
|
||||
|
||||
@@ -496,6 +496,21 @@ _link_or_copy() {
|
||||
fi
|
||||
}
|
||||
|
||||
# _link_runtime_dists SOURCE ROOT — the compiled design and make-pdf binaries
|
||||
# behind $GSTACK_DESIGN and $GSTACK_MAKE_PDF on every env-var host (#2891).
|
||||
# dist/ only, never the whole directory: make-pdf/ ships a Claude SKILL.md a
|
||||
# host scanning its skills root would discover as a duplicate skill.
|
||||
_link_runtime_dists() {
|
||||
local d
|
||||
for d in design/dist make-pdf/dist; do
|
||||
[ -d "$1/$d" ] || continue
|
||||
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$2/$d" ] || [ ! -e "$2/$d" ]; then
|
||||
mkdir -p "$2/${d%/dist}"
|
||||
_link_or_copy "$1/$d" "$2/$d"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ─── Ownership gate for skill entries (#2119) ─────────────────────────────────
|
||||
# setup and gstack-relink must never delete or link over a skill they do not
|
||||
# own. This is the single rule both use (relink carries the same logic — keep
|
||||
@@ -2322,6 +2337,8 @@ create_agents_sidecar() {
|
||||
fi
|
||||
done
|
||||
|
||||
_link_runtime_dists "$SOURCE_GSTACK_DIR" "$agents_gstack"
|
||||
|
||||
# supabase/config.sh — required by gstack-telemetry-sync to resolve GSTACK_SUPABASE_URL
|
||||
# (file-level on purpose: migrations/ and functions/ are dev-only)
|
||||
if [ -f "$SOURCE_GSTACK_DIR/supabase/config.sh" ]; then
|
||||
@@ -2398,6 +2415,7 @@ create_codex_runtime_root() {
|
||||
if [ -d "$gstack_dir/browse/bin" ]; then
|
||||
_link_or_copy "$gstack_dir/browse/bin" "$codex_gstack/browse/bin"
|
||||
fi
|
||||
_link_runtime_dists "$gstack_dir" "$codex_gstack"
|
||||
if [ -f "$agents_dir/gstack-upgrade/SKILL.md" ]; then
|
||||
_link_or_copy "$agents_dir/gstack-upgrade/SKILL.md" "$codex_gstack/gstack-upgrade/SKILL.md"
|
||||
fi
|
||||
@@ -2453,6 +2471,7 @@ create_factory_runtime_root() {
|
||||
if [ -d "$gstack_dir/browse/bin" ]; then
|
||||
_link_or_copy "$gstack_dir/browse/bin" "$factory_gstack/browse/bin"
|
||||
fi
|
||||
_link_runtime_dists "$gstack_dir" "$factory_gstack"
|
||||
if [ -f "$factory_dir/gstack-upgrade/SKILL.md" ]; then
|
||||
_link_or_copy "$factory_dir/gstack-upgrade/SKILL.md" "$factory_gstack/gstack-upgrade/SKILL.md"
|
||||
fi
|
||||
@@ -2489,7 +2508,7 @@ create_opencode_runtime_root() {
|
||||
rm -rf "$opencode_gstack"
|
||||
fi
|
||||
|
||||
mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/design" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review"
|
||||
mkdir -p "$opencode_gstack" "$opencode_gstack/browse" "$opencode_gstack/gstack-upgrade" "$opencode_gstack/review" "$opencode_gstack/qa" "$opencode_gstack/plan-devex-review"
|
||||
|
||||
if [ -f "$opencode_dir/gstack/SKILL.md" ]; then
|
||||
_link_or_copy "$opencode_dir/gstack/SKILL.md" "$opencode_gstack/SKILL.md"
|
||||
@@ -2506,9 +2525,7 @@ create_opencode_runtime_root() {
|
||||
if [ -d "$gstack_dir/browse/bin" ]; then
|
||||
_link_or_copy "$gstack_dir/browse/bin" "$opencode_gstack/browse/bin"
|
||||
fi
|
||||
if [ -d "$gstack_dir/design/dist" ]; then
|
||||
_link_or_copy "$gstack_dir/design/dist" "$opencode_gstack/design/dist"
|
||||
fi
|
||||
_link_runtime_dists "$gstack_dir" "$opencode_gstack"
|
||||
if [ -f "$opencode_dir/gstack-upgrade/SKILL.md" ]; then
|
||||
_link_or_copy "$opencode_dir/gstack-upgrade/SKILL.md" "$opencode_gstack/gstack-upgrade/SKILL.md"
|
||||
fi
|
||||
@@ -2671,6 +2688,7 @@ create_cursor_runtime_root() {
|
||||
if [ -d "$gstack_dir/browse/bin" ]; then
|
||||
_link_or_copy "$gstack_dir/browse/bin" "$cursor_gstack/browse/bin"
|
||||
fi
|
||||
_link_runtime_dists "$gstack_dir" "$cursor_gstack"
|
||||
if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then
|
||||
_link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md"
|
||||
fi
|
||||
@@ -2728,6 +2746,7 @@ create_cursor_sidecar() {
|
||||
_link_or_copy "$repo_root/browse/bin" "$cursor_gstack/browse/bin"
|
||||
fi
|
||||
fi
|
||||
_link_runtime_dists "$repo_root" "$cursor_gstack"
|
||||
if [ -f "$cursor_dir/gstack-upgrade/SKILL.md" ]; then
|
||||
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$cursor_gstack/gstack-upgrade/SKILL.md" ] || [ ! -e "$cursor_gstack/gstack-upgrade/SKILL.md" ]; then
|
||||
_link_or_copy "$cursor_dir/gstack-upgrade/SKILL.md" "$cursor_gstack/gstack-upgrade/SKILL.md"
|
||||
@@ -2838,6 +2857,7 @@ create_copilot_runtime_root() {
|
||||
_link_or_copy "$gstack_dir/$asset" "$copilot_gstack/$asset"
|
||||
fi
|
||||
done
|
||||
_link_runtime_dists "$gstack_dir" "$copilot_gstack"
|
||||
if [ -f "$copilot_dir/gstack-upgrade/SKILL.md" ]; then
|
||||
_link_or_copy "$copilot_dir/gstack-upgrade/SKILL.md" "$copilot_gstack/gstack-upgrade/SKILL.md"
|
||||
fi
|
||||
@@ -3122,6 +3142,7 @@ if [ "$INSTALL_KIRO" -eq 1 ]; then
|
||||
_link_or_copy "$SOURCE_GSTACK_DIR/lib" "$KIRO_GSTACK/lib"
|
||||
_link_or_copy "$SOURCE_GSTACK_DIR/browse/dist" "$KIRO_GSTACK/browse/dist"
|
||||
_link_or_copy "$SOURCE_GSTACK_DIR/browse/bin" "$KIRO_GSTACK/browse/bin"
|
||||
_link_runtime_dists "$SOURCE_GSTACK_DIR" "$KIRO_GSTACK"
|
||||
if [ -f "$SOURCE_GSTACK_DIR/ETHOS.md" ]; then
|
||||
_link_or_copy "$SOURCE_GSTACK_DIR/ETHOS.md" "$KIRO_GSTACK/ETHOS.md"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types';
|
||||
import { generateDesignSetup } from '../scripts/resolvers/design';
|
||||
import { generateMakePdfSetup } from '../scripts/resolvers/make-pdf';
|
||||
|
||||
// #2891 (C2): Codex design and make-pdf skills resolve their binaries from the
|
||||
// repo-local .agents sidecar first, then from $GSTACK_DESIGN / $GSTACK_MAKE_PDF
|
||||
// under the global ~/.codex/skills/gstack root. setup never linked either
|
||||
// dist/ into those roots, so both preflights printed *_NOT_AVAILABLE on a
|
||||
// fresh install even though the binaries were built.
|
||||
//
|
||||
// Each root is built with the real setup shell functions, then the bash block
|
||||
// the generator emits for Codex runs in a fresh process (the way Codex runs
|
||||
// every shell call).
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
|
||||
|
||||
function extractFunction(name: string): string {
|
||||
const start = SETUP_SRC.indexOf(`\n${name}() {`);
|
||||
const end = SETUP_SRC.indexOf('\n}\n', start);
|
||||
if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`);
|
||||
return SETUP_SRC.slice(start + 1, end + 2);
|
||||
}
|
||||
|
||||
function codexCtx(skillName: string): TemplateContext {
|
||||
return { skillName, tmplPath: `${skillName}/SKILL.md.tmpl`, host: 'codex', paths: HOST_PATHS.codex };
|
||||
}
|
||||
|
||||
function firstBashBlock(rendered: string): string {
|
||||
const m = rendered.match(/```bash\n([\s\S]*?)```/);
|
||||
if (!m) throw new Error('no bash block in rendered section');
|
||||
return m[1];
|
||||
}
|
||||
|
||||
const DESIGN_PREFLIGHT = firstBashBlock(generateDesignSetup(codexCtx('design-shotgun')));
|
||||
const MAKE_PDF_PREFLIGHT = firstBashBlock(generateMakePdfSetup(codexCtx('make-pdf')));
|
||||
|
||||
// A minimal gstack source tree: both built binaries, plus make-pdf's own
|
||||
// SKILL.md and design sources, which must NOT leak into a Codex-scanned root.
|
||||
function makeSource(sandbox: string): string {
|
||||
const src = path.join(sandbox, 'gstack');
|
||||
for (const [rel, body] of [
|
||||
['design/dist/design', '#!/bin/sh\necho design\n'],
|
||||
['make-pdf/dist/pdf', '#!/bin/sh\necho pdf\n'],
|
||||
] as const) {
|
||||
fs.mkdirSync(path.dirname(path.join(src, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(src, rel), body, { mode: 0o755 });
|
||||
}
|
||||
fs.mkdirSync(path.join(src, 'design', 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(src, 'make-pdf', 'SKILL.md'), '---\nname: make-pdf\n---\n');
|
||||
return src;
|
||||
}
|
||||
|
||||
function run(cmd: string, opts: { cwd: string; env?: Record<string, string> }) {
|
||||
const env: Record<string, string> = { PATH: process.env.PATH ?? '/usr/bin:/bin', ...opts.env };
|
||||
return spawnSync('bash', ['-c', cmd], { cwd: opts.cwd, encoding: 'utf-8', timeout: 30000, env });
|
||||
}
|
||||
|
||||
interface Built { script: string; rootDir: string; preflight: { cwd: string; env: Record<string, string> } }
|
||||
|
||||
const BUILDERS: Record<string, (sandbox: string, src: string) => Built> = {
|
||||
'codex global root': (sandbox, src) => {
|
||||
const rootDir = path.join(sandbox, 'home', '.codex', 'skills', 'gstack');
|
||||
const outside = path.join(sandbox, 'not-a-repo');
|
||||
fs.mkdirSync(outside, { recursive: true });
|
||||
return {
|
||||
script: `create_codex_runtime_root "${src}" "${rootDir}"`,
|
||||
rootDir,
|
||||
preflight: {
|
||||
cwd: outside,
|
||||
env: {
|
||||
HOME: path.join(sandbox, 'home'),
|
||||
GSTACK_DESIGN: path.join(rootDir, 'design', 'dist'),
|
||||
GSTACK_MAKE_PDF: path.join(rootDir, 'make-pdf', 'dist'),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
'agents sidecar': (sandbox, src) => {
|
||||
const repo = path.join(sandbox, 'repo');
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
spawnSync('git', ['init', '-q'], { cwd: repo, timeout: 30000 });
|
||||
return {
|
||||
script: `SOURCE_GSTACK_DIR="${src}"\ncreate_agents_sidecar "${repo}"`,
|
||||
rootDir: path.join(repo, '.agents', 'skills', 'gstack'),
|
||||
preflight: {
|
||||
cwd: repo,
|
||||
env: {
|
||||
HOME: path.join(sandbox, 'home'),
|
||||
GSTACK_DESIGN: path.join(sandbox, 'missing'),
|
||||
GSTACK_MAKE_PDF: path.join(sandbox, 'missing'),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const FUNCTIONS = ['_link_or_copy', '_link_runtime_dists', '_sidecar_root_user_owned', '_gstack_generated_header', 'create_agents_sidecar', 'create_codex_runtime_root']
|
||||
.filter(name => SETUP_SRC.includes(`\n${name}() {`))
|
||||
.map(extractFunction)
|
||||
.join('\n');
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('setup: Codex roots expose design and make-pdf binaries (#2891)', () => {
|
||||
for (const [name, build] of Object.entries(BUILDERS)) {
|
||||
for (const isWindows of ['0', '1'] as const) {
|
||||
const mode = isWindows === '1' ? 'Windows copy install' : 'symlink install';
|
||||
test(`${name} (${mode}): design and make-pdf preflights report READY`, () => {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-dist-'));
|
||||
try {
|
||||
const src = makeSource(sandbox);
|
||||
const built = build(sandbox, src);
|
||||
const b = run(`IS_WINDOWS=${isWindows}\n${FUNCTIONS}\n${built.script}`, { cwd: sandbox });
|
||||
expect(b.stderr).toBe('');
|
||||
expect(b.status).toBe(0);
|
||||
|
||||
expect(fs.lstatSync(path.join(built.rootDir, 'design', 'dist')).isSymbolicLink()).toBe(isWindows === '0');
|
||||
expect(fs.existsSync(path.join(built.rootDir, 'make-pdf', 'SKILL.md'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(built.rootDir, 'design', 'src'))).toBe(false);
|
||||
|
||||
const d = run(DESIGN_PREFLIGHT, built.preflight);
|
||||
expect(d.stdout).toContain('DESIGN_READY:');
|
||||
expect(d.stdout).not.toContain('DESIGN_NOT_AVAILABLE');
|
||||
const p = run(MAKE_PDF_PREFLIGHT, built.preflight);
|
||||
expect(p.stdout).toContain('MAKE_PDF_READY:');
|
||||
expect(p.stdout).not.toContain('MAKE_PDF_NOT_AVAILABLE');
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Negative control: a root without design/dist and make-pdf/dist fails both
|
||||
// preflights, so the READY cells are not vacuous.
|
||||
test('a Codex root without design/dist and make-pdf/dist reports NOT_AVAILABLE', () => {
|
||||
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-dist-'));
|
||||
try {
|
||||
const rootDir = path.join(sandbox, 'home', '.codex', 'skills', 'gstack');
|
||||
fs.mkdirSync(path.join(rootDir, 'browse'), { recursive: true });
|
||||
const outside = path.join(sandbox, 'not-a-repo');
|
||||
fs.mkdirSync(outside, { recursive: true });
|
||||
const env = {
|
||||
HOME: path.join(sandbox, 'home'),
|
||||
GSTACK_DESIGN: path.join(rootDir, 'design', 'dist'),
|
||||
GSTACK_MAKE_PDF: path.join(rootDir, 'make-pdf', 'dist'),
|
||||
};
|
||||
expect(run(DESIGN_PREFLIGHT, { cwd: outside, env }).stdout).toContain('DESIGN_NOT_AVAILABLE');
|
||||
expect(run(MAKE_PDF_PREFLIGHT, { cwd: outside, env }).stdout).toContain('MAKE_PDF_NOT_AVAILABLE');
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ const fn = (name: string) => {
|
||||
const blockStart = setup.indexOf('# 6. Install for Kiro CLI');
|
||||
const block = setup.slice(blockStart, setup.indexOf('# 6b.', blockStart));
|
||||
const helpers = [
|
||||
'_link_or_copy', '_sidecar_root_user_owned', '_claude_entry_is_ours',
|
||||
'_link_or_copy', '_link_runtime_dists', '_sidecar_root_user_owned', '_claude_entry_is_ours',
|
||||
'_claude_entry_owned_strongly', '_gstack_link_target_abs', '_gstack_target_is_ours',
|
||||
'_gstack_generated_header', '_backup_skill_md', '_prune_stale_generated',
|
||||
'_skill_source_exists', '_owned_for_windows_refresh', '_cleanup_weak_dir',
|
||||
|
||||
@@ -159,6 +159,7 @@ function runInstaller(
|
||||
`IS_WINDOWS=${isWindows}`,
|
||||
extraVars,
|
||||
extractFn('_link_or_copy'),
|
||||
extractFn('_link_runtime_dists'),
|
||||
// Ownership gates (#2142) — dependencies of every installer under test.
|
||||
extractFn('_owned_for_windows_refresh'),
|
||||
extractFn('_sidecar_root_user_owned'),
|
||||
|
||||
Reference in new issue
Block a user