@meshxdata/fops 0.0.4 → 0.0.6

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 (39) hide show
  1. package/package.json +2 -1
  2. package/src/commands/index.js +163 -1
  3. package/src/doctor.js +155 -17
  4. package/src/plugins/bundled/coda/auth.js +79 -0
  5. package/src/plugins/bundled/coda/client.js +187 -0
  6. package/src/plugins/bundled/coda/fops.plugin.json +7 -0
  7. package/src/plugins/bundled/coda/index.js +284 -0
  8. package/src/plugins/bundled/coda/package.json +3 -0
  9. package/src/plugins/bundled/coda/skills/coda/SKILL.md +82 -0
  10. package/src/plugins/bundled/cursor/fops.plugin.json +7 -0
  11. package/src/plugins/bundled/cursor/index.js +432 -0
  12. package/src/plugins/bundled/cursor/package.json +1 -0
  13. package/src/plugins/bundled/cursor/skills/cursor/SKILL.md +48 -0
  14. package/src/plugins/bundled/fops-plugin-1password/fops.plugin.json +7 -0
  15. package/src/plugins/bundled/fops-plugin-1password/index.js +239 -0
  16. package/src/plugins/bundled/fops-plugin-1password/lib/env.js +100 -0
  17. package/src/plugins/bundled/fops-plugin-1password/lib/op.js +111 -0
  18. package/src/plugins/bundled/fops-plugin-1password/lib/setup.js +235 -0
  19. package/src/plugins/bundled/fops-plugin-1password/lib/sync.js +61 -0
  20. package/src/plugins/bundled/fops-plugin-1password/package.json +1 -0
  21. package/src/plugins/bundled/fops-plugin-1password/skills/1password/SKILL.md +79 -0
  22. package/src/plugins/bundled/fops-plugin-ecr/fops.plugin.json +7 -0
  23. package/src/plugins/bundled/fops-plugin-ecr/index.js +302 -0
  24. package/src/plugins/bundled/fops-plugin-ecr/lib/aws.js +147 -0
  25. package/src/plugins/bundled/fops-plugin-ecr/lib/images.js +73 -0
  26. package/src/plugins/bundled/fops-plugin-ecr/lib/setup.js +180 -0
  27. package/src/plugins/bundled/fops-plugin-ecr/lib/sync.js +74 -0
  28. package/src/plugins/bundled/fops-plugin-ecr/package.json +1 -0
  29. package/src/plugins/bundled/fops-plugin-ecr/skills/ecr/SKILL.md +105 -0
  30. package/src/plugins/bundled/fops-plugin-memory/fops.plugin.json +7 -0
  31. package/src/plugins/bundled/fops-plugin-memory/index.js +148 -0
  32. package/src/plugins/bundled/fops-plugin-memory/lib/relevance.js +72 -0
  33. package/src/plugins/bundled/fops-plugin-memory/lib/store.js +75 -0
  34. package/src/plugins/bundled/fops-plugin-memory/package.json +1 -0
  35. package/src/plugins/bundled/fops-plugin-memory/skills/memory/SKILL.md +58 -0
  36. package/src/plugins/loader.js +40 -0
  37. package/src/setup/aws.js +51 -38
  38. package/src/setup/setup.js +2 -0
  39. package/src/setup/wizard.js +137 -12
