@mnemom/mnemom 0.17.2-next.7 → 0.17.2-next.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/commands/agent-doctor.d.ts +1 -1
  2. package/dist/commands/agent-doctor.js +5 -1
  3. package/dist/commands/agent-setup.d.ts +34 -3
  4. package/dist/commands/agent-setup.js +86 -11
  5. package/dist/commands/agent.js +11 -3
  6. package/dist/commands/config.d.ts +36 -0
  7. package/dist/commands/config.js +82 -0
  8. package/dist/commands/update.d.ts +30 -0
  9. package/dist/commands/update.js +90 -0
  10. package/dist/index.js +41 -0
  11. package/dist/lib/agent.d.ts +13 -7
  12. package/dist/lib/agent.js +12 -12
  13. package/dist/lib/cli-config.d.ts +8 -0
  14. package/dist/lib/cli-config.js +13 -0
  15. package/dist/lib/update/action.d.ts +9 -0
  16. package/dist/lib/update/action.js +17 -0
  17. package/dist/lib/update/decide.d.ts +152 -0
  18. package/dist/lib/update/decide.js +195 -0
  19. package/dist/lib/update/doctor.d.ts +18 -0
  20. package/dist/lib/update/doctor.js +63 -0
  21. package/dist/lib/update/install-method.d.ts +12 -0
  22. package/dist/lib/update/install-method.js +33 -0
  23. package/dist/lib/update/min-version.d.ts +34 -0
  24. package/dist/lib/update/min-version.js +69 -0
  25. package/dist/lib/update/registry.d.ts +27 -0
  26. package/dist/lib/update/registry.js +50 -0
  27. package/dist/lib/update/run.d.ts +54 -0
  28. package/dist/lib/update/run.js +100 -0
  29. package/dist/lib/update/semver.d.ts +22 -0
  30. package/dist/lib/update/semver.js +65 -0
  31. package/dist/lib/update/startup.d.ts +27 -0
  32. package/dist/lib/update/startup.js +75 -0
  33. package/dist/lib/update/state.d.ts +23 -0
  34. package/dist/lib/update/state.js +83 -0
  35. package/dist/lib/update/system.d.ts +55 -0
  36. package/dist/lib/update/system.js +104 -0
  37. package/dist/lib/update/worker.d.ts +42 -0
  38. package/dist/lib/update/worker.js +104 -0
  39. package/dist/lib/version-gate.d.ts +2 -1
  40. package/dist/lib/version-gate.js +9 -1
  41. package/dist/update-worker.d.ts +6 -0
  42. package/dist/update-worker.js +8 -0
  43. package/package.json +1 -1
@@ -17,6 +17,7 @@
17
17
  * Membership can change after it's stored — consumers must treat a server
18
18
  * 403 as the truth (the claim path already renders the teaching list).
19
19
  */
