@quolu/lattice 0.53.0 → 0.53.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.
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Windows bridge supervisor (bh6 prep). Startup-folder items only run once at
4
+ * logon — nothing like launchd's KeepAlive exists to restart a crashed
5
+ * process. This script is that supervision, written in JS instead of a batch
6
+ * GOTO loop because a loop's own process (and therefore its killability) is
7
+ * awkward to track reliably on Windows; a Node process's pid is not.
8
+ *
9
+ * Usage: node lattice-bridge-supervisor.mjs <descriptor.json>
10
+ * The descriptor supplies the environment `lattice-bridge.mjs` needs (it is
11
+ * never inherited from the Startup-folder launch context) and the path to
12
+ * write this supervisor's own pid to, so `bridge-startup-folder.mjs` can find
13
+ * and stop the whole tree later via `taskkill /T /F`.
14
+ */
15
+
16
+ import { spawn } from 'node:child_process';
17
+ import { writeFile } from 'node:fs/promises';
18
+ import { readFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ const RESTART_DELAY_MS = 3_000;
22
+
23
+ const descriptorPath = process.argv[2];
24
+ if (typeof descriptorPath !== 'string' || descriptorPath.length === 0) {
25
+ process.stderr.write('usage: lattice-bridge-supervisor.mjs <descriptor.json>\n');
26
+ process.exit(2);
27
+ }
28
+ const descriptor = JSON.parse(readFileSync(descriptorPath, 'utf8'));
29
+ if (descriptor?.schema !== 'lattice.bridge_supervisor_descriptor.v1'
30
+ || typeof descriptor.bridgePath !== 'string' || typeof descriptor.pidPath !== 'string'
31
+ || typeof descriptor.env !== 'object' || descriptor.env === null) {
32
+ process.stderr.write('bridge supervisor descriptor is invalid\n');
33
+ process.exit(2);
34
+ }
35
+
36
+ await writeFile(descriptor.pidPath, String(process.pid), { encoding: 'utf8', flag: 'w' });
37
+
38
+ let stopping = false;
39
+ let child = null;
40
+ const stop = () => {
41
+ stopping = true;
42
+ child?.kill();
43
+ };
44
+ process.once('SIGINT', stop);
45
+ process.once('SIGTERM', stop);
46
+
47
+ const bridgePath = path.resolve(descriptor.bridgePath);
48
+ while (!stopping) {
49
+ child = spawn(process.execPath, [bridgePath], {
50
+ env: { ...process.env, ...descriptor.env },
51
+ stdio: 'ignore',
52
+ windowsHide: true,
53
+ });
54
+ await new Promise((resolve) => child.once('exit', resolve));
55
+ child = null;
56
+ if (stopping) break;
57
+ await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY_MS));
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.53.0",
3
+ "version": "0.53.1",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
@@ -17,6 +17,10 @@ import {
17
17
  disableBridgeLaunchAgent, installBridgeLaunchAgent, restoreBridgeLaunchAgent,
18
18
  snapshotBridgeLaunchAgent,
19
19
  } from './bridge-launch-agent.mjs';
20
+ import {
21
+ disableBridgeStartupFolder, installBridgeStartupFolder, restoreBridgeStartupFolder,
22
+ snapshotBridgeStartupFolder,
23
+ } from './bridge-startup-folder.mjs';
20
24
 
21
25
  // v2 adds the liveness fields. `enabled` only says the configuration is on;
22
26
  // it never said the bridge could actually be reached, which let a DHCP lease
@@ -66,6 +70,22 @@ async function bridgeLiveness(config, { interfaces = networkInterfaces(), probe
66
70
  };
67
71
  }
68
72
 
