ticketlens 0.17.0 → 0.18.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/README.md CHANGED
@@ -400,6 +400,8 @@ Every note is scanned before saving — anything shaped like a real secret (API
400
400
 
401
401
  **Team sync:** on a Team plan with Recall enabled for your account (owner-managed, per-tier or per-client), notes also sync to your team's shared pool — `note add` pushes in the background, `recall` pulls the team's notes (cached 4h) before searching. A team manager reviews and verifies incoming notes at `console/admin/recall` before they're marked trusted. Without Team Recall entitlement, everything stays on your machine — no network call.
402
402
 
403
+ **Offline resilience:** if a team push fails for a transient reason (network error, timeout, or a 5xx from the backend), the note stays safely in your local vault and is queued for retry — nothing is lost. The queue flushes automatically in the background (at most once every 15 minutes, whenever `recall` or `note add` next talks to the network), or on demand with `ticketlens recall sync`. A session-expired (401) or not-entitled (403) push is never queued — those need you to act (`ticketlens login`, or an owner grant), not a retry. Queued entries expire after 30 days and are capped at 200; switching accounts never flushes a note under the wrong login.
404
+
403
405
  `note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead.
404
406
 
405
407
  **Gaps** — every `ticketlens PROJ-123` brief also diffs the ticket's own description against its linked tickets (from the depth traversal you already requested) and its own downloaded attachments, looking for requirements mentioned there but missing here. Anything uncovered shows up under a `## Gaps` section, citing exactly where it came from — a linked ticket key or an attachment filename — as evidence, never an instruction to act on. Nothing is saved anywhere; it's recomputed fresh on every fetch. Requires a Pro license, same as Recall. No network call beyond what the brief already made.
@@ -674,6 +676,7 @@ ticketlens history <TICKET-KEY> # Show urgency timeline for a tick
674
676
  echo "note body" | ticketlens note add --title="..." --ticket=CNV1-2 --tags=a,b # Save a note [Pro]
675
677
  ticketlens recall CNV1-2 # Search saved notes by ticket key [Pro]
676
678
  ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
679
+ ticketlens recall sync # Retry any notes stuck in the local queue [Pro]
677
680
 
678
681
  # ── Stats ──────────────────────────────────────────────────────────────────────
679
682
  ticketlens stats # Response-time metrics from local history
@@ -752,6 +755,7 @@ ticketlens triage --digest # POST scored triage results to digest
752
755
  ticketlens schedule # Set up a scheduled daily digest
753
756
  ticketlens note add --title="..." # Save a Recall note (body from stdin)
754
757
  ticketlens recall <query|TICKET-KEY> # Search your saved Recall notes
758
+ ticketlens recall sync # Retry any notes stuck in the local queue
755
759
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
756
760
  ```
757
761
 
@@ -637,6 +637,16 @@ switch (command) {
637
637
 
638
638
  case 'recall': {
639
639
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printRecallHelp(); break; }
640
+ if (cmdArgs[0] === 'sync') {
641
+ const { runRecallSync } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
642
+ runRecallSync(cmdArgs.slice(1)).then(({ ok }) => {
643
+ if (!ok) process.exitCode = 1;
644
+ }).catch(err => {
645
+ process.stderr.write(`Error: ${err.message}\n`);
646
+ process.exitCode = 1;
647
+ });
648
+ break;
649
+ }
640
650
  const { runRecall } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
641
651
  runRecall(cmdArgs).then(({ ok }) => {
642
652
  if (!ok) process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.17.0 -->
1
+ <!-- jtb-skill-version: 0.18.0 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -53,6 +53,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
53
53
  /jtb cloud-keys timeout anthropic 15 # set per-request timeout in seconds
54
54
  /jtb note "gotcha text" --ticket=PROD-1234 # save a Recall note (Pro)
55
55
  /jtb recall PROD-1234 # search saved Recall notes (Pro)
56
+ /jtb recall sync # retry any notes stuck in the local queue (Pro)
56
57
  ```
57
58
 
58
59
  ## Prerequisites
@@ -241,7 +242,7 @@ This never calls any external API or bills any tokens beyond the session you alr
241
242
  **Known limitation:** `note patch` only updates the local vault copy. If `note add` already pushed the original draft to a team (Team Recall enabled), a later refinement from this loop is *not* re-pushed — teammates who already pulled the note keep the original draft until this is addressed in a future iteration.
242
243
 
243
244
  ### Privacy
244
- Recall notes are stored locally at `~/.ticketlens/recall/`. On a Free/Pro account with no Team Recall entitlement, they never leave the machine — no network calls. On a Team account with Recall enabled (owner-managed, may vary per user), notes also sync to the team's shared pool in the background so teammates can benefit from them too; a team manager reviews and verifies each incoming note before it's marked trusted.
245
+ Recall notes are stored locally at `~/.ticketlens/recall/`. On a Free/Pro account with no Team Recall entitlement, they never leave the machine — no network calls. On a Team account with Recall enabled (owner-managed, may vary per user), notes also sync to the team's shared pool in the background so teammates can benefit from them too; a team manager reviews and verifies each incoming note before it's marked trusted. If a team push fails for a transient reason (network error, timeout, 5xx), the note is queued locally and retried automatically in the background, or on demand with `ticketlens recall sync` — a session-expired or not-entitled push is never queued, since retrying those can't succeed without the user acting first.
245
246
 
246
247
  ---
247
248
 
@@ -536,12 +536,17 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
536
536
  ` ${s.brand('--full')} Print each matching note's full body content`,
537
537
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
538
538
  '',
539
+ ` ${s.bold('COMMANDS')}`,
540
+ '',
541
+ ` ${s.brand('sync')} Manually retry any team-synced notes stuck in the local retry queue ${s.dim('[Pro, requires login]')}`,
542
+ '',
539
543
  ` ${s.bold('EXAMPLES')}`,
540
544
  '',
541
545
  ` ${s.dim('$')} ticketlens recall PROD-123`,
542
546
  ` ${s.dim('$')} ticketlens recall "retry backoff"`,
543
547
  ` ${s.dim('$')} ticketlens recall PROD-123 --plain`,
544
548
  ` ${s.dim('$')} ticketlens recall PROD-123 --full`,
549
+ ` ${s.dim('$')} ticketlens recall sync`,
545
550
  '',
546
551
  ];
