@serfab/cadre-host 1.14.0 → 1.16.0

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 (107) hide show
  1. package/README.md +19 -4
  2. package/dist/bin/host.js +32 -20
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/bin/nat-output.d.ts +5 -0
  5. package/dist/bin/nat-output.js +12 -1
  6. package/dist/bin/nat-output.js.map +1 -1
  7. package/dist/bin/ui-access.d.ts +22 -0
  8. package/dist/bin/ui-access.js +44 -0
  9. package/dist/bin/ui-access.js.map +1 -0
  10. package/dist/hosted/claim-details.d.ts +25 -3
  11. package/dist/hosted/claim-details.js +31 -37
  12. package/dist/hosted/claim-details.js.map +1 -1
  13. package/dist/hosted/hosted-node-service.d.ts +7 -0
  14. package/dist/hosted/hosted-node-service.js +16 -4
  15. package/dist/hosted/hosted-node-service.js.map +1 -1
  16. package/dist/hosted/hosted-node-watcher.d.ts +16 -4
  17. package/dist/hosted/hosted-node-watcher.js +45 -9
  18. package/dist/hosted/hosted-node-watcher.js.map +1 -1
  19. package/dist/hosted/node-status.d.ts +19 -3
  20. package/dist/hosted/node-status.js +21 -5
  21. package/dist/hosted/node-status.js.map +1 -1
  22. package/dist/hosted/types.d.ts +12 -5
  23. package/dist/hosted/types.js.map +1 -1
  24. package/dist/installer/config.d.ts +23 -0
  25. package/dist/installer/config.js +46 -0
  26. package/dist/installer/config.js.map +1 -1
  27. package/dist/installer/index.d.ts +2 -0
  28. package/dist/installer/index.js +7 -16
  29. package/dist/installer/index.js.map +1 -1
  30. package/dist/installer/wizard.d.ts +12 -2
  31. package/dist/installer/wizard.js +15 -7
  32. package/dist/installer/wizard.js.map +1 -1
  33. package/dist/nat/index.d.ts +2 -1
  34. package/dist/nat/index.js +1 -0
  35. package/dist/nat/index.js.map +1 -1
  36. package/dist/nat/nat-service.d.ts +22 -5
  37. package/dist/nat/nat-service.js +75 -26
  38. package/dist/nat/nat-service.js.map +1 -1
  39. package/dist/nat/nat-store.js +11 -0
  40. package/dist/nat/nat-store.js.map +1 -1
  41. package/dist/nat/network-mode.d.ts +19 -0
  42. package/dist/nat/network-mode.js +32 -0
  43. package/dist/nat/network-mode.js.map +1 -0
  44. package/dist/nat/reachability.d.ts +5 -4
  45. package/dist/nat/reachability.js +19 -6
  46. package/dist/nat/reachability.js.map +1 -1
  47. package/dist/nat/types.d.ts +26 -6
  48. package/dist/nat/types.js.map +1 -1
  49. package/dist/orchestrator/host-process-orchestrator.d.ts +3 -3
  50. package/dist/orchestrator/host-process-orchestrator.js +6 -15
  51. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  52. package/dist/package-version.d.ts +1 -0
  53. package/dist/package-version.js +4 -0
  54. package/dist/package-version.js.map +1 -0
  55. package/dist/server/routes/settings.d.ts +1 -0
  56. package/dist/server/routes/settings.js +15 -1
  57. package/dist/server/routes/settings.js.map +1 -1
  58. package/dist/server/routes/status.js +2 -32
  59. package/dist/server/routes/status.js.map +1 -1
  60. package/dist/ui/assets/index-BHd8CWZA.css +1 -0
  61. package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
  62. package/dist/ui/assets/{index-4FWuU8ye.js.map → index-Ccr2GjPT.js.map} +1 -1
  63. package/dist/ui/index.html +2 -2
  64. package/package.json +4 -5
  65. package/src/__tests__/cli.smoke.test.ts +1 -1
  66. package/src/__tests__/orchestrator-claim-secret.test.ts +1 -2
  67. package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
  68. package/src/__tests__/orchestrator-handle-contract.ts +1 -1
  69. package/src/__tests__/orchestrator-node-identity.test.ts +0 -5
  70. package/src/__tests__/orchestrator-push.test.ts +3 -3
  71. package/src/__tests__/orchestrator.test.ts +3 -5
  72. package/src/__tests__/ui-access.test.ts +30 -0
  73. package/src/bin/host.ts +30 -20
  74. package/src/bin/nat-output.ts +12 -1
  75. package/src/bin/ui-access.ts +50 -0
  76. package/src/hosted/__tests__/fake-orchestrator.test.ts +0 -1
  77. package/src/hosted/__tests__/fake-orchestrator.ts +263 -265
  78. package/src/hosted/__tests__/hosted-node-service.test.ts +708 -609
  79. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -474
  80. package/src/hosted/claim-details.ts +44 -37
  81. package/src/hosted/hosted-node-service.ts +23 -4
  82. package/src/hosted/hosted-node-watcher.ts +288 -250
  83. package/src/hosted/node-status.ts +31 -5
  84. package/src/hosted/types.ts +160 -153
  85. package/src/installer/__tests__/installer.smoke.test.ts +184 -182
  86. package/src/installer/__tests__/wizard.test.ts +76 -56
  87. package/src/installer/config.ts +66 -0
  88. package/src/installer/index.ts +252 -259
  89. package/src/installer/wizard.ts +101 -87
  90. package/src/nat/__tests__/nat-service.test.ts +608 -540
  91. package/src/nat/__tests__/nat-store.test.ts +130 -127
  92. package/src/nat/__tests__/network-mode.test.ts +38 -0
  93. package/src/nat/index.ts +91 -87
  94. package/src/nat/nat-service.ts +79 -26
  95. package/src/nat/nat-store.ts +249 -237
  96. package/src/nat/network-mode.ts +36 -0
  97. package/src/nat/reachability.ts +155 -143
  98. package/src/nat/types.ts +232 -209
  99. package/src/orchestrator/host-process-orchestrator.ts +1089 -1098
  100. package/src/package-version.ts +4 -0
  101. package/src/server/__tests__/fakes.ts +41 -40
  102. package/src/server/__tests__/hosted-nodes-route.test.ts +161 -160
  103. package/src/server/__tests__/settings-route.test.ts +31 -0
  104. package/src/server/routes/settings.ts +128 -115
  105. package/src/server/routes/status.ts +72 -102
  106. package/dist/ui/assets/index-4FWuU8ye.js +0 -19
  107. package/dist/ui/assets/index-Di9Q4d01.css +0 -1
@@ -1,182 +1,184 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
- import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
-
6
- import { Installer } from '../index.js';
7
- import type { ServiceHost, ServiceHostContext, ServiceHostStatus } from '../service-host/types.js';
8
-
9
- class FakeServiceHost implements ServiceHost {
10
- readonly name = 'cadre-host';
11
- installCalls: ServiceHostContext[] = [];
12
- uninstallCalls: ServiceHostContext[] = [];
13
- installed = false;
14
- running = false;
15
-
16
- async install(ctx: ServiceHostContext): Promise<void> {
17
- this.installCalls.push(ctx);
18
- this.installed = true;
19
- this.running = true;
20
- }
21
- async uninstall(ctx: ServiceHostContext): Promise<void> {
22
- this.uninstallCalls.push(ctx);
23
- this.installed = false;
24
- this.running = false;
25
- }
26
- async restart(_ctx: ServiceHostContext): Promise<void> {
27
- this.running = true;
28
- }
29
- async status(_ctx: ServiceHostContext): Promise<ServiceHostStatus> {
30
- return { installed: this.installed, running: this.running };
31
- }
32
- renderUnit(_ctx: ServiceHostContext): string | null {
33
- return 'rendered-unit';
34
- }
35
- }
36
-
37
- describe('Installer smoke', () => {
38
- let tmp: string;
39
-
40
- beforeEach(() => {
41
- tmp = mkdtempSync(join(tmpdir(), 'cadre-host-installer-smoke-'));
42
- });
43
-
44
- afterEach(() => {
45
- rmSync(tmp, { recursive: true, force: true });
46
- });
47
-
48
- it('install creates config + invokes service-host', async () => {
49
- const fake = new FakeServiceHost();
50
- const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
51
- const result = await installer.install({
52
- nonInteractive: true,
53
- dataDir: tmp,
54
- uiPort: 19999,
55
- openBrowser: false,
56
- serviceHost: fake,
57
- });
58
-
59
- expect(result.dataDir).toBe(tmp);
60
- expect(result.uiUrl).toBe('http://127.0.0.1:19999/');
61
- expect(result.serviceName).toBe('cadre-host');
62
- expect(result.configPath).toBe(join(tmp, 'host.config.json'));
63
-
64
- expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
65
- // The host holds no identity of its own; each node writes its own key in its workdir.
66
- expect(existsSync(join(tmp, 'identity.key'))).toBe(false);
67
- expect(existsSync(join(tmp, 'nat.json'))).toBe(true);
68
- expect(existsSync(join(tmp, 'logs'))).toBe(true);
69
-
70
- expect(fake.installCalls).toHaveLength(1);
71
- expect(fake.installCalls[0]!.dataDir).toBe(tmp);
72
- });
73
-
74
- it('noService writes the data dir without registering a service', async () => {
75
- const fake = new FakeServiceHost();
76
- const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
77
- const result = await installer.install({
78
- nonInteractive: true,
79
- dataDir: tmp,
80
- uiPort: 19995,
81
- noService: true,
82
- serviceHost: fake,
83
- });
84
-
85
- expect(fake.installCalls).toHaveLength(0);
86
- expect(result.serviceName).toBeUndefined();
87
- expect(result.uiUrl).toBe('http://127.0.0.1:19995/');
88
- expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
89
- expect(existsSync(join(tmp, 'nat.json'))).toBe(true);
90
- });
91
-
92
- it('uninstall removes data only when --remove-data is set', async () => {
93
- const fake = new FakeServiceHost();
94
- const installer = new Installer({ platform: 'linux' });
95
- await installer.install({
96
- nonInteractive: true,
97
- dataDir: tmp,
98
- uiPort: 19998,
99
- openBrowser: false,
100
- serviceHost: fake,
101
- });
102
- expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
103
-
104
- await installer.uninstall({ yes: true, removeData: false, dataDir: tmp, serviceHost: fake });
105
- expect(fake.uninstallCalls).toHaveLength(1);
106
- expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
107
-
108
- await installer.uninstall({ yes: true, removeData: true, dataDir: tmp, serviceHost: fake });
109
- expect(existsSync(tmp)).toBe(false);
110
- });
111
-
112
- it('rejects --system in v1', async () => {
113
- const fake = new FakeServiceHost();
114
- const installer = new Installer({ platform: 'linux' });
115
- await expect(installer.install({
116
- nonInteractive: true,
117
- dataDir: tmp,
118
- system: true,
119
- serviceHost: fake,
120
- openBrowser: false,
121
- })).rejects.toThrow(/system.*not yet supported/);
122
- expect(fake.installCalls).toHaveLength(0);
123
- });
124
-
125
- it('passes CLI overrides to the wizard as suggested defaults', async () => {
126
- // Regression: the interactive wizard previously showed the platform
127
- // default (`~/.local/share/cadre-host`) in the prompt even when the
128
- // caller passed `--data-dir <path>`. Whatever the user typed was then
129
- // silently discarded because the CLI flag won post-hoc. The fix: CLI
130
- // overrides become the wizard's suggested defaults, and the wizard's
131
- // returned answers stand on their own.
132
- const fake = new FakeServiceHost();
133
- const installer = new Installer({ platform: 'linux' });
134
- let seenDefaults: { dataDir: string; uiPort: number; upnpEnabled: boolean } | undefined;
135
- await installer.install({
136
- nonInteractive: false,
137
- dataDir: tmp,
138
- uiPort: 19997,
139
- noUpnp: true,
140
- openBrowser: false,
141
- serviceHost: fake,
142
- wizard: async (defaults) => {
143
- seenDefaults = { ...defaults };
144
- // Simulate the user pressing Enter at every prompt.
145
- return {
146
- dataDir: defaults.dataDir,
147
- uiPort: defaults.uiPort,
148
- upnpEnabled: defaults.upnpEnabled,
149
- configureDdns: false,
150
- };
151
- },
152
- });
153
-
154
- expect(seenDefaults).toEqual({
155
- dataDir: tmp,
156
- uiPort: 19997,
157
- upnpEnabled: false,
158
- });
159
- expect(fake.installCalls).toHaveLength(1);
160
- expect(fake.installCalls[0]!.dataDir).toBe(tmp);
161
- });
162
-
163
- it('honors wizard-typed input when no CLI override is passed', async () => {
164
- // Mirror of the previous test: with no `--data-dir`, the wizard's
165
- // returned value is what the installer uses.
166
- const fake = new FakeServiceHost();
167
- const installer = new Installer({ platform: 'linux' });
168
- await installer.install({
169
- nonInteractive: false,
170
- openBrowser: false,
171
- serviceHost: fake,
172
- wizard: async () => ({
173
- dataDir: tmp,
174
- uiPort: 19996,
175
- upnpEnabled: true,
176
- configureDdns: false,
177
- }),
178
- });
179
- expect(fake.installCalls).toHaveLength(1);
180
- expect(fake.installCalls[0]!.dataDir).toBe(tmp);
181
- });
182
- });
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { Installer } from '../index.js';
7
+ import type { ServiceHost, ServiceHostContext, ServiceHostStatus } from '../service-host/types.js';
8
+
9
+ class FakeServiceHost implements ServiceHost {
10
+ readonly name = 'cadre-host';
11
+ installCalls: ServiceHostContext[] = [];
12
+ uninstallCalls: ServiceHostContext[] = [];
13
+ installed = false;
14
+ running = false;
15
+
16
+ async install(ctx: ServiceHostContext): Promise<void> {
17
+ this.installCalls.push(ctx);
18
+ this.installed = true;
19
+ this.running = true;
20
+ }
21
+ async uninstall(ctx: ServiceHostContext): Promise<void> {
22
+ this.uninstallCalls.push(ctx);
23
+ this.installed = false;
24
+ this.running = false;
25
+ }
26
+ async restart(_ctx: ServiceHostContext): Promise<void> {
27
+ this.running = true;
28
+ }
29
+ async status(_ctx: ServiceHostContext): Promise<ServiceHostStatus> {
30
+ return { installed: this.installed, running: this.running };
31
+ }
32
+ renderUnit(_ctx: ServiceHostContext): string | null {
33
+ return 'rendered-unit';
34
+ }
35
+ }
36
+
37
+ describe('Installer smoke', () => {
38
+ let tmp: string;
39
+
40
+ beforeEach(() => {
41
+ tmp = mkdtempSync(join(tmpdir(), 'cadre-host-installer-smoke-'));
42
+ });
43
+
44
+ afterEach(() => {
45
+ rmSync(tmp, { recursive: true, force: true });
46
+ });
47
+
48
+ it('install creates config + invokes service-host', async () => {
49
+ const fake = new FakeServiceHost();
50
+ const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
51
+ const result = await installer.install({
52
+ nonInteractive: true,
53
+ dataDir: tmp,
54
+ uiPort: 19999,
55
+ openBrowser: false,
56
+ serviceHost: fake,
57
+ });
58
+
59
+ expect(result.dataDir).toBe(tmp);
60
+ expect(result.uiUrl).toBe('http://127.0.0.1:19999/');
61
+ expect(result.serviceName).toBe('cadre-host');
62
+ expect(result.configPath).toBe(join(tmp, 'host.config.json'));
63
+
64
+ expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
65
+ // The host holds no identity of its own; each node writes its own key in its workdir.
66
+ expect(existsSync(join(tmp, 'identity.key'))).toBe(false);
67
+ expect(existsSync(join(tmp, 'nat.json'))).toBe(true);
68
+ expect(existsSync(join(tmp, 'logs'))).toBe(true);
69
+
70
+ expect(fake.installCalls).toHaveLength(1);
71
+ expect(fake.installCalls[0]!.dataDir).toBe(tmp);
72
+ });
73
+
74
+ it('noService writes the data dir without registering a service', async () => {
75
+ const fake = new FakeServiceHost();
76
+ const installer = new Installer({ platform: 'linux', installerVersion: 'test-1.0.0' });
77
+ const result = await installer.install({
78
+ nonInteractive: true,
79
+ dataDir: tmp,
80
+ uiPort: 19995,
81
+ noService: true,
82
+ serviceHost: fake,
83
+ });
84
+
85
+ expect(fake.installCalls).toHaveLength(0);
86
+ expect(result.serviceName).toBeUndefined();
87
+ expect(result.uiUrl).toBe('http://127.0.0.1:19995/');
88
+ expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
89
+ expect(existsSync(join(tmp, 'nat.json'))).toBe(true);
90
+ });
91
+
92
+ it('uninstall removes data only when --remove-data is set', async () => {
93
+ const fake = new FakeServiceHost();
94
+ const installer = new Installer({ platform: 'linux' });
95
+ await installer.install({
96
+ nonInteractive: true,
97
+ dataDir: tmp,
98
+ uiPort: 19998,
99
+ openBrowser: false,
100
+ serviceHost: fake,
101
+ });
102
+ expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
103
+
104
+ await installer.uninstall({ yes: true, removeData: false, dataDir: tmp, serviceHost: fake });
105
+ expect(fake.uninstallCalls).toHaveLength(1);
106
+ expect(existsSync(join(tmp, 'host.config.json'))).toBe(true);
107
+
108
+ await installer.uninstall({ yes: true, removeData: true, dataDir: tmp, serviceHost: fake });
109
+ expect(existsSync(tmp)).toBe(false);
110
+ });
111
+
112
+ it('rejects --system in v1', async () => {
113
+ const fake = new FakeServiceHost();
114
+ const installer = new Installer({ platform: 'linux' });
115
+ await expect(installer.install({
116
+ nonInteractive: true,
117
+ dataDir: tmp,
118
+ system: true,
119
+ serviceHost: fake,
120
+ openBrowser: false,
121
+ })).rejects.toThrow(/system.*not yet supported/);
122
+ expect(fake.installCalls).toHaveLength(0);
123
+ });
124
+
125
+ it('passes CLI overrides to the wizard as suggested defaults', async () => {
126
+ // Regression: the interactive wizard previously showed the platform
127
+ // default (`~/.local/share/cadre-host`) in the prompt even when the
128
+ // caller passed `--data-dir <path>`. Whatever the user typed was then
129
+ // silently discarded because the CLI flag won post-hoc. The fix: CLI
130
+ // overrides become the wizard's suggested defaults, and the wizard's
131
+ // returned answers stand on their own.
132
+ const fake = new FakeServiceHost();
133
+ const installer = new Installer({ platform: 'linux' });
134
+ let seenDefaults: Record<string, unknown> | undefined;
135
+ await installer.install({
136
+ nonInteractive: false,
137
+ dataDir: tmp,
138
+ uiPort: 19997,
139
+ noUpnp: true,
140
+ openBrowser: false,
141
+ serviceHost: fake,
142
+ publicInterfaceAddress: () => null,
143
+ wizard: async (defaults) => {
144
+ seenDefaults = { ...defaults };
145
+ // Simulate the user pressing Enter at every prompt.
146
+ return {
147
+ dataDir: defaults.dataDir,
148
+ uiPort: defaults.uiPort,
149
+ upnpEnabled: defaults.upnpEnabled,
150
+ };
151
+ },
152
+ });
153
+
154
+ // Every value came from a flag, so the wizard is told not to ask for any of them.
155
+ expect(seenDefaults).toEqual({
156
+ dataDir: tmp,
157
+ uiPort: 19997,
158
+ upnpEnabled: false,
159
+ given: { dataDir: true, uiPort: true, upnpEnabled: true },
160
+ publicInterfaceIp: null,
161
+ });
162
+ expect(fake.installCalls).toHaveLength(1);
163
+ expect(fake.installCalls[0]!.dataDir).toBe(tmp);
164
+ });
165
+
166
+ it('honors wizard-typed input when no CLI override is passed', async () => {
167
+ // Mirror of the previous test: with no `--data-dir`, the wizard's
168
+ // returned value is what the installer uses.
169
+ const fake = new FakeServiceHost();
170
+ const installer = new Installer({ platform: 'linux' });
171
+ await installer.install({
172
+ nonInteractive: false,
173
+ openBrowser: false,
174
+ serviceHost: fake,
175
+ wizard: async () => ({
176
+ dataDir: tmp,
177
+ uiPort: 19996,
178
+ upnpEnabled: true,
179
+ }),
180
+ });
181
+ expect(fake.installCalls).toHaveLength(1);
182
+ expect(fake.installCalls[0]!.dataDir).toBe(tmp);
183
+ });
184
+ });
@@ -1,56 +1,76 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { runWizardWith, type WizardDefaults } from '../wizard.js';
4
-
5
- const defaults: WizardDefaults = {
6
- dataDir: '/data/cadre-host',
7
- uiPort: 8765,
8
- upnpEnabled: true,
9
- };
10
-
11
- function scripted(answers: ReadonlyArray<string>): (label: string, fallback: string) => Promise<string> {
12
- let i = 0;
13
- return async (_label, _fallback) => {
14
- if (i >= answers.length) throw new Error(`wizard asked more questions than scripted (i=${i})`);
15
- return answers[i++]!;
16
- };
17
- }
18
-
19
- describe('runWizardWith', () => {
20
- it('returns the defaults when every answer is blank', async () => {
21
- const out = await runWizardWith(defaults, scripted(['', '', '', '']));
22
- expect(out).toEqual({
23
- dataDir: '/data/cadre-host',
24
- uiPort: 8765,
25
- upnpEnabled: true,
26
- configureDdns: false,
27
- });
28
- });
29
-
30
- it('respects overrides', async () => {
31
- const out = await runWizardWith(defaults, scripted([
32
- '/srv/host',
33
- '9000',
34
- 'n',
35
- 'y',
36
- ]));
37
- expect(out).toEqual({
38
- dataDir: '/srv/host',
39
- uiPort: 9000,
40
- upnpEnabled: false,
41
- configureDdns: true,
42
- });
43
- });
44
-
45
- it('rejects invalid port input', async () => {
46
- await expect(
47
- runWizardWith(defaults, scripted(['', 'banana', '', ''])),
48
- ).rejects.toThrow(/Invalid port/);
49
- });
50
-
51
- it('rejects unrecognized yes/no answers', async () => {
52
- await expect(
53
- runWizardWith(defaults, scripted(['', '', 'maybe', ''])),
54
- ).rejects.toThrow(/Invalid yes\/no/);
55
- });
56
- });
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { runWizardWith, type WizardDefaults } from '../wizard.js';
4
+
5
+ const defaults: WizardDefaults = {
6
+ dataDir: '/data/cadre-host',
7
+ uiPort: 8765,
8
+ upnpEnabled: true,
9
+ };
10
+
11
+ function scripted(answers: ReadonlyArray<string>): (label: string, fallback: string) => Promise<string> {
12
+ let i = 0;
13
+ return async (_label, _fallback) => {
14
+ if (i >= answers.length) throw new Error(`wizard asked more questions than scripted (i=${i})`);
15
+ return answers[i++]!;
16
+ };
17
+ }
18
+
19
+ describe('runWizardWith', () => {
20
+ it('returns the defaults when every answer is blank', async () => {
21
+ const out = await runWizardWith(defaults, scripted(['', '', '', '']));
22
+ expect(out).toEqual({
23
+ dataDir: '/data/cadre-host',
24
+ uiPort: 8765,
25
+ upnpEnabled: true,
26
+ });
27
+ });
28
+
29
+ it('respects overrides', async () => {
30
+ const out = await runWizardWith(defaults, scripted([
31
+ '/srv/host',
32
+ '9000',
33
+ 'n',
34
+ 'y',
35
+ ]));
36
+ expect(out).toEqual({
37
+ dataDir: '/srv/host',
38
+ uiPort: 9000,
39
+ upnpEnabled: false,
40
+ });
41
+ });
42
+
43
+ it('rejects invalid port input', async () => {
44
+ await expect(
45
+ runWizardWith(defaults, scripted(['', 'banana', '', ''])),
46
+ ).rejects.toThrow(/Invalid port/);
47
+ });
48
+
49
+ it('asks nothing a flag already answered', async () => {
50
+ const asked: string[] = [];
51
+ const out = await runWizardWith(
52
+ { ...defaults, dataDir: '/srv/host', uiPort: 9000, upnpEnabled: false, given: { dataDir: true, uiPort: true, upnpEnabled: true } },
53
+ async (label) => { asked.push(label); return ''; },
54
+ );
55
+ expect(asked).toEqual([]);
56
+ expect(out).toEqual({ dataDir: '/srv/host', uiPort: 9000, upnpEnabled: false });
57
+ });
58
+
59
+ it('does not ask about UPnP on a machine with a public IP, and says why', async () => {
60
+ const asked: string[] = [];
61
+ const said: string[] = [];
62
+ await runWizardWith(
63
+ { ...defaults, given: { dataDir: true }, publicInterfaceIp: '203.0.113.7' },
64
+ async (label) => { asked.push(label); return ''; },
65
+ (line) => said.push(line),
66
+ );
67
+ expect(asked).toEqual(['UI port (localhost only)']);
68
+ expect(said.join(' ')).toMatch(/public IP address \(203\.0\.113\.7\).*no UPnP/);
69
+ });
70
+
71
+ it('rejects unrecognized yes/no answers', async () => {
72
+ await expect(
73
+ runWizardWith(defaults, scripted(['', '', 'maybe', ''])),
74
+ ).rejects.toThrow(/Invalid yes\/no/);
75
+ });
76
+ });
@@ -12,8 +12,11 @@
12
12
  */
13
13
 
14
14
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { isIP } from 'node:net';
15
16
  import { dirname } from 'node:path';
16
17
 
18
+ import { destinationPeerIdOf } from '@serfab/cadre-core';
19
+
17
20
  export interface UpdatesConfig {
18
21
  /** When true, the daily-check timer auto-applies updates. Default false. */
19
22
  autoApply: boolean;
@@ -41,6 +44,65 @@ export interface PushSettings {
41
44
  debounceMs?: number;
42
45
  }
43
46
 
47
+ /**
48
+ * Which addresses a hosted node's QR code carries (cadre-core's `selectNodeClaimAddresses`).
49
+ * Absent ⇒ `{ lan: 'auto' }`. Read on every claim-code request, so an edit applies to the
50
+ * next code shown.
51
+ */
52
+ export interface ClaimAddressSettings {
53
+ /**
54
+ * The LAN address a phone at home dials: `'auto'` (the address on the router's subnet, else
55
+ * the machine's primary one; none in public-IP mode), `'none'` (public addresses only), or an
56
+ * IP address. Other private addresses (Docker bridges, VPNs) are left out either way.
57
+ */
58
+ lan?: string;
59
+ /**
60
+ * Exact addresses to put in every code instead, for an operator who knows better than the
61
+ * rule, such as one name behind a proxy. Each names no destination peer (a relay address ends
62
+ * at its `/p2p-circuit` hop); every node's code appends its own peer id.
63
+ */
64
+ addrs?: string[];
65
+ }
66
+
67
+ /** Why a `claimAddresses` value is unacceptable, naming the field; undefined when it is fine. */
68
+ export function claimAddressSettingsProblem(value: unknown): string | undefined {
69
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return '"claimAddresses" must be an object';
70
+ const o = value as Record<string, unknown>;
71
+ for (const key of Object.keys(o)) {
72
+ if (key !== 'lan' && key !== 'addrs') return `Unknown setting "claimAddresses.${key}"`;
73
+ }
74
+ if (o.lan !== undefined) {
75
+ if (typeof o.lan !== 'string' || (o.lan !== 'auto' && o.lan !== 'none' && isIP(o.lan) === 0)) {
76
+ return '"claimAddresses.lan" must be "auto", "none" or an IP address';
77
+ }
78
+ }
79
+ if (o.addrs !== undefined) {
80
+ if (!Array.isArray(o.addrs) || !o.addrs.every((a) => typeof a === 'string')) {
81
+ return '"claimAddresses.addrs" must be a list of multiaddr strings';
82
+ }
83
+ for (const addr of o.addrs as string[]) {
84
+ const problem = exactClaimAddressProblem(addr);
85
+ if (problem) return `"claimAddresses.addrs" entry "${addr}" ${problem}`;
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ /**
92
+ * Why one `claimAddresses.addrs` entry is unacceptable. It must not name its destination peer:
93
+ * the list is host-wide and each hosted node's code appends its own peer id, so a named one
94
+ * would be some other node's address in every code but that node's.
95
+ */
96
+ function exactClaimAddressProblem(addr: string): string | undefined {
97
+ let destination: string | null;
98
+ try {
99
+ destination = destinationPeerIdOf(addr.trim());
100
+ } catch {
101
+ return 'is not a valid multiaddr';
102
+ }
103
+ return destination === null ? undefined : 'must not end in /p2p/<peer id>: each node\'s own id is appended';
104
+ }
105
+
44
106
  export interface HostConfigFile {
45
107
  version: 3;
46
108
  /** A stable per-install id, for log correlation. */
@@ -63,6 +125,8 @@ export interface HostConfigFile {
63
125
  * they are read from the secret store at node-spawn time (`src/push/`).
64
126
  */
65
127
  push?: PushSettings;
128
+ /** Which addresses claim codes carry; absent ⇒ automatic. */
129
+ claimAddresses?: ClaimAddressSettings;
66
130
  }
67
131
 
68
132
  const CURRENT_VERSION = 3;
@@ -111,6 +175,8 @@ export function readHostConfig(filePath: string): HostConfigFile {
111
175
  if (!isHostConfigShape(obj)) {
112
176
  throw new Error(`host.config.json at ${filePath} is missing required fields`);
113
177
  }
178
+ const claimAddressesProblem = obj.claimAddresses === undefined ? undefined : claimAddressSettingsProblem(obj.claimAddresses);
179
+ if (claimAddressesProblem) throw new Error(`host.config.json at ${filePath}: ${claimAddressesProblem}`);
114
180
  return obj;
115
181
  }
116
182