@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.
- package/dist/commands/agent-doctor.d.ts +1 -1
- package/dist/commands/agent-doctor.js +5 -1
- package/dist/commands/agent-setup.d.ts +34 -3
- package/dist/commands/agent-setup.js +86 -11
- package/dist/commands/agent.js +11 -3
- package/dist/commands/config.d.ts +36 -0
- package/dist/commands/config.js +82 -0
- package/dist/commands/update.d.ts +30 -0
- package/dist/commands/update.js +90 -0
- package/dist/index.js +41 -0
- package/dist/lib/agent.d.ts +13 -7
- package/dist/lib/agent.js +12 -12
- package/dist/lib/cli-config.d.ts +8 -0
- package/dist/lib/cli-config.js +13 -0
- package/dist/lib/update/action.d.ts +9 -0
- package/dist/lib/update/action.js +17 -0
- package/dist/lib/update/decide.d.ts +152 -0
- package/dist/lib/update/decide.js +195 -0
- package/dist/lib/update/doctor.d.ts +18 -0
- package/dist/lib/update/doctor.js +63 -0
- package/dist/lib/update/install-method.d.ts +12 -0
- package/dist/lib/update/install-method.js +33 -0
- package/dist/lib/update/min-version.d.ts +34 -0
- package/dist/lib/update/min-version.js +69 -0
- package/dist/lib/update/registry.d.ts +27 -0
- package/dist/lib/update/registry.js +50 -0
- package/dist/lib/update/run.d.ts +54 -0
- package/dist/lib/update/run.js +100 -0
- package/dist/lib/update/semver.d.ts +22 -0
- package/dist/lib/update/semver.js +65 -0
- package/dist/lib/update/startup.d.ts +27 -0
- package/dist/lib/update/startup.js +75 -0
- package/dist/lib/update/state.d.ts +23 -0
- package/dist/lib/update/state.js +83 -0
- package/dist/lib/update/system.d.ts +55 -0
- package/dist/lib/update/system.js +104 -0
- package/dist/lib/update/worker.d.ts +42 -0
- package/dist/lib/update/worker.js +104 -0
- package/dist/lib/version-gate.d.ts +2 -1
- package/dist/lib/version-gate.js +9 -1
- package/dist/update-worker.d.ts +6 -0
- package/dist/update-worker.js +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network reads for the updater: npm dist-tags and the `next`-channel
|
|
3
|
+
* entitlement. Both take an injected fetch and use a hard timeout; neither goes
|
|
4
|
+
* through `mnemomFetch` (its min-version hard-exit must not fire inside the
|
|
5
|
+
* updater that is trying to fix exactly that).
|
|
6
|
+
*/
|
|
7
|
+
import { type DistTags } from "./decide.js";
|
|
8
|
+
export declare const DIST_TAGS_URL: string;
|
|
9
|
+
export declare const FETCH_TIMEOUT_MS = 5000;
|
|
10
|
+
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
11
|
+
export declare function fetchDistTags(fetchFn: FetchLike, timeoutMs?: number): Promise<DistTags>;
|
|
12
|
+
/** An auth header for the Mnemom API, or null when the caller has no usable credential. */
|
|
13
|
+
export type ApiCredential = {
|
|
14
|
+
name: "Authorization" | "X-Mnemom-Api-Key";
|
|
15
|
+
value: string;
|
|
16
|
+
} | null;
|
|
17
|
+
/**
|
|
18
|
+
* Entitlement answer:
|
|
19
|
+
* { next: true|false } — a definitive server answer (cacheable);
|
|
20
|
+
* "unauthenticated" — no credential / 401: treat as not entitled, don't cache;
|
|
21
|
+
* "unavailable" — 5xx / network / timeout: keep whatever was cached.
|
|
22
|
+
*/
|
|
23
|
+
export type EntitlementResult = {
|
|
24
|
+
next: boolean;
|
|
25
|
+
} | "unauthenticated" | "unavailable";
|
|
26
|
+
export declare function fetchChannelEntitlement(fetchFn: FetchLike, apiBase: string, credential: ApiCredential, timeoutMs?: number): Promise<EntitlementResult>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network reads for the updater: npm dist-tags and the `next`-channel
|
|
3
|
+
* entitlement. Both take an injected fetch and use a hard timeout; neither goes
|
|
4
|
+
* through `mnemomFetch` (its min-version hard-exit must not fire inside the
|
|
5
|
+
* updater that is trying to fix exactly that).
|
|
6
|
+
*/
|
|
7
|
+
import { PACKAGE_NAME } from "./decide.js";
|
|
8
|
+
export const DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME.replace(/\//g, "%2F")}/dist-tags`;
|
|
9
|
+
export const FETCH_TIMEOUT_MS = 5000;
|
|
10
|
+
export async function fetchDistTags(fetchFn, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
11
|
+
const res = await fetchFn(DIST_TAGS_URL, {
|
|
12
|
+
headers: { accept: "application/json" },
|
|
13
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error(`npm registry returned HTTP ${res.status}`);
|
|
17
|
+
const body = (await res.json());
|
|
18
|
+
const tags = {};
|
|
19
|
+
if (typeof body.latest === "string")
|
|
20
|
+
tags.latest = body.latest;
|
|
21
|
+
if (typeof body.next === "string")
|
|
22
|
+
tags.next = body.next;
|
|
23
|
+
return tags;
|
|
24
|
+
}
|
|
25
|
+
export async function fetchChannelEntitlement(fetchFn, apiBase, credential, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
26
|
+
if (!credential)
|
|
27
|
+
return "unauthenticated";
|
|
28
|
+
let res;
|
|
29
|
+
try {
|
|
30
|
+
res = await fetchFn(`${apiBase}/v1/cli/channel-entitlement`, {
|
|
31
|
+
headers: { accept: "application/json", [credential.name]: credential.value },
|
|
32
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return "unavailable";
|
|
37
|
+
}
|
|
38
|
+
if (res.status === 401)
|
|
39
|
+
return "unauthenticated";
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
return res.status >= 500 ? "unavailable" : { next: false };
|
|
42
|
+
try {
|
|
43
|
+
const body = (await res.json());
|
|
44
|
+
// Fail closed: anything but a literal `true` is "not entitled".
|
|
45
|
+
return { next: body.next === true };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return "unavailable";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -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 {};
|