Skip to content

Commit 8a74960

Browse files
fix(starter): recover from missing Copilot CLI during setup (#26)
<!-- Use a conventional commit-style title, such as "fix(hub): retain prototype history". --> ## Why <!-- Explain the problem and why this change matters in a short, plain-English paragraph. Link the issue when applicable. --> When the Copilot app cannot find the `copilot` command, Replica setup fails before installing app dependencies. Its recovery message tells users to enable a plugin that was never installed. ## What changes <!-- Describe what becomes different for a first-time reader. Explain necessary terms. Include accurate Mermaid diagrams when they clarify the proposal, using before/after diagrams when helpful. Add screenshots for meaningful UI changes. --> <!-- Omit validation sections, checklists, and routine test/CI summaries such as "CI is green" or "tests passed". Mention only known verification gaps that affect the change, not disclaimers about drafting this description. --> Setup installs a missing CLI globally with `npm install -g @github/copilot`, then uses npm's installation path without changing shell configuration or requiring a restart. Existing CLIs are left untouched. Installation does not use sudo. Startup always attempts `npm ci`, even when CLI installation or Replica setup fails. Afterward, a final summary reports dependency and plugin results, failure reasons, and recovery commands. Only dependency installation failures fail startup; running plugin setup directly still reports failure with a nonzero exit status. Successful plugin installation does not claim the current session loaded the canvas. The summary appears in setup output, not chat. Existing prototype repositories need the updated setup script and startup configuration to adopt this behavior. Real global installation and activation in a fresh Copilot app session remain unverified; automated coverage uses isolated command stand-ins. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 672ac438-2a2b-4b12-8aac-b964632eebde
1 parent ab5358a commit 8a74960

5 files changed

Lines changed: 318 additions & 39 deletions

File tree

‎docs/getting-started.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ copilot plugin install replica-prototype@replica
5050

5151
You can also run this manually in a terminal with Copilot CLI available.
5252

53-
If the `copilot` command is unavailable, follow the [Copilot CLI installation instructions](https://docs.github.com/en/copilot/get-started/cli-quickstart) first.
53+
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.
5454

5555
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.
5656

‎scripts/setup-replica-plugin.test.mjs‎

Lines changed: 231 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import assert from "node:assert/strict"
22
import { spawnSync } from "node:child_process"
3-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
4-
import { tmpdir } from "node:os"
3+
import {
4+
copyFileSync,
5+
mkdirSync,
6+
mkdtempSync,
7+
readFileSync,
8+
rmSync,
9+
symlinkSync,
10+
writeFileSync,
11+
} from "node:fs"
512
import { dirname, join } from "node:path"
613
import { test } from "node:test"
714
import { fileURLToPath } from "node:url"
@@ -19,38 +26,67 @@ const marketplaceList = ["plugin", "marketplace", "list", "--json"]
1926
const install = ["plugin", "install", "replica-prototype@replica"]
2027
const enable = ["plugin", "enable", "replica-prototype@replica"]
2128

22-
function run(t, steps, { missingCli = false } = {}) {
23-
const directory = mkdtempSync(join(tmpdir(), "replica-plugin-test-"))
29+
function run(
30+
t,
31+
steps,
32+
{ missingCli = false, missingNpm = false, cliMode = 0o755, startup = false, repeat = 1 } = {},
33+
) {
34+
const directory = mkdtempSync(join(root, ".replica-plugin-test-"))
2435
t.after(() => rmSync(directory, { recursive: true, force: true }))
2536
const state = join(directory, "state.json")
37+
const prefix = join(directory, "npm global")
2638
writeFileSync(state, JSON.stringify(steps))
27-
if (!missingCli) {
28-
writeFileSync(
29-
join(directory, "copilot"),
30-
`#!${process.execPath}
39+
const executable = `#!${process.execPath}
3140
const assert = require("node:assert/strict")
3241
const fs = require("node:fs")
42+
const path = require("node:path")
43+
assert.equal(process.env.PATH, path.dirname(process.env.REPLICA_TEST_STATE))
3344
const steps = JSON.parse(fs.readFileSync(process.env.REPLICA_TEST_STATE, "utf8"))
3445
const step = steps.shift()
3546
assert.ok(step, "Unexpected extra CLI call")
47+
assert.equal(path.basename(process.argv[1]), step.command ?? "copilot")
3648
assert.deepEqual(process.argv.slice(2), step.args)
3749
fs.writeFileSync(process.env.REPLICA_TEST_STATE, JSON.stringify(steps))
3850
if (step.error) {
3951
console.error(step.error)
4052
process.exit(1)
4153
}
42-
console.log(step.raw ?? JSON.stringify(step.result ?? []))
43-
`,
44-
{ mode: 0o755 },
45-
)
54+
if (step.installCli) {
55+
const bin = path.join(process.env.REPLICA_TEST_PREFIX, "bin")
56+
fs.mkdirSync(bin, { recursive: true })
57+
fs.copyFileSync(__filename, path.join(bin, "copilot"))
58+
}
59+
console.log(step.raw ?? (step.command === "npm" && step.args[0] === "prefix"
60+
? process.env.REPLICA_TEST_PREFIX
61+
: JSON.stringify(step.result ?? [])))
62+
`
63+
for (const command of ["copilot", "npm"]) {
64+
if ((command === "copilot" && missingCli) || (command === "npm" && missingNpm)) continue
65+
writeFileSync(join(directory, command), executable, {
66+
mode: command === "copilot" ? cliMode : 0o755,
67+
})
68+
}
69+
let command = process.execPath
70+
let args = [script]
71+
if (startup) {
72+
mkdirSync(join(directory, "scripts"))
73+
copyFileSync(script, join(directory, "scripts/setup-replica-plugin.mjs"))
74+
symlinkSync(process.execPath, join(directory, "node"))
75+
const config = readFileSync(join(root, "starter/.github/github-app.yml"), "utf8")
76+
command = "/bin/sh"
77+
args = ["-c", config.match(/^\s+command: (.+)$/m)[1]]
78+
}
79+
let result
80+
for (let attempt = 0; attempt < repeat; attempt++) {
81+
result = spawnSync(command, args, {
82+
cwd: directory,
83+
encoding: "utf8",
84+
timeout: 10_000,
85+
env: { PATH: directory, REPLICA_TEST_STATE: state, REPLICA_TEST_PREFIX: prefix },
86+
})
87+
assert.ifError(result.error)
88+
if (attempt < repeat - 1) assert.equal(result.status, 0, result.stderr)
4689
}
47-
const result = spawnSync(process.execPath, [script], {
48-
cwd: directory,
49-
encoding: "utf8",
50-
timeout: 10_000,
51-
env: { PATH: directory, REPLICA_TEST_STATE: state },
52-
})
53-
assert.ifError(result.error)
5490
assert.deepEqual(JSON.parse(readFileSync(state, "utf8")), [], result.stderr)
5591
return result
5692
}
@@ -124,16 +160,107 @@ test("direct, external and duplicate installations are left unchanged", (t) => {
124160
}
125161
})
126162

127-
test("missing CLI and malformed lists report setup failure with recovery guidance", (t) => {
128-
for (const options of [
129-
{ steps: [], missingCli: true },
130-
{ steps: [{ args: pluginList, raw: "not json" }] },
131-
{ steps: [{ args: pluginList, result: {} }] },
163+
test("startup installs a missing CLI outside PATH, the plugin and app dependencies", (t) => {
164+
const result = run(
165+
t,
166+
[
167+
{ command: "npm", args: ["prefix", "--global"] },
168+
{ command: "npm", args: ["install", "-g", "@github/copilot"], installCli: true },
169+
{ args: pluginList, result: [] },
170+
{ args: marketplaceList, result: [] },
171+
{ args: ["plugin", "marketplace", "add", "github/replica"] },
172+
{ args: install },
173+
{ args: pluginList, result: [installed] },
174+
{ command: "npm", args: ["ci"] },
175+
],
176+
{ missingCli: true, startup: true },
177+
)
178+
assert.equal(result.status, 0, result.stderr)
179+
assert.match(result.stdout, /future sessions/)
180+
assert.match(result.stdout, /both the Replica plugin and the Replica extension/)
181+
assert.equal(result.stderr, "")
182+
})
183+
184+
test("a missing CLI and missing npm report how to install the prerequisite", (t) => {
185+
const result = run(t, [], { missingCli: true, missingNpm: true })
186+
assert.equal(result.status, 1)
187+
assert.match(result.stderr, /npm/)
188+
assert.match(result.stderr, /install/i)
189+
assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/)
190+
})
191+
192+
test("repeated startups reuse the installed global CLI outside PATH without reinstalling", (t) => {
193+
const result = run(
194+
t,
195+
[
196+
{ command: "npm", args: ["prefix", "--global"] },
197+
{ command: "npm", args: ["install", "-g", "@github/copilot"], installCli: true },
198+
{ args: pluginList, result: [installed] },
199+
{ command: "npm", args: ["ci"] },
200+
{ command: "npm", args: ["prefix", "--global"] },
201+
{ args: pluginList, result: [installed] },
202+
{ command: "npm", args: ["ci"] },
203+
],
204+
{ missingCli: true, startup: true, repeat: 2 },
205+
)
206+
assert.equal(result.status, 0, result.stderr)
207+
assert.equal(result.stderr, "")
208+
assert.doesNotMatch(result.stdout, /Installing it globally/)
209+
})
210+
211+
test("a failed npm install preserves its cause and stops setup", (t) => {
212+
const result = run(
213+
t,
214+
[
215+
{ command: "npm", args: ["prefix", "--global"] },
216+
{
217+
command: "npm",
218+
args: ["install", "-g", "@github/copilot"],
219+
error: "EACCES: global installation denied",
220+
},
221+
],
222+
{ missingCli: true },
223+
)
224+
assert.equal(result.status, 1)
225+
assert.match(result.stderr, /EACCES: global installation denied/)
226+
assert.match(result.stderr, /npm/)
227+
assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/)
228+
})
229+
230+
test("an existing CLI without execute permission is not reinstalled", (t) => {
231+
const result = run(t, [], { cliMode: 0o644 })
232+
assert.equal(result.status, 1)
233+
assert.match(result.stderr, /EACCES/)
234+
assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/)
235+
})
236+
237+
test("npm success without a valid prefix or runnable CLI still fails setup", (t) => {
238+
for (const prefixResult of [{}, { raw: "" }, { raw: "relative/prefix" }]) {
239+
const result = run(
240+
t,
241+
[
242+
{ command: "npm", args: ["prefix", "--global"], ...prefixResult },
243+
...(Object.keys(prefixResult).length === 0
244+
? [{ command: "npm", args: ["install", "-g", "@github/copilot"] }]
245+
: []),
246+
],
247+
{ missingCli: true },
248+
)
249+
assert.equal(result.status, 1)
250+
assert.match(result.stderr, /Copilot|copilot|prefix/)
251+
assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/)
252+
}
253+
})
254+
255+
test("malformed plugin lists report setup failure without installation or enable advice", (t) => {
256+
for (const step of [
257+
{ args: pluginList, raw: "not json" },
258+
{ args: pluginList, result: {} },
132259
]) {
133-
const result = run(t, options.steps, options)
260+
const result = run(t, [step])
134261
assert.equal(result.status, 1)
135262
assert.match(result.stderr, /Replica plugin setup failed/)
136-
assert.match(result.stderr, /Customization, select Installed/)
263+
assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/)
137264
}
138265
})
139266

@@ -162,6 +289,7 @@ test("CLI failures stop setup without retrying or hiding the cause", (t) => {
162289
assert.equal(result.status, 1)
163290
assert.match(result.stderr, /CLI access denied/)
164291
assert.equal(result.stdout, "")
292+
assert.doesNotMatch(result.stderr, /Customization, select Installed|future sessions/)
165293
}
166294
})
167295

@@ -174,11 +302,86 @@ test("setup rejects a missing or disabled plugin after CLI success", (t) => {
174302
])
175303
assert.equal(result.status, 1)
176304
assert.match(result.stderr, /did not report the Replica plugin installed and enabled/)
305+
assert.doesNotMatch(result.stdout + result.stderr, /Customization, select Installed|future sessions/)
177306
}
178307
})
179308

