clispark 1.19.1 → 1.20.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/README.md +2 -1
- package/dist/cli.js +241 -131
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/templates/dotnet/ARCHITECTURE.md +19 -0
- package/templates/dotnet/src/Cli.csproj +7 -0
- package/templates/dotnet/src/Logging/CliLoggerFactory.cs +4 -3
- package/templates/node/.prettierignore +1 -0
- package/templates/node/.prettierrc +5 -0
- package/templates/node/ARCHITECTURE.md +22 -8
- package/templates/node/eslint.config.js +18 -0
- package/templates/node/package.json +7 -0
- package/templates/node/src/commands/task/complete.ts +2 -1
- package/templates/node/src/commands/task/list.test.ts +3 -1
- package/templates/node/src/commands/task/list.ts +9 -3
- package/templates/node/src/commands/task.test.ts +3 -1
- package/templates/node/src/commands/task.ts +7 -3
- package/templates/node/src/logger.test.ts +9 -3
- package/templates/node/src/logger.ts +26 -6
package/package.json
CHANGED
|
@@ -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/
|
|
@@ -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))
|
|
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 =
|
|
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(
|
|
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 =
|
|
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({
|
|
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
|
|
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(
|
|
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 =
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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 {
|
|
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 =
|
|
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(
|
|
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({
|
|
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([
|
|
99
|
+
? pino.multistream([
|
|
100
|
+
{ stream: fileDestination },
|
|
101
|
+
{ stream: process.stdout },
|
|
102
|
+
])
|
|
86
103
|
: fileDestination;
|
|
87
|
-
const logger = pino(
|
|
104
|
+
const logger = pino(
|
|
105
|
+
{ redact: buildRedactPaths(SENSITIVE_LOG_KEYS) },
|
|
106
|
+
destination,
|
|
107
|
+
);
|
|
88
108
|
|
|
89
109
|
return { logger, logFilePath };
|
|
90
110
|
}
|