@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.
- package/ai/application/ai-command.ts +1 -1
- package/ai/application/docs-pattern.ts +9 -5
- package/ai/application/resolve-chat-credentials.ts +13 -6
- package/ai/core/models/parse-ai-cli-config.ts +6 -8
- package/application/home/handler.mjs +3 -1
- package/application/ports/cli-runtime-ports.ts +2 -0
- package/application/report/config-only-reporter.mjs +2 -5
- package/builders/config-orchestrator.mjs +4 -7
- package/builders/language-bundles-builder.mjs +4 -4
- package/builders/root-config-builder.mjs +1 -1
- package/content/docs-en.mjs +4 -4
- package/content/docs-es.mjs +4 -4
- package/content/docs-pt.mjs +4 -4
- package/contracts/doc-versions.mjs +1 -1
- package/contracts/langs-paths.mjs +8 -5
- package/contracts/layouts-paths.mjs +4 -4
- package/contracts/path-segments.d.mts +2 -0
- package/contracts/path-segments.mjs +15 -0
- package/data/default-site-config.mjs +9 -2
- package/data/i18n-langmenu.mjs +3 -0
- package/domain/services/layouts-migration.mjs +15 -1
- package/home/home-file-writer.mjs +2 -3
- package/home/templates.mjs +5 -2
- package/infrastructure/home/runtime.mjs +2 -2
- package/package.json +3 -3
- package/presentation/commands/config-info.ts +44 -9
- package/presentation/commands/diagnostics.ts +30 -20
- package/presentation/commands/docs.ts +7 -2
- package/presentation/commands/pages.ts +4 -2
- package/presentation/options/parser.ts +46 -45
- package/presentation/ui/prompts.ts +6 -5
- package/runtime/doc-path-resolver.mjs +8 -6
- package/runtime/exec.mjs +168 -0
- package/runtime/git-ops.mjs +22 -20
- package/runtime/layouts-migrator.mjs +15 -1
- package/runtime/output.mjs +88 -58
- package/runtime/workflow.mjs +3 -1
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
ok:
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 ${
|
|
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}: ${
|
|
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${
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 ||
|
|
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
|
|
107
|
-
basePath:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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}`;
|
package/runtime/exec.mjs
ADDED
|
@@ -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
|
+
}
|
package/runtime/git-ops.mjs
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
37
|
+
for (const args of candidates) {
|
|
35
38
|
try {
|
|
36
|
-
|
|
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 =
|
|
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
|
-
|
|
73
|
+
runExecutable("git", ["remote", "get-url", "origin"], { cwd: root, stdio: "ignore" });
|
|
71
74
|
} catch {
|
|
72
|
-
|
|
75
|
+
runExecutable("git", ["remote", "add", "origin", repoUrl], { cwd: root, stdio: "inherit" });
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
/**
|