Skip to content

Commit 6075f9f

Browse files
robcostclaude
andcommitted
fix: scope sessions/ gitignore to root only
The blanket `sessions/` rule was also ignoring `apps/orchestrator/src/sessions/` which contains the session manager, project scaffolder, and persistence modules. Changed to `/sessions/` so only the root-level runtime data directory is ignored. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9656b22 commit 6075f9f

7 files changed

Lines changed: 1319 additions & 2 deletions

File tree

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ testem.log
3838
.DS_Store
3939
Thumbs.db
4040

41-
# GameForge runtime session data
42-
sessions/
41+
# GameForge runtime session data (root-level only)
42+
/sessions/
4343

4444
# Environment variables
4545
.env
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { describe, it, expect, afterEach } from 'vitest';
2+
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
import { scaffoldProject, isProjectScaffolded, copySkills } from './projectScaffolder.js';
6+
import type { Session } from '@robcost/shared-types';
7+
import { createDefaultAgentStates } from '@robcost/shared-types';
8+
9+
import type { GameEngine } from '@robcost/shared-types';
10+
11+
/** Creates a minimal session object pointing to a temp directory. */
12+
function createTestSession(engine: GameEngine = 'phaser'): Session {
13+
const tempDir = mkdtempSync(join(tmpdir(), 'gameforge-test-'));
14+
const projectPath = join(tempDir, 'game');
15+
return {
16+
id: 'test-session-id',
17+
createdAt: Date.now(),
18+
updatedAt: Date.now(),
19+
status: 'scaffolding',
20+
engine,
21+
genre: 'platformer',
22+
projectPath,
23+
vitePort: null,
24+
viteUrl: null,
25+
gdd: null,
26+
conversationHistory: [],
27+
agentStates: createDefaultAgentStates(),
28+
qaResults: [],
29+
iterationCount: 0,
30+
totalCostUsd: 0,
31+
};
32+
}
33+
34+
let tempDirs: string[] = [];
35+
36+
afterEach(() => {
37+
for (const dir of tempDirs) {
38+
rmSync(dir, { recursive: true, force: true });
39+
}
40+
tempDirs = [];
41+
});
42+
43+
describe('scaffoldProject', () => {
44+
it('copies template files to the project directory', async () => {
45+
const session = createTestSession();
46+
tempDirs.push(join(session.projectPath, '..'));
47+
48+
await scaffoldProject(session, { skipInstall: true });
49+
50+
expect(existsSync(join(session.projectPath, 'package.json'))).toBe(true);
51+
expect(existsSync(join(session.projectPath, 'src', 'main.ts'))).toBe(true);
52+
expect(existsSync(join(session.projectPath, 'vite.config.ts'))).toBe(true);
53+
expect(existsSync(join(session.projectPath, 'index.html'))).toBe(true);
54+
});
55+
56+
it('copied package.json has phaser dependency', async () => {
57+
const session = createTestSession();
58+
tempDirs.push(join(session.projectPath, '..'));
59+
60+
await scaffoldProject(session, { skipInstall: true });
61+
62+
const pkg = JSON.parse(
63+
readFileSync(join(session.projectPath, 'package.json'), 'utf-8')
64+
);
65+
expect(pkg.dependencies.phaser).toBeDefined();
66+
});
67+
68+
it('copies scene files', async () => {
69+
const session = createTestSession();
70+
tempDirs.push(join(session.projectPath, '..'));
71+
72+
await scaffoldProject(session, { skipInstall: true });
73+
74+
expect(
75+
existsSync(join(session.projectPath, 'src', 'scenes', 'BootScene.ts'))
76+
).toBe(true);
77+
expect(
78+
existsSync(join(session.projectPath, 'src', 'scenes', 'MainScene.ts'))
79+
).toBe(true);
80+
});
81+
82+
it('scaffolds threejs-starter template for threejs engine', async () => {
83+
const session = createTestSession('threejs');
84+
tempDirs.push(join(session.projectPath, '..'));
85+
86+
await scaffoldProject(session, { skipInstall: true });
87+
88+
expect(existsSync(join(session.projectPath, 'package.json'))).toBe(true);
89+
expect(existsSync(join(session.projectPath, 'src', 'main.ts'))).toBe(true);
90+
91+
const pkg = JSON.parse(
92+
readFileSync(join(session.projectPath, 'package.json'), 'utf-8')
93+
);
94+
expect(pkg.dependencies.three).toBeDefined();
95+
expect(pkg.dependencies.phaser).toBeUndefined();
96+
});
97+
98+
it('scaffolds phaser-starter template for phaser engine', async () => {
99+
const session = createTestSession('phaser');
100+
tempDirs.push(join(session.projectPath, '..'));
101+
102+
await scaffoldProject(session, { skipInstall: true });
103+
104+
const pkg = JSON.parse(
105+
readFileSync(join(session.projectPath, 'package.json'), 'utf-8')
106+
);
107+
expect(pkg.dependencies.phaser).toBeDefined();
108+
expect(pkg.dependencies.three).toBeUndefined();
109+
});
110+
});
111+
112+
describe('copySkills', () => {
113+
it('copies skill files into .claude/skills/ directory', async () => {
114+
const session = createTestSession();
115+
tempDirs.push(join(session.projectPath, '..'));
116+
117+
await scaffoldProject(session, { skipInstall: true });
118+
119+
expect(existsSync(join(session.projectPath, '.claude', 'skills'))).toBe(true);
120+
expect(
121+
existsSync(join(session.projectPath, '.claude', 'skills', 'phaser-development', 'SKILL.md'))
122+
).toBe(true);
123+
expect(
124+
existsSync(join(session.projectPath, '.claude', 'skills', 'threejs-development', 'SKILL.md'))
125+
).toBe(true);
126+
});
127+
128+
it('copies genre and asset reference files', async () => {
129+
const session = createTestSession();
130+
tempDirs.push(join(session.projectPath, '..'));
131+
132+
await scaffoldProject(session, { skipInstall: true });
133+
134+
expect(
135+
existsSync(join(session.projectPath, '.claude', 'skills', 'phaser-development', 'GENRES.md'))
136+
).toBe(true);
137+
expect(
138+
existsSync(join(session.projectPath, '.claude', 'skills', 'phaser-development', 'ASSETS.md'))
139+
).toBe(true);
140+
expect(
141+
existsSync(join(session.projectPath, '.claude', 'skills', 'threejs-development', 'ASSETS.md'))
142+
).toBe(true);
143+
});
144+
145+
it('is idempotent — does not error on repeated calls', async () => {
146+
const session = createTestSession();
147+
tempDirs.push(join(session.projectPath, '..'));
148+
149+
await scaffoldProject(session, { skipInstall: true });
150+
151+
// Second call should not throw
152+
expect(() => copySkills(session.projectPath)).not.toThrow();
153+
});
154+
});
155+
156+
describe('isProjectScaffolded', () => {
157+
it('returns true after scaffolding', async () => {
158+
const session = createTestSession();
159+
tempDirs.push(join(session.projectPath, '..'));
160+
161+
await scaffoldProject(session, { skipInstall: true });
162+
163+
expect(isProjectScaffolded(session)).toBe(true);
164+
});
165+
166+
it('returns false for unscaffolded session', () => {
167+
const session = createTestSession();
168+
tempDirs.push(join(session.projectPath, '..'));
169+
170+
expect(isProjectScaffolded(session)).toBe(false);
171+
});
172+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Project scaffolding — copies a game template into a session directory
3+
* and installs dependencies.
4+
*
5+
* @remarks
6+
* The scaffolder uses `getTemplatePath` from the game-templates package
7+
* to locate the source template, then performs a recursive copy into the
8+
* session's project directory. After copying, it runs `npm install` to
9+
* install the game's dependencies (Phaser, Vite, etc.).
10+
*
11+
* @packageDocumentation
12+
*/
13+
14+
import { mkdirSync, cpSync, existsSync } from 'node:fs';
15+
import { execFile } from 'node:child_process';
16+
import { resolve } from 'node:path';
17+
import { getTemplatePath } from '@robcost/game-templates';
18+
import type { TemplateName } from '@robcost/game-templates';
19+
import type { Session } from '@robcost/shared-types';
20+
21+
/**
22+
* Copies the game template into the session's project directory
23+
* and runs `npm install`.
24+
*
25+
* @param session - The session whose projectPath will be populated.
26+
* @param options - Optional configuration.
27+
* @param options.skipInstall - If true, skips `npm install` (useful for tests).
28+
* @throws If the template cannot be found or the copy/install fails.
29+
*/
30+
export async function scaffoldProject(
31+
session: Session,
32+
options?: { skipInstall?: boolean }
33+
): Promise<void> {
34+
const templateName: TemplateName = session.engine === 'threejs' ? 'threejs-starter' : 'phaser-starter';
35+
const templatePath = getTemplatePath(templateName);
36+
37+
// Create the session project directory
38+
mkdirSync(session.projectPath, { recursive: true });
39+
40+
// Copy the entire template into the project directory
41+
cpSync(templatePath, session.projectPath, { recursive: true });
42+
43+
// Copy Agent Skills into the session's .claude/skills/ directory for SDK discovery
44+
copySkills(session.projectPath);
45+
46+
// Run npm install unless explicitly skipped
47+
if (!options?.skipInstall) {
48+
await runNpmInstall(session.projectPath);
49+
}
50+
}
51+
52+
/**
53+
* Runs `npm install` in the given directory.
54+
*
55+
* @param cwd - The working directory to run npm install in.
56+
* @returns A promise that resolves when install completes.
57+
* @throws If npm install exits with a non-zero code.
58+
*/
59+
function runNpmInstall(cwd: string): Promise<void> {
60+
return new Promise((resolve, reject) => {
61+
execFile('npm', ['install'], { cwd }, (error, _stdout, stderr) => {
62+
if (error) {
63+
reject(new Error(`npm install failed: ${stderr || error.message}`));
64+
return;
65+
}
66+
resolve();
67+
});
68+
});
69+
}
70+
71+
/**
72+
* Copies Agent Skills from the orchestrator's source into the session's
73+
* `.claude/skills/` directory. This enables the Claude Agent SDK to discover
74+
* Skills when the agent runs with `cwd` set to the session project path.
75+
*
76+
* @remarks
77+
* Skills are resolved from `apps/orchestrator/src/agents/skills/` relative to
78+
* `process.cwd()` (the monorepo root). The copy is idempotent — if the target
79+
* already exists it is silently skipped. If the source doesn't exist (e.g., no
80+
* Skills defined yet), the function returns without error.
81+
*
82+
* @param projectPath - The session project directory to copy Skills into.
83+
*/
84+
export function copySkills(projectPath: string): void {
85+
// Try monorepo-root-relative path first (production: cwd = monorepo root),
86+
// then project-relative path (tests: cwd = apps/orchestrator/).
87+
const candidates = [
88+
resolve(process.cwd(), 'apps', 'orchestrator', 'src', 'agents', 'skills'),
89+
resolve(process.cwd(), 'src', 'agents', 'skills'),
90+
];
91+
const skillsSource = candidates.find((p) => existsSync(p));
92+
93+
if (!skillsSource) {
94+
return;
95+
}
96+
97+
const targetDir = resolve(projectPath, '.claude', 'skills');
98+
99+
if (existsSync(targetDir)) {
100+
return;
101+
}
102+
103+
mkdirSync(resolve(projectPath, '.claude'), { recursive: true });
104+
cpSync(skillsSource, targetDir, { recursive: true });
105+
}
106+
107+
/**
108+
* Checks if a session's project directory has been scaffolded.
109+
*
110+
* @param session - The session to check.
111+
* @returns true if the project directory contains a package.json.
112+
*/
113+
export function isProjectScaffolded(session: Session): boolean {
114+
return existsSync(`${session.projectPath}/package.json`);
115+
}

0 commit comments

Comments
 (0)