@fabiofiorita/porcelain 0.57.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 (36) hide show
  1. package/README.md +118 -0
  2. package/bin/porcelain.js +405 -0
  3. package/main/chunks/index-BlxZmzlT.js +10580 -0
  4. package/main/contracts/protocol.js +36 -0
  5. package/main/daemon/server.js +16387 -0
  6. package/package.json +42 -0
  7. package/renderer/apple-touch-icon.png +0 -0
  8. package/renderer/assets/SymbolsNerdFontMono-Regular-DDncdh2F.ttf +0 -0
  9. package/renderer/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
  10. package/renderer/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  11. package/renderer/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  12. package/renderer/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  13. package/renderer/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  14. package/renderer/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  15. package/renderer/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  16. package/renderer/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  17. package/renderer/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  18. package/renderer/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  19. package/renderer/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  20. package/renderer/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  21. package/renderer/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
  22. package/renderer/assets/ghostty-write-pty-CVPirtOQ.wasm +0 -0
  23. package/renderer/assets/index-BHMijQkc.js +339 -0
  24. package/renderer/assets/index-BbXe8VR5.css +1 -0
  25. package/renderer/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  26. package/renderer/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  27. package/renderer/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  28. package/renderer/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  29. package/renderer/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  30. package/renderer/assets/logo-DqyMvkTw.png +0 -0
  31. package/renderer/favicon-16.png +0 -0
  32. package/renderer/favicon-32.png +0 -0
  33. package/renderer/icon-192.png +0 -0
  34. package/renderer/icon-512.png +0 -0
  35. package/renderer/index.html +41 -0
  36. package/renderer/manifest.webmanifest +16 -0
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # @fabiofiorita/porcelain (0.57.0)
2
+
3
+ Headless **Porcelain** backend — the Electron-free daemon + renderer, packaged for
4
+ plain Node on any machine (Linux mini-PC, cloud VM, laptop). Same credential-gated
5
+ HTTP/WS surface the Mac app and browser clients already talk to.
6
+
7
+ ## Quick start (recommended)
8
+
9
+ On the remote host (Node ≥ 22, git, and a C toolchain for `node-pty`):
10
+
11
+ ```sh
12
+ npx @fabiofiorita/porcelain@latest serve --lan --cloudflare
13
+ ```
14
+
15
+ That:
16
+
17
+ 1. Fetches the **latest** published package (use `@latest` so you don't stick on a
18
+ stale npx cache of an older version).
19
+ 2. Compiles `node-pty` for this host on first install.
20
+ 3. Starts the daemon on port **43117**, with LAN access and opt-in
21
+ public HTTPS through a Cloudflare tunnel.
22
+ 4. Keeps host administration local; it never prints the administrator credential.
23
+
24
+ Leave the process in the foreground while you work (Termius / tmux / SSH session).
25
+ Ctrl+C stops it — **no systemd required**. Start it when you sit down; stop it
26
+ when you're done.
27
+
28
+ ### Pair a device
29
+
30
+ ```sh
31
+ npx @fabiofiorita/porcelain@latest access issue --name "My phone"
32
+ ```
33
+
34
+ Open the printed connection link in a browser, or paste it into the Mac app's
35
+ **Settings → Remotes**. The link expires in 15 minutes, works once, and becomes
36
+ an individually revocable device credential. Manage access only on the host:
37
+
38
+ ```sh
39
+ npx @fabiofiorita/porcelain@latest access list
40
+ npx @fabiofiorita/porcelain@latest access revoke <id>
41
+ npx @fabiofiorita/porcelain@latest share status
42
+ ```
43
+
44
+ ## Host launcher
45
+
46
+ ```text
47
+ porcelain serve [options]
48
+ porcelain access issue --name <device> [--base-url <url>]
49
+ porcelain access list | revoke <id>
50
+ porcelain share status
51
+
52
+ --port <n> Port (default 43117)
53
+ --user-data <path> Config dir (default ~/.local/share/porcelain)
54
+ --tailnet Bind Tailscale interface too
55
+ --lan Bind RFC1918 LAN addresses too
56
+ --cloudflare Publish loopback over Cloudflare (named or quick)
57
+ --allowed-origin <origin>
58
+ Trust a browser Hub origin (repeat for more than one)
59
+ --no-watchdog For systemd / supervisors (stdin is /dev/null)
60
+ ```
61
+
62
+ Porcelain always binds loopback, never `0.0.0.0`. Private listeners are explicit.
63
+ Cloudflare is public HTTPS but still credential-gated. Tailscale and Cloudflare are
64
+ exclusive; LAN can combine with either.
65
+
66
+ ## Always-on (optional)
67
+
68
+ If you *do* want a supervised process, use `--no-watchdog` and a unit like:
69
+
70
+ ```ini
71
+ [Service]
72
+ Environment=PORCELAIN_USER_DATA=%h/.local/share/porcelain
73
+ Environment=PORCELAIN_DAEMON_PORT=43117
74
+ Environment=PORCELAIN_ALLOWED_ORIGIN=http://hub-host:43118
75
+ Environment=PORCELAIN_TAILNET_BIND=1
76
+ Environment=PORCELAIN_NO_STDIN_WATCHDOG=1
77
+ ExecStart=/usr/bin/npx --yes @fabiofiorita/porcelain@latest serve --no-watchdog --tailnet
78
+ Restart=on-failure
79
+ ```
80
+
81
+ Prefer a real `node` binary over Volta/fnm/nvm shims in `ExecStart` when pinning
82
+ a global install instead of npx.
83
+
84
+ For a browser Hub served from another origin, set `PORCELAIN_ALLOWED_ORIGIN` to that Hub's
85
+ bare `http(s)` origin (repeat `--allowed-origin` for multiple Hubs; comma-separated values
86
+ are accepted). Paths, credentials, wildcards, and `null` are rejected. This setting only
87
+ controls CORS response headers; every API and WebSocket request remains token-gated.
88
+
89
+ ## Agent access (MCP)
90
+
91
+ The daemon serves the Model Context Protocol at `POST /mcp` for direct loopback agent
92
+ clients without an admin token. LAN, Tailscale, Cloudflare, and proxied requests receive
93
+ 404. Install the plugin through your client's native plugin manager. For an Agent Plugin client,
94
+ add the repository `FabioFiorita/porcelain`; for Claude Code, use:
95
+
96
+ ```text
97
+ /plugin marketplace add FabioFiorita/porcelain
98
+ /plugin install porcelain@porcelain
99
+ ```
100
+
101
+ Installing the plugin is the opt-in. The endpoint is always served, so upgrading the
102
+ daemon ships new tools with no per-agent configuration to rewrite.
103
+
104
+ ## Requirements
105
+
106
+ - **Node ≥ 22**
107
+ - **git** on PATH
108
+ - A **C toolchain** (make, g++, python3) — first install compiles `node-pty`
109
+
110
+ ## Develop / ship from the monorepo
111
+
112
+ ```sh
113
+ pnpm build && pnpm daemon:dist
114
+ cd dist-daemon && npm publish --access public
115
+ ```
116
+
117
+ Assembled by `pnpm daemon:dist` from a completed `pnpm build`. Do not edit
118
+ `dist-daemon/` by hand — regenerate.
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env node
2
+ // Narrow host launcher for the published @fabiofiorita/porcelain package.
3
+ // Copied into dist-daemon/bin/ by scripts/build-daemon-dist.mjs — not run from
4
+ // the monorepo root (paths resolve relative to the installed package layout).
5
+ //
6
+ // Goal: t3-style one-liner on a remote box —
7
+ // npx @fabiofiorita/porcelain@latest serve --tailnet
8
+ // instead of scp'ing a dist tarball and wiring systemd.
9
+
10
+ const { randomBytes } = require('node:crypto')
11
+ const {
12
+ chmodSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ readFileSync,
16
+ renameSync,
17
+ writeFileSync,
18
+ } = require('node:fs')
19
+ const { homedir } = require('node:os')
20
+ const { dirname, join } = require('node:path')
21
+ const { createTRPCUntypedClient, httpLink } = require('@trpc/client')
22
+
23
+ const DEFAULT_PORT = 43117
24
+ const DEFAULT_USER_DATA = join(homedir(), '.local', 'share', 'porcelain')
25
+ // PORCELAIN_HOME redirects token + channels (dev stack uses ~/.porcelain-dev).
26
+ const porcelainHome = () => process.env.PORCELAIN_HOME ?? join(homedir(), '.porcelain')
27
+ const ADMIN_TOKEN_PATH = () =>
28
+ process.env.PORCELAIN_ADMIN_TOKEN_FILE ?? join(porcelainHome(), 'admin-token')
29
+
30
+ const HELP = `porcelain — headless Porcelain backend (plain Node, no Electron)
31
+
32
+ Usage:
33
+ porcelain serve [options]
34
+ porcelain [options] (same as serve)
35
+ porcelain access issue --name <device> [--base-url <url>]
36
+ porcelain access list
37
+ porcelain access revoke <id>
38
+ porcelain share status
39
+
40
+ Options:
41
+ --port <n> Listen port for loopback AND LAN/tailnet (default ${DEFAULT_PORT})
42
+ --user-data <path> Config dir (default ${DEFAULT_USER_DATA})
43
+ --lan Also bind RFC1918 LAN addresses (same --port)
44
+ --tailnet Also bind the Tailscale interface (same --port)
45
+ --cloudflare Publish loopback over Cloudflare (named tunnel if
46
+ PORCELAIN_CLOUDFLARE_TOKEN is set, else a quick tunnel)
47
+ --cloudflare-hostname <host>
48
+ Public https hostname of a named tunnel
49
+ --allowed-origin <origin>
50
+ Trust a browser Hub origin for cross-origin API/WS (repeatable)
51
+ --no-watchdog Disable stdin parent-death watchdog (required under systemd)
52
+ -h, --help Show this help
53
+
54
+ Host-management options:
55
+ --daemon-url <url> Loopback daemon URL when not using the default port
56
+ --base-url <url> Reachable URL to embed in a new connection link
57
+
58
+ Examples:
59
+ npx @fabiofiorita/porcelain@latest serve --lan
60
+ npx @fabiofiorita/porcelain@latest serve --lan --tailnet
61
+ npx @fabiofiorita/porcelain@latest serve --lan --cloudflare --cloudflare-hostname review.example.com
62
+ npx @fabiofiorita/porcelain@latest access issue --name "My phone"
63
+ npx @fabiofiorita/porcelain@latest serve --port 43118 --lan
64
+
65
+ Env (same as the raw daemon; flags set these when passed):
66
+ PORCELAIN_USER_DATA, PORCELAIN_DAEMON_PORT, PORCELAIN_ADMIN_TOKEN,
67
+ PORCELAIN_ALLOWED_ORIGIN (comma-separated), PORCELAIN_ALLOWED_ORIGINS (comma-separated),
68
+ PORCELAIN_TAILNET_BIND, PORCELAIN_LAN_BIND, PORCELAIN_CLOUDFLARE_BIND,
69
+ PORCELAIN_CLOUDFLARE_HOSTNAME, PORCELAIN_CLOUDFLARE_TOKEN,
70
+ PORCELAIN_NO_STDIN_WATCHDOG
71
+
72
+ Notes:
73
+ • Always binds 127.0.0.1; --tailnet / --lan add private interfaces only
74
+ (never 0.0.0.0). Cloudflare uses a separate loopback ingress without MCP.
75
+ • Named tunnels: set PORCELAIN_CLOUDFLARE_TOKEN (never a flag) and
76
+ --cloudflare-hostname. Without a token, --cloudflare is a quick tunnel
77
+ whose URL changes every start.
78
+ • --tailnet and --cloudflare are mutually exclusive. LAN can combine with either.
79
+ • Host administration lives at ~/.porcelain/admin-token (0600) and is never shared.
80
+ • Pairing links are one-time credentials; clients receive individually revocable access.
81
+ • Use @latest so each invoke can pick up a newer published package.
82
+ • First install compiles node-pty for this host (needs a C toolchain).
83
+ `
84
+
85
+ function fail(message) {
86
+ console.error(`[porcelain] ${message}`)
87
+ process.exit(1)
88
+ }
89
+
90
+ /**
91
+ * Minimal argv parse — no deps in the published package beyond the daemon's.
92
+ * Unknown flags / missing values exit non-zero with a short message.
93
+ */
94
+ function parseArgs(argv) {
95
+ const opts = {
96
+ command: 'serve',
97
+ port: DEFAULT_PORT,
98
+ userData: DEFAULT_USER_DATA,
99
+ tailnet: false,
100
+ lan: false,
101
+ cloudflare: false,
102
+ cloudflareHostname: null,
103
+ noWatchdog: false,
104
+ allowedOrigins: [],
105
+ help: false,
106
+ }
107
+
108
+ let i = 0
109
+ // Bare invocation and explicit `serve` both mean serve (t3-compatible).
110
+ if (argv[0] === 'serve') {
111
+ i = 1
112
+ } else if (argv[0] === 'help' || argv[0] === '--help' || argv[0] === '-h') {
113
+ opts.help = true
114
+ return opts
115
+ }
116
+
117
+ while (i < argv.length) {
118
+ const arg = argv[i]
119
+ if (arg === '-h' || arg === '--help') {
120
+ opts.help = true
121
+ i += 1
122
+ continue
123
+ }
124
+ if (arg === '--tailnet') {
125
+ opts.tailnet = true
126
+ i += 1
127
+ continue
128
+ }
129
+ if (arg === '--lan') {
130
+ opts.lan = true
131
+ i += 1
132
+ continue
133
+ }
134
+ if (arg === '--cloudflare') {
135
+ opts.cloudflare = true
136
+ i += 1
137
+ continue
138
+ }
139
+ if (arg === '--cloudflare-hostname') {
140
+ const raw = argv[i + 1]
141
+ if (raw === undefined) fail('--cloudflare-hostname requires a hostname')
142
+ opts.cloudflareHostname = raw
143
+ opts.cloudflare = true
144
+ i += 2
145
+ continue
146
+ }
147
+ if (arg === '--funnel') {
148
+ fail('Tailscale Funnel was removed. Use --cloudflare for public HTTPS.')
149
+ }
150
+ if (arg === '--no-watchdog') {
151
+ opts.noWatchdog = true
152
+ i += 1
153
+ continue
154
+ }
155
+ if (arg === '--allowed-origin' || arg === '--allowed-origins') {
156
+ const raw = argv[i + 1]
157
+ if (raw === undefined) fail(`${arg} requires a value`)
158
+ opts.allowedOrigins.push(raw)
159
+ i += 2
160
+ continue
161
+ }
162
+ if (arg === '--port') {
163
+ const raw = argv[i + 1]
164
+ if (raw === undefined) fail('--port requires a value')
165
+ const port = Number(raw)
166
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
167
+ fail(`--port must be an integer 1–65535 (got ${raw})`)
168
+ }
169
+ opts.port = port
170
+ i += 2
171
+ continue
172
+ }
173
+ if (arg === '--user-data') {
174
+ const raw = argv[i + 1]
175
+ if (raw === undefined) fail('--user-data requires a path')
176
+ opts.userData = raw
177
+ i += 2
178
+ continue
179
+ }
180
+ fail(`unknown argument: ${arg}\n\n${HELP}`)
181
+ }
182
+
183
+ return opts
184
+ }
185
+
186
+ /** Same semantics as apps/daemon/src/net/admin-token.ts (plain CJS copy). */
187
+ function ensureAdminToken(path = ADMIN_TOKEN_PATH()) {
188
+ try {
189
+ const existing = readFileSync(path, 'utf8').trim()
190
+ if (existing !== '') {
191
+ chmodSync(path, 0o600)
192
+ return existing
193
+ }
194
+ } catch {
195
+ // absent — mint
196
+ }
197
+ const token = randomBytes(32).toString('hex')
198
+ mkdirSync(dirname(path), { recursive: true })
199
+ const tmp = `${path}.tmp`
200
+ writeFileSync(tmp, token, { encoding: 'utf8', mode: 0o600 })
201
+ renameSync(tmp, path)
202
+ return token
203
+ }
204
+
205
+ // The built contracts module (scripts/build-node.mjs), in the two layouts this script
206
+ // ships in: the installed package (bin/ beside main/) and the monorepo checkout (scripts/
207
+ // beside apps/desktop/out/main/). Same relative-resolution mechanism the serve path already
208
+ // uses for main/daemon/server.js.
209
+ const PROTOCOL_MODULES = [
210
+ join(__dirname, '..', 'main', 'contracts', 'protocol.js'),
211
+ join(__dirname, '..', 'apps', 'desktop', 'out', 'main', 'contracts', 'protocol.js'),
212
+ ]
213
+
214
+ /**
215
+ * The daemon refuses any request that does not announce its exact wire protocol, so this
216
+ * CLI announces it too — read from the built contracts, never a literal copied here, which
217
+ * would silently become a lie the day the protocol moves.
218
+ */
219
+ function protocolHeaders() {
220
+ const modulePath = PROTOCOL_MODULES.find((candidate) => existsSync(candidate))
221
+ if (modulePath === undefined) {
222
+ fail('protocol contracts missing — run `pnpm build` in the monorepo, or reinstall the package')
223
+ }
224
+ const { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } = require(modulePath)
225
+ return { [PROTOCOL_VERSION_HEADER]: String(PROTOCOL_VERSION) }
226
+ }
227
+
228
+ function adminClient(daemonUrl, token) {
229
+ return createTRPCUntypedClient({
230
+ links: [
231
+ httpLink({
232
+ url: `${daemonUrl}/trpc`,
233
+ headers: { authorization: `Bearer ${token}`, ...protocolHeaders() },
234
+ }),
235
+ ],
236
+ })
237
+ }
238
+
239
+ function adminCommandOptions(argv) {
240
+ const result = {
241
+ args: [],
242
+ daemonUrl: `http://127.0.0.1:${process.env.PORCELAIN_DAEMON_PORT || DEFAULT_PORT}`,
243
+ baseUrl: null,
244
+ name: null,
245
+ }
246
+ for (let i = 0; i < argv.length; i += 1) {
247
+ const arg = argv[i]
248
+ if (arg === '--daemon-url' || arg === '--base-url' || arg === '--name') {
249
+ const value = argv[i + 1]
250
+ if (!value) fail(`${arg} requires a value`)
251
+ if (arg === '--daemon-url') result.daemonUrl = value.replace(/\/+$/, '')
252
+ else if (arg === '--base-url') result.baseUrl = value
253
+ else result.name = value
254
+ i += 1
255
+ } else {
256
+ result.args.push(arg)
257
+ }
258
+ }
259
+ return result
260
+ }
261
+
262
+ async function suggestedBaseUrl(client) {
263
+ const lan = await client.query('lanStatus').catch(() => null)
264
+ if (lan?.numericUrl || lan?.url) return lan.numericUrl || lan.url
265
+ const tailnet = await client.query('tailnetStatus').catch(() => null)
266
+ if (tailnet?.url) return tailnet.url
267
+ const cloudflare = await client.query('cloudflareStatus').catch(() => null)
268
+ return cloudflare?.url || null
269
+ }
270
+
271
+ async function runAccessCommand(argv) {
272
+ const options = adminCommandOptions(argv)
273
+ const [action, id] = options.args
274
+ const client = adminClient(options.daemonUrl, ensureAdminToken())
275
+ if (action === 'issue') {
276
+ if (!options.name) fail('access issue requires --name <device>')
277
+ const baseUrl = options.baseUrl || (await suggestedBaseUrl(client))
278
+ if (!baseUrl) fail('no reachable endpoint is enabled; pass --base-url explicitly')
279
+ const result = await client.mutation('issuePairingLink', {
280
+ label: options.name,
281
+ baseUrl,
282
+ })
283
+ process.stdout.write(`${result.url}\n`)
284
+ return
285
+ }
286
+ if (action === 'list') {
287
+ const status = await client.query('accessStatus')
288
+ process.stdout.write(`${JSON.stringify(status, null, 2)}\n`)
289
+ return
290
+ }
291
+ if (action === 'revoke' && id) {
292
+ const status = await client.query('accessStatus')
293
+ if (status.clients.some((entry) => entry.id === id)) {
294
+ await client.mutation('revokeAuthorizedClient', id)
295
+ } else if (status.pairings.some((entry) => entry.id === id)) {
296
+ await client.mutation('revokePairingLink', id)
297
+ } else {
298
+ fail(`no device or pairing link has id ${id}`)
299
+ }
300
+ process.stdout.write(`Revoked ${id}\n`)
301
+ return
302
+ }
303
+ fail('usage: porcelain access issue --name <device> | list | revoke <id>')
304
+ }
305
+
306
+ async function runShareCommand(argv) {
307
+ const options = adminCommandOptions(argv)
308
+ const [target] = options.args
309
+ const client = adminClient(options.daemonUrl, ensureAdminToken())
310
+ if (target === 'status') {
311
+ const [lan, tailnet, cloudflare] = await Promise.all([
312
+ client.query('lanStatus'),
313
+ client.query('tailnetStatus'),
314
+ client.query('cloudflareStatus'),
315
+ ])
316
+ process.stdout.write(`${JSON.stringify({ lan, tailnet, cloudflare }, null, 2)}\n`)
317
+ return
318
+ }
319
+ fail('usage: porcelain share status')
320
+ }
321
+
322
+ async function main() {
323
+ const argv = process.argv.slice(2)
324
+ if (argv[0] === 'access') {
325
+ await runAccessCommand(argv.slice(1))
326
+ return
327
+ }
328
+ if (argv[0] === 'share') {
329
+ await runShareCommand(argv.slice(1))
330
+ return
331
+ }
332
+ const opts = parseArgs(argv)
333
+ if (opts.help) {
334
+ process.stdout.write(HELP)
335
+ process.exit(0)
336
+ }
337
+
338
+ // Prefer an already-set env (systemd unit, shell export) over flag defaults.
339
+ if (!process.env.PORCELAIN_USER_DATA) {
340
+ process.env.PORCELAIN_USER_DATA = opts.userData
341
+ }
342
+ if (!process.env.PORCELAIN_DAEMON_PORT) {
343
+ process.env.PORCELAIN_DAEMON_PORT = String(opts.port)
344
+ }
345
+ // Preserve an explicitly supplied service environment. A flag supplies the same
346
+ // comma-separated format as PORCELAIN_ALLOWED_ORIGIN and is repeated for several Hubs.
347
+ if (
348
+ opts.allowedOrigins.length > 0 &&
349
+ !process.env.PORCELAIN_ALLOWED_ORIGIN &&
350
+ !process.env.PORCELAIN_ALLOWED_ORIGINS
351
+ ) {
352
+ process.env.PORCELAIN_ALLOWED_ORIGIN = opts.allowedOrigins.join(',')
353
+ }
354
+ if (opts.tailnet && opts.cloudflare) {
355
+ fail('--tailnet and --cloudflare cannot be used together. Pick one off-network route.')
356
+ }
357
+ if (opts.tailnet) process.env.PORCELAIN_TAILNET_BIND = '1'
358
+ if (opts.lan) process.env.PORCELAIN_LAN_BIND = '1'
359
+ if (opts.cloudflare) process.env.PORCELAIN_CLOUDFLARE_BIND = '1'
360
+ if (opts.cloudflareHostname && !process.env.PORCELAIN_CLOUDFLARE_HOSTNAME) {
361
+ process.env.PORCELAIN_CLOUDFLARE_HOSTNAME = opts.cloudflareHostname
362
+ }
363
+ if (opts.noWatchdog) process.env.PORCELAIN_NO_STDIN_WATCHDOG = '1'
364
+
365
+ const token = process.env.PORCELAIN_ADMIN_TOKEN || ensureAdminToken()
366
+ process.env.PORCELAIN_ADMIN_TOKEN = token
367
+
368
+ const userData = process.env.PORCELAIN_USER_DATA
369
+ const port = process.env.PORCELAIN_DAEMON_PORT
370
+ const binds = ['127.0.0.1']
371
+ if (process.env.PORCELAIN_TAILNET_BIND === '1') binds.push('tailnet')
372
+ if (process.env.PORCELAIN_LAN_BIND === '1') binds.push('lan')
373
+ if (process.env.PORCELAIN_CLOUDFLARE_BIND === '1') binds.push('cloudflare')
374
+ const allowedOriginValue =
375
+ process.env.PORCELAIN_ALLOWED_ORIGINS || process.env.PORCELAIN_ALLOWED_ORIGIN || ''
376
+ const allowedOriginCount = allowedOriginValue
377
+ .split(',')
378
+ .map((origin) => origin.trim())
379
+ .filter(Boolean).length
380
+
381
+ // Human-facing status on stderr; the daemon still owns the one stdout port line.
382
+ console.error(`[porcelain] user data ${userData}`)
383
+ console.error(`[porcelain] port ${port}`)
384
+ console.error(`[porcelain] binds ${binds.join(', ')}`)
385
+ console.error(
386
+ `[porcelain] cors ${allowedOriginCount === 0 ? 'same-origin only' : `${allowedOriginCount} trusted Hub origin(s) configured`}`,
387
+ )
388
+ console.error(`[porcelain] admin file ${ADMIN_TOKEN_PATH()}`)
389
+ console.error('[porcelain] pair with: porcelain access issue --name <device>')
390
+ console.error('[porcelain] starting… Ctrl+C to stop')
391
+
392
+ const serverEntry = [
393
+ join(__dirname, '..', 'main', 'daemon', 'server.js'),
394
+ // Monorepo fallback used by the composed proof; the published package uses
395
+ // the first layout above after `daemon:dist` assembles it.
396
+ join(__dirname, '..', 'apps', 'desktop', 'out', 'main', 'daemon', 'server.js'),
397
+ ].find((candidate) => existsSync(candidate))
398
+ if (serverEntry === undefined) {
399
+ fail('daemon entry missing — package is corrupt; reinstall')
400
+ }
401
+ // Side-effect entry: boots the HTTP/WS listeners (same as `node main/daemon/server.js`).
402
+ require(serverEntry)
403
+ }
404
+
405
+ main().catch((error) => fail(error instanceof Error ? error.message : String(error)))