mirror of
https://github.com/arjunkomath/openclaw-railway-template.git
synced 2026-08-14 00:48:11 +00:00
Add config editor
This commit is contained in:
@@ -195,3 +195,4 @@ This avoids repeatedly reading large files and provides instant context about th
|
||||
5. **Gateway spawn inherits stdio** → logs appear in wrapper output (src/server.js:134)
|
||||
6. **WebSocket auth requires proxy event handlers** → Direct `req.headers` modification doesn't work for WebSocket upgrades with http-proxy; must use `proxyReqWs` event (src/server.js:741) to reliably inject Authorization header
|
||||
7. **Control UI requires allowInsecureAuth to bypass pairing** → Set `gateway.controlUi.allowInsecureAuth=true` during onboarding to prevent "disconnected (1008): pairing required" errors (GitHub issue #2284). Wrapper already handles bearer token auth, so device pairing is unnecessary.
|
||||
8. **Config Editor (`/setup/config`) writes `openclaw.json` directly** → unlike onboarding (which uses `openclaw config set --json`), the editor parses+validates JSON, makes a `.bak-<timestamp>` copy, then `fs.writeFileSync`s the new contents and calls `restartGateway()`. This is intentional: it's an operator-targeted advanced tool, so direct writes are simpler and let users fix arbitrarily-broken config without depending on the CLI.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>🦞 OpenClaw — Config Editor</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('configApp', () => ({
|
||||
path: '',
|
||||
exists: false,
|
||||
content: '',
|
||||
loading: false,
|
||||
saving: false,
|
||||
message: '',
|
||||
error: '',
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async httpJson(url, opts = {}) {
|
||||
opts.credentials = 'same-origin';
|
||||
const res = await fetch(url, opts);
|
||||
const text = await res.text();
|
||||
let body = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { /* leave null */ }
|
||||
if (!res.ok) {
|
||||
const err = (body && body.error) || text || res.statusText;
|
||||
throw new Error('HTTP ' + res.status + ': ' + err);
|
||||
}
|
||||
return body;
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const j = await this.httpJson('/setup/api/config/raw');
|
||||
this.path = j.path || '';
|
||||
this.exists = !!j.exists;
|
||||
this.content = j.content || '';
|
||||
} catch (e) {
|
||||
this.error = String(e.message || e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async save() {
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
try {
|
||||
JSON.parse(this.content);
|
||||
} catch (e) {
|
||||
this.error = 'Invalid JSON: ' + (e.message || String(e));
|
||||
return;
|
||||
}
|
||||
if (!confirm('Save config and restart the gateway? A timestamped .bak backup will be created next to openclaw.json.')) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
try {
|
||||
const j = await this.httpJson('/setup/api/config/raw', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ content: this.content })
|
||||
});
|
||||
let msg = 'Saved to ' + (j.path || this.path) + '.';
|
||||
if (j.backupPath) msg += '\nBackup: ' + j.backupPath;
|
||||
msg += j.restarted ? '\nGateway restarted.' : '\nGateway not running — no restart needed.';
|
||||
this.message = msg;
|
||||
this.exists = true;
|
||||
} catch (e) {
|
||||
this.error = String(e.message || e);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body x-data="configApp()">
|
||||
<div class="container">
|
||||
<div class="logo-wrap">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" class="logo-img">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1.25rem">
|
||||
<a href="/setup" class="inline-link" style="font-size:0.875rem">← Back to Setup</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2 class="card-title">Config Editor</h2>
|
||||
<p class="card-subtitle">Direct edit of <code>openclaw.json</code>. Saving creates a timestamped <code>.bak</code> backup and restarts the gateway.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="form-hint" style="margin-bottom:0.75rem">
|
||||
<strong>File:</strong>
|
||||
<span x-text="path || '(loading…)'"></span>
|
||||
<span x-show="!loading && !exists" style="color:var(--text-muted)">(does not exist yet)</span>
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
class="config-textarea"
|
||||
x-model="content"
|
||||
:disabled="loading || saving"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
></textarea>
|
||||
|
||||
<div class="btn-row" style="margin-top:1rem;display:flex;gap:0.75rem;flex-wrap:wrap">
|
||||
<button @click="load()" :disabled="loading || saving" class="btn btn-secondary">
|
||||
<span x-show="!loading">Reload</span>
|
||||
<span x-show="loading" style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
<span class="spinner spinner-inline" aria-hidden="true"></span>
|
||||
Loading
|
||||
</span>
|
||||
</button>
|
||||
<button @click="save()" :disabled="loading || saving" class="btn btn-primary">
|
||||
<span x-show="!saving">Save & Restart Gateway</span>
|
||||
<span x-show="saving" style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
<span class="spinner spinner-inline" aria-hidden="true"></span>
|
||||
Saving
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="error" x-cloak class="setup-error-panel" style="margin-top:1rem" x-text="error"></div>
|
||||
<div x-show="message" x-cloak class="log-output" style="margin-top:1rem" x-text="message"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,9 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OpenClaw</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
.fade-out { opacity: 0; transition: opacity 0.4s ease; }
|
||||
.fade-in { opacity: 1; transition: opacity 0.4s ease; }
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>🦞 OpenClaw — Server Logs</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('logsApp', () => ({
|
||||
|
||||
+76
-7
@@ -5,9 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>🦞 OpenClaw — Personal AI Assistant</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('setupApp', () => ({
|
||||
@@ -48,6 +45,9 @@
|
||||
exporting: false,
|
||||
showExportInfo: false,
|
||||
importing: false,
|
||||
showImportModal: false,
|
||||
pendingImportFile: null,
|
||||
importArchivePassword: '',
|
||||
showDevicesModal: false,
|
||||
devicesLoading: false,
|
||||
devices: [],
|
||||
@@ -354,11 +354,31 @@
|
||||
}
|
||||
},
|
||||
|
||||
async importData(event) {
|
||||
onImportFileSelected(event) {
|
||||
const f = event.target.files && event.target.files[0];
|
||||
event.target.value = '';
|
||||
if (!f) return;
|
||||
if (!confirm('Import backup? This overwrites your config and workspace under /data and restarts the gateway.')) return;
|
||||
this.pendingImportFile = f;
|
||||
this.importArchivePassword = '';
|
||||
this.showImportModal = true;
|
||||
},
|
||||
|
||||
cancelImport() {
|
||||
if (this.importing) return;
|
||||
this.showImportModal = false;
|
||||
this.pendingImportFile = null;
|
||||
this.importArchivePassword = '';
|
||||
},
|
||||
|
||||
async confirmImport() {
|
||||
const f = this.pendingImportFile;
|
||||
if (!f) return;
|
||||
|
||||
const headers = { 'content-type': 'application/zip' };
|
||||
const pw = (this.importArchivePassword || '').trim();
|
||||
if (pw) {
|
||||
headers['x-archive-password'] = btoa(unescape(encodeURIComponent(pw)));
|
||||
}
|
||||
|
||||
this.importing = true;
|
||||
this.log = 'Uploading ' + f.name + ' (' + f.size + ' bytes)...\n';
|
||||
@@ -368,7 +388,7 @@
|
||||
const res = await fetch('/setup/api/import', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': 'application/zip' },
|
||||
headers,
|
||||
body: buf,
|
||||
});
|
||||
const text = await res.text();
|
||||
@@ -377,6 +397,9 @@
|
||||
if (j.output) this.log += j.output + '\n';
|
||||
if (res.ok && j.ok) {
|
||||
this.log += '✓ Import complete. Gateway restarted.\n';
|
||||
this.showImportModal = false;
|
||||
this.pendingImportFile = null;
|
||||
this.importArchivePassword = '';
|
||||
await this.refreshStatus();
|
||||
} else {
|
||||
this.log += '✗ Import failed: ' + (j.error || text) + '\n';
|
||||
@@ -586,7 +609,7 @@
|
||||
<span class="action-title">Import Data</span>
|
||||
</div>
|
||||
<p class="action-desc">Restore from a password-protected ZIP. Overwrites config and workspace, then restarts the gateway.</p>
|
||||
<input x-ref="importInput" @change="importData($event)" type="file" accept=".zip,application/zip" style="display:none" />
|
||||
<input x-ref="importInput" @change="onImportFileSelected($event)" type="file" accept=".zip,application/zip" style="display:none" />
|
||||
<button @click="$refs.importInput.click()" :disabled="loading || importing || exporting" class="action-btn">
|
||||
<span x-show="!importing">Import Data</span>
|
||||
<span x-show="importing" style="display:inline-flex;align-items:center;justify-content:center;gap:0.5rem">
|
||||
@@ -608,6 +631,19 @@
|
||||
View Logs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="action-card action-violet">
|
||||
<div class="action-card-header">
|
||||
<div class="action-icon">
|
||||
<svg class="icon-md" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</div>
|
||||
<span class="action-title">Edit Config</span>
|
||||
</div>
|
||||
<p class="action-desc">Directly edit <code>openclaw.json</code>. Creates a backup and restarts the gateway on save.</p>
|
||||
<a href="/setup/config" class="action-btn" style="display:block;text-align:center;text-decoration:none">
|
||||
Edit Config
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="log" class="log-output" x-text="log"></div>
|
||||
@@ -924,6 +960,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showImportModal" x-cloak class="modal-overlay" @click.self="cancelImport()">
|
||||
<div class="modal-card modal-md">
|
||||
<h3 class="modal-title mb-4">Import Backup</h3>
|
||||
<p class="text-sm text-muted mb-4">
|
||||
Restoring <strong x-text="pendingImportFile?.name"></strong>
|
||||
(<span x-text="pendingImportFile ? Math.round(pendingImportFile.size / 1024) + ' KB' : ''"></span>).
|
||||
This overwrites your config and workspace under <code>/data</code>, then restarts the gateway.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label class="label">Archive password</label>
|
||||
<input
|
||||
x-model="importArchivePassword"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
placeholder="Leave blank to use this instance's SETUP_PASSWORD"
|
||||
:disabled="importing"
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-sm text-muted" style="margin-top:0.25rem">Use the SETUP_PASSWORD of the instance the backup was exported from. Leave blank if it was this instance.</p>
|
||||
</div>
|
||||
<div class="btn-pair">
|
||||
<button @click="confirmImport()" :disabled="importing" class="btn btn-primary">
|
||||
<span x-show="!importing">Import & Restart</span>
|
||||
<span x-show="importing" style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
<span class="spinner spinner-inline" aria-hidden="true"></span>
|
||||
Importing
|
||||
</span>
|
||||
</button>
|
||||
<button @click="cancelImport()" :disabled="importing" class="btn btn-close-full" style="margin-top:0">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showPairingModal" x-cloak class="modal-overlay" @click.self="if (!pairingSubmitting) showPairingModal = false">
|
||||
<div class="modal-card modal-sm">
|
||||
<h3 class="modal-title mb-4">Approve Channel Access</h3>
|
||||
|
||||
+18
-1
@@ -7,7 +7,7 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'Space Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--bg: #f5f5f5;
|
||||
--bg-card: #fff;
|
||||
--bg-input: #fafafa;
|
||||
@@ -157,6 +157,23 @@ img { max-width: 100%; height: auto; }
|
||||
|
||||
.form-group + .form-group { margin-top: 1.25rem; }
|
||||
|
||||
.config-textarea {
|
||||
width: 100%;
|
||||
min-height: 460px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
tab-size: 2;
|
||||
}
|
||||
.config-textarea:focus { border-color: #dc2626; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
<title>🦞 OpenClaw TUI</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="tui-body">
|
||||
<div class="tui-header">
|
||||
|
||||
+111
-21
@@ -575,6 +575,61 @@ app.get("/setup", requireSetupAuth, (_req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), "src", "public", "setup.html"));
|
||||
});
|
||||
|
||||
app.get("/setup/config", requireSetupAuth, (_req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), "src", "public", "config.html"));
|
||||
});
|
||||
|
||||
app.get("/setup/api/config/raw", requireSetupAuth, (_req, res) => {
|
||||
const p = configPath();
|
||||
const exists = fs.existsSync(p);
|
||||
const content = exists ? fs.readFileSync(p, "utf8") : "";
|
||||
res.json({ ok: true, path: p, exists, content });
|
||||
});
|
||||
|
||||
app.post("/setup/api/config/raw", requireSetupAuth, async (req, res) => {
|
||||
const content = String((req.body && req.body.content) || "");
|
||||
if (content.length > 500_000) {
|
||||
return res
|
||||
.status(413)
|
||||
.json({ ok: false, error: "Config too large (max 500KB)" });
|
||||
}
|
||||
try {
|
||||
JSON.parse(content);
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, error: `Invalid JSON: ${err.message}` });
|
||||
}
|
||||
|
||||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||
const p = configPath();
|
||||
let backupPath = null;
|
||||
if (fs.existsSync(p)) {
|
||||
backupPath = `${p}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
fs.copyFileSync(p, backupPath);
|
||||
}
|
||||
|
||||
// Write to a sibling tmp file then renameSync over the live config so a
|
||||
// crash or disk-full mid-write can't leave openclaw.json truncated.
|
||||
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
fs.writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
|
||||
fs.renameSync(tmp, p);
|
||||
} catch (err) {
|
||||
try { fs.rmSync(tmp, { force: true }); } catch {}
|
||||
return res
|
||||
.status(500)
|
||||
.json({ ok: false, error: `Failed to write config: ${err.message}` });
|
||||
}
|
||||
|
||||
let restarted = false;
|
||||
if (isConfigured()) {
|
||||
await restartGateway();
|
||||
restarted = true;
|
||||
}
|
||||
res.json({ ok: true, path: p, backupPath, restarted });
|
||||
});
|
||||
|
||||
app.get("/setup/api/status", requireSetupAuth, async (_req, res) => {
|
||||
const { version } = await getOpenclawInfo();
|
||||
|
||||
@@ -1540,6 +1595,20 @@ app.post(
|
||||
try { fs.rmSync(tmpExtract, { recursive: true, force: true }); } catch {}
|
||||
};
|
||||
|
||||
let archivePassword = SETUP_PASSWORD;
|
||||
const headerPw = req.get("x-archive-password");
|
||||
if (headerPw) {
|
||||
try {
|
||||
const decoded = Buffer.from(headerPw, "base64").toString("utf8").trim();
|
||||
if (decoded) archivePassword = decoded;
|
||||
} catch {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: "Invalid X-Archive-Password header.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const reject = (status, error, extra = {}) => {
|
||||
const e = new Error(error);
|
||||
e.status = status;
|
||||
@@ -1599,25 +1668,23 @@ app.post(
|
||||
);
|
||||
}
|
||||
|
||||
if (gatewayProc) {
|
||||
intentionalRestart = true;
|
||||
try {
|
||||
gatewayProc.kill("SIGTERM");
|
||||
} catch (err) {
|
||||
log.warn("import", `kill error: ${err.message}`);
|
||||
}
|
||||
await sleep(750);
|
||||
gatewayProc = null;
|
||||
intentionalRestart = false;
|
||||
gatewayWasStopped = true;
|
||||
}
|
||||
|
||||
// Extract into a sibling directory under /data so renames into
|
||||
// STATE_DIR / WORKSPACE_DIR are intra-volume (atomic on POSIX).
|
||||
// We extract BEFORE stopping the gateway so a wrong password or
|
||||
// corrupt archive doesn't cost the user any gateway downtime.
|
||||
fs.mkdirSync(tmpExtract, { recursive: true });
|
||||
const extractResult = await runCmd("unzip", ["-P", SETUP_PASSWORD, "-o", tmpZip, "-d", tmpExtract]);
|
||||
const extractResult = await runCmd("unzip", ["-P", archivePassword, "-o", tmpZip, "-d", tmpExtract]);
|
||||
if (extractResult.code !== 0) {
|
||||
log.error("import", `unzip exit ${extractResult.code}: ${extractResult.output}`);
|
||||
// unzip exit 82 = wrong password; 81 = needs password but none given.
|
||||
if (extractResult.code === 82 || extractResult.code === 81) {
|
||||
throw reject(
|
||||
400,
|
||||
headerPw
|
||||
? "Incorrect archive password."
|
||||
: "Incorrect archive password. If this backup was exported from another instance, supply that instance's SETUP_PASSWORD.",
|
||||
);
|
||||
}
|
||||
throw reject(500, "Failed to extract archive.", { output: extractResult.output });
|
||||
}
|
||||
|
||||
@@ -1634,17 +1701,40 @@ app.post(
|
||||
{ target: stateAbs, source: path.join(tmpExtract, stateRel) },
|
||||
{ target: workspaceAbs, source: path.join(tmpExtract, workspaceRel) },
|
||||
];
|
||||
for (const { target, source } of replacements) {
|
||||
if (!fs.existsSync(source)) continue;
|
||||
// Even after walkRejectSymlinks, the entry at the source path itself
|
||||
// could be a regular file masquerading as data/.openclaw. Require an
|
||||
// actual directory before swapping it in for STATE_DIR / WORKSPACE_DIR.
|
||||
if (!fs.lstatSync(source).isDirectory()) {
|
||||
|
||||
// Even after walkRejectSymlinks, the entry at the source path itself
|
||||
// could be a regular file masquerading as data/.openclaw. Require an
|
||||
// actual directory before swapping it in for STATE_DIR / WORKSPACE_DIR.
|
||||
// Run this validation BEFORE stopping the gateway so a malformed
|
||||
// archive doesn't cost any downtime.
|
||||
const validReplacements = [];
|
||||
for (const r of replacements) {
|
||||
if (!fs.existsSync(r.source)) continue;
|
||||
if (!fs.lstatSync(r.source).isDirectory()) {
|
||||
throw reject(
|
||||
400,
|
||||
`Archive entry ${path.relative(tmpExtract, source)} is not a directory.`,
|
||||
`Archive entry ${path.relative(tmpExtract, r.source)} is not a directory.`,
|
||||
);
|
||||
}
|
||||
validReplacements.push(r);
|
||||
}
|
||||
|
||||
// Now that the archive is fully extracted and validated, take down the
|
||||
// gateway. From here through the rename swap, downtime is unavoidable.
|
||||
if (gatewayProc) {
|
||||
intentionalRestart = true;
|
||||
try {
|
||||
gatewayProc.kill("SIGTERM");
|
||||
} catch (err) {
|
||||
log.warn("import", `kill error: ${err.message}`);
|
||||
}
|
||||
await sleep(750);
|
||||
gatewayProc = null;
|
||||
intentionalRestart = false;
|
||||
gatewayWasStopped = true;
|
||||
}
|
||||
|
||||
for (const { target, source } of validReplacements) {
|
||||
const backup = `${target}.bak-${ts}`;
|
||||
if (fs.existsSync(target)) {
|
||||
fs.renameSync(target, backup);
|
||||
|
||||
Reference in New Issue
Block a user