Skip to content

Commit bdd06b1

Browse files
committed
feat(cli): support self-updated binaries
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5a30d535-428b-49ca-892c-e2a25c137a5f
1 parent 287ae03 commit bdd06b1

9 files changed

Lines changed: 144 additions & 52 deletions

bin/trackseries.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { spawnSync } from "node:child_process";
44
import { existsSync } from "node:fs";
55
import { ensureBinary } from "../scripts/install.js";
6+
import { binaryRoot } from "../scripts/platform.js";
67

78
let executablePath;
89
try {
@@ -13,6 +14,10 @@ try {
1314
}
1415

1516
const result = spawnSync(executablePath, process.argv.slice(2), {
17+
env: {
18+
...process.env,
19+
TRACKSERIES_CLI_BINARY_ROOT: binaryRoot()
20+
},
1621
stdio: "inherit"
1722
});
1823

bin/trackseries.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import assert from "node:assert/strict";
2+
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { resolve } from "node:path";
5+
import { spawnSync } from "node:child_process";
6+
import test from "node:test";
7+
8+
test("launches the active self-updated binary with the managed root", async () => {
9+
const dataHome = await mkdtemp(resolve(tmpdir(), "trackseries-launcher-test-"));
10+
const binaryRoot = resolve(dataHome, "trackseries", "bin");
11+
const binaryDirectory = resolve(binaryRoot, "9.9.9");
12+
const executablePath = resolve(binaryDirectory, "trackseries");
13+
14+
try {
15+
await mkdir(binaryDirectory, { recursive: true });
16+
await writeFile(resolve(binaryRoot, "current.json"), JSON.stringify({ version: "9.9.9" }));
17+
await writeFile(executablePath, '#!/bin/sh\nprintf "%s\\n" "$TRACKSERIES_CLI_BINARY_ROOT" "$*"\n');
18+
await chmod(executablePath, 0o755);
19+
20+
const result = spawnSync(process.execPath, ["bin/trackseries.js", "stats", "--json"], {
21+
cwd: resolve(import.meta.dirname, ".."),
22+
encoding: "utf8",
23+
env: {
24+
...process.env,
25+
XDG_DATA_HOME: dataHome
26+
}
27+
});
28+
29+
assert.equal(result.status, 0, result.stderr);
30+
assert.equal(result.stdout, `${binaryRoot}\nstats --json\n`);
31+
} finally {
32+
await rm(dataHome, { force: true, recursive: true });
33+
}
34+
});

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
},
2020
"dependencies": {
2121
"fflate": "0.8.3",
22+
"semver": "7.8.5",
2223
"tar": "7.5.22"
2324
},
2425
"publishConfig": {

scripts/assert-newer-version.js

Lines changed: 9 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,15 @@
1-
const candidate = parseVersion(process.argv[2]);
2-
const published = process.argv[3] ? parseVersion(process.argv[3]) : undefined;
1+
import { gt, valid } from "semver";
32

4-
if (published && compareVersions(candidate, published) <= 0) {
5-
throw new Error(`${candidate.source} must be newer than the current npm tag version ${published.source}.`);
6-
}
7-
8-
function parseVersion(source) {
9-
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/.exec(source);
10-
if (!match) {
11-
throw new Error(`${source} is not a supported semantic version.`);
12-
}
3+
const candidate = assertVersion(process.argv[2]);
4+
const published = process.argv[3] ? assertVersion(process.argv[3]) : undefined;
135

14-
return {
15-
source,
16-
core: [Number(match[1]), Number(match[2]), Number(match[3])],
17-
prerelease: match[4]?.split(".")
18-
};
6+
if (published && !gt(candidate, published)) {
7+
throw new Error(`${candidate} must be newer than the current npm tag version ${published}.`);
198
}
209

21-
function compareVersions(left, right) {
22-
for (let index = 0; index < left.core.length; index += 1) {
23-
if (left.core[index] !== right.core[index]) {
24-
return left.core[index] - right.core[index];
25-
}
26-
}
27-
28-
if (!left.prerelease || !right.prerelease) {
29-
if (!left.prerelease && !right.prerelease) return 0;
30-
return left.prerelease ? -1 : 1;
31-
}
32-
33-
const length = Math.max(left.prerelease.length, right.prerelease.length);
34-
for (let index = 0; index < length; index += 1) {
35-
const leftIdentifier = left.prerelease[index];
36-
const rightIdentifier = right.prerelease[index];
37-
if (leftIdentifier === undefined) return -1;
38-
if (rightIdentifier === undefined) return 1;
39-
if (leftIdentifier === rightIdentifier) continue;
40-
41-
const leftNumeric = /^\d+$/.test(leftIdentifier);
42-
const rightNumeric = /^\d+$/.test(rightIdentifier);
43-
if (leftNumeric && rightNumeric) return Number(leftIdentifier) - Number(rightIdentifier);
44-
if (leftNumeric) return -1;
45-
if (rightNumeric) return 1;
46-
return leftIdentifier < rightIdentifier ? -1 : 1;
10+
function assertVersion(source) {
11+
if (!source || !valid(source)) {
12+
throw new Error(`${source} is not a supported semantic version.`);
4713
}
48-
49-
return 0;
14+
return source;
5015
}

scripts/install.js

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { tmpdir } from "node:os";
44
import { dirname, resolve } from "node:path";
55
import { fileURLToPath } from "node:url";
66
import { unzipSync } from "fflate";
7+
import { gt, valid } from "semver";
78
import { x as extractTar } from "tar";
8-
import { assertSupportedRuntime, binaryDirectory, releaseArtifact } from "./platform.js";
9+
import { assertSupportedRuntime, binaryDirectory, binaryRoot, releaseArtifact } from "./platform.js";
910

1011
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
1112
const metadata = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8"));
@@ -54,11 +55,13 @@ async function isRegularFile(path) {
5455

5556
export async function ensureBinary() {
5657
assertSupportedRuntime();
57-
const artifact = releaseArtifact(metadata.version);
58+
const root = binaryRoot();
59+
const version = await resolveBinaryVersion(metadata.version, root);
60+
const artifact = releaseArtifact(version);
5861
const releaseBaseUrl =
59-
process.env.TRACKSERIES_CLI_RELEASE_BASE_URL ?? `https://github.com/TrackSeries/cli/releases/download/cli-v${metadata.version}`;
62+
process.env.TRACKSERIES_CLI_RELEASE_BASE_URL ?? `https://github.com/TrackSeries/cli/releases/download/cli-v${version}`;
6063
const executableName = process.platform === "win32" ? "trackseries.exe" : "trackseries";
61-
const installedExecutable = resolve(binaryDirectory(metadata.version), executableName);
64+
const installedExecutable = resolve(binaryDirectory(version), executableName);
6265

6366
if (await isRegularFile(installedExecutable)) return installedExecutable;
6467

@@ -67,7 +70,7 @@ export async function ensureBinary() {
6770
const extractionPath = resolve(temporaryDirectory, "extracted");
6871
const pendingExecutable = `${installedExecutable}.${process.pid}.tmp`;
6972

70-
console.error(`Downloading TrackSeries CLI ${metadata.version} for ${process.platform}-${process.arch}...`);
73+
console.error(`Downloading TrackSeries CLI ${version} for ${process.platform}-${process.arch}...`);
7174

7275
try {
7376
const [archive, checksumFile] = await Promise.all([
@@ -119,11 +122,30 @@ export async function ensureBinary() {
119122
} catch (error) {
120123
const reason = error instanceof Error ? error.message : String(error);
121124
throw new Error(
122-
`Unable to install TrackSeries CLI ${metadata.version}: ${reason} Download ${artifact} manually from ${releaseBaseUrl}.`,
125+
`Unable to install TrackSeries CLI ${version}: ${reason} Download ${artifact} manually from ${releaseBaseUrl}.`,
123126
{ cause: error }
124127
);
125128
} finally {
126129
await rm(pendingExecutable, { force: true });
127130
await rm(temporaryDirectory, { force: true, recursive: true });
128131
}
129132
}
133+
134+
export async function resolveBinaryVersion(packageVersion, root) {
135+
const manifestPath = resolve(root, "current.json");
136+
let manifest;
137+
try {
138+
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
139+
} catch (error) {
140+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
141+
return packageVersion;
142+
}
143+
throw new Error(`Unable to read the TrackSeries CLI update state at ${manifestPath}.`, { cause: error });
144+
}
145+
146+
const activeVersion = typeof manifest?.version === "string" ? valid(manifest.version) : null;
147+
if (!activeVersion) {
148+
throw new Error(`The TrackSeries CLI update state at ${manifestPath} contains an invalid version.`);
149+
}
150+
return gt(activeVersion, packageVersion) ? activeVersion : packageVersion;
151+
}

scripts/install.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import assert from "node:assert/strict";
2+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { resolve } from "node:path";
5+
import test from "node:test";
6+
7+
import { resolveBinaryVersion } from "./install.js";
8+
9+
test("uses the package version without an active self-update", async () => {
10+
const root = await mkdtemp(resolve(tmpdir(), "trackseries-launcher-test-"));
11+
try {
12+
assert.equal(await resolveBinaryVersion("1.2.0", root), "1.2.0");
13+
} finally {
14+
await rm(root, { force: true, recursive: true });
15+
}
16+
});
17+
18+
test("uses a newer self-updated binary version", async () => {
19+
const root = await mkdtemp(resolve(tmpdir(), "trackseries-launcher-test-"));
20+
try {
21+
await writeFile(resolve(root, "current.json"), JSON.stringify({ version: "1.3.0-preview.1" }));
22+
assert.equal(await resolveBinaryVersion("1.2.0", root), "1.3.0-preview.1");
23+
} finally {
24+
await rm(root, { force: true, recursive: true });
25+
}
26+
});
27+
28+
test("prefers a newer npm package over an old self-update", async () => {
29+
const root = await mkdtemp(resolve(tmpdir(), "trackseries-launcher-test-"));
30+
try {
31+
await writeFile(resolve(root, "current.json"), JSON.stringify({ version: "1.1.0" }));
32+
assert.equal(await resolveBinaryVersion("1.2.0", root), "1.2.0");
33+
} finally {
34+
await rm(root, { force: true, recursive: true });
35+
}
36+
});
37+
38+
test("rejects an invalid self-update manifest", async () => {
39+
const root = await mkdtemp(resolve(tmpdir(), "trackseries-launcher-test-"));
40+
try {
41+
await writeFile(resolve(root, "current.json"), JSON.stringify({ version: "../../escape" }));
42+
await assert.rejects(resolveBinaryVersion("1.2.0", root), /contains an invalid version/);
43+
} finally {
44+
await rm(root, { force: true, recursive: true });
45+
}
46+
});

scripts/platform.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export function assertSupportedRuntime(platform = process.platform, report = pro
2525
}
2626

2727
export function binaryDirectory(version, platform = process.platform, environment = process.env, home = homedir()) {
28+
const path = platform === "win32" ? win32 : posix;
29+
return path.join(binaryRoot(platform, environment, home), version);
30+
}
31+
32+
export function binaryRoot(platform = process.platform, environment = process.env, home = homedir()) {
2833
const path = platform === "win32" ? win32 : posix;
2934
let dataDirectory;
3035

@@ -36,5 +41,5 @@ export function binaryDirectory(version, platform = process.platform, environmen
3641
dataDirectory = environment.XDG_DATA_HOME ?? path.join(home, ".local", "share");
3742
}
3843

39-
return path.join(dataDirectory, "trackseries", "bin", version);
44+
return path.join(dataDirectory, "trackseries", "bin");
4045
}

scripts/platform.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import test from "node:test";
3-
import { assertSupportedRuntime, binaryDirectory, releaseArtifact } from "./platform.js";
3+
import { assertSupportedRuntime, binaryDirectory, binaryRoot, releaseArtifact } from "./platform.js";
44

55
test("maps supported platforms to release archives", () => {
66
assert.equal(releaseArtifact("1.2.3", "linux", "x64"), "trackseries-1.2.3-linux-x64.tar.gz");
@@ -20,6 +20,7 @@ test("rejects musl-based Linux installations", () => {
2020
});
2121

2222
test("uses platform-specific user data directories", () => {
23+
assert.equal(binaryRoot("linux", { XDG_DATA_HOME: "/data" }, "/home/user"), "/data/trackseries/bin");
2324
assert.equal(binaryDirectory("1.2.3", "linux", { XDG_DATA_HOME: "/data" }, "/home/user"), "/data/trackseries/bin/1.2.3");
2425
assert.equal(
2526
binaryDirectory("1.2.3", "darwin", {}, "/Users/user"),

0 commit comments

Comments
 (0)