@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.
Files changed (4) hide show
  1. package/README.md +62 -0
  2. package/bin/cli.js +241 -0
  3. package/package.json +30 -0
  4. 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
+ });