@@ -0,0 +1,61 @@
1
+ import fs from "node:fs";
2
+ import chalk from "chalk";
3
+ import { opInject } from "./op.js";
4
+ import { parseEnv, mergeEnv, discoverTemplates } from "./env.js";
5
+
6
+ /**
7
+ * Sync secrets from 1Password into .env files.
8
+ * For each discovered .env.1password template:
9
+ * 1. Read the template (contains op:// references)
10
+ * 2. Run `op inject` to resolve references
11
+ * 3. Parse resolved output into key=value pairs
12
+ * 4. Merge into .env (create from .env.example if needed)
13
+ *
14
+ * Returns { synced: number, errors: string[] }
15
+ */
16
+ export async function syncSecrets(root) {
17
+ const templates = discoverTemplates(root);
18
+ if (templates.length === 0) {
19
+ console.log(chalk.yellow(" No .env.1password templates found."));
20
+ return { synced: 0, errors: [] };
21
+ }
22
+
23
+ let synced = 0;
24
+ const errors = [];
25
+
26
+ for (const { templatePath, envPath, dir } of templates) {
27
+ const relDir = dir === root ? "." : dir.replace(root + "/", "");
28
+ try {
29
+ const template = fs.readFileSync(templatePath, "utf8");
30
+ const resolved = await opInject(template);
31
+ const secrets = parseEnv(resolved);
32
+
33
+ if (secrets.size === 0) {
34
+ console.log(chalk.dim(` ${relDir}/.env.1password — no secrets resolved`));
35
+ continue;
36
+ }
37
+
38
+ // Read existing .env, or fall back to .env.example, or start empty
39
+ let existing = "";
40
+ if (fs.existsSync(envPath)) {
41
+ existing = fs.readFileSync(envPath, "utf8");
42
+ } else {
43
+ const examplePath = envPath.replace(/\.env$/, ".env.example");
44
+ if (fs.existsSync(examplePath)) {
45
+ existing = fs.readFileSync(examplePath, "utf8");
46
+ }
47
+ }
48
+
49
+ const merged = mergeEnv(existing, secrets);
50
+ fs.writeFileSync(envPath, merged);
51
+ console.log(chalk.green(` ✓ ${relDir}/.env — ${secrets.size} secret(s) synced`));
52
+ synced++;
53
+ } catch (err) {
54
+ const msg = `${relDir}: ${err.message}`;
55
+ errors.push(msg);
56
+ console.log(chalk.red(` ✗ ${relDir}/.env.1password — ${err.message}`));
57
+ }
58
+ }
59
+
60
+ return { synced, errors };
61
+ }
@@ -0,0 +1 @@
1
+ { "type": "module" }
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: 1password
3
+ description: 1Password secrets integration for Foundation .env files
4
+ requires: op
5
+ ---
6
+
7
+ ## 1Password Plugin for fops
8
+
9
+ Automates injecting secrets from 1Password into `.env` files using `op://` references.
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ fops 1password setup # Interactive wizard: install op, sign in, pick vault, scaffold templates
15
+ fops 1password sync # Pull secrets from 1Password → merge into .env files
16
+ fops 1password status # Show op version, auth, vault, discovered templates
17
+ fops 1p sync # Shorthand alias
18
+ ```
19
+
20
+ ## Template Format
21
+
22
+ `.env.1password` files live alongside `.env.example` and contain only secret variables with `op://` references:
23
+
24
+ ```
25
+ # Format: KEY=op://<vault>/<item>/<field>
26
+ BEARER_TOKEN=op://Foundation/auth0-dev/bearer-token
27
+ MX_POSTGRES_PASSWORD=op://Foundation/postgres-dev/password
28
+ S3_SECRET_KEY=op://Foundation/minio-dev/secret-key
29
+ ```
30
+
31
+ The sync command reads each template, resolves the `op://` references via `op inject`, and merges the values into the corresponding `.env` file — preserving comments, ordering, and non-secret values.
32
+
33
+ ## How It Works
34
+
35
+ 1. **Template discovery**: Scans project root + subdirectories for `.env.1password` files
36
+ 2. **Secret resolution**: Pipes each template through `op inject` (stdin→stdout)
37
+ 3. **Merge**: Parsed secrets overwrite matching keys in `.env`, new keys are appended
38
+ 4. **Auto-sync**: When `autoSync: true` in config, secrets sync automatically before `fops up`
39
+
40
+ ## Config
41
+
42
+ Stored in `~/.fops.json`:
43
+
44
+ ```json
45
+ {
46
+ "plugins": {
47
+ "entries": {
48
+ "fops-plugin-1password": {
49
+ "enabled": true,
50
+ "config": {
51
+ "defaultVault": "Foundation",
52
+ "autoSync": true
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## Setup
61
+
62
+ Run `fops 1password setup` to:
63
+ 1. Check/install `op` CLI (offers Homebrew install)
64
+ 2. Sign in to 1Password (biometric/browser)
65
+ 3. Select default vault
66
+ 4. Set auto-sync preference
67
+ 5. Scaffold `.env.1password` templates from `.env.example` (detects secret-looking keys)
68
+
69
+ ## Troubleshooting
70
+
71
+ **op CLI not found**: Install via `brew install --cask 1password-cli` or see https://developer.1password.com/docs/cli/get-started/
72
+
73
+ **Not signed in**: Run `op signin` or `fops 1password setup`
74
+
75
+ **Secrets not resolving**: Verify the `op://vault/item/field` path matches your 1Password items. Use `op item get "item-name" --vault "vault-name"` to check field names.
76
+
77
+ **Permission denied**: Ensure 1Password desktop app is running and CLI integration is enabled in Settings → Developer → CLI.
78
+
79
+ **Auto-sync not firing**: Check `~/.fops.json` has `autoSync: true` under the plugin config.
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "fops-plugin-ecr",
3
+ "name": "ECR Container Registry",
4
+ "version": "0.1.0",
5
+ "description": "Manage AWS ECR authentication and container image pulls",
6
+ "skills": ["skills/ecr"]
7
+ }
@@ -0,0 +1,302 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import chalk from "chalk";
4
+ import {
5
+ awsVersion,
6
+ detectEcrRegistry,
7
+ detectSsoProfiles,
8
+ stsIdentity,
9
+ ecrLogin,
10
+ ssoLogin,
11
+ } from "./lib/aws.js";
12
+ import { listEcrImages, checkImageFreshness } from "./lib/images.js";
13
+ import { syncImages } from "./lib/sync.js";
14
+ import { runSetupWizard } from "./lib/setup.js";
15
+
16
+ /**
17
+ * Find the Foundation project root by walking up from cwd,
18
+ * looking for docker-compose.yaml + Makefile.
19
+ */
20
+ function findRoot() {
21
+ let dir = process.cwd();
22
+ while (dir) {
23
+ const hasCompose =
24
+ fs.existsSync(path.join(dir, "docker-compose.yaml")) ||
25
+ fs.existsSync(path.join(dir, "docker-compose.yml"));
26
+ if (hasCompose && fs.existsSync(path.join(dir, "Makefile"))) return dir;
27
+ const parent = path.dirname(dir);
28
+ if (parent === dir) break;
29
+ dir = parent;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ export function register(api) {
35
+ const config = api.config;
36
+
37
+ // ── Command: fops ecr ──────────────────────────────
38
+ api.registerCommand((program) => {
39
+ const cmd = program
40
+ .command("ecr")
41
+ .description("Manage AWS ECR authentication and images");
42
+
43
+ // fops ecr setup
44
+ cmd
45
+ .command("setup")
46
+ .description("Interactive setup wizard for ECR integration")
47
+ .action(async () => {
48
+ const root = findRoot();
49
+ if (!root) {
50
+ console.log(chalk.red("Not a Foundation project. Run from foundation-compose directory."));
51
+ process.exit(1);
52
+ }
53
+ await runSetupWizard(root);
54
+ });
55
+
56
+ // fops ecr sync
57
+ cmd
58
+ .command("sync")
59
+ .description("Authenticate to ECR and pull latest images")
60
+ .action(async () => {
61
+ const root = findRoot();
62
+ if (!root) {
63
+ console.log(chalk.red("Not a Foundation project. Run from foundation-compose directory."));
64
+ process.exit(1);
65
+ }
66
+ console.log(chalk.bold.cyan("\n ECR Sync\n"));
67
+ await syncImages(root, config);
68
+ });
69
+
70
+ // fops ecr status
71
+ cmd
72
+ .command("status")
73
+ .description("Show AWS CLI, SSO session, ECR auth, and image freshness")
74
+ .action(async () => {
75
+ console.log(chalk.bold.cyan("\n ECR Status\n"));
76
+
77
+ // AWS CLI
78
+ const version = await awsVersion();
79
+ if (version) {
80
+ console.log(chalk.green(` ✓ AWS CLI v${version}`));
81
+ } else {
82
+ console.log(chalk.red(" ✗ AWS CLI not installed"));
83
+ console.log("");
84
+ return;
85
+ }
86
+
87
+ // SSO profiles
88
+ const profiles = detectSsoProfiles();
89
+ const profile = config.profile || profiles[0]?.name || null;
90
+ if (profile) {
91
+ console.log(chalk.green(` ✓ Profile: ${profile}`));
92
+ } else {
93
+ console.log(chalk.yellow(" ⚠ No SSO profiles configured"));
94
+ }
95
+
96
+ // STS session
97
+ if (profile) {
98
+ const sts = await stsIdentity(profile);
99
+ if (sts.valid) {
100
+ console.log(chalk.green(` ✓ SSO session valid (${sts.account})`));
101
+ } else {
102
+ console.log(chalk.yellow(" ⚠ SSO session expired or invalid"));
103
+ }
104
+ }
105
+
106
+ // ECR registry
107
+ const root = findRoot();
108
+ if (root) {
109
+ const ecr = detectEcrRegistry(root);
110
+ if (ecr) {
111
+ const ecrUrl = `${ecr.accountId}.dkr.ecr.${ecr.region}.amazonaws.com`;
112
+ console.log(chalk.green(` ✓ ECR registry: ${ecrUrl}`));
113
+
114
+ // Image freshness
115
+ const images = listEcrImages(root);
116
+ if (images.length > 0) {
117
+ const freshness = await checkImageFreshness(images);
118
+ const staleCount = freshness.filter((f) => f.stale === true).length;
119
+ const missingCount = freshness.filter((f) => f.ageDays === null).length;
120
+
121
+ console.log(chalk.dim(` · ${images.length} ECR image ref(s) in compose`));
122
+ if (staleCount > 0) {
123
+ console.log(chalk.yellow(` ⚠ ${staleCount} stale image(s) (>7d old)`));
124
+ }
125
+ if (missingCount > 0) {
126
+ console.log(chalk.yellow(` ⚠ ${missingCount} image(s) not pulled locally`));
127
+ }
128
+
129
+ for (const f of freshness) {
130
+ if (f.ageDays === null) {
131
+ console.log(chalk.dim(` · ${f.image} — not pulled`));
132
+ } else if (f.stale) {
133
+ console.log(chalk.yellow(` · ${f.image} — ${f.ageDays}d old`));
134
+ } else {
135
+ console.log(chalk.green(` · ${f.image} — ${f.ageDays === 0 ? "today" : f.ageDays + "d ago"}`));
136
+ }
137
+ }
138
+ }
139
+ } else {
140
+ console.log(chalk.dim(" · No ECR images in docker-compose.yaml"));
141
+ }
142
+ }
143
+
144
+ // Config
145
+ console.log(chalk.dim(` · Auto-login: ${config.autoLogin ? "enabled" : "disabled"}`));
146
+ console.log("");
147
+ });
148
+ });
149
+
150
+ // ── Doctor check ───────────────────────────────────
151
+ api.registerDoctorCheck({
152
+ name: "ECR",
153
+ fn: async (ok, warn, fail) => {
154
+ const version = await awsVersion();
155
+ if (!version) {
156
+ warn("AWS CLI not installed", "optional — run: brew install awscli");
157
+ return;
158
+ }
159
+ ok(`AWS CLI v${version}`);
160
+
161
+ const profiles = detectSsoProfiles();
162
+ const profile = config.profile || profiles[0]?.name || null;
163
+ if (!profile) {
164
+ warn("No AWS SSO profile configured", "run: fops ecr setup", async () => {
165
+ await runSetupWizard(config, api);
166
+ });
167
+ return;
168
+ }
169
+
170
+ const sts = await stsIdentity(profile);
171
+ if (sts.valid) {
172
+ ok("AWS SSO session valid", `account ${sts.account}`);
173
+ } else {
174
+ fail("AWS SSO session expired", "run: fops ecr sync");
175
+ return;
176
+ }
177
+
178
+ const root = findRoot();
179
+ if (!root) return;
180
+
181
+ const ecr = detectEcrRegistry(root);
182
+ if (ecr) {
183
+ const login = await ecrLogin(ecr.accountId, ecr.region, profile);
184
+ if (login.success) {
185
+ ok("ECR authenticated", login.url);
186
+ } else {
187
+ fail("ECR login failed", login.url, async () => {
188
+ await ssoLogin(profile);
189
+ await ecrLogin(ecr.accountId, ecr.region, profile);
190
+ });
191
+ }
192
+ }
193
+ },
194
+ });
195
+
196
+ // ── Hook: before:up — auto ECR login ───────────────
197
+ api.registerHook("before:up", async () => {
198
+ if (!config.autoLogin) return;
199
+
200
+ const root = findRoot();
201
+ if (!root) return;
202
+
203
+ const ecr = detectEcrRegistry(root);
204
+ if (!ecr) return;
205
+
206
+ const profiles = detectSsoProfiles();
207
+ const profile = config.profile || profiles[0]?.name || null;
208
+ if (!profile) return;
209
+
210
+ // Check if session is valid
211
+ const sts = await stsIdentity(profile);
212
+ if (!sts.valid) {
213
+ console.log(chalk.yellow(" ECR: AWS session expired — logging in via SSO..."));
214
+ try {
215
+ await ssoLogin(profile);
216
+ } catch {
217
+ console.log(chalk.red(" ✗ ECR: SSO login failed — image pulls may fail"));
218
+ return;
219
+ }
220
+ }
221
+
222
+ // Docker login to ECR
223
+ const login = await ecrLogin(ecr.accountId, ecr.region, profile);
224
+ if (login.success) {
225
+ console.log(chalk.green(` ✓ ECR authenticated (${login.url})`));
226
+ } else {
227
+ console.log(chalk.yellow(" ⚠ ECR: docker login failed — image pulls may fail"));
228
+ }
229
+ });
230
+
231
+ // ── Hook: after:setup — trigger ECR if images detected ─
232
+ api.registerHook("after:setup", async () => {
233
+ const root = findRoot();
234
+ if (!root) return;
235
+
236
+ const ecr = detectEcrRegistry(root);
237
+ if (!ecr) return;
238
+
239
+ const profiles = detectSsoProfiles();
240
+ const profile = config.profile || profiles[0]?.name || null;
241
+
242
+ if (profile) {
243
+ const sts = await stsIdentity(profile);
244
+ if (sts.valid) {
245
+ console.log(chalk.blue("\n ECR images detected — authenticating..."));
246
+ const login = await ecrLogin(ecr.accountId, ecr.region, profile);
247
+ if (login.success) {
248
+ console.log(chalk.green(` ✓ ECR authenticated (${login.url})`));
249
+ }
250
+ } else {
251
+ console.log(chalk.yellow("\n ECR images detected. Run: fops ecr setup"));
252
+ }
253
+ } else {
254
+ console.log(chalk.yellow("\n ECR images detected but no AWS profile configured. Run: fops ecr setup"));
255
+ }
256
+ });
257
+
258
+ // ── Knowledge source ───────────────────────────────
259
+ api.registerKnowledgeSource({
260
+ name: "ECR Container Registry",
261
+ description: "AWS ECR authentication and image management",
262
+ search(query) {
263
+ const keywords = ["ecr", "aws", "docker login", "image pull", "sso", "container registry", "ecr login"];
264
+ const q = query.toLowerCase();
265
+ const match = keywords.some((kw) => q.includes(kw));
266
+ if (!match) return [];
267
+
268
+ return [
269
+ {
270
+ title: "ECR Plugin",
271
+ content: [
272
+ "## ECR Container Registry Plugin",
273
+ "",
274
+ "Manages AWS ECR authentication and container image pulls for the Foundation stack.",
275
+ "",
276
+ "### Commands",
277
+ "- `fops ecr setup` — Interactive wizard: install AWS CLI, configure SSO, ECR login",
278
+ "- `fops ecr sync` — Authenticate to ECR and pull latest images",
279
+ "- `fops ecr status` — Show AWS CLI, SSO session, ECR auth, image freshness",
280
+ "",
281
+ "### Auth Flow",
282
+ "1. AWS SSO login (`aws sso login --profile <name>`)",
283
+ "2. Get ECR password (`aws ecr get-login-password`)",
284
+ "3. Docker login (`docker login --username AWS --password-stdin <registry>`)",
285
+ "",
286
+ "### Config (~/.fops.json)",
287
+ '```json',
288
+ '{ "plugins": { "entries": { "fops-plugin-ecr": { "config": { "profile": "dev", "autoLogin": true } } } } }',
289
+ '```',
290
+ "",
291
+ "### Troubleshooting",
292
+ "- `aws sso login --profile dev` — Re-authenticate SSO",
293
+ "- `aws sts get-caller-identity` — Check current session",
294
+ "- `docker login` errors — Run `fops ecr sync` to refresh credentials",
295
+ "- Images not pulling — Check ECR registry URL in docker-compose.yaml",
296
+ ].join("\n"),
297
+ score: 0.9,
298
+ },
299
+ ];
300
+ },
301
+ });
302
+ }
@@ -0,0 +1,147 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execa } from "execa";
5
+
6
+ /**
7
+ * Get the installed AWS CLI version.
8
+ * Returns version string or null if not installed.
9
+ */
10
+ export async function awsVersion() {
11
+ try {
12
+ const { stdout } = await execa("aws", ["--version"], { timeout: 5000 });
13
+ return stdout?.split(" ")[0]?.replace("aws-cli/", "") || null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Check current STS identity for a given profile.
21
+ * Returns { valid, account, arn } or { valid: false }.
22
+ */
23
+ export async function stsIdentity(profile) {
24
+ const profileArgs = profile ? ["--profile", profile] : [];
25
+ try {
26
+ const { stdout } = await execa(
27
+ "aws",
28
+ ["sts", "get-caller-identity", "--output", "json", ...profileArgs],
29
+ { timeout: 10000, reject: false },
30
+ );
31
+ if (stdout && stdout.includes("Account")) {
32
+ const info = JSON.parse(stdout);
33
+ return { valid: true, account: info.Account, arn: info.Arn };
34
+ }
35
+ } catch {}
36
+ return { valid: false, account: null, arn: null };
37
+ }
38
+
39
+ /**
40
+ * Run `aws sso login` for the given profile.
41
+ * Uses inherited stdio so the browser auth flow works.
42
+ */
43
+ export async function ssoLogin(profile) {
44
+ const args = ["sso", "login"];
45
+ if (profile) args.push("--profile", profile);
46
+
47
+ // Open /dev/tty so SSO login gets a real terminal even under piped stdio
48
+ let ttyFd;
49
+ try {
50
+ ttyFd = fs.openSync("/dev/tty", "r");
51
+ } catch {
52
+ ttyFd = null;
53
+ }
54
+
55
+ await execa("aws", args, {
56
+ stdio: [ttyFd ?? "inherit", "inherit", "inherit"],
57
+ reject: false,
58
+ timeout: 120_000,
59
+ });
60
+
61
+ if (ttyFd !== null) fs.closeSync(ttyFd);
62
+ }
63
+
64
+ /**
65
+ * Docker-login to an ECR registry.
66
+ * Returns { success, url }.
67
+ */
68
+ export async function ecrLogin(accountId, region, profile) {
69
+ const profileArgs = profile ? ["--profile", profile] : [];
70
+ const url = `${accountId}.dkr.ecr.${region}.amazonaws.com`;
71
+
72
+ const { stdout: password } = await execa(
73
+ "aws",
74
+ ["ecr", "get-login-password", "--region", region, ...profileArgs],
75
+ { reject: false, timeout: 15000 },
76
+ );
77
+
78
+ if (!password?.trim()) {
79
+ return { success: false, url };
80
+ }
81
+
82
+ const { exitCode } = await execa(
83
+ "docker",
84
+ ["login", "--username", "AWS", "--password-stdin", url],
85
+ { input: password.trim(), reject: false, timeout: 15000 },
86
+ );
87
+
88
+ return { success: exitCode === 0, url };
89
+ }
90
+
91
+ /**
92
+ * Detect ECR registry from a docker-compose.yaml in the given directory.
93
+ * Returns { accountId, region } or null.
94
+ */
95
+ export function detectEcrRegistry(root) {
96
+ try {
97
+ const composePath = path.join(root, "docker-compose.yaml");
98
+ if (!fs.existsSync(composePath)) return null;
99
+ const content = fs.readFileSync(composePath, "utf8");
100
+ const match = content.match(
101
+ /(\d{12})\.dkr\.ecr\.([^.]+)\.amazonaws\.com/,
102
+ );
103
+ if (match) return { accountId: match[1], region: match[2] };
104
+ } catch {}
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Parse ~/.aws/config for profiles that have an sso_session.
110
+ * Returns [{ name, region, sso_session, ... }].
111
+ */
112
+ export function detectSsoProfiles() {
113
+ const configPath = path.join(os.homedir(), ".aws", "config");
114
+ if (!fs.existsSync(configPath)) return [];
115
+ const content = fs.readFileSync(configPath, "utf8");
116
+ const profiles = [];
117
+ let currentProfile = null;
118
+ let currentAttrs = {};
119
+
120
+ for (const line of content.split("\n")) {
121
+ const profileMatch = line.match(/^\[profile\s+(.+?)\]/);
122
+ if (profileMatch) {
123
+ if (currentProfile && currentAttrs.sso_session) {
124
+ profiles.push({ name: currentProfile, ...currentAttrs });
125
+ }
126
+ currentProfile = profileMatch[1];
127
+ currentAttrs = {};
128
+ continue;
129
+ }
130
+ if (line.startsWith("[")) {
131
+ if (currentProfile && currentAttrs.sso_session) {
132
+ profiles.push({ name: currentProfile, ...currentAttrs });
133
+ }
134
+ currentProfile = null;
135
+ currentAttrs = {};
136
+ continue;
137
+ }
138
+ const kv = line.match(/^\s*(\S+)\s*=\s*(.+)/);
139
+ if (kv && currentProfile) {
140
+ currentAttrs[kv[1]] = kv[2].trim();
141
+ }
142
+ }
143
+ if (currentProfile && currentAttrs.sso_session) {
144
+ profiles.push({ name: currentProfile, ...currentAttrs });
145
+ }
146
+ return profiles;
147
+ }
@@ -0,0 +1,73 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execa } from "execa";
4
+
5
+ const ECR_RE = /(\d{12})\.dkr\.ecr\.([^.]+)\.amazonaws\.com\/([^:]+):?(\S*)/;
6
+
7
+ /**
8
+ * Parse docker-compose.yaml for all ECR image references.
9
+ * Returns [{ service, image, repo, tag }].
10
+ */
11
+ export function listEcrImages(root) {
12
+ const composePath = path.join(root, "docker-compose.yaml");
13
+ if (!fs.existsSync(composePath)) return [];
14
+ const content = fs.readFileSync(composePath, "utf8");
15
+
16
+ const results = [];
17
+ const seen = new Set();
18
+
19
+ // Walk through service blocks: "image: ..." lines
20
+ for (const match of content.matchAll(/^\s+image:\s*(.+)/gm)) {
21
+ const image = match[1].trim();
22
+ const ecrMatch = image.match(ECR_RE);
23
+ if (!ecrMatch) continue;
24
+
25
+ // Deduplicate by full image ref
26
+ if (seen.has(image)) continue;
27
+ seen.add(image);
28
+
29
+ results.push({
30
+ image,
31
+ repo: ecrMatch[3],
32
+ tag: ecrMatch[4] || "latest",
33
+ });
34
+ }
35
+
36
+ return results;
37
+ }
38
+
39
+ /**
40
+ * Check freshness of local Docker images.
41
+ * Returns [{ image, ageDays, stale }].
42
+ */
43
+ export async function checkImageFreshness(images, staleDays = 7) {
44
+ const results = [];
45
+
46
+ for (const { image, repo, tag } of images) {
47
+ // Resolve env vars in tag for inspect (e.g. ${IMAGE_TAG:-compose})
48
+ const resolvedTag = tag.replace(/\$\{[^}]+:-([^}]+)\}/, "$1");
49
+ const resolvedImage = image.replace(/\$\{[^}]+:-([^}]+)\}/, "$1");
50
+
51
+ try {
52
+ const { stdout } = await execa(
53
+ "docker",
54
+ ["image", "inspect", resolvedImage, "--format", "{{.Created}}"],
55
+ { reject: false, timeout: 5000 },
56
+ );
57
+
58
+ if (stdout?.trim()) {
59
+ const created = new Date(stdout.trim());
60
+ const ageDays = Math.floor(
61
+ (Date.now() - created.getTime()) / (1000 * 60 * 60 * 24),
62
+ );
63
+ results.push({ image: `${repo}:${resolvedTag}`, ageDays, stale: ageDays > staleDays });
64
+ } else {
65
+ results.push({ image: `${repo}:${resolvedTag}`, ageDays: null, stale: null });
66
+ }
67
+ } catch {
68
+ results.push({ image: `${repo}:${resolvedTag}`, ageDays: null, stale: null });
69
+ }
70
+ }
71
+
72
+ return results;
73
+ }