support for connect & disconnect

This commit is contained in:
tiero
2023-01-04 03:35:48 +01:00
parent f8f67d6749
commit d7903a88bd
9 changed files with 245 additions and 203 deletions

View File

@@ -5,9 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Playground</title> <title>Playground</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
</head> </head>
<body style="background-color:lightgray;"}}> <body>
<div id="root"></div> <div id="root"></div>
<script src="./index.tsx" type="module"></script> <script src="./index.tsx" type="module"></script>
</body> </body>

View File

@@ -1,97 +0,0 @@
import 'react-app-polyfill/ie11';
import * as React from 'react';
import { useEffect } from 'react';
import * as ReactDOM from 'react-dom';
import { Session } from '../src/index';
import { generatePrivateKey, getPublicKey, nip04, relayInit } from 'nostr-tools'
const App = () => {
/* useEffect(() => {
(async () => {
})();
}, []); */
const [walletKey, setWalletKey] = React.useState<{ pk: string; sk: string }>();
const [sessionHolder, setSession] = React.useState<Session>();
const [request, setRequest] = React.useState<any>();
const newWallet = () => {
//this is the wallet public key
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
setWalletKey({ pk, sk });
}
const newSession = async () => {
const session = new Session({
name: 'Auth',
description: 'lorem ipsum dolor sit amet',
url: 'https://vulpem.com',
icons: ['https://vulpem.com/1000x860-p-500.422be1bc.png'],
});
await session.pair(walletKey?.pk);
setSession(session);
};
const listen = async () => {
if (!sessionHolder) return;
// let's query for an event to this wallet pub key
const relay = relayInit('wss://nostr.vulpem.com');
await relay.connect()
relay.on('connect', () => {
console.log(`wallet: connected to ${relay.url}`)
})
relay.on('error', () => {
console.log(`wallet: failed to connect to ${relay.url}`)
})
let sub = relay.sub([{ kinds: [4] }])
// on the receiver side
sub.on('event', async (event) => {
if (!walletKey) return;
const mention = event.tags.find(([k, v]) => k === 'p' && v && v !== '')[1]
if (mention !== walletKey.pk) return;
const plaintext = await nip04.decrypt(walletKey?.sk, sessionHolder.pubKey, event.content);
console.log('wallet', event.id, event.pubkey, JSON.parse(plaintext));
setRequest(JSON.parse(plaintext));
})
sub.on('eose', () => {
sub.unsub()
})
}
return (
<div>
<h1>💸 Wallet</h1>
{walletKey && <p> 🔎 Wallet Pub: {walletKey?.pk} </p>}
<button onClick={newWallet}>Create Wallet</button>
<button disabled={!sessionHolder} onClick={listen}>Listen</button>
{request && <div>
<h1>📨 Incoming Request</h1>
<img height={80} src={request?.icons[0]} />
<p>
Name <b>{request?.name}</b>
</p>
<p>
Description <b>{request?.description}</b>
</p>
<p>
URL: <b>{request?.url}</b>
</p>
</div>}
<hr />
<h1> App </h1>
<button disabled={!walletKey} onClick={newSession}>New Session</button>
</div>
);
};
ReactDOM.render(<App />, document.getElementById('root'));

View File

