@tanishqkancharla/diffmap 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/package.json +3 -3
- package/src/cli.test.ts +50 -0
- package/src/cli.ts +40 -5
- package/src/errors.ts +5 -0
- package/src/gist/GistApp.tsx +3 -2
- package/src/gist/GistStatus.tsx +2 -51
- package/src/host/HostHome.tsx +73 -0
- package/src/host/files.ts +10 -0
- package/src/host/home.md +194 -0
- package/src/host/home.test.ts +82 -0
- package/src/packageVersion.ts +7 -0
- package/src/serve.test.ts +306 -0
- package/src/serve.ts +14 -4
- package/src/update.test.ts +19 -0
- package/src/update.ts +285 -0
- package/src/viewer/CloseServerButton.tsx +16 -0
- package/src/viewer/FileExcerpt.tsx +25 -3
- package/src/viewer/HtmlPlaceholder.tsx +6 -1
- package/src/viewer/SourceDiffPanel.tsx +26 -7
- package/src/viewer/TableOfContents.tsx +230 -40
- package/src/viewer/TocButton.tsx +27 -0
- package/src/viewer/ViewerApp.tsx +182 -50
- package/src/viewer/sourceNavigation.ts +37 -3
- package/src/viewer/viewerMode.ts +1 -1
- package/vite.config.ts +1 -1
- package/src/viewer/Check.tsx +0 -24
- package/src/viewer/DoneButton.tsx +0 -32
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import { DiffmapServeError } from "./errors.js";
|
|
12
|
+
import { startServer, type DiffmapServer } from "./serve.js";
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
const srcDir = fileURLToPath(new URL(".", import.meta.url));
|
|
16
|
+
const packageRoot = path.resolve(srcDir, "..");
|
|
17
|
+
const cliPath = path.join(srcDir, "cli.ts");
|
|
18
|
+
const sampleSpec = path.join(packageRoot, "fixtures", "sample-spec.md");
|
|
19
|
+
const localUrlPattern = /http:\/\/127\.0\.0\.1:\d+/g;
|
|
20
|
+
|
|
21
|
+
function httpUrlPort(url: string) {
|
|
22
|
+
const parsed = new URL(url);
|
|
23
|
+
assert.equal(parsed.hostname, "127.0.0.1");
|
|
24
|
+
const port = Number(parsed.port);
|
|
25
|
+
assert.ok(Number.isInteger(port) && port > 0);
|
|
26
|
+
return port;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function assertStarted(
|
|
30
|
+
started: Awaited<ReturnType<typeof startServer>>,
|
|
31
|
+
): DiffmapServer {
|
|
32
|
+
assert.equal(
|
|
33
|
+
started instanceof Error,
|
|
34
|
+
false,
|
|
35
|
+
started instanceof Error ? started.message : "",
|
|
36
|
+
);
|
|
37
|
+
if (started instanceof Error) throw started;
|
|
38
|
+
return started;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function occupyPort(port: number) {
|
|
42
|
+
const server = net.createServer();
|
|
43
|
+
try {
|
|
44
|
+
await new Promise<void>((resolve, reject) => {
|
|
45
|
+
server.once("error", reject);
|
|
46
|
+
server.listen(port, "127.0.0.1", () => resolve());
|
|
47
|
+
});
|
|
48
|
+
return server;
|
|
49
|
+
} catch {
|
|
50
|
+
server.close();
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function listenEphemeral() {
|
|
56
|
+
const server = net.createServer();
|
|
57
|
+
await new Promise<void>((resolve, reject) => {
|
|
58
|
+
server.once("error", reject);
|
|
59
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
60
|
+
});
|
|
61
|
+
return server;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function boundPort(server: net.Server) {
|
|
65
|
+
const address = server.address();
|
|
66
|
+
assert.ok(address !== null && typeof address !== "string");
|
|
67
|
+
return address.port;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function closeTcp(server: net.Server | undefined) {
|
|
71
|
+
if (server === undefined) return;
|
|
72
|
+
await new Promise<void>((resolve) => {
|
|
73
|
+
server.close(() => resolve());
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function fetchOk(url: string) {
|
|
78
|
+
const response = await fetch(url, {
|
|
79
|
+
headers: { accept: "text/markdown" },
|
|
80
|
+
});
|
|
81
|
+
assert.equal(response.ok, true);
|
|
82
|
+
const body = await response.text();
|
|
83
|
+
assert.match(body, /Spec fixture/);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function waitForClosed(server: DiffmapServer, timeoutMs = 10_000) {
|
|
87
|
+
await Promise.race([
|
|
88
|
+
server.closed,
|
|
89
|
+
delay(timeoutMs).then(() => {
|
|
90
|
+
throw new Error(`server did not close: ${server.url}`);
|
|
91
|
+
}),
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test(
|
|
96
|
+
"startServer binds distinct free ports when 4177 is taken",
|
|
97
|
+
{ timeout: 90_000 },
|
|
98
|
+
async () => {
|
|
99
|
+
const occupied = await occupyPort(4177);
|
|
100
|
+
try {
|
|
101
|
+
const first = assertStarted(
|
|
102
|
+
await startServer({
|
|
103
|
+
filePath: sampleSpec,
|
|
104
|
+
workspaceRoot: packageRoot,
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
const second = assertStarted(
|
|
108
|
+
await startServer({
|
|
109
|
+
filePath: sampleSpec,
|
|
110
|
+
workspaceRoot: packageRoot,
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
try {
|
|
114
|
+
const firstPort = httpUrlPort(first.url);
|
|
115
|
+
const secondPort = httpUrlPort(second.url);
|
|
116
|
+
assert.notEqual(firstPort, secondPort);
|
|
117
|
+
assert.notEqual(firstPort, 4177);
|
|
118
|
+
assert.notEqual(secondPort, 4177);
|
|
119
|
+
await fetchOk(first.url);
|
|
120
|
+
await fetchOk(second.url);
|
|
121
|
+
} finally {
|
|
122
|
+
await first.shutdown();
|
|
123
|
+
await second.shutdown();
|
|
124
|
+
await waitForClosed(first);
|
|
125
|
+
await waitForClosed(second);
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
await closeTcp(occupied);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
test(
|
|
134
|
+
"startServer --port pins and fails when that port is taken",
|
|
135
|
+
{ timeout: 90_000 },
|
|
136
|
+
async () => {
|
|
137
|
+
const holder = await listenEphemeral();
|
|
138
|
+
const port = boundPort(holder);
|
|
139
|
+
|
|
140
|
+
const blocked = await startServer({
|
|
141
|
+
filePath: sampleSpec,
|
|
142
|
+
workspaceRoot: packageRoot,
|
|
143
|
+
port,
|
|
144
|
+
});
|
|
145
|
+
assert.equal(blocked instanceof DiffmapServeError, true);
|
|
146
|
+
if (!(blocked instanceof DiffmapServeError)) return;
|
|
147
|
+
assert.match(blocked.message, /already in use/i);
|
|
148
|
+
|
|
149
|
+
await closeTcp(holder);
|
|
150
|
+
|
|
151
|
+
const pinned = assertStarted(
|
|
152
|
+
await startServer({
|
|
153
|
+
filePath: sampleSpec,
|
|
154
|
+
workspaceRoot: packageRoot,
|
|
155
|
+
port,
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
try {
|
|
159
|
+
assert.equal(httpUrlPort(pinned.url), port);
|
|
160
|
+
await fetchOk(pinned.url);
|
|
161
|
+
} finally {
|
|
162
|
+
await pinned.shutdown();
|
|
163
|
+
await waitForClosed(pinned);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
test(
|
|
169
|
+
"startServer Close server still stops an ephemeral listener",
|
|
170
|
+
{ timeout: 90_000 },
|
|
171
|
+
async () => {
|
|
172
|
+
const started = assertStarted(
|
|
173
|
+
await startServer({
|
|
174
|
+
filePath: sampleSpec,
|
|
175
|
+
workspaceRoot: packageRoot,
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
const response = await fetch(`${started.url}/__diffmap/shutdown`, {
|
|
179
|
+
method: "POST",
|
|
180
|
+
});
|
|
181
|
+
assert.equal(response.ok, true);
|
|
182
|
+
await waitForClosed(started);
|
|
183
|
+
await assert.rejects(() => fetch(started.url));
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
type SpawnedCli = {
|
|
188
|
+
child: ChildProcess;
|
|
189
|
+
output: () => string;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
function spawnCli(args: string[]) {
|
|
193
|
+
let stdout = "";
|
|
194
|
+
let stderr = "";
|
|
195
|
+
const child = spawn(process.execPath, ["--import", "tsx", cliPath, ...args], {
|
|
196
|
+
cwd: packageRoot,
|
|
197
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
198
|
+
});
|
|
199
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
200
|
+
stdout += chunk.toString();
|
|
201
|
+
});
|
|
202
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
203
|
+
stderr += chunk.toString();
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
child,
|
|
207
|
+
output: () => `${stdout}${stderr}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function firstLocalUrl(text: string) {
|
|
212
|
+
return text.match(localUrlPattern)?.[0];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function waitForLocalUrl(spawned: SpawnedCli, timeoutMs = 60_000) {
|
|
216
|
+
const start = Date.now();
|
|
217
|
+
while (Date.now() - start < timeoutMs) {
|
|
218
|
+
if (spawned.child.exitCode !== null) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`cli exited ${spawned.child.exitCode} before listen:\n${spawned.output()}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const url = firstLocalUrl(spawned.output());
|
|
224
|
+
if (url !== undefined) return url;
|
|
225
|
+
await delay(50);
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`cli printed no listen url:\n${spawned.output()}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function waitForExit(child: ChildProcess, timeoutMs = 15_000) {
|
|
231
|
+
if (child.exitCode !== null) return;
|
|
232
|
+
await Promise.race([
|
|
233
|
+
new Promise<void>((resolve) => {
|
|
234
|
+
child.once("exit", () => resolve());
|
|
235
|
+
}),
|
|
236
|
+
delay(timeoutMs).then(() => {
|
|
237
|
+
child.kill("SIGKILL");
|
|
238
|
+
throw new Error("cli did not exit");
|
|
239
|
+
}),
|
|
240
|
+
]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function stopCli(spawned: SpawnedCli, url: string) {
|
|
244
|
+
const response = await fetch(`${url}/__diffmap/shutdown`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
}).catch(() => undefined);
|
|
247
|
+
if (response === undefined || !response.ok) {
|
|
248
|
+
spawned.child.kill("SIGTERM");
|
|
249
|
+
}
|
|
250
|
+
await waitForExit(spawned.child);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function withTempSpecs(
|
|
254
|
+
run: (firstFile: string, secondFile: string) => Promise<void>,
|
|
255
|
+
) {
|
|
256
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "diffmap-serve-"));
|
|
257
|
+
const firstFile = path.join(dir, "one.md");
|
|
258
|
+
const secondFile = path.join(dir, "two.md");
|
|
259
|
+
const source = await fs.readFile(sampleSpec, "utf8");
|
|
260
|
+
await Promise.all([
|
|
261
|
+
fs.writeFile(firstFile, source),
|
|
262
|
+
fs.writeFile(secondFile, source),
|
|
263
|
+
]);
|
|
264
|
+
try {
|
|
265
|
+
await run(firstFile, secondFile);
|
|
266
|
+
} finally {
|
|
267
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
test(
|
|
272
|
+
"diffmap serve prints a free port and list tracks both servers",
|
|
273
|
+
{ timeout: 90_000 },
|
|
274
|
+
async () => {
|
|
275
|
+
await withTempSpecs(async (firstFile, secondFile) => {
|
|
276
|
+
const first = spawnCli(["serve", firstFile]);
|
|
277
|
+
const second = spawnCli([secondFile]);
|
|
278
|
+
try {
|
|
279
|
+
const firstUrl = await waitForLocalUrl(first);
|
|
280
|
+
const secondUrl = await waitForLocalUrl(second);
|
|
281
|
+
assert.notEqual(firstUrl, secondUrl);
|
|
282
|
+
await fetchOk(firstUrl);
|
|
283
|
+
await fetchOk(secondUrl);
|
|
284
|
+
|
|
285
|
+
const listed = await execFileAsync(
|
|
286
|
+
process.execPath,
|
|
287
|
+
["--import", "tsx", cliPath, "list"],
|
|
288
|
+
{
|
|
289
|
+
cwd: packageRoot,
|
|
290
|
+
timeout: 20_000,
|
|
291
|
+
maxBuffer: 1_000_000,
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
const listOutput = `${listed.stdout}${listed.stderr}`;
|
|
295
|
+
assert.equal(listOutput.includes(firstUrl), true);
|
|
296
|
+
assert.equal(listOutput.includes(secondUrl), true);
|
|
297
|
+
|
|
298
|
+
await stopCli(first, firstUrl);
|
|
299
|
+
await stopCli(second, secondUrl);
|
|
300
|
+
} finally {
|
|
301
|
+
first.child.kill("SIGTERM");
|
|
302
|
+
second.child.kill("SIGTERM");
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
},
|
|
306
|
+
);
|
package/src/serve.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type DiffmapServer = {
|
|
|
22
22
|
export type StartServerInput = {
|
|
23
23
|
filePath: string;
|
|
24
24
|
workspaceRoot: string;
|
|
25
|
-
port
|
|
25
|
+
port?: number;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
const packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
@@ -45,6 +45,7 @@ function createClosedBarrier() {
|
|
|
45
45
|
export async function startServer(input: StartServerInput) {
|
|
46
46
|
const filePath = path.resolve(input.filePath);
|
|
47
47
|
const workspaceRoot = path.resolve(input.workspaceRoot);
|
|
48
|
+
const port = input.port ?? 0;
|
|
48
49
|
const source = await fs.readFile(filePath, "utf8").catch(
|
|
49
50
|
(cause) =>
|
|
50
51
|
new DiffmapFileError({
|
|
@@ -82,9 +83,9 @@ export async function startServer(input: StartServerInput) {
|
|
|
82
83
|
configFile: path.join(packageRoot, "vite.config.ts"),
|
|
83
84
|
root: packageRoot,
|
|
84
85
|
server: {
|
|
85
|
-
port
|
|
86
|
+
port,
|
|
86
87
|
host: "127.0.0.1",
|
|
87
|
-
strictPort:
|
|
88
|
+
strictPort: port !== 0,
|
|
88
89
|
fs: {
|
|
89
90
|
allow: [packageRoot, workspaceRoot, path.dirname(filePath)],
|
|
90
91
|
},
|
|
@@ -138,7 +139,16 @@ export async function startServer(input: StartServerInput) {
|
|
|
138
139
|
],
|
|
139
140
|
});
|
|
140
141
|
|
|
141
|
-
await vite.listen(
|
|
142
|
+
const listened = await vite.listen(port).catch(
|
|
143
|
+
(cause) =>
|
|
144
|
+
new DiffmapServeError({
|
|
145
|
+
reason: cause instanceof Error ? cause.message : "listen failed",
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
if (listened instanceof Error) {
|
|
149
|
+
await shutdown();
|
|
150
|
+
return listened;
|
|
151
|
+
}
|
|
142
152
|
const localUrl = vite.resolvedUrls?.local[0];
|
|
143
153
|
if (localUrl === undefined) {
|
|
144
154
|
await shutdown();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { compareVersions, resolveUpdateArgv } from "./update.js";
|
|
4
|
+
|
|
5
|
+
test("compareVersions orders versions by semver precedence", () => {
|
|
6
|
+
assert.equal(compareVersions("0.1.4", "0.1.4"), 0);
|
|
7
|
+
assert.equal(compareVersions("0.1.4", "0.1.5"), -1);
|
|
8
|
+
assert.equal(compareVersions("0.1.10", "0.1.9"), 1);
|
|
9
|
+
assert.equal(compareVersions("1.0.0-rc.1", "1.0.0"), -1);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("update and --update run the same command", () => {
|
|
13
|
+
assert.deepEqual(resolveUpdateArgv(["update"]), ["update"]);
|
|
14
|
+
assert.deepEqual(resolveUpdateArgv(["--update"]), ["update"]);
|
|
15
|
+
assert.deepEqual(resolveUpdateArgv(["serve", "spec.md"]), [
|
|
16
|
+
"serve",
|
|
17
|
+
"spec.md",
|
|
18
|
+
]);
|
|
19
|
+
});
|
package/src/update.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { DiffmapUpdateError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
// The unscoped `diffmap` package on npm is unrelated; always use the scoped name.
|
|
6
|
+
export const packageName = "@tanishqkancharla/diffmap";
|
|
7
|
+
const latestSpec = `${packageName}@latest`;
|
|
8
|
+
const registryUrl =
|
|
9
|
+
"https://registry.npmjs.org/@tanishqkancharla%2Fdiffmap/latest";
|
|
10
|
+
|
|
11
|
+
type InstallMethod = "npm" | "pnpm" | "bun" | "npx" | "unknown";
|
|
12
|
+
|
|
13
|
+
type Command = { command: string; args: string[] };
|
|
14
|
+
|
|
15
|
+
type UpdateResult =
|
|
16
|
+
| { status: "up-to-date"; current: string; latest: string; message: string }
|
|
17
|
+
| {
|
|
18
|
+
status: "updated";
|
|
19
|
+
previous: string;
|
|
20
|
+
current: string;
|
|
21
|
+
command: string;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
status: "manual";
|
|
26
|
+
current: string;
|
|
27
|
+
latest: string;
|
|
28
|
+
command: string;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type UpdateDeps = {
|
|
33
|
+
currentVersion: string;
|
|
34
|
+
fetchLatestVersion: () => Promise<string | Error>;
|
|
35
|
+
detectInstallMethod: () => Promise<InstallMethod>;
|
|
36
|
+
runCommand: (command: Command) => Promise<void | Error>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Routes `diffmap --update [...]` to the `update` command so incur's built-in updater never runs. */
|
|
40
|
+
export function resolveUpdateArgv(argv: string[]) {
|
|
41
|
+
const separator = argv.indexOf("--");
|
|
42
|
+
const flags = separator === -1 ? argv : argv.slice(0, separator);
|
|
43
|
+
if (!flags.includes("--update")) return argv;
|
|
44
|
+
const rest = argv.filter((token, index) => {
|
|
45
|
+
return token !== "--update" || (separator !== -1 && index > separator);
|
|
46
|
+
});
|
|
47
|
+
return ["update", ...rest];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ParsedVersion = {
|
|
51
|
+
core: [number, number, number];
|
|
52
|
+
prerelease: string[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function parseVersion(version: string): ParsedVersion | undefined {
|
|
56
|
+
const match = version
|
|
57
|
+
.trim()
|
|
58
|
+
.replace(/^v/, "")
|
|
59
|
+
.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
|
|
60
|
+
if (!match) return undefined;
|
|
61
|
+
return {
|
|
62
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
63
|
+
prerelease: match[4] === undefined ? [] : match[4].split("."),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Semver precedence: negative when `a < b`, positive when `a > b`, 0 when equal. */
|
|
68
|
+
export function compareVersions(a: string, b: string) {
|
|
69
|
+
const left = parseVersion(a);
|
|
70
|
+
const right = parseVersion(b);
|
|
71
|
+
if (!left || !right) {
|
|
72
|
+
return new DiffmapUpdateError({
|
|
73
|
+
reason: `cannot compare versions "${a}" and "${b}"`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
for (let index = 0; index < 3; index++) {
|
|
77
|
+
const difference = left.core[index]! - right.core[index]!;
|
|
78
|
+
if (difference !== 0) return Math.sign(difference);
|
|
79
|
+
}
|
|
80
|
+
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
|
81
|
+
return Math.sign(right.prerelease.length - left.prerelease.length);
|
|
82
|
+
}
|
|
83
|
+
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
|
84
|
+
for (let index = 0; index < length; index++) {
|
|
85
|
+
const leftPart = left.prerelease[index];
|
|
86
|
+
const rightPart = right.prerelease[index];
|
|
87
|
+
if (leftPart === undefined) return -1;
|
|
88
|
+
if (rightPart === undefined) return 1;
|
|
89
|
+
if (leftPart === rightPart) continue;
|
|
90
|
+
const leftNumeric = /^\d+$/.test(leftPart);
|
|
91
|
+
const rightNumeric = /^\d+$/.test(rightPart);
|
|
92
|
+
if (leftNumeric && rightNumeric) {
|
|
93
|
+
return Math.sign(Number(leftPart) - Number(rightPart));
|
|
94
|
+
}
|
|
95
|
+
if (leftNumeric) return -1;
|
|
96
|
+
if (rightNumeric) return 1;
|
|
97
|
+
return leftPart < rightPart ? -1 : 1;
|
|
98
|
+
}
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizePath(value: string) {
|
|
103
|
+
return value.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Infers the install method from the real path of the running entry script. */
|
|
107
|
+
function detectInstallMethodFromPath(input: {
|
|
108
|
+
entryPath: string;
|
|
109
|
+
env: Record<string, string | undefined>;
|
|
110
|
+
npmGlobalRoot?: string | undefined;
|
|
111
|
+
}): InstallMethod {
|
|
112
|
+
const entry = normalizePath(input.entryPath);
|
|
113
|
+
if (input.env.npm_command === "exec" || entry.includes("/_npx/")) {
|
|
114
|
+
return "npx";
|
|
115
|
+
}
|
|
116
|
+
if (entry.includes("/.bun/install/global/")) return "bun";
|
|
117
|
+
const pnpmHome = input.env.PNPM_HOME;
|
|
118
|
+
if (
|
|
119
|
+
entry.includes("/pnpm/global/") ||
|
|
120
|
+
(pnpmHome !== undefined &&
|
|
121
|
+
pnpmHome !== "" &&
|
|
122
|
+
entry.startsWith(`${normalizePath(pnpmHome)}/global/`))
|
|
123
|
+
) {
|
|
124
|
+
return "pnpm";
|
|
125
|
+
}
|
|
126
|
+
if (input.npmGlobalRoot !== undefined && input.npmGlobalRoot !== "") {
|
|
127
|
+
const root = normalizePath(input.npmGlobalRoot);
|
|
128
|
+
if (entry.startsWith(`${root}/${packageName}/`)) return "npm";
|
|
129
|
+
}
|
|
130
|
+
return "unknown";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function globalInstallCommand(method: "npm" | "pnpm" | "bun"): Command {
|
|
134
|
+
if (method === "pnpm") {
|
|
135
|
+
return { command: "pnpm", args: ["add", "--global", latestSpec] };
|
|
136
|
+
}
|
|
137
|
+
if (method === "bun") {
|
|
138
|
+
return { command: "bun", args: ["add", "--global", latestSpec] };
|
|
139
|
+
}
|
|
140
|
+
return { command: "npm", args: ["install", "--global", latestSpec] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatCommand(command: Command) {
|
|
144
|
+
return [command.command, ...command.args].join(" ");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function runUpdate(deps: UpdateDeps) {
|
|
148
|
+
const current = deps.currentVersion;
|
|
149
|
+
const latest = await deps.fetchLatestVersion();
|
|
150
|
+
if (latest instanceof Error) return latest;
|
|
151
|
+
|
|
152
|
+
const comparison = compareVersions(current, latest);
|
|
153
|
+
if (comparison instanceof Error) return comparison;
|
|
154
|
+
if (comparison >= 0) {
|
|
155
|
+
return {
|
|
156
|
+
message: `diffmap is already up to date (${current})`,
|
|
157
|
+
status: "up-to-date",
|
|
158
|
+
current,
|
|
159
|
+
latest,
|
|
160
|
+
} satisfies UpdateResult;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const method = await deps.detectInstallMethod();
|
|
164
|
+
if (method === "npx") {
|
|
165
|
+
const command = `npx ${latestSpec}`;
|
|
166
|
+
return {
|
|
167
|
+
message: `diffmap ${latest} is available (running ${current} through npx). Run \`${command}\` to use it, or install it globally with \`${formatCommand(globalInstallCommand("npm"))}\`.`,
|
|
168
|
+
status: "manual",
|
|
169
|
+
current,
|
|
170
|
+
latest,
|
|
171
|
+
command,
|
|
172
|
+
} satisfies UpdateResult;
|
|
173
|
+
}
|
|
174
|
+
if (method === "unknown") {
|
|
175
|
+
const command = formatCommand(globalInstallCommand("npm"));
|
|
176
|
+
return {
|
|
177
|
+
message: `diffmap ${latest} is available (running ${current}), but diffmap could not tell how it was installed. Update it with \`${command}\` or your package manager's equivalent.`,
|
|
178
|
+
status: "manual",
|
|
179
|
+
current,
|
|
180
|
+
latest,
|
|
181
|
+
command,
|
|
182
|
+
} satisfies UpdateResult;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const install = globalInstallCommand(method);
|
|
186
|
+
const installed = await deps.runCommand(install);
|
|
187
|
+
if (installed instanceof Error) return installed;
|
|
188
|
+
return {
|
|
189
|
+
message: `Updated diffmap from ${current} to ${latest}`,
|
|
190
|
+
status: "updated",
|
|
191
|
+
previous: current,
|
|
192
|
+
current: latest,
|
|
193
|
+
command: formatCommand(install),
|
|
194
|
+
} satisfies UpdateResult;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function fetchLatestVersion() {
|
|
198
|
+
const response = await fetch(registryUrl, {
|
|
199
|
+
headers: { accept: "application/json" },
|
|
200
|
+
signal: AbortSignal.timeout(10_000),
|
|
201
|
+
}).catch(
|
|
202
|
+
(cause) =>
|
|
203
|
+
new DiffmapUpdateError({
|
|
204
|
+
reason: "could not reach the npm registry",
|
|
205
|
+
cause,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
if (response instanceof Error) return response;
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
return new DiffmapUpdateError({
|
|
211
|
+
reason: `npm registry responded ${response.status} for ${packageName}`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const body = (await response.json().catch(() => undefined)) as
|
|
215
|
+
| { version?: unknown }
|
|
216
|
+
| undefined;
|
|
217
|
+
if (typeof body?.version !== "string") {
|
|
218
|
+
return new DiffmapUpdateError({
|
|
219
|
+
reason: `npm registry returned no version for ${packageName}`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return body.version;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function execCommand(command: Command) {
|
|
226
|
+
return new Promise<{ stdout: string; stderr: string } | Error>((resolve) => {
|
|
227
|
+
execFile(
|
|
228
|
+
command.command,
|
|
229
|
+
command.args,
|
|
230
|
+
{ shell: process.platform === "win32", maxBuffer: 10_000_000 },
|
|
231
|
+
(cause, stdout, stderr) => {
|
|
232
|
+
if (cause) {
|
|
233
|
+
const detail = stderr.trim() || stdout.trim() || cause.message;
|
|
234
|
+
resolve(
|
|
235
|
+
new DiffmapUpdateError({
|
|
236
|
+
reason: `\`${formatCommand(command)}\` failed: ${detail}`,
|
|
237
|
+
cause,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
resolve({ stdout, stderr });
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function readNpmGlobalRoot() {
|
|
249
|
+
const result = await execCommand({
|
|
250
|
+
command: "npm",
|
|
251
|
+
args: ["root", "--global"],
|
|
252
|
+
});
|
|
253
|
+
if (result instanceof Error) return undefined;
|
|
254
|
+
const root = result.stdout.trim();
|
|
255
|
+
if (root === "") return undefined;
|
|
256
|
+
try {
|
|
257
|
+
return fs.realpathSync(root);
|
|
258
|
+
} catch {
|
|
259
|
+
return root;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function detectInstallMethod() {
|
|
264
|
+
const entry = process.argv[1];
|
|
265
|
+
if (entry === undefined) return "unknown" satisfies InstallMethod;
|
|
266
|
+
let entryPath = entry;
|
|
267
|
+
try {
|
|
268
|
+
entryPath = fs.realpathSync(entry);
|
|
269
|
+
} catch {}
|
|
270
|
+
const withoutNpm = detectInstallMethodFromPath({
|
|
271
|
+
entryPath,
|
|
272
|
+
env: process.env,
|
|
273
|
+
});
|
|
274
|
+
if (withoutNpm !== "unknown") return withoutNpm;
|
|
275
|
+
return detectInstallMethodFromPath({
|
|
276
|
+
entryPath,
|
|
277
|
+
env: process.env,
|
|
278
|
+
npmGlobalRoot: await readNpmGlobalRoot(),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function runCommand(command: Command) {
|
|
283
|
+
const result = await execCommand(command);
|
|
284
|
+
if (result instanceof Error) return result;
|
|
285
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Button, Close, colors } from "maui";
|
|
2
|
+
import { style, useStyles } from "purse-styles";
|
|
3
|
+
|
|
4
|
+
const closeIcon = style({
|
|
5
|
+
color: colors.red[9],
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export function CloseServerButton(props: { onClick?: () => void }) {
|
|
9
|
+
const iconClass = useStyles(closeIcon);
|
|
10
|
+
return (
|
|
11
|
+
<Button variant="default" onClick={props.onClick}>
|
|
12
|
+
<Close className={iconClass} size="sm" />
|
|
13
|
+
Close server
|
|
14
|
+
</Button>
|
|
15
|
+
);
|
|
16
|
+
}
|