Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
259 changes: 231 additions & 28 deletions scripts/setup-replica-plugin.test.mjs
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -19,38 +26,67 @@ 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, repeat = 1 } = {},
) {
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]]
}
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)
}
const result = spawnSync(process.execPath, [script], {
cwd: directory,
encoding: "utf8",
timeout: 10_000,
env: { PATH: directory, REPLICA_TEST_STATE: state },
})
assert.ifError(result.error)
assert.deepEqual(JSON.parse(readFileSync(state, "utf8")), [], result.stderr)
return result
}
Expand Down Expand Up @@ -124,16 +160,107 @@ 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: ["prefix", "--global"] },
{ command: "npm", args: ["install", "-g", "@github/copilot"], installCli: true },
{ 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("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"],
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: ["prefix", "--global"], ...prefixResult },
...(Object.keys(prefixResult).length === 0
? [{ command: "npm", args: ["install", "-g", "@github/copilot"] }]
: []),
],
{ 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/)
}
})

Expand Down Expand Up @@ -162,6 +289,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/)
}
})

Expand All @@ -174,11 +302,86 @@ 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: ["prefix", "--global"] },
{
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/,
)
}
})
2 changes: 1 addition & 1 deletion starter/.github/github-app.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 8 additions & 4 deletions starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 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.
Expand Down
Loading
Loading