favo-cli 0.0.0 → 0.1.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/README.md +19 -2
- package/SHA256SUMS +5 -0
- package/THIRD-PARTY-NOTICES.md +1646 -0
- package/bin/favo.mjs +217 -0
- package/package.json +35 -5
package/bin/favo.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import zlib from 'node:zlib';
|
|
9
|
+
|
|
10
|
+
const PackageRoot = path.dirname(import.meta.dirname);
|
|
11
|
+
const { version } = JSON.parse(fs.readFileSync(path.join(PackageRoot, 'package.json'), 'utf8'));
|
|
12
|
+
|
|
13
|
+
const DefaultReleaseBaseUrl = 'https://github.com/irwinarruda/favo-cli/releases/download';
|
|
14
|
+
const Assets = {
|
|
15
|
+
'darwin-arm64': 'favo-darwin-arm64.gz',
|
|
16
|
+
'darwin-x64': 'favo-darwin-x64.gz',
|
|
17
|
+
'linux-arm64': 'favo-linux-arm64.gz',
|
|
18
|
+
'linux-x64': 'favo-linux-x64.gz',
|
|
19
|
+
'win32-x64': 'favo-windows-x64.exe.gz',
|
|
20
|
+
};
|
|
21
|
+
const ChecksumLine = /^([0-9a-f]{64})\s+\*?(.+)$/i;
|
|
22
|
+
const VersionPrefix = /^\d+\.\d+\.\d+/;
|
|
23
|
+
const CompletionMarker = '.install-complete';
|
|
24
|
+
const BinaryName = process.platform === 'win32' ? 'favo.exe' : 'favo';
|
|
25
|
+
const ForwardedSignals = process.platform === 'win32' ? ['SIGTERM'] : ['SIGTERM', 'SIGHUP'];
|
|
26
|
+
|
|
27
|
+
function setting(name) {
|
|
28
|
+
const value = (process.env[name] ?? '').trim();
|
|
29
|
+
return value === '' ? undefined : value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function favoHome() {
|
|
33
|
+
const configured = setting('FAVO_HOME');
|
|
34
|
+
return configured ? path.resolve(configured) : path.join(process.env.HOME ?? os.homedir(), '.favo');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function get(url) {
|
|
38
|
+
const response = await fetch(url).catch((error) => {
|
|
39
|
+
throw new Error(`could not reach ${url}: ${error.cause?.message ?? error.message}`, { cause: error });
|
|
40
|
+
});
|
|
41
|
+
if (response.status === 404) {
|
|
42
|
+
throw new Error(`${url} does not exist yet. If ${version} was just released, try again in a few minutes.`);
|
|
43
|
+
}
|
|
44
|
+
if (!response.ok) throw new Error(`GET ${url} returned HTTP ${response.status}`);
|
|
45
|
+
return response;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function download(url, file) {
|
|
49
|
+
const response = await get(url);
|
|
50
|
+
const total = Number(response.headers.get('content-length')) || 0;
|
|
51
|
+
const hash = crypto.createHash('sha256');
|
|
52
|
+
const out = fs.openSync(file, 'w');
|
|
53
|
+
const isTty = process.stderr.isTTY;
|
|
54
|
+
let received = 0;
|
|
55
|
+
let shown = -1;
|
|
56
|
+
try {
|
|
57
|
+
for await (const chunk of response.body) {
|
|
58
|
+
hash.update(chunk);
|
|
59
|
+
fs.writeFileSync(out, chunk);
|
|
60
|
+
received += chunk.byteLength;
|
|
61
|
+
const step = total ? Math.floor((received * 100) / total) : Math.floor(received / 1048576);
|
|
62
|
+
if (isTty && step !== shown) {
|
|
63
|
+
shown = step;
|
|
64
|
+
const percent = total ? ` ${step}%` : '';
|
|
65
|
+
process.stderr.write(
|
|
66
|
+
`\r\x1b[2Kfavo: downloading favo ${version}${percent} (${(received / 1048576).toFixed(1)} MB)`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
fs.closeSync(out);
|
|
72
|
+
if (isTty) process.stderr.write('\r\x1b[2K');
|
|
73
|
+
}
|
|
74
|
+
return hash.digest('hex');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readBundledSums() {
|
|
78
|
+
try {
|
|
79
|
+
return fs.readFileSync(path.join(PackageRoot, 'SHA256SUMS'), 'utf8');
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.code === 'ENOENT') return undefined;
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function install(directory, asset) {
|
|
87
|
+
const base = (setting('FAVO_RELEASE_BASE_URL') ?? DefaultReleaseBaseUrl).replace(/\/+$/, '');
|
|
88
|
+
const bundledSums = readBundledSums();
|
|
89
|
+
const sums = bundledSums ?? (await (await get(`${base}/v${version}/SHA256SUMS`)).text());
|
|
90
|
+
const line = sums
|
|
91
|
+
.split(/\r?\n/)
|
|
92
|
+
.map((candidate) => ChecksumLine.exec(candidate.trim()))
|
|
93
|
+
.find((match) => match?.[2] === asset);
|
|
94
|
+
const source = bundledSums === undefined ? "the release's" : "this package's";
|
|
95
|
+
if (!line) throw new Error(`${asset} is not listed in ${source} SHA256SUMS`);
|
|
96
|
+
|
|
97
|
+
fs.mkdirSync(path.dirname(directory), { recursive: true });
|
|
98
|
+
const staging = fs.mkdtempSync(`${directory}.staging-`);
|
|
99
|
+
try {
|
|
100
|
+
const archive = path.join(staging, asset);
|
|
101
|
+
if ((await download(`${base}/v${version}/${asset}`, archive)) !== line[1].toLowerCase()) {
|
|
102
|
+
throw new Error(`checksum mismatch for ${asset}`);
|
|
103
|
+
}
|
|
104
|
+
await pipeline(
|
|
105
|
+
fs.createReadStream(archive),
|
|
106
|
+
zlib.createGunzip(),
|
|
107
|
+
fs.createWriteStream(path.join(staging, BinaryName), { mode: 0o755 })
|
|
108
|
+
);
|
|
109
|
+
fs.rmSync(archive);
|
|
110
|
+
fs.writeFileSync(path.join(staging, CompletionMarker), `${version}\n`);
|
|
111
|
+
publish(staging, directory);
|
|
112
|
+
} finally {
|
|
113
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function assertOwnFolder(directory) {
|
|
118
|
+
if (!process.getuid) return;
|
|
119
|
+
const entry = fs.lstatSync(directory, { throwIfNoEntry: false });
|
|
120
|
+
if (!entry) return;
|
|
121
|
+
if (!entry.isSymbolicLink() && entry.uid === process.getuid()) return;
|
|
122
|
+
const problem = entry.isSymbolicLink() ? 'is a symlink' : 'belongs to another user';
|
|
123
|
+
throw new Error(
|
|
124
|
+
`${directory} ${problem}, so favo won't run what's in it. Delete it, or set FAVO_HOME to a folder only you can write to.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isComplete(directory) {
|
|
129
|
+
assertOwnFolder(directory);
|
|
130
|
+
try {
|
|
131
|
+
const marker = fs.readFileSync(path.join(directory, CompletionMarker), 'utf8').trim();
|
|
132
|
+
return marker === version && fs.existsSync(path.join(directory, BinaryName));
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function publish(staging, directory) {
|
|
139
|
+
try {
|
|
140
|
+
fs.renameSync(staging, directory);
|
|
141
|
+
return;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (isComplete(directory)) return;
|
|
144
|
+
if (!fs.existsSync(directory)) throw error;
|
|
145
|
+
}
|
|
146
|
+
const lock = `${directory}.lock`;
|
|
147
|
+
try {
|
|
148
|
+
fs.mkdirSync(lock);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error.code !== 'EEXIST') throw error;
|
|
151
|
+
throw new Error(`another favo is installing ${version}. If none is running, delete ${lock} and try again.`, {
|
|
152
|
+
cause: error,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const stale = `${staging}.stale`;
|
|
156
|
+
try {
|
|
157
|
+
if (isComplete(directory)) return;
|
|
158
|
+
fs.renameSync(directory, stale);
|
|
159
|
+
try {
|
|
160
|
+
fs.renameSync(staging, directory);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (!isComplete(directory)) throw error;
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
166
|
+
fs.rmSync(stale, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isOlderThanThisVersion(name) {
|
|
171
|
+
const candidate = VersionPrefix.exec(name)?.[0];
|
|
172
|
+
return candidate !== undefined && candidate.localeCompare(version, undefined, { numeric: true }) < 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function removeOlderVersions(runtime) {
|
|
176
|
+
for (const name of fs.readdirSync(runtime).filter(isOlderThanThisVersion)) {
|
|
177
|
+
try {
|
|
178
|
+
fs.rmSync(path.join(runtime, name), { recursive: true, force: true });
|
|
179
|
+
} catch {}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function assertGlibc() {
|
|
184
|
+
if (process.platform !== 'linux') return;
|
|
185
|
+
if (process.report.getReport().header.glibcVersionRuntime) return;
|
|
186
|
+
throw new Error("Alpine/musl isn't supported yet: favo's Linux builds need glibc.");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function run(binary) {
|
|
190
|
+
const child = spawn(binary, process.argv.slice(2), { stdio: 'inherit' });
|
|
191
|
+
process.on('SIGINT', () => {});
|
|
192
|
+
for (const signal of ForwardedSignals) process.on(signal, () => child.kill(signal));
|
|
193
|
+
child.on('error', fail);
|
|
194
|
+
child.on('exit', (code, signal) => process.exit(code ?? 128 + (os.constants.signals[signal] ?? 1)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function main() {
|
|
198
|
+
const key = `${process.platform}-${process.arch}`;
|
|
199
|
+
const asset = Assets[key];
|
|
200
|
+
if (!asset) {
|
|
201
|
+
throw new Error(`no favo build is available for ${key} (supported: ${Object.keys(Assets).join(', ')}).`);
|
|
202
|
+
}
|
|
203
|
+
const directory = path.join(favoHome(), 'runtime', version);
|
|
204
|
+
if (!isComplete(directory)) {
|
|
205
|
+
assertGlibc();
|
|
206
|
+
await install(directory, asset);
|
|
207
|
+
removeOlderVersions(path.dirname(directory));
|
|
208
|
+
}
|
|
209
|
+
run(path.join(directory, BinaryName));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function fail(error) {
|
|
213
|
+
process.stderr.write(`favo: ${error.message}\n`);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await main().catch(fail);
|
package/package.json
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "favo-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "favo keeps several AI subscriptions on every computer you own. Downloads the self-contained executable for this platform from the GitHub Release.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"favo",
|
|
7
|
+
"cli",
|
|
8
|
+
"tui",
|
|
9
|
+
"ai",
|
|
10
|
+
"accounts",
|
|
11
|
+
"claude",
|
|
12
|
+
"codex",
|
|
13
|
+
"opencode"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/irwinarruda/favo-cli#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/irwinarruda/favo-cli/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/irwinarruda/favo-cli.git"
|
|
22
|
+
},
|
|
5
23
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
24
|
+
"bin": {
|
|
25
|
+
"favo": "bin/favo.mjs"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"bin",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"SHA256SUMS",
|
|
31
|
+
"THIRD-PARTY-NOTICES.md"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20.11"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
9
39
|
}
|