@holochain/hc-spin
Version:
CLI to run Holochain apps during development.
403 lines (361 loc) • 13.9 kB
text/typescript
import {
AdminWebsocket,
AgentPubKey,
AppWebsocket,
CallZomeRequest,
CallZomeRequestSigned,
getNonceExpiration,
randomNonce,
} from '@holochain/client';
import { ZomeCallSigner } from '@holochain/hc-spin-rust-utils';
import { encode } from '@msgpack/msgpack';
import * as childProcess from 'child_process';
import { Command, Option } from 'commander';
import { app, BrowserWindow, ipcMain, IpcMainInvokeEvent, Menu, protocol } from 'electron';
import contextMenu from 'electron-context-menu';
import fs from 'fs';
import getPort from 'get-port';
import { sha512 } from 'js-sha512';
import { nanoid } from 'nanoid';
import path from 'path';
import split from 'split';
import { menu } from './menu';
import { validateCliArgs } from './validateArgs';
import { createHappWindow, loadHappWindow } from './windows';
const rustUtils = require('@holochain/hc-spin-rust-utils');
const cli = new Command();
cli
.name('hc-spin')
.description('CLI to run Holochain apps during development.')
.version(`${__PACKAGE_VERSION__} (built for holochain ${__HOLOCHAIN_VERSION__})`)
.argument(
'<path>',
'Path to .webhapp or .happ file to launch. If a .happ file is passed, either a UI path must be specified via --ui-path or a port pointing to a localhost server via --ui-port',
)
.option(
'--app-id <string>',
'Install the app with a specific app id. By default the app id is derived from the name of the .webhapp/.happ file that you pass but this option allows you to set it explicitly',
)
.option(
'--bootstrap-url <url>',
'Url of the bootstrap server to use. By default, hc spin spins up a local development bootstrap server for you but this argument allows you to specify a custom one.',
)
.option('--holochain-path <path>', 'Set the path to the holochain binary [default: holochain].')
.addOption(
new Option('-n, --num-agents <number>', 'How many agents to spawn the app for.').argParser(
parseInt,
),
)
.option('--network-seed <string>', 'Install the app with a specific network seed.')
.addOption(
new Option(
'-t, --target-arc-factor <number>',
'Set the target arc factor for all conductors. In normal operation, leave this as the default 1. For leacher/zero-arc nodes that do not contribute to gossip, set to 0.',
).argParser(parseInt),
)
.option('--ui-path <path>', "Path to the folder containing the index.html of the webhapp's UI.")
.option(
'--ui-port <number>',
'Port pointing to a localhost dev server that serves your UI assets.',
)
.option(
'--relay-url <url>',
'Url of the relay server to use. By default, hc spin spins up a local development relay server for you but this argument allows you to specify a custom one.',
)
.option('--open-devtools', 'Automatically open the devtools on startup.');
cli.parse();
// console.log('Got CLI opts: ', cli.opts());
// console.log('Got CLI args: ', cli.args);
// In nix shell and on Windows SIGINT does not seem to be emitted so it is read from the command line instead.
// https://stackoverflow.com/questions/10021373/what-is-the-windows-equivalent-of-process-onsigint-in-node-js
const rl = require('readline').createInterface({
input: process.stdin,
output: process.stdout,
});
rl.on('SIGINT', function () {
process.emit('SIGINT');
});
process.on('SIGINT', () => {
app.quit();
});
// Garbage collect unused directories of previous runs
const files = fs.readdirSync(app.getPath('temp'));
const hcSpinFolders = files.filter((file) => file.startsWith(`hc-spin-`));
for (const folder of hcSpinFolders) {
const folderPath = path.join(app.getPath('temp'), folder);
const folderFiles = fs.readdirSync(folderPath);
if (folderFiles.includes('.abandoned')) {
fs.rmSync(folderPath, { recursive: true, force: true, maxRetries: 4 });
}
}
// Set app path to temp directory
const DATA_ROOT_DIR = path.join(app.getPath('temp'), `hc-spin-${nanoid(8)}`);
app.setPath('userData', path.join(DATA_ROOT_DIR, 'electron'));
Menu.setApplicationMenu(menu);
const CLI_OPTS = validateCliArgs(cli.args, cli.opts(), DATA_ROOT_DIR);
// const SANDBOX_DIRECTORIES: Array<string> = [];
const SANDBOX_PROCESSES: childProcess.ChildProcessWithoutNullStreams[] = [];
const WINDOW_INFO_MAP: Record<
string,
{ agentPubKey: AgentPubKey; zomeCallSigner: ZomeCallSigner }
> = {};
protocol.registerSchemesAsPrivileged([
{
scheme: 'webhapp',
privileges: { standard: true, secure: true, stream: true },
},
]);
contextMenu({
showSaveImageAs: true,
showSearchWithGoogle: false,
showInspectElement: true,
append: (_defaultActions, _parameters, browserWindow) => [
{
label: 'Reload',
click: () => (browserWindow as BrowserWindow).reload(),
},
],
});
const handleSignZomeCall = async (
e: IpcMainInvokeEvent,
request: CallZomeRequest,
): Promise<CallZomeRequestSigned> => {
const windowInfo = WINDOW_INFO_MAP[e.sender.id];
if (!request.provenance)
return Promise.reject(
'Call zome request has provenance field not set. This should be set by the js-client.',
);
if (request.provenance.toString() !== Array.from(windowInfo.agentPubKey).toString())
return Promise.reject('Agent public key unauthorized.');
const zomeCallToSign: CallZomeRequest = {
cell_id: request.cell_id,
zome_name: request.zome_name,
fn_name: request.fn_name,
payload: encode(request.payload),
provenance: request.provenance,
nonce: await randomNonce(),
expires_at: getNonceExpiration(),
};
const zomeCallBytes = encode(zomeCallToSign);
const bytesHash = sha512.array(zomeCallBytes);
const signature: number[] = await windowInfo.zomeCallSigner.signZomeCall(
bytesHash,
Array.from(request.provenance),
);
const signedZomeCall: CallZomeRequestSigned = {
bytes: zomeCallBytes,
signature: Uint8Array.from(signature),
};
return signedZomeCall;
};
async function startLocalServices(): Promise<[string, string]> {
const localServicesHandle = childProcess.spawn('kitsune2-bootstrap-srv');
return new Promise((resolve) => {
let bootStrapUrl;
let relayUrl;
let bootstrapRunning = false;
let relayRunning = false;
localServicesHandle.stdout.pipe(split()).on('data', async (line: string) => {
console.log(`[hc-spin] | [kitsune2-bootstrap-srv]: ${line}`);
if (line.includes('#kitsune2_bootstrap_srv#listening#')) {
const hostAndPort = line.split('#kitsune2_bootstrap_srv#listening#')[1].split('#')[0];
bootStrapUrl = `http://${hostAndPort}`;
relayUrl = `http://${hostAndPort}`;
}
if (line.includes('#kitsune2_bootstrap_srv#running#')) {
bootstrapRunning = true;
relayRunning = true;
}
if (bootstrapRunning && relayRunning && bootStrapUrl && relayUrl)
resolve([bootStrapUrl, relayUrl]);
});
localServicesHandle.stderr.pipe(split()).on('data', async (line: string) => {
console.log(`[hc-spin] | [hc run-local-services] ERROR: ${line}`);
});
});
}
type PortsInfo = {
admin_port: number;
app_ports: number[];
};
async function spawnSandboxes(
nAgents: number,
happPath: string,
bootStrapUrl: string,
relayUrl: string,
appId: string,
networkSeed?: string,
targetArcFactor?: number,
): Promise<
[childProcess.ChildProcessWithoutNullStreams, Array<string>, Record<number, PortsInfo>]
> {
const generateArgs = [
'sandbox',
'--piped',
'generate',
'--num-sandboxes',
nAgents.toString(),
'--app-id',
appId,
'--run',
];
let appPorts = '';
for (let i = 1; i <= nAgents; i++) {
const appPort = await getPort();
appPorts += `${appPort},`;
}
generateArgs.push(appPorts.slice(0, appPorts.length - 1));
if (networkSeed) {
generateArgs.push('--network-seed');
generateArgs.push(networkSeed);
}
generateArgs.push(happPath, 'network');
if (targetArcFactor !== undefined) {
generateArgs.push('--target-arc-factor', targetArcFactor.toString());
}
generateArgs.push('--bootstrap', bootStrapUrl, 'quic', relayUrl);
let readyConductors = 0;
const portsInfo: Record<number, PortsInfo> = {};
const sandboxPaths: Array<string> = [];
const lairUrls: string[] = [];
const sandboxHandle = childProcess.spawn('hc', generateArgs);
sandboxHandle.stdin.write('pass');
sandboxHandle.stdin.end();
return new Promise((resolve) => {
sandboxHandle.stdout.pipe(split()).on('data', async (line: string) => {
console.log(`[hc-spin] | [hc sandbox]: ${line}`);
if (line.includes('Created directory at:')) {
// hc-sandbox: Created directory at: /tmp/v7cLY7ls3onZFMmyrFi5y Keep this path to rerun the same sandbox. It has also been saved to a file called `.hc` in your current working directory.
const sanboxPath = line
.split('\x1B[1;4;48;5;254;38;5;4m')[1]
.split('\x1B[0m \x1B[1m')[0]
.trim();
sandboxPaths.push(sanboxPath);
}
if (line.includes('lair-keystore connection_url')) {
const lairKeystoreUrl = line.split('#')[2].trim();
lairUrls.push(lairKeystoreUrl);
}
if (line.includes('Conductor launched')) {
// hc-sandbox: Conductor launched #!1 {"admin_port":37045,"app_ports":[]}
const split1 = line.split('{');
const ports: PortsInfo = JSON.parse(`{${split1[1]}`);
const conductorNum = split1[0].split('#!')[1].trim();
portsInfo[conductorNum] = ports;
// hc-sandbox: Conductor launched #!1 {"admin_port":32805,"app_ports":[45309]}
readyConductors += 1;
if (readyConductors === nAgents) resolve([sandboxHandle, sandboxPaths, portsInfo]);
}
});
sandboxHandle.stderr.pipe(split()).on('data', async (line: string) => {
console.log(`[hc-spin] | [hc sandbox] ERROR: ${line}`);
});
});
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
ipcMain.handle('sign-zome-call', handleSignZomeCall);
let happTargetDir: string | undefined;
// TODO unpack assets to UI dir if webhapp is passed
if (CLI_OPTS.happOrWebhappPath.type === 'webhapp') {
happTargetDir = path.join(DATA_ROOT_DIR, 'apps', CLI_OPTS.appId);
const uiTargetDir = path.join(happTargetDir, 'ui');
await rustUtils.saveHappOrWebhapp(
CLI_OPTS.happOrWebhappPath.path,
CLI_OPTS.appId,
uiTargetDir,
happTargetDir,
);
}
const [bootstrapUrl, relayUrl] = await startLocalServices();
const [sandboxHandle, sandboxPaths, portsInfo] = await spawnSandboxes(
CLI_OPTS.numAgents,
happTargetDir ? happTargetDir : CLI_OPTS.happOrWebhappPath.path,
CLI_OPTS.bootstrapUrl ? CLI_OPTS.bootstrapUrl : bootstrapUrl,
CLI_OPTS.relayUrl ? CLI_OPTS.relayUrl : relayUrl,
CLI_OPTS.appId,
CLI_OPTS.networkSeed,
CLI_OPTS.targetArcFactor,
);
const lairUrls: string[] = [];
sandboxPaths.forEach((sandbox) => {
const conductorConfigPath = path.join(sandbox, 'conductor-config.yaml');
const configStr = fs.readFileSync(conductorConfigPath, 'utf-8');
const lines = configStr.split('\n');
for (const line of lines) {
if (line.includes('connection_url')) {
// connection_url: unix:///tmp/NgYtyB9jdYSC6BlmNTyra/keystore/socket?k=c-B-bRZIObKsh9c5q899hWjAWsWT28DNQUSElAFLJic
const lairUrl = line.split('connection_url:')[1].trim();
lairUrls.push(lairUrl);
// console.log('Got lairUrl form conductor-config.yaml: ', lairUrl);
break;
}
}
});
SANDBOX_PROCESSES.push(sandboxHandle);
// open browser window for each sandbox
//
for (let i = 0; i < CLI_OPTS.numAgents; i++) {
const zomeCallSigner = await rustUtils.ZomeCallSigner.connect(lairUrls[i], 'pass');
const adminPort = portsInfo[i].admin_port;
const adminWs = await AdminWebsocket.connect({
url: new URL(`ws://localhost:${adminPort}`),
wsClientOptions: {
origin: 'hc-spin',
},
});
const appAuthTokenResponse = await adminWs.issueAppAuthenticationToken({
installed_app_id: CLI_OPTS.appId,
single_use: false,
expiry_seconds: 999999,
});
const appPort = portsInfo[i].app_ports[0];
const appWs = await AppWebsocket.connect({
url: new URL(`ws://localhost:${appPort}`),
wsClientOptions: {
origin: 'hc-spin',
},
token: appAuthTokenResponse.token,
});
const appInfo = await appWs.appInfo();
if (!appInfo) throw new Error('AppInfo is null.');
const happWindow = await createHappWindow(
CLI_OPTS.uiSource,
CLI_OPTS.appId,
i + 1,
appPort,
appAuthTokenResponse.token,
DATA_ROOT_DIR,
);
// We need to add the window to the window map before loading its UI, otherwise
// zome calls can be made before handleSignZomeCall() can verify that the
// zome call is made from an authorized window (https://github.com/holochain/hc-spin/issues/30)
WINDOW_INFO_MAP[happWindow.webContents.id] = {
agentPubKey: appInfo.agent_pub_key,
zomeCallSigner,
};
await loadHappWindow(
happWindow,
CLI_OPTS.uiSource,
CLI_OPTS.happOrWebhappPath,
i + 1,
CLI_OPTS.openDevtools,
);
}
// app.on('activate', function () {
// // On macOS it's common to re-create a window in the app when the
// // dock icon is clicked and there are no other windows open.
// if (BrowserWindow.getAllWindows().length === 0) createWindow();
// });
});
app.on('quit', () => {
fs.writeFileSync(
path.join(DATA_ROOT_DIR, '.abandoned'),
"I'm not in use anymore by an active hc-spin process.",
);
// clean up sandboxes
SANDBOX_PROCESSES.forEach((handle) => handle.kill());
childProcess.spawnSync('hc', ['sandbox', 'clean']);
});