sealcode 1.2.0 → 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 +1 -1
- package/src/cli-grants.js +66 -8
- package/src/cli-watch.js +139 -3
- package/src/cli.js +63 -4
package/package.json
CHANGED
package/src/cli-grants.js
CHANGED
|
@@ -354,20 +354,24 @@ async function runPause({ grantId, reason, extendOnResume = false }) {
|
|
|
354
354
|
/**
|
|
355
355
|
* sealcode@1.2.0 — Resume a paused access code from the CLI.
|
|
356
356
|
*
|
|
357
|
-
* sealcode resume <grantId>
|
|
357
|
+
* sealcode resume <grantId> [--unlock]
|
|
358
358
|
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
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).
|
|
362
366
|
*/
|
|
363
|
-
async function runResume({ grantId }) {
|
|
364
|
-
if (!grantId) throw new Error('Usage: sealcode resume <grantId>');
|
|
367
|
+
async function runResume({ grantId, autoUnlock = false }) {
|
|
368
|
+
if (!grantId) throw new Error('Usage: sealcode resume <grantId> [--unlock]');
|
|
365
369
|
let res;
|
|
366
370
|
try {
|
|
367
371
|
res = await request(
|
|
368
372
|
'POST',
|
|
369
373
|
`/api/v1/grants/${encodeURIComponent(grantId)}/resume`,
|
|
370
|
-
{ auth: true, body: {} },
|
|
374
|
+
{ auth: true, body: { autoUnlock: !!autoUnlock } },
|
|
371
375
|
);
|
|
372
376
|
} catch (err) {
|
|
373
377
|
if (err instanceof ApiError) {
|
|
@@ -391,7 +395,53 @@ async function runResume({ grantId }) {
|
|
|
391
395
|
const mins = Math.round((res.pausedDurationMs || 0) / 60000);
|
|
392
396
|
process.stdout.write(` expiry shifted forward by ~${mins}m (paused duration)\n`);
|
|
393
397
|
}
|
|
394
|
-
|
|
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
|
+
}
|
|
395
445
|
}
|
|
396
446
|
|
|
397
447
|
async function runRevoke({ grantId }) {
|
|
@@ -481,6 +531,13 @@ async function runRedeem({ projectRoot, code, json = false, agreeNda = false })
|
|
|
481
531
|
source: 'grant',
|
|
482
532
|
grantId: g.id,
|
|
483
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,
|
|
484
541
|
grantExpiresAt: g.expiresAt,
|
|
485
542
|
deviceFingerprint: getDeviceFingerprint(),
|
|
486
543
|
strictWatch: !!g.strictMode,
|
|
@@ -667,4 +724,5 @@ module.exports = {
|
|
|
667
724
|
runLockdown,
|
|
668
725
|
runPause,
|
|
669
726
|
runResume,
|
|
727
|
+
checkUnlockAllowed,
|
|
670
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
|
|
540
|
+
// and exit (revoke/expire/etc), OR soft-lock and stay running (paused).
|
|
534
541
|
if (first.response.action === 'lock') {
|
|
535
|
-
|
|
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';
|
|
@@ -781,7 +916,8 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
|
|
|
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
918
|
// sealcode@1.2.0 — be specific about the "paused" case so the
|
|
784
|
-
// recipient knows it's reversible
|
|
919
|
+
// recipient knows it's reversible. (sealcode@1.3.0+ goes through
|
|
920
|
+
// softLock instead and won't reach this branch.)
|
|
785
921
|
if (label === 'paused') {
|
|
786
922
|
ui.hint(' This access code was PAUSED by the owner — not revoked.');
|
|
787
923
|
ui.hint(` Wait for the owner to resume, then run: ${ui.c.cyan(`sealcode redeem ${code}`)}`);
|
package/src/cli.js
CHANGED
|
@@ -40,6 +40,7 @@ const {
|
|
|
40
40
|
runRedeem,
|
|
41
41
|
runPause,
|
|
42
42
|
runResume,
|
|
43
|
+
checkUnlockAllowed,
|
|
43
44
|
} = require('./cli-grants');
|
|
44
45
|
const {
|
|
45
46
|
runCiCreate,
|
|
@@ -286,6 +287,63 @@ function build() {
|
|
|
286
287
|
try {
|
|
287
288
|
const projectRoot = resolveProject(program.opts());
|
|
288
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
|
+
|
|
289
347
|
const K = await resolveKey(projectRoot, config, { useRecovery: opts.recovery });
|
|
290
348
|
ui.step(`unlocking ${ui.c.dim(projectRoot)}`);
|
|
291
349
|
const sp = new ui.Spinner('decrypting').start();
|
|
@@ -774,14 +832,15 @@ function build() {
|
|
|
774
832
|
}
|
|
775
833
|
});
|
|
776
834
|
|
|
777
|
-
// -------- resume (sealcode@1.2.0) --------
|
|
835
|
+
// -------- resume (sealcode@1.2.0; --unlock added 1.3.0) --------
|
|
778
836
|
program
|
|
779
837
|
.command('resume')
|
|
780
838
|
.argument('<grantId>', 'grant ID to resume (see `sealcode grants`)')
|
|
781
|
-
.description('Resume a paused access code. The recipient
|
|
782
|
-
.
|
|
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) => {
|
|
783
842
|
try {
|
|
784
|
-
await runResume({ grantId });
|
|
843
|
+
await runResume({ grantId, autoUnlock: !!opts.unlock });
|
|
785
844
|
} catch (err) {
|
|
786
845
|
process.exitCode = reportError(err);
|
|
787
846
|
}
|