project-logbook 0.3.3 → 0.4.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 (75) hide show
  1. package/dist/commands/build.js +54 -76
  2. package/dist/commands/init.js +11 -11
  3. package/dist/commands/lint.js +27 -10
  4. package/dist/commands/list.js +18 -18
  5. package/dist/commands/log.js +5 -5
  6. package/dist/commands/new.js +19 -20
  7. package/dist/commands/preview.js +9 -8
  8. package/dist/commands/release.js +6 -6
  9. package/dist/commands/start.js +26 -21
  10. package/dist/commands/status.d.ts +1 -0
  11. package/dist/commands/status.js +138 -0
  12. package/dist/commands/steer.js +9 -9
  13. package/dist/commands/upgrade.js +5 -5
  14. package/dist/index.js +9 -8
  15. package/dist/lib/build-helpers.js +30 -67
  16. package/dist/lib/build-steps.d.ts +20 -0
  17. package/dist/lib/build-steps.js +57 -0
  18. package/dist/lib/config.d.ts +17 -0
  19. package/dist/lib/config.js +27 -3
  20. package/dist/lib/entry-id.d.ts +22 -0
  21. package/dist/lib/entry-id.js +26 -0
  22. package/dist/lib/entry-paths.d.ts +23 -0
  23. package/dist/lib/entry-paths.js +55 -0
  24. package/dist/lib/git-helpers.d.ts +32 -1
  25. package/dist/lib/git-helpers.js +119 -26
  26. package/dist/lib/hast-helpers.d.ts +10 -0
  27. package/dist/lib/hast-helpers.js +22 -0
  28. package/dist/lib/html-attributes.d.ts +17 -0
  29. package/dist/lib/html-attributes.js +17 -0
  30. package/dist/lib/html-escape.d.ts +16 -0
  31. package/dist/lib/html-escape.js +38 -0
  32. package/dist/lib/image-helpers.js +26 -33
  33. package/dist/lib/lint-runner.js +5 -5
  34. package/dist/lib/markdown-processors.d.ts +22 -0
  35. package/dist/lib/markdown-processors.js +68 -0
  36. package/dist/lib/package-version.d.ts +5 -0
  37. package/dist/lib/package-version.js +16 -0
  38. package/dist/lib/rss.d.ts +29 -0
  39. package/dist/lib/rss.js +77 -0
  40. package/dist/lib/styles.js +5 -2
  41. package/dist/lib/template-helpers.d.ts +4 -3
  42. package/dist/lib/template-helpers.js +51 -27
  43. package/dist/lib/template-types.d.ts +2 -2
  44. package/dist/lib/templates.d.ts +12 -3
  45. package/dist/lib/templates.js +59 -52
  46. package/dist/lib/theme.d.ts +37 -0
  47. package/dist/lib/theme.js +50 -0
  48. package/dist/lib/url-helpers.d.ts +13 -0
  49. package/dist/lib/url-helpers.js +27 -0
  50. package/dist/linters/diff-to-narrative.d.ts +6 -0
  51. package/dist/linters/diff-to-narrative.js +114 -0
  52. package/dist/linters/index.js +4 -0
  53. package/dist/linters/technical-log.d.ts +7 -0
  54. package/dist/linters/technical-log.js +72 -0
  55. package/dist/templates/CONTRIBUTING.md +12 -3
  56. package/dist/templates/index.md +10 -6
  57. package/dist/templates/log.md +5 -0
  58. package/dist/templates/logbook-client.js +42 -16
  59. package/dist/templates/steer.txt +21 -5
  60. package/dist/templates/styles.css +121 -0
  61. package/dist/utils/date.d.ts +30 -1
  62. package/dist/utils/date.js +68 -15
  63. package/dist/utils/frontmatter.d.ts +26 -0
  64. package/dist/utils/frontmatter.js +37 -0
  65. package/dist/utils/fs.d.ts +13 -0
  66. package/dist/utils/fs.js +23 -0
  67. package/dist/utils/log-timeline.d.ts +69 -0
  68. package/dist/utils/log-timeline.js +218 -0
  69. package/package.json +4 -2
  70. package/src/templates/CONTRIBUTING.md +12 -3
  71. package/src/templates/index.md +10 -6
  72. package/src/templates/log.md +5 -0
  73. package/src/templates/logbook-client.js +42 -16
  74. package/src/templates/steer.txt +21 -5
  75. package/src/templates/styles.css +121 -0