@@ -1,57 +1,151 @@
import * as React from 'react'; import * as React from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useStatePersist } from 'use-state-persist';
import * as ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom';
import { NostrRPC } from '../src/rpc'; import { broadcastToRelay, Connect, connectToRelay, ConnectURI } from '../src';
import { QRCodeSVG } from 'qrcode.react';
import { getEventHash, getPublicKey, Event } from 'nostr-tools';
class Server extends NostrRPC { const secretKey = "5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3";
async ping(): Promise<string> { const connectURI = new ConnectURI({
return 'pong'; target: getPublicKey(secretKey),
} relayURL: 'wss://nostr.vulpem.com',
} metadata: {
const server = new Server({ name: 'Example',
secretKey: description: '🔉🔉🔉',
'ed779ff047f99c95f732b22c9f8f842afb870c740aab591776ebc7b64e83cf6c', url: 'https://example.com',
}); icons: ['https://example.com/icon.png'],
const client = new NostrRPC({ },
secretKey:
'5acff99d1ad3e1706360d213fd69203312d9b5e91a2d5f2e06100cc6f686e5b3',
}); });
const App = () => { const App = () => {
const [pubkey, setPubkey] = useStatePersist('@pubkey', '');
const [response, setResponse] = useState(''); const [getPublicKeyReply, setGetPublicKeyReply] = useState('');
const [eventWithSig, setEvent] = useState({});
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const sub = await server.listen(); const target = pubkey.length > 0 ? pubkey : undefined;
sub.on('event', (evt) => { const connect = new Connect({
console.log('server received message', evt); secretKey,
target,
}); });
connect.events.on('connect', (pubkey: string) => {
console.log('We are connected to ' + pubkey)
setPubkey(pubkey);
});
connect.events.on('disconnect', () => {
console.log('We got disconnected')
setEvent({});
setPubkey('');
setGetPublicKeyReply('');
});
await connect.init();
})(); })();
}, []); }, []);
const makeCall = async () => { const getPub = async () => {
const result = await client.call({ if (pubkey.length === 0) return;
target: server.self.pubkey, const connect = new Connect({
request: { method: 'ping' }, secretKey,
target: pubkey,
}); });
setResponse(result); const pk = await connect.getPublicKey();
setGetPublicKeyReply(pk);
} }
const sendMessage = async () => {
if (pubkey.length === 0) return;
const connect = new Connect({
secretKey,
target: pubkey,
});
let event: Event = {
kind: 1,
pubkey: pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: "Running Nostr Connect 🔌"
};
event.id = getEventHash(event)
event.sig = await connect.signEvent(event);
const relay = await connectToRelay('wss://relay.damus.io');
await broadcastToRelay(relay, event);
setEvent(event);
}
const isConnected = () => {
return pubkey.length > 0;
}
return ( return (
<div> <>
<h1>Nostr Connect Playground</h1> <section className="container">
<p>Server pubkey: {server.self.pubkey}</p> <div className='content'>
<p>Client pubkey: {client.self.pubkey}</p> <h1 className='title'>Nostr Connect Playground</h1>
<hr /> </div>
<button onClick={makeCall}>Ping</button> <div className='content'>
<br /> <p className='subtitle is-6'><strong>Nostr ID</strong> {getPublicKey(secretKey)}</p>
<p> {response} </p> </div>
</div> <div className='content'>
<p className='subtitle is-6'><strong>Status</strong> {isConnected() ? '🟢 Connected' : '🔴 Disconnected'}</p>
</div>
{!isConnected() && <div className='content has-text-centered'>
<div className='notification is-info'>
<h2 className='title is-5'>Connect with Nostr</h2>
<QRCodeSVG value={connectURI.toString()} />
<input
className='input is-info'
type='text'
value={connectURI.toString()}
readOnly
/>
</div>
</div>}
</section>
<section className="container mt-6">
{
isConnected() &&
<>
<div className='content'>
<h2 className='title is-5'>Get Public Key</h2>
<button className='button is-info' onClick={getPub}>
Get public key
</button>
{getPublicKeyReply.length > 0 && <input
className='input is-info mt-3'
type='text'
value={getPublicKeyReply}
readOnly
/>}
</div>
<div className='content'>
<h2 className='title is-5'>Send a message with text <b>Running Nostr Connect 🔌</b></h2>
<button className='button is-info' onClick={sendMessage}>
Send message to Nostr
</button>
{
Object.keys(eventWithSig).length > 0 &&
<textarea
className="textarea"
readOnly
rows={12}
defaultValue={JSON.stringify(eventWithSig, null, 2)}
/>
}
</div>
</>
}
</section>
</>
) )
} }
ReactDOM.render(<App />, document.getElementById('root')); ReactDOM.render(<App />, document.getElementById('root'));

View File

@@ -8,7 +8,9 @@
"build": "parcel build index.html" "build": "parcel build index.html"
}, },
"dependencies": { "dependencies": {
"react-app-polyfill": "^1.0.0" "qrcode.react": "^3.1.0",
"react-app-polyfill": "^1.0.0",
"use-state-persist": "^0.3.1"
}, },
"alias": { "alias": {
"react": "../node_modules/react", "react": "../node_modules/react",
@@ -18,7 +20,10 @@
"devDependencies": { "devDependencies": {
"@types/react": "^16.9.11", "@types/react": "^16.9.11",
"@types/react-dom": "^16.8.4", "@types/react-dom": "^16.8.4",
"events": "^3.1.0",
"parcel": "^2.8.2", "parcel": "^2.8.2",
"path-browserify": "^1.0.0",
"process": "^0.11.10",
"typescript": "^3.4.5" "typescript": "^3.4.5"
} }
} }

View File