547
552
  stream.write(lines.join('\n') + '\n');
@@ -14,6 +14,7 @@ import { checkNoteStructure } from './note-structural-check.mjs';
14
14
  import { writeNote, patchNoteBody } from './recall-vault.mjs';
15
15
  import { readCliToken } from './cli-auth.mjs';
16
16
  import { pushNote } from './recall-sync.mjs';
17
+ import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
17
18
  import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mjs';
18
19
  import { extractText } from './attachment-text.mjs';
19
20
  import { TICKET_KEY_PATTERN } from './cli.mjs';
@@ -72,6 +73,9 @@ export async function runNoteAdd(cmdArgs, {
72
73
  writeNoteFn = writeNote,
73
74
  readCliTokenFn = readCliToken,
74
75
  pushNoteFn = pushNote,
76
+ enqueueNoteFn = enqueueNote,
77
+ isRetryableFailureFn = isRetryableFailure,
78
+ maybeAutoFlushFn = maybeAutoFlush,
75
79
  incrementDraftKeptFn = incrementDraftKept,
76
80
  incrementDraftDeletedFn = incrementDraftDeleted,
77
81
  listAttachmentsFn = defaultListAttachments,
@@ -131,12 +135,15 @@ export async function runNoteAdd(cmdArgs, {
131
135
 
132
136
  const cliToken = readCliTokenFn(configDir);
133
137
  if (cliToken) {
138
+ const warn = (s) => stream.write(s);
134
139
  // Field names match PushRequest's validation rules (external_id, tickets) —
135
140
  // the backend wire contract, not the local vault's internal camelCase shape.
136
- await pushNoteFn(
137
- { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body },
138
- { cliToken, configDir, warn: (s) => stream.write(s) },
139
- );
141
+ const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body };
142
+ const result = await pushNoteFn(payload, { cliToken, configDir, warn });
143
+ if (isRetryableFailureFn(result)) {
144
+ enqueueNoteFn(payload, { cliToken, configDir, warn });
145
+ }
146
+ await maybeAutoFlushFn({ cliToken, configDir });
140
147
  }
141
148
 
142
149
  return { written: true };
@@ -12,6 +12,7 @@ import { isLicensed, showUpgradePrompt } from './license.mjs';
12
12
  import { listNotes } from './recall-vault.mjs';
13
13
  import { readCliToken } from './cli-auth.mjs';
14
14
  import { pullNotes } from './recall-sync.mjs';
15
+ import { maybeAutoFlush, flushQueue, readQueue } from './recall-queue.mjs';
15
16
  import { styleRecallResults } from './styled-assembler.mjs';
16
17
 
17
18
  /**
@@ -26,6 +27,7 @@ export async function runRecall(cmdArgs, {
26
27
  listNotesFn = listNotes,
27
28
  readCliTokenFn = readCliToken,
28
29
  pullNotesFn = pullNotes,
30
+ maybeAutoFlushFn = maybeAutoFlush,
29
31
  } = {}) {
30
32
  if (!isLicensedFn('pro', configDir)) {
31
33
  showUpgradePrompt('pro', 'ticketlens recall', { stream: errorStream });
@@ -45,6 +47,7 @@ export async function runRecall(cmdArgs, {
45
47
  configDir,
46
48
  ...(cmdArgs.includes('--no-cache') && { ttlMs: 0 }),
47
49
  });
50
+ await maybeAutoFlushFn({ cliToken, configDir });
48
51
  }
49
52
 
50
53
  const filter = TICKET_KEY_PATTERN.test(arg) ? { ticketKey: arg } : { query: arg };
@@ -55,3 +58,41 @@ export async function runRecall(cmdArgs, {
55
58
  stream.write(styleRecallResults(results, { styled, full }) + '\n');
56
59
  return { ok: true };
57
60
  }
61
+
62
+ /**
63
+ * Implements `tl recall sync` — manually flushes the local retry queue
64
+ * (notes whose team push previously failed for a transient reason). Unlike
65
+ * the auto-flush attempted from `runRecall`/`note add`, this is an explicit
66
+ * user action, so failures are reported visibly rather than staying silent.
67
+ *
68
+ * @param {string[]} cmdArgs
69
+ * @returns {Promise<{ ok: boolean }>}
70
+ */
71
+ export async function runRecallSync(cmdArgs, {
72
+ configDir = DEFAULT_CONFIG_DIR,
73
+ stream = process.stdout,
74
+ isLicensedFn = isLicensed,
75
+ readCliTokenFn = readCliToken,
76
+ readQueueFn = readQueue,
77
+ flushQueueFn = flushQueue,
78
+ } = {}) {
79
+ if (!isLicensedFn('pro', configDir)) {
80
+ showUpgradePrompt('pro', 'ticketlens recall', { stream });
81
+ return { ok: false };
82
+ }
83
+
84
+ const cliToken = readCliTokenFn(configDir);
85
+ if (!cliToken) {
86
+ stream.write('Not logged in — run `ticketlens login` first.\n');
87
+ return { ok: false };
88
+ }
89
+
90
+ if (readQueueFn(configDir).length === 0) {
91
+ stream.write('Nothing to sync.\n');
92
+ return { ok: true };
93
+ }
94
+
95
+ const { flushed, remaining } = await flushQueueFn({ cliToken, configDir, warn: (s) => stream.write(s) });
96
+ stream.write(`Synced ${flushed} note(s). ${remaining} still pending.\n`);
97
+ return { ok: true };
98
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Local retry queue for Recall notes whose push to the team backend failed
3
+ * for a transient reason (network error, timeout, or 5xx). A note is never
4
+ * lost — it's already safe in the local vault (recall-vault.mjs) before this
5
+ * module ever sees it; this only tracks the separate, best-effort intent to
6
+ * also sync it to the team.
7
+ *
8
+ * Retry classification happens once, at the moment the original push fails
9
+ * (isRetryableFailure) — 401/403/other-4xx are deliberately excluded: a
10
+ * stale session or a doomed payload will never succeed by retrying, and
11
+ * pushNote already warns the user about those synchronously.
12
+ *
13
+ * Growth is bounded two independent ways: a hard cap on entry count, and an
14
+ * age-based expiry keyed off firstQueuedAt (not failedAt, which refreshes on
15
+ * every retry attempt and would otherwise let a perpetually-failing entry
16
+ * live forever).
17
+ *
18
+ * enqueueNote/flushQueue do read-modify-write on the queue file with no file
19
+ * lock — same tradeoff already accepted by recall-pull-state.json and
20
+ * recall-entitlement-state.json in recall-sync.mjs. Two concurrent CLI
21
+ * invocations racing this file can lose one writer's update. Bounded,
22
+ * accepted risk: this queue is a retry-intent cache, not the source of truth
23
+ * (the note itself is already safe in the vault before it ever reaches here)
24
+ * — not worth a lock for a low-frequency, single-user CLI tool.
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
30
+ import { writeFileAtomically } from './recall-vault.mjs';
31
+ import { pushNote, hashToken } from './recall-sync.mjs';
32
+
33
+ const QUEUE_FILE = 'recall-pending.json';
34
+ const FLUSH_STATE_FILE = 'recall-flush-state.json';
35
+
36
+ export const MAX_QUEUE_SIZE = 200;
37
+ export const MAX_ENTRY_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
38
+ export const AUTO_FLUSH_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
39
+
40
+ function queuePath(configDir) {
41
+ return path.join(configDir, QUEUE_FILE);
42
+ }
43
+
44
+ function flushStatePath(configDir) {
45
+ return path.join(configDir, FLUSH_STATE_FILE);
46
+ }
47
+
48
+ /**
49
+ * @param {string} configDir
50
+ * @returns {Array<{id: string, notePayload: object, tokenHash: string, firstQueuedAt: string, failedAt: string, attempts: number}>}
51
+ */
52
+ export function readQueue(configDir) {
53
+ try {
54
+ const parsed = JSON.parse(fs.readFileSync(queuePath(configDir), 'utf8'));
55
+ return Array.isArray(parsed) ? parsed : [];
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ function writeQueue(configDir, entries) {
62
+ writeFileAtomically(queuePath(configDir), JSON.stringify(entries));
63
+ }
64
+
65
+ function purgeExpired(entries, now) {
66
+ return entries.filter(entry => now - new Date(entry.firstQueuedAt).getTime() <= MAX_ENTRY_AGE_MS);
67
+ }
68
+
69
+ /**
70
+ * Decides whether a failed push is worth retrying later. Network errors and
71
+ * timeouts (pushNote returns no `status` for these) and 5xx responses are
72
+ * transient. 401 (session expired), 403 (not entitled / no team), and any
73
+ * other 4xx (e.g. a validation failure) are not — retrying cannot fix them
74
+ * without the user acting, or would just retry a payload that will never be
75
+ * accepted.
76
+ *
77
+ * @param {{ ok: boolean, status?: number }} result
78
+ * @returns {boolean}
79
+ */
80
+ export function isRetryableFailure(result) {
81
+ return !result.ok && (result.status === undefined || result.status >= 500);
82
+ }
83
+
84
+ /**
85
+ * Queues a note for later retry after a transient push failure. Purges
86
+ * expired entries first, then evicts the oldest entry (with a single warn)
87
+ * if appending would exceed MAX_QUEUE_SIZE.
88
+ *
89
+ * @param {object} notePayload - exact wire payload passed to pushNote
90
+ * @param {object} opts
91
+ * @param {string} opts.cliToken
92
+ * @param {string} [opts.configDir]
93
+ * @param {() => number} [opts.now]
94
+ * @param {Function} [opts.warn]
95
+ */
96
+ export function enqueueNote(notePayload, {
97
+ cliToken,
98
+ configDir = DEFAULT_CONFIG_DIR,
99
+ now = () => Date.now(),
100
+ warn = (s) => process.stderr.write(s),
101
+ } = {}) {
102
+ const nowMs = now();
103
+ let entries = purgeExpired(readQueue(configDir), nowMs);
104
+
105
+ if (entries.length >= MAX_QUEUE_SIZE) {
106
+ entries = entries.slice(1);
107
+ warn(' Recall queue full — dropped the oldest queued note to make room.\n');
108
+ }
109
+
110
+ const nowIso = new Date(nowMs).toISOString();
111
+ entries.push({
112
+ id: notePayload.external_id,
113
+ notePayload,
114
+ tokenHash: hashToken(cliToken),
115
+ firstQueuedAt: nowIso,
116
+ failedAt: nowIso,
117
+ attempts: 0,
118
+ });
119
+
120
+ writeQueue(configDir, entries);
121
+ }
122
+
123
+ /**
124
+ * Attempts to push every queued entry belonging to the current account
125
+ * (matched by tokenHash). Entries queued under a different account are left
126
+ * untouched — never attempted, never evicted by this pass. Expired entries
127
+ * are purged first, regardless of tokenHash.
128
+ *
129
+ * @param {object} opts
130
+ * @param {string} opts.cliToken
131
+ * @param {string} [opts.configDir]
132
+ * @param {Function} [opts.pushNoteFn]
133
+ * @param {Function} [opts.warn] - defaults to silent; callers doing a visible/manual sync should pass a real one
134
+ * @param {() => number} [opts.now]
135
+ * @returns {Promise<{ flushed: number, remaining: number }>}
136
+ */
137
+ export async function flushQueue({
138
+ cliToken,
139
+ configDir = DEFAULT_CONFIG_DIR,
140
+ pushNoteFn = pushNote,
141
+ isRetryableFailureFn = isRetryableFailure,
142
+ warn = () => {},
143
+ now = () => Date.now(),
144
+ } = {}) {
145
+ const nowMs = now();
146
+ const currentHash = hashToken(cliToken);
147
+ const entries = purgeExpired(readQueue(configDir), nowMs);
148
+
149
+ let flushed = 0;
150
+ const remaining = [];
151
+ for (const entry of entries) {
152
+ if (entry.tokenHash !== currentHash) {
153
+ remaining.push(entry);
154
+ continue;
155
+ }
156
+
157
+ const result = await pushNoteFn(entry.notePayload, { cliToken, configDir, warn });
158
+ if (result.ok) {
159
+ flushed++;
160
+ continue;
161
+ }
162
+
163
+ // A retry can surface a DIFFERENT failure than the one that originally
164
+ // queued this entry (e.g. the session expired between enqueue and this
165
+ // attempt) — reclassify every time rather than trusting the original
166
+ // enqueue decision, so a now-unrecoverable entry is dropped immediately
167
+ // instead of silently retrying for up to MAX_ENTRY_AGE_MS.
168
+ if (!isRetryableFailureFn(result)) continue;
169
+
170
+ remaining.push({ ...entry, attempts: entry.attempts + 1, failedAt: new Date(nowMs).toISOString() });
171
+ }
172
+
173
+ writeQueue(configDir, remaining);
174
+ return { flushed, remaining: remaining.length };
175
+ }
176
+
177
+ function readLastFlushAttemptAt(configDir) {
178
+ try {
179
+ return JSON.parse(fs.readFileSync(flushStatePath(configDir), 'utf8')).lastAttemptAt ?? null;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function writeLastFlushAttemptAt(configDir, isoTimestamp) {
186
+ try {
187
+ fs.writeFileSync(flushStatePath(configDir), JSON.stringify({ lastAttemptAt: isoTimestamp }), 'utf8');
188
+ } catch {
189
+ // Non-fatal — worst case the next command re-checks a moment sooner than the interval intends.
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Time-gated background flush, attempted from the CLI's existing
195
+ * Recall-touching entry points (note add's push, recall's pull) rather than
196
+ * on every invocation — a no-op unless the queue is non-empty AND at least
197
+ * AUTO_FLUSH_INTERVAL_MS has passed since the last attempt. The attempt
198
+ * timestamp is recorded even on failure, so a down backend can't be hammered
199
+ * once per command within the window.
200
+ *
201
+ * @param {object} opts
202
+ * @param {string} opts.cliToken
203
+ * @param {string} [opts.configDir]
204
+ * @param {() => number} [opts.now]
205
+ * @param {Function} [opts.flushQueueFn]
206
+ */
207
+ export async function maybeAutoFlush({
208
+ cliToken,
209
+ configDir = DEFAULT_CONFIG_DIR,
210
+ now = () => Date.now(),
211
+ flushQueueFn = flushQueue,
212
+ } = {}) {
213
+ if (readQueue(configDir).length === 0) return;
214
+
215
+ const lastAttemptAt = readLastFlushAttemptAt(configDir);
216
+ const nowMs = now();
217
+ if (lastAttemptAt && nowMs - new Date(lastAttemptAt).getTime() < AUTO_FLUSH_INTERVAL_MS) return;
218
+
219
+ try {
220
+ await flushQueueFn({ cliToken, configDir, now });
221
+ } catch {
222
+ // A down backend or a thrown network error must never crash the command
223
+ // that opportunistically triggered this background attempt.
224
+ } finally {
225
+ writeLastFlushAttemptAt(configDir, new Date(nowMs).toISOString());
226
+ }
227
+ }
@@ -56,7 +56,7 @@ function entitlementCachePath(configDir) {
56
56
  // switch (e.g. login as someone else) so a stale cache never suppresses a
57
57
  // different account's push. Found via Local Live Test: configDir alone is
58
58
  // not a valid cache key — cli-token.json can change without configDir changing.
59
- function hashToken(cliToken) {
59
+ export function hashToken(cliToken) {
60
60
  return createHash('sha256').update(cliToken).digest('hex');
61
61
  }
62
62
 
@@ -107,7 +107,11 @@ export async function pushNote(note, {
107
107
  // flips the Recall grant — without this, the warning below repeats forever.
108
108
  const checkedAt = readEntitlementCheckedAt(configDir, cliToken);
109
109
  if (checkedAt && now() - new Date(checkedAt).getTime() < ttlMs) {
110
- return { ok: false, skipped: true };
110
+ // status: 403 — this is a cached replay of the entitlement 403 below, not a
111
+ // fresh network outcome. Without it, isRetryableFailure's "no status means
112
+ // network error" rule misclassifies this as transient and queues a note
113
+ // that can never succeed until the owner grants entitlement.
114
+ return { ok: false, status: 403, skipped: true };
111
115
  }
112
116
 
113
117
  warnIfInsecure(apiBase(), warn);
@@ -49,7 +49,7 @@ function generateNoteId() {
49
49
  return `${Date.now()}-${randomBytes(3).toString('hex')}.md`;
50
50
  }
51
51
 
52
- function writeFileAtomically(filePath, contents) {
52
+ export function writeFileAtomically(filePath, contents) {
53
53
  const tmpPath = `${filePath}.${process.pid}.tmp`;
54
54
  fs.writeFileSync(tmpPath, contents, 'utf8');
55
55
  fs.renameSync(tmpPath, filePath);