@happier-dev/relay-server 0.1.2-preview.10.1 → 0.1.2-preview.138.1
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/bin/happier-server.mjs +92 -101
- package/package.json +5 -1
- package/src/checksums.mjs +1 -17
- package/src/checksums.test.mjs +1 -2
- package/src/minisign.mjs +1 -94
- package/src/minisign.verify.test.mjs +1 -2
- package/src/releaseAssets.mjs +30 -30
- package/src/releaseAssets.test.mjs +33 -1
- package/src/runnerConfig.mjs +60 -0
- package/src/runnerConfig.test.mjs +31 -0
- package/src/target.mjs +48 -0
- package/src/target.test.mjs +54 -0
package/bin/happier-server.mjs
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, readFile, rm, stat } from 'node:fs/promises';
|
|
4
3
|
import { homedir, platform, arch, tmpdir } from 'node:os';
|
|
5
4
|
import { join } from 'node:path';
|
|
6
5
|
import { spawn } from 'node:child_process';
|
|
7
6
|
|
|
8
|
-
import { resolveServerReleaseAssets } from '../src/releaseAssets.mjs';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
import { resolveServerReleaseAssets, resolveUiWebReleaseAssets } from '../src/releaseAssets.mjs';
|
|
8
|
+
import { resolveRunnerCacheRoot, resolveServerRunnerTarget } from '../src/target.mjs';
|
|
9
|
+
import { parseRunnerInvocation } from '../src/runnerConfig.mjs';
|
|
10
|
+
import { downloadVerifiedReleaseAssetBundle } from '@happier-dev/release-runtime/verifiedDownload';
|
|
11
|
+
import { planArchiveExtraction } from '@happier-dev/release-runtime/extractPlan';
|
|
12
|
+
import { fetchGitHubReleaseByTag } from '@happier-dev/release-runtime/github';
|
|
11
13
|
|
|
12
14
|
const OWNER = 'happier-dev';
|
|
13
15
|
const REPO = 'happier';
|
|
@@ -17,76 +19,16 @@ function fail(msg) {
|
|
|
17
19
|
process.exit(1);
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
function parseArgs(argv) {
|
|
21
|
-
const kv = new Map();
|
|
22
|
-
const positionals = [];
|
|
23
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
24
|
-
const a = argv[i];
|
|
25
|
-
if (a === '--') {
|
|
26
|
-
positionals.push(...argv.slice(i + 1));
|
|
27
|
-
break;
|
|
28
|
-
}
|
|
29
|
-
if (!a.startsWith('--')) {
|
|
30
|
-
positionals.push(a);
|
|
31
|
-
continue;
|
|
32
|
-
}
|
|
33
|
-
const v = argv[i + 1];
|
|
34
|
-
if (v && !v.startsWith('--')) {
|
|
35
|
-
kv.set(a, v);
|
|
36
|
-
i += 1;
|
|
37
|
-
} else {
|
|
38
|
-
kv.set(a, 'true');
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return { kv, positionals };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
22
|
function resolveTarget() {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
fail(`Unsupported platform '${os}'. Server runner currently supports linux only.`);
|
|
50
|
-
}
|
|
51
|
-
if (cpu !== 'x64' && cpu !== 'arm64') {
|
|
52
|
-
fail(`Unsupported architecture '${cpu}'. Expected x64 or arm64.`);
|
|
23
|
+
try {
|
|
24
|
+
return resolveServerRunnerTarget({ platform: platform(), arch: arch() });
|
|
25
|
+
} catch (e) {
|
|
26
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
53
27
|
}
|
|
54
|
-
return { os, arch: cpu };
|
|
55
28
|
}
|
|
56
29
|
|
|
57
30
|
function cacheRoot() {
|
|
58
|
-
|
|
59
|
-
if (xdg) return xdg;
|
|
60
|
-
return join(homedir(), '.cache');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function fetchJson(url) {
|
|
64
|
-
const res = await fetch(url, {
|
|
65
|
-
headers: {
|
|
66
|
-
'user-agent': 'happier-server-runner',
|
|
67
|
-
accept: 'application/vnd.github+json',
|
|
68
|
-
},
|
|
69
|
-
});
|
|
70
|
-
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
71
|
-
return res.json();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async function fetchText(url) {
|
|
75
|
-
const res = await fetch(url, { headers: { 'user-agent': 'happier-server-runner' } });
|
|
76
|
-
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
77
|
-
return res.text();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function downloadFile(url, destPath) {
|
|
81
|
-
const res = await fetch(url, { headers: { 'user-agent': 'happier-server-runner' } });
|
|
82
|
-
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
83
|
-
const ab = await res.arrayBuffer();
|
|
84
|
-
await writeFile(destPath, Buffer.from(ab));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function sha256File(path) {
|
|
88
|
-
const bytes = await readFile(path);
|
|
89
|
-
return createHash('sha256').update(bytes).digest('hex');
|
|
31
|
+
return resolveRunnerCacheRoot({ platform: platform(), homedir: homedir(), env: process.env });
|
|
90
32
|
}
|
|
91
33
|
|
|
92
34
|
async function pathExists(p) {
|
|
@@ -99,17 +41,18 @@ async function pathExists(p) {
|
|
|
99
41
|
}
|
|
100
42
|
|
|
101
43
|
async function main() {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
if (channel !== 'stable' && channel !== 'preview') {
|
|
105
|
-
fail(`Invalid --channel '${channel}'. Expected stable|preview.`);
|
|
106
|
-
}
|
|
107
|
-
const tag = String(kv.get('--tag') ?? '').trim() || (channel === 'preview' ? 'server-preview' : 'server-stable');
|
|
44
|
+
const parsed = parseRunnerInvocation(process.argv.slice(2));
|
|
45
|
+
const { serverTag: tag, uiWebTag, withUiWeb, positionals } = parsed;
|
|
108
46
|
|
|
109
47
|
const target = resolveTarget();
|
|
48
|
+
const githubRepo = `${OWNER}/${REPO}`;
|
|
110
49
|
|
|
111
|
-
const
|
|
112
|
-
|
|
50
|
+
const release = await fetchGitHubReleaseByTag({
|
|
51
|
+
githubRepo,
|
|
52
|
+
tag,
|
|
53
|
+
userAgent: 'happier-server-runner',
|
|
54
|
+
githubToken: String(process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''),
|
|
55
|
+
});
|
|
113
56
|
const assets = resolveServerReleaseAssets({ release, os: target.os, arch: target.arch });
|
|
114
57
|
|
|
115
58
|
const pubkeyPath = new URL('../assets/happier-release.pub', import.meta.url);
|
|
@@ -118,37 +61,37 @@ async function main() {
|
|
|
118
61
|
const tmp = join(tmpdir(), `happier-server-${process.pid}-${Date.now()}`);
|
|
119
62
|
await mkdir(tmp, { recursive: true });
|
|
120
63
|
try {
|
|
121
|
-
const checksumsPath = join(tmp, assets.checksums.name);
|
|
122
|
-
const checksumsSigPath = join(tmp, assets.checksumsSig.name);
|
|
123
|
-
await downloadFile(assets.checksums.url, checksumsPath);
|
|
124
|
-
await downloadFile(assets.checksumsSig.url, checksumsSigPath);
|
|
125
|
-
|
|
126
|
-
const checksumsText = await readFile(checksumsPath, 'utf-8');
|
|
127
|
-
const sigFile = await readFile(checksumsSigPath, 'utf-8');
|
|
128
|
-
const ok = verifyMinisign({ message: Buffer.from(checksumsText, 'utf-8'), pubkeyFile, sigFile });
|
|
129
|
-
if (!ok) fail('Signature verification failed for checksums file.');
|
|
130
|
-
|
|
131
|
-
const expected = lookupSha256({ checksumsText, filename: assets.tarball.name });
|
|
132
|
-
|
|
133
64
|
const cacheDir = join(cacheRoot(), 'happier', 'server', tag, assets.version, `${target.os}-${target.arch}`);
|
|
134
65
|
await mkdir(cacheDir, { recursive: true });
|
|
135
66
|
const artifactStem = `happier-server-v${assets.version}-${target.os}-${target.arch}`;
|
|
136
67
|
const serverDir = join(cacheDir, artifactStem);
|
|
137
|
-
const serverBin = join(serverDir,
|
|
68
|
+
const serverBin = join(serverDir, target.exeName);
|
|
138
69
|
|
|
139
70
|
if (!(await pathExists(serverBin))) {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
71
|
+
const downloaded = await downloadVerifiedReleaseAssetBundle({
|
|
72
|
+
bundle: {
|
|
73
|
+
version: assets.version,
|
|
74
|
+
archive: assets.tarball,
|
|
75
|
+
checksums: assets.checksums,
|
|
76
|
+
checksumsSig: assets.checksumsSig,
|
|
77
|
+
},
|
|
78
|
+
destDir: tmp,
|
|
79
|
+
pubkeyFile,
|
|
80
|
+
userAgent: 'happier-server-runner',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const plan = planArchiveExtraction({
|
|
84
|
+
archiveName: downloaded.archiveName,
|
|
85
|
+
archivePath: downloaded.archivePath,
|
|
86
|
+
destDir: cacheDir,
|
|
87
|
+
os: target.os,
|
|
88
|
+
});
|
|
146
89
|
|
|
147
90
|
// Extract archive into cache (archive root contains the artifactStem folder).
|
|
148
|
-
const extract = spawn(
|
|
91
|
+
const extract = spawn(plan.command.cmd, plan.command.args, { stdio: 'inherit' });
|
|
149
92
|
await new Promise((resolve, reject) => {
|
|
150
93
|
extract.on('error', reject);
|
|
151
|
-
extract.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(
|
|
94
|
+
extract.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${plan.command.cmd} exited with ${code}`))));
|
|
152
95
|
});
|
|
153
96
|
}
|
|
154
97
|
|
|
@@ -156,7 +99,56 @@ async function main() {
|
|
|
156
99
|
fail(`Extracted server binary not found at ${serverBin}`);
|
|
157
100
|
}
|
|
158
101
|
|
|
159
|
-
const
|
|
102
|
+
const childEnv = { ...process.env };
|
|
103
|
+
if (withUiWeb && !String(process.env.HAPPIER_SERVER_UI_DIR ?? '').trim()) {
|
|
104
|
+
const uiRelease = await fetchGitHubReleaseByTag({
|
|
105
|
+
githubRepo,
|
|
106
|
+
tag: uiWebTag,
|
|
107
|
+
userAgent: 'happier-server-runner',
|
|
108
|
+
githubToken: String(process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''),
|
|
109
|
+
});
|
|
110
|
+
const uiAssets = resolveUiWebReleaseAssets({ release: uiRelease });
|
|
111
|
+
const uiCacheDir = join(cacheRoot(), 'happier', 'ui-web', uiWebTag, uiAssets.version, 'web-any');
|
|
112
|
+
await mkdir(uiCacheDir, { recursive: true });
|
|
113
|
+
const uiStem = `happier-ui-web-v${uiAssets.version}-web-any`;
|
|
114
|
+
const uiDir = join(uiCacheDir, uiStem);
|
|
115
|
+
const uiIndex = join(uiDir, 'index.html');
|
|
116
|
+
|
|
117
|
+
if (!(await pathExists(uiIndex))) {
|
|
118
|
+
const uiDownloaded = await downloadVerifiedReleaseAssetBundle({
|
|
119
|
+
bundle: {
|
|
120
|
+
version: uiAssets.version,
|
|
121
|
+
archive: uiAssets.tarball,
|
|
122
|
+
checksums: uiAssets.checksums,
|
|
123
|
+
checksumsSig: uiAssets.checksumsSig,
|
|
124
|
+
},
|
|
125
|
+
destDir: tmp,
|
|
126
|
+
pubkeyFile,
|
|
127
|
+
userAgent: 'happier-server-runner',
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const uiPlan = planArchiveExtraction({
|
|
131
|
+
archiveName: uiDownloaded.archiveName,
|
|
132
|
+
archivePath: uiDownloaded.archivePath,
|
|
133
|
+
destDir: uiCacheDir,
|
|
134
|
+
os: target.os,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const extractUi = spawn(uiPlan.command.cmd, uiPlan.command.args, { stdio: 'inherit' });
|
|
138
|
+
await new Promise((resolve, reject) => {
|
|
139
|
+
extractUi.on('error', reject);
|
|
140
|
+
extractUi.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${uiPlan.command.cmd} exited with ${code}`))));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!(await pathExists(uiIndex))) {
|
|
145
|
+
fail(`Extracted ui web bundle not found at ${uiIndex}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
childEnv.HAPPIER_SERVER_UI_DIR = uiDir;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const child = spawn(serverBin, positionals, { stdio: 'inherit', env: childEnv });
|
|
160
152
|
child.on('exit', (code, signal) => {
|
|
161
153
|
if (signal) process.kill(process.pid, signal);
|
|
162
154
|
process.exit(code ?? 1);
|
|
@@ -169,4 +161,3 @@ async function main() {
|
|
|
169
161
|
main().catch((err) => {
|
|
170
162
|
fail(err instanceof Error ? err.message : String(err));
|
|
171
163
|
});
|
|
172
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happier-dev/relay-server",
|
|
3
|
-
"version": "0.1.2-preview.
|
|
3
|
+
"version": "0.1.2-preview.138.1",
|
|
4
4
|
"description": "Happier server runner (downloads and verifies the correct server binary for your platform).",
|
|
5
5
|
"repository": "https://github.com/happier-dev/happier.git",
|
|
6
6
|
"author": "Leeroy Brun <leeroy.brun@gmail.com>",
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
"bin",
|
|
16
16
|
"src"
|
|
17
17
|
],
|
|
18
|
+
"bundledDependencies": [
|
|
19
|
+
"@happier-dev/release-runtime"
|
|
20
|
+
],
|
|
18
21
|
"engines": {
|
|
19
22
|
"node": ">=22"
|
|
20
23
|
},
|
|
@@ -23,6 +26,7 @@
|
|
|
23
26
|
"provenance": true
|
|
24
27
|
},
|
|
25
28
|
"scripts": {
|
|
29
|
+
"prepack": "node ./scripts/bundleWorkspaceDeps.mjs",
|
|
26
30
|
"test": "node --test"
|
|
27
31
|
}
|
|
28
32
|
}
|
package/src/checksums.mjs
CHANGED
|
@@ -1,17 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
const target = String(filename ?? '').trim();
|
|
3
|
-
if (!target) throw new Error('[checksums] filename is required');
|
|
4
|
-
|
|
5
|
-
const text = String(checksumsText ?? '');
|
|
6
|
-
const lines = text.split('\n');
|
|
7
|
-
for (const line of lines) {
|
|
8
|
-
const trimmed = line.trim();
|
|
9
|
-
if (!trimmed) continue;
|
|
10
|
-
const m = /^([0-9a-fA-F]{8,})\s+(.+)$/.exec(trimmed);
|
|
11
|
-
if (!m) continue;
|
|
12
|
-
const hash = m[1].toLowerCase();
|
|
13
|
-
const file = m[2].trim();
|
|
14
|
-
if (file === target) return hash;
|
|
15
|
-
}
|
|
16
|
-
throw new Error(`[checksums] sha256 not found for ${target}`);
|
|
17
|
-
}
|
|
1
|
+
export { lookupSha256 } from '@happier-dev/release-runtime/checksums';
|
package/src/checksums.test.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
|
|
4
|
-
import { lookupSha256 } from '
|
|
4
|
+
import { lookupSha256 } from '@happier-dev/release-runtime/checksums';
|
|
5
5
|
|
|
6
6
|
test('lookupSha256 returns sha256 for matching filename', () => {
|
|
7
7
|
const text = [
|
|
@@ -15,4 +15,3 @@ test('lookupSha256 returns sha256 for matching filename', () => {
|
|
|
15
15
|
test('lookupSha256 throws when filename not present', () => {
|
|
16
16
|
assert.throws(() => lookupSha256({ checksumsText: 'aaaa other', filename: 'missing' }));
|
|
17
17
|
});
|
|
18
|
-
|
package/src/minisign.mjs
CHANGED
|
@@ -1,94 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
4
|
-
|
|
5
|
-
function decodeBase64Line(line, expectedBytes) {
|
|
6
|
-
const bytes = Buffer.from(String(line ?? '').trim(), 'base64');
|
|
7
|
-
if (expectedBytes != null && bytes.length !== expectedBytes) {
|
|
8
|
-
throw new Error(`[minisign] expected ${expectedBytes} bytes, got ${bytes.length}`);
|
|
9
|
-
}
|
|
10
|
-
return bytes;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function parseMinisignPublicKeyFile(pubkeyFile) {
|
|
14
|
-
const lines = String(pubkeyFile ?? '')
|
|
15
|
-
.split('\n')
|
|
16
|
-
.map((l) => l.trim())
|
|
17
|
-
.filter(Boolean);
|
|
18
|
-
if (lines.length < 2) throw new Error('[minisign] invalid public key file');
|
|
19
|
-
const payload = lines.at(-1);
|
|
20
|
-
const bytes = decodeBase64Line(payload, 42);
|
|
21
|
-
const signatureAlgorithm = bytes.subarray(0, 2);
|
|
22
|
-
const keyId = bytes.subarray(2, 10);
|
|
23
|
-
const rawPublicKey = bytes.subarray(10, 42);
|
|
24
|
-
return { signatureAlgorithm, keyId, rawPublicKey };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function parseMinisignSignatureFile(sigFile) {
|
|
28
|
-
const lines = String(sigFile ?? '').split('\n');
|
|
29
|
-
if (lines.length < 4) throw new Error('[minisign] invalid signature file');
|
|
30
|
-
|
|
31
|
-
const untrustedPayload = String(lines[1] ?? '').trim();
|
|
32
|
-
const trustedComment = String(lines[2] ?? '');
|
|
33
|
-
const globalPayload = String(lines[3] ?? '').trim();
|
|
34
|
-
|
|
35
|
-
const untrustedBytes = decodeBase64Line(untrustedPayload, 74);
|
|
36
|
-
const signatureAlgorithm = untrustedBytes.subarray(0, 2);
|
|
37
|
-
const keyId = untrustedBytes.subarray(2, 10);
|
|
38
|
-
const signature = untrustedBytes.subarray(10, 74);
|
|
39
|
-
|
|
40
|
-
const globalSignature = decodeBase64Line(globalPayload, 64);
|
|
41
|
-
|
|
42
|
-
if (!trustedComment.startsWith('trusted comment: ')) {
|
|
43
|
-
throw new Error('[minisign] unexpected trusted comment format');
|
|
44
|
-
}
|
|
45
|
-
const trustedSuffix = Buffer.from(trustedComment.slice('trusted comment: '.length), 'utf-8');
|
|
46
|
-
|
|
47
|
-
return { signatureAlgorithm, keyId, signature, trustedSuffix, globalSignature };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function createEd25519PublicKey(rawPublicKey) {
|
|
51
|
-
if (!Buffer.isBuffer(rawPublicKey) || rawPublicKey.length !== 32) {
|
|
52
|
-
throw new Error('[minisign] invalid Ed25519 public key length');
|
|
53
|
-
}
|
|
54
|
-
const spki = Buffer.concat([ED25519_SPKI_PREFIX, rawPublicKey]);
|
|
55
|
-
return createPublicKey({ key: spki, format: 'der', type: 'spki' });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function bytesEqual(a, b) {
|
|
59
|
-
if (!a || !b) return false;
|
|
60
|
-
if (a.length !== b.length) return false;
|
|
61
|
-
// constant-time compare not required here (public data), but keep it simple.
|
|
62
|
-
return Buffer.compare(a, b) === 0;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function verifyMinisign({ message, pubkeyFile, sigFile }) {
|
|
66
|
-
const bin = Buffer.isBuffer(message) ? message : Buffer.from(message ?? '');
|
|
67
|
-
const pubkey = parseMinisignPublicKeyFile(pubkeyFile);
|
|
68
|
-
const sig = parseMinisignSignatureFile(sigFile);
|
|
69
|
-
|
|
70
|
-
if (!bytesEqual(pubkey.signatureAlgorithm, Buffer.from('Ed'))) {
|
|
71
|
-
throw new Error('[minisign] incompatible public key signature algorithm');
|
|
72
|
-
}
|
|
73
|
-
if (!bytesEqual(pubkey.keyId, sig.keyId)) {
|
|
74
|
-
throw new Error('[minisign] incompatible key identifiers');
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
let prehashed = false;
|
|
78
|
-
if (bytesEqual(sig.signatureAlgorithm, Buffer.from('Ed'))) {
|
|
79
|
-
prehashed = false;
|
|
80
|
-
} else if (bytesEqual(sig.signatureAlgorithm, Buffer.from('ED'))) {
|
|
81
|
-
prehashed = true;
|
|
82
|
-
} else {
|
|
83
|
-
throw new Error('[minisign] unsupported signature algorithm');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const publicKey = createEd25519PublicKey(pubkey.rawPublicKey);
|
|
87
|
-
const payload = prehashed ? createHash('blake2b512').update(bin).digest() : bin;
|
|
88
|
-
|
|
89
|
-
const okSig = verify(null, payload, publicKey, sig.signature);
|
|
90
|
-
if (!okSig) return false;
|
|
91
|
-
|
|
92
|
-
const okGlobal = verify(null, Buffer.concat([sig.signature, sig.trustedSuffix]), publicKey, sig.globalSignature);
|
|
93
|
-
return okGlobal;
|
|
94
|
-
}
|
|
1
|
+
export { verifyMinisign } from '@happier-dev/release-runtime/minisign';
|
|
@@ -2,7 +2,7 @@ import test from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { generateKeyPairSync, sign } from 'node:crypto';
|
|
4
4
|
|
|
5
|
-
import { verifyMinisign } from '
|
|
5
|
+
import { verifyMinisign } from '@happier-dev/release-runtime/minisign';
|
|
6
6
|
|
|
7
7
|
function b64(buf) {
|
|
8
8
|
return Buffer.from(buf).toString('base64');
|
|
@@ -72,4 +72,3 @@ test('verifyMinisign rejects invalid signatures', () => {
|
|
|
72
72
|
|
|
73
73
|
assert.equal(verifyMinisign({ message: Buffer.from('tampered', 'utf-8'), pubkeyFile, sigFile }), false);
|
|
74
74
|
});
|
|
75
|
-
|
package/src/releaseAssets.mjs
CHANGED
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
const assets = Array.isArray(release?.assets) ? release.assets : [];
|
|
3
|
-
const byName = new Map();
|
|
4
|
-
for (const asset of assets) {
|
|
5
|
-
const name = String(asset?.name ?? '');
|
|
6
|
-
const url = String(asset?.browser_download_url ?? '');
|
|
7
|
-
if (!name || !url) continue;
|
|
8
|
-
byName.set(name, { name, url });
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const checksumsName = [...byName.keys()].find((name) => /^checksums-happier-server-v\d+\.\d+\.\d+\.txt$/.test(name));
|
|
12
|
-
if (!checksumsName) {
|
|
13
|
-
throw new Error('[server] missing checksums-happier-server-v<version>.txt asset');
|
|
14
|
-
}
|
|
15
|
-
const versionMatch = /^checksums-happier-server-v(\d+\.\d+\.\d+)\.txt$/.exec(checksumsName);
|
|
16
|
-
const version = versionMatch?.[1] ?? null;
|
|
17
|
-
if (!version) {
|
|
18
|
-
throw new Error('[server] unable to derive server version from checksums filename');
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const checksumsSigName = `${checksumsName}.minisig`;
|
|
22
|
-
const tarballName = `happier-server-v${version}-${os}-${arch}.tar.gz`;
|
|
1
|
+
import { resolveReleaseAssetBundle } from '@happier-dev/release-runtime/assets';
|
|
23
2
|
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
3
|
+
export function resolveServerReleaseAssets({ release, os, arch }) {
|
|
4
|
+
const resolved = resolveReleaseAssetBundle({
|
|
5
|
+
assets: release?.assets,
|
|
6
|
+
product: 'happier-server',
|
|
7
|
+
os,
|
|
8
|
+
arch,
|
|
9
|
+
preferZipOnWindows: true,
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
version: resolved.version,
|
|
13
|
+
tarball: resolved.archive,
|
|
14
|
+
checksums: resolved.checksums,
|
|
15
|
+
checksumsSig: resolved.checksumsSig,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
31
18
|
|
|
32
|
-
|
|
19
|
+
export function resolveUiWebReleaseAssets({ release }) {
|
|
20
|
+
const resolved = resolveReleaseAssetBundle({
|
|
21
|
+
assets: release?.assets,
|
|
22
|
+
product: 'happier-ui-web',
|
|
23
|
+
os: 'web',
|
|
24
|
+
arch: 'any',
|
|
25
|
+
preferZipOnWindows: false,
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
version: resolved.version,
|
|
29
|
+
tarball: resolved.archive,
|
|
30
|
+
checksums: resolved.checksums,
|
|
31
|
+
checksumsSig: resolved.checksumsSig,
|
|
32
|
+
};
|
|
33
33
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
|
|
4
|
-
import { resolveServerReleaseAssets } from './releaseAssets.mjs';
|
|
4
|
+
import { resolveServerReleaseAssets, resolveUiWebReleaseAssets } from './releaseAssets.mjs';
|
|
5
5
|
|
|
6
6
|
test('resolveServerReleaseAssets picks tarball + checksums + minisig for linux-x64', () => {
|
|
7
7
|
const release = {
|
|
@@ -21,8 +21,40 @@ test('resolveServerReleaseAssets picks tarball + checksums + minisig for linux-x
|
|
|
21
21
|
assert.equal(resolved.checksumsSig.name, 'checksums-happier-server-v0.1.0.txt.minisig');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
test('resolveServerReleaseAssets prefers windows zip artifacts when available', () => {
|
|
25
|
+
const release = {
|
|
26
|
+
tag_name: 'server-preview',
|
|
27
|
+
assets: [
|
|
28
|
+
{ name: 'checksums-happier-server-v0.2.0-preview.7.txt', browser_download_url: 'https://example/checksums.txt' },
|
|
29
|
+
{ name: 'checksums-happier-server-v0.2.0-preview.7.txt.minisig', browser_download_url: 'https://example/checksums.txt.minisig' },
|
|
30
|
+
{ name: 'happier-server-v0.2.0-preview.7-windows-x64.zip', browser_download_url: 'https://example/server-win.zip' },
|
|
31
|
+
{ name: 'happier-server-v0.2.0-preview.7-windows-x64.tar.gz', browser_download_url: 'https://example/server-win.tgz' },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const resolved = resolveServerReleaseAssets({ release, os: 'windows', arch: 'x64' });
|
|
36
|
+
assert.equal(resolved.version, '0.2.0-preview.7');
|
|
37
|
+
assert.equal(resolved.tarball.name, 'happier-server-v0.2.0-preview.7-windows-x64.zip');
|
|
38
|
+
});
|
|
39
|
+
|
|
24
40
|
test('resolveServerReleaseAssets throws when required assets are missing', () => {
|
|
25
41
|
const release = { tag_name: 'server-preview', assets: [{ name: 'nope', browser_download_url: 'x' }] };
|
|
26
42
|
assert.throws(() => resolveServerReleaseAssets({ release, os: 'linux', arch: 'x64' }));
|
|
27
43
|
});
|
|
28
44
|
|
|
45
|
+
test('resolveUiWebReleaseAssets picks ui-web tarball + checksums + minisig', () => {
|
|
46
|
+
const release = {
|
|
47
|
+
tag_name: 'ui-web-preview',
|
|
48
|
+
assets: [
|
|
49
|
+
{ name: 'checksums-happier-ui-web-v0.3.0-preview.1.1.txt', browser_download_url: 'https://example/checksums.txt' },
|
|
50
|
+
{ name: 'checksums-happier-ui-web-v0.3.0-preview.1.1.txt.minisig', browser_download_url: 'https://example/checksums.txt.minisig' },
|
|
51
|
+
{ name: 'happier-ui-web-v0.3.0-preview.1.1-web-any.tar.gz', browser_download_url: 'https://example/ui-web.tgz' },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const resolved = resolveUiWebReleaseAssets({ release });
|
|
56
|
+
assert.equal(resolved.version, '0.3.0-preview.1.1');
|
|
57
|
+
assert.equal(resolved.tarball.name, 'happier-ui-web-v0.3.0-preview.1.1-web-any.tar.gz');
|
|
58
|
+
assert.equal(resolved.checksums.name, 'checksums-happier-ui-web-v0.3.0-preview.1.1.txt');
|
|
59
|
+
assert.equal(resolved.checksumsSig.name, 'checksums-happier-ui-web-v0.3.0-preview.1.1.txt.minisig');
|
|
60
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
function parseArgs(argv) {
|
|
2
|
+
const kv = new Map();
|
|
3
|
+
const flags = new Set();
|
|
4
|
+
const positionals = [];
|
|
5
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
6
|
+
const a = argv[i];
|
|
7
|
+
if (a === '--') {
|
|
8
|
+
positionals.push(...argv.slice(i + 1));
|
|
9
|
+
break;
|
|
10
|
+
}
|
|
11
|
+
if (!String(a ?? '').startsWith('--')) {
|
|
12
|
+
positionals.push(String(a));
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const next = argv[i + 1];
|
|
16
|
+
if (next && !String(next).startsWith('--')) {
|
|
17
|
+
kv.set(a, String(next));
|
|
18
|
+
i += 1;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
flags.add(String(a));
|
|
22
|
+
kv.set(a, 'true');
|
|
23
|
+
}
|
|
24
|
+
return { kv, flags, positionals };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeChannel(raw) {
|
|
28
|
+
const channel = String(raw ?? '').trim() || 'stable';
|
|
29
|
+
if (channel !== 'stable' && channel !== 'preview') {
|
|
30
|
+
throw new Error(`Invalid --channel '${channel}'. Expected stable|preview.`);
|
|
31
|
+
}
|
|
32
|
+
return channel;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseBooleanFlag(raw, fallback) {
|
|
36
|
+
const value = String(raw ?? '').trim().toLowerCase();
|
|
37
|
+
if (!value) return fallback;
|
|
38
|
+
if (value === '1' || value === 'true' || value === 'yes' || value === 'y' || value === 'on') return true;
|
|
39
|
+
if (value === '0' || value === 'false' || value === 'no' || value === 'n' || value === 'off') return false;
|
|
40
|
+
return fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseRunnerInvocation(argv = []) {
|
|
44
|
+
const { kv, flags, positionals } = parseArgs(Array.isArray(argv) ? argv : []);
|
|
45
|
+
const channel = normalizeChannel(kv.get('--channel'));
|
|
46
|
+
const serverTag = String(kv.get('--tag') ?? '').trim() || (channel === 'preview' ? 'server-preview' : 'server-stable');
|
|
47
|
+
const uiWebTag = String(kv.get('--ui-tag') ?? '').trim() || (channel === 'preview' ? 'ui-web-preview' : 'ui-web-stable');
|
|
48
|
+
|
|
49
|
+
const withUiWeb =
|
|
50
|
+
!(flags.has('--without-ui') || parseBooleanFlag(kv.get('--with-ui'), true) === false);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
channel,
|
|
54
|
+
serverTag,
|
|
55
|
+
uiWebTag,
|
|
56
|
+
withUiWeb,
|
|
57
|
+
positionals,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { parseRunnerInvocation } from './runnerConfig.mjs';
|
|
5
|
+
|
|
6
|
+
test('parseRunnerInvocation defaults to stable channel and installs ui web bundle', () => {
|
|
7
|
+
const parsed = parseRunnerInvocation([]);
|
|
8
|
+
assert.equal(parsed.channel, 'stable');
|
|
9
|
+
assert.equal(parsed.serverTag, 'server-stable');
|
|
10
|
+
assert.equal(parsed.uiWebTag, 'ui-web-stable');
|
|
11
|
+
assert.equal(parsed.withUiWeb, true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('parseRunnerInvocation resolves preview tags from channel', () => {
|
|
15
|
+
const parsed = parseRunnerInvocation(['--channel', 'preview']);
|
|
16
|
+
assert.equal(parsed.channel, 'preview');
|
|
17
|
+
assert.equal(parsed.serverTag, 'server-preview');
|
|
18
|
+
assert.equal(parsed.uiWebTag, 'ui-web-preview');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('parseRunnerInvocation honors explicit tag overrides', () => {
|
|
22
|
+
const parsed = parseRunnerInvocation(['--tag', 'server-preview', '--ui-tag', 'ui-web-preview']);
|
|
23
|
+
assert.equal(parsed.serverTag, 'server-preview');
|
|
24
|
+
assert.equal(parsed.uiWebTag, 'ui-web-preview');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('parseRunnerInvocation supports --without-ui', () => {
|
|
28
|
+
const parsed = parseRunnerInvocation(['--without-ui']);
|
|
29
|
+
assert.equal(parsed.withUiWeb, false);
|
|
30
|
+
});
|
|
31
|
+
|
package/src/target.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export function resolveServerRunnerTarget({ platform, arch }) {
|
|
4
|
+
const p = String(platform ?? '').trim();
|
|
5
|
+
const a = String(arch ?? '').trim();
|
|
6
|
+
if (!p) {
|
|
7
|
+
throw new Error('Unsupported platform: (empty)');
|
|
8
|
+
}
|
|
9
|
+
if (p !== 'linux' && p !== 'darwin' && p !== 'win32') {
|
|
10
|
+
throw new Error(`Unsupported platform '${p}'. Expected linux|darwin|win32.`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const os = p === 'win32' ? 'windows' : p;
|
|
14
|
+
if (a !== 'x64' && a !== 'arm64') {
|
|
15
|
+
throw new Error(`Unsupported architecture '${a}'. Expected x64 or arm64.`);
|
|
16
|
+
}
|
|
17
|
+
if (p === 'win32' && a !== 'x64') {
|
|
18
|
+
throw new Error(`Unsupported architecture '${a}' for windows. Expected x64.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
os,
|
|
23
|
+
arch: a,
|
|
24
|
+
exeName: os === 'windows' ? 'happier-server.exe' : 'happier-server',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveRunnerCacheRoot({ platform, homedir, env }) {
|
|
29
|
+
const p = String(platform ?? '').trim();
|
|
30
|
+
const home = String(homedir ?? '').trim();
|
|
31
|
+
const e = env && typeof env === 'object' ? env : {};
|
|
32
|
+
|
|
33
|
+
if (String(e.HAPPIER_CACHE_DIR ?? '').trim()) {
|
|
34
|
+
return String(e.HAPPIER_CACHE_DIR).trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (p === 'win32') {
|
|
38
|
+
const local = String(e.LOCALAPPDATA ?? '').trim();
|
|
39
|
+
return local || join(home || 'C:\\\\Users\\\\Default', 'AppData', 'Local');
|
|
40
|
+
}
|
|
41
|
+
if (p === 'darwin') {
|
|
42
|
+
return join(home || '', 'Library', 'Caches');
|
|
43
|
+
}
|
|
44
|
+
const xdg = String(e.XDG_CACHE_HOME ?? '').trim();
|
|
45
|
+
if (xdg) return xdg;
|
|
46
|
+
return join(home || '', '.cache');
|
|
47
|
+
}
|
|
48
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { resolveServerRunnerTarget, resolveRunnerCacheRoot } from './target.mjs';
|
|
6
|
+
|
|
7
|
+
test('resolveServerRunnerTarget maps linux platforms to linux assets', () => {
|
|
8
|
+
assert.deepEqual(resolveServerRunnerTarget({ platform: 'linux', arch: 'x64' }), {
|
|
9
|
+
os: 'linux',
|
|
10
|
+
arch: 'x64',
|
|
11
|
+
exeName: 'happier-server',
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('resolveServerRunnerTarget maps darwin platforms to darwin assets', () => {
|
|
16
|
+
assert.deepEqual(resolveServerRunnerTarget({ platform: 'darwin', arch: 'arm64' }), {
|
|
17
|
+
os: 'darwin',
|
|
18
|
+
arch: 'arm64',
|
|
19
|
+
exeName: 'happier-server',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('resolveServerRunnerTarget maps win32 platforms to windows assets', () => {
|
|
24
|
+
assert.deepEqual(resolveServerRunnerTarget({ platform: 'win32', arch: 'x64' }), {
|
|
25
|
+
os: 'windows',
|
|
26
|
+
arch: 'x64',
|
|
27
|
+
exeName: 'happier-server.exe',
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('resolveServerRunnerTarget rejects unsupported platform/arch combinations', () => {
|
|
32
|
+
assert.throws(() => resolveServerRunnerTarget({ platform: 'freebsd', arch: 'x64' }), /Unsupported platform/i);
|
|
33
|
+
assert.throws(() => resolveServerRunnerTarget({ platform: 'win32', arch: 'arm64' }), /Unsupported architecture/i);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('resolveRunnerCacheRoot uses platform-specific defaults', () => {
|
|
37
|
+
assert.equal(
|
|
38
|
+
resolveRunnerCacheRoot({ platform: 'linux', homedir: '/home/me', env: {} }),
|
|
39
|
+
join('/home/me', '.cache')
|
|
40
|
+
);
|
|
41
|
+
assert.equal(
|
|
42
|
+
resolveRunnerCacheRoot({ platform: 'linux', homedir: '/home/me', env: { XDG_CACHE_HOME: '/tmp/xdg' } }),
|
|
43
|
+
'/tmp/xdg'
|
|
44
|
+
);
|
|
45
|
+
assert.equal(
|
|
46
|
+
resolveRunnerCacheRoot({ platform: 'darwin', homedir: '/Users/me', env: {} }),
|
|
47
|
+
join('/Users/me', 'Library', 'Caches')
|
|
48
|
+
);
|
|
49
|
+
assert.equal(
|
|
50
|
+
resolveRunnerCacheRoot({ platform: 'win32', homedir: 'C:\\\\Users\\\\me', env: { LOCALAPPDATA: 'C:\\\\Local' } }),
|
|
51
|
+
'C:\\\\Local'
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|