clispark 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martin Mohn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # clispark
2
+
3
+ Interactive scaffolding tool for new CLI projects. Run `npx clispark` to generate a new, ready-to-run TypeScript CLI project with consistent logging, error handling, and command structure — no manual setup required.
4
+
5
+ ## Status
6
+
7
+ 🚧 **Work in progress — not yet published to npm.**
8
+
9
+ | Milestone | Description | Status |
10
+ | --- | --- | --- |
11
+ | M1 | Generator scaffold (wizard, package-name availability check) | ✅ Done |
12
+ | M2 | Project-scaffold engine (file generation, git init, install & build) | ✅ Done |
13
+ | M2.5 | Generator's own logging & error handling (dogfooding) | ✅ Done |
14
+ | M3 | Core runtime features in generated boilerplate (auto command registration, logging, error handling, testing, example command) | ✅ Done |
15
+ | M4 | Private registry support (`.npmrc` generation, wired into scaffold's own `npm install`) | ✅ Done |
16
+ | M5 | Documentation (`ARCHITECTURE.md`) & release automation (CI, Conventional-Commits versioning, npm publish pipeline) | ✅ Done — first real release pending |
17
+ | M6 | Update mechanism for already-generated projects | 🔜 Next |
18
+
19
+ ## Usage
20
+
21
+ Once published, running the generator will look like this:
22
+
23
+ ```bash
24
+ npx clispark
25
+ ```
26
+
27
+ The wizard asks a few questions (project name, work/private profile, registry URL if applicable), checks the chosen package name's availability, then scaffolds a new directory with a ready-to-run project — `git init`, `npm install`, and `npm run build` all happen automatically. If a custom registry URL was given, an `.npmrc` pointing at it is generated too, so the install (and every future one) uses it automatically.
28
+
29
+ ## What you get
30
+
31
+ Every generated project includes:
32
+
33
+ - **oclif-based CLI structure** with convention-based command discovery — drop a file in `src/commands/`, no manual registration needed
34
+ - **Structured logging** (`pino`, one log file per invocation in an OS-appropriate log directory) that automatically covers every command
35
+ - **Consistent error handling** with no opt-out — clean `Error: <message>` output on failure, full stack trace captured in the log file
36
+ - **A working test setup** (`vitest` + `@oclif/test`) with an example test to copy from
37
+ - **A first example command** (`hello`) as a starting point for your own commands
38
+ - **A clean build pipeline** (`tsup`) producing a directly runnable binary
39
+
40
+ ## Tech stack
41
+
42
+ **Generator itself (`clispark`):** TypeScript, [commander](https://github.com/tj/commander.js) (CLI structure), [@clack/prompts](https://github.com/bombshell-dev/clack) (interactive wizard), `cross-spawn` (cross-platform shelling out to git/npm), `pino` + `env-paths` (own logging), `tsup` + `vitest`.
43
+
44
+ **Generated boilerplate:** TypeScript, [oclif](https://oclif.io/) (command framework), `pino` + `env-paths` (logging), `tsup` (build), `vitest` + `@oclif/test` (testing).
45
+
46
+ ## Releases
47
+
48
+ Releases are automated via [release-please](https://github.com/googleapis/release-please): every commit to `master` follows [Conventional Commits](https://www.conventionalcommits.org/) (`feat:` → minor, `fix:` → patch, `BREAKING CHANGE` → major), and release-please maintains a running "Release PR" with the version bump and changelog. Merging that PR creates a GitHub Release, which triggers the `publish.yml` workflow: it re-runs the full CI suite (tests, typecheck, build, security audit, a scaffold smoke test) against the release commit, then publishes to npm.
49
+
50
+ CI (`ci.yml`) runs on every push/PR: unit tests, typecheck, build, `npm audit --audit-level=high` (blocking on high/critical findings, which are also tracked as GitHub issues), and an end-to-end scaffold smoke test that generates a real project and runs its own test suite — the same kind of check previously done manually for each milestone.
51
+
52
+ ## Development notes
53
+
54
+ This project is being built with the help of [Claude](https://claude.com/claude-code). Implementation plans are written before coding starts and committed alongside the code under [`docs/superpowers/plans/`](docs/superpowers/plans/), so the reasoning and step-by-step approach behind each milestone stays visible in version control.
55
+
56
+ Planning and execution follow the [Superpowers](https://github.com/obra/superpowers) skill set for Claude Code (brainstorming → writing-plans → subagent-driven-development) — credit to [obra](https://github.com/obra) for that workflow.
57
+
58
+ ## License
59
+
60
+ [MIT](LICENSE)
package/dist/cli.js ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { createRequire } from "module";
5
+ import path3 from "path";
6
+ import { Command } from "commander";
7
+
8
+ // src/wizard.ts
9
+ import { intro, outro, text, select, log, isCancel, cancel } from "@clack/prompts";
10
+
11
+ // src/registry.ts
12
+ var DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
13
+ async function checkPackageNameAvailability(name, registryUrl = DEFAULT_REGISTRY_URL) {
14
+ const url = `${registryUrl.replace(/\/$/, "")}/${encodeURIComponent(name)}`;
15
+ try {
16
+ const response = await fetch(url);
17
+ if (response.status === 404) return "available";
18
+ if (response.status === 200) return "taken";
19
+ return "unverified";
20
+ } catch {
21
+ return "unverified";
22
+ }
23
+ }
24
+
25
+ // src/wizard.ts
26
+ var defaultDeps = {
27
+ checkAvailability: checkPackageNameAvailability
28
+ };
29
+ function exitIfCancelled(value) {
30
+ if (isCancel(value)) {
31
+ cancel("Operation cancelled.");
32
+ process.exit(1);
33
+ }
34
+ }
35
+ function validateProjectName(value) {
36
+ if (!value || value.trim().length === 0) return "Project name is required.";
37
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
38
+ return "Use lowercase letters and numbers, with single hyphens between words (no leading, trailing, or repeated hyphens).";
39
+ }
40
+ return void 0;
41
+ }
42
+ async function runWizard(deps = defaultDeps) {
43
+ intro("clispark \u2014 scaffold a new CLI project");
44
+ const nameValue = await text({
45
+ message: "Project name",
46
+ validate: validateProjectName
47
+ });
48
+ exitIfCancelled(nameValue);
49
+ let projectName = nameValue;
50
+ const profileValue = await select({
51
+ message: "Is this a work or private project?",
52
+ options: [
53
+ { value: "private", label: "Private" },
54
+ { value: "work", label: "Work" }
55
+ ]
56
+ });
57
+ exitIfCancelled(profileValue);
58
+ const profile = profileValue;
59
+ let registryUrl = DEFAULT_REGISTRY_URL;
60
+ if (profile === "work") {
61
+ const registryValue = await text({
62
+ message: "Custom npm registry URL (leave empty for npmjs.org)",
63
+ placeholder: DEFAULT_REGISTRY_URL,
64
+ defaultValue: DEFAULT_REGISTRY_URL
65
+ });
66
+ exitIfCancelled(registryValue);
67
+ registryUrl = registryValue || DEFAULT_REGISTRY_URL;
68
+ }
69
+ let nameAvailability = await deps.checkAvailability(projectName, registryUrl);
70
+ while (nameAvailability === "taken") {
71
+ log.warn(`"${projectName}" is already taken on ${registryUrl}. Please choose a different name.`);
72
+ const retryValue = await text({
73
+ message: "Project name",
74
+ validate: validateProjectName
75
+ });
76
+ exitIfCancelled(retryValue);
77
+ projectName = retryValue;
78
+ nameAvailability = await deps.checkAvailability(projectName, registryUrl);
79
+ }
80
+ if (nameAvailability === "unverified") {
81
+ log.warn(`Could not verify availability of "${projectName}" on ${registryUrl}. Continuing anyway.`);
82
+ }
83
+ outro(`Ready to scaffold "${projectName}".`);
84
+ return { projectName, profile, registryUrl, nameAvailability };
85
+ }
86
+
87
+ // src/scaffold.ts
88
+ import spawn from "cross-spawn";
89
+ import { fileURLToPath } from "url";
90
+ import path from "path";
91
+ import { cp, readdir, readFile, rename, writeFile } from "fs/promises";
92
+ var TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "templates", "base");
93
+ async function assertTargetDirIsUsable(targetDir) {
94
+ let entries;
95
+ try {
96
+ entries = await readdir(targetDir);
97
+ } catch {
98
+ return;
99
+ }
100
+ if (entries.length > 0) {
101
+ throw new Error(`Directory "${targetDir}" already exists and is not empty.`);
102
+ }
103
+ }
104
+ async function replacePlaceholder(filePath, projectName) {
105
+ const content = (await readFile(filePath, "utf8")).replaceAll("{{projectName}}", projectName);
106
+ await writeFile(filePath, content);
107
+ }
108
+ async function copyTemplate(options) {
109
+ const { projectName, targetDir, registryUrl } = options;
110
+ await assertTargetDirIsUsable(targetDir);
111
+ await cp(TEMPLATE_DIR, targetDir, { recursive: true });
112
+ await rename(path.join(targetDir, "gitignore"), path.join(targetDir, ".gitignore"));
113
+ await replacePlaceholder(path.join(targetDir, "package.json"), projectName);
114
+ await replacePlaceholder(path.join(targetDir, "README.md"), projectName);
115
+ await replacePlaceholder(path.join(targetDir, "src", "logger.ts"), projectName);
116
+ await replacePlaceholder(path.join(targetDir, "ARCHITECTURE.md"), projectName);
117
+ if (registryUrl && registryUrl !== DEFAULT_REGISTRY_URL) {
118
+ await writeFile(path.join(targetDir, ".npmrc"), `registry=${registryUrl}
119
+ `);
120
+ }
121
+ }
122
+ async function defaultRunCommand(command, args, cwd) {
123
+ await new Promise((resolve, reject) => {
124
+ const child = spawn(command, args, { cwd, stdio: "inherit" });
125
+ child.on("error", reject);
126
+ child.on("close", (code) => {
127
+ if (code === 0) {
128
+ resolve();
129
+ } else {
130
+ reject(new Error(`Command "${command} ${args.join(" ")}" exited with code ${code}`));
131
+ }
132
+ });
133
+ });
134
+ }
135
+ var defaultScaffoldDeps = { runCommand: defaultRunCommand };
136
+ async function scaffoldProject(options, deps = defaultScaffoldDeps) {
137
+ await copyTemplate(options);
138
+ const { targetDir } = options;
139
+ await deps.runCommand("git", ["init"], targetDir);
140
+ await deps.runCommand("git", ["add", "-A"], targetDir);
141
+ await deps.runCommand("git", ["commit", "-m", "chore: initial scaffold from clispark"], targetDir);
142
+ await deps.runCommand("npm", ["install"], targetDir);
143
+ await deps.runCommand("npm", ["run", "build"], targetDir);
144
+ }
145
+
146
+ // src/logger.ts
147
+ import { randomBytes } from "crypto";
148
+ import { mkdirSync } from "fs";
149
+ import path2 from "path";
150
+ import envPaths from "env-paths";
151
+ import pino from "pino";
152
+ var paths = envPaths("clispark", { suffix: "" });
153
+ function buildLogFileName(commandName) {
154
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "-");
155
+ const suffix = randomBytes(3).toString("hex");
156
+ return `${commandName}-${timestamp}-${suffix}.log`;
157
+ }
158
+ function createLogger(commandName, logDir = paths.log) {
159
+ mkdirSync(logDir, { recursive: true });
160
+ const logFilePath = path2.join(logDir, buildLogFileName(commandName));
161
+ const logger = pino(pino.destination({ dest: logFilePath, sync: true }));
162
+ return { logger, logFilePath };
163
+ }
164
+ function withLogging(commandName, action, logDir = paths.log) {
165
+ return async () => {
166
+ let handle;
167
+ try {
168
+ handle = createLogger(commandName, logDir);
169
+ } catch (error) {
170
+ const message = error instanceof Error ? error.message : String(error);
171
+ console.error(`
172
+ \u2716 ${message}`);
173
+ process.exit(1);
174
+ return;
175
+ }
176
+ const { logger, logFilePath } = handle;
177
+ logger.info({ command: commandName }, "started");
178
+ try {
179
+ await action(logger);
180
+ logger.info({ command: commandName }, "completed");
181
+ } catch (error) {
182
+ logger.error({ command: commandName, err: error }, "failed");
183
+ const message = error instanceof Error ? error.message : String(error);
184
+ console.error(`
185
+ \u2716 ${message}`);
186
+ console.error(`Details: ${logFilePath}`);
187
+ process.exit(1);
188
+ }
189
+ };
190
+ }
191
+
192
+ // src/cli.ts
193
+ var require2 = createRequire(import.meta.url);
194
+ var pkg = require2("../package.json");
195
+ var program = new Command();
196
+ program.name("clispark").description("Interactive scaffolding tool for new CLI projects").version(pkg.version);
197
+ program.action(
198
+ withLogging("scaffold", async (logger) => {
199
+ const answers = await runWizard();
200
+ const targetDir = path3.join(process.cwd(), answers.projectName);
201
+ logger.info({ projectName: answers.projectName, targetDir }, "scaffold started");
202
+ await scaffoldProject({ projectName: answers.projectName, targetDir, registryUrl: answers.registryUrl });
203
+ logger.info({ projectName: answers.projectName }, "scaffold completed");
204
+ console.log(`
205
+ Done! Your new CLI project is ready at ${targetDir}`);
206
+ })
207
+ );
208
+ program.parseAsync(process.argv).catch((error) => {
209
+ console.error(error);
210
+ process.exit(1);
211
+ });
212
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts","../src/wizard.ts","../src/registry.ts","../src/scaffold.ts","../src/logger.ts"],"sourcesContent":["// src/cli.ts\r\nimport { createRequire } from 'node:module';\r\nimport path from 'node:path';\r\nimport { Command } from 'commander';\r\nimport { runWizard } from './wizard.js';\r\nimport { scaffoldProject } from './scaffold.js';\r\nimport { withLogging } from './logger.js';\r\n\r\nconst require = createRequire(import.meta.url);\r\nconst pkg = require('../package.json') as { version: string };\r\n\r\nconst program = new Command();\r\n\r\nprogram\r\n .name('clispark')\r\n .description('Interactive scaffolding tool for new CLI projects')\r\n .version(pkg.version);\r\n\r\nprogram.action(\r\n withLogging('scaffold', async (logger) => {\r\n const answers = await runWizard();\r\n const targetDir = path.join(process.cwd(), answers.projectName);\r\n\r\n logger.info({ projectName: answers.projectName, targetDir }, 'scaffold started');\r\n await scaffoldProject({ projectName: answers.projectName, targetDir, registryUrl: answers.registryUrl });\r\n logger.info({ projectName: answers.projectName }, 'scaffold completed');\r\n\r\n console.log(`\\nDone! Your new CLI project is ready at ${targetDir}`);\r\n }),\r\n);\r\n\r\nprogram.parseAsync(process.argv).catch((error: unknown) => {\r\n console.error(error);\r\n process.exit(1);\r\n});\r\n","// src/wizard.ts\r\nimport { intro, outro, text, select, log, isCancel, cancel } from '@clack/prompts';\r\nimport { checkPackageNameAvailability, DEFAULT_REGISTRY_URL } from './registry.js';\r\nimport type { Profile, WizardAnswers } from './types.js';\r\n\r\nexport interface WizardDeps {\r\n checkAvailability: typeof checkPackageNameAvailability;\r\n}\r\n\r\nconst defaultDeps: WizardDeps = {\r\n checkAvailability: checkPackageNameAvailability,\r\n};\r\n\r\nfunction exitIfCancelled(value: unknown): void {\r\n if (isCancel(value)) {\r\n cancel('Operation cancelled.');\r\n process.exit(1);\r\n }\r\n}\r\n\r\nfunction validateProjectName(value: string): string | undefined {\r\n if (!value || value.trim().length === 0) return 'Project name is required.';\r\n if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {\r\n return 'Use lowercase letters and numbers, with single hyphens between words (no leading, trailing, or repeated hyphens).';\r\n }\r\n return undefined;\r\n}\r\n\r\nexport async function runWizard(deps: WizardDeps = defaultDeps): Promise<WizardAnswers> {\r\n intro('clispark — scaffold a new CLI project');\r\n\r\n const nameValue = await text({\r\n message: 'Project name',\r\n validate: validateProjectName,\r\n });\r\n exitIfCancelled(nameValue);\r\n let projectName = nameValue as string;\r\n\r\n const profileValue = await select({\r\n message: 'Is this a work or private project?',\r\n options: [\r\n { value: 'private', label: 'Private' },\r\n { value: 'work', label: 'Work' },\r\n ],\r\n });\r\n exitIfCancelled(profileValue);\r\n const profile = profileValue as Profile;\r\n\r\n let registryUrl = DEFAULT_REGISTRY_URL;\r\n if (profile === 'work') {\r\n const registryValue = await text({\r\n message: 'Custom npm registry URL (leave empty for npmjs.org)',\r\n placeholder: DEFAULT_REGISTRY_URL,\r\n defaultValue: DEFAULT_REGISTRY_URL,\r\n });\r\n exitIfCancelled(registryValue);\r\n registryUrl = (registryValue as string) || DEFAULT_REGISTRY_URL;\r\n }\r\n\r\n let nameAvailability = await deps.checkAvailability(projectName, registryUrl);\r\n\r\n while (nameAvailability === 'taken') {\r\n log.warn(`\"${projectName}\" is already taken on ${registryUrl}. Please choose a different name.`);\r\n\r\n const retryValue = await text({\r\n message: 'Project name',\r\n validate: validateProjectName,\r\n });\r\n exitIfCancelled(retryValue);\r\n projectName = retryValue as string;\r\n\r\n nameAvailability = await deps.checkAvailability(projectName, registryUrl);\r\n }\r\n\r\n if (nameAvailability === 'unverified') {\r\n log.warn(`Could not verify availability of \"${projectName}\" on ${registryUrl}. Continuing anyway.`);\r\n }\r\n\r\n outro(`Ready to scaffold \"${projectName}\".`);\r\n\r\n return { projectName, profile, registryUrl, nameAvailability };\r\n}\r\n","export const DEFAULT_REGISTRY_URL = 'https://registry.npmjs.org';\r\n\r\nexport type NameCheckResult = 'available' | 'taken' | 'unverified';\r\n\r\nexport async function checkPackageNameAvailability(\r\n name: string,\r\n registryUrl: string = DEFAULT_REGISTRY_URL,\r\n): Promise<NameCheckResult> {\r\n const url = `${registryUrl.replace(/\\/$/, '')}/${encodeURIComponent(name)}`;\r\n\r\n try {\r\n const response = await fetch(url);\r\n if (response.status === 404) return 'available';\r\n if (response.status === 200) return 'taken';\r\n return 'unverified';\r\n } catch {\r\n return 'unverified';\r\n }\r\n}\r\n","import spawn from 'cross-spawn';\r\nimport { fileURLToPath } from 'node:url';\r\nimport path from 'node:path';\r\nimport { cp, readdir, readFile, rename, writeFile } from 'node:fs/promises';\r\nimport { DEFAULT_REGISTRY_URL } from './registry.js';\r\n\r\nexport interface ScaffoldOptions {\r\n projectName: string;\r\n targetDir: string;\r\n registryUrl?: string;\r\n}\r\n\r\nconst TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates', 'base');\r\n\r\nasync function assertTargetDirIsUsable(targetDir: string): Promise<void> {\r\n let entries: string[];\r\n try {\r\n entries = await readdir(targetDir);\r\n } catch {\r\n return;\r\n }\r\n if (entries.length > 0) {\r\n throw new Error(`Directory \"${targetDir}\" already exists and is not empty.`);\r\n }\r\n}\r\n\r\nasync function replacePlaceholder(filePath: string, projectName: string): Promise<void> {\r\n const content = (await readFile(filePath, 'utf8')).replaceAll('{{projectName}}', projectName);\r\n await writeFile(filePath, content);\r\n}\r\n\r\nexport async function copyTemplate(options: ScaffoldOptions): Promise<void> {\r\n const { projectName, targetDir, registryUrl } = options;\r\n\r\n await assertTargetDirIsUsable(targetDir);\r\n await cp(TEMPLATE_DIR, targetDir, { recursive: true });\r\n\r\n await rename(path.join(targetDir, 'gitignore'), path.join(targetDir, '.gitignore'));\r\n\r\n await replacePlaceholder(path.join(targetDir, 'package.json'), projectName);\r\n await replacePlaceholder(path.join(targetDir, 'README.md'), projectName);\r\n await replacePlaceholder(path.join(targetDir, 'src', 'logger.ts'), projectName);\r\n await replacePlaceholder(path.join(targetDir, 'ARCHITECTURE.md'), projectName);\r\n\r\n if (registryUrl && registryUrl !== DEFAULT_REGISTRY_URL) {\r\n await writeFile(path.join(targetDir, '.npmrc'), `registry=${registryUrl}\\n`);\r\n }\r\n}\r\n\r\nexport interface ScaffoldDeps {\r\n runCommand: (command: string, args: string[], cwd: string) => Promise<void>;\r\n}\r\n\r\nasync function defaultRunCommand(command: string, args: string[], cwd: string): Promise<void> {\r\n await new Promise<void>((resolve, reject) => {\r\n const child = spawn(command, args, { cwd, stdio: 'inherit' });\r\n child.on('error', reject);\r\n child.on('close', (code) => {\r\n if (code === 0) {\r\n resolve();\r\n } else {\r\n reject(new Error(`Command \"${command} ${args.join(' ')}\" exited with code ${code}`));\r\n }\r\n });\r\n });\r\n}\r\n\r\nconst defaultScaffoldDeps: ScaffoldDeps = { runCommand: defaultRunCommand };\r\n\r\nexport async function scaffoldProject(\r\n options: ScaffoldOptions,\r\n deps: ScaffoldDeps = defaultScaffoldDeps,\r\n): Promise<void> {\r\n await copyTemplate(options);\r\n\r\n const { targetDir } = options;\r\n await deps.runCommand('git', ['init'], targetDir);\r\n await deps.runCommand('git', ['add', '-A'], targetDir);\r\n await deps.runCommand('git', ['commit', '-m', 'chore: initial scaffold from clispark'], targetDir);\r\n await deps.runCommand('npm', ['install'], targetDir);\r\n await deps.runCommand('npm', ['run', 'build'], targetDir);\r\n}\r\n","import { randomBytes } from 'node:crypto';\r\nimport { mkdirSync } from 'node:fs';\r\nimport path from 'node:path';\r\nimport envPaths from 'env-paths';\r\nimport pino, { type Logger } from 'pino';\r\n\r\nconst paths = envPaths('clispark', { suffix: '' });\r\n\r\nexport interface LoggerHandle {\r\n logger: Logger;\r\n logFilePath: string;\r\n}\r\n\r\nfunction buildLogFileName(commandName: string): string {\r\n const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');\r\n const suffix = randomBytes(3).toString('hex');\r\n return `${commandName}-${timestamp}-${suffix}.log`;\r\n}\r\n\r\nexport function createLogger(commandName: string, logDir: string = paths.log): LoggerHandle {\r\n mkdirSync(logDir, { recursive: true });\r\n\r\n const logFilePath = path.join(logDir, buildLogFileName(commandName));\r\n const logger = pino(pino.destination({ dest: logFilePath, sync: true }));\r\n\r\n return { logger, logFilePath };\r\n}\r\n\r\nexport function withLogging(\r\n commandName: string,\r\n action: (logger: Logger) => Promise<void>,\r\n logDir: string = paths.log,\r\n): () => Promise<void> {\r\n return async () => {\r\n let handle: LoggerHandle;\r\n try {\r\n handle = createLogger(commandName, logDir);\r\n } catch (error) {\r\n const message = error instanceof Error ? error.message : String(error);\r\n console.error(`\\n✖ ${message}`);\r\n process.exit(1);\r\n return;\r\n }\r\n\r\n const { logger, logFilePath } = handle;\r\n logger.info({ command: commandName }, 'started');\r\n\r\n try {\r\n await action(logger);\r\n logger.info({ command: commandName }, 'completed');\r\n } catch (error) {\r\n logger.error({ command: commandName, err: error }, 'failed');\r\n const message = error instanceof Error ? error.message : String(error);\r\n console.error(`\\n✖ ${message}`);\r\n console.error(`Details: ${logFilePath}`);\r\n process.exit(1);\r\n }\r\n };\r\n}\r\n"],"mappings":"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","names":["path","path","require","path"]}
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "clispark",
3
+ "version": "1.0.0",
4
+ "description": "Interactive scaffolding tool for new CLI projects",
5
+ "keywords": [
6
+ "cli",
7
+ "scaffolding",
8
+ "generator",
9
+ "boilerplate",
10
+ "starter",
11
+ "oclif",
12
+ "typescript"
13
+ ],
14
+ "homepage": "https://github.com/martinwichner/clispark#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/martinwichner/clispark/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/martinwichner/clispark.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Martin Mohn <martin.wichner@googlemail.com>",
24
+ "type": "module",
25
+ "bin": {
26
+ "clispark": "./dist/cli.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "templates",
31
+ "LICENSE",
32
+ "README.md"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "scripts": {
38
+ "build": "tsup",
39
+ "postbuild": "shx chmod +x dist/cli.js",
40
+ "dev": "tsx src/cli.ts",
41
+ "test": "vitest run",
42
+ "typecheck": "tsc --noEmit"
43
+ },
44
+ "dependencies": {
45
+ "@clack/prompts": "^0.9.1",
46
+ "commander": "^13.1.0",
47
+ "cross-spawn": "^7.0.3",
48
+ "env-paths": "^3.0.0",
49
+ "pino": "^9.6.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/cross-spawn": "^6.0.6",
53
+ "@types/node": "^22.10.5",
54
+ "shx": "^0.3.4",
55
+ "tsup": "^8.3.5",
56
+ "tsx": "^4.19.2",
57
+ "typescript": "^5.7.2",
58
+ "vitest": "^4.1.10"
59
+ }
60
+ }
@@ -0,0 +1,36 @@
1
+ # {{projectName}} Architecture
2
+
3
+ This document explains the conventions this project was generated with, so the automatic behavior (command discovery, logging, error handling) doesn't feel like unexplained magic.
4
+
5
+ ## Commands
6
+
7
+ Every command lives in `src/commands/` and extends `BaseCommand` (`src/base-command.ts`) instead of oclif's own `Command` class directly:
8
+
9
+ ```ts
10
+ import { BaseCommand } from '../base-command.js';
11
+
12
+ export default class MyCommand extends BaseCommand {
13
+ static description = 'What this command does';
14
+ static args = {};
15
+ static flags = {};
16
+
17
+ async run(): Promise<void> {
18
+ await this.parse(MyCommand);
19
+ // ...
20
+ }
21
+ }
22
+ ```
23
+
24
+ `BaseCommand` overrides oclif's `init()`/`catch()`/`finally()` lifecycle methods to log every command's start, failure, and completion automatically — no manual logging calls needed inside `run()`.
25
+
26
+ ## Command Discovery
27
+
28
+ oclif discovers commands at runtime from the `oclif.commands` path in `package.json` (`./dist/commands`) — there is no custom filesystem-scanning code. Dropping a new file in `src/commands/` and building the project is enough for oclif to pick it up; nothing needs to be manually registered.
29
+
30
+ ## Logging
31
+
32
+ `src/logger.ts` writes structured JSON logs via `pino`, one file per command invocation, to an OS-appropriate log directory (via `env-paths`) — never to the project's own working directory. Every log line includes the command name. On failure, the full error (including stack trace) is logged, while the terminal only ever shows a clean `Error: <message>` — never a raw stack trace.
33
+
34
+ ## Testing
35
+
36
+ Tests use `@oclif/test`'s `runCommand()` helper and live next to the command they test (e.g. `src/commands/hello.test.ts`). Running `npm test` first runs a build (via the `pretest` script) — `runCommand()` reads compiled output from `dist/commands`, so a stale or missing build produces empty, unhelpful output instead of a clear error. Every command's `run()` must call `await this.parse(<CommandClass>)`, even with no flags/args, to avoid an oclif `UnparsedCommand` warning.
@@ -0,0 +1,3 @@
1
+ # {{projectName}}
2
+
3
+ Generated with [clispark](https://github.com/martinwichner/clispark).
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execute } from '@oclif/core';
4
+
5
+ await execute({ dir: import.meta.url });
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ dist
3
+ *.log
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "version": "0.0.0",
4
+ "description": "",
5
+ "type": "module",
6
+ "bin": {
7
+ "{{projectName}}": "./bin/run.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "dist"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "oclif": {
17
+ "bin": "{{projectName}}",
18
+ "dirname": "{{projectName}}",
19
+ "commands": "./dist/commands",
20
+ "topicSeparator": " ",
21
+ "plugins": [
22
+ "@oclif/plugin-help"
23
+ ]
24
+ },
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "postbuild": "shx chmod +x bin/run.js",
28
+ "pretest": "npm run build",
29
+ "test": "vitest run",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "@oclif/core": "^4.0.0",
34
+ "@oclif/plugin-help": "^6.0.0",
35
+ "env-paths": "^3.0.0",
36
+ "pino": "^9.6.0"
37
+ },
38
+ "devDependencies": {
39
+ "@oclif/test": "^4.0.0",
40
+ "@types/node": "^22.10.5",
41
+ "shx": "^0.3.4",
42
+ "tsup": "^8.3.5",
43
+ "typescript": "^5.7.2",
44
+ "vitest": "^2.1.8"
45
+ }
46
+ }
@@ -0,0 +1,28 @@
1
+ // templates/base/src/base-command.ts
2
+ import { Command, type Interfaces } from '@oclif/core';
3
+ import type { Logger } from 'pino';
4
+ import { createLogger } from './logger.js';
5
+
6
+ export abstract class BaseCommand extends Command {
7
+ protected logger?: Logger;
8
+
9
+ async init(): Promise<void> {
10
+ await super.init();
11
+
12
+ const { logger } = createLogger(this.id ?? 'unknown');
13
+ this.logger = logger;
14
+ this.logger.info({ command: this.id }, 'started');
15
+ }
16
+
17
+ protected async catch(err: Interfaces.CommandError): Promise<unknown> {
18
+ this.logger?.error({ command: this.id, err }, 'failed');
19
+ return super.catch(err);
20
+ }
21
+
22
+ protected async finally(err: Error | undefined): Promise<unknown> {
23
+ if (!err) {
24
+ this.logger?.info({ command: this.id }, 'completed');
25
+ }
26
+ return super.finally(err);
27
+ }
28
+ }
@@ -0,0 +1,10 @@
1
+ // templates/base/src/commands/hello.test.ts
2
+ import { describe, it, expect } from 'vitest';
3
+ import { runCommand } from '@oclif/test';
4
+
5
+ describe('hello', () => {
6
+ it('prints a greeting', async () => {
7
+ const { stdout } = await runCommand('hello');
8
+ expect(stdout).toContain('Hello from your new CLI!');
9
+ });
10
+ });
@@ -0,0 +1,13 @@
1
+ // templates/base/src/commands/hello.ts
2
+ import { BaseCommand } from '../base-command.js';
3
+
4
+ export default class Hello extends BaseCommand {
5
+ static description = 'Say hello';
6
+ static args = {};
7
+ static flags = {};
8
+
9
+ async run(): Promise<void> {
10
+ await this.parse(Hello);
11
+ this.log('Hello from your new CLI!');
12
+ }
13
+ }
@@ -0,0 +1 @@
1
+ export { run } from '@oclif/core';
@@ -0,0 +1,28 @@
1
+ // templates/base/src/logger.ts
2
+ import { randomBytes } from 'node:crypto';
3
+ import { mkdirSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import envPaths from 'env-paths';
6
+ import pino, { type Logger } from 'pino';
7
+
8
+ const paths = envPaths('{{projectName}}', { suffix: '' });
9
+
10
+ export interface LoggerHandle {
11
+ logger: Logger;
12
+ logFilePath: string;
13
+ }
14
+
15
+ function buildLogFileName(commandName: string): string {
16
+ const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
17
+ const suffix = randomBytes(3).toString('hex');
18
+ return `${commandName}-${timestamp}-${suffix}.log`;
19
+ }
20
+
21
+ export function createLogger(commandName: string, logDir: string = paths.log): LoggerHandle {
22
+ mkdirSync(logDir, { recursive: true });
23
+
24
+ const logFilePath = path.join(logDir, buildLogFileName(commandName));
25
+ const logger = pino(pino.destination({ dest: logFilePath, sync: true }));
26
+
27
+ return { logger, logFilePath };
28
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "resolveJsonModule": true,
11
+ "rootDir": "src",
12
+ "outDir": "dist",
13
+ "types": ["node"]
14
+ },
15
+ "include": ["src"]
16
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts', 'src/commands/**/*.ts', '!src/commands/**/*.test.ts'],
5
+ format: ['esm'],
6
+ target: 'node18',
7
+ outDir: 'dist',
8
+ clean: true,
9
+ sourcemap: true,
10
+ });
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ disableConsoleIntercept: true,
7
+ },
8
+ });