@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.
- package/README.md +118 -0
- package/bin/porcelain.js +405 -0
- package/main/chunks/index-BlxZmzlT.js +10580 -0
- package/main/contracts/protocol.js +36 -0
- package/main/daemon/server.js +16387 -0
- package/package.json +42 -0
- package/renderer/apple-touch-icon.png +0 -0
- package/renderer/assets/SymbolsNerdFontMono-Regular-DDncdh2F.ttf +0 -0
- package/renderer/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
- package/renderer/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/renderer/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/renderer/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/renderer/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/renderer/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- package/renderer/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- package/renderer/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- package/renderer/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- package/renderer/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- package/renderer/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- package/renderer/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/renderer/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
- package/renderer/assets/ghostty-write-pty-CVPirtOQ.wasm +0 -0
- package/renderer/assets/index-BHMijQkc.js +339 -0
- package/renderer/assets/index-BbXe8VR5.css +1 -0
- package/renderer/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/renderer/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/renderer/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/renderer/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/renderer/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/renderer/assets/logo-DqyMvkTw.png +0 -0
- package/renderer/favicon-16.png +0 -0
- package/renderer/favicon-32.png +0 -0
- package/renderer/icon-192.png +0 -0
- package/renderer/icon-512.png +0 -0
- package/renderer/index.html +41 -0
- 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.
|
package/bin/porcelain.js
ADDED
|
@@ -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)))
|