@feralfile/cli 1.1.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -0
  3. package/config.json.example +96 -0
  4. package/dist/index.js +54 -0
  5. package/dist/src/ai-orchestrator/index.js +1019 -0
  6. package/dist/src/ai-orchestrator/registry.js +96 -0
  7. package/dist/src/commands/build.js +69 -0
  8. package/dist/src/commands/chat.js +189 -0
  9. package/dist/src/commands/config.js +68 -0
  10. package/dist/src/commands/device.js +278 -0
  11. package/dist/src/commands/helpers/config-files.js +62 -0
  12. package/dist/src/commands/helpers/device-discovery.js +111 -0
  13. package/dist/src/commands/helpers/playlist-display.js +161 -0
  14. package/dist/src/commands/helpers/prompt.js +65 -0
  15. package/dist/src/commands/helpers/ssh-helpers.js +44 -0
  16. package/dist/src/commands/play.js +110 -0
  17. package/dist/src/commands/publish.js +115 -0
  18. package/dist/src/commands/setup.js +225 -0
  19. package/dist/src/commands/sign.js +41 -0
  20. package/dist/src/commands/ssh.js +108 -0
  21. package/dist/src/commands/status.js +126 -0
  22. package/dist/src/commands/validate.js +18 -0
  23. package/dist/src/config.js +441 -0
  24. package/dist/src/intent-parser/index.js +1382 -0
  25. package/dist/src/intent-parser/utils.js +108 -0
  26. package/dist/src/logger.js +82 -0
  27. package/dist/src/main.js +459 -0
  28. package/dist/src/types.js +5 -0
  29. package/dist/src/utilities/address-validator.js +242 -0
  30. package/dist/src/utilities/device-default.js +36 -0
  31. package/dist/src/utilities/device-lookup.js +107 -0
  32. package/dist/src/utilities/device-normalize.js +62 -0
  33. package/dist/src/utilities/device-upsert.js +91 -0
  34. package/dist/src/utilities/domain-resolver.js +291 -0
  35. package/dist/src/utilities/ed25519-key-derive.js +155 -0
  36. package/dist/src/utilities/feed-fetcher.js +471 -0
  37. package/dist/src/utilities/ff1-compatibility.js +269 -0
  38. package/dist/src/utilities/ff1-device.js +250 -0
  39. package/dist/src/utilities/ff1-discovery.js +330 -0
  40. package/dist/src/utilities/functions.js +308 -0
  41. package/dist/src/utilities/index.js +469 -0
  42. package/dist/src/utilities/nft-indexer.js +1024 -0
  43. package/dist/src/utilities/playlist-builder.js +523 -0
  44. package/dist/src/utilities/playlist-publisher.js +131 -0
  45. package/dist/src/utilities/playlist-send.js +260 -0
  46. package/dist/src/utilities/playlist-signer.js +204 -0
  47. package/dist/src/utilities/playlist-signing-role.js +41 -0
  48. package/dist/src/utilities/playlist-source.js +128 -0
  49. package/dist/src/utilities/playlist-verifier.js +274 -0
  50. package/dist/src/utilities/ssh-access.js +145 -0
  51. package/dist/src/utils.js +48 -0
  52. package/docs/CONFIGURATION.md +206 -0
  53. package/docs/EXAMPLES.md +390 -0
  54. package/docs/FUNCTION_CALLING.md +96 -0
  55. package/docs/PROJECT_SPEC.md +228 -0
  56. package/docs/README.md +348 -0
  57. package/docs/RELEASING.md +73 -0
  58. package/package.json +76 -0
