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
36 changes: 36 additions & 0 deletions packages/cli/snap-tests-global/new-create-vite/snap.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,42 @@
> 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:
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
16 changes: 16 additions & 0 deletions packages/cli/snap-tests-global/new-create-vite/steps.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 61 additions & 11 deletions packages/cli/src/create/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -220,6 +222,18 @@ export interface Options {
packageManager?: string;
}

type ParsedAgentOption = string | false | Array<string | false>;

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'
Expand All @@ -237,7 +251,7 @@ function parseArgs() {
list?: boolean;
help?: boolean;
verbose?: boolean;
agent?: string | string[] | false;
agent?: ParsedAgentOption;
editor?: string;
git?: boolean;
hooks?: boolean;
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<ReturnType<typeof selectEditors>>;
let selectedParentDir: string | undefined;
let remoteTargetDir: string | undefined;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
95 changes: 95 additions & 0 deletions packages/cli/src/utils/__tests__/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading