sealcode 1.1.1 → 1.3.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.1",
3
+ "version": "1.3.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
@@ -299,8 +299,14 @@ async function runGrants({ projectRoot, json = false }) {
299
299
  process.stdout.write(`\n${link.projectName || link.projectId}: ${grants.length} grant(s)\n`);
300
300
  for (const g of grants) {
301
301
  const state = g.state.padEnd(8);
302
- const remaining =
303
- 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
+ }
304
310
  const who = g.developerEmail || g.developerLabel || '(no label)';
305
311
  process.stdout.write(
306
312
  ` ${state} ${g.codePrefix.padEnd(12)} ${remaining.padEnd(10)} ${who} id=${g.id}\n`,
@@ -309,6 +315,135 @@ async function runGrants({ projectRoot, json = false }) {
309
315
  process.stdout.write('\n');
310
316
  }
311
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> [--unlock]
358
+ *
359
+ * sealcode@1.3.0:
360
+ * --unlock pushes an unlock event to the recipient's live watcher,
361
+ * which re-materializes plaintext using the K it kept cached through
362
+ * the pause. Without --unlock, status flips back to active but the
363
+ * recipient stays locked until they manually `sealcode unlock`
364
+ * (which now works without a passphrase since the session was
365
+ * preserved through the pause).
366
+ */
367
+ async function runResume({ grantId, autoUnlock = false }) {
368
+ if (!grantId) throw new Error('Usage: sealcode resume <grantId> [--unlock]');
369
+ let res;
370
+ try {
371
+ res = await request(
372
+ 'POST',
373
+ `/api/v1/grants/${encodeURIComponent(grantId)}/resume`,
374
+ { auth: true, body: { autoUnlock: !!autoUnlock } },
375
+ );
376
+ } catch (err) {
377
+ if (err instanceof ApiError) {
378
+ if (err.status === 410) {
379
+ ui.fail(err.message || 'Grant expired while paused.');
380
+ ui.hint(' Issue a fresh access code instead: `sealcode share ...`');
381
+ process.exitCode = 1;
382
+ return;
383
+ }
384
+ if (err.status === 409 && err.apiCode === 'wrong_state') {
385
+ ui.fail(err.message || 'Grant is not paused.');
386
+ process.exitCode = 1;
387
+ return;
388
+ }
389
+ }
390
+ throw err;
391
+ }
392
+ process.stdout.write(`▶ resumed grant ${grantId}\n`);
393
+ if (res.newExpiresAt) process.stdout.write(` expires: ${res.newExpiresAt}\n`);
394
+ if (res.extendedExpiry) {
395
+ const mins = Math.round((res.pausedDurationMs || 0) / 60000);
396
+ process.stdout.write(` expiry shifted forward by ~${mins}m (paused duration)\n`);
397
+ }
398
+ if (autoUnlock) {
399
+ process.stdout.write(' Recipient\'s 1.3+ watcher will auto-unlock within ~1 second.\n');
400
+ process.stdout.write(' (No redeem needed — K was kept cached through the pause.)\n');
401
+ } else {
402
+ process.stdout.write(' Recipient can run `sealcode unlock` to re-open (no passphrase needed).\n');
403
+ }
404
+ }
405
+
406
+ /**
407
+ * sealcode@1.3.0 — Ask the server whether a grant-derived session is
408
+ * currently allowed to unlock. Used by the `unlock` command to refuse
409
+ * unlocking while the grant is paused (or revoked / expired) WITHOUT
410
+ * requiring the user to redeem first.
411
+ *
412
+ * Returns one of:
413
+ * { allow: true, reason: 'active', ... }
414
+ * { allow: false, reason: 'paused' | 'revoked' | 'expired' | 'not_found', ... }
415
+ * { allow: true, reason: 'network_unreachable' } (best-effort fallback)
416
+ *
417
+ * The last case lets a recipient who's offline still unlock — by
418
+ * design. If you want strict "must be online to unlock", pair this
419
+ * with the `strictMode` grant flag and the watcher's offline-grace
420
+ * (which already covers that case).
421
+ */
422
+ async function checkUnlockAllowed(code, { strict = false } = {}) {
423
+ if (!code) return { allow: true, reason: 'no_code_local_only' };
424
+ try {
425
+ const res = await request('POST', '/api/v1/access/unlock-check', {
426
+ body: {
427
+ code: String(code).trim(),
428
+ deviceFingerprint: getDeviceFingerprint(),
429
+ },
430
+ timeoutMs: 8000,
431
+ });
432
+ return res || { allow: false, reason: 'unknown' };
433
+ } catch (err) {
434
+ if (err instanceof ApiError && err.status === 404) {
435
+ // Older deploys (pre-1.3) without the endpoint — degrade open
436
+ // so existing CLIs against an older server don't break.
437
+ return { allow: true, reason: 'endpoint_missing' };
438
+ }
439
+ if (err instanceof ApiError && (err.status === 0 || err.status >= 500)) {
440
+ if (strict) return { allow: false, reason: 'network_unreachable_strict' };
441
+ return { allow: true, reason: 'network_unreachable' };
442
+ }
443
+ throw err;
444
+ }
445
+ }
446
+
312
447
  async function runRevoke({ grantId }) {
313
448
  if (!grantId) throw new Error('Usage: sealcode revoke <grantId>');
314
449
  const res = await request(
@@ -396,6 +531,13 @@ async function runRedeem({ projectRoot, code, json = false, agreeNda = false })
396
531
  source: 'grant',
397
532
  grantId: g.id,
398
533
  grantCodeHash: require('crypto').createHash('sha256').update(trimmed).digest('hex'),
534
+ // sealcode@1.3.0 — stash the code itself so the local
535
+ // unlock command can ask the server "may I unlock?" after
536
+ // a pause without requiring the user to re-type it. Lives
537
+ // under ~/.sealcode/sessions/ at mode 0600 alongside the
538
+ // already-cached K, so this doesn't materially expand the
539
+ // local attack surface.
540
+ grantCode: trimmed,
399
541
  grantExpiresAt: g.expiresAt,
400
542
  deviceFingerprint: getDeviceFingerprint(),
401
543
  strictWatch: !!g.strictMode,
@@ -574,4 +716,13 @@ async function runLockdown({ projectRoot, confirm = true } = {}) {
574
716
  }
575
717
  }
576
718
 
577
- module.exports = { runShare, runGrants, runRevoke, runRedeem, runLockdown };
719
+ module.exports = {
720
+ runShare,
721
+ runGrants,
722
+ runRevoke,
723
+ runRedeem,
724
+ runLockdown,
725
+ runPause,
726
+ runResume,
727
+ checkUnlockAllowed,
728
+ };
package/src/cli-watch.js CHANGED
@@ -55,6 +55,7 @@ const {
55
55
  projectId,
56
56
  } = require('./keystore');
57
57
  const { runLock } = require('./seal');
58
+ const { runUnlock } = require('./open');
58
59
  const { getDeviceFingerprint } = require('./device');
59
60
  const { SealcodeError } = require('./errors');
60
61
  const grantPolicy = require('./grant-policy');
@@ -529,10 +530,22 @@ async function runWatch({
529
530
  strictWatch: strict,
530
531
  });
531
532
 
533
+ // sealcode@1.3.0 — track local pause state. When true, files are
534
+ // locked-on-disk but K is still in our session cache. We keep
535
+ // heartbeating so a server-side "unlock" event (owner resumed with
536
+ // auto-unlock) can re-materialize plaintext without a redeem.
537
+ let pausedLocally = false;
538
+
532
539
  // If we somehow started watching an already-locked grant, lock first
533
- // and exit. Don't pretend everything's fine.
540
+ // and exit (revoke/expire/etc), OR soft-lock and stay running (paused).
534
541
  if (first.response.action === 'lock') {
535
- return finalLock(projectRoot, config, trimmedCode, first.response.reason, json, daemon);
542
+ if (first.response.keepSession || first.response.reason === 'paused') {
543
+ await softLock(projectRoot, config, trimmedCode, first.response.reason || 'paused', json, daemon);
544
+ pausedLocally = true;
545
+ writeWatchState(projectRoot, { pausedLocally: true, pausedReason: first.response.reason || 'paused' });
546
+ } else {
547
+ return finalLock(projectRoot, config, trimmedCode, first.response.reason, json, daemon);
548
+ }
536
549
  }
537
550
 
538
551
  if (daemon) {
@@ -695,9 +708,76 @@ async function runWatch({
695
708
  continue;
696
709
  }
697
710
  consecutiveTransient = 0;
711
+
712
+ // sealcode@1.3.0 — branch on action.
698
713
  if (r.response.action === 'lock') {
714
+ // Soft lock (paused): re-encrypt, KEEP K, KEEP watcher alive.
715
+ // The 1.3 server marks this with `keepSession: true`; we also
716
+ // honor the legacy `reason: "paused"` from 1.2.0 servers in case
717
+ // someone is on a mixed deploy.
718
+ const isSoft = !!r.response.keepSession || r.response.reason === 'paused';
719
+ if (isSoft) {
720
+ if (!pausedLocally) {
721
+ await softLock(projectRoot, config, trimmedCode, r.response.reason || 'paused', json, daemon);
722
+ pausedLocally = true;
723
+ writeWatchState(projectRoot, { pausedLocally: true, pausedReason: r.response.reason || 'paused' });
724
+ appendLog(projectRoot, { type: 'soft_locked', reason: r.response.reason || 'paused' });
725
+ }
726
+ // Stay in the loop — wait for the next event (unlock or hard lock).
727
+ continue;
728
+ }
729
+ // Hard lock — revoke / expire / device mismatch / idle / etc.
699
730
  return finalLock(projectRoot, config, trimmedCode, r.response.reason, json, daemon);
700
731
  }
732
+
733
+ if (r.response.action === 'unlock') {
734
+ // Owner resumed with auto-unlock. Re-materialize plaintext using
735
+ // the K we kept cached through the pause.
736
+ try {
737
+ const K = loadSession(projectRoot);
738
+ if (!K) {
739
+ // We somehow lost K (e.g. another process cleared it). Best
740
+ // we can do: stay locked and nudge the user.
741
+ appendLog(projectRoot, { type: 'unlock_skipped_no_key' });
742
+ if (!daemon && !json) ui.warn(`[${ts()}] resumed, but no cached key on disk — run: sealcode redeem ${trimmedCode}`);
743
+ } else {
744
+ const sm = loadSessionMeta(projectRoot);
745
+ const pol = (sm && sm.meta && sm.meta.policy) || {};
746
+ const wctx = pol.watermark ? {
747
+ email: (sm && sm.meta && sm.meta.recipientEmail) || '',
748
+ grantId: (sm && sm.meta && sm.meta.grantId) || '',
749
+ projectName: (sm && sm.meta && sm.meta.projectName) || '',
750
+ date: new Date().toISOString().slice(0, 10),
751
+ fingerprint: getDeviceFingerprint(),
752
+ } : null;
753
+ const ures = await runUnlock({
754
+ projectRoot, config, K, removeStubs: true, policy: pol, watermarkCtx: wctx,
755
+ log: () => {},
756
+ });
757
+ K.fill(0);
758
+ appendLog(projectRoot, { type: 'auto_unlocked', count: ures.count, reason: r.response.reason || 'resumed' });
759
+ if (!daemon && !json) ui.ok(`[${ts()}] owner resumed — auto-unlocked ${ui.c.bold(ures.count)} files`);
760
+ }
761
+ } catch (err) {
762
+ appendLog(projectRoot, { type: 'auto_unlock_error', error: String(err.message || err) });
763
+ if (!daemon && !json) ui.warn(`[${ts()}] auto-unlock failed: ${err.message || err}`);
764
+ }
765
+ pausedLocally = false;
766
+ writeWatchState(projectRoot, { pausedLocally: false, pausedReason: null });
767
+ continue;
768
+ }
769
+
770
+ // Plain "ok" or "warn". If we WERE paused and the server now says
771
+ // "ok" without sending an unlock event, that means the owner
772
+ // resumed without auto-unlock. Clear our paused flag so the user's
773
+ // `sealcode unlock` invocation will be allowed by the local guard;
774
+ // we leave files locked-on-disk and let them choose when to unlock.
775
+ if (pausedLocally && (r.response.action === 'ok' || r.response.action === 'warn')) {
776
+ pausedLocally = false;
777
+ writeWatchState(projectRoot, { pausedLocally: false, pausedReason: null });
778
+ appendLog(projectRoot, { type: 'resumed_manual' });
779
+ if (!daemon && !json) ui.ok(`[${ts()}] owner resumed — files stay locked until you run: ${ui.c.cyan('sealcode unlock')}`);
780
+ }
701
781
  printTick(r.response, json, verbose, daemon, projectRoot);
702
782
  // After a successful (possibly long-polled) heartbeat we do NOT
703
783
  // sleep here — long-poll already consumed our budget. If the server
@@ -731,6 +811,61 @@ function printTick(resp, json, verbose, daemon, projectRoot) {
731
811
  }
732
812
  }
733
813
 
814
+ /**
815
+ * sealcode@1.3.0 — Soft lock: re-encrypt plaintext but KEEP the cached
816
+ * K and KEEP the watcher process alive. Used for pause, which is
817
+ * meant to be reversible — the owner can resume and (optionally)
818
+ * push an `action: "unlock"` event that re-materializes the plaintext
819
+ * without requiring the recipient to redeem again.
820
+ *
821
+ * Idempotent: if there's no K cached (e.g. we restarted into an
822
+ * already-locked project), this is a no-op.
823
+ */
824
+ async function softLock(projectRoot, config, code, reason, json, daemon) {
825
+ if (!json && !daemon && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
826
+ const label = reason || 'paused';
827
+ if (daemon) {
828
+ appendLog(projectRoot, { type: 'soft_lock', reason: label });
829
+ } else if (json) {
830
+ process.stdout.write(JSON.stringify({ type: 'soft_lock', reason: label }) + '\n');
831
+ } else {
832
+ ui.warn(`[${ts()}] paused by owner — re-locking, keeping session for resume`);
833
+ }
834
+
835
+ const K = loadSession(projectRoot);
836
+ if (!K) {
837
+ // Nothing to re-encrypt. Files are already in their locked state.
838
+ return;
839
+ }
840
+
841
+ const _sessionMeta = loadSessionMeta(projectRoot);
842
+ const _preserveUnseen =
843
+ !!_sessionMeta &&
844
+ _sessionMeta.meta &&
845
+ _sessionMeta.meta.source === 'grant' &&
846
+ _sessionMeta.meta.policy &&
847
+ Array.isArray(_sessionMeta.meta.policy.allowedPaths) &&
848
+ _sessionMeta.meta.policy.allowedPaths.length > 0;
849
+
850
+ try {
851
+ const res = await runLock({ projectRoot, config, K, preserveUnseen: _preserveUnseen });
852
+ K.fill(0);
853
+ if (daemon) {
854
+ appendLog(projectRoot, { type: 'soft_locked', count: res.count, reason: label });
855
+ } else if (!json) {
856
+ ui.ok(`[${ts()}] re-locked ${ui.c.bold(res.count)} files — waiting for owner to resume`);
857
+ ui.hint(` Files will auto-unlock if the owner picks "Resume + unlock now",`);
858
+ ui.hint(` or run ${ui.c.cyan('sealcode unlock')} yourself once they resume (no passphrase).`);
859
+ }
860
+ } catch (err) {
861
+ // If soft-lock fails we DO NOT escalate to hard-lock — that would
862
+ // wipe the session and force a redeem, which is exactly the UX we
863
+ // built this feature to avoid. Surface the error and stay locked.
864
+ if (daemon) appendLog(projectRoot, { type: 'soft_lock_error', error: String(err.message || err) });
865
+ else if (!json) ui.fail(`soft-lock failed: ${err.message}. Files may still contain plaintext — investigate.`);
866
+ }
867
+ }
868
+
734
869
  async function finalLock(projectRoot, config, code, reason, json, daemon) {
735
870
  if (!json && !daemon && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
736
871
  const label = reason || 'lock';
@@ -780,7 +915,15 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
780
915
  process.stdout.write(JSON.stringify({ type: 'locked', count: res.count, reason: label }) + '\n');
781
916
  } else {
782
917
  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.');
918
+ // sealcode@1.2.0 be specific about the "paused" case so the
919
+ // recipient knows it's reversible. (sealcode@1.3.0+ goes through
920
+ // softLock instead and won't reach this branch.)
921
+ if (label === 'paused') {
922
+ ui.hint(' This access code was PAUSED by the owner — not revoked.');
923
+ ui.hint(` Wait for the owner to resume, then run: ${ui.c.cyan(`sealcode redeem ${code}`)}`);
924
+ } else {
925
+ ui.hint(' Your local plaintext has been wiped. Ask the owner for a fresh code if you still need access.');
926
+ }
784
927
  }
785
928
  process.exit(0);
786
929
  }
package/src/cli.js CHANGED
@@ -33,7 +33,15 @@ 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
+ checkUnlockAllowed,
44
+ } = require('./cli-grants');
37
45
  const {
38
46
  runCiCreate,
39
47
  runCiList,
@@ -279,6 +287,63 @@ function build() {
279
287
  try {
280
288
  const projectRoot = resolveProject(program.opts());
281
289
  const config = getActiveConfig(projectRoot);
290
+
291
+ // sealcode@1.3.0 — grant-derived sessions must clear a server
292
+ // status check before unlocking. This is what stops a recipient
293
+ // from running `sealcode unlock` to bypass a pause.
294
+ //
295
+ // We do this BEFORE resolveKey so a paused/revoked grant
296
+ // doesn't even decrypt the cached K from disk. If the local
297
+ // session isn't grant-derived (owner-passphrase flow), this
298
+ // whole block is a no-op.
299
+ {
300
+ const { loadSessionMeta: _lsm } = require('./keystore');
301
+ const _sm = _lsm(projectRoot);
302
+ if (_sm && _sm.meta && _sm.meta.source === 'grant' && _sm.meta.grantCode) {
303
+ const _strict = !!_sm.meta.strictWatch;
304
+ const _gate = await checkUnlockAllowed(_sm.meta.grantCode, { strict: _strict });
305
+ if (!_gate.allow) {
306
+ if (_gate.reason === 'paused') {
307
+ ui.fail(`This access has been PAUSED by the project owner.`);
308
+ if (_gate.pauseReason) ui.hint(` Reason: ${_gate.pauseReason}`);
309
+ ui.hint(` Files stay locked until the owner resumes. You'll get an automatic`);
310
+ ui.hint(` notification (and auto-unlock) when they do — no redeem needed.`);
311
+ process.exitCode = 1;
312
+ return;
313
+ }
314
+ if (_gate.reason === 'revoked') {
315
+ ui.fail('This access code has been revoked. You can no longer unlock.');
316
+ ui.hint(' Ask the owner for a fresh code: `sealcode share` on their side.');
317
+ process.exitCode = 1;
318
+ return;
319
+ }
320
+ if (_gate.reason === 'expired') {
321
+ ui.fail('This access code has expired. You can no longer unlock.');
322
+ ui.hint(' Ask the owner for a fresh code.');
323
+ process.exitCode = 1;
324
+ return;
325
+ }
326
+ if (_gate.reason === 'not_found') {
327
+ ui.fail('Access code not found on the server (it may have been deleted).');
328
+ process.exitCode = 1;
329
+ return;
330
+ }
331
+ if (_gate.reason === 'network_unreachable_strict') {
332
+ ui.fail('Cannot reach sealcode.dev to verify access status, and this grant is strict.');
333
+ ui.hint(' Reconnect to the network, then re-run `sealcode unlock`.');
334
+ process.exitCode = 1;
335
+ return;
336
+ }
337
+ ui.fail(`Unlock denied (${_gate.reason}).`);
338
+ process.exitCode = 1;
339
+ return;
340
+ }
341
+ if (_gate.reason === 'network_unreachable') {
342
+ ui.warn(`Could not reach sealcode.dev — proceeding with cached key (lenient grant).`);
343
+ }
344
+ }
345
+ }
346
+
282
347
  const K = await resolveKey(projectRoot, config, { useRecovery: opts.recovery });
283
348
  ui.step(`unlocking ${ui.c.dim(projectRoot)}`);
284
349
  const sp = new ui.Spinner('decrypting').start();
@@ -748,6 +813,39 @@ function build() {
748
813
  }
749
814
  });
750
815
 
816
+ // -------- pause (sealcode@1.2.0) --------
817
+ program
818
+ .command('pause')
819
+ .argument('<grantId>', 'grant ID to pause (see `sealcode grants`)')
820
+ .description('Pause an active access code. Reversible: any live watcher re-locks immediately, but you can resume later.')
821
+ .option('--reason <text>', 'optional note visible to project admins (≤500 chars)')
822
+ .option('--extend-on-resume', 'on resume, shift expiry forward by the paused duration (default: clock keeps ticking)', false)
823
+ .action(async (grantId, opts) => {
824
+ try {
825
+ await runPause({
826
+ grantId,
827
+ reason: opts.reason,
828
+ extendOnResume: !!opts.extendOnResume,
829
+ });
830
+ } catch (err) {
831
+ process.exitCode = reportError(err);
832
+ }
833
+ });
834
+
835
+ // -------- resume (sealcode@1.2.0; --unlock added 1.3.0) --------
836
+ program
837
+ .command('resume')
838
+ .argument('<grantId>', 'grant ID to resume (see `sealcode grants`)')
839
+ .description('Resume a paused access code. The recipient\'s 1.3+ watcher can re-open the working copy automatically — no redeem needed.')
840
+ .option('--unlock', 'also push an unlock event so the recipient\'s files re-materialize automatically (1.3+ watcher required)', false)
841
+ .action(async (grantId, opts) => {
842
+ try {
843
+ await runResume({ grantId, autoUnlock: !!opts.unlock });
844
+ } catch (err) {
845
+ process.exitCode = reportError(err);
846
+ }
847
+ });
848
+
751
849
  // -------- lockdown (sealcode@1.1.0) --------
752
850
  program
753
851
  .command('lockdown')