@reckona/create-mreact-app 0.0.93 → 0.0.95

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/src/prompts.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { createInterface, emitKeypressEvents, type Key } from "node:readline";
2
+
3
+ /**
4
+ * Minimal, dependency-free interactive prompts built on Node's `node:readline`.
5
+ *
6
+ * All functions accept `input`/`output` streams so they can be driven by
7
+ * in-memory streams (e.g. `PassThrough`) in tests without mocking.
8
+ */
9
+
10
+ interface PromptStreamControls {
11
+ isTTY?: boolean;
12
+ pause?: () => void;
13
+ resume?: () => void;
14
+ setRawMode?: (mode: boolean) => void;
15
+ }
16
+
17
+ type PromptReadable = NodeJS.ReadableStream & PromptStreamControls;
18
+ type PromptWritable = NodeJS.WritableStream;
19
+
20
+ export interface TextOptions {
21
+ defaultValue: string;
22
+ input?: PromptReadable | undefined;
23
+ message: string;
24
+ output?: PromptWritable | undefined;
25
+ }
26
+
27
+ export interface SelectChoice<T> {
28
+ hint?: string | undefined;
29
+ label: string;
30
+ value: T;
31
+ }
32
+
33
+ export interface SelectOptions<T> {
34
+ choices: ReadonlyArray<SelectChoice<T>>;
35
+ initialIndex?: number | undefined;
36
+ input?: PromptReadable | undefined;
37
+ message: string;
38
+ output?: PromptWritable | undefined;
39
+ }
40
+
41
+ /** Create the error used to signal that the user aborted a prompt (Ctrl+C). */
42
+ export function promptCancelledError(): Error {
43
+ const error = new Error("Prompt cancelled.");
44
+ (error as Error & { code?: string }).code = "PROMPT_CANCELLED";
45
+ return error;
46
+ }
47
+
48
+ /** Whether an unknown error originates from a cancelled prompt. */
49
+ export function isPromptCancelled(error: unknown): boolean {
50
+ return error instanceof Error && (error as { code?: string }).code === "PROMPT_CANCELLED";
51
+ }
52
+
53
+ function clampIndex(index: number, length: number): number {
54
+ if (length === 0) {
55
+ return 0;
56
+ }
57
+ if (index < 0) {
58
+ return 0;
59
+ }
60
+ if (index > length - 1) {
61
+ return length - 1;
62
+ }
63
+ return index;
64
+ }
65
+
66
+ /** Free-form line input; an empty answer falls back to `defaultValue`. */
67
+ export function text(options: TextOptions): Promise<string> {
68
+ const input = (options.input ?? process.stdin) as PromptReadable;
69
+ const output = options.output ?? process.stdout;
70
+ const prompt = `${options.message} (${options.defaultValue}): `;
71
+
72
+ return new Promise<string>((resolve, reject) => {
73
+ const rl = createInterface({ input, output });
74
+
75
+ rl.question(prompt, (answer) => {
76
+ rl.close();
77
+ const trimmed = answer.trim();
78
+ resolve(trimmed === "" ? options.defaultValue : trimmed);
79
+ });
80
+
81
+ rl.on("SIGINT", () => {
82
+ rl.close();
83
+ reject(promptCancelledError());
84
+ });
85
+ });
86
+ }
87
+
88
+ const CURSOR_POINTER = ">";
89
+ const CLEAR_LINE = "";
90
+ const HIDE_CURSOR = "[?25l";
91
+ const SHOW_CURSOR = "[?25h";
92
+
93
+ function supportsColor(output: PromptWritable): boolean {
94
+ return (output as { isTTY?: boolean }).isTTY === true && process.env.NO_COLOR === undefined;
95
+ }
96
+
97
+ /** Single-choice selection navigated with the arrow keys. */
98
+ export function select<T>(options: SelectOptions<T>): Promise<T> {
99
+ const input = (options.input ?? process.stdin) as PromptReadable;
100
+ const output = options.output ?? process.stdout;
101
+ const choices = options.choices;
102
+ const color = supportsColor(output);
103
+ const cyan = (value: string): string => (color ? `${value}` : value);
104
+ const dim = (value: string): string => (color ? `${value}` : value);
105
+ let index = clampIndex(options.initialIndex ?? 0, choices.length);
106
+
107
+ return new Promise<T>((resolve, reject) => {
108
+ emitKeypressEvents(input);
109
+ const isRawCapable = input.isTTY === true;
110
+ if (isRawCapable) {
111
+ input.setRawMode?.(true);
112
+ }
113
+ input.resume?.();
114
+
115
+ const renderChoiceLines = (): string =>
116
+ choices
117
+ .map((choice, choiceIndex) => {
118
+ const active = choiceIndex === index;
119
+ const prefix = active ? `${CURSOR_POINTER} ` : " ";
120
+ const label = active ? cyan(choice.label) : choice.label;
121
+ const hint = choice.hint === undefined ? "" : ` ${dim(`(${choice.hint})`)}`;
122
+ return `${CLEAR_LINE}${prefix}${label}${hint}`;
123
+ })
124
+ .join("\n");
125
+
126
+ let rendered = false;
127
+ const render = (): void => {
128
+ if (rendered) {
129
+ // Move the cursor back to the first choice line before redrawing.
130
+ output.write(`[${choices.length}A`);
131
+ }
132
+ output.write(`${renderChoiceLines()}\n`);
133
+ rendered = true;
134
+ };
135
+
136
+ const cleanup = (): void => {
137
+ input.removeListener("keypress", onKeypress);
138
+ if (isRawCapable) {
139
+ input.setRawMode?.(false);
140
+ }
141
+ if (color) {
142
+ output.write(SHOW_CURSOR);
143
+ }
144
+ input.pause?.();
145
+ };
146
+
147
+ function onKeypress(_value: string | undefined, key: Key | undefined): void {
148
+ if (key === undefined) {
149
+ return;
150
+ }
151
+
152
+ if (key.ctrl === true && key.name === "c") {
153
+ cleanup();
154
+ reject(promptCancelledError());
155
+ return;
156
+ }
157
+
158
+ if (key.name === "up") {
159
+ index = (index - 1 + choices.length) % choices.length;
160
+ render();
161
+ return;
162
+ }
163
+
164
+ if (key.name === "down") {
165
+ index = (index + 1) % choices.length;
166
+ render();
167
+ return;
168
+ }
169
+
170
+ if (key.name === "return" || key.name === "enter") {
171
+ cleanup();
172
+ const choice = choices[index];
173
+ if (choice === undefined) {
174
+ reject(new Error("No choice available to select."));
175
+ return;
176
+ }
177
+ resolve(choice.value);
178
+ }
179
+ }
180
+
181
+ input.on("keypress", onKeypress);
182
+ output.write(`${options.message}\n`);
183
+ if (color) {
184
+ output.write(HIDE_CURSOR);
185
+ }
186
+ render();
187
+ });
188
+ }
@@ -0,0 +1,129 @@
1
+ import type { CreateMreactAppCreateCliOptions } from "./cli-args.js";
2
+ import type {
3
+ CreateMreactAppDeployTarget,
4
+ CreateMreactAppPackageManager,
5
+ CreateMreactAppTemplate,
6
+ } from "./index.js";
7
+ import { createMreactAppDeployTargets, createMreactAppTemplates } from "./index.js";
8
+ import { select, text } from "./prompts.js";
9
+
10
+ interface PromptStreamControls {
11
+ isTTY?: boolean;
12
+ pause?: () => void;
13
+ resume?: () => void;
14
+ setRawMode?: (mode: boolean) => void;
15
+ }
16
+
17
+ type PromptReadable = NodeJS.ReadableStream & PromptStreamControls;
18
+ type PromptWritable = NodeJS.WritableStream;
19
+
20
+ export interface ResolveCreateOptionsContext {
21
+ env?: NodeJS.ProcessEnv | undefined;
22
+ input?: PromptReadable | undefined;
23
+ isTTY?: boolean | undefined;
24
+ output?: PromptWritable | undefined;
25
+ }
26
+
27
+ export interface ResolvedCreateOptions {
28
+ deploy?: CreateMreactAppDeployTarget | undefined;
29
+ directory: string;
30
+ packageManager: CreateMreactAppPackageManager;
31
+ srcDir: boolean;
32
+ template: CreateMreactAppTemplate;
33
+ }
34
+
35
+ const DEFAULT_DIRECTORY = "mreact-app";
36
+ const DEFAULT_TEMPLATE: CreateMreactAppTemplate = "basic";
37
+ const DEFAULT_PACKAGE_MANAGER: CreateMreactAppPackageManager = "pnpm";
38
+
39
+ const PACKAGE_MANAGERS: readonly CreateMreactAppPackageManager[] = ["pnpm", "npm", "bun"];
40
+
41
+ /** Identify the package manager that invoked us via `npm_config_user_agent`. */
42
+ export function detectPackageManager(
43
+ env: NodeJS.ProcessEnv,
44
+ ): CreateMreactAppPackageManager | undefined {
45
+ const userAgent = env.npm_config_user_agent;
46
+ if (userAgent === undefined) {
47
+ return undefined;
48
+ }
49
+
50
+ const name = userAgent.split("/")[0];
51
+ if (name === "pnpm" || name === "npm" || name === "bun") {
52
+ return name;
53
+ }
54
+
55
+ return undefined;
56
+ }
57
+
58
+ /**
59
+ * Resolve a complete set of scaffolding options from parsed CLI flags.
60
+ *
61
+ * Non-interactive (non-TTY): every unprovided option falls back to its
62
+ * default. Interactive (TTY): only the unprovided options are prompted for.
63
+ */
64
+ export async function resolveCreateOptions(
65
+ parsed: CreateMreactAppCreateCliOptions,
66
+ context: ResolveCreateOptionsContext,
67
+ ): Promise<ResolvedCreateOptions> {
68
+ const env = context.env ?? process.env;
69
+ const detectedPackageManager = detectPackageManager(env);
70
+ const packageManagerDefault = detectedPackageManager ?? DEFAULT_PACKAGE_MANAGER;
71
+
72
+ if (context.isTTY !== true) {
73
+ return {
74
+ deploy: parsed.deploy,
75
+ directory: parsed.directory ?? DEFAULT_DIRECTORY,
76
+ packageManager: parsed.packageManager ?? packageManagerDefault,
77
+ srcDir: parsed.srcDir ?? false,
78
+ template: parsed.template ?? DEFAULT_TEMPLATE,
79
+ };
80
+ }
81
+
82
+ const streams = { input: context.input, output: context.output };
83
+
84
+ const directory =
85
+ parsed.directory ??
86
+ (await text({ defaultValue: DEFAULT_DIRECTORY, message: "Project directory", ...streams }));
87
+
88
+ const template =
89
+ parsed.template ??
90
+ (await select<CreateMreactAppTemplate>({
91
+ choices: createMreactAppTemplates.map((value) => ({ label: value, value })),
92
+ initialIndex: createMreactAppTemplates.indexOf(DEFAULT_TEMPLATE),
93
+ message: "Template",
94
+ ...streams,
95
+ }));
96
+
97
+ const packageManager =
98
+ parsed.packageManager ??
99
+ (await select<CreateMreactAppPackageManager>({
100
+ choices: PACKAGE_MANAGERS.map((value) => ({ label: value, value })),
101
+ initialIndex: PACKAGE_MANAGERS.indexOf(packageManagerDefault),
102
+ message: "Package manager",
103
+ ...streams,
104
+ }));
105
+
106
+ const srcDir =
107
+ parsed.srcDir ??
108
+ (await select<boolean>({
109
+ choices: [
110
+ { label: "No", value: false },
111
+ { label: "Yes", value: true },
112
+ ],
113
+ message: "Place routes under src/app?",
114
+ ...streams,
115
+ }));
116
+
117
+ const deploy =
118
+ parsed.deploy ??
119
+ (await select<CreateMreactAppDeployTarget | undefined>({
120
+ choices: [
121
+ { label: "none", value: undefined },
122
+ ...createMreactAppDeployTargets.map((value) => ({ label: value, value })),
123
+ ],
124
+ message: "Deploy target",
125
+ ...streams,
126
+ }));
127
+
128
+ return { deploy, directory, packageManager, srcDir, template };
129
+ }
package/src/run-cli.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { resolve } from "node:path";
2
+ import {
3
+ createMreactAppHelpText,
4
+ createMreactAppSuccessText,
5
+ parseCreateMreactAppCliArgs,
6
+ } from "./cli-args.js";
7
+ import { createMreactApp, upgradeMreactApp } from "./index.js";
8
+ import { isPromptCancelled } from "./prompts.js";
9
+ import { resolveCreateOptions } from "./resolve-create-options.js";
10
+
11
+ interface PromptStreamControls {
12
+ isTTY?: boolean;
13
+ pause?: () => void;
14
+ resume?: () => void;
15
+ setRawMode?: (mode: boolean) => void;
16
+ }
17
+
18
+ export interface RunCliContext {
19
+ /** Environment used for package-manager detection. Defaults to `process.env`. */
20
+ env?: NodeJS.ProcessEnv | undefined;
21
+ /** Stream the interactive prompts read from. Defaults to `process.stdin`. */
22
+ input?: (NodeJS.ReadableStream & PromptStreamControls) | undefined;
23
+ /** Whether to run the interactive wizard for unprovided options. */
24
+ isTTY?: boolean | undefined;
25
+ /** Stream the interactive prompts render to. Defaults to `process.stdout`. */
26
+ output?: NodeJS.WritableStream | undefined;
27
+ /** Sink for error messages. Defaults to `console.error`. */
28
+ stderr?: ((line: string) => void) | undefined;
29
+ /** Sink for normal output. Defaults to `console.log`. */
30
+ stdout?: ((line: string) => void) | undefined;
31
+ }
32
+
33
+ const EXIT_OK = 0;
34
+ const EXIT_ERROR = 1;
35
+ const EXIT_CANCELLED = 130;
36
+
37
+ /**
38
+ * Run the create-mreact-app CLI and return a process exit code.
39
+ *
40
+ * Pulled out of the bin shim so the full flow (parse -> resolve -> scaffold)
41
+ * is testable with in-memory streams and without touching `process`.
42
+ */
43
+ export async function runCreateMreactAppCli(
44
+ argv: readonly string[],
45
+ context: RunCliContext = {},
46
+ ): Promise<number> {
47
+ const stdout = context.stdout ?? ((line: string) => console.log(line));
48
+ const stderr = context.stderr ?? ((line: string) => console.error(line));
49
+
50
+ try {
51
+ const options = parseCreateMreactAppCliArgs(argv);
52
+
53
+ if (options.help === true) {
54
+ stdout(createMreactAppHelpText());
55
+ return EXIT_OK;
56
+ }
57
+
58
+ if (options.command === "upgrade") {
59
+ const result = await upgradeMreactApp({
60
+ directory: resolve(options.directory ?? "."),
61
+ dryRun: options.dryRun,
62
+ fromVersion: options.fromVersion,
63
+ targetVersion: options.targetVersion,
64
+ });
65
+
66
+ stdout(
67
+ result.changed
68
+ ? `Updated ${result.updatedDependencies.length} mreact dependency range(s).`
69
+ : "No mreact dependency ranges needed updating.",
70
+ );
71
+ if (result.codemods.length > 0) {
72
+ stdout(`Codemods: ${result.codemods.map((codemod) => codemod.id).join(", ")}`);
73
+ }
74
+ return EXIT_OK;
75
+ }
76
+
77
+ const resolved = await resolveCreateOptions(options, {
78
+ env: context.env ?? process.env,
79
+ input: context.input,
80
+ isTTY: context.isTTY,
81
+ output: context.output,
82
+ });
83
+
84
+ const result = await createMreactApp({
85
+ deploy: resolved.deploy,
86
+ directory: resolve(resolved.directory),
87
+ // Leave `name` unset so createMreactApp derives a workspace-aware package
88
+ // name from the target directory (e.g. @reckona/example-<name> under examples/*).
89
+ packageManager: resolved.packageManager,
90
+ srcDir: resolved.srcDir,
91
+ template: resolved.template,
92
+ });
93
+
94
+ stdout(
95
+ createMreactAppSuccessText({
96
+ directory: result.directory,
97
+ displayDirectory: resolved.directory,
98
+ packageManager: result.packageManager,
99
+ template: result.template,
100
+ }),
101
+ );
102
+ return EXIT_OK;
103
+ } catch (error) {
104
+ if (isPromptCancelled(error)) {
105
+ stderr("Aborted.");
106
+ return EXIT_CANCELLED;
107
+ }
108
+ stderr(error instanceof Error ? error.message : String(error));
109
+ return EXIT_ERROR;
110
+ }
111
+ }