@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.
- package/package.json +2 -1
- package/src/commands/index.js +163 -1
- package/src/doctor.js +155 -17
- package/src/plugins/bundled/coda/auth.js +79 -0
- package/src/plugins/bundled/coda/client.js +187 -0
- package/src/plugins/bundled/coda/fops.plugin.json +7 -0
- package/src/plugins/bundled/coda/index.js +284 -0
- package/src/plugins/bundled/coda/package.json +3 -0
- package/src/plugins/bundled/coda/skills/coda/SKILL.md +82 -0
- package/src/plugins/bundled/cursor/fops.plugin.json +7 -0
- package/src/plugins/bundled/cursor/index.js +432 -0
- package/src/plugins/bundled/cursor/package.json +1 -0
- package/src/plugins/bundled/cursor/skills/cursor/SKILL.md +48 -0
- package/src/plugins/bundled/fops-plugin-1password/fops.plugin.json +7 -0
- package/src/plugins/bundled/fops-plugin-1password/index.js +239 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/env.js +100 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/op.js +111 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/setup.js +235 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/sync.js +61 -0
- package/src/plugins/bundled/fops-plugin-1password/package.json +1 -0
- package/src/plugins/bundled/fops-plugin-1password/skills/1password/SKILL.md +79 -0
- package/src/plugins/bundled/fops-plugin-ecr/fops.plugin.json +7 -0
- package/src/plugins/bundled/fops-plugin-ecr/index.js +302 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/aws.js +147 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/images.js +73 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/setup.js +180 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/sync.js +74 -0
- package/src/plugins/bundled/fops-plugin-ecr/package.json +1 -0
- package/src/plugins/bundled/fops-plugin-ecr/skills/ecr/SKILL.md +105 -0
- package/src/plugins/bundled/fops-plugin-memory/fops.plugin.json +7 -0
- package/src/plugins/bundled/fops-plugin-memory/index.js +148 -0
- package/src/plugins/bundled/fops-plugin-memory/lib/relevance.js +72 -0
- package/src/plugins/bundled/fops-plugin-memory/lib/store.js +75 -0
- package/src/plugins/bundled/fops-plugin-memory/package.json +1 -0
- package/src/plugins/bundled/fops-plugin-memory/skills/memory/SKILL.md +58 -0
- package/src/plugins/loader.js +40 -0
- package/src/setup/aws.js +51 -38
- package/src/setup/setup.js +2 -0
- 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,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
|
+
}
|