@drewpayment/mink 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/out/404.html +1 -1
- package/dashboard/out/action-log.html +1 -1
- package/dashboard/out/action-log.txt +1 -1
- package/dashboard/out/activity.html +1 -1
- package/dashboard/out/activity.txt +1 -1
- package/dashboard/out/bugs.html +1 -1
- package/dashboard/out/bugs.txt +1 -1
- package/dashboard/out/capture.html +1 -1
- package/dashboard/out/capture.txt +1 -1
- package/dashboard/out/config.html +1 -1
- package/dashboard/out/config.txt +1 -1
- package/dashboard/out/daemon.html +1 -1
- package/dashboard/out/daemon.txt +1 -1
- package/dashboard/out/design.html +1 -1
- package/dashboard/out/design.txt +1 -1
- package/dashboard/out/discord.html +1 -1
- package/dashboard/out/discord.txt +1 -1
- package/dashboard/out/file-index.html +1 -1
- package/dashboard/out/file-index.txt +1 -1
- package/dashboard/out/index.html +1 -1
- package/dashboard/out/index.txt +1 -1
- package/dashboard/out/insights.html +1 -1
- package/dashboard/out/insights.txt +1 -1
- package/dashboard/out/learning.html +1 -1
- package/dashboard/out/learning.txt +1 -1
- package/dashboard/out/overview.html +1 -1
- package/dashboard/out/overview.txt +1 -1
- package/dashboard/out/scheduler.html +1 -1
- package/dashboard/out/scheduler.txt +1 -1
- package/dashboard/out/sync.html +1 -1
- package/dashboard/out/sync.txt +1 -1
- package/dashboard/out/tokens.html +1 -1
- package/dashboard/out/tokens.txt +1 -1
- package/dashboard/out/waste.html +1 -1
- package/dashboard/out/waste.txt +1 -1
- package/dashboard/out/wiki.html +1 -1
- package/dashboard/out/wiki.txt +1 -1
- package/dist/cli.js +906 -443
- package/package.json +1 -1
- package/src/cli.ts +8 -1
- package/src/commands/init.ts +21 -21
- package/src/commands/update.ts +4 -9
- package/src/commands/upgrade.ts +128 -0
- package/src/core/self-update.ts +363 -0
- package/src/core/task-registry.ts +52 -2
- package/src/types/config.ts +24 -0
- /package/dashboard/out/_next/static/{FLxzihv7lbkF71kIxdNQT → frTrvF6NV-Xl2bLk21NkY}/_buildManifest.js +0 -0
- /package/dashboard/out/_next/static/{FLxzihv7lbkF71kIxdNQT → frTrvF6NV-Xl2bLk21NkY}/_ssgManifest.js +0 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -107,6 +107,12 @@ switch (command) {
|
|
|
107
107
|
break;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
case "upgrade": {
|
|
111
|
+
const { upgrade } = await import("./commands/upgrade");
|
|
112
|
+
await upgrade(cwd, process.argv.slice(3));
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
|
|
110
116
|
case "restore": {
|
|
111
117
|
const { restore } = await import("./commands/restore");
|
|
112
118
|
restore(cwd, process.argv.slice(3));
|
|
@@ -244,7 +250,8 @@ switch (command) {
|
|
|
244
250
|
console.log(" dashboard [--port=N] Open the real-time web dashboard");
|
|
245
251
|
console.log(" daemon <cmd> Manage the background daemon (start|stop|restart|logs|install|uninstall)");
|
|
246
252
|
console.log(" cron <cmd> [id] Manage scheduled tasks (list|run|retry)");
|
|
247
|
-
console.log(" update [options] Update Mink across registered projects");
|
|
253
|
+
console.log(" update [options] Update Mink hooks across registered projects");
|
|
254
|
+
console.log(" upgrade [options] Self-upgrade the mink CLI from npm (--check|--dry-run|--force)");
|
|
248
255
|
console.log(" restore [backup] Restore state from a backup");
|
|
249
256
|
console.log(" bug search <term> Search the bug log");
|
|
250
257
|
console.log(" detect-waste Detect and flag wasteful patterns");
|
package/src/commands/init.ts
CHANGED
|
@@ -53,18 +53,13 @@ export function resolveCliPath(): string {
|
|
|
53
53
|
return resolve(selfDir, "../cli.ts");
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function buildHooksConfig(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
)
|
|
60
|
-
//
|
|
61
|
-
// If using .ts source, must use bun
|
|
56
|
+
export function buildHooksConfig(cliPath: string): HooksConfig {
|
|
57
|
+
// For installed packages emit the `mink` bin shim so the resulting
|
|
58
|
+
// .claude/settings.json is portable across machines, users, and runtimes
|
|
59
|
+
// when committed to git (issue #55). For source-dev mode (cli.ts) the shim
|
|
60
|
+
// isn't on PATH, so fall back to `bun run <abs path>`.
|
|
62
61
|
const isTsSource = cliPath.endsWith(".ts");
|
|
63
|
-
const prefix = isTsSource
|
|
64
|
-
? `bun run ${cliPath}`
|
|
65
|
-
: runtime === "bun"
|
|
66
|
-
? `bun run ${cliPath}`
|
|
67
|
-
: `node ${cliPath}`;
|
|
62
|
+
const prefix = isTsSource ? `bun run ${cliPath}` : "mink";
|
|
68
63
|
const hook = (cmd: string): HookCommand[] => [{ type: "command", command: cmd }];
|
|
69
64
|
return {
|
|
70
65
|
SessionStart: [{ matcher: "", hooks: hook(`${prefix} session-start`) }],
|
|
@@ -83,15 +78,20 @@ export function buildHooksConfig(
|
|
|
83
78
|
}
|
|
84
79
|
|
|
85
80
|
function isMinkCommand(cmd: string): boolean {
|
|
86
|
-
|
|
87
|
-
cmd.includes("
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
81
|
+
const hasMinkSubcommand =
|
|
82
|
+
cmd.includes("session-start") ||
|
|
83
|
+
cmd.includes("session-stop") ||
|
|
84
|
+
cmd.includes("pre-read") ||
|
|
85
|
+
cmd.includes("post-read") ||
|
|
86
|
+
cmd.includes("pre-write") ||
|
|
87
|
+
cmd.includes("post-write");
|
|
88
|
+
if (!hasMinkSubcommand) return false;
|
|
89
|
+
// Match the new bin-shim format (`mink <subcmd>` or `/abs/path/to/mink <subcmd>`)
|
|
90
|
+
// as well as legacy formats (`bun run .../cli.js ...`, `node .../cli.js ...`,
|
|
91
|
+
// `bun run .../cli.ts ...`) so re-init replaces stale entries instead of
|
|
92
|
+
// duplicating them.
|
|
93
|
+
if (/(^|\/|\s)mink\s/.test(cmd)) return true;
|
|
94
|
+
return cmd.includes("cli.js") || cmd.includes("cli.ts");
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
function isMinkHook(entry: HookEntry | Record<string, unknown>): boolean {
|
|
@@ -159,7 +159,7 @@ function isExistingInstallation(cwd: string): boolean {
|
|
|
159
159
|
export async function init(cwd: string): Promise<void> {
|
|
160
160
|
const runtime = detectRuntime();
|
|
161
161
|
const cliPath = resolveCliPath();
|
|
162
|
-
const hooks = buildHooksConfig(
|
|
162
|
+
const hooks = buildHooksConfig(cliPath);
|
|
163
163
|
const settingsPath = resolve(cwd, ".claude", "settings.json");
|
|
164
164
|
const dir = projectDir(cwd);
|
|
165
165
|
const upgrading = isExistingInstallation(cwd);
|
package/src/commands/update.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { resolve
|
|
2
|
-
import { existsSync } from "fs";
|
|
1
|
+
import { resolve } from "path";
|
|
3
2
|
import { listRegisteredProjects } from "../core/project-registry";
|
|
4
3
|
import { createBackup } from "../core/backup";
|
|
5
4
|
import { projectMetaPath } from "../core/paths";
|
|
6
5
|
import { atomicWriteJson, safeReadJson } from "../core/fs-utils";
|
|
7
|
-
import { buildHooksConfig,
|
|
6
|
+
import { buildHooksConfig, mergeHooksIntoSettings, resolveCliPath } from "./init";
|
|
8
7
|
|
|
9
8
|
function parseArgs(args: string[]): {
|
|
10
9
|
dryRun: boolean;
|
|
@@ -78,12 +77,8 @@ export async function update(cwd: string, args: string[]): Promise<void> {
|
|
|
78
77
|
return;
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
dirname(new URL(import.meta.url).pathname),
|
|
84
|
-
"../cli.ts"
|
|
85
|
-
);
|
|
86
|
-
const newHooks = buildHooksConfig(runtime, cliPath);
|
|
80
|
+
const cliPath = resolveCliPath();
|
|
81
|
+
const newHooks = buildHooksConfig(cliPath);
|
|
87
82
|
|
|
88
83
|
for (const target of targets) {
|
|
89
84
|
console.log(`[mink] updating: ${target.name} (${target.id})`);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { runSelfUpgrade, PACKAGE_NAME, type UpgradeResult } from "../core/self-update";
|
|
2
|
+
|
|
3
|
+
interface UpgradeArgs {
|
|
4
|
+
check: boolean;
|
|
5
|
+
dryRun: boolean;
|
|
6
|
+
force: boolean;
|
|
7
|
+
yes: boolean;
|
|
8
|
+
help: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseArgs(args: string[]): UpgradeArgs {
|
|
12
|
+
const out: UpgradeArgs = {
|
|
13
|
+
check: false,
|
|
14
|
+
dryRun: false,
|
|
15
|
+
force: false,
|
|
16
|
+
yes: false,
|
|
17
|
+
help: false,
|
|
18
|
+
};
|
|
19
|
+
for (const arg of args) {
|
|
20
|
+
switch (arg) {
|
|
21
|
+
case "--check":
|
|
22
|
+
out.check = true;
|
|
23
|
+
break;
|
|
24
|
+
case "--dry-run":
|
|
25
|
+
out.dryRun = true;
|
|
26
|
+
break;
|
|
27
|
+
case "--force":
|
|
28
|
+
out.force = true;
|
|
29
|
+
break;
|
|
30
|
+
case "--yes":
|
|
31
|
+
case "-y":
|
|
32
|
+
out.yes = true;
|
|
33
|
+
break;
|
|
34
|
+
case "--help":
|
|
35
|
+
case "-h":
|
|
36
|
+
out.help = true;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function printHelp(): void {
|
|
44
|
+
console.log("Usage: mink upgrade [options]");
|
|
45
|
+
console.log("");
|
|
46
|
+
console.log("Check the npm registry for a newer mink release and install it.");
|
|
47
|
+
console.log(`Tracks the 'latest' dist-tag of ${PACKAGE_NAME}.`);
|
|
48
|
+
console.log("");
|
|
49
|
+
console.log("Options:");
|
|
50
|
+
console.log(" --check Report whether an upgrade is available; do not install");
|
|
51
|
+
console.log(" --dry-run Resolve everything but do not run the install command");
|
|
52
|
+
console.log(" --force Install the latest version even if it is not strictly newer");
|
|
53
|
+
console.log(" --yes, -y Skip the interactive confirmation prompt");
|
|
54
|
+
console.log(" --help, -h Show this help");
|
|
55
|
+
console.log("");
|
|
56
|
+
console.log("Auto-update on a schedule:");
|
|
57
|
+
console.log(" mink config set cli.auto-update true");
|
|
58
|
+
console.log(" mink config set cli.auto-update-schedule \"0 4 * * *\"");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function describeResult(r: UpgradeResult): string {
|
|
62
|
+
switch (r.status) {
|
|
63
|
+
case "up-to-date":
|
|
64
|
+
return `Already up-to-date — ${r.current} matches latest.`;
|
|
65
|
+
case "update-available":
|
|
66
|
+
return `Update available: ${r.current} → ${r.latest}` +
|
|
67
|
+
(r.packageManager ? ` (would install via ${r.packageManager})` : "");
|
|
68
|
+
case "would-upgrade":
|
|
69
|
+
return `Would upgrade: ${r.current} → ${r.latest}\n command: ${r.command}`;
|
|
70
|
+
case "upgraded":
|
|
71
|
+
return `Upgraded ${r.from} → ${r.to} (via ${r.packageManager}).`;
|
|
72
|
+
case "skipped":
|
|
73
|
+
return `Skipped: ${r.reason}`;
|
|
74
|
+
case "error":
|
|
75
|
+
return `Error: ${r.reason}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function confirm(prompt: string): Promise<boolean> {
|
|
80
|
+
if (!process.stdin.isTTY) return false;
|
|
81
|
+
process.stdout.write(prompt);
|
|
82
|
+
return new Promise<boolean>((resolveConfirm) => {
|
|
83
|
+
process.stdin.setEncoding("utf-8");
|
|
84
|
+
process.stdin.once("data", (chunk) => {
|
|
85
|
+
const answer = String(chunk).trim().toLowerCase();
|
|
86
|
+
resolveConfirm(answer === "y" || answer === "yes");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function upgrade(_cwd: string, args: string[]): Promise<void> {
|
|
92
|
+
const parsed = parseArgs(args);
|
|
93
|
+
|
|
94
|
+
if (parsed.help) {
|
|
95
|
+
printHelp();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// For interactive runs without --yes/--check/--dry-run/--force, do a check
|
|
100
|
+
// first and ask for confirmation before mutating the global install.
|
|
101
|
+
const isCheckLike = parsed.check || parsed.dryRun;
|
|
102
|
+
if (!isCheckLike && !parsed.yes && process.stdin.isTTY) {
|
|
103
|
+
const probe = await runSelfUpgrade({ source: "manual", checkOnly: true, force: parsed.force });
|
|
104
|
+
console.log(describeResult(probe));
|
|
105
|
+
if (probe.status !== "update-available" && !parsed.force) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const ok = await confirm("Proceed with install? [y/N] ");
|
|
109
|
+
if (!ok) {
|
|
110
|
+
console.log("Aborted.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result = await runSelfUpgrade({
|
|
116
|
+
source: "manual",
|
|
117
|
+
checkOnly: parsed.check,
|
|
118
|
+
dryRun: parsed.dryRun,
|
|
119
|
+
force: parsed.force,
|
|
120
|
+
interactive: true,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
console.log(describeResult(result));
|
|
124
|
+
|
|
125
|
+
if (result.status === "error") {
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { spawnSync } from "child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { dirname, resolve } from "path";
|
|
4
|
+
import { resolveConfigValue } from "./global-config";
|
|
5
|
+
import { minkRoot } from "./paths";
|
|
6
|
+
import { safeAppendText, atomicWriteText } from "./fs-utils";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
|
|
9
|
+
export const PACKAGE_NAME = "@drewpayment/mink";
|
|
10
|
+
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
11
|
+
const NETWORK_TIMEOUT_MS = 5_000;
|
|
12
|
+
const INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
13
|
+
const LOG_MAX_LINES = 1000;
|
|
14
|
+
|
|
15
|
+
export type UpgradeSource = "manual" | "scheduler";
|
|
16
|
+
export type PackageManager = "npm" | "bun";
|
|
17
|
+
|
|
18
|
+
export type UpgradeResult =
|
|
19
|
+
| { status: "up-to-date"; current: string; latest: string }
|
|
20
|
+
| { status: "update-available"; current: string; latest: string; packageManager?: PackageManager }
|
|
21
|
+
| { status: "would-upgrade"; current: string; latest: string; packageManager: PackageManager; command: string }
|
|
22
|
+
| { status: "upgraded"; from: string; to: string; packageManager: PackageManager }
|
|
23
|
+
| { status: "skipped"; reason: string }
|
|
24
|
+
| { status: "error"; reason: string; transient: boolean };
|
|
25
|
+
|
|
26
|
+
export interface UpgradeOptions {
|
|
27
|
+
source: UpgradeSource;
|
|
28
|
+
/** Skip the install step; only report whether an upgrade is available. */
|
|
29
|
+
checkOnly?: boolean;
|
|
30
|
+
/** Don't run the install command, but resolve everything else and report what would run. */
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
/** Install even if the latest version is not strictly newer. */
|
|
33
|
+
force?: boolean;
|
|
34
|
+
/** Stream the install command's stdio to the parent terminal. False for scheduler runs. */
|
|
35
|
+
interactive?: boolean;
|
|
36
|
+
/** Override the registry URL (for tests). */
|
|
37
|
+
registryUrlOverride?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Version compare ─────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
interface ParsedVersion {
|
|
43
|
+
numbers: number[];
|
|
44
|
+
prerelease: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseSemver(input: string): ParsedVersion | null {
|
|
48
|
+
const trimmed = input.trim().replace(/^v/, "");
|
|
49
|
+
if (!trimmed) return null;
|
|
50
|
+
const [versionPart, ...prereleaseParts] = trimmed.split("-");
|
|
51
|
+
const numbers = versionPart.split(".").map((s) => Number.parseInt(s, 10));
|
|
52
|
+
if (numbers.some((n) => Number.isNaN(n))) return null;
|
|
53
|
+
return {
|
|
54
|
+
numbers,
|
|
55
|
+
prerelease: prereleaseParts.length ? prereleaseParts.join("-") : null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compare two semver strings.
|
|
61
|
+
* Returns 1 if a > b, -1 if a < b, 0 if equal.
|
|
62
|
+
* A version with a prerelease tag is considered older than the same version without one
|
|
63
|
+
* (e.g. 1.0.0-rc.1 < 1.0.0). Prerelease vs prerelease falls back to lexicographic compare.
|
|
64
|
+
*/
|
|
65
|
+
export function compareSemver(a: string, b: string): number {
|
|
66
|
+
const pa = parseSemver(a);
|
|
67
|
+
const pb = parseSemver(b);
|
|
68
|
+
if (!pa && !pb) return 0;
|
|
69
|
+
if (!pa) return -1;
|
|
70
|
+
if (!pb) return 1;
|
|
71
|
+
|
|
72
|
+
const len = Math.max(pa.numbers.length, pb.numbers.length);
|
|
73
|
+
for (let i = 0; i < len; i++) {
|
|
74
|
+
const ai = pa.numbers[i] ?? 0;
|
|
75
|
+
const bi = pb.numbers[i] ?? 0;
|
|
76
|
+
if (ai > bi) return 1;
|
|
77
|
+
if (ai < bi) return -1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (pa.prerelease === pb.prerelease) return 0;
|
|
81
|
+
if (pa.prerelease === null) return 1;
|
|
82
|
+
if (pb.prerelease === null) return -1;
|
|
83
|
+
if (pa.prerelease > pb.prerelease) return 1;
|
|
84
|
+
if (pa.prerelease < pb.prerelease) return -1;
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── CLI install path resolution ─────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export interface CliInstallInfo {
|
|
91
|
+
/** Absolute path to the running cli.js (or src/cli.ts in dev mode). */
|
|
92
|
+
cliPath: string;
|
|
93
|
+
/** Absolute path to the package.json that owns the running CLI. */
|
|
94
|
+
packageJsonPath: string;
|
|
95
|
+
/** Version reported by that package.json. */
|
|
96
|
+
currentVersion: string;
|
|
97
|
+
/** True when running under src/cli.ts via bun/tsx. */
|
|
98
|
+
isDevMode: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the install location of the *running* CLI by walking up from
|
|
103
|
+
* `import.meta.url` until we find a package.json. We treat anything ending
|
|
104
|
+
* in `.ts` or living inside the working source tree as dev mode.
|
|
105
|
+
*/
|
|
106
|
+
export function getInstallInfo(): CliInstallInfo {
|
|
107
|
+
const selfPath = new URL(import.meta.url).pathname;
|
|
108
|
+
const isDevMode = selfPath.endsWith(".ts");
|
|
109
|
+
|
|
110
|
+
// Walk up directories until we find package.json
|
|
111
|
+
let dir = dirname(selfPath);
|
|
112
|
+
let packageJsonPath: string | null = null;
|
|
113
|
+
for (let i = 0; i < 10; i++) {
|
|
114
|
+
const candidate = join(dir, "package.json");
|
|
115
|
+
if (existsSync(candidate)) {
|
|
116
|
+
packageJsonPath = candidate;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
const parent = dirname(dir);
|
|
120
|
+
if (parent === dir) break;
|
|
121
|
+
dir = parent;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!packageJsonPath) {
|
|
125
|
+
throw new Error("Unable to locate package.json for the running mink CLI");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let currentVersion = "0.0.0";
|
|
129
|
+
try {
|
|
130
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
131
|
+
if (typeof pkg.version === "string") currentVersion = pkg.version;
|
|
132
|
+
} catch {
|
|
133
|
+
// fall through with 0.0.0
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
cliPath: selfPath,
|
|
138
|
+
packageJsonPath,
|
|
139
|
+
currentVersion,
|
|
140
|
+
isDevMode,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Registry fetch ──────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
async function fetchLatestVersion(
|
|
147
|
+
url: string,
|
|
148
|
+
currentVersion: string
|
|
149
|
+
): Promise<string> {
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS);
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(url, {
|
|
154
|
+
signal: controller.signal,
|
|
155
|
+
headers: {
|
|
156
|
+
"User-Agent": `mink-self-update/${currentVersion}`,
|
|
157
|
+
Accept: "application/json",
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
throw new Error(`registry returned ${res.status}`);
|
|
162
|
+
}
|
|
163
|
+
const body = (await res.json()) as { version?: unknown };
|
|
164
|
+
if (typeof body.version !== "string") {
|
|
165
|
+
throw new Error("registry response missing version field");
|
|
166
|
+
}
|
|
167
|
+
return body.version;
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Package manager detection ───────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function isOnPath(bin: string): boolean {
|
|
176
|
+
const result = spawnSync(bin, ["--version"], { stdio: "ignore" });
|
|
177
|
+
return !result.error && result.status === 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function detectPackageManager(cliPath: string): PackageManager | null {
|
|
181
|
+
// Honor explicit user override first.
|
|
182
|
+
const configured = resolveConfigValue("cli.auto-update-package-manager").value;
|
|
183
|
+
if (configured === "bun" && isOnPath("bun")) return "bun";
|
|
184
|
+
if (configured === "npm" && isOnPath("npm")) return "npm";
|
|
185
|
+
|
|
186
|
+
// Auto-detect: prefer bun if the install path looks bun-ish (e.g. ~/.bun/install).
|
|
187
|
+
const looksLikeBun = /[\\/]\.bun[\\/]/.test(cliPath);
|
|
188
|
+
if (looksLikeBun && isOnPath("bun")) return "bun";
|
|
189
|
+
|
|
190
|
+
if (isOnPath("npm")) return "npm";
|
|
191
|
+
if (isOnPath("bun")) return "bun";
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildInstallCommand(pm: PackageManager, version: string): string[] {
|
|
196
|
+
const ref = `${PACKAGE_NAME}@${version}`;
|
|
197
|
+
if (pm === "bun") return ["bun", "add", "-g", ref];
|
|
198
|
+
return ["npm", "install", "-g", ref];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Logging ─────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export function selfUpdateLogPath(): string {
|
|
204
|
+
return join(minkRoot(), "self-update.log");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function appendLogEntry(entry: Record<string, unknown>): void {
|
|
208
|
+
const path = selfUpdateLogPath();
|
|
209
|
+
const line = JSON.stringify({ timestamp: new Date().toISOString(), ...entry }) + "\n";
|
|
210
|
+
try {
|
|
211
|
+
safeAppendText(path, line);
|
|
212
|
+
rotateLogIfNeeded(path);
|
|
213
|
+
} catch {
|
|
214
|
+
// Logging failures must not crash the upgrade flow.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function rotateLogIfNeeded(path: string): void {
|
|
219
|
+
try {
|
|
220
|
+
const content = readFileSync(path, "utf-8");
|
|
221
|
+
const lines = content.split("\n");
|
|
222
|
+
if (lines.length <= LOG_MAX_LINES + 1) return;
|
|
223
|
+
const trimmed = lines.slice(lines.length - LOG_MAX_LINES - 1).join("\n");
|
|
224
|
+
atomicWriteText(path, trimmed);
|
|
225
|
+
} catch {
|
|
226
|
+
// ignore — best effort
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Main entry point ────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
export async function runSelfUpgrade(opts: UpgradeOptions): Promise<UpgradeResult> {
|
|
233
|
+
const result = await runSelfUpgradeInner(opts);
|
|
234
|
+
appendLogEntry({ source: opts.source, ...result });
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function runSelfUpgradeInner(opts: UpgradeOptions): Promise<UpgradeResult> {
|
|
239
|
+
// 1. Hard kill switch.
|
|
240
|
+
if (process.env.MINK_DISABLE_AUTO_UPDATE === "1" && opts.source === "scheduler") {
|
|
241
|
+
return { status: "skipped", reason: "MINK_DISABLE_AUTO_UPDATE=1" };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 2. Scheduler runs respect the cli.auto-update flag; manual runs do not.
|
|
245
|
+
if (opts.source === "scheduler") {
|
|
246
|
+
const enabled = resolveConfigValue("cli.auto-update").value;
|
|
247
|
+
if (enabled !== "true") {
|
|
248
|
+
return { status: "skipped", reason: "cli.auto-update is disabled" };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 3. Resolve install info.
|
|
253
|
+
let info: CliInstallInfo;
|
|
254
|
+
try {
|
|
255
|
+
info = getInstallInfo();
|
|
256
|
+
} catch (err) {
|
|
257
|
+
return {
|
|
258
|
+
status: "error",
|
|
259
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
260
|
+
transient: false,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 4. Dev-mode guard — never auto-mutate the working source tree.
|
|
265
|
+
if (info.isDevMode) {
|
|
266
|
+
return {
|
|
267
|
+
status: "skipped",
|
|
268
|
+
reason: "running from source tree; refuse to self-upgrade in dev mode",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 5. Fetch latest from registry.
|
|
273
|
+
let latest: string;
|
|
274
|
+
try {
|
|
275
|
+
latest = await fetchLatestVersion(
|
|
276
|
+
opts.registryUrlOverride ?? NPM_REGISTRY_URL,
|
|
277
|
+
info.currentVersion
|
|
278
|
+
);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return {
|
|
281
|
+
status: "error",
|
|
282
|
+
reason:
|
|
283
|
+
"failed to fetch latest version: " +
|
|
284
|
+
(err instanceof Error ? err.message : String(err)),
|
|
285
|
+
transient: true,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 6. Compare versions.
|
|
290
|
+
const cmp = compareSemver(latest, info.currentVersion);
|
|
291
|
+
if (cmp <= 0 && !opts.force) {
|
|
292
|
+
return { status: "up-to-date", current: info.currentVersion, latest };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 7. Resolve package manager (needed even for dry-run output).
|
|
296
|
+
const pm = detectPackageManager(info.cliPath);
|
|
297
|
+
if (!pm) {
|
|
298
|
+
return {
|
|
299
|
+
status: "error",
|
|
300
|
+
reason: "no package manager (npm or bun) available on PATH",
|
|
301
|
+
transient: false,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const cmd = buildInstallCommand(pm, latest);
|
|
306
|
+
|
|
307
|
+
if (opts.checkOnly) {
|
|
308
|
+
return {
|
|
309
|
+
status: "update-available",
|
|
310
|
+
current: info.currentVersion,
|
|
311
|
+
latest,
|
|
312
|
+
packageManager: pm,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (opts.dryRun) {
|
|
317
|
+
return {
|
|
318
|
+
status: "would-upgrade",
|
|
319
|
+
current: info.currentVersion,
|
|
320
|
+
latest,
|
|
321
|
+
packageManager: pm,
|
|
322
|
+
command: cmd.join(" "),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 8. Run install.
|
|
327
|
+
const stdio = opts.interactive ? "inherit" : "pipe";
|
|
328
|
+
const spawned = spawnSync(cmd[0], cmd.slice(1), {
|
|
329
|
+
stdio,
|
|
330
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
331
|
+
});
|
|
332
|
+
if (spawned.error) {
|
|
333
|
+
return {
|
|
334
|
+
status: "error",
|
|
335
|
+
reason: `install command failed to spawn: ${spawned.error.message}`,
|
|
336
|
+
transient: true,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (spawned.status !== 0) {
|
|
340
|
+
const stderr = spawned.stderr ? spawned.stderr.toString().trim() : "";
|
|
341
|
+
return {
|
|
342
|
+
status: "error",
|
|
343
|
+
reason: `${cmd.join(" ")} exited with code ${spawned.status}${stderr ? ": " + stderr.slice(0, 500) : ""}`,
|
|
344
|
+
transient: true,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 9. Verify post-install version by re-reading the on-disk package.json.
|
|
349
|
+
let verifiedVersion = latest;
|
|
350
|
+
try {
|
|
351
|
+
const pkg = JSON.parse(readFileSync(info.packageJsonPath, "utf-8"));
|
|
352
|
+
if (typeof pkg.version === "string") verifiedVersion = pkg.version;
|
|
353
|
+
} catch {
|
|
354
|
+
// package may have been replaced and the path may be stale; trust latest
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
status: "upgraded",
|
|
359
|
+
from: info.currentVersion,
|
|
360
|
+
to: verifiedVersion,
|
|
361
|
+
packageManager: pm,
|
|
362
|
+
};
|
|
363
|
+
}
|