@kousaku-maron/template-manager 0.1.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 +62 -0
- package/bin/cli.js +241 -0
- package/package.json +30 -0
- package/src/cli.ts +297 -0
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @kousaku-maron/template-manager
|
|
2
|
+
|
|
3
|
+
Download template directories from GitHub and scaffold projects from extracted files.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @kousaku-maron/template-manager --template=owner/repo/templates/my-template
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
If you run without `--template`, the CLI shows an interactive template picker (`↑/↓` to move, `Enter` to select) and then asks the output directory (default: `.`):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
node ./bin/cli.js
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Development
|
|
18
|
+
|
|
19
|
+
Source code is managed in TypeScript:
|
|
20
|
+
|
|
21
|
+
- Edit `/Users/kurinokousaku/Workspace/maron/template-manager/src/cli.ts`
|
|
22
|
+
- Build with `pnpm build` (outputs `/Users/kurinokousaku/Workspace/maron/template-manager/bin/cli.js`)
|
|
23
|
+
|
|
24
|
+
## CLI Check
|
|
25
|
+
|
|
26
|
+
Manual check examples:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
node ./bin/cli.js --help
|
|
30
|
+
node ./bin/cli.js --version
|
|
31
|
+
node ./bin/cli.js --template=astro-cloudflare-blog --dir=tmp/astro-blog-test --force
|
|
32
|
+
node ./bin/cli.js --template=kousaku-maron/template-manager/templates/astro-cloudflare-blog --dir=my-blog
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Options
|
|
36
|
+
|
|
37
|
+
- `-t, --template` (required): Template source.
|
|
38
|
+
- `-d, --dir`: Output directory. Default is the template folder name.
|
|
39
|
+
- `-f, --force`: Overwrite destination directory if it exists.
|
|
40
|
+
- `-h, --help`: Show help.
|
|
41
|
+
- `-v, --version`: Show version.
|
|
42
|
+
|
|
43
|
+
## Template format
|
|
44
|
+
|
|
45
|
+
Both formats are supported:
|
|
46
|
+
|
|
47
|
+
- `owner/repo/path/to/template`
|
|
48
|
+
- `gh:owner/repo/path/to/template`
|
|
49
|
+
- Included template alias (current): `astro-cloudflare-blog`
|
|
50
|
+
|
|
51
|
+
## Included templates
|
|
52
|
+
|
|
53
|
+
- `kousaku-maron/template-manager/templates/astro-cloudflare-blog`
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx @kousaku-maron/template-manager --template=cloudflare/templates/astro-blog-starter-template
|
|
59
|
+
npx @kousaku-maron/template-manager --template=owner/repo/templates/next-app --dir=my-app
|
|
60
|
+
npx @kousaku-maron/template-manager --template=astro-cloudflare-blog --dir=my-astro-cloudflare-blog
|
|
61
|
+
npx @kousaku-maron/template-manager --template=kousaku-maron/template-manager/templates/astro-cloudflare-blog --dir=my-astro-cloudflare-blog
|
|
62
|
+
```
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { emitKeypressEvents } from "node:readline";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
const VERSION = "0.1.0";
|
|
9
|
+
const INCLUDED_TEMPLATES = {
|
|
10
|
+
"astro-cloudflare-blog": "kousaku-maron/template-manager/templates/astro-cloudflare-blog",
|
|
11
|
+
};
|
|
12
|
+
function usage() {
|
|
13
|
+
console.log(`template-manager
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
npx @kousaku-maron/template-manager --template=<owner/repo/path> [options]
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
-t, --template Source template (required)
|
|
20
|
+
-d, --dir Output directory (default: template folder name)
|
|
21
|
+
-f, --force Overwrite existing output directory
|
|
22
|
+
-h, --help Show help
|
|
23
|
+
-v, --version Show version
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
npx @kousaku-maron/template-manager --template=cloudflare/templates/astro-blog-starter-template
|
|
27
|
+
npx @kousaku-maron/template-manager --template=owner/repo/templates/next-app --dir=my-app
|
|
28
|
+
npx @kousaku-maron/template-manager --template=astro-cloudflare-blog --dir=my-blog
|
|
29
|
+
`);
|
|
30
|
+
}
|
|
31
|
+
function fail(message) {
|
|
32
|
+
console.error(`Error: ${message}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const options = {
|
|
37
|
+
force: false,
|
|
38
|
+
};
|
|
39
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
40
|
+
const arg = argv[i];
|
|
41
|
+
if (arg === "--") {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (arg === "-h" || arg === "--help") {
|
|
45
|
+
options.help = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (arg === "-v" || arg === "--version") {
|
|
49
|
+
options.version = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (arg === "-f" || arg === "--force") {
|
|
53
|
+
options.force = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg === "-t" || arg === "--template") {
|
|
57
|
+
const value = argv[i + 1];
|
|
58
|
+
if (!value || value.startsWith("-")) {
|
|
59
|
+
fail("--template requires a value");
|
|
60
|
+
}
|
|
61
|
+
options.template = value;
|
|
62
|
+
i += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (arg.startsWith("--template=")) {
|
|
66
|
+
const value = arg.slice("--template=".length);
|
|
67
|
+
if (!value) {
|
|
68
|
+
fail("--template requires a value");
|
|
69
|
+
}
|
|
70
|
+
options.template = value;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (arg === "-d" || arg === "--dir") {
|
|
74
|
+
const value = argv[i + 1];
|
|
75
|
+
if (!value || value.startsWith("-")) {
|
|
76
|
+
fail("--dir requires a value");
|
|
77
|
+
}
|
|
78
|
+
options.dir = value;
|
|
79
|
+
i += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (arg.startsWith("--dir=")) {
|
|
83
|
+
const value = arg.slice("--dir=".length);
|
|
84
|
+
if (!value) {
|
|
85
|
+
fail("--dir requires a value");
|
|
86
|
+
}
|
|
87
|
+
options.dir = value;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
fail(`Unknown argument: ${arg}`);
|
|
91
|
+
}
|
|
92
|
+
return options;
|
|
93
|
+
}
|
|
94
|
+
function stripPrefix(value) {
|
|
95
|
+
if (value.startsWith("gh:"))
|
|
96
|
+
return value.slice(3);
|
|
97
|
+
if (value.startsWith("github:"))
|
|
98
|
+
return value.slice(7);
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
function normalizeTemplate(input) {
|
|
102
|
+
if (input.startsWith("gh:") || input.startsWith("github:"))
|
|
103
|
+
return input;
|
|
104
|
+
const [name, ref] = input.split("#");
|
|
105
|
+
if (INCLUDED_TEMPLATES[name]) {
|
|
106
|
+
const source = INCLUDED_TEMPLATES[name];
|
|
107
|
+
return `gh:${source}${ref ? `#${ref}` : ""}`;
|
|
108
|
+
}
|
|
109
|
+
if (/^[^/]+\/[^/]+(\/.+)?$/.test(input))
|
|
110
|
+
return `gh:${input}`;
|
|
111
|
+
fail("Invalid template format. Use owner/repo/path, gh:owner/repo/path, or one of: astro-cloudflare-blog");
|
|
112
|
+
}
|
|
113
|
+
async function selectIncludedTemplate() {
|
|
114
|
+
const entries = Object.entries(INCLUDED_TEMPLATES);
|
|
115
|
+
if (entries.length === 0) {
|
|
116
|
+
fail("--template is required");
|
|
117
|
+
}
|
|
118
|
+
const input = process.stdin;
|
|
119
|
+
const output = process.stdout;
|
|
120
|
+
let selected = 0;
|
|
121
|
+
let renderedLines = 0;
|
|
122
|
+
const render = () => {
|
|
123
|
+
if (renderedLines > 0) {
|
|
124
|
+
output.write(`\x1b[${renderedLines}A`);
|
|
125
|
+
output.write("\x1b[J");
|
|
126
|
+
}
|
|
127
|
+
const lines = [
|
|
128
|
+
"Select a template (Use ↑/↓ and Enter):",
|
|
129
|
+
...entries.map(([name], index) => {
|
|
130
|
+
const cursor = index === selected ? ">" : " ";
|
|
131
|
+
return ` ${cursor} ${name}`;
|
|
132
|
+
}),
|
|
133
|
+
];
|
|
134
|
+
output.write(`${lines.join("\n")}\n`);
|
|
135
|
+
renderedLines = lines.length;
|
|
136
|
+
};
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const cleanup = () => {
|
|
139
|
+
input.off("keypress", onKeypress);
|
|
140
|
+
if (input.isTTY) {
|
|
141
|
+
input.setRawMode(false);
|
|
142
|
+
}
|
|
143
|
+
input.pause();
|
|
144
|
+
};
|
|
145
|
+
const onKeypress = (_text, key) => {
|
|
146
|
+
if (key.ctrl && key.name === "c") {
|
|
147
|
+
output.write("\n");
|
|
148
|
+
cleanup();
|
|
149
|
+
reject(new Error("Cancelled"));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (key.name === "up") {
|
|
153
|
+
selected = (selected - 1 + entries.length) % entries.length;
|
|
154
|
+
render();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (key.name === "down") {
|
|
158
|
+
selected = (selected + 1) % entries.length;
|
|
159
|
+
render();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (key.name === "return" || key.name === "enter") {
|
|
163
|
+
output.write("\n");
|
|
164
|
+
const [name] = entries[selected];
|
|
165
|
+
cleanup();
|
|
166
|
+
resolve(name);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
emitKeypressEvents(input);
|
|
170
|
+
if (input.isTTY) {
|
|
171
|
+
input.setRawMode(true);
|
|
172
|
+
}
|
|
173
|
+
input.resume();
|
|
174
|
+
input.on("keypress", onKeypress);
|
|
175
|
+
render();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async function promptOutputDirectory(defaultDir) {
|
|
179
|
+
const rl = createInterface({
|
|
180
|
+
input: process.stdin,
|
|
181
|
+
output: process.stdout,
|
|
182
|
+
});
|
|
183
|
+
const answer = await rl.question(`Output directory (default: ${defaultDir}): `);
|
|
184
|
+
rl.close();
|
|
185
|
+
const value = answer.trim();
|
|
186
|
+
return value || defaultDir;
|
|
187
|
+
}
|
|
188
|
+
function defaultDirectory(template) {
|
|
189
|
+
const clean = stripPrefix(template).split("#")[0];
|
|
190
|
+
const segments = clean.split("/").filter(Boolean);
|
|
191
|
+
const last = segments[segments.length - 1];
|
|
192
|
+
return last || "new-project";
|
|
193
|
+
}
|
|
194
|
+
async function main() {
|
|
195
|
+
const options = parseArgs(process.argv.slice(2));
|
|
196
|
+
if (options.help) {
|
|
197
|
+
usage();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (options.version) {
|
|
201
|
+
console.log(VERSION);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (!options.template) {
|
|
205
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
206
|
+
usage();
|
|
207
|
+
fail("--template is required");
|
|
208
|
+
}
|
|
209
|
+
options.template = await selectIncludedTemplate();
|
|
210
|
+
if (!options.dir) {
|
|
211
|
+
options.dir = await promptOutputDirectory(".");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const template = normalizeTemplate(options.template);
|
|
215
|
+
const targetDir = options.dir || defaultDirectory(template);
|
|
216
|
+
const currentDir = resolve(process.cwd());
|
|
217
|
+
const destination = resolve(currentDir, targetDir);
|
|
218
|
+
const isCurrentDir = destination === currentDir;
|
|
219
|
+
if (!isCurrentDir && existsSync(destination) && !options.force) {
|
|
220
|
+
fail(`Destination already exists: ${destination} (use --force to overwrite)`);
|
|
221
|
+
}
|
|
222
|
+
if (!isCurrentDir && existsSync(destination) && options.force) {
|
|
223
|
+
await rm(destination, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
const { downloadTemplate } = await import("giget");
|
|
226
|
+
console.log(`Downloading and extracting template: ${template}`);
|
|
227
|
+
console.log(`Destination: ${destination}`);
|
|
228
|
+
await downloadTemplate(template, {
|
|
229
|
+
dir: destination,
|
|
230
|
+
force: true,
|
|
231
|
+
});
|
|
232
|
+
console.log("");
|
|
233
|
+
console.log("Done.");
|
|
234
|
+
console.log("Next steps:");
|
|
235
|
+
console.log(` cd ${targetDir}`);
|
|
236
|
+
console.log(" pnpm install");
|
|
237
|
+
console.log(" pnpm dev");
|
|
238
|
+
}
|
|
239
|
+
main().catch((error) => {
|
|
240
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
241
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kousaku-maron/template-manager",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold projects from repository templates.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"template-manager": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.json && chmod +x ./bin/cli.js",
|
|
19
|
+
"start": "node ./bin/cli.js",
|
|
20
|
+
"prepublishOnly": "pnpm build"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"giget": "^2.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^24.5.2",
|
|
27
|
+
"typescript": "^5.9.2"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { rm } from "node:fs/promises";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { emitKeypressEvents } from "node:readline";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
|
|
10
|
+
const VERSION = "0.1.0";
|
|
11
|
+
const INCLUDED_TEMPLATES: Record<string, string> = {
|
|
12
|
+
"astro-cloudflare-blog":
|
|
13
|
+
"kousaku-maron/template-manager/templates/astro-cloudflare-blog",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type CliOptions = {
|
|
17
|
+
template?: string;
|
|
18
|
+
dir?: string;
|
|
19
|
+
help?: boolean;
|
|
20
|
+
version?: boolean;
|
|
21
|
+
force: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function usage(): void {
|
|
25
|
+
console.log(`template-manager
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
npx @kousaku-maron/template-manager --template=<owner/repo/path> [options]
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
-t, --template Source template (required)
|
|
32
|
+
-d, --dir Output directory (default: template folder name)
|
|
33
|
+
-f, --force Overwrite existing output directory
|
|
34
|
+
-h, --help Show help
|
|
35
|
+
-v, --version Show version
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
npx @kousaku-maron/template-manager --template=cloudflare/templates/astro-blog-starter-template
|
|
39
|
+
npx @kousaku-maron/template-manager --template=owner/repo/templates/next-app --dir=my-app
|
|
40
|
+
npx @kousaku-maron/template-manager --template=astro-cloudflare-blog --dir=my-blog
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fail(message: string): never {
|
|
45
|
+
console.error(`Error: ${message}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv: string[]): CliOptions {
|
|
50
|
+
const options: CliOptions = {
|
|
51
|
+
force: false,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
55
|
+
const arg = argv[i];
|
|
56
|
+
|
|
57
|
+
if (arg === "--") {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (arg === "-h" || arg === "--help") {
|
|
62
|
+
options.help = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (arg === "-v" || arg === "--version") {
|
|
66
|
+
options.version = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (arg === "-f" || arg === "--force") {
|
|
70
|
+
options.force = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (arg === "-t" || arg === "--template") {
|
|
75
|
+
const value = argv[i + 1];
|
|
76
|
+
if (!value || value.startsWith("-")) {
|
|
77
|
+
fail("--template requires a value");
|
|
78
|
+
}
|
|
79
|
+
options.template = value;
|
|
80
|
+
i += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (arg.startsWith("--template=")) {
|
|
84
|
+
const value = arg.slice("--template=".length);
|
|
85
|
+
if (!value) {
|
|
86
|
+
fail("--template requires a value");
|
|
87
|
+
}
|
|
88
|
+
options.template = value;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (arg === "-d" || arg === "--dir") {
|
|
93
|
+
const value = argv[i + 1];
|
|
94
|
+
if (!value || value.startsWith("-")) {
|
|
95
|
+
fail("--dir requires a value");
|
|
96
|
+
}
|
|
97
|
+
options.dir = value;
|
|
98
|
+
i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (arg.startsWith("--dir=")) {
|
|
102
|
+
const value = arg.slice("--dir=".length);
|
|
103
|
+
if (!value) {
|
|
104
|
+
fail("--dir requires a value");
|
|
105
|
+
}
|
|
106
|
+
options.dir = value;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fail(`Unknown argument: ${arg}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return options;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function stripPrefix(value: string): string {
|
|
117
|
+
if (value.startsWith("gh:")) return value.slice(3);
|
|
118
|
+
if (value.startsWith("github:")) return value.slice(7);
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeTemplate(input: string): string {
|
|
123
|
+
if (input.startsWith("gh:") || input.startsWith("github:")) return input;
|
|
124
|
+
const [name, ref] = input.split("#");
|
|
125
|
+
if (INCLUDED_TEMPLATES[name]) {
|
|
126
|
+
const source = INCLUDED_TEMPLATES[name];
|
|
127
|
+
return `gh:${source}${ref ? `#${ref}` : ""}`;
|
|
128
|
+
}
|
|
129
|
+
if (/^[^/]+\/[^/]+(\/.+)?$/.test(input)) return `gh:${input}`;
|
|
130
|
+
fail(
|
|
131
|
+
"Invalid template format. Use owner/repo/path, gh:owner/repo/path, or one of: astro-cloudflare-blog",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function selectIncludedTemplate(): Promise<string> {
|
|
136
|
+
const entries = Object.entries(INCLUDED_TEMPLATES);
|
|
137
|
+
|
|
138
|
+
if (entries.length === 0) {
|
|
139
|
+
fail("--template is required");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const input = process.stdin;
|
|
143
|
+
const output = process.stdout;
|
|
144
|
+
let selected = 0;
|
|
145
|
+
let renderedLines = 0;
|
|
146
|
+
|
|
147
|
+
const render = (): void => {
|
|
148
|
+
if (renderedLines > 0) {
|
|
149
|
+
output.write(`\x1b[${renderedLines}A`);
|
|
150
|
+
output.write("\x1b[J");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const lines = [
|
|
154
|
+
"Select a template (Use ↑/↓ and Enter):",
|
|
155
|
+
...entries.map(([name], index) => {
|
|
156
|
+
const cursor = index === selected ? ">" : " ";
|
|
157
|
+
return ` ${cursor} ${name}`;
|
|
158
|
+
}),
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
output.write(`${lines.join("\n")}\n`);
|
|
162
|
+
renderedLines = lines.length;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return new Promise<string>((resolve, reject) => {
|
|
166
|
+
const cleanup = (): void => {
|
|
167
|
+
input.off("keypress", onKeypress);
|
|
168
|
+
if (input.isTTY) {
|
|
169
|
+
input.setRawMode(false);
|
|
170
|
+
}
|
|
171
|
+
input.pause();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const onKeypress = (
|
|
175
|
+
_text: string,
|
|
176
|
+
key: { name?: string; ctrl?: boolean },
|
|
177
|
+
): void => {
|
|
178
|
+
if (key.ctrl && key.name === "c") {
|
|
179
|
+
output.write("\n");
|
|
180
|
+
cleanup();
|
|
181
|
+
reject(new Error("Cancelled"));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (key.name === "up") {
|
|
186
|
+
selected = (selected - 1 + entries.length) % entries.length;
|
|
187
|
+
render();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (key.name === "down") {
|
|
192
|
+
selected = (selected + 1) % entries.length;
|
|
193
|
+
render();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (key.name === "return" || key.name === "enter") {
|
|
198
|
+
output.write("\n");
|
|
199
|
+
const [name] = entries[selected];
|
|
200
|
+
cleanup();
|
|
201
|
+
resolve(name);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
emitKeypressEvents(input);
|
|
206
|
+
if (input.isTTY) {
|
|
207
|
+
input.setRawMode(true);
|
|
208
|
+
}
|
|
209
|
+
input.resume();
|
|
210
|
+
input.on("keypress", onKeypress);
|
|
211
|
+
render();
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function promptOutputDirectory(defaultDir: string): Promise<string> {
|
|
216
|
+
const rl = createInterface({
|
|
217
|
+
input: process.stdin,
|
|
218
|
+
output: process.stdout,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const answer = await rl.question(
|
|
222
|
+
`Output directory (default: ${defaultDir}): `,
|
|
223
|
+
);
|
|
224
|
+
rl.close();
|
|
225
|
+
|
|
226
|
+
const value = answer.trim();
|
|
227
|
+
return value || defaultDir;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function defaultDirectory(template: string): string {
|
|
231
|
+
const clean = stripPrefix(template).split("#")[0];
|
|
232
|
+
const segments = clean.split("/").filter(Boolean);
|
|
233
|
+
const last = segments[segments.length - 1];
|
|
234
|
+
return last || "new-project";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function main(): Promise<void> {
|
|
238
|
+
const options = parseArgs(process.argv.slice(2));
|
|
239
|
+
|
|
240
|
+
if (options.help) {
|
|
241
|
+
usage();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (options.version) {
|
|
246
|
+
console.log(VERSION);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!options.template) {
|
|
251
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
252
|
+
usage();
|
|
253
|
+
fail("--template is required");
|
|
254
|
+
}
|
|
255
|
+
options.template = await selectIncludedTemplate();
|
|
256
|
+
if (!options.dir) {
|
|
257
|
+
options.dir = await promptOutputDirectory(".");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const template = normalizeTemplate(options.template);
|
|
262
|
+
const targetDir = options.dir || defaultDirectory(template);
|
|
263
|
+
const currentDir = resolve(process.cwd());
|
|
264
|
+
const destination = resolve(currentDir, targetDir);
|
|
265
|
+
const isCurrentDir = destination === currentDir;
|
|
266
|
+
|
|
267
|
+
if (!isCurrentDir && existsSync(destination) && !options.force) {
|
|
268
|
+
fail(
|
|
269
|
+
`Destination already exists: ${destination} (use --force to overwrite)`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!isCurrentDir && existsSync(destination) && options.force) {
|
|
274
|
+
await rm(destination, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const { downloadTemplate } = await import("giget");
|
|
278
|
+
|
|
279
|
+
console.log(`Downloading and extracting template: ${template}`);
|
|
280
|
+
console.log(`Destination: ${destination}`);
|
|
281
|
+
|
|
282
|
+
await downloadTemplate(template, {
|
|
283
|
+
dir: destination,
|
|
284
|
+
force: true,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
console.log("");
|
|
288
|
+
console.log("Done.");
|
|
289
|
+
console.log("Next steps:");
|
|
290
|
+
console.log(` cd ${targetDir}`);
|
|
291
|
+
console.log(" pnpm install");
|
|
292
|
+
console.log(" pnpm dev");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
main().catch((error: unknown) => {
|
|
296
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
297
|
+
});
|