@lorekit/cli 1.51.0 → 1.52.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
@@ -597,7 +597,7 @@ erases your cross-repo home lesson.
597
597
  > sharing comes from the account/token, so remote needs no second location.
598
598
  > Different mechanism, same concept.
599
599
 
600
- ### `lorekit migrate` — relocation / rename tool
600
+ ### `lorekit migrate` — relocate a store, or push one to the hosted store
601
601
 
602
602
  Moved or renamed a local store (e.g. an old `.lore/`)? `migrate` re-writes its
603
603
  entries into the current two-tier layout so lessons are never stranded:
@@ -612,6 +612,80 @@ Dry-run (preview) by default; `--yes` (or `--apply`) applies. Idempotent — a
612
612
  re-run is a no-op. It reads LoreKit's own on-disk format only (it does **not**
613
613
  import persistent-memory's `~/.agent-memory/<bucket>/` format).
614
614
 
615
+ #### `--to remote` — bring your local lessons up
616
+
617
+ Started offline and later connected a token? `--to remote` pushes the whole
618
+ local store to the hosted one in a single command, instead of the agent
619
+ re-writing lessons one at a time in remote mode:
620
+
621
+ ```bash
622
+ lorekit migrate --from .lorekit --to remote # dry-run: the plan, per scope
623
+ lorekit migrate --from .lorekit --to remote --yes # push it
624
+ ```
625
+
626
+ The connection and token come from the same place every other command reads
627
+ them (`.mcp.json` / `.lorekit.json` / `LOREKIT_MCP_URL` + `LOREKIT_TOKEN`), so
628
+ a `deny: remote` constraint still wins. Everything is checked **before** the
629
+ first request rather than mid-push:
630
+
631
+ | Condition | Result |
632
+ |---|---|
633
+ | No usable connection | Fails with the `lorekit install` command to run |
634
+ | Read-only `lk_ro_*` token | Fails — a migration writes |
635
+ | Write-only `lk_wo_*` token | Warns, then pushes without reading: its reads are denied, so the destination is not classified at all and every entry reports as `add` (the hosted upsert is idempotent either way) |
636
+ | Unrecognized token prefix | Warns and proceeds, so a self-hosted or custom token still works |
637
+ | `deny: remote` | Fails, naming the config source that denied it |
638
+
639
+ **What transfers, and what the server owns.** `scope`, `key`, `value`,
640
+ `source_agent` and `trigger` travel as the source states them (`value` is
641
+ trimmed server-side), and the original creation date travels as `created_at` —
642
+ so a migrated lesson keeps the recency its ranking depends on. The rest is not
643
+ verbatim, and every departure is reported per entry, in the dry run as well as
644
+ the apply:
645
+
646
+ | Field | What happens |
647
+ |---|---|
648
+ | `updated` | Re-stamped at the write instant — there is no parameter for it |
649
+ | `seen_count` | Starts at 1 for a key the hosted store has never seen; one it already holds lands at ITS count plus one (the RPC treats a write as a sighting, migration 00059). A local tally of 12 never transfers |
650
+ | `tags` | **Replace** the hosted row's labels, so an untagged local entry clears them |
651
+ | `origin_*` | **Sticky** — the RPC coalesces provenance, so an entry carrying none leaves whatever the hosted row already had |
652
+ | `created` | Honoured only when the lesson is new to the hosted store (the RPC's update clause omits it, so one already there keeps its hosted date); an unusable or future-dated value is dropped for the write instant, and the entry is named |
653
+ | `expires_at` | Converted to the remaining whole `ttl_days`; anything beyond the API's 365-day cap lands shortened |
654
+
655
+ **Archived and expired entries are skipped** and reported as such. Neither can
656
+ be represented: a write against an archived key inserts a second, live row
657
+ beside it rather than reviving it, and any TTL would re-date an expired row
658
+ into the future — so pushing them would resurrect lore you retired.
659
+
660
+ Idempotent, with one nuance: a re-run compares only what a hosted write can
661
+ change, and compares it the way the server stores it — trimmed `value`, only
662
+ the provenance the source carries, and an expiry that still honours the local
663
+ intent (the hosted one is fixed at push time while the local one is measured
664
+ from now, so it is judged on whether at least half the intended life remains —
665
+ matching them instant-for-instant could never converge). Comparing the server-owned fields
666
+ directly would report a change forever and re-push the whole store on every
667
+ run. A remote `unchanged` therefore means "the hosted lesson already says
668
+ this", not "byte-identical row".
669
+
670
+ Rate limits and transient failures are handled rather than surfaced as
671
+ failures. The run paces itself under the hosted 120 req/min limit (and says so
672
+ the first time it has to wait), and retries a `429` on the server's own
673
+ `Retry-After` — plus a 5xx or a dropped connection, which on a push of
674
+ thousands of requests is the likeliest failure and the least worth losing an
675
+ entry to. Any other 4xx is a decision, not a blip, and is never retried.
676
+
677
+ Retrying is bounded, so none of this should be read as "it never fails": an
678
+ entry that exhausts its attempts is reported and the run exits non-zero, and
679
+ five CONSECUTIVE failures of any kind stop the run early with a
680
+ partial-progress report — an outage is not worth grinding a whole store through
681
+ the retry budget for. Every one of those exits is safe to re-run. The memory cap ([5,000 active memories on the
682
+ free plan](../../docs/limits.md)) is returned as a `429` too but is terminal —
683
+ the run stops, reports how many entries landed, and exits non-zero, so you can
684
+ archive or upgrade and re-run to resume.
685
+
686
+ Not in v1: `--org <slug>` org-owned writes (a migration lands as your personal
687
+ lore) and the reverse remote → local direction.
688
+
615
689
  ### The control model — two layers, deny-wins
616
690
 
617
691
  Two config layers decide the mode:
@@ -870,7 +944,7 @@ also returns their headroom against the plan's memory cap.
870
944
  | `--mode <mode>` | Memory mode override for `doctor`: `off` / `local` / `remote` |
871
945
  | `--store <path>` | Local project-tier store directory (default `.lorekit`) |
872
946
  | `--from <path>` | Source store to migrate from (`migrate`) |
873
- | `--to <tier>` | Migration destination tier: `home` / `project` (`migrate`; default routes by scope) |
947
+ | `--to <dest>` | Migration destination: `home` / `project` / `remote` (`migrate`; default routes by scope across the local tiers) |
874
948
  | `--apply` | Apply the migration — alias of `--yes` (`migrate`) |
875
949
  | `-y, --yes` | Non-interactive / apply; never prompt |
876
950
  | `--hooks <mode>` | Lifecycle hooks to wire: `all` / `read-only` / `none` (`install`; `none` removes any already wired) |
package/bin/lorekit.mjs CHANGED
@@ -94,7 +94,8 @@ ${c.bold('Commands')}
94
94
  bootstrap Apply the BYOD schema to a user-supplied Supabase database.
95
95
  Only needed when using LOREKIT_STORAGE_URL / LOREKIT_STORAGE_ANON_KEY.
96
96
  See docs/byod.md for setup instructions.
97
- migrate Relocate a LoreKit-format local store into the current layout.
97
+ migrate Relocate a LoreKit-format local store into the current layout,
98
+ or push it up to the hosted store with --to remote.
98
99
  Dry-run by default; pass --yes to apply. Idempotent.
99
100
  hook Hook engine for Claude Code / Cursor / Codex. Reads the host's
100
101
  JSON on stdin and injects memories or a retrospective nudge.
@@ -121,8 +122,8 @@ ${c.bold('Options')}
121
122
  --base <url> Dashboard base URL for deep links (link / --link; else LOREKIT_APP_URL, default https://lorekit.io)
122
123
  --threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
123
124
  --from <path> Source store to migrate from (migrate)
124
- --to <tier> Migration destination tier: home | project (migrate;
125
- default routes each entry by scope)
125
+ --to <dest> Migration destination: home | project | remote (migrate;
126
+ default routes each entry by scope across the local tiers)
126
127
  --apply Apply the migration (alias of --yes) (migrate)
127
128
  -y, --yes Non-interactive / apply; never prompt
128
129
  --hooks <mode> Lifecycle hooks to wire: all | read-only | none (install)
@@ -158,6 +159,7 @@ ${c.bold('Examples')}
158
159
  npx @lorekit/cli doctor --deep
159
160
  npx @lorekit/cli migrate --from .lore # preview a rename
160
161
  npx @lorekit/cli migrate --from .lore --to project --yes
162
+ npx @lorekit/cli migrate --from .lorekit --to remote --yes # push local lore up
161
163
 
162
164
  Run ${c.cyan('lorekit <command> --help')} for command-specific options.
163
165
  `;
@@ -603,7 +605,7 @@ ${c.bold('Examples')}
603
605
  npx @lorekit/cli url --q "flaky test" --owner personal # search + ownership filter
604
606
  npx @lorekit/cli link global --tags "perf,ci" # Explorer filtered to labels
605
607
  `,
606
- migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
608
+ migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store, or push it to the hosted store
607
609
 
608
610
  ${c.bold('Usage')}
609
611
  npx @lorekit/cli migrate --from <path> [options]
@@ -613,13 +615,34 @@ Dry-run by default; pass --yes (or --apply) to write. Idempotent.
613
615
  ${c.bold('Options')}
614
616
  -d, --dir <path> Target project root (default: current directory)
615
617
  --from <path> Source store to migrate from (required)
616
- --to <tier> Destination tier: home | project (default routes by scope)
618
+ --to <dest> Destination: home | project | remote (default routes by
619
+ scope across the local tiers)
617
620
  --apply Apply the migration (alias of --yes)
618
621
  -y, --yes Apply the migration; never prompt
619
622
 
623
+ ${c.bold('--to remote')}
624
+ Pushes every entry in the source store up to the hosted store, over the
625
+ connection and token \`lorekit install\` configured (\`--endpoint\` / \`--token\`
626
+ override both). A read-only \`lk_ro_*\` token is rejected before anything is
627
+ written; an unrecognized prefix only warns and proceeds, so a self-hosted or
628
+ custom token still works.
629
+
630
+ What the hosted store does NOT take verbatim:
631
+ - archived and expired entries are skipped — a write would insert a second,
632
+ live row beside the archived one, and any TTL would re-date an expired one
633
+ - \`tags\` REPLACE the hosted row's labels, so an untagged local entry clears
634
+ them
635
+ - a creation date is honoured only when the lesson is new to the hosted
636
+ store, and an unusable one is dropped for the write instant
637
+ - \`updated\` and the \`seen_count\` tally are re-derived by the server, and a
638
+ TTL beyond 365 days is shortened
639
+ Every one of those is reported per entry, in the dry run as well as the apply.
640
+
620
641
  ${c.bold('Examples')}
621
642
  npx @lorekit/cli migrate --from .lore # preview a rename
622
643
  npx @lorekit/cli migrate --from .lore --to project --yes
644
+ npx @lorekit/cli migrate --from .lorekit --to remote # preview the push
645
+ npx @lorekit/cli migrate --from .lorekit --to remote --yes # push local lore up
623
646
  `,
624
647
  hook: `${c.bold('lorekit hook')} — hook engine for Claude Code / Cursor / Codex
625
648
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.51.0",
3
+ "version": "1.52.1",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/migrate.mjs CHANGED
@@ -1,22 +1,36 @@
1
- // `lorekit migrate --from <path> [--to home|project] [--yes|--apply]`
1
+ // `lorekit migrate --from <path> [--to home|project|remote] [--yes|--apply]`
2
2
  //
3
3
  // Relocation / rename tool — NOT a persistent-memory importer. It reads a
4
4
  // LoreKit-format local store at <path> (e.g. an old `.lore/` directory, or a
5
5
  // store that was moved elsewhere) and re-writes its entries into the resolved
6
6
  // current-layout store(s), so lessons are never stranded by a rename or a move.
7
7
  //
8
+ // `--to remote` is the one destination that leaves the machine: it pushes the
9
+ // source store up to the hosted store, which is the local→remote transition a
10
+ // user who started offline and then connected a token had no bulk path for.
11
+ // Everything else about the command is unchanged by it — same dry-run default,
12
+ // same per-scope report, same idempotency — because the destination is just
13
+ // another store behind the same `getEntry`/`putEntry` pair.
14
+ //
8
15
  // Dry-run (preview) by default: it prints what would move, per scope, and
9
16
  // changes nothing. Only `--yes` (or `--apply`) mutates. Idempotent: entries are
10
17
  // upserted verbatim by scope+key, so a re-run is all NOOP.
11
18
  //
12
19
  // Out of scope: reading persistent-memory's `~/.agent-memory/<bucket>/INDEX.md`
13
20
  // + `entries/` format. This tool only understands LoreKit's own on-disk format.
21
+ // Also out of scope: the reverse direction (remote → local) and `--org <slug>`
22
+ // org-owned writes; a v1 migration always lands as the caller's personal lore.
14
23
  import fs from 'node:fs';
15
24
  import path from 'node:path';
16
- import { resolveProjectRoot } from './config.mjs';
17
- import { localStoreDirs } from './control.mjs';
18
- import { createLocalStore, createTwoTierStore } from './store/index.mjs';
25
+ import { resolveProjectRoot, tokenKind } from './config.mjs';
26
+ import { localStoreDirs, loadControl } from './control.mjs';
27
+ import { createLocalStore, createTwoTierStore, createRemoteStore } from './store/index.mjs';
19
28
  import { parseEntry } from './store/format.mjs';
29
+ import { isLive, TTL_MAX_DAYS } from './store/ttl.mjs';
30
+ import { remoteWriteLosses } from './store/remote.mjs';
31
+ import {
32
+ createPacer, withRetry, isMemoryCap, sleep, DEFAULT_CONSECUTIVE_FAILURE_LIMIT,
33
+ } from './store/rate-limit.mjs';
20
34
  import { log, heading, status, err, c } from './util.mjs';
21
35
 
22
36
  // Recursively collect every parseable LoreKit entry under a base dir. The
@@ -65,7 +79,206 @@ function sameEntry(a, b) {
65
79
  return norm(a) === norm(b);
66
80
  }
67
81
 
68
- export async function migrate(args) {
82
+ // The remote counterpart of `sameEntry`, and deliberately a DIFFERENT one —
83
+ // not weaker, but mirrored against what the hosted write actually stores.
84
+ //
85
+ // It compares only the fields a hosted write can actually change. Everything
86
+ // else the row carries is server-owned, and including any of it would make the
87
+ // command permanently non-idempotent rather than more accurate:
88
+ //
89
+ // `created_at` is honoured on INSERT only, so a row that already exists with
90
+ // a different creation date can never be made to match — every
91
+ // re-run would report UPDATE and re-push the whole store.
92
+ // `updated_at` is stamped at the write instant, so it differs by definition
93
+ // the moment the comparison runs.
94
+ // `expires_at` is recomputed from `ttl_days` at write time, so the two
95
+ // instants drift apart the moment they agree. What is
96
+ // comparable is whether the hosted expiry SATISFIES the local
97
+ // one — see `sameRemoteTtl`.
98
+ //
99
+ // A remote NOOP therefore means "the hosted lesson already says this", not
100
+ // "the two rows are byte-identical". That is the honest guarantee, and it is
101
+ // the one that makes a second `--yes` a no-op.
102
+ function sameRemoteEntry(current, entry, now = new Date()) {
103
+ if (!current || !entry) return false;
104
+ // The hosted write TRIMS `value` (`MemoryWriteSchema`), so comparing the
105
+ // untrimmed local text against the trimmed hosted one would report UPDATE
106
+ // for a padded entry on every single run and re-push it forever.
107
+ const value = (e) => (e.value == null ? '' : String(e.value)).trim();
108
+ const scalar = (e) =>
109
+ JSON.stringify({
110
+ tags: [...(e.tags || [])].sort(),
111
+ source_agent: e.source_agent ?? null,
112
+ trigger: e.trigger ?? null,
113
+ value: value(e),
114
+ });
115
+ if (scalar(current) !== scalar(entry)) return false;
116
+
117
+ // Provenance is COALESCED server-side, so a local entry that knows nothing
118
+ // about a field can never make the hosted row forget it. Comparing the two
119
+ // directly would report UPDATE forever; the honest question is whether the
120
+ // fields this entry DOES carry already match.
121
+ for (const f of ['origin_repo', 'origin_branch', 'origin_commit', 'origin_pr']) {
122
+ if (entry[f] == null) continue;
123
+ if (String(current[f] ?? '') !== String(entry[f])) return false;
124
+ }
125
+
126
+ return sameRemoteTtl(current.expires_at, entry.expires_at, now);
127
+ }
128
+
129
+ // Whether the hosted row's expiry still honours the local entry's intent.
130
+ //
131
+ // The instants cannot be compared. A push recomputes `expires_at` from
132
+ // `ttl_days` at the write instant and it then stays fixed, while the local
133
+ // intent is always measured from NOW — so the two drift apart the moment they
134
+ // agree, and any test for equality (or for "hosted >= what I would write
135
+ // today") re-pushes the entry on every run forever. An over-365-day entry is
136
+ // the worst case: its hosted row is capped at the API maximum and can never
137
+ // catch up with the local date at all.
138
+ //
139
+ // So the question is not "do these match" but "has the hosted lesson lost
140
+ // enough of its intended life to be worth rewriting":
141
+ //
142
+ // both permanent → honoured.
143
+ // one permanent, one not → different intent, re-push.
144
+ // both expiring → honoured while the hosted row still has at least
145
+ // HALF the life the local entry asks for (the ask
146
+ // itself capped at the API maximum, since that is
147
+ // the longest a write can request). A fresh push
148
+ // leaves the two equal and the entry then coasts
149
+ // for half its TTL before one re-push refreshes
150
+ // it — bounded and convergent, never a loop.
151
+ //
152
+ // The threshold is what makes it converge, and it is deliberately generous in
153
+ // both directions: a hosted row expiring in 7 days does NOT honour a 300-day
154
+ // lesson, and one expiring within the hour does NOT honour a one-day lesson,
155
+ // so a genuinely shortened TTL still migrates.
156
+ const TTL_HONOURED_FRACTION = 0.5;
157
+
158
+ function sameRemoteTtl(currentExpiry, entryExpiry, now = new Date()) {
159
+ if (!currentExpiry && !entryExpiry) return true;
160
+ if (!currentExpiry || !entryExpiry) return false;
161
+ const hosted = Date.parse(currentExpiry);
162
+ const local = Date.parse(entryExpiry);
163
+ // An unparseable value on either side is not a difference we can act on —
164
+ // `putEntry` leaves the hosted expiry alone in that case, so re-pushing
165
+ // would change nothing.
166
+ if (Number.isNaN(hosted) || Number.isNaN(local)) return true;
167
+ const at = now.getTime();
168
+ // What a write could actually ask for, from now: the local intent, capped at
169
+ // the API's maximum.
170
+ const asked = Math.min(local - at, TTL_MAX_DAYS * 86_400_000);
171
+ if (asked <= 0) return true; // the local entry is expiring anyway
172
+ return hosted - at >= asked * TTL_HONOURED_FRACTION;
173
+ }
174
+
175
+ // The global connection flags, folded into the environment the resolver reads.
176
+ // A COPY of the shim `doctor.mjs` and `mcp-server.mjs` use (kept local for the
177
+ // same reason theirs are: `resolveControl` is a pure resolver that takes an
178
+ // env object, and threading flags through it is each command's own business).
179
+ function withOverrides(args) {
180
+ const env = { ...process.env };
181
+ if (args.endpoint) env.LOREKIT_MCP_URL = args.endpoint;
182
+ if (args.token) env.LOREKIT_TOKEN = args.token;
183
+ if (args.store) env.LOREKIT_STORE = args.store;
184
+ return env;
185
+ }
186
+
187
+ // Name the entries a remote write altered, then tally them. A bare count tells
188
+ // a user something was changed without telling them what — and these are
189
+ // silent changes, so they are the ones most worth naming.
190
+ function reportAltered(keys, apply, what) {
191
+ if (!keys.length) return;
192
+ for (const key of keys.slice(0, ALTERED_LIST_CAP)) status('warn', key, what);
193
+ if (keys.length > ALTERED_LIST_CAP) {
194
+ log(` ${c.dim(`… and ${keys.length - ALTERED_LIST_CAP} more`)}`);
195
+ }
196
+ log(` ${keys.length} entr${keys.length === 1 ? 'y' : 'ies'}: ${apply ? '' : 'would have '}${what}.`);
197
+ }
198
+
199
+ // How many altered entries to name before summarising the rest. A migration
200
+ // can carry thousands; naming every one would bury the summary it belongs to.
201
+ const ALTERED_LIST_CAP = 10;
202
+
203
+ // Resolve the hosted store to push to, or the reason we cannot.
204
+ //
205
+ // Every check here is a PREFLIGHT: it runs once, before the first entry, so a
206
+ // misconfigured run fails in one line instead of halfway through a push with
207
+ // an unknown amount already written.
208
+ //
209
+ // Returns `{ error }` when the destination is unusable, else
210
+ // `{ store, endpoint, warnings, classify }` — where `classify` is false when
211
+ // the token cannot READ, so the caller must not try to.
212
+ export function resolveRemoteDestination(control, args = {}) {
213
+ const denied = (control.denies || []).find((d) => d.mode === 'remote');
214
+ if (denied) {
215
+ return { error: `remote mode is denied by ${denied.source} — a migration cannot override a deny` };
216
+ }
217
+
218
+ // The global `-e/--endpoint` and `-t/--token` outrank the resolved
219
+ // connection. `withOverrides` alone is not enough for these two:
220
+ // `resolveProjectConnection` reads `process.env` directly rather than the
221
+ // injected env, so a flag that never touches `process.env` would be silently
222
+ // ignored — which is exactly what happened before.
223
+ const resolved = control.connection || {};
224
+ const endpoint = (typeof args.endpoint === 'string' && args.endpoint.trim()) || resolved.endpoint || null;
225
+ const token = (typeof args.token === 'string' && args.token.trim()) || resolved.token || null;
226
+ const conn = {
227
+ endpoint,
228
+ token,
229
+ usable: Boolean(endpoint && token && !String(endpoint).includes('<project-ref>')),
230
+ };
231
+ if (!conn.usable) {
232
+ return {
233
+ error: 'no usable remote connection is configured.\n'
234
+ + ` Run ${c.cyan('lorekit install --endpoint <url> --token lk_rw_...')}, or set `
235
+ + 'LOREKIT_MCP_URL + LOREKIT_TOKEN.',
236
+ };
237
+ }
238
+
239
+ // The token's PREFIX is a local, offline claim about its permissions, which
240
+ // is exactly what a preflight needs: a read-only token is rejected before a
241
+ // single request rather than 403-ing on the first write, mid-run.
242
+ const kind = tokenKind(conn.token);
243
+ if (kind === 'read-only') {
244
+ return {
245
+ error: 'the configured token is read-only (lk_ro_*) — a migration to remote writes.\n'
246
+ + ` Create a read+write token (${c.cyan('lk_rw_*')}) in the dashboard and re-run.`,
247
+ };
248
+ }
249
+
250
+ const warnings = [];
251
+ // Whether the destination can be READ to classify ADD / UPDATE / NOOP. A
252
+ // write-only token's reads are denied outright, so asking would 403 every
253
+ // entry — see `classify: false` below.
254
+ let classify = true;
255
+ if (kind === 'write-only') {
256
+ // A write-only token is legitimate here — the writes will succeed — but the
257
+ // classifying READ will 403, so every entry looks new. Say so up front
258
+ // rather than presenting a plan that silently overstates the work.
259
+ classify = false;
260
+ warnings.push(
261
+ 'token is write-only (lk_wo_*) — reads are denied, so the destination is not read at all: '
262
+ + 'every entry is reported as "add" and pushed. The writes are still idempotent server-side.',
263
+ );
264
+ } else if (kind === 'unknown') {
265
+ warnings.push(
266
+ 'token has an unrecognized prefix (expected lk_rw_* / lk_ro_* / lk_wo_*) — proceeding anyway; '
267
+ + 'a token without write permission will fail on the first write.',
268
+ );
269
+ }
270
+
271
+ return {
272
+ store: createRemoteStore({ endpoint: conn.endpoint, token: conn.token }),
273
+ endpoint: conn.endpoint,
274
+ warnings,
275
+ classify,
276
+ };
277
+ }
278
+
279
+ // `options` is a test seam, not a user-facing surface: `sleepFn` lets the
280
+ // rate-limit tests exercise the backoff without actually waiting out a window.
281
+ export async function migrate(args, options = {}) {
69
282
  const root = resolveProjectRoot(args.dir);
70
283
 
71
284
  const from = typeof args.from === 'string' ? args.from : null;
@@ -80,8 +293,8 @@ export async function migrate(args) {
80
293
  }
81
294
 
82
295
  const to = typeof args.to === 'string' ? args.to.toLowerCase() : null;
83
- if (to && to !== 'home' && to !== 'project') {
84
- err(`${c.red('migrate:')} --to must be "home" or "project"`);
296
+ if (to && to !== 'home' && to !== 'project' && to !== 'remote') {
297
+ err(`${c.red('migrate:')} --to must be "home", "project" or "remote"`);
85
298
  return 1;
86
299
  }
87
300
  const apply = Boolean(args.apply || args.yes);
@@ -93,7 +306,24 @@ export async function migrate(args) {
93
306
  // repo/branch → project when opted-in, else home).
94
307
  let targetFor;
95
308
  let destLabel;
96
- if (to === 'home') {
309
+ const remote = to === 'remote';
310
+ let remoteWarnings = [];
311
+ let classifyRemote = true;
312
+ if (remote) {
313
+ // `withOverrides` so the global `-t/--token` and `-e/--endpoint` reach the
314
+ // resolver, exactly as `doctor` and the stdio MCP server do. Without it a
315
+ // user who passed both on the command line still failed the preflight.
316
+ const control = loadControl(root, { env: withOverrides(args) });
317
+ const dest = resolveRemoteDestination(control, args);
318
+ if (dest.error) {
319
+ err(`${c.red('migrate:')} ${dest.error}`);
320
+ return 1;
321
+ }
322
+ remoteWarnings = dest.warnings;
323
+ classifyRemote = dest.classify !== false;
324
+ targetFor = () => dest.store;
325
+ destLabel = `remote (${dest.endpoint})`;
326
+ } else if (to === 'home') {
97
327
  const store = createLocalStore(dirs.home);
98
328
  targetFor = () => store;
99
329
  destLabel = `home (${dirs.home})`;
@@ -108,30 +338,148 @@ export async function migrate(args) {
108
338
  destLabel = 'resolved layout (global→home, repo/branch→project-if-opted-in)';
109
339
  }
110
340
 
111
- const entries = collectEntries(src);
341
+ // ONE clock for the run: the TTL comparison, the lossiness preview and the
342
+ // conversion inside `putEntry` must not disagree about what "now" is.
343
+ const now = options.now instanceof Date ? options.now : new Date();
344
+
345
+ const collected = collectEntries(src);
346
+ // A remote destination cannot represent an archived or an expired entry
347
+ // (see `RemoteStore.putEntry`), so they are filtered here and counted rather
348
+ // than pushed and silently revived. A local destination keeps taking them —
349
+ // `LocalStore.putEntry` writes them verbatim, hidden state included, which is
350
+ // the whole point of a relocation.
351
+ const entries = remote ? collected.filter((e) => isLive(e)) : collected;
352
+ const skipped = collected.length - entries.length;
112
353
 
113
354
  heading('LoreKit migrate');
114
355
  log(` from: ${c.dim(src)}`);
115
356
  log(` to: ${c.dim(destLabel)}`);
116
357
  log(` mode: ${apply ? c.bold('apply') : 'dry-run — pass --yes to apply'}`);
117
- log(` found: ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}\n`);
358
+ log(` found: ${collected.length} entr${collected.length === 1 ? 'y' : 'ies'}\n`);
359
+ for (const w of remoteWarnings) status('warn', 'token', w);
360
+ if (skipped > 0) {
361
+ status('warn', 'skipped', `${skipped} archived or expired entr${skipped === 1 ? 'y' : 'ies'} — not representable remotely`);
362
+ }
363
+
364
+ // Stay under the hosted 120 req/min ceiling proactively, and survive a 429
365
+ // reactively. Both are no-ops for a local destination, which issues no
366
+ // requests at all — `pace()` never fills its window and `withRetry` never
367
+ // sees a rate-limited result.
368
+ let pacedOnce = false;
369
+ const pace = remote
370
+ ? createPacer({
371
+ // The pacer's wait is deliberately NOT `options.sleepFn`. That seam
372
+ // exists so a retry test does not wait out a backoff, and a sleep that
373
+ // returns without the clock advancing would spin this window forever.
374
+ // Pacing is bounded by real time, always.
375
+ sleepFn: async (ms) => {
376
+ // Say it once. A few-thousand-entry store otherwise sits silent for
377
+ // minutes after the header and looks hung.
378
+ if (!pacedOnce) {
379
+ pacedOnce = true;
380
+ status('info', 'pacing', 'staying under the hosted rate limit — this run will take longer');
381
+ }
382
+ return sleep(ms);
383
+ },
384
+ ...(options.maxPerWindow ? { maxPerWindow: options.maxPerWindow } : {}),
385
+ ...(options.windowMs ? { windowMs: options.windowMs } : {}),
386
+ })
387
+ : async () => {};
388
+ const call = async (fn) => {
389
+ if (!remote) return fn();
390
+ // `pace()` is INSIDE the retried function, so every attempt is counted
391
+ // against the window. Pacing only the first one would leave the ceiling
392
+ // unenforced during exactly the 429 episodes it exists to prevent.
393
+ return withRetry(async () => { await pace(); return fn(); }, {
394
+ sleepFn: options.sleepFn,
395
+ onRetry: ({ attempt, delayMs }) =>
396
+ status('warn', 'rate limit', `retrying in ${Math.round(delayMs / 1000)}s (attempt ${attempt})`),
397
+ });
398
+ };
118
399
 
119
400
  const totals = { add: 0, update: 0, noop: 0 };
120
401
  const byScope = new Map();
402
+ let capped = null;
403
+ let failed = 0;
404
+ // Named, not merely counted — the same courtesy the failure list gets, so a
405
+ // user knows WHICH lessons were altered rather than how many.
406
+ const clamped = [];
407
+ const redated = [];
408
+ // A blip is worth retrying; an outage is not. Once this many entries fail
409
+ // back to back the destination is not having a bad moment, it is down — and
410
+ // continuing means every remaining entry pays the full retry budget before
411
+ // failing anyway. Reset by any entry that succeeds.
412
+ const failureLimit = options.consecutiveFailureLimit ?? DEFAULT_CONSECUTIVE_FAILURE_LIMIT;
413
+ let consecutiveFailures = 0;
414
+ let abandoned = false;
121
415
  for (const entry of entries) {
122
416
  const store = targetFor(entry.scope);
123
- const current = store.getEntry({ scope: entry.scope, key: entry.key });
417
+ // Awaited so the loop is destination-agnostic: LocalStore.getEntry is
418
+ // synchronous and awaiting its plain return value is a no-op, while a
419
+ // remote destination's is a REST round-trip. One code path, both stores.
420
+ //
421
+ // A remote read that FAILS throws rather than answering null (see
422
+ // `RemoteStore.getEntry`), precisely so it cannot be mistaken for "not
423
+ // there" and overwrite a hosted lesson. Report that entry and move on: one
424
+ // unreadable key must not abort a migration, and it must not be counted as
425
+ // migrated either. The local store never throws here.
426
+ let current;
427
+ try {
428
+ // A write-only token's reads are denied, so asking would 403 every entry
429
+ // and fail a run the preflight just promised would work. Skip straight
430
+ // to the write; the hosted upsert is idempotent either way.
431
+ current = classifyRemote
432
+ ? await call(() => store.getEntry({ scope: entry.scope, key: entry.key }))
433
+ : null;
434
+ } catch (e) {
435
+ failed++;
436
+ status('fail', entry.scope, `${entry.key}: ${e?.message || 'read failed'}`);
437
+ if (++consecutiveFailures >= failureLimit) { abandoned = true; break; }
438
+ continue;
439
+ }
124
440
  let verdict;
125
441
  if (!current) verdict = 'add';
126
- else if (sameEntry(current, entry)) verdict = 'noop';
442
+ else if (remote ? sameRemoteEntry(current, entry, now) : sameEntry(current, entry)) verdict = 'noop';
127
443
  else verdict = 'update';
128
444
 
445
+ // What this entry WOULD lose, computed from the entry itself so a DRY RUN
446
+ // warns about exactly what an apply would do — reporting it only on the
447
+ // write made the preview quietly less informative than the thing it
448
+ // previews. Recorded only once the outcome is known, though: a write that
449
+ // failed shortened nothing.
450
+ const losses = remote && verdict !== 'noop'
451
+ ? remoteWriteLosses(entry, now)
452
+ : { ttlClamped: false, createdAtDropped: false };
453
+ const recordLosses = () => {
454
+ if (losses.ttlClamped) clamped.push(`${entry.scope}::${entry.key}`);
455
+ if (losses.createdAtDropped) redated.push(`${entry.scope}::${entry.key}`);
456
+ };
457
+ if (!apply) recordLosses();
458
+
459
+ if (apply && verdict !== 'noop') {
460
+ const res = await call(() => store.putEntry(entry, { now }));
461
+ if (res && res.ok === false) {
462
+ // The memory cap is terminal for the whole run, not just this entry:
463
+ // every remaining write would hit the same ceiling. Stop, and report
464
+ // what did land — a partial migration the user can resume after
465
+ // archiving or upgrading is far more useful than a stack trace.
466
+ if (isMemoryCap(res)) {
467
+ capped = res.error?.message || 'memory cap reached';
468
+ break;
469
+ }
470
+ failed++;
471
+ status('fail', entry.scope, `${entry.key}: ${res.error?.message || res.networkError || 'write failed'}`);
472
+ if (++consecutiveFailures >= failureLimit) { abandoned = true; break; }
473
+ continue; // not counted as migrated — the report must not claim it
474
+ }
475
+ recordLosses(); // the write landed, so the loss actually happened
476
+ }
477
+ consecutiveFailures = 0;
478
+
129
479
  totals[verdict]++;
130
480
  const s = byScope.get(entry.scope) || { add: 0, update: 0, noop: 0 };
131
481
  s[verdict]++;
132
482
  byScope.set(entry.scope, s);
133
-
134
- if (apply && verdict !== 'noop') await store.putEntry(entry);
135
483
  }
136
484
 
137
485
  for (const [scope, s] of byScope) {
@@ -144,6 +492,38 @@ export async function migrate(args) {
144
492
  ` ${apply ? 'migrated' : 'would migrate'} ${moved} entr${moved === 1 ? 'y' : 'ies'} ` +
145
493
  `(${totals.add} new, ${totals.update} updated), ${totals.noop} unchanged.`,
146
494
  );
495
+ if (skipped > 0) log(` ${skipped} skipped (archived or expired).`);
496
+ reportAltered(clamped, apply, 'TTL shortened to the hosted maximum of 365 days');
497
+ reportAltered(redated, apply, 'unusable created date dropped — the server stamps the write instant');
498
+
499
+ // A cap can fire after an entry has already failed, so the failure count is
500
+ // reported on BOTH exits — it used to be lost whenever the cap won the race.
501
+ const reportFailures = () => {
502
+ if (failed > 0) {
503
+ // "failed", not "failed to write" — an entry lands here from an
504
+ // unreadable classification as well as from a rejected write.
505
+ err(`${c.red('migrate:')} ${failed} entr${failed === 1 ? 'y' : 'ies'} failed (listed above).`);
506
+ }
507
+ };
508
+ if (abandoned) {
509
+ err(`\n${c.red('migrate:')} stopped after ${failureLimit} consecutive failures — the destination looks unavailable.`);
510
+ log(` ${moved} entr${moved === 1 ? 'y' : 'ies'} migrated before it gave up.`);
511
+ log(` ${c.dim('Re-run when it is healthy — the migration resumes where it stopped.')}`);
512
+ reportFailures();
513
+ return 1;
514
+ }
515
+ if (capped) {
516
+ err(`\n${c.red('migrate:')} ${capped}`);
517
+ log(` ${moved} entr${moved === 1 ? 'y' : 'ies'} migrated before the cap was reached.`);
518
+ log(` ${c.dim('Archive unused memories or raise the plan limit, then re-run — the migration resumes where it stopped.')}`);
519
+ reportFailures();
520
+ return 1;
521
+ }
522
+ if (failed > 0) {
523
+ err('');
524
+ reportFailures();
525
+ return 1;
526
+ }
147
527
  if (!apply && moved > 0) log(` ${c.dim('Re-run with --yes to apply.')}`);
148
528
  return 0;
149
529
  }
@@ -0,0 +1,149 @@
1
+ // Client-side rate-limit handling for a bulk REST push (`migrate --to remote`).
2
+ //
3
+ // The hosted API allows 120 requests/min/user (docs/limits.md), enforced by a
4
+ // Postgres fixed-window counter. A migration is the one CLI flow that can
5
+ // exceed that on its own: it issues up to two requests per entry (a read to
6
+ // classify, a write to apply), so a few hundred lessons blow the window in
7
+ // seconds. Two independent guards, because either alone is wrong:
8
+ //
9
+ // `createPacer` — PROACTIVE. Keeps the client under a self-imposed ceiling
10
+ // below the server's, so a normal run never trips the limit
11
+ // and never pays a retry.
12
+ // `withRetry` — REACTIVE. The ceiling is a guess (the limit is per USER,
13
+ // not per process — a concurrent agent shares it, and a
14
+ // per-user override can move it), so a 429 must still be
15
+ // survivable rather than a failed migration.
16
+ //
17
+ // Both take their clock and sleep as parameters, so the tests are instant and
18
+ // deterministic rather than actually waiting out a window.
19
+ //
20
+ // Zero-dependency.
21
+
22
+ export const DEFAULT_MAX_PER_WINDOW = 100; // under the 120/min server limit
23
+ export const WINDOW_MS = 60_000;
24
+ export const DEFAULT_MAX_ATTEMPTS = 5;
25
+ // After this many entries fail in a row, stop retrying and let the caller
26
+ // abort. Retrying is worth it for a blip; a systematic outage just turns a
27
+ // 2,000-entry push into hours of backoff before failing anyway.
28
+ export const DEFAULT_CONSECUTIVE_FAILURE_LIMIT = 5;
29
+ export const DEFAULT_RETRY_DELAY_MS = 1_000;
30
+ export const MAX_RETRY_DELAY_MS = 60_000;
31
+
32
+ export function sleep(ms) {
33
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
34
+ }
35
+
36
+ /**
37
+ * A sliding-window pacer: `await pace()` before each request.
38
+ *
39
+ * Returns immediately while fewer than `maxPerWindow` requests were issued in
40
+ * the last `windowMs`; otherwise waits exactly until the oldest one falls out
41
+ * of the window. A small migration therefore runs at full speed and pays
42
+ * nothing for this, and a large one self-throttles instead of being throttled.
43
+ */
44
+ export function createPacer({
45
+ maxPerWindow = DEFAULT_MAX_PER_WINDOW,
46
+ windowMs = WINDOW_MS,
47
+ now = () => Date.now(),
48
+ sleepFn = sleep,
49
+ } = {}) {
50
+ const issued = [];
51
+ return async function pace() {
52
+ for (;;) {
53
+ const cutoff = now() - windowMs;
54
+ while (issued.length && issued[0] <= cutoff) issued.shift();
55
+ if (issued.length < maxPerWindow) {
56
+ issued.push(now());
57
+ return;
58
+ }
59
+ // Wait out the oldest request, then re-check: the clock moved, so the
60
+ // window has to be re-evaluated rather than assumed clear.
61
+ await sleepFn(issued[0] + windowMs - now() + 1);
62
+ }
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Whether a store result is a retryable rate-limit rejection.
68
+ *
69
+ * `memory_cap` is the reason this is a function and not a status comparison:
70
+ * the memory-cap trigger (LK001) is ALSO translated to HTTP 429
71
+ * (supabase/functions/_shared/api/errors.ts), and it is terminal — retrying it
72
+ * just burns the user's rate budget on a write that can never succeed.
73
+ */
74
+ export function isRateLimited(res) {
75
+ return Boolean(res && res.httpStatus === 429 && res.error?.code !== 'memory_cap');
76
+ }
77
+
78
+ /**
79
+ * Whether a store result is worth trying again at all.
80
+ *
81
+ * Wider than `isRateLimited` on purpose. This module exists for a BULK push of
82
+ * potentially thousands of requests, which is precisely the shape of run where
83
+ * a single transient 5xx or a dropped connection is likeliest — and failing
84
+ * one entry out of two thousand for a blip the next attempt would sail through
85
+ * is a bad trade. So a 5xx and a transport error retry too.
86
+ *
87
+ * What does NOT retry is anything the server has decided: a 4xx is a rejection
88
+ * (bad scope, denied permission), and `memory_cap` is terminal even though it
89
+ * arrives as a 429. Retrying either just burns the user's rate budget.
90
+ */
91
+ export function isRetryable(res) {
92
+ if (!res || isMemoryCap(res)) return false;
93
+ if (res.networkError) return true;
94
+ if (isRateLimited(res)) return true;
95
+ return typeof res.httpStatus === 'number' && res.httpStatus >= 500;
96
+ }
97
+
98
+ /** Whether a store result is the terminal memory-cap rejection. */
99
+ export function isMemoryCap(res) {
100
+ return Boolean(res && res.error?.code === 'memory_cap');
101
+ }
102
+
103
+ /**
104
+ * Run `fn` and retry it while it comes back retryable (see `isRetryable`).
105
+ *
106
+ * Honours the server's own `Retry-After` when it sent one — it knows when its
107
+ * window rolls over and the client does not — and falls back to exponential
108
+ * backoff otherwise. Returns the last result once the attempts are spent, so
109
+ * the caller reports a real error rather than a synthesised one; `onRetry` is
110
+ * for progress output.
111
+ */
112
+ export async function withRetry(fn, {
113
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
114
+ baseDelayMs = DEFAULT_RETRY_DELAY_MS,
115
+ sleepFn = sleep,
116
+ onRetry = null,
117
+ } = {}) {
118
+ let res;
119
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
120
+ // A read reports a rate limit by THROWING (`RemoteStore.getEntry` refuses
121
+ // to let a failed read look like an absence), so the rejection has to be
122
+ // classified here too — otherwise only writes would ever be retried and a
123
+ // 429 on the classifying read would fail the entry outright. Anything that
124
+ // is not a rate limit propagates untouched.
125
+ try {
126
+ res = await fn();
127
+ } catch (e) {
128
+ if (!isRetryable(e?.result) || attempt === maxAttempts) throw e;
129
+ await sleepFn(retryDelay(e.result, attempt, baseDelayMs, onRetry));
130
+ continue;
131
+ }
132
+ if (!isRetryable(res) || attempt === maxAttempts) return res;
133
+ await sleepFn(retryDelay(res, attempt, baseDelayMs, onRetry));
134
+ }
135
+ return res;
136
+ }
137
+
138
+ // How long to wait before the next attempt: the server's own hint when it sent
139
+ // one — it knows when its window rolls over and the client does not — else
140
+ // exponential backoff. Shared by the resolved and the thrown path so the two
141
+ // cannot drift.
142
+ function retryDelay(res, attempt, baseDelayMs, onRetry) {
143
+ const hinted = Number(res?.retryAfter);
144
+ const delayMs = Number.isFinite(hinted) && hinted > 0
145
+ ? Math.min(hinted * 1000, MAX_RETRY_DELAY_MS)
146
+ : Math.min(baseDelayMs * 2 ** (attempt - 1), MAX_RETRY_DELAY_MS);
147
+ if (onRetry) onRetry({ attempt, delayMs });
148
+ return delayMs;
149
+ }
@@ -46,6 +46,41 @@ export class StoreReadError extends Error {
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * What a remote write of `entry` would lose, without performing it.
51
+ *
52
+ * Exported because a DRY RUN has to be able to say the same thing the apply
53
+ * will: a preview that omits "this entry's expiry will be shortened" is a
54
+ * preview of a different operation. `putEntry` calls it too, so the two
55
+ * cannot drift.
56
+ *
57
+ * `ttlClamped` the entry's TTL exceeds the API's 365-day maximum and
58
+ * will land shortened.
59
+ * `createdAtDropped` the entry's `created` is unusable (unparseable or
60
+ * future-dated), so the override is dropped and the
61
+ * server stamps the write instant instead.
62
+ *
63
+ * Total over any input: a malformed entry reports no losses rather than
64
+ * throwing, because this runs on the preview path where an exception would
65
+ * cost the user the whole plan.
66
+ */
67
+ export function remoteWriteLosses(entry, now = new Date()) {
68
+ const exact = remoteTtlDaysExact(entry?.expires_at, now);
69
+ return {
70
+ ttlClamped: typeof exact === 'number' && exact > 365,
71
+ createdAtDropped: Boolean(entry?.created) && safeCreatedAt(entry?.created, now) === null,
72
+ };
73
+ }
74
+
75
+ // `normalizeCreatedAt`, but an invalid value yields null instead of throwing.
76
+ function safeCreatedAt(created, now) {
77
+ try {
78
+ return normalizeCreatedAt(created ?? null, now);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
49
84
  // An absolute `expires_at` expressed as the hosted write's relative `ttl_days`.
50
85
  //
51
86
  // Three outcomes, and the third exists because "no expiry" and "I cannot tell"
@@ -423,20 +458,11 @@ class RemoteStore {
423
458
  if (ttl === 'expired') {
424
459
  return refuse('expired', 'expired entries cannot be written remotely — any TTL would re-date them into the future');
425
460
  }
426
- // The same validation the server applies (`_shared/created-at.ts`, mirrored
427
- // here), run BEFORE the request rather than discovered as a 400. A local
428
- // file can hold a hand-edited or clock-skewed `created`, and losing the
429
- // whole lesson over its creation date is the wrong trade: drop the
430
- // override, let the server stamp now, and report the loss so the caller
431
- // can say which entries were re-dated.
432
- let createdAt;
433
- let createdAtDropped = false;
434
- try {
435
- createdAt = normalizeCreatedAt(entry?.created ?? null, now) ?? undefined;
436
- } catch {
437
- createdAt = undefined;
438
- createdAtDropped = Boolean(entry?.created);
439
- }
461
+ // What this write will lose, decided by the same pure function a DRY RUN
462
+ // calls so a preview cannot promise something the apply then silently
463
+ // does differently.
464
+ const { ttlClamped, createdAtDropped } = remoteWriteLosses(entry, now);
465
+ const createdAt = createdAtDropped ? undefined : (safeCreatedAt(entry?.created, now) ?? undefined);
440
466
  const result = await this.write(stripUndefined({
441
467
  scope: entry.scope,
442
468
  key: entry.key,
@@ -462,7 +488,7 @@ class RemoteStore {
462
488
  return {
463
489
  ...result,
464
490
  unsupported: null,
465
- ttlClamped: Boolean(result.ok) && ttl === 365 && remoteTtlDaysExact(entry?.expires_at, now) > 365,
491
+ ttlClamped: Boolean(result.ok) && ttlClamped,
466
492
  createdAtDropped: Boolean(result.ok) && createdAtDropped,
467
493
  };
468
494
  }
package/src/util.mjs CHANGED
@@ -21,12 +21,32 @@ export const sym = {
21
21
  info: useColor ? c.cyan('•') : '-',
22
22
  };
23
23
 
24
+ // Where `log` and `err` send their output. Indirected through a mutable pair
25
+ // so a test can capture a command's output WITHOUT hijacking
26
+ // `process.stdout.write` — which is not a private channel: `node --test` runs
27
+ // each file in a child process and reports results over that same stdout, so a
28
+ // global hijack silently swallows the runner's own result lines and a failing
29
+ // test can go unreported. Production behaviour is unchanged; the default
30
+ // writers are the streams themselves.
31
+ let writers = {
32
+ out: (s) => process.stdout.write(s),
33
+ err: (s) => process.stderr.write(s),
34
+ };
35
+
36
+ // Redirect `log`/`err` (and therefore `heading`/`status`, which build on them).
37
+ // Returns the restore function, so a caller cannot forget what it replaced.
38
+ export function setWriters(next = {}) {
39
+ const previous = writers;
40
+ writers = { ...writers, ...next };
41
+ return () => { writers = previous; };
42
+ }
43
+
24
44
  export function log(msg = '') {
25
- process.stdout.write(`${msg}\n`);
45
+ writers.out(`${msg}\n`);
26
46
  }
27
47
 
28
48
  export function err(msg = '') {
29
- process.stderr.write(`${msg}\n`);
49
+ writers.err(`${msg}\n`);
30
50
  }
31
51
 
32
52
  export function heading(title) {