FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

[GHCP Plugin] Fix Copilot plugin registration on Windows by GeorgeNgMsft · Pull Request #2947 · microsoft/TypeAgent · GitHub

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

Filter by extension

Filter by extension .md  (1) .mjs  (2) All 2 file types selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
37 changes: 17 additions & 20 deletions ts/packages/copilot-plugin/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -244,28 +244,25 @@ The current Copilot CLI (>= 1.0) does **not** accept a local path for
subdirs, or git URLs. However, `copilot plugin marketplace add <path>` **does**
accept a local path. So `pnpm run register` (`scripts/install-plugin.mjs`):

1. Registers the `ts` workspace root as a local marketplace named
`typeagent-local`. The CLI discovers the marketplace manifest at
`ts/.github/plugin/marketplace.json`, whose plugin `source` points at
`./packages/copilot-plugin` (resolved relative to the marketplace root).
2. Installs `typeagent@typeagent-local`, which **copies** the plugin dir into
`~/.copilot/installed-plugins/`.

> The CLI searches several locations for the marketplace manifest, in order:
> `marketplace.json` (root), `.plugin/marketplace.json`,
> `.github/plugin/marketplace.json`, then `.claude-plugin/marketplace.json`.
> We use `.github/plugin/` since the workspace already has a `.github` folder.
1. Stages only the bundled runtime files under
`~/.typeagent-copilot/plugin-stage`. This deliberately excludes the
workspace's pnpm `node_modules` junctions, which Copilot cannot copy on
Windows.
2. Creates and registers a local marketplace at
`~/.copilot/marketplaces/typeagent-local`.
3. Installs `typeagent@typeagent-local`, which copies the staged snapshot into
`~/.copilot/installed-plugins/`, and verifies that it appears in
`copilot plugin list`.

### Why the build must bundle

Installing copies the plugin directory into `~/.copilot/installed-plugins/`.
Because this is a pnpm workspace, the plugin's runtime deps (the MCP SDK and the
`workspace:*` packages) are symlinks/junctions into the central `.pnpm` store —
the copy breaks them, and the MCP server crashes on launch with
`ERR_MODULE_NOT_FOUND`. To fix this, `pnpm run build` runs an esbuild bundle
step (`scripts/bundle.mjs`) that inlines every dependency into the hook and MCP
entry points, so the copied `dist/` is self-contained and needs no
`node_modules` at runtime.
Installing copies a plugin snapshot into `~/.copilot/installed-plugins/`.
Because this is a pnpm workspace, the package's runtime dependencies are
symlinks/junctions into the central `.pnpm` store. Copying those links can fail
with `Access is denied` on Windows, and copied links would not be portable.
`pnpm run build` therefore runs `scripts/bundle.mjs` to inline every dependency,
and registration stages only that self-contained runtime without
`node_modules`.

### Updating after a code change

Expand All @@ -274,7 +271,7 @@ the plugin, rebuild and refresh the global copy:

```powershell
pnpm run build # re-bundle
pnpm run register # re-copies the fresh build (runs `copilot plugin update`)
pnpm run register # stages and installs a fresh snapshot
```

> For rapid local development with live edits, prefer `pnpm copilot`
Expand Down
175 changes: 75 additions & 100 deletions ts/packages/copilot-plugin/scripts/install-plugin.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -3,51 +3,57 @@
// Licensed under the MIT License.

/**
* Install this plugin into GitHub Copilot CLI *globally*, so it loads in every
* `copilot` session regardless of the directory you launch from (no
* `--plugin-dir` and no `pnpm copilot` wrapper needed).
* Stage and install this plugin through the shared TypeAgent registrar.
*
* Mechanism (Copilot CLI >= 1.0):
* `copilot plugin install <path>` is NOT supported — the install source must
* be a marketplace, a GitHub repo, or a git URL. However,
* `copilot plugin marketplace add <path>` DOES accept a local path. So we:
* 1. Register the `ts` workspace root as a local marketplace
* ("typeagent-local"). The CLI discovers the marketplace manifest at
* `ts/.github/plugin/marketplace.json`, whose plugin `source` points at
* `./packages/copilot-plugin` (relative to the marketplace root).
* 2. Install (or refresh) the "typeagent" plugin from that marketplace.
*
* Installing COPIES the plugin (including dist/ and node_modules/) into
* ~/.copilot/installed-plugins/. It is a snapshot, not a live reference, so
* after you rebuild the plugin you must re-run this script (which calls
* `copilot plugin update`) to refresh the global copy.
*
* - If `copilot` is not on PATH, we warn and exit 0 (don't fail the build).
* - Pass `--skip-install` (or set `TYPEAGENT_SKIP_PLUGIN_INSTALL=1`) to opt out.
* The workspace package cannot be used as the marketplace source directly:
* its pnpm node_modules contains Windows junctions that Copilot tries to copy,
* which fails with access denied.
*/

import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { existsSync } from "node:fs";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pluginRoot = resolve(__dirname, "..");
// The marketplace manifest lives at <workspaceRoot>/.github/plugin/
// marketplace.json, so the marketplace source we register is the `ts`
// workspace root (packages/copilot-plugin -> ../..).
const workspaceRoot = resolve(pluginRoot, "..", "..");
import { stageCopilotPlugin } from "../../../tools/scripts/stageCopilotPlugin.mjs";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const pluginRoot = path.resolve(scriptDir, "..");
const workspaceRoot = path.resolve(pluginRoot, "..", "..");
const marketplaceName = "typeagent-local";
const copilotHome = path.resolve(
process.env.COPILOT_HOME ?? path.join(os.homedir(), ".copilot"),
);
const stagingRoot = path.join(
os.homedir(),
".typeagent-copilot",
"plugin-stage",
);
const marketplaceRoot = path.join(copilotHome, "marketplaces", marketplaceName);
const registerScript = path.join(
workspaceRoot,
"tools",
"installers",
"common",
"register-plugin.mjs",
);

