@nowline/cli 0.8.5 → 0.9.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 +42 -12
- package/dist/cli/args.d.ts +21 -5
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +26 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/formats.d.ts.map +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +35 -2
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/options.d.ts +74 -0
- package/dist/cli/options.d.ts.map +1 -0
- package/dist/cli/options.js +209 -0
- package/dist/cli/options.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +2 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/mcp-install.d.ts +18 -0
- package/dist/commands/mcp-install.d.ts.map +1 -0
- package/dist/commands/mcp-install.js +151 -0
- package/dist/commands/mcp-install.js.map +1 -0
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +6 -12
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/render.d.ts +7 -0
- package/dist/commands/render.d.ts.map +1 -1
- package/dist/commands/render.js +176 -152
- package/dist/commands/render.js.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +87 -60
- package/dist/commands/serve.js.map +1 -1
- package/dist/core/parse.js.map +1 -1
- package/dist/diagnostics/adapt.d.ts +12 -1
- package/dist/diagnostics/adapt.d.ts.map +1 -1
- package/dist/diagnostics/adapt.js +53 -1
- package/dist/diagnostics/adapt.js.map +1 -1
- package/dist/diagnostics/format.js.map +1 -1
- package/dist/diagnostics/model.d.ts +1 -1
- package/dist/diagnostics/text.js.map +1 -1
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +2 -2
- package/dist/i18n/locale.d.ts +1 -1
- package/dist/i18n/locale.d.ts.map +1 -1
- package/dist/i18n/locale.js +2 -5
- package/dist/i18n/locale.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/io/config.d.ts +1 -1
- package/dist/io/config.d.ts.map +1 -1
- package/dist/io/config.js +1 -1
- package/dist/io/config.js.map +1 -1
- package/dist/io/exit-codes.d.ts.map +1 -1
- package/dist/io/exit-codes.js.map +1 -1
- package/dist/io/log.d.ts +17 -0
- package/dist/io/log.d.ts.map +1 -0
- package/dist/io/log.js +16 -0
- package/dist/io/log.js.map +1 -0
- package/dist/io/read.d.ts +3 -0
- package/dist/io/read.d.ts.map +1 -1
- package/dist/io/read.js +32 -5
- package/dist/io/read.js.map +1 -1
- package/dist/io/write.js.map +1 -1
- package/dist/mcp-install/clients.d.ts +58 -0
- package/dist/mcp-install/clients.d.ts.map +1 -0
- package/dist/mcp-install/clients.js +304 -0
- package/dist/mcp-install/clients.js.map +1 -0
- package/dist/mcp-install/host.d.ts +29 -0
- package/dist/mcp-install/host.d.ts.map +1 -0
- package/dist/mcp-install/host.js +72 -0
- package/dist/mcp-install/host.js.map +1 -0
- package/dist/mcp-install/json-config.d.ts +29 -0
- package/dist/mcp-install/json-config.d.ts.map +1 -0
- package/dist/mcp-install/json-config.js +139 -0
- package/dist/mcp-install/json-config.js.map +1 -0
- package/dist/mcp-install/server-command.d.ts +26 -0
- package/dist/mcp-install/server-command.d.ts.map +1 -0
- package/dist/mcp-install/server-command.js +52 -0
- package/dist/mcp-install/server-command.js.map +1 -0
- package/man/fr/nowline.1 +246 -19
- package/man/fr/nowline.5 +819 -45
- package/man/nowline.1 +191 -18
- package/man/nowline.5 +764 -36
- package/package.json +24 -24
- package/src/cli/args.ts +69 -8
- package/src/cli/help.ts +35 -2
- package/src/cli/options.ts +286 -0
- package/src/commands/init.ts +2 -3
- package/src/commands/mcp-install.ts +211 -0
- package/src/commands/mcp.ts +9 -14
- package/src/commands/render.ts +219 -186
- package/src/commands/serve.ts +102 -74
- package/src/diagnostics/adapt.ts +62 -0
- package/src/diagnostics/model.ts +1 -1
- package/src/generated/version.ts +2 -2
- package/src/i18n/locale.ts +2 -6
- package/src/index.ts +3 -0
- package/src/io/config.ts +0 -1
- package/src/io/log.ts +33 -0
- package/src/io/read.ts +39 -5
- package/src/mcp-install/clients.ts +443 -0
- package/src/mcp-install/host.ts +106 -0
- package/src/mcp-install/json-config.ts +179 -0
- package/src/mcp-install/server-command.ts +75 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowline/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Command-line tool to check, convert, and create Nowline roadmaps.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -30,35 +30,35 @@
|
|
|
30
30
|
"scripts/"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@babel/code-frame": "^8.0.
|
|
34
|
-
"@clack/prompts": "^1.
|
|
35
|
-
"@modelcontextprotocol/
|
|
36
|
-
"
|
|
33
|
+
"@babel/code-frame": "^8.0.6",
|
|
34
|
+
"@clack/prompts": "^1.8.1",
|
|
35
|
+
"@modelcontextprotocol/server": "^2.3.1",
|
|
36
|
+
"@nowline/config": "0.9.0",
|
|
37
|
+
"@nowline/core": "0.9.0",
|
|
38
|
+
"@nowline/export": "0.9.0",
|
|
39
|
+
"@nowline/export-core": "0.9.0",
|
|
40
|
+
"@nowline/export-html": "0.9.0",
|
|
41
|
+
"@nowline/export-mermaid": "0.9.0",
|
|
42
|
+
"@nowline/export-msproj": "0.9.0",
|
|
43
|
+
"@nowline/export-pdf": "0.9.0",
|
|
44
|
+
"@nowline/export-png": "0.9.0",
|
|
45
|
+
"@nowline/export-xlsx": "0.9.0",
|
|
46
|
+
"@nowline/layout": "0.9.0",
|
|
47
|
+
"@nowline/mcp": "0.9.0",
|
|
48
|
+
"@nowline/renderer": "0.9.0",
|
|
49
|
+
"chalk": "^6.0.1",
|
|
37
50
|
"citty": "^0.2.2",
|
|
38
51
|
"consola": "^3.4.2",
|
|
39
|
-
"js-yaml": "^
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"@nowline/export": "0.8.5",
|
|
43
|
-
"@nowline/export-core": "0.8.5",
|
|
44
|
-
"@nowline/export-html": "0.8.5",
|
|
45
|
-
"@nowline/export-mermaid": "0.8.5",
|
|
46
|
-
"@nowline/config": "0.8.5",
|
|
47
|
-
"@nowline/export-msproj": "0.8.5",
|
|
48
|
-
"@nowline/export-pdf": "0.8.5",
|
|
49
|
-
"@nowline/export-png": "0.8.5",
|
|
50
|
-
"@nowline/export-xlsx": "0.8.5",
|
|
51
|
-
"@nowline/layout": "0.8.5",
|
|
52
|
-
"@nowline/mcp": "0.8.5",
|
|
53
|
-
"@nowline/renderer": "0.8.5"
|
|
52
|
+
"js-yaml": "^5.5.0",
|
|
53
|
+
"jsonc-parser": "^3.3.1",
|
|
54
|
+
"langium": "~4.4.0"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
57
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
57
58
|
"@types/babel__code-frame": "^7.27.0",
|
|
58
|
-
"@types/
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"vitest": "^4.1.9"
|
|
59
|
+
"@types/node": "^26.6.5",
|
|
60
|
+
"typescript": "^7.0.2",
|
|
61
|
+
"vitest": "^5.0.5"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|
|
64
64
|
"bundle-templates": "node scripts/bundle-templates.mjs",
|
package/src/cli/args.ts
CHANGED
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
import { type ParseArgsConfig, parseArgs } from 'node:util';
|
|
2
|
+
import { isDiagnosticFormat } from '../diagnostics/format.js';
|
|
2
3
|
import { CliError, ExitCode } from '../io/exit-codes.js';
|
|
4
|
+
import type { LogLevel } from '../io/log.js';
|
|
3
5
|
|
|
4
|
-
export type ModeKind =
|
|
6
|
+
export type ModeKind =
|
|
7
|
+
| 'render'
|
|
8
|
+
| 'serve'
|
|
9
|
+
| 'init'
|
|
10
|
+
| 'mcp'
|
|
11
|
+
| 'mcp-install'
|
|
12
|
+
| 'mcp-uninstall'
|
|
13
|
+
| 'help'
|
|
14
|
+
| 'version';
|
|
15
|
+
|
|
16
|
+
export type McpScope = 'user' | 'project';
|
|
17
|
+
export type McpServerKind = 'self' | 'npx';
|
|
5
18
|
|
|
6
19
|
export interface ParsedArgs {
|
|
7
20
|
/** Resolved mode after dispatch (mutual-exclusivity already checked). */
|
|
8
21
|
mode: ModeKind;
|
|
9
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Positional argument, or undefined. Render = input path. Init = project name. Serve = input path.
|
|
24
|
+
* MCP install / uninstall = client id.
|
|
25
|
+
*/
|
|
10
26
|
positional?: string;
|
|
11
|
-
/** True if `--dry-run` / `-n` was passed (
|
|
27
|
+
/** True if `--dry-run` / `-n` was passed (render and MCP install / uninstall). */
|
|
12
28
|
dryRun: boolean;
|
|
13
29
|
/** Logging level. Verbose and quiet are mutually exclusive. */
|
|
14
|
-
logLevel:
|
|
30
|
+
logLevel: LogLevel;
|
|
15
31
|
|
|
16
32
|
// I/O
|
|
17
33
|
output?: string;
|
|
@@ -37,6 +53,12 @@ export interface ParsedArgs {
|
|
|
37
53
|
scale?: string;
|
|
38
54
|
strict: boolean;
|
|
39
55
|
width?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Raw `--non-working` value (`hide` or `show`). Undefined means unset, so
|
|
58
|
+
* the file's `default roadmap non-working:` key still applies. Validated
|
|
59
|
+
* by the render and serve commands, like `--width`.
|
|
60
|
+
*/
|
|
61
|
+
nonWorking?: string;
|
|
40
62
|
assetRoot?: string;
|
|
41
63
|
|
|
42
64
|
// Format-specific options (m2c)
|
|
@@ -67,14 +89,20 @@ export interface ParsedArgs {
|
|
|
67
89
|
host?: string;
|
|
68
90
|
open: boolean;
|
|
69
91
|
|
|
70
|
-
// Validate / dry-run formatting
|
|
71
|
-
|
|
92
|
+
// Validate / dry-run formatting. `text` or `json`; validated in parseArgv.
|
|
93
|
+
// Undefined means "not given": the render handler defaults to `json` for
|
|
94
|
+
// `--dry-run --format=json` and `text` otherwise.
|
|
95
|
+
diagnosticFormat?: 'text' | 'json';
|
|
72
96
|
|
|
73
97
|
// Init
|
|
74
98
|
template?: string;
|
|
75
99
|
|
|
76
100
|
// MCP
|
|
77
101
|
root?: string;
|
|
102
|
+
/** `--scope` for --mcp-install / --mcp-uninstall; validated in parseArgv. */
|
|
103
|
+
scope?: McpScope;
|
|
104
|
+
/** `--server` for --mcp-install; validated in parseArgv. */
|
|
105
|
+
server?: McpServerKind;
|
|
78
106
|
}
|
|
79
107
|
|
|
80
108
|
/**
|
|
@@ -115,6 +143,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
|
|
|
115
143
|
serve: { type: 'boolean' },
|
|
116
144
|
init: { type: 'boolean' },
|
|
117
145
|
mcp: { type: 'boolean' },
|
|
146
|
+
'mcp-install': { type: 'boolean' },
|
|
147
|
+
'mcp-uninstall': { type: 'boolean' },
|
|
118
148
|
'dry-run': { type: 'boolean', short: 'n' },
|
|
119
149
|
|
|
120
150
|
theme: { type: 'string', short: 't' },
|
|
@@ -130,6 +160,7 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
|
|
|
130
160
|
scale: { type: 'string', short: 's' },
|
|
131
161
|
strict: { type: 'boolean' },
|
|
132
162
|
width: { type: 'string', short: 'w' },
|
|
163
|
+
'non-working': { type: 'string' },
|
|
133
164
|
'asset-root': { type: 'string' },
|
|
134
165
|
|
|
135
166
|
port: { type: 'string', short: 'p' },
|
|
@@ -142,6 +173,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
|
|
|
142
173
|
|
|
143
174
|
// MCP
|
|
144
175
|
root: { type: 'string' },
|
|
176
|
+
scope: { type: 'string' },
|
|
177
|
+
server: { type: 'string' },
|
|
145
178
|
|
|
146
179
|
// Format-specific (m2c)
|
|
147
180
|
'page-size': { type: 'string' },
|
|
@@ -205,6 +238,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
|
|
|
205
238
|
if (values.serve === true) modes.push('serve');
|
|
206
239
|
if (values.init === true) modes.push('init');
|
|
207
240
|
if (values.mcp === true) modes.push('mcp');
|
|
241
|
+
if (values['mcp-install'] === true) modes.push('mcp-install');
|
|
242
|
+
if (values['mcp-uninstall'] === true) modes.push('mcp-uninstall');
|
|
208
243
|
if (modes.length > 1) {
|
|
209
244
|
throw new CliError(
|
|
210
245
|
ExitCode.InputError,
|
|
@@ -230,7 +265,30 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
|
|
|
230
265
|
throw new CliError(ExitCode.InputError, `nowline: unexpected extra arguments: ${extras}.`);
|
|
231
266
|
}
|
|
232
267
|
|
|
233
|
-
const
|
|
268
|
+
const scope = stringOrUndefined(values.scope);
|
|
269
|
+
if (scope !== undefined && scope !== 'user' && scope !== 'project') {
|
|
270
|
+
throw new CliError(
|
|
271
|
+
ExitCode.InputError,
|
|
272
|
+
`nowline: invalid --scope "${scope}". Expected user or project.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
const server = stringOrUndefined(values.server);
|
|
276
|
+
if (server !== undefined && server !== 'self' && server !== 'npx') {
|
|
277
|
+
throw new CliError(
|
|
278
|
+
ExitCode.InputError,
|
|
279
|
+
`nowline: invalid --server "${server}". Expected self or npx.`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const rawDiagnosticFormat = stringOrUndefined(values['diagnostic-format']);
|
|
284
|
+
if (rawDiagnosticFormat !== undefined && !isDiagnosticFormat(rawDiagnosticFormat)) {
|
|
285
|
+
throw new CliError(
|
|
286
|
+
ExitCode.InputError,
|
|
287
|
+
`nowline: invalid --diagnostic-format "${rawDiagnosticFormat}". Expected text or json. Try --help for usage.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const logLevel: LogLevel =
|
|
234
292
|
values.verbose === true ? 'verbose' : values.quiet === true ? 'quiet' : 'normal';
|
|
235
293
|
|
|
236
294
|
return {
|
|
@@ -248,11 +306,12 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
|
|
|
248
306
|
scale: stringOrUndefined(values.scale),
|
|
249
307
|
strict: values.strict === true,
|
|
250
308
|
width: stringOrUndefined(values.width),
|
|
309
|
+
nonWorking: stringOrUndefined(values['non-working']),
|
|
251
310
|
assetRoot: stringOrUndefined(values['asset-root']),
|
|
252
311
|
port: stringOrUndefined(values.port),
|
|
253
312
|
host: stringOrUndefined(values.host),
|
|
254
313
|
open: values.open === true,
|
|
255
|
-
diagnosticFormat:
|
|
314
|
+
diagnosticFormat: rawDiagnosticFormat,
|
|
256
315
|
template: stringOrUndefined(values.template),
|
|
257
316
|
pageSize: stringOrUndefined(values['page-size']),
|
|
258
317
|
orientation: stringOrUndefined(values.orientation),
|
|
@@ -264,6 +323,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
|
|
|
264
323
|
start: stringOrUndefined(values.start),
|
|
265
324
|
locale: stringOrUndefined(values.locale),
|
|
266
325
|
root: stringOrUndefined(values.root),
|
|
326
|
+
scope,
|
|
327
|
+
server,
|
|
267
328
|
};
|
|
268
329
|
}
|
|
269
330
|
|
package/src/cli/help.ts
CHANGED
|
@@ -9,6 +9,7 @@ USAGE
|
|
|
9
9
|
nowline <input> [options]
|
|
10
10
|
nowline --serve <input> [options]
|
|
11
11
|
nowline --init [<name>]
|
|
12
|
+
nowline --mcp-install [<client>] [options]
|
|
12
13
|
nowline --version
|
|
13
14
|
nowline --help
|
|
14
15
|
|
|
@@ -35,9 +36,22 @@ MODE FLAGS (mutually exclusive)
|
|
|
35
36
|
exposing validate, render, export, and file tools.
|
|
36
37
|
Shares the same @nowline/mcp server code as
|
|
37
38
|
\`npx @nowline/mcp\`.
|
|
39
|
+
--mcp-install [<client>]
|
|
40
|
+
Register the MCP server with an agent: claude-code,
|
|
41
|
+
claude-desktop, cursor, vscode, codex, gemini,
|
|
42
|
+
antigravity, muse, opencode, copilot, windsurf, zed,
|
|
43
|
+
amp, goose, or all. Omitted: every agent found on this machine.
|
|
44
|
+
Re-running is safe; -n previews the change.
|
|
45
|
+
--mcp-uninstall [<client>]
|
|
46
|
+
Remove the server from the same configs.
|
|
38
47
|
-n, --dry-run Run the full pipeline (parse + validate + layout +
|
|
39
48
|
format) but skip the write step. Subsumes the old
|
|
40
49
|
'validate' verb. Exit 0 on success, 1 on errors.
|
|
50
|
+
--diagnostic-format <f>
|
|
51
|
+
Diagnostics on stderr: text | json. json prints one
|
|
52
|
+
{ "$nowlineDiagnostics": "1", ... } document per run
|
|
53
|
+
(warnings included). Default: json with --dry-run
|
|
54
|
+
--format=json, else text.
|
|
41
55
|
|
|
42
56
|
RENDER OPTIONS
|
|
43
57
|
-t, --theme <name> light | dark | grayscale (greyscale alias)
|
|
@@ -61,8 +75,18 @@ RENDER OPTIONS
|
|
|
61
75
|
--no-links Omit link icons from rendered items.
|
|
62
76
|
-s, --scale <n> Raster scale factor (PNG only; default 1).
|
|
63
77
|
--strict Promote asset / sanitizer warnings to errors.
|
|
64
|
-
-w, --width <px>
|
|
65
|
-
|
|
78
|
+
-w, --width <px> Maximum canvas width in pixels. Default: fit the
|
|
79
|
+
content. A wider roadmap shrinks its timeline to
|
|
80
|
+
fit; long captions can still overflow it.
|
|
81
|
+
--non-working <hide|show>
|
|
82
|
+
How a business calendar's non-working days appear:
|
|
83
|
+
hide collapses them (the default), show draws them
|
|
84
|
+
as shaded bands. Overrides the file's
|
|
85
|
+
'default roadmap non-working:' key; omit it to let
|
|
86
|
+
the key apply. Mermaid, MS Project, and XLSX output
|
|
87
|
+
is the same in both views.
|
|
88
|
+
--asset-root <dir> Root for logo / image assets (default: the
|
|
89
|
+
.nowlinerc assetRoot key, then the input dir).
|
|
66
90
|
--locale <bcp47> BCP-47 locale (e.g. fr, fr-CA) for CLI message
|
|
67
91
|
output (validator diagnostics, --help, errors).
|
|
68
92
|
Used as a fallback for the rendered drawing only
|
|
@@ -94,6 +118,13 @@ SERVE OPTIONS
|
|
|
94
118
|
MCP OPTIONS
|
|
95
119
|
--root <dir> Allowed-root directory for file tools (default: cwd).
|
|
96
120
|
File paths are resolved and restricted to this root.
|
|
121
|
+
With --mcp-install, written into the registered
|
|
122
|
+
command.
|
|
123
|
+
--scope <s> --mcp-install target: user (default, every
|
|
124
|
+
project) or project (config files in cwd).
|
|
125
|
+
--server <s> What --mcp-install registers: self (default, this
|
|
126
|
+
nowline binary; needs no Node) or npx
|
|
127
|
+
(npx -y @nowline/mcp, tracks npm).
|
|
97
128
|
|
|
98
129
|
LOGGING (mutually exclusive)
|
|
99
130
|
-v, --verbose Print extra diagnostics to stderr.
|
|
@@ -115,6 +146,8 @@ EXAMPLES
|
|
|
115
146
|
nowline --init my-project # scaffold ./my-project.nowline
|
|
116
147
|
nowline --mcp # start MCP stdio server in cwd
|
|
117
148
|
nowline --mcp --root ./roadmaps # MCP server with custom root
|
|
149
|
+
nowline --mcp-install # register with every agent found
|
|
150
|
+
nowline --mcp-install cursor --scope project -n # preview .cursor/mcp.json
|
|
118
151
|
|
|
119
152
|
EXIT CODES
|
|
120
153
|
0 Success
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// How the CLI turns flags and `.nowlinerc` keys into render options. The five
|
|
2
|
+
// shared options (theme, width, now, page size, non-working display) come from
|
|
3
|
+
// `@nowline/config`'s schema; the CLI-only keys are registered here as option
|
|
4
|
+
// specs over the same two layers, so adding an rc key is one entry in
|
|
5
|
+
// `CLI_OPTION_SPECS` rather than a hand-written merge in each command
|
|
6
|
+
// (specs/surface-options.md §3.1).
|
|
7
|
+
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
ASSET_ROOT_SPEC,
|
|
11
|
+
type NowlineRc,
|
|
12
|
+
type OptionError,
|
|
13
|
+
type OptionLayer,
|
|
14
|
+
type OptionParse,
|
|
15
|
+
type OptionSpec,
|
|
16
|
+
rcLayer,
|
|
17
|
+
resolveOptions,
|
|
18
|
+
resolveSurfaceOptions,
|
|
19
|
+
SURFACE_OPTION_SPECS,
|
|
20
|
+
type SurfaceOptions,
|
|
21
|
+
} from '@nowline/config';
|
|
22
|
+
import { lengthToPoints, parseLength } from '@nowline/export-core';
|
|
23
|
+
import { type NormalizedZone, normalizeZone, TimezoneError } from '@nowline/layout';
|
|
24
|
+
import { CliError, ExitCode } from '../io/exit-codes.js';
|
|
25
|
+
import type { ParsedArgs } from './args.js';
|
|
26
|
+
|
|
27
|
+
// ---- CLI-only option specs --------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export type PdfOrientation = 'portrait' | 'landscape' | 'auto';
|
|
30
|
+
|
|
31
|
+
const ORIENTATIONS: readonly PdfOrientation[] = ['portrait', 'landscape', 'auto'];
|
|
32
|
+
|
|
33
|
+
/** An rc key the CLI reads as a string; any other type is ignored, as it always was. */
|
|
34
|
+
function lenientString(raw: unknown): OptionParse<string> {
|
|
35
|
+
return { ok: true, value: typeof raw === 'string' ? raw : undefined };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An rc or flag switch: only a literal `true` turns it on. */
|
|
39
|
+
function lenientSwitch(raw: unknown): OptionParse<boolean> {
|
|
40
|
+
return { ok: true, value: raw === true ? true : undefined };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseOrientation(raw: unknown): OptionParse<PdfOrientation> {
|
|
44
|
+
if (raw === '') return { ok: true, value: undefined };
|
|
45
|
+
const lower = typeof raw === 'string' ? raw.toLowerCase() : undefined;
|
|
46
|
+
const found = ORIENTATIONS.find((orientation) => orientation === lower);
|
|
47
|
+
if (found) return { ok: true, value: found };
|
|
48
|
+
return { ok: false, detail: 'must be an orientation', expected: ORIENTATIONS };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseMargin(raw: unknown): OptionParse<number> {
|
|
52
|
+
if (raw === '') return { ok: true, value: undefined };
|
|
53
|
+
// A bare number is points; YAML and JSON hand the rc one over as a number.
|
|
54
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
|
|
55
|
+
return { ok: true, value: raw };
|
|
56
|
+
}
|
|
57
|
+
const text = String(raw);
|
|
58
|
+
if (/^\d+(?:\.\d+)?$/.test(text)) return { ok: true, value: Number(text) };
|
|
59
|
+
try {
|
|
60
|
+
return { ok: true, value: lengthToPoints(parseLength(text)) };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every option the CLI resolves over its flag and rc layers: the shared five, then its own. */
|
|
67
|
+
export const CLI_OPTION_SPECS = Object.freeze({
|
|
68
|
+
...SURFACE_OPTION_SPECS,
|
|
69
|
+
orientation: { rcKey: 'pdfOrientation', parse: parseOrientation } as OptionSpec<PdfOrientation>,
|
|
70
|
+
margin: { rcKey: 'pdfMargin', parse: parseMargin } as OptionSpec<number>,
|
|
71
|
+
defaultFormat: { rcKey: 'defaultFormat', parse: lenientString } as OptionSpec<string>,
|
|
72
|
+
fontSans: { rcKey: 'fontSans', parse: lenientString } as OptionSpec<string>,
|
|
73
|
+
fontMono: { rcKey: 'fontMono', parse: lenientString } as OptionSpec<string>,
|
|
74
|
+
headless: { rcKey: 'headlessFonts', parse: lenientSwitch } as OptionSpec<boolean>,
|
|
75
|
+
useSystemFonts: { rcKey: 'useSystemFonts', parse: lenientSwitch } as OptionSpec<boolean>,
|
|
76
|
+
locale: { rcKey: 'locale', parse: lenientString } as OptionSpec<string>,
|
|
77
|
+
assetRoot: ASSET_ROOT_SPEC as OptionSpec<string>,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
type CliSpecs = typeof CLI_OPTION_SPECS;
|
|
81
|
+
export type CliOptionKey = Extract<keyof CliSpecs, string>;
|
|
82
|
+
|
|
83
|
+
/** The flag each option is passed as, for an error that names its source. */
|
|
84
|
+
const FLAG_NAMES: Readonly<Partial<Record<CliOptionKey, string>>> = {
|
|
85
|
+
theme: '--theme',
|
|
86
|
+
width: '--width',
|
|
87
|
+
now: '--now',
|
|
88
|
+
pageSize: '--page-size',
|
|
89
|
+
nonWorking: '--non-working',
|
|
90
|
+
orientation: '--orientation',
|
|
91
|
+
margin: '--margin',
|
|
92
|
+
fontSans: '--font-sans',
|
|
93
|
+
fontMono: '--font-mono',
|
|
94
|
+
headless: '--headless',
|
|
95
|
+
useSystemFonts: '--use-system-fonts',
|
|
96
|
+
assetRoot: '--asset-root',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ---- Resolution -------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
export interface CliOptions {
|
|
102
|
+
/** Theme, width, now-line date and non-working display; page size when set. */
|
|
103
|
+
surface: SurfaceOptions;
|
|
104
|
+
orientation?: PdfOrientation;
|
|
105
|
+
/** Page margin in points. */
|
|
106
|
+
margin?: number;
|
|
107
|
+
/** The rc `defaultFormat`; the `--format` flag is resolved by `resolveFormat`. */
|
|
108
|
+
defaultFormat?: string;
|
|
109
|
+
fontSans?: string;
|
|
110
|
+
fontMono?: string;
|
|
111
|
+
headless: boolean;
|
|
112
|
+
useSystemFonts: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Where `logo:` assets are read from, as an absolute path: `--asset-root`,
|
|
115
|
+
* else the rc `assetRoot`. Unset means the input file's directory.
|
|
116
|
+
*/
|
|
117
|
+
assetRoot?: string;
|
|
118
|
+
/** The rc `locale`; the flag and `NOWLINE_LOCALE` rank above it (`resolveLocaleOverride`). */
|
|
119
|
+
rcLocale?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Throw the first invalid option as a usage error, in the order the CLI
|
|
122
|
+
* has always reported them: theme, timezone, now, width, non-working.
|
|
123
|
+
*/
|
|
124
|
+
assertValid(): void;
|
|
125
|
+
/**
|
|
126
|
+
* Throw the first invalid PDF setting (page size, orientation, margin).
|
|
127
|
+
* Run after validation, so these errors follow any diagnostics.
|
|
128
|
+
*/
|
|
129
|
+
assertPdfSettingsValid(): void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the CLI's options over the flag layer, then the `.nowlinerc` layer.
|
|
134
|
+
* `render` and `--serve` both call this, so they read the same keys the same
|
|
135
|
+
* way. `cwd` is what a relative `--asset-root` resolves against (default: the
|
|
136
|
+
* process's). Nothing throws here: a caller reads `defaultFormat` early and reports bad
|
|
137
|
+
* values at the point it always did, through `assertValid`.
|
|
138
|
+
*/
|
|
139
|
+
export function resolveCliOptions(
|
|
140
|
+
args: ParsedArgs,
|
|
141
|
+
config: NowlineRc | null,
|
|
142
|
+
context: { rcPath?: string; cwd?: string; clock?: () => Date } = {},
|
|
143
|
+
): CliOptions {
|
|
144
|
+
const { rcPath, cwd, clock } = context;
|
|
145
|
+
const layers = [flagLayer(args), rcLayer(config ?? {}, CLI_OPTION_SPECS)];
|
|
146
|
+
|
|
147
|
+
// `--timezone` is validated even when `--now` is explicit, as it always was.
|
|
148
|
+
const zone = readZone(args.timezone);
|
|
149
|
+
const { options, errors: surfaceErrors } = resolveSurfaceOptions(
|
|
150
|
+
layers,
|
|
151
|
+
{ theme: 'light' },
|
|
152
|
+
{ zone: zone.zone, clock },
|
|
153
|
+
);
|
|
154
|
+
const { values, errors: cliErrors } = resolveOptions(CLI_OPTION_SPECS, layers);
|
|
155
|
+
const failures = new Map<string, OptionError<CliOptionKey>>(
|
|
156
|
+
[...surfaceErrors, ...cliErrors].map((error) => [error.key, error]),
|
|
157
|
+
);
|
|
158
|
+
const failure = (key: CliOptionKey): CliError | undefined => {
|
|
159
|
+
const error = failures.get(key);
|
|
160
|
+
return error && new CliError(ExitCode.InputError, formatOptionError(error));
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
surface: options,
|
|
165
|
+
orientation: values.orientation,
|
|
166
|
+
margin: values.margin,
|
|
167
|
+
defaultFormat: values.defaultFormat,
|
|
168
|
+
fontSans: values.fontSans,
|
|
169
|
+
fontMono: values.fontMono,
|
|
170
|
+
headless: values.headless ?? false,
|
|
171
|
+
useSystemFonts: values.useSystemFonts ?? false,
|
|
172
|
+
assetRoot: absoluteAssetRoot(values.assetRoot, args.assetRoot !== undefined, rcPath, cwd),
|
|
173
|
+
rcLocale: values.locale,
|
|
174
|
+
assertValid: () =>
|
|
175
|
+
throwFirst([
|
|
176
|
+
failure('theme'),
|
|
177
|
+
zone.error,
|
|
178
|
+
failure('now'),
|
|
179
|
+
failure('width'),
|
|
180
|
+
failure('nonWorking'),
|
|
181
|
+
failure('assetRoot'),
|
|
182
|
+
]),
|
|
183
|
+
assertPdfSettingsValid: () =>
|
|
184
|
+
throwFirst([failure('pageSize'), failure('orientation'), failure('margin')]),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function throwFirst(candidates: readonly (CliError | undefined)[]): void {
|
|
189
|
+
for (const candidate of candidates) {
|
|
190
|
+
if (candidate) throw candidate;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function flagLayer(args: ParsedArgs): OptionLayer<CliOptionKey> {
|
|
195
|
+
return {
|
|
196
|
+
source: (key) => ({ kind: 'flag', name: FLAG_NAMES[key] ?? `--${key}` }),
|
|
197
|
+
values: {
|
|
198
|
+
// An empty `--theme ""` has always meant "not passed", so the rc theme applies.
|
|
199
|
+
theme: args.theme || undefined,
|
|
200
|
+
width: args.width,
|
|
201
|
+
now: args.now,
|
|
202
|
+
pageSize: args.pageSize,
|
|
203
|
+
nonWorking: args.nonWorking,
|
|
204
|
+
orientation: args.orientation,
|
|
205
|
+
margin: args.margin,
|
|
206
|
+
fontSans: args.fontSans,
|
|
207
|
+
fontMono: args.fontMono,
|
|
208
|
+
assetRoot: args.assetRoot,
|
|
209
|
+
// A switch flag is absent or on; absent leaves the rc to decide.
|
|
210
|
+
headless: args.headless || undefined,
|
|
211
|
+
useSystemFonts: args.useSystemFonts || undefined,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readZone(raw: string | undefined): { zone?: NormalizedZone; error?: CliError } {
|
|
217
|
+
if (!raw) return {};
|
|
218
|
+
try {
|
|
219
|
+
return { zone: normalizeZone(raw) };
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (err instanceof TimezoneError) {
|
|
222
|
+
return { error: new CliError(ExitCode.InputError, err.message) };
|
|
223
|
+
}
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- Asset root -------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The asset root as an absolute path. A flag resolves against `cwd` (the
|
|
232
|
+
* process's, unless a caller passes its own); an rc value against the
|
|
233
|
+
* directory of the `.nowlinerc` it came from, so a checked-in `assetRoot:
|
|
234
|
+
* assets` means the same directory wherever `nowline` is run from.
|
|
235
|
+
*/
|
|
236
|
+
function absoluteAssetRoot(
|
|
237
|
+
value: string | undefined,
|
|
238
|
+
fromFlag: boolean,
|
|
239
|
+
rcPath: string | undefined,
|
|
240
|
+
cwd?: string,
|
|
241
|
+
): string | undefined {
|
|
242
|
+
if (value === undefined) return undefined;
|
|
243
|
+
if (fromFlag || rcPath === undefined) return path.resolve(cwd ?? '.', value);
|
|
244
|
+
return path.resolve(path.dirname(rcPath), value);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- Error text -------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* An {@link OptionError} in the CLI's wording, naming the flag or `.nowlinerc`
|
|
251
|
+
* key the value came from: `nowline: invalid --width "abc". Must be an integer
|
|
252
|
+
* ≥ 320.` The templates are the ones the CLI used before the schema, so
|
|
253
|
+
* scripts that match them keep working.
|
|
254
|
+
*/
|
|
255
|
+
export function formatOptionError(error: OptionError<string>): string {
|
|
256
|
+
const lead = `nowline: invalid ${describeSource(error.source)} "${String(error.raw)}"`;
|
|
257
|
+
if (error.expected) return `${lead}. Expected ${formatChoices(error.expected)}.`;
|
|
258
|
+
switch (error.key) {
|
|
259
|
+
// Detail reads "must be YYYY-MM-DD, ...": the CLI says "Expected".
|
|
260
|
+
case 'now':
|
|
261
|
+
return `${lead}. Expected ${error.detail.replace(/^must be /, '')}.`;
|
|
262
|
+
// These two carry the parser's own message after a colon.
|
|
263
|
+
case 'pageSize':
|
|
264
|
+
case 'margin':
|
|
265
|
+
return `${lead}: ${error.detail}`;
|
|
266
|
+
default:
|
|
267
|
+
return `${lead}. ${error.detail.charAt(0).toUpperCase()}${error.detail.slice(1)}.`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function describeSource(source: OptionError['source']): string {
|
|
272
|
+
switch (source.kind) {
|
|
273
|
+
case 'flag':
|
|
274
|
+
case 'argument':
|
|
275
|
+
case 'setting':
|
|
276
|
+
return source.name;
|
|
277
|
+
case 'rc':
|
|
278
|
+
return `.nowlinerc ${source.key}`;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// `a`, `a or b`, `a, b, or c`.
|
|
283
|
+
export function formatChoices(names: readonly string[]): string {
|
|
284
|
+
if (names.length <= 2) return names.join(' or ');
|
|
285
|
+
return `${names.slice(0, -1).join(', ')}, or ${names[names.length - 1]}`;
|
|
286
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ParsedArgs } from '../cli/args.js';
|
|
|
2
2
|
import { defaultInitOutputPath, initNameHasIncompatibleExtension } from '../cli/output-path.js';
|
|
3
3
|
import { TEMPLATE_NAMES, TEMPLATES, type TemplateName } from '../generated/templates.js';
|
|
4
4
|
import { CliError, ExitCode } from '../io/exit-codes.js';
|
|
5
|
+
import { createLog } from '../io/log.js';
|
|
5
6
|
import { writeOutput } from '../io/write.js';
|
|
6
7
|
|
|
7
8
|
export interface InitHandlerOptions {
|
|
@@ -47,9 +48,7 @@ export async function initHandler(options: InitHandlerOptions): Promise<void> {
|
|
|
47
48
|
|
|
48
49
|
await writeOutput(outputPath, rendered, 'text', { cwd });
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
process.stderr.write(`nowline: wrote ${outputPath}\n`);
|
|
52
|
-
}
|
|
51
|
+
createLog(args.logLevel).verbose(`nowline: wrote ${outputPath}`);
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
function parseTemplateName(raw: string | undefined): TemplateName {
|