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 +21 -0
- package/README.md +128 -0
- package/bin/lecoffre.ts +63 -0
- package/package.json +47 -2
- package/src/commands/import.command.ts +101 -0
- package/src/commands/init.command.ts +11 -0
- package/src/commands/list.command.ts +47 -0
- package/src/commands/load.command.ts +32 -0
- package/src/commands/unload.command.ts +32 -0
- package/src/lib/define-argument.ts +13 -0
- package/src/lib/define-command.ts +43 -0
- package/src/lib/define-option.ts +15 -0
- package/src/lib/format.ts +108 -0
- package/src/lib/get-storage.ts +11 -0
- package/src/lib/json-storage.ts +74 -0
- package/src/lib/one-password-storage.ts +233 -0
- package/src/lib/parse-command.ts +108 -0
- package/src/lib/shell.ts +52 -0
- package/src/lib/storage.ts +37 -0
- package/src/lib/zod-utils.ts +50 -0
- package/src/options/environment.option.ts +10 -0
- package/src/options/project.option.ts +13 -0
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
|
package/bin/lecoffre.ts
ADDED
|
@@ -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
|
|
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
|
+
}
|
package/src/lib/shell.ts
ADDED
|
@@ -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
|
+
});
|