@flui-cloud/cli 0.0.1 → 0.1.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 (106) hide show
  1. package/lib/cli/src/commands/app/list.d.ts +3 -0
  2. package/lib/cli/src/commands/app/list.js +72 -18
  3. package/lib/cli/src/commands/app/status.d.ts +1 -0
  4. package/lib/cli/src/commands/app/status.js +27 -2
  5. package/lib/cli/src/commands/cluster/destroy.d.ts +1 -1
  6. package/lib/cli/src/commands/cluster/destroy.js +2 -2
  7. package/lib/cli/src/commands/deploy.d.ts +3 -0
  8. package/lib/cli/src/commands/deploy.js +19 -0
  9. package/lib/cli/src/commands/dev/creds.d.ts +0 -1
  10. package/lib/cli/src/commands/dev/creds.js +6 -27
  11. package/lib/cli/src/commands/dev/tunnel.js +8 -8
  12. package/lib/cli/src/commands/env/capacity.js +4 -4
  13. package/lib/cli/src/commands/env/create.d.ts +4 -1
  14. package/lib/cli/src/commands/env/create.js +73 -52
  15. package/lib/cli/src/commands/env/credentials.js +12 -12
  16. package/lib/cli/src/commands/env/destroy.d.ts +2 -1
  17. package/lib/cli/src/commands/env/destroy.js +45 -28
  18. package/lib/cli/src/commands/env/diag-ca.js +5 -5
  19. package/lib/cli/src/commands/env/export-config.d.ts +0 -17
  20. package/lib/cli/src/commands/env/export-config.js +45 -44
  21. package/lib/cli/src/commands/env/force-ready.d.ts +1 -1
  22. package/lib/cli/src/commands/env/force-ready.js +8 -8
  23. package/lib/cli/src/commands/env/inspect.js +5 -5
  24. package/lib/cli/src/commands/env/refresh-kubeconfig.js +4 -4
  25. package/lib/cli/src/commands/env/repair-ssh-ca.js +4 -4
  26. package/lib/cli/src/commands/env/repair-storage.d.ts +9 -0
  27. package/lib/cli/src/commands/env/repair-storage.js +82 -0
  28. package/lib/cli/src/commands/env/restart.d.ts +1 -1
  29. package/lib/cli/src/commands/env/restart.js +9 -9
  30. package/lib/cli/src/commands/env/scale-master.js +4 -4
  31. package/lib/cli/src/commands/env/scale-node.js +4 -4
  32. package/lib/cli/src/commands/env/set-master-protection.d.ts +16 -0
  33. package/lib/cli/src/commands/env/set-master-protection.js +120 -0
  34. package/lib/cli/src/commands/env/status.d.ts +1 -1
  35. package/lib/cli/src/commands/env/status.js +10 -10
  36. package/lib/cli/src/commands/env/stop.d.ts +1 -1
  37. package/lib/cli/src/commands/env/stop.js +8 -8
  38. package/lib/cli/src/commands/env/storage-expand.js +4 -4
  39. package/lib/cli/src/commands/env/storage.d.ts +1 -1
  40. package/lib/cli/src/commands/env/storage.js +5 -5
  41. package/lib/cli/src/commands/env/sync.js +5 -5
  42. package/lib/cli/src/commands/env/uncordon.js +4 -4
  43. package/lib/cli/src/commands/env/update-firewall.d.ts +13 -1
  44. package/lib/cli/src/commands/env/update-firewall.js +232 -126
  45. package/lib/cli/src/commands/integration/connect.d.ts +1 -0
  46. package/lib/cli/src/commands/integration/connect.js +19 -1
  47. package/lib/cli/src/commands/integration/reset.d.ts +13 -0
  48. package/lib/cli/src/commands/integration/reset.js +95 -0
  49. package/lib/cli/src/commands/integration/setup.d.ts +18 -0
  50. package/lib/cli/src/commands/integration/setup.js +320 -0
  51. package/lib/cli/src/commands/integration/status.d.ts +9 -0
  52. package/lib/cli/src/commands/integration/status.js +117 -0
  53. package/lib/cli/src/commands/node/list.d.ts +1 -0
  54. package/lib/cli/src/commands/node/list.js +19 -2
  55. package/lib/cli/src/commands/server-types/list.d.ts +3 -0
  56. package/lib/cli/src/commands/server-types/list.js +84 -0
  57. package/lib/cli/src/commands/ssh.js +5 -5
  58. package/lib/cli/src/commands/version.d.ts +18 -0
  59. package/lib/cli/src/commands/version.js +85 -0
  60. package/lib/cli/src/config/bootstrap.config.d.ts +10 -1
  61. package/lib/cli/src/config/bootstrap.config.js +21 -4
  62. package/lib/cli/src/config/preferences-schema.js +5 -5
  63. package/lib/cli/src/config/release.config.d.ts +31 -0
  64. package/lib/cli/src/config/release.config.js +38 -0
  65. package/lib/cli/src/lib/prompts.d.ts +1 -6
  66. package/lib/cli/src/lib/prompts.js +33 -13
  67. package/lib/cli/src/lib/services/cli-app.service.d.ts +33 -0
  68. package/lib/cli/src/lib/services/cli-app.service.js +9 -0
  69. package/lib/cli/src/lib/services/reconciliation.service.js +1 -1
  70. package/lib/cli/src/lib/templates/firewall-rules.d.ts +2 -2
  71. package/lib/cli/src/lib/templates/firewall-rules.js +3 -3
  72. package/lib/cli/src/modules/cli-infrastructure.module.js +3 -3
  73. package/lib/cli/src/services/cli-cluster-creator.service.js +31 -6
  74. package/lib/cli/src/services/cli-clusters.service.d.ts +3 -3
  75. package/lib/cli/src/services/cli-clusters.service.js +57 -34
  76. package/lib/cli/src/services/cli-control-cluster.service.d.ts +129 -0
  77. package/lib/cli/src/services/cli-control-cluster.service.js +544 -0
  78. package/lib/cli/src/services/cli-endpoint-resolver.service.d.ts +1 -0
  79. package/lib/cli/src/services/cli-endpoint-resolver.service.js +8 -2
  80. package/lib/cli/src/services/cli-k3s-script.service.d.ts +8 -1
  81. package/lib/cli/src/services/cli-k3s-script.service.js +14 -6
  82. package/lib/src/config/release.config.d.ts +28 -0
  83. package/lib/src/config/release.config.js +35 -0
  84. package/lib/src/modules/applications/entities/application.entity.d.ts +13 -20
  85. package/lib/src/modules/applications/entities/application.entity.js +12 -0
  86. package/lib/src/modules/applications/enums/application-exposure.enum.d.ts +2 -1
  87. package/lib/src/modules/applications/enums/application-exposure.enum.js +1 -0
  88. package/lib/src/modules/applications/interfaces/source-config.interface.d.ts +1 -0
  89. package/lib/src/modules/infrastructure/clusters/entities/cluster.entity.d.ts +8 -2
  90. package/lib/src/modules/infrastructure/clusters/entities/cluster.entity.js +16 -1
  91. package/lib/src/modules/infrastructure/clusters/services/cluster-node-scaling.service.js +2 -2
  92. package/lib/src/modules/infrastructure/firewalls/templates/firewall-rules.template.d.ts +3 -2
  93. package/lib/src/modules/infrastructure/firewalls/templates/firewall-rules.template.js +11 -4
  94. package/lib/src/modules/infrastructure/shared/services/kubernetes.service.d.ts +26 -0
  95. package/lib/src/modules/infrastructure/shared/services/kubernetes.service.js +105 -8
  96. package/lib/src/modules/management/entities/provider-capabilities.entity.d.ts +2 -0
  97. package/lib/src/modules/providers/implementations/contabo/contabo-capabilities.service.js +2 -0
  98. package/lib/src/modules/providers/implementations/hetzner/hetzner-capabilities.service.js +3 -6
  99. package/lib/src/modules/providers/implementations/scaleway/scaleway-capabilities.service.js +2 -1
  100. package/lib/src/modules/providers/implementations/scaleway/scaleway-firewall.service.js +3 -1
  101. package/lib/src/modules/providers/implementations/scaleway/scaleway-provider.service.js +3 -1
  102. package/lib/src/modules/providers/interfaces/provider-capabilities.interface.d.ts +0 -2
  103. package/lib/src/modules/providers/services/hetzner-firewall.service.d.ts +1 -1
  104. package/lib/src/modules/providers/services/hetzner-firewall.service.js +2 -1
  105. package/oclif.manifest.json +1025 -678
  106. package/package.json +2 -2
