Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/deploy #82

Merged
merged 13 commits into from
Apr 29, 2024
19 changes: 15 additions & 4 deletions lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "path";
import { dev } from "./dev";
import { cloneRepository } from "./repository";
import { buildWorkspace, devWorkspace } from "./workspace";
import { deploy } from "./deploy";

const program = new Command();

Expand Down Expand Up @@ -122,11 +123,21 @@ async function run() {

program
.command("deploy")
.description("Deploy the project (not implemented)")
.argument("[string]", "app name")
.action((appName) => {
console.log("not yet supported");
.description("Deploy the project")
.argument("[string]", "Workspace app name to deploy")
.option("--deploy-account-id <deployAccountId>", "Account under which component code should be deployed")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This all looks good, can you run bw help and update the README with this new command?

.option("--signer-account-id <signerAccountId>", "Account which will be used for signing deploy transaction, frequently the same as deploy-account-id")
.option("--signer-public-key <signerPublicKey>", "Public key for signing transactions in the format: `ed25519:<public_key>`")
.option("--signer-private-key <signerPrivateKey>", "Private key in `ed25519:<private_key>` format for signing transaction")
.option("-n, --network <network>", "network to deploy for", "mainnet")
.option("-l, --loglevel <loglevel>", "log level (ERROR, WARN, INFO, DEV, BUILD, DEBUG)")
.action(async (appName, opts) => {
global.log = new Logger(LogLevel?.[opts?.loglevel?.toUpperCase() as keyof typeof LogLevel] || LogLevel.BUILD);
await deploy(appName, opts).catch((e: Error) => {
log.error(e.stack || e.message);
})
});

program
.command("upload")
.description("Upload data to SocialDB (not implemented)")
Expand Down
97 changes: 95 additions & 2 deletions lib/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,102 @@
import { BaseConfig } from "./config";
import path from "path";
import { exec, ExecException } from "child_process";
import { BaseConfig, readConfig } from "./config";
import { buildApp } from "./build";
import { readWorkspace } from "./workspace";
import { Network } from "./types";
import { readdir, rename, remove } from "@/lib/utils/fs";

const DEPLOY_DIST_FOLDER = "build";

export type DeployOptions = {
deployAccountId: string;
signerAccountId: string;
signerPublicKey: string;
signerPrivateKey: string;
network?: Network;
};

// deploy the app widgets and modules
export async function deployAppCode(src: string, config: BaseConfig) {
export async function deployAppCode(src: string, dist: string, opts: DeployOptions) {
const deploying = log.loading(`[${src}] Deploying app`, LogLevels.BUILD);

const config = await readConfig(path.join(src, "bos.config.json"), opts.network);

// Move files from "src/widget" to "src/"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, how about moving L25-53 to a "translate" or "translateForBosCli" function so we can test it?

const srcDir = path.join(dist, "src", "widget");
const targetDir = path.join(dist, "src");

const original_files = await readdir(targetDir).catch(() => ([]));
for (const file of original_files) {
if (file == "widget")
continue;

await remove(file).catch(() => {
deploying.error(`Failed to remove ${path.join(targetDir, file)}`);
})
}

const new_files = await readdir(srcDir).catch(() => ([]));
if (new_files.length === 0) {
deploying.error(`Failed to read ${srcDir}`);
return;
}

for (const file of new_files) {
rename(path.join(srcDir, file), path.join(targetDir, file)).catch(() => {
deploying.error(`Failed to move ${path.join(srcDir, file)}`);
});
}

remove(path.join(targetDir, "widget")).catch(() => {
deploying.error(`Failed to remove widget`);
});

// Deploy using bos-cli;
const BOS_DEPLOY_ACCOUNT_ID = config.accounts.deploy || opts.deployAccountId;
const BOS_SIGNER_ACCOUNT_ID = config.accounts.signer || opts.signerAccountId;
const BOS_SIGNER_PUBLIC_KEY = opts.signerPublicKey;
const BOS_SIGNER_PRIVATE_KEY = opts.signerPrivateKey;

await exec(
`cd ${dist} && npx bos components deploy "${BOS_DEPLOY_ACCOUNT_ID}" sign-as "${BOS_SIGNER_ACCOUNT_ID}" network-config "${opts.network}" sign-with-plaintext-private-key --signer-public-key "${BOS_SIGNER_PUBLIC_KEY}" --signer-private-key "${BOS_SIGNER_PRIVATE_KEY}" send`,
(error: ExecException | null, stdout: string, stderr: string) => {
if (!error) {
deploying.finish(`[${src}] App deployed successfully`);
return;
}

deploying.error(error.message);
}
);
}

// publish data.json to SocialDB
export async function deployAppData(src: string, config: BaseConfig) {
}

export async function deploy(appName: string, opts: DeployOptions) {
const src = ".";
if (!appName) {
const dist = path.join(src, DEPLOY_DIST_FOLDER);
await buildApp(src, dist, opts.network);

deployAppCode(src, dist, opts);

} else {
const { apps } = await readWorkspace(src);

const findingApp = log.loading(`Finding ${appName} in the workspace`, LogLevels.BUILD);
const appSrc = apps.find((app) => app.includes(appName));
if (!appSrc) {
findingApp.error(`Not found ${appName} in the workspace`);
return;
}
findingApp.finish(`Found ${appName} in the workspace`);

const dist = path.join(DEPLOY_DIST_FOLDER, appSrc);
await buildApp(appSrc, dist, opts.network);

deployAppCode(appSrc, dist, opts);
}
}
4 changes: 2 additions & 2 deletions lib/utils/fs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { copy, readFile, lstat, readJson, writeJson, ensureDir, outputFile, readdir, remove } from 'fs-extra';
import { copy, readFile, lstat, readJson, writeJson, ensureDir, outputFile, readdir, remove, rename } from 'fs-extra';
import path from 'path';

async function loopThroughFiles(pwd: string, callback: (file: string) => Promise<void>) {
Expand All @@ -16,4 +16,4 @@ async function loopThroughFiles(pwd: string, callback: (file: string) => Promise
}
}

export { copy, readJson, writeJson, ensureDir, outputFile, loopThroughFiles, readFile, remove };
export { copy, readJson, writeJson, ensureDir, outputFile, loopThroughFiles, readFile, readdir, remove, rename };
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"bos-cli": "^0.3.13",
"commander": "^11.1.0",
"crypto-js": "^4.2.0",
"express": "^4.18.2",
Expand Down
113 changes: 113 additions & 0 deletions tests/unit/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { deploy } from '@/lib/deploy';
import { BaseConfig, DEFAULT_CONFIG } from '@/lib/config';
import * as fs from '@/lib/utils/fs';
import { LogLevel, Logger } from "@/lib/logger";

import { vol, } from 'memfs';
jest.mock('fs', () => require('memfs').fs);
jest.mock('fs/promises', () => require('memfs').fs.promises);

const app_example = {
"./bos.config.json": JSON.stringify({
...DEFAULT_CONFIG,
account: "test.near",
ipfs: {
gateway: "https://testipfs/ipfs",
},
format: true,
}),
"./aliases.json": JSON.stringify({
"name": "world",
}),
"./ipfs/logo.svg": "<svg viewBox='0 0 100 100'><circle cx='50' cy='50' r='50' fill='red' /></svg>",
"./module/hello/utils.ts": "const hello = (name: string) => `Hello, ${name}!`; export default { hello };",
"./widget/index.tsx": "type Hello = {}; const hello: Hello = 'hi'; export default hello;",
"./widget/index.metadata.json": JSON.stringify({
name: "Hello",
description: "Hello world widget",
}),
"./widget/nested/index.tsx": "type Hello = {}; const hello: Hello = 'hi'; export default hello;",
"./widget/nested/index.metadata.json": JSON.stringify({
name: "Nested Hello",
description: "Nested Hello world widget",
}),
"./widget/module.tsx": "VM.require('${module_hello_utils}'); export default hello('world');",
"./widget/config.jsx": "return <h1>${config_account}${config_account_deploy}</h1>;",
"./widget/alias.tsx": "export default <h1>Hello ${alias_name}!</h1>;",
"./widget/ipfs.tsx": "export default <img height='100' src='${ipfs_logo.svg}' />;",
"./data/thing/data.json": JSON.stringify({
"type": "efiz.near/type/thing",
}),
"./data/thing/datastring.jsonc": JSON.stringify({
name: "Thing",
}),
};

const app_example_output = {
"/build/ipfs.json": JSON.stringify({
"logo.svg": "QmHash",
}, null, 2) + "\n",
"/build/src/hello.utils.module.js": "const hello = (name) => `Hello, ${name}!`;\nreturn { hello };\n",
"/build/src/index.jsx": "const hello = \"hi\";\nreturn hello(props);\n",
"/build/src/nested.index.jsx": "const hello = \"hi\";\nreturn hello(props);\n",
"/build/src/module.jsx": "VM.require(\"test.near/widget/hello.utils.module\");\nreturn hello(\"world\");\n",
"/build/src/config.jsx": "return <h1>test.neartest.near</h1>;\n",
"/build/src/alias.jsx": "return <h1>Hello world!</h1>;\n",
"/build/src/ipfs.jsx": "return <img height=\"100\" src=\"https://testipfs/ipfs/QmHash\" />;\n",
"/build/data.json": JSON.stringify({
"test.near": {
thing: {
data: {
"type": "efiz.near/type/thing",
},
datastring: JSON.stringify({
name: "Thing",
})
},
widget: {
index: {
metadata: {
name: "Hello",
description: "Hello world widget",
}
},
"nested.index": {
metadata: {
name: "Nested Hello",
description: "Nested Hello world widget",
}

}
}
}
}, null, 2) + "\n",
};

const unmockedFetch = global.fetch;
const unmockedLog = global.log;

describe('build', () => {
beforeEach(() => {
vol.reset();
vol.fromJSON(app_example, '/app_example');

global.fetch = (() => {
return Promise.resolve({
json: () => Promise.resolve({
cid: "QmHash",
})
})
}) as any;
global.log = new Logger(LogLevel.ERROR);
})
afterAll(() => {
global.fetch = unmockedFetch;
global.log = unmockedLog;
})

it('should build correctly without logs', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like how you've set up these tests so far -- for test cases, I'm thinking:

  • should match expected input for bos-cli-rs (src/widget -> src), isolate that function in code call it "translate"
  • should exec with correct command -> compare the the exec method call after running deployApp

const { logs } = await deploy('/app_example', {});
expect(logs).toEqual([]);
expect(vol.toJSON('/build')).toEqual(app_example_output);
})
})