clispark 1.19.1 → 1.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clispark",
3
- "version": "1.19.1",
3
+ "version": "1.21.0",
4
4
  "description": "Interactive scaffolding tool for new CLI projects",
5
5
  "keywords": [
6
6
  "cli",
@@ -75,3 +75,22 @@ A few things run automatically around every log call:
75
75
  ## Testing
76
76
 
77
77
  Tests use `xunit` and live in `tests/Cli.Tests.csproj` (a separate project referencing `src/Cli.csproj`, not colocated with the commands they test — the conventional .NET layout, unlike vitest's colocated `*.test.ts` files). A test exercises a command directly via `new SomeCommand().Build()` then `.Parse(args).Invoke(...)` — no process spawning needed. Remember the `EnableDefaultExceptionHandler = false` detail from "Error Handling" above when asserting on thrown exceptions.
78
+
79
+ ## Lint Tooling
80
+
81
+ If you answered "yes" to "Set up lint tooling?" during scaffolding, `src/Cli.csproj` includes a `<PropertyGroup>` enabling the .NET SDK's built-in Roslyn analyzers — no new NuGet dependency:
82
+
83
+ ```xml
84
+ <PropertyGroup>
85
+ <EnableNETAnalyzers>true</EnableNETAnalyzers>
86
+ <AnalysisLevel>latest</AnalysisLevel>
87
+ <AnalysisMode>Recommended</AnalysisMode>
88
+ <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
89
+ </PropertyGroup>
90
+ ```
91
+
92
+ There's no separate lint command — analyzer warnings surface as part of a normal `dotnet build`. `AnalysisMode=Recommended` (over the SDK default `Default`) and `EnforceCodeStyleInBuild=true` (over the SDK default `false`, which otherwise confines code-style rules to the IDE) are what actually change build output; `EnableNETAnalyzers`/`AnalysisLevel` are already the `net10.0` SDK's own defaults and are listed here for explicitness.
93
+
94
+ If you answered "no", this `<PropertyGroup>` isn't present, and `dotnet build` runs with only the SDK's own defaults.
95
+
96
+ Either way, this choice is permanent and core-managed: `npx clispark update` keeps this block current for a project that opted in, and will never add it to a project that declined. There's no retroactive "turn lint tooling on later" command — rerun `clispark` in a new directory if you want it.
@@ -13,6 +13,13 @@
13
13
  <PackageId>{{projectName}}</PackageId>
14
14
  </PropertyGroup>
15
15
 
16
+ <PropertyGroup>
17
+ <EnableNETAnalyzers>true</EnableNETAnalyzers>
18
+ <AnalysisLevel>latest</AnalysisLevel>
19
+ <AnalysisMode>Recommended</AnalysisMode>
20
+ <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
21
+ </PropertyGroup>
22
+
16
23
  <ItemGroup>
17
24
  <PackageReference Include="System.CommandLine" Version="2.0.10" />
18
25
  <PackageReference Include="Serilog" Version="4.4.0" />
@@ -1,3 +1,4 @@
1
+ using System.Globalization;
1
2
  using Serilog;
2
3
  using Serilog.Core;
3
4
  using Xdg.Directories;
@@ -17,17 +18,17 @@ public static class CliLoggerFactory
17
18
  Directory.CreateDirectory(logDir);
18
19
  SweepOldLogs(logDir);
19
20
 
20
- var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH-mm-ss-fffZ");
21
+ var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH-mm-ss-fffZ", CultureInfo.InvariantCulture);
21
22
  var suffix = Guid.NewGuid().ToString("N")[..6];
22
23
  var logFilePath = Path.Combine(logDir, $"{commandName}-{timestamp}-{suffix}.log");
23
24
 
24
25
  var config = new LoggerConfiguration()
25
26
  .Enrich.With(new SensitivePropertyEnricher(SensitiveKeys))
26
- .WriteTo.File(logFilePath);
27
+ .WriteTo.File(logFilePath, formatProvider: CultureInfo.InvariantCulture);
27
28
 
28
29
  if (Environment.GetEnvironmentVariable("DEBUG") is not null)
29
30
  {
30
- config = config.WriteTo.Console();
31
+ config = config.WriteTo.Console(formatProvider: CultureInfo.InvariantCulture);
31
32
  }
32
33
 
33
34
  var logger = config.CreateLogger();
@@ -0,0 +1 @@
1
+ .clispark/
@@ -0,0 +1,5 @@
1
+ {
2
+ "singleQuote": true,
3
+ "semi": true,
4
+ "endOfLine": "auto"
5
+ }
@@ -29,39 +29,40 @@ Every entry in a command's `static args` is built from `@oclif/core`'s `Args` he
29
29
 
30
30
  - **`Args.string()`** — plain text, no parsing/validation. The default choice.
31
31
  ```ts
32
- title: Args.string({ description: 'Task title' })
32
+ title: Args.string({ description: 'Task title' });
33
33
  ```
34
34
  - **`Args.integer({ min?, max? })`** — parses digits into a real `number`, rejects non-numeric input, optional bounds.
35
35
  ```ts
36
- id: Args.integer({ description: 'Task ID' })
36
+ id: Args.integer({ description: 'Task ID' });
37
37
  // `task complete abc` → "Expected an integer but received: abc"
38
38
  ```
39
39
  - **`Args.boolean()`** — parses into a real `boolean`. Any input is `true` except (case-insensitive) `0`, `false`, `n`, `no`, which parse to `false`.
40
40
  ```ts
41
- done: Args.boolean({ description: 'Only completed tasks' })
41
+ done: Args.boolean({ description: 'Only completed tasks' });
42
42
  // `task list true` → done === true; `task list no` → done === false
43
43
  ```
44
44
  - **`Args.file({ exists? })`** / **`Args.directory({ exists? })`** — plain string path by default; with `exists: true`, verifies the path actually exists on disk (as a file/directory respectively) and throws otherwise.
45
45
  ```ts
46
- config: Args.file({ exists: true, description: 'Path to a config file' })
46
+ config: Args.file({ exists: true, description: 'Path to a config file' });
47
47
  // missing file → "No file found at <path>"
48
48
  ```
49
49
  - **`Args.url()`** — parses into a real `URL` object, throws if the input isn't a valid URL.
50
50
  ```ts
51
- endpoint: Args.url({ description: 'API endpoint' })
51
+ endpoint: Args.url({ description: 'API endpoint' });
52
52
  ```
53
53
  - **`Args.custom<T, Opts>({ parse })`** — build your own type when none of the above fit.
54
54
  ```ts
55
55
  const semver = Args.custom<string>({
56
56
  parse: async (input) => {
57
- if (!/^\d+\.\d+\.\d+$/.test(input)) throw new Error(`Not a valid semver: ${input}`);
57
+ if (!/^\d+\.\d+\.\d+$/.test(input))
58
+ throw new Error(`Not a valid semver: ${input}`);
58
59
  return input;
59
60
  },
60
61
  });
61
62
  ```
62
63
  - **`options: [...]`** — a cross-cutting constraint any arg type can add (not a type itself): restricts input to a fixed list of values.
63
64
  ```ts
64
- priority: Args.string({ options: ['low', 'medium', 'high'] })
65
+ priority: Args.string({ options: ['low', 'medium', 'high'] });
65
66
  // `task <title> urgent` → "Expected urgent to be one of: low, medium, high"
66
67
  ```
67
68
 
@@ -73,7 +74,7 @@ Flags (`--name`/`-n`) are `@oclif/core`'s other input mechanism, declared in `st
73
74
 
74
75
  - **`Flags.boolean()`** — presence-based: passing the flag sets it to `true`, omitting it leaves it `undefined`.
75
76
  ```ts
76
- done: Flags.boolean({ description: 'Only show completed tasks' })
77
+ done: Flags.boolean({ description: 'Only show completed tasks' });
77
78
  // `task list --done` → done === true; omitted → done === undefined
78
79
  ```
79
80
  - Flags otherwise mirror the `Args` catalogue above (`Flags.string()`, `Flags.integer()`, `options: [...]`, etc.) — the choice between an arg and a flag is about calling convention (positional vs. named), not available types.
@@ -98,3 +99,16 @@ A few things run automatically around every log call:
98
99
  ## Testing
99
100
 
100
101
  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.
102
+
103
+ ## Lint Tooling
104
+
105
+ If you answered "yes" to "Set up lint tooling?" during scaffolding, this project includes `eslint.config.js` (flat config, `@eslint/js` + `typescript-eslint` recommended rulesets), `.prettierrc`, and `.prettierignore` (excludes `.clispark/`, the tool's own generated manifest, from Prettier's formatting checks), plus `eslint-config-prettier` to keep ESLint out of Prettier's way on formatting-related rules. Run it with:
106
+
107
+ ```bash
108
+ npm run lint # eslint src bin
109
+ npm run format # prettier --write .
110
+ ```
111
+
112
+ If you answered "no", none of this is present — no config files, no `lint`/`format` scripts, no eslint/prettier devDependencies.
113
+
114
+ Either way, this choice is permanent and core-managed: `npx clispark update` keeps the lint config and its devDependency versions current for a project that opted in, and will never add any of it to a project that declined. There's no retroactive "turn lint tooling on later" command — rerun `clispark` in a new directory if you want it.
@@ -0,0 +1,18 @@
1
+ // eslint.config.js
2
+ import eslint from '@eslint/js';
3
+ import tseslint from 'typescript-eslint';
4
+ import eslintConfigPrettier from 'eslint-config-prettier';
5
+
6
+ export default tseslint.config(
7
+ {
8
+ ignores: ['dist/**'],
9
+ },
10
+ {
11
+ files: ['src/**/*.ts', 'bin/**/*.ts'],
12
+ extends: [
13
+ eslint.configs.recommended,
14
+ ...tseslint.configs.recommended,
15
+ eslintConfigPrettier,
16
+ ],
17
+ },
18
+ );
@@ -25,6 +25,8 @@
25
25
  "scripts": {
26
26
  "build": "tsup",
27
27
  "postbuild": "shx chmod +x bin/run.ts",
28
+ "format": "prettier --write .",
29
+ "lint": "eslint src bin",
28
30
  "pretest": "npm run build",
29
31
  "test": "vitest run",
30
32
  "typecheck": "tsc --noEmit"
@@ -36,11 +38,16 @@
36
38
  "pino": "^9.6.0"
37
39
  },
38
40
  "devDependencies": {
41
+ "@eslint/js": "^10.0.1",
39
42
  "@oclif/test": "^4.0.0",
40
43
  "@types/node": "^24.0.0",
44
+ "eslint": "^10.8.0",
45
+ "eslint-config-prettier": "^10.1.8",
46
+ "prettier": "^3.9.6",
41
47
  "shx": "^0.3.4",
42
48
  "tsup": "^8.3.5",
43
49
  "typescript": "^5.7.2",
50
+ "typescript-eslint": "^8.65.0",
44
51
  "vitest": "^4.1.10"
45
52
  }
46
53
  }
@@ -3,7 +3,8 @@ import { Args } from '@oclif/core';
3
3
  import { BaseCommand } from '../../base-command';
4
4
 
5
5
  export default class TaskComplete extends BaseCommand {
6
- static description = 'Complete a task (demonstrates a subcommand with a required integer argument)';
6
+ static description =
7
+ 'Complete a task (demonstrates a subcommand with a required integer argument)';
7
8
  static examples = [
8
9
  {
9
10
  command: '<%= config.bin %> task complete 1',
@@ -15,7 +15,9 @@ describe('task list', () => {
15
15
 
16
16
  it('combines a filter with the --done flag', async () => {
17
17
  const { stdout } = await runCommand('task list groceries --done');
18
- expect(stdout).toContain('Listing tasks matching "groceries" (completed only: true)');
18
+ expect(stdout).toContain(
19
+ 'Listing tasks matching "groceries" (completed only: true)',
20
+ );
19
21
  });
20
22
 
21
23
  it('omits the completed-only note when --done is not passed', async () => {
@@ -3,7 +3,8 @@ import { Args, Flags } from '@oclif/core';
3
3
  import { BaseCommand } from '../../base-command';
4
4
 
5
5
  export default class TaskList extends BaseCommand {
6
- static description = 'List tasks (demonstrates an optional argument combined with a boolean flag)';
6
+ static description =
7
+ 'List tasks (demonstrates an optional argument combined with a boolean flag)';
7
8
  static examples = [
8
9
  {
9
10
  command: '<%= config.bin %> task list',
@@ -19,7 +20,10 @@ export default class TaskList extends BaseCommand {
19
20
  },
20
21
  ];
21
22
  static args = {
22
- filter: Args.string({ required: false, description: 'Optional filter term' }),
23
+ filter: Args.string({
24
+ required: false,
25
+ description: 'Optional filter term',
26
+ }),
23
27
  };
24
28
  static flags = {
25
29
  done: Flags.boolean({ description: 'Only show completed tasks' }),
@@ -27,7 +31,9 @@ export default class TaskList extends BaseCommand {
27
31
 
28
32
  async run(): Promise<void> {
29
33
  const { args, flags } = await this.parse(TaskList);
30
- const base = args.filter ? `Listing tasks matching "${args.filter}"` : 'Listing all tasks';
34
+ const base = args.filter
35
+ ? `Listing tasks matching "${args.filter}"`
36
+ : 'Listing all tasks';
31
37
  this.log(flags.done ? `${base} (completed only: true)` : base);
32
38
  }
33
39
  }
@@ -16,7 +16,9 @@ describe('task', () => {
16
16
 
17
17
  it('rejects a priority outside the allowed values', async () => {
18
18
  const { error } = await runCommand('task "Buy milk" urgent');
19
- expect(error?.message).toContain('Expected urgent to be one of: low, medium, high');
19
+ expect(error?.message).toContain(
20
+ 'Expected urgent to be one of: low, medium, high',
21
+ );
20
22
  });
21
23
 
22
24
  it('requires a title', async () => {
@@ -3,7 +3,8 @@ import { Args } from '@oclif/core';
3
3
  import { BaseCommand } from '../base-command';
4
4
 
5
5
  export default class Task extends BaseCommand {
6
- static description = 'Create a task (demonstrates a required string arg and an optional enum-constrained arg)';
6
+ static description =
7
+ 'Create a task (demonstrates a required string arg and an optional enum-constrained arg)';
7
8
  static examples = [
8
9
  {
9
10
  command: '<%= config.bin %> task "Buy milk"',
@@ -11,7 +12,8 @@ export default class Task extends BaseCommand {
11
12
  },
12
13
  {
13
14
  command: '<%= config.bin %> task "Buy milk" high',
14
- description: 'Creates a task with an optional priority (low, medium, or high)',
15
+ description:
16
+ 'Creates a task with an optional priority (low, medium, or high)',
15
17
  },
16
18
  ];
17
19
  static args = {
@@ -26,6 +28,8 @@ export default class Task extends BaseCommand {
26
28
 
27
29
  async run(): Promise<void> {
28
30
  const { args } = await this.parse(Task);
29
- this.log(`Created task: "${args.title}"${args.priority ? ` (priority: ${args.priority})` : ''}`);
31
+ this.log(
32
+ `Created task: "${args.title}"${args.priority ? ` (priority: ${args.priority})` : ''}`,
33
+ );
30
34
  }
31
35
  }
@@ -10,7 +10,9 @@ describe('createLogger', () => {
10
10
  let tmpRoot: string;
11
11
 
12
12
  beforeEach(async () => {
13
- tmpRoot = await mkdtemp(path.join(tmpdir(), 'clispark-template-logger-test-'));
13
+ tmpRoot = await mkdtemp(
14
+ path.join(tmpdir(), 'clispark-template-logger-test-'),
15
+ );
14
16
  });
15
17
 
16
18
  afterEach(async () => {
@@ -151,7 +153,9 @@ describe('createLogger', () => {
151
153
  });
152
154
 
153
155
  it('streams log lines to stdout when DEBUG is set', async () => {
154
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
156
+ const writeSpy = vi
157
+ .spyOn(process.stdout, 'write')
158
+ .mockImplementation(() => true);
155
159
  process.env.DEBUG = '1';
156
160
 
157
161
  try {
@@ -168,7 +172,9 @@ describe('createLogger', () => {
168
172
  });
169
173
 
170
174
  it('does not stream to stdout when DEBUG is unset', async () => {
171
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
175
+ const writeSpy = vi
176
+ .spyOn(process.stdout, 'write')
177
+ .mockImplementation(() => true);
172
178
  delete process.env.DEBUG;
173
179
 
174
180
  const { logger } = createLogger('hello', tmpRoot);
@@ -1,6 +1,12 @@
1
1
  // templates/base/src/logger.ts
2
2
  import { randomBytes } from 'node:crypto';
3
- import { mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import {
4
+ mkdirSync,
5
+ readdirSync,
6
+ statSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
4
10
  import path from 'node:path';
5
11
  import envPaths from 'env-paths';
6
12
  import pino, { type Logger } from 'pino';
@@ -57,7 +63,8 @@ function sweepOldLogs(logDir: string): void {
57
63
  const markerPath = path.join(logDir, SWEEP_MARKER_FILE);
58
64
  let shouldSweep = true;
59
65
  try {
60
- shouldSweep = Date.now() - statSync(markerPath).mtimeMs >= SWEEP_THROTTLE_MS;
66
+ shouldSweep =
67
+ Date.now() - statSync(markerPath).mtimeMs >= SWEEP_THROTTLE_MS;
61
68
  } catch {
62
69
  // no marker yet (first run in this directory) - sweep now
63
70
  }
@@ -75,16 +82,29 @@ function sweepOldLogs(logDir: string): void {
75
82
  });
76
83
  }
77
84
 
78
- export function createLogger(commandName: string, logDir: string = paths.log): LoggerHandle {
85
+ export function createLogger(
86
+ commandName: string,
87
+ logDir: string = paths.log,
88
+ ): LoggerHandle {
79
89
  mkdirSync(logDir, { recursive: true });
80
90
  sweepOldLogs(logDir);
81
91
 
82
92
  const logFilePath = path.join(logDir, buildLogFileName(commandName));
83
- const fileDestination = pino.destination({ dest: logFilePath, sync: true, mode: 0o600 });
93
+ const fileDestination = pino.destination({
94
+ dest: logFilePath,
95
+ sync: true,
96
+ mode: 0o600,
97
+ });
84
98
  const destination = process.env.DEBUG
85
- ? pino.multistream([{ stream: fileDestination }, { stream: process.stdout }])
99
+ ? pino.multistream([
100
+ { stream: fileDestination },
101
+ { stream: process.stdout },
102
+ ])
86
103
  : fileDestination;
87
- const logger = pino({ redact: buildRedactPaths(SENSITIVE_LOG_KEYS) }, destination);
104
+ const logger = pino(
105
+ { redact: buildRedactPaths(SENSITIVE_LOG_KEYS) },
106
+ destination,
107
+ );
88
108
 
89
109
  return { logger, logFilePath };
90
110
  }
@@ -0,0 +1,10 @@
1
+ function Complete-Task {
2
+ [CmdletBinding()]
3
+ param(
4
+ [Parameter(Mandatory, Position = 0)]
5
+ [int]$Id
6
+ )
7
+ process {
8
+ Write-Output "Completed task $Id"
9
+ }
10
+ }
@@ -0,0 +1,17 @@
1
+ function Get-Task {
2
+ [CmdletBinding()]
3
+ param(
4
+ [Parameter(Position = 0)]
5
+ [string]$Filter,
6
+
7
+ [switch]$Done
8
+ )
9
+ process {
10
+ $base = if ($Filter) { "Listing tasks matching `"$Filter`"" } else { 'Listing all tasks' }
11
+ if ($Done) {
12
+ Write-Output "$base (completed only: true)"
13
+ } else {
14
+ Write-Output $base
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,17 @@
1
+ function New-Task {
2
+ [CmdletBinding()]
3
+ param(
4
+ [Parameter(Mandatory, Position = 0)]
5
+ [string]$Title,
6
+
7
+ [ValidateSet('Low', 'Medium', 'High')]
8
+ [string]$Priority
9
+ )
10
+ process {
11
+ if ($Priority) {
12
+ Write-Output "Created task: `"$Title`" (priority: $Priority)"
13
+ } else {
14
+ Write-Output "Created task: `"$Title`""
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,5 @@
1
+ Describe 'Complete-Task' {
2
+ It 'marks a task as complete' {
3
+ Complete-Task -Id 1 | Should -Be 'Completed task 1'
4
+ }
5
+ }
@@ -0,0 +1,13 @@
1
+ Describe 'Get-Task' {
2
+ It 'lists all tasks by default' {
3
+ Get-Task | Should -Be 'Listing all tasks'
4
+ }
5
+
6
+ It 'lists tasks matching a filter' {
7
+ Get-Task -Filter 'groceries' | Should -Be 'Listing tasks matching "groceries"'
8
+ }
9
+
10
+ It 'lists tasks matching a filter, showing only completed ones' {
11
+ Get-Task -Filter 'groceries' -Done | Should -Be 'Listing tasks matching "groceries" (completed only: true)'
12
+ }
13
+ }
@@ -0,0 +1,9 @@
1
+ Describe 'New-Task' {
2
+ It 'creates a task with just a title' {
3
+ New-Task -Title 'Buy milk' | Should -Be 'Created task: "Buy milk"'
4
+ }
5
+
6
+ It 'creates a task with an optional priority' {
7
+ New-Task -Title 'Buy milk' -Priority 'High' | Should -Be 'Created task: "Buy milk" (priority: High)'
8
+ }
9
+ }