@matter/nodejs-shell 0.17.9 → 0.18.0-alpha.0-20260812-4d7f2790e

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.
Files changed (52) hide show
  1. package/README.md +11 -1
  2. package/dist/esm/MatterNode.js +237 -134
  3. package/dist/esm/MatterNode.js.map +1 -1
  4. package/dist/esm/app.js +13 -7
  5. package/dist/esm/app.js.map +1 -1
  6. package/dist/esm/shell/cmd_cluster-attributes.js +64 -45
  7. package/dist/esm/shell/cmd_cluster-attributes.js.map +2 -2
  8. package/dist/esm/shell/cmd_cluster-commands.js +11 -9
  9. package/dist/esm/shell/cmd_cluster-commands.js.map +1 -1
  10. package/dist/esm/shell/cmd_cluster-events.js +36 -9
  11. package/dist/esm/shell/cmd_cluster-events.js.map +1 -1
  12. package/dist/esm/shell/cmd_commission.js +119 -93
  13. package/dist/esm/shell/cmd_commission.js.map +1 -1
  14. package/dist/esm/shell/cmd_discover.js +23 -21
  15. package/dist/esm/shell/cmd_discover.js.map +1 -1
  16. package/dist/esm/shell/cmd_icd.js +46 -30
  17. package/dist/esm/shell/cmd_icd.js.map +1 -1
  18. package/dist/esm/shell/cmd_identify.js +11 -7
  19. package/dist/esm/shell/cmd_identify.js.map +1 -1
  20. package/dist/esm/shell/cmd_nodes.js +189 -168
  21. package/dist/esm/shell/cmd_nodes.js.map +1 -1
  22. package/dist/esm/shell/cmd_session.js +3 -4
  23. package/dist/esm/shell/cmd_session.js.map +1 -1
  24. package/dist/esm/shell/cmd_subscribe.js +26 -12
  25. package/dist/esm/shell/cmd_subscribe.js.map +1 -1
  26. package/dist/esm/util/ClusterEndpoint.js +61 -0
  27. package/dist/esm/util/ClusterEndpoint.js.map +6 -0
  28. package/dist/esm/util/awaitSeeded.js +31 -0
  29. package/dist/esm/util/awaitSeeded.js.map +6 -0
  30. package/dist/esm/util/diagnosticLogging.js +99 -0
  31. package/dist/esm/util/diagnosticLogging.js.map +6 -0
  32. package/dist/esm/util/legacyStorageMigration.js +188 -0
  33. package/dist/esm/util/legacyStorageMigration.js.map +6 -0
  34. package/package.json +10 -11
  35. package/src/MatterNode.ts +298 -159
  36. package/src/app.ts +17 -9
  37. package/src/shell/cmd_cluster-attributes.ts +77 -53
  38. package/src/shell/cmd_cluster-commands.ts +12 -13
  39. package/src/shell/cmd_cluster-events.ts +38 -11
  40. package/src/shell/cmd_commission.ts +136 -113
  41. package/src/shell/cmd_discover.ts +36 -22
  42. package/src/shell/cmd_icd.ts +52 -32
  43. package/src/shell/cmd_identify.ts +11 -7
  44. package/src/shell/cmd_nodes.ts +226 -199
  45. package/src/shell/cmd_session.ts +3 -5
  46. package/src/shell/cmd_subscribe.ts +37 -14
  47. package/src/shell/webassets/index.html +1 -1
  48. package/src/tsconfig.json +0 -3
  49. package/src/util/ClusterEndpoint.ts +107 -0
  50. package/src/util/awaitSeeded.ts +43 -0
  51. package/src/util/diagnosticLogging.ts +117 -0
  52. package/src/util/legacyStorageMigration.ts +275 -0
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
+ import { SessionsBehavior } from "@matter/node";
7
8
  import { MatterNode } from "../MatterNode.js";
8
9
 
