project-logbook 0.3.2 → 0.3.4

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 (68) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/build.js +33 -76
  3. package/dist/commands/init.js +11 -11
  4. package/dist/commands/lint.js +10 -11
  5. package/dist/commands/list.js +18 -18
  6. package/dist/commands/log.js +5 -5
  7. package/dist/commands/new.js +19 -20
  8. package/dist/commands/preview.js +9 -8
  9. package/dist/commands/release.js +6 -6
  10. package/dist/commands/start.js +26 -21
  11. package/dist/commands/status.d.ts +1 -0
  12. package/dist/commands/status.js +122 -0
  13. package/dist/commands/steer.js +9 -9
  14. package/dist/commands/upgrade.js +5 -5
  15. package/dist/index.js +9 -8
  16. package/dist/lib/build-helpers.d.ts +7 -0
  17. package/dist/lib/build-helpers.js +46 -46
  18. package/dist/lib/build-steps.d.ts +20 -0
  19. package/dist/lib/build-steps.js +57 -0
  20. package/dist/lib/config.d.ts +16 -0
  21. package/dist/lib/config.js +20 -0
  22. package/dist/lib/entry-id.d.ts +22 -0
  23. package/dist/lib/entry-id.js +26 -0
  24. package/dist/lib/entry-paths.d.ts +23 -0
  25. package/dist/lib/entry-paths.js +55 -0
  26. package/dist/lib/git-helpers.d.ts +11 -1
  27. package/dist/lib/git-helpers.js +28 -26
  28. package/dist/lib/hast-helpers.d.ts +10 -0
  29. package/dist/lib/hast-helpers.js +22 -0
  30. package/dist/lib/html-attributes.d.ts +17 -0
  31. package/dist/lib/html-attributes.js +17 -0
  32. package/dist/lib/html-escape.d.ts +16 -0
  33. package/dist/lib/html-escape.js +38 -0
  34. package/dist/lib/image-helpers.d.ts +4 -0
  35. package/dist/lib/image-helpers.js +47 -30
  36. package/dist/lib/lint-runner.js +5 -5
  37. package/dist/lib/markdown-processors.d.ts +18 -0
  38. package/dist/lib/markdown-processors.js +42 -0
  39. package/dist/lib/package-version.d.ts +5 -0
  40. package/dist/lib/package-version.js +16 -0
  41. package/dist/lib/styles.js +5 -2
  42. package/dist/lib/template-helpers.d.ts +1 -0
  43. package/dist/lib/template-helpers.js +35 -24
  44. package/dist/lib/template-types.d.ts +0 -2
  45. package/dist/lib/templates.d.ts +8 -2
  46. package/dist/lib/templates.js +50 -46
  47. package/dist/lib/theme.d.ts +37 -0
  48. package/dist/lib/theme.js +50 -0
  49. package/dist/lib/url-helpers.d.ts +13 -0
  50. package/dist/lib/url-helpers.js +27 -0
  51. package/dist/linters/diff-to-narrative.d.ts +6 -0
  52. package/dist/linters/diff-to-narrative.js +114 -0
  53. package/dist/linters/index.js +2 -0
  54. package/dist/templates/CONTRIBUTING.md +12 -3
  55. package/dist/templates/index.md +6 -6
  56. package/dist/templates/logbook-client.js +42 -16
  57. package/dist/templates/styles.css +5 -0
  58. package/dist/utils/date.d.ts +23 -1
  59. package/dist/utils/date.js +56 -15
  60. package/dist/utils/frontmatter.d.ts +26 -0
  61. package/dist/utils/frontmatter.js +37 -0
  62. package/dist/utils/fs.d.ts +13 -0
  63. package/dist/utils/fs.js +23 -0
  64. package/package.json +3 -2
  65. package/src/templates/CONTRIBUTING.md +12 -3
  66. package/src/templates/index.md +6 -6
  67. package/src/templates/logbook-client.js +42 -16
  68. package/src/templates/styles.css +5 -0
package/README.md CHANGED
@@ -17,7 +17,7 @@ Each entry in `logbook/` consists of:
17
17
 
18
18
  ## Installation
19
19
  ```bash
20
- npm i project-loogbook -g
20
+ npm i project-logbook -g
21
21
  ```
22
22
 
23
23
  ## Commands
@@ -1,73 +1,33 @@
1
1
  import fs from 'fs-extra';
2
2
  import { join } from 'node:path';
3
- import chalk from 'chalk';
4
- import { getConfig, getWorkspaces } from '../lib/config.js';
3
+ import { error, neutral, success } from '../lib/theme.js';
4
+ import { getConfig, getWorkspaces, getLogbookDirPath, getOutputDirPath } from '../lib/config.js';
5
5
  import { layout, timelineTemplate } from '../lib/templates.js';
6
- import { getStyles } from '../lib/styles.js';
7
- import { getClientScript } from '../lib/logbook-client.js';
8
- import { mdToHtml, buildProjectMdFiles, renderPost } from '../lib/build-helpers.js';
9
- import { mdToHtmlWithImages, copyImages } from '../lib/image-helpers.js';
6
+ import { buildProjectMdFiles, renderPost, generateBuildMeta } from '../lib/build-helpers.js';
10
7
  import { getGitCommits, getGitTags } from '../lib/git-helpers.js';
