@flame0510/project-aether 1.1.12 → 1.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/app/agents/PageClient.tsx +140 -34
- package/app/agents/create/PageClient.tsx +11 -4
- package/app/api/agents/[id]/recreate/route.ts +3 -3
- package/app/api/agents/[id]/route.ts +3 -1
- package/app/api/agents/channels-summary/route.ts +48 -17
- package/app/api/agents/create/route.ts +32 -13
- package/app/api/agents/download-image/route.ts +2 -0
- package/app/api/agents/image-status/route.ts +94 -85
- package/app/api/agents/route.ts +10 -53
- package/app/api/agents/token/route.ts +4 -1
- package/app/api/auth/check/route.ts +2 -23
- package/app/api/auth/login/route.ts +3 -35
- package/app/api/auth/logout/route.ts +1 -1
- package/app/api/auth/status/route.ts +2 -21
- package/app/api/config/env/route.ts +7 -3
- package/app/api/config/restart/route.ts +2 -0
- package/app/api/containers/route.ts +3 -34
- package/app/api/crons/route.ts +2 -0
- package/app/api/debug/route.ts +2 -0
- package/app/api/envcheck/route.ts +3 -1
- package/app/api/gateway/agent/route.ts +8 -6
- package/app/api/gateway/provider/route.ts +43 -5
- package/app/api/gateway/route.ts +104 -136
- package/app/api/gateway/sync.ts +12 -15
- package/app/api/models/route.ts +2 -0
- package/app/api/provider/upstream.ts +12 -3
- package/app/api/setup/agent-image/route.ts +8 -3
- package/app/api/setup/password/route.ts +32 -37
- package/app/api/setup/restart/route.ts +8 -2
- package/app/api/skills/delete/route.js +1 -1
- package/app/api/skills/promote/route.js +1 -1
- package/app/api/skills/route.js +89 -97
- package/app/api/skills/save/route.js +18 -5
- package/app/api/update-check/route.ts +4 -23
- package/app/api/wizard/complete/route.ts +7 -3
- package/app/api/wizard/reset/route.ts +8 -3
- package/app/api/wizard/status/route.ts +8 -5
- package/app/components/AuthGuard.tsx +29 -8
- package/app/components/Skeleton.tsx +71 -0
- package/app/components/ui/ModalityIcons.tsx +157 -0
- package/app/components/ui/index.ts +1 -0
- package/app/crons/PageClient.tsx +32 -7
- package/app/gateway/PageClient.tsx +104 -16
- package/bin/rev4a.js +35 -24
- package/docs/ARCHITECTURE.md +31 -14
- package/docs/FRONTEND-ARCHITECTURE.md +1 -0
- package/docs/REV4A.md +36 -6
- package/docs/dev/API-REFERENCE.md +13 -8
- package/docs/dev/GATEWAY.md +42 -1
- package/docs/dev/PROVIDERS.md +4 -3
- package/docs/rag/REV4A-OVERVIEW.md +1 -1
- package/instrumentation.ts +18 -0
- package/lib/agent-setup.ts +8 -9
- package/lib/buildAgentImage.ts +5 -1
- package/lib/channelManager.ts +23 -16
- package/lib/db.ts +0 -10
- package/lib/docker-exec.ts +195 -0
- package/lib/docker-socket.ts +121 -0
- package/lib/docker-utils.ts +22 -48
- package/lib/openclaw-cron.ts +81 -48
- package/lib/openrouter-pricing.ts +184 -0
- package/lib/rev4a-auth.d.ts +4 -1
- package/lib/rev4a-auth.js +68 -20
- package/lib/rev4a-paths.ts +19 -0
- package/model-pricing.json +50 -50
- package/models.config.json +2 -2
- package/package.json +4 -1
- package/proxy.ts +68 -0
- package/scripts/refresh-model-pricing.mjs +171 -0
- package/lib/auth.ts +0 -28
- package/lib/requireAuth.tsx +0 -38
package/app/api/skills/route.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import {
|
|
4
|
+
import { execFile } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
5
6
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
7
|
+
import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
|
|
8
|
+
import { dockerExecShellNoFail, mapWithConcurrency } from '@/lib/docker-exec';
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
6
11
|
|
|
7
|
-
const SHARED_SKILLS_DIR = '/docker/shared-skills';
|
|
8
12
|
const CONTAINER_BUNDLED_SKILLS_DIR = '/usr/local/lib/node_modules/openclaw/skills';
|
|
13
|
+
const WORKSPACE_SKILLS_DIR = '/root/.openclaw/workspace/skills';
|
|
14
|
+
|
|
15
|
+
/** Agents read at once — each is a single bulk `docker exec`. */
|
|
16
|
+
const AGENT_QUERY_CONCURRENCY = 6;
|
|
9
17
|
|
|
10
18
|
function readSkillMeta(skillDir, name, type, agentId) {
|
|
11
19
|
const skillMdPath = path.join(skillDir, name, 'SKILL.md');
|
|
@@ -41,22 +49,19 @@ function readSkillMeta(skillDir, name, type, agentId) {
|
|
|
41
49
|
* Discover agent containers with AGENT_ID label.
|
|
42
50
|
* Returns [{ containerName, agentId }]
|
|
43
51
|
*/
|
|
44
|
-
function discoverAgentContainers() {
|
|
52
|
+
async function discoverAgentContainers() {
|
|
45
53
|
try {
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'inspect', containerName, '--format', '{{index .Config.Labels "AGENT_ID"}}'
|
|
58
|
-
], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
59
|
-
return { containerName, agentId: agentId || containerName };
|
|
54
|
+
// `docker ps` prints labels itself, so the AGENT_ID comes back with the name.
|
|
55
|
+
// This used to follow up with a `docker inspect` per container to read that
|
|
56
|
+
// same label — N+1 process spawns for data the first call already had.
|
|
57
|
+
const { stdout } = await execFileAsync('docker', [
|
|
58
|
+
'ps', '--filter', 'label=AGENT_ID', '--format', '{{.Label "AGENT_ID"}}\t{{.Names}}'
|
|
59
|
+
], { encoding: 'utf8', timeout: 5000 });
|
|
60
|
+
|
|
61
|
+
return stdout.trim().split('\n').map(line => {
|
|
62
|
+
const [agentId, containerName] = line.split('\t');
|
|
63
|
+
if (!containerName?.trim()) return null;
|
|
64
|
+
return { containerName: containerName.trim(), agentId: agentId?.trim() || containerName.trim() };
|
|
60
65
|
}).filter(Boolean);
|
|
61
66
|
} catch {
|
|
62
67
|
return [];
|
|
@@ -64,42 +69,44 @@ function discoverAgentContainers() {
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
/**
|
|
67
|
-
* Read
|
|
68
|
-
*
|
|
72
|
+
* Read every SKILL.md under `dir` in one shot.
|
|
73
|
+
*
|
|
74
|
+
* The previous approach ran `docker exec … cat` once per skill. With 52 bundled
|
|
75
|
+
* skills in the base image that was 52 sequential process spawns at ~260ms each
|
|
76
|
+
* — roughly 13s of blocked event loop to read 52 text files sitting in the same
|
|
77
|
+
* container.
|
|
78
|
+
*
|
|
79
|
+
* The shell loop emits `path\0content\0` pairs. NUL is the delimiter precisely
|
|
80
|
+
* because it cannot occur inside the text files being read, so no SKILL.md can
|
|
81
|
+
* forge a record boundary — a risk any printable separator would carry.
|
|
82
|
+
*
|
|
83
|
+
* Returns a Map of skill name → SKILL.md contents.
|
|
69
84
|
*/
|
|
70
|
-
function
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
*/
|
|
94
|
-
function readContainerSkillMd(containerName, skillName) {
|
|
95
|
-
const filePath = `/root/.openclaw/workspace/skills/${skillName}/SKILL.md`;
|
|
96
|
-
try {
|
|
97
|
-
return execFileSync('docker', ['exec', containerName, 'cat', filePath], {
|
|
98
|
-
encoding: 'utf8', timeout: 5000
|
|
99
|
-
});
|
|
100
|
-
} catch {
|
|
101
|
-
return null;
|
|
85
|
+
async function readSkillsBulk(containerName, dir) {
|
|
86
|
+
const script =
|
|
87
|
+
`for d in ${dir}/*/; do ` +
|
|
88
|
+
`[ -f "$d/SKILL.md" ] || continue; ` +
|
|
89
|
+
`n=$(basename "$d"); ` +
|
|
90
|
+
`printf '%s\\0' "$n"; ` +
|
|
91
|
+
`cat "$d/SKILL.md"; ` +
|
|
92
|
+
`printf '\\0'; ` +
|
|
93
|
+
`done`;
|
|
94
|
+
|
|
95
|
+
const raw = await dockerExecShellNoFail(containerName, script, {
|
|
96
|
+
timeoutMs: 15000,
|
|
97
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const out = new Map();
|
|
101
|
+
if (!raw) return out;
|
|
102
|
+
|
|
103
|
+
const parts = raw.split('\0');
|
|
104
|
+
// Pairs of name, content — a trailing empty element from the final NUL is fine.
|
|
105
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
106
|
+
const name = parts[i].trim();
|
|
107
|
+
if (name) out.set(name, parts[i + 1]);
|
|
102
108
|
}
|
|
109
|
+
return out;
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
/**
|
|
@@ -136,66 +143,51 @@ export async function GET(request) {
|
|
|
136
143
|
}
|
|
137
144
|
} catch {}
|
|
138
145
|
|
|
139
|
-
// 2. Agent container skills (workspace-local)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
146
|
+
// 2. Agent container skills (workspace-local) — one bulk read per agent,
|
|
147
|
+
// agents queried concurrently.
|
|
148
|
+
const agents = await discoverAgentContainers();
|
|
149
|
+
const perAgent = await mapWithConcurrency(agents, AGENT_QUERY_CONCURRENCY, (agent) =>
|
|
150
|
+
readSkillsBulk(agent.containerName, WORKSPACE_SKILLS_DIR),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
for (const [i, found] of perAgent.entries()) {
|
|
154
|
+
const agent = agents[i];
|
|
155
|
+
for (const [name, content] of found) {
|
|
156
|
+
const { description, version } = parseSkillContent(content);
|
|
157
|
+
skills.push({
|
|
158
|
+
name,
|
|
159
|
+
type: 'workspace',
|
|
160
|
+
agentId: agent.agentId,
|
|
161
|
+
containerName: agent.containerName,
|
|
162
|
+
path: `${WORKSPACE_SKILLS_DIR}/${name}`,
|
|
163
|
+
skillMdPath: `${WORKSPACE_SKILLS_DIR}/${name}/SKILL.md`,
|
|
164
|
+
hasSkillMd: true,
|
|
165
|
+
description,
|
|
166
|
+
version,
|
|
167
|
+
});
|
|
159
168
|
}
|
|
160
169
|
}
|
|
161
170
|
|
|
162
|
-
// 3. Bundled skills (read-only) —
|
|
171
|
+
// 3. Bundled skills (read-only) — identical in every agent, so the first
|
|
172
|
+
// container that answers is enough. One bulk read instead of 52 `cat`s.
|
|
163
173
|
try {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (agents.length > 0) {
|
|
168
|
-
for (const agent of agents) {
|
|
169
|
-
try {
|
|
170
|
-
const raw = execFileSync('docker', [
|
|
171
|
-
'exec', agent.containerName, 'ls', '-1', CONTAINER_BUNDLED_SKILLS_DIR
|
|
172
|
-
], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
173
|
-
if (raw) {
|
|
174
|
-
bundledDirNames = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
|
175
|
-
bundledSourceContainer = agent.containerName;
|
|
176
|
-
break;
|
|
177
|
-
}
|
|
178
|
-
} catch { /* try next container */ }
|
|
179
|
-
}
|
|
180
|
-
}
|
|
174
|
+
for (const agent of agents) {
|
|
175
|
+
const found = await readSkillsBulk(agent.containerName, CONTAINER_BUNDLED_SKILLS_DIR);
|
|
176
|
+
if (found.size === 0) continue; // container not ready — try the next one
|
|
181
177
|
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
const skillMdPath = `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}/SKILL.md`;
|
|
185
|
-
const content = execFileSync('docker', [
|
|
186
|
-
'exec', bundledSourceContainer, 'cat', skillMdPath
|
|
187
|
-
], { encoding: 'utf8', timeout: 5000 });
|
|
178
|
+
for (const [name, content] of found) {
|
|
188
179
|
const { description, version } = parseSkillContent(content);
|
|
189
180
|
skills.push({
|
|
190
181
|
name,
|
|
191
182
|
type: 'bundled',
|
|
192
183
|
path: `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}`,
|
|
193
|
-
skillMdPath
|
|
184
|
+
skillMdPath: `${CONTAINER_BUNDLED_SKILLS_DIR}/${name}/SKILL.md`,
|
|
194
185
|
hasSkillMd: true,
|
|
195
186
|
description,
|
|
196
187
|
version,
|
|
197
188
|
});
|
|
198
|
-
}
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
199
191
|
}
|
|
200
192
|
} catch {}
|
|
201
193
|
|
|
@@ -3,10 +3,25 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { execSync, execFileSync } from 'child_process';
|
|
5
5
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
|
+
import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
|
|
6
7
|
|
|
7
|
-
const SHARED_SKILLS_DIR = '/docker/shared-skills';
|
|
8
8
|
const BUNDLED_SKILLS_DIR = '/usr/lib/node_modules/openclaw/skills';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Pick an HTTP status from a caught error.
|
|
12
|
+
*
|
|
13
|
+
* Only the deliberate `Object.assign(new Error(…), { status: 404 })` throws in
|
|
14
|
+
* this file carry a real HTTP status. A failing `docker exec` also sets
|
|
15
|
+
* `.status`, but to the process exit code — usually 1. Passing that straight
|
|
16
|
+
* to NextResponse makes undici throw `RangeError: init["status"] must be in
|
|
17
|
+
* the range of 200 to 599`, so the caller got an opaque 500 with no body
|
|
18
|
+
* instead of the intended error. Anything outside the valid range falls back.
|
|
19
|
+
*/
|
|
20
|
+
function httpStatus(err, fallback) {
|
|
21
|
+
const s = err?.status;
|
|
22
|
+
return Number.isInteger(s) && s >= 200 && s <= 599 ? s : fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
/**
|
|
11
26
|
* Read SKILL.md content from various sources:
|
|
12
27
|
* - Host path (shared / bundled)
|
|
@@ -96,8 +111,7 @@ export async function POST(request) {
|
|
|
96
111
|
return NextResponse.json({ ok: true, saved: isContainer ? skillPath : absPath });
|
|
97
112
|
} catch (err) {
|
|
98
113
|
const msg = err instanceof Error ? err.message : 'Internal error';
|
|
99
|
-
|
|
100
|
-
return NextResponse.json({ error: msg }, { status });
|
|
114
|
+
return NextResponse.json({ error: msg }, { status: httpStatus(err, 500) });
|
|
101
115
|
}
|
|
102
116
|
}
|
|
103
117
|
|
|
@@ -136,7 +150,6 @@ export async function GET(request) {
|
|
|
136
150
|
return NextResponse.json({ content });
|
|
137
151
|
} catch (err) {
|
|
138
152
|
const msg = err instanceof Error ? err.message : 'Internal error';
|
|
139
|
-
|
|
140
|
-
return NextResponse.json({ error: msg }, { status });
|
|
153
|
+
return NextResponse.json({ error: msg }, { status: httpStatus(err, 404) });
|
|
141
154
|
}
|
|
142
155
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { jwtVerify } from 'jose';
|
|
3
2
|
import { execSync } from 'child_process';
|
|
4
3
|
import { readFileSync } from 'fs';
|
|
5
4
|
import { join } from 'path';
|
|
@@ -33,7 +32,8 @@ const CACHE_TTL_MS = 15 * 60 * 1000;
|
|
|
33
32
|
* GET /api/update-check
|
|
34
33
|
*/
|
|
35
34
|
export async function GET(request: Request): Promise<NextResponse> {
|
|
36
|
-
const denied = await requireAuthJWT(request);
|
|
35
|
+
const denied = await requireAuthJWT(request);
|
|
36
|
+
if (denied) return denied;
|
|
37
37
|
const url = new URL(request.url);
|
|
38
38
|
const check = url.searchParams.get('check') === '1';
|
|
39
39
|
|
|
@@ -71,23 +71,6 @@ export async function GET(request: Request): Promise<NextResponse> {
|
|
|
71
71
|
return NextResponse.json({ installed, latest, updateAvailable, package: getPackageInfo().name });
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
async function verifyAuth(request: Request): Promise<boolean> {
|
|
75
|
-
const raw = request.headers.get('cookie') || '';
|
|
76
|
-
const match = raw.split(';').map(c => c.trim()).find(c => c.startsWith('rev4a_token='));
|
|
77
|
-
if (!match) return false;
|
|
78
|
-
|
|
79
|
-
const cookieToken = decodeURIComponent(match.split('=')[1]);
|
|
80
|
-
try {
|
|
81
|
-
const secret = new TextEncoder().encode(
|
|
82
|
-
process.env.REV4A_JWT_SECRET || (() => { throw new Error('REV4A_JWT_SECRET not set'); })(),
|
|
83
|
-
);
|
|
84
|
-
await jwtVerify(cookieToken, secret);
|
|
85
|
-
return true;
|
|
86
|
-
} catch {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
74
|
/**
|
|
92
75
|
* POST /api/update-check
|
|
93
76
|
*
|
|
@@ -97,10 +80,8 @@ async function verifyAuth(request: Request): Promise<boolean> {
|
|
|
97
80
|
* with the newly installed binary. No systemd required.
|
|
98
81
|
*/
|
|
99
82
|
export async function POST(request: Request): Promise<NextResponse> {
|
|
100
|
-
const denied = await requireAuthJWT(request);
|
|
101
|
-
if (
|
|
102
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
103
|
-
}
|
|
83
|
+
const denied = await requireAuthJWT(request);
|
|
84
|
+
if (denied) return denied;
|
|
104
85
|
|
|
105
86
|
// Spawn rev4a update in background, then write restart flag directly
|
|
106
87
|
// from this process so rev4a serve detects it and restarts.
|
|
@@ -4,16 +4,20 @@
|
|
|
4
4
|
* Sets wizard as complete in a persistent file (cross-browser).
|
|
5
5
|
* Also sets the httpOnly cookie for backwards compatibility.
|
|
6
6
|
*/
|
|
7
|
-
import { NextResponse } from 'next/server';
|
|
7
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import { REV4A_DATA } from '@/lib/rev4a-paths';
|
|
11
|
+
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
11
12
|
|
|
12
13
|
export const dynamic = 'force-dynamic';
|
|
13
14
|
|
|
14
|
-
const WIZARD_FILE = REV4A_DATA
|
|
15
|
+
const WIZARD_FILE = path.join(REV4A_DATA, 'data', 'wizard.json');
|
|
16
|
+
|
|
17
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
18
|
+
const denied = await requireAuthIfConfigured(request);
|
|
19
|
+
if (denied) return denied;
|
|
15
20
|
|
|
16
|
-
export function POST(): NextResponse {
|
|
17
21
|
// Write persistent marker
|
|
18
22
|
const dir = path.dirname(WIZARD_FILE);
|
|
19
23
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
@@ -4,15 +4,20 @@
|
|
|
4
4
|
* Clears the wizard marker so the wizard runs again.
|
|
5
5
|
* Removes both the persistent file and the cookie.
|
|
6
6
|
*/
|
|
7
|
-
import { NextResponse } from 'next/server';
|
|
7
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
9
10
|
import { REV4A_DATA } from '@/lib/rev4a-paths';
|
|
11
|
+
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
10
12
|
|
|
11
13
|
export const dynamic = 'force-dynamic';
|
|
12
14
|
|
|
13
|
-
const WIZARD_FILE = REV4A_DATA
|
|
15
|
+
const WIZARD_FILE = path.join(REV4A_DATA, 'data', 'wizard.json');
|
|
16
|
+
|
|
17
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
18
|
+
const denied = await requireAuthIfConfigured(request);
|
|
19
|
+
if (denied) return denied;
|
|
14
20
|
|
|
15
|
-
export function POST(): NextResponse {
|
|
16
21
|
// Remove persistent marker
|
|
17
22
|
try { fs.unlinkSync(WIZARD_FILE); } catch { /* ignore */ }
|
|
18
23
|
|
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* Returns whether the wizard is complete.
|
|
5
5
|
* Checks a persistent file so it survives browser changes and cache clears.
|
|
6
6
|
*/
|
|
7
|
-
import { NextResponse } from 'next/server';
|
|
7
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
9
10
|
import { REV4A_DATA } from '@/lib/rev4a-paths';
|
|
11
|
+
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
10
12
|
|
|
11
13
|
export const dynamic = 'force-dynamic';
|
|
12
14
|
|
|
13
|
-
const WIZARD_FILE = REV4A_DATA
|
|
15
|
+
const WIZARD_FILE = path.join(REV4A_DATA, 'data', 'wizard.json');
|
|
14
16
|
|
|
15
17
|
function readWizardFile(): boolean {
|
|
16
18
|
try {
|
|
@@ -22,7 +24,8 @@ function readWizardFile(): boolean {
|
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export function GET(): NextResponse {
|
|
26
|
-
const
|
|
27
|
-
|
|
27
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
28
|
+
const denied = await requireAuthIfConfigured(request);
|
|
29
|
+
if (denied) return denied;
|
|
30
|
+
return NextResponse.json({ done: readWizardFile() });
|
|
28
31
|
}
|
|
@@ -7,17 +7,38 @@ import { useRouter, usePathname } from 'next/navigation';
|
|
|
7
7
|
const authCache = { valid: false, checked: false, ts: 0 };
|
|
8
8
|
const AUTH_CACHE_TTL = 30_000; // 30s before re-check
|
|
9
9
|
|
|
10
|
+
function isCacheValid(): boolean {
|
|
11
|
+
return authCache.checked && authCache.valid && (Date.now() - authCache.ts) < AUTH_CACHE_TTL;
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
11
15
|
const router = useRouter();
|
|
12
16
|
const pathname = usePathname();
|
|
13
17
|
const [redirecting, setRedirecting] = useState(false);
|
|
14
18
|
const checkingRef = useRef(false);
|
|
15
19
|
|
|
20
|
+
// Which pathname the last successful check applies to. Kept per-path so a
|
|
21
|
+
// client-side navigation to a different protected route can't inherit the
|
|
22
|
+
// previous route's "verified" state and flash its content for a frame.
|
|
23
|
+
const [verifiedPath, setVerifiedPath] = useState<string | null>(null);
|
|
24
|
+
|
|
25
|
+
const isPublicRoute = pathname === '/setup' || pathname === '/login';
|
|
26
|
+
// Evaluated fresh on every render (not held in state), so an expired cache
|
|
27
|
+
// closes the gate immediately rather than one paint late.
|
|
28
|
+
const ready = isPublicRoute || isCacheValid() || verifiedPath === pathname;
|
|
29
|
+
|
|
16
30
|
useEffect(() => {
|
|
31
|
+
// A redirect that has landed is finished: clear the flag on every
|
|
32
|
+
// navigation, otherwise the render gate below stays closed forever and
|
|
33
|
+
// the destination page renders blank. (AppShell currently unmounts this
|
|
34
|
+
// component for /login, /setup and /wizard, which used to hide the bug —
|
|
35
|
+
// don't depend on that staying true.)
|
|
36
|
+
setRedirecting(false);
|
|
37
|
+
|
|
17
38
|
// /setup: always accessible
|
|
18
39
|
if (pathname === '/setup') return;
|
|
19
40
|
|
|
20
|
-
// /login: public,
|
|
41
|
+
// /login: public, but bounce to /setup if no password exists yet
|
|
21
42
|
if (pathname === '/login') {
|
|
22
43
|
fetch('/api/auth/status')
|
|
23
44
|
.then((r) => r.json())
|
|
@@ -31,18 +52,16 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
|
31
52
|
// Already checking — prevent duplicate
|
|
32
53
|
if (checkingRef.current) return;
|
|
33
54
|
|
|
34
|
-
// Cache hit —
|
|
35
|
-
if (
|
|
36
|
-
return; // Still valid, no re-check
|
|
37
|
-
}
|
|
55
|
+
// Cache hit — content is already known-good, `ready` above covers it
|
|
56
|
+
if (isCacheValid()) return;
|
|
38
57
|
|
|
39
58
|
checkingRef.current = true;
|
|
40
59
|
|
|
41
|
-
// Protected
|
|
60
|
+
// Protected route with no valid cache — verify before revealing content
|
|
42
61
|
fetch('/api/auth/check')
|
|
43
62
|
.then(async (r) => {
|
|
44
63
|
if (!r.ok) {
|
|
45
|
-
// Not authenticated — redirect
|
|
64
|
+
// Not authenticated — redirect, keep content hidden
|
|
46
65
|
authCache.valid = false;
|
|
47
66
|
authCache.checked = true;
|
|
48
67
|
const status = await fetch('/api/auth/status').then((r2) => r2.json());
|
|
@@ -55,6 +74,7 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
|
55
74
|
authCache.valid = true;
|
|
56
75
|
authCache.checked = true;
|
|
57
76
|
authCache.ts = Date.now();
|
|
77
|
+
setVerifiedPath(pathname);
|
|
58
78
|
|
|
59
79
|
if (pathname === '/wizard') return;
|
|
60
80
|
|
|
@@ -86,6 +106,7 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
|
86
106
|
});
|
|
87
107
|
}, [pathname, router]);
|
|
88
108
|
|
|
89
|
-
|
|
109
|
+
if (redirecting) return null;
|
|
110
|
+
if (!ready) return null; // hold content until this route's check resolves
|
|
90
111
|
return <>{children}</>;
|
|
91
112
|
}
|
|
@@ -95,6 +95,77 @@ export function CodeSkeleton({ lines = 12 }: { lines?: number }) {
|
|
|
95
95
|
);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Cron job rows — mirrors the real row in the scheduled-jobs column so the
|
|
100
|
+
* layout doesn't jump when the data lands: same 12px/14px padding, same divider,
|
|
101
|
+
* same title/description/badge header, schedule line, and NEXT RUN / LAST RUN
|
|
102
|
+
* grid. Widths vary per row so it reads as a list rather than a stack of bars.
|
|
103
|
+
*/
|
|
104
|
+
export function CronJobsSkeleton({ rows = 5 }: { rows?: number }) {
|
|
105
|
+
const shapes = [
|
|
106
|
+
{ title: '58%', desc: '82%', badge: 54, expr: '34%' },
|
|
107
|
+
{ title: '42%', desc: null, badge: 40, expr: '28%' },
|
|
108
|
+
{ title: '66%', desc: '71%', badge: 62, expr: '40%' },
|
|
109
|
+
{ title: '50%', desc: null, badge: 46, expr: '31%' },
|
|
110
|
+
{ title: '61%', desc: '64%', badge: 58, expr: '36%' },
|
|
111
|
+
{ title: '45%', desc: null, badge: 50, expr: '26%' },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div aria-hidden="true">
|
|
116
|
+
{shapes.slice(0, rows).map((s, i) => (
|
|
117
|
+
<div key={i} style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
118
|
+
{/* Header: title + optional description on the left, agent pill right */}
|
|
119
|
+
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 6, gap: 8 }}>
|
|
120
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
121
|
+
<Skeleton width={s.title} height={12} accent />
|
|
122
|
+
{s.desc && <Skeleton width={s.desc} height={9} style={{ marginTop: 4 }} />}
|
|
123
|
+
</div>
|
|
124
|
+
<Skeleton width={s.badge} height={13} style={{ flexShrink: 0 }} />
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Schedule line: label + cron expression */}
|
|
128
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
|
129
|
+
<Skeleton width={46} height={9} />
|
|
130
|
+
<Skeleton width={s.expr} height={10} />
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* NEXT RUN / LAST RUN grid */}
|
|
134
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px' }}>
|
|
135
|
+
{[0, 1].map((c) => (
|
|
136
|
+
<div key={c}>
|
|
137
|
+
<Skeleton width={52} height={8} style={{ marginBottom: 3 }} />
|
|
138
|
+
<Skeleton width="72%" height={10} />
|
|
139
|
+
</div>
|
|
140
|
+
))}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Cron run-history entries — one timestamp line plus a status/duration row,
|
|
150
|
+
* matching the entries the runs column renders once a job is selected.
|
|
151
|
+
*/
|
|
152
|
+
export function CronRunsSkeleton({ rows = 6 }: { rows?: number }) {
|
|
153
|
+
const widths = ['64%', '48%', '71%', '55%', '60%', '44%'];
|
|
154
|
+
return (
|
|
155
|
+
<div aria-hidden="true">
|
|
156
|
+
{widths.slice(0, rows).map((w, i) => (
|
|
157
|
+
<div key={i} style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)' }}>
|
|
158
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 5 }}>
|
|
159
|
+
<Skeleton width={w} height={10} />
|
|
160
|
+
<Skeleton width={38} height={11} style={{ flexShrink: 0 }} />
|
|
161
|
+
</div>
|
|
162
|
+
<Skeleton width="30%" height={9} />
|
|
163
|
+
</div>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
98
169
|
/* ─── Legacy aliases (used by existing components) ────────────────────────── */
|
|
99
170
|
/** Stack of horizontal skeleton lines */
|
|
100
171
|
export function SkeletonLines({ count = 3, width }: { count?: number; width?: string | number }) {
|