diff --git a/README.md b/README.md index 89d5183..9c5acba 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Commands: init [options] [path] Initialize a new project clone [account] [dest] Clone a SocialDB repository pull [account] Pull updates from a SocialDB repository - deploy [string] Deploy the project (not implemented) + deploy [options] [appName] Deploy the project upload [string] Upload data to SocialDB (not implemented) help [command] display help for command ``` diff --git a/lib/build.ts b/lib/build.ts index 8ccb4fd..14405e3 100644 --- a/lib/build.ts +++ b/lib/build.ts @@ -1,6 +1,6 @@ import path from "path"; import { readConfig } from "@/lib/config"; -import { writeJson, copy, loopThroughFiles, outputFile, readFile, readJson, remove } from "@/lib/utils/fs"; +import { writeJson, copy, loopThroughFiles, outputFile, readdir, readFile, readJson, remove } from "@/lib/utils/fs"; import { transpileJS, EvalCustomSyntaxParams } from "@/lib/parser"; import { Log } from "@/lib/types"; import { UploadToIPFSOptions, uploadToIPFS } from "@/lib/ipfs"; @@ -67,7 +67,6 @@ export async function buildApp(src: string, dest: string, network: string = "mai }; const new_build_files: string[] = []; - const original_build_files: string[] = []; // module transpilation const loadingModules = log.loading(`Transpiling ${modules.length} modules`, LogLevels.BUILD); @@ -90,7 +89,7 @@ export async function buildApp(src: string, dest: string, network: string = "mai logs.push(...new_logs); // write to dest - let new_file_name = path.relative(path.join(src, "module"), file).replace("/", "."); + let new_file_name = path.relative(path.join(src, "module"), file).replace(path.sep, "."); new_file_name = new_file_name.substring(0, new_file_name.length - path.extname(file).length); new_file_name += ".module.js"; @@ -129,7 +128,7 @@ export async function buildApp(src: string, dest: string, network: string = "mai logs.push(...new_logs); // write to dest - let new_file_name = path.relative(path.join(src, "widget"), file).replace("/", "."); + let new_file_name = path.relative(path.join(src, "widget"), file).replace(path.sep, "."); new_file_name = new_file_name.substring(0, new_file_name.length - path.extname(file).length); new_file_name += ".jsx"; @@ -146,14 +145,13 @@ export async function buildApp(src: string, dest: string, network: string = "mai } // remove unnecessary build files - await loopThroughFiles(path.join(dest, "src", "widget"), async (file: string) => { - original_build_files.push(file); - }) + const original_build_files = await readdir(path.join(dest, "src", "widget")).catch(() => []); for (const file of original_build_files) { - if (new_build_files.includes(file)) + const filePath = path.join(dest, "src", "widget", file); + if (new_build_files.includes(filePath)) continue; - await remove(file); + await remove(filePath); } await log.wait( diff --git a/lib/cli.ts b/lib/cli.ts index 2c2a40a..88a6815 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -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(); @@ -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("[appName]", "Workspace app name to deploy") + .option("--deploy-account-id ", "Account under which component code should be deployed") + .option("--signer-account-id ", "Account which will be used for signing deploy transaction, frequently the same as deploy-account-id") + .option("--signer-public-key ", "Public key for signing transactions in the format: `ed25519:`") + .option("--signer-private-key ", "Private key in `ed25519:` format for signing transaction") + .option("-n, --network ", "network to deploy for", "mainnet") + .option("-l, --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)") diff --git a/lib/deploy.ts b/lib/deploy.ts index 4016678..13631b4 100644 --- a/lib/deploy.ts +++ b/lib/deploy.ts @@ -1,9 +1,127 @@ -import { BaseConfig } from "./config"; +import path from "path"; +import { exec, ExecException } from "child_process"; + +import { BaseConfig, readConfig } from "@/lib/config"; +import { buildApp } from "@/lib/build"; +import { readWorkspace } from "@/lib/workspace"; +import { Log, Network } from "@/lib/types"; +import { readdir, remove, move, pathExists } from "@/lib/utils/fs"; +import { Logger } from "./logger"; + +const DEPLOY_DIST_FOLDER = "build"; + +export type DeployOptions = { + deployAccountId?: string; + signerAccountId?: string; + signerPublicKey?: string; + signerPrivateKey?: string; + network?: Network; +}; + +// translate files from src/widget to src +export async function translateForBosCli(dist: string) { + const srcDir = path.join(dist, "src", "widget"); + const targetDir = path.join(dist, "src"); + + const new_files = await readdir(srcDir).catch(() => ([])); + const original_files = await readdir(targetDir).catch(() => ([])); + + for (const file of new_files) { + await move(path.join(srcDir, file), path.join(targetDir, file), { overwrite: true }); + } + + for (const file of original_files) { + if (new_files.includes(file)) + continue; + + await remove(path.join(targetDir, file)); + } +} // 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 fullSrc = path.resolve(src); + const fullDist = path.resolve(dist); + + const deploying = log.loading(`[${fullSrc}] Deploying app`, LogLevels.BUILD); + + // Build + await buildApp(src, dist, opts.network); + + // Translate for bos cli + await log.wait( + translateForBosCli(dist), + `[${fullDist}] Translating files for bos cli`, + `[${fullDist}] Translated successfully`, + `[${fullDist}] Failed to translate`, + LogLevels.BUILD + ); + + // Exec bos-cli; + const config = await readConfig(path.join(src, "bos.config.json"), opts.network); + + 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; + + if (!BOS_DEPLOY_ACCOUNT_ID) { + deploying.error(`Necessary values not provided, please provide Account ID for deploy`); + return; + } else if (!BOS_SIGNER_ACCOUNT_ID) { + deploying.error(`Necessary values not provided, please provide Signer Account ID for deploy`); + return; + } else if (!BOS_SIGNER_PUBLIC_KEY || !BOS_SIGNER_PRIVATE_KEY) { + deploying.error(`Necessary values not provided, please provide private & public key for deploy`); + return; + } + + 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(`[${fullSrc}] 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 = '.'; + + // Deploy single project + if (!appName) { + if (await pathExists(path.join(src, "bos.config.json"))) { // Check if the directory has bos.config.json file + await deployAppCode(src, path.join(src, DEPLOY_DIST_FOLDER), opts); + return; + } else { // Check if the directory has bos.workspace.json file + if (await pathExists(path.join(src, "bos.workspace.json"))) { + log.error(`Please provide app name`); + return; + } + } + + log.error(`[${src}] bos.config.json file is not existing in the project`); + return; + } + + // Deploy workspace app + 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`); + + await deployAppCode(appSrc, path.join(DEPLOY_DIST_FOLDER, appSrc), opts); +} diff --git a/lib/utils/fs.ts b/lib/utils/fs.ts index e56f0d7..a925eb9 100644 --- a/lib/utils/fs.ts +++ b/lib/utils/fs.ts @@ -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, move, pathExists } from 'fs-extra'; import path from 'path'; async function loopThroughFiles(pwd: string, callback: (file: string) => Promise) { @@ -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, move, pathExists }; diff --git a/package.json b/package.json index 791c686..6a2e245 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/unit/build.ts b/tests/unit/build.ts index e0cf25c..d510f4e 100644 --- a/tests/unit/build.ts +++ b/tests/unit/build.ts @@ -168,9 +168,8 @@ describe('build', () => { global.log = unmockedLog; }) - it('should build correctly without logs', async () => { - const { logs } = await buildApp('/app_example_1', '/build'); - expect(logs).toEqual([]); + it('should build correctly', async () => { + await buildApp('/app_example_1', '/build'); expect(vol.toJSON('/build')).toEqual(app_example_1_output); }) diff --git a/tests/unit/deploy.ts b/tests/unit/deploy.ts new file mode 100644 index 0000000..29bfd50 --- /dev/null +++ b/tests/unit/deploy.ts @@ -0,0 +1,118 @@ +import * as process from "child_process"; +import { deployAppCode } 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); +jest.mock('child_process', () => ({ + exec: jest.fn((command: string) => { + return command; + }), +})) + +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": "", + "./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

${config_account}${config_account_deploy}

;", + "./widget/alias.tsx": "export default

Hello ${alias_name}!

;", + "./widget/ipfs.tsx": "export default ;", + "./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

test.neartest.near

;\n", + "/build/src/alias.jsx": "return

Hello world!

;\n", + "/build/src/ipfs.jsx": "return ;\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('deploy', () => { + 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 match expected input for bos-cli-rs', async () => { + await deployAppCode('/app_example', '/build', {}); + expect(vol.toJSON('/build')).toEqual(app_example_output); + }) +})