logwork-cli 0.0.0-stage → 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.
Files changed (91) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +287 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +101 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/delete.mjs +69 -0
  7. package/commands/doctor.mjs +24 -0
  8. package/commands/edit.mjs +88 -0
  9. package/commands/history.mjs +62 -0
  10. package/commands/query.mjs +52 -0
  11. package/commands/quick.mjs +163 -0
  12. package/commands/reconcile.mjs +55 -0
  13. package/commands/release.mjs +77 -0
  14. package/commands/reminder.mjs +172 -0
  15. package/commands/setup.mjs +504 -0
  16. package/commands/tui.mjs +50 -0
  17. package/commands/update.mjs +161 -0
  18. package/docs/advanced.md +334 -0
  19. package/docs/mcp-setup.md +347 -0
  20. package/docs/operations-guide.md +587 -0
  21. package/docs/recipes.md +259 -0
  22. package/docs/security.md +151 -0
  23. package/examples/mcp/antigravity.mcp_config.json +8 -0
  24. package/examples/mcp/codex.config.toml +5 -0
  25. package/examples/mcp/copilot.mcp.json +9 -0
  26. package/lib/api-auth.mjs +1259 -0
  27. package/lib/api.mjs +1422 -0
  28. package/lib/apply-ledger.mjs +227 -0
  29. package/lib/atomic-file.mjs +24 -0
  30. package/lib/auth-diagnostics.mjs +113 -0
  31. package/lib/auth-errors.mjs +22 -0
  32. package/lib/auth-redaction.mjs +78 -0
  33. package/lib/auth-terminal.mjs +97 -0
  34. package/lib/auth.mjs +323 -0
  35. package/lib/batch-parser.mjs +145 -0
  36. package/lib/batch-workflow.mjs +842 -0
  37. package/lib/config.mjs +191 -0
  38. package/lib/credential-store.mjs +130 -0
  39. package/lib/diagnostics.mjs +307 -0
  40. package/lib/entrypoint.mjs +14 -0
  41. package/lib/file-lock.mjs +44 -0
  42. package/lib/http.mjs +125 -0
  43. package/lib/jira-api.mjs +258 -0
  44. package/lib/jira-auth.mjs +203 -0
  45. package/lib/jira-workflow.mjs +523 -0
  46. package/lib/keychain.mjs +105 -0
  47. package/lib/legacy-migration.mjs +66 -0
  48. package/lib/list-navigation.mjs +19 -0
  49. package/lib/logwork-config.mjs +199 -0
  50. package/lib/logwork-f03-enrich.mjs +41 -0
  51. package/lib/macos.mjs +33 -0
  52. package/lib/manual-apply-state.mjs +35 -0
  53. package/lib/manual-commands.mjs +294 -0
  54. package/lib/manual-drafts.mjs +154 -0
  55. package/lib/manual-ink-app.mjs +1654 -0
  56. package/lib/manual-ink-auth.mjs +349 -0
  57. package/lib/manual-ink-ui.mjs +675 -0
  58. package/lib/manual-input.mjs +21 -0
  59. package/lib/manual-logwork-wizard.mjs +310 -0
  60. package/lib/manual-mcp.mjs +187 -0
  61. package/lib/manual-repl.mjs +1037 -0
  62. package/lib/manual-targets.mjs +78 -0
  63. package/lib/mcp-server.mjs +793 -0
  64. package/lib/os-notification.mjs +105 -0
  65. package/lib/package-info.mjs +17 -0
  66. package/lib/paths.mjs +68 -0
  67. package/lib/project-identity.mjs +66 -0
  68. package/lib/project-mapping-workflow.mjs +309 -0
  69. package/lib/project-resolver.mjs +373 -0
  70. package/lib/query-workflow.mjs +553 -0
  71. package/lib/reconciliation-workflow.mjs +446 -0
  72. package/lib/release-doctor.mjs +448 -0
  73. package/lib/reminder-config.mjs +71 -0
  74. package/lib/reminder-scheduler.mjs +301 -0
  75. package/lib/reminder-service.mjs +118 -0
  76. package/lib/reminder-state.mjs +38 -0
  77. package/lib/reminder-workflow.mjs +299 -0
  78. package/lib/ro-delete-args.mjs +38 -0
  79. package/lib/ro-delete-workflow.mjs +115 -0
  80. package/lib/ro-edit-args.mjs +142 -0
  81. package/lib/ro-edit-workflow.mjs +632 -0
  82. package/lib/ro-entry-reader.mjs +56 -0
  83. package/lib/ro-resubmit-workflow.mjs +134 -0
  84. package/lib/terminal-auth-prompts.mjs +120 -0
  85. package/lib/ui.mjs +133 -0
  86. package/lib/update-service.mjs +350 -0
  87. package/lib/util.mjs +66 -0
  88. package/lib/windows-credential-manager.mjs +260 -0
  89. package/lib/worklog-task-resolver.mjs +113 -0
  90. package/package.json +72 -4
  91. package/setup.sh +22 -0