180-
test("session startup checks the plugin before installing dependencies", () => {
309+
test("startup is configured to run when a session is created", () => {
181310
const config = readFileSync(join(root, "starter/.github/github-app.yml"), "utf8")
182-
assert.match(config, /command: node scripts\/setup-replica-plugin\.mjs && npm ci/)
183311
assert.match(config, /triggers:\n\s+- session\.create/)
184312
})
313+
314+
test("startup installs dependencies after successful or failed plugin setup", (t) => {
315+
for (const plugin of [
316+
{ args: pluginList, result: [installed] },
317+
{ args: pluginList, error: "CLI access denied" },
318+
]) {
319+
const result = run(
320+
t,
321+
[plugin, { command: "npm", args: ["ci"], raw: "Dependencies finished" }],
322+
{ startup: true },
323+
)
324+
assert.equal(result.status, 0, result.stderr)
325+
const summary = result.stdout.slice(result.stdout.indexOf("Setup summary"))
326+
assert.match(summary, /App dependencies: installed/)
327+
assert.ok(result.stdout.indexOf("Setup summary") > result.stdout.indexOf("Dependencies finished"))
328+
if (plugin.error) {
329+
assert.match(summary, /Replica canvas: unavailable/)
330+
assert.match(summary, /CLI access denied/)
331+
assert.match(summary, /node scripts\/setup-replica-plugin\.mjs/)
332+
} else {
333+
assert.match(summary, /Replica plugin: installed and enabled/)
334+
assert.match(summary, /fresh Copilot session/)
335+
assert.doesNotMatch(summary, /canvas: ready/i)
336+
}
337+
if (plugin.error) assert.match(result.stderr, /CLI access denied/)
338+
}
339+
})
340+
341+
test("startup installs dependencies even when CLI installation fails", (t) => {
342+
const result = run(
343+
t,
344+
[
345+
{ command: "npm", args: ["prefix", "--global"] },
346+
{
347+
command: "npm",
348+
args: ["install", "-g", "@github/copilot"],
349+
error: "EACCES: global installation denied",
350+
},
351+
{ command: "npm", args: ["ci"] },
352+
],
353+
{ missingCli: true, startup: true },
354+
)
355+
assert.equal(result.status, 0, result.stderr)
356+
assert.match(result.stderr, /EACCES: global installation denied/)
357+
const summary = result.stdout.slice(result.stdout.indexOf("Setup summary"))
358+
assert.match(summary, /App dependencies: installed/)
359+
assert.match(summary, /Replica canvas: unavailable/)
360+
assert.match(summary, /Copilot CLI installation failed/)
361+
assert.match(summary, /EACCES: global installation denied/)
362+
})
363+
364+
test("startup still fails when dependency installation fails", (t) => {
365+
for (const plugin of [
366+
{ args: pluginList, result: [installed] },
367+
{ args: pluginList, error: "CLI access denied" },
368+
]) {
369+
const result = run(
370+
t,
371+
[
372+
plugin,
373+
{ command: "npm", args: ["ci"], error: "Dependency installation failed" },
374+
],
375+
{ startup: true },
376+
)
377+
assert.equal(result.status, 1)
378+
assert.match(result.stderr, /Dependency installation failed/)
379+
const summary = result.stdout.slice(result.stdout.indexOf("Setup summary"))
380+
assert.match(summary, /App dependencies: failed/)
381+
assert.match(summary, /npm ci/)
382+
assert.match(
383+
summary,
384+
plugin.error ? /Replica canvas: unavailable/ : /Replica plugin: installed and enabled/,
385+
)
386+
}
387+
})

‎starter/.github/github-app.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
scripts:
22
- name: Startup
3-
command: node scripts/setup-replica-plugin.mjs && npm ci
3+
command: node scripts/setup-replica-plugin.mjs --startup
44
triggers:
55
- session.create
66
- name: Dev

‎starter/README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,16 +205,20 @@ The preview's browser origin differs from the original address. Original-origin
205205
After you review and accept the Copilot app's repository configuration,
206206
session setup checks the Replica plugin before installing app dependencies.
207207
An enabled installation needs one local CLI check and no network requests.
208+
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.
208209
Setup enables a disabled marketplace installation or installs a missing one
209210
from `github/replica`. It preserves an existing marketplace's branch or release
210-
pin and stops on conflicting sources or CLI errors. For an organization-required
211+
pin and stops plugin setup on conflicting sources or CLI errors. For an organization-required
211212
pin, register the approved marketplace before setup.
212213

213214
Outside automatic session setup, run `node scripts/setup-replica-plugin.mjs`
214-
manually. The script requires Node.js and Copilot CLI and uses no agent.
215+
manually. The script requires Node.js and npm and uses no agent.
215216
Installation commands run without interactive input and have bounded timeouts;
216-
approval or policy failures need user action. A failed plugin check stops the
217-
startup command before `npm ci`; resolve it and rerun setup.
217+
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.
218+
219+
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.
220+
221+
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.
218222
For manual installation, updates or source changes, follow the
219223
[plugin guide](https://github.com/github/replica/blob/main/.github/extensions/replica-prototype/README.md#install).
220224
Keep only one Replica provider active.

0 commit comments

Comments
 (0)