Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ export function resolveServerScript(
);
}

const SERVER_SCRIPT = resolveServerScript();

/**
* On Windows, resolve the Node.js-compatible server bundle.
* Falls back to null if not found (server will use Bun instead).
Expand All @@ -80,15 +78,38 @@ export function resolveNodeServerScript(
return null;
}

const NODE_SERVER_SCRIPT = IS_WINDOWS ? resolveNodeServerScript() : null;
export function resolveServerLaunchScripts(
platform: NodeJS.Platform = process.platform,
env: Record<string, string | undefined> = process.env,
metaDir: string = import.meta.dir,
execPath: string = process.execPath,
): { serverScript: string | null; nodeServerScript: string | null } {
const nodeServerScript = platform === 'win32'
? resolveNodeServerScript(metaDir, execPath)
: null;

// The Windows distribution intentionally ships only the Node bundle in its
// minimal runtime root. Do not require browse/src/server.ts on that path.
if (platform === 'win32') {
if (!nodeServerScript) {
throw new Error(
'server-node.mjs not found. Run `bun run build` to generate the Windows server bundle.'
);
}
return { serverScript: null, nodeServerScript };
}

// On Windows, hard-fail if server-node.mjs is missing — the Bun path is known broken.
if (IS_WINDOWS && !NODE_SERVER_SCRIPT) {
throw new Error(
'server-node.mjs not found. Run `bun run build` to generate the Windows server bundle.'
);
return {
serverScript: resolveServerScript(env, metaDir, execPath),
nodeServerScript: null,
};
}

const {
serverScript: SERVER_SCRIPT,
nodeServerScript: NODE_SERVER_SCRIPT,
} = resolveServerLaunchScripts();

interface ServerState {
pid: number;
port: number;
Expand Down Expand Up @@ -338,6 +359,9 @@ async function startServer(extraEnv?: Record<string, string>): Promise<ServerSta
// which calls setsid() so the server becomes its own session leader
// (PPID=1, STAT=Ss) and survives the spawning shell's exit. Mirrors
// the Windows path's rationale — same root cause, different OS API.
if (!SERVER_SCRIPT) {
throw new Error('Bun server script was not resolved for this platform.');
}
nodeSpawn('bun', ['run', SERVER_SCRIPT], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
Expand Down
26 changes: 24 additions & 2 deletions browse/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ describe('resolveServerScript', () => {
});

describe('resolveNodeServerScript', () => {
const { resolveNodeServerScript } = require('../src/cli');
const { resolveNodeServerScript, resolveServerLaunchScripts } = require('../src/cli');

test('finds server-node.mjs in dist from dev mode', () => {
const srcDir = path.resolve(__dirname, '../src');
Expand All @@ -225,6 +225,28 @@ describe('resolveNodeServerScript', () => {
expect(result).toBe(distFile);
}
});

test('Windows minimal runtime uses adjacent Node bundle without server.ts', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-minimal-runtime-'));
try {
const nodeBundle = path.join(tmpDir, 'server-node.mjs');
fs.writeFileSync(nodeBundle, '// test bundle\n');

const result = resolveServerLaunchScripts(
'win32',
{},
'/nonexistent/$bunfs',
path.join(tmpDir, 'browse.exe'),
);

expect(result).toEqual({
serverScript: null,
nodeServerScript: nodeBundle,
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});

describe('version mismatch detection', () => {
Expand Down Expand Up @@ -367,7 +389,7 @@ describe('resolveChromiumProfile', () => {
delete process.env.CHROMIUM_PROFILE;
process.env.GSTACK_HOME = '/tmp/fallback-gstack';
try {
expect(resolveChromiumProfile()).toBe('/tmp/fallback-gstack/chromium-profile');
expect(resolveChromiumProfile()).toBe(path.join('/tmp/fallback-gstack', 'chromium-profile'));
} finally {
if (origEnv !== undefined) process.env.CHROMIUM_PROFILE = origEnv;
if (origHome === undefined) delete process.env.GSTACK_HOME;
Expand Down
7 changes: 5 additions & 2 deletions scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ esac
"$BUN_CMD" build --compile design/src/cli.ts --outfile design/dist/design
"$BUN_CMD" build --compile make-pdf/src/cli.ts --outfile make-pdf/dist/pdf
"$BUN_CMD" build --compile bin/gstack-global-discover.ts --outfile bin/gstack-global-discover
bash browse/scripts/build-node-server.sh
bash scripts/write-version-files.sh browse/dist/.version design/dist/.version make-pdf/dist/.version
# Keep nested build steps in this shell. On Windows, a plain `bash` lookup can
# resolve to wsl.exe through System32 even when this script is running in Git
# Bash, which rewrites the checkout to /mnt/c/... and makes Bun miss the files.
"$BASH" browse/scripts/build-node-server.sh
"$BASH" scripts/write-version-files.sh browse/dist/.version design/dist/.version make-pdf/dist/.version
chmod +x browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf bin/gstack-global-discover
rm -f .*.bun-build
if [ "$BUN_CMD_WAS_COPIED" -eq 1 ]; then
Expand Down
5 changes: 3 additions & 2 deletions scripts/resolvers/browse.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { TemplateContext } from './types';
import { shellRuntimePath, type TemplateContext } from './types';
import { COMMAND_DESCRIPTIONS } from '../../browse/src/commands';
import { SNAPSHOT_FLAGS } from '../../browse/src/snapshot';

Expand Down Expand Up @@ -100,13 +100,14 @@ export function generateSnapshotFlags(_ctx: TemplateContext): string {
}

export function generateBrowseSetup(ctx: TemplateContext): string {
const browseDir = shellRuntimePath(ctx.paths.browseDir);
return `## SETUP (run this check BEFORE any browse command)

\`\`\`bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse"
[ -z "$B" ] && B="$HOME${ctx.paths.browseDir.replace(/^~/, '')}/browse"
[ -z "$B" ] && B="${browseDir}/browse"
if [ -x "$B" ]; then
echo "READY: $B"
else
Expand Down
12 changes: 7 additions & 5 deletions scripts/resolvers/design.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { TemplateContext } from './types';
import { shellRuntimePath, type TemplateContext } from './types';
import { AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS } from './constants';

export function generateDesignReviewLite(ctx: TemplateContext): string {
Expand Down Expand Up @@ -786,21 +786,23 @@ Source: [OpenAI "Designing Delightful Frontends with GPT-5.4"](https://developer
}

export function generateDesignSetup(ctx: TemplateContext): string {
const designDir = shellRuntimePath(ctx.paths.designDir);
const browseDir = shellRuntimePath(ctx.paths.browseDir);
return `## DESIGN SETUP (run this check BEFORE any design mockup command)

\`\`\`bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
[ -z "$D" ] && D="$HOME${ctx.paths.designDir.replace(/^~/, '')}/design"
[ -z "$D" ] && D="${designDir}/design"
if [ -x "$D" ]; then
echo "DESIGN_READY: $D"
else
echo "DESIGN_NOT_AVAILABLE"
fi
B=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse" ] && B="$_ROOT/${ctx.paths.localSkillRoot}/browse/dist/browse"
[ -z "$B" ] && B="$HOME${ctx.paths.browseDir.replace(/^~/, '')}/browse"
[ -z "$B" ] && B="${browseDir}/browse"
if [ -x "$B" ]; then
echo "BROWSE_READY: $B"
else
Expand Down Expand Up @@ -831,13 +833,14 @@ data, not project files. They persist across branches, conversations, and worksp
}

export function generateDesignMockup(ctx: TemplateContext): string {
const designDir = shellRuntimePath(ctx.paths.designDir);
return `## Visual Design Exploration

\`\`\`bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
D=""
[ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design" ] && D="$_ROOT/${ctx.paths.localSkillRoot}/design/dist/design"
[ -z "$D" ] && D="$HOME${ctx.paths.designDir.replace(/^~/, '')}/design"
[ -z "$D" ] && D="${designDir}/design"
[ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE"
\`\`\`

Expand Down Expand Up @@ -1154,4 +1157,3 @@ Flat design can strip away useful visual information that signals interactivity.
Prioritize ruthlessly: things needed in a hurry go close at hand, everything
else a few taps away with an obvious path to get there.`;
}

5 changes: 3 additions & 2 deletions scripts/resolvers/make-pdf.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { TemplateContext } from './types';
import { shellRuntimePath, type TemplateContext } from './types';

/**
* {{MAKE_PDF_SETUP}} — emits the shell preamble that resolves $P to the
Expand All @@ -12,14 +12,15 @@ import type { TemplateContext } from './types';
* 3. Env override (MAKE_PDF_BIN) — for contributor dev builds
*/
export function generateMakePdfSetup(ctx: TemplateContext): string {
const makePdfDir = shellRuntimePath(ctx.paths.makePdfDir);
return `## MAKE-PDF SETUP (run this check BEFORE any make-pdf command)

\`\`\`bash
_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
P=""
[ -n "$MAKE_PDF_BIN" ] && [ -x "$MAKE_PDF_BIN" ] && P="$MAKE_PDF_BIN"
[ -z "$P" ] && [ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/make-pdf/dist/pdf" ] && P="$_ROOT/${ctx.paths.localSkillRoot}/make-pdf/dist/pdf"
[ -z "$P" ] && P="$HOME${ctx.paths.makePdfDir.replace(/^~/, '')}/pdf"
[ -z "$P" ] && P="${makePdfDir}/pdf"
if [ -x "$P" ]; then
echo "MAKE_PDF_READY: $P"
alias _p_="$P" # shellcheck alias helper (not exported)
Expand Down
11 changes: 11 additions & 0 deletions scripts/resolvers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ function buildHostPaths(): Record<string, HostPaths> {

export const HOST_PATHS: Record<string, HostPaths> = buildHostPaths();

/**
* Convert a host path into the shell expression used by generated setup blocks.
* Env-var-backed hosts (Codex, Factory, etc.) already produce absolute runtime
* roots in their preamble, while Claude-style paths begin with `~` and need an
* explicit `$HOME` expansion inside quoted strings.
*/
export function shellRuntimePath(runtimePath: string): string {
if (runtimePath.startsWith('$')) return runtimePath;
return `$HOME${runtimePath.replace(/^~/, '')}`;
}

import type { Model } from '../models';
export type { Model } from '../models';

Expand Down
8 changes: 4 additions & 4 deletions test/gen-skill-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ describe('gen-skill-docs', () => {
stderr: 'pipe',
});
expect(result.exitCode).toBe(0);
const output = result.stdout.toString();
const output = result.stdout.toString().replaceAll('\\', '/');
// Every skill should be FRESH
for (const skill of CLAUDE_GENERATED_SKILLS) {
const file = skill.dir === '.' ? 'SKILL.md' : `${skill.dir}/SKILL.md`;
Expand Down Expand Up @@ -1804,7 +1804,7 @@ describe('Codex generation (--host codex)', () => {
stderr: 'pipe',
});
expect(result.exitCode).toBe(0);
const output = result.stdout.toString();
const output = result.stdout.toString().replaceAll('\\', '/');
// Every Codex skill should be FRESH
for (const skill of CODEX_SKILLS) {
expect(output).toContain(`FRESH: .agents/skills/${skill.codexName}/SKILL.md`);
Expand Down Expand Up @@ -2109,7 +2109,7 @@ describe('Factory generation (--host factory)', () => {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
expect(result.exitCode).toBe(0);
const output = result.stdout.toString();
const output = result.stdout.toString().replaceAll('\\', '/');
for (const skill of FACTORY_SKILLS) {
expect(output).toContain(`FRESH: .factory/skills/${skill.factoryName}/SKILL.md`);
}
Expand Down Expand Up @@ -2242,7 +2242,7 @@ describe('--host all', () => {
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
});
expect(result.exitCode).toBe(0);
const output = result.stdout.toString();
const output = result.stdout.toString().replaceAll('\\', '/');
// All hosts should appear in output
expect(output).toContain('FRESH: SKILL.md'); // claude
for (const hostConfig of getExternalHosts()) {
Expand Down
47 changes: 47 additions & 0 deletions test/resolver-runtime-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, test } from 'bun:test';
import { generateBrowseSetup } from '../scripts/resolvers/browse';
import { generateDesignMockup, generateDesignSetup } from '../scripts/resolvers/design';
import { generateMakePdfSetup } from '../scripts/resolvers/make-pdf';
import { HOST_PATHS, shellRuntimePath, type TemplateContext } from '../scripts/resolvers/types';

function context(host: 'claude' | 'codex'): TemplateContext {
return {
skillName: 'test-skill',
tmplPath: 'test/SKILL.md.tmpl',
host,
paths: HOST_PATHS[host],
};
}

describe('generated runtime executable paths', () => {
test('expands home-relative paths without changing env-var roots', () => {
expect(shellRuntimePath('~/.claude/skills/gstack/browse/dist'))
.toBe('$HOME/.claude/skills/gstack/browse/dist');
expect(shellRuntimePath('$GSTACK_BROWSE')).toBe('$GSTACK_BROWSE');
});

test('Codex setup blocks never prepend HOME to GSTACK env vars', () => {
const ctx = context('codex');
const output = [
generateBrowseSetup(ctx),
generateDesignSetup(ctx),
generateDesignMockup(ctx),
generateMakePdfSetup(ctx),
].join('\n');

expect(output).toContain('B="$GSTACK_BROWSE/browse"');
expect(output).toContain('D="$GSTACK_DESIGN/design"');
expect(output).toContain('P="$GSTACK_MAKE_PDF/pdf"');
expect(output).not.toContain('$HOME$GSTACK_');
});

test('Claude setup blocks still expand tilde paths through HOME', () => {
const ctx = context('claude');
expect(generateBrowseSetup(ctx))
.toContain('B="$HOME/.claude/skills/gstack/browse/dist/browse"');
expect(generateDesignSetup(ctx))
.toContain('D="$HOME/.claude/skills/gstack/design/dist/design"');
expect(generateMakePdfSetup(ctx))
.toContain('P="$HOME/.claude/skills/gstack/make-pdf/dist/pdf"');
});
});