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/LICENSE +21 -0
- package/README.md +202 -0
- package/bin/sealcode.js +11 -0
- package/package.json +53 -0
- package/src/api.js +162 -0
- package/src/bundle.js +133 -0
- package/src/cli-auth.js +233 -0
- package/src/cli-grants.js +185 -0
- package/src/cli-link.js +90 -0
- package/src/cli.js +683 -0
- package/src/config.js +76 -0
- package/src/crypto.js +151 -0
- package/src/errors.js +111 -0
- package/src/hooks.js +127 -0
- package/src/index.js +19 -0
- package/src/init.js +180 -0
- package/src/kdf.js +83 -0
- package/src/keystore.js +249 -0
- package/src/link-state.js +60 -0
- package/src/manifest.js +25 -0
- package/src/open.js +43 -0
- package/src/presets.js +338 -0
- package/src/prompt.js +108 -0
- package/src/recovery.js +154 -0
- package/src/seal.js +174 -0
- package/src/status.js +207 -0
- package/src/ui.js +270 -0
- package/src/util.js +59 -0
- package/src/verify.js +51 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { Command } = require('commander');
|
|
6
|
+
|
|
7
|
+
const pkg = require('../package.json');
|
|
8
|
+
const { detectPreset, getPreset, listPresets } = require('./presets');
|
|
9
|
+
const {
|
|
10
|
+
CONFIG_FILE,
|
|
11
|
+
findProjectRoot,
|
|
12
|
+
loadConfig,
|
|
13
|
+
configExists,
|
|
14
|
+
} = require('./config');
|
|
15
|
+
const {
|
|
16
|
+
bootstrap,
|
|
17
|
+
unwrap,
|
|
18
|
+
saveSession,
|
|
19
|
+
loadSession,
|
|
20
|
+
clearSession,
|
|
21
|
+
isInitialized,
|
|
22
|
+
rotatePassphrase,
|
|
23
|
+
} = require('./keystore');
|
|
24
|
+
const { runInit } = require('./init');
|
|
25
|
+
const { runLock } = require('./seal');
|
|
26
|
+
const { runUnlock } = require('./open');
|
|
27
|
+
const { runVerify } = require('./verify');
|
|
28
|
+
const { runStatus, renderStatus, runPrecommitCheck } = require('./status');
|
|
29
|
+
const { parseRecoveryCode } = require('./recovery');
|
|
30
|
+
const { SealcodeError, reportError } = require('./errors');
|
|
31
|
+
const { hidden, confirm } = require('./prompt');
|
|
32
|
+
const { installHook, uninstallHook } = require('./hooks');
|
|
33
|
+
const { exportBundle, importBundle } = require('./bundle');
|
|
34
|
+
const { runLogin, runSignout, runWhoami } = require('./cli-auth');
|
|
35
|
+
const { runLink, runUnlink, runLinkInfo } = require('./cli-link');
|
|
36
|
+
const { runShare, runGrants, runRevoke, runRedeem } = require('./cli-grants');
|
|
37
|
+
const ui = require('./ui');
|
|
38
|
+
|
|
39
|
+
function logger(verbose) {
|
|
40
|
+
return verbose ? (msg) => process.stdout.write(msg + '\n') : () => {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveProject(opts) {
|
|
44
|
+
const projectRoot = opts.project ? path.resolve(opts.project) : findProjectRoot(process.cwd());
|
|
45
|
+
return projectRoot;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read the project passphrase from env vars. Prefer SEALCODE_PASSPHRASE; fall
|
|
50
|
+
* back to VAULTLINE_PASSPHRASE so anyone with the env var baked into their CI
|
|
51
|
+
* keeps working through the rename.
|
|
52
|
+
*/
|
|
53
|
+
function passphraseFromEnv() {
|
|
54
|
+
return process.env.SEALCODE_PASSPHRASE || process.env.VAULTLINE_PASSPHRASE || '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the active config: prefer the file on disk; if missing, fall back to
|
|
59
|
+
* the auto-detected preset's defaults. This lets the tool work in stealth
|
|
60
|
+
* mode where `.sealcoderc.json` isn't checked into the repo.
|
|
61
|
+
*/
|
|
62
|
+
function getActiveConfig(projectRoot) {
|
|
63
|
+
const fromFile = loadConfig(projectRoot);
|
|
64
|
+
if (fromFile) return fromFile;
|
|
65
|
+
const preset = detectPreset(projectRoot);
|
|
66
|
+
return {
|
|
67
|
+
version: 1,
|
|
68
|
+
preset: preset.id,
|
|
69
|
+
lockedDir: preset.lockedDir,
|
|
70
|
+
include: preset.include,
|
|
71
|
+
exclude: preset.exclude,
|
|
72
|
+
stubs: preset.stubs || {},
|
|
73
|
+
_file: null,
|
|
74
|
+
_implicit: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the master key K for an existing vault, prompting the user as
|
|
80
|
+
* needed. Supports:
|
|
81
|
+
* - process.env.SEALCODE_PASSPHRASE (non-interactive)
|
|
82
|
+
* - process.env.VAULTLINE_PASSPHRASE (legacy non-interactive)
|
|
83
|
+
* - active session cache (skip scrypt for ~8h)
|
|
84
|
+
* - interactive passphrase prompt
|
|
85
|
+
* - interactive recovery-code prompt (with --recovery)
|
|
86
|
+
*/
|
|
87
|
+
async function resolveKey(projectRoot, config, { useRecovery = false } = {}) {
|
|
88
|
+
if (!isInitialized(projectRoot, config.lockedDir)) {
|
|
89
|
+
throw new SealcodeError('SEALCODE_NO_MANIFEST');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!useRecovery) {
|
|
93
|
+
const cached = loadSession(projectRoot);
|
|
94
|
+
if (cached) return cached;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (useRecovery) {
|
|
98
|
+
const code = await (process.stdin.isTTY
|
|
99
|
+
? hidden('Recovery code:')
|
|
100
|
+
: require('./prompt').question('Recovery code:'));
|
|
101
|
+
let seed;
|
|
102
|
+
try {
|
|
103
|
+
seed = parseRecoveryCode(code);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
throw new SealcodeError('SEALCODE_WRONG_KEY', { detail: err.message });
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const K = await unwrap(projectRoot, config.lockedDir, { recoverySeed: seed });
|
|
109
|
+
saveSession(projectRoot, K);
|
|
110
|
+
return K;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err.message === 'SEALCODE_WRONG_KEY') {
|
|
113
|
+
throw new SealcodeError('SEALCODE_WRONG_KEY');
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const pp = passphraseFromEnv() || (await hidden('Passphrase:'));
|
|
120
|
+
if (!pp) throw new SealcodeError('SEALCODE_NO_KEY');
|
|
121
|
+
try {
|
|
122
|
+
const K = await unwrap(projectRoot, config.lockedDir, { passphrase: pp });
|
|
123
|
+
saveSession(projectRoot, K);
|
|
124
|
+
return K;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (err.message === 'SEALCODE_WRONG_KEY') {
|
|
127
|
+
throw new SealcodeError('SEALCODE_WRONG_KEY');
|
|
128
|
+
}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Session or SEALCODE_PASSPHRASE only — no interactive prompt (for hooks / CI). */
|
|
134
|
+
async function getKeyForCheck(projectRoot, config) {
|
|
135
|
+
let K = loadSession(projectRoot);
|
|
136
|
+
if (K) return K;
|
|
137
|
+
const pp = passphraseFromEnv();
|
|
138
|
+
if (pp) {
|
|
139
|
+
try {
|
|
140
|
+
return await unwrap(projectRoot, config.lockedDir, { passphrase: pp });
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function build() {
|
|
149
|
+
const program = new Command();
|
|
150
|
+
|
|
151
|
+
program
|
|
152
|
+
.name('sealcode')
|
|
153
|
+
.description(
|
|
154
|
+
'Lock your source code in your own git repo. Stop AI agents, scrapers, and curious eyes from reading what is yours.'
|
|
155
|
+
)
|
|
156
|
+
.version(pkg.version)
|
|
157
|
+
.option('-p, --project <dir>', 'project root (default: nearest ancestor with .sealcoderc.json)')
|
|
158
|
+
.addHelpText('beforeAll', ui.banner(pkg.version) + '\n');
|
|
159
|
+
|
|
160
|
+
// -------- init --------
|
|
161
|
+
program
|
|
162
|
+
.command('init')
|
|
163
|
+
.description('Set up a new vault in this project. Interactive wizard.')
|
|
164
|
+
.option('--preset <id>', 'force a specific ecosystem preset')
|
|
165
|
+
.option('--force', 'overwrite an existing vault (DANGER)', false)
|
|
166
|
+
.option('--noninteractive', 'use SEALCODE_PASSPHRASE env var; print recovery code to stdout', false)
|
|
167
|
+
.action(async (opts) => {
|
|
168
|
+
try {
|
|
169
|
+
const projectRoot = resolveProject(program.opts());
|
|
170
|
+
if (opts.noninteractive) process.env.SEALCODE_NONINTERACTIVE = '1';
|
|
171
|
+
const result = await runInit({
|
|
172
|
+
projectRoot,
|
|
173
|
+
presetId: opts.preset,
|
|
174
|
+
force: opts.force,
|
|
175
|
+
noninteractive: opts.noninteractive,
|
|
176
|
+
});
|
|
177
|
+
// Run the first lock right away so the project ends in the locked state.
|
|
178
|
+
const boot = await bootstrap(result.passphrase, result.recoverySeed);
|
|
179
|
+
const lockResult = await runLock({
|
|
180
|
+
projectRoot,
|
|
181
|
+
config: result.config,
|
|
182
|
+
K: boot.K,
|
|
183
|
+
bootstrapOutput: {
|
|
184
|
+
salt: boot.salt,
|
|
185
|
+
wrappedPass: boot.wrappedPass,
|
|
186
|
+
wrappedRecovery: boot.wrappedRecovery,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
saveSession(projectRoot, boot.K);
|
|
190
|
+
ui.say('');
|
|
191
|
+
ui.ok(`Vault ready. Locked ${ui.c.bold(lockResult.count)} files into ${ui.c.cyan(result.config.lockedDir + '/')}`);
|
|
192
|
+
ui.hint(` Next: commit, then run \`sealcode unlock\` whenever you want to work on it.`);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
process.exitCode = reportError(err);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// -------- lock --------
|
|
199
|
+
program
|
|
200
|
+
.command('lock')
|
|
201
|
+
.alias('seal')
|
|
202
|
+
.description('Encrypt your source files. Removes plaintext after success.')
|
|
203
|
+
.option('-v, --verbose', 'log each file as it is processed', false)
|
|
204
|
+
.option('--keep', 'keep plaintext on disk (testing only)', false)
|
|
205
|
+
.action(async (opts) => {
|
|
206
|
+
try {
|
|
207
|
+
const projectRoot = resolveProject(program.opts());
|
|
208
|
+
const config = getActiveConfig(projectRoot);
|
|
209
|
+
const K = await resolveKey(projectRoot, config);
|
|
210
|
+
ui.step(`locking ${ui.c.dim(projectRoot)}`);
|
|
211
|
+
const sp = new ui.Spinner('encrypting').start();
|
|
212
|
+
let n = 0;
|
|
213
|
+
const res = await runLock({
|
|
214
|
+
projectRoot,
|
|
215
|
+
config,
|
|
216
|
+
K,
|
|
217
|
+
keepOriginals: opts.keep,
|
|
218
|
+
log: (msg) => {
|
|
219
|
+
n += 1;
|
|
220
|
+
const file = String(msg).replace(/^\s*locked\s+/, '');
|
|
221
|
+
sp.update(`encrypting ${ui.c.dim(`(${n})`)} ${file}`);
|
|
222
|
+
if (opts.verbose) {
|
|
223
|
+
// Don't break the spinner — verbose mode just writes to stdout below it.
|
|
224
|
+
process.stdout.write(msg + '\n');
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
sp.succeed(`locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')}`);
|
|
229
|
+
const stubs = Object.keys(res.stubs);
|
|
230
|
+
if (stubs.length) ui.hint(` stubs placed: ${stubs.join(', ')}`);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
process.exitCode = reportError(err);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// -------- unlock --------
|
|
237
|
+
program
|
|
238
|
+
.command('unlock')
|
|
239
|
+
.alias('open')
|
|
240
|
+
.description('Decrypt and restore your source files.')
|
|
241
|
+
.option('-v, --verbose', 'log each file as it is processed', false)
|
|
242
|
+
.option('--recovery', 'use recovery code instead of passphrase', false)
|
|
243
|
+
.option('--keep-stubs', 'do not remove stub files', false)
|
|
244
|
+
.action(async (opts) => {
|
|
245
|
+
try {
|
|
246
|
+
const projectRoot = resolveProject(program.opts());
|
|
247
|
+
const config = getActiveConfig(projectRoot);
|
|
248
|
+
const K = await resolveKey(projectRoot, config, { useRecovery: opts.recovery });
|
|
249
|
+
ui.step(`unlocking ${ui.c.dim(projectRoot)}`);
|
|
250
|
+
const sp = new ui.Spinner('decrypting').start();
|
|
251
|
+
let n = 0;
|
|
252
|
+
const res = await runUnlock({
|
|
253
|
+
projectRoot,
|
|
254
|
+
config,
|
|
255
|
+
K,
|
|
256
|
+
removeStubs: !opts.keepStubs,
|
|
257
|
+
log: (msg) => {
|
|
258
|
+
n += 1;
|
|
259
|
+
const file = String(msg).replace(/^\s*unlocked\s+/, '');
|
|
260
|
+
sp.update(`decrypting ${ui.c.dim(`(${n})`)} ${file}`);
|
|
261
|
+
if (opts.verbose) {
|
|
262
|
+
process.stdout.write(msg + '\n');
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
sp.succeed(`unlocked ${ui.c.bold(res.count)} files ${ui.c.dim(`(locked at ${res.sealedAt})`)}`);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
process.exitCode = reportError(err);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// -------- verify --------
|
|
273
|
+
program
|
|
274
|
+
.command('verify')
|
|
275
|
+
.description('Decrypt every blob and confirm it matches its recorded hash.')
|
|
276
|
+
.option('-v, --verbose', 'log each file as it is processed', false)
|
|
277
|
+
.action(async (opts) => {
|
|
278
|
+
try {
|
|
279
|
+
const projectRoot = resolveProject(program.opts());
|
|
280
|
+
const config = getActiveConfig(projectRoot);
|
|
281
|
+
const K = await resolveKey(projectRoot, config);
|
|
282
|
+
const res = await runVerify({ projectRoot, config, K, log: logger(opts.verbose) });
|
|
283
|
+
if (res.ok) {
|
|
284
|
+
ui.ok(`${ui.c.bold(res.okCount)}/${res.total} files verified`);
|
|
285
|
+
} else {
|
|
286
|
+
ui.fail(`${res.okCount}/${res.total} ok, ${res.issues.length} issues:`);
|
|
287
|
+
for (const i of res.issues) ui.say(` ${ui.c.red('[' + i.kind + ']')} ${i.path}: ${i.detail}`);
|
|
288
|
+
process.exitCode = 2;
|
|
289
|
+
}
|
|
290
|
+
} catch (err) {
|
|
291
|
+
process.exitCode = reportError(err);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// -------- status --------
|
|
296
|
+
program
|
|
297
|
+
.command('status')
|
|
298
|
+
.description('Show whether the project is locked / unlocked and any drift.')
|
|
299
|
+
.option('--check', 'exit 1 if unlocked with drift (for git hooks)', false)
|
|
300
|
+
.option('--json', 'machine-readable output (for editors / CI)', false)
|
|
301
|
+
.action(async (opts) => {
|
|
302
|
+
try {
|
|
303
|
+
const projectRoot = resolveProject(program.opts());
|
|
304
|
+
const config = getActiveConfig(projectRoot);
|
|
305
|
+
if (opts.check) {
|
|
306
|
+
const r = await runPrecommitCheck(projectRoot, config, () => getKeyForCheck(projectRoot, config));
|
|
307
|
+
if (!r.ok) {
|
|
308
|
+
process.stderr.write(r.message + '\n');
|
|
309
|
+
process.exitCode = 1;
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const s = await runStatus({ projectRoot, config });
|
|
314
|
+
if (opts.json) {
|
|
315
|
+
const drift = s.drift
|
|
316
|
+
? {
|
|
317
|
+
added: s.drift.added.length,
|
|
318
|
+
modified: s.drift.modified.length,
|
|
319
|
+
removed: s.drift.removed.length,
|
|
320
|
+
total: s.drift.added.length + s.drift.modified.length + s.drift.removed.length,
|
|
321
|
+
}
|
|
322
|
+
: null;
|
|
323
|
+
process.stdout.write(
|
|
324
|
+
JSON.stringify({
|
|
325
|
+
projectRoot,
|
|
326
|
+
initialized: s.initialized,
|
|
327
|
+
state: s.state,
|
|
328
|
+
lockedDir: s.lockedDir,
|
|
329
|
+
lockedFileCount: s.lockedFileCount,
|
|
330
|
+
onDiskFileCount: s.onDiskFileCount,
|
|
331
|
+
sessionActive: s.sessionActive,
|
|
332
|
+
drift,
|
|
333
|
+
}) + '\n'
|
|
334
|
+
);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
process.stdout.write(`\n${projectRoot}\n`);
|
|
338
|
+
process.stdout.write(renderStatus(s) + '\n');
|
|
339
|
+
} catch (err) {
|
|
340
|
+
process.exitCode = reportError(err);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// -------- presets --------
|
|
345
|
+
program
|
|
346
|
+
.command('presets')
|
|
347
|
+
.description('List built-in ecosystem presets.')
|
|
348
|
+
.action(() => {
|
|
349
|
+
const all = listPresets();
|
|
350
|
+
process.stdout.write('\nBuilt-in presets:\n');
|
|
351
|
+
for (const p of all) process.stdout.write(` ${p.id.padEnd(10)} ${p.label}\n`);
|
|
352
|
+
process.stdout.write('\nUse with: sealcode init --preset <id>\n');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// -------- logout --------
|
|
356
|
+
program
|
|
357
|
+
.command('logout')
|
|
358
|
+
.description('Clear the cached session so the next command re-prompts for your passphrase.')
|
|
359
|
+
.action(() => {
|
|
360
|
+
try {
|
|
361
|
+
const projectRoot = resolveProject(program.opts());
|
|
362
|
+
clearSession(projectRoot);
|
|
363
|
+
process.stdout.write('✓ session cleared\n');
|
|
364
|
+
} catch (err) {
|
|
365
|
+
process.exitCode = reportError(err);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// -------- panic --------
|
|
370
|
+
program
|
|
371
|
+
.command('panic')
|
|
372
|
+
.description('Re-lock immediately and wipe plaintext (for "shut my laptop NOW" moments).')
|
|
373
|
+
.action(async () => {
|
|
374
|
+
try {
|
|
375
|
+
const projectRoot = resolveProject(program.opts());
|
|
376
|
+
const config = getActiveConfig(projectRoot);
|
|
377
|
+
const K = await resolveKey(projectRoot, config);
|
|
378
|
+
const res = await runLock({ projectRoot, config, K });
|
|
379
|
+
clearSession(projectRoot);
|
|
380
|
+
process.stdout.write(`✓ panic-locked ${res.count} files. Session cleared.\n`);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
process.exitCode = reportError(err);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// -------- install-hook --------
|
|
387
|
+
program
|
|
388
|
+
.command('install-hook')
|
|
389
|
+
.description('Install a git pre-commit hook that runs `sealcode status --check`')
|
|
390
|
+
.action(() => {
|
|
391
|
+
try {
|
|
392
|
+
const projectRoot = resolveProject(program.opts());
|
|
393
|
+
const hookPath = installHook(projectRoot);
|
|
394
|
+
process.stdout.write(`✓ pre-commit hook installed:\n ${hookPath}\n`);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
process.exitCode = reportError(err);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
program
|
|
401
|
+
.command('uninstall-hook')
|
|
402
|
+
.description('Remove the sealcode block from .git/hooks/pre-commit')
|
|
403
|
+
.action(() => {
|
|
404
|
+
try {
|
|
405
|
+
const projectRoot = resolveProject(program.opts());
|
|
406
|
+
const r = uninstallHook(projectRoot);
|
|
407
|
+
if (r.removed) process.stdout.write('✓ sealcode block removed from pre-commit hook\n');
|
|
408
|
+
else process.stdout.write(`(nothing removed: ${r.reason})\n`);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
process.exitCode = reportError(err);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// -------- rotate --------
|
|
415
|
+
program
|
|
416
|
+
.command('rotate')
|
|
417
|
+
.description('Change the passphrase that wraps your key (encrypted blobs are unchanged).')
|
|
418
|
+
.action(async () => {
|
|
419
|
+
try {
|
|
420
|
+
const projectRoot = resolveProject(program.opts());
|
|
421
|
+
const config = getActiveConfig(projectRoot);
|
|
422
|
+
if (!isInitialized(projectRoot, config.lockedDir)) {
|
|
423
|
+
throw new SealcodeError('SEALCODE_NO_MANIFEST');
|
|
424
|
+
}
|
|
425
|
+
let oldPp =
|
|
426
|
+
process.env.SEALCODE_OLD_PASSPHRASE || process.env.VAULTLINE_OLD_PASSPHRASE;
|
|
427
|
+
let newPp =
|
|
428
|
+
process.env.SEALCODE_NEW_PASSPHRASE || process.env.VAULTLINE_NEW_PASSPHRASE;
|
|
429
|
+
if (!oldPp) oldPp = await hidden('Current passphrase:');
|
|
430
|
+
if (!newPp) {
|
|
431
|
+
newPp = await hidden('New passphrase:');
|
|
432
|
+
const confirmPp = await hidden('Confirm new passphrase:');
|
|
433
|
+
if (newPp !== confirmPp) {
|
|
434
|
+
process.stderr.write('Passphrases do not match.\n');
|
|
435
|
+
process.exitCode = 1;
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
await rotatePassphrase(projectRoot, config.lockedDir, oldPp, newPp);
|
|
440
|
+
process.stdout.write('✓ passphrase rotated (recovery code is unchanged)\n');
|
|
441
|
+
} catch (err) {
|
|
442
|
+
process.exitCode = reportError(err);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// -------- backup / restore --------
|
|
447
|
+
program
|
|
448
|
+
.command('backup')
|
|
449
|
+
.description('Copy the locked vault + config snapshot to a folder (offline backup)')
|
|
450
|
+
.argument('<dir>', 'output directory (must not exist)')
|
|
451
|
+
.action(async (dir) => {
|
|
452
|
+
try {
|
|
453
|
+
const projectRoot = resolveProject(program.opts());
|
|
454
|
+
const config = getActiveConfig(projectRoot);
|
|
455
|
+
const r = await exportBundle(projectRoot, config, dir);
|
|
456
|
+
process.stdout.write(`✓ backup written to ${r.path}\n`);
|
|
457
|
+
} catch (err) {
|
|
458
|
+
process.exitCode = reportError(err);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
program
|
|
463
|
+
.command('restore')
|
|
464
|
+
.description('Restore locked data from a backup folder (from sealcode backup)')
|
|
465
|
+
.argument('<dir>', 'backup folder')
|
|
466
|
+
.option('--force', 'replace existing locked directory', false)
|
|
467
|
+
.action(async (dir, opts) => {
|
|
468
|
+
try {
|
|
469
|
+
const projectRoot = resolveProject(program.opts());
|
|
470
|
+
const r = await importBundle(projectRoot, dir, { force: opts.force });
|
|
471
|
+
process.stdout.write(`✓ restored locked data → ${r.lockedDir}/\n`);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
process.exitCode = reportError(err);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// -------- pro --------
|
|
478
|
+
program
|
|
479
|
+
.command('pro')
|
|
480
|
+
.description('Learn about sealcode Pro (team key sharing, key rotation, audit log, cloud escrow).')
|
|
481
|
+
.action(() => {
|
|
482
|
+
const lines = [
|
|
483
|
+
'',
|
|
484
|
+
'sealcode Pro — for teams who lock more than one project.',
|
|
485
|
+
'',
|
|
486
|
+
' ✦ team key sharing invite teammates, no passphrase exchange needed',
|
|
487
|
+
' ✦ key rotation rotate passphrases across N projects in one command',
|
|
488
|
+
' ✦ audit log see who unlocked what, when',
|
|
489
|
+
' ✦ cloud key escrow passphrase recovery via account auth',
|
|
490
|
+
' ✦ ci/cd tokens short-lived deploy tokens, no secrets in actions',
|
|
491
|
+
'',
|
|
492
|
+
' Free 14-day trial at https://sealcode.dev/pro',
|
|
493
|
+
'',
|
|
494
|
+
].join('\n');
|
|
495
|
+
process.stdout.write(lines);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// -------- where --------
|
|
499
|
+
program
|
|
500
|
+
.command('where')
|
|
501
|
+
.description('Print resolved project, config, and session state (no secrets).')
|
|
502
|
+
.action(() => {
|
|
503
|
+
const projectRoot = resolveProject(program.opts());
|
|
504
|
+
const config = getActiveConfig(projectRoot);
|
|
505
|
+
const inited = isInitialized(projectRoot, config.lockedDir);
|
|
506
|
+
const session = loadSession(projectRoot) ? 'active' : 'idle';
|
|
507
|
+
process.stdout.write(
|
|
508
|
+
[
|
|
509
|
+
`project: ${projectRoot}`,
|
|
510
|
+
`config: ${configExists(projectRoot) ? path.join(projectRoot, CONFIG_FILE) : '(implicit defaults)'}`,
|
|
511
|
+
`preset: ${config.preset || '(unset)'}`,
|
|
512
|
+
`lockedDir: ${path.join(projectRoot, config.lockedDir)}`,
|
|
513
|
+
`initialized: ${inited ? 'yes' : 'no'}`,
|
|
514
|
+
`session: ${session}`,
|
|
515
|
+
].join('\n') + '\n'
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// ============================================================
|
|
520
|
+
// sealcode.dev account commands — talk to the web app
|
|
521
|
+
// ============================================================
|
|
522
|
+
|
|
523
|
+
// -------- login --------
|
|
524
|
+
program
|
|
525
|
+
.command('login')
|
|
526
|
+
.description('Pair this CLI with your sealcode.dev account (one-time, per machine).')
|
|
527
|
+
.option('--api-url <url>', 'override the server URL (default: $SEALCODE_API_URL or https://sealcode.dev)')
|
|
528
|
+
.action(async (opts) => {
|
|
529
|
+
try {
|
|
530
|
+
await runLogin({ apiUrl: opts.apiUrl });
|
|
531
|
+
} catch (err) {
|
|
532
|
+
process.exitCode = reportError(err);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// -------- signout --------
|
|
537
|
+
program
|
|
538
|
+
.command('signout')
|
|
539
|
+
.alias('logout-account')
|
|
540
|
+
.description('Revoke this CLI\'s account token on the server and clear local credentials.')
|
|
541
|
+
.action(async () => {
|
|
542
|
+
try {
|
|
543
|
+
await runSignout();
|
|
544
|
+
} catch (err) {
|
|
545
|
+
process.exitCode = reportError(err);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// -------- whoami --------
|
|
550
|
+
program
|
|
551
|
+
.command('whoami')
|
|
552
|
+
.description('Show which sealcode.dev account this CLI is signed in as.')
|
|
553
|
+
.option('-v, --verbose', 'include user id, server URL, and online/offline state', false)
|
|
554
|
+
.action(async (opts) => {
|
|
555
|
+
try {
|
|
556
|
+
await runWhoami({ verbose: !!opts.verbose });
|
|
557
|
+
} catch (err) {
|
|
558
|
+
process.exitCode = reportError(err);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// -------- link / unlink / link-info --------
|
|
563
|
+
program
|
|
564
|
+
.command('link')
|
|
565
|
+
.argument('[projectId]', 'project ID from the dashboard')
|
|
566
|
+
.description('Link this repository to a project on sealcode.dev (so `share` knows where to mint codes).')
|
|
567
|
+
.option('--info', 'just print the current link state, don\'t change it', false)
|
|
568
|
+
.option('--remove', 'remove the existing link from this repository', false)
|
|
569
|
+
.option('--json', 'machine-readable output (with --info)', false)
|
|
570
|
+
.action(async (projectId, opts) => {
|
|
571
|
+
try {
|
|
572
|
+
const projectRoot = resolveProject(program.opts());
|
|
573
|
+
if (opts.remove) {
|
|
574
|
+
runUnlink({ projectRoot });
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (opts.info || !projectId) {
|
|
578
|
+
runLinkInfo({ projectRoot, json: opts.json });
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
await runLink({ projectRoot, projectId });
|
|
582
|
+
} catch (err) {
|
|
583
|
+
process.exitCode = reportError(err);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// -------- share --------
|
|
588
|
+
program
|
|
589
|
+
.command('share')
|
|
590
|
+
.description('Mint a time-boxed access code for this linked project (Pro feature).')
|
|
591
|
+
.option('--hours <n>', 'hours until the code expires', (v) => parseInt(v, 10), 24)
|
|
592
|
+
.option('--email <addr>', 'developer email (for the audit log; not an invite email yet)')
|
|
593
|
+
.option('--label <text>', 'short human-readable label for the grant')
|
|
594
|
+
.option('--no-auto-lock', 'do not auto-lock the unlocked tree at expiry', false)
|
|
595
|
+
.option('--auto-lock-after <minutes>', 'auto-lock this many minutes after redeem (0 = at expiry)', (v) => parseInt(v, 10), 0)
|
|
596
|
+
.option('--strict', 'lock the project root read-only between heartbeats', false)
|
|
597
|
+
.option('--heartbeat <seconds>', 'how often the client must heartbeat', (v) => parseInt(v, 10), 300)
|
|
598
|
+
.option('--offline-grace <seconds>', 'how long a heartbeat may be late before auto-lock', (v) => parseInt(v, 10), 1800)
|
|
599
|
+
.option('--json', 'machine-readable output', false)
|
|
600
|
+
.action(async (opts) => {
|
|
601
|
+
try {
|
|
602
|
+
const projectRoot = resolveProject(program.opts());
|
|
603
|
+
await runShare({
|
|
604
|
+
projectRoot,
|
|
605
|
+
hours: opts.hours,
|
|
606
|
+
email: opts.email,
|
|
607
|
+
label: opts.label,
|
|
608
|
+
autoLock: opts.autoLock !== false,
|
|
609
|
+
autoLockOffsetMinutes: opts.autoLockAfter,
|
|
610
|
+
strict: !!opts.strict,
|
|
611
|
+
heartbeatSec: opts.heartbeat,
|
|
612
|
+
offlineGraceSec: opts.offlineGrace,
|
|
613
|
+
json: !!opts.json,
|
|
614
|
+
});
|
|
615
|
+
} catch (err) {
|
|
616
|
+
process.exitCode = reportError(err);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// -------- grants --------
|
|
621
|
+
program
|
|
622
|
+
.command('grants')
|
|
623
|
+
.description('List access codes for the linked project (no secrets revealed).')
|
|
624
|
+
.option('--json', 'machine-readable output', false)
|
|
625
|
+
.action(async (opts) => {
|
|
626
|
+
try {
|
|
627
|
+
const projectRoot = resolveProject(program.opts());
|
|
628
|
+
await runGrants({ projectRoot, json: !!opts.json });
|
|
629
|
+
} catch (err) {
|
|
630
|
+
process.exitCode = reportError(err);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// -------- revoke --------
|
|
635
|
+
program
|
|
636
|
+
.command('revoke')
|
|
637
|
+
.argument('<grantId>', 'grant ID to revoke (see `sealcode grants`)')
|
|
638
|
+
.description('Revoke an active access code so it stops working immediately.')
|
|
639
|
+
.action(async (grantId) => {
|
|
640
|
+
try {
|
|
641
|
+
await runRevoke({ grantId });
|
|
642
|
+
} catch (err) {
|
|
643
|
+
process.exitCode = reportError(err);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// -------- redeem --------
|
|
648
|
+
program
|
|
649
|
+
.command('redeem')
|
|
650
|
+
.argument('<code>', 'access code shared by the project owner (e.g. SC-XXXX-XXXX-...)')
|
|
651
|
+
.description('Redeem an access code shared with you. Prints project info; the owner still has to hand off the unlocked vault separately.')
|
|
652
|
+
.option('--json', 'machine-readable output', false)
|
|
653
|
+
.action(async (code, opts) => {
|
|
654
|
+
try {
|
|
655
|
+
await runRedeem({ code, json: !!opts.json });
|
|
656
|
+
} catch (err) {
|
|
657
|
+
process.exitCode = reportError(err);
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
return program;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function run(argv) {
|
|
665
|
+
const program = build();
|
|
666
|
+
// First-run welcome: only when the user invokes `sealcode` with no subcommand.
|
|
667
|
+
// After the first show, a sentinel in ~/.sealcode prevents repeats.
|
|
668
|
+
const userArgs = argv.slice(2);
|
|
669
|
+
const bare = userArgs.length === 0;
|
|
670
|
+
const helpish = userArgs[0] === '-h' || userArgs[0] === '--help' || userArgs[0] === 'help';
|
|
671
|
+
if (bare || helpish) {
|
|
672
|
+
ui.maybeShowWelcome(pkg.version);
|
|
673
|
+
}
|
|
674
|
+
await program.parseAsync(argv);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
module.exports = { build, run };
|
|
678
|
+
|
|
679
|
+
// Suppress unused-import warning during partial dev cycles.
|
|
680
|
+
void confirm;
|
|
681
|
+
void detectPreset;
|
|
682
|
+
void getPreset;
|
|
683
|
+
void fs;
|