@nowline/cli 0.0.0-dev.20260601071750.g04bdff9

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 (139) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +372 -0
  3. package/dist/cli/args.d.ts +54 -0
  4. package/dist/cli/args.d.ts.map +1 -0
  5. package/dist/cli/args.js +165 -0
  6. package/dist/cli/args.js.map +1 -0
  7. package/dist/cli/formats.d.ts +61 -0
  8. package/dist/cli/formats.d.ts.map +1 -0
  9. package/dist/cli/formats.js +153 -0
  10. package/dist/cli/formats.js.map +1 -0
  11. package/dist/cli/help.d.ts +3 -0
  12. package/dist/cli/help.d.ts.map +1 -0
  13. package/dist/cli/help.js +90 -0
  14. package/dist/cli/help.js.map +1 -0
  15. package/dist/cli/output-path.d.ts +57 -0
  16. package/dist/cli/output-path.d.ts.map +1 -0
  17. package/dist/cli/output-path.js +70 -0
  18. package/dist/cli/output-path.js.map +1 -0
  19. package/dist/commands/init.d.ts +20 -0
  20. package/dist/commands/init.d.ts.map +1 -0
  21. package/dist/commands/init.js +80 -0
  22. package/dist/commands/init.js.map +1 -0
  23. package/dist/commands/render.d.ts +15 -0
  24. package/dist/commands/render.d.ts.map +1 -0
  25. package/dist/commands/render.js +436 -0
  26. package/dist/commands/render.js.map +1 -0
  27. package/dist/commands/serve.d.ts +16 -0
  28. package/dist/commands/serve.d.ts.map +1 -0
  29. package/dist/commands/serve.js +287 -0
  30. package/dist/commands/serve.js.map +1 -0
  31. package/dist/convert/parse-json.d.ts +7 -0
  32. package/dist/convert/parse-json.d.ts.map +1 -0
  33. package/dist/convert/parse-json.js +34 -0
  34. package/dist/convert/parse-json.js.map +1 -0
  35. package/dist/convert/printer.d.ts +6 -0
  36. package/dist/convert/printer.d.ts.map +1 -0
  37. package/dist/convert/printer.js +334 -0
  38. package/dist/convert/printer.js.map +1 -0
  39. package/dist/convert/schema.d.ts +33 -0
  40. package/dist/convert/schema.d.ts.map +1 -0
  41. package/dist/convert/schema.js +77 -0
  42. package/dist/convert/schema.js.map +1 -0
  43. package/dist/core/parse.d.ts +24 -0
  44. package/dist/core/parse.d.ts.map +1 -0
  45. package/dist/core/parse.js +64 -0
  46. package/dist/core/parse.js.map +1 -0
  47. package/dist/diagnostics/adapt.d.ts +6 -0
  48. package/dist/diagnostics/adapt.d.ts.map +1 -0
  49. package/dist/diagnostics/adapt.js +81 -0
  50. package/dist/diagnostics/adapt.js.map +1 -0
  51. package/dist/diagnostics/format.d.ts +18 -0
  52. package/dist/diagnostics/format.d.ts.map +1 -0
  53. package/dist/diagnostics/format.js +41 -0
  54. package/dist/diagnostics/format.js.map +1 -0
  55. package/dist/diagnostics/index.d.ts +5 -0
  56. package/dist/diagnostics/index.d.ts.map +1 -0
  57. package/dist/diagnostics/index.js +5 -0
  58. package/dist/diagnostics/index.js.map +1 -0
  59. package/dist/diagnostics/json.d.ts +8 -0
  60. package/dist/diagnostics/json.d.ts.map +1 -0
  61. package/dist/diagnostics/json.js +24 -0
  62. package/dist/diagnostics/json.js.map +1 -0
  63. package/dist/diagnostics/model.d.ts +44 -0
  64. package/dist/diagnostics/model.d.ts.map +1 -0
  65. package/dist/diagnostics/model.js +2 -0
  66. package/dist/diagnostics/model.js.map +1 -0
  67. package/dist/diagnostics/text.d.ts +6 -0
  68. package/dist/diagnostics/text.d.ts.map +1 -0
  69. package/dist/diagnostics/text.js +43 -0
  70. package/dist/diagnostics/text.js.map +1 -0
  71. package/dist/generated/templates.d.ts +4 -0
  72. package/dist/generated/templates.d.ts.map +1 -0
  73. package/dist/generated/templates.js +10 -0
  74. package/dist/generated/templates.js.map +1 -0
  75. package/dist/generated/version.d.ts +11 -0
  76. package/dist/generated/version.d.ts.map +1 -0
  77. package/dist/generated/version.js +8 -0
  78. package/dist/generated/version.js.map +1 -0
  79. package/dist/i18n/locale.d.ts +56 -0
  80. package/dist/i18n/locale.d.ts.map +1 -0
  81. package/dist/i18n/locale.js +107 -0
  82. package/dist/i18n/locale.js.map +1 -0
  83. package/dist/index.d.ts +3 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +60 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/io/config.d.ts +2 -0
  88. package/dist/io/config.d.ts.map +1 -0
  89. package/dist/io/config.js +5 -0
  90. package/dist/io/config.js.map +1 -0
  91. package/dist/io/exit-codes.d.ts +12 -0
  92. package/dist/io/exit-codes.d.ts.map +1 -0
  93. package/dist/io/exit-codes.js +15 -0
  94. package/dist/io/exit-codes.js.map +1 -0
  95. package/dist/io/read.d.ts +13 -0
  96. package/dist/io/read.d.ts.map +1 -0
  97. package/dist/io/read.js +53 -0
  98. package/dist/io/read.js.map +1 -0
  99. package/dist/io/write.d.ts +32 -0
  100. package/dist/io/write.d.ts.map +1 -0
  101. package/dist/io/write.js +61 -0
  102. package/dist/io/write.js.map +1 -0
  103. package/dist/version.d.ts +13 -0
  104. package/dist/version.d.ts.map +1 -0
  105. package/dist/version.js +20 -0
  106. package/dist/version.js.map +1 -0
  107. package/man/fr/nowline.1 +463 -0
  108. package/man/fr/nowline.5 +1864 -0
  109. package/man/nowline.1 +448 -0
  110. package/man/nowline.5 +1785 -0
  111. package/package.json +70 -0
  112. package/scripts/bundle-templates.mjs +106 -0
  113. package/scripts/compile.mjs +131 -0
  114. package/src/cli/args.ts +252 -0
  115. package/src/cli/formats.ts +207 -0
  116. package/src/cli/help.ts +92 -0
  117. package/src/cli/output-path.ts +98 -0
  118. package/src/commands/init.ts +99 -0
  119. package/src/commands/render.ts +567 -0
  120. package/src/commands/serve.ts +322 -0
  121. package/src/convert/parse-json.ts +57 -0
  122. package/src/convert/printer.ts +376 -0
  123. package/src/convert/schema.ts +105 -0
  124. package/src/core/parse.ts +97 -0
  125. package/src/diagnostics/adapt.ts +89 -0
  126. package/src/diagnostics/format.ts +70 -0
  127. package/src/diagnostics/index.ts +4 -0
  128. package/src/diagnostics/json.ts +30 -0
  129. package/src/diagnostics/model.ts +48 -0
  130. package/src/diagnostics/text.ts +62 -0
  131. package/src/generated/templates.ts +13 -0
  132. package/src/generated/version.ts +18 -0
  133. package/src/i18n/locale.ts +133 -0
  134. package/src/index.ts +60 -0
  135. package/src/io/config.ts +11 -0
  136. package/src/io/exit-codes.ts +18 -0
  137. package/src/io/read.ts +70 -0
  138. package/src/io/write.ts +94 -0
  139. package/src/version.ts +21 -0
