mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-09-14 20:16:53 +08:00
73207a6f2c
* feat(copilot): make cloud coding-agent files opt-in Selecting the `github-copilot` tool auto-generated a GitHub Actions workflow (.github/workflows/copilot-setup-steps.yml) plus an agent file. Writing into a user's CI on init/update is invasive, benefits only the narrow set of Copilot *cloud* coding-agent users, and couples us to GitHub's externally-owned custom-agent format. Cloud files are now opt-in: - `openspec init` prompts before generating them (default No) and records the choice in openspec/config.yaml (`githubCopilot.cloudAgent`). - `--copilot-cloud` / `--no-copilot-cloud` decide non-interactively. - `openspec update` never prompts; it only refreshes files for projects that opted in, or that already have generated cloud files (so existing setups keep working — the migration path). The pre-existing content-matching guarantees are unchanged and now proven by regression tests: a user-customized cloud file is never overwritten or deleted. Opt-in state is persisted via the YAML document model so the user's hand-authored config comments and formatting survive untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(copilot): polish the cloud opt-in — safety, UX, and docs Follow-up hardening driven by a five-agent review swarm over the opt-in. Correctness: - persistCopilotCloudOptIn no longer throws on a scalar/`null` config file (reproduced crash); it starts a fresh map while preserving comment-only and empty files. - Explicit opt-out (`--no-copilot-cloud` / `cloudAgent: false`) now removes OpenSpec-managed cloud files on both init and update, instead of orphaning them. Customized files are still never touched. - `--copilot-cloud` / `--no-copilot-cloud` warns when github-copilot isn't among the selected tools, instead of silently no-opping. UX / discoverability: - init prints whether cloud files were written or, when skipped for want of a signal, how to enable them (`--copilot-cloud`). - When the user opts in but already has their own copilot-setup-steps.yml or agent file, init/update say it was left untouched and that the OpenSpec install step must be added by hand — the direct answer to "will this affect my existing Copilot cloud agent?". - Clearer interactive prompt (names both files; distinguishes the GitHub-hosted cloud agent from Copilot in the editor); a dim, interactive-only, decision- gated hint on `openspec update`; tightened flag help text. Docs (the feature was undocumented): new "GitHub Copilot cloud coding agent" section in supported-tools.md; init flags in cli.md; the githubCopilot.cloudAgent key in customization.md. Tests: interactive prompt (accept/decline), opt-out removal + customized-file preservation, config.yml variant, scalar-config regression, collision reporting, flag-ignored warning, re-init honoring persisted opt-in, and the config parse/warn branches. 2763 tests pass; the only failures are pre-existing and unrelated (completion mocks, adapters loader, one config-profile PATH case, one experimental-alias case), verified identical on clean main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(copilot): make init cloud-file output honest; harden config guard Final hardening pass (adversarial review of the opt-in polish). - init's success line listed both cloud-file paths from the *decision* to write, not from what was written — so it claimed files that a write skipped (user already owns them) or that the alternate-agent path removed. It now lists only OpenSpec-managed files that actually exist after the write (listManagedCloudFiles), keeps the "left untouched" caveat for user-owned files, and reports opt-out removals in the normal output block. - persistCopilotCloudOptIn's non-map guard used isCollection, which is also true for sequences, so a YAML list at the config root still made setIn throw. Gate on isMap so scalars AND sequences fall back to a fresh document; empty/comment-only files still round-trip with comments intact. - Fixed a misleading catch comment on the opt-out removal path. Tests: success-line accuracy over a user-owned file, sequence-root config regression, and listManagedCloudFiles coverage. 318 tests pass across the touched suites; build + lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(copilot): replace a non-map githubCopilot node before setIn Addresses alfred review on #1517. The prior guard only fixed a non-map config *root*; a valid top-level map whose `githubCopilot` value is itself a scalar/null/sequence (`githubCopilot: false`, `null`, or a list) still made `setIn(['githubCopilot','cloudAgent'], ...)` throw, which init swallowed — so the explicit opt-in/out was never saved. Now the intermediate node is replaced with an empty map before descending, keeping the rest of the config and its comments intact. Regression covers all three reproduced cases (false/null/sequence). Full suite: 2770 pass; only the pre-existing unrelated failures remain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(copilot): never throw persisting into an unparseable config Deeper pass on persistCopilotCloudOptIn (the function alfred flagged), driven by an exhaustive input-shape check. Two malformed inputs still threw at toString(): a multi-document YAML stream and a tab-indented (syntactically invalid) file. Such a file can't be edited without corrupting it, so persist now detects parse errors and leaves it untouched (no throw, no clobber) — it is already invalid, so readProjectConfig ignores it regardless. With this the function is throw-free across every shape exercised: empty, comment-only, scalar/sequence root, a non-map githubCopilot value, anchors, CRLF, BOM, and the two malformed cases (now skipped byte-identical). Regression added for the multi-document case. Touched suites: 314 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
770 lines
34 KiB
TypeScript
770 lines
34 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,
|
|
readCopilotCloudOptIn,
|
|
hasExistingManagedCloudFiles,
|
|
isCopilotCloudEnabled,
|
|
persistCopilotCloudOptIn,
|
|
findUnmanagedCloudFiles,
|
|
listManagedCloudFiles,
|
|
} 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 });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('cloud opt-in', () => {
|
|
const CONFIG_WITH_COMMENTS = `schema: spec-driven
|
|
|
|
# Project context (optional)
|
|
context: |
|
|
Tech stack: TypeScript
|
|
`;
|
|
|
|
async function writeConfig(content: string): Promise<string> {
|
|
const configPath = path.join(tempDir, 'openspec', 'config.yaml');
|
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
await fs.writeFile(configPath, content);
|
|
return configPath;
|
|
}
|
|
|
|
describe('readCopilotCloudOptIn', () => {
|
|
it('returns undefined when there is no config', () => {
|
|
expect(readCopilotCloudOptIn(tempDir)).toBeUndefined();
|
|
});
|
|
|
|
it('reads an explicit opt-in and opt-out', async () => {
|
|
await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: true\n`);
|
|
expect(readCopilotCloudOptIn(tempDir)).toBe(true);
|
|
|
|
await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: false\n`);
|
|
expect(readCopilotCloudOptIn(tempDir)).toBe(false);
|
|
});
|
|
|
|
it('treats a non-boolean value as undecided', async () => {
|
|
await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: "yes"\n`);
|
|
expect(readCopilotCloudOptIn(tempDir)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('persistCopilotCloudOptIn', () => {
|
|
it('writes the nested key while preserving existing comments and content', async () => {
|
|
const configPath = await writeConfig(CONFIG_WITH_COMMENTS);
|
|
|
|
await persistCopilotCloudOptIn(tempDir, true);
|
|
|
|
const written = await fs.readFile(configPath, 'utf8');
|
|
expect(written).toContain('# Project context (optional)');
|
|
expect(written).toContain('Tech stack: TypeScript');
|
|
expect(parse(written)).toMatchObject({
|
|
schema: 'spec-driven',
|
|
githubCopilot: { cloudAgent: true },
|
|
});
|
|
// Round-trips through the reader.
|
|
expect(readCopilotCloudOptIn(tempDir)).toBe(true);
|
|
});
|
|
|
|
it('flips an existing decision in place', async () => {
|
|
await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: true\n`);
|
|
await persistCopilotCloudOptIn(tempDir, false);
|
|
expect(readCopilotCloudOptIn(tempDir)).toBe(false);
|
|
});
|
|
|
|
it('is a no-op when no config file exists', async () => {
|
|
await persistCopilotCloudOptIn(tempDir, true);
|
|
await expect(
|
|
fs.stat(path.join(tempDir, 'openspec', 'config.yaml'))
|
|
).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('persists into and reads from config.yml when only .yml exists', async () => {
|
|
const ymlPath = path.join(tempDir, 'openspec', 'config.yml');
|
|
await fs.mkdir(path.dirname(ymlPath), { recursive: true });
|
|
await fs.writeFile(ymlPath, `${CONFIG_WITH_COMMENTS}`);
|
|
|
|
await persistCopilotCloudOptIn(tempDir, true);
|
|
|
|
// No sibling .yaml was created; the .yml file was edited in place.
|
|
await expect(
|
|
fs.stat(path.join(tempDir, 'openspec', 'config.yaml'))
|
|
).rejects.toMatchObject({ code: 'ENOENT' });
|
|
const written = await fs.readFile(ymlPath, 'utf8');
|
|
expect(written).toContain('# Project context (optional)');
|
|
expect(written).toContain('cloudAgent: true');
|
|
expect(readCopilotCloudOptIn(tempDir)).toBe(true);
|
|
});
|
|
|
|
it('does not throw on a scalar-content config and writes a valid map', async () => {
|
|
// A degenerate config whose top-level node is a bare scalar used to
|
|
// throw "Expected a YAML collection as document contents".
|
|
await writeConfig('null\n');
|
|
await expect(persistCopilotCloudOptIn(tempDir, false)).resolves.toBeUndefined();
|
|
expect(readCopilotCloudOptIn(tempDir)).toBe(false);
|
|
});
|
|
|
|
it('does not throw on a sequence-root config and writes a valid map', async () => {
|
|
// A YAML list at the root is also not a map: setIn would throw, so it
|
|
// must be replaced with a fresh document rather than crash.
|
|
await writeConfig('- a\n- b\n');
|
|
await expect(persistCopilotCloudOptIn(tempDir, true)).resolves.toBeUndefined();
|
|
expect(readCopilotCloudOptIn(tempDir)).toBe(true);
|
|
});
|
|
|
|
it('leaves an unparseable config untouched instead of throwing', async () => {
|
|
// A multi-document stream can't be edited without corrupting it; persist
|
|
// must skip it (no throw, no clobber) rather than crash.
|
|
const malformed = '---\na: 1\n---\nb: 2\n';
|
|
const configPath = await writeConfig(malformed);
|
|
|
|
await expect(persistCopilotCloudOptIn(tempDir, true)).resolves.toBeUndefined();
|
|
|
|
expect(await fs.readFile(configPath, 'utf8')).toBe(malformed);
|
|
});
|
|
|
|
it('does not throw when the githubCopilot node itself is not a map', async () => {
|
|
// Root is a valid map, but `githubCopilot` holds a scalar/null/sequence:
|
|
// descending into it with setIn used to throw. Each must be replaced
|
|
// with a map, keeping the rest of the config (and its comments) intact.
|
|
for (const bad of [
|
|
'githubCopilot: false',
|
|
'githubCopilot: null',
|
|
'githubCopilot:\n - a\n - b',
|
|
]) {
|
|
await writeConfig(`schema: spec-driven\n# keep me\n${bad}\n`);
|
|
await expect(persistCopilotCloudOptIn(tempDir, true)).resolves.toBeUndefined();
|
|
expect(readCopilotCloudOptIn(tempDir)).toBe(true);
|
|
const written = await fs.readFile(
|
|
path.join(tempDir, 'openspec', 'config.yaml'),
|
|
'utf8'
|
|
);
|
|
expect(written).toContain('# keep me');
|
|
expect(written).toContain('schema: spec-driven');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('listManagedCloudFiles', () => {
|
|
it('is empty on a clean project and lists managed files after a write', async () => {
|
|
await expect(listManagedCloudFiles(tempDir)).resolves.toEqual([]);
|
|
await writeCopilotCloudFiles(tempDir);
|
|
await expect(listManagedCloudFiles(tempDir)).resolves.toEqual([
|
|
COPILOT_CLOUD_FILES.setupSteps,
|
|
COPILOT_CLOUD_FILES.agent,
|
|
]);
|
|
});
|
|
|
|
it('excludes a user-owned (non-managed) file', async () => {
|
|
await writeCopilotCloudFiles(tempDir);
|
|
await fs.writeFile(
|
|
path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps),
|
|
'name: my own build workflow\n'
|
|
);
|
|
await expect(listManagedCloudFiles(tempDir)).resolves.toEqual([COPILOT_CLOUD_FILES.agent]);
|
|
});
|
|
});
|
|
|
|
describe('findUnmanagedCloudFiles', () => {
|
|
it('is empty on a clean project and after a managed write', async () => {
|
|
await expect(findUnmanagedCloudFiles(tempDir)).resolves.toEqual([]);
|
|
await writeCopilotCloudFiles(tempDir);
|
|
await expect(findUnmanagedCloudFiles(tempDir)).resolves.toEqual([]);
|
|
});
|
|
|
|
it('reports a user-owned (non-managed) file that would be left untouched', async () => {
|
|
const setupStepsPath = path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps);
|
|
await fs.mkdir(path.dirname(setupStepsPath), { recursive: true });
|
|
await fs.writeFile(setupStepsPath, 'name: my own build workflow\n');
|
|
|
|
await expect(findUnmanagedCloudFiles(tempDir)).resolves.toEqual([
|
|
COPILOT_CLOUD_FILES.setupSteps,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('hasExistingManagedCloudFiles', () => {
|
|
it('is false on a clean project', async () => {
|
|
await expect(hasExistingManagedCloudFiles(tempDir)).resolves.toBe(false);
|
|
});
|
|
|
|
it('is true when a managed file exists, false for a purely customized one', async () => {
|
|
await writeCopilotCloudFiles(tempDir);
|
|
await expect(hasExistingManagedCloudFiles(tempDir)).resolves.toBe(true);
|
|
|
|
// Replace both managed files with customized content: no longer "managed".
|
|
await fs.writeFile(
|
|
path.join(tempDir, COPILOT_CLOUD_FILES.setupSteps),
|
|
'name: my own workflow\n'
|
|
);
|
|
await fs.writeFile(
|
|
path.join(tempDir, COPILOT_CLOUD_FILES.agent),
|
|
'my own agent instructions\n'
|
|
);
|
|
await expect(hasExistingManagedCloudFiles(tempDir)).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isCopilotCloudEnabled', () => {
|
|
it('honors an explicit opt-out even when managed files exist', async () => {
|
|
await writeCopilotCloudFiles(tempDir);
|
|
await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: false\n`);
|
|
await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(false);
|
|
});
|
|
|
|
it('honors an explicit opt-in with no files yet', async () => {
|
|
await writeConfig(`${CONFIG_WITH_COMMENTS}githubCopilot:\n cloudAgent: true\n`);
|
|
await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(true);
|
|
});
|
|
|
|
it('falls back to existing managed files when undecided (migration)', async () => {
|
|
await writeCopilotCloudFiles(tempDir);
|
|
await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(true);
|
|
});
|
|
|
|
it('is false when undecided and no managed files exist', async () => {
|
|
await expect(isCopilotCloudEnabled(tempDir)).resolves.toBe(false);
|
|
});
|
|
});
|
|
});
|
|
});
|