73
+ // The bridge's own persistence mechanism is OS-specific; everything above
74
+ // this line (config, daemon lifecycle, registrar) is not. Selecting by
75
+ // `process.platform` here — rather than requiring every caller to pick — is
76
+ // what lets `lattice bridge setup` on Windows persist via the Startup folder
77
+ // exactly the way it persists via a LaunchAgent on macOS, with no separate
78
+ // command or manual step (see bridge-startup-folder.mjs's module doc for why
79
+ // Task Scheduler's ONLOGON trigger could not be used instead).
80
+ function platformLaunchAgent() {
81
+ if (process.platform === 'win32') {
82
+ return { snapshot: snapshotBridgeStartupFolder, install: installBridgeStartupFolder,
83
+ disable: disableBridgeStartupFolder, restore: restoreBridgeStartupFolder };
84
+ }
85
+ return { snapshot: snapshotBridgeLaunchAgent, install: installBridgeLaunchAgent,
86
+ disable: disableBridgeLaunchAgent, restore: restoreBridgeLaunchAgent };
87
+ }
88
+
69
89
  function fail(stderr, code, message) {
70
90
  stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code, message })}\n`);
71
91
  return 2;
@@ -160,8 +180,7 @@ export async function collectBridgeSetupWizard({ input, output, prompts = clack
160
180
  export async function runBridgeCli({ argv, stdout, stderr, env = process.env,
161
181
  stdin = process.stdin, daemon = { ensure: ensureBridgeDaemon, requestStop: requestBridgeDaemonStop,
162
182
  stop: stopBridgeDaemon, clearStop: clearBridgeStopControl },
163
- launchAgent = { snapshot: snapshotBridgeLaunchAgent, install: installBridgeLaunchAgent,
164
- disable: disableBridgeLaunchAgent, restore: restoreBridgeLaunchAgent },
183
+ launchAgent = platformLaunchAgent(),
165
184
  prompts = clack, probe = probeBridgeListener, interfaces = networkInterfaces() } = {}) {
166
185
  if (!Array.isArray(argv)) {
167
186
  return fail(stderr, 'USAGE', 'usage: lattice bridge <setup|reconfigure|status|disable|register> [options] --json');
@@ -175,7 +175,14 @@ async function readDocument(ref) {
175
175
  if (error?.code === 'ENOENT') return null;
176
176
  throw new BridgeConfigError('BRIDGE_CONFIG_UNREADABLE', 'bridge config cannot be read', undefined, error);
177
177
  }
178
- if (!stats.isFile() || stats.isSymbolicLink() || (stats.mode & 0o777) !== 0o600) {
178
+ // Windows has no POSIX permission-bit model: `fs.stat().mode` never reports
179
+ // 0600 there regardless of what `mode`/`chmod` requested at write time, so
180
+ // this check is a hard, unconditional block on every platform but darwin/
181
+ // linux — verified against a real Windows host (`BRIDGE_CONFIG_MODE_INVALID`
182
+ // on a config `configureBridge` itself had just written moments earlier).
183
+ // The other checks (regular file, not a symlink) still apply everywhere.
184
+ if (!stats.isFile() || stats.isSymbolicLink()
185
+ || (process.platform !== 'win32' && (stats.mode & 0o777) !== 0o600)) {
179
186
  throw new BridgeConfigError('BRIDGE_CONFIG_MODE_INVALID', 'bridge config must be a regular 0600 file');
180
187
  }
181
188
  let value;
@@ -83,7 +83,10 @@ async function readStrictJsonOnce(ref, label) {
83
83
  let handle;
84
84
  try {
85
85
  before = await lstat(ref);
86
- if (!before.isFile() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o600
86
+ // Windows has no POSIX permission-bit model see bridge-config.mjs's
87
+ // readDocument for the same guard and the real-host verification.
88
+ if (!before.isFile() || before.isSymbolicLink()
89
+ || (process.platform !== 'win32' && (before.mode & 0o777) !== 0o600)
87
90
  || before.size > CONTROL_MAX_BYTES) throw new Error(`${label} unsafe`);
88
91
  handle = await open(ref, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
89
92
  const opened = await handle.stat();
@@ -176,19 +176,30 @@ function respondError(response, status, code, detail = null) {
176
176
  response.end(`${JSON.stringify(body)}\n`);
177
177
  }
178
178
 
179
+ // This must stay visually identical to todo-gantt-live.mjs's dashboardHtml
180
+ // (same shell/brand/card markup, same design tokens) — the public entrance
181
+ // changing its own look when the routing behind it changed from single- to
182
+ // multi-terminal is exactly the regression the owner flagged (room 2474;
183
+ // plan_bridge-hub.md's non-goal "dashboard renderer/gantt UIの変更はしない"
184
+ // covers keeping this landing's appearance, not just the diagrams behind it).
185
+ // Reimplemented locally rather than imported: this codebase's bridge modules
186
+ // each keep their own copy of such patterns rather than cross-importing
187
+ // (see bh2-hub-server.md's rationale for validatedHubHost et al.), and the
188
+ // only genuinely new thing here — an online/offline badge per project — has
189
+ // no home in the single-terminal original to import from anyway.
179
190
  function hubIndexHtml(view) {
180
191
  const rows = view.map((project) => {
181
192
  const href = `/projects/${encodeURIComponent(project.project_id)}/`;
182
- const statusLabel = project.status === 'online' ? 'オンライン' : 'オフライン';
193
+ const online = project.status === 'online';
194
+ const statusLabel = online ? 'オンライン' : 'オフライン';
195
+ const statusClass = online ? 'status-online' : 'status-offline';
183
196
  const identity = project.display_name === project.project_id ? '' : `<code>${escapeHtml(project.project_id)}</code>`;
184
197
  return `<li><a href="${escapeHtml(href)}"><strong>${escapeHtml(project.display_name)}</strong>`
185
- + `${identity}<span>${escapeHtml(statusLabel)}</span></a></li>`;
198
+ + `${identity}<span class="${statusClass}">${escapeHtml(statusLabel)}</span>`
199
+ + `<span aria-hidden="true">→</span></a></li>`;
186
200
  }).join('');
187
- const content = rows.length === 0 ? '<p>登録されているプロジェクトはありません。</p>' : `<ul>${rows}</ul>`;
188
- return `<!doctype html><html lang="ja"><head><meta charset="utf-8">`
189
- + `<meta name="viewport" content="width=device-width,initial-scale=1">`
190
- + `<title>登録済みプロジェクト — Lattice hub</title></head>`
191
- + `<body><h1>登録済みプロジェクト</h1>${content}</body></html>`;
201
+ const content = rows.length === 0 ? '<p>登録されている端末はありません。</p>' : `<ul>${rows}</ul>`;
202
+ return `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta name="robots" content="noindex, nofollow"><meta property="og:title" content="公開中の工程表 — Lattice"><meta property="og:description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta name="theme-color" content="#f7f3ea"><title>公開中の工程表 — Lattice</title><style>:root{color-scheme:light;--paper:#f7f3ea;--panel:#fffdf8;--ink:#201d19;--soft:#6c655d;--line:#d8d0c5;--cobalt:#315cbe;--orange:#e85f2a;--good:#0ca30c;--critical:#d03b3b}*{box-sizing:border-box}body{min-height:100vh;margin:0;color:var(--ink);background:var(--paper);font:16px/1.7 system-ui,-apple-system,sans-serif}.shell{max-width:880px;margin:0 auto;padding:28px 22px 40px}.brand{display:flex;align-items:center;gap:9px;padding-bottom:24px;border-bottom:1px solid var(--line);font-size:.88rem}.brand a,.footer a{color:var(--ink);font-weight:800;text-decoration:none}.brand a:hover,.footer a:hover{color:var(--cobalt)}.brand span{color:var(--soft)}main{padding:64px 0 72px}.eyebrow{margin:0 0 8px;color:var(--orange);font-size:.76rem;font-weight:800;letter-spacing:.14em}.lead{max-width:620px;margin:0 0 34px;color:var(--soft)}h1{margin:0 0 14px;font-size:clamp(2rem,6vw,3.4rem);line-height:1.12;letter-spacing:-.04em}ul{display:grid;gap:12px;margin:0;padding:0;list-style:none}li a{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:20px;padding:18px 20px;border:1px solid var(--line);border-radius:12px;color:inherit;background:var(--panel);text-decoration:none;box-shadow:0 8px 28px rgba(48,39,27,.04)}li a:hover{border-color:var(--cobalt);transform:translateY(-1px)}li strong{font-size:1.04rem}li code{color:var(--soft);font-size:.78rem}li .status-online{color:var(--good);font-weight:800}li .status-offline{color:var(--critical);font-weight:800}li>a>span[aria-hidden]{color:var(--cobalt);font-weight:800}.note{margin:28px 0 0;padding:16px 18px;border-left:3px solid var(--orange);color:var(--soft);background:rgba(255,253,248,.72);font-size:.88rem}.footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:16px;padding-top:20px;border-top:1px solid var(--line);color:var(--soft);font-size:.82rem}.footer nav{display:flex;gap:18px}@media(max-width:560px){.shell{padding:20px 16px 32px}main{padding:44px 0 56px}li a{grid-template-columns:minmax(0,1fr) auto;padding:16px}li code{grid-column:1/-1;grid-row:2}.footer{display:block}.footer nav{margin-top:10px}}</style></head><body><div class="shell"><header class="brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong></header><main><p class="eyebrow">LIVE DEVELOPMENT</p><h1>公開中の工程表</h1><p class="lead">Latticeが管理しているプロジェクトの工程と、いまどこまで進んでいるかを公開データから確認できます。</p>${content}<p class="note">表示内容はLatticeの記録から自動生成されます。製品の紹介や使い方はGitHubをご覧ください。</p></main><footer class="footer"><span>kitepon.dev の開発工程を、Latticeで可視化しています。</span><nav aria-label="関連リンク"><a href="https://kitepon.dev/">kitepon.dev</a><a href="https://github.com/kitepon-rgb/Lattice">GitHub</a></nav></footer></div></body></html>`;
192
203
  }
