@just-every/manager 0.1.3
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 +28 -0
- package/bin/justevery-manager.js +42 -0
- package/lib/installer.js +592 -0
- package/package.json +26 -0
- package/postinstall.js +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @just-every/manager
|
|
2
|
+
|
|
3
|
+
Installer helper for Every Manager.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install -g @just-every/manager
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
justevery-manager
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Use `--download-only` to cache the installer without launching it, and
|
|
18
|
+
`--print-path` to print the cached path.
|
|
19
|
+
|
|
20
|
+
## Environment
|
|
21
|
+
|
|
22
|
+
- `JE_AGENT_VERSION` – override the agent version.
|
|
23
|
+
- `JE_AGENT_RELEASE_TAG` – override the GitHub release tag.
|
|
24
|
+
- `JE_AGENT_RELEASE_BASE_URL` – primary base URL (comma-separated).
|
|
25
|
+
- `JE_AGENT_FALLBACK_BASE_URL` – fallback base URLs (comma-separated).
|
|
26
|
+
- `JE_AGENT_ASSET` – override the installer filename.
|
|
27
|
+
- `JE_AGENT_GITHUB_TOKEN` – optional GitHub token for private releases (also supports `GH_TOKEN` / `GITHUB_TOKEN`).
|
|
28
|
+
- `JE_AGENT_SKIP_DOWNLOAD=1` – skip the postinstall download/caching step.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ensureInstaller, launchInstaller } from '../lib/installer.js';
|
|
3
|
+
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
const wantsHelp = args.includes('--help') || args.includes('-h');
|
|
6
|
+
const downloadOnly = args.includes('--download-only') || args.includes('--no-open');
|
|
7
|
+
const printPath = args.includes('--print-path');
|
|
8
|
+
|
|
9
|
+
if (wantsHelp) {
|
|
10
|
+
console.log(`Every Manager installer helper
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
justevery-manager [--download-only] [--print-path]
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--download-only Download the installer but do not launch it.
|
|
17
|
+
--print-path Print the installer path after download.
|
|
18
|
+
-h, --help Show this help message.
|
|
19
|
+
|
|
20
|
+
Environment:
|
|
21
|
+
JE_AGENT_VERSION Override the agent version.
|
|
22
|
+
JE_AGENT_RELEASE_TAG Override the GitHub release tag.
|
|
23
|
+
JE_AGENT_RELEASE_BASE_URL Override the primary base URL (comma-separated).
|
|
24
|
+
JE_AGENT_FALLBACK_BASE_URL Add fallback base URLs (comma-separated).
|
|
25
|
+
JE_AGENT_ASSET Override the installer filename.
|
|
26
|
+
JE_AGENT_GITHUB_TOKEN Optional GitHub token for private releases.
|
|
27
|
+
`);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const result = await ensureInstaller({ allowDownload: true });
|
|
33
|
+
if (printPath || downloadOnly) {
|
|
34
|
+
console.log(result.path);
|
|
35
|
+
}
|
|
36
|
+
if (!downloadOnly) {
|
|
37
|
+
await launchInstaller(result.path);
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error(`Failed to prepare installer: ${error.message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
package/lib/installer.js
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
createWriteStream,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
statSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
} from 'fs';
|
|
11
|
+
import { dirname, join, resolve } from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
import { arch as osArch, homedir, platform as osPlatform } from 'os';
|
|
14
|
+
import { get } from 'https';
|
|
15
|
+
import { spawn } from 'child_process';
|
|
16
|
+
|
|
17
|
+
const OWNER_REPO = 'just-every/manager';
|
|
18
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const PACKAGE_JSON = resolve(PACKAGE_ROOT, 'package.json');
|
|
20
|
+
const PLACEHOLDER_VERSIONS = new Set(['0.0.0', '0.0.0-dev']);
|
|
21
|
+
|
|
22
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
const GITHUB_HOSTS = new Set([
|
|
25
|
+
'github.com',
|
|
26
|
+
'api.github.com',
|
|
27
|
+
'github-releases.githubusercontent.com',
|
|
28
|
+
'release-assets.githubusercontent.com',
|
|
29
|
+
'objects.githubusercontent.com',
|
|
30
|
+
'raw.githubusercontent.com',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const ARCH_ALIASES_BY_PLATFORM = {
|
|
34
|
+
darwin: {
|
|
35
|
+
arm64: ['aarch64', 'arm64'],
|
|
36
|
+
x64: ['x64', 'x86_64', 'amd64'],
|
|
37
|
+
},
|
|
38
|
+
linux: {
|
|
39
|
+
arm64: ['aarch64', 'arm64'],
|
|
40
|
+
x64: ['amd64', 'x86_64', 'x64'],
|
|
41
|
+
},
|
|
42
|
+
win32: {
|
|
43
|
+
arm64: ['arm64', 'aarch64'],
|
|
44
|
+
x64: ['x64', 'amd64', 'x86_64'],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const EXTENSIONS = {
|
|
49
|
+
darwin: ['.dmg'],
|
|
50
|
+
win32: ['.msi', '.exe'],
|
|
51
|
+
linux: ['.AppImage', '.appimage', '.deb'],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const FILE_PREFIXES = ['Every.Manager', 'JustEvery.Agent'];
|
|
55
|
+
|
|
56
|
+
function readPackageVersion() {
|
|
57
|
+
const raw = readFileSync(PACKAGE_JSON, 'utf8');
|
|
58
|
+
return JSON.parse(raw).version || '0.0.0';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isPlaceholderVersion(version) {
|
|
62
|
+
return PLACEHOLDER_VERSIONS.has(version);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getCacheDir(version) {
|
|
66
|
+
const platform = osPlatform();
|
|
67
|
+
const home = homedir();
|
|
68
|
+
let base;
|
|
69
|
+
if (platform === 'win32') {
|
|
70
|
+
base = process.env.LOCALAPPDATA || join(home, 'AppData', 'Local');
|
|
71
|
+
} else if (platform === 'darwin') {
|
|
72
|
+
base = join(home, 'Library', 'Caches');
|
|
73
|
+
} else {
|
|
74
|
+
base = process.env.XDG_CACHE_HOME || join(home, '.cache');
|
|
75
|
+
}
|
|
76
|
+
const dir = join(base, 'justevery', 'manager', version);
|
|
77
|
+
if (!existsSync(dir)) {
|
|
78
|
+
mkdirSync(dir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
return dir;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function fetchLatestVersion() {
|
|
84
|
+
const url = `https://api.github.com/repos/${OWNER_REPO}/releases/latest`;
|
|
85
|
+
const response = await downloadJson(url);
|
|
86
|
+
const tag = (response.tag_name || '').trim();
|
|
87
|
+
if (!tag) {
|
|
88
|
+
throw new Error('Unable to resolve latest release tag from GitHub.');
|
|
89
|
+
}
|
|
90
|
+
return tag.replace(/^agent-v/, '').replace(/^v/, '');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function downloadJson(url) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const token = getGitHubToken();
|
|
96
|
+
const headers = {
|
|
97
|
+
'User-Agent': 'justevery-manager-wrapper',
|
|
98
|
+
Accept: 'application/vnd.github+json',
|
|
99
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
100
|
+
};
|
|
101
|
+
const req = get(
|
|
102
|
+
url,
|
|
103
|
+
{
|
|
104
|
+
headers,
|
|
105
|
+
},
|
|
106
|
+
(res) => {
|
|
107
|
+
const status = res.statusCode || 0;
|
|
108
|
+
if (status < 200 || status >= 300) {
|
|
109
|
+
reject(Object.assign(new Error(`GitHub API request failed (${status}) for ${url}`), { status }));
|
|
110
|
+
res.resume();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
let body = '';
|
|
114
|
+
res.setEncoding('utf8');
|
|
115
|
+
res.on('data', (chunk) => {
|
|
116
|
+
body += chunk;
|
|
117
|
+
});
|
|
118
|
+
res.on('end', () => {
|
|
119
|
+
try {
|
|
120
|
+
resolve(JSON.parse(body));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
reject(error);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
128
|
+
req.destroy(new Error(`Request timeout (${REQUEST_TIMEOUT_MS}ms) for ${url}`));
|
|
129
|
+
});
|
|
130
|
+
req.on('error', reject);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeBaseUrls(value) {
|
|
135
|
+
if (!value) return [];
|
|
136
|
+
return value
|
|
137
|
+
.split(',')
|
|
138
|
+
.map((entry) => entry.trim())
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.map((entry) => entry.replace(/\/$/, ''));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildBaseUrls(version) {
|
|
144
|
+
const overrides = normalizeBaseUrls(process.env.JE_AGENT_RELEASE_BASE_URL || process.env.JE_AGENT_BASE_URL);
|
|
145
|
+
const tag = process.env.JE_AGENT_RELEASE_TAG || `agent-v${version}`;
|
|
146
|
+
const githubBase = `https://github.com/${OWNER_REPO}/releases/download/${tag}`;
|
|
147
|
+
const fallback = normalizeBaseUrls(process.env.JE_AGENT_FALLBACK_BASE_URL);
|
|
148
|
+
const marketingBase = `https://manager.justevery.com/marketing/agent-release/${tag}`;
|
|
149
|
+
const bases = overrides.length ? overrides : [githubBase];
|
|
150
|
+
for (const extra of [...fallback, marketingBase]) {
|
|
151
|
+
if (!bases.includes(extra)) bases.push(extra);
|
|
152
|
+
}
|
|
153
|
+
return bases;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildCandidateFiles(version) {
|
|
157
|
+
const platform = osPlatform();
|
|
158
|
+
const nodeArch = osArch();
|
|
159
|
+
const archKey = resolveArchKey(nodeArch);
|
|
160
|
+
const archCandidates = (ARCH_ALIASES_BY_PLATFORM[platform] || {})[archKey] || [nodeArch];
|
|
161
|
+
const extensions = EXTENSIONS[platform] || [];
|
|
162
|
+
const extra = (process.env.JE_AGENT_ASSET || '').trim();
|
|
163
|
+
if (extra) {
|
|
164
|
+
return [extra];
|
|
165
|
+
}
|
|
166
|
+
const files = new Set();
|
|
167
|
+
if (platform === 'win32') {
|
|
168
|
+
for (const prefix of FILE_PREFIXES) {
|
|
169
|
+
for (const arch of archCandidates) {
|
|
170
|
+
files.add(`${prefix}_${version}_${arch}_en-US.msi`);
|
|
171
|
+
files.add(`${prefix}_${version}_${arch}.msi`);
|
|
172
|
+
files.add(`${prefix}_${version}_${arch}-setup.exe`);
|
|
173
|
+
files.add(`${prefix}_${version}_${arch}.exe`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return Array.from(files);
|
|
177
|
+
}
|
|
178
|
+
for (const ext of extensions) {
|
|
179
|
+
for (const prefix of FILE_PREFIXES) {
|
|
180
|
+
for (const arch of archCandidates) {
|
|
181
|
+
files.add(`${prefix}_${version}_${arch}${ext}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return Array.from(files);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function fileLooksValid(path) {
|
|
189
|
+
try {
|
|
190
|
+
const stat = statSync(path);
|
|
191
|
+
return stat.isFile() && stat.size > 0;
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function downloadWithRedirects(url, dest, redirects = 5) {
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
const req = get(url, { headers: { 'User-Agent': 'justevery-manager-wrapper' } }, (res) => {
|
|
200
|
+
const status = res.statusCode || 0;
|
|
201
|
+
if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
|
|
202
|
+
if (redirects <= 0) {
|
|
203
|
+
reject(new Error(`Too many redirects for ${url}`));
|
|
204
|
+
res.resume();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const next = res.headers.location.startsWith('http')
|
|
208
|
+
? res.headers.location
|
|
209
|
+
: new URL(res.headers.location, url).toString();
|
|
210
|
+
res.resume();
|
|
211
|
+
downloadWithRedirects(next, dest, redirects - 1).then(resolve).catch(reject);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (status !== 200) {
|
|
216
|
+
reject(Object.assign(new Error(`Download failed (${status})`), { status }));
|
|
217
|
+
res.resume();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const contentType = String(res.headers['content-type'] || '').toLowerCase();
|
|
222
|
+
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
|
223
|
+
reject(
|
|
224
|
+
Object.assign(new Error('Download returned HTML instead of an installer.'), {
|
|
225
|
+
status: 200,
|
|
226
|
+
code: 'unexpected_html',
|
|
227
|
+
url,
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
res.resume();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const expectedLength = Number(res.headers['content-length'] || 0);
|
|
235
|
+
const tmp = `${dest}.tmp-${Math.random().toString(36).slice(2, 8)}`;
|
|
236
|
+
const file = createWriteStream(tmp);
|
|
237
|
+
|
|
238
|
+
res.pipe(file);
|
|
239
|
+
|
|
240
|
+
const cleanup = () => {
|
|
241
|
+
try {
|
|
242
|
+
unlinkSync(tmp);
|
|
243
|
+
} catch {}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
res.on('aborted', () => {
|
|
247
|
+
cleanup();
|
|
248
|
+
reject(new Error('Download was aborted.'));
|
|
249
|
+
});
|
|
250
|
+
res.on('error', (err) => {
|
|
251
|
+
cleanup();
|
|
252
|
+
reject(err);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
file.on('error', (err) => {
|
|
256
|
+
cleanup();
|
|
257
|
+
reject(err);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
file.on('finish', () => {
|
|
261
|
+
file.close((closeErr) => {
|
|
262
|
+
if (closeErr) {
|
|
263
|
+
cleanup();
|
|
264
|
+
reject(closeErr);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (expectedLength > 0) {
|
|
269
|
+
try {
|
|
270
|
+
const stat = statSync(tmp);
|
|
271
|
+
if (stat.size !== expectedLength) {
|
|
272
|
+
cleanup();
|
|
273
|
+
reject(new Error(`Download incomplete (${stat.size}/${expectedLength} bytes).`));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
} catch (err) {
|
|
277
|
+
cleanup();
|
|
278
|
+
reject(err);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
resolve(tmp);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
289
|
+
req.destroy(new Error(`Request timeout (${REQUEST_TIMEOUT_MS}ms) for ${url}`));
|
|
290
|
+
});
|
|
291
|
+
req.on('error', reject);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function downloadInstaller(baseUrl, fileName, dest) {
|
|
296
|
+
const url = `${baseUrl}/${fileName}`;
|
|
297
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
298
|
+
const tmp = await downloadWithRedirects(url, dest);
|
|
299
|
+
if (!fileLooksValid(tmp)) {
|
|
300
|
+
throw new Error('Downloaded file is empty.');
|
|
301
|
+
}
|
|
302
|
+
if (existsSync(dest)) {
|
|
303
|
+
try {
|
|
304
|
+
unlinkSync(dest);
|
|
305
|
+
} catch {}
|
|
306
|
+
}
|
|
307
|
+
renameSync(tmp, dest);
|
|
308
|
+
return dest;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function ensureExecutable(path) {
|
|
312
|
+
if (osPlatform() !== 'linux') return;
|
|
313
|
+
if (!path.endsWith('.AppImage')) return;
|
|
314
|
+
try {
|
|
315
|
+
chmodSync(path, 0o755);
|
|
316
|
+
} catch {}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function resolveAgentVersion() {
|
|
320
|
+
if (process.env.JE_AGENT_VERSION) {
|
|
321
|
+
return process.env.JE_AGENT_VERSION.trim();
|
|
322
|
+
}
|
|
323
|
+
const version = readPackageVersion();
|
|
324
|
+
if (!isPlaceholderVersion(version)) {
|
|
325
|
+
return version;
|
|
326
|
+
}
|
|
327
|
+
return await fetchLatestVersion();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function ensureInstaller({ allowDownload = true } = {}) {
|
|
331
|
+
const version = await resolveAgentVersion();
|
|
332
|
+
const baseUrls = buildBaseUrls(version);
|
|
333
|
+
const candidates = buildCandidateFiles(version);
|
|
334
|
+
const cacheDir = getCacheDir(version);
|
|
335
|
+
let sawGitHubHtml = false;
|
|
336
|
+
|
|
337
|
+
for (const candidate of candidates) {
|
|
338
|
+
const dest = join(cacheDir, candidate);
|
|
339
|
+
if (fileLooksValid(dest)) {
|
|
340
|
+
ensureExecutable(dest);
|
|
341
|
+
return { path: dest, version, fileName: candidate, cached: true };
|
|
342
|
+
}
|
|
343
|
+
if (!allowDownload) continue;
|
|
344
|
+
for (const baseUrl of baseUrls) {
|
|
345
|
+
try {
|
|
346
|
+
await downloadInstallerWithAuth(baseUrl, version, candidate, dest);
|
|
347
|
+
ensureExecutable(dest);
|
|
348
|
+
return { path: dest, version, fileName: candidate, cached: false };
|
|
349
|
+
} catch (err) {
|
|
350
|
+
if (err && err.code === 'unexpected_html' && isGitHubDownloadBase(baseUrl)) {
|
|
351
|
+
sawGitHubHtml = true;
|
|
352
|
+
}
|
|
353
|
+
if (err && err.status && err.status === 404) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const authHint = sawGitHubHtml
|
|
361
|
+
? ' If the release is private, set JE_AGENT_GITHUB_TOKEN (or GH_TOKEN) so the wrapper can download release assets via the GitHub API.'
|
|
362
|
+
: '';
|
|
363
|
+
|
|
364
|
+
throw new Error(`Unable to locate a compatible installer for ${osPlatform()} ${osArch()}.${authHint}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function launchInstaller(installerPath) {
|
|
368
|
+
const platform = osPlatform();
|
|
369
|
+
if (platform === 'darwin') {
|
|
370
|
+
return runCommand('open', [installerPath]);
|
|
371
|
+
}
|
|
372
|
+
if (platform === 'win32') {
|
|
373
|
+
return runCommand('cmd', ['/c', 'start', '""', installerPath]);
|
|
374
|
+
}
|
|
375
|
+
if (installerPath.endsWith('.AppImage')) {
|
|
376
|
+
return runCommand(installerPath, []);
|
|
377
|
+
}
|
|
378
|
+
return runCommand('xdg-open', [installerPath]);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function runCommand(command, args) {
|
|
382
|
+
const child = spawn(command, args, { stdio: 'inherit', shell: false });
|
|
383
|
+
return new Promise((resolve, reject) => {
|
|
384
|
+
child.on('exit', (code) => {
|
|
385
|
+
if (code === 0) resolve();
|
|
386
|
+
else reject(new Error(`${command} exited with ${code}`));
|
|
387
|
+
});
|
|
388
|
+
child.on('error', reject);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function getGitHubToken() {
|
|
393
|
+
return (
|
|
394
|
+
process.env.JE_AGENT_GITHUB_TOKEN ||
|
|
395
|
+
process.env.GH_TOKEN ||
|
|
396
|
+
process.env.GITHUB_TOKEN ||
|
|
397
|
+
process.env.GITHUB_PAT ||
|
|
398
|
+
''
|
|
399
|
+
).trim();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function resolveArchKey(nodeArch) {
|
|
403
|
+
if (nodeArch === 'x64' || nodeArch === 'arm64') return nodeArch;
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Unsupported architecture ${nodeArch}. Supported architectures are x64 and arm64. ` +
|
|
406
|
+
'Set JE_AGENT_ASSET to override the installer file name if you have a custom build.',
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function isGitHubDownloadBase(baseUrl) {
|
|
411
|
+
return baseUrl.startsWith(`https://github.com/${OWNER_REPO}/releases/download/`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function releaseTagFromGitHubBase(baseUrl) {
|
|
415
|
+
try {
|
|
416
|
+
const parsed = new URL(baseUrl);
|
|
417
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
418
|
+
const downloadIndex = parts.findIndex((part) => part === 'download');
|
|
419
|
+
if (downloadIndex === -1) return '';
|
|
420
|
+
return parts[downloadIndex + 1] || '';
|
|
421
|
+
} catch {
|
|
422
|
+
return '';
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function downloadInstallerWithAuth(baseUrl, version, fileName, dest) {
|
|
427
|
+
const token = getGitHubToken();
|
|
428
|
+
|
|
429
|
+
if (token && isGitHubDownloadBase(baseUrl)) {
|
|
430
|
+
const tag = releaseTagFromGitHubBase(baseUrl) || `agent-v${version}`;
|
|
431
|
+
try {
|
|
432
|
+
return await downloadGitHubReleaseAsset({ token, tag, fileName, dest });
|
|
433
|
+
} catch (err) {
|
|
434
|
+
if (err && err.status === 404) {
|
|
435
|
+
// Asset not present in the release; fall back to direct URL attempts.
|
|
436
|
+
} else {
|
|
437
|
+
// If the API download fails for non-404 reasons, still try other base URLs.
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return await downloadInstaller(baseUrl, fileName, dest);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function downloadGitHubReleaseAsset({ token, tag, fileName, dest }) {
|
|
446
|
+
const release = await downloadJson(`https://api.github.com/repos/${OWNER_REPO}/releases/tags/${tag}`);
|
|
447
|
+
const assets = Array.isArray(release.assets) ? release.assets : [];
|
|
448
|
+
const asset = assets.find((entry) => entry && entry.name === fileName);
|
|
449
|
+
if (!asset) {
|
|
450
|
+
throw Object.assign(new Error(`Release asset not found: ${fileName}`), { status: 404 });
|
|
451
|
+
}
|
|
452
|
+
if (!asset.url) {
|
|
453
|
+
throw new Error(`Release asset is missing API url: ${fileName}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const tmp = await downloadWithRedirectsAuthenticated(asset.url, dest, token);
|
|
457
|
+
if (!fileLooksValid(tmp)) {
|
|
458
|
+
throw new Error('Downloaded file is empty.');
|
|
459
|
+
}
|
|
460
|
+
if (existsSync(dest)) {
|
|
461
|
+
try {
|
|
462
|
+
unlinkSync(dest);
|
|
463
|
+
} catch {}
|
|
464
|
+
}
|
|
465
|
+
renameSync(tmp, dest);
|
|
466
|
+
return dest;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function downloadWithRedirectsAuthenticated(url, dest, token, redirects = 5) {
|
|
470
|
+
return new Promise((resolve, reject) => {
|
|
471
|
+
const headers = {
|
|
472
|
+
'User-Agent': 'justevery-manager-wrapper',
|
|
473
|
+
Accept: 'application/octet-stream',
|
|
474
|
+
Authorization: `Bearer ${token}`,
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const req = get(url, { headers }, (res) => {
|
|
478
|
+
const status = res.statusCode || 0;
|
|
479
|
+
|
|
480
|
+
if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
|
|
481
|
+
if (redirects <= 0) {
|
|
482
|
+
reject(new Error(`Too many redirects for ${url}`));
|
|
483
|
+
res.resume();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const next = res.headers.location.startsWith('http')
|
|
488
|
+
? res.headers.location
|
|
489
|
+
: new URL(res.headers.location, url).toString();
|
|
490
|
+
|
|
491
|
+
const nextHost = safeHost(next);
|
|
492
|
+
const currentHost = safeHost(url);
|
|
493
|
+
|
|
494
|
+
// Never forward Authorization headers to non-GitHub hosts.
|
|
495
|
+
if (nextHost && currentHost && nextHost !== currentHost && !GITHUB_HOSTS.has(nextHost)) {
|
|
496
|
+
res.resume();
|
|
497
|
+
downloadWithRedirects(next, dest, redirects - 1).then(resolve).catch(reject);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
res.resume();
|
|
502
|
+
downloadWithRedirectsAuthenticated(next, dest, token, redirects - 1).then(resolve).catch(reject);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (status !== 200) {
|
|
507
|
+
reject(Object.assign(new Error(`Download failed (${status})`), { status }));
|
|
508
|
+
res.resume();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const contentType = String(res.headers['content-type'] || '').toLowerCase();
|
|
513
|
+
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
|
514
|
+
reject(
|
|
515
|
+
Object.assign(new Error('Download returned HTML instead of an installer.'), {
|
|
516
|
+
status: 200,
|
|
517
|
+
code: 'unexpected_html',
|
|
518
|
+
url,
|
|
519
|
+
}),
|
|
520
|
+
);
|
|
521
|
+
res.resume();
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const expectedLength = Number(res.headers['content-length'] || 0);
|
|
526
|
+
const tmp = `${dest}.tmp-${Math.random().toString(36).slice(2, 8)}`;
|
|
527
|
+
const file = createWriteStream(tmp);
|
|
528
|
+
|
|
529
|
+
res.pipe(file);
|
|
530
|
+
|
|
531
|
+
const cleanup = () => {
|
|
532
|
+
try {
|
|
533
|
+
unlinkSync(tmp);
|
|
534
|
+
} catch {}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
res.on('aborted', () => {
|
|
538
|
+
cleanup();
|
|
539
|
+
reject(new Error('Download was aborted.'));
|
|
540
|
+
});
|
|
541
|
+
res.on('error', (err) => {
|
|
542
|
+
cleanup();
|
|
543
|
+
reject(err);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
file.on('error', (err) => {
|
|
547
|
+
cleanup();
|
|
548
|
+
reject(err);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
file.on('finish', () => {
|
|
552
|
+
file.close((closeErr) => {
|
|
553
|
+
if (closeErr) {
|
|
554
|
+
cleanup();
|
|
555
|
+
reject(closeErr);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (expectedLength > 0) {
|
|
560
|
+
try {
|
|
561
|
+
const stat = statSync(tmp);
|
|
562
|
+
if (stat.size !== expectedLength) {
|
|
563
|
+
cleanup();
|
|
564
|
+
reject(new Error(`Download incomplete (${stat.size}/${expectedLength} bytes).`));
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
} catch (err) {
|
|
568
|
+
cleanup();
|
|
569
|
+
reject(err);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
resolve(tmp);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => {
|
|
580
|
+
req.destroy(new Error(`Request timeout (${REQUEST_TIMEOUT_MS}ms) for ${url}`));
|
|
581
|
+
});
|
|
582
|
+
req.on('error', reject);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function safeHost(url) {
|
|
587
|
+
try {
|
|
588
|
+
return new URL(url).hostname;
|
|
589
|
+
} catch {
|
|
590
|
+
return '';
|
|
591
|
+
}
|
|
592
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@just-every/manager",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Installer wrapper for Every Manager",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"justevery-manager": "bin/justevery-manager.js",
|
|
9
|
+
"justevery-agent": "bin/justevery-manager.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin/justevery-manager.js",
|
|
13
|
+
"lib/installer.js",
|
|
14
|
+
"postinstall.js",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"postinstall": "node postinstall.js"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/postinstall.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ensureInstaller } from './lib/installer.js';
|
|
3
|
+
|
|
4
|
+
const skip = process.env.JE_AGENT_SKIP_DOWNLOAD === '1';
|
|
5
|
+
|
|
6
|
+
if (skip) {
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const result = await ensureInstaller({ allowDownload: true });
|
|
12
|
+
console.log(`Every Manager installer cached at ${result.path}`);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.warn(`Every Manager installer download skipped: ${error.message}`);
|
|
15
|
+
}
|