jevway 0.1.2 → 0.2.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.
Files changed (52) hide show
  1. package/INSTALL-FOR-AGENTS.md +32 -38
  2. package/README.md +193 -150
  3. package/dist/cli/daemon-call.js +78 -0
  4. package/dist/cli/dispatch.js +74 -0
  5. package/dist/cli/flags.js +152 -0
  6. package/dist/cli/help.js +46 -0
  7. package/dist/cli/journey-command.js +162 -0
  8. package/dist/cli/look-command.js +47 -0
  9. package/dist/cli/main.js +64 -0
  10. package/dist/cli/output.js +27 -0
  11. package/dist/cli/run-command.js +173 -0
  12. package/dist/cli/session-commands.js +132 -0
  13. package/dist/daemon/client.js +175 -0
  14. package/dist/daemon/paths.js +35 -0
  15. package/dist/daemon/protocol.js +28 -0
  16. package/dist/daemon/server.js +320 -0
  17. package/dist/jev/replay.js +7 -3
  18. package/dist/jev/retry.js +29 -0
  19. package/dist/journeys/journey-doc.js +2 -1
  20. package/dist/journeys/journey-session.js +14 -3
  21. package/dist/journeys/render-viewer.js +163 -150
  22. package/dist/mcp/instructions.js +18 -0
  23. package/dist/mcp/journey-tools.js +117 -0
  24. package/dist/mcp/results.js +53 -0
  25. package/dist/mcp/server.js +50 -0
  26. package/dist/mcp/task-tools.js +197 -0
  27. package/dist/mcp.js +10 -337
  28. package/dist/output/layout.js +50 -0
  29. package/dist/output/manifest.js +80 -0
  30. package/dist/output/run-folder.js +38 -0
  31. package/dist/recording/describe-step.js +66 -0
  32. package/dist/{task → recording}/recording-page.js +164 -18
  33. package/dist/{task → recording}/recording.js +33 -91
  34. package/dist/recording/run-page.js +155 -0
  35. package/dist/session/session-service.js +193 -0
  36. package/dist/setup/atomic-write.js +13 -0
  37. package/dist/setup/clients.js +180 -0
  38. package/dist/setup/command-output.js +15 -0
  39. package/dist/setup/doctor-command.js +16 -7
  40. package/dist/setup/doctor.js +17 -2
  41. package/dist/setup/env-file.js +22 -0
  42. package/dist/setup/setup-command.js +104 -0
  43. package/dist/setup/setup-wizard.js +216 -0
  44. package/dist/setup/skill-command.js +101 -0
  45. package/dist/setup/terminal-prompter.js +51 -0
  46. package/dist/setup/unified-diff.js +69 -0
  47. package/dist/task/present.js +90 -0
  48. package/dist/task/run-task.js +74 -11
  49. package/dist/task/start-task.js +21 -5
  50. package/package.json +9 -7
  51. package/skill/jevway/SKILL.md +50 -14
  52. package/dist/cli.js +0 -112
