@gitpagedocs/cli 1.1.60 → 1.1.62
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 +7 -5
- package/application/config-only/handler.mjs +2 -0
- package/application/home/handler.mjs +2 -0
- package/application/ports/cli-runtime-ports.ts +4 -0
- package/application/report/config-only-reporter.mjs +4 -3
- package/builders/project-links.mjs +24 -0
- package/builders/root-config-builder.d.mts +16 -0
- package/builders/root-config-builder.mjs +9 -1
- package/content/docs-en.mjs +24 -20
- package/content/docs-es.mjs +16 -14
- package/content/docs-pt.mjs +16 -14
- package/contracts/doc-versions.mjs +1 -1
- package/contracts/index.mjs +10 -0
- package/contracts/layouts-paths.d.mts +17 -0
- package/contracts/layouts-paths.mjs +68 -0
- package/domain/models/cli-options.ts +15 -0
- package/domain/services/layouts-migration.d.mts +15 -0
- package/domain/services/layouts-migration.mjs +60 -0
- package/index.mjs +1 -1
- package/package.json +3 -3
- package/presentation/commands/diagnostics.ts +102 -8
- package/presentation/index.ts +8 -0
- package/presentation/options/parser.ts +15 -0
- package/presentation/options/resolver.ts +6 -8
- package/presentation/options/schema.ts +4 -0
- package/presentation/ui/layouts-prompts.ts +124 -0
- package/presentation/ui/prompts.ts +37 -29
- package/presentation/ui/tty.ts +37 -0
- package/runtime/layouts-migrator.d.mts +11 -0
- package/runtime/layouts-migrator.mjs +103 -0
- package/runtime/output.mjs +33 -20
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy layouts migration — planning only.
|
|
3
|
+
*
|
|
4
|
+
* Deciding *what* should move is domain logic, so it stays free of prompts and
|
|
5
|
+
* filesystem writes: callers list the two folders, ask the user, and perform
|
|
6
|
+
* the move. That keeps the rule testable with plain arrays.
|
|
7
|
+
*/
|
|
8
|
+
import { legacyLayoutsDir, normalizeLayoutsDir } from "../../contracts/layouts-paths.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} LayoutsMigrationPlan
|
|
12
|
+
* @property {boolean} required True when the legacy folder holds files to move.
|
|
13
|
+
* @property {string} from Repo-relative legacy folder.
|
|
14
|
+
* @property {string} to Repo-relative destination folder.
|
|
15
|
+
* @property {string[]} files Paths relative to `from`, sorted.
|
|
16
|
+
* @property {string[]} overwrites Subset of `files` already present at `to`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Plan the move from the legacy layouts folder to the standalone home.
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} options
|
|
23
|
+
* @param {string} options.outputDir Docs output dir (owns the legacy folder).
|
|
24
|
+
* @param {string} options.layoutsDir Destination layouts folder.
|
|
25
|
+
* @param {string[]} [options.legacyFiles] Paths relative to the legacy folder.
|
|
26
|
+
* @param {string[]} [options.targetFiles] Paths relative to the destination folder.
|
|
27
|
+
* @returns {LayoutsMigrationPlan}
|
|
28
|
+
*/
|
|
29
|
+
export function planLayoutsMigration(options = {}) {
|
|
30
|
+
const { outputDir, layoutsDir, legacyFiles, targetFiles } = options;
|
|
31
|
+
|
|
32
|
+
const from = legacyLayoutsDir(outputDir);
|
|
33
|
+
const to = normalizeLayoutsDir(layoutsDir);
|
|
34
|
+
|
|
35
|
+
const files = toSortedPaths(legacyFiles);
|
|
36
|
+
const present = new Set(toSortedPaths(targetFiles));
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
required: files.length > 0 && from !== to,
|
|
40
|
+
from,
|
|
41
|
+
to,
|
|
42
|
+
files,
|
|
43
|
+
overwrites: files.filter((file) => present.has(file)),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalize, de-duplicate and sort a list of relative paths so a plan is
|
|
49
|
+
* deterministic regardless of directory-read order.
|
|
50
|
+
*
|
|
51
|
+
* @param {unknown} paths
|
|
52
|
+
* @returns {string[]}
|
|
53
|
+
*/
|
|
54
|
+
function toSortedPaths(paths) {
|
|
55
|
+
if (!Array.isArray(paths)) return [];
|
|
56
|
+
const normalized = paths
|
|
57
|
+
.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
58
|
+
.map((entry) => entry.replace(/[\\/]+/g, "/").replace(/^\/+/, ""));
|
|
59
|
+
return [...new Set(normalized)].sort();
|
|
60
|
+
}
|
package/index.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gitpagedocs/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.62",
|
|
4
4
|
"description": "CLI that scaffolds and maintains gitpagedocs documentation, generates docs with AI, configures GitHub Pages, and runs the MCP server.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"gitpagedocs": "index.mjs"
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@clack/prompts": "^1.5.1",
|
|
32
|
-
"@gitpagedocs/mcp": "^1.1.
|
|
33
|
-
"@gitpagedocs/tools": "^1.1.
|
|
32
|
+
"@gitpagedocs/mcp": "^1.1.62",
|
|
33
|
+
"@gitpagedocs/tools": "^1.1.62",
|
|
34
34
|
"tsx": "^4.21.0"
|
|
35
35
|
},
|
|
36
36
|
"repository": {
|
|
@@ -57,14 +57,108 @@ export async function runDoctor(ctx: CommandContext): Promise<void> {
|
|
|
57
57
|
console.log(`\n ${failed === 0 ? "All required checks passed." : `${failed} check(s) need attention.`}\n`);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Used only when package.json is unreadable — the CLI's published name. */
|
|
61
|
+
const FALLBACK_PACKAGE = "@gitpagedocs/cli";
|
|
62
|
+
/** The bin this package installs; shown alongside the package name. */
|
|
63
|
+
const BIN_NAME = "gitpagedocs";
|
|
64
|
+
|
|
65
|
+
interface PackageIdentity {
|
|
66
|
+
name: string;
|
|
67
|
+
version: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read the identity from the installed package.json rather than hardcoding it,
|
|
72
|
+
* so the hint always names the package the user actually has. A hardcoded name
|
|
73
|
+
* is how `update` ended up advertising an unpublished package.
|
|
74
|
+
*/
|
|
75
|
+
function readPackageIdentity(pkgRoot: string): PackageIdentity {
|
|
76
|
+
try {
|
|
77
|
+
const pkg = JSON.parse(readFileSync(path.join(pkgRoot, "package.json"), "utf8"));
|
|
78
|
+
const name = typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : FALLBACK_PACKAGE;
|
|
79
|
+
return { name, version: String(pkg.version ?? "unknown") };
|
|
80
|
+
} catch {
|
|
81
|
+
return { name: FALLBACK_PACKAGE, version: "unknown" };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type RegistryLookup =
|
|
86
|
+
| { status: "ok"; latest: string }
|
|
87
|
+
| { status: "missing" }
|
|
88
|
+
| { status: "unreachable" };
|
|
89
|
+
|
|
90
|
+
function registryBase(): string {
|
|
91
|
+
const configured = process.env.npm_config_registry || "https://registry.npmjs.org";
|
|
92
|
+
return configured.endsWith("/") ? configured.slice(0, -1) : configured;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resolve the dist-tag `latest` for a package. Never throws. */
|
|
96
|
+
async function fetchLatest(name: string, timeoutMs = 6000): Promise<RegistryLookup> {
|
|
97
|
+
const encoded = name.split("/").map(encodeURIComponent).join("/");
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch(`${registryBase()}/${encoded}/latest`, {
|
|
100
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
101
|
+
});
|
|
102
|
+
if (response.status === 404) return { status: "missing" };
|
|
103
|
+
if (!response.ok) return { status: "unreachable" };
|
|
104
|
+
const body = (await response.json()) as { version?: unknown };
|
|
105
|
+
if (typeof body.version === "string" && body.version.length > 0) {
|
|
106
|
+
return { status: "ok", latest: body.version };
|
|
107
|
+
}
|
|
108
|
+
return { status: "unreachable" };
|
|
109
|
+
} catch {
|
|
110
|
+
return { status: "unreachable" };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Numeric version compare, prerelease suffix ignored. */
|
|
115
|
+
function compareVersions(a: string, b: string): number {
|
|
116
|
+
const parts = (value: string): number[] =>
|
|
117
|
+
value.split("-")[0].split(".").map((piece) => Number.parseInt(piece, 10) || 0);
|
|
118
|
+
const left = parts(a);
|
|
119
|
+
const right = parts(b);
|
|
120
|
+
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
121
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
122
|
+
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
123
|
+
}
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
60
127
|
export async function runUpdate(ctx: CommandContext): Promise<void> {
|
|
61
|
-
const
|
|
128
|
+
const { name, version } = readPackageIdentity(ctx.pkgRoot);
|
|
129
|
+
const lookup = await fetchLatest(name);
|
|
130
|
+
const label = name === BIN_NAME ? `${BIN_NAME} ${version}` : `${BIN_NAME} ${version} (${name})`;
|
|
131
|
+
const lines = ["", ` Installed: ${label}`];
|
|
132
|
+
|
|
133
|
+
if (lookup.status === "ok") {
|
|
134
|
+
lines.push(` Latest: ${lookup.latest}`);
|
|
135
|
+
} else if (lookup.status === "missing") {
|
|
136
|
+
lines.push(` Latest: unavailable — the registry has no published ${name}`);
|
|
137
|
+
} else {
|
|
138
|
+
lines.push(" Latest: unknown — could not reach the npm registry");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (lookup.status === "missing") {
|
|
142
|
+
lines.push(
|
|
143
|
+
"",
|
|
144
|
+
" Nothing can be installed under that name. If the package was renamed",
|
|
145
|
+
" or unpublished, check the current one:",
|
|
146
|
+
" https://www.npmjs.com/package/@gitpagedocs/cli",
|
|
147
|
+
);
|
|
148
|
+
} else if (lookup.status === "ok" && compareVersions(version, lookup.latest) >= 0) {
|
|
149
|
+
lines.push("", " Already on the latest published version.");
|
|
150
|
+
} else {
|
|
151
|
+
const target = lookup.status === "ok" ? lookup.latest : "latest";
|
|
152
|
+
lines.push(
|
|
153
|
+
"",
|
|
154
|
+
" To update the global CLI:",
|
|
155
|
+
` npm i -g ${name}@${target}`,
|
|
156
|
+
" Or, in a project:",
|
|
157
|
+
` pnpm add -D ${name}@${target}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
lines.push("");
|
|
62
162
|
// eslint-disable-next-line no-console
|
|
63
|
-
console.log(
|
|
64
|
-
`\n Installed: gitpagedocs ${current}\n` +
|
|
65
|
-
" To update the global CLI:\n" +
|
|
66
|
-
" npm i -g gitpagedocs@latest\n" +
|
|
67
|
-
" Or, in a project:\n" +
|
|
68
|
-
" pnpm add -D gitpagedocs@latest\n",
|
|
69
|
-
);
|
|
163
|
+
console.log(lines.join("\n"));
|
|
70
164
|
}
|
package/presentation/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { printBanner, printCredits } from "./ui/banner";
|
|
6
6
|
import { resolveOptions } from "./options/resolver";
|
|
7
|
+
import { migrateLegacyLayoutsInteractive } from "./ui/layouts-prompts";
|
|
7
8
|
import { runNewCommand } from "./commands/run-command";
|
|
8
9
|
import { dispatchMode } from "../application/use-cases/dispatch-mode";
|
|
9
10
|
import type {
|
|
@@ -57,6 +58,13 @@ async function main(): Promise<void> {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
const options = await resolveOptions(process.argv, process.env);
|
|
61
|
+
|
|
62
|
+
// Layouts moved out of <outputDir>/layouts/. Offer to relocate anything left
|
|
63
|
+
// behind before generating, so the config written next points at one folder.
|
|
64
|
+
if (options.mode === "config-only") {
|
|
65
|
+
await migrateLegacyLayoutsInteractive(root, options);
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
const configOnlyRuntime = createConfigOnlyRuntime() as ConfigOnlyRuntimePort;
|
|
61
69
|
const homeRuntime = createHomeRuntime() as HomeRuntimePort;
|
|
62
70
|
await dispatchMode(options, params, {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CliOptions } from "../../domain/models/cli-options";
|
|
2
2
|
import { DEFAULTS } from "./schema";
|
|
3
|
+
import { normalizeLayoutsDir } from "../../contracts/layouts-paths.mjs";
|
|
3
4
|
|
|
4
5
|
const KNOWN_FLAGS = new Set([
|
|
5
6
|
"--build",
|
|
@@ -11,8 +12,12 @@ const KNOWN_FLAGS = new Set([
|
|
|
11
12
|
"--search",
|
|
12
13
|
"--path",
|
|
13
14
|
"--output",
|
|
15
|
+
"--layouts-dir",
|
|
14
16
|
"--interactive",
|
|
15
17
|
"-i",
|
|
18
|
+
"--no-interactive",
|
|
19
|
+
"--yes",
|
|
20
|
+
"-y",
|
|
16
21
|
"--ai",
|
|
17
22
|
"--pages-actions",
|
|
18
23
|
]);
|
|
@@ -35,6 +40,7 @@ export function parseCliOptions(argv: string[], env: NodeJS.ProcessEnv): CliOpti
|
|
|
35
40
|
let githubRepo = readOptionValue(args, "--repo");
|
|
36
41
|
const docsPath = readOptionValue(args, "--path");
|
|
37
42
|
const outputDir = readOptionValue(args, "--output");
|
|
43
|
+
const layoutsDir = readOptionValue(args, "--layouts-dir");
|
|
38
44
|
const searchRaw = readOptionValue(args, "--search");
|
|
39
45
|
|
|
40
46
|
const fallbackDashedArgs = args
|
|
@@ -45,6 +51,7 @@ export function parseCliOptions(argv: string[], env: NodeJS.ProcessEnv): CliOpti
|
|
|
45
51
|
.filter((a) => !a.startsWith("--path"))
|
|
46
52
|
.filter((a) => !a.startsWith("--home"))
|
|
47
53
|
.filter((a) => !a.startsWith("--output"))
|
|
54
|
+
.filter((a) => !a.startsWith("--layouts-dir"))
|
|
48
55
|
.filter((a) => !a.startsWith("--search"))
|
|
49
56
|
.map((a) => a.replace(/^--/, "").trim())
|
|
50
57
|
.filter(Boolean);
|
|
@@ -91,6 +98,7 @@ export function parseCliOptions(argv: string[], env: NodeJS.ProcessEnv): CliOpti
|
|
|
91
98
|
mode,
|
|
92
99
|
aiCommand,
|
|
93
100
|
outputDir: outputDir || (isHome ? DEFAULTS.home.outputDir : DEFAULTS.outputDir),
|
|
101
|
+
layoutsDir: normalizeLayoutsDir(layoutsDir || DEFAULTS.layoutsDir),
|
|
94
102
|
useLocalLayoutConfig,
|
|
95
103
|
shouldPush,
|
|
96
104
|
githubOwner,
|
|
@@ -100,5 +108,12 @@ export function parseCliOptions(argv: string[], env: NodeJS.ProcessEnv): CliOpti
|
|
|
100
108
|
repositorySearch,
|
|
101
109
|
isInteractive,
|
|
102
110
|
hasArgs: args.length > 0,
|
|
111
|
+
explicit: {
|
|
112
|
+
useLocalLayoutConfig,
|
|
113
|
+
layoutsDir: Boolean(layoutsDir),
|
|
114
|
+
githubOwner: Boolean(githubOwner),
|
|
115
|
+
githubRepo: Boolean(githubRepo),
|
|
116
|
+
outputDir: Boolean(outputDir),
|
|
117
|
+
},
|
|
103
118
|
};
|
|
104
119
|
}
|
|
@@ -34,15 +34,13 @@ export async function resolveOptions(argv: string[], env: NodeJS.ProcessEnv): Pr
|
|
|
34
34
|
return resolved;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
41
|
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
return promptConfigOnlyOptions(parsed);
|
|
45
|
-
}
|
|
42
|
+
if (parsed.mode === "home") return promptHomeOptions(parsed);
|
|
43
|
+
if (parsed.mode === "config-only") return promptConfigOnlyOptions(parsed);
|
|
46
44
|
|
|
47
45
|
return parsed;
|
|
48
46
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { DEFAULT_LAYOUTS_DIR } from "../../contracts/layouts-paths.mjs";
|
|
2
|
+
|
|
1
3
|
export const DEFAULTS = {
|
|
2
4
|
outputDir: "gitpagedocs",
|
|
5
|
+
/** Standalone layouts home; local layouts are generated here, never in outputDir. */
|
|
6
|
+
layoutsDir: DEFAULT_LAYOUTS_DIR as string,
|
|
3
7
|
repositorySearch: false,
|
|
4
8
|
basePath: "",
|
|
5
9
|
home: {
|
|
@@ -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
|
+
}
|
|
@@ -4,26 +4,19 @@ import { execSync } from "node:child_process";
|
|
|
4
4
|
import type { CliOptions } from "../../domain/models/cli-options";
|
|
5
5
|
import { DEFAULTS } from "../options/schema";
|
|
6
6
|
import { askText, askConfirm, note } from "./clack";
|
|
7
|
+
import { interactivePromptsAvailable, promptingEnabled } from "./tty";
|
|
8
|
+
import { askLayoutsSource, askLayoutsDir } from "./layouts-prompts";
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
if (process.env.CI === "true") return true;
|
|
10
|
-
if (process.env.GITHUB_ACTIONS === "true") return true;
|
|
11
|
-
if (process.stdin && !process.stdin.isTTY) return true;
|
|
12
|
-
return false;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** True when clack prompts can run (interactive TTY, not CI). */
|
|
16
|
-
export function interactivePromptsAvailable(): boolean {
|
|
17
|
-
return !isCiOrNonTty();
|
|
18
|
-
}
|
|
10
|
+
export { interactivePromptsAvailable };
|
|
19
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Every command prompts when it runs in a real terminal, so a bare invocation
|
|
14
|
+
* reflects what the user actually needs instead of silently taking defaults.
|
|
15
|
+
* CI, a piped stdin, and an explicit `--no-interactive` / `--yes` / `-y` opt out;
|
|
16
|
+
* `--interactive` / `-i` remains accepted and reads the same either way.
|
|
17
|
+
*/
|
|
20
18
|
export function shouldRunInteractive(argv: string[]): boolean {
|
|
21
|
-
|
|
22
|
-
const args = argv.slice(2);
|
|
23
|
-
if (args.includes("--interactive") || args.includes("-i")) return true;
|
|
24
|
-
if (args.length === 0) return true;
|
|
25
|
-
if (args.length === 1 && args[0] === "--home") return true;
|
|
26
|
-
return false;
|
|
19
|
+
return promptingEnabled(argv.slice(2));
|
|
27
20
|
}
|
|
28
21
|
|
|
29
22
|
export async function promptHomeOptions(parsed: CliOptions): Promise<CliOptions> {
|
|
@@ -115,23 +108,38 @@ export async function ensureGitRepoInteractive(root: string): Promise<void> {
|
|
|
115
108
|
}
|
|
116
109
|
}
|
|
117
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Ask only for what the command line did not already answer, so an explicit
|
|
113
|
+
* flag is never second-guessed by a prompt.
|
|
114
|
+
*/
|
|
118
115
|
export async function promptConfigOnlyOptions(parsed: CliOptions): Promise<CliOptions> {
|
|
119
|
-
const useLocalLayoutConfig =
|
|
120
|
-
|
|
121
|
-
parsed.useLocalLayoutConfig ?? false
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
116
|
+
const useLocalLayoutConfig = parsed.explicit.useLocalLayoutConfig
|
|
117
|
+
? parsed.useLocalLayoutConfig
|
|
118
|
+
: await askLayoutsSource(parsed.useLocalLayoutConfig ?? false);
|
|
119
|
+
|
|
120
|
+
const layoutsDir =
|
|
121
|
+
useLocalLayoutConfig && !parsed.explicit.layoutsDir
|
|
122
|
+
? await askLayoutsDir(parsed.layoutsDir)
|
|
123
|
+
: parsed.layoutsDir;
|
|
124
|
+
|
|
125
|
+
const githubOwner = parsed.explicit.githubOwner
|
|
126
|
+
? parsed.githubOwner
|
|
127
|
+
: await askText({
|
|
128
|
+
message: "GitHub owner (optional):",
|
|
129
|
+
defaultValue: parsed.githubOwner ?? "",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const githubRepo = parsed.explicit.githubRepo
|
|
133
|
+
? parsed.githubRepo
|
|
134
|
+
: await askText({
|
|
135
|
+
message: "GitHub repo (optional):",
|
|
136
|
+
defaultValue: parsed.githubRepo ?? "",
|
|
137
|
+
});
|
|
131
138
|
|
|
132
139
|
return {
|
|
133
140
|
...parsed,
|
|
134
141
|
useLocalLayoutConfig,
|
|
142
|
+
layoutsDir,
|
|
135
143
|
githubOwner: githubOwner?.trim() ?? "",
|
|
136
144
|
githubRepo: githubRepo?.trim() ?? "",
|
|
137
145
|
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface LayoutsMigrationResult {
|
|
2
|
+
moved: string[];
|
|
3
|
+
failed: Array<{ file: string; reason: string }>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export declare function listFilesRecursively(absoluteDir: string): string[];
|
|
7
|
+
|
|
8
|
+
export declare function executeLayoutsMigration(
|
|
9
|
+
root: string,
|
|
10
|
+
plan: { from: string; to: string; files: string[] },
|
|
11
|
+
): LayoutsMigrationResult;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem side of the legacy layouts migration.
|
|
3
|
+
*
|
|
4
|
+
* The decision of *what* moves lives in the domain planner; this module only
|
|
5
|
+
* reads directories and performs the move, so both halves stay testable on
|
|
6
|
+
* their own.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
statSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
copyFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* List every file under a directory, as POSIX paths relative to it.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} absoluteDir
|
|
23
|
+
* @returns {string[]} Empty when the directory is absent or unreadable.
|
|
24
|
+
*/
|
|
25
|
+
export function listFilesRecursively(absoluteDir) {
|
|
26
|
+
if (!absoluteDir || !existsSync(absoluteDir)) return [];
|
|
27
|
+
|
|
28
|
+
const found = [];
|
|
29
|
+
const walk = (currentDir, prefix) => {
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(currentDir);
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const absolute = path.join(currentDir, entry);
|
|
38
|
+
const relative = prefix ? `${prefix}/${entry}` : entry;
|
|
39
|
+
let isDirectory = false;
|
|
40
|
+
try {
|
|
41
|
+
isDirectory = statSync(absolute).isDirectory();
|
|
42
|
+
} catch {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (isDirectory) walk(absolute, relative);
|
|
46
|
+
else found.push(relative);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
walk(absoluteDir, "");
|
|
51
|
+
return found.sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Move one file, falling back to copy + delete when rename is not possible
|
|
56
|
+
* (different volumes, or a locked handle on Windows).
|
|
57
|
+
*
|
|
58
|
+
* @param {string} fromAbsolute
|
|
59
|
+
* @param {string} toAbsolute
|
|
60
|
+
*/
|
|
61
|
+
function moveFile(fromAbsolute, toAbsolute) {
|
|
62
|
+
mkdirSync(path.dirname(toAbsolute), { recursive: true });
|
|
63
|
+
try {
|
|
64
|
+
renameSync(fromAbsolute, toAbsolute);
|
|
65
|
+
} catch {
|
|
66
|
+
copyFileSync(fromAbsolute, toAbsolute);
|
|
67
|
+
rmSync(fromAbsolute, { force: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Execute a migration plan produced by `planLayoutsMigration`.
|
|
73
|
+
*
|
|
74
|
+
* Files are moved one by one so a single failure is reported without aborting
|
|
75
|
+
* the rest; the legacy folder is removed only once it is genuinely empty.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} root Project root the plan's paths are relative to.
|
|
78
|
+
* @param {{ from: string, to: string, files: string[] }} plan
|
|
79
|
+
* @returns {{ moved: string[], failed: Array<{ file: string, reason: string }> }}
|
|
80
|
+
*/
|
|
81
|
+
export function executeLayoutsMigration(root, plan) {
|
|
82
|
+
const moved = [];
|
|
83
|
+
const failed = [];
|
|
84
|
+
|
|
85
|
+
const fromRoot = path.join(root, ...plan.from.split("/"));
|
|
86
|
+
const toRoot = path.join(root, ...plan.to.split("/"));
|
|
87
|
+
|
|
88
|
+
for (const file of plan.files) {
|
|
89
|
+
const segments = file.split("/");
|
|
90
|
+
try {
|
|
91
|
+
moveFile(path.join(fromRoot, ...segments), path.join(toRoot, ...segments));
|
|
92
|
+
moved.push(file);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
failed.push({ file, reason: error instanceof Error ? error.message : String(error) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (failed.length === 0 && listFilesRecursively(fromRoot).length === 0) {
|
|
99
|
+
rmSync(fromRoot, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { moved, failed };
|
|
103
|
+
}
|