@vanshbhardwaj/worklog 1.0.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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +28 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +19 -0
- package/.github/workflows/release.yml +63 -0
- package/.github/workflows/validate.yml +103 -0
- package/.prettierrc +8 -0
- package/ARCHITECTURE.md +82 -0
- package/CODE_OF_CONDUCT.md +65 -0
- package/CONTRIBUTING.md +74 -0
- package/INSTALLATION.md +98 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/SECURITY.md +18 -0
- package/benchmarks/benchmark.ts +149 -0
- package/benchmarks/vitest.bench.config.ts +9 -0
- package/dist/database/migrations/001_initial.sql +60 -0
- package/dist/database/migrations/002_relational_tables.sql +22 -0
- package/dist/index.cjs +31007 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1873 -0
- package/dist/index.js.map +1 -0
- package/eslint.config.js +38 -0
- package/package.json +49 -0
- package/pnpm-workspace.yaml +6 -0
- package/scripts/build-bin.js +75 -0
- package/sea-config.json +5 -0
- package/src/cli/commands/add.ts +176 -0
- package/src/cli/commands/continue.ts +80 -0
- package/src/cli/commands/dashboard.ts +30 -0
- package/src/cli/commands/export.ts +72 -0
- package/src/cli/commands/init.ts +52 -0
- package/src/cli/commands/list.ts +33 -0
- package/src/cli/commands/search.ts +33 -0
- package/src/cli/commands/show.ts +42 -0
- package/src/cli/commands/stats.ts +23 -0
- package/src/cli/commands/today.ts +27 -0
- package/src/cli/commands/week.ts +93 -0
- package/src/cli/index.ts +294 -0
- package/src/cli/ui.ts +323 -0
- package/src/core/config.ts +69 -0
- package/src/core/discovery.ts +38 -0
- package/src/core/logger.ts +53 -0
- package/src/database/connection.ts +95 -0
- package/src/database/migration-data.ts +71 -0
- package/src/database/migrations/001_initial.sql +60 -0
- package/src/database/migrations/002_relational_tables.sql +22 -0
- package/src/database/migrations.ts +65 -0
- package/src/errors/index.ts +51 -0
- package/src/exporters/csv.ts +59 -0
- package/src/exporters/json.ts +8 -0
- package/src/exporters/markdown.ts +71 -0
- package/src/models/work-unit.ts +38 -0
- package/src/repositories/work-unit.ts +466 -0
- package/src/services/work-unit.ts +131 -0
- package/src/tests/cli/__snapshots__/cli-productivity.test.ts.snap +37 -0
- package/src/tests/cli/__snapshots__/cli.test.ts.snap +20 -0
- package/src/tests/cli/cli-productivity.test.ts +167 -0
- package/src/tests/cli/cli.test.ts +171 -0
- package/src/tests/core/config.test.ts +53 -0
- package/src/tests/core/discovery.test.ts +46 -0
- package/src/tests/database/migrations.test.ts +75 -0
- package/src/tests/repositories/work-unit.test.ts +243 -0
- package/src/tests/services/work-unit.test.ts +87 -0
- package/src/validators/work-unit.ts +63 -0
- package/tsconfig.json +16 -0
- package/tsup.config.ts +50 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { NotInitializedError } from '../errors/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Searches upward from the starting directory to find the `.worklog` directory.
|
|
7
|
+
* Returns the absolute path of the directory containing `.worklog`.
|
|
8
|
+
* Returns null if not found.
|
|
9
|
+
*/
|
|
10
|
+
export function findWorkspaceRoot(startDir: string = process.cwd()): string | null {
|
|
11
|
+
let current = path.resolve(startDir);
|
|
12
|
+
const rootPath = path.parse(current).root;
|
|
13
|
+
|
|
14
|
+
while (true) {
|
|
15
|
+
const candidate = path.join(current, '.worklog');
|
|
16
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
|
17
|
+
return current;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (current === rootPath) {
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
current = path.dirname(current);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Finds the workspace root or throws a NotInitializedError.
|
|
31
|
+
*/
|
|
32
|
+
export function getWorkspaceRoot(startDir: string = process.cwd()): string {
|
|
33
|
+
const root = findWorkspaceRoot(startDir);
|
|
34
|
+
if (!root) {
|
|
35
|
+
throw new NotInitializedError();
|
|
36
|
+
}
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
|
|
5
|
+
let loggerInstance: pino.Logger | null = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns the current logger instance or a silent fallback if not initialized.
|
|
9
|
+
*/
|
|
10
|
+
export function getLogger(): pino.Logger {
|
|
11
|
+
if (loggerInstance) {
|
|
12
|
+
return loggerInstance;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Silent fallback before repository discovery
|
|
16
|
+
loggerInstance = pino({
|
|
17
|
+
level: 'silent',
|
|
18
|
+
});
|
|
19
|
+
return loggerInstance;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Initializes the logger to write to the `.worklog/worklog.log` file in the workspace.
|
|
24
|
+
*/
|
|
25
|
+
export function initLogger(workspaceRoot: string): pino.Logger {
|
|
26
|
+
const logDir = path.join(workspaceRoot, '.worklog');
|
|
27
|
+
if (!fs.existsSync(logDir)) {
|
|
28
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const logFile = path.join(logDir, 'worklog.log');
|
|
32
|
+
|
|
33
|
+
loggerInstance = pino(
|
|
34
|
+
{
|
|
35
|
+
level: process.env.WORKLOG_LOG_LEVEL || 'info',
|
|
36
|
+
base: undefined, // remove pid/hostname for clean local logs
|
|
37
|
+
timestamp: () => `,"time":"${new Date().toISOString()}"`,
|
|
38
|
+
},
|
|
39
|
+
pino.destination({
|
|
40
|
+
dest: logFile,
|
|
41
|
+
sync: true, // synchronous write to guarantee flush before CLI exit
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return loggerInstance;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const logger = {
|
|
49
|
+
info: (obj: any, msg?: string, ...args: any[]) => getLogger().info(obj, msg, ...args),
|
|
50
|
+
error: (obj: any, msg?: string, ...args: any[]) => getLogger().error(obj, msg, ...args),
|
|
51
|
+
debug: (obj: any, msg?: string, ...args: any[]) => getLogger().debug(obj, msg, ...args),
|
|
52
|
+
warn: (obj: any, msg?: string, ...args: any[]) => getLogger().warn(obj, msg, ...args),
|
|
53
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import { DatabaseError } from '../errors/index.js';
|
|
5
|
+
import { logger } from '../core/logger.js';
|
|
6
|
+
import type DatabaseType from 'better-sqlite3';
|
|
7
|
+
|
|
8
|
+
// Construct dynamic disk require to bypass Node SEA embedded require constraints
|
|
9
|
+
let DatabaseConstructor: any;
|
|
10
|
+
try {
|
|
11
|
+
const contextPath =
|
|
12
|
+
typeof import.meta !== 'undefined' && import.meta.url
|
|
13
|
+
? import.meta.url
|
|
14
|
+
: typeof __filename !== 'undefined'
|
|
15
|
+
? __filename
|
|
16
|
+
: process.cwd() + '/package.json';
|
|
17
|
+
|
|
18
|
+
const requireFromDisk = createRequire(contextPath);
|
|
19
|
+
DatabaseConstructor = requireFromDisk('better-sqlite3');
|
|
20
|
+
} catch {
|
|
21
|
+
try {
|
|
22
|
+
// Fallback: resolve from the current working directory
|
|
23
|
+
const requireFromDisk = createRequire(process.cwd() + '/package.json');
|
|
24
|
+
DatabaseConstructor = requireFromDisk('better-sqlite3');
|
|
25
|
+
} catch {
|
|
26
|
+
// Fallback: resolve from the directory of the executable
|
|
27
|
+
const execDir = path.dirname(process.execPath);
|
|
28
|
+
const requireFromDisk = createRequire(execDir + '/package.json');
|
|
29
|
+
DatabaseConstructor = requireFromDisk('better-sqlite3');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let dbInstance: DatabaseType.Database | null = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Connects to the SQLite database located at `<workspaceRoot>/.worklog/worklog.db`.
|
|
37
|
+
* Optimizes SQLite performance parameters for local CLI use.
|
|
38
|
+
*/
|
|
39
|
+
export function connectDatabase(workspaceRoot: string): DatabaseType.Database {
|
|
40
|
+
if (dbInstance) {
|
|
41
|
+
return dbInstance;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const dbDir = path.join(workspaceRoot, '.worklog');
|
|
45
|
+
if (!fs.existsSync(dbDir)) {
|
|
46
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const dbPath = path.join(dbDir, 'worklog.db');
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const db = new DatabaseConstructor(dbPath);
|
|
53
|
+
|
|
54
|
+
// Performance optimizations for local development CLI
|
|
55
|
+
db.pragma('foreign_keys = ON');
|
|
56
|
+
db.pragma('journal_mode = WAL');
|
|
57
|
+
db.pragma('synchronous = NORMAL');
|
|
58
|
+
db.pragma('temp_store = MEMORY');
|
|
59
|
+
|
|
60
|
+
dbInstance = db;
|
|
61
|
+
logger.debug({ dbPath }, 'Connected to SQLite database successfully');
|
|
62
|
+
return db;
|
|
63
|
+
} catch (error: any) {
|
|
64
|
+
logger.error({ error, dbPath }, 'Failed to connect to SQLite database');
|
|
65
|
+
throw new DatabaseError('Failed to connect to database', error.message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Returns the currently active database instance.
|
|
71
|
+
* Throws if the database has not been initialized.
|
|
72
|
+
*/
|
|
73
|
+
export function getDatabase(): DatabaseType.Database {
|
|
74
|
+
const db = dbInstance;
|
|
75
|
+
if (!db) {
|
|
76
|
+
throw new DatabaseError('Database is not initialized. Call connectDatabase first.');
|
|
77
|
+
}
|
|
78
|
+
return db;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Closes the database connection cleanly.
|
|
83
|
+
*/
|
|
84
|
+
export function closeDatabase(): void {
|
|
85
|
+
if (dbInstance) {
|
|
86
|
+
try {
|
|
87
|
+
dbInstance.close();
|
|
88
|
+
logger.debug('Database connection closed');
|
|
89
|
+
} catch (error: any) {
|
|
90
|
+
logger.error({ error }, 'Error closing database connection');
|
|
91
|
+
} finally {
|
|
92
|
+
dbInstance = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export const MIGRATIONS: Record<string, string> = {
|
|
2
|
+
'001_initial.sql': `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS work_units (
|
|
4
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
5
|
+
title TEXT NOT NULL,
|
|
6
|
+
type TEXT NOT NULL,
|
|
7
|
+
status TEXT NOT NULL,
|
|
8
|
+
priority TEXT,
|
|
9
|
+
module TEXT,
|
|
10
|
+
description TEXT,
|
|
11
|
+
next_step TEXT,
|
|
12
|
+
decision TEXT,
|
|
13
|
+
blocker TEXT,
|
|
14
|
+
notes TEXT,
|
|
15
|
+
created_at TEXT NOT NULL,
|
|
16
|
+
updated_at TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
-- Full Text Search Index Table
|
|
20
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS work_units_fts USING fts5(
|
|
21
|
+
title,
|
|
22
|
+
description,
|
|
23
|
+
decision,
|
|
24
|
+
blocker,
|
|
25
|
+
notes,
|
|
26
|
+
content=work_units,
|
|
27
|
+
content_rowid=id
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
-- Auto-sync Insert Trigger
|
|
31
|
+
CREATE TRIGGER IF NOT EXISTS work_units_ai AFTER INSERT ON work_units BEGIN
|
|
32
|
+
INSERT INTO work_units_fts(rowid, title, description, decision, blocker, notes)
|
|
33
|
+
VALUES (new.id, new.title, new.description, new.decision, new.blocker, new.notes);
|
|
34
|
+
END;
|
|
35
|
+
|
|
36
|
+
-- Auto-sync Delete Trigger
|
|
37
|
+
CREATE TRIGGER IF NOT EXISTS work_units_ad AFTER DELETE ON work_units BEGIN
|
|
38
|
+
INSERT INTO work_units_fts(work_units_fts, rowid, title, description, decision, blocker, notes)
|
|
39
|
+
VALUES('delete', old.id, old.title, old.description, old.decision, old.blocker, old.notes);
|
|
40
|
+
END;
|
|
41
|
+
|
|
42
|
+
-- Auto-sync Update Trigger
|
|
43
|
+
CREATE TRIGGER IF NOT EXISTS work_units_au AFTER UPDATE ON work_units BEGIN
|
|
44
|
+
INSERT INTO work_units_fts(work_units_fts, rowid, title, description, decision, blocker, notes)
|
|
45
|
+
VALUES('delete', old.id, old.title, old.description, old.decision, old.blocker, old.notes);
|
|
46
|
+
INSERT INTO work_units_fts(rowid, title, description, decision, blocker, notes)
|
|
47
|
+
VALUES (new.id, new.title, new.description, new.decision, new.blocker, new.notes);
|
|
48
|
+
END;
|
|
49
|
+
`,
|
|
50
|
+
'002_relational_tables.sql': `
|
|
51
|
+
-- Junction table for tags
|
|
52
|
+
CREATE TABLE IF NOT EXISTS work_unit_tags (
|
|
53
|
+
work_unit_id INTEGER NOT NULL,
|
|
54
|
+
tag TEXT NOT NULL,
|
|
55
|
+
PRIMARY KEY (work_unit_id, tag),
|
|
56
|
+
FOREIGN KEY (work_unit_id) REFERENCES work_units(id) ON DELETE CASCADE
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
-- Index for tag filtering performance
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_work_unit_tags_tag ON work_unit_tags(tag);
|
|
61
|
+
|
|
62
|
+
-- Junction table for bidirectional relation mapping
|
|
63
|
+
CREATE TABLE IF NOT EXISTS work_unit_relations (
|
|
64
|
+
work_unit_id INTEGER NOT NULL,
|
|
65
|
+
related_work_unit_id INTEGER NOT NULL,
|
|
66
|
+
PRIMARY KEY (work_unit_id, related_work_unit_id),
|
|
67
|
+
FOREIGN KEY (work_unit_id) REFERENCES work_units(id) ON DELETE CASCADE,
|
|
68
|
+
FOREIGN KEY (related_work_unit_id) REFERENCES work_units(id) ON DELETE CASCADE
|
|
69
|
+
);
|
|
70
|
+
`,
|
|
71
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
-- Table to track database migrations
|
|
2
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
3
|
+
version TEXT PRIMARY KEY,
|
|
4
|
+
applied_at TEXT NOT NULL
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
-- Core work_units table
|
|
8
|
+
CREATE TABLE IF NOT EXISTS work_units (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
10
|
+
title TEXT NOT NULL,
|
|
11
|
+
type TEXT NOT NULL,
|
|
12
|
+
status TEXT NOT NULL,
|
|
13
|
+
priority TEXT,
|
|
14
|
+
module TEXT,
|
|
15
|
+
description TEXT,
|
|
16
|
+
next_step TEXT,
|
|
17
|
+
decision TEXT,
|
|
18
|
+
blocker TEXT,
|
|
19
|
+
tags TEXT, -- JSON array of strings
|
|
20
|
+
related_work_units TEXT, -- JSON array of integers
|
|
21
|
+
notes TEXT,
|
|
22
|
+
created_at TEXT NOT NULL,
|
|
23
|
+
updated_at TEXT NOT NULL
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
-- Performance indexes for status, type, and dates
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_work_units_status ON work_units(status);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_work_units_type ON work_units(type);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_work_units_created_at ON work_units(created_at);
|
|
30
|
+
|
|
31
|
+
-- SQLite FTS5 Search Index Table using work_units as external content
|
|
32
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS work_units_fts USING fts5(
|
|
33
|
+
title,
|
|
34
|
+
description,
|
|
35
|
+
decision,
|
|
36
|
+
blocker,
|
|
37
|
+
notes,
|
|
38
|
+
content='work_units',
|
|
39
|
+
content_rowid='id'
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
-- Trigger to sync insert events to FTS
|
|
43
|
+
CREATE TRIGGER IF NOT EXISTS work_units_ai AFTER INSERT ON work_units BEGIN
|
|
44
|
+
INSERT INTO work_units_fts(rowid, title, description, decision, blocker, notes)
|
|
45
|
+
VALUES (new.id, new.title, new.description, new.decision, new.blocker, new.notes);
|
|
46
|
+
END;
|
|
47
|
+
|
|
48
|
+
-- Trigger to sync delete events to FTS
|
|
49
|
+
CREATE TRIGGER IF NOT EXISTS work_units_ad AFTER DELETE ON work_units BEGIN
|
|
50
|
+
INSERT INTO work_units_fts(work_units_fts, rowid, title, description, decision, blocker, notes)
|
|
51
|
+
VALUES('delete', old.id, old.title, old.description, old.decision, old.blocker, old.notes);
|
|
52
|
+
END;
|
|
53
|
+
|
|
54
|
+
-- Trigger to sync update events to FTS
|
|
55
|
+
CREATE TRIGGER IF NOT EXISTS work_units_au AFTER UPDATE ON work_units BEGIN
|
|
56
|
+
INSERT INTO work_units_fts(work_units_fts, rowid, title, description, decision, blocker, notes)
|
|
57
|
+
VALUES('delete', old.id, old.title, old.description, old.decision, old.blocker, old.notes);
|
|
58
|
+
INSERT INTO work_units_fts(rowid, title, description, decision, blocker, notes)
|
|
59
|
+
VALUES (new.id, new.title, new.description, new.decision, new.blocker, new.notes);
|
|
60
|
+
END;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- Create work_unit_tags table for indexing and querying tags
|
|
2
|
+
CREATE TABLE IF NOT EXISTS work_unit_tags (
|
|
3
|
+
work_unit_id INTEGER NOT NULL,
|
|
4
|
+
tag TEXT NOT NULL,
|
|
5
|
+
PRIMARY KEY (work_unit_id, tag),
|
|
6
|
+
FOREIGN KEY (work_unit_id) REFERENCES work_units(id) ON DELETE CASCADE
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE INDEX IF NOT EXISTS idx_work_unit_tags_tag ON work_unit_tags(tag);
|
|
10
|
+
|
|
11
|
+
-- Create work_unit_relations table for modeling links between work units
|
|
12
|
+
CREATE TABLE IF NOT EXISTS work_unit_relations (
|
|
13
|
+
work_unit_id INTEGER NOT NULL,
|
|
14
|
+
related_work_unit_id INTEGER NOT NULL,
|
|
15
|
+
PRIMARY KEY (work_unit_id, related_work_unit_id),
|
|
16
|
+
FOREIGN KEY (work_unit_id) REFERENCES work_units(id) ON DELETE CASCADE,
|
|
17
|
+
FOREIGN KEY (related_work_unit_id) REFERENCES work_units(id) ON DELETE CASCADE
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
-- Drop unused JSON columns from the main work_units table
|
|
21
|
+
ALTER TABLE work_units DROP COLUMN tags;
|
|
22
|
+
ALTER TABLE work_units DROP COLUMN related_work_units;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { logger } from '../core/logger.js';
|
|
3
|
+
import { DatabaseError } from '../errors/index.js';
|
|
4
|
+
import { MIGRATIONS } from './migration-data.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Runs all pending migrations sequentially within a database transaction.
|
|
8
|
+
*/
|
|
9
|
+
export function runMigrations(db: Database.Database): void {
|
|
10
|
+
logger.debug('Starting database migrations run');
|
|
11
|
+
|
|
12
|
+
// Step 1: Ensure the schema_migrations table exists
|
|
13
|
+
db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
15
|
+
version TEXT PRIMARY KEY,
|
|
16
|
+
applied_at TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
`);
|
|
19
|
+
|
|
20
|
+
// Step 2: Get sorted migration keys
|
|
21
|
+
const files = Object.keys(MIGRATIONS).sort();
|
|
22
|
+
|
|
23
|
+
if (files.length === 0) {
|
|
24
|
+
logger.debug('No migration definitions found');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Step 3: Get already applied migrations
|
|
29
|
+
const stmt = db.prepare('SELECT version FROM schema_migrations');
|
|
30
|
+
const applied: string[] = stmt.all().map((row: any) => row.version);
|
|
31
|
+
|
|
32
|
+
// Step 4: Apply outstanding migrations in order
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
if (applied.includes(file)) {
|
|
35
|
+
logger.debug({ migration: file }, 'Migration already applied, skipping');
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
logger.info({ migration: file }, `Applying database migration: ${file}`);
|
|
40
|
+
const sql = MIGRATIONS[file];
|
|
41
|
+
|
|
42
|
+
// Run migrations inside a transaction
|
|
43
|
+
const runMigrationTx = db.transaction(() => {
|
|
44
|
+
db.exec(sql);
|
|
45
|
+
const insertStmt = db.prepare(
|
|
46
|
+
'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)',
|
|
47
|
+
);
|
|
48
|
+
insertStmt.run(file, new Date().toISOString());
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
runMigrationTx();
|
|
53
|
+
logger.info({ migration: file }, `Migration ${file} applied successfully`);
|
|
54
|
+
} catch (error: any) {
|
|
55
|
+
logger.error({ error, migration: file }, `Failed to apply migration: ${file}`);
|
|
56
|
+
throw new DatabaseError(`Failed to apply migration ${file}`, error.message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
logger.debug('Database migrations completed');
|
|
61
|
+
}
|
|
62
|
+
export function getMigrationsDir(): string {
|
|
63
|
+
// Provided for backward compatibility in tests
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export class WorkLogDomainError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
message: string,
|
|
4
|
+
public readonly code: string,
|
|
5
|
+
public readonly actionRequired?: string,
|
|
6
|
+
) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = this.constructor.name;
|
|
9
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class NotInitializedError extends WorkLogDomainError {
|
|
14
|
+
constructor() {
|
|
15
|
+
super(
|
|
16
|
+
'Not a WorkLog repository (or any of the parent directories): .worklog',
|
|
17
|
+
'NOT_INITIALIZED',
|
|
18
|
+
"Run 'worklog init' or 'worklog a' to initialize a new repository.",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class DatabaseError extends WorkLogDomainError {
|
|
24
|
+
constructor(message: string, details?: string) {
|
|
25
|
+
super(
|
|
26
|
+
`Database error: ${message}${details ? ` (${details})` : ''}`,
|
|
27
|
+
'DATABASE_ERROR',
|
|
28
|
+
'Please check your database integrity or log files.',
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ValidationError extends WorkLogDomainError {
|
|
34
|
+
constructor(message: string) {
|
|
35
|
+
super(
|
|
36
|
+
`Validation error: ${message}`,
|
|
37
|
+
'VALIDATION_ERROR',
|
|
38
|
+
'Please provide valid inputs conforming to the schema.',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ConfigError extends WorkLogDomainError {
|
|
44
|
+
constructor(message: string) {
|
|
45
|
+
super(
|
|
46
|
+
`Configuration error: ${message}`,
|
|
47
|
+
'CONFIG_ERROR',
|
|
48
|
+
'Please check if .worklog/config.json is well-formed.',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { WorkUnit } from '../models/work-unit.js';
|
|
2
|
+
|
|
3
|
+
function escapeCSV(val: any): string {
|
|
4
|
+
if (val === null || val === undefined) return '';
|
|
5
|
+
const str = String(val);
|
|
6
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
|
7
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
8
|
+
}
|
|
9
|
+
return str;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Converts WorkUnits to a standard CSV string.
|
|
14
|
+
*/
|
|
15
|
+
export function exportToCSV(units: WorkUnit | WorkUnit[]): string {
|
|
16
|
+
const list = Array.isArray(units) ? units : [units];
|
|
17
|
+
const headers = [
|
|
18
|
+
'ID',
|
|
19
|
+
'Title',
|
|
20
|
+
'Type',
|
|
21
|
+
'Status',
|
|
22
|
+
'Priority',
|
|
23
|
+
'Module',
|
|
24
|
+
'Description',
|
|
25
|
+
'Next Step',
|
|
26
|
+
'Decision',
|
|
27
|
+
'Blocker',
|
|
28
|
+
'Tags',
|
|
29
|
+
'Related Work Units',
|
|
30
|
+
'Notes',
|
|
31
|
+
'Created At',
|
|
32
|
+
'Updated At',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const lines: string[] = [headers.join(',')];
|
|
36
|
+
|
|
37
|
+
for (const u of list) {
|
|
38
|
+
const row = [
|
|
39
|
+
u.id,
|
|
40
|
+
escapeCSV(u.title),
|
|
41
|
+
escapeCSV(u.type),
|
|
42
|
+
escapeCSV(u.status),
|
|
43
|
+
escapeCSV(u.priority),
|
|
44
|
+
escapeCSV(u.module),
|
|
45
|
+
escapeCSV(u.description),
|
|
46
|
+
escapeCSV(u.nextStep),
|
|
47
|
+
escapeCSV(u.decision),
|
|
48
|
+
escapeCSV(u.blocker),
|
|
49
|
+
escapeCSV(u.tags.join(';')),
|
|
50
|
+
escapeCSV(u.relatedWorkUnits.join(';')),
|
|
51
|
+
escapeCSV(u.notes),
|
|
52
|
+
escapeCSV(u.createdAt),
|
|
53
|
+
escapeCSV(u.updatedAt),
|
|
54
|
+
];
|
|
55
|
+
lines.push(row.join(','));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return lines.join('\n') + '\n';
|
|
59
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { WorkUnit } from '../models/work-unit.js';
|
|
2
|
+
|
|
3
|
+
function formatSingleMarkdown(u: WorkUnit): string {
|
|
4
|
+
const lines: string[] = [];
|
|
5
|
+
lines.push(`# [#${u.id}] ${u.title}\n`);
|
|
6
|
+
lines.push(`- **Type:** ${u.type}`);
|
|
7
|
+
lines.push(`- **Status:** ${u.status}`);
|
|
8
|
+
if (u.priority) lines.push(`- **Priority:** ${u.priority}`);
|
|
9
|
+
if (u.module) lines.push(`- **Module:** ${u.module}`);
|
|
10
|
+
if (u.tags && u.tags.length > 0) lines.push(`- **Tags:** ${u.tags.join(', ')}`);
|
|
11
|
+
lines.push(`- **Created:** ${u.createdAt}`);
|
|
12
|
+
lines.push(`- **Updated:** ${u.updatedAt}`);
|
|
13
|
+
lines.push('');
|
|
14
|
+
|
|
15
|
+
if (u.description) {
|
|
16
|
+
lines.push('## Description');
|
|
17
|
+
lines.push(u.description);
|
|
18
|
+
lines.push('');
|
|
19
|
+
}
|
|
20
|
+
if (u.nextStep) {
|
|
21
|
+
lines.push(`## Next Step\n-> ${u.nextStep}\n`);
|
|
22
|
+
}
|
|
23
|
+
if (u.decision) {
|
|
24
|
+
lines.push(`## Decision\n✔ ${u.decision}\n`);
|
|
25
|
+
}
|
|
26
|
+
if (u.blocker) {
|
|
27
|
+
lines.push(`## Blocker\n✖ ${u.blocker}\n`);
|
|
28
|
+
}
|
|
29
|
+
if (u.notes) {
|
|
30
|
+
lines.push('## Notes');
|
|
31
|
+
lines.push(u.notes);
|
|
32
|
+
lines.push('');
|
|
33
|
+
}
|
|
34
|
+
return lines.join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Converts WorkUnits to a clean Markdown string.
|
|
39
|
+
*/
|
|
40
|
+
export function exportToMarkdown(units: WorkUnit | WorkUnit[]): string {
|
|
41
|
+
if (!units) return 'No work units found.\n';
|
|
42
|
+
|
|
43
|
+
if (!Array.isArray(units)) {
|
|
44
|
+
return formatSingleMarkdown(units);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (units.length === 0) {
|
|
48
|
+
return 'No work units found.\n';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const lines: string[] = [];
|
|
52
|
+
lines.push('# WorkLog Export\n');
|
|
53
|
+
|
|
54
|
+
const statuses = ['In Progress', 'Blocked', 'Planned', 'Completed', 'Cancelled'] as const;
|
|
55
|
+
for (const status of statuses) {
|
|
56
|
+
const filtered = units.filter((u) => u.status === status);
|
|
57
|
+
if (filtered.length === 0) continue;
|
|
58
|
+
|
|
59
|
+
lines.push(`## ${status}`);
|
|
60
|
+
for (const u of filtered) {
|
|
61
|
+
const moduleStr = u.module ? ` [${u.module}]` : '';
|
|
62
|
+
lines.push(`- **#${u.id}**: ${u.title}${moduleStr} (${u.type})`);
|
|
63
|
+
if (u.nextStep) {
|
|
64
|
+
lines.push(` - *Next step:* ${u.nextStep}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
lines.push('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return lines.join('\n');
|
|
71
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type WorkUnitType =
|
|
2
|
+
| 'Feature'
|
|
3
|
+
| 'Bug'
|
|
4
|
+
| 'Spike'
|
|
5
|
+
| 'Refactor'
|
|
6
|
+
| 'Improvement'
|
|
7
|
+
| 'Research'
|
|
8
|
+
| 'Decision'
|
|
9
|
+
| 'Blocker'
|
|
10
|
+
| 'Review'
|
|
11
|
+
| 'Idea';
|
|
12
|
+
|
|
13
|
+
export type WorkUnitStatus =
|
|
14
|
+
| 'Planned'
|
|
15
|
+
| 'In Progress'
|
|
16
|
+
| 'Blocked'
|
|
17
|
+
| 'Completed'
|
|
18
|
+
| 'Cancelled';
|
|
19
|
+
|
|
20
|
+
export type WorkUnitPriority = 'Low' | 'Medium' | 'High';
|
|
21
|
+
|
|
22
|
+
export interface WorkUnit {
|
|
23
|
+
id: number;
|
|
24
|
+
title: string;
|
|
25
|
+
type: WorkUnitType;
|
|
26
|
+
status: WorkUnitStatus;
|
|
27
|
+
priority: WorkUnitPriority | null;
|
|
28
|
+
module: string | null;
|
|
29
|
+
description: string | null;
|
|
30
|
+
nextStep: string | null;
|
|
31
|
+
decision: string | null;
|
|
32
|
+
blocker: string | null;
|
|
33
|
+
tags: string[];
|
|
34
|
+
relatedWorkUnits: number[];
|
|
35
|
+
notes: string | null;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
updatedAt: string;
|
|
38
|
+
}
|