@ts-for-gir/cli
Version:
TypeScript type definition generator for GObject introspection GIR files
287 lines (259 loc) • 10.7 kB
text/typescript
/**
* Everything you need for the `ts-for-gir create` command is located here
*/
import { spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { input, select } from "@inquirer/prompts";
import { APP_NAME, type ConfigFlags, Logger } from "@ts-for-gir/lib";
import { createOptions } from "../config.ts";
import type { CreateCommandArgs, CreateTemplateId } from "../types/index.ts";
import { createBuilder } from "./command-builder.ts";
const command = "create [name]";
const description = "Scaffold a new GJS TypeScript project from a template";
const examples: ReadonlyArray<[string, string?]> = [
[`${APP_NAME} create my-app --template types-gjsify`, "Scaffold a Node-free GJS app via gjsify (recommended)"],
[`${APP_NAME} create my-app --template types-npm`, "Scaffold using the @girs/* NPM types + node/esbuild"],
[`${APP_NAME} create my-app --template types-locally`, "Scaffold and generate types into ./@types/ locally"],
[
`${APP_NAME} create my-app --template types-workspace`,
"Scaffold an npm workspace with types as workspace packages",
],
[`${APP_NAME} create`, "Interactive: prompts for name and template"],
];
const TEMPLATE_CHOICES: ReadonlyArray<{ value: CreateTemplateId; name: string; description: string }> = [
{
value: "types-gjsify",
name: "types-gjsify",
description: "Node-free: @girs/* from NPM, all dev scripts (install/build/run/format) routed through gjsify",
},
{
value: "types-npm",
name: "types-npm",
description: "Use pre-generated types from the @girs/* NPM packages",
},
{
value: "types-locally",
name: "types-locally",
description: "Generate GIR types directly into ./@types/ (no package format, no @girs/* deps)",
},
{
value: "types-workspace",
name: "types-workspace",
description: "npm workspace; generate @girs/* as workspace packages under ./@girs/",
},
];
const PROJECT_NAME_PLACEHOLDER = "__PROJECT_NAME__";
const TEXT_FILE_EXT = new Set([".json", ".md", ".ts", ".tsx", ".js", ".mjs", ".cjs"]);
const builder = createBuilder<CreateCommandArgs>(createOptions, examples);
function findTemplatesRoot(): string {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Resolve symlinks too. When the CLI is installed via `npm i -g`,
// `gjsify install -g`, or any tool that lands a symlink in the user's PATH
// (Yarn Berry's bin links, asdf shims, ...), `import.meta.url` resolves to
// the symlink path — `<dir>/../dist-templates` would then look for the
// templates next to the symlink (e.g. `~/.local/bin/../dist-templates`)
// instead of next to the real package. Resolving via realpath first lets
// the same `<dir>/../dist-templates` candidate hit the actual install dir.
let realDirname = __dirname;
try {
realDirname = dirname(realpathSync(__filename));
} catch {
// `realpathSync` can throw on bundled paths that don't exist on disk
// (e.g. virtual entries in a single-file binary). Fall through to the
// direct __dirname — the candidate list below is a superset and will
// still find templates when they live alongside the bundle.
}
const candidates = [
// Symlink-resolved binary (`npm i -g` / `gjsify install -g`): the
// realpath-aware candidate is required when the CLI is launched via a
// `~/.local/bin/<name>` symlink that points at the real package's
// `bin/<name>`. Try it first so the success path is symmetric across
// install modes.
resolve(realDirname, "..", "dist-templates"),
// Bundled production binary invoked at its real path (no symlink),
// or a tarball extracted into a flat `bin/` + `dist-templates/` layout.
resolve(__dirname, "..", "dist-templates"),
// Source layout (src/commands/create.ts): ../../dist-templates then ../../templates
resolve(__dirname, "..", "..", "dist-templates"),
resolve(__dirname, "..", "..", "templates"),
];
const seen = new Set<string>();
for (const path of candidates) {
if (seen.has(path)) continue;
seen.add(path);
if (existsSync(path)) return path;
}
throw new Error(
`Could not locate templates directory. Looked in:\n ${[...seen].join("\n ")}\n` +
"If you are running from source, make sure packages/cli/templates/ exists. " +
"If you are running the published CLI, make sure dist-templates/ was packed.",
);
}
function listTemplates(templatesRoot: string): CreateTemplateId[] {
const knownIds = TEMPLATE_CHOICES.map((c) => c.value) as readonly string[];
return readdirSync(templatesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name): name is CreateTemplateId => knownIds.includes(name));
}
function sanitizeProjectName(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) throw new Error("Project name cannot be empty");
// npm package name rules: lowercase letters, digits, dashes, underscores, dots; no leading dot/underscore
const cleaned = trimmed
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^[._-]+/, "")
.replace(/[._-]+$/, "");
if (!cleaned) throw new Error(`"${raw}" is not a valid npm package name`);
return cleaned;
}
function isDirEmpty(path: string): boolean {
if (!existsSync(path)) return true;
return readdirSync(path).length === 0;
}
function substituteInFile(filePath: string, projectName: string): void {
const content = readFileSync(filePath, "utf8");
if (!content.includes(PROJECT_NAME_PLACEHOLDER)) return;
writeFileSync(filePath, content.replaceAll(PROJECT_NAME_PLACEHOLDER, projectName));
}
function walkAndSubstitute(rootDir: string, projectName: string): void {
for (const entry of readdirSync(rootDir, { withFileTypes: true })) {
const full = join(rootDir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules") continue;
walkAndSubstitute(full, projectName);
continue;
}
if (!entry.isFile()) continue;
const ext = entry.name.slice(entry.name.lastIndexOf("."));
if (!TEXT_FILE_EXT.has(ext)) continue;
substituteInFile(full, projectName);
}
}
declare const __GJS_BUNDLE__: boolean | undefined;
const handler = async (args: ConfigFlags) => {
const opts = args as unknown as CreateCommandArgs;
const log = new Logger(opts.verbose ?? false, "CreateCommand");
let templatesRoot: string;
try {
templatesRoot = findTemplatesRoot();
} catch (err) {
// `dist-templates/` not next to the running file. Two known scenarios:
// 1. `install.js` (or any flow) deployed only the single-file GJS
// binary without the package tree. We can't scaffold without
// templates — point the user at the working install path.
// 2. Source-mode mis-checkout (no `packages/cli/templates/`). Surface
// the original error so the developer can fix their layout.
// The `__GJS_BUNDLE__` define lets us discriminate at build time.
if (typeof __GJS_BUNDLE__ !== "undefined") {
process.stderr.write(
"The 'create' command needs templates that aren't shipped alongside this binary.\n" +
"Install the full package instead so `dist-templates/` lives next to the bin:\n" +
" gjsify install -g @ts-for-gir/cli\n" +
" npm install -g @ts-for-gir/cli\n" +
" npx @ts-for-gir/cli create ... # one-shot, no install\n",
);
process.exitCode = 1;
return;
}
throw err;
}
const available = listTemplates(templatesRoot);
if (available.length === 0) {
throw new Error(`No templates found in ${templatesRoot}`);
}
let template: CreateTemplateId | undefined = opts.template;
if (template && !available.includes(template)) {
throw new Error(`Unknown template "${template}". Available: ${available.join(", ")}`);
}
if (!template) {
if (!process.stdin.isTTY) {
throw new Error(`--template is required (non-TTY). Available: ${available.join(", ")}`);
}
template = await select<CreateTemplateId>({
message: "Choose a template:",
choices: TEMPLATE_CHOICES.filter((c) => available.includes(c.value)).map((c) => ({
value: c.value,
name: c.name,
description: c.description,
})),
});
}
let nameRaw = opts.name;
if (!nameRaw) {
if (!process.stdin.isTTY) {
throw new Error("Project name is required (non-TTY). Pass it as the first positional argument.");
}
nameRaw = await input({
message: "Project name:",
default: "my-gjs-app",
validate: (v) => (v.trim().length > 0 ? true : "Project name cannot be empty"),
});
}
const projectName = sanitizeProjectName(nameRaw);
const targetDir = resolve(process.cwd(), projectName);
if (existsSync(targetDir) && !isDirEmpty(targetDir) && !opts.force) {
throw new Error(
`Target directory ${targetDir} exists and is not empty. Use --force to scaffold into a non-empty directory.`,
);
}
mkdirSync(targetDir, { recursive: true });
const templateDir = join(templatesRoot, template);
cpSync(templateDir, targetDir, { recursive: true });
walkAndSubstitute(targetDir, projectName);
log.success(`Scaffolded ${template} into ${targetDir}`);
if (opts.install) {
// types-gjsify is Node-free at runtime — bootstrap deps via `gjsify install`
// so the user's first impression matches the rest of the template's scripts.
// Other templates remain on npm (matches the prior behavior + their README).
const installer = template === "types-gjsify" ? "gjsify" : "npm";
const installerArgs = template === "types-gjsify" ? ["install"] : ["install", "--no-audit", "--no-fund"];
log.info(`Running ${installer} install...`);
const result = spawnSync(installer, installerArgs, {
cwd: targetDir,
stdio: "inherit",
});
if (result.status !== 0) {
log.warn(`${installer} install failed; you can re-run it manually in the project directory.`);
}
}
log.info("\nNext steps:");
log.white(` cd ${projectName}`);
if (!opts.install) {
if (template === "types-gjsify") {
log.white(" gjsify install");
} else {
log.white(" npm install");
}
}
switch (template) {
case "types-locally":
log.white(" npm run generate");
log.white(" npm run check:types");
log.white(" npm run build && npm start");
break;
case "types-npm":
log.white(" npm run check");
log.white(" npm run build && npm start");
break;
case "types-workspace":
log.white(" npm run build:types && npm install");
log.white(" npm run build:app && npm start");
break;
case "types-gjsify":
log.white(" gjsify run check");
log.white(" gjsify run build && gjsify run start");
break;
}
};
export const create = {
command,
description,
builder,
handler,
examples,
};