@catheadowl/ukp 0.2.1 → 0.2.3

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 CHANGED
@@ -1,13 +1,13 @@
1
- # UKP
1
+ # UKP — Unified Knowledge Plane
2
2
 
3
3
  Folder as a Service: turn folders into named knowledge endpoints — one
4
4
  stable command surface, on this machine or your own server.
5
5
 
6
6
  Register a folder and it becomes an endpoint for humans and agents alike —
7
7
  nothing is started, the folder stays a folder. Navigate, read, and search
8
- it; propose changes back to the owner as reviewable suggestions; update
9
- provider-owned indexes (local today). No physical paths to memorize, no
10
- provider-specific commands to relearn.
8
+ it; propose changes back to the owner as reviewable suggestions; refresh
9
+ the search index behind it (local folders today). No physical paths to
10
+ memorize, no provider-specific commands to relearn.
11
11
 
12
12
  > [!NOTE]
13
13
  > Early-stage CLI (MVP) — commands and behavior may still change.
@@ -27,7 +27,8 @@ $ ukp read ukp://notes/2026/q3-plan.md # read straight from the reference
27
27
  # Q3 Plan
28
28
  Goals for Q3: ship the knowledge plane.
29
29
 
30
- $ ukp propose --endpoint notes --id typo-fix --file typo-fix.md # the write face: declare propose first
30
+ $ echo '[capabilities.propose]' >> .ukp/service.toml # the write face is opt-in: declare propose
31
+ $ ukp propose --endpoint notes --id typo-fix --file typo-fix.md
31
32
  proposal typo-fix created (revision 1)
32
33
  ```
33
34
 
@@ -53,38 +54,41 @@ predictable scope, and a single command surface. Use UKP when:
53
54
  command will touch before running it.
54
55
  - Navigate the Markdown structure of an endpoint — folders, descriptions,
55
56
  depth — with zero provider dependency.
56
- - Read endpoint-scoped references through `read/file` — if a file moves,
57
- reference recovery re-finds it (git history first, then search). The
57
+ - Read endpoint files through the built-in `read/file` capability — if a
58
+ file moves, reference recovery re-finds it best-effort (git history
59
+ first, then search). The
58
60
  optional `read/qmd` goes through QMD (an external tool — see
59
61
  Requirements). `read` and `nav` are derived defaults of every local
60
62
  Service.
61
- - Run base lexical search with `rg` across endpoint files — provider-free
62
- and on by default, shaped into `read`-ready references.
63
- - Search one endpoint, a workspace default scope, the whole local Registry,
64
- or endpoints you have explicitly declared as its dependencies
65
- (authority/context links), with explicit recursion (QMD-backed).
63
+ - Run base lexical search with `rg` across endpoint files — no search
64
+ provider needed, on by default, shaped into `read`-ready references.
65
+ - Search indexed content, QMD-backed: one endpoint, the workspace default
66
+ scope, or the whole local Registry. `--recursive` additionally searches
67
+ the endpoints a Service declares in its manifest `dependencies` —
68
+ nothing recurs unless declared.
66
69
  - Propose changes as an idempotent, reviewable suggestion — the proposal
67
70
  lands in the endpoint's inbox and the verdict stays with its owner.
68
71
  - Update provider-owned indexes through a stable UKP command (QMD-backed).
69
- - Serve an endpoint over HTTP — a single endpoint, or the whole registry as
72
+ - Serve an endpoint over HTTP — a single endpoint, or every local binding as
70
73
  a host door — when you want it reachable from other machines.
71
74
  - Want a read-only endpoint? Declare zero capabilities — an empty
72
75
  `[capabilities]` table still registers one (derived `read`/`nav` only).
73
76
  - Give agents JSON output (`--json`; `ukp read` uses `--format json`),
74
- reference sidecars, and
75
- `ukp://` handoff keys when they need machine-readable results.
77
+ reference sidecars, docid handoff keys, and durable `ukp://` references
78
+ when they need machine-readable results.
76
79
 
77
- UKP owns endpoint names, Registry bindings, Client scope, capability
78
- selection, routing, and output shape; the provider owns collection setup,
79
- indexes, ranking, and maintenance. UKP points to the delegated setup path —
80
- it does not rewrite provider config for you.
80
+ UKP owns endpoint names, Registry bindings, scope, and output shape; the
81
+ provider owns collection setup, indexes, ranking, and maintenance. UKP
82
+ points you at the provider's own setup — it does not rewrite provider
83
+ config for you.
81
84
 
82
85
  ## Install
83
86
 
84
87
  Requirements:
85
88
 
86
- - Bun `1.3.14` or newer — npm installs the `ukp` command, but the CLI runs
87
- on Bun.
89
+ - Bun `1.3.14` or newer, installed by you — the npm package installs the
90
+ `ukp` command, but the CLI runs on Bun; when `bun` is missing the command
91
+ says how to install it instead of showing a stack trace.
88
92
  - QMD on `PATH` only for the QMD-backed capabilities (`search/qmd`,
89
93
  `read/qmd`, `update/qmd`). QMD is an external tool maintained as a
90
94
  separate project; see its own release channel for installation. UKP
@@ -114,8 +118,8 @@ For a human or agent starting from a folder:
114
118
  7. `ukp search "<query>" --endpoint <name>` finds indexed matches after
115
119
  provider setup.
116
120
 
117
- No QMD installed? Stop at step 6 — `nav`, `read`, `rg`, and `propose` work on
118
- any endpoint without it.
121
+ No QMD installed? Stop at step 6 — `nav`, `read`, and `rg` work on any
122
+ endpoint without it; `propose` works wherever it's declared.
119
123
 
120
124
  An agent can carry out the same flow on your behalf.
121
125
 
@@ -125,20 +129,21 @@ For workspace defaults, use `ukp guide client` and `.ukp/client.toml`.
125
129
 
126
130
  Grouping below follows `ukp --help`; every command stays a flat
127
131
  `ukp <verb>`. `--endpoint <name>` is the canonical endpoint selector
128
- (`-c` is a compatibility alias); `-g` selects the full local Host Registry
129
- where supported.
132
+ (`ukp unregister <name>` still takes the endpoint as a positional legacy
133
+ form); `-g` selects the full local Host Registry where supported.
130
134
 
131
- The same five commands work on local and remote endpoints — consuming a
132
- remote endpoint is a one-command gesture (see below).
135
+ The same five commands — search, read, nav, rg, propose — work on local and
136
+ remote endpoints; consuming a remote endpoint is a one-command gesture
137
+ (see below).
133
138
 
134
139
  Endpoint commands:
135
140
 
136
141
  | Command | What it does |
137
142
  |---|---|
138
- | `ukp search` | Indexed search through the endpoint's provider (QMD today); `--recursive` expands endpoints explicitly declared as dependencies (authority/context). Results hand off via `ukp://` references. |
139
- | `ukp read` | Reads an endpoint-scoped resource — exact path, `ukp://` URI, or `docid` handoff key. |
143
+ | `ukp search` | Indexed search through the endpoint's provider (QMD today); `--recursive` expands the manifest's declared `dependencies`. Results hand off via docid session keys and verified `ukp://` references. |
144
+ | `ukp read` | Reads an endpoint-scoped resource — exact path, `ukp://` URI, or a `docid` handed off by search results. |
140
145
  | `ukp nav` | Markdown outline of an endpoint (`--depth`, respects `.gitignore`); on by default, configurable via `[capabilities.nav]`. |
141
- | `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default; results become `read`-ready `ukp://` references. A missing rg binary skips the endpoint with a warning (a single-endpoint run exits non-zero). |
146
+ | `ukp rg` | Lexical grep (ripgrep) across endpoint files — provider-free, on by default; results become `read`-ready `ukp://` references. A missing rg binary skips the endpoint with a warning. |
142
147
  | `ukp propose` | Submits an idempotent change proposal (suggestion box — the owner decides what happens next). Created/unchanged/updated; the revision bumps only on `updated`. |
143
148
 
144
149
  Registry commands:
@@ -146,8 +151,8 @@ Registry commands:
146
151
  | Command | What it does |
147
152
  |---|---|
148
153
  | `ukp init service` | Creates a minimal `.ukp/service.toml`. |
