zurik 2.0.0 → 2.1.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/bin/zurik.js +92 -18
- package/lib/index.js +10 -0
- package/lib/init.js +59 -0
- package/package.json +1 -1
- package/readme.md +16 -2
- package/skills/zurik/SKILL.md +1 -0
package/bin/zurik.js
CHANGED
|
@@ -4,17 +4,18 @@ import fs from "fs";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import * as readline from "readline";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
|
-
import { encrypt, decrypt, status, resolveRoot, ZurikError, ERROR_CODES, MIN_PASSWORD_LENGTH } from "../lib/index.js";
|
|
7
|
+
import { init, encrypt, decrypt, status, resolveRoot, ZurikError, ERROR_CODES, MIN_PASSWORD_LENGTH } from "../lib/index.js";
|
|
8
8
|
|
|
9
9
|
const PKG_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
10
|
const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_DIR, "package.json"), "utf8")).version;
|
|
11
11
|
|
|
12
12
|
const EXIT = { OK: 0, ERROR: 1, USAGE: 2 };
|
|
13
|
-
const COMMANDS = { encrypt: "encrypt", "-e": "encrypt", decrypt: "decrypt", "-d": "decrypt", status: "status", "-s": "status", skill: "skill", help: "help" };
|
|
13
|
+
const COMMANDS = { init: "init", encrypt: "encrypt", "-e": "encrypt", decrypt: "decrypt", "-d": "decrypt", status: "status", "-s": "status", skill: "skill", help: "help" };
|
|
14
14
|
|
|
15
15
|
const HELP = `zurik ${VERSION} — encrypt project secrets into a single .zurik bundle
|
|
16
16
|
|
|
17
17
|
Usage:
|
|
18
|
+
zurik init [options] Find .env files and add them to .gitencrypt (creates it)
|
|
18
19
|
zurik encrypt [options] Encrypt files listed in .gitencrypt into .zurik (alias: -e)
|
|
19
20
|
zurik decrypt [options] Restore files from .zurik (alias: -d)
|
|
20
21
|
zurik status [options] Show which files are in sync, without decrypting (alias: -s)
|
|
@@ -37,6 +38,9 @@ Password sources, first match wins: --password value, --password-file, --passwor
|
|
|
37
38
|
-p prompt, $ZURIK_PASSWORD, $ZURIK_PASSWORD_FILE, then an interactive prompt if stdin is
|
|
38
39
|
a terminal (never with --json, --no-input or $CI). Encrypting needs ${MIN_PASSWORD_LENGTH}+ characters.
|
|
39
40
|
|
|
41
|
+
init needs no password. In a terminal it lets you pick the files (all selected by default);
|
|
42
|
+
otherwise (--json, --no-input, $CI or piped stdin) it adds every .env file found.
|
|
43
|
+
|
|
40
44
|
zurik uses the nearest parent directory containing .gitencrypt (encrypt), .zurik (decrypt)
|
|
41
45
|
or either (status). Exit codes: 0 success, 1 error, 2 usage error.`;
|
|
42
46
|
|
|
@@ -149,6 +153,57 @@ function promptPassword() {
|
|
|
149
153
|
});
|
|
150
154
|
}
|
|
151
155
|
|
|
156
|
+
// Checkbox list on stderr; every item starts selected. Resolves to the selected items.
|
|
157
|
+
// ponytail: redraws in place, so a list taller than the terminal scrolls messily; paginate if that bites.
|
|
158
|
+
function selectFiles(items) {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
const stdin = process.stdin;
|
|
161
|
+
const out = process.stderr;
|
|
162
|
+
const selected = items.map(() => true);
|
|
163
|
+
let cursor = 0;
|
|
164
|
+
let drawn = 0;
|
|
165
|
+
|
|
166
|
+
const draw = () => {
|
|
167
|
+
const lines = [
|
|
168
|
+
"Select files to add to .gitencrypt (up/down move, space toggle, a all/none, enter confirm):",
|
|
169
|
+
...items.map((p, i) => `${i === cursor ? ">" : " "} [${selected[i] ? "x" : " "}] ${p}`),
|
|
170
|
+
];
|
|
171
|
+
if (drawn) out.write(`\x1b[${drawn}A`);
|
|
172
|
+
out.write(lines.map(l => `\x1b[2K${l}\n`).join(""));
|
|
173
|
+
drawn = lines.length;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const cleanup = () => {
|
|
177
|
+
stdin.removeListener("data", onData);
|
|
178
|
+
stdin.setRawMode(false);
|
|
179
|
+
stdin.pause();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// A chunk may hold several keys (fast typing, paste); arrow keys are 3-byte escapes.
|
|
183
|
+
const onData = (chunk) => {
|
|
184
|
+
for (const key of chunk.toString("utf8").match(/\x1b\[[A-D]|[^]/g)) {
|
|
185
|
+
if (key === "\u0003") {
|
|
186
|
+
cleanup();
|
|
187
|
+
process.exit(130);
|
|
188
|
+
} else if (key === "\r" || key === "\n") {
|
|
189
|
+
cleanup();
|
|
190
|
+
resolve(items.filter((_, i) => selected[i]));
|
|
191
|
+
return;
|
|
192
|
+
} else if (key === " ") selected[cursor] = !selected[cursor];
|
|
193
|
+
else if (key === "a") selected.fill(!selected.every(Boolean));
|
|
194
|
+
else if (key === "\x1b[A" || key === "k") cursor = (cursor + items.length - 1) % items.length;
|
|
195
|
+
else if (key === "\x1b[B" || key === "j") cursor = (cursor + 1) % items.length;
|
|
196
|
+
}
|
|
197
|
+
draw();
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
draw();
|
|
201
|
+
stdin.setRawMode(true);
|
|
202
|
+
stdin.resume();
|
|
203
|
+
stdin.on("data", onData);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
152
207
|
function readPasswordFile(file) {
|
|
153
208
|
try {
|
|
154
209
|
return fs.readFileSync(file, "utf8").replace(/\r?\n$/, "");
|
|
@@ -157,8 +212,10 @@ function readPasswordFile(file) {
|
|
|
157
212
|
}
|
|
158
213
|
}
|
|
159
214
|
|
|
215
|
+
const canPrompt = (opts) => Boolean(process.stdin.isTTY && !opts.noInput && !opts.json && !process.env.CI);
|
|
216
|
+
|
|
160
217
|
async function resolvePassword(opts) {
|
|
161
|
-
const interactive =
|
|
218
|
+
const interactive = canPrompt(opts);
|
|
162
219
|
|
|
163
220
|
if (opts.password) return opts.password;
|
|
164
221
|
if (opts.passwordFile) return readPasswordFile(opts.passwordFile);
|
|
@@ -176,7 +233,12 @@ function printHuman(result) {
|
|
|
176
233
|
const prefix = result.dryRun ? "[dry run] would be " : "";
|
|
177
234
|
const log = (s) => process.stdout.write(s + "\n");
|
|
178
235
|
|
|
179
|
-
if (result.command === "
|
|
236
|
+
if (result.command === "init") {
|
|
237
|
+
log(!result.created ? "Using existing .gitencrypt" : result.dryRun ? "[dry run] would create .gitencrypt" : "Created .gitencrypt");
|
|
238
|
+
for (const f of result.files) log(` ${f.action === "added" ? prefix : ""}${f.action.padEnd(14)} ${f.path}`);
|
|
239
|
+
if (result.files.length === 0) log("No .env files found. Add the paths of your secret files to .gitencrypt.");
|
|
240
|
+
log("Next: run 'zurik encrypt' and commit .zurik and .gitencrypt.");
|
|
241
|
+
} else if (result.command === "encrypt") {
|
|
180
242
|
for (const f of result.files) log(` ${prefix}encrypted ${f.path}`);
|
|
181
243
|
if (result.gitignoreAdded.length) log(` ${prefix}added to .gitignore: ${result.gitignoreAdded.join(", ")}`);
|
|
182
244
|
log(`${result.dryRun ? "Would encrypt" : "Encrypted"} ${result.files.length} file(s) into .zurik`);
|
|
@@ -195,6 +257,7 @@ function helpSpec() {
|
|
|
195
257
|
name: "zurik",
|
|
196
258
|
version: VERSION,
|
|
197
259
|
commands: {
|
|
260
|
+
init: { options: ["dryRun", "cwd"], password: false, result: "{ created, files: [{ path, action: 'added'|'already-listed'|'skipped' }] }", note: "Adds every .env file found unless run interactively in a terminal" },
|
|
198
261
|
encrypt: { options: ["password*", "dryRun", "cwd"], result: "{ files: [{ path, action: 'encrypted' }], gitignoreAdded: [path] }" },
|
|
199
262
|
decrypt: { options: ["password*", "force", "dryRun", "cwd"], result: "{ files: [{ path, action: 'created'|'updated'|'unchanged' }] }" },
|
|
200
263
|
status: { options: ["password (only if .zurik exists)", "cwd"], result: "{ bundleExists, inSync, files: [{ path, state: 'in-sync'|'modified'|'missing-locally'|'not-in-bundle'|'missing', listed }] }" },
|
|
@@ -207,6 +270,30 @@ function helpSpec() {
|
|
|
207
270
|
};
|
|
208
271
|
}
|
|
209
272
|
|
|
273
|
+
// init needs no password: preview what would be added, let a terminal user untick files, then write.
|
|
274
|
+
async function runInit(opts, base) {
|
|
275
|
+
const preview = await init({ ...base, dryRun: true });
|
|
276
|
+
const candidates = preview.files.filter(f => f.action === "added").map(f => f.path);
|
|
277
|
+
const files = canPrompt(opts) && candidates.length > 0 ? await selectFiles(candidates) : candidates;
|
|
278
|
+
return init({ ...base, files, dryRun: opts.dryRun });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function runWithPassword(opts, base) {
|
|
282
|
+
// status only needs a password when there is a bundle to compare against.
|
|
283
|
+
const needsPassword =
|
|
284
|
+
opts.command !== "status" || fs.existsSync(path.join(resolveRoot("status", base), ".zurik"));
|
|
285
|
+
const password = needsPassword ? await resolvePassword(opts) : undefined;
|
|
286
|
+
if (needsPassword && !password) {
|
|
287
|
+
throw new ZurikError(
|
|
288
|
+
"E_NO_PASSWORD",
|
|
289
|
+
"No password provided. Use --password-file, --password-stdin, $ZURIK_PASSWORD or -p."
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const run = { encrypt, decrypt, status }[opts.command];
|
|
294
|
+
return run({ ...base, password, force: opts.force, dryRun: opts.dryRun });
|
|
295
|
+
}
|
|
296
|
+
|
|
210
297
|
// ---------- main ----------
|
|
211
298
|
|
|
212
299
|
async function main() {
|
|
@@ -229,20 +316,7 @@ async function main() {
|
|
|
229
316
|
}
|
|
230
317
|
|
|
231
318
|
const base = { cwd: opts.cwd ? path.resolve(opts.cwd) : process.cwd() };
|
|
232
|
-
|
|
233
|
-
// status only needs a password when there is a bundle to compare against.
|
|
234
|
-
const needsPassword =
|
|
235
|
-
opts.command !== "status" || fs.existsSync(path.join(resolveRoot("status", base), ".zurik"));
|
|
236
|
-
const password = needsPassword ? await resolvePassword(opts) : undefined;
|
|
237
|
-
if (needsPassword && !password) {
|
|
238
|
-
throw new ZurikError(
|
|
239
|
-
"E_NO_PASSWORD",
|
|
240
|
-
"No password provided. Use --password-file, --password-stdin, $ZURIK_PASSWORD or -p."
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const run = { encrypt, decrypt, status }[opts.command];
|
|
245
|
-
const result = await run({ ...base, password, force: opts.force, dryRun: opts.dryRun });
|
|
319
|
+
const result = opts.command === "init" ? await runInit(opts, base) : await runWithPassword(opts, base);
|
|
246
320
|
|
|
247
321
|
if (opts.json) console.log(JSON.stringify({ ok: true, ...result }));
|
|
248
322
|
else printHuman(result);
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from "path";
|
|
|
4
4
|
import encryptFiles from "./encrypt.js";
|
|
5
5
|
import decryptFiles from "./decrypt.js";
|
|
6
6
|
import statusFiles from "./status.js";
|
|
7
|
+
import initList from "./init.js";
|
|
7
8
|
import { BUNDLE_FILE, LIST_FILE } from "./bundle.js";
|
|
8
9
|
import { ZurikError, ERROR_CODES } from "./errors.js";
|
|
9
10
|
import { findProjectRoot } from "./paths.js";
|
|
@@ -58,3 +59,12 @@ export async function status(options = {}) {
|
|
|
58
59
|
const root = resolveRoot("status", options);
|
|
59
60
|
return { command: "status", root, ...(await statusFiles({ ...options, root })) };
|
|
60
61
|
}
|
|
62
|
+
|
|
63
|
+
/** Add .env files (or `files`) to .gitencrypt, creating it if missing. No password needed.
|
|
64
|
+
* Options: { root?, cwd?, files?, dryRun? }. Root: `root`, else the nearest parent with .gitencrypt or .git, else `cwd`. */
|
|
65
|
+
export async function init(options = {}) {
|
|
66
|
+
const cwd = options.cwd ?? process.cwd();
|
|
67
|
+
const root = options.root ? path.resolve(options.root) : findProjectRoot([LIST_FILE, ".git"], cwd) ?? path.resolve(cwd);
|
|
68
|
+
const dryRun = Boolean(options.dryRun);
|
|
69
|
+
return { command: "init", root, dryRun, ...initList({ ...options, root, dryRun }) };
|
|
70
|
+
}
|
package/lib/init.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { LIST_FILE, readFileList } from "./bundle.js";
|
|
4
|
+
import { safeRelativePath } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
7
|
+
const ENV_FILE = /^\.env(\..+)?$|\.env$/; // .env, .env.local, prod.env
|
|
8
|
+
const TEMPLATE = /\.(example|sample|template|dist)$/i; // meant to be committed, not secret
|
|
9
|
+
|
|
10
|
+
const HEADER = "# Files to encrypt into .zurik with `zurik encrypt`, one path per line.\n";
|
|
11
|
+
|
|
12
|
+
// .env-style files under `root`, as sorted "/"-separated relative paths.
|
|
13
|
+
export function findEnvFiles(root) {
|
|
14
|
+
const found = [];
|
|
15
|
+
const walk = (dir) => {
|
|
16
|
+
let entries;
|
|
17
|
+
try {
|
|
18
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
} catch {
|
|
20
|
+
return; // unreadable directory: nothing to offer from it
|
|
21
|
+
}
|
|
22
|
+
for (const e of entries) {
|
|
23
|
+
const abs = path.join(dir, e.name);
|
|
24
|
+
if (e.isDirectory()) {
|
|
25
|
+
if (!SKIP_DIRS.has(e.name)) walk(abs);
|
|
26
|
+
} else if (e.isFile() && ENV_FILE.test(e.name) && !TEMPLATE.test(e.name)) {
|
|
27
|
+
found.push(path.relative(root, abs).split(path.sep).join("/"));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
walk(root);
|
|
32
|
+
return found.sort();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Adds `files` (default: every .env file found) to .gitencrypt, creating it if needed.
|
|
36
|
+
export default function initList({ root, files, dryRun = false }) {
|
|
37
|
+
const listed = readFileList(root);
|
|
38
|
+
const already = new Set(listed ?? []);
|
|
39
|
+
const found = findEnvFiles(root);
|
|
40
|
+
|
|
41
|
+
const chosen = new Set(files ? files.map(f => safeRelativePath(f, root)) : found);
|
|
42
|
+
const added = [...chosen].filter(f => !already.has(f));
|
|
43
|
+
|
|
44
|
+
if (!dryRun && (listed === null || added.length > 0)) {
|
|
45
|
+
const listPath = path.join(root, LIST_FILE);
|
|
46
|
+
const text = listed === null ? HEADER : fs.readFileSync(listPath, "utf8");
|
|
47
|
+
const sep = text && !text.endsWith("\n") ? "\n" : "";
|
|
48
|
+
fs.writeFileSync(listPath, text + sep + added.map(f => f + "\n").join(""));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
created: listed === null,
|
|
53
|
+
files: [
|
|
54
|
+
...found.filter(f => already.has(f)).map(p => ({ path: p, action: "already-listed" })),
|
|
55
|
+
...added.map(p => ({ path: p, action: "added" })),
|
|
56
|
+
...found.filter(f => !already.has(f) && !chosen.has(f)).map(p => ({ path: p, action: "skipped" })),
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -55,9 +55,19 @@ zurik never prompts when stdin is not a terminal, or with `--json`, `--no-input`
|
|
|
55
55
|
|
|
56
56
|
## Usage
|
|
57
57
|
|
|
58
|
+
### Set up a project
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
npx zurik init
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`init` finds the `.env` files in your project (`.env`, `.env.local`, `prod.env`, ...; skipping `node_modules` and templates like `.env.example`) and lists them with all of them selected. Untick any you don't want (space toggles, enter confirms) and they are added to `.gitencrypt`, which is created if it doesn't exist. Run it again any time to pick up new `.env` files; paths already listed are left alone. It needs no password.
|
|
65
|
+
|
|
66
|
+
Outside a terminal (`--json`, `--no-input`, `$CI` or piped stdin) it adds every file found without asking. Add `--dry-run` to only see what it would do.
|
|
67
|
+
|
|
58
68
|
### Encrypt files
|
|
59
69
|
|
|
60
|
-
1. Create a `.gitencrypt` file in the root of your project.
|
|
70
|
+
1. Create a `.gitencrypt` file in the root of your project (or run `zurik init`).
|
|
61
71
|
2. Add the paths of the files you want to encrypt, one per line. Lines starting with `#` are ignored, and paths must be inside the project.
|
|
62
72
|
|
|
63
73
|
```txt
|
|
@@ -133,7 +143,7 @@ Branch on `error.code`. Exit codes are `0` success, `1` error and `2` usage erro
|
|
|
133
143
|
**Library API** for harnesses and scripts. It returns the same objects as `--json`, throws `ZurikError` with a `code`, prints nothing and never changes `process.cwd()`:
|
|
134
144
|
|
|
135
145
|
```js
|
|
136
|
-
import { encrypt, decrypt, status, ZurikError } from "zurik";
|
|
146
|
+
import { init, encrypt, decrypt, status, ZurikError } from "zurik";
|
|
137
147
|
|
|
138
148
|
const s = await status({ cwd: "/repo", password: process.env.ZURIK_PASSWORD });
|
|
139
149
|
if (!s.inSync) {
|
|
@@ -146,6 +156,8 @@ if (!s.inSync) {
|
|
|
146
156
|
}
|
|
147
157
|
```
|
|
148
158
|
|
|
159
|
+
`init({ cwd, files, dryRun })` adds `files` (default: every `.env` file found) to `.gitencrypt` and needs no password.
|
|
160
|
+
|
|
149
161
|
Options: `password`, `cwd` (search for the project root from here) or `root` (use exactly), `force` and `dryRun`.
|
|
150
162
|
|
|
151
163
|
## Bundle format
|
|
@@ -159,3 +171,5 @@ Bundles written by zurik 1.0.5 and earlier (no `zurik2:` prefix) still decrypt,
|
|
|
159
171
|
```sh
|
|
160
172
|
npm test
|
|
161
173
|
```
|
|
174
|
+
|
|
175
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md).
|
package/skills/zurik/SKILL.md
CHANGED
|
@@ -13,6 +13,7 @@ Run it with `npx zurik` (or `zurik` if it's installed). Always pass `--json` and
|
|
|
13
13
|
|
|
14
14
|
| Goal | Command |
|
|
15
15
|
|---|---|
|
|
16
|
+
| Set up a repo without `.gitencrypt` (adds every `.env` file found; no password) | `npx zurik init --dry-run --json`, confirm the list with the user, then `npx zurik init --json` |
|
|
16
17
|
| See what's out of sync (safe, read-only) | `npx zurik status --json` |
|
|
17
18
|
| Restore secrets after a clone | `npx zurik decrypt --json` |
|
|
18
19
|
| Save changes to secret files into `.zurik` | `npx zurik encrypt --json` |
|