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()
}