@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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/config.json.example +96 -0
- package/dist/index.js +54 -0
- package/dist/src/ai-orchestrator/index.js +1019 -0
- package/dist/src/ai-orchestrator/registry.js +96 -0
- package/dist/src/commands/build.js +69 -0
- package/dist/src/commands/chat.js +189 -0
- package/dist/src/commands/config.js +68 -0
- package/dist/src/commands/device.js +278 -0
- package/dist/src/commands/helpers/config-files.js +62 -0
- package/dist/src/commands/helpers/device-discovery.js +111 -0
- package/dist/src/commands/helpers/playlist-display.js +161 -0
- package/dist/src/commands/helpers/prompt.js +65 -0
- package/dist/src/commands/helpers/ssh-helpers.js +44 -0
- package/dist/src/commands/play.js +110 -0
- package/dist/src/commands/publish.js +115 -0
- package/dist/src/commands/setup.js +225 -0
- package/dist/src/commands/sign.js +41 -0
- package/dist/src/commands/ssh.js +108 -0
- package/dist/src/commands/status.js +126 -0
- package/dist/src/commands/validate.js +18 -0
- package/dist/src/config.js +441 -0
- package/dist/src/intent-parser/index.js +1382 -0
- package/dist/src/intent-parser/utils.js +108 -0
- package/dist/src/logger.js +82 -0
- package/dist/src/main.js +459 -0
- package/dist/src/types.js +5 -0
- package/dist/src/utilities/address-validator.js +242 -0
- package/dist/src/utilities/device-default.js +36 -0
- package/dist/src/utilities/device-lookup.js +107 -0
- package/dist/src/utilities/device-normalize.js +62 -0
- package/dist/src/utilities/device-upsert.js +91 -0
- package/dist/src/utilities/domain-resolver.js +291 -0
- package/dist/src/utilities/ed25519-key-derive.js +155 -0
- package/dist/src/utilities/feed-fetcher.js +471 -0
- package/dist/src/utilities/ff1-compatibility.js +269 -0
- package/dist/src/utilities/ff1-device.js +250 -0
- package/dist/src/utilities/ff1-discovery.js +330 -0
- package/dist/src/utilities/functions.js +308 -0
- package/dist/src/utilities/index.js +469 -0
- package/dist/src/utilities/nft-indexer.js +1024 -0
- package/dist/src/utilities/playlist-builder.js +523 -0
- package/dist/src/utilities/playlist-publisher.js +131 -0
- package/dist/src/utilities/playlist-send.js +260 -0
- package/dist/src/utilities/playlist-signer.js +204 -0
- package/dist/src/utilities/playlist-signing-role.js +41 -0
- package/dist/src/utilities/playlist-source.js +128 -0
- package/dist/src/utilities/playlist-verifier.js +274 -0
- package/dist/src/utilities/ssh-access.js +145 -0
- package/dist/src/utils.js +48 -0
- package/docs/CONFIGURATION.md +206 -0
- package/docs/EXAMPLES.md +390 -0
- package/docs/FUNCTION_CALLING.md +96 -0
- package/docs/PROJECT_SPEC.md +228 -0
- package/docs/README.md +348 -0
- package/docs/RELEASING.md +73 -0
- 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
|
+
}
|