@wenathlan/getry 2.0.154 → 2.0.158

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.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * cli — the scaffolding cli under real subprocess execution (the getry copy
3
+ * of the devthink lane): every command runs as a child process exactly like
4
+ * a consumer types it: init with piped answers, list show validate models
5
+ * keys export help. the runner spawns the owner face directly (tsx cli.ts)
6
+ * — the devthink entry keeps answering the same family through its compat
7
+ * shim, and that lane stays covered by the devthink battery.
8
+ */
9
+
10
+ import { execFile } from "node:child_process";
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { promisify } from "node:util";
15
+ import { afterAll, describe, expect, it } from "vitest";
16
+
17
+ const exec = promisify(execFile);
18
+
19
+ /** the tests village sits inside the getry app folder, so the cli runner
20
+ * and its tsx binary resolve through the app root one hop up (the owner
21
+ * face answers from its own root the same way). */
22
+ const tsxbin = join(import.meta.dirname, "..", "node_modules", ".bin", "tsx");
23
+ const clits = join(import.meta.dirname, "..", "cli.ts");
24
+ /** the tsx subprocess resolves the library specifiers through the explicit
25
+ * application tsconfig (the runner cwd sits in a scaffold workdir, and the
26
+ * tsconfig discovery that walks from the cwd would never find the app
27
+ * paths — the flag pins the resolution to the owner tree). */
28
+ const tsconfigpath = join(import.meta.dirname, "..", "tsconfig.json");
29
+
30
+ /** run the cli in a workdir with optional piped stdin */
31
+ async function runcli(
32
+ workdir: string,
33
+ args: string[],
34
+ input = "",
35
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
36
+ try {
37
+ const { stdout, stderr } = await exec(tsxbin, ["--tsconfig", tsconfigpath, clits, ...args], {
38
+ cwd: workdir,
39
+ env: { ...process.env, DEVTHINK_DATABASE_URL: `file:${join(workdir, "cli.db")}` },
40
+ maxBuffer: 10 * 1024 * 1024,
41
+ });
42
+ if (input) {
43
+ // promisified execFile does not feed stdin — handled by the caller via runcliwithinput
44
+ }
45
+ return { stdout, stderr, code: 0 };
46
+ } catch (err) {
47
+ const e = err as { stdout?: string; stderr?: string; code?: number };
48
+ return { stdout: e.stdout ?? "", stderr: e.stderr ?? "", code: e.code ?? 1 };
49
+ }
50
+ }
51
+
52
+ /** run the cli feeding piped stdin answers (the automation consumer flow) */
53
+ async function runcliwithinput(
54
+ workdir: string,
55
+ args: string[],
56
+ input: string,
57
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
58
+ return new Promise((resolve) => {
59
+ const child = execFile(tsxbin, ["--tsconfig", tsconfigpath, clits, ...args], {
60
+ cwd: workdir,
61
+ env: { ...process.env, DEVTHINK_DATABASE_URL: `file:${join(workdir, "cli.db")}` },
62
+ maxBuffer: 10 * 1024 * 1024,
63
+ });
64
+ child.stdin?.write(input);
65
+ child.stdin?.end();
66
+ let stdout = "";
67
+ let stderr = "";
68
+ child.stdout?.on("data", (d: Buffer) => {
69
+ stdout += d.toString();
70
+ });
71
+ child.stderr?.on("data", (d: Buffer) => {
72
+ stderr += d.toString();
73
+ });
74
+ child.on("close", (code) => resolve({ stdout, stderr, code: code ?? 0 }));
75
+ });
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // workspace — one temp dir hosts the whole consumer journey
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const workdir = mkdtempSync(join(tmpdir(), "gateway-cli-"));
83
+
84
+ afterAll(() => {
85
+ rmSync(workdir, { recursive: true, force: true });
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // help and unknown commands
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe("cli help", () => {
93
+ it("prints the usage banner with every command", async () => {
94
+ const { stdout, code } = await runcli(workdir, ["help"]);
95
+ expect(code).toBe(0);
96
+ expect(stdout).toContain("@wenathlan/devthink gateway");
97
+ expect(stdout).toContain("Usage: devthink gateway <command>");
98
+ for (const cmd of ["init", "add", "list", "show", "validate", "keys", "models", "serve", "export"]) {
99
+ expect(stdout).toContain(cmd);
100
+ }
101
+ });
102
+
103
+ it("bare invocation prints help", async () => {
104
+ const { stdout, code } = await runcli(workdir, []);
105
+ expect(code).toBe(0);
106
+ expect(stdout).toContain("Usage: devthink gateway <command>");
107
+ });
108
+
109
+ it("unknown commands fail with the usage", async () => {
110
+ const { stdout, code } = await runcli(workdir, ["definitely-not-a-command"]);
111
+ expect(code).toBe(1);
112
+ expect(stdout).toContain("unknown command");
113
+ });
114
+ });
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // init — the piped automation consumer flow
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("cli init", () => {
121
+ it("scaffolds the full project from piped default answers", async () => {
122
+ // 30 blank lines cover every question — each falls back to its default
123
+ const { stdout, code } = await runcliwithinput(workdir, ["init"], "\n".repeat(30));
124
+ expect(code).toBe(0);
125
+ expect(stdout).toContain("init");
126
+ expect(existsSync(join(workdir, "config.mjs"))).toBe(true);
127
+ expect(existsSync(join(workdir, "schema.prisma"))).toBe(true);
128
+ expect(existsSync(join(workdir, "prismaconfig.ts"))).toBe(true);
129
+ expect(existsSync(join(workdir, ".env.example"))).toBe(true);
130
+ expect(stdout).toContain("config.mjs");
131
+ expect(stdout).toContain("schema.prisma");
132
+ expect(stdout).toContain("prismaconfig.ts");
133
+ });
134
+
135
+ it("the scaffold is .mjs — loadable by plain node in a typeless package", () => {
136
+ const cfg = readFileSync(join(workdir, "config.mjs"), "utf8");
137
+ expect(cfg).toContain("export const config");
138
+ });
139
+
140
+ it("the generated config carries the version and meta model", () => {
141
+ const cfg = readFileSync(join(workdir, "config.mjs"), "utf8");
142
+ expect(cfg).toContain("v1");
143
+ expect(cfg).toContain("devthink");
144
+ expect(cfg).toContain("custom");
145
+ expect(cfg).toContain("rotation");
146
+ expect(cfg).toContain("bearer");
147
+ });
148
+
149
+ it("the generated schema defines the chat message and key models", () => {
150
+ const schema = readFileSync(join(workdir, "schema.prisma"), "utf8");
151
+ expect(schema).toContain("model ChatMessage");
152
+ expect(schema).toContain("model ApiKey");
153
+ expect(schema).toContain("model NvidiaKey");
154
+ expect(schema).toContain("datasource db");
155
+ });
156
+
157
+ it("the prisma config resolves the schema and datasource url", () => {
158
+ const prismaconfig = readFileSync(join(workdir, "prismaconfig.ts"), "utf8");
159
+ expect(prismaconfig).toContain("schema.prisma");
160
+ expect(prismaconfig).toContain("DEVTHINK_DATABASE_URL");
161
+ });
162
+
163
+ it("the env example lists the derived env var", () => {
164
+ const env = readFileSync(join(workdir, ".env.example"), "utf8");
165
+ expect(env).toContain("CUSTOM_API_KEY=");
166
+ });
167
+
168
+ it("init refuses to overwrite without a yes answer", async () => {
169
+ const before = readFileSync(join(workdir, "config.mjs"), "utf8");
170
+ const { stdout, code } = await runcliwithinput(workdir, ["init"], "n\n");
171
+ expect(code).toBe(0);
172
+ expect(stdout).toContain("keeping existing config");
173
+ expect(readFileSync(join(workdir, "config.mjs"), "utf8")).toBe(before);
174
+ });
175
+ });
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // list show validate models — reading the scaffolded config
179
+ // ---------------------------------------------------------------------------
180
+
181
+ describe("cli list show validate models", () => {
182
+ it("list shows the version summary", async () => {
183
+ const { stdout, code } = await runcli(workdir, ["list"]);
184
+ expect(code).toBe(0);
185
+ expect(stdout).toContain("1 versions");
186
+ expect(stdout).toContain("v1");
187
+ expect(stdout).toContain("custom");
188
+ });
189
+
190
+ it("show prints one version detail", async () => {
191
+ const { stdout, code } = await runcli(workdir, ["show", "v1"]);
192
+ expect(code).toBe(0);
193
+ expect(stdout).toContain("providername");
194
+ expect(stdout).toContain("upstreams");
195
+ });
196
+
197
+ it("show of an unknown version fails", async () => {
198
+ const { code } = await runcli(workdir, ["show", "nope"]);
199
+ expect(code).toBe(1);
200
+ });
201
+
202
+ it("validate reports the scaffolded config valid", async () => {
203
+ const { stdout, code } = await runcli(workdir, ["validate"]);
204
+ expect(code).toBe(0);
205
+ expect(stdout).toContain("config is valid");
206
+ });
207
+
208
+ it("models lists the catalog and rotation pool", async () => {
209
+ const { stdout, code } = await runcli(workdir, ["models", "v1"]);
210
+ expect(code).toBe(0);
211
+ expect(stdout).toContain("models for v1");
212
+ expect(stdout).toContain("default-model");
213
+ expect(stdout).toContain("rotation pool");
214
+ });
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // keys — env instructions and the db seed script
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe("cli keys", () => {
222
+ it("without a db model it documents the env var", async () => {
223
+ const { stdout, code } = await runcli(workdir, ["keys", "v1"]);
224
+ expect(code).toBe(0);
225
+ expect(stdout).toContain("CUSTOM_API_KEY");
226
+ expect(stdout).toContain("comma separated");
227
+ });
228
+
229
+ it("keys of an unknown version fails", async () => {
230
+ const { code } = await runcli(workdir, ["keys", "nope"]);
231
+ expect(code).toBe(1);
232
+ });
233
+ });
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // export — the deployable scaffold
237
+ // ---------------------------------------------------------------------------
238
+
239
+ describe("cli export", () => {
240
+ it("exports the config and schema for deploy", async () => {
241
+ const { stdout, code } = await runcli(workdir, ["export", "--dir", "export"]);
242
+ expect(code).toBe(0);
243
+ expect(stdout).toContain("exported config.mjs");
244
+ expect(stdout).toContain("exported schema.prisma");
245
+ expect(existsSync(join(workdir, "export", "config.mjs"))).toBe(true);
246
+ expect(existsSync(join(workdir, "export", "schema.prisma"))).toBe(true);
247
+ });
248
+ });
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // db-backed keys — a second workspace with db key storage enabled
252
+ // ---------------------------------------------------------------------------
253
+
254
+ describe("cli init with db keys", () => {
255
+ const dbworkdir = mkdtempSync(join(tmpdir(), "gateway-cli-db-"));
256
+
257
+ it("scaffolds with database key storage and generates the seed script", async () => {
258
+ const answers = [
259
+ "1", // versions
260
+ "", // id → v1
261
+ "", // provider → custom
262
+ "", // baseurl → default
263
+ "", // auth → bearer
264
+ "", // required → default true
265
+ "", // envvar → CUSTOM_API_KEY
266
+ "y", // store keys in database
267
+ "apiKey", // db model
268
+ "1", // one model
269
+ "", // model id default
270
+ "", // context
271
+ "", // maxoutput
272
+ "n", // no rotation (single model)
273
+ "y", // retry
274
+ "", // max retries
275
+ "", // backoff base
276
+ "", // backoff cap
277
+ "n", // no shared context
278
+ "", // meta id → devthink
279
+ "", // gateway name
280
+ "", // description
281
+ ].join("\n");
282
+ const { code } = await runcliwithinput(dbworkdir, ["init"], `${answers}\n`);
283
+ expect(code).toBe(0);
284
+
285
+ const { stdout, code: keyscode } = await runcliwithinput(
286
+ dbworkdir,
287
+ ["keys", "v1"],
288
+ "2\nkey-aaaaaaaaaa\nkey-bbbbbbbbbb\n",
289
+ );
290
+ expect(keyscode).toBe(0);
291
+ expect(stdout).toContain("register keys");
292
+ const seed = join(dbworkdir, "tests", "seedkeys.mjs");
293
+ expect(existsSync(seed)).toBe(true);
294
+ const content = readFileSync(seed, "utf8");
295
+ expect(content).toContain("key-aaaaaaaaaa");
296
+ expect(content).toContain("key-bbbbbbbbbb");
297
+ expect(content).toContain("apiKey");
298
+ rmSync(dbworkdir, { recursive: true, force: true });
299
+ });
300
+ });
@@ -1,8 +1,12 @@
1
1
  // # debonairlane.test — the debonair api lane of the family gateway (the
2
- // mount adapter contract): the thirteen-route catalog mirrors the debonair
3
- // gateway table in the family shape (first match wins, the params capture,
4
- // an empty capture never matches, the provider lanes answer absent), the
5
- // adapter translates the getry mount shape (the web request) into the
2
+ // mount adapter contract): the nineteen-route catalog imports the debonair
3
+ // gateway table from the owner's leaf module (the published
4
+ // "@wenathlan/debonair/routetable" specifier bridged to the monorepo
5
+ // source — the mirror era is over) in the family shape (first match wins,
6
+ // the params capture, an empty capture never matches, the provider lanes
7
+ // answer absent, the render, the recon and the tracks lanes the mirror
8
+ // never saw now ride, the generate lane answers its real handler names),
9
+ // the adapter translates the getry mount shape (the web request) into the
6
10
  // debonair gateway request (the json lane, the raw lane, the query record)
7
11
  // and the debonair answer back into the web response (the json body, the
8
12
  // blob stream with the digest headers, the standardized error shape, the
@@ -15,7 +19,6 @@ import assert from "node:assert/strict";
15
19
  import { describe, it } from "vitest";
16
20
  import {
17
21
  DEBONAIR_URL_ENV,
18
- DEBONAIRROUTES,
19
22
  type DebonairDispatch,
20
23
  type DebonairGatewayAnswer,
21
24
  type DebonairGatewayRequest,
@@ -25,8 +28,9 @@ import {
25
28
  debonairrequest,
26
29
  debonairresponse,
27
30
  debonairserve,
28
- matchdebonairpath,
29
- matchdebonairroute,
31
+ matchpath,
32
+ matchroute,
33
+ routes,
30
34
  } from "../debonairlane.ts";
31
35
 
32
36
  /** the debonair origin the wire tests ride (a public https host — the law). */
@@ -64,41 +68,82 @@ function valueseat(value: (request: DebonairGatewayRequest) => unknown): {
64
68
  return engineseat((request) => ({ ok: true, status: 200, value: value(request) }));
65
69
  }
66
70
 
67
- describe("the debonair route catalog (the published contract mirror)", () => {
68
- it("carries the thirteen debonair routes in the family shape", () => {
69
- assert.equal(DEBONAIRROUTES.length, 13);
70
- assert.deepEqual(DEBONAIRROUTES[0], { method: "POST", path: "/api/v1/ingest/upload", handler: "uploadIngest" });
71
- assert.deepEqual(DEBONAIRROUTES[5], { method: "GET", path: "/api/v1/blob/:id", handler: "getBlob" });
72
- assert.deepEqual(DEBONAIRROUTES[12], { method: "GET", path: "/api/v1/vitrine", handler: "listVitrine" });
73
- for (const route of DEBONAIRROUTES) {
71
+ describe("the debonair route catalog (the owner's table imported through the leaf)", () => {
72
+ it("carries the nineteen debonair routes in the family shape (the real table, the real handler names)", () => {
73
+ assert.equal(routes.length, 19);
74
+ assert.deepEqual(routes[0], { method: "POST", path: "/api/v1/ingest/upload", handler: "uploadIngest" });
75
+ assert.deepEqual(routes[5], { method: "GET", path: "/api/v1/blob/:id", handler: "getBlob" });
76
+ assert.deepEqual(routes[9], { method: "POST", path: "/api/v1/render", handler: "queueRender" });
77
+ assert.deepEqual(routes[11], { method: "POST", path: "/api/v1/recon", handler: "queueRecon" });
78
+ assert.deepEqual(routes[13], { method: "POST", path: "/api/v1/tracks", handler: "publishTrack" });
79
+ assert.deepEqual(routes[14], { method: "GET", path: "/api/v1/tracks", handler: "listTracks" });
80
+ assert.deepEqual(
81
+ routes[15],
82
+ { method: "POST", path: "/api/v1/generate/render", handler: "queueGenerateRender" },
83
+ "the generate lane answers its real handler name (the mirror said queueRender)",
84
+ );
85
+ assert.deepEqual(
86
+ routes[16],
87
+ { method: "GET", path: "/api/v1/generate/jobs/:id", handler: "getGenerateJob" },
88
+ "the generate job poll answers its real handler name (the mirror said getRenderJob)",
89
+ );
90
+ assert.deepEqual(
91
+ routes[17],
92
+ { method: "POST", path: "/api/v1/tracks/:id/publish", handler: "publishTrackById" },
93
+ "the publish by id answers its real handler name (the mirror said publishTrack)",
94
+ );
95
+ assert.deepEqual(routes[18], { method: "GET", path: "/api/v1/vitrine", handler: "listVitrine" });
96
+ for (const route of routes) {
74
97
  assert.ok(route.path.startsWith("/api/v1/"), `${route.path} rides the api prefix`);
75
98
  assert.ok(["GET", "POST", "DELETE"].includes(route.method));
76
99
  }
77
100
  });
78
101
 
79
102
  it("matches the params, refuses the empty capture and answers the method verdict", () => {
80
- const routed = matchdebonairroute("GET", "/api/v1/ingest/jobs/123");
103
+ const routed = matchroute("GET", "/api/v1/ingest/jobs/123");
81
104
  assert.ok(routed.kind === "routed");
82
105
  assert.equal(routed.route.handler, "getIngestJob");
83
106
  assert.deepEqual(routed.params, { id: "123" });
84
107
  // the empty capture never matches — a route param always carries a value
85
- assert.equal(matchdebonairpath("/api/v1/blob/:id", "/api/v1/blob/"), null);
108
+ assert.equal(matchpath("/api/v1/blob/:id", "/api/v1/blob/"), null);
86
109
  // the specific rows ride first: the jobs row wins over the :id row
87
- const shelf = matchdebonairroute("GET", "/api/v1/ingest");
110
+ const shelf = matchroute("GET", "/api/v1/ingest");
88
111
  assert.ok(shelf.kind === "routed" && shelf.route.handler === "listIngestShelf");
89
112
  // the path matches, the method does not — the allowed list answers
90
- const method = matchdebonairroute("PUT", "/api/v1/vitrine");
113
+ const method = matchroute("PUT", "/api/v1/vitrine");
91
114
  assert.ok(method.kind === "method");
92
115
  assert.deepEqual(method.allowed, ["GET"]);
93
116
  // the provider lanes own their paths — the catalog answers absent
94
- const foreign = matchdebonairroute("POST", "/v1/chat/completions");
117
+ const foreign = matchroute("POST", "/v1/chat/completions");
95
118
  assert.deepEqual(foreign, { kind: "absent" });
96
119
  });
97
120
 
121
+ it("routes the render, the recon and the tracks lanes the mirror never saw", () => {
122
+ const render = matchroute("POST", "/api/v1/render");
123
+ assert.ok(render.kind === "routed" && render.route.handler === "queueRender");
124
+ const renderjob = matchroute("GET", "/api/v1/render/jobs/9");
125
+ assert.ok(renderjob.kind === "routed" && renderjob.route.handler === "getRenderJob");
126
+ const recon = matchroute("POST", "/api/v1/recon");
127
+ assert.ok(recon.kind === "routed" && recon.route.handler === "queueRecon");
128
+ const reconjob = matchroute("GET", "/api/v1/recon/jobs/9");
129
+ assert.ok(reconjob.kind === "routed" && reconjob.route.handler === "getReconJob");
130
+ const publish = matchroute("POST", "/api/v1/tracks");
131
+ assert.ok(publish.kind === "routed" && publish.route.handler === "publishTrack");
132
+ const tracks = matchroute("GET", "/api/v1/tracks");
133
+ assert.ok(tracks.kind === "routed" && tracks.route.handler === "listTracks");
134
+ // the generate lane's own spellings route under the real handler names
135
+ const generate = matchroute("POST", "/api/v1/generate/render");
136
+ assert.ok(generate.kind === "routed" && generate.route.handler === "queueGenerateRender");
137
+ const generatejob = matchroute("GET", "/api/v1/generate/jobs/9");
138
+ assert.ok(generatejob.kind === "routed" && generatejob.route.handler === "getGenerateJob");
139
+ const publishbyid = matchroute("POST", "/api/v1/tracks/9/publish");
140
+ assert.ok(publishbyid.kind === "routed" && publishbyid.route.handler === "publishTrackById");
141
+ });
142
+
98
143
  it("decodes the captured params (the vault safeDecode law)", () => {
99
- const params = matchdebonairpath("/api/v1/profiles/:genre", "/api/v1/profiles/drill%20beats");
144
+ const params = matchpath("/api/v1/profiles/:genre", "/api/v1/profiles/drill%20beats");
100
145
  assert.deepEqual(params, { genre: "drill beats" });
101
- const malformed = matchdebonairpath("/api/v1/profiles/:genre", "/api/v1/profiles/%zz");
146
+ const malformed = matchpath("/api/v1/profiles/:genre", "/api/v1/profiles/%zz");
102
147
  assert.deepEqual(malformed, { genre: "%zz" }, "a malformed escape keeps the raw text");
103
148
  });
104
149
  });
@@ -20,15 +20,15 @@ function read(path: string): string {
20
20
  return readFileSync(new URL(path, import.meta.url), "utf8");
21
21
  }
22
22
 
23
- const CSS = read("../Sol/sol.css");
23
+ const CSS = read("../sol/sol.css");
24
24
  const ENTRY = read("../index.html");
25
- const MARK = read("../Sol/wingets/ShellBrandmark.tsx");
26
- const SHELL = read("../Sol/shell/Shell.tsx");
27
- const SOL = read("../Sol/Sol.tsx");
28
- const HOME = read("../Sol/home/home.tsx");
29
- const GATE = read("../Sol/wingets/HomeEntrygate.tsx");
30
- const INTRO = read("../Sol/intro/intro.tsx");
31
- const ONBOARDING = read("../Sol/onboarding/onboarding.tsx");
25
+ const MARK = read("../sol/wingets/ShellBrandmark.tsx");
26
+ const SHELL = read("../sol/shell/Shell.tsx");
27
+ const SOL = read("../sol/Sol.tsx");
28
+ const HOME = read("../sol/home/home.tsx");
29
+ const GATE = read("../sol/wingets/HomeEntrygate.tsx");
30
+ const INTRO = read("../sol/intro/intro.tsx");
31
+ const ONBOARDING = read("../sol/onboarding/onboarding.tsx");
32
32
  const STATE = read("../onboardingstate.ts");
33
33
  const DB = read("../db.ts");
34
34
  const SCHEMA = read("../schema.prisma");
@@ -197,7 +197,7 @@ describe("flow contract — intro → onboarding → the platform", () => {
197
197
 
198
198
  it("gates the home anchor on the seen state before anything shows", () => {
199
199
  /* the widget surface law: the dotted widget name is written exactly
200
- once (inside Sol/wingets/index.ts) and the page answers the surface
200
+ once (inside sol/wingets/index.ts) and the page answers the surface
201
201
  import with its canonical alias — never the dotted path again */
202
202
  expect(HOME).toMatch(/import \{ HomeEntrygate as EntryGate \} from "\.\.\/wingets"/);
203
203
  expect(GATE).toContain('navigate("/intro", { replace: true })');