@maestroagora/agora 1.2.2 → 1.3.0

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.
@@ -1,400 +1,400 @@
1
- #!/usr/bin/env node
2
-
3
- import { constants } from "node:fs";
4
- import {
5
- access,
6
- cp,
7
- mkdir,
8
- readFile,
9
- readdir,
10
- rename,
11
- rm,
12
- stat,
13
- } from "node:fs/promises";
14
- import { homedir } from "node:os";
15
- import { dirname, join, relative, resolve } from "node:path";
16
- import { fileURLToPath } from "node:url";
17
-
18
- const SKILL_NAME = "agora";
19
- const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
20
- const PACKAGE_ROOT = resolve(SCRIPT_DIR, "..");
21
- const SOURCE_DIR = join(PACKAGE_ROOT, "skills", SKILL_NAME);
22
-
23
- const TARGETS = {
24
- shared: {
25
- description: "Shared Agent Skills path for Codex, Cursor, Gemini CLI, GitHub Copilot, and Windsurf",
26
- user: [".agents", "skills"],
27
- project: [".agents", "skills"],
28
- },
29
- codex: {
30
- description: "Codex CLI, IDE, and desktop app",
31
- user: [".agents", "skills"],
32
- project: [".agents", "skills"],
33
- },
34
- claude: {
35
- description: "Claude Code",
36
- user: [".claude", "skills"],
37
- project: [".claude", "skills"],
38
- },
39
- cursor: {
40
- description: "Cursor",
41
- user: [".cursor", "skills"],
42
- project: [".cursor", "skills"],
43
- },
44
- gemini: {
45
- description: "Gemini CLI",
46
- user: [".gemini", "skills"],
47
- project: [".gemini", "skills"],
48
- },
49
- copilot: {
50
- description: "GitHub Copilot CLI, coding agent, and VS Code agent mode",
51
- user: [".copilot", "skills"],
52
- project: [".github", "skills"],
53
- },
54
- windsurf: {
55
- description: "Windsurf Cascade",
56
- user: [".codeium", "windsurf", "skills"],
57
- project: [".windsurf", "skills"],
58
- },
59
- };
60
-
61
- const UNIVERSAL_TARGETS = ["shared", "claude"];
62
-
63
- function usage() {
64
- return `Install ${SKILL_NAME} into documented Agent Skills locations.
65
-
66
- Usage:
67
- agora [options]
68
-
69
- Options:
70
- --target <name> universal (default), shared, codex, claude, cursor,
71
- gemini, copilot, or windsurf. Accepts comma-separated names.
72
- --scope <scope> user (default) or project.
73
- --project <path> Project root for project scope. Defaults to the current directory.
74
- --home <path> Home directory override. Useful for CI and isolated installs.
75
- --force Replace a different existing copy at the exact skill destination.
76
- --dry-run Print destinations without changing files.
77
- --list-targets Show native target paths.
78
- -h, --help Show this help.
79
-
80
- Examples:
81
- agora --target universal
82
- agora --target universal --scope project --project .
83
- agora --target claude,cursor --force`;
84
- }
85
-
86
- function fail(message) {
87
- process.stderr.write(`Error: ${message}\n`);
88
- process.exitCode = 1;
89
- }
90
-
91
- function takeValue(argv, index, option) {
92
- const value = argv[index + 1];
93
- if (!value || value.startsWith("--")) {
94
- throw new Error(`${option} requires a value`);
95
- }
96
- return value;
97
- }
98
-
99
- function parseArgs(argv) {
100
- const options = {
101
- dryRun: false,
102
- force: false,
103
- help: false,
104
- home: homedir(),
105
- listTargets: false,
106
- project: process.cwd(),
107
- scope: "user",
108
- targets: [],
109
- };
110
-
111
- for (let index = 0; index < argv.length; index += 1) {
112
- const arg = argv[index];
113
- if (arg === "--") {
114
- continue;
115
- } else if (arg === "-h" || arg === "--help") {
116
- options.help = true;
117
- } else if (arg === "--dry-run") {
118
- options.dryRun = true;
119
- } else if (arg === "--force") {
120
- options.force = true;
121
- } else if (arg === "--list-targets") {
122
- options.listTargets = true;
123
- } else if (arg === "--target") {
124
- options.targets.push(...takeValue(argv, index, arg).split(","));
125
- index += 1;
126
- } else if (arg.startsWith("--target=")) {
127
- options.targets.push(...arg.slice("--target=".length).split(","));
128
- } else if (arg === "--scope") {
129
- options.scope = takeValue(argv, index, arg);
130
- index += 1;
131
- } else if (arg.startsWith("--scope=")) {
132
- options.scope = arg.slice("--scope=".length);
133
- } else if (arg === "--project") {
134
- options.project = takeValue(argv, index, arg);
135
- index += 1;
136
- } else if (arg.startsWith("--project=")) {
137
- options.project = arg.slice("--project=".length);
138
- } else if (arg === "--home") {
139
- options.home = takeValue(argv, index, arg);
140
- index += 1;
141
- } else if (arg.startsWith("--home=")) {
142
- options.home = arg.slice("--home=".length);
143
- } else {
144
- throw new Error(`unknown option: ${arg}`);
145
- }
146
- }
147
-
148
- options.targets = options.targets
149
- .map((target) => target.trim().toLowerCase())
150
- .filter(Boolean);
151
- if (options.targets.length === 0) options.targets = ["universal"];
152
- if (!new Set(["user", "project"]).has(options.scope)) {
153
- throw new Error("--scope must be user or project");
154
- }
155
-
156
- const knownTargets = new Set(["universal", ...Object.keys(TARGETS)]);
157
- for (const target of options.targets) {
158
- if (!knownTargets.has(target)) {
159
- throw new Error(`unknown target '${target}'`);
160
- }
161
- }
162
-
163
- options.home = resolve(options.home);
164
- options.project = resolve(options.project);
165
- return options;
166
- }
167
-
168
- function printTargets() {
169
- process.stdout.write("Target User path Project path\n");
170
- process.stdout.write("--------- -------------------------------- -----------------------\n");
171
- process.stdout.write("universal ~/.agents/skills + ~/.claude/skills .agents/skills + .claude/skills\n");
172
- for (const [name, target] of Object.entries(TARGETS)) {
173
- process.stdout.write(
174
- `${name.padEnd(9)} ~/${target.user.join("/").padEnd(31)} ${target.project.join("/")}\n`,
175
- );
176
- }
177
- }
178
-
179
- async function exists(path) {
180
- try {
181
- await access(path, constants.F_OK);
182
- return true;
183
- } catch {
184
- return false;
185
- }
186
- }
187
-
188
- async function listFiles(root, base = root) {
189
- const entries = await readdir(root, { withFileTypes: true });
190
- const files = [];
191
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
192
- const path = join(root, entry.name);
193
- if (entry.isDirectory()) {
194
- files.push(...(await listFiles(path, base)));
195
- } else if (entry.isFile()) {
196
- files.push(relative(base, path).replaceAll("\\", "/"));
197
- } else {
198
- throw new Error(`unsupported filesystem entry in skill: ${path}`);
199
- }
200
- }
201
- return files;
202
- }
203
-
204
- async function sameTree(left, right) {
205
- if (!(await exists(left)) || !(await exists(right))) return false;
206
- const [leftStat, rightStat] = await Promise.all([stat(left), stat(right)]);
207
- if (!leftStat.isDirectory() || !rightStat.isDirectory()) return false;
208
-
209
- const [leftFiles, rightFiles] = await Promise.all([listFiles(left), listFiles(right)]);
210
- if (leftFiles.length !== rightFiles.length) return false;
211
- if (leftFiles.some((file, index) => file !== rightFiles[index])) return false;
212
-
213
- for (const file of leftFiles) {
214
- const [leftContent, rightContent] = await Promise.all([
215
- readFile(join(left, file)),
216
- readFile(join(right, file)),
217
- ]);
218
- if (!leftContent.equals(rightContent)) return false;
219
- }
220
- return true;
221
- }
222
-
223
- function expandedTargets(targets) {
224
- const expanded = targets.flatMap((target) =>
225
- target === "universal" ? UNIVERSAL_TARGETS : [target],
226
- );
227
- return [...new Set(expanded)];
228
- }
229
-
230
- function destinations(options) {
231
- const base = options.scope === "user" ? options.home : options.project;
232
- const seen = new Set();
233
- const output = [];
234
-
235
- for (const targetName of expandedTargets(options.targets)) {
236
- const target = TARGETS[targetName];
237
- const destination = join(base, ...target[options.scope], SKILL_NAME);
238
- const key = destination.toLowerCase();
239
- if (seen.has(key)) continue;
240
- seen.add(key);
241
- output.push({ destination, targetName });
242
- }
243
- return output;
244
- }
245
-
246
- async function verifySource() {
247
- const required = [
248
- "SKILL.md",
249
- "agents/openai.yaml",
250
- "references/agora-marketing.md",
251
- ];
252
- for (const file of required) {
253
- if (!(await exists(join(SOURCE_DIR, file)))) {
254
- throw new Error(`package is missing skills/${SKILL_NAME}/${file}`);
255
- }
256
- }
257
- }
258
-
259
- async function buildPlan(requestedDestinations, options) {
260
- const plan = [];
261
- for (const requested of requestedDestinations) {
262
- const current = await sameTree(SOURCE_DIR, requested.destination);
263
- const destinationExists = await exists(requested.destination);
264
- plan.push({ ...requested, current, destinationExists });
265
- }
266
-
267
- const conflicts = plan.filter((item) => item.destinationExists && !item.current);
268
- if (conflicts.length > 0 && !options.force && !options.dryRun) {
269
- const paths = conflicts.map((item) => item.destination).join(", ");
270
- throw new Error(
271
- `${paths} already exists and differs; inspect ${conflicts.length === 1 ? "it" : "them"}, then rerun with --force to replace only those skill folders`,
272
- );
273
- }
274
- return plan;
275
- }
276
-
277
- function planResults(plan, options) {
278
- return plan.map((item) => ({
279
- action: item.current
280
- ? "current"
281
- : item.destinationExists
282
- ? options.force
283
- ? "would replace"
284
- : "needs --force"
285
- : "would install",
286
- destination: item.destination,
287
- targetName: item.targetName,
288
- }));
289
- }
290
-
291
- async function cleanPrepared(items) {
292
- for (const item of items) {
293
- if (item.temporary && (await exists(item.temporary))) {
294
- await rm(item.temporary, { force: true, recursive: true });
295
- }
296
- }
297
- }
298
-
299
- async function applyPlan(plan) {
300
- const changes = plan.filter((item) => !item.current);
301
- const token = `${process.pid}-${Date.now()}`;
302
-
303
- try {
304
- for (let index = 0; index < changes.length; index += 1) {
305
- const item = changes[index];
306
- const parent = dirname(item.destination);
307
- await mkdir(parent, { recursive: true });
308
- const stagingRoot = dirname(parent);
309
- await mkdir(stagingRoot, { recursive: true });
310
- item.temporary = join(stagingRoot, `.${SKILL_NAME}.install-${token}-${index}`);
311
- item.backup = join(stagingRoot, `.${SKILL_NAME}.backup-${token}-${index}`);
312
- await cp(SOURCE_DIR, item.temporary, { errorOnExist: true, recursive: true });
313
- }
314
- } catch (error) {
315
- await cleanPrepared(changes);
316
- throw new Error(`could not prepare every destination; no installed copy was changed: ${error.message}`);
317
- }
318
-
319
- try {
320
- for (const item of changes) {
321
- if (item.destinationExists) {
322
- await rename(item.destination, item.backup);
323
- item.backedUp = true;
324
- }
325
- await rename(item.temporary, item.destination);
326
- item.swapped = true;
327
- }
328
- } catch (error) {
329
- const rollbackErrors = [];
330
- for (const item of [...changes].reverse()) {
331
- try {
332
- if (item.swapped && (await exists(item.destination))) {
333
- await rm(item.destination, { force: true, recursive: true });
334
- }
335
- if (item.backedUp && (await exists(item.backup))) {
336
- await rename(item.backup, item.destination);
337
- }
338
- if (item.temporary && (await exists(item.temporary))) {
339
- await rm(item.temporary, { force: true, recursive: true });
340
- }
341
- } catch (rollbackError) {
342
- rollbackErrors.push(`${item.destination}: ${rollbackError.message}`);
343
- }
344
- }
345
- if (rollbackErrors.length > 0) {
346
- throw new Error(
347
- `install failed and rollback needs attention (${rollbackErrors.join("; ")}): ${error.message}`,
348
- );
349
- }
350
- throw new Error(`install failed; every destination was rolled back: ${error.message}`);
351
- }
352
-
353
- for (const item of changes) {
354
- if (item.backedUp && (await exists(item.backup))) {
355
- await rm(item.backup, { force: true, recursive: true });
356
- }
357
- }
358
-
359
- return plan.map((item) => ({
360
- action: item.current ? "current" : item.destinationExists ? "replaced" : "installed",
361
- destination: item.destination,
362
- targetName: item.targetName,
363
- }));
364
- }
365
-
366
- async function main() {
367
- let options;
368
- try {
369
- options = parseArgs(process.argv.slice(2));
370
- } catch (error) {
371
- fail(error.message);
372
- process.stderr.write(`\n${usage()}\n`);
373
- return;
374
- }
375
-
376
- if (options.help) {
377
- process.stdout.write(`${usage()}\n`);
378
- return;
379
- }
380
- if (options.listTargets) {
381
- printTargets();
382
- return;
383
- }
384
-
385
- try {
386
- await verifySource();
387
- const plan = await buildPlan(destinations(options), options);
388
- const results = options.dryRun ? planResults(plan, options) : await applyPlan(plan);
389
- process.stdout.write(`${options.dryRun ? "Install plan" : "Install result"}:\n`);
390
- for (const result of results) {
391
- process.stdout.write(
392
- ` ${result.action.padEnd(13)} ${result.destination} (${result.targetName})\n`,
393
- );
394
- }
395
- } catch (error) {
396
- fail(error.message);
397
- }
398
- }
399
-
400
- await main();
1
+ #!/usr/bin/env node
2
+
3
+ import { constants } from "node:fs";
4
+ import {
5
+ access,
6
+ cp,
7
+ mkdir,
8
+ readFile,
9
+ readdir,
10
+ rename,
11
+ rm,
12
+ stat,
13
+ } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join, relative, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const SKILL_NAME = "agora";
19
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
20
+ const PACKAGE_ROOT = resolve(SCRIPT_DIR, "..");
21
+ const SOURCE_DIR = join(PACKAGE_ROOT, "skills", SKILL_NAME);
22
+
23
+ const TARGETS = {
24
+ shared: {
25
+ description: "Shared Agent Skills path for Codex, Cursor, Gemini CLI, GitHub Copilot, and Windsurf",
26
+ user: [".agents", "skills"],
27
+ project: [".agents", "skills"],
28
+ },
29
+ codex: {
30
+ description: "Codex CLI, IDE, and desktop app",
31
+ user: [".agents", "skills"],
32
+ project: [".agents", "skills"],
33
+ },
34
+ claude: {
35
+ description: "Claude Code",
36
+ user: [".claude", "skills"],
37
+ project: [".claude", "skills"],
38
+ },
39
+ cursor: {
40
+ description: "Cursor",
41
+ user: [".cursor", "skills"],
42
+ project: [".cursor", "skills"],
43
+ },
44
+ gemini: {
45
+ description: "Gemini CLI",
46
+ user: [".gemini", "skills"],
47
+ project: [".gemini", "skills"],
48
+ },
49
+ copilot: {
50
+ description: "GitHub Copilot CLI, coding agent, and VS Code agent mode",
51
+ user: [".copilot", "skills"],
52
+ project: [".github", "skills"],
53
+ },
54
+ windsurf: {
55
+ description: "Windsurf Cascade",
56
+ user: [".codeium", "windsurf", "skills"],
57
+ project: [".windsurf", "skills"],
58
+ },
59
+ };
60
+
61
+ const UNIVERSAL_TARGETS = ["shared", "claude"];
62
+
63
+ function usage() {
64
+ return `Install ${SKILL_NAME} into documented Agent Skills locations.
65
+
66
+ Usage:
67
+ agora [options]
68
+
69
+ Options:
70
+ --target <name> universal (default), shared, codex, claude, cursor,
71
+ gemini, copilot, or windsurf. Accepts comma-separated names.
72
+ --scope <scope> user (default) or project.
73
+ --project <path> Project root for project scope. Defaults to the current directory.
74
+ --home <path> Home directory override. Useful for CI and isolated installs.
75
+ --force Replace a different existing copy at the exact skill destination.
76
+ --dry-run Print destinations without changing files.
77
+ --list-targets Show native target paths.
78
+ -h, --help Show this help.
79
+
80
+ Examples:
81
+ agora --target universal
82
+ agora --target universal --scope project --project .
83
+ agora --target claude,cursor --force`;
84
+ }
85
+
86
+ function fail(message) {
87
+ process.stderr.write(`Error: ${message}\n`);
88
+ process.exitCode = 1;
89
+ }
90
+
91
+ function takeValue(argv, index, option) {
92
+ const value = argv[index + 1];
93
+ if (!value || value.startsWith("--")) {
94
+ throw new Error(`${option} requires a value`);
95
+ }
96
+ return value;
97
+ }
98
+
99
+ function parseArgs(argv) {
100
+ const options = {
101
+ dryRun: false,
102
+ force: false,
103
+ help: false,
104
+ home: homedir(),
105
+ listTargets: false,
106
+ project: process.cwd(),
107
+ scope: "user",
108
+ targets: [],
109
+ };
110
+
111
+ for (let index = 0; index < argv.length; index += 1) {
112
+ const arg = argv[index];
113
+ if (arg === "--") {
114
+ continue;
115
+ } else if (arg === "-h" || arg === "--help") {
116
+ options.help = true;
117
+ } else if (arg === "--dry-run") {
118
+ options.dryRun = true;
119
+ } else if (arg === "--force") {
120
+ options.force = true;
121
+ } else if (arg === "--list-targets") {
122
+ options.listTargets = true;
123
+ } else if (arg === "--target") {
124
+ options.targets.push(...takeValue(argv, index, arg).split(","));
125
+ index += 1;
126
+ } else if (arg.startsWith("--target=")) {
127
+ options.targets.push(...arg.slice("--target=".length).split(","));
128
+ } else if (arg === "--scope") {
129
+ options.scope = takeValue(argv, index, arg);
130
+ index += 1;
131
+ } else if (arg.startsWith("--scope=")) {
132
+ options.scope = arg.slice("--scope=".length);
133
+ } else if (arg === "--project") {
134
+ options.project = takeValue(argv, index, arg);
135
+ index += 1;
136
+ } else if (arg.startsWith("--project=")) {
137
+ options.project = arg.slice("--project=".length);
138
+ } else if (arg === "--home") {
139
+ options.home = takeValue(argv, index, arg);
140
+ index += 1;
141
+ } else if (arg.startsWith("--home=")) {
142
+ options.home = arg.slice("--home=".length);
143
+ } else {
144
+ throw new Error(`unknown option: ${arg}`);
145
+ }
146
+ }
147
+
148
+ options.targets = options.targets
149
+ .map((target) => target.trim().toLowerCase())
150
+ .filter(Boolean);
151
+ if (options.targets.length === 0) options.targets = ["universal"];
152
+ if (!new Set(["user", "project"]).has(options.scope)) {
153
+ throw new Error("--scope must be user or project");
154
+ }
155
+
156
+ const knownTargets = new Set(["universal", ...Object.keys(TARGETS)]);
157
+ for (const target of options.targets) {
158
+ if (!knownTargets.has(target)) {
159
+ throw new Error(`unknown target '${target}'`);
160
+ }
161
+ }
162
+
163
+ options.home = resolve(options.home);
164
+ options.project = resolve(options.project);
165
+ return options;
166
+ }
167
+
168
+ function printTargets() {
169
+ process.stdout.write("Target User path Project path\n");
170
+ process.stdout.write("--------- -------------------------------- -----------------------\n");
171
+ process.stdout.write("universal ~/.agents/skills + ~/.claude/skills .agents/skills + .claude/skills\n");
172
+ for (const [name, target] of Object.entries(TARGETS)) {
173
+ process.stdout.write(
174
+ `${name.padEnd(9)} ~/${target.user.join("/").padEnd(31)} ${target.project.join("/")}\n`,
175
+ );
176
+ }
177
+ }
178
+
179
+ async function exists(path) {
180
+ try {
181
+ await access(path, constants.F_OK);
182
+ return true;
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ async function listFiles(root, base = root) {
189
+ const entries = await readdir(root, { withFileTypes: true });
190
+ const files = [];
191
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
192
+ const path = join(root, entry.name);
193
+ if (entry.isDirectory()) {
194
+ files.push(...(await listFiles(path, base)));
195
+ } else if (entry.isFile()) {
196
+ files.push(relative(base, path).replaceAll("\\", "/"));
197
+ } else {
198
+ throw new Error(`unsupported filesystem entry in skill: ${path}`);
199
+ }
200
+ }
201
+ return files;
202
+ }
203
+
204
+ async function sameTree(left, right) {
205
+ if (!(await exists(left)) || !(await exists(right))) return false;
206
+ const [leftStat, rightStat] = await Promise.all([stat(left), stat(right)]);
207
+ if (!leftStat.isDirectory() || !rightStat.isDirectory()) return false;
208
+
209
+ const [leftFiles, rightFiles] = await Promise.all([listFiles(left), listFiles(right)]);
210
+ if (leftFiles.length !== rightFiles.length) return false;
211
+ if (leftFiles.some((file, index) => file !== rightFiles[index])) return false;
212
+
213
+ for (const file of leftFiles) {
214
+ const [leftContent, rightContent] = await Promise.all([
215
+ readFile(join(left, file)),
216
+ readFile(join(right, file)),
217
+ ]);
218
+ if (!leftContent.equals(rightContent)) return false;
219
+ }
220
+ return true;
221
+ }
222
+
223
+ function expandedTargets(targets) {
224
+ const expanded = targets.flatMap((target) =>
225
+ target === "universal" ? UNIVERSAL_TARGETS : [target],
226
+ );
227
+ return [...new Set(expanded)];
228
+ }
229
+
230
+ function destinations(options) {
231
+ const base = options.scope === "user" ? options.home : options.project;
232
+ const seen = new Set();
233
+ const output = [];
234
+
235
+ for (const targetName of expandedTargets(options.targets)) {
236
+ const target = TARGETS[targetName];
237
+ const destination = join(base, ...target[options.scope], SKILL_NAME);
238
+ const key = destination.toLowerCase();
239
+ if (seen.has(key)) continue;
240
+ seen.add(key);
241
+ output.push({ destination, targetName });
242
+ }
243
+ return output;
244
+ }
245
+
246
+ async function verifySource() {
247
+ const required = [
248
+ "SKILL.md",
249
+ "agents/openai.yaml",
250
+ "references/agora-marketing.md",
251
+ ];
252
+ for (const file of required) {
253
+ if (!(await exists(join(SOURCE_DIR, file)))) {
254
+ throw new Error(`package is missing skills/${SKILL_NAME}/${file}`);
255
+ }
256
+ }
257
+ }
258
+
259
+ async function buildPlan(requestedDestinations, options) {
260
+ const plan = [];
261
+ for (const requested of requestedDestinations) {
262
+ const current = await sameTree(SOURCE_DIR, requested.destination);
263
+ const destinationExists = await exists(requested.destination);
264
+ plan.push({ ...requested, current, destinationExists });
265
+ }
266
+
267
+ const conflicts = plan.filter((item) => item.destinationExists && !item.current);
268
+ if (conflicts.length > 0 && !options.force && !options.dryRun) {
269
+ const paths = conflicts.map((item) => item.destination).join(", ");
270
+ throw new Error(
271
+ `${paths} already exists and differs; inspect ${conflicts.length === 1 ? "it" : "them"}, then rerun with --force to replace only those skill folders`,
272
+ );
273
+ }
274
+ return plan;
275
+ }
276
+
277
+ function planResults(plan, options) {
278
+ return plan.map((item) => ({
279
+ action: item.current
280
+ ? "current"
281
+ : item.destinationExists
282
+ ? options.force
283
+ ? "would replace"
284
+ : "needs --force"
285
+ : "would install",
286
+ destination: item.destination,
287
+ targetName: item.targetName,
288
+ }));
289
+ }
290
+
291
+ async function cleanPrepared(items) {
292
+ for (const item of items) {
293
+ if (item.temporary && (await exists(item.temporary))) {
294
+ await rm(item.temporary, { force: true, recursive: true });
295
+ }
296
+ }
297
+ }
298
+
299
+ async function applyPlan(plan) {
300
+ const changes = plan.filter((item) => !item.current);
301
+ const token = `${process.pid}-${Date.now()}`;
302
+
303
+ try {
304
+ for (let index = 0; index < changes.length; index += 1) {
305
+ const item = changes[index];
306
+ const parent = dirname(item.destination);
307
+ await mkdir(parent, { recursive: true });
308
+ const stagingRoot = dirname(parent);
309
+ await mkdir(stagingRoot, { recursive: true });
310
+ item.temporary = join(stagingRoot, `.${SKILL_NAME}.install-${token}-${index}`);
311
+ item.backup = join(stagingRoot, `.${SKILL_NAME}.backup-${token}-${index}`);
312
+ await cp(SOURCE_DIR, item.temporary, { errorOnExist: true, recursive: true });
313
+ }
314
+ } catch (error) {
315
+ await cleanPrepared(changes);
316
+ throw new Error(`could not prepare every destination; no installed copy was changed: ${error.message}`);
317
+ }
318
+
319
+ try {
320
+ for (const item of changes) {
321
+ if (item.destinationExists) {
322
+ await rename(item.destination, item.backup);
323
+ item.backedUp = true;
324
+ }
325
+ await rename(item.temporary, item.destination);
326
+ item.swapped = true;
327
+ }
328
+ } catch (error) {
329
+ const rollbackErrors = [];
330
+ for (const item of [...changes].reverse()) {
331
+ try {
332
+ if (item.swapped && (await exists(item.destination))) {
333
+ await rm(item.destination, { force: true, recursive: true });
334
+ }
335
+ if (item.backedUp && (await exists(item.backup))) {
336
+ await rename(item.backup, item.destination);
337
+ }
338
+ if (item.temporary && (await exists(item.temporary))) {
339
+ await rm(item.temporary, { force: true, recursive: true });
340
+ }
341
+ } catch (rollbackError) {
342
+ rollbackErrors.push(`${item.destination}: ${rollbackError.message}`);
343
+ }
344
+ }
345
+ if (rollbackErrors.length > 0) {
346
+ throw new Error(
347
+ `install failed and rollback needs attention (${rollbackErrors.join("; ")}): ${error.message}`,
348
+ );
349
+ }
350
+ throw new Error(`install failed; every destination was rolled back: ${error.message}`);
351
+ }
352
+
353
+ for (const item of changes) {
354
+ if (item.backedUp && (await exists(item.backup))) {
355
+ await rm(item.backup, { force: true, recursive: true });
356
+ }
357
+ }
358
+
359
+ return plan.map((item) => ({
360
+ action: item.current ? "current" : item.destinationExists ? "replaced" : "installed",
361
+ destination: item.destination,
362
+ targetName: item.targetName,
363
+ }));
364
+ }
365
+
366
+ async function main() {
367
+ let options;
368
+ try {
369
+ options = parseArgs(process.argv.slice(2));
370
+ } catch (error) {
371
+ fail(error.message);
372
+ process.stderr.write(`\n${usage()}\n`);
373
+ return;
374
+ }
375
+
376
+ if (options.help) {
377
+ process.stdout.write(`${usage()}\n`);
378
+ return;
379
+ }
380
+ if (options.listTargets) {
381
+ printTargets();
382
+ return;
383
+ }
384
+
385
+ try {
386
+ await verifySource();
387
+ const plan = await buildPlan(destinations(options), options);
388
+ const results = options.dryRun ? planResults(plan, options) : await applyPlan(plan);
389
+ process.stdout.write(`${options.dryRun ? "Install plan" : "Install result"}:\n`);
390
+ for (const result of results) {
391
+ process.stdout.write(
392
+ ` ${result.action.padEnd(13)} ${result.destination} (${result.targetName})\n`,
393
+ );
394
+ }
395
+ } catch (error) {
396
+ fail(error.message);
397
+ }
398
+ }
399
+
400
+ await main();