11
- import { formatRelativeDate, getMonthYear, getSortTime } from '../utils/date.js';
8
+ import { setupOutputDirectory, processReadme, copyReadmeImages, processAboutContent } from '../lib/build-steps.js';
9
+ import { formatAbsoluteDate, getMonthYear, getSortTime, formatDateTimeForDisplay } from '../utils/date.js';
12
10
  import { getLogbookEntries } from '../utils/fs.js';
11
+ import { toDisplayString, asString, asStringArray } from '../utils/frontmatter.js';
13
12
  import { getAboutContent } from '../lib/about-content.js';
13
+ import { getPackageVersion } from '../lib/package-version.js';
14
14
  import { groupTimelineItems } from '../utils/timeline-helpers.js';
15
- const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
16
- const version = pkg.version;
17
- /** Normalises a frontmatter field that may be a scalar string or a YAML list of strings. */
18
- const toDisplayString = (v) => {
19
- if (typeof v === 'string')
20
- return v || undefined;
21
- if (Array.isArray(v)) {
22
- const joined = v.filter((x) => typeof x === 'string').join(' + ');
23
- return joined || undefined;
24
- }
25
- return undefined;
26
- };
15
+ import { getEntryDisplayId, getEntryDisplayTitle } from '../lib/entry-id.js';
16
+ const version = getPackageVersion();
27
17
  export async function build() {
28
18
  const config = getConfig();
29
- const logbookDir = join(process.cwd(), config.logbookDir);
30
- const outputDir = join(process.cwd(), config.outputDir);
31
- const buildTime = new Date()
32
- .toLocaleString('de-DE', {
33
- year: 'numeric',
34
- month: '2-digit',
35
- day: '2-digit',
36
- hour: '2-digit',
37
- minute: '2-digit',
38
- second: '2-digit',
39
- hour12: false,
40
- })
41
- .replace(',', '');
19
+ const logbookDir = getLogbookDirPath(config);
20
+ const outputDir = getOutputDirPath(config);
21
+ const buildTime = formatDateTimeForDisplay(new Date());
42
22
  if (!(await fs.pathExists(logbookDir))) {
43
- console.error(chalk.red(`Error: Logbook directory '${config.logbookDir}' not found.`));
23
+ console.error(error(`Error: Logbook directory '${config.logbookDir}' not found.`));
44
24
  return;
45
25
  }
46
- await fs.remove(outputDir);
47
- await fs.mkdirp(outputDir);
48
- await fs.copyFile(new URL('../templates/favicon.svg', import.meta.url), join(outputDir, 'favicon.svg'));
49
- await fs.writeFile(join(outputDir, 'style.css'), getStyles(config.primaryColor));
50
- await fs.writeFile(join(outputDir, 'logbook.js'), getClientScript());
51
- const readmePath = join(process.cwd(), 'README.md');
52
- let readmeHtml = '';
53
- let readmeImages = [];
54
- try {
55
- if (await fs.pathExists(readmePath)) {
56
- const readmeContent = await fs.readFile(readmePath, 'utf8');
57
- const { html, imagePaths } = await mdToHtmlWithImages(readmeContent);
58
- readmeHtml = html;
59
- readmeImages = imagePaths;
60
- }
61
- }
62
- catch (error) {
63
- console.warn(chalk.yellow('Warning: Failed to read README.md:', error instanceof Error ? error.message : error));
64
- readmeHtml = '<p>README unavailable.</p>';
65
- }
66
- // Copy README images to output
67
- if (readmeImages.length > 0) {
68
- await copyImages(process.cwd(), outputDir, readmeImages);
69
- }
70
- const aboutHtml = await mdToHtml(getAboutContent());
26
+ await setupOutputDirectory(outputDir, config);
27
+ const projectRoot = process.cwd();
28
+ const { html: readmeHtml, imagePaths: readmeImages } = await processReadme(projectRoot);
29
+ await copyReadmeImages(projectRoot, outputDir, readmeImages);
30
+ const aboutHtml = await processAboutContent(getAboutContent());
71
31
  const logbookEntries = await getLogbookEntries(logbookDir);
72
32
  const timelineEntries = [];
73
33
  const availableWorkspaces = getWorkspaces();
@@ -82,9 +42,7 @@ export async function build() {
82
42
  ? firstParagraph.trim().substring(0, 200).replace(/[*#`]/g, '') + '...'
83
43
  : 'No summary available.';
84
44
  }
85
- const entryWorkspaces = Array.isArray(data.workspaces)
86
- ? data.workspaces.filter((ws) => typeof ws === 'string' && availableWorkspaces.includes(ws))
87
- : [];
45
+ const entryWorkspaces = asStringArray(data.workspaces).filter((ws) => availableWorkspaces.includes(ws));
88
46
  const dateStartValue = data.dateStart;
89
47
  const dateStart = typeof dateStartValue === 'string'
90
48
  ? dateStartValue
@@ -92,7 +50,7 @@ export async function build() {
92
50
  ? dateStartValue.toISOString()
93
51
  : '';
94
52
  // Skip DRAFT entries (no valid dateStart) — they break the timeline
95
- if (!dateStart || dateStart.includes('{{'))
53
+ if (!dateStart || dateStart.includes('{{') || dateStart === '[DATE_START]')
96
54
  continue;
97
55
  const dateEndValue = data.dateEnd;
98
56
  const dateEnd = typeof dateEndValue === 'string'
@@ -105,14 +63,14 @@ export async function build() {
105
63
  slug,
106
64
  dateStart,
107
65
  dateEnd,
108
- displayDate: formatRelativeDate(dateStart),
66
+ displayDate: formatAbsoluteDate(dateStart),
109
67
  sortTime: getSortTime(dateStart, dateEnd),
110
- summary: typeof summary === 'string' ? summary : 'No summary available.',
111
- ticket: typeof data.ticket === 'string' ? data.ticket : slug,
68
+ summary: asString(summary, 'No summary available.') || '',
69
+ ticket: getEntryDisplayId(asString(data.ticket), slug),
112
70
  monthGroup: getMonthYear(dateStart),
113
- tags: Array.isArray(data.tags) ? data.tags.filter((t) => typeof t === 'string') : [],
71
+ tags: asStringArray(data.tags),
114
72
  workspaces: entryWorkspaces,
115
- title: typeof data.title === 'string' ? data.title : undefined,
73
+ title: getEntryDisplayTitle(asString(data.title), slug),
116
74
  harness: toDisplayString(data.harness),
117
75
  llm: toDisplayString(data.llm),
118
76
  prompter: toDisplayString(data.prompter),
@@ -125,8 +83,7 @@ export async function build() {
125
83
  sha: c.sha,
126
84
  message: c.message,
127
85
  timestamp: c.timestamp,
128
- relativeTime: c.relativeTime,
129
- displayDate: formatRelativeDate(c.timestamp),
86
+ displayDate: formatAbsoluteDate(c.timestamp),
130
87
  monthGroup: getMonthYear(c.timestamp),
131
88
  sortTime: getSortTime(c.timestamp),
132
89
  }));
@@ -134,11 +91,11 @@ export async function build() {
134
91
  kind: 'tag',
135
92
  name: t.name,
136
93
  timestamp: t.timestamp,
137
- displayDate: formatRelativeDate(t.timestamp),
94
+ displayDate: formatAbsoluteDate(t.timestamp),
138
95
  monthGroup: getMonthYear(t.timestamp),
139
96
  sortTime: getSortTime(t.timestamp),
140
97
  }));
141
- console.log(chalk.gray(`Found ${timelineEntries.length} entr${timelineEntries.length === 1 ? 'y' : 'ies'}, ${commitItems.length} commit${commitItems.length === 1 ? '' : 's'} (max 100), and ${tagItems.length} tag${tagItems.length === 1 ? '' : 's'}.`));
98
+ console.log(neutral(`Found ${timelineEntries.length} entr${timelineEntries.length === 1 ? 'y' : 'ies'}, ${commitItems.length} commit${commitItems.length === 1 ? '' : 's'} (max 100), and ${tagItems.length} tag${tagItems.length === 1 ? '' : 's'}.`));
142
99
  timelineEntries.sort((a, b) => {
143
100
  const aEnd = a.dateEnd ? new Date(a.dateEnd).getTime() : new Date(a.dateStart).getTime();
144
101
  const bEnd = b.dateEnd ? new Date(b.dateEnd).getTime() : new Date(b.dateStart).getTime();
@@ -164,7 +121,7 @@ export async function build() {
164
121
  }
165
122
  group.items.push(item);
166
123
  }
167
- const timelineContent = timelineTemplate({
124
+ const { header: timelineHeader, content: timelineMainContent } = timelineTemplate({
168
125
  projectName: config.projectName,
169
126
  version,
170
127
  buildTime,
@@ -176,14 +133,14 @@ export async function build() {
176
133
  currentPage,
177
134
  totalPages,
178
135
  }); // prettier-ignore
179
- const buildMeta = `Generated by ${config.projectName} v${version} • ${buildTime}`;
136
+ const buildMeta = generateBuildMeta(version, buildTime);
180
137
  const timelineHtml = layout({
181
138
  title: 'Timeline',
182
139
  projectName: config.projectName,
183
140
  basePath: './',
184
141
  buildMeta,
185
- header: timelineContent.split('</header>')[0] + '</header>',
186
- content: timelineContent.split('</header>')[1],
142
+ header: timelineHeader,
143
+ content: timelineMainContent,
187
144
  });
188
145
  const fileName = currentPage === 1 ? 'index.html' : `index-${currentPage}.html`;
189
146
  await fs.writeFile(join(outputDir, fileName), timelineHtml);
@@ -193,5 +150,5 @@ export async function build() {
193
150
  version,
194
151
  buildTime,
195
152
  });
196
- console.log(chalk.green(`\nSuccessfully built logbook to ${config.outputDir}/`));
153
+ console.log(success(`\nSuccessfully built logbook to ${config.outputDir}/`));
197
154
  }
@@ -1,9 +1,9 @@
1
1
  import fs from 'fs-extra';
2
2
  import { join } from 'node:path';
3
3
  import { createInterface } from 'node:readline';
4
- import chalk from 'chalk';
5
4
  import { DEFAULT_CONFIG } from '../lib/config.js';
6
5
  import { getTemplatesDir } from '../lib/migrations.js';
6
+ import { warning, info, highlight, success } from '../lib/theme.js';
7
7
  const rl = createInterface({
8
8
  input: process.stdin,
9
9
  output: process.stdout,
@@ -34,22 +34,22 @@ export async function init() {
34
34
  const contributingPath = join(cwd, 'CONTRIBUTING.md');
35
35
  // 1. Config file
36
36
  if (await fs.pathExists(configPath)) {
37
- console.log(chalk.yellow('Already initialized. .project-logbook exists.'));
37
+ console.log(warning('Already initialized. .project-logbook exists.'));
38
38
  rl.close();
39
39
  return;
40
40
  }
41
- console.log(chalk.blue('\n📒 Initializing project-logbook\n'));
41
+ console.log(info('\n📒 Initializing project-logbook\n'));
42
42
  // Detect project name from package.json
43
43
  const detectedName = readPackageJsonName(cwd);
44
44
  const defaultName = detectedName || DEFAULT_CONFIG.projectName;
45
- const nameInput = await question(chalk.cyan(`Project name (press Enter for "${defaultName}"): `));
45
+ const nameInput = await question(highlight(`Project name (press Enter for "${defaultName}"): `));
46
46
  const projectName = nameInput || defaultName;
47
47
  // Optional Jira config
48
- const jiraPrefixInput = await question(chalk.cyan('Jira project prefix (e.g. MYAPP, leave blank to skip): '));
48
+ const jiraPrefixInput = await question(highlight('Jira project prefix (e.g. MYAPP, leave blank to skip): '));
49
49
  const jiraPrefix = jiraPrefixInput || undefined;
50
50
  let jiraBaseUrl;
51
51
  if (jiraPrefix) {
52
- const jiraUrlInput = await question(chalk.cyan('Jira base URL (e.g. https://yourorg.atlassian.net, leave blank to skip): '));
52
+ const jiraUrlInput = await question(highlight('Jira base URL (e.g. https://yourorg.atlassian.net, leave blank to skip): '));
53
53
  jiraBaseUrl = jiraUrlInput || undefined;
54
54
  }
55
55
  rl.close();
@@ -63,21 +63,21 @@ export async function init() {
63
63
  if (jiraBaseUrl)
64
64
  config.jiraBaseUrl = jiraBaseUrl;
65
65
  await fs.writeJson(configPath, config, { spaces: 2 });
66
- console.log(chalk.green('\n✔ Created .project-logbook config file.'));
66
+ console.log(success('\n✔ Created .project-logbook config file.'));
67
67
  // 2. Logbook directory
68
68
  if (!(await fs.pathExists(logbookDir))) {
69
69
  await fs.mkdirp(logbookDir);
70
- console.log(chalk.green(`✔ Created ${DEFAULT_CONFIG.logbookDir}/ directory.`));
70
+ console.log(success(`✔ Created ${DEFAULT_CONFIG.logbookDir}/ directory.`));
71
71
  }
72
72
  // 3. CONTRIBUTING.md (from template)
73
73
  if (!(await fs.pathExists(contributingPath))) {
74
74
  const templatePath = join(getTemplatesDir(), 'CONTRIBUTING.md');
75
75
  if (await fs.pathExists(templatePath)) {
76
76
  await fs.copyFile(templatePath, contributingPath);
77
- console.log(chalk.green('✔ Created CONTRIBUTING.md with agentic workflow protocol.'));
77
+ console.log(success('✔ Created CONTRIBUTING.md with agentic workflow protocol.'));
78
78
  }
79
79
  }
80
80
  // 4. Hint
81
- console.log(chalk.dim('\n💡 Tip: Edit .project-logbook to customize primaryColor, add more tags, or configure Jira settings.'));
82
- console.log(chalk.dim(' Run `logbook new` to create your first entry.\n'));
81
+ console.log(info('\n💡 Tip: Edit .project-logbook to customize primaryColor, add more tags, or configure Jira settings.'));
82
+ console.log(info(' Run `logbook new` to create your first entry.\n'));
83
83
  }
@@ -1,19 +1,18 @@
1
1
  import fs from 'fs-extra';
2
- import { join } from 'node:path';
3
- import chalk from 'chalk';
4
- import { getConfig } from '../lib/config.js';
2
+ import { error as errorStyle, header, success as successStyle, neutral as neutralStyle } from '../lib/theme.js';
3
+ import { getConfig, getLogbookDirPath } from '../lib/config.js';
5
4
  import { runLinters } from '../lib/lint-runner.js';
6
5
  import { getLogbookEntries } from '../utils/fs.js';
7
6
  export async function lint() {
8
7
  const config = getConfig();
9
- const logbookDir = join(process.cwd(), config.logbookDir);
8
+ const logbookDir = getLogbookDirPath(config);
10
9
  if (!(await fs.pathExists(logbookDir))) {
11
- console.error(chalk.red(`Error: Logbook directory '${config.logbookDir}' not found.`));
10
+ console.error(errorStyle(`Error: Logbook directory '${config.logbookDir}' not found.`));
12
11
  return;
13
12
  }
14
13
  let overallSuccess = true;
15
14
  // 1. Project-level checks
16
- console.log(chalk.blue('Checking project integrity...'));
15
+ console.log(header('Checking project integrity...'));
17
16
  const projectSuccess = await runLinters({ config });
18
17
  if (!projectSuccess)
19
18
  overallSuccess = false;
@@ -23,7 +22,7 @@ export async function lint() {
23
22
  let skippedCount = 0; // Re-introducing skippedCount
24
23
  for (const entry of logbookEntries) {
25
24
  if (!entry.hasIndex) {
26
- console.error(chalk.red(` [MISSING] ${entry.slug}/index.md`));
25
+ console.error(errorStyle(` [MISSING] ${entry.slug}/index.md`));
27
26
  overallSuccess = false;
28
27
  continue;
29
28
  }
@@ -48,12 +47,12 @@ export async function lint() {
48
47
  }
49
48
  if (overallSuccess) {
50
49
  const skippedNote = skippedCount > 0
51
- ? chalk.gray(` (${skippedCount} DRAFT ${skippedCount === 1 ? 'entry' : 'entries'} skipped)`)
50
+ ? neutralStyle(` (${skippedCount} DRAFT ${skippedCount === 1 ? 'entry' : 'entries'} skipped)`)
52
51
  : '';
53
- console.log(chalk.green(`\nAll ${passedCount} entries passed linting!`) + skippedNote);
52
+ console.log(successStyle(`\nAll ${passedCount} entries passed linting!`) + skippedNote);
54
53
  }
55
54
  else {
56
- console.log(chalk.red('\nLinting failed with errors. If you are a LLM, try to fix the errors.'));
55
+ console.log(errorStyle('\nLinting failed with errors. If you are a LLM, try to fix the errors.'));
57
56
  process.exit(1);
58
57
  }
59
58
  }
@@ -66,5 +65,5 @@ function isDraft(entry) {
66
65
  // An entry is a draft if:
67
66
  // 1. The 'dateStart' key is missing from frontmatter (i.e., undefined or null).
68
67
  // 2. OR the string representation of 'dateStart' is '[DATE_START]'.
69
- return !frontmatter?.dateStart || String(frontmatter.dateStart) === 'DATE_START';
68
+ return !frontmatter?.dateStart || String(frontmatter.dateStart) === '[DATE_START]';
70
69
  }
@@ -1,16 +1,16 @@
1
1
  import fs from 'fs-extra';
2
- import { join } from 'node:path';
3
- import chalk from 'chalk';
4
- import { getConfig } from '../lib/config.js';
2
+ import { getConfig, getLogbookDirPath } from '../lib/config.js';
5
3
  import { getLogbookEntries } from '../utils/fs.js';
6
4
  import { stripAnsi } from '../utils/string.js';
7
5
  import { parseTicketId } from '../utils/id.js';
8
6
  import { getActiveEntry } from '../lib/session.js';
7
+ import { asString } from '../utils/frontmatter.js';
8
+ import { error, neutral, warning, highlight, success, bold } from '../lib/theme.js';
9
9
  export async function list(options = {}) {
10
10
  const config = getConfig();
11
- const logbookDir = join(process.cwd(), config.logbookDir);
11
+ const logbookDir = getLogbookDirPath(config);
12
12
  if (!(await fs.pathExists(logbookDir))) {
13
- console.error(chalk.red(`Error: Logbook directory '${config.logbookDir}' not found. Run 'logbook init' first.`));
13
+ console.error(error(`Error: Logbook directory '${config.logbookDir}' not found. Run 'logbook init' first.`));
14
14
  process.exit(1);
15
15
  }
16
16
  // Detect currently active entry (if any)
@@ -35,9 +35,9 @@ export async function list(options = {}) {
35
35
  }
36
36
  rows.push({
37
37
  ticketId: draftTicketId,
38
- title: chalk.gray(draftTitle),
38
+ title: neutral(draftTitle),
39
39
  prompter: '',
40
- status: chalk.yellow('DRAFT'),
40
+ status: warning('DRAFT'),
41
41
  });
42
42
  continue;
43
43
  }
@@ -58,16 +58,16 @@ export async function list(options = {}) {
58
58
  const bodyHasPlaceholders = contentWithoutCode.includes('TODO:') ||
59
59
  content.includes('Write a polished, highly readable "short story" of the change here.');
60
60
  const hasPlaceholders = frontmatterHasPlaceholders || bodyHasPlaceholders;
61
- const status = isActive ? chalk.cyan('● ACTIVE') : hasPlaceholders ? chalk.yellow('DRAFT') : chalk.green('DONE');
61
+ const status = isActive ? highlight('● ACTIVE') : hasPlaceholders ? warning('DRAFT') : success('DONE');
62
62
  rows.push({
63
- ticketId: typeof data.ticket === 'string' ? data.ticket : chalk.gray('-'),
64
- title: typeof data.title === 'string' ? data.title : chalk.gray('(untitled)'),
65
- prompter: typeof data.prompter === 'string' ? data.prompter : chalk.gray('-'),
63
+ ticketId: asString(data.ticket) || neutral('-'),
64
+ title: asString(data.title) || neutral('(untitled)'),
65
+ prompter: asString(data.prompter) || neutral('-'),
66
66
  status,
67
67
  });
68
68
  }
69
69
  if (rows.length === 0) {
70
- console.log(chalk.yellow('No logbook entries found. Use `logbook new <id> <slug>` to create one.'));
70
+ console.log(warning('No logbook entries found. Use `logbook new <id> <slug>` to create one.'));
71
71
  return;
72
72
  }
73
73
  // Sort by ticket ID descending
@@ -97,10 +97,10 @@ export async function list(options = {}) {
97
97
  };
98
98
  const pad = (s, len) => s + ' '.repeat(Math.max(0, len - stripAnsi(s).length));
99
99
  const header = [
100
- chalk.bold(pad('TICKET-ID', colWidths.ticketId)),
101
- chalk.bold(pad('TITLE', colWidths.title)),
102
- chalk.bold(pad('PROMPTER', colWidths.prompter)),
103
- chalk.bold('STATUS'),
100
+ bold(pad('TICKET-ID', colWidths.ticketId)),
101
+ bold(pad('TITLE', colWidths.title)),
102
+ bold(pad('PROMPTER', colWidths.prompter)),
103
+ bold('STATUS'),
104
104
  ].join(' ');
105
105
  const divider = [
106
106
  '─'.repeat(colWidths.ticketId),
@@ -110,7 +110,7 @@ export async function list(options = {}) {
110
110
  ].join(' ');
111
111
  console.log('');
112
112
  console.log(header);
113
- console.log(chalk.gray(divider));
113
+ console.log(neutral(divider));
114
114
  for (const row of displayedRows) {
115
115
  console.log([
116
116
  pad(row.ticketId, colWidths.ticketId),
@@ -123,5 +123,5 @@ export async function list(options = {}) {
123
123
  const countText = displayedRows.length === totalEntries
124
124
  ? `${totalEntries} ${totalEntries === 1 ? 'entry' : 'entries'}`
125
125
  : `Showing latest ${displayedRows.length} of ${totalEntries} entries (use --all to show all)`;
126
- console.log(chalk.gray(`${countText} in ${config.logbookDir}/`));
126
+ console.log(neutral(`${countText} in ${config.logbookDir}/`));
127
127
  }
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs-extra';
2
2
  import { join } from 'node:path';
3
- import chalk from 'chalk';
3
+ import { error as errorStyle, successMessage } from '../lib/theme.js';
4
4
  import { getConfig } from '../lib/config.js';
5
5
  import { getActiveEntry } from '../lib/session.js';
6
6
  export async function log(messages, options = {}) {
@@ -8,14 +8,14 @@ export async function log(messages, options = {}) {
8
8
  // 1. Require an active entry
9
9
  const active = await getActiveEntry(options, cwd);
10
10
  if (!active) {
11
- console.error(chalk.red(`Error: No active logbook entry resolved.`));
12
- console.error(chalk.red(`Activate an entry via 'logbook start <id>', checkout a feature branch, or pass --id.`));
11
+ console.error(errorStyle(`Error: No active logbook entry resolved.`));
12
+ console.error(errorStyle(`Activate an entry via 'logbook start <id>', checkout a feature branch, or pass --id.`));
13
13
  process.exit(1);
14
14
  }
15
15
  const config = getConfig();
16
16
  const logPath = join(cwd, config.logbookDir, active.slug, 'log.md');
17
17
  if (!(await fs.pathExists(logPath))) {
18
- console.error(chalk.red(`Error: log.md not found`));
18
+ console.error(errorStyle(`Error: log.md not found`));
19
19
  process.exit(1);
20
20
  }
21
21
  // 2. Build new lines: "- <ISO timestamp>: <message>"
@@ -26,6 +26,6 @@ export async function log(messages, options = {}) {
26
26
  const separator = existing.endsWith('\n') ? '' : '\n';
27
27
  await fs.writeFile(logPath, existing + separator + newLines + '\n');
28
28
  for (const msg of messages) {
29
- console.log(chalk.green(`Logged to ${active.slug} (via ${active.source}): ${timestamp}: ${msg}`));
29
+ console.log(successMessage('Logged', `${active.slug} (via ${active.source}): ${timestamp}: ${msg}`));
30
30
  }
31
31
  }
@@ -1,13 +1,13 @@
1
1
  import fs from 'fs-extra';
2
- import { join, dirname } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
2
+ import { join } from 'node:path';
4
3
  import { createInterface } from 'node:readline';
5
- import chalk from 'chalk';
6
- import { getConfig } from '../lib/config.js';
4
+ import { getConfig, getLogbookDirPath } from '../lib/config.js';
5
+ import { getEntryPath } from '../lib/entry-paths.js';
7
6
  import { getTemplatesDir } from '../lib/migrations.js';
7
+ import { header, warning as warningStyle, highlight, errorMessage, successMessage, warningMessage, } from '../lib/theme.js';
8
+ import { getPackageVersion } from '../lib/package-version.js';
8
9
  import { parseTicketId, getNextId } from '../utils/id.js';
9
10
  import { sanitiseSlug } from '../utils/slug.js';
10
- const __dirname = dirname(fileURLToPath(import.meta.url));
11
11
  const rl = createInterface({
12
12
  input: process.stdin,
13
13
  output: process.stdout,
@@ -21,7 +21,7 @@ const question = (prompt) => {
21
21
  };
22
22
  export async function createNewEntry(id, slug) {
23
23
  const config = getConfig();
24
- const logbookDir = join(process.cwd(), config.logbookDir);
24
+ const logbookDir = getLogbookDirPath(config);
25
25
  if (!(await fs.pathExists(logbookDir))) {
26
26
  await fs.mkdirp(logbookDir);
27
27
  }
@@ -29,19 +29,19 @@ export async function createNewEntry(id, slug) {
29
29
  let entrySlug = slug;
30
30
  // Interactive mode if no parameters provided
31
31
  if (!entryId || !entrySlug) {
32
- console.log(chalk.blue('\n📝 Creating a new logbook entry (interactive mode)\n'));
32
+ console.log(header('\n📝 Creating a new logbook entry (interactive mode)\n'));
33
33
  // Determine if we have a jiraPrefix configured
34
34
  const hasJiraPrefix = !!config.jiraPrefix;
35
35
  if (!entryId) {
36
36
  if (hasJiraPrefix) {
37
37
  // Auto-generate ID based on jiraPrefix
38
38
  const nextId = getNextId(logbookDir, config.jiraPrefix);
39
- const input = await question(chalk.cyan(`Enter entry ID (e.g. ${nextId}, press Enter for auto-generated): `));
39
+ const input = await question(highlight(`Enter entry ID (e.g. ${nextId}, press Enter for auto-generated): `));
40
40
  if (input) {
41
41
  const parsed = parseTicketId(input);
42
42
  const bareNumber = /^\d+$/.test(input) ? parseInt(input, 10) : null;
43
43
  if (parsed && parsed.prefix.toUpperCase() !== config.jiraPrefix?.toUpperCase()) {
44
- console.warn(chalk.yellow(`Warning: Prefix '${parsed.prefix}' doesn't match configured '${config.jiraPrefix}'. Using configured prefix.`));
44
+ console.warn(warningStyle(`Warning: Prefix '${parsed.prefix}' doesn't match configured '${config.jiraPrefix}'. Using configured prefix.`));
45
45
  entryId = `${config.jiraPrefix}-${parsed.number}`;
46
46
  }
47
47
  else if (parsed) {
@@ -51,7 +51,7 @@ export async function createNewEntry(id, slug) {
51
51
  entryId = `${config.jiraPrefix}-${bareNumber}`;
52
52
  }
53
53
  else {
54
- console.warn(chalk.yellow('Invalid ID format. Using auto-generated ID.'));
54
+ console.warn(warningStyle('Invalid ID format. Using auto-generated ID.'));
55
55
  entryId = nextId;
56
56
  }
57
57
  }
@@ -61,7 +61,7 @@ export async function createNewEntry(id, slug) {
61
61
  }
62
62
  else {
63
63
  const nextId = getNextId(logbookDir, 'LB');
64
- const input = await question(chalk.cyan(`Enter entry ID (e.g. ${nextId}, press Enter for auto-generated): `));
64
+ const input = await question(highlight(`Enter entry ID (e.g. ${nextId}, press Enter for auto-generated): `));
65
65
  if (input) {
66
66
  const parsed = parseTicketId(input);
67
67
  const bareNumber = /^\d+$/.test(input) ? parseInt(input, 10) : null;
@@ -72,7 +72,7 @@ export async function createNewEntry(id, slug) {
72
72
  entryId = `LB-${bareNumber}`;
73
73
  }
74
74
  else {
75
- console.warn(chalk.yellow('Invalid ID format. Using auto-generated ID.'));
75
+ console.warn(warningStyle('Invalid ID format. Using auto-generated ID.'));
76
76
  entryId = nextId;
77
77
  }
78
78
  }
@@ -82,9 +82,9 @@ export async function createNewEntry(id, slug) {
82
82
  }
83
83
  }
84
84
  if (!entrySlug) {
85
- const input = await question(chalk.cyan('Enter a short title (will be converted to slug): '));
85
+ const input = await question(highlight('Enter a short title (will be converted to slug): '));
86
86
  if (!input) {
87
- console.error(chalk.red('Error: Title is required.'));
87
+ console.error(errorMessage('Error', 'Title is required.'));
88
88
  process.exit(1);
89
89
  }
90
90
  entrySlug = sanitiseSlug(input);
@@ -97,17 +97,16 @@ export async function createNewEntry(id, slug) {
97
97
  // Sanitise slug
98
98
  const sanitised = sanitiseSlug(entrySlug);
99
99
  if (sanitised !== entrySlug) {
100
- console.log(chalk.yellow(`Notice: Slug normalised from '${entrySlug}' to '${sanitised}'.`));
100
+ console.warn(warningMessage('Notice', `Slug normalised from '${entrySlug}' to '${sanitised}'.`));
101
101
  entrySlug = sanitised;
102
102
  }
103
- const entryDir = join(process.cwd(), config.logbookDir, `${entryId}-${entrySlug}`);
103
+ const entryDir = getEntryPath(getLogbookDirPath(config), `${entryId}-${entrySlug}`);
104
104
  if (await fs.pathExists(entryDir)) {
105
- console.error(chalk.red(`Error: Entry ${entryId}-${entrySlug} already exists.`));
105
+ console.error(errorMessage('Error', `Entry ${entryId}-${entrySlug} already exists.`));
106
106
  process.exit(1);
107
107
  }
108
108
  await fs.mkdirp(entryDir);
109
- const pkg = JSON.parse(await fs.readFile(join(__dirname, '../../package.json'), 'utf8'));
110
- const version = pkg.version;
109
+ const version = getPackageVersion();
111
110
  const now = new Date();
112
111
  const replacements = {
113
112
  id: entryId,
@@ -131,5 +130,5 @@ export async function createNewEntry(id, slug) {
131
130
  await fs.writeFile(join(entryDir, 'index.md'), fillTemplate(getTemplate('index.md')));
132
131
  await fs.writeFile(join(entryDir, 'ticket.md'), fillTemplate(getTemplate('ticket.md')));
133
132
  await fs.writeFile(join(entryDir, 'log.md'), fillTemplate(getTemplate('log.md')));
134
- console.log(chalk.green(`\nSuccessfully created logbook entry in ${config.logbookDir}/${entryId}-${entrySlug}/`));
133
+ console.log(successMessage('Created', `logbook entry in ${config.logbookDir}/${entryId}-${entrySlug}/`));
135
134
  }
@@ -1,19 +1,20 @@
1
- import { exec } from 'node:child_process';
1
+ import { execFile } from 'node:child_process';
2
2
  import { join } from 'node:path';
3
- import chalk from 'chalk';
4
- import { getConfig } from '../lib/config.js';
3
+ import { header, error as errorStyle } from '../lib/theme.js';
4
+ import { getConfig, getOutputDirPath } from '../lib/config.js';
5
5
  import { build } from './build.js';
6
6
  export async function preview() {
7
7
  const config = getConfig();
8
- const outputDir = join(process.cwd(), config.outputDir);
8
+ const outputDir = getOutputDirPath(config);
9
9
  const indexPath = join(outputDir, 'index.html');
10
- console.log(chalk.blue('Building logbook before preview...'));
10
+ console.log(header('Building logbook before preview...'));
11
11
  await build();
12
- console.log(chalk.blue(`Opening preview: ${indexPath}`));
12
+ console.log(header(`Opening preview: ${indexPath}`));
13
13
  const start = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
14
- exec(`${start} "${indexPath}"`, (err) => {
14
+ // Use execFile with explicit args array to avoid shell interpolation
15
+ execFile(start, [indexPath], (err) => {
15
16
  if (err) {
16
- console.error(chalk.red(`Failed to open preview: ${err.message}`));
17
+ console.error(errorStyle(`Failed to open preview: ${err.message}`));
17
18
  }
18
19
  });
19
20
  }
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs-extra';
2
2
  import { join } from 'node:path';
3
- import chalk from 'chalk';
3
+ import { neutral, warning as warningStyle, successMessage } from '../lib/theme.js';
4
4
  import matter from 'gray-matter';
5
5
  import { getConfig } from '../lib/config.js';
6
6
  import { getActiveEntry } from '../lib/session.js';
@@ -10,7 +10,7 @@ export async function release(options = {}) {
10
10
  const lockfilePath = join(cwd, LOCKFILE_NAME);
11
11
  const active = await getActiveEntry(options, cwd);
12
12
  if (!active) {
13
- console.log(chalk.yellow('No active entry resolved. Nothing to release.'));
13
+ console.log(warningStyle('No active entry resolved. Nothing to release.'));
14
14
  return;
15
15
  }
16
16
  const releasedAt = new Date().toISOString();
@@ -21,7 +21,7 @@ export async function release(options = {}) {
21
21
  if (await fs.pathExists(indexPath)) {
22
22
  let raw = await fs.readFile(indexPath, 'utf8');
23
23
  // Always update dateEnd (replace placeholder OR existing value)
24
- const hasPlaceholder = raw.includes('dateEnd: [DATE_END]');
24
+ const hasPlaceholder = raw.includes('dateEnd: "[DATE_END]"');
25
25
  const dateEndRegex = /dateEnd:.*$/m;
26
26
  if (hasPlaceholder || dateEndRegex.test(raw)) {
27
27
  raw = raw.replace(dateEndRegex, `dateEnd: ${releasedAt}`);
@@ -44,13 +44,13 @@ export async function release(options = {}) {
44
44
  await fs.remove(lockfilePath);
45
45
  }
46
46
  }
47
- console.log(chalk.green(`Released entry: ${active.slug} (via ${active.source})`));
47
+ console.log(successMessage('Released', `entry: ${active.slug} (via ${active.source})`));
48
48
  if (active.startedAt) {
49
49
  const elapsed = formatElapsed(new Date(active.startedAt), new Date(releasedAt));
50
- console.log(chalk.gray(`Active for ${elapsed}. You may now commit and build.`));
50
+ console.log(neutral(`Active for ${elapsed}. You may now commit and build.`));
51
51
  }
52
52
  else {
53
- console.log(chalk.gray(`Released at ${releasedAt}. You may now commit and build.`));
53
+ console.log(neutral(`Released at ${releasedAt}. You may now commit and build.`));
54
54
  }
55
55
  }
56
56
  function formatElapsed(from, to) {