toyon 0.0.0 → 0.2.0
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 +116 -2
- package/bin/toyon.js +46 -0
- package/cloud/Dockerfile +32 -0
- package/cloud/entrypoint.sh +66 -0
- package/dist/bridge.js +1 -0
- package/dist/cli.js +1466 -0
- package/dist/daemon.js +25838 -0
- package/dist/shell/assets/Editor-CB2SckMT.css +1 -0
- package/dist/shell/assets/Editor-D-PlbMSY.js +928 -0
- package/dist/shell/assets/XTerm-6GBZ9nXN.css +32 -0
- package/dist/shell/assets/XTerm-CM_FJEwN.js +40 -0
- package/dist/shell/assets/abap-DLDM7-KI.js +1 -0
- package/dist/shell/assets/apex-DNDY2TF8.js +1 -0
- package/dist/shell/assets/azcli-Y6nb8tq_.js +1 -0
- package/dist/shell/assets/bat-BwHxbl9M.js +1 -0
- package/dist/shell/assets/bicep-CFznDFnq.js +2 -0
- package/dist/shell/assets/cameligo-Bf6VGUru.js +1 -0
- package/dist/shell/assets/clojure-Dnu-v4kV.js +1 -0
- package/dist/shell/assets/codicon-Brq4_Ui5.ttf +0 -0
- package/dist/shell/assets/coffee-Bd8akH9Z.js +1 -0
- package/dist/shell/assets/cpp-BbWJElDN.js +1 -0
- package/dist/shell/assets/csharp-Co3qMtFm.js +1 -0
- package/dist/shell/assets/csp-D-4FJmMZ.js +1 -0
- package/dist/shell/assets/css-DdJfP1eB.js +3 -0
- package/dist/shell/assets/css.worker-CqwWTjeG.js +97 -0
- package/dist/shell/assets/cssMode-Bkqyr9Lr.js +1 -0
- package/dist/shell/assets/cypher-cTPe9QuQ.js +1 -0
- package/dist/shell/assets/dart-BOtBlQCF.js +1 -0
- package/dist/shell/assets/dockerfile-BG73LgW2.js +1 -0
- package/dist/shell/assets/ecl-BEgZUVRK.js +1 -0
- package/dist/shell/assets/editor.worker-DCKwvLbM.js +30 -0
- package/dist/shell/assets/elixir-BkW5O-1t.js +1 -0
- package/dist/shell/assets/flow9-BeJ5waoc.js +1 -0
- package/dist/shell/assets/freemarker2-DVAK5zoJ.js +3 -0
- package/dist/shell/assets/fsharp-PahG7c26.js +1 -0
- package/dist/shell/assets/go-acbASCJo.js +1 -0
- package/dist/shell/assets/graphql-BxJiqAUM.js +1 -0
- package/dist/shell/assets/handlebars-Bdtj7uMl.js +1 -0
- package/dist/shell/assets/hcl-DtV1sZF8.js +1 -0
- package/dist/shell/assets/html-B1pqTvyA.js +1 -0
- package/dist/shell/assets/html.worker-DhJ4VZMp.js +474 -0
- package/dist/shell/assets/htmlMode-CBtvzewS.js +1 -0
- package/dist/shell/assets/index-GBQLHG7z.css +1 -0
- package/dist/shell/assets/index-jy_zeFGs.js +230 -0
- package/dist/shell/assets/ini-Kd9XrMLS.js +1 -0
- package/dist/shell/assets/java-CXBNlu9o.js +1 -0
- package/dist/shell/assets/javascript-FNavUzsm.js +1 -0
- package/dist/shell/assets/json.worker-BF9eV5mS.js +62 -0
- package/dist/shell/assets/jsonMode-HgW1GXQH.js +7 -0
- package/dist/shell/assets/julia-cl7-CwDS.js +1 -0
- package/dist/shell/assets/kotlin-s7OhZKlX.js +1 -0
- package/dist/shell/assets/less-9HpZscsL.js +2 -0
- package/dist/shell/assets/lexon-OrD6JF1K.js +1 -0
- package/dist/shell/assets/liquid-6wkVlojW.js +1 -0
- package/dist/shell/assets/lspLanguageFeatures-CvNG6b_1.js +4 -0
- package/dist/shell/assets/lua-Cyyb5UIc.js +1 -0
- package/dist/shell/assets/m3-B8OfTtLu.js +1 -0
- package/dist/shell/assets/markdown-BFxVWTOG.js +1 -0
- package/dist/shell/assets/mdx-C-byKky3.js +1 -0
- package/dist/shell/assets/mips-CiqrrVzr.js +1 -0
- package/dist/shell/assets/msdax-DmeGPVcC.js +1 -0
- package/dist/shell/assets/mysql-C_tMU-Nz.js +1 -0
- package/dist/shell/assets/objective-c-BDtDVThU.js +1 -0
- package/dist/shell/assets/pascal-vHIfCaH5.js +1 -0
- package/dist/shell/assets/pascaligo-DtZ0uQbO.js +1 -0
- package/dist/shell/assets/perl-Ub6l9XKa.js +1 -0
- package/dist/shell/assets/pgsql-BlNEE0v7.js +1 -0
- package/dist/shell/assets/php-BBUBE1dy.js +1 -0
- package/dist/shell/assets/pla-DSh2-awV.js +1 -0
- package/dist/shell/assets/postiats-CocnycG-.js +1 -0
- package/dist/shell/assets/powerquery-tScXyioY.js +1 -0
- package/dist/shell/assets/powershell-COWaemsV.js +1 -0
- package/dist/shell/assets/protobuf-Brw8urJB.js +2 -0
- package/dist/shell/assets/pug-8SOpv6rk.js +1 -0
- package/dist/shell/assets/python-BEJMbP4i.js +1 -0
- package/dist/shell/assets/qsharp-Bw9ernYp.js +1 -0
- package/dist/shell/assets/r-j7ic8hl3.js +1 -0
- package/dist/shell/assets/razor-DeXWW25R.js +1 -0
- package/dist/shell/assets/redis-Bu5POkcn.js +1 -0
- package/dist/shell/assets/redshift-Bs9aos_-.js +1 -0
- package/dist/shell/assets/restructuredtext-CqXO7rUv.js +1 -0
- package/dist/shell/assets/ruby-zBfavPgS.js +1 -0
- package/dist/shell/assets/rust-BzKRNQWT.js +1 -0
- package/dist/shell/assets/sb-BBc9UKZt.js +1 -0
- package/dist/shell/assets/scala-D9hQfWCl.js +1 -0
- package/dist/shell/assets/scheme-BPhDTwHR.js +1 -0
- package/dist/shell/assets/scss-CBJaRo0y.js +3 -0
- package/dist/shell/assets/shell-DiJ1NA_G.js +1 -0
- package/dist/shell/assets/solidity-Db0IVjzk.js +1 -0
- package/dist/shell/assets/sophia-CnS9iZB_.js +1 -0
- package/dist/shell/assets/sparql-CJmd_6j2.js +1 -0
- package/dist/shell/assets/sql-ClhHkBeG.js +1 -0
- package/dist/shell/assets/st-CHwy0fLd.js +1 -0
- package/dist/shell/assets/swift-Bqt4WxQ4.js +3 -0
- package/dist/shell/assets/systemverilog-Bs9z6M-B.js +1 -0
- package/dist/shell/assets/tcl-Dm6ycUr_.js +1 -0
- package/dist/shell/assets/ts.worker-DTZAwq0V.js +67735 -0
- package/dist/shell/assets/tsMode-DMwJnvD-.js +11 -0
- package/dist/shell/assets/twig-Csy3S7wG.js +1 -0
- package/dist/shell/assets/typescript-Cj57ccSp.js +1 -0
- package/dist/shell/assets/typespec-Btyra-wh.js +1 -0
- package/dist/shell/assets/vb-Db0cS2oM.js +1 -0
- package/dist/shell/assets/wgsl-BTesnYfV.js +298 -0
- package/dist/shell/assets/workers-BZi9jqBp.js +1 -0
- package/dist/shell/assets/xml-DUDeQI0I.js +1 -0
- package/dist/shell/assets/yaml-BCD8Shg5.js +1 -0
- package/dist/shell/icon.svg +3 -0
- package/dist/shell/index.html +34 -0
- package/dist/shell/manifest.json +21 -0
- package/dist/shell/sw.js +4 -0
- package/package.json +42 -12
- package/LICENSE-APACHE +0 -201
- package/index.js +0 -2
- /package/{LICENSE-MIT → LICENSE} +0 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
import { existsSync as existsSync10 } from "fs";
|
|
6
|
+
import { join as join7, resolve } from "path";
|
|
7
|
+
// package.json
|
|
8
|
+
var package_default = {
|
|
9
|
+
name: "toyon",
|
|
10
|
+
version: "0.2.0",
|
|
11
|
+
description: "Checking an agent's work means running it. Toyon gives every chat its own copy of your web app, with a live preview.",
|
|
12
|
+
license: "MIT",
|
|
13
|
+
type: "module",
|
|
14
|
+
homepage: "https://toyon.dev",
|
|
15
|
+
repository: {
|
|
16
|
+
type: "git",
|
|
17
|
+
url: "git+https://github.com/toyon-dev/toyon.git"
|
|
18
|
+
},
|
|
19
|
+
bugs: "https://github.com/toyon-dev/toyon/issues",
|
|
20
|
+
keywords: [
|
|
21
|
+
"ai",
|
|
22
|
+
"agents",
|
|
23
|
+
"claude-code",
|
|
24
|
+
"codex",
|
|
25
|
+
"opencode",
|
|
26
|
+
"git",
|
|
27
|
+
"worktree",
|
|
28
|
+
"preview",
|
|
29
|
+
"dev-server"
|
|
30
|
+
],
|
|
31
|
+
bin: {
|
|
32
|
+
toyon: "bin/toyon.js"
|
|
33
|
+
},
|
|
34
|
+
files: [
|
|
35
|
+
"bin",
|
|
36
|
+
"dist",
|
|
37
|
+
"cloud"
|
|
38
|
+
],
|
|
39
|
+
engines: {
|
|
40
|
+
node: ">=18"
|
|
41
|
+
},
|
|
42
|
+
os: [
|
|
43
|
+
"darwin",
|
|
44
|
+
"linux"
|
|
45
|
+
],
|
|
46
|
+
scripts: {
|
|
47
|
+
prepublishOnly: "bun ../../scripts/pack.ts"
|
|
48
|
+
},
|
|
49
|
+
dependencies: {
|
|
50
|
+
bun: "1.4.2",
|
|
51
|
+
"bun-pty": "0.4.10"
|
|
52
|
+
},
|
|
53
|
+
devDependencies: {
|
|
54
|
+
"@toyon/shared": "workspace:*"
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/app.ts
|
|
59
|
+
import { spawn as spawn2, spawnSync } from "child_process";
|
|
60
|
+
import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
|
|
61
|
+
import { homedir as homedir2 } from "os";
|
|
62
|
+
import { join as join3 } from "path";
|
|
63
|
+
|
|
64
|
+
// src/daemon.ts
|
|
65
|
+
import { spawn } from "child_process";
|
|
66
|
+
import { existsSync as existsSync2, mkdirSync, openSync, readFileSync } from "fs";
|
|
67
|
+
import { homedir } from "os";
|
|
68
|
+
import { join as join2 } from "path";
|
|
69
|
+
|
|
70
|
+
// ../shared/src/daemon.ts
|
|
71
|
+
var DAEMON_FILES = {
|
|
72
|
+
token: "token",
|
|
73
|
+
pid: "daemon.pid",
|
|
74
|
+
state: "state.json",
|
|
75
|
+
log: "daemon.log",
|
|
76
|
+
remote: "remote.json"
|
|
77
|
+
};
|
|
78
|
+
var PREVIEW_PORTS = { from: 10001, to: 10008 };
|
|
79
|
+
var DNS_NAME = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
80
|
+
function isRemoteHost(name) {
|
|
81
|
+
return DNS_NAME.test(name) && !/\.\d+$/.test(name) && name !== "localhost" && !name.endsWith(".localhost");
|
|
82
|
+
}
|
|
83
|
+
var hostPreviews = (host) => `https://w{id}.${host}`;
|
|
84
|
+
var portPreviews = (host) => `https://${host}:{port}`;
|
|
85
|
+
var addressedByPort = (previews) => previews.includes("{port}");
|
|
86
|
+
var PLACEHOLDER = /\{(id|port)\}/g;
|
|
87
|
+
function checkPreviews(previews, host) {
|
|
88
|
+
const found = [...previews.matchAll(PLACEHOLDER)];
|
|
89
|
+
if (found.length !== 1)
|
|
90
|
+
return `${previews} needs exactly one of {id} or {port}`;
|
|
91
|
+
if (!/^https:\/\/[^/?#@]+$/.test(previews))
|
|
92
|
+
return `${previews} must be an https origin, with nothing after the name`;
|
|
93
|
+
if (previews.includes(":{id}"))
|
|
94
|
+
return `${previews} puts {id} where the port goes`;
|
|
95
|
+
let hostname;
|
|
96
|
+
try {
|
|
97
|
+
hostname = new URL(previewOrigin(previews, "0", 1)).hostname;
|
|
98
|
+
} catch {
|
|
99
|
+
return `${previews} is not an origin`;
|
|
100
|
+
}
|
|
101
|
+
if (hostname !== host && !hostname.endsWith(`.${host}`)) {
|
|
102
|
+
return `previews at ${hostname} are not ${host} or under it, so the preview cookie cannot reach them`;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
function previewOrigin(previews, worktreeId, port) {
|
|
107
|
+
return previews.replace("{id}", worktreeId).replace("{port}", String(port));
|
|
108
|
+
}
|
|
109
|
+
var matchers = new Map;
|
|
110
|
+
function parseRemote(text) {
|
|
111
|
+
try {
|
|
112
|
+
const v = JSON.parse(text);
|
|
113
|
+
if (typeof v?.host !== "string" || !isRemoteHost(v.host))
|
|
114
|
+
return null;
|
|
115
|
+
const previews = v.previews ?? hostPreviews(v.host);
|
|
116
|
+
if (typeof previews !== "string" || checkPreviews(previews, v.host) !== null)
|
|
117
|
+
return null;
|
|
118
|
+
return { host: v.host, previews };
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
var WS_CLOSE_UNAUTHORIZED = 4401;
|
|
124
|
+
|
|
125
|
+
// ../shared/src/launcher.ts
|
|
126
|
+
var LAUNCHER_ORIGIN = "https://toyon.cloud";
|
|
127
|
+
var launcherAddLink = (machineOrigin) => `${LAUNCHER_ORIGIN}/#add=${encodeURIComponent(machineOrigin)}`;
|
|
128
|
+
|
|
129
|
+
// ../shared/src/ports.ts
|
|
130
|
+
var DAEMON_DEFAULT_PORT = 4141;
|
|
131
|
+
|
|
132
|
+
// ../shared/src/sandbox.ts
|
|
133
|
+
var BWRAP_APPARMOR_PROFILE = `abi <abi/4.0>,
|
|
134
|
+
include <tunables/global>
|
|
135
|
+
profile bwrap /usr/bin/bwrap flags=(unconfined) {
|
|
136
|
+
userns,
|
|
137
|
+
include if exists <local/bwrap>
|
|
138
|
+
}
|
|
139
|
+
`;
|
|
140
|
+
var BWRAP_PROBE_ARGS = ["--ro-bind", "/", "/", "true"];
|
|
141
|
+
function bwrapBlockedAdvice(error, restricted) {
|
|
142
|
+
if (!restricted)
|
|
143
|
+
return `bubblewrap is installed but cannot start a sandbox: ${error}`;
|
|
144
|
+
return [
|
|
145
|
+
`bubblewrap cannot start a sandbox: ${error}`,
|
|
146
|
+
"This system restricts unprivileged user namespaces. Allow them for bubblewrap alone:",
|
|
147
|
+
" sudo tee /etc/apparmor.d/bwrap >/dev/null <<'EOF'",
|
|
148
|
+
...BWRAP_APPARMOR_PROFILE.trimEnd().split(`
|
|
149
|
+
`).map((l) => ` ${l}`),
|
|
150
|
+
" EOF",
|
|
151
|
+
" sudo apparmor_parser -r /etc/apparmor.d/bwrap"
|
|
152
|
+
].join(`
|
|
153
|
+
`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/layout.ts
|
|
157
|
+
import { existsSync } from "fs";
|
|
158
|
+
import { dirname, join } from "path";
|
|
159
|
+
import { fileURLToPath } from "url";
|
|
160
|
+
var here = dirname(fileURLToPath(import.meta.url));
|
|
161
|
+
var packaged = existsSync(join(here, "daemon.js"));
|
|
162
|
+
var daemonEntry = packaged ? join(here, "daemon.js") : join(here, "../../daemon/src/index.ts");
|
|
163
|
+
var iconSvg = packaged ? join(here, "shell", "icon.svg") : join(here, "../../shell/public/icon.svg");
|
|
164
|
+
|
|
165
|
+
// src/daemon.ts
|
|
166
|
+
var port = Number(process.env.TOYON_PORT ?? DAEMON_DEFAULT_PORT);
|
|
167
|
+
var base = `http://127.0.0.1:${port}`;
|
|
168
|
+
var home = process.env.TOYON_HOME ?? join2(homedir(), ".toyon");
|
|
169
|
+
var tokenFile = join2(home, DAEMON_FILES.token);
|
|
170
|
+
var pidFile = join2(home, DAEMON_FILES.pid);
|
|
171
|
+
var logFile = join2(home, DAEMON_FILES.log);
|
|
172
|
+
var remoteFile = join2(home, DAEMON_FILES.remote);
|
|
173
|
+
async function health() {
|
|
174
|
+
try {
|
|
175
|
+
const r = await fetch(`${base}/health`, { signal: AbortSignal.timeout(1000) });
|
|
176
|
+
if (!r.ok)
|
|
177
|
+
return null;
|
|
178
|
+
return await r.json();
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function readToken() {
|
|
184
|
+
if (!existsSync2(tokenFile))
|
|
185
|
+
return null;
|
|
186
|
+
const t = readFileSync(tokenFile, "utf8").trim();
|
|
187
|
+
return t === "" ? null : t;
|
|
188
|
+
}
|
|
189
|
+
function readPid() {
|
|
190
|
+
if (!existsSync2(pidFile))
|
|
191
|
+
return null;
|
|
192
|
+
const n = Number(readFileSync(pidFile, "utf8").trim());
|
|
193
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
194
|
+
}
|
|
195
|
+
function alive(pid) {
|
|
196
|
+
try {
|
|
197
|
+
process.kill(pid, 0);
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async function startDaemon() {
|
|
204
|
+
mkdirSync(home, { recursive: true });
|
|
205
|
+
const logFd = openSync(logFile, "a");
|
|
206
|
+
const child = spawn(process.execPath, ["run", daemonEntry], {
|
|
207
|
+
detached: true,
|
|
208
|
+
stdio: ["ignore", logFd, logFd],
|
|
209
|
+
env: { ...process.env }
|
|
210
|
+
});
|
|
211
|
+
child.on("error", (e) => console.error(`could not start bun: ${e.message}`));
|
|
212
|
+
child.unref();
|
|
213
|
+
for (let i = 0;i < 40; i++) {
|
|
214
|
+
if (await health())
|
|
215
|
+
return true;
|
|
216
|
+
await Bun.sleep(250);
|
|
217
|
+
}
|
|
218
|
+
return await health() !== null;
|
|
219
|
+
}
|
|
220
|
+
function shellUrl(token, branded) {
|
|
221
|
+
return branded ? `http://toyon.localhost/#token=${token}` : `http://toyon.localhost:${port}/#token=${token}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/app.ts
|
|
225
|
+
var CHROMIUMS = ["Google Chrome", "Arc", "Brave Browser", "Microsoft Edge", "Chromium"];
|
|
226
|
+
var DATA_DIRS = {
|
|
227
|
+
"Google Chrome": "Google/Chrome",
|
|
228
|
+
Arc: "Arc/User Data",
|
|
229
|
+
"Brave Browser": "BraveSoftware/Brave-Browser",
|
|
230
|
+
"Microsoft Edge": "Microsoft Edge",
|
|
231
|
+
Chromium: "Chromium"
|
|
232
|
+
};
|
|
233
|
+
var pwaCacheDir = join3(home, "pwa");
|
|
234
|
+
function profilesOf(browser) {
|
|
235
|
+
const root = join3(homedir2(), "Library", "Application Support", DATA_DIRS[browser] ?? browser);
|
|
236
|
+
if (!existsSync3(root))
|
|
237
|
+
return [];
|
|
238
|
+
return readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => join3(root, d.name));
|
|
239
|
+
}
|
|
240
|
+
function pwaStillInstalled(browser, id) {
|
|
241
|
+
return profilesOf(browser).some((p) => existsSync3(join3(p, "Web Applications", "Manifest Resources", id)));
|
|
242
|
+
}
|
|
243
|
+
function findPwaId(browser, manifestId) {
|
|
244
|
+
const cache = join3(pwaCacheDir, `${browser.replace(/\W+/g, "-")}.id`);
|
|
245
|
+
if (existsSync3(cache)) {
|
|
246
|
+
const id = readFileSync2(cache, "utf8").trim();
|
|
247
|
+
if (/^[a-p]{32}$/.test(id) && pwaStillInstalled(browser, id))
|
|
248
|
+
return id;
|
|
249
|
+
}
|
|
250
|
+
const re = new RegExp(`web_apps-dt-([a-p]{32})[\\s\\S]{0,16}?${manifestId.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}`);
|
|
251
|
+
for (const p of profilesOf(browser)) {
|
|
252
|
+
const dir = join3(p, "Sync Data", "LevelDB");
|
|
253
|
+
if (!existsSync3(dir))
|
|
254
|
+
continue;
|
|
255
|
+
for (const f of readdirSync(dir)) {
|
|
256
|
+
if (!/\.(log|ldb)$/.test(f))
|
|
257
|
+
continue;
|
|
258
|
+
const m = re.exec(readFileSync2(join3(dir, f), "latin1"));
|
|
259
|
+
if (m && pwaStillInstalled(browser, m[1])) {
|
|
260
|
+
mkdirSync2(pwaCacheDir, { recursive: true });
|
|
261
|
+
writeFileSync(cache, m[1]);
|
|
262
|
+
return m[1];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
function openAppWindow(appUrl) {
|
|
269
|
+
const manifestId = new URL("/", appUrl).href;
|
|
270
|
+
for (const app of CHROMIUMS) {
|
|
271
|
+
if (spawnSync("open", ["-Ra", app]).status === 0) {
|
|
272
|
+
const id = findPwaId(app, manifestId);
|
|
273
|
+
const flag = id ? `--app-id=${id}` : `--app=${appUrl}`;
|
|
274
|
+
spawn2("open", ["-na", app, "--args", flag], { stdio: "ignore" }).unref();
|
|
275
|
+
if (!id) {
|
|
276
|
+
console.log(`tip: install Toyon as an app (browser menu, then Install, or the install button in the top bar when opened in a tab)`);
|
|
277
|
+
console.log(` once installed, it gets a native-style title bar; \`toyon --app\` then launches the installed app`);
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
function installApp(appUrl) {
|
|
285
|
+
const manifestId = new URL("/", appUrl).href;
|
|
286
|
+
const appDir = join3(homedir2(), "Applications", "Toyon.app");
|
|
287
|
+
const macos = join3(appDir, "Contents", "MacOS");
|
|
288
|
+
const resources = join3(appDir, "Contents", "Resources");
|
|
289
|
+
rmSync(appDir, { recursive: true, force: true });
|
|
290
|
+
mkdirSync2(macos, { recursive: true });
|
|
291
|
+
mkdirSync2(resources, { recursive: true });
|
|
292
|
+
writeFileSync(join3(appDir, "Contents", "Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
|
|
293
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
294
|
+
<plist version="1.0"><dict>
|
|
295
|
+
<key>CFBundleName</key><string>Toyon</string>
|
|
296
|
+
<key>CFBundleDisplayName</key><string>Toyon</string>
|
|
297
|
+
<key>CFBundleIdentifier</key><string>dev.toyon.app</string>
|
|
298
|
+
<key>CFBundleVersion</key><string>0.0.1</string>
|
|
299
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
300
|
+
<key>CFBundleExecutable</key><string>Toyon</string>
|
|
301
|
+
<key>CFBundleIconFile</key><string>AppIcon</string>
|
|
302
|
+
</dict></plist>
|
|
303
|
+
`);
|
|
304
|
+
const launcher = join3(resources, "launch.sh");
|
|
305
|
+
writeFileSync(launcher, `#!/bin/bash
|
|
306
|
+
# Finder launches get a minimal PATH; find bun ourselves
|
|
307
|
+
BUN=""
|
|
308
|
+
for CAND in "$HOME/.bun/bin/bun" /opt/homebrew/bin/bun /usr/local/bin/bun; do
|
|
309
|
+
[ -x "$CAND" ] && BUN="$CAND" && break
|
|
310
|
+
done
|
|
311
|
+
|
|
312
|
+
# start the daemon if it isn't running
|
|
313
|
+
if ! curl -s --max-time 1 http://127.0.0.1:${port}/health >/dev/null 2>&1; then
|
|
314
|
+
if [ -n "$BUN" ] && [ -f "${daemonEntry}" ]; then
|
|
315
|
+
nohup "$BUN" run "${daemonEntry}" >> "$HOME/.toyon/daemon.log" 2>&1 &
|
|
316
|
+
for _ in $(seq 1 40); do
|
|
317
|
+
curl -s --max-time 1 http://127.0.0.1:${port}/health >/dev/null 2>&1 && break
|
|
318
|
+
sleep 0.25
|
|
319
|
+
done
|
|
320
|
+
fi
|
|
321
|
+
fi
|
|
322
|
+
|
|
323
|
+
TOKEN=$(cat "$HOME/.toyon/token" 2>/dev/null)
|
|
324
|
+
URL="http://toyon.localhost:${port}/#token=$TOKEN"
|
|
325
|
+
MANIFEST_ID="${manifestId}"
|
|
326
|
+
CACHE_DIR="$HOME/.toyon/pwa"
|
|
327
|
+
# installed PWA (native-style title bar) if any profile has it, else a plain app window.
|
|
328
|
+
# The app id lives in the browser's sync DB right before the manifest id; cache it once found.
|
|
329
|
+
find_pwa_id() { # $1 = data dir, $2 = cache file
|
|
330
|
+
local root="$HOME/Library/Application Support/$1" id p f
|
|
331
|
+
if [ -f "$2" ]; then
|
|
332
|
+
id=$(tr -d '[:space:]' < "$2")
|
|
333
|
+
for p in "$root"/*/; do [ -d "$p/Web Applications/Manifest Resources/$id" ] && { echo "$id"; return; }; done
|
|
334
|
+
fi
|
|
335
|
+
for p in "$root"/*/; do
|
|
336
|
+
for f in "$p/Sync Data/LevelDB/"*.log "$p/Sync Data/LevelDB/"*.ldb; do
|
|
337
|
+
[ -f "$f" ] || continue
|
|
338
|
+
# the record straddles newline bytes, so flatten before the line-based grep
|
|
339
|
+
id=$(LC_ALL=C tr '\\n\\0' ' ' < "$f" | LC_ALL=C grep -aoE "web_apps-dt-[a-p]{32}.{0,16}$MANIFEST_ID" 2>/dev/null | head -1 | LC_ALL=C sed -E 's/^web_apps-dt-([a-p]{32}).*/\\1/')
|
|
340
|
+
if [ -n "$id" ] && [ -d "$p/Web Applications/Manifest Resources/$id" ]; then
|
|
341
|
+
mkdir -p "$CACHE_DIR" && printf '%s' "$id" > "$2"; echo "$id"; return
|
|
342
|
+
fi
|
|
343
|
+
done
|
|
344
|
+
done
|
|
345
|
+
}
|
|
346
|
+
launch() { # $1 = browser name, $2 = data dir
|
|
347
|
+
local id; id=$(find_pwa_id "$2" "$CACHE_DIR/$(echo "$1" | tr -c 'A-Za-z0-9
|
|
348
|
+
' '-').id")
|
|
349
|
+
if [ -n "$id" ]; then exec open -na "$1" --args --app-id="$id"; fi
|
|
350
|
+
exec open -na "$1" --args --app="$URL"
|
|
351
|
+
}
|
|
352
|
+
${CHROMIUMS.map((a) => `if open -Ra "${a}" 2>/dev/null; then launch "${a}" "${DATA_DIRS[a]}"; fi`).join(`
|
|
353
|
+
`)}
|
|
354
|
+
exec open "$URL"
|
|
355
|
+
`);
|
|
356
|
+
chmodSync(launcher, 493);
|
|
357
|
+
const stub = join3(macos, "Toyon");
|
|
358
|
+
const cSrc = join3(home, "launcher.c");
|
|
359
|
+
writeFileSync(cSrc, `#include <mach-o/dyld.h>
|
|
360
|
+
#include <stdio.h>
|
|
361
|
+
#include <stdint.h>
|
|
362
|
+
#include <stdlib.h>
|
|
363
|
+
#include <string.h>
|
|
364
|
+
#include <unistd.h>
|
|
365
|
+
#include <libgen.h>
|
|
366
|
+
int main(int argc, char **argv) {
|
|
367
|
+
char self[4096]; uint32_t n = sizeof(self);
|
|
368
|
+
if (_NSGetExecutablePath(self, &n) != 0) return 1;
|
|
369
|
+
char script[4600];
|
|
370
|
+
snprintf(script, sizeof(script), "%s/../Resources/launch.sh", dirname(self));
|
|
371
|
+
execl("/bin/bash", "bash", script, (char *)0);
|
|
372
|
+
return 1;
|
|
373
|
+
}
|
|
374
|
+
`);
|
|
375
|
+
const cc = spawnSync("clang", ["-O2", "-o", stub, cSrc], { stdio: "ignore" });
|
|
376
|
+
if (cc.status !== 0) {
|
|
377
|
+
writeFileSync(stub, `#!/bin/bash
|
|
378
|
+
exec /bin/bash "$(dirname "$0")/../Resources/launch.sh"
|
|
379
|
+
`);
|
|
380
|
+
chmodSync(stub, 493);
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
const svg = iconSvg;
|
|
384
|
+
const tmp = join3(home, "iconset.tmp");
|
|
385
|
+
const iconset = join3(tmp, "AppIcon.iconset");
|
|
386
|
+
mkdirSync2(iconset, { recursive: true });
|
|
387
|
+
spawnSync("qlmanage", ["-t", "-s", "1024", "-o", tmp, svg], { stdio: "ignore" });
|
|
388
|
+
const big = join3(tmp, "icon.svg.png");
|
|
389
|
+
if (existsSync3(big)) {
|
|
390
|
+
for (const size of [16, 32, 64, 128, 256, 512, 1024]) {
|
|
391
|
+
spawnSync("sips", ["-z", String(size), String(size), big, "--out", join3(iconset, `icon_${size}x${size}.png`)], {
|
|
392
|
+
stdio: "ignore"
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
spawnSync("iconutil", ["-c", "icns", iconset, "-o", join3(resources, "AppIcon.icns")], { stdio: "ignore" });
|
|
396
|
+
}
|
|
397
|
+
} catch {}
|
|
398
|
+
spawnSync("xattr", ["-cr", appDir], { stdio: "ignore" });
|
|
399
|
+
const signed = spawnSync("codesign", ["--force", "--deep", "-s", "-", appDir], { stdio: "ignore" }).status === 0;
|
|
400
|
+
if (!signed)
|
|
401
|
+
console.warn("warning: could not codesign the app bundle; macOS may refuse to open it");
|
|
402
|
+
console.log(`installed ${appDir}; launch "Toyon" from Spotlight or drag it to the Dock`);
|
|
403
|
+
spawn2("open", ["-a", appDir], { stdio: "ignore" }).unref();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/args.ts
|
|
407
|
+
var VERBS = new Set(["stop", "restart", "doctor", "logs", "version", "uninstall", "remote", "deploy", "help"]);
|
|
408
|
+
var DEFAULT_LOG_LINES = 100;
|
|
409
|
+
function parseArgs(argv) {
|
|
410
|
+
if (argv.includes("--help") || argv.includes("-h"))
|
|
411
|
+
return { kind: "help" };
|
|
412
|
+
if (argv.includes("--version") || argv.includes("-v"))
|
|
413
|
+
return { kind: "version" };
|
|
414
|
+
const [first, ...rest] = argv;
|
|
415
|
+
if (first !== undefined && VERBS.has(first)) {
|
|
416
|
+
switch (first) {
|
|
417
|
+
case "stop":
|
|
418
|
+
case "restart":
|
|
419
|
+
case "doctor":
|
|
420
|
+
case "version":
|
|
421
|
+
case "help":
|
|
422
|
+
if (rest.length > 0)
|
|
423
|
+
return { kind: "error", message: `toyon ${first} takes no arguments` };
|
|
424
|
+
return { kind: first };
|
|
425
|
+
case "logs":
|
|
426
|
+
return parseLogs(rest);
|
|
427
|
+
case "uninstall": {
|
|
428
|
+
const extra = rest.filter((a) => a !== "--yes" && a !== "-y");
|
|
429
|
+
if (extra.length > 0)
|
|
430
|
+
return { kind: "error", message: `unknown option ${extra[0]}` };
|
|
431
|
+
return { kind: "uninstall", yes: rest.length > 0 };
|
|
432
|
+
}
|
|
433
|
+
case "remote": {
|
|
434
|
+
const ports = rest.includes("--ports");
|
|
435
|
+
const tailscale = rest.includes("--tailscale");
|
|
436
|
+
const names = rest.filter((a) => a !== "--ports" && a !== "--tailscale");
|
|
437
|
+
const option = names.find((a) => a.startsWith("-"));
|
|
438
|
+
if (option)
|
|
439
|
+
return { kind: "error", message: `unknown option ${option}` };
|
|
440
|
+
if (names.length > 1)
|
|
441
|
+
return { kind: "error", message: "toyon remote takes one name, or off" };
|
|
442
|
+
const to = names[0] ?? null;
|
|
443
|
+
if (tailscale) {
|
|
444
|
+
if (to !== null || ports) {
|
|
445
|
+
return { kind: "error", message: "--tailscale goes alone: the name comes from Tailscale" };
|
|
446
|
+
}
|
|
447
|
+
return { kind: "remote", to, ports, tailscale };
|
|
448
|
+
}
|
|
449
|
+
if (ports && (to === null || to === "off")) {
|
|
450
|
+
return { kind: "error", message: "--ports goes with a name: toyon remote <name> --ports" };
|
|
451
|
+
}
|
|
452
|
+
if (to !== null && to !== "off" && !isRemoteHost(to)) {
|
|
453
|
+
return {
|
|
454
|
+
kind: "error",
|
|
455
|
+
message: `${to} is not a host name; give the name your TLS front answers for, like toyon.example.com`
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
return { kind: "remote", to, ports, tailscale };
|
|
459
|
+
}
|
|
460
|
+
case "deploy":
|
|
461
|
+
return parseDeploy(rest);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
let path = null;
|
|
465
|
+
let app = false;
|
|
466
|
+
let installApp = false;
|
|
467
|
+
for (const a of argv) {
|
|
468
|
+
if (a === "--app" || a === "--pwa")
|
|
469
|
+
app = true;
|
|
470
|
+
else if (a === "--install-app")
|
|
471
|
+
installApp = true;
|
|
472
|
+
else if (a.startsWith("-"))
|
|
473
|
+
return { kind: "error", message: `unknown option ${a}` };
|
|
474
|
+
else if (path !== null)
|
|
475
|
+
return { kind: "error", message: "Toyon opens one path at a time" };
|
|
476
|
+
else
|
|
477
|
+
path = a;
|
|
478
|
+
}
|
|
479
|
+
return { kind: "open", path, app, installApp };
|
|
480
|
+
}
|
|
481
|
+
function parseLogs(rest) {
|
|
482
|
+
let follow = false;
|
|
483
|
+
let lines = DEFAULT_LOG_LINES;
|
|
484
|
+
for (let i = 0;i < rest.length; i++) {
|
|
485
|
+
const a = rest[i];
|
|
486
|
+
if (a === "-f" || a === "--follow")
|
|
487
|
+
follow = true;
|
|
488
|
+
else if (a === "-n" || a === "--lines") {
|
|
489
|
+
const n = Number(rest[++i]);
|
|
490
|
+
if (!Number.isInteger(n) || n < 0)
|
|
491
|
+
return { kind: "error", message: `${a} needs a whole number` };
|
|
492
|
+
lines = n;
|
|
493
|
+
} else
|
|
494
|
+
return { kind: "error", message: `unknown option ${a}` };
|
|
495
|
+
}
|
|
496
|
+
return { kind: "logs", follow, lines };
|
|
497
|
+
}
|
|
498
|
+
var FLY_APP = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
|
|
499
|
+
function parseDeploy(rest) {
|
|
500
|
+
const error = (message) => ({ kind: "error", message });
|
|
501
|
+
const [provider, action, ...more] = rest;
|
|
502
|
+
if (provider !== "fly")
|
|
503
|
+
return error("toyon deploy knows one host so far: toyon deploy fly up <name>");
|
|
504
|
+
if (action !== "up" && action !== "url" && action !== "destroy") {
|
|
505
|
+
return error("toyon deploy fly takes up, url or destroy, then an app name");
|
|
506
|
+
}
|
|
507
|
+
let name = null;
|
|
508
|
+
let region = null;
|
|
509
|
+
let repo = null;
|
|
510
|
+
let yes = false;
|
|
511
|
+
for (let i = 0;i < more.length; i++) {
|
|
512
|
+
const a = more[i];
|
|
513
|
+
if ((a === "--region" || a === "--repo") && action === "up") {
|
|
514
|
+
const value = more[++i];
|
|
515
|
+
if (!value)
|
|
516
|
+
return error(`${a} needs a value`);
|
|
517
|
+
if (a === "--region") {
|
|
518
|
+
if (!/^[a-z]{3}$/.test(value))
|
|
519
|
+
return error(`${value} is not a Fly region code, like fra or iad`);
|
|
520
|
+
region = value;
|
|
521
|
+
} else {
|
|
522
|
+
if (!/^https:\/\/\S+$/.test(value))
|
|
523
|
+
return error("--repo takes an https clone URL, like https://github.com/you/app.git");
|
|
524
|
+
repo = value;
|
|
525
|
+
}
|
|
526
|
+
} else if ((a === "--yes" || a === "-y") && action === "destroy")
|
|
527
|
+
yes = true;
|
|
528
|
+
else if (a.startsWith("-"))
|
|
529
|
+
return error(`unknown option ${a} for toyon deploy fly ${action}`);
|
|
530
|
+
else if (name !== null)
|
|
531
|
+
return error("toyon deploy takes one app name");
|
|
532
|
+
else
|
|
533
|
+
name = a;
|
|
534
|
+
}
|
|
535
|
+
if (name === null)
|
|
536
|
+
return error(`toyon deploy fly ${action} needs an app name`);
|
|
537
|
+
if (!FLY_APP.test(name))
|
|
538
|
+
return error(`${name} is not a Fly app name: lowercase letters, digits and dashes`);
|
|
539
|
+
return { kind: "deploy", provider, action, name, region, repo, yes };
|
|
540
|
+
}
|
|
541
|
+
var HELP = `toyon: one chat per git worktree, every worktree running live
|
|
542
|
+
|
|
543
|
+
usage
|
|
544
|
+
toyon [path] start the daemon if it is not running, register the repo at path
|
|
545
|
+
(default: the current directory) and open the shell
|
|
546
|
+
toyon stop stop the daemon and every dev server and agent it runs
|
|
547
|
+
toyon restart stop the daemon and start it again, for a Toyon you have just rebuilt;
|
|
548
|
+
every shell reconnects on its own
|
|
549
|
+
toyon doctor check the daemon, the token, the shell build and the tools Toyon needs
|
|
550
|
+
toyon logs [-f] [-n N] print the daemon log; -f keeps following it, -n sets how many lines
|
|
551
|
+
toyon version print the CLI version, and the daemon's if one is running
|
|
552
|
+
toyon uninstall [--yes] stop the daemon and remove everything Toyon put on this machine;
|
|
553
|
+
your repos and the branches Toyon made stay
|
|
554
|
+
toyon remote [name|off] open the shell from another device at https://name, through a TLS
|
|
555
|
+
front on this machine; with no name, print the setting; off also
|
|
556
|
+
removes the tailscale serve entries Toyon set
|
|
557
|
+
--ports put each preview on its own port of the name, for a front that cannot
|
|
558
|
+
hold a wildcard certificate
|
|
559
|
+
toyon remote --tailscale
|
|
560
|
+
open the shell from your tailnet at this machine's Tailscale name:
|
|
561
|
+
sets up tailscale serve for Toyon and ports 10001-10008
|
|
562
|
+
toyon deploy fly up <name> [--region code] [--repo url]
|
|
563
|
+
run Toyon on your own Fly account at https://name.fly.dev with your own
|
|
564
|
+
keys; --repo clones that repository onto it the first time
|
|
565
|
+
toyon deploy fly url <name>
|
|
566
|
+
print the link to that machine again
|
|
567
|
+
toyon deploy fly destroy <name> [--yes]
|
|
568
|
+
delete that app and its volume
|
|
569
|
+
|
|
570
|
+
options for toyon [path]
|
|
571
|
+
--app open a Chromium app window (the installed Toyon app when there is one)
|
|
572
|
+
--install-app write ~/Applications/Toyon.app and open it
|
|
573
|
+
|
|
574
|
+
environment
|
|
575
|
+
TOYON_HOME where state lives (default ~/.toyon)
|
|
576
|
+
TOYON_PORT the daemon's port (default 4141)
|
|
577
|
+
`;
|
|
578
|
+
|
|
579
|
+
// src/deploy/fly.ts
|
|
580
|
+
import { randomBytes } from "crypto";
|
|
581
|
+
import { Resolver } from "dns/promises";
|
|
582
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, mkdtempSync, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
583
|
+
import { tmpdir } from "os";
|
|
584
|
+
import { join as join5 } from "path";
|
|
585
|
+
|
|
586
|
+
// src/openUrl.ts
|
|
587
|
+
import { spawn as spawn3 } from "child_process";
|
|
588
|
+
function openUrl(url) {
|
|
589
|
+
const child = spawn3(process.platform === "darwin" ? "open" : "xdg-open", [url], { stdio: "ignore" });
|
|
590
|
+
child.on("error", () => {});
|
|
591
|
+
child.unref();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/deploy/context.ts
|
|
595
|
+
import { cpSync, existsSync as existsSync4, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
596
|
+
import { join as join4 } from "path";
|
|
597
|
+
var bunVersion = package_default.dependencies.bun;
|
|
598
|
+
function machineSources() {
|
|
599
|
+
const dist = packaged ? here : join4(here, "..", "dist");
|
|
600
|
+
return existsSync4(join4(dist, "daemon.js")) ? { dist, cloud: join4(dist, "..", "cloud") } : null;
|
|
601
|
+
}
|
|
602
|
+
function machinePackageJson() {
|
|
603
|
+
const manifest = {
|
|
604
|
+
name: "toyon-machine",
|
|
605
|
+
private: true,
|
|
606
|
+
version: package_default.version,
|
|
607
|
+
dependencies: { "bun-pty": package_default.dependencies["bun-pty"] }
|
|
608
|
+
};
|
|
609
|
+
return `${JSON.stringify(manifest, null, 2)}
|
|
610
|
+
`;
|
|
611
|
+
}
|
|
612
|
+
function writeMachineContext(src, out) {
|
|
613
|
+
mkdirSync3(out, { recursive: true });
|
|
614
|
+
cpSync(src.dist, join4(out, "dist"), { recursive: true });
|
|
615
|
+
cpSync(join4(src.cloud, "Dockerfile"), join4(out, "Dockerfile"));
|
|
616
|
+
cpSync(join4(src.cloud, "entrypoint.sh"), join4(out, "entrypoint.sh"));
|
|
617
|
+
writeFileSync2(join4(out, "package.json"), machinePackageJson());
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/deploy/fly.ts
|
|
621
|
+
var DAEMON_PORT = 4141;
|
|
622
|
+
var VOLUME = "toyon_data";
|
|
623
|
+
var VOLUME_GB = 5;
|
|
624
|
+
var cloudDir = join5(home, "cloud");
|
|
625
|
+
var tokenFile2 = (app) => join5(cloudDir, `${app}.token`);
|
|
626
|
+
|
|
627
|
+
class DeployError extends Error {
|
|
628
|
+
}
|
|
629
|
+
function flyToml(app, region) {
|
|
630
|
+
const host = `${app}.fly.dev`;
|
|
631
|
+
const lines = [
|
|
632
|
+
"# written by toyon deploy fly for one deploy",
|
|
633
|
+
`app = "${app}"`,
|
|
634
|
+
`primary_region = "${region}"`,
|
|
635
|
+
"",
|
|
636
|
+
"[env]",
|
|
637
|
+
` TOYON_PUBLIC_HOST = "${host}"`,
|
|
638
|
+
` TOYON_PREVIEWS = "${portPreviews(host)}"`,
|
|
639
|
+
"",
|
|
640
|
+
"[mounts]",
|
|
641
|
+
` source = "${VOLUME}"`,
|
|
642
|
+
' destination = "/data"',
|
|
643
|
+
"",
|
|
644
|
+
"[[vm]]",
|
|
645
|
+
' size = "shared-cpu-2x"',
|
|
646
|
+
' memory = "2048mb"',
|
|
647
|
+
"",
|
|
648
|
+
"# the shell and the daemon's websocket",
|
|
649
|
+
"[[services]]",
|
|
650
|
+
` internal_port = ${DAEMON_PORT}`,
|
|
651
|
+
' protocol = "tcp"',
|
|
652
|
+
' auto_stop_machines = "stop"',
|
|
653
|
+
" auto_start_machines = true",
|
|
654
|
+
" min_machines_running = 0",
|
|
655
|
+
" [[services.ports]]",
|
|
656
|
+
" port = 80",
|
|
657
|
+
' handlers = ["http"]',
|
|
658
|
+
" force_https = true",
|
|
659
|
+
" [[services.ports]]",
|
|
660
|
+
" port = 443",
|
|
661
|
+
' handlers = ["tls", "http"]',
|
|
662
|
+
" [[services.http_checks]]",
|
|
663
|
+
' interval = "15s"',
|
|
664
|
+
' timeout = "5s"',
|
|
665
|
+
' grace_period = "60s"',
|
|
666
|
+
' method = "get"',
|
|
667
|
+
' path = "/health"'
|
|
668
|
+
];
|
|
669
|
+
for (let p = PREVIEW_PORTS.from;p <= PREVIEW_PORTS.to; p++) {
|
|
670
|
+
lines.push("", "[[services]]", ` internal_port = ${p}`, ' protocol = "tcp"', ' auto_stop_machines = "stop"', " auto_start_machines = true", " min_machines_running = 0", " [[services.ports]]", ` port = ${p}`, ' handlers = ["tls", "http"]');
|
|
671
|
+
}
|
|
672
|
+
return `${lines.join(`
|
|
673
|
+
`)}
|
|
674
|
+
`;
|
|
675
|
+
}
|
|
676
|
+
function regionFromRequestId(id) {
|
|
677
|
+
return id?.match(/-([a-z]{3})$/)?.[1] ?? null;
|
|
678
|
+
}
|
|
679
|
+
function secretsInput(secrets) {
|
|
680
|
+
const lines = [];
|
|
681
|
+
for (const [name, value] of Object.entries(secrets)) {
|
|
682
|
+
if (value === null)
|
|
683
|
+
continue;
|
|
684
|
+
if (/[\r\n]/.test(value))
|
|
685
|
+
throw new DeployError(`${name} holds a line break; a key is one line`);
|
|
686
|
+
lines.push(`${name}=${value}`);
|
|
687
|
+
}
|
|
688
|
+
return `${lines.join(`
|
|
689
|
+
`)}
|
|
690
|
+
`;
|
|
691
|
+
}
|
|
692
|
+
async function nearestRegion() {
|
|
693
|
+
try {
|
|
694
|
+
const res = await fetch("https://api.fly.io/", { method: "HEAD", signal: AbortSignal.timeout(5000) });
|
|
695
|
+
return regionFromRequestId(res.headers.get("fly-request-id"));
|
|
696
|
+
} catch {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function secret(env, file) {
|
|
701
|
+
const fromEnv = process.env[env]?.trim();
|
|
702
|
+
if (fromEnv)
|
|
703
|
+
return fromEnv;
|
|
704
|
+
const path = join5(cloudDir, file);
|
|
705
|
+
return existsSync5(path) ? readFileSync3(path, "utf8").trim() || null : null;
|
|
706
|
+
}
|
|
707
|
+
async function capture(bin, args, input) {
|
|
708
|
+
const p = Bun.spawn([bin, ...args], {
|
|
709
|
+
stdin: input === undefined ? "ignore" : new Blob([input]),
|
|
710
|
+
stdout: "pipe",
|
|
711
|
+
stderr: "pipe"
|
|
712
|
+
});
|
|
713
|
+
const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
714
|
+
return { ok: await p.exited === 0, out, err };
|
|
715
|
+
}
|
|
716
|
+
async function show(bin, args) {
|
|
717
|
+
const p = Bun.spawn([bin, ...args], { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
|
|
718
|
+
return await p.exited === 0;
|
|
719
|
+
}
|
|
720
|
+
function must(r, what) {
|
|
721
|
+
if (!r.ok)
|
|
722
|
+
throw new DeployError(`${what}: ${r.err.trim().split(`
|
|
723
|
+
`).at(-1) ?? "flyctl failed"}`);
|
|
724
|
+
}
|
|
725
|
+
function namesApp(json, app) {
|
|
726
|
+
try {
|
|
727
|
+
const apps = JSON.parse(json);
|
|
728
|
+
return apps.some((a) => (a.Name ?? a.name) === app);
|
|
729
|
+
} catch {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
async function published(host) {
|
|
734
|
+
const r = new Resolver({ timeout: 3000, tries: 1 });
|
|
735
|
+
r.setServers(["1.1.1.1", "8.8.8.8"]);
|
|
736
|
+
for (let i = 0;i < 60; i++) {
|
|
737
|
+
try {
|
|
738
|
+
if ((await r.resolve4(host)).length)
|
|
739
|
+
return true;
|
|
740
|
+
} catch (e) {
|
|
741
|
+
const code = e.code;
|
|
742
|
+
if (code !== "ENOTFOUND" && code !== "ENODATA")
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
await Bun.sleep(2000);
|
|
746
|
+
}
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
async function answers(url) {
|
|
750
|
+
try {
|
|
751
|
+
return (await fetch(url, { signal: AbortSignal.timeout(5000) })).ok;
|
|
752
|
+
} catch {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
async function up(cmd, bin) {
|
|
757
|
+
const src = machineSources();
|
|
758
|
+
if (!src)
|
|
759
|
+
throw new DeployError("there is no packed build to deploy here; in a source checkout run `bun scripts/pack.ts` first");
|
|
760
|
+
if (!(await capture(bin, ["auth", "whoami"])).ok)
|
|
761
|
+
throw new DeployError("flyctl is not logged in; run `fly auth login`");
|
|
762
|
+
const anthropic = secret("ANTHROPIC_API_KEY", "anthropic.key");
|
|
763
|
+
if (!anthropic)
|
|
764
|
+
console.log("no Anthropic key: sign in with your Claude plan from the first chat");
|
|
765
|
+
const region = cmd.region ?? await nearestRegion();
|
|
766
|
+
if (!region)
|
|
767
|
+
throw new DeployError("could not tell which Fly region is closest; pass --region (`fly platform regions` lists them)");
|
|
768
|
+
console.log(cmd.region ? `region ${region}` : `region ${region}, the closest to you; --region picks another`);
|
|
769
|
+
mkdirSync4(cloudDir, { recursive: true, mode: 448 });
|
|
770
|
+
if (!existsSync5(tokenFile2(cmd.name)))
|
|
771
|
+
writeFileSync3(tokenFile2(cmd.name), randomBytes(32).toString("hex"), { mode: 384 });
|
|
772
|
+
const token = readFileSync3(tokenFile2(cmd.name), "utf8").trim();
|
|
773
|
+
const app = ["-a", cmd.name];
|
|
774
|
+
if (!namesApp((await capture(bin, ["apps", "list", "--json"])).out, cmd.name)) {
|
|
775
|
+
console.log(`creating the app ${cmd.name}`);
|
|
776
|
+
must(await capture(bin, ["apps", "create", cmd.name]), "could not create the app");
|
|
777
|
+
}
|
|
778
|
+
if (!(await capture(bin, ["volumes", "list", ...app, "--json"])).out.includes(`"${VOLUME}"`)) {
|
|
779
|
+
console.log(`creating a ${VOLUME_GB} GB volume in ${region}`);
|
|
780
|
+
must(await capture(bin, [
|
|
781
|
+
"volumes",
|
|
782
|
+
"create",
|
|
783
|
+
VOLUME,
|
|
784
|
+
...app,
|
|
785
|
+
"--region",
|
|
786
|
+
region,
|
|
787
|
+
"--size",
|
|
788
|
+
String(VOLUME_GB),
|
|
789
|
+
"--yes"
|
|
790
|
+
]), "could not create the volume");
|
|
791
|
+
}
|
|
792
|
+
console.log("setting secrets");
|
|
793
|
+
const secrets = secretsInput({
|
|
794
|
+
TOYON_TOKEN: token,
|
|
795
|
+
ANTHROPIC_API_KEY: anthropic,
|
|
796
|
+
OPENAI_API_KEY: secret("OPENAI_API_KEY", "openai.key"),
|
|
797
|
+
GITHUB_TOKEN: secret("GITHUB_TOKEN", "github.token"),
|
|
798
|
+
TOYON_REPO_URL: cmd.repo
|
|
799
|
+
});
|
|
800
|
+
must(await capture(bin, ["secrets", "import", ...app, "--stage"], secrets), "could not set the secrets");
|
|
801
|
+
const ips = (await capture(bin, ["ips", "list", ...app, "--json"])).out;
|
|
802
|
+
if (!/"Type":\s*"(shared_)?v4"/.test(ips))
|
|
803
|
+
must(await capture(bin, ["ips", "allocate-v4", "--shared", ...app]), "could not add an IPv4 address");
|
|
804
|
+
if (!/"Type":\s*"v6"/.test(ips))
|
|
805
|
+
must(await capture(bin, ["ips", "allocate-v6", ...app]), "could not add an IPv6 address");
|
|
806
|
+
const ctx = mkdtempSync(join5(tmpdir(), "toyon-machine-"));
|
|
807
|
+
try {
|
|
808
|
+
writeMachineContext(src, ctx);
|
|
809
|
+
writeFileSync3(join5(ctx, "fly.toml"), flyToml(cmd.name, region));
|
|
810
|
+
console.log("building the machine in your Fly builder and deploying it");
|
|
811
|
+
const deployArgs = ["deploy", ctx, "--config", join5(ctx, "fly.toml"), "--remote-only", "--ha=false", "--yes"];
|
|
812
|
+
if (!await show(bin, [...deployArgs, "--build-arg", `BUN_VERSION=${bunVersion}`])) {
|
|
813
|
+
throw new DeployError("the deploy failed; flyctl's output above says why");
|
|
814
|
+
}
|
|
815
|
+
} finally {
|
|
816
|
+
rmSync2(ctx, { recursive: true, force: true });
|
|
817
|
+
}
|
|
818
|
+
console.log("waiting for the machine to answer");
|
|
819
|
+
let up = false;
|
|
820
|
+
if (!await published(`${cmd.name}.fly.dev`))
|
|
821
|
+
console.log(`${cmd.name}.fly.dev is not in public DNS yet`);
|
|
822
|
+
for (let i = 0;i < 60 && !up; i++) {
|
|
823
|
+
up = await answers(`https://${cmd.name}.fly.dev/health`);
|
|
824
|
+
if (!up)
|
|
825
|
+
await Bun.sleep(2000);
|
|
826
|
+
}
|
|
827
|
+
if (!up)
|
|
828
|
+
console.log(`it has not answered yet; \`fly logs -a ${cmd.name}\` shows what it is doing`);
|
|
829
|
+
printLinks(cmd.name, token);
|
|
830
|
+
console.log("the volume holds the only copy of anything not pushed to a remote");
|
|
831
|
+
openUrl(launcherAddLink(`https://${cmd.name}.fly.dev`));
|
|
832
|
+
}
|
|
833
|
+
function printLinks(app, token) {
|
|
834
|
+
console.log(`
|
|
835
|
+
toyon: https://${app}.fly.dev/#token=${token}`);
|
|
836
|
+
console.log("the link grants a shell on that machine; keep it to yourself");
|
|
837
|
+
console.log(`add it to your list at toyon.cloud: ${launcherAddLink(`https://${app}.fly.dev`)}`);
|
|
838
|
+
}
|
|
839
|
+
async function destroy(cmd, bin) {
|
|
840
|
+
if (!cmd.yes) {
|
|
841
|
+
if (!process.stdin.isTTY)
|
|
842
|
+
throw new DeployError("destroy deletes the app and its volume; pass --yes to confirm it");
|
|
843
|
+
process.stdout.write(`this deletes ${cmd.name} and its volume, the only copy of anything not pushed. Type its name to confirm: `);
|
|
844
|
+
let typed = "";
|
|
845
|
+
for await (const line of console) {
|
|
846
|
+
typed = line;
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
if (typed.trim() !== cmd.name) {
|
|
850
|
+
console.log("nothing deleted");
|
|
851
|
+
return 0;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
must(await capture(bin, ["apps", "destroy", cmd.name, "--yes"]), "could not delete the app");
|
|
855
|
+
rmSync2(tokenFile2(cmd.name), { force: true });
|
|
856
|
+
console.log(`deleted ${cmd.name}`);
|
|
857
|
+
return 0;
|
|
858
|
+
}
|
|
859
|
+
async function deploy(cmd) {
|
|
860
|
+
const bin = Bun.which("fly") ?? Bun.which("flyctl");
|
|
861
|
+
if (!bin) {
|
|
862
|
+
console.error("toyon: flyctl is not installed; https://fly.io/docs/flyctl/install/ has it");
|
|
863
|
+
return 1;
|
|
864
|
+
}
|
|
865
|
+
try {
|
|
866
|
+
if (cmd.action === "up")
|
|
867
|
+
await up(cmd, bin);
|
|
868
|
+
else if (cmd.action === "destroy")
|
|
869
|
+
return await destroy(cmd, bin);
|
|
870
|
+
else if (existsSync5(tokenFile2(cmd.name))) {
|
|
871
|
+
printLinks(cmd.name, readFileSync3(tokenFile2(cmd.name), "utf8").trim());
|
|
872
|
+
} else {
|
|
873
|
+
throw new DeployError(`${cmd.name} was not deployed from this machine; its token is not in ${cloudDir}`);
|
|
874
|
+
}
|
|
875
|
+
return 0;
|
|
876
|
+
} catch (e) {
|
|
877
|
+
if (!(e instanceof DeployError))
|
|
878
|
+
throw e;
|
|
879
|
+
console.error(`toyon: ${e.message}`);
|
|
880
|
+
return 1;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/doctor.ts
|
|
885
|
+
import { existsSync as existsSync6, statSync } from "fs";
|
|
886
|
+
|
|
887
|
+
// src/sandboxDeps.ts
|
|
888
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
889
|
+
var LINUX_SANDBOX_TOOLS = [
|
|
890
|
+
{ exe: "bwrap", apt: "bubblewrap" },
|
|
891
|
+
{ exe: "socat", apt: "socat" }
|
|
892
|
+
];
|
|
893
|
+
function missingSandboxTools(path = process.env.PATH ?? "", platform = process.platform) {
|
|
894
|
+
if (platform !== "linux")
|
|
895
|
+
return [];
|
|
896
|
+
return LINUX_SANDBOX_TOOLS.filter((t) => !Bun.which(t.exe, { PATH: path })).map((t) => t.exe);
|
|
897
|
+
}
|
|
898
|
+
function sandboxAdvice(missing) {
|
|
899
|
+
const apt = LINUX_SANDBOX_TOOLS.map((t) => t.apt).join(" ");
|
|
900
|
+
return `Claude Code's sandbox on Linux needs ${missing.join(" and ")}; install with: sudo apt install ${apt}`;
|
|
901
|
+
}
|
|
902
|
+
function bwrapStartError(bwrap = Bun.which("bwrap")) {
|
|
903
|
+
if (!bwrap)
|
|
904
|
+
return null;
|
|
905
|
+
try {
|
|
906
|
+
const r = Bun.spawnSync([bwrap, ...BWRAP_PROBE_ARGS], { stdout: "ignore", stderr: "pipe" });
|
|
907
|
+
return r.exitCode === 0 ? null : r.stderr.toString().trim() || `bwrap exited ${r.exitCode}`;
|
|
908
|
+
} catch (e) {
|
|
909
|
+
return e.message;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
function userNamespacesRestricted(read = () => readFileSync4("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", "utf8")) {
|
|
913
|
+
try {
|
|
914
|
+
return read().trim() === "1";
|
|
915
|
+
} catch {
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// src/doctor.ts
|
|
921
|
+
function line(ok, label, detail) {
|
|
922
|
+
return { ok, label, detail };
|
|
923
|
+
}
|
|
924
|
+
async function toolVersion(cmd, args) {
|
|
925
|
+
try {
|
|
926
|
+
const p = Bun.spawn([cmd, ...args], { stdout: "pipe", stderr: "pipe" });
|
|
927
|
+
const out = await new Response(p.stdout).text();
|
|
928
|
+
await p.exited;
|
|
929
|
+
return p.exitCode === 0 ? out.trim().split(`
|
|
930
|
+
`)[0] : null;
|
|
931
|
+
} catch {
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
function handshake(token) {
|
|
936
|
+
return new Promise((resolve) => {
|
|
937
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?token=${token}`);
|
|
938
|
+
const timer = setTimeout(() => {
|
|
939
|
+
ws.close();
|
|
940
|
+
resolve("blocked");
|
|
941
|
+
}, 3000);
|
|
942
|
+
ws.onmessage = () => {
|
|
943
|
+
clearTimeout(timer);
|
|
944
|
+
ws.close();
|
|
945
|
+
resolve("ok");
|
|
946
|
+
};
|
|
947
|
+
ws.onclose = (ev) => {
|
|
948
|
+
clearTimeout(timer);
|
|
949
|
+
resolve(ev.code === WS_CLOSE_UNAUTHORIZED ? "unauthorized" : "blocked");
|
|
950
|
+
};
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
async function doctor() {
|
|
954
|
+
const lines = [];
|
|
955
|
+
lines.push(line(true, "cli", `toyon ${package_default.version}, bun ${Bun.version}, ${process.platform} ${process.arch}`));
|
|
956
|
+
lines.push(line(true, "home", home));
|
|
957
|
+
const git = await toolVersion("git", ["--version"]);
|
|
958
|
+
lines.push(git ? line(true, "git", git) : line(false, "git", "not found on PATH; Toyon needs git 2.x"));
|
|
959
|
+
if (process.platform === "linux") {
|
|
960
|
+
const missing = missingSandboxTools();
|
|
961
|
+
const blocked = missing.length === 0 ? bwrapStartError() : null;
|
|
962
|
+
lines.push(missing.length > 0 ? line(false, "sandbox", sandboxAdvice(missing)) : blocked ? line(false, "sandbox", bwrapBlockedAdvice(blocked, userNamespacesRestricted())) : line(true, "sandbox", "bubblewrap and socat found, and bubblewrap starts a sandbox"));
|
|
963
|
+
}
|
|
964
|
+
const h = await health();
|
|
965
|
+
if (!h) {
|
|
966
|
+
const pid = readPid();
|
|
967
|
+
const stale = pid !== null && !alive(pid);
|
|
968
|
+
lines.push(line(false, "daemon", stale ? `not running at ${base}; ${pid} in daemon.pid is dead, so it did not exit cleanly. \`toyon logs\` has its last words` : pid !== null ? `pid ${pid} is alive but nothing answers at ${base}; it may still be booting, or another program holds the port` : `not running at ${base}; \`toyon\` starts it`));
|
|
969
|
+
} else {
|
|
970
|
+
const version = h.version ?? "?";
|
|
971
|
+
lines.push(line(true, "daemon", `${version} at ${base}, pid ${h.pid ?? "?"}${h.branded ? ", also http://toyon.localhost" : ""}` + (h.lag ? `, event loop worst ${h.lag.max}ms${h.lag.maxCause ? ` (${h.lag.maxCause})` : ""}` : "")));
|
|
972
|
+
if (h.remote) {
|
|
973
|
+
lines.push(line(true, "remote", `https://${h.remote.host}/ through a TLS front on 127.0.0.1:${port}, previews at ${h.remote.previews}`));
|
|
974
|
+
}
|
|
975
|
+
if (h.worktrees) {
|
|
976
|
+
const { total, running } = h.worktrees;
|
|
977
|
+
lines.push(line(true, "running", `${running} of ${total} worktrees started; the rest start when opened`));
|
|
978
|
+
}
|
|
979
|
+
if (version !== package_default.version) {
|
|
980
|
+
lines.push(line(false, "version", `cli is ${package_default.version} and the daemon is ${version}; \`toyon stop\` then \`toyon\` restarts it`));
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
const token = readToken();
|
|
984
|
+
if (!token) {
|
|
985
|
+
lines.push(line(false, "token", `${tokenFile} is missing; the daemon writes it on first start`));
|
|
986
|
+
} else if (h) {
|
|
987
|
+
const r = await handshake(token);
|
|
988
|
+
lines.push(r === "ok" ? line(true, "websocket", "handshake accepted with the token on disk") : r === "unauthorized" ? line(false, "websocket", "the daemon refused the token on disk; it was started with another TOYON_HOME") : line(false, "websocket", "the daemon answers HTTP but the websocket never opened; a proxy or VPN in the way?"));
|
|
989
|
+
} else {
|
|
990
|
+
lines.push(line(true, "token", "present"));
|
|
991
|
+
}
|
|
992
|
+
if (h) {
|
|
993
|
+
try {
|
|
994
|
+
const r = await fetch(`${base}/`, { signal: AbortSignal.timeout(1000) });
|
|
995
|
+
const body = await r.text();
|
|
996
|
+
const built = r.ok && !body.startsWith("Toyon daemon running; shell not built");
|
|
997
|
+
lines.push(built ? line(true, "shell", "served") : line(false, "shell", "not built: run `bun run build` in the Toyon checkout"));
|
|
998
|
+
} catch (e) {
|
|
999
|
+
lines.push(line(false, "shell", `could not fetch: ${e.message}`));
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (existsSync6(logFile)) {
|
|
1003
|
+
const kb = Math.round(statSync(logFile).size / 1024);
|
|
1004
|
+
lines.push(line(true, "log", `${logFile} (${kb} KB)`));
|
|
1005
|
+
}
|
|
1006
|
+
const width = Math.max(...lines.map((l) => l.label.length));
|
|
1007
|
+
for (const l of lines) {
|
|
1008
|
+
console.log(`${l.ok ? "ok " : "FAIL"} ${l.label.padEnd(width)} ${l.detail}`);
|
|
1009
|
+
}
|
|
1010
|
+
return lines.every((l) => l.ok) ? 0 : 1;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// src/logs.ts
|
|
1014
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5, statSync as statSync2 } from "fs";
|
|
1015
|
+
async function logs(opts) {
|
|
1016
|
+
if (!existsSync7(logFile)) {
|
|
1017
|
+
console.log(`no log yet at ${logFile}; it appears once \`toyon\` has started the daemon`);
|
|
1018
|
+
return opts.follow ? 0 : 1;
|
|
1019
|
+
}
|
|
1020
|
+
const text = readFileSync5(logFile, "utf8");
|
|
1021
|
+
const all = text.split(`
|
|
1022
|
+
`);
|
|
1023
|
+
if (all.at(-1) === "")
|
|
1024
|
+
all.pop();
|
|
1025
|
+
const tail = opts.lines === 0 ? [] : all.slice(-opts.lines);
|
|
1026
|
+
if (tail.length > 0)
|
|
1027
|
+
process.stdout.write(`${tail.join(`
|
|
1028
|
+
`)}
|
|
1029
|
+
`);
|
|
1030
|
+
if (!opts.follow)
|
|
1031
|
+
return 0;
|
|
1032
|
+
let offset = statSync2(logFile).size;
|
|
1033
|
+
for (;; ) {
|
|
1034
|
+
await Bun.sleep(300);
|
|
1035
|
+
if (!existsSync7(logFile))
|
|
1036
|
+
continue;
|
|
1037
|
+
const size = statSync2(logFile).size;
|
|
1038
|
+
if (size < offset)
|
|
1039
|
+
offset = 0;
|
|
1040
|
+
if (size === offset)
|
|
1041
|
+
continue;
|
|
1042
|
+
const chunk = readFileSync5(logFile, { encoding: "utf8", flag: "r" }).slice(offset);
|
|
1043
|
+
offset = size;
|
|
1044
|
+
process.stdout.write(chunk);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// src/remote.ts
|
|
1049
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync6, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
1050
|
+
|
|
1051
|
+
// src/tailscale.ts
|
|
1052
|
+
class TailscaleError extends Error {
|
|
1053
|
+
}
|
|
1054
|
+
function tailscaleCli(bin = Bun.which("tailscale")) {
|
|
1055
|
+
return async (args) => {
|
|
1056
|
+
if (bin === null) {
|
|
1057
|
+
throw new TailscaleError("Tailscale is not installed here (no tailscale on PATH); https://tailscale.com/download");
|
|
1058
|
+
}
|
|
1059
|
+
const p = Bun.spawn([bin, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
1060
|
+
const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
1061
|
+
return { ok: await p.exited === 0, out, err };
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
function entries(daemonPort) {
|
|
1065
|
+
const list = [{ port: 443, target: `http://127.0.0.1:${daemonPort}` }];
|
|
1066
|
+
for (let p = PREVIEW_PORTS.from;p <= PREVIEW_PORTS.to; p++)
|
|
1067
|
+
list.push({ port: p, target: `http://127.0.0.1:${p}` });
|
|
1068
|
+
return list;
|
|
1069
|
+
}
|
|
1070
|
+
var serveOn = (e) => ["serve", "--bg", `--https=${e.port}`, e.target];
|
|
1071
|
+
var serveOff = (e) => ["serve", `--https=${e.port}`, "off"];
|
|
1072
|
+
var lastLine = (r) => (r.err.trim() || r.out.trim()).split(`
|
|
1073
|
+
`).at(-1) ?? "tailscale failed";
|
|
1074
|
+
var notRunning = (text) => /failed to connect|is Tailscale running/i.test(text);
|
|
1075
|
+
var NOT_RUNNING = "Tailscale is not running here; start the Tailscale app or the tailscaled service, then run `tailscale up`";
|
|
1076
|
+
function tailnetName(json) {
|
|
1077
|
+
let st;
|
|
1078
|
+
try {
|
|
1079
|
+
st = JSON.parse(json);
|
|
1080
|
+
} catch {
|
|
1081
|
+
throw new TailscaleError(notRunning(json) ? NOT_RUNNING : "tailscale status --json printed nothing Toyon can read");
|
|
1082
|
+
}
|
|
1083
|
+
switch (st.BackendState) {
|
|
1084
|
+
case "Running":
|
|
1085
|
+
break;
|
|
1086
|
+
case "NeedsLogin":
|
|
1087
|
+
throw new TailscaleError("Tailscale is signed out; run `tailscale up`, then this again");
|
|
1088
|
+
case "NeedsMachineAuth":
|
|
1089
|
+
throw new TailscaleError("this machine is waiting to be approved in the Tailscale admin console");
|
|
1090
|
+
case "Stopped":
|
|
1091
|
+
throw new TailscaleError("Tailscale is disconnected; run `tailscale up`, then this again");
|
|
1092
|
+
default:
|
|
1093
|
+
throw new TailscaleError(`Tailscale is not connected yet (${st.BackendState ?? "no state"}); try again shortly`);
|
|
1094
|
+
}
|
|
1095
|
+
const name = (st.Self?.DNSName ?? "").replace(/\.$/, "").toLowerCase();
|
|
1096
|
+
if (!name || st.CurrentTailnet?.MagicDNSEnabled === false) {
|
|
1097
|
+
throw new TailscaleError("MagicDNS is off for this tailnet; turn it on under DNS in the Tailscale admin console");
|
|
1098
|
+
}
|
|
1099
|
+
if (!(st.CertDomains ?? []).some((d) => d.toLowerCase() === name)) {
|
|
1100
|
+
throw new TailscaleError("HTTPS certificates are off for this tailnet; turn them on under DNS in the Tailscale admin console");
|
|
1101
|
+
}
|
|
1102
|
+
return name;
|
|
1103
|
+
}
|
|
1104
|
+
function parseServeConfig(json) {
|
|
1105
|
+
const text = json.trim();
|
|
1106
|
+
if (text === "" || text === "null")
|
|
1107
|
+
return {};
|
|
1108
|
+
try {
|
|
1109
|
+
return JSON.parse(text) ?? {};
|
|
1110
|
+
} catch {
|
|
1111
|
+
throw new TailscaleError(notRunning(text) ? NOT_RUNNING : "tailscale serve status --json printed nothing Toyon can read");
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
var bare = (url) => url.replace(/\/+$/, "");
|
|
1115
|
+
function holder(config, name, e) {
|
|
1116
|
+
const tcp = config.TCP?.[String(e.port)];
|
|
1117
|
+
if (!tcp)
|
|
1118
|
+
return { kind: "free" };
|
|
1119
|
+
if (tcp.TCPForward)
|
|
1120
|
+
return { kind: "other", what: `TCP forwarding to ${tcp.TCPForward}` };
|
|
1121
|
+
const webs = Object.entries(config.Web ?? {}).filter(([hp]) => name === null ? hp.endsWith(`:${e.port}`) : hp === `${name}:${e.port}`);
|
|
1122
|
+
const handlers = webs.flatMap(([, w]) => Object.entries(w?.Handlers ?? {}));
|
|
1123
|
+
if (handlers.length === 0)
|
|
1124
|
+
return { kind: "other", what: "a serve entry on another name" };
|
|
1125
|
+
const [mount, h] = handlers[0];
|
|
1126
|
+
if (handlers.length === 1 && mount === "/" && h?.Proxy && bare(h.Proxy) === e.target)
|
|
1127
|
+
return { kind: "toyon" };
|
|
1128
|
+
return { kind: "other", what: h?.Proxy ? `a proxy to ${h.Proxy}` : "files or text" };
|
|
1129
|
+
}
|
|
1130
|
+
async function readConfig(ts) {
|
|
1131
|
+
const r = await ts(["serve", "status", "--json"]);
|
|
1132
|
+
if (!r.ok)
|
|
1133
|
+
throw new TailscaleError(notRunning(r.err + r.out) ? NOT_RUNNING : `tailscale serve status: ${lastLine(r)}`);
|
|
1134
|
+
return parseServeConfig(r.out);
|
|
1135
|
+
}
|
|
1136
|
+
async function serveTailnet(ts, daemonPort) {
|
|
1137
|
+
const st = await ts(["status", "--json"]);
|
|
1138
|
+
if (!st.ok && notRunning(st.err + st.out))
|
|
1139
|
+
throw new TailscaleError(NOT_RUNNING);
|
|
1140
|
+
const name = tailnetName(st.out || st.err);
|
|
1141
|
+
const config = await readConfig(ts);
|
|
1142
|
+
const want = entries(daemonPort).map((e) => ({ e, h: holder(config, name, e) }));
|
|
1143
|
+
const taken = want.flatMap(({ e, h }) => h.kind === "other" ? [`https ${e.port} (${h.what})`] : []);
|
|
1144
|
+
if (taken.length > 0) {
|
|
1145
|
+
throw new TailscaleError(`tailscale serve already uses ${taken.join(", ")}; Toyon changed nothing. Free those with \`tailscale serve --https=<port> off\`, or use \`toyon remote ${name} --ports\` with a front of your own`);
|
|
1146
|
+
}
|
|
1147
|
+
for (const { e, h } of want) {
|
|
1148
|
+
if (h.kind === "toyon")
|
|
1149
|
+
continue;
|
|
1150
|
+
const r = await ts(serveOn(e));
|
|
1151
|
+
if (!r.ok) {
|
|
1152
|
+
throw new TailscaleError(`tailscale serve --https=${e.port}: ${lastLine(r)}. \`toyon remote off\` removes the entries set so far`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return name;
|
|
1156
|
+
}
|
|
1157
|
+
async function unserveTailnet(ts, daemonPort) {
|
|
1158
|
+
const config = await readConfig(ts);
|
|
1159
|
+
let removed = 0;
|
|
1160
|
+
for (const e of entries(daemonPort)) {
|
|
1161
|
+
if (holder(config, null, e).kind !== "toyon")
|
|
1162
|
+
continue;
|
|
1163
|
+
const r = await ts(serveOff(e));
|
|
1164
|
+
if (!r.ok)
|
|
1165
|
+
throw new TailscaleError(`tailscale serve --https=${e.port} off: ${lastLine(r)}`);
|
|
1166
|
+
removed++;
|
|
1167
|
+
}
|
|
1168
|
+
return removed;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// src/remote.ts
|
|
1172
|
+
function saved() {
|
|
1173
|
+
if (!existsSync8(remoteFile))
|
|
1174
|
+
return null;
|
|
1175
|
+
return parseRemote(readFileSync6(remoteFile, "utf8"));
|
|
1176
|
+
}
|
|
1177
|
+
var same = (a, b) => (a?.host ?? null) === (b?.host ?? null) && (a?.previews ?? null) === (b?.previews ?? null);
|
|
1178
|
+
var ports = `${PREVIEW_PORTS.from}-${PREVIEW_PORTS.to}`;
|
|
1179
|
+
function describe(r) {
|
|
1180
|
+
const at = addressedByPort(r.previews) ? `${r.previews}, ports ${ports}` : r.previews;
|
|
1181
|
+
return `remote access: https://${r.host}/, previews at ${at}`;
|
|
1182
|
+
}
|
|
1183
|
+
function save(r) {
|
|
1184
|
+
mkdirSync5(home, { recursive: true });
|
|
1185
|
+
writeFileSync4(remoteFile, `${JSON.stringify(r, null, 2)}
|
|
1186
|
+
`);
|
|
1187
|
+
console.log(describe(r));
|
|
1188
|
+
}
|
|
1189
|
+
async function remote(cmd) {
|
|
1190
|
+
const h = await health();
|
|
1191
|
+
if (cmd.to === null && !cmd.tailscale) {
|
|
1192
|
+
const r = saved();
|
|
1193
|
+
console.log(r ? describe(r) : "remote access is off; `toyon remote <name>` turns it on");
|
|
1194
|
+
if (h && !same(h.remote, r))
|
|
1195
|
+
console.log("the running daemon started before this; `toyon stop` then `toyon` applies it");
|
|
1196
|
+
return 0;
|
|
1197
|
+
}
|
|
1198
|
+
let r = null;
|
|
1199
|
+
if (cmd.tailscale) {
|
|
1200
|
+
let name;
|
|
1201
|
+
try {
|
|
1202
|
+
name = await serveTailnet(tailscaleCli(), port);
|
|
1203
|
+
} catch (e) {
|
|
1204
|
+
if (!(e instanceof TailscaleError))
|
|
1205
|
+
throw e;
|
|
1206
|
+
console.error(`toyon: ${e.message}`);
|
|
1207
|
+
return 1;
|
|
1208
|
+
}
|
|
1209
|
+
r = { host: name, previews: portPreviews(name) };
|
|
1210
|
+
save(r);
|
|
1211
|
+
console.log(`tailscale serve sends https://${name}/ to 127.0.0.1:${port}, and each of ports ${ports} to the`);
|
|
1212
|
+
console.log("same port on 127.0.0.1; only devices on your tailnet reach them");
|
|
1213
|
+
} else if (cmd.to === "off") {
|
|
1214
|
+
const was = saved();
|
|
1215
|
+
rmSync3(remoteFile, { force: true });
|
|
1216
|
+
console.log("remote access is off");
|
|
1217
|
+
try {
|
|
1218
|
+
const removed = await unserveTailnet(tailscaleCli(), port);
|
|
1219
|
+
if (removed > 0)
|
|
1220
|
+
console.log(`removed Toyon's ${removed} tailscale serve entries`);
|
|
1221
|
+
} catch (e) {
|
|
1222
|
+
if (!(e instanceof TailscaleError))
|
|
1223
|
+
throw e;
|
|
1224
|
+
if (was?.host.endsWith(".ts.net"))
|
|
1225
|
+
console.error(`toyon: could not remove tailscale serve entries: ${e.message}`);
|
|
1226
|
+
}
|
|
1227
|
+
} else if (cmd.to !== null) {
|
|
1228
|
+
r = { host: cmd.to, previews: cmd.ports ? portPreviews(cmd.to) : hostPreviews(cmd.to) };
|
|
1229
|
+
save(r);
|
|
1230
|
+
if (addressedByPort(r.previews)) {
|
|
1231
|
+
console.log(`point a TLS front for ${r.host} at 127.0.0.1:${port} on 443, and each of ports ${ports} at`);
|
|
1232
|
+
console.log("the same port on 127.0.0.1");
|
|
1233
|
+
} else {
|
|
1234
|
+
console.log(`point a TLS front for ${r.host} and *.${r.host} at 127.0.0.1:${port}. With Caddy:
|
|
1235
|
+
`);
|
|
1236
|
+
console.log(` ${r.host}, *.${r.host} {
|
|
1237
|
+
reverse_proxy 127.0.0.1:${port}
|
|
1238
|
+
}
|
|
1239
|
+
`);
|
|
1240
|
+
console.log("the wildcard certificate needs Caddy's DNS challenge, through your DNS provider's module");
|
|
1241
|
+
}
|
|
1242
|
+
console.log("Toyon refuses the name over plain http: the token in the link grants a shell on this machine");
|
|
1243
|
+
}
|
|
1244
|
+
if (r) {
|
|
1245
|
+
const add = launcherAddLink(`https://${r.host}`);
|
|
1246
|
+
console.log(`add it to your list at toyon.cloud: ${add}`);
|
|
1247
|
+
openUrl(add);
|
|
1248
|
+
}
|
|
1249
|
+
if (h && !same(h.remote, r))
|
|
1250
|
+
console.log("the daemon reads this at start; `toyon stop` then `toyon` applies it");
|
|
1251
|
+
return 0;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// src/stop.ts
|
|
1255
|
+
import { rmSync as rmSync4 } from "fs";
|
|
1256
|
+
var WAIT_MS = 1e4;
|
|
1257
|
+
async function stop() {
|
|
1258
|
+
const h = await health();
|
|
1259
|
+
let pid = h?.pid ?? readPid();
|
|
1260
|
+
if (pid !== null && !alive(pid)) {
|
|
1261
|
+
rmSync4(pidFile, { force: true });
|
|
1262
|
+
pid = null;
|
|
1263
|
+
}
|
|
1264
|
+
if (pid === null) {
|
|
1265
|
+
console.log("Toyon is not running");
|
|
1266
|
+
return 0;
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
process.kill(pid, "SIGTERM");
|
|
1270
|
+
} catch (e) {
|
|
1271
|
+
console.error(`could not signal pid ${pid}: ${e.message}`);
|
|
1272
|
+
return 1;
|
|
1273
|
+
}
|
|
1274
|
+
const deadline = Date.now() + WAIT_MS;
|
|
1275
|
+
while (alive(pid) && Date.now() < deadline)
|
|
1276
|
+
await Bun.sleep(100);
|
|
1277
|
+
if (alive(pid)) {
|
|
1278
|
+
process.kill(pid, "SIGKILL");
|
|
1279
|
+
console.error(`daemon (pid ${pid}) did not exit in ${WAIT_MS / 1000}s; killed. Dev servers it ran may still be up.`);
|
|
1280
|
+
rmSync4(pidFile, { force: true });
|
|
1281
|
+
return 1;
|
|
1282
|
+
}
|
|
1283
|
+
console.log(`stopped Toyon (pid ${pid})`);
|
|
1284
|
+
return 0;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// src/restart.ts
|
|
1288
|
+
async function restart() {
|
|
1289
|
+
const before = await health();
|
|
1290
|
+
if (before) {
|
|
1291
|
+
const code = await stop();
|
|
1292
|
+
if (code !== 0)
|
|
1293
|
+
return code;
|
|
1294
|
+
} else {
|
|
1295
|
+
console.log("Toyon was not running");
|
|
1296
|
+
}
|
|
1297
|
+
if (!await startDaemon()) {
|
|
1298
|
+
console.error(`daemon failed to start; \`toyon logs\` shows why (${logFile})`);
|
|
1299
|
+
return 1;
|
|
1300
|
+
}
|
|
1301
|
+
const after = await health();
|
|
1302
|
+
console.log(`started Toyon${after?.pid !== undefined ? ` (pid ${after.pid})` : ""}`);
|
|
1303
|
+
return 0;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// src/uninstall.ts
|
|
1307
|
+
import { existsSync as existsSync9, readFileSync as readFileSync7, rmSync as rmSync5 } from "fs";
|
|
1308
|
+
import { homedir as homedir3 } from "os";
|
|
1309
|
+
import { join as join6 } from "path";
|
|
1310
|
+
function readState() {
|
|
1311
|
+
const file = join6(home, DAEMON_FILES.state);
|
|
1312
|
+
if (!existsSync9(file))
|
|
1313
|
+
return {};
|
|
1314
|
+
try {
|
|
1315
|
+
return JSON.parse(readFileSync7(file, "utf8"));
|
|
1316
|
+
} catch {
|
|
1317
|
+
return {};
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
async function git(cwd, ...args) {
|
|
1321
|
+
try {
|
|
1322
|
+
const p = Bun.spawn(["git", ...args], { cwd, stdout: "ignore", stderr: "pipe" });
|
|
1323
|
+
await p.exited;
|
|
1324
|
+
return p.exitCode === 0;
|
|
1325
|
+
} catch {
|
|
1326
|
+
return false;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
function confirm(question) {
|
|
1330
|
+
process.stdout.write(`${question} [y/N] `);
|
|
1331
|
+
return new Promise((resolve) => {
|
|
1332
|
+
process.stdin.once("data", (chunk) => resolve(String(chunk).trim().toLowerCase().startsWith("y")));
|
|
1333
|
+
process.stdin.once("end", () => resolve(false));
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
async function uninstall(opts) {
|
|
1337
|
+
const state = readState();
|
|
1338
|
+
const repos = state.repos ?? [];
|
|
1339
|
+
const worktrees = (state.worktrees ?? []).filter((w) => w.kind !== "main");
|
|
1340
|
+
const app = join6(homedir3(), "Applications", "Toyon.app");
|
|
1341
|
+
const branches = worktrees.map((w) => w.branch).filter((b) => !!b && b.startsWith("toyon/"));
|
|
1342
|
+
console.log("toyon uninstall removes:");
|
|
1343
|
+
console.log(` ${home} (state, transcripts, agent adapters, ${worktrees.length} worktree director${worktrees.length === 1 ? "y" : "ies"})`);
|
|
1344
|
+
for (const w of worktrees) {
|
|
1345
|
+
const repo = repos.find((r) => r.id === w.repoId);
|
|
1346
|
+
console.log(` ${w.path} (worktree of ${repo?.path ?? "?"})`);
|
|
1347
|
+
}
|
|
1348
|
+
if (existsSync9(app))
|
|
1349
|
+
console.log(` ${app}`);
|
|
1350
|
+
console.log("and stops the daemon if it is running.");
|
|
1351
|
+
console.log("it keeps: your repos, every branch Toyon made" + (branches.length ? ` (${branches.length} under toyon/)` : "") + ", and Toyon's settings in each repo (.toyon/ or toyon.json).");
|
|
1352
|
+
if (!opts.yes && !await confirm("continue?")) {
|
|
1353
|
+
console.log("nothing removed");
|
|
1354
|
+
return 1;
|
|
1355
|
+
}
|
|
1356
|
+
if (await health() || readPid() !== null && alive(readPid()))
|
|
1357
|
+
await stop();
|
|
1358
|
+
for (const w of worktrees) {
|
|
1359
|
+
const repo = repos.find((r) => r.id === w.repoId);
|
|
1360
|
+
if (repo && existsSync9(repo.path)) {
|
|
1361
|
+
const ok = await git(repo.path, "worktree", "remove", "--force", w.path);
|
|
1362
|
+
if (!ok && existsSync9(w.path))
|
|
1363
|
+
rmSync5(w.path, { recursive: true, force: true });
|
|
1364
|
+
await git(repo.path, "worktree", "prune");
|
|
1365
|
+
} else if (existsSync9(w.path)) {
|
|
1366
|
+
rmSync5(w.path, { recursive: true, force: true });
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
rmSync5(home, { recursive: true, force: true });
|
|
1370
|
+
if (existsSync9(app))
|
|
1371
|
+
rmSync5(app, { recursive: true, force: true });
|
|
1372
|
+
console.log("removed. `npm uninstall -g toyon` removes the command itself.");
|
|
1373
|
+
return 0;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// src/cli.ts
|
|
1377
|
+
async function open(cmd) {
|
|
1378
|
+
const explicit = cmd.path !== null;
|
|
1379
|
+
const target = resolve(cmd.path ?? process.cwd());
|
|
1380
|
+
const register = explicit || existsSync10(join7(target, ".git"));
|
|
1381
|
+
if (process.platform === "linux") {
|
|
1382
|
+
const missing = missingSandboxTools();
|
|
1383
|
+
const blocked = missing.length === 0 ? bwrapStartError() : null;
|
|
1384
|
+
if (missing.length > 0)
|
|
1385
|
+
console.log(sandboxAdvice(missing));
|
|
1386
|
+
else if (blocked)
|
|
1387
|
+
console.log(bwrapBlockedAdvice(blocked, userNamespacesRestricted()));
|
|
1388
|
+
}
|
|
1389
|
+
if (!await health()) {
|
|
1390
|
+
console.log("starting Toyon daemon\u2026");
|
|
1391
|
+
if (!await startDaemon()) {
|
|
1392
|
+
console.error(`daemon failed to start; \`toyon logs\` shows why (${logFile})`);
|
|
1393
|
+
return 1;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
const token = readToken();
|
|
1397
|
+
if (!token) {
|
|
1398
|
+
console.error("daemon token missing; `toyon doctor` says where it looked");
|
|
1399
|
+
return 1;
|
|
1400
|
+
}
|
|
1401
|
+
if (register) {
|
|
1402
|
+
const res = await fetch(`${base}/register`, {
|
|
1403
|
+
method: "POST",
|
|
1404
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
1405
|
+
body: JSON.stringify({ path: target })
|
|
1406
|
+
});
|
|
1407
|
+
if (!res.ok) {
|
|
1408
|
+
console.error(`could not open ${target}: ${await res.text()}`);
|
|
1409
|
+
if (explicit || !cmd.app && !cmd.installApp)
|
|
1410
|
+
return 1;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
const branded = (await health())?.branded === true;
|
|
1414
|
+
const url = shellUrl(token, branded);
|
|
1415
|
+
const appUrl = `http://toyon.localhost:${port}/#token=${token}`;
|
|
1416
|
+
if (cmd.installApp) {
|
|
1417
|
+
installApp(appUrl);
|
|
1418
|
+
} else if (cmd.app) {
|
|
1419
|
+
console.log(`toyon: app window (${appUrl.split("#")[0]})`);
|
|
1420
|
+
if (!openAppWindow(appUrl)) {
|
|
1421
|
+
console.log("no Chromium browser found; opening in default browser");
|
|
1422
|
+
openUrl(url);
|
|
1423
|
+
}
|
|
1424
|
+
} else {
|
|
1425
|
+
console.log(`toyon: ${url}`);
|
|
1426
|
+
openUrl(url);
|
|
1427
|
+
}
|
|
1428
|
+
const remote = (await health())?.remote;
|
|
1429
|
+
if (remote)
|
|
1430
|
+
console.log(`toyon: remote at https://${remote.host}/#token=${token}`);
|
|
1431
|
+
return 0;
|
|
1432
|
+
}
|
|
1433
|
+
async function run(cmd) {
|
|
1434
|
+
switch (cmd.kind) {
|
|
1435
|
+
case "help":
|
|
1436
|
+
process.stdout.write(HELP);
|
|
1437
|
+
return 0;
|
|
1438
|
+
case "error":
|
|
1439
|
+
console.error(`toyon: ${cmd.message}
|
|
1440
|
+
`);
|
|
1441
|
+
process.stderr.write(HELP);
|
|
1442
|
+
return 2;
|
|
1443
|
+
case "version": {
|
|
1444
|
+
const h = await health();
|
|
1445
|
+
console.log(h ? `toyon ${package_default.version} (daemon ${h.version ?? "?"} running)` : `toyon ${package_default.version}`);
|
|
1446
|
+
return 0;
|
|
1447
|
+
}
|
|
1448
|
+
case "stop":
|
|
1449
|
+
return stop();
|
|
1450
|
+
case "restart":
|
|
1451
|
+
return restart();
|
|
1452
|
+
case "doctor":
|
|
1453
|
+
return doctor();
|
|
1454
|
+
case "logs":
|
|
1455
|
+
return logs(cmd);
|
|
1456
|
+
case "uninstall":
|
|
1457
|
+
return uninstall(cmd);
|
|
1458
|
+
case "remote":
|
|
1459
|
+
return remote(cmd);
|
|
1460
|
+
case "deploy":
|
|
1461
|
+
return deploy(cmd);
|
|
1462
|
+
case "open":
|
|
1463
|
+
return open(cmd);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
process.exitCode = await run(parseArgs(process.argv.slice(2)));
|