149
- | `ukp register` / `ukp unregister --endpoint <name>` | Manages Host Registry bindings. `ukp register --url <url>` registers a remote endpoint under a local handle — the declared name by default, or `--name <handle>` when that name is already taken (two hosts both declaring `notes` become your `notes` and `ali-notes`); `--endpoint <name>` stays an expected-name assertion on the declared name, and `--token <token>` stores the bearer credential in the binding. Identity is pinned TOFU-style (trust on first use), and self-signed certificates are pinned automatically. A **host door** url imports every endpoint behind it — the `--endpoint` assertion narrows the import to exactly that one endpoint, `--select` narrows to a subset, and re-running refreshes idempotently. |
150
- | `ukp list` | Lists registered endpoints with their declared capabilities. Door drift shows as a stderr note (`door <origin>: N unimported endpoint(s) …`) — importing stays an explicit gesture. |
154
+ | `ukp register` / `ukp unregister --endpoint <name>` | Manages Host Registry bindings — local folders and remote endpoints alike (`ukp register --url`, see Remote in 60 seconds). `--name <handle>` disambiguates collisions: `notes` vs `ali-notes`. |
155
+ | `ukp list` | Lists registered endpoints with their declared capabilities. New endpoints on a host door show as a stderr note (`door <origin>: N unimported endpoint(s) …`) — importing stays an explicit gesture. |
151
156
 
152
157
  Operations commands:
153
158
 
@@ -156,7 +161,7 @@ Operations commands:
156
161
  | `ukp diagnose` | Checks a Service folder or endpoint scope for wiring problems. |
157
162
  | `ukp inspect` | Explains current scope, bindings, manifest capabilities, and provider availability. |
158
163
  | `ukp update` | Runs provider-owned maintenance when `update/qmd` is declared (local endpoints today). |
159
- | `ukp serve` | Serves one endpoint — or, without `--endpoint`, the whole registry as a **host door** — over HTTP: a discovery document plus the five command routes (the write face requires the endpoint to declare propose). Loopback by default; `UKP_SERVE_TOKEN` enables bearer auth; `--tls` for HTTPS. |
164
+ | `ukp serve` | Serves one endpoint — or, without `--endpoint`, every local binding as a **host door** — over HTTP (a discovery document plus the five command routes; loopback by default). |
160
165
 
161
166
  Help commands:
162
167
 
@@ -180,7 +185,7 @@ The two fastest paths — each block notes where its commands run:
180
185
  # From the consumer machine — SSH between two personal machines; the door is
181
186
  # woken on demand, nothing to start on the host (prerequisites: ssh
182
187
  # reachable + ukp on its PATH):
183
- ukp register --url ssh://<host> # once; TOFU stored
188
+ ukp register --url ssh://<host> # once; trust-on-first-use pinned
184
189
  ukp read --endpoint <name> notes/x.md # just works, like local
185
190
 
186
191
  # Native TLS on a bare IP — self-signed, pinned automatically at registration.
@@ -188,11 +193,18 @@ ukp read --endpoint <name> notes/x.md # just works, like local
188
193
  # register line runs on the consumer machine:
189
194
  UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 0.0.0.0 --port 8570 --tls
190
195
  ukp register --url https://<ip>:8570 --endpoint <name> --token <token>