const MARKETPLACE_NAME = "typeagent-local";
const PLUGIN_NAME = "typeagent";
function log(message) {
process.stdout.write(`[copilot-plugin] ${message}\n`);
}

function log(msg) {
process.stdout.write(`[copilot-plugin] ${msg}\n`);
function warn(message) {
process.stderr.write(`[copilot-plugin] ${message}\n`);
}

function warn(msg) {
process.stderr.write(`[copilot-plugin] ${msg}\n`);
function findCopilotCli() {
if (process.env.COPILOT_CLI_PATH) {
return process.env.COPILOT_CLI_PATH;
}
const command = process.platform === "win32" ? "where" : "which";
const result = spawnSync(command, ["copilot"], { encoding: "utf8" });
if (result.status !== 0 || !result.stdout.trim()) return undefined;
return result.stdout.split(/\r?\n/)[0].trim();
}

if (
Expand All @@ -58,87 +64,56 @@ if (
process.exit(0);
}

// Sanity check: built output must exist.
const distHook = resolve(pluginRoot, "dist", "hooks", "hook-router.js");
const distHook = path.join(pluginRoot, "dist", "hooks", "hook-router.js");
if (!existsSync(distHook)) {
warn(
`Built output not found at ${distHook}. Run \`pnpm run build\` first.`,
);
process.exit(1);
}

// Locate copilot CLI. spawnSync with shell:false; resolve via which/where.
function findCopilotCli() {
const cmd = process.platform === "win32" ? "where" : "which";
const result = spawnSync(cmd, ["copilot"], { encoding: "utf-8" });
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.split(/\r?\n/)[0].trim();
}
return null;
}

const copilotPath = findCopilotCli();
if (!copilotPath) {
warn(
"GitHub Copilot CLI (`copilot`) not found on PATH. " +
"Skipping global plugin registration. " +
"Install Copilot CLI and rerun `pnpm run register` if you want " +
"to use the plugin outside this repo.",
"Skipping global plugin registration.",
);
process.exit(0);
}

// Run a copilot subcommand, returning { status, stdout, stderr }.
function copilot(args) {
return spawnSync("copilot", args, {
encoding: "utf-8",
shell: process.platform === "win32",
});
}

log(`Found copilot at ${copilotPath}`);

// 1. Register the local marketplace if not already registered.
const mpList = copilot(["plugin", "marketplace", "list"]);
if ((mpList.stdout || "").includes(MARKETPLACE_NAME)) {
log(`Marketplace "${MARKETPLACE_NAME}" already registered.`);
} else {
log(`Registering local marketplace from ${workspaceRoot}`);
const add = copilot(["plugin", "marketplace", "add", workspaceRoot]);
process.stdout.write(add.stdout || "");
process.stderr.write(add.stderr || "");
if (add.status !== 0) {
warn("Failed to register marketplace. Aborting.");
process.exit(0);
}
}

// 2. Install the plugin, or update it if it's already installed (refresh the
// global snapshot from the freshly built local source).
const pluginList = copilot(["plugin", "list"]);
const alreadyInstalled = (pluginList.stdout || "").includes(
`${PLUGIN_NAME}@${MARKETPLACE_NAME}`,
const metrics = stageCopilotPlugin(stagingRoot);
log(`Staged ${metrics.files} runtime files without workspace node_modules.`);

const registration = spawnSync(
process.execPath,
[
registerScript,
"--install-dir",
workspaceRoot,
"--plugin-source-dir",
stagingRoot,
"--marketplace-name",
marketplaceName,
"--marketplace-root",
marketplaceRoot,
"--plugin-name",
"typeagent",
"--copilot-path",
copilotPath,
],
{ encoding: "utf8", shell: false },
);
process.stdout.write(registration.stdout || "");
process.stderr.write(registration.stderr || "");

const op = alreadyInstalled
? ["plugin", "update", PLUGIN_NAME]
: ["plugin", "install", `${PLUGIN_NAME}@${MARKETPLACE_NAME}`];

log(alreadyInstalled ? "Refreshing global plugin copy…" : "Installing plugin…");
const result = copilot(op);
process.stdout.write(result.stdout || "");
process.stderr.write(result.stderr || "");

if (result.status === 0) {
log(
"Done. The plugin is now available in every `copilot` session. " +
"After rebuilding, re-run `pnpm run register` to refresh it.",
);
process.exit(0);
if (registration.error) {
warn(`Plugin registration could not start: ${registration.error.message}`);
process.exit(1);
}
if (registration.status !== 0) {
warn(`Plugin registration failed with exit code ${registration.status}.`);
process.exit(registration.status ?? 1);
}

warn(
`copilot ${op.join(" ")} exited with code ${result.status}. ` +
"Re-run manually with `pnpm run register` if needed.",
);
process.exit(0);
log("Done. The plugin is available in every `copilot` session.");
Loading
Loading

Back | FazBrowse Home | New Git URL