@@ -1002,6 +1002,16 @@ escape-string-regexp@^1.0.5:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
events@^3.1.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
get-port@^4.2.0: get-port@^4.2.0:
version "4.2.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-4.2.0.tgz#e37368b1e863b7629c43c5a323625f95cf24b119" resolved "https://registry.yarnpkg.com/get-port/-/get-port-4.2.0.tgz#e37368b1e863b7629c43c5a323625f95cf24b119"
@@ -1265,6 +1275,11 @@ parse-json@^5.0.0:
json-parse-even-better-errors "^2.3.0" json-parse-even-better-errors "^2.3.0"
lines-and-columns "^1.1.6" lines-and-columns "^1.1.6"
path-browserify@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
path-type@^4.0.0: path-type@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
@@ -1314,6 +1329,11 @@ posthtml@^0.16.4, posthtml@^0.16.5:
posthtml-parser "^0.11.0" posthtml-parser "^0.11.0"
posthtml-render "^3.0.0" posthtml-render "^3.0.0"
process@^0.11.10:
version "0.11.10"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
promise@^8.0.3: promise@^8.0.3:
version "8.3.0" version "8.3.0"
resolved "https://registry.yarnpkg.com/promise/-/promise-8.3.0.tgz#8cb333d1edeb61ef23869fbb8a4ea0279ab60e0a" resolved "https://registry.yarnpkg.com/promise/-/promise-8.3.0.tgz#8cb333d1edeb61ef23869fbb8a4ea0279ab60e0a"
@@ -1321,6 +1341,11 @@ promise@^8.0.3:
dependencies: dependencies:
asap "~2.0.6" asap "~2.0.6"
qrcode.react@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-3.1.0.tgz#5c91ddc0340f768316fbdb8fff2765134c2aecd8"
integrity sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==
raf@^3.4.1: raf@^3.4.1:
version "3.4.1" version "3.4.1"
resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
@@ -1458,6 +1483,13 @@ update-browserslist-db@^1.0.9:
escalade "^3.1.1" escalade "^3.1.1"
picocolors "^1.0.0" picocolors "^1.0.0"
use-state-persist@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/use-state-persist/-/use-state-persist-0.3.1.tgz#ab65aacfdeb4adcba96a7bba60d5bdd7c1531d7c"
integrity sha512-bNU/9uZMNZDhFGSFfC2DOtsLvNU41FU2/87AVsC2kJuEKfqD4ksCdqGD9mUVfoGkahu4nZAMYvvmOVwpT2kTYw==
dependencies:
fast-deep-equal "^3.1.3"
utility-types@^3.10.0: utility-types@^3.10.0:
version "3.10.0" version "3.10.0"
resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b" resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b"

View File

@@ -60,6 +60,7 @@
"typescript": "^4.9.4" "typescript": "^4.9.4"
}, },
"dependencies": { "dependencies": {
"events": "^3.3.0",
"nostr-tools": "^1.0.1" "nostr-tools": "^1.0.1"
} }
} }

View File