@@ -0,0 +1,504 @@
1
+ import { constants as fsConstants, promises as fs } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { delimiter, dirname, join, relative, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { redactText } from '../lib/auth-redaction.mjs';
6
+ import { credentialStoreLabel } from '../lib/credential-store.mjs';
7
+ import { helperHome } from '../lib/paths.mjs';
8
+ import {
9
+ MCP_SERVER_NAME,
10
+ defaultMcpServerPath,
11
+ formatCodexToml,
12
+ stdioMcpServersConfig,
13
+ vscodeMcpConfig
14
+ } from '../lib/manual-mcp.mjs';
15
+ import { loadReminderConfig } from '../lib/reminder-config.mjs';
16
+ import { installReminderScheduler } from '../lib/reminder-scheduler.mjs';
17
+
18
+ // Package name before the rename. Its global install provides a `logwork` bin
19
+ // that would block `npm link` of logwork-cli, so setup removes it first.
20
+ const LEGACY_PACKAGE_NAME = 'logwork-helper';
21
+ const PACKAGE_NAMES = ['logwork-cli', LEGACY_PACKAGE_NAME];
22
+
23
+ const EXCLUDED_DIRS = new Set([
24
+ '.git',
25
+ 'node_modules'
26
+ ]);
27
+
28
+ // `logwork setup`: copy this package into ~/.logwork-cli, install its
29
+ // dependencies, link the `logwork` command, refresh a migrated reminder, and
30
+ // print the MCP configs.
31
+ export async function run(argv = []) {
32
+ if (argv.includes('--help') || argv.includes('-h')) {
33
+ console.log(formatSetupHelp());
34
+ return 0;
35
+ }
36
+ const options = parseSetupUserArgs(argv);
37
+ const sourceDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
38
+ const targetDir = helperHome();
39
+ await installUserRuntime({ options, sourceDir, targetDir });
40
+ return 0;
41
+ }
42
+
43
+ export function formatSetupHelp() {
44
+ return `Usage:
45
+ logwork setup [--login|--no-login]
46
+
47
+ Installs the runtime to ~/.logwork-cli, links the \`logwork\` command, and prints
48
+ MCP configs for Cursor, Antigravity, Claude Code, Codex and VS Code.
49
+ Settings, mappings and the login session from an older logwork-helper install
50
+ are carried over automatically.
51
+
52
+ Options:
53
+ --login Log in to Resource Optimiser after installing
54
+ --no-login Do not ask to log in`;
55
+ }
56
+
57
+ export async function installUserRuntime({
58
+ options,
59
+ sourceDir,
60
+ targetDir,
61
+ mkdirFn = fs.mkdir,
62
+ copyFn = copyDirectory,
63
+ cleanupFn = sanitizeRuntimeMetadata,
64
+ installFn = installDependencies,
65
+ linkFn = linkGlobalBinaries,
66
+ authFn = maybeRunAuthLogin,
67
+ reminderFn = refreshReminderSchedule,
68
+ printFn = printMcpInstructions
69
+ }) {
70
+ await mkdirFn(targetDir, { recursive: true });
71
+ if (resolve(sourceDir) !== resolve(targetDir)) {
72
+ await copyFn(sourceDir, targetDir, sourceDir);
73
+ }
74
+ const cleanupResult = await cleanupFn(targetDir);
75
+ await installFn(targetDir);
76
+ const linkResult = await linkFn(targetDir);
77
+ const authResult = await authFn(targetDir, options);
78
+ const reminderResult = await reminderFn(targetDir);
79
+ await printFn(targetDir, authResult, linkResult, cleanupResult, reminderResult);
80
+
81
+ return {
82
+ targetDir,
83
+ authResult,
84
+ linkResult,
85
+ cleanupResult,
86
+ reminderResult
87
+ };
88
+ }
89
+
90
+ // A reminder enabled by an older install still points at the old runtime.
91
+ // Re-register it so the OS scheduler runs this runtime (and the legacy entry
92
+ // is removed by installReminderScheduler).
93
+ async function refreshReminderSchedule(targetDir) {
94
+ try {
95
+ const config = await loadReminderConfig();
96
+ if (!config?.enabled) {
97
+ return null;
98
+ }
99
+ await installReminderScheduler(config, { home: targetDir });
100
+ return { ok: true };
101
+ } catch (error) {
102
+ return { ok: false, error: error.message };
103
+ }
104
+ }
105
+
106
+ async function copyDirectory(sourceDir, targetDir, rootDir) {
107
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
108
+
109
+ for (const entry of entries) {
110
+ if (EXCLUDED_DIRS.has(entry.name)) {
111
+ continue;
112
+ }
113
+
114
+ const sourcePath = join(sourceDir, entry.name);
115
+ const targetPath = join(targetDir, relative(rootDir, sourcePath));
116
+
117
+ if (entry.isDirectory()) {
118
+ await fs.mkdir(targetPath, { recursive: true });
119
+ await copyDirectory(sourcePath, targetDir, rootDir);
120
+ continue;
121
+ }
122
+
123
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
124
+ continue;
125
+ }
126
+
127
+ await fs.mkdir(dirname(targetPath), { recursive: true });
128
+ await fs.copyFile(sourcePath, targetPath);
129
+ }
130
+ }
131
+
132
+ function installDependencies(targetDir) {
133
+ return resolveInstallArgs(targetDir)
134
+ .then((args) => runCommand('npm', args, {
135
+ cwd: targetDir,
136
+ stdio: 'inherit'
137
+ }));
138
+ }
139
+
140
+ function runCommand(command, args, options = {}) {
141
+ return new Promise((resolveValue, reject) => {
142
+ const child = spawn(command, args, options);
143
+
144
+ child.on('error', reject);
145
+ child.on('close', (code) => {
146
+ if (code === 0) {
147
+ resolveValue();
148
+ return;
149
+ }
150
+
151
+ reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}.`));
152
+ });
153
+ });
154
+ }
155
+
156
+ async function resolveInstallArgs(targetDir) {
157
+ try {
158
+ await fs.access(join(targetDir, 'package-lock.json'));
159
+ return ['ci', '--omit=dev'];
160
+ } catch (error) {
161
+ if (error.code !== 'ENOENT') {
162
+ throw error;
163
+ }
164
+ return ['install', '--omit=dev'];
165
+ }
166
+ }
167
+
168
+ export async function sanitizeRuntimeMetadata(targetDir, {
169
+ readFileFn = fs.readFile,
170
+ writeFileFn = fs.writeFile
171
+ } = {}) {
172
+ const changes = [];
173
+ const packagePath = join(targetDir, 'package.json');
174
+ const packageLockPath = join(targetDir, 'package-lock.json');
175
+
176
+ await updateJsonFile(packagePath, {
177
+ readFileFn,
178
+ writeFileFn,
179
+ changes
180
+ }, (json) => {
181
+ for (const name of PACKAGE_NAMES) {
182
+ if (isStaleSelfDependency(json.dependencies?.[name])) {
183
+ delete json.dependencies[name];
184
+ changes.push(`package.json dependencies.${name}`);
185
+ }
186
+ }
187
+ return json;
188
+ });
189
+
190
+ await updateJsonFile(packageLockPath, {
191
+ readFileFn,
192
+ writeFileFn,
193
+ changes
194
+ }, (json) => {
195
+ for (const name of PACKAGE_NAMES) {
196
+ if (isStaleSelfDependency(json.packages?.['']?.dependencies?.[name])) {
197
+ delete json.packages[''].dependencies[name];
198
+ changes.push(`package-lock.json packages[""].dependencies.${name}`);
199
+ }
200
+ if (json.packages?.[`node_modules/${name}`]) {
201
+ delete json.packages[`node_modules/${name}`];
202
+ changes.push(`package-lock.json packages["node_modules/${name}"]`);
203
+ }
204
+ if (json.dependencies?.[`node_modules/${name}`]) {
205
+ delete json.dependencies[`node_modules/${name}`];
206
+ changes.push(`package-lock.json dependencies["node_modules/${name}"]`);
207
+ }
208
+ if (isStaleSelfDependency(json.dependencies?.[name]?.version)) {
209
+ delete json.dependencies[name];
210
+ changes.push(`package-lock.json dependencies.${name}`);
211
+ }
212
+ }
213
+ return json;
214
+ });
215
+
216
+ return {
217
+ changed: changes.length > 0,
218
+ changes
219
+ };
220
+ }
221
+
222
+ async function updateJsonFile(path, { readFileFn, writeFileFn }, update) {
223
+ let text;
224
+ try {
225
+ text = await readFileFn(path, 'utf8');
226
+ } catch (error) {
227
+ if (error.code === 'ENOENT') {
228
+ return;
229
+ }
230
+ throw error;
231
+ }
232
+
233
+ const json = JSON.parse(text);
234
+ const before = JSON.stringify(json);
235
+ const updated = update(json);
236
+ const after = JSON.stringify(updated);
237
+ if (before !== after) {
238
+ await writeFileFn(path, `${JSON.stringify(updated, null, 2)}\n`, 'utf8');
239
+ }
240
+ }
241
+
242
+ function isStaleSelfDependency(value) {
243
+ return typeof value === 'string' && /^file:.*logwork-(helper|cli).*\.tgz$/i.test(value);
244
+ }
245
+
246
+ export async function linkGlobalBinaries(targetDir, {
247
+ runner = runCommand,
248
+ commandFinder = findCommandOnPath
249
+ } = {}) {
250
+ // Best effort: drop the pre-rename global package so its `logwork` bin does
251
+ // not block linking this one. A no-op when it is not installed.
252
+ await runner('npm', ['rm', '-g', LEGACY_PACKAGE_NAME], {
253
+ cwd: targetDir,
254
+ stdio: 'ignore'
255
+ }).catch(() => {});
256
+
257
+ try {
258
+ await runner('npm', ['link'], {
259
+ cwd: targetDir,
260
+ stdio: 'inherit'
261
+ });
262
+ } catch (error) {
263
+ return {
264
+ linked: false,
265
+ commandAvailable: false,
266
+ error: error.message
267
+ };
268
+ }
269
+
270
+ const commandAvailable = await commandFinder('logwork');
271
+ return {
272
+ linked: true,
273
+ commandAvailable,
274
+ warning: commandAvailable
275
+ ? null
276
+ : '`logwork` was linked, but it is not visible on PATH in this shell.'
277
+ };
278
+ }
279
+
280
+ export async function findCommandOnPath(commandName, {
281
+ platform = process.platform,
282
+ envPath = process.env.PATH || '',
283
+ accessFn = fs.access
284
+ } = {}) {
285
+ const pathEntries = envPath.split(platform === 'win32' ? ';' : delimiter).filter(Boolean);
286
+ const names = platform === 'win32'
287
+ ? [commandName, `${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`]
288
+ : [commandName];
289
+ for (const pathEntry of pathEntries) {
290
+ for (const name of names) {
291
+ const candidate = resolve(pathEntry, name);
292
+ try {
293
+ await accessFn(candidate, fsConstants.X_OK);
294
+ return true;
295
+ } catch {
296
+ // Keep searching PATH.
297
+ }
298
+ }
299
+ }
300
+
301
+ return false;
302
+ }
303
+
304
+ export function parseSetupUserArgs(args = []) {
305
+ let loginMode = 'prompt';
306
+
307
+ for (const arg of args) {
308
+ if (arg === '--login') {
309
+ loginMode = 'required';
310
+ continue;
311
+ }
312
+ if (arg === '--no-login') {
313
+ loginMode = 'skip';
314
+ continue;
315
+ }
316
+ throw new Error(`Unknown setup option: ${arg}`);
317
+ }
318
+
319
+ return { loginMode };
320
+ }
321
+
322
+ async function maybeRunAuthLogin(targetDir, { loginMode }) {
323
+ if (loginMode === 'skip') {
324
+ return null;
325
+ }
326
+
327
+ const shouldRun = loginMode === 'required' || await shouldPromptLogin();
328
+ if (!shouldRun) {
329
+ return null;
330
+ }
331
+
332
+ try {
333
+ await runAuthLogin(targetDir);
334
+ return { ok: true };
335
+ } catch (error) {
336
+ if (loginMode === 'required') {
337
+ throw error;
338
+ }
339
+ return {
340
+ ok: false,
341
+ error: error.message
342
+ };
343
+ }
344
+ }
345
+
346
+ async function shouldPromptLogin() {
347
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
348
+ return false;
349
+ }
350
+
351
+ const { confirm, isCancel } = await import('@clack/prompts');
352
+ const { getStoredAuthStatus } = await import('../lib/auth.mjs');
353
+ const status = await getStoredAuthStatus().catch(() => ({ authenticated: false }));
354
+ if (status.authenticated) {
355
+ return false;
356
+ }
357
+
358
+ const answer = await confirm({
359
+ message: 'Log in to Resource Optimiser now?',
360
+ initialValue: true
361
+ });
362
+ if (isCancel(answer)) {
363
+ return false;
364
+ }
365
+
366
+ return Boolean(answer);
367
+ }
368
+
369
+ function runAuthLogin(targetDir) {
370
+ return new Promise((resolveValue, reject) => {
371
+ const child = spawn(process.execPath, [resolve(targetDir, 'bin', 'logwork.mjs'), 'login'], {
372
+ cwd: targetDir,
373
+ stdio: 'inherit',
374
+ env: {
375
+ ...process.env,
376
+ LOGWORK_HOME: targetDir
377
+ }
378
+ });
379
+ child.on('error', reject);
380
+ child.on('close', (code) => {
381
+ if (code === 0) {
382
+ resolveValue();
383
+ return;
384
+ }
385
+ reject(new Error(`Resource Optimiser auth login failed with exit code ${code}.`));
386
+ });
387
+ });
388
+ }
389
+
390
+ function printMcpInstructions(targetDir, authResult = null, linkResult = null, cleanupResult = null, reminderResult = null) {
391
+ console.log(formatSetupUserInstructions({
392
+ targetDir,
393
+ authResult,
394
+ linkResult,
395
+ cleanupResult,
396
+ reminderResult
397
+ }));
398
+ }
399
+
400
+ export function formatSetupUserInstructions({
401
+ targetDir,
402
+ authResult = null,
403
+ linkResult = null,
404
+ cleanupResult = null,
405
+ reminderResult = null,
406
+ platform = process.platform
407
+ } = {}) {
408
+ const entryPath = resolve(targetDir, 'bin', 'logwork.mjs');
409
+ const storeLabel = credentialStoreLabel({ platform });
410
+ const sections = [
411
+ `logwork-cli installed to ${targetDir}.`,
412
+ '',
413
+ 'Install status',
414
+ `- Runtime: ${targetDir}`,
415
+ '- Dependencies: installed',
416
+ cleanupResult?.changed
417
+ ? '- Runtime metadata: removed stale local tarball dependency'
418
+ : '- Runtime metadata: ready',
419
+ `- Auth: ${formatAuthStatus(authResult)}`,
420
+ ...(reminderResult ? [`- Reminder: ${reminderResult.ok ? 're-registered for this runtime' : `not refreshed (${redactText(reminderResult.error || 'unknown error')}); run \`logwork reminder enable\``}`] : []),
421
+ '',
422
+ formatTerminalCommandInstructions(linkResult),
423
+ '',
424
+ 'Next steps',
425
+ ...formatNextSteps(authResult),
426
+ '',
427
+ `MCP config (server name "${MCP_SERVER_NAME}", runs: node ${entryPath} mcp)`,
428
+ '',
429
+ 'Cursor / Antigravity / Claude Code (.mcp.json):',
430
+ JSON.stringify(stdioMcpServersConfig(entryPath), null, 2),
431
+ '',
432
+ 'Codex config.toml:',
433
+ formatCodexToml(entryPath),
434
+ '',
435
+ 'GitHub Copilot / VS Code mcp.json:',
436
+ JSON.stringify(vscodeMcpConfig(entryPath), null, 2),
437
+ '',
438
+ 'Verify',
439
+ '- Restart or reload your IDE MCP tools after pasting config.',
440
+ '- In the IDE, ask: Check my logwork for this week.',
441
+ '- An old "logwork-helper" MCP entry can be deleted; it points at the old runtime.',
442
+ '',
443
+ 'Safety',
444
+ '- Do not paste passwords, 2FA codes, Bearer tokens, cookies, or raw curl auth logs into AI chat.',
445
+ `- MCP config only needs command and args; tokens stay in ${storeLabel}.`,
446
+ '- Project mappings are stored separately and never store Resource Optimiser tokens.'
447
+ ];
448
+
449
+ return sections.join('\n');
450
+ }
451
+
452
+ function formatAuthStatus(authResult) {
453
+ if (authResult?.ok) {
454
+ return 'completed';
455
+ }
456
+ if (authResult?.ok === false) {
457
+ return `not completed (${redactText(authResult.error || 'unknown error')})`;
458
+ }
459
+ return 'not run';
460
+ }
461
+
462
+ function formatNextSteps(authResult) {
463
+ const steps = [];
464
+ let index = 1;
465
+ if (!authResult?.ok) {
466
+ steps.push(`${index}. Log in to Resource Optimiser:`);
467
+ steps.push(' logwork login');
468
+ index += 1;
469
+ }
470
+ steps.push(`${index}. Paste one MCP config below into your IDE.`);
471
+ steps.push(`${index + 1}. Restart or reload your IDE MCP tools.`);
472
+ steps.push(`${index + 2}. Verify with: Check my logwork for this week.`);
473
+ return steps;
474
+ }
475
+
476
+ export function formatTerminalCommandInstructions(linkResult = null) {
477
+ const commands = [
478
+ 'Terminal command installed:',
479
+ ' logwork open the terminal UI',
480
+ ' logwork login log in to Resource Optimiser',
481
+ ' logwork --help list all commands'
482
+ ];
483
+
484
+ if (!linkResult || (linkResult.linked === true && linkResult.commandAvailable === true)) {
485
+ return commands.join('\n');
486
+ }
487
+
488
+ if (linkResult.linked === true && linkResult.commandAvailable === false) {
489
+ return [
490
+ ...commands,
491
+ '',
492
+ `Warning: ${linkResult.warning}`,
493
+ 'If `logwork` is not found, open a new terminal or ensure npm global bin is on PATH.'
494
+ ].join('\n');
495
+ }
496
+
497
+ return [
498
+ 'Terminal command link did not complete.',
499
+ `Reason: ${linkResult.error}`,
500
+ '',
501
+ 'Fallback:',
502
+ ' npm install -g logwork-cli'
503
+ ].join('\n');
504
+ }
@@ -0,0 +1,50 @@
1
+ import { resolve } from 'node:path';
2
+ import { runManualRepl } from '../lib/manual-repl.mjs';
3
+ import { checkForUpdates, formatUpdateNotice } from '../lib/update-service.mjs';
4
+
5
+ // `logwork` with no command: the Ink terminal UI.
6
+ export async function run(argv = []) {
7
+ if (argv.includes('--help') || argv.includes('-h')) {
8
+ console.log(formatTuiHelp());
9
+ return 0;
10
+ }
11
+ if (argv.length) {
12
+ throw new Error(`Unknown option: ${argv[0]}. Run \`logwork --help\` for commands.`);
13
+ }
14
+
15
+ const updateNotice = process.stdin.isTTY && process.stdout.isTTY
16
+ ? await getStartupUpdateNotice()
17
+ : '';
18
+ await runManualRepl({
19
+ cwd: resolve(process.cwd()),
20
+ updateNotice
21
+ });
22
+ return undefined;
23
+ }
24
+
25
+ export async function getStartupUpdateNotice({ checkForUpdatesFn = checkForUpdates } = {}) {
26
+ try {
27
+ return formatUpdateNotice(await checkForUpdatesFn());
28
+ } catch {
29
+ return '';
30
+ }
31
+ }
32
+
33
+ export function formatTuiHelp() {
34
+ return `Usage:
35
+ logwork
36
+
37
+ Opens the terminal UI. Type / for live command suggestions, Esc to exit.
38
+
39
+ Common commands inside the UI:
40
+ /query this-week Booked vs logged hours with entry ids
41
+ /logwork Paste or write a weekly block, preview, then apply
42
+ /edit-logwork 304032 --task Bug Fix --desc Fix date picker (SAP-42)
43
+ /reconcile this-week Compare RO and Jira
44
+ /projects List RO projects
45
+ /map SCB 5234 Map a ticket prefix to a project
46
+ /mcp Show MCP config for an IDE
47
+ /history Local apply history
48
+
49
+ Run \`logwork --help\` for the CLI commands.`;
50
+ }
@@ -0,0 +1,161 @@
1
+ import { confirm, isCancel } from '@clack/prompts';
2
+ import {
3
+ checkForUpdates,
4
+ installUpdate,
5
+ parseSemver
6
+ } from '../lib/update-service.mjs';
7
+
8
+ export function parseUpdateArgs(argv) {
9
+ const args = [...argv];
10
+ let command = 'check';
11
+ let force = false;
12
+ let json = false;
13
+ let yes = false;
14
+ let version;
15
+ let help = false;
16
+
17
+ if (args[0] && !args[0].startsWith('-')) {
18
+ command = args.shift();
19
+ }
20
+ if (!['check', 'install'].includes(command)) {
21
+ throw new Error(`Unknown update command: ${command}. Use check or install.`);
22
+ }
23
+
24
+ for (let index = 0; index < args.length; index += 1) {
25
+ const arg = args[index];
26
+ if (arg === '--help' || arg === '-h') {
27
+ help = true;
28
+ continue;
29
+ }
30
+ if (arg === '--json') {
31
+ json = true;
32
+ continue;
33
+ }
34
+ if (arg === '--force') {
35
+ force = true;
36
+ continue;
37
+ }
38
+ if (arg === '--yes' || arg === '-y') {
39
+ yes = true;
40
+ continue;
41
+ }
42
+ if (arg === '--version') {
43
+ version = args[index + 1];
44
+ if (!version) {
45
+ throw new Error('--version requires an exact SemVer value.');
46
+ }
47
+ index += 1;
48
+ continue;
49
+ }
50
+ if (arg.startsWith('--version=')) {
51
+ version = arg.slice('--version='.length);
52
+ continue;
53
+ }
54
+ throw new Error(`Unknown update option: ${arg}`);
55
+ }
56
+
57
+ if (command === 'check' && (yes || version)) {
58
+ throw new Error('--yes and --version are only valid with update install.');
59
+ }
60
+ if (command === 'install' && force) {
61
+ throw new Error('--force is only valid with update check; install always checks npm again.');
62
+ }
63
+ if (version && !parseSemver(version)) {
64
+ throw new Error('--version must be an exact SemVer value such as 1.2.3.');
65
+ }
66
+
67
+ return { command, force, json, yes, version, help };
68
+ }
69
+
70
+ export async function runUpdateCli(argv, {
71
+ checkForUpdatesFn = checkForUpdates,
72
+ installUpdateFn = installUpdate,
73
+ promptConfirm = defaultConfirm,
74
+ isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY),
75
+ output = process.stdout
76
+ } = {}) {
77
+ const options = parseUpdateArgs(argv);
78
+ if (options.help) {
79
+ output.write(`${formatUpdateHelp()}\n`);
80
+ return 0;
81
+ }
82
+
83
+ if (options.command === 'check') {
84
+ const result = await checkForUpdatesFn({ force: options.force });
85
+ printResult(output, result, options.json);
86
+ return 0;
87
+ }
88
+
89
+ const latest = await checkForUpdatesFn({
90
+ force: true,
91
+ allowStaleOnError: false
92
+ });
93
+ const targetVersion = options.version || latest.latestVersion;
94
+ if (targetVersion !== latest.latestVersion) {
95
+ throw new Error(`Only the npm latest version can be installed. Requested ${targetVersion}; latest is ${latest.latestVersion}.`);
96
+ }
97
+ if (!latest.updateAvailable) {
98
+ printResult(output, latest, options.json);
99
+ return 0;
100
+ }
101
+
102
+ let approved = options.yes;
103
+ if (!approved) {
104
+ if (!isTTY) {
105
+ throw new Error('Interactive confirmation is unavailable. Re-run with --yes to install the update.');
106
+ }
107
+ approved = await promptConfirm(`Install Logwork Helper ${targetVersion}?`);
108
+ }
109
+ if (!approved) {
110
+ output.write('Update cancelled.\n');
111
+ return 0;
112
+ }
113
+
114
+ const result = await installUpdateFn({
115
+ version: targetVersion,
116
+ confirm: true,
117
+ stdio: options.json ? 'pipe' : 'inherit'
118
+ });
119
+ printResult(output, result, options.json);
120
+ return 0;
121
+ }
122
+
123
+ export function formatUpdateHelp() {
124
+ return `Usage:
125
+ logwork update check [--force] [--json]
126
+ logwork update install [--version <semver>] [--yes] [--json]
127
+
128
+ Commands:
129
+ check Compare the installed version with npm latest. Results are cached for 24 hours.
130
+ install Re-check npm latest, install that exact version, and verify the local runtime.
131
+
132
+ Options:
133
+ --force Ignore the update cache when checking
134
+ --version <semver> Install only when this exact version is npm latest
135
+ -y, --yes Skip the interactive install confirmation
136
+ --json Print structured JSON output
137
+ -h, --help Show this help`;
138
+ }
139
+
140
+ async function defaultConfirm(message) {
141
+ const result = await confirm({
142
+ message,
143
+ initialValue: false
144
+ });
145
+ return !isCancel(result) && result === true;
146
+ }
147
+
148
+ function printResult(output, result, json) {
149
+ if (json) {
150
+ output.write(`${JSON.stringify(result, null, 2)}\n`);
151
+ return;
152
+ }
153
+ output.write(`${result.summary}\n`);
154
+ if (result.updateAvailable) {
155
+ output.write(`Release: ${result.releaseUrl}\n`);
156
+ output.write('Install: logwork update install\n');
157
+ }
158
+ if (result.warning) {
159
+ output.write(`Warning: ${result.warning}\n`);
160
+ }
161
+ }