@@ -0,0 +1,242 @@
1
+ "use strict";
2
+ /**
3
+ * Address Validator
4
+ * Validates Ethereum and Tezos wallet addresses
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.validateEthereumAddress = validateEthereumAddress;
8
+ exports.validateTezosAddress = validateTezosAddress;
9
+ exports.validateAddresses = validateAddresses;
10
+ const viem_1 = require("viem");
11
+ /**
12
+ * Validate Ethereum address format
13
+ * Uses viem library for EIP-55 checksum validation
14
+ *
15
+ * @param {string} address - Address to validate
16
+ * @returns {Object} Validation result
17
+ * @returns {boolean} returns.valid - Whether address is valid Ethereum format
18
+ * @returns {string} [returns.error] - Error message if invalid
19
+ * @returns {string} [returns.normalized] - Checksummed address if valid
20
+ * @example
21
+ * const result = validateEthereumAddress('0x1234567890123456789012345678901234567890');
22
+ * if (result.valid) console.log(result.normalized);
23
+ */
24
+ function validateEthereumAddress(address) {
25
+ if (!address || typeof address !== 'string') {
26
+ return {
27
+ valid: false,
28
+ error: 'Address must be a non-empty string',
29
+ };
30
+ }
31
+ try {
32
+ // viem's isAddress checks format and returns true/false
33
+ if (!(0, viem_1.isAddress)(address)) {
34
+ return {
35
+ valid: false,
36
+ error: 'Invalid Ethereum address format. Must be 0x followed by 40 hex characters',
37
+ };
38
+ }
39
+ // getAddress returns the checksummed address
40
+ const normalized = (0, viem_1.getAddress)(address);
41
+ return {
42
+ valid: true,
43
+ normalized,
44
+ };
45
+ }
46
+ catch (err) {
47
+ return {
48
+ valid: false,
49
+ error: `Address validation failed: ${err instanceof Error ? err.message : String(err)}`,
50
+ };
51
+ }
52
+ }
53
+ /**
54
+ * Validate Tezos address format
55
+ * Checks tz1, tz2, tz3, and KT1 address prefixes
56
+ *
57
+ * @param {string} address - Address to validate
58
+ * @returns {Object} Validation result
59
+ * @returns {boolean} returns.valid - Whether address is valid Tezos format
60
+ * @returns {string} [returns.error] - Error message if invalid
61
+ * @returns {string} [returns.type] - Address type (user, contract)
62
+ * @example
63
+ * const result = validateTezosAddress('tz1VSUr8wwNhLAzempoch5d6hLKEUNvD14');
64
+ * if (result.valid) console.log(result.type);
65
+ */
66
+ function validateTezosAddress(address) {
67
+ if (!address || typeof address !== 'string') {
68
+ return {
69
+ valid: false,
70
+ error: 'Address must be a non-empty string',
71
+ };
72
+ }
73
+ // Tezos addresses use base58 encoding with specific prefixes
74
+ // tz1, tz2, tz3: user/implicit accounts (34 chars total, or longer with suffix)
75
+ // KT1: contracts (34 chars total, or longer with suffix)
76
+ // Base58 alphabet: 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz
77
+ const userAddressRegex = /^tz[1-3][123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{30,}$/;
78
+ const contractAddressRegex = /^KT1[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{30,}$/;
79
+ if (userAddressRegex.test(address)) {
80
+ return {
81
+ valid: true,
82
+ type: 'user',
83
+ };
84
+ }
85
+ if (contractAddressRegex.test(address)) {
86
+ return {
87
+ valid: true,
88
+ type: 'contract',
89
+ };
90
+ }
91
+ return {
92
+ valid: false,
93
+ error: 'Invalid Tezos address format. Must start with tz1/tz2/tz3 (user) or KT1 (contract)',
94
+ };
95
+ }
96
+ /**
97
+ * Validate mixed Ethereum and Tezos addresses, ENS domains, and Tezos domains
98
+ * Detects address type and applies appropriate validation
99
+ *
100
+ * Supported formats:
101
+ * - Ethereum addresses: 0x followed by 40 hex characters
102
+ * - Tezos addresses: tz1/tz2/tz3 (user) or KT1 (contract)
103
+ * - ENS domains: alphanumeric names ending in .eth
104
+ * - Tezos domains: alphanumeric names ending in .tez
105
+ *
106
+ * @param {Array<string>} addresses - Array of addresses to validate
107
+ * @returns {Object} Validation result
108
+ * @returns {boolean} returns.valid - Whether all addresses are valid
109
+ * @returns {Array<Object>} returns.results - Validation result for each address
110
+ * @returns {Array<string>} returns.errors - List of error messages
111
+ * @example
112
+ * const result = validateAddresses(['0x...', 'tz1...', 'reas.eth', 'einstein-rosen.tez']);
113
+ * if (!result.valid) console.log(result.errors);
114
+ */
115
+ function validateAddresses(addresses) {
116
+ const results = [];
117
+ const errors = [];
118
+ if (!Array.isArray(addresses)) {
119
+ errors.push('Input must be an array of addresses');
120
+ return { valid: false, results, errors };
121
+ }
122
+ if (addresses.length === 0) {
123
+ errors.push('At least one address is required for validation');
124
+ return { valid: false, results, errors };
125
+ }
126
+ for (const address of addresses) {
127
+ if (typeof address !== 'string') {
128
+ errors.push(`Invalid input: ${JSON.stringify(address)} is not a string`);
129
+ results.push({
130
+ address: String(address),
131
+ valid: false,
132
+ type: 'unknown',
133
+ error: 'Address must be a string',
134
+ });
135
+ continue;
136
+ }
137
+ const trimmed = address.trim();
138
+ // Try Ethereum first (starts with 0x)
139
+ if (trimmed.startsWith('0x')) {
140
+ const ethResult = validateEthereumAddress(trimmed);
141
+ if (ethResult.valid) {
142
+ results.push({
143
+ address: trimmed,
144
+ valid: true,
145
+ type: 'ethereum',
146
+ normalized: ethResult.normalized,
147
+ });
148
+ }
149
+ else {
150
+ const errorMsg = `Invalid Ethereum address "${trimmed}": ${ethResult.error}`;
151
+ errors.push(errorMsg);
152
+ results.push({
153
+ address: trimmed,
154
+ valid: false,
155
+ type: 'ethereum',
156
+ error: ethResult.error,
157
+ });
158
+ }
159
+ }
160
+ else if (trimmed.startsWith('tz') || trimmed.startsWith('KT1')) {
161
+ // Try Tezos
162
+ const tezResult = validateTezosAddress(trimmed);
163
+ if (tezResult.valid) {
164
+ results.push({
165
+ address: trimmed,
166
+ valid: true,
167
+ type: tezResult.type || 'tezos',
168
+ });
169
+ }
170
+ else {
171
+ const errorMsg = `Invalid Tezos address "${trimmed}": ${tezResult.error}`;
172
+ errors.push(errorMsg);
173
+ results.push({
174
+ address: trimmed,
175
+ valid: false,
176
+ type: 'tezos',
177
+ error: tezResult.error,
178
+ });
179
+ }
180
+ }
181
+ else if (trimmed.endsWith('.eth')) {
182
+ // ENS domain name (Ethereum Name Service)
183
+ // These are valid owner identifiers that will be resolved to addresses
184
+ const ensPattern = /^[a-z0-9-]+\.eth$/i;
185
+ if (ensPattern.test(trimmed)) {
186
+ results.push({
187
+ address: trimmed,
188
+ valid: true,
189
+ type: 'ens',
190
+ });
191
+ }
192
+ else {
193
+ const errorMsg = `Invalid ENS domain "${trimmed}". Must be alphanumeric with hyphens ending in .eth`;
194
+ errors.push(errorMsg);
195
+ results.push({
196
+ address: trimmed,
197
+ valid: false,
198
+ type: 'ens',
199
+ error: 'Invalid ENS domain format',
200
+ });
201
+ }
202
+ }
203
+ else if (trimmed.endsWith('.tez')) {
204
+ // Tezos domain name
205
+ // These are valid owner identifiers that will be resolved to addresses
206
+ const tezDomainPattern = /^[a-z0-9-]+\.tez$/i;
207
+ if (tezDomainPattern.test(trimmed)) {
208
+ results.push({
209
+ address: trimmed,
210
+ valid: true,
211
+ type: 'tezos-domain',
212
+ });
213
+ }
214
+ else {
215
+ const errorMsg = `Invalid Tezos domain "${trimmed}". Must be alphanumeric with hyphens ending in .tez`;
216
+ errors.push(errorMsg);
217
+ results.push({
218
+ address: trimmed,
219
+ valid: false,
220
+ type: 'tezos-domain',
221
+ error: 'Invalid Tezos domain format',
222
+ });
223
+ }
224
+ }
225
+ else {
226
+ const errorMsg = `Unknown address format "${trimmed}". Must be 0x... (Ethereum), tz/KT1 (Tezos), .eth (ENS), or .tez (Tezos domain)`;
227
+ errors.push(errorMsg);
228
+ results.push({
229
+ address: trimmed,
230
+ valid: false,
231
+ type: 'unknown',
232
+ error: 'Must be Ethereum (0x...), Tezos (tz1/tz2/tz3/KT1), ENS (.eth), or Tezos domain (.tez)',
233
+ });
234
+ }
235
+ }
236
+ const allValid = results.every((r) => r.valid);
237
+ return {
238
+ valid: allValid,
239
+ results,
240
+ errors,
241
+ };
242
+ }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.promoteDeviceToDefault = promoteDeviceToDefault;
4
+ const device_normalize_1 = require("./device-normalize");
5
+ /**
6
+ * Move the named device to index 0 so it becomes the implicit default.
7
+ *
8
+ * Matches by name (case-insensitive) or by host URL, mirroring `device remove`
9
+ * so unnamed legacy entries can still be targeted.
10
+ *
11
+ * @throws {Error} When no device matches the identifier
12
+ */
13
+ function promoteDeviceToDefault(devices, identifier) {
14
+ const normalizedArg = identifier.toLowerCase();
15
+ let normalizedArgHost = '';
16
+ try {
17
+ normalizedArgHost = (0, device_normalize_1.normalizeDeviceHost)(identifier).toLowerCase();
18
+ }
19
+ catch {
20
+ // not a valid URL — host matching will not apply
21
+ }
22
+ const index = devices.findIndex((d) => (d.name && d.name.toLowerCase() === normalizedArg) ||
23
+ (d.host && d.host.toLowerCase() === normalizedArg) ||
24
+ (normalizedArgHost &&
25
+ d.host &&
26
+ (0, device_normalize_1.normalizeDeviceHost)(d.host).toLowerCase() === normalizedArgHost));
27
+ if (index === -1) {
28
+ throw new Error(`Device "${identifier}" not found`);
29
+ }
30
+ const promoted = devices[index];
31
+ if (index === 0) {
32
+ return { devices: [...devices], promoted, alreadyDefault: true };
33
+ }
34
+ const reordered = [promoted, ...devices.slice(0, index), ...devices.slice(index + 1)];
35
+ return { devices: reordered, promoted, alreadyDefault: false };
36
+ }
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findExistingDeviceEntry = findExistingDeviceEntry;
4
+ /**
5
+ * Find an existing configured device that corresponds to a newly discovered host.
6
+ *
7
+ * Priority (most reliable to least):
8
+ * 1. mDNS device ID match — the device's stable hardware identity. Checked first
9
+ * so a stale row that happens to share the same IP cannot shadow the correct
10
+ * entry. Requires the stored entry to have been written with an id field.
11
+ * 2. Exact URL match — normal case after ID (same host format, no migration).
12
+ * 3. mDNS hostname component match — both URLs are parsed and only the hostname
13
+ * part is compared (same .local name, different underlying IP or port).
14
+ * 4a. Discovered IP → stored IP: the mDNS discovery reports a resolved IP that
15
+ * matches the IP in a stored entry. Bridges .local ↔ stored-IP migration for
16
+ * pre-id configs with no stored id.
17
+ * 4b. New-host IP → stored addresses: the caller provides an IP host (e.g. from
18
+ * --host <ip>) and a stored entry has that IP in its addresses list. Bridges
19
+ * the reverse direction (.local stored, IP provided by the user).
20
+ * 5. TXT-name match — if the device advertises a name that matches a stored
21
+ * friendly name, treat them as the same device (last-resort fallback for
22
+ * configs written before the id field existed).
23
+ *
24
+ * Returns the matched entry so callers can preserve the stored friendly name as
25
+ * the default when prompting, rather than falling back to the raw mDNS label.
26
+ */
27
+ function findExistingDeviceEntry(existingDevices, newHost, discoveredName, discoveredId, discoveredAddresses) {
28
+ // 1. mDNS device ID — stable hardware identity, checked before URL so stale
29
+ // host entries for other devices at the same IP do not shadow the result.
30
+ if (discoveredId) {
31
+ const byId = existingDevices.find((d) => d.id === discoveredId);
32
+ if (byId) {
33
+ return byId;
34
+ }
35
+ }
36
+ // 2. Exact URL match
37
+ const byHost = existingDevices.find((d) => d.host === newHost);
38
+ if (byHost) {
39
+ return byHost;
40
+ }
41
+ // 3. mDNS hostname component match (ignores port and protocol differences)
42
+ let newHostname = '';
43
+ try {
44
+ newHostname = new URL(newHost).hostname;
45
+ }
46
+ catch {
47
+ // not a valid URL — skip hostname matching
48
+ }
49
+ if (newHostname) {
50
+ const byHostname = existingDevices.find((d) => {
51
+ try {
52
+ return new URL(d.host || '').hostname === newHostname;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ });
58
+ if (byHostname) {
59
+ return byHostname;
60
+ }
61
+ }
62
+ // Node.js URL.hostname wraps IPv6 addresses in brackets: [fe80::1].
63
+ // Strip them so comparisons work against the bracket-free strings stored in addresses[].
64
+ const stripBrackets = (h) => (h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h);
65
+ // 4a. Discovered IP → stored IP: mDNS reported addresses include the stored entry's IP.
66
+ // Skip only when both the stored entry AND the discovered device carry an id that
67
+ // differs — in that case the devices are provably distinct. If discoveredId is absent
68
+ // (partial avahi result, --host path) allow the address match regardless of whether
69
+ // the stored entry has an id; the address is the best identity signal available.
70
+ if (discoveredAddresses && discoveredAddresses.length > 0) {
71
+ const byDiscoveredAddress = existingDevices.find((d) => {
72
+ if (d.id && discoveredId && d.id !== discoveredId) {
73
+ return false; // different physical devices — do not conflate
74
+ }
75
+ try {
76
+ const storedIp = stripBrackets(new URL(d.host || '').hostname);
77
+ return discoveredAddresses.includes(storedIp);
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ });
83
+ if (byDiscoveredAddress) {
84
+ return byDiscoveredAddress;
85
+ }
86
+ }
87
+ // 4b. New-host IP → stored addresses: the new host is an IP URL (IPv4 or IPv6)
88
+ // and a stored entry has that IP in its stored addresses list (populated from
89
+ // prior mDNS discoveries). This bridges --host <ip/ipv6> → existing .local entry.
90
+ // Strip IPv6 brackets before comparing (Node URL.hostname returns '[fe80::1]').
91
+ const rawHostname = stripBrackets(newHostname);
92
+ if (rawHostname && (/^[0-9.]+$/.test(rawHostname) || rawHostname.includes(':'))) {
93
+ const byStoredAddress = existingDevices.find((d) => d.addresses?.includes(rawHostname));
94
+ if (byStoredAddress) {
95
+ return byStoredAddress;
96
+ }
97
+ }
98
+ // 5. TXT-name match — only for entries WITHOUT a stored id.
99
+ // If a stored entry already has an id and it did not match in step 1, then
100
+ // the discovered device is a physically distinct device that merely advertises
101
+ // the same friendly name; matching by name alone would resolve to the wrong row
102
+ // and cause upsertDevice to overwrite a different device.
103
+ if (discoveredName) {
104
+ return existingDevices.find((d) => !d.id && d.name === discoveredName);
105
+ }
106
+ return undefined;
107
+ }
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeDeviceHost = normalizeDeviceHost;
4
+ exports.normalizeDeviceIdToHost = normalizeDeviceIdToHost;
5
+ /**
6
+ * Normalize a raw host string into a canonical `http://<host>:<port>` URL.
7
+ *
8
+ * Handles:
9
+ * - Trailing dot removal (mDNS labels sometimes end with '.')
10
+ * - Case-insensitive scheme detection (HTTP://, HTTPS://, http://, https://)
11
+ * - Bare IPv6 addresses (e.g. fe80::1 → [fe80::1]) so new URL() can parse them
12
+ * - Missing scheme → http://
13
+ * - Missing port → 1111
14
+ */
15
+ function normalizeDeviceHost(host) {
16
+ let normalized = host.trim().replace(/\.$/, '');
17
+ if (!normalized) {
18
+ return normalized;
19
+ }
20
+ const lower = normalized.toLowerCase();
21
+ // Bare IPv6 address (e.g. fe80::1) — must be bracketed before adding http://
22
+ // so that new URL() doesn't misparse the colons as port separators.
23
+ // Only applies when there is no existing scheme, no existing brackets, and the
24
+ // string consists solely of hex digits and colons (the IPv6 character set).
25
+ if (!lower.startsWith('http://') &&
26
+ !lower.startsWith('https://') &&
27
+ !normalized.startsWith('[') &&
28
+ /^[0-9a-fA-F:]+$/.test(normalized) &&
29
+ normalized.includes(':')) {
30
+ normalized = `[${normalized}]`;
31
+ }
32
+ if (!lower.startsWith('http://') && !lower.startsWith('https://')) {
33
+ normalized = `http://${normalized}`;
34
+ }
35
+ try {
36
+ const url = new URL(normalized);
37
+ const port = url.port || '1111';
38
+ return `${url.protocol}//${url.hostname}:${port}`;
39
+ }
40
+ catch (_error) {
41
+ return normalized;
42
+ }
43
+ }
44
+ /**
45
+ * Resolve a raw device identifier or host string into a canonical host URL.
46
+ *
47
+ * Accepts:
48
+ * - Full URLs (http://..., HTTPS://...) — forwarded to normalizeDeviceHost
49
+ * - IP addresses (contain dots or colons)
50
+ * - .local hostnames (contain dots)
51
+ * - Raw device IDs (e.g. 'hh9jsnoc', 'FF1-HH9JSNOC', 'ff1-hh9jsnoc') →
52
+ * normalized to lowercase and prefixed with 'ff1-' if missing, then '.local' appended
53
+ */
54
+ function normalizeDeviceIdToHost(rawId) {
55
+ const lower = rawId.trim().toLowerCase();
56
+ const looksLikeHost = lower.includes('.') || lower.includes(':') || lower.startsWith('http');
57
+ if (looksLikeHost) {
58
+ return normalizeDeviceHost(rawId);
59
+ }
60
+ const deviceId = lower.startsWith('ff1-') ? lower : `ff1-${lower}`;
61
+ return normalizeDeviceHost(`${deviceId}.local`);
62
+ }
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.upsertDevice = upsertDevice;
4
+ /**
5
+ * Strip undefined values from an object so spreads do not overwrite existing
6
+ * keys with undefined (e.g. when a caller passes id: undefined because the
7
+ * device was discovered without an ID).
8
+ */
9
+ function withoutUndefined(obj) {
10
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
11
+ }
12
+ /**
13
+ * Apply a patch to an entry.
14
+ *
15
+ * Addresses are handled specially:
16
+ * - Incoming non-empty: replace (fresh discovery data supersedes stale IPs).
17
+ * - Host changed, no incoming addresses: clear (stale IPs belonged to the old
18
+ * network location; keeping them lets --host <old-ip> match the wrong device
19
+ * after DHCP churn or a partial avahi timeout that omits the address field).
20
+ * - Same host, no incoming addresses: keep existing (--host path provides no
21
+ * addresses; the stored set must be preserved so reverse IP lookup still works).
22
+ *
23
+ * Dual-stack accumulation (IPv4 + IPv6) is handled upstream by parseAvahiBrowseOutput
24
+ * before upsertDevice is called, so the full address set is always present in a single
25
+ * invocation.
26
+ */
27
+ function applyPatch(existing, patch) {
28
+ const hostChanged = patch.host !== undefined && patch.host !== existing.host;
29
+ let addresses;
30
+ if (patch.addresses && patch.addresses.length > 0) {
31
+ addresses = patch.addresses; // fresh data: replace
32
+ }
33
+ else if (hostChanged) {
34
+ addresses = undefined; // host changed, no new IPs: clear stale addresses
35
+ }
36
+ else {
37
+ addresses = existing.addresses; // same host, no new IPs: keep stored set
38
+ }
39
+ return { ...existing, ...patch, addresses };
40
+ }
41
+ /**
42
+ * Insert or update a device in the configured device list.
43
+ *
44
+ * Priority:
45
+ * 0. matchedIndex provided — caller already resolved the row via findExistingDeviceEntry;
46
+ * update that position directly. Handles rename + host-change combos where none of
47
+ * the id/name/host heuristics below would find the correct row.
48
+ * 1. Same mDNS device ID → update in-place (preserves position, handles host change).
49
+ * 2. Same host → update in-place (preserves position and metadata).
50
+ * 3. Same name, different host → replace in-place (preserves position so that
51
+ * devices[0] — the implicit default for play/send/ssh — does not silently change).
52
+ * 4. Neither match → append.
53
+ */
54
+ function upsertDevice(existingDevices, newDevice,
55
+ /** Pre-resolved row index from findExistingDeviceEntry. When provided, the
56
+ * heuristics below are skipped and this row is updated directly. */
57
+ matchedIndex) {
58
+ const devices = [...existingDevices];
59
+ const patch = withoutUndefined(newDevice);
60
+ // Case 0: caller already resolved the match — update directly.
61
+ if (matchedIndex !== undefined && matchedIndex >= 0 && matchedIndex < devices.length) {
62
+ const isSameHost = devices[matchedIndex].host === newDevice.host;
63
+ devices[matchedIndex] = applyPatch(devices[matchedIndex], patch);
64
+ return { devices, updated: isSameHost };
65
+ }
66
+ // Case 1: same mDNS device ID — update in-place even when host changed.
67
+ if (newDevice.id) {
68
+ const sameIdIndex = devices.findIndex((d) => d.id === newDevice.id);
69
+ if (sameIdIndex !== -1) {
70
+ const isSameHost = devices[sameIdIndex].host === newDevice.host;
71
+ devices[sameIdIndex] = applyPatch(devices[sameIdIndex], patch);
72
+ return { devices, updated: isSameHost };
73
+ }
74
+ }
75
+ // Case 2: same host — update in-place
76
+ const sameHostIndex = devices.findIndex((d) => d.host === newDevice.host);
77
+ if (sameHostIndex !== -1) {
78
+ devices[sameHostIndex] = applyPatch(devices[sameHostIndex], patch);
79
+ return { devices, updated: true };
80
+ }
81
+ // Case 3: same name, different host — replace in-place to preserve array order.
82
+ // Spread existing entry first so apiKey/topicID survive a host change.
83
+ const staleNameIndex = devices.findIndex((d) => d.name === newDevice.name);
84
+ if (staleNameIndex !== -1) {
85
+ devices[staleNameIndex] = applyPatch(devices[staleNameIndex], patch);
86
+ return { devices, updated: false };
87
+ }
88
+ // Case 4: new device
89
+ devices.push({ ...patch });
90
+ return { devices, updated: false };
91
+ }