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
11 changes: 9 additions & 2 deletions packages/action/dist/check.js
Original file line number Diff line number Diff line change
Expand Up @@ -53437,6 +53437,13 @@ var AppConfigSchema = external_exports.object({
/** Extra context appended to every LLM prompt (e.g. auth instructions, app-specific notes). */
hint: external_exports.string().optional()
});
var EntryPointSchema = AppConfigSchema.extend({
name: external_exports.string()
});
var RouteMapValueSchema = external_exports.union([
external_exports.string(),
external_exports.object({ entry: external_exports.string(), route: external_exports.string() })
]);
var RecordingConfigSchema = external_exports.object({
viewport: external_exports.object({
width: external_exports.number().default(1280),
Expand All @@ -53452,8 +53459,8 @@ var LLMConfigSchema = external_exports.object({
model: external_exports.string().default("claude-sonnet-4-6")
});
var GitGlimpseConfigSchema = external_exports.object({
app: AppConfigSchema,
routeMap: external_exports.record(external_exports.string()).optional(),
app: external_exports.union([AppConfigSchema, external_exports.array(EntryPointSchema).min(1)]),
routeMap: external_exports.record(RouteMapValueSchema).optional(),
setup: external_exports.string().optional(),
recording: RecordingConfigSchema.optional(),
llm: LLMConfigSchema.optional(),
Expand Down
4 changes: 2 additions & 2 deletions packages/action/dist/check.js.map

Large diffs are not rendered by default.

238 changes: 173 additions & 65 deletions packages/action/dist/index.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions packages/action/dist/index.js.map

Large diffs are not rendered by default.

64 changes: 47 additions & 17 deletions packages/action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
DEFAULT_TRIGGER,
type GitGlimpseConfig,
} from '@git-glimpse/core';
import { resolveBaseUrl } from './resolve-base-url.js';
import { normalizeConfig } from '@git-glimpse/core';
import { resolveEntryPointUrls } from './resolve-base-url.js';
import { checkApiKey } from './api-key-check.js';

function streamCommand(cmd: string, args: string[]): Promise<string> {
Expand Down Expand Up @@ -57,12 +58,22 @@ async function run(): Promise<void> {
const triggerModeInput = core.getInput('trigger-mode') || undefined;

let config = await loadConfig(configPath);
if (previewUrlInput) {
config = { ...config, app: { ...config.app, previewUrl: previewUrlInput } };
}
if (startCommandInput) {
config = { ...config, app: { ...config.app, startCommand: startCommandInput } };

// Action-level overrides apply to the first/default entry point
if (previewUrlInput || startCommandInput) {
if (Array.isArray(config.app)) {
const first = { ...config.app[0] };
if (previewUrlInput) first.previewUrl = previewUrlInput;
if (startCommandInput) first.startCommand = startCommandInput;
config = { ...config, app: [first, ...config.app.slice(1)] };
} else {
const app = { ...config.app };
if (previewUrlInput) app.previewUrl = previewUrlInput;
if (startCommandInput) app.startCommand = startCommandInput;
config = { ...config, app };
}
}

if (triggerModeInput && ['auto', 'on-demand', 'smart'].includes(triggerModeInput)) {
config = {
...config,
Expand Down Expand Up @@ -162,27 +173,43 @@ async function run(): Promise<void> {
return;
}

const baseUrlResult = resolveBaseUrl(config, previewUrlInput);
if (!baseUrlResult.url) {
core.setFailed(baseUrlResult.error!);
// Normalize config and resolve entry point URLs
const normalized = normalizeConfig(config);
const urlResult = resolveEntryPointUrls(normalized.entryPoints, previewUrlInput);
if (urlResult.error) {
core.setFailed(urlResult.error);
return;
}
const baseUrl = baseUrlResult.url;
const entryPoints = urlResult.entryPoints;

core.info(`Entry points: ${entryPoints.map((ep) => `${ep.name}=${ep.baseUrl}`).join(', ')}`);

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

let appProcess: ReturnType<typeof spawn> | null = null;
if (config.app.startCommand && !config.app.previewUrl) {
appProcess = await startApp(config.app.startCommand, config.app.readyWhen?.url ?? baseUrl);
// Start app processes for entry points that have a startCommand and no previewUrl
const appProcesses: Array<ReturnType<typeof spawn>> = [];
for (const ep of normalized.entryPoints) {
if (ep.startCommand && !ep.previewUrl) {
const resolved = entryPoints.find((r) => r.name === ep.name);
const readyUrl = ep.readyWhen?.url ?? resolved?.baseUrl ?? 'http://localhost:3000';
const proc = await startApp(ep.name, ep.startCommand, readyUrl);
appProcesses.push(proc);
}
}

try {
core.info('Running git-glimpse pipeline...');
const result = await runPipeline({ diff, baseUrl, outputDir: './recordings', config, generalDemo: decision.generalDemo });
const result = await runPipeline({
diff,
entryPoints,
outputDir: './recordings',
config,
generalDemo: decision.generalDemo,
});

if (result.errors.length > 0) {
core.warning(`Pipeline completed with errors:\n${result.errors.join('\n')}`);
Expand Down Expand Up @@ -228,21 +255,24 @@ async function run(): Promise<void> {
await addCommentReaction('confused');
throw err;
} finally {
appProcess?.kill();
for (const proc of appProcesses) {
proc.kill();
}
}
}


async function startApp(
name: string,
startCommand: string,
readyUrl: string
): Promise<ReturnType<typeof spawn>> {
const parts = startCommand.split(' ');
core.info(`Starting app: ${startCommand}`);
core.info(`Starting app "${name}": ${startCommand}`);
const proc = spawn(parts[0]!, parts.slice(1), { stdio: 'inherit', shell: false });

await waitForUrl(readyUrl, 30000);
core.info('App is ready');
core.info(`App "${name}" is ready`);
return proc;
}

Expand Down
43 changes: 36 additions & 7 deletions packages/action/src/resolve-base-url.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import type { GitGlimpseConfig } from '../../core/src/config/schema.js';
import type { GitGlimpseConfig, AppConfig, ResolvedEntryPoint, EntryPointUrl } from '@git-glimpse/core';

export function resolveBaseUrl(
config: GitGlimpseConfig,
function resolveAppUrl(
app: AppConfig,
previewUrlOverride?: string
): { url: string; error?: never } | { url?: never; error: string } {
const previewUrl = previewUrlOverride ?? config.app.previewUrl;
const previewUrl = previewUrlOverride ?? app.previewUrl;
if (previewUrl) {
const resolved = process.env[previewUrl];
if (resolved === undefined) {
// previewUrl is a literal URL string, not an env var name
if (previewUrl.startsWith('http')) return { url: previewUrl };
return {
error:
`app.previewUrl is set to "${previewUrl}" but it doesn't look like a URL and no env var with that name was found. ` +
`previewUrl is set to "${previewUrl}" but it doesn't look like a URL and no env var with that name was found. ` +
`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.`,
};
}
Expand All @@ -23,9 +23,38 @@ export function resolveBaseUrl(
}
return { url: resolved };
}
if (config.app.readyWhen?.url) {
const u = new URL(config.app.readyWhen.url);
if (app.readyWhen?.url) {
const u = new URL(app.readyWhen.url);
return { url: u.origin };
}
return { url: 'http://localhost:3000' };
}

/** Resolve base URL for a single-app config (backward compat). */
export function resolveBaseUrl(
config: GitGlimpseConfig,
previewUrlOverride?: string
): { url: string; error?: never } | { url?: never; error: string } {
const app = Array.isArray(config.app) ? config.app[0] : config.app;
return resolveAppUrl(app, previewUrlOverride);
}

/** Resolve base URLs for all entry points. */
export function resolveEntryPointUrls(
entryPoints: ResolvedEntryPoint[],
previewUrlOverride?: string,
): { entryPoints: EntryPointUrl[]; error?: never } | { entryPoints?: never; error: string } {
const result: EntryPointUrl[] = [];

for (const ep of entryPoints) {
// Only apply the override to the first/default entry point
const override = result.length === 0 ? previewUrlOverride : undefined;
const resolved = resolveAppUrl(ep, override);
if (resolved.error) {
return { error: `Entry point "${ep.name}": ${resolved.error}` };
}
result.push({ name: ep.name, baseUrl: resolved.url });
}

return { entryPoints: result };
}
47 changes: 39 additions & 8 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { program } from 'commander';
import { loadConfig, runPipeline } from '@git-glimpse/core';
import { loadConfig, runPipeline, normalizeConfig } from '@git-glimpse/core';
import type { EntryPointUrl } from '@git-glimpse/core';
import { execSync, execFile } from 'node:child_process';
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
Expand All @@ -9,6 +10,36 @@ import { resolve } from 'node:path';
const require = createRequire(import.meta.url);
const pkg = require('../../package.json') as { version: string };

/**
* Parse --url values into entry points.
* Supports:
* --url http://localhost:3000 (single URL, backward compat)
* --url admin=http://localhost:3000 (named entry point)
* --url admin=http://localhost:3000 --url storefront=http://localhost:4000
*/
function parseUrlOptions(urls: string[] | undefined, config: ReturnType<typeof normalizeConfig>): EntryPointUrl[] {
if (!urls || urls.length === 0) {
// Fall back to config
return config.entryPoints.map((ep) => {
const baseUrl = ep.previewUrl
?? ep.readyWhen?.url?.replace(/\/[^/]*$/, '')
?? 'http://localhost:3000';
return { name: ep.name, baseUrl };
});
}

return urls.map((raw, i) => {
const eqIndex = raw.indexOf('=');
if (eqIndex > 0 && !raw.startsWith('http')) {
// name=url format
return { name: raw.slice(0, eqIndex), baseUrl: raw.slice(eqIndex + 1) };
}
// Plain URL — use default or positional name
const name = config.entryPoints[i]?.name ?? 'default';
return { name, baseUrl: raw };
});
}

program
.name('git-glimpse')
.description('Auto-generate visual demo clips of UI changes')
Expand All @@ -18,13 +49,14 @@ program
.command('run')
.description('Generate a demo clip for the current working tree changes')
.option('-d, --diff <diff>', 'Git ref or diff (e.g. HEAD~1, main..HEAD)')
.option('-u, --url <url>', 'Base URL of the running app')
.option('-u, --url <url...>', 'Base URL(s) of the running app (e.g. http://localhost:3000 or admin=http://localhost:3000)')
.option('-c, --config <path>', 'Path to git-glimpse.config.ts')
.option('-o, --output <dir>', 'Output directory for recordings', './recordings')
.option('--open', 'Open the recording after generation')
.action(async (options) => {
try {
const config = await loadConfig(options.config);
const normalized = normalizeConfig(config);

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

const baseUrl =
options.url ??
config.app.readyWhen?.url?.replace(/\/[^/]*$/, '') ??
'http://localhost:3000';
const entryPoints = parseUrlOptions(options.url, normalized);

console.log(`Running git-glimpse...`);
console.log(` Base URL: ${baseUrl}`);
for (const ep of entryPoints) {
console.log(` Entry point "${ep.name}": ${ep.baseUrl}`);
}
console.log(` Diff: ${diffRef}`);
console.log(` Output: ${options.output}`);

const result = await runPipeline({
diff,
baseUrl,
entryPoints,
outputDir: options.output,
config,
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/analyzer/change-summarizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function summarizeChanges(
function buildSummaryPrompt(diff: string, routes: RouteMapping[]): string {
const routeList =
routes.length > 0
? routes.map((r) => ` - ${r.file} → ${r.route}`).join('\n')
? routes.map((r) => ` - ${r.file} → [${r.entry}] ${r.route}`).join('\n')
: ' (no routes detected automatically)';

return `Analyze this code diff and provide a brief summary of the UI changes for a demo video.
Expand Down
36 changes: 23 additions & 13 deletions packages/core/src/analyzer/route-detector.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { minimatch } from 'minimatch';
import type { ParsedDiff } from './diff-parser.js';
import type { NormalizedRouteMap } from '../config/normalize.js';

export interface RouteMapping {
file: string;
route: string;
entry: string;
changeType: 'added' | 'modified' | 'deleted';
}

export interface RouteDetectionOptions {
routeMap?: Record<string, string>;
baseUrl: string;
routeMap?: NormalizedRouteMap;
defaultEntry: string;
}

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

const changeType = file.changeType === 'added' ? 'added' : 'modified';
const route = resolveRoute(file.path, options);
const resolved = resolveRoute(file.path, options);

if (route && !seen.has(route)) {
seen.add(route);
mappings.push({ file: file.path, route, changeType });
if (resolved) {
const key = `${resolved.entry}:${resolved.route}`;
if (!seen.has(key)) {
seen.add(key);
mappings.push({ file: file.path, route: resolved.route, entry: resolved.entry, changeType });
}
}
}

return mappings;
}

function resolveRoute(filePath: string, options: RouteDetectionOptions): string | null {
interface ResolvedRoute {
entry: string;
route: string;
}

function resolveRoute(filePath: string, options: RouteDetectionOptions): ResolvedRoute | null {
// 1. Explicit routeMap (supports glob patterns)
if (options.routeMap) {
for (const [pattern, route] of Object.entries(options.routeMap)) {
for (const [pattern, mapping] of Object.entries(options.routeMap)) {
if (minimatch(filePath, pattern) || filePath === pattern || filePath.startsWith(pattern)) {
return route;
return { entry: mapping.entry, route: mapping.route };
}
}
}

// 2. Framework convention detection
// 2. Framework convention detection (defaults to the first entry point)
const remixRoute = detectRemixRoute(filePath);
if (remixRoute) return remixRoute;
if (remixRoute) return { entry: options.defaultEntry, route: remixRoute };

const nextRoute = detectNextjsRoute(filePath);
if (nextRoute) return nextRoute;
if (nextRoute) return { entry: options.defaultEntry, route: nextRoute };

const sveltekitRoute = detectSvelteKitRoute(filePath);
if (sveltekitRoute) return sveltekitRoute;
if (sveltekitRoute) return { entry: options.defaultEntry, route: sveltekitRoute };

return null;
}
Expand Down
Loading
Loading