@meshxdata/fops 0.0.5 → 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.
Potentially problematic release.
This version of @meshxdata/fops might be problematic. Click here for more details.
- package/package.json +1 -1
- package/src/commands/index.js +115 -0
- package/src/doctor.js +7 -0
- 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/setup.js +2 -0
- package/src/setup/wizard.js +12 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { opVersion, opWhoami, opSignin } from "./lib/op.js";
|
|
5
|
+
import { discoverTemplates } from "./lib/env.js";
|
|
6
|
+
import { syncSecrets } from "./lib/sync.js";
|
|
7
|
+
import { runSetupWizard } from "./lib/setup.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Find the Foundation project root by walking up from cwd,
|
|
11
|
+
* looking for docker-compose.yaml + Makefile.
|
|
12
|
+
*/
|
|
13
|
+
function findRoot() {
|
|
14
|
+
let dir = process.cwd();
|
|
15
|
+
while (dir) {
|
|
16
|
+
const hasCompose =
|
|
17
|
+
fs.existsSync(path.join(dir, "docker-compose.yaml")) ||
|
|
18
|
+
fs.existsSync(path.join(dir, "docker-compose.yml"));
|
|
19
|
+
if (hasCompose && fs.existsSync(path.join(dir, "Makefile"))) return dir;
|
|
20
|
+
const parent = path.dirname(dir);
|
|
21
|
+
if (parent === dir) break;
|
|
22
|
+
dir = parent;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function register(api) {
|
|
28
|
+
const config = api.config;
|
|
29
|
+
|
|
30
|
+
// ── Command: fops 1password ────────────────────────
|
|
31
|
+
api.registerCommand((program) => {
|
|
32
|
+
const cmd = program
|
|
33
|
+
.command("1password")
|
|
34
|
+
.alias("1p")
|
|
35
|
+
.description("Manage secrets with 1Password");
|
|
36
|
+
|
|
37
|
+
// fops 1password setup
|
|
38
|
+
cmd
|
|
39
|
+
.command("setup")
|
|
40
|
+
.description("Interactive setup wizard for 1Password integration")
|
|
41
|
+
.action(async () => {
|
|
42
|
+
const root = findRoot();
|
|
43
|
+
if (!root) {
|
|
44
|
+
console.log(chalk.red("Not a Foundation project. Run from foundation-compose directory."));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
await runSetupWizard(root);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// fops 1password sync
|
|
51
|
+
cmd
|
|
52
|
+
.command("sync")
|
|
53
|
+
.description("Pull secrets from 1Password into .env files")
|
|
54
|
+
.action(async () => {
|
|
55
|
+
const root = findRoot();
|
|
56
|
+
if (!root) {
|
|
57
|
+
console.log(chalk.red("Not a Foundation project. Run from foundation-compose directory."));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
console.log(chalk.bold.cyan("\n 1Password Sync\n"));
|
|
61
|
+
|
|
62
|
+
const whoami = await opWhoami();
|
|
63
|
+
if (!whoami.authenticated) {
|
|
64
|
+
console.log(chalk.red(" Not signed in to 1Password. Run: fops 1password setup"));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { synced, errors } = await syncSecrets(root);
|
|
69
|
+
console.log("");
|
|
70
|
+
if (errors.length > 0) {
|
|
71
|
+
console.log(chalk.yellow(` ${synced} synced, ${errors.length} error(s)`));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
} else if (synced > 0) {
|
|
74
|
+
console.log(chalk.green(` Done — ${synced} file(s) synced.`));
|
|
75
|
+
} else {
|
|
76
|
+
console.log(chalk.dim(" Nothing to sync."));
|
|
77
|
+
}
|
|
78
|
+
console.log("");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// fops 1password status
|
|
82
|
+
cmd
|
|
83
|
+
.command("status")
|
|
84
|
+
.description("Show 1Password integration status")
|
|
85
|
+
.action(async () => {
|
|
86
|
+
console.log(chalk.bold.cyan("\n 1Password Status\n"));
|
|
87
|
+
|
|
88
|
+
// op CLI version
|
|
89
|
+
const version = await opVersion();
|
|
90
|
+
if (version) {
|
|
91
|
+
console.log(chalk.green(` ✓ op CLI v${version}`));
|
|
92
|
+
} else {
|
|
93
|
+
console.log(chalk.red(" ✗ op CLI not installed"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Auth status
|
|
97
|
+
const whoami = await opWhoami();
|
|
98
|
+
if (whoami.authenticated) {
|
|
99
|
+
console.log(chalk.green(` ✓ Signed in as ${whoami.email}`));
|
|
100
|
+
} else {
|
|
101
|
+
console.log(chalk.yellow(" ⚠ Not signed in"));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Config
|
|
105
|
+
if (config.defaultVault) {
|
|
106
|
+
console.log(chalk.green(` ✓ Default vault: ${config.defaultVault}`));
|
|
107
|
+
} else {
|
|
108
|
+
console.log(chalk.dim(" · No default vault configured"));
|
|
109
|
+
}
|
|
110
|
+
console.log(chalk.dim(` · Auto-sync: ${config.autoSync ? "enabled" : "disabled"}`));
|
|
111
|
+
|
|
112
|
+
// Templates
|
|
113
|
+
const root = findRoot();
|
|
114
|
+
if (root) {
|
|
115
|
+
const templates = discoverTemplates(root);
|
|
116
|
+
if (templates.length > 0) {
|
|
117
|
+
console.log(chalk.green(` ✓ ${templates.length} template(s) found:`));
|
|
118
|
+
for (const t of templates) {
|
|
119
|
+
const rel = t.dir === root ? "." : t.dir.replace(root + "/", "");
|
|
120
|
+
console.log(chalk.dim(` · ${rel}/.env.1password`));
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
console.log(chalk.yellow(" ⚠ No .env.1password templates found"));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
console.log("");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── Doctor check ───────────────────────────────────
|
|
131
|
+
api.registerDoctorCheck({
|
|
132
|
+
name: "1Password CLI",
|
|
133
|
+
fn: async (ok, warn, fail) => {
|
|
134
|
+
const version = await opVersion();
|
|
135
|
+
if (!version) {
|
|
136
|
+
warn("1Password CLI (op) not installed", "optional — run: brew install --cask 1password-cli");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
ok(`1Password CLI v${version}`);
|
|
140
|
+
|
|
141
|
+
const whoami = await opWhoami();
|
|
142
|
+
if (whoami.authenticated) {
|
|
143
|
+
ok(`1Password signed in`, whoami.email);
|
|
144
|
+
} else if (whoami.hasAccount) {
|
|
145
|
+
ok(`1Password configured`, `${whoami.email} — session will unlock on next use`);
|
|
146
|
+
} else {
|
|
147
|
+
warn("1Password not signed in", "enable CLI integration in 1Password app", async () => {
|
|
148
|
+
await opSignin();
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── Hook: before:up — auto-sync secrets ────────────
|
|
155
|
+
api.registerHook("before:up", async () => {
|
|
156
|
+
if (!config.autoSync) return;
|
|
157
|
+
|
|
158
|
+
const root = findRoot();
|
|
159
|
+
if (!root) return;
|
|
160
|
+
|
|
161
|
+
const templates = discoverTemplates(root);
|
|
162
|
+
if (templates.length === 0) return;
|
|
163
|
+
|
|
164
|
+
const whoami = await opWhoami();
|
|
165
|
+
if (!whoami.authenticated) {
|
|
166
|
+
console.log(chalk.yellow(" ⚠ 1Password: not signed in — skipping secret sync"));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log(chalk.blue(" 1Password: syncing secrets..."));
|
|
171
|
+
const { errors } = await syncSecrets(root);
|
|
172
|
+
if (errors.length > 0) {
|
|
173
|
+
console.log(chalk.yellow(` ⚠ 1Password: ${errors.length} sync error(s) — check with: fops 1password status`));
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── Hook: after:setup — remind about 1password ─────
|
|
178
|
+
api.registerHook("after:setup", async () => {
|
|
179
|
+
const root = findRoot();
|
|
180
|
+
if (!root) return;
|
|
181
|
+
|
|
182
|
+
const templates = discoverTemplates(root);
|
|
183
|
+
if (templates.length > 0) {
|
|
184
|
+
const whoami = await opWhoami();
|
|
185
|
+
if (whoami.authenticated) {
|
|
186
|
+
console.log(chalk.blue("\n 1Password templates detected — syncing secrets..."));
|
|
187
|
+
await syncSecrets(root);
|
|
188
|
+
} else {
|
|
189
|
+
console.log(chalk.yellow("\n 1Password templates found. Run: fops 1password setup"));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── Knowledge source ───────────────────────────────
|
|
195
|
+
api.registerKnowledgeSource({
|
|
196
|
+
name: "1Password Secrets",
|
|
197
|
+
description: "1Password integration for .env secret injection",
|
|
198
|
+
search(query) {
|
|
199
|
+
const keywords = ["1password", "op://", "secret", "vault", ".env.1password", "op inject"];
|
|
200
|
+
const q = query.toLowerCase();
|
|
201
|
+
const match = keywords.some((kw) => q.includes(kw));
|
|
202
|
+
if (!match) return [];
|
|
203
|
+
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
title: "1Password Plugin",
|
|
207
|
+
content: [
|
|
208
|
+
"## 1Password Secret Sync",
|
|
209
|
+
"",
|
|
210
|
+
"The 1Password plugin resolves `op://` secret references from `.env.1password` templates into `.env` files.",
|
|
211
|
+
"",
|
|
212
|
+
"### Commands",
|
|
213
|
+
"- `fops 1password setup` — Interactive wizard: install op CLI, sign in, pick vault, scaffold templates",
|
|
214
|
+
"- `fops 1password sync` — Pull secrets from 1Password and merge into .env files",
|
|
215
|
+
"- `fops 1password status` — Show op version, auth status, vault, discovered templates",
|
|
216
|
+
"- `fops 1p ...` — Shorthand alias",
|
|
217
|
+
"",
|
|
218
|
+
"### Template Format (.env.1password)",
|
|
219
|
+
"```",
|
|
220
|
+
"BEARER_TOKEN=op://Foundation/auth0-dev/bearer-token",
|
|
221
|
+
"DB_PASSWORD=op://Foundation/postgres-dev/password",
|
|
222
|
+
"```",
|
|
223
|
+
"",
|
|
224
|
+
"### Config (~/.fops.json)",
|
|
225
|
+
"```json",
|
|
226
|
+
'{ "plugins": { "entries": { "fops-plugin-1password": { "config": { "defaultVault": "Foundation", "autoSync": true } } } } }',
|
|
227
|
+
"```",
|
|
228
|
+
"",
|
|
229
|
+
"### Troubleshooting",
|
|
230
|
+
"- `op signin` — Sign in to 1Password",
|
|
231
|
+
"- `op whoami` — Check auth status",
|
|
232
|
+
"- Ensure the op:// vault/item/field path matches your 1Password items",
|
|
233
|
+
].join("\n"),
|
|
234
|
+
score: 0.9,
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse a .env file content string into a Map of key→value pairs.
|
|
6
|
+
* Skips blank lines and comments.
|
|
7
|
+
*/
|
|
8
|
+
export function parseEnv(content) {
|
|
9
|
+
const env = new Map();
|
|
10
|
+
for (const line of content.split("\n")) {
|
|
11
|
+
const trimmed = line.trim();
|
|
12
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
13
|
+
const idx = trimmed.indexOf("=");
|
|
14
|
+
if (idx === -1) continue;
|
|
15
|
+
const key = trimmed.slice(0, idx).trim();
|
|
16
|
+
const value = trimmed.slice(idx + 1).trim();
|
|
17
|
+
env.set(key, value);
|
|
18
|
+
}
|
|
19
|
+
return env;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Merge resolved secrets into an existing .env file content string.
|
|
24
|
+
* - Overwrites matching keys in-place
|
|
25
|
+
* - Appends new keys at the end under a 1Password section header
|
|
26
|
+
* - Preserves comments, blank lines, and ordering
|
|
27
|
+
*/
|
|
28
|
+
export function mergeEnv(existingContent, secrets) {
|
|
29
|
+
if (!secrets || secrets.size === 0) return existingContent;
|
|
30
|
+
|
|
31
|
+
const remaining = new Map(secrets);
|
|
32
|
+
const lines = existingContent.split("\n");
|
|
33
|
+
const result = [];
|
|
34
|
+
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
38
|
+
result.push(line);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const idx = trimmed.indexOf("=");
|
|
42
|
+
if (idx === -1) {
|
|
43
|
+
result.push(line);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const key = trimmed.slice(0, idx).trim();
|
|
47
|
+
if (remaining.has(key)) {
|
|
48
|
+
result.push(`${key}=${remaining.get(key)}`);
|
|
49
|
+
remaining.delete(key);
|
|
50
|
+
} else {
|
|
51
|
+
result.push(line);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Append any new keys not already in the file
|
|
56
|
+
if (remaining.size > 0) {
|
|
57
|
+
result.push("");
|
|
58
|
+
result.push("# 1Password secrets (auto-synced)");
|
|
59
|
+
for (const [key, value] of remaining) {
|
|
60
|
+
result.push(`${key}=${value}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result.join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Discover .env.1password template files in a project root.
|
|
69
|
+
* Scans root and immediate subdirectories.
|
|
70
|
+
* Returns array of { templatePath, envPath, dir }.
|
|
71
|
+
*/
|
|
72
|
+
export function discoverTemplates(root) {
|
|
73
|
+
const templates = [];
|
|
74
|
+
|
|
75
|
+
function checkDir(dir) {
|
|
76
|
+
const templatePath = path.join(dir, ".env.1password");
|
|
77
|
+
if (fs.existsSync(templatePath)) {
|
|
78
|
+
templates.push({
|
|
79
|
+
templatePath,
|
|
80
|
+
envPath: path.join(dir, ".env"),
|
|
81
|
+
dir,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
checkDir(root);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (!entry.isDirectory()) continue;
|
|
92
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
93
|
+
checkDir(path.join(root, entry.name));
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// ignore read errors
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return templates;
|
|
100
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get the installed op CLI version.
|
|
5
|
+
* Returns version string or null if not installed.
|
|
6
|
+
*/
|
|
7
|
+
export async function opVersion() {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await execa("op", ["--version"], { timeout: 5000 });
|
|
10
|
+
return stdout.trim() || null;
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check current 1Password auth status.
|
|
18
|
+
* Returns { authenticated: true, email, account }
|
|
19
|
+
* or { authenticated: false, hasAccount: true/false }.
|
|
20
|
+
*
|
|
21
|
+
* `hasAccount: true` means an account is configured but the session needs
|
|
22
|
+
* a biometric/unlock prompt — this is normal and not a real error.
|
|
23
|
+
*/
|
|
24
|
+
export async function opWhoami() {
|
|
25
|
+
try {
|
|
26
|
+
const { stdout } = await execa("op", ["whoami", "--format", "json"], { timeout: 10000 });
|
|
27
|
+
const info = JSON.parse(stdout);
|
|
28
|
+
return {
|
|
29
|
+
authenticated: true,
|
|
30
|
+
email: info.email || info.user_email || "",
|
|
31
|
+
account: info.url || info.account || "",
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
// whoami failed — check if an account is at least configured
|
|
35
|
+
try {
|
|
36
|
+
const { stdout } = await execa("op", ["account", "list", "--format", "json"], { timeout: 5000 });
|
|
37
|
+
const accounts = JSON.parse(stdout);
|
|
38
|
+
if (accounts.length > 0) {
|
|
39
|
+
return {
|
|
40
|
+
authenticated: false,
|
|
41
|
+
hasAccount: true,
|
|
42
|
+
email: accounts[0].email || "",
|
|
43
|
+
account: accounts[0].url || "",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
return { authenticated: false, hasAccount: false, email: "", account: "" };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run `op inject` with a template string as stdin.
|
|
53
|
+
* Resolves op:// references and returns the output with real values.
|
|
54
|
+
*/
|
|
55
|
+
export async function opInject(template) {
|
|
56
|
+
const { stdout } = await execa("op", ["inject"], {
|
|
57
|
+
input: template,
|
|
58
|
+
timeout: 30000,
|
|
59
|
+
});
|
|
60
|
+
return stdout;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* List available 1Password vaults.
|
|
65
|
+
* Returns array of { id, name }.
|
|
66
|
+
*/
|
|
67
|
+
export async function opListVaults() {
|
|
68
|
+
const { stdout } = await execa("op", ["vault", "list", "--format", "json"], { timeout: 10000 });
|
|
69
|
+
const vaults = JSON.parse(stdout);
|
|
70
|
+
return vaults.map((v) => ({ id: v.id, name: v.name }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Trigger 1Password sign-in.
|
|
75
|
+
* First tries biometric unlock via desktop app integration.
|
|
76
|
+
* If that fails, opens 1Password developer settings and waits for user to enable CLI integration.
|
|
77
|
+
*/
|
|
78
|
+
export async function opSignin() {
|
|
79
|
+
// Try biometric/app integration first
|
|
80
|
+
try {
|
|
81
|
+
await execa("op", ["signin", "-f"], {
|
|
82
|
+
env: { ...process.env, OP_BIOMETRIC_UNLOCK_ENABLED: "true" },
|
|
83
|
+
timeout: 10000,
|
|
84
|
+
});
|
|
85
|
+
// Check if it actually worked
|
|
86
|
+
const check = await execa("op", ["whoami", "--format", "json"], {
|
|
87
|
+
env: { ...process.env, OP_BIOMETRIC_UNLOCK_ENABLED: "true" },
|
|
88
|
+
reject: false, timeout: 5000,
|
|
89
|
+
});
|
|
90
|
+
if (check.exitCode === 0) return;
|
|
91
|
+
} catch {}
|
|
92
|
+
|
|
93
|
+
// Desktop app integration not enabled — open settings and wait
|
|
94
|
+
if (process.platform === "darwin") {
|
|
95
|
+
console.log(" Opening 1Password Developer settings...");
|
|
96
|
+
console.log(" Enable \"Integrate with 1Password CLI\", then come back.\n");
|
|
97
|
+
await execa("open", ["onepassword://settings/developer"], { reject: false, timeout: 5000 });
|
|
98
|
+
|
|
99
|
+
// Poll until the user enables it (up to 60s)
|
|
100
|
+
const deadline = Date.now() + 60_000;
|
|
101
|
+
while (Date.now() < deadline) {
|
|
102
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
103
|
+
try {
|
|
104
|
+
const { exitCode } = await execa("op", ["whoami"], { reject: false, timeout: 5000 });
|
|
105
|
+
if (exitCode === 0) return;
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new Error("1Password CLI integration not enabled");
|
|
111
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import inquirer from "inquirer";
|
|
6
|
+
import { opVersion, opWhoami, opSignin, opListVaults } from "./op.js";
|
|
7
|
+
import { discoverTemplates } from "./env.js";
|
|
8
|
+
|
|
9
|
+
// Patterns that indicate a key is likely a secret
|
|
10
|
+
const SECRET_PATTERNS = [
|
|
11
|
+
/password/i, /secret/i, /token/i, /key/i, /bearer/i,
|
|
12
|
+
/credential/i, /auth/i, /api_key/i, /apikey/i,
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function isSecretKey(key) {
|
|
16
|
+
return SECRET_PATTERNS.some((p) => p.test(key));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read ~/.fops.json config (full file).
|
|
21
|
+
*/
|
|
22
|
+
function readFopsConfig() {
|
|
23
|
+
const configPath = path.join(os.homedir(), ".fops.json");
|
|
24
|
+
try {
|
|
25
|
+
if (fs.existsSync(configPath)) {
|
|
26
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// ignore
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Save ~/.fops.json config (merge into existing).
|
|
36
|
+
*/
|
|
37
|
+
function saveFopsConfig(updates) {
|
|
38
|
+
const configPath = path.join(os.homedir(), ".fops.json");
|
|
39
|
+
const existing = readFopsConfig();
|
|
40
|
+
const merged = { ...existing, ...updates };
|
|
41
|
+
// Deep merge plugins.entries
|
|
42
|
+
if (updates.plugins) {
|
|
43
|
+
merged.plugins = { ...existing.plugins, ...updates.plugins };
|
|
44
|
+
merged.plugins.entries = {
|
|
45
|
+
...existing?.plugins?.entries,
|
|
46
|
+
...updates.plugins.entries,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function ask(message, defaultValue = true) {
|
|
53
|
+
const { answer } = await inquirer.prompt([{
|
|
54
|
+
type: "confirm", name: "answer", message, default: defaultValue,
|
|
55
|
+
}]);
|
|
56
|
+
return answer;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function choose(message, choices) {
|
|
60
|
+
const { answer } = await inquirer.prompt([{
|
|
61
|
+
type: "list", name: "answer", message, choices,
|
|
62
|
+
}]);
|
|
63
|
+
return answer;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Interactive setup wizard for the 1Password plugin.
|
|
68
|
+
*/
|
|
69
|
+
export async function runSetupWizard(root) {
|
|
70
|
+
console.log(chalk.bold.cyan("\n 1Password Plugin Setup\n"));
|
|
71
|
+
|
|
72
|
+
// Step 1: Check op CLI
|
|
73
|
+
console.log(chalk.dim(" Checking 1Password CLI..."));
|
|
74
|
+
let version = await opVersion();
|
|
75
|
+
if (!version) {
|
|
76
|
+
console.log(chalk.red(" ✗ 1Password CLI (op) not found."));
|
|
77
|
+
const install = await ask("Install via Homebrew?", true);
|
|
78
|
+
if (install) {
|
|
79
|
+
console.log(chalk.cyan(" ▶ brew install --cask 1password-cli"));
|
|
80
|
+
const { execa: execaFn } = await import("execa");
|
|
81
|
+
try {
|
|
82
|
+
await execaFn("brew", ["install", "--cask", "1password-cli"], { stdio: "inherit", timeout: 120000 });
|
|
83
|
+
version = await opVersion();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.log(chalk.red(` Install failed: ${err.message}`));
|
|
86
|
+
console.log(chalk.dim(" Install manually: https://developer.1password.com/docs/cli/get-started/"));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
console.log(chalk.dim(" Install manually: https://developer.1password.com/docs/cli/get-started/"));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
console.log(chalk.green(` ✓ op CLI v${version}`));
|
|
95
|
+
|
|
96
|
+
// Step 2: Sign in automatically
|
|
97
|
+
console.log(chalk.dim("\n Checking authentication..."));
|
|
98
|
+
let whoami = await opWhoami();
|
|
99
|
+
if (!whoami.authenticated) {
|
|
100
|
+
console.log(chalk.yellow(" Not signed in to 1Password. Signing in..."));
|
|
101
|
+
try {
|
|
102
|
+
await opSignin();
|
|
103
|
+
whoami = await opWhoami();
|
|
104
|
+
} catch {
|
|
105
|
+
console.log(chalk.red(" Sign-in failed. Enable CLI integration in 1Password > Settings > Developer"));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (whoami.authenticated) {
|
|
110
|
+
console.log(chalk.green(` ✓ Signed in as ${whoami.email}`));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Step 3: Pick vault
|
|
114
|
+
console.log(chalk.dim("\n Loading vaults..."));
|
|
115
|
+
let vaults;
|
|
116
|
+
try {
|
|
117
|
+
vaults = await opListVaults();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.log(chalk.red(` Could not list vaults: ${err.message}`));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (vaults.length === 0) {
|
|
124
|
+
console.log(chalk.yellow(" No vaults found."));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let defaultVault;
|
|
129
|
+
if (vaults.length === 1) {
|
|
130
|
+
defaultVault = vaults[0].name;
|
|
131
|
+
console.log(chalk.green(` ✓ Using vault: ${defaultVault}`));
|
|
132
|
+
} else {
|
|
133
|
+
const choices = vaults.map((v) => ({ name: v.name, value: v.name }));
|
|
134
|
+
defaultVault = await choose("Select default vault for secrets:", choices);
|
|
135
|
+
console.log(chalk.green(` ✓ Vault: ${defaultVault}`));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Step 4: Auto-sync preference
|
|
139
|
+
const autoSync = await ask("Auto-sync secrets before `fops up`?", true);
|
|
140
|
+
|
|
141
|
+
// Step 5: Scaffold .env.1password templates
|
|
142
|
+
console.log(chalk.dim("\n Scanning for .env.example files..."));
|
|
143
|
+
const existingTemplates = discoverTemplates(root);
|
|
144
|
+
const existingTemplateDirs = new Set(existingTemplates.map((t) => t.dir));
|
|
145
|
+
|
|
146
|
+
let scaffolded = 0;
|
|
147
|
+
const exampleFiles = findEnvExamples(root);
|
|
148
|
+
for (const { examplePath, dir } of exampleFiles) {
|
|
149
|
+
if (existingTemplateDirs.has(dir)) continue; // already has template
|
|
150
|
+
|
|
151
|
+
const content = fs.readFileSync(examplePath, "utf8");
|
|
152
|
+
const secretLines = [];
|
|
153
|
+
for (const line of content.split("\n")) {
|
|
154
|
+
const trimmed = line.trim();
|
|
155
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
156
|
+
const idx = trimmed.indexOf("=");
|
|
157
|
+
if (idx === -1) continue;
|
|
158
|
+
const key = trimmed.slice(0, idx).trim();
|
|
159
|
+
const value = trimmed.slice(idx + 1).trim();
|
|
160
|
+
// Only include keys that look like secrets and have empty/placeholder values
|
|
161
|
+
if (isSecretKey(key) && (!value || value === '""' || value === "''")) {
|
|
162
|
+
secretLines.push(`${key}=op://${defaultVault}/ITEM_NAME/${key.toLowerCase()}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (secretLines.length > 0) {
|
|
167
|
+
const relDir = dir === root ? "." : dir.replace(root + "/", "");
|
|
168
|
+
const scaffold = await ask(`Scaffold ${relDir}/.env.1password (${secretLines.length} secrets)?`, true);
|
|
169
|
+
if (scaffold) {
|
|
170
|
+
const templateContent = [
|
|
171
|
+
"# 1Password secret references — edit op:// paths to match your vault items",
|
|
172
|
+
`# Format: KEY=op://<vault>/<item>/<field>`,
|
|
173
|
+
"",
|
|
174
|
+
...secretLines,
|
|
175
|
+
"",
|
|
176
|
+
].join("\n");
|
|
177
|
+
fs.writeFileSync(path.join(dir, ".env.1password"), templateContent);
|
|
178
|
+
console.log(chalk.green(` ✓ Created ${relDir}/.env.1password`));
|
|
179
|
+
scaffolded++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (scaffolded === 0 && existingTemplates.length === 0) {
|
|
185
|
+
console.log(chalk.dim(" No secret-looking keys found in .env.example files."));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Step 6: Save config
|
|
189
|
+
saveFopsConfig({
|
|
190
|
+
plugins: {
|
|
191
|
+
entries: {
|
|
192
|
+
"fops-plugin-1password": {
|
|
193
|
+
enabled: true,
|
|
194
|
+
config: {
|
|
195
|
+
defaultVault,
|
|
196
|
+
autoSync,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
console.log(chalk.green("\n ✓ Config saved to ~/.fops.json"));
|
|
203
|
+
console.log(chalk.dim(` defaultVault: ${defaultVault}`));
|
|
204
|
+
console.log(chalk.dim(` autoSync: ${autoSync}`));
|
|
205
|
+
console.log(chalk.bold.green("\n Setup complete! Run: fops 1password sync\n"));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Find .env.example files in root + immediate subdirectories.
|
|
210
|
+
*/
|
|
211
|
+
function findEnvExamples(root) {
|
|
212
|
+
const results = [];
|
|
213
|
+
|
|
214
|
+
function checkDir(dir) {
|
|
215
|
+
const examplePath = path.join(dir, ".env.example");
|
|
216
|
+
if (fs.existsSync(examplePath)) {
|
|
217
|
+
results.push({ examplePath, dir });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
checkDir(root);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
if (!entry.isDirectory()) continue;
|
|
227
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
228
|
+
checkDir(path.join(root, entry.name));
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// ignore
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return results;
|
|
235
|
+
}
|