mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-11-13 10:40:25 +08:00
* feat: generate copilot cloud agent files when github-copilot tool is selected When `openspec init` or `openspec update` is run with the github-copilot tool selected, two additional files are now generated in the user's project: 1. `.github/workflows/copilot-setup-steps.yml` - A GitHub Actions workflow that pre-installs the OpenSpec CLI in the Copilot coding agent's ephemeral environment (required for the agent to use `openspec` commands). 2. `.github/agents/openspec.agent.md` - A custom agent definition that instructs the GitHub Copilot coding agent how to use the OpenSpec CLI, including all agent-compatible commands with `--json` output, workflow patterns, and best practices. These files are only written if they don't already exist (to preserve user customizations). The generation is non-fatal — if it fails, init/update still completes successfully. New module: src/core/github-copilot/cloud-agent.ts Tests: test/core/github-copilot-cloud-agent.test.ts * fix: wire up removeCopilotCloudFiles in update flow When github-copilot is not in the configured tools during update, remove the cloud agent files (copilot-setup-steps.yml and openspec.agent.md) if they exist. * fix: refresh Copilot cloud agent restore * fix: address Copilot cloud review feedback * fix: recognize legacy Copilot cloud files * fix: harden Copilot legacy file matching * fix(copilot): harden cloud agent file management * fix(copilot): harden cloud agent file handling --------- Co-authored-by: Clay Good <hi@claygood.com>
551 lines
25 KiB
TypeScript
551 lines
25 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { promises as fs } from 'fs';
|
|
import { parse } from 'yaml';
|
|
import {
|
|
includesGitHubCopilot,
|
|
generateCopilotSetupSteps,
|
|
generateCopilotAgentFile,
|
|
COPILOT_CLOUD_FILES,
|
|
removeCopilotCloudFiles,
|
|
writeCopilotCloudFiles,
|
|
} from '../../src/core/github-copilot/cloud-agent.js';
|
|
|
|
const MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.';
|
|
const MARKERLESS_LEGACY_COPILOT_AGENT_FILE = `---
|
|
name: OpenSpec
|
|
description: "Manages OpenSpec changes, specs, and workflows using the OpenSpec CLI. Use this agent for proposing changes, exploring ideas, validating artifacts, checking status, and archiving completed work."
|
|
tools:
|
|
- "terminal"
|
|
---
|
|
|
|
# OpenSpec Agent
|
|
|
|
You are a specialized agent for managing OpenSpec workflows. You have access to the \`openspec\` CLI which is pre-installed in the development environment via \`copilot-setup-steps.yml\`.
|
|
|
|
## What is OpenSpec?
|
|
|
|
OpenSpec is a structured change management system for codebases. It organizes work into **changes** with planning artifacts (proposals, specs, designs, tasks) that guide implementation.
|
|
|
|
## Available Commands
|
|
|
|
### Agent-Compatible CLI Commands (prefer \`--json\` for structured output)
|
|
|
|
| Command | Purpose |
|
|
|---------|---------|
|
|
| \`openspec list [--json]\` | List all changes and specs |
|
|
| \`openspec show <item> [--json]\` | View a specific change or spec |
|
|
| \`openspec validate [--all] [--json]\` | Validate changes and specs for issues |
|
|
| \`openspec status [--json]\` | Show artifact progress for active changes |
|
|
| \`openspec instructions [--json]\` | Get next-step instructions for a change |
|
|
| \`openspec templates [--json]\` | List available templates |
|
|
| \`openspec schemas [--json]\` | List available workflow schemas |
|
|
| \`openspec archive <change>\` | Archive a completed change |
|
|
|
|
### Interactive CLI Commands (use when prompted by the user)
|
|
|
|
| Command | Purpose |
|
|
|---------|---------|
|
|
| \`openspec init\` | Initialize OpenSpec in the project |
|
|
| \`openspec update\` | Update OpenSpec configuration and artifacts |
|
|
| \`openspec view\` | Interactive dashboard |
|
|
| \`openspec config\` | View or modify settings |
|
|
|
|
## Workflow
|
|
|
|
When asked to work with OpenSpec, follow this pattern:
|
|
|
|
1. **Check current state**: Run \`openspec status --json\` to understand what changes exist and their progress.
|
|
2. **Follow instructions**: Run \`openspec instructions --json\` to get context-aware next steps.
|
|
3. **Validate before completing**: Run \`openspec validate --all --json\` to ensure artifacts are correct.
|
|
|
|
## Creating New Changes
|
|
|
|
When the user wants to propose a new change:
|
|
|
|
1. Create the change directory under \`openspec/changes/<change-name>/\`
|
|
2. Generate the required planning artifacts based on the project's configured workflow schema
|
|
3. Run \`openspec validate --json\` to verify the artifacts are well-formed
|
|
|
|
## Key Directories
|
|
|
|
- \`openspec/\` \u2014 Root OpenSpec directory
|
|
- \`openspec/changes/\` \u2014 Active changes with their artifacts
|
|
- \`openspec/config.yaml\` \u2014 Project configuration
|
|
- \`openspec/explorations/\` \u2014 Exploration documents
|
|
|
|
## Best Practices
|
|
|
|
- Always use \`--json\` flag when you need to parse output programmatically
|
|
- Run \`openspec validate\` after creating or modifying artifacts
|
|
- Check \`openspec status\` before starting work to understand the current state
|
|
- When archiving, ensure all tasks are completed and validated first
|
|
`;
|
|
|
|
describe('GitHub Copilot Cloud Agent', () => {
|
|
let tempDir: string;
|
|
|
|
function removeManagedMarker(content: string): string {
|
|
const withoutMarker = content
|
|
.replace(/^# Generated by OpenSpec for GitHub Copilot coding agent support\.\n\n/, '')
|
|
.replace(/\n<!-- Generated by OpenSpec for GitHub Copilot coding agent support\. -->\n/, '');
|
|
expect(withoutMarker).not.toBe(content);
|
|
expect(withoutMarker).not.toContain(MANAGED_MARKER);
|
|
return withoutMarker;
|
|
}
|
|
|
|
function withCrLf(content: string): string {
|
|
return content.replace(/\n/g, '\r\n');
|
|
}
|
|
|
|
async function linkDirectoryOutsideProject(outsideDir: string): Promise<void> {
|
|
await fs.symlink(
|
|
outsideDir,
|
|
path.join(tempDir, '.github'),
|
|
process.platform === 'win32' ? 'junction' : 'dir'
|
|
);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-cloud-agent-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('includesGitHubCopilot', () => {
|
|
it('returns true when github-copilot is in the list', () => {
|
|
expect(includesGitHubCopilot(['claude', 'github-copilot', 'cursor'])).toBe(true);
|
|
});
|
|
|
|
it('returns false when github-copilot is not in the list', () => {
|
|
expect(includesGitHubCopilot(['claude', 'cursor'])).toBe(false);
|
|
});
|
|
|
|
it('returns false for empty list', () => {
|
|
expect(includesGitHubCopilot([])).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('generateCopilotSetupSteps', () => {
|
|
it('generates a structurally valid Copilot setup workflow', () => {
|
|
const content = generateCopilotSetupSteps();
|
|
const workflow = parse(content);
|
|
|
|
expect(workflow).toMatchObject({
|
|
name: 'Copilot Setup Steps',
|
|
on: {
|
|
workflow_dispatch: null,
|
|
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',
|
|
'timeout-minutes': 10,
|
|
permissions: { contents: 'read' },
|
|
},
|
|
},
|
|
});
|
|
expect(Object.keys(workflow.jobs)).toEqual(['copilot-setup-steps']);
|
|
expect(workflow.jobs['copilot-setup-steps'].steps).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ run: 'npm install -g @fission-ai/openspec' }),
|
|
expect.objectContaining({ run: 'openspec --version' }),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('generateCopilotAgentFile', () => {
|
|
it('generates valid agent frontmatter and non-interactive guidance', () => {
|
|
const content = generateCopilotAgentFile();
|
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
|
|
expect(frontmatterMatch).not.toBeNull();
|
|
const frontmatter = parse(frontmatterMatch![1]);
|
|
|
|
expect(frontmatter).toEqual({
|
|
name: 'OpenSpec',
|
|
description: expect.any(String),
|
|
tools: ['execute', 'read', 'search', 'edit'],
|
|
});
|
|
expect(content).toContain('Generated by OpenSpec for GitHub Copilot coding agent support.');
|
|
expect(content).toContain('# OpenSpec Agent');
|
|
expect(content).toContain('openspec list');
|
|
expect(content).toContain('openspec new change <name>');
|
|
expect(content).toContain('openspec status --change <name> --json');
|
|
expect(content).toContain('openspec instructions [artifact] --change <name> --json');
|
|
expect(content).toContain('openspec archive <change> --json [--yes]');
|
|
expect(content).toContain('use `--yes` only after confirming all tasks are complete');
|
|
expect(content).toContain('run `openspec --version`');
|
|
expect(content).not.toContain('pre-installed in the development environment');
|
|
expect(content).not.toContain('Create the change directory under');
|
|
expect(content).toContain('openspec validate');
|
|
});
|
|
});
|
|
|
|
describe('COPILOT_CLOUD_FILES', () => {
|
|
it('has correct file paths', () => {
|
|
expect(COPILOT_CLOUD_FILES.setupSteps).toBe(path.join('.github', 'workflows', 'copilot-setup-steps.yml'));
|
|
expect(COPILOT_CLOUD_FILES.agent).toBe(path.join('.github', 'agents', 'openspec.agent.md'));
|
|
});
|
|
});
|
|
|
|
describe('writeCopilotCloudFiles', () => {
|
|
it('writes missing cloud files and creates parent directories', async () => {
|
|
const result = await writeCopilotCloudFiles(tempDir);
|
|
|
|
expect(result).toEqual({ setupStepsWritten: true, agentWritten: true });
|
|
await expect(fs.stat(path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps))).resolves.toBeTruthy();
|
|
await expect(fs.stat(path.join(tempDir, COPILOT_CLOUD_FILES.agent))).resolves.toBeTruthy();
|
|
});
|
|
|
|
it('preserves customized existing files', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
|
|
await fs.mkdir(path.dirname(agentPath), { recursive: true });
|
|
await fs.writeFile(setupStepsPath, 'custom setup');
|
|
await fs.writeFile(agentPath, 'custom agent');
|
|
|
|
const result = await writeCopilotCloudFiles(tempDir);
|
|
|
|
expect(result).toEqual({ setupStepsWritten: false, agentWritten: false });
|
|
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('custom setup');
|
|
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe('custom agent');
|
|
});
|
|
|
|
it('creates robust agent guidance alongside a customized setup workflow', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
const customSetup = 'name: custom setup\n';
|
|
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
|
|
await fs.writeFile(setupStepsPath, customSetup);
|
|
|
|
const result = await writeCopilotCloudFiles(tempDir);
|
|
|
|
expect(result).toEqual({ setupStepsWritten: false, agentWritten: true });
|
|
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(customSetup);
|
|
const agentContent = await fs.readFile(agentPath, 'utf8');
|
|
expect(agentContent).toContain('run `openspec --version`');
|
|
expect(agentContent).toContain('install it with `npm install -g @fission-ai/openspec`');
|
|
expect(agentContent).not.toContain('pre-installed in the development environment');
|
|
});
|
|
|
|
it('preserves an alternate user-owned agent with the same Copilot identifier', async () => {
|
|
const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md');
|
|
const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
const customAgent = 'user-owned OpenSpec agent\n';
|
|
await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true });
|
|
await fs.writeFile(alternateAgentPath, customAgent);
|
|
|
|
const result = await writeCopilotCloudFiles(tempDir);
|
|
|
|
expect(result).toEqual({ setupStepsWritten: true, agentWritten: false });
|
|
await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent);
|
|
await expect(fs.stat(generatedAgentPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('removes a managed agent when an alternate user-owned agent is added later', async () => {
|
|
const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md');
|
|
const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
const customAgent = 'user-owned OpenSpec agent\n';
|
|
await writeCopilotCloudFiles(tempDir);
|
|
await fs.writeFile(alternateAgentPath, customAgent);
|
|
|
|
const result = await writeCopilotCloudFiles(tempDir);
|
|
|
|
expect(result).toEqual({ setupStepsWritten: false, agentWritten: false });
|
|
await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent);
|
|
await expect(fs.stat(generatedAgentPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('reports conflicting user-owned agent profiles without creating setup files', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md');
|
|
const generatedAgentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true });
|
|
await fs.writeFile(alternateAgentPath, 'custom alternate agent\n');
|
|
await fs.writeFile(generatedAgentPath, 'custom generated-path agent\n');
|
|
|
|
await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow(
|
|
'Conflicting Copilot agent profiles'
|
|
);
|
|
|
|
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(
|
|
'custom alternate agent\n'
|
|
);
|
|
await expect(fs.readFile(generatedAgentPath, 'utf8')).resolves.toBe(
|
|
'custom generated-path agent\n'
|
|
);
|
|
});
|
|
|
|
it('rejects a directory at a managed file path before creating other files', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
await fs.mkdir(agentPath, { recursive: true });
|
|
|
|
await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow(
|
|
'Managed Copilot path is not a regular file'
|
|
);
|
|
|
|
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
expect((await fs.stat(agentPath)).isDirectory()).toBe(true);
|
|
});
|
|
|
|
it('refreshes exact legacy generated files', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
|
|
await fs.mkdir(path.dirname(agentPath), { recursive: true });
|
|
await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps()));
|
|
await fs.writeFile(agentPath, MARKERLESS_LEGACY_COPILOT_AGENT_FILE);
|
|
|
|
const result = await writeCopilotCloudFiles(tempDir);
|
|
|
|
expect(result).toEqual({ setupStepsWritten: true, agentWritten: true });
|
|
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(
|
|
generateCopilotSetupSteps()
|
|
);
|
|
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(generateCopilotAgentFile());
|
|
});
|
|
|
|
it('refreshes the previous marker-bearing generated agent', async () => {
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
const previousAgent = generateCopilotAgentFile()
|
|
.replace(
|
|
'You are a specialized agent for managing OpenSpec workflows. Before using the `openspec` CLI, run `openspec --version`. If it is unavailable, install it with `npm install -g @fission-ai/openspec`.',
|
|
'You are a specialized agent for managing OpenSpec workflows. You have access to the `openspec` CLI through shell commands, pre-installed in the development environment via `copilot-setup-steps.yml`.'
|
|
)
|
|
.replace(
|
|
'| `openspec archive <change> --json [--yes]` | Archive a completed change; use `--yes` only after confirming all tasks are complete |',
|
|
'| `openspec archive <change>` | Archive a completed change |'
|
|
);
|
|
await fs.mkdir(path.dirname(agentPath), { recursive: true });
|
|
await fs.writeFile(agentPath, previousAgent);
|
|
|
|
const result = await writeCopilotCloudFiles(tempDir);
|
|
|
|
expect(result.agentWritten).toBe(true);
|
|
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(generateCopilotAgentFile());
|
|
});
|
|
|
|
it('leaves current generated files unchanged, including CRLF content', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
const setupStepsContent = generateCopilotSetupSteps();
|
|
const agentContent = withCrLf(generateCopilotAgentFile());
|
|
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
|
|
await fs.mkdir(path.dirname(agentPath), { recursive: true });
|
|
await fs.writeFile(setupStepsPath, setupStepsContent);
|
|
await fs.writeFile(agentPath, agentContent);
|
|
|
|
const result = await writeCopilotCloudFiles(tempDir);
|
|
|
|
expect(result).toEqual({ setupStepsWritten: false, agentWritten: false });
|
|
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(setupStepsContent);
|
|
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(agentContent);
|
|
});
|
|
|
|
it('refuses to write cloud files through a linked .github directory', async () => {
|
|
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-'));
|
|
const outsideSetupStepsPath = path.join(
|
|
outsideDir,
|
|
'workflows',
|
|
'copilot-setup-steps.yml'
|
|
);
|
|
const outsideAgentPath = path.join(outsideDir, 'agents', 'openspec.agent.md');
|
|
|
|
try {
|
|
await linkDirectoryOutsideProject(outsideDir);
|
|
|
|
await expect(writeCopilotCloudFiles(tempDir)).rejects.toThrow(
|
|
'Path is outside the allowed directory'
|
|
);
|
|
await expect(fs.stat(outsideSetupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
await expect(fs.stat(outsideAgentPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
} finally {
|
|
await fs.rm(outsideDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('removeCopilotCloudFiles', () => {
|
|
it('removes only existing cloud files and returns the removal count', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
await writeCopilotCloudFiles(tempDir);
|
|
await fs.rm(agentPath, { force: true });
|
|
|
|
const removed = await removeCopilotCloudFiles(tempDir);
|
|
|
|
expect(removed).toBe(1);
|
|
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('keeps customized cloud files', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
|
|
await fs.mkdir(path.dirname(agentPath), { recursive: true });
|
|
await fs.writeFile(setupStepsPath, 'custom setup');
|
|
await fs.writeFile(agentPath, 'custom agent');
|
|
|
|
const removed = await removeCopilotCloudFiles(tempDir);
|
|
|
|
expect(removed).toBe(0);
|
|
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('custom setup');
|
|
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe('custom agent');
|
|
});
|
|
|
|
it('keeps modified marker-bearing cloud files', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
|
|
await fs.mkdir(path.dirname(agentPath), { recursive: true });
|
|
await fs.writeFile(setupStepsPath, `${generateCopilotSetupSteps()}\n# custom change\n`);
|
|
await fs.writeFile(agentPath, `${generateCopilotAgentFile()}\ncustom change\n`);
|
|
|
|
const removed = await removeCopilotCloudFiles(tempDir);
|
|
|
|
expect(removed).toBe(0);
|
|
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toContain('custom change');
|
|
await expect(fs.readFile(agentPath, 'utf8')).resolves.toContain('custom change');
|
|
});
|
|
|
|
it('removes markerless current generated cloud files', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
|
|
await fs.mkdir(path.dirname(agentPath), { recursive: true });
|
|
await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps()));
|
|
await fs.writeFile(agentPath, removeManagedMarker(generateCopilotAgentFile()));
|
|
|
|
const removed = await removeCopilotCloudFiles(tempDir);
|
|
|
|
expect(removed).toBe(2);
|
|
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('removes markerless legacy generated cloud files', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
const currentAgentContent = removeManagedMarker(generateCopilotAgentFile());
|
|
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
|
|
await fs.mkdir(path.dirname(agentPath), { recursive: true });
|
|
await fs.writeFile(setupStepsPath, removeManagedMarker(generateCopilotSetupSteps()));
|
|
await fs.writeFile(agentPath, MARKERLESS_LEGACY_COPILOT_AGENT_FILE);
|
|
|
|
const removed = await removeCopilotCloudFiles(tempDir);
|
|
|
|
expect(MARKERLESS_LEGACY_COPILOT_AGENT_FILE).not.toBe(currentAgentContent);
|
|
expect(MARKERLESS_LEGACY_COPILOT_AGENT_FILE).toContain(' - "terminal"');
|
|
expect(removed).toBe(2);
|
|
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('removes current and legacy generated cloud files with CRLF line endings', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
|
|
await fs.mkdir(path.dirname(agentPath), { recursive: true });
|
|
await fs.writeFile(setupStepsPath, withCrLf(generateCopilotSetupSteps()));
|
|
await fs.writeFile(agentPath, withCrLf(MARKERLESS_LEGACY_COPILOT_AGENT_FILE));
|
|
|
|
const removed = await removeCopilotCloudFiles(tempDir);
|
|
|
|
expect(removed).toBe(2);
|
|
await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('keeps customized cloud files with CRLF line endings', async () => {
|
|
const agentPath = path.join(tempDir, COPILOT_CLOUD_FILES.agent);
|
|
const customizedContent = withCrLf(`${generateCopilotAgentFile()}\ncustom change\n`);
|
|
await fs.mkdir(path.dirname(agentPath), { recursive: true });
|
|
await fs.writeFile(agentPath, customizedContent);
|
|
|
|
const removed = await removeCopilotCloudFiles(tempDir);
|
|
|
|
expect(removed).toBe(0);
|
|
await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(customizedContent);
|
|
});
|
|
|
|
it('preserves the alternate user-owned agent during cleanup', async () => {
|
|
const alternateAgentPath = path.join(tempDir, '.github', 'agents', 'openspec.md');
|
|
const customAgent = 'user-owned OpenSpec agent\n';
|
|
await fs.mkdir(path.dirname(alternateAgentPath), { recursive: true });
|
|
await fs.writeFile(alternateAgentPath, customAgent);
|
|
|
|
const removed = await removeCopilotCloudFiles(tempDir);
|
|
|
|
expect(removed).toBe(0);
|
|
await expect(fs.readFile(alternateAgentPath, 'utf8')).resolves.toBe(customAgent);
|
|
});
|
|
|
|
it('preflights nested linked paths before removing any managed file', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
const agentsDir = path.join(tempDir, '.github', 'agents');
|
|
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-'));
|
|
const outsideAgentPath = path.join(outsideDir, 'openspec.agent.md');
|
|
const setupStepsContent = generateCopilotSetupSteps();
|
|
const agentContent = generateCopilotAgentFile();
|
|
|
|
try {
|
|
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
|
|
await fs.writeFile(setupStepsPath, setupStepsContent);
|
|
await fs.writeFile(outsideAgentPath, agentContent);
|
|
await fs.symlink(
|
|
outsideDir,
|
|
agentsDir,
|
|
process.platform === 'win32' ? 'junction' : 'dir'
|
|
);
|
|
|
|
await expect(removeCopilotCloudFiles(tempDir)).rejects.toThrow(
|
|
'Path is outside the allowed directory'
|
|
);
|
|
await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(setupStepsContent);
|
|
await expect(fs.readFile(outsideAgentPath, 'utf8')).resolves.toBe(agentContent);
|
|
} finally {
|
|
await fs.rm(outsideDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('refuses to remove managed cloud files through a linked .github directory', async () => {
|
|
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-copilot-outside-'));
|
|
const outsideSetupStepsPath = path.join(
|
|
outsideDir,
|
|
'workflows',
|
|
'copilot-setup-steps.yml'
|
|
);
|
|
const outsideAgentPath = path.join(outsideDir, 'agents', 'openspec.agent.md');
|
|
const setupStepsContent = generateCopilotSetupSteps();
|
|
const agentContent = generateCopilotAgentFile();
|
|
|
|
try {
|
|
await fs.mkdir(path.dirname(outsideSetupStepsPath), { recursive: true });
|
|
await fs.mkdir(path.dirname(outsideAgentPath), { recursive: true });
|
|
await fs.writeFile(outsideSetupStepsPath, setupStepsContent);
|
|
await fs.writeFile(outsideAgentPath, agentContent);
|
|
await linkDirectoryOutsideProject(outsideDir);
|
|
|
|
await expect(removeCopilotCloudFiles(tempDir)).rejects.toThrow(
|
|
'Path is outside the allowed directory'
|
|
);
|
|
await expect(fs.readFile(outsideSetupStepsPath, 'utf8')).resolves.toBe(
|
|
setupStepsContent
|
|
);
|
|
await expect(fs.readFile(outsideAgentPath, 'utf8')).resolves.toBe(agentContent);
|
|
} finally {
|
|
await fs.rm(outsideDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
});
|