zurik 1.0.3 → 2.0.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
@@ -1,95 +1,261 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import fs from "fs";
4
+ import path from "path";
3
5
  import * as readline from "readline";
4
- import encryptFiles from "../lib/encrypt.js";
5
- import decryptFiles from "../lib/decrypt.js";
6
+ import { fileURLToPath } from "url";
7
+ import { encrypt, decrypt, status, resolveRoot, ZurikError, ERROR_CODES, MIN_PASSWORD_LENGTH } from "../lib/index.js";
6
8
 
7
- const args = process.argv.slice(2);
9
+ const PKG_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_DIR, "package.json"), "utf8")).version;
8
11
 
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" };
9
14
 
10
- function readPassword() {
11
- return new Promise((resolve) => {
12
- const rl = readline.createInterface({
13
- input: process.stdin,
14
- output: process.stdout,
15
- terminal: false
16
- });
15
+ const HELP = `zurik ${VERSION} — encrypt project secrets into a single .zurik bundle
17
16
 
18
- if (process.stdin.isTTY) {
19
- process.stdin.setRawMode(true);
20
- }
17
+ Usage:
18
+ zurik encrypt [options] Encrypt files listed in .gitencrypt into .zurik (alias: -e)
19
+ zurik decrypt [options] Restore files from .zurik (alias: -d)
20
+ zurik status [options] Show which files are in sync, without decrypting (alias: -s)
21
+ zurik skill Print instructions for AI agents (SKILL.md)
22
+ zurik help [--json] Show this help; --json prints a machine-readable spec
21
23
 
22
- process.stdout.write("Password: ");
24
+ Options:
25
+ -p, --password [value] Prompt for the password (or read a line from piped stdin);
26
+ with a value, use it inline (visible in ps / shell history)
27
+ --password-stdin Read the password from the first line of stdin
28
+ --password-file <path> Read the password from a file
29
+ -f, --force Decrypt: overwrite local files that differ from .zurik
30
+ -n, --dry-run Report what would happen without writing anything
31
+ --json Print one JSON object on stdout (results and errors)
32
+ --no-input Never prompt; fail with E_NO_PASSWORD instead
33
+ -C, --cwd <dir> Run as if started in <dir>
34
+ -v, --version Print the version
23
35
 
24
- let password = "";
36
+ Password sources, first match wins: --password value, --password-file, --password-stdin,
37
+ -p prompt, $ZURIK_PASSWORD, $ZURIK_PASSWORD_FILE, then an interactive prompt if stdin is
38
+ a terminal (never with --json, --no-input or $CI). Encrypting needs ${MIN_PASSWORD_LENGTH}+ characters.
25
39
 
26
- process.stdin.on("data", (char) => {
27
- char = char.toString();
40
+ zurik uses the nearest parent directory containing .gitencrypt (encrypt), .zurik (decrypt)
41
+ or either (status). Exit codes: 0 success, 1 error, 2 usage error.`;
28
42
 
29
- if (char === "\n" || char === "\r" || char === "\u0004") {
30
- process.stdin.pause();
31
- if (process.stdin.isTTY) {
32
- process.stdin.setRawMode(false);
33
- }
34
- rl.close();
35
- console.log();
36
- resolve(password);
37
- } else if (char === "\u007f" || char === "\b") {
38
- // Backspace
39
- if (password.length > 0) {
40
- password = password.slice(0, -1);
41
- process.stdout.clearLine(0);
42
- process.stdout.cursorTo(0);
43
- process.stdout.write("Password: " + "*".repeat(password.length));
43
+ function usageError(message) {
44
+ return new ZurikError("E_USAGE", message);
45
+ }
46
+
47
+ function parseArgs(argv) {
48
+ const opts = { command: null, json: false, force: false, dryRun: false, noInput: false, help: false, version: false };
49
+
50
+ // Support --flag=value for long options.
51
+ const args = argv.flatMap(a => (a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a]));
52
+
53
+ const takeValue = (i, flag) => {
54
+ if (args[i + 1] === undefined) throw usageError(`${flag} requires a value`);
55
+ return args[i + 1];
56
+ };
57
+
58
+ for (let i = 0; i < args.length; i++) {
59
+ const a = args[i];
60
+ if (COMMANDS[a]) {
61
+ if (opts.command && opts.command !== COMMANDS[a]) throw usageError("Specify only one command");
62
+ opts.command = COMMANDS[a];
63
+ continue;
64
+ }
65
+ switch (a) {
66
+ case "-p":
67
+ case "--password": {
68
+ const next = args[i + 1];
69
+ if (next !== undefined && !next.startsWith("-") && !COMMANDS[next]) {
70
+ opts.password = next;
71
+ i++;
72
+ } else {
73
+ opts.promptPassword = true;
44
74
  }
45
- } else if (char === "\u0003") {
46
- // Ctrl+C
47
- console.log();
48
- process.exit(0);
49
- } else {
50
- password += char;
51
- process.stdout.write("*");
75
+ break;
52
76
  }
77
+ case "--password-stdin": opts.passwordStdin = true; break;
78
+ case "--password-file": opts.passwordFile = takeValue(i++, a); break;
79
+ case "-f": case "--force": opts.force = true; break;
80
+ case "-n": case "--dry-run": opts.dryRun = true; break;
81
+ case "--json": opts.json = true; break;
82
+ case "--no-input": opts.noInput = true; break;
83
+ case "-C": case "--cwd": opts.cwd = takeValue(i++, a); break;
84
+ case "-h": case "--help": opts.help = true; break;
85
+ case "-v": case "--version": opts.version = true; break;
86
+ default: throw usageError(`Unknown argument: ${a}`);
87
+ }
88
+ }
89
+ return opts;
90
+ }
91
+
92
+ // ---------- password input ----------
93
+
94
+ function readLineFromStdin() {
95
+ return new Promise((resolve) => {
96
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
97
+ let done = false;
98
+ rl.once("line", (line) => {
99
+ done = true;
100
+ rl.close();
101
+ resolve(line.replace(/\r$/, ""));
102
+ });
103
+ rl.once("close", () => {
104
+ if (!done) resolve("");
53
105
  });
54
106
  });
55
107
  }
56
108
 
109
+ // Prompt goes to stderr so stdout stays clean for --json.
110
+ function promptPassword() {
111
+ return new Promise((resolve) => {
112
+ const stdin = process.stdin;
113
+ const out = process.stderr;
114
+ let password = "";
57
115
 
58
- async function extractPassword() {
59
- const pIndex = args.indexOf("-p");
60
- if (pIndex !== -1) {
61
- if (args[pIndex + 1] && !args[pIndex + 1].startsWith("-")) {
62
- return args[pIndex + 1];
63
- }
116
+ const cleanup = () => {
117
+ stdin.removeListener("data", onData);
118
+ stdin.setRawMode(false);
119
+ stdin.pause();
120
+ out.write("\n");
121
+ };
64
122
 
65
- return await readPassword()
66
- }
67
- return process.env.ZURIK_PASSWORD || null;
123
+ const onData = (chunk) => {
124
+ // A chunk may contain several characters (e.g. when pasting).
125
+ for (const char of chunk.toString("utf8")) {
126
+ if (char === "\n" || char === "\r" || char === "\u0004") {
127
+ cleanup();
128
+ resolve(password);
129
+ return;
130
+ } else if (char === "\u0003") {
131
+ cleanup();
132
+ process.exit(130);
133
+ } else if (char === "\u007f" || char === "\b") {
134
+ if (password.length > 0) {
135
+ password = [...password].slice(0, -1).join("");
136
+ out.write("\b \b");
137
+ }
138
+ } else {
139
+ password += char;
140
+ out.write("*");
141
+ }
142
+ }
143
+ };
144
+
145
+ out.write("Password: ");
146
+ stdin.setRawMode(true);
147
+ stdin.resume();
148
+ stdin.on("data", onData);
149
+ });
68
150
  }
69
151
 
70
- async function run() {
71
- const password = await extractPassword();
72
- if (!password) {
73
- console.error("Missing password. Use -p <password> or set ZURIK_PASSWORD environment variable.");
74
- process.exit(1);
152
+ function readPasswordFile(file) {
153
+ try {
154
+ return fs.readFileSync(file, "utf8").replace(/\r?\n$/, "");
155
+ } catch (err) {
156
+ throw new ZurikError("E_NO_PASSWORD", `Cannot read password file ${file}: ${err.code ?? err.message}`);
75
157
  }
158
+ }
76
159
 
77
- if (args.includes("-e")) {
78
- await encryptFiles(password);
79
- return;
80
- }
160
+ async function resolvePassword(opts) {
161
+ const interactive = process.stdin.isTTY && !opts.noInput && !opts.json && !process.env.CI;
162
+
163
+ if (opts.password) return opts.password;
164
+ if (opts.passwordFile) return readPasswordFile(opts.passwordFile);
165
+ if (opts.passwordStdin) return readLineFromStdin();
166
+ if (opts.promptPassword) return process.stdin.isTTY ? promptPassword() : readLineFromStdin();
167
+ if (process.env.ZURIK_PASSWORD) return process.env.ZURIK_PASSWORD;
168
+ if (process.env.ZURIK_PASSWORD_FILE) return readPasswordFile(process.env.ZURIK_PASSWORD_FILE);
169
+ if (interactive) return promptPassword();
170
+ return "";
171
+ }
81
172
 
82
- if (args.includes("-d")) {
83
- await decryptFiles(password);
84
- return;
173
+ // ---------- output ----------
174
+
175
+ function printHuman(result) {
176
+ const prefix = result.dryRun ? "[dry run] would be " : "";
177
+ const log = (s) => process.stdout.write(s + "\n");
178
+
179
+ if (result.command === "encrypt") {
180
+ for (const f of result.files) log(` ${prefix}encrypted ${f.path}`);
181
+ if (result.gitignoreAdded.length) log(` ${prefix}added to .gitignore: ${result.gitignoreAdded.join(", ")}`);
182
+ log(`${result.dryRun ? "Would encrypt" : "Encrypted"} ${result.files.length} file(s) into .zurik`);
183
+ } else if (result.command === "decrypt") {
184
+ for (const f of result.files) log(` ${f.action === "unchanged" ? "" : prefix}${f.action.padEnd(9)} ${f.path}`);
185
+ log(`${result.dryRun ? "Would decrypt" : "Decrypted"} ${result.files.length} file(s) from .zurik`);
186
+ } else if (result.command === "status") {
187
+ if (!result.bundleExists) log("No .zurik bundle yet.");
188
+ for (const f of result.files) log(` ${f.state.padEnd(15)} ${f.path}${f.listed ? "" : " (not in .gitencrypt)"}`);
189
+ log(result.inSync ? "All files in sync." : "Out of sync.");
85
190
  }
191
+ }
192
+
193
+ function helpSpec() {
194
+ return {
195
+ name: "zurik",
196
+ version: VERSION,
197
+ commands: {
198
+ encrypt: { options: ["password*", "dryRun", "cwd"], result: "{ files: [{ path, action: 'encrypted' }], gitignoreAdded: [path] }" },
199
+ decrypt: { options: ["password*", "force", "dryRun", "cwd"], result: "{ files: [{ path, action: 'created'|'updated'|'unchanged' }] }" },
200
+ status: { options: ["password (only if .zurik exists)", "cwd"], result: "{ bundleExists, inSync, files: [{ path, state: 'in-sync'|'modified'|'missing-locally'|'not-in-bundle'|'missing', listed }] }" },
201
+ },
202
+ passwordSources: ["--password <value>", "--password-file <path>", "--password-stdin", "$ZURIK_PASSWORD", "$ZURIK_PASSWORD_FILE"],
203
+ minPasswordLength: MIN_PASSWORD_LENGTH,
204
+ output: "With --json, stdout is exactly one JSON object: { ok: true, command, root, ... } or { ok: false, error: { code, message, details } }",
205
+ exitCodes: { 0: "success", 1: "error (see error.code)", 2: "usage error" },
206
+ errorCodes: ERROR_CODES,
207
+ };
208
+ }
209
+
210
+ // ---------- main ----------
86
211
 
87
- console.log("Usage:");
88
- console.log(" npx zurik -e [-p password] Encrypt");
89
- console.log(" npx zurik -d [-p password] Decrypt");
212
+ async function main() {
213
+ let opts = { json: process.argv.includes("--json") };
214
+ try {
215
+ opts = parseArgs(process.argv.slice(2));
216
+
217
+ if (opts.version) return void console.log(VERSION);
218
+ if (opts.help || opts.command === "help") {
219
+ return void console.log(opts.json ? JSON.stringify(helpSpec(), null, 2) : HELP);
220
+ }
221
+ if (opts.command === "skill") {
222
+ return void process.stdout.write(fs.readFileSync(path.join(PKG_DIR, "skills", "zurik", "SKILL.md"), "utf8"));
223
+ }
224
+ if (!opts.command) {
225
+ if (opts.json) throw usageError("No command given");
226
+ console.log(HELP);
227
+ process.exitCode = EXIT.USAGE;
228
+ return;
229
+ }
230
+
231
+ 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 });
246
+
247
+ if (opts.json) console.log(JSON.stringify({ ok: true, ...result }));
248
+ else printHuman(result);
249
+ } catch (err) {
250
+ const error =
251
+ err instanceof ZurikError
252
+ ? { code: err.code, message: err.message, details: err.details }
253
+ : { code: "E_INTERNAL", message: err.message, details: {} };
254
+
255
+ if (opts.json) console.log(JSON.stringify({ ok: false, error }));
256
+ else console.error(`zurik: ${error.message}`);
257
+ process.exitCode = error.code === "E_USAGE" ? EXIT.USAGE : EXIT.ERROR;
258
+ }
90
259
  }
91
260
 
92
- run().catch(err => {
93
- console.error(err.message);
94
- process.exit(1);
95
- });
261
+ main();
package/lib/bundle.js ADDED
@@ -0,0 +1,63 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { decryptData } from "./crypto.js";
4
+ import { ZurikError } from "./errors.js";
5
+ import { safeRelativePath } from "./paths.js";
6
+
7
+ export const LIST_FILE = ".gitencrypt";
8
+ export const BUNDLE_FILE = ".zurik";
9
+
10
+ // Bundles from older versions carry no modes; secrets default to owner-only.
11
+ const DEFAULT_MODE = 0o600;
12
+
13
+ // Paths listed in .gitencrypt, normalized and de-duplicated. `null` if the file doesn't exist.
14
+ export function readFileList(root) {
15
+ const listPath = path.join(root, LIST_FILE);
16
+ if (!fs.existsSync(listPath)) return null;
17
+
18
+ return [
19
+ ...new Set(
20
+ fs
21
+ .readFileSync(listPath, "utf8")
22
+ .split("\n")
23
+ .map(x => x.trim())
24
+ .filter(x => x && !x.startsWith("#"))
25
+ .map(f => safeRelativePath(f, root))
26
+ ),
27
+ ];
28
+ }
29
+
30
+ // Decrypts .zurik and returns [{ path, content: Buffer, mode }]. Every path is validated.
31
+ export async function readBundle(root, password) {
32
+ const bundlePath = path.join(root, BUNDLE_FILE);
33
+ if (!fs.existsSync(bundlePath)) {
34
+ throw new ZurikError("E_NO_BUNDLE", `${BUNDLE_FILE} not found in ${root}`);
35
+ }
36
+
37
+ const plaintext = await decryptData(fs.readFileSync(bundlePath, "utf8"), password);
38
+
39
+ let data;
40
+ try {
41
+ data = JSON.parse(plaintext);
42
+ } catch {
43
+ throw new ZurikError("E_CORRUPT", "Invalid .zurik bundle");
44
+ }
45
+ if (!data || typeof data.files !== "object" || data.files === null) {
46
+ throw new ZurikError("E_CORRUPT", "Invalid .zurik bundle");
47
+ }
48
+
49
+ const modes = data.modes && typeof data.modes === "object" ? data.modes : {};
50
+
51
+ return Object.entries(data.files).map(([filePath, base64]) => ({
52
+ path: safeRelativePath(filePath, root),
53
+ content: Buffer.from(base64, "base64"),
54
+ mode: Number.isInteger(modes[filePath]) ? modes[filePath] & 0o777 : DEFAULT_MODE,
55
+ }));
56
+ }
57
+
58
+ // "missing" | "same" | "different" for a local file compared with `content`.
59
+ export function compareLocal(root, relPath, content) {
60
+ const abs = path.join(root, relPath);
61
+ if (!fs.existsSync(abs)) return "missing";
62
+ return fs.readFileSync(abs).equals(content) ? "same" : "different";
63
+ }
package/lib/crypto.js CHANGED
@@ -1,11 +1,25 @@
1
1
  import crypto from "crypto";
2
+ import { ZurikError } from "./errors.js";
2
3
 
3
4
  const ALGO = "aes-256-gcm";
4
- const IV_LENGTH = 16;
5
+ const SALT_LENGTH = 16;
6
+ const TAG_LENGTH = 16;
5
7
 
6
- function deriveKey(password) {
8
+ // Current format: "zurik2:" + base64( logN | r | p | salt(16) | iv(12) | tag(16) | ciphertext )
9
+ // The KDF params are authenticated as GCM additional data, so they can't be altered.
10
+ const V2_PREFIX = "zurik2:";
11
+ const V2_IV_LENGTH = 12;
12
+ const KDF = { logN: 17, r: 8, p: 1 }; // N = 2^17 ≈ 128 MiB of memory per guess
13
+
14
+ // Upper bounds so a crafted file can't make decryption allocate gigabytes.
15
+ const MAX_KDF = { logN: 20, r: 16, p: 4 };
16
+
17
+ export const MIN_PASSWORD_LENGTH = 16;
18
+
19
+ function deriveKey(password, salt, { logN, r, p }) {
20
+ const N = 2 ** logN;
7
21
  return new Promise((resolve, reject) => {
8
- crypto.scrypt(password, "zurik_fixed_salt", 32, (err, key) => {
22
+ crypto.scrypt(password, salt, 32, { N, r, p, maxmem: 2 * 128 * N * r + 1024 * 1024 }, (err, key) => {
9
23
  if (err) reject(err);
10
24
  else resolve(key);
11
25
  });
@@ -13,29 +27,77 @@ function deriveKey(password) {
13
27
  }
14
28
 
15
29
  export async function encryptData(data, password) {
16
- const key = await deriveKey(password);
17
- const iv = crypto.randomBytes(IV_LENGTH);
18
- const cipher = crypto.createCipheriv(ALGO, key, iv);
30
+ const params = Buffer.from([KDF.logN, KDF.r, KDF.p]);
31
+ const salt = crypto.randomBytes(SALT_LENGTH);
32
+ const iv = crypto.randomBytes(V2_IV_LENGTH);
33
+ const key = await deriveKey(password, salt, KDF);
19
34
 
35
+ const cipher = crypto.createCipheriv(ALGO, key, iv);
36
+ cipher.setAAD(params);
20
37
  const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
21
38
  const tag = cipher.getAuthTag();
22
39
 
23
- return Buffer.concat([iv, tag, encrypted]).toString("base64");
40
+ return V2_PREFIX + Buffer.concat([params, salt, iv, tag, encrypted]).toString("base64");
41
+ }
42
+
43
+ export async function decryptData(text, password) {
44
+ text = text.trim();
45
+ return text.startsWith(V2_PREFIX)
46
+ ? decryptV2(text.slice(V2_PREFIX.length), password)
47
+ : decryptV1(text, password);
24
48
  }
25
49
 
26
- export async function decryptData(base64, password) {
50
+ async function decryptV2(base64, password) {
27
51
  const buffer = Buffer.from(base64, "base64");
52
+ const headerLength = 3 + SALT_LENGTH + V2_IV_LENGTH + TAG_LENGTH;
53
+ if (buffer.length < headerLength) {
54
+ throw new ZurikError("E_CORRUPT", "Corrupted .zurik file");
55
+ }
56
+
57
+ const params = buffer.subarray(0, 3);
58
+ const kdf = { logN: params[0], r: params[1], p: params[2] };
59
+ if (
60
+ kdf.logN < 1 || kdf.logN > MAX_KDF.logN ||
61
+ kdf.r < 1 || kdf.r > MAX_KDF.r ||
62
+ kdf.p < 1 || kdf.p > MAX_KDF.p
63
+ ) {
64
+ throw new ZurikError("E_CORRUPT", "Corrupted .zurik file: unsupported key derivation parameters");
65
+ }
28
66
 
29
- const iv = buffer.subarray(0, 16);
30
- const tag = buffer.subarray(16, 32);
31
- const ciphertext = buffer.subarray(32);
67
+ let offset = 3;
68
+ const salt = buffer.subarray(offset, (offset += SALT_LENGTH));
69
+ const iv = buffer.subarray(offset, (offset += V2_IV_LENGTH));
70
+ const tag = buffer.subarray(offset, (offset += TAG_LENGTH));
71
+ const ciphertext = buffer.subarray(offset);
32
72
 
33
- const key = await deriveKey(password);
73
+ const key = await deriveKey(password, salt, kdf);
34
74
  const decipher = crypto.createDecipheriv(ALGO, key, iv);
75
+ decipher.setAAD(params);
35
76
  decipher.setAuthTag(tag);
77
+ return finish(decipher, ciphertext);
78
+ }
79
+
80
+ // Legacy format (zurik <= 1.0.5): base64( salt(16) | iv(16) | tag(16) | ciphertext ), default scrypt params.
81
+ async function decryptV1(base64, password) {
82
+ const buffer = Buffer.from(base64, "base64");
83
+ if (buffer.length < 48) {
84
+ throw new ZurikError("E_CORRUPT", "Corrupted .zurik file");
85
+ }
86
+ const salt = buffer.subarray(0, 16);
87
+ const iv = buffer.subarray(16, 32);
88
+ const tag = buffer.subarray(32, 48);
89
+ const ciphertext = buffer.subarray(48);
90
+
91
+ const key = await deriveKey(password, salt, { logN: 14, r: 8, p: 1 });
92
+ const decipher = crypto.createDecipheriv(ALGO, key, iv);
93
+ decipher.setAuthTag(tag);
94
+ return finish(decipher, ciphertext);
95
+ }
36
96
 
37
- return Buffer.concat([
38
- decipher.update(ciphertext),
39
- decipher.final()
40
- ]);
97
+ function finish(decipher, ciphertext) {
98
+ try {
99
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
100
+ } catch {
101
+ throw new ZurikError("E_WRONG_PASSWORD", "Decryption failed: wrong password or corrupted .zurik file");
102
+ }
41
103
  }
package/lib/decrypt.js CHANGED
@@ -1,19 +1,35 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { decryptData } from "./crypto.js";
3
+ import { ZurikError } from "./errors.js";
4
+ import { compareLocal, readBundle } from "./bundle.js";
4
5
 
5
- export default async function decryptFiles(password) {
6
- if (!fs.existsSync(".zurik")) {
7
- throw new Error(".zurik not found");
8
- }
6
+ const ACTION = { missing: "created", same: "unchanged", different: "updated" };
7
+
8
+ export default async function decryptFiles({ root, password, force = false, dryRun = false }) {
9
+ const entries = (await readBundle(root, password)).map(entry => ({
10
+ ...entry,
11
+ action: ACTION[compareLocal(root, entry.path, entry.content)],
12
+ }));
9
13
 
10
- const blob = fs.readFileSync(".zurik", "utf8");
11
- const jsonStr = await decryptData(blob, password);
12
- const data = JSON.parse(jsonStr);
14
+ const conflicts = entries.filter(e => e.action === "updated").map(e => e.path);
15
+ if (conflicts.length > 0 && !force) {
16
+ throw new ZurikError(
17
+ "E_WOULD_OVERWRITE",
18
+ `These local files differ from .zurik and would be overwritten (use --force):\n ${conflicts.join("\n ")}`,
19
+ { files: conflicts }
20
+ );
21
+ }
13
22
 
14
- for (const [filePath, base64] of Object.entries(data.files)) {
15
- const buffer = Buffer.from(base64, "base64");
16
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
17
- fs.writeFileSync(filePath, buffer);
23
+ if (!dryRun) {
24
+ for (const { path: relPath, content, mode, action } of entries) {
25
+ const abs = path.join(root, relPath);
26
+ if (action !== "unchanged") {
27
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
28
+ fs.writeFileSync(abs, content, { mode });
29
+ }
30
+ fs.chmodSync(abs, mode); // `mode` above only applies when the file is created
31
+ }
18
32
  }
33
+
34
+ return { files: entries.map(({ path, action }) => ({ path, action })) };
19
35
  }
package/lib/encrypt.js CHANGED
@@ -1,50 +1,68 @@
1
1
  import fs from "fs";
2
- import { encryptData } from "./crypto.js";
2
+ import path from "path";
3
+ import { encryptData, MIN_PASSWORD_LENGTH } from "./crypto.js";
4
+ import { ZurikError } from "./errors.js";
5
+ import { BUNDLE_FILE, LIST_FILE, readFileList } from "./bundle.js";
3
6
 
4
- export default async function encryptFiles(password) {
5
- const listFile = ".gitencrypt";
6
-
7
- if (!fs.existsSync(listFile)) {
8
- throw new Error(".gitencrypt not found");
7
+ export default async function encryptFiles({ root, password, dryRun = false }) {
8
+ // Only enforced when encrypting, so bundles made with shorter passwords still decrypt.
9
+ if ([...password].length < MIN_PASSWORD_LENGTH) {
10
+ throw new ZurikError(
11
+ "E_WEAK_PASSWORD",
12
+ `Password must be at least ${MIN_PASSWORD_LENGTH} characters. ` +
13
+ "Use a generated passphrase, e.g. from your password manager or `openssl rand -base64 24`."
14
+ );
9
15
  }
10
16
 
11
- const fileList = fs
12
- .readFileSync(listFile, "utf8")
13
- .split("\n")
14
- .map(x => x.trim())
15
- .filter(Boolean);
16
-
17
+ const fileList = readFileList(root);
18
+ if (fileList === null) {
19
+ throw new ZurikError("E_NO_LIST", `${LIST_FILE} not found in ${root}`);
20
+ }
17
21
  if (fileList.length === 0) {
18
- throw new Error("No files listed in .gitencrypt");
22
+ throw new ZurikError("E_EMPTY_LIST", `No files listed in ${LIST_FILE}`);
19
23
  }
20
24
 
21
- const bundle = { version: 1, files: {} };
25
+ // Refuse to write a partial bundle: it would silently overwrite secrets in the existing .zurik.
26
+ const missing = fileList.filter(f => !fs.existsSync(path.join(root, f)));
27
+ if (missing.length > 0) {
28
+ throw new ZurikError(
29
+ "E_MISSING_FILES",
30
+ `Missing files listed in ${LIST_FILE} (run 'zurik decrypt' first?):\n ${missing.join("\n ")}`,
31
+ { files: missing }
32
+ );
33
+ }
22
34
 
23
- for (const filePath of fileList) {
24
- if (!fs.existsSync(filePath)) {
25
- console.warn(`Skipping missing file: ${filePath}`);
26
- continue;
27
- }
35
+ // `modes` is optional so bundles stay readable by older zurik versions.
36
+ const bundle = { version: 1, files: {}, modes: {} };
28
37
 
29
- const content = fs.readFileSync(filePath);
30
- bundle.files[filePath] = content.toString("base64");
38
+ for (const filePath of fileList) {
39
+ const abs = path.join(root, filePath);
40
+ bundle.files[filePath] = fs.readFileSync(abs).toString("base64");
41
+ bundle.modes[filePath] = fs.statSync(abs).mode & 0o777;
31
42
  }
32
43
 
33
44
  const encrypted = await encryptData(JSON.stringify(bundle), password);
34
- fs.writeFileSync(".zurik", encrypted);
45
+ const gitignoreAdded = updateGitignore(root, fileList, dryRun);
46
+
47
+ if (!dryRun) {
48
+ fs.writeFileSync(path.join(root, BUNDLE_FILE), encrypted);
49
+ }
35
50
 
36
- updateGitignore(fileList);
51
+ return {
52
+ files: fileList.map(p => ({ path: p, action: "encrypted" })),
53
+ gitignoreAdded,
54
+ };
37
55
  }
38
56
 
39
- function updateGitignore(fileList) {
40
- const gi = ".gitignore";
41
- let text = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
57
+ // Appends listed files that .gitignore doesn't already contain as a whole line. Returns what was (or would be) added.
58
+ function updateGitignore(root, fileList, dryRun) {
59
+ const gi = path.join(root, ".gitignore");
60
+ const text = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
61
+ const lines = new Set(text.split("\n").map(x => x.trim()));
42
62
 
43
- for (const file of fileList) {
44
- if (!text.includes(file)) {
45
- text += `\n${file}`;
46
- }
63
+ const toAdd = fileList.filter(f => !lines.has(f) && !lines.has(`/${f}`));
64
+ if (toAdd.length > 0 && !dryRun) {
65
+ fs.writeFileSync(gi, [text.trimEnd(), ...toAdd].filter(Boolean).join("\n") + "\n");
47
66
  }
48
-
49
- fs.writeFileSync(gi, text.trim() + "\n");
67
+ return toAdd;
50
68
  }
package/lib/errors.js ADDED
@@ -0,0 +1,25 @@
1
+ // Stable error codes. Agents and scripts should branch on `code`, not on `message`.
2
+ export const ERROR_CODES = {
3
+ E_USAGE: "Invalid command line arguments",
4
+ E_NO_PASSWORD: "No password was provided",
5
+ E_WEAK_PASSWORD: "Password is too short to encrypt with",
6
+ E_WRONG_PASSWORD: "Wrong password, or the bundle was modified",
7
+ E_CORRUPT: "The .zurik file is not a valid bundle",
8
+ E_NOT_FOUND: "No .gitencrypt / .zurik found in this directory or any parent",
9
+ E_NO_LIST: ".gitencrypt is missing",
10
+ E_EMPTY_LIST: ".gitencrypt lists no files",
11
+ E_NO_BUNDLE: ".zurik is missing",
12
+ E_MISSING_FILES: "Files listed in .gitencrypt do not exist (details.files)",
13
+ E_WOULD_OVERWRITE: "Local files differ from the bundle; rerun with --force (details.files)",
14
+ E_UNSAFE_PATH: "A path is absolute or outside the project (details.path)",
15
+ E_INTERNAL: "Unexpected internal error",
16
+ };
17
+
18
+ export class ZurikError extends Error {
19
+ constructor(code, message, details = {}) {
20
+ super(message);
21
+ this.name = "ZurikError";
22
+ this.code = code;
23
+ this.details = details;
24
+ }
25
+ }
package/lib/index.js ADDED
@@ -0,0 +1,60 @@
1
+ // Programmatic API. Every function returns a plain result object and throws ZurikError
2
+ // (with a stable `code`) on failure. Nothing is printed and process.cwd() is never changed.
3
+ import path from "path";
4
+ import encryptFiles from "./encrypt.js";
5
+ import decryptFiles from "./decrypt.js";
6
+ import statusFiles from "./status.js";
7
+ import { BUNDLE_FILE, LIST_FILE } from "./bundle.js";
8
+ import { ZurikError, ERROR_CODES } from "./errors.js";
9
+ import { findProjectRoot } from "./paths.js";
10
+ import { MIN_PASSWORD_LENGTH } from "./crypto.js";
11
+
12
+ export { ZurikError, ERROR_CODES, MIN_PASSWORD_LENGTH };
13
+
14
+ const MARKERS = {
15
+ encrypt: [LIST_FILE],
16
+ decrypt: [BUNDLE_FILE],
17
+ status: [LIST_FILE, BUNDLE_FILE],
18
+ };
19
+
20
+ // `root` is used as-is; otherwise the nearest parent of `cwd` holding the command's marker file.
21
+ export function resolveRoot(command, { root, cwd = process.cwd() } = {}) {
22
+ if (root) return path.resolve(root);
23
+ const markers = MARKERS[command];
24
+ const found = findProjectRoot(markers, cwd);
25
+ if (!found) {
26
+ throw new ZurikError(
27
+ "E_NOT_FOUND",
28
+ `${markers.join(" or ")} not found in ${path.resolve(cwd)} or any parent directory`
29
+ );
30
+ }
31
+ return found;
32
+ }
33
+
34
+ function requirePassword(password) {
35
+ if (typeof password !== "string" || password.length === 0) {
36
+ throw new ZurikError("E_NO_PASSWORD", "A password is required");
37
+ }
38
+ }
39
+
40
+ /** Encrypt the files listed in .gitencrypt into .zurik. Options: { password, root?, cwd?, dryRun? } */
41
+ export async function encrypt(options = {}) {
42
+ requirePassword(options.password);
43
+ const root = resolveRoot("encrypt", options);
44
+ const dryRun = Boolean(options.dryRun);
45
+ return { command: "encrypt", root, dryRun, ...(await encryptFiles({ ...options, root, dryRun })) };
46
+ }
47
+
48
+ /** Decrypt .zurik into the working tree. Options: { password, root?, cwd?, force?, dryRun? } */
49
+ export async function decrypt(options = {}) {
50
+ requirePassword(options.password);
51
+ const root = resolveRoot("decrypt", options);
52
+ const dryRun = Boolean(options.dryRun);
53
+ return { command: "decrypt", root, dryRun, ...(await decryptFiles({ ...options, root, dryRun })) };
54
+ }
55
+
56
+ /** Compare .gitencrypt, .zurik and local files. The password is only needed when .zurik exists. */
57
+ export async function status(options = {}) {
58
+ const root = resolveRoot("status", options);
59
+ return { command: "status", root, ...(await statusFiles({ ...options, root })) };
60
+ }
package/lib/paths.js ADDED
@@ -0,0 +1,32 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { ZurikError } from "./errors.js";
4
+
5
+ // Walks up from `start` to the nearest directory containing any of `markers`; null if none.
6
+ export function findProjectRoot(markers, start = process.cwd()) {
7
+ let dir = path.resolve(start);
8
+ for (;;) {
9
+ if (markers.some(m => fs.existsSync(path.join(dir, m)))) return dir;
10
+ const parent = path.dirname(dir);
11
+ if (parent === dir) return null;
12
+ dir = parent;
13
+ }
14
+ }
15
+
16
+ // Normalizes a bundle path and ensures it stays inside `root`. Returns a "/"-separated relative path.
17
+ export function safeRelativePath(filePath, root) {
18
+ if (typeof filePath !== "string" || filePath.length === 0 || path.isAbsolute(filePath)) {
19
+ throw new ZurikError("E_UNSAFE_PATH", `Refusing unsafe path: ${filePath}`, { path: filePath });
20
+ }
21
+
22
+ const resolved = path.resolve(root, filePath);
23
+ const rel = path.relative(root, resolved);
24
+
25
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
26
+ throw new ZurikError("E_UNSAFE_PATH", `Refusing path outside project directory: ${filePath}`, {
27
+ path: filePath,
28
+ });
29
+ }
30
+
31
+ return rel.split(path.sep).join("/");
32
+ }
package/lib/status.js ADDED
@@ -0,0 +1,39 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { BUNDLE_FILE, compareLocal, readBundle, readFileList } from "./bundle.js";
4
+ import { ZurikError } from "./errors.js";
5
+
6
+ // Per-file state, comparing .gitencrypt, .zurik and the working tree. Never reveals file contents.
7
+ // in-sync local file matches the bundle
8
+ // modified local file differs from the bundle -> encrypt to save it, or decrypt --force to discard
9
+ // missing-locally in the bundle, not on disk -> decrypt
10
+ // not-in-bundle listed and on disk, not in bundle -> encrypt
11
+ // missing listed, but neither on disk nor in the bundle
12
+ // `listed` is false for files in the bundle that were removed from .gitencrypt (the next encrypt drops them).
13
+ export default async function status({ root, password }) {
14
+ const listed = new Set(readFileList(root) ?? []);
15
+ const bundleExists = fs.existsSync(path.join(root, BUNDLE_FILE));
16
+ if (bundleExists && (typeof password !== "string" || password.length === 0)) {
17
+ throw new ZurikError("E_NO_PASSWORD", `A password is required to compare against ${BUNDLE_FILE}`);
18
+ }
19
+ const bundle = bundleExists ? await readBundle(root, password) : [];
20
+ const inBundle = new Map(bundle.map(e => [e.path, e.content]));
21
+
22
+ const STATE = { missing: "missing-locally", same: "in-sync", different: "modified" };
23
+ const paths = [...new Set([...listed, ...inBundle.keys()])].sort();
24
+
25
+ const files = paths.map(p => {
26
+ const state = inBundle.has(p)
27
+ ? STATE[compareLocal(root, p, inBundle.get(p))]
28
+ : fs.existsSync(path.join(root, p))
29
+ ? "not-in-bundle"
30
+ : "missing";
31
+ return { path: p, state, listed: listed.has(p) };
32
+ });
33
+
34
+ return {
35
+ bundleExists,
36
+ inSync: files.length > 0 && files.every(f => f.state === "in-sync" && f.listed),
37
+ files,
38
+ };
39
+ }
package/package.json CHANGED
@@ -1,10 +1,26 @@
1
1
  {
2
2
  "name": "zurik",
3
- "version": "1.0.3",
3
+ "version": "2.0.0",
4
4
  "description": "CLI tool for encrypting and decrypting sensitive project files into a single .zurik bundle.",
5
5
  "type": "module",
6
+ "main": "./lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./package.json": "./package.json"
10
+ },
6
11
  "bin": {
7
- "zurik": "./bin/zurik.js"
12
+ "zurik": "bin/zurik.js"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "lib",
17
+ "skills"
18
+ ],
19
+ "scripts": {
20
+ "test": "node --test"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
8
24
  },
9
25
  "keywords": [
10
26
  "encryption",
@@ -13,12 +29,16 @@
13
29
  "aes",
14
30
  "gcm",
15
31
  "git",
16
- "files"
32
+ "files",
33
+ "secrets",
34
+ "dotenv",
35
+ "ai-agents",
36
+ "automation"
17
37
  ],
18
38
  "author": "Safi",
19
39
  "repository": {
20
40
  "type": "git",
21
- "url": "git@github.com:apotox/zurik.git"
41
+ "url": "git+ssh://git@github.com/apotox/zurik.git"
22
42
  },
23
43
  "license": "MIT"
24
44
  }
package/readme.md CHANGED
@@ -6,47 +6,156 @@ It collects file paths from `.gitencrypt`, bundles them into a single `.zurik` f
6
6
  ## Features
7
7
 
8
8
  - Encrypt multiple files into a **single** `.zurik` bundle
9
- - AES-256-GCM authenticated encryption
10
- - Automatically append sensitive files to `.gitignore` when encrypting.
9
+ - AES-256-GCM authenticated encryption with a scrypt-derived key
10
+ - Automatically appends sensitive files to `.gitignore` when encrypting
11
+ - Preserves file permissions; never overwrites changed local files without `--force`
12
+ - Built for automation and AI agents: `--json` output, stable error codes, `status` and `--dry-run`, never blocks on a prompt, and a library API
11
13
 
12
14
  ## Installation
13
15
 
14
- You can run it directly using `npx`:
16
+ Requires Node.js 18 or newer. Run it directly with `npx`, or install it globally:
15
17
 
16
18
  ```sh
17
- export ZURIK_PASSWORD="your-secret"
18
- npx zurik -e
19
-
20
- # or install it globally
19
+ npx zurik help
21
20
  npm install -g zurik
21
+ ```
22
+
23
+ ## Choosing a password
24
+
25
+ Anyone who obtains `.zurik` can try passwords offline, as fast as their hardware allows. zurik makes each guess expensive (scrypt with N=2¹⁷, ~128 MiB of memory per guess), but only a strong password actually protects the files.
22
26
 
23
- # also you can pass the password as an argument
24
- npx zurik -e -p "your-password"
27
+ - Passwords must be at least **16 characters** to encrypt.
28
+ - Use a randomly generated one, stored in a password manager or your CI secret store:
25
29
 
26
- # or you can type the password interactively
27
- npx zurik -e -p
30
+ ```sh
31
+ openssl rand -base64 24
32
+ ```
33
+
34
+ ## Providing the password
35
+
36
+ In order of preference:
37
+
38
+ ```sh
39
+ # 1. interactive prompt (zurik prompts automatically in a terminal when no other source is set)
40
+ npx zurik encrypt
41
+
42
+ # 2. environment variable or a file, e.g. in CI or an agent's environment
43
+ export ZURIK_PASSWORD="your-secret" # or: export ZURIK_PASSWORD_FILE=~/.secrets/zurik
44
+ npx zurik encrypt
45
+ npx zurik encrypt --password-file ~/.secrets/zurik
46
+
47
+ # 3. piped from a password manager or secret store
48
+ pass show my-project/zurik | npx zurik decrypt --password-stdin
49
+
50
+ # 4. inline (avoid: visible in `ps` and saved in shell history)
51
+ npx zurik encrypt -p "your-password"
28
52
  ```
29
53
 
54
+ zurik never prompts when stdin is not a terminal, or with `--json`, `--no-input` or `$CI` set; it fails with `E_NO_PASSWORD` instead of hanging.
55
+
30
56
  ## Usage
57
+
31
58
  ### Encrypt files
32
- - create *.gitencrypt* file in the root of your project
33
- - add paths to files you want to encrypt, one per line
34
- - for example:
35
- ```txt
36
- # .gitencrypt
37
- src/config.json
38
- src/secret.txt
39
- ```
40
- - run the command below
59
+
60
+ 1. Create a `.gitencrypt` file in the root of your project.
61
+ 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
+
63
+ ```txt
64
+ # secrets for local development
65
+ src/config.json
66
+ src/secret.txt
67
+ ```
68
+
69
+ 3. Run:
70
+
71
+ ```sh
72
+ npx zurik encrypt
73
+ ```
74
+
75
+ The files are encrypted into `.zurik`, and any that are not already listed in `.gitignore` are appended to it. Commit `.zurik` and `.gitencrypt`.
76
+
77
+ Every listed file must exist. If any is missing, nothing is written, so an existing `.zurik` is never overwritten with a partial bundle. On a fresh clone, run `zurik decrypt` first.
78
+
79
+ ### Decrypt files
80
+
41
81
  ```sh
42
- npx zurik -e -p "your-password"
82
+ npx zurik decrypt
43
83
  ```
44
- - the encrypted files will be saved in `.zurik` file
45
- - it will also add the encrypted files to `.gitignore` if they are not already ignored
46
84
 
47
- ### Decrypt files
48
- - run the command below to decrypt the files
85
+ Files are restored to their original paths with their original permissions. If a local file has changed since it was encrypted, zurik lists the affected files and stops; pass `--force` to overwrite them.
86
+
87
+ ### Check status
88
+
89
+ ```sh
90
+ npx zurik status
91
+ ```
92
+
93
+ Shows, for every file, whether it is `in-sync`, `modified`, `missing-locally`, `not-in-bundle` or `missing`, without writing anything or printing contents. The password is only needed once `.zurik` exists.
94
+
95
+ Add `--dry-run` (`-n`) to `encrypt` or `decrypt` to see what would happen without writing anything.
96
+
97
+ You can run zurik from any subdirectory: it uses the nearest parent directory containing `.gitencrypt` (encrypt), `.zurik` (decrypt) or either (status). `--cwd <dir>` runs it against another directory.
98
+
99
+ The short flags from earlier versions still work: `-e` (encrypt), `-d` (decrypt), `-s` (status).
100
+
101
+ ## Use with AI agents and automation
102
+
103
+ zurik lets an agent manage secrets without the secrets, or the password, ever entering its context: it never prints file contents, and the password comes from the environment.
104
+
105
+ **Give your agent the skill.** `zurik skill` prints a `SKILL.md` that tells an agent when to use zurik, how to supply the password safely, and how to handle each error (including asking before `--force`):
106
+
107
+ ```sh
108
+ # Claude Code (project-level; use ~/.claude/skills for all projects)
109
+ mkdir -p .claude/skills/zurik && npx zurik skill > .claude/skills/zurik/SKILL.md
110
+ ```
111
+
112
+ For other agents, add the output to `AGENTS.md`, or add this line:
113
+
114
+ ```md
115
+ Secrets are managed with zurik (.gitencrypt / .zurik). Run `npx zurik skill` for instructions; never read or print secret files or the password.
116
+ ```
117
+
118
+ **Provide the password** through `ZURIK_PASSWORD` or `ZURIK_PASSWORD_FILE` in the agent's environment, never in the prompt.
119
+
120
+ **Machine-readable interface.** With `--json`, stdout is exactly one JSON object:
121
+
122
+ ```sh
123
+ $ npx zurik status --json
124
+ {"ok":true,"command":"status","root":"/repo","bundleExists":true,"inSync":false,
125
+ "files":[{"path":".env","state":"modified","listed":true}]}
126
+
127
+ $ npx zurik decrypt --json
128
+ {"ok":false,"error":{"code":"E_WOULD_OVERWRITE","message":"...","details":{"files":[".env"]}}}
129
+ ```
130
+
131
+ Branch on `error.code`. Exit codes are `0` success, `1` error and `2` usage error. `npx zurik help --json` prints the full spec: commands, result shapes, password sources and every error code.
132
+
133
+ **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
+
135
+ ```js
136
+ import { encrypt, decrypt, status, ZurikError } from "zurik";
137
+
138
+ const s = await status({ cwd: "/repo", password: process.env.ZURIK_PASSWORD });
139
+ if (!s.inSync) {
140
+ try {
141
+ await decrypt({ root: "/repo", password: process.env.ZURIK_PASSWORD });
142
+ } catch (err) {
143
+ if (err instanceof ZurikError && err.code === "E_WOULD_OVERWRITE") console.log(err.details.files);
144
+ else throw err;
145
+ }
146
+ }
147
+ ```
148
+
149
+ Options: `password`, `cwd` (search for the project root from here) or `root` (use exactly), `force` and `dryRun`.
150
+
151
+ ## Bundle format
152
+
153
+ `.zurik` is a text file: `zurik2:` followed by base64 of `logN | r | p | salt (16) | iv (12) | tag (16) | ciphertext`. The scrypt parameters are authenticated as AES-GCM additional data, so they cannot be altered to weaken the key.
154
+
155
+ Bundles written by zurik 1.0.5 and earlier (no `zurik2:` prefix) still decrypt, including those with passwords shorter than 16 characters. The next `zurik encrypt` rewrites them in the new format, and at that point the password must meet the minimum length.
156
+
157
+ ## Development
158
+
49
159
  ```sh
50
- npx zurik -d -p "your-password"
160
+ npm test
51
161
  ```
52
- - the decrypted files will be saved in the same directory as the original files
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: zurik
3
+ description: Encrypt, decrypt and check project secrets (.env files, keys, config) that are stored in a .zurik bundle and listed in .gitencrypt. Use whenever a repo contains .zurik or .gitencrypt, when secret files are missing after a clone, when the user asks to encrypt/commit/share/restore secrets, or before editing or committing a file listed in .gitencrypt. Prefer it over reading, copying, printing or hand-encrypting secret files.
4
+ ---
5
+
6
+ # zurik
7
+
8
+ zurik keeps a project's secret files encrypted in one committed file, `.zurik`. The plaintext files are listed in `.gitencrypt` and are git-ignored. zurik never prints file contents, so you can manage secrets without them entering your context.
9
+
10
+ Run it with `npx zurik` (or `zurik` if it's installed). Always pass `--json` and branch on `ok` / `error.code`, never on message text.
11
+
12
+ ## Commands
13
+
14
+ | Goal | Command |
15
+ |---|---|
16
+ | See what's out of sync (safe, read-only) | `npx zurik status --json` |
17
+ | Restore secrets after a clone | `npx zurik decrypt --json` |
18
+ | Save changes to secret files into `.zurik` | `npx zurik encrypt --json` |
19
+ | Preview without writing anything | add `--dry-run` |
20
+ | Machine-readable spec of all options and codes | `npx zurik help --json` |
21
+
22
+ `--cwd <dir>` runs against another directory. zurik uses the nearest parent directory containing `.gitencrypt` / `.zurik`.
23
+
24
+ ## Password: never put it in your context
25
+
26
+ - Do **not** ask the user to paste the password into the chat. Do **not** use `-p <password>` or `--password <value>`: that puts it in the transcript, the shell history and the process list.
27
+ - Use what the environment already provides, in this order: `$ZURIK_PASSWORD`, `$ZURIK_PASSWORD_FILE`, or `--password-file <path>` / `--password-stdin` piped from a secret manager the user names (e.g. `op read ... | npx zurik decrypt --password-stdin --json`).
28
+ - On `E_NO_PASSWORD`, stop and tell the user to set `ZURIK_PASSWORD` (or `ZURIK_PASSWORD_FILE`) in the environment the agent runs in. Don't guess or generate one for an existing bundle.
29
+ - zurik never prompts when stdin isn't a terminal or when `--json` is set, so it won't hang.
30
+
31
+ ## Workflow
32
+
33
+ 1. `status --json` first. States: `in-sync`, `modified` (local differs from bundle), `missing-locally` (run decrypt), `not-in-bundle` (run encrypt), `missing` (listed but absent everywhere). `listed: false` means it's in the bundle but was removed from `.gitencrypt`.
34
+ 2. After a clone or when files are `missing-locally`: `decrypt --json`.
35
+ 3. After you or the user changed a secret file: `encrypt --json`, then commit `.zurik` (and `.gitencrypt` / `.gitignore` if they changed). Never commit the plaintext files.
36
+ 4. To protect a new secret file: append its path to `.gitencrypt`, then `encrypt --json`. zurik adds it to `.gitignore` for you.
37
+
38
+ ## Errors
39
+
40
+ | `error.code` | What to do |
41
+ |---|---|
42
+ | `E_NO_PASSWORD` | Ask the user to provide the password via the environment (see above). |
43
+ | `E_WRONG_PASSWORD` | Wrong password or tampered bundle. Tell the user; don't retry with guesses. |
44
+ | `E_WOULD_OVERWRITE` | `error.details.files` have local changes. **Ask the user** before rerunning with `--force`; it discards those changes. If they should be kept, run `encrypt` instead. |
45
+ | `E_MISSING_FILES` | `error.details.files` are listed but absent. Usually run `decrypt` first, or remove them from `.gitencrypt` if they're obsolete. |
46
+ | `E_WEAK_PASSWORD` | Encrypting needs 16+ characters. The user must choose a longer password. |
47
+ | `E_NOT_FOUND`, `E_NO_LIST`, `E_NO_BUNDLE`, `E_EMPTY_LIST` | Nothing to work on at this location; check `--cwd` or set up `.gitencrypt`. |
48
+ | `E_UNSAFE_PATH` | A path is absolute or outside the project. Fix `.gitencrypt`; for a bundle, treat it as suspicious and tell the user. |
49
+
50
+ Exit codes: `0` success, `1` error, `2` usage error.
51
+
52
+ ## Rules
53
+
54
+ - Don't `cat`, print, summarize or copy decrypted secret files unless the user explicitly asks to see them. Edit them in place when a task requires it.
55
+ - Don't use `--force` without the user's go-ahead.
56
+ - Don't delete `.zurik`, and don't remove entries from `.gitencrypt` unless asked.
package/.gitencrypt DELETED
File without changes
@@ -1,31 +0,0 @@
1
- name: Publish to npm
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v*.*.*'
7
-
8
- jobs:
9
- publish:
10
- runs-on: ubuntu-latest
11
-
12
- steps:
13
- - name: Checkout
14
- uses: actions/checkout@v4
15
-
16
- - name: Set up Node
17
- uses: actions/setup-node@v4
18
- with:
19
- node-version: 20
20
- registry-url: 'https://registry.npmjs.org'
21
-
22
- - name: Install deps
23
- run: npm ci
24
-
25
- - name: Build
26
- run: npm run build --if-present
27
-
28
- - name: Publish
29
- env:
30
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
31
- run: npm publish --access public