@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.
@@ -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
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitpagedocs/cli",
3
- "version": "1.1.60",
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.60",
33
- "@gitpagedocs/tools": "^1.1.60",
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 current = readVersion(ctx.pkgRoot);
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
  }
@@ -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
- const runHomeInteractive = parsed.isInteractive || (shouldRunInteractive(argv) && parsed.mode === "home");
38
- if (runHomeInteractive && parsed.mode === "home") {
39
- return promptHomeOptions(parsed);
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
- const runConfigInteractive = parsed.isInteractive || (shouldRunInteractive(argv) && parsed.mode === "config-only");
43
- if (runConfigInteractive && parsed.mode === "config-only") {
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
- function isCiOrNonTty(): boolean {
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
- if (isCiOrNonTty()) return false;
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 = await askConfirm(
120
- "Generate local layout templates?",
121
- parsed.useLocalLayoutConfig ?? false,
122
- );
123
- const githubOwner = await askText({
124
- message: "GitHub owner (optional):",
125
- defaultValue: parsed.githubOwner ?? "",
126
- });
127
- const githubRepo = await askText({
128
- message: "GitHub repo (optional):",
129
- defaultValue: parsed.githubRepo ?? "",
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
+ }