@hogsend/cli 0.19.0 → 0.21.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.
@@ -79,6 +79,7 @@ function makeCtx(opts: {
79
79
  ),
80
80
  post: () => Promise.reject(new Error("unexpected POST")),
81
81
  patch: () => Promise.reject(new Error("unexpected PATCH")),
82
+ put: () => Promise.reject(new Error("unexpected PUT")),
82
83
  del: () => Promise.reject(new Error("unexpected DELETE")),
83
84
  } as AdminClient;
84
85
 
@@ -111,6 +111,7 @@ function makeCtx(opts: {
111
111
  body,
112
112
  ),
113
113
  patch: () => Promise.reject(new Error("unexpected PATCH")),
114
+ put: () => Promise.reject(new Error("unexpected PUT")),
114
115
  del: () => Promise.reject(new Error("unexpected DELETE")),
115
116
  } as AdminClient;
116
117
 
@@ -0,0 +1,159 @@
1
+ import { createServer as createNetServer, type Server } from "node:net";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import {
4
+ LoopbackError,
5
+ type LoopbackServer,
6
+ startLoopbackServer,
7
+ } from "../lib/loopback.js";
8
+
9
+ /**
10
+ * Real node:http servers throughout — production passes LOOPBACK_PORTS, but
11
+ * tests inject `ports: [0]` for ephemeral binds so CI never collides.
12
+ */
13
+
14
+ const STATE = "S";
15
+ const loopbacks: LoopbackServer[] = [];
16
+ const blockers: Server[] = [];
17
+
18
+ afterEach(async () => {
19
+ for (const server of loopbacks.splice(0)) {
20
+ await server.close();
21
+ }
22
+ for (const server of blockers.splice(0)) {
23
+ await new Promise<void>((resolve) => server.close(() => resolve()));
24
+ }
25
+ });
26
+
27
+ async function start(ports: readonly number[] = [0], state = STATE) {
28
+ const server = await startLoopbackServer({ ports, state });
29
+ loopbacks.push(server);
30
+ return server;
31
+ }
32
+
33
+ /** Bind a throwaway TCP server to occupy an ephemeral port. */
34
+ function occupyPort(): Promise<{ port: number; server: Server }> {
35
+ return new Promise((resolve) => {
36
+ const server = createNetServer();
37
+ server.listen({ host: "127.0.0.1", port: 0 }, () => {
38
+ const address = server.address();
39
+ const port =
40
+ address !== null && typeof address === "object" ? address.port : 0;
41
+ blockers.push(server);
42
+ resolve({ port, server });
43
+ });
44
+ });
45
+ }
46
+
47
+ const expectLoopbackRejection = async (
48
+ promise: Promise<unknown>,
49
+ reason: string,
50
+ ) => {
51
+ await expect(promise).rejects.toSatisfy((err: unknown) => {
52
+ expect(err).toBeInstanceOf(LoopbackError);
53
+ expect((err as LoopbackError).reason).toBe(reason);
54
+ return true;
55
+ });
56
+ };
57
+
58
+ describe("startLoopbackServer", () => {
59
+ it("happy path: success page + resolved code", async () => {
60
+ const server = await start();
61
+ const wait = server.waitForCallback();
62
+
63
+ const res = await fetch(`${server.redirectUri}?code=abc&state=${STATE}`);
64
+ expect(res.status).toBe(200);
65
+ expect(await res.text()).toContain("Connected");
66
+
67
+ await expect(wait).resolves.toEqual({ code: "abc" });
68
+ });
69
+
70
+ it("rejects state_mismatch with HTTP 400", async () => {
71
+ const server = await start();
72
+ // Attach the rejection handler BEFORE triggering the callback, so the
73
+ // rejection is never momentarily unhandled.
74
+ const assertion = expectLoopbackRejection(
75
+ server.waitForCallback(),
76
+ "state_mismatch",
77
+ );
78
+
79
+ const res = await fetch(`${server.redirectUri}?code=abc&state=WRONG`);
80
+ expect(res.status).toBe(400);
81
+
82
+ await assertion;
83
+ });
84
+
85
+ it("rejects consent_denied on error=access_denied", async () => {
86
+ const server = await start();
87
+ const assertion = expectLoopbackRejection(
88
+ server.waitForCallback(),
89
+ "consent_denied",
90
+ );
91
+
92
+ const res = await fetch(
93
+ `${server.redirectUri}?error=access_denied&state=${STATE}`,
94
+ );
95
+ expect(res.status).toBe(200);
96
+
97
+ await assertion;
98
+ });
99
+
100
+ it("404s wrong paths and keeps the wait pending", async () => {
101
+ const server = await start();
102
+ const wait = server.waitForCallback();
103
+
104
+ const res = await fetch(`http://127.0.0.1:${server.port}/nope`);
105
+ expect(res.status).toBe(404);
106
+
107
+ // Still pending: a sentinel wins the race.
108
+ const pending = await Promise.race([
109
+ wait.then(() => "settled"),
110
+ new Promise((resolve) => setTimeout(() => resolve("pending"), 50)),
111
+ ]);
112
+ expect(pending).toBe("pending");
113
+
114
+ // Settle it for cleanup symmetry.
115
+ await fetch(`${server.redirectUri}?code=abc&state=${STATE}`);
116
+ await expect(wait).resolves.toEqual({ code: "abc" });
117
+ });
118
+
119
+ it("falls back to the next port when the first is busy", async () => {
120
+ const { port } = await occupyPort();
121
+ const server = await start([port, 0]);
122
+ expect(server.port).not.toBe(port);
123
+ expect(server.port).toBeGreaterThan(0);
124
+ });
125
+
126
+ it("rejects ports_busy listing the ports when all are taken", async () => {
127
+ const { port } = await occupyPort();
128
+ await expect(
129
+ startLoopbackServer({ ports: [port], state: STATE }),
130
+ ).rejects.toSatisfy((err: unknown) => {
131
+ expect(err).toBeInstanceOf(LoopbackError);
132
+ expect((err as LoopbackError).reason).toBe("ports_busy");
133
+ expect((err as LoopbackError).message).toContain(String(port));
134
+ return true;
135
+ });
136
+ });
137
+
138
+ it("times out when no callback arrives", async () => {
139
+ const server = await start();
140
+ await expectLoopbackRejection(
141
+ server.waitForCallback({ timeoutMs: 20 }),
142
+ "timeout",
143
+ );
144
+ });
145
+
146
+ it("first answer wins — later requests get a 410", async () => {
147
+ const server = await start();
148
+ const wait = server.waitForCallback();
149
+
150
+ const first = await fetch(`${server.redirectUri}?code=abc&state=${STATE}`);
151
+ expect(first.status).toBe(200);
152
+ await expect(wait).resolves.toEqual({ code: "abc" });
153
+
154
+ const second = await fetch(
155
+ `${server.redirectUri}?code=later&state=${STATE}`,
156
+ );
157
+ expect(second.status).toBe(410);
158
+ });
159
+ });
@@ -0,0 +1,230 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildAuthorizeUrl,
4
+ computeChallenge,
5
+ discoverOAuthServer,
6
+ exchangeCode,
7
+ generatePkce,
8
+ generateState,
9
+ POSTHOG_CLIENT_ID,
10
+ POSTHOG_SCOPES,
11
+ } from "../lib/oauth.js";
12
+
13
+ const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
14
+
15
+ /** Minimal recording fetch fake (mirrors hatchet-token.test.ts's pattern). */
16
+ function fakeFetch(
17
+ respond: (url: string, init?: RequestInit) => Response | Promise<Response>,
18
+ ): { fetchImpl: typeof fetch; calls: { url: string; init?: RequestInit }[] } {
19
+ const calls: { url: string; init?: RequestInit }[] = [];
20
+ const fetchImpl = (async (input: unknown, init?: RequestInit) => {
21
+ const url = String(input);
22
+ calls.push({ url, init });
23
+ return respond(url, init);
24
+ }) as typeof fetch;
25
+ return { fetchImpl, calls };
26
+ }
27
+
28
+ const json = (body: unknown, status = 200) =>
29
+ new Response(JSON.stringify(body), {
30
+ status,
31
+ headers: { "content-type": "application/json" },
32
+ });
33
+
34
+ describe("computeChallenge", () => {
35
+ it("matches the RFC 7636 appendix B vector", () => {
36
+ expect(
37
+ computeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
38
+ ).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
39
+ });
40
+ });
41
+
42
+ describe("generatePkce", () => {
43
+ it("emits a 43-char base64url verifier with a matching S256 challenge", () => {
44
+ const pkce = generatePkce();
45
+ expect(pkce.verifier).toHaveLength(43);
46
+ expect(pkce.verifier).toMatch(BASE64URL_RE);
47
+ expect(pkce.verifier).not.toContain("=");
48
+ expect(pkce.challenge).toBe(computeChallenge(pkce.verifier));
49
+ expect(pkce.method).toBe("S256");
50
+ });
51
+
52
+ it("two calls differ", () => {
53
+ expect(generatePkce().verifier).not.toBe(generatePkce().verifier);
54
+ });
55
+ });
56
+
57
+ describe("generateState", () => {
58
+ it("is unique and base64url across 100 calls", () => {
59
+ const states = Array.from({ length: 100 }, () => generateState());
60
+ expect(new Set(states).size).toBe(100);
61
+ for (const state of states) {
62
+ expect(state).toMatch(BASE64URL_RE);
63
+ }
64
+ });
65
+ });
66
+
67
+ describe("buildAuthorizeUrl", () => {
68
+ const base = {
69
+ authorizationEndpoint: "https://eu.posthog.com/oauth/authorize/",
70
+ clientId: POSTHOG_CLIENT_ID,
71
+ redirectUri: "http://127.0.0.1:8423/callback",
72
+ scope: POSTHOG_SCOPES,
73
+ state: "st4te",
74
+ pkce: {
75
+ verifier: "v".repeat(43),
76
+ challenge: "ch4llenge",
77
+ method: "S256" as const,
78
+ },
79
+ };
80
+
81
+ it("sets every OAuth param and never a client_secret", () => {
82
+ const url = new URL(
83
+ buildAuthorizeUrl({ ...base, requiredAccessLevel: "team" }),
84
+ );
85
+ expect(url.origin + url.pathname).toBe(
86
+ "https://eu.posthog.com/oauth/authorize/",
87
+ );
88
+ expect(url.searchParams.get("response_type")).toBe("code");
89
+ expect(url.searchParams.get("client_id")).toBe(POSTHOG_CLIENT_ID);
90
+ expect(url.searchParams.get("redirect_uri")).toBe(base.redirectUri);
91
+ expect(url.searchParams.get("scope")).toBe(POSTHOG_SCOPES);
92
+ expect(url.searchParams.get("state")).toBe("st4te");
93
+ expect(url.searchParams.get("code_challenge")).toBe("ch4llenge");
94
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
95
+ expect(url.searchParams.get("required_access_level")).toBe("team");
96
+ expect(url.searchParams.has("client_secret")).toBe(false);
97
+ });
98
+
99
+ it("omits required_access_level when not provided", () => {
100
+ const url = new URL(buildAuthorizeUrl(base));
101
+ expect(url.searchParams.has("required_access_level")).toBe(false);
102
+ });
103
+ });
104
+
105
+ describe("discoverOAuthServer", () => {
106
+ const DOC = {
107
+ issuer: "https://eu.posthog.com",
108
+ authorization_endpoint: "https://eu.posthog.com/oauth/authorize/",
109
+ token_endpoint: "https://eu.posthog.com/oauth/token/",
110
+ };
111
+
112
+ it("requests the RFC 8414 well-known path and returns ok metadata", async () => {
113
+ const { fetchImpl, calls } = fakeFetch(() => json(DOC));
114
+ const result = await discoverOAuthServer({
115
+ privateHost: "https://eu.posthog.com",
116
+ fetchImpl,
117
+ });
118
+ expect(calls[0]?.url).toBe(
119
+ "https://eu.posthog.com/.well-known/oauth-authorization-server",
120
+ );
121
+ expect(result).toEqual({ status: "ok", metadata: DOC });
122
+ });
123
+
124
+ it("maps 404 and 410 to unsupported", async () => {
125
+ for (const status of [404, 410]) {
126
+ const { fetchImpl } = fakeFetch(() => new Response("nope", { status }));
127
+ const result = await discoverOAuthServer({
128
+ privateHost: "https://ph.selfhosted.example",
129
+ fetchImpl,
130
+ });
131
+ expect(result).toEqual({ status: "unsupported" });
132
+ }
133
+ });
134
+
135
+ it("flags a 200 missing token_endpoint as malformed", async () => {
136
+ const { fetchImpl } = fakeFetch(() =>
137
+ json({
138
+ issuer: DOC.issuer,
139
+ authorization_endpoint: DOC.authorization_endpoint,
140
+ }),
141
+ );
142
+ const result = await discoverOAuthServer({
143
+ privateHost: "https://eu.posthog.com",
144
+ fetchImpl,
145
+ });
146
+ expect(result.status).toBe("error");
147
+ if (result.status === "error") {
148
+ expect(result.message).toContain("malformed");
149
+ }
150
+ });
151
+
152
+ it("never throws on transport failure", async () => {
153
+ const fetchImpl = (async () => {
154
+ throw new Error("getaddrinfo ENOTFOUND");
155
+ }) as unknown as typeof fetch;
156
+ const result = await discoverOAuthServer({
157
+ privateHost: "https://eu.posthog.com",
158
+ fetchImpl,
159
+ });
160
+ expect(result.status).toBe("error");
161
+ if (result.status === "error") {
162
+ expect(result.message).toContain("ENOTFOUND");
163
+ }
164
+ });
165
+ });
166
+
167
+ describe("exchangeCode", () => {
168
+ const OPTS = {
169
+ tokenEndpoint: "https://eu.posthog.com/oauth/token/",
170
+ clientId: POSTHOG_CLIENT_ID,
171
+ code: "auth-code-abc",
172
+ codeVerifier: "verifier-xyz",
173
+ redirectUri: "http://127.0.0.1:8423/callback",
174
+ };
175
+ const TOKENS = {
176
+ access_token: "pha_fixture",
177
+ refresh_token: "phr_fixture",
178
+ token_type: "Bearer",
179
+ expires_in: 36_000,
180
+ scope: POSTHOG_SCOPES,
181
+ scoped_teams: [123],
182
+ scoped_organizations: [],
183
+ };
184
+
185
+ it("POSTs exactly the five form params with no client_secret / auth header", async () => {
186
+ const { fetchImpl, calls } = fakeFetch(() => json(TOKENS));
187
+ const result = await exchangeCode({ ...OPTS, fetchImpl });
188
+
189
+ const call = calls[0];
190
+ expect(call?.init?.method).toBe("POST");
191
+ const headers = call?.init?.headers as Record<string, string>;
192
+ expect(headers.Authorization).toBeUndefined();
193
+
194
+ const body = new URLSearchParams(String(call?.init?.body));
195
+ expect(Object.fromEntries(body.entries())).toEqual({
196
+ grant_type: "authorization_code",
197
+ code: "auth-code-abc",
198
+ redirect_uri: OPTS.redirectUri,
199
+ client_id: POSTHOG_CLIENT_ID,
200
+ code_verifier: "verifier-xyz",
201
+ });
202
+ expect(body.has("client_secret")).toBe(false);
203
+
204
+ expect(result).toEqual(TOKENS);
205
+ });
206
+
207
+ it("throws with the status (never the code/verifier) on a 400", async () => {
208
+ const { fetchImpl } = fakeFetch(() =>
209
+ json({ error: "invalid_grant" }, 400),
210
+ );
211
+ await expect(exchangeCode({ ...OPTS, fetchImpl })).rejects.toSatisfy(
212
+ (err: unknown) => {
213
+ const message = (err as Error).message;
214
+ expect(message).toContain("400");
215
+ expect(message).toContain("invalid_grant");
216
+ expect(message).not.toContain("auth-code-abc");
217
+ expect(message).not.toContain("verifier-xyz");
218
+ return true;
219
+ },
220
+ );
221
+ });
222
+
223
+ it("throws when a 200 omits refresh_token", async () => {
224
+ const { refresh_token: _omitted, ...withoutRefresh } = TOKENS;
225
+ const { fetchImpl } = fakeFetch(() => json(withoutRefresh));
226
+ await expect(exchangeCode({ ...OPTS, fetchImpl })).rejects.toThrow(
227
+ /missing refresh_token/,
228
+ );
229
+ });
230
+ });
@@ -0,0 +1,178 @@
1
+ import { parseArgs } from "node:util";
2
+ import { confirm, select, text } from "@clack/prompts";
3
+ import { openBrowser } from "../lib/browser.js";
4
+ import {
5
+ ConnectError,
6
+ type ConnectFlowDeps,
7
+ runConnectPosthog,
8
+ } from "../lib/connect-flow.js";
9
+ import { startLoopbackServer } from "../lib/loopback.js";
10
+ import { discoverOAuthServer, exchangeCode } from "../lib/oauth.js";
11
+ import { color } from "../lib/output.js";
12
+ import { bail } from "../lib/prompt.js";
13
+ import type { Command, CommandContext } from "./types.js";
14
+
15
+ const usage = `hogsend connect <provider> [--posthog-host <url>] [--provision-only] [--no-provision] [--no-browser] [--json]
16
+
17
+ Connect this Hogsend instance to an analytics provider via OAuth. Providers:
18
+
19
+ posthog Authorize Hogsend against your PostHog region (PKCE, loopback
20
+ callback on 127.0.0.1), store the refresh token on the instance,
21
+ then provision the PostHog -> Hogsend event loop (a PostHog
22
+ destination posting to /v1/webhooks/posthog).
23
+
24
+ The browser consent must happen on THIS machine (the OAuth callback lands on
25
+ 127.0.0.1). The target instance can be anywhere — point --url at it and run
26
+ this command from your laptop, not from an SSH session on the server.
27
+
28
+ Options:
29
+ --posthog-host PostHog app/private host to authorize against, e.g.
30
+ https://eu.posthog.com or https://us.posthog.com (NOT the
31
+ i. ingestion host). Required when the instance has no
32
+ PostHog config and you're running non-interactively.
33
+ --provision-only Skip OAuth; (re-)provision the event loop using the
34
+ already-stored credential.
35
+ --no-provision Stop after storing the credential.
36
+ --no-browser Don't spawn a browser; just print the authorize URL.
37
+ --url, --admin-key, --json, -h, --help Global flags as usual.
38
+
39
+ Exit code: 0 when a credential is stored (even if provisioning was skipped),
40
+ 1 otherwise.`;
41
+
42
+ async function run(ctx: CommandContext): Promise<void> {
43
+ const { values, positionals } = parseArgs({
44
+ args: ctx.argv,
45
+ allowPositionals: true,
46
+ strict: false,
47
+ options: {
48
+ "posthog-host": { type: "string" },
49
+ "provision-only": { type: "boolean", default: false },
50
+ "no-provision": { type: "boolean", default: false },
51
+ "no-browser": { type: "boolean", default: false },
52
+ help: { type: "boolean", short: "h", default: false },
53
+ },
54
+ });
55
+
56
+ if (values.help) {
57
+ ctx.out.log(usage);
58
+ return;
59
+ }
60
+
61
+ const provider = positionals[0];
62
+ if (!provider) {
63
+ ctx.out.fail("missing provider — try: hogsend connect posthog");
64
+ }
65
+ if (provider !== "posthog") {
66
+ ctx.out.fail(`unknown provider "${provider}" — supported: posthog`);
67
+ }
68
+
69
+ if (values["provision-only"] && values["no-provision"]) {
70
+ ctx.out.fail("--provision-only and --no-provision are mutually exclusive");
71
+ }
72
+
73
+ // The PUT carries the OAuth tokens — warn when they'd ride plain http to a
74
+ // non-local instance.
75
+ try {
76
+ const target = new URL(ctx.cfg.baseUrl);
77
+ if (
78
+ target.protocol === "http:" &&
79
+ target.hostname !== "localhost" &&
80
+ target.hostname !== "127.0.0.1"
81
+ ) {
82
+ ctx.out.log(
83
+ color.yellow(
84
+ `warning: ${ctx.cfg.baseUrl} is plain http — OAuth tokens will ` +
85
+ "be sent to it unencrypted; use https for remote instances.",
86
+ ),
87
+ );
88
+ }
89
+ } catch {
90
+ // unparseable base URL — the HTTP client will surface it
91
+ }
92
+
93
+ ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} connect`);
94
+
95
+ const deps: ConnectFlowDeps = {
96
+ http: ctx.http,
97
+ out: ctx.out,
98
+ interactive: ctx.out.interactive,
99
+ discover: discoverOAuthServer,
100
+ startLoopback: startLoopbackServer,
101
+ exchangeCode,
102
+ openBrowser,
103
+ confirm: async (message) => bail(await confirm({ message })),
104
+ selectRegion: async () => {
105
+ const choice = bail(
106
+ await select({
107
+ message: "Which PostHog region should Hogsend authorize against?",
108
+ options: [
109
+ { value: "https://eu.posthog.com", label: "PostHog EU Cloud" },
110
+ { value: "https://us.posthog.com", label: "PostHog US Cloud" },
111
+ { value: "custom", label: "Custom / self-hosted" },
112
+ ],
113
+ }),
114
+ ) as string;
115
+ if (choice !== "custom") return choice;
116
+ return bail(
117
+ await text({
118
+ message:
119
+ "PostHog app/private host URL (e.g. https://posthog.example.com)",
120
+ placeholder: "https://posthog.example.com",
121
+ }),
122
+ );
123
+ },
124
+ now: () => new Date(),
125
+ };
126
+
127
+ try {
128
+ const result = await runConnectPosthog(deps, {
129
+ provisionOnly: Boolean(values["provision-only"]),
130
+ noProvision: Boolean(values["no-provision"]),
131
+ noBrowser: Boolean(values["no-browser"]),
132
+ posthogHost:
133
+ typeof values["posthog-host"] === "string"
134
+ ? values["posthog-host"]
135
+ : undefined,
136
+ });
137
+
138
+ if (ctx.json) {
139
+ // One document; ConnectResult carries no token material by invariant.
140
+ ctx.out.json({ ok: true, ...result });
141
+ return;
142
+ }
143
+ ctx.out.outro(
144
+ color.green(
145
+ `connect: posthog ${
146
+ result.verdict === "connected"
147
+ ? "connected"
148
+ : "connected (loop not provisioned)"
149
+ }`,
150
+ ),
151
+ );
152
+ } catch (error) {
153
+ if (error instanceof ConnectError) {
154
+ if (ctx.json) {
155
+ ctx.out.json({
156
+ ok: false,
157
+ verdict: error.verdict,
158
+ error: error.message,
159
+ hint: error.hint,
160
+ });
161
+ process.exit(1);
162
+ }
163
+ ctx.out.note(
164
+ error.hint ? `${error.message}\n\n${error.hint}` : error.message,
165
+ );
166
+ ctx.out.outro(color.red(`connect: ${error.verdict}`));
167
+ process.exit(1);
168
+ }
169
+ throw error; // router renders unexpected errors
170
+ }
171
+ }
172
+
173
+ export const connectCommand: Command = {
174
+ name: "connect",
175
+ summary: "Connect an analytics provider via OAuth (posthog)",
176
+ usage,
177
+ run,
178
+ };
@@ -1,4 +1,5 @@
1
1
  import { campaignsCommand } from "./campaigns.js";
2
+ import { connectCommand } from "./connect.js";
2
3
  import { contactsCommand } from "./contacts.js";
3
4
  import { devCommand } from "./dev.js";
4
5
  import { doctorCommand } from "./doctor.js";
@@ -35,6 +36,7 @@ export const commands: Command[] = [
35
36
  campaignsCommand,
36
37
  webhooksCommand,
37
38
  domainCommand,
39
+ connectCommand,
38
40
  hatchetCommand,
39
41
  studioCommand,
40
42
  devCommand,
@@ -1,9 +1,9 @@
1
- import { spawn } from "node:child_process";
2
1
  import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
3
2
  import { createServer } from "node:http";
4
3
  import { extname, join, normalize, resolve, sep } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
6
5
  import { parseArgs } from "node:util";
6
+ import { openBrowser } from "../lib/browser.js";
7
7
  import { color } from "../lib/output.js";
8
8
  import { runStudioAdmin } from "./studio-admin.js";
9
9
  import type { Command, CommandContext } from "./types.js";
@@ -115,21 +115,6 @@ function indexHtml(distPath: string, baseUrl: string | undefined): string {
115
115
  return `${inject}${raw}`;
116
116
  }
117
117
 
118
- /** Open a URL in the OS default browser (best-effort, never throws). */
119
- function openBrowser(url: string): void {
120
- const platform = process.platform;
121
- const cmd =
122
- platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
123
- const args = platform === "win32" ? ["/c", "start", "", url] : [url];
124
- try {
125
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
126
- child.on("error", () => {});
127
- child.unref();
128
- } catch {
129
- // best-effort
130
- }
131
- }
132
-
133
118
  async function run(ctx: CommandContext): Promise<void> {
134
119
  // Subcommand dispatch: `hogsend studio admin <create|reset|list> ...`.
135
120
  // Route before the static-server flag parsing so the admin flags are owned
@@ -0,0 +1,17 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /** Open a URL in the OS default browser. Best-effort: returns false instead of throwing. */
4
+ export function openBrowser(url: string): boolean {
5
+ const platform = process.platform;
6
+ const cmd =
7
+ platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
8
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
9
+ try {
10
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
11
+ child.on("error", () => {});
12
+ child.unref();
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }