lecoffre 0.0.1 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hubert SABLONNIÈRE
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # lecoffre
2
+
3
+ > [!WARNING]
4
+ > This project is a work in progress. Expect breaking changes.
5
+
6
+ Per-project environment variable manager for the shell.
7
+
8
+ Store, load and unload environment variables by project and environment, directly in your current shell session.
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
+
15
+ ## Installation
16
+
17
+ ```sh
18
+ npm install -g lecoffre
19
+ ```
20
+
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
+ ```
44
+
45
+ ## Quick start
46
+
47
+ ```sh
48
+ # Initialize the storage backend
49
+ lecoffre init
50
+
51
+ # Import variables from a .env file
52
+ lecoffre import < .env
53
+
54
+ # Load them into your shell
55
+ eval "$(lecoffre load)"
56
+
57
+ # When you're done, unload them
58
+ eval "$(lecoffre unload)"
59
+ ```
60
+
61
+ ## Commands
62
+
63
+ ### `lecoffre init`
64
+
65
+ Initialize the storage backend. For 1Password, this creates the `lecoffre` vault if it doesn't exist.
66
+
67
+ ### `lecoffre list [project]`
68
+
69
+ List all projects and their environments. When a project name is given, list only the environments for that project.
70
+
71
+ ```sh
72
+ # List all projects
73
+ lecoffre list
74
+
75
+ # List environments for a specific project
76
+ lecoffre list my-app
77
+ ```
78
+
79
+ ### `lecoffre import`
80
+
81
+ Import variables from stdin in `.env` format. By default, imported variables replace all existing variables for the target environment. Use `--merge` to add or overwrite without removing existing variables.
82
+
83
+ ```sh
84
+ # Import and replace
85
+ lecoffre import < .env
86
+
87
+ # Import and merge with existing variables
88
+ lecoffre import --merge < .env
89
+
90
+ # Pipe from another command
91
+ cat .env.production | lecoffre import -e production
92
+ ```
93
+
94
+ ### `lecoffre load`
95
+
96
+ Output shell commands that export the stored variables. Wrap with `eval` to apply them to the current shell.
97
+
98
+ ```sh
99
+ eval "$(lecoffre load)"
100
+
101
+ # Load a specific environment
102
+ eval "$(lecoffre load -e production)"
103
+ ```
104
+
105
+ ### `lecoffre unload`
106
+
107
+ Output shell commands that unset the stored variables. Wrap with `eval` to remove them from the current shell.
108
+
109
+ ```sh
110
+ eval "$(lecoffre unload)"
111
+ ```
112
+
113
+ ## Common options
114
+
115
+ | Option | Alias | Description | Default |
116
+ | --------------------- | ----- | ---------------- | --------------------------------- |
117
+ | `--project <name>` | `-p` | Project name | basename of the current directory |
118
+ | `--environment <env>` | `-e` | Environment name | `default` |
119
+
120
+ These options are available on `import`, `load` and `unload`.
121
+
122
+ ## Shell support
123
+
124
+ Supported shells: **bash**, **zsh** and **fish**. The shell is detected automatically from the parent process.
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parse } from "@bomb.sh/args";
4
+ import packageJson from "../package.json" with { type: "json" };
5
+ import { importCommand } from "../src/commands/import.command.ts";
6
+ import { initCommand } from "../src/commands/init.command.ts";
7
+ import { listCommand } from "../src/commands/list.command.ts";
8
+ import { loadCommand } from "../src/commands/load.command.ts";
9
+ import { unloadCommand } from "../src/commands/unload.command.ts";
10
+ import type { AnyCommandDefinition } from "../src/lib/define-command.ts";
11
+ import { formatCommandHelp, formatErrors, formatGlobalHelp } from "../src/lib/format.ts";
12
+ import {
13
+ CommandHelpRequested,
14
+ CommandValidationError,
15
+ parseCommand,
16
+ } from "../src/lib/parse-command.ts";
17
+
18
+ const { name } = packageJson;
19
+ const commands: Record<string, AnyCommandDefinition> = {
20
+ init: initCommand,
21
+ list: listCommand,
22
+ load: loadCommand,
23
+ unload: unloadCommand,
24
+ import: importCommand,
25
+ };
26
+
27
+ const initial = parse(process.argv.slice(2));
28
+ const [commandNameRaw] = initial._;
29
+
30
+ if (commandNameRaw === undefined) {
31
+ console.log(formatGlobalHelp(name, commands));
32
+ process.exit(0);
33
+ }
34
+
35
+ const commandName = String(commandNameRaw);
36
+ const command = commands[commandName];
37
+
38
+ if (command === undefined) {
39
+ console.error(`Unknown command "${commandName}" for "${name}"\n`);
40
+ console.error(formatGlobalHelp(name, commands));
41
+ process.exit(1);
42
+ }
43
+
44
+ try {
45
+ const { options, args } = parseCommand(process.argv.slice(3), command);
46
+ await command.handler(options, ...args);
47
+ } catch (error) {
48
+ if (error instanceof CommandHelpRequested) {
49
+ console.log(formatCommandHelp(name, commandName, command));
50
+ process.exit(0);
51
+ }
52
+ if (error instanceof CommandValidationError) {
53
+ console.error(formatErrors(error.errors) + "\n");
54
+ console.error(formatCommandHelp(name, commandName, command));
55
+ process.exit(1);
56
+ }
57
+ if (error instanceof Error) {
58
+ console.error(error.message);
59
+ process.exit(1);
60
+ }
61
+ console.error("An unexpected error occurred");
62
+ process.exit(1);
63
+ }
package/package.json CHANGED
@@ -1,4 +1,49 @@
1
1
  {
2
2
  "name": "lecoffre",
3
- "version": "0.0.1"
4
- }
3
+ "version": "0.2.0",
4
+ "description": "Work in progress CLI project.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/hsablonniere/lecoffre"
8
+ },
9
+ "bin": {
10
+ "lecoffre": "./bin/lecoffre.ts"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "type": "module",
19
+ "dependencies": {
20
+ "@bomb.sh/args": "^0.3.1",
21
+ "zod": "^4.3.6"
22
+ },
23
+ "devDependencies": {
24
+ "@changesets/changelog-github": "^0.5.2",
25
+ "@changesets/cli": "^2.29.8",
26
+ "@types/node": "^25.2.3",
27
+ "@typescript/native-preview": "^7.0.0-dev.20260217.1",
28
+ "execa": "^9.6.1",
29
+ "oxfmt": "^0.33.0",
30
+ "oxlint": "^1.48.0",
31
+ "vitest": "^4.0.18"
32
+ },
33
+ "engines": {
34
+ "node": ">=24"
35
+ },
36
+ "scripts": {
37
+ "dev": "node bin/lecoffre.ts",
38
+ "test": "vitest run",
39
+ "lint": "oxlint . --vitest-plugin --deny-warnings",
40
+ "lint:fix": "oxlint . --vitest-plugin --deny-warnings --fix",
41
+ "format": "oxfmt . --write",
42
+ "format:check": "oxfmt . --check",
43
+ "typecheck": "tsgo --noEmit",
44
+ "check": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test",
45
+ "changeset": "changeset",
46
+ "version-packages": "changeset version",
47
+ "release": "changeset publish"
48
+ }
49
+ }
@@ -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
+ });