@hyperdrive.bot/cli 1.0.2
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/README.md +1598 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +3 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +5 -0
- package/dist/commands/account/add.d.ts +16 -0
- package/dist/commands/account/add.js +185 -0
- package/dist/commands/account/list.d.ts +6 -0
- package/dist/commands/account/list.js +37 -0
- package/dist/commands/account/remove.d.ts +11 -0
- package/dist/commands/account/remove.js +57 -0
- package/dist/commands/auth/login.d.ts +16 -0
- package/dist/commands/auth/login.js +178 -0
- package/dist/commands/auth/logout.d.ts +6 -0
- package/dist/commands/auth/logout.js +39 -0
- package/dist/commands/auth/refresh.d.ts +6 -0
- package/dist/commands/auth/refresh.js +66 -0
- package/dist/commands/auth/status.d.ts +6 -0
- package/dist/commands/auth/status.js +63 -0
- package/dist/commands/ci/account/create.d.ts +16 -0
- package/dist/commands/ci/account/create.js +158 -0
- package/dist/commands/ci/account/delete.d.ts +14 -0
- package/dist/commands/ci/account/delete.js +88 -0
- package/dist/commands/ci/account/list.d.ts +10 -0
- package/dist/commands/ci/account/list.js +65 -0
- package/dist/commands/config/get.d.ts +9 -0
- package/dist/commands/config/get.js +37 -0
- package/dist/commands/config/set.d.ts +10 -0
- package/dist/commands/config/set.js +48 -0
- package/dist/commands/config/show.d.ts +6 -0
- package/dist/commands/config/show.js +10 -0
- package/dist/commands/deployment/create.d.ts +30 -0
- package/dist/commands/deployment/create.js +188 -0
- package/dist/commands/deployment/get.d.ts +13 -0
- package/dist/commands/deployment/get.js +101 -0
- package/dist/commands/deployment/launch.d.ts +15 -0
- package/dist/commands/deployment/launch.js +105 -0
- package/dist/commands/deployment/list.d.ts +11 -0
- package/dist/commands/deployment/list.js +91 -0
- package/dist/commands/domain/current.d.ts +6 -0
- package/dist/commands/domain/current.js +18 -0
- package/dist/commands/domain/list.d.ts +6 -0
- package/dist/commands/domain/list.js +42 -0
- package/dist/commands/domain/switch.d.ts +9 -0
- package/dist/commands/domain/switch.js +40 -0
- package/dist/commands/example.d.ts +13 -0
- package/dist/commands/example.js +24 -0
- package/dist/commands/git/connect.d.ts +10 -0
- package/dist/commands/git/connect.js +56 -0
- package/dist/commands/git/disconnect.d.ts +11 -0
- package/dist/commands/git/disconnect.js +93 -0
- package/dist/commands/git/list.d.ts +10 -0
- package/dist/commands/git/list.js +53 -0
- package/dist/commands/git/sync.d.ts +18 -0
- package/dist/commands/git/sync.js +235 -0
- package/dist/commands/init.d.ts +188 -0
- package/dist/commands/init.js +817 -0
- package/dist/commands/jira/connect.d.ts +9 -0
- package/dist/commands/jira/connect.js +141 -0
- package/dist/commands/jira/status.d.ts +9 -0
- package/dist/commands/jira/status.js +118 -0
- package/dist/commands/module/analyze.d.ts +29 -0
- package/dist/commands/module/analyze.js +201 -0
- package/dist/commands/module/create.d.ts +42 -0
- package/dist/commands/module/create.js +498 -0
- package/dist/commands/module/destroy.d.ts +11 -0
- package/dist/commands/module/destroy.js +77 -0
- package/dist/commands/module/get.d.ts +10 -0
- package/dist/commands/module/get.js +43 -0
- package/dist/commands/module/link.d.ts +15 -0
- package/dist/commands/module/link.js +175 -0
- package/dist/commands/module/list.d.ts +9 -0
- package/dist/commands/module/list.js +51 -0
- package/dist/commands/module/reanalyze.d.ts +30 -0
- package/dist/commands/module/reanalyze.js +206 -0
- package/dist/commands/module/update.d.ts +27 -0
- package/dist/commands/module/update.js +102 -0
- package/dist/commands/parameter/add.d.ts +15 -0
- package/dist/commands/parameter/add.js +99 -0
- package/dist/commands/parameter/backfill.d.ts +12 -0
- package/dist/commands/parameter/backfill.js +113 -0
- package/dist/commands/parameter/clear.d.ts +14 -0
- package/dist/commands/parameter/clear.js +95 -0
- package/dist/commands/parameter/list.d.ts +14 -0
- package/dist/commands/parameter/list.js +92 -0
- package/dist/commands/parameter/pull.d.ts +14 -0
- package/dist/commands/parameter/pull.js +124 -0
- package/dist/commands/parameter/remove.d.ts +15 -0
- package/dist/commands/parameter/remove.js +90 -0
- package/dist/commands/parameter/sync.d.ts +14 -0
- package/dist/commands/parameter/sync.js +153 -0
- package/dist/commands/parameter/update.d.ts +15 -0
- package/dist/commands/parameter/update.js +100 -0
- package/dist/commands/stage/create.d.ts +28 -0
- package/dist/commands/stage/create.js +312 -0
- package/dist/commands/stage/list.d.ts +9 -0
- package/dist/commands/stage/list.js +63 -0
- package/dist/commands/test-api.d.ts +9 -0
- package/dist/commands/test-api.js +40 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/auth-service.d.ts +84 -0
- package/dist/services/auth-service.js +240 -0
- package/dist/services/git.d.ts +46 -0
- package/dist/services/git.js +409 -0
- package/dist/services/hyperdrive-sigv4.d.ts +449 -0
- package/dist/services/hyperdrive-sigv4.js +375 -0
- package/dist/services/hyperdrive.d.ts +87 -0
- package/dist/services/hyperdrive.js +108 -0
- package/dist/services/log-tailer.d.ts +95 -0
- package/dist/services/log-tailer.js +242 -0
- package/dist/services/tenant-service.d.ts +106 -0
- package/dist/services/tenant-service.js +332 -0
- package/dist/utils/account-flow.d.ts +74 -0
- package/dist/utils/account-flow.js +228 -0
- package/dist/utils/auth-flow.d.ts +146 -0
- package/dist/utils/auth-flow.js +477 -0
- package/dist/utils/git-flow.d.ts +72 -0
- package/dist/utils/git-flow.js +232 -0
- package/dist/utils/jira-flow.d.ts +71 -0
- package/dist/utils/jira-flow.js +120 -0
- package/dist/utils/summary-display.d.ts +59 -0
- package/dist/utils/summary-display.js +140 -0
- package/dist/utils/validation.d.ts +15 -0
- package/dist/utils/validation.js +32 -0
- package/oclif.manifest.json +2819 -0
- package/package.json +112 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudWatch Log Tailer Service
|
|
3
|
+
*
|
|
4
|
+
* Provides real-time log streaming from CloudWatch for deployment feedback.
|
|
5
|
+
* Uses scoped temporary credentials provided by the API for least-privilege access.
|
|
6
|
+
*/
|
|
7
|
+
import { CloudWatchLogsClient, FilterLogEventsCommand, } from '@aws-sdk/client-cloudwatch-logs';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import ora from 'ora';
|
|
10
|
+
/**
|
|
11
|
+
* CloudWatch Log Tailer
|
|
12
|
+
*
|
|
13
|
+
* Streams deployment logs in real-time and displays progress to the user.
|
|
14
|
+
*/
|
|
15
|
+
export class CloudWatchLogTailer {
|
|
16
|
+
client;
|
|
17
|
+
config;
|
|
18
|
+
currentStage = '';
|
|
19
|
+
isComplete = false;
|
|
20
|
+
lastEventTime = 0;
|
|
21
|
+
options;
|
|
22
|
+
spinner;
|
|
23
|
+
constructor(config, options = {}) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.options = {
|
|
26
|
+
pollInterval: options.pollInterval ?? 1000,
|
|
27
|
+
showDebug: options.showDebug ?? false,
|
|
28
|
+
verbose: options.verbose ?? false,
|
|
29
|
+
};
|
|
30
|
+
// Create CloudWatch client with scoped credentials
|
|
31
|
+
this.client = new CloudWatchLogsClient({
|
|
32
|
+
credentials: {
|
|
33
|
+
accessKeyId: config.credentials.accessKeyId,
|
|
34
|
+
secretAccessKey: config.credentials.secretAccessKey,
|
|
35
|
+
sessionToken: config.credentials.sessionToken,
|
|
36
|
+
},
|
|
37
|
+
region: config.region,
|
|
38
|
+
});
|
|
39
|
+
this.spinner = ora({ spinner: 'dots' });
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Start tailing logs until deployment completes or fails
|
|
43
|
+
*/
|
|
44
|
+
async tail() {
|
|
45
|
+
this.spinner.start(chalk.gray('Connecting to deployment logs...'));
|
|
46
|
+
const startTime = Date.now();
|
|
47
|
+
const timeout = 30 * 60 * 1000; // 30 min timeout
|
|
48
|
+
let retryCount = 0;
|
|
49
|
+
const maxRetries = 60; // 60 retries * 2s = 2 minutes waiting for logs to appear
|
|
50
|
+
let lastActivityTime = Date.now();
|
|
51
|
+
while (!this.isComplete) {
|
|
52
|
+
// Check credentials validity
|
|
53
|
+
if (!this.isCredentialsValid()) {
|
|
54
|
+
this.spinner.fail(chalk.red('Log streaming credentials expired'));
|
|
55
|
+
return {
|
|
56
|
+
message: 'Credentials expired. Deployment may still be running.',
|
|
57
|
+
success: false,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const events = await this.fetchNewEvents();
|
|
62
|
+
if (events.length > 0) {
|
|
63
|
+
retryCount = 0; // Reset retry count on successful fetch
|
|
64
|
+
lastActivityTime = Date.now();
|
|
65
|
+
for (const event of events) {
|
|
66
|
+
this.processEvent(event);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Check for timeout
|
|
70
|
+
if (Date.now() - startTime > timeout) {
|
|
71
|
+
this.spinner.fail(chalk.red('Deployment timed out after 30 minutes'));
|
|
72
|
+
return { message: 'Timeout', success: false };
|
|
73
|
+
}
|
|
74
|
+
// Warn if no activity for 5 minutes
|
|
75
|
+
if (Date.now() - lastActivityTime > 5 * 60 * 1000 && !this.isComplete) {
|
|
76
|
+
this.spinner.text = chalk.yellow('No activity for 5 minutes, still waiting...');
|
|
77
|
+
}
|
|
78
|
+
await this.sleep(this.options.pollInterval);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const err = error;
|
|
82
|
+
// Log stream may not have events yet
|
|
83
|
+
if (err.name === 'ResourceNotFoundException') {
|
|
84
|
+
retryCount++;
|
|
85
|
+
if (retryCount > maxRetries) {
|
|
86
|
+
this.spinner.fail(chalk.red('Log stream not found after waiting'));
|
|
87
|
+
return { message: 'Log stream unavailable', success: false };
|
|
88
|
+
}
|
|
89
|
+
this.spinner.text = chalk.gray(`Waiting for deployment to start... (${retryCount}/${maxRetries})`);
|
|
90
|
+
await this.sleep(2000);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// Access denied = credentials issue
|
|
94
|
+
if (err.name === 'AccessDeniedException') {
|
|
95
|
+
this.spinner.fail(chalk.red('Access denied to log stream'));
|
|
96
|
+
return { message: 'Access denied', success: false };
|
|
97
|
+
}
|
|
98
|
+
// Unknown error
|
|
99
|
+
this.spinner.fail(chalk.red(`Error: ${err.message}`));
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
message: this.currentStage === 'completed'
|
|
105
|
+
? 'Deployment complete!'
|
|
106
|
+
: 'Deployment failed',
|
|
107
|
+
success: this.currentStage === 'completed',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Fetch new events from CloudWatch
|
|
112
|
+
*/
|
|
113
|
+
async fetchNewEvents() {
|
|
114
|
+
const command = new FilterLogEventsCommand({
|
|
115
|
+
logGroupName: this.config.logGroup,
|
|
116
|
+
logStreamNames: [this.config.logStream],
|
|
117
|
+
startTime: this.lastEventTime + 1, // Exclude already-seen events
|
|
118
|
+
});
|
|
119
|
+
const response = await this.client.send(command);
|
|
120
|
+
return (response.events || [])
|
|
121
|
+
.map((e) => {
|
|
122
|
+
// Update last event time
|
|
123
|
+
if (e.timestamp && e.timestamp > this.lastEventTime) {
|
|
124
|
+
this.lastEventTime = e.timestamp;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(e.message || '{}');
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
.filter((e) => e !== null);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Format duration in human-readable form
|
|
137
|
+
*/
|
|
138
|
+
formatDuration(ms) {
|
|
139
|
+
const seconds = Math.floor(ms / 1000);
|
|
140
|
+
if (seconds < 60)
|
|
141
|
+
return `${seconds}s`;
|
|
142
|
+
const minutes = Math.floor(seconds / 60);
|
|
143
|
+
const secs = seconds % 60;
|
|
144
|
+
return `${minutes}m ${secs}s`;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Get color function for log level
|
|
148
|
+
*/
|
|
149
|
+
getLevelColor(level) {
|
|
150
|
+
switch (level) {
|
|
151
|
+
case 'debug':
|
|
152
|
+
return chalk.gray;
|
|
153
|
+
case 'error':
|
|
154
|
+
return chalk.red;
|
|
155
|
+
case 'info':
|
|
156
|
+
return chalk.white;
|
|
157
|
+
case 'warn':
|
|
158
|
+
return chalk.yellow;
|
|
159
|
+
default:
|
|
160
|
+
return chalk.white;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Handle a log line event
|
|
165
|
+
*/
|
|
166
|
+
handleLogEvent(event) {
|
|
167
|
+
if (!this.options.verbose)
|
|
168
|
+
return;
|
|
169
|
+
if (event.level === 'debug' && !this.options.showDebug)
|
|
170
|
+
return;
|
|
171
|
+
const levelColor = this.getLevelColor(event.level);
|
|
172
|
+
const sourceTag = chalk.dim(`[${event.source}]`);
|
|
173
|
+
const line = levelColor(event.line || '');
|
|
174
|
+
// Temporarily hide spinner, print log, restore
|
|
175
|
+
this.spinner.clear();
|
|
176
|
+
console.log(` ${sourceTag} ${line}`);
|
|
177
|
+
this.spinner.render();
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Handle a stage transition event
|
|
181
|
+
*/
|
|
182
|
+
handleStageEvent(event) {
|
|
183
|
+
this.currentStage = event.stage || '';
|
|
184
|
+
const { icon, message, progress, stage, totalDuration } = event;
|
|
185
|
+
if (stage === 'completed') {
|
|
186
|
+
const duration = totalDuration ? ` in ${this.formatDuration(totalDuration)}` : '';
|
|
187
|
+
this.spinner.succeed(chalk.green(`${icon || '🎉'} ${message}${duration}`));
|
|
188
|
+
this.isComplete = true;
|
|
189
|
+
}
|
|
190
|
+
else if (stage === 'failed') {
|
|
191
|
+
this.spinner.fail(chalk.red(`${icon || '❌'} ${message}`));
|
|
192
|
+
this.isComplete = true;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
const bar = this.renderProgressBar(progress || 0);
|
|
196
|
+
const time = totalDuration
|
|
197
|
+
? chalk.dim(` (${this.formatDuration(totalDuration)})`)
|
|
198
|
+
: '';
|
|
199
|
+
this.spinner.text = `${bar} ${icon || '•'} ${chalk.cyan(message)}${time}`;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Check if credentials are still valid
|
|
204
|
+
*/
|
|
205
|
+
isCredentialsValid() {
|
|
206
|
+
const expiration = new Date(this.config.credentials.expiration);
|
|
207
|
+
const now = new Date();
|
|
208
|
+
// Add 60 second buffer
|
|
209
|
+
return expiration.getTime() - now.getTime() > 60_000;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Process a single event
|
|
213
|
+
*/
|
|
214
|
+
processEvent(event) {
|
|
215
|
+
if (event.type === 'stage') {
|
|
216
|
+
this.handleStageEvent(event);
|
|
217
|
+
}
|
|
218
|
+
else if (event.type === 'log') {
|
|
219
|
+
this.handleLogEvent(event);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Render a progress bar
|
|
224
|
+
*/
|
|
225
|
+
renderProgressBar(progress) {
|
|
226
|
+
if (progress < 0)
|
|
227
|
+
return chalk.red('[FAILED]');
|
|
228
|
+
const width = 20;
|
|
229
|
+
const filled = Math.round((progress / 100) * width);
|
|
230
|
+
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
231
|
+
const pct = progress.toString().padStart(3);
|
|
232
|
+
return chalk.blue(`[${bar}] ${pct}%`);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Sleep for specified milliseconds
|
|
236
|
+
*/
|
|
237
|
+
sleep(ms) {
|
|
238
|
+
return new Promise((resolve) => {
|
|
239
|
+
setTimeout(resolve, ms);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export interface TenantConfig {
|
|
2
|
+
apiUrl: string;
|
|
3
|
+
cognitoClientId: string;
|
|
4
|
+
cognitoDomain: string;
|
|
5
|
+
cognitoIdentityPoolId: string;
|
|
6
|
+
cognitoUserPoolId: string;
|
|
7
|
+
displayName: string;
|
|
8
|
+
region: string;
|
|
9
|
+
tenantDomain: string;
|
|
10
|
+
tenantId: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CLIConfig {
|
|
13
|
+
apiUrl?: string;
|
|
14
|
+
bootstrapUrl?: string;
|
|
15
|
+
domains?: Record<string, {
|
|
16
|
+
apiUrl?: string;
|
|
17
|
+
bootstrapUrl?: string;
|
|
18
|
+
region?: string;
|
|
19
|
+
}>;
|
|
20
|
+
region?: string;
|
|
21
|
+
tenantDomain?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Tenant Service for CLI
|
|
25
|
+
*
|
|
26
|
+
* Handles tenant resolution and Cognito configuration discovery via bootstrap endpoint.
|
|
27
|
+
* Supports environment variables and config file overrides.
|
|
28
|
+
*/
|
|
29
|
+
export declare class TenantService {
|
|
30
|
+
private readonly configDir;
|
|
31
|
+
private readonly configPath;
|
|
32
|
+
private readonly defaultBootstrapUrl;
|
|
33
|
+
private readonly defaultDomainPath;
|
|
34
|
+
private readonly domain?;
|
|
35
|
+
constructor(domain?: string);
|
|
36
|
+
/**
|
|
37
|
+
* Clear cached tenant configuration
|
|
38
|
+
*/
|
|
39
|
+
clearCache(): void;
|
|
40
|
+
/**
|
|
41
|
+
* Fetch tenant configuration from bootstrap endpoint
|
|
42
|
+
*/
|
|
43
|
+
fetchTenantConfig(tenantDomain?: string): Promise<TenantConfig>;
|
|
44
|
+
/**
|
|
45
|
+
* Get all configured domains
|
|
46
|
+
*/
|
|
47
|
+
getConfiguredDomains(): string[];
|
|
48
|
+
/**
|
|
49
|
+
* Get current tenant configuration (from cache or fetch)
|
|
50
|
+
*/
|
|
51
|
+
getCurrentTenant(): Promise<TenantConfig>;
|
|
52
|
+
/**
|
|
53
|
+
* Get default domain from file
|
|
54
|
+
*/
|
|
55
|
+
getDefaultDomain(): null | string;
|
|
56
|
+
/**
|
|
57
|
+
* Get tenant domain with fallback chain:
|
|
58
|
+
* 1. Constructor domain parameter (from --domain flag)
|
|
59
|
+
* 2. Environment variable
|
|
60
|
+
* 3. Default domain file
|
|
61
|
+
* 4. Legacy single domain from config
|
|
62
|
+
* 5. Return null (caller should prompt user interactively)
|
|
63
|
+
*/
|
|
64
|
+
getTenantDomain(): null | string;
|
|
65
|
+
/**
|
|
66
|
+
* Save CLI configuration to file
|
|
67
|
+
*/
|
|
68
|
+
saveConfig(config: CLIConfig): void;
|
|
69
|
+
/**
|
|
70
|
+
* Set default domain
|
|
71
|
+
*/
|
|
72
|
+
setDefaultDomain(domain: string): void;
|
|
73
|
+
/**
|
|
74
|
+
* Set tenant domain in config file
|
|
75
|
+
*
|
|
76
|
+
* Public method for init wizard to persist tenant domain configuration.
|
|
77
|
+
* Creates config directory if needed and applies 0o600 permissions.
|
|
78
|
+
*
|
|
79
|
+
* @param domain - The tenant domain to save
|
|
80
|
+
*/
|
|
81
|
+
setTenantDomain(domain: string): void;
|
|
82
|
+
/**
|
|
83
|
+
* Display current configuration
|
|
84
|
+
*/
|
|
85
|
+
showConfig(): void;
|
|
86
|
+
/**
|
|
87
|
+
* Get API URL for a region
|
|
88
|
+
*/
|
|
89
|
+
private getApiUrl;
|
|
90
|
+
/**
|
|
91
|
+
* Get bootstrap URL with fallback chain:
|
|
92
|
+
* 1. Environment variable
|
|
93
|
+
* 2. Config file
|
|
94
|
+
* 3. Construct from tenant domain
|
|
95
|
+
* 4. Default URL
|
|
96
|
+
*/
|
|
97
|
+
private getBootstrapUrl;
|
|
98
|
+
/**
|
|
99
|
+
* Load CLI configuration from file
|
|
100
|
+
*/
|
|
101
|
+
private loadConfigFile;
|
|
102
|
+
/**
|
|
103
|
+
* Save tenant domain to config file for future use
|
|
104
|
+
*/
|
|
105
|
+
private saveTenantDomainToConfig;
|
|
106
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
/**
|
|
7
|
+
* Tenant Service for CLI
|
|
8
|
+
*
|
|
9
|
+
* Handles tenant resolution and Cognito configuration discovery via bootstrap endpoint.
|
|
10
|
+
* Supports environment variables and config file overrides.
|
|
11
|
+
*/
|
|
12
|
+
export class TenantService {
|
|
13
|
+
configDir;
|
|
14
|
+
configPath;
|
|
15
|
+
defaultBootstrapUrl = 'https://api.hyperdrive.bot/tenant/bootstrap';
|
|
16
|
+
defaultDomainPath;
|
|
17
|
+
domain;
|
|
18
|
+
constructor(domain) {
|
|
19
|
+
this.configDir = join(homedir(), '.hyperdrive');
|
|
20
|
+
this.configPath = join(this.configDir, 'config.json');
|
|
21
|
+
this.defaultDomainPath = join(this.configDir, 'default-domain');
|
|
22
|
+
this.domain = domain;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Clear cached tenant configuration
|
|
26
|
+
*/
|
|
27
|
+
clearCache() {
|
|
28
|
+
const config = this.loadConfigFile();
|
|
29
|
+
if (config) {
|
|
30
|
+
// Remove tenant-specific settings but keep custom URLs
|
|
31
|
+
const { tenantDomain, ...rest } = config;
|
|
32
|
+
this.saveConfig(rest);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Fetch tenant configuration from bootstrap endpoint
|
|
37
|
+
*/
|
|
38
|
+
async fetchTenantConfig(tenantDomain) {
|
|
39
|
+
const domain = tenantDomain || this.getTenantDomain();
|
|
40
|
+
if (!domain) {
|
|
41
|
+
throw new Error('Tenant not configured. Run `hd init` to set up your environment.');
|
|
42
|
+
}
|
|
43
|
+
const bootstrapUrl = this.getBootstrapUrl();
|
|
44
|
+
try {
|
|
45
|
+
console.log(chalk.gray(`🔗 Fetching tenant config for: ${domain}`));
|
|
46
|
+
console.log(chalk.gray(`📍 Using bootstrap endpoint: ${bootstrapUrl}`));
|
|
47
|
+
const response = await axios.get(bootstrapUrl, {
|
|
48
|
+
headers: {
|
|
49
|
+
'Accept': 'application/json',
|
|
50
|
+
'X-Tenant-Domain': domain,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const bootstrap = response.data;
|
|
54
|
+
if (!bootstrap.amplifyConfig) {
|
|
55
|
+
throw new Error('Tenant authentication not configured');
|
|
56
|
+
}
|
|
57
|
+
const { Cognito } = bootstrap.amplifyConfig.Auth;
|
|
58
|
+
const region = Cognito.region;
|
|
59
|
+
// Extract Cognito domain from OAuth config or construct from tenant ID
|
|
60
|
+
// The bootstrap response may provide either:
|
|
61
|
+
// - Just the prefix (e.g., "api-tenants-dev-semana-43-a1c4ea06")
|
|
62
|
+
// - Full domain (e.g., "api-tenants-dev-devsquad-804677f8.auth.sa-east-1.amazoncognito.com")
|
|
63
|
+
let cognitoDomain;
|
|
64
|
+
if (Cognito.loginWith?.oauth?.domain) {
|
|
65
|
+
const oauthDomain = Cognito.loginWith.oauth.domain;
|
|
66
|
+
// Check if domain already includes amazoncognito.com (full domain provided)
|
|
67
|
+
if (oauthDomain.includes('.amazoncognito.com')) {
|
|
68
|
+
cognitoDomain = oauthDomain;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
cognitoDomain = `${oauthDomain}.auth.${region}.amazoncognito.com`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Fallback: construct from tenant ID
|
|
76
|
+
cognitoDomain = `${bootstrap.tenantId}.auth.${region}.amazoncognito.com`;
|
|
77
|
+
}
|
|
78
|
+
const config = {
|
|
79
|
+
apiUrl: this.getApiUrl(region, bootstrap.amplifyConfig.API, domain),
|
|
80
|
+
cognitoClientId: Cognito.userPoolClientId,
|
|
81
|
+
cognitoDomain,
|
|
82
|
+
cognitoIdentityPoolId: Cognito.identityPoolId || '',
|
|
83
|
+
cognitoUserPoolId: Cognito.userPoolId,
|
|
84
|
+
displayName: bootstrap.displayName,
|
|
85
|
+
region,
|
|
86
|
+
tenantDomain: domain,
|
|
87
|
+
tenantId: bootstrap.tenantId,
|
|
88
|
+
};
|
|
89
|
+
// Cache the tenant domain for future use
|
|
90
|
+
this.saveTenantDomainToConfig(domain);
|
|
91
|
+
// Set as default domain if no default exists
|
|
92
|
+
if (!this.getDefaultDomain()) {
|
|
93
|
+
this.setDefaultDomain(domain);
|
|
94
|
+
}
|
|
95
|
+
return config;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (axios.isAxiosError(error)) {
|
|
99
|
+
if (error.response?.status === 404) {
|
|
100
|
+
throw new Error(`Tenant not found: ${domain}`);
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`Failed to fetch tenant config: ${error.response?.data?.message || error.message}`);
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Get all configured domains
|
|
109
|
+
*/
|
|
110
|
+
getConfiguredDomains() {
|
|
111
|
+
const config = this.loadConfigFile();
|
|
112
|
+
const domains = new Set();
|
|
113
|
+
// Add legacy single domain if exists
|
|
114
|
+
if (config?.tenantDomain) {
|
|
115
|
+
domains.add(config.tenantDomain);
|
|
116
|
+
}
|
|
117
|
+
// Add multi-domain entries
|
|
118
|
+
if (config?.domains) {
|
|
119
|
+
Object.keys(config.domains).forEach(d => domains.add(d));
|
|
120
|
+
}
|
|
121
|
+
return Array.from(domains);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Get current tenant configuration (from cache or fetch)
|
|
125
|
+
*/
|
|
126
|
+
async getCurrentTenant() {
|
|
127
|
+
return this.fetchTenantConfig();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Get default domain from file
|
|
131
|
+
*/
|
|
132
|
+
getDefaultDomain() {
|
|
133
|
+
try {
|
|
134
|
+
if (!existsSync(this.defaultDomainPath)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return readFileSync(this.defaultDomainPath, 'utf8').trim();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Get tenant domain with fallback chain:
|
|
145
|
+
* 1. Constructor domain parameter (from --domain flag)
|
|
146
|
+
* 2. Environment variable
|
|
147
|
+
* 3. Default domain file
|
|
148
|
+
* 4. Legacy single domain from config
|
|
149
|
+
* 5. Return null (caller should prompt user interactively)
|
|
150
|
+
*/
|
|
151
|
+
getTenantDomain() {
|
|
152
|
+
// Priority 1: Explicit domain parameter (from --domain flag)
|
|
153
|
+
if (this.domain) {
|
|
154
|
+
return this.domain;
|
|
155
|
+
}
|
|
156
|
+
// Priority 2: Environment variable
|
|
157
|
+
if (process.env.HYPERDRIVE_TENANT_DOMAIN) {
|
|
158
|
+
return process.env.HYPERDRIVE_TENANT_DOMAIN;
|
|
159
|
+
}
|
|
160
|
+
// Priority 3: Default domain file
|
|
161
|
+
const defaultDomain = this.getDefaultDomain();
|
|
162
|
+
if (defaultDomain) {
|
|
163
|
+
return defaultDomain;
|
|
164
|
+
}
|
|
165
|
+
// Priority 4: Legacy single domain from config file
|
|
166
|
+
const config = this.loadConfigFile();
|
|
167
|
+
if (config?.tenantDomain) {
|
|
168
|
+
return config.tenantDomain;
|
|
169
|
+
}
|
|
170
|
+
// Priority 5: Return null to trigger interactive prompt
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Save CLI configuration to file
|
|
175
|
+
*/
|
|
176
|
+
saveConfig(config) {
|
|
177
|
+
try {
|
|
178
|
+
if (!existsSync(this.configDir)) {
|
|
179
|
+
mkdirSync(this.configDir, { recursive: true });
|
|
180
|
+
}
|
|
181
|
+
writeFileSync(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
182
|
+
console.log(chalk.gray(`✓ Configuration saved to ${this.configPath}`));
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
console.error(chalk.red('Failed to save configuration:'), error);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Set default domain
|
|
190
|
+
*/
|
|
191
|
+
setDefaultDomain(domain) {
|
|
192
|
+
try {
|
|
193
|
+
if (!existsSync(this.configDir)) {
|
|
194
|
+
mkdirSync(this.configDir, { recursive: true });
|
|
195
|
+
}
|
|
196
|
+
writeFileSync(this.defaultDomainPath, domain, { mode: 0o600 });
|
|
197
|
+
console.log(chalk.gray(`✓ Default domain set to ${domain}`));
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
console.error(chalk.red('Failed to set default domain:'), error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Set tenant domain in config file
|
|
205
|
+
*
|
|
206
|
+
* Public method for init wizard to persist tenant domain configuration.
|
|
207
|
+
* Creates config directory if needed and applies 0o600 permissions.
|
|
208
|
+
*
|
|
209
|
+
* @param domain - The tenant domain to save
|
|
210
|
+
*/
|
|
211
|
+
setTenantDomain(domain) {
|
|
212
|
+
this.saveTenantDomainToConfig(domain);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Display current configuration
|
|
216
|
+
*/
|
|
217
|
+
showConfig() {
|
|
218
|
+
const config = this.loadConfigFile();
|
|
219
|
+
const tenantDomain = this.getTenantDomain();
|
|
220
|
+
const bootstrapUrl = this.getBootstrapUrl();
|
|
221
|
+
console.log(chalk.blue('🔧 Current Configuration'));
|
|
222
|
+
console.log('');
|
|
223
|
+
console.log(chalk.white('Bootstrap URL:'), chalk.cyan(bootstrapUrl));
|
|
224
|
+
console.log(chalk.white('Source:'), process.env.HYPERDRIVE_BOOTSTRAP_URL
|
|
225
|
+
? chalk.yellow('ENV')
|
|
226
|
+
: config?.bootstrapUrl
|
|
227
|
+
? chalk.green('Config File')
|
|
228
|
+
: chalk.gray('Default'));
|
|
229
|
+
console.log('');
|
|
230
|
+
console.log(chalk.white('Tenant Domain:'), tenantDomain ? chalk.cyan(tenantDomain) : chalk.gray('Not set'));
|
|
231
|
+
if (tenantDomain) {
|
|
232
|
+
console.log(chalk.white('Source:'), process.env.HYPERDRIVE_TENANT_DOMAIN
|
|
233
|
+
? chalk.yellow('ENV')
|
|
234
|
+
: config?.tenantDomain
|
|
235
|
+
? chalk.green('Config File')
|
|
236
|
+
: chalk.gray('Unknown'));
|
|
237
|
+
}
|
|
238
|
+
console.log('');
|
|
239
|
+
console.log(chalk.gray('Config file: ' + this.configPath));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Get API URL for a region
|
|
243
|
+
*/
|
|
244
|
+
getApiUrl(region, apiConfig, domain) {
|
|
245
|
+
// Priority 1: Environment variable (for testing/override)
|
|
246
|
+
if (process.env.HYPERDRIVE_API_URL) {
|
|
247
|
+
console.log(chalk.gray(`✓ Using API endpoint from environment: ${process.env.HYPERDRIVE_API_URL}`));
|
|
248
|
+
return process.env.HYPERDRIVE_API_URL;
|
|
249
|
+
}
|
|
250
|
+
// Priority 2: Domain-specific config (for multi-domain setups)
|
|
251
|
+
const config = this.loadConfigFile();
|
|
252
|
+
if (domain && config?.domains?.[domain]?.apiUrl) {
|
|
253
|
+
const domainApiUrl = config.domains[domain].apiUrl;
|
|
254
|
+
console.log(chalk.gray(`✓ Using API endpoint from domain config: ${domainApiUrl}`));
|
|
255
|
+
return domainApiUrl;
|
|
256
|
+
}
|
|
257
|
+
// Priority 3: Legacy global config file (for manual override)
|
|
258
|
+
if (config?.apiUrl) {
|
|
259
|
+
console.log(chalk.gray(`✓ Using API endpoint from config: ${config.apiUrl}`));
|
|
260
|
+
return config.apiUrl;
|
|
261
|
+
}
|
|
262
|
+
// Priority 4: From bootstrap API config (REQUIRED - no fallback)
|
|
263
|
+
if (apiConfig?.REST?.hyperdrive?.endpoint) {
|
|
264
|
+
console.log(chalk.gray(`✓ Using API endpoint from bootstrap: ${apiConfig.REST.hyperdrive.endpoint}`));
|
|
265
|
+
return apiConfig.REST.hyperdrive.endpoint;
|
|
266
|
+
}
|
|
267
|
+
// No hyperdrive endpoint found - user doesn't have access
|
|
268
|
+
throw new Error('Hyperdrive API not available for this tenant.\n\n' +
|
|
269
|
+
chalk.yellow('This tenant does not have access to Hyperdrive.\n') +
|
|
270
|
+
chalk.gray('Please contact your administrator to enable the Hyperdrive module.'));
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Get bootstrap URL with fallback chain:
|
|
274
|
+
* 1. Environment variable
|
|
275
|
+
* 2. Config file
|
|
276
|
+
* 3. Construct from tenant domain
|
|
277
|
+
* 4. Default URL
|
|
278
|
+
*/
|
|
279
|
+
getBootstrapUrl() {
|
|
280
|
+
// Priority 1: Environment variable
|
|
281
|
+
if (process.env.HYPERDRIVE_BOOTSTRAP_URL) {
|
|
282
|
+
return process.env.HYPERDRIVE_BOOTSTRAP_URL;
|
|
283
|
+
}
|
|
284
|
+
// Priority 2: Config file
|
|
285
|
+
const config = this.loadConfigFile();
|
|
286
|
+
if (config?.bootstrapUrl) {
|
|
287
|
+
return config.bootstrapUrl;
|
|
288
|
+
}
|
|
289
|
+
// Priority 3: Construct from tenant domain if configured
|
|
290
|
+
const tenantDomain = this.getTenantDomain();
|
|
291
|
+
if (tenantDomain) {
|
|
292
|
+
return `https://${tenantDomain}/tenant/bootstrap`;
|
|
293
|
+
}
|
|
294
|
+
// Priority 4: Default
|
|
295
|
+
return this.defaultBootstrapUrl;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Load CLI configuration from file
|
|
299
|
+
*/
|
|
300
|
+
loadConfigFile() {
|
|
301
|
+
try {
|
|
302
|
+
if (!existsSync(this.configPath)) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const data = readFileSync(this.configPath, 'utf8');
|
|
306
|
+
return JSON.parse(data);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
console.error(chalk.yellow('⚠️ Failed to load config file, using defaults'));
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Save tenant domain to config file for future use
|
|
315
|
+
*/
|
|
316
|
+
saveTenantDomainToConfig(tenantDomain) {
|
|
317
|
+
const existingConfig = this.loadConfigFile() || {};
|
|
318
|
+
// Initialize domains object if it doesn't exist
|
|
319
|
+
if (!existingConfig.domains) {
|
|
320
|
+
existingConfig.domains = {};
|
|
321
|
+
}
|
|
322
|
+
// Add this domain to the domains map (even if just an empty object for now)
|
|
323
|
+
if (!existingConfig.domains[tenantDomain]) {
|
|
324
|
+
existingConfig.domains[tenantDomain] = {};
|
|
325
|
+
}
|
|
326
|
+
// Also keep legacy tenantDomain for backward compatibility
|
|
327
|
+
this.saveConfig({
|
|
328
|
+
...existingConfig,
|
|
329
|
+
tenantDomain,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|