diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d502512
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/node_modules
+/package-lock.json
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..77696e8
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,11 @@
+.PHONY: lint
+lint:
+ npx @biomejs/biome check ./src
+
+.PHONY: format
+format:
+ npx @biomejs/biome check ./src
+
+.PHONY: publish
+publish:
+ npm publish
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8c73c76
--- /dev/null
+++ b/README.md
@@ -0,0 +1,27 @@
+# @cubing/deploy
+
+Dreamhost-compatible deploys using [`bun`](https://bun.sh/) and `rsync`.
+
+1. Install:
+
+```shell
+bun add --development @cubing/deploy
+# or
+npm install --save-dev @cubing/deploy
+```
+
+2. Add URLs to `package.json`:
+
+```json
+{
+ "@cubing/deploy": {
+ "https://experiments.cubing.net/test/deploy": {}
+ }
+}
+```
+
+3. Run:
+
+```shell
+bun x @cubing/deploy
+```
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..c139865
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,13 @@
+{
+ "formatter": {
+ "indentStyle": "space",
+ "indentWidth": 2
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true
+ }
+ }
+}
+
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..9091e8c
Binary files /dev/null and b/bun.lockb differ
diff --git a/dist/web/experiments.cubing.net/test/deploy/index.html b/dist/web/experiments.cubing.net/test/deploy/index.html
new file mode 100644
index 0000000..80898c0
--- /dev/null
+++ b/dist/web/experiments.cubing.net/test/deploy/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+ Example
+
+
+
+
+
+ Example!
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0b34998
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "@cubing/deploy",
+ "version": "0.1.0",
+ "module": "src/main.ts",
+ "type": "module",
+ "devDependencies": {
+ "@biomejs/biome": "^1.7.2",
+ "@types/bun": "latest",
+ "typescript": "^5.4.5"
+ },
+ "@cubing/deploy": {
+ "https://experiments.cubing.net/test/deploy": {}
+ }
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..4cbbd2a
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,117 @@
+import assert from "node:assert";
+import { exit } from "node:process";
+import { parseArgs } from "node:util";
+import { argv } from "bun";
+
+function printHelpAndExit(): void {
+ console.log(
+ `Usage: bun x @cubing/deploy
+
+Options:
+
+ --dry-run
+ --create-folder-on-server
+
+Requires \`bun\` and \`rsync\` to be installed. Reads paths from a field in \`package.json\` in the current folder:
+
+{
+ "@cubing/deploy": {
+ "https://experiments.cubing.net/test/deploy": {}
+ }
+}
+`,
+ );
+ exit(1);
+}
+
+const { values } = parseArgs({
+ args: argv.slice(2),
+ options: {
+ "dry-run": {
+ type: "boolean",
+ },
+ "create-folder-on-server": {
+ type: "boolean",
+ },
+ },
+ strict: true,
+});
+
+function barebonesShellEscape(s: string): string {
+ return s.replaceAll('"', '\\"');
+}
+
+function printCommand(c: string[]): void {
+ console.log(c.map((s) => `"${barebonesShellEscape(s)}"`).join(" "));
+}
+
+// TODO: reuse connections based on domain or host IP.
+async function deployURL(urlString: string): Promise {
+ if (urlString.at(-1) !== "/") {
+ // biome-ignore lint/style/noParameterAssign: Safety check
+ urlString = `${urlString}/`; // Only sync folder contents.
+ }
+ const url = new URL(urlString); // TODO: avoid URL encoding special chars
+
+ const localDistPath = `./dist/web/${url.hostname}${url.pathname}`;
+
+ const rsyncCommand = ["rsync", "-avz"];
+ // rsyncCommand.push("--mkpath"); // TODO: requires `rsync` v3.2.3 (https://stackoverflow.com/a/65435579) but Dreamhost is stuck on 3.1.3. 😖
+ rsyncCommand.push("--exclude", ".DS_Store");
+ rsyncCommand.push("--exclude", ".git"); // TODO: we probably don't need this?
+ rsyncCommand.push(localDistPath);
+
+ let login_host = url.hostname;
+ if (url.username) {
+ login_host = `${url.username}@${url.hostname}`;
+ }
+
+ const serverFolder = url.hostname + url.pathname;
+
+ const rsyncTarget = `${login_host}:~/${serverFolder}`;
+ rsyncCommand.push(rsyncTarget);
+
+ const sshCommand = [
+ "ssh",
+ login_host,
+ `mkdir -p "${barebonesShellEscape(serverFolder)}"`,
+ ];
+
+ console.log("--------");
+ console.log(`Deploying from: ${localDistPath}`);
+ console.log(`Deploying to: ${rsyncTarget}`);
+ if (values["dry-run"]) {
+ if (values["create-folder-on-server"]) {
+ console.write("[--dry-run] ");
+ printCommand(sshCommand);
+ }
+ console.write("[--dry-run] ");
+ printCommand(rsyncCommand);
+ } else {
+ if (values["create-folder-on-server"]) {
+ assert((await Bun.spawn(sshCommand).exited) === 0);
+ }
+ assert((await Bun.spawn(rsyncCommand).exited) === 0);
+ console.log(`
+Successfully deployed:
+
+ ${url}
+`);
+ }
+}
+
+const packageJSON = await Bun.file("package.json").json();
+const cubingDeployArgs = packageJSON["@cubing/deploy"];
+if (!cubingDeployArgs) {
+ console.log("No `@cubing/deploy` entry was found in `package.json`");
+ printHelpAndExit();
+}
+const urlStrings = Object.keys(cubingDeployArgs);
+
+if (urlStrings.length === 0) {
+ printHelpAndExit();
+}
+
+for (const urlString of urlStrings) {
+ await deployURL(urlString);
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..238655f
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ // Enable latest features
+ "lib": ["ESNext", "DOM"],
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ }
+}