project-logbook 0.3.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 (102) hide show
  1. package/README.md +34 -0
  2. package/dist/commands/build.d.ts +1 -0
  3. package/dist/commands/build.js +174 -0
  4. package/dist/commands/init.d.ts +1 -0
  5. package/dist/commands/init.js +83 -0
  6. package/dist/commands/lint.d.ts +1 -0
  7. package/dist/commands/lint.js +78 -0
  8. package/dist/commands/list.d.ts +3 -0
  9. package/dist/commands/list.js +127 -0
  10. package/dist/commands/log.d.ts +3 -0
  11. package/dist/commands/log.js +31 -0
  12. package/dist/commands/new.d.ts +1 -0
  13. package/dist/commands/new.js +135 -0
  14. package/dist/commands/preview.d.ts +1 -0
  15. package/dist/commands/preview.js +19 -0
  16. package/dist/commands/release.d.ts +3 -0
  17. package/dist/commands/release.js +66 -0
  18. package/dist/commands/start.d.ts +1 -0
  19. package/dist/commands/start.js +87 -0
  20. package/dist/commands/steer.d.ts +1 -0
  21. package/dist/commands/steer.js +22 -0
  22. package/dist/commands/upgrade.d.ts +1 -0
  23. package/dist/commands/upgrade.js +23 -0
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.js +92 -0
  26. package/dist/lib/about-content.d.ts +1 -0
  27. package/dist/lib/about-content.js +7 -0
  28. package/dist/lib/build-helpers.d.ts +15 -0
  29. package/dist/lib/build-helpers.js +171 -0
  30. package/dist/lib/config.d.ts +14 -0
  31. package/dist/lib/config.js +41 -0
  32. package/dist/lib/git-helpers.d.ts +16 -0
  33. package/dist/lib/git-helpers.js +93 -0
  34. package/dist/lib/image-helpers.d.ts +19 -0
  35. package/dist/lib/image-helpers.js +121 -0
  36. package/dist/lib/jira-helpers.d.ts +5 -0
  37. package/dist/lib/jira-helpers.js +5 -0
  38. package/dist/lib/lint-runner.d.ts +2 -0
  39. package/dist/lib/lint-runner.js +31 -0
  40. package/dist/lib/lint-types.d.ts +24 -0
  41. package/dist/lib/lint-types.js +1 -0
  42. package/dist/lib/lockfile.d.ts +6 -0
  43. package/dist/lib/lockfile.js +1 -0
  44. package/dist/lib/logbook-client.d.ts +5 -0
  45. package/dist/lib/logbook-client.js +11 -0
  46. package/dist/lib/migrations.d.ts +11 -0
  47. package/dist/lib/migrations.js +34 -0
  48. package/dist/lib/session.d.ts +16 -0
  49. package/dist/lib/session.js +74 -0
  50. package/dist/lib/styles.d.ts +1 -0
  51. package/dist/lib/styles.js +21 -0
  52. package/dist/lib/template-types.d.ts +118 -0
  53. package/dist/lib/template-types.js +1 -0
  54. package/dist/lib/templates.d.ts +14 -0
  55. package/dist/lib/templates.js +158 -0
  56. package/dist/linters/frontmatter.d.ts +3 -0
  57. package/dist/linters/frontmatter.js +68 -0
  58. package/dist/linters/index.d.ts +1 -0
  59. package/dist/linters/index.js +18 -0
  60. package/dist/linters/jira-prefix.d.ts +3 -0
  61. package/dist/linters/jira-prefix.js +34 -0
  62. package/dist/linters/links.d.ts +3 -0
  63. package/dist/linters/links.js +28 -0
  64. package/dist/linters/lockfile.d.ts +3 -0
  65. package/dist/linters/lockfile.js +20 -0
  66. package/dist/linters/placeholders.d.ts +3 -0
  67. package/dist/linters/placeholders.js +62 -0
  68. package/dist/linters/project-integrity.d.ts +3 -0
  69. package/dist/linters/project-integrity.js +29 -0
  70. package/dist/linters/readability.d.ts +3 -0
  71. package/dist/linters/readability.js +41 -0
  72. package/dist/linters/workspaces.d.ts +3 -0
  73. package/dist/linters/workspaces.js +26 -0
  74. package/dist/templates/ABOUT.md +23 -0
  75. package/dist/templates/CONTRIBUTING.md +78 -0
  76. package/dist/templates/favicon.svg +21 -0
  77. package/dist/templates/index.md +30 -0
  78. package/dist/templates/log.md +4 -0
  79. package/dist/templates/logbook-client.js +162 -0
  80. package/dist/templates/steer.txt +25 -0
  81. package/dist/templates/styles.css +641 -0
  82. package/dist/templates/ticket.md +4 -0
  83. package/dist/utils/date.d.ts +8 -0
  84. package/dist/utils/date.js +47 -0
  85. package/dist/utils/fs.d.ts +13 -0
  86. package/dist/utils/fs.js +38 -0
  87. package/dist/utils/id.d.ts +7 -0
  88. package/dist/utils/id.js +36 -0
  89. package/dist/utils/slug.d.ts +2 -0
  90. package/dist/utils/slug.js +9 -0
  91. package/dist/utils/string.d.ts +2 -0
  92. package/dist/utils/string.js +4 -0
  93. package/package.json +69 -0
  94. package/src/templates/ABOUT.md +23 -0
  95. package/src/templates/CONTRIBUTING.md +78 -0
  96. package/src/templates/favicon.svg +21 -0
  97. package/src/templates/index.md +30 -0
  98. package/src/templates/log.md +4 -0
  99. package/src/templates/logbook-client.js +162 -0
  100. package/src/templates/steer.txt +25 -0
  101. package/src/templates/styles.css +641 -0
  102. package/src/templates/ticket.md +4 -0
