create-middag-ui 0.1.4 → 0.2.0
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 +43 -24
- package/cli.js +149 -287
- package/lib/auth.js +210 -0
- package/lib/detect.js +29 -0
- package/lib/install.js +92 -0
- package/lib/prompts.js +120 -0
- package/lib/scaffold.js +386 -0
- package/lib/ui.js +85 -0
- package/package.json +2 -1
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
|
+
}
|