@meshxdata/fops 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/STRUCTURE.md +43 -0
- package/foundation.mjs +16 -0
- package/package.json +52 -0
- package/src/agent/agent.js +367 -0
- package/src/agent/agent.test.js +233 -0
- package/src/agent/context.js +143 -0
- package/src/agent/context.test.js +81 -0
- package/src/agent/index.js +2 -0
- package/src/agent/llm.js +127 -0
- package/src/agent/llm.test.js +139 -0
- package/src/auth/index.js +4 -0
- package/src/auth/keychain.js +58 -0
- package/src/auth/keychain.test.js +185 -0
- package/src/auth/login.js +421 -0
- package/src/auth/login.test.js +192 -0
- package/src/auth/oauth.js +203 -0
- package/src/auth/oauth.test.js +118 -0
- package/src/auth/resolve.js +78 -0
- package/src/auth/resolve.test.js +153 -0
- package/src/commands/index.js +268 -0
- package/src/config.js +24 -0
- package/src/config.test.js +70 -0
- package/src/doctor.js +487 -0
- package/src/doctor.test.js +134 -0
- package/src/plugins/api.js +37 -0
- package/src/plugins/api.test.js +95 -0
- package/src/plugins/discovery.js +78 -0
- package/src/plugins/discovery.test.js +92 -0
- package/src/plugins/hooks.js +13 -0
- package/src/plugins/hooks.test.js +118 -0
- package/src/plugins/index.js +3 -0
- package/src/plugins/loader.js +110 -0
- package/src/plugins/manifest.js +26 -0
- package/src/plugins/manifest.test.js +106 -0
- package/src/plugins/registry.js +14 -0
- package/src/plugins/registry.test.js +43 -0
- package/src/plugins/skills.js +126 -0
- package/src/plugins/skills.test.js +173 -0
- package/src/project.js +61 -0
- package/src/project.test.js +196 -0
- package/src/setup/aws.js +369 -0
- package/src/setup/aws.test.js +280 -0
- package/src/setup/index.js +3 -0
- package/src/setup/setup.js +161 -0
- package/src/setup/wizard.js +119 -0
- package/src/shell.js +9 -0
- package/src/shell.test.js +72 -0
- package/src/skills/foundation/SKILL.md +107 -0
- package/src/ui/banner.js +56 -0
- package/src/ui/banner.test.js +97 -0
- package/src/ui/confirm.js +97 -0
- package/src/ui/index.js +5 -0
- package/src/ui/input.js +199 -0
- package/src/ui/spinner.js +170 -0
- package/src/ui/spinner.test.js +29 -0
- package/src/ui/streaming.js +106 -0
package/src/setup/aws.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { execa } from "execa";
|
|
7
|
+
import inquirer from "inquirer";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read saved FOPS config (~/.fops.json) for AWS SSO settings etc.
|
|
11
|
+
*/
|
|
12
|
+
export function readFopsConfig() {
|
|
13
|
+
const configPath = path.join(os.homedir(), ".fops.json");
|
|
14
|
+
try {
|
|
15
|
+
if (fs.existsSync(configPath)) {
|
|
16
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
17
|
+
}
|
|
18
|
+
} catch {}
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Save FOPS config (~/.fops.json)
|
|
24
|
+
*/
|
|
25
|
+
export function saveFopsConfig(config) {
|
|
26
|
+
const configPath = path.join(os.homedir(), ".fops.json");
|
|
27
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Prompt user for AWS SSO configuration interactively
|
|
32
|
+
*/
|
|
33
|
+
export async function promptAwsSsoConfig() {
|
|
34
|
+
console.log(chalk.cyan("\n AWS SSO Configuration\n"));
|
|
35
|
+
console.log(chalk.gray(" We'll set up an AWS CLI profile for ECR image pulls."));
|
|
36
|
+
console.log(chalk.gray(" You can find these values in your AWS SSO portal.\n"));
|
|
37
|
+
|
|
38
|
+
const answers = await inquirer.prompt([
|
|
39
|
+
{
|
|
40
|
+
type: "input",
|
|
41
|
+
name: "profileName",
|
|
42
|
+
message: "AWS profile name:",
|
|
43
|
+
default: "dev",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: "input",
|
|
47
|
+
name: "ssoStartUrl",
|
|
48
|
+
message: "SSO start URL:",
|
|
49
|
+
validate: (v) => v?.trim() ? true : "SSO start URL is required.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
type: "input",
|
|
53
|
+
name: "ssoRegion",
|
|
54
|
+
message: "SSO region:",
|
|
55
|
+
default: "us-east-1",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
type: "input",
|
|
59
|
+
name: "accountId",
|
|
60
|
+
message: "AWS account ID:",
|
|
61
|
+
validate: (v) => /^\d{12}$/.test(v?.trim()) ? true : "Account ID must be 12 digits.",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: "input",
|
|
65
|
+
name: "roleName",
|
|
66
|
+
message: "SSO role name:",
|
|
67
|
+
default: "AdministratorAccess",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: "input",
|
|
71
|
+
name: "region",
|
|
72
|
+
message: "Default region:",
|
|
73
|
+
default: (answers) => answers.ssoRegion,
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
profileName: answers.profileName.trim(),
|
|
79
|
+
ssoStartUrl: answers.ssoStartUrl.trim(),
|
|
80
|
+
ssoRegion: answers.ssoRegion.trim(),
|
|
81
|
+
accountId: answers.accountId.trim(),
|
|
82
|
+
roleName: answers.roleName.trim(),
|
|
83
|
+
region: answers.region.trim(),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Detect ECR registry URL from docker-compose.yaml
|
|
89
|
+
*/
|
|
90
|
+
export function detectEcrRegistry(dir) {
|
|
91
|
+
try {
|
|
92
|
+
const composePath = path.join(dir, "docker-compose.yaml");
|
|
93
|
+
if (!fs.existsSync(composePath)) return null;
|
|
94
|
+
const content = fs.readFileSync(composePath, "utf8");
|
|
95
|
+
const match = content.match(/(\d{12})\.dkr\.ecr\.([^.]+)\.amazonaws\.com/);
|
|
96
|
+
if (match) return { accountId: match[1], region: match[2] };
|
|
97
|
+
} catch {}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse ~/.aws/config and find SSO profiles with their sso_session names.
|
|
103
|
+
*/
|
|
104
|
+
export function detectAwsSsoProfiles() {
|
|
105
|
+
const configPath = path.join(os.homedir(), ".aws", "config");
|
|
106
|
+
if (!fs.existsSync(configPath)) return [];
|
|
107
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
108
|
+
const profiles = [];
|
|
109
|
+
let currentProfile = null;
|
|
110
|
+
let currentAttrs = {};
|
|
111
|
+
|
|
112
|
+
for (const line of content.split("\n")) {
|
|
113
|
+
const profileMatch = line.match(/^\[profile\s+(.+?)\]/);
|
|
114
|
+
if (profileMatch) {
|
|
115
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
116
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
117
|
+
}
|
|
118
|
+
currentProfile = profileMatch[1];
|
|
119
|
+
currentAttrs = {};
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (line.startsWith("[")) {
|
|
123
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
124
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
125
|
+
}
|
|
126
|
+
currentProfile = null;
|
|
127
|
+
currentAttrs = {};
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const kv = line.match(/^\s*(\S+)\s*=\s*(.+)/);
|
|
131
|
+
if (kv && currentProfile) {
|
|
132
|
+
currentAttrs[kv[1]] = kv[2].trim();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
136
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
137
|
+
}
|
|
138
|
+
return profiles;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Simple readline prompt helper.
|
|
143
|
+
*/
|
|
144
|
+
function ask(question, defaultVal) {
|
|
145
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
146
|
+
const suffix = defaultVal ? chalk.gray(` (${defaultVal})`) : "";
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
rl.question(` ${question}${suffix}: `, (answer) => {
|
|
149
|
+
rl.close();
|
|
150
|
+
resolve(answer.trim() || defaultVal || "");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check if ~/.aws/config has an sso-session block with sso_start_url.
|
|
157
|
+
* If not, prompt user for the values and write them.
|
|
158
|
+
*/
|
|
159
|
+
export async function ensureSsoConfig() {
|
|
160
|
+
const configPath = path.join(os.homedir(), ".aws", "config");
|
|
161
|
+
const content = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
|
|
162
|
+
|
|
163
|
+
// Check if any sso-session block has sso_start_url
|
|
164
|
+
if (/sso_start_url\s*=/.test(content)) return; // already configured
|
|
165
|
+
|
|
166
|
+
console.log(chalk.cyan("\n AWS SSO is not configured. Let's set it up.\n"));
|
|
167
|
+
console.log(chalk.gray(" You can find these values in your AWS SSO portal.\n"));
|
|
168
|
+
|
|
169
|
+
const sessionName = await ask("SSO session name", "meshx");
|
|
170
|
+
const startUrl = await ask("SSO start URL");
|
|
171
|
+
const ssoRegion = await ask("SSO region", "us-east-1");
|
|
172
|
+
const accountId = await ask("AWS account ID");
|
|
173
|
+
const roleName = await ask("SSO role name", "AdministratorAccess");
|
|
174
|
+
const region = await ask("Default region", ssoRegion);
|
|
175
|
+
const profileName = await ask("Profile name", "dev");
|
|
176
|
+
|
|
177
|
+
if (!startUrl || !accountId) {
|
|
178
|
+
throw new Error("SSO start URL and account ID are required");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Ensure ~/.aws directory exists
|
|
182
|
+
const awsDir = path.join(os.homedir(), ".aws");
|
|
183
|
+
if (!fs.existsSync(awsDir)) fs.mkdirSync(awsDir, { mode: 0o700 });
|
|
184
|
+
|
|
185
|
+
const block = `
|
|
186
|
+
[sso-session ${sessionName}]
|
|
187
|
+
sso_start_url = ${startUrl}
|
|
188
|
+
sso_region = ${ssoRegion}
|
|
189
|
+
sso_registration_scopes = sso:account:access
|
|
190
|
+
|
|
191
|
+
[profile ${profileName}]
|
|
192
|
+
sso_session = ${sessionName}
|
|
193
|
+
sso_account_id = ${accountId}
|
|
194
|
+
sso_role_name = ${roleName}
|
|
195
|
+
region = ${region}
|
|
196
|
+
output = json
|
|
197
|
+
`;
|
|
198
|
+
|
|
199
|
+
fs.appendFileSync(configPath, block);
|
|
200
|
+
console.log(chalk.green(`\n ✓ Written to ~/.aws/config (profile: ${profileName})`));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Fix AWS SSO: ensure config exists, detect profile, then login.
|
|
205
|
+
*/
|
|
206
|
+
export async function fixAwsSso() {
|
|
207
|
+
await ensureSsoConfig();
|
|
208
|
+
|
|
209
|
+
const profiles = detectAwsSsoProfiles();
|
|
210
|
+
if (profiles.length === 0) {
|
|
211
|
+
throw new Error("No SSO profiles found after config — check ~/.aws/config");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const profile = profiles[0];
|
|
215
|
+
console.log(chalk.gray(` Using AWS profile: ${profile.name}`));
|
|
216
|
+
console.log(chalk.cyan(` ▶ aws sso login --profile ${profile.name}`));
|
|
217
|
+
|
|
218
|
+
// Open /dev/tty directly so SSO login gets a real terminal even when
|
|
219
|
+
// the parent process has piped stdio (e.g. agent → runShellCommand → fops doctor)
|
|
220
|
+
let ttyFd;
|
|
221
|
+
try { ttyFd = fs.openSync("/dev/tty", "r"); } catch { ttyFd = null; }
|
|
222
|
+
|
|
223
|
+
await execa("aws", ["sso", "login", "--profile", profile.name], {
|
|
224
|
+
stdio: [ttyFd ?? "inherit", "inherit", "inherit"],
|
|
225
|
+
reject: false,
|
|
226
|
+
timeout: 120_000,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (ttyFd !== null) fs.closeSync(ttyFd);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Fix ECR: ensure SSO session is valid, then docker login to ECR.
|
|
234
|
+
*/
|
|
235
|
+
export async function fixEcr(ecrInfo) {
|
|
236
|
+
const profiles = detectAwsSsoProfiles();
|
|
237
|
+
// Pick the profile whose region matches ECR, or fall back to first
|
|
238
|
+
const profile = profiles.find((p) => p.region === ecrInfo.region) || profiles[0];
|
|
239
|
+
const profileArgs = profile ? ["--profile", profile.name] : [];
|
|
240
|
+
|
|
241
|
+
// First make sure SSO works
|
|
242
|
+
const { stdout } = await execa("aws", ["sts", "get-caller-identity", "--output", "json", ...profileArgs], {
|
|
243
|
+
reject: false, timeout: 10000,
|
|
244
|
+
});
|
|
245
|
+
if (!stdout || !stdout.includes("Account")) {
|
|
246
|
+
await fixAwsSso();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Now do ECR docker login
|
|
250
|
+
const ecrUrl = `${ecrInfo.accountId}.dkr.ecr.${ecrInfo.region}.amazonaws.com`;
|
|
251
|
+
console.log(chalk.cyan(` ▶ ECR docker login → ${ecrUrl}`));
|
|
252
|
+
const { stdout: password } = await execa("aws", [
|
|
253
|
+
"ecr", "get-login-password", "--region", ecrInfo.region, ...profileArgs,
|
|
254
|
+
], { reject: false, timeout: 15000 });
|
|
255
|
+
|
|
256
|
+
if (password?.trim()) {
|
|
257
|
+
const { exitCode } = await execa("docker", ["login", "--username", "AWS", "--password-stdin", ecrUrl], {
|
|
258
|
+
input: password.trim(), reject: false, timeout: 15000,
|
|
259
|
+
});
|
|
260
|
+
if (exitCode === 0) console.log(chalk.green(` ✓ Logged in to ECR`));
|
|
261
|
+
else throw new Error("docker login failed");
|
|
262
|
+
} else {
|
|
263
|
+
throw new Error("Could not get ECR login password — check AWS session");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Ensure ECR auth is valid before starting services.
|
|
269
|
+
* Detects ECR images in compose file, checks/refreshes credentials.
|
|
270
|
+
* Silently skips if no ECR images are found.
|
|
271
|
+
*/
|
|
272
|
+
export async function ensureEcrAuth(dir) {
|
|
273
|
+
const ecrInfo = detectEcrRegistry(dir);
|
|
274
|
+
if (!ecrInfo) return; // no ECR images — nothing to do
|
|
275
|
+
|
|
276
|
+
const ecrUrl = `${ecrInfo.accountId}.dkr.ecr.${ecrInfo.region}.amazonaws.com`;
|
|
277
|
+
|
|
278
|
+
// Check if AWS CLI is available
|
|
279
|
+
try {
|
|
280
|
+
await execa("aws", ["--version"], { timeout: 5000 });
|
|
281
|
+
} catch {
|
|
282
|
+
console.log(chalk.yellow(" ⚠ AWS CLI not found — skipping ECR auth (image pulls may fail)"));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const profiles = detectAwsSsoProfiles();
|
|
287
|
+
const profile = profiles.find((p) => p.region === ecrInfo.region) || profiles[0];
|
|
288
|
+
const profileArgs = profile ? ["--profile", profile.name] : [];
|
|
289
|
+
|
|
290
|
+
// Check if STS session is valid
|
|
291
|
+
const { stdout: stsOut } = await execa("aws", ["sts", "get-caller-identity", "--output", "json", ...profileArgs], {
|
|
292
|
+
reject: false, timeout: 10000,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (!stsOut || !stsOut.includes("Account")) {
|
|
296
|
+
console.log(chalk.yellow(" ⚠ AWS session expired — logging in via SSO..."));
|
|
297
|
+
try {
|
|
298
|
+
await fixAwsSso();
|
|
299
|
+
} catch (err) {
|
|
300
|
+
console.log(chalk.red(` ✗ AWS SSO login failed: ${err.message}`));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Docker login to ECR
|
|
306
|
+
try {
|
|
307
|
+
const { stdout: password } = await execa("aws", [
|
|
308
|
+
"ecr", "get-login-password", "--region", ecrInfo.region, ...profileArgs,
|
|
309
|
+
], { reject: false, timeout: 15000 });
|
|
310
|
+
|
|
311
|
+
if (!password?.trim()) {
|
|
312
|
+
console.log(chalk.red(" ✗ Could not get ECR login password — image pulls may fail"));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const { exitCode } = await execa("docker", ["login", "--username", "AWS", "--password-stdin", ecrUrl], {
|
|
317
|
+
input: password.trim(), reject: false, timeout: 15000,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
if (exitCode === 0) {
|
|
321
|
+
console.log(chalk.green(` ✓ ECR authenticated (${ecrUrl})`));
|
|
322
|
+
} else {
|
|
323
|
+
console.log(chalk.red(` ✗ ECR docker login failed — image pulls may fail`));
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
console.log(chalk.red(` ✗ ECR auth error: ${err.message}`));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function checkEcrRepos(dir, awsConfig) {
|
|
331
|
+
const profile = awsConfig?.profileName || "dev";
|
|
332
|
+
const ecrInfo = detectEcrRegistry(dir);
|
|
333
|
+
if (!ecrInfo) return;
|
|
334
|
+
|
|
335
|
+
const ecrPrefix = `${ecrInfo.accountId}.dkr.ecr.${ecrInfo.region}.amazonaws.com/`;
|
|
336
|
+
try {
|
|
337
|
+
const composePath = path.join(dir, "docker-compose.yaml");
|
|
338
|
+
if (!fs.existsSync(composePath)) return;
|
|
339
|
+
const content = fs.readFileSync(composePath, "utf8");
|
|
340
|
+
const imageRefs = [...content.matchAll(/image:\s*(.+)/g)]
|
|
341
|
+
.map((m) => m[1].trim())
|
|
342
|
+
.filter((img) => img.includes(".dkr.ecr.") && img.includes(".amazonaws.com"));
|
|
343
|
+
const neededRepos = [...new Set(
|
|
344
|
+
imageRefs.map((img) => {
|
|
345
|
+
const noTag = img.replace(/\$\{[^}]+\}/, "compose").split(":")[0];
|
|
346
|
+
return noTag.replace(ecrPrefix, "").replace(/^\//, "");
|
|
347
|
+
})
|
|
348
|
+
)];
|
|
349
|
+
const { stdout } = await execa("aws", [
|
|
350
|
+
"ecr", "describe-repositories",
|
|
351
|
+
"--registry-id", ecrInfo.accountId,
|
|
352
|
+
"--region", ecrInfo.region,
|
|
353
|
+
"--profile", profile,
|
|
354
|
+
"--query", "repositories[].repositoryName",
|
|
355
|
+
"--output", "json",
|
|
356
|
+
], { timeout: 10000 });
|
|
357
|
+
const existingRepos = JSON.parse(stdout);
|
|
358
|
+
const missing = neededRepos.filter((r) => !existingRepos.includes(r));
|
|
359
|
+
if (missing.length > 0) {
|
|
360
|
+
console.log(chalk.yellow("\n⚠ These ECR repos are referenced but don't exist (will need local build):"));
|
|
361
|
+
for (const r of missing) console.log(chalk.gray(` ✗ ${r}`));
|
|
362
|
+
console.log(chalk.gray(" These services will be built from source instead.\n"));
|
|
363
|
+
} else {
|
|
364
|
+
console.log(chalk.green("All required ECR repos exist."));
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
// Non-critical
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
describe("setup/aws", () => {
|
|
7
|
+
let tmpDir;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fops-aws-"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("readFopsConfig / saveFopsConfig", () => {
|
|
18
|
+
it("readFopsConfig returns empty object when file does not exist", () => {
|
|
19
|
+
const configPath = path.join(tmpDir, ".fops.json");
|
|
20
|
+
expect(fs.existsSync(configPath)).toBe(false);
|
|
21
|
+
let config = {};
|
|
22
|
+
try {
|
|
23
|
+
if (fs.existsSync(configPath)) {
|
|
24
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
25
|
+
}
|
|
26
|
+
} catch {}
|
|
27
|
+
expect(config).toEqual({});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("saveFopsConfig writes valid JSON", () => {
|
|
31
|
+
const configPath = path.join(tmpDir, ".fops.json");
|
|
32
|
+
const data = { aws: { profile: "dev" } };
|
|
33
|
+
fs.writeFileSync(configPath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
34
|
+
const read = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
35
|
+
expect(read).toEqual(data);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("readFopsConfig handles corrupted JSON gracefully", () => {
|
|
39
|
+
const configPath = path.join(tmpDir, ".fops.json");
|
|
40
|
+
fs.writeFileSync(configPath, "not valid json{");
|
|
41
|
+
let config = {};
|
|
42
|
+
try {
|
|
43
|
+
if (fs.existsSync(configPath)) {
|
|
44
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
expect(config).toEqual({});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("round-trips nested config", () => {
|
|
51
|
+
const configPath = path.join(tmpDir, ".fops.json");
|
|
52
|
+
const data = {
|
|
53
|
+
aws: { profile: "dev", region: "us-east-1" },
|
|
54
|
+
plugins: { entries: { "my-plugin": { enabled: true, config: { key: "val" } } } },
|
|
55
|
+
};
|
|
56
|
+
fs.writeFileSync(configPath, JSON.stringify(data, null, 2) + "\n");
|
|
57
|
+
const read = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
58
|
+
expect(read).toEqual(data);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("detectEcrRegistry", () => {
|
|
63
|
+
let detectEcrRegistry;
|
|
64
|
+
|
|
65
|
+
beforeEach(async () => {
|
|
66
|
+
({ detectEcrRegistry } = await import("./aws.js"));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null when no docker-compose.yaml", () => {
|
|
70
|
+
expect(detectEcrRegistry(tmpDir)).toBe(null);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns null when no ECR URL in compose", () => {
|
|
74
|
+
fs.writeFileSync(path.join(tmpDir, "docker-compose.yaml"), "version: '3'\nservices:\n web:\n image: nginx\n");
|
|
75
|
+
expect(detectEcrRegistry(tmpDir)).toBe(null);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("extracts ECR info from docker-compose.yaml", () => {
|
|
79
|
+
fs.writeFileSync(
|
|
80
|
+
path.join(tmpDir, "docker-compose.yaml"),
|
|
81
|
+
`services:\n app:\n image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest\n`
|
|
82
|
+
);
|
|
83
|
+
const result = detectEcrRegistry(tmpDir);
|
|
84
|
+
expect(result).toEqual({ accountId: "123456789012", region: "us-east-1" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("extracts different regions", () => {
|
|
88
|
+
fs.writeFileSync(
|
|
89
|
+
path.join(tmpDir, "docker-compose.yaml"),
|
|
90
|
+
`services:\n app:\n image: 987654321098.dkr.ecr.eu-west-1.amazonaws.com/app:v1\n`
|
|
91
|
+
);
|
|
92
|
+
const result = detectEcrRegistry(tmpDir);
|
|
93
|
+
expect(result).toEqual({ accountId: "987654321098", region: "eu-west-1" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("handles multiple ECR references (returns first match)", () => {
|
|
97
|
+
fs.writeFileSync(
|
|
98
|
+
path.join(tmpDir, "docker-compose.yaml"),
|
|
99
|
+
[
|
|
100
|
+
"services:",
|
|
101
|
+
" app1:",
|
|
102
|
+
" image: 111111111111.dkr.ecr.us-east-1.amazonaws.com/app1:latest",
|
|
103
|
+
" app2:",
|
|
104
|
+
" image: 222222222222.dkr.ecr.eu-west-1.amazonaws.com/app2:latest",
|
|
105
|
+
].join("\n")
|
|
106
|
+
);
|
|
107
|
+
const result = detectEcrRegistry(tmpDir);
|
|
108
|
+
expect(result.accountId).toBe("111111111111");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns null for compose with no image directives", () => {
|
|
112
|
+
fs.writeFileSync(
|
|
113
|
+
path.join(tmpDir, "docker-compose.yaml"),
|
|
114
|
+
"services:\n app:\n build: .\n"
|
|
115
|
+
);
|
|
116
|
+
expect(detectEcrRegistry(tmpDir)).toBe(null);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("detectAwsSsoProfiles", () => {
|
|
121
|
+
let detectAwsSsoProfiles;
|
|
122
|
+
|
|
123
|
+
beforeEach(async () => {
|
|
124
|
+
({ detectAwsSsoProfiles } = await import("./aws.js"));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns an array", () => {
|
|
128
|
+
const result = detectAwsSsoProfiles();
|
|
129
|
+
expect(Array.isArray(result)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("detectAwsSsoProfiles parsing logic", () => {
|
|
134
|
+
it("parses SSO profiles from aws config content", () => {
|
|
135
|
+
const configContent = `[profile dev]
|
|
136
|
+
sso_session = meshx
|
|
137
|
+
sso_account_id = 123456789012
|
|
138
|
+
sso_role_name = AdministratorAccess
|
|
139
|
+
region = us-east-1
|
|
140
|
+
|
|
141
|
+
[sso-session meshx]
|
|
142
|
+
sso_start_url = https://meshx.awsapps.com/start
|
|
143
|
+
sso_region = us-east-1
|
|
144
|
+
|
|
145
|
+
[profile staging]
|
|
146
|
+
sso_session = meshx
|
|
147
|
+
sso_account_id = 987654321098
|
|
148
|
+
sso_role_name = ReadOnly
|
|
149
|
+
region = us-west-2
|
|
150
|
+
`;
|
|
151
|
+
// Inline the parsing logic (same as detectAwsSsoProfiles)
|
|
152
|
+
const content = configContent;
|
|
153
|
+
const profiles = [];
|
|
154
|
+
let currentProfile = null;
|
|
155
|
+
let currentAttrs = {};
|
|
156
|
+
for (const line of content.split("\n")) {
|
|
157
|
+
const profileMatch = line.match(/^\[profile\s+(.+?)\]/);
|
|
158
|
+
if (profileMatch) {
|
|
159
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
160
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
161
|
+
}
|
|
162
|
+
currentProfile = profileMatch[1];
|
|
163
|
+
currentAttrs = {};
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (line.startsWith("[")) {
|
|
167
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
168
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
169
|
+
}
|
|
170
|
+
currentProfile = null;
|
|
171
|
+
currentAttrs = {};
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const kv = line.match(/^\s*(\S+)\s*=\s*(.+)/);
|
|
175
|
+
if (kv && currentProfile) {
|
|
176
|
+
currentAttrs[kv[1]] = kv[2].trim();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
180
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
expect(profiles).toHaveLength(2);
|
|
184
|
+
expect(profiles[0].name).toBe("dev");
|
|
185
|
+
expect(profiles[0].sso_session).toBe("meshx");
|
|
186
|
+
expect(profiles[0].sso_account_id).toBe("123456789012");
|
|
187
|
+
expect(profiles[0].region).toBe("us-east-1");
|
|
188
|
+
expect(profiles[1].name).toBe("staging");
|
|
189
|
+
expect(profiles[1].sso_account_id).toBe("987654321098");
|
|
190
|
+
expect(profiles[1].region).toBe("us-west-2");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("skips profiles without sso_session", () => {
|
|
194
|
+
const content = `[profile default]
|
|
195
|
+
region = us-east-1
|
|
196
|
+
output = json
|
|
197
|
+
|
|
198
|
+
[profile sso-dev]
|
|
199
|
+
sso_session = meshx
|
|
200
|
+
region = us-east-1
|
|
201
|
+
`;
|
|
202
|
+
const profiles = [];
|
|
203
|
+
let currentProfile = null;
|
|
204
|
+
let currentAttrs = {};
|
|
205
|
+
for (const line of content.split("\n")) {
|
|
206
|
+
const profileMatch = line.match(/^\[profile\s+(.+?)\]/);
|
|
207
|
+
if (profileMatch) {
|
|
208
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
209
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
210
|
+
}
|
|
211
|
+
currentProfile = profileMatch[1];
|
|
212
|
+
currentAttrs = {};
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (line.startsWith("[")) {
|
|
216
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
217
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
218
|
+
}
|
|
219
|
+
currentProfile = null;
|
|
220
|
+
currentAttrs = {};
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const kv = line.match(/^\s*(\S+)\s*=\s*(.+)/);
|
|
224
|
+
if (kv && currentProfile) {
|
|
225
|
+
currentAttrs[kv[1]] = kv[2].trim();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
229
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
expect(profiles).toHaveLength(1);
|
|
233
|
+
expect(profiles[0].name).toBe("sso-dev");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("handles empty config", () => {
|
|
237
|
+
const profiles = [];
|
|
238
|
+
// No profiles to parse
|
|
239
|
+
expect(profiles).toHaveLength(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("handles profile at end of file (no trailing section)", () => {
|
|
243
|
+
const content = `[profile last]
|
|
244
|
+
sso_session = test
|
|
245
|
+
region = us-east-1`;
|
|
246
|
+
const profiles = [];
|
|
247
|
+
let currentProfile = null;
|
|
248
|
+
let currentAttrs = {};
|
|
249
|
+
for (const line of content.split("\n")) {
|
|
250
|
+
const profileMatch = line.match(/^\[profile\s+(.+?)\]/);
|
|
251
|
+
if (profileMatch) {
|
|
252
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
253
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
254
|
+
}
|
|
255
|
+
currentProfile = profileMatch[1];
|
|
256
|
+
currentAttrs = {};
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (line.startsWith("[")) {
|
|
260
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
261
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
262
|
+
}
|
|
263
|
+
currentProfile = null;
|
|
264
|
+
currentAttrs = {};
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const kv = line.match(/^\s*(\S+)\s*=\s*(.+)/);
|
|
268
|
+
if (kv && currentProfile) {
|
|
269
|
+
currentAttrs[kv[1]] = kv[2].trim();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (currentProfile && currentAttrs.sso_session) {
|
|
273
|
+
profiles.push({ name: currentProfile, ...currentAttrs });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
expect(profiles).toHaveLength(1);
|
|
277
|
+
expect(profiles[0].name).toBe("last");
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|