diff --git a/.changeset/copilot-cloud-opt-in.md b/.changeset/copilot-cloud-opt-in.md new file mode 100644 index 0000000000..7a542c69f5 --- /dev/null +++ b/.changeset/copilot-cloud-opt-in.md @@ -0,0 +1,9 @@ +--- +"@fission-ai/openspec": minor +--- + +Make GitHub Copilot cloud coding-agent files opt-in. Selecting the `github-copilot` tool no longer silently writes a GitHub Actions workflow into `.github/`; `openspec init` now asks first (default No) and remembers the choice in `openspec/config.yaml` (`githubCopilot.cloudAgent`). Use `--copilot-cloud` / `--no-copilot-cloud` to decide non-interactively. + +- `openspec update` never prompts — it only refreshes cloud files for projects that opted in (or that already have generated cloud files, so existing setups keep working). +- Opting out (`--no-copilot-cloud` or `cloudAgent: false`) removes OpenSpec-managed cloud files; a user-customized file is always preserved, never overwritten or deleted. +- `init` and `update` now report whether cloud files were written, skipped, or left untouched — and if you already have your own `copilot-setup-steps.yml`, they say it was preserved and that you need to add the OpenSpec install step by hand. diff --git a/docs/cli.md b/docs/cli.md index 0cb7666c1e..c76ffb9add 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -102,6 +102,8 @@ openspec init [path] [options] | `--force` | Auto-cleanup legacy files without prompting | | `--profile ` | Override global profile for this init run (`core` or `custom`) | | `--no-animation` | Show a static welcome screen instead of the animated one | +| `--copilot-cloud` | Set up GitHub Copilot [cloud coding-agent files](supported-tools.md#github-copilot-cloud-coding-agent) without prompting | +| `--no-copilot-cloud` | Skip GitHub Copilot cloud coding-agent files without prompting | `--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`). diff --git a/docs/customization.md b/docs/customization.md index 0321e481ad..b1143b9276 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -18,6 +18,7 @@ The `openspec/config.yaml` file is the easiest way to customize OpenSpec for you - **Inject project context** - AI sees your tech stack, conventions, etc. - **Add per-artifact rules** - Custom rules for specific artifacts - **Add per-operation guidance** - Advisory preferences for apply and archive work +- **Remember integration choices** - e.g. the [GitHub Copilot cloud coding agent](supported-tools.md#github-copilot-cloud-coding-agent) opt-in ### Quick Setup @@ -52,6 +53,11 @@ operations: archive: guidance: - Keep the completion summary concise + +# Set by `openspec init` when you choose (or decline) the GitHub Copilot +# cloud coding agent; controls whether `init`/`update` generate its files. +githubCopilot: + cloudAgent: false ``` ### How It Works diff --git a/docs/supported-tools.md b/docs/supported-tools.md index f7b1861744..756a80e878 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -102,7 +102,7 @@ to read the hint. | ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/opsx/.md` | | Shared `.agents` skills (`agents`) | `.agents/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | -\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. +\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. Selecting `github-copilot` can also set up the GitHub-hosted **cloud coding agent** — see [GitHub Copilot cloud coding agent](#github-copilot-cloud-coding-agent) below. \*\*\* Hermes loads skills from `~/.hermes/skills/` by default. To use project-local OpenSpec skills, add the project `.hermes/skills/` directory to `skills.external_dirs` in `~/.hermes/config.yaml`; Hermes then exposes skills with user-facing slash invocations such as `/openspec-propose`. @@ -114,6 +114,24 @@ repo-local `.minimax` or `.mavis` directories. Commands-only delivery leaves existing global MiniMax Code skills untouched so one project's delivery setting cannot remove skills used by another project. +### GitHub Copilot cloud coding agent + +GitHub's [Copilot coding agent](https://docs.github.com/en/copilot/using-github-copilot/coding-agent) runs on GitHub in a GitHub Actions environment — separate from Copilot in your editor. OpenSpec can set it up to use the OpenSpec CLI by generating two files: + +- `.github/workflows/copilot-setup-steps.yml` — installs `@fission-ai/openspec` in the agent's environment +- `.github/agents/openspec.agent.md` — tells the agent how to drive OpenSpec + +Because this writes a GitHub Actions workflow into your repository, it is **opt-in**: + +| How | Behavior | +|-----|----------| +| `openspec init` (interactive) | Asks whether to set up cloud files. Default is **No**. | +| `openspec init --copilot-cloud` | Sets them up without prompting (for scripts/CI). | +| `openspec init --no-copilot-cloud` | Skips them without prompting, and removes any previously generated ones. | +| `openspec update` | Never prompts. Refreshes the files only if you opted in (or the project already has them). If you opted out, it removes OpenSpec-managed cloud files. | + +Your choice is saved in `openspec/config.yaml` as `githubCopilot.cloudAgent: true|false`, so non-interactive updates honor it. OpenSpec only ever writes or removes files whose content it generated — if you customize `copilot-setup-steps.yml` or `openspec.agent.md`, or already have your own, it is left untouched (and `init`/`update` tell you so). + ### When to pick the shared `.agents` target `agents` is the vendor-neutral option: it writes skills to `.agents/skills/`, the diff --git a/src/cli/index.ts b/src/cli/index.ts index a20a1b4893..619f958ecc 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -161,7 +161,9 @@ program .option('--force', 'Auto-cleanup legacy files without prompting') .option('--profile ', 'Override global config profile (core or custom)') .option('--no-animation', 'Show a static welcome screen instead of the animated one') - .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean }) => { + .option('--copilot-cloud', 'Set up GitHub Copilot cloud coding-agent files without prompting') + .option('--no-copilot-cloud', 'Skip GitHub Copilot cloud coding-agent files without prompting') + .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean; copilotCloud?: boolean }) => { try { // Validate that the path is a valid directory const resolvedPath = path.resolve(targetPath); @@ -188,6 +190,7 @@ program force: options?.force, profile: options?.profile, animation: options?.animation, + copilotCloud: options?.copilotCloud, }); await initCommand.execute(targetPath); } catch (error) { diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 33db57e874..2d139b3043 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -27,6 +27,14 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'no-animation', description: 'Show a static welcome screen instead of the animated one', }, + { + name: 'copilot-cloud', + description: 'Generate GitHub Copilot cloud coding-agent files (opt-in; default: prompt)', + }, + { + name: 'no-copilot-cloud', + description: 'Skip generating GitHub Copilot cloud coding-agent files', + }, ], }, { diff --git a/src/core/github-copilot/cloud-agent.ts b/src/core/github-copilot/cloud-agent.ts index c46b4175e0..551037c919 100644 --- a/src/core/github-copilot/cloud-agent.ts +++ b/src/core/github-copilot/cloud-agent.ts @@ -9,7 +9,9 @@ import path from 'path'; import { promises as fs } from 'fs'; +import { Document, YAMLMap, parseDocument, isMap } from 'yaml'; import { FileSystemUtils } from '../../utils/file-system.js'; +import { readProjectConfig, resolveConfigFilePath } from '../project-config.js'; const COPILOT_TOOL_ID = 'github-copilot'; const OPENSPEC_MANAGED_MARKER = 'Generated by OpenSpec for GitHub Copilot coding agent support.'; @@ -482,3 +484,149 @@ export async function removeCopilotCloudFiles(projectPath: string): Promise { + for (const relPath of Object.values(COPILOT_CLOUD_FILES)) { + const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath); + if (!(await FileSystemUtils.fileExists(fullPath))) { + continue; + } + const content = await FileSystemUtils.readFile(fullPath); + if (isManagedCopilotCloudFile(relPath, content)) { + return true; + } + } + return false; +} + +/** + * Effective decision on whether to generate/refresh Copilot cloud files. + * An explicit opt-in or opt-out always wins; when undecided, fall back to + * whether managed files already exist (the migration path above). + */ +export async function isCopilotCloudEnabled(projectPath: string): Promise { + const optIn = readCopilotCloudOptIn(projectPath); + if (typeof optIn === 'boolean') { + return optIn; + } + return hasExistingManagedCloudFiles(projectPath); +} + +/** + * Persist the Copilot cloud opt-in into openspec/config.yaml. + * + * Uses the YAML document model rather than a re-serialize so the user's + * existing comments, ordering, and formatting survive untouched — the config + * file is hand-authored and heavily commented, so a lossy round-trip would be + * its own source of toil. No-op when no config file exists yet (init creates it + * before this is called); the caller treats persistence failures as non-fatal. + */ +export async function persistCopilotCloudOptIn( + projectPath: string, + value: boolean +): Promise { + const configPath = resolveConfigFilePath(projectPath); + if (!configPath) { + return; + } + const existing = await FileSystemUtils.readFile(configPath); + const parsed = parseDocument(existing); + // A file YAML can't parse cleanly — a multi-document stream, a tab-indented + // syntax error — can't be edited without corrupting it, and toString() would + // throw. Leave it untouched rather than clobber or crash; such a file is + // already invalid, so readProjectConfig ignores it anyway. + if (parsed.errors.length > 0) { + return; + } + // `setIn(['githubCopilot', ...])` needs a top-level map. A config whose root + // is anything else — a scalar (`null`, a bare string) or even a sequence — + // has no map to set a key on and makes setIn throw. Such a file is already + // invalid (readProjectConfig rejects it), so start fresh rather than crash. + // An empty or comment-only file parses to null contents, which setIn fills in + // while keeping the comments — so only a non-map root is discarded. + const doc: Document = + parsed.contents === null || isMap(parsed.contents) ? parsed : new Document(); + // The root is a map now, but the `githubCopilot` node itself may be a stray + // scalar/sequence/null (e.g. `githubCopilot: false`) — descending into that + // with setIn also throws. Replace any non-map node with an empty map first. + const section = doc.getIn([COPILOT_CONFIG_KEY], true); + if (section !== undefined && !isMap(section)) { + doc.setIn([COPILOT_CONFIG_KEY], new YAMLMap()); + } + doc.setIn([COPILOT_CONFIG_KEY, COPILOT_CLOUD_AGENT_KEY], value); + await FileSystemUtils.writeFile(configPath, doc.toString()); +} + +/** + * Return the managed cloud-file paths (relative to the project root) that + * currently hold user-owned, non-managed content — i.e. files OpenSpec will + * deliberately leave untouched. Used to tell an opted-in user that we preserved + * their existing file rather than silently doing nothing, which is the honest + * answer to "will this affect my existing Copilot cloud setup?". + */ +export async function findUnmanagedCloudFiles(projectPath: string): Promise { + const collisions: string[] = []; + for (const relPath of Object.values(COPILOT_CLOUD_FILES)) { + const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath); + if (!(await FileSystemUtils.fileExists(fullPath))) { + continue; + } + const content = await FileSystemUtils.readFile(fullPath); + if (!isManagedCopilotCloudFile(relPath, content)) { + collisions.push(relPath); + } + } + return collisions; +} + +/** + * Return the managed cloud-file paths (relative to the project root) that + * currently exist and hold OpenSpec-generated content. Callers report this + * rather than the intended paths, so output never claims a file that a write + * skipped (user already owns it) or that reconciliation removed. + */ +export async function listManagedCloudFiles(projectPath: string): Promise { + const present: string[] = []; + for (const relPath of Object.values(COPILOT_CLOUD_FILES)) { + const fullPath = FileSystemUtils.resolveProjectArtifactPath(projectPath, relPath); + if (!(await FileSystemUtils.fileExists(fullPath))) { + continue; + } + const content = await FileSystemUtils.readFile(fullPath); + if (isManagedCopilotCloudFile(relPath, content)) { + present.push(relPath); + } + } + return present; +} diff --git a/src/core/init.ts b/src/core/init.ts index 28d2337168..76c2a96977 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -64,7 +64,15 @@ import { shouldReconcileCommandFilesForTool, shouldRemoveSkillsForTool, } from './command-surface.js'; -import { writeCopilotCloudFiles } from './github-copilot/cloud-agent.js'; +import { + writeCopilotCloudFiles, + readCopilotCloudOptIn, + hasExistingManagedCloudFiles, + persistCopilotCloudOptIn, + removeCopilotCloudFiles, + findUnmanagedCloudFiles, + listManagedCloudFiles, +} from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -106,6 +114,12 @@ type InitCommandOptions = { profile?: string; /** Commander's --no-animation flag: false disables the welcome animation. */ animation?: boolean; + /** + * Explicit opt-in/out for GitHub Copilot cloud coding-agent files. + * `--copilot-cloud` sets true, `--no-copilot-cloud` sets false; undefined + * leaves the decision to config, migration, or an interactive prompt. + */ + copilotCloud?: boolean; }; type ValidatedInitTool = { @@ -136,6 +150,7 @@ export class InitCommand { private readonly interactiveOption?: boolean; private readonly profileOverride?: string; private readonly animation: boolean; + private readonly copilotCloudOption?: boolean; constructor(options: InitCommandOptions = {}) { this.toolsArg = options.tools; @@ -143,6 +158,7 @@ export class InitCommand { this.interactiveOption = options.interactive; this.profileOverride = options.profile; this.animation = options.animation ?? true; + this.copilotCloudOption = options.copilotCloud; } async execute(targetPath: string): Promise { @@ -231,11 +247,22 @@ export class InitCommand { if (kept) console.log(chalk.dim(kept)); } + // Decide whether to generate GitHub Copilot cloud files. This is opt-in + // (see cloud-agent.ts): selecting the Copilot tool no longer silently + // writes a GitHub Actions workflow into the user's .github/. The decision + // is made before generation so the write can be gated, and persisted after + // config.yaml exists so future non-interactive updates honor it. + const copilotDecision = await this.resolveCopilotCloudDecision(projectPath, validatedTools); + // Create directory structure and config await this.createDirectoryStructure(openspecPath, extendMode); // Generate skills and commands for each tool - const results = await this.generateSkillsAndCommands(projectPath, validatedTools); + const results = await this.generateSkillsAndCommands( + projectPath, + validatedTools, + copilotDecision.write + ); // Legacy cleanup was deferred to avoid interfering with skill/command generation; // now that outputs are written, finalize the cleanup (e.g. remove stale files). @@ -246,8 +273,49 @@ export class InitCommand { // Create config.yaml if needed const configStatus = await this.createConfig(openspecPath, extendMode); + // Persist an explicit Copilot cloud decision so `openspec update` (which + // never prompts) honors it. Best-effort: a config-write failure must not + // fail an otherwise-successful init. + if (copilotDecision.persist !== undefined) { + try { + await persistCopilotCloudOptIn(projectPath, copilotDecision.persist); + } catch { + // Non-fatal: the files (if any) were still written correctly. + } + } + + // An explicit opt-out means "no cloud files here": clean up any that a + // previous run (or an older OpenSpec) generated. Only OpenSpec-managed + // files are removed — a user-customized file is preserved. + let copilotRemoved = 0; + if (copilotDecision.optedOut) { + try { + copilotRemoved = await removeCopilotCloudFiles(projectPath); + } catch { + // Non-fatal: removal targets files from a prior run; a failure here + // just leaves them for the next `openspec update` to clean up. + } + } + + // Report the cloud outcome from what is actually on disk after the write, + // not from the decision alone: writing over a user-owned file is a no-op, + // and the alternate-agent path can remove a managed file — so list only + // managed files that exist, and separately flag any left-untouched ones. + const copilotSucceeded = [...results.createdTools, ...results.refreshedTools].some( + (tool) => tool.value === 'github-copilot' + ); + const wroteCloud = copilotDecision.write && copilotSucceeded; + const copilotPresent = wroteCloud ? await listManagedCloudFiles(projectPath) : []; + const copilotCollisions = wroteCloud ? await findUnmanagedCloudFiles(projectPath) : []; + // Display success message - this.displaySuccessMessage(projectPath, validatedTools, results, configStatus); + this.displaySuccessMessage(projectPath, validatedTools, results, configStatus, { + write: copilotDecision.write, + skippedUndecided: copilotDecision.skippedUndecided, + present: copilotPresent, + collisions: copilotCollisions, + removed: copilotRemoved, + }); if (results.failedTools.length > 0) { throw new Error( `OpenSpec setup failed for: ${results.failedTools.map((tool) => tool.name).join(', ')}` @@ -278,6 +346,73 @@ export class InitCommand { return isInteractive({ interactive: this.interactiveOption }); } + /** + * Decide whether to generate GitHub Copilot cloud files, and whether to + * persist that decision. Precedence: + * 1. `--copilot-cloud` / `--no-copilot-cloud` flag (explicit this run) + * 2. persisted opt-in in config.yaml + * 3. managed files already present (migration for pre-opt-in projects) + * 4. interactive confirm (default No) + * 5. non-interactive with no signal: skip, and don't persist a default + * + * @returns `write` — generate the files this run; `persist` — value to write + * back to config (undefined = leave config untouched); `optedOut` — the user + * explicitly declined, so any already-generated managed files should be + * removed; `skippedUndecided` — selected but no signal and couldn't ask, so + * the caller can hint that the opt-in exists. + */ + private async resolveCopilotCloudDecision( + projectPath: string, + tools: ValidatedInitTool[] + ): Promise<{ write: boolean; persist?: boolean; optedOut: boolean; skippedUndecided: boolean }> { + const copilotSelected = tools.some((tool) => tool.value === 'github-copilot'); + if (!copilotSelected) { + // A flag that can't apply is a likely mistake — say so rather than no-op. + if (this.copilotCloudOption !== undefined) { + console.log( + chalk.yellow( + '--copilot-cloud/--no-copilot-cloud was ignored because the github-copilot tool was not selected.' + ) + ); + } + return { write: false, optedOut: false, skippedUndecided: false }; + } + + if (this.copilotCloudOption !== undefined) { + return { + write: this.copilotCloudOption, + persist: this.copilotCloudOption, + optedOut: !this.copilotCloudOption, + skippedUndecided: false, + }; + } + + const persistedOptIn = readCopilotCloudOptIn(projectPath); + if (typeof persistedOptIn === 'boolean') { + return { write: persistedOptIn, optedOut: !persistedOptIn, skippedUndecided: false }; + } + + if (await hasExistingManagedCloudFiles(projectPath)) { + return { write: true, optedOut: false, skippedUndecided: false }; + } + + if (this.canPromptInteractively()) { + const { confirm } = await import('@inquirer/prompts'); + const answer = await confirm({ + message: + 'Set up GitHub Copilot cloud coding-agent files? This is for the GitHub-hosted ' + + 'Copilot coding agent (github.com), not Copilot in your editor. It writes two files: ' + + '.github/workflows/copilot-setup-steps.yml and .github/agents/openspec.agent.md.', + default: false, + }); + return { write: answer, persist: answer, optedOut: !answer, skippedUndecided: false }; + } + + // Non-interactive with no explicit signal: don't write, and leave the + // decision unpersisted so a later interactive run can still prompt. + return { write: false, optedOut: false, skippedUndecided: true }; + } + private resolveProfileOverride(): Profile | undefined { if (this.profileOverride === undefined) { return undefined; @@ -714,7 +849,8 @@ export class InitCommand { */ private async generateSkillsAndCommands( projectPath: string, - tools: ValidatedInitTool[] + tools: ValidatedInitTool[], + writeCopilotCloud: boolean ): Promise<{ createdTools: typeof tools; refreshedTools: typeof tools; @@ -801,7 +937,7 @@ export class InitCommand { if (shouldReconcileCommandFilesForTool(tool.value, delivery)) { removedCommandCount += await this.removeCommandFiles(projectPath, tool.value); } - if (tool.value === 'github-copilot') { + if (tool.value === 'github-copilot' && writeCopilotCloud) { await writeCopilotCloudFiles(projectPath); } @@ -884,7 +1020,14 @@ export class InitCommand { removedCommandCount: number; removedSkillCount: number; }, - configStatus: 'created' | 'exists' | 'skipped' + configStatus: 'created' | 'exists' | 'skipped', + copilot: { + write: boolean; + skippedUndecided: boolean; + present: string[]; + collisions: string[]; + removed: number; + } ): void { console.log(); console.log( @@ -991,6 +1134,33 @@ export class InitCommand { console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`)); } + // GitHub Copilot cloud files are opt-in — report what is actually on disk: + // list the managed files that now exist (never files we didn't write), flag + // any user-owned file we left untouched, note an opt-out cleanup, or (when + // skipped for want of a signal) say how to turn them on. + const copilotSucceeded = successfulTools.some((tool) => tool.value === 'github-copilot'); + if (copilotSucceeded && copilot.write) { + if (copilot.present.length > 0) { + console.log(`GitHub Copilot cloud files: ${copilot.present.join(', ')}`); + } + if (copilot.collisions.length > 0) { + console.log( + chalk.dim( + `Left your existing ${copilot.collisions.join(' and ')} untouched — add the OpenSpec ` + + `install step by hand so the Copilot cloud agent can run openspec.` + ) + ); + } + } else if (copilotSucceeded && copilot.removed > 0) { + console.log( + chalk.dim(`Removed: ${copilot.removed} Copilot cloud agent file(s) (opted out of cloud files)`) + ); + } else if (copilotSucceeded && copilot.skippedUndecided) { + console.log( + chalk.dim("Skipped GitHub Copilot cloud files (opt-in). Enable with 'openspec init --copilot-cloud'.") + ); + } + // Show manual setup notes for tools that need extra configuration for (const tool of successfulTools) { const setupNote = AI_TOOLS.find((t) => t.value === tool.value)?.setupNote; diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 8469a2f210..922e31505b 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -73,6 +73,16 @@ export const ProjectConfigSchema = z.object({ .string() .optional() .describe('Store id used as the OpenSpec root when no local planning shape exists'), + + // Optional: GitHub Copilot integration preferences. `cloudAgent` is the + // opt-in for generating the Copilot cloud coding-agent files (a GitHub + // Actions workflow + agent file); absent means "not yet decided". + githubCopilot: z + .object({ + cloudAgent: z.boolean().optional(), + }) + .optional() + .describe('GitHub Copilot integration preferences'), }); /** Normalized in-memory shape of a referenced store declaration. */ @@ -366,6 +376,24 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { } } + // Parse githubCopilot preferences (only cloudAgent is recognized today). + if (raw.githubCopilot !== undefined) { + if ( + typeof raw.githubCopilot === 'object' && + raw.githubCopilot !== null && + !Array.isArray(raw.githubCopilot) + ) { + const cloudAgent = (raw.githubCopilot as Record).cloudAgent; + if (typeof cloudAgent === 'boolean') { + config.githubCopilot = { cloudAgent }; + } else if (cloudAgent !== undefined) { + console.warn(`Invalid 'githubCopilot.cloudAgent' field in config (must be a boolean)`); + } + } else { + console.warn(`Invalid 'githubCopilot' field in config (must be an object)`); + } + } + // Return partial config even if some fields failed return Object.keys(config).length > 0 ? (config as ProjectConfig) : null; } catch (error) { diff --git a/src/core/update.ts b/src/core/update.ts index 9985501216..3ac02feb44 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -71,7 +71,7 @@ import { shouldRemoveSkillsForTool, } from './command-surface.js'; import { writeSharedSkillTarget } from './shared-skill-target.js'; -import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles } from './github-copilot/cloud-agent.js'; +import { includesGitHubCopilot, writeCopilotCloudFiles, removeCopilotCloudFiles, isCopilotCloudEnabled, readCopilotCloudOptIn, findUnmanagedCloudFiles } from './github-copilot/cloud-agent.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -486,7 +486,43 @@ export class UpdateCommand { private async syncCopilotCloudFiles(projectPath: string, configuredTools: string[]): Promise { try { if (includesGitHubCopilot(configuredTools)) { - await writeCopilotCloudFiles(projectPath); + // Cloud files are opt-in (see cloud-agent.ts). `update` never prompts, + // so it only refreshes files the user has already opted into (via + // `openspec init` or a `githubCopilot.cloudAgent: true` config), or that + // a pre-opt-in project already has. Opting in is a deliberate init/config + // step, never a silent side effect of running update. + if (await isCopilotCloudEnabled(projectPath)) { + await writeCopilotCloudFiles(projectPath); + const collisions = await findUnmanagedCloudFiles(projectPath); + if (collisions.length > 0) { + console.log( + chalk.dim( + `Left your existing ${collisions.join(' and ')} untouched — add the OpenSpec ` + + `install step by hand so the Copilot cloud agent can run openspec.` + ) + ); + } + return; + } + + // Explicit opt-out (githubCopilot.cloudAgent: false) means "not here": + // remove any managed files a prior opt-in left behind (customized files + // are preserved). If the user simply never decided, stay quiet unless + // we're at an interactive terminal, where a one-line hint aids discovery. + if (readCopilotCloudOptIn(projectPath) === false) { + const removed = await removeCopilotCloudFiles(projectPath); + if (removed > 0) { + console.log( + chalk.dim(`Removed: ${removed} Copilot cloud agent file(s) (opted out of cloud files)`) + ); + } + } else if (isInteractive()) { + console.log( + chalk.dim( + "GitHub Copilot cloud coding-agent files are available (opt-in). Enable with 'openspec init --copilot-cloud'." + ) + ); + } return; } diff --git a/test/core/github-copilot-cloud-agent.test.ts b/test/core/github-copilot-cloud-agent.test.ts index 70891c2d6f..863bff8270 100644 --- a/test/core/github-copilot-cloud-agent.test.ts +++ b/test/core/github-copilot-cloud-agent.test.ts @@ -10,6 +10,12 @@ import { 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.'; @@ -547,4 +553,217 @@ describe('GitHub Copilot Cloud Agent', () => { } }); }); + + describe('cloud opt-in', () => { + const CONFIG_WITH_COMMENTS = `schema: spec-driven + +# Project context (optional) +context: | + Tech stack: TypeScript +`; + + async function writeConfig(content: string): Promise { + 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); + }); + }); + }); }); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 216ebff77a..55e611f762 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -183,7 +183,11 @@ describe('InitCommand', () => { process.platform === 'win32' ? 'junction' : 'dir' ); - const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: true, + }); await expect(initCommand.execute(testDir)).rejects.toThrow( 'OpenSpec setup failed for: GitHub Copilot' ); @@ -1096,7 +1100,11 @@ describe('InitCommand', () => { await fs.mkdir(path.dirname(agentsPath), { recursive: true }); await fs.writeFile(agentsPath, 'blocks the generated agent directory'); - const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: true, + }); await expect(initCommand.execute(testDir)).rejects.toThrow( 'OpenSpec setup failed for: GitHub Copilot' ); @@ -1106,6 +1114,57 @@ describe('InitCommand', () => { 'OpenSpec Setup Incomplete' ); }); + + it('does not write cloud files by default (opt-in) but still installs local Copilot files', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await initCommand.execute(testDir); + + // Local Copilot command files are unaffected by the cloud opt-in. + expect( + await fileExists(path.join(testDir, '.github', 'prompts', 'opsx-explore.prompt.md')) + ).toBe(true); + // Cloud files are NOT written without an explicit opt-in. + await expect( + fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fs.stat(path.join(testDir, '.github', 'agents', 'openspec.agent.md')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + // An undecided run leaves config untouched (no githubCopilot key). + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).not.toContain('githubCopilot'); + }); + + it('writes cloud files and persists the opt-in when --copilot-cloud is passed', async () => { + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: true, + }); + await initCommand.execute(testDir); + + await expect( + fs.readFile(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'), 'utf8') + ).resolves.toContain('copilot-setup-steps:'); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('githubCopilot:'); + expect(config).toContain('cloudAgent: true'); + }); + + it('persists an explicit opt-out and writes no cloud files with --no-copilot-cloud', async () => { + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: false, + }); + await initCommand.execute(testDir); + + await expect( + fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: false'); + }); }); }); @@ -1326,6 +1385,93 @@ describe('InitCommand - profile and detection features', () => { expect(githubCopilot?.preSelected).toBe(true); }); + it('interactive init: confirming the cloud prompt writes files and persists the opt-in', async () => { + searchableMultiSelectMock.mockResolvedValue(['github-copilot']); + confirmMock.mockImplementation(({ message }: { message: string }) => + Promise.resolve(String(message).includes('Copilot cloud coding-agent')) + ); + + const initCommand = new InitCommand({}); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + await initCommand.execute(testDir); + + expect( + await fileExists(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).toBe(true); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: true'); + expect(confirmMock).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('copilot-setup-steps.yml') }) + ); + }); + + it('interactive init: declining the cloud prompt writes no cloud files but keeps local ones', async () => { + searchableMultiSelectMock.mockResolvedValue(['github-copilot']); + confirmMock.mockResolvedValue(false); + + const initCommand = new InitCommand({}); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + await initCommand.execute(testDir); + + await expect( + fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + // Local Copilot prompt files are unaffected by the cloud decision. + expect( + await fileExists(path.join(testDir, '.github', 'prompts', 'opsx-explore.prompt.md')) + ).toBe(true); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: false'); + }); + + it('re-init with --no-copilot-cloud removes previously generated managed cloud files', async () => { + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + expect(await fileExists(setupStepsPath)).toBe(true); + + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: false }).execute(testDir); + + expect(await fileExists(setupStepsPath)).toBe(false); + const config = await fs.readFile(path.join(testDir, 'openspec', 'config.yaml'), 'utf8'); + expect(config).toContain('cloudAgent: false'); + }); + + it('re-init without a flag honors the persisted opt-in', async () => { + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + await fs.rm(setupStepsPath, { force: true }); + + // No flag this run: the persisted cloudAgent: true must drive the write. + await new InitCommand({ tools: 'github-copilot', force: true }).execute(testDir); + + expect(await fileExists(setupStepsPath)).toBe(true); + }); + + it('warns when --copilot-cloud is passed but github-copilot is not selected', async () => { + await new InitCommand({ tools: 'claude', force: true, copilotCloud: true }).execute(testDir); + + const out = vi.mocked(console.log).mock.calls.flat().join('\n'); + expect(out).toContain('was ignored because the github-copilot tool was not selected'); + }); + + it('opting in over a user-owned cloud file never claims that file was written', async () => { + const setupRel = path.join('.github', 'workflows', 'copilot-setup-steps.yml'); + const agentRel = path.join('.github', 'agents', 'openspec.agent.md'); + const setupStepsPath = path.join(testDir, setupRel); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, 'name: my own workflow\n'); + + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + + const out = vi.mocked(console.log).mock.calls.flat().join('\n'); + // Only the agent file was actually written; the workflow was left untouched. + expect(out).toContain(`GitHub Copilot cloud files: ${agentRel}`); + expect(out).not.toContain(`cloud files: ${setupRel}`); + expect(out).toContain(`Left your existing ${setupRel} untouched`); + // And the user's own file is preserved verbatim. + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('name: my own workflow\n'); + }); + it('should respect custom profile from global config', async () => { saveGlobalConfig({ featureFlags: {}, diff --git a/test/core/project-config.test.ts b/test/core/project-config.test.ts index 285caca0a5..2adbdf9ad6 100644 --- a/test/core/project-config.test.ts +++ b/test/core/project-config.test.ts @@ -182,6 +182,56 @@ operations: ); }); + it('should parse githubCopilot.cloudAgent', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +githubCopilot: + cloudAgent: true +` + ); + + expect(readProjectConfig(tempDir)?.githubCopilot?.cloudAgent).toBe(true); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should warn on a non-boolean cloudAgent and keep the rest of the config', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +githubCopilot: + cloudAgent: "yes" +` + ); + + const config = readProjectConfig(tempDir); + expect(config?.schema).toBe('spec-driven'); + expect(config?.githubCopilot).toBeUndefined(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'githubCopilot.cloudAgent' field") + ); + }); + + it('should warn on a non-object githubCopilot field', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +githubCopilot: true +` + ); + + expect(readProjectConfig(tempDir)?.schema).toBe('spec-driven'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'githubCopilot' field") + ); + }); + it('should ignore malformed operation entries independently', () => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 78eecd029f..3a40b7e97c 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -4,7 +4,7 @@ import { InitCommand } from '../../src/core/init.js'; import { FileSystemUtils } from '../../src/utils/file-system.js'; import { OPENSPEC_MARKERS } from '../../src/core/config.js'; import type { GlobalConfig } from '../../src/core/global-config.js'; -import { generateCopilotSetupSteps } from '../../src/core/github-copilot/cloud-agent.js'; +import { generateCopilotSetupSteps, persistCopilotCloudOptIn } from '../../src/core/github-copilot/cloud-agent.js'; import path from 'path'; import fs from 'fs/promises'; import os from 'os'; @@ -104,7 +104,11 @@ describe('UpdateCommand', () => { }); it('should remove generated Copilot cloud files when no tools are configured', async () => { - const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + const initCommand = new InitCommand({ + tools: 'github-copilot', + force: true, + copilotCloud: true, + }); await initCommand.execute(testDir); await fs.rm(path.join(testDir, '.github', 'skills'), { recursive: true, force: true }); await fs.rm(path.join(testDir, '.github', 'prompts'), { recursive: true, force: true }); @@ -1708,7 +1712,7 @@ metadata: }); it('should create GitHub Copilot cloud files when github-copilot is up to date', async () => { - const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + const initCommand = new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }); await initCommand.execute(testDir); const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); @@ -1723,7 +1727,7 @@ metadata: }); it('should refresh managed legacy Copilot files and preserve custom files during force update', async () => { - const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + const initCommand = new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }); await initCommand.execute(testDir); const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); @@ -1744,10 +1748,71 @@ metadata: await expect(fs.readFile(agentPath, 'utf8')).resolves.toBe(customAgent); }); - it('should warn when GitHub Copilot cloud files cannot be synchronized', async () => { + it('should not create cloud files on update when Copilot is configured but not opted in', async () => { + // Seed a configured github-copilot WITHOUT opting into cloud files. + const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); + await initCommand.execute(testDir); + + await updateCommand.execute(testDir); + + await expect( + fs.stat(path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + await expect( + fs.stat(path.join(testDir, '.github', 'agents', 'openspec.agent.md')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('should refresh pre-existing managed cloud files even without a config opt-in (migration)', async () => { + // A project created before the opt-in existed: managed files are present + // but config carries no githubCopilot key. Update must keep them current. const initCommand = new InitCommand({ tools: 'github-copilot', force: true }); await initCommand.execute(testDir); + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + const legacySetupSteps = generateCopilotSetupSteps().replace( + /^# Generated by OpenSpec for GitHub Copilot coding agent support\.\n\n/, + '' + ); + await fs.mkdir(path.dirname(setupStepsPath), { recursive: true }); + await fs.writeFile(setupStepsPath, legacySetupSteps); + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe(generateCopilotSetupSteps()); + }); + + it('should remove managed cloud files on update when the user has opted out', async () => { + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + const agentPath = path.join(testDir, '.github', 'agents', 'openspec.agent.md'); + expect(await fs.stat(setupStepsPath)).toBeTruthy(); + + await persistCopilotCloudOptIn(testDir, false); // explicit opt-out + + await new UpdateCommand({ force: true }).execute(testDir); + + await expect(fs.stat(setupStepsPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.stat(agentPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('should preserve a customized cloud file on update even when opted out', async () => { + await new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }).execute(testDir); + const setupStepsPath = path.join(testDir, '.github', 'workflows', 'copilot-setup-steps.yml'); + await fs.writeFile(setupStepsPath, 'name: my own workflow\n'); + + await persistCopilotCloudOptIn(testDir, false); // explicit opt-out + + await new UpdateCommand({ force: true }).execute(testDir); + + // A user-customized file is never removed, even on opt-out. + await expect(fs.readFile(setupStepsPath, 'utf8')).resolves.toBe('name: my own workflow\n'); + }); + + it('should warn when GitHub Copilot cloud files cannot be synchronized', async () => { + const initCommand = new InitCommand({ tools: 'github-copilot', force: true, copilotCloud: true }); + await initCommand.execute(testDir); + const agentsPath = path.join(testDir, '.github', 'agents'); await fs.rm(agentsPath, { recursive: true, force: true }); await fs.writeFile(agentsPath, 'blocks the generated agent directory');