@twinsearth/w2m-dsh-plugin 2.1.11 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "2.1.11",
3
+ "version": "2.2.1",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -71,7 +71,7 @@
71
71
  "pack": "node scripts/pack.mjs",
72
72
  "reachability": "node scripts/reachability-check.mjs",
73
73
  "reachability:classify": "node scripts/reachability-classify.mjs",
74
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs test/remote-s2-capture.test.mjs test/view-capture.test.mjs test/view-frame-channel.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs test/view-session-key.test.mjs test/remote-requests.test.mjs test/relay-target.test.mjs",
74
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.test.mjs test/joint-r5-wiring.test.mjs test/p1-hardening.test.mjs test/joint-merge-engine.test.mjs test/artifact-ref-dir-sink.test.mjs test/chat-sticker-guard.test.mjs test/relay-task-progress.test.mjs test/gpw-modes.test.mjs test/joint-cli.test.mjs test/joint-command-hash.test.mjs test/joint-dispatch-audit.test.mjs test/tools-cancel-auth.test.mjs test/fleet-mirror.test.mjs test/sink-git-ssh.test.mjs test/agent-sse-watchdog.test.mjs test/version-gate.test.mjs test/joint-merge-cli.test.mjs test/joint-progress.test.mjs test/project-room-truncation.test.mjs test/chat-crypto-aad.test.mjs test/agent-artifact-delivery.test.mjs test/dataplane-sink-route.test.mjs test/artifact-sink-e2e.test.mjs test/project-room-persistence.test.mjs test/project-room-disk-persistence.test.mjs test/project-stream-route.test.mjs test/chat-v2-gate.test.mjs test/xiangqi.test.mjs test/xfer-foundation.test.mjs test/xfer-ferry.test.mjs test/xfer-publish.test.mjs test/xfer-want-block.test.mjs test/p2p-cipher-diag.test.mjs test/chacha-fallback.test.mjs test/fleet-binding.test.mjs test/fleet-home.test.mjs test/fleet-config.test.mjs test/fleet-config-route.test.mjs test/xfer-channel.test.mjs test/p2p-race.test.mjs test/path-scheduler.test.mjs test/transport.test.mjs test/iroh-sidecar.test.mjs test/discovery.test.mjs test/p2p-rtt.test.mjs test/artifact-ref-v3.test.mjs test/xfer-recovery.test.mjs test/xfer-router.test.mjs test/remote-s2-capture.test.mjs test/view-capture.test.mjs test/view-frame-channel.test.mjs test/remote-view-route.test.mjs test/remote-media.test.mjs test/view-session-key.test.mjs test/remote-requests.test.mjs test/relay-target.test.mjs",
75
75
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
76
76
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
77
77
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "2.1.11",
4
+ "version": "2.2.1",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -39,6 +39,28 @@ export function shouldRejoin({ token, fleetId, fleetKey, expectedFleetId = null
39
39
  return typeof expectedFleetId === 'string' && expectedFleetId !== '' && fleetId !== expectedFleetId;
40
40
  }
41
41
 
42
+ /**
43
+ * Choose the key a start actually binds with.
44
+ *
45
+ * `fleetKeyPolicy: 'home'` (the default in 2.2.1) is the "the leaver returns, nobody else moves"
46
+ * rule: a machine that has been in a fleet for this account uses that fleet's key again, even when
47
+ * the freshly derived account key differs. `'account'` is the v2.2.0 opt-in that always follows the
48
+ * signed-in account, and `'config'` is the explicit operator pin.
49
+ *
50
+ * @param {{configured?: string|null, account?: string|null, home?: string|null, policy?: string}} input
51
+ * @returns {{fleetKey: string|null, source: 'home'|'account'|'config'|null}}
52
+ */
53
+ export function selectFleetKey({ configured = null, account = null, home = null, policy = 'home' } = {}) {
54
+ const hasConfig = typeof configured === 'string' && configured.trim() !== '';
55
+ const hasAccount = typeof account === 'string' && account.trim() !== '';
56
+ const hasHome = typeof home === 'string' && home.trim() !== '';
57
+ if (policy === 'config' && hasConfig) return { fleetKey: configured, source: 'config' };
58
+ if (policy === 'home' && hasHome) return { fleetKey: home, source: 'home' };
59
+ if (hasAccount) return { fleetKey: account, source: 'account' };
60
+ if (hasConfig) return { fleetKey: configured, source: 'config' };
61
+ return { fleetKey: null, source: null };
62
+ }
63
+
42
64
  /**
43
65
  * Read this machine's relay record and classify its fleet binding.
44
66
  *
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `/api/w2m/fleet-config` (v2.2.1): the settings-page half of the fleet pin.
3
+ *
4
+ * GET /api/w2m/fleet-config
5
+ * → {ok, configured, policy, fleet_id, fingerprint, path}
6
+ * POST /api/w2m/fleet-config {action:'preview', fleet_key, expected_fleet_id?}
7
+ * → {ok, fleet_id, fleet_key_fingerprint, matches_expected} (writes nothing)
8
+ * POST /api/w2m/fleet-config {action:'save', fleet_key, expected_fleet_id?}
9
+ * → {ok, configured, policy, fleet_id, fingerprint, path, backup, restart_required}
10
+ *
11
+ * The raw key is never returned. `preview` is the confirmation step: the page shows the derived
12
+ * fleet id, the operator compares it with the expected id, and only then does `save` replace the
13
+ * file (with a timestamped backup and rollback).
14
+ */
15
+
16
+ import { fingerprintOf } from './operator-token.mjs';
17
+ import { readFleetConfig, writeFleetConfig } from './fleet-config.mjs';
18
+
19
+ export const FLEET_CONFIG_ROUTE_PATH = '/api/w2m/fleet-config';
20
+ const NO_STORE = { 'cache-control': 'no-store' };
21
+
22
+ /**
23
+ * @param {object} ctx Cordis context.
24
+ * @param {{file?: string|(() => string|null)}} [deps]
25
+ * @returns {{mounted: boolean, reason: string|null}}
26
+ */
27
+ export function registerFleetConfigRoute(ctx, deps = {}) {
28
+ const status = { mounted: false, reason: 'the host has no Connection service' };
29
+ if (typeof ctx?.inject !== 'function') return status;
30
+ ctx.inject(['connection'], (webCtx) => {
31
+ const registry = webCtx?.connection?.fetch;
32
+ if (registry === undefined || typeof registry.register !== 'function') {
33
+ status.reason = 'the connection service carries no fetch registry';
34
+ return;
35
+ }
36
+ const fileOf = () => (typeof deps.file === 'function' ? deps.file() : deps.file ?? null);
37
+
38
+ registry.register({
39
+ path: FLEET_CONFIG_ROUTE_PATH,
40
+ methods: ['GET', 'POST'],
41
+ requestBody: 'buffered',
42
+ fetch: async (request) => {
43
+ const file = fileOf();
44
+ if (typeof file !== 'string' || file === '') {
45
+ return Response.json({ ok: false, code: 'FLEET_CONFIG_NO_STATE', error: 'no state directory is known' }, { status: 503, headers: NO_STORE });
46
+ }
47
+
48
+ if (request.method === 'GET') {
49
+ const read = readFleetConfig(file);
50
+ if (read.ok !== true) {
51
+ return Response.json({ ok: false, code: read.code, error: read.error, path: read.path }, { status: 500, headers: NO_STORE });
52
+ }
53
+ const config = read.config;
54
+ return Response.json({
55
+ ok: true,
56
+ configured: config !== null,
57
+ policy: config?.policy ?? null,
58
+ fleet_id: config?.fleetId ?? null,
59
+ fingerprint: config === null ? null : fingerprintOf(config.fleetKey),
60
+ updated_at: config?.updatedAt ?? null,
61
+ path: file,
62
+ source: config === null ? 'none' : 'file',
63
+ }, { headers: NO_STORE });
64
+ }
65
+
66
+ let body = null;
67
+ try { body = await request.json(); } catch { /* refused below */ }
68
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
69
+ return Response.json({ ok: false, code: 'FLEET_CONFIG_BAD_BODY', error: 'send a JSON object' }, { status: 400, headers: NO_STORE });
70
+ }
71
+ const action = body.action;
72
+ if (action !== 'preview' && action !== 'save') {
73
+ return Response.json({ ok: false, code: 'FLEET_CONFIG_BAD_ACTION', error: 'action must be preview or save' }, { status: 400, headers: NO_STORE });
74
+ }
75
+ const fleetKey = typeof body.fleet_key === 'string' ? body.fleet_key.trim() : '';
76
+ if (fleetKey === '') {
77
+ return Response.json({ ok: false, code: 'FLEET_CONFIG_KEY_REQUIRED', error: 'fleet_key is required' }, { status: 400, headers: NO_STORE });
78
+ }
79
+ const { planJoin } = await import('../chatops/fleet-join.mjs');
80
+ const plan = planJoin({ fleetKeyEncoded: fleetKey });
81
+ if (plan.ok !== true) {
82
+ return Response.json({ ok: false, code: plan.reason, error: plan.text }, { status: 400, headers: NO_STORE });
83
+ }
84
+ const expected = typeof body.expected_fleet_id === 'string' ? body.expected_fleet_id.trim().toLowerCase() : '';
85
+ const matchesExpected = expected === '' ? null : expected === plan.fleetId;
86
+ if (action === 'preview') {
87
+ return Response.json({
88
+ ok: true,
89
+ action: 'preview',
90
+ fleet_id: plan.fleetId,
91
+ fleet_key_fingerprint: fingerprintOf(fleetKey),
92
+ matches_expected: matchesExpected,
93
+ }, { headers: NO_STORE });
94
+ }
95
+ if (matchesExpected === false) {
96
+ return Response.json({
97
+ ok: false,
98
+ code: 'FLEET_CONFIG_FLEET_ID_MISMATCH',
99
+ error: 'the key derives a different fleet_id than the expected one',
100
+ fleet_id: plan.fleetId,
101
+ expected_fleet_id: expected,
102
+ }, { status: 409, headers: NO_STORE });
103
+ }
104
+
105
+ const written = writeFleetConfig(file, {
106
+ fleetKey,
107
+ fleetId: plan.fleetId,
108
+ policy: 'config',
109
+ });
110
+ if (written.ok !== true) {
111
+ return Response.json({ ok: false, code: written.code, error: written.error, path: written.path }, { status: 500, headers: NO_STORE });
112
+ }
113
+ return Response.json({
114
+ ok: true,
115
+ action: 'save',
116
+ configured: true,
117
+ policy: 'config',
118
+ fleet_id: plan.fleetId,
119
+ fingerprint: fingerprintOf(fleetKey),
120
+ path: written.path,
121
+ backup: written.backup,
122
+ restart_required: true,
123
+ }, { headers: NO_STORE });
124
+ },
125
+ });
126
+ status.mounted = true;
127
+ status.reason = null;
128
+ });
129
+ return status;
130
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The operator-facing fleet pin, stored beside `device.json`.
3
+ *
4
+ * `cordis.patch.yml` is a profile layer, and asking an operator to hand-edit it is how a working
5
+ * DSH gets broken. This file is the supported alternative: the settings page writes it atomically,
6
+ * the plugin reads it at startup, and the profile YAML is never touched.
7
+ *
8
+ * The file contains an encoded fleet key, which is a credential: mode 0600 where the platform
9
+ * honours it, and the route only ever returns a fingerprint. Every write makes a timestamped
10
+ * backup first; a failed validation or rename restores that backup instead of leaving a half-written
11
+ * pin that changes the next boot.
12
+ */
13
+
14
+ import {
15
+ chmodSync,
16
+ copyFileSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ readFileSync,
20
+ renameSync,
21
+ rmSync,
22
+ writeFileSync,
23
+ } from 'node:fs';
24
+ import { dirname, join } from 'node:path';
25
+ import { deviceFilePath } from '../agent/identity.mjs';
26
+
27
+ export const FLEET_CONFIG_FILE = 'fleet-config.json';
28
+ export const FLEET_CONFIG_VERSION = 1;
29
+ export const FLEET_CONFIG_POLICIES = Object.freeze(['config', 'home', 'account']);
30
+
31
+ const MAX_KEY_LENGTH = 4096;
32
+ const FLEET_ID_RE = /^[0-9a-f]{64}$/;
33
+
34
+ /**
35
+ * Resolve the pin file the same way the plugin resolves `device.json`.
36
+ *
37
+ * @param {string|null|undefined} stateDir An explicit state directory, or null/undefined.
38
+ * @returns {string|null}
39
+ */
40
+ export function fleetConfigPathFor(stateDir) {
41
+ if (typeof stateDir === 'string' && stateDir.trim() !== '') {
42
+ return join(stateDir.trim(), FLEET_CONFIG_FILE);
43
+ }
44
+ try {
45
+ return join(dirname(deviceFilePath({})), FLEET_CONFIG_FILE);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** @param {string|null} deviceFile */
52
+ export function fleetConfigPathBeside(deviceFile) {
53
+ return typeof deviceFile === 'string' && deviceFile !== '' ? join(dirname(deviceFile), FLEET_CONFIG_FILE) : null;
54
+ }
55
+
56
+ /**
57
+ * Read the pin. A missing file is "not configured", not an error.
58
+ *
59
+ * @param {string|null} file
60
+ * @returns {{ok: true, config: null, path: string|null}
61
+ * | {ok: true, config: {fleetKey: string, policy: string, fleetId: string, updatedAt: string}, path: string}
62
+ * | {ok: false, code: string, error: string, path: string|null}}
63
+ */
64
+ export function readFleetConfig(file) {
65
+ if (file === null || file === undefined || file === '') return { ok: true, config: null, path: null };
66
+ if (!existsSync(file)) return { ok: true, config: null, path: file };
67
+
68
+ let text;
69
+ try {
70
+ text = readFileSync(file, 'utf8');
71
+ } catch (error) {
72
+ return { ok: false, code: 'FLEET_CONFIG_UNREADABLE', error: error instanceof Error ? error.message : String(error), path: file };
73
+ }
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(text);
77
+ } catch (error) {
78
+ return { ok: false, code: 'FLEET_CONFIG_CORRUPT', error: `not valid JSON: ${error instanceof Error ? error.message : String(error)}`, path: file };
79
+ }
80
+ if (parsed === null || typeof parsed !== 'object' || parsed.version !== FLEET_CONFIG_VERSION) {
81
+ return { ok: false, code: 'FLEET_CONFIG_CORRUPT', error: `expected version ${FLEET_CONFIG_VERSION}`, path: file };
82
+ }
83
+ const fleetKey = typeof parsed.fleet_key === 'string' ? parsed.fleet_key.trim() : '';
84
+ const policy = typeof parsed.fleet_key_policy === 'string' ? parsed.fleet_key_policy.trim() : '';
85
+ const fleetId = typeof parsed.fleet_id === 'string' ? parsed.fleet_id.trim().toLowerCase() : '';
86
+ if (fleetKey === '' || fleetKey.length > MAX_KEY_LENGTH) {
87
+ return { ok: false, code: 'FLEET_CONFIG_CORRUPT', error: 'fleet_key is missing or too long', path: file };
88
+ }
89
+ if (!FLEET_CONFIG_POLICIES.includes(policy)) {
90
+ return { ok: false, code: 'FLEET_CONFIG_CORRUPT', error: `fleet_key_policy must be one of ${FLEET_CONFIG_POLICIES.join(', ')}`, path: file };
91
+ }
92
+ if (!FLEET_ID_RE.test(fleetId)) {
93
+ return { ok: false, code: 'FLEET_CONFIG_CORRUPT', error: 'fleet_id is not a 64-hex SHA-256', path: file };
94
+ }
95
+ return {
96
+ ok: true,
97
+ config: {
98
+ fleetKey,
99
+ policy,
100
+ fleetId,
101
+ updatedAt: typeof parsed.updated_at === 'string' ? parsed.updated_at : '',
102
+ },
103
+ path: file,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Write a validated pin with a backup and rollback.
109
+ *
110
+ * Validation is repeated on the temporary file before it replaces the live one, so a write that
111
+ * somehow serialised wrong cannot become the next boot's configuration.
112
+ *
113
+ * @param {string|null} file
114
+ * @param {{fleetKey: string, fleetId: string, policy?: string, now?: number}} input
115
+ * @returns {{ok: true, path: string, backup: string|null}
116
+ * | {ok: false, code: string, error: string, path: string|null}}
117
+ */
118
+ export function writeFleetConfig(file, input) {
119
+ if (file === null || file === undefined || file === '') {
120
+ return { ok: false, code: 'FLEET_CONFIG_NO_PATH', error: 'no state directory is known', path: null };
121
+ }
122
+ const fleetKey = typeof input?.fleetKey === 'string' ? input.fleetKey.trim() : '';
123
+ const fleetId = typeof input?.fleetId === 'string' ? input.fleetId.trim().toLowerCase() : '';
124
+ const policy = typeof input?.policy === 'string' ? input.policy : 'config';
125
+ if (fleetKey === '' || fleetKey.length > MAX_KEY_LENGTH) {
126
+ return { ok: false, code: 'FLEET_CONFIG_BAD_KEY', error: 'fleet key is empty or too long', path: file };
127
+ }
128
+ if (!FLEET_ID_RE.test(fleetId)) {
129
+ return { ok: false, code: 'FLEET_CONFIG_BAD_FLEET_ID', error: 'fleet id is not a 64-hex SHA-256', path: file };
130
+ }
131
+ if (!FLEET_CONFIG_POLICIES.includes(policy)) {
132
+ return { ok: false, code: 'FLEET_CONFIG_BAD_POLICY', error: 'policy must be config, home or account', path: file };
133
+ }
134
+
135
+ const previous = readFleetConfig(file);
136
+ if (previous.ok !== true) return previous;
137
+
138
+ const now = Number.isInteger(input?.now) ? input.now : Date.now();
139
+ const backup = previous.config === null
140
+ ? null
141
+ : `${file}.bak-${new Date(now).toISOString().replace(/[:.]/g, '-')}`;
142
+ try {
143
+ mkdirSync(dirname(file), { recursive: true });
144
+ if (backup !== null) {
145
+ copyFileSync(file, backup);
146
+ try { chmodSync(backup, 0o600); } catch { /* Windows ACL carries it */ }
147
+ }
148
+ const tmp = `${file}.tmp-${process.pid}-${now}`;
149
+ writeFileSync(tmp, `${JSON.stringify({
150
+ version: FLEET_CONFIG_VERSION,
151
+ fleet_key: fleetKey,
152
+ fleet_key_policy: policy,
153
+ fleet_id: fleetId,
154
+ updated_at: new Date(now).toISOString(),
155
+ }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
156
+ try { chmodSync(tmp, 0o600); } catch { /* Windows ACL carries it */ }
157
+ const check = readFleetConfig(tmp);
158
+ if (check.ok !== true || check.config === null || check.config.fleetKey !== fleetKey || check.config.fleetId !== fleetId) {
159
+ rmSync(tmp, { force: true });
160
+ throw new Error(check.ok === true ? 'the written pin did not validate' : check.error);
161
+ }
162
+ try {
163
+ renameSync(tmp, file);
164
+ } catch (error) {
165
+ rmSync(tmp, { force: true });
166
+ throw error;
167
+ }
168
+ return { ok: true, path: file, backup };
169
+ } catch (error) {
170
+ // Roll back to the previous file. Without a backup, remove a partial new file rather than leave
171
+ // a half-written pin for the next boot.
172
+ try {
173
+ if (backup !== null && existsSync(backup)) copyFileSync(backup, file);
174
+ else rmSync(file, { force: true });
175
+ } catch { /* best effort: the original error is more useful */ }
176
+ return {
177
+ ok: false,
178
+ code: 'FLEET_CONFIG_WRITE_FAILED',
179
+ error: error instanceof Error ? error.message : String(error),
180
+ path: file,
181
+ };
182
+ }
183
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Per-account fleet homes: the local answer to "which fleet was this machine in last time it was
3
+ * signed in to this account?"
4
+ *
5
+ * # Why a file, and why this file
6
+ *
7
+ * The account-derived key is deterministic, but it is not a complete history. A fleet can have
8
+ * been created with an explicit key, or before account-derived keys existed, and the derived key
9
+ * then points at a different (new) fleet. Replacing the old key on every account switch is what
10
+ * made one machine's sign-in move the whole fleet -- v2.2.0's `fleetKeyPolicy: account`.
11
+ *
12
+ * The home map is deliberately **per account**: switching from A to B does not erase A's home, so
13
+ * switching back to A returns to A's fleet instead of minting another one. It is written beside
14
+ * `device.json` (same directory, same permissions concerns) and contains encoded fleet keys, which
15
+ * are credentials: the file is 0600 where the platform honours it.
16
+ *
17
+ * # Failure policy
18
+ *
19
+ * `readFleetHome` never silently treats a corrupt file as "no homes". A machine in `home` policy
20
+ * that cannot read its history must stay unbound and report the fault rather than derive a new key
21
+ * and create a new fleet -- that automatic migration is the behaviour this module exists to stop.
22
+ * The caller decides whether to block (home policy) or continue without the history (account
23
+ * policy).
24
+ */
25
+
26
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
27
+ import { dirname, join } from 'node:path';
28
+
29
+ /** The file beside `device.json`; the basename is part of the operator-visible contract. */
30
+ export const FLEET_HOME_FILE = 'fleet-home.json';
31
+
32
+ /** Bump only with a reader that still understands the previous shape. */
33
+ export const FLEET_HOME_VERSION = 1;
34
+
35
+ const ACCOUNT_CODE_RE = /^[0-9a-f]{8}$/;
36
+ const MAX_KEY_LENGTH = 4096;
37
+ const MAX_ACCOUNTS = 64;
38
+
39
+ /**
40
+ * @param {string|null} deviceFile Absolute path of `device.json`, or null when unknown.
41
+ * @returns {string|null} The path of the home file, or null when there is no state directory.
42
+ */
43
+ export function fleetHomePath(deviceFile) {
44
+ return typeof deviceFile === 'string' && deviceFile !== '' ? join(dirname(deviceFile), FLEET_HOME_FILE) : null;
45
+ }
46
+
47
+ /**
48
+ * Read the map. Missing file means "no history yet", which is a success, not an error.
49
+ *
50
+ * @param {string|null} file
51
+ * @returns {{ok: true, homes: Record<string, {fleet_key: string, updated_at: string}>, lastAccount: string|null, path: string|null}
52
+ * | {ok: false, code: string, error: string, path: string|null}}
53
+ */
54
+ export function readFleetHome(file) {
55
+ if (file === null || file === undefined || file === '') return { ok: true, homes: {}, lastAccount: null, path: null };
56
+ if (!existsSync(file)) return { ok: true, homes: {}, lastAccount: null, path: file };
57
+
58
+ let text;
59
+ try {
60
+ text = readFileSync(file, 'utf8');
61
+ } catch (error) {
62
+ return {
63
+ ok: false,
64
+ code: 'FLEET_HOME_UNREADABLE',
65
+ error: error instanceof Error ? error.message : String(error),
66
+ path: file,
67
+ };
68
+ }
69
+
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(text);
73
+ } catch (error) {
74
+ return {
75
+ ok: false,
76
+ code: 'FLEET_HOME_CORRUPT',
77
+ error: `not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
78
+ path: file,
79
+ };
80
+ }
81
+ if (parsed === null || typeof parsed !== 'object'
82
+ || parsed.version !== FLEET_HOME_VERSION
83
+ || parsed.homes === null || typeof parsed.homes !== 'object' || Array.isArray(parsed.homes)) {
84
+ return {
85
+ ok: false,
86
+ code: 'FLEET_HOME_CORRUPT',
87
+ error: `expected {version: ${FLEET_HOME_VERSION}, homes: {…}}`,
88
+ path: file,
89
+ };
90
+ }
91
+
92
+ const homes = {};
93
+ for (const [accountCode, entry] of Object.entries(parsed.homes)) {
94
+ if (!ACCOUNT_CODE_RE.test(accountCode)) {
95
+ return { ok: false, code: 'FLEET_HOME_CORRUPT', error: `bad account code ${accountCode}`, path: file };
96
+ }
97
+ if (entry === null || typeof entry !== 'object'
98
+ || typeof entry.fleet_key !== 'string' || entry.fleet_key === ''
99
+ || entry.fleet_key.length > MAX_KEY_LENGTH) {
100
+ return { ok: false, code: 'FLEET_HOME_CORRUPT', error: `bad home for ${accountCode}`, path: file };
101
+ }
102
+ homes[accountCode] = {
103
+ fleet_key: entry.fleet_key,
104
+ updated_at: typeof entry.updated_at === 'string' ? entry.updated_at : '',
105
+ };
106
+ }
107
+ if (Object.keys(homes).length > MAX_ACCOUNTS) {
108
+ return { ok: false, code: 'FLEET_HOME_CORRUPT', error: `more than ${MAX_ACCOUNTS} account homes`, path: file };
109
+ }
110
+ const lastAccount = typeof parsed.last_account === 'string' && ACCOUNT_CODE_RE.test(parsed.last_account)
111
+ ? parsed.last_account
112
+ : null;
113
+ return { ok: true, homes, lastAccount, path: file };
114
+ }
115
+
116
+ /**
117
+ * @param {Record<string, {fleet_key: string}>} homes
118
+ * @param {string|null} accountCode
119
+ * @returns {{fleet_key: string, updated_at: string}|null}
120
+ */
121
+ export function getFleetHome(homes, accountCode) {
122
+ if (typeof accountCode !== 'string' || !ACCOUNT_CODE_RE.test(accountCode)) return null;
123
+ const entry = homes?.[accountCode];
124
+ return entry !== undefined && typeof entry.fleet_key === 'string' && entry.fleet_key !== '' ? entry : null;
125
+ }
126
+
127
+ /**
128
+ * Should a machine with an existing token and no recorded home stay exactly where it is?
129
+ *
130
+ * This is the migration guard for 2.2.0 -> 2.2.1. Such a machine may be sitting in an old fleet
131
+ * whose key no longer exists in its profile; deriving the account key at that moment is precisely
132
+ * how an upgrade would move it (and, if the account is shared, tempt everyone else to follow).
133
+ * Staying put and reporting "no home key" is recoverable; silently re-pairing is not.
134
+ *
135
+ * A configured key is excluded: it is the operator's explicit seed and is used instead of the
136
+ * account key. A fresh, unpaired machine is excluded too: it has no fleet to preserve.
137
+ *
138
+ * @param {{policy?: string, hasHome?: boolean, hasConfiguredKey?: boolean, hasToken?: boolean,
139
+ * accountChanged?: boolean}} input
140
+ * @returns {boolean}
141
+ */
142
+ export function shouldPreserveExistingFleet({
143
+ policy = 'home',
144
+ hasHome = false,
145
+ hasConfiguredKey = false,
146
+ hasToken = false,
147
+ accountChanged = false,
148
+ } = {}) {
149
+ return policy === 'home'
150
+ && hasHome !== true
151
+ && hasConfiguredKey !== true
152
+ && hasToken === true
153
+ && accountChanged !== true;
154
+ }
155
+
156
+ /**
157
+ * Write the complete state atomically. Internal: callers have already validated what they add.
158
+ *
159
+ * @param {string} file
160
+ * @param {{homes: Record<string, {fleet_key: string, updated_at: string}>, lastAccount: string|null}} state
161
+ * @returns {{ok: true, path: string}|{ok: false, code: string, error: string, path: string|null}}
162
+ */
163
+ function writeFleetState(file, state) {
164
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
165
+ try {
166
+ mkdirSync(dirname(file), { recursive: true });
167
+ writeFileSync(tmp, `${JSON.stringify({
168
+ version: FLEET_HOME_VERSION,
169
+ last_account: state.lastAccount,
170
+ homes: state.homes,
171
+ }, null, 2)}\n`, {
172
+ encoding: 'utf8',
173
+ mode: 0o600,
174
+ });
175
+ try { chmodSync(tmp, 0o600); } catch { /* Windows: the file ACL carries the protection */ }
176
+ try {
177
+ renameSync(tmp, file);
178
+ } catch {
179
+ // Windows can refuse to replace a file that another handle has open. The old file is gone
180
+ // only after the copy exists, so this fallback cannot turn a failed write into no file.
181
+ rmSync(file, { force: true });
182
+ renameSync(tmp, file);
183
+ }
184
+ return { ok: true, path: file };
185
+ } catch (error) {
186
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
187
+ return {
188
+ ok: false,
189
+ code: 'FLEET_HOME_WRITE_FAILED',
190
+ error: error instanceof Error ? error.message : String(error),
191
+ path: file,
192
+ };
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Remember one account's fleet key. Other accounts are preserved verbatim, and the last account
198
+ * that successfully bound is recorded so a restart can tell "same account, no home yet" apart from
199
+ * "the account actually changed".
200
+ *
201
+ * The write is temp-file + rename in the same directory, so a crash leaves either the old complete
202
+ * map or the new complete map. A corrupt existing map is **not** overwritten: silently replacing a
203
+ * history we cannot parse is how the recovery data would be lost exactly when it is needed.
204
+ *
205
+ * @param {string|null} file
206
+ * @param {{accountCode: string, fleetKey: string, lastAccount?: string|null, now?: number}} input
207
+ * @returns {{ok: true, path: string}|{ok: false, code: string, error: string, path: string|null}}
208
+ */
209
+ export function rememberFleetHome(file, input) {
210
+ if (file === null || file === undefined || file === '') {
211
+ return { ok: false, code: 'FLEET_HOME_NO_PATH', error: 'no state directory is known', path: null };
212
+ }
213
+ const accountCode = typeof input?.accountCode === 'string' ? input.accountCode : '';
214
+ if (!ACCOUNT_CODE_RE.test(accountCode)) {
215
+ return { ok: false, code: 'FLEET_HOME_BAD_ACCOUNT', error: 'account code is not 8 hex characters', path: file };
216
+ }
217
+ const fleetKey = typeof input?.fleetKey === 'string' ? input.fleetKey : '';
218
+ if (fleetKey === '' || fleetKey.length > MAX_KEY_LENGTH) {
219
+ return { ok: false, code: 'FLEET_HOME_BAD_KEY', error: 'fleet key is empty or too long', path: file };
220
+ }
221
+ const previous = readFleetHome(file);
222
+ if (previous.ok !== true) return previous;
223
+ const homes = {
224
+ ...previous.homes,
225
+ [accountCode]: {
226
+ fleet_key: fleetKey,
227
+ updated_at: new Date(Number.isInteger(input?.now) ? input.now : Date.now()).toISOString(),
228
+ },
229
+ };
230
+ if (Object.keys(homes).length > MAX_ACCOUNTS) {
231
+ return { ok: false, code: 'FLEET_HOME_TOO_MANY', error: `more than ${MAX_ACCOUNTS} account homes`, path: file };
232
+ }
233
+ const lastAccount = typeof input?.lastAccount === 'string' && ACCOUNT_CODE_RE.test(input.lastAccount)
234
+ ? input.lastAccount
235
+ : accountCode;
236
+ if (previous.homes[accountCode]?.fleet_key === fleetKey && previous.lastAccount === lastAccount) {
237
+ return { ok: true, path: file };
238
+ }
239
+ return writeFleetState(file, { homes, lastAccount });
240
+ }
241
+
242
+ /**
243
+ * Record which account this machine last bound under, without changing any fleet key.
244
+ *
245
+ * Used by the migration guard: a paired machine that cannot derive a key still has an account
246
+ * identity, and persisting it is what lets the *next* real account switch be recognised.
247
+ *
248
+ * @param {string|null} file
249
+ * @param {string} accountCode
250
+ * @returns {{ok: true, path: string}|{ok: false, code: string, error: string, path: string|null}}
251
+ */
252
+ export function rememberFleetAccount(file, accountCode) {
253
+ if (file === null || file === undefined || file === '') {
254
+ return { ok: false, code: 'FLEET_HOME_NO_PATH', error: 'no state directory is known', path: null };
255
+ }
256
+ if (!ACCOUNT_CODE_RE.test(typeof accountCode === 'string' ? accountCode : '')) {
257
+ return { ok: false, code: 'FLEET_HOME_BAD_ACCOUNT', error: 'account code is not 8 hex characters', path: file };
258
+ }
259
+ const previous = readFleetHome(file);
260
+ if (previous.ok !== true) return previous;
261
+ if (previous.lastAccount === accountCode) return { ok: true, path: file };
262
+ return writeFleetState(file, { homes: previous.homes, lastAccount: accountCode });
263
+ }