tmux-ide 2.9.2 → 2.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/cli.js +2071 -625
- package/package.json +14 -5
- package/packages/contracts/src/desktop-host.ts +3 -0
- package/packages/contracts/src/saved-machines.ts +19 -0
- package/packages/contracts/src/terminal-replica.ts +1 -1
- package/packages/core/package.json +3 -2
- package/packages/core/src/terminal-delivery.ts +51 -3
- package/packages/core/src/terminal-fnv64-wasm.ts +4 -3
- package/packages/core/src/terminal-replica-hash-cache.ts +129 -31
- package/packages/core/src/terminal-replica-owned-row.ts +27 -0
- package/packages/core/src/terminal-replica.ts +13 -28
- package/packages/daemon/dist/command-center/daemon-fleet-facts-observer.js +96 -10
- package/packages/daemon/dist/command-center/resources/saved-machine-route.js +40 -1
- package/packages/daemon/dist/command-center/ws-events.js +5 -24
- package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +8 -0
- package/packages/daemon/dist/lib/canonical-daemon.js +28 -2
- package/packages/daemon/dist/lib/daemon-embed.js +1 -1
- package/packages/daemon/dist/lib/daemon-service-manager.js +122 -0
- package/packages/daemon/dist/lib/daemon-service-plan.js +127 -0
- package/packages/daemon/dist/lib/daemon-service.js +259 -0
- package/packages/daemon/dist/lib/install-origin.js +2 -0
- package/packages/daemon/dist/lib/local-fleet-request.js +9 -3
- package/packages/daemon/dist/lib/log.js +6 -10
- package/packages/daemon/dist/lib/managed-installer-update.js +67 -0
- package/packages/daemon/dist/lib/{tui-download-lock.js → private-operation-lock.js} +11 -11
- package/packages/daemon/dist/lib/remote-daemon-info.js +3 -2
- package/packages/daemon/dist/lib/remote-daemon-lifecycle.js +4 -4
- package/packages/daemon/dist/lib/remote-tmux-command.js +10 -0
- package/packages/daemon/dist/lib/runtime-namespace.js +28 -16
- package/packages/daemon/dist/lib/saved-machines.js +40 -2
- package/packages/daemon/dist/lib/ssh-daemon-transport.js +24 -9
- package/packages/daemon/dist/lib/tmux-server-owner.js +1 -1
- package/packages/daemon/dist/lib/tmux-server-registration.js +21 -3
- package/packages/daemon/dist/lib/tui-binary.js +2 -2
- package/packages/daemon/dist/lib/update.js +18 -0
- package/packages/daemon/dist/lib/workspace-multiplexer-verbs.js +24 -1
- package/packages/daemon/dist/machines.js +40 -4
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
- package/packages/daemon/dist/terminal/attachments/direct-websocket.js +4 -1
- package/packages/daemon/dist/terminal/attachments/native-runtime.js +22 -13
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +101 -3
- package/packages/daemon/dist/terminal/mirror/registered-window-guard.js +62 -0
- package/packages/daemon/dist/terminal/mirror/session-channel.js +75 -4
- package/packages/daemon/dist/terminal/mirror/shared-window-index.js +45 -0
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +11 -1
- package/packages/daemon/dist/terminal/session-runtime/multiplexer-backend.js +1 -0
- package/packages/daemon/dist/terminal/session-runtime/registry.js +56 -7
- package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-executor.js +3 -1
- package/packages/daemon/dist/tui/mirror/dialog-model.js +2 -35
- package/packages/daemon/dist/tui/mirror/runtime/application-daemon-authority-owner.js +8 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-home-fleet.js +20 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-agents.js +7 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-authority.js +59 -9
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +27 -19
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +23 -18
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-startup.js +31 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-palette-search-owner.js +7 -3
- package/packages/daemon/dist/tui/mirror/runtime/application-performance-log.js +3 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +146 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +201 -50
- package/packages/daemon/dist/tui/mirror/runtime/bounded-performance-record-writer.js +50 -10
- package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +6 -5
- package/packages/daemon/dist/tui/mirror/runtime/terminal-pane-input-router.js +3 -0
- package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +8 -2
- package/packages/daemon/dist/tui/mirror/terminal-text.js +35 -0
- package/packages/daemon/dist/tui/mirror/workspace/connection-feedback.js +5 -3
- package/packages/daemon/src/command-center/daemon-fleet-facts-observer.ts +100 -10
- package/packages/daemon/src/command-center/resources/saved-machine-route.ts +48 -1
- package/packages/daemon/src/command-center/ws-events.ts +5 -25
- package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +12 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +44 -2
- package/packages/daemon/src/lib/daemon-embed.ts +1 -0
- package/packages/daemon/src/lib/daemon-service-manager.ts +152 -0
- package/packages/daemon/src/lib/daemon-service-plan.ts +153 -0
- package/packages/daemon/src/lib/daemon-service.ts +301 -0
- package/packages/daemon/src/lib/install-origin.ts +5 -0
- package/packages/daemon/src/lib/local-fleet-request.ts +19 -3
- package/packages/daemon/src/lib/log.ts +13 -9
- package/packages/daemon/src/lib/managed-installer-update.ts +79 -0
- package/packages/daemon/src/lib/{tui-download-lock.ts → private-operation-lock.ts} +15 -11
- package/packages/daemon/src/lib/remote-daemon-info.ts +3 -3
- package/packages/daemon/src/lib/remote-daemon-lifecycle.ts +4 -4
- package/packages/daemon/src/lib/remote-tmux-command.ts +12 -0
- package/packages/daemon/src/lib/runtime-namespace.ts +27 -18
- package/packages/daemon/src/lib/saved-machines.ts +40 -1
- package/packages/daemon/src/lib/ssh-daemon-transport.ts +25 -7
- package/packages/daemon/src/lib/tmux-server-owner.ts +1 -0
- package/packages/daemon/src/lib/tmux-server-registration.ts +21 -3
- package/packages/daemon/src/lib/tui-binary.ts +2 -2
- package/packages/daemon/src/lib/update.ts +24 -0
- package/packages/daemon/src/lib/workspace-multiplexer-verbs.ts +22 -0
- package/packages/daemon/src/machines.ts +53 -5
- package/packages/daemon/src/terminal/attachments/direct-websocket.ts +8 -2
- package/packages/daemon/src/terminal/attachments/native-runtime.ts +28 -13
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +105 -3
- package/packages/daemon/src/terminal/mirror/registered-window-guard.ts +66 -0
- package/packages/daemon/src/terminal/mirror/session-channel.ts +92 -4
- package/packages/daemon/src/terminal/mirror/shared-window-index.ts +42 -0
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +12 -1
- package/packages/daemon/src/terminal/session-runtime/multiplexer-backend.ts +2 -0
- package/packages/daemon/src/terminal/session-runtime/registry.ts +70 -8
- package/packages/daemon/src/terminal/session-runtime/semantic-mutation-executor.ts +3 -1
- package/packages/daemon/src/tui/mirror/dialog-model.ts +2 -32
- package/packages/daemon/src/tui/mirror/runtime/application-daemon-authority-owner.ts +8 -0
- package/packages/daemon/src/tui/mirror/runtime/application-home-fleet.ts +19 -2
- package/packages/daemon/src/tui/mirror/runtime/application-machine-agents.ts +6 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-authority.ts +60 -11
- package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +39 -33
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +31 -23
- package/packages/daemon/src/tui/mirror/runtime/application-machine-startup.ts +38 -2
- package/packages/daemon/src/tui/mirror/runtime/application-palette-search-owner.ts +7 -4
- package/packages/daemon/src/tui/mirror/runtime/application-performance-log.ts +3 -0
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +1 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +47 -46
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +165 -2
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +207 -53
- package/packages/daemon/src/tui/mirror/runtime/bounded-performance-record-writer.ts +49 -9
- package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +6 -5
- package/packages/daemon/src/tui/mirror/runtime/terminal-pane-input-router.ts +4 -0
- package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +8 -3
- package/packages/daemon/src/tui/mirror/terminal-text.ts +32 -0
- package/packages/daemon/src/tui/mirror/workspace/connection-feedback.ts +6 -3
- package/packages/daemon-client/src/fleet-connection-status.ts +10 -1
- package/packages/daemon-client/src/workspace-client.ts +4 -1
- package/packages/contracts/src/__tests__/actions-contract.test.ts +0 -119
- package/packages/contracts/src/__tests__/agent-graph-overlay.test.ts +0 -428
- package/packages/contracts/src/__tests__/app-window-mutation.test.ts +0 -51
- package/packages/contracts/src/__tests__/app-window-state.test.ts +0 -34
- package/packages/contracts/src/__tests__/application-shell.test.ts +0 -645
- package/packages/contracts/src/__tests__/automation-operations.test.ts +0 -78
- package/packages/contracts/src/__tests__/causal-cell.test.ts +0 -47
- package/packages/contracts/src/__tests__/client-view-state.test.ts +0 -115
- package/packages/contracts/src/__tests__/cohesion-fixture.test.ts +0 -106
- package/packages/contracts/src/__tests__/commands.test.ts +0 -86
- package/packages/contracts/src/__tests__/control.test.ts +0 -154
- package/packages/contracts/src/__tests__/daemon-events.test.ts +0 -405
- package/packages/contracts/src/__tests__/daemon-resources.test.ts +0 -143
- package/packages/contracts/src/__tests__/daemon-wire.test.ts +0 -149
- package/packages/contracts/src/__tests__/desktop-host.test.ts +0 -269
- package/packages/contracts/src/__tests__/desktop-missions.test.ts +0 -215
- package/packages/contracts/src/__tests__/experience-conformance.test.ts +0 -153
- package/packages/contracts/src/__tests__/experience-kernel-boundary.test.ts +0 -113
- package/packages/contracts/src/__tests__/experience-shell.test.ts +0 -65
- package/packages/contracts/src/__tests__/fixtures/daemon-wire-v2.json +0 -35
- package/packages/contracts/src/__tests__/fixtures/daemon-wire-v3.json +0 -35
- package/packages/contracts/src/__tests__/fixtures/daemon-wire-v4.json +0 -35
- package/packages/contracts/src/__tests__/fixtures/scoped-receipt.ts +0 -62
- package/packages/contracts/src/__tests__/fleet-agent-graph.test.ts +0 -231
- package/packages/contracts/src/__tests__/fleet-catalog.test.ts +0 -270
- package/packages/contracts/src/__tests__/fleet-lifecycle.test.ts +0 -62
- package/packages/contracts/src/__tests__/focus-overlay.test.ts +0 -110
- package/packages/contracts/src/__tests__/ide-config.test.ts +0 -64
- package/packages/contracts/src/__tests__/interaction-evidence.test.ts +0 -288
- package/packages/contracts/src/__tests__/interaction-receipt-v2.test.ts +0 -72
- package/packages/contracts/src/__tests__/issue-error.test.ts +0 -78
- package/packages/contracts/src/__tests__/mission-projections.test.ts +0 -186
- package/packages/contracts/src/__tests__/missions.test.ts +0 -272
- package/packages/contracts/src/__tests__/multiplexer-verbs.test.ts +0 -242
- package/packages/contracts/src/__tests__/pane-appearance.test.ts +0 -146
- package/packages/contracts/src/__tests__/pane-read-result.test.ts +0 -55
- package/packages/contracts/src/__tests__/pane-stream.test.ts +0 -536
- package/packages/contracts/src/__tests__/pane-widget-marker.test.ts +0 -327
- package/packages/contracts/src/__tests__/performance-metrics.test.ts +0 -73
- package/packages/contracts/src/__tests__/performance-qualification.test.ts +0 -137
- package/packages/contracts/src/__tests__/rich-card-widget.test.ts +0 -29
- package/packages/contracts/src/__tests__/session-runtime.test.ts +0 -512
- package/packages/contracts/src/__tests__/startup-readiness.test.ts +0 -409
- package/packages/contracts/src/__tests__/terminal-attachment-stream.test.ts +0 -72
- package/packages/contracts/src/__tests__/terminal-attachments.test.ts +0 -329
- package/packages/contracts/src/__tests__/terminal-delivery.test.ts +0 -88
- package/packages/contracts/src/__tests__/terminal-runtime-inventory.test.ts +0 -68
- package/packages/contracts/src/__tests__/terminals.test.ts +0 -87
- package/packages/contracts/src/__tests__/tmux-server-scope.test.ts +0 -166
- package/packages/contracts/src/__tests__/transport-contract-loading.test.ts +0 -91
- package/packages/contracts/src/__tests__/visual-tokens.test.ts +0 -224
- package/packages/contracts/src/__tests__/window-links.test.ts +0 -243
- package/packages/contracts/src/__tests__/workspace-changes-resource.test.ts +0 -317
- package/packages/contracts/src/__tests__/workspace-changes-view.test.ts +0 -122
- package/packages/contracts/src/__tests__/workspace-config.test.ts +0 -294
- package/packages/contracts/src/__tests__/workspace-files-resource.test.ts +0 -261
- package/packages/contracts/src/__tests__/workspace-files-tree.test.ts +0 -147
- package/packages/contracts/src/__tests__/workspace-pane-creation.test.ts +0 -249
- package/packages/contracts/src/__tests__/workspace-promotion.test.ts +0 -94
- package/packages/contracts/src/__tests__/workspace-resource-identity.test.ts +0 -90
- package/packages/contracts/src/daemon-resource-request.test.ts +0 -192
- package/packages/contracts/src/native-operation-identity.test.ts +0 -23
- package/packages/contracts/src/workspace-admission.test.ts +0 -21
- package/packages/contracts/src/workspace-catalog-resource.test.ts +0 -146
- package/packages/core/src/agent-provisioning.test.ts +0 -42
- package/packages/core/src/application-shell-session.test.ts +0 -105
- package/packages/core/src/interaction-evidence.test.ts +0 -134
- package/packages/core/src/interaction-flow.test.ts +0 -66
- package/packages/core/src/interaction-receipts.test.ts +0 -671
- package/packages/core/src/navigator.test.ts +0 -34
- package/packages/core/src/optimistic-projection-conformance.test.ts +0 -75
- package/packages/core/src/optimistic-projection.test.ts +0 -118
- package/packages/core/src/performance-metrics.test.ts +0 -121
- package/packages/core/src/performance-qualification.test.ts +0 -283
- package/packages/core/src/saved-machines.test.ts +0 -80
- package/packages/core/src/terminal-compact-cold.test.ts +0 -87
- package/packages/core/src/terminal-conformance.test.ts +0 -48
- package/packages/core/src/terminal-delivery-cache-lifetime.test.ts +0 -167
- package/packages/core/src/terminal-delivery-encode-cooperative.test.ts +0 -140
- package/packages/core/src/terminal-delivery-representation-hash.test.ts +0 -77
- package/packages/core/src/terminal-delivery.test.ts +0 -1471
- package/packages/core/src/terminal-fnv64-wasm.test.ts +0 -61
- package/packages/core/src/terminal-replica-cold.test.ts +0 -31
- package/packages/core/src/terminal-replica-cooperative.test.ts +0 -170
- package/packages/core/src/terminal-replica-hash-cache.test.ts +0 -283
- package/packages/core/src/terminal-replica.test.ts +0 -347
- package/packages/core/src/workspace-selection.test.ts +0 -63
- package/packages/daemon/dist/__tests__/support.js +0 -33
- package/packages/daemon/dist/lib/__tests__/fixtures/workspace-state-writer.js +0 -58
- package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +0 -400
- package/packages/daemon/dist/terminal/__tests__/MockPtyAdapter.js +0 -182
- package/packages/daemon/dist/terminal/mirror/__tests__/scripted-channel.js +0 -98
- package/packages/daemon/dist/terminal/mirror/__tests__/simulated-channel.js +0 -156
- package/packages/daemon/src/tui/mirror/__snapshots__/activity-surface-renderer.test.tsx.snap +0 -42
- package/packages/daemon/src/tui/mirror/__snapshots__/changes-terminal-surface-renderer.test.tsx.snap +0 -267
- package/packages/daemon/src/tui/mirror/__snapshots__/home-files-surface-renderer.test.tsx.snap +0 -267
- package/packages/daemon/src/tui/mirror/__snapshots__/missions-surface-renderer.test.tsx.snap +0 -134
- package/packages/daemon/src/tui/mirror/__snapshots__/recipes-gallery-renderer.test.tsx.snap +0 -141
- package/packages/daemon/src/tui/mirror/__snapshots__/shell-chrome-renderer.test.tsx.snap +0 -134
- package/packages/daemon/src/tui/mirror/runtime/__snapshots__/application-shell-view-renderer.test.tsx.snap +0 -87
- package/packages/daemon/src/tui/mirror/workspace/__snapshots__/agent-terminal-canvas-renderer.test.tsx.snap +0 -348
- package/packages/daemon/src/tui/mirror/workspace/__snapshots__/application-shell-renderer.test.tsx.snap +0 -134
- package/packages/daemon/src/tui/mirror/workspace/__snapshots__/command-palette-surface-renderer.test.tsx.snap +0 -255
- package/packages/daemon/src/tui/mirror/workspace/__snapshots__/pane-frame-renderer.test.tsx.snap +0 -525
- package/packages/daemon/src/tui/mirror/workspace/__snapshots__/terminal-pane-header-polish-renderer.test.tsx.snap +0 -31
- package/packages/daemon/src/tui/mirror/workspace/__snapshots__/workbench-shell-renderer.test.tsx.snap +0 -134
- package/packages/daemon-client/src/application-shell-session.test.ts +0 -196
- package/packages/daemon-client/src/automation-client.test.ts +0 -214
- package/packages/daemon-client/src/bootstrap-coordinator.test.ts +0 -145
- package/packages/daemon-client/src/connection-supervisor.test.ts +0 -109
- package/packages/daemon-client/src/direct-application-shell-transport.test.ts +0 -941
- package/packages/daemon-client/src/first-latest-coordinator.test.ts +0 -175
- package/packages/daemon-client/src/fleet-dial-scheduler.test.ts +0 -114
- package/packages/daemon-client/src/fleet-environments.test.ts +0 -40
- package/packages/daemon-client/src/generation-bound-store-timers.test.ts +0 -74
- package/packages/daemon-client/src/owner-action-client.test.ts +0 -245
- package/packages/daemon-client/src/pane-stream-client.test.ts +0 -2841
- package/packages/daemon-client/src/pane-stream-clock-calibration.test.ts +0 -207
- package/packages/daemon-client/src/pane-stream-loopback-benchmark.test.ts +0 -51
- package/packages/daemon-client/src/push-resource-session.test.ts +0 -509
- package/packages/daemon-client/src/resource-replica.test.ts +0 -186
- package/packages/daemon-client/src/runtime-resource-ledger.test.ts +0 -18
- package/packages/daemon-client/src/scoped-tmux-server-transport.test.ts +0 -114
- package/packages/daemon-client/src/terminal-fast-lane.test.ts +0 -835
- package/packages/daemon-client/src/tmux-server-client.test.ts +0 -279
- package/packages/daemon-client/src/tmux-server-interaction-events.test.ts +0 -230
- package/packages/daemon-client/src/workspace-catalog-v2.test.ts +0 -109
- package/packages/daemon-client/src/workspace-client-boundaries.test.ts +0 -59
- package/packages/daemon-client/src/workspace-client-operations.test.ts +0 -96
- package/packages/daemon-client/src/workspace-client.test.ts +0 -2912
- package/packages/daemon-client/src/workspace-event-supervisor.test.ts +0 -1086
- package/packages/daemon-client/src/workspace-pane-client.test.ts +0 -80
- package/packages/presentation/src/presentation-contract.test.ts +0 -63
- package/packages/tmux-bridge/src/runner.test.ts +0 -614
- package/packages/tmux-bridge/src/targeting.test.ts +0 -107
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { accessSync, constants, openSync, closeSync, fstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, rmSync, realpathSync, linkSync, } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { getCanonicalDaemonInfoPath, inspectCanonicalDaemonInfo, probeCanonicalDaemonIdentity, releaseCanonicalDaemonSupervision, reserveCanonicalDaemonSupervision, } from "./canonical-daemon.js";
|
|
7
|
+
import { createDaemonServiceManager } from "./daemon-service-manager.js";
|
|
8
|
+
import { planDaemonService } from "./daemon-service-plan.js";
|
|
9
|
+
import { resolveRuntimeNamespace } from "./runtime-namespace.js";
|
|
10
|
+
import { IdeError } from "./errors.js";
|
|
11
|
+
import { acquirePrivateOperationLock } from "./private-operation-lock.js";
|
|
12
|
+
const RecordSchema = z.strictObject({
|
|
13
|
+
version: z.literal(1),
|
|
14
|
+
removing: z.boolean().optional(),
|
|
15
|
+
executable: z.string().min(1),
|
|
16
|
+
contents: z
|
|
17
|
+
.string()
|
|
18
|
+
.min(1)
|
|
19
|
+
.max(64 * 1024),
|
|
20
|
+
});
|
|
21
|
+
function defaults() {
|
|
22
|
+
const namespace = resolveRuntimeNamespace();
|
|
23
|
+
return {
|
|
24
|
+
plan: (executable) => planDaemonService({
|
|
25
|
+
platform: process.platform,
|
|
26
|
+
uid: process.getuid?.() ?? 0,
|
|
27
|
+
home: homedir(),
|
|
28
|
+
configHome: process.env.XDG_CONFIG_HOME || undefined,
|
|
29
|
+
executable,
|
|
30
|
+
path: process.env.PATH || "/usr/bin:/bin",
|
|
31
|
+
namespace,
|
|
32
|
+
}),
|
|
33
|
+
manager: createDaemonServiceManager,
|
|
34
|
+
inspect: inspectCanonicalDaemonInfo,
|
|
35
|
+
reserve: reserveCanonicalDaemonSupervision,
|
|
36
|
+
release: releaseCanonicalDaemonSupervision,
|
|
37
|
+
probe: probeCanonicalDaemonIdentity,
|
|
38
|
+
recordPath: join(dirname(getCanonicalDaemonInfoPath()), "service.json"),
|
|
39
|
+
waitMs: 30_000,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function refused(message) {
|
|
43
|
+
throw new IdeError(message, { code: "DAEMON_SERVICE_REFUSED" });
|
|
44
|
+
}
|
|
45
|
+
function ownedFile(path) {
|
|
46
|
+
let descriptor;
|
|
47
|
+
try {
|
|
48
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
49
|
+
const stat = fstatSync(descriptor);
|
|
50
|
+
if (!stat.isFile() ||
|
|
51
|
+
stat.uid !== process.getuid?.() ||
|
|
52
|
+
(stat.mode & 0o022) !== 0 ||
|
|
53
|
+
stat.size > 128 * 1024)
|
|
54
|
+
refused(`Service file is not a bounded, user-owned regular file: ${path}`);
|
|
55
|
+
return readFileSync(descriptor, "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code === "ENOENT")
|
|
59
|
+
return null;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
if (descriptor !== undefined)
|
|
64
|
+
closeSync(descriptor);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function boundTo(state, id) {
|
|
68
|
+
return state.status === "reserved"
|
|
69
|
+
? state.reservation.supervisionId === id
|
|
70
|
+
: state.status === "valid" && state.info.supervisionId === id;
|
|
71
|
+
}
|
|
72
|
+
/** Explicit OS supervision. Never starts a detached daemon or signals a guessed PID. */
|
|
73
|
+
export async function manageDaemonService(action, executable, deps = defaults()) {
|
|
74
|
+
const previousRecord = ownedFile(deps.recordPath);
|
|
75
|
+
const record = previousRecord === null ? null : RecordSchema.parse(JSON.parse(previousRecord));
|
|
76
|
+
if (record?.removing && action !== "remove" && action !== "status")
|
|
77
|
+
refused("Service removal is incomplete; finish remove before installing or restarting");
|
|
78
|
+
if (!record && action !== "install") {
|
|
79
|
+
if (action === "status")
|
|
80
|
+
return { status: "not-installed" };
|
|
81
|
+
refused("No managed service is installed in this daemon namespace");
|
|
82
|
+
}
|
|
83
|
+
if (action === "install" && !executable)
|
|
84
|
+
refused("Service installation requires an absolute stable CLI launcher path");
|
|
85
|
+
if (record && executable && record.executable !== executable)
|
|
86
|
+
refused("A different launcher is already registered; remove the stopped service before changing it");
|
|
87
|
+
const plan = deps.plan(record?.executable ?? executable);
|
|
88
|
+
if (plan.recordPath !== deps.recordPath)
|
|
89
|
+
refused("Service namespace changed during resolution");
|
|
90
|
+
const contents = record?.contents ?? plan.contents;
|
|
91
|
+
const manager = deps.manager(plan);
|
|
92
|
+
const assertFiles = (allowMissingUnit = false) => {
|
|
93
|
+
const unit = ownedFile(plan.unitPath);
|
|
94
|
+
if (unit !== contents && !((record?.removing || allowMissingUnit) && unit === null))
|
|
95
|
+
refused("Service definition is missing or modified; refusing to control it");
|
|
96
|
+
if (record && ownedFile(plan.recordPath) !== previousRecord)
|
|
97
|
+
refused("Service ownership record changed");
|
|
98
|
+
};
|
|
99
|
+
const inspectManager = async () => {
|
|
100
|
+
const state = await manager.inspect();
|
|
101
|
+
// systemd reports its generated search-path symlink for units enabled by
|
|
102
|
+
// absolute path. Resolve that link to the owned definition; the definition
|
|
103
|
+
// itself still has to pass our no-follow private-file and content checks.
|
|
104
|
+
if (state.loaded &&
|
|
105
|
+
(!state.definitionPath || realpathSync(state.definitionPath) !== realpathSync(plan.unitPath)))
|
|
106
|
+
refused("The service manager loaded a different definition; refusing to control it");
|
|
107
|
+
return state;
|
|
108
|
+
};
|
|
109
|
+
const ready = async (previousInstance) => {
|
|
110
|
+
const deadline = Date.now() + deps.waitMs;
|
|
111
|
+
do {
|
|
112
|
+
const state = deps.inspect();
|
|
113
|
+
if (!boundTo(state, plan.supervisionId))
|
|
114
|
+
refused("Service reservation changed during startup");
|
|
115
|
+
if (state.status === "valid" && state.info.instanceId !== previousInstance) {
|
|
116
|
+
const identity = await deps.probe(state.info);
|
|
117
|
+
const service = await inspectManager();
|
|
118
|
+
if (identity &&
|
|
119
|
+
service.active &&
|
|
120
|
+
service.pid === state.info.pid &&
|
|
121
|
+
identity.pid === state.info.pid &&
|
|
122
|
+
identity.instanceId === state.info.instanceId &&
|
|
123
|
+
identity.startedAt === state.info.startedAt &&
|
|
124
|
+
identity.protocolVersion === state.info.protocolVersion)
|
|
125
|
+
return {
|
|
126
|
+
status: "running",
|
|
127
|
+
manager: plan.manager,
|
|
128
|
+
target: plan.target,
|
|
129
|
+
pid: state.info.pid,
|
|
130
|
+
instanceId: state.info.instanceId,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
134
|
+
} while (Date.now() < deadline);
|
|
135
|
+
refused("Service did not publish a verified daemon before the deadline; inspect service logs, then retry restart or remove");
|
|
136
|
+
};
|
|
137
|
+
await manager.available();
|
|
138
|
+
const service = await inspectManager();
|
|
139
|
+
const allowMissingUnit = action === "remove" && !service.loaded;
|
|
140
|
+
if (record)
|
|
141
|
+
assertFiles(allowMissingUnit);
|
|
142
|
+
if (action === "status") {
|
|
143
|
+
const state = deps.inspect();
|
|
144
|
+
const identity = service.active && state.status === "valid" && boundTo(state, plan.supervisionId)
|
|
145
|
+
? await deps.probe(state.info)
|
|
146
|
+
: null;
|
|
147
|
+
const verified = identity !== null &&
|
|
148
|
+
state.status === "valid" &&
|
|
149
|
+
service.pid === state.info.pid &&
|
|
150
|
+
identity.pid === state.info.pid &&
|
|
151
|
+
identity.instanceId === state.info.instanceId &&
|
|
152
|
+
identity.startedAt === state.info.startedAt &&
|
|
153
|
+
identity.protocolVersion === state.info.protocolVersion;
|
|
154
|
+
return {
|
|
155
|
+
status: verified
|
|
156
|
+
? "running"
|
|
157
|
+
: service.active
|
|
158
|
+
? "active-unverified"
|
|
159
|
+
: "stopped",
|
|
160
|
+
manager: plan.manager,
|
|
161
|
+
target: plan.target,
|
|
162
|
+
pid: service.pid,
|
|
163
|
+
reservationMatches: boundTo(state, plan.supervisionId),
|
|
164
|
+
unitPath: plan.unitPath,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (!record && (service.loaded || ownedFile(plan.unitPath) !== null))
|
|
168
|
+
refused("A service definition already exists without this namespace's ownership record");
|
|
169
|
+
if (record &&
|
|
170
|
+
!boundTo(deps.inspect(), plan.supervisionId) &&
|
|
171
|
+
!(record.removing && action === "remove" && deps.inspect().status === "missing"))
|
|
172
|
+
refused("Service reservation is missing or belongs to another owner");
|
|
173
|
+
if (!record) {
|
|
174
|
+
accessSync(plan.executable, constants.X_OK);
|
|
175
|
+
// Canonical reservation admission rejects live, uncertain and foreign owners.
|
|
176
|
+
deps.reserve(plan.supervisionId);
|
|
177
|
+
}
|
|
178
|
+
const lock = `${plan.recordPath}.lock`;
|
|
179
|
+
const releaseLock = await acquirePrivateOperationLock(lock, 1000);
|
|
180
|
+
let wroteRecord = false;
|
|
181
|
+
let wroteUnit = false;
|
|
182
|
+
let activationAttempted = false;
|
|
183
|
+
try {
|
|
184
|
+
if (record)
|
|
185
|
+
assertFiles(allowMissingUnit);
|
|
186
|
+
if (action === "install" && !record) {
|
|
187
|
+
writeFileSync(plan.recordPath, JSON.stringify({ version: 1, executable: plan.executable, contents }) + "\n", { flag: "wx", mode: 0o600 });
|
|
188
|
+
wroteRecord = true;
|
|
189
|
+
mkdirSync(dirname(plan.unitPath), { recursive: true, mode: 0o700 });
|
|
190
|
+
const unitStage = `${plan.unitPath}.${randomUUID()}.tmp`;
|
|
191
|
+
writeFileSync(unitStage, contents, { flag: "wx", mode: 0o600 });
|
|
192
|
+
try {
|
|
193
|
+
linkSync(unitStage, plan.unitPath);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
unlinkSync(unitStage);
|
|
197
|
+
}
|
|
198
|
+
wroteUnit = true;
|
|
199
|
+
activationAttempted = true;
|
|
200
|
+
await manager.install();
|
|
201
|
+
return await ready();
|
|
202
|
+
}
|
|
203
|
+
if (action === "remove") {
|
|
204
|
+
if (service.loaded)
|
|
205
|
+
await manager.stop();
|
|
206
|
+
let stopped = await inspectManager();
|
|
207
|
+
const stopDeadline = Date.now() + deps.waitMs;
|
|
208
|
+
while ((stopped.active || stopped.pid !== null) && Date.now() < stopDeadline) {
|
|
209
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
210
|
+
stopped = await inspectManager();
|
|
211
|
+
}
|
|
212
|
+
if (stopped.active || stopped.pid !== null)
|
|
213
|
+
refused("Service manager has not confirmed the daemon stopped; reservation retained");
|
|
214
|
+
assertFiles(allowMissingUnit);
|
|
215
|
+
const removalRecord = JSON.stringify({ ...record, removing: true }) + "\n";
|
|
216
|
+
const temporaryRecord = `${plan.recordPath}.${randomUUID()}.tmp`;
|
|
217
|
+
writeFileSync(temporaryRecord, removalRecord, { flag: "wx", mode: 0o600 });
|
|
218
|
+
try {
|
|
219
|
+
if (ownedFile(plan.recordPath) !== previousRecord)
|
|
220
|
+
refused("Service ownership record changed during removal");
|
|
221
|
+
renameSync(temporaryRecord, plan.recordPath);
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
rmSync(temporaryRecord, { force: true });
|
|
225
|
+
}
|
|
226
|
+
if (ownedFile(plan.unitPath) !== null)
|
|
227
|
+
unlinkSync(plan.unitPath);
|
|
228
|
+
await manager.reload();
|
|
229
|
+
if ((await inspectManager()).loaded)
|
|
230
|
+
refused("Service remains loaded after removal; reservation retained");
|
|
231
|
+
if (deps.inspect().status !== "missing")
|
|
232
|
+
deps.release(plan.supervisionId);
|
|
233
|
+
unlinkSync(plan.recordPath);
|
|
234
|
+
return { status: "removed", target: plan.target };
|
|
235
|
+
}
|
|
236
|
+
const before = deps.inspect();
|
|
237
|
+
if (action === "install" && service.active)
|
|
238
|
+
return await ready();
|
|
239
|
+
if (service.loaded)
|
|
240
|
+
await manager.restart(service.pid !== null);
|
|
241
|
+
else
|
|
242
|
+
await manager.install();
|
|
243
|
+
return await ready(before.status === "valid" ? before.info.instanceId : undefined);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
// Never discard ownership after an uncertain OS activation. The retained
|
|
247
|
+
// record/reservation gives the explicit remove/restart commands a recovery path.
|
|
248
|
+
if (!activationAttempted && wroteRecord) {
|
|
249
|
+
if (wroteUnit && ownedFile(plan.unitPath) === contents)
|
|
250
|
+
unlinkSync(plan.unitPath);
|
|
251
|
+
unlinkSync(plan.recordPath);
|
|
252
|
+
deps.release(plan.supervisionId);
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
releaseLock();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -3,6 +3,8 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
/** Conservative layout evidence; an arbitrary node_modules directory is not a global install. */
|
|
5
5
|
export function detectPackageManager(path) {
|
|
6
|
+
if (/\/share\/tmux-ide\/releases\/install-[^/]+\/npm\/lib\/node_modules\/tmux-ide\/bin$/.test(path))
|
|
7
|
+
return "installer";
|
|
6
8
|
if (/\/(?:Cellar|Caskroom)\//.test(path))
|
|
7
9
|
return "homebrew";
|
|
8
10
|
if (/\/_npx\//.test(path))
|
|
@@ -1,22 +1,28 @@
|
|
|
1
|
-
import { SavedMachineRegistrySchema } from "@tmux-ide/contracts/saved-machines";
|
|
1
|
+
import { SavedMachineRegistrySchema, SavedMachineMutationSchema, } from "@tmux-ide/contracts/saved-machines";
|
|
2
2
|
import { canonicalDaemonUrl, isCanonicalDaemonAlive, readCanonicalDaemonInfo, } from "./canonical-daemon.js";
|
|
3
3
|
/** Intentional local registry mutation. Never uses selected-machine authority. */
|
|
4
4
|
export async function saveMachineProfiles(machines) {
|
|
5
5
|
const registry = SavedMachineRegistrySchema.parse({ version: 1, machines });
|
|
6
|
+
return writeLocalMachineRegistry("POST", { registry });
|
|
7
|
+
}
|
|
8
|
+
export async function mutateMachineProfile(change) {
|
|
9
|
+
return writeLocalMachineRegistry("PATCH", { change: SavedMachineMutationSchema.parse(change) });
|
|
10
|
+
}
|
|
11
|
+
async function writeLocalMachineRegistry(method, payload) {
|
|
6
12
|
const daemon = readCanonicalDaemonInfo();
|
|
7
13
|
if (!daemon?.authToken || !(await isCanonicalDaemonAlive(daemon)))
|
|
8
14
|
throw new Error("Start a local tmux-ide daemon to save machine profiles.");
|
|
9
15
|
try {
|
|
10
16
|
const response = await fetch(canonicalDaemonUrl("http", daemon.bindHostname, daemon.port) +
|
|
11
17
|
"/api/resources/saved-machines", {
|
|
12
|
-
method
|
|
18
|
+
method,
|
|
13
19
|
redirect: "error",
|
|
14
20
|
signal: AbortSignal.timeout(5000),
|
|
15
21
|
headers: {
|
|
16
22
|
Authorization: `Bearer ${daemon.authToken}`,
|
|
17
23
|
"Content-Type": "application/json",
|
|
18
24
|
},
|
|
19
|
-
body: JSON.stringify({ expectedInstanceId: daemon.instanceId,
|
|
25
|
+
body: JSON.stringify({ expectedInstanceId: daemon.instanceId, ...payload }),
|
|
20
26
|
});
|
|
21
27
|
if (!response.ok) {
|
|
22
28
|
await response.body?.cancel();
|
|
@@ -86,17 +86,13 @@ function markStreamFailed(name, error) {
|
|
|
86
86
|
if (streamState[name].failed)
|
|
87
87
|
return;
|
|
88
88
|
streamState[name].failed = true;
|
|
89
|
-
const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
89
|
+
const detail = sanitizeLogMessage(error instanceof Error ? `${error.name}: ${error.message}` : String(error), secrets, budget);
|
|
90
90
|
const warning = `[log.ts] ${name} write failed (${detail}); further records are retained in memory only\n`;
|
|
91
91
|
const other = name === "stdout" ? "stderr" : "stdout";
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
catch {
|
|
97
|
-
streamState[other].failed = true;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
92
|
+
// The fallback can fail asynchronously too. Install the same error handling
|
|
93
|
+
// before writing it; failed flags prevent a warning loop if both streams fail.
|
|
94
|
+
if (!streamState[other].failed)
|
|
95
|
+
writeToStream(other, warning);
|
|
100
96
|
}
|
|
101
97
|
function writeToStream(name, line) {
|
|
102
98
|
const state = streamState[name];
|
|
@@ -150,7 +146,7 @@ function writeStructuredLog(level, component, message, data) {
|
|
|
150
146
|
}
|
|
151
147
|
catch (err) {
|
|
152
148
|
// Avoid recursion via the logger; write directly to stderr.
|
|
153
|
-
writeToStream("stderr", `[log.ts] subscriber threw: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
149
|
+
writeToStream("stderr", `[log.ts] subscriber threw: ${sanitizeLogMessage(err instanceof Error ? err.message : String(err), secrets, budget)}\n`);
|
|
154
150
|
}
|
|
155
151
|
}
|
|
156
152
|
// Stream form for humans tailing the process. The data field is
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
export const MANAGED_INSTALLER_URL = "https://tmux-ide.com/install.sh";
|
|
6
|
+
/** Only the active release of a marked installation may update its prefix. */
|
|
7
|
+
export function managedInstallerPrefix(cliPath) {
|
|
8
|
+
const match = /^(.*)\/share\/tmux-ide\/releases\/(install-[A-Za-z0-9]{6})\/npm\/lib\/node_modules\/tmux-ide\/bin$/.exec(cliPath);
|
|
9
|
+
if (!match)
|
|
10
|
+
return null;
|
|
11
|
+
const prefix = match[1] || "/";
|
|
12
|
+
const root = join(prefix, "share/tmux-ide");
|
|
13
|
+
const release = join(root, "releases", match[2]);
|
|
14
|
+
try {
|
|
15
|
+
if (realpathSync(join(root, "current")) !== release)
|
|
16
|
+
return null;
|
|
17
|
+
if (realpathSync(release) !== release)
|
|
18
|
+
return null;
|
|
19
|
+
if (readFileSync(join(root, "installer-v1"), "utf8") !== "1\n")
|
|
20
|
+
return null;
|
|
21
|
+
if (readFileSync(join(release, ".installer-release-v1"), "utf8") !== "1\n")
|
|
22
|
+
return null;
|
|
23
|
+
if (!readFileSync(join(prefix, "bin/tmux-ide"), "utf8")
|
|
24
|
+
.split("\n")
|
|
25
|
+
.includes("# tmux-ide universal installer v1"))
|
|
26
|
+
return null;
|
|
27
|
+
if (JSON.parse(readFileSync(join(dirname(cliPath), "package.json"), "utf8")).name !== "tmux-ide")
|
|
28
|
+
return null;
|
|
29
|
+
return prefix;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Download completely before execution; the canonical installer owns activation and rollback. */
|
|
36
|
+
export function runManagedInstallerUpdate(prefix, channel, json, execute = execFileSync) {
|
|
37
|
+
const temporary = mkdtempSync(join(tmpdir(), "tmux-ide-update-"));
|
|
38
|
+
const script = join(temporary, "install.sh");
|
|
39
|
+
try {
|
|
40
|
+
execute("curl", [
|
|
41
|
+
"--fail",
|
|
42
|
+
"--location",
|
|
43
|
+
"--silent",
|
|
44
|
+
"--show-error",
|
|
45
|
+
"--proto",
|
|
46
|
+
"=https",
|
|
47
|
+
"--proto-redir",
|
|
48
|
+
"=https",
|
|
49
|
+
"--connect-timeout",
|
|
50
|
+
"15",
|
|
51
|
+
"--max-time",
|
|
52
|
+
"180",
|
|
53
|
+
"--output",
|
|
54
|
+
script,
|
|
55
|
+
MANAGED_INSTALLER_URL,
|
|
56
|
+
], { stdio: ["ignore", 2, 2] });
|
|
57
|
+
if (!readFileSync(script, "utf8").startsWith("#!/bin/sh\n"))
|
|
58
|
+
throw new Error("Installer download was not a shell script; installation unchanged");
|
|
59
|
+
execute("/bin/sh", ["-n", script], { stdio: ["ignore", 2, 2] });
|
|
60
|
+
execute("/bin/sh", [script, "--prefix", prefix, "--version", channel], {
|
|
61
|
+
stdio: json ? ["ignore", 2, 2] : "inherit",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -10,7 +10,7 @@ function trusted(path, directory) {
|
|
|
10
10
|
value.uid !== process.getuid?.() ||
|
|
11
11
|
(value.mode & 0o777) !== (directory ? 0o700 : 0o600) ||
|
|
12
12
|
(!directory && value.nlink !== 1))
|
|
13
|
-
throw new Error("unsafe
|
|
13
|
+
throw new Error("unsafe private operation lock");
|
|
14
14
|
return value;
|
|
15
15
|
}
|
|
16
16
|
function readOwner(path) {
|
|
@@ -19,16 +19,16 @@ function readOwner(path) {
|
|
|
19
19
|
try {
|
|
20
20
|
const actual = fstatSync(fd);
|
|
21
21
|
if (actual.ino !== before.ino || actual.dev !== before.dev || actual.size > 32) {
|
|
22
|
-
throw new Error("unsafe
|
|
22
|
+
throw new Error("unsafe private operation lock");
|
|
23
23
|
}
|
|
24
24
|
const bytes = Buffer.alloc(33);
|
|
25
25
|
const length = readSync(fd, bytes, 0, bytes.length, 0);
|
|
26
26
|
const text = bytes.subarray(0, length).toString("utf8");
|
|
27
27
|
if (!/^[1-9][0-9]*\n$/.test(text))
|
|
28
|
-
throw new Error("invalid
|
|
28
|
+
throw new Error("invalid private operation lock owner");
|
|
29
29
|
const pid = Number(text.trim());
|
|
30
30
|
if (!Number.isSafeInteger(pid) || pid > 2147483647)
|
|
31
|
-
throw new Error("invalid
|
|
31
|
+
throw new Error("invalid private operation lock owner");
|
|
32
32
|
return { pid, ino: actual.ino, dev: actual.dev };
|
|
33
33
|
}
|
|
34
34
|
finally {
|
|
@@ -76,7 +76,7 @@ function cleanStaging(path, name, expected, failed) {
|
|
|
76
76
|
try {
|
|
77
77
|
const current = trusted(path, true);
|
|
78
78
|
if (current.ino !== expected.ino || current.dev !== expected.dev)
|
|
79
|
-
throw new Error("
|
|
79
|
+
throw new Error("private operation staging lock changed");
|
|
80
80
|
try {
|
|
81
81
|
unlinkSync(join(path, name));
|
|
82
82
|
}
|
|
@@ -93,7 +93,7 @@ function cleanStaging(path, name, expected, failed) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
/** Publish a populated private directory atomically; age never proves owner death. */
|
|
96
|
-
export async function
|
|
96
|
+
export async function acquirePrivateOperationLock(lock, waitMs) {
|
|
97
97
|
const deadline = Date.now() + waitMs;
|
|
98
98
|
const nonce = randomUUID();
|
|
99
99
|
const name = `owner-${nonce}`;
|
|
@@ -107,7 +107,7 @@ export async function acquireTuiDownloadLock(lock, waitMs) {
|
|
|
107
107
|
writeFileSync(join(staging, name), `${process.pid}\n`, { flag: "wx", mode: 0o600 });
|
|
108
108
|
while (true) {
|
|
109
109
|
if (attempted && Date.now() >= deadline)
|
|
110
|
-
throw new Error("timed out waiting for another
|
|
110
|
+
throw new Error("timed out waiting for another private operation");
|
|
111
111
|
attempted = true;
|
|
112
112
|
try {
|
|
113
113
|
const existing = lstatSync(lock);
|
|
@@ -152,7 +152,7 @@ export async function acquireTuiDownloadLock(lock, waitMs) {
|
|
|
152
152
|
if (names.length !== 1 || !/^owner-[0-9a-f-]{36}$/.test(names[0])) {
|
|
153
153
|
// Empty is only a retirement gap; the next atomic rename can claim it.
|
|
154
154
|
if (names.length !== 0)
|
|
155
|
-
throw new Error("invalid
|
|
155
|
+
throw new Error("invalid private operation lock inventory");
|
|
156
156
|
}
|
|
157
157
|
else {
|
|
158
158
|
const owner = readOwner(join(lock, names[0]));
|
|
@@ -161,7 +161,7 @@ export async function acquireTuiDownloadLock(lock, waitMs) {
|
|
|
161
161
|
if (current.ino !== owner.ino ||
|
|
162
162
|
current.dev !== owner.dev ||
|
|
163
163
|
current.pid !== owner.pid) {
|
|
164
|
-
throw new Error("
|
|
164
|
+
throw new Error("private operation lock changed");
|
|
165
165
|
}
|
|
166
166
|
retire(lock, names[0], entry);
|
|
167
167
|
}
|
|
@@ -178,7 +178,7 @@ export async function acquireTuiDownloadLock(lock, waitMs) {
|
|
|
178
178
|
if (current.ino !== owner.ino ||
|
|
179
179
|
current.dev !== owner.dev ||
|
|
180
180
|
current.pid !== owner.pid) {
|
|
181
|
-
throw new Error("
|
|
181
|
+
throw new Error("private operation lock changed");
|
|
182
182
|
}
|
|
183
183
|
try {
|
|
184
184
|
unlinkSync(lock);
|
|
@@ -195,7 +195,7 @@ export async function acquireTuiDownloadLock(lock, waitMs) {
|
|
|
195
195
|
throw error;
|
|
196
196
|
}
|
|
197
197
|
if (Date.now() >= deadline)
|
|
198
|
-
throw new Error("timed out waiting for another
|
|
198
|
+
throw new Error("timed out waiting for another private operation");
|
|
199
199
|
await new Promise((resolve) => setTimeout(resolve, Math.min(50, Math.max(0, deadline - Date.now()))));
|
|
200
200
|
}
|
|
201
201
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DAEMON_WIRE_PROTOCOL_VERSION } from "@tmux-ide/contracts";
|
|
1
2
|
import { canonicalDaemonUrl, isCanonicalDaemonAlive, readCanonicalDaemonInfo, } from "./canonical-daemon.js";
|
|
2
3
|
import { probeSshDaemonIdentity, RemoteDaemonHandshakeSchema, RemoteDaemonHandshakeFailureSchema, } from "./ssh-daemon-transport.js";
|
|
3
4
|
class RemoteDaemonInfoError extends Error {
|
|
@@ -13,8 +14,8 @@ export async function readRemoteDaemonHandshake(dependencies = {}) {
|
|
|
13
14
|
if (!info || !(await (dependencies.isAlive ?? isCanonicalDaemonAlive)(info)))
|
|
14
15
|
throw new RemoteDaemonInfoError("No running tmux-ide daemon. Start tmux-ide on this machine first.", "daemon-missing");
|
|
15
16
|
const parsed = RemoteDaemonHandshakeSchema.safeParse({ version: 1, daemon: info });
|
|
16
|
-
if (!parsed.success)
|
|
17
|
-
throw new RemoteDaemonInfoError("The daemon does not support authenticated SSH discovery.", "incompatible");
|
|
17
|
+
if (!parsed.success || info.protocolVersion !== DAEMON_WIRE_PROTOCOL_VERSION)
|
|
18
|
+
throw new RemoteDaemonInfoError("The daemon does not support this authenticated SSH discovery protocol.", "incompatible");
|
|
18
19
|
try {
|
|
19
20
|
if (!(await probeSshDaemonIdentity(canonicalDaemonUrl("http", info.bindHostname, info.port), parsed.data.daemon, AbortSignal.timeout(3_000), dependencies.request ?? fetch)))
|
|
20
21
|
throw new Error("Identity changed");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { SavedMachineSchema } from "@tmux-ide/contracts/saved-machines";
|
|
3
|
+
import { remoteTmuxIdeCommand } from "./remote-tmux-command.js";
|
|
3
4
|
/** Only explicit user actions call this; catalog discovery and retries never do. */
|
|
4
5
|
export async function startInstalledRemoteDaemon(alias, options = {}) {
|
|
5
6
|
SavedMachineSchema.shape.sshTarget.parse(alias);
|
|
@@ -16,12 +17,11 @@ export async function startInstalledRemoteDaemon(alias, options = {}) {
|
|
|
16
17
|
"BatchMode=yes",
|
|
17
18
|
"-o",
|
|
18
19
|
"ConnectTimeout=10",
|
|
20
|
+
"-o",
|
|
21
|
+
"ForkAfterAuthentication=no",
|
|
19
22
|
"--",
|
|
20
23
|
alias,
|
|
21
|
-
"
|
|
22
|
-
"update",
|
|
23
|
-
"--daemon",
|
|
24
|
-
"--json",
|
|
24
|
+
remoteTmuxIdeCommand("start"),
|
|
25
25
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
26
26
|
let settled = false;
|
|
27
27
|
let force;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { shellEscape } from "./shell.js";
|
|
2
|
+
/** Fixed commands only. SSH normally omits interactive shell PATH setup. */
|
|
3
|
+
export function remoteTmuxIdeCommand(operation) {
|
|
4
|
+
const args = operation === "discover" ? "remote-daemon-info --json" : "update --daemon --json";
|
|
5
|
+
// Respect the host's existing installation preference, then try the installer
|
|
6
|
+
// and common package-manager prefixes. Never source shell profiles.
|
|
7
|
+
const script = 'export PATH="${PATH:-/usr/bin:/bin}:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/home/linuxbrew/.linuxbrew/bin:$HOME/.npm-global/bin"; ' +
|
|
8
|
+
`exec tmux-ide ${args}`;
|
|
9
|
+
return `/bin/sh -c ${shellEscape(script)}`;
|
|
10
|
+
}
|
|
@@ -37,35 +37,36 @@ function absolutePath(value, cwd, key) {
|
|
|
37
37
|
throw new TypeError(`${key} must resolve to an absolute path`);
|
|
38
38
|
return path;
|
|
39
39
|
}
|
|
40
|
-
function
|
|
40
|
+
function pathEntryStat(path) {
|
|
41
41
|
try {
|
|
42
|
-
lstatSync(path);
|
|
43
|
-
return true;
|
|
42
|
+
return lstatSync(path);
|
|
44
43
|
}
|
|
45
44
|
catch (error) {
|
|
46
45
|
if (error.code === "ENOENT")
|
|
47
|
-
return
|
|
46
|
+
return undefined;
|
|
48
47
|
throw error;
|
|
49
48
|
}
|
|
50
49
|
}
|
|
51
50
|
function pathIdentity(path) {
|
|
52
51
|
let cursor = resolve(path);
|
|
53
52
|
const suffix = [];
|
|
54
|
-
|
|
53
|
+
let stat = pathEntryStat(cursor);
|
|
54
|
+
while (!stat) {
|
|
55
55
|
const parent = dirname(cursor);
|
|
56
56
|
if (parent === cursor)
|
|
57
57
|
break;
|
|
58
58
|
suffix.unshift(basename(cursor));
|
|
59
59
|
cursor = parent;
|
|
60
|
+
stat = pathEntryStat(cursor);
|
|
60
61
|
}
|
|
61
|
-
if (
|
|
62
|
+
if (stat?.isSocket()) {
|
|
62
63
|
return resolve(captureUnixSocketIdentity(cursor).path, ...suffix);
|
|
63
64
|
}
|
|
64
|
-
return resolve(
|
|
65
|
+
return resolve(stat ? realpathSync(cursor) : cursor, ...suffix);
|
|
65
66
|
}
|
|
66
|
-
function isInsideOrEqual(path, parent) {
|
|
67
|
-
const child =
|
|
68
|
-
const root =
|
|
67
|
+
function isInsideOrEqual(path, parent, identity = pathIdentity) {
|
|
68
|
+
const child = identity(path);
|
|
69
|
+
const root = identity(parent);
|
|
69
70
|
const offset = relative(root, child);
|
|
70
71
|
return offset === "" || (!offset.startsWith(`..${sep}`) && offset !== "..");
|
|
71
72
|
}
|
|
@@ -77,6 +78,18 @@ function runtimeMode(env) {
|
|
|
77
78
|
return raw;
|
|
78
79
|
}
|
|
79
80
|
export function resolveRuntimeNamespace(options = {}) {
|
|
81
|
+
// Reuse a path's identity only within this synchronous resolution. Every
|
|
82
|
+
// subsequent resolution must see new ancestors and replaced symlinks.
|
|
83
|
+
let identities;
|
|
84
|
+
const identity = (path) => {
|
|
85
|
+
const cached = identities?.get(path);
|
|
86
|
+
if (cached !== undefined)
|
|
87
|
+
return cached;
|
|
88
|
+
const resolved = pathIdentity(path);
|
|
89
|
+
(identities ??= new Map()).set(path, resolved);
|
|
90
|
+
return resolved;
|
|
91
|
+
};
|
|
92
|
+
const within = (path, parent) => isInsideOrEqual(path, parent, identity);
|
|
80
93
|
const env = options.env ?? process.env;
|
|
81
94
|
const userHome = options.userHome ?? homedir();
|
|
82
95
|
const cwd = options.cwd ?? process.cwd();
|
|
@@ -141,14 +154,13 @@ export function resolveRuntimeNamespace(options = {}) {
|
|
|
141
154
|
if (isolated && tmuxSocket.kind === "name" && tmuxSocket.name === "default") {
|
|
142
155
|
throw new TypeError(`${mode} runtime requires a non-default ${TMUX_SOCKET_NAME_ENV}`);
|
|
143
156
|
}
|
|
144
|
-
if (isolated &&
|
|
157
|
+
if (isolated && within(stateHome, canonicalHome)) {
|
|
145
158
|
throw new TypeError(`${mode} runtime cannot use the canonical tmux-ide state home`);
|
|
146
159
|
}
|
|
147
|
-
if (isolated &&
|
|
148
|
-
(isInsideOrEqual(registryDir, canonicalHome) || isInsideOrEqual(daemonInfoDir, canonicalHome))) {
|
|
160
|
+
if (isolated && (within(registryDir, canonicalHome) || within(daemonInfoDir, canonicalHome))) {
|
|
149
161
|
throw new TypeError(`${mode} runtime cannot use canonical registry or daemon state`);
|
|
150
162
|
}
|
|
151
|
-
if (isolated && tmuxSocket.kind === "path" &&
|
|
163
|
+
if (isolated && tmuxSocket.kind === "path" && within(tmuxSocket.path, canonicalHome)) {
|
|
152
164
|
throw new TypeError(`${mode} runtime cannot use a tmux socket inside canonical state`);
|
|
153
165
|
}
|
|
154
166
|
if (isolated && cleanupToken === null) {
|
|
@@ -161,7 +173,7 @@ export function resolveRuntimeNamespace(options = {}) {
|
|
|
161
173
|
const value = (key === "TMUX_IDE_CONFIG" || key === "TMUX_IDE_SETTINGS_DIR"
|
|
162
174
|
? env[key]
|
|
163
175
|
: nonEmpty(env, key)) ?? fallback;
|
|
164
|
-
if (development && (!isAbsolute(value) || !
|
|
176
|
+
if (development && (!isAbsolute(value) || !within(value, development.root)))
|
|
165
177
|
throw new TypeError(`development ${key} escapes instance`);
|
|
166
178
|
return value;
|
|
167
179
|
};
|
|
@@ -191,7 +203,7 @@ export function resolveRuntimeNamespace(options = {}) {
|
|
|
191
203
|
scoped(key, "");
|
|
192
204
|
if (env.TMUX) {
|
|
193
205
|
const socket = /^(.*),[0-9]+,[0-9]+$/u.exec(env.TMUX)?.[1];
|
|
194
|
-
if (!socket ||
|
|
206
|
+
if (!socket || identity(socket) !== identity(join(runtimeDir, "tmux.sock")))
|
|
195
207
|
throw new TypeError("development rejects inherited foreign TMUX authority");
|
|
196
208
|
}
|
|
197
209
|
}
|