gitwrit 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Made for `git`-ters and `writ`-ters.
6
6
 
7
- **gitwrit** watches your Markdown files and quietly commits and pushes them to your Git repository — no manual `git add`, no `git commit`, no `git push`. You write. gitwrit handles the rest.
7
+ **gitwrit** watches your Markdown files and quietly commits and pushes them to your Git repository — no manual `git add`, no `git commit`, no `git push`. You write. gitwrit handles the rest.
8
8
 
9
9
  It is built for engineers writing internal specs, researchers building private knowledge bases, AI practitioners documenting models and experiments, and anyone who wants the safety of version control without the overhead of Git discipline. Your files stay in your own repository, on your own terms—not in someone else's cloud.
10
10
 
@@ -40,7 +40,7 @@ gitwrit init # first run sets up global defaults + registers this directory
40
40
  gitwrit start # start watching
41
41
  ```
42
42
 
43
- That's it. Write something, save it, and gitwrit commits it.
43
+ That's it. Write something, save it, and gitwrit commits it.
44
44
 
45
45
  ---
46
46
 
@@ -93,6 +93,18 @@ gitwrit supports two branch modes, configurable globally or per directory.
93
93
 
94
94
  ---
95
95
 
96
+ ## Updates
97
+
98
+ gitwrit checks for updates automatically after every command. If a newer version is available, you will see a single note at the bottom of your output:
99
+
100
+ ```
101
+ · Update available: 0.2.1 → 0.3.0 Run npm install -g gitwrit to update.
102
+ ```
103
+
104
+ The check runs in the background, caches for 24 hours, and fails silently if you are offline. It will never interrupt or slow down your workflow.
105
+
106
+ ---
107
+
96
108
  ## Requirements
97
109
 
98
110
  - Node.js ≥ 18
@@ -105,7 +117,7 @@ gitwrit supports two branch modes, configurable globally or per directory.
105
117
 
106
118
  Those tools store your files on their infrastructure. gitwrit does not. Your writing lives in a plain Git repository that you control, pushed wherever you want—GitHub, GitLab, a private server, anywhere that accepts a Git remote.
107
119
 
108
- Just to be clear, there's defnitely nothing wrong with using any of these tools. I use them!
120
+ Just to be clear, there is absolutely nothing wrong with using any of these tools. I use them!
109
121
 
110
122
  But I thought making this might be especially useful for proprietary documentation like internal specs, research notes, model cards, experiment logs, etc. For when putting files into a third-party cloud is not an option, or when you want to keep everything essential to your codebase **all in one place**.
111
123
 
@@ -119,4 +131,4 @@ gitwrit is open source and built to be extended. Contributions are welcome at ev
119
131
 
120
132
  ## License
121
133
 
122
- MIT
134
+ MIT
package/bin/gitwrit.js CHANGED
@@ -11,59 +11,61 @@ import { add } from '../src/commands/add.js';
11
11
  import { remove } from '../src/commands/remove.js';
12
12
  import { logs } from '../src/commands/logs.js';
13
13
  import { help } from '../src/commands/help.js';
14
+ import { checkForUpdate, currentVersion } from '../src/updater.js';
15
+ import chalk from 'chalk';
16
+ import { TEAL, PINK } from '../src/ui.js';
14
17
 
15
18
  const program = new Command();
16
19
 
17
20
  program
18
21
  .name('gitwrit')
19
- .description('private, versioned writing for people who live in the terminal.')
20
- .version('0.1.0')
21
- .addHelpCommand(false) // we handle help ourselves
22
+ .description('Private, versioned writing for people who live in the terminal.')
23
+ .version('0.2.1')
24
+ .addHelpCommand(false)
22
25
  .helpOption(false);
23
26
 
24
27
  program.command('init')
25
- .description('set up gitwrit in the current directory')
28
+ .description('Set up gitwrit in the current directory')
26
29
  .action(init);
27
30
 
28
31
  program.command('start')
29
- .description('start watching your registered directories')
32
+ .description('Start watching your registered directories')
30
33
  .action(start);
31
34
 
32
35
  program.command('stop')
33
- .description('stop the daemon gracefully')
36
+ .description('Stop the daemon gracefully')
34
37
  .action(stop);
35
38
 
36
39
  program.command('restart')
37
- .description('stop and restart the daemon')
40
+ .description('Stop and restart the daemon')
38
41
  .action(restart);
39
42
 
40
43
  program.command('status')
41
- .description('show running state and recent activity')
44
+ .description('Show running state and recent activity')
42
45
  .action(status);
43
46
 
44
47
  program.command('config')
45
- .description('edit global defaults or local directory overrides')
48
+ .description('Edit global defaults or local directory overrides')
46
49
  .action(config);
47
50
 
48
51
  program.command('add [path]')
49
- .description('add a directory to your watch list')
52
+ .description('Add a directory to your watch list')
50
53
  .action(add);
51
54
 
52
55
  program.command('remove [path]')
53
- .description('remove a directory from your watch list')
56
+ .description('Remove a directory from your watch list')
54
57
  .action(remove);
55
58
 
56
59
  program.command('logs')
57
- .description('tail the activity log')
60
+ .description('Tail the activity log')
58
61
  .option('-n, --lines <number>', 'number of lines to show', '20')
59
62
  .action((opts) => logs(parseInt(opts.lines, 10)));
60
63
 
61
64
  program.command('help')
62
- .description('show all commands')
65
+ .description('List all available commands')
63
66
  .action(help);
64
67
 
65
68
  // ── hidden daemon command ─────────────────────────────────────────────────────
66
- // not shown in help — only called internally by `start`
67
69
  program.command('__daemon', { hidden: true })
68
70
  .action(() => import('../src/daemon.js'));
69
71
 
@@ -73,4 +75,21 @@ if (process.argv.length <= 2) {
73
75
  process.exit(0);
74
76
  }
75
77
 
76
- program.parse(process.argv);
78
+ // ── run command then check for updates ───────────────────────────────────────
79
+ // update check runs after the command completes so it never delays the user.
80
+ // shows a single hint line at the bottom if a newer version is available.
81
+ program.parseAsync(process.argv).then(async () => {
82
+ const latest = await checkForUpdate();
83
+ if (latest) {
84
+ console.log(
85
+ '\n' +
86
+ chalk.dim(' · Update available: ') +
87
+ chalk.hex(TEAL)(currentVersion()) +
88
+ chalk.dim(' → ') +
89
+ chalk.hex(PINK).bold(latest) +
90
+ chalk.dim(' Run ') +
91
+ chalk.white('npm install -g gitwrit') +
92
+ chalk.dim(' to update.')
93
+ );
94
+ }
95
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitwrit",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Private, versioned writing for people who live in the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,9 +19,13 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "@inquirer/prompts": "^5.0.0",
22
+ "boxen": "^7.1.1",
22
23
  "chalk": "^5.3.0",
23
24
  "chokidar": "^3.6.0",
24
25
  "commander": "^12.1.0",
26
+ "figlet": "^1.11.0",
27
+ "gradient-string": "^2.0.2",
28
+ "ora": "^8.2.0",
25
29
  "simple-git": "^3.27.0"
26
30
  }
27
31
  }
package/src/banner.js ADDED
@@ -0,0 +1,51 @@
1
+ import figlet from 'figlet';
2
+ import gradient from 'gradient-string';
3
+ import chalk from 'chalk';
4
+
5
+ // ─── palette ──────────────────────────────────────────────────────────────────
6
+
7
+ const TEAL = '#2EC4B6';
8
+ const PINK = '#F72585';
9
+
10
+ const brandGradient = gradient(TEAL, PINK);
11
+
12
+ // ─── banner ───────────────────────────────────────────────────────────────────
13
+
14
+ // renders the full gitwrit banner:
15
+ // - BlurVision ASCII art in teal→pink gradient
16
+ // - tagline with git (teal) and writ (pink) colored separately
17
+ // - subtitle dimmed
18
+ //
19
+ // shown on:
20
+ // - gitwrit init (first run only)
21
+ // - gitwrit help
22
+
23
+ export function renderBanner() {
24
+ const ascii = figlet.textSync('gitwrit', {
25
+ font: 'BlurVision ASCII',
26
+ horizontalLayout: 'default',
27
+ });
28
+
29
+ // apply gradient line by line so the block characters catch
30
+ // the color transition at different densities (░ ▒ ▓)
31
+ const colored = brandGradient.multiline(ascii);
32
+
33
+ // tagline — git in teal, writ in pink, rest dimmed
34
+ const tagline =
35
+ chalk.dim(' Made for ') +
36
+ chalk.hex(TEAL).bold('`git`') +
37
+ chalk.dim('-ters and ') +
38
+ chalk.hex(PINK).bold('`writ`') +
39
+ chalk.dim('-ters.');
40
+
41
+ // subtitle
42
+ const subtitle = chalk.dim(
43
+ ' Private, versioned writing for people who live in the terminal.'
44
+ );
45
+
46
+ console.log();
47
+ console.log(colored);
48
+ console.log(subtitle);
49
+ console.log(tagline);
50
+ console.log();
51
+ }
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
- import { print } from '../ui.js';
2
+ import { print, TEAL, PINK } from '../ui.js';
3
+ import { renderBanner } from '../banner.js';
3
4
 
4
5
  const commands = [
5
6
  { name: 'init', desc: 'Set up gitwrit in the current directory' },
@@ -8,7 +9,7 @@ const commands = [
8
9
  { name: 'stop', desc: 'Stop the daemon gracefully' },
9
10
  { name: 'restart', desc: 'Stop and restart (useful after config changes)' },
10
11
  { name: '', desc: '' },
11
- { name: 'status', desc: 'Show what gitwrit is watching and what it\'s done lately' },
12
+ { name: 'status', desc: 'Show what gitwrit is watching and what it\'s done lately' },
12
13
  { name: 'logs', desc: 'Tail the activity log' },
13
14
  { name: '', desc: '' },
14
15
  { name: 'config', desc: 'Edit your global defaults or local directory overrides' },
@@ -27,15 +28,13 @@ const tips = [
27
28
  ];
28
29
 
29
30
  export function help() {
30
- print.gap();
31
- print.brand('gitwrit — Private, versioned writing for people who live in the terminal.');
32
- print.gap();
31
+ renderBanner();
33
32
 
34
33
  console.log(chalk.dim(' commands'));
35
34
  print.gap();
36
35
 
37
36
  for (const { name, desc } of commands) {
38
- const k = chalk.white(name.padEnd(12));
37
+ const k = chalk.hex(TEAL)(name.padEnd(12));
39
38
  console.log(` ${k}${desc}`);
40
39
  }
41
40
 
@@ -48,6 +47,9 @@ export function help() {
48
47
  }
49
48
 
50
49
  print.gap();
51
- console.log(chalk.dim(' docs + source github.com/TBiddy/gitwrit'));
50
+ console.log(
51
+ chalk.dim(' docs + source ') +
52
+ chalk.hex(TEAL)('github.com/TBiddy/gitwrit')
53
+ );
52
54
  print.gap();
53
- }
55
+ }
@@ -2,8 +2,10 @@ import { checkbox, select, confirm, input } from '@inquirer/prompts';
2
2
  import { mkdir } from 'fs/promises';
3
3
  import { resolve } from 'path';
4
4
  import { cwd } from 'process';
5
+ import ora from 'ora';
5
6
 
6
- import { print } from '../ui.js';
7
+ import { print, TEAL, PINK } from '../ui.js';
8
+ import { renderBanner } from '../banner.js';
7
9
  import {
8
10
  DEFAULTS,
9
11
  globalConfigExists,
@@ -23,7 +25,8 @@ export async function init() {
23
25
  // ── first-run: global setup ────────────────────────────────────────────────
24
26
 
25
27
  if (isFirstRun) {
26
- print.brand("Welcome to gitwrit. Let's get you set up.");
28
+ renderBanner();
29
+ print.brand('Welcome to gitwrit. Let\'s get you set up.');
27
30
  print.gap();
28
31
  print.divider('Global defaults');
29
32
  print.hint('These apply to every directory you watch.');
@@ -77,7 +80,7 @@ export async function init() {
77
80
  message: 'Default branch mode?',
78
81
  choices: [
79
82
  {
80
- name: "Current branch — commit to whatever branch you're on",
83
+ name: 'Current branch — commit to whatever branch you\'re on',
81
84
  value: 'current',
82
85
  },
83
86
  {
@@ -107,19 +110,23 @@ export async function init() {
107
110
  print.divider(`Registering ${dir}`);
108
111
  print.gap();
109
112
 
110
- // hard gate: must be a git repo
113
+ // validate repo with spinner
114
+ const spinner = ora({
115
+ text: 'Checking repository...',
116
+ color: 'cyan',
117
+ }).start();
118
+
111
119
  const repoValid = await isGitRepo(dir);
112
120
  if (!repoValid) {
113
- print.bad(`${dir} is not a Git repo.`);
121
+ spinner.fail('Not a Git repository.');
114
122
  print.hint('Run git init here first, then run gitwrit init again.');
115
123
  print.gap();
116
124
  process.exit(1);
117
125
  }
118
126
 
119
- // hard gate: must have a remote
120
127
  const remoteValid = await hasRemote(dir);
121
128
  if (!remoteValid) {
122
- print.bad('No Git remote configured.');
129
+ spinner.fail('No Git remote configured.');
123
130
  print.hint('gitwrit pushes your work to a remote repository to keep it safe.');
124
131
  print.hint('Add one first: git remote add origin <url>');
125
132
  print.hint('Then run gitwrit init again.');
@@ -127,11 +134,10 @@ export async function init() {
127
134
  process.exit(1);
128
135
  }
129
136
 
130
- print.good('Valid Git repo found.');
131
- print.good('Remote origin configured.');
137
+ spinner.succeed('Valid Git repo with remote confirmed.');
132
138
  print.gap();
133
139
 
134
- // local overrides — only ask if not first run (first run just inherits global)
140
+ // local overrides — only ask if not first run
135
141
  let useGlobalDefaults = true;
136
142
  if (!isFirstRun) {
137
143
  useGlobalDefaults = await confirm({
@@ -144,7 +150,6 @@ export async function init() {
144
150
  print.hint('Local overrides can be set anytime with gitwrit config.');
145
151
  }
146
152
 
147
- // register the directory in global watch list
148
153
  const added = await addWatchPath({ type: 'directory', path: dir });
149
154
 
150
155
  print.gap();
@@ -167,6 +172,6 @@ export async function init() {
167
172
  print.row('Committing', `${config.debounce / 1000}s after last save`);
168
173
  print.row('Pushing', `Every ${config.pushInterval / 60000} min`);
169
174
  print.gap();
170
- print.hint("Run gitwrit start whenever you're ready.");
175
+ print.hint('Run gitwrit start whenever you\'re ready.');
171
176
  print.gap();
172
- }
177
+ }
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
2
2
  import { writeFile, mkdir, readFile } from 'fs/promises';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
+ import ora from 'ora';
5
6
 
6
7
  import { print } from '../ui.js';
7
8
  import { PID_FILE, GITWRIT_DIR } from '../paths.js';
@@ -77,6 +78,12 @@ export async function start() {
77
78
 
78
79
  await mkdir(GITWRIT_DIR, { recursive: true });
79
80
 
81
+ // spinner while daemon spins up
82
+ const spinner = ora({
83
+ text: 'Starting gitwrit...',
84
+ color: 'cyan',
85
+ }).start();
86
+
80
87
  const daemon = spawn(process.execPath, [DAEMON_SCRIPT], {
81
88
  detached: true,
82
89
  stdio: 'ignore',
@@ -87,18 +94,10 @@ export async function start() {
87
94
  });
88
95
 
89
96
  daemon.unref();
90
-
91
97
  await writeFile(PID_FILE, String(daemon.pid), 'utf8');
92
-
93
98
  await new Promise(r => setTimeout(r, 300));
94
99
 
95
- print.gap();
96
-
97
- if (wasUnclean) {
98
- print.brand('gitwrit is running — picked up where you left off.');
99
- } else {
100
- print.brand('gitwrit is running.');
101
- }
100
+ spinner.succeed(wasUnclean ? 'Picked up where you left off.' : 'gitwrit is running.');
102
101
 
103
102
  print.gap();
104
103
  print.row('Watching', config.watch.map(w => w.path).join(', '));
@@ -115,4 +114,4 @@ export async function start() {
115
114
  print.gap();
116
115
  print.hint(randomStartMessage());
117
116
  print.gap();
118
- }
117
+ }
package/src/notify.js ADDED
@@ -0,0 +1,34 @@
1
+ import { exec } from 'child_process';
2
+ import { platform } from 'os';
3
+
4
+ // ─── native notification ───────────────────────────────────────────────────────
5
+ //
6
+ // fires a native OS notification with no npm dependencies.
7
+ // macOS → osascript
8
+ // Linux → notify-send (degrades silently if not installed)
9
+ // other → silent, no crash
10
+ //
11
+ // only called in two places:
12
+ // 1. scheduler.js — on push failure (always notify)
13
+ // 2. nowhere else — stop summary lives in the terminal output only
14
+
15
+ export function notify(title, message) {
16
+ const os = platform();
17
+
18
+ // sanitize inputs — prevent shell injection via single-quote stripping
19
+ const safeTitle = title.replace(/'/g, '');
20
+ const safeMessage = message.replace(/'/g, '');
21
+
22
+ if (os === 'darwin') {
23
+ exec(
24
+ `osascript -e 'display notification "${safeMessage}" with title "${safeTitle}"'`,
25
+ () => {} // ignore errors — notification is best-effort
26
+ );
27
+ } else if (os === 'linux') {
28
+ exec(
29
+ `notify-send "${safeTitle}" "${safeMessage}" 2>/dev/null`,
30
+ () => {}
31
+ );
32
+ }
33
+ // windows + anything else — silent
34
+ }
package/src/scheduler.js CHANGED
@@ -1,11 +1,10 @@
1
1
  import { push, getUnpushedCount } from './git.js';
2
2
  import { logger } from './logger.js';
3
3
  import { writeState, readState } from './state.js';
4
+ import { notify } from './notify.js';
4
5
 
5
6
  // ─── scheduler ────────────────────────────────────────────────────────────────
6
7
 
7
- // maintains a set of repos that have unpushed commits and pushes them
8
- // on a fixed interval. safe to call multiple times — push is idempotent.
9
8
  export function createScheduler({ interval, onPushComplete }) {
10
9
  const pendingRepos = new Set();
11
10
 
@@ -21,13 +20,17 @@ export function createScheduler({ interval, onPushComplete }) {
21
20
  await push(dir);
22
21
  pendingRepos.delete(dir);
23
22
  await logger.push(`${dir}`);
24
-
25
- const state = await readState();
26
23
  await writeState({ lastPushedAt: new Date().toISOString() });
27
24
 
28
25
  if (onPushComplete) onPushComplete(dir);
29
26
  } catch (err) {
30
27
  await logger.error(`push failed for ${dir}: ${err.message}`);
28
+
29
+ // native OS notification — only fires on failure
30
+ notify(
31
+ 'gitwrit — Push failed',
32
+ 'Your commits are safe locally. Check your network or remote config.'
33
+ );
31
34
  }
32
35
  }
33
36
  }
@@ -35,12 +38,8 @@ export function createScheduler({ interval, onPushComplete }) {
35
38
  const timer = setInterval(flush, interval);
36
39
 
37
40
  return {
38
- // call this after every successful local commit
39
- queue: (dir) => pendingRepos.add(dir),
40
-
41
- // flush immediately (e.g. on wake from sleep)
42
- flushNow: () => flush(),
43
-
44
- stop: () => clearInterval(timer),
41
+ queue: (dir) => pendingRepos.add(dir),
42
+ flushNow: () => flush(),
43
+ stop: () => clearInterval(timer),
45
44
  };
46
45
  }
package/src/ui.js CHANGED
@@ -1,26 +1,31 @@
1
1
  import chalk from 'chalk';
2
2
 
3
+ // ─── palette ──────────────────────────────────────────────────────────────────
4
+
5
+ export const TEAL = '#2EC4B6';
6
+ export const PINK = '#F72585';
7
+
3
8
  // ─── sigil + brand ────────────────────────────────────────────────────────────
4
9
 
5
10
  export const SIGIL = '✦';
6
11
 
7
- export const brand = (msg) => chalk.white(`\n ${SIGIL} ${msg}`);
8
- export const info = (msg) => chalk.white(` ${msg}`);
9
- export const good = (msg) => chalk.green(` ✔ ${msg}`);
10
- export const bad = (msg) => chalk.red(` ✗ ${msg}`);
11
- export const warn = (msg) => chalk.yellow(` ⚠ ${msg}`);
12
- export const hint = (msg) => chalk.dim(` ${msg}`);
12
+ export const brand = (msg) => chalk.hex(TEAL)(`\n ${SIGIL} ${msg}`);
13
+ export const info = (msg) => chalk.white(` ${msg}`);
14
+ export const good = (msg) => chalk.hex(TEAL)(` ✔ ${msg}`);
15
+ export const bad = (msg) => chalk.red(` ✗ ${msg}`);
16
+ export const warn = (msg) => chalk.hex(PINK)(` ⚠ ${msg}`);
17
+ export const hint = (msg) => chalk.dim(` ${msg}`);
13
18
  export const divider = (label) =>
14
- chalk.dim(`\n ${'─'.repeat(41)}\n ${label}\n ${'─'.repeat(41)}`);
19
+ chalk.hex(TEAL).dim(`\n ${'─'.repeat(41)}\n `) +
20
+ chalk.hex(TEAL)(label) +
21
+ chalk.hex(TEAL).dim(`\n ${'─'.repeat(41)}`);
15
22
 
16
23
  // ─── key/value rows ───────────────────────────────────────────────────────────
17
24
 
18
- // prints a two-column row like:
19
- // watching ~/notes
20
25
  export function row(key, value, tag) {
21
26
  const k = chalk.dim(key.padEnd(14));
22
27
  const v = chalk.white(value);
23
- const t = tag ? chalk.dim(` (${tag})`) : '';
28
+ const t = tag ? chalk.hex(PINK).dim(` (${tag})`) : '';
24
29
  return ` ${k}${v}${t}`;
25
30
  }
26
31
 
@@ -47,8 +52,8 @@ export const print = {
47
52
  export function timeAgo(date) {
48
53
  if (!date) return 'never';
49
54
  const seconds = Math.floor((Date.now() - new Date(date)) / 1000);
50
- if (seconds < 10) return 'just now';
51
- if (seconds < 60) return `${seconds}s ago`;
55
+ if (seconds < 10) return 'just now';
56
+ if (seconds < 60) return `${seconds}s ago`;
52
57
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
53
58
  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
54
59
  return `${Math.floor(seconds / 86400)}d ago`;
@@ -57,7 +62,7 @@ export function timeAgo(date) {
57
62
  // ─── ms formatting ────────────────────────────────────────────────────────────
58
63
 
59
64
  export function formatMs(ms) {
60
- if (ms < 1000) return `${ms}ms`;
65
+ if (ms < 1000) return `${ms}ms`;
61
66
  if (ms < 60000) return `${ms / 1000}s`;
62
67
  return `${ms / 60000} min`;
63
68
  }
package/src/updater.js ADDED
@@ -0,0 +1,88 @@
1
+ import { readFile, writeFile, mkdir } from 'fs/promises';
2
+ import { join } from 'path';
3
+ import { GITWRIT_DIR } from './paths.js';
4
+ import { createRequire } from 'module';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname } from 'path';
7
+
8
+ // ─── current version ──────────────────────────────────────────────────────────
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const PKG = require(join(dirname(fileURLToPath(import.meta.url)), '../package.json'));
12
+ const CURRENT_VERSION = PKG.version;
13
+
14
+ // ─── cache ────────────────────────────────────────────────────────────────────
15
+
16
+ const CACHE_FILE = join(GITWRIT_DIR, 'update-check.json');
17
+ const CACHE_TTL = 1000 * 60 * 60 * 24; // 24 hours
18
+
19
+ async function readCache() {
20
+ try {
21
+ const raw = await readFile(CACHE_FILE, 'utf8');
22
+ return JSON.parse(raw);
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ async function writeCache(data) {
29
+ try {
30
+ await mkdir(GITWRIT_DIR, { recursive: true });
31
+ await writeFile(CACHE_FILE, JSON.stringify(data), 'utf8');
32
+ } catch {
33
+ // cache write failure is non-fatal
34
+ }
35
+ }
36
+
37
+ // ─── registry fetch ───────────────────────────────────────────────────────────
38
+
39
+ async function fetchLatestVersion() {
40
+ const res = await fetch('https://registry.npmjs.org/gitwrit/latest', {
41
+ signal: AbortSignal.timeout(3000), // 3s timeout — never block the user
42
+ });
43
+ if (!res.ok) throw new Error(`registry responded ${res.status}`);
44
+ const data = await res.json();
45
+ return data.version;
46
+ }
47
+
48
+ // ─── public api ───────────────────────────────────────────────────────────────
49
+
50
+ // returns the latest version string if an update is available,
51
+ // or null if up to date, offline, or the check fails for any reason.
52
+ // always resolves — never rejects.
53
+ export async function checkForUpdate() {
54
+ try {
55
+ const cache = await readCache();
56
+ const now = Date.now();
57
+
58
+ // use cached result if fresh
59
+ if (cache && (now - cache.checkedAt) < CACHE_TTL) {
60
+ return isNewer(cache.latestVersion, CURRENT_VERSION)
61
+ ? cache.latestVersion
62
+ : null;
63
+ }
64
+
65
+ // fetch fresh
66
+ const latestVersion = await fetchLatestVersion();
67
+ await writeCache({ latestVersion, checkedAt: now });
68
+
69
+ return isNewer(latestVersion, CURRENT_VERSION) ? latestVersion : null;
70
+ } catch {
71
+ return null; // offline, registry down, anything — silent
72
+ }
73
+ }
74
+
75
+ export function currentVersion() {
76
+ return CURRENT_VERSION;
77
+ }
78
+
79
+ // ─── semver comparison ────────────────────────────────────────────────────────
80
+
81
+ function isNewer(latest, current) {
82
+ const parse = v => v.replace(/^v/, '').split('.').map(Number);
83
+ const [lMaj, lMin, lPat] = parse(latest);
84
+ const [cMaj, cMin, cPat] = parse(current);
85
+ if (lMaj !== cMaj) return lMaj > cMaj;
86
+ if (lMin !== cMin) return lMin > cMin;
87
+ return lPat > cPat;
88
+ }