diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index cb83045..7b0a9ce 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,6 +1,7 @@ \ No newline at end of file diff --git a/cli/.roadsignrc b/cli/.roadsignrc new file mode 100644 index 0000000..daf0e70 --- /dev/null +++ b/cli/.roadsignrc @@ -0,0 +1,9 @@ +{ + "deployments": [ + { + "path": "test/static-files", + "region": "static-files", + "site": "static-files-des" + } + ] +} \ No newline at end of file diff --git a/cli/bun.lockb b/cli/bun.lockb index 8d69663..fbf857b 100755 Binary files a/cli/bun.lockb and b/cli/bun.lockb differ diff --git a/cli/index.ts b/cli/index.ts index 39fc1aa..aa7311a 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -1,4 +1,4 @@ -import { Cli } from "clipanion" +import { Builtins, Cli } from "clipanion" import figlet from "figlet" import chalk from "chalk" @@ -9,6 +9,8 @@ import { StatusCommand } from "./src/cmd/status.ts" import { InfoCommand } from "./src/cmd/info.ts" import { ProcessCommand } from "./src/cmd/process-info.ts" import { DeployCommand } from "./src/cmd/deploy.ts" +import { SyncCommand } from "./src/cmd/sync.ts" +import { ReloadCommand } from "./src/cmd/reload.ts" const [node, app, ...args] = process.argv @@ -26,6 +28,8 @@ const cli = new Cli({ binaryVersion: `1.0.0` }) +cli.register(Builtins.VersionCommand) +cli.register(Builtins.HelpCommand) cli.register(LoginCommand) cli.register(LogoutCommand) cli.register(ListServerCommand) @@ -33,4 +37,6 @@ cli.register(StatusCommand) cli.register(InfoCommand) cli.register(ProcessCommand) cli.register(DeployCommand) +cli.register(SyncCommand) +cli.register(ReloadCommand) cli.runExit(args) \ No newline at end of file diff --git a/cli/package.json b/cli/package.json index 682ffcb..8775505 100644 --- a/cli/package.json +++ b/cli/package.json @@ -4,6 +4,7 @@ "type": "module", "devDependencies": { "@types/bun": "latest", + "@types/cli-progress": "^3.11.6", "@types/figlet": "^1.5.8" }, "peerDependencies": { @@ -11,6 +12,7 @@ }, "dependencies": { "chalk": "^5.3.0", + "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", "clipanion": "^4.0.0-rc.4", "figlet": "^1.7.0", diff --git a/cli/src/cmd/deploy.ts b/cli/src/cmd/deploy.ts index 1ef0049..4663558 100644 --- a/cli/src/cmd/deploy.ts +++ b/cli/src/cmd/deploy.ts @@ -6,6 +6,7 @@ import * as fs from "node:fs" import * as child_process from "node:child_process" import * as path from "node:path" import { createAuthHeader } from "../utils/auth.ts" +import { RsLocalConfig } from "../utils/config-local.ts" export class DeployCommand extends Command { static paths = [[`deploy`]] @@ -13,54 +14,53 @@ export class DeployCommand extends Command { category: `Building`, description: `Deploying App / Static Site onto RoadSign`, details: `Deploying an application or hosting a static site via RoadSign, you need preconfigured the RoadSign, or sync the configurations via sync command.`, - examples: [["Deploying to RoadSign", `deploy `]] + examples: [ + ["Deploying to RoadSign", `deploy `], + ["Deploying to RoadSign with .roadsignrc file", `deploy `] + ] } server = Option.String({ required: true }) - site = Option.String({ required: true }) - upstream = Option.String({ required: true }) - input = Option.String({ required: true }) + region = Option.String({ required: false }) + site = Option.String({ required: false }) + input = Option.String({ required: false }) - async execute() { - const config = await RsConfig.getInstance() - - const server = config.config.servers.find(item => item.label === this.server) + async deploy(serverLabel: string, region: string, site: string, input: string) { + const cfg = await RsConfig.getInstance() + const server = cfg.config.servers.find(item => item.label === serverLabel) if (server == null) { this.context.stdout.write(chalk.red(`Server with label ${chalk.bold(this.server)} was not found.\n`)) return } - if (!fs.existsSync(this.input)) { + if (!fs.existsSync(input)) { this.context.stdout.write(chalk.red(`Input file ${chalk.bold(this.input)} was not found.\n`)) return } let isDirectory = false - if (fs.statSync(this.input).isDirectory()) { - if (this.input.endsWith("/")) { - this.input = this.input.slice(0, -1) - } - this.input += "/*" + if (fs.statSync(input).isDirectory()) { + input = path.join(input, "*") const compressPrefStart = performance.now() - const compressSpinner = ora(`Compressing ${chalk.bold(this.input)}...`).start() + const compressSpinner = ora(`Compressing ${chalk.bold(input)}...`).start() const destName = `${Date.now()}-roadsign-archive.zip` - child_process.execSync(`zip -rj ${destName} ${this.input}`) + child_process.execSync(`zip -rj ${destName} ${input}`) const compressPrefTook = performance.now() - compressPrefStart compressSpinner.succeed(`Compressing completed in ${(compressPrefTook / 1000).toFixed(2)}s 🎉`) - this.input = destName + input = destName isDirectory = true } - const destBreadcrumb = [this.site, this.upstream].join(" ➜ ") + const destBreadcrumb = [region, site].join(" ➜ ") const spinner = ora(`Deploying ${chalk.bold(destBreadcrumb)} to ${chalk.bold(this.server)}...`).start() const prefStart = performance.now() try { const payload = new FormData() - payload.set("attachments", await fs.openAsBlob(this.input), isDirectory ? "dist.zip" : path.basename(this.input)) - const res = await fetch(`${server.url}/webhooks/publish/${this.site}/${this.upstream}?mimetype=application/zip`, { + payload.set("attachments", await fs.openAsBlob(input), isDirectory ? "dist.zip" : path.basename(input)) + const res = await fetch(`${server.url}/webhooks/publish/${region}/${site}?mimetype=application/zip`, { method: "PUT", body: payload, headers: { @@ -76,10 +76,37 @@ export class DeployCommand extends Command { this.context.stdout.write(`Failed to deploy to remote: ${e}\n`) spinner.fail(`Server with label ${chalk.bold(this.server)} is not running! 😢`) } finally { - if (isDirectory && this.input.endsWith(".zip")) { - fs.unlinkSync(this.input) + if (isDirectory && input.endsWith(".zip")) { + fs.unlinkSync(input) } } + } + + async execute() { + if (this.region && this.site && this.input) { + await this.deploy(this.server, this.region, this.site, this.input) + } else { + let localCfg: RsLocalConfig + try { + localCfg = await RsLocalConfig.getInstance() + } catch (e) { + this.context.stdout.write(chalk.red(`Unable to load .roadsignrc: ${e}\n`)) + return + } + + if (!localCfg.config.deployments) { + this.context.stdout.write(chalk.red(`No deployments found in .roadsignrc, exiting...\n`)) + return + } + + let idx = 0 + for (const deployment of localCfg.config.deployments ?? []) { + this.context.stdout.write(chalk.cyan(`Deploying ${idx + 1} out of ${localCfg.config.deployments.length} deployments...\n`)) + await this.deploy(this.server, deployment.region, deployment.site, deployment.path) + } + + this.context.stdout.write(chalk.green(`All deployments has been deployed!\n`)) + } process.exit(0) } diff --git a/cli/src/cmd/reload.ts b/cli/src/cmd/reload.ts new file mode 100644 index 0000000..a229ca9 --- /dev/null +++ b/cli/src/cmd/reload.ts @@ -0,0 +1,53 @@ +import { RsConfig } from "../utils/config.ts" +import { Command, Option, type Usage } from "clipanion" +import chalk from "chalk" +import ora from "ora" +import * as fs from "node:fs" +import { createAuthHeader } from "../utils/auth.ts" +import { RsLocalConfig } from "../utils/config-local.ts" + +export class ReloadCommand extends Command { + static paths = [[`reload`]] + static usage: Usage = { + category: `Building`, + description: `Reload configuration on RoadSign`, + details: `Reload configuration on remote RoadSign to make changes applied.`, + examples: [ + ["Reload an connected server", `reload `], + ] + } + + server = Option.String({ required: true }) + + async execute() { + const cfg = await RsConfig.getInstance() + const server = cfg.config.servers.find(item => item.label === this.server) + if (server == null) { + this.context.stdout.write(chalk.red(`Server with label ${chalk.bold(this.server)} was not found.\n`)) + return + } + + const spinner = ora(`Reloading server ${chalk.bold(this.server)}...`).start() + + const prefStart = performance.now() + + try { + const res = await fetch(`${server.url}/cgi/reload`, { + method: "POST", + headers: { + Authorization: createAuthHeader(server.credential) + } + }) + if (res.status !== 200) { + throw new Error(await res.text()) + } + const prefTook = performance.now() - prefStart + spinner.succeed(`Reloading completed in ${(prefTook / 1000).toFixed(2)}s 🎉`) + } catch (e) { + this.context.stdout.write(`Failed to reload remote: ${e}\n`) + spinner.fail(`Server with label ${chalk.bold(this.server)} is not running! 😢`) + } + + process.exit(0) + } +} \ No newline at end of file diff --git a/cli/src/cmd/sync.ts b/cli/src/cmd/sync.ts new file mode 100644 index 0000000..b7ff4c7 --- /dev/null +++ b/cli/src/cmd/sync.ts @@ -0,0 +1,87 @@ +import { RsConfig } from "../utils/config.ts" +import { Command, Option, type Usage } from "clipanion" +import chalk from "chalk" +import ora from "ora" +import * as fs from "node:fs" +import { createAuthHeader } from "../utils/auth.ts" +import { RsLocalConfig } from "../utils/config-local.ts" + +export class SyncCommand extends Command { + static paths = [[`sync`]] + static usage: Usage = { + category: `Building`, + description: `Sync configuration to RoadSign over Sideload`, + details: `Update remote RoadSign configuration with local ones.`, + examples: [ + ["Sync to RoadSign", `sync `], + ["Sync to RoadSign with .roadsignrc file", `sync `] + ] + } + + server = Option.String({ required: true }) + region = Option.String({ required: false }) + input = Option.String({ required: false }) + + async sync(serverLabel: string, region: string, input: string) { + const cfg = await RsConfig.getInstance() + const server = cfg.config.servers.find(item => item.label === serverLabel) + if (server == null) { + this.context.stdout.write(chalk.red(`Server with label ${chalk.bold(this.server)} was not found.\n`)) + return + } + + if (!fs.existsSync(input)) { + this.context.stdout.write(chalk.red(`Input file ${chalk.bold(this.input)} was not found.\n`)) + return + } + if (!fs.statSync(input).isFile()) { + this.context.stdout.write(chalk.red(`Input file ${chalk.bold(this.input)} is not a file.\n`)) + return + } + + const spinner = ora(`Syncing ${chalk.bold(region)} to ${chalk.bold(this.server)}...`).start() + + const prefStart = performance.now() + + try { + const res = await fetch(`${server.url}/webhooks/sync/${region}`, { + method: "PUT", + body: fs.readFileSync(input, "utf8"), + headers: { + Authorization: createAuthHeader(server.credential) + } + }) + if (res.status !== 200) { + throw new Error(await res.text()) + } + const prefTook = performance.now() - prefStart + spinner.succeed(`Syncing completed in ${(prefTook / 1000).toFixed(2)}s 🎉`) + } catch (e) { + this.context.stdout.write(`Failed to sync to remote: ${e}\n`) + spinner.fail(`Server with label ${chalk.bold(this.server)} is not running! 😢`) + } + } + + async execute() { + if (this.region && this.input) { + await this.sync(this.server, this.region, this.input) + } else { + let localCfg: RsLocalConfig + try { + localCfg = await RsLocalConfig.getInstance() + } catch (e) { + this.context.stdout.write(chalk.red(`Unable to load .roadsignrc: ${e}\n`)) + return + } + + if (!localCfg.config.sync) { + this.context.stdout.write(chalk.red(`No sync configuration found in .roadsignrc, exiting...\n`)) + return + } + + await this.sync(this.server, localCfg.config.sync.region, localCfg.config.sync.configPath) + } + + process.exit(0) + } +} \ No newline at end of file diff --git a/cli/src/utils/config-local.ts b/cli/src/utils/config-local.ts new file mode 100644 index 0000000..32274e2 --- /dev/null +++ b/cli/src/utils/config-local.ts @@ -0,0 +1,60 @@ +import * as path from "node:path" +import * as fs from "node:fs/promises" + +interface RsLocalConfigData { + sync?: RsLocalConfigSyncData + deployments?: RsLocalConfigDeploymentData[] +} + +interface RsLocalConfigSyncData { + configPath: string + region: string +} + +interface RsLocalConfigDeploymentData { + path: string + region: string + site: string + autoBuild?: RsLocalConfigDeploymentAutoBuildData +} + +interface RsLocalConfigDeploymentAutoBuildData { + command: string + environment?: string[] +} + +class RsLocalConfig { + private static instance: RsLocalConfig + + public config: RsLocalConfigData = {} + + private constructor() { + } + + public static async getInstance(): Promise { + if (!RsLocalConfig.instance) { + RsLocalConfig.instance = new RsLocalConfig() + await RsLocalConfig.instance.readConfig() + } + return RsLocalConfig.instance + } + + public async readConfig() { + const basepath = process.cwd() + const filepath = path.join(basepath, ".roadsignrc") + if (!await fs.exists(filepath)) { + throw new Error(`.roadsignrc file was not found at ${filepath}`) + } + + const data = await fs.readFile(filepath, "utf8") + this.config = JSON.parse(data) + } + + public async writeConfig() { + const basepath = process.cwd() + const filepath = path.join(basepath, ".roadsignrc") + await fs.writeFile(filepath, JSON.stringify(this.config)) + } +} + +export { RsLocalConfig, type RsLocalConfigData } \ No newline at end of file diff --git a/cli/test/static-files.toml b/cli/test/static-files.toml new file mode 100644 index 0000000..5927820 --- /dev/null +++ b/cli/test/static-files.toml @@ -0,0 +1,9 @@ +id = "static-files-num2" + +[[locations]] +id = "static-files-loc-num2" +hosts = ["127.0.0.1:8000"] +paths = ["/"] +[[locations.destinations]] +id = "static-files-des-num2" +uri = "files://../data/static-files" diff --git a/pkg/sideload/regions.go b/pkg/sideload/regions.go index 33e753d..dba4f44 100644 --- a/pkg/sideload/regions.go +++ b/pkg/sideload/regions.go @@ -38,8 +38,11 @@ func doSync(c *fiber.Ctx) error { if file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755); err != nil { return fiber.NewError(fiber.ErrInternalServerError.Code, err.Error()) } else { - raw, _ := toml.Marshal(req) - file.Write(raw) + var testOut map[string]any + if err := toml.Unmarshal([]byte(req), &testOut); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("invalid configuration: %v", err)) + } + _, _ = file.Write([]byte(req)) defer file.Close() }