create-fugi 1.0.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/dist/commands/create.js +63 -0
- package/dist/index.js +36 -0
- package/dist/lib/github.js +75 -0
- package/dist/lib/templates.js +51 -0
- package/package.json +59 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { rm, mkdtemp } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { input, select } from '@inquirer/prompts';
|
|
5
|
+
import { downloadTemplatesDirectory as downloadTemplatesDirectoryFromGithub, } from '../lib/github.js';
|
|
6
|
+
import { copyTemplate, ensureDirectoryDoesNotExist, listTemplates, validateProjectName, } from '../lib/templates.js';
|
|
7
|
+
async function promptTemplateName(templates) {
|
|
8
|
+
return select({
|
|
9
|
+
message: 'Select a scaffold template',
|
|
10
|
+
choices: templates.map((template) => ({
|
|
11
|
+
name: template.name,
|
|
12
|
+
value: template.name,
|
|
13
|
+
})),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
async function promptProjectName() {
|
|
17
|
+
return input({
|
|
18
|
+
message: 'Project name',
|
|
19
|
+
validate: (value) => {
|
|
20
|
+
try {
|
|
21
|
+
validateProjectName(value);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error instanceof Error) {
|
|
26
|
+
return error.message;
|
|
27
|
+
}
|
|
28
|
+
return 'Invalid project name.';
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export async function runCreateCommand(dependencies = {}) {
|
|
34
|
+
const log = dependencies.log ?? console.log;
|
|
35
|
+
const cwd = dependencies.cwd ?? process.cwd();
|
|
36
|
+
const selectTemplate = dependencies.promptTemplate ?? promptTemplateName;
|
|
37
|
+
const askProjectName = dependencies.promptProjectName ?? promptProjectName;
|
|
38
|
+
const downloadTemplatesDirectory = dependencies.downloadTemplatesDirectory ?? downloadTemplatesDirectoryFromGithub;
|
|
39
|
+
const tempRootDir = await mkdtemp(path.join(os.tmpdir(), 'create-fugi-'));
|
|
40
|
+
try {
|
|
41
|
+
log('Fetching templates from GitHub...');
|
|
42
|
+
const { templatesRootDir } = await downloadTemplatesDirectory(tempRootDir);
|
|
43
|
+
const templates = await listTemplates(templatesRootDir);
|
|
44
|
+
if (templates.length === 0) {
|
|
45
|
+
throw new Error('No templates found in remote repository');
|
|
46
|
+
}
|
|
47
|
+
const selectedTemplateName = await selectTemplate(templates);
|
|
48
|
+
const selectedTemplate = templates.find((template) => template.name === selectedTemplateName);
|
|
49
|
+
if (selectedTemplate === undefined) {
|
|
50
|
+
throw new Error(`Template "${selectedTemplateName}" is not available.`);
|
|
51
|
+
}
|
|
52
|
+
const rawProjectName = await askProjectName();
|
|
53
|
+
const projectName = validateProjectName(rawProjectName);
|
|
54
|
+
const targetDir = path.resolve(cwd, projectName);
|
|
55
|
+
await ensureDirectoryDoesNotExist(targetDir);
|
|
56
|
+
await copyTemplate(selectedTemplate.absolutePath, targetDir);
|
|
57
|
+
log(`Project created at ${targetDir}`);
|
|
58
|
+
log(`Next steps:\n cd ${projectName}`);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await rm(tempRootDir, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { runCreateCommand } from './commands/create.js';
|
|
4
|
+
export const HELP_TEXT = `Usage:
|
|
5
|
+
pnpm create fugi
|
|
6
|
+
create-fugi`;
|
|
7
|
+
export async function runCli(argv, dependencies = {}) {
|
|
8
|
+
const command = argv[0];
|
|
9
|
+
if (command === undefined || command === 'create') {
|
|
10
|
+
await (dependencies.runCreateCommand ?? runCreateCommand)();
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
;
|
|
14
|
+
(dependencies.writeStdout ?? console.log)(HELP_TEXT);
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
17
|
+
function formatError(error) {
|
|
18
|
+
if (error instanceof Error) {
|
|
19
|
+
return error.message;
|
|
20
|
+
}
|
|
21
|
+
return String(error);
|
|
22
|
+
}
|
|
23
|
+
async function main() {
|
|
24
|
+
try {
|
|
25
|
+
const exitCode = await runCli(process.argv.slice(2));
|
|
26
|
+
process.exitCode = exitCode;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
console.error(formatError(error));
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const entry = process.argv[1];
|
|
34
|
+
if (entry !== undefined && import.meta.url === pathToFileURL(entry).href) {
|
|
35
|
+
void main();
|
|
36
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdir, readdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { x as extractArchive } from 'tar';
|
|
4
|
+
const GITHUB_OWNER = 'ryougifujino';
|
|
5
|
+
const GITHUB_REPO = 'create-fugi';
|
|
6
|
+
const GITHUB_REPOSITORY_API = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}`;
|
|
7
|
+
const GITHUB_USER_AGENT = 'create-fugi-cli';
|
|
8
|
+
function assertOkResponse(response, requestUrl) {
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
throw new Error(`Request failed (${response.status} ${response.statusText}) for ${requestUrl}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function fetchDefaultBranch(fetchImpl = fetch) {
|
|
14
|
+
const response = await fetchImpl(GITHUB_REPOSITORY_API, {
|
|
15
|
+
headers: {
|
|
16
|
+
Accept: 'application/vnd.github+json',
|
|
17
|
+
'User-Agent': GITHUB_USER_AGENT,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
assertOkResponse(response, GITHUB_REPOSITORY_API);
|
|
21
|
+
const responseJson = (await response.json());
|
|
22
|
+
const branch = responseJson?.default_branch;
|
|
23
|
+
if (typeof branch !== 'string' || branch.length === 0) {
|
|
24
|
+
throw new Error('Failed to resolve default branch from GitHub response.');
|
|
25
|
+
}
|
|
26
|
+
return branch;
|
|
27
|
+
}
|
|
28
|
+
export async function downloadTemplatesDirectory(tempRootDir, fetchImpl = fetch) {
|
|
29
|
+
const branch = await fetchDefaultBranch(fetchImpl);
|
|
30
|
+
const tarballUrl = `https://codeload.github.com/${GITHUB_OWNER}/${GITHUB_REPO}/tar.gz/refs/heads/${encodeURIComponent(branch)}`;
|
|
31
|
+
const tarballPath = path.join(tempRootDir, 'repository.tar.gz');
|
|
32
|
+
const extractDir = path.join(tempRootDir, 'extracted');
|
|
33
|
+
const tarballResponse = await fetchImpl(tarballUrl, {
|
|
34
|
+
headers: {
|
|
35
|
+
'User-Agent': GITHUB_USER_AGENT,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
assertOkResponse(tarballResponse, tarballUrl);
|
|
39
|
+
const tarballBytes = Buffer.from(await tarballResponse.arrayBuffer());
|
|
40
|
+
await writeFile(tarballPath, tarballBytes);
|
|
41
|
+
await mkdir(extractDir, { recursive: true });
|
|
42
|
+
await extractArchive({
|
|
43
|
+
cwd: extractDir,
|
|
44
|
+
file: tarballPath,
|
|
45
|
+
});
|
|
46
|
+
const extractedRepositoryRootDir = await resolveExtractedRepositoryRootDir(extractDir);
|
|
47
|
+
const templatesRootDir = path.join(extractedRepositoryRootDir, 'templates');
|
|
48
|
+
await assertDirectoryExists(templatesRootDir, 'Remote repository does not contain a templates directory.');
|
|
49
|
+
return {
|
|
50
|
+
branch,
|
|
51
|
+
templatesRootDir,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
async function resolveExtractedRepositoryRootDir(extractDir) {
|
|
55
|
+
const entries = await readdir(extractDir, { withFileTypes: true });
|
|
56
|
+
const directories = entries.filter((entry) => entry.isDirectory());
|
|
57
|
+
if (directories.length !== 1) {
|
|
58
|
+
throw new Error(`Expected one root directory in repository archive, but found ${directories.length}.`);
|
|
59
|
+
}
|
|
60
|
+
return path.join(extractDir, directories[0].name);
|
|
61
|
+
}
|
|
62
|
+
async function assertDirectoryExists(directoryPath, errorMessage) {
|
|
63
|
+
try {
|
|
64
|
+
const directoryStat = await stat(directoryPath);
|
|
65
|
+
if (!directoryStat.isDirectory()) {
|
|
66
|
+
throw new Error(errorMessage);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error.code === 'ENOENT') {
|
|
71
|
+
throw new Error(errorMessage, { cause: error });
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { cp, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const PROJECT_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
4
|
+
export async function listTemplates(templatesRootDir) {
|
|
5
|
+
const entries = await readdir(templatesRootDir, { withFileTypes: true });
|
|
6
|
+
return entries
|
|
7
|
+
.filter((entry) => entry.isDirectory())
|
|
8
|
+
.map((entry) => ({
|
|
9
|
+
name: entry.name,
|
|
10
|
+
absolutePath: path.join(templatesRootDir, entry.name),
|
|
11
|
+
}))
|
|
12
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
13
|
+
}
|
|
14
|
+
export function validateProjectName(rawProjectName) {
|
|
15
|
+
const projectName = rawProjectName.trim();
|
|
16
|
+
if (projectName.length === 0) {
|
|
17
|
+
throw new Error('Project name is required.');
|
|
18
|
+
}
|
|
19
|
+
if (projectName === '.' || projectName === '..') {
|
|
20
|
+
throw new Error('Project name cannot be "." or "..".');
|
|
21
|
+
}
|
|
22
|
+
if (/[\\/]/.test(projectName)) {
|
|
23
|
+
throw new Error('Project name must be a single directory name.');
|
|
24
|
+
}
|
|
25
|
+
if (!PROJECT_NAME_PATTERN.test(projectName)) {
|
|
26
|
+
throw new Error('Project name can only contain letters, numbers, dots, underscores, and hyphens.');
|
|
27
|
+
}
|
|
28
|
+
if (path.basename(projectName) !== projectName) {
|
|
29
|
+
throw new Error('Project name must not include path traversal.');
|
|
30
|
+
}
|
|
31
|
+
return projectName;
|
|
32
|
+
}
|
|
33
|
+
export async function ensureDirectoryDoesNotExist(targetDir) {
|
|
34
|
+
try {
|
|
35
|
+
await stat(targetDir);
|
|
36
|
+
throw new Error(`Target directory already exists: ${targetDir}`);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT') {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function copyTemplate(templateDir, targetDir) {
|
|
46
|
+
await cp(templateDir, targetDir, {
|
|
47
|
+
recursive: true,
|
|
48
|
+
errorOnExist: true,
|
|
49
|
+
force: false,
|
|
50
|
+
});
|
|
51
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-fugi",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24.0.0"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"bin": {
|
|
11
|
+
"create-fugi": "dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"lint-staged": {
|
|
17
|
+
"src/**/*.{js,jsx,ts,tsx}": [
|
|
18
|
+
"eslint --max-warnings=0 --fix",
|
|
19
|
+
"prettier --write"
|
|
20
|
+
],
|
|
21
|
+
"src/**/*.{json,md,html,yml,yaml}": [
|
|
22
|
+
"prettier --write"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com:ryougifujino/create-fugi.git"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [],
|
|
30
|
+
"author": "ryougifujino",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/ryougifujino/create-fugi/issues"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@inquirer/prompts": "^8.0.1",
|
|
37
|
+
"tar": "^7.5.2"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@commitlint/cli": "^20.4.1",
|
|
41
|
+
"@commitlint/config-conventional": "^20.4.1",
|
|
42
|
+
"@eslint/js": "^10.0.1",
|
|
43
|
+
"@types/node": "24.10.12",
|
|
44
|
+
"@typescript/native-preview": "7.0.0-dev.20260208.1",
|
|
45
|
+
"eslint": "^10.0.0",
|
|
46
|
+
"eslint-config-prettier": "^10.1.8",
|
|
47
|
+
"globals": "^17.3.0",
|
|
48
|
+
"lefthook": "^2.1.0",
|
|
49
|
+
"lint-staged": "^16.2.7",
|
|
50
|
+
"prettier": "3.8.1",
|
|
51
|
+
"rimraf": "^6.1.2",
|
|
52
|
+
"typescript-eslint": "^8.54.0"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"typecheck": "tsgo --noEmit",
|
|
56
|
+
"build": "rimraf dist && tsgo",
|
|
57
|
+
"test": "pnpm typecheck && node --test \"test/**/*.test.ts\""
|
|
58
|
+
}
|
|
59
|
+
}
|