Skip to content

Commit 43d5741

Browse files
committed
feat(showcase): add local CLI entry point and config loader
Commander-based CLI with up/down/test/rebuild/ps/logs/status commands. Shorthand flags (--smoke, --d4, --d5) with mutual exclusion against --level. Default test level is smoke. Config loader reads showcase.local.json for PocketBase and port overrides.
1 parent 2a22fc3 commit 43d5741

2 files changed

Lines changed: 201 additions & 0 deletions

File tree

showcase/harness/src/cli.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env node
2+
import { Command, Option, InvalidArgumentError } from "commander";
3+
import { loadConfig } from "./cli/config.js";
4+
import { up, down, rebuild, ps, logs } from "./cli/lifecycle.js";
5+
import { run } from "./cli/runner.js";
6+
import type { TestLevel } from "./cli/targets.js";
7+
8+
const program = new Command();
9+
10+
program
11+
.name("showcase")
12+
.description("Local showcase depth-level (smoke/D4/D5) test infrastructure")
13+
.version("0.1.0");
14+
15+
// ── up ──────────────────────────────────────────────────────────────────
16+
program
17+
.command("up [slugs...]")
18+
.description("Start infrastructure and named packages")
19+
.action(async (slugs: string[]) => {
20+
loadConfig(); // validate config before starting
21+
await up(slugs);
22+
});
23+
24+
// ── down ────────────────────────────────────────────────────────────────
25+
program
26+
.command("down [slugs...]")
27+
.description("Stop services")
28+
.action(async (slugs: string[]) => {
29+
loadConfig();
30+
await down(slugs);
31+
});
32+
33+
// ── test ────────────────────────────────────────────────────────────────
34+
program
35+
.command("test <target>")
36+
.description(
37+
"Run probe tests (target: <slug>, <slug>:<demo>, or all)",
38+
)
39+
.addOption(
40+
new Option("--level <level>", "probe depth (smoke|d4|d5|all)")
41+
.choices(["smoke", "d4", "d5", "all"]),
42+
)
43+
.option("--d5", "shorthand for --level d5")
44+
.option("--d4", "shorthand for --level d4")
45+
.option("--smoke", "shorthand for --level smoke")
46+
.option("--verbose", "Enable verbose logging output")
47+
.option("--headed", "run Playwright in headed mode")
48+
.option("--repeat <n>", "run N times", (val: string) => {
49+
const n = parseInt(val, 10);
50+
if (isNaN(n) || n < 1) {
51+
throw new InvalidArgumentError("must be a positive integer");
52+
}
53+
return n;
54+
})
55+
.option("--keep", "don't stop auto-started packages after test")
56+
.option("--live", "write results to PocketBase for dashboard")
57+
.option("--rebuild", "force Docker rebuild before running")
58+
.action(
59+
async (
60+
target: string,
61+
opts: {
62+
level?: string;
63+
d5?: boolean;
64+
d4?: boolean;
65+
smoke?: boolean;
66+
verbose?: boolean;
67+
headed?: boolean;
68+
repeat?: number;
69+
keep?: boolean;
70+
live?: boolean;
71+
rebuild?: boolean;
72+
},
73+
) => {
74+
const config = loadConfig();
75+
76+
const shorthands = [opts.smoke, opts.d4, opts.d5].filter(Boolean);
77+
if (shorthands.length > 1) {
78+
console.error("Error: specify at most one of --smoke, --d4, --d5");
79+
process.exit(1);
80+
}
81+
82+
const shorthand = opts.smoke ? "smoke" : opts.d4 ? "d4" : opts.d5 ? "d5" : null;
83+
if (shorthand && opts.level) {
84+
console.error("Error: --level and shorthand flags (--smoke, --d4, --d5) are mutually exclusive");
85+
process.exit(1);
86+
}
87+
const level: TestLevel = shorthand ?? (opts.level as TestLevel) ?? "smoke";
88+
89+
const result = await run(target, { ...opts, level }, config);
90+
91+
if (result.failed > 0) {
92+
process.exit(1);
93+
}
94+
},
95+
);
96+
97+
// ── rebuild ─────────────────────────────────────────────────────────────
98+
program
99+
.command("rebuild [slugs...]")
100+
.description("Rebuild Docker images")
101+
.action(async (slugs: string[]) => {
102+
loadConfig();
103+
await rebuild(slugs);
104+
});
105+
106+
// ── ps ──────────────────────────────────────────────────────────────────
107+
program
108+
.command("ps")
109+
.description("Show running services")
110+
.action(async () => {
111+
loadConfig();
112+
const output = await ps();
113+
console.log(output);
114+
});
115+
116+
// ── logs ────────────────────────────────────────────────────────────────
117+
program
118+
.command("logs <slug>")
119+
.description("Tail logs for a service")
120+
.action(async (slug: string) => {
121+
loadConfig();
122+
await logs(slug);
123+
});
124+
125+
// ── status ──────────────────────────────────────────────────────────────
126+
program
127+
.command("status")
128+
.description("Show last test results summary")
129+
.action(async () => {
130+
const config = loadConfig();
131+
console.log(
132+
`Visit the showcase dashboard at ${config.dashboardUrl} for test results and status.`,
133+
);
134+
});
135+
136+
// ── error handling & entry point ────────────────────────────────────────
137+
process.on("unhandledRejection", (err) => {
138+
console.error(
139+
`\x1b[31m[showcase] Unhandled error:\x1b[0m ${err instanceof Error ? err.message : String(err)}`,
140+
);
141+
process.exit(1);
142+
});
143+
144+
program.parseAsync().catch((err: unknown) => {
145+
console.error(
146+
`\x1b[31m[showcase] Error:\x1b[0m ${err instanceof Error ? err.message : String(err)}`,
147+
);
148+
process.exit(1);
149+
});

showcase/harness/src/cli/config.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import path from "node:path";
2+
import fs from "node:fs";
3+
import { fileURLToPath } from "node:url";
4+
5+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
6+
const SHOWCASE_DIR = path.resolve(__dirname, "../../..");
7+
8+
export interface LocalConfig {
9+
showcaseDir: string;
10+
composeFile: string;
11+
localPorts: Record<string, number>;
12+
pocketbase: {
13+
url: string;
14+
email: string;
15+
password: string;
16+
};
17+
aimockUrl: string;
18+
dashboardUrl: string;
19+
dashboardPort: number;
20+
}
21+
22+
export function loadConfig(): LocalConfig {
23+
const portsFile = path.join(SHOWCASE_DIR, "shared/local-ports.json");
24+
const localPorts = JSON.parse(fs.readFileSync(portsFile, "utf-8")) as Record<
25+
string,
26+
number
27+
>;
28+
29+
return {
30+
showcaseDir: SHOWCASE_DIR,
31+
composeFile: path.join(SHOWCASE_DIR, "docker-compose.local.yml"),
32+
localPorts,
33+
pocketbase: {
34+
url: "http://localhost:8090",
35+
email: "admin@localhost",
36+
password: "showcase-local-dev",
37+
},
38+
aimockUrl: "http://localhost:4010",
39+
dashboardUrl: "http://localhost:3200",
40+
dashboardPort: 3200,
41+
};
42+
}
43+
44+
export function getPackageUrl(slug: string, config: LocalConfig): string {
45+
const port = config.localPorts[slug];
46+
if (!port) {
47+
throw new Error(
48+
`No local port mapping for slug "${slug}". Check shared/local-ports.json.`,
49+
);
50+
}
51+
return `http://localhost:${port}`;
52+
}

0 commit comments

Comments
 (0)