Skip to content

Commit 0d53ea0

Browse files
committed
feat: support multiple app entry points for repos with distinct UI surfaces
Allow configuring multiple named entry points, each with its own URL, startCommand, and hint. The routeMap now accepts both string values (backward-compatible) and { entry, route } objects to associate routes with specific entry points. Key changes: - Config schema: `app` accepts either AppConfig or EntryPoint[] array - New normalizeConfig() converts any config to canonical internal form - RouteMapping gains `entry` field linking routes to entry points - Pipeline uses `entryPoints: EntryPointUrl[]` instead of `baseUrl: string` - LLM prompts list all entry points and tag routes with entry names - Action starts multiple app processes in parallel - CLI supports `--url name=http://...` for named entry points - All 86 tests pass including new normalize and multi-entry tests Closes #41 https://claude.ai/code/session_01Cb3fgZ9bbvF1aowmZvMkVd
1 parent 0c0d319 commit 0d53ea0

22 files changed

Lines changed: 761 additions & 187 deletions

packages/action/dist/check.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53437,6 +53437,13 @@ var AppConfigSchema = external_exports.object({
5343753437
/** Extra context appended to every LLM prompt (e.g. auth instructions, app-specific notes). */
5343853438
hint: external_exports.string().optional()
5343953439
});
53440+
var EntryPointSchema = AppConfigSchema.extend({
53441+
name: external_exports.string()
53442+
});
53443+
var RouteMapValueSchema = external_exports.union([
53444+
external_exports.string(),
53445+
external_exports.object({ entry: external_exports.string(), route: external_exports.string() })
53446+
]);
5344053447
var RecordingConfigSchema = external_exports.object({
5344153448
viewport: external_exports.object({
5344253449
width: external_exports.number().default(1280),
@@ -53452,8 +53459,8 @@ var LLMConfigSchema = external_exports.object({
5345253459
model: external_exports.string().default("claude-sonnet-4-6")
5345353460
});
5345453461
var GitGlimpseConfigSchema = external_exports.object({
53455-
app: AppConfigSchema,
53456-
routeMap: external_exports.record(external_exports.string()).optional(),
53462+
app: external_exports.union([AppConfigSchema, external_exports.array(EntryPointSchema).min(1)]),
53463+
routeMap: external_exports.record(RouteMapValueSchema).optional(),
5345753464
setup: external_exports.string().optional(),
5345853465
recording: RecordingConfigSchema.optional(),
5345953466
llm: LLMConfigSchema.optional(),

packages/action/dist/check.js.map

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/action/dist/index.js

Lines changed: 193 additions & 73 deletions
Large diffs are not rendered by default.

packages/action/dist/index.js.map

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/action/src/index.ts

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
DEFAULT_TRIGGER,
1414
type GitGlimpseConfig,
1515
} from '@git-glimpse/core';
16-
import { resolveBaseUrl } from './resolve-base-url.js';
16+
import { normalizeConfig } from '@git-glimpse/core';
17+
import { resolveEntryPointUrls } from './resolve-base-url.js';
1718

1819
function streamCommand(cmd: string, args: string[]): Promise<string> {
1920
return new Promise((resolve, reject) => {
@@ -50,12 +51,22 @@ async function run(): Promise<void> {
5051
const triggerModeInput = core.getInput('trigger-mode') || undefined;
5152

5253
let config = await loadConfig(configPath);
53-
if (previewUrlInput) {
54-
config = { ...config, app: { ...config.app, previewUrl: previewUrlInput } };
55-
}
56-
if (startCommandInput) {
57-
config = { ...config, app: { ...config.app, startCommand: startCommandInput } };
54+
55+
// Action-level overrides apply to the first/default entry point
56+
if (previewUrlInput || startCommandInput) {
57+
if (Array.isArray(config.app)) {
58+
const first = { ...config.app[0] };
59+
if (previewUrlInput) first.previewUrl = previewUrlInput;
60+
if (startCommandInput) first.startCommand = startCommandInput;
61+
config = { ...config, app: [first, ...config.app.slice(1)] };
62+
} else {
63+
const app = { ...config.app };
64+
if (previewUrlInput) app.previewUrl = previewUrlInput;
65+
if (startCommandInput) app.startCommand = startCommandInput;
66+
config = { ...config, app };
67+
}
5868
}
69+
5970
if (triggerModeInput && ['auto', 'on-demand', 'smart'].includes(triggerModeInput)) {
6071
config = {
6172
...config,
@@ -155,27 +166,43 @@ async function run(): Promise<void> {
155166
return;
156167
}
157168

158-
const baseUrlResult = resolveBaseUrl(config, previewUrlInput);
159-
if (!baseUrlResult.url) {
160-
core.setFailed(baseUrlResult.error!);
169+
// Normalize config and resolve entry point URLs
170+
const normalized = normalizeConfig(config);
171+
const urlResult = resolveEntryPointUrls(normalized.entryPoints, previewUrlInput);
172+
if (urlResult.error) {
173+
core.setFailed(urlResult.error);
161174
return;
162175
}
163-
const baseUrl = baseUrlResult.url;
176+
const entryPoints = urlResult.entryPoints;
177+
178+
core.info(`Entry points: ${entryPoints.map((ep) => `${ep.name}=${ep.baseUrl}`).join(', ')}`);
164179

165180
if (config.setup) {
166181
core.info(`Running setup: ${config.setup}`);
167182
const parts = config.setup.split(' ');
168183
execFileSync(parts[0]!, parts.slice(1), { stdio: 'inherit' });
169184
}
170185

171-
let appProcess: ReturnType<typeof spawn> | null = null;
172-
if (config.app.startCommand && !config.app.previewUrl) {
173-
appProcess = await startApp(config.app.startCommand, config.app.readyWhen?.url ?? baseUrl);
186+
// Start app processes for entry points that have a startCommand and no previewUrl
187+
const appProcesses: Array<ReturnType<typeof spawn>> = [];
188+
for (const ep of normalized.entryPoints) {
189+
if (ep.startCommand && !ep.previewUrl) {
190+
const resolved = entryPoints.find((r) => r.name === ep.name);
191+
const readyUrl = ep.readyWhen?.url ?? resolved?.baseUrl ?? 'http://localhost:3000';
192+
const proc = await startApp(ep.name, ep.startCommand, readyUrl);
193+
appProcesses.push(proc);
194+
}
174195
}
175196

176197
try {
177198
core.info('Running git-glimpse pipeline...');
178-
const result = await runPipeline({ diff, baseUrl, outputDir: './recordings', config, generalDemo: decision.generalDemo });
199+
const result = await runPipeline({
200+
diff,
201+
entryPoints,
202+
outputDir: './recordings',
203+
config,
204+
generalDemo: decision.generalDemo,
205+
});
179206

180207
if (result.errors.length > 0) {
181208
core.warning(`Pipeline completed with errors:\n${result.errors.join('\n')}`);
@@ -221,21 +248,24 @@ async function run(): Promise<void> {
221248
await addCommentReaction('confused');
222249
throw err;
223250
} finally {
224-
appProcess?.kill();
251+
for (const proc of appProcesses) {
252+
proc.kill();
253+
}
225254
}
226255
}
227256

228257

229258
async function startApp(
259+
name: string,
230260
startCommand: string,
231261
readyUrl: string
232262
): Promise<ReturnType<typeof spawn>> {
233263
const parts = startCommand.split(' ');
234-
core.info(`Starting app: ${startCommand}`);
264+
core.info(`Starting app "${name}": ${startCommand}`);
235265
const proc = spawn(parts[0]!, parts.slice(1), { stdio: 'inherit', shell: false });
236266

237267
await waitForUrl(readyUrl, 30000);
238-
core.info('App is ready');
268+
core.info(`App "${name}" is ready`);
239269
return proc;
240270
}
241271

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
import type { GitGlimpseConfig } from '../../core/src/config/schema.js';
1+
import type { GitGlimpseConfig, AppConfig, ResolvedEntryPoint, EntryPointUrl } from '@git-glimpse/core';
22

3-
export function resolveBaseUrl(
4-
config: GitGlimpseConfig,
3+
function resolveAppUrl(
4+
app: AppConfig,
55
previewUrlOverride?: string
66
): { url: string; error?: never } | { url?: never; error: string } {
7-
const previewUrl = previewUrlOverride ?? config.app.previewUrl;
7+
const previewUrl = previewUrlOverride ?? app.previewUrl;
88
if (previewUrl) {
99
const resolved = process.env[previewUrl];
1010
if (resolved === undefined) {
1111
// previewUrl is a literal URL string, not an env var name
1212
if (previewUrl.startsWith('http')) return { url: previewUrl };
1313
return {
1414
error:
15-
`app.previewUrl is set to "${previewUrl}" but it doesn't look like a URL and no env var with that name was found. ` +
15+
`previewUrl is set to "${previewUrl}" but it doesn't look like a URL and no env var with that name was found. ` +
1616
`Set it to a full URL (e.g. "https://my-preview.vercel.app") or an env var name that is available in this workflow job.`,
1717
};
1818
}
@@ -23,9 +23,38 @@ export function resolveBaseUrl(
2323
}
2424
return { url: resolved };
2525
}
26-
if (config.app.readyWhen?.url) {
27-
const u = new URL(config.app.readyWhen.url);
26+
if (app.readyWhen?.url) {
27+
const u = new URL(app.readyWhen.url);
2828
return { url: u.origin };
2929
}
3030
return { url: 'http://localhost:3000' };
3131
}
32+
33+
/** Resolve base URL for a single-app config (backward compat). */
34+
export function resolveBaseUrl(
35+
config: GitGlimpseConfig,
36+
previewUrlOverride?: string
37+
): { url: string; error?: never } | { url?: never; error: string } {
38+
const app = Array.isArray(config.app) ? config.app[0] : config.app;
39+
return resolveAppUrl(app, previewUrlOverride);
40+
}
41+
42+
/** Resolve base URLs for all entry points. */
43+
export function resolveEntryPointUrls(
44+
entryPoints: ResolvedEntryPoint[],
45+
previewUrlOverride?: string,
46+
): { entryPoints: EntryPointUrl[]; error?: never } | { entryPoints?: never; error: string } {
47+
const result: EntryPointUrl[] = [];
48+
49+
for (const ep of entryPoints) {
50+
// Only apply the override to the first/default entry point
51+
const override = result.length === 0 ? previewUrlOverride : undefined;
52+
const resolved = resolveAppUrl(ep, override);
53+
if (resolved.error) {
54+
return { error: `Entry point "${ep.name}": ${resolved.error}` };
55+
}
56+
result.push({ name: ep.name, baseUrl: resolved.url });
57+
}
58+
59+
return { entryPoints: result };
60+
}

packages/cli/src/index.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env node
22
import { program } from 'commander';
3-
import { loadConfig, runPipeline } from '@git-glimpse/core';
3+
import { loadConfig, runPipeline, normalizeConfig } from '@git-glimpse/core';
4+
import type { EntryPointUrl } from '@git-glimpse/core';
45
import { execSync, execFile } from 'node:child_process';
56
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
67
import { createRequire } from 'node:module';
@@ -9,6 +10,36 @@ import { resolve } from 'node:path';
910
const require = createRequire(import.meta.url);
1011
const pkg = require('../../package.json') as { version: string };
1112

13+
/**
14+
* Parse --url values into entry points.
15+
* Supports:
16+
* --url http://localhost:3000 (single URL, backward compat)
17+
* --url admin=http://localhost:3000 (named entry point)
18+
* --url admin=http://localhost:3000 --url storefront=http://localhost:4000
19+
*/
20+
function parseUrlOptions(urls: string[] | undefined, config: ReturnType<typeof normalizeConfig>): EntryPointUrl[] {
21+
if (!urls || urls.length === 0) {
22+
// Fall back to config
23+
return config.entryPoints.map((ep) => {
24+
const baseUrl = ep.previewUrl
25+
?? ep.readyWhen?.url?.replace(/\/[^/]*$/, '')
26+
?? 'http://localhost:3000';
27+
return { name: ep.name, baseUrl };
28+
});
29+
}
30+
31+
return urls.map((raw, i) => {
32+
const eqIndex = raw.indexOf('=');
33+
if (eqIndex > 0 && !raw.startsWith('http')) {
34+
// name=url format
35+
return { name: raw.slice(0, eqIndex), baseUrl: raw.slice(eqIndex + 1) };
36+
}
37+
// Plain URL — use default or positional name
38+
const name = config.entryPoints[i]?.name ?? 'default';
39+
return { name, baseUrl: raw };
40+
});
41+
}
42+
1243
program
1344
.name('git-glimpse')
1445
.description('Auto-generate visual demo clips of UI changes')
@@ -18,13 +49,14 @@ program
1849
.command('run')
1950
.description('Generate a demo clip for the current working tree changes')
2051
.option('-d, --diff <diff>', 'Git ref or diff (e.g. HEAD~1, main..HEAD)')
21-
.option('-u, --url <url>', 'Base URL of the running app')
52+
.option('-u, --url <url...>', 'Base URL(s) of the running app (e.g. http://localhost:3000 or admin=http://localhost:3000)')
2253
.option('-c, --config <path>', 'Path to git-glimpse.config.ts')
2354
.option('-o, --output <dir>', 'Output directory for recordings', './recordings')
2455
.option('--open', 'Open the recording after generation')
2556
.action(async (options) => {
2657
try {
2758
const config = await loadConfig(options.config);
59+
const normalized = normalizeConfig(config);
2860

2961
// Get diff
3062
const diffRef = options.diff ?? 'HEAD~1';
@@ -41,19 +73,18 @@ program
4173
process.exit(1);
4274
}
4375

44-
const baseUrl =
45-
options.url ??
46-
config.app.readyWhen?.url?.replace(/\/[^/]*$/, '') ??
47-
'http://localhost:3000';
76+
const entryPoints = parseUrlOptions(options.url, normalized);
4877

4978
console.log(`Running git-glimpse...`);
50-
console.log(` Base URL: ${baseUrl}`);
79+
for (const ep of entryPoints) {
80+
console.log(` Entry point "${ep.name}": ${ep.baseUrl}`);
81+
}
5182
console.log(` Diff: ${diffRef}`);
5283
console.log(` Output: ${options.output}`);
5384

5485
const result = await runPipeline({
5586
diff,
56-
baseUrl,
87+
entryPoints,
5788
outputDir: options.output,
5889
config,
5990
});

packages/core/src/analyzer/change-summarizer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export async function summarizeChanges(
4444
function buildSummaryPrompt(diff: string, routes: RouteMapping[]): string {
4545
const routeList =
4646
routes.length > 0
47-
? routes.map((r) => ` - ${r.file}${r.route}`).join('\n')
47+
? routes.map((r) => ` - ${r.file}[${r.entry}] ${r.route}`).join('\n')
4848
: ' (no routes detected automatically)';
4949

5050
return `Analyze this code diff and provide a brief summary of the UI changes for a demo video.

packages/core/src/analyzer/route-detector.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { minimatch } from 'minimatch';
22
import type { ParsedDiff } from './diff-parser.js';
3+
import type { NormalizedRouteMap } from '../config/normalize.js';
34

45
export interface RouteMapping {
56
file: string;
67
route: string;
8+
entry: string;
79
changeType: 'added' | 'modified' | 'deleted';
810
}
911

1012
export interface RouteDetectionOptions {
11-
routeMap?: Record<string, string>;
12-
baseUrl: string;
13+
routeMap?: NormalizedRouteMap;
14+
defaultEntry: string;
1315
}
1416

1517
export function detectRoutes(
@@ -23,36 +25,44 @@ export function detectRoutes(
2325
if (file.changeType === 'deleted') continue;
2426

2527
const changeType = file.changeType === 'added' ? 'added' : 'modified';
26-
const route = resolveRoute(file.path, options);
28+
const resolved = resolveRoute(file.path, options);
2729

28-
if (route && !seen.has(route)) {
29-
seen.add(route);
30-
mappings.push({ file: file.path, route, changeType });
30+
if (resolved) {
31+
const key = `${resolved.entry}:${resolved.route}`;
32+
if (!seen.has(key)) {
33+
seen.add(key);
34+
mappings.push({ file: file.path, route: resolved.route, entry: resolved.entry, changeType });
35+
}
3136
}
3237
}
3338

3439
return mappings;
3540
}
3641

37-
function resolveRoute(filePath: string, options: RouteDetectionOptions): string | null {
42+
interface ResolvedRoute {
43+
entry: string;
44+
route: string;
45+
}
46+
47+
function resolveRoute(filePath: string, options: RouteDetectionOptions): ResolvedRoute | null {
3848
// 1. Explicit routeMap (supports glob patterns)
3949
if (options.routeMap) {
40-
for (const [pattern, route] of Object.entries(options.routeMap)) {
50+
for (const [pattern, mapping] of Object.entries(options.routeMap)) {
4151
if (minimatch(filePath, pattern) || filePath === pattern || filePath.startsWith(pattern)) {
42-
return route;
52+
return { entry: mapping.entry, route: mapping.route };
4353
}
4454
}
4555
}
4656

47-
// 2. Framework convention detection
57+
// 2. Framework convention detection (defaults to the first entry point)
4858
const remixRoute = detectRemixRoute(filePath);
49-
if (remixRoute) return remixRoute;
59+
if (remixRoute) return { entry: options.defaultEntry, route: remixRoute };
5060

5161
const nextRoute = detectNextjsRoute(filePath);
52-
if (nextRoute) return nextRoute;
62+
if (nextRoute) return { entry: options.defaultEntry, route: nextRoute };
5363

5464
const sveltekitRoute = detectSvelteKitRoute(filePath);
55-
if (sveltekitRoute) return sveltekitRoute;
65+
if (sveltekitRoute) return { entry: options.defaultEntry, route: sveltekitRoute };
5666

5767
return null;
5868
}

0 commit comments

Comments
 (0)