sealcode 0.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/src/seal.js ADDED
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const fg = require('fast-glob');
6
+ const { seal, opaqueName, sha256Hex } = require('./crypto');
7
+ const { writeManifest } = require('./manifest');
8
+ const {
9
+ SALT_NAME,
10
+ WRAP_PASS_NAME,
11
+ WRAP_RECOVERY_NAME,
12
+ MANIFEST_NAME,
13
+ persistBootstrap,
14
+ } = require('./keystore');
15
+ const {
16
+ ensureDir,
17
+ rmIfExists,
18
+ pruneEmptyDirs,
19
+ writeFileEnsuringDir,
20
+ nowIso,
21
+ normPath,
22
+ } = require('./util');
23
+
24
+ async function collectFiles(projectRoot, cfg) {
25
+ const matches = await fg(cfg.include, {
26
+ cwd: projectRoot,
27
+ ignore: cfg.exclude,
28
+ dot: true,
29
+ onlyFiles: true,
30
+ followSymbolicLinks: false,
31
+ suppressErrors: true,
32
+ });
33
+ return matches.map(normPath).sort();
34
+ }
35
+
36
+ function applyStubs(projectRoot, stubs) {
37
+ const written = {};
38
+ for (const [relPath, value] of Object.entries(stubs || {})) {
39
+ const target = path.join(projectRoot, relPath);
40
+ let contents;
41
+ let mode;
42
+ if (typeof value === 'string') {
43
+ contents = value;
44
+ } else if (value && typeof value === 'object' && typeof value.contents === 'string') {
45
+ contents = value.contents;
46
+ mode = value.mode;
47
+ } else {
48
+ throw new Error(`stub for ${relPath} must be string or { contents, mode? }`);
49
+ }
50
+ writeFileEnsuringDir(target, contents, mode);
51
+ written[relPath] = true;
52
+ }
53
+ return written;
54
+ }
55
+
56
+ /**
57
+ * Run lock (a.k.a. seal). Removes plaintext after writing the locked dir.
58
+ *
59
+ * @param {Object} opts
60
+ * @param {string} opts.projectRoot
61
+ * @param {Object} opts.config
62
+ * @param {Buffer} opts.K
63
+ * @param {Object} [opts.bootstrapOutput] - first-time init: { salt, wrappedPass, wrappedRecovery }
64
+ * @param {boolean} [opts.keepOriginals=false]
65
+ * @param {(msg:string)=>void} [opts.log]
66
+ */
67
+ async function runLock({
68
+ projectRoot,
69
+ config,
70
+ K,
71
+ bootstrapOutput,
72
+ keepOriginals = false,
73
+ log = () => {},
74
+ }) {
75
+ const lockedDir = config.lockedDir;
76
+ const lockedRoot = path.join(projectRoot, lockedDir);
77
+
78
+ const files = await collectFiles(projectRoot, config);
79
+ if (files.length === 0) {
80
+ throw new Error('SEALCODE_NOTHING_TO_LOCK');
81
+ }
82
+
83
+ // Preserve any existing keystore files when re-locking. On first init we
84
+ // wipe and persist the bootstrap output fresh.
85
+ const existingSalt = !bootstrapOutput && fs.existsSync(path.join(lockedRoot, SALT_NAME));
86
+ const existingPassWrap = !bootstrapOutput && fs.existsSync(path.join(lockedRoot, WRAP_PASS_NAME));
87
+ const existingRecoveryWrap =
88
+ !bootstrapOutput && fs.existsSync(path.join(lockedRoot, WRAP_RECOVERY_NAME));
89
+
90
+ const saved = {};
91
+ if (existingSalt) saved[SALT_NAME] = fs.readFileSync(path.join(lockedRoot, SALT_NAME));
92
+ if (existingPassWrap) saved[WRAP_PASS_NAME] = fs.readFileSync(path.join(lockedRoot, WRAP_PASS_NAME));
93
+ if (existingRecoveryWrap)
94
+ saved[WRAP_RECOVERY_NAME] = fs.readFileSync(path.join(lockedRoot, WRAP_RECOVERY_NAME));
95
+
96
+ rmIfExists(lockedRoot);
97
+ ensureDir(lockedRoot);
98
+
99
+ // Restore the keystore so the passphrase keeps working across re-locks.
100
+ for (const [name, buf] of Object.entries(saved)) {
101
+ const p = path.join(lockedRoot, name);
102
+ ensureDir(path.dirname(p));
103
+ fs.writeFileSync(p, buf);
104
+ }
105
+ if (bootstrapOutput) {
106
+ persistBootstrap(projectRoot, lockedDir, bootstrapOutput);
107
+ }
108
+
109
+ const manifestFiles = [];
110
+ const usedLockedNames = new Map();
111
+ const reservedNames = new Set([SALT_NAME, WRAP_PASS_NAME, WRAP_RECOVERY_NAME, MANIFEST_NAME]);
112
+
113
+ for (const rel of files) {
114
+ const abs = path.join(projectRoot, rel);
115
+ const stat = fs.statSync(abs);
116
+ const bytes = fs.readFileSync(abs);
117
+
118
+ const lockedRel = opaqueName(rel, K);
119
+ if (reservedNames.has(lockedRel)) {
120
+ // Astronomically unlikely (HMAC collision with public sentinel), but bail safely.
121
+ throw new Error(`opaque name collision with keystore sentinel for ${rel}`);
122
+ }
123
+ if (usedLockedNames.has(lockedRel) && usedLockedNames.get(lockedRel) !== rel) {
124
+ throw new Error(
125
+ `opaque-name collision for ${rel} vs ${usedLockedNames.get(lockedRel)} — please file a bug`
126
+ );
127
+ }
128
+ usedLockedNames.set(lockedRel, rel);
129
+
130
+ const lockedAbs = path.join(lockedRoot, lockedRel);
131
+ ensureDir(path.dirname(lockedAbs));
132
+ fs.writeFileSync(lockedAbs, seal(bytes, K));
133
+
134
+ manifestFiles.push({
135
+ p: rel,
136
+ l: lockedRel,
137
+ h: sha256Hex(bytes),
138
+ m: stat.mode & 0o777,
139
+ s: bytes.length,
140
+ });
141
+ log(` locked ${rel}`);
142
+ }
143
+
144
+ const stubsApplied = applyStubs(projectRoot, config.stubs);
145
+
146
+ const manifest = {
147
+ v: 1,
148
+ sealedAt: nowIso(),
149
+ files: manifestFiles,
150
+ stubs: stubsApplied,
151
+ lockedDir,
152
+ };
153
+ writeManifest(projectRoot, lockedDir, manifest, K);
154
+
155
+ if (!keepOriginals) {
156
+ const stubSet = new Set(Object.keys(stubsApplied));
157
+ const dirsTouched = new Set();
158
+ for (const rel of files) {
159
+ if (stubSet.has(rel)) continue;
160
+ const abs = path.join(projectRoot, rel);
161
+ try {
162
+ fs.unlinkSync(abs);
163
+ dirsTouched.add(path.dirname(abs));
164
+ } catch (_) {
165
+ /* manifest is authoritative */
166
+ }
167
+ }
168
+ for (const d of dirsTouched) pruneEmptyDirs(d, projectRoot);
169
+ }
170
+
171
+ return { count: manifestFiles.length, lockedRoot, stubs: stubsApplied };
172
+ }
173
+
174
+ module.exports = { runLock, collectFiles };
package/src/status.js ADDED
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `sealcode status` — answers two questions at a glance:
5
+ * 1. What state is this project in? (initialized? unlocked? locked?)
6
+ * 2. Has my local working tree drifted from the locked snapshot?
7
+ *
8
+ * Drift detection lets us warn: "You have 4 unsaved edits — run `sealcode
9
+ * lock` before committing." This is the single feature that prevents the
10
+ * most common footgun: committing a stale `vendor/` while plaintext sits
11
+ * one folder over.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { readManifest } = require('./manifest');
17
+ const { isInitialized, loadSession } = require('./keystore');
18
+ const { sha256Hex } = require('./crypto');
19
+ const { collectFiles } = require('./seal');
20
+
21
+ /**
22
+ * Compute drift between the manifest on disk and plaintext files (unlocked tree).
23
+ * @param {string} projectRoot
24
+ * @param {object} config
25
+ * @param {Buffer} K
26
+ * @returns {Promise<{ added: string[], modified: string[], removed: string[] }>}
27
+ */
28
+ async function computeDrift(projectRoot, config, K) {
29
+ const lockedDir = config.lockedDir;
30
+ const manifest = readManifest(projectRoot, lockedDir, K);
31
+ const stubKeys = new Set(Object.keys(manifest.stubs || {}));
32
+ const expected = new Map(manifest.files.map((f) => [f.p, f]));
33
+
34
+ const onDisk = await collectFiles(projectRoot, config);
35
+ const added = [];
36
+ const modified = [];
37
+ const removed = [];
38
+
39
+ for (const rel of onDisk) {
40
+ if (stubKeys.has(rel)) continue;
41
+ const entry = expected.get(rel);
42
+ if (!entry) {
43
+ added.push(rel);
44
+ continue;
45
+ }
46
+ const abs = path.join(projectRoot, rel);
47
+ const h = sha256Hex(fs.readFileSync(abs));
48
+ if (h !== entry.h) modified.push(rel);
49
+ }
50
+ for (const [rel] of expected) {
51
+ if (stubKeys.has(rel)) continue;
52
+ if (!onDisk.includes(rel)) removed.push(rel);
53
+ }
54
+ return { added, modified, removed };
55
+ }
56
+
57
+ async function runStatus({ projectRoot, config }) {
58
+ const lockedDir = config.lockedDir;
59
+ const lockedRoot = path.join(projectRoot, lockedDir);
60
+ const initialized = isInitialized(projectRoot, lockedDir);
61
+
62
+ const status = {
63
+ initialized,
64
+ lockedDir,
65
+ state: 'unknown',
66
+ lockedFileCount: 0,
67
+ onDiskFileCount: 0,
68
+ drift: null, // populated if we have K via session
69
+ sessionActive: false,
70
+ };
71
+
72
+ if (!initialized) {
73
+ status.state = 'uninitialized';
74
+ return status;
75
+ }
76
+
77
+ // Count locked blobs by reading the locked dir directly. We don't need K
78
+ // to do this — it's a quick file-system head count.
79
+ let locked = 0;
80
+ if (fs.existsSync(lockedRoot)) {
81
+ (function walk(dir) {
82
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
83
+ const full = path.join(dir, e.name);
84
+ if (e.isDirectory()) walk(full);
85
+ else locked++;
86
+ }
87
+ })(lockedRoot);
88
+ }
89
+ status.lockedFileCount = locked;
90
+
91
+ const onDisk = await collectFiles(projectRoot, config);
92
+ status.onDiskFileCount = onDisk.length;
93
+
94
+ const K = loadSession(projectRoot);
95
+ status.sessionActive = K != null;
96
+
97
+ // For the locked/unlocked decision we discount any files that are explicitly
98
+ // declared as stubs in the config — those are placed BY the lock command and
99
+ // their presence doesn't mean the project is unlocked.
100
+ const stubSet = new Set(Object.keys(config.stubs || {}));
101
+ const nonStubOnDisk = onDisk.filter((p) => !stubSet.has(p));
102
+
103
+ if (nonStubOnDisk.length === 0) {
104
+ status.state = 'locked';
105
+ } else if (locked === 0) {
106
+ status.state = 'unlocked-no-vault';
107
+ } else {
108
+ status.state = 'unlocked';
109
+ }
110
+
111
+ // Drift only makes sense when the project is unlocked. In the locked state
112
+ // the manifest's `files` deliberately aren't on disk — reporting that as
113
+ // "removed" would be misleading.
114
+ if (K && status.state === 'unlocked') {
115
+ try {
116
+ status.drift = await computeDrift(projectRoot, config, K);
117
+ } catch (_) {
118
+ status.sessionActive = false;
119
+ }
120
+ }
121
+
122
+ return status;
123
+ }
124
+
125
+ function renderStatus(status) {
126
+ const lines = [];
127
+ if (!status.initialized) {
128
+ lines.push(' ⚠ No vault here yet.');
129
+ lines.push(' Run: sealcode init');
130
+ return lines.join('\n');
131
+ }
132
+ const state = status.state;
133
+ const stateIcon =
134
+ state === 'locked' ? '🔒' : state === 'unlocked' ? '🔓' : state === 'unlocked-no-vault' ? '?' : '·';
135
+ lines.push(` ${stateIcon} ${state}`);
136
+ lines.push(` locked blobs: ${status.lockedFileCount}`);
137
+ if (status.state !== 'locked') {
138
+ lines.push(` plaintext files: ${status.onDiskFileCount}`);
139
+ }
140
+ lines.push(` session: ${status.sessionActive ? 'active (no passphrase needed)' : 'idle'}`);
141
+ if (status.drift) {
142
+ const { added, modified, removed } = status.drift;
143
+ const total = added.length + modified.length + removed.length;
144
+ if (total === 0) {
145
+ lines.push(' drift: none — locked snapshot matches working tree');
146
+ } else {
147
+ lines.push(` drift: ${total} change(s) vs last lock`);
148
+ if (added.length) lines.push(` + ${added.length} added`);
149
+ if (modified.length) lines.push(` ~ ${modified.length} modified`);
150
+ if (removed.length) lines.push(` - ${removed.length} removed`);
151
+ lines.push(' → run `sealcode lock` before committing');
152
+ }
153
+ }
154
+ return lines.join('\n');
155
+ }
156
+
157
+ /**
158
+ * Pre-commit / CI gate: pass only if locked, or unlocked with no drift.
159
+ * Caller supplies `getK` — session load, env passphrase unwrap, or null.
160
+ * @param {( ) => Promise<Buffer|null>} getK
161
+ */
162
+ async function runPrecommitCheck(projectRoot, config, getK) {
163
+ const s = await runStatus({ projectRoot, config });
164
+ if (!s.initialized) return { ok: true, message: '' };
165
+ if (s.state === 'locked') return { ok: true, message: '' };
166
+ if (s.state === 'unlocked-no-vault') {
167
+ return {
168
+ ok: false,
169
+ message:
170
+ 'sealcode: locked directory is missing. Restore `vendor/` (or your preset folder) before committing.',
171
+ };
172
+ }
173
+
174
+ const K = await getK();
175
+ if (!K) {
176
+ return {
177
+ ok: false,
178
+ message:
179
+ 'sealcode: project is unlocked but there is no session key.\n' +
180
+ ' Fix: run `sealcode unlock` once in this terminal, or set SEALCODE_PASSPHRASE,\n' +
181
+ ' or run `sealcode lock` to seal before you commit.',
182
+ };
183
+ }
184
+
185
+ let drift;
186
+ try {
187
+ drift = await computeDrift(projectRoot, config, K);
188
+ } catch (err) {
189
+ return {
190
+ ok: false,
191
+ message: `sealcode: cannot read manifest (${err.message}). Try \`sealcode logout\` then \`sealcode unlock\`.`,
192
+ };
193
+ }
194
+
195
+ const total = drift.added.length + drift.modified.length + drift.removed.length;
196
+ if (total > 0) {
197
+ return {
198
+ ok: false,
199
+ message:
200
+ `sealcode: working tree drift vs last lock (+${drift.added.length} ~${drift.modified.length} -${drift.removed.length}).\n` +
201
+ ' Run `sealcode lock` then stage the updated blobs, or discard changes.',
202
+ };
203
+ }
204
+ return { ok: true, message: '' };
205
+ }
206
+
207
+ module.exports = { runStatus, renderStatus, computeDrift, runPrecommitCheck };
package/src/ui.js ADDED
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Tiny zero-dep UI helpers for the sealcode CLI.
5
+ *
6
+ * Design rules:
7
+ * - Respect `NO_COLOR` and `!process.stdout.isTTY` — degrade to plain text.
8
+ * - Spinners/progress write to stderr so `sealcode status | grep …` still works.
9
+ * - Never throw from a UI helper; UI noise must not crash a real command.
10
+ * - Use direct ANSI escapes; do not pull in chalk/ora/boxen/cli-progress.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+
17
+ const STDOUT_TTY = process.stdout.isTTY === true;
18
+ const STDERR_TTY = process.stderr.isTTY === true;
19
+ const USE_COLOR = STDOUT_TTY && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
20
+
21
+ function wrap(code, str) {
22
+ return USE_COLOR ? `\x1b[${code}m${str}\x1b[0m` : String(str);
23
+ }
24
+
25
+ const c = {
26
+ dim: (s) => wrap('2', s),
27
+ bold: (s) => wrap('1', s),
28
+ green: (s) => wrap('32', s),
29
+ red: (s) => wrap('31', s),
30
+ yellow: (s) => wrap('33', s),
31
+ cyan: (s) => wrap('36', s),
32
+ magenta: (s) => wrap('35', s),
33
+ blue: (s) => wrap('34', s),
34
+ gray: (s) => wrap('90', s),
35
+ };
36
+
37
+ const sym = {
38
+ ok: USE_COLOR ? c.green('✓') : 'OK ',
39
+ err: USE_COLOR ? c.red('✗') : 'ERR',
40
+ warn: USE_COLOR ? c.yellow('⚠') : '! ',
41
+ arrow: USE_COLOR ? c.cyan('→') : '->',
42
+ bullet: '•',
43
+ spin: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
44
+ };
45
+
46
+ function say(line = '') { process.stdout.write(line + '\n'); }
47
+ function ok(msg) { say(`${sym.ok} ${msg}`); }
48
+ function fail(msg) { say(`${sym.err} ${msg}`); }
49
+ function warn(msg) { say(`${sym.warn} ${msg}`); }
50
+ function hint(msg) { say(c.dim(msg)); }
51
+ function step(msg) { say(`${sym.arrow} ${msg}`); }
52
+
53
+ function stripAnsi(s) {
54
+ return String(s).replace(/\x1b\[[0-9;]*m/g, '');
55
+ }
56
+
57
+ function visibleLen(s) {
58
+ return stripAnsi(s).length;
59
+ }
60
+
61
+ /** Animated spinner. No-op in non-TTY env (just prints a single line). */
62
+ class Spinner {
63
+ constructor(label) {
64
+ this.label = label;
65
+ this.frame = 0;
66
+ this.timer = null;
67
+ this.startTime = 0;
68
+ }
69
+ start() {
70
+ if (!STDERR_TTY) {
71
+ process.stderr.write(`${this.label}...\n`);
72
+ return this;
73
+ }
74
+ this.startTime = Date.now();
75
+ process.stderr.write('\x1b[?25l'); // hide cursor
76
+ const tick = () => {
77
+ const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
78
+ const glyph = USE_COLOR ? c.cyan(sym.spin[this.frame % sym.spin.length]) : '*';
79
+ process.stderr.write(
80
+ `\r${glyph} ${this.label} ${c.dim(`${elapsed}s`)}\x1b[K`,
81
+ );
82
+ this.frame += 1;
83
+ };
84
+ tick();
85
+ this.timer = setInterval(tick, 80);
86
+ return this;
87
+ }
88
+ update(label) {
89
+ this.label = label;
90
+ }
91
+ succeed(msg) {
92
+ this._stop();
93
+ const finalText = msg || this.label;
94
+ if (STDERR_TTY) process.stderr.write(`\r${sym.ok} ${finalText}\x1b[K\n`);
95
+ else process.stderr.write(`${finalText} OK\n`);
96
+ }
97
+ fail(msg) {
98
+ this._stop();
99
+ const finalText = msg || this.label;
100
+ if (STDERR_TTY) process.stderr.write(`\r${sym.err} ${finalText}\x1b[K\n`);
101
+ else process.stderr.write(`${finalText} FAIL\n`);
102
+ }
103
+ stop() {
104
+ this._stop();
105
+ if (STDERR_TTY) process.stderr.write('\r\x1b[K');
106
+ }
107
+ _stop() {
108
+ if (this.timer) clearInterval(this.timer);
109
+ this.timer = null;
110
+ if (STDERR_TTY) process.stderr.write('\x1b[?25h'); // show cursor
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Run `fn` with a spinner; succeeds on resolve, fails on reject.
116
+ * `fn` receives the spinner so it can `sp.update("...")` mid-flight.
117
+ */
118
+ async function withSpinner(label, fn) {
119
+ const sp = new Spinner(label).start();
120
+ try {
121
+ const v = await fn(sp);
122
+ sp.succeed();
123
+ return v;
124
+ } catch (e) {
125
+ sp.fail();
126
+ throw e;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Progress bar for file batches. No-op when not a TTY or for tiny batches.
132
+ * const bar = makeProgress(files.length, 'Encrypting');
133
+ * for (const f of files) { …; bar.tick(f); }
134
+ * bar.done('Encrypted 47 files');
135
+ */
136
+ function makeProgress(total, label = 'Working') {
137
+ if (!STDERR_TTY || total < 2) {
138
+ let n = 0;
139
+ return {
140
+ tick() { n += 1; },
141
+ done(msg) { if (msg) ok(msg); },
142
+ };
143
+ }
144
+ let n = 0;
145
+ const width = 24;
146
+ return {
147
+ tick(currentLabel) {
148
+ n += 1;
149
+ const filled = Math.max(0, Math.min(width, Math.round((n / total) * width)));
150
+ const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
151
+ const pct = `${n}/${total}`.padStart(`${total}/${total}`.length);
152
+ const trail = currentLabel ? ` ${c.dim(truncatePath(String(currentLabel), 40))}` : '';
153
+ const head = USE_COLOR ? c.cyan(bar) : bar;
154
+ process.stderr.write(`\r${head} ${pct} ${c.dim(label)}${trail}\x1b[K`);
155
+ },
156
+ done(msg) {
157
+ process.stderr.write('\r\x1b[K');
158
+ if (msg) ok(msg);
159
+ },
160
+ };
161
+ }
162
+
163
+ function truncatePath(s, max) {
164
+ if (s.length <= max) return s;
165
+ return '…' + s.slice(-(max - 1));
166
+ }
167
+
168
+ /**
169
+ * Framed box for important blocks (welcome, access codes). Pass plain text or
170
+ * pre-colored strings; we measure visible length, not byte length.
171
+ */
172
+ function box(title, lines, { tone = 'cyan' } = {}) {
173
+ if (!STDOUT_TTY) {
174
+ // No box in piped/CI output — just print contents flat.
175
+ say('');
176
+ if (title) say(title);
177
+ for (const l of lines) say(typeof l === 'string' ? stripAnsi(l) : '');
178
+ say('');
179
+ return;
180
+ }
181
+ const color = c[tone] || ((s) => s);
182
+ const allLines = Array.isArray(lines) ? lines : [lines];
183
+ const titleVis = title ? visibleLen(title) : 0;
184
+ const inner = Math.max(40, titleVis, ...allLines.map((l) => visibleLen(l)));
185
+ const horizontal = '─'.repeat(inner + 2);
186
+
187
+ say('');
188
+ say(color(`╭${horizontal}╮`));
189
+ if (title) {
190
+ const padded = ' '.repeat(inner - titleVis);
191
+ say(color('│ ') + c.bold(title) + padded + color(' │'));
192
+ say(color(`├${horizontal}┤`));
193
+ }
194
+ for (const line of allLines) {
195
+ const visible = visibleLen(line);
196
+ const padded = ' '.repeat(Math.max(0, inner - visible));
197
+ say(color('│ ') + line + padded + color(' │'));
198
+ }
199
+ say(color(`╰${horizontal}╯`));
200
+ say('');
201
+ }
202
+
203
+ /** Branded one-liner. Appears at the top of `--help`. */
204
+ function banner(version) {
205
+ const left = c.bold(c.cyan('sealcode'));
206
+ const right = c.dim(`v${version} · lock your source code`);
207
+ return `${left} ${right}`;
208
+ }
209
+
210
+ /**
211
+ * Show a welcome box the first time the user invokes the CLI with no args
212
+ * (i.e. they just installed it and ran `sealcode`). After the first show,
213
+ * touch a sentinel so we never spam them again.
214
+ */
215
+ function maybeShowWelcome(version) {
216
+ const dir = path.join(os.homedir(), '.sealcode');
217
+ const sentinel = path.join(dir, '.welcomed');
218
+ try {
219
+ if (fs.existsSync(sentinel)) return false;
220
+ } catch (_) {
221
+ return false;
222
+ }
223
+
224
+ box('Welcome to sealcode', [
225
+ c.dim('Lock the source files in your repo so AI agents,'),
226
+ c.dim("scrapers, and people without your passphrase can't read them."),
227
+ '',
228
+ `${sym.arrow} ${c.bold('sealcode init')} ${c.dim('set up this project')}`,
229
+ `${sym.arrow} ${c.bold('sealcode lock')} ${c.dim('encrypt your source')}`,
230
+ `${sym.arrow} ${c.bold('sealcode unlock')} ${c.dim('decrypt it back')}`,
231
+ `${sym.arrow} ${c.bold('sealcode login')} ${c.dim('pair with sealcode.dev (optional)')}`,
232
+ '',
233
+ c.dim(`docs: https://sealcode.dev/docs · v${version}`),
234
+ ]);
235
+
236
+ try {
237
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
238
+ fs.writeFileSync(sentinel, new Date().toISOString());
239
+ } catch (_) {
240
+ /* never fail because of the welcome marker */
241
+ }
242
+ return true;
243
+ }
244
+
245
+ module.exports = {
246
+ // primitives
247
+ c,
248
+ sym,
249
+ // output helpers
250
+ say,
251
+ ok,
252
+ fail,
253
+ warn,
254
+ hint,
255
+ step,
256
+ // components
257
+ Spinner,
258
+ withSpinner,
259
+ makeProgress,
260
+ box,
261
+ banner,
262
+ maybeShowWelcome,
263
+ // capability flags
264
+ STDOUT_TTY,
265
+ STDERR_TTY,
266
+ USE_COLOR,
267
+ // util
268
+ stripAnsi,
269
+ visibleLen,
270
+ };
package/src/util.js ADDED
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function ensureDir(dir) {
7
+ fs.mkdirSync(dir, { recursive: true });
8
+ }
9
+
10
+ function rmIfExists(p) {
11
+ if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
12
+ }
13
+
14
+ function pruneEmptyDirs(dir, root) {
15
+ if (!fs.existsSync(dir) || dir === root) return;
16
+ let entries;
17
+ try {
18
+ entries = fs.readdirSync(dir);
19
+ } catch (_) {
20
+ return;
21
+ }
22
+ for (const entry of entries) {
23
+ const full = path.join(dir, entry);
24
+ const stat = fs.lstatSync(full);
25
+ if (stat.isDirectory()) pruneEmptyDirs(full, root);
26
+ }
27
+ try {
28
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
29
+ } catch (_) {
30
+ /* ignore */
31
+ }
32
+ }
33
+
34
+ function writeFileEnsuringDir(filePath, contents, mode) {
35
+ ensureDir(path.dirname(filePath));
36
+ const opts = mode != null ? { mode } : undefined;
37
+ fs.writeFileSync(filePath, contents, opts);
38
+ }
39
+
40
+ function nowIso() {
41
+ return new Date().toISOString();
42
+ }
43
+
44
+ /**
45
+ * Normalize any path to forward slashes so opaque-name derivation matches
46
+ * across OSes. Use whenever a path will be hashed or compared.
47
+ */
48
+ function normPath(p) {
49
+ return String(p).replace(/\\/g, '/');
50
+ }
51
+
52
+ module.exports = {
53
+ ensureDir,
54
+ rmIfExists,
55
+ pruneEmptyDirs,
56
+ writeFileEnsuringDir,
57
+ nowIso,
58
+ normPath,
59
+ };