@fevernova90/beckon 0.1.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 +40 -0
- package/dist/bin.js +419 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# beckon
|
|
2
|
+
|
|
3
|
+
> AI-native local tunnels on your own domain. `beckon up` exposes your local dev ports to stable,
|
|
4
|
+
> deterministic public HTTPS URLs — driven from one command (or your AI coding agent).
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm i -g @fevernova90/beckon
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
**Prerequisites:** Node ≥ 20 and a system `ssh` client (built in on macOS/Linux). beckon connects to
|
|
11
|
+
a [sish](https://github.com/antoniomika/sish) edge you control.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
beckon login # authenticate in the browser (OAuth); stores a token + your slug
|
|
17
|
+
beckon up # read ./tunnel.yaml, open tunnels, print live URLs as JSON
|
|
18
|
+
beckon status # show live tunnels
|
|
19
|
+
beckon down # tear down this repo's tunnels
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`beckon login <token>` also works for a pasted token (CI / headless). Override the edge with
|
|
23
|
+
`--edge`, `--ssh-port`, `--base-domain`, or pin a transport key with `--identity`.
|
|
24
|
+
|
|
25
|
+
### `tunnel.yaml`
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
app: themebuilder
|
|
29
|
+
tunnels:
|
|
30
|
+
web: 5173 # → themebuilder-web-<slug>.<base-domain>
|
|
31
|
+
api: 3000
|
|
32
|
+
hook: 4000 # webhook receiver — bodies + signature headers pass through byte-exact
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Hostnames are a pure function of `{app}-{role}-{slug}`, so a URL is predictable before the tunnel
|
|
36
|
+
starts — write it into `.env` / Clerk / Stripe once.
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
MIT
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bin.ts
|
|
4
|
+
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { join as join2 } from "path";
|
|
7
|
+
import { spawn as spawn2 } from "child_process";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
|
|
10
|
+
// ../core/src/labels.ts
|
|
11
|
+
var LABEL_PART = /^[a-z0-9-]+$/;
|
|
12
|
+
function assertDnsLabelPart(field, value) {
|
|
13
|
+
if (value.length === 0) {
|
|
14
|
+
throw new Error(`beckon: ${field} must not be empty`);
|
|
15
|
+
}
|
|
16
|
+
if (!LABEL_PART.test(value)) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`beckon: invalid ${field} "${value}" \u2014 must contain only lowercase a-z, 0-9, or hyphen`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
if (value.startsWith("-") || value.endsWith("-")) {
|
|
22
|
+
throw new Error(`beckon: invalid ${field} "${value}" \u2014 must not start or end with a hyphen`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ../core/src/naming.ts
|
|
27
|
+
var DEFAULT_BASE_DOMAIN = "beckon.gotelz.com";
|
|
28
|
+
function hostnameFor(name, opts) {
|
|
29
|
+
assertDnsLabelPart("app", name.app);
|
|
30
|
+
assertDnsLabelPart("role", name.role);
|
|
31
|
+
assertDnsLabelPart("slug", name.slug);
|
|
32
|
+
const label = `${name.app}-${name.role}-${name.slug}`;
|
|
33
|
+
if (label.length > 63) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`beckon: hostname label "${label}" is ${label.length} chars \u2014 DNS labels are limited to 63`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const baseDomain = opts?.baseDomain ?? DEFAULT_BASE_DOMAIN;
|
|
39
|
+
return `${label}.${baseDomain}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ../core/src/config.ts
|
|
43
|
+
import { parse } from "yaml";
|
|
44
|
+
function isRecord(value) {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
function parseTunnelConfig(source) {
|
|
48
|
+
const raw = parse(source);
|
|
49
|
+
if (!isRecord(raw)) {
|
|
50
|
+
throw new Error("beckon: tunnel.yaml must be a mapping with `app` and `tunnels`");
|
|
51
|
+
}
|
|
52
|
+
const app = raw["app"];
|
|
53
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
54
|
+
throw new Error("beckon: tunnel.yaml is missing a string `app` name");
|
|
55
|
+
}
|
|
56
|
+
assertDnsLabelPart("app", app);
|
|
57
|
+
const tunnelsRaw = raw["tunnels"];
|
|
58
|
+
if (!isRecord(tunnelsRaw) || Object.keys(tunnelsRaw).length === 0) {
|
|
59
|
+
throw new Error("beckon: tunnel.yaml must define at least one entry under `tunnels`");
|
|
60
|
+
}
|
|
61
|
+
const tunnels = Object.entries(tunnelsRaw).map(([role, port]) => {
|
|
62
|
+
assertDnsLabelPart("role", role);
|
|
63
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`beckon: tunnel "${role}" has an invalid port ${JSON.stringify(port)} \u2014 must be an integer 1..65535`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return { role, port };
|
|
69
|
+
});
|
|
70
|
+
return { app, tunnels };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ../core/src/plan.ts
|
|
74
|
+
function planTunnels(config, opts) {
|
|
75
|
+
return config.tunnels.map((tunnel) => {
|
|
76
|
+
const hostname = hostnameFor(
|
|
77
|
+
{ app: config.app, role: tunnel.role, slug: opts.slug },
|
|
78
|
+
{ baseDomain: opts.baseDomain }
|
|
79
|
+
);
|
|
80
|
+
return { role: tunnel.role, port: tunnel.port, hostname, url: `https://${hostname}` };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ../core/src/token.ts
|
|
85
|
+
import { randomBytes, createHash, timingSafeEqual } from "crypto";
|
|
86
|
+
|
|
87
|
+
// src/settings.ts
|
|
88
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
89
|
+
import { join } from "path";
|
|
90
|
+
var FILE = "config.json";
|
|
91
|
+
function settingsDir(env, home) {
|
|
92
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
93
|
+
const base = xdg && xdg.length > 0 ? xdg : join(home, ".config");
|
|
94
|
+
return join(base, "beckon");
|
|
95
|
+
}
|
|
96
|
+
function readSettings(dir) {
|
|
97
|
+
const path = join(dir, FILE);
|
|
98
|
+
if (!existsSync(path)) {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
102
|
+
}
|
|
103
|
+
function writeSettings(dir, settings) {
|
|
104
|
+
mkdirSync(dir, { recursive: true });
|
|
105
|
+
writeFileSync(join(dir, FILE), `${JSON.stringify(settings, null, 2)}
|
|
106
|
+
`, { mode: 384 });
|
|
107
|
+
}
|
|
108
|
+
function resolveToken(env, settings) {
|
|
109
|
+
return env["BECKON_TOKEN"] ?? settings.token;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/state.ts
|
|
113
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
|
|
114
|
+
import { dirname } from "path";
|
|
115
|
+
function readState(path) {
|
|
116
|
+
if (!existsSync2(path)) {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
return JSON.parse(readFileSync2(path, "utf8"));
|
|
120
|
+
}
|
|
121
|
+
function writeState(path, state) {
|
|
122
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
123
|
+
writeFileSync2(path, `${JSON.stringify(state, null, 2)}
|
|
124
|
+
`, { mode: 384 });
|
|
125
|
+
}
|
|
126
|
+
function putApp(state, entry) {
|
|
127
|
+
return { ...state, [entry.app]: entry };
|
|
128
|
+
}
|
|
129
|
+
function removeApp(state, app) {
|
|
130
|
+
const next = { ...state };
|
|
131
|
+
delete next[app];
|
|
132
|
+
return next;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/ssh.ts
|
|
136
|
+
function buildSshArgs(conn, forwards) {
|
|
137
|
+
const args = ["-p", String(conn.port)];
|
|
138
|
+
if (conn.identityFile) {
|
|
139
|
+
args.push("-i", conn.identityFile, "-o", "IdentitiesOnly=yes");
|
|
140
|
+
}
|
|
141
|
+
args.push(
|
|
142
|
+
"-o",
|
|
143
|
+
"ExitOnForwardFailure=yes",
|
|
144
|
+
"-o",
|
|
145
|
+
"ServerAliveInterval=15",
|
|
146
|
+
"-o",
|
|
147
|
+
"ServerAliveCountMax=3",
|
|
148
|
+
"-o",
|
|
149
|
+
"StrictHostKeyChecking=accept-new",
|
|
150
|
+
"-o",
|
|
151
|
+
"BatchMode=yes"
|
|
152
|
+
);
|
|
153
|
+
for (const forward of forwards) {
|
|
154
|
+
args.push("-R", `${forward.label}:${forward.remotePort}:localhost:${forward.localPort}`);
|
|
155
|
+
}
|
|
156
|
+
args.push(`${conn.token}@${conn.edgeHost}`);
|
|
157
|
+
return args;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/supervisor.ts
|
|
161
|
+
var defaultSchedule = (cb, ms) => {
|
|
162
|
+
setTimeout(cb, ms);
|
|
163
|
+
};
|
|
164
|
+
function supervise(opts) {
|
|
165
|
+
const schedule = opts.schedule ?? defaultSchedule;
|
|
166
|
+
const backoff = opts.backoffMs ?? 1e3;
|
|
167
|
+
let stopped = false;
|
|
168
|
+
let restarts = 0;
|
|
169
|
+
let current = null;
|
|
170
|
+
function start() {
|
|
171
|
+
current = opts.spawn();
|
|
172
|
+
current.once("exit", () => {
|
|
173
|
+
if (stopped) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
restarts += 1;
|
|
177
|
+
opts.onRestart?.(restarts);
|
|
178
|
+
schedule(start, backoff);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
start();
|
|
182
|
+
return {
|
|
183
|
+
stop() {
|
|
184
|
+
stopped = true;
|
|
185
|
+
current?.kill();
|
|
186
|
+
},
|
|
187
|
+
get restarts() {
|
|
188
|
+
return restarts;
|
|
189
|
+
},
|
|
190
|
+
get pid() {
|
|
191
|
+
return current?.pid;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/loopback.ts
|
|
197
|
+
import http from "http";
|
|
198
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
199
|
+
import { spawn } from "child_process";
|
|
200
|
+
function parseCallback(reqUrl, expectedState) {
|
|
201
|
+
const url = new URL(reqUrl, "http://127.0.0.1");
|
|
202
|
+
if (url.searchParams.get("state") !== expectedState) {
|
|
203
|
+
return { ok: false, error: "state mismatch" };
|
|
204
|
+
}
|
|
205
|
+
const error = url.searchParams.get("error");
|
|
206
|
+
if (error) {
|
|
207
|
+
return { ok: false, error };
|
|
208
|
+
}
|
|
209
|
+
const token = url.searchParams.get("token");
|
|
210
|
+
if (!token) {
|
|
211
|
+
return { ok: false, error: "no token in callback" };
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, token, slug: url.searchParams.get("slug") ?? void 0 };
|
|
214
|
+
}
|
|
215
|
+
function openBrowser(url) {
|
|
216
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
217
|
+
try {
|
|
218
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
|
|
219
|
+
} catch {
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function loopbackLogin(opts) {
|
|
223
|
+
const state = randomBytes2(16).toString("hex");
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
const server = http.createServer((req, res) => {
|
|
226
|
+
const result = parseCallback(req.url ?? "/", state);
|
|
227
|
+
res.writeHead(result.ok ? 200 : 400, { "content-type": "text/html" });
|
|
228
|
+
res.end(
|
|
229
|
+
`<!doctype html><meta charset=utf-8><body style="font-family:system-ui;padding:3rem;text-align:center">` + (result.ok ? "<h2>beckon: you're logged in \u2713</h2>" : `<h2>beckon login failed</h2><p>${result.error ?? ""}</p>`) + "<p>You can close this tab and return to the terminal.</p>"
|
|
230
|
+
);
|
|
231
|
+
server.close();
|
|
232
|
+
resolve(result);
|
|
233
|
+
});
|
|
234
|
+
server.on("error", reject);
|
|
235
|
+
server.listen(0, "127.0.0.1", () => {
|
|
236
|
+
const addr = server.address();
|
|
237
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
238
|
+
const authorizeUrl = new URL("/cli/authorize", opts.dashboardUrl);
|
|
239
|
+
authorizeUrl.searchParams.set("port", String(port));
|
|
240
|
+
authorizeUrl.searchParams.set("state", state);
|
|
241
|
+
if (opts.slug) {
|
|
242
|
+
authorizeUrl.searchParams.set("slug", opts.slug);
|
|
243
|
+
}
|
|
244
|
+
opts.onAuthorizeUrl(authorizeUrl.toString());
|
|
245
|
+
openBrowser(authorizeUrl.toString());
|
|
246
|
+
});
|
|
247
|
+
const timer = setTimeout(() => {
|
|
248
|
+
server.close();
|
|
249
|
+
reject(new Error("login timed out after waiting for the browser"));
|
|
250
|
+
}, opts.timeoutMs ?? 12e4);
|
|
251
|
+
server.on("close", () => clearTimeout(timer));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/bin.ts
|
|
256
|
+
var DEFAULT_EDGE = "edge.beckon.gotelz.com";
|
|
257
|
+
var DEFAULT_SSH_PORT = 2222;
|
|
258
|
+
var DEFAULT_DASHBOARD = process.env.BECKON_DASHBOARD_URL ?? "https://web-production-60e7b.up.railway.app";
|
|
259
|
+
function fail(message) {
|
|
260
|
+
process.stderr.write(`beckon: ${message}
|
|
261
|
+
`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
function configDir() {
|
|
265
|
+
return settingsDir(process.env, homedir());
|
|
266
|
+
}
|
|
267
|
+
function loadSettings() {
|
|
268
|
+
return readSettings(configDir());
|
|
269
|
+
}
|
|
270
|
+
function loadTunnelConfig() {
|
|
271
|
+
const path = join2(process.cwd(), "tunnel.yaml");
|
|
272
|
+
if (!existsSync3(path)) {
|
|
273
|
+
fail("no tunnel.yaml in this directory \u2014 see tunnel.yaml.example");
|
|
274
|
+
}
|
|
275
|
+
return parseTunnelConfig(readFileSync3(path, "utf8"));
|
|
276
|
+
}
|
|
277
|
+
function planFor(settings) {
|
|
278
|
+
const config = loadTunnelConfig();
|
|
279
|
+
const slug = settings.slug;
|
|
280
|
+
if (!slug) {
|
|
281
|
+
fail("no slug configured \u2014 run `beckon login <token> --slug <handle>`");
|
|
282
|
+
}
|
|
283
|
+
return { app: config.app, slug, plan: planTunnels(config, { slug, baseDomain: settings.baseDomain }) };
|
|
284
|
+
}
|
|
285
|
+
function urlsJson(app, plan) {
|
|
286
|
+
return Object.fromEntries(plan.map((t) => [`${app}-${t.role}`, t.url]));
|
|
287
|
+
}
|
|
288
|
+
var program = new Command();
|
|
289
|
+
program.name("beckon").description("AI-native local tunnels on your own domain").version("0.1.0");
|
|
290
|
+
program.command("login").description("authenticate via the browser (OAuth); or pass a token to store it directly").argument("[token]", "beckon token (bk_live_\u2026); omit to log in through the browser").option("--slug <handle>", "hostname slug (provisions it on first browser login)").option("--edge <host>", "sish edge host", DEFAULT_EDGE).option("--ssh-port <port>", "sish ssh port", String(DEFAULT_SSH_PORT)).option("--base-domain <domain>", "wildcard base domain").option("--identity <file>", "ssh identity file").option("--dashboard <url>", "beckon dashboard URL", DEFAULT_DASHBOARD).action(async (token, opts) => {
|
|
291
|
+
const dir = configDir();
|
|
292
|
+
const applyCommon = (s) => {
|
|
293
|
+
if (opts["edge"]) s.edgeHost = opts["edge"];
|
|
294
|
+
if (opts["sshPort"]) s.sshPort = Number(opts["sshPort"]);
|
|
295
|
+
if (opts["baseDomain"]) s.baseDomain = opts["baseDomain"];
|
|
296
|
+
if (opts["identity"]) s.identityFile = opts["identity"];
|
|
297
|
+
return s;
|
|
298
|
+
};
|
|
299
|
+
if (token) {
|
|
300
|
+
const next2 = applyCommon({ ...readSettings(dir), token });
|
|
301
|
+
if (opts["slug"]) next2.slug = opts["slug"];
|
|
302
|
+
writeSettings(dir, next2);
|
|
303
|
+
process.stdout.write(`beckon: saved credentials to ${join2(dir, "config.json")}
|
|
304
|
+
`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
process.stdout.write("beckon: opening your browser to authenticate\u2026\n");
|
|
308
|
+
const result = await loopbackLogin({
|
|
309
|
+
dashboardUrl: opts["dashboard"] ?? DEFAULT_DASHBOARD,
|
|
310
|
+
slug: opts["slug"],
|
|
311
|
+
onAuthorizeUrl: (url) => process.stdout.write(` if it doesn't open, visit:
|
|
312
|
+
${url}
|
|
313
|
+
`)
|
|
314
|
+
});
|
|
315
|
+
if (!result.ok || !result.token) {
|
|
316
|
+
if (result.error === "no_slug") {
|
|
317
|
+
fail("no slug yet \u2014 run `beckon login --slug <handle>` to provision it in one step");
|
|
318
|
+
}
|
|
319
|
+
fail(`login failed: ${result.error ?? "unknown error"}`);
|
|
320
|
+
}
|
|
321
|
+
const next = applyCommon({ ...readSettings(dir), token: result.token });
|
|
322
|
+
if (result.slug) next.slug = result.slug;
|
|
323
|
+
else if (opts["slug"]) next.slug = opts["slug"];
|
|
324
|
+
writeSettings(dir, next);
|
|
325
|
+
process.stdout.write(`beckon: logged in${next.slug ? ` as "${next.slug}"` : ""} \u2713
|
|
326
|
+
`);
|
|
327
|
+
});
|
|
328
|
+
program.command("up").description("open this repo's tunnels (reads ./tunnel.yaml)").option("--dry-run", "print the URLs without opening tunnels").action((opts) => {
|
|
329
|
+
const settings = loadSettings();
|
|
330
|
+
const { app, slug, plan } = planFor(settings);
|
|
331
|
+
process.stdout.write(`${JSON.stringify(urlsJson(app, plan), null, 2)}
|
|
332
|
+
`);
|
|
333
|
+
if (opts.dryRun) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const token = resolveToken(process.env, settings);
|
|
337
|
+
if (!token) {
|
|
338
|
+
fail("no token \u2014 run `beckon login <token>` or set BECKON_TOKEN");
|
|
339
|
+
}
|
|
340
|
+
const edgeHost = settings.edgeHost ?? DEFAULT_EDGE;
|
|
341
|
+
const port = settings.sshPort ?? DEFAULT_SSH_PORT;
|
|
342
|
+
const forwards = plan.map((t) => ({
|
|
343
|
+
label: `${app}-${t.role}-${slug}`,
|
|
344
|
+
remotePort: 80,
|
|
345
|
+
localPort: t.port
|
|
346
|
+
}));
|
|
347
|
+
const args = buildSshArgs({ edgeHost, port, token, identityFile: settings.identityFile }, forwards);
|
|
348
|
+
const spawnSsh = () => {
|
|
349
|
+
const child = spawn2("ssh", args, { stdio: ["ignore", "ignore", "inherit"] });
|
|
350
|
+
return {
|
|
351
|
+
pid: child.pid,
|
|
352
|
+
once: (event, cb) => child.once(event, () => cb()),
|
|
353
|
+
kill: (signal) => {
|
|
354
|
+
child.kill(signal);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
const sup = supervise({ spawn: spawnSsh });
|
|
359
|
+
const statePath = join2(configDir(), "state.json");
|
|
360
|
+
writeState(
|
|
361
|
+
statePath,
|
|
362
|
+
putApp(readState(statePath), {
|
|
363
|
+
app,
|
|
364
|
+
slug,
|
|
365
|
+
pid: process.pid,
|
|
366
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
367
|
+
tunnels: plan.map((t) => ({ role: t.role, port: t.port, url: t.url }))
|
|
368
|
+
})
|
|
369
|
+
);
|
|
370
|
+
const shutdown = () => {
|
|
371
|
+
sup.stop();
|
|
372
|
+
writeState(statePath, removeApp(readState(statePath), app));
|
|
373
|
+
process.exit(0);
|
|
374
|
+
};
|
|
375
|
+
process.on("SIGINT", shutdown);
|
|
376
|
+
process.on("SIGTERM", shutdown);
|
|
377
|
+
});
|
|
378
|
+
program.command("status").description("show live tunnels").option("--json", "output JSON").action((opts) => {
|
|
379
|
+
const state = readState(join2(configDir(), "state.json"));
|
|
380
|
+
if (opts.json) {
|
|
381
|
+
process.stdout.write(`${JSON.stringify(state, null, 2)}
|
|
382
|
+
`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const rows = Object.values(state);
|
|
386
|
+
if (rows.length === 0) {
|
|
387
|
+
process.stdout.write("no active tunnels\n");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
for (const entry of rows) {
|
|
391
|
+
process.stdout.write(`${entry.app} (pid ${entry.pid}, since ${entry.startedAt})
|
|
392
|
+
`);
|
|
393
|
+
for (const t of entry.tunnels) {
|
|
394
|
+
process.stdout.write(` ${t.role.padEnd(8)} :${t.port} \u2192 ${t.url}
|
|
395
|
+
`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
program.command("down").description("tear down this repo's tunnels").action(() => {
|
|
400
|
+
const config = loadTunnelConfig();
|
|
401
|
+
const statePath = join2(configDir(), "state.json");
|
|
402
|
+
const state = readState(statePath);
|
|
403
|
+
const entry = state[config.app];
|
|
404
|
+
if (!entry) {
|
|
405
|
+
process.stdout.write(`beckon: no active tunnels for "${config.app}"
|
|
406
|
+
`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
process.kill(entry.pid, "SIGTERM");
|
|
411
|
+
} catch {
|
|
412
|
+
}
|
|
413
|
+
writeState(statePath, removeApp(state, config.app));
|
|
414
|
+
process.stdout.write(`beckon: stopped "${config.app}"
|
|
415
|
+
`);
|
|
416
|
+
});
|
|
417
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
418
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
419
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fevernova90/beckon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-native local tunnels on your own domain — the beckon CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"beckon": "./dist/bin.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"tunnel",
|
|
18
|
+
"localhost",
|
|
19
|
+
"ssh",
|
|
20
|
+
"ngrok-alternative",
|
|
21
|
+
"sish",
|
|
22
|
+
"cli",
|
|
23
|
+
"webhooks"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"commander": "^12.1.0",
|
|
30
|
+
"yaml": "^2.6.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.10.2",
|
|
34
|
+
"tsup": "^8.3.5",
|
|
35
|
+
"tsx": "^4.19.2",
|
|
36
|
+
"typescript": "^5.6.3",
|
|
37
|
+
"vitest": "^2.1.8",
|
|
38
|
+
"@beckon/core": "0.0.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"beckon": "tsx src/bin.ts",
|
|
42
|
+
"build": "tsup",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"test:watch": "vitest",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
}
|
|
47
|
+
}
|