@flame0510/project-aether 1.1.15 → 1.3.0
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 +2 -1
- package/app/agents/ModelSection.tsx +313 -0
- package/app/agents/PageClient.tsx +83 -4
- package/app/agents/create/page.tsx +8 -21
- package/app/api/agents/[id]/model/route.ts +113 -0
- package/app/api/agents/[id]/recreate/route.ts +10 -34
- package/app/api/agents/[id]/route.ts +10 -29
- package/app/api/agents/create/route.ts +59 -57
- package/app/api/agents/models-summary/route.ts +163 -0
- package/app/api/assistant/route.ts +36 -15
- package/app/api/credentials/[id]/sync/route.ts +3 -3
- package/app/api/credentials/detect/route.ts +126 -176
- package/app/api/credentials/route.ts +3 -0
- package/app/api/gateway/agent/route.ts +23 -6
- package/app/api/gateway/provider/keys.ts +13 -1
- package/app/api/gateway/provider/route.ts +43 -12
- package/app/api/gateway/sync.ts +248 -72
- package/app/api/models/route.ts +28 -34
- package/app/api/provider/auth.ts +65 -0
- package/app/api/provider/upstream.ts +9 -2
- package/app/api/provider/v1/chat/completions/route.ts +22 -16
- package/app/api/provider/v1/models/route.ts +26 -133
- package/app/components/PulseChat.tsx +25 -39
- package/app/components/Skeleton.tsx +132 -0
- package/app/components/ui/RemoveButton.tsx +46 -0
- package/app/components/ui/Select.tsx +3 -2
- package/app/components/ui/index.ts +1 -0
- package/app/credentials/PageClient.tsx +461 -140
- package/app/credentials/loading.tsx +19 -5
- package/app/gateway/PageClient.tsx +257 -673
- package/app/globals.css +8 -0
- package/app/lib/models-context.tsx +43 -7
- package/app/wizard/useWizard.ts +6 -1
- package/bin/rev4a.js +73 -9
- package/docs/ARCHITECTURE.md +92 -33
- package/docs/FRONTEND-ARCHITECTURE.md +36 -6
- package/docs/REV4A.md +62 -30
- package/docs/dev/API-REFERENCE.md +490 -227
- package/docs/dev/DATABASE.md +8 -3
- package/docs/dev/GATEWAY.md +236 -92
- package/docs/dev/PROVIDERS.md +44 -44
- package/docs/rag/DATA-FRESHNESS.md +57 -28
- package/docs/rag/GLOSSARY.md +20 -18
- package/docs/rag/REV4A-OVERVIEW.md +28 -32
- package/docs/rag/WHAT-I-CAN-ANSWER.md +10 -12
- package/instrumentation.ts +9 -1
- package/lib/agent-readiness.ts +110 -0
- package/lib/channelManager.ts +64 -22
- package/lib/container-file.ts +27 -0
- package/lib/credentials/delivery.ts +212 -119
- package/lib/credentials/detect.ts +229 -97
- package/lib/credentials/providers.ts +38 -7
- package/lib/credentials/vault.ts +78 -13
- package/lib/docker-exec.ts +50 -14
- package/lib/model-catalogue.ts +140 -27
- package/lib/rev4a-paths.ts +0 -21
- package/model-pricing.json +118 -110
- package/models.config.json +27 -12
- package/package.json +1 -1
- package/app/api/gateway/route.ts +0 -191
package/lib/docker-exec.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* thread does nothing else — no other request is served, no agent's stream
|
|
7
7
|
* advances. Node's own guidance names `execSync` in a server as a
|
|
8
8
|
* denial-of-service vector, and the numbers here bear that out: a bare
|
|
9
|
-
* `docker exec … echo` costs ~
|
|
9
|
+
* `docker exec … echo` costs ~90ms warm and ~225ms on the first call after an
|
|
10
|
+
* idle period, and the OpenClaw CLI inside a container
|
|
10
11
|
* takes 2.5–6s per invocation because it boots a whole Node process.
|
|
11
12
|
*
|
|
12
13
|
* Routes that walk every agent multiplied that: `/api/crons` measured 19.2s on a
|
|
@@ -30,7 +31,12 @@ const DEFAULT_MAX_BUFFER = 1024 * 1024;
|
|
|
30
31
|
|
|
31
32
|
export interface DockerExecOptions {
|
|
32
33
|
timeoutMs?: number;
|
|
33
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Extra environment for the command. Only the *names* reach argv, as `-e KEY`;
|
|
36
|
+
* docker then forwards each value from its own environment. A value passed as
|
|
37
|
+
* `-e KEY=VALUE` would sit in the process table for anyone on the host to read
|
|
38
|
+
* with `ps`, and in the text of any rejection Node builds from that argv.
|
|
39
|
+
*/
|
|
34
40
|
env?: Record<string, string>;
|
|
35
41
|
maxBuffer?: number;
|
|
36
42
|
signal?: AbortSignal;
|
|
@@ -38,11 +44,32 @@ export interface DockerExecOptions {
|
|
|
38
44
|
|
|
39
45
|
function baseArgs(container: string, opts?: DockerExecOptions): string[] {
|
|
40
46
|
const args = ['exec'];
|
|
41
|
-
for (const
|
|
47
|
+
for (const k of Object.keys(opts?.env ?? {})) args.push('-e', k);
|
|
42
48
|
args.push(container);
|
|
43
49
|
return args;
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Rebuild a failure without the command that produced it.
|
|
54
|
+
*
|
|
55
|
+
* Node composes its own message as `Command failed: <argv…>\n<stderr>`, and the
|
|
56
|
+
* argv holds whatever the caller put in the script. For credential delivery that
|
|
57
|
+
* is the secret — base64 is encoding, not protection — and the message travels:
|
|
58
|
+
* `syncProfileToAgents` puts it in `SyncResult.error`, which the sync route
|
|
59
|
+
* returns to the browser. The most common failure of all, a revoked or expired
|
|
60
|
+
* token, is exactly the one that triggers it.
|
|
61
|
+
*
|
|
62
|
+
* `stderr` is kept because that is the part that actually explains the failure.
|
|
63
|
+
*/
|
|
64
|
+
function execFailure(e: unknown, container: string): Error {
|
|
65
|
+
const err = e as { stderr?: string; code?: number; killed?: boolean; name?: string };
|
|
66
|
+
// An abort is the caller's own doing and is matched on elsewhere by name.
|
|
67
|
+
if (err.name === 'AbortError') return e as Error;
|
|
68
|
+
const cause = err.killed ? 'timed out' : err.code !== undefined ? `exit ${err.code}` : 'failed';
|
|
69
|
+
const detail = (err.stderr ?? '').trim();
|
|
70
|
+
return new Error(`docker exec ${container}: ${cause}${detail ? ` — ${detail}` : ''}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
/**
|
|
47
74
|
* Run a command inside a container, passing argv directly.
|
|
48
75
|
*
|
|
@@ -59,17 +86,22 @@ export async function dockerExec(
|
|
|
59
86
|
): Promise<string> {
|
|
60
87
|
if (!resolveDockerSocket()) throw new Error('Docker is not available');
|
|
61
88
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
89
|
+
try {
|
|
90
|
+
const { stdout } = await execFileAsync(
|
|
91
|
+
'docker',
|
|
92
|
+
[...baseArgs(container, opts), ...argv],
|
|
93
|
+
{
|
|
94
|
+
encoding: 'utf-8',
|
|
95
|
+
timeout: opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
96
|
+
maxBuffer: opts?.maxBuffer ?? DEFAULT_MAX_BUFFER,
|
|
97
|
+
signal: opts?.signal,
|
|
98
|
+
...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
return stdout;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
throw execFailure(e, container);
|
|
104
|
+
}
|
|
73
105
|
}
|
|
74
106
|
|
|
75
107
|
/**
|
|
@@ -132,6 +164,10 @@ export function dockerExecWithInput(
|
|
|
132
164
|
return new Promise((resolve, reject) => {
|
|
133
165
|
const child = spawn('docker', ['exec', '-i', ...baseArgs(container, opts).slice(1), ...argv], {
|
|
134
166
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
167
|
+
// `baseArgs` names the variables but carries no values, so they have to
|
|
168
|
+
// reach docker through its own environment — without this the container
|
|
169
|
+
// would be handed empty variables and never say why.
|
|
170
|
+
...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
|
|
135
171
|
});
|
|
136
172
|
|
|
137
173
|
let stdout = '';
|
package/lib/model-catalogue.ts
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* Model catalogue — bundle-first + user-overrides.
|
|
3
3
|
*
|
|
4
4
|
* Reads the bundled models.config.json from the npm package and applies
|
|
5
|
-
* user toggles from REV4A_DATA/model-overrides.json.
|
|
6
|
-
*
|
|
7
|
-
* Imported by both provider/route.ts and gateway/sync.ts — shared logic,
|
|
8
|
-
* avoids circular dependencies.
|
|
5
|
+
* user toggles from REV4A_DATA/model-overrides.json. The only module that reads
|
|
6
|
+
* either file: routes, pages and the sync all go through it.
|
|
9
7
|
*/
|
|
10
8
|
import * as fs from 'fs';
|
|
11
9
|
import * as path from 'path';
|
|
12
10
|
import { REV4A_DATA } from '@/lib/rev4a-paths';
|
|
11
|
+
import { readProviderKeys } from '@/app/api/gateway/provider/keys';
|
|
13
12
|
|
|
14
13
|
/* ------------------------------------------------------------------ */
|
|
15
14
|
/* Types */
|
|
@@ -21,6 +20,8 @@ export interface ModelConfigEntry {
|
|
|
21
20
|
provider: string;
|
|
22
21
|
enabled: boolean;
|
|
23
22
|
modality?: string;
|
|
23
|
+
/** Upstream has retired this id. It still answers, but is redirected. */
|
|
24
|
+
deprecated?: boolean;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/* ------------------------------------------------------------------ */
|
|
@@ -45,21 +46,61 @@ function resolveBundledPath(): string {
|
|
|
45
46
|
/* Loaders */
|
|
46
47
|
/* ------------------------------------------------------------------ */
|
|
47
48
|
|
|
49
|
+
/** Last catalogue read successfully by this process. */
|
|
50
|
+
let lastGoodBundle: ModelConfigEntry[] | null = null;
|
|
51
|
+
|
|
52
|
+
/** How the most recent read of models.config.json went. */
|
|
53
|
+
let bundleStatus: 'ok' | 'stale' | 'unavailable' = 'ok';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where the last catalogue read left us:
|
|
57
|
+
* - `ok` the file was read and parsed
|
|
58
|
+
* - `stale` the read failed; this process's last good copy was used
|
|
59
|
+
* - `unavailable` the read failed with no earlier copy, so the catalogue is empty
|
|
60
|
+
*/
|
|
61
|
+
export function bundledCatalogueStatus(): 'ok' | 'stale' | 'unavailable' {
|
|
62
|
+
return bundleStatus;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The bundled catalogue.
|
|
67
|
+
*
|
|
68
|
+
* A failed read must not look like an empty catalogue. It used to: this returned
|
|
69
|
+
* `[]`, the loader then treated every user override as an orphan and deleted them
|
|
70
|
+
* all from disk, the sync pushed an empty model list into every agent, and the
|
|
71
|
+
* proxy refused every request. One unlucky read — the pricing script rewriting the
|
|
72
|
+
* file in place, a malformed release — was enough. A failed read now falls back to
|
|
73
|
+
* the last good copy and reports it through `bundledCatalogueStatus()`.
|
|
74
|
+
*/
|
|
48
75
|
function loadBundledModels(): ModelConfigEntry[] {
|
|
49
76
|
try {
|
|
50
77
|
const raw = fs.readFileSync(resolveBundledPath(), 'utf-8');
|
|
51
78
|
const parsed = JSON.parse(raw);
|
|
52
|
-
if (Array.isArray(parsed.models)) {
|
|
53
|
-
|
|
54
|
-
id: String(m.id ?? ''),
|
|
55
|
-
name: String(m.name ?? ''),
|
|
56
|
-
provider: String(m.provider ?? ''),
|
|
57
|
-
enabled: Boolean(m.enabled),
|
|
58
|
-
modality: typeof m.modality === 'string' ? m.modality : undefined,
|
|
59
|
-
}));
|
|
79
|
+
if (!Array.isArray(parsed.models) || parsed.models.length === 0) {
|
|
80
|
+
throw new Error('no models array');
|
|
60
81
|
}
|
|
61
|
-
|
|
62
|
-
|
|
82
|
+
const models: ModelConfigEntry[] = parsed.models.map((m: Record<string, unknown>) => ({
|
|
83
|
+
id: String(m.id ?? ''),
|
|
84
|
+
name: String(m.name ?? ''),
|
|
85
|
+
provider: String(m.provider ?? ''),
|
|
86
|
+
enabled: Boolean(m.enabled),
|
|
87
|
+
modality: typeof m.modality === 'string' ? m.modality : undefined,
|
|
88
|
+
deprecated: m.deprecated === true ? true : undefined,
|
|
89
|
+
}));
|
|
90
|
+
lastGoodBundle = models;
|
|
91
|
+
bundleStatus = 'ok';
|
|
92
|
+
return models;
|
|
93
|
+
} catch (e) {
|
|
94
|
+
const why = (e as Error).message;
|
|
95
|
+
if (lastGoodBundle) {
|
|
96
|
+
if (bundleStatus !== 'stale') console.warn(`[model-catalogue] models.config.json unreadable, using the last good copy: ${why}`);
|
|
97
|
+
bundleStatus = 'stale';
|
|
98
|
+
return lastGoodBundle;
|
|
99
|
+
}
|
|
100
|
+
if (bundleStatus !== 'unavailable') console.error(`[model-catalogue] models.config.json unreadable and no earlier copy: ${why}`);
|
|
101
|
+
bundleStatus = 'unavailable';
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
63
104
|
}
|
|
64
105
|
|
|
65
106
|
export function loadOverrides(): Record<string, boolean> {
|
|
@@ -90,23 +131,14 @@ export function writeOverrides(overrides: Record<string, boolean>): void {
|
|
|
90
131
|
* 1. Read models.config.json from the npm package (bundle).
|
|
91
132
|
* 2. Apply user overrides (enabled/disabled toggles).
|
|
92
133
|
*
|
|
93
|
-
*
|
|
134
|
+
* Read-only. Overrides for ids the bundle no longer has are ignored here and
|
|
135
|
+
* pruned only by `toggleModelOverride`, against a fresh read: this runs on every
|
|
136
|
+
* proxy request, and a read path that deletes user settings is one failed read
|
|
137
|
+
* away from deleting all of them.
|
|
94
138
|
*/
|
|
95
139
|
export function loadModelsConfig(): ModelConfigEntry[] {
|
|
96
140
|
const bundled = loadBundledModels();
|
|
97
141
|
const overrides = loadOverrides();
|
|
98
|
-
|
|
99
|
-
// Clean up orphan overrides (models removed from bundle)
|
|
100
|
-
const bundledIds = new Set(bundled.map((m) => m.id));
|
|
101
|
-
let dirty = false;
|
|
102
|
-
for (const id of Object.keys(overrides)) {
|
|
103
|
-
if (!bundledIds.has(id)) {
|
|
104
|
-
delete overrides[id];
|
|
105
|
-
dirty = true;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (dirty) writeOverrides(overrides);
|
|
109
|
-
|
|
110
142
|
return bundled.map((m) => ({
|
|
111
143
|
...m,
|
|
112
144
|
enabled: Object.prototype.hasOwnProperty.call(overrides, m.id) ? overrides[m.id] : m.enabled,
|
|
@@ -126,6 +158,87 @@ export function toggleModelOverride(modelId: string, enabled: boolean): boolean
|
|
|
126
158
|
} else {
|
|
127
159
|
overrides[modelId] = enabled;
|
|
128
160
|
}
|
|
161
|
+
|
|
162
|
+
// Drop overrides for models the catalogue no longer has — but only against a
|
|
163
|
+
// catalogue read just now. Against a stale or missing copy every override
|
|
164
|
+
// would look orphaned.
|
|
165
|
+
if (bundledCatalogueStatus() === 'ok') {
|
|
166
|
+
const ids = new Set(bundled.map((m) => m.id));
|
|
167
|
+
for (const id of Object.keys(overrides)) if (!ids.has(id)) delete overrides[id];
|
|
168
|
+
}
|
|
169
|
+
|
|
129
170
|
writeOverrides(overrides);
|
|
130
171
|
return true;
|
|
131
172
|
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The models this deployment actually offers.
|
|
176
|
+
*
|
|
177
|
+
* Two conditions, and they are separate questions:
|
|
178
|
+
*
|
|
179
|
+
* - **enabled** — policy. What the operator wants offered, `models.config.json`
|
|
180
|
+
* as the default and `model-overrides.json` as their decision on top.
|
|
181
|
+
* - **provider has a key** — availability. Offering a model nobody can be billed
|
|
182
|
+
* for produces a runtime failure at the worst moment.
|
|
183
|
+
*
|
|
184
|
+
* This exists because four readers each answered it differently, and the answers
|
|
185
|
+
* mattered. Measured on the production host, which has keys for four providers
|
|
186
|
+
* and eight overrides that all diverge from the bundled default: the create
|
|
187
|
+
* wizard listed 58 models where the correct answer was 62, offering two the
|
|
188
|
+
* operator had switched off and hiding six they had switched on. It read the
|
|
189
|
+
* bundle directly and never opened the overrides file. Meanwhile
|
|
190
|
+
* `/api/provider/v1/models` applied the overrides but not the key filter, so it
|
|
191
|
+
* advertised models for providers with no credentials.
|
|
192
|
+
*
|
|
193
|
+
* Callers still shape the result themselves — the sync adds `input` modalities,
|
|
194
|
+
* the pickers add prices — but the *set* is decided here, once.
|
|
195
|
+
*/
|
|
196
|
+
export function loadOfferedModels(): ModelConfigEntry[] {
|
|
197
|
+
const configured = new Set(Object.keys(readProviderKeys()));
|
|
198
|
+
return loadModelsConfig().filter((m) => {
|
|
199
|
+
if (!m.enabled) return false;
|
|
200
|
+
if (configured.has(m.provider)) return true;
|
|
201
|
+
// A `rev4a/*` alias bills through DeepSeek upstream, so a DeepSeek key is
|
|
202
|
+
// enough to offer one. The catalogue currently defines none.
|
|
203
|
+
if (m.provider === 'rev4a' && configured.has('deepseek')) return true;
|
|
204
|
+
return false;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* May this deployment serve a request for this model?
|
|
210
|
+
*
|
|
211
|
+
* `loadOfferedModels` answers what to *show*; this answers what to *honour*. They
|
|
212
|
+
* were the same question asked in two places and only the first was ever asked:
|
|
213
|
+
* the proxy and the assistant took whatever model the caller named and billed it.
|
|
214
|
+
* Unchecking a model on the Gateway page removed it from every picker and stopped
|
|
215
|
+
* nothing, because a client that already held the id — a stale in-memory list, a
|
|
216
|
+
* saved preference, an agent configured before the change — kept working.
|
|
217
|
+
*
|
|
218
|
+
* Takes the *resolved* pair, not the raw string. Callers reach it through
|
|
219
|
+
* `extractProviderAndModel`, which collapses `rev4a/deepseek/deepseek-flash`,
|
|
220
|
+
* `deepseek/deepseek-flash` and the two-segment alias `rev4a/deepseek-flash` onto
|
|
221
|
+
* one provider and model, so all three are tested as the same catalogue id.
|
|
222
|
+
*/
|
|
223
|
+
export function isModelOffered(provider: string, model: string): boolean {
|
|
224
|
+
const id = `${provider}/${model}`;
|
|
225
|
+
return loadOfferedModels().some((m) => m.id === id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Where a model id stands against the catalogue, for explaining a failure.
|
|
230
|
+
*
|
|
231
|
+
* - `offered` — enabled and its provider has a key; the proxy serves it
|
|
232
|
+
* - `disabled` — in the catalogue but not offered: unchecked, or no key
|
|
233
|
+
* - `missing` — not in the catalogue at all, e.g. removed in a release
|
|
234
|
+
*
|
|
235
|
+
* Takes a bare id, without the `rev4a/` prefix. The Model panel used to infer the
|
|
236
|
+
* cause from the offered list alone, so a model removed from the catalogue was
|
|
237
|
+
* explained as "unchecked, or its provider has no key" while the card said
|
|
238
|
+
* `NOT IN CATALOGUE`. `models-summary` computes the same two sets once for the
|
|
239
|
+
* whole fleet instead of calling this per agent.
|
|
240
|
+
*/
|
|
241
|
+
export function catalogueStatus(id: string): 'offered' | 'disabled' | 'missing' {
|
|
242
|
+
if (!loadModelsConfig().some((m) => m.id === id)) return 'missing';
|
|
243
|
+
return loadOfferedModels().some((m) => m.id === id) ? 'offered' : 'disabled';
|
|
244
|
+
}
|
package/lib/rev4a-paths.ts
CHANGED
|
@@ -58,27 +58,6 @@ export const SHARED_SKILLS_DIR = path.join(REV4A_DATA, 'shared', 'shared-skills'
|
|
|
58
58
|
/** Rev4a system rules injected into every agent workspace, mounted read-only. */
|
|
59
59
|
export const REV4A_RULES_DIR = path.join(REV4A_DATA, 'shared', 'rev4a-rules');
|
|
60
60
|
|
|
61
|
-
/**
|
|
62
|
-
* Model catalogue — read-only, shipped with the package.
|
|
63
|
-
* Resolved at module load from the package root (alongside bin/rev4a.js).
|
|
64
|
-
*/
|
|
65
|
-
function resolvePackageFile(filename: string): string {
|
|
66
|
-
// cwd in dev (workspace checkout)
|
|
67
|
-
const cwdPath = path.join(process.cwd(), filename);
|
|
68
|
-
if (fs.existsSync(cwdPath)) return cwdPath;
|
|
69
|
-
|
|
70
|
-
// Installed package: this file is at <pkgRoot>/lib/rev4a-paths.js
|
|
71
|
-
const pkgRoot = path.resolve(__dirname, '..');
|
|
72
|
-
const pkgPath = path.join(pkgRoot, filename);
|
|
73
|
-
if (fs.existsSync(pkgPath)) return pkgPath;
|
|
74
|
-
|
|
75
|
-
// Last resort: accept that it may not exist (will throw at read time)
|
|
76
|
-
return cwdPath;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Static model catalogue — read-only, inside the package repo */
|
|
80
|
-
export const MODELS_CONFIG_FILE = resolvePackageFile('models.config.json');
|
|
81
|
-
|
|
82
61
|
/** Top-level .next build directory (kept inside npm package for now — built by rev4a serve) */
|
|
83
62
|
export function nextDir(pkgRoot: string): string {
|
|
84
63
|
return path.join(pkgRoot, '.next');
|