@@ -9,13 +9,7 @@ export interface Metadata {
icons?: string[]; icons?: string[];
} }
export enum SessionStatus {
Paired = 'paired',
Unpaired = 'unpaired',
}
export class ConnectURI { export class ConnectURI {
status: SessionStatus = SessionStatus.Unpaired;
target: string; target: string;
metadata: Metadata; metadata: Metadata;
relayURL: string; relayURL: string;
@@ -68,14 +62,13 @@ export class ConnectURI {
relay: this.relayURL, relay: this.relayURL,
secretKey, secretKey,
}); });
const response = await rpc.call({ await rpc.call({
target: this.target, target: this.target,
request: { request: {
method: 'connect', method: 'connect',
params: [getPublicKey(secretKey)], params: [getPublicKey(secretKey)],
}, },
}); }, { skipResponse: true });
if (!response) throw new Error('Invalid response from remote');
return; return;
} }
@@ -85,29 +78,22 @@ export class ConnectURI {
relay: this.relayURL, relay: this.relayURL,
secretKey, secretKey,
}); });
const response = await rpc.call({ await rpc.call({
target: this.target, target: this.target,
request: { request: {
method: 'disconnect', method: 'disconnect',
params: [], params: [],
}, },
}); }, { skipResponse: true });
if (!response) throw new Error('Invalid response from remote');
return; return;
} }
} }
export enum ConnectStatus {
Connected = 'connected',
Disconnected = 'disconnected',
}
export class Connect { export class Connect {
rpc: NostrRPC; rpc: NostrRPC;
target?: string; target?: string;
events = new EventEmitter(); events = new EventEmitter();
status = ConnectStatus.Disconnected;
constructor({ constructor({
target, target,
@@ -121,7 +107,6 @@ export class Connect {
this.rpc = new NostrRPC({ relay, secretKey }); this.rpc = new NostrRPC({ relay, secretKey });
if (target) { if (target) {
this.target = target; this.target = target;
this.status = ConnectStatus.Connected;
} }
} }
@@ -140,19 +125,24 @@ export class Connect {
} catch (ignore) { } catch (ignore) {
return; return;
} }
// ignore all the events that are not NostrRPCRequest events // ignore all the events that are not NostrRPCRequest events
if (!isValidRequest(payload)) return; if (!isValidRequest(payload)) return;
// ignore all the request that are not connect switch (payload.method) {
if (payload.method !== 'connect') return; case 'connect':
if (!payload.params || payload.params.length !== 1) return;
// ignore all the request that are not for us const [pubkey] = payload.params;
if (!payload.params || payload.params.length !== 1) return; this.target = pubkey;
const [pubkey] = payload.params; this.events.emit('connect', pubkey);
return
this.status = ConnectStatus.Connected; case 'disconnect':
this.target = pubkey; this.target = undefined;
this.events.emit('connect', pubkey); this.events.emit('disconnect');
return;
default:
return;
}
}); });
} }
@@ -163,12 +153,8 @@ export class Connect {
this.events.off(evt, cb); this.events.off(evt, cb);
} }
private isConnected() {
return this.status === ConnectStatus.Connected;
}
async getPublicKey(): Promise<string> { async getPublicKey(): Promise<string> {
if (!this.target || !this.isConnected()) throw new Error('Not connected'); if (!this.target) throw new Error('Not connected');
const response = await this.rpc.call({ const response = await this.rpc.call({
target: this.target, target: this.target,
@@ -180,8 +166,19 @@ export class Connect {
return response as string; return response as string;
} }
async signEvent(_event: Event): Promise<Event> { async signEvent(event: Event): Promise<string> {
throw new Error('Not implemented'); if (!this.target) throw new Error('Not connected');
const signature = await this.rpc.call({
target: this.target,
request: {
method: 'sign_event',
params: [event],
},
});
console.log('signature', signature);
return signature as string;
} }
async getRelays(): Promise<{ async getRelays(): Promise<{

View File

@@ -9,6 +9,7 @@ import {
Event, Event,
Sub, Sub,
Filter, Filter,
Relay,
} from 'nostr-tools'; } from 'nostr-tools';
export interface NostrRPCRequest { export interface NostrRPCRequest {
@@ -46,38 +47,19 @@ export class NostrRPC {
method: string; method: string;
params?: any[]; params?: any[];
}; };
}): Promise<any> { }, opts?: { skipResponse?: boolean, timeout?: number }): Promise<any> {
const relay = await relayInit(this.relay); // connect to relay
const relay = await connectToRelay(this.relay);
// prepare request to be sent // prepare request to be sent
const request = prepareRequest(id, method, params); const request = prepareRequest(id, method, params);
const event = await prepareEvent(this.self.secret, target, request); const event = await prepareEvent(this.self.secret, target, request);
// connect to relay await broadcastToRelay(relay, event);
await relay.connect();
await new Promise<void>((resolve, reject) => {
relay.on('connect', () => {
resolve();
});
relay.on('error', () => {
reject(`not possible to connect to ${relay.url}`);
});
});
// send request via relay
await new Promise<void>((resolve, reject) => {
relay.on('error', () => {
reject(`failed to connect to ${relay.url}`);
});
const pub = relay.publish(event);
pub.on('failed', (reason: any) => {
reject(reason);
});
pub.on('seen', () => {
resolve();
});
});
// waiting for response from remote // waiting for response from remote
if (opts && opts.skipResponse === true) return Promise.resolve();
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
let sub = relay.sub([ let sub = relay.sub([
{ {
@@ -122,16 +104,7 @@ export class NostrRPC {
} }
async listen(): Promise<Sub> { async listen(): Promise<Sub> {
const relay = relayInit(this.relay); const relay = await connectToRelay(this.relay);
await relay.connect();
await new Promise<void>((resolve, reject) => {
relay.on('connect', () => {
resolve();
});
relay.on('error', () => {
reject(`not possible to connect to ${relay.url}`);
});
});
let sub = relay.sub([ let sub = relay.sub([
{ {
@@ -159,6 +132,7 @@ export class NostrRPC {
if (!isValidRequest(payload)) return; if (!isValidRequest(payload)) return;
// handle request // handle request
if (typeof this[payload.method] !== 'function') Promise.resolve();
const response = await this.handleRequest(payload); const response = await this.handleRequest(payload);
const body = prepareResponse( const body = prepareResponse(
@@ -292,3 +266,33 @@ export function isValidResponse(payload: any): boolean {
return true; return true;
} }
export async function connectToRelay(realayURL: string) {
const relay = relayInit(realayURL);
await relay.connect();
await new Promise<void>((resolve, reject) => {
relay.on('connect', () => {
resolve();
});
relay.on('error', () => {
reject(`not possible to connect to ${relay.url}`);
});
});
return relay;
}
export async function broadcastToRelay(relay: Relay, event: Event) {
// send request via relay
return await new Promise<void>((resolve, reject) => {
relay.on('error', () => {
reject(`failed to connect to ${relay.url}`);
});
const pub = relay.publish(event);
pub.on('failed', (reason: any) => {
reject(reason);
});
pub.on('seen', () => {
resolve();
});
});
}

View File

@@ -2984,6 +2984,11 @@ esutils@^2.0.2:
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
events@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
exec-sh@^0.3.2: exec-sh@^0.3.2:
version "0.3.6" version "0.3.6"
resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc"