@gitpagedocs/cli 1.1.68 → 1.1.70

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.
Files changed (38) hide show
  1. package/ai/application/ai-command.ts +1 -1
  2. package/ai/application/docs-pattern.ts +9 -5
  3. package/ai/application/resolve-chat-credentials.ts +13 -6
  4. package/ai/core/models/parse-ai-cli-config.ts +6 -8
  5. package/application/home/handler.mjs +3 -1
  6. package/application/ports/cli-runtime-ports.ts +2 -0
  7. package/application/report/config-only-reporter.mjs +2 -5
  8. package/builders/config-orchestrator.mjs +4 -7
  9. package/builders/language-bundles-builder.mjs +4 -4
  10. package/builders/root-config-builder.mjs +1 -1
  11. package/content/docs-en.mjs +4 -4
  12. package/content/docs-es.mjs +4 -4
  13. package/content/docs-pt.mjs +4 -4
  14. package/contracts/doc-versions.mjs +1 -1
  15. package/contracts/langs-paths.mjs +8 -5
  16. package/contracts/layouts-paths.mjs +4 -4
  17. package/contracts/path-segments.d.mts +2 -0
  18. package/contracts/path-segments.mjs +15 -0
  19. package/data/default-site-config.mjs +9 -2
  20. package/data/i18n-langmenu.mjs +3 -0
  21. package/domain/services/layouts-migration.mjs +15 -1
  22. package/home/home-file-writer.mjs +2 -3
  23. package/home/templates.mjs +5 -2
  24. package/infrastructure/home/runtime.mjs +2 -2
  25. package/package.json +3 -3
  26. package/presentation/commands/config-info.ts +44 -9
  27. package/presentation/commands/diagnostics.ts +30 -20
  28. package/presentation/commands/docs.ts +7 -2
  29. package/presentation/commands/pages.ts +4 -2
  30. package/presentation/options/parser.ts +46 -45
  31. package/presentation/ui/prompts.ts +6 -5
  32. package/runtime/doc-path-resolver.mjs +8 -6
  33. package/runtime/exec.mjs +168 -0
  34. package/runtime/git-ops.mjs +22 -20
  35. package/runtime/layouts-migrator.mjs +15 -1
  36. package/runtime/output.mjs +88 -58
  37. package/runtime/workflow.mjs +3 -1
  38. package/start.mjs +8 -6
@@ -1,8 +1,15 @@
1
1
  import path from "node:path";
2
2
  import { readFileSync, existsSync } from "node:fs";
3
- import { execSync } from "node:child_process";
4
3
  import { defaultConfigLoader } from "@gitpagedocs/tools";
5
4
  import type { CommandContext } from "./run-command";
5
+ // @ts-expect-error .mjs runtime module is type-less in this package.
6
+ import { runExecutableCapture } from "../../runtime/exec.mjs";
7
+
8
+ interface DoctorCheck {
9
+ label: string;
10
+ ok: boolean;
11
+ detail: string;
12
+ }
6
13
 