@@ -0,0 +1,320 @@
1
+ import { chmodSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { createConnection, createServer } from 'node:net';
3
+ import { dirname, join } from 'node:path';
4
+ import { ensureOutRoot } from '../output/layout.js';
5
+ import { createSessionService, UnknownSession } from '../session/session-service.js';
6
+ import { presentSummary } from '../task/present.js';
7
+ import { readLines, sendLine } from './protocol.js';
8
+ /** Another daemon already answers on the socket. */
9
+ export class DaemonRunning extends Error {
10
+ code = 'daemon_running';
11
+ }
12
+ const STOP_WAIT_MS = 10_000;
13
+ // 3600000 → '1 h', 1800000 → '30 min'.
14
+ const idleWords = (ms) => (ms % 3_600_000 === 0 ? `${ms / 3_600_000} h` : `${Math.round(ms / 60_000)} min`);
15
+ const listen = (server, path) => new Promise((resolve, reject) => {
16
+ server.once('error', reject);
17
+ server.listen(path, () => {
18
+ server.off('error', reject);
19
+ resolve();
20
+ });
21
+ });
22
+ const answers = (path) => new Promise((resolve) => {
23
+ const probe = createConnection(path);
24
+ probe.once('connect', () => {
25
+ probe.destroy();
26
+ resolve(true);
27
+ });
28
+ probe.once('error', () => resolve(false));
29
+ });
30
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
31
+ const errorBody = (error) => {
32
+ if (error instanceof UnknownSession) {
33
+ return { code: 'unknown_session', message: error.message, hint: 'List the open ones with jevway sessions, or start one with jevway run --session new --url <url> --goal <goal>' };
34
+ }
35
+ return { code: 'failed', message: error instanceof Error ? error.message : String(error), hint: 'The message says what failed; jevway doctor checks the key and the browser.' };
36
+ };
37
+ /**
38
+ * Serves the session service over a local socket until stopped or idle: one runner per browser/model choice, one
39
+ * service per output root, sessions found by id in whichever service holds them, and `sessions.json` kept in each
40
+ * output root. A socket file nothing answers on is replaced; one another daemon answers on is `DaemonRunning`.
41
+ *
42
+ * @example
43
+ * const daemon = await startDaemon({ socket, version, fingerprint, idleMs: 3_600_000, runnerFor, log });
44
+ * await daemon.closed;
45
+ */
46
+ export const startDaemon = async ({ socket, version, fingerprint, idleMs, runnerFor, log }) => {
47
+ const runners = new Map();
48
+ const services = new Map();
49
+ const connections = new Set();
50
+ const controllers = new Set();
51
+ const pending = new Set();
52
+ const busy = new Map();
53
+ const startedAt = new Date();
54
+ let lastActivity = Date.now();
55
+ let stopping;
56
+ let markClosed;
57
+ const closed = new Promise((resolve) => (markClosed = resolve));
58
+ const lifetime = `Sessions end when the daemon stops${idleMs ? ` or after ${idleWords(idleMs)} idle` : ''}`;
59
+ const unknown = (id, kind) => new UnknownSession(kind === 'session'
60
+ ? `Unknown session "${id}". ${lifetime}; start a new one with --session new and --url (or --tab).`
61
+ : `Unknown journey "${id}". Journeys end when finished, or as sessions do: ${lifetime.toLowerCase()}; start a new one with jevway journey start --url <url>.`);
62
+ const allSessions = () => [...services.values()].flatMap(({ service }) => service.listSessions());
63
+ const holderOf = (id, kind) => {
64
+ for (const holder of services.values())
65
+ if (holder.service.listSessions().some(({ session }) => session === id))
66
+ return holder;
67
+ throw unknown(id, kind);
68
+ };
69
+ const serviceFor = (choice, place) => {
70
+ const runnerKey = JSON.stringify([choice.browser, choice.model ?? null, choice.screenshots ?? null]);
71
+ let runner = runners.get(runnerKey);
72
+ if (!runner)
73
+ runners.set(runnerKey, (runner = runnerFor(choice)));
74
+ const key = JSON.stringify([runnerKey, place.outDir, place.journeysDir ?? null]);
75
+ let holder = services.get(key);
76
+ if (!holder) {
77
+ const env = place.journeysDir ? { JEVWAY_JOURNEYS_DIR: place.journeysDir } : {};
78
+ holder = { service: createSessionService(runner, { outDir: place.outDir, cwd: place.cwd, env, idleMs: idleMs || undefined }), outDir: place.outDir };
79
+ services.set(key, holder);
80
+ }
81
+ return holder;
82
+ };
83
+ /** Rewrites `<root>/sessions.json` in every output root this daemon writes to. */
84
+ const writeSessionFiles = () => {
85
+ const byRoot = new Map();
86
+ for (const { service, outDir } of services.values())
87
+ byRoot.set(outDir, [...(byRoot.get(outDir) ?? []), ...service.listSessions()]);
88
+ for (const [root, sessions] of byRoot) {
89
+ try {
90
+ ensureOutRoot(root);
91
+ sessions.sort((a, b) => a.started_at.localeCompare(b.started_at));
92
+ writeFileSync(join(root, 'sessions.json'), `${JSON.stringify({ updated_at: new Date().toISOString(), sessions }, null, 1)}\n`);
93
+ }
94
+ catch {
95
+ // A folder that can't be written never fails a call.
96
+ }
97
+ }
98
+ };
99
+ const endOne = ({ service }, { session, kind }, keepTab = false) => kind === 'journey' ? service.journeyFinish(session, { keepTab }) : service.end(session, { keepTab });
100
+ const hello = () => ({
101
+ version,
102
+ fingerprint,
103
+ pid: process.pid,
104
+ sessions: allSessions().length,
105
+ idle_timeout_s: idleMs ? idleMs / 1000 : null,
106
+ started_at: startedAt.toISOString(),
107
+ });
108
+ const withoutPreview = ({ journey, report }) => ({ journey, ...report });
109
+ const handlers = {
110
+ hello: () => hello(),
111
+ async task({ request, detail, runner, place }, { signal, emit, track }) {
112
+ const { service } = request.session ? holderOf(request.session, 'session') : serviceFor(runner, place);
113
+ const result = await service.task(request, {
114
+ signal,
115
+ onEvent: (event) => {
116
+ if (event.type === 'started') {
117
+ track(event.session);
118
+ writeSessionFiles();
119
+ }
120
+ emit(event);
121
+ },
122
+ });
123
+ writeSessionFiles();
124
+ return presentSummary(result, { detail });
125
+ },
126
+ async look({ session }) {
127
+ return { session, ...(await holderOf(session, 'session').service.look({ session })) };
128
+ },
129
+ async end({ session, keepTab }) {
130
+ const holder = holderOf(session, 'session');
131
+ const info = holder.service.listSessions().find((entry) => entry.session === session);
132
+ const ended = await endOne(holder, info, keepTab);
133
+ writeSessionFiles();
134
+ return info.kind === 'journey' ? { journey: session, ...ended } : ended;
135
+ },
136
+ sessions: () => ({ daemon: hello(), sessions: allSessions().sort((a, b) => a.started_at.localeCompare(b.started_at)) }),
137
+ async 'journey.start'({ request, runner, place }) {
138
+ const reply = await serviceFor(runner, place).service.journeyStart(request);
139
+ writeSessionFiles();
140
+ return withoutPreview(reply);
141
+ },
142
+ async 'journey.step'({ journey, input }) {
143
+ return withoutPreview(await holderOf(journey, 'journey').service.journeyStep(journey, input));
144
+ },
145
+ async 'journey.autopilot'({ journey, input }) {
146
+ return withoutPreview(await holderOf(journey, 'journey').service.journeyAutopilot(journey, input));
147
+ },
148
+ 'journey.annotate'({ journey, screen, notes, relabel }) {
149
+ holderOf(journey, 'journey').service.journeyAnnotate(journey, screen, notes, relabel);
150
+ return { journey, screen, annotated: true };
151
+ },
152
+ async 'journey.finish'({ journey, keepTab }) {
153
+ const summary = await holderOf(journey, 'journey').service.journeyFinish(journey, { keepTab });
154
+ writeSessionFiles();
155
+ return { journey, ...summary };
156
+ },
157
+ };
158
+ // Ending (not destroying) lets the last replies reach their clients first.
159
+ const finish = () => {
160
+ for (const connection of connections) {
161
+ connection.end();
162
+ setTimeout(() => connection.destroy(), 1_000).unref();
163
+ }
164
+ markClosed();
165
+ };
166
+ const shutdown = (reason) => (stopping ??= (async () => {
167
+ clearInterval(timer);
168
+ server.close();
169
+ for (const controller of controllers)
170
+ controller.abort();
171
+ await Promise.race([Promise.allSettled([...pending]), delay(STOP_WAIT_MS)]);
172
+ let ended = 0;
173
+ for (const holder of services.values()) {
174
+ for (const info of holder.service.listSessions()) {
175
+ try {
176
+ await endOne(holder, info);
177
+ ended++;
178
+ }
179
+ catch {
180
+ // A tab that is already gone is ended all the same.
181
+ }
182
+ }
183
+ }
184
+ for (const runner of runners.values())
185
+ await runner.close().catch(() => undefined);
186
+ writeSessionFiles();
187
+ log({ event: 'stopped', reason, ended });
188
+ return ended;
189
+ })());
190
+ const handle = async (socket, mine, { id, method, params = {} }) => {
191
+ if (method === 'stop') {
192
+ const ended = await shutdown('stop');
193
+ sendLine(socket, { id, result: { stopped: true, ended } });
194
+ finish();
195
+ return;
196
+ }
197
+ if (stopping) {
198
+ sendLine(socket, { id, error: { code: 'daemon_unavailable', message: 'The daemon is stopping.', hint: 'Run the command again: it starts a new daemon.' } });
199
+ return;
200
+ }
201
+ const handler = handlers[method];
202
+ if (!handler) {
203
+ sendLine(socket, { id, error: { code: 'unknown_method', message: `Unknown method "${method}"`, hint: 'The daemon and this jevway may differ in version: run jevway stop.' } });
204
+ return;
205
+ }
206
+ const controller = new AbortController();
207
+ mine.set(id, controller);
208
+ controllers.add(controller);
209
+ const tracked = [];
210
+ const track = (session) => {
211
+ tracked.push(session);
212
+ busy.set(session, (busy.get(session) ?? 0) + 1);
213
+ };
214
+ for (const session of [params.session, params.journey, params.request?.session])
215
+ if (typeof session === 'string')
216
+ track(session);
217
+ const began = Date.now();
218
+ lastActivity = began;
219
+ let failed;
220
+ const work = (async () => {
221
+ try {
222
+ const result = await handler(params, { signal: controller.signal, emit: (event) => sendLine(socket, { id, event }), track });
223
+ sendLine(socket, { id, result });
224
+ }
225
+ catch (error) {
226
+ const body = errorBody(error);
227
+ failed = body.code;
228
+ sendLine(socket, { id, error: body });
229
+ }
230
+ })();
231
+ pending.add(work);
232
+ await work;
233
+ pending.delete(work);
234
+ mine.delete(id);
235
+ controllers.delete(controller);
236
+ for (const session of tracked) {
237
+ const left = (busy.get(session) ?? 1) - 1;
238
+ if (left > 0)
239
+ busy.set(session, left);
240
+ else
241
+ busy.delete(session);
242
+ }
243
+ lastActivity = Date.now();
244
+ log({ event: 'request', method, id, ...(tracked[0] && { session: tracked[0] }), ms: lastActivity - began, ...(failed && { error: failed }) });
245
+ };
246
+ const onConnection = (socket) => {
247
+ connections.add(socket);
248
+ const mine = new Map();
249
+ readLines(socket, (message) => {
250
+ if (typeof message?.cancel === 'number')
251
+ mine.get(message.cancel)?.abort();
252
+ else if (typeof message?.id === 'number' && typeof message?.method === 'string')
253
+ void handle(socket, mine, message);
254
+ });
255
+ // A client that goes away cancels its running calls, as a cancelled MCP request does.
256
+ socket.on('close', () => {
257
+ connections.delete(socket);
258
+ for (const controller of mine.values())
259
+ controller.abort();
260
+ });
261
+ socket.on('error', () => undefined);
262
+ };
263
+ let sweeping = false;
264
+ const sweep = async () => {
265
+ if (sweeping || stopping)
266
+ return;
267
+ sweeping = true;
268
+ try {
269
+ const cutoff = Date.now() - idleMs;
270
+ let changed = false;
271
+ for (const holder of services.values()) {
272
+ for (const info of holder.service.listSessions()) {
273
+ if (info.running || busy.has(info.session) || Date.parse(info.last_used_at) > cutoff)
274
+ continue;
275
+ await endOne(holder, info).catch(() => undefined);
276
+ changed = true;
277
+ log({ event: 'idle_end', session: info.session, kind: info.kind });
278
+ }
279
+ }
280
+ if (changed)
281
+ writeSessionFiles();
282
+ if (!allSessions().length && !pending.size && lastActivity <= cutoff) {
283
+ await shutdown('idle');
284
+ finish();
285
+ }
286
+ }
287
+ finally {
288
+ sweeping = false;
289
+ }
290
+ };
291
+ if (process.platform !== 'win32')
292
+ mkdirSync(dirname(socket), { recursive: true, mode: 0o700 });
293
+ let server = createServer(onConnection);
294
+ try {
295
+ await listen(server, socket);
296
+ }
297
+ catch (error) {
298
+ if (error.code !== 'EADDRINUSE')
299
+ throw error;
300
+ if (await answers(socket))
301
+ throw new DaemonRunning(`A jevway daemon already answers on ${socket}`);
302
+ // A socket file left by a daemon that died: nothing answers on it.
303
+ rmSync(socket, { force: true });
304
+ server = createServer(onConnection);
305
+ await listen(server, socket);
306
+ }
307
+ if (process.platform !== 'win32')
308
+ chmodSync(socket, 0o600);
309
+ const timer = idleMs > 0 ? setInterval(() => void sweep(), Math.min(Math.max(idleMs / 4, 25), 60_000)) : undefined;
310
+ timer?.unref();
311
+ log({ event: 'started', pid: process.pid, version, socket, idle_timeout_s: idleMs ? idleMs / 1000 : null });
312
+ return {
313
+ socket,
314
+ closed,
315
+ async stop() {
316
+ await shutdown('stopped');
317
+ finish();
318
+ },
319
+ };
320
+ };
@@ -15,7 +15,6 @@ export class ReplayMiss extends Error {
15
15
  this.pageDiff = pageDiff;
16
16
  }
17
17
  }
