1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
| #!/usr/bin/env node
/*
* Push / pull the Node-RED flow over the Admin API.
*
* Deploying with POST /flows replaces the running config in place, matching on
* the node IDs in the file. Nodes keep their identity, so nothing is duplicated
* and credentials (the MQTT broker password, which lives in flows_cred.json
* keyed by node id) survive. That is the difference from importing the JSON in
* the editor, which mints new IDs and therefore both duplicates and de-credentials.
*
* Global context (F_SOLAR, F_COOL, F_S2C, ...) lives in the context store, not
* in flows.json, so your settings are not touched by a deploy.
*
* node tools/nodered.js push deploy the repo flow to Node-RED
* node tools/nodered.js pull overwrite the repo flow with the live one
* node tools/nodered.js status show what is running, no changes
*
* Credentials are read from tools/.env, which is gitignored so they never land
* in the repo. Copy .env.example to .env and fill it in:
* NR_URL default http://1.2.3.4:1880
* NR_USER your Home Assistant username
* NR_PASS your Home Assistant password
* A real environment variable, if set, wins over the .env file.
*/
const fs = require('fs');
const path = require('path');
// Minimal .env reader - not worth a dependency for three values.
function loadEnv() {
const file = path.join(__dirname, '.env');
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (!m) continue; // blank line or # comment
const key = m[1];
let val = m[2].trim().replace(/\s+#.*$/, ''); // strip trailing comment
if (/^".*"$|^'.*'$/.test(val)) { val = val.slice(1, -1); }
if (val !== '' && process.env~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~[key] === undefined) { process.env~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~[key] = val; }
}
}
loadEnv();
const URL_BASE = (process.env.NR_URL || 'http://1.2.3.4:1880').replace(/\/+$/, '');
const USER = process.env.NR_USER;
const PASS = process.env.NR_PASS;
const REPO = path.resolve(__dirname, '..');
const FLOW_FILE = path.join(REPO, 'flows (26.5.1 stable).json');
const BACKUP_DIR = path.join(__dirname, 'backups');
const cmd = process.argv[2] || 'status';
const args = process.argv.slice(3);
const has = (f) => args.includes(f);
// The add-on puts nginx in front of Node-RED with HTTP Basic auth against your
// Home Assistant account, so we authenticate at that layer.
function headers(extra) {
const h = Object.assign({ 'Node-RED-API-Version': 'v2' }, extra || {});
if (USER && PASS) {
h.Authorization = 'Basic ' + Buffer.from(USER + ':' + PASS).toString('base64');
}
return h;
}
function die(msg) { console.error('\n ' + msg + '\n'); process.exit(1); }
/* ---------------------------------------------------------------------------
* Protected nodes: whatever is running on the box wins, always.
*
* - the MQTT broker config node. Its host/port/client-id are configured on the
* live instance and are not in the repo, so pushing the repo's copy would
* wipe them.
* - any flow tab whose name contains "personal" (e.g. WP_personal), plus every
* node on it. That tab exists only on your instance; the repo must never add,
* change or delete it.
*
* push : take these from the live instance, not from the repo.
* pull : do not drag them into the repo.
* ------------------------------------------------------------------------- */
const PROTECTED_TYPES = ['mqtt-broker'];
const PROTECTED_TAB = /personal/i;
function protectedIds(flows) {
const tabs = new Set();
const ids = new Set();
for (const n of flows) {
if (PROTECTED_TYPES.includes(n.type)) { ids.add(n.id); }
if (n.type === 'tab' && PROTECTED_TAB.test(n.label || '')) { ids.add(n.id); tabs.add(n.id); }
}
for (const n of flows) { if (n.z && tabs.has(n.z)) { ids.add(n.id); } } // nodes living on a protected tab
return { ids, tabs };
}
function describe(flows, ids) {
const kept = flows.filter((n) => ids.has(n.id));
const tabs = kept.filter((n) => n.type === 'tab').map((n) => n.label);
const cfg = kept.filter((n) => PROTECTED_TYPES.includes(n.type)).map((n) => n.type + (n.name ? ' "' + n.name + '"' : ''));
const out = [];
if (cfg.length) out.push(cfg.join(', '));
if (tabs.length) out.push(tabs.map((t) => 'tab "' + t + '" (+ its nodes)').join(', '));
return out.length ? out.join(' and ') : null;
}
// The flow we will actually deploy: the repo, with protected nodes taken from live.
function mergeForPush(repoFlows, liveFlows) {
const live = protectedIds(liveFlows);
const liveById = Object.fromEntries(liveFlows.map((n) => [n.id, n]));
const merged = repoFlows.map((n) => (live.ids.has(n.id) && liveById[n.id] ? liveById[n.id] : n));
// protected nodes that exist only on the live box (the personal tab) must be carried over
const inRepo = new Set(repoFlows.map((n) => n.id));
for (const n of liveFlows) { if (live.ids.has(n.id) && !inRepo.has(n.id)) { merged.push(n); } }
return { merged, preserved: describe(liveFlows, live.ids) };
}
async function call(method, route, body) {
let res;
try {
res = await fetch(URL_BASE + route, {
method,
headers: headers(body ? { 'Content-Type': 'application/json' } : undefined),
body,
});
} catch (e) {
die('Cannot reach ' + URL_BASE + ' - ' + e.message);
}
if (res.status === 401) {
die('401 Unauthorized.\n' +
' Set NR_USER / NR_PASS to your Home Assistant username and password.\n' +
' If that account has multi-factor auth enabled, Basic auth will always fail -\n' +
' create a separate HA user without MFA for this.');
}
if (res.status === 409) {
die('409 Conflict. The flow changed in Node-RED since we read it, so the deploy\n' +
' was refused rather than silently overwriting it. Close/deploy the editor,\n' +
' then run this again.');
}
if (!res.ok) {
die('HTTP ' + res.status + ' on ' + method + ' ' + route + '\n ' + (await res.text()).slice(0, 400));
}
return res;
}
// GET /flows with API v2 returns { rev, flows }. Passing that rev back on the
// POST is what makes a concurrent editor change 409 instead of being clobbered.
async function getLive() {
const res = await call('GET', '/flows');
return res.json();
}
function readRepoFlow() {
if (!fs.existsSync(FLOW_FILE)) { die('Flow file not found: ' + FLOW_FILE); }
const raw = fs.readFileSync(FLOW_FILE, 'utf8');
try { return { raw, flows: JSON.parse(raw) }; }
catch (e) { die('Repo flow file is not valid JSON: ' + e.message); }
}
function backup(flows, label) {
if (has('--no-backup')) return null;
fs.mkdirSync(BACKUP_DIR, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const file = path.join(BACKUP_DIR, 'flows-' + label + '-' + ts + '.json');
fs.writeFileSync(file, JSON.stringify(flows, null, 4));
return file;
}
function summarise(flows) {
const tabs = flows.filter((n) => n.type === 'tab');
const uitabs = flows.filter((n) => n.type === 'ui_tab').sort((a, b) => a.order - b.order);
console.log(' nodes : ' + flows.length);
console.log(' flow tabs : ' + tabs.map((t) => t.label).join(', '));
console.log(' dashboard : ' + uitabs.map((t) => t.name).join(', '));
}
(async () => {
if (!USER || !PASS) {
console.log('\n NR_USER / NR_PASS are empty - this will 401.');
console.log(' Fill in NR_USER and NR_PASS in tools/.env (see tools/.env.example).\n');
}
// Diagnoses a failing login without ever printing the password.
if (cmd === 'doctor') {
const q = (s) => (s === undefined ? '(not set)' : JSON.stringify(s));
console.log('\n URL : ' + URL_BASE);
console.log(' NR_USER : ' + (USER === undefined ? '(not set)' : q(USER)));
console.log(' NR_PASS : ' + (PASS === undefined ? '(not set)'
: 'set, ' + PASS.length + ' chars'
+ (PASS !== PASS.trim() ? ' <-- WARNING: leading/trailing whitespace' : '')
+ (/^["'].*["']$/.test(PASS) ? ' <-- WARNING: looks like the quotes are part of the password' : '')));
if (USER !== undefined && USER !== USER.trim()) { console.log(' <-- WARNING: NR_USER has leading/trailing whitespace'); }
if (!USER || !PASS) { console.log('\n Fill in NR_USER and NR_PASS in tools/.env.\n'); return; }
const res = await fetch(URL_BASE + '/flows', { headers: headers() }).catch((e) => {
console.log('\n Cannot reach the server: ' + e.message + '\n'); process.exit(1);
});
console.log('\n GET /flows -> HTTP ' + res.status);
if (res.status === 200) { console.log('\n Credentials work. Use "status" / "push" / "pull".\n'); return; }
if (res.status === 401) {
console.log(' Auth realm : ' + (res.headers.get('www-authenticate') || '(none)'));
console.log(' Served by : ' + (res.headers.get('server') || '(unknown)'));
console.log('\n The add-on rejected these credentials. Most likely, in order:');
console.log(' 1. The HA account has MFA enabled - Basic auth then cannot ever work.');
console.log(' Create a second HA user, no MFA, and use that.');
console.log(' 2. Wrong username/password. Test by opening ' + URL_BASE + ' in a private');
console.log(' browser window - it should prompt, and the same details should work.');
console.log(' 3. The account is not an HA administrator.\n');
return;
}
console.log(' Unexpected. Body: ' + (await res.text()).slice(0, 300) + '\n');
return;
}
if (cmd === 'status') {
const live = await getLive();
console.log('\nLive on ' + URL_BASE + ' (rev ' + live.rev + ')');
summarise(live.flows);
const repo = readRepoFlow();
console.log('\nIn the repo');
summarise(repo.flows);
const { merged, preserved } = mergeForPush(repo.flows, live.flows);
if (preserved) { console.log('\n Protected (kept from the live instance): ' + preserved); }
const same = JSON.stringify(live.flows) === JSON.stringify(merged);
console.log('\n ' + (same ? 'Identical - nothing to deploy.' : 'They differ. Run "push" to deploy.') + '\n');
return;
}
if (cmd === 'pull') {
const live = await getLive();
const repo = readRepoFlow();
// Don't drag protected things into the repo: keep the repo's own MQTT broker
// node, and leave the personal tab out of the file entirely.
const p = protectedIds(live.flows);
const repoById = Object.fromEntries(repo.flows.map((n) => [n.id, n]));
const out = live.flows
.filter((n) => !p.ids.has(n.id) || repoById[n.id])
.map((n) => (p.ids.has(n.id) && repoById[n.id] ? repoById[n.id] : n));
const skipped = live.flows.length - out.length;
const b = backup(repo.flows, 'repo-before-pull');
fs.writeFileSync(FLOW_FILE, JSON.stringify(out, null, 4));
console.log('\n Pulled ' + out.length + ' nodes into ' + path.basename(FLOW_FILE));
if (skipped) { console.log(' Left out of the repo: ' + skipped + ' protected node(s) - ' + describe(live.flows, p.ids)); }
if (b) console.log(' Previous repo flow backed up to ' + path.relative(REPO, b));
console.log(' Review with: git diff\n');
return;
}
if (cmd === 'push') {
const repo = readRepoFlow();
const live = await getLive();
const { merged, preserved } = mergeForPush(repo.flows, live.flows);
const b = backup(live.flows, 'live-before-push');
if (b) console.log('\n Live flow backed up to ' + path.relative(REPO, b));
if (preserved) { console.log(' Protected, kept from live: ' + preserved); }
if (has('--dry-run')) {
console.log(' Dry run - would deploy ' + merged.length + ' nodes (live has ' + live.flows.length + ').\n');
return;
}
// "full" restarts every node. That is what we want: it re-runs the Load-on-boot
// injects that repopulate the dashboard widgets from global context.
await call('POST', '/flows', JSON.stringify({ rev: live.rev, flows: merged }));
console.log(' Deployed ' + merged.length + ' nodes to ' + URL_BASE);
console.log(' No duplicates: node IDs were matched and updated in place.\n');
return;
}
die('Usage: node tools/nodered.js [status|push|pull|doctor] [--dry-run] [--no-backup]');
})(); |