@tolgamorf/env2op-cli 0.1.1 → 0.1.3
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 +60 -20
- package/package.json +5 -2
- package/src/cli.ts +20 -7
- package/src/commands/convert.ts +24 -19
- package/src/commands/inject.ts +130 -0
- package/src/core/env-parser.ts +40 -1
- package/src/core/onepassword.ts +12 -0
- package/src/core/template-generator.ts +78 -19
- package/src/core/types.ts +29 -7
- package/src/op2env-cli.ts +94 -0
- package/src/utils/errors.ts +2 -0
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# env2op
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Push `.env` files to 1Password and pull them back with two simple commands.
|
|
4
|
+
|
|
5
|
+

|
|
4
6
|
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
@@ -20,7 +22,18 @@ bunx @tolgamorf/env2op-cli .env Personal "MyApp"
|
|
|
20
22
|
- [1Password CLI](https://1password.com/downloads/command-line/) installed and signed in
|
|
21
23
|
- [Bun](https://bun.sh) runtime (for best performance)
|
|
22
24
|
|
|
23
|
-
##
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
This package provides two commands:
|
|
28
|
+
|
|
29
|
+
| Command | Description |
|
|
30
|
+
|---------|-------------|
|
|
31
|
+
| `env2op` | Push `.env` to 1Password, generate `.env.tpl` template |
|
|
32
|
+
| `op2env` | Pull secrets from 1Password using `.env.tpl` template |
|
|
33
|
+
|
|
34
|
+
## env2op (Push)
|
|
35
|
+
|
|
36
|
+
Push environment variables to 1Password and generate a template file.
|
|
24
37
|
|
|
25
38
|
```bash
|
|
26
39
|
env2op <env_file> <vault> <item_name> [options]
|
|
@@ -32,6 +45,9 @@ env2op <env_file> <vault> <item_name> [options]
|
|
|
32
45
|
# Basic usage - creates a Secure Note and generates .env.tpl
|
|
33
46
|
env2op .env.production Personal "MyApp - Production"
|
|
34
47
|
|
|
48
|
+
# Custom output path for template
|
|
49
|
+
env2op .env Personal "MyApp" -o secrets.tpl
|
|
50
|
+
|
|
35
51
|
# Preview what would happen without making changes
|
|
36
52
|
env2op .env.production Personal "MyApp" --dry-run
|
|
37
53
|
|
|
@@ -39,38 +55,62 @@ env2op .env.production Personal "MyApp" --dry-run
|
|
|
39
55
|
env2op .env.production Personal "MyApp" --secret
|
|
40
56
|
|
|
41
57
|
# Skip confirmation prompts (useful for scripts/CI)
|
|
42
|
-
env2op .env.production Personal "MyApp" -
|
|
58
|
+
env2op .env.production Personal "MyApp" -f
|
|
43
59
|
```
|
|
44
60
|
|
|
45
61
|
### Options
|
|
46
62
|
|
|
47
63
|
| Flag | Description |
|
|
48
64
|
|------|-------------|
|
|
65
|
+
| `-o, --output` | Output template path (default: `<env_file>.tpl`) |
|
|
66
|
+
| `-f, --force` | Skip confirmation prompts |
|
|
49
67
|
| `--dry-run` | Preview actions without executing |
|
|
50
68
|
| `--secret` | Store all fields as 'password' type (default: 'text') |
|
|
51
|
-
| `-y, --yes` | Skip confirmation prompts (auto-accept) |
|
|
52
69
|
| `-h, --help` | Show help |
|
|
53
70
|
| `-v, --version` | Show version |
|
|
54
71
|
|
|
55
|
-
|
|
72
|
+
## op2env (Pull)
|
|
56
73
|
|
|
57
|
-
|
|
74
|
+
Pull secrets from 1Password to generate a `.env` file.
|
|
58
75
|
|
|
59
|
-
|
|
76
|
+
```bash
|
|
77
|
+
op2env <template_file> [options]
|
|
78
|
+
```
|
|
60
79
|
|
|
61
|
-
|
|
62
|
-
2. **Creates** a 1Password Secure Note with all variables as fields
|
|
63
|
-
3. **Generates** a `.tpl` template file with `op://` references
|
|
80
|
+
### Examples
|
|
64
81
|
|
|
65
|
-
|
|
82
|
+
```bash
|
|
83
|
+
# Basic usage - generates .env from .env.tpl
|
|
84
|
+
op2env .env.tpl
|
|
66
85
|
|
|
67
|
-
|
|
86
|
+
# Custom output path
|
|
87
|
+
op2env .env.tpl -o .env.local
|
|
68
88
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
89
|
+
# Preview without making changes
|
|
90
|
+
op2env .env.tpl --dry-run
|
|
91
|
+
|
|
92
|
+
# Overwrite existing .env without prompting
|
|
93
|
+
op2env .env.tpl -f
|
|
94
|
+
```
|
|
72
95
|
|
|
73
|
-
|
|
96
|
+
### Options
|
|
97
|
+
|
|
98
|
+
| Flag | Description |
|
|
99
|
+
|------|-------------|
|
|
100
|
+
| `-o, --output` | Output .env path (default: template without `.tpl`) |
|
|
101
|
+
| `-f, --force` | Overwrite without prompting |
|
|
102
|
+
| `--dry-run` | Preview actions without executing |
|
|
103
|
+
| `-h, --help` | Show help |
|
|
104
|
+
| `-v, --version` | Show version |
|
|
105
|
+
|
|
106
|
+
## How It Works
|
|
107
|
+
|
|
108
|
+
1. **env2op** parses your `.env` file, creates a 1Password Secure Note, and generates a `.tpl` template
|
|
109
|
+
2. **op2env** reads the template and pulls current values from 1Password to create a `.env` file
|
|
110
|
+
|
|
111
|
+
You can also use the `op run` command to run processes with secrets injected:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
74
114
|
op run --env-file .env.tpl -- npm start
|
|
75
115
|
```
|
|
76
116
|
|
|
@@ -99,12 +139,12 @@ Creates a 1Password Secure Note with fields:
|
|
|
99
139
|
- `API_KEY` (text)
|
|
100
140
|
- `DEBUG` (text)
|
|
101
141
|
|
|
102
|
-
And generates `.env.tpl
|
|
142
|
+
And generates `.env.tpl` with UUID-based references (avoids naming conflicts):
|
|
103
143
|
|
|
104
144
|
```env
|
|
105
|
-
DATABASE_URL=op://
|
|
106
|
-
API_KEY=op://
|
|
107
|
-
DEBUG=op://
|
|
145
|
+
DATABASE_URL=op://abc123vaultid/xyz789itemid/def456fieldid
|
|
146
|
+
API_KEY=op://abc123vaultid/xyz789itemid/ghi012fieldid
|
|
147
|
+
DEBUG=op://abc123vaultid/xyz789itemid/jkl345fieldid
|
|
108
148
|
```
|
|
109
149
|
|
|
110
150
|
## Programmatic Usage
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tolgamorf/env2op-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Convert .env files to 1Password Secure Notes and generate templates for op inject/run",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"bin": {
|
|
16
|
-
"env2op": "src/cli.ts"
|
|
16
|
+
"env2op": "src/cli.ts",
|
|
17
|
+
"op2env": "src/op2env-cli.ts"
|
|
17
18
|
},
|
|
18
19
|
"files": [
|
|
19
20
|
"src",
|
|
@@ -23,6 +24,8 @@
|
|
|
23
24
|
"scripts": {
|
|
24
25
|
"dev": "bun run src/cli.ts",
|
|
25
26
|
"test": "bun test",
|
|
27
|
+
"test:watch": "bun test --watch",
|
|
28
|
+
"test:coverage": "bun test --coverage",
|
|
26
29
|
"typecheck": "tsc --noEmit",
|
|
27
30
|
"lint": "bunx biome check .",
|
|
28
31
|
"lint:fix": "bunx biome check . --write",
|
package/src/cli.ts
CHANGED
|
@@ -9,9 +9,17 @@ const args = process.argv.slice(2);
|
|
|
9
9
|
// Parse arguments
|
|
10
10
|
const flags = new Set<string>();
|
|
11
11
|
const positional: string[] = [];
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const options: Record<string, string> = {};
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
const arg = args[i] as string;
|
|
16
|
+
if (arg === "-o" || arg === "--output") {
|
|
17
|
+
const next = args[i + 1];
|
|
18
|
+
if (next && !next.startsWith("-")) {
|
|
19
|
+
options.output = next;
|
|
20
|
+
i++; // skip next arg
|
|
21
|
+
}
|
|
22
|
+
} else if (arg.startsWith("--")) {
|
|
15
23
|
flags.add(arg.slice(2));
|
|
16
24
|
} else if (arg.startsWith("-")) {
|
|
17
25
|
// Handle short flags
|
|
@@ -48,9 +56,10 @@ await runConvert({
|
|
|
48
56
|
envFile,
|
|
49
57
|
vault,
|
|
50
58
|
itemName,
|
|
59
|
+
output: options.output,
|
|
51
60
|
dryRun: flags.has("dry-run"),
|
|
52
61
|
secret: flags.has("secret"),
|
|
53
|
-
|
|
62
|
+
force: flags.has("f") || flags.has("force"),
|
|
54
63
|
});
|
|
55
64
|
|
|
56
65
|
function showHelp(): void {
|
|
@@ -70,9 +79,10 @@ ${pc.bold("ARGUMENTS")}
|
|
|
70
79
|
${pc.yellow("item_name")} Name for the Secure Note in 1Password
|
|
71
80
|
|
|
72
81
|
${pc.bold("OPTIONS")}
|
|
82
|
+
${pc.cyan("-o, --output")} Output template path (default: <env_file>.tpl)
|
|
83
|
+
${pc.cyan("-f, --force")} Skip confirmation prompts
|
|
73
84
|
${pc.cyan("--dry-run")} Preview actions without executing
|
|
74
85
|
${pc.cyan("--secret")} Store all fields as password type (hidden)
|
|
75
|
-
${pc.cyan("-y, --yes")} Skip confirmation prompts (auto-accept)
|
|
76
86
|
${pc.cyan("-h, --help")} Show this help message
|
|
77
87
|
${pc.cyan("-v, --version")} Show version
|
|
78
88
|
|
|
@@ -80,6 +90,9 @@ ${pc.bold("EXAMPLES")}
|
|
|
80
90
|
${pc.dim("# Basic usage")}
|
|
81
91
|
${pc.cyan("$")} env2op .env.production Personal "MyApp - Production"
|
|
82
92
|
|
|
93
|
+
${pc.dim("# Custom output path")}
|
|
94
|
+
${pc.cyan("$")} env2op .env Personal "MyApp" -o secrets.tpl
|
|
95
|
+
|
|
83
96
|
${pc.dim("# Preview without making changes")}
|
|
84
97
|
${pc.cyan("$")} env2op .env Personal "MyApp" --dry-run
|
|
85
98
|
|
|
@@ -87,10 +100,10 @@ ${pc.bold("EXAMPLES")}
|
|
|
87
100
|
${pc.cyan("$")} env2op .env Personal "MyApp" --secret
|
|
88
101
|
|
|
89
102
|
${pc.dim("# Skip confirmation prompts (for CI/scripts)")}
|
|
90
|
-
${pc.cyan("$")} env2op .env Personal "MyApp" -
|
|
103
|
+
${pc.cyan("$")} env2op .env Personal "MyApp" -f
|
|
91
104
|
|
|
92
105
|
${pc.bold("DOCUMENTATION")}
|
|
93
|
-
${pc.dim("https://github.com/tolgamorf/env2op")}
|
|
106
|
+
${pc.dim("https://github.com/tolgamorf/env2op-cli")}
|
|
94
107
|
`);
|
|
95
108
|
}
|
|
96
109
|
|
package/src/commands/convert.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
vaultExists,
|
|
12
12
|
} from "../core/onepassword";
|
|
13
13
|
import { generateTemplateContent, generateUsageInstructions, writeTemplate } from "../core/template-generator";
|
|
14
|
-
import type { ConvertOptions } from "../core/types";
|
|
14
|
+
import type { ConvertOptions, CreateItemResult } from "../core/types";
|
|
15
15
|
import { Env2OpError } from "../utils/errors";
|
|
16
16
|
import { logger } from "../utils/logger";
|
|
17
17
|
|
|
@@ -19,7 +19,7 @@ import { logger } from "../utils/logger";
|
|
|
19
19
|
* Execute the convert operation
|
|
20
20
|
*/
|
|
21
21
|
export async function runConvert(options: ConvertOptions): Promise<void> {
|
|
22
|
-
const { envFile, vault, itemName, dryRun, secret,
|
|
22
|
+
const { envFile, vault, itemName, output, dryRun, secret, force } = options;
|
|
23
23
|
|
|
24
24
|
// Display intro
|
|
25
25
|
const pkg = await import("../../package.json");
|
|
@@ -41,6 +41,8 @@ export async function runConvert(options: ConvertOptions): Promise<void> {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// Step 2: Create 1Password Secure Note
|
|
44
|
+
let itemResult: CreateItemResult | null = null;
|
|
45
|
+
|
|
44
46
|
if (dryRun) {
|
|
45
47
|
logger.warn("Would create Secure Note");
|
|
46
48
|
logger.keyValue("Vault", vault);
|
|
@@ -70,8 +72,10 @@ export async function runConvert(options: ConvertOptions): Promise<void> {
|
|
|
70
72
|
// Check if vault exists
|
|
71
73
|
const vaultFound = await vaultExists(vault);
|
|
72
74
|
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
+
if (vaultFound) {
|
|
76
|
+
logger.success(`Vault "${vault}" found`);
|
|
77
|
+
} else {
|
|
78
|
+
if (force) {
|
|
75
79
|
// Auto-create vault
|
|
76
80
|
logger.warn(`Vault "${vault}" not found, creating...`);
|
|
77
81
|
await createVault(vault);
|
|
@@ -99,7 +103,7 @@ export async function runConvert(options: ConvertOptions): Promise<void> {
|
|
|
99
103
|
const exists = await itemExists(vault, itemName);
|
|
100
104
|
|
|
101
105
|
if (exists) {
|
|
102
|
-
if (
|
|
106
|
+
if (force) {
|
|
103
107
|
// Auto-accept: delete and recreate
|
|
104
108
|
logger.warn(`Item "${itemName}" already exists, overwriting...`);
|
|
105
109
|
await deleteItem(vault, itemName);
|
|
@@ -122,14 +126,14 @@ export async function runConvert(options: ConvertOptions): Promise<void> {
|
|
|
122
126
|
spinner.start("Creating 1Password Secure Note...");
|
|
123
127
|
|
|
124
128
|
try {
|
|
125
|
-
|
|
129
|
+
itemResult = await createSecureNote({
|
|
126
130
|
vault,
|
|
127
131
|
title: itemName,
|
|
128
132
|
fields: variables,
|
|
129
133
|
secret,
|
|
130
134
|
});
|
|
131
135
|
|
|
132
|
-
spinner.stop(`Created "${
|
|
136
|
+
spinner.stop(`Created "${itemResult.title}" in vault "${itemResult.vault}"`);
|
|
133
137
|
} catch (error) {
|
|
134
138
|
spinner.stop("Failed to create Secure Note");
|
|
135
139
|
throw error;
|
|
@@ -137,21 +141,22 @@ export async function runConvert(options: ConvertOptions): Promise<void> {
|
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
// Step 3: Generate template file
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const templateContent = generateTemplateContent(
|
|
143
|
-
{
|
|
144
|
-
vault,
|
|
145
|
-
itemTitle: itemName,
|
|
146
|
-
variables,
|
|
147
|
-
lines,
|
|
148
|
-
},
|
|
149
|
-
templateFileName,
|
|
150
|
-
);
|
|
144
|
+
const templatePath = output ?? join(dirname(envFile), `${basename(envFile)}.tpl`);
|
|
145
|
+
const templateFileName = basename(templatePath);
|
|
151
146
|
|
|
152
147
|
if (dryRun) {
|
|
153
148
|
logger.warn(`Would generate template: ${templatePath}`);
|
|
154
|
-
} else {
|
|
149
|
+
} else if (itemResult) {
|
|
150
|
+
const templateContent = generateTemplateContent(
|
|
151
|
+
{
|
|
152
|
+
vaultId: itemResult.vaultId,
|
|
153
|
+
itemId: itemResult.id,
|
|
154
|
+
variables,
|
|
155
|
+
lines,
|
|
156
|
+
fieldIds: itemResult.fieldIds,
|
|
157
|
+
},
|
|
158
|
+
templateFileName,
|
|
159
|
+
);
|
|
155
160
|
writeTemplate(templateContent, templatePath);
|
|
156
161
|
logger.success(`Generated template: ${templatePath}`);
|
|
157
162
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import * as p from "@clack/prompts";
|
|
4
|
+
import { $ } from "bun";
|
|
5
|
+
import { stripHeaders } from "../core/env-parser";
|
|
6
|
+
import { checkOpCli, checkSignedIn } from "../core/onepassword";
|
|
7
|
+
import { generateEnvHeader } from "../core/template-generator";
|
|
8
|
+
import type { InjectOptions } from "../core/types";
|
|
9
|
+
import { Env2OpError } from "../utils/errors";
|
|
10
|
+
import { logger } from "../utils/logger";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Derive output path from template path
|
|
14
|
+
* .env.tpl -> .env
|
|
15
|
+
* .env.local.tpl -> .env.local
|
|
16
|
+
* secrets.tpl -> secrets
|
|
17
|
+
*/
|
|
18
|
+
function deriveOutputPath(templatePath: string): string {
|
|
19
|
+
if (templatePath.endsWith(".tpl")) {
|
|
20
|
+
return templatePath.slice(0, -4);
|
|
21
|
+
}
|
|
22
|
+
return `${templatePath}.env`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Execute the inject operation (op2env)
|
|
27
|
+
*/
|
|
28
|
+
export async function runInject(options: InjectOptions): Promise<void> {
|
|
29
|
+
const { templateFile, output, dryRun, force } = options;
|
|
30
|
+
const outputPath = output ?? deriveOutputPath(templateFile);
|
|
31
|
+
|
|
32
|
+
// Display intro
|
|
33
|
+
const pkg = await import("../../package.json");
|
|
34
|
+
logger.intro("op2env", pkg.version, dryRun);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// Step 1: Check template file exists
|
|
38
|
+
if (!existsSync(templateFile)) {
|
|
39
|
+
throw new Env2OpError(
|
|
40
|
+
`Template file not found: ${templateFile}`,
|
|
41
|
+
"TEMPLATE_NOT_FOUND",
|
|
42
|
+
"Ensure the file exists and the path is correct",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
logger.success(`Found template: ${basename(templateFile)}`);
|
|
47
|
+
|
|
48
|
+
// Step 2: Check 1Password CLI
|
|
49
|
+
if (!dryRun) {
|
|
50
|
+
const opInstalled = await checkOpCli();
|
|
51
|
+
if (!opInstalled) {
|
|
52
|
+
throw new Env2OpError(
|
|
53
|
+
"1Password CLI (op) is not installed",
|
|
54
|
+
"OP_CLI_NOT_INSTALLED",
|
|
55
|
+
"Install from https://1password.com/downloads/command-line/",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const signedIn = await checkSignedIn();
|
|
60
|
+
if (!signedIn) {
|
|
61
|
+
throw new Env2OpError(
|
|
62
|
+
"Not signed in to 1Password CLI",
|
|
63
|
+
"OP_NOT_SIGNED_IN",
|
|
64
|
+
'Run "op signin" to authenticate',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Step 3: Check if output file exists
|
|
70
|
+
const outputExists = existsSync(outputPath);
|
|
71
|
+
|
|
72
|
+
if (dryRun) {
|
|
73
|
+
if (outputExists) {
|
|
74
|
+
logger.warn(`Would overwrite: ${outputPath}`);
|
|
75
|
+
} else {
|
|
76
|
+
logger.warn(`Would create: ${outputPath}`);
|
|
77
|
+
}
|
|
78
|
+
logger.outro("Dry run complete. No changes made.");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (outputExists && !force) {
|
|
83
|
+
const shouldOverwrite = await p.confirm({
|
|
84
|
+
message: `File "${outputPath}" already exists. Overwrite?`,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (p.isCancel(shouldOverwrite) || !shouldOverwrite) {
|
|
88
|
+
logger.cancel("Operation cancelled");
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Step 4: Run op inject
|
|
94
|
+
const spinner = logger.spinner();
|
|
95
|
+
spinner.start("Injecting secrets from 1Password...");
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const result = await $`op inject -i ${templateFile} -o ${outputPath} -f`.quiet();
|
|
99
|
+
|
|
100
|
+
if (result.exitCode !== 0) {
|
|
101
|
+
throw new Error(result.stderr.toString());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Strip any existing headers and prepend fresh .env header
|
|
105
|
+
const rawContent = readFileSync(outputPath, "utf-8");
|
|
106
|
+
const envContent = stripHeaders(rawContent);
|
|
107
|
+
const header = generateEnvHeader(basename(outputPath)).join("\n");
|
|
108
|
+
writeFileSync(outputPath, header + envContent, "utf-8");
|
|
109
|
+
|
|
110
|
+
spinner.stop(`Generated: ${outputPath}`);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
spinner.stop("Failed to inject secrets");
|
|
113
|
+
// Extract stderr from Bun shell error
|
|
114
|
+
const stderr = (error as { stderr?: Buffer })?.stderr?.toString?.();
|
|
115
|
+
const message = stderr || (error instanceof Error ? error.message : String(error));
|
|
116
|
+
throw new Env2OpError("Failed to inject secrets from 1Password", "INJECT_FAILED", message);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
logger.outro("Done! Your .env file is ready");
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error instanceof Env2OpError) {
|
|
122
|
+
logger.error(error.message);
|
|
123
|
+
if (error.suggestion) {
|
|
124
|
+
logger.info(`Suggestion: ${error.suggestion}`);
|
|
125
|
+
}
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/core/env-parser.ts
CHANGED
|
@@ -2,6 +2,44 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { errors } from "../utils/errors";
|
|
3
3
|
import type { EnvLine, EnvVariable, ParseResult } from "./types";
|
|
4
4
|
|
|
5
|
+
const HEADER_SEPARATOR = "# ===========================================================================";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Strip env2op/op2env header blocks from content
|
|
9
|
+
* Headers are delimited by separator lines
|
|
10
|
+
*/
|
|
11
|
+
export function stripHeaders(content: string): string {
|
|
12
|
+
const lines = content.split("\n");
|
|
13
|
+
const result: string[] = [];
|
|
14
|
+
let inHeader = false;
|
|
15
|
+
|
|
16
|
+
for (const line of lines) {
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
|
|
19
|
+
if (trimmed === HEADER_SEPARATOR) {
|
|
20
|
+
if (!inHeader) {
|
|
21
|
+
// Starting a header block
|
|
22
|
+
inHeader = true;
|
|
23
|
+
} else {
|
|
24
|
+
// Ending a header block
|
|
25
|
+
inHeader = false;
|
|
26
|
+
}
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!inHeader) {
|
|
31
|
+
result.push(line);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Remove leading empty lines left after stripping header
|
|
36
|
+
while (result.length > 0 && result[0]?.trim() === "") {
|
|
37
|
+
result.shift();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
5
43
|
/**
|
|
6
44
|
* Parse a value from an environment variable line
|
|
7
45
|
* Handles quoted strings and inline comments
|
|
@@ -43,7 +81,8 @@ export function parseEnvFile(filePath: string): ParseResult {
|
|
|
43
81
|
throw errors.envFileNotFound(filePath);
|
|
44
82
|
}
|
|
45
83
|
|
|
46
|
-
const
|
|
84
|
+
const rawContent = readFileSync(filePath, "utf-8");
|
|
85
|
+
const content = stripHeaders(rawContent);
|
|
47
86
|
const rawLines = content.split("\n");
|
|
48
87
|
const variables: EnvVariable[] = [];
|
|
49
88
|
const lines: EnvLine[] = [];
|
package/src/core/onepassword.ts
CHANGED
|
@@ -76,10 +76,22 @@ export async function createSecureNote(options: CreateItemOptions): Promise<Crea
|
|
|
76
76
|
// Execute op command
|
|
77
77
|
const result = await $`op ${args}`.json();
|
|
78
78
|
|
|
79
|
+
// Extract field IDs mapped by label
|
|
80
|
+
const fieldIds: Record<string, string> = {};
|
|
81
|
+
if (Array.isArray(result.fields)) {
|
|
82
|
+
for (const field of result.fields) {
|
|
83
|
+
if (field.label && field.id) {
|
|
84
|
+
fieldIds[field.label] = field.id;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
return {
|
|
80
90
|
id: result.id,
|
|
81
91
|
title: result.title,
|
|
82
92
|
vault: result.vault?.name ?? vault,
|
|
93
|
+
vaultId: result.vault?.id ?? "",
|
|
94
|
+
fieldIds,
|
|
83
95
|
};
|
|
84
96
|
} catch (error) {
|
|
85
97
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -2,27 +2,89 @@ import { writeFileSync } from "node:fs";
|
|
|
2
2
|
import pkg from "../../package.json";
|
|
3
3
|
import type { TemplateOptions } from "./types";
|
|
4
4
|
|
|
5
|
+
const SEPARATOR = "# ===========================================================================";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Derive .env filename from template filename
|
|
9
|
+
* .env.tpl -> .env
|
|
10
|
+
* .env.local.tpl -> .env.local
|
|
11
|
+
*/
|
|
12
|
+
function deriveEnvFileName(templateFileName: string): string {
|
|
13
|
+
if (templateFileName.endsWith(".tpl")) {
|
|
14
|
+
return templateFileName.slice(0, -4);
|
|
15
|
+
}
|
|
16
|
+
return templateFileName;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Derive template filename from .env filename
|
|
21
|
+
* .env -> .env.tpl
|
|
22
|
+
* .env.local -> .env.local.tpl
|
|
23
|
+
*/
|
|
24
|
+
function deriveTemplateFileName(envFileName: string): string {
|
|
25
|
+
return `${envFileName}.tpl`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generate header for .env.tpl template files
|
|
30
|
+
*/
|
|
31
|
+
export function generateTemplateHeader(templateFileName: string): string[] {
|
|
32
|
+
const envFileName = deriveEnvFileName(templateFileName);
|
|
33
|
+
return [
|
|
34
|
+
SEPARATOR,
|
|
35
|
+
`# ${templateFileName} — 1Password Secret References`,
|
|
36
|
+
"#",
|
|
37
|
+
"# This template contains references to secrets stored in 1Password.",
|
|
38
|
+
"# The actual values are not stored here — only secret references.",
|
|
39
|
+
"#",
|
|
40
|
+
`# To generate ${envFileName} with real values:`,
|
|
41
|
+
`# op2env ${templateFileName}`,
|
|
42
|
+
"#",
|
|
43
|
+
"# To run a command with secrets injected:",
|
|
44
|
+
`# op run --env-file ${templateFileName} -- npm start`,
|
|
45
|
+
"#",
|
|
46
|
+
`# Generated by env2op v${pkg.version}`,
|
|
47
|
+
"# https://github.com/tolgamorf/env2op-cli",
|
|
48
|
+
SEPARATOR,
|
|
49
|
+
"",
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generate header for .env files (after pulling from 1Password)
|
|
55
|
+
*/
|
|
56
|
+
export function generateEnvHeader(envFileName: string): string[] {
|
|
57
|
+
const templateFileName = deriveTemplateFileName(envFileName);
|
|
58
|
+
return [
|
|
59
|
+
SEPARATOR,
|
|
60
|
+
`# ${envFileName} — Environment Variables`,
|
|
61
|
+
"#",
|
|
62
|
+
"# WARNING: This file contains sensitive values. Do not commit to git!",
|
|
63
|
+
"#",
|
|
64
|
+
`# To push updates to 1Password and generate ${templateFileName}:`,
|
|
65
|
+
`# env2op ${envFileName} <vault> "<item_name>"`,
|
|
66
|
+
"#",
|
|
67
|
+
`# Pulled from 1Password by op2env v${pkg.version}`,
|
|
68
|
+
"# https://github.com/tolgamorf/env2op-cli",
|
|
69
|
+
SEPARATOR,
|
|
70
|
+
"",
|
|
71
|
+
"",
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
5
75
|
/**
|
|
6
76
|
* Generate op:// reference template content
|
|
7
77
|
*
|
|
8
78
|
* Format: KEY=op://vault/item/field
|
|
9
79
|
*
|
|
10
80
|
* This template can be used with:
|
|
11
|
-
* - `
|
|
81
|
+
* - `op2env template.tpl` to generate .env
|
|
12
82
|
* - `op run --env-file template.tpl -- command`
|
|
13
83
|
*/
|
|
14
84
|
export function generateTemplateContent(options: TemplateOptions, templateFileName: string): string {
|
|
15
|
-
const {
|
|
85
|
+
const { vaultId, itemId, lines: envLines, fieldIds } = options;
|
|
16
86
|
|
|
17
|
-
const outputLines: string[] =
|
|
18
|
-
`# Generated by env2op v${pkg.version}`,
|
|
19
|
-
"# https://github.com/tolgamorf/env2op-cli#README",
|
|
20
|
-
"#",
|
|
21
|
-
"# Usage:",
|
|
22
|
-
`# op inject -i ${templateFileName} -o .env`,
|
|
23
|
-
`# op run --env-file ${templateFileName} -- npm start`,
|
|
24
|
-
"",
|
|
25
|
-
];
|
|
87
|
+
const outputLines: string[] = generateTemplateHeader(templateFileName);
|
|
26
88
|
|
|
27
89
|
for (const line of envLines) {
|
|
28
90
|
switch (line.type) {
|
|
@@ -32,9 +94,11 @@ export function generateTemplateContent(options: TemplateOptions, templateFileNa
|
|
|
32
94
|
case "comment":
|
|
33
95
|
outputLines.push(line.content);
|
|
34
96
|
break;
|
|
35
|
-
case "variable":
|
|
36
|
-
|
|
97
|
+
case "variable": {
|
|
98
|
+
const fieldId = fieldIds[line.key] ?? line.key;
|
|
99
|
+
outputLines.push(`${line.key}=op://${vaultId}/${itemId}/${fieldId}`);
|
|
37
100
|
break;
|
|
101
|
+
}
|
|
38
102
|
}
|
|
39
103
|
}
|
|
40
104
|
|
|
@@ -52,10 +116,5 @@ export function writeTemplate(content: string, outputPath: string): void {
|
|
|
52
116
|
* Generate usage instructions for display
|
|
53
117
|
*/
|
|
54
118
|
export function generateUsageInstructions(templatePath: string): string {
|
|
55
|
-
return [
|
|
56
|
-
"",
|
|
57
|
-
"Usage:",
|
|
58
|
-
` op inject -i ${templatePath} -o .env`,
|
|
59
|
-
` op run --env-file ${templatePath} -- npm start`,
|
|
60
|
-
].join("\n");
|
|
119
|
+
return ["", "Usage:", ` op2env ${templatePath}`, ` op run --env-file ${templatePath} -- npm start`].join("\n");
|
|
61
120
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -56,10 +56,14 @@ export interface CreateItemResult {
|
|
|
56
56
|
title: string;
|
|
57
57
|
/** Vault name */
|
|
58
58
|
vault: string;
|
|
59
|
+
/** Vault ID */
|
|
60
|
+
vaultId: string;
|
|
61
|
+
/** Field IDs mapped by field label */
|
|
62
|
+
fieldIds: Record<string, string>;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
|
-
* Options for the convert command
|
|
66
|
+
* Options for the convert command (env2op)
|
|
63
67
|
*/
|
|
64
68
|
export interface ConvertOptions {
|
|
65
69
|
/** Path to .env file */
|
|
@@ -68,24 +72,42 @@ export interface ConvertOptions {
|
|
|
68
72
|
vault: string;
|
|
69
73
|
/** Secure Note title */
|
|
70
74
|
itemName: string;
|
|
75
|
+
/** Custom output path for template file */
|
|
76
|
+
output?: string;
|
|
71
77
|
/** Preview mode - don't make changes */
|
|
72
78
|
dryRun: boolean;
|
|
73
79
|
/** Store all fields as password type */
|
|
74
80
|
secret: boolean;
|
|
75
|
-
/** Skip confirmation prompts
|
|
76
|
-
|
|
81
|
+
/** Skip confirmation prompts */
|
|
82
|
+
force: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Options for the inject command (op2env)
|
|
87
|
+
*/
|
|
88
|
+
export interface InjectOptions {
|
|
89
|
+
/** Path to template file */
|
|
90
|
+
templateFile: string;
|
|
91
|
+
/** Custom output path for .env file */
|
|
92
|
+
output?: string;
|
|
93
|
+
/** Preview mode - don't make changes */
|
|
94
|
+
dryRun: boolean;
|
|
95
|
+
/** Skip confirmation prompts */
|
|
96
|
+
force: boolean;
|
|
77
97
|
}
|
|
78
98
|
|
|
79
99
|
/**
|
|
80
100
|
* Options for template generation
|
|
81
101
|
*/
|
|
82
102
|
export interface TemplateOptions {
|
|
83
|
-
/** Vault
|
|
84
|
-
|
|
85
|
-
/** Item
|
|
86
|
-
|
|
103
|
+
/** Vault ID */
|
|
104
|
+
vaultId: string;
|
|
105
|
+
/** Item ID in 1Password */
|
|
106
|
+
itemId: string;
|
|
87
107
|
/** Variables to include */
|
|
88
108
|
variables: EnvVariable[];
|
|
89
109
|
/** All lines preserving structure */
|
|
90
110
|
lines: EnvLine[];
|
|
111
|
+
/** Field IDs mapped by field label */
|
|
112
|
+
fieldIds: Record<string, string>;
|
|
91
113
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { runInject } from "./commands/inject";
|
|
5
|
+
|
|
6
|
+
const pkg = await import("../package.json");
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
|
|
9
|
+
// Parse arguments
|
|
10
|
+
const flags = new Set<string>();
|
|
11
|
+
const positional: string[] = [];
|
|
12
|
+
const options: Record<string, string> = {};
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
const arg = args[i] as string;
|
|
16
|
+
if (arg === "-o" || arg === "--output") {
|
|
17
|
+
const next = args[i + 1];
|
|
18
|
+
if (next && !next.startsWith("-")) {
|
|
19
|
+
options.output = next;
|
|
20
|
+
i++; // skip next arg
|
|
21
|
+
}
|
|
22
|
+
} else if (arg.startsWith("--")) {
|
|
23
|
+
flags.add(arg.slice(2));
|
|
24
|
+
} else if (arg.startsWith("-")) {
|
|
25
|
+
// Handle short flags
|
|
26
|
+
for (const char of arg.slice(1)) {
|
|
27
|
+
flags.add(char);
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
positional.push(arg);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check for help/version flags first
|
|
35
|
+
const hasHelp = flags.has("h") || flags.has("help");
|
|
36
|
+
const hasVersion = flags.has("v") || flags.has("version");
|
|
37
|
+
|
|
38
|
+
if (hasVersion) {
|
|
39
|
+
console.log(pkg.version);
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (hasHelp || positional.length === 0) {
|
|
44
|
+
showHelp();
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Run the inject command
|
|
49
|
+
const [templateFile] = positional as [string];
|
|
50
|
+
await runInject({
|
|
51
|
+
templateFile,
|
|
52
|
+
output: options.output,
|
|
53
|
+
dryRun: flags.has("dry-run"),
|
|
54
|
+
force: flags.has("f") || flags.has("force"),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function showHelp(): void {
|
|
58
|
+
const name = pc.bold(pc.cyan("op2env"));
|
|
59
|
+
const version = pc.dim(`v${pkg.version}`);
|
|
60
|
+
|
|
61
|
+
console.log(`
|
|
62
|
+
${name} ${version}
|
|
63
|
+
Pull secrets from 1Password to generate .env files
|
|
64
|
+
|
|
65
|
+
${pc.bold("USAGE")}
|
|
66
|
+
${pc.cyan("$")} op2env ${pc.yellow("<template_file>")} ${pc.dim("[options]")}
|
|
67
|
+
|
|
68
|
+
${pc.bold("ARGUMENTS")}
|
|
69
|
+
${pc.yellow("template_file")} Path to .env.tpl template file
|
|
70
|
+
|
|
71
|
+
${pc.bold("OPTIONS")}
|
|
72
|
+
${pc.cyan("-o, --output")} Output .env path (default: template without .tpl)
|
|
73
|
+
${pc.cyan("-f, --force")} Overwrite without prompting
|
|
74
|
+
${pc.cyan("--dry-run")} Preview actions without executing
|
|
75
|
+
${pc.cyan("-h, --help")} Show this help message
|
|
76
|
+
${pc.cyan("-v, --version")} Show version
|
|
77
|
+
|
|
78
|
+
${pc.bold("EXAMPLES")}
|
|
79
|
+
${pc.dim("# Basic usage - generates .env from .env.tpl")}
|
|
80
|
+
${pc.cyan("$")} op2env .env.tpl
|
|
81
|
+
|
|
82
|
+
${pc.dim("# Custom output path")}
|
|
83
|
+
${pc.cyan("$")} op2env .env.tpl -o .env.local
|
|
84
|
+
|
|
85
|
+
${pc.dim("# Preview without making changes")}
|
|
86
|
+
${pc.cyan("$")} op2env .env.tpl --dry-run
|
|
87
|
+
|
|
88
|
+
${pc.dim("# Overwrite existing .env without prompting")}
|
|
89
|
+
${pc.cyan("$")} op2env .env.tpl -f
|
|
90
|
+
|
|
91
|
+
${pc.bold("DOCUMENTATION")}
|
|
92
|
+
${pc.dim("https://github.com/tolgamorf/env2op-cli")}
|
|
93
|
+
`);
|
|
94
|
+
}
|
package/src/utils/errors.ts
CHANGED
|
@@ -25,6 +25,8 @@ export const ErrorCodes = {
|
|
|
25
25
|
ITEM_EXISTS: "ITEM_EXISTS",
|
|
26
26
|
ITEM_CREATE_FAILED: "ITEM_CREATE_FAILED",
|
|
27
27
|
PARSE_ERROR: "PARSE_ERROR",
|
|
28
|
+
TEMPLATE_NOT_FOUND: "TEMPLATE_NOT_FOUND",
|
|
29
|
+
INJECT_FAILED: "INJECT_FAILED",
|
|
28
30
|
} as const;
|
|
29
31
|
|
|
30
32
|
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|