20
+ import type { UpdatePrefs } from "./update/decide.js";
20
21
  export interface ActiveOrg {
21
22
  org_id: string;
22
23
  slug: string;
@@ -24,6 +25,8 @@ export interface ActiveOrg {
24
25
  }
25
26
  export interface CliConfig {
26
27
  activeOrg?: ActiveOrg;
28
+ /** Auto-update preferences (`mnemom config set update.channel|update.mode`). */
29
+ update?: UpdatePrefs;
27
30
  }
28
31
  /** Load the config; a missing or corrupt file degrades to `{}`. */
29
32
  export declare function loadCliConfig(): CliConfig;
@@ -31,3 +34,8 @@ export declare function loadCliConfig(): CliConfig;
31
34
  export declare function setActiveOrg(org: ActiveOrg | null): void;
32
35
  /** The active org, or undefined when none is set. */
33
36
  export declare function getActiveOrg(): ActiveOrg | undefined;
37
+ /**
38
+ * Set (or with `undefined`, clear) one auto-update preference. Validation is
39
+ * the caller's job (commands/config.ts) — this only persists.
40
+ */
41
+ export declare function setUpdatePref<K extends keyof UpdatePrefs>(key: K, value: UpdatePrefs[K] | undefined): void;
@@ -68,3 +68,16 @@ export function setActiveOrg(org) {
68
68
  export function getActiveOrg() {
69
69
  return loadCliConfig().activeOrg;
70
70
  }
71
+ /**
72
+ * Set (or with `undefined`, clear) one auto-update preference. Validation is
73
+ * the caller's job (commands/config.ts) — this only persists.
74
+ */
75
+ export function setUpdatePref(key, value) {
76
+ const config = loadCliConfig();
77
+ const update = Object.fromEntries(Object.entries({ ...config.update, [key]: value }).filter(([, v]) => v !== undefined));
78
+ if (Object.keys(update).length === 0)
79
+ delete config.update;
80
+ else
81
+ config.update = update;
82
+ saveCliConfig(config);
83
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The commander-action wrapper for `mnemom update` / `mnemom config`: print
3
+ * the error and exit 1. Injected so the error path is testable.
4
+ */
5
+ export interface ActionIO {
6
+ error(...args: unknown[]): void;
7
+ exit(code: number): void;
8
+ }
9
+ export declare function runAction(fn: () => void | Promise<void>, io?: ActionIO): Promise<void>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The commander-action wrapper for `mnemom update` / `mnemom config`: print
3
+ * the error and exit 1. Injected so the error path is testable.
4
+ */
5
+ const realIO = {
6
+ error: (...args) => console.error(...args),
7
+ exit: (code) => process.exit(code),
8
+ };
9
+ export async function runAction(fn, io = realIO) {
10
+ try {
11
+ await fn();
12
+ }
13
+ catch (error) {
14
+ io.error("Error:", error instanceof Error ? error.message : error);
15
+ io.exit(1);
16
+ }
17
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Auto-update policy — the pure core.
3
+ *
4
+ * Every decision the updater makes lives here as a side-effect-free function of
5
+ * its inputs (current version, registry dist-tags, config, env, install method,
6
+ * persisted state, clock) so each branch is directly testable. The drivers
7
+ * (`startup.ts`, `worker.ts`, `commands/update.ts`) only gather inputs and act.
8
+ *
9
+ * Channels:
10
+ * latest — the stable dist-tag. A prerelease is NEVER auto-installed here,
11
+ * even if it is (mis)tagged `latest` (the 2026-09-22 next.9 incident:
12
+ * OIDC publishing can't move a dist-tag back, so the guard is ours).
13
+ * next — max(next, latest) by SemVer, so a next user rides onto each stable
14
+ * cut instead of being stranded on an older prerelease. Honoured only
15
+ * for entitled callers (internal cohort, staff, `cli_next` invite —
16
+ * GET /v1/cli/channel-entitlement); otherwise it behaves as latest.
17
+ *
18
+ * Never downgrades automatically: switching next → latest while ahead of latest
19
+ * keeps the current build until latest overtakes it (`mnemom update --force`).
20
+ */
21
+ export type UpdateChannel = "latest" | "next";
22
+ export type UpdateMode = "auto" | "notify" | "off";
23
+ export declare const UPDATE_CHANNELS: readonly UpdateChannel[];
24
+ export declare const UPDATE_MODES: readonly UpdateMode[];
25
+ export declare const PACKAGE_NAME = "@mnemom/mnemom";
26
+ /** How often the background worker polls the registry, per channel. */
27
+ export declare const CHECK_INTERVAL_MS: Record<UpdateChannel, number>;
28
+ /** Cached channel entitlement is trusted for this long before re-asking. */
29
+ export declare const ENTITLEMENT_TTL_MS: number;
30
+ /** At most one "update available" line per this window. */
31
+ export declare const NOTIFY_INTERVAL_MS: number;
32
+ /** Failure backoff: 1d, 2d, 4d … capped at 7d. */
33
+ export declare const BACKOFF_BASE_MS: number;
34
+ export declare const BACKOFF_MAX_MS: number;
35
+ /** How the running CLI was installed — decides whether it can replace itself. */
36
+ export type InstallMethod = "npm-global" | "pnpm-global" | "yarn-global" | "bun-global" | "npx" | "project" | "dev" | "unknown";
37
+ export interface DistTags {
38
+ latest?: string;
39
+ next?: string;
40
+ }
41
+ export interface EntitlementCache {
42
+ next: boolean;
43
+ checkedAt: number;
44
+ }
45
+ /** ~/.mnemom/update-state.json */
46
+ export interface UpdateState {
47
+ lastCheck?: number;
48
+ /** Newest installable version the last check found (for notify mode). */
49
+ available?: string;
50
+ /** Written after a successful background install; shown once, then cleared. */
51
+ pending?: {
52
+ from: string;
53
+ to: string;
54
+ channel: UpdateChannel;
55
+ };
56
+ lastNotified?: number;
57
+ failures?: number;
58
+ backoffUntil?: number;
59
+ lastError?: string;
60
+ /** Global prefix isn't writable (EACCES) — stop trying, tell the user how to fix. */
61
+ installBlocked?: boolean;
62
+ entitlement?: EntitlementCache;
63
+ /** Install method the worker last detected (startup can't afford `npm root -g`). */
64
+ method?: InstallMethod;
65
+ }
66
+ export interface UpdatePrefs {
67
+ channel?: UpdateChannel;
68
+ mode?: UpdateMode;
69
+ }
70
+ export interface ResolvedSettings {
71
+ channel: UpdateChannel;
72
+ mode: UpdateMode;
73
+ /** Why mode was forced to off, when it was. */
74
+ offReason?: string;
75
+ }
76
+ export declare function isUpdateChannel(v: unknown): v is UpdateChannel;
77
+ export declare function isUpdateMode(v: unknown): v is UpdateMode;
78
+ /** Channel precedence: flag > MNEMOM_UPDATE_CHANNEL > config.json > latest. */
79
+ export declare function resolveChannel(prefs: UpdatePrefs, env: Record<string, string | undefined>, flag?: UpdateChannel): UpdateChannel;
80
+ /** Mode precedence: flag > MNEMOM_UPDATE_MODE > config.json > auto. */
81
+ export declare function resolveMode(prefs: UpdatePrefs, env: Record<string, string | undefined>, flag?: UpdateMode): UpdateMode;
82
+ /**
83
+ * Settings precedence: flag > env > config.json > built-in (latest / auto).
84
+ * Then hard overrides that force `off` regardless: the kill switch, CI, and a
85
+ * non-interactive stdout (CI images and pipelines must never mutate themselves).
86
+ * An invalid env value is ignored rather than fatal — a typo must not break
87
+ * every command.
88
+ */
89
+ export declare function resolveSettings(input: {
90
+ prefs: UpdatePrefs;
91
+ env: Record<string, string | undefined>;
92
+ isTTY: boolean;
93
+ flags?: UpdatePrefs;
94
+ }): ResolvedSettings;
95
+ /** The channel actually honoured: `next` requires a positive entitlement. */
96
+ export declare function effectiveChannel(requested: UpdateChannel, entitledToNext: boolean): UpdateChannel;
97
+ /** Whether the cached entitlement answer is still fresh enough to use. */
98
+ export declare function entitlementFresh(cache: EntitlementCache | undefined, now: number): boolean;
99
+ /**
100
+ * The version a channel points at, or undefined when the registry offered
101
+ * nothing usable. Invalid tag values are dropped (defence against a malformed
102
+ * registry response).
103
+ */
104
+ export declare function pickTarget(tags: DistTags, channel: UpdateChannel): string | undefined;
105
+ /** Whether the background worker should poll the registry now. */
106
+ export declare function shouldCheck(state: UpdateState, channel: UpdateChannel, now: number): boolean;
107
+ export declare function backoffDelay(failures: number): number;
108
+ export declare function canSelfUpdate(method: InstallMethod): boolean;
109
+ export type UpdateAction = {
110
+ kind: "noop";
111
+ reason: string;
112
+ } | {
113
+ kind: "notify";
114
+ version: string;
115
+ reason: string;
116
+ } | {
117
+ kind: "install";
118
+ version: string;
119
+ };
120
+ /**
121
+ * The single decision: given where the registry is and how we're installed,
122
+ * install / notify / do nothing. `force` (explicit `mnemom update --force`)
123
+ * permits a downgrade to the channel's target.
124
+ */
125
+ export declare function decide(input: {
126
+ current: string;
127
+ target: string | undefined;
128
+ mode: UpdateMode;
129
+ method: InstallMethod;
130
+ installBlocked?: boolean;
131
+ force?: boolean;
132
+ }): UpdateAction;
133
+ /** The command a human should run to upgrade, for the given install method. */
134
+ export declare function manualUpgradeCommand(method: InstallMethod, version: string): string;
135
+ export interface StartupNotice {
136
+ message: string;
137
+ /** State to persist after showing the notice. */
138
+ patch: Partial<UpdateState>;
139
+ }
140
+ /**
141
+ * What (if anything) to print on stderr at startup. A completed background
142
+ * install is announced once — only when the running binary IS the new version,
143
+ * so a user still inside an old process doesn't see a false "updated". Otherwise
144
+ * an available-but-not-installed version is surfaced at most once a day.
145
+ */
146
+ export declare function startupNotice(input: {
147
+ current: string;
148
+ state: UpdateState;
149
+ mode: UpdateMode;
150
+ method: InstallMethod;
151
+ now: number;
152
+ }): StartupNotice | null;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Auto-update policy — the pure core.
3
+ *
4
+ * Every decision the updater makes lives here as a side-effect-free function of
5
+ * its inputs (current version, registry dist-tags, config, env, install method,
6
+ * persisted state, clock) so each branch is directly testable. The drivers
7
+ * (`startup.ts`, `worker.ts`, `commands/update.ts`) only gather inputs and act.
8
+ *
9
+ * Channels:
10
+ * latest — the stable dist-tag. A prerelease is NEVER auto-installed here,
11
+ * even if it is (mis)tagged `latest` (the 2026-09-22 next.9 incident:
12
+ * OIDC publishing can't move a dist-tag back, so the guard is ours).
13
+ * next — max(next, latest) by SemVer, so a next user rides onto each stable
14
+ * cut instead of being stranded on an older prerelease. Honoured only
15
+ * for entitled callers (internal cohort, staff, `cli_next` invite —
16
+ * GET /v1/cli/channel-entitlement); otherwise it behaves as latest.
17
+ *
18
+ * Never downgrades automatically: switching next → latest while ahead of latest
19
+ * keeps the current build until latest overtakes it (`mnemom update --force`).
20
+ */
21
+ import { compareVersions, isPrerelease, isValidVersion } from "./semver.js";
22
+ export const UPDATE_CHANNELS = ["latest", "next"];
23
+ export const UPDATE_MODES = ["auto", "notify", "off"];
24
+ export const PACKAGE_NAME = "@mnemom/mnemom";
25
+ const HOUR = 60 * 60 * 1000;
26
+ const DAY = 24 * HOUR;
27
+ /** How often the background worker polls the registry, per channel. */
28
+ export const CHECK_INTERVAL_MS = {
29
+ latest: DAY,
30
+ next: 6 * HOUR,
31
+ };
32
+ /** Cached channel entitlement is trusted for this long before re-asking. */
33
+ export const ENTITLEMENT_TTL_MS = DAY;
34
+ /** At most one "update available" line per this window. */
35
+ export const NOTIFY_INTERVAL_MS = DAY;
36
+ /** Failure backoff: 1d, 2d, 4d … capped at 7d. */
37
+ export const BACKOFF_BASE_MS = DAY;
38
+ export const BACKOFF_MAX_MS = 7 * DAY;
39
+ export function isUpdateChannel(v) {
40
+ return typeof v === "string" && UPDATE_CHANNELS.includes(v);
41
+ }
42
+ export function isUpdateMode(v) {
43
+ return typeof v === "string" && UPDATE_MODES.includes(v);
44
+ }
45
+ function truthyEnv(v) {
46
+ return v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false";
47
+ }
48
+ /** Channel precedence: flag > MNEMOM_UPDATE_CHANNEL > config.json > latest. */
49
+ export function resolveChannel(prefs, env, flag) {
50
+ const fromEnv = env.MNEMOM_UPDATE_CHANNEL;
51
+ return flag ?? (isUpdateChannel(fromEnv) ? fromEnv : undefined) ?? prefs.channel ?? "latest";
52
+ }
53
+ /** Mode precedence: flag > MNEMOM_UPDATE_MODE > config.json > auto. */
54
+ export function resolveMode(prefs, env, flag) {
55
+ const fromEnv = env.MNEMOM_UPDATE_MODE;
56
+ return flag ?? (isUpdateMode(fromEnv) ? fromEnv : undefined) ?? prefs.mode ?? "auto";
57
+ }
58
+ /**
59
+ * Settings precedence: flag > env > config.json > built-in (latest / auto).
60
+ * Then hard overrides that force `off` regardless: the kill switch, CI, and a
61
+ * non-interactive stdout (CI images and pipelines must never mutate themselves).
62
+ * An invalid env value is ignored rather than fatal — a typo must not break
63
+ * every command.
64
+ */
65
+ export function resolveSettings(input) {
66
+ const { prefs, env, isTTY, flags } = input;
67
+ const channel = resolveChannel(prefs, env, flags?.channel);
68
+ const mode = resolveMode(prefs, env, flags?.mode);
69
+ if (truthyEnv(env.MNEMOM_DISABLE_AUTOUPDATE)) {
70
+ return { channel, mode: "off", offReason: "MNEMOM_DISABLE_AUTOUPDATE is set" };
71
+ }
72
+ if (truthyEnv(env.CI))
73
+ return { channel, mode: "off", offReason: "running in CI" };
74
+ if (!isTTY)
75
+ return { channel, mode: "off", offReason: "not an interactive terminal" };
76
+ return { channel, mode };
77
+ }
78
+ /** The channel actually honoured: `next` requires a positive entitlement. */
79
+ export function effectiveChannel(requested, entitledToNext) {
80
+ return requested === "next" && entitledToNext ? "next" : "latest";
81
+ }
82
+ /** Whether the cached entitlement answer is still fresh enough to use. */
83
+ export function entitlementFresh(cache, now) {
84
+ return cache !== undefined && now - cache.checkedAt < ENTITLEMENT_TTL_MS;
85
+ }
86
+ /**
87
+ * The version a channel points at, or undefined when the registry offered
88
+ * nothing usable. Invalid tag values are dropped (defence against a malformed
89
+ * registry response).
90
+ */
91
+ export function pickTarget(tags, channel) {
92
+ const latest = tags.latest && isValidVersion(tags.latest) ? tags.latest : undefined;
93
+ // Mis-tag guard: the stable channel never offers a prerelease.
94
+ const stable = latest && !isPrerelease(latest) ? latest : undefined;
95
+ if (channel === "latest")
96
+ return stable;
97
+ const next = tags.next && isValidVersion(tags.next) ? tags.next : undefined;
98
+ if (!next)
99
+ return stable;
100
+ if (!stable)
101
+ return next;
102
+ return compareVersions(next, stable) >= 0 ? next : stable;
103
+ }
104
+ /** Whether the background worker should poll the registry now. */
105
+ export function shouldCheck(state, channel, now) {
106
+ if (state.backoffUntil !== undefined && now < state.backoffUntil)
107
+ return false;
108
+ if (state.lastCheck === undefined)
109
+ return true;
110
+ return now - state.lastCheck >= CHECK_INTERVAL_MS[channel];
111
+ }
112
+ export function backoffDelay(failures) {
113
+ const exp = BACKOFF_BASE_MS * 2 ** Math.max(0, failures - 1);
114
+ return Math.min(exp, BACKOFF_MAX_MS);
115
+ }
116
+ export function canSelfUpdate(method) {
117
+ return method === "npm-global";
118
+ }
119
+ /**
120
+ * The single decision: given where the registry is and how we're installed,
121
+ * install / notify / do nothing. `force` (explicit `mnemom update --force`)
122
+ * permits a downgrade to the channel's target.
123
+ */
124
+ export function decide(input) {
125
+ const { current, target, mode, method, installBlocked, force } = input;
126
+ if (mode === "off")
127
+ return { kind: "noop", reason: "updates are off" };
128
+ if (!target)
129
+ return { kind: "noop", reason: "no version published on this channel" };
130
+ const cmp = compareVersions(target, current);
131
+ if (cmp === 0)
132
+ return { kind: "noop", reason: "already on the newest version" };
133
+ if (cmp < 0 && !force)
134
+ return { kind: "noop", reason: "current build is newer than the channel" };
135
+ if (!canSelfUpdate(method)) {
136
+ return { kind: "notify", version: target, reason: `installed via ${method}` };
137
+ }
138
+ if (installBlocked) {
139
+ return { kind: "notify", version: target, reason: "global npm prefix is not writable" };
140
+ }
141
+ if (mode === "notify")
142
+ return { kind: "notify", version: target, reason: "update.mode is notify" };
143
+ return { kind: "install", version: target };
144
+ }
145
+ /** The command a human should run to upgrade, for the given install method. */
146
+ export function manualUpgradeCommand(method, version) {
147
+ const spec = `${PACKAGE_NAME}@${version}`;
148
+ switch (method) {
149
+ case "pnpm-global":
150
+ return `pnpm add -g ${spec}`;
151
+ case "yarn-global":
152
+ return `yarn global add ${spec}`;
153
+ case "bun-global":
154
+ return `bun add -g ${spec}`;
155
+ case "project":
156
+ return `npm install ${spec} (in your project)`;
157
+ case "npx":
158
+ return `npx ${spec}`;
159
+ default:
160
+ return `npm install -g ${spec}`;
161
+ }
162
+ }
163
+ /**
164
+ * What (if anything) to print on stderr at startup. A completed background
165
+ * install is announced once — only when the running binary IS the new version,
166
+ * so a user still inside an old process doesn't see a false "updated". Otherwise
167
+ * an available-but-not-installed version is surfaced at most once a day.
168
+ */
169
+ export function startupNotice(input) {
170
+ const { current, state, mode, method, now } = input;
171
+ if (state.pending && state.pending.to === current) {
172
+ return {
173
+ message: `✓ mnemom updated ${state.pending.from} → ${current} (${state.pending.channel})`,
174
+ patch: { pending: undefined },
175
+ };
176
+ }
177
+ if (mode === "off" || !state.available || !isValidVersion(state.available))
178
+ return null;
179
+ if (compareVersions(state.available, current) <= 0)
180
+ return null;
181
+ // In auto mode with a working self-update, the worker installs it — stay quiet.
182
+ if (mode === "auto" && canSelfUpdate(method) && !state.installBlocked)
183
+ return null;
184
+ if (state.lastNotified !== undefined && now - state.lastNotified < NOTIFY_INTERVAL_MS)
185
+ return null;
186
+ const cmd = !canSelfUpdate(method)
187
+ ? ` — run: ${manualUpgradeCommand(method, state.available)}`
188
+ : state.installBlocked
189
+ ? ` — the global npm prefix isn't writable; run: ${manualUpgradeCommand(method, state.available)} (see https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally)`
190
+ : " — run: mnemom update";
191
+ return {
192
+ message: `mnemom ${state.available} is available (you have ${current})${cmd}`,
193
+ patch: { lastNotified: now },
194
+ };
195
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The "auto-update" row for `mnemom agent doctor`: can this install update
3
+ * itself, on which channel, and did the last check go wrong? `updateDoctorRow`
4
+ * is pure; `probeUpdateStatus` gathers the real inputs.
5
+ */
6
+ import { type InstallMethod, type ResolvedSettings, type UpdateState } from "./decide.js";
7
+ export interface DoctorRow {
8
+ mark: "ok" | "warn" | "info";
9
+ text: string;
10
+ hint?: string;
11
+ }
12
+ export declare function updateDoctorRow(input: {
13
+ current: string;
14
+ settings: ResolvedSettings;
15
+ method: InstallMethod;
16
+ state: UpdateState;
17
+ }): DoctorRow;
18
+ export declare function probeUpdateStatus(): Promise<DoctorRow>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The "auto-update" row for `mnemom agent doctor`: can this install update
3
+ * itself, on which channel, and did the last check go wrong? `updateDoctorRow`
4
+ * is pure; `probeUpdateStatus` gathers the real inputs.
5
+ */
6
+ import { CLI_VERSION } from "../../version.js";
7
+ import { canSelfUpdate, manualUpgradeCommand, resolveSettings, } from "./decide.js";
8
+ import { classifyInstall } from "./install-method.js";
9
+ import { loadUpdateState } from "./state.js";
10
+ import { entryRealpath, loadUpdatePrefs, npmGlobalRoot } from "./system.js";
11
+ export function updateDoctorRow(input) {
12
+ const { current, settings, method, state } = input;
13
+ const version = state.available ?? "latest";
14
+ if (settings.mode === "off") {
15
+ return {
16
+ mark: "info",
17
+ text: `auto-update: off${settings.offReason ? ` (${settings.offReason})` : ""}`,
18
+ hint: "check manually with `mnemom update --check`",
19
+ };
20
+ }
21
+ if (!canSelfUpdate(method)) {
22
+ return {
23
+ mark: "info",
24
+ text: `auto-update: can't self-update (installed via ${method})`,
25
+ hint: `upgrade with: ${manualUpgradeCommand(method, version)}`,
26
+ };
27
+ }
28
+ if (state.installBlocked) {
29
+ return {
30
+ mark: "warn",
31
+ text: "auto-update: the global npm prefix is not writable",
32
+ hint: "fix npm's global prefix permissions (https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally), then run `mnemom update`",
33
+ };
34
+ }
35
+ if (settings.channel === "next" && state.entitlement?.next === false) {
36
+ return {
37
+ mark: "warn",
38
+ text: "auto-update: update.channel=next, but this account isn't invited to it — following latest",
39
+ hint: "ask your Mnemom contact for a next-channel invite, or `mnemom config set update.channel latest`",
40
+ };
41
+ }
42
+ if (state.lastError) {
43
+ return {
44
+ mark: "warn",
45
+ text: `auto-update: last check failed (${state.lastError})`,
46
+ hint: "it retries automatically with backoff; `mnemom update` retries now",
47
+ };
48
+ }
49
+ const pending = state.available ? `, ${state.available} available` : "";
50
+ return {
51
+ mark: "ok",
52
+ text: `auto-update: ${settings.mode} on ${settings.channel} (v${current}${pending})`,
53
+ };
54
+ }
55
+ export async function probeUpdateStatus() {
56
+ const settings = resolveSettings({
57
+ prefs: loadUpdatePrefs(),
58
+ env: process.env,
59
+ isTTY: process.stdout.isTTY === true,
60
+ });
61
+ const method = classifyInstall(entryRealpath(process.argv[1]), await npmGlobalRoot());
62
+ return updateDoctorRow({ current: CLI_VERSION, settings, method, state: loadUpdateState() });
63
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Classify how the running CLI was installed, from the realpath of its entry
3
+ * script. Only a plain `npm install -g` is self-updatable; every other shape
4
+ * gets the right manual command instead of an `npm i -g` that would install a
5
+ * second, shadowed copy.
6
+ */
7
+ import type { InstallMethod } from "./decide.js";
8
+ /**
9
+ * @param entryRealpath realpath of process.argv[1] (the bin symlink resolved)
10
+ * @param npmGlobalRoot output of `npm root -g`, or undefined if npm is unavailable
11
+ */
12
+ export declare function classifyInstall(entryRealpath: string, npmGlobalRoot: string | undefined): InstallMethod;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Classify how the running CLI was installed, from the realpath of its entry
3
+ * script. Only a plain `npm install -g` is self-updatable; every other shape
4
+ * gets the right manual command instead of an `npm i -g` that would install a
5
+ * second, shadowed copy.
6
+ */
7
+ /** Normalise Windows separators so one set of patterns covers every platform. */
8
+ function norm(p) {
9
+ return p.replace(/\\/g, "/");
10
+ }
11
+ /**
12
+ * @param entryRealpath realpath of process.argv[1] (the bin symlink resolved)
13
+ * @param npmGlobalRoot output of `npm root -g`, or undefined if npm is unavailable
14
+ */
15
+ export function classifyInstall(entryRealpath, npmGlobalRoot) {
16
+ const p = norm(entryRealpath);
17
+ if (/\.(c|m)?ts$/.test(p))
18
+ return "dev";
19
+ if (p.includes("/_npx/"))
20
+ return "npx";
21
+ if (p.includes("/.pnpm/") || p.includes("/pnpm/global/"))
22
+ return "pnpm-global";
23
+ if (p.includes("/.yarn/") || p.includes("/yarn/global/"))
24
+ return "yarn-global";
25
+ if (p.includes("/.bun/"))
26
+ return "bun-global";
27
+ if (npmGlobalRoot && p.startsWith(`${norm(npmGlobalRoot).replace(/\/$/, "")}/@mnemom/mnemom/`)) {
28
+ return "npm-global";
29
+ }
30
+ if (p.includes("/node_modules/@mnemom/mnemom/"))
31
+ return "project";
32
+ return "unknown";
33
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Turn the server's minimum-CLI hard-fail (lib/version-gate.ts) into a
3
+ * one-retry fix: when update.mode is auto and this install can update itself,
4
+ * install the newest stable release satisfying the floor, in the foreground,
5
+ * and ask the user to re-run. Synchronous because the gate is.
6
+ */
7
+ import { spawnSync, type StdioOptions } from "node:child_process";
8
+ import { type InstallMethod, type ResolvedSettings } from "./decide.js";
9
+ import { type NpmInvocation } from "./system.js";
10
+ export interface FloorUpgradeDeps {
11
+ settings(): ResolvedSettings;
12
+ method(): InstallMethod;
13
+ /** The same install lock the worker and `mnemom update` hold. */
14
+ acquireLock(): (() => void) | null;
15
+ /** Install `spec` (an npm version range); true on success. */
16
+ install(spec: string): boolean;
17
+ write(chunk: string): void;
18
+ }
19
+ /**
20
+ * Returns true when the CLI was upgraded. Only in `auto` mode — `notify` means
21
+ * "never install without my say-so", even for a server floor. The floor is
22
+ * untrusted header input: it must be a plain release version before it goes
23
+ * anywhere near argv. Another installer holding the lock → don't race it.
24
+ */
25
+ export declare function upgradeToFloor(floor: string, deps: FloorUpgradeDeps): boolean;
26
+ /** Sync npm spawn (the gate is synchronous); never a shell. */
27
+ export declare function spawnNpmSync(inv: NpmInvocation | null, args: string[], opts: {
28
+ stdio?: StdioOptions;
29
+ timeout: number;
30
+ }, spawn?: typeof spawnSync): {
31
+ ok: boolean;
32
+ stdout: string;
33
+ };
34
+ export declare function defaultFloorUpgradeDeps(): FloorUpgradeDeps;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Turn the server's minimum-CLI hard-fail (lib/version-gate.ts) into a
3
+ * one-retry fix: when update.mode is auto and this install can update itself,
4
+ * install the newest stable release satisfying the floor, in the foreground,
5
+ * and ask the user to re-run. Synchronous because the gate is.
6
+ */
7
+ import { spawnSync } from "node:child_process";
8
+ import { canSelfUpdate, PACKAGE_NAME, resolveSettings, } from "./decide.js";
9
+ import { classifyInstall } from "./install-method.js";
10
+ import { isValidVersion, isPrerelease } from "./semver.js";
11
+ import { acquireLock } from "./state.js";
12
+ import { entryRealpath, loadUpdatePrefs, npmInvocation } from "./system.js";
13
+ /**
14
+ * Returns true when the CLI was upgraded. Only in `auto` mode — `notify` means
15
+ * "never install without my say-so", even for a server floor. The floor is
16
+ * untrusted header input: it must be a plain release version before it goes
17
+ * anywhere near argv. Another installer holding the lock → don't race it.
18
+ */
19
+ export function upgradeToFloor(floor, deps) {
20
+ if (!isValidVersion(floor) || isPrerelease(floor))
21
+ return false;
22
+ if (deps.settings().mode !== "auto")
23
+ return false;
24
+ if (!canSelfUpdate(deps.method()))
25
+ return false;
26
+ const release = deps.acquireLock();
27
+ if (!release)
28
+ return false;
29
+ try {
30
+ deps.write(`\nUpdating the Mnemom CLI to meet the server minimum (v${floor})…\n`);
31
+ // A range, not a tag: npm picks the newest STABLE version ≥ floor (ranges
32
+ // exclude prereleases), so a mis-tagged `latest` can't sneak in here.
33
+ return deps.install(`${PACKAGE_NAME}@>=${floor}`);
34
+ }
35
+ finally {
36
+ release();
37
+ }
38
+ }
39
+ /** Sync npm spawn (the gate is synchronous); never a shell. */
40
+ export function spawnNpmSync(inv, args, opts, spawn = spawnSync) {
41
+ if (!inv)
42
+ return { ok: false, stdout: "" };
43
+ const r = spawn(inv.file, [...inv.prefix, ...args], {
44
+ encoding: "utf8",
45
+ windowsHide: true,
46
+ ...opts,
47
+ });
48
+ return { ok: r.status === 0, stdout: typeof r.stdout === "string" ? r.stdout : "" };
49
+ }
50
+ export function defaultFloorUpgradeDeps() {
51
+ const inv = npmInvocation();
52
+ return {
53
+ settings: () => resolveSettings({
54
+ prefs: loadUpdatePrefs(),
55
+ env: process.env,
56
+ isTTY: process.stdout.isTTY === true,
57
+ }),
58
+ method: () => {
59
+ const root = spawnNpmSync(inv, ["root", "-g"], { timeout: 15_000 });
60
+ return classifyInstall(entryRealpath(process.argv[1]), root.ok ? root.stdout.trim() : undefined);
61
+ },
62
+ acquireLock: () => acquireLock(),
63
+ install: (spec) => spawnNpmSync(inv, ["install", "-g", "--no-fund", "--no-audit", spec], {
64
+ stdio: ["ignore", 2, 2],
65
+ timeout: 5 * 60 * 1000,
66
+ }).ok,
67
+ write: (chunk) => void process.stderr.write(chunk),
68
+ };
69
+ }