@nowline/cli 0.8.6 → 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.
Files changed (89) hide show
  1. package/README.md +32 -9
  2. package/dist/cli/args.d.ts +20 -4
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +20 -0
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/help.d.ts.map +1 -1
  7. package/dist/cli/help.js +30 -2
  8. package/dist/cli/help.js.map +1 -1
  9. package/dist/cli/options.d.ts +74 -0
  10. package/dist/cli/options.d.ts.map +1 -0
  11. package/dist/cli/options.js +209 -0
  12. package/dist/cli/options.js.map +1 -0
  13. package/dist/commands/init.d.ts.map +1 -1
  14. package/dist/commands/init.js +2 -3
  15. package/dist/commands/init.js.map +1 -1
  16. package/dist/commands/mcp-install.d.ts +18 -0
  17. package/dist/commands/mcp-install.d.ts.map +1 -0
  18. package/dist/commands/mcp-install.js +151 -0
  19. package/dist/commands/mcp-install.js.map +1 -0
  20. package/dist/commands/mcp.d.ts.map +1 -1
  21. package/dist/commands/mcp.js +5 -11
  22. package/dist/commands/mcp.js.map +1 -1
  23. package/dist/commands/render.d.ts +7 -0
  24. package/dist/commands/render.d.ts.map +1 -1
  25. package/dist/commands/render.js +161 -155
  26. package/dist/commands/render.js.map +1 -1
  27. package/dist/commands/serve.d.ts.map +1 -1
  28. package/dist/commands/serve.js +87 -60
  29. package/dist/commands/serve.js.map +1 -1
  30. package/dist/diagnostics/adapt.d.ts +12 -1
  31. package/dist/diagnostics/adapt.d.ts.map +1 -1
  32. package/dist/diagnostics/adapt.js +53 -1
  33. package/dist/diagnostics/adapt.js.map +1 -1
  34. package/dist/diagnostics/model.d.ts +1 -1
  35. package/dist/generated/version.d.ts +1 -1
  36. package/dist/generated/version.js +2 -2
  37. package/dist/i18n/locale.d.ts.map +1 -1
  38. package/dist/i18n/locale.js +2 -5
  39. package/dist/i18n/locale.js.map +1 -1
  40. package/dist/index.js +4 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/io/config.d.ts +1 -1
  43. package/dist/io/config.d.ts.map +1 -1
  44. package/dist/io/config.js +1 -1
  45. package/dist/io/config.js.map +1 -1
  46. package/dist/io/log.d.ts +17 -0
  47. package/dist/io/log.d.ts.map +1 -0
  48. package/dist/io/log.js +16 -0
  49. package/dist/io/log.js.map +1 -0
  50. package/dist/mcp-install/clients.d.ts +58 -0
  51. package/dist/mcp-install/clients.d.ts.map +1 -0
  52. package/dist/mcp-install/clients.js +304 -0
  53. package/dist/mcp-install/clients.js.map +1 -0
  54. package/dist/mcp-install/host.d.ts +29 -0
  55. package/dist/mcp-install/host.d.ts.map +1 -0
  56. package/dist/mcp-install/host.js +72 -0
  57. package/dist/mcp-install/host.js.map +1 -0
  58. package/dist/mcp-install/json-config.d.ts +29 -0
  59. package/dist/mcp-install/json-config.d.ts.map +1 -0
  60. package/dist/mcp-install/json-config.js +139 -0
  61. package/dist/mcp-install/json-config.js.map +1 -0
  62. package/dist/mcp-install/server-command.d.ts +26 -0
  63. package/dist/mcp-install/server-command.d.ts.map +1 -0
  64. package/dist/mcp-install/server-command.js +52 -0
  65. package/dist/mcp-install/server-command.js.map +1 -0
  66. package/man/fr/nowline.1 +246 -19
  67. package/man/fr/nowline.5 +819 -45
  68. package/man/nowline.1 +191 -18
  69. package/man/nowline.5 +764 -36
  70. package/package.json +19 -19
  71. package/src/cli/args.ts +55 -5
  72. package/src/cli/help.ts +30 -2
  73. package/src/cli/options.ts +286 -0
  74. package/src/commands/init.ts +2 -3
  75. package/src/commands/mcp-install.ts +211 -0
  76. package/src/commands/mcp.ts +8 -13
  77. package/src/commands/render.ts +198 -195
  78. package/src/commands/serve.ts +102 -74
  79. package/src/diagnostics/adapt.ts +62 -0
  80. package/src/diagnostics/model.ts +1 -1
  81. package/src/generated/version.ts +2 -2
  82. package/src/i18n/locale.ts +2 -6
  83. package/src/index.ts +3 -0
  84. package/src/io/config.ts +0 -1
  85. package/src/io/log.ts +33 -0
  86. package/src/mcp-install/clients.ts +443 -0
  87. package/src/mcp-install/host.ts +106 -0
  88. package/src/mcp-install/json-config.ts +179 -0
  89. 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.8.6",
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": {
@@ -32,33 +32,33 @@
32
32
  "dependencies": {
33
33
  "@babel/code-frame": "^8.0.6",
34
34
  "@clack/prompts": "^1.8.1",
35
- "@modelcontextprotocol/node": "^2.1.0",
36
- "@modelcontextprotocol/server": "^2.2.0",
37
- "@nowline/config": "0.8.6",
38
- "@nowline/core": "0.8.6",
39
- "@nowline/export": "0.8.6",
40
- "@nowline/export-core": "0.8.6",
41
- "@nowline/export-html": "0.8.6",
42
- "@nowline/export-mermaid": "0.8.6",
43
- "@nowline/export-msproj": "0.8.6",
44
- "@nowline/export-pdf": "0.8.6",
45
- "@nowline/export-png": "0.8.6",
46
- "@nowline/export-xlsx": "0.8.6",
47
- "@nowline/layout": "0.8.6",
48
- "@nowline/mcp": "0.8.6",
49
- "@nowline/renderer": "0.8.6",
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",
50
49
  "chalk": "^6.0.1",
51
50
  "citty": "^0.2.2",
52
51
  "consola": "^3.4.2",
53
- "js-yaml": "^5.4.2",
52
+ "js-yaml": "^5.5.0",
53
+ "jsonc-parser": "^3.3.1",
54
54
  "langium": "~4.4.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@resvg/resvg-wasm": "^2.6.2",
58
58
  "@types/babel__code-frame": "^7.27.0",
59
- "@types/node": "^26.6.3",
59
+ "@types/node": "^26.6.5",
60
60
  "typescript": "^7.0.2",
61
- "vitest": "^5.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,18 +1,33 @@
1
1
  import { type ParseArgsConfig, parseArgs } from 'node:util';
2
2
  import { isDiagnosticFormat } from '../diagnostics/format.js';
3
3
  import { CliError, ExitCode } from '../io/exit-codes.js';
4
+ import type { LogLevel } from '../io/log.js';
4
5
 
5
- export type ModeKind = 'render' | 'serve' | 'init' | 'mcp' | 'help' | 'version';
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';
6
18
 
7
19
  export interface ParsedArgs {
8
20
  /** Resolved mode after dispatch (mutual-exclusivity already checked). */
9
21
  mode: ModeKind;
10
- /** Positional argument, or undefined. Render = input path. Init = project name. Serve = input path. */
22
+ /**
23
+ * Positional argument, or undefined. Render = input path. Init = project name. Serve = input path.
24
+ * MCP install / uninstall = client id.
25
+ */
11
26
  positional?: string;
12
- /** True if `--dry-run` / `-n` was passed (only valid for render mode). */
27
+ /** True if `--dry-run` / `-n` was passed (render and MCP install / uninstall). */
13
28
  dryRun: boolean;
14
29
  /** Logging level. Verbose and quiet are mutually exclusive. */
15
- logLevel: 'verbose' | 'quiet' | 'normal';
30
+ logLevel: LogLevel;
16
31
 
17
32
  // I/O
18
33
  output?: string;
@@ -38,6 +53,12 @@ export interface ParsedArgs {
38
53
  scale?: string;
39
54
  strict: boolean;
40
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;
41
62
  assetRoot?: string;
42
63
 
43
64
  // Format-specific options (m2c)
@@ -78,6 +99,10 @@ export interface ParsedArgs {
78
99
 
79
100
  // MCP
80
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;
81
106
  }
82
107
 
83
108
  /**
@@ -118,6 +143,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
118
143
  serve: { type: 'boolean' },
119
144
  init: { type: 'boolean' },
120
145
  mcp: { type: 'boolean' },
146
+ 'mcp-install': { type: 'boolean' },
147
+ 'mcp-uninstall': { type: 'boolean' },
121
148
  'dry-run': { type: 'boolean', short: 'n' },
122
149
 
123
150
  theme: { type: 'string', short: 't' },
@@ -133,6 +160,7 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
133
160
  scale: { type: 'string', short: 's' },
134
161
  strict: { type: 'boolean' },
135
162
  width: { type: 'string', short: 'w' },
163
+ 'non-working': { type: 'string' },
136
164
  'asset-root': { type: 'string' },
137
165
 
138
166
  port: { type: 'string', short: 'p' },
@@ -145,6 +173,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
145
173
 
146
174
  // MCP
147
175
  root: { type: 'string' },
176
+ scope: { type: 'string' },
177
+ server: { type: 'string' },
148
178
 
149
179
  // Format-specific (m2c)
150
180
  'page-size': { type: 'string' },
@@ -208,6 +238,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
208
238
  if (values.serve === true) modes.push('serve');
209
239
  if (values.init === true) modes.push('init');
210
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');
211
243
  if (modes.length > 1) {
212
244
  throw new CliError(
213
245
  ExitCode.InputError,
@@ -233,6 +265,21 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
233
265
  throw new CliError(ExitCode.InputError, `nowline: unexpected extra arguments: ${extras}.`);
234
266
  }
235
267
 
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
+
236
283
  const rawDiagnosticFormat = stringOrUndefined(values['diagnostic-format']);
237
284
  if (rawDiagnosticFormat !== undefined && !isDiagnosticFormat(rawDiagnosticFormat)) {
238
285
  throw new CliError(
@@ -241,7 +288,7 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
241
288
  );
242
289
  }
243
290
 
244
- const logLevel: ParsedArgs['logLevel'] =
291
+ const logLevel: LogLevel =
245
292
  values.verbose === true ? 'verbose' : values.quiet === true ? 'quiet' : 'normal';
246
293
 
247
294
  return {
@@ -259,6 +306,7 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
259
306
  scale: stringOrUndefined(values.scale),
260
307
  strict: values.strict === true,
261
308
  width: stringOrUndefined(values.width),
309
+ nonWorking: stringOrUndefined(values['non-working']),
262
310
  assetRoot: stringOrUndefined(values['asset-root']),
263
311
  port: stringOrUndefined(values.port),
264
312
  host: stringOrUndefined(values.host),
@@ -275,6 +323,8 @@ export function parseArgv(argv: readonly string[]): ParsedArgs {
275
323
  start: stringOrUndefined(values.start),
276
324
  locale: stringOrUndefined(values.locale),
277
325
  root: stringOrUndefined(values.root),
326
+ scope,
327
+ server,
278
328
  };
279
329
  }
280
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,6 +36,14 @@ 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.
@@ -66,8 +75,18 @@ RENDER OPTIONS
66
75
  --no-links Omit link icons from rendered items.
67
76
  -s, --scale <n> Raster scale factor (PNG only; default 1).
68
77
  --strict Promote asset / sanitizer warnings to errors.
69
- -w, --width <px> Canvas width in pixels (default: 1280).
70
- --asset-root <dir> Root for logo / image assets (default: input dir).
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).
71
90
  --locale <bcp47> BCP-47 locale (e.g. fr, fr-CA) for CLI message
72
91
  output (validator diagnostics, --help, errors).
73
92
  Used as a fallback for the rendered drawing only
@@ -99,6 +118,13 @@ SERVE OPTIONS
99
118
  MCP OPTIONS
100
119
  --root <dir> Allowed-root directory for file tools (default: cwd).
101
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).
102
128
 
103
129
  LOGGING (mutually exclusive)
104
130
  -v, --verbose Print extra diagnostics to stderr.
@@ -120,6 +146,8 @@ EXAMPLES
120
146
  nowline --init my-project # scaffold ./my-project.nowline
121
147
  nowline --mcp # start MCP stdio server in cwd
122
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
123
151
 
124
152
  EXIT CODES
125
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
+ }
@@ -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
- if (args.logLevel === 'verbose') {
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 {