9
10
  export default function commands(theNode: MatterNode) {
@@ -12,11 +13,8 @@ export default function commands(theNode: MatterNode) {
12
13
  describe: "Manage session",
13
14
  builder: {},
14
15
  handler: async () => {
15
- if (!theNode.commissioningController) {
16
- throw new Error("CommissioningController not initialized");
17
- }
18
-
19
- const sessions = theNode.commissioningController?.getActiveSessionInformation();
16
+ await theNode.start();
17
+ const sessions = Object.values(theNode.node.stateOf(SessionsBehavior).sessions);
20
18
  console.log(sessions);
21
19
  },
22
20
  };
@@ -4,10 +4,13 @@
4
4
  * SPDX-License-Identifier: Apache-2.0
5
5
  */
6
6
 
7
- import { Diagnostic } from "@matter/general";
7
+ import { ImplementationError, ObserverGroup } from "@matter/general";
8
+ import { NetworkClient } from "@matter/node";
8
9
  import type { Argv } from "yargs";
9
10
  import { MatterNode } from "../MatterNode.js";
10
11
 
12
+ const watchers = new Map<string, ObserverGroup>();
13
+
11
14
  export default function commands(theNode: MatterNode) {
12
15
  return {
13
16
  command: "subscribe [node-id]",
@@ -23,21 +26,41 @@ export default function commands(theNode: MatterNode) {
23
26
  handler: async (argv: any) => {
24
27
  const { nodeId: subscribeNodeId } = argv;
25
28
  const node = (await theNode.connectAndGetNodes(subscribeNodeId))[0];
29
+ if (node === undefined) {
30
+ throw new ImplementationError("No commissioned node to subscribe to");
31
+ }
32
+ const nodeId = node.peerAddress?.nodeId;
33
+ if (nodeId === undefined) {
34
+ throw new ImplementationError("Resolved node has no peer address to subscribe to");
35
+ }
36
+ const key = String(nodeId);
37
+
38
+ // Re-subscribing the same node replaces its watcher so change lines are not logged twice.
39
+ watchers.get(key)?.close();
40
+ watchers.delete(key);
41
+ const observers = new ObserverGroup();
42
+ watchers.set(key, observers);
43
+ observers.on(node.lifecycle.destroyed, () => {
44
+ watchers.get(key)?.close();
45
+ watchers.delete(key);
46
+ });
26
47
 
27
- await node.subscribeAllAttributesAndEvents({
28
- attributeChangedCallback: ({ path: { nodeId, clusterId, endpointId, attributeName }, value }) =>
29
- console.log(
30
- `${subscribeNodeId}: Attribute ${nodeId}/${endpointId}/${clusterId}/${attributeName} changed to ${Diagnostic.json(
31
- value,
32
- )}`,
33
- ),
34
- eventTriggeredCallback: ({ path: { nodeId, clusterId, endpointId, eventName }, events }) =>
35
- console.log(
36
- `${subscribeNodeId} Event ${nodeId}/${endpointId}/${clusterId}/${eventName} triggered with ${Diagnostic.json(
37
- events,
38
- )}`,
39
- ),
48
+ // Attribute/event/structure changes for every peer are logged node-wide by installDiagnosticLogging
49
+ // (MatterNode.start); this command only establishes the subscription and reports its liveness.
50
+
51
+ // Surface subscription liveness transitions (establishment, drop, re-establishment).
52
+ observers.on(node.eventsOf(NetworkClient).subscriptionStatusChanged, isActive => {
53
+ console.log(`${nodeId}: subscription ${isActive ? "active" : "inactive"}`);
40
54
  });
55
+
56
+ // Enabling auto-subscribe establishes (and thereafter re-establishes) the sustained subscription; changes
57
+ // flow to the already-registered listener above.
58
+ await node.set({ network: { autoSubscribe: true } });
59
+
60
+ const subscriptionActive = await node.act(agent => agent.get(NetworkClient).subscriptionActive);
61
+ console.log(
62
+ `Subscribed to node ${nodeId} (subscription active: ${subscriptionActive}). Attribute and event changes will be logged below as they arrive.`,
63
+ );
41
64
  },
42
65
  };
43
66
  }
@@ -285,7 +285,7 @@
285
285
  logMessage(`Received: "${trimmed}"`, trimmed.toLowerCase().includes('error') ? 'error' : 'received');
286
286
 
287
287
  // result of nodes log command
288
- let matches = message.match(/INFO\s+PairedNode [^0-9]*(\d+)\D*/);
288
+ let matches = message.match(/Logging structure of Node\s+(\d+)/);
289
289
  if (matches) { // could save this message of extensive node data for later use
290
290
  let currentNode = matches[1];
291
291
  let currentEndpoint = null;
package/src/tsconfig.json CHANGED
@@ -11,9 +11,6 @@
11
11
  {
12
12
  "path": "../../general/src"
13
13
  },
14
- {
15
- "path": "../../matter.js/src"
16
- },
17
14
  {
18
15
  "path": "../../model/src"
19
16
  },
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022-2026 Matter.js Authors
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { ClientNode, ClusterBehavior, Endpoint } from "@matter/node";
8
+ import { Read, ReadResult } from "@matter/protocol";
9
+ import { AttributePath, ClusterId, EventPath, Status } from "@matter/types";
10
+ import { MatterNode } from "../MatterNode.js";
11
+ import { awaitSeeded } from "./awaitSeeded.js";
12
+
13
+ export interface ResolvedClusterEndpoint {
14
+ node: ClientNode;
15
+ endpoint: Endpoint;
16
+ behaviorType: ClusterBehavior.Type;
17
+ }
18
+
19
+ /**
20
+ * Connect to `nodeIdStr`, wait for the peer's endpoint structure to seed, and resolve the behavior implementing
21
+ * `clusterId` on `endpointId`. Mirrors the legacy `getDeviceById(endpointId)?.getClusterClientById(clusterId)` lookup
22
+ * (same "not found" message on any failure) so the three cluster-access command files can share one gate.
23
+ */
24
+ export async function resolveClusterEndpoint(
25
+ theNode: MatterNode,
26
+ nodeIdStr: string,
27
+ endpointId: number,
28
+ clusterId: number,
29
+ ): Promise<ResolvedClusterEndpoint | undefined> {
30
+ const node = (await theNode.connectAndGetNodes(nodeIdStr))[0];
31
+ if (!(await awaitSeeded(node))) {
32
+ return undefined;
33
+ }
34
+
35
+ const behaviorType = node.endpoints.has(endpointId)
36
+ ? node.endpoints.for(endpointId).behaviors.forCluster(ClusterId(clusterId))
37
+ : undefined;
38
+ if (behaviorType === undefined) {
39
+ console.log(`ERROR: Cluster ${node.peerAddress?.nodeId}/${endpointId}/${clusterId} not found.`);
40
+ return undefined;
41
+ }
42
+ return { node, endpoint: node.endpoints.for(endpointId), behaviorType };
43
+ }
44
+
45
+ /**
46
+ * True only when `name` is a *known-absent* attribute/command of `behaviorType` on `endpoint`.
47
+ *
48
+ * The supported set derives from the behavior's global attribute/command list, which is EMPTY until that list has
49
+ * been read (a narrow window right after connect). An empty set is treated as "not yet known" — the caller proceeds
50
+ * to the live read/invoke rather than false-rejecting a genuinely supported element. Only a populated set that omits
51
+ * `name` is a real "unsupported" answer.
52
+ */
53
+ export function elementKnownUnsupported(
54
+ endpoint: Endpoint,
55
+ behaviorType: ClusterBehavior.Type,
56
+ kind: "attributes" | "commands",
57
+ name: string,
58
+ ): boolean {
59
+ const elements = endpoint.behaviors.elementsOf(behaviorType)[kind];
60
+ return elements.size > 0 && !elements.has(name);
61
+ }
62
+
63
+ /**
64
+ * Live (un-cached) attribute read via the interaction protocol. Used for `--remote` reads and for the numeric
65
+ * by-id command, which (like its legacy predecessor) must work for clusters the node has no behavior for.
66
+ */
67
+ export async function readAttributesRemote(
68
+ node: ClientNode,
69
+ attributes: AttributePath[],
70
+ isFabricFiltered: boolean,
71
+ ): Promise<ReadResult.AttributeValue[]> {
72
+ const values = new Array<ReadResult.AttributeValue>();
73
+ for await (const chunk of node.interaction.read(Read({ attributes, fabricFilter: isFabricFiltered }))) {
74
+ for await (const report of chunk) {
75
+ if (report.kind === "attr-value") {
76
+ values.push(report);
77
+ } else if (report.kind === "attr-status") {
78
+ // A device-declined read (e.g. UnsupportedAttribute/UnsupportedAccess) otherwise reads as empty.
79
+ const { path, status } = report;
80
+ console.log(
81
+ `ERROR: ${path.endpointId}/${path.clusterId}/${path.attributeId}: ${Status[status] ?? status}`,
82
+ );
83
+ }
84
+ }
85
+ }
86
+ return values;
87
+ }
88
+
89
+ /** Live event read via the interaction protocol (there is no local event cache to read from instead). */
90
+ export async function readEventsRemote(
91
+ node: ClientNode,
92
+ events: EventPath[],
93
+ isFabricFiltered: boolean,
94
+ ): Promise<ReadResult.EventValue[]> {
95
+ const values = new Array<ReadResult.EventValue>();
96
+ for await (const chunk of node.interaction.read(Read({ events, fabricFilter: isFabricFiltered }))) {
97
+ for await (const report of chunk) {
98
+ if (report.kind === "event-value") {
99
+ values.push(report);
100
+ } else if (report.kind === "event-status") {
101
+ const { path, status } = report;
102
+ console.log(`ERROR: ${path.endpointId}/${path.clusterId}/${path.eventId}: ${Status[status] ?? status}`);
103
+ }
104
+ }
105
+ }
106
+ return values;
107
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022-2026 Matter.js Authors
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { Duration, ObserverGroup, Seconds, Time } from "@matter/general";
8
+ import { ClientNode } from "@matter/node";
9
+
10
+ const DEFAULT_SEEDED_TIMEOUT = Seconds(60);
11
+
12
+ /**
13
+ * Wait (bounded) for a peer's endpoint structure to seed before reading it.
14
+ *
15
+ * Legacy `await node.events.initialized` blocked forever for an offline peer; the direct replacement
16
+ * `await node.lifecycle.seeded` inherits that hang. This bounds the wait: it returns `true` once the node is seeded
17
+ * and, on timeout, prints an "offline?" notice and returns `false` so the caller can abort the command instead of
18
+ * hanging.
19
+ */
20
+ export async function awaitSeeded(
21
+ node: ClientNode,
22
+ { timeout = DEFAULT_SEEDED_TIMEOUT, quiet = false }: { timeout?: Duration; quiet?: boolean } = {},
23
+ ): Promise<boolean> {
24
+ if (node.lifecycle.isSeeded) {
25
+ return true;
26
+ }
27
+
28
+ const observers = new ObserverGroup();
29
+ const sleep = Time.sleep("awaitSeeded", timeout);
30
+ try {
31
+ const seeded = new Promise<void>(resolve => observers.on(node.lifecycle.seeded, () => resolve()));
32
+ const isSeeded = await Promise.race([seeded.then(() => true), sleep.then(() => false)]);
33
+ if (!isSeeded && !quiet) {
34
+ console.log(
35
+ `Node ${node.peerAddress?.nodeId} did not become ready within ${Duration.format(timeout)} (offline?); giving up.`,
36
+ );
37
+ }
38
+ return isSeeded;
39
+ } finally {
40
+ sleep.cancel();
41
+ observers.close();
42
+ }
43
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022-2026 Matter.js Authors
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { Diagnostic, ObserverGroup } from "@matter/general";
8
+ import {
9
+ ChangeNotificationService,
10
+ ClientNode,
11
+ ClusterBehavior,
12
+ Endpoint,
13
+ NodeConnectionState,
14
+ ServerNode,
15
+ } from "@matter/node";
16
+
17
+ /** True if `endpoint` belongs to `node`'s endpoint tree (the node itself is its own root endpoint). */
18
+ function ownedBy(endpoint: Endpoint, node: Endpoint) {
19
+ for (let e: Endpoint | undefined = endpoint; e !== undefined; e = e.owner) {
20
+ if (e === node) {
21
+ return true;
22
+ }
23
+ }
24
+ return false;
25
+ }
26
+
27
+ function connectionStateLabel(state: NodeConnectionState) {
28
+ switch (state) {
29
+ case NodeConnectionState.Connected:
30
+ return "connected";
31
+ case NodeConnectionState.Disconnected:
32
+ return "disconnected";
33
+ case NodeConnectionState.Reconnecting:
34
+ return "reconnecting";
35
+ case NodeConnectionState.WaitingForDeviceDiscovery:
36
+ return "waiting for device to be discovered again";
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Wire node-wide diagnostic logging once for the controller and all its peers.
42
+ *
43
+ * Replaces the legacy per-connect `createDiagnosticCallbacks` (attribute/event/state callbacks passed into each
44
+ * `PairedNode.connect`), which the ClientNode API dropped. A single aggregate {@link ChangeNotificationService} stream
45
+ * covers attribute/event changes for every peer, and each peer's connection-state transitions are logged from its
46
+ * lifecycle. Registered handlers are owned by `observers` and torn down when it closes.
47
+ */
48
+ export function installDiagnosticLogging(node: ServerNode, observers: ObserverGroup): void {
49
+ observers.on(node.env.get(ChangeNotificationService).change, change => {
50
+ const { endpoint } = change;
51
+ const peer = node.peers.commissioned.find(peer => ownedBy(endpoint, peer));
52
+ if (peer === undefined) {
53
+ return; // A change on the controller's own node, not a peer.
54
+ }
55
+ const nodeId = peer.peerAddress?.nodeId;
56
+
57
+ switch (change.kind) {
58
+ case "update": {
59
+ const { behavior, properties, version } = change;
60
+ if (!ClusterBehavior.is(behavior)) {
61
+ break;
62
+ }
63
+ const state = endpoint.stateOf(behavior.id);
64
+ const changed =
65
+ properties === undefined ? state : Object.fromEntries(properties.map(name => [name, state[name]]));
66
+ console.log(
67
+ `Node ${nodeId}: Attribute ${endpoint.number}/${behavior.cluster.id} changed to ${Diagnostic.json(changed)} (version ${version})`,
68
+ );
69
+ break;
70
+ }
71
+ case "event": {
72
+ const { behavior, event, number, timestamp, priority, payload } = change;
73
+ if (!ClusterBehavior.is(behavior)) {
74
+ break;
75
+ }
76
+ console.log(
77
+ `Node ${nodeId}: Event ${endpoint.number}/${behavior.cluster.id}/${event.propertyName} (#${number}, priority ${priority}, at ${timestamp}) triggered with ${Diagnostic.json(payload)}`,
78
+ );
79
+ break;
80
+ }
81
+ case "delete": {
82
+ console.log(`Node ${nodeId}: Endpoint ${endpoint.number} removed`);
83
+ break;
84
+ }
85
+ }
86
+ });
87
+
88
+ // Log each commissioned peer's connection-state transitions, torn down when the peer is removed.
89
+ // peers.added/deleted fire for every node in the container (including transient discovery and group nodes),
90
+ // so key the handlers by node to remove them on cull and skip nodes without a peer address.
91
+ const connectionHandlers = new Map<ClientNode, (state: NodeConnectionState) => void>();
92
+ const watchConnection = (peer: ClientNode) => {
93
+ if (connectionHandlers.has(peer)) {
94
+ return;
95
+ }
96
+ const handler = (state: NodeConnectionState) => {
97
+ const nodeId = peer.peerAddress?.nodeId;
98
+ if (nodeId !== undefined) {
99
+ console.log(`Node ${nodeId} ${connectionStateLabel(state)}`);
100
+ }
101
+ };
102
+ connectionHandlers.set(peer, handler);
103
+ peer.lifecycle.connectionStateChanged.on(handler);
104
+ };
105
+ const unwatchConnection = (peer: ClientNode) => {
106
+ const handler = connectionHandlers.get(peer);
107
+ if (handler !== undefined) {
108
+ peer.lifecycle.connectionStateChanged.off(handler);
109
+ connectionHandlers.delete(peer);
110
+ }
111
+ };
112
+ for (const peer of node.peers.commissioned) {
113
+ watchConnection(peer);
114
+ }
115
+ observers.on(node.peers.added, watchConnection);
116
+ observers.on(node.peers.deleted, unwatchConnection);
117
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2022-2026 Matter.js Authors
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ Environment,
9
+ isObject,
10
+ Logger,
11
+ StorageManager,
12
+ StorageService,
13
+ SupportedStorageTypes,
14
+ Time,
15
+ } from "@matter/general";
16
+ import { ClientNode, NetworkClient, RemoteDescriptor, ServerNode, ServerNodeStore } from "@matter/node";
17
+ import { DiscoveryData, OperationalAddress, PeerAddress } from "@matter/protocol";
18
+ import { EventNumber, FabricIndex, NodeId } from "@matter/types";
19
+
20
+ const logger = Logger.get("LegacyStorageMigration");
21
+
22
+ /** A pre-0.16 `credentials` entry (fabric config or CA key material); relocated verbatim, never decoded. */
23
+ type LegacyCredentialRecord = Record<string, SupportedStorageTypes>;
24
+
25
+ /**
26
+ * A pre-0.16 `fabrics` entry; only `fabricIndex` is read here, the rest was relocated verbatim in step 1.
27
+ * Intersected with `Record<string, SupportedStorageTypes>` (rather than a plain interface) so the type still
28
+ * satisfies `StorageContext.get`'s `SupportedStorageTypes` constraint.
29
+ */
30
+ type LegacyFabricRecord = Record<string, SupportedStorageTypes> & { fabricIndex: number };
31
+
32
+ /** Per-node metadata carried in a `commissionedNodes` list entry. `deviceData` is intentionally not migrated. */
33
+ type LegacyCommissionedEntry = Record<string, SupportedStorageTypes> & {
34
+ operationalServerAddress?: OperationalAddress;
35
+ discoveryData?: DiscoveryData;
36
+ };
37
+
38
+ /** One `commissionedNodes` list entry: the peer's raw stored node id paired with its metadata. */
39
+ type LegacyCommissionedNode = [bigint, LegacyCommissionedEntry];
40
+
41
+ /** True for a pre-0.16 cached attribute record (current storage keeps the bare value instead). */
42
+ function isLegacyAttributeRecord(value: SupportedStorageTypes): value is Record<string, SupportedStorageTypes> {
43
+ return isObject(value) && "value" in value;
44
+ }
45
+
46
+ async function stepOneNeeded(mgr: StorageManager): Promise<boolean> {
47
+ const credentials = mgr.createContext("credentials");
48
+ if (!(await credentials.has("fabric"))) {
49
+ return false;
50
+ }
51
+ const fabrics = await mgr.createContext("fabrics").get<LegacyCredentialRecord[]>("fabrics", []);
52
+ return fabrics.length === 0;
53
+ }
54
+
55
+ async function stepTwoNeeded(mgr: StorageManager): Promise<boolean> {
56
+ const nodes = mgr.createContext("nodes");
57
+ const commissioned = await nodes.get<SupportedStorageTypes[]>("commissionedNodes", []);
58
+ if (commissioned.length === 0) {
59
+ return false;
60
+ }
61
+ // Current-format storage keys each peer under its own subcontext of "nodes".
62
+ return (await nodes.contexts()).length === 0;
63
+ }
64
+
65
+ /**
66
+ * True when a pre-0.16 controller storage still needs migrating to the current layout.
67
+ * See docs/MIGRATION_CONTROLLER_018.md.
68
+ */
69
+ export async function legacyMigrationNeeded(env: Environment, id: string): Promise<boolean> {
70
+ const mgr = await env.get(StorageService).open(id);
71
+ try {
72
+ return (await stepOneNeeded(mgr)) || (await stepTwoNeeded(mgr));
73
+ } finally {
74
+ await closeStorage(mgr, id);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Step 1: relayout fabric + certificate authority data from the legacy `credentials` context into the
80
+ * `fabrics`/`certificates` contexts the current controller reads. Must run before the controller ServerNode is
81
+ * constructed. Idempotent.
82
+ */
83
+ export async function migrateLegacyControllerCredentials(env: Environment, id: string): Promise<void> {
84
+ const mgr = await env.get(StorageService).open(id);
85
+ try {
86
+ if (!(await stepOneNeeded(mgr))) {
87
+ logger.debug(`No legacy controller credentials to migrate for store ${id}`);
88
+ return;
89
+ }
90
+
91
+ const credentials = mgr.createContext("credentials");
92
+ const fabricStore = mgr.createContext("fabrics");
93
+ const certificates = mgr.createContext("certificates");
94
+
95
+ let fabric: LegacyCredentialRecord | undefined;
96
+ for (const key of await credentials.keys()) {
97
+ if (key === "fabric") {
98
+ fabric = await credentials.get<LegacyCredentialRecord>("fabric");
99
+ } else if (!(await certificates.has(key))) {
100
+ await certificates.set(key, await credentials.get<SupportedStorageTypes>(key));
101
+ }
102
+ }
103
+
104
+ // Certificate authority keys must land in `certificates` before `fabrics.fabrics` is written: a crash
105
+ // between the two would otherwise strand the CA under the old layout while the guard reports "migrated".
106
+ if (fabric !== undefined) {
107
+ await fabricStore.set("fabrics", [fabric]);
108
+ }
109
+ logger.info(`Migrated legacy controller credentials for store ${id}`);
110
+ } finally {
111
+ await closeStorage(mgr, id);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Step 2: migrate cached per-node data (`node-<id>` attribute trees + the `commissionedNodes` list) into the
117
+ * current per-peer store layout, and register each peer. Must run after the controller ServerNode is constructed
118
+ * (so its fabric is loaded) but before it goes online. Non-destructive — old data is left in place for
119
+ * {@link cleanupLegacyStorage}. Idempotent (skips peers that already exist); a peer that fails to migrate is
120
+ * skipped without aborting the rest and its partially-written store is cleared. Returns migrated counts.
121
+ */
122
+ export async function migrateLegacyCommissionedNodes(
123
+ node: ServerNode,
124
+ ): Promise<{ nodes: number; endpoints: number; failed: number }> {
125
+ const serverStore = node.env.get(ServerNodeStore);
126
+ const baseStorage = serverStore.storage;
127
+ const nodesCtx = baseStorage.createContext("nodes");
128
+
129
+ // Per-peer idempotency is handled below; this only needs to know whether there is legacy data left to
130
+ // look at, so a resume after a partial run isn't skipped.
131
+ if (!(await nodesCtx.has("commissionedNodes"))) {
132
+ logger.debug("No former commissioned nodes to migrate.");
133
+ return { nodes: 0, endpoints: 0, failed: 0 };
134
+ }
135
+ const commissioned = await nodesCtx.get<LegacyCommissionedNode[]>("commissionedNodes", []);
136
+
137
+ // A migrated Era-B store carries exactly one fabric. Anything else (no fabric yet, or an unexpected
138
+ // multi-fabric store) cannot be addressed here; skip rather than abort the shell's startup. Report the
139
+ // skipped peers as failed so the caller does not treat this as a clean run and proceed to delete them.
140
+ const fabrics = await baseStorage.createContext("fabrics").get<LegacyFabricRecord[]>("fabrics", []);
141
+ if (fabrics.length !== 1) {
142
+ logger.warn(`Skipping legacy node migration: expected exactly one migrated fabric, found ${fabrics.length}`);
143
+ return { nodes: 0, endpoints: 0, failed: commissioned.length };
144
+ }
145
+ const fabricIndex = FabricIndex(fabrics[0].fabricIndex);
146
+
147
+ let migratedNodes = 0;
148
+ let migratedEndpoints = 0;
149
+ let failedNodes = 0;
150
+
151
+ for (const [rawNodeId, { operationalServerAddress, discoveryData }] of commissioned) {
152
+ const nodeId = NodeId(rawNodeId);
153
+ const peerAddress = PeerAddress({ fabricIndex, nodeId });
154
+
155
+ const existingPeer = node.peers.get(peerAddress);
156
+ if (existingPeer !== undefined) {
157
+ logger.debug(`Node ${nodeId} already migrated, skipping`);
158
+ if (existingPeer.stateOf(NetworkClient).autoSubscribe) {
159
+ await existingPeer.setStateOf(NetworkClient, { autoSubscribe: false });
160
+ }
161
+ continue;
162
+ }
163
+
164
+ const id = serverStore.clientStores.allocateId();
165
+ let peerNode: ClientNode | undefined;
166
+ try {
167
+ const oldNode = baseStorage.createContext(`node-${nodeId}`);
168
+
169
+ const maxEventNumber = await oldNode.get<EventNumber>("__maxEventNumber__", EventNumber(0));
170
+
171
+ // Written before forAddress() below allocates the peer's store for the same id, which loads rather
172
+ // than overwrites whatever is already on disk under that context.
173
+ const peerStorage = nodesCtx.createContext(id).createContext("endpoints");
174
+ let endpointsForNode = 0;
175
+ for (const ep of await oldNode.contexts()) {
176
+ const oldEndpoint = oldNode.createContext(ep);
177
+ const newEndpoint = peerStorage.createContext(ep);
178
+ for (const cluster of await oldEndpoint.contexts()) {
179
+ const oldCluster = oldEndpoint.createContext(cluster);
180
+ const newCluster = newEndpoint.createContext(cluster);
181
+ for (const key of await oldCluster.keys()) {
182
+ const value = await oldCluster.get(key);
183
+ if (key === "__version__") {
184
+ await newCluster.set(key, value);
185
+ } else if (isLegacyAttributeRecord(value)) {
186
+ await newCluster.set(key, value.value);
187
+ }
188
+ }
189
+ }
190
+ endpointsForNode++;
191
+ }
192
+
193
+ const commissioning = RemoteDescriptor.toLongForm({
194
+ // Fallback only — a peer with its own discoveredAt keeps it.
195
+ discoveredAt: Time.nowMs,
196
+ ...discoveryData,
197
+ addresses: operationalServerAddress ? [operationalServerAddress] : [],
198
+ });
199
+
200
+ peerNode = await node.peers.forAddress(peerAddress, { id });
201
+ await peerNode.set({ commissioning, network: { maxEventNumber, autoSubscribe: false } });
202
+ migratedNodes++;
203
+ migratedEndpoints += endpointsForNode;
204
+ logger.info(`Migrated commissioned node ${nodeId} as ${id}`);
205
+ } catch (error) {
206
+ failedNodes++;
207
+ logger.error(`Failed to migrate commissioned node ${nodeId} as ${id}, skipping:`, error);
208
+ if (peerNode !== undefined) {
209
+ await peerNode.delete();
210
+ }
211
+ await nodesCtx.createContext(id).clearAll();
212
+ }
213
+ }
214
+
215
+ return { nodes: migratedNodes, endpoints: migratedEndpoints, failed: failedNodes };
216
+ }
217
+
218
+ /**
219
+ * Irreversible: once the legacy artifacts are deleted there is no way back, so this must only be run when the
220
+ * operator is certain migration succeeded and the store will not be downgraded below 0.16.
221
+ *
222
+ * Deletes all legacy (pre-0.16) storage artifacts once migration is no longer needed (the same
223
+ * `stepOneNeeded`/`stepTwoNeeded` predicate {@link legacyMigrationNeeded} uses). It does not verify that every
224
+ * individual peer migrated successfully: this is an explicit, user-confirmed action (e.g. the shell's
225
+ * `--cleanup-legacy-storage` flag). Running it against a store where step 1 or step 2 has not fully completed
226
+ * loses the un-migrated data; that is accepted, and is the caller's responsibility. Idempotent.
227
+ */
228
+ export async function cleanupLegacyStorage(env: Environment, id: string): Promise<void> {
229
+ const mgr = await env.get(StorageService).open(id);
230
+ try {
231
+ if ((await stepOneNeeded(mgr)) || (await stepTwoNeeded(mgr))) {
232
+ logger.warn(`Refusing legacy cleanup for store ${id}: migration has not completed`);
233
+ return;
234
+ }
235
+ await wipeLegacyContexts(mgr);
236
+ logger.info(`Removed legacy storage artifacts for store ${id}`);
237
+ } finally {
238
+ await closeStorage(mgr, id);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Unconditionally remove the legacy (pre-0.16) storage artifacts, no migration-state guard.
244
+ *
245
+ * A factory reset clears the current-format fabric but not the legacy source; leaving it would let the next boot
246
+ * re-migrate and resurrect the identity/key material the reset destroyed. The reset path therefore wipes the
247
+ * legacy source too. Unlike {@link cleanupLegacyStorage} this makes no completeness assumption — the reset is
248
+ * discarding everything regardless.
249
+ */
250
+ export async function eraseLegacyStorage(env: Environment, id: string): Promise<void> {
251
+ const mgr = await env.get(StorageService).open(id);
252
+ try {
253
+ await wipeLegacyContexts(mgr);
254
+ } finally {
255
+ await closeStorage(mgr, id);
256
+ }
257
+ }
258
+
259
+ async function wipeLegacyContexts(mgr: StorageManager): Promise<void> {
260
+ for (const context of await mgr.driver.contexts([])) {
261
+ if (context.startsWith("node-")) {
262
+ await mgr.createContext(context).clearAll();
263
+ }
264
+ }
265
+ await mgr.createContext("nodes").delete("commissionedNodes");
266
+ await mgr.createContext("credentials").clearAll();
267
+ }
268
+
269
+ async function closeStorage(mgr: StorageManager, id: string): Promise<void> {
270
+ try {
271
+ await mgr.close();
272
+ } catch (closeError) {
273
+ logger.warn(`Error closing storage for store ${id}:`, closeError);
274
+ }
275
+ }