18
- const DEFAULT_DIR = 'verify/out/replay/default';
19
18
  const IMAGE = '<image>';
20
19
  const LOCAL_ORIGIN = /\b(127\.0\.0\.1|localhost):\d+/g;
21
20
  /**
@@ -33,8 +32,13 @@ export const replayMode = () => {
33
32
  throw new Error(`JEVWAY_REPLAY must be record or replay, got "${mode}".`);
34
33
  return mode;
35
34
  };
36
- /** The recording folder: `JEVWAY_REPLAY_DIR`, or `verify/out/replay/default` under the working directory. */
37
- export const replayDir = () => resolve(process.env.JEVWAY_REPLAY_DIR?.trim() || DEFAULT_DIR);
35
+ /** The recording folder, `JEVWAY_REPLAY_DIR`: required whenever `JEVWAY_REPLAY` is set, so no run writes into a guessed folder. */
36
+ export const replayDir = () => {
37
+ const dir = process.env.JEVWAY_REPLAY_DIR?.trim();
38
+ if (!dir)
39
+ throw new Error('JEVWAY_REPLAY needs JEVWAY_REPLAY_DIR: the folder that holds the recordings.');
40
+ return resolve(dir);
41
+ };
38
42
  const isPlainObject = (value) => typeof value === 'object' && value !== null && Object.getPrototypeOf(value) === Object.prototype;
