From a5c326a127a5011156dd3de826d4dd5ddd831909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A2=85=EA=B2=BD?= Date: Tue, 26 May 2026 15:23:30 +0900 Subject: [PATCH 1/2] feat(cli): add opt-in Copilot setup workflow --- .../new-create-vite/snap.txt | 37 +++++ .../new-create-vite/steps.json | 16 ++ packages/cli/src/create/bin.ts | 72 +++++++-- .../cli/src/utils/__tests__/agent.spec.ts | 95 ++++++++++++ packages/cli/src/utils/agent.ts | 141 +++++++++++++++--- 5 files changed, 328 insertions(+), 33 deletions(-) diff --git a/packages/cli/snap-tests-global/new-create-vite/snap.txt b/packages/cli/snap-tests-global/new-create-vite/snap.txt index b79161806a..0e28f86509 100644 --- a/packages/cli/snap-tests-global/new-create-vite/snap.txt +++ b/packages/cli/snap-tests-global/new-create-vite/snap.txt @@ -2,6 +2,43 @@ > ls vite-plus-application/package.json # check package.json vite-plus-application/package.json +> test ! -f vite-plus-application/.github/workflows/copilot-setup-steps.yml # default create should not add Copilot setup workflow +> vp create vite:application --no-interactive --directory claude-app --agent claude # create vite app with non-Copilot agent +> test ! -f claude-app/.github/workflows/copilot-setup-steps.yml # non-Copilot agent should not add Copilot setup workflow +> vp create vite:application --no-interactive --directory no-agent-app --no-agent # create vite app without agent setup +> test ! -f no-agent-app/.github/workflows/copilot-setup-steps.yml # --no-agent should not add Copilot setup workflow +> vp create vite:application --no-interactive --directory copilot-app --agent copilot # create vite app with Copilot agent setup +> cat copilot-app/.github/workflows/copilot-setup-steps.yml # check Copilot setup workflow +name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version: "24" + cache: true + run-install: true + - name: Verify Vite+ + run: vp --version + > vp create vite:application --no-interactive --directory my-react-ts -- --template react-ts # create vite app with react-ts template > ls my-react-ts/package.json # check package.json my-react-ts/package.json diff --git a/packages/cli/snap-tests-global/new-create-vite/steps.json b/packages/cli/snap-tests-global/new-create-vite/steps.json index a3a241c268..ddce6505dd 100644 --- a/packages/cli/snap-tests-global/new-create-vite/steps.json +++ b/packages/cli/snap-tests-global/new-create-vite/steps.json @@ -5,6 +5,22 @@ "ignoreOutput": true }, "ls vite-plus-application/package.json # check package.json", + "test ! -f vite-plus-application/.github/workflows/copilot-setup-steps.yml # default create should not add Copilot setup workflow", + { + "command": "vp create vite:application --no-interactive --directory claude-app --agent claude # create vite app with non-Copilot agent", + "ignoreOutput": true + }, + "test ! -f claude-app/.github/workflows/copilot-setup-steps.yml # non-Copilot agent should not add Copilot setup workflow", + { + "command": "vp create vite:application --no-interactive --directory no-agent-app --no-agent # create vite app without agent setup", + "ignoreOutput": true + }, + "test ! -f no-agent-app/.github/workflows/copilot-setup-steps.yml # --no-agent should not add Copilot setup workflow", + { + "command": "vp create vite:application --no-interactive --directory copilot-app --agent copilot # create vite app with Copilot agent setup", + "ignoreOutput": true + }, + "cat copilot-app/.github/workflows/copilot-setup-steps.yml # check Copilot setup workflow", { "command": "vp create vite:application --no-interactive --directory my-react-ts -- --template react-ts # create vite app with react-ts template", "ignoreOutput": true diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index d9cb4d274b..069a0b4478 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -24,9 +24,11 @@ import { } from '../migration/migrator.ts'; import { DependencyType, PackageManager, type WorkspaceInfo } from '../types/index.ts'; import { + COPILOT_AGENT_ID, detectExistingAgentTargetPaths, - selectAgentTargetPaths, + selectAgentTargets, writeAgentInstructions, + writeCopilotSetupWorkflow, } from '../utils/agent.ts'; import { detectExistingEditors, selectEditors, writeEditorConfigs } from '../utils/editor.ts'; import { createInitialCommit, initGitRepository } from '../utils/git.ts'; @@ -220,6 +222,18 @@ export interface Options { packageManager?: string; } +type ParsedAgentOption = string | false | Array; + +function normalizeAgentOption(agent: ParsedAgentOption | undefined): Options['agent'] { + if (!Array.isArray(agent)) { + return agent; + } + if (agent.includes(false)) { + return false; + } + return agent.filter((value): value is string => typeof value === 'string'); +} + // Parse CLI arguments: split on '--' separator function parseArgs() { const args = process.argv.slice(3); // Skip 'node', 'vite' @@ -237,7 +251,7 @@ function parseArgs() { list?: boolean; help?: boolean; verbose?: boolean; - agent?: string | string[] | false; + agent?: ParsedAgentOption; editor?: string; git?: boolean; hooks?: boolean; @@ -259,7 +273,7 @@ function parseArgs() { list: parsed.list || false, help: parsed.help || false, verbose: parsed.verbose || false, - agent: parsed.agent, + agent: normalizeAgentOption(parsed.agent), editor: parsed.editor, git: parsed.git, hooks: parsed.hooks, @@ -350,6 +364,27 @@ function getNextCommand(projectDir: string, command: string) { return `cd ${projectDir} && ${command}`; } +function findGitRoot(startPath: string) { + let dir = startPath; + while (true) { + if (fs.existsSync(path.join(dir, '.git'))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + +function getCopilotSetupRoot(projectRoot: string, isExistingMonorepo: boolean) { + if (!isExistingMonorepo) { + return projectRoot; + } + return findGitRoot(projectRoot) ?? projectRoot; +} + function showCreateSummary(options: { description?: string; installSummary?: CommandRunSummary; @@ -448,6 +483,7 @@ async function main() { let selectedTemplateName = templateName as string; let selectedTemplateArgs = [...templateArgs]; let selectedAgentTargetPaths: string[] | undefined; + let shouldWriteCopilotSetupWorkflow = false; let selectedEditors: Awaited>; let selectedParentDir: string | undefined; let remoteTargetDir: string | undefined; @@ -732,14 +768,19 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h options.agent !== undefined || !options.interactive ? undefined : detectExistingAgentTargetPaths(workspaceInfoOptional.rootDir); - selectedAgentTargetPaths = - existingAgentTargetPaths !== undefined - ? existingAgentTargetPaths - : await selectAgentTargetPaths({ - interactive: options.interactive, - agent: options.agent, - onCancel: () => cancelAndExit(), - }); + if (existingAgentTargetPaths !== undefined) { + selectedAgentTargetPaths = existingAgentTargetPaths; + } else { + const agentSelection = await selectAgentTargets({ + interactive: options.interactive, + agent: options.agent, + onCancel: () => cancelAndExit(), + }); + selectedAgentTargetPaths = agentSelection.targetPaths; + shouldWriteCopilotSetupWorkflow = agentSelection.selectedAgents.some( + (agent) => agent.id === COPILOT_AGENT_ID, + ); + } const existingEditors = options.editor || !options.interactive @@ -895,6 +936,9 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h interactive: options.interactive, silent: compactOutput, }); + if (shouldWriteCopilotSetupWorkflow) { + await writeCopilotSetupWorkflow({ projectRoot: fullPath, silent: compactOutput }); + } resumeCreateProgress(); updateCreateProgress('Writing editor configs'); pauseCreateProgress(); @@ -1017,6 +1061,12 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h interactive: options.interactive, silent: compactOutput, }); + if (shouldWriteCopilotSetupWorkflow) { + await writeCopilotSetupWorkflow({ + projectRoot: getCopilotSetupRoot(agentInstructionsRoot, isMonorepo), + silent: compactOutput, + }); + } resumeCreateProgress(); updateCreateProgress('Writing editor configs'); pauseCreateProgress(); diff --git a/packages/cli/src/utils/__tests__/agent.spec.ts b/packages/cli/src/utils/__tests__/agent.spec.ts index 51b0ee20b8..784b1736a8 100644 --- a/packages/cli/src/utils/__tests__/agent.spec.ts +++ b/packages/cli/src/utils/__tests__/agent.spec.ts @@ -6,13 +6,17 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + COPILOT_SETUP_WORKFLOW_PATH, detectExistingAgentTargetPaths, detectExistingAgentTargetPath, hasExistingAgentInstructions, replaceMarkedAgentInstructionsSection, + resolveAgentOptions, resolveAgentTargetPaths, selectAgentTargetPaths, + selectAgentTargets, writeAgentInstructions, + writeCopilotSetupWorkflow, } from '../agent.js'; import { pkgRoot } from '../path.js'; @@ -279,6 +283,65 @@ describe('resolveAgentTargetPaths', () => { }); }); +describe('resolveAgentOptions', () => { + it('resolves explicit selections to supported agent options', () => { + expect(resolveAgentOptions(['agents', 'copilot']).map((agent) => agent.id)).toEqual([ + 'agents', + 'copilot', + ]); + expect(resolveAgentOptions('github-copilot').map((agent) => agent.id)).toEqual(['copilot']); + expect(resolveAgentOptions('.github/copilot-instructions.md').map((agent) => agent.id)).toEqual( + ['copilot'], + ); + }); + + it('falls back to AGENTS.md for default or unknown selections', () => { + expect(resolveAgentOptions().map((agent) => agent.id)).toEqual(['agents']); + expect(resolveAgentOptions('unknown-agent').map((agent) => agent.id)).toEqual(['agents']); + }); +}); + +describe('selectAgentTargets', () => { + it('returns selected agent options from CLI input', async () => { + await expect( + selectAgentTargets({ + interactive: false, + agent: ['agents', 'copilot'], + onCancel: vi.fn(), + }), + ).resolves.toMatchObject({ + targetPaths: ['AGENTS.md', '.github/copilot-instructions.md'], + selectedAgents: [{ id: 'agents' }, { id: 'copilot' }], + }); + }); + + it('does not treat defaults as explicit Copilot selection', async () => { + await expect( + selectAgentTargets({ + interactive: false, + onCancel: vi.fn(), + }), + ).resolves.toMatchObject({ + targetPaths: ['AGENTS.md'], + selectedAgents: [{ id: 'agents' }], + }); + }); + + it('returns selected agent options from interactive selections', async () => { + vi.spyOn(prompts, 'multiselect').mockResolvedValue(['agents', 'copilot']); + + await expect( + selectAgentTargets({ + interactive: true, + onCancel: vi.fn(), + }), + ).resolves.toMatchObject({ + targetPaths: ['AGENTS.md', '.github/copilot-instructions.md'], + selectedAgents: [{ id: 'agents' }, { id: 'copilot' }], + }); + }); +}); + describe('selectAgentTargetPaths', () => { it('prompts with file-based targets and agent hints', async () => { const multiselectSpy = vi.spyOn(prompts, 'multiselect').mockResolvedValue(['agents', 'claude']); @@ -462,6 +525,38 @@ describe('writeAgentInstructions symlink behavior', () => { }); }); +describe('writeCopilotSetupWorkflow', () => { + it('writes the Copilot setup workflow without overwriting existing files', async () => { + const dir = await createProjectDir(); + + await writeCopilotSetupWorkflow({ projectRoot: dir }); + + const workflowPath = path.join(dir, COPILOT_SETUP_WORKFLOW_PATH); + const content = await mockFs.readText(workflowPath); + expect(content).toContain('copilot-setup-steps:'); + expect(content).toContain('runs-on: ubuntu-latest'); + expect(content).toContain('persist-credentials: false'); + expect(content).toContain('uses: actions/checkout@v6'); + expect(content).toContain('uses: voidzero-dev/setup-vp@v1'); + expect(content).toContain('run-install: true'); + expect(content).toContain('- .github/workflows/copilot-setup-steps.yml'); + + await mockFs.writeFile(workflowPath, 'custom workflow'); + await writeCopilotSetupWorkflow({ projectRoot: dir }); + + expect(await mockFs.readText(workflowPath)).toBe('custom workflow'); + }); + + it('suppresses logs in silent mode', async () => { + const dir = await createProjectDir(); + const successSpy = vi.spyOn(prompts.log, 'success'); + + await writeCopilotSetupWorkflow({ projectRoot: dir, silent: true }); + + expect(successSpy).not.toHaveBeenCalled(); + }); +}); + describe('hasExistingAgentInstructions', () => { it('returns true when an agent file has start marker', async () => { const dir = await createProjectDir(); diff --git a/packages/cli/src/utils/agent.ts b/packages/cli/src/utils/agent.ts index f823cfa5c1..f43bf1ac81 100644 --- a/packages/cli/src/utils/agent.ts +++ b/packages/cli/src/utils/agent.ts @@ -66,9 +66,14 @@ export const AGENTS = [ }, ] as const; +export type AgentOption = (typeof AGENTS)[number]; +export type AgentId = AgentOption['id']; + type AgentSelection = string | string[] | false; -const AGENT_DEFAULT_ID = 'agents'; +const AGENT_DEFAULT_ID = 'agents' satisfies AgentId; const AGENT_STANDARD_PATH = 'AGENTS.md'; +export const COPILOT_AGENT_ID = 'copilot' satisfies AgentId; +export const COPILOT_SETUP_WORKFLOW_PATH = '.github/workflows/copilot-setup-steps.yml'; const AGENT_INSTRUCTIONS_START_MARKER = ''; const AGENT_INSTRUCTIONS_END_MARKER = ''; @@ -78,7 +83,38 @@ const AGENT_ALIASES = Object.fromEntries( ), ) as Record; -export async function selectAgentTargetPaths({ +const COPILOT_SETUP_WORKFLOW_CONTENT = `name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version: "24" + cache: true + run-install: true + - name: Verify Vite+ + run: vp --version +`; + +export async function selectAgentTargets({ interactive, agent, onCancel, @@ -89,11 +125,11 @@ export async function selectAgentTargetPaths({ }) { // Skip entirely if --no-agent is passed if (agent === false) { - return undefined; + return { targetPaths: undefined, selectedAgents: [] }; } if (interactive && !agent) { - const selectedAgents = await prompts.multiselect({ + const selectedAgentIds = await prompts.multiselect({ message: 'Which coding agent instruction files should Vite+ create?', options: AGENTS.map((option) => ({ label: option.label, @@ -104,18 +140,39 @@ export async function selectAgentTargetPaths({ required: false, }); - if (prompts.isCancel(selectedAgents)) { + if (prompts.isCancel(selectedAgentIds)) { onCancel(); - return undefined; + return { targetPaths: undefined, selectedAgents: [] }; } - if (selectedAgents.length === 0) { - return undefined; + if (selectedAgentIds.length === 0) { + return { targetPaths: undefined, selectedAgents: [] }; } - return resolveAgentTargetPaths(selectedAgents); + const selectedAgents = resolveAgentOptions(selectedAgentIds); + return { + targetPaths: getAgentTargetPaths(selectedAgents), + selectedAgents, + }; } - return resolveAgentTargetPaths(agent ?? AGENT_DEFAULT_ID); + const selectedAgents = resolveAgentOptions(agent ?? AGENT_DEFAULT_ID); + return { + targetPaths: getAgentTargetPaths(selectedAgents), + selectedAgents, + }; +} + +export async function selectAgentTargetPaths({ + interactive, + agent, + onCancel, +}: { + interactive: boolean; + agent?: AgentSelection; + onCancel: () => void; +}) { + const selection = await selectAgentTargets({ interactive, agent, onCancel }); + return selection.targetPaths; } export async function selectAgentTargetPath({ @@ -200,23 +257,40 @@ export function updateExistingAgentInstructions(projectRoot: string): void { } export function resolveAgentTargetPaths(agent?: string | string[]) { + return getAgentTargetPaths(resolveAgentOptions(agent)); +} + +export function resolveAgentTargetPath(agent?: string) { + return resolveAgentTargetPaths(agent)[0] ?? 'AGENTS.md'; +} + +export function resolveAgentOptions(agent?: string | string[]) { const agentNames = parseAgentNames(agent); - const resolvedAgentNames = agentNames.length > 0 ? agentNames : ['other']; - const dedupedTargetPaths: string[] = []; - const seenTargetPaths = new Set(); + const resolvedAgentNames = agentNames.length > 0 ? agentNames : [AGENT_DEFAULT_ID]; + const dedupedAgents: AgentOption[] = []; + const seenAgentIds = new Set(); for (const name of resolvedAgentNames) { - const targetPath = resolveSingleAgentTargetPath(name); - if (seenTargetPaths.has(targetPath)) { + const option = resolveSingleAgentOption(name); + if (seenAgentIds.has(option.id)) { continue; } - seenTargetPaths.add(targetPath); - dedupedTargetPaths.push(targetPath); + seenAgentIds.add(option.id); + dedupedAgents.push(option); } - return dedupedTargetPaths; + return dedupedAgents; } -export function resolveAgentTargetPath(agent?: string) { - return resolveAgentTargetPaths(agent)[0] ?? 'AGENTS.md'; +function getAgentTargetPaths(agents: AgentOption[]) { + const dedupedTargetPaths: string[] = []; + const seenTargetPaths = new Set(); + for (const agent of agents) { + if (seenTargetPaths.has(agent.targetPath)) { + continue; + } + seenTargetPaths.add(agent.targetPath); + dedupedTargetPaths.push(agent.targetPath); + } + return dedupedTargetPaths; } function parseAgentNames(agent?: string | string[]) { @@ -231,7 +305,7 @@ function parseAgentNames(agent?: string | string[]) { .filter((value) => value.length > 0); } -function resolveSingleAgentTargetPath(agent: string) { +function resolveSingleAgentOption(agent: string): AgentOption { const normalized = normalizeAgentName(agent); const alias = AGENT_ALIASES[normalized]; const resolved = alias ? normalizeAgentName(alias) : normalized; @@ -242,7 +316,7 @@ function resolveSingleAgentTargetPath(agent: string) { normalizeAgentName(option.targetPath) === resolved || option.aliases?.some((candidate) => normalizeAgentName(candidate) === resolved), ); - return match?.targetPath ?? AGENT_STANDARD_PATH; + return match ?? AGENTS.find((option) => option.id === AGENT_DEFAULT_ID)!; } export interface AgentConflictInfo { @@ -466,6 +540,29 @@ export async function writeAgentInstructions({ } } +export async function writeCopilotSetupWorkflow({ + projectRoot, + silent = false, +}: { + projectRoot: string; + silent?: boolean; +}) { + const destinationPath = path.join(projectRoot, COPILOT_SETUP_WORKFLOW_PATH); + await fsPromises.mkdir(path.dirname(destinationPath), { recursive: true }); + + if (fs.existsSync(destinationPath)) { + if (!silent) { + prompts.log.info(`Skipped writing ${COPILOT_SETUP_WORKFLOW_PATH} (already exists)`); + } + return; + } + + await fsPromises.writeFile(destinationPath, COPILOT_SETUP_WORKFLOW_CONTENT); + if (!silent) { + prompts.log.success(`Wrote Copilot setup workflow to ${COPILOT_SETUP_WORKFLOW_PATH}`); + } +} + async function appendAgentContent( destinationPath: string, targetPath: string, From cda3b95c41ab58b32660b6852f3f1ed5dba65bc2 Mon Sep 17 00:00:00 2001 From: JongKyung Lee Date: Tue, 26 May 2026 19:07:51 +0900 Subject: [PATCH 2/2] fix(cli): defer Copilot setup Node version to project --- packages/cli/snap-tests-global/new-create-vite/snap.txt | 1 - packages/cli/src/utils/agent.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/cli/snap-tests-global/new-create-vite/snap.txt b/packages/cli/snap-tests-global/new-create-vite/snap.txt index 0e28f86509..6c317c8d43 100644 --- a/packages/cli/snap-tests-global/new-create-vite/snap.txt +++ b/packages/cli/snap-tests-global/new-create-vite/snap.txt @@ -33,7 +33,6 @@ jobs: - name: Set up Vite+ uses: voidzero-dev/setup-vp@v1 with: - node-version: "24" cache: true run-install: true - name: Verify Vite+ diff --git a/packages/cli/src/utils/agent.ts b/packages/cli/src/utils/agent.ts index f43bf1ac81..155abab4d0 100644 --- a/packages/cli/src/utils/agent.ts +++ b/packages/cli/src/utils/agent.ts @@ -107,7 +107,6 @@ jobs: - name: Set up Vite+ uses: voidzero-dev/setup-vp@v1 with: - node-version: "24" cache: true run-install: true - name: Verify Vite+