🔥 Sync 2026-07-19 20:20:12

This commit is contained in:
github-actions[bot] committed 2026-07-19 20:20:12 +08:00
1 parent 7974ca67ae
commit c0107db2e8
29 files changed
+1161 -1032

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
# Optional — run `pnpm setup` once and you usually don't need a .env at all.
# Optional — run `pnpm setup:router` once and it writes/updates every value below.
# OpenWrt router address — bare IP/hostname; the web proxy target and the
# .ut-sync ssh target both derive from it (ssh key selection etc. belongs
+4
View File
@@ -0,0 +1,4 @@
# Fail `pnpm install` early with a clear message on unsupported Node versions
# (the range in package.json `engines` mirrors Vite 7's requirement) instead
# of letting the install die somewhere deep inside a dependency.
engine-strict=true
+27 -8
View File
@@ -4,7 +4,7 @@ This guide covers the complete development workflow for the Aurora theme, from e
## Prerequisites
- **[Node.js v20.19+](https://nodejs.org/en/download)** - JavaScript runtime
- **[Node.js 20.19+ / 22.12+](https://nodejs.org/en/download)** - JavaScript runtime (mirrors Vite 7's support range — odd releases like 21.x don't qualify; enforced at `pnpm install` time via `engines` + `engine-strict`, so an unsupported Node fails fast with a clear message)
- **pnpm** - Package manager (managed via [Corepack](https://github.com/nodejs/corepack))
- **Tailwind CSS knowledge** - Required for styling. See [Tailwind CSS Documentation](https://tailwindcss.com/docs)
- **Network access** - Development machine must be on the same network as your OpenWrt router
@@ -28,11 +28,29 @@ pnpm install
### 2. Configure Environment
```bash
# One-shot wizard: asks for the router IP, generates/installs an SSH key
# (one router-password prompt), and writes .env
pnpm setup
# One-shot wizard: asks for every .env value (router IP, dev-server host/port,
# current .env entries as defaults), generates/installs an SSH key (one
# router-password prompt), verifies template sync end-to-end, and writes .env
pnpm setup:router
# Non-interactive: pass the IP directly (no prompts; dev-server values keep
# their saved .env entries or defaults)
pnpm setup:router 192.168.2.1
```
> The script is named `setup:router` (not `setup`) because `pnpm setup` resolves to pnpm's own built-in setup command and would never run a package script.
**What it does** (`scripts/setup.js`), in order:
1. **Collects the `.env` values.** With no argument it prompts for each variable in turn, showing the current `.env` entry as the default (an empty answer keeps it). With an IP argument it takes that as `VITE_OPENWRT_HOST` non-interactively and leaves the dev-server values alone. Nothing is written yet — `.env` is only updated once every step below has succeeded.
2. **Pre-flights the connection.** A raw TCP probe of `<host>:22` with a 2s timeout, so an unreachable device or one with SSH disabled fails immediately with a clear message instead of hanging inside ssh.
3. **Finds or generates an SSH key.** It looks for `~/.ssh/id_ed25519.pub`, `id_rsa.pub`, `id_ecdsa.pub` in that order and reuses the first one present — an existing key is never overwritten. **If you have no key at all, it generates one** (`ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519`). The empty passphrase is deliberate: `.ut` sync has to run unattended for the whole `pnpm dev` session.
4. **Installs the public key on the router.** It first tests whether passwordless SSH already works (`ssh -o BatchMode=yes … echo ok`) and skips the install if so — after a full reflash that wipes the key, the test fails and the install simply runs again. Otherwise it appends the key to `/etc/dropbear/authorized_keys` over one interactive SSH session; the append is guarded by `grep -qxF`, so re-running never duplicates the entry. **This is the only step that asks for the router's root password, and it asks once.** Afterwards it re-tests passwordless auth and classifies any failure (wrong password / host unreachable / other). On a device running openssh rather than dropbear the key belongs in `/root/.ssh/authorized_keys` instead — install it manually there and this step detects it and skips.
5. **Verifies the sync end-to-end.** Rather than trusting `echo ok`, it pushes `ucode/template/themes/aurora/` through the exact `tar -cf - | ssh … tar -xf -` pipeline the `ut-sync` plugin uses (see [Template (`.ut`) Live Sync](#template-ut-live-sync)). If this passes, `pnpm dev`'s template sync will too.
6. **Writes `.env`.** Managed keys are rewritten in place so surrounding comments keep their meaning, missing ones are appended, and any other line passes through untouched.
> The password prompt in step 4 comes from the **local** ssh client, which reads it straight from `/dev/tty` rather than stdin — so it shows up normally even though the script pipes ssh's stderr in order to classify errors. It does mean the step needs a controlling terminal **on the development machine** (the router side is unaffected): run it from a normal shell, not from CI, `nohup`, or an editor task pane, where ssh has no way to ask and the run fails at authentication. Check with `tty` if unsure; in such an environment, install the key by hand instead.
If the router is at the default `192.168.1.1` and passwordless SSH already works, no `.env` is needed at all — every value below has a working default.
**Environment Variables** (all optional):
@@ -182,11 +200,11 @@ For LuCI-specific JavaScript development, refer to the official API documentatio
- **CSS changes**: Trigger full page reload via custom HMR handler
- **JS changes**: Trigger full page reload via custom HMR handler
- **Template changes** (`.ut` files): Auto-synced to router over SSH and trigger full page reload (one-time `pnpm setup` required, see below)
- **Template changes** (`.ut` files): Auto-synced to router over SSH and trigger full page reload (one-time `pnpm setup:router` required, see below)
### Template (`.ut`) Live Sync
The `.ut` template files are rendered server-side on the OpenWrt device, so unlike CSS/JS they can't be served locally — the dev server pushes them to the router instead. Run `pnpm setup` once to configure passwordless SSH; after that it's fully automatic:
The `.ut` template files are rendered server-side on the OpenWrt device, so unlike CSS/JS they can't be served locally — the dev server pushes them to the router instead. Run `pnpm setup:router` once to configure passwordless SSH; after that it's fully automatic:
- **On startup**, the whole template directory is pushed (as one tarball over ssh stdin — Dropbear has no SFTP server for scp), so edits made while the dev server was down never leave the router stale.
- **On save**, changes are debounced and the directory is pushed again, then the browser reloads.
@@ -195,7 +213,7 @@ The `.ut` template files are rendered server-side on the OpenWrt device, so unli
**Troubleshooting** — sync errors are printed with the fix:
- **Host key mismatch** (device was reflashed): Run `ssh-keygen -R <device-ip>`, then restart the dev server
- **Authentication failed** (public key not on device): Run `pnpm setup`
- **Authentication failed** (public key not on device, e.g. after a reflash): Run `pnpm setup:router`
- **Connection refused/timed out**: Check that the device is online and SSH is enabled
A failed sync is retried on the next `.ut` change; CSS/JS dev features work normally without SSH.
@@ -271,7 +289,8 @@ luci-theme-aurora/
│ │ └── images/ # Theme images + PWA icons
│ ├── scripts/ # Build scripts
│ │ ├── clean.js # Build cleanup utility
│ │ └── gen-tokens.js # Regenerates src/media/_tokens.css from @eamonxg/aurora-tokens
│ │ ├── gen-tokens.js # Regenerates src/media/_tokens.css from @eamonxg/aurora-tokens
│ │ └── setup.js # pnpm setup:router — .env wizard + passwordless SSH to the router
│ ├── src/ # Source code
│ │ ├── assets/icons/ # SVG icons
│ │ ├── media/ # CSS source (Tailwind CSS v4)
+4 -1
View File
@@ -4,12 +4,15 @@
"type": "module",
"scripts": {
"dev": "vite",
"setup": "node scripts/setup.js",
"setup:router": "node scripts/setup.js",
"build": "npm run clean && npm run gen:tokens && vite build",
"clean": "node scripts/clean.js",
"gen:tokens": "node scripts/gen-tokens.js",
"test": "node --test tests/*.test.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"devDependencies": {
"@eamonxg/aurora-tokens": "^1.2.0",
"@tailwindcss/vite": "^4.1.11",
+185 -25
View File
@@ -1,12 +1,19 @@
/**
* One-shot dev environment setup: writes .env and installs an SSH key on the
* router so ut-sync works without any manual ssh configuration.
* Router connection setup: writes .env and installs an SSH key on the router
* so ut-sync works without any manual ssh configuration.
*
* Usage: pnpm setup (asks for the router IP, then one router password prompt)
* Usage: pnpm setup:router [ip]
* ip router address; omit for the interactive wizard, which asks for
* every .env value (router IP, dev-server host/port) with the
* current .env entries as defaults
*
* (The script is named `setup:router` because plain `pnpm setup` resolves to
* pnpm's own built-in setup command, never a package.json script.)
*/
import { spawnSync } from "node:child_process";
import { execSync, spawnSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { connect } from "node:net";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline/promises";
@@ -18,22 +25,121 @@ const SSH_OPTS = [
"UserKnownHostsFile=/dev/null",
];
// Must match the ut-sync plugin in vite.config.ts — the final push below is a
// real template sync, so setup only succeeds if `pnpm dev` sync will too.
const UT_TEMPLATE_DIR = resolve(
process.cwd(),
"../ucode/template/themes/aurora",
);
const UT_REMOTE_DIR = "/usr/share/ucode/luci/template/themes/aurora";
function canAuth(host) {
return (
spawnSync(
"ssh",
[...SSH_OPTS, "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", host, "echo ok"],
[
...SSH_OPTS,
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
host,
"echo ok",
],
{ stdio: "ignore" },
).status === 0
);
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ip =
(await rl.question("Router IP [192.168.1.1]: ")).trim() || "192.168.1.1";
rl.close();
function canReachSsh(hostname, timeout = 2000) {
return new Promise((done) => {
const sock = connect({ host: hostname, port: 22, timeout });
const finish = (ok) => {
sock.destroy();
done(ok);
};
sock.once("connect", () => finish(true));
sock.once("timeout", () => finish(false));
sock.once("error", () => finish(false));
});
}
console.log("Aurora router setup — passwordless SSH + .env for `pnpm dev`\n");
const args = process.argv.slice(2);
const argIp = args.find((a) => !a.startsWith("-"));
// Every variable the wizard manages (the full .env surface, see
// .env.example); any other lines in .env pass through untouched.
const ENV_VARS = [
{ key: "VITE_OPENWRT_HOST", label: "Router IP", fallback: "192.168.1.1" },
{ key: "VITE_DEV_HOST", label: "Dev server host", fallback: "127.0.0.1" },
{ key: "VITE_DEV_PORT", label: "Dev server port", fallback: "5173" },
];
const envPath = resolve(process.cwd(), ".env");
const envLines = existsSync(envPath)
? readFileSync(envPath, "utf-8").split("\n")
: [];
const savedValue = (key) =>
envLines
.find((l) => l.startsWith(`${key}=`))
?.slice(key.length + 1)
.trim();
const values = Object.fromEntries(
ENV_VARS.map(({ key, fallback }) => [key, savedValue(key) || fallback]),
);
if (argIp) {
// Arg mode is non-interactive: take the IP from argv and keep the saved
// (or default) dev-server values.
values.VITE_OPENWRT_HOST = argIp;
} else {
const rl = createInterface({ input: process.stdin, output: process.stdout });
// Answers come through the async iterator — unlike rl.question, it buffers
// lines, so piped input (`printf 'ip\nhost\nport\n' | …`) is not dropped
// between questions, and EOF just leaves the remaining defaults in place.
const lines = rl[Symbol.asyncIterator]();
for (const { key, label } of ENV_VARS) {
rl.setPrompt(`${label} [${values[key]}]: `);
rl.prompt();
const { value, done } = await lines.next();
if (done) {
console.log();
break;
}
const answer = value.trim();
if (answer) values[key] = answer;
}
rl.close();
}
if (!/^\d+$/.test(values.VITE_DEV_PORT)) {
console.log(
` Ignoring invalid port "${values.VITE_DEV_PORT}" — using 5173.`,
);
values.VITE_DEV_PORT = "5173";
}
const ip = values.VITE_OPENWRT_HOST;
// Accept the same forms vite.config.ts does (bare IP, host:port, http:// URL);
// ssh always targets the bare hostname — port/key overrides live in ~/.ssh/config.
const hostname = new URL(/^https?:\/\//.test(ip) ? ip : `http://${ip}`)
.hostname;
const host = `root@${hostname}`;
if (!(await canReachSsh(hostname))) {
console.error(`✗ Cannot reach ${hostname}:22 (2s timeout).`);
console.error(
" Check that the device is powered on, on the same network, and has SSH enabled.",
);
console.error(
" (If it listens on a non-standard port, add a Host block to ~/.ssh/config.)",
);
process.exit(1);
}
const host = `root@${ip}`;
const sshDir = join(homedir(), ".ssh");
let pubKeyPath = ["id_ed25519.pub", "id_rsa.pub", "id_ecdsa.pub"]
@@ -55,39 +161,93 @@ if (!pubKeyPath) {
}
if (canAuth(host)) {
console.log(`✓ Passwordless SSH to ${host} already works.`);
console.log(
`✓ Passwordless SSH to ${host} already works (checked: ssh echo).`,
);
} else {
console.log(
`Installing ${pubKeyPath} on the router (enter the router password once):`,
);
const pubKey = readFileSync(pubKeyPath, "utf-8").trim();
// stderr is piped so failures can be classified below; ssh's password prompt
// goes straight to the tty, so interactive login still works.
const install = spawnSync(
"ssh",
[
...SSH_OPTS,
"-o",
"ConnectTimeout=5",
host,
`mkdir -p /etc/dropbear && echo '${pubKey}' >> /etc/dropbear/authorized_keys && chmod 600 /etc/dropbear/authorized_keys`,
`mkdir -p /etc/dropbear && touch /etc/dropbear/authorized_keys && (grep -qxF '${pubKey}' /etc/dropbear/authorized_keys || echo '${pubKey}' >> /etc/dropbear/authorized_keys) && chmod 600 /etc/dropbear/authorized_keys`,
],
{ stdio: "inherit" },
{ stdio: ["inherit", "inherit", "pipe"], encoding: "utf-8" },
);
if (install.status !== 0 || !canAuth(host)) {
if (install.status !== 0) {
const stderr = (install.stderr || "").trim();
if (/Permission denied|Authentication failed/i.test(stderr)) {
console.error(
"✗ Authentication failed — likely a wrong password. Re-run pnpm setup:router.",
);
console.error(
" (If the device runs openssh instead of dropbear, keys belong in /root/.ssh/authorized_keys.)",
);
} else if (
/timed out|No route to host|Connection refused|Could not resolve/i.test(
stderr,
)
) {
console.error(`✗ ${hostname} did not respond as an SSH host.`);
console.error(
" Check the IP and that SSH is enabled on the device. (A VPN/proxy in TUN mode can also intercept the connection.)",
);
} else {
console.error("✗ Key install failed:");
}
if (stderr) console.error(` ${stderr.replace(/\n/g, "\n ")}`);
process.exit(1);
}
if (!canAuth(host)) {
console.error("✗ Key installed, but passwordless SSH still fails.");
console.error(
`✗ Key install failed. Check that the device at ${ip} is reachable and the password is correct, then re-run pnpm setup.`,
" Check the dropbear config on the device (PubkeyAuth) and /etc/dropbear/authorized_keys.",
);
process.exit(1);
}
console.log("✓ Passwordless SSH configured.");
}
const envPath = resolve(process.cwd(), ".env");
const kept = existsSync(envPath)
? readFileSync(envPath, "utf-8")
.split("\n")
.filter((l) => l && !l.startsWith("VITE_OPENWRT_HOST="))
: [];
writeFileSync(
envPath,
[`VITE_OPENWRT_HOST=${ip}`, ...kept].join("\n") + "\n",
// End-to-end check: push the templates through the exact pipeline ut-sync uses.
// `ssh echo ok` alone can pass while the real tar-over-ssh sync would not.
const started = Date.now();
try {
execSync(
`tar -C "${UT_TEMPLATE_DIR}" -cf - . | ssh ${SSH_OPTS.join(" ")} -o BatchMode=yes -o ConnectTimeout=5 "${host}" "mkdir -p '${UT_REMOTE_DIR}' && tar -xf - -C '${UT_REMOTE_DIR}'"`,
{ stdio: ["ignore", "pipe", "pipe"] },
);
console.log(
`✓ Templates pushed to ${host}:${UT_REMOTE_DIR} (${Date.now() - started}ms) — dev sync verified.`,
);
} catch (err) {
console.error(
"✗ Template push failed — `pnpm dev` template sync will not work:",
);
console.error(` ${String(err?.stderr || err?.message || err).trim()}`);
process.exit(1);
}
// Update managed keys in place so surrounding comments keep their meaning,
// then append any the file does not have yet; other lines pass through.
const out = envLines.map((l) => {
const managed = ENV_VARS.find(({ key }) => l.startsWith(`${key}=`));
return managed ? `${managed.key}=${values[managed.key]}` : l;
});
while (out.length && !out[out.length - 1]) out.pop();
for (const { key } of ENV_VARS) {
if (!out.some((l) => l.startsWith(`${key}=`)))
out.push(`${key}=${values[key]}`);
}
writeFileSync(envPath, out.join("\n") + "\n");
console.log(
`✓ Wrote .env (${ENV_VARS.map(({ key }) => `${key}=${values[key]}`).join(", ")})`,
);
console.log(`✓ Wrote .env (VITE_OPENWRT_HOST=${ip})`);
console.log("Done — run `pnpm dev`.");
+1 -1
View File
@@ -185,7 +185,7 @@ function reportSshError(err: any, sshHost: string): void {
) {
console.error(`\n${utTag()} SSH authentication failed for ${sshHost}.`);
console.error(
`${utTag()} Run \`pnpm setup\` to configure passwordless login.\n`,
`${utTag()} Run \`pnpm setup:router\` to configure passwordless login.\n`,
);
} else if (
stderr.includes("Connection refused") ||
+1 -1
View File
@@ -8,7 +8,7 @@ All dev commands run from `.dev/`:
```bash
cd .dev/
pnpm setup # One-shot dev setup: router IP → .env, installs SSH key on device
pnpm setup:router # One-shot dev setup: all .env values (router IP, dev host/port) → .env, installs SSH key on device ([ip] to run non-interactively; safe to re-run after a reflash)
pnpm dev # Start Vite dev server (proxies to OpenWrt device)
pnpm build # Clean + regenerate tokens + build production assets to htdocs/luci-static/
pnpm gen:tokens # Regenerate src/media/_tokens.css from tokens/ (also runs as part of build)
+1 -1
View File
@@ -9,7 +9,7 @@ LUCI_TITLE:=Aurora Theme (A modern browser theme built with Vite and Tailwind CS
LUCI_DEPENDS:=+luci-base
PKG_VERSION:=1.1.0
PKG_RELEASE:=49
PKG_RELEASE:=50
PKG_LICENSE:=Apache-2.0
LUCI_MINIFY_CSS:=