@gitpagedocs/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -0
- package/ai/application/ai-command.ts +24 -0
- package/ai/application/ai-provider-factory.ts +22 -0
- package/ai/application/docs-pattern.ts +72 -0
- package/ai/application/resolve-chat-credentials.ts +69 -0
- package/ai/application/run-ai-cli-command.ts +167 -0
- package/ai/config.ts +50 -0
- package/ai/core/chat-session.ts +44 -0
- package/ai/core/models/ai-cli-config.ts +33 -0
- package/ai/core/models/parse-ai-cli-config.ts +76 -0
- package/ai/core/ports/illm-provider.ts +32 -0
- package/ai/infrastructure/ai-config-file.ts +101 -0
- package/ai/infrastructure/file-system-adapter.ts +85 -0
- package/ai/infrastructure/llm/tools-llm-provider.ts +54 -0
- package/ai/infrastructure/version-docs-writer.ts +143 -0
- package/ai/presentation/ai-prompts.ts +239 -0
- package/application/config-only/handler.mjs +57 -0
- package/application/home/handler.mjs +87 -0
- package/application/ports/cli-runtime-ports.ts +93 -0
- package/application/report/config-only-reporter.mjs +68 -0
- package/application/use-cases/dispatch-mode.ts +18 -0
- package/builders/config-orchestrator.mjs +35 -0
- package/builders/language-bundles-builder.mjs +24 -0
- package/builders/project-links.mjs +50 -0
- package/builders/root-config-builder.d.mts +16 -0
- package/builders/root-config-builder.mjs +56 -0
- package/builders/route-builders.mjs +136 -0
- package/builders/theme-template.mjs +76 -0
- package/builders/version-config-builder.mjs +186 -0
- package/content/docs-en.mjs +556 -0
- package/content/docs-es.mjs +469 -0
- package/content/docs-pt.mjs +469 -0
- package/content/docs.mjs +9 -0
- package/contracts/doc-versions.mjs +3 -0
- package/contracts/index.mjs +11 -0
- package/contracts/langs-paths.mjs +28 -0
- package/contracts/languages.mjs +6 -0
- package/contracts/layouts-paths.d.mts +17 -0
- package/contracts/layouts-paths.mjs +68 -0
- package/contracts/path-segments.d.mts +2 -0
- package/contracts/path-segments.mjs +15 -0
- package/data/default-site-config.mjs +328 -0
- package/data/i18n-langmenu.mjs +350 -0
- package/data/i18n-translations.mjs +20 -0
- package/data/layouts.mjs +437 -0
- package/data/path-mappings.mjs +26 -0
- package/data/route-metas.mjs +93 -0
- package/data/theme-colors.mjs +444 -0
- package/data/urls.mjs +12 -0
- package/data/version-constants.mjs +3 -0
- package/domain/models/cli-options.ts +33 -0
- package/domain/services/layouts-migration.d.mts +15 -0
- package/domain/services/layouts-migration.mjs +74 -0
- package/domain/services/sanitize-segment.mjs +7 -0
- package/home/constants.mjs +4 -0
- package/home/home-file-writer.mjs +19 -0
- package/home/templates.mjs +51 -0
- package/index.mjs +38 -0
- package/infrastructure/config-only/runtime.mjs +17 -0
- package/infrastructure/gitpagedocs-config-file.ts +47 -0
- package/infrastructure/home/runtime.mjs +37 -0
- package/package.json +60 -0
- package/presentation/commands/ai-info.ts +57 -0
- package/presentation/commands/chat.ts +259 -0
- package/presentation/commands/config-clear.ts +23 -0
- package/presentation/commands/config-info.ts +72 -0
- package/presentation/commands/diagnostics.ts +174 -0
- package/presentation/commands/docs.ts +42 -0
- package/presentation/commands/mcp.ts +20 -0
- package/presentation/commands/pages.ts +103 -0
- package/presentation/commands/password.ts +62 -0
- package/presentation/commands/run-command.ts +72 -0
- package/presentation/index.ts +125 -0
- package/presentation/options/parser.ts +120 -0
- package/presentation/options/resolver.ts +46 -0
- package/presentation/options/schema.ts +14 -0
- package/presentation/ui/banner.ts +13 -0
- package/presentation/ui/clack.ts +91 -0
- package/presentation/ui/layouts-prompts.ts +124 -0
- package/presentation/ui/prompts.ts +147 -0
- package/presentation/ui/terminal-stream.ts +97 -0
- package/presentation/ui/tty.ts +37 -0
- package/runtime/doc-path-resolver.mjs +59 -0
- package/runtime/exec.mjs +168 -0
- package/runtime/git-ops.mjs +112 -0
- package/runtime/layouts-migrator.d.mts +11 -0
- package/runtime/layouts-migrator.mjs +117 -0
- package/runtime/output.mjs +201 -0
- package/runtime/workflow.mjs +131 -0
- package/start.mjs +14 -0
- package/ui/logger.mjs +9 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { CliMode, CliOptions } from "../../domain/models/cli-options";
|
|
2
|
+
import { DEFAULTS } from "./schema";
|
|
3
|
+
import { normalizeLayoutsDir } from "../../contracts/layouts-paths.mjs";
|
|
4
|
+
|
|
5
|
+
const KNOWN_FLAGS = new Set([
|
|
6
|
+
"--build",
|
|
7
|
+
"--serve",
|
|
8
|
+
"--layoutconfig",
|
|
9
|
+
"--full",
|
|
10
|
+
"--push",
|
|
11
|
+
"--home",
|
|
12
|
+
"--search",
|
|
13
|
+
"--path",
|
|
14
|
+
"--output",
|
|
15
|
+
"--layouts-dir",
|
|
16
|
+
"--interactive",
|
|
17
|
+
"-i",
|
|
18
|
+
"--no-interactive",
|
|
19
|
+
"--yes",
|
|
20
|
+
"-y",
|
|
21
|
+
"--ai",
|
|
22
|
+
"--pages-actions",
|
|
23
|
+
]);
|
|
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
|
+
|
|
28
|
+
function readOptionValue(args: string[], optionName: string): string {
|
|
29
|
+
const equalsArg = args.find((a) => a.startsWith(`${optionName}=`));
|
|
30
|
+
if (equalsArg) return equalsArg.slice(optionName.length + 1).trim();
|
|
31
|
+
const index = args.indexOf(optionName);
|
|
32
|
+
if (index >= 0) {
|
|
33
|
+
const next = args[index + 1];
|
|
34
|
+
if (next && !next.startsWith("--")) return next.trim();
|
|
35
|
+
}
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
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
|
+
|
|
70
|
+
export function parseCliOptions(argv: string[], env: NodeJS.ProcessEnv): CliOptions {
|
|
71
|
+
const args = argv.slice(2);
|
|
72
|
+
|
|
73
|
+
const docsPath = readOptionValue(args, "--path");
|
|
74
|
+
const outputDir = readOptionValue(args, "--output");
|
|
75
|
+
const layoutsDir = readOptionValue(args, "--layouts-dir");
|
|
76
|
+
const searchRaw = readOptionValue(args, "--search");
|
|
77
|
+
|
|
78
|
+
const fallbackDashedArgs = readFallbackOwnerRepo(args);
|
|
79
|
+
const githubOwner = readOptionValue(args, "--owner") || fallbackDashedArgs[0] || "";
|
|
80
|
+
const githubRepo = readOptionValue(args, "--repo") || fallbackDashedArgs[1] || "";
|
|
81
|
+
|
|
82
|
+
const { isDocument, isDeploy, aiCommand } = readCommandAliases(args);
|
|
83
|
+
|
|
84
|
+
const isBuild = args.includes("--build") || env.GITPAGEDOCS_BUILD === "1";
|
|
85
|
+
const isServe = args.includes("--serve");
|
|
86
|
+
const useLocalLayoutConfig = args.includes("--layoutconfig");
|
|
87
|
+
const shouldPush = args.includes("--push") || isDeploy;
|
|
88
|
+
const isHome = args.includes("--home");
|
|
89
|
+
const isInteractive = args.includes("--interactive") || args.includes("-i");
|
|
90
|
+
const isAi = args[0] === "ai" || args.includes("--ai") || isDocument;
|
|
91
|
+
|
|
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;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
isBuild,
|
|
98
|
+
isServe,
|
|
99
|
+
mode,
|
|
100
|
+
aiCommand,
|
|
101
|
+
outputDir: outputDir || defaultOutputDir,
|
|
102
|
+
layoutsDir: normalizeLayoutsDir(layoutsDir || DEFAULTS.layoutsDir),
|
|
103
|
+
useLocalLayoutConfig,
|
|
104
|
+
shouldPush,
|
|
105
|
+
githubOwner,
|
|
106
|
+
githubRepo,
|
|
107
|
+
docsPath,
|
|
108
|
+
basePath: docsPath,
|
|
109
|
+
repositorySearch,
|
|
110
|
+
isInteractive,
|
|
111
|
+
hasArgs: args.length > 0,
|
|
112
|
+
explicit: {
|
|
113
|
+
useLocalLayoutConfig,
|
|
114
|
+
layoutsDir: Boolean(layoutsDir),
|
|
115
|
+
githubOwner: Boolean(githubOwner),
|
|
116
|
+
githubRepo: Boolean(githubRepo),
|
|
117
|
+
outputDir: Boolean(outputDir),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { CliOptions } from "../../domain/models/cli-options";
|
|
2
|
+
import { parseCliOptions } from "./parser";
|
|
3
|
+
import {
|
|
4
|
+
shouldRunInteractive,
|
|
5
|
+
promptConfigOnlyOptions,
|
|
6
|
+
promptHomeOptions,
|
|
7
|
+
promptDeployOptions,
|
|
8
|
+
ensureGitRepoInteractive,
|
|
9
|
+
interactivePromptsAvailable,
|
|
10
|
+
} from "../ui/prompts";
|
|
11
|
+
import { DEFAULTS } from "./schema";
|
|
12
|
+
// @ts-expect-error .mjs runtime module is type-less in this package.
|
|
13
|
+
import { detectRepoFromGit } from "../../runtime/git-ops.mjs";
|
|
14
|
+
|
|
15
|
+
export async function resolveOptions(argv: string[], env: NodeJS.ProcessEnv): Promise<CliOptions> {
|
|
16
|
+
const parsed = parseCliOptions(argv, env);
|
|
17
|
+
|
|
18
|
+
if (parsed.mode === "home") {
|
|
19
|
+
parsed.repositorySearch = parsed.repositorySearch ?? DEFAULTS.home.repositorySearch;
|
|
20
|
+
parsed.basePath = parsed.basePath ?? parsed.docsPath ?? DEFAULTS.home.basePath;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// `deploy` / `--push` need owner + repo AND a git repo. If anything is
|
|
24
|
+
// missing, prompt interactively (owner/repo pre-filled from the git origin
|
|
25
|
+
// remote; offer to `git init`) instead of throwing. In CI/non-TTY we fall
|
|
26
|
+
// through so the explicit guards in the push flow still fire clearly.
|
|
27
|
+
if (parsed.shouldPush && interactivePromptsAvailable()) {
|
|
28
|
+
let resolved = parsed;
|
|
29
|
+
if (!parsed.githubOwner || !parsed.githubRepo) {
|
|
30
|
+
const detected = detectRepoFromGit(process.cwd()) as { owner: string; repo: string } | null;
|
|
31
|
+
resolved = await promptDeployOptions(parsed, detected);
|
|
32
|
+
}
|
|
33
|
+
await ensureGitRepoInteractive(process.cwd());
|
|
34
|
+
return resolved;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Every command prompts in a real terminal; shouldRunInteractive owns the
|
|
38
|
+
// CI / piped-stdin / --no-interactive opt-outs, and each prompt asks only for
|
|
39
|
+
// what the command line left unanswered.
|
|
40
|
+
if (!shouldRunInteractive(argv)) return parsed;
|
|
41
|
+
|
|
42
|
+
if (parsed.mode === "home") return promptHomeOptions(parsed);
|
|
43
|
+
if (parsed.mode === "config-only") return promptConfigOnlyOptions(parsed);
|
|
44
|
+
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { DEFAULT_LAYOUTS_DIR } from "../../contracts/layouts-paths.mjs";
|
|
2
|
+
|
|
3
|
+
export const DEFAULTS = {
|
|
4
|
+
outputDir: "gitpagedocs",
|
|
5
|
+
/** Standalone layouts home; local layouts are generated here, never in outputDir. */
|
|
6
|
+
layoutsDir: DEFAULT_LAYOUTS_DIR as string,
|
|
7
|
+
repositorySearch: false,
|
|
8
|
+
basePath: "",
|
|
9
|
+
home: {
|
|
10
|
+
outputDir: "gitpagedocshome",
|
|
11
|
+
repositorySearch: false,
|
|
12
|
+
basePath: "",
|
|
13
|
+
},
|
|
14
|
+
} as const;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function printBanner(): void {
|
|
2
|
+
console.log("");
|
|
3
|
+
console.log(" Git Page Docs");
|
|
4
|
+
console.log(" by Vidigal-code");
|
|
5
|
+
console.log(" https://github.com/Vidigal-code/git-page-docs");
|
|
6
|
+
console.log("");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function printCredits(): void {
|
|
10
|
+
console.log("");
|
|
11
|
+
console.log(" Powered by Vidigal-code - git-page-docs");
|
|
12
|
+
console.log("");
|
|
13
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin, cancellation-aware wrappers over @clack/prompts. Centralizes the
|
|
3
|
+
* isCancel handling so prompt modules stay declarative and a Ctrl-C exits
|
|
4
|
+
* cleanly (mirroring the previous inquirer behaviour).
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
text,
|
|
8
|
+
password,
|
|
9
|
+
confirm,
|
|
10
|
+
select,
|
|
11
|
+
multiselect,
|
|
12
|
+
isCancel,
|
|
13
|
+
cancel,
|
|
14
|
+
} from "@clack/prompts";
|
|
15
|
+
|
|
16
|
+
function exitIfCancelled<T>(value: T | symbol): T {
|
|
17
|
+
if (isCancel(value)) {
|
|
18
|
+
cancel("Operation cancelled.");
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
return value as T;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AskTextOptions {
|
|
25
|
+
message: string;
|
|
26
|
+
defaultValue?: string;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
validate?: (value: string) => string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function askText(options: AskTextOptions): Promise<string> {
|
|
32
|
+
const result = await text({
|
|
33
|
+
message: options.message,
|
|
34
|
+
placeholder: options.placeholder,
|
|
35
|
+
defaultValue: options.defaultValue,
|
|
36
|
+
initialValue: undefined,
|
|
37
|
+
validate: options.validate
|
|
38
|
+
? (value) => options.validate?.(value ?? "")
|
|
39
|
+
: undefined,
|
|
40
|
+
});
|
|
41
|
+
return exitIfCancelled(result);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AskPasswordOptions {
|
|
45
|
+
message: string;
|
|
46
|
+
validate?: (value: string) => string | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Masked password prompt (input hidden as the user types). */
|
|
50
|
+
export async function askPassword(options: AskPasswordOptions): Promise<string> {
|
|
51
|
+
const result = await password({
|
|
52
|
+
message: options.message,
|
|
53
|
+
validate: options.validate
|
|
54
|
+
? (value) => options.validate?.(value ?? "")
|
|
55
|
+
: undefined,
|
|
56
|
+
});
|
|
57
|
+
return exitIfCancelled(result);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function askConfirm(message: string, initialValue = false): Promise<boolean> {
|
|
61
|
+
const result = await confirm({ message, initialValue });
|
|
62
|
+
return exitIfCancelled(result);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Choice<T extends string> {
|
|
66
|
+
value: T;
|
|
67
|
+
label: string;
|
|
68
|
+
hint?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function askSelect<T extends string>(
|
|
72
|
+
message: string,
|
|
73
|
+
options: Choice<T>[],
|
|
74
|
+
initialValue?: T,
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
// clack's Option<Value> is a conditional type TS can't match against a generic
|
|
77
|
+
// Choice<T> (identical shape); cast at the boundary, inference flows from initialValue.
|
|
78
|
+
const result = await select<T>({ message, options: options as never, initialValue });
|
|
79
|
+
return exitIfCancelled(result);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function askMultiSelect<T extends string>(
|
|
83
|
+
message: string,
|
|
84
|
+
options: Choice<T>[],
|
|
85
|
+
initialValues?: T[],
|
|
86
|
+
): Promise<T[]> {
|
|
87
|
+
const result = await multiselect<T>({ message, options: options as never, initialValues, required: true });
|
|
88
|
+
return exitIfCancelled(result);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { intro, outro, note, spinner } from "@clack/prompts";
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout-related prompts: where layouts come from, where they are written, and
|
|
3
|
+
* the opt-in migration away from the legacy `<outputDir>/layouts/` folder.
|
|
4
|
+
*/
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { askConfirm, askSelect, askText, note } from "./clack";
|
|
7
|
+
import { interactivePromptsAvailable } from "./tty";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_LAYOUTS_DIR,
|
|
10
|
+
normalizeLayoutsDir,
|
|
11
|
+
legacyLayoutsDir,
|
|
12
|
+
} from "../../contracts/layouts-paths.mjs";
|
|
13
|
+
import { planLayoutsMigration } from "../../domain/services/layouts-migration.mjs";
|
|
14
|
+
import { listFilesRecursively, executeLayoutsMigration } from "../../runtime/layouts-migrator.mjs";
|
|
15
|
+
|
|
16
|
+
type LayoutsSource = "official" | "local";
|
|
17
|
+
|
|
18
|
+
/** Outcome of a completed migration, for the caller to report. */
|
|
19
|
+
export interface LayoutsMigrationOutcome {
|
|
20
|
+
from: string;
|
|
21
|
+
to: string;
|
|
22
|
+
movedCount: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Ask whether layouts come from the official remote source or a local folder. */
|
|
26
|
+
export async function askLayoutsSource(useLocal: boolean): Promise<boolean> {
|
|
27
|
+
const choice = await askSelect<LayoutsSource>(
|
|
28
|
+
"Layout source",
|
|
29
|
+
[
|
|
30
|
+
{
|
|
31
|
+
value: "official",
|
|
32
|
+
label: "Official layouts (remote)",
|
|
33
|
+
hint: "nothing is written to your repository",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
value: "local",
|
|
37
|
+
label: "Local layouts",
|
|
38
|
+
hint: `generated into ${DEFAULT_LAYOUTS_DIR}/`,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
useLocal ? "local" : "official",
|
|
42
|
+
);
|
|
43
|
+
return choice === "local";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Ask which folder should hold the generated layouts. */
|
|
47
|
+
export async function askLayoutsDir(current: string): Promise<string> {
|
|
48
|
+
const answer = await askText({
|
|
49
|
+
message: "Layouts folder:",
|
|
50
|
+
defaultValue: normalizeLayoutsDir(current),
|
|
51
|
+
validate: (value) => (value.trim() ? undefined : "A folder name is required."),
|
|
52
|
+
});
|
|
53
|
+
return normalizeLayoutsDir(answer);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toAbsolute(root: string, repoRelative: string): string {
|
|
57
|
+
return path.join(root, ...repoRelative.split("/"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Offer to move layouts left behind in the legacy folder.
|
|
62
|
+
*
|
|
63
|
+
* Nothing is moved without an explicit confirmation. Outside a TTY the legacy
|
|
64
|
+
* folder is reported and left alone — the viewer still reads it, so declining
|
|
65
|
+
* (or automating) never breaks an existing site.
|
|
66
|
+
*
|
|
67
|
+
* @returns The outcome when files were moved, otherwise `null`.
|
|
68
|
+
*/
|
|
69
|
+
export async function migrateLegacyLayoutsInteractive(
|
|
70
|
+
root: string,
|
|
71
|
+
options: { outputDir: string; layoutsDir: string },
|
|
72
|
+
): Promise<LayoutsMigrationOutcome | null> {
|
|
73
|
+
const from = legacyLayoutsDir(options.outputDir);
|
|
74
|
+
const to = normalizeLayoutsDir(options.layoutsDir);
|
|
75
|
+
|
|
76
|
+
const plan = planLayoutsMigration({
|
|
77
|
+
outputDir: options.outputDir,
|
|
78
|
+
layoutsDir: to,
|
|
79
|
+
legacyFiles: listFilesRecursively(toAbsolute(root, from)),
|
|
80
|
+
targetFiles: listFilesRecursively(toAbsolute(root, to)),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!plan.required) return null;
|
|
84
|
+
|
|
85
|
+
if (!interactivePromptsAvailable()) {
|
|
86
|
+
note(
|
|
87
|
+
`${plan.files.length} layout file(s) still live in ${plan.from}/.\n` +
|
|
88
|
+
`The viewer still reads them. Run this in a terminal to move them to ${plan.to}/.`,
|
|
89
|
+
"Legacy layouts",
|
|
90
|
+
);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const overwriteWarning =
|
|
95
|
+
plan.overwrites.length > 0
|
|
96
|
+
? `\n${plan.overwrites.length} file(s) already in ${plan.to}/ would be replaced.`
|
|
97
|
+
: "";
|
|
98
|
+
note(
|
|
99
|
+
`Found ${plan.files.length} layout file(s) in ${plan.from}/.\n` +
|
|
100
|
+
`Layouts are now generated in ${plan.to}/.${overwriteWarning}`,
|
|
101
|
+
"Legacy layouts",
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const confirmed = await askConfirm(
|
|
105
|
+
`Move them to ${plan.to}/ now?`,
|
|
106
|
+
plan.overwrites.length === 0,
|
|
107
|
+
);
|
|
108
|
+
if (!confirmed) {
|
|
109
|
+
note(`Left untouched — the viewer still reads ${plan.from}/.`, "Legacy layouts");
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result = executeLayoutsMigration(root, plan);
|
|
114
|
+
|
|
115
|
+
if (result.failed.length > 0) {
|
|
116
|
+
note(
|
|
117
|
+
`${result.failed.length} file(s) could not be moved:\n` +
|
|
118
|
+
result.failed.map((entry) => `- ${entry.file}: ${entry.reason}`).join("\n"),
|
|
119
|
+
"Migration incomplete",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { from: plan.from, to: plan.to, movedCount: result.moved.length };
|
|
124
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// @ts-expect-error .mjs runtime module is type-less in this package.
|
|
4
|
+
import { runExecutable } from "../../runtime/exec.mjs";
|
|
5
|
+
import type { CliOptions } from "../../domain/models/cli-options";
|
|
6
|
+
import { DEFAULTS } from "../options/schema";
|
|
7
|
+
import { askText, askConfirm, note } from "./clack";
|
|
8
|
+
import { interactivePromptsAvailable, promptingEnabled } from "./tty";
|
|
9
|
+
import { askLayoutsSource, askLayoutsDir } from "./layouts-prompts";
|
|
10
|
+
|
|
11
|
+
export { interactivePromptsAvailable };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every command prompts when it runs in a real terminal, so a bare invocation
|
|
15
|
+
* reflects what the user actually needs instead of silently taking defaults.
|
|
16
|
+
* CI, a piped stdin, and an explicit `--no-interactive` / `--yes` / `-y` opt out;
|
|
17
|
+
* `--interactive` / `-i` remains accepted and reads the same either way.
|
|
18
|
+
*/
|
|
19
|
+
export function shouldRunInteractive(argv: string[]): boolean {
|
|
20
|
+
return promptingEnabled(argv.slice(2));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function promptHomeOptions(parsed: CliOptions): Promise<CliOptions> {
|
|
24
|
+
const outputDir = await askText({
|
|
25
|
+
message: "Output directory:",
|
|
26
|
+
defaultValue: parsed.outputDir ?? DEFAULTS.home.outputDir,
|
|
27
|
+
validate: (v) => (v?.trim() ? undefined : "Required"),
|
|
28
|
+
});
|
|
29
|
+
const repositorySearch = await askConfirm(
|
|
30
|
+
"Enable repository search home?",
|
|
31
|
+
parsed.repositorySearch ?? DEFAULTS.repositorySearch,
|
|
32
|
+
);
|
|
33
|
+
const basePath = await askText({
|
|
34
|
+
message: "Base path (e.g. git-page-docs, empty for root):",
|
|
35
|
+
defaultValue: parsed.basePath ?? DEFAULTS.basePath,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
...parsed,
|
|
40
|
+
outputDir: outputDir.trim(),
|
|
41
|
+
repositorySearch,
|
|
42
|
+
basePath: basePath?.trim() ?? "",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Deploy/--push needs a GitHub owner + repo. When they are missing, prompt for
|
|
48
|
+
* them (pre-filled from the git `origin` remote when available) instead of
|
|
49
|
+
* crashing with "`--push` requires owner and repo".
|
|
50
|
+
*/
|
|
51
|
+
export async function askOwnerRepo(
|
|
52
|
+
detected: { owner: string; repo: string } | null,
|
|
53
|
+
defaults?: { owner?: string; repo?: string },
|
|
54
|
+
): Promise<{ owner: string; repo: string }> {
|
|
55
|
+
const owner = await askText({
|
|
56
|
+
message: "GitHub owner (user or organization):",
|
|
57
|
+
defaultValue: defaults?.owner || detected?.owner || "",
|
|
58
|
+
validate: (v) => (v?.trim() ? undefined : "Owner is required."),
|
|
59
|
+
});
|
|
60
|
+
const repo = await askText({
|
|
61
|
+
message: "GitHub repository name:",
|
|
62
|
+
defaultValue: defaults?.repo || detected?.repo || "",
|
|
63
|
+
validate: (v) => (v?.trim() ? undefined : "Repository is required."),
|
|
64
|
+
});
|
|
65
|
+
return { owner: owner.trim(), repo: repo.trim() };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function promptDeployOptions(
|
|
69
|
+
parsed: CliOptions,
|
|
70
|
+
detected: { owner: string; repo: string } | null,
|
|
71
|
+
): Promise<CliOptions> {
|
|
72
|
+
note(
|
|
73
|
+
"Deploy publishes gitpagedocs to GitHub Pages and needs the target\nrepository (owner/repo). Press Enter to accept a detected value.",
|
|
74
|
+
"GitHub Pages deploy",
|
|
75
|
+
);
|
|
76
|
+
const { owner, repo } = await askOwnerRepo(detected, {
|
|
77
|
+
owner: parsed.githubOwner,
|
|
78
|
+
repo: parsed.githubRepo,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
...parsed,
|
|
83
|
+
githubOwner: owner,
|
|
84
|
+
githubRepo: repo,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Deploy/--push needs a git repository in the current folder. When there isn't
|
|
90
|
+
* one, offer to run `git init` instead of crashing with "not a git repository".
|
|
91
|
+
* In CI/non-TTY this is a no-op so the downstream guard still reports clearly.
|
|
92
|
+
*/
|
|
93
|
+
export async function ensureGitRepoInteractive(root: string): Promise<void> {
|
|
94
|
+
if (existsSync(path.join(root, ".git"))) return;
|
|
95
|
+
if (!interactivePromptsAvailable()) return;
|
|
96
|
+
const init = await askConfirm(
|
|
97
|
+
"This folder is not a git repository yet. Initialize one here (git init)?",
|
|
98
|
+
true,
|
|
99
|
+
);
|
|
100
|
+
if (!init) {
|
|
101
|
+
note("Deploy needs a git repository. Run `git init`, then retry.", "Heads up");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
runExecutable("git", ["init"], { cwd: root, stdio: "ignore" });
|
|
106
|
+
note("Initialized an empty git repository.", "git");
|
|
107
|
+
} catch {
|
|
108
|
+
note("Could not run `git init`. Initialize git manually, then retry.", "git");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ask only for what the command line did not already answer, so an explicit
|
|
114
|
+
* flag is never second-guessed by a prompt.
|
|
115
|
+
*/
|
|
116
|
+
export async function promptConfigOnlyOptions(parsed: CliOptions): Promise<CliOptions> {
|
|
117
|
+
const useLocalLayoutConfig = parsed.explicit.useLocalLayoutConfig
|
|
118
|
+
? parsed.useLocalLayoutConfig
|
|
119
|
+
: await askLayoutsSource(parsed.useLocalLayoutConfig ?? false);
|
|
120
|
+
|
|
121
|
+
const layoutsDir =
|
|
122
|
+
useLocalLayoutConfig && !parsed.explicit.layoutsDir
|
|
123
|
+
? await askLayoutsDir(parsed.layoutsDir)
|
|
124
|
+
: parsed.layoutsDir;
|
|
125
|
+
|
|
126
|
+
const githubOwner = parsed.explicit.githubOwner
|
|
127
|
+
? parsed.githubOwner
|
|
128
|
+
: await askText({
|
|
129
|
+
message: "GitHub owner (optional):",
|
|
130
|
+
defaultValue: parsed.githubOwner ?? "",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const githubRepo = parsed.explicit.githubRepo
|
|
134
|
+
? parsed.githubRepo
|
|
135
|
+
: await askText({
|
|
136
|
+
message: "GitHub repo (optional):",
|
|
137
|
+
defaultValue: parsed.githubRepo ?? "",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
...parsed,
|
|
142
|
+
useLocalLayoutConfig,
|
|
143
|
+
layoutsDir,
|
|
144
|
+
githubOwner: githubOwner?.trim() ?? "",
|
|
145
|
+
githubRepo: githubRepo?.trim() ?? "",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file terminal-stream.ts
|
|
3
|
+
* @description Terminal rendering for streamed AI output, non-TTY-first:
|
|
4
|
+
* - model text goes to STDOUT (so `chat "q" > out.txt` captures clean content)
|
|
5
|
+
* - all chrome (spinner, headers, hints) goes to STDERR
|
|
6
|
+
* - colours/spinner are emitted only on a TTY; a pipe gets plain text
|
|
7
|
+
* Keeps the streaming/rendering side effects in one place, apart from the pure
|
|
8
|
+
* ChatSession state.
|
|
9
|
+
*/
|
|
10
|
+
const ANSI = {
|
|
11
|
+
reset: "\x1b[0m",
|
|
12
|
+
dim: "\x1b[2m",
|
|
13
|
+
cyan: "\x1b[36m",
|
|
14
|
+
green: "\x1b[32m",
|
|
15
|
+
red: "\x1b[31m",
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
19
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
20
|
+
|
|
21
|
+
function stdoutIsTty(): boolean {
|
|
22
|
+
return Boolean(process.stdout.isTTY);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stderrIsTty(): boolean {
|
|
26
|
+
return Boolean(process.stderr.isTTY);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Writes model content to stdout (verbatim; the actual deliverable). */
|
|
30
|
+
export function writeContent(text: string): void {
|
|
31
|
+
process.stdout.write(text);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Writes one chrome line to stderr, dimmed on a TTY. */
|
|
35
|
+
export function writeChrome(line: string): void {
|
|
36
|
+
if (stderrIsTty()) {
|
|
37
|
+
process.stderr.write(`${ANSI.dim}${line}${ANSI.reset}\n`);
|
|
38
|
+
} else {
|
|
39
|
+
process.stderr.write(`${line}\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Colour helpers that no-op when stderr is not a TTY. */
|
|
44
|
+
export function accent(text: string): string {
|
|
45
|
+
return stderrIsTty() ? `${ANSI.cyan}${text}${ANSI.reset}` : text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function danger(text: string): string {
|
|
49
|
+
return stderrIsTty() ? `${ANSI.red}${text}${ANSI.reset}` : text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A spinner with elapsed-time readout on stderr. On a non-TTY it prints a
|
|
54
|
+
* single static line instead of animating, so logs stay clean. `stop()` clears
|
|
55
|
+
* the line and is idempotent.
|
|
56
|
+
*/
|
|
57
|
+
export class ElapsedSpinner {
|
|
58
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
59
|
+
private frame = 0;
|
|
60
|
+
private readonly startedAt = Date.now();
|
|
61
|
+
|
|
62
|
+
constructor(private readonly label: string) {}
|
|
63
|
+
|
|
64
|
+
start(): void {
|
|
65
|
+
if (!stderrIsTty()) {
|
|
66
|
+
process.stderr.write(`${this.label}...\n`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.render();
|
|
70
|
+
this.timer = setInterval(() => this.render(), SPINNER_INTERVAL_MS);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private render(): void {
|
|
74
|
+
const seconds = ((Date.now() - this.startedAt) / 1000).toFixed(1);
|
|
75
|
+
const glyph = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
|
|
76
|
+
this.frame += 1;
|
|
77
|
+
process.stderr.write(`\r${ANSI.cyan}${glyph}${ANSI.reset} ${ANSI.dim}${this.label} ${seconds}s${ANSI.reset}\x1b[K`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
stop(): void {
|
|
81
|
+
if (this.timer) {
|
|
82
|
+
clearInterval(this.timer);
|
|
83
|
+
this.timer = null;
|
|
84
|
+
}
|
|
85
|
+
if (stderrIsTty()) {
|
|
86
|
+
process.stderr.write("\r\x1b[K");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Elapsed seconds since construction. */
|
|
91
|
+
elapsedSeconds(): number {
|
|
92
|
+
return (Date.now() - this.startedAt) / 1000;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const supportsColor = stderrIsTty;
|
|
97
|
+
export const isInteractiveOutput = stdoutIsTty;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal capability detection.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own module so every prompt module can ask "may I prompt?" without
|
|
5
|
+
* importing the prompt modules themselves (which would be circular).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Environment variables that mark a non-interactive automated run. */
|
|
9
|
+
const CI_ENV_FLAGS = ["CI", "GITHUB_ACTIONS"] as const;
|
|
10
|
+
|
|
11
|
+
/** Flags that explicitly opt out of prompting. */
|
|
12
|
+
export const NON_INTERACTIVE_FLAGS = ["--no-interactive", "--yes", "-y"] as const;
|
|
13
|
+
|
|
14
|
+
function isCiOrNonTty(): boolean {
|
|
15
|
+
for (const flag of CI_ENV_FLAGS) {
|
|
16
|
+
if (process.env[flag] === "true") return true;
|
|
17
|
+
}
|
|
18
|
+
return Boolean(process.stdin && !process.stdin.isTTY);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** True when clack prompts can run (interactive TTY, not CI). */
|
|
22
|
+
export function interactivePromptsAvailable(): boolean {
|
|
23
|
+
return !isCiOrNonTty();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True when the user explicitly asked for a non-interactive run. */
|
|
27
|
+
export function promptingOptedOut(args: readonly string[]): boolean {
|
|
28
|
+
return NON_INTERACTIVE_FLAGS.some((flag) => args.includes(flag));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* True when prompts should run for this invocation: a real terminal, and no
|
|
33
|
+
* explicit opt-out.
|
|
34
|
+
*/
|
|
35
|
+
export function promptingEnabled(args: readonly string[]): boolean {
|
|
36
|
+
return interactivePromptsAvailable() && !promptingOptedOut(args);
|
|
37
|
+
}
|