Skip to content

Commit caea905

Browse files
stuff
1 parent d913e5a commit caea905

File tree

25 files changed

+1387
-66
lines changed

25 files changed

+1387
-66
lines changed

.changeset/cli-previews-command.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@walkeros/cli': minor
3+
---
4+
5+
Add `walkeros previews {list|get|create|delete}` commands for managing preview
6+
bundles. `create` supports `--flow <name>` or `--settings-id <id>` to target a
7+
flow settings entry, and `--url <siteUrl>` to produce a ready-to-open activation
8+
URL. Use `--open` to launch it in your default browser.

.changeset/mcp-preview-actions.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@walkeros/mcp': minor
3+
---
4+
5+
Add `preview.list`, `preview.get`, `preview.create`, and `preview.delete`
6+
actions to the `api` tool. When `siteUrl` is provided to `preview.create`, the
7+
response includes a ready-to-open `activationUrl` and `deactivationUrl`.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@walkeros/cli': minor
3+
---
4+
5+
Preview preflight now self-heals when a preview bundle is deleted. Instead of
6+
injecting the preview script directly and letting it 404, the preflight does a
7+
`fetch(HEAD)` first. If the bundle is missing, it clears the `elbPreview` cookie
8+
and loads the production walker, so visitors never see silent analytics
9+
breakage.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@walkeros/web-core': patch
3+
---
4+
5+
Wrap `localStorage`/`sessionStorage`/cookie operations in try/catch. Storage
6+
access in private browsing (Safari), sandboxed iframes, or when quota is
7+
exceeded throws `SecurityError`/`QuotaExceededError` — previously these crashed
8+
the event pipeline at the call site. Reads now return empty, writes return empty
9+
and do not persist, deletes are silently ignored.

