@mnemom/mnemom 0.17.2-next.6 → 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 (53) 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-slack.d.ts +1 -0
  6. package/dist/commands/agent-slack.js +33 -4
  7. package/dist/commands/agent.js +11 -3
  8. package/dist/commands/config.d.ts +36 -0
  9. package/dist/commands/config.js +82 -0
  10. package/dist/commands/update.d.ts +30 -0
  11. package/dist/commands/update.js +90 -0
  12. package/dist/index.js +41 -0
  13. package/dist/lib/agent.d.ts +13 -7
  14. package/dist/lib/agent.js +12 -12
  15. package/dist/lib/cli-config.d.ts +8 -0
  16. package/dist/lib/cli-config.js +13 -0
  17. package/dist/lib/slack-channel.d.ts +3 -0
  18. package/dist/lib/slack-channel.js +78 -5
  19. package/dist/lib/slack-route.d.ts +5 -0
  20. package/dist/lib/slack-router.js +28 -2
  21. package/dist/lib/slack-state.d.ts +8 -0
  22. package/dist/lib/slack-state.js +11 -0
  23. package/dist/lib/slack.d.ts +38 -0
  24. package/dist/lib/slack.js +69 -0
  25. package/dist/lib/update/action.d.ts +9 -0
  26. package/dist/lib/update/action.js +17 -0
  27. package/dist/lib/update/decide.d.ts +152 -0
  28. package/dist/lib/update/decide.js +195 -0
  29. package/dist/lib/update/doctor.d.ts +18 -0
  30. package/dist/lib/update/doctor.js +63 -0
  31. package/dist/lib/update/install-method.d.ts +12 -0
  32. package/dist/lib/update/install-method.js +33 -0
  33. package/dist/lib/update/min-version.d.ts +34 -0
  34. package/dist/lib/update/min-version.js +69 -0
  35. package/dist/lib/update/registry.d.ts +27 -0
  36. package/dist/lib/update/registry.js +50 -0
  37. package/dist/lib/update/run.d.ts +54 -0
  38. package/dist/lib/update/run.js +100 -0
  39. package/dist/lib/update/semver.d.ts +22 -0
  40. package/dist/lib/update/semver.js +65 -0
  41. package/dist/lib/update/startup.d.ts +27 -0
  42. package/dist/lib/update/startup.js +75 -0
  43. package/dist/lib/update/state.d.ts +23 -0
  44. package/dist/lib/update/state.js +83 -0
  45. package/dist/lib/update/system.d.ts +55 -0
  46. package/dist/lib/update/system.js +104 -0
  47. package/dist/lib/update/worker.d.ts +42 -0
  48. package/dist/lib/update/worker.js +104 -0
  49. package/dist/lib/version-gate.d.ts +2 -1
  50. package/dist/lib/version-gate.js +9 -1
  51. package/dist/update-worker.d.ts +6 -0
  52. package/dist/update-worker.js +8 -0
  53. package/package.json +1 -1
