copilot-ship 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/dist/constants.js +13 -0
- package/dist/index.js +195 -0
- package/dist/installer.js +374 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# copilot-ship
|
|
2
|
+
|
|
3
|
+
Ship GitHub Copilot plugin artifacts into canonical Copilot directories.
|
|
4
|
+
|
|
5
|
+
## npm vs npx (quick explanation)
|
|
6
|
+
|
|
7
|
+
- **npm** is the package manager used to install/publish packages.
|
|
8
|
+
- **npx** runs a package's CLI command (usually from npm) without requiring a permanent global install.
|
|
9
|
+
|
|
10
|
+
Before publishing, you can test this project fully from local source.
|
|
11
|
+
|
|
12
|
+
## Build and test locally (no publish required)
|
|
13
|
+
|
|
14
|
+
### 1. Install dependencies
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm ci
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### 2. Build TypeScript
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm run build
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 3. Run local checks
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm test
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 4. Run the CLI directly from local build output
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
node dist/index.js --help
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 5. End-to-end install test against the plugin marketplace repo
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# from this repo root
|
|
42
|
+
node dist/index.js add https://github.com/github/copilot-plugins --plugin spark -y
|
|
43
|
+
node dist/index.js list
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or using the global command (after `npm link`):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
copilot-ship add https://github.com/github/copilot-plugins --plugin spark -y
|
|
50
|
+
copilot-ship list
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This ships artifacts into `./.github/` by default.
|
|
54
|
+
|
|
55
|
+
## Optional: test as if globally installed
|
|
56
|
+
|
|
57
|
+
If you want to invoke `copilot-ship` directly (without `node dist/index.js`):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm link
|
|
61
|
+
copilot-ship --help
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
When done:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm unlink -g copilot-ship
|
|
68
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const MARKETPLACE_MANIFEST = path.join(".claude-plugin", "marketplace.json");
|
|
4
|
+
export const PROJECT_INSTALL_ROOT = ".github";
|
|
5
|
+
export const GLOBAL_INSTALL_ROOT = path.join(os.homedir(), ".copilot");
|
|
6
|
+
export const DIRECTORY_TARGETS = {
|
|
7
|
+
agents: "agents",
|
|
8
|
+
hooks: "hooks",
|
|
9
|
+
instructions: "instructions",
|
|
10
|
+
prompts: "prompts",
|
|
11
|
+
skills: "skills",
|
|
12
|
+
};
|
|
13
|
+
export const COPILOT_INSTRUCTIONS_FILE = "copilot-instructions.md";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cancel, confirm, intro, isCancel, log, multiselect, outro, spinner, } from "@clack/prompts";
|
|
3
|
+
import { installFromMarketplace, listInstalledArtifacts, } from "./installer.js";
|
|
4
|
+
async function main() {
|
|
5
|
+
const parsedArgs = parseArgs(process.argv.slice(2));
|
|
6
|
+
if (parsedArgs.command === "help") {
|
|
7
|
+
printUsage();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
intro("copilot-ship");
|
|
11
|
+
try {
|
|
12
|
+
if (parsedArgs.command === "add") {
|
|
13
|
+
await runAdd(parsedArgs);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
await runList(parsedArgs.flags.scope);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
21
|
+
log.error(message);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
outro("Done.");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function runAdd(parsedArgs) {
|
|
29
|
+
if (!parsedArgs.source) {
|
|
30
|
+
throw new Error("The add command requires a source.");
|
|
31
|
+
}
|
|
32
|
+
const activity = spinner();
|
|
33
|
+
activity.start("Resolving source and preparing install plan");
|
|
34
|
+
let result;
|
|
35
|
+
try {
|
|
36
|
+
result = await installFromMarketplace(parsedArgs.source, parsedArgs.flags, async (plugins) => {
|
|
37
|
+
activity.stop("Marketplace loaded");
|
|
38
|
+
let selected = plugins;
|
|
39
|
+
if (parsedArgs.flags.pluginName) {
|
|
40
|
+
const plugin = plugins.find((candidate) => candidate.plugin.name === parsedArgs.flags.pluginName);
|
|
41
|
+
if (!plugin) {
|
|
42
|
+
throw new Error(`Plugin "${parsedArgs.flags.pluginName}" was not found in the selected marketplace scope.`);
|
|
43
|
+
}
|
|
44
|
+
selected = [plugin];
|
|
45
|
+
}
|
|
46
|
+
else if (!parsedArgs.flags.installAll && plugins.length > 1) {
|
|
47
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
48
|
+
throw new Error("Interactive plugin selection requires a TTY. Use --plugin or --all instead.");
|
|
49
|
+
}
|
|
50
|
+
const selection = await multiselect({
|
|
51
|
+
message: "Select plugins to install",
|
|
52
|
+
options: plugins.map((candidate) => ({
|
|
53
|
+
...(candidate.plugin.description ? { hint: candidate.plugin.description } : {}),
|
|
54
|
+
label: candidate.plugin.name,
|
|
55
|
+
value: candidate.plugin.name,
|
|
56
|
+
})),
|
|
57
|
+
required: true,
|
|
58
|
+
});
|
|
59
|
+
if (isCancel(selection)) {
|
|
60
|
+
cancel("Installation cancelled.");
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
const selectedNames = new Set(Array.isArray(selection) ? selection : []);
|
|
64
|
+
selected = plugins.filter((candidate) => selectedNames.has(candidate.plugin.name));
|
|
65
|
+
}
|
|
66
|
+
if (!parsedArgs.flags.yes) {
|
|
67
|
+
const accepted = await confirm({
|
|
68
|
+
message: `Install ${selected.length} plugin(s) into ${resultTargetDescription(parsedArgs.flags.scope)}?`,
|
|
69
|
+
});
|
|
70
|
+
if (isCancel(accepted) || !accepted) {
|
|
71
|
+
cancel("Installation cancelled.");
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
activity.start("Installing selected plugins");
|
|
76
|
+
return selected;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
activity.error("Install failed");
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
activity.stop(`Installed ${result.installedPlugins.length} plugin(s) into ${result.targetRoot}`);
|
|
84
|
+
for (const warning of result.warnings) {
|
|
85
|
+
log.warn(warning);
|
|
86
|
+
}
|
|
87
|
+
for (const plan of result.installedPlugins) {
|
|
88
|
+
log.success(`${plan.plugin.name}`);
|
|
89
|
+
for (const entry of plan.entries) {
|
|
90
|
+
log.step(` ${entry.targetRelativePath}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function runList(scope) {
|
|
95
|
+
const result = await listInstalledArtifacts(scope);
|
|
96
|
+
if (result.groups.length === 0) {
|
|
97
|
+
log.info(`No Copilot artifacts installed in ${result.targetRoot}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
log.info(`Installed artifacts in ${result.targetRoot}`);
|
|
101
|
+
for (const group of result.groups) {
|
|
102
|
+
log.step(group.kind);
|
|
103
|
+
for (const entry of group.entries) {
|
|
104
|
+
log.step(` ${entry}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function parseArgs(argv) {
|
|
109
|
+
const flags = {
|
|
110
|
+
installAll: false,
|
|
111
|
+
scope: "project",
|
|
112
|
+
yes: false,
|
|
113
|
+
};
|
|
114
|
+
const positionals = [];
|
|
115
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
116
|
+
const argument = argv[index];
|
|
117
|
+
if (argument === undefined) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
switch (argument) {
|
|
121
|
+
case "add":
|
|
122
|
+
case "list":
|
|
123
|
+
case "help":
|
|
124
|
+
positionals.push(argument);
|
|
125
|
+
break;
|
|
126
|
+
case "-g":
|
|
127
|
+
case "--global":
|
|
128
|
+
flags.scope = "global";
|
|
129
|
+
break;
|
|
130
|
+
case "-y":
|
|
131
|
+
case "--yes":
|
|
132
|
+
flags.yes = true;
|
|
133
|
+
break;
|
|
134
|
+
case "--all":
|
|
135
|
+
flags.installAll = true;
|
|
136
|
+
break;
|
|
137
|
+
case "--plugin": {
|
|
138
|
+
const nextValue = argv[index + 1];
|
|
139
|
+
if (!nextValue) {
|
|
140
|
+
throw new Error("Expected a value after --plugin.");
|
|
141
|
+
}
|
|
142
|
+
flags.pluginName = nextValue;
|
|
143
|
+
index += 1;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "-h":
|
|
147
|
+
case "--help":
|
|
148
|
+
return { command: "help", flags };
|
|
149
|
+
default:
|
|
150
|
+
positionals.push(argument);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const command = positionals[0];
|
|
155
|
+
if (!command) {
|
|
156
|
+
return { command: "help", flags };
|
|
157
|
+
}
|
|
158
|
+
if (command !== "add" && command !== "list" && command !== "help") {
|
|
159
|
+
throw new Error(`Unknown command "${command}".`);
|
|
160
|
+
}
|
|
161
|
+
if (command === "list") {
|
|
162
|
+
return { command, flags };
|
|
163
|
+
}
|
|
164
|
+
if (command === "help") {
|
|
165
|
+
return { command, flags };
|
|
166
|
+
}
|
|
167
|
+
const source = positionals[1];
|
|
168
|
+
if (!source) {
|
|
169
|
+
throw new Error("Usage: copilot-ship add <source> [--plugin <name> | --all] [-g] [-y]");
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
command,
|
|
173
|
+
flags,
|
|
174
|
+
source,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function printUsage() {
|
|
178
|
+
console.log(`copilot-ship
|
|
179
|
+
|
|
180
|
+
Usage:
|
|
181
|
+
copilot-ship add <source> [--plugin <name> | --all] [-g] [-y]
|
|
182
|
+
copilot-ship list [-g]
|
|
183
|
+
|
|
184
|
+
Sources:
|
|
185
|
+
owner/repo
|
|
186
|
+
https://github.com/owner/repo
|
|
187
|
+
https://github.com/owner/repo/tree/main/plugins/my-plugin
|
|
188
|
+
git@github.com:owner/repo.git
|
|
189
|
+
./local-path
|
|
190
|
+
`);
|
|
191
|
+
}
|
|
192
|
+
function resultTargetDescription(scope) {
|
|
193
|
+
return scope === "global" ? "~/.copilot" : ".github";
|
|
194
|
+
}
|
|
195
|
+
void main();
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { access, copyFile, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { COPILOT_INSTRUCTIONS_FILE, DIRECTORY_TARGETS, GLOBAL_INSTALL_ROOT, MARKETPLACE_MANIFEST, PROJECT_INSTALL_ROOT, } from "./constants.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
export async function resolveSource(source) {
|
|
9
|
+
const localPath = await tryResolveLocalPath(source);
|
|
10
|
+
if (localPath) {
|
|
11
|
+
return {
|
|
12
|
+
cleanup: undefined,
|
|
13
|
+
marketplaceRoot: await findMarketplaceRoot(localPath),
|
|
14
|
+
requestedPath: localPath,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const parsedGitHubSource = parseGitHubSource(source);
|
|
18
|
+
if (!parsedGitHubSource) {
|
|
19
|
+
throw new Error(`Unsupported source "${source}". Use a local path, owner/repo shorthand, a GitHub URL, or a git@github.com URL.`);
|
|
20
|
+
}
|
|
21
|
+
const tempRoot = await createTempDirectory();
|
|
22
|
+
const checkoutRoot = path.join(tempRoot, "source");
|
|
23
|
+
const cloneArgs = ["clone", "--quiet", "--depth", "1"];
|
|
24
|
+
if (parsedGitHubSource.branch) {
|
|
25
|
+
cloneArgs.push("--branch", parsedGitHubSource.branch, "--single-branch");
|
|
26
|
+
}
|
|
27
|
+
cloneArgs.push(parsedGitHubSource.cloneUrl, checkoutRoot);
|
|
28
|
+
try {
|
|
29
|
+
await execFileAsync("git", cloneArgs);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
await rm(tempRoot, { force: true, recursive: true });
|
|
33
|
+
throw wrapExecError(`Failed to clone ${parsedGitHubSource.cloneUrl}`, error);
|
|
34
|
+
}
|
|
35
|
+
const requestedPath = path.resolve(checkoutRoot, parsedGitHubSource.subpath ?? ".");
|
|
36
|
+
try {
|
|
37
|
+
await access(requestedPath);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
await rm(tempRoot, { force: true, recursive: true });
|
|
41
|
+
throw new Error(`Resolved source path "${parsedGitHubSource.subpath ?? "."}" does not exist in ${parsedGitHubSource.cloneUrl}.`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
cleanup: async () => rm(tempRoot, { force: true, recursive: true }),
|
|
45
|
+
marketplaceRoot: await findMarketplaceRoot(requestedPath),
|
|
46
|
+
requestedPath,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export async function installFromMarketplace(source, flags, selectPlugins) {
|
|
50
|
+
const resolvedSource = await resolveSource(source);
|
|
51
|
+
try {
|
|
52
|
+
const manifest = await readMarketplaceManifest(resolvedSource.marketplaceRoot);
|
|
53
|
+
const warnings = [];
|
|
54
|
+
const skippedPlugins = [];
|
|
55
|
+
const localCandidates = [];
|
|
56
|
+
for (const plugin of manifest.plugins) {
|
|
57
|
+
if (typeof plugin.source !== "string") {
|
|
58
|
+
skippedPlugins.push(plugin.name);
|
|
59
|
+
warnings.push(`Skipped plugin "${plugin.name}": external marketplace sources are deferred to MVP2.`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const pluginRoot = path.resolve(resolvedSource.marketplaceRoot, plugin.source);
|
|
63
|
+
await assertDirectory(pluginRoot, `Plugin "${plugin.name}" points to missing directory "${plugin.source}".`);
|
|
64
|
+
localCandidates.push({ plugin, pluginRoot });
|
|
65
|
+
}
|
|
66
|
+
const scopedCandidates = narrowCandidatesByRequestedPath(localCandidates, resolvedSource.requestedPath);
|
|
67
|
+
const candidatePool = scopedCandidates.length > 0 ? scopedCandidates : localCandidates;
|
|
68
|
+
if (candidatePool.length === 0) {
|
|
69
|
+
throw new Error("No installable plugins were found in the marketplace.");
|
|
70
|
+
}
|
|
71
|
+
const selectedCandidates = await selectPlugins(candidatePool);
|
|
72
|
+
if (selectedCandidates.length === 0) {
|
|
73
|
+
throw new Error("No plugins were selected.");
|
|
74
|
+
}
|
|
75
|
+
const targetRoot = getInstallRoot(flags.scope);
|
|
76
|
+
const installPlans = await Promise.all(selectedCandidates.map(async (candidate) => ({
|
|
77
|
+
entries: await discoverArtifacts(candidate.pluginRoot, targetRoot),
|
|
78
|
+
plugin: candidate.plugin,
|
|
79
|
+
pluginRoot: candidate.pluginRoot,
|
|
80
|
+
})));
|
|
81
|
+
const installablePlans = installPlans.filter((plan) => {
|
|
82
|
+
if (plan.entries.length === 0) {
|
|
83
|
+
warnings.push(`Plugin "${plan.plugin.name}" has no supported Copilot artifacts.`);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
});
|
|
88
|
+
if (installablePlans.length === 0) {
|
|
89
|
+
throw new Error("Nothing to install: the selected plugins do not contain supported Copilot artifacts.");
|
|
90
|
+
}
|
|
91
|
+
await ensureNoConflicts(installablePlans);
|
|
92
|
+
await copyInstallPlans(installablePlans);
|
|
93
|
+
return {
|
|
94
|
+
installedPlugins: installablePlans,
|
|
95
|
+
skippedPlugins,
|
|
96
|
+
sourceRoot: resolvedSource.marketplaceRoot,
|
|
97
|
+
targetRoot,
|
|
98
|
+
warnings,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await resolvedSource.cleanup?.();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function listInstalledArtifacts(scope) {
|
|
106
|
+
const targetRoot = getInstallRoot(scope);
|
|
107
|
+
const groups = [];
|
|
108
|
+
const instructionsPath = path.join(targetRoot, COPILOT_INSTRUCTIONS_FILE);
|
|
109
|
+
if (await pathExists(instructionsPath)) {
|
|
110
|
+
groups.push({
|
|
111
|
+
entries: [COPILOT_INSTRUCTIONS_FILE],
|
|
112
|
+
kind: "copilot-instructions",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
for (const kind of Object.keys(DIRECTORY_TARGETS)) {
|
|
116
|
+
const root = path.join(targetRoot, DIRECTORY_TARGETS[kind]);
|
|
117
|
+
const entries = await collectRelativeFiles(root);
|
|
118
|
+
if (entries.length > 0) {
|
|
119
|
+
groups.push({ entries, kind });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
groups,
|
|
124
|
+
targetRoot,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function getInstallRoot(scope) {
|
|
128
|
+
return scope === "global" ? GLOBAL_INSTALL_ROOT : path.resolve(process.cwd(), PROJECT_INSTALL_ROOT);
|
|
129
|
+
}
|
|
130
|
+
async function readMarketplaceManifest(marketplaceRoot) {
|
|
131
|
+
const manifestPath = path.join(marketplaceRoot, MARKETPLACE_MANIFEST);
|
|
132
|
+
let raw;
|
|
133
|
+
try {
|
|
134
|
+
raw = await readFile(manifestPath, "utf8");
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new Error(`MVP1 requires a marketplace manifest at "${manifestPath}".`);
|
|
138
|
+
}
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(raw);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
throw wrapExecError(`Failed to parse marketplace manifest at "${manifestPath}"`, error);
|
|
145
|
+
}
|
|
146
|
+
if (!isMarketplaceManifest(parsed)) {
|
|
147
|
+
throw new Error(`Marketplace manifest at "${manifestPath}" is missing a valid "plugins" array.`);
|
|
148
|
+
}
|
|
149
|
+
return parsed;
|
|
150
|
+
}
|
|
151
|
+
async function findMarketplaceRoot(startingPath) {
|
|
152
|
+
let currentPath = path.resolve(startingPath);
|
|
153
|
+
const stats = await readdirOrNull(currentPath);
|
|
154
|
+
if (stats === null) {
|
|
155
|
+
currentPath = path.dirname(currentPath);
|
|
156
|
+
}
|
|
157
|
+
while (true) {
|
|
158
|
+
const candidate = path.join(currentPath, MARKETPLACE_MANIFEST);
|
|
159
|
+
if (await pathExists(candidate)) {
|
|
160
|
+
return currentPath;
|
|
161
|
+
}
|
|
162
|
+
const parent = path.dirname(currentPath);
|
|
163
|
+
if (parent === currentPath) {
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
currentPath = parent;
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`MVP1 requires a marketplace manifest. None was found from "${startingPath}" upward.`);
|
|
169
|
+
}
|
|
170
|
+
function narrowCandidatesByRequestedPath(candidates, requestedPath) {
|
|
171
|
+
const resolvedRequestedPath = path.resolve(requestedPath);
|
|
172
|
+
return candidates.filter((candidate) => {
|
|
173
|
+
const pluginRoot = path.resolve(candidate.pluginRoot);
|
|
174
|
+
return isSamePath(pluginRoot, resolvedRequestedPath) || isDescendantOf(resolvedRequestedPath, pluginRoot);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
async function discoverArtifacts(pluginRoot, targetRoot) {
|
|
178
|
+
const entries = [];
|
|
179
|
+
const repoInstructions = path.join(pluginRoot, COPILOT_INSTRUCTIONS_FILE);
|
|
180
|
+
if (await pathExists(repoInstructions)) {
|
|
181
|
+
entries.push({
|
|
182
|
+
kind: "copilot-instructions",
|
|
183
|
+
sourcePath: repoInstructions,
|
|
184
|
+
targetPath: path.join(targetRoot, COPILOT_INSTRUCTIONS_FILE),
|
|
185
|
+
targetRelativePath: COPILOT_INSTRUCTIONS_FILE,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
for (const kind of Object.keys(DIRECTORY_TARGETS)) {
|
|
189
|
+
const sourceRoot = path.join(pluginRoot, DIRECTORY_TARGETS[kind]);
|
|
190
|
+
if (!(await pathExists(sourceRoot))) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const files = await walkFiles(sourceRoot);
|
|
194
|
+
for (const file of files) {
|
|
195
|
+
const relativePath = path.relative(sourceRoot, file);
|
|
196
|
+
entries.push({
|
|
197
|
+
kind,
|
|
198
|
+
sourcePath: file,
|
|
199
|
+
targetPath: path.join(targetRoot, DIRECTORY_TARGETS[kind], relativePath),
|
|
200
|
+
targetRelativePath: path.join(DIRECTORY_TARGETS[kind], relativePath),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return entries.sort((left, right) => left.targetRelativePath.localeCompare(right.targetRelativePath));
|
|
205
|
+
}
|
|
206
|
+
async function ensureNoConflicts(plans) {
|
|
207
|
+
const seenTargets = new Map();
|
|
208
|
+
const fileConflicts = [];
|
|
209
|
+
for (const plan of plans) {
|
|
210
|
+
for (const entry of plan.entries) {
|
|
211
|
+
const existingSource = seenTargets.get(entry.targetPath);
|
|
212
|
+
if (existingSource) {
|
|
213
|
+
throw new Error(`Install conflict: "${entry.targetRelativePath}" would be written by both "${existingSource}" and "${plan.plugin.name}".`);
|
|
214
|
+
}
|
|
215
|
+
seenTargets.set(entry.targetPath, plan.plugin.name);
|
|
216
|
+
if (await pathExists(entry.targetPath)) {
|
|
217
|
+
fileConflicts.push(entry.targetPath);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (fileConflicts.length > 0) {
|
|
222
|
+
throw new Error(`Install conflict: target file already exists:\n${fileConflicts.map((conflict) => `- ${conflict}`).join("\n")}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function copyInstallPlans(plans) {
|
|
226
|
+
for (const plan of plans) {
|
|
227
|
+
for (const entry of plan.entries) {
|
|
228
|
+
await mkdir(path.dirname(entry.targetPath), { recursive: true });
|
|
229
|
+
await copyFile(entry.sourcePath, entry.targetPath);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function collectRelativeFiles(root) {
|
|
234
|
+
if (!(await pathExists(root))) {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
const files = await walkFiles(root);
|
|
238
|
+
return files
|
|
239
|
+
.map((file) => path.relative(root, file))
|
|
240
|
+
.sort((left, right) => left.localeCompare(right));
|
|
241
|
+
}
|
|
242
|
+
async function walkFiles(root) {
|
|
243
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
244
|
+
const files = [];
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
const fullPath = path.join(root, entry.name);
|
|
247
|
+
if (entry.isDirectory()) {
|
|
248
|
+
files.push(...(await walkFiles(fullPath)));
|
|
249
|
+
}
|
|
250
|
+
else if (entry.isFile()) {
|
|
251
|
+
files.push(fullPath);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return files;
|
|
255
|
+
}
|
|
256
|
+
async function assertDirectory(directoryPath, errorMessage) {
|
|
257
|
+
const children = await readdirOrNull(directoryPath);
|
|
258
|
+
if (children === null) {
|
|
259
|
+
throw new Error(errorMessage);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async function pathExists(targetPath) {
|
|
263
|
+
try {
|
|
264
|
+
await access(targetPath);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async function readdirOrNull(targetPath) {
|
|
272
|
+
try {
|
|
273
|
+
return await readdir(targetPath);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async function tryResolveLocalPath(source) {
|
|
280
|
+
const expandedSource = source.startsWith("~/") ? path.join(os.homedir(), source.slice(2)) : source;
|
|
281
|
+
const shouldTreatAsPath = expandedSource.startsWith(".") || expandedSource.startsWith("/") || expandedSource.startsWith("~");
|
|
282
|
+
if (!shouldTreatAsPath) {
|
|
283
|
+
const candidate = path.resolve(expandedSource);
|
|
284
|
+
if (!(await pathExists(candidate))) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
return candidate;
|
|
288
|
+
}
|
|
289
|
+
const resolved = path.resolve(expandedSource);
|
|
290
|
+
return (await pathExists(resolved)) ? resolved : null;
|
|
291
|
+
}
|
|
292
|
+
function parseGitHubSource(source) {
|
|
293
|
+
const shorthandMatch = source.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/);
|
|
294
|
+
if (shorthandMatch) {
|
|
295
|
+
const owner = shorthandMatch[1];
|
|
296
|
+
const repo = shorthandMatch[2];
|
|
297
|
+
if (!owner || !repo) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
return { cloneUrl: `https://github.com/${owner}/${stripGitSuffix(repo)}.git` };
|
|
301
|
+
}
|
|
302
|
+
const sshMatch = source.match(/^git@github\.com:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/);
|
|
303
|
+
if (sshMatch) {
|
|
304
|
+
return { cloneUrl: source };
|
|
305
|
+
}
|
|
306
|
+
let url;
|
|
307
|
+
try {
|
|
308
|
+
url = new URL(source);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
if (url.hostname !== "github.com") {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
317
|
+
if (segments.length < 2) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
const owner = segments[0];
|
|
321
|
+
const repoSegment = segments[1];
|
|
322
|
+
if (!owner || !repoSegment) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
const repo = stripGitSuffix(repoSegment);
|
|
326
|
+
const cloneUrl = `https://github.com/${owner}/${repo}.git`;
|
|
327
|
+
if (segments[2] === "tree" && segments[3]) {
|
|
328
|
+
return {
|
|
329
|
+
branch: segments[3],
|
|
330
|
+
cloneUrl,
|
|
331
|
+
subpath: segments.slice(4).join("/"),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return { cloneUrl };
|
|
335
|
+
}
|
|
336
|
+
function stripGitSuffix(repo) {
|
|
337
|
+
return repo.endsWith(".git") ? repo.slice(0, -4) : repo;
|
|
338
|
+
}
|
|
339
|
+
function isMarketplaceManifest(value) {
|
|
340
|
+
if (typeof value !== "object" || value === null) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
const record = value;
|
|
344
|
+
return Array.isArray(record.plugins) && record.plugins.every(isMarketplacePlugin);
|
|
345
|
+
}
|
|
346
|
+
function isMarketplacePlugin(value) {
|
|
347
|
+
if (typeof value !== "object" || value === null) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
const record = value;
|
|
351
|
+
return typeof record.name === "string" && "source" in record;
|
|
352
|
+
}
|
|
353
|
+
function isSamePath(left, right) {
|
|
354
|
+
return path.resolve(left) === path.resolve(right);
|
|
355
|
+
}
|
|
356
|
+
function isDescendantOf(child, parent) {
|
|
357
|
+
const relative = path.relative(parent, child);
|
|
358
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
359
|
+
}
|
|
360
|
+
async function createTempDirectory() {
|
|
361
|
+
return mkdtemp(path.join(os.tmpdir(), "copilot-ship-"));
|
|
362
|
+
}
|
|
363
|
+
function wrapExecError(prefix, error) {
|
|
364
|
+
if (typeof error === "object" && error !== null && "stderr" in error) {
|
|
365
|
+
const stderr = String(error.stderr ?? "").trim();
|
|
366
|
+
if (stderr.length > 0) {
|
|
367
|
+
return new Error(`${prefix}: ${stderr}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (error instanceof Error) {
|
|
371
|
+
return new Error(`${prefix}: ${error.message}`);
|
|
372
|
+
}
|
|
373
|
+
return new Error(prefix);
|
|
374
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "copilot-ship",
|
|
3
|
+
"version": "1.0.0-beta.1",
|
|
4
|
+
"description": "Ship GitHub Copilot plugin artifacts into canonical Copilot directories.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"copilot-ship": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"prepare": "npm run build",
|
|
16
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
17
|
+
"test": "npm run typecheck"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"copilot",
|
|
21
|
+
"github-copilot",
|
|
22
|
+
"cli",
|
|
23
|
+
"plugin"
|
|
24
|
+
],
|
|
25
|
+
"author": "",
|
|
26
|
+
"license": "ISC",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@clack/prompts": "^1.4.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
35
|
+
"@semantic-release/github": "^11.0.6",
|
|
36
|
+
"@semantic-release/npm": "^12.0.2",
|
|
37
|
+
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
38
|
+
"@types/node": "^25.8.0",
|
|
39
|
+
"semantic-release": "^24.2.9",
|
|
40
|
+
"typescript": "^6.0.3"
|
|
41
|
+
}
|
|
42
|
+
}
|