packages/cli/src/__tests__/unit/bundle/preview-preflight-jsdom.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ describe('Preview preflight jsdom integration', () => {
109109
d: unknown,
110110
) => d;
111111
(win as unknown as Record<string, unknown>).__mockConfigData = {};
112+
// Mock HEAD probe as 200 so the self-heal preflight proceeds to inject
113+
(win as unknown as Record<string, unknown>).fetch = () =>
114+
Promise.resolve({ ok: true, status: 200 });
112115

113116
const script = win.document.createElement('script');
114117
script.textContent = extractEvaluableScript(entry);
@@ -156,6 +159,9 @@ describe('Preview preflight jsdom integration', () => {
156159
d: unknown,
157160
) => d;
158161
(win as unknown as Record<string, unknown>).__mockConfigData = {};
162+
// Mock HEAD probe as 200 so self-heal proceeds with injection
163+
(win as unknown as Record<string, unknown>).fetch = () =>
164+
Promise.resolve({ ok: true, status: 200 });
159165

160166
const script = win.document.createElement('script');
161167
script.textContent = extractEvaluableScript(entry);
@@ -232,6 +238,9 @@ describe('Preview preflight jsdom integration', () => {
232238
d: unknown,
233239
) => d;
234240
(win as unknown as Record<string, unknown>).__mockConfigData = {};
241+
// Mock HEAD probe as 200 so self-heal proceeds with injection
242+
(win as unknown as Record<string, unknown>).fetch = () =>
243+
Promise.resolve({ ok: true, status: 200 });
235244

236245
const script = win.document.createElement('script');
237246
script.textContent = extractEvaluableScript(entry);
@@ -256,6 +265,165 @@ describe('Preview preflight jsdom integration', () => {
256265
});
257266
});
258267

268+
describe('404 self-heal (missing preview bundle)', () => {
269+
it('clears cookie and falls through to startFlow when HEAD returns 404', async () => {
270+
const token = 'k9x2m4p7abcd';
271+
const entry = generateWrapEntry('./skeleton.mjs', {
272+
previewOrigin: 'cdn.walkeros.io',
273+
previewScope: 'proj_abc',
274+
});
275+
276+
// Pre-set the cookie so the cookie-valid branch is taken (no query param)
277+
const dom = createDom('https://example.com/page');
278+
const win = dom.window;
279+
win.document.cookie = `elbPreview=${token}; path=/`;
280+
281+
let startFlowCalled = false;
282+
(win as unknown as Record<string, unknown>).__mockStartFlow = () => {
283+
startFlowCalled = true;
284+
return Promise.resolve({ collector: {}, elb: () => {} });
285+
};
286+
(win as unknown as Record<string, unknown>).__mockWireConfig = (
287+
d: unknown,
288+
) => d;
289+
(win as unknown as Record<string, unknown>).__mockConfigData = {};
290+
291+
const fetchCalls: Array<{ url: string; method?: string }> = [];
292+
(win as unknown as Record<string, unknown>).fetch = (
293+
url: string,
294+
init?: { method?: string },
295+
) => {
296+
fetchCalls.push({ url, method: init?.method });
297+
return Promise.resolve({ ok: false, status: 404 });
298+
};
299+
300+
const script = win.document.createElement('script');
301+
script.textContent = extractEvaluableScript(entry);
302+
win.document.body.appendChild(script);
303+
304+
// Wait for async fetch + IIFE chain to settle
305+
await new Promise((r) => setTimeout(r, 50));
306+
307+
// HEAD was issued to the preview URL
308+
expect(fetchCalls.length).toBe(1);
309+
expect(fetchCalls[0].method).toBe('HEAD');
310+
expect(fetchCalls[0].url).toBe(
311+
`https://cdn.walkeros.io/preview/proj_abc/walker.${token}.js`,
312+
);
313+
314+
// No preview <script> injected
315+
const injected = win.document.querySelectorAll('head > script[src]');
316+
expect(injected.length).toBe(0);
317+
318+
// Cookie cleared (max-age=0 effectively removes it)
319+
expect(win.document.cookie).not.toContain(`elbPreview=${token}`);
320+
321+
// Production path runs (fall-through to startFlow)
322+
expect(startFlowCalled).toBe(true);
323+
324+
win.close();
325+
});
326+
327+
it('clears cookie and falls through to startFlow when fetch rejects (network error)', async () => {
328+
const token = 'Ab3Df5Gh7j9L';
329+
const entry = generateWrapEntry('./skeleton.mjs', {
330+
previewOrigin: 'cdn.walkeros.io',
331+
previewScope: 'proj_abc',
332+
});
333+
334+
const dom = createDom('https://example.com/page');
335+
const win = dom.window;
336+
win.document.cookie = `elbPreview=${token}; path=/`;
337+
338+
let startFlowCalled = false;
339+
(win as unknown as Record<string, unknown>).__mockStartFlow = () => {
340+
startFlowCalled = true;
341+
return Promise.resolve({ collector: {}, elb: () => {} });
342+
};
343+
(win as unknown as Record<string, unknown>).__mockWireConfig = (
344+
d: unknown,
345+
) => d;
346+
(win as unknown as Record<string, unknown>).__mockConfigData = {};
347+
348+
(win as unknown as Record<string, unknown>).fetch = () =>
349+
Promise.reject(new Error('network failure'));
350+
351+
const script = win.document.createElement('script');
352+
script.textContent = extractEvaluableScript(entry);
353+
win.document.body.appendChild(script);
354+
355+
await new Promise((r) => setTimeout(r, 50));
356+
357+
// No preview <script> injected
358+
const injected = win.document.querySelectorAll('head > script[src]');
359+
expect(injected.length).toBe(0);
360+
361+
// Cookie cleared
362+
expect(win.document.cookie).not.toContain(`elbPreview=${token}`);
363+
364+
// Production path runs (fall-through to startFlow)
365+
expect(startFlowCalled).toBe(true);
366+
367+
win.close();
368+
});
369+
370+
it('injects preview script and skips startFlow when HEAD returns 200', async () => {
371+
const token = 'k9x2m4p7abcd';
372+
const entry = generateWrapEntry('./skeleton.mjs', {
373+
previewOrigin: 'cdn.walkeros.io',
374+
previewScope: 'proj_abc',
375+
});
376+
377+
const dom = createDom('https://example.com/page');
378+
const win = dom.window;
379+
win.document.cookie = `elbPreview=${token}; path=/`;
380+
381+
let startFlowCalled = false;
382+
(win as unknown as Record<string, unknown>).__mockStartFlow = () => {
383+
startFlowCalled = true;
384+
return Promise.resolve({ collector: {}, elb: () => {} });
385+
};
386+
(win as unknown as Record<string, unknown>).__mockWireConfig = (
387+
d: unknown,
388+
) => d;
389+
(win as unknown as Record<string, unknown>).__mockConfigData = {};
390+
391+
const fetchCalls: Array<{ url: string; method?: string }> = [];
392+
(win as unknown as Record<string, unknown>).fetch = (
393+
url: string,
394+
init?: { method?: string },
395+
) => {
396+
fetchCalls.push({ url, method: init?.method });
397+
return Promise.resolve({ ok: true, status: 200 });
398+
};
399+
400+
const script = win.document.createElement('script');
401+
script.textContent = extractEvaluableScript(entry);
402+
win.document.body.appendChild(script);
403+
404+
await new Promise((r) => setTimeout(r, 50));
405+
406+
// HEAD probe fired
407+
expect(fetchCalls.length).toBe(1);
408+
expect(fetchCalls[0].method).toBe('HEAD');
409+
410+
// Preview script injected
411+
const injected = win.document.querySelectorAll('head > script[src]');
412+
expect(injected.length).toBe(1);
413+
expect((injected[0] as HTMLScriptElement).src).toBe(
414+
`https://cdn.walkeros.io/preview/proj_abc/walker.${token}.js`,
415+
);
416+
417+
// Cookie preserved
418+
expect(win.document.cookie).toContain(`elbPreview=${token}`);
419+
420+
// startFlow NOT called (preview took over)
421+
expect(startFlowCalled).toBe(false);
422+
423+
win.close();
424+
});
425+
});
426+
259427
describe('regression: no preflight without preview options', () => {
260428
it('does NOT inject any preflight code when no preview options', async () => {
261429
const entry = generateWrapEntry('./skeleton.mjs', {

packages/cli/src/cli.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
getDeploymentBySlugCommand,
3838
} from './commands/deployments/index.js';
3939
import { feedbackCommand } from './commands/feedback/index.js';
40+
import { writeResult } from './core/output.js';
4041

4142
setClientContext({ type: 'cli', version: VERSION });
4243

@@ -445,6 +446,96 @@ deployCmd
445446
await deleteDeploymentCommand(idOrSlug, options);
446447
});
447448

449+
// ── Previews ─────────────────────────────────────────────────────────────
450+
const previewsCmd = program
451+
.command('previews')
452+
.description('Manage preview bundles for testing flow changes on live sites');
453+
454+
previewsCmd
455+
.command('list <flowId>')
456+
.description('List previews for a flow')
457+
.option('--project <projectId>', 'Project ID (overrides default)')
458+
.action(async (flowId, options) => {
459+
try {
460+
const { listPreviews } = await import('./commands/previews/index.js');
461+
const result = (await listPreviews({
462+
projectId: options.project,
463+
flowId,
464+
})) as { previews: unknown };
465+
await writeResult(JSON.stringify(result.previews, null, 2), {});
466+
} catch (err) {
467+
handleCliError(err);
468+
}
469+
});
470+
471+
previewsCmd
472+
.command('get <flowId> <previewId>')
473+
.description('Get preview details')
474+
.option('--project <projectId>', 'Project ID (overrides default)')
475+
.action(async (flowId, previewId, options) => {
476+
try {
477+
const { getPreview } = await import('./commands/previews/index.js');
478+
const result = await getPreview({
479+
projectId: options.project,
480+
flowId,
481+
previewId,
482+
});
483+
await writeResult(JSON.stringify(result, null, 2), {});
484+
} catch (err) {
485+
handleCliError(err);
486+
}
487+
});
488+
489+
previewsCmd
490+
.command('create <flowId>')
491+
.description('Create a preview bundle for a flow settings entry')
492+
.option('-f, --flow <name>', 'Flow settings name (resolved to ID)')
493+
.option('-s, --settings-id <id>', 'Flow settings ID (alternative to --flow)')
494+
.option('-u, --url <url>', 'Site URL to construct activation URL')
495+
.option('--open', 'Open activation URL in the default browser')
496+
.option('--project <projectId>', 'Project ID (overrides default)')
497+
.action(async (flowId, options) => {
498+
try {
499+
const { createPreview } = await import('./commands/previews/index.js');
500+
const { printPreviewCreated } =
501+
await import('./commands/previews/output.js');
502+
const preview = (await createPreview({
503+
projectId: options.project,
504+
flowId,
505+
flowName: options.flow,
506+
flowSettingsId: options.settingsId,
507+
})) as Parameters<typeof printPreviewCreated>[0];
508+
await printPreviewCreated(preview, {
509+
url: options.url,
510+
open: options.open,
511+
});
512+
} catch (err) {
513+
handleCliError(err);
514+
}
515+
});
516+
517+
previewsCmd
518+
.command('delete <flowId> <previewId>')
519+
.description('Delete a preview')
520+
.option('-y, --yes', 'Skip confirmation prompt')
521+
.option('--project <projectId>', 'Project ID (overrides default)')
522+
.action(async (flowId, previewId, options) => {
523+
try {
524+
if (!options.yes) {
525+
throw new Error('Confirmation required. Use --yes to skip.');
526+
}
527+
const { deletePreview } = await import('./commands/previews/index.js');
528+
await deletePreview({
529+
projectId: options.project,
530+
flowId,
531+
previewId,
532+
});
533+
process.stderr.write(`Deleted ${previewId}\n`);
534+
} catch (err) {
535+
handleCliError(err);
536+
}
537+
});
538+
448539
// Run command
449540
program
450541
.command('run [file]')

packages/cli/src/commands/bundle/bundler.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1757,10 +1757,25 @@ export function generateWrapEntry(
17571757
var __match = /(?:^|; )elbPreview=([^;]+)/.exec(document.cookie);
17581758
var __token = __match && __match[1];
17591759
if (__token && /^[a-zA-Z0-9_-]{8,32}$/.test(__token)) {
1760-
var __s = document.createElement('script');
1761-
__s.src = 'https://' + __previewOrigin + '/preview/' + __previewScope + '/walker.' + __token + '.js';
1762-
document.head.appendChild(__s);
1763-
return;
1760+
var __previewSrc = 'https://' + __previewOrigin + '/preview/' + __previewScope + '/walker.' + __token + '.js';
1761+
var __clearPreviewCookie = function () {
1762+
document.cookie = 'elbPreview=; path=/; max-age=0; SameSite=Lax' + __secure;
1763+
};
1764+
try {
1765+
var __probe = await fetch(__previewSrc, { method: 'HEAD' });
1766+
if (__probe && __probe.ok) {
1767+
var __s = document.createElement('script');
1768+
__s.src = __previewSrc;
1769+
document.head.appendChild(__s);
1770+
return;
1771+
}
1772+
// Preview bundle missing (404/5xx) — self-heal by clearing cookie and
1773+
// falling through to the production walker in this same bundle.
1774+
__clearPreviewCookie();
1775+
} catch (__err) {
1776+
// Network error — fall through to production too.
1777+
__clearPreviewCookie();
1778+
}
17641779
}
17651780
}
17661781
// --- End preview mode preflight ---

0 commit comments

Comments
 (0)