@@ -0,0 +1,135 @@
1
+ import fs from 'fs-extra';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createInterface } from 'node:readline';
5
+ import chalk from 'chalk';
6
+ import { getConfig } from '../lib/config.js';
7
+ import { getTemplatesDir } from '../lib/migrations.js';
8
+ import { parseTicketId, getNextId } from '../utils/id.js';
9
+ import { sanitiseSlug } from '../utils/slug.js';
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const rl = createInterface({
12
+ input: process.stdin,
13
+ output: process.stdout,
14
+ });
15
+ const question = (prompt) => {
16
+ return new Promise((resolve) => {
17
+ rl.question(prompt, (answer) => {
18
+ resolve(answer.trim());
19
+ });
20
+ });
21
+ };
22
+ export async function createNewEntry(id, slug) {
23
+ const config = getConfig();
24
+ const logbookDir = join(process.cwd(), config.logbookDir);
25
+ if (!(await fs.pathExists(logbookDir))) {
26
+ await fs.mkdirp(logbookDir);
27
+ }
28
+ let entryId = id;
29
+ let entrySlug = slug;
30
+ // Interactive mode if no parameters provided
31
+ if (!entryId || !entrySlug) {
32
+ console.log(chalk.blue('\n📝 Creating a new logbook entry (interactive mode)\n'));
33
+ // Determine if we have a jiraPrefix configured
34
+ const hasJiraPrefix = !!config.jiraPrefix;
35
+ if (!entryId) {
36
+ if (hasJiraPrefix) {
37
+ // Auto-generate ID based on jiraPrefix
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): `));
40
+ if (input) {
41
+ const parsed = parseTicketId(input);
42
+ const bareNumber = /^\d+$/.test(input) ? parseInt(input, 10) : null;
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.`));
45
+ entryId = `${config.jiraPrefix}-${parsed.number}`;
46
+ }
47
+ else if (parsed) {
48
+ entryId = input.toUpperCase();
49
+ }
50
+ else if (bareNumber !== null) {
51
+ entryId = `${config.jiraPrefix}-${bareNumber}`;
52
+ }
53
+ else {
54
+ console.warn(chalk.yellow('Invalid ID format. Using auto-generated ID.'));
55
+ entryId = nextId;
56
+ }
57
+ }
58
+ else {
59
+ entryId = nextId;
60
+ }
61
+ }
62
+ else {
63
+ const nextId = getNextId(logbookDir, 'LB');
64
+ const input = await question(chalk.cyan(`Enter entry ID (e.g. ${nextId}, press Enter for auto-generated): `));
65
+ if (input) {
66
+ const parsed = parseTicketId(input);
67
+ const bareNumber = /^\d+$/.test(input) ? parseInt(input, 10) : null;
68
+ if (parsed) {
69
+ entryId = input.toUpperCase();
70
+ }
71
+ else if (bareNumber !== null) {
72
+ entryId = `LB-${bareNumber}`;
73
+ }
74
+ else {
75
+ console.warn(chalk.yellow('Invalid ID format. Using auto-generated ID.'));
76
+ entryId = nextId;
77
+ }
78
+ }
79
+ else {
80
+ entryId = nextId;
81
+ }
82
+ }
83
+ }
84
+ if (!entrySlug) {
85
+ const input = await question(chalk.cyan('Enter a short title (will be converted to slug): '));
86
+ if (!input) {
87
+ console.error(chalk.red('Error: Title is required.'));
88
+ process.exit(1);
89
+ }
90
+ entrySlug = sanitiseSlug(input);
91
+ }
92
+ rl.close();
93
+ }
94
+ else {
95
+ rl.close();
96
+ }
97
+ // Sanitise slug
98
+ const sanitised = sanitiseSlug(entrySlug);
99
+ if (sanitised !== entrySlug) {
100
+ console.log(chalk.yellow(`Notice: Slug normalised from '${entrySlug}' to '${sanitised}'.`));
101
+ entrySlug = sanitised;
102
+ }
103
+ const entryDir = join(process.cwd(), config.logbookDir, `${entryId}-${entrySlug}`);
104
+ if (await fs.pathExists(entryDir)) {
105
+ console.error(chalk.red(`Error: Entry ${entryId}-${entrySlug} already exists.`));
106
+ process.exit(1);
107
+ }
108
+ await fs.mkdirp(entryDir);
109
+ const pkg = JSON.parse(await fs.readFile(join(__dirname, '../../package.json'), 'utf8'));
110
+ const version = pkg.version;
111
+ const now = new Date();
112
+ const replacements = {
113
+ id: entryId,
114
+ slug: entrySlug,
115
+ title: entrySlug.replace(/-/g, ' '),
116
+ date: now.toISOString().split('T')[0],
117
+ time: now.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
118
+ fullIso: now.toISOString(),
119
+ version: `v${version}`,
120
+ };
121
+ const templatesDir = getTemplatesDir();
122
+ const getTemplate = (name) => {
123
+ const p = join(templatesDir, name);
124
+ if (fs.existsSync(p))
125
+ return fs.readFileSync(p, 'utf8');
126
+ throw new Error(`Template ${name} not found.`);
127
+ };
128
+ const fillTemplate = (template) => {
129
+ return template.replace(/{{(\w+)}}/g, (_, key) => replacements[key] || `{{${key}}}`);
130
+ };
131
+ await fs.writeFile(join(entryDir, 'index.md'), fillTemplate(getTemplate('index.md')));
132
+ await fs.writeFile(join(entryDir, 'ticket.md'), fillTemplate(getTemplate('ticket.md')));
133
+ 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}/`));
135
+ }
@@ -0,0 +1 @@
1
+ export declare function preview(): Promise<void>;
@@ -0,0 +1,19 @@
1
+ import { exec } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { getConfig } from '../lib/config.js';
5
+ import { build } from './build.js';
6
+ export async function preview() {
7
+ const config = getConfig();
8
+ const outputDir = join(process.cwd(), config.outputDir);
9
+ const indexPath = join(outputDir, 'index.html');
10
+ console.log(chalk.blue('Building logbook before preview...'));
11
+ await build();
12
+ console.log(chalk.blue(`Opening preview: ${indexPath}`));
13
+ const start = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
14
+ exec(`${start} "${indexPath}"`, (err) => {
15
+ if (err) {
16
+ console.error(chalk.red(`Failed to open preview: ${err.message}`));
17
+ }
18
+ });
19
+ }
@@ -0,0 +1,3 @@
1
+ export declare function release(options?: {
2
+ id?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,66 @@
1
+ import fs from 'fs-extra';
2
+ import { join } from 'node:path';
3
+ import chalk from 'chalk';
4
+ import matter from 'gray-matter';
5
+ import { getConfig } from '../lib/config.js';
6
+ import { getActiveEntry } from '../lib/session.js';
7
+ import { LOCKFILE_NAME } from '../lib/lockfile.js';
8
+ export async function release(options = {}) {
9
+ const cwd = process.cwd();
10
+ const lockfilePath = join(cwd, LOCKFILE_NAME);
11
+ const active = await getActiveEntry(options, cwd);
12
+ if (!active) {
13
+ console.log(chalk.yellow('No active entry resolved. Nothing to release.'));
14
+ return;
15
+ }
16
+ const releasedAt = new Date().toISOString();
17
+ // Inject dateEnd into index.md frontmatter
18
+ const config = getConfig();
19
+ const entryDir = join(cwd, config.logbookDir, active.slug);
20
+ const indexPath = join(entryDir, 'index.md');
21
+ if (await fs.pathExists(indexPath)) {
22
+ let raw = await fs.readFile(indexPath, 'utf8');
23
+ // Always update dateEnd (replace placeholder OR existing value)
24
+ const hasPlaceholder = raw.includes('dateEnd: [DATE_END]');
25
+ const dateEndRegex = /dateEnd:.*$/m;
26
+ if (hasPlaceholder || dateEndRegex.test(raw)) {
27
+ raw = raw.replace(dateEndRegex, `dateEnd: ${releasedAt}`);
28
+ }
29
+ // Parse frontmatter and re-stringify to strip YAML comments
30
+ const parsed = matter(raw);
31
+ const cleanedFrontmatter = matter.stringify(parsed.content, parsed.data);
32
+ await fs.writeFile(indexPath, cleanedFrontmatter);
33
+ }
34
+ // Remove lockfile if it exists and matches the released entry slug (or is general)
35
+ if (await fs.pathExists(lockfilePath)) {
36
+ try {
37
+ const lock = await fs.readJson(lockfilePath);
38
+ if (lock.slug === active.slug) {
39
+ await fs.remove(lockfilePath);
40
+ }
41
+ }
42
+ catch {
43
+ // corrupt lockfile, remove it anyway
44
+ await fs.remove(lockfilePath);
45
+ }
46
+ }
47
+ console.log(chalk.green(`Released entry: ${active.slug} (via ${active.source})`));
48
+ if (active.startedAt) {
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.`));
51
+ }
52
+ else {
53
+ console.log(chalk.gray(`Released at ${releasedAt}. You may now commit and build.`));
54
+ }
55
+ }
56
+ function formatElapsed(from, to) {
57
+ const totalSeconds = Math.floor((to.getTime() - from.getTime()) / 1000);
58
+ const h = Math.floor(totalSeconds / 3600);
59
+ const m = Math.floor((totalSeconds % 3600) / 60);
60
+ const s = totalSeconds % 60;
61
+ if (h > 0)
62
+ return `${h}h ${m}m`;
63
+ if (m > 0)
64
+ return `${m}m ${s}s`;
65
+ return `${s}s`;
66
+ }
@@ -0,0 +1 @@
1
+ export declare function start(id: string): Promise<void>;
@@ -0,0 +1,87 @@
1
+ import fs from 'fs-extra';
2
+ import { join } from 'node:path';
3
+ import { execSync } from 'node:child_process';
4
+ import chalk from 'chalk';
5
+ import matter from 'gray-matter';
6
+ import { getConfig } from '../lib/config.js';
7
+ import { getActiveEntry } from '../lib/session.js';
8
+ import { LOCKFILE_NAME } from '../lib/lockfile.js';
9
+ function getGitUsername() {
10
+ try {
11
+ return (execSync('git config user.name', { stdio: ['ignore', 'pipe', 'ignore'] })
12
+ .toString()
13
+ .trim() || undefined);
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ export async function start(id) {
20
+ const cwd = process.cwd();
21
+ const lockfilePath = join(cwd, LOCKFILE_NAME);
22
+ // 1. Enforce single active entry
23
+ if (await fs.pathExists(lockfilePath)) {
24
+ const existing = await fs.readJson(lockfilePath);
25
+ console.error(chalk.red(`Error: Entry '${existing.slug}' is already active.`));
26
+ console.error(chalk.red(`Run 'logbook release' before starting a new entry.`));
27
+ process.exit(1);
28
+ }
29
+ // 2. Resolve folder by ID prefix (e.g. "LB-5" → "LB-5-jira-support")
30
+ const config = getConfig();
31
+ // If a bare number is given (e.g. "19"), auto-prepend the configured jiraPrefix
32
+ const resolvedId = /^\d+$/.test(id) && config.jiraPrefix ? `${config.jiraPrefix}-${id}` : id;
33
+ const logbookDir = join(cwd, config.logbookDir);
34
+ const allDirs = await fs.readdir(logbookDir);
35
+ const matchingDir = allDirs.find((d) => {
36
+ const prefix = d.split('-').slice(0, resolvedId.split('-').length).join('-');
37
+ return prefix.toLowerCase() === resolvedId.toLowerCase();
38
+ });
39
+ if (!matchingDir) {
40
+ console.error(chalk.red(`Error: No entry found matching ID '${resolvedId}' in ${config.logbookDir}/.`));
41
+ console.error(chalk.red(`Run 'logbook new <id> <slug>' to create it first.`));
42
+ process.exit(1);
43
+ }
44
+ const slug = matchingDir;
45
+ const entryDir = join(logbookDir, slug);
46
+ // Warn if branch-resolved entry is different
47
+ const activeFromBranch = await getActiveEntry({}, cwd);
48
+ if (activeFromBranch && activeFromBranch.source === 'branch' && activeFromBranch.slug !== slug) {
49
+ console.warn(chalk.yellow(`Warning: Your current Git branch resolves to active entry '${activeFromBranch.slug}', but you are manually starting '${slug}'.`));
50
+ }
51
+ // 3. Detect git prompter
52
+ const prompter = getGitUsername();
53
+ // 4. Write lockfile — store the resolved folder name
54
+ const lockData = {
55
+ slug,
56
+ startedAt: new Date().toISOString(),
57
+ ...(prompter ? { prompter } : {}),
58
+ };
59
+ await fs.writeJson(lockfilePath, lockData, { spaces: 2 });
60
+ // 5. Inject `dateStart` and `prompter` into index.md frontmatter
61
+ // We do a raw string injection rather than matter.stringify() to avoid
62
+ // YAML re-serialization corrupting bracket placeholders like [YOUR_NAME].
63
+ const indexPath = join(entryDir, 'index.md');
64
+ if (await fs.pathExists(indexPath)) {
65
+ let raw = await fs.readFile(indexPath, 'utf8');
66
+ const parsed = matter(raw);
67
+ // Inject dateStart only if the placeholder exists (preserve existing dateStart on re-start)
68
+ // Note: gray-matter parses [DATE_START] as a YAML array, so we check the raw string directly.
69
+ if (raw.includes('dateStart: [DATE_START]')) {
70
+ raw = raw.replace('dateStart: [DATE_START]', `dateStart: ${lockData.startedAt}`);
71
+ }
72
+ // Inject prompter if available and not already set
73
+ if (prompter && !parsed.data.prompter) {
74
+ raw = raw.replace(/^(---\n[\s\S]*?)(^---$)/m, (_, frontmatter, closing) => {
75
+ if (frontmatter.includes('prompter:'))
76
+ return _;
77
+ return `${frontmatter}prompter: '${prompter.replace(/'/g, "''")}'\n${closing}`;
78
+ });
79
+ }
80
+ await fs.writeFile(indexPath, raw);
81
+ }
82
+ console.log(chalk.green(`Started entry: ${slug}`));
83
+ if (prompter) {
84
+ console.log(chalk.blue(`Prompter set to: ${prompter}`));
85
+ }
86
+ console.log(chalk.gray(`Lockfile written to ${LOCKFILE_NAME}. Run 'logbook release' when done.`));
87
+ }
@@ -0,0 +1 @@
1
+ export declare function steer(): Promise<void>;
@@ -0,0 +1,22 @@
1
+ import fs from 'fs-extra';
2
+ import { join } from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { getTemplatesDir } from '../lib/migrations.js';
5
+ export async function steer() {
6
+ const steerPath = join(getTemplatesDir(), 'steer.txt');
7
+ if (!fs.existsSync(steerPath)) {
8
+ console.error(chalk.red('Error: Steering template not found.'));
9
+ return;
10
+ }
11
+ const protocol = fs.readFileSync(steerPath, 'utf8');
12
+ // Add some color to headers and mandatory parts
13
+ const formatted = protocol
14
+ .replace(/^PROJECT LOGBOOK:.+$/m, (m) => chalk.bold.cyan(m))
15
+ .replace(/^Phase \d:.+$/gm, (m) => chalk.bold(m))
16
+ .replace(/logbook \w+/g, (m) => chalk.green(m))
17
+ .replace(/\w+\.md/g, (m) => chalk.blue(m))
18
+ .replace(/Mandatory:/g, chalk.yellow('Mandatory:'))
19
+ .replace(/Storytelling Mode:/g, chalk.yellow('Storytelling Mode:'))
20
+ .replace(/Quality Gate:/g, chalk.red('Quality Gate:'));
21
+ console.log(formatted);
22
+ }
@@ -0,0 +1 @@
1
+ export declare function upgrade(): Promise<void>;
@@ -0,0 +1,23 @@
1
+ import fs from 'fs-extra';
2
+ import chalk from 'chalk';
3
+ import { getProjectStatus } from '../lib/migrations.js';
4
+ export async function upgrade() {
5
+ const projectStatus = await getProjectStatus();
6
+ let updatedCount = 0;
7
+ for (const file of projectStatus) {
8
+ if (file.status === 'UP_TO_DATE') {
9
+ console.log(chalk.blue(`[UP TO DATE] ${file.name}`));
10
+ }
11
+ else {
12
+ await fs.writeFile(file.localPath, file.templateContent);
13
+ console.log(chalk.green(`[${file.status === 'MISSING' ? 'CREATED' : 'UPDATED'}] ${file.name}`));
14
+ updatedCount++;
15
+ }
16
+ }
17
+ if (updatedCount === 0) {
18
+ console.log(chalk.blue('\nEverything is already up to date.'));
19
+ }
20
+ else {
21
+ console.log(chalk.green(`\nSuccessfully upgraded ${updatedCount} files.`));
22
+ }
23
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { readFileSync } from 'node:fs';
4
+ import { join, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+ const pkgPath = join(__dirname, '..', 'package.json');
9
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
10
+ const program = new Command();
11
+ program.name('logbook').description('A CLI tool for managing project logbooks.').version(pkg.version);
12
+ program
13
+ .command('init')
14
+ .description('Initialize .project-logbook config and /logbook folder')
15
+ .action(async () => {
16
+ const { init } = await import('./commands/init.js');
17
+ await init();
18
+ });
19
+ program
20
+ .command('new [id] [slug]')
21
+ .description('Create a new logbook entry (with optional id and slug, or use interactive mode)')
22
+ .action(async (id, slug) => {
23
+ const { createNewEntry } = await import('./commands/new.js');
24
+ await createNewEntry(id, slug);
25
+ });
26
+ program
27
+ .command('lint')
28
+ .description('Verify the 3-file structure and frontmatter')
29
+ .action(async () => {
30
+ const { lint } = await import('./commands/lint.js');
31
+ await lint();
32
+ });
33
+ program
34
+ .command('build')
35
+ .description('Compile the logbook into a static website')
36
+ .action(async () => {
37
+ const { build } = await import('./commands/build.js');
38
+ await build();
39
+ });
40
+ program
41
+ .command('preview')
42
+ .description('Build and open the logbook in the browser')
43
+ .action(async () => {
44
+ const { preview } = await import('./commands/preview.js');
45
+ await preview();
46
+ });
47
+ program
48
+ .command('upgrade')
49
+ .description('Upgrade project files (like CONTRIBUTING.md) to the latest templates')
50
+ .action(async () => {
51
+ const { upgrade } = await import('./commands/upgrade.js');
52
+ await upgrade();
53
+ });
54
+ program
55
+ .command('start <id>')
56
+ .description('Mark a logbook entry as active by ID, e.g. LB-5 (writes .logbook-active lockfile)')
57
+ .action(async (id) => {
58
+ const { start } = await import('./commands/start.js');
59
+ await start(id);
60
+ });
61
+ program
62
+ .command('release')
63
+ .description('Release the active logbook entry (removes .logbook-active lockfile)')
64
+ .option('-i, --id <id>', 'Target logbook entry ID to release')
65
+ .action(async (options) => {
66
+ const { release } = await import('./commands/release.js');
67
+ await release(options);
68
+ });
69
+ program
70
+ .command('list')
71
+ .description('List all logbook entries with their status')
72
+ .option('-a, --all', 'Show all entries (default is latest 20)')
73
+ .action(async (options) => {
74
+ const { list } = await import('./commands/list.js');
75
+ await list(options);
76
+ });
77
+ program
78
+ .command('log <message...>')
79
+ .description('Append a timestamped log entry to the active log.md (e.g. logbook log "Did a thing")')
80
+ .option('-i, --id <id>', 'Override active logbook entry ID')
81
+ .action(async (messages, options) => {
82
+ const { log } = await import('./commands/log.js');
83
+ await log(messages, options);
84
+ });
85
+ program
86
+ .command('steer')
87
+ .description('Output the Agentic Protocol for AI agents')
88
+ .action(async () => {
89
+ const { steer } = await import('./commands/steer.js');
90
+ await steer();
91
+ });
92
+ program.parse();
@@ -0,0 +1 @@
1
+ export declare function getAboutContent(): string;
@@ -0,0 +1,7 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ export function getAboutContent() {
6
+ return readFileSync(join(__dirname, '../templates/ABOUT.md'), 'utf8');
7
+ }
@@ -0,0 +1,15 @@
1
+ import type { LogbookConfig } from './config.js';
2
+ import type { TimelineEntry } from './template-types.js';
3
+ export declare function mdToHtml(md: string): Promise<string>;
4
+ export declare function buildProjectMdFiles(projectRoot: string, outputDir: string, excludeDirs: string[], ctx: {
5
+ config: LogbookConfig;
6
+ version: string;
7
+ buildTime: string;
8
+ }): Promise<void>;
9
+ export declare function renderPost(data: TimelineEntry, i: number, allEntries: TimelineEntry[], ctx: {
10
+ logbookDir: string;
11
+ outputDir: string;
12
+ config: LogbookConfig;
13
+ version: string;
14
+ buildTime: string;
15
+ }): Promise<void>;
@@ -0,0 +1,171 @@
1
+ import fs from 'fs-extra';
2
+ import { join } from 'node:path';
3
+ import { unified } from 'unified';
4
+ import remarkParse from 'remark-parse';
5
+ import remarkRehype from 'remark-rehype';
6
+ import rehypeSlug from 'rehype-slug';
7
+ import rehypeFormat from 'rehype-format';
8
+ import rehypeStringify from 'rehype-stringify';
9
+ import matter from 'gray-matter';
10
+ import { layout, postTemplate } from './templates.js';
11
+ import { getGitCommits } from './git-helpers.js';
12
+ import { mdToHtmlWithImages, extractImagePathsFromMarkdown, copyImages } from './image-helpers.js';
13
+ /** Rehype plugin: rewrite relative .md links to /index.html equivalents. */
14
+ const rehypeRewriteMdLinks = () => {
15
+ return (tree) => {
16
+ visitLinks(tree, (node) => {
17
+ const href = node.properties?.href;
18
+ if (typeof href !== 'string')
19
+ return;
20
+ if (/^[a-z][a-z\d+\-.]*:/i.test(href) || href.startsWith('#'))
21
+ return;
22
+ const mdMatch = href.match(/^(.*?)\.md(#.*)?$/i);
23
+ if (!mdMatch)
24
+ return;
25
+ const base = mdMatch[1];
26
+ const fragment = mdMatch[2] ?? '';
27
+ node.properties.href = `${base}/index.html${fragment}`;
28
+ });
29
+ };
30
+ };
31
+ function visitLinks(node, visitor) {
32
+ for (const child of node.children) {
33
+ if (child.type === 'element') {
34
+ if (child.tagName === 'a')
35
+ visitor(child);
36
+ visitLinks(child, visitor);
37
+ }
38
+ }
39
+ }
40
+ const processor = unified()
41
+ .use(remarkParse)
42
+ .use(remarkRehype)
43
+ .use(rehypeSlug)
44
+ .use(rehypeRewriteMdLinks)
45
+ .use(rehypeFormat)
46
+ .use(rehypeStringify);
47
+ export async function mdToHtml(md) {
48
+ const result = await processor.process(md);
49
+ return result.toString();
50
+ }
51
+ export async function buildProjectMdFiles(projectRoot, outputDir, excludeDirs, ctx) {
52
+ const entries = await fs.readdir(projectRoot, { withFileTypes: true });
53
+ for (const entry of entries) {
54
+ if (!entry.isFile() || !entry.name.endsWith('.md'))
55
+ continue;
56
+ if (excludeDirs.includes(entry.name))
57
+ continue;
58
+ const sourcePath = join(projectRoot, entry.name);
59
+ const stem = entry.name.replace(/\.md$/i, '');
60
+ const outDir = join(outputDir, stem);
61
+ const mdContent = await fs.readFile(sourcePath, 'utf8');
62
+ const bodyHtml = await mdToHtml(mdContent);
63
+ const buildMeta = `Generated by ${ctx.config.projectName} v${ctx.version} • ${ctx.buildTime}`;
64
+ const pageHtml = layout({
65
+ title: stem,
66
+ projectName: ctx.config.projectName,
67
+ basePath: '../',
68
+ buildMeta,
69
+ header: `<header><h1>${stem}</h1></header>`,
70
+ content: bodyHtml,
71
+ });
72
+ await fs.mkdirp(outDir);
73
+ await fs.writeFile(join(outDir, 'index.html'), pageHtml);
74
+ }
75
+ }
76
+ async function renderLinkedMdFiles(markdownSources, entryPath, entryOutputDir, ctx) {
77
+ const linkRegex = /\[.*?\]\(([^)]+\.md(?:#[^)]*)?)?\)/gi;
78
+ const seen = new Set();
79
+ for (const source of markdownSources) {
80
+ let match;
81
+ linkRegex.lastIndex = 0;
82
+ while ((match = linkRegex.exec(source)) !== null) {
83
+ const raw = match[1];
84
+ if (!raw)
85
+ continue;
86
+ const href = raw.split('#')[0];
87
+ if (!href)
88
+ continue;
89
+ if (/^[a-z][a-z\d+\-.]*:/i.test(href))
90
+ continue;
91
+ if (seen.has(href))
92
+ continue;
93
+ seen.add(href);
94
+ let sourcePath = join(entryPath, href);
95
+ if (!(await fs.pathExists(sourcePath)))
96
+ sourcePath = join(process.cwd(), href);
97
+ if (!(await fs.pathExists(sourcePath))) {
98
+ console.warn(` Linked file not found, skipping: ${href}`);
99
+ continue;
100
+ }
101
+ const mdContent = await fs.readFile(sourcePath, 'utf8');
102
+ const bodyHtml = await mdToHtml(mdContent);
103
+ const title = href.replace(/\.md$/i, '');
104
+ const buildMeta = `Generated by ${ctx.config.projectName} v${ctx.version} • ${ctx.buildTime}`;
105
+ const pageHtml = layout({
106
+ title,
107
+ projectName: ctx.config.projectName,
108
+ basePath: '../',
109
+ buildMeta,
110
+ header: `<header><h1>${title}</h1></header>`,
111
+ content: bodyHtml,
112
+ });
113
+ const stem = href.replace(/\.md$/i, '').replace(/^\.\//, '');
114
+ const outDir = join(entryOutputDir, stem);
115
+ await fs.mkdirp(outDir);
116
+ await fs.writeFile(join(outDir, 'index.html'), pageHtml);
117
+ }
118
+ }
119
+ }
120
+ export async function renderPost(data, i, allEntries, ctx) {
121
+ const entryPath = join(ctx.logbookDir, data.slug);
122
+ const ticketPath = join(entryPath, 'ticket.md');
123
+ const logPath = join(entryPath, 'log.md');
124
+ const { content, content: summaryContent } = matter(await fs.readFile(join(entryPath, 'index.md'), 'utf8'));
125
+ const ticketRaw = (await fs.pathExists(ticketPath)) ? await fs.readFile(ticketPath, 'utf8') : '';
126
+ const logRaw = (await fs.pathExists(logPath)) ? await fs.readFile(logPath, 'utf8') : '';
127
+ // Process with image collection for index.md content
128
+ const { html: storyHtml, imagePaths: storyImages } = await mdToHtmlWithImages(content.replace(/^#\s+.+$/m, '').trim());
129
+ // Collect images from all markdown sources
130
+ const ticketImages = extractImagePathsFromMarkdown(ticketRaw);
131
+ const logImages = extractImagePathsFromMarkdown(logRaw);
132
+ const allImagePaths = [...storyImages, ...ticketImages, ...logImages];
133
+ const commits = await getGitCommits(entryPath);
134
+ const jiraUrl = ctx.config.jiraBaseUrl && data.ticket ? `${ctx.config.jiraBaseUrl}${data.ticket}` : undefined;
135
+ const prev = i > 0 ? allEntries[i - 1] : null;
136
+ const next = i < allEntries.length - 1 ? allEntries[i + 1] : null;
137
+ const postContent = postTemplate({
138
+ ...data,
139
+ title: data.title ?? data.slug,
140
+ harness: data.harness ?? '',
141
+ content: storyHtml,
142
+ ticketHtml: ticketRaw ? await mdToHtml(ticketRaw) : '',
143
+ logHtml: logRaw ? await mdToHtml(logRaw) : '',
144
+ commits,
145
+ version: ctx.version,
146
+ jiraUrl,
147
+ prevEntry: prev ? { slug: prev.slug, title: prev.title ?? '', ticket: prev.ticket } : null,
148
+ nextEntry: next ? { slug: next.slug, title: next.title ?? '', ticket: next.ticket } : null,
149
+ });
150
+ const descriptionRaw = typeof data.summary === 'string' ? data.summary.replace(/[*#`]/g, '').trim() : '';
151
+ const buildMeta = `Generated by ${ctx.config.projectName} v${ctx.version} • ${ctx.buildTime}`;
152
+ const postHtml = layout({
153
+ title: data.title ?? data.slug,
154
+ projectName: ctx.config.projectName,
155
+ basePath: '../',
156
+ description: descriptionRaw.substring(0, 160),
157
+ bodySlug: data.slug,
158
+ buildMeta,
159
+ header: postContent.split('</header>')[0] + '</header>',
160
+ content: postContent.split('</header>')[1],
161
+ });
162
+ const entryOutputDir = join(ctx.outputDir, data.slug);
163
+ await fs.mkdirp(entryOutputDir);
164
+ await fs.writeFile(join(entryOutputDir, 'index.html'), postHtml);
165
+ // Copy images to output directory
166
+ if (allImagePaths.length > 0) {
167
+ const imagesOutputDir = join(entryOutputDir, 'images');
168
+ await copyImages(entryPath, imagesOutputDir, allImagePaths);
169
+ }
170
+ await renderLinkedMdFiles([summaryContent, ticketRaw, logRaw], entryPath, entryOutputDir, ctx);
171
+ }