From f2012b83ae4b64ce68d9edd4ce23ec9b8573045a Mon Sep 17 00:00:00 2001 From: Lukas Oppermann Date: Mon, 19 Oct 2026 12:46:48 +0200 Subject: [PATCH 1/2] fix(starter): recover from missing Copilot CLI during setup Install a missing CLI, keep app dependency installation independent of Replica setup, and print a final setup summary with recovery guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 672ac438-2a2b-4b12-8aac-b964632eebde --- docs/getting-started.md | 2 +- scripts/setup-replica-plugin.test.mjs | 222 ++++++++++++++++++++--- starter/.github/github-app.yml | 2 +- starter/README.md | 12 +- starter/scripts/setup-replica-plugin.mjs | 77 +++++++- 5 files changed, 281 insertions(+), 34 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index 0ea4497..e67f538 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -50,7 +50,7 @@ copilot plugin install replica-prototype@replica You can also run this manually in a terminal with Copilot CLI available. -If the `copilot` command is unavailable, follow the [Copilot CLI installation instructions](https://docs.github.com/en/copilot/get-started/cli-quickstart) first. +Repositories using the current Replica starter setup script automatically run `npm install -g @github/copilot` if `copilot` is missing, then install the Replica plugin. This needs npm and a writable global installation directory. Setup does not use sudo or change your shell configuration. Dependency installation with `npm ci` still runs if CLI or plugin setup fails; this does not mean the canvas is ready. Check the app's setup output for the failure reason and recovery steps. If automatic installation fails, or your repository does not use that script, follow the [Copilot CLI installation instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli), then rerun `node scripts/setup-replica-plugin.mjs` in repositories that have it. In the Copilot app sidebar, open **Customize**, then **Installed**. Find the `replica-prototype` plugin and enable it. Also find its `replica-prototype` canvas and enable that extension; both the plugin and extension must be enabled. diff --git a/scripts/setup-replica-plugin.test.mjs b/scripts/setup-replica-plugin.test.mjs index 64c63ff..fc9917a 100644 --- a/scripts/setup-replica-plugin.test.mjs +++ b/scripts/setup-replica-plugin.test.mjs @@ -1,7 +1,14 @@ import assert from "node:assert/strict" import { spawnSync } from "node:child_process" -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs" import { dirname, join } from "node:path" import { test } from "node:test" import { fileURLToPath } from "node:url" @@ -19,36 +26,61 @@ const marketplaceList = ["plugin", "marketplace", "list", "--json"] const install = ["plugin", "install", "replica-prototype@replica"] const enable = ["plugin", "enable", "replica-prototype@replica"] -function run(t, steps, { missingCli = false } = {}) { - const directory = mkdtempSync(join(tmpdir(), "replica-plugin-test-")) +function run( + t, + steps, + { missingCli = false, missingNpm = false, cliMode = 0o755, startup = false } = {}, +) { + const directory = mkdtempSync(join(root, ".replica-plugin-test-")) t.after(() => rmSync(directory, { recursive: true, force: true })) const state = join(directory, "state.json") + const prefix = join(directory, "npm global") writeFileSync(state, JSON.stringify(steps)) - if (!missingCli) { - writeFileSync( - join(directory, "copilot"), - `#!${process.execPath} + const executable = `#!${process.execPath} const assert = require("node:assert/strict") const fs = require("node:fs") +const path = require("node:path") +assert.equal(process.env.PATH, path.dirname(process.env.REPLICA_TEST_STATE)) const steps = JSON.parse(fs.readFileSync(process.env.REPLICA_TEST_STATE, "utf8")) const step = steps.shift() assert.ok(step, "Unexpected extra CLI call") +assert.equal(path.basename(process.argv[1]), step.command ?? "copilot") assert.deepEqual(process.argv.slice(2), step.args) fs.writeFileSync(process.env.REPLICA_TEST_STATE, JSON.stringify(steps)) if (step.error) { console.error(step.error) process.exit(1) } -console.log(step.raw ?? JSON.stringify(step.result ?? [])) -`, - { mode: 0o755 }, - ) +if (step.installCli) { + const bin = path.join(process.env.REPLICA_TEST_PREFIX, "bin") + fs.mkdirSync(bin, { recursive: true }) + fs.copyFileSync(__filename, path.join(bin, "copilot")) +} +console.log(step.raw ?? (step.command === "npm" && step.args[0] === "prefix" + ? process.env.REPLICA_TEST_PREFIX + : JSON.stringify(step.result ?? []))) +` + for (const command of ["copilot", "npm"]) { + if ((command === "copilot" && missingCli) || (command === "npm" && missingNpm)) continue + writeFileSync(join(directory, command), executable, { + mode: command === "copilot" ? cliMode : 0o755, + }) + } + let command = process.execPath + let args = [script] + if (startup) { + mkdirSync(join(directory, "scripts")) + copyFileSync(script, join(directory, "scripts/setup-replica-plugin.mjs")) + symlinkSync(process.execPath, join(directory, "node")) + const config = readFileSync(join(root, "starter/.github/github-app.yml"), "utf8") + command = "/bin/sh" + args = ["-c", config.match(/^\s+command: (.+)$/m)[1]] } - const result = spawnSync(process.execPath, [script], { + const result = spawnSync(command, args, { cwd: directory, encoding: "utf8", timeout: 10_000, - env: { PATH: directory, REPLICA_TEST_STATE: state }, + env: { PATH: directory, REPLICA_TEST_STATE: state, REPLICA_TEST_PREFIX: prefix }, }) assert.ifError(result.error) assert.deepEqual(JSON.parse(readFileSync(state, "utf8")), [], result.stderr) @@ -124,16 +156,85 @@ test("direct, external and duplicate installations are left unchanged", (t) => { } }) -test("missing CLI and malformed lists report setup failure with recovery guidance", (t) => { - for (const options of [ - { steps: [], missingCli: true }, - { steps: [{ args: pluginList, raw: "not json" }] }, - { steps: [{ args: pluginList, result: {} }] }, +test("startup installs a missing CLI outside PATH, the plugin and app dependencies", (t) => { + const result = run( + t, + [ + { command: "npm", args: ["install", "-g", "@github/copilot"], installCli: true }, + { command: "npm", args: ["prefix", "--global"] }, + { args: pluginList, result: [] }, + { args: marketplaceList, result: [] }, + { args: ["plugin", "marketplace", "add", "github/replica"] }, + { args: install }, + { args: pluginList, result: [installed] }, + { command: "npm", args: ["ci"] }, + ], + { missingCli: true, startup: true }, + ) + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /future sessions/) + assert.match(result.stdout, /both the Replica plugin and the Replica extension/) + assert.equal(result.stderr, "") +}) + +test("a missing CLI and missing npm report how to install the prerequisite", (t) => { + const result = run(t, [], { missingCli: true, missingNpm: true }) + assert.equal(result.status, 1) + assert.match(result.stderr, /npm/) + assert.match(result.stderr, /install/i) + assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/) +}) + +test("a failed npm install preserves its cause and stops setup", (t) => { + const result = run( + t, + [ + { + command: "npm", + args: ["install", "-g", "@github/copilot"], + error: "EACCES: global installation denied", + }, + ], + { missingCli: true }, + ) + assert.equal(result.status, 1) + assert.match(result.stderr, /EACCES: global installation denied/) + assert.match(result.stderr, /npm/) + assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/) +}) + +test("an existing CLI without execute permission is not reinstalled", (t) => { + const result = run(t, [], { cliMode: 0o644 }) + assert.equal(result.status, 1) + assert.match(result.stderr, /EACCES/) + assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/) +}) + +test("npm success without a valid prefix or runnable CLI still fails setup", (t) => { + for (const prefixResult of [{}, { raw: "" }, { raw: "relative/prefix" }]) { + const result = run( + t, + [ + { command: "npm", args: ["install", "-g", "@github/copilot"] }, + { command: "npm", args: ["prefix", "--global"], ...prefixResult }, + ], + { missingCli: true }, + ) + assert.equal(result.status, 1) + assert.match(result.stderr, /Copilot|copilot|prefix/) + assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/) + } +}) + +test("malformed plugin lists report setup failure without installation or enable advice", (t) => { + for (const step of [ + { args: pluginList, raw: "not json" }, + { args: pluginList, result: {} }, ]) { - const result = run(t, options.steps, options) + const result = run(t, [step]) assert.equal(result.status, 1) assert.match(result.stderr, /Replica plugin setup failed/) - assert.match(result.stderr, /Customization, select Installed/) + assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/) } }) @@ -162,6 +263,7 @@ test("CLI failures stop setup without retrying or hiding the cause", (t) => { assert.equal(result.status, 1) assert.match(result.stderr, /CLI access denied/) assert.equal(result.stdout, "") + assert.doesNotMatch(result.stderr, /Customization, select Installed|future sessions/) } }) @@ -174,11 +276,85 @@ test("setup rejects a missing or disabled plugin after CLI success", (t) => { ]) assert.equal(result.status, 1) assert.match(result.stderr, /did not report the Replica plugin installed and enabled/) + assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/) } }) -test("session startup checks the plugin before installing dependencies", () => { +test("startup is configured to run when a session is created", () => { const config = readFileSync(join(root, "starter/.github/github-app.yml"), "utf8") - assert.match(config, /command: node scripts\/setup-replica-plugin\.mjs && npm ci/) assert.match(config, /triggers:\n\s+- session\.create/) }) + +test("startup installs dependencies after successful or failed plugin setup", (t) => { + for (const plugin of [ + { args: pluginList, result: [installed] }, + { args: pluginList, error: "CLI access denied" }, + ]) { + const result = run( + t, + [plugin, { command: "npm", args: ["ci"], raw: "Dependencies finished" }], + { startup: true }, + ) + assert.equal(result.status, 0, result.stderr) + const summary = result.stdout.slice(result.stdout.indexOf("Setup summary")) + assert.match(summary, /App dependencies: installed/) + assert.ok(result.stdout.indexOf("Setup summary") > result.stdout.indexOf("Dependencies finished")) + if (plugin.error) { + assert.match(summary, /Replica canvas: unavailable/) + assert.match(summary, /CLI access denied/) + assert.match(summary, /node scripts\/setup-replica-plugin\.mjs/) + } else { + assert.match(summary, /Replica plugin: installed and enabled/) + assert.match(summary, /fresh Copilot session/) + assert.doesNotMatch(summary, /canvas: ready/i) + } + if (plugin.error) assert.match(result.stderr, /CLI access denied/) + } +}) + +test("startup installs dependencies even when CLI installation fails", (t) => { + const result = run( + t, + [ + { + command: "npm", + args: ["install", "-g", "@github/copilot"], + error: "EACCES: global installation denied", + }, + { command: "npm", args: ["ci"] }, + ], + { missingCli: true, startup: true }, + ) + assert.equal(result.status, 0, result.stderr) + assert.match(result.stderr, /EACCES: global installation denied/) + const summary = result.stdout.slice(result.stdout.indexOf("Setup summary")) + assert.match(summary, /App dependencies: installed/) + assert.match(summary, /Replica canvas: unavailable/) + assert.match(summary, /Copilot CLI installation failed/) + assert.match(summary, /EACCES: global installation denied/) +}) + +test("startup still fails when dependency installation fails", (t) => { + for (const plugin of [ + { args: pluginList, result: [installed] }, + { args: pluginList, error: "CLI access denied" }, + ]) { + const result = run( + t, + [ + plugin, + { command: "npm", args: ["ci"], error: "Dependency installation failed" }, + ], + { startup: true }, + ) + assert.equal(result.status, 1) + assert.match(result.stderr, /Dependency installation failed/) + const summary = result.stdout.slice(result.stdout.indexOf("Setup summary")) + assert.match(summary, /App dependencies: failed/) + assert.match(summary, /npm ci/) + assert.match( + summary, + plugin.error ? /Replica canvas: unavailable/ : /Replica plugin: installed and enabled/, + ) + } +}) diff --git a/starter/.github/github-app.yml b/starter/.github/github-app.yml index 14d5b9b..d966b21 100644 --- a/starter/.github/github-app.yml +++ b/starter/.github/github-app.yml @@ -1,6 +1,6 @@ scripts: - name: Startup - command: node scripts/setup-replica-plugin.mjs && npm ci + command: node scripts/setup-replica-plugin.mjs --startup triggers: - session.create - name: Dev diff --git a/starter/README.md b/starter/README.md index d6bb37a..6fcb19f 100644 --- a/starter/README.md +++ b/starter/README.md @@ -205,16 +205,20 @@ The preview's browser origin differs from the original address. Original-origin After you review and accept the Copilot app's repository configuration, session setup checks the Replica plugin before installing app dependencies. An enabled installation needs one local CLI check and no network requests. +If `copilot` is missing, setup runs `npm install -g @github/copilot`, then uses npm's global installation path without requiring a shell restart or changing `PATH`. It does not reinstall an existing CLI, use sudo, install Node.js, or change shell configuration. The npm global installation directory must be writable. Setup enables a disabled marketplace installation or installs a missing one from `github/replica`. It preserves an existing marketplace's branch or release -pin and stops on conflicting sources or CLI errors. For an organization-required +pin and stops plugin setup on conflicting sources or CLI errors. For an organization-required pin, register the approved marketplace before setup. Outside automatic session setup, run `node scripts/setup-replica-plugin.mjs` -manually. The script requires Node.js and Copilot CLI and uses no agent. +manually. The script requires Node.js and npm and uses no agent. Installation commands run without interactive input and have bounded timeouts; -approval or policy failures need user action. A failed plugin check stops the -startup command before `npm ci`; resolve it and rerun setup. +approval or policy failures need user action. Session startup always attempts `npm ci`, even if CLI installation or plugin setup fails. If dependencies install successfully, startup completes without a hard error, but the Replica canvas remains unavailable until plugin setup succeeds. Dependency installation failures still fail startup. + +Session startup runs `node scripts/setup-replica-plugin.mjs --startup`. After `npm ci` finishes, the script prints a final summary of dependency installation and Replica plugin setup, including failure reasons and recovery commands. Plugin installation success does not prove that the current session loaded the canvas; the summary includes the separate activation steps. + +Plugin failures also print the cause and recovery guidance when they occur. Repository setup scripts have no documented way to post a chat message; agents do not automatically receive this output. Open the app's setup output to diagnose the failure, then rerun `node scripts/setup-replica-plugin.mjs` after resolving it. Running that script without `--startup` only sets up the plugin and still exits nonzero on failure. For manual installation, updates or source changes, follow the [plugin guide](https://github.com/github/replica/blob/main/.github/extensions/replica-prototype/README.md#install). Keep only one Replica provider active. diff --git a/starter/scripts/setup-replica-plugin.mjs b/starter/scripts/setup-replica-plugin.mjs index 7a99717..23cf275 100644 --- a/starter/scripts/setup-replica-plugin.mjs +++ b/starter/scripts/setup-replica-plugin.mjs @@ -1,18 +1,45 @@ import { execFileSync } from "node:child_process" +import { isAbsolute, join } from "node:path" const pluginName = "replica-prototype" const pluginId = `${pluginName}@replica` +let cliExecutable = "copilot" const enableHelp = "Open Customization, select Installed in the top-left bar, and enable both the Replica plugin and the Replica extension. Start a fresh Copilot session if the canvas is still missing." function cli(args, timeout = 10_000) { - return execFileSync("copilot", ["plugin", ...args], { + return execFileSync(cliExecutable, ["plugin", ...args], { encoding: "utf8", timeout, stdio: ["ignore", "pipe", "pipe"], }) } +function installCli() { + console.log("Copilot CLI was not found. Installing it globally with npm.") + try { + execFileSync("npm", ["install", "-g", "@github/copilot"], { + encoding: "utf8", + timeout: 180_000, + stdio: ["ignore", "pipe", "pipe"], + }) + const prefix = execFileSync("npm", ["prefix", "--global"], { + encoding: "utf8", + timeout: 10_000, + stdio: ["ignore", "pipe", "pipe"], + }).trim() + if (!isAbsolute(prefix)) { + throw new Error("npm did not return an absolute global installation path.") + } + cliExecutable = join(prefix, "bin", "copilot") + } catch (error) { + throw new Error( + `Copilot CLI installation failed: ${error.message}. Check that npm is available and its global installation directory is writable, or install Copilot CLI manually, then rerun setup.`, + { cause: error }, + ) + } +} + function list(args) { const entries = JSON.parse(cli([...args, "list", "--json"])) if (!Array.isArray(entries)) { @@ -36,7 +63,14 @@ function installedPlugin() { } function setup() { - const installed = installedPlugin() + let installed + try { + installed = installedPlugin() + } catch (error) { + if (error.code !== "ENOENT" || error.path !== "copilot") throw error + installCli() + installed = installedPlugin() + } if (installed?.enabled) return if (!installed) { @@ -67,12 +101,45 @@ function setup() { console.log(`Replica plugin enabled for future sessions. ${enableHelp}`) } +const startup = process.argv.includes("--startup") +let pluginError try { setup() } catch (error) { - console.error(`Replica plugin setup failed: ${error.message}`) + pluginError = error + console.error(`Warning: Replica plugin setup failed: ${error.message}`) console.error( - `Check the session setup output and CLI access or organization policy. ${enableHelp}`, + "The Replica canvas is not ready. App dependencies can still be installed with npm ci. Resolve the error above, then rerun node scripts/setup-replica-plugin.mjs. If Replica is absent from Customization > Installed, installation has not completed; enabling it there cannot fix this failure.", ) - process.exitCode = 1 + if (!startup) process.exitCode = 1 +} + +if (startup) { + let dependencyError + try { + execFileSync("npm", ["ci"], { + stdio: ["ignore", "inherit", "inherit"], + }) + } catch (error) { + dependencyError = error + console.error(`App dependency installation failed: ${error.message}`) + process.exitCode = error.status || 1 + } + + console.log("\nSetup summary") + console.log(`App dependencies: ${dependencyError ? "failed" : "installed"}`) + if (dependencyError) { + console.log(`Reason: ${dependencyError.message}`) + console.log("Next step: resolve the npm error above, then rerun npm ci.") + } + if (pluginError) { + console.log("Replica canvas: unavailable") + console.log(`Reason: ${pluginError.message}`) + console.log( + "Next step: resolve the Replica setup error above, then run node scripts/setup-replica-plugin.mjs.", + ) + } else { + console.log("Replica plugin: installed and enabled") + console.log(`Canvas activation: ${enableHelp}`) + } } From 851a83b0ebee79c7c4c4e29727d370f6637d7774 Mon Sep 17 00:00:00 2001 From: Lukas Oppermann Date: Mon, 19 Oct 2026 13:05:19 +0200 Subject: [PATCH 2/2] fix(starter): reuse global Copilot CLI outside PATH Check npm's existing global installation before downloading the CLI again on subsequent startups. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 672ac438-2a2b-4b12-8aac-b964632eebde --- scripts/setup-replica-plugin.test.mjs | 47 +++++++++++++++++++----- starter/README.md | 2 +- starter/scripts/setup-replica-plugin.mjs | 21 +++++++---- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/scripts/setup-replica-plugin.test.mjs b/scripts/setup-replica-plugin.test.mjs index fc9917a..f7644e1 100644 --- a/scripts/setup-replica-plugin.test.mjs +++ b/scripts/setup-replica-plugin.test.mjs @@ -29,7 +29,7 @@ const enable = ["plugin", "enable", "replica-prototype@replica"] function run( t, steps, - { missingCli = false, missingNpm = false, cliMode = 0o755, startup = false } = {}, + { missingCli = false, missingNpm = false, cliMode = 0o755, startup = false, repeat = 1 } = {}, ) { const directory = mkdtempSync(join(root, ".replica-plugin-test-")) t.after(() => rmSync(directory, { recursive: true, force: true })) @@ -76,13 +76,17 @@ console.log(step.raw ?? (step.command === "npm" && step.args[0] === "prefix" command = "/bin/sh" args = ["-c", config.match(/^\s+command: (.+)$/m)[1]] } - const result = spawnSync(command, args, { - cwd: directory, - encoding: "utf8", - timeout: 10_000, - env: { PATH: directory, REPLICA_TEST_STATE: state, REPLICA_TEST_PREFIX: prefix }, - }) - assert.ifError(result.error) + let result + for (let attempt = 0; attempt < repeat; attempt++) { + result = spawnSync(command, args, { + cwd: directory, + encoding: "utf8", + timeout: 10_000, + env: { PATH: directory, REPLICA_TEST_STATE: state, REPLICA_TEST_PREFIX: prefix }, + }) + assert.ifError(result.error) + if (attempt < repeat - 1) assert.equal(result.status, 0, result.stderr) + } assert.deepEqual(JSON.parse(readFileSync(state, "utf8")), [], result.stderr) return result } @@ -160,8 +164,8 @@ test("startup installs a missing CLI outside PATH, the plugin and app dependenci const result = run( t, [ - { command: "npm", args: ["install", "-g", "@github/copilot"], installCli: true }, { command: "npm", args: ["prefix", "--global"] }, + { command: "npm", args: ["install", "-g", "@github/copilot"], installCli: true }, { args: pluginList, result: [] }, { args: marketplaceList, result: [] }, { args: ["plugin", "marketplace", "add", "github/replica"] }, @@ -185,10 +189,30 @@ test("a missing CLI and missing npm report how to install the prerequisite", (t) assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/) }) +test("repeated startups reuse the installed global CLI outside PATH without reinstalling", (t) => { + const result = run( + t, + [ + { command: "npm", args: ["prefix", "--global"] }, + { command: "npm", args: ["install", "-g", "@github/copilot"], installCli: true }, + { args: pluginList, result: [installed] }, + { command: "npm", args: ["ci"] }, + { command: "npm", args: ["prefix", "--global"] }, + { args: pluginList, result: [installed] }, + { command: "npm", args: ["ci"] }, + ], + { missingCli: true, startup: true, repeat: 2 }, + ) + assert.equal(result.status, 0, result.stderr) + assert.equal(result.stderr, "") + assert.doesNotMatch(result.stdout, /Installing it globally/) +}) + test("a failed npm install preserves its cause and stops setup", (t) => { const result = run( t, [ + { command: "npm", args: ["prefix", "--global"] }, { command: "npm", args: ["install", "-g", "@github/copilot"], @@ -215,8 +239,10 @@ test("npm success without a valid prefix or runnable CLI still fails setup", (t) const result = run( t, [ - { command: "npm", args: ["install", "-g", "@github/copilot"] }, { command: "npm", args: ["prefix", "--global"], ...prefixResult }, + ...(Object.keys(prefixResult).length === 0 + ? [{ command: "npm", args: ["install", "-g", "@github/copilot"] }] + : []), ], { missingCli: true }, ) @@ -316,6 +342,7 @@ test("startup installs dependencies even when CLI installation fails", (t) => { const result = run( t, [ + { command: "npm", args: ["prefix", "--global"] }, { command: "npm", args: ["install", "-g", "@github/copilot"], diff --git a/starter/README.md b/starter/README.md index 6fcb19f..2e01c85 100644 --- a/starter/README.md +++ b/starter/README.md @@ -205,7 +205,7 @@ The preview's browser origin differs from the original address. Original-origin After you review and accept the Copilot app's repository configuration, session setup checks the Replica plugin before installing app dependencies. An enabled installation needs one local CLI check and no network requests. -If `copilot` is missing, setup runs `npm install -g @github/copilot`, then uses npm's global installation path without requiring a shell restart or changing `PATH`. It does not reinstall an existing CLI, use sudo, install Node.js, or change shell configuration. The npm global installation directory must be writable. +If `copilot` is missing from `PATH`, setup checks npm's global installation path and reuses an existing CLI there. Only if neither location has the CLI does setup run `npm install -g @github/copilot`. This works without a shell restart or changing `PATH`, including on later startups. Setup does not reinstall an existing CLI, use sudo, install Node.js, or change shell configuration. The npm global installation directory must be writable when installation is needed. Setup enables a disabled marketplace installation or installs a missing one from `github/replica`. It preserves an existing marketplace's branch or release pin and stops plugin setup on conflicting sources or CLI errors. For an organization-required diff --git a/starter/scripts/setup-replica-plugin.mjs b/starter/scripts/setup-replica-plugin.mjs index 23cf275..4835298 100644 --- a/starter/scripts/setup-replica-plugin.mjs +++ b/starter/scripts/setup-replica-plugin.mjs @@ -16,13 +16,7 @@ function cli(args, timeout = 10_000) { } function installCli() { - console.log("Copilot CLI was not found. Installing it globally with npm.") try { - execFileSync("npm", ["install", "-g", "@github/copilot"], { - encoding: "utf8", - timeout: 180_000, - stdio: ["ignore", "pipe", "pipe"], - }) const prefix = execFileSync("npm", ["prefix", "--global"], { encoding: "utf8", timeout: 10_000, @@ -32,6 +26,18 @@ function installCli() { throw new Error("npm did not return an absolute global installation path.") } cliExecutable = join(prefix, "bin", "copilot") + try { + return installedPlugin() + } catch (error) { + if (error.code !== "ENOENT" || error.path !== cliExecutable) throw error + } + console.log("Copilot CLI was not found. Installing it globally with npm.") + execFileSync("npm", ["install", "-g", "@github/copilot"], { + encoding: "utf8", + timeout: 180_000, + stdio: ["ignore", "pipe", "pipe"], + }) + return installedPlugin() } catch (error) { throw new Error( `Copilot CLI installation failed: ${error.message}. Check that npm is available and its global installation directory is writable, or install Copilot CLI manually, then rerun setup.`, @@ -68,8 +74,7 @@ function setup() { installed = installedPlugin() } catch (error) { if (error.code !== "ENOENT" || error.path !== "copilot") throw error - installCli() - installed = installedPlugin() + installed = installCli() } if (installed?.enabled) return