echoclaw-relay-agent 0.14.0 → 0.15.2
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/dist/RelayAgent.js +51 -28
- package/dist/RelayClient.d.ts +1 -0
- package/dist/RelayClient.js +117 -41
- package/dist/cli.js +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.js +11 -0
- package/package.json +1 -1
package/dist/RelayAgent.js
CHANGED
|
@@ -23,6 +23,7 @@ import { SessionStore } from './relay/SessionStore.js';
|
|
|
23
23
|
import { IdentityStore } from './relay/IdentityStore.js';
|
|
24
24
|
import { FrameCrypto } from './crypto/FrameCrypto.js';
|
|
25
25
|
import { GatewayManager } from './gateway/GatewayManager.js';
|
|
26
|
+
import { IdentityExchangeError } from './types.js';
|
|
26
27
|
export class RelayAgent extends EventEmitter {
|
|
27
28
|
constructor(config) {
|
|
28
29
|
super();
|
|
@@ -537,7 +538,7 @@ export class RelayAgent extends EventEmitter {
|
|
|
537
538
|
await this._handleIdentityExchange(sessionData, freshPairing);
|
|
538
539
|
}
|
|
539
540
|
catch (err) {
|
|
540
|
-
this.emit('error', new
|
|
541
|
+
this.emit('error', new IdentityExchangeError(`Identity exchange failed: ${err instanceof Error ? err.message : err}`));
|
|
541
542
|
}
|
|
542
543
|
}
|
|
543
544
|
// Persist session (with identity fields if exchange succeeded)
|
|
@@ -564,35 +565,57 @@ export class RelayAgent extends EventEmitter {
|
|
|
564
565
|
* Agent waits for desktop's identity_exchange, then responds with its own.
|
|
565
566
|
*/
|
|
566
567
|
async _handleIdentityExchange(sessionData, forceExchange = false) {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
//
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
568
|
+
// Start listening for desktop's identity_exchange IMMEDIATELY — before any
|
|
569
|
+
// async work — to prevent a race condition where the desktop sends its
|
|
570
|
+
// identity_exchange before our loadOrCreate() completes.
|
|
571
|
+
// The desktop (warm restart, identity cached) may complete onPaired() and
|
|
572
|
+
// send the message before our cold-start I/O finishes.
|
|
573
|
+
let earlyIdentity = null;
|
|
574
|
+
const earlyHandler = (payload) => {
|
|
575
|
+
if (payload?.type === 'identity_exchange' && payload.ed25519_pubkey) {
|
|
576
|
+
earlyIdentity = payload;
|
|
577
|
+
this.removeListener('message', earlyHandler);
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
this.on('message', earlyHandler);
|
|
581
|
+
try {
|
|
582
|
+
const identity = await this.identityStore.loadOrCreate();
|
|
583
|
+
const ed = this.identityStore.getEd25519Identity();
|
|
584
|
+
// Populate session with own identity
|
|
585
|
+
sessionData.identityDeviceId = identity.deviceId;
|
|
586
|
+
// If we already have peer's key from previous pairing, skip exchange
|
|
587
|
+
// UNLESS forceExchange is true (fresh pairing → may be a new desktop machine).
|
|
588
|
+
if (identity.peerPublicKeyRaw && !forceExchange) {
|
|
589
|
+
sessionData.peerPublicKeyRaw = identity.peerPublicKeyRaw;
|
|
590
|
+
sessionData.bindingSecret = identity.bindingSecret;
|
|
591
|
+
this._identityExchanged = true;
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
// Remove early handler before entering wait — prevents both handlers from
|
|
595
|
+
// processing the same message simultaneously (Reviewer #2, #3 finding).
|
|
596
|
+
this.removeListener('message', earlyHandler);
|
|
597
|
+
// Use early-captured message if available, otherwise wait with timeout
|
|
598
|
+
const peerIdentity = earlyIdentity ?? await this._waitForIdentityExchange(10000);
|
|
599
|
+
// Respond with our own identity
|
|
600
|
+
const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
|
|
601
|
+
await this.send({
|
|
602
|
+
type: 'identity_exchange',
|
|
603
|
+
ed25519_pubkey: myPubKeyBase64,
|
|
604
|
+
device_id: identity.deviceId,
|
|
605
|
+
});
|
|
606
|
+
// Persist peer's public key
|
|
607
|
+
await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
|
|
608
|
+
sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
|
|
576
609
|
this._identityExchanged = true;
|
|
577
|
-
|
|
610
|
+
this.emit('identity_exchanged', {
|
|
611
|
+
ownDeviceId: identity.deviceId,
|
|
612
|
+
peerDeviceId: peerIdentity.device_id,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
finally {
|
|
616
|
+
// Clean up early handler if still registered
|
|
617
|
+
this.removeListener('message', earlyHandler);
|
|
578
618
|
}
|
|
579
|
-
// Wait for desktop's identity_exchange (10s timeout)
|
|
580
|
-
const peerIdentity = await this._waitForIdentityExchange(10000);
|
|
581
|
-
// Respond with our own identity
|
|
582
|
-
const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
|
|
583
|
-
await this.send({
|
|
584
|
-
type: 'identity_exchange',
|
|
585
|
-
ed25519_pubkey: myPubKeyBase64,
|
|
586
|
-
device_id: identity.deviceId,
|
|
587
|
-
});
|
|
588
|
-
// Persist peer's public key
|
|
589
|
-
await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
|
|
590
|
-
sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
|
|
591
|
-
this._identityExchanged = true;
|
|
592
|
-
this.emit('identity_exchanged', {
|
|
593
|
-
ownDeviceId: identity.deviceId,
|
|
594
|
-
peerDeviceId: peerIdentity.device_id,
|
|
595
|
-
});
|
|
596
619
|
}
|
|
597
620
|
/**
|
|
598
621
|
* Wait for an identity_exchange message from the desktop.
|
package/dist/RelayClient.d.ts
CHANGED
package/dist/RelayClient.js
CHANGED
|
@@ -30,6 +30,7 @@ import { PairingProtocol } from './relay/PairingProtocol.js';
|
|
|
30
30
|
import { SessionStore } from './relay/SessionStore.js';
|
|
31
31
|
import { IdentityStore } from './relay/IdentityStore.js';
|
|
32
32
|
import { FrameCrypto } from './crypto/FrameCrypto.js';
|
|
33
|
+
import { IdentityExchangeError } from './types.js';
|
|
33
34
|
export class RelayClient extends EventEmitter {
|
|
34
35
|
constructor(config) {
|
|
35
36
|
super();
|
|
@@ -93,6 +94,12 @@ export class RelayClient extends EventEmitter {
|
|
|
93
94
|
writable: true,
|
|
94
95
|
value: false
|
|
95
96
|
});
|
|
97
|
+
Object.defineProperty(this, "_rekeyInFlight", {
|
|
98
|
+
enumerable: true,
|
|
99
|
+
configurable: true,
|
|
100
|
+
writable: true,
|
|
101
|
+
value: false
|
|
102
|
+
});
|
|
96
103
|
Object.defineProperty(this, "_rekeyRetryCount", {
|
|
97
104
|
enumerable: true,
|
|
98
105
|
configurable: true,
|
|
@@ -242,6 +249,7 @@ export class RelayClient extends EventEmitter {
|
|
|
242
249
|
this._stopped = true;
|
|
243
250
|
this._paired = false;
|
|
244
251
|
this._needsRekey = false;
|
|
252
|
+
this._rekeyInFlight = false;
|
|
245
253
|
this._activePairing?.abort();
|
|
246
254
|
this._activePairing = null;
|
|
247
255
|
this.transport?.disconnect();
|
|
@@ -510,7 +518,7 @@ export class RelayClient extends EventEmitter {
|
|
|
510
518
|
catch (err) {
|
|
511
519
|
// Identity exchange failure is non-fatal — connection works without it,
|
|
512
520
|
// but identity reconnect won't be available.
|
|
513
|
-
this.emit('error', new
|
|
521
|
+
this.emit('error', new IdentityExchangeError(`Identity exchange failed: ${err instanceof Error ? err.message : err}`));
|
|
514
522
|
}
|
|
515
523
|
}
|
|
516
524
|
// Persist session (with identity fields if exchange succeeded)
|
|
@@ -527,36 +535,56 @@ export class RelayClient extends EventEmitter {
|
|
|
527
535
|
* Desktop initiates by sending its identity first, then waits for agent's response.
|
|
528
536
|
*/
|
|
529
537
|
async _performIdentityExchange(sessionData, forceExchange = false) {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
538
|
+
// Register listener IMMEDIATELY — before any async work — to prevent
|
|
539
|
+
// a race condition where the agent responds before we start waiting.
|
|
540
|
+
// Agent (warm restart, identity cached) may send identity_exchange
|
|
541
|
+
// before our loadOrCreate() or send() completes.
|
|
542
|
+
let earlyIdentity = null;
|
|
543
|
+
const earlyHandler = (payload) => {
|
|
544
|
+
if (payload?.type === 'identity_exchange' && payload.ed25519_pubkey) {
|
|
545
|
+
earlyIdentity = payload;
|
|
546
|
+
this.removeListener('message', earlyHandler);
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
this.on('message', earlyHandler);
|
|
550
|
+
try {
|
|
551
|
+
const identity = await this.identityStore.loadOrCreate();
|
|
552
|
+
const ed = this.identityStore.getEd25519Identity();
|
|
553
|
+
// Populate session with own identity
|
|
554
|
+
sessionData.identityDeviceId = identity.deviceId;
|
|
555
|
+
// If we already have peer's key (from previous pairing stored in identity file),
|
|
556
|
+
// skip the exchange — just carry it forward.
|
|
557
|
+
// UNLESS forceExchange is true (fresh pairing with new code → may be a new agent machine).
|
|
558
|
+
if (identity.peerPublicKeyRaw && !forceExchange) {
|
|
559
|
+
sessionData.peerPublicKeyRaw = identity.peerPublicKeyRaw;
|
|
560
|
+
sessionData.bindingSecret = identity.bindingSecret;
|
|
561
|
+
this._identityExchanged = true;
|
|
562
|
+
return; // finally will clean up earlyHandler
|
|
563
|
+
}
|
|
564
|
+
// Send our Ed25519 public key to the agent (through E2E encrypted DATA channel)
|
|
565
|
+
const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
|
|
566
|
+
await this.send({
|
|
567
|
+
type: 'identity_exchange',
|
|
568
|
+
ed25519_pubkey: myPubKeyBase64,
|
|
569
|
+
device_id: identity.deviceId,
|
|
570
|
+
});
|
|
571
|
+
// Remove early handler before entering wait — prevents dual listeners
|
|
572
|
+
this.removeListener('message', earlyHandler);
|
|
573
|
+
// Use early-captured message if available, otherwise wait with timeout
|
|
574
|
+
const peerIdentity = earlyIdentity ?? await this._waitForIdentityExchange(10000);
|
|
575
|
+
// Persist peer's public key
|
|
576
|
+
await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
|
|
577
|
+
sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
|
|
540
578
|
this._identityExchanged = true;
|
|
541
|
-
|
|
579
|
+
this.emit('identity_exchanged', {
|
|
580
|
+
ownDeviceId: identity.deviceId,
|
|
581
|
+
peerDeviceId: peerIdentity.device_id,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
// Clean up early handler if still registered (e.g. early return path)
|
|
586
|
+
this.removeListener('message', earlyHandler);
|
|
542
587
|
}
|
|
543
|
-
// Send our Ed25519 public key to the agent (through E2E encrypted DATA channel)
|
|
544
|
-
const myPubKeyBase64 = toBase64(ed.publicKeyRaw);
|
|
545
|
-
await this.send({
|
|
546
|
-
type: 'identity_exchange',
|
|
547
|
-
ed25519_pubkey: myPubKeyBase64,
|
|
548
|
-
device_id: identity.deviceId,
|
|
549
|
-
});
|
|
550
|
-
// Wait for agent's identity_exchange response (10s timeout)
|
|
551
|
-
const peerIdentity = await this._waitForIdentityExchange(10000);
|
|
552
|
-
// Persist peer's public key
|
|
553
|
-
await this.identityStore.updatePeer(peerIdentity.ed25519_pubkey);
|
|
554
|
-
sessionData.peerPublicKeyRaw = peerIdentity.ed25519_pubkey;
|
|
555
|
-
this._identityExchanged = true;
|
|
556
|
-
this.emit('identity_exchanged', {
|
|
557
|
-
ownDeviceId: identity.deviceId,
|
|
558
|
-
peerDeviceId: peerIdentity.device_id,
|
|
559
|
-
});
|
|
560
588
|
}
|
|
561
589
|
/**
|
|
562
590
|
* Wait for an identity_exchange message from the agent.
|
|
@@ -650,34 +678,82 @@ export class RelayClient extends EventEmitter {
|
|
|
650
678
|
});
|
|
651
679
|
this.transport.on('open', () => {
|
|
652
680
|
if (this._paired && this._needsRekey && this.pairingCode && this.transport) {
|
|
681
|
+
// If a re-key is already in flight on a previous (now-dead) connection,
|
|
682
|
+
// abort it and start fresh on this new connection. Just returning would
|
|
683
|
+
// leave the new WebSocket idle while the old pairing hangs on a dead socket.
|
|
684
|
+
// (Reviewer finding: Gemini + Claude both flagged simple `return` as a bug.)
|
|
685
|
+
if (this._rekeyInFlight) {
|
|
686
|
+
this._activePairing?.abort();
|
|
687
|
+
this._rekeyInFlight = false;
|
|
688
|
+
// Fall through to start fresh re-key on the new connection.
|
|
689
|
+
}
|
|
653
690
|
// Server requested re-ECDH (e.g. AGENT_RESTARTED) —
|
|
654
|
-
//
|
|
691
|
+
// start fresh handshake on the (possibly new) transport.
|
|
655
692
|
// Invalidate old crypto state: agent has new keys, old ones are useless.
|
|
656
|
-
|
|
657
|
-
this.
|
|
693
|
+
// Note: _needsRekey stays true until handshake succeeds.
|
|
694
|
+
this._rekeyInFlight = true;
|
|
658
695
|
this.sessionKey = null;
|
|
659
696
|
this.frameCrypto = null;
|
|
660
697
|
this.setStatus('connecting');
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
698
|
+
let pairing;
|
|
699
|
+
let pairingPromise;
|
|
700
|
+
try {
|
|
701
|
+
pairing = new PairingProtocol(this.transport);
|
|
702
|
+
this._activePairing = pairing;
|
|
703
|
+
pairingPromise = pairing.pairAsClient(this.pairingCode);
|
|
704
|
+
}
|
|
705
|
+
catch (syncErr) {
|
|
706
|
+
// Guard against synchronous throw from constructor or pairAsClient
|
|
707
|
+
this._rekeyInFlight = false;
|
|
708
|
+
this._activePairing = null;
|
|
709
|
+
this.emit('error', syncErr instanceof Error ? syncErr : new Error(String(syncErr)));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
pairingPromise
|
|
664
713
|
.then(result => {
|
|
665
|
-
|
|
714
|
+
// Identity guard: if _activePairing changed, this is a stale
|
|
715
|
+
// (aborted) promise settling as microtask — ignore it.
|
|
716
|
+
if (this._activePairing !== pairing)
|
|
717
|
+
return;
|
|
718
|
+
this._rekeyInFlight = false;
|
|
719
|
+
this._activePairing = null;
|
|
720
|
+
this._needsRekey = false; // Clear only on success
|
|
721
|
+
this._rekeyRetryCount = 0;
|
|
666
722
|
this.onPaired(result, 'resumed');
|
|
667
723
|
})
|
|
668
724
|
.catch(err => {
|
|
725
|
+
// Identity guard: old aborted pairing's .catch() fires as microtask
|
|
726
|
+
// AFTER new pairing is set up — must not clobber new pairing's state.
|
|
727
|
+
if (this._activePairing !== pairing)
|
|
728
|
+
return;
|
|
729
|
+
this._rekeyInFlight = false;
|
|
669
730
|
this._activePairing = null;
|
|
670
731
|
this._rekeyRetryCount++;
|
|
671
|
-
// Circuit breaker: stop after 5 consecutive re-key failures
|
|
732
|
+
// Circuit breaker: stop after 5 consecutive re-key failures.
|
|
733
|
+
// Clear all rekey state so next connect() can start fresh.
|
|
734
|
+
// IMPORTANT: reset ALL state before emitting error — emit() is
|
|
735
|
+
// synchronous, and a listener may call connect() immediately.
|
|
672
736
|
if (this._rekeyRetryCount >= 5) {
|
|
737
|
+
const retries = this._rekeyRetryCount;
|
|
738
|
+
// 1. Reset all in-memory state
|
|
673
739
|
this._needsRekey = false;
|
|
674
|
-
this.
|
|
675
|
-
|
|
740
|
+
this._paired = false;
|
|
741
|
+
this._identityExchanged = false;
|
|
742
|
+
this.sessionKey = null;
|
|
743
|
+
this.frameCrypto = null;
|
|
744
|
+
this._rekeyRetryCount = 0;
|
|
745
|
+
// 2. Clear disk state + teardown transport
|
|
746
|
+
this.sessionStore.clear().catch((clearErr) => {
|
|
747
|
+
this.emit('error', new Error(`Failed to clear stale session: ${clearErr instanceof Error ? clearErr.message : clearErr}`));
|
|
748
|
+
});
|
|
676
749
|
this.transport?.disconnect();
|
|
750
|
+
this.setStatus('disconnected');
|
|
751
|
+
// 3. Notify LAST — state is fully clean if listener calls connect()
|
|
752
|
+
this.emit('error', new Error(`Re-key failed after ${retries} retries, giving up`));
|
|
677
753
|
return;
|
|
678
754
|
}
|
|
679
|
-
// Re-key failed —
|
|
680
|
-
|
|
755
|
+
// Re-key failed — _needsRekey stays true (never cleared), so next
|
|
756
|
+
// on('open') will retry automatically.
|
|
681
757
|
// If WS is still OPEN (pairing failed at protocol level, not transport level),
|
|
682
758
|
// _needsRekey won't be consumed until next on('open').
|
|
683
759
|
// Force a reconnect to trigger a fresh on('open').
|
package/dist/cli.js
CHANGED
|
@@ -283,6 +283,13 @@ async function runSetup(code, relay, bridgePort) {
|
|
|
283
283
|
resolve(info);
|
|
284
284
|
});
|
|
285
285
|
agent.on('error', (err) => {
|
|
286
|
+
// Identity exchange failure is non-fatal — ECDH pairing still works,
|
|
287
|
+
// only identity reconnect won't be available. Don't reject setup.
|
|
288
|
+
if (err.constructor?.name === 'IdentityExchangeError') {
|
|
289
|
+
console.log(` ${YELLOW}Warning: ${err.message}${RESET}`);
|
|
290
|
+
console.log(` ${DIM}Connection will work, but identity reconnect won't be available.${RESET}`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
286
293
|
clearTimeout(timeout);
|
|
287
294
|
reject(err);
|
|
288
295
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* connect(pairingCode) → send(payload) / subscribe(event, cb) → disconnect()
|
|
9
9
|
*/
|
|
10
10
|
export type { EncryptedFrame, RelayMessage, SessionData, ReconnectConfig, RelayClientConfig, RelayAgentConfig, ClientStatus, ConnectedEvent, DisconnectedEvent, ReconnectingEvent, PairedEvent, ErrorEvent, } from './types.js';
|
|
11
|
-
export { RELAY_PROTOCOL_VERSION, DEFAULT_RECONNECT } from './types.js';
|
|
11
|
+
export { RELAY_PROTOCOL_VERSION, DEFAULT_RECONNECT, IdentityExchangeError } from './types.js';
|
|
12
12
|
export { FrameCrypto } from './crypto/FrameCrypto.js';
|
|
13
13
|
export { ReconnectPolicy } from './relay/ReconnectPolicy.js';
|
|
14
14
|
export { SessionStore } from './relay/SessionStore.js';
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* External callers only need:
|
|
8
8
|
* connect(pairingCode) → send(payload) / subscribe(event, cb) → disconnect()
|
|
9
9
|
*/
|
|
10
|
-
export { RELAY_PROTOCOL_VERSION, DEFAULT_RECONNECT } from './types.js';
|
|
10
|
+
export { RELAY_PROTOCOL_VERSION, DEFAULT_RECONNECT, IdentityExchangeError } from './types.js';
|
|
11
11
|
// ── Crypto ───────────────────────────────────────────────────────
|
|
12
12
|
export { FrameCrypto } from './crypto/FrameCrypto.js';
|
|
13
13
|
// ── Relay ────────────────────────────────────────────────────────
|
package/dist/types.d.ts
CHANGED
|
@@ -73,5 +73,13 @@ export interface PeerStatusEvent {
|
|
|
73
73
|
status: 'warning' | 'online';
|
|
74
74
|
timestamp?: number;
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Non-fatal error emitted when Ed25519 identity exchange fails.
|
|
78
|
+
* Connection still works — only identity-based reconnect is unavailable.
|
|
79
|
+
* Consumers (e.g. setup CLI) can check `instanceof` to avoid treating this as fatal.
|
|
80
|
+
*/
|
|
81
|
+
export declare class IdentityExchangeError extends Error {
|
|
82
|
+
constructor(message: string);
|
|
83
|
+
}
|
|
76
84
|
export type { InstallRequest, InstallAbort, InstallAck, InstallStatus, InstallResult, InstallPreviewData, InstallPreviewResult, InstallPhase, InstallResultStatus, InstallIncoming, InstallOutgoing, } from './install/types.js';
|
|
77
85
|
export { INSTALL_ERROR_CODES } from './install/types.js';
|
package/dist/types.js
CHANGED
|
@@ -9,4 +9,15 @@ export const DEFAULT_RECONNECT = {
|
|
|
9
9
|
maxDelayMs: 30000,
|
|
10
10
|
jitterFactor: 0.3,
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Non-fatal error emitted when Ed25519 identity exchange fails.
|
|
14
|
+
* Connection still works — only identity-based reconnect is unavailable.
|
|
15
|
+
* Consumers (e.g. setup CLI) can check `instanceof` to avoid treating this as fatal.
|
|
16
|
+
*/
|
|
17
|
+
export class IdentityExchangeError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'IdentityExchangeError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
12
23
|
export { INSTALL_ERROR_CODES } from './install/types.js';
|
package/package.json
CHANGED