diff --git a/src/cli/commands/analyze.ts b/src/cli/commands/analyze.ts index 8585ad7e..ec478389 100644 --- a/src/cli/commands/analyze.ts +++ b/src/cli/commands/analyze.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { resolve, dirname } from "node:path"; import type { CAC } from "cac"; +import { z } from "zod"; import type { RuleConfig, RuleId } from "../../core/contracts/rule.js"; import { analyzeFile } from "../../core/engine/rule-engine.js"; @@ -10,7 +11,7 @@ import { getFigmaToken, getReportsDir, ensureReportsDir, } from "../../core/engine/config-store.js"; import { calculateScores, formatScoreSummary, buildResultJson } from "../../core/engine/scoring.js"; -import { getConfigsWithPreset, RULE_CONFIGS, type Preset } from "../../core/rules/rule-config.js"; +import { getConfigsWithPreset, RULE_CONFIGS } from "../../core/rules/rule-config.js"; import { ruleRegistry } from "../../core/rules/rule-registry.js"; import { loadCustomRules } from "../../core/rules/custom/custom-rule-loader.js"; import { loadConfigFile, mergeConfigs } from "../../core/rules/custom/config-loader.js"; @@ -18,17 +19,18 @@ import { generateHtmlReport } from "../../core/report-html/index.js"; import { trackEvent, trackError, EVENTS } from "../../core/monitoring/index.js"; import { pickRandomScope, countNodes, MAX_NODES_WITHOUT_SCOPE } from "../helpers.js"; -interface AnalyzeOptions { - preset?: Preset; - output?: string; - token?: string; - api?: boolean; - screenshot?: boolean; - customRules?: string; - config?: string; - noOpen?: boolean; - json?: boolean; -} +const AnalyzeOptionsSchema = z.object({ + preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional(), + output: z.string().optional(), + token: z.string().optional(), + api: z.boolean().optional(), + screenshot: z.boolean().optional(), + customRules: z.string().optional(), + config: z.string().optional(), + noOpen: z.boolean().optional(), + json: z.boolean().optional(), +}); + export function registerAnalyze(cli: CAC): void { cli @@ -47,7 +49,14 @@ export function registerAnalyze(cli: CAC): void { .example(" canicode analyze ./fixtures/my-design --output report.html") .example(" canicode analyze ./fixtures/my-design --custom-rules ./my-rules.json") .example(" canicode analyze ./fixtures/my-design --config ./my-config.json") - .action(async (input: string, options: AnalyzeOptions) => { + .action(async (input: string, rawOptions: Record) => { + const parseResult = AnalyzeOptionsSchema.safeParse(rawOptions); + if (!parseResult.success) { + const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n"); + console.error(`\nInvalid options:\n${msg}`); + process.exit(1); + } + const options = parseResult.data; const analysisStart = Date.now(); trackEvent(EVENTS.ANALYSIS_STARTED, { source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma" }); // In --json mode, send progress messages to stderr so stdout contains only valid JSON diff --git a/src/cli/commands/implement.ts b/src/cli/commands/implement.ts index 3960a25c..995ec700 100644 --- a/src/cli/commands/implement.ts +++ b/src/cli/commands/implement.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { resolve, dirname } from "node:path"; import type { CAC } from "cac"; +import { z } from "zod"; import { parseFigmaUrl } from "../../core/adapters/figma-url-parser.js"; import { analyzeFile } from "../../core/engine/rule-engine.js"; @@ -10,12 +11,13 @@ import { getFigmaToken } from "../../core/engine/config-store.js"; import { calculateScores, buildResultJson } from "../../core/engine/scoring.js"; import { collectVectorNodeIds, collectImageNodes, sanitizeFilename } from "../helpers.js"; -interface ImplementOptions { - token?: string; - output?: string; - prompt?: string; - imageScale?: string; -} +const ImplementOptionsSchema = z.object({ + token: z.string().optional(), + output: z.string().optional(), + prompt: z.string().optional(), + imageScale: z.string().optional(), +}); + export function registerImplement(cli: CAC): void { cli @@ -29,8 +31,30 @@ export function registerImplement(cli: CAC): void { .option("--image-scale ", "Image export scale: 2 for PC (default), 3 for mobile") .example(" canicode implement ./fixtures/my-design") .example(" canicode implement ./fixtures/my-design --prompt ./my-react-prompt.md --image-scale 3") - .action(async (input: string, options: ImplementOptions) => { + .action(async (input: string, rawOptions: Record) => { try { + const parseResult = ImplementOptionsSchema.safeParse(rawOptions); + if (!parseResult.success) { + const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n"); + console.error(`\nInvalid options:\n${msg}`); + process.exit(1); + } + const options = parseResult.data; + + // Validate --image-scale early + if (options.imageScale !== undefined) { + const scale = Number(options.imageScale); + if (!Number.isFinite(scale) || scale < 1 || scale > 4) { + console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)"); + process.exit(1); + } + } + + // Warn for unscoped Figma URL + if (isFigmaUrl(input) && !parseFigmaUrl(input).nodeId) { + console.warn("Warning: No node-id in Figma URL. Implementation package will cover the entire file."); + console.warn("Tip: Add ?node-id=XXX to target a specific section.\n"); + } const outputDir = resolve(options.output ?? "canicode-implement"); mkdirSync(outputDir, { recursive: true }); @@ -87,10 +111,6 @@ export function registerImplement(cli: CAC): void { const figmaToken = options.token ?? getFigmaToken(); if (figmaToken) { const imgScale = options.imageScale !== undefined ? Number(options.imageScale) : 2; - if (!Number.isFinite(imgScale) || imgScale < 1 || imgScale > 4) { - console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)"); - process.exitCode = 1; return; - } const { FigmaClient } = await import("../../core/adapters/figma-client.js"); const client = new FigmaClient({ token: figmaToken }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 76eb17ec..f4393086 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -1,21 +1,33 @@ import type { CAC } from "cac"; +import { z } from "zod"; import { initAiready, getConfigPath, getReportsDir, } from "../../core/engine/config-store.js"; -interface InitOptions { - token?: string; - mcp?: boolean; -} +const InitOptionsSchema = z.object({ + token: z.string().optional(), + mcp: z.boolean().optional(), +}).refine( + (opts) => !(opts.token && opts.mcp), + { message: "--token and --mcp are mutually exclusive. Choose one." } +); export function registerInit(cli: CAC): void { cli .command("init", "Set up canicode (Figma token or MCP)") .option("--token ", "Save Figma API token to ~/.canicode/") .option("--mcp", "Show Figma MCP setup instructions") - .action((options: InitOptions) => { + .action((rawOptions: Record) => { try { + const parseResult = InitOptionsSchema.safeParse(rawOptions); + if (!parseResult.success) { + const msg = parseResult.error.issues.map(i => i.message).join("\n"); + console.error(`\nInvalid options:\n${msg}`); + process.exit(1); + } + const options = parseResult.data; + if (options.token) { initAiready(options.token); diff --git a/src/cli/commands/save-fixture.ts b/src/cli/commands/save-fixture.ts index f8782a59..9ee46d71 100644 --- a/src/cli/commands/save-fixture.ts +++ b/src/cli/commands/save-fixture.ts @@ -2,19 +2,21 @@ import { mkdirSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import type { CAC } from "cac"; +import { z } from "zod"; import { parseFigmaUrl } from "../../core/adapters/figma-url-parser.js"; import { loadFile, isFigmaUrl } from "../../core/engine/loader.js"; import { getFigmaToken } from "../../core/engine/config-store.js"; import { collectVectorNodeIds, collectImageNodes, sanitizeFilename, countNodes } from "../helpers.js"; -interface SaveFixtureOptions { - output?: string; - api?: boolean; - token?: string; - imageScale?: string; - name?: string; -} +const SaveFixtureOptionsSchema = z.object({ + output: z.string().optional(), + api: z.boolean().optional(), + token: z.string().optional(), + imageScale: z.string().optional(), + name: z.string().optional(), +}); + export function registerSaveFixture(cli: CAC): void { cli @@ -28,12 +30,29 @@ export function registerSaveFixture(cli: CAC): void { .option("--image-scale ", "Image export scale: 2 for PC (default), 3 for mobile") .example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234") .example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234 --image-scale 3") - .action(async (input: string, options: SaveFixtureOptions) => { + .action(async (input: string, rawOptions: Record) => { try { + const parseResult = SaveFixtureOptionsSchema.safeParse(rawOptions); + if (!parseResult.success) { + const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n"); + console.error(`\nInvalid options:\n${msg}`); + process.exit(1); + } + const options = parseResult.data; + if (!isFigmaUrl(input)) { throw new Error("save-fixture requires a Figma URL as input."); } + // Validate --image-scale early (before any file I/O) + if (options.imageScale !== undefined) { + const scale = Number(options.imageScale); + if (!Number.isFinite(scale) || scale < 1 || scale > 4) { + console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)"); + process.exit(1); + } + } + if (!parseFigmaUrl(input).nodeId) { console.warn("\nWarning: No node-id specified. Saving entire file as fixture."); console.warn("Tip: Add ?node-id=XXX to save a specific section.\n"); @@ -123,10 +142,6 @@ export function registerSaveFixture(cli: CAC): void { const imageNodes = collectImageNodes(file.document); if (imageNodes.length > 0) { const imgScale = options.imageScale !== undefined ? Number(options.imageScale) : 2; - if (!Number.isFinite(imgScale) || imgScale < 1 || imgScale > 4) { - console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)"); - process.exitCode = 1; return; - } const imageDir = resolve(fixtureDir, "images"); mkdirSync(imageDir, { recursive: true }); diff --git a/src/cli/commands/visual-compare.ts b/src/cli/commands/visual-compare.ts index 279f6822..875bfc5a 100644 --- a/src/cli/commands/visual-compare.ts +++ b/src/cli/commands/visual-compare.ts @@ -1,16 +1,19 @@ import { resolve } from "node:path"; import type { CAC } from "cac"; +import { z } from "zod"; +import { parseFigmaUrl } from "../../core/adapters/figma-url-parser.js"; import { getFigmaToken } from "../../core/engine/config-store.js"; -interface VisualCompareOptions { - figmaUrl: string; - token?: string; - output?: string; - width?: number; - height?: number; - figmaScale?: string; -} +const VisualCompareOptionsSchema = z.object({ + figmaUrl: z.string().optional(), + token: z.string().optional(), + output: z.string().optional(), + width: z.union([z.string(), z.number()]).optional(), + height: z.union([z.string(), z.number()]).optional(), + figmaScale: z.string().optional(), +}); + export function registerVisualCompare(cli: CAC): void { cli @@ -25,13 +28,27 @@ export function registerVisualCompare(cli: CAC): void { .option("--height ", "Logical viewport height in CSS px (default: infer from Figma PNG รท export scale)") .option("--figma-scale ", "Figma export scale (default: 2, matches save-fixture / @2x PNGs)") .example(" canicode visual-compare ./generated/index.html --figma-url 'https://www.figma.com/design/ABC/File?node-id=1-234'") - .action(async (codePath: string, options: VisualCompareOptions) => { + .action(async (codePath: string, rawOptions: Record) => { try { + const parseResult = VisualCompareOptionsSchema.safeParse(rawOptions); + if (!parseResult.success) { + const msg = parseResult.error.issues.map(i => `--${i.path.join(".")}: ${i.message}`).join("\n"); + console.error(`\nInvalid options:\n${msg}`); + process.exit(1); + } + const options = parseResult.data; + if (!options.figmaUrl) { console.error("Error: --figma-url is required"); process.exitCode = 1; return; } + // Warn if --figma-url has no node-id + if (!parseFigmaUrl(options.figmaUrl).nodeId) { + console.warn("Warning: --figma-url has no node-id. Results may be inaccurate for full files."); + console.warn("Tip: Add ?node-id=XXX to target a specific section.\n"); + } + const token = options.token ?? getFigmaToken(); if (!token) { console.error("Error: Figma token required. Use --token or set FIGMA_TOKEN env var.");