193
204
 
194
205
  function hubProjectStatusHtml(code, projectId, requestPath, message) {
@@ -21,6 +21,8 @@
21
21
 
22
22
  import { execFile } from 'node:child_process';
23
23
 
24
+ import { normalizeBridgeHubUrl } from './bridge-config.mjs';
25
+
24
26
  export const REGISTRAR_RESULT_SCHEMA = 'lattice.bridge_registrar_result.v1';
25
27
 
26
28
  const SSH_HOST = /^[A-Za-z0-9][A-Za-z0-9._-]{0,253}$/u;
@@ -100,3 +102,19 @@ export async function registerBridgeUpstream({
100
102
  state: remote.changed === true ? 'updated' : 'unchanged',
101
103
  port, host: settings.host, remote, detail: null };
102
104
  }
105
+
106
+ /**
107
+ * Extract a validated hub URL from a `registerBridgeUpstream` result, or
108
+ * `null` if this response carries none (old `lattice.bridge_registration.v1`
109
+ * script, a failed/not_configured registration, or a malformed value).
110
+ *
111
+ * Never throws: the caller is bh5's auto-migration path, which must fail
112
+ * safe into the legacy (no-hub) configuration rather than crash the daemon
113
+ * over a malformed hint from a remote script it does not control the
114
+ * deployment of.
115
+ */
116
+ export function deriveBridgeHubUrlFromRegistration(result) {
117
+ const hubUrl = result?.remote?.hub_url;
118
+ if (typeof hubUrl !== 'string' || hubUrl.length === 0) return null;
119
+ try { return normalizeBridgeHubUrl({ url: hubUrl }).url; } catch { return null; }
120
+ }
@@ -45,7 +45,10 @@ async function readDashboardDescriptor(ref) {
45
45
  let handle;
46
46
  try {
47
47
  before = await lstat(ref);
48
- if (!before.isFile() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o600 || before.size > 65_536) {
48
+ // Windows has no POSIX permission-bit model see bridge-config.mjs's
49
+ // readDocument for the same guard and the real-host verification.
50
+ if (!before.isFile() || before.isSymbolicLink()
51
+ || (process.platform !== 'win32' && (before.mode & 0o777) !== 0o600) || before.size > 65_536) {
49
52
  throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor is unsafe');
50
53
  }
51
54
  handle = await open(ref, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Windows bridge persistence via the per-user Startup folder — the Windows
3
+ * counterpart to `bridge-launch-agent.mjs`'s macOS LaunchAgent, with the same
4
+ * public contract (`snapshot`/`install`/`disable`/`restore`) so `bridge-cli.mjs`
5
+ * can select between them by `process.platform` without changing its own flow.
6
+ *
7
+ * Windows has no per-user analogue of launchd's KeepAlive supervision, and
8
+ * Task Scheduler's `ONLOGON` trigger requires elevation this product's "no
9
+ * ritual beyond one-time setup" bar cannot spend (verified empirically: both
10
+ * `schtasks /Create /SC ONLOGON` and `Register-ScheduledTask -Trigger
11
+ * (New-ScheduledTaskTrigger -AtLogOn)` return access-denied under a normal,
12
+ * non-elevated user token). The Startup folder needs no elevation — writing
13
+ * into `%APPDATA%\...\Startup` is an ordinary per-user file operation — but it
14
+ * only *starts* something at logon; nothing supervises it afterward.
15
+ *
16
+ * `lattice-bridge-supervisor.mjs` supplies that supervision (spawn, wait for
17
+ * exit, restart) in plain JS rather than a batch GOTO loop: a loop's own
18
+ * process is awkward to track and kill reliably on Windows, while a Node
19
+ * process's pid is not. The Startup-folder `.vbs` launcher runs the
20
+ * supervisor hidden (`WindowStyle 0`) so no console window appears at logon
21
+ * or at any crash-restart — the same class of bug the Windows-console-
22
+ * avalanche P0 hotfix (`windowsHide`, 0.52.4) exists to avoid, here via a
23
+ * different mechanism since this process tree is spawned by Windows logon
24
+ * rather than by this codebase's own `child_process.spawn`. Stopping the
25
+ * whole tree (supervisor + whatever bridge child it currently owns) uses
26
+ * `taskkill /T /F /PID <supervisor pid>` — Windows's own recursive-kill,
27
+ * since a forcibly-terminated supervisor gets no chance to clean up its own
28
+ * child.
29
+ */
30
+
31
+ import { execFile } from 'node:child_process';
32
+ import { randomBytes } from 'node:crypto';
33
+ import { constants as fsConstants } from 'node:fs';
34
+ import {
35
+ lstat, mkdir, open, readFile, realpath, rename, rm, writeFile,
36
+ } from 'node:fs/promises';
37
+ import path from 'node:path';
38
+ import { promisify } from 'node:util';
39
+
40
+ import { BridgeConfigError, readBridgeConfig } from './bridge-config.mjs';
41
+ import { readBridgeDaemonDescriptor } from './bridge-daemon.mjs';
42
+ import { bridgeRegistrarSettings } from './bridge-registrar.mjs';
43
+
44
+ export const BRIDGE_STARTUP_LABEL = 'LatticeBridge';
45
+ const DESCRIPTOR_SCHEMA = 'lattice.bridge_supervisor_descriptor.v1';
46
+ const START_TIMEOUT_MS = 5_000;
47
+ const STOP_TIMEOUT_MS = 3_000;
48
+ const execFileAsync = promisify(execFile);
49
+
50
+ function fail(code, message, cause = undefined) {
51
+ return new BridgeConfigError(code, message, undefined, cause);
52
+ }
53
+
54
+ export function bridgeStartupFolderPaths(env = process.env) {
55
+ const appData = env.APPDATA;
56
+ const localAppData = env.LOCALAPPDATA;
57
+ if (typeof appData !== 'string' || !path.isAbsolute(appData)) {
58
+ throw fail('BRIDGE_STARTUP_FOLDER_APPDATA_INVALID', 'APPDATA must be an absolute path');
59
+ }
60
+ if (typeof localAppData !== 'string' || !path.isAbsolute(localAppData)) {
61
+ throw fail('BRIDGE_STARTUP_FOLDER_APPDATA_INVALID', 'LOCALAPPDATA must be an absolute path');
62
+ }
63
+ // The launcher must live in the Startup folder — Windows only runs what it
64
+ // finds there. Everything it launches lives in our own runtime directory
65
+ // instead: Startup-folder contents are conventionally opaque shortcuts, and
66
+ // keeping the real state (descriptor, pidfile) in a folder this module
67
+ // fully owns keeps the safety checks below meaningful.
68
+ const startupDirectory = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
69
+ const runtimeDirectory = path.join(localAppData, 'Lattice', 'bridge-startup');
70
+ return Object.freeze({
71
+ startupDirectory, runtimeDirectory,
72
+ launcher: path.join(startupDirectory, `${BRIDGE_STARTUP_LABEL}.vbs`),
73
+ descriptor: path.join(runtimeDirectory, 'descriptor.json'),
74
+ pidfile: path.join(runtimeDirectory, 'supervisor.pid'),
75
+ });
76
+ }
77
+
78
+ async function prepareDirectory(directory) {
79
+ await mkdir(directory, { recursive: true });
80
+ const stats = await lstat(directory);
81
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
82
+ throw fail('BRIDGE_STARTUP_FOLDER_DIR_UNSAFE', 'startup folder path is unsafe');
83
+ }
84
+ }
85
+
86
+ async function strictFile(ref, maxBytes = 65_536) {
87
+ let before;
88
+ let handle;
89
+ try {
90
+ before = await lstat(ref);
91
+ if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes) {
92
+ throw new Error('unsafe startup file');
93
+ }
94
+ handle = await open(ref, fsConstants.O_RDONLY);
95
+ const opened = await handle.stat();
96
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
97
+ || opened.size !== before.size) {
98
+ throw new Error('startup file changed during validation');
99
+ }
100
+ const content = await handle.readFile('utf8');
101
+ const after = await lstat(ref);
102
+ if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) {
103
+ throw new Error('startup file changed during read');
104
+ }
105
+ return content;
106
+ } catch (error) {
107
+ if (error?.code === 'ENOENT' && before === undefined) return null;
108
+ throw fail('BRIDGE_STARTUP_FOLDER_FILE_UNSAFE', 'bridge startup file is unsafe', error);
109
+ } finally {
110
+ await handle?.close();
111
+ }
112
+ }
113
+
114
+ async function atomicFile(ref, content) {
115
+ const temporary = `${ref}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
116
+ try {
117
+ await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
118
+ await rename(temporary, ref);
119
+ } finally {
120
+ await rm(temporary, { force: true });
121
+ }
122
+ }
123
+
124
+ async function executablePath(ref, label) {
125
+ if (typeof ref !== 'string' || !path.isAbsolute(ref)) {
126
+ throw fail('BRIDGE_STARTUP_FOLDER_EXECUTABLE_INVALID', `${label} path must be absolute`);
127
+ }
128
+ let resolved;
129
+ let stats;
130
+ try {
131
+ resolved = await realpath(ref);
132
+ stats = await lstat(resolved);
133
+ } catch (error) {
134
+ throw fail('BRIDGE_STARTUP_FOLDER_EXECUTABLE_INVALID', `${label} is unavailable`, error);
135
+ }
136
+ if (!stats.isFile() || stats.isSymbolicLink()) {
137
+ throw fail('BRIDGE_STARTUP_FOLDER_EXECUTABLE_INVALID', `${label} is unsafe`);
138
+ }
139
+ return resolved;
140
+ }
141
+
142
+ /** VBS escapes an embedded `"` by doubling it. Paths we generate never
143
+ * contain one (`executablePath`/our own runtime dir), so this only guards
144
+ * against that invariant silently breaking rather than mis-escaping. */
145
+ function vbsQuoted(label, value) {
146
+ if (typeof value !== 'string' || value.length === 0 || value.includes('"')) {
147
+ throw fail('BRIDGE_STARTUP_FOLDER_VALUE_UNSAFE', `${label} is unsafe to embed in the startup launcher`);
148
+ }
149
+ return `"""${value}"""`;
150
+ }
151
+
152
+ function launcherScript({ nodePath, supervisorPath, descriptorPath }) {
153
+ // WScript.Shell.Run(command, windowStyle, waitOnReturn). windowStyle 0 =
154
+ // hidden, waitOnReturn False = fire-and-forget (the supervisor outlives
155
+ // wscript.exe, which exits right after this call).
156
+ const command = [vbsQuoted('node executable', nodePath), vbsQuoted('supervisor script', supervisorPath),
157
+ vbsQuoted('descriptor path', descriptorPath)].join(' & " " & ');
158
+ return `Set shell = CreateObject("WScript.Shell")\r\nshell.Run ${command}, 0, False\r\n`;
159
+ }
160
+
161
+ function supervisorDescriptor({ bridgePath, pidfile, instanceToken, env }) {
162
+ const forwarded = { LATTICE_BRIDGE_INSTANCE_TOKEN: instanceToken };
163
+ if (env.LATTICE_CONFIG_DIR !== undefined) {
164
+ if (typeof env.LATTICE_CONFIG_DIR !== 'string' || !path.isAbsolute(env.LATTICE_CONFIG_DIR)) {
165
+ throw fail('BRIDGE_CONFIG_DIR_INVALID', 'LATTICE_CONFIG_DIR must be absolute');
166
+ }
167
+ forwarded.LATTICE_CONFIG_DIR = env.LATTICE_CONFIG_DIR;
168
+ }
169
+ // Same rationale as the LaunchAgent plist: nothing here inherits the
170
+ // installer's shell environment at restart time, so registrar settings must
171
+ // be baked in or self-registration silently never fires after a crash restart.
172
+ const registrar = bridgeRegistrarSettings(env);
173
+ if (registrar !== null) {
174
+ forwarded.LATTICE_BRIDGE_REGISTRAR_SSH_HOST = registrar.host;
175
+ forwarded.LATTICE_BRIDGE_REGISTRAR_SCRIPT = registrar.script;
176
+ }
177
+ return JSON.stringify({ schema: DESCRIPTOR_SCHEMA, bridgePath, pidPath: pidfile, env: forwarded });
178
+ }
179
+
180
+ export async function defaultStartupRunner(args) {
181
+ try {
182
+ const result = await execFileAsync(args[0], args.slice(1), { encoding: 'utf8', windowsHide: true });
183
+ return { code: 0, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
184
+ } catch (error) {
185
+ if (Number.isInteger(error?.code)) {
186
+ return { code: error.code, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
187
+ }
188
+ throw fail('BRIDGE_STARTUP_LAUNCHER_UNAVAILABLE', 'the startup launcher could not be executed', error);
189
+ }
190
+ }
191
+
192
+ function healthHost(address) {
193
+ if (address === '0.0.0.0') return '127.0.0.1';
194
+ if (address === '::') return '[::1]';
195
+ return address.includes(':') ? `[${address}]` : address;
196
+ }
197
+
198
+ async function defaultWaitReady({ config, instanceToken, env, timeoutMs = START_TIMEOUT_MS }) {
199
+ const deadline = Date.now() + timeoutMs;
200
+ while (Date.now() < deadline) {
201
+ const descriptor = await readBridgeDaemonDescriptor({ env });
202
+ if (descriptor?.address === config.listen.address && descriptor?.port === config.listen.port
203
+ && descriptor?.config_updated_at === config.updated_at) {
204
+ try {
205
+ const response = await fetch(
206
+ `http://${healthHost(descriptor.address)}:${descriptor.port}/__lattice/bridge-health`, {
207
+ headers: { 'x-lattice-bridge-instance-token': instanceToken },
208
+ signal: AbortSignal.timeout(400),
209
+ });
210
+ const body = response.status === 200 ? await response.json() : null;
211
+ if (body?.schema === 'lattice.bridge_health.v1' && body.pid === descriptor.pid
212
+ && body.updated_at === config.updated_at) return descriptor;
213
+ } catch {}
214
+ }
215
+ await new Promise((resolve) => setTimeout(resolve, 50));
216
+ }
217
+ throw fail('BRIDGE_STARTUP_FOLDER_START_FAILED', 'bridge startup process did not become healthy');
218
+ }
219
+
220
+ async function defaultWaitStopped({ listen, timeoutMs = STOP_TIMEOUT_MS }) {
221
+ if (listen === null) return;
222
+ const deadline = Date.now() + timeoutMs;
223
+ while (Date.now() < deadline) {
224
+ try {
225
+ await fetch(`http://${healthHost(listen.address)}:${listen.port}/__lattice/bridge-health`,
226
+ { signal: AbortSignal.timeout(300) });
227
+ } catch { return; }
228
+ await new Promise((resolve) => setTimeout(resolve, 50));
229
+ }
230
+ throw fail('BRIDGE_STARTUP_FOLDER_STOP_FAILED', 'bridge startup process socket did not stop');
231
+ }
232
+
233
+ export async function snapshotBridgeStartupFolder({ env = process.env } = {}) {
234
+ const refs = bridgeStartupFolderPaths(env);
235
+ await prepareDirectory(refs.startupDirectory);
236
+ await prepareDirectory(refs.runtimeDirectory);
237
+ const launcherContent = await strictFile(refs.launcher);
238
+ const descriptorContent = await strictFile(refs.descriptor);
239
+ if ((launcherContent === null) !== (descriptorContent === null)) {
240
+ throw fail('BRIDGE_STARTUP_FOLDER_STATE_INVALID', 'startup launcher and descriptor disagree on installed state');
241
+ }
242
+ return Object.freeze({ installed: launcherContent !== null, launcherContent, descriptorContent });
243
+ }
244
+
245
+ /** Read the supervisor's own recorded pid and kill its whole process tree
246
+ * (`taskkill /T /F`) — a forcibly-terminated supervisor cannot clean up its
247
+ * child itself, so the tree kill is what actually stops the bridge, not the
248
+ * SIGTERM handler `lattice-bridge.mjs` relies on when Node manages it directly. */
249
+ async function stopRunning({ env, listen, runner, waitStopped }) {
250
+ const refs = bridgeStartupFolderPaths(env);
251
+ let pidText;
252
+ try { pidText = await readFile(refs.pidfile, 'utf8'); } catch (error) {
253
+ if (error?.code === 'ENOENT') return;
254
+ throw fail('BRIDGE_STARTUP_FOLDER_STOP_FAILED', 'could not read supervisor pidfile', error);
255
+ }
256
+ const pid = Number(pidText.trim());
257
+ if (!Number.isSafeInteger(pid) || pid <= 0) return;
258
+ const result = await runner(['taskkill.exe', '/T', '/F', '/PID', String(pid)]);
259
+ // taskkill exits non-zero (128) when the target is already gone — not a failure to report.
260
+ if (result.code !== 0 && !/not found|not running/iu.test(result.stderr ?? '')) {
261
+ throw fail('BRIDGE_STARTUP_FOLDER_STOP_FAILED', 'could not stop bridge supervisor process tree');
262
+ }
263
+ await waitStopped({ listen, env });
264
+ await rm(refs.pidfile, { force: true });
265
+ }
266
+
267
+ export async function installBridgeStartupFolder({ config, env = process.env,
268
+ runner = defaultStartupRunner, nodePath = process.execPath,
269
+ bridgePath = path.resolve(import.meta.dirname, '../bin/lattice-bridge.mjs'),
270
+ supervisorPath = path.resolve(import.meta.dirname, '../bin/lattice-bridge-supervisor.mjs'),
271
+ waitReady = defaultWaitReady, waitStopped = defaultWaitStopped,
272
+ previousListen = null } = {}) {
273
+ if (config?.enabled !== true) throw fail('BRIDGE_DISABLED', 'bridge is disabled');
274
+ const refs = bridgeStartupFolderPaths(env);
275
+ await prepareDirectory(refs.startupDirectory);
276
+ await prepareDirectory(refs.runtimeDirectory);
277
+ await strictFile(refs.launcher);
278
+ await strictFile(refs.descriptor);
279
+ const resolvedNode = await executablePath(nodePath, 'node executable');
280
+ const resolvedBridge = await executablePath(bridgePath, 'bridge executable');
281
+ const resolvedSupervisor = await executablePath(supervisorPath, 'supervisor executable');
282
+ const instanceToken = randomBytes(32).toString('hex');
283
+ const descriptorContent = supervisorDescriptor({
284
+ bridgePath: resolvedBridge, pidfile: refs.pidfile, instanceToken, env,
285
+ });
286
+ await stopRunning({ env, listen: previousListen, runner, waitStopped });
287
+ await atomicFile(refs.descriptor, descriptorContent);
288
+ await atomicFile(refs.launcher,
289
+ launcherScript({ nodePath: resolvedNode, supervisorPath: resolvedSupervisor, descriptorPath: refs.descriptor }));
290
+ await launch(runner, ['wscript.exe', refs.launcher], 'BRIDGE_STARTUP_LAUNCHER_FAILED',
291
+ 'could not start the bridge startup process');
292
+ return waitReady({ config, instanceToken, env });
293
+ }
294
+
295
+ async function launch(runner, args, code, message) {
296
+ let result;
297
+ try { result = await runner(args); } catch (error) {
298
+ if (error instanceof BridgeConfigError) throw error;
299
+ throw fail(code, message, error);
300
+ }
301
+ if (!result || result.code !== 0) throw fail(code, message);
302
+ return result;
303
+ }
304
+
305
+ export async function disableBridgeStartupFolder({ snapshot, listen, env = process.env,
306
+ runner = defaultStartupRunner, waitStopped = defaultWaitStopped } = {}) {
307
+ if (!snapshot || typeof snapshot.installed !== 'boolean') {
308
+ throw new TypeError('bridge startup folder snapshot required');
309
+ }
310
+ const refs = bridgeStartupFolderPaths(env);
311
+ const stopped = snapshot.installed;
312
+ if (stopped) await stopRunning({ env, listen, runner, waitStopped });
313
+ await rm(refs.launcher, { force: true });
314
+ await rm(refs.descriptor, { force: true });
315
+ return { removed: snapshot.installed, stopped };
316
+ }
317
+
318
+ export async function restoreBridgeStartupFolder({ snapshot, listen = null, env = process.env,
319
+ runner = defaultStartupRunner, waitStopped = defaultWaitStopped,
320
+ config = undefined, waitReady = defaultWaitReady } = {}) {
321
+ if (!snapshot || typeof snapshot.installed !== 'boolean'
322
+ || (snapshot.installed
323
+ && (typeof snapshot.launcherContent !== 'string' || typeof snapshot.descriptorContent !== 'string'))) {
324
+ throw new TypeError('bridge startup folder snapshot required');
325
+ }
326
+ const refs = bridgeStartupFolderPaths(env);
327
+ await prepareDirectory(refs.startupDirectory);
328
+ await prepareDirectory(refs.runtimeDirectory);
329
+ await stopRunning({ env, listen, runner, waitStopped });
330
+ if (snapshot.installed) {
331
+ await atomicFile(refs.descriptor, snapshot.descriptorContent);
332
+ await atomicFile(refs.launcher, snapshot.launcherContent);
333
+ await launch(runner, ['wscript.exe', refs.launcher], 'BRIDGE_STARTUP_ROLLBACK_FAILED',
334
+ 'could not restore the bridge startup process');
335
+ const restoredConfig = config ?? await readBridgeConfig({ env });
336
+ let tokenMatch = null;
337
+ try { tokenMatch = JSON.parse(snapshot.descriptorContent).env?.LATTICE_BRIDGE_INSTANCE_TOKEN ?? null; }
338
+ catch { tokenMatch = null; }
339
+ if (restoredConfig?.enabled !== true || typeof tokenMatch !== 'string' || !/^[0-9a-f]{64}$/u.test(tokenMatch)) {
340
+ throw fail('BRIDGE_STARTUP_ROLLBACK_FAILED', 'restored bridge startup process is not attestable');
341
+ }
342
+ await waitReady({ config: restoredConfig, instanceToken: tokenMatch, env });
343
+ } else {
344
+ await rm(refs.launcher, { force: true });
345
+ await rm(refs.descriptor, { force: true });
346
+ }
347
+ return snapshot;
348
+ }