@moondeck/station 0.0.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/0031_first_turn_receipts.sql +9 -0
- package/db/migrations/meta/_journal.json +230 -0
- package/db/schema.ts +871 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1470 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +937 -0
- package/lib/env.ts +195 -0
- package/lib/first-turn-receipts.ts +62 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +115 -8
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/bridge-diagnostics.ts +39 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2585 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/enrollment-next-steps.ts +24 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2907 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6363 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5550 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
- package/bin/moondeck-station.mjs +0 -3
package/src/cli.ts
ADDED
|
@@ -0,0 +1,2585 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* `uai-host` CLI (ADR-028). Hand-rolled argv switch — no argparser dependency.
|
|
4
|
+
*
|
|
5
|
+
* run | install | uninstall | start | stop | restart
|
|
6
|
+
* status | logs [--follow] | runtime recheck
|
|
7
|
+
* enroll <token> | pair <token> | open
|
|
8
|
+
*
|
|
9
|
+
* `run` boots the service in-process; the install/start/... family dispatches
|
|
10
|
+
* to the per-OS installer (scripts/install/{darwin,linux,win}.ts) by
|
|
11
|
+
* process.platform. `status`/`open` read the local UI's own JSON API.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// MUST be first: load-env populates process.env from .env.local before any
|
|
15
|
+
// import below pulls in lib/env, which freezes its config snapshot (uaiHome,
|
|
16
|
+
// dataDir, hostKeyPath, …) at module-eval. `./paths` imports lib/env, so
|
|
17
|
+
// without this the `run` path boots before UAI_DATA_DIR/UAI_HOME are loaded and
|
|
18
|
+
// resolves the data dir — and the master key that decrypts every secret store
|
|
19
|
+
// — from the wrong base. (main.ts loads it too, but `await import("./main")`
|
|
20
|
+
// runs far too late.)
|
|
21
|
+
import "./load-env";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
spawn,
|
|
25
|
+
spawnSync,
|
|
26
|
+
type SpawnSyncReturns,
|
|
27
|
+
} from "node:child_process";
|
|
28
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
29
|
+
import Database from "better-sqlite3";
|
|
30
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { homedir, hostname } from "node:os";
|
|
32
|
+
import { dirname, join, resolve } from "node:path";
|
|
33
|
+
|
|
34
|
+
import { parse as parseDotenv } from "dotenv";
|
|
35
|
+
import { ulid } from "ulid";
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
commitPendingEnrollment,
|
|
39
|
+
discardPendingEnrollment,
|
|
40
|
+
enrollmentTokenSha256,
|
|
41
|
+
ensurePendingEnrollment,
|
|
42
|
+
pendingEnrollmentMatches,
|
|
43
|
+
pendingEnrollmentPath,
|
|
44
|
+
removeCommittedPendingEnrollment,
|
|
45
|
+
writeEnvValuesAtomic,
|
|
46
|
+
type PendingEnrollment,
|
|
47
|
+
} from "../lib/enrollment-state";
|
|
48
|
+
import { parseHostCloudEndpoint } from "../lib/host-cloud-url";
|
|
49
|
+
import { env } from "../lib/env";
|
|
50
|
+
import {
|
|
51
|
+
persistTelemetryDisclosure,
|
|
52
|
+
telemetryNoticeIfActive,
|
|
53
|
+
} from "../lib/obs";
|
|
54
|
+
import {
|
|
55
|
+
abortManagedRuntimeInstall,
|
|
56
|
+
acknowledgeManagedRuntimeActivation,
|
|
57
|
+
acknowledgeManagedRuntimeActivationWithCapabilityFloor,
|
|
58
|
+
beginManagedRuntimeInstall,
|
|
59
|
+
completeManagedRuntimeInstall,
|
|
60
|
+
installManagedRuntimeArchive,
|
|
61
|
+
managedRuntimeInstallStartupState,
|
|
62
|
+
managedRuntimePaths,
|
|
63
|
+
nativeReleasePlatform,
|
|
64
|
+
reconcileManagedRuntimeActivationWithCapabilityFloor,
|
|
65
|
+
reconcileManagedRuntimeInstallLifecycle,
|
|
66
|
+
reconcileManagedRuntimeUninstall,
|
|
67
|
+
removeManagedRuntime,
|
|
68
|
+
resolveBundledRuntimeFromEnvironment,
|
|
69
|
+
rollbackManagedRuntime,
|
|
70
|
+
runManagedRuntimeInstallIntentMutation,
|
|
71
|
+
runManagedRuntimeInstallLifecycleMutation,
|
|
72
|
+
stopManagedRuntimeServiceForInstall,
|
|
73
|
+
updateManagedRuntime,
|
|
74
|
+
validateManagedRuntimeSelection,
|
|
75
|
+
validateProvisionalManagedRuntimeSelection,
|
|
76
|
+
MANAGED_UPDATE_RESTART_EXIT_CODE,
|
|
77
|
+
type ManagedServiceDefinitionState,
|
|
78
|
+
type ManagedServiceManagerState,
|
|
79
|
+
type ReconcileManagedRuntimeInstallLifecycleOptions,
|
|
80
|
+
} from "../lib/managed-runtime";
|
|
81
|
+
import { PROJECT_ENV_BINDINGS_RUNTIME_CAPABILITY } from "../lib/project-env-feature";
|
|
82
|
+
import { teardownAppleRuntimeStateForUninstall } from "../lib/apple-uninstall-teardown";
|
|
83
|
+
import {
|
|
84
|
+
readRuntimeProviderState,
|
|
85
|
+
runtimeProviderStatePath,
|
|
86
|
+
} from "../lib/runtime-provider-state";
|
|
87
|
+
import { ManagedActivationWatcher } from "../lib/managed-activation-watcher";
|
|
88
|
+
import { ManagedInstallOwnerWatcher } from "../lib/managed-install-owner-watcher";
|
|
89
|
+
import { ManagedUpdateScheduler } from "../lib/managed-update-scheduler";
|
|
90
|
+
import { PINNED_RELEASE_MANIFEST_KEYS } from "../lib/release-trust";
|
|
91
|
+
import { sidecarMode } from "../lib/sidecar";
|
|
92
|
+
import { systemUserBusProbe } from "../lib/user-bus-env";
|
|
93
|
+
import { LEGACY_HOST, STATION, STATION_DEFAULT_CLOUD_URL } from "./station-identity";
|
|
94
|
+
import { enrollmentNextSteps } from "./enrollment-next-steps";
|
|
95
|
+
import {
|
|
96
|
+
inspectLegacyHost,
|
|
97
|
+
legacyHostRunningReason,
|
|
98
|
+
packageNameOfCommand,
|
|
99
|
+
uninstallLegacyHost,
|
|
100
|
+
type LegacyHostSeams,
|
|
101
|
+
} from "../lib/legacy-host";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `run`/`install`/`start` refused because the old host runs here: EX_CONFIG.
|
|
105
|
+
* Never 3, which a sidecar exits with when its lease is denied (main.ts) and
|
|
106
|
+
* its unit treats as "stay down" (RestartPreventExitStatus=3).
|
|
107
|
+
*/
|
|
108
|
+
const LEGACY_HOST_RUNNING_EXIT_CODE = 78;
|
|
109
|
+
import {
|
|
110
|
+
hasActiveHostTasks,
|
|
111
|
+
hasInFlightHostTaskLifecycle,
|
|
112
|
+
hasNonTerminalHostTasks,
|
|
113
|
+
} from "../lib/runtime-state";
|
|
114
|
+
import {
|
|
115
|
+
ENROLLMENT_REPLAY_PROTOCOL_HEADER,
|
|
116
|
+
ENROLLMENT_REPLAY_PROTOCOL_VERSION,
|
|
117
|
+
} from "./protocol";
|
|
118
|
+
import {
|
|
119
|
+
envLocalPath,
|
|
120
|
+
packageVersion,
|
|
121
|
+
readPersistedHostId,
|
|
122
|
+
serviceLogPath,
|
|
123
|
+
uaiHome,
|
|
124
|
+
uiPortFilePath,
|
|
125
|
+
} from "./paths";
|
|
126
|
+
import {
|
|
127
|
+
CloudResponse,
|
|
128
|
+
ContainerRuntimeResponse,
|
|
129
|
+
StatusResponse,
|
|
130
|
+
TasksResponse,
|
|
131
|
+
} from "./ui/types";
|
|
132
|
+
import type { InstallContext, Installer } from "../scripts/install/types";
|
|
133
|
+
import { PAYLOAD_AMI_REGIONS } from "../lib/machine-provider-aws";
|
|
134
|
+
|
|
135
|
+
// --- tiny ANSI (no chalk) ---------------------------------------------------
|
|
136
|
+
|
|
137
|
+
const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
138
|
+
const paint =
|
|
139
|
+
(code: number) =>
|
|
140
|
+
(s: string): string =>
|
|
141
|
+
useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
142
|
+
const bold = paint(1);
|
|
143
|
+
const dim = paint(2);
|
|
144
|
+
const red = paint(31);
|
|
145
|
+
const green = paint(32);
|
|
146
|
+
const yellow = paint(33);
|
|
147
|
+
const cyan = paint(36);
|
|
148
|
+
|
|
149
|
+
// --- dispatch ---------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
async function main(): Promise<void> {
|
|
152
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
153
|
+
const dryRun = rest.includes("--dry-run");
|
|
154
|
+
const follow = rest.includes("--follow") || rest.includes("-f");
|
|
155
|
+
switch (cmd) {
|
|
156
|
+
case "run":
|
|
157
|
+
return cmdRun(rest);
|
|
158
|
+
case "status":
|
|
159
|
+
return cmdStatus();
|
|
160
|
+
case "open":
|
|
161
|
+
return cmdOpen();
|
|
162
|
+
case "logs":
|
|
163
|
+
return cmdLogs(follow);
|
|
164
|
+
case "runtime":
|
|
165
|
+
return cmdRuntime(rest);
|
|
166
|
+
case "tune":
|
|
167
|
+
return cmdTune();
|
|
168
|
+
case "aws":
|
|
169
|
+
if (rest[0] === "setup") return cmdAwsSetup(rest.slice(1));
|
|
170
|
+
console.error(red("usage: moondeck-station aws setup [--region <region>] [--rotate]"));
|
|
171
|
+
process.exitCode = 1;
|
|
172
|
+
return;
|
|
173
|
+
case "update":
|
|
174
|
+
return cmdUpdate(rest);
|
|
175
|
+
case "rollback":
|
|
176
|
+
return cmdRollback(rest);
|
|
177
|
+
case "enrollment":
|
|
178
|
+
return cmdEnrollmentStatus(rest);
|
|
179
|
+
case "setup":
|
|
180
|
+
return cmdSetup(rest);
|
|
181
|
+
case "enroll":
|
|
182
|
+
return cmdEnroll(rest);
|
|
183
|
+
case "pair":
|
|
184
|
+
return cmdPair(rest[0]);
|
|
185
|
+
case "install":
|
|
186
|
+
if (!rest.includes("--dry-run") && (await refuseWhileLegacyHostRuns())) return;
|
|
187
|
+
return cmdInstall(rest);
|
|
188
|
+
case "start":
|
|
189
|
+
case "restart":
|
|
190
|
+
if (!dryRun && (await refuseWhileLegacyHostRuns())) return;
|
|
191
|
+
return cmdInstaller(cmd, dryRun);
|
|
192
|
+
case "stop":
|
|
193
|
+
return cmdInstaller(cmd, dryRun);
|
|
194
|
+
case "uninstall-legacy":
|
|
195
|
+
return cmdUninstallLegacy(rest);
|
|
196
|
+
case "uninstall":
|
|
197
|
+
return cmdUninstall(rest);
|
|
198
|
+
case "version":
|
|
199
|
+
case "-v":
|
|
200
|
+
case "--version":
|
|
201
|
+
// A bare version line: the sidecar's prepare step compares it with the
|
|
202
|
+
// pinned runner version, and any other flag prints the usage instead.
|
|
203
|
+
console.log(packageVersion());
|
|
204
|
+
return;
|
|
205
|
+
case undefined:
|
|
206
|
+
case "help":
|
|
207
|
+
case "-h":
|
|
208
|
+
case "--help":
|
|
209
|
+
return printHelp();
|
|
210
|
+
default:
|
|
211
|
+
console.error(red(`unknown command: ${cmd}`));
|
|
212
|
+
printHelp();
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- run --------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
function timestampServiceLogs(): void {
|
|
220
|
+
const stamp = (): string => new Date().toISOString().slice(0, 19);
|
|
221
|
+
for (const level of ["log", "warn", "error"] as const) {
|
|
222
|
+
const original = console[level].bind(console);
|
|
223
|
+
console[level] = (...args: unknown[]) => original(`[${stamp()}]`, ...args);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function cmdRun(rest: string[] = []): Promise<void> {
|
|
228
|
+
// ADR-133 sidecar mode: `uai-host run --task <id>` on the task's own
|
|
229
|
+
// machine. The flag becomes the environment the rest of the process asks
|
|
230
|
+
// (lib/sidecar.ts) — set before main.ts loads, since its boot reads it.
|
|
231
|
+
const sidecarTask = flagValue(rest, "--task");
|
|
232
|
+
if (sidecarTask) process.env.UAI_SIDECAR_TASK = sidecarTask;
|
|
233
|
+
// ADR-136 sign-in box: `uai-host run --signin-box` on the small always-on
|
|
234
|
+
// machine behind the "Uai Cloud" row. Same package, one flag
|
|
235
|
+
// (lib/signin-box.ts); set before main.ts loads for the same reason.
|
|
236
|
+
if (rest.includes("--signin-box")) process.env.UAI_SIGNIN_BOX = "1";
|
|
237
|
+
// A station never runs beside the old host (plan §4). Task-machine and
|
|
238
|
+
// warm-pool sidecars (UAI_SIDECAR_POOL) and the sign-in box run on cloud
|
|
239
|
+
// machines, never beside one.
|
|
240
|
+
if (!sidecarMode() && !rest.includes("--signin-box") && (await refuseWhileLegacyHostRuns())) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// Timestamped service logs: the ADR-106 live smoke burned an hour dating
|
|
244
|
+
// undated scrollback. Applies to the service process only, not one-shot CLI
|
|
245
|
+
// commands.
|
|
246
|
+
timestampServiceLogs();
|
|
247
|
+
let installState = await managedRuntimeInstallStartupState();
|
|
248
|
+
if (installState === "attested") {
|
|
249
|
+
// Attestation is the durable commit decision. A supervisor restart after
|
|
250
|
+
// that point finishes the service/command journals; it must never enter the
|
|
251
|
+
// pre-attestation recovery gate or restore the old installation.
|
|
252
|
+
await reconcileInstallLifecycle(await loadInstaller());
|
|
253
|
+
installState = "none";
|
|
254
|
+
}
|
|
255
|
+
if (installState === "stale") {
|
|
256
|
+
const outcome = await waitForManagedInstaller();
|
|
257
|
+
if (outcome === "stopped") return;
|
|
258
|
+
if (outcome === "restart") {
|
|
259
|
+
process.exitCode = MANAGED_UPDATE_RESTART_EXIT_CODE;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
installState = "live";
|
|
263
|
+
}
|
|
264
|
+
const runningVersion = packageVersion();
|
|
265
|
+
const managedPaths = managedRuntimePaths();
|
|
266
|
+
const provisionalInstall =
|
|
267
|
+
installState === "live" || installState === "unknown";
|
|
268
|
+
if (
|
|
269
|
+
existsSync(managedPaths.runtime) &&
|
|
270
|
+
!(provisionalInstall
|
|
271
|
+
? await validateProvisionalManagedRuntimeSelection(runningVersion)
|
|
272
|
+
: await validateManagedRuntimeSelection(runningVersion))
|
|
273
|
+
) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`managed runtime activation is still in progress; refusing to start stale runtime ${runningVersion}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
// Boot the service in-process: main.ts's top-level code starts the WSS
|
|
279
|
+
// client + local UI server and keeps the event loop alive.
|
|
280
|
+
const host = await import("./main");
|
|
281
|
+
// Close the none→intent race while the comparatively expensive service
|
|
282
|
+
// module imported. This is one lock-free marker read, not a steady-state
|
|
283
|
+
// poller; only a provisional install arms the recurring watcher.
|
|
284
|
+
try {
|
|
285
|
+
installState = await managedRuntimeInstallStartupState();
|
|
286
|
+
} catch (error) {
|
|
287
|
+
host.requestManagedHostRestart();
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
if (installState === "stale" || installState === "attested") {
|
|
291
|
+
host.requestManagedHostRestart();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (installState === "live" || installState === "unknown") {
|
|
295
|
+
watchManagedInstaller(host.requestManagedHostRestart);
|
|
296
|
+
}
|
|
297
|
+
if (
|
|
298
|
+
existsSync(managedPaths.runtime) &&
|
|
299
|
+
installState !== "live" &&
|
|
300
|
+
installState !== "unknown"
|
|
301
|
+
) {
|
|
302
|
+
const activationWasPending = existsSync(managedPaths.activationPending);
|
|
303
|
+
try {
|
|
304
|
+
const acknowledged = activationWasPending
|
|
305
|
+
? await acknowledgeManagedRuntimeActivationWithCapabilityFloor(
|
|
306
|
+
runningVersion,
|
|
307
|
+
PROJECT_ENV_BINDINGS_RUNTIME_CAPABILITY,
|
|
308
|
+
host.completeProjectEnvBindingsAfterManagedRuntimeFloor,
|
|
309
|
+
)
|
|
310
|
+
: await acknowledgeManagedRuntimeActivation(runningVersion);
|
|
311
|
+
if (!acknowledged) {
|
|
312
|
+
// Selection changed while the service module was loading. Its task
|
|
313
|
+
// admission remains closed by the marker; drain and let the supervisor
|
|
314
|
+
// start the newly selected stable command.
|
|
315
|
+
host.requestManagedHostRestart();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (activationWasPending) {
|
|
319
|
+
host.advertiseProjectEnvBindingsAfterManagedRuntimeActivation();
|
|
320
|
+
console.log(
|
|
321
|
+
`[host-agent] managed runtime ${runningVersion} activated (pending activation acknowledged)`,
|
|
322
|
+
);
|
|
323
|
+
await logLaunchdJobAfterActivation();
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
// A malformed or concurrently changed marker must not leave an imported
|
|
327
|
+
// service running indefinitely. Admission is still closed, so request a
|
|
328
|
+
// graceful stop before surfacing the settlement failure.
|
|
329
|
+
host.requestManagedHostRestart();
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
let localManagedUpdateRunning = false;
|
|
334
|
+
if (existsSync(managedPaths.current)) {
|
|
335
|
+
new ManagedActivationWatcher({
|
|
336
|
+
restartRequired: async () => {
|
|
337
|
+
if (localManagedUpdateRunning) return false;
|
|
338
|
+
let capabilityCompleted = false;
|
|
339
|
+
const restartRequired =
|
|
340
|
+
await reconcileManagedRuntimeActivationWithCapabilityFloor(
|
|
341
|
+
runningVersion,
|
|
342
|
+
PROJECT_ENV_BINDINGS_RUNTIME_CAPABILITY,
|
|
343
|
+
() => {
|
|
344
|
+
host.completeProjectEnvBindingsAfterManagedRuntimeFloor();
|
|
345
|
+
capabilityCompleted = true;
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
if (capabilityCompleted) {
|
|
349
|
+
host.advertiseProjectEnvBindingsAfterManagedRuntimeActivation();
|
|
350
|
+
}
|
|
351
|
+
return restartRequired;
|
|
352
|
+
},
|
|
353
|
+
isIdle: host.isIdleForManagedHostUpdate,
|
|
354
|
+
onReady: host.requestManagedHostRestart,
|
|
355
|
+
onError: (error) =>
|
|
356
|
+
console.warn(
|
|
357
|
+
`[host-agent] managed runtime activation check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
358
|
+
),
|
|
359
|
+
}).start();
|
|
360
|
+
}
|
|
361
|
+
if (managedAutoupdateEnabled()) {
|
|
362
|
+
new ManagedUpdateScheduler({
|
|
363
|
+
isIdle: host.isIdleForManagedHostUpdate,
|
|
364
|
+
check: async () => {
|
|
365
|
+
localManagedUpdateRunning = true;
|
|
366
|
+
try {
|
|
367
|
+
const result = await updateManagedRuntime({
|
|
368
|
+
canActivate: host.isIdleForManagedHostUpdate,
|
|
369
|
+
});
|
|
370
|
+
if (result.status === "deferred") return "busy";
|
|
371
|
+
return result.status;
|
|
372
|
+
} finally {
|
|
373
|
+
localManagedUpdateRunning = false;
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
onUpdated: host.requestManagedHostRestart,
|
|
377
|
+
onError: (error) =>
|
|
378
|
+
console.warn(
|
|
379
|
+
`[host-agent] managed runtime update check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
380
|
+
),
|
|
381
|
+
}).start();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The boot after an activation is the moment a launchd respawn problem is
|
|
387
|
+
* visible — `last exit code = 75: EX_TEMPFAIL` with a `pended nondemand
|
|
388
|
+
* spawn` is what the 2026-09-06 stalls looked like in `launchctl print`, and
|
|
389
|
+
* nothing in the service log said so. Read-only, macOS, and only when launchd
|
|
390
|
+
* actually spawned this process (pid 1 is its parent): a terminal `uai-host
|
|
391
|
+
* run`, the desktop supervisor, or a test spawning the CLI never runs
|
|
392
|
+
* `launchctl` here.
|
|
393
|
+
*/
|
|
394
|
+
async function logLaunchdJobAfterActivation(): Promise<void> {
|
|
395
|
+
if (process.platform !== "darwin" || process.ppid !== 1) return;
|
|
396
|
+
try {
|
|
397
|
+
const { describeLaunchdJob, formatLaunchdJobState, launchdJobTarget } =
|
|
398
|
+
await import("../scripts/install/darwin");
|
|
399
|
+
console.log(
|
|
400
|
+
`[host-agent] launchd job ${launchdJobTarget()} after activation: ${formatLaunchdJobState(describeLaunchdJob())}`,
|
|
401
|
+
);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
console.warn(
|
|
404
|
+
`[host-agent] could not read the launchd job state after activation: ${
|
|
405
|
+
error instanceof Error ? error.message : String(error)
|
|
406
|
+
}`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function waitForManagedInstaller(): Promise<"live" | "restart" | "stopped"> {
|
|
412
|
+
console.error(
|
|
413
|
+
red(
|
|
414
|
+
"managed host installation was interrupted; rerun the installer to resume safely",
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
return new Promise((resolve) => {
|
|
418
|
+
let stopped = false;
|
|
419
|
+
let timer: NodeJS.Timeout | undefined;
|
|
420
|
+
const finish = (state: "live" | "restart" | "stopped") => {
|
|
421
|
+
if (stopped) return;
|
|
422
|
+
stopped = true;
|
|
423
|
+
if (timer) clearTimeout(timer);
|
|
424
|
+
process.off("SIGTERM", onSignal);
|
|
425
|
+
process.off("SIGINT", onSignal);
|
|
426
|
+
resolve(state);
|
|
427
|
+
};
|
|
428
|
+
const onSignal = () => finish("stopped");
|
|
429
|
+
const poll = async () => {
|
|
430
|
+
try {
|
|
431
|
+
const state = await managedRuntimeInstallStartupState();
|
|
432
|
+
if (state === "live") {
|
|
433
|
+
finish("live");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (state === "none" || state === "attested") {
|
|
437
|
+
// Another CLI settled the stale transaction. Exit nonzero so a
|
|
438
|
+
// Restart=on-failure supervisor launches a fresh process that can
|
|
439
|
+
// either boot normally or finish the attested commit before import.
|
|
440
|
+
finish("restart");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
} catch (error) {
|
|
444
|
+
console.error(
|
|
445
|
+
red(
|
|
446
|
+
`managed host install gate could not be checked: ${
|
|
447
|
+
error instanceof Error ? error.message : String(error)
|
|
448
|
+
}`,
|
|
449
|
+
),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (!stopped) timer = setTimeout(poll, 1_000);
|
|
453
|
+
};
|
|
454
|
+
process.once("SIGTERM", onSignal);
|
|
455
|
+
process.once("SIGINT", onSignal);
|
|
456
|
+
timer = setTimeout(poll, 1_000);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function watchManagedInstaller(onStale: () => void): void {
|
|
461
|
+
new ManagedInstallOwnerWatcher({
|
|
462
|
+
check: () => managedRuntimeInstallStartupState(),
|
|
463
|
+
onRestart: (state) => {
|
|
464
|
+
console.error(
|
|
465
|
+
red(
|
|
466
|
+
state === "attested"
|
|
467
|
+
? "managed host install attested; draining so the supervisor can finalize it"
|
|
468
|
+
: "managed host installer exited before attestation; draining into the recovery gate",
|
|
469
|
+
),
|
|
470
|
+
);
|
|
471
|
+
onStale();
|
|
472
|
+
},
|
|
473
|
+
onUnsafe: (error) => {
|
|
474
|
+
console.error(
|
|
475
|
+
red(
|
|
476
|
+
`managed host install intent became unreadable; draining safely: ${
|
|
477
|
+
error instanceof Error ? error.message : String(error)
|
|
478
|
+
}`,
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
onStale();
|
|
482
|
+
},
|
|
483
|
+
}).start();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// --- status -----------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
async function cmdStatus(): Promise<void> {
|
|
489
|
+
const port = readPort();
|
|
490
|
+
if (port == null) {
|
|
491
|
+
console.log(
|
|
492
|
+
dim("service not running (no host.port). Start it: moondeck-station start (or: moondeck-station run)"),
|
|
493
|
+
);
|
|
494
|
+
process.exitCode = 1;
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
let status, cloud, tasks;
|
|
498
|
+
try {
|
|
499
|
+
const [rawStatus, rawCloud, rawTasks] = await Promise.all([
|
|
500
|
+
api(port, "/api/status"),
|
|
501
|
+
api(port, "/api/cloud"),
|
|
502
|
+
api(port, "/api/tasks"),
|
|
503
|
+
]);
|
|
504
|
+
status = StatusResponse.parse(rawStatus);
|
|
505
|
+
cloud = CloudResponse.parse(rawCloud);
|
|
506
|
+
tasks = TasksResponse.parse(rawTasks);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
console.log(dim(localApiFailure(port, err)));
|
|
509
|
+
process.exitCode = 1;
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const stateColor =
|
|
514
|
+
cloud.state === "connected"
|
|
515
|
+
? green
|
|
516
|
+
: cloud.state === "reconnecting"
|
|
517
|
+
? yellow
|
|
518
|
+
: red;
|
|
519
|
+
console.log(bold("Moondeck Station") + dim(` — ${status.hostName} · v${status.version}`));
|
|
520
|
+
console.log(
|
|
521
|
+
` cloud ${stateColor(cloud.state)} ${dim(status.cloudUrl)}` +
|
|
522
|
+
(cloud.reconnectCount ? dim(` (reconnects: ${cloud.reconnectCount})`) : ""),
|
|
523
|
+
);
|
|
524
|
+
if (cloud.lastError) console.log(` ${dim("last error: " + cloud.lastError)}`);
|
|
525
|
+
console.log(` service ${dim(`pid ${status.pid} · up ${fmtDuration(status.uptime * 1000)}`)}`);
|
|
526
|
+
// Only worth a line when it's the surprising answer. A mock host looks
|
|
527
|
+
// identical to a real one everywhere else — connected, tasks running, output
|
|
528
|
+
// streaming — so this is the only place it can be caught before the work is.
|
|
529
|
+
if (status.agentMode === "mock") {
|
|
530
|
+
console.log(` agents ${yellow("mock")} ${dim("— echo only, no real CLI runs")}`);
|
|
531
|
+
console.log(` ${dim("unset UAI_AGENTS in " + envLocalPath() + ", then: moondeck-station restart")}`);
|
|
532
|
+
}
|
|
533
|
+
if (status.containerRuntime) {
|
|
534
|
+
const runtime = status.containerRuntime;
|
|
535
|
+
const label =
|
|
536
|
+
runtime.status === "ready"
|
|
537
|
+
? green(runtime.provider)
|
|
538
|
+
: runtime.status === "checking"
|
|
539
|
+
? yellow("checking")
|
|
540
|
+
: red("unavailable");
|
|
541
|
+
console.log(` runtime ${label} ${dim(`(${runtime.preference})`)}`);
|
|
542
|
+
if (runtime.status === "checking" && runtime.detail) {
|
|
543
|
+
console.log(` ${dim(runtime.detail)}`);
|
|
544
|
+
}
|
|
545
|
+
if (runtime.status === "no-runtime") {
|
|
546
|
+
console.log(` ${dim(runtime.message)}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
console.log(` ui ${dim(`http://127.0.0.1:${port}`)}`);
|
|
550
|
+
console.log(` log ${dim(status.logPath)}`);
|
|
551
|
+
console.log(` tasks ${tasks.tasks.length === 0 ? dim("none") : tasks.tasks.length}`);
|
|
552
|
+
for (const t of tasks.tasks) {
|
|
553
|
+
const slugs = t.projectSlugs.length ? t.projectSlugs.join(",") : "scratchpad";
|
|
554
|
+
const age = t.ageMs != null ? fmtDuration(t.ageMs) : "—";
|
|
555
|
+
const mem = t.memoryBytes != null ? fmtBytes(t.memoryBytes) : "—";
|
|
556
|
+
console.log(
|
|
557
|
+
` ${cyan((t.ownerEmail ?? "unknown").padEnd(22))} ${dim(slugs.padEnd(16))} ` +
|
|
558
|
+
`${(t.status ?? "—").padEnd(9)} ${dim(`up ${age}`.padEnd(11))} ${dim(mem)}`,
|
|
559
|
+
);
|
|
560
|
+
console.log(` ${dim(t.taskId)}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** What `uai-host status` gives each local endpoint. A healthy runner
|
|
565
|
+
* answers in well under a second; one second was too tight once
|
|
566
|
+
* `/api/tasks` waited on `docker stats` (2026-10-30). */
|
|
567
|
+
const LOCAL_API_TIMEOUT_MS = 5_000;
|
|
568
|
+
|
|
569
|
+
/** A local endpoint that did not answer in time: the service is there. */
|
|
570
|
+
class LocalApiTimeout extends Error {}
|
|
571
|
+
/** Nothing accepted the connection: the service is not there. */
|
|
572
|
+
class LocalApiUnreachable extends Error {}
|
|
573
|
+
|
|
574
|
+
async function api(port: number, path: string): Promise<unknown> {
|
|
575
|
+
try {
|
|
576
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
577
|
+
signal: AbortSignal.timeout(LOCAL_API_TIMEOUT_MS),
|
|
578
|
+
});
|
|
579
|
+
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
580
|
+
return (await res.json()) as unknown;
|
|
581
|
+
} catch (err) {
|
|
582
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
583
|
+
throw new LocalApiTimeout(
|
|
584
|
+
`${path} did not answer within ${String(LOCAL_API_TIMEOUT_MS / 1000)}s`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
const code = (err as { cause?: { code?: unknown } } | null)?.cause?.code;
|
|
588
|
+
if (code === "ECONNREFUSED") {
|
|
589
|
+
throw new LocalApiUnreachable(`${path}: connection refused`);
|
|
590
|
+
}
|
|
591
|
+
throw err;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** One line for a status read that failed: no service, a slow one, or one
|
|
596
|
+
* that answered wrong are three different things to the operator. */
|
|
597
|
+
function localApiFailure(port: number, err: unknown): string {
|
|
598
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
599
|
+
if (err instanceof LocalApiUnreachable) {
|
|
600
|
+
return `service unreachable on 127.0.0.1:${port} (${detail}). Start it: moondeck-station start`;
|
|
601
|
+
}
|
|
602
|
+
if (err instanceof LocalApiTimeout) {
|
|
603
|
+
return `service on 127.0.0.1:${port} is up but slow to answer (${detail}); try again, or check its log`;
|
|
604
|
+
}
|
|
605
|
+
return `service on 127.0.0.1:${port} answered unexpectedly (${detail})`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function boundedResponseJson(
|
|
609
|
+
response: Response,
|
|
610
|
+
maxBytes = 16 * 1024,
|
|
611
|
+
): Promise<unknown> {
|
|
612
|
+
const declared = response.headers.get("content-length");
|
|
613
|
+
if (declared !== null) {
|
|
614
|
+
if (!/^(?:0|[1-9]\d*)$/.test(declared) || Number(declared) > maxBytes) {
|
|
615
|
+
throw new Error("cloud response exceeds its byte limit");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (!response.body) throw new Error("cloud response has no body");
|
|
619
|
+
const reader = response.body.getReader();
|
|
620
|
+
const chunks: Uint8Array[] = [];
|
|
621
|
+
let total = 0;
|
|
622
|
+
try {
|
|
623
|
+
for (;;) {
|
|
624
|
+
const { done, value } = await reader.read();
|
|
625
|
+
if (done) break;
|
|
626
|
+
total += value.byteLength;
|
|
627
|
+
if (total > maxBytes) {
|
|
628
|
+
await reader.cancel("cloud response exceeds its byte limit");
|
|
629
|
+
throw new Error("cloud response exceeds its byte limit");
|
|
630
|
+
}
|
|
631
|
+
chunks.push(value);
|
|
632
|
+
}
|
|
633
|
+
} finally {
|
|
634
|
+
reader.releaseLock();
|
|
635
|
+
}
|
|
636
|
+
const bytes = new Uint8Array(total);
|
|
637
|
+
let offset = 0;
|
|
638
|
+
for (const chunk of chunks) {
|
|
639
|
+
bytes.set(chunk, offset);
|
|
640
|
+
offset += chunk.byteLength;
|
|
641
|
+
}
|
|
642
|
+
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// --- runtime ---------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
// --- aws setup (ADR-125 BYO-AWS: mint the host's scoped AWS identity) --------
|
|
648
|
+
|
|
649
|
+
/** Mirrors scripts/machine-ami/byo-aws-quickcreate.yaml — keep in sync. */
|
|
650
|
+
const AWS_MACHINE_POLICY = {
|
|
651
|
+
Version: "2012-10-17",
|
|
652
|
+
Statement: [
|
|
653
|
+
{
|
|
654
|
+
Sid: "UaiMachines",
|
|
655
|
+
Effect: "Allow",
|
|
656
|
+
Action: [
|
|
657
|
+
"ec2:RunInstances",
|
|
658
|
+
"ec2:StartInstances",
|
|
659
|
+
"ec2:StopInstances",
|
|
660
|
+
"ec2:TerminateInstances",
|
|
661
|
+
"ec2:CreateTags",
|
|
662
|
+
"ec2:DescribeInstances",
|
|
663
|
+
"ec2:DescribeImages",
|
|
664
|
+
"ec2:DescribeSubnets",
|
|
665
|
+
"ec2:DescribeVpcs",
|
|
666
|
+
"ec2:DescribeSecurityGroups",
|
|
667
|
+
"ec2:CreateSecurityGroup",
|
|
668
|
+
"ec2:AuthorizeSecurityGroupIngress",
|
|
669
|
+
"ec2:CreateSnapshot",
|
|
670
|
+
"ec2:DeleteSnapshot",
|
|
671
|
+
"ec2:CreateVolume",
|
|
672
|
+
"ec2:DescribeSnapshots",
|
|
673
|
+
"ec2:DescribeVolumes",
|
|
674
|
+
],
|
|
675
|
+
Resource: "*",
|
|
676
|
+
},
|
|
677
|
+
],
|
|
678
|
+
};
|
|
679
|
+
const AWS_SETUP_USER = "uai-host";
|
|
680
|
+
|
|
681
|
+
/** Run the aws CLI with the operator's AMBIENT (admin) credentials: any
|
|
682
|
+
* AWS_* that load-env pulled from .env.local is a previously-minted SCOPED
|
|
683
|
+
* key that cannot administer IAM, and letting it shadow the shell's admin
|
|
684
|
+
* credentials makes every failure read as a permissions mystery. */
|
|
685
|
+
function awsAdmin(
|
|
686
|
+
args: string[],
|
|
687
|
+
region: string | null,
|
|
688
|
+
): SpawnSyncReturns<string> {
|
|
689
|
+
const env = { ...process.env };
|
|
690
|
+
const fromFile = (() => {
|
|
691
|
+
try {
|
|
692
|
+
return parseDotenv(readFileSync(envLocalPath(), "utf8"));
|
|
693
|
+
} catch {
|
|
694
|
+
return {} as Record<string, string>;
|
|
695
|
+
}
|
|
696
|
+
})();
|
|
697
|
+
if (
|
|
698
|
+
env.AWS_ACCESS_KEY_ID &&
|
|
699
|
+
env.AWS_ACCESS_KEY_ID === fromFile.AWS_ACCESS_KEY_ID
|
|
700
|
+
) {
|
|
701
|
+
delete env.AWS_ACCESS_KEY_ID;
|
|
702
|
+
delete env.AWS_SECRET_ACCESS_KEY;
|
|
703
|
+
delete env.AWS_SESSION_TOKEN;
|
|
704
|
+
}
|
|
705
|
+
return spawnSync(
|
|
706
|
+
"aws",
|
|
707
|
+
[...(region ? ["--region", region] : []), "--output", "json", ...args],
|
|
708
|
+
{ encoding: "utf8", env },
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function cmdAwsSetup(rest: string[]): Promise<void> {
|
|
713
|
+
const rotate = rest.includes("--rotate");
|
|
714
|
+
const regionFlagAt = rest.indexOf("--region");
|
|
715
|
+
let region = regionFlagAt >= 0 ? (rest[regionFlagAt + 1] ?? "") : "";
|
|
716
|
+
|
|
717
|
+
const version = spawnSync("aws", ["--version"], { encoding: "utf8" });
|
|
718
|
+
if (version.status !== 0) {
|
|
719
|
+
console.error(red("the aws CLI is not installed (or not on PATH)"));
|
|
720
|
+
console.error("install it: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html");
|
|
721
|
+
process.exitCode = 1;
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if (!region) {
|
|
726
|
+
const configured = spawnSync("aws", ["configure", "get", "region"], {
|
|
727
|
+
encoding: "utf8",
|
|
728
|
+
});
|
|
729
|
+
region = (configured.stdout ?? "").trim();
|
|
730
|
+
}
|
|
731
|
+
if (!region) {
|
|
732
|
+
console.error(red("no AWS region — pass --region <region> (e.g. --region us-west-2)"));
|
|
733
|
+
process.exitCode = 1;
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (!PAYLOAD_AMI_REGIONS.includes(region)) {
|
|
737
|
+
console.error(
|
|
738
|
+
red(`task machines are not available in ${region} yet — Moondeck's machine image is published in ${PAYLOAD_AMI_REGIONS.join(", ")}`),
|
|
739
|
+
);
|
|
740
|
+
console.error(dim("pass --region with one of those; more regions are coming."));
|
|
741
|
+
process.exitCode = 1;
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const identity = awsAdmin(["sts", "get-caller-identity"], region);
|
|
746
|
+
if (identity.status !== 0) {
|
|
747
|
+
console.error(red("no working AWS admin credentials in this shell"));
|
|
748
|
+
console.error(
|
|
749
|
+
"sign in first (aws configure, aws sso login, or exported AWS_* vars), then re-run",
|
|
750
|
+
);
|
|
751
|
+
console.error(dim((identity.stderr ?? "").trim().slice(0, 200)));
|
|
752
|
+
process.exitCode = 1;
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const account = (() => {
|
|
756
|
+
try {
|
|
757
|
+
return (JSON.parse(identity.stdout) as { Account?: string }).Account ?? "?";
|
|
758
|
+
} catch {
|
|
759
|
+
return "?";
|
|
760
|
+
}
|
|
761
|
+
})();
|
|
762
|
+
|
|
763
|
+
const existingEnv = (() => {
|
|
764
|
+
try {
|
|
765
|
+
return parseDotenv(readFileSync(envLocalPath(), "utf8"));
|
|
766
|
+
} catch {
|
|
767
|
+
return {} as Record<string, string>;
|
|
768
|
+
}
|
|
769
|
+
})();
|
|
770
|
+
if (existingEnv.AWS_ACCESS_KEY_ID && !rotate) {
|
|
771
|
+
console.log(
|
|
772
|
+
`already configured (key ${existingEnv.AWS_ACCESS_KEY_ID.slice(0, 8)}…, region ${existingEnv.UAI_AWS_REGION ?? "?"})`,
|
|
773
|
+
);
|
|
774
|
+
console.log(`re-run with ${bold("--rotate")} to mint a fresh key`);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
console.log(`account ${account}, region ${region} — creating the ${AWS_SETUP_USER} identity`);
|
|
779
|
+
|
|
780
|
+
const created = awsAdmin(
|
|
781
|
+
["iam", "create-user", "--user-name", AWS_SETUP_USER],
|
|
782
|
+
region,
|
|
783
|
+
);
|
|
784
|
+
if (
|
|
785
|
+
created.status !== 0 &&
|
|
786
|
+
!(created.stderr ?? "").includes("EntityAlreadyExists")
|
|
787
|
+
) {
|
|
788
|
+
console.error(red(`could not create the ${AWS_SETUP_USER} IAM user: ${(created.stderr ?? "").trim().slice(0, 300)}`));
|
|
789
|
+
process.exitCode = 1;
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// put-user-policy overwrites: re-running upgrades the policy in place.
|
|
794
|
+
const policy = awsAdmin(
|
|
795
|
+
[
|
|
796
|
+
"iam",
|
|
797
|
+
"put-user-policy",
|
|
798
|
+
"--user-name",
|
|
799
|
+
AWS_SETUP_USER,
|
|
800
|
+
"--policy-name",
|
|
801
|
+
"uai-machines",
|
|
802
|
+
"--policy-document",
|
|
803
|
+
JSON.stringify(AWS_MACHINE_POLICY),
|
|
804
|
+
],
|
|
805
|
+
region,
|
|
806
|
+
);
|
|
807
|
+
if (policy.status !== 0) {
|
|
808
|
+
console.error(red(`could not attach the machine policy: ${(policy.stderr ?? "").trim().slice(0, 300)}`));
|
|
809
|
+
process.exitCode = 1;
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const keyResult = awsAdmin(
|
|
814
|
+
["iam", "create-access-key", "--user-name", AWS_SETUP_USER],
|
|
815
|
+
region,
|
|
816
|
+
);
|
|
817
|
+
if (keyResult.status !== 0) {
|
|
818
|
+
console.error(red(`could not create an access key: ${(keyResult.stderr ?? "").trim().slice(0, 300)}`));
|
|
819
|
+
if ((keyResult.stderr ?? "").includes("LimitExceeded")) {
|
|
820
|
+
console.error(
|
|
821
|
+
`the user already has two keys — list them with: aws iam list-access-keys --user-name ${AWS_SETUP_USER}\n` +
|
|
822
|
+
`and delete one with: aws iam delete-access-key --user-name ${AWS_SETUP_USER} --access-key-id <id>`,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
process.exitCode = 1;
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
let keyId = "";
|
|
829
|
+
let secret = "";
|
|
830
|
+
try {
|
|
831
|
+
const parsed = JSON.parse(keyResult.stdout) as {
|
|
832
|
+
AccessKey?: { AccessKeyId?: string; SecretAccessKey?: string };
|
|
833
|
+
};
|
|
834
|
+
keyId = parsed.AccessKey?.AccessKeyId ?? "";
|
|
835
|
+
secret = parsed.AccessKey?.SecretAccessKey ?? "";
|
|
836
|
+
} catch {
|
|
837
|
+
// handled below
|
|
838
|
+
}
|
|
839
|
+
if (!keyId || !secret) {
|
|
840
|
+
console.error(red("create-access-key returned no key material"));
|
|
841
|
+
process.exitCode = 1;
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
writeEnvValuesAtomic(envLocalPath(), {
|
|
846
|
+
UAI_MACHINE_TASKS: "1",
|
|
847
|
+
UAI_MACHINE_PROVIDER: "aws",
|
|
848
|
+
UAI_AWS_REGION: region,
|
|
849
|
+
AWS_ACCESS_KEY_ID: keyId,
|
|
850
|
+
AWS_SECRET_ACCESS_KEY: secret,
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
// Rotation hygiene: the key the env file carried before this run belongs
|
|
854
|
+
// to the scoped user; a minted replacement leaves it orphaned AND holding
|
|
855
|
+
// the 2-key limit. Best-effort delete, loudly skippable.
|
|
856
|
+
const previousKey = existingEnv.AWS_ACCESS_KEY_ID;
|
|
857
|
+
if (rotate && previousKey && previousKey !== keyId) {
|
|
858
|
+
const deleted = awsAdmin(
|
|
859
|
+
[
|
|
860
|
+
"iam",
|
|
861
|
+
"delete-access-key",
|
|
862
|
+
"--user-name",
|
|
863
|
+
AWS_SETUP_USER,
|
|
864
|
+
"--access-key-id",
|
|
865
|
+
previousKey,
|
|
866
|
+
],
|
|
867
|
+
region,
|
|
868
|
+
);
|
|
869
|
+
if (deleted.status === 0) {
|
|
870
|
+
console.log(`rotated: deleted the previous key ${previousKey.slice(0, 8)}…`);
|
|
871
|
+
} else {
|
|
872
|
+
console.log(
|
|
873
|
+
yellow(`minted a new key but could not delete ${previousKey.slice(0, 8)}… — remove it manually if unused`),
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
console.log(`${bold("done")} — scoped credentials written to ${envLocalPath()}`);
|
|
879
|
+
console.log(` UAI_MACHINE_PROVIDER=aws UAI_AWS_REGION=${region} (machine tasks enabled)`);
|
|
880
|
+
console.log(`restart the host to activate: ${cyan("moondeck-station restart")}`);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// --- tune (ADR-118 follow-up: vnode/file-table ceilings) ---------------------
|
|
884
|
+
|
|
885
|
+
const TUNE_DAEMON_LABEL = STATION.tuneDaemonLabel;
|
|
886
|
+
const TUNE_DAEMON_PLIST = `/Library/LaunchDaemons/${TUNE_DAEMON_LABEL}.plist`;
|
|
887
|
+
/** Floors, not targets — an operator-raised ceiling is never lowered. Each
|
|
888
|
+
* apple-container task VM pins host vnodes for its life (~12k after ADR-118,
|
|
889
|
+
* far more during dependency churn), and the boot defaults sit low enough
|
|
890
|
+
* that two heavy tasks have filled the table live (2026-08-25). ~2M vnodes
|
|
891
|
+
* is roughly half a GB of kernel memory IF fully populated. */
|
|
892
|
+
const TUNE_MAXVNODES_FLOOR = 2_097_152;
|
|
893
|
+
const TUNE_MAXFILES_FLOOR = 2_097_152;
|
|
894
|
+
const TUNE_MAXFILESPERPROC_FLOOR = 1_048_576;
|
|
895
|
+
|
|
896
|
+
function readSysctlNumber(name: string): number | null {
|
|
897
|
+
const res = spawnSync("/usr/sbin/sysctl", ["-n", name], {
|
|
898
|
+
encoding: "utf8",
|
|
899
|
+
});
|
|
900
|
+
const value = Number((res.stdout ?? "").trim());
|
|
901
|
+
return res.status === 0 && Number.isFinite(value) ? value : null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function tunePlistContent(settings: Array<[string, number]>): string {
|
|
905
|
+
const args = ["/usr/sbin/sysctl", ...settings.map(([k, v]) => `${k}=${v}`)]
|
|
906
|
+
.map((a) => ` <string>${a}</string>`)
|
|
907
|
+
.join("\n");
|
|
908
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
909
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
910
|
+
<plist version="1.0">
|
|
911
|
+
<dict>
|
|
912
|
+
<key>Label</key>
|
|
913
|
+
<string>${TUNE_DAEMON_LABEL}</string>
|
|
914
|
+
<key>ProgramArguments</key>
|
|
915
|
+
<array>
|
|
916
|
+
${args}
|
|
917
|
+
</array>
|
|
918
|
+
<key>RunAtLoad</key>
|
|
919
|
+
<true/>
|
|
920
|
+
</dict>
|
|
921
|
+
</plist>
|
|
922
|
+
`;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function cmdTune(): Promise<void> {
|
|
926
|
+
if (process.platform !== "darwin") {
|
|
927
|
+
console.error(red("tune is macOS-only (apple-container task VMs)"));
|
|
928
|
+
process.exitCode = 1;
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const current = {
|
|
932
|
+
maxvnodes: readSysctlNumber("kern.maxvnodes"),
|
|
933
|
+
maxfiles: readSysctlNumber("kern.maxfiles"),
|
|
934
|
+
maxfilesperproc: readSysctlNumber("kern.maxfilesperproc"),
|
|
935
|
+
};
|
|
936
|
+
if (
|
|
937
|
+
current.maxvnodes === null ||
|
|
938
|
+
current.maxfiles === null ||
|
|
939
|
+
current.maxfilesperproc === null
|
|
940
|
+
) {
|
|
941
|
+
console.error(red("could not read the current kernel limits via sysctl"));
|
|
942
|
+
process.exitCode = 1;
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
// Order matters at apply time: maxfilesperproc must stay <= maxfiles.
|
|
946
|
+
const settings: Array<[string, number]> = [
|
|
947
|
+
["kern.maxfiles", Math.max(current.maxfiles, TUNE_MAXFILES_FLOOR)],
|
|
948
|
+
[
|
|
949
|
+
"kern.maxfilesperproc",
|
|
950
|
+
Math.max(current.maxfilesperproc, TUNE_MAXFILESPERPROC_FLOOR),
|
|
951
|
+
],
|
|
952
|
+
["kern.maxvnodes", Math.max(current.maxvnodes, TUNE_MAXVNODES_FLOOR)],
|
|
953
|
+
];
|
|
954
|
+
const desiredPlist = tunePlistContent(settings);
|
|
955
|
+
const plistCurrent = existsSync(TUNE_DAEMON_PLIST)
|
|
956
|
+
? readFileSync(TUNE_DAEMON_PLIST, "utf8")
|
|
957
|
+
: null;
|
|
958
|
+
const changes = settings.filter(
|
|
959
|
+
([key, value]) => value !== current[key.slice("kern.".length) as keyof typeof current],
|
|
960
|
+
);
|
|
961
|
+
if (changes.length === 0 && plistCurrent === desiredPlist) {
|
|
962
|
+
const summary = settings.map(([k, v]) => `${k}=${v}`).join(" ");
|
|
963
|
+
console.log(`already tuned (${summary}); boot persistence in place`);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
|
|
968
|
+
console.log(`${bold("moondeck-station tune")} will (needs sudo):`);
|
|
969
|
+
for (const [key, value] of changes) {
|
|
970
|
+
console.log(
|
|
971
|
+
` raise ${key}: ${current[key.slice("kern.".length) as keyof typeof current]} -> ${value}`,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
console.log(
|
|
975
|
+
` install ${TUNE_DAEMON_PLIST} so the limits survive reboots`,
|
|
976
|
+
);
|
|
977
|
+
console.log(`\nrun: ${cyan("sudo moondeck-station tune")}`);
|
|
978
|
+
process.exitCode = 1;
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
for (const [key, value] of settings) {
|
|
983
|
+
const res = spawnSync("/usr/sbin/sysctl", [`${key}=${value}`], {
|
|
984
|
+
encoding: "utf8",
|
|
985
|
+
});
|
|
986
|
+
if (res.status !== 0) {
|
|
987
|
+
console.error(
|
|
988
|
+
red(`sysctl ${key}=${value} failed: ${(res.stderr ?? "").trim()}`),
|
|
989
|
+
);
|
|
990
|
+
process.exitCode = 1;
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
writeFileSync(TUNE_DAEMON_PLIST, desiredPlist, { mode: 0o644 });
|
|
995
|
+
spawnSync("/usr/sbin/chown", ["root:wheel", TUNE_DAEMON_PLIST]);
|
|
996
|
+
// Re-bootstrap so launchd owns the daemon under its current definition.
|
|
997
|
+
spawnSync("/bin/launchctl", ["bootout", `system/${TUNE_DAEMON_LABEL}`]);
|
|
998
|
+
const bootstrap = spawnSync(
|
|
999
|
+
"/bin/launchctl",
|
|
1000
|
+
["bootstrap", "system", TUNE_DAEMON_PLIST],
|
|
1001
|
+
{ encoding: "utf8" },
|
|
1002
|
+
);
|
|
1003
|
+
if (bootstrap.status !== 0) {
|
|
1004
|
+
console.error(
|
|
1005
|
+
red(
|
|
1006
|
+
`limits applied, but launchctl bootstrap failed (${(bootstrap.stderr ?? "").trim()}) — they will not survive a reboot`,
|
|
1007
|
+
),
|
|
1008
|
+
);
|
|
1009
|
+
process.exitCode = 1;
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const summary = settings.map(([k, v]) => `${k}=${v}`).join(" ");
|
|
1013
|
+
console.log(`tuned: ${summary} (persisted via ${TUNE_DAEMON_PLIST})`);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
async function cmdRuntime(rest: string[]): Promise<void> {
|
|
1017
|
+
if (rest[0] === "begin-install") {
|
|
1018
|
+
return cmdBeginRuntimeInstall(rest.slice(1));
|
|
1019
|
+
}
|
|
1020
|
+
if (rest[0] === "complete-install") {
|
|
1021
|
+
return cmdCompleteRuntimeInstall(rest.slice(1));
|
|
1022
|
+
}
|
|
1023
|
+
if (rest[0] === "abort-install") {
|
|
1024
|
+
return cmdAbortRuntimeInstall(rest.slice(1));
|
|
1025
|
+
}
|
|
1026
|
+
if (rest[0] === "stop-service") {
|
|
1027
|
+
return cmdStopRuntimeInstallService(rest.slice(1));
|
|
1028
|
+
}
|
|
1029
|
+
if (rest.length === 1 && rest[0] === "reconcile-uninstall") {
|
|
1030
|
+
await reconcileManagedRuntimeUninstall();
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (rest[0] === "install-archive") {
|
|
1034
|
+
return cmdInstallRuntimeArchive(rest.slice(1));
|
|
1035
|
+
}
|
|
1036
|
+
if (rest.length !== 1 || rest[0] !== "recheck") {
|
|
1037
|
+
console.error(
|
|
1038
|
+
red(
|
|
1039
|
+
"usage: moondeck-station runtime recheck\n moondeck-station runtime install-archive --archive <file> --version <x.y.z> --size <bytes> --sha256 <hex>",
|
|
1040
|
+
),
|
|
1041
|
+
);
|
|
1042
|
+
process.exitCode = 1;
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const port = readPort();
|
|
1046
|
+
if (port == null) {
|
|
1047
|
+
console.error(
|
|
1048
|
+
red("service not running — start it before rechecking"),
|
|
1049
|
+
);
|
|
1050
|
+
process.exitCode = 1;
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
try {
|
|
1054
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/runtime/recheck`, {
|
|
1055
|
+
method: "POST",
|
|
1056
|
+
signal: AbortSignal.timeout(30_000),
|
|
1057
|
+
headers: {
|
|
1058
|
+
"x-uai-local-control": "runtime-recheck-v1",
|
|
1059
|
+
},
|
|
1060
|
+
});
|
|
1061
|
+
if (!res.ok) throw new Error(`runtime recheck → ${res.status}`);
|
|
1062
|
+
const runtime = ContainerRuntimeResponse.parse(
|
|
1063
|
+
await boundedResponseJson(res),
|
|
1064
|
+
);
|
|
1065
|
+
if (runtime.status === "ready") {
|
|
1066
|
+
console.log(
|
|
1067
|
+
green("container runtime ready") + dim(` — ${runtime.provider}`),
|
|
1068
|
+
);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (runtime.status === "checking") {
|
|
1072
|
+
console.log(
|
|
1073
|
+
yellow("container runtime detected") +
|
|
1074
|
+
dim(" — preparing images and recovering tasks; check status shortly"),
|
|
1075
|
+
);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
console.error(red(runtime.message ?? "container runtime unavailable"));
|
|
1079
|
+
process.exitCode = 1;
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
console.error(
|
|
1082
|
+
red(err instanceof Error ? err.message : "runtime recheck failed"),
|
|
1083
|
+
);
|
|
1084
|
+
process.exitCode = 1;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
async function cmdBeginRuntimeInstall(rest: string[]): Promise<void> {
|
|
1089
|
+
if (rest.length !== 0) {
|
|
1090
|
+
throw new Error("usage: moondeck-station runtime begin-install");
|
|
1091
|
+
}
|
|
1092
|
+
const installer = await loadInstaller();
|
|
1093
|
+
const result = await beginManagedRuntimeInstall({
|
|
1094
|
+
// The installer invokes this command directly with output redirection. Its
|
|
1095
|
+
// parent is therefore the long-lived compound shell, not a short-lived
|
|
1096
|
+
// command-substitution process. PID start identity protects against reuse.
|
|
1097
|
+
ownerPid: process.ppid,
|
|
1098
|
+
reconcileStandaloneDefinition: () => reconcileDefinitionInstall(installer),
|
|
1099
|
+
definitionState: (managedCommand) =>
|
|
1100
|
+
serviceDefinitionState(installer, managedCommand),
|
|
1101
|
+
prepareRecovery: (installIntent) =>
|
|
1102
|
+
prepareDefinitionInstallRollback(installer, installIntent),
|
|
1103
|
+
resumeRecovery: (installIntent) =>
|
|
1104
|
+
resumeDefinitionInstallRollback(installer, installIntent),
|
|
1105
|
+
resumeStoppedService: (installIntent, priorManager) =>
|
|
1106
|
+
resumeServiceForInstall(installer, installIntent, priorManager),
|
|
1107
|
+
beforeFinalize: (installIntent) =>
|
|
1108
|
+
completeDefinitionInstall(installer, installIntent),
|
|
1109
|
+
});
|
|
1110
|
+
console.log(
|
|
1111
|
+
[
|
|
1112
|
+
result.nonce,
|
|
1113
|
+
result.legacyShim ? "legacy" : "none",
|
|
1114
|
+
result.serviceOwnership,
|
|
1115
|
+
result.serviceQuiescence,
|
|
1116
|
+
result.serviceManager.loaded ? "loaded" : "unloaded",
|
|
1117
|
+
result.serviceManager.active ? "active" : "inactive",
|
|
1118
|
+
result.serviceManager.enabled ? "enabled" : "disabled",
|
|
1119
|
+
].join(" "),
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async function cmdStopRuntimeInstallService(rest: string[]): Promise<void> {
|
|
1124
|
+
const installIntent = exactFlagValue(rest, "--install-intent");
|
|
1125
|
+
if (!installIntent) {
|
|
1126
|
+
throw new Error(
|
|
1127
|
+
"usage: moondeck-station runtime stop-service --install-intent <nonce>",
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
const installer = await loadInstaller();
|
|
1131
|
+
await stopManagedRuntimeServiceForInstall({
|
|
1132
|
+
installIntent,
|
|
1133
|
+
stopService: (intent, priorManager) =>
|
|
1134
|
+
stopServiceForInstall(installer, intent, priorManager),
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function cmdCompleteRuntimeInstall(rest: string[]): Promise<void> {
|
|
1139
|
+
const installIntent = exactFlagValue(rest, "--install-intent");
|
|
1140
|
+
if (!installIntent) {
|
|
1141
|
+
throw new Error(
|
|
1142
|
+
"usage: moondeck-station runtime complete-install --install-intent <nonce>",
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
const installer = await loadInstaller();
|
|
1146
|
+
await completeManagedRuntimeInstall({
|
|
1147
|
+
installIntent,
|
|
1148
|
+
beforeFinalize: (intent) => completeDefinitionInstall(installer, intent),
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function cmdAbortRuntimeInstall(rest: string[]): Promise<void> {
|
|
1153
|
+
const installIntent = exactFlagValue(rest, "--install-intent");
|
|
1154
|
+
if (!installIntent) {
|
|
1155
|
+
throw new Error(
|
|
1156
|
+
"usage: moondeck-station runtime abort-install --install-intent <nonce>",
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
const installer = await loadInstaller();
|
|
1160
|
+
await abortManagedRuntimeInstall({
|
|
1161
|
+
installIntent,
|
|
1162
|
+
prepareRestore: (intent) =>
|
|
1163
|
+
prepareDefinitionInstallRollback(installer, intent),
|
|
1164
|
+
resumeRestore: (intent) =>
|
|
1165
|
+
resumeDefinitionInstallRollback(installer, intent),
|
|
1166
|
+
resumeStoppedService: (intent, priorManager) =>
|
|
1167
|
+
resumeServiceForInstall(installer, intent, priorManager),
|
|
1168
|
+
beforeFinalize: (intent) => completeDefinitionInstall(installer, intent),
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
async function cmdInstallRuntimeArchive(rest: string[]): Promise<void> {
|
|
1173
|
+
const parsed = parseRuntimeInstallArgs(rest);
|
|
1174
|
+
if (!parsed) {
|
|
1175
|
+
console.error(
|
|
1176
|
+
red(
|
|
1177
|
+
"usage: moondeck-station runtime install-archive --archive <file> --version <x.y.z> --size <bytes> --sha256 <hex>",
|
|
1178
|
+
),
|
|
1179
|
+
);
|
|
1180
|
+
process.exitCode = 1;
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
const platform = nativeReleasePlatform();
|
|
1184
|
+
const result = await installManagedRuntimeArchive({
|
|
1185
|
+
archivePath: parsed.archive,
|
|
1186
|
+
artifact: {
|
|
1187
|
+
// Bootstrap authenticated the tuple in the cloud-rendered script. The
|
|
1188
|
+
// local installer consumes only size/hash; update URLs come from the
|
|
1189
|
+
// separately verified signed manifest.
|
|
1190
|
+
url: "bootstrap-authenticated-artifact",
|
|
1191
|
+
size: parsed.size,
|
|
1192
|
+
sha256: parsed.sha256,
|
|
1193
|
+
},
|
|
1194
|
+
version: parsed.version,
|
|
1195
|
+
platform,
|
|
1196
|
+
allowDowngrade: parsed.allowDowngrade,
|
|
1197
|
+
// Legacy conversion takes over the old service's live tasks, so it needs
|
|
1198
|
+
// every task idle. A managed version update restarts only our own
|
|
1199
|
+
// service, which running tasks survive (durable sessions) — it drains
|
|
1200
|
+
// only in-flight lifecycle mutations.
|
|
1201
|
+
canActivate: (context) =>
|
|
1202
|
+
context.legacyConversion
|
|
1203
|
+
? !hasActiveHostTasks()
|
|
1204
|
+
: !hasInFlightHostTaskLifecycle(),
|
|
1205
|
+
releaseRollbackUpdateHold: true,
|
|
1206
|
+
installIntent: parsed.installIntent,
|
|
1207
|
+
});
|
|
1208
|
+
if (result.deferred) {
|
|
1209
|
+
console.error(red("runtime activation deferred because host tasks are active"));
|
|
1210
|
+
process.exitCode = 1;
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
console.log(
|
|
1214
|
+
green(result.changed ? `installed host runtime ${result.version}` : `host runtime ${result.version} is already installed`),
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
interface RuntimeInstallArgs {
|
|
1219
|
+
archive: string;
|
|
1220
|
+
version: string;
|
|
1221
|
+
size: number;
|
|
1222
|
+
sha256: string;
|
|
1223
|
+
allowDowngrade: boolean;
|
|
1224
|
+
installIntent?: string;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function parseRuntimeInstallArgs(rest: string[]): RuntimeInstallArgs | null {
|
|
1228
|
+
const values = new Map<string, string>();
|
|
1229
|
+
let allowDowngrade = false;
|
|
1230
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
1231
|
+
const arg = rest[index]!;
|
|
1232
|
+
if (arg === "--allow-downgrade") {
|
|
1233
|
+
if (allowDowngrade) return null;
|
|
1234
|
+
allowDowngrade = true;
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
if (
|
|
1238
|
+
![
|
|
1239
|
+
"--archive",
|
|
1240
|
+
"--version",
|
|
1241
|
+
"--size",
|
|
1242
|
+
"--sha256",
|
|
1243
|
+
"--install-intent",
|
|
1244
|
+
].includes(arg)
|
|
1245
|
+
) {
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
if (values.has(arg)) return null;
|
|
1249
|
+
const value = rest[index + 1];
|
|
1250
|
+
if (!value || value.startsWith("--")) return null;
|
|
1251
|
+
values.set(arg, value);
|
|
1252
|
+
index += 1;
|
|
1253
|
+
}
|
|
1254
|
+
const archive = values.get("--archive");
|
|
1255
|
+
const version = values.get("--version");
|
|
1256
|
+
const sizeValue = values.get("--size");
|
|
1257
|
+
const sha256 = values.get("--sha256");
|
|
1258
|
+
const installIntent = values.get("--install-intent");
|
|
1259
|
+
if (!archive || !version || !sizeValue || !sha256) return null;
|
|
1260
|
+
if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(version)) {
|
|
1261
|
+
return null;
|
|
1262
|
+
}
|
|
1263
|
+
if (!/^[1-9]\d*$/.test(sizeValue) || !/^[a-f0-9]{64}$/.test(sha256)) {
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
const size = Number(sizeValue);
|
|
1267
|
+
if (!Number.isSafeInteger(size)) return null;
|
|
1268
|
+
if (installIntent && !/^[a-f0-9]{64}$/.test(installIntent)) return null;
|
|
1269
|
+
return { archive, version, size, sha256, allowDowngrade, installIntent };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
async function cmdUpdate(rest: string[]): Promise<void> {
|
|
1273
|
+
if (rest.length !== 0) {
|
|
1274
|
+
console.error(red("usage: moondeck-station update"));
|
|
1275
|
+
process.exitCode = 1;
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
// Running tasks survive the service restart (durable sessions); only a
|
|
1279
|
+
// task mid-lifecycle blocks, and only until it drains.
|
|
1280
|
+
if (hasInFlightHostTaskLifecycle()) {
|
|
1281
|
+
console.error(red("refusing to update while a task is still starting; retry in a moment"));
|
|
1282
|
+
process.exitCode = 1;
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
const result = await updateManagedRuntime({
|
|
1286
|
+
canActivate: () => !hasInFlightHostTaskLifecycle(),
|
|
1287
|
+
releaseRollbackUpdateHold: true,
|
|
1288
|
+
});
|
|
1289
|
+
if (result.status === "not-managed") {
|
|
1290
|
+
console.error(red("this host is not installed as a managed runtime"));
|
|
1291
|
+
process.exitCode = 1;
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
if (result.status === "current") {
|
|
1295
|
+
console.log(green(`host runtime ${result.version} is current`));
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (result.status === "deferred") {
|
|
1299
|
+
console.error(red("host runtime update was deferred because the host became busy"));
|
|
1300
|
+
process.exitCode = 1;
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
console.log(
|
|
1304
|
+
green(`updated host runtime ${result.fromVersion} → ${result.version}`) +
|
|
1305
|
+
dim(" — activation staged; the service will restart after in-flight work drains"),
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
async function cmdRollback(rest: string[]): Promise<void> {
|
|
1310
|
+
if (rest.length !== 0) {
|
|
1311
|
+
console.error(red("usage: moondeck-station rollback"));
|
|
1312
|
+
process.exitCode = 1;
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
if (hasActiveHostTasks()) {
|
|
1316
|
+
console.error(red("refusing to roll back while host tasks are active"));
|
|
1317
|
+
process.exitCode = 1;
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
const idle = () => !hasActiveHostTasks();
|
|
1321
|
+
const result = await rollbackManagedRuntime(undefined, undefined, idle);
|
|
1322
|
+
console.log(
|
|
1323
|
+
green(`rolled back host runtime ${result.fromVersion} → ${result.version}`) +
|
|
1324
|
+
dim(" — activation staged; the service will restart after in-flight work drains"),
|
|
1325
|
+
);
|
|
1326
|
+
console.log(
|
|
1327
|
+
yellow("automatic runtime updates are paused; run `moondeck-station update` to resume them"),
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function cmdEnrollmentStatus(rest: string[]): void {
|
|
1332
|
+
if (rest.length !== 1 || rest[0] !== "status") {
|
|
1333
|
+
console.error(red("usage: moondeck-station enrollment status"));
|
|
1334
|
+
process.exitCode = 2;
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const identity = hostIdentity();
|
|
1338
|
+
if (identity.kind === "complete") {
|
|
1339
|
+
console.log("enrolled");
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (identity.kind === "partial") {
|
|
1343
|
+
console.error(red("incomplete host identity"));
|
|
1344
|
+
process.exitCode = 2;
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
console.log("unenrolled");
|
|
1348
|
+
process.exitCode = 1;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function managedAutoupdateEnabled(): boolean {
|
|
1352
|
+
return (
|
|
1353
|
+
process.env.UAI_HOST_AUTOUPDATE !== "0" &&
|
|
1354
|
+
PINNED_RELEASE_MANIFEST_KEYS.size > 0 &&
|
|
1355
|
+
existsSync(managedRuntimePaths().current)
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// --- open -------------------------------------------------------------------
|
|
1360
|
+
|
|
1361
|
+
async function cmdOpen(): Promise<void> {
|
|
1362
|
+
const port = readPort();
|
|
1363
|
+
if (port == null) {
|
|
1364
|
+
console.error(red("no host.port — is the service running?"));
|
|
1365
|
+
process.exitCode = 1;
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
const url = `http://127.0.0.1:${port}`;
|
|
1369
|
+
if (process.platform === "darwin") detach("open", [url]);
|
|
1370
|
+
else if (process.platform === "win32") detach("cmd", ["/c", "start", "", url]);
|
|
1371
|
+
else detach("xdg-open", [url]);
|
|
1372
|
+
console.log(`opening ${url}`);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function detach(cmd: string, args: string[]): void {
|
|
1376
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
1377
|
+
child.on("error", (err) => console.error(red(`could not run ${cmd}: ${err.message}`)));
|
|
1378
|
+
child.unref();
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// --- logs -------------------------------------------------------------------
|
|
1382
|
+
|
|
1383
|
+
async function cmdLogs(follow: boolean): Promise<void> {
|
|
1384
|
+
const log = serviceLogPath();
|
|
1385
|
+
if (log.startsWith("journald")) {
|
|
1386
|
+
const args = ["--user", "-u", STATION.command];
|
|
1387
|
+
if (follow) args.push("-f");
|
|
1388
|
+
inherit("journalctl", args);
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
if (!existsSync(log)) {
|
|
1392
|
+
console.error(red(`log not found: ${log}`));
|
|
1393
|
+
console.error(dim("(install + start the service first, or run in the foreground)"));
|
|
1394
|
+
process.exitCode = 1;
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
inherit("tail", follow ? ["-f", log] : ["-n", "200", log]);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function inherit(cmd: string, args: string[]): void {
|
|
1401
|
+
const child = spawn(cmd, args, { stdio: "inherit" });
|
|
1402
|
+
child.on("error", (err) => {
|
|
1403
|
+
console.error(red(`could not run ${cmd}: ${err.message}`));
|
|
1404
|
+
process.exitCode = 1;
|
|
1405
|
+
});
|
|
1406
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// --- setup (enrollment, ADR-031) --------------------------------------------
|
|
1410
|
+
|
|
1411
|
+
/** Read a `--name value` or `--name=value` flag from argv. */
|
|
1412
|
+
function flagValue(rest: string[], name: string): string | undefined {
|
|
1413
|
+
const i = rest.indexOf(name);
|
|
1414
|
+
if (i >= 0 && rest[i + 1]) return rest[i + 1];
|
|
1415
|
+
const eq = rest.find((a) => a.startsWith(`${name}=`));
|
|
1416
|
+
return eq ? eq.slice(name.length + 1) : undefined;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function exactFlagValue(rest: string[], name: string): string | undefined {
|
|
1420
|
+
return rest.length === 2 && rest[0] === name && rest[1]
|
|
1421
|
+
? rest[1]
|
|
1422
|
+
: undefined;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
type EnrollArgs =
|
|
1426
|
+
| { ok: true; token: string; cloud?: string }
|
|
1427
|
+
| { ok: false };
|
|
1428
|
+
|
|
1429
|
+
/** `enroll` spends a one-time token, so reject malformed/ambiguous argv
|
|
1430
|
+
* instead of silently falling back to persisted configuration. */
|
|
1431
|
+
function parseEnrollArgs(rest: string[]): EnrollArgs {
|
|
1432
|
+
let token: string | undefined;
|
|
1433
|
+
let cloud: string | undefined;
|
|
1434
|
+
let sawCloud = false;
|
|
1435
|
+
|
|
1436
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
1437
|
+
const arg = rest[index]!;
|
|
1438
|
+
if (arg === "--cloud") {
|
|
1439
|
+
if (sawCloud) return { ok: false };
|
|
1440
|
+
sawCloud = true;
|
|
1441
|
+
const value = rest[index + 1];
|
|
1442
|
+
if (!value || value.startsWith("-")) return { ok: false };
|
|
1443
|
+
cloud = value;
|
|
1444
|
+
index += 1;
|
|
1445
|
+
continue;
|
|
1446
|
+
}
|
|
1447
|
+
if (arg.startsWith("--cloud=")) {
|
|
1448
|
+
if (sawCloud) return { ok: false };
|
|
1449
|
+
sawCloud = true;
|
|
1450
|
+
cloud = arg.slice("--cloud=".length);
|
|
1451
|
+
if (!cloud) return { ok: false };
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
if (arg.startsWith("-") || token) return { ok: false };
|
|
1455
|
+
token = arg;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
return token ? { ok: true, token, ...(cloud ? { cloud } : {}) } : { ok: false };
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
type HostIdentity =
|
|
1462
|
+
| { kind: "none" }
|
|
1463
|
+
| { kind: "partial" }
|
|
1464
|
+
| { kind: "complete"; hostId: string; hostToken: string };
|
|
1465
|
+
|
|
1466
|
+
/** Resolve both modern env identities and legacy `pair` identities whose id
|
|
1467
|
+
* lives in `$UAI_DATA_DIR/host-id`. Exactly one unresolved half fails closed. */
|
|
1468
|
+
function hostIdentity(): HostIdentity {
|
|
1469
|
+
const hostToken = process.env.UAI_HOST_TOKEN;
|
|
1470
|
+
const hostId = process.env.UAI_HOST_ID ?? (hostToken ? readPersistedHostId() : null);
|
|
1471
|
+
if (!hostId && !hostToken) return { kind: "none" };
|
|
1472
|
+
if (!hostId || !hostToken) return { kind: "partial" };
|
|
1473
|
+
return { kind: "complete", hostId, hostToken };
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function rejectPartialIdentity(): void {
|
|
1477
|
+
console.error(
|
|
1478
|
+
red("incomplete host identity — refusing to replace an existing enrollment"),
|
|
1479
|
+
);
|
|
1480
|
+
console.error(
|
|
1481
|
+
dim("restore UAI_HOST_ID/UAI_HOST_TOKEN, or use `moondeck-station setup --force` for a deliberate reset."),
|
|
1482
|
+
);
|
|
1483
|
+
process.exitCode = 1;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/** The bridge credential is durable, so its destination trust anchor must be
|
|
1487
|
+
* durable too. Process env is intentionally NOT consulted here: a pasted
|
|
1488
|
+
* command can override it before load-env reads the host's real config. */
|
|
1489
|
+
function persistedCloudUrl(): string | undefined {
|
|
1490
|
+
for (const path of [envLocalPath(), join(uaiHome(), ".env")]) {
|
|
1491
|
+
if (!existsSync(path)) continue;
|
|
1492
|
+
try {
|
|
1493
|
+
const value = parseDotenv(readFileSync(path)).UAI_CLOUD_URL;
|
|
1494
|
+
if (value) return value;
|
|
1495
|
+
} catch {
|
|
1496
|
+
// The normal env loader already reports malformed/unreadable files. A
|
|
1497
|
+
// missing trust anchor is handled by the caller without leaking secrets.
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return undefined;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Claim this machine as a host: redeem a one-time enrollment token (minted in
|
|
1505
|
+
* the web app) for a permanent bridge credential, then write the config. The
|
|
1506
|
+
* bridge secret is generated HERE and never leaves — only its hash is sent
|
|
1507
|
+
* (ADR-015/ADR-031).
|
|
1508
|
+
*
|
|
1509
|
+
* uai-host setup --cloud wss://app.runuai.com/host --enroll uaienroll_…
|
|
1510
|
+
*/
|
|
1511
|
+
async function cmdSetup(rest: string[]): Promise<void> {
|
|
1512
|
+
const enroll = flagValue(rest, "--enroll");
|
|
1513
|
+
const cloud = flagValue(rest, "--cloud");
|
|
1514
|
+
if (!enroll || !cloud) {
|
|
1515
|
+
console.error(
|
|
1516
|
+
red("usage: moondeck-station setup --cloud <wss-url> --enroll <token>"),
|
|
1517
|
+
);
|
|
1518
|
+
process.exitCode = 1;
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
let cloudEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
|
|
1523
|
+
try {
|
|
1524
|
+
cloudEndpoint = parseHostCloudEndpoint(cloud);
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
console.error(
|
|
1527
|
+
red(
|
|
1528
|
+
`invalid --cloud URL: ${error instanceof Error ? error.message : String(error)}`,
|
|
1529
|
+
),
|
|
1530
|
+
);
|
|
1531
|
+
process.exitCode = 1;
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
const normalizedCloud = cloudEndpoint.bridgeUrl;
|
|
1535
|
+
|
|
1536
|
+
// An already-claimed machine ATTACHES the token to its existing identity
|
|
1537
|
+
// (ADR-098) — pasting a second org's enroll command Just Works, no new host
|
|
1538
|
+
// row, no --force. --force still re-enrolls from scratch (new identity,
|
|
1539
|
+
// orphans the old row) for the rare deliberate reset.
|
|
1540
|
+
const identity = hostIdentity();
|
|
1541
|
+
if (identity.kind === "partial" && !rest.includes("--force")) {
|
|
1542
|
+
rejectPartialIdentity();
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
if (identity.kind === "complete" && !rest.includes("--force")) {
|
|
1546
|
+
try {
|
|
1547
|
+
removeCommittedPendingEnrollment(pendingEnrollmentPath(uaiHome()), {
|
|
1548
|
+
hostId: identity.hostId,
|
|
1549
|
+
hostSecret: identity.hostToken,
|
|
1550
|
+
});
|
|
1551
|
+
} catch (err) {
|
|
1552
|
+
console.error(
|
|
1553
|
+
red(
|
|
1554
|
+
`cannot verify pending enrollment state: ${err instanceof Error ? err.message : String(err)}`,
|
|
1555
|
+
),
|
|
1556
|
+
);
|
|
1557
|
+
process.exitCode = 1;
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
console.log(
|
|
1561
|
+
dim("already enrolled — attaching this machine to the token's workspace instead."),
|
|
1562
|
+
);
|
|
1563
|
+
return attachToOrg(enroll, normalizedCloud, identity);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const pendingFile = pendingEnrollmentPath(uaiHome());
|
|
1567
|
+
if (rest.includes("--force")) discardPendingEnrollment(pendingFile);
|
|
1568
|
+
|
|
1569
|
+
const redeemUrl = `${cloudEndpoint.apiOrigin}/api/hosts/enroll/redeem`;
|
|
1570
|
+
|
|
1571
|
+
// Persist this host's proposed identity BEFORE spending the one-time token.
|
|
1572
|
+
// A retry after a lost response reuses the exact id/secret, and the cloud's
|
|
1573
|
+
// same-host replay returns the already-completed result without new access.
|
|
1574
|
+
const candidate: PendingEnrollment = {
|
|
1575
|
+
schemaVersion: 1,
|
|
1576
|
+
enrollmentTokenSha256: enrollmentTokenSha256(enroll),
|
|
1577
|
+
hostId: ulid().toLowerCase(),
|
|
1578
|
+
hostSecret: randomBytes(32).toString("hex"),
|
|
1579
|
+
cloudUrl: normalizedCloud,
|
|
1580
|
+
hostName: hostname(),
|
|
1581
|
+
createdAt: Date.now(),
|
|
1582
|
+
};
|
|
1583
|
+
let pending: PendingEnrollment;
|
|
1584
|
+
try {
|
|
1585
|
+
pending = ensurePendingEnrollment(pendingFile, candidate).pending;
|
|
1586
|
+
} catch (err) {
|
|
1587
|
+
console.error(
|
|
1588
|
+
red(
|
|
1589
|
+
`could not persist pending enrollment: ${err instanceof Error ? err.message : String(err)}`,
|
|
1590
|
+
),
|
|
1591
|
+
);
|
|
1592
|
+
process.exitCode = 1;
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
if (!pendingEnrollmentMatches(pending, enroll, normalizedCloud)) {
|
|
1596
|
+
console.error(red("another enrollment is already pending on this machine"));
|
|
1597
|
+
console.error(
|
|
1598
|
+
dim(
|
|
1599
|
+
"retry the original enrollment command, or use `moondeck-station setup --force` for a deliberate reset.",
|
|
1600
|
+
),
|
|
1601
|
+
);
|
|
1602
|
+
process.exitCode = 1;
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const hostId = pending.hostId;
|
|
1606
|
+
const secret = pending.hostSecret;
|
|
1607
|
+
const tokenHash = createHash("sha256").update(secret).digest("hex");
|
|
1608
|
+
const name = pending.hostName;
|
|
1609
|
+
|
|
1610
|
+
console.log(
|
|
1611
|
+
dim(`enrolling ${name} (${hostId}) with ${cloudEndpoint.displayHost}…`),
|
|
1612
|
+
);
|
|
1613
|
+
let res: Response;
|
|
1614
|
+
try {
|
|
1615
|
+
res = await fetch(redeemUrl, {
|
|
1616
|
+
method: "POST",
|
|
1617
|
+
redirect: "error",
|
|
1618
|
+
headers: { "content-type": "application/json" },
|
|
1619
|
+
body: JSON.stringify({ enrollToken: enroll, hostId, tokenHash, name }),
|
|
1620
|
+
signal: AbortSignal.timeout(15_000),
|
|
1621
|
+
});
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
console.error(
|
|
1624
|
+
red(`could not reach the cloud: ${err instanceof Error ? err.message : String(err)}`),
|
|
1625
|
+
);
|
|
1626
|
+
console.error(dim("the proposed identity is saved locally; retry this exact command."));
|
|
1627
|
+
process.exitCode = 1;
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
if (!res.ok) {
|
|
1631
|
+
let detail = `HTTP ${res.status}`;
|
|
1632
|
+
try {
|
|
1633
|
+
const j = (await boundedResponseJson(res)) as {
|
|
1634
|
+
error?: { message?: string };
|
|
1635
|
+
};
|
|
1636
|
+
if (j.error?.message) detail = j.error.message;
|
|
1637
|
+
} catch {
|
|
1638
|
+
/* keep the HTTP status */
|
|
1639
|
+
}
|
|
1640
|
+
console.error(red(`enrollment failed: ${detail}`));
|
|
1641
|
+
const definitiveUnclaimed =
|
|
1642
|
+
(res.status === 404 || res.status === 410) &&
|
|
1643
|
+
res.headers.get(ENROLLMENT_REPLAY_PROTOCOL_HEADER) ===
|
|
1644
|
+
ENROLLMENT_REPLAY_PROTOCOL_VERSION;
|
|
1645
|
+
if (definitiveUnclaimed) {
|
|
1646
|
+
// A same-host-v1 cloud checks `redeemedAt` before expiry, so these are
|
|
1647
|
+
// proven unclaimed. Old/rolled-back handlers carry no header and may
|
|
1648
|
+
// return 410 after committing a response that was lost in transit.
|
|
1649
|
+
discardPendingEnrollment(pendingFile);
|
|
1650
|
+
console.error(dim("mint a fresh enrollment command in the web app and retry."));
|
|
1651
|
+
} else {
|
|
1652
|
+
// USED is intentionally retained for rolling compatibility: an older
|
|
1653
|
+
// cloud may have completed the first request but lack same-host replay.
|
|
1654
|
+
console.error(dim("the proposed identity is saved locally; retry this exact command."));
|
|
1655
|
+
}
|
|
1656
|
+
process.exitCode = 1;
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
let success: unknown;
|
|
1661
|
+
try {
|
|
1662
|
+
success = await boundedResponseJson(res);
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
console.error(
|
|
1665
|
+
red(
|
|
1666
|
+
`enrollment outcome could not be verified: ${error instanceof Error ? error.message : String(error)}`,
|
|
1667
|
+
),
|
|
1668
|
+
);
|
|
1669
|
+
console.error(dim("the proposed identity is saved locally; retry this exact command."));
|
|
1670
|
+
process.exitCode = 1;
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
// ADR-136 step 4: a replace-machine enrollment answers with the EXISTING
|
|
1674
|
+
// row's id — this machine adopts it instead of its own proposal, so the
|
|
1675
|
+
// runner keeps its identity (and every task created against it).
|
|
1676
|
+
const outcome =
|
|
1677
|
+
success && typeof success === "object" && !Array.isArray(success)
|
|
1678
|
+
? (success as Record<string, unknown>)
|
|
1679
|
+
: null;
|
|
1680
|
+
const replacedHostId =
|
|
1681
|
+
outcome?.replaced === true && typeof outcome.hostId === "string" && outcome.hostId.length > 0
|
|
1682
|
+
? outcome.hostId
|
|
1683
|
+
: null;
|
|
1684
|
+
if (
|
|
1685
|
+
!outcome ||
|
|
1686
|
+
outcome.ok !== true ||
|
|
1687
|
+
(replacedHostId === null && outcome.hostId !== pending.hostId)
|
|
1688
|
+
) {
|
|
1689
|
+
console.error(red("enrollment outcome did not match the proposed host identity"));
|
|
1690
|
+
console.error(dim("the proposed identity is saved locally; retry this exact command."));
|
|
1691
|
+
process.exitCode = 1;
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// Atomically commit all three values. The pending file is removed only after
|
|
1696
|
+
// the replacement is durable; a crash can leave both files, never neither.
|
|
1697
|
+
try {
|
|
1698
|
+
commitPendingEnrollment(pendingFile, envLocalPath(), pending);
|
|
1699
|
+
} catch (err) {
|
|
1700
|
+
console.error(
|
|
1701
|
+
red(
|
|
1702
|
+
`enrolled, but could not write ${envLocalPath()}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1703
|
+
),
|
|
1704
|
+
);
|
|
1705
|
+
console.error(
|
|
1706
|
+
dim("the identity remains in protected pending state — retry this exact enrollment command."),
|
|
1707
|
+
);
|
|
1708
|
+
process.exitCode = 1;
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
if (replacedHostId !== null) {
|
|
1712
|
+
// The pending commit wrote the proposed id; the row this machine now
|
|
1713
|
+
// answers as is the replaced one. Same secret, so the bridge admits it.
|
|
1714
|
+
try {
|
|
1715
|
+
writeEnvValuesAtomic(envLocalPath(), { UAI_HOST_ID: replacedHostId });
|
|
1716
|
+
} catch (err) {
|
|
1717
|
+
console.error(
|
|
1718
|
+
red(
|
|
1719
|
+
`replaced, but could not record the station id in ${envLocalPath()}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1720
|
+
),
|
|
1721
|
+
);
|
|
1722
|
+
console.error(dim(`set UAI_HOST_ID=${replacedHostId} there by hand before starting.`));
|
|
1723
|
+
process.exitCode = 1;
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
console.log(
|
|
1727
|
+
green("✓ replaced") + dim(` — this machine now answers as station ${replacedHostId}; config written to ${envLocalPath()}`),
|
|
1728
|
+
);
|
|
1729
|
+
} else {
|
|
1730
|
+
console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
|
|
1731
|
+
}
|
|
1732
|
+
printTelemetryNotice();
|
|
1733
|
+
const steps = enrollmentNextSteps();
|
|
1734
|
+
if (steps.length > 0) console.log("");
|
|
1735
|
+
for (const step of steps) {
|
|
1736
|
+
console.log(step.command ? cyan(step.command) + dim(step.note ?? "") : step.text);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// --- enroll (attach an org, ADR-098) -----------------------------------------
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Enroll this machine. Without an identity this delegates to fresh setup; with
|
|
1744
|
+
* one, it spends the token against the existing host rather than minting a new
|
|
1745
|
+
* identity. The bridge secret authenticates attach (same credential the WSS
|
|
1746
|
+
* bridge presents) and may only be sent over TLS or to a loopback dev cloud.
|
|
1747
|
+
*
|
|
1748
|
+
* uai-host enroll <token> [--cloud <wss-url>]
|
|
1749
|
+
*/
|
|
1750
|
+
async function cmdEnroll(rest: string[]): Promise<void> {
|
|
1751
|
+
const parsed = parseEnrollArgs(rest);
|
|
1752
|
+
if (!parsed.ok) {
|
|
1753
|
+
console.error(red("usage: moondeck-station enroll <token> [--cloud <wss-url>]"));
|
|
1754
|
+
process.exitCode = 1;
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
const identity = hostIdentity();
|
|
1758
|
+
if (identity.kind === "partial") {
|
|
1759
|
+
rejectPartialIdentity();
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
// A fresh station enrolls with moondeck.io unless told otherwise; an
|
|
1763
|
+
// enrolled one stays on the cloud it already belongs to.
|
|
1764
|
+
const cloud =
|
|
1765
|
+
parsed.cloud ??
|
|
1766
|
+
(identity.kind === "complete"
|
|
1767
|
+
? persistedCloudUrl()
|
|
1768
|
+
: process.env.UAI_CLOUD_URL || STATION_DEFAULT_CLOUD_URL);
|
|
1769
|
+
if (!cloud) {
|
|
1770
|
+
console.error(
|
|
1771
|
+
red(
|
|
1772
|
+
identity.kind === "complete"
|
|
1773
|
+
? "no persisted cloud URL — restore UAI_CLOUD_URL in the host config"
|
|
1774
|
+
: "no cloud URL — pass --cloud <wss-url>",
|
|
1775
|
+
),
|
|
1776
|
+
);
|
|
1777
|
+
process.exitCode = 1;
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
if (identity.kind === "none") {
|
|
1781
|
+
return cmdSetup(["--cloud", cloud, "--enroll", parsed.token]);
|
|
1782
|
+
}
|
|
1783
|
+
return attachToOrg(parsed.token, cloud, identity);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
/** Shared by `enroll` and `setup`-on-an-enrolled-machine. */
|
|
1787
|
+
async function attachToOrg(
|
|
1788
|
+
enrollToken: string,
|
|
1789
|
+
cloud: string,
|
|
1790
|
+
identity: Extract<HostIdentity, { kind: "complete" }>,
|
|
1791
|
+
): Promise<void> {
|
|
1792
|
+
const { hostId, hostToken } = identity;
|
|
1793
|
+
|
|
1794
|
+
let cloudEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
|
|
1795
|
+
try {
|
|
1796
|
+
cloudEndpoint = parseHostCloudEndpoint(cloud);
|
|
1797
|
+
} catch (error) {
|
|
1798
|
+
console.error(
|
|
1799
|
+
red(
|
|
1800
|
+
`invalid cloud URL: ${error instanceof Error ? error.message : String(error)}`,
|
|
1801
|
+
),
|
|
1802
|
+
);
|
|
1803
|
+
process.exitCode = 1;
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
const configuredCloud = persistedCloudUrl();
|
|
1808
|
+
if (!configuredCloud) {
|
|
1809
|
+
console.error(
|
|
1810
|
+
red("cannot verify the enrollment destination — UAI_CLOUD_URL is not persisted"),
|
|
1811
|
+
);
|
|
1812
|
+
console.error(dim("restore the original cloud URL before attaching this host."));
|
|
1813
|
+
process.exitCode = 1;
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
let configuredEndpoint: ReturnType<typeof parseHostCloudEndpoint>;
|
|
1817
|
+
try {
|
|
1818
|
+
configuredEndpoint = parseHostCloudEndpoint(configuredCloud);
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
console.error(
|
|
1821
|
+
red(
|
|
1822
|
+
`invalid configured UAI_CLOUD_URL: ${error instanceof Error ? error.message : String(error)}`,
|
|
1823
|
+
),
|
|
1824
|
+
);
|
|
1825
|
+
process.exitCode = 1;
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
if (cloudEndpoint.apiOrigin !== configuredEndpoint.apiOrigin) {
|
|
1829
|
+
console.error(
|
|
1830
|
+
red("refusing to send the host credential to a different cloud origin"),
|
|
1831
|
+
);
|
|
1832
|
+
console.error(dim(`configured cloud: ${configuredEndpoint.displayHost}`));
|
|
1833
|
+
process.exitCode = 1;
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
const attachUrl = `${cloudEndpoint.apiOrigin}/api/hosts/enroll/attach`;
|
|
1837
|
+
|
|
1838
|
+
console.log(
|
|
1839
|
+
dim(`attaching ${hostname()} (${hostId}) via ${cloudEndpoint.displayHost}…`),
|
|
1840
|
+
);
|
|
1841
|
+
let res: Response;
|
|
1842
|
+
try {
|
|
1843
|
+
res = await fetch(attachUrl, {
|
|
1844
|
+
method: "POST",
|
|
1845
|
+
redirect: "error",
|
|
1846
|
+
headers: { "content-type": "application/json" },
|
|
1847
|
+
body: JSON.stringify({ enrollToken, hostId, token: hostToken }),
|
|
1848
|
+
signal: AbortSignal.timeout(15_000),
|
|
1849
|
+
});
|
|
1850
|
+
} catch (err) {
|
|
1851
|
+
console.error(
|
|
1852
|
+
red(`could not reach the cloud: ${err instanceof Error ? err.message : String(err)}`),
|
|
1853
|
+
);
|
|
1854
|
+
process.exitCode = 1;
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
if (!res.ok) {
|
|
1858
|
+
let detail = `HTTP ${res.status}`;
|
|
1859
|
+
try {
|
|
1860
|
+
const j = (await boundedResponseJson(res)) as {
|
|
1861
|
+
error?: { message?: string };
|
|
1862
|
+
};
|
|
1863
|
+
if (j.error?.message) detail = j.error.message;
|
|
1864
|
+
} catch {
|
|
1865
|
+
/* keep the HTTP status */
|
|
1866
|
+
}
|
|
1867
|
+
console.error(red(`attach failed: ${detail}`));
|
|
1868
|
+
console.error(
|
|
1869
|
+
dim("the token may be expired or already used — mint a fresh one in the web app."),
|
|
1870
|
+
);
|
|
1871
|
+
process.exitCode = 1;
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
let body: unknown;
|
|
1875
|
+
try {
|
|
1876
|
+
body = await boundedResponseJson(res);
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
console.error(
|
|
1879
|
+
red(
|
|
1880
|
+
`attach outcome could not be verified: ${error instanceof Error ? error.message : String(error)}`,
|
|
1881
|
+
),
|
|
1882
|
+
);
|
|
1883
|
+
process.exitCode = 1;
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
const result = body as Record<string, unknown> | null;
|
|
1887
|
+
if (
|
|
1888
|
+
!result ||
|
|
1889
|
+
Array.isArray(result) ||
|
|
1890
|
+
result.ok !== true ||
|
|
1891
|
+
result.hostId !== hostId ||
|
|
1892
|
+
typeof result.orgId !== "string" ||
|
|
1893
|
+
result.orgId.length === 0 ||
|
|
1894
|
+
result.orgId.length > 128 ||
|
|
1895
|
+
(result.orgName !== null &&
|
|
1896
|
+
result.orgName !== undefined &&
|
|
1897
|
+
typeof result.orgName !== "string")
|
|
1898
|
+
) {
|
|
1899
|
+
console.error(red("attach outcome did not match this host identity"));
|
|
1900
|
+
process.exitCode = 1;
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
const where = result.orgName ? ` to ${result.orgName}` : "";
|
|
1904
|
+
console.log(green(`✓ enrolled${where}`) + dim(" — no restart needed."));
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// --- pair -------------------------------------------------------------------
|
|
1908
|
+
|
|
1909
|
+
async function cmdPair(token: string | undefined): Promise<void> {
|
|
1910
|
+
if (!token) {
|
|
1911
|
+
console.error(red("usage: moondeck-station pair <token>"));
|
|
1912
|
+
process.exitCode = 1;
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
// v1 "pairing" = persist the host token to .env.local (load-env.ts reads it).
|
|
1916
|
+
// The cloud-issued-pairing-token -> host-token handshake is a future flow
|
|
1917
|
+
// (hosted-architecture.md, "what we don't do now"); this stores what you paste.
|
|
1918
|
+
upsertEnvLocal("UAI_HOST_TOKEN", token);
|
|
1919
|
+
console.log(green("paired") + dim(` — wrote UAI_HOST_TOKEN to ${envLocalPath()}`));
|
|
1920
|
+
console.log(dim("apply it: moondeck-station restart (or start the service)"));
|
|
1921
|
+
printTelemetryNotice();
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function upsertEnvLocal(key: string, value: string): void {
|
|
1925
|
+
writeEnvValuesAtomic(envLocalPath(), { [key]: value });
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// --- install dispatch -------------------------------------------------------
|
|
1929
|
+
|
|
1930
|
+
async function cmdInstall(rest: string[]): Promise<void> {
|
|
1931
|
+
const parsed = parseInstallArgs(rest);
|
|
1932
|
+
if (!parsed) {
|
|
1933
|
+
console.error(
|
|
1934
|
+
red(
|
|
1935
|
+
"usage: moondeck-station install [--no-start] [--dry-run] [--install-intent <nonce>]",
|
|
1936
|
+
),
|
|
1937
|
+
);
|
|
1938
|
+
process.exitCode = 1;
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
const { dryRun, startOnInstall, installIntent } = parsed;
|
|
1942
|
+
// Disclosure BEFORE activation (review): install starts the service, and
|
|
1943
|
+
// the notice must precede the first telemetry-armed run.
|
|
1944
|
+
if (!dryRun) printTelemetryNotice();
|
|
1945
|
+
if (!dryRun) mkdirSync(uaiHome(), { recursive: true, mode: 0o700 });
|
|
1946
|
+
const installer = await loadInstaller();
|
|
1947
|
+
if (dryRun) {
|
|
1948
|
+
await installer.install(installContext(true, startOnInstall));
|
|
1949
|
+
} else if (installIntent) {
|
|
1950
|
+
await runManagedRuntimeInstallIntentMutation({
|
|
1951
|
+
installIntent,
|
|
1952
|
+
mutation: (identity) =>
|
|
1953
|
+
installer.install(installContext(false, startOnInstall, identity)),
|
|
1954
|
+
});
|
|
1955
|
+
} else {
|
|
1956
|
+
await runInstallLifecycleMutation(installer, () =>
|
|
1957
|
+
installer.install(installContext(false, startOnInstall)),
|
|
1958
|
+
);
|
|
1959
|
+
}
|
|
1960
|
+
if (!dryRun) {
|
|
1961
|
+
console.log(
|
|
1962
|
+
green("installed") +
|
|
1963
|
+
dim(
|
|
1964
|
+
startOnInstall
|
|
1965
|
+
? " — service registered and started"
|
|
1966
|
+
: " — service registered but not started; start it: moondeck-station start",
|
|
1967
|
+
),
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function parseInstallArgs(rest: string[]): {
|
|
1973
|
+
dryRun: boolean;
|
|
1974
|
+
startOnInstall: boolean;
|
|
1975
|
+
installIntent?: string;
|
|
1976
|
+
} | null {
|
|
1977
|
+
let dryRun = false;
|
|
1978
|
+
let noStart = false;
|
|
1979
|
+
let installIntent: string | undefined;
|
|
1980
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
1981
|
+
const arg = rest[index]!;
|
|
1982
|
+
if (arg === "--dry-run") {
|
|
1983
|
+
if (dryRun) return null;
|
|
1984
|
+
dryRun = true;
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
if (arg === "--no-start") {
|
|
1988
|
+
if (noStart) return null;
|
|
1989
|
+
noStart = true;
|
|
1990
|
+
continue;
|
|
1991
|
+
}
|
|
1992
|
+
if (arg === "--install-intent") {
|
|
1993
|
+
if (installIntent) return null;
|
|
1994
|
+
const value = rest[index + 1];
|
|
1995
|
+
if (!value || !/^[a-f0-9]{64}$/.test(value)) return null;
|
|
1996
|
+
installIntent = value;
|
|
1997
|
+
index += 1;
|
|
1998
|
+
continue;
|
|
1999
|
+
}
|
|
2000
|
+
return null;
|
|
2001
|
+
}
|
|
2002
|
+
if (dryRun && installIntent) return null;
|
|
2003
|
+
return { dryRun, startOnInstall: !noStart, installIntent };
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/**
|
|
2007
|
+
* ADR-071 default-on disclosure at the surfaces an operator actually SEES:
|
|
2008
|
+
* the service's own start line only reaches host.out.log/journald, so the
|
|
2009
|
+
* interactive commands (install/pair/setup/start/restart) print it — and
|
|
2010
|
+
* PERSIST the shown-once marker that gates the baked default (a silently
|
|
2011
|
+
* upgraded service stays off until one of these runs).
|
|
2012
|
+
*/
|
|
2013
|
+
function printTelemetryNotice(): void {
|
|
2014
|
+
const notice = telemetryNoticeIfActive();
|
|
2015
|
+
if (notice) {
|
|
2016
|
+
console.log(yellow("telemetry: ") + notice);
|
|
2017
|
+
persistTelemetryDisclosure();
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
async function cmdInstaller(
|
|
2022
|
+
action: "start" | "stop" | "restart",
|
|
2023
|
+
dryRun: boolean,
|
|
2024
|
+
): Promise<void> {
|
|
2025
|
+
// Upgrade path (review finding): operators of EXISTING installs meet
|
|
2026
|
+
// default-on telemetry through start/restart — disclose BEFORE the
|
|
2027
|
+
// service action activates anything, and persist the gate marker.
|
|
2028
|
+
if (!dryRun && (action === "start" || action === "restart")) {
|
|
2029
|
+
printTelemetryNotice();
|
|
2030
|
+
}
|
|
2031
|
+
const installer = await loadInstaller();
|
|
2032
|
+
if (dryRun) {
|
|
2033
|
+
await installer[action](true);
|
|
2034
|
+
} else {
|
|
2035
|
+
await runInstallLifecycleMutation(installer, () => installer[action](false));
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
async function cmdUninstall(rest: string[]): Promise<void> {
|
|
2040
|
+
const dryRun = rest.includes("--dry-run");
|
|
2041
|
+
const purge = rest.includes("--purge");
|
|
2042
|
+
if (
|
|
2043
|
+
rest.some((arg) => arg !== "--dry-run" && arg !== "--purge") ||
|
|
2044
|
+
rest.filter((arg) => arg === "--dry-run").length > 1 ||
|
|
2045
|
+
rest.filter((arg) => arg === "--purge").length > 1
|
|
2046
|
+
) {
|
|
2047
|
+
console.error(red("usage: moondeck-station uninstall [--purge] [--dry-run]"));
|
|
2048
|
+
process.exitCode = 1;
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
const installer = await loadInstaller();
|
|
2052
|
+
const paths = managedRuntimePaths();
|
|
2053
|
+
if (dryRun) {
|
|
2054
|
+
await installer.uninstall(true);
|
|
2055
|
+
console.log(`$ rm -rf ${paths.root}`);
|
|
2056
|
+
if (purge) console.log(`$ rm -rf ${paths.data}`);
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
await reconcileInstallLifecycle(installer);
|
|
2060
|
+
await removeManagedRuntime({
|
|
2061
|
+
purgeData: purge,
|
|
2062
|
+
canRemove: () => !hasNonTerminalHostTasks(),
|
|
2063
|
+
beforeRemove: () => installer.uninstall(false),
|
|
2064
|
+
afterServiceTeardown: () => teardownBundledRuntimeForUninstall(purge),
|
|
2065
|
+
});
|
|
2066
|
+
console.log(
|
|
2067
|
+
green("uninstalled") +
|
|
2068
|
+
(purge
|
|
2069
|
+
? dim(" — host runtime and ~/.moondeck data removed")
|
|
2070
|
+
: dim(" — ~/.moondeck data preserved (use --purge to remove it)")),
|
|
2071
|
+
);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* The service is already down, but the active signed generation and purge
|
|
2076
|
+
* target are still attached here — the last moment the bundled CLI exists to
|
|
2077
|
+
* clean up what Uai provisioned through it. Ownership-proven and fail-closed
|
|
2078
|
+
* (see lib/apple-uninstall-teardown.ts): an incomplete teardown THROWS, which
|
|
2079
|
+
* deliberately leaves the managed-removal transaction's teardown marker and
|
|
2080
|
+
* the signed runtime in place for a retry — never remove the escape route
|
|
2081
|
+
* while Uai resources may remain.
|
|
2082
|
+
*/
|
|
2083
|
+
async function teardownBundledRuntimeForUninstall(purge: boolean): Promise<void> {
|
|
2084
|
+
if (process.platform !== "darwin") return;
|
|
2085
|
+
// The durable provider claim marker is the authority for whether Apple
|
|
2086
|
+
// state can exist at all: it is written BEFORE any apple task provisioning
|
|
2087
|
+
// (runtime-provider-state.ts). Absent marker = nothing was ever
|
|
2088
|
+
// provisioned = nothing to tear down. A malformed marker throws from the
|
|
2089
|
+
// reader — uncertainty keeps the uninstall retryable. With the marker
|
|
2090
|
+
// present, a corrupt bundle must THROW rather than skip (review
|
|
2091
|
+
// 2026-08-18 round 3: the old `.catch(() => null)` silently removed the
|
|
2092
|
+
// signed runtime while Apple state remained — the escape-route failure).
|
|
2093
|
+
const providerState = readRuntimeProviderState(
|
|
2094
|
+
runtimeProviderStatePath(env.dataDir),
|
|
2095
|
+
);
|
|
2096
|
+
if (providerState?.statefulProvider !== "apple-container") return;
|
|
2097
|
+
let bundle: Awaited<ReturnType<typeof resolveBundledRuntimeFromEnvironment>>;
|
|
2098
|
+
try {
|
|
2099
|
+
bundle = await resolveBundledRuntimeFromEnvironment();
|
|
2100
|
+
} catch (error) {
|
|
2101
|
+
throw new Error(
|
|
2102
|
+
"Apple container state exists but the bundled runtime failed " +
|
|
2103
|
+
`verification (${error instanceof Error ? error.message : String(error)}); ` +
|
|
2104
|
+
"uninstall was left retryable — repair or reinstall the host runtime, then retry",
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
const cliPath = bundle?.containerCliPath;
|
|
2108
|
+
if (!cliPath) {
|
|
2109
|
+
throw new Error(
|
|
2110
|
+
"Apple container state exists but no bundled CLI is available to tear it down; uninstall was left retryable",
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
const cli = (args: string[], timeoutMs: number) =>
|
|
2114
|
+
new Promise<{ code: number | null; stdout: string; stderr: string }>(
|
|
2115
|
+
(resolveCli) => {
|
|
2116
|
+
let stdout = "";
|
|
2117
|
+
let stderr = "";
|
|
2118
|
+
let settled = false;
|
|
2119
|
+
const child = spawn(cliPath, args, {
|
|
2120
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2121
|
+
});
|
|
2122
|
+
const settle = (code: number | null): void => {
|
|
2123
|
+
if (settled) return;
|
|
2124
|
+
settled = true;
|
|
2125
|
+
clearTimeout(timer);
|
|
2126
|
+
resolveCli({ code, stdout, stderr });
|
|
2127
|
+
};
|
|
2128
|
+
const timer = setTimeout(() => {
|
|
2129
|
+
child.kill("SIGKILL");
|
|
2130
|
+
const backstop = setTimeout(() => settle(null), 1_000);
|
|
2131
|
+
backstop.unref();
|
|
2132
|
+
}, timeoutMs);
|
|
2133
|
+
timer.unref();
|
|
2134
|
+
child.stdout?.setEncoding("utf8");
|
|
2135
|
+
child.stderr?.setEncoding("utf8");
|
|
2136
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
2137
|
+
stdout += chunk;
|
|
2138
|
+
});
|
|
2139
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
2140
|
+
stderr += chunk;
|
|
2141
|
+
});
|
|
2142
|
+
child.on("error", () => settle(null));
|
|
2143
|
+
child.on("close", (code) => settle(code));
|
|
2144
|
+
},
|
|
2145
|
+
);
|
|
2146
|
+
const result = await teardownAppleRuntimeStateForUninstall({ cli, purge });
|
|
2147
|
+
for (const removed of result.removedContainers) {
|
|
2148
|
+
console.log(dim(`removed container ${removed}`));
|
|
2149
|
+
}
|
|
2150
|
+
for (const removed of result.removedImages) {
|
|
2151
|
+
console.log(dim(`removed image ${removed}`));
|
|
2152
|
+
}
|
|
2153
|
+
if (result.problems.length > 0) {
|
|
2154
|
+
throw new Error(
|
|
2155
|
+
`Apple container teardown is incomplete — uninstall was left retryable: ${result.problems.join("; ")}`,
|
|
2156
|
+
);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
async function loadInstaller(): Promise<Installer> {
|
|
2161
|
+
const name =
|
|
2162
|
+
process.platform === "darwin"
|
|
2163
|
+
? "darwin"
|
|
2164
|
+
: process.platform === "win32"
|
|
2165
|
+
? "win"
|
|
2166
|
+
: "linux";
|
|
2167
|
+
try {
|
|
2168
|
+
const mod = (await import(`../scripts/install/${name}`)) as {
|
|
2169
|
+
installer: Installer;
|
|
2170
|
+
};
|
|
2171
|
+
return mod.installer;
|
|
2172
|
+
} catch (err) {
|
|
2173
|
+
throw new Error(
|
|
2174
|
+
`no installer available for ${process.platform}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
async function reconcileDefinitionInstall(
|
|
2180
|
+
installer: Installer,
|
|
2181
|
+
installIntent?: string,
|
|
2182
|
+
): Promise<void> {
|
|
2183
|
+
const reconcile = (
|
|
2184
|
+
installer as Installer & {
|
|
2185
|
+
reconcileDefinitionInstall?: (installIntent?: string) => Promise<void>;
|
|
2186
|
+
}
|
|
2187
|
+
).reconcileDefinitionInstall;
|
|
2188
|
+
if (!reconcile) {
|
|
2189
|
+
throw new Error(
|
|
2190
|
+
"service installer cannot reconcile an interrupted definition update",
|
|
2191
|
+
);
|
|
2192
|
+
}
|
|
2193
|
+
await reconcile.call(installer, installIntent);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
async function completeDefinitionInstall(
|
|
2197
|
+
installer: Installer,
|
|
2198
|
+
installIntent?: string,
|
|
2199
|
+
): Promise<void> {
|
|
2200
|
+
const complete = (
|
|
2201
|
+
installer as Installer & {
|
|
2202
|
+
completeDefinitionInstall?: (installIntent?: string) => Promise<void>;
|
|
2203
|
+
}
|
|
2204
|
+
).completeDefinitionInstall;
|
|
2205
|
+
if (!complete) {
|
|
2206
|
+
throw new Error(
|
|
2207
|
+
"service installer cannot complete its durable definition update",
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
await complete.call(installer, installIntent);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
async function prepareDefinitionInstallRollback(
|
|
2214
|
+
installer: Installer,
|
|
2215
|
+
installIntent: string,
|
|
2216
|
+
): Promise<void> {
|
|
2217
|
+
const prepare = (
|
|
2218
|
+
installer as Installer & {
|
|
2219
|
+
prepareDefinitionInstallRollback?: (installIntent: string) => Promise<void>;
|
|
2220
|
+
}
|
|
2221
|
+
).prepareDefinitionInstallRollback;
|
|
2222
|
+
if (!prepare) {
|
|
2223
|
+
throw new Error(
|
|
2224
|
+
"service installer cannot prepare durable definition rollback",
|
|
2225
|
+
);
|
|
2226
|
+
}
|
|
2227
|
+
await prepare.call(installer, installIntent);
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
async function resumeDefinitionInstallRollback(
|
|
2231
|
+
installer: Installer,
|
|
2232
|
+
installIntent: string,
|
|
2233
|
+
): Promise<void> {
|
|
2234
|
+
const resume = (
|
|
2235
|
+
installer as Installer & {
|
|
2236
|
+
resumeDefinitionInstallRollback?: (installIntent: string) => Promise<void>;
|
|
2237
|
+
}
|
|
2238
|
+
).resumeDefinitionInstallRollback;
|
|
2239
|
+
if (!resume) {
|
|
2240
|
+
throw new Error(
|
|
2241
|
+
"service installer cannot resume durable definition rollback",
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
await resume.call(installer, installIntent);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
async function serviceDefinitionState(
|
|
2248
|
+
installer: Installer,
|
|
2249
|
+
managedCommand: string,
|
|
2250
|
+
): Promise<ManagedServiceDefinitionState> {
|
|
2251
|
+
const inspect = (
|
|
2252
|
+
installer as Installer & {
|
|
2253
|
+
definitionState?: (
|
|
2254
|
+
managedCommand: string,
|
|
2255
|
+
) => Promise<ManagedServiceDefinitionState>;
|
|
2256
|
+
}
|
|
2257
|
+
).definitionState;
|
|
2258
|
+
if (!inspect) {
|
|
2259
|
+
throw new Error("service installer cannot inspect definition lifecycle state");
|
|
2260
|
+
}
|
|
2261
|
+
return inspect.call(installer, managedCommand);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
async function stopServiceForInstall(
|
|
2265
|
+
installer: Installer,
|
|
2266
|
+
installIntent: string,
|
|
2267
|
+
priorManager: ManagedServiceManagerState,
|
|
2268
|
+
): Promise<void> {
|
|
2269
|
+
const stop = (
|
|
2270
|
+
installer as Installer & {
|
|
2271
|
+
stopForInstall?: (
|
|
2272
|
+
installIntent: string,
|
|
2273
|
+
priorManager: ManagedServiceManagerState,
|
|
2274
|
+
) => Promise<void>;
|
|
2275
|
+
}
|
|
2276
|
+
).stopForInstall;
|
|
2277
|
+
if (!stop) {
|
|
2278
|
+
throw new Error("service installer cannot stop an install-owned service");
|
|
2279
|
+
}
|
|
2280
|
+
await stop.call(installer, installIntent, priorManager);
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
async function resumeServiceForInstall(
|
|
2284
|
+
installer: Installer,
|
|
2285
|
+
installIntent: string,
|
|
2286
|
+
priorManager: ManagedServiceManagerState,
|
|
2287
|
+
): Promise<void> {
|
|
2288
|
+
const resume = (
|
|
2289
|
+
installer as Installer & {
|
|
2290
|
+
resumeServiceForInstall?: (
|
|
2291
|
+
installIntent: string,
|
|
2292
|
+
priorManager: ManagedServiceManagerState,
|
|
2293
|
+
) => Promise<void>;
|
|
2294
|
+
}
|
|
2295
|
+
).resumeServiceForInstall;
|
|
2296
|
+
if (!resume) {
|
|
2297
|
+
throw new Error("service installer cannot resume an install-owned service");
|
|
2298
|
+
}
|
|
2299
|
+
await resume.call(installer, installIntent, priorManager);
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
async function reconcileInstallLifecycle(installer: Installer): Promise<void> {
|
|
2303
|
+
await reconcileManagedRuntimeInstallLifecycle(
|
|
2304
|
+
installLifecycleCallbacks(installer),
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
async function runInstallLifecycleMutation<T>(
|
|
2309
|
+
installer: Installer,
|
|
2310
|
+
mutation: () => Promise<T>,
|
|
2311
|
+
): Promise<T> {
|
|
2312
|
+
return runManagedRuntimeInstallLifecycleMutation({
|
|
2313
|
+
...installLifecycleCallbacks(installer),
|
|
2314
|
+
mutation,
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
function installLifecycleCallbacks(
|
|
2319
|
+
installer: Installer,
|
|
2320
|
+
): ReconcileManagedRuntimeInstallLifecycleOptions {
|
|
2321
|
+
return {
|
|
2322
|
+
reconcileDefinition: (installIntent) =>
|
|
2323
|
+
reconcileDefinitionInstall(installer, installIntent),
|
|
2324
|
+
completeDefinition: (installIntent) =>
|
|
2325
|
+
completeDefinitionInstall(installer, installIntent),
|
|
2326
|
+
prepareDefinitionRollback: (installIntent) =>
|
|
2327
|
+
prepareDefinitionInstallRollback(installer, installIntent),
|
|
2328
|
+
resumeDefinitionRollback: (installIntent) =>
|
|
2329
|
+
resumeDefinitionInstallRollback(installer, installIntent),
|
|
2330
|
+
resumeStoppedService: (installIntent, priorManager) =>
|
|
2331
|
+
resumeServiceForInstall(installer, installIntent, priorManager),
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function installContext(
|
|
2336
|
+
dryRun: boolean,
|
|
2337
|
+
startOnInstall = true,
|
|
2338
|
+
definitionInstallIntent?: { nonce: string; ownerPid: number },
|
|
2339
|
+
): InstallContext {
|
|
2340
|
+
const run = resolveRunCommand();
|
|
2341
|
+
const currentPath = process.env.PATH ?? "";
|
|
2342
|
+
const managedBin = pathForManagedCommand(run.execPath);
|
|
2343
|
+
const envPath = managedBin
|
|
2344
|
+
? [managedBin, ...currentPath.split(":").filter((entry) => entry !== managedBin)].join(":")
|
|
2345
|
+
: currentPath;
|
|
2346
|
+
const context: InstallContext & {
|
|
2347
|
+
definitionInstallIntent?: { nonce: string; ownerPid: number };
|
|
2348
|
+
} = {
|
|
2349
|
+
execPath: run.execPath,
|
|
2350
|
+
args: run.args,
|
|
2351
|
+
cwd: uaiHome(),
|
|
2352
|
+
dryRun,
|
|
2353
|
+
envPath,
|
|
2354
|
+
startOnInstall,
|
|
2355
|
+
definitionInstallIntent,
|
|
2356
|
+
};
|
|
2357
|
+
return context;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
/** The command that runs `uai-host run`: the installed binary if on PATH,
|
|
2361
|
+
* else `pnpm host-agent` from the repo (dev). */
|
|
2362
|
+
function resolveRunCommand(): { execPath: string; args: string[] } {
|
|
2363
|
+
const managed = managedRuntimePaths();
|
|
2364
|
+
if (existsSync(managed.current) && existsSync(managed.shim)) {
|
|
2365
|
+
return { execPath: managed.shim, args: ["run"] };
|
|
2366
|
+
}
|
|
2367
|
+
const uaiHost = which(STATION.command);
|
|
2368
|
+
if (uaiHost) return { execPath: uaiHost, args: ["run"] };
|
|
2369
|
+
return { execPath: which("pnpm") ?? "pnpm", args: ["host-agent"] };
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
function pathForManagedCommand(execPath: string): string | null {
|
|
2373
|
+
const managed = managedRuntimePaths();
|
|
2374
|
+
return resolve(execPath) === managed.shim ? dirname(managed.shim) : null;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function which(cmd: string): string | null {
|
|
2378
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
2379
|
+
const res = spawnSync(finder, [cmd], { encoding: "utf8" });
|
|
2380
|
+
if (res.status !== 0 || !res.stdout) return null;
|
|
2381
|
+
return res.stdout.split("\n")[0]?.trim() || null;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// --- helpers ----------------------------------------------------------------
|
|
2385
|
+
|
|
2386
|
+
function readPort(): number | null {
|
|
2387
|
+
const p = uiPortFilePath();
|
|
2388
|
+
if (!existsSync(p)) return null;
|
|
2389
|
+
const n = Number(readFileSync(p, "utf8").trim());
|
|
2390
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
function fmtDuration(ms: number): string {
|
|
2394
|
+
const s = Math.floor(ms / 1000);
|
|
2395
|
+
if (s < 60) return `${s}s`;
|
|
2396
|
+
const m = Math.floor(s / 60);
|
|
2397
|
+
if (m < 60) return `${m}m`;
|
|
2398
|
+
const h = Math.floor(m / 60);
|
|
2399
|
+
if (h < 24) return `${h}h ${m % 60}m`;
|
|
2400
|
+
return `${Math.floor(h / 24)}d ${h % 24}h`;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
function fmtBytes(n: number): string {
|
|
2404
|
+
if (n < 1024) return `${n} B`;
|
|
2405
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
2406
|
+
let v = n / 1024;
|
|
2407
|
+
let i = 0;
|
|
2408
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
2409
|
+
v /= 1024;
|
|
2410
|
+
i += 1;
|
|
2411
|
+
}
|
|
2412
|
+
return `${v.toFixed(1)} ${units[i]}`;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function printHelp(): void {
|
|
2416
|
+
console.log(`${bold("moondeck-station")} — Moondeck Station: runs Moondeck tasks on this machine (ADR-028)
|
|
2417
|
+
|
|
2418
|
+
run run the service in the foreground (debug)
|
|
2419
|
+
install [--dry-run] install as a per-user service for this OS
|
|
2420
|
+
[--no-start] register the service without launching it
|
|
2421
|
+
uninstall [--purge] remove service/runtime; preserve ~/.moondeck unless --purge
|
|
2422
|
+
uninstall-legacy [--purge] [--force] [--dry-run]
|
|
2423
|
+
remove the old Uai host from this machine: its service,
|
|
2424
|
+
runtime, uai-host commands and task containers; keeps
|
|
2425
|
+
~/.uai unless --purge; refuses while it runs tasks
|
|
2426
|
+
unless --force (their work is lost)
|
|
2427
|
+
start | stop control the installed service
|
|
2428
|
+
restart stop + start
|
|
2429
|
+
update accept signed updates (also resumes after rollback)
|
|
2430
|
+
rollback reactivate previous runtime; pause automatic updates
|
|
2431
|
+
moondeck-station-recover rollback
|
|
2432
|
+
roll back via previous runtime if current cannot start
|
|
2433
|
+
enrollment status report whether this machine has a complete identity
|
|
2434
|
+
status connection, service info, active tasks (same as the UI)
|
|
2435
|
+
logs [--follow] tail the service log
|
|
2436
|
+
runtime recheck probe Docker again and refresh cloud capabilities
|
|
2437
|
+
tune (macOS, sudo) raise kernel vnode/file-table ceilings for
|
|
2438
|
+
apple-container task VMs and persist them across boots
|
|
2439
|
+
aws setup [--region <r>] [--rotate]
|
|
2440
|
+
enable machine-backed tasks in YOUR AWS account: uses
|
|
2441
|
+
the admin credentials already in this shell to mint a
|
|
2442
|
+
scoped uai-host IAM key and writes the host config
|
|
2443
|
+
setup --cloud <wss-url> --enroll <token>
|
|
2444
|
+
claim this machine via an enrollment token from the web
|
|
2445
|
+
app (mints + writes the host credential); on an
|
|
2446
|
+
already-enrolled machine, attaches the token's workspace
|
|
2447
|
+
enroll <token> [--cloud <wss-url>]
|
|
2448
|
+
first-time setup, or attach this machine to another
|
|
2449
|
+
workspace (token minted on that workspace's Hosts page)
|
|
2450
|
+
pair <token> store a host token directly (manual fallback)
|
|
2451
|
+
open open the local UI in your browser
|
|
2452
|
+
`);
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
main().catch((err) => {
|
|
2456
|
+
console.error(red(err instanceof Error ? err.message : String(err)));
|
|
2457
|
+
process.exitCode = 1;
|
|
2458
|
+
});
|
|
2459
|
+
|
|
2460
|
+
// --- the old @runuai/host on this machine (Moondeck migration plan §4) ------
|
|
2461
|
+
|
|
2462
|
+
function legacyHostSeams(): LegacyHostSeams {
|
|
2463
|
+
return {
|
|
2464
|
+
home: homedir(),
|
|
2465
|
+
platform: process.platform,
|
|
2466
|
+
uid: process.getuid?.() ?? 0,
|
|
2467
|
+
inheritedEnv: process.env,
|
|
2468
|
+
userBus: systemUserBusProbe(),
|
|
2469
|
+
exec: (command, args, commandEnv) => {
|
|
2470
|
+
const result = spawnSync(command, [...args], {
|
|
2471
|
+
encoding: "utf8",
|
|
2472
|
+
timeout: 120_000,
|
|
2473
|
+
...(commandEnv ? { env: { ...commandEnv } } : {}),
|
|
2474
|
+
});
|
|
2475
|
+
return {
|
|
2476
|
+
status: result.error ? null : result.status,
|
|
2477
|
+
stdout: result.stdout ?? "",
|
|
2478
|
+
stderr: result.stderr ?? (result.error ? String(result.error) : ""),
|
|
2479
|
+
};
|
|
2480
|
+
},
|
|
2481
|
+
fetchJson: async (url) => {
|
|
2482
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
|
|
2483
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
2484
|
+
return response.json();
|
|
2485
|
+
},
|
|
2486
|
+
exists: (target) => existsSync(target),
|
|
2487
|
+
readText: (target) => {
|
|
2488
|
+
try {
|
|
2489
|
+
return readFileSync(target, "utf8");
|
|
2490
|
+
} catch {
|
|
2491
|
+
return null;
|
|
2492
|
+
}
|
|
2493
|
+
},
|
|
2494
|
+
remove: (target) => rmSync(target, { recursive: true, force: true }),
|
|
2495
|
+
which,
|
|
2496
|
+
readLegacyTaskIds: () => {
|
|
2497
|
+
// The old host's default store (its env is pinned to its defaults the
|
|
2498
|
+
// same way: legacyCommandEnv). Read only; anything wrong reads as null.
|
|
2499
|
+
const file = join(homedir(), LEGACY_HOST.homeDirName, "data", "host.sqlite");
|
|
2500
|
+
if (!existsSync(file)) return null;
|
|
2501
|
+
try {
|
|
2502
|
+
const db = new Database(file, { readonly: true, fileMustExist: true });
|
|
2503
|
+
try {
|
|
2504
|
+
const rows = db.prepare("SELECT task_id FROM uai_host_tasks").all() as Array<{ task_id: unknown }>;
|
|
2505
|
+
return new Set(rows.flatMap((row) => (typeof row.task_id === "string" ? [row.task_id] : [])));
|
|
2506
|
+
} finally {
|
|
2507
|
+
db.close();
|
|
2508
|
+
}
|
|
2509
|
+
} catch {
|
|
2510
|
+
return null;
|
|
2511
|
+
}
|
|
2512
|
+
},
|
|
2513
|
+
processCommandOf: (pid) => {
|
|
2514
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
2515
|
+
const result = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 });
|
|
2516
|
+
const command = result.status === 0 ? (result.stdout ?? "").trim() : "";
|
|
2517
|
+
return command || null;
|
|
2518
|
+
},
|
|
2519
|
+
packageNameOf: (command) =>
|
|
2520
|
+
packageNameOfCommand(command, {
|
|
2521
|
+
realpath: (target) => realpathSync(target),
|
|
2522
|
+
readText: (target) => {
|
|
2523
|
+
try {
|
|
2524
|
+
return readFileSync(target, "utf8");
|
|
2525
|
+
} catch {
|
|
2526
|
+
return null;
|
|
2527
|
+
}
|
|
2528
|
+
},
|
|
2529
|
+
}),
|
|
2530
|
+
log: (line) => console.log(line),
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
/** True (and explains why) when the old host runs here, so the caller must stop. */
|
|
2535
|
+
async function refuseWhileLegacyHostRuns(): Promise<boolean> {
|
|
2536
|
+
const reason = legacyHostRunningReason(await inspectLegacyHost(legacyHostSeams()));
|
|
2537
|
+
if (!reason) return false;
|
|
2538
|
+
console.error(red(reason));
|
|
2539
|
+
process.exitCode = LEGACY_HOST_RUNNING_EXIT_CODE;
|
|
2540
|
+
return true;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
/** `moondeck-station uninstall-legacy [--purge] [--force] [--dry-run] [--check]` */
|
|
2544
|
+
async function cmdUninstallLegacy(rest: string[]): Promise<void> {
|
|
2545
|
+
const known = new Set(["--purge", "--force", "--dry-run", "--check"]);
|
|
2546
|
+
if (rest.some((arg) => !known.has(arg)) || new Set(rest).size !== rest.length) {
|
|
2547
|
+
console.error(red("usage: moondeck-station uninstall-legacy [--purge] [--force] [--dry-run]"));
|
|
2548
|
+
process.exitCode = 1;
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
const seams = legacyHostSeams();
|
|
2552
|
+
if (rest.includes("--check")) {
|
|
2553
|
+
// For the installer: exit LEGACY_HOST_RUNNING_EXIT_CODE with the reason
|
|
2554
|
+
// when the old host runs, 0 otherwise. Changes nothing.
|
|
2555
|
+
await refuseWhileLegacyHostRuns();
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
// Run by the installer, the retry is the installer's own pipeline:
|
|
2559
|
+
// moondeck-station is not on PATH yet (scripts/host-runtime/install.sh).
|
|
2560
|
+
const installerRetry = process.env.UAI_UNINSTALL_LEGACY_FORCE_COMMAND?.trim();
|
|
2561
|
+
const result = await uninstallLegacyHost(seams, {
|
|
2562
|
+
purge: rest.includes("--purge"),
|
|
2563
|
+
force: rest.includes("--force"),
|
|
2564
|
+
dryRun: rest.includes("--dry-run"),
|
|
2565
|
+
...(installerRetry ? { forceCommand: installerRetry } : {}),
|
|
2566
|
+
});
|
|
2567
|
+
if (result.outcome === "absent") {
|
|
2568
|
+
console.log(dim("no old Uai host found on this machine"));
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
if (result.outcome === "refused") {
|
|
2572
|
+
console.error(red(result.reason));
|
|
2573
|
+
process.exitCode = 1;
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
for (const step of result.steps) console.log(dim(`$ ${step}`));
|
|
2577
|
+
for (const note of result.notes) console.log(yellow(note));
|
|
2578
|
+
console.log(
|
|
2579
|
+
rest.includes("--dry-run")
|
|
2580
|
+
? dim("dry run: nothing was changed")
|
|
2581
|
+
: green("old Uai host removed") +
|
|
2582
|
+
dim(` — next: ${STATION.command} install && ${STATION.command} enroll <token from app.moondeck.io>`),
|
|
2583
|
+
);
|
|
2584
|
+
}
|
|
2585
|
+
|