@kidd-cli/cli 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.
@@ -0,0 +1,167 @@
1
+ import { n as renderTemplate, t as writeFiles } from "../write-DDGnajpV.mjs";
2
+ import { command } from "@kidd-cli/core";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+
6
+ //#region src/commands/init.ts
7
+ const KEBAB_CASE_CHARS_RE = /^[a-z][\da-z-]*$/;
8
+ const initCommand = command({
9
+ args: z.object({
10
+ description: z.string().describe("Project description").optional(),
11
+ example: z.boolean().describe("Include example command").optional(),
12
+ name: z.string().describe("Project name (kebab-case)").optional(),
13
+ pm: z.enum([
14
+ "pnpm",
15
+ "yarn",
16
+ "npm"
17
+ ]).describe("Package manager").optional()
18
+ }),
19
+ description: "Scaffold a new kidd CLI project",
20
+ handler: async (ctx) => {
21
+ const projectName = await resolveProjectName(ctx);
22
+ const projectDescription = await resolveDescription(ctx);
23
+ const packageManager = await resolvePackageManager(ctx);
24
+ const includeExample = await resolveIncludeExample(ctx);
25
+ ctx.spinner.start("Scaffolding project...");
26
+ const [renderError, rendered] = await renderTemplate({
27
+ templateDir: join(import.meta.dirname, "..", "lib", "templates", "project"),
28
+ variables: {
29
+ description: projectDescription,
30
+ name: projectName,
31
+ packageManager
32
+ }
33
+ });
34
+ if (renderError) {
35
+ ctx.spinner.stop("Failed");
36
+ return ctx.fail(renderError.message);
37
+ }
38
+ const [writeError] = await writeFiles({
39
+ files: selectFiles(includeExample, rendered),
40
+ outputDir: join(process.cwd(), projectName),
41
+ overwrite: false
42
+ });
43
+ if (writeError) {
44
+ ctx.spinner.stop("Failed");
45
+ return ctx.fail(writeError.message);
46
+ }
47
+ ctx.spinner.stop("Project created!");
48
+ ctx.output.raw("");
49
+ ctx.output.raw(`Next steps:`);
50
+ ctx.output.raw(` cd ${projectName}`);
51
+ ctx.output.raw(` ${packageManager} install`);
52
+ }
53
+ });
54
+ /**
55
+ * Check whether a string is valid kebab-case.
56
+ *
57
+ * @param value - The string to validate.
58
+ * @returns True when the string is kebab-case.
59
+ * @private
60
+ */
61
+ function isKebabCase(value) {
62
+ if (!KEBAB_CASE_CHARS_RE.test(value)) return false;
63
+ if (value.endsWith("-")) return false;
64
+ if (value.includes("--")) return false;
65
+ return true;
66
+ }
67
+ /**
68
+ * Resolve the project name from args or prompt.
69
+ *
70
+ * @param ctx - Command context.
71
+ * @returns The validated project name.
72
+ * @private
73
+ */
74
+ async function resolveProjectName(ctx) {
75
+ if (ctx.args.name) {
76
+ if (!isKebabCase(ctx.args.name)) return ctx.fail("Project name must be kebab-case (e.g. my-cli)");
77
+ return ctx.args.name;
78
+ }
79
+ return ctx.prompts.text({
80
+ message: "Project name",
81
+ placeholder: "my-cli",
82
+ validate: (value) => {
83
+ if (value === void 0 || !isKebabCase(value)) return "Must be kebab-case (e.g. my-cli)";
84
+ }
85
+ });
86
+ }
87
+ /**
88
+ * Resolve the project description from args or prompt.
89
+ *
90
+ * @param ctx - Command context.
91
+ * @returns The project description string.
92
+ * @private
93
+ */
94
+ async function resolveDescription(ctx) {
95
+ if (ctx.args.description) return ctx.args.description;
96
+ return ctx.prompts.text({
97
+ defaultValue: "A CLI built with kidd",
98
+ message: "Description",
99
+ placeholder: "A CLI built with kidd"
100
+ });
101
+ }
102
+ /**
103
+ * Resolve the package manager from args or prompt.
104
+ *
105
+ * @param ctx - Command context.
106
+ * @returns The selected package manager.
107
+ * @private
108
+ */
109
+ async function resolvePackageManager(ctx) {
110
+ if (ctx.args.pm) return ctx.args.pm;
111
+ return ctx.prompts.select({
112
+ message: "Package manager",
113
+ options: [
114
+ {
115
+ label: "pnpm",
116
+ value: "pnpm"
117
+ },
118
+ {
119
+ label: "yarn",
120
+ value: "yarn"
121
+ },
122
+ {
123
+ label: "npm",
124
+ value: "npm"
125
+ }
126
+ ]
127
+ });
128
+ }
129
+ /**
130
+ * Resolve whether to include the example command from args or prompt.
131
+ *
132
+ * @param ctx - Command context.
133
+ * @returns True when the example hello command should be included.
134
+ * @private
135
+ */
136
+ async function resolveIncludeExample(ctx) {
137
+ if (ctx.args.example !== void 0 && ctx.args.example !== null) return ctx.args.example;
138
+ return ctx.prompts.confirm({
139
+ initialValue: true,
140
+ message: "Include example command?"
141
+ });
142
+ }
143
+ /**
144
+ * Select the rendered files to write, optionally excluding the example command.
145
+ *
146
+ * @param includeExample - Whether to include the example hello command.
147
+ * @param rendered - The full set of rendered files.
148
+ * @returns The filtered file list.
149
+ * @private
150
+ */
151
+ function selectFiles(includeExample, rendered) {
152
+ if (includeExample) return rendered;
153
+ return rendered.filter(excludeHelloCommand);
154
+ }
155
+ /**
156
+ * Filter predicate that excludes the hello.ts example command.
157
+ *
158
+ * @param file - A rendered file to check.
159
+ * @returns True when the file is not the hello command.
160
+ * @private
161
+ */
162
+ function excludeHelloCommand(file) {
163
+ return !file.relativePath.includes("commands/hello.ts");
164
+ }
165
+
166
+ //#endregion
167
+ export { initCommand as default };
@@ -0,0 +1,73 @@
1
+ import { join } from "node:path";
2
+ import { access, readFile } from "node:fs/promises";
3
+ import { attemptAsync, ok, toErrorMessage } from "@kidd-cli/utils/fp";
4
+
5
+ //#region src/lib/detect.ts
6
+ /**
7
+ * Detect whether the given directory contains a kidd-based CLI project.
8
+ *
9
+ * Looks for a `package.json` with `kidd` listed in `dependencies` or
10
+ * `devDependencies`, and checks for a `src/commands/` directory.
11
+ *
12
+ * @param cwd - The directory to inspect.
13
+ * @returns An async Result containing project info or null when no kidd project is found.
14
+ */
15
+ async function detectProject(cwd) {
16
+ const packageJsonPath = join(cwd, "package.json");
17
+ if (!await fileExists(packageJsonPath)) return ok(null);
18
+ const [readError, pkg] = await readPackageJson(packageJsonPath);
19
+ if (readError) return [readError, null];
20
+ const deps = pkg.dependencies ?? {};
21
+ const devDeps = pkg.devDependencies ?? {};
22
+ const hasKiddDep = "@kidd-cli/core" in deps || "@kidd-cli/core" in devDeps;
23
+ if (!hasKiddDep) return ok(null);
24
+ const commandsPath = join(cwd, "src", "commands");
25
+ if (await fileExists(commandsPath)) return ok({
26
+ commandsDir: commandsPath,
27
+ hasKiddDep,
28
+ rootDir: cwd
29
+ });
30
+ return ok({
31
+ commandsDir: null,
32
+ hasKiddDep,
33
+ rootDir: cwd
34
+ });
35
+ }
36
+ /**
37
+ * Read and parse a package.json file.
38
+ *
39
+ * @param filePath - Absolute path to the package.json.
40
+ * @returns A Result tuple with the parsed package data or a GenerateError.
41
+ * @private
42
+ */
43
+ async function readPackageJson(filePath) {
44
+ const [readError, content] = await attemptAsync(() => readFile(filePath, "utf8"));
45
+ if (readError || content === null || content === void 0) return [{
46
+ message: `Failed to read package.json: ${toErrorMessage(readError)}`,
47
+ path: filePath,
48
+ type: "read_error"
49
+ }, null];
50
+ try {
51
+ return ok(JSON.parse(content));
52
+ } catch (error) {
53
+ return [{
54
+ message: `Failed to parse package.json: ${toErrorMessage(error)}`,
55
+ path: filePath,
56
+ type: "read_error"
57
+ }, null];
58
+ }
59
+ }
60
+ /**
61
+ * Check whether a path exists on disk.
62
+ *
63
+ * @param filePath - The path to check.
64
+ * @returns True when the path is accessible, false otherwise.
65
+ * @private
66
+ */
67
+ async function fileExists(filePath) {
68
+ const [err] = await attemptAsync(() => access(filePath));
69
+ return err === null;
70
+ }
71
+
72
+ //#endregion
73
+ export { detectProject as t };
@@ -0,0 +1 @@
1
+ export { };
package/dist/index.mjs ADDED
@@ -0,0 +1,41 @@
1
+ import { cli } from "@kidd-cli/core";
2
+ import { join } from "node:path";
3
+ import { readManifest } from "@kidd-cli/utils/manifest";
4
+
5
+ //#region src/manifest.ts
6
+ /**
7
+ * Read and validate the CLI package manifest.
8
+ *
9
+ * Reads package.json one directory above `baseDir` (the dist output sits
10
+ * one level below the package root) and ensures all required fields are
11
+ * present. Throws immediately if the manifest cannot be read or any
12
+ * required field is missing — this is an unrecoverable entry-point guard.
13
+ *
14
+ * @param baseDir - The directory the CLI entry file lives in (typically `import.meta.dirname`).
15
+ * @returns A validated {@link CLIManifest} with all required fields.
16
+ */
17
+ async function loadCLIManifest(baseDir) {
18
+ const [manifestError, manifest] = await readManifest(join(baseDir, ".."));
19
+ if (manifestError) throw new Error(`Failed to read CLI manifest: ${manifestError.message}`);
20
+ if (!manifest.name) throw new Error("CLI manifest is missing required field: name");
21
+ if (!manifest.version) throw new Error("CLI manifest is missing required field: version");
22
+ if (!manifest.description) throw new Error("CLI manifest is missing required field: description");
23
+ return {
24
+ description: manifest.description,
25
+ name: manifest.name,
26
+ version: manifest.version
27
+ };
28
+ }
29
+
30
+ //#endregion
31
+ //#region src/index.ts
32
+ const manifest = await loadCLIManifest(import.meta.dirname);
33
+ await cli({
34
+ commands: `${import.meta.dirname}/commands`,
35
+ description: manifest.description,
36
+ name: manifest.name,
37
+ version: manifest.version
38
+ });
39
+
40
+ //#endregion
41
+ export { };
@@ -0,0 +1,12 @@
1
+ import { command } from '@kidd-cli/core'
2
+ {% if includeArgs %}import { z } from 'zod'
3
+ {% endif %}
4
+ export default command({
5
+ description: '{{ description }}',
6
+ {% if includeArgs %} args: z.object({
7
+ // Add your args here
8
+ }),
9
+ {% endif %} handler: async (ctx) => {
10
+ // Implement your command here
11
+ },
12
+ })
@@ -0,0 +1,9 @@
1
+ import { middleware } from '@kidd-cli/core'
2
+
3
+ /**
4
+ * {{ description }}
5
+ */
6
+ export default middleware(async (ctx, next) => {
7
+ // Implement your middleware here
8
+ await next()
9
+ })
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "{{ name }}",
3
+ "version": "0.0.0",
4
+ "description": "{{ description }}",
5
+ "keywords": [],
6
+ "license": "MIT",
7
+ "bin": {
8
+ "{{ name }}": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "type": "module",
14
+ "scripts": {
15
+ "build": "tsdown",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
19
+ },
20
+ "dependencies": {
21
+ "@kidd-cli/core": "^0.0.0",
22
+ "zod": "^3.24.0"
23
+ },
24
+ "devDependencies": {
25
+ "tsdown": "^0.21.0",
26
+ "typescript": "^5.7.0",
27
+ "vitest": "^4.0.0"
28
+ }
29
+ }
@@ -0,0 +1,12 @@
1
+ import { command } from '@kidd-cli/core'
2
+ import { z } from 'zod'
3
+
4
+ export default command({
5
+ description: 'Say hello',
6
+ args: z.object({
7
+ name: z.string().describe('Name to greet').default('world'),
8
+ }),
9
+ handler: async (ctx) => {
10
+ ctx.output.raw(`Hello, ${ctx.args.name}!`)
11
+ },
12
+ })
@@ -0,0 +1,8 @@
1
+ import { cli } from '@kidd-cli/core'
2
+
3
+ void cli({
4
+ name: '{{ name }}',
5
+ version: '0.0.0',
6
+ description: '{{ description }}',
7
+ commands: import.meta.dirname + '/commands',
8
+ })
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "isolatedModules": true,
8
+ "isolatedDeclarations": true,
9
+ "noEmit": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "declaration": true,
14
+ "sourceMap": true,
15
+ "outDir": "dist"
16
+ },
17
+ "include": ["src"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'tsdown'
2
+
3
+ export default defineConfig({
4
+ clean: true,
5
+ dts: true,
6
+ entry: ['src/index.ts'],
7
+ format: 'esm',
8
+ outDir: 'dist',
9
+ })
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/**/*.test.ts'],
6
+ passWithNoTests: true,
7
+ },
8
+ })
@@ -0,0 +1,166 @@
1
+ import { dirname, join, relative } from "node:path";
2
+ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import { attemptAsync, ok, toErrorMessage } from "@kidd-cli/utils/fp";
4
+ import { Liquid } from "liquidjs";
5
+
6
+ //#region src/lib/render.ts
7
+ /**
8
+ * Render all `.liquid` templates in a directory using LiquidJS.
9
+ *
10
+ * Recursively collects `.liquid` files under `templateDir`, renders each
11
+ * with the provided variables, and strips the `.liquid` extension from
12
+ * the output path. Files named `gitignore.liquid` are mapped to `.gitignore`.
13
+ *
14
+ * @param params - Template directory and variable bindings.
15
+ * @returns An async Result containing rendered files or a GenerateError.
16
+ */
17
+ async function renderTemplate(params) {
18
+ const engine = new Liquid({ root: params.templateDir });
19
+ const entries = await collectLiquidFiles(params.templateDir);
20
+ if (entries.length === 0) return ok([]);
21
+ const results = await Promise.all(entries.map(async (entry) => {
22
+ const [renderError, content] = await renderSingleFile(engine, join(params.templateDir, entry), params.variables);
23
+ if (renderError) return renderError;
24
+ return {
25
+ content,
26
+ relativePath: mapOutputPath(entry)
27
+ };
28
+ }));
29
+ const firstError = results.find(isGenerateError);
30
+ if (firstError) return [firstError, null];
31
+ return ok(results);
32
+ }
33
+ /**
34
+ * Recursively collect all `.liquid` file paths relative to root.
35
+ *
36
+ * @param root - The directory to scan.
37
+ * @returns Relative paths of all `.liquid` files.
38
+ * @private
39
+ */
40
+ async function collectLiquidFiles(root) {
41
+ return (await readdir(root, {
42
+ recursive: true,
43
+ withFileTypes: true
44
+ })).filter((entry) => entry.isFile() && entry.name.endsWith(".liquid")).map((entry) => {
45
+ const parent = entry.parentPath;
46
+ return relative(root, join(parent, entry.name));
47
+ });
48
+ }
49
+ /**
50
+ * Render a single `.liquid` file with the given variables.
51
+ *
52
+ * @param engine - The LiquidJS engine instance.
53
+ * @param absolutePath - Absolute path to the `.liquid` file.
54
+ * @param variables - Template variable bindings.
55
+ * @returns A Result tuple with the rendered content or a GenerateError.
56
+ * @private
57
+ */
58
+ async function renderSingleFile(engine, absolutePath, variables) {
59
+ try {
60
+ const template = await readFile(absolutePath, "utf8");
61
+ return ok(await engine.parseAndRender(template, variables));
62
+ } catch (error) {
63
+ return [{
64
+ message: `Failed to render template: ${toErrorMessage(error)}`,
65
+ path: absolutePath,
66
+ type: "render_error"
67
+ }, null];
68
+ }
69
+ }
70
+ /**
71
+ * Map a `.liquid` relative path to its output path.
72
+ *
73
+ * Strips the `.liquid` extension and renames bare `gitignore` segments
74
+ * to `.gitignore` so dotfiles survive version control.
75
+ *
76
+ * @param liquidPath - Relative path ending in `.liquid`.
77
+ * @returns The output-relative path without the `.liquid` suffix.
78
+ * @private
79
+ */
80
+ function mapOutputPath(liquidPath) {
81
+ return liquidPath.replace(/\.liquid$/, "").replaceAll(/(^|\/)gitignore($|\/)/g, "$1.gitignore$2");
82
+ }
83
+ /**
84
+ * Type guard for GenerateError objects.
85
+ *
86
+ * @param value - The value to check.
87
+ * @returns True when value has a `type` and `message` property matching GenerateError.
88
+ * @private
89
+ */
90
+ function isGenerateError(value) {
91
+ if (typeof value !== "object" || value === null) return false;
92
+ return "type" in value && "message" in value;
93
+ }
94
+
95
+ //#endregion
96
+ //#region src/lib/write.ts
97
+ /**
98
+ * Write rendered files to disk with optional conflict detection.
99
+ *
100
+ * For each file, resolves the target path under `outputDir`, creates parent
101
+ * directories as needed, and writes the content. When `overwrite` is false,
102
+ * existing files are skipped rather than overwritten.
103
+ *
104
+ * @param params - Files to write, target directory, and overwrite flag.
105
+ * @returns An async Result with counts of written/skipped files or a GenerateError.
106
+ */
107
+ async function writeFiles(params) {
108
+ const written = [];
109
+ const skipped = [];
110
+ const results = await Promise.all(params.files.map((file) => writeSingleFile(file, params.outputDir, params.overwrite)));
111
+ const firstError = results.find((r) => r[0] !== null);
112
+ if (firstError) return [firstError[0], null];
113
+ const validStatuses = results.filter((r) => r[1] !== null);
114
+ const writtenPaths = validStatuses.filter(([, status]) => status.action === "written").map(([, status]) => status.path);
115
+ const skippedPaths = validStatuses.filter(([, status]) => status.action === "skipped").map(([, status]) => status.path);
116
+ writtenPaths.map((p) => written.push(p));
117
+ skippedPaths.map((p) => skipped.push(p));
118
+ return ok({
119
+ skipped,
120
+ written
121
+ });
122
+ }
123
+ /**
124
+ * Write a single rendered file to disk.
125
+ *
126
+ * @param file - The rendered file to write.
127
+ * @param outputDir - The root output directory.
128
+ * @param overwrite - Whether to overwrite existing files.
129
+ * @returns A Result tuple with the write status or a GenerateError.
130
+ * @private
131
+ */
132
+ async function writeSingleFile(file, outputDir, overwrite) {
133
+ const targetPath = join(outputDir, file.relativePath);
134
+ try {
135
+ if (await fileExists(targetPath) && !overwrite) return ok({
136
+ action: "skipped",
137
+ path: file.relativePath
138
+ });
139
+ await mkdir(dirname(targetPath), { recursive: true });
140
+ await writeFile(targetPath, file.content, "utf8");
141
+ return ok({
142
+ action: "written",
143
+ path: file.relativePath
144
+ });
145
+ } catch (error) {
146
+ return [{
147
+ message: `Failed to write file: ${toErrorMessage(error)}`,
148
+ path: targetPath,
149
+ type: "write_error"
150
+ }, null];
151
+ }
152
+ }
153
+ /**
154
+ * Check whether a path exists on disk.
155
+ *
156
+ * @param filePath - The path to check.
157
+ * @returns True when the path is accessible, false otherwise.
158
+ * @private
159
+ */
160
+ async function fileExists(filePath) {
161
+ const [err] = await attemptAsync(() => access(filePath));
162
+ return err === null;
163
+ }
164
+
165
+ //#endregion
166
+ export { renderTemplate as n, writeFiles as t };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@kidd-cli/cli",
3
+ "version": "0.1.0",
4
+ "description": "DX companion CLI for the kidd framework",
5
+ "keywords": [
6
+ "cli",
7
+ "codegen",
8
+ "kidd",
9
+ "scaffolding"
10
+ ],
11
+ "license": "MIT",
12
+ "bin": {
13
+ "kidd": "./dist/index.mjs"
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "type": "module",
19
+ "dependencies": {
20
+ "fs-extra": "^11.3.3",
21
+ "liquidjs": "^10.24.0",
22
+ "picocolors": "^1.1.1",
23
+ "zod": "^4.3.6",
24
+ "@kidd-cli/bundler": "0.1.0",
25
+ "@kidd-cli/core": "0.1.0",
26
+ "@kidd-cli/utils": "0.1.0",
27
+ "@kidd-cli/config": "0.1.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/fs-extra": "^11.0.4",
31
+ "tsdown": "0.21.0-beta.2",
32
+ "typescript": "^5.9.3",
33
+ "vitest": "^4.0.18"
34
+ },
35
+ "scripts": {
36
+ "build": "tsdown && mkdir -p dist/lib && cp -r src/lib/templates dist/lib/templates",
37
+ "typecheck": "tsgo --noEmit",
38
+ "lint": "oxlint --ignore-pattern node_modules",
39
+ "lint:fix": "oxlint --fix --ignore-pattern node_modules",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest"
42
+ }
43
+ }