@@ -1,73 +1,34 @@
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, generateBuildMeta } 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
+ import { generateRssFeed, validateRssXml } from '../lib/rss.js';
17
+ const version = getPackageVersion();
27
18
  export async function build() {
28
19
  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(',', '');
20
+ const logbookDir = getLogbookDirPath(config);
21
+ const outputDir = getOutputDirPath(config);
22
+ const buildTime = formatDateTimeForDisplay(new Date());
42
23
  if (!(await fs.pathExists(logbookDir))) {
43
- console.error(chalk.red(`Error: Logbook directory '${config.logbookDir}' not found.`));
24
+ console.error(error(`Error: Logbook directory '${config.logbookDir}' not found.`));
44
25
  return;
45
26
  }
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());
27
+ await setupOutputDirectory(outputDir, config);
28
+ const projectRoot = process.cwd();
29
+ const { html: readmeHtml, imagePaths: readmeImages } = await processReadme(projectRoot);
30
+ await copyReadmeImages(projectRoot, outputDir, readmeImages);
31
+ const aboutHtml = await processAboutContent(getAboutContent());
71
32
  const logbookEntries = await getLogbookEntries(logbookDir);
72
33
  const timelineEntries = [];
73
34
  const availableWorkspaces = getWorkspaces();
@@ -82,9 +43,7 @@ export async function build() {
82
43
  ? firstParagraph.trim().substring(0, 200).replace(/[*#`]/g, '') + '...'
83
44
  : 'No summary available.';
84
45
  }
85
- const entryWorkspaces = Array.isArray(data.workspaces)
86
- ? data.workspaces.filter((ws) => typeof ws === 'string' && availableWorkspaces.includes(ws))
87
- : [];
46
+ const entryWorkspaces = asStringArray(data.workspaces).filter((ws) => availableWorkspaces.includes(ws));
88
47
  const dateStartValue = data.dateStart;
89
48
  const dateStart = typeof dateStartValue === 'string'
90
49
  ? dateStartValue
@@ -92,10 +51,10 @@ export async function build() {
92
51
  ? dateStartValue.toISOString()
93
52
  : '';
94
53
  // Skip DRAFT entries (no valid dateStart) — they break the timeline
95
- if (!dateStart || dateStart.includes('{{'))
54
+ if (!dateStart || dateStart.includes('{{') || dateStart === '[DATE_START]')
96
55
  continue;
97
56
  const dateEndValue = data.dateEnd;
98
- const dateEnd = typeof dateEndValue === 'string'
57
+ const dateEnd = typeof dateEndValue === 'string' && dateEndValue !== '[DATE_END]'
99
58
  ? dateEndValue
100
59
  : dateEndValue instanceof Date
101
60
  ? dateEndValue.toISOString()
@@ -105,14 +64,14 @@ export async function build() {
105
64
  slug,
106
65
  dateStart,
107
66
  dateEnd,
108
- displayDate: formatRelativeDate(dateStart),
67
+ displayDate: formatAbsoluteDate(dateStart),
109
68
  sortTime: getSortTime(dateStart, dateEnd),
110
- summary: typeof summary === 'string' ? summary : 'No summary available.',
111
- ticket: typeof data.ticket === 'string' ? data.ticket : slug,
69
+ summary: asString(summary, 'No summary available.') || '',
70
+ ticket: getEntryDisplayId(asString(data.ticket), slug),
112
71
  monthGroup: getMonthYear(dateStart),
113
- tags: Array.isArray(data.tags) ? data.tags.filter((t) => typeof t === 'string') : [],
72
+ tags: asStringArray(data.tags),
114
73
  workspaces: entryWorkspaces,
115
- title: typeof data.title === 'string' ? data.title : undefined,
74
+ title: getEntryDisplayTitle(asString(data.title), slug),
116
75
  harness: toDisplayString(data.harness),
117
76
  llm: toDisplayString(data.llm),
118
77
  prompter: toDisplayString(data.prompter),
@@ -125,8 +84,7 @@ export async function build() {
125
84
  sha: c.sha,
126
85
  message: c.message,
127
86
  timestamp: c.timestamp,
128
- relativeTime: c.relativeTime,
129
- displayDate: formatRelativeDate(c.timestamp),
87
+ displayDate: formatAbsoluteDate(c.timestamp),
130
88
  monthGroup: getMonthYear(c.timestamp),
131
89
  sortTime: getSortTime(c.timestamp),
132
90
  }));
@@ -134,11 +92,11 @@ export async function build() {
134
92
  kind: 'tag',
135
93
  name: t.name,
136
94
  timestamp: t.timestamp,
137
- displayDate: formatRelativeDate(t.timestamp),
95
+ displayDate: formatAbsoluteDate(t.timestamp),
138
96
  monthGroup: getMonthYear(t.timestamp),
139
97
  sortTime: getSortTime(t.timestamp),
140
98
  }));
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'}.`));
99
+ 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
100
  timelineEntries.sort((a, b) => {
143
101
  const aEnd = a.dateEnd ? new Date(a.dateEnd).getTime() : new Date(a.dateStart).getTime();
144
102
  const bEnd = b.dateEnd ? new Date(b.dateEnd).getTime() : new Date(b.dateStart).getTime();
@@ -164,7 +122,7 @@ export async function build() {
164
122
  }
165
123
  group.items.push(item);
166
124
  }
167
- const timelineContent = timelineTemplate({
125
+ const { header: timelineHeader, content: timelineMainContent } = timelineTemplate({
168
126
  projectName: config.projectName,
169
127
  version,
170
128
  buildTime,
@@ -173,8 +131,10 @@ export async function build() {
173
131
  aboutHtml,
174
132
  jiraBaseUrl: config.jiraBaseUrl,
175
133
  jiraPrefix: config.jiraPrefix,
134
+ repositoryUrl: config.repositoryUrl,
176
135
  currentPage,
177
136
  totalPages,
137
+ includeRssLink: currentPage === 1, // Only show RSS link on first page
178
138
  }); // prettier-ignore
179
139
  const buildMeta = generateBuildMeta(version, buildTime);
180
140
  const timelineHtml = layout({
@@ -182,8 +142,9 @@ export async function build() {
182
142
  projectName: config.projectName,
183
143
  basePath: './',
184
144
  buildMeta,
185
- header: timelineContent.split('</header>')[0] + '</header>',
186
- content: timelineContent.split('</header>')[1],
145
+ header: timelineHeader,
146
+ content: timelineMainContent,
147
+ includeRssLink: currentPage === 1, // Only show RSS link on first page
187
148
  });
188
149
  const fileName = currentPage === 1 ? 'index.html' : `index-${currentPage}.html`;
189
150
  await fs.writeFile(join(outputDir, fileName), timelineHtml);
@@ -193,5 +154,22 @@ export async function build() {
193
154
  version,
194
155
  buildTime,
195
156
  });
196
- console.log(chalk.green(`\nSuccessfully built logbook to ${config.outputDir}/`));
157
+ // Generate RSS feed
158
+ const rssConfig = {
159
+ title: `${config.projectName} - Recent Updates`,
160
+ description: `Latest logbook entries from ${config.projectName}`,
161
+ language: 'en',
162
+ feed_url: `${config.projectName.toLowerCase().replace(/\s+/g, '-')}/rss.xml`,
163
+ site_url: config.projectName,
164
+ generator: `Project Logbook CLI v${version}`,
165
+ };
166
+ const rssXml = generateRssFeed(timelineEntries, rssConfig);
167
+ // Basic validation
168
+ if (!validateRssXml(rssXml)) {
169
+ console.error(error('Error: Generated RSS feed failed basic validation.'));
170
+ return;
171
+ }
172
+ await fs.writeFile(join(outputDir, 'rss.xml'), rssXml);
173
+ console.log(neutral(` Generated RSS feed with ${Math.min(50, timelineEntries.length)} entries`));
174
+ console.log(success(`\nSuccessfully built logbook to ${config.outputDir}/`));
197
175
  }
@@ -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,36 @@
1
1
  import fs from 'fs-extra';
2
2
  import { join } from 'node:path';
3
- import chalk from 'chalk';
4
- import { getConfig } from '../lib/config.js';
3
+ import { error as errorStyle, header, success as successStyle, neutral as neutralStyle } from '../lib/theme.js';
4
+ import { getConfig, getLogbookDirPath } from '../lib/config.js';
5
5
  import { runLinters } from '../lib/lint-runner.js';
6
6
  import { getLogbookEntries } from '../utils/fs.js';
7
+ import { getActiveEntry } from '../lib/session.js';
8
+ import { getLastLogEntry } from '../utils/log-timeline.js';
7
9
  export async function lint() {
8
10
  const config = getConfig();
9
- const logbookDir = join(process.cwd(), config.logbookDir);
11
+ const logbookDir = getLogbookDirPath(config);
10
12
  if (!(await fs.pathExists(logbookDir))) {
11
- console.error(chalk.red(`Error: Logbook directory '${config.logbookDir}' not found.`));
13
+ console.error(errorStyle(`Error: Logbook directory '${config.logbookDir}' not found.`));
12
14
  return;
13
15
  }
14
16
  let overallSuccess = true;
17
+ // Show last log entry nudge if an entry is active
18
+ const active = await getActiveEntry();
19
+ if (active) {
20
+ const logPath = join(logbookDir, active.slug, 'log.md');
21
+ if (await fs.pathExists(logPath)) {
22
+ const logContent = await fs.readFile(logPath, 'utf8');
23
+ const lastEntry = getLastLogEntry(logContent);
24
+ if (lastEntry) {
25
+ const truncated = lastEntry.message.length > 80 ? lastEntry.message.slice(0, 77) + '...' : lastEntry.message;
26
+ console.log(header(`Active entry: ${active.slug}`));
27
+ console.log(` Last Log: ${lastEntry.isoTimestamp}: ${truncated}`);
28
+ console.log(neutralStyle(` → Use 'logbook log "<message>"' if there's anything to add.\n`));
29
+ }
30
+ }
31
+ }
15
32
  // 1. Project-level checks
16
- console.log(chalk.blue('Checking project integrity...'));
33
+ console.log(header('Checking project integrity...'));
17
34
  const projectSuccess = await runLinters({ config });
18
35
  if (!projectSuccess)
19
36
  overallSuccess = false;
@@ -23,7 +40,7 @@ export async function lint() {
23
40
  let skippedCount = 0; // Re-introducing skippedCount
24
41
  for (const entry of logbookEntries) {
25
42
  if (!entry.hasIndex) {
26
- console.error(chalk.red(` [MISSING] ${entry.slug}/index.md`));
43
+ console.error(errorStyle(` [MISSING] ${entry.slug}/index.md`));
27
44
  overallSuccess = false;
28
45
  continue;
29
46
  }
@@ -48,12 +65,12 @@ export async function lint() {
48
65
  }
49
66
  if (overallSuccess) {
50
67
  const skippedNote = skippedCount > 0
51
- ? chalk.gray(` (${skippedCount} DRAFT ${skippedCount === 1 ? 'entry' : 'entries'} skipped)`)
68
+ ? neutralStyle(` (${skippedCount} DRAFT ${skippedCount === 1 ? 'entry' : 'entries'} skipped)`)
52
69
  : '';
53
- console.log(chalk.green(`\nAll ${passedCount} entries passed linting!`) + skippedNote);
70
+ console.log(successStyle(`\nAll ${passedCount} entries passed linting!`) + skippedNote);
54
71
  }
55
72
  else {
56
- console.log(chalk.red('\nLinting failed with errors. If you are a LLM, try to fix the errors.'));
73
+ console.log(errorStyle('\nLinting failed with errors. If you are a LLM, try to fix the errors.'));
57
74
  process.exit(1);
58
75
  }
59
76
  }
@@ -66,5 +83,5 @@ function isDraft(entry) {
66
83
  // An entry is a draft if:
67
84
  // 1. The 'dateStart' key is missing from frontmatter (i.e., undefined or null).
68
85
  // 2. OR the string representation of 'dateStart' is '[DATE_START]'.
69
- return !frontmatter?.dateStart || String(frontmatter.dateStart) === 'DATE_START';
86
+ return !frontmatter?.dateStart || String(frontmatter.dateStart) === '[DATE_START]';
70
87
  }
@@ -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
  }