@@ -0,0 +1,70 @@
1
+ import { type MessageCode, tr } from '@nowline/core';
2
+ import { renderJson } from './json.js';
3
+ import type { CliDiagnostic, DiagnosticSource } from './model.js';
4
+ import { renderText } from './text.js';
5
+
6
+ export type DiagnosticFormat = 'text' | 'json';
7
+
8
+ export interface FormatDiagnosticsOptions {
9
+ color?: boolean;
10
+ /**
11
+ * Operator locale (BCP-47) used to re-format validator messages
12
+ * that carry `data: { code, args }`. Validator messages without
13
+ * `data` and parser/lexer errors pass through unchanged.
14
+ *
15
+ * Defaults to en-US: an unset operator locale produces the same
16
+ * canonical English text the validator stashed at parse time.
17
+ * See `specs/localization.md` for the two-chain model.
18
+ */
19
+ operatorLocale?: string;
20
+ }
21
+
22
+ export function isDiagnosticFormat(value: unknown): value is DiagnosticFormat {
23
+ return value === 'text' || value === 'json';
24
+ }
25
+
26
+ export function formatDiagnostics(
27
+ diagnostics: CliDiagnostic[],
28
+ format: DiagnosticFormat,
29
+ sources: Map<string, DiagnosticSource>,
30
+ options: FormatDiagnosticsOptions = {},
31
+ ): string {
32
+ const localized = relocalizeDiagnostics(diagnostics, options.operatorLocale);
33
+ if (format === 'json') return renderJson(localized);
34
+ return renderText(localized, sources, { color: options.color });
35
+ }
36
+
37
+ /**
38
+ * Re-format any diagnostic carrying `data.{code,args}` in the operator
39
+ * locale. Diagnostics without `data` (literal-English validator
40
+ * strings, parser/lexer errors, include-resolution errors) pass
41
+ * through verbatim.
42
+ *
43
+ * No-op when `operatorLocale` is undefined or `'en-US'`: the messages
44
+ * stashed by the validator are already en-US, so we avoid the work
45
+ * and the allocation in the common case.
46
+ */
47
+ function relocalizeDiagnostics(
48
+ diagnostics: CliDiagnostic[],
49
+ operatorLocale: string | undefined,
50
+ ): CliDiagnostic[] {
51
+ if (!operatorLocale || operatorLocale === 'en-US') return diagnostics;
52
+ return diagnostics.map((d) => {
53
+ if (!d.data) return d;
54
+ // The validator stashes args as the verbatim spread tuple it received,
55
+ // typed strongly per-code in `@nowline/core/i18n`. By the time it
56
+ // reaches us through `unknown` it has lost that tagged shape; we cast
57
+ // back here because the only producer is the validator and the
58
+ // `code` half of the pair pins the expected arity.
59
+ const args = d.data.args as Parameters<typeof tr<MessageCode>> extends [
60
+ string,
61
+ MessageCode,
62
+ ...infer R,
63
+ ]
64
+ ? R
65
+ : never;
66
+ const message = tr(operatorLocale, d.data.code as MessageCode, ...args);
67
+ if (message === d.message) return d;
68
+ return { ...d, message };
69
+ });
70
+ }
@@ -0,0 +1,4 @@
1
+ export * from './adapt.js';
2
+ export * from './format.js';
3
+ export { DIAGNOSTICS_SCHEMA_VERSION, type DiagnosticsDocument } from './json.js';
4
+ export * from './model.js';
@@ -0,0 +1,30 @@
1
+ import type { CliDiagnostic } from './model.js';
2
+
3
+ export const DIAGNOSTICS_SCHEMA_VERSION = '1';
4
+
5
+ export interface DiagnosticsDocument {
6
+ $nowlineDiagnostics: string;
7
+ diagnostics: CliDiagnostic[];
8
+ }
9
+
10
+ export function renderJson(diagnostics: CliDiagnostic[]): string {
11
+ const doc: DiagnosticsDocument = {
12
+ $nowlineDiagnostics: DIAGNOSTICS_SCHEMA_VERSION,
13
+ diagnostics: diagnostics.map(normalize),
14
+ };
15
+ return JSON.stringify(doc, null, 2);
16
+ }
17
+
18
+ function normalize(d: CliDiagnostic): CliDiagnostic {
19
+ const out: CliDiagnostic = {
20
+ file: d.file,
21
+ line: d.line,
22
+ column: d.column,
23
+ severity: d.severity,
24
+ code: d.code,
25
+ message: d.message,
26
+ };
27
+ if (d.suggestion) out.suggestion = d.suggestion;
28
+ if (d.span) out.span = d.span;
29
+ return out;
30
+ }
@@ -0,0 +1,48 @@
1
+ export type DiagnosticSeverity = 'error' | 'warning';
2
+
3
+ export interface SourcePosition {
4
+ line: number;
5
+ column: number;
6
+ offset?: number;
7
+ }
8
+
9
+ export interface SourceSpan {
10
+ start: SourcePosition;
11
+ end: SourcePosition;
12
+ }
13
+
14
+ /**
15
+ * Carries the validator's stable message code plus the named-argument
16
+ * record it was formatted from, so the CLI can re-format the human
17
+ * message in the operator's locale at print time. See
18
+ * `specs/localization.md` for the two-chain precedence model.
19
+ */
20
+ export interface LocalizedMessageData {
21
+ code: string;
22
+ args: ReadonlyArray<unknown>;
23
+ }
24
+
25
+ export interface CliDiagnostic {
26
+ file: string;
27
+ line: number;
28
+ column: number;
29
+ severity: DiagnosticSeverity;
30
+ code: string;
31
+ message: string;
32
+ suggestion?: string;
33
+ span?: SourceSpan;
34
+ /**
35
+ * Stable code + args as captured by the validator. Present on
36
+ * messages that flow through the `tr()` registry; absent for
37
+ * literal-English validator strings, parser/lexer errors, and
38
+ * include-resolution diagnostics. The CLI re-formats `message`
39
+ * from this when an operator locale is supplied to
40
+ * `formatDiagnostics`.
41
+ */
42
+ data?: LocalizedMessageData;
43
+ }
44
+
45
+ export interface DiagnosticSource {
46
+ file: string;
47
+ contents: string;
48
+ }
@@ -0,0 +1,62 @@
1
+ import { codeFrameColumns } from '@babel/code-frame';
2
+ import chalk from 'chalk';
3
+ import type { CliDiagnostic, DiagnosticSource } from './model.js';
4
+
5
+ export interface RenderTextOptions {
6
+ color?: boolean;
7
+ }
8
+
9
+ export function renderText(
10
+ diagnostics: CliDiagnostic[],
11
+ sources: Map<string, DiagnosticSource>,
12
+ options: RenderTextOptions = {},
13
+ ): string {
14
+ const useColor = options.color ?? chalk.level > 0;
15
+ const parts = diagnostics.map((d) => renderOne(d, sources.get(d.file), useColor));
16
+ return parts.join('\n\n');
17
+ }
18
+
19
+ function renderOne(
20
+ diag: CliDiagnostic,
21
+ source: DiagnosticSource | undefined,
22
+ useColor: boolean,
23
+ ): string {
24
+ const header = renderHeader(diag, useColor);
25
+ if (!source) return header;
26
+ const frame = codeFrameColumns(
27
+ source.contents,
28
+ {
29
+ start: { line: diag.line, column: diag.column },
30
+ end: diag.span ? { line: diag.span.end.line, column: diag.span.end.column } : undefined,
31
+ },
32
+ {
33
+ highlightCode: useColor,
34
+ linesAbove: 2,
35
+ linesBelow: 1,
36
+ forceColor: useColor,
37
+ },
38
+ );
39
+ return `${header}\n${frame}`;
40
+ }
41
+
42
+ function renderHeader(diag: CliDiagnostic, useColor: boolean): string {
43
+ const loc = `${diag.file}:${diag.line}:${diag.column}`;
44
+ const severityWord = diag.severity === 'error' ? 'error' : 'warning';
45
+ if (!useColor) {
46
+ const suffix = diag.suggestion ? ` — did you mean '${diag.suggestion}'?` : '';
47
+ return `${loc} ${severityWord}: ${stripSuggestion(diag.message, diag.suggestion)}${suffix}`;
48
+ }
49
+ const coloredLoc = chalk.cyan(loc);
50
+ const coloredSeverity =
51
+ diag.severity === 'error' ? chalk.red.bold(severityWord) : chalk.yellow.bold(severityWord);
52
+ const message = stripSuggestion(diag.message, diag.suggestion);
53
+ const suggestion = diag.suggestion
54
+ ? ` ${chalk.dim('—')} did you mean ${chalk.green(`'${diag.suggestion}'`)}?`
55
+ : '';
56
+ return `${coloredLoc} ${coloredSeverity}: ${message}${suggestion}`;
57
+ }
58
+
59
+ function stripSuggestion(message: string, suggestion: string | undefined): string {
60
+ if (!suggestion) return message;
61
+ return message.replace(/ —\s*did you mean ['"]?[^'"?]+['"]?\??/i, '').trimEnd();
62
+ }
@@ -0,0 +1,13 @@
1
+ // Auto-generated by scripts/bundle-templates.mjs. Do not edit by hand.
2
+ // Run `pnpm run bundle-templates` to regenerate.
3
+
4
+ export type TemplateName = 'minimal' | 'teams' | 'product' | 'showcase';
5
+
6
+ export const TEMPLATE_NAMES: readonly TemplateName[] = ['minimal', 'teams', 'product', 'showcase'];
7
+
8
+ export const TEMPLATES: Record<TemplateName, string> = {
9
+ "minimal": "nowline v1\n\nroadmap minimal \"Starter\" start:2026-01-05 scale:2w author:\"Jane Doe\"\n\nswimlane engineering \"Engineering\"\n parallel concurrent-block style:concurrent\n group research-group \"Research\"\n item research \"Research\" duration:3w status:done\n item design \"Design\" duration:2w status:in-progress remaining:5d\n item build \"Build\" duration:3w status:planned\n group release-group \"Release\"\n item release \"Release\" duration:1w status:planned\n item deploy \"Deploy\" duration:1w status:planned\n",
10
+ "teams": "nowline v1\n\nroadmap teams-2026 \"Teams Roadmap 2026\" start:2026-01-05\n\nperson sam \"Sam Chen\" link:https://github.com/samchen\nperson jen \"Jennifer Wu\"\nperson alex \"Alex Rivera\"\n\nteam engineering \"Engineering\"\n team platform-team \"Platform Team\"\n person sam\n person jen\n team mobile-team \"Mobile Team\"\n person alex\n\nanchor kickoff date:2026-01-06\nanchor mid-year date:2026-07-01\nanchor eoy \"End of Year\" date:2026-12-15\n\nswimlane platform owner:platform-team\n item auth \"Auth refactor\" duration:1m after:kickoff owner:sam status:done\n item api-v2 \"API v2\" duration:2w owner:jen status:in-progress remaining:40%\n\nswimlane mobile owner:mobile-team\n item offline \"Offline mode\" duration:1m after:kickoff owner:alex status:at-risk remaining:60%\n item push \"Push notifications\" duration:2w owner:alex\n\nmilestone beta \"Beta\" after:[auth, api-v2]\nmilestone ga \"GA\" date:2026-12-01 after:[offline, push]\n\nfootnote eng-capacity \"Mobile team capacity risk\" on:mobile-team\n description \"Mobile team is understaffed through Q2.\"\n\nfootnote vendor-dep \"Vendor dependency\" on:[auth, api-v2]\n description \"Blocked until vendor contract is signed.\"\n",
11
+ "product": "nowline v1\n\nconfig\n\nscale\n name: weeks\n label-every: 2\n label: \"W{n}\"\n\ncalendar\n days-per-week: 5\n days-per-month: 22\n days-per-quarter: 65\n days-per-year: 260\n\nstyle enterprise-style \"Enterprise readiness\"\n bg: blue\n fg: navy\n text: white\n border: solid\n\nstyle risky\n border: dashed\n fg: orange\n\nstyle subtle\n bg: gray\n\ndefault item shadow:subtle\ndefault swimlane padding:sm spacing:none\ndefault roadmap padding:md header-height:md font:sans\ndefault parallel bracket:none\n\nroadmap platform-2026 \"Platform 2026\" author:\"Acme Engineering\" start:2026-01-05 calendar:custom\n\nperson sam \"Sam Chen\"\nperson jen \"Jennifer Wu\"\n\nsize xs effort:1d\nsize sm effort:3d\nsize md effort:1w\nsize lg effort:2w\nsize xl effort:1m\n\nstatus awaiting-review\nstatus in-review\n\nlabel enterprise \"Enterprise readiness\" style:enterprise-style\nlabel security \"Security hardening\" style:enterprise-style\nlabel low-confidence style:risky\n\nanchor kickoff date:2026-01-06\nanchor code-freeze \"Code Freeze\" date:2026-05-01\nanchor ga-date \"GA Date\" date:2026-06-01\n\nswimlane platform\n item auth-refactor \"Auth refactor\" size:lg after:kickoff status:done owner:sam labels:enterprise link:https://github.com/acme/platform/issues/123\n parallel after:auth-refactor\n group audit-track \"Audit Track\" labels:security\n item audit-log \"Audit log v2\" size:xl before:code-freeze remaining:30% labels:[enterprise, security]\n description \"Comprehensive audit trail for all admin actions\"\n item audit-ui \"Audit UI\" size:md\n item sso \"SSO plugins\" size:md labels:[enterprise, low-confidence]\n item platform-qa \"Platform QA\" size:sm\n\nswimlane mobile\n item offline \"Offline mode\" size:lg after:kickoff owner:jen status:at-risk remaining:60% link:https://github.com/acme/mobile/pull/87\n item push-v2 \"Push notifications v2\" size:md\n\nmilestone beta \"Beta\" after:auth-refactor\nmilestone ga-launch \"GA launch\" date:2026-06-01 after:[auth-refactor, audit-log]\n\nfootnote vendor-footnote \"Vendor dependency\" on:audit-log\n description \"Blocked until vendor contract is signed. Expected March resolution.\"\n",
12
+ "showcase": "nowline v1\n\nroadmap showcase \"Nowline Showcase\" start:2026-01-05 scale:1m author:\"Nowline\"\n\nanchor kickoff date:2026-01-05\n\nswimlane engineering \"Engineering\"\n item design \"Design spec\" duration:2w after:kickoff status:done\n parallel after:design\n group build \"Build\"\n item api \"API surface\" duration:3w status:in-progress remaining:1w\n item ui \"Web client\" duration:3w status:in-progress remaining:2w\n item integrate \"Integration tests\" duration:2w\n item ship \"Ship\" duration:1w after:integrate\n\nswimlane marketing \"Marketing\"\n item brief \"Launch brief\" duration:1w after:kickoff status:done\n item assets \"Launch assets\" duration:3w after:brief\n item announce \"Announcement\" duration:1w after:ship\n\nmilestone launch \"Launch\" after:[ship, announce]\n"
13
+ };
@@ -0,0 +1,18 @@
1
+ // Auto-generated by scripts/bundle-templates.mjs. Do not edit by hand.
2
+
3
+ export const CLI_VERSION = "0.0.0-dev.20260601071750.g04bdff9";
4
+
5
+ export interface CliBuild {
6
+ /** Short git SHA at build time, or empty when not in a git checkout. */
7
+ readonly sha: string;
8
+ /** True when HEAD is the tag matching `v${CLI_VERSION}`. */
9
+ readonly isRelease: boolean;
10
+ /** True when the working tree had uncommitted changes at build time. */
11
+ readonly isDirty: boolean;
12
+ }
13
+
14
+ export const CLI_BUILD: CliBuild = {
15
+ "sha": "04bdff9",
16
+ "isRelease": false,
17
+ "isDirty": true
18
+ };
@@ -0,0 +1,133 @@
1
+ import type { NowlineFile } from '@nowline/core';
2
+
3
+ // Resolve the operator-locale override from CLI flag, environment, and
4
+ // `.nowlinerc`. See `specs/localization.md` for the two-chain model.
5
+ //
6
+ // Operator locale governs CLI message output: validator diagnostics on
7
+ // stderr, `--help`, format errors, verbose logs. It is independent from
8
+ // the file's `locale:` directive, which governs the rendered artifact.
9
+ //
10
+ // Returns the resolved tag plus the source it came from, so verbose
11
+ // logging can describe the chain without re-walking it.
12
+
13
+ const ENV_VARS = ['LC_ALL', 'LC_MESSAGES', 'LANG'] as const;
14
+ type EnvVar = (typeof ENV_VARS)[number];
15
+
16
+ export type LocaleSource = 'flag' | 'env' | 'rc';
17
+
18
+ export interface ResolvedLocale {
19
+ /** BCP-47 tag, or undefined when no source produced one. */
20
+ tag: string | undefined;
21
+ /** Which input chain produced `tag`. Undefined iff `tag` is undefined. */
22
+ source: LocaleSource | undefined;
23
+ /** When `source === 'env'`, the specific variable that contributed. */
24
+ envVar?: EnvVar;
25
+ }
26
+
27
+ export interface ResolveLocaleInputs {
28
+ /** Value of the `--locale` flag, or undefined. */
29
+ flag: string | undefined;
30
+ /** Process environment, parameterized for testability. */
31
+ env: NodeJS.ProcessEnv;
32
+ /** Optional `.nowlinerc` `locale` key. Slots below env vars. */
33
+ rc?: string | undefined;
34
+ }
35
+
36
+ /**
37
+ * Resolve the operator's locale override.
38
+ *
39
+ * Precedence (highest wins): `--locale` flag > env vars > `.nowlinerc`.
40
+ * POSIX `C` / `POSIX` env values mean "no localization" and are skipped.
41
+ *
42
+ * Returns `{ tag: undefined, source: undefined }` when nothing is set —
43
+ * `operatorLocale` then defaults to `en-US`, while the layout's content
44
+ * chain falls back to whatever the file's `locale:` directive declares.
45
+ */
46
+ export function resolveLocaleOverride({ flag, env, rc }: ResolveLocaleInputs): ResolvedLocale {
47
+ if (flag) return { tag: flag, source: 'flag' };
48
+ for (const name of ENV_VARS) {
49
+ const raw = env[name];
50
+ if (!raw) continue;
51
+ const stripped = stripPosixSuffix(raw);
52
+ if (!stripped || stripped === 'C' || stripped === 'POSIX') {
53
+ // POSIX `C` / `POSIX` locale means "no localization." Skip and
54
+ // let the next env var or the rc file take over.
55
+ continue;
56
+ }
57
+ return { tag: normalizePosixToBcp47(stripped), source: 'env', envVar: name };
58
+ }
59
+ if (rc) return { tag: rc, source: 'rc' };
60
+ return { tag: undefined, source: undefined };
61
+ }
62
+
63
+ /**
64
+ * Resolve the operator-facing locale used to format CLI message output.
65
+ * `en-US` when the operator has no signal in the environment.
66
+ */
67
+ export function operatorLocale(resolved: ResolvedLocale): string {
68
+ return resolved.tag ?? 'en-US';
69
+ }
70
+
71
+ /**
72
+ * Describe which locale governs the rendered artifact and where the
73
+ * value came from. Used by the `--verbose` log line so an operator can
74
+ * see at a glance whether the file or the operator chain is winning.
75
+ *
76
+ * Precedence mirrors the layout's `resolveLocale`:
77
+ * file directive > CLI flag > env > `.nowlinerc` > en-US (default).
78
+ */
79
+ export function describeContentLocaleSource(
80
+ directive: string | undefined,
81
+ resolved: ResolvedLocale,
82
+ ): { tag: string; source: string } {
83
+ if (directive) return { tag: directive, source: 'from file directive' };
84
+ if (resolved.tag === undefined) return { tag: 'en-US', source: 'default' };
85
+ switch (resolved.source) {
86
+ case 'flag':
87
+ return { tag: resolved.tag, source: 'from --locale' };
88
+ case 'env':
89
+ return { tag: resolved.tag, source: `from ${resolved.envVar} env var` };
90
+ case 'rc':
91
+ return { tag: resolved.tag, source: 'from .nowlinerc' };
92
+ default:
93
+ return { tag: resolved.tag, source: 'from operator chain' };
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Read the optional `locale:` property from a parsed file's
99
+ * `nowline v1` directive. Returns undefined when the directive is
100
+ * absent, malformed, or omits the property.
101
+ */
102
+ export function readDirectiveLocale(file: NowlineFile | undefined): string | undefined {
103
+ const prop = file?.directive?.properties.find((p) => stripColon(p.key) === 'locale');
104
+ return prop?.value || undefined;
105
+ }
106
+
107
+ function stripColon(key: string): string {
108
+ return key.endsWith(':') ? key.slice(0, -1) : key;
109
+ }
110
+
111
+ /**
112
+ * POSIX `LANG` values look like `en_US.UTF-8` or `fr_CA@euro`. Strip
113
+ * everything from `.` or `@` onward — Nowline only cares about the
114
+ * language and region.
115
+ */
116
+ function stripPosixSuffix(value: string): string {
117
+ const dot = value.indexOf('.');
118
+ const at = value.indexOf('@');
119
+ let end = value.length;
120
+ if (dot !== -1 && dot < end) end = dot;
121
+ if (at !== -1 && at < end) end = at;
122
+ return value.slice(0, end).trim();
123
+ }
124
+
125
+ /**
126
+ * Normalize `fr_CA` to `fr-CA`. POSIX uses `_`; BCP-47 uses `-`.
127
+ * Casing follows BCP-47 conventions: language lowercase, region
128
+ * uppercase 2-letter, but the layout's BCP-47 walker normalizes again
129
+ * defensively, so this is best-effort.
130
+ */
131
+ function normalizePosixToBcp47(value: string): string {
132
+ return value.replace('_', '-');
133
+ }
package/src/index.ts ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgv } from './cli/args.js';
3
+ import { renderHelp, renderVersion } from './cli/help.js';
4
+ import { initHandler } from './commands/init.js';
5
+ import { renderHandler } from './commands/render.js';
6
+ import { serveHandler } from './commands/serve.js';
7
+ import { CliError, ExitCode } from './io/exit-codes.js';
8
+
9
+ async function run(): Promise<number> {
10
+ const argv = process.argv.slice(2);
11
+
12
+ let parsed: ReturnType<typeof parseArgv>;
13
+ try {
14
+ parsed = parseArgv(argv);
15
+ } catch (err) {
16
+ return handleError(err);
17
+ }
18
+
19
+ if (parsed.mode === 'help') {
20
+ process.stdout.write(renderHelp());
21
+ return ExitCode.Success;
22
+ }
23
+ if (parsed.mode === 'version') {
24
+ process.stdout.write(renderVersion());
25
+ return ExitCode.Success;
26
+ }
27
+
28
+ try {
29
+ if (parsed.mode === 'init') {
30
+ await initHandler({ args: parsed });
31
+ } else if (parsed.mode === 'serve') {
32
+ await serveHandler({ args: parsed });
33
+ } else {
34
+ await renderHandler({ args: parsed });
35
+ }
36
+ return ExitCode.Success;
37
+ } catch (err) {
38
+ return handleError(err);
39
+ }
40
+ }
41
+
42
+ function handleError(err: unknown): number {
43
+ if (err instanceof CliError) {
44
+ if (err.message) process.stderr.write(`${err.message}\n`);
45
+ return err.exitCode;
46
+ }
47
+ const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
48
+ process.stderr.write(`${message}\n`);
49
+ return ExitCode.ValidationError;
50
+ }
51
+
52
+ run()
53
+ .then((code) => {
54
+ process.exit(code);
55
+ })
56
+ .catch((err: unknown) => {
57
+ const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
58
+ process.stderr.write(`${message}\n`);
59
+ process.exit(ExitCode.ValidationError);
60
+ });
@@ -0,0 +1,11 @@
1
+ // Thin re-export of the shared `.nowlinerc` loader. The implementation
2
+ // lives in `@nowline/config` so the VS Code/Cursor extension can read the
3
+ // same files without depending on the CLI's heavier export-format graph.
4
+ export {
5
+ type LoadConfigOptions,
6
+ type LoadConfigResult,
7
+ loadConfig,
8
+ mergeConfig,
9
+ type NowlineRc,
10
+ parseConfig,
11
+ } from '@nowline/config';
@@ -0,0 +1,18 @@
1
+ export const ExitCode = {
2
+ Success: 0,
3
+ ValidationError: 1,
4
+ InputError: 2,
5
+ OutputError: 3,
6
+ } as const;
7
+
8
+ export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
9
+
10
+ export class CliError extends Error {
11
+ constructor(
12
+ public readonly exitCode: ExitCode,
13
+ message: string,
14
+ ) {
15
+ super(message);
16
+ this.name = 'CliError';
17
+ }
18
+ }
package/src/io/read.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { CliError, ExitCode } from './exit-codes.js';
4
+
5
+ export interface ReadInputResult {
6
+ contents: string;
7
+ path: string;
8
+ displayPath: string;
9
+ isStdin: boolean;
10
+ }
11
+
12
+ export interface ReadInputOptions {
13
+ readFile?: (absPath: string) => Promise<string>;
14
+ readStdin?: () => Promise<string>;
15
+ cwd?: string;
16
+ }
17
+
18
+ export async function readInput(
19
+ inputArg: string,
20
+ options: ReadInputOptions = {},
21
+ ): Promise<ReadInputResult> {
22
+ const readFile = options.readFile ?? ((p) => fs.readFile(p, 'utf-8'));
23
+ const readStdin = options.readStdin ?? defaultReadStdin;
24
+ const cwd = options.cwd ?? process.cwd();
25
+
26
+ if (inputArg === '-') {
27
+ return {
28
+ contents: await readStdin(),
29
+ path: '<stdin>',
30
+ displayPath: '<stdin>',
31
+ isStdin: true,
32
+ };
33
+ }
34
+
35
+ const absPath = path.resolve(cwd, inputArg);
36
+ try {
37
+ const contents = await readFile(absPath);
38
+ return {
39
+ contents,
40
+ path: absPath,
41
+ displayPath: inputArg,
42
+ isStdin: false,
43
+ };
44
+ } catch (err) {
45
+ throw new CliError(ExitCode.InputError, formatReadError(inputArg, err));
46
+ }
47
+ }
48
+
49
+ function formatReadError(inputArg: string, err: unknown): string {
50
+ if (isNodeError(err)) {
51
+ if (err.code === 'ENOENT') return `File not found: ${inputArg}`;
52
+ if (err.code === 'EACCES') return `Permission denied: ${inputArg}`;
53
+ if (err.code === 'EISDIR') return `Not a file: ${inputArg}`;
54
+ }
55
+ const message = err instanceof Error ? err.message : String(err);
56
+ return `Could not read ${inputArg}: ${message}`;
57
+ }
58
+
59
+ function isNodeError(err: unknown): err is NodeJS.ErrnoException {
60
+ return err instanceof Error && typeof (err as { code?: unknown }).code === 'string';
61
+ }
62
+
63
+ async function defaultReadStdin(): Promise<string> {
64
+ let data = '';
65
+ process.stdin.setEncoding('utf-8');
66
+ for await (const chunk of process.stdin) {
67
+ data += chunk;
68
+ }
69
+ return data;
70
+ }
@@ -0,0 +1,94 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { CliError, ExitCode } from './exit-codes.js';
4
+
5
+ export interface WriteOutputOptions {
6
+ /**
7
+ * Current working directory used to resolve relative output paths. Defaults
8
+ * to `process.cwd()`.
9
+ */
10
+ cwd?: string;
11
+ /**
12
+ * Test seam: writes the output bytes to `absPath`. Defaults to
13
+ * `fs.writeFile`.
14
+ */
15
+ writeFile?: (absPath: string, data: string | Uint8Array) => Promise<void>;
16
+ /**
17
+ * Test seam: stdout writer. Defaults to `process.stdout.write`.
18
+ */
19
+ stdoutWrite?: (data: string | Uint8Array) => boolean;
20
+ /**
21
+ * Test seam: stdout-is-a-TTY override. Defaults to `process.stdout.isTTY`.
22
+ */
23
+ stdoutIsTTY?: boolean;
24
+ }
25
+
26
+ export type OutputFormat = 'text' | 'binary';
27
+
28
+ /**
29
+ * Writes `data` to either a file (`outputArg` is a path) or stdout (`outputArg`
30
+ * is `-` or `undefined` — though `undefined` is no longer the default; mode
31
+ * dispatch always resolves a concrete path now).
32
+ *
33
+ * Existing files are silently overwritten. m2b.5 removed the `--force` gate;
34
+ * matches POSIX redirection (`> file`) and every peer drawing CLI (mmdc, d2,
35
+ * prettier, tsc, pandoc).
36
+ */
37
+ export async function writeOutput(
38
+ outputArg: string | undefined,
39
+ data: string | Uint8Array,
40
+ format: OutputFormat,
41
+ options: WriteOutputOptions = {},
42
+ ): Promise<void> {
43
+ if (outputArg === undefined || outputArg === '-') {
44
+ guardBinaryStdout(format, options);
45
+ const write = options.stdoutWrite ?? ((chunk) => process.stdout.write(chunk));
46
+ const payload = ensureTrailingNewline(data, format);
47
+ write(payload);
48
+ return;
49
+ }
50
+
51
+ const cwd = options.cwd ?? process.cwd();
52
+ const absPath = path.resolve(cwd, outputArg);
53
+
54
+ const writeFile = options.writeFile ?? ((p, d) => fs.writeFile(p, d));
55
+ try {
56
+ await writeFile(absPath, data);
57
+ } catch (err) {
58
+ throw new CliError(ExitCode.OutputError, formatWriteError(outputArg, err));
59
+ }
60
+ }
61
+
62
+ function guardBinaryStdout(format: OutputFormat, options: WriteOutputOptions): void {
63
+ if (format !== 'binary') return;
64
+ const isTTY = options.stdoutIsTTY ?? process.stdout.isTTY === true;
65
+ if (isTTY) {
66
+ throw new CliError(
67
+ ExitCode.InputError,
68
+ 'nowline: binary output to terminal refused; use -o <path> or pipe to a file.',
69
+ );
70
+ }
71
+ }
72
+
73
+ function ensureTrailingNewline(
74
+ data: string | Uint8Array,
75
+ format: OutputFormat,
76
+ ): string | Uint8Array {
77
+ if (format !== 'text') return data;
78
+ if (typeof data !== 'string') return data;
79
+ return data.endsWith('\n') ? data : `${data}\n`;
80
+ }
81
+
82
+ function formatWriteError(outputArg: string, err: unknown): string {
83
+ if (isNodeError(err)) {
84
+ if (err.code === 'EACCES') return `Permission denied: ${outputArg}`;
85
+ if (err.code === 'ENOENT') return `Output directory does not exist: ${outputArg}`;
86
+ if (err.code === 'EISDIR') return `Output path is a directory: ${outputArg}`;
87
+ }
88
+ const message = err instanceof Error ? err.message : String(err);
89
+ return `Could not write ${outputArg}: ${message}`;
90
+ }
91
+
92
+ function isNodeError(err: unknown): err is NodeJS.ErrnoException {
93
+ return err instanceof Error && typeof (err as { code?: unknown }).code === 'string';
94
+ }
package/src/version.ts ADDED
@@ -0,0 +1,21 @@
1
+ export { CLI_BUILD, CLI_VERSION, type CliBuild } from './generated/version.js';
2
+
3
+ import { CLI_BUILD, CLI_VERSION } from './generated/version.js';
4
+
5
+ /**
6
+ * Compose the user-visible version string per SemVer build-metadata
7
+ * rules. Released builds print just the SemVer (`0.1.0`); dev builds
8
+ * append the short SHA (and `.dirty` if the working tree had local
9
+ * edits when the binary was built):
10
+ *
11
+ * release -> 0.1.0
12
+ * dev (clean) -> 0.1.0+abc1234
13
+ * dev (dirty) -> 0.1.0+abc1234.dirty
14
+ */
15
+ export function fullVersionString(): string {
16
+ if (CLI_BUILD.isRelease || CLI_BUILD.sha === '') {
17
+ return CLI_VERSION;
18
+ }
19
+ const dirty = CLI_BUILD.isDirty ? '.dirty' : '';
20
+ return `${CLI_VERSION}+${CLI_BUILD.sha}${dirty}`;
21
+ }