create-middag-ui 0.1.4 → 0.2.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/lib/auth.js ADDED
@@ -0,0 +1,210 @@
1
+ /* global console, process */
2
+ /**
3
+ * auth.js — GitHub token flow (ask, validate, save to ~/.npmrc).
4
+ *
5
+ * Token is stored in ~/.npmrc (global), never in project-local .npmrc.
6
+ * Two lines are written:
7
+ * @middag-io:registry=https://npm.pkg.github.com
8
+ * //npm.pkg.github.com/:_authToken=TOKEN
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { spawnSync } from "node:child_process";
15
+ import { askSecret, confirm } from "./prompts.js";
16
+ import { success, warn, error, info, createSpinner } from "./ui.js";
17
+
18
+ const REGISTRY_URL = "https://npm.pkg.github.com";
19
+ const SCOPE_LINE = `@middag-io:registry=${REGISTRY_URL}`;
20
+ const TOKEN_PREFIX = `//npm.pkg.github.com/:_authToken=`;
21
+
22
+ /**
23
+ * Check if ~/.npmrc already has a valid GitHub Packages token for @middag-io.
24
+ *
25
+ * @returns {{ hasScope: boolean, hasToken: boolean }}
26
+ */
27
+ export function checkExistingAuth() {
28
+ const npmrcPath = join(homedir(), ".npmrc");
29
+ if (!existsSync(npmrcPath)) {
30
+ return { hasScope: false, hasToken: false };
31
+ }
32
+
33
+ try {
34
+ const content = readFileSync(npmrcPath, "utf-8");
35
+ const hasScope = content.includes(SCOPE_LINE);
36
+ const hasToken = content.includes(TOKEN_PREFIX);
37
+ return { hasScope, hasToken };
38
+ } catch {
39
+ return { hasScope: false, hasToken: false };
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Validate a GitHub token by running `npm whoami` against GitHub Packages.
45
+ *
46
+ * @param {string} token - GitHub personal access token
47
+ * @returns {{ valid: boolean, username: string|null }}
48
+ */
49
+ export function validateToken(token) {
50
+ try {
51
+ const result = spawnSync(
52
+ "npm",
53
+ ["whoami", `--registry=${REGISTRY_URL}`],
54
+ {
55
+ env: {
56
+ ...process.env,
57
+ // Temporarily set the token for this check
58
+ npm_config__npm_pkg_github_com__authToken: token,
59
+ },
60
+ timeout: 15000,
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ encoding: "utf-8",
63
+ },
64
+ );
65
+
66
+ if (result.status === 0 && result.stdout) {
67
+ return { valid: true, username: result.stdout.trim() };
68
+ }
69
+ return { valid: false, username: null };
70
+ } catch {
71
+ return { valid: false, username: null };
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Save scope + token to ~/.npmrc (global).
77
+ * Appends only the lines that are missing.
78
+ *
79
+ * @param {string} token - GitHub personal access token
80
+ */
81
+ export function saveTokenToGlobalNpmrc(token) {
82
+ const npmrcPath = join(homedir(), ".npmrc");
83
+ let content = "";
84
+
85
+ if (existsSync(npmrcPath)) {
86
+ try {
87
+ content = readFileSync(npmrcPath, "utf-8");
88
+ } catch (err) {
89
+ throw new Error(`Cannot read ~/.npmrc: ${err.message}`, { cause: err });
90
+ }
91
+ }
92
+
93
+ // If an existing token line exists, replace it in-place
94
+ if (content.includes(TOKEN_PREFIX)) {
95
+ const updated = content.replace(
96
+ new RegExp(`${TOKEN_PREFIX.replace(/[/]/g, "\\/")}.*`, "g"),
97
+ `${TOKEN_PREFIX}${token}`,
98
+ );
99
+ try {
100
+ writeFileSync(npmrcPath, updated);
101
+ } catch (err) {
102
+ throw new Error(`Cannot write ~/.npmrc: ${err.message}`, { cause: err });
103
+ }
104
+ // Still need to add scope if missing
105
+ if (!content.includes(SCOPE_LINE)) {
106
+ try {
107
+ appendFileSync(npmrcPath, `\n${SCOPE_LINE}\n`);
108
+ } catch (err) {
109
+ throw new Error(`Cannot append to ~/.npmrc: ${err.message}`, { cause: err });
110
+ }
111
+ }
112
+ return;
113
+ }
114
+
115
+ // No existing token — append new lines
116
+ const lines = [];
117
+ if (!content.includes(SCOPE_LINE)) {
118
+ lines.push(SCOPE_LINE);
119
+ }
120
+ lines.push(`${TOKEN_PREFIX}${token}`);
121
+
122
+ const prefix = content && !content.endsWith("\n") ? "\n" : "";
123
+ try {
124
+ appendFileSync(npmrcPath, `${prefix}${lines.join("\n")}\n`);
125
+ } catch (err) {
126
+ throw new Error(`Cannot write to ~/.npmrc: ${err.message}`, { cause: err });
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Interactive GitHub token flow.
132
+ *
133
+ * 1. Ask for token (masked input)
134
+ * 2. Validate with npm whoami
135
+ * 3. Save to ~/.npmrc if valid
136
+ * 4. If invalid, offer fallback to npm publico
137
+ *
138
+ * @returns {Promise<'github'|'public'>} Which registry path was chosen
139
+ */
140
+ export async function runTokenFlow() {
141
+ // Check if already configured
142
+ const existing = checkExistingAuth();
143
+ if (existing.hasScope && existing.hasToken) {
144
+ const spinner = createSpinner("Checking existing GitHub Packages auth...");
145
+ const result = spawnSync(
146
+ "npm",
147
+ ["whoami", `--registry=${REGISTRY_URL}`],
148
+ {
149
+ timeout: 15000,
150
+ stdio: ["ignore", "pipe", "pipe"],
151
+ encoding: "utf-8",
152
+ },
153
+ );
154
+ spinner.stop();
155
+
156
+ if (result.status === 0 && result.stdout) {
157
+ success(`Already authenticated as ${result.stdout.trim()} on GitHub Packages`);
158
+ return "github";
159
+ }
160
+ warn("Existing GitHub Packages token is invalid or expired");
161
+ }
162
+
163
+ console.log("");
164
+ info("Create a GitHub Personal Access Token at:");
165
+ info(" https://github.com/settings/tokens");
166
+ info("Required scope: read:packages");
167
+ console.log("");
168
+
169
+ const token = await askSecret(" GitHub token: ");
170
+
171
+ if (!token) {
172
+ warn("No token provided");
173
+ const useFallback = await confirm("Continue without token? (uses npm public registry)", true);
174
+ if (useFallback) {
175
+ return "public";
176
+ }
177
+ process.exit(1);
178
+ }
179
+
180
+ // Validate
181
+ const spinner = createSpinner("Validating token...");
182
+ const result = validateToken(token);
183
+ if (result.valid) {
184
+ spinner.stop(`Token valid \u2014 authenticated as ${result.username}`);
185
+ } else {
186
+ spinner.fail("Token invalid or missing read:packages scope");
187
+ console.log("");
188
+ info("Make sure your token has the read:packages scope.");
189
+ info("Create one at: https://github.com/settings/tokens");
190
+ console.log("");
191
+ const useFallback = await confirm("Continue without token? (uses npm public registry)", true);
192
+ if (useFallback) {
193
+ return "public";
194
+ }
195
+ process.exit(1);
196
+ }
197
+
198
+ // Save to ~/.npmrc
199
+ try {
200
+ saveTokenToGlobalNpmrc(token);
201
+ success("Saved registry + token to ~/.npmrc (global)");
202
+ } catch (err) {
203
+ error(`Failed to save token: ${err.message}`);
204
+ warn("You can add it manually to ~/.npmrc:");
205
+ info(` ${SCOPE_LINE}`);
206
+ info(` ${TOKEN_PREFIX}<your-token>`);
207
+ }
208
+
209
+ return "github";
210
+ }
package/lib/detect.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * detect.js — Host detection (Moodle / WordPress / Custom).
3
+ *
4
+ * Checks for marker files in the working directory.
5
+ */
6
+
7
+ import { existsSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ export const HOSTS = {
11
+ moodle: { name: "Moodle", detect: "version.php", port: 5174 },
12
+ wordpress: { name: "WordPress", detect: "wp-config.php", port: 5175 },
13
+ custom: { name: "Custom", detect: null, port: 5176 },
14
+ };
15
+
16
+ /**
17
+ * Detect host platform by checking for marker files.
18
+ *
19
+ * @param {string} cwd - Directory to check
20
+ * @returns {string|null} Host key ('moodle', 'wordpress') or null
21
+ */
22
+ export function detectHost(cwd) {
23
+ for (const [key, host] of Object.entries(HOSTS)) {
24
+ if (host.detect && existsSync(join(cwd, host.detect))) {
25
+ return key;
26
+ }
27
+ }
28
+ return null;
29
+ }
package/lib/install.js ADDED
@@ -0,0 +1,92 @@
1
+ /* global console, process */
2
+ /**
3
+ * install.js — npm install runner with spinner.
4
+ *
5
+ * Spawns `npm install` in the target directory, shows a spinner,
6
+ * and surfaces errors clearly if it fails.
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import { createSpinner, error, warn, info } from "./ui.js";
11
+
12
+ /**
13
+ * Run `npm install` in the target directory.
14
+ *
15
+ * Shows a spinner while running. On failure, prints the npm error
16
+ * output and suggests troubleshooting steps.
17
+ *
18
+ * @param {string} targetDir - Absolute path to the directory
19
+ * @param {'github'|'public'} registryPath - Which registry was chosen
20
+ * @returns {Promise<boolean>} true if install succeeded
21
+ */
22
+ export async function runNpmInstall(targetDir, registryPath) {
23
+ const registryLabel =
24
+ registryPath === "github" ? "GitHub Packages" : "npm public registry";
25
+
26
+ const spinner = createSpinner(
27
+ `Installing dependencies from ${registryLabel}...`,
28
+ );
29
+
30
+ return new Promise((resolve) => {
31
+ const child = spawn("npm", ["install"], {
32
+ cwd: targetDir,
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ env: process.env,
35
+ });
36
+
37
+ let stderr = "";
38
+
39
+ child.stdout.on("data", () => {
40
+ // consume stdout silently
41
+ });
42
+
43
+ child.stderr.on("data", (data) => {
44
+ stderr += data.toString();
45
+ });
46
+
47
+ child.on("error", (err) => {
48
+ spinner.fail("npm install failed to start");
49
+ error(`Could not run npm: ${err.message}`);
50
+ info("Make sure npm is installed and in your PATH.");
51
+ resolve(false);
52
+ });
53
+
54
+ child.on("close", (code) => {
55
+ if (code === 0) {
56
+ spinner.stop("Dependencies installed");
57
+ resolve(true);
58
+ } else {
59
+ spinner.fail("npm install failed");
60
+
61
+ // Show the npm error, trimmed
62
+ if (stderr) {
63
+ const lines = stderr.trim().split("\n");
64
+ // Show last 15 lines max (most relevant npm errors are at the end)
65
+ const relevant = lines.slice(-15);
66
+ console.log("");
67
+ for (const line of relevant) {
68
+ console.log(` ${line}`);
69
+ }
70
+ console.log("");
71
+ }
72
+
73
+ // Suggest troubleshooting based on path
74
+ if (registryPath === "github") {
75
+ warn("Troubleshooting:");
76
+ info(" 1. Check your GitHub token has read:packages scope");
77
+ info(" 2. Verify token in ~/.npmrc:");
78
+ info(" cat ~/.npmrc | grep github");
79
+ info(" 3. Try: npm whoami --registry=https://npm.pkg.github.com");
80
+ info(" 4. Or re-run: npx create-middag-ui (choose 'No' for GitHub access)");
81
+ } else {
82
+ warn("Troubleshooting:");
83
+ info(" 1. Check your internet connection");
84
+ info(" 2. Try running manually: cd " + targetDir + " && npm install");
85
+ info(" 3. Check npm registry: npm config get registry");
86
+ }
87
+
88
+ resolve(false);
89
+ }
90
+ });
91
+ });
92
+ }
package/lib/prompts.js ADDED
@@ -0,0 +1,120 @@
1
+ /* global console, process */
2
+ /**
3
+ * prompts.js — readline helpers (ask, select, confirm).
4
+ *
5
+ * Uses Node.js built-in readline. No external deps.
6
+ */
7
+
8
+ import { createInterface } from "node:readline";
9
+
10
+ /**
11
+ * Ask a free-text question. Returns trimmed answer.
12
+ */
13
+ export function ask(question) {
14
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
15
+ return new Promise((resolve) =>
16
+ rl.question(question, (answer) => {
17
+ rl.close();
18
+ resolve(answer.trim());
19
+ }),
20
+ );
21
+ }
22
+
23
+ /**
24
+ * Ask a free-text question with masked input (for tokens/passwords).
25
+ * Characters are replaced with '*' on screen.
26
+ */
27
+ export function askSecret(question) {
28
+ return new Promise((resolve) => {
29
+ const rl = createInterface({ input: process.stdin, terminal: false });
30
+ process.stdout.write(question);
31
+
32
+ // Disable echo by switching stdin to raw mode
33
+ if (process.stdin.isTTY) {
34
+ process.stdin.setRawMode(true);
35
+ }
36
+ process.stdin.resume();
37
+
38
+ let input = "";
39
+ const onData = (ch) => {
40
+ const c = ch.toString();
41
+ // Enter
42
+ if (c === "\n" || c === "\r" || c === "\u0004") {
43
+ if (process.stdin.isTTY) {
44
+ process.stdin.setRawMode(false);
45
+ }
46
+ process.stdin.removeListener("data", onData);
47
+ process.stdin.pause();
48
+ rl.close();
49
+ process.stdout.write("\n");
50
+ resolve(input.trim());
51
+ return;
52
+ }
53
+ // Ctrl+C
54
+ if (c === "\u0003") {
55
+ if (process.stdin.isTTY) {
56
+ process.stdin.setRawMode(false);
57
+ }
58
+ process.stdin.removeListener("data", onData);
59
+ rl.close();
60
+ process.stdout.write("\n");
61
+ process.exit(1);
62
+ }
63
+ // Backspace
64
+ if (c === "\u007F" || c === "\b") {
65
+ if (input.length > 0) {
66
+ input = input.slice(0, -1);
67
+ process.stdout.write("\b \b");
68
+ }
69
+ return;
70
+ }
71
+ input += c;
72
+ process.stdout.write("*");
73
+ };
74
+
75
+ process.stdin.on("data", onData);
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Show numbered options, return the selected value.
81
+ *
82
+ * @param {string} question - Prompt text
83
+ * @param {{ label: string, value: string }[]} options - Choices
84
+ * @param {string} [defaultValue] - Default if user presses Enter
85
+ * @returns {Promise<string>} Selected value
86
+ */
87
+ export async function select(question, options, defaultValue) {
88
+ for (let i = 0; i < options.length; i++) {
89
+ const marker = options[i].value === defaultValue ? " (default)" : "";
90
+ console.log(` ${i + 1}) ${options[i].label}${marker}`);
91
+ }
92
+ const answer = await ask(`\n ${question} [1-${options.length}]: `);
93
+
94
+ if (!answer && defaultValue) {
95
+ return defaultValue;
96
+ }
97
+
98
+ const idx = parseInt(answer, 10) - 1;
99
+ if (idx >= 0 && idx < options.length) {
100
+ return options[idx].value;
101
+ }
102
+
103
+ return defaultValue || options[0].value;
104
+ }
105
+
106
+ /**
107
+ * Yes/No confirmation. Returns boolean.
108
+ *
109
+ * @param {string} question - Prompt text
110
+ * @param {boolean} [defaultYes=true] - Default if user presses Enter
111
+ * @returns {Promise<boolean>}
112
+ */
113
+ export async function confirm(question, defaultYes = true) {
114
+ const hint = defaultYes ? "[Y/n]" : "[y/N]";
115
+ const answer = await ask(` ${question} ${hint}: `);
116
+
117
+ if (!answer) return defaultYes;
118
+
119
+ return answer.toLowerCase().startsWith("y");
120
+ }