@@ -8,12 +8,30 @@ const chalk_1 = __importDefault(require("chalk"));
8
8
  const ora_1 = __importDefault(require("ora"));
9
9
  const nest_app_1 = require("../../lib/nest-app");
10
10
  const nip_base_domain_util_1 = require("../../lib/nip-base-domain.util");
11
- const cli_observability_cluster_service_1 = require("../../services/cli-observability-cluster.service");
11
+ const cli_control_cluster_service_1 = require("../../services/cli-control-cluster.service");
12
12
  const firewall_provider_factory_1 = require("../../../../src/modules/providers/core/factories/firewall-provider.factory");
13
13
  const ip_detection_1 = require("../../lib/utils/ip-detection");
14
14
  const cli_firewall_repository_1 = require("../../lib/repositories/cli-firewall.repository");
15
15
  const firewall_rules_1 = require("../../lib/templates/firewall-rules");
16
16
  const context_banner_1 = require("../../lib/context-banner");
17
+ const isSshRule = (r) => r.direction === 'in' && r.protocol === 'tcp' && r.port === '22';
18
+ /** Replace the SSH rule's source IPs in place; add one if absent (other rules untouched). */
19
+ function withSshSource(baseRules, sshCidrs) {
20
+ const rules = baseRules.length ? baseRules : (0, firewall_rules_1.CONTROL_FIREWALL_RULES)(sshCidrs);
21
+ if (!rules.some(isSshRule)) {
22
+ return [
23
+ {
24
+ description: 'SSH access for server management',
25
+ direction: 'in',
26
+ protocol: 'tcp',
27
+ port: '22',
28
+ sourceIps: sshCidrs,
29
+ },
30
+ ...rules,
31
+ ];
32
+ }
33
+ return rules.map((r) => (isSshRule(r) ? { ...r, sourceIps: sshCidrs } : r));
34
+ }
17
35
  class EnvUpdateFirewall extends core_1.Command {
18
36
  async run() {
19
37
  const { flags } = await this.parse(EnvUpdateFirewall);
@@ -21,14 +39,14 @@ class EnvUpdateFirewall extends core_1.Command {
21
39
  let spinner = (0, ora_1.default)('Loading cluster information...').start();
22
40
  try {
23
41
  const app = await (0, nest_app_1.getNestApp)();
24
- const observabilityService = app.get(cli_observability_cluster_service_1.CliObservabilityClusterService);
42
+ const controlService = app.get(cli_control_cluster_service_1.CliControlClusterService);
25
43
  const ipService = app.get(ip_detection_1.IpDetectionService);
26
44
  const firewallFactory = app.get(firewall_provider_factory_1.FirewallProviderFactory);
27
45
  const firewallRepo = app.get(cli_firewall_repository_1.CliFirewallRepository);
28
- const cluster = await observabilityService.getObservabilityCluster();
46
+ const cluster = await controlService.getControlCluster();
29
47
  if (!cluster) {
30
- spinner.fail('No observability cluster found');
31
- console.log(chalk_1.default.yellow('\n⚠️ No observability cluster exists.\n'));
48
+ spinner.fail('No control cluster found');
49
+ console.log(chalk_1.default.yellow('\n⚠️ No control cluster exists.\n'));
32
50
  console.log(chalk_1.default.dim('Create one with:'));
33
51
  console.log(` ${chalk_1.default.cyan('flui env create')}\n`);
34
52
  return;
@@ -37,149 +55,237 @@ class EnvUpdateFirewall extends core_1.Command {
37
55
  const providerEnum = (cluster.provider || '').toLowerCase();
38
56
  const firewallService = firewallFactory.getFirewallProviderOrFail(providerEnum);
39
57
  const providerLabel = providerEnum.toUpperCase();
40
- let sourceCidrs;
41
- if (flags.ip) {
42
- sourceCidrs = ipService.parseCidrList(flags.ip);
43
- console.log(chalk_1.default.blue(`\nUsing custom IP(s): ${sourceCidrs.join(', ')}`));
44
- }
45
- else {
46
- spinner = (0, ora_1.default)('Detecting public IP...').start();
47
- const publicIp = await ipService.getPublicIp();
48
- sourceCidrs = [ipService.toCidr(publicIp)];
49
- spinner.succeed(`Auto-detected IP: ${sourceCidrs[0]}`);
50
- }
51
58
  spinner = (0, ora_1.default)('Finding firewall...').start();
52
- let existingFirewall = await firewallRepo.findByClusterId(cluster.id);
53
- if (!existingFirewall) {
54
- const byProvider = await firewallRepo.findByProvider(providerLabel);
55
- if (byProvider.length === 1) {
56
- existingFirewall = byProvider[0];
57
- spinner.text = `Adopting unlinked ${providerLabel} firewall ${existingFirewall.name}`;
58
- }
59
- else if (byProvider.length > 1) {
60
- const masterIds = (cluster.nodes || [])
61
- .filter((n) => n.nodeType === 'master')
62
- .map((n) => {
63
- const raw = String(n.providerResourceId || '');
64
- const parts = raw.split(':');
65
- return parts.at(-1);
66
- })
67
- .filter(Boolean);
68
- spinner.text = `Disambiguating ${byProvider.length} firewall candidates by master attachment...`;
69
- const matches = [];
70
- for (const fw of byProvider) {
71
- const details = await firewallService
72
- .getFirewall(fw.id)
73
- .catch(() => null);
74
- if (!details)
75
- continue;
76
- const attached = new Set(details.appliedTo.map((a) => a.serverId));
77
- if (masterIds.some((m) => attached.has(m))) {
78
- matches.push(fw);
79
- }
80
- }
81
- if (matches.length === 1) {
82
- existingFirewall = matches[0];
83
- spinner.text = `Found attached firewall ${existingFirewall.name}`;
84
- }
85
- else if (matches.length === 0) {
86
- spinner.fail('No firewall currently attached to the cluster master');
87
- this.exit(1);
88
- }
89
- else {
90
- spinner.fail('Multiple firewalls attached, cannot disambiguate');
91
- for (const f of matches)
92
- this.log(` - ${f.name} (${f.id})`);
93
- this.exit(1);
94
- }
95
- }
96
- }
97
- if (existingFirewall) {
98
- spinner.text = 'Updating firewall rules...';
99
- const newRules = (0, firewall_rules_1.OBSERVABILITY_FIREWALL_RULES)(sourceCidrs);
100
- await firewallService.updateFirewallRules(existingFirewall.id, newRules);
101
- existingFirewall.clusterId = cluster.id;
102
- existingFirewall.provider = providerLabel;
103
- existingFirewall.sourceCidrs = sourceCidrs;
104
- existingFirewall.rules = newRules;
105
- await firewallRepo.save(existingFirewall);
106
- spinner.succeed('Firewall updated successfully');
59
+ const existingFirewall = await this.findFirewall(firewallRepo, firewallService, cluster, providerLabel, spinner);
60
+ if (flags.list) {
61
+ await this.showAllowlist(firewallService, existingFirewall, spinner);
62
+ return;
107
63
  }
108
- else {
109
- spinner.text = 'Creating firewall...';
110
- const firewallName = `flui-observability-${cluster.id}`;
111
- const rules = (0, firewall_rules_1.OBSERVABILITY_FIREWALL_RULES)(sourceCidrs);
112
- const result = await firewallService.createFirewall({
113
- name: firewallName,
114
- labels: [
115
- { key: 'managed-by', value: 'flui-cloud' },
116
- { key: 'flui-resource-type', value: 'firewall' },
117
- { key: 'flui-cluster-id', value: cluster.id },
118
- { key: 'flui-cluster-type', value: 'observability' },
119
- ],
120
- rules,
121
- applyToLabelSelector: `flui-cluster-id=${cluster.id}`,
122
- });
123
- const serverIds = (cluster.nodes || [])
124
- .map((n) => n.providerResourceId)
125
- .filter((x) => typeof x === 'string' && x.length > 0);
126
- if (serverIds.length > 0) {
127
- await firewallService.applyToServers(result.firewallId, serverIds);
128
- }
129
- await firewallRepo.save({
130
- id: result.firewallId,
131
- name: firewallName,
132
- provider: providerLabel,
133
- clusterId: cluster.id,
134
- rules,
135
- appliedToServerIds: serverIds,
136
- sourceCidrs,
137
- labels: [
138
- { key: 'managed-by', value: 'flui-cloud' },
139
- { key: 'flui-cluster-id', value: cluster.id },
140
- ],
141
- });
142
- spinner.succeed('Firewall created successfully');
64
+ if ((flags.add || flags.remove) && !existingFirewall) {
65
+ spinner.fail('No firewall found for this cluster');
66
+ console.log(chalk_1.default.dim('\nRun `flui env update-firewall` (no flags) to create one first.\n'));
67
+ return;
143
68
  }
144
- console.log(chalk_1.default.cyan('\n📋 Firewall Configuration:\n'));
145
- console.log(` ${chalk_1.default.bold('Provider:')} ${providerLabel}`);
146
- console.log(` ${chalk_1.default.bold('Cluster:')} ${cluster.name}`);
147
- console.log(` ${chalk_1.default.bold('Source IP:')} ${sourceCidrs.join(', ')}`);
148
- console.log('');
149
- console.log(chalk_1.default.bold('Exposed Services:'));
150
- console.log(` SSH: ${cluster.masterIpAddress}:22`);
151
- const baseDomain = (0, nip_base_domain_util_1.buildNipBaseDomain)(cluster.masterIpAddress, cluster.nipHostnameToken);
152
- console.log(` Flui API: https://api.${baseDomain}`);
153
- console.log(` Dashboard: https://app.${baseDomain}`);
154
- console.log(` Grafana/Prometheus/Loki: cluster-internal (kubectl port-forward)`);
155
- console.log('');
69
+ const sourceCidrs = await this.resolveSourceCidrs(ipService, flags.ip, spinner);
70
+ const finalSshCidrs = existingFirewall
71
+ ? await this.updateAllowlist(firewallService, firewallRepo, existingFirewall, cluster, providerLabel, sourceCidrs, flags)
72
+ : await this.createFirewall(firewallService, firewallRepo, cluster, providerLabel, sourceCidrs);
73
+ if (!finalSshCidrs)
74
+ return; // a guard (no-change / lockout) already reported
75
+ this.printSummary(cluster, providerLabel, finalSshCidrs);
156
76
  }
157
77
  catch (error) {
158
78
  spinner.fail('Failed to configure firewall');
159
79
  console.log(chalk_1.default.red('\n❌ Error:\n'));
160
- if (error instanceof Error) {
161
- console.log(` ${error.message}\n`);
162
- }
163
- else {
164
- console.log(` ${String(error)}\n`);
165
- }
80
+ console.log(` ${error instanceof Error ? error.message : String(error)}\n`);
166
81
  this.exit(1);
167
82
  }
168
83
  finally {
169
84
  await (0, nest_app_1.closeNestApp)();
170
85
  }
171
86
  }
87
+ /** Locate the cluster firewall, adopting/disambiguating unlinked ones by master attachment. */
88
+ async findFirewall(firewallRepo, firewallService, cluster, providerLabel, spinner) {
89
+ const linked = await firewallRepo.findByClusterId(cluster.id);
90
+ if (linked)
91
+ return linked;
92
+ const byProvider = await firewallRepo.findByProvider(providerLabel);
93
+ if (byProvider.length === 0)
94
+ return null;
95
+ if (byProvider.length === 1) {
96
+ spinner.text = `Adopting unlinked ${providerLabel} firewall ${byProvider[0].name}`;
97
+ return byProvider[0];
98
+ }
99
+ const masterIds = (cluster.nodes || [])
100
+ .filter((n) => n.nodeType === 'master')
101
+ .map((n) => String(n.providerResourceId || '')
102
+ .split(':')
103
+ .at(-1))
104
+ .filter(Boolean);
105
+ spinner.text = `Disambiguating ${byProvider.length} firewall candidates by master attachment...`;
106
+ const matches = [];
107
+ for (const fw of byProvider) {
108
+ const details = await firewallService
109
+ .getFirewall(fw.id)
110
+ .catch(() => null);
111
+ if (!details)
112
+ continue;
113
+ const attached = new Set(details.appliedTo.map((a) => a.serverId));
114
+ if (masterIds.some((m) => attached.has(m)))
115
+ matches.push(fw);
116
+ }
117
+ if (matches.length === 1) {
118
+ spinner.text = `Found attached firewall ${matches[0].name}`;
119
+ return matches[0];
120
+ }
121
+ if (matches.length === 0) {
122
+ spinner.fail('No firewall currently attached to the cluster master');
123
+ this.exit(1);
124
+ }
125
+ spinner.fail('Multiple firewalls attached, cannot disambiguate');
126
+ for (const f of matches)
127
+ this.log(` - ${f.name} (${f.id})`);
128
+ this.exit(1);
129
+ }
130
+ async sshSourceOf(firewallService, firewall) {
131
+ const live = await firewallService
132
+ .getFirewall(firewall.id)
133
+ .catch(() => null);
134
+ const rules = (live?.rules?.length ? live.rules : firewall.rules) ?? [];
135
+ return { rules, sshCidrs: rules.find(isSshRule)?.sourceIps ?? [] };
136
+ }
137
+ async showAllowlist(firewallService, firewall, spinner) {
138
+ if (!firewall) {
139
+ spinner.fail('No firewall found for this cluster');
140
+ return;
141
+ }
142
+ spinner.text = 'Reading current firewall rules...';
143
+ const { sshCidrs } = await this.sshSourceOf(firewallService, firewall);
144
+ spinner.succeed(`Firewall ${firewall.name}`);
145
+ console.log(chalk_1.default.cyan('\n📋 SSH allowlist (port 22):\n'));
146
+ if (sshCidrs.length === 0) {
147
+ console.log(chalk_1.default.yellow(' (empty — no source ranges, SSH is unreachable)'));
148
+ }
149
+ else {
150
+ for (const c of sshCidrs)
151
+ console.log(` ${c}`);
152
+ }
153
+ console.log('');
154
+ }
155
+ async resolveSourceCidrs(ipService, ip, spinner) {
156
+ if (ip) {
157
+ const cidrs = ipService.parseCidrList(ip);
158
+ spinner.info(`Using IP(s): ${cidrs.join(', ')}`);
159
+ return cidrs;
160
+ }
161
+ spinner.stop();
162
+ const detectSpinner = (0, ora_1.default)('Detecting public IP...').start();
163
+ const publicIp = await ipService.getPublicIp();
164
+ const cidr = ipService.toCidr(publicIp);
165
+ detectSpinner.succeed(`Auto-detected IP: ${cidr}`);
166
+ return [cidr];
167
+ }
168
+ /** Apply add/remove/replace to the SSH allowlist. Returns null when nothing was written. */
169
+ async updateAllowlist(firewallService, firewallRepo, firewall, cluster, providerLabel, sourceCidrs, flags) {
170
+ const spinner = (0, ora_1.default)('Reading current firewall rules...').start();
171
+ const { rules: baseRules, sshCidrs: currentSsh } = await this.sshSourceOf(firewallService, firewall);
172
+ let finalSshCidrs;
173
+ if (flags.add) {
174
+ finalSshCidrs = [...new Set([...currentSsh, ...sourceCidrs])];
175
+ if (finalSshCidrs.length === currentSsh.length) {
176
+ spinner.info('SSH allowlist already contains the given IP(s) — no change');
177
+ return null;
178
+ }
179
+ }
180
+ else if (flags.remove) {
181
+ finalSshCidrs = currentSsh.filter((c) => !sourceCidrs.includes(c));
182
+ if (finalSshCidrs.length === currentSsh.length) {
183
+ spinner.info('None of the given IP(s) were in the allowlist — no change');
184
+ return null;
185
+ }
186
+ }
187
+ else {
188
+ finalSshCidrs = sourceCidrs;
189
+ }
190
+ if (finalSshCidrs.length === 0) {
191
+ spinner.fail('Refusing to leave SSH with no allowed source ranges');
192
+ console.log(chalk_1.default.yellow('\n⚠️ That change would lock out all SSH access (port 22).\n'));
193
+ console.log(chalk_1.default.dim(' Keep at least one IP/CIDR, or pass --ip to set a new one.\n'));
194
+ return null;
195
+ }
196
+ const newRules = withSshSource(baseRules, finalSshCidrs);
197
+ spinner.text = 'Updating SSH allowlist...';
198
+ await firewallService.updateFirewallRules(firewall.id, newRules);
199
+ firewall.clusterId = cluster.id;
200
+ firewall.provider = providerLabel;
201
+ firewall.sourceCidrs = finalSshCidrs;
202
+ firewall.rules = newRules;
203
+ await firewallRepo.save(firewall);
204
+ spinner.succeed('SSH allowlist updated');
205
+ return finalSshCidrs;
206
+ }
207
+ async createFirewall(firewallService, firewallRepo, cluster, providerLabel, sourceCidrs) {
208
+ const spinner = (0, ora_1.default)('Creating firewall...').start();
209
+ const firewallName = `flui-control-${cluster.id}`;
210
+ const rules = (0, firewall_rules_1.CONTROL_FIREWALL_RULES)(sourceCidrs);
211
+ const result = await firewallService.createFirewall({
212
+ name: firewallName,
213
+ labels: [
214
+ { key: 'managed-by', value: 'flui-cloud' },
215
+ { key: 'flui-resource-type', value: 'firewall' },
216
+ { key: 'flui-cluster-id', value: cluster.id },
217
+ { key: 'flui-cluster-type', value: 'control' },
218
+ ],
219
+ rules,
220
+ applyToLabelSelector: `flui-cluster-id=${cluster.id}`,
221
+ });
222
+ const serverIds = (cluster.nodes || [])
223
+ .map((n) => n.providerResourceId)
224
+ .filter((x) => typeof x === 'string' && x.length > 0);
225
+ if (serverIds.length > 0) {
226
+ await firewallService.applyToServers(result.firewallId, serverIds);
227
+ }
228
+ await firewallRepo.save({
229
+ id: result.firewallId,
230
+ name: firewallName,
231
+ provider: providerLabel,
232
+ clusterId: cluster.id,
233
+ rules,
234
+ appliedToServerIds: serverIds,
235
+ sourceCidrs,
236
+ labels: [
237
+ { key: 'managed-by', value: 'flui-cloud' },
238
+ { key: 'flui-cluster-id', value: cluster.id },
239
+ ],
240
+ });
241
+ spinner.succeed('Firewall created successfully');
242
+ return sourceCidrs;
243
+ }
244
+ printSummary(cluster, providerLabel, finalSshCidrs) {
245
+ console.log(chalk_1.default.cyan('\n📋 Firewall Configuration:\n'));
246
+ console.log(` ${chalk_1.default.bold('Provider:')} ${providerLabel}`);
247
+ console.log(` ${chalk_1.default.bold('Cluster:')} ${cluster.name}`);
248
+ console.log(` ${chalk_1.default.bold('SSH allowlist:')} ${finalSshCidrs.join(', ')}`);
249
+ console.log('');
250
+ console.log(chalk_1.default.bold('Exposed Services:'));
251
+ console.log(` SSH: ${cluster.masterIpAddress}:22`);
252
+ const baseDomain = (0, nip_base_domain_util_1.buildNipBaseDomain)(cluster.masterIpAddress, cluster.nipHostnameToken);
253
+ console.log(` Flui API: https://api.${baseDomain}`);
254
+ console.log(` Dashboard: https://app.${baseDomain}`);
255
+ console.log(` Grafana/Prometheus/Loki: cluster-internal (kubectl port-forward)`);
256
+ console.log('');
257
+ }
172
258
  }
173
- EnvUpdateFirewall.description = 'Create or update firewall IP ranges for observability cluster';
259
+ EnvUpdateFirewall.description = 'Manage SSH access (port 22) on the control cluster firewall. ' +
260
+ 'Updates only the SSH source IPs — every other rule is left untouched. ' +
261
+ 'Runs directly against the cloud provider, so it works even when your ' +
262
+ 'current IP is locked out.';
174
263
  EnvUpdateFirewall.examples = [
175
264
  '<%= config.bin %> <%= command.id %>',
176
265
  '<%= config.bin %> <%= command.id %> --ip 203.0.113.42',
177
- '<%= config.bin %> <%= command.id %> --ip "203.0.113.0/24,198.51.100.5/32"',
266
+ '<%= config.bin %> <%= command.id %> --add --ip 203.0.113.42',
267
+ '<%= config.bin %> <%= command.id %> --remove --ip 198.51.100.5/32',
268
+ '<%= config.bin %> <%= command.id %> --list',
178
269
  ];
179
270
  EnvUpdateFirewall.flags = {
180
271
  ip: core_1.Flags.string({
181
272
  description: 'Source IP/CIDR or comma-separated list (default: auto-detect current IP)',
182
273
  required: false,
183
274
  }),
275
+ add: core_1.Flags.boolean({
276
+ description: 'Add the IP(s) to the existing SSH allowlist (keeps current entries)',
277
+ default: false,
278
+ exclusive: ['remove', 'list'],
279
+ }),
280
+ remove: core_1.Flags.boolean({
281
+ description: 'Remove the IP(s) from the SSH allowlist',
282
+ default: false,
283
+ exclusive: ['add', 'list'],
284
+ }),
285
+ list: core_1.Flags.boolean({
286
+ description: 'Show the current SSH allowlist and exit (no changes)',
287
+ default: false,
288
+ exclusive: ['add', 'remove'],
289
+ }),
184
290
  };
185
291
  exports.default = EnvUpdateFirewall;
@@ -9,5 +9,6 @@ export default class IntegrationConnect extends Command {
9
9
  headless: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
10
  };
11
11
  run(): Promise<void>;
12
+ private handleInstallUrlError;
12
13
  private waitForCallback;
13
14
  }
@@ -67,7 +67,7 @@ class IntegrationConnect extends core_1.Command {
67
67
  }
68
68
  catch (error) {
69
69
  spinner.fail('Failed to get install URL');
70
- console.log(chalk_1.default.red(` ${error.message}`));
70
+ this.handleInstallUrlError(error, apiUrl);
71
71
  this.exit(1);
72
72
  }
73
73
  if (install.alreadyConnected) {
@@ -104,6 +104,24 @@ class IntegrationConnect extends core_1.Command {
104
104
  console.log(chalk_1.default.red(`\n ✖ Connection failed: ${result.error}\n`));
105
105
  this.exit(1);
106
106
  }
107
+ handleInstallUrlError(error, apiUrl) {
108
+ const isNotConfigured = error instanceof api_client_1.ApiError &&
109
+ (error.statusCode === 400 ||
110
+ error.statusCode === 404 ||
111
+ error.statusCode === 503) &&
112
+ /not configured|callback url|client_id|not yet configured/i.test(error.message);
113
+ if (!isNotConfigured) {
114
+ console.log(chalk_1.default.red(` ${error.message}`));
115
+ return;
116
+ }
117
+ const dashboardHint = apiUrl.replace(/\/api(\/v1)?$/, '');
118
+ console.log('');
119
+ console.log(chalk_1.default.yellow(" This Flui instance doesn't have a GitHub integration configured yet."));
120
+ console.log('');
121
+ console.log(` Run: ${chalk_1.default.cyan('flui integration setup github')}`);
122
+ console.log(chalk_1.default.dim(` Or visit: ${dashboardHint}/apps/repositories/github-setup`));
123
+ console.log('');
124
+ }
107
125
  waitForCallback(port) {
108
126
  return new Promise((resolve) => {
109
127
  const timer = setTimeout(() => {
@@ -0,0 +1,13 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class IntegrationReset extends Command {
3
+ static readonly description = "Remove the instance-wide GitHub integration (admin). Clears the stored config plus all per-user tokens and App installations. Users will need to reconnect afterwards.";
4
+ static readonly examples: string[];
5
+ static readonly args: {
6
+ provider: import("@oclif/core/lib/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static readonly flags: {
9
+ yes: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
+ };
11
+ run(): Promise<void>;
12
+ private printApiError;
13
+ }
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const core_1 = require("@oclif/core");
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const ora_1 = __importDefault(require("ora"));
9
+ const api_client_1 = require("../../lib/api-client");
10
+ const config_storage_1 = require("../../lib/config-storage");
11
+ const prompts_1 = require("../../lib/prompts");
12
+ class IntegrationReset extends core_1.Command {
13
+ async run() {
14
+ const { args, flags } = await this.parse(IntegrationReset);
15
+ if (args.provider !== 'github') {
16
+ this.error(`Unknown provider "${args.provider}"`, { exit: 1 });
17
+ }
18
+ const configStorage = new config_storage_1.ConfigStorage();
19
+ const apiUrl = configStorage.getApiUrlOrThrow();
20
+ const apiKey = configStorage.getApiKey();
21
+ if (!apiKey) {
22
+ this.error('Not logged in. Run `flui auth login` first.', { exit: 1 });
23
+ }
24
+ const api = new api_client_1.ApiClient({ baseUrl: apiUrl, apiKey });
25
+ let status = null;
26
+ try {
27
+ status = await api.get('/repositories/github/setup/status');
28
+ }
29
+ catch (error) {
30
+ this.printApiError(error);
31
+ this.exit(1);
32
+ }
33
+ if (!status?.configured) {
34
+ console.log('');
35
+ console.log(chalk_1.default.dim(' GitHub integration is not configured — nothing to reset.'));
36
+ console.log('');
37
+ return;
38
+ }
39
+ console.log('');
40
+ console.log(chalk_1.default.bold(' This will remove:'));
41
+ console.log(` • Instance config (mode=${status.authMethod}${status.appSlug ? `, app=${status.appSlug}` : ''})`);
42
+ console.log(' • All per-user GitHub tokens stored by Flui');
43
+ console.log(' • All recorded GitHub App installations');
44
+ console.log('');
45
+ console.log(chalk_1.default.yellow(' Existing apps will keep deploying until their next event, but new repo syncs and webhooks will fail until the integration is reconfigured.'));
46
+ console.log('');
47
+ if (!flags.yes) {
48
+ const confirmed = await (0, prompts_1.confirmByTypingPrompt)(` Type ${chalk_1.default.bold('reset')} to confirm`, 'reset');
49
+ if (!confirmed) {
50
+ console.log(chalk_1.default.dim('\n Cancelled.\n'));
51
+ return;
52
+ }
53
+ }
54
+ const spinner = (0, ora_1.default)('Resetting GitHub integration…').start();
55
+ try {
56
+ await api.delete('/repositories/github/setup');
57
+ spinner.succeed('GitHub integration reset');
58
+ }
59
+ catch (error) {
60
+ spinner.fail('Reset failed');
61
+ this.printApiError(error);
62
+ this.exit(1);
63
+ }
64
+ console.log('');
65
+ console.log(chalk_1.default.dim(' Next: `flui integration setup github` to configure a fresh integration.'));
66
+ console.log('');
67
+ }
68
+ printApiError(error) {
69
+ if (error instanceof api_client_1.ApiError) {
70
+ console.log(chalk_1.default.red(` ${error.statusCode}: ${error.message}`));
71
+ }
72
+ else {
73
+ console.log(chalk_1.default.red(` ${error.message}`));
74
+ }
75
+ }
76
+ }
77
+ IntegrationReset.description = 'Remove the instance-wide GitHub integration (admin). Clears the stored config plus all per-user tokens and App installations. Users will need to reconnect afterwards.';
78
+ IntegrationReset.examples = [
79
+ '<%= config.bin %> <%= command.id %> github',
80
+ '<%= config.bin %> <%= command.id %> github --yes',
81
+ ];
82
+ IntegrationReset.args = {
83
+ provider: core_1.Args.string({
84
+ description: 'Integration provider (currently only `github`)',
85
+ required: true,
86
+ options: ['github'],
87
+ }),
88
+ };
89
+ IntegrationReset.flags = {
90
+ yes: core_1.Flags.boolean({
91
+ description: 'Skip the typed confirmation (for scripts / CI)',
92
+ default: false,
93
+ }),
94
+ };
95
+ exports.default = IntegrationReset;
@@ -0,0 +1,18 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class IntegrationSetup extends Command {
3
+ static readonly description = "Guided GitHub integration setup (admin). Pick GitHub App (recommended, creates the App on GitHub via manifest flow) or Personal Access Token (validates and saves a token).";
4
+ static readonly examples: string[];
5
+ static readonly args: {
6
+ provider: import("@oclif/core/lib/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static readonly flags: {
9
+ headless: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
+ };
11
+ run(): Promise<void>;
12
+ private fetchStatus;
13
+ private runManifestFlow;
14
+ private startManifestSubmitServer;
15
+ private pollUntilConfigured;
16
+ private runPatFlow;
17
+ private printApiError;
18
+ }