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 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 = process.stdin.isTTY && !opts.noInput && !opts.json && !process.env.CI;
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 === "encrypt") {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zurik",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "CLI tool for encrypting and decrypting sensitive project files into a single .zurik bundle.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
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).
@@ -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` |