@catheadowl/ukp 0.2.0 → 0.2.1

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.1",
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,25 @@ 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. cmd.exe errors quote the missing token ('sh' is not
347
+ * recognized, exit 9009); POSIX shells print "<token>: command not found". */
348
+ 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
+ }
352
+
324
353
  /** HTTP-level readiness probe through the tunnel (W9): a TCP accept on the
325
354
  * local forward proves nothing — ssh accepts locally and connects remotely
326
355
  * lazily — so we poll the door's public discovery document until it answers
@@ -399,6 +428,11 @@ async function openSshTunnel(
399
428
  // options for the remaining attempts so the endpoint degrades to Tier 0
400
429
  // instead of hard-failing what worked without mux.
401
430
  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";
402
436
  const attempts = 3;
403
437
  for (let attempt = 1; attempt <= attempts; attempt++) {
404
438
  const localPort = await freePort();
@@ -416,7 +450,7 @@ async function openSshTunnel(
416
450
  ...(muxDisabled ? [] : muxClientOptions()),
417
451
  "-L", `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`,
418
452
  target,
419
- wakeDoorCommand(remotePort),
453
+ wakeDoorCommand(remotePort, form),
420
454
  ];
421
455
  const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" });
422
456
  const stderrTail = drainStderrTail([proc.stdout, proc.stderr]);
@@ -432,6 +466,17 @@ async function openSshTunnel(
432
466
  if (/ControlPath too long|muxserver_listen|unix_listener/i.test(diagnostics)) {
433
467
  muxDisabled = true;
434
468
  }
469
+ if (exited && wrongShellSignature(form, diagnostics)) {
470
+ if (attempt < attempts) {
471
+ form = form === "posix" ? "cmd" : "posix";
472
+ continue;
473
+ }
474
+ // Both forms rejected: this is a shell mismatch, not a missing-ukp
475
+ // failure — say so instead of falling into the missing-ukp wording.
476
+ throw new RemoteTransportError(
477
+ `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`,
478
+ );
479
+ }
435
480
  // POSIX login shells print "…: ukp: command not found" (exit 127); Windows
436
481
  // OpenSSH under cmd prints "'ukp' is not recognized…" (exit 9009).
437
482
  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:",
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,
@@ -231,13 +232,19 @@ function readInstanceUid(serviceFolder: string): string {
231
232
  }
232
233
 
233
234
  /** 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). */
235
+ * file-native defaults (read/nav) are always present, then the external-tool
236
+ * base tier (rg, ADR-RG-003 — served on /v1/rg for every endpoint; a missing
237
+ * binary degrades to per-endpoint availability data per the W6 wire
238
+ * contract, never an undeclared capability); declared capabilities override
239
+ * or extend both (a declared nav or rg replaces the default entry). */
236
240
  function projectCapabilities(loaded: LoadedManifest): DiscoveryDocument["capabilities"] {
237
241
  const capabilities: DiscoveryDocument["capabilities"] = {};
238
242
  for (const [name, spec] of Object.entries(FILE_NATIVE_CAPABILITIES)) {
239
243
  if (spec.derived) capabilities[name] = { provider: "file", derived: true };
240
244
  }
245
+ for (const name of Object.keys(EXTERNAL_TOOL_CAPABILITIES)) {
246
+ capabilities[name] = { provider: EXTERNAL_PROVIDER, derived: true };
247
+ }
241
248
  for (const name of Object.keys(loaded.manifest.capabilities).sort()) {
242
249
  const declared = loaded.manifest.capabilities[name]!;
243
250
  capabilities[name] = { provider: declared.provider ?? "file" };