msdevflow 0.6.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.
@@ -0,0 +1,923 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import {
4
+ closeSync,
5
+ cpSync,
6
+ fchmodSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ readdirSync,
12
+ readSync,
13
+ renameSync,
14
+ rmSync,
15
+ unlinkSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import path from "node:path";
20
+ import process from "node:process";
21
+ import { createInterface } from "node:readline/promises";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const REGISTRY = "https://registry.npmjs.org";
25
+ const GITCODE_PACKAGE = "@gitcode-cli/cli@latest";
26
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
27
+ const BUNDLED_SKILL_DIR = path.join(PACKAGE_ROOT, "skill", "msdevflow");
28
+ const REQUIRED_SCHEMAS = {
29
+ "auth login": ["web"],
30
+ "auth status": ["json"],
31
+ "issue create": ["body-file", "dry-run", "json", "repo", "title"],
32
+ "issue list": ["assignee", "json", "repo", "state"],
33
+ "issue view": ["comments", "json", "repo"],
34
+ "issue edit": ["assignee", "json", "repo", "state"],
35
+ "issue comment": ["body-file", "json", "repo"],
36
+ "issue prs": ["json", "repo"],
37
+ "pr create": ["base", "body-file", "fork", "head", "json", "repo", "title"],
38
+ "pr list": ["direction", "head", "json", "limit", "repo", "sort", "state"],
39
+ "pr view": ["comments", "json", "repo"],
40
+ "pr diff": ["json", "repo"],
41
+ "pr comments": ["json", "repo"],
42
+ "pr comment": ["body-file", "json", "path", "position", "repo"],
43
+ "pr reply": ["body", "discussion", "repo"],
44
+ "pr merge": ["json", "method", "repo", "yes"],
45
+ };
46
+
47
+ export class BootstrapError extends Error {
48
+ constructor(message, exitCode = 1) {
49
+ super(message);
50
+ this.exitCode = exitCode;
51
+ }
52
+ }
53
+
54
+ export function usage() {
55
+ return `Usage:
56
+ msdevflow setup [options]
57
+
58
+ Options:
59
+ --skills-dir <path> Installed skills directory (default: ~/.claude/skills)
60
+ --dry-run Diagnose and print the plan without changing the environment
61
+ --yes Apply the displayed plan without an interactive confirmation
62
+ --json Print the final result as JSON
63
+ -h, --help Show this help
64
+
65
+ The setup installs or updates the bundled msdevflow skill, installs or upgrades
66
+ the official GitCode npm CLI, and installs all reviewed Python runtime
67
+ dependencies. It never downloads or launches Chromium. If GitCode is not
68
+ authenticated, it starts the CLI's official browser login; setup never reads or
69
+ prints a token.`;
70
+ }
71
+
72
+ export function parseArgs(argv) {
73
+ if (argv.includes("--help") || argv.includes("-h")) {
74
+ return { help: true };
75
+ }
76
+ if (argv[0] !== "setup") {
77
+ throw new BootstrapError("Expected the setup command.\n\n" + usage(), 2);
78
+ }
79
+
80
+ const options = {
81
+ skillsDir: "",
82
+ dryRun: false,
83
+ yes: false,
84
+ json: false,
85
+ };
86
+ for (let index = 1; index < argv.length; index += 1) {
87
+ const argument = argv[index];
88
+ if (argument === "--skills-dir") {
89
+ index += 1;
90
+ if (!argv[index]) {
91
+ throw new BootstrapError("--skills-dir requires a path.", 2);
92
+ }
93
+ options.skillsDir = argv[index];
94
+ continue;
95
+ }
96
+ if (argument === "--dry-run") {
97
+ options.dryRun = true;
98
+ continue;
99
+ }
100
+ if (argument === "--yes") {
101
+ options.yes = true;
102
+ continue;
103
+ }
104
+ if (argument === "--json") {
105
+ options.json = true;
106
+ continue;
107
+ }
108
+ throw new BootstrapError(`Unknown argument: ${argument}`, 2);
109
+ }
110
+ return options;
111
+ }
112
+
113
+ function defaultSkillsDir(environment) {
114
+ return environment.CLAUDE_SKILLS_DIR || path.join(homedir(), ".claude", "skills");
115
+ }
116
+
117
+ export function resolveLayout(options, environment = process.env, bundledSkillDir = BUNDLED_SKILL_DIR) {
118
+ const skillsDir = path.resolve(options.skillsDir || defaultSkillsDir(environment));
119
+ const workflowDir = path.join(skillsDir, "msdevflow");
120
+ const bundledWorkflowDir = path.resolve(bundledSkillDir);
121
+ return {
122
+ skillsDir,
123
+ workflowDir,
124
+ workflowManifest: path.join(workflowDir, "SKILL.md"),
125
+ pythonRequirements: path.join(workflowDir, "scripts", "requirements.txt"),
126
+ bundledSkillDir: bundledWorkflowDir,
127
+ bundledManifest: path.join(bundledWorkflowDir, "SKILL.md"),
128
+ bundledPythonRequirements: path.join(bundledWorkflowDir, "scripts", "requirements.txt"),
129
+ };
130
+ }
131
+
132
+ function namedCommand(command, args, platform) {
133
+ return executableCommand(platform === "win32" && command === "npm" ? "npm.cmd" : command, args, platform);
134
+ }
135
+
136
+ function executableCommand(executable, args, platform) {
137
+ if (platform !== "win32") {
138
+ return { command: executable, args };
139
+ }
140
+ if (/\.ps1$/i.test(executable)) {
141
+ return {
142
+ command: "powershell.exe",
143
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", executable, ...args],
144
+ };
145
+ }
146
+ if (!/\.(?:cmd|bat)$/i.test(executable)) {
147
+ return { command: executable, args };
148
+ }
149
+ return {
150
+ command: "powershell.exe",
151
+ args: [
152
+ "-NoProfile",
153
+ "-Command",
154
+ "& { $exe = $args[0]; $rest = @($args | Select-Object -Skip 1); & $exe @rest; exit $LASTEXITCODE }",
155
+ executable,
156
+ ...args,
157
+ ],
158
+ };
159
+ }
160
+
161
+ export function formatCommand(invocation) {
162
+ return [invocation.command, ...invocation.args]
163
+ .map((value) => (/^[A-Za-z0-9_./:@=\\-]+$/.test(value) ? value : JSON.stringify(value)))
164
+ .join(" ");
165
+ }
166
+
167
+ function execute(invocation, { allowFailure = false, inherit = false, outputToStderr = false } = {}) {
168
+ const result = spawnSync(invocation.command, invocation.args, {
169
+ encoding: "utf8",
170
+ stdio: inherit
171
+ ? ["inherit", outputToStderr ? process.stderr : "inherit", "inherit"]
172
+ : "pipe",
173
+ maxBuffer: 20 * 1024 * 1024,
174
+ windowsHide: false,
175
+ });
176
+ if (result.error) {
177
+ throw new BootstrapError(`Failed to run ${invocation.command}: ${result.error.message}`);
178
+ }
179
+ const status = result.status ?? 1;
180
+ if (status !== 0 && !allowFailure) {
181
+ const detail = inherit ? "" : `\n${(result.stderr || result.stdout || "").trim()}`;
182
+ throw new BootstrapError(`Command failed (${status}): ${formatCommand(invocation)}${detail}`);
183
+ }
184
+ return {
185
+ status,
186
+ stdout: result.stdout || "",
187
+ stderr: result.stderr || "",
188
+ };
189
+ }
190
+
191
+ function runOptional(run, invocation) {
192
+ try {
193
+ return run(invocation, { allowFailure: true });
194
+ } catch (error) {
195
+ if (error instanceof BootstrapError && error.message.startsWith("Failed to run ")) {
196
+ return { status: 1, stdout: "", stderr: error.message };
197
+ }
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ function verifyFile(file, missingMessage) {
203
+ let descriptor;
204
+ try {
205
+ descriptor = openSync(file, "r");
206
+ } catch (error) {
207
+ if (error?.code === "ENOENT") {
208
+ throw new BootstrapError(missingMessage);
209
+ }
210
+ throw error;
211
+ } finally {
212
+ if (descriptor !== undefined) {
213
+ closeSync(descriptor);
214
+ }
215
+ }
216
+ }
217
+
218
+ function requireFile(file, description) {
219
+ verifyFile(file, `${description} not found: ${file}. Reinstall the msdevflow npm package.`);
220
+ }
221
+
222
+ function skillManifestIsOwned(file) {
223
+ try {
224
+ const metadata = lstatSync(file);
225
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
226
+ return false;
227
+ }
228
+ const frontmatter = readFileSync(file, "utf8").match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
229
+ if (frontmatter === null) {
230
+ return false;
231
+ }
232
+ const names = [...frontmatter[1].matchAll(/^name:\s*([^\s#]+)\s*$/gm)];
233
+ return names.length === 1 && names[0][1] === "msdevflow";
234
+ } catch (error) {
235
+ if (error?.code === "ENOENT") {
236
+ return false;
237
+ }
238
+ throw error;
239
+ }
240
+ }
241
+
242
+ function skillEntries(root) {
243
+ const entries = [];
244
+ const visit = (directory, relativeDirectory = "") => {
245
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
246
+ const relative = path.join(relativeDirectory, entry.name);
247
+ const absolute = path.join(directory, entry.name);
248
+ if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
249
+ throw new BootstrapError(`Bundled skill contains an unsupported entry: ${absolute}`, 3);
250
+ }
251
+ if (entry.isDirectory()) {
252
+ visit(absolute, relative);
253
+ } else {
254
+ entries.push(relative);
255
+ }
256
+ }
257
+ };
258
+ visit(root);
259
+ return entries.sort();
260
+ }
261
+
262
+ function skillDigest(root) {
263
+ const digest = createHash("sha256");
264
+ for (const relative of skillEntries(root)) {
265
+ digest.update(relative.replaceAll("\\", "/"));
266
+ digest.update("\0");
267
+ digest.update(readFileSync(path.join(root, relative)));
268
+ digest.update("\0");
269
+ }
270
+ return digest.digest("hex");
271
+ }
272
+
273
+ function skillInstallDetails(layout) {
274
+ requireFile(layout.bundledManifest, "bundled msdevflow skill manifest");
275
+ requireFile(layout.bundledPythonRequirements, "bundled workflow Python requirements file");
276
+ if (!skillManifestIsOwned(layout.bundledManifest)) {
277
+ throw new BootstrapError("Bundled skill manifest does not identify msdevflow.", 3);
278
+ }
279
+ validatePythonRequirements(layout.bundledPythonRequirements);
280
+ const bundledDigest = skillDigest(layout.bundledSkillDir);
281
+ let status = "absent";
282
+ let existingDigest = null;
283
+ try {
284
+ const metadata = lstatSync(layout.workflowDir);
285
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()
286
+ || !skillManifestIsOwned(layout.workflowManifest)) {
287
+ throw new BootstrapError(
288
+ `Refusing to overwrite a skills entry not owned by msdevflow: ${layout.workflowDir}`,
289
+ 3,
290
+ );
291
+ }
292
+ existingDigest = skillDigest(layout.workflowDir);
293
+ status = existingDigest === bundledDigest ? "current" : "update";
294
+ } catch (error) {
295
+ if (error?.code !== "ENOENT") {
296
+ throw error;
297
+ }
298
+ }
299
+ return {
300
+ source: layout.bundledSkillDir,
301
+ target: layout.workflowDir,
302
+ status,
303
+ bundledDigest,
304
+ existingDigest,
305
+ };
306
+ }
307
+
308
+ function installBundledSkill(details) {
309
+ const parent = path.dirname(details.target);
310
+ mkdirSync(parent, { recursive: true });
311
+ const current = skillInstallDetails({
312
+ bundledSkillDir: details.source,
313
+ bundledManifest: path.join(details.source, "SKILL.md"),
314
+ bundledPythonRequirements: path.join(details.source, "scripts", "requirements.txt"),
315
+ workflowDir: details.target,
316
+ workflowManifest: path.join(details.target, "SKILL.md"),
317
+ });
318
+ if (current.status !== details.status || current.existingDigest !== details.existingDigest) {
319
+ throw new BootstrapError("msdevflow skill target changed after confirmation; refusing to overwrite it.", 3);
320
+ }
321
+ if (current.status === "current") {
322
+ return "current";
323
+ }
324
+ const staging = path.join(parent, `.msdevflow-${randomUUID()}.tmp`);
325
+ const backup = path.join(parent, `.msdevflow-${randomUUID()}.backup`);
326
+ let movedExisting = false;
327
+ try {
328
+ cpSync(details.source, staging, { recursive: true, errorOnExist: true });
329
+ if (skillDigest(staging) !== details.bundledDigest) {
330
+ throw new BootstrapError("Bundled skill verification failed after staging.", 3);
331
+ }
332
+ if (current.status === "update") {
333
+ renameSync(details.target, backup);
334
+ movedExisting = true;
335
+ }
336
+ try {
337
+ renameSync(staging, details.target);
338
+ } catch (error) {
339
+ if (movedExisting) {
340
+ renameSync(backup, details.target);
341
+ movedExisting = false;
342
+ }
343
+ throw error;
344
+ }
345
+ if (movedExisting) {
346
+ movedExisting = false;
347
+ try {
348
+ rmSync(backup, { recursive: true, force: true });
349
+ } catch {
350
+ // The verified target is already live; stale backup cleanup must not invalidate it.
351
+ }
352
+ }
353
+ return current.status === "absent" ? "installed" : "updated";
354
+ } finally {
355
+ rmSync(staging, { recursive: true, force: true });
356
+ if (movedExisting) {
357
+ try {
358
+ renameSync(backup, details.target);
359
+ } catch {
360
+ throw new BootstrapError(`Failed to restore previous msdevflow skill from ${backup}.`, 3);
361
+ }
362
+ }
363
+ }
364
+ }
365
+
366
+ function validatePythonRequirements(file) {
367
+ const requirements = readFileSync(file, "utf8")
368
+ .split(/\r?\n/)
369
+ .map((line) => line.trim())
370
+ .filter((line) => line && !line.startsWith("#"));
371
+ const playwrightRequirement = /^playwright(?:\[[a-z0-9_,.-]+\])?(?:(?:===|==|~=|!=|<=|>=|<|>)[a-z0-9.*+_-]+(?:,(?:===|==|~=|!=|<=|>=|<|>)[a-z0-9.*+_-]+)*)?$/i;
372
+ if (requirements.length !== 1 || !playwrightRequirement.test(requirements[0])) {
373
+ throw new BootstrapError("Unexpected workflow requirements; refusing to install unreviewed Python packages.", 3);
374
+ }
375
+ }
376
+
377
+ function firstOutputLine(output) {
378
+ return output.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || "";
379
+ }
380
+
381
+ export function resolveExecutable(name, run, platform) {
382
+ const invocation = platform === "win32"
383
+ ? { command: "where.exe", args: [name] }
384
+ : { command: "sh", args: ["-c", "command -v -- \"$1\"", "sh", name] };
385
+ const result = runOptional(run, invocation);
386
+ return result.status === 0 ? firstOutputLine(result.stdout) : "";
387
+ }
388
+
389
+ function normalizedPath(value, platform) {
390
+ const normalized = value.replaceAll("\\", "/").replace(/\/+$/, "");
391
+ return platform === "win32" ? normalized.toLowerCase() : normalized;
392
+ }
393
+
394
+ function executableDirectory(value, platform) {
395
+ return platform === "win32" ? path.win32.dirname(value) : path.posix.dirname(value);
396
+ }
397
+
398
+ function hasPythonShebang(file) {
399
+ let descriptor;
400
+ try {
401
+ descriptor = openSync(file, "r");
402
+ const buffer = Buffer.alloc(256);
403
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
404
+ return /^#!.*\bpython(?:[0-9.]*)?\b/i.test(buffer.toString("utf8", 0, bytesRead).split(/\r?\n/, 1)[0]);
405
+ } catch {
406
+ return false;
407
+ } finally {
408
+ if (descriptor !== undefined) {
409
+ closeSync(descriptor);
410
+ }
411
+ }
412
+ }
413
+
414
+ function pythonScriptDirectories(run, platform) {
415
+ const candidates = platform === "win32"
416
+ ? [{ command: "py", prefix: ["-3"] }, { command: "python", prefix: [] }, { command: "python3", prefix: [] }]
417
+ : [{ command: "python3", prefix: [] }, { command: "python", prefix: [] }];
418
+ const probe = "import json,sysconfig; print(json.dumps(sysconfig.get_path('scripts')))";
419
+ const directories = [];
420
+ for (const candidate of candidates) {
421
+ const result = runOptional(run, {
422
+ command: candidate.command,
423
+ args: [...candidate.prefix, "-c", probe],
424
+ });
425
+ if (result.status !== 0) {
426
+ continue;
427
+ }
428
+ try {
429
+ const directory = JSON.parse(result.stdout.trim());
430
+ if (typeof directory === "string" && directory) {
431
+ directories.push(directory);
432
+ }
433
+ } catch {
434
+ continue;
435
+ }
436
+ }
437
+ return directories;
438
+ }
439
+
440
+ function isPythonGitcode(executable, run, platform) {
441
+ if (/[\\/]Python[^\\/]*[\\/]Scripts[\\/]gitcode(?:\.exe)?$/i.test(executable)
442
+ || /[\\/]pipx[\\/]/i.test(executable)
443
+ || hasPythonShebang(executable)) {
444
+ return true;
445
+ }
446
+ const directory = normalizedPath(executableDirectory(executable, platform), platform);
447
+ return pythonScriptDirectories(run, platform)
448
+ .some((candidate) => normalizedPath(candidate, platform) === directory);
449
+ }
450
+
451
+ export function diagnoseGitcode(run, platform) {
452
+ const executable = resolveExecutable("gitcode", run, platform);
453
+ if (!executable) {
454
+ return {
455
+ classification: "absent",
456
+ existingExecutable: null,
457
+ workflowCommand: "gitcode",
458
+ };
459
+ }
460
+ if (isPythonGitcode(executable, run, platform)) {
461
+ return {
462
+ classification: "python",
463
+ existingExecutable: executable,
464
+ workflowCommand: "gitcode-npm",
465
+ };
466
+ }
467
+ const doctor = runOptional(run, executableCommand(executable, ["doctor", "install", "--json"], platform));
468
+ if (doctor.status === 0) {
469
+ try {
470
+ const metadata = JSON.parse(doctor.stdout);
471
+ if (metadata.distribution === "npm") {
472
+ return {
473
+ classification: "npm-official",
474
+ existingExecutable: executable,
475
+ workflowCommand: "gitcode",
476
+ };
477
+ }
478
+ } catch {
479
+ // Fall through to the fail-closed ownership error.
480
+ }
481
+ }
482
+ throw new BootstrapError(`Existing gitcode has unknown ownership: ${executable}. Refusing to overwrite it.`, 3);
483
+ }
484
+
485
+ function detectPython(run, platform) {
486
+ const candidates = platform === "win32"
487
+ ? [{ command: "py", prefix: ["-3"] }, { command: "python", prefix: [] }, { command: "python3", prefix: [] }]
488
+ : [{ command: "python3", prefix: [] }, { command: "python", prefix: [] }];
489
+ const probe = "import json,sys; print(json.dumps({'executable':sys.executable,'version':list(sys.version_info[:3])}))";
490
+ for (const candidate of candidates) {
491
+ const result = runOptional(run, { command: candidate.command, args: [...candidate.prefix, "-c", probe] });
492
+ if (result.status !== 0) {
493
+ continue;
494
+ }
495
+ try {
496
+ const data = JSON.parse(result.stdout.trim());
497
+ if (data.version[0] > 3 || (data.version[0] === 3 && data.version[1] >= 10)) {
498
+ return data;
499
+ }
500
+ } catch {
501
+ continue;
502
+ }
503
+ }
504
+ throw new BootstrapError("Python >=3.10 is required for workflow scripts.", 2);
505
+ }
506
+
507
+ function environmentHome(environment, platform) {
508
+ if (platform === "win32") {
509
+ return environment.USERPROFILE || homedir();
510
+ }
511
+ return environment.HOME || homedir();
512
+ }
513
+
514
+ export function gitcodeInstallDetails(
515
+ classification,
516
+ environment = process.env,
517
+ platform = process.platform,
518
+ npmPrefix = "",
519
+ ) {
520
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
521
+ if (classification === "python") {
522
+ if (platform === "win32") {
523
+ const installPrefix = environment.GITCODE_NPM_PREFIX
524
+ || pathApi.join(environment.LOCALAPPDATA || "<LOCALAPPDATA>", "gitcode-npm");
525
+ const wrapperDir = environment.GITCODE_NPM_WRAPPER_DIR || npmPrefix || "<npm-user-prefix>";
526
+ return {
527
+ mode: "coexist",
528
+ workflowCommand: "gitcode-npm",
529
+ target: installPrefix,
530
+ cliTarget: pathApi.join(installPrefix, "gitcode.cmd"),
531
+ wrapperDir,
532
+ wrapper: pathApi.join(wrapperDir, "gitcode-npm.cmd"),
533
+ npmCommand: ["npm", "install", "-g", GITCODE_PACKAGE, "--prefix", installPrefix, `--registry=${REGISTRY}`],
534
+ };
535
+ }
536
+ const home = environmentHome(environment, platform);
537
+ const installPrefix = environment.GITCODE_NPM_PREFIX
538
+ || pathApi.join(environment.XDG_DATA_HOME || pathApi.join(home, ".local", "share"), "gitcode-npm");
539
+ const wrapperDir = environment.GITCODE_NPM_WRAPPER_DIR || pathApi.join(home, ".local", "bin");
540
+ return {
541
+ mode: "coexist",
542
+ workflowCommand: "gitcode-npm",
543
+ target: installPrefix,
544
+ cliTarget: pathApi.join(installPrefix, "bin", "gitcode"),
545
+ wrapperDir,
546
+ wrapper: pathApi.join(wrapperDir, "gitcode-npm"),
547
+ npmCommand: ["npm", "install", "-g", GITCODE_PACKAGE, "--prefix", installPrefix, `--registry=${REGISTRY}`],
548
+ };
549
+ }
550
+ return {
551
+ mode: classification === "npm-official" ? "upgrade-npm" : "standard",
552
+ workflowCommand: "gitcode",
553
+ target: npmPrefix || "<npm-global-prefix>",
554
+ cliTarget: null,
555
+ wrapperDir: null,
556
+ wrapper: null,
557
+ npmCommand: ["npm", "install", "-g", GITCODE_PACKAGE, `--registry=${REGISTRY}`],
558
+ };
559
+ }
560
+
561
+ function readOptionalFile(file) {
562
+ try {
563
+ const metadata = lstatSync(file);
564
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
565
+ throw new BootstrapError(`Refusing to overwrite unrelated wrapper: ${file}`, 3);
566
+ }
567
+ return readFileSync(file, "utf8");
568
+ } catch (error) {
569
+ if (error?.code === "ENOENT") {
570
+ return null;
571
+ }
572
+ throw error;
573
+ }
574
+ }
575
+
576
+ function shellQuote(value) {
577
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
578
+ }
579
+
580
+ function wrapperContent(details, platform) {
581
+ return platform === "win32"
582
+ ? `@echo off\r\ncall "${details.cliTarget}" %*\r\n`
583
+ : `#!/usr/bin/env bash\nexec ${shellQuote(details.cliTarget)} "$@"\n`;
584
+ }
585
+
586
+ function validateWrapper(details, platform) {
587
+ const existing = readOptionalFile(details.wrapper);
588
+ if (existing === null) {
589
+ return;
590
+ }
591
+ const accepted = [wrapperContent(details, platform)];
592
+ if (platform !== "win32") {
593
+ accepted.push(`#!/usr/bin/env bash\nexec "${details.cliTarget}" "$@"\n`);
594
+ }
595
+ if (!accepted.includes(existing)) {
596
+ throw new BootstrapError(`Refusing to overwrite unrelated wrapper: ${details.wrapper}`, 3);
597
+ }
598
+ }
599
+
600
+ function writeWrapper(details, platform) {
601
+ const content = wrapperContent(details, platform);
602
+ if (readOptionalFile(details.wrapper) === content) {
603
+ return;
604
+ }
605
+ validateWrapper(details, platform);
606
+ mkdirSync(details.wrapperDir, { recursive: true });
607
+ const temporary = path.join(details.wrapperDir, `.gitcode-npm-${randomUUID()}.tmp`);
608
+ let descriptor;
609
+ try {
610
+ descriptor = openSync(temporary, "wx");
611
+ writeFileSync(descriptor, content, "utf8");
612
+ if (platform !== "win32") {
613
+ fchmodSync(descriptor, 0o755);
614
+ }
615
+ closeSync(descriptor);
616
+ descriptor = undefined;
617
+ validateWrapper(details, platform);
618
+ if (platform === "win32" && readOptionalFile(details.wrapper) !== null) {
619
+ unlinkSync(details.wrapper);
620
+ }
621
+ renameSync(temporary, details.wrapper);
622
+ } finally {
623
+ if (descriptor !== undefined) {
624
+ closeSync(descriptor);
625
+ }
626
+ try {
627
+ unlinkSync(temporary);
628
+ } catch (error) {
629
+ if (error?.code !== "ENOENT") {
630
+ throw error;
631
+ }
632
+ }
633
+ }
634
+ }
635
+
636
+ function installGitcode(plan, run, platform) {
637
+ run(plan.installInvocation);
638
+ if (plan.gitcodeInstall.mode === "coexist") {
639
+ verifyFile(
640
+ plan.gitcodeInstall.cliTarget,
641
+ `Installed GitCode npm CLI target not found: ${plan.gitcodeInstall.cliTarget}.`,
642
+ );
643
+ writeWrapper(plan.gitcodeInstall, platform);
644
+ return {
645
+ command: plan.gitcodeInstall.workflowCommand,
646
+ executable: plan.gitcodeInstall.wrapper,
647
+ };
648
+ }
649
+ const executable = resolveExecutable("gitcode", run, platform);
650
+ if (!executable) {
651
+ throw new BootstrapError("GitCode npm CLI was installed but the gitcode executable is not available on PATH.");
652
+ }
653
+ return {
654
+ command: plan.gitcodeInstall.workflowCommand,
655
+ executable,
656
+ };
657
+ }
658
+
659
+ function safeAuthStatus(output) {
660
+ try {
661
+ const status = JSON.parse(output.trim());
662
+ return {
663
+ logged_in: status.logged_in === true,
664
+ username: typeof status.username === "string" ? status.username : null,
665
+ token_source: typeof status.token_source === "string" ? status.token_source : null,
666
+ git_protocol: typeof status.git_protocol === "string" ? status.git_protocol : null,
667
+ };
668
+ } catch {
669
+ return {
670
+ logged_in: false,
671
+ username: null,
672
+ token_source: null,
673
+ git_protocol: null,
674
+ };
675
+ }
676
+ }
677
+
678
+ function printPlan(plan, write) {
679
+ write("Setup plan (no changes made yet):");
680
+ write(` Skills directory: ${plan.layout.skillsDir}`);
681
+ write(` Bundled skill: ${plan.skillInstall.source}`);
682
+ write(` Skill target: ${plan.skillInstall.target}`);
683
+ write(` Skill action: ${plan.skillInstall.status}`);
684
+ write(` Existing gitcode: ${plan.diagnosis.classification}`);
685
+ if (plan.diagnosis.existingExecutable) {
686
+ write(` Existing executable: ${plan.diagnosis.existingExecutable}`);
687
+ }
688
+ write(` Installation mode: ${plan.gitcodeInstall.mode}`);
689
+ write(` Workflow command: ${plan.gitcodeInstall.workflowCommand}`);
690
+ write(` GitCode package: ${GITCODE_PACKAGE}`);
691
+ write(` Registry latest: ${plan.diagnosis.officialLatest || "unavailable"}`);
692
+ write(` Registry: ${REGISTRY}`);
693
+ write(` Target prefix: ${plan.gitcodeInstall.target}`);
694
+ if (plan.gitcodeInstall.wrapper) {
695
+ write(` Wrapper: ${plan.gitcodeInstall.wrapper}`);
696
+ write(` Wrapper target: ${plan.gitcodeInstall.cliTarget}`);
697
+ }
698
+ write(` npm command: ${formatCommand(plan.installInvocation)}`);
699
+ write(` Python: ${plan.python.executable} (${plan.python.version.join(".")})`);
700
+ write(` Python dependency command: ${formatCommand(plan.pipInvocation)}`);
701
+ write(" Browser binary download: disabled");
702
+ write(` GitCode authentication command when needed: ${plan.gitcodeInstall.workflowCommand} auth login --web`);
703
+ write(" Token handling: delegated to GitCode CLI; setup never reads or prints it");
704
+ }
705
+
706
+ async function confirmPlan(input, output) {
707
+ if (!input.isTTY || !output.isTTY) {
708
+ throw new BootstrapError("Refusing to modify the environment without --yes in a non-interactive session.", 2);
709
+ }
710
+ const terminal = createInterface({ input, output });
711
+ try {
712
+ const answer = await terminal.question("Apply this plan? [y/N] ");
713
+ return /^(?:y|yes)$/i.test(answer.trim());
714
+ } finally {
715
+ terminal.close();
716
+ }
717
+ }
718
+
719
+ function cliInvoker(cliExecutable, run, platform) {
720
+ return (args, options) => run(executableCommand(cliExecutable, args, platform), options);
721
+ }
722
+
723
+ function validateCore(cliExecutable, run, platform) {
724
+ const invoke = cliInvoker(cliExecutable, run, platform);
725
+ const version = invoke(["version"]).stdout.trim();
726
+ const doctor = invoke(["doctor", "install", "--json"], { allowFailure: true });
727
+ const missing = [];
728
+ try {
729
+ const metadata = JSON.parse(doctor.stdout);
730
+ if (doctor.status !== 0 || metadata.distribution !== "npm") {
731
+ missing.push("doctor install (official npm distribution)");
732
+ }
733
+ } catch {
734
+ missing.push("doctor install (invalid JSON)");
735
+ }
736
+ for (const [schema, requiredFlags] of Object.entries(REQUIRED_SCHEMAS)) {
737
+ const result = invoke(["schema", schema], { allowFailure: true });
738
+ if (result.status !== 0) {
739
+ missing.push(schema);
740
+ continue;
741
+ }
742
+ try {
743
+ const payload = JSON.parse(result.stdout);
744
+ const flags = new Set(Array.isArray(payload.flags) ? payload.flags.map((flag) => flag.name) : []);
745
+ for (const flag of requiredFlags) {
746
+ if (!flags.has(flag)) {
747
+ missing.push(`${schema} --${flag}`);
748
+ }
749
+ }
750
+ } catch {
751
+ missing.push(`${schema} (invalid schema JSON)`);
752
+ }
753
+ }
754
+ if (invoke(["api", "--help"], { allowFailure: true }).status !== 0) {
755
+ missing.push("api");
756
+ }
757
+ if (missing.length) {
758
+ throw new BootstrapError(`GitCode CLI is missing required capabilities: ${missing.join(", ")}.`);
759
+ }
760
+ const authentication = safeAuthStatus(invoke(["auth", "status", "--json"], { allowFailure: true }).stdout);
761
+ return { version, authentication, capabilities: "passed" };
762
+ }
763
+
764
+ function authenticateGitcode(cliExecutable, authentication, run, platform, write, jsonOutput) {
765
+ if (authentication.logged_in) {
766
+ return authentication;
767
+ }
768
+ const invoke = cliInvoker(cliExecutable, run, platform);
769
+ write("GitCode authentication is required. Opening the official browser login.");
770
+ write("Complete the GitCode page shown by the CLI; do not paste a token into this terminal or the conversation.");
771
+ invoke(["auth", "login", "--web"], {
772
+ inherit: true,
773
+ outputToStderr: jsonOutput,
774
+ });
775
+ authentication = safeAuthStatus(invoke(["auth", "status", "--json"], { allowFailure: true }).stdout);
776
+ if (!authentication.logged_in) {
777
+ throw new BootstrapError("GitCode browser login did not produce an authenticated CLI session.", 2);
778
+ }
779
+ return authentication;
780
+ }
781
+
782
+ function textResult(result, write) {
783
+ write("Setup completed:");
784
+ write(` Skill: ${result.skill.status} at ${result.skill.target}`);
785
+ write(` Workflow CLI command: ${result.gitcode.command}`);
786
+ write(` GitCode CLI: ${result.gitcode.version}`);
787
+ write(` Required capabilities: ${result.gitcode.capabilities}`);
788
+ write(" Authentication: authenticated via GitCode CLI");
789
+ if (result.gitcode.authentication.username) {
790
+ write(` Username: ${result.gitcode.authentication.username}`);
791
+ }
792
+ write(` Python: ${result.python.executable} ${result.python.version}`);
793
+ write(" Playwright Python package: installed");
794
+ write(" Chromium: not downloaded");
795
+ }
796
+
797
+ export async function runSetup(options, dependencies = {}) {
798
+ const platform = dependencies.platform || process.platform;
799
+ const environment = dependencies.environment || process.env;
800
+ const run = dependencies.run || execute;
801
+ const write = dependencies.write || ((line) => console.log(line));
802
+ const writePlan = dependencies.writePlan || write;
803
+ const layout = resolveLayout(options, environment, dependencies.bundledSkillDir);
804
+ const skillInstall = skillInstallDetails(layout);
805
+
806
+ if (Number(process.versions.node.split(".")[0]) < 18) {
807
+ throw new BootstrapError(`Node.js >=18 is required; found ${process.version}.`, 2);
808
+ }
809
+ run(namedCommand("npm", ["--version"], platform));
810
+ const npmPrefix = run(namedCommand("npm", ["config", "get", "prefix"], platform)).stdout.trim();
811
+ const gitVersion = run(namedCommand("git", ["--version"], platform)).stdout.trim();
812
+ const latestResult = runOptional(run, namedCommand(
813
+ "npm",
814
+ ["view", "@gitcode-cli/cli", "version", `--registry=${REGISTRY}`],
815
+ platform,
816
+ ));
817
+ const diagnosis = diagnoseGitcode(run, platform);
818
+ diagnosis.officialLatest = latestResult.status === 0 ? latestResult.stdout.trim() || null : null;
819
+ const gitcodeInstall = gitcodeInstallDetails(diagnosis.classification, environment, platform, npmPrefix);
820
+ if (gitcodeInstall.wrapper) {
821
+ validateWrapper(gitcodeInstall, platform);
822
+ }
823
+ const installInvocation = namedCommand(
824
+ gitcodeInstall.npmCommand[0],
825
+ gitcodeInstall.npmCommand.slice(1),
826
+ platform,
827
+ );
828
+
829
+ const python = detectPython(run, platform);
830
+ run({ command: python.executable, args: ["-m", "pip", "--version"] });
831
+ const pipInvocation = {
832
+ command: python.executable,
833
+ args: ["-m", "pip", "install", "-r", layout.bundledPythonRequirements],
834
+ };
835
+ const plan = {
836
+ layout,
837
+ skillInstall,
838
+ diagnosis,
839
+ installInvocation,
840
+ gitcodeInstall,
841
+ python,
842
+ pipInvocation,
843
+ };
844
+ printPlan(plan, writePlan);
845
+ if (options.dryRun) {
846
+ return { state: "planned", git: gitVersion, plan };
847
+ }
848
+ if (!options.yes && !(await (dependencies.confirm || confirmPlan)(process.stdin, process.stdout))) {
849
+ throw new BootstrapError("Setup cancelled; no changes were applied.", 2);
850
+ }
851
+ const currentDiagnosis = diagnoseGitcode(run, platform);
852
+ if (currentDiagnosis.classification !== diagnosis.classification
853
+ || currentDiagnosis.existingExecutable !== diagnosis.existingExecutable) {
854
+ throw new BootstrapError("GitCode command ownership changed after confirmation; refusing to install.", 3);
855
+ }
856
+ if (gitcodeInstall.wrapper) {
857
+ validateWrapper(gitcodeInstall, platform);
858
+ }
859
+
860
+ const installed = installGitcode(plan, run, platform);
861
+ const gitcode = validateCore(installed.executable, run, platform);
862
+ gitcode.command = installed.command;
863
+ gitcode.executable = installed.executable;
864
+
865
+ run(pipInvocation);
866
+ run({ command: python.executable, args: ["-c", "import playwright.sync_api"] });
867
+ gitcode.authentication = authenticateGitcode(
868
+ installed.executable,
869
+ gitcode.authentication,
870
+ run,
871
+ platform,
872
+ options.json ? writePlan : write,
873
+ options.json,
874
+ );
875
+ const skillStatus = installBundledSkill(skillInstall);
876
+ return {
877
+ state: "ready",
878
+ git: gitVersion,
879
+ skill: {
880
+ status: skillStatus,
881
+ target: layout.workflowDir,
882
+ },
883
+ gitcode,
884
+ python: {
885
+ executable: python.executable,
886
+ version: python.version.join("."),
887
+ playwright: "installed",
888
+ chromium: "not-downloaded",
889
+ },
890
+ };
891
+ }
892
+
893
+ export async function main(argv, dependencies = {}) {
894
+ const write = dependencies.write || ((line) => console.log(line));
895
+ const writeError = dependencies.writeError || ((line) => console.error(line));
896
+ const writePlan = dependencies.writePlan || (argv.includes("--json") ? writeError : write);
897
+ let options;
898
+ try {
899
+ options = parseArgs(argv);
900
+ if (options.help) {
901
+ write(usage());
902
+ return 0;
903
+ }
904
+ const result = await runSetup(options, { ...dependencies, write, writePlan });
905
+ if (options.json) {
906
+ write(JSON.stringify(result, null, 2));
907
+ } else if (result.state === "planned") {
908
+ write("Dry run completed; no changes were applied.");
909
+ } else {
910
+ textResult(result, write);
911
+ }
912
+ return 0;
913
+ } catch (error) {
914
+ const exitCode = error instanceof BootstrapError ? error.exitCode : 1;
915
+ const message = error instanceof Error ? error.message : String(error);
916
+ if (options?.json) {
917
+ write(JSON.stringify({ state: "blocked", error: message }));
918
+ } else {
919
+ writeError(`Error: ${message}`);
920
+ }
921
+ return exitCode;
922
+ }
923
+ }