7
14
  function readVersion(pkgRoot: string): string {
8
15
  try {
@@ -18,9 +25,10 @@ export async function runVersion(ctx: CommandContext): Promise<void> {
18
25
  console.log(`gitpagedocs ${readVersion(ctx.pkgRoot)} (node ${process.version})`);
19
26
  }
20
27
 
21
- function probe(label: string, command: string): { label: string; ok: boolean; detail: string } {
28
+ /** Run `<program> --version` (argv array, no shell) and keep its first output line. */
29
+ function probe(label: string, program: string): DoctorCheck {
22
30
  try {
23
- const out = execSync(command, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
31
+ const out = String(runExecutableCapture(program, ["--version"], { stdio: ["ignore", "pipe", "ignore"] }));
24
32
  return { label, ok: true, detail: out.split("\n")[0] };
25
33
  } catch {
26
34
  return { label, ok: false, detail: "not found" };
@@ -28,23 +36,24 @@ function probe(label: string, command: string): { label: string; ok: boolean; de
28
36
  }
29
37
 
30
38
  export async function runDoctor(ctx: CommandContext): Promise<void> {
31
- const checks: Array<{ label: string; ok: boolean; detail: string }> = [];
32
- checks.push({ label: "node", ok: true, detail: process.version });
33
- checks.push(probe("git", "git --version"));
34
- checks.push(probe("gh (GitHub CLI)", "gh --version"));
35
- checks.push(probe("pnpm", "pnpm --version"));
36
-
37
39
  const configPath = await defaultConfigLoader.resolveConfigPath(ctx.cwd);
38
- checks.push({
39
- label: "gitpagedocs config",
40
- ok: Boolean(configPath),
41
- detail: configPath ? path.relative(ctx.cwd, configPath) : "not found (run `gitpagedocs init`)",
42
- });
43
- checks.push({
44
- label: "gitpagedocs/ directory",
45
- ok: existsSync(path.join(ctx.cwd, "gitpagedocs")),
46
- detail: existsSync(path.join(ctx.cwd, "gitpagedocs")) ? "present" : "missing",
47
- });
40
+ const hasDocsDirectory = existsSync(path.join(ctx.cwd, "gitpagedocs"));
41
+ const checks: DoctorCheck[] = [
42
+ { label: "node", ok: true, detail: process.version },
43
+ probe("git", "git"),
44
+ probe("gh (GitHub CLI)", "gh"),
45
+ probe("pnpm", "pnpm"),
46
+ {
47
+ label: "gitpagedocs config",
48
+ ok: Boolean(configPath),
49
+ detail: configPath ? path.relative(ctx.cwd, configPath) : "not found (run `gitpagedocs init`)",
50
+ },
51
+ {
52
+ label: "gitpagedocs/ directory",
53
+ ok: hasDocsDirectory,
54
+ detail: hasDocsDirectory ? "present" : "missing",
55
+ },
56
+ ];
48
57
 
49
58
  // eslint-disable-next-line no-console
50
59
  console.log("\n gitpagedocs doctor\n");
@@ -53,8 +62,9 @@ export async function runDoctor(ctx: CommandContext): Promise<void> {
53
62
  console.log(` ${c.ok ? "[ok]" : "[x] "} ${c.label.padEnd(22)} ${c.detail}`);
54
63
  }
55
64
  const failed = checks.filter((c) => !c.ok && c.label !== "gh (GitHub CLI)").length;
65
+ const summary = failed === 0 ? "All required checks passed." : `${failed} check(s) need attention.`;
56
66
  // eslint-disable-next-line no-console
57
- console.log(`\n ${failed === 0 ? "All required checks passed." : `${failed} check(s) need attention.`}\n`);
67
+ console.log(`\n ${summary}\n`);
58
68
  }
59
69
 
60
70
  /** Used only when package.json is unreadable — the CLI's published name. */
@@ -9,6 +9,12 @@ import {
9
9
  } from "@gitpagedocs/tools";
10
10
  import type { CommandContext } from "./run-command";
11
11
 
12
+ /** Human-readable outcome of one managed-region update. */
13
+ function describeUpdate(result: { changed: boolean; replaced: boolean }): string {
14
+ if (!result.changed) return "already up to date";
15
+ return result.replaced ? "managed region updated" : "managed region added";
16
+ }
17
+
12
18
  /**
13
19
  * `gitpagedocs docs` — refresh the managed regions of the standard docs files
14
20
  * with deterministic content. Idempotent and marker-bounded: manual content
@@ -28,9 +34,8 @@ export async function runDocs(ctx: CommandContext): Promise<void> {
28
34
  // eslint-disable-next-line no-console
29
35
  console.log("");
30
36
  for (const r of results) {
31
- const state = r.changed ? (r.replaced ? "managed region updated" : "managed region added") : "already up to date";
32
37
  // eslint-disable-next-line no-console
33
- console.log(` ${r.path}: ${state}.`);
38
+ console.log(` ${r.path}: ${describeUpdate(r)}.`);
34
39
  }
35
40
  // eslint-disable-next-line no-console
36
41
  console.log("");
@@ -5,6 +5,7 @@ import { askConfirm } from "../ui/clack";
5
5
  import { askOwnerRepo, interactivePromptsAvailable } from "../ui/prompts";
6
6
  // @ts-expect-error .mjs runtime module
7
7
  import { detectRepoFromGit, getCurrentGitBranch, tryConfigurePagesToGitHubActions } from "../../runtime/git-ops.mjs";
8
+ import { trimSlashes } from "../../contracts/path-segments.mjs";
8
9
 
9
10
  /** Read `--name value` (or `--name=value`) from a raw arg list. */
10
11
  function readFlag(args: string[], name: string): string {
@@ -73,8 +74,9 @@ export async function runPagesDeploy(ctx: CommandContext): Promise<void> {
73
74
  repo = answered.repo;
74
75
  }
75
76
 
77
+ const pathSuffix = docsPath ? ` (path: ${docsPath})` : "";
76
78
  // eslint-disable-next-line no-console
77
- console.log(`\n Deploy ${owner}/${repo} to GitHub Pages via Actions${docsPath ? ` (path: ${docsPath})` : ""}.`);
79
+ console.log(`\n Deploy ${owner}/${repo} to GitHub Pages via Actions${pathSuffix}.`);
78
80
  const ok = await askConfirm(
79
81
  `Generate docs + workflow, commit, push, and switch Pages to Actions for ${owner}/${repo}?`,
80
82
  false,
@@ -91,7 +93,7 @@ export async function runPagesDeploy(ctx: CommandContext): Promise<void> {
91
93
  const result = spawnSync(process.execPath, [bin, ...pushArgs], { stdio: "inherit", cwd: ctx.cwd, env: process.env });
92
94
 
93
95
  if (result.status === 0) {
94
- const seg = docsPath ? `/${docsPath.replace(/^\/+|\/+$/g, "")}` : "";
96
+ const seg = docsPath ? `/${trimSlashes(docsPath)}` : "";
95
97
  // eslint-disable-next-line no-console
96
98
  console.log(`\n Deployed. Final URL: https://${owner}.github.io/${repo}${seg}/\n`);
97
99
  } else {
@@ -1,4 +1,4 @@
1
- import type { CliOptions } from "../../domain/models/cli-options";
1
+ import type { CliMode, CliOptions } from "../../domain/models/cli-options";
2
2
  import { DEFAULTS } from "./schema";
3
3
  import { normalizeLayoutsDir } from "../../contracts/layouts-paths.mjs";
4
4
 
@@ -22,6 +22,9 @@ const KNOWN_FLAGS = new Set([
22
22
  "--pages-actions",
23
23
  ]);
24
24
 
25
+ /** Options that carry a value; `--name=value` spellings must never be read as an owner/repo. */
26
+ const VALUE_OPTION_PREFIXES = ["--owner", "--repo", "--path", "--home", "--output", "--layouts-dir", "--search"];
27
+
25
28
  function readOptionValue(args: string[], optionName: string): string {
26
29
  const equalsArg = args.find((a) => a.startsWith(`${optionName}=`));
27
30
  if (equalsArg) return equalsArg.slice(optionName.length + 1).trim();
@@ -33,36 +36,50 @@ function readOptionValue(args: string[], optionName: string): string {
33
36
  return "";
34
37
  }
35
38
 
39
+ /** Legacy `--<owner> --<repo>` spelling: every dashed arg that is not a known flag or option. */
40
+ function readFallbackOwnerRepo(args: string[]): string[] {
41
+ return args
42
+ .filter((a) => a.startsWith("--") && !KNOWN_FLAGS.has(a))
43
+ .filter((a) => !VALUE_OPTION_PREFIXES.some((prefix) => a.startsWith(prefix)))
44
+ .map((a) => a.slice(2).trim())
45
+ .filter(Boolean);
46
+ }
47
+
48
+ /** New verb aliases (Phase 6) that map onto existing modes without new modes. */
49
+ function readCommandAliases(args: string[]): { isDocument: boolean; isDeploy: boolean; aiCommand?: string } {
50
+ const command = args[0];
51
+ if (command === "ai") return { isDocument: false, isDeploy: false, aiCommand: args[1] };
52
+ if (command === "document") return { isDocument: true, isDeploy: false, aiCommand: args[1] ?? "repo" };
53
+ if (command?.startsWith("document:")) return { isDocument: true, isDeploy: false, aiCommand: command.split(":")[1] };
54
+ return { isDocument: false, isDeploy: command === "deploy" || command === "pages", aiCommand: undefined };
55
+ }
56
+
57
+ function resolveMode(flags: { isAi: boolean; isHome: boolean; isFull: boolean }): CliMode {
58
+ if (flags.isAi) return "ai";
59
+ if (flags.isHome) return "home";
60
+ if (flags.isFull) return "full";
61
+ return "config-only";
62
+ }
63
+
64
+ function resolveRepositorySearch(searchRaw: string, isHome: boolean): boolean | undefined {
65
+ if (searchRaw === "true") return true;
66
+ if (searchRaw === "false") return false;
67
+ return isHome ? DEFAULTS.home.repositorySearch : undefined;
68
+ }
69
+
36
70
  export function parseCliOptions(argv: string[], env: NodeJS.ProcessEnv): CliOptions {
37
71
  const args = argv.slice(2);
38
72
 
39
- let githubOwner = readOptionValue(args, "--owner");
40
- let githubRepo = readOptionValue(args, "--repo");
41
73
  const docsPath = readOptionValue(args, "--path");
42
74
  const outputDir = readOptionValue(args, "--output");
43
75
  const layoutsDir = readOptionValue(args, "--layouts-dir");
44
76
  const searchRaw = readOptionValue(args, "--search");
45
77
 
46
- const fallbackDashedArgs = args
47
- .filter((a) => a.startsWith("--"))
48
- .filter((a) => !KNOWN_FLAGS.has(a))
49
- .filter((a) => !a.startsWith("--owner"))
50
- .filter((a) => !a.startsWith("--repo"))
51
- .filter((a) => !a.startsWith("--path"))
52
- .filter((a) => !a.startsWith("--home"))
53
- .filter((a) => !a.startsWith("--output"))
54
- .filter((a) => !a.startsWith("--layouts-dir"))
55
- .filter((a) => !a.startsWith("--search"))
56
- .map((a) => a.replace(/^--/, "").trim())
57
- .filter(Boolean);
78
+ const fallbackDashedArgs = readFallbackOwnerRepo(args);
79
+ const githubOwner = readOptionValue(args, "--owner") || fallbackDashedArgs[0] || "";
80
+ const githubRepo = readOptionValue(args, "--repo") || fallbackDashedArgs[1] || "";
58
81
 
59
- if (!githubOwner && fallbackDashedArgs[0]) githubOwner = fallbackDashedArgs[0];
60
- if (!githubRepo && fallbackDashedArgs[1]) githubRepo = fallbackDashedArgs[1];
61
-
62
- // New verb aliases (Phase 6) that map onto existing modes without new modes.
63
- const command = args[0];
64
- const isDocument = command === "document" || (command?.startsWith("document:") ?? false);
65
- const isDeploy = command === "deploy" || command === "pages";
82
+ const { isDocument, isDeploy, aiCommand } = readCommandAliases(args);
66
83
 
67
84
  const isBuild = args.includes("--build") || env.GITPAGEDOCS_BUILD === "1";
68
85
  const isServe = args.includes("--serve");
@@ -70,41 +87,25 @@ export function parseCliOptions(argv: string[], env: NodeJS.ProcessEnv): CliOpti
70
87
  const shouldPush = args.includes("--push") || isDeploy;
71
88
  const isHome = args.includes("--home");
72
89
  const isInteractive = args.includes("--interactive") || args.includes("-i");
73
- const isAi = command === "ai" || args.includes("--ai") || isDocument;
74
- const aiCommand =
75
- command === "ai"
76
- ? args[1]
77
- : command === "document"
78
- ? args[1] ?? "repo"
79
- : command?.startsWith("document:")
80
- ? command.split(":")[1]
81
- : undefined;
82
-
83
- const mode: import("../../domain/models/cli-options").CliMode = isAi ? "ai" : isHome ? "home" : args.includes("--full") ? "full" : "config-only";
84
- const repositorySearch =
85
- searchRaw === "true"
86
- ? true
87
- : searchRaw === "false"
88
- ? false
89
- : isHome
90
- ? DEFAULTS.home.repositorySearch
91
- : undefined;
90
+ const isAi = args[0] === "ai" || args.includes("--ai") || isDocument;
92
91
 
93
- const normalizedPath = docsPath.trim();
92
+ const mode = resolveMode({ isAi, isHome, isFull: args.includes("--full") });
93
+ const repositorySearch = resolveRepositorySearch(searchRaw, isHome);
94
+ const defaultOutputDir = isHome ? DEFAULTS.home.outputDir : DEFAULTS.outputDir;
94
95
 
95
96
  return {
96
97
  isBuild,
97
98
  isServe,
98
99
  mode,
99
100
  aiCommand,
100
- outputDir: outputDir || (isHome ? DEFAULTS.home.outputDir : DEFAULTS.outputDir),
101
+ outputDir: outputDir || defaultOutputDir,
101
102
  layoutsDir: normalizeLayoutsDir(layoutsDir || DEFAULTS.layoutsDir),
102
103
  useLocalLayoutConfig,
103
104
  shouldPush,
104
105
  githubOwner,
105
106
  githubRepo,
106
- docsPath: normalizedPath,
107
- basePath: normalizedPath,
107
+ docsPath,
108
+ basePath: docsPath,
108
109
  repositorySearch,
109
110
  isInteractive,
110
111
  hasArgs: args.length > 0,
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { execSync } from "node:child_process";
3
+ // @ts-expect-error .mjs runtime module is type-less in this package.
4
+ import { runExecutable } from "../../runtime/exec.mjs";
4
5
  import type { CliOptions } from "../../domain/models/cli-options";
5
6
  import { DEFAULTS } from "../options/schema";
6
7
  import { askText, askConfirm, note } from "./clack";
@@ -23,7 +24,7 @@ export async function promptHomeOptions(parsed: CliOptions): Promise<CliOptions>
23
24
  const outputDir = await askText({
24
25
  message: "Output directory:",
25
26
  defaultValue: parsed.outputDir ?? DEFAULTS.home.outputDir,
26
- validate: (v) => (v && v.trim() ? undefined : "Required"),
27
+ validate: (v) => (v?.trim() ? undefined : "Required"),
27
28
  });
28
29
  const repositorySearch = await askConfirm(
29
30
  "Enable repository search home?",
@@ -54,12 +55,12 @@ export async function askOwnerRepo(
54
55
  const owner = await askText({
55
56
  message: "GitHub owner (user or organization):",
56
57
  defaultValue: defaults?.owner || detected?.owner || "",
57
- validate: (v) => (v && v.trim() ? undefined : "Owner is required."),
58
+ validate: (v) => (v?.trim() ? undefined : "Owner is required."),
58
59
  });
59
60
  const repo = await askText({
60
61
  message: "GitHub repository name:",
61
62
  defaultValue: defaults?.repo || detected?.repo || "",
62
- validate: (v) => (v && v.trim() ? undefined : "Repository is required."),
63
+ validate: (v) => (v?.trim() ? undefined : "Repository is required."),
63
64
  });
64
65
  return { owner: owner.trim(), repo: repo.trim() };
65
66
  }
@@ -101,7 +102,7 @@ export async function ensureGitRepoInteractive(root: string): Promise<void> {
101
102
  return;
102
103
  }
103
104
  try {
104
- execSync("git init", { cwd: root, stdio: "ignore" });
105
+ runExecutable("git", ["init"], { cwd: root, stdio: "ignore" });
105
106
  note("Initialized an empty git repository.", "git");
106
107
  } catch {
107
108
  note("Could not run `git init`. Initialize git manually, then retry.", "git");
@@ -41,16 +41,18 @@ export function withVersionBadge(content, versionId, language) {
41
41
  return normalized;
42
42
  }
43
43
 
44
- const label =
45
- language === "pt"
46
- ? `> Versao: ${versionId}`
47
- : language === "es"
48
- ? `> Version (ES): ${versionId}`
49
- : `> Version: ${versionId}`;
44
+ const label = `> ${versionLabelPrefix(language)}: ${versionId}`;
50
45
 
51
46
  return `${normalized.trimEnd()}\n\n${label}\n`;
52
47
  }
53
48
 
49
+ /** Badge wording per language; every other language reads the English form. */
50
+ const VERSION_LABEL_PREFIXES = { pt: "Versao", es: "Version (ES)" };
51
+
52
+ function versionLabelPrefix(language) {
53
+ return VERSION_LABEL_PREFIXES[language] ?? "Version";
54
+ }
55
+
54
56
  export function normalizeToOutputPath(outputDir, configPath) {
55
57
  const normalized = configPath.replace(/^gitpagedocs\//, "");
56
58
  return `${outputDir}/${normalized}`;
@@ -0,0 +1,168 @@
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync, spawn } from "node:child_process";
4
+
5
+ /**
6
+ * Child-process helpers that never rely on the OS resolving a bare command
7
+ * name through PATH at spawn time and never build a shell command string.
8
+ *
9
+ * Every entry point takes the program name (e.g. "git"), resolves it to an
10
+ * absolute path up front by walking PATH ourselves, and hands that absolute
11
+ * path plus an argument ARRAY to `execFileSync`/`spawn`. Owner, repo, branch
12
+ * and URL values therefore reach the child as discrete argv entries instead of
13
+ * being interpolated into a shell string.
14
+ */
15
+
16
+ const IS_WINDOWS = process.platform === "win32";
17
+
18
+ /** Windows extensions that CreateProcess/cmd.exe can actually launch. */
19
+ const WINDOWS_EXECUTABLE_EXTENSIONS = [".COM", ".EXE", ".BAT", ".CMD"];
20
+
21
+ /** Batch launchers are not executables: Windows runs them through cmd.exe. */
22
+ const CMD_SHELL_EXTENSIONS = new Set([".cmd", ".bat"]);
23
+
24
+ function readEnvVar(env, name) {
25
+ if (env[name] !== undefined) return env[name];
26
+ if (!IS_WINDOWS) return undefined;
27
+ // A plain object spread from `process.env` on Windows keeps the original
28
+ // casing ("Path"), so the case-insensitive lookup `process.env` offers is
29
+ // lost. Restore it for the two variables PATH resolution depends on.
30
+ const wanted = name.toUpperCase();
31
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === wanted);
32
+ return key === undefined ? undefined : env[key];
33
+ }
34
+
35
+ function pathDirectories(env) {
36
+ return (readEnvVar(env, "PATH") ?? "").split(path.delimiter).filter(Boolean);
37
+ }
38
+
39
+ function windowsExtensions(env) {
40
+ const configured = (readEnvVar(env, "PATHEXT") ?? "")
41
+ .split(";")
42
+ .map((ext) => ext.trim().toUpperCase())
43
+ .filter((ext) => WINDOWS_EXECUTABLE_EXTENSIONS.includes(ext));
44
+ return configured.length > 0 ? configured : WINDOWS_EXECUTABLE_EXTENSIONS;
45
+ }
46
+
47
+ function isExecutableFile(candidate) {
48
+ try {
49
+ if (!statSync(candidate).isFile()) return false;
50
+ if (!IS_WINDOWS) accessSync(candidate, constants.X_OK);
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ function candidateFileNames(name, env) {
58
+ if (!IS_WINDOWS) return [name];
59
+ const upper = name.toUpperCase();
60
+ const extensions = windowsExtensions(env);
61
+ if (extensions.some((ext) => upper.endsWith(ext))) return [name];
62
+ return extensions.map((ext) => `${name}${ext}`);
63
+ }
64
+
65
+ function usesCmdShell(file) {
66
+ return IS_WINDOWS && CMD_SHELL_EXTENSIONS.has(path.extname(file).toLowerCase());
67
+ }
68
+
69
+ function quoteForCmd(value) {
70
+ const text = String(value);
71
+ if (/["\r\n]/.test(text)) {
72
+ throw new Error(`Cannot pass ${JSON.stringify(text)} through a .cmd/.bat launcher: quotes and line breaks are not supported.`);
73
+ }
74
+ return `"${text}"`;
75
+ }
76
+
77
+ /**
78
+ * Resolve `name` to the absolute path of the executable that PATH points at.
79
+ *
80
+ * Directories are scanned in PATH order. On Windows the PATHEXT executable
81
+ * extensions are tried in their configured order (.COM, .EXE, .BAT, .CMD by
82
+ * default), so `git` resolves to `git.exe` before any `git.cmd` shim. A name
83
+ * that already contains a path separator is only checked, never searched.
84
+ *
85
+ * @param {string} name Program name such as "git", "gh" or "npx".
86
+ * @param {{ env?: NodeJS.ProcessEnv }} [options] Environment whose PATH/PATHEXT drive the lookup (defaults to `process.env`).
87
+ * @returns {string} Absolute path of the executable.
88
+ * @throws {Error} When nothing on PATH matches, with an actionable message.
89
+ */
90
+ export function resolveExecutable(name, options = {}) {
91
+ if (typeof name !== "string" || name.trim() === "") {
92
+ throw new TypeError("resolveExecutable(name) requires a non-empty program name.");
93
+ }
94
+ const env = options.env ?? process.env;
95
+
96
+ if (name.includes("/") || name.includes("\\")) {
97
+ const direct = path.resolve(name);
98
+ if (isExecutableFile(direct)) return direct;
99
+ throw new Error(`\`${name}\` is not an executable file (looked at ${direct}).`);
100
+ }
101
+
102
+ const fileNames = candidateFileNames(name, env);
103
+ for (const directory of pathDirectories(env)) {
104
+ for (const fileName of fileNames) {
105
+ const candidate = path.join(directory, fileName);
106
+ if (isExecutableFile(candidate)) return path.resolve(candidate);
107
+ }
108
+ }
109
+
110
+ throw new Error(
111
+ `\`${name}\` was not found on PATH. Install it and make sure the directory that contains it is listed in the PATH environment variable, then retry.`,
112
+ );
113
+ }
114
+
115
+ function launchOptions(file, args, options) {
116
+ if (usesCmdShell(file)) {
117
+ // Node refuses to spawn .cmd/.bat files without a shell (EINVAL, see
118
+ // CVE-2024-27980) because only cmd.exe can interpret them. The launcher path
119
+ // is already absolute and every argument is individually quoted, so cmd.exe
120
+ // receives a fixed command line rather than one assembled from user input.
121
+ return { file: quoteForCmd(file), args: args.map(quoteForCmd), options: { ...options, shell: true } };
122
+ }
123
+ return { file, args, options: { ...options, shell: false } };
124
+ }
125
+
126
+ /**
127
+ * Run `name` synchronously with an argument array and wait for it to finish.
128
+ * Same contract as `execFileSync` (throws on a non-zero exit, returns stdout).
129
+ *
130
+ * @param {string} name Program name such as "git".
131
+ * @param {readonly string[]} [args] Arguments passed verbatim, one argv entry each.
132
+ * @param {import("node:child_process").ExecFileSyncOptions} [options] Forwarded to `execFileSync` (cwd, env, stdio, ...).
133
+ * @returns {Buffer | string} stdout, as `execFileSync` returns it.
134
+ */
135
+ export function runExecutable(name, args = [], options = {}) {
136
+ const resolved = resolveExecutable(name, { env: options.env });
137
+ const launch = launchOptions(resolved, args, options);
138
+ return execFileSync(launch.file, launch.args, launch.options);
139
+ }
140
+
141
+ /**
142
+ * Run `name` synchronously and return its trimmed stdout as a string.
143
+ * Uses `stdio: "pipe"` unless the caller overrides it.
144
+ *
145
+ * @param {string} name Program name such as "git".
146
+ * @param {readonly string[]} [args] Arguments passed verbatim, one argv entry each.
147
+ * @param {import("node:child_process").ExecFileSyncOptions} [options] Forwarded to `execFileSync`.
148
+ * @returns {string} Trimmed stdout.
149
+ */
150
+ export function runExecutableCapture(name, args = [], options = {}) {
151
+ const output = runExecutable(name, args, { stdio: "pipe", ...options });
152
+ // execFileSync yields null when the caller routes stdout away from "pipe".
153
+ return output == null ? "" : output.toString().trim();
154
+ }
155
+
156
+ /**
157
+ * Start `name` asynchronously (like `spawn`) with an argument array.
158
+ *
159
+ * @param {string} name Program name such as "npx".
160
+ * @param {readonly string[]} [args] Arguments passed verbatim, one argv entry each.
161
+ * @param {import("node:child_process").SpawnOptions} [options] Forwarded to `spawn` (cwd, env, stdio, ...).
162
+ * @returns {import("node:child_process").ChildProcess} The spawned child.
163
+ */
164
+ export function spawnExecutable(name, args = [], options = {}) {
165
+ const resolved = resolveExecutable(name, { env: options.env });
166
+ const launch = launchOptions(resolved, args, options);
167
+ return spawn(launch.file, launch.args, launch.options);
168
+ }
@@ -1,22 +1,22 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { execSync } from "node:child_process";
3
+ import { runExecutable, runExecutableCapture } from "./exec.mjs";
4
4
 
5
5
  /** Detect { owner, repo } from the git `origin` remote URL, or null. */
6
6
  export function detectRepoFromGit(root) {
7
7
  try {
8
- const url = execSync("git remote get-url origin", { cwd: root, stdio: "pipe" }).toString().trim();
8
+ const url = runExecutableCapture("git", ["remote", "get-url", "origin"], { cwd: root });
9
9
  const match = url.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/);
10
10
  if (match) return { owner: match[1], repo: match[2] };
11
11
  } catch {
12
- // no remote / not a repo
12
+ // no remote / not a repo / git not installed
13
13
  }
14
14
  return null;
15
15
  }
16
16
 
17
17
  export function tryConfigurePagesToGitHubActions(owner, repo, branch, root) {
18
18
  try {
19
- execSync("gh --version", { cwd: root, stdio: "ignore" });
19
+ runExecutable("gh", ["--version"], { cwd: root, stdio: "ignore" });
20
20
  } catch {
21
21
  console.warn(
22
22
  "GitHub CLI (gh) not found. Could not auto-configure Pages source. Set GitHub Pages source to 'GitHub Actions' manually in repository settings.",
@@ -24,16 +24,19 @@ export function tryConfigurePagesToGitHubActions(owner, repo, branch, root) {
24
24
  return;
25
25
  }
26
26
 
27
+ // Each candidate is an argv array: owner/repo/branch travel as discrete
28
+ // arguments, never through a shell string.
29
+ const pagesEndpoint = `repos/${owner}/${repo}/pages`;
27
30
  const candidates = [
28
- `gh api -X PUT "repos/${owner}/${repo}/pages" -f build_type=workflow`,
29
- `gh api -X POST "repos/${owner}/${repo}/pages" -f build_type=workflow`,
30
- `gh api -X POST "repos/${owner}/${repo}/pages" -f source[branch]=${branch} -f source[path]=/`,
31
- `gh api -X PUT "repos/${owner}/${repo}/pages" -f source[branch]=${branch} -f source[path]=/ -f build_type=workflow`,
31
+ ["api", "-X", "PUT", pagesEndpoint, "-f", "build_type=workflow"],
32
+ ["api", "-X", "POST", pagesEndpoint, "-f", "build_type=workflow"],
33
+ ["api", "-X", "POST", pagesEndpoint, "-f", `source[branch]=${branch}`, "-f", "source[path]=/"],
34
+ ["api", "-X", "PUT", pagesEndpoint, "-f", `source[branch]=${branch}`, "-f", "source[path]=/", "-f", "build_type=workflow"],
32
35
  ];
33
36
 
34
- for (const command of candidates) {
37
+ for (const args of candidates) {
35
38
  try {
36
- execSync(command, { cwd: root, stdio: "ignore" });
39
+ runExecutable("gh", args, { cwd: root, stdio: "ignore" });
37
40
  console.log("GitHub Pages source configured for GitHub Actions.");
38
41
  return;
39
42
  } catch {
@@ -48,7 +51,7 @@ export function tryConfigurePagesToGitHubActions(owner, repo, branch, root) {
48
51
 
49
52
  export function getCurrentGitBranch(root) {
50
53
  try {
51
- const branch = execSync("git branch --show-current", { cwd: root, stdio: "pipe" }).toString().trim();
54
+ const branch = runExecutableCapture("git", ["branch", "--show-current"], { cwd: root });
52
55
  return branch || "main";
53
56
  } catch {
54
57
  return "main";
@@ -67,17 +70,16 @@ export function runGitPushForGeneratedArtifacts(options, root, sanitizeSegment)
67
70
 
68
71
  const repoUrl = `https://github.com/${owner}/${repo}.git`;
69
72
  try {
70
- execSync("git remote get-url origin", { cwd: root, stdio: "ignore" });
73
+ runExecutable("git", ["remote", "get-url", "origin"], { cwd: root, stdio: "ignore" });
71
74
  } catch {
72
- execSync(`git remote add origin "${repoUrl}"`, { cwd: root, stdio: "inherit" });
75
+ runExecutable("git", ["remote", "add", "origin", repoUrl], { cwd: root, stdio: "inherit" });
73
76
  }
74
77
 
75
- execSync('git add "gitpagedocs" ".github/workflows/gitpagedocs-pages.yml"', { cwd: root, stdio: "inherit" });
78
+ runExecutable("git", ["add", "gitpagedocs", ".github/workflows/gitpagedocs-pages.yml"], { cwd: root, stdio: "inherit" });
76
79
 
77
80
  let hasStagedChanges = false;
78
81
  try {
79
- execSync("git diff --cached --quiet", { cwd: root, stdio: "ignore" });
80
- hasStagedChanges = false;
82
+ runExecutable("git", ["diff", "--cached", "--quiet"], { cwd: root, stdio: "ignore" });
81
83
  } catch {
82
84
  hasStagedChanges = true;
83
85
  }
@@ -86,11 +88,11 @@ export function runGitPushForGeneratedArtifacts(options, root, sanitizeSegment)
86
88
  return;
87
89
  }
88
90
 
89
- execSync('git commit -m "chore: setup gitpagedocs pages workflow"', { cwd: root, stdio: "inherit" });
91
+ runExecutable("git", ["commit", "-m", "chore: setup gitpagedocs pages workflow"], { cwd: root, stdio: "inherit" });
90
92
 
91
93
  const currentBranch = getCurrentGitBranch(root);
92
94
  try {
93
- execSync(`git push -u origin ${currentBranch}`, { cwd: root, stdio: "inherit" });
95
+ runExecutable("git", ["push", "-u", "origin", currentBranch], { cwd: root, stdio: "inherit" });
94
96
  tryConfigurePagesToGitHubActions(owner, repo, currentBranch, root);
95
97
  return;
96
98
  } catch {
@@ -98,8 +100,8 @@ export function runGitPushForGeneratedArtifacts(options, root, sanitizeSegment)
98
100
  }
99
101
 
100
102
  try {
101
- execSync(`git pull --rebase origin ${currentBranch}`, { cwd: root, stdio: "inherit" });
102
- execSync(`git push -u origin ${currentBranch}`, { cwd: root, stdio: "inherit" });
103
+ runExecutable("git", ["pull", "--rebase", "origin", currentBranch], { cwd: root, stdio: "inherit" });
104
+ runExecutable("git", ["push", "-u", "origin", currentBranch], { cwd: root, stdio: "inherit" });
103
105
  } catch {
104
106
  throw new Error(
105
107
  `Failed to push after automatic rebase on branch '${currentBranch}'. Resolve conflicts and run 'git push -u origin ${currentBranch}' manually.`,
@@ -48,7 +48,21 @@ export function listFilesRecursively(absoluteDir) {
48
48
  };
49
49
 
50
50
  walk(absoluteDir, "");
51
- return found.sort();
51
+ return found.sort(compareCodeUnits);
52
+ }
53
+
54
+ /**
55
+ * Plain UTF-16 code-unit order (what `sort()` does by default), spelled out so
56
+ * the listing never depends on the runtime locale.
57
+ *
58
+ * @param {string} a
59
+ * @param {string} b
60
+ * @returns {number}
61
+ */
62
+ function compareCodeUnits(a, b) {
63
+ if (a < b) return -1;
64
+ if (a > b) return 1;
65
+ return 0;
52
66
  }
53
67
 
54
68
  /**