logwork-cli 0.0.0-stage → 1.1.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 (96) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +330 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +104 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/commits.mjs +82 -0
  7. package/commands/delete.mjs +69 -0
  8. package/commands/doctor.mjs +24 -0
  9. package/commands/edit.mjs +88 -0
  10. package/commands/history.mjs +62 -0
  11. package/commands/query.mjs +52 -0
  12. package/commands/quick.mjs +163 -0
  13. package/commands/reconcile.mjs +55 -0
  14. package/commands/release.mjs +77 -0
  15. package/commands/reminder.mjs +172 -0
  16. package/commands/setup.mjs +508 -0
  17. package/commands/skill.mjs +91 -0
  18. package/commands/tui.mjs +50 -0
  19. package/commands/update.mjs +161 -0
  20. package/docs/advanced.md +334 -0
  21. package/docs/mcp-setup.md +368 -0
  22. package/docs/operations-guide.md +587 -0
  23. package/docs/recipes.md +259 -0
  24. package/docs/security.md +151 -0
  25. package/examples/mcp/antigravity.mcp_config.json +8 -0
  26. package/examples/mcp/codex.config.toml +5 -0
  27. package/examples/mcp/copilot.mcp.json +9 -0
  28. package/lib/agent-skill.mjs +16 -0
  29. package/lib/api-auth.mjs +1259 -0
  30. package/lib/api.mjs +1422 -0
  31. package/lib/apply-ledger.mjs +227 -0
  32. package/lib/atomic-file.mjs +24 -0
  33. package/lib/auth-diagnostics.mjs +113 -0
  34. package/lib/auth-errors.mjs +22 -0
  35. package/lib/auth-redaction.mjs +78 -0
  36. package/lib/auth-terminal.mjs +97 -0
  37. package/lib/auth.mjs +323 -0
  38. package/lib/batch-parser.mjs +145 -0
  39. package/lib/batch-workflow.mjs +842 -0
  40. package/lib/commit-workflow.mjs +402 -0
  41. package/lib/config.mjs +191 -0
  42. package/lib/credential-store.mjs +130 -0
  43. package/lib/diagnostics.mjs +307 -0
  44. package/lib/entrypoint.mjs +14 -0
  45. package/lib/file-lock.mjs +44 -0
  46. package/lib/http.mjs +125 -0
  47. package/lib/jira-api.mjs +258 -0
  48. package/lib/jira-auth.mjs +203 -0
  49. package/lib/jira-workflow.mjs +523 -0
  50. package/lib/keychain.mjs +105 -0
  51. package/lib/legacy-migration.mjs +66 -0
  52. package/lib/list-navigation.mjs +19 -0
  53. package/lib/logwork-config.mjs +199 -0
  54. package/lib/logwork-f03-enrich.mjs +41 -0
  55. package/lib/macos.mjs +33 -0
  56. package/lib/manual-apply-state.mjs +35 -0
  57. package/lib/manual-commands.mjs +294 -0
  58. package/lib/manual-drafts.mjs +154 -0
  59. package/lib/manual-ink-app.mjs +1654 -0
  60. package/lib/manual-ink-auth.mjs +349 -0
  61. package/lib/manual-ink-ui.mjs +675 -0
  62. package/lib/manual-input.mjs +21 -0
  63. package/lib/manual-logwork-wizard.mjs +310 -0
  64. package/lib/manual-mcp.mjs +187 -0
  65. package/lib/manual-repl.mjs +1037 -0
  66. package/lib/manual-targets.mjs +78 -0
  67. package/lib/mcp-server.mjs +833 -0
  68. package/lib/os-notification.mjs +105 -0
  69. package/lib/package-info.mjs +17 -0
  70. package/lib/paths.mjs +68 -0
  71. package/lib/project-identity.mjs +66 -0
  72. package/lib/project-mapping-workflow.mjs +309 -0
  73. package/lib/project-resolver.mjs +373 -0
  74. package/lib/query-workflow.mjs +553 -0
  75. package/lib/reconciliation-workflow.mjs +446 -0
  76. package/lib/release-doctor.mjs +448 -0
  77. package/lib/reminder-config.mjs +71 -0
  78. package/lib/reminder-scheduler.mjs +301 -0
  79. package/lib/reminder-service.mjs +118 -0
  80. package/lib/reminder-state.mjs +38 -0
  81. package/lib/reminder-workflow.mjs +299 -0
  82. package/lib/ro-delete-args.mjs +38 -0
  83. package/lib/ro-delete-workflow.mjs +115 -0
  84. package/lib/ro-edit-args.mjs +142 -0
  85. package/lib/ro-edit-workflow.mjs +632 -0
  86. package/lib/ro-entry-reader.mjs +56 -0
  87. package/lib/ro-resubmit-workflow.mjs +134 -0
  88. package/lib/terminal-auth-prompts.mjs +120 -0
  89. package/lib/ui.mjs +133 -0
  90. package/lib/update-service.mjs +350 -0
  91. package/lib/util.mjs +66 -0
  92. package/lib/windows-credential-manager.mjs +260 -0
  93. package/lib/worklog-task-resolver.mjs +113 -0
  94. package/package.json +73 -4
  95. package/setup.sh +22 -0
  96. package/skills/logwork/SKILL.md +43 -0