39
43
  /**
40
44
  * The request as it is matched: keys sorted at every depth, every image (an SDK `Image`, an `image_url` part, a
@@ -0,0 +1,29 @@
1
+ /** A busy or flaky model API (5xx incl. 529 overload, 429, 499, timeouts, dropped connections): worth another try. */
2
+ const TRANSIENT_MODEL_ERROR = /^(5\d\d|429|499)\b|timeout|timed out|ECONNRESET|ETIMEDOUT|fetch failed|socket hang up/i;
3
+ /**
4
+ * Whether a model call failed for a reason that may pass on its own.
5
+ *
6
+ * @example
7
+ * isTransientModelError(new Error('529 We are currently experiencing high traffic')) // true
8
+ */
9
+ export const isTransientModelError = (error) => error instanceof Error && TRANSIENT_MODEL_ERROR.test(error.message);
10
+ /**
11
+ * An `Ask` that tries again after a transient failure, waiting each of `delaysMs` in turn; any other failure, or the
12
+ * last one, is thrown as it came.
13
+ *
14
+ * @example
15
+ * const ask = retryingAsk(createAsk(profile), { delaysMs: [1000, 3000] });
16
+ */
17
+ export const retryingAsk = (ask, { delaysMs, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) => async (request) => {
18
+ for (const delay of delaysMs) {
19
+ try {
20
+ return await ask(request);
21
+ }
22
+ catch (error) {
23
+ if (!isTransientModelError(error))
24
+ throw error;
25
+ await wait(delay);
26
+ }
27
+ }
28
+ return ask(request);
29
+ };
@@ -44,7 +44,8 @@ export const buildFlows = (steps) => {
44
44
  return [...flows.values()];
45
45
  };
46
46
  const parentOf = (name) => (name.includes('/') ? name.slice(0, name.lastIndexOf('/')) : null);
47
- const describeAction = (step) => {
47
+ /** A step's action as a person reads it: `Tap "Search"`, `Type into "Destination" → "Lisbon"`. */
48
+ export const describeAction = (step) => {
48
49
  const verb = { CLICK: 'Tap', TYPE_TEXT: 'Type into', SELECT: 'Choose' }[step.action.operation] ?? step.action.operation;
49
50
  return `${verb} "${step.action.label}"${step.action.value ? ` → "${step.action.value}"` : ''}`;
50
51
  };
@@ -10,6 +10,9 @@ import { buildFlows, hotspotOf } from './journey-doc.js';
10
10
  import { pickNextMove } from './pick-next-move.js';
11
11
  import { renderViewer } from './render-viewer.js';
12
12
  import { qaFindings, readScreenJudgments, screenQuestions, } from './screen-judgments.js';
13
+ import { screenFileName } from '../output/layout.js';
14
+ import { writeManifest } from '../output/manifest.js';
15
+ import { outsideHosts } from '../task/hosts.js';
13
16
  const EXPLORE_GOAL = `Explore this site the way a first-time mobile visitor would, one screen at a time.
14
17
  Open the next screen that matters most for the site's core user journeys: discovering, searching, comparing,
15
18
  and evaluating its main offering. Prefer primary navigation, search, results, and detail screens over legal,
@@ -24,7 +27,7 @@ footer, social, app-download, or external links. Dismiss blocking banners. Never
24
27
  * journey.report(); // screen s01 + Jev's suggested next tap
25
28
  * await journey.step({ next: 4, flow: 'Search', insight: 'Search is a full-screen sheet' });
26
29
  * await journey.autopilot({ screens: 5, flow: 'Search/Results' });
27
- * journey.finish(); // → journey.json, index.html, screens/*.png
30
+ * journey.finish(); // → journey.json, index.html, manifest.json, screens/s01-home.png …
28
31
  */
29
32
  export class JourneySession {
30
33
  doc;
@@ -227,7 +230,8 @@ export class JourneySession {
227
230
  continue;
228
231
  }
229
232
  const scope = this.#options.stayWithin ?? new URL(this.#options.startUrl).origin;
230
- if (!(await this.#tab.observe()).url.startsWith(scope)) {
233
+ const landed = (await this.#tab.observe()).url;
234
+ if (!landed.startsWith(scope) || this.#outsideHosts(landed)) {
231
235
  await this.#goBack();
232
236
  this.#logMove('left_scope', move.action.label);
233
237
  continue;
@@ -286,9 +290,15 @@ export class JourneySession {
286
290
  return this.report();
287
291
  }
288
292
  // Observe → one Jev request (next step + screen understanding) → dedupe → screenshot → QA → save.
293
+ #outsideHosts(url) {
294
+ return this.#options.hosts && outsideHosts(url, this.#options.hosts);
295
+ }
289
296
  async #capture(arrival) {
290
297
  await this.#diagnostics.waitForIdle();
291
298
  const page = await this.#settledPage();
299
+ const outside = this.#outsideHosts(page.url);
300
+ if (outside)
301
+ throw new Error(`The page is outside the server's hosts: ${outside}. Take \`back\` to return.`);
292
302
  const seesScreen = (this.#options.screenshots ?? 'off') !== 'off';
293
303
  const boxed = seesScreen && Boolean(this.#options.box);
294
304
  const decision = await decide(this.#ask, {
@@ -320,7 +330,7 @@ export class JourneySession {
320
330
  mergeFindings(this.#screen(existing).qa, findings);
321
331
  }
322
332
  else {
323
- const screenshot = `screens/${id}.png`;
333
+ const screenshot = screenFileName(id, judgments.type);
324
334
  writeFileSync(join(this.outDir, screenshot), Buffer.from(await this.#tab.screenshot(), 'base64'));
325
335
  this.doc.screens.push({
326
336
  id,
@@ -433,6 +443,7 @@ export class JourneySession {
433
443
  this.doc.flows = buildFlows(this.doc.steps);
434
444
  writeFileSync(join(this.outDir, 'journey.json'), JSON.stringify(this.doc, null, 2));
435
445
  writeFileSync(join(this.outDir, 'index.html'), renderViewer(this.doc));
446
+ writeManifest(this.outDir, 'journey');
436
447
  }
437
448
  }
438
449
  // "https://site/#/" and "https://site/" are the same route.