Skip to content

Commit 84c2e22

Browse files
authored
feat(cli): 1Password save and UX improvements for generated passwords (#139)
* fix: only show generated password when clipboard is unavailable * feat: offer 1Password save for generated passwords When generating a password, prompt where to save it: 1Password (as a bsky.app login), clipboard, or display. 1Password option only shown when op CLI is available. * fix: use assignment syntax for op username field
1 parent 90e9771 commit 84c2e22

File tree

2 files changed

+113
-8
lines changed

2 files changed

+113
-8
lines changed

packages/pds/src/cli/utils/cli-helpers.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,56 @@ export async function saveTo1Password(
203203
});
204204
}
205205

206+
/**
207+
* Save a password to 1Password as a Login item for bsky.app
208+
*/
209+
export async function savePasswordTo1Password(
210+
password: string,
211+
handle: string,
212+
): Promise<{ success: boolean; itemName?: string; error?: string }> {
213+
const itemName = `Bluesky - @${handle}`;
214+
215+
return new Promise((resolve) => {
216+
const child = spawn(
217+
"op",
218+
[
219+
"item",
220+
"create",
221+
"--category",
222+
"Login",
223+
"--title",
224+
itemName,
225+
`username=${handle}`,
226+
`password=${password}`,
227+
"--url=https://bsky.app",
228+
"--tags",
229+
"cirrus,pds,bluesky",
230+
],
231+
{ stdio: ["ignore", "pipe", "pipe"] },
232+
);
233+
234+
let stderr = "";
235+
child.stderr?.on("data", (data) => {
236+
stderr += data.toString();
237+
});
238+
239+
child.on("error", (err) => {
240+
resolve({ success: false, error: err.message });
241+
});
242+
243+
child.on("close", (code) => {
244+
if (code === 0) {
245+
resolve({ success: true, itemName });
246+
} else {
247+
resolve({
248+
success: false,
249+
error: stderr || `1Password CLI exited with code ${code}`,
250+
});
251+
}
252+
});
253+
});
254+
}
255+
206256
export interface RunCommandOptions {
207257
/** If true, stream output to stdout/stderr in real-time */
208258
stream?: boolean;

packages/pds/src/cli/utils/secrets.ts

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import bcrypt from "bcryptjs";
77
import * as p from "@clack/prompts";
88
import { setSecret, setVar, type SecretName } from "./wrangler.js";
99
import { setDevVar } from "./dotenv.js";
10-
import { promptSelect, copyToClipboard } from "./cli-helpers.js";
10+
import {
11+
promptSelect,
12+
copyToClipboard,
13+
is1PasswordAvailable,
14+
savePasswordTo1Password,
15+
} from "./cli-helpers.js";
1116

1217
export interface SigningKeypair {
1318
privateKey: string; // hex-encoded
@@ -78,15 +83,65 @@ export async function promptPassword(handle?: string): Promise<string> {
7883

7984
if (method === "generate") {
8085
const password = generatePassword();
81-
p.note(password, "Generated password");
82-
const copied = await copyToClipboard(password);
83-
if (copied) {
84-
p.log.success("Copied to clipboard");
86+
const has1Password = await is1PasswordAvailable();
87+
88+
type SaveOption = "1password" | "clipboard" | "show";
89+
const saveOptions: Array<{
90+
value: SaveOption;
91+
label: string;
92+
hint: string;
93+
}> = [];
94+
95+
if (has1Password) {
96+
saveOptions.push({
97+
value: "1password",
98+
label: "Save to 1Password",
99+
hint: "as a bsky.app login",
100+
});
101+
}
102+
103+
saveOptions.push(
104+
{ value: "clipboard", label: "Copy to clipboard", hint: "paste into password manager" },
105+
{ value: "show", label: "Display it", hint: "shown in terminal" },
106+
);
107+
108+
const saveChoice = await promptSelect<SaveOption>({
109+
message: "Where should we save the password?",
110+
options: saveOptions,
111+
});
112+
113+
if (saveChoice === "1password") {
114+
const spinner = p.spinner();
115+
spinner.start("Saving to 1Password...");
116+
const result = await savePasswordTo1Password(password, handle ?? "");
117+
if (result.success) {
118+
spinner.stop("Saved to 1Password");
119+
p.log.success(`Created: "${result.itemName}"`);
120+
} else {
121+
spinner.stop("Failed to save to 1Password");
122+
p.log.error(result.error || "Unknown error");
123+
// Fall back to clipboard
124+
const copied = await copyToClipboard(password);
125+
if (copied) {
126+
p.log.info("Copied to clipboard instead");
127+
} else {
128+
p.note(password, "Generated password");
129+
p.log.warn("Save this password somewhere safe!");
130+
}
131+
}
132+
} else if (saveChoice === "clipboard") {
133+
const copied = await copyToClipboard(password);
134+
if (copied) {
135+
p.log.success("Password generated and copied to clipboard");
136+
} else {
137+
p.note(password, "Generated password");
138+
p.log.warn("Could not copy to clipboard — save this password somewhere safe!");
139+
}
85140
} else {
86-
p.log.warn(
87-
"Could not copy to clipboard — save this password somewhere safe!",
88-
);
141+
p.note(password, "Generated password");
142+
p.log.warn("Save this password somewhere safe!");
89143
}
144+
90145
return password;
91146
}
92147

0 commit comments

Comments
 (0)