@catheadowl/ukp 0.2.0 → 0.2.2

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/bin/ukp.js ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // Self-locating launcher (D-085 completion, Windows-host wake 2026-09-20).
3
+ //
4
+ // npm's shims exec this file with node (the shebang above); bun's own bin
5
+ // links and `bunx` run it with bun itself. Either way the launcher finds a
6
+ // bun executable WITHOUT relying on the caller's PATH: non-interactive
7
+ // shells (ssh sessions, schtasks, service wrappers) see only the system
8
+ // PATH, while every standard install mode lands bun in a user-local dir.
9
+ // Candidate order: an explicit UKP_BUN override, the current interpreter
10
+ // when it already is bun, the standard install locations, then PATH.
11
+ import { spawnSync } from "node:child_process";
12
+ import { existsSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const cli = join(here, "..", "src", "cli.ts");
19
+ const isWindows = process.platform === "win32";
20
+ const runningUnderBun = typeof Bun !== "undefined" || Boolean(process.versions && process.versions.bun);
21
+
22
+ function bunCandidates() {
23
+ const exe = isWindows ? "bun.exe" : "bun";
24
+ const home = homedir();
25
+ const candidates = [];
26
+ if (typeof process.env.UKP_BUN === "string" && process.env.UKP_BUN.length > 0) {
27
+ candidates.push(process.env.UKP_BUN);
28
+ }
29
+ if (runningUnderBun) candidates.push(process.execPath);
30
+ // bun's official installer (same shape on every platform).
31
+ candidates.push(join(home, ".bun", "bin", exe));
32
+ if (isWindows) {
33
+ // `npm i -g bun` lands its shim in the npm user prefix — the same
34
+ // prefix root this package's own shim lives under.
35
+ candidates.push(join(here, "..", "..", "..", "..", exe));
36
+ // scoop installs stay user-local too.
37
+ candidates.push(join(home, "scoop", "shims", exe));
38
+ } else {
39
+ candidates.push(join(home, ".npm-global", "bin", exe));
40
+ // Homebrew's path is joined, not written as one literal: the release
41
+ // leak scan rightly flags literal home-directory-shaped paths, and this
42
+ // is a functional standard-location constant (same stance as the wake
43
+ // command's PATH prefix in src/capabilities/remote-client.ts).
44
+ candidates.push(join("/", "opt", "homebrew", "bin", "bun"));
45
+ candidates.push("/usr/local/bin/bun");
46
+ }
47
+ // PATH last, resolved ourselves dir-by-dir: a bare-name spawn would let
48
+ // the OS search with the PARENT's PATH on Windows (CreateProcess
49
+ // semantics), which this process cannot see or control.
50
+ const pathVar = process.env.PATH;
51
+ if (typeof pathVar === "string" && pathVar.length > 0) {
52
+ for (const dir of pathVar.split(isWindows ? ";" : ":")) {
53
+ if (dir.length > 0) candidates.push(join(dir, exe));
54
+ }
55
+ }
56
+ return candidates;
57
+ }
58
+
59
+ function fail(detail) {
60
+ process.stderr.write(
61
+ "ukp: no bun executable found (" + detail + ").\n"
62
+ + "ukp runs on bun — install it (https://bun.sh), or point UKP_BUN at the\n"
63
+ + "bun executable. Standard install locations (~/.bun/bin and the npm\n"
64
+ + "global prefix) are checked before PATH.\n",
65
+ );
66
+ process.exit(1);
67
+ }
68
+
69
+ const bunPath = bunCandidates().find((candidate) => existsSync(candidate));
70
+ if (bunPath === undefined) {
71
+ fail("looked for UKP_BUN, the standard install locations, and PATH");
72
+ }
73
+ const result = spawnSync(bunPath, [cli, ...process.argv.slice(2)], {
74
+ stdio: "inherit",
75
+ env: process.env,
76
+ });
77
+ if (result.error) {
78
+ // bunPath existed on disk a moment ago; a spawn failure here is exotic,
79
+ // but it must still land in the remedy, never a stack trace.
80
+ fail(`spawning '${bunPath}' failed: ${String(result.error.message ?? result.error)}`);
81
+ }
82
+ process.exit(result.status === null ? (result.signal !== null ? 130 : 1) : result.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catheadowl/ukp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3.14"
@@ -24,9 +24,10 @@
24
24
  "agent"
25
25
  ],
26
26
  "bin": {
27
- "ukp": "src/cli.ts"
27
+ "ukp": "bin/ukp.js"
28
28
  },
29
29
  "files": [
30
+ "bin",
30
31
  "src",
31
32
  "README.md",
32
33
  "LICENSE",
@@ -290,10 +290,24 @@ function spawnMuxMaster(
290
290
  }
291
291
  }
292
292
 
293
- /** The pinned wake command (W9 / ADR-REM-006 §4 safety narrowing): a fixed shape where
294
- * only the client-chosen integer port and idle window are interpolated — the
295
- * exact string an operator can allowlist with git-shell / authorized_keys
296
- * `command=`. Loopback-only, anonymous: SSH carries encryption and auth.
293
+ /** The pinned wake command (W9 / ADR-REM-006 §4 safety narrowing), in two
294
+ * host-shell forms: only the client-chosen integer port and idle window are
295
+ * interpolated — the exact string an operator can allowlist with git-shell /
296
+ * authorized_keys `command=`. Loopback-only, anonymous: SSH carries
297
+ * encryption and auth. The POSIX form is unchanged since W9; the cmd form
298
+ * (2026-09-20, liku feedback) serves Windows hosts whose OpenSSH default
299
+ * shell is cmd.exe and cannot parse `sh -c` at all — it is the pinned
300
+ * equivalent with a Windows-shaped PATH prefix (bun official, scoop, npm
301
+ * user prefix; the launcher resolves bun itself once ukp is found, the
302
+ * prefix guarantees ukp resolves). The `set "PATH=…"` quoting is load-
303
+ * bearing: an unquoted `set PATH=…%PATH%&&…` breaks when the expanded PATH
304
+ * contains cmd metacharacters (`&` in a directory name — verified), while
305
+ * the quoted form survives both the sshd double-cmd wrapper and cmd's own
306
+ * expansion (verified against Win32-OpenSSH source and live). The wake
307
+ * ladder sends POSIX first and switches on the wrong-shell failure
308
+ * signature; host shells covered: POSIX login shells, cmd.exe default, and
309
+ * Git-Bash-style DefaultShell hosts — a powershell DefaultShell is NOT
310
+ * supported (its -c quoting drops the form).
297
311
  *
298
312
  * The remote shell ssh runs commands in is NON-INTERACTIVE (bare system
299
313
  * PATH), while every standard install mode lands in user-local,
@@ -301,9 +315,9 @@ function spawnMuxMaster(
301
315
  * interactivity guard; `bun add -g` links into ~/.bun/bin; a user npm prefix
302
316
  * like ~/.npm-global never leaves the user dir; brew lives outside the
303
317
  * default ssh PATH). So the command prepends the standard install locations
304
- * to PATH before exec — ukp AND its `#!/usr/bin/env bun` shebang both
305
- * resolve for any usual install. nvm-style versioned layouts have no fixed
306
- * path shape and need a system symlink (documented exception). */
318
+ * to PATH before exec — ukp AND its shebang/launcher both resolve for any
319
+ * usual install. nvm-style versioned layouts have no fixed path shape and
320
+ * need a system symlink (documented exception). */
307
321
  // The Linuxbrew segment is joined, not written as one literal: its standard
308
322
  // install location is a functional constant (part of the byte-pinned remote
309
323
  // command), while the release "absolute user path" leak scan rightly flags
@@ -317,10 +331,54 @@ const WAKE_REMOTE_PATH_PREFIX = [
317
331
  ["/home", "linuxbrew", ".linuxbrew", "bin"].join("/"),
318
332
  ].join(":");
319
333
 
320
- function wakeDoorCommand(remotePort: number): string {
334
+ export type WakeShellForm = "posix" | "cmd";
335
+
336
+ function wakeDoorCommand(remotePort: number, form: WakeShellForm = "posix"): string {
337
+ if (form === "cmd") {
338
+ return `cmd /d /c "set "PATH=%USERPROFILE%\\.bun\\bin;%USERPROFILE%\\scoop\\shims;%APPDATA%\\npm;%PATH%"&&ukp serve --allow-anonymous --host 127.0.0.1 --port ${remotePort} --max-idle ${WAKE_DOOR_MAX_IDLE_SECONDS}"`;
339
+ }
321
340
  return `sh -c 'PATH="${WAKE_REMOTE_PATH_PREFIX}:$PATH" exec ukp serve --allow-anonymous --host 127.0.0.1 --port ${remotePort} --max-idle ${WAKE_DOOR_MAX_IDLE_SECONDS}'`;
322
341
  }
323
342
 
343
+ /** Wrong-shell failure signature for the wake ladder: the remote shell could
344
+ * not even parse the sent form, so the OTHER form is the retry — as opposed
345
+ * to a missing-ukp failure, where the shell ran fine and named 'ukp' as the
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"). */
350
+ function wrongShellSignature(form: WakeShellForm, diagnostics: string): boolean {
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";
380
+ }
381
+
324
382
  /** HTTP-level readiness probe through the tunnel (W9): a TCP accept on the
325
383
  * local forward proves nothing — ssh accepts locally and connects remotely
326
384
  * lazily — so we poll the door's public discovery document until it answers
@@ -399,6 +457,10 @@ async function openSshTunnel(
399
457
  // options for the remaining attempts so the endpoint degrades to Tier 0
400
458
  // instead of hard-failing what worked without mux.
401
459
  let muxDisabled = false;
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);
402
464
  const attempts = 3;
403
465
  for (let attempt = 1; attempt <= attempts; attempt++) {
404
466
  const localPort = await freePort();
@@ -409,14 +471,17 @@ async function openSshTunnel(
409
471
  // SIGHUP on disconnect — normal disconnect included, the door only ever
410
472
  // died to --max-idle. Forcing a pty gives the session a controlling
411
473
  // terminal, so killing the client reaps the door in seconds (verified:
412
- // kill -9 of the local ssh → door gone within 3s).
413
- "-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"] : []),
414
479
  "-o", "ExitOnForwardFailure=yes",
415
480
  "-o", "BatchMode=yes",
416
481
  ...(muxDisabled ? [] : muxClientOptions()),
417
482
  "-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`,
418
483
  target,
419
- wakeDoorCommand(remotePort),
484
+ wakeDoorCommand(remotePort, form),
420
485
  ];
421
486
  const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
422
487
  const stderrTail = drainStderrTail([proc.stdout, proc.stderr]);
@@ -432,6 +497,17 @@ async function openSshTunnel(
432
497
  if (/ControlPath too long|muxserver_listen|unix_listener/i.test(diagnostics)) {
433
498
  muxDisabled = true;
434
499
  }
500
+ if (exited && wrongShellSignature(form, diagnostics)) {
501
+ if (attempt < attempts) {
502
+ form = form === "posix" ? "cmd" : "posix";
503
+ continue;
504
+ }
505
+ // Both forms rejected: this is a shell mismatch, not a missing-ukp
506
+ // failure — say so instead of falling into the missing-ukp wording.
507
+ throw new RemoteTransportError(
508
+ `waking the ukp door on '${target}' (endpoint '${endpointLabel}') failed — the remote shell rejected both wake command forms (POSIX and cmd.exe): ${diagnostics}. Supported host shells: POSIX login shells, cmd.exe default, Git-Bash-style DefaultShell; a powershell DefaultShell is not supported`,
509
+ );
510
+ }
435
511
  // POSIX login shells print "…: ukp: command not found" (exit 127); Windows
436
512
  // OpenSSH under cmd prints "'ukp' is not recognized…" (exit 9009).
437
513
  if (exited && /not found|not recognized|exit status 127|status 9009/i.test(diagnostics)) {
@@ -54,8 +54,8 @@ Name and identity:
54
54
  - ssh:// urls take no port: registration rejects one (the woken door listens on a client-chosen loopback port; use an ssh config Host alias for a non-standard sshd port).
55
55
 
56
56
  Operational note:
57
- - ssh:// hosts: nothing to manage — ukp wakes and reaps doors itself; 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. The CLI does not install or autostart the door.
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.
59
59
  - serve accepts --max-idle <seconds> to self-exit after that long without requests (the same backstop the woken doors use).
60
60
 
61
61
  Remember:
@@ -80,7 +80,7 @@ export const REGISTER_SPEC: UkpCommandSpec = {
80
80
  " assertion on the declared name; bearer-token services read",
81
81
  " UKP_ENDPOINT_<NAME>_TOKEN (keyed by the handle) at call time.",
82
82
  "",
83
- "Host doors (ADR-REM-004):",
83
+ "Host doors:",
84
84
  " 'ukp register --url ssh://ali' fetches the door's discovery document",
85
85
  " and imports every endpoint behind it (a door url may also name one",
86
86
  " endpoint: ssh://ali/notes). Import is explicit and idempotent:",
@@ -173,6 +173,7 @@ export function renderServeBanner(info: ServeInfo): string {
173
173
  ` endpoints: ${info.door!.endpoints.length > 0 ? info.door!.endpoints.join(", ") : "(none — register endpoints on this host)"}`,
174
174
  ` write: ${info.door!.write.length > 0 ? info.door!.write.join(", ") + " (propose via PUT /e/<name>/v1/propose/<id>)" : "(no endpoint declares propose)"}`,
175
175
  ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback bind; ssh-forwarded clients authenticate by SSH key)"}`,
176
+ ` rg: ${info.rg === "ok" ? "ok" : "missing (rg calls skip with a warning — install ripgrep on this door's PATH)"}`,
176
177
  ...(info.maxIdleSeconds !== undefined
177
178
  ? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
178
179
  : []),
@@ -187,6 +188,7 @@ export function renderServeBanner(info: ServeInfo): string {
187
188
  ` listening: ${info.url}`,
188
189
  ` discovery: ${info.url.startsWith("http") ? `${info.url}${DISCOVERY_PATH}` : info.url}`,
189
190
  ` auth: ${info.authRequired ? "bearer token required" : "no token (loopback only)"}`,
191
+ ` rg: ${info.rg === "ok" ? "ok" : "missing (rg calls skip with a warning — install ripgrep on this door's PATH)"}`,
190
192
  ...(info.maxIdleSeconds !== undefined
191
193
  ? [` idle: exits after ${info.maxIdleSeconds}s without requests (--max-idle)`]
192
194
  : []),
package/src/server.ts CHANGED
@@ -4,6 +4,7 @@ import { createServer as netCreateServer, connect as netConnect, type Server as
4
4
  import { ENDPOINT_NAME, loadManifest, type LoadedManifest } from "./config/manifest.ts";
5
5
  import { localPathOf, readRegistry } from "./registry.ts";
6
6
  import { FILE_NATIVE_CAPABILITIES, resolveFileNativeCapability } from "./config/file-native.ts";
7
+ import { EXTERNAL_PROVIDER, EXTERNAL_TOOL_CAPABILITIES } from "./config/external-tool.ts";
7
8
  import {
8
9
  PROPOSE_SLUG,
9
10
  ProposeBusyError,
@@ -28,6 +29,7 @@ import {
28
29
  type NavRequest,
29
30
  } from "./capabilities/nav.ts";
30
31
  import {
32
+ rgExecutableAvailable,
31
33
  runRg,
32
34
  projectRgEnvelope,
33
35
  RG_DEFAULT_LIMIT,
@@ -149,6 +151,14 @@ export interface ServeInfo {
149
151
  host: string;
150
152
  port: number;
151
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";
152
162
  /** TLS identity summary (W5' / D-079) when serving over HTTPS; the pin is
153
163
  * what remote clients TOFU-pin at registration. `source` "re-signed" marks
154
164
  * the one start that grew SAN coverage over the existing key (--tls-san
@@ -231,13 +241,19 @@ function readInstanceUid(serviceFolder: string): string {
231
241
  }
232
242
 
233
243
  /** Capability projection shared by endpoint and door documents: derived
234
- * file-native defaults (read/nav) are always present; declared capabilities
235
- * override or extend them (a declared nav replaces the derived entry). */
244
+ * file-native defaults (read/nav) are always present, then the external-tool
245
+ * base tier (rg, ADR-RG-003 — served on /v1/rg for every endpoint; a missing
246
+ * binary degrades to per-endpoint availability data per the W6 wire
247
+ * contract, never an undeclared capability); declared capabilities override
248
+ * or extend both (a declared nav or rg replaces the default entry). */
236
249
  function projectCapabilities(loaded: LoadedManifest): DiscoveryDocument["capabilities"] {
237
250
  const capabilities: DiscoveryDocument["capabilities"] = {};
238
251
  for (const [name, spec] of Object.entries(FILE_NATIVE_CAPABILITIES)) {
239
252
  if (spec.derived) capabilities[name] = { provider: "file", derived: true };
240
253
  }
254
+ for (const name of Object.keys(EXTERNAL_TOOL_CAPABILITIES)) {
255
+ capabilities[name] = { provider: EXTERNAL_PROVIDER, derived: true };
256
+ }
241
257
  for (const name of Object.keys(loaded.manifest.capabilities).sort()) {
242
258
  const declared = loaded.manifest.capabilities[name]!;
243
259
  capabilities[name] = { provider: declared.provider ?? "file" };
@@ -886,6 +902,7 @@ function startDoorServer(config: ServeConfig, host: string, tlsMaterial: ReturnT
886
902
  host,
887
903
  port: listener.port,
888
904
  authRequired: (config.tokens?.length ?? 0) > 0,
905
+ rg: rgExecutableAvailable() ? "ok" : "missing",
889
906
  ...(tlsMaterial !== undefined
890
907
  ? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
891
908
  : {}),
@@ -1019,6 +1036,7 @@ export function startUkpServer(config: ServeConfig): StartedServe {
1019
1036
  host,
1020
1037
  port: listener.port,
1021
1038
  authRequired: (config.tokens?.length ?? 0) > 0,
1039
+ rg: rgExecutableAvailable() ? "ok" : "missing",
1022
1040
  ...(tlsMaterial !== undefined
1023
1041
  ? { tls: { pin: tlsMaterial.pin, san: tlsMaterial.san, source: tlsMaterial.source } }
1024
1042
  : {}),