@catheadowl/ukp 0.2.4 → 0.2.6

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.
@@ -139,6 +139,31 @@ function renderServiceManifest(options: { name?: string; description?: string; d
139
139
  return encoded.endsWith("\n") ? encoded : `${encoded}\n`;
140
140
  }
141
141
 
142
+ /** Self-ignore guard for `.ukp/` (O-020 door-family B-b): the folder holds
143
+ * machine-local Service state (manifest, instance identity, TLS private
144
+ * keys) that must never enter version control or folder sync. `*` inside
145
+ * the tool-owned directory is the virtualenv / Python 3.13 venv / Meson
146
+ * precedent — it never touches the user's repo-root `.gitignore`. An
147
+ * existing file is never touched, so operators who pre-write their own
148
+ * ignore policy keep it (e.g. tracking service.toml on purpose). */
149
+ const UKP_SELF_IGNORE = [
150
+ "# UKP local Service state (manifest, identity, TLS keys) is machine-local:",
151
+ "# self-ignored to keep it out of version control and folder sync.",
152
+ "*",
153
+ "",
154
+ ].join("\n");
155
+
156
+ function ensureUkpSelfIgnore(ukpDirectory: string): void {
157
+ const gitignorePath = join(ukpDirectory, ".gitignore");
158
+ if (existsSync(gitignorePath)) return;
159
+ try {
160
+ writeFileSync(gitignorePath, UKP_SELF_IGNORE, { flag: "wx", mode: 0o644 });
161
+ } catch (error) {
162
+ // Concurrent writer installed the same guard — in place either way.
163
+ if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
164
+ }
165
+ }
166
+
142
167
  function createServiceManifest(
143
168
  currentDirectory: string,
144
169
  options: InitServiceOptions,
@@ -169,6 +194,8 @@ function createServiceManifest(
169
194
  throw error;
170
195
  }
171
196
 
197
+ ensureUkpSelfIgnore(ukpDirectory);
198
+
172
199
  return { folder, manifestPath, name, nameSource };
173
200
  }
174
201
 
@@ -3,6 +3,7 @@ import {
3
3
  projectRgEnvelope,
4
4
  renderRgHuman,
5
5
  RG_DEFAULT_LIMIT,
6
+ RG_FILES_DEFAULT_LIMIT,
6
7
  RG_MAX_LIMIT,
7
8
  RgPlanningError,
8
9
  RgUsageError,
@@ -11,6 +12,7 @@ import {
11
12
  type RgAggregateStatus,
12
13
  type RgContext,
13
14
  type RgEndpointOutcome,
15
+ type RgMode,
14
16
  type RgResult,
15
17
  } from "../capabilities/rg.ts";
16
18
  import { EXTERNAL_PROVIDER } from "../config/external-tool.ts";
@@ -50,17 +52,19 @@ export const RG_SPEC: UkpCommandSpec = {
50
52
  summary: "grep raw endpoint files with ripgrep (no index or declaration needed; results as ukp:// references)",
51
53
  group: "endpoint",
52
54
  description:
53
- "Run base lexical search (ripgrep) across one or more Service endpoints. "
55
+ "Run base lexical search (ripgrep) across one or more Service endpoints, "
56
+ + "or enumerate their files with --files. "
54
57
  + "Available on every registered endpoint by default; results are shaped into ukp:// references that 'ukp read' consumes directly. "
55
58
  + "For indexed/semantic search use 'ukp search'.",
56
- usage: "[--endpoint <name> ... | -g] <pattern> [--limit <1-1000>] [--count] [--glob <glob>] [--type <type>] [-i] [-- <rg flags>]",
57
- arguments: [{ name: "pattern", help: "one non-empty regex pattern; quote to escape the shell" }],
59
+ usage: "[--endpoint <name> ... | -g] (<pattern> | --files) [--limit <1-1000>] [--count] [--glob <glob> ...] [--type <type>] [-i] [-- <rg flags>]",
60
+ arguments: [{ name: "pattern", help: "one non-empty regex pattern; quote to escape the shell (omit with --files)" }],
58
61
  options: [
59
62
  { flags: "-c, --endpoint <name>", help: "select one endpoint; repeat to select multiple endpoints", multi: true },
60
63
  { flags: "-g", help: "search every endpoint in the Host Registry; takes no value" },
61
- { flags: "--limit <1-1000>", help: `maximum matches per run (default: ${RG_DEFAULT_LIMIT})` },
64
+ { flags: "--limit <1-1000>", help: `maximum results per run (default: ${RG_DEFAULT_LIMIT}; ${RG_FILES_DEFAULT_LIMIT} with --files)` },
62
65
  { flags: "--count", help: "count mode: per-file match counts instead of matches" },
63
- { flags: "--glob <glob>", help: "glob filter passed to rg (e.g. \"*.md\")" },
66
+ { flags: "--files", help: "files mode: enumerate files by tree walk with no pattern (empty and binary files included; combine with --glob/--type)" },
67
+ { flags: "--glob <glob>", help: "glob filter passed to rg (e.g. \"*.md\"); repeat to combine - a later glob may negate with !", multi: true },
64
68
  { flags: "--type <type>", help: "file type filter passed to rg (e.g. md, py)" },
65
69
  { flags: "-i", help: "case-insensitive search" },
66
70
  { flags: "--json", help: "emit the structured response envelope" },
@@ -69,7 +73,8 @@ export const RG_SPEC: UkpCommandSpec = {
69
73
  "",
70
74
  "Passthrough:",
71
75
  " After '--', rg native flags are passed through on an allowlist",
72
- " (-A/-B/-C/-m/--glob/--type/--max-filesize and common boolean flags).",
76
+ " (-A/-B/-C/-m/--glob/--iglob/--type/--max-filesize and common boolean",
77
+ " flags).",
73
78
  " Path operands and output-changing flags (--json, -r, --pre, --config)",
74
79
  " are rejected: the endpoint selector owns scope, UKP owns the output.",
75
80
  "",
@@ -84,6 +89,12 @@ export const RG_SPEC: UkpCommandSpec = {
84
89
  " 'ukp search' covers indexed search where the Service declares a search",
85
90
  " capability provider.",
86
91
  "",
92
+ "Visibility:",
93
+ " Every rg mode skips hidden files (dot-prefixed, including the .ukp/",
94
+ " wiring) by default; pass -- --hidden after '--' to include them",
95
+ " (-uu additionally lifts .gitignore rules). --files lists every",
96
+ " non-hidden file, including empty and binary ones.",
97
+ "",
87
98
  ].join("\n"),
88
99
  };
89
100
 
@@ -92,7 +103,8 @@ interface RgCommandOptions extends Record<string, unknown> {
92
103
  g?: boolean;
93
104
  limit?: string;
94
105
  count?: boolean;
95
- glob?: string;
106
+ files?: boolean;
107
+ glob?: string[];
96
108
  type?: string;
97
109
  i?: boolean;
98
110
  json?: boolean;
@@ -102,10 +114,13 @@ export function parseRgArgs(args: readonly string[]): ParsedRg {
102
114
  const parsed = parseKitArgs<RgCommandOptions>(RG_SPEC, args);
103
115
  const explicit: string[] = [];
104
116
  const warnings: string[] = [];
117
+ const files = parsed.options.files === true;
105
118
 
106
119
  // Command-side semantic checks, in the pre-kit order: limit validation,
107
- // duplicate warnings, excess positional, emptiness, conflict.
108
- let limit = RG_DEFAULT_LIMIT;
120
+ // duplicate warnings, excess positional, mode conflicts, pattern rule,
121
+ // scope conflict. Files mode (ADR-RG-005) relaxes the pattern requirement
122
+ // and rejects combinations that would silently no-op.
123
+ let limit = files ? RG_FILES_DEFAULT_LIMIT : RG_DEFAULT_LIMIT;
109
124
  if (parsed.options.limit !== undefined) {
110
125
  if (!/^[0-9]+$/.test(parsed.options.limit)) throw new KitUsageError("--limit must be a decimal integer");
111
126
  limit = Number(parsed.options.limit);
@@ -125,20 +140,34 @@ export function parseRgArgs(args: readonly string[]): ParsedRg {
125
140
  `unexpected argument '${unexpected}'; rg accepts exactly one pattern. Use '--endpoint <name>' to select an endpoint; '-g' takes no value.`,
126
141
  );
127
142
  }
128
- if (pattern === undefined || pattern.length === 0) throw new KitUsageError("rg pattern must be non-empty");
143
+ if (files && parsed.options.count === true) {
144
+ throw new KitUsageError("--files and --count cannot be used together (pick one output mode)");
145
+ }
146
+ if (files && parsed.options.i === true) {
147
+ throw new KitUsageError("--files cannot be combined with -i: there is no pattern to match; pass '-- --iglob <glob>' for a case-insensitive glob filter");
148
+ }
149
+ if (files) {
150
+ if (pattern !== undefined) {
151
+ throw new KitUsageError(`unexpected argument '${pattern}'; --files takes no pattern - use --glob to filter by name`);
152
+ }
153
+ } else if (pattern === undefined || pattern.length === 0) {
154
+ throw new KitUsageError("rg pattern must be non-empty (or pass --files to enumerate files without a pattern)");
155
+ }
129
156
  if (parsed.options.g && explicit.length > 0) {
130
157
  throw new KitUsageError("--endpoint and -g cannot be used together");
131
158
  }
132
159
 
160
+ const globs = parsed.options.glob ?? [];
133
161
  return {
134
- request: { query: pattern, limit },
162
+ request: { query: files ? "" : pattern!, limit },
135
163
  options: {
136
164
  explicitEndpoints: explicit.length > 0 ? explicit : undefined,
137
165
  global: parsed.options.g ?? false,
138
- ...(parsed.options.glob === undefined ? {} : { glob: parsed.options.glob }),
166
+ ...(globs.length > 0 ? { globs } : {}),
139
167
  ...(parsed.options.type === undefined ? {} : { type: parsed.options.type }),
140
168
  ...(parsed.options.i === true ? { ignoreCase: true } : {}),
141
169
  ...(parsed.options.count === true ? { count: true } : {}),
170
+ ...(files ? { files: true } : {}),
142
171
  ...(parsed.options.json === true ? { json: true } : {}),
143
172
  passthrough: [],
144
173
  },
@@ -238,7 +267,7 @@ function renderRgCommandResult(parsed: ParsedRg, result: RgResult): RgCommandRes
238
267
  if (parsed.options.json === true) {
239
268
  return {
240
269
  exitCode: RG_EXIT_BY_AGGREGATE[result.aggregate],
241
- stdout: `${JSON.stringify(projectRgEnvelope(result, parsed.options.count === true), null, 2)}\n`,
270
+ stdout: `${JSON.stringify(projectRgEnvelope(result), null, 2)}\n`,
242
271
  stderr: view.diagnostics,
243
272
  };
244
273
  }
@@ -305,10 +334,11 @@ async function executeMixedRg(
305
334
  const execution = await remoteRg(binding, transport, token, {
306
335
  query: parsed.request.query,
307
336
  limit: parsed.request.limit,
308
- ...(parsed.options.glob !== undefined ? { glob: parsed.options.glob } : {}),
337
+ ...(parsed.options.globs !== undefined ? { globs: parsed.options.globs } : {}),
309
338
  ...(parsed.options.type !== undefined ? { type: parsed.options.type } : {}),
310
339
  ...(parsed.options.ignoreCase === true ? { ignoreCase: true } : {}),
311
340
  ...(parsed.options.count === true ? { count: true } : {}),
341
+ ...(parsed.options.files === true ? { files: true } : {}),
312
342
  passthrough: parsed.options.passthrough,
313
343
  });
314
344
  remoteOutcomes.set(binding.name, execution.outcome);
@@ -343,6 +373,7 @@ async function executeMixedRg(
343
373
  return renderRgCommandResult(parsed, {
344
374
  query: parsed.request.query,
345
375
  limit: parsed.request.limit,
376
+ mode: (parsed.options.files === true ? "files" : parsed.options.count === true ? "count" : "match") as RgMode,
346
377
  endpoints,
347
378
  warnings,
348
379
  aggregate,
@@ -2,18 +2,59 @@
2
2
  * renders the process-manager artifacts for an https door on a Windows host
3
3
  * — the start script, the hidden launcher (vbs), the Task Scheduler command,
4
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).
5
+ * by default: UKP installs nothing, starts nothing, and the printed script
6
+ * line carries a placeholder (the operator pastes the token into the file
7
+ * they save, under their own permission regime).
8
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,
9
+ * `--print-task --write` (O-020 ruling C, 2026-09-23 owner verdict: the
10
+ * D-090 secret-free clause scopes to the print surface) swaps the two file
11
+ * artifacts' sink from stdout to disk: the same bodies land as
12
+ * `%USERPROFILE%\.ukp\start-door.cmd` + `start-door-hidden.vbs`, CRLF, with
13
+ * the token interpolated from `UKP_SERVE_TOKEN` (never displayed, never in
14
+ * argv, refused outright if it carries characters that would corrupt a cmd
15
+ * file). Existing files are never overwritten - operator edits (PATH
16
+ * patches) live there; regeneration goes through the removal recipe. The
17
+ * remaining steps stay on stdout: register the task, open the firewall,
18
+ * verify with `ukp diagnose --door`. Still nothing is installed, started,
19
+ * or uninstalled by UKP.
20
+ *
21
+ * The emitted shapes mirror the field-verified recipes (deployment
22
+ * handbook, Windows section; provider round 2026-09-23): a bare `ukp`
23
+ * resolves through the logged-on task's per-user PATH and the
24
+ * self-locating launcher finds bun itself, so no PATH export is emitted.
25
+ * The hidden launcher is the product default (owner ruling 2026-09-21: a
26
+ * visibly popping cmd window is not a correct product form): zero flash
27
+ * (hidden at process creation), sole door.log capture (ISSUE-016: a
28
+ * redirect on the cmd's serve line too deadlocks the nested append-open),
15
29
  * exit-code propagation, bounded crash retry — Task Scheduler's own
16
- * restart-on-failure does not fire on exit codes (disproven on liku). */
30
+ * restart-on-failure does not fire on exit codes (disproven on liku). The
31
+ * scheduled task registers unelevated via a per-user logon trigger with
32
+ * no execution time limit (the scheduler's 72h default silently kills
33
+ * resident doors).
34
+ *
35
+ * Riders 2026-09-23 (O-020 ruling A, copy-only): single-endpoint prints
36
+ * name the whole-registry host-door alternative (one door instead of one
37
+ * port per endpoint), the self-signed notes add the task-PATH openssl gap
38
+ * (the logged-on task may lack the shell's openssl source; Git for
39
+ * Windows' mingw64\bin is named as the common remedy, no hardcoded path),
40
+ * and every print carries the stop-before-editing hazard (a running cmd
41
+ * re-reads the script at a stale byte offset).
42
+ *
43
+ * §5 "To remove the door" (2026-09-23, O-020 ruling B1 / teardown FR):
44
+ * the print's own inverse, three levels — stop now (the same launcher-root
45
+ * kill as the restart note), stop for good (Unregister-ScheduledTask:
46
+ * confirms by default, and does NOT stop the running instance), erase
47
+ * everything (the reverse of steps 4→0, firewall rule included, the
48
+ * token-bearing cmd named, self-signed identity located by door shape —
49
+ * the single-endpoint identity sits INSIDE the served folder, so sync
50
+ * carries the private key out). The scheduler's own End/Stop task route
51
+ * is deliberately absent: it does not kill the tree (disproven in the
52
+ * field). Print-only as ever — the removal advice documents, never
53
+ * executes. */
54
+
55
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
56
+ import { join } from "node:path";
57
+ import { KitUsageError } from "./kit.ts";
17
58
 
18
59
  export interface ServeTaskTlsSelfSigned {
19
60
  mode: "self-signed";
@@ -31,6 +72,9 @@ export type ServeTaskTls = ServeTaskTlsSelfSigned | ServeTaskTlsCertificates;
31
72
  export interface ServeTaskInput {
32
73
  /** Present = single-endpoint mode; absent = host door mode. */
33
74
  endpoint?: string;
75
+ /** Subset door (ADR-REM-010): the mirrored --select names; the §1 serve
76
+ * line carries them so the resident door serves the same subset. */
77
+ select?: readonly string[];
34
78
  host: string;
35
79
  port: number;
36
80
  tls?: ServeTaskTls;
@@ -71,6 +115,7 @@ function serveCommandLine(input: ServeTaskInput): string {
71
115
  return [
72
116
  "ukp serve",
73
117
  ...(input.endpoint !== undefined ? [`--endpoint ${input.endpoint}`] : []),
118
+ ...(input.select !== undefined ? [`--select ${input.select.join(",")}`] : []),
74
119
  `--host ${input.host}`,
75
120
  `--port ${input.port}`,
76
121
  ...tls,
@@ -83,8 +128,87 @@ function parentDirOf(path: string): string | undefined {
83
128
  return cut > 0 ? path.slice(0, cut) : undefined;
84
129
  }
85
130
 
131
+ /** The start script's file body, unindented — the single source both the
132
+ * print form (indented embed, `<paste-your-token>` slot) and `--write`
133
+ * (bytes on disk, real token) render. `written` switches the token rem
134
+ * lines to the written-file provenance so the file reads truthfully about
135
+ * where its secret came from. */
136
+ export function startDoorCmdBody(
137
+ input: ServeTaskInput,
138
+ tokenSlot: string,
139
+ written: boolean,
140
+ ): string[] {
141
+ return [
142
+ "@echo off",
143
+ "rem UKP resident door (generated by ukp serve --print-task)",
144
+ ...(written
145
+ ? [
146
+ "rem The token line is the only secret: --write interpolated it",
147
+ "rem from UKP_SERVE_TOKEN (never displayed). Keep this file",
148
+ "rem private; deleting it destroys this copy.",
149
+ ]
150
+ : [
151
+ "rem The token line is the only secret: mint a long random string",
152
+ "rem (e.g. openssl rand -hex 32), paste it in, keep this file private.",
153
+ ]),
154
+ `set "UKP_SERVE_TOKEN=${tokenSlot}"`,
155
+ "rem ukp resolves through the logged-on task's per-user PATH; the",
156
+ "rem launcher finds bun itself - no PATH export needed.",
157
+ 'if not exist "%USERPROFILE%\\.ukp" mkdir "%USERPROFILE%\\.ukp"',
158
+ "rem logging is owned by start-door-hidden.vbs (outer redirect) - a",
159
+ "rem second redirect to the same file here deadlocks the append open",
160
+ serveCommandLine(input),
161
+ ];
162
+ }
163
+
164
+ /** The hidden launcher's file body, unindented (print embeds it indented;
165
+ * --write writes these lines as-is). Carries no secret - the token lives
166
+ * only in start-door.cmd. */
167
+ export function startDoorVbsBody(): string[] {
168
+ return [
169
+ "' UKP door hidden launcher: runs start-door.cmd with NO visible",
170
+ "' window (hidden at process creation - nothing flashes), captures",
171
+ "' everything to door.log, waits out the door's lifetime, propagates",
172
+ "' its exit code, and retries a crashed door 3 times, 30s apart.",
173
+ 'Dim shell : Set shell = CreateObject("WScript.Shell")',
174
+ "Dim q : q = Chr(34)",
175
+ 'Dim home : home = shell.ExpandEnvironmentStrings("%USERPROFILE%")',
176
+ 'Dim cmdline : cmdline = "cmd /c " & q & q & home & "\\.ukp\\start-door.cmd" & q & " >> " & q & home & "\\.ukp\\door.log" & q & " 2>&1" & q',
177
+ "Dim rc : rc = 0",
178
+ "Dim attempt",
179
+ "For attempt = 1 To 3",
180
+ " rc = shell.Run(cmdline, 0, True)",
181
+ " If rc = 0 Then Exit For",
182
+ " If attempt < 3 Then WScript.Sleep 30000",
183
+ "Next",
184
+ "WScript.Quit rc",
185
+ ];
186
+ }
187
+
188
+ /** Task-registration step (PowerShell, unelevated) - shared by the print
189
+ * form's step 3 and the --write report's next steps. */
190
+ function taskRegistrationLines(): string[] {
191
+ return [
192
+ '$t = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\\$env:USERNAME"',
193
+ "$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `",
194
+ "-DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew `",
195
+ "-ExecutionTimeLimit ([TimeSpan]::Zero)",
196
+ "Register-ScheduledTask -TaskName 'ukp-door' -Action (New-ScheduledTaskAction `",
197
+ "-Execute 'wscript.exe' -Argument '\"%USERPROFILE%\\.ukp\\start-door-hidden.vbs\"') `",
198
+ "-Trigger $t -Settings $s -Force",
199
+ "Start-ScheduledTask -TaskName ukp-door",
200
+ ];
201
+ }
202
+
203
+ function firewallRuleLine(port: number): string {
204
+ return `netsh advfirewall firewall add rule name="ukp-door" dir=in action=allow protocol=TCP localport=${port}`;
205
+ }
206
+
207
+ function indent3(lines: readonly string[]): string[] {
208
+ return lines.map((line) => ` ${line}`);
209
+ }
210
+
86
211
  export function renderServeTaskArtifacts(input: ServeTaskInput): string {
87
- const serveLine = serveCommandLine(input);
88
212
  const out: string[] = [
89
213
  "ukp serve --print-task - Windows resident-door artifacts",
90
214
  "",
@@ -119,49 +243,91 @@ export function renderServeTaskArtifacts(input: ServeTaskInput): string {
119
243
  " (write it with a real editor and CRLF line endings; writing cmd",
120
244
  " files over ssh echo mangles % escaping)",
121
245
  "",
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`,
246
+ ...indent3(startDoorCmdBody(input, "<paste-your-token>", false)),
131
247
  "",
132
248
  "2) Hidden launcher - save as %USERPROFILE%\\.ukp\\start-door-hidden.vbs",
133
249
  " (CRLF and a real editor again: the vbs carries literal % signs that",
134
250
  " ssh echo would expand - copy the file instead)",
135
251
  "",
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",
252
+ ...indent3(startDoorVbsBody()),
152
253
  "",
153
254
  "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)",
255
+ " (run this block in PowerShell; no elevation needed: this per-user",
256
+ " logon trigger registers from a plain shell. A machine-level logon",
257
+ " trigger or another user's session still needs the elevated",
258
+ " schtasks route)",
156
259
  "",
157
- " schtasks /Create /TN ukp-door /TR \"wscript.exe \\\"%USERPROFILE%\\.ukp\\start-door-hidden.vbs\\\"\" /SC ONLOGON /F",
158
- " schtasks /Run /TN ukp-door",
260
+ ...indent3(taskRegistrationLines()),
159
261
  "",
160
262
  "4) Firewall rule for the port (from an elevated shell)",
161
263
  "",
162
- ` netsh advfirewall firewall add rule name="ukp-door" dir=in action=allow protocol=TCP localport=${input.port}`,
264
+ ` ${firewallRuleLine(input.port)}`,
265
+ "",
266
+ "5) To remove the door - stopping it is not disabling it, and",
267
+ " disabling it is not erasing it. Three levels; go as far as you",
268
+ " mean to.",
269
+ "",
270
+ " Stop it NOW: kill the LAUNCHER root - the same command as the",
271
+ " restart note in the Notes below:",
272
+ " taskkill /PID <wscript-pid> /T /F",
273
+ " The door stays down until the next logon or a manual Start; with",
274
+ " the task still registered, the next logon brings it back.",
275
+ "",
276
+ " Stop it FOR GOOD: delete the scheduled task step 3 registered",
277
+ " (ukp-door in this printout - a renamed second door has its own",
278
+ " name), from a plain shell, the same zero-elevation path as",
279
+ " registering it:",
280
+ " Unregister-ScheduledTask -TaskName ukp-door -Confirm:$false",
281
+ " The cmdlet asks for confirmation by default; unregistering does",
282
+ " NOT kill a running door - the kill above is not optional.",
283
+ "",
284
+ " Erase EVERYTHING - the reverse of steps 4 -> 0 (the firewall",
285
+ " delete needs the elevated shell again):",
286
+ ' netsh advfirewall firewall delete rule name="ukp-door"',
287
+ // Comma-separated paths: cmd's native delimiter AND PowerShell's
288
+ // array argument (a space-separated del line binds only the first
289
+ // path in PowerShell - caught live in the 2026-09-23 drill).
290
+ ' del "%USERPROFILE%\\.ukp\\start-door.cmd", "%USERPROFILE%\\.ukp\\door.log"',
291
+ ' del "%USERPROFILE%\\.ukp\\start-door-hidden.vbs"',
292
+ " start-door.cmd carries the token in plaintext: deleting it",
293
+ " destroys this copy, but if it ever left this machine (a backup,",
294
+ " a copy), treat the token as burned and mint a fresh one for any",
295
+ " future door.",
296
+ ...(input.tls?.mode === "certificates"
297
+ ? [
298
+ ` del "${input.tls.keyPath}" "${input.tls.certPath}"`,
299
+ " (the certificate paths step 0 printed)",
300
+ ]
301
+ : []),
302
+ ...(input.tls?.mode === "self-signed" && input.endpoint !== undefined
303
+ ? [
304
+ " The --tls identity lives in the served folder's .ukp\\tls\\ -",
305
+ " INSIDE the knowledge folder. Git or cloud sync",
306
+ " carries the private key out with it. Delete it when the folder",
307
+ " leaves this machine; ukp init service drops a self-ignoring",
308
+ " .ukp\\.gitignore there for exactly this reason.",
309
+ ]
310
+ : []),
311
+ ...(input.tls?.mode === "self-signed" && input.endpoint === undefined
312
+ ? [
313
+ " The --tls identity is host-local: the tls\\ folder beside the",
314
+ " host registry - delete it with the door if you are done.",
315
+ ]
316
+ : []),
317
+ " The endpoints stay registered on the host for local use;",
318
+ " unregister them too only if you are done with them, one per",
319
+ " endpoint: ukp unregister --endpoint <name>. Every consumer that",
320
+ " ran ukp register --url holds the token in plaintext in its own",
321
+ " registry - run ukp unregister --endpoint <name> there as well.",
163
322
  "",
164
323
  "Notes:",
324
+ ...(input.endpoint !== undefined
325
+ ? [
326
+ "- Single-endpoint door (one port, task, and token per endpoint):",
327
+ " omit --endpoint to serve the whole registry as one host door",
328
+ " (/e/<name>/ routing, all endpoints, one port).",
329
+ ]
330
+ : []),
165
331
  ...(input.tls?.mode === "self-signed"
166
332
  ? [
167
333
  "- --tls self-signs at first start using an openssl on PATH (Git",
@@ -169,6 +335,11 @@ export function renderServeTaskArtifacts(input: ServeTaskInput): string {
169
335
  " anywhere, pre-generate the certificate once instead: re-run with",
170
336
  " --tls-cert/--tls-key - the handbook's Windows section carries",
171
337
  " that recipe.",
338
+ "- The task's PATH is not your shell's: the logged-on task",
339
+ " environment may lack the openssl source your interactive shell",
340
+ " sees (--tls then fails to self-sign or re-sign). The common",
341
+ " source is Git for Windows' mingw64\\bin - extend PATH inside",
342
+ " start-door.cmd: set \"PATH=%PATH%;<git>\\mingw64\\bin\".",
172
343
  ]
173
344
  : []),
174
345
  "- No visible window, by design: the hidden launcher starts the console",
@@ -180,15 +351,35 @@ export function renderServeTaskArtifacts(input: ServeTaskInput): string {
180
351
  " failure visible (non-zero task result + door.log). Do not rely on",
181
352
  " Task Scheduler's restart-on-failure setting - it does not fire on",
182
353
  " exit codes.",
354
+ "- The task recipe pins no execution time limit on purpose: the Task",
355
+ " Scheduler default stops a task 72 hours after it starts, which",
356
+ " would silently kill a resident door (no window, no log line).",
183
357
  "- 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).",
358
+ " kill the LAUNCHER root, not just the serve leaf. The kill command:",
359
+ " taskkill /PID <wscript-pid> /T /F - where <wscript-pid> is the",
360
+ " wscript.exe running start-door-hidden.vbs; then",
361
+ " Start-ScheduledTask -TaskName ukp-door. A leaf-only kill lets",
362
+ " the launcher's bounded retry re-grab the port within 30s, racing",
363
+ " your Start (which IgnoreNew no-ops while the old launcher lives);",
364
+ " the scheduler's End-Task does not kill the tree either. Each door",
365
+ " has its own wscript - in PowerShell, list them and match the vbs",
366
+ " name: Get-CimInstance Win32_Process -Filter \"Name='wscript.exe'\"",
367
+ " | Select-Object ProcessId,CommandLine",
368
+ "- Git Bash users: prefix slash-style commands with MSYS_NO_PATHCONV=1",
369
+ " (e.g. MSYS_NO_PATHCONV=1 taskkill /PID <pid> /T /F), or MSYS rewrites",
370
+ " the slash arguments into Git paths and the command fails with a",
371
+ " misleading Invalid-argument error.",
186
372
  "- LOGON, not boot: the door starts when someone logs on - nobody",
187
373
  " logged on means no door. Unattended-boot always-on needs a service",
188
374
  " wrapper (WinSW-class) - deliberately outside this recipe; prefer the",
189
375
  " ssh:// path or a Linux host (systemd socket activation) for that.",
376
+ "- Stop the door before editing start-door.cmd: a running cmd",
377
+ " re-reads the script from a stale byte offset when the current",
378
+ " line's child exits, so editing lines above can make it execute",
379
+ " torn fragments of the edit.",
190
380
  "- Re-run ukp serve --print-task with different flags to regenerate;",
191
- " edits land by saving the files and re-running schtasks /Run.",
381
+ " edits land by saving the files and starting the task again",
382
+ " (Start-ScheduledTask -TaskName ukp-door).",
192
383
  "- Full recipes and hardening: the deployment handbook -",
193
384
  " docs/remote-deployment.md in this package, or",
194
385
  " https://github.com/CatheadOwl/ukp/blob/main/docs/remote-deployment.md",
@@ -196,3 +387,102 @@ export function renderServeTaskArtifacts(input: ServeTaskInput): string {
196
387
  );
197
388
  return out.join("\n");
198
389
  }
390
+
391
+ /** Characters that would corrupt the written cmd file: a quote breaks the
392
+ * `set "..."` quoting, a percent sign pairs into cmd's parse-time
393
+ * expansion, any control character can tear the file into lines. This is
394
+ * the third-live-observed hand-error class (printf `\b` mangling the PATH
395
+ * line, 2026-09-23) refused by construction - minted hex tokens (openssl
396
+ * rand -hex 32) always pass. Returns the problem description, not the
397
+ * token: the value is never echoed anywhere. */
398
+ export function unsafeTokenReason(token: string): string | undefined {
399
+ for (const ch of token) {
400
+ if (ch === '"') return "a double quote (it would break the script's set quoting)";
401
+ if (ch === "%") return "a percent sign (cmd expands % pairs at parse time)";
402
+ const code = ch.codePointAt(0)!;
403
+ if (code < 0x20 || code === 0x7f) return "a control character (it would tear the file into lines)";
404
+ }
405
+ return undefined;
406
+ }
407
+
408
+ function crlf(lines: readonly string[]): string {
409
+ return `${lines.join("\r\n")}\r\n`;
410
+ }
411
+
412
+ /** `--print-task --write` (O-020 ruling C): the two file artifacts land on
413
+ * disk under `<home>\\.ukp\\` with the env token interpolated, CRLF line
414
+ * endings by construction. Existing files are never overwritten (operator
415
+ * edits like PATH patches live in the cmd); anything already there aborts
416
+ * the whole write before the first byte. Throws KitUsageError for
417
+ * invocation-level token problems, Error for the existing-file refusal.
418
+ * Returns the stdout report - composed only from token-free pieces, so the
419
+ * token exists in exactly one place: the cmd file's bytes. */
420
+ export function writeServeTaskArtifacts(
421
+ input: ServeTaskInput,
422
+ options: { token: string | undefined; homeDir: string },
423
+ ): string {
424
+ if (options.token === undefined || options.token.length === 0) {
425
+ throw new KitUsageError(
426
+ "--write needs the door token in the environment: set UKP_SERVE_TOKEN (mint one, e.g. openssl rand -hex 32) - the generator never invents or displays it",
427
+ );
428
+ }
429
+ const unsafe = unsafeTokenReason(options.token);
430
+ if (unsafe !== undefined) {
431
+ throw new KitUsageError(
432
+ `--write refuses this UKP_SERVE_TOKEN: it contains ${unsafe} - mint a token without such characters (e.g. openssl rand -hex 32); the value itself is never echoed`,
433
+ );
434
+ }
435
+ const dir = join(options.homeDir, ".ukp");
436
+ const cmdPath = join(dir, "start-door.cmd");
437
+ const vbsPath = join(dir, "start-door-hidden.vbs");
438
+ const existing = [cmdPath, vbsPath].filter((path) => existsSync(path));
439
+ if (existing.length > 0) {
440
+ throw new Error(
441
+ `refusing to overwrite ${existing.join(", ")}: --write never clobbers existing files (operator edits like PATH patches live there) - remove them first (the print-only form's section 5 carries the removal recipe) or keep hand-saving the printed form`,
442
+ );
443
+ }
444
+ mkdirSync(dir, { recursive: true });
445
+ // The cmd carries the token: 0600 like every other secret-bearing file
446
+ // this CLI writes (the vbs is inert). CRLF, not the platform default -
447
+ // the same ending the print form begs the operator to preserve by hand.
448
+ writeFileSync(cmdPath, crlf(startDoorCmdBody(input, options.token, true)), { mode: 0o600 });
449
+ writeFileSync(vbsPath, crlf(startDoorVbsBody()), { mode: 0o644 });
450
+ const printInvocation = serveCommandLine(input).replace("ukp serve", "ukp serve --print-task");
451
+ return [
452
+ "ukp serve --print-task --write - door artifacts written",
453
+ "",
454
+ ` wrote: ${cmdPath}`,
455
+ " (the token line was interpolated from UKP_SERVE_TOKEN;",
456
+ " it is never displayed - lose the file, lose the token)",
457
+ ` wrote: ${vbsPath}`,
458
+ "",
459
+ "Nothing was registered, started, or opened - apply the rest yourself:",
460
+ "",
461
+ " register the task (PowerShell, no elevation needed):",
462
+ ...indent2(taskRegistrationLines()),
463
+ "",
464
+ " firewall rule for the port (from an elevated shell):",
465
+ ` ${firewallRuleLine(input.port)}`,
466
+ "",
467
+ ...(input.tls?.mode === "certificates"
468
+ ? [
469
+ " the certificate and key paths written into the script must",
470
+ " exist before the door starts (the print-only form's step 0",
471
+ " generates them once):",
472
+ ` ${printInvocation}`,
473
+ "",
474
+ ]
475
+ : []),
476
+ " then verify once applied: ukp diagnose --door",
477
+ "",
478
+ "Restart recipes, crash behavior, and the removal section live in the",
479
+ "print-only form and the deployment handbook:",
480
+ ` ${printInvocation}`,
481
+ " docs/remote-deployment.md (in this package)",
482
+ "",
483
+ ].join("\n");
484
+ }
485
+
486
+ function indent2(lines: readonly string[]): string[] {
487
+ return lines.map((line) => ` ${line}`);
488
+ }