lecoffre 0.1.0 → 0.2.0

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 CHANGED
@@ -2,23 +2,52 @@
2
2
 
3
3
  > [!WARNING]
4
4
  > This project is a work in progress. Expect breaking changes.
5
- > Variables are currently stored in a plain JSON file. 1Password integration is planned as the default storage backend.
6
5
 
7
6
  Per-project environment variable manager for the shell.
8
7
 
9
8
  Store, load and unload environment variables by project and environment, directly in your current shell session.
10
9
 
10
+ ## Prerequisites
11
+
12
+ - Node.js 24 or later
13
+ - [1Password CLI (`op`)](https://developer.1password.com/docs/cli/get-started/) installed and signed in
14
+
11
15
  ## Installation
12
16
 
13
17
  ```sh
14
18
  npm install -g lecoffre
15
19
  ```
16
20
 
17
- Requires Node.js 24 or later.
21
+ ## Storage
22
+
23
+ By default, lecoffre uses **1Password** as its storage backend. Variables are stored as Secure Notes inside a dedicated `lecoffre` vault.
24
+
25
+ > [!NOTE]
26
+ > When importing variables, field values are briefly visible in the process argument list (`/proc/<pid>/cmdline`). This is a limitation of the 1Password CLI, which does not support reading field values from stdin when spawned as a child process.
27
+
28
+ To get started, initialize the vault:
29
+
30
+ ```sh
31
+ lecoffre init
32
+ ```
33
+
34
+ ### Alternative: JSON file storage
35
+
36
+ > [!CAUTION]
37
+ > This storage backend is **not secure**. Variables are stored in plain text on disk.
38
+
39
+ For development or environments without 1Password, set the `LECOFFRE_STORAGE_PATH` environment variable to use a local JSON file instead:
40
+
41
+ ```sh
42
+ export LECOFFRE_STORAGE_PATH=/tmp/lecoffre.json
43
+ ```
18
44
 
19
45
  ## Quick start
20
46
 
21
47
  ```sh
48
+ # Initialize the storage backend
49
+ lecoffre init
50
+
22
51
  # Import variables from a .env file
23
52
  lecoffre import < .env
24
53
 
@@ -31,6 +60,10 @@ eval "$(lecoffre unload)"
31
60
 
32
61
  ## Commands
33
62
 
63
+ ### `lecoffre init`
64
+
65
+ Initialize the storage backend. For 1Password, this creates the `lecoffre` vault if it doesn't exist.
66
+
34
67
  ### `lecoffre list [project]`
35
68
 
36
69
  List all projects and their environments. When a project name is given, list only the environments for that project.
package/bin/lecoffre.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { parse } from "@bomb.sh/args";
4
4
  import packageJson from "../package.json" with { type: "json" };
5
5
  import { importCommand } from "../src/commands/import.command.ts";
6
+ import { initCommand } from "../src/commands/init.command.ts";
6
7
  import { listCommand } from "../src/commands/list.command.ts";
7
8
  import { loadCommand } from "../src/commands/load.command.ts";
8
9
  import { unloadCommand } from "../src/commands/unload.command.ts";
@@ -16,6 +17,7 @@ import {
16
17
 
17
18
  const { name } = packageJson;
18
19
  const commands: Record<string, AnyCommandDefinition> = {
20
+ init: initCommand,
19
21
  list: listCommand,
20
22
  load: loadCommand,
21
23
  unload: unloadCommand,
package/package.json CHANGED
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "name": "lecoffre",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Work in progress CLI project.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/hsablonniere/lecoffre"
8
+ },
5
9
  "bin": {
6
10
  "lecoffre": "./bin/lecoffre.ts"
7
11
  },
8
12
  "files": [
9
13
  "bin",
14
+ "src",
10
15
  "README.md",
11
16
  "LICENSE"
12
17
  ],
@@ -25,10 +30,6 @@
25
30
  "oxlint": "^1.48.0",
26
31
  "vitest": "^4.0.18"
27
32
  },
28
- "repository": {
29
- "type": "git",
30
- "url": "https://github.com/hsablonniere/lecoffre"
31
- },
32
33
  "engines": {
33
34
  "node": ">=24"
34
35
  },
@@ -0,0 +1,101 @@
1
+ import { basename } from "node:path";
2
+ import { realpath } from "node:fs/promises";
3
+ import { parseEnv } from "node:util";
4
+ import { z } from "zod";
5
+ import { defineCommand } from "../lib/define-command.ts";
6
+ import { defineOption } from "../lib/define-option.ts";
7
+ import { getStorage } from "../lib/get-storage.ts";
8
+ import { environmentOption } from "../options/environment.option.ts";
9
+ import { projectOption } from "../options/project.option.ts";
10
+ import { ProjectNotFoundError } from "../lib/storage.ts";
11
+
12
+ export const importCommand = defineCommand({
13
+ description: "Import variables from stdin (.env format)",
14
+ options: {
15
+ project: projectOption,
16
+ environment: environmentOption,
17
+ merge: defineOption({
18
+ name: "merge",
19
+ schema: z.boolean().default(false),
20
+ description: "Merge with existing variables instead of replacing",
21
+ aliases: ["m"],
22
+ }),
23
+ },
24
+ async handler(options) {
25
+ const storage = getStorage();
26
+ const project = options.project ?? basename(await realpath(process.cwd()));
27
+
28
+ const input = await readStdin();
29
+ const newVars = parseEnv(input) as Record<string, string>;
30
+
31
+ let existingVars: Record<string, string>;
32
+ try {
33
+ const projectData = await storage.getProject(project);
34
+ existingVars = projectData[options.environment] ?? {};
35
+ } catch (error) {
36
+ if (error instanceof ProjectNotFoundError) {
37
+ existingVars = {};
38
+ } else {
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ const added: Array<string> = [];
44
+ const updated: Array<string> = [];
45
+ const removed: Array<string> = [];
46
+
47
+ if (options.merge) {
48
+ // Merge mode: add/overwrite without clearing
49
+ for (const key of Object.keys(newVars)) {
50
+ if (key in existingVars) {
51
+ if (existingVars[key] !== newVars[key]) {
52
+ updated.push(key);
53
+ }
54
+ } else {
55
+ added.push(key);
56
+ }
57
+ }
58
+ await storage.setVariables(project, options.environment, { ...existingVars, ...newVars });
59
+ } else {
60
+ // Replace mode: clear then set
61
+ for (const key of Object.keys(newVars)) {
62
+ if (key in existingVars) {
63
+ if (existingVars[key] !== newVars[key]) {
64
+ updated.push(key);
65
+ }
66
+ } else {
67
+ added.push(key);
68
+ }
69
+ }
70
+ for (const key of Object.keys(existingVars)) {
71
+ if (!(key in newVars)) {
72
+ removed.push(key);
73
+ }
74
+ }
75
+ await storage.setVariables(project, options.environment, newVars);
76
+ }
77
+
78
+ for (const key of added) {
79
+ console.error(`+ ${key} (added)`);
80
+ }
81
+ for (const key of updated) {
82
+ console.error(`~ ${key} (updated)`);
83
+ }
84
+ for (const key of removed) {
85
+ console.error(`- ${key} (removed)`);
86
+ }
87
+
88
+ const totalVars = Object.keys(newVars).length;
89
+ console.error(
90
+ `Imported ${totalVars} variable${totalVars !== 1 ? "s" : ""} into ${project} [${options.environment}]`,
91
+ );
92
+ },
93
+ });
94
+
95
+ async function readStdin(): Promise<string> {
96
+ const chunks: Array<Buffer> = [];
97
+ for await (const chunk of process.stdin) {
98
+ chunks.push(chunk as Buffer);
99
+ }
100
+ return Buffer.concat(chunks).toString("utf-8");
101
+ }
@@ -0,0 +1,11 @@
1
+ import { defineCommand } from "../lib/define-command.ts";
2
+ import { getStorage } from "../lib/get-storage.ts";
3
+
4
+ export const initCommand = defineCommand({
5
+ description: "Initialize the storage backend",
6
+ async handler() {
7
+ const storage = getStorage();
8
+ await storage.init();
9
+ console.log("Storage initialized.");
10
+ },
11
+ });
@@ -0,0 +1,47 @@
1
+ import { defineCommand } from "../lib/define-command.ts";
2
+ import { getStorage } from "../lib/get-storage.ts";
3
+ import { projectOption } from "../options/project.option.ts";
4
+ import { ProjectNotFoundError } from "../lib/storage.ts";
5
+
6
+ export const listCommand = defineCommand({
7
+ description: "List projects and their environments",
8
+ options: {
9
+ project: projectOption,
10
+ },
11
+ async handler(options) {
12
+ const storage = getStorage();
13
+
14
+ if (options.project !== undefined) {
15
+ let projectData: Record<string, Record<string, string>>;
16
+ try {
17
+ projectData = await storage.getProject(options.project);
18
+ } catch (error) {
19
+ if (error instanceof ProjectNotFoundError) {
20
+ throw new Error(`Project not found: ${options.project}`);
21
+ }
22
+ throw error;
23
+ }
24
+ for (const [env, vars] of Object.entries(projectData)) {
25
+ const count = Object.keys(vars).length;
26
+ console.log(`${env} (${count} variable${count !== 1 ? "s" : ""})`);
27
+ }
28
+ return;
29
+ }
30
+
31
+ const projects = await storage.getProjects();
32
+
33
+ if (projects.length === 0) {
34
+ console.log("No projects found.");
35
+ return;
36
+ }
37
+
38
+ for (const p of projects) {
39
+ console.log(p);
40
+ const projectData = await storage.getProject(p);
41
+ for (const [env, vars] of Object.entries(projectData)) {
42
+ const count = Object.keys(vars).length;
43
+ console.log(` ${env} (${count} variable${count !== 1 ? "s" : ""})`);
44
+ }
45
+ }
46
+ },
47
+ });
@@ -0,0 +1,32 @@
1
+ import { basename } from "node:path";
2
+ import { realpath } from "node:fs/promises";
3
+ import { defineCommand } from "../lib/define-command.ts";
4
+ import { getStorage } from "../lib/get-storage.ts";
5
+ import { detectShell, formatVariables } from "../lib/shell.ts";
6
+ import { EnvironmentNotFoundError } from "../lib/storage.ts";
7
+ import { environmentOption } from "../options/environment.option.ts";
8
+ import { projectOption } from "../options/project.option.ts";
9
+
10
+ export const loadCommand = defineCommand({
11
+ description: "Load variables into the current shell environment",
12
+ options: {
13
+ project: projectOption,
14
+ environment: environmentOption,
15
+ },
16
+ async handler(options) {
17
+ const storage = getStorage();
18
+ const project = options.project ?? basename(await realpath(process.cwd()));
19
+
20
+ const projectData = await storage.getProject(project);
21
+ const vars = projectData[options.environment];
22
+ if (vars === undefined) {
23
+ throw new EnvironmentNotFoundError(project, options.environment);
24
+ }
25
+
26
+ const shell = detectShell();
27
+ const output = formatVariables(shell, vars);
28
+ if (output !== "") {
29
+ console.log(output);
30
+ }
31
+ },
32
+ });
@@ -0,0 +1,32 @@
1
+ import { basename } from "node:path";
2
+ import { realpath } from "node:fs/promises";
3
+ import { defineCommand } from "../lib/define-command.ts";
4
+ import { getStorage } from "../lib/get-storage.ts";
5
+ import { detectShell, formatUnsetVariables } from "../lib/shell.ts";
6
+ import { EnvironmentNotFoundError } from "../lib/storage.ts";
7
+ import { environmentOption } from "../options/environment.option.ts";
8
+ import { projectOption } from "../options/project.option.ts";
9
+
10
+ export const unloadCommand = defineCommand({
11
+ description: "Unload variables from the current shell environment",
12
+ options: {
13
+ project: projectOption,
14
+ environment: environmentOption,
15
+ },
16
+ async handler(options) {
17
+ const storage = getStorage();
18
+ const project = options.project ?? basename(await realpath(process.cwd()));
19
+
20
+ const projectData = await storage.getProject(project);
21
+ const vars = projectData[options.environment];
22
+ if (vars === undefined) {
23
+ throw new EnvironmentNotFoundError(project, options.environment);
24
+ }
25
+
26
+ const shell = detectShell();
27
+ const output = formatUnsetVariables(shell, Object.keys(vars));
28
+ if (output !== "") {
29
+ console.log(output);
30
+ }
31
+ },
32
+ });
@@ -0,0 +1,13 @@
1
+ import type { z } from "zod";
2
+
3
+ export interface ArgumentDefinition<S extends z.ZodType = z.ZodType> {
4
+ schema: S;
5
+ description: string;
6
+ placeholder: string;
7
+ }
8
+
9
+ export function defineArgument<S extends z.ZodType>(
10
+ definition: ArgumentDefinition<S>,
11
+ ): ArgumentDefinition<S> {
12
+ return definition;
13
+ }
@@ -0,0 +1,43 @@
1
+ import type { z } from "zod";
2
+ import type { ArgumentDefinition } from "./define-argument.ts";
3
+ import type { OptionDefinition } from "./define-option.ts";
4
+
5
+ type OptionsRecord = Record<string, OptionDefinition>;
6
+ type ArgumentsArray = readonly ArgumentDefinition[];
7
+
8
+ type InferOptionsType<O> = O extends OptionsRecord
9
+ ? { [K in keyof O]: z.infer<O[K]["schema"]> }
10
+ : Record<string, never>;
11
+
12
+ type InferArgsType<A extends ArgumentsArray> = {
13
+ [K in keyof A]: A[K] extends ArgumentDefinition ? z.infer<A[K]["schema"]> : never;
14
+ };
15
+
16
+ type CommandHandler<O, A> = A extends ArgumentsArray
17
+ ? (options: InferOptionsType<O>, ...args: InferArgsType<A>) => void | Promise<void>
18
+ : (options: InferOptionsType<O>) => void | Promise<void>;
19
+
20
+ export interface CommandDefinition<
21
+ O extends OptionsRecord | undefined = OptionsRecord,
22
+ A extends ArgumentsArray | undefined = ArgumentsArray,
23
+ > {
24
+ description: string;
25
+ options?: O | undefined;
26
+ args?: A | undefined;
27
+ handler: CommandHandler<O, A>;
28
+ }
29
+
30
+ /** Type-erased command definition for use in registries. */
31
+ export interface AnyCommandDefinition {
32
+ description: string;
33
+ options?: OptionsRecord | undefined;
34
+ args?: ArgumentsArray | undefined;
35
+ handler: (options: any, ...args: any[]) => void | Promise<void>;
36
+ }
37
+
38
+ export function defineCommand<
39
+ O extends OptionsRecord | undefined = undefined,
40
+ A extends ArgumentsArray | undefined = undefined,
41
+ >(definition: CommandDefinition<O, A>): CommandDefinition<O, A> {
42
+ return definition;
43
+ }
@@ -0,0 +1,15 @@
1
+ import type { z } from "zod";
2
+
3
+ export interface OptionDefinition<S extends z.ZodType = z.ZodType> {
4
+ name: string;
5
+ schema: S;
6
+ description: string;
7
+ aliases?: Array<string>;
8
+ placeholder?: string;
9
+ }
10
+
11
+ export function defineOption<S extends z.ZodType>(
12
+ definition: OptionDefinition<S>,
13
+ ): OptionDefinition<S> {
14
+ return definition;
15
+ }
@@ -0,0 +1,108 @@
1
+ import { styleText } from "node:util";
2
+ import type { z } from "zod";
3
+ import type { ArgumentDefinition } from "./define-argument.ts";
4
+ import type { AnyCommandDefinition } from "./define-command.ts";
5
+ import type { OptionDefinition } from "./define-option.ts";
6
+ import { getDefault, isBoolean, isRequired } from "./zod-utils.ts";
7
+
8
+ export function formatGlobalHelp(toolName: string, commands: Record<string, AnyCommandDefinition>) {
9
+ const names = Object.keys(commands);
10
+
11
+ const sections: Array<string> = [styleText("bold", "USAGE"), ` ${toolName} <command> [options]`];
12
+
13
+ if (names.length > 0) {
14
+ const longest = Math.max(...names.map((n) => n.length));
15
+ const commandList = names
16
+ .map((name) => ` ${name.padEnd(longest)} ${commands[name]!.description}`)
17
+ .join("\n");
18
+ sections.push("", styleText("bold", "COMMANDS"), commandList);
19
+ }
20
+
21
+ return sections.join("\n");
22
+ }
23
+
24
+ export function formatCommandHelp(
25
+ toolName: string,
26
+ commandName: string,
27
+ command: AnyCommandDefinition,
28
+ ) {
29
+ const sections: Array<string> = [
30
+ styleText("bold", "USAGE"),
31
+ formatUsageLine(toolName, commandName, command),
32
+ ];
33
+
34
+ const argList = formatArgList(command.args ?? []);
35
+ if (argList !== null) {
36
+ sections.push("", styleText("bold", "ARGUMENTS"), argList);
37
+ }
38
+
39
+ const optionList = formatOptionList(command.options ?? {});
40
+ if (optionList !== null) {
41
+ sections.push("", styleText("bold", "OPTIONS"), optionList);
42
+ }
43
+
44
+ return sections.join("\n");
45
+ }
46
+
47
+ function formatUsageLine(toolName: string, commandName: string, command: AnyCommandDefinition) {
48
+ const argPlaceholders = (command.args ?? []).map((a) => `<${a.placeholder}>`).join(" ");
49
+ const parts = [toolName, commandName];
50
+ if (argPlaceholders !== "") parts.push(argPlaceholders);
51
+ parts.push("[options]");
52
+ return ` ${parts.join(" ")}`;
53
+ }
54
+
55
+ function formatArgList(args: readonly ArgumentDefinition[]) {
56
+ if (args.length === 0) return null;
57
+
58
+ const lines = args.map((arg) => ({
59
+ left: ` ${arg.placeholder}`,
60
+ description: formatArgDescription(arg.description, arg.schema),
61
+ }));
62
+
63
+ const longest = Math.max(...lines.map((l) => l.left.length));
64
+ return lines.map((l) => `${l.left.padEnd(longest)} ${l.description}`).join("\n");
65
+ }
66
+
67
+ function formatOptionList(options: Record<string, OptionDefinition>) {
68
+ const entries = Object.values(options);
69
+ if (entries.length === 0) return null;
70
+
71
+ const aliasPrefixes = entries.map((opt) => {
72
+ const aliases = opt.aliases ?? [];
73
+ return aliases.length > 0 ? aliases.map((a) => `-${a}`).join(", ") + ", " : "";
74
+ });
75
+ const longestAlias = Math.max(...aliasPrefixes.map((p) => p.length));
76
+
77
+ const lines = entries.map((opt, i) => {
78
+ const aliasPrefix = aliasPrefixes[i]!.padStart(longestAlias);
79
+ const placeholder = opt.placeholder ?? opt.name;
80
+ const flag = isBoolean(opt.schema) ? `--${opt.name}` : `--${opt.name} <${placeholder}>`;
81
+ return {
82
+ left: ` ${aliasPrefix}${flag}`,
83
+ description: formatDescription(opt.description, opt.schema),
84
+ };
85
+ });
86
+
87
+ const longest = Math.max(...lines.map((l) => l.left.length));
88
+ return lines.map((l) => `${l.left.padEnd(longest)} ${l.description}`).join("\n");
89
+ }
90
+
91
+ function formatArgDescription(description: string, schema: z.ZodType): string {
92
+ const defaultValue = getDefault(schema);
93
+ if (defaultValue !== undefined) return `${description} (default: ${String(defaultValue)})`;
94
+ return isRequired(schema) ? description : `${description} (optional)`;
95
+ }
96
+
97
+ function formatDescription(description: string, schema: z.ZodType): string {
98
+ if (isRequired(schema)) return `${description} (required)`;
99
+ const defaultValue = getDefault(schema);
100
+ return defaultValue === undefined
101
+ ? description
102
+ : `${description} (default: ${String(defaultValue)})`;
103
+ }
104
+
105
+ export function formatErrors(errors: Array<string>) {
106
+ const errorLines = errors.map((e) => ` ${e}`).join("\n");
107
+ return `${styleText("bold", "ERRORS")}\n${errorLines}`;
108
+ }
@@ -0,0 +1,11 @@
1
+ import { JsonStorage } from "./json-storage.ts";
2
+ import { OnePasswordStorage } from "./one-password-storage.ts";
3
+ import type { Storage } from "./storage.ts";
4
+
5
+ export function getStorage(): Storage {
6
+ const storagePath = process.env.LECOFFRE_STORAGE_PATH;
7
+ if (storagePath !== undefined) {
8
+ return new JsonStorage(storagePath);
9
+ }
10
+ return new OnePasswordStorage();
11
+ }
@@ -0,0 +1,74 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { ProjectNotFoundError, Storage } from "./storage.ts";
3
+
4
+ type StoreData = Record<string, Record<string, Record<string, string>>>;
5
+
6
+ export class JsonStorage extends Storage {
7
+ private readonly filePath: string;
8
+
9
+ constructor(filePath: string) {
10
+ super();
11
+ this.filePath = filePath;
12
+ }
13
+
14
+ private async read(): Promise<StoreData> {
15
+ try {
16
+ const content = await readFile(this.filePath, "utf-8");
17
+ return JSON.parse(content) as StoreData;
18
+ } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
20
+ return {};
21
+ }
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ private async write(data: StoreData): Promise<void> {
27
+ await writeFile(this.filePath, JSON.stringify(data, null, 2) + "\n");
28
+ }
29
+
30
+ async getProjects(): Promise<Array<string>> {
31
+ const data = await this.read();
32
+ return Object.keys(data);
33
+ }
34
+
35
+ async getProject(project: string): Promise<Record<string, Record<string, string>>> {
36
+ const data = await this.read();
37
+ const projectData = data[project];
38
+ if (projectData === undefined) {
39
+ throw new ProjectNotFoundError(project);
40
+ }
41
+ return Object.fromEntries(Object.entries(projectData).map(([env, vars]) => [env, { ...vars }]));
42
+ }
43
+
44
+ async init(): Promise<void> {
45
+ // No initialization needed for JSON storage
46
+ }
47
+
48
+ async setVariables(project: string, env: string, vars: Record<string, string>): Promise<void> {
49
+ const data = await this.read();
50
+ if (data[project] === undefined) {
51
+ data[project] = {};
52
+ }
53
+ data[project][env] = vars;
54
+ await this.write(data);
55
+ }
56
+
57
+ async deleteEnvironment(project: string, env: string): Promise<void> {
58
+ const data = await this.read();
59
+ const projectData = data[project];
60
+ if (projectData !== undefined) {
61
+ delete projectData[env];
62
+ if (Object.keys(projectData).length === 0) {
63
+ delete data[project];
64
+ }
65
+ await this.write(data);
66
+ }
67
+ }
68
+
69
+ async deleteProject(project: string): Promise<void> {
70
+ const data = await this.read();
71
+ delete data[project];
72
+ await this.write(data);
73
+ }
74
+ }
@@ -0,0 +1,233 @@
1
+ import { execFile as execFileCb } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { ProjectNotFoundError, Storage, StorageNotInitializedError } from "./storage.ts";
4
+
5
+ interface OpItemSummary {
6
+ id: string;
7
+ title: string;
8
+ category: string;
9
+ }
10
+
11
+ interface OpField {
12
+ id: string;
13
+ label: string;
14
+ value: string;
15
+ type: string;
16
+ section?: { id: string; label: string };
17
+ purpose?: string;
18
+ }
19
+
20
+ interface OpItemDetail {
21
+ id: string;
22
+ title: string;
23
+ fields: Array<OpField>;
24
+ }
25
+
26
+ interface ExecError {
27
+ stderr?: string;
28
+ }
29
+
30
+ function hasStderr(error: unknown): error is ExecError {
31
+ return typeof error === "object" && error !== null && "stderr" in error;
32
+ }
33
+
34
+ function getStderr(error: unknown): string {
35
+ return hasStderr(error) ? (error.stderr?.trim() ?? "") : "";
36
+ }
37
+
38
+ function isItemNotFound(error: unknown): boolean {
39
+ return /isn't an item/i.test(getStderr(error));
40
+ }
41
+
42
+ function isVaultNotFound(error: unknown): boolean {
43
+ return /isn't a vault/i.test(getStderr(error));
44
+ }
45
+
46
+ const execFile = promisify(execFileCb);
47
+
48
+ async function execOp(...args: Array<string>): Promise<string> {
49
+ try {
50
+ const { stdout } = await execFile("op", args);
51
+ return stdout;
52
+ } catch (error) {
53
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
54
+ throw new Error(
55
+ "1Password CLI (op) is not installed. See https://developer.1password.com/docs/cli/get-started/",
56
+ );
57
+ }
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ const VAULT = "lecoffre";
63
+
64
+ export class OnePasswordStorage extends Storage {
65
+ private readonly vault: string;
66
+
67
+ constructor(vault: string = VAULT) {
68
+ super();
69
+ this.vault = vault;
70
+ }
71
+
72
+ private rethrow(error: unknown): never {
73
+ if (isVaultNotFound(error)) {
74
+ throw new StorageNotInitializedError(
75
+ `Vault "${this.vault}" not found. Run "lecoffre init" to create it.`,
76
+ );
77
+ }
78
+ const stderr = getStderr(error);
79
+ if (stderr !== "") {
80
+ throw new Error(stderr);
81
+ }
82
+ throw error;
83
+ }
84
+
85
+ async init(): Promise<void> {
86
+ try {
87
+ await execOp("vault", "get", this.vault, "--format", "json");
88
+ } catch (error) {
89
+ if (isVaultNotFound(error)) {
90
+ await execOp("vault", "create", this.vault);
91
+ return;
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ private async getItem(project: string): Promise<OpItemDetail | null> {
98
+ try {
99
+ const stdout = await execOp(
100
+ "item",
101
+ "get",
102
+ project,
103
+ "--vault",
104
+ this.vault,
105
+ "--format",
106
+ "json",
107
+ );
108
+ return JSON.parse(stdout) as OpItemDetail;
109
+ } catch (error) {
110
+ if (isItemNotFound(error)) return null;
111
+ return this.rethrow(error);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Return only user-defined fields. 1Password items include system fields
117
+ * (e.g. "notesPlain") that have a `purpose` property set. User-created
118
+ * fields never have `purpose`, so we use that to distinguish them.
119
+ */
120
+ private getUserFields(fields: Array<OpField>): Array<OpField> {
121
+ return fields.filter(
122
+ (field) => field.purpose === undefined && field.section?.label !== undefined,
123
+ );
124
+ }
125
+
126
+ async getProjects(): Promise<Array<string>> {
127
+ try {
128
+ const stdout = await execOp("item", "list", "--vault", this.vault, "--format", "json");
129
+ const items = JSON.parse(stdout) as Array<OpItemSummary>;
130
+ return items.map((item) => item.title);
131
+ } catch (error) {
132
+ return this.rethrow(error);
133
+ }
134
+ }
135
+
136
+ async getProject(project: string): Promise<Record<string, Record<string, string>>> {
137
+ const item = await this.getItem(project);
138
+ if (item === null) {
139
+ throw new ProjectNotFoundError(project);
140
+ }
141
+
142
+ const envs: Record<string, Record<string, string>> = {};
143
+ for (const field of this.getUserFields(item.fields)) {
144
+ const sectionLabel = field.section?.label;
145
+ if (sectionLabel !== undefined) {
146
+ envs[sectionLabel] ??= {};
147
+ envs[sectionLabel][field.label] = field.value;
148
+ }
149
+ }
150
+ return envs;
151
+ }
152
+
153
+ // Note: field values are passed as process arguments and are briefly visible
154
+ // in /proc/<pid>/cmdline. The 1Password CLI does not support reading field
155
+ // values from stdin when spawned as a child process (only shell pipes work).
156
+ async setVariables(project: string, env: string, vars: Record<string, string>): Promise<void> {
157
+ const item = await this.getItem(project);
158
+
159
+ if (item === null) {
160
+ const fieldAssignments = Object.entries(vars).map(
161
+ ([key, value]) => `${env}.${key}[concealed]=${value}`,
162
+ );
163
+ await execOp(
164
+ "item",
165
+ "create",
166
+ "--vault",
167
+ this.vault,
168
+ "--category",
169
+ "Secure Note",
170
+ "--title",
171
+ project,
172
+ ...fieldAssignments,
173
+ );
174
+ return;
175
+ }
176
+
177
+ const operations: Array<string> = [];
178
+
179
+ for (const field of this.getUserFields(item.fields)) {
180
+ if (field.section?.label === env) {
181
+ operations.push(`${env}.${field.label}[delete]`);
182
+ }
183
+ }
184
+
185
+ for (const [key, value] of Object.entries(vars)) {
186
+ operations.push(`${env}.${key}[concealed]=${value}`);
187
+ }
188
+
189
+ if (operations.length > 0) {
190
+ await execOp("item", "edit", project, "--vault", this.vault, ...operations);
191
+ }
192
+ }
193
+
194
+ async deleteEnvironment(project: string, env: string): Promise<void> {
195
+ const item = await this.getItem(project);
196
+ if (item === null) {
197
+ return;
198
+ }
199
+
200
+ const userFields = this.getUserFields(item.fields);
201
+ const sections = new Set<string>();
202
+ const fieldsToDelete: Array<string> = [];
203
+
204
+ for (const field of userFields) {
205
+ if (field.section?.label !== undefined) {
206
+ sections.add(field.section.label);
207
+ }
208
+ if (field.section?.label === env) {
209
+ fieldsToDelete.push(`${env}.${field.label}[delete]`);
210
+ }
211
+ }
212
+
213
+ if (fieldsToDelete.length === 0) {
214
+ return;
215
+ }
216
+
217
+ if (sections.size <= 1 && sections.has(env)) {
218
+ await execOp("item", "delete", project, "--vault", this.vault);
219
+ return;
220
+ }
221
+
222
+ await execOp("item", "edit", project, "--vault", this.vault, ...fieldsToDelete);
223
+ }
224
+
225
+ async deleteProject(project: string): Promise<void> {
226
+ try {
227
+ await execOp("item", "delete", project, "--vault", this.vault);
228
+ } catch (error) {
229
+ if (isItemNotFound(error)) return;
230
+ this.rethrow(error);
231
+ }
232
+ }
233
+ }
@@ -0,0 +1,108 @@
1
+ import { parse } from "@bomb.sh/args";
2
+ import { ZodError } from "zod";
3
+ import type { AnyCommandDefinition } from "./define-command.ts";
4
+ import type { OptionDefinition } from "./define-option.ts";
5
+ import { isBoolean } from "./zod-utils.ts";
6
+
7
+ export class CommandValidationError extends Error {
8
+ errors: Array<string>;
9
+
10
+ constructor(errors: Array<string>) {
11
+ super(errors.join("\n"));
12
+ this.errors = errors;
13
+ }
14
+ }
15
+
16
+ export class CommandHelpRequested extends Error {
17
+ constructor() {
18
+ super("Help requested");
19
+ }
20
+ }
21
+
22
+ export function parseCommand(
23
+ argv: Array<string>,
24
+ command: AnyCommandDefinition,
25
+ ): { options: Record<string, unknown>; args: Array<unknown> } {
26
+ const commandOptions = command.options ?? {};
27
+ const commandArgs = command.args ?? [];
28
+
29
+ const parseOpts = buildParseOptions(commandOptions);
30
+ const raw = parse(argv, parseOpts);
31
+
32
+ if (raw["help"]) {
33
+ throw new CommandHelpRequested();
34
+ }
35
+
36
+ const errors: Array<string> = [];
37
+
38
+ const options: Record<string, unknown> = {};
39
+ for (const [key, opt] of Object.entries(commandOptions)) {
40
+ const rawValue = raw[opt.name] as unknown;
41
+ try {
42
+ if (rawValue !== undefined) {
43
+ options[key] = opt.schema.parse(rawValue);
44
+ } else {
45
+ options[key] = opt.schema.parse(undefined);
46
+ }
47
+ } catch (error) {
48
+ if (error instanceof ZodError) {
49
+ for (const issue of error.issues) {
50
+ errors.push(`option "--${opt.name}": ${issue.message}`);
51
+ }
52
+ } else {
53
+ throw error;
54
+ }
55
+ }
56
+ }
57
+
58
+ const args: Array<unknown> = [];
59
+ for (let i = 0; i < commandArgs.length; i++) {
60
+ const argDef = commandArgs[i]!;
61
+ const rawValue = raw._[i];
62
+ try {
63
+ if (rawValue !== undefined) {
64
+ args.push(argDef.schema.parse(String(rawValue)));
65
+ } else {
66
+ args.push(argDef.schema.parse(undefined));
67
+ }
68
+ } catch (error) {
69
+ if (error instanceof ZodError) {
70
+ for (const issue of error.issues) {
71
+ errors.push(`argument <${argDef.placeholder}>: ${issue.message}`);
72
+ }
73
+ } else {
74
+ throw error;
75
+ }
76
+ }
77
+ }
78
+
79
+ if (errors.length > 0) {
80
+ throw new CommandValidationError(errors);
81
+ }
82
+
83
+ return { options, args };
84
+ }
85
+
86
+ function buildParseOptions(options: Record<string, OptionDefinition>) {
87
+ const boolean: Array<string> = [];
88
+ const string: Array<string> = [];
89
+ const alias: Record<string, string> = {};
90
+
91
+ for (const opt of Object.values(options)) {
92
+ if (isBoolean(opt.schema)) {
93
+ boolean.push(opt.name);
94
+ } else {
95
+ string.push(opt.name);
96
+ }
97
+ if (opt.aliases !== undefined) {
98
+ for (const a of opt.aliases) {
99
+ alias[a] = opt.name;
100
+ }
101
+ }
102
+ }
103
+
104
+ boolean.push("help");
105
+ alias["h"] = "help";
106
+
107
+ return { boolean, string, alias };
108
+ }
@@ -0,0 +1,52 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { basename } from "node:path";
3
+
4
+ const SUPPORTED_SHELLS = ["bash", "zsh", "fish"] as const;
5
+
6
+ export type ShellName = (typeof SUPPORTED_SHELLS)[number];
7
+
8
+ function isSupportedShell(name: string): name is ShellName {
9
+ return (SUPPORTED_SHELLS as ReadonlyArray<string>).includes(name);
10
+ }
11
+
12
+ export function detectShell(): ShellName {
13
+ const name = basename(
14
+ execFileSync("ps", ["-p", String(process.ppid), "-o", "comm="], { encoding: "utf-8" }).trim(),
15
+ );
16
+ if (isSupportedShell(name)) {
17
+ return name;
18
+ }
19
+ throw new Error(`Unsupported shell: ${name}`);
20
+ }
21
+
22
+ export function formatVariables(shell: ShellName, vars: Record<string, string>): string {
23
+ const lines = Object.entries(vars).map(([key, value]) => formatSingleVariable(shell, key, value));
24
+ return lines.join("\n");
25
+ }
26
+
27
+ function formatSingleVariable(shell: ShellName, key: string, value: string): string {
28
+ if (shell === "fish") {
29
+ return `set -gx ${key} '${escapeSingleQuotes(value, "fish")}'`;
30
+ }
31
+ return `export ${key}='${escapeSingleQuotes(value, "posix")}'`;
32
+ }
33
+
34
+ export function formatUnsetVariables(shell: ShellName, keys: Array<string>): string {
35
+ const lines = keys.map((key) => formatSingleUnsetVariable(shell, key));
36
+ return lines.join("\n");
37
+ }
38
+
39
+ function formatSingleUnsetVariable(shell: ShellName, key: string): string {
40
+ if (shell === "fish") {
41
+ return `set -e ${key}`;
42
+ }
43
+ return `unset ${key}`;
44
+ }
45
+
46
+ function escapeSingleQuotes(value: string, mode: "posix" | "fish"): string {
47
+ if (mode === "fish") {
48
+ return value.replaceAll("\\", "\\\\").replaceAll("'", "\\'");
49
+ }
50
+ // In POSIX shells: end quote, escaped quote, restart quote
51
+ return value.replaceAll("'", "'\\''");
52
+ }
@@ -0,0 +1,37 @@
1
+ export class ProjectNotFoundError extends Error {
2
+ readonly project: string;
3
+
4
+ constructor(project: string) {
5
+ super(`Project not found: ${project}`);
6
+ this.name = "ProjectNotFoundError";
7
+ this.project = project;
8
+ }
9
+ }
10
+
11
+ export class EnvironmentNotFoundError extends Error {
12
+ readonly project: string;
13
+ readonly environment: string;
14
+
15
+ constructor(project: string, environment: string) {
16
+ super(`Environment not found: ${environment}`);
17
+ this.name = "EnvironmentNotFoundError";
18
+ this.project = project;
19
+ this.environment = environment;
20
+ }
21
+ }
22
+
23
+ export class StorageNotInitializedError extends Error {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = "StorageNotInitializedError";
27
+ }
28
+ }
29
+
30
+ export abstract class Storage {
31
+ abstract init(): Promise<void>;
32
+ abstract getProjects(): Promise<Array<string>>;
33
+ abstract getProject(project: string): Promise<Record<string, Record<string, string>>>;
34
+ abstract setVariables(project: string, env: string, vars: Record<string, string>): Promise<void>;
35
+ abstract deleteEnvironment(project: string, env: string): Promise<void>;
36
+ abstract deleteProject(project: string): Promise<void>;
37
+ }
@@ -0,0 +1,50 @@
1
+ import type { z } from "zod";
2
+
3
+ interface ZodDef {
4
+ type?: string;
5
+ innerType?: z.ZodType;
6
+ defaultValue?: unknown;
7
+ in?: z.ZodType;
8
+ }
9
+
10
+ function getDef(schema: z.ZodType): ZodDef {
11
+ return (schema as unknown as { _zod: { def: ZodDef } })._zod.def;
12
+ }
13
+
14
+ export function isBoolean(schema: z.ZodType): boolean {
15
+ const def = getDef(schema);
16
+ if (def.type === "boolean") return true;
17
+ if (def.type === "default" || def.type === "optional" || def.type === "nullable") {
18
+ if (def.innerType !== undefined) return isBoolean(def.innerType);
19
+ }
20
+ return false;
21
+ }
22
+
23
+ export function isRequired(schema: z.ZodType): boolean {
24
+ const def = getDef(schema);
25
+ if (def.type === "default" || def.type === "optional" || def.type === "nullable") {
26
+ return false;
27
+ }
28
+ if (def.type === "pipe" && def.in !== undefined) {
29
+ return isRequired(def.in);
30
+ }
31
+ return true;
32
+ }
33
+
34
+ export function getDefault(schema: z.ZodType): unknown {
35
+ let current: z.ZodType | undefined = schema;
36
+ while (current !== undefined) {
37
+ const def = getDef(current);
38
+ if (def.type === "default") return def.defaultValue;
39
+ if (def.type === "optional" || def.type === "nullable") {
40
+ current = def.innerType;
41
+ continue;
42
+ }
43
+ if (def.type === "pipe") {
44
+ current = def.in;
45
+ continue;
46
+ }
47
+ break;
48
+ }
49
+ return undefined;
50
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ import { defineOption } from "../lib/define-option.ts";
3
+
4
+ export const environmentOption = defineOption({
5
+ name: "environment",
6
+ schema: z.string().default("default"),
7
+ description: "Environment name",
8
+ aliases: ["e"],
9
+ placeholder: "env",
10
+ });
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+ import { defineOption } from "../lib/define-option.ts";
3
+
4
+ export const projectOption = defineOption({
5
+ name: "project",
6
+ schema: z
7
+ .string()
8
+ .refine((val) => !val.startsWith("-"), { message: 'must not start with "-"' })
9
+ .optional(),
10
+ description: "Project name",
11
+ aliases: ["p"],
12
+ placeholder: "name",
13
+ });