@@ -0,0 +1,301 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import { homedir, userInfo } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { atomicWriteFile } from './atomic-file.mjs';
6
+ import { helperHome } from './paths.mjs';
7
+ import { normalizeReminderConfig } from './reminder-config.mjs';
8
+
9
+ export const MACOS_REMINDER_LABEL = 'sg.vinova.logwork-cli.reminder';
10
+ export const WINDOWS_REMINDER_TASK_NAME = 'Logwork Reminder';
11
+ // Scheduler names used before the logwork-helper -> logwork-cli rename. They
12
+ // point at the old runtime, so installing or removing the reminder clears them.
13
+ export const LEGACY_MACOS_REMINDER_LABEL = 'sg.vinova.logwork-helper.reminder';
14
+ export const LEGACY_WINDOWS_REMINDER_TASK_NAME = 'Logwork Helper Reminder';
15
+ // The scheduler runs `node <home>/bin/logwork.mjs reminder run --home <home>`.
16
+ export const REMINDER_RUNNER_RELATIVE_PATH = join('bin', 'logwork.mjs');
17
+
18
+ export async function installReminderScheduler(config, {
19
+ platform = process.platform,
20
+ nodePath = process.execPath,
21
+ home = helperHome(),
22
+ userHome = homedir(),
23
+ uid = userInfo().uid,
24
+ run = runCommand,
25
+ writeFile = atomicWriteFile,
26
+ accessRunner = fs.access
27
+ } = {}) {
28
+ const normalized = normalizeReminderConfig({ ...config, enabled: true });
29
+ const runnerPath = join(home, REMINDER_RUNNER_RELATIVE_PATH);
30
+ try {
31
+ await accessRunner(runnerPath);
32
+ } catch {
33
+ throw new Error(`Reminder runtime is missing at ${runnerPath}. Run \`logwork setup --no-login\` and retry.`);
34
+ }
35
+ await removeLegacyReminderScheduler({ platform, userHome, uid, run });
36
+
37
+ if (platform === 'darwin') {
38
+ const plistPath = macosReminderPlistPath(userHome);
39
+ await writeFile(plistPath, buildMacosReminderPlist({
40
+ config: normalized,
41
+ nodePath,
42
+ runnerPath,
43
+ home
44
+ }));
45
+ const domain = `gui/${uid}`;
46
+ await run('/bin/launchctl', ['bootout', domain, plistPath]);
47
+ const loaded = await run('/bin/launchctl', ['bootstrap', domain, plistPath]);
48
+ assertCommandSuccess(loaded, 'launchctl bootstrap');
49
+ return {
50
+ platform,
51
+ installed: true,
52
+ scheduler: 'launchd',
53
+ identifier: MACOS_REMINDER_LABEL,
54
+ path: plistPath
55
+ };
56
+ }
57
+
58
+ if (platform === 'win32') {
59
+ const result = await run('schtasks.exe', buildWindowsReminderCreateArgs({
60
+ config: normalized,
61
+ nodePath,
62
+ runnerPath,
63
+ home
64
+ }));
65
+ assertCommandSuccess(result, 'schtasks /Create');
66
+ return {
67
+ platform,
68
+ installed: true,
69
+ scheduler: 'Task Scheduler',
70
+ identifier: WINDOWS_REMINDER_TASK_NAME,
71
+ path: null
72
+ };
73
+ }
74
+
75
+ throw unsupportedReminderPlatform(platform);
76
+ }
77
+
78
+ export async function removeReminderScheduler({
79
+ platform = process.platform,
80
+ userHome = homedir(),
81
+ uid = userInfo().uid,
82
+ run = runCommand,
83
+ removeFile = fs.rm
84
+ } = {}) {
85
+ await removeLegacyReminderScheduler({ platform, userHome, uid, run, removeFile });
86
+ if (platform === 'darwin') {
87
+ const plistPath = macosReminderPlistPath(userHome);
88
+ await run('/bin/launchctl', ['bootout', `gui/${uid}`, plistPath]);
89
+ await removeFile(plistPath, { force: true });
90
+ return {
91
+ platform,
92
+ installed: false,
93
+ scheduler: 'launchd',
94
+ identifier: MACOS_REMINDER_LABEL,
95
+ path: plistPath
96
+ };
97
+ }
98
+
99
+ if (platform === 'win32') {
100
+ const existing = await run('schtasks.exe', ['/Query', '/TN', WINDOWS_REMINDER_TASK_NAME]);
101
+ if (existing.code === 0) {
102
+ const deleted = await run('schtasks.exe', ['/Delete', '/TN', WINDOWS_REMINDER_TASK_NAME, '/F']);
103
+ assertCommandSuccess(deleted, 'schtasks /Delete');
104
+ }
105
+ return {
106
+ platform,
107
+ installed: false,
108
+ scheduler: 'Task Scheduler',
109
+ identifier: WINDOWS_REMINDER_TASK_NAME,
110
+ path: null
111
+ };
112
+ }
113
+
114
+ throw unsupportedReminderPlatform(platform);
115
+ }
116
+
117
+ export async function getReminderSchedulerStatus({
118
+ platform = process.platform,
119
+ userHome = homedir(),
120
+ uid = userInfo().uid,
121
+ run = runCommand,
122
+ accessFile = fs.access
123
+ } = {}) {
124
+ if (platform === 'darwin') {
125
+ const plistPath = macosReminderPlistPath(userHome);
126
+ const fileExists = await accessFile(plistPath).then(() => true).catch(() => false);
127
+ const result = await run('/bin/launchctl', ['print', `gui/${uid}/${MACOS_REMINDER_LABEL}`]);
128
+ return {
129
+ platform,
130
+ installed: fileExists && result.code === 0,
131
+ scheduler: 'launchd',
132
+ identifier: MACOS_REMINDER_LABEL,
133
+ path: plistPath
134
+ };
135
+ }
136
+
137
+ if (platform === 'win32') {
138
+ const result = await run('schtasks.exe', ['/Query', '/TN', WINDOWS_REMINDER_TASK_NAME, '/FO', 'LIST']);
139
+ return {
140
+ platform,
141
+ installed: result.code === 0,
142
+ scheduler: 'Task Scheduler',
143
+ identifier: WINDOWS_REMINDER_TASK_NAME,
144
+ path: null
145
+ };
146
+ }
147
+
148
+ throw unsupportedReminderPlatform(platform);
149
+ }
150
+
151
+ export function buildMacosReminderPlist({ config, nodePath, runnerPath, home }) {
152
+ const normalized = normalizeReminderConfig({ ...config, enabled: true });
153
+ const [hour, minute] = normalized.time.split(':').map(Number);
154
+ const calendarEntries = normalized.days.map((weekday) => ` <dict>
155
+ <key>Weekday</key><integer>${weekday}</integer>
156
+ <key>Hour</key><integer>${hour}</integer>
157
+ <key>Minute</key><integer>${minute}</integer>
158
+ </dict>`).join('\n');
159
+
160
+ return `<?xml version="1.0" encoding="UTF-8"?>
161
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
162
+ <plist version="1.0">
163
+ <dict>
164
+ <key>Label</key><string>${escapeXml(MACOS_REMINDER_LABEL)}</string>
165
+ <key>ProgramArguments</key>
166
+ <array>
167
+ <string>${escapeXml(assertSafePath(nodePath))}</string>
168
+ <string>${escapeXml(assertSafePath(runnerPath))}</string>
169
+ <string>reminder</string>
170
+ <string>run</string>
171
+ <string>--home</string>
172
+ <string>${escapeXml(assertSafePath(home))}</string>
173
+ </array>
174
+ <key>WorkingDirectory</key><string>${escapeXml(assertSafePath(home))}</string>
175
+ <key>StartCalendarInterval</key>
176
+ <array>
177
+ ${calendarEntries}
178
+ </array>
179
+ <key>ProcessType</key><string>Background</string>
180
+ <key>ThrottleInterval</key><integer>60</integer>
181
+ </dict>
182
+ </plist>
183
+ `;
184
+ }
185
+
186
+ export function buildWindowsReminderCreateArgs({ config, nodePath, runnerPath, home }) {
187
+ const normalized = normalizeReminderConfig({ ...config, enabled: true });
188
+ const action = [nodePath, runnerPath, 'reminder', 'run', '--home', home]
189
+ .map(quoteWindowsActionPart)
190
+ .join(' ');
191
+ const dayNames = normalized.days.map((day) => ({
192
+ 1: 'MON',
193
+ 2: 'TUE',
194
+ 3: 'WED',
195
+ 4: 'THU',
196
+ 5: 'FRI'
197
+ })[day]).join(',');
198
+
199
+ return [
200
+ '/Create',
201
+ '/TN', WINDOWS_REMINDER_TASK_NAME,
202
+ '/TR', action,
203
+ '/SC', 'WEEKLY',
204
+ '/D', dayNames,
205
+ '/ST', normalized.time,
206
+ '/IT',
207
+ '/RL', 'LIMITED',
208
+ '/F'
209
+ ];
210
+ }
211
+
212
+ export function macosReminderPlistPath(userHome = homedir(), label = MACOS_REMINDER_LABEL) {
213
+ return join(userHome, 'Library', 'LaunchAgents', `${label}.plist`);
214
+ }
215
+
216
+ export async function removeLegacyReminderScheduler({
217
+ platform = process.platform,
218
+ userHome = homedir(),
219
+ uid = userInfo().uid,
220
+ run = runCommand,
221
+ removeFile = fs.rm
222
+ } = {}) {
223
+ try {
224
+ if (platform === 'darwin') {
225
+ const plistPath = macosReminderPlistPath(userHome, LEGACY_MACOS_REMINDER_LABEL);
226
+ const exists = await fs.access(plistPath).then(() => true).catch(() => false);
227
+ if (!exists) {
228
+ return false;
229
+ }
230
+ await run('/bin/launchctl', ['bootout', `gui/${uid}`, plistPath]);
231
+ await removeFile(plistPath, { force: true });
232
+ return true;
233
+ }
234
+ if (platform === 'win32') {
235
+ const existing = await run('schtasks.exe', ['/Query', '/TN', LEGACY_WINDOWS_REMINDER_TASK_NAME]);
236
+ if (existing.code !== 0) {
237
+ return false;
238
+ }
239
+ await run('schtasks.exe', ['/Delete', '/TN', LEGACY_WINDOWS_REMINDER_TASK_NAME, '/F']);
240
+ return true;
241
+ }
242
+ } catch {
243
+ // Best effort: a leftover legacy schedule only fires the old runtime.
244
+ }
245
+ return false;
246
+ }
247
+
248
+ export function runCommand(command, args) {
249
+ return new Promise((resolveValue, reject) => {
250
+ const child = spawn(command, args, {
251
+ stdio: ['ignore', 'pipe', 'pipe'],
252
+ windowsHide: true
253
+ });
254
+ let stdout = '';
255
+ let stderr = '';
256
+ child.stdout?.setEncoding('utf8');
257
+ child.stderr?.setEncoding('utf8');
258
+ child.stdout?.on('data', (chunk) => { stdout += chunk; });
259
+ child.stderr?.on('data', (chunk) => { stderr += chunk; });
260
+ child.on('error', reject);
261
+ child.on('close', (code, signal) => {
262
+ resolveValue({
263
+ code: signal ? 1 : (code ?? 1),
264
+ stdout,
265
+ stderr
266
+ });
267
+ });
268
+ });
269
+ }
270
+
271
+ function assertCommandSuccess(result, command) {
272
+ if (result.code !== 0) {
273
+ throw new Error(`${command} failed with exit code ${result.code}: ${String(result.stderr || result.stdout || 'no output').trim()}`);
274
+ }
275
+ }
276
+
277
+ function quoteWindowsActionPart(value) {
278
+ const path = assertSafePath(value);
279
+ return /[\s&()^%!,;]/.test(path) ? `"${path}"` : path;
280
+ }
281
+
282
+ function assertSafePath(value) {
283
+ const path = String(value || '');
284
+ if (!path || /[\r\n"<>|]/.test(path)) {
285
+ throw new Error('Reminder scheduler path contains unsupported characters.');
286
+ }
287
+ return path;
288
+ }
289
+
290
+ function escapeXml(value) {
291
+ return String(value)
292
+ .replaceAll('&', '&amp;')
293
+ .replaceAll('<', '&lt;')
294
+ .replaceAll('>', '&gt;')
295
+ .replaceAll('"', '&quot;')
296
+ .replaceAll("'", '&apos;');
297
+ }
298
+
299
+ function unsupportedReminderPlatform(platform) {
300
+ return new Error(`Reminder scheduling is unsupported on ${platform}. Supported platforms are macOS and Windows.`);
301
+ }
@@ -0,0 +1,118 @@
1
+ import {
2
+ loadReminderConfig,
3
+ normalizeReminderConfig,
4
+ saveReminderConfig
5
+ } from './reminder-config.mjs';
6
+ import {
7
+ getReminderSchedulerStatus,
8
+ installReminderScheduler,
9
+ removeReminderScheduler
10
+ } from './reminder-scheduler.mjs';
11
+ import { loadReminderState } from './reminder-state.mjs';
12
+ import { sendTestReminderNotification } from './reminder-workflow.mjs';
13
+
14
+ export async function enableLogworkReminder({
15
+ time,
16
+ target,
17
+ confirm,
18
+ loadConfig = loadReminderConfig,
19
+ saveConfig = saveReminderConfig,
20
+ installScheduler = installReminderScheduler,
21
+ removeScheduler = removeReminderScheduler
22
+ } = {}) {
23
+ requireConfirmation(confirm, 'enable');
24
+ const current = await loadConfig();
25
+ const config = normalizeReminderConfig({
26
+ ...current,
27
+ enabled: true,
28
+ ...(time ? { time } : {}),
29
+ ...(target ? { target } : {})
30
+ });
31
+
32
+ const scheduler = await installScheduler(config);
33
+ try {
34
+ await saveConfig(config);
35
+ } catch (error) {
36
+ await removeScheduler().catch(() => {});
37
+ throw error;
38
+ }
39
+
40
+ return {
41
+ status: 'enabled',
42
+ config,
43
+ scheduler,
44
+ summary: `Enabled ${config.target} logwork reminder at ${config.time}, Monday-Friday, using ${scheduler.scheduler}.`
45
+ };
46
+ }
47
+
48
+ export async function disableLogworkReminder({
49
+ confirm,
50
+ loadConfig = loadReminderConfig,
51
+ saveConfig = saveReminderConfig,
52
+ removeScheduler = removeReminderScheduler
53
+ } = {}) {
54
+ requireConfirmation(confirm, 'disable');
55
+ const current = await loadConfig();
56
+ const scheduler = await removeScheduler();
57
+ const config = await saveConfig({
58
+ ...current,
59
+ enabled: false
60
+ });
61
+ return {
62
+ status: 'disabled',
63
+ config,
64
+ scheduler,
65
+ summary: 'Disabled the logwork reminder and removed its OS schedule.'
66
+ };
67
+ }
68
+
69
+ export async function getLogworkReminderStatus({
70
+ loadConfig = loadReminderConfig,
71
+ loadState = loadReminderState,
72
+ schedulerStatus = getReminderSchedulerStatus
73
+ } = {}) {
74
+ const [config, state, scheduler] = await Promise.all([
75
+ loadConfig(),
76
+ loadState(),
77
+ schedulerStatus()
78
+ ]);
79
+ const consistent = config.enabled === scheduler.installed;
80
+ const lines = [
81
+ `Logwork reminder: ${config.enabled ? 'enabled' : 'disabled'}.`,
82
+ `Schedule: Monday-Friday at ${config.time}; target ${config.target}.`,
83
+ `OS scheduler: ${scheduler.scheduler} ${scheduler.installed ? 'installed' : 'not installed'}.`
84
+ ];
85
+ if (!consistent) {
86
+ lines.push('Warning: reminder config and OS scheduler are out of sync; run reminder enable or disable again.');
87
+ }
88
+ if (state?.lastRunAt) {
89
+ lines.push(`Last run: ${state.lastRunAt}, status ${state.lastStatus || 'unknown'}.`);
90
+ }
91
+ return {
92
+ status: 'ok',
93
+ config,
94
+ scheduler,
95
+ state,
96
+ consistent,
97
+ summary: lines.join('\n')
98
+ };
99
+ }
100
+
101
+ export async function testLogworkReminder({
102
+ target,
103
+ confirm,
104
+ loadConfig = loadReminderConfig,
105
+ sendTest = sendTestReminderNotification
106
+ } = {}) {
107
+ requireConfirmation(confirm, 'test');
108
+ const config = await loadConfig();
109
+ return sendTest({
110
+ target: target || config.target
111
+ });
112
+ }
113
+
114
+ function requireConfirmation(confirm, action) {
115
+ if (confirm !== true) {
116
+ throw new Error(`Reminder ${action} requires confirm: true.`);
117
+ }
118
+ }
@@ -0,0 +1,38 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { atomicWriteFile } from './atomic-file.mjs';
3
+ import { redactText } from './auth-redaction.mjs';
4
+ import { reminderStatePath } from './paths.mjs';
5
+
6
+ export async function loadReminderState({ path = reminderStatePath() } = {}) {
7
+ try {
8
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
9
+ return sanitizeReminderState(parsed);
10
+ } catch (error) {
11
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) {
12
+ return null;
13
+ }
14
+ throw error;
15
+ }
16
+ }
17
+
18
+ export async function saveReminderState(state, { path = reminderStatePath() } = {}) {
19
+ const sanitized = sanitizeReminderState(state);
20
+ await atomicWriteFile(path, `${JSON.stringify(sanitized, null, 2)}\n`);
21
+ return sanitized;
22
+ }
23
+
24
+ export function sanitizeReminderState(state = {}) {
25
+ return {
26
+ lastRunAt: cleanText(state.lastRunAt, 80),
27
+ lastRunDate: cleanText(state.lastRunDate, 20),
28
+ lastStatus: cleanText(state.lastStatus, 80),
29
+ lastNotifiedDate: cleanText(state.lastNotifiedDate, 20),
30
+ summary: cleanText(state.summary, 1_000),
31
+ error: cleanText(state.error, 500)
32
+ };
33
+ }
34
+
35
+ function cleanText(value, maxLength) {
36
+ const text = redactText(String(value || '')).replace(/[\r\n]+/g, ' ').trim();
37
+ return text.slice(0, maxLength);
38
+ }