@@ -0,0 +1,54 @@
1
+ /**
2
+ * One update pass — shared by the detached background worker and the
3
+ * foreground `mnemom update` command. Pure orchestration over injected deps:
4
+ * fetch dist-tags → resolve the `next` entitlement → pick a target → decide →
5
+ * (maybe) install → persist state. Never throws; failures become state
6
+ * (backoff) and a result the caller may print.
7
+ */
8
+ import { type DistTags, type InstallMethod, type ResolvedSettings, type UpdateAction, type UpdateChannel, type UpdateState } from "./decide.js";
9
+ import type { EntitlementResult } from "./registry.js";
10
+ export type InstallResult = {
11
+ ok: true;
12
+ } | {
13
+ ok: false;
14
+ eacces: boolean;
15
+ message: string;
16
+ };
17
+ export interface RunDeps {
18
+ current: string;
19
+ now(): number;
20
+ loadState(): UpdateState;
21
+ saveState(patch: Partial<UpdateState>): void;
22
+ fetchDistTags(): Promise<DistTags>;
23
+ fetchEntitlement(): Promise<EntitlementResult>;
24
+ installMethod(): Promise<InstallMethod>;
25
+ install(version: string): Promise<InstallResult>;
26
+ /**
27
+ * Gate before installing: the background worker waits here for the CLI
28
+ * process that spawned it to exit (never swap files under a live
29
+ * `mnemom agent` session). Resolving false skips the install this pass.
30
+ */
31
+ beforeInstall?(): Promise<boolean>;
32
+ }
33
+ export interface RunResult {
34
+ /** The channel actually honoured (next falls back to latest when not entitled). */
35
+ channel: UpdateChannel;
36
+ /** True when `next` was requested but not honoured. */
37
+ channelDowngraded: boolean;
38
+ method?: InstallMethod;
39
+ target?: string;
40
+ action?: UpdateAction;
41
+ installed?: string;
42
+ error?: string;
43
+ }
44
+ /**
45
+ * Resolve the `next` entitlement: a fresh cache wins; otherwise ask the API.
46
+ * A definitive answer is cached; "unavailable" falls back to any cached value
47
+ * (stale is better than flapping a paying customer off their channel during an
48
+ * API blip); "unauthenticated" is simply not entitled.
49
+ */
50
+ export declare function resolveEntitlement(deps: Pick<RunDeps, "fetchEntitlement" | "saveState">, state: UpdateState, now: number): Promise<boolean>;
51
+ export declare function runUpdate(deps: RunDeps, settings: ResolvedSettings, opts?: {
52
+ force?: boolean;
53
+ foreground?: boolean;
54
+ }): Promise<RunResult>;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * One update pass — shared by the detached background worker and the
3
+ * foreground `mnemom update` command. Pure orchestration over injected deps:
4
+ * fetch dist-tags → resolve the `next` entitlement → pick a target → decide →
5
+ * (maybe) install → persist state. Never throws; failures become state
6
+ * (backoff) and a result the caller may print.
7
+ */
8
+ import { backoffDelay, decide, effectiveChannel, entitlementFresh, pickTarget, } from "./decide.js";
9
+ import { compareVersions } from "./semver.js";
10
+ /**
11
+ * Resolve the `next` entitlement: a fresh cache wins; otherwise ask the API.
12
+ * A definitive answer is cached; "unavailable" falls back to any cached value
13
+ * (stale is better than flapping a paying customer off their channel during an
14
+ * API blip); "unauthenticated" is simply not entitled.
15
+ */
16
+ export async function resolveEntitlement(deps, state, now) {
17
+ const cached = state.entitlement;
18
+ if (cached && entitlementFresh(cached, now))
19
+ return cached.next;
20
+ const res = await deps.fetchEntitlement();
21
+ if (res === "unauthenticated")
22
+ return false;
23
+ if (res === "unavailable")
24
+ return state.entitlement?.next ?? false;
25
+ deps.saveState({ entitlement: { next: res.next, checkedAt: now } });
26
+ return res.next;
27
+ }
28
+ function failurePatch(state, now, message) {
29
+ const failures = (state.failures ?? 0) + 1;
30
+ return {
31
+ failures,
32
+ backoffUntil: now + backoffDelay(failures),
33
+ lastError: message,
34
+ lastCheck: now,
35
+ };
36
+ }
37
+ export async function runUpdate(deps, settings, opts = {}) {
38
+ const now = deps.now();
39
+ const state = deps.loadState();
40
+ let tags;
41
+ try {
42
+ tags = await deps.fetchDistTags();
43
+ }
44
+ catch (err) {
45
+ const message = err instanceof Error ? err.message : String(err);
46
+ deps.saveState(failurePatch(state, now, message));
47
+ return { channel: settings.channel, channelDowngraded: false, error: message };
48
+ }
49
+ const entitled = settings.channel === "next" ? await resolveEntitlement(deps, state, now) : false;
50
+ const channel = effectiveChannel(settings.channel, entitled);
51
+ const channelDowngraded = channel !== settings.channel;
52
+ const target = pickTarget(tags, channel);
53
+ const method = await deps.installMethod();
54
+ const action = decide({
55
+ current: deps.current,
56
+ target,
57
+ mode: settings.mode,
58
+ method,
59
+ // An explicit `mnemom update` retries even after an EACCES (the user may have fixed it).
60
+ installBlocked: opts.foreground ? false : state.installBlocked,
61
+ force: opts.force,
62
+ });
63
+ const newer = target !== undefined && compareVersions(target, deps.current) > 0;
64
+ const base = {
65
+ lastCheck: now,
66
+ method,
67
+ available: newer ? target : undefined,
68
+ failures: undefined,
69
+ backoffUntil: undefined,
70
+ lastError: undefined,
71
+ };
72
+ const result = { channel, channelDowngraded, method, target, action };
73
+ if (action.kind !== "install") {
74
+ deps.saveState(base);
75
+ return result;
76
+ }
77
+ const proceed = deps.beforeInstall ? await deps.beforeInstall() : true;
78
+ if (!proceed) {
79
+ deps.saveState(base);
80
+ return { ...result, action: { kind: "notify", version: action.version, reason: "deferred" } };
81
+ }
82
+ const outcome = await deps.install(action.version);
83
+ if (outcome.ok) {
84
+ deps.saveState({
85
+ ...base,
86
+ available: undefined,
87
+ installBlocked: undefined,
88
+ pending: { from: deps.current, to: action.version, channel },
89
+ });
90
+ return { ...result, installed: action.version };
91
+ }
92
+ if (outcome.eacces) {
93
+ // Retrying can't fix permissions — switch to notify-with-instructions.
94
+ deps.saveState({ ...base, installBlocked: true, lastError: outcome.message });
95
+ }
96
+ else {
97
+ deps.saveState({ ...failurePatch(state, now, outcome.message), available: base.available });
98
+ }
99
+ return { ...result, error: outcome.message };
100
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Minimal SemVer 2.0 precedence for auto-update decisions.
3
+ *
4
+ * `lib/version-gate.ts#isBelowVersion` deliberately IGNORES prerelease tags (a
5
+ * `-rc` of a good release passes the server floor). Auto-update needs the
6
+ * opposite: `0.17.2-next.3 < 0.17.2 < 0.17.3-next.0` must order correctly so a
7
+ * `next` user moves onto the stable cut and a `latest` user never regresses.
8
+ * Build metadata (`+…`) is ignored, per spec.
9
+ */
10
+ interface Parsed {
11
+ core: [number, number, number];
12
+ pre: string[];
13
+ }
14
+ export declare function parseVersion(v: string): Parsed | null;
15
+ export declare function isValidVersion(v: string): boolean;
16
+ export declare function isPrerelease(v: string): boolean;
17
+ /**
18
+ * -1 / 0 / 1 by SemVer precedence. Throws on an unparseable version — callers
19
+ * validate registry input with `isValidVersion` first.
20
+ */
21
+ export declare function compareVersions(a: string, b: string): number;
22
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Minimal SemVer 2.0 precedence for auto-update decisions.
3
+ *
4
+ * `lib/version-gate.ts#isBelowVersion` deliberately IGNORES prerelease tags (a
5
+ * `-rc` of a good release passes the server floor). Auto-update needs the
6
+ * opposite: `0.17.2-next.3 < 0.17.2 < 0.17.3-next.0` must order correctly so a
7
+ * `next` user moves onto the stable cut and a `latest` user never regresses.
8
+ * Build metadata (`+…`) is ignored, per spec.
9
+ */
10
+ const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
11
+ export function parseVersion(v) {
12
+ const m = VERSION_RE.exec(v.trim());
13
+ if (!m)
14
+ return null;
15
+ return {
16
+ core: [Number(m[1]), Number(m[2]), Number(m[3])],
17
+ pre: m[4] ? m[4].split(".") : [],
18
+ };
19
+ }
20
+ export function isValidVersion(v) {
21
+ return parseVersion(v) !== null;
22
+ }
23
+ export function isPrerelease(v) {
24
+ return (parseVersion(v)?.pre.length ?? 0) > 0;
25
+ }
26
+ function compareIdent(a, b) {
27
+ const an = /^\d+$/.test(a);
28
+ const bn = /^\d+$/.test(b);
29
+ if (an && bn)
30
+ return Math.sign(Number(a) - Number(b));
31
+ // Numeric identifiers always have lower precedence than alphanumeric ones.
32
+ if (an !== bn)
33
+ return an ? -1 : 1;
34
+ return a < b ? -1 : a > b ? 1 : 0;
35
+ }
36
+ /**
37
+ * -1 / 0 / 1 by SemVer precedence. Throws on an unparseable version — callers
38
+ * validate registry input with `isValidVersion` first.
39
+ */
40
+ export function compareVersions(a, b) {
41
+ const pa = parseVersion(a);
42
+ const pb = parseVersion(b);
43
+ if (!pa || !pb)
44
+ throw new Error(`invalid version: ${!pa ? a : b}`);
45
+ for (let i = 0; i < 3; i++) {
46
+ if (pa.core[i] !== pb.core[i])
47
+ return Math.sign(pa.core[i] - pb.core[i]);
48
+ }
49
+ // A release outranks any prerelease of the same core.
50
+ if (pa.pre.length === 0 || pb.pre.length === 0) {
51
+ return Math.sign(pb.pre.length - pa.pre.length);
52
+ }
53
+ const len = Math.max(pa.pre.length, pb.pre.length);
54
+ for (let i = 0; i < len; i++) {
55
+ // A shorter identifier list is lower precedence when all shared ones match.
56
+ if (pa.pre[i] === undefined)
57
+ return -1;
58
+ if (pb.pre[i] === undefined)
59
+ return 1;
60
+ const c = compareIdent(pa.pre[i], pb.pre[i]);
61
+ if (c !== 0)
62
+ return c;
63
+ }
64
+ return 0;
65
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The per-invocation hook, run before commander parses argv. It must cost
3
+ * ~nothing and never fail the user's command:
4
+ * 1. print a one-line stderr notice (completed update, or an available one);
5
+ * 2. if a check is due, spawn the detached worker and return immediately.
6
+ * All I/O is injected; `defaultStartupDeps()` wires the real process.
7
+ */
8
+ import { type UpdateChannel, type UpdateMode, type UpdatePrefs, type UpdateState } from "./decide.js";
9
+ export interface StartupDeps {
10
+ current: string;
11
+ argv: string[];
12
+ env: Record<string, string | undefined>;
13
+ stdoutTTY: boolean;
14
+ stderrTTY: boolean;
15
+ now(): number;
16
+ prefs(): UpdatePrefs;
17
+ loadState(): UpdateState;
18
+ saveState(patch: Partial<UpdateState>): void;
19
+ entryPath: string;
20
+ writeErr(line: string): void;
21
+ spawnWorker(args: [entry: string, channel: UpdateChannel, mode: UpdateMode, parentPid: string]): void;
22
+ pid: number;
23
+ }
24
+ export declare function maybeRunUpdateHook(deps: StartupDeps): void;
25
+ /** dist/lib/update/startup.js → dist/update-worker.js */
26
+ export declare const WORKER_PATH: string;
27
+ export declare function defaultStartupDeps(argv: string[]): StartupDeps;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The per-invocation hook, run before commander parses argv. It must cost
3
+ * ~nothing and never fail the user's command:
4
+ * 1. print a one-line stderr notice (completed update, or an available one);
5
+ * 2. if a check is due, spawn the detached worker and return immediately.
6
+ * All I/O is injected; `defaultStartupDeps()` wires the real process.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { fileURLToPath } from "node:url";
10
+ import { CLI_VERSION } from "../../version.js";
11
+ import { resolveSettings, shouldCheck, startupNotice, } from "./decide.js";
12
+ import { classifyInstall } from "./install-method.js";
13
+ import { loadUpdateState, saveUpdateState } from "./state.js";
14
+ import { entryRealpath, loadUpdatePrefs } from "./system.js";
15
+ /** Commands that manage updates themselves, or must stay side-effect free. */
16
+ const SKIP_COMMANDS = new Set(["update", "config"]);
17
+ export function maybeRunUpdateHook(deps) {
18
+ try {
19
+ const settings = resolveSettings({ prefs: deps.prefs(), env: deps.env, isTTY: deps.stdoutTTY });
20
+ if (settings.mode === "off")
21
+ return;
22
+ if (classifyInstall(deps.entryPath, undefined) === "dev")
23
+ return;
24
+ const now = deps.now();
25
+ const state = deps.loadState();
26
+ const notice = startupNotice({
27
+ current: deps.current,
28
+ state,
29
+ mode: settings.mode,
30
+ method: state.method ?? "unknown",
31
+ now,
32
+ });
33
+ if (notice && deps.stderrTTY && !deps.argv.includes("--json")) {
34
+ deps.writeErr(`${notice.message}\n`);
35
+ deps.saveState(notice.patch);
36
+ }
37
+ if (SKIP_COMMANDS.has(deps.argv[0] ?? ""))
38
+ return;
39
+ if (!shouldCheck(state, settings.channel, now))
40
+ return;
41
+ // Stamp first so a burst of commands doesn't spawn a burst of workers.
42
+ deps.saveState({ lastCheck: now });
43
+ deps.spawnWorker([deps.entryPath, settings.channel, settings.mode, String(deps.pid)]);
44
+ }
45
+ catch {
46
+ /* the updater never breaks the user's command */
47
+ }
48
+ }
49
+ /** dist/lib/update/startup.js → dist/update-worker.js */
50
+ export const WORKER_PATH = fileURLToPath(new URL("../../update-worker.js", import.meta.url));
51
+ export function defaultStartupDeps(argv) {
52
+ return {
53
+ current: CLI_VERSION,
54
+ argv,
55
+ env: process.env,
56
+ stdoutTTY: process.stdout.isTTY === true,
57
+ stderrTTY: process.stderr.isTTY === true,
58
+ now: Date.now,
59
+ prefs: loadUpdatePrefs,
60
+ loadState: () => loadUpdateState(),
61
+ saveState: (patch) => saveUpdateState(patch),
62
+ entryPath: entryRealpath(process.argv[1]),
63
+ pid: process.pid,
64
+ writeErr: (line) => void process.stderr.write(line),
65
+ spawnWorker: (args) => {
66
+ const child = spawn(process.execPath, [WORKER_PATH, ...args], {
67
+ detached: true,
68
+ stdio: "ignore",
69
+ windowsHide: true,
70
+ });
71
+ child.on("error", () => { });
72
+ child.unref();
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Persistence for the updater: ~/.mnemom/update-state.json (throttle, backoff,
3
+ * pending notice, entitlement cache) and ~/.mnemom/update.lock (one installer
4
+ * at a time across every open terminal).
5
+ *
6
+ * Every failure here degrades silently — the updater must never break the
7
+ * command the user actually ran.
8
+ */
9
+ import type { UpdateState } from "./decide.js";
10
+ /** A lock older than this is from a crashed worker and may be taken over. */
11
+ export declare const LOCK_STALE_MS: number;
12
+ export declare function stateFile(dir?: string): string;
13
+ export declare function lockFile(dir?: string): string;
14
+ /** Missing, unreadable or corrupt → `{}` (read-and-catch; no exists-then-read race). */
15
+ export declare function loadUpdateState(dir?: string): UpdateState;
16
+ /** Merge `patch` into the stored state (undefined values delete keys); atomic write. */
17
+ export declare function saveUpdateState(patch: Partial<UpdateState>, dir?: string): void;
18
+ /**
19
+ * Take the install lock with O_EXCL. A lock older than LOCK_STALE_MS is removed
20
+ * and retried once. Returns a release function, or null if another live
21
+ * process holds it.
22
+ */
23
+ export declare function acquireLock(dir?: string, now?: number): (() => void) | null;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Persistence for the updater: ~/.mnemom/update-state.json (throttle, backoff,
3
+ * pending notice, entitlement cache) and ~/.mnemom/update.lock (one installer
4
+ * at a time across every open terminal).
5
+ *
6
+ * Every failure here degrades silently — the updater must never break the
7
+ * command the user actually ran.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { MNEMOM_DIR } from "../config.js";
12
+ /** A lock older than this is from a crashed worker and may be taken over. */
13
+ export const LOCK_STALE_MS = 10 * 60 * 1000;
14
+ export function stateFile(dir = MNEMOM_DIR) {
15
+ return path.join(dir, "update-state.json");
16
+ }
17
+ export function lockFile(dir = MNEMOM_DIR) {
18
+ return path.join(dir, "update.lock");
19
+ }
20
+ /** Missing, unreadable or corrupt → `{}` (read-and-catch; no exists-then-read race). */
21
+ export function loadUpdateState(dir = MNEMOM_DIR) {
22
+ try {
23
+ const parsed = JSON.parse(fs.readFileSync(stateFile(dir), "utf-8"));
24
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
25
+ ? parsed
26
+ : {};
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ /** Merge `patch` into the stored state (undefined values delete keys); atomic write. */
33
+ export function saveUpdateState(patch, dir = MNEMOM_DIR) {
34
+ try {
35
+ const merged = { ...loadUpdateState(dir), ...patch };
36
+ const next = Object.fromEntries(Object.entries(merged).filter(([, v]) => v !== undefined));
37
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
38
+ const tmp = `${stateFile(dir)}.${process.pid}.tmp`;
39
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
40
+ fs.renameSync(tmp, stateFile(dir));
41
+ }
42
+ catch {
43
+ /* best effort */
44
+ }
45
+ }
46
+ /**
47
+ * Take the install lock with O_EXCL. A lock older than LOCK_STALE_MS is removed
48
+ * and retried once. Returns a release function, or null if another live
49
+ * process holds it.
50
+ */
51
+ export function acquireLock(dir = MNEMOM_DIR, now = Date.now()) {
52
+ const file = lockFile(dir);
53
+ const tryCreate = () => {
54
+ try {
55
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
56
+ fs.writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 });
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ };
63
+ if (!tryCreate()) {
64
+ try {
65
+ if (now - fs.statSync(file).mtimeMs < LOCK_STALE_MS)
66
+ return null;
67
+ fs.rmSync(file, { force: true });
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ if (!tryCreate())
73
+ return null;
74
+ }
75
+ return () => {
76
+ try {
77
+ fs.rmSync(file, { force: true });
78
+ }
79
+ catch {
80
+ /* best effort */
81
+ }
82
+ };
83
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The real-world side of the updater: spawn npm, read credentials, read prefs.
3
+ * Each function takes its process-level dependencies as injectable parameters
4
+ * so the branches are testable without touching the machine.
5
+ */
6
+ import { resolveAuth } from "../auth.js";
7
+ import { type UpdatePrefs } from "./decide.js";
8
+ import type { ApiCredential } from "./registry.js";
9
+ import type { InstallResult } from "./run.js";
10
+ export declare const INSTALL_TIMEOUT_MS: number;
11
+ type ExecFile = (file: string, args: string[], opts: {
12
+ timeout: number;
13
+ windowsHide: boolean;
14
+ }, cb: (err: (Error & {
15
+ code?: unknown;
16
+ }) | null, stdout: string, stderr: string) => void) => unknown;
17
+ /** How to run npm WITHOUT a shell: the program plus any leading args. */
18
+ export interface NpmInvocation {
19
+ file: string;
20
+ prefix: string[];
21
+ }
22
+ /**
23
+ * Never `shell: true` (npm's registry-/header-derived arguments must not reach
24
+ * a shell). POSIX runs `npm` directly. On Windows `npm` is a `.cmd` shim that
25
+ * can't be spawned without a shell, so run the npm CLI script with this node
26
+ * binary instead — it ships beside node.exe in every standard Windows install.
27
+ * No such script → null, and the caller treats npm as unavailable (notify only).
28
+ */
29
+ export declare function npmInvocation(platform?: NodeJS.Platform, execPath?: string, exists?: (p: string) => boolean): NpmInvocation | null;
30
+ /** `npm root -g`, or undefined when npm isn't available / fails. */
31
+ export declare function npmGlobalRoot(execFile?: ExecFile, inv?: NpmInvocation | null): Promise<string | undefined>;
32
+ /** Permission failures can't be fixed by retrying — the caller switches to notify. */
33
+ export declare function isPermissionError(text: string): boolean;
34
+ /**
35
+ * `npm install -g @mnemom/mnemom@<exact version>` — exact, never a tag (no
36
+ * tag-move race). The version is re-validated here, at the sink, even though
37
+ * `pickTarget` already filtered it.
38
+ */
39
+ export declare function npmInstallGlobal(version: string, execFile?: ExecFile, inv?: NpmInvocation | null): Promise<InstallResult>;
40
+ /** realpath of the entry script (the global bin is a symlink); falls back to the raw path. */
41
+ export declare function entryRealpath(argv1: string | undefined, realpath?: (p: string) => string): string;
42
+ /**
43
+ * Credential for the BACKGROUND worker. Deliberately never refreshes: a
44
+ * detached refresh racing a foreground command could rotate the refresh token
45
+ * out from under it. An expired stored token just means "unauthenticated" for
46
+ * this pass (the cached entitlement still applies).
47
+ */
48
+ export declare function backgroundCredential(env?: Record<string, string | undefined>, auth?: {
49
+ accessToken: string;
50
+ expiresAt: number;
51
+ } | null, nowSec?: number): ApiCredential;
52
+ /** Credential for the FOREGROUND `mnemom update` — normal resolution, refresh allowed. */
53
+ export declare function foregroundCredential(resolve?: typeof resolveAuth): Promise<ApiCredential>;
54
+ export declare function loadUpdatePrefs(): UpdatePrefs;
55
+ export {};
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The real-world side of the updater: spawn npm, read credentials, read prefs.
3
+ * Each function takes its process-level dependencies as injectable parameters
4
+ * so the branches are testable without touching the machine.
5
+ */
6
+ import { execFile as nodeExecFile } from "node:child_process";
7
+ import { existsSync, realpathSync } from "node:fs";
8
+ import { win32 } from "node:path";
9
+ import { getAuthInfo, resolveAuth } from "../auth.js";
10
+ import { loadCliConfig } from "../cli-config.js";
11
+ import { PACKAGE_NAME } from "./decide.js";
12
+ import { isValidVersion } from "./semver.js";
13
+ export const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
14
+ /**
15
+ * Never `shell: true` (npm's registry-/header-derived arguments must not reach
16
+ * a shell). POSIX runs `npm` directly. On Windows `npm` is a `.cmd` shim that
17
+ * can't be spawned without a shell, so run the npm CLI script with this node
18
+ * binary instead — it ships beside node.exe in every standard Windows install.
19
+ * No such script → null, and the caller treats npm as unavailable (notify only).
20
+ */
21
+ export function npmInvocation(platform = process.platform, execPath = process.execPath, exists = existsSync) {
22
+ if (platform !== "win32")
23
+ return { file: "npm", prefix: [] };
24
+ const cli = win32.join(win32.dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js");
25
+ return exists(cli) ? { file: execPath, prefix: [cli] } : null;
26
+ }
27
+ function run(inv, args, timeout, execFile) {
28
+ return new Promise((resolve) => {
29
+ execFile(inv.file, [...inv.prefix, ...args], { timeout, windowsHide: true }, (err, stdout, stderr) => resolve({ err, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }));
30
+ });
31
+ }
32
+ /** `npm root -g`, or undefined when npm isn't available / fails. */
33
+ export async function npmGlobalRoot(execFile = nodeExecFile, inv = npmInvocation()) {
34
+ if (!inv)
35
+ return undefined;
36
+ const { err, stdout } = await run(inv, ["root", "-g"], 15_000, execFile);
37
+ const root = stdout.trim();
38
+ return err || !root ? undefined : root;
39
+ }
40
+ /** Permission failures can't be fixed by retrying — the caller switches to notify. */
41
+ export function isPermissionError(text) {
42
+ return /\bE(ACCES|PERM)\b/.test(text);
43
+ }
44
+ /**
45
+ * `npm install -g @mnemom/mnemom@<exact version>` — exact, never a tag (no
46
+ * tag-move race). The version is re-validated here, at the sink, even though
47
+ * `pickTarget` already filtered it.
48
+ */
49
+ export async function npmInstallGlobal(version, execFile = nodeExecFile, inv = npmInvocation()) {
50
+ if (!isValidVersion(version)) {
51
+ return {
52
+ ok: false,
53
+ eacces: false,
54
+ message: `refusing to install invalid version '${version}'`,
55
+ };
56
+ }
57
+ if (!inv)
58
+ return { ok: false, eacces: false, message: "npm is not available" };
59
+ const { err, stderr } = await run(inv, ["install", "-g", "--no-fund", "--no-audit", `${PACKAGE_NAME}@${version}`], INSTALL_TIMEOUT_MS, execFile);
60
+ if (!err)
61
+ return { ok: true };
62
+ const detail = `${err.message}\n${stderr}`;
63
+ const lastLine = stderr.trim().split("\n").pop() || err.message;
64
+ return { ok: false, eacces: isPermissionError(detail), message: lastLine.slice(0, 300) };
65
+ }
66
+ /** realpath of the entry script (the global bin is a symlink); falls back to the raw path. */
67
+ export function entryRealpath(argv1, realpath = realpathSync) {
68
+ if (!argv1)
69
+ return "";
70
+ try {
71
+ return realpath(argv1);
72
+ }
73
+ catch {
74
+ return argv1;
75
+ }
76
+ }
77
+ /**
78
+ * Credential for the BACKGROUND worker. Deliberately never refreshes: a
79
+ * detached refresh racing a foreground command could rotate the refresh token
80
+ * out from under it. An expired stored token just means "unauthenticated" for
81
+ * this pass (the cached entitlement still applies).
82
+ */
83
+ export function backgroundCredential(env = process.env, auth = getAuthInfo(), nowSec = Math.floor(Date.now() / 1000)) {
84
+ if (env.MNEMOM_TOKEN)
85
+ return { name: "Authorization", value: `Bearer ${env.MNEMOM_TOKEN}` };
86
+ if (auth && auth.expiresAt > nowSec + 60) {
87
+ return { name: "Authorization", value: `Bearer ${auth.accessToken}` };
88
+ }
89
+ if (env.MNEMOM_API_KEY)
90
+ return { name: "X-Mnemom-Api-Key", value: env.MNEMOM_API_KEY };
91
+ return null;
92
+ }
93
+ /** Credential for the FOREGROUND `mnemom update` — normal resolution, refresh allowed. */
94
+ export async function foregroundCredential(resolve = resolveAuth) {
95
+ const cred = await resolve();
96
+ if (cred.type === "jwt")
97
+ return { name: "Authorization", value: `Bearer ${cred.token}` };
98
+ if (cred.type === "api-key")
99
+ return { name: "X-Mnemom-Api-Key", value: cred.key };
100
+ return null;
101
+ }
102
+ export function loadUpdatePrefs() {
103
+ return loadCliConfig().update ?? {};
104
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The detached background worker (entry: src/update-worker.ts) and the real
3
+ * `RunDeps` wiring shared with `mnemom update`.
4
+ *
5
+ * The parent passes the settings it resolved (`<entry> <channel> <mode> <pid>`): the
6
+ * worker runs with stdio ignored, so re-resolving here would always see a
7
+ * non-TTY and force `off`.
8
+ */
9
+ import { type ResolvedSettings } from "./decide.js";
10
+ import { type ApiCredential } from "./registry.js";
11
+ import { type InstallResult, type RunDeps, type RunResult } from "./run.js";
12
+ export interface WorkerArgs {
13
+ entry: string;
14
+ settings: ResolvedSettings;
15
+ /** The CLI process that spawned us; installs wait until it has exited. */
16
+ parentPid?: number;
17
+ }
18
+ export declare function parseWorkerArgs(args: string[]): WorkerArgs | null;
19
+ export declare const PARENT_POLL_MS = 5000;
20
+ /** Give up waiting on a very long session; the next run picks the update up. */
21
+ export declare const PARENT_WAIT_MAX_MS: number;
22
+ /** `kill(pid, 0)` probes liveness without signalling. EPERM still means alive. */
23
+ export declare function isAlive(pid: number, kill?: (pid: number, sig: 0) => void): boolean;
24
+ /** Resolve true once `pid` is gone, false if it outlives `maxMs`. */
25
+ export declare function waitForExit(pid: number | undefined, deps?: {
26
+ alive(pid: number): boolean;
27
+ sleep(ms: number): Promise<void>;
28
+ now(): number;
29
+ }, maxMs?: number): Promise<boolean>;
30
+ export declare function realRunDeps(entry: string, credential: () => ApiCredential | Promise<ApiCredential>): RunDeps;
31
+ /**
32
+ * The install lock is held ONLY around `npm install` — never while waiting for
33
+ * the parent to exit (a 12h wait would look stale at LOCK_STALE_MS and let a
34
+ * second installer in). Lock busy → skip this pass; the other installer wins.
35
+ */
36
+ export declare function lockedInstall(parentPid: number | undefined, deps: {
37
+ waitForExit(pid: number | undefined): Promise<boolean>;
38
+ acquireLock(): (() => void) | null;
39
+ install(version: string): Promise<InstallResult>;
40
+ }): Pick<RunDeps, "beforeInstall" | "install">;
41
+ export declare function runWorker(args: string[], run: (args: WorkerArgs) => Promise<RunResult>): Promise<"bad-args" | RunResult>;
42
+ export declare function defaultWorkerRun(credential: () => ApiCredential): (args: WorkerArgs) => Promise<RunResult>;