191
- # NAT/EIP cloud host (public IP on no NIC)? Name it explicitly — the persisted
192
- # cert re-signs over the same key, so the pin (and every registration) survives:
193
- # ukp serve … --tls --tls-san <public-ip>
194
196
  ```
195
197
 
198
+ NAT/EIP cloud host (public IP on no NIC)? Name it with
199
+ `ukp serve --tls --tls-san <public-ip>` — the persisted certificate re-signs
200
+ over the same key, so the pin (and every registration) survives; details in
201
+ the deployment guide.
202
+
203
+ Registration mechanics: a **host door** url imports every endpoint behind it
204
+ (`--endpoint <name>` checks the served name and narrows the import to that
205
+ one, `--select` narrows to a subset); `--token` is stored with the binding;
206
+ re-running a registration refreshes it idempotently.
207
+
196
208
  Remote endpoints take the same commands as local ones — search, read, nav,
197
209
  rg, propose. The full guide — the host door (one port for the whole
198
210
  registry), Caddy and Tailscale fronting, the certificate pinning lifecycle,
@@ -210,9 +222,7 @@ and the hardening posture — lives in the
210
222
 
211
223
  ## Documentation
212
224
 
213
- - [Remote deployment](https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md) — serving endpoints over
214
- SSH/HTTPS, the host door, Caddy and Tailscale fronting, the certificate
215
- pinning lifecycle, and the hardening posture.
225
+ - [Remote deployment](https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md) — serving endpoints across machines: the full guide.
216
226
  - [Contributing](https://github.com/CatheadOwl/ukp/blob/main/CONTRIBUTING.md) — development setup and release packaging.
217
227
  - [Changelog](https://github.com/CatheadOwl/ukp/blob/main/CHANGELOG.md) — what changed per release.
218
228
  - Repository: https://github.com/CatheadOwl/ukp ·
@@ -0,0 +1,309 @@
1
+ # Remote deployment
2
+
3
+ Operational guide for exposing UKP endpoints over the network. For the
4
+ two fastest consumer-side paths (SSH between personal machines, native TLS
5
+ on a bare IP), see the README's "Remote in 60 seconds"; this guide covers
6
+ every deployment shape end to end.
7
+
8
+ **Choosing a path by burden** (someone always holds the port — the only
9
+ question is who): the ssh:// path wakes the door on demand and adds zero
10
+ resident processes on every OS (Windows hosts enable the built-in OpenSSH
11
+ Server feature once). The https path needs a resident listener: on Linux,
12
+ systemd socket activation holds the port and spawns the door per use
13
+ (zero resident ukp); on Windows there is no systemd equivalent and a bun
14
+ process cannot natively be a service — the resident door runs as a
15
+ hidden logon task (no visible window, bounded crash retry; see the
16
+ Windows section), and only unattended-boot always-on would want a real
17
+ service wrapper (WinSW-class), or prefer ssh:// there. The https door is
18
+ the right shape when consumers have no SSH credentials.
19
+
20
+ `ukp serve` speaks plain HTTP by default; TLS and public exposure are either
21
+ **native** (`--tls` self-signs through the local openssl, `--tls-cert/--tls-key`
22
+ serve your own certificate) or **delegated to a fronting component** (reverse
23
+ proxy, SSH tunnel, overlay). The trust model is public PKI, or automatic TOFU
24
+ pinning for self-signed and private-CA certificates. **Authentication is
25
+ deny-by-default**: serving requires `UKP_SERVE_TOKEN`; tokenless serving needs
26
+ an explicit `--allow-anonymous` and is refused off loopback. A reverse proxy on
27
+ the same host forwards from the public side to the loopback bind, so proxied
28
+ deployments treat the token as mandatory (serve cannot see past its own bind
29
+ address).
30
+
31
+ ## Via SSH (two personal machines — zero extra components, zero ceremony)
32
+
33
+ The `ssh://` URL scheme makes the transport transparent AND wakes the door
34
+ on demand: each invocation runs one ssh process that opens the forward and
35
+ starts a pinned loopback `ukp serve` on the host (the door serves the host
36
+ Registry fresh and self-reaps when idle — nothing to pre-start, no token:
37
+ SSH carries encryption and auth). Later invocations within a couple of
38
+ minutes reuse the warmed SSH connection. Prerequisites on the host: ssh
39
+ reachable (your key) and ukp on its PATH. A first call that cannot find
40
+ `ukp` on the remote PATH says so with the remedy.
41
+
42
+ ```bash
43
+ ukp register --url ssh://<host> # once; the first call wakes the door
44
+ ukp read --endpoint <name> notes/x.md # just works, like local
45
+ ```
46
+
47
+ Notes:
48
+ - The `ssh://` url port selects nothing — the woken door listens on a
49
+ client-chosen loopback port per invocation.
50
+ - **Host prerequisite = a standard install.** `npm install -g
51
+ @catheadowl/ukp` (or `bun add -g`) on the host is all it takes — the wake
52
+ command prepends the standard install locations (`~/.bun/bin`,
53
+ `~/.npm-global/bin`, brew prefixes, then `$PATH`) before exec, so both
54
+ `ukp` and its bun shebang resolve even though `ssh host cmd` runs a bare
55
+ non-interactive PATH. Verified with a user-prefix npm install (ukp + bun
56
+ both in `~/.npm-global/bin`, invisible to a bare shell):
57
+ wake register 5.2s, day-2 read 4.8s, no orphans. Only exception:
58
+ nvm-style versioned layouts have no fixed path shape — link once with
59
+ `sudo ln -s "$(command -v ukp)" /usr/local/bin/ukp`.
60
+ - Consecutive invocations share an ssh multiplexing master for two minutes
61
+ (handshake amortization); Windows' native ssh has no multiplexing and
62
+ quietly pays the handshake per call.
63
+ - To lock down what SSH may run, restrict the login shell (git-shell
64
+ style). A forced `command=` in authorized_keys does NOT compose with
65
+ wake — the client chooses the door port per invocation, which a fixed
66
+ forced command cannot express.
67
+
68
+ Windows hosts work out of the box: when the host's OpenSSH default shell
69
+ (cmd.exe) rejects the POSIX wake form, the client detects the signature and
70
+ resends the pinned cmd.exe form automatically — nothing to configure beyond
71
+ the OpenSSH Server feature and your key. A powershell DefaultShell is not
72
+ supported (its `-c` quoting drops the wake form).
73
+
74
+ ## Native TLS (bare IP, no domain — zero extra components)
75
+
76
+ `--tls` self-signs on first start (identity = keypair, persisted under the
77
+ Service folder's `.ukp/tls/`; SAN covers the host's addresses). Clients pin
78
+ the certificate automatically at registration — same command as any other
79
+ remote, no flags added:
80
+
81
+ ```bash
82
+ UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 0.0.0.0 --port 8570 --tls
83
+ # consumer machine (self-signed is TOFU-pinned at registration):
84
+ ukp register --url https://<ip>:8570 --endpoint <name> --token <token>
85
+ ukp read --endpoint <name> notes/x.md # just works, like local
86
+ ```
87
+
88
+ A certificate renewal that keeps the key re-anchors transparently; a new key
89
+ (reinstall) blocks with both fingerprints until you re-register. Prefer real
90
+ certificates? Let's Encrypt issues IP-address certificates (GA 2026-01) —
91
+ run certbot with a renewal timer and point `--tls-cert/--tls-key` at the
92
+ files; clients then need no pinning at all.
93
+
94
+ **NAT/EIP cloud hosts** (public IP on no NIC — Alibaba/AWS-style 1:1 NAT):
95
+ the automatic SAN coverage can never see the public address, so name it
96
+ explicitly with `--tls-san` (repeatable; DNS names too):
97
+
98
+ ```bash
99
+ UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 0.0.0.0 --port 8570 \
100
+ --tls --tls-san <public-ip>
101
+ ```
102
+
103
+ A persisted certificate missing a requested entry is re-signed over the
104
+ SAME key — the pin (and thus every client registration) survives, the
105
+ banner honestly reports `self-signed identity (re-signed)`, and coverage
106
+ only grows: the re-sign keeps everything the certificate already carried
107
+ (so changing the flag list loses nothing), and already-covered or
108
+ narrower requests reuse the certificate untouched. Explicit `--tls-cert` certificates carry their own SAN, so
109
+ `--tls-san` next to them is a usage error, not a no-op.
110
+
111
+ ## Windows host (https — resident form)
112
+
113
+ Windows has no socket-activation equivalent, so the https path on a
114
+ Windows host is the resident form: one-time setup, auto-start at logon,
115
+ one bun process always running — with **no visible window** (a hidden
116
+ launcher owns the console; a popping cmd box is not an acceptable product
117
+ form) and bounded crash retry. Verified end to end (register/TOFU →
118
+ read → nav, plus zero-flash and crash-recovery drills) on a Windows 11
119
+ host with a plain user-level install. Steps 3–6 are exactly what
120
+ `ukp serve --print-task --host 0.0.0.0 --tls`
121
+ (or `--tls-cert/--tls-key`) prints for you — the generator assembles the
122
+ mechanical half (start script, hidden launcher, Task Scheduler command,
123
+ firewall rule, token placeholder); this section keeps the judgment calls
124
+ and the pitfalls the verification run caught:
125
+
126
+ 1. Install the usual way (bun's official installer + `npm i -g
127
+ @catheadowl/ukp` — the npm shim needs bun on PATH, both land in
128
+ per-user PATH dirs, which is fine here: the door is started by a
129
+ logged-on task, not a bare ssh shell).
130
+ 2. `ukp init service` in each folder, then run `ukp register` from inside
131
+ it (register takes no folder argument) for every endpoint to expose.
132
+ 3. Certificate — generate ONCE (Git for Windows' openssl works; avoids
133
+ any runtime openssl dependency of `--tls`):
134
+ `openssl req -x509 -newkey rsa:2048 -nodes -days 825 -keyout
135
+ %USERPROFILE%\.ukp\tls\key.pem -out %USERPROFILE%\.ukp\tls\cert.pem
136
+ -subj "/CN=ukp" -addext "subjectAltName=IP:<lan-ip>,DNS:localhost"`
137
+ 4. `%USERPROFILE%\.ukp\start-door.cmd` (CRLF!) — set UKP_SERVE_TOKEN,
138
+ run `ukp serve --host 0.0.0.0 --port 8570 --tls-cert ... --tls-key ...`
139
+ with the door.log redirect. No PATH export: the launcher finds bun
140
+ itself, and the logged-on task carries the per-user PATH. Write it
141
+ with a real editor (writing cmd files over ssh echo mangles `%`
142
+ escaping — copy the file instead).
143
+ 5. `%USERPROFILE%\.ukp\start-door-hidden.vbs` (CRLF, real editor) — the
144
+ hidden launcher the task actually runs: it starts the console hidden
145
+ at process creation (nothing ever flashes), captures everything to
146
+ door.log, propagates the exit code, and retries a crashed door
147
+ 3 times, 30s apart. `ukp serve --print-task` prints it verbatim.
148
+ 6. `schtasks /Create /TN ukp-door /TR "wscript.exe
149
+ \"%USERPROFILE%\.ukp\start-door-hidden.vbs\"" /SC ONLOGON /F` (from an
150
+ elevated shell — creating an ONLOGON task from a plain one fails with
151
+ Access denied) + a firewall rule for the port; `schtasks /Run /TN
152
+ ukp-door` to start now.
153
+ 7. Client: `ukp register --url https://<host>:8570 --token <token>` —
154
+ TOFU pins the certificate; re-registering refreshes the pin.
155
+
156
+ Pitfalls the verification run caught:
157
+ - **A silent bind failure**: if something already listens on the port
158
+ (a leftover manual door, say), the new door exits EADDRINUSE invisibly
159
+ — door.log carries the line (the hidden launcher captures everything);
160
+ check `netstat -ano | findstr :8570` and the PID's command line.
161
+ - ONLOGON starts at LOGON, not boot, and the launcher's crash retry is
162
+ bounded (3×30s): after an unattended reboot nobody logs into, or after
163
+ exhausted retries, the door is down until the next logon or a manual
164
+ `schtasks /Run`. Unattended-boot always-on would need WinSW/NSSM-class
165
+ wrapping — or prefer ssh:// there. (Task Scheduler's own
166
+ restart-on-failure setting does not fire on exit codes — do not rely
167
+ on it.)
168
+ - To restart the door by hand (new token, ukp upgrade, new cert):
169
+ `taskkill /PID <door-pid> /T /F`, then `schtasks /Run /TN ukp-door` —
170
+ `schtasks /End` does not reliably kill the process tree.
171
+ - `rg` on the host is the host's business — no ripgrep installed means
172
+ the remote `rg` capability reports unavailable (everything else works).
173
+
174
+ ## Socket activation (Linux — no resident process on the https path either)
175
+
176
+ systemd can hold the listening port itself and spawn the door on first
177
+ connection; the door then self-reaps after `--max-idle` seconds of no
178
+ requests and systemd re-spawns it on the next one. Nothing resident
179
+ between uses — the same zero-maintenance posture as the ssh wake, for the
180
+ consumer-facing https door (Cockpit and Ubuntu's own sshd run this way).
181
+
182
+ Two user-level units (matches a user-local install). Two systemd facts
183
+ learned the hard way: **`Environment=` does not expand
184
+ `%h`/`$HOME`** (specifiers work in `ExecStart=` paths but NOT inside
185
+ `Environment=` — a literal `%h/...` PATH yields 203/EXEC), and **units
186
+ source no profile** — so write literal paths:
187
+
188
+ ```ini
189
+ # ~/.config/systemd/user/ukp-door.socket
190
+ [Unit]
191
+ Description=ukp host door (socket-activated)
192
+
193
+ [Socket]
194
+ ListenStream=8570
195
+
196
+ # ~/.config/systemd/user/ukp-door.service
197
+ [Unit]
198
+ Description=ukp host door
199
+
200
+ [Service]
201
+ Environment=PATH=/home/<user>/.npm-global/bin:/home/<user>/.bun/bin:/usr/local/bin:/usr/bin
202
+ EnvironmentFile=-/home/<user>/.config/ukp-door.env # UKP_SERVE_TOKEN=… (chmod 600)
203
+ ExecStart=/home/<user>/.npm-global/bin/ukp serve --systemd-socket --max-idle 60 --tls
204
+ ```
205
+
206
+ ```bash
207
+ loginctl enable-linger "$USER" # let the socket answer while you're
208
+ # not logged in
209
+ systemctl --user daemon-reload
210
+ systemctl --user enable --now ukp-door.socket
211
+ # nothing is running yet — the first client connection spawns the door:
212
+ ukp register --url https://<host>:8570 --token <token> # cold start, TOFU-pins
213
+ ```
214
+
215
+ Notes:
216
+ - The port belongs to the SOCKET unit — serve runs `--systemd-socket`
217
+ (`--port` is rejected alongside) and requires `UKP_SERVE_TOKEN`: the
218
+ bind address is the unit's (possibly public), so tokenless serving is
219
+ not verifiable from inside the process.
220
+ - TLS: `--tls` self-signs on first start (identity under `~/.ukp/tls/`);
221
+ clients pin at registration. LE/mkcert: `--tls-cert/--tls-key` as usual.
222
+ - Do NOT put an HTTP health-check in front of the socket — it would wake
223
+ the door on every probe and defeat `--max-idle`.
224
+ - `systemctl --user` over bare ssh needs `XDG_RUNTIME_DIR=/run/user/$(id -u)`.
225
+ - Restarting the SOCKET while a door is still running is refused
226
+ ("Socket service already active") — `systemctl --user restart
227
+ ukp-door.service` instead, or let the door idle out first.
228
+ - **NAT/EIP hosts**: `--tls` self-signs with the machine's interface
229
+ addresses — a public EIP is not among them, and registration would fail
230
+ CERT_ALTNAME_INVALID. Add `--tls-san <public-ip>` next to `--tls`
231
+ (see Native TLS above): the SAN grows, the key (and pin) does not.
232
+
233
+ ## Host door (one host, many endpoints — one gesture, zero tokens)
234
+
235
+ Trust's natural unit is the host, not the endpoint: if you can ssh to a
236
+ machine, per-endpoint tokens are ceremony — and with the on-demand wake you
237
+ don't even run the door yourself: `ukp register --url ssh://<host>`
238
+ wakes a loopback door (`docker DOCKER_HOST=ssh://` posture — SSH carries
239
+ encryption and auth), imports everything behind it, and the door reaps
240
+ itself when idle:
241
+
242
+ ```bash
243
+ # nothing to run on the host first — one gesture imports everything:
244
+ ukp register --url ssh://<host>
245
+ # door ssh://<host>: 2 endpoint(s)
246
+ # imported: notes (search,propose)
247
+ # imported: archive (-)
248
+ ukp list # flat rows, urls like ssh://<host>/notes
249
+ ukp read ukp://notes/plan.md # day-2 is byte-identical to today's remotes
250
+ ```
251
+
252
+ A manually started door still works (e.g. under systemd, for a host you
253
+ deliberately keep serving): `ukp serve --allow-anonymous` without
254
+ `--endpoint` exposes the same whole-registry door on a fixed loopback port —
255
+ but the wake makes that an explicit choice, not a prerequisite.
256
+
257
+ Imported endpoints are ordinary remote bindings (`url = <door>/<name>`); a
258
+ name already bound elsewhere is skipped with a visible reason, re-running
259
+ the import refreshes pins idempotently, and `--select a,b` narrows the
260
+ take. When the door grows, `ukp list` says so on stderr (`1 unimported
261
+ endpoint(s): …`) — importing it stays your call. The consumer variant of
262
+ the same door is public: `ukp serve --host 0.0.0.0 --tls-cert …` with
263
+ `UKP_SERVE_TOKEN`, imported with `ukp register --url https://<ip>:8570
264
+ --token <t>` for the whole door, or `ukp register --url https://<ip>:8570
265
+ --endpoint <name> --token <t>` for one asserted endpoint (token and pinned
266
+ certificate copied into each binding).
267
+
268
+ ## Behind Caddy (public domain)
269
+
270
+ ```
271
+ # Caddyfile — automatic Let's Encrypt
272
+ kb.example.com {
273
+ reverse_proxy 127.0.0.1:8570
274
+ }
275
+ ```
276
+
277
+ ```bash
278
+ UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 127.0.0.1 --port 8570
279
+ # on the consumer machine:
280
+ UKP_ENDPOINT_<NAME>_TOKEN=<token> ukp register --url https://kb.example.com
281
+ ```
282
+
283
+ ## Via Tailscale (two machines on any network — including the same LAN)
284
+
285
+ Enable HTTPS certificates on your tailnet once (admin console: DNS →
286
+ MagicDNS → HTTPS Certificates); Tailscale then provisions Let's Encrypt
287
+ certificates for your `*.ts.net` hostnames.
288
+
289
+ ```bash
290
+ # both machines: tailscale up (same tailnet)
291
+ # server machine:
292
+ UKP_SERVE_TOKEN=<token> ukp serve --endpoint <name> --host 127.0.0.1 --port 8570
293
+ tailscale serve --bg --https=443 http://127.0.0.1:8570
294
+ # consumer machine:
295
+ UKP_ENDPOINT_<NAME>_TOKEN=<token> ukp register --url https://<machine>.<tailnet>.ts.net
296
+ ```
297
+
298
+ The overlay covers LAN and roaming machines alike; a fully offline LAN (no
299
+ coordination reachability) is the one gap — a dedicated private-CA recipe is
300
+ not covered here.
301
+
302
+ ## Hardening posture
303
+
304
+ - rate limiting, IP allowlists, and audit logging live at the proxy layer —
305
+ the `ukp` core stays thin;
306
+ - multiple client tokens: comma-separate them — `UKP_SERVE_TOKEN=alice,bob`
307
+ (any listed token authorizes);
308
+ - identity pinning is TOFU on `instance_uid`: a changed endpoint warns by
309
+ default; set `UKP_TOFU=block` on hostile networks to refuse it outright.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catheadowl/ukp",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3.14"
@@ -29,6 +29,7 @@
29
29
  "files": [
30
30
  "bin",
31
31
  "src",
32
+ "docs",
32
33
  "README.md",
33
34
  "LICENSE",
34
35
  "package.json"
@@ -343,11 +343,40 @@ function wakeDoorCommand(remotePort: number, form: WakeShellForm = "posix"): str
343
343
  /** Wrong-shell failure signature for the wake ladder: the remote shell could
344
344
  * not even parse the sent form, so the OTHER form is the retry — as opposed
345
345
  * to a missing-ukp failure, where the shell ran fine and named 'ukp' as the
346
- * thing it cannot find. cmd.exe errors quote the missing token ('sh' is not
347
- * recognized, exit 9009); POSIX shells print "<token>: command not found". */
346
+ * thing it cannot find. Language-independent: cmd.exe locales vary the
347
+ * message body (English, zh-CN, German … all differ) but always quote the
348
+ * missing ASCII token; POSIX shells prefix the
349
+ * token ("sh: command not found"). */
348
350
  function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean {
349
- if (form === "posix") return /'sh' is not recognized|sh: command not found/i.test(diagnostics);
350
- return /'cmd' is not recognized|cmd: command not found/i.test(diagnostics);
351
+ if (form === "posix") return /['"]sh['"]|sh: (?:command )?not found/i.test(diagnostics);
352
+ return /['"]cmd['"]|cmd: (?:command )?not found/i.test(diagnostics);
353
+ }
354
+
355
+ /** One cheap, language-independent roundtrip to learn the host shell family
356
+ * BEFORE the first wake attempt (2026-09-20, liku 9.5 finding): cmd.exe
357
+ * expands `%OS%` to `Windows_NT`; a POSIX shell echoes the literal token.
358
+ * Choosing the form up front avoids the blind POSIX-first attempt costing a
359
+ * full ready-timeout on every Windows invocation, and picks the right tty
360
+ * stance — `-tt` exists for POSIX SIGHUP reaping; on Windows there is no
361
+ * SIGHUP (max-idle bounds the door), and at least one Win32-OpenSSH 9.5
362
+ * build LOSES a quoted remote command under a pty (an interactive cmd
363
+ * swallows it and waits on stdin forever — the no-pty path executes it
364
+ * correctly). Defaults to POSIX (the common server shape) when the probe
365
+ * errors or times out; the ladder remains as the backstop. */
366
+ async function probeHostShellFamily(
367
+ options: { sshCommand?: readonly string[] },
368
+ target: string,
369
+ ): Promise<WakeShellForm> {
370
+ const proc = Bun.spawn(
371
+ [...(options.sshCommand ?? ["ssh"]), "-o", "BatchMode=yes", target, "echo %OS%"],
372
+ { stdout: "pipe", stderr: "pipe", stdin: "ignore" },
373
+ );
374
+ const output = await Promise.race([
375
+ new Response(proc.stdout).text().catch(() => ""),
376
+ Bun.sleep(5_000).then(() => ""),
377
+ ]);
378
+ proc.kill();
379
+ return /Windows_NT/i.test(output) ? "cmd" : "posix";
351
380
  }
352
381
 
353
382
  /** HTTP-level readiness probe through the tunnel (W9): a TCP accept on the
@@ -428,11 +457,10 @@ async function openSshTunnel(
428
457
  // options for the remaining attempts so the endpoint degrades to Tier 0
429
458
  // instead of hard-failing what worked without mux.
430
459
  let muxDisabled = false;
431
- // Shell-form ladder (2026-09-20): POSIX first (the common server shell),
432
- // cmd second — a Windows host whose OpenSSH default shell is cmd.exe fails
433
- // the POSIX form with an 'sh'-not-recognized signature (not a missing-ukp
434
- // failure: the shell never got as far as looking for ukp).
435
- let form: WakeShellForm = "posix";
460
+ // Shell form (2026-09-20): probed up front via `echo %OS%` (language-
461
+ // independent — see probeHostShellFamily), POSIX by default; the wrong-
462
+ // shell ladder below remains the backstop for a lying or timed-out probe.
463
+ let form: WakeShellForm = await probeHostShellFamily(options, target);
436
464
  const attempts = 3;
437
465
  for (let attempt = 1; attempt <= attempts; attempt++) {
438
466
  const localPort = await freePort();
@@ -443,8 +471,11 @@ async function openSshTunnel(
443
471
  // SIGHUP on disconnect — normal disconnect included, the door only ever
444
472
  // died to --max-idle. Forcing a pty gives the session a controlling
445
473
  // terminal, so killing the client reaps the door in seconds (verified:
446
- // kill -9 of the local ssh → door gone within 3s).
447
- "-tt",
474
+ // kill -9 of the local ssh → door gone within 3s). POSIX hosts only:
475
+ // Windows has no SIGHUP (max-idle bounds the door there), and pty
476
+ // sessions lose quoted remote commands on at least one
477
+ // Win32-OpenSSH 9.5 build — the cmd form goes without -tt.
478
+ ...(form === "posix" ? ["-tt"] : []),
448
479
  "-o", "ExitOnForwardFailure=yes",
449
480
  "-o", "BatchMode=yes",
450
481
  ...(muxDisabled ? [] : muxClientOptions()),
package/src/cli.ts CHANGED
@@ -198,9 +198,9 @@ export function renderHelp(): string {
198
198
  ]),
199
199
  "Endpoint names for --endpoint come from 'ukp list'.",
200
200
  "",
201
- "A Service's declared capabilities are backed by a provider (QMD backs search and update today); rg and nav work on endpoint files directly, no provider needed.",
201
+ "A Service's declared capabilities are backed by a provider (QMD, an external tool, backs search and update today); rg and nav work on endpoint files directly, no provider needed.",
202
202
  "",
203
- "A Service is a folder with a manifest (a name plus optional declared capabilities); registering it binds that name as an endpoint you address with --endpoint. The scope is which endpoints commands use when no --endpoint or -g (every registered endpoint) is given; 'ukp guide client' shows how to set the workspace default.",
203
+ "A Service is a folder with a manifest (a name plus optional declared capabilities); registering it binds that name as an endpoint you address with --endpoint. The scope is which endpoints commands use when no --endpoint is given; -g widens a command to every registered endpoint; 'ukp guide client' shows how to set the workspace default.",
204
204
  "",
205
205
  "Guides:",
206
206
  ...GUIDE_TOPICS.map(([topic, summary]) => ` ukp ${`guide ${topic}`.padEnd(17)} ${summary}`),
@@ -227,7 +227,7 @@ function diagnoseBinding(
227
227
  if (isRemoteBinding(binding)) {
228
228
  return {
229
229
  status: "failed",
230
- message: `remote endpoint '${binding.name}' (${binding.url}): diagnose is local-only in ukp_remote W2; reachability shows in 'ukp list'`,
230
+ message: `remote endpoint '${binding.name}' (${binding.url}): diagnose is local-only for remote endpoints; reachability shows in 'ukp list'`,
231
231
  };
232
232
  }
233
233
  try {
@@ -55,7 +55,8 @@ Name and identity:
55
55
 
56
56
  Operational note:
57
57
  - ssh:// hosts: nothing to manage — ukp wakes and reaps doors itself (on Windows, enabling the built-in OpenSSH Server feature once is the low-burden path). SSH lockdown (login-shell restriction, why a forced authorized_keys command= does not compose) and nvm-style install layouts are deployment-handbook material.
58
- - https hosts: keep ukp serve under your normal process manager, for example systemd, Task Scheduler, or a terminal multiplexer. Linux can go resident-free (systemd socket activation, deployment handbook); Windows has no systemd equivalent, so a resident door wants a real service wrapper (WinSW-class) — prefer the ssh:// path there unless consumers have no SSH credentials. The CLI does not install or autostart the door.
58
+ - https hosts: keep ukp serve under your normal process manager, for example systemd, Task Scheduler, or a terminal multiplexer. Linux can go resident-free (systemd socket activation, deployment handbook); Windows has no systemd equivalent, so a resident door wants a real service wrapper (WinSW-class) — prefer the ssh:// path there unless consumers have no SSH credentials. The CLI does not install or autostart the door; on Windows, ukp serve --print-task prints the resident-door artifacts (start script, hidden launcher, Task Scheduler command, firewall rule) for you to review and apply yourself — the token stays yours to paste, never printed.
59
+ - The deployment handbook is where those recipes live: it ships with this package (docs/remote-deployment.md) and online (https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md) — Windows resident door (certificate, start script, Task Scheduler), systemd socket-activation units, Caddy, Tailscale, hardening notes.
59
60
  - serve accepts --max-idle <seconds> to self-exit after that long without requests (the same backstop the woken doors use).
60
61
 
61
62
  Remember:
@@ -189,7 +189,7 @@ function executeInitServiceCommand(args: readonly string[], context: InitCommand
189
189
  `location: ${result.folder}`,
190
190
  "declared_capabilities: -",
191
191
  "derived_capabilities: read, nav",
192
- "provider_free: rg (if rg is installed)",
192
+ "base_tier: rg (external tool; active when ripgrep is installed)",
193
193
  // Folder-existence probe only (no provider state read): an existing
194
194
  // provider index means collections may already be registered — route
195
195
  // to the provider-scoped topic instead of naming provider commands
@@ -66,7 +66,7 @@ function inspectBinding(
66
66
  if (isRemoteBinding(binding)) {
67
67
  return {
68
68
  status: "failed",
69
- message: `remote endpoint '${binding.name}' (${binding.url}): inspect deep-dive is local-only in ukp_remote W2; capabilities show in 'ukp list'`,
69
+ message: `remote endpoint '${binding.name}' (${binding.url}): inspect deep-dive is local-only for remote endpoints; capabilities show in 'ukp list'`,
70
70
  };
71
71
  }
72
72
  try {
@@ -665,9 +665,21 @@ async function collectDoorDriftNotes(
665
665
  const unimported = door.doc.endpoints
666
666
  .map((endpoint) => endpoint.name)
667
667
  .filter((name) => !group.imported.has(name));
668
- if (unimported.length > 0) {
668
+ if (unimported.length === 0) continue;
669
+ // A roster name can be unimported because it COLLIDED — the name is
670
+ // held by another binding, and the plain import remedy would skip it
671
+ // again. Those names get the single-endpoint --name remedy instead
672
+ // (W11 / ADR-REM-007).
673
+ const free = unimported.filter((name) => !endpoints.some((binding) => binding.name === name));
674
+ const taken = unimported.filter((name) => endpoints.some((binding) => binding.name === name));
675
+ if (free.length > 0) {
669
676
  notes.push(
670
- `door ${origin}: ${unimported.length} unimported endpoint(s): ${unimported.join(", ")} — run 'ukp register --url ${origin}' to import`,
677
+ `door ${origin}: ${free.length} unimported endpoint(s): ${free.join(", ")} — run 'ukp register --url ${origin}' to import`,
678
+ );
679
+ }
680
+ if (taken.length > 0) {
681
+ notes.push(
682
+ `door ${origin}: name(s) taken: ${taken.join(", ")} — import under another handle: 'ukp register --url ${origin}/<name> --name <handle>'`,
671
683
  );
672
684
  }
673
685
  } catch {
@@ -66,7 +66,7 @@ const READ_EXIT_BY_ERROR_CLASS: Record<ReadErrorClass, number> = {
66
66
  * dispatch and its precedence stay command-side. */
67
67
  export const READ_SPEC: UkpCommandSpec = {
68
68
  name: "read",
69
- summary: "read an endpoint-scoped resource (exact path, ukp:// URI, or docid handoff key)",
69
+ summary: "read an endpoint-scoped resource (exact path, ukp:// URI, or a docid from search results)",
70
70
  group: "endpoint",
71
71
  description:
72
72
  "Read an endpoint-scoped resource reference from one Service endpoint (endpoint names come from 'ukp list'). "
@@ -0,0 +1,198 @@
1
+ /** Windows resident-door artifact generator (W12): `ukp serve --print-task`
2
+ * renders the process-manager artifacts for an https door on a Windows host
3
+ * — the start script, the hidden launcher (vbs), the Task Scheduler command,
4
+ * the firewall rule — as text the operator reviews and applies. Print-only
5
+ * by construction: UKP installs nothing, starts nothing, and never sees the
6
+ * real token (the script line carries a placeholder; the operator pastes the
7
+ * token into the file they save, under their own permission regime).
8
+ *
9
+ * The emitted shapes mirror the machine-verified liku recipe (deployment
10
+ * handbook, Windows section): a bare `ukp` resolves through the logged-on
11
+ * task's per-user PATH and the self-locating launcher finds bun itself, so
12
+ * no PATH export is emitted. The hidden launcher is the product default
13
+ * (owner ruling 2026-09-21: a visibly popping cmd window is not a correct
14
+ * product form): zero flash (hidden at process creation), door.log capture,
15
+ * exit-code propagation, bounded crash retry — Task Scheduler's own
16
+ * restart-on-failure does not fire on exit codes (disproven on liku). */
17
+
18
+ export interface ServeTaskTlsSelfSigned {
19
+ mode: "self-signed";
20
+ sanEntries: string[];
21
+ }
22
+
23
+ export interface ServeTaskTlsCertificates {
24
+ mode: "certificates";
25
+ certPath: string;
26
+ keyPath: string;
27
+ }
28
+
29
+ export type ServeTaskTls = ServeTaskTlsSelfSigned | ServeTaskTlsCertificates;
30
+
31
+ export interface ServeTaskInput {
32
+ /** Present = single-endpoint mode; absent = host door mode. */
33
+ endpoint?: string;
34
+ host: string;
35
+ port: number;
36
+ tls?: ServeTaskTls;
37
+ }
38
+
39
+ /** Door-shape guards the command layer runs before printing. Pure so both
40
+ * platforms are testable: `platform` is `process.platform` in production.
41
+ * These mirror what serve itself would refuse at task start — they never
42
+ * widen admission, and the TLS one stops a dead-end artifact before it is
43
+ * applied (clients refuse to register plain-http urls off loopback). */
44
+ export function printTaskPreflightError(input: {
45
+ platform: string;
46
+ host: string;
47
+ hasTls: boolean;
48
+ allowAnonymous: boolean;
49
+ }): string | undefined {
50
+ if (input.platform !== "win32") {
51
+ return "--print-task is Windows-only; on Linux prefer systemd socket activation (ukp serve --systemd-socket) — the deployment handbook carries that recipe";
52
+ }
53
+ const loopback =
54
+ input.host === "127.0.0.1" || input.host === "localhost" || input.host === "::1" || input.host === "[::1]";
55
+ if (!loopback && !input.hasTls) {
56
+ return "refusing to print an off-loopback door without TLS: clients refuse to register plain-http urls — pass --tls (self-signed, pinned at registration) or --tls-cert/--tls-key, or bind loopback behind a TLS-terminating proxy";
57
+ }
58
+ if (input.allowAnonymous && !loopback) {
59
+ return "refusing to print this shape: --allow-anonymous is loopback-only and serve would refuse it at task start — set the token in the generated script instead";
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ function serveCommandLine(input: ServeTaskInput): string {
65
+ const tls =
66
+ input.tls === undefined
67
+ ? []
68
+ : input.tls.mode === "self-signed"
69
+ ? ["--tls", ...input.tls.sanEntries.map((entry) => `--tls-san ${entry}`)]
70
+ : [`--tls-cert "${input.tls.certPath}"`, `--tls-key "${input.tls.keyPath}"`];
71
+ return [
72
+ "ukp serve",
73
+ ...(input.endpoint !== undefined ? [`--endpoint ${input.endpoint}`] : []),
74
+ `--host ${input.host}`,
75
+ `--port ${input.port}`,
76
+ ...tls,
77
+ ].join(" ");
78
+ }
79
+
80
+ /** Parent directory of a path string, or undefined for a bare filename. */
81
+ function parentDirOf(path: string): string | undefined {
82
+ const cut = Math.max(path.lastIndexOf("\\"), path.lastIndexOf("/"));
83
+ return cut > 0 ? path.slice(0, cut) : undefined;
84
+ }
85
+
86
+ export function renderServeTaskArtifacts(input: ServeTaskInput): string {
87
+ const serveLine = serveCommandLine(input);
88
+ const out: string[] = [
89
+ "ukp serve --print-task — Windows resident-door artifacts",
90
+ "",
91
+ "Print-only: review these, then apply them yourself. UKP installs nothing,",
92
+ "starts nothing, and supervises nothing — the door's lifecycle belongs to",
93
+ "you and your process manager.",
94
+ "",
95
+ ];
96
+
97
+ if (input.tls?.mode === "certificates") {
98
+ const certDir = parentDirOf(input.tls.certPath);
99
+ out.push(
100
+ "0) Certificate — generate ONCE (Git for Windows' openssl works; this",
101
+ " avoids any runtime openssl dependency of --tls):",
102
+ "",
103
+ " openssl req -x509 -newkey rsa:2048 -nodes -days 825 -keyout",
104
+ ` ${input.tls.keyPath} -out ${input.tls.certPath}`,
105
+ " -subj \"/CN=ukp\" -addext \"subjectAltName=IP:<lan-ip>,DNS:localhost\"",
106
+ "",
107
+ " The SAN must carry the address consumers dial (find <lan-ip> with",
108
+ " ipconfig); a mismatch fails registration with a certificate name",
109
+ " error. Renewing over the same key keeps client pins valid.",
110
+ "",
111
+ );
112
+ if (certDir !== undefined) {
113
+ out.push(` (openssl will not create the directory: if not exist "${certDir}" mkdir "${certDir}" first)`, "");
114
+ }
115
+ }
116
+
117
+ out.push(
118
+ "1) Start script — save as %USERPROFILE%\\.ukp\\start-door.cmd",
119
+ " (write it with a real editor and CRLF line endings; writing cmd",
120
+ " files over ssh echo mangles % escaping)",
121
+ "",
122
+ " @echo off",
123
+ " rem UKP resident door (generated by ukp serve --print-task)",
124
+ " rem The token line is the only secret: mint a long random string",
125
+ " rem (e.g. openssl rand -hex 32), paste it in, keep this file private.",
126
+ " set \"UKP_SERVE_TOKEN=<paste-your-token>\"",
127
+ " rem ukp resolves through the logged-on task's per-user PATH; the",
128
+ " rem launcher finds bun itself — no PATH export needed.",
129
+ " if not exist \"%USERPROFILE%\\.ukp\" mkdir \"%USERPROFILE%\\.ukp\"",
130
+ ` ${serveLine} >> "%USERPROFILE%\\.ukp\\door.log" 2>&1`,
131
+ "",
132
+ "2) Hidden launcher — save as %USERPROFILE%\\.ukp\\start-door-hidden.vbs",
133
+ " (CRLF and a real editor again: the vbs carries literal % signs that",
134
+ " ssh echo would expand — copy the file instead)",
135
+ "",
136
+ " ' UKP door hidden launcher: runs start-door.cmd with NO visible",
137
+ " ' window (hidden at process creation - nothing flashes), captures",
138
+ " ' everything to door.log, waits out the door's lifetime, propagates",
139
+ " ' its exit code, and retries a crashed door 3 times, 30s apart.",
140
+ " Dim shell : Set shell = CreateObject(\"WScript.Shell\")",
141
+ " Dim q : q = Chr(34)",
142
+ " Dim home : home = shell.ExpandEnvironmentStrings(\"%USERPROFILE%\")",
143
+ " Dim cmdline : cmdline = \"cmd /c \" & q & q & home & \"\\.ukp\\start-door.cmd\" & q & \" >> \" & q & home & \"\\.ukp\\door.log\" & q & \" 2>&1\" & q",
144
+ " Dim rc : rc = 0",
145
+ " Dim attempt",
146
+ " For attempt = 1 To 3",
147
+ " rc = shell.Run(cmdline, 0, True)",
148
+ " If rc = 0 Then Exit For",
149
+ " If attempt < 3 Then WScript.Sleep 30000",
150
+ " Next",
151
+ " WScript.Quit rc",
152
+ "",
153
+ "3) Scheduled task — starts the door at LOGON, hidden",
154
+ " (create it from an elevated shell: creating an ONLOGON task from a",
155
+ " plain shell fails with Access denied)",
156
+ "",
157
+ " schtasks /Create /TN ukp-door /TR \"wscript.exe \\\"%USERPROFILE%\\.ukp\\start-door-hidden.vbs\\\"\" /SC ONLOGON /F",
158
+ " schtasks /Run /TN ukp-door",
159
+ "",
160
+ "4) Firewall rule for the port (from an elevated shell)",
161
+ "",
162
+ ` netsh advfirewall firewall add rule name="ukp-door" dir=in action=allow protocol=TCP localport=${input.port}`,
163
+ "",
164
+ "Notes:",
165
+ ...(input.tls?.mode === "self-signed"
166
+ ? [
167
+ "- --tls self-signs at first start using an openssl on PATH (Git",
168
+ " for Windows' openssl qualifies). On a host with no openssl",
169
+ " anywhere, pre-generate the certificate once instead: re-run with",
170
+ " --tls-cert/--tls-key — the handbook's Windows section carries",
171
+ " that recipe.",
172
+ ]
173
+ : []),
174
+ "- No visible window, by design: the hidden launcher starts the console",
175
+ " hidden at process creation (nothing ever flashes on the desktop) and",
176
+ " captures everything to door.log — that redirect is the paper trail (a",
177
+ " silent EADDRINUSE exit leaves a line).",
178
+ "- Crash self-recovery is the launcher's bounded retry: a non-zero exit",
179
+ " is retried up to 3 times, 30s apart; exhausted retries leave the",
180
+ " failure visible (non-zero task result + door.log). Do not rely on",
181
+ " Task Scheduler's restart-on-failure setting — it does not fire on",
182
+ " exit codes.",
183
+ "- To restart the door by hand (new token, ukp upgrade, new cert):",
184
+ " taskkill /PID <door-pid> /T /F, then schtasks /Run /TN ukp-door",
185
+ " (schtasks /End does not reliably kill the process tree).",
186
+ "- LOGON, not boot: the door starts when someone logs on — nobody",
187
+ " logged on means no door. Unattended-boot always-on needs a service",
188
+ " wrapper (WinSW-class) — deliberately outside this recipe; prefer the",
189
+ " ssh:// path or a Linux host (systemd socket activation) for that.",
190
+ "- Re-run ukp serve --print-task with different flags to regenerate;",
191
+ " edits land by saving the files and re-running schtasks /Run.",
192
+ "- Full recipes and hardening: the deployment handbook —",
193
+ " docs/remote-deployment.md in this package, or",
194
+ " https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md",
195
+ "",
196
+ );
197
+ return out.join("\n");
198
+ }
@@ -9,6 +9,7 @@ import {
9
9
  } from "./kit.ts";
10
10
  import { normalizeTlsSanEntry } from "../capabilities/tls-identity.ts";
11
11
  import { DISCOVERY_PATH, startUkpServer, type ServeInfo } from "../server.ts";
12
+ import { printTaskPreflightError, renderServeTaskArtifacts, type ServeTaskTls } from "./serve-task.ts";
12
13
 
13
14
  export interface ServeCommandContext {
14
15
  currentDirectory: string;
@@ -33,11 +34,11 @@ function parseServeTokens(raw: string | undefined): string[] {
33
34
  * over `/e/<name>/` routing. */
34
35
  export const SERVE_SPEC: UkpCommandSpec = {
35
36
  name: "serve",
36
- summary: "serve one endpoint — or the whole registry as a host door — over HTTP for remote UKP clients",
37
+ summary: "serve one endpoint — or the whole registry as a host door, one HTTP server for every endpoint — for remote UKP clients",
37
38
  group: "operations",
38
39
  description:
39
40
  "Expose a registered endpoint over HTTP using the ukp-remote wire, or — without --endpoint — serve every local endpoint as one host door routed by name.",
40
- usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]… | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket]",
41
+ usage: "[--endpoint <name>] [--host <addr>] [--port <n>] [--tls [--tls-san <ip|dns>]… | --tls-cert <pem> --tls-key <pem>] [--max-idle <seconds>] [--systemd-socket] [--print-task]",
41
42
  options: [
42
43
  { flags: "--endpoint <name>", help: "the registered endpoint to expose; omit it to serve the whole registry as a host door (/e/<name>/ routing, all endpoints, one port)" },
43
44
  { flags: "--host <addr>", help: "listen address (default 127.0.0.1, loopback only without a token)" },
@@ -49,6 +50,7 @@ export const SERVE_SPEC: UkpCommandSpec = {
49
50
  { flags: "--allow-anonymous", help: "permit tokenless access on loopback (local testing, or an ssh-forwarded host door where SSH carries encryption and auth; reverse-proxy deployments still require UKP_SERVE_TOKEN)" },
50
51
  { flags: "--max-idle <seconds>", help: "exit after <seconds> without requests (self-reap; the orphan backstop for on-demand-woken doors — fractional values accepted for tests)" },
51
52
  { flags: "--systemd-socket", help: "serve on the systemd socket-activation listener (LISTEN_FDS fd 3) instead of binding a port — the port belongs to your .socket unit; Linux-only" },
53
+ { flags: "--print-task", help: "Windows: print this door's process-manager artifacts (start script, hidden launcher, Task Scheduler command, firewall rule) instead of serving — review and apply them yourself; nothing is installed or started for you, and the token is yours to paste (never printed)" },
52
54
  ],
53
55
  helpSuffix: [
54
56
  "",
@@ -81,7 +83,7 @@ export const SERVE_SPEC: UkpCommandSpec = {
81
83
  " 'ukp register --url ssh://<host>' — zero tokens, docker",
82
84
  " DOCKER_HOST=ssh:// posture). It is not the way to consume a",
83
85
  " same-machine endpoint — register its local path instead.",
84
- " TLS (W5'): pass --tls to serve HTTPS with a self-signed identity",
86
+ " TLS: pass --tls to serve HTTPS with a self-signed identity",
85
87
  " (generated under .ukp/tls/ — under the registry directory in door",
86
88
  " mode —, SAN covers this host's addresses; remote clients TOFU-pin it",
87
89
  " at registration and refresh by re-registering), or --tls-cert/--tls-key",
@@ -107,6 +109,9 @@ export interface ParsedServe {
107
109
  maxIdleSeconds?: number;
108
110
  /** Present = the systemd-passed listener (W10, Linux-only). */
109
111
  systemdSocket?: boolean;
112
+ /** Present = render the Windows process-manager artifacts instead of
113
+ * serving (W12; Windows-only, print-only, never starts a listener). */
114
+ printTask?: boolean;
110
115
  /** `--tls-san` values, normalized to `IP:x`/`DNS:y` SAN form (repeatable);
111
116
  * valid only next to `--tls` — the execute layer enforces the pairing.
112
117
  * Absent when the flag is not passed (parse output stays byte-identical
@@ -115,7 +120,7 @@ export interface ParsedServe {
115
120
  }
116
121
 
117
122
  function toParsedServe(parsed: KitParsed): ParsedServe {
118
- const options = parsed.options as { host?: string; port?: string; endpoint?: string; maxIdle?: string; systemdSocket?: boolean; tlsSan?: string[] };
123
+ const options = parsed.options as { host?: string; port?: string; endpoint?: string; maxIdle?: string; systemdSocket?: boolean; printTask?: boolean; tlsSan?: string[] };
119
124
  const endpoint = options.endpoint;
120
125
  if (endpoint !== undefined && endpoint.length === 0) {
121
126
  throw new KitUsageError("--endpoint <name> must not be empty (omit it to serve a host door)");
@@ -129,12 +134,19 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
129
134
  if (systemdSocket && options.port !== undefined) {
130
135
  throw new KitUsageError("--systemd-socket and --port are mutually exclusive (the socket unit owns the port)");
131
136
  }
137
+ const printTask = options.printTask === true;
138
+ if (printTask && systemdSocket) {
139
+ throw new KitUsageError("--print-task is the Windows resident form; --systemd-socket is the Linux one (systemd holds the port — see the deployment handbook)");
140
+ }
132
141
  let maxIdleSeconds: number | undefined;
133
142
  if (options.maxIdle !== undefined) {
134
143
  maxIdleSeconds = Number(options.maxIdle);
135
144
  if (!Number.isFinite(maxIdleSeconds) || maxIdleSeconds <= 0 || maxIdleSeconds > 86400) {
136
145
  throw new KitUsageError("--max-idle must be a positive number of seconds (at most 86400)");
137
146
  }
147
+ if (printTask) {
148
+ throw new KitUsageError("--print-task is for a resident door; --max-idle self-reaps (it is the on-demand backstop) and would leave the door down until the next logon");
149
+ }
138
150
  }
139
151
  let tlsSan: string[] = [];
140
152
  if (options.tlsSan !== undefined) {
@@ -152,6 +164,7 @@ function toParsedServe(parsed: KitParsed): ParsedServe {
152
164
  port,
153
165
  ...(maxIdleSeconds !== undefined ? { maxIdleSeconds } : {}),
154
166
  ...(systemdSocket ? { systemdSocket } : {}),
167
+ ...(printTask ? { printTask } : {}),
155
168
  ...(tlsSan.length > 0 ? { tlsSan } : {}),
156
169
  };
157
170
  }
@@ -173,6 +186,7 @@ export function renderServeBanner(info: ServeInfo): string {
173
186
  ` endpoints: ${info.door!.endpoints.length > 0 ? info.door!.endpoints.join(", ") : "(none — register endpoints on this host)"}`,
174
187
  ` write: ${info.door!.write.length > 0 ? info.door!.write.join(", ") + " (propose via PUT /e/<name>/v1/propose/<id>)" : "(no endpoint declares propose)"}`,
175
188
  ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback bind; ssh-forwarded clients authenticate by SSH key)"}`,
189
+ ` rg: ${info.rg === "ok" ? "ok" : "missing (rg calls skip with a warning — install ripgrep on this door's PATH)"}`,
176
190
  ...(info.maxIdleSeconds !== undefined
177
191
  ? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
178
192
  : []),
@@ -187,6 +201,7 @@ export function renderServeBanner(info: ServeInfo): string {
187
201
  ` listening: ${info.url}`,
188
202
  ` discovery: ${info.url.startsWith("http") ? `${info.url}${DISCOVERY_PATH}` : info.url}`,
189
203
  ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback only)"}`,
204
+ ` rg: ${info.rg === "ok" ? "ok" : "missing (rg calls skip with a warning — install ripgrep on this door's PATH)"}`,
190
205
  ...(info.maxIdleSeconds !== undefined
191
206
  ? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
192
207
  : []),
@@ -228,6 +243,7 @@ export function executeServeCommand(
228
243
  return executeKitCommand(SERVE_SPEC, args, (parsed) => {
229
244
  const { endpoint, host, port, maxIdleSeconds, systemdSocket, tlsSan = [] } = toParsedServe(parsed);
230
245
  const allowAnonymous = (parsed.options as { allowAnonymous?: boolean }).allowAnonymous === true;
246
+ const printTask = (parsed.options as { printTask?: boolean }).printTask === true;
231
247
  const options = parsed.options as { tls?: boolean; tlsCert?: string; tlsKey?: string };
232
248
  // TLS flag family (W5'): --tls and --tls-cert/--tls-key are mutually
233
249
  // exclusive; the explicit pair must arrive complete. --tls-san only
@@ -242,6 +258,37 @@ export function executeServeCommand(
242
258
  if (tlsSan.length > 0 && options.tls !== true) {
243
259
  throw new KitUsageError("--tls-san is only used with --tls (an explicit --tls-cert certificate carries its own SAN)");
244
260
  }
261
+ // W12 print-only branch: render the Windows artifacts and stop. No
262
+ // listener, no admission token check (the token is pasted into the
263
+ // generated script later), and the env token is deliberately not read
264
+ // — the output is secret-free by construction.
265
+ if (printTask) {
266
+ const preflight = printTaskPreflightError({
267
+ platform: process.platform,
268
+ host,
269
+ hasTls: options.tls === true || options.tlsCert !== undefined,
270
+ allowAnonymous,
271
+ });
272
+ if (preflight !== undefined) {
273
+ throw new Error(preflight);
274
+ }
275
+ const tls: ServeTaskTls | undefined =
276
+ options.tls === true
277
+ ? { mode: "self-signed", sanEntries: tlsSan }
278
+ : options.tlsCert !== undefined && options.tlsKey !== undefined
279
+ ? { mode: "certificates", certPath: options.tlsCert, keyPath: options.tlsKey }
280
+ : undefined;
281
+ return {
282
+ exitCode: 0,
283
+ stdout: renderServeTaskArtifacts({
284
+ host,
285
+ port,
286
+ ...(endpoint !== undefined ? { endpoint } : {}),
287
+ ...(tls !== undefined ? { tls } : {}),
288
+ }),
289
+ stderr: "",
290
+ };
291
+ }
245
292
  const tokens = context.tokens ?? parseServeTokens(process.env.UKP_SERVE_TOKEN);
246
293
  // W10: socket activation must not admit tokenless serving — the bind
247
294
  // address belongs to the socket unit (possibly public), so the loopback
package/src/server.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  type NavRequest,
30
30
  } from "./capabilities/nav.ts";
31
31
  import {
32
+ rgExecutableAvailable,
32
33
  runRg,
33
34
  projectRgEnvelope,
34
35
  RG_DEFAULT_LIMIT,
@@ -150,6 +151,14 @@ export interface ServeInfo {
150
151
  host: string;
151
152
  port: number;
152
153
  authRequired: boolean;
154
+ /** ripgrep availability probed once at startup IN THE DOOR'S OWN PROCESS
155
+ * (2026-09-20, liku feedback): /v1/rg serves every endpoint and a missing
156
+ * binary degrades per-endpoint on the wire by design — but that truth is
157
+ * consumer-channel only. The banner line is the operator channel, and the
158
+ * probe environment is by construction the one that matters (ukp diagnose
159
+ * measures the invoking shell instead, which can differ from the door's
160
+ * schtasks / wake environment). */
161
+ rg: "ok" | "missing";
153
162
  /** TLS identity summary (W5' / D-079) when serving over HTTPS; the pin is
154
163
  * what remote clients TOFU-pin at registration. `source` "re-signed" marks
155
164
  * the one start that grew SAN coverage over the existing key (--tls-san
@@ -893,6 +902,7 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
893
902
  host,
894
903
  port: listener.port,
895
904
  authRequired: (config.tokens?.length ?? 0) > 0,
905
+ rg: rgExecutableAvailable() ? "ok" : "missing",
896
906
  ...(tlsMaterial !== undefined
897
907
  ? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
898
908
  : {}),
@@ -1026,6 +1036,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1026
1036
  host,
1027
1037
  port: listener.port,
1028
1038
  authRequired: (config.tokens?.length ?? 0) > 0,
1039
+ rg: rgExecutableAvailable() ? "ok" : "missing",
1029
1040
  ...(tlsMaterial !== undefined
1030
1041
  ? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
1031
1042
  : {}),