@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.
Files changed (3) hide show
  1. package/README.md +40 -0
  2. package/dist/bin.js +419 -0
  3. 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
+ }