sealcode 1.1.0 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealcode",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Lock your source code in your own git repo. Stop AI agents, scrapers, and curious eyes from reading what's yours.",
5
5
  "keywords": [
6
6
  "encryption",
package/src/cli-grants.js CHANGED
@@ -74,17 +74,38 @@ function getActiveConfig(projectRoot) {
74
74
  * a real cryptographic bug; we don't want to silently downgrade in that
75
75
  * case).
76
76
  */
77
- async function maybeWrapKeyForRecipient({ projectRoot, recipientEmail, verbose = false }) {
77
+ async function maybeWrapKeyForRecipient({
78
+ projectRoot,
79
+ recipientEmail,
80
+ verbose = false,
81
+ resolveKey = null,
82
+ }) {
78
83
  if (!recipientEmail) return null;
79
84
 
80
- // Need the master key K to wrap. If there's no live session on this
81
- // machine, we can't wrap owner has to `sealcode unlock` first.
85
+ // Need the master key K to wrap. Prefer a cached session; if none, ask
86
+ // the caller (cli.js) to prompt for the passphrase. We deliberately do
87
+ // NOT silently downgrade to "passphrase fallback" here — the whole point
88
+ // of `--email` is to spare the recipient the passphrase exchange.
82
89
  const config = getActiveConfig(projectRoot);
83
90
  if (!isInitialized(projectRoot, config.lockedDir)) return null;
84
- const K = loadSession(projectRoot);
91
+ let K = loadSession(projectRoot);
92
+ if (!K && typeof resolveKey === 'function') {
93
+ if (verbose) {
94
+ ui.step('No cached master key — prompting for passphrase so we can wrap for the recipient.');
95
+ }
96
+ try {
97
+ K = await resolveKey(projectRoot, config);
98
+ } catch (err) {
99
+ if (verbose) {
100
+ ui.warn(`Could not derive master key (${err.message || err}). Will mint without wrapping.`);
101
+ }
102
+ return null;
103
+ }
104
+ }
85
105
  if (!K) {
86
106
  if (verbose) {
87
- ui.hint('(no cached sessionskipping team-key wrap; recipient will need the passphrase)');
107
+ ui.warn('No cached master key minting code WITHOUT wrapped key. Recipient will need the passphrase.');
108
+ ui.hint('Run `sealcode unlock` first, or supply a passphrase prompt, to enable passphrase-free sharing.');
88
109
  }
89
110
  return null;
90
111
  }
@@ -100,13 +121,14 @@ async function maybeWrapKeyForRecipient({ projectRoot, recipientEmail, verbose =
100
121
  } catch (err) {
101
122
  if (err instanceof ApiError) {
102
123
  if (err.status === 404) {
103
- // Either the user doesn't exist or hasn't published a pubkey
104
- // yet. We still mint the code; the recipient just falls back to
105
- // the legacy flow when they redeem.
106
124
  if (verbose) {
125
+ ui.warn(
126
+ `Recipient ${recipientEmail} hasn't signed in to sealcode.dev yet, so no pubkey is on file.`,
127
+ );
107
128
  ui.hint(
108
- `(recipient hasn't published a pubkey yet falling back to passphrase-share flow)`,
129
+ 'Ask them to visit https://sealcode.dev/login, then re-run `sealcode share --email`.',
109
130
  );
131
+ ui.hint('Continuing with passphrase-fallback for this code…');
110
132
  }
111
133
  return null;
112
134
  }
@@ -146,6 +168,7 @@ async function runShare({
146
168
  singleDeviceEnforce = false,
147
169
  ndaText = null,
148
170
  json = false,
171
+ resolveKey = null,
149
172
  }) {
150
173
  const link = requireLink(projectRoot);
151
174
 
@@ -159,6 +182,7 @@ async function runShare({
159
182
  projectRoot,
160
183
  recipientEmail: email,
161
184
  verbose: !json,
185
+ resolveKey,
162
186
  });
163
187
  } catch (err) {
164
188
  // Network errors during pubkey lookup shouldn't be silent — degrade
@@ -275,8 +299,14 @@ async function runGrants({ projectRoot, json = false }) {
275
299
  process.stdout.write(`\n${link.projectName || link.projectId}: ${grants.length} grant(s)\n`);
276
300
  for (const g of grants) {
277
301
  const state = g.state.padEnd(8);
278
- const remaining =
279
- g.state === 'active' ? formatRemaining(g.remainingMs) : g.state;
302
+ let remaining;
303
+ if (g.state === 'active') {
304
+ remaining = formatRemaining(g.remainingMs);
305
+ } else if (g.state === 'paused') {
306
+ remaining = 'paused';
307
+ } else {
308
+ remaining = g.state;
309
+ }
280
310
  const who = g.developerEmail || g.developerLabel || '(no label)';
281
311
  process.stdout.write(
282
312
  ` ${state} ${g.codePrefix.padEnd(12)} ${remaining.padEnd(10)} ${who} id=${g.id}\n`,
@@ -285,6 +315,85 @@ async function runGrants({ projectRoot, json = false }) {
285
315
  process.stdout.write('\n');
286
316
  }
287
317
 
318
+ /**
319
+ * sealcode@1.2.0 — Pause an active access code from the CLI.
320
+ *
321
+ * sealcode pause <grantId> [--reason "<text>"] [--extend-on-resume]
322
+ *
323
+ * Mirrors the dashboard pause modal. Owner must be `sealcode login`ed
324
+ * and be admin (or owner) of the project.
325
+ */
326
+ async function runPause({ grantId, reason, extendOnResume = false }) {
327
+ if (!grantId) throw new Error('Usage: sealcode pause <grantId> [--reason "<text>"] [--extend-on-resume]');
328
+ const body = {};
329
+ if (reason) body.reason = String(reason).slice(0, 500);
330
+ if (extendOnResume) body.extendsExpiry = true;
331
+ try {
332
+ await request(
333
+ 'POST',
334
+ `/api/v1/grants/${encodeURIComponent(grantId)}/pause`,
335
+ { auth: true, body },
336
+ );
337
+ } catch (err) {
338
+ if (err instanceof ApiError && err.status === 409 && err.apiCode === 'wrong_state') {
339
+ ui.fail(err.message || 'Grant is not in a pausable state.');
340
+ process.exitCode = 1;
341
+ return;
342
+ }
343
+ throw err;
344
+ }
345
+ process.stdout.write(`⏸ paused grant ${grantId}\n`);
346
+ if (reason) process.stdout.write(` reason: ${reason}\n`);
347
+ if (extendOnResume) {
348
+ process.stdout.write(` extend-on-resume: expiry will shift forward by the paused duration\n`);
349
+ }
350
+ process.stdout.write(' Any live watcher will re-lock the recipient\'s files within seconds.\n');
351
+ process.stdout.write(` Resume with: ${ui.c.cyan(`sealcode resume ${grantId}`)}\n`);
352
+ }
353
+
354
+ /**
355
+ * sealcode@1.2.0 — Resume a paused access code from the CLI.
356
+ *
357
+ * sealcode resume <grantId>
358
+ *
359
+ * If the grant was paused with --extend-on-resume, the expiresAt shifts
360
+ * forward by the just-elapsed pause duration. The recipient must run
361
+ * `sealcode redeem <code>` again to re-open their working copy.
362
+ */
363
+ async function runResume({ grantId }) {
364
+ if (!grantId) throw new Error('Usage: sealcode resume <grantId>');
365
+ let res;
366
+ try {
367
+ res = await request(
368
+ 'POST',
369
+ `/api/v1/grants/${encodeURIComponent(grantId)}/resume`,
370
+ { auth: true, body: {} },
371
+ );
372
+ } catch (err) {
373
+ if (err instanceof ApiError) {
374
+ if (err.status === 410) {
375
+ ui.fail(err.message || 'Grant expired while paused.');
376
+ ui.hint(' Issue a fresh access code instead: `sealcode share ...`');
377
+ process.exitCode = 1;
378
+ return;
379
+ }
380
+ if (err.status === 409 && err.apiCode === 'wrong_state') {
381
+ ui.fail(err.message || 'Grant is not paused.');
382
+ process.exitCode = 1;
383
+ return;
384
+ }
385
+ }
386
+ throw err;
387
+ }
388
+ process.stdout.write(`▶ resumed grant ${grantId}\n`);
389
+ if (res.newExpiresAt) process.stdout.write(` expires: ${res.newExpiresAt}\n`);
390
+ if (res.extendedExpiry) {
391
+ const mins = Math.round((res.pausedDurationMs || 0) / 60000);
392
+ process.stdout.write(` expiry shifted forward by ~${mins}m (paused duration)\n`);
393
+ }
394
+ process.stdout.write(' Recipient must re-redeem the code to re-open the vault.\n');
395
+ }
396
+
288
397
  async function runRevoke({ grantId }) {
289
398
  if (!grantId) throw new Error('Usage: sealcode revoke <grantId>');
290
399
  const res = await request(
@@ -550,4 +659,12 @@ async function runLockdown({ projectRoot, confirm = true } = {}) {
550
659
  }
551
660
  }
552
661
 
553
- module.exports = { runShare, runGrants, runRevoke, runRedeem, runLockdown };
662
+ module.exports = {
663
+ runShare,
664
+ runGrants,
665
+ runRevoke,
666
+ runRedeem,
667
+ runLockdown,
668
+ runPause,
669
+ runResume,
670
+ };
package/src/cli-watch.js CHANGED
@@ -780,7 +780,14 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
780
780
  process.stdout.write(JSON.stringify({ type: 'locked', count: res.count, reason: label }) + '\n');
781
781
  } else {
782
782
  ui.ok(`[${ts()}] re-locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')} — session cleared`);
783
- ui.hint(' Your local plaintext has been wiped. Ask the owner for a fresh code if you still need access.');
783
+ // sealcode@1.2.0 be specific about the "paused" case so the
784
+ // recipient knows it's reversible and what their next step is.
785
+ if (label === 'paused') {
786
+ ui.hint(' This access code was PAUSED by the owner — not revoked.');
787
+ ui.hint(` Wait for the owner to resume, then run: ${ui.c.cyan(`sealcode redeem ${code}`)}`);
788
+ } else {
789
+ ui.hint(' Your local plaintext has been wiped. Ask the owner for a fresh code if you still need access.');
790
+ }
784
791
  }
785
792
  process.exit(0);
786
793
  }
package/src/cli.js CHANGED
@@ -33,7 +33,14 @@ const { installHook, uninstallHook } = require('./hooks');
33
33
  const { exportBundle, importBundle } = require('./bundle');
34
34
  const { runLogin, runSignout, runWhoami } = require('./cli-auth');
35
35
  const { runLink, runUnlink, runLinkInfo } = require('./cli-link');
36
- const { runShare, runGrants, runRevoke, runRedeem } = require('./cli-grants');
36
+ const {
37
+ runShare,
38
+ runGrants,
39
+ runRevoke,
40
+ runRedeem,
41
+ runPause,
42
+ runResume,
43
+ } = require('./cli-grants');
37
44
  const {
38
45
  runCiCreate,
39
46
  runCiList,
@@ -714,6 +721,7 @@ function build() {
714
721
  singleDeviceEnforce: !!opts.singleDevice,
715
722
  ndaText: opts.nda || null,
716
723
  json: !!opts.json,
724
+ resolveKey,
717
725
  });
718
726
  } catch (err) {
719
727
  process.exitCode = reportError(err);
@@ -747,6 +755,38 @@ function build() {
747
755
  }
748
756
  });
749
757
 
758
+ // -------- pause (sealcode@1.2.0) --------
759
+ program
760
+ .command('pause')
761
+ .argument('<grantId>', 'grant ID to pause (see `sealcode grants`)')
762
+ .description('Pause an active access code. Reversible: any live watcher re-locks immediately, but you can resume later.')
763
+ .option('--reason <text>', 'optional note visible to project admins (≤500 chars)')
764
+ .option('--extend-on-resume', 'on resume, shift expiry forward by the paused duration (default: clock keeps ticking)', false)
765
+ .action(async (grantId, opts) => {
766
+ try {
767
+ await runPause({
768
+ grantId,
769
+ reason: opts.reason,
770
+ extendOnResume: !!opts.extendOnResume,
771
+ });
772
+ } catch (err) {
773
+ process.exitCode = reportError(err);
774
+ }
775
+ });
776
+
777
+ // -------- resume (sealcode@1.2.0) --------
778
+ program
779
+ .command('resume')
780
+ .argument('<grantId>', 'grant ID to resume (see `sealcode grants`)')
781
+ .description('Resume a paused access code. The recipient must `sealcode redeem <code>` again to re-open their working copy.')
782
+ .action(async (grantId) => {
783
+ try {
784
+ await runResume({ grantId });
785
+ } catch (err) {
786
+ process.exitCode = reportError(err);
787
+ }
788
+ });
789
+
750
790
  // -------- lockdown (sealcode@1.1.0) --------
751
791
  program
752
792
  .command('lockdown')