Skip to content

Commit fbd90b1

Browse files
jong-kyungfengmk2
andauthored
feat(cli): add opt-in Copilot setup workflow for create (#1683)
## Summary Adds opt-in GitHub Copilot Coding Agent setup during vp create. When users explicitly select Copilot via --agent copilot, generated projects now include `.github/workflows/copilot-setup-steps.yml` so Copilot can set up Vite+ and run vp directly. ## Changes - Add Copilot setup workflow generation for explicit Copilot agent selection - Keep default scaffolds clean - no workflow for default vp create - no workflow for --no-agent - no workflow for non-Copilot agents - Preserve existing workflow files without overwriting them - Refactor agent selection metadata to return selected supported agent definitions - Add snap coverage for default, non-Copilot, no-agent, and Copilot create flows closes #1336 --------- Co-authored-by: MK (fengmk2) <fengmk2@gmail.com>
1 parent bd10024 commit fbd90b1

5 files changed

Lines changed: 326 additions & 33 deletions

File tree

packages/cli/snap-tests-global/new-create-vite/snap.txt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,42 @@
22
> ls vite-plus-application/package.json # check package.json
33
vite-plus-application/package.json
44

5+
> test ! -f vite-plus-application/.github/workflows/copilot-setup-steps.yml # default create should not add Copilot setup workflow
6+
> vp create vite:application --no-interactive --directory claude-app --agent claude # create vite app with non-Copilot agent
7+
> test ! -f claude-app/.github/workflows/copilot-setup-steps.yml # non-Copilot agent should not add Copilot setup workflow
8+
> vp create vite:application --no-interactive --directory no-agent-app --no-agent # create vite app without agent setup
9+
> test ! -f no-agent-app/.github/workflows/copilot-setup-steps.yml # --no-agent should not add Copilot setup workflow
10+
> vp create vite:application --no-interactive --directory copilot-app --agent copilot # create vite app with Copilot agent setup
11+
> cat copilot-app/.github/workflows/copilot-setup-steps.yml # check Copilot setup workflow
12+
name: "Copilot Setup Steps"
13+
14+
on:
15+
workflow_dispatch:
16+
push:
17+
paths:
18+
- .github/workflows/copilot-setup-steps.yml
19+
pull_request:
20+
paths:
21+
- .github/workflows/copilot-setup-steps.yml
22+
23+
jobs:
24+
copilot-setup-steps:
25+
runs-on: ubuntu-latest
26+
permissions:
27+
contents: read
28+
steps:
29+
- name: Checkout code
30+
uses: actions/checkout@v6
31+
with:
32+
persist-credentials: false
33+
- name: Set up Vite+
34+
uses: voidzero-dev/setup-vp@v1
35+
with:
36+
cache: true
37+
run-install: true
38+
- name: Verify Vite+
39+
run: vp --version
40+
541
> vp create vite:application --no-interactive --directory my-react-ts -- --template react-ts # create vite app with react-ts template
642
> ls my-react-ts/package.json # check package.json
743
my-react-ts/package.json

packages/cli/snap-tests-global/new-create-vite/steps.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@
55
"ignoreOutput": true
66
},
77
"ls vite-plus-application/package.json # check package.json",
8+
"test ! -f vite-plus-application/.github/workflows/copilot-setup-steps.yml # default create should not add Copilot setup workflow",
9+
{
10+
"command": "vp create vite:application --no-interactive --directory claude-app --agent claude # create vite app with non-Copilot agent",
11+
"ignoreOutput": true
12+
},
13+
"test ! -f claude-app/.github/workflows/copilot-setup-steps.yml # non-Copilot agent should not add Copilot setup workflow",
14+
{
15+
"command": "vp create vite:application --no-interactive --directory no-agent-app --no-agent # create vite app without agent setup",
16+
"ignoreOutput": true
17+
},
18+
"test ! -f no-agent-app/.github/workflows/copilot-setup-steps.yml # --no-agent should not add Copilot setup workflow",
19+
{
20+
"command": "vp create vite:application --no-interactive --directory copilot-app --agent copilot # create vite app with Copilot agent setup",
21+
"ignoreOutput": true
22+
},
23+
"cat copilot-app/.github/workflows/copilot-setup-steps.yml # check Copilot setup workflow",
824
{
925
"command": "vp create vite:application --no-interactive --directory my-react-ts -- --template react-ts # create vite app with react-ts template",
1026
"ignoreOutput": true

packages/cli/src/create/bin.ts

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ import {
2424
} from '../migration/migrator.ts';
2525
import { DependencyType, PackageManager, type WorkspaceInfo } from '../types/index.ts';
2626
import {
27+
COPILOT_AGENT_ID,
2728
detectExistingAgentTargetPaths,
28-
selectAgentTargetPaths,
29+
selectAgentTargets,
2930
writeAgentInstructions,
31+
writeCopilotSetupWorkflow,
3032
} from '../utils/agent.ts';
3133
import { detectExistingEditors, selectEditors, writeEditorConfigs } from '../utils/editor.ts';
3234
import { createInitialCommit, initGitRepository } from '../utils/git.ts';
@@ -220,6 +222,18 @@ export interface Options {
220222
packageManager?: string;
221223
}
222224

225+
type ParsedAgentOption = string | false | Array<string | false>;
226+
227+
function normalizeAgentOption(agent: ParsedAgentOption | undefined): Options['agent'] {
228+
if (!Array.isArray(agent)) {
229+
return agent;
230+
}
231+
if (agent.includes(false)) {
232+
return false;
233+
}
234+
return agent.filter((value): value is string => typeof value === 'string');
235+
}
236+
223237
// Parse CLI arguments: split on '--' separator
224238
function parseArgs() {
225239
const args = process.argv.slice(3); // Skip 'node', 'vite'
@@ -237,7 +251,7 @@ function parseArgs() {
237251
list?: boolean;
238252
help?: boolean;
239253
verbose?: boolean;
240-
agent?: string | string[] | false;
254+
agent?: ParsedAgentOption;
241255
editor?: string;
242256
git?: boolean;
243257
hooks?: boolean;
@@ -259,7 +273,7 @@ function parseArgs() {
259273
list: parsed.list || false,
260274
help: parsed.help || false,
261275
verbose: parsed.verbose || false,
262-
agent: parsed.agent,
276+
agent: normalizeAgentOption(parsed.agent),
263277
editor: parsed.editor,
264278
git: parsed.git,
265279
hooks: parsed.hooks,
@@ -350,6 +364,27 @@ function getNextCommand(projectDir: string, command: string) {
350364
return `cd ${projectDir} && ${command}`;
351365
}
352366

367+
function findGitRoot(startPath: string) {
368+
let dir = startPath;
369+
while (true) {
370+
if (fs.existsSync(path.join(dir, '.git'))) {
371+
return dir;
372+
}
373+
const parent = path.dirname(dir);
374+
if (parent === dir) {
375+
return undefined;
376+
}
377+
dir = parent;
378+
}
379+
}
380+
381+
function getCopilotSetupRoot(projectRoot: string, isExistingMonorepo: boolean) {
382+
if (!isExistingMonorepo) {
383+
return projectRoot;
384+
}
385+
return findGitRoot(projectRoot) ?? projectRoot;
386+
}
387+
353388
function showCreateSummary(options: {
354389
description?: string;
355390
installSummary?: CommandRunSummary;
@@ -448,6 +483,7 @@ async function main() {
448483
let selectedTemplateName = templateName as string;
449484
let selectedTemplateArgs = [...templateArgs];
450485
let selectedAgentTargetPaths: string[] | undefined;
486+
let shouldWriteCopilotSetupWorkflow = false;
451487
let selectedEditors: Awaited<ReturnType<typeof selectEditors>>;
452488
let selectedParentDir: string | undefined;
453489
let remoteTargetDir: string | undefined;
@@ -732,14 +768,19 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
732768
options.agent !== undefined || !options.interactive
733769
? undefined
734770
: detectExistingAgentTargetPaths(workspaceInfoOptional.rootDir);
735-
selectedAgentTargetPaths =
736-
existingAgentTargetPaths !== undefined
737-
? existingAgentTargetPaths
738-
: await selectAgentTargetPaths({
739-
interactive: options.interactive,
740-
agent: options.agent,
741-
onCancel: () => cancelAndExit(),
742-
});
771+
if (existingAgentTargetPaths !== undefined) {
772+
selectedAgentTargetPaths = existingAgentTargetPaths;
773+
} else {
774+
const agentSelection = await selectAgentTargets({
775+
interactive: options.interactive,
776+
agent: options.agent,
777+
onCancel: () => cancelAndExit(),
778+
});
779+
selectedAgentTargetPaths = agentSelection.targetPaths;
780+
shouldWriteCopilotSetupWorkflow = agentSelection.selectedAgents.some(
781+
(agent) => agent.id === COPILOT_AGENT_ID,
782+
);
783+
}
743784

744785
const existingEditors =
745786
options.editor || !options.interactive
@@ -895,6 +936,9 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
895936
interactive: options.interactive,
896937
silent: compactOutput,
897938
});
939+
if (shouldWriteCopilotSetupWorkflow) {
940+
await writeCopilotSetupWorkflow({ projectRoot: fullPath, silent: compactOutput });
941+
}
898942
resumeCreateProgress();
899943
updateCreateProgress('Writing editor configs');
900944
pauseCreateProgress();
@@ -1017,6 +1061,12 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
10171061
interactive: options.interactive,
10181062
silent: compactOutput,
10191063
});
1064+
if (shouldWriteCopilotSetupWorkflow) {
1065+
await writeCopilotSetupWorkflow({
1066+
projectRoot: getCopilotSetupRoot(agentInstructionsRoot, isMonorepo),
1067+
silent: compactOutput,
1068+
});
1069+
}
10201070
resumeCreateProgress();
10211071
updateCreateProgress('Writing editor configs');
10221072
pauseCreateProgress();

packages/cli/src/utils/__tests__/agent.spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts';
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
77

88
import {
9+
COPILOT_SETUP_WORKFLOW_PATH,
910
detectExistingAgentTargetPaths,
1011
detectExistingAgentTargetPath,
1112
hasExistingAgentInstructions,
1213
replaceMarkedAgentInstructionsSection,
14+
resolveAgentOptions,
1315
resolveAgentTargetPaths,
1416
selectAgentTargetPaths,
17+
selectAgentTargets,
1518
writeAgentInstructions,
19+
writeCopilotSetupWorkflow,
1620
} from '../agent.js';
1721
import { pkgRoot } from '../path.js';
1822

@@ -279,6 +283,65 @@ describe('resolveAgentTargetPaths', () => {
279283
});
280284
});
281285

286+
describe('resolveAgentOptions', () => {
287+
it('resolves explicit selections to supported agent options', () => {
288+
expect(resolveAgentOptions(['agents', 'copilot']).map((agent) => agent.id)).toEqual([
289+
'agents',
290+
'copilot',
291+
]);
292+
expect(resolveAgentOptions('github-copilot').map((agent) => agent.id)).toEqual(['copilot']);
293+
expect(resolveAgentOptions('.github/copilot-instructions.md').map((agent) => agent.id)).toEqual(
294+
['copilot'],
295+
);
296+
});
297+
298+
it('falls back to AGENTS.md for default or unknown selections', () => {
299+
expect(resolveAgentOptions().map((agent) => agent.id)).toEqual(['agents']);
300+
expect(resolveAgentOptions('unknown-agent').map((agent) => agent.id)).toEqual(['agents']);
301+
});
302+
});
303+
304+
describe('selectAgentTargets', () => {
305+
it('returns selected agent options from CLI input', async () => {
306+
await expect(
307+
selectAgentTargets({
308+
interactive: false,
309+
agent: ['agents', 'copilot'],
310+
onCancel: vi.fn(),
311+
}),
312+
).resolves.toMatchObject({
313+
targetPaths: ['AGENTS.md', '.github/copilot-instructions.md'],
314+
selectedAgents: [{ id: 'agents' }, { id: 'copilot' }],
315+
});
316+
});
317+
318+
it('does not treat defaults as explicit Copilot selection', async () => {
319+
await expect(
320+
selectAgentTargets({
321+
interactive: false,
322+
onCancel: vi.fn(),
323+
}),
324+
).resolves.toMatchObject({
325+
targetPaths: ['AGENTS.md'],
326+
selectedAgents: [{ id: 'agents' }],
327+
});
328+
});
329+
330+
it('returns selected agent options from interactive selections', async () => {
331+
vi.spyOn(prompts, 'multiselect').mockResolvedValue(['agents', 'copilot']);
332+
333+
await expect(
334+
selectAgentTargets({
335+
interactive: true,
336+
onCancel: vi.fn(),
337+
}),
338+
).resolves.toMatchObject({
339+
targetPaths: ['AGENTS.md', '.github/copilot-instructions.md'],
340+
selectedAgents: [{ id: 'agents' }, { id: 'copilot' }],
341+
});
342+
});
343+
});
344+
282345
describe('selectAgentTargetPaths', () => {
283346
it('prompts with file-based targets and agent hints', async () => {
284347
const multiselectSpy = vi.spyOn(prompts, 'multiselect').mockResolvedValue(['agents', 'claude']);
@@ -462,6 +525,38 @@ describe('writeAgentInstructions symlink behavior', () => {
462525
});
463526
});
464527

528+
describe('writeCopilotSetupWorkflow', () => {
529+
it('writes the Copilot setup workflow without overwriting existing files', async () => {
530+
const dir = await createProjectDir();
531+
532+
await writeCopilotSetupWorkflow({ projectRoot: dir });
533+
534+
const workflowPath = path.join(dir, COPILOT_SETUP_WORKFLOW_PATH);
535+
const content = await mockFs.readText(workflowPath);
536+
expect(content).toContain('copilot-setup-steps:');
537+
expect(content).toContain('runs-on: ubuntu-latest');
538+
expect(content).toContain('persist-credentials: false');
539+
expect(content).toContain('uses: actions/checkout@v6');
540+
expect(content).toContain('uses: voidzero-dev/setup-vp@v1');
541+
expect(content).toContain('run-install: true');
542+
expect(content).toContain('- .github/workflows/copilot-setup-steps.yml');
543+
544+
await mockFs.writeFile(workflowPath, 'custom workflow');
545+
await writeCopilotSetupWorkflow({ projectRoot: dir });
546+
547+
expect(await mockFs.readText(workflowPath)).toBe('custom workflow');
548+
});
549+
550+
it('suppresses logs in silent mode', async () => {
551+
const dir = await createProjectDir();
552+
const successSpy = vi.spyOn(prompts.log, 'success');
553+
554+
await writeCopilotSetupWorkflow({ projectRoot: dir, silent: true });
555+
556+
expect(successSpy).not.toHaveBeenCalled();
557+
});
558+
});
559+
465560
describe('hasExistingAgentInstructions', () => {
466561
it('returns true when an agent file has start marker', async () => {
467562
const dir = await createProjectDir();

0 commit comments

Comments
 (0)