@getrefino/onboarding 0.1.0-rc.2 → 0.1.0-rc.4

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.
@@ -1,4 +1,18 @@
1
+ /**
2
+ * Boilerplate for a site connected to Refino (hosted mode). Everything runs
3
+ * in the browser: the site holds two public constants and no secrets, no
4
+ * copy endpoint, no session endpoint and no repository credential. Refino
5
+ * authenticates the owner, authorizes the site, and loads and saves the
6
+ * copy file in the repository.
7
+ *
8
+ * /edit → Refino /editor/authorize (PKCE) → back to /edit?code&state
9
+ * → POST Refino /api/editor/token → 12 h editor token in sessionStorage
10
+ * → <RefinoProvider endpoint={Refino copy API} headers={bearer}>
11
+ */
12
+ import { isInstallMethod } from "./install-method.js";
1
13
  import { TOOL_VERSION } from "./plan.js";
14
+ import { createTemplateFactory } from "./template-file.js";
15
+ import { COPY_EDITING_HOSTED_RC1, RC1, REFINO_CLIENT_RC1, REFINO_SITE_RC1 } from "./templates-legacy.js";
2
16
  export const DEFAULT_REFINO_APP_URL = "https://app.refino.dev";
3
17
  export const REFINO_SITE_ID_PATTERN = /^site_[a-f0-9]{32}$/;
4
18
  /** Public configuration; safe to ship to the browser and to commit. */
@@ -398,35 +412,70 @@ export function analyticsHosting(hosting) {
398
412
  return "unknown";
399
413
  return ["cloudflare", "vercel", "netlify"].includes(hosting) ? hosting : "other";
400
414
  }
415
+ /**
416
+ * Recognise an earlier Refino's `refino/refino-site` module.
417
+ *
418
+ * This is the one generated file whose bytes depend on the site, so "is it
419
+ * untouched?" cannot be a lookup: the site id, the app URL and the install
420
+ * facts are read back out of the file, each released shape is re-rendered
421
+ * from exactly those values, and the result is compared byte for byte. A file
422
+ * that round-trips is that release's output and nothing else; a file that
423
+ * does not is the owner's and is never overwritten.
424
+ *
425
+ * It also answers the question the manifest cannot for a reconnected site:
426
+ * after `--refino-site <new id>` the generated bytes name a different site, so
427
+ * only re-rendering with the *old* id can show the file was untouched.
428
+ */
429
+ const recognizeSiteModule = (current, render) => {
430
+ const siteId = /REFINO_SITE_ID\s*=\s*"(site_[a-f0-9]{32})"/.exec(current)?.[1];
431
+ const appUrl = /REFINO_APP_URL\s*=\s*"([^"]+)"/.exec(current)?.[1];
432
+ if (!siteId || !appUrl)
433
+ return null;
434
+ // Before the install facts existed there was nothing else to read.
435
+ if (render(REFINO_SITE_RC1({ siteId, appUrl })) === current)
436
+ return { version: RC1, content: current };
437
+ const read = (key) => new RegExp(`${key}:\\s*"([^"]*)"`).exec(current)?.[1];
438
+ const method = read("method");
439
+ const framework = read("framework");
440
+ const hosting = read("hosting");
441
+ const packageVersion = read("packageVersion");
442
+ if (method === undefined || framework === undefined || hosting === undefined || packageVersion === undefined)
443
+ return null;
444
+ if (!isInstallMethod(method))
445
+ return null;
446
+ const rendered = render(REFINO_SITE({ siteId, appUrl, installMethod: method, framework, hosting, packageVersion }));
447
+ return rendered === current ? { version: packageVersion, content: current } : null;
448
+ };
449
+ /** What 0.1.0-rc.1 wrote for the two hosted modules whose bodies have changed since. */
450
+ const RC1_CLIENT = { version: RC1, content: REFINO_CLIENT_RC1 };
451
+ const RC1_COPY_EDITING = { version: RC1, content: COPY_EDITING_HOSTED_RC1 };
401
452
  /** Files for a Refino-connected site. No server code, no secrets, in any framework. */
402
- export function hostedFiles(plan) {
453
+ export function hostedFiles(plan, factory) {
403
454
  const refino = plan.refino;
404
455
  if (!refino)
405
456
  return [];
457
+ const make = factory ?? createTemplateFactory(plan.repository.language);
406
458
  const dir = plan.integration.boilerplateDir;
407
459
  const files = [
408
- {
409
- path: `${dir}/refino-site.ts`,
410
- content: REFINO_SITE({
411
- ...refino,
412
- framework: analyticsFramework(plan.repository.framework),
413
- hosting: analyticsHosting(plan.repository.hosting),
414
- packageVersion: TOOL_VERSION,
415
- }),
416
- },
417
- { path: `${dir}/refino-client.ts`, content: REFINO_CLIENT },
418
- { path: `${dir}/copy-editing.tsx`, content: COPY_EDITING_HOSTED },
419
- { path: `${dir}/edit-page.tsx`, content: EDIT_PAGE_HOSTED },
460
+ make.machine(`${dir}/refino-site.ts`, REFINO_SITE({
461
+ ...refino,
462
+ framework: analyticsFramework(plan.repository.framework),
463
+ hosting: analyticsHosting(plan.repository.hosting),
464
+ packageVersion: TOOL_VERSION,
465
+ }), { recognize: recognizeSiteModule }),
466
+ make.machine(`${dir}/refino-client.ts`, REFINO_CLIENT, { previous: [RC1_CLIENT] }),
467
+ make.machine(`${dir}/copy-editing.tsx`, COPY_EDITING_HOSTED, { previous: [RC1_COPY_EDITING] }),
468
+ make.scaffold(`${dir}/edit-page.tsx`, EDIT_PAGE_HOSTED),
420
469
  ];
421
470
  const editPage = plan.files.find((file) => /(^|\/)edit(\/page)?\.\w+$/.test(file.path) && !file.path.startsWith(`${dir}/`))?.path;
422
471
  if (plan.repository.router === "next-app") {
423
472
  const routesDir = editPage?.replace(/\/edit\/page\.\w+$/, "") ?? "app";
424
- files.push({ path: `${routesDir}/edit/layout.tsx`, content: EDIT_LAYOUT_HOSTED });
425
- files.push({ path: `${routesDir}/edit/page.tsx`, content: EDIT_ROUTE_NEXT_HOSTED(relativeImport(`${routesDir}/edit/page.tsx`, `${dir}/edit-page.tsx`)) });
473
+ files.push(make.machine(`${routesDir}/edit/layout.tsx`, EDIT_LAYOUT_HOSTED));
474
+ files.push(make.machine(`${routesDir}/edit/page.tsx`, EDIT_ROUTE_NEXT_HOSTED(relativeImport(`${routesDir}/edit/page.tsx`, `${dir}/edit-page.tsx`))));
426
475
  }
427
476
  else if (plan.repository.router === "next-pages") {
428
477
  const routesDir = editPage?.replace(/\/edit\.\w+$/, "") ?? "pages";
429
- files.push({ path: `${routesDir}/edit.tsx`, content: EDIT_PAGE_NEXT_PAGES_HOSTED(relativeImport(`${routesDir}/edit.tsx`, `${dir}/edit-page.tsx`)) });
478
+ files.push(make.machine(`${routesDir}/edit.tsx`, EDIT_PAGE_NEXT_PAGES_HOSTED(relativeImport(`${routesDir}/edit.tsx`, `${dir}/edit-page.tsx`))));
430
479
  }
431
480
  return files;
432
481
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Byte-exact renderings of generated files as earlier Refino releases wrote
3
+ * them. They exist for one reason: a site onboarded before .refino/generated.json
4
+ * existed carries no record of what Refino wrote, so the only way to prove a
5
+ * generated file is untouched is to reproduce the old bytes and compare.
6
+ *
7
+ * Nothing here is ever written to a repository. It is read-only evidence.
8
+ *
9
+ * Maintenance: when a template body in templates.ts or templates-hosted.ts
10
+ * changes, copy the previous body here and list it in that file's `previous`
11
+ * renderings. `generated-templates.test.ts` fails until you do, because it
12
+ * pins the hash of every generated file.
13
+ */
14
+ export interface LegacySiteConfig {
15
+ readonly siteId: string;
16
+ readonly appUrl: string;
17
+ }
18
+ /** The last release that wrote the bodies below. */
19
+ export declare const RC1 = "0.1.0-rc.1";
20
+ /** 0.1.0-rc.1 refino/refino-site.ts: the two public constants, before REFINO_INSTALL. */
21
+ export declare const REFINO_SITE_RC1: (config: LegacySiteConfig) => string;
22
+ /** 0.1.0-rc.1 refino/refino-client.ts: before reportEditorEvent and the install facts. */
23
+ export declare const REFINO_CLIENT_RC1 = "/**\n * Browser-side Refino editor authorization for this site. No secrets here:\n * the site is a public client (OAuth 2.1 with PKCE). The editor token lives\n * in sessionStorage only (never localStorage, never a URL), is scoped to\n * this site, and Refino re-checks the account's entitlement on every load\n * and save. Never import this from server code.\n */\nimport { REFINO_APP_URL, REFINO_SITE_ID } from \"./refino-site\";\n\nconst PKCE_KEY = \"refino.pkce\";\nconst SESSION_KEY = \"refino.editor\";\nconst CALLBACK_PATH = \"/edit\";\n\nexport interface EditorSession {\n readonly token: string;\n /** Unix seconds. */\n readonly expiresAt: number;\n readonly siteId: string;\n}\n\ninterface PendingAuthorization {\n readonly verifier: string;\n readonly state: string;\n readonly returnTo: string;\n}\n\nfunction base64url(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/, \"\");\n}\n\nfunction randomToken(bytes: number): string {\n const buffer = new Uint8Array(bytes);\n crypto.getRandomValues(buffer);\n return base64url(buffer);\n}\n\nasync function sha256(text: string): Promise<string> {\n const digest = await crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(text));\n return base64url(new Uint8Array(digest));\n}\n\nfunction storage(): Storage | null {\n try {\n return typeof window === \"undefined\" ? null : window.sessionStorage;\n } catch {\n return null;\n }\n}\n\nfunction readJson<T>(key: string): T | null {\n const raw = storage()?.getItem(key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n/** Only a path on this site, never another origin. */\nexport function safeReturnTo(value: string | null | undefined): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\") || value.startsWith(\"/\\\\\") || value.length > 512) return \"/\";\n return value;\n}\n\nexport function editorEndpoint(): string {\n return `${REFINO_APP_URL}/api/sites/${REFINO_SITE_ID}/copy`;\n}\n\nexport function callbackUrl(): string {\n return `${window.location.origin}${CALLBACK_PATH}`;\n}\n\n/** The current editor session, or null when there is none or it expired. */\nexport function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {\n const session = readJson<EditorSession>(SESSION_KEY);\n if (!session || typeof session.token !== \"string\" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== \"number\") return null;\n if (session.expiresAt <= nowSeconds) {\n storage()?.removeItem(SESSION_KEY);\n return null;\n }\n return session;\n}\n\nexport function clearEditorSession(): void {\n storage()?.removeItem(SESSION_KEY);\n storage()?.removeItem(PKCE_KEY);\n}\n\n/** Start the flow: remember the PKCE verifier and state, then go to Refino. */\nexport async function beginAuthorization(returnTo: string): Promise<void> {\n const verifier = randomToken(32);\n const state = randomToken(16);\n const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };\n storage()?.setItem(PKCE_KEY, JSON.stringify(pending));\n const params = new URLSearchParams({\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n code_challenge: await sha256(verifier),\n code_challenge_method: \"S256\",\n state,\n });\n window.location.assign(`${REFINO_APP_URL}/editor/authorize?${params.toString()}`);\n}\n\nexport type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };\n\n/**\n * Finish the flow on /edit?code&state: check the state, exchange the code\n * with the verifier this browser kept, store the session, and scrub the\n * code from the URL. The token is never placed in a URL.\n */\nexport async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {\n const params = new URLSearchParams(search);\n const code = params.get(\"code\");\n const state = params.get(\"state\");\n const pending = readJson<PendingAuthorization>(PKCE_KEY);\n storage()?.removeItem(PKCE_KEY);\n if (typeof window !== \"undefined\" && window.history.replaceState) window.history.replaceState(null, \"\", window.location.pathname);\n if (!code || !state) return { ok: false, error: \"Refino did not return an authorization code.\" };\n if (!pending || pending.state !== state) return { ok: false, error: \"Sign-in was interrupted (state mismatch). Start again.\" };\n\n let response: Response;\n try {\n response = await fetch(`${REFINO_APP_URL}/api/editor/token`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify({ grant_type: \"authorization_code\", code, code_verifier: pending.verifier, site_id: REFINO_SITE_ID, redirect_uri: callbackUrl() }),\n });\n } catch {\n return { ok: false, error: \"Could not reach Refino to finish signing in.\" };\n }\n const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;\n if (!response.ok || !body?.ok || typeof body.token !== \"string\" || typeof body.expiresAt !== \"number\" || body.siteId !== REFINO_SITE_ID) {\n return { ok: false, error: body?.error?.message ?? \"Refino did not accept the authorization.\" };\n }\n const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };\n storage()?.setItem(SESSION_KEY, JSON.stringify(session));\n return { ok: true, returnTo: pending.returnTo };\n}\n";
24
+ /** 0.1.0-rc.1 refino/copy-editing.tsx: before onEvent={reportEditorEvent}. */
25
+ export declare const COPY_EDITING_HOSTED_RC1 = "\"use client\";\n\nimport type { CopyContent } from \"@getrefino/core\";\nimport { RefinoProvider } from \"@getrefino/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport { clearEditorSession, editorEndpoint, readEditorSession } from \"./refino-client\";\nimport type { EditorSession } from \"./refino-client\";\n\ninterface CopyEditingProps {\n content: CopyContent;\n children: ReactNode;\n}\n\n/**\n * Hosted mode (Refino). Visitors get the plain site: the server never\n * decides edit mode, so pages can stay static. In the browser, an editor\n * session stored by /edit turns edit mode on; loads and saves go to Refino\n * with the session's bearer token, and Refino checks the site's\n * entitlement on every request.\n */\nexport function CopyEditing({ content, children }: CopyEditingProps) {\n const [session, setSession] = useState<EditorSession | null>(null);\n\n useEffect(() => {\n // Read the session only after mount so server-rendered HTML never differs from the visitor's.\n setSession(readEditorSession());\n }, []);\n\n // Read at request time (the provider keeps its options from the first render), so the\n // token is always the one currently stored and never sent once the session is cleared.\n const headers = useCallback((): Record<string, string> => {\n const current = readEditorSession();\n return current ? { authorization: `Bearer ${current.token}` } : {};\n }, []);\n\n const onExit = useCallback(async () => {\n clearEditorSession();\n window.location.assign(\"/\");\n }, []);\n\n return (\n <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref=\"/edit\" onExit={onExit}>\n {children}\n </RefinoProvider>\n );\n}\n";
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Byte-exact renderings of generated files as earlier Refino releases wrote
3
+ * them. They exist for one reason: a site onboarded before .refino/generated.json
4
+ * existed carries no record of what Refino wrote, so the only way to prove a
5
+ * generated file is untouched is to reproduce the old bytes and compare.
6
+ *
7
+ * Nothing here is ever written to a repository. It is read-only evidence.
8
+ *
9
+ * Maintenance: when a template body in templates.ts or templates-hosted.ts
10
+ * changes, copy the previous body here and list it in that file's `previous`
11
+ * renderings. `generated-templates.test.ts` fails until you do, because it
12
+ * pins the hash of every generated file.
13
+ */
14
+ /** The last release that wrote the bodies below. */
15
+ export const RC1 = "0.1.0-rc.1";
16
+ /** 0.1.0-rc.1 refino/refino-site.ts: the two public constants, before REFINO_INSTALL. */
17
+ export const REFINO_SITE_RC1 = (config) => `/**
18
+ * Public Refino configuration for this site. Neither value is a secret:
19
+ * the site id appears in editor URLs and the app URL is where owners sign
20
+ * in. Generated by Refino; change it only when the site is
21
+ * reconnected to Refino.
22
+ */
23
+ export const REFINO_SITE_ID = ${JSON.stringify(config.siteId)};
24
+ export const REFINO_APP_URL = ${JSON.stringify(config.appUrl)};
25
+ `;
26
+ /** 0.1.0-rc.1 refino/refino-client.ts: before reportEditorEvent and the install facts. */
27
+ export const REFINO_CLIENT_RC1 = `/**
28
+ * Browser-side Refino editor authorization for this site. No secrets here:
29
+ * the site is a public client (OAuth 2.1 with PKCE). The editor token lives
30
+ * in sessionStorage only (never localStorage, never a URL), is scoped to
31
+ * this site, and Refino re-checks the account's entitlement on every load
32
+ * and save. Never import this from server code.
33
+ */
34
+ import { REFINO_APP_URL, REFINO_SITE_ID } from "./refino-site";
35
+
36
+ const PKCE_KEY = "refino.pkce";
37
+ const SESSION_KEY = "refino.editor";
38
+ const CALLBACK_PATH = "/edit";
39
+
40
+ export interface EditorSession {
41
+ readonly token: string;
42
+ /** Unix seconds. */
43
+ readonly expiresAt: number;
44
+ readonly siteId: string;
45
+ }
46
+
47
+ interface PendingAuthorization {
48
+ readonly verifier: string;
49
+ readonly state: string;
50
+ readonly returnTo: string;
51
+ }
52
+
53
+ function base64url(bytes: Uint8Array): string {
54
+ let binary = "";
55
+ for (const byte of bytes) binary += String.fromCharCode(byte);
56
+ return btoa(binary).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
57
+ }
58
+
59
+ function randomToken(bytes: number): string {
60
+ const buffer = new Uint8Array(bytes);
61
+ crypto.getRandomValues(buffer);
62
+ return base64url(buffer);
63
+ }
64
+
65
+ async function sha256(text: string): Promise<string> {
66
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
67
+ return base64url(new Uint8Array(digest));
68
+ }
69
+
70
+ function storage(): Storage | null {
71
+ try {
72
+ return typeof window === "undefined" ? null : window.sessionStorage;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function readJson<T>(key: string): T | null {
79
+ const raw = storage()?.getItem(key);
80
+ if (!raw) return null;
81
+ try {
82
+ return JSON.parse(raw) as T;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** Only a path on this site, never another origin. */
89
+ export function safeReturnTo(value: string | null | undefined): string {
90
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\\\") || value.length > 512) return "/";
91
+ return value;
92
+ }
93
+
94
+ export function editorEndpoint(): string {
95
+ return \`\${REFINO_APP_URL}/api/sites/\${REFINO_SITE_ID}/copy\`;
96
+ }
97
+
98
+ export function callbackUrl(): string {
99
+ return \`\${window.location.origin}\${CALLBACK_PATH}\`;
100
+ }
101
+
102
+ /** The current editor session, or null when there is none or it expired. */
103
+ export function readEditorSession(nowSeconds: number = Math.floor(Date.now() / 1000)): EditorSession | null {
104
+ const session = readJson<EditorSession>(SESSION_KEY);
105
+ if (!session || typeof session.token !== "string" || session.siteId !== REFINO_SITE_ID || typeof session.expiresAt !== "number") return null;
106
+ if (session.expiresAt <= nowSeconds) {
107
+ storage()?.removeItem(SESSION_KEY);
108
+ return null;
109
+ }
110
+ return session;
111
+ }
112
+
113
+ export function clearEditorSession(): void {
114
+ storage()?.removeItem(SESSION_KEY);
115
+ storage()?.removeItem(PKCE_KEY);
116
+ }
117
+
118
+ /** Start the flow: remember the PKCE verifier and state, then go to Refino. */
119
+ export async function beginAuthorization(returnTo: string): Promise<void> {
120
+ const verifier = randomToken(32);
121
+ const state = randomToken(16);
122
+ const pending: PendingAuthorization = { verifier, state, returnTo: safeReturnTo(returnTo) };
123
+ storage()?.setItem(PKCE_KEY, JSON.stringify(pending));
124
+ const params = new URLSearchParams({
125
+ site_id: REFINO_SITE_ID,
126
+ redirect_uri: callbackUrl(),
127
+ code_challenge: await sha256(verifier),
128
+ code_challenge_method: "S256",
129
+ state,
130
+ });
131
+ window.location.assign(\`\${REFINO_APP_URL}/editor/authorize?\${params.toString()}\`);
132
+ }
133
+
134
+ export type AuthorizationResult = { readonly ok: true; readonly returnTo: string } | { readonly ok: false; readonly error: string };
135
+
136
+ /**
137
+ * Finish the flow on /edit?code&state: check the state, exchange the code
138
+ * with the verifier this browser kept, store the session, and scrub the
139
+ * code from the URL. The token is never placed in a URL.
140
+ */
141
+ export async function completeAuthorization(search: string = window.location.search): Promise<AuthorizationResult> {
142
+ const params = new URLSearchParams(search);
143
+ const code = params.get("code");
144
+ const state = params.get("state");
145
+ const pending = readJson<PendingAuthorization>(PKCE_KEY);
146
+ storage()?.removeItem(PKCE_KEY);
147
+ if (typeof window !== "undefined" && window.history.replaceState) window.history.replaceState(null, "", window.location.pathname);
148
+ if (!code || !state) return { ok: false, error: "Refino did not return an authorization code." };
149
+ if (!pending || pending.state !== state) return { ok: false, error: "Sign-in was interrupted (state mismatch). Start again." };
150
+
151
+ let response: Response;
152
+ try {
153
+ response = await fetch(\`\${REFINO_APP_URL}/api/editor/token\`, {
154
+ method: "POST",
155
+ mode: "cors",
156
+ credentials: "omit",
157
+ headers: { "content-type": "application/json", accept: "application/json" },
158
+ body: JSON.stringify({ grant_type: "authorization_code", code, code_verifier: pending.verifier, site_id: REFINO_SITE_ID, redirect_uri: callbackUrl() }),
159
+ });
160
+ } catch {
161
+ return { ok: false, error: "Could not reach Refino to finish signing in." };
162
+ }
163
+ const body = (await response.json().catch(() => null)) as { ok?: boolean; token?: string; expiresAt?: number; siteId?: string; error?: { message?: string } } | null;
164
+ if (!response.ok || !body?.ok || typeof body.token !== "string" || typeof body.expiresAt !== "number" || body.siteId !== REFINO_SITE_ID) {
165
+ return { ok: false, error: body?.error?.message ?? "Refino did not accept the authorization." };
166
+ }
167
+ const session: EditorSession = { token: body.token, expiresAt: body.expiresAt, siteId: body.siteId };
168
+ storage()?.setItem(SESSION_KEY, JSON.stringify(session));
169
+ return { ok: true, returnTo: pending.returnTo };
170
+ }
171
+ `;
172
+ /** 0.1.0-rc.1 refino/copy-editing.tsx: before onEvent={reportEditorEvent}. */
173
+ export const COPY_EDITING_HOSTED_RC1 = `"use client";
174
+
175
+ import type { CopyContent } from "@getrefino/core";
176
+ import { RefinoProvider } from "@getrefino/react";
177
+ import type { ReactNode } from "react";
178
+ import { useCallback, useEffect, useState } from "react";
179
+
180
+ import { clearEditorSession, editorEndpoint, readEditorSession } from "./refino-client";
181
+ import type { EditorSession } from "./refino-client";
182
+
183
+ interface CopyEditingProps {
184
+ content: CopyContent;
185
+ children: ReactNode;
186
+ }
187
+
188
+ /**
189
+ * Hosted mode (Refino). Visitors get the plain site: the server never
190
+ * decides edit mode, so pages can stay static. In the browser, an editor
191
+ * session stored by /edit turns edit mode on; loads and saves go to Refino
192
+ * with the session's bearer token, and Refino checks the site's
193
+ * entitlement on every request.
194
+ */
195
+ export function CopyEditing({ content, children }: CopyEditingProps) {
196
+ const [session, setSession] = useState<EditorSession | null>(null);
197
+
198
+ useEffect(() => {
199
+ // Read the session only after mount so server-rendered HTML never differs from the visitor's.
200
+ setSession(readEditorSession());
201
+ }, []);
202
+
203
+ // Read at request time (the provider keeps its options from the first render), so the
204
+ // token is always the one currently stored and never sent once the session is cleared.
205
+ const headers = useCallback((): Record<string, string> => {
206
+ const current = readEditorSession();
207
+ return current ? { authorization: \`Bearer \${current.token}\` } : {};
208
+ }, []);
209
+
210
+ const onExit = useCallback(async () => {
211
+ clearEditorSession();
212
+ window.location.assign("/");
213
+ }, []);
214
+
215
+ return (
216
+ <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit}>
217
+ {children}
218
+ </RefinoProvider>
219
+ );
220
+ }
221
+ `;
@@ -1,8 +1,6 @@
1
+ import type { TemplateFile } from "./template-file.js";
1
2
  import type { MigrationPlan } from "./types.js";
2
- export interface TemplateFile {
3
- readonly path: string;
4
- readonly content: string;
5
- }
3
+ export type { TemplateFile, TemplateRendering } from "./template-file.js";
6
4
  export declare const ENV_EXAMPLE_BLOCK = "\n# --- Refino (server-side only; never expose with NEXT_PUBLIC_/VITE_) ---\n# Edit-mode login. Both required in production; development falls back to password \"edit\".\nEDITOR_PASSWORD=\nEDITOR_SESSION_SECRET=\n# Persistence: \"local\" writes the copy file on disk (development), \"github\" commits it.\nCOPY_ADAPTER=local\n# GitHub persistence (COPY_ADAPTER=github): fine-grained token, Contents read/write, one repo.\nCOPY_GITHUB_TOKEN=\nCOPY_GITHUB_REPO=owner/name\nCOPY_GITHUB_BRANCH=main\nCOPY_FILE_PATH=\n";
7
5
  /** Files the apply step may create for a plan, in TS or JS as the project requires. */
8
6
  export declare function boilerplateFiles(plan: MigrationPlan): TemplateFile[];
package/dist/templates.js CHANGED
@@ -1,11 +1,17 @@
1
1
  /**
2
- * Boilerplate written by `refino init --apply`. Each file is isolated
3
- * under `refino/` (or is a new route file) and is only written when it
4
- * does not already exist. Authored in TypeScript; JavaScript projects get a
5
- * transpiled copy.
2
+ * Boilerplate written by `refino init --agent`. Each file is isolated under
3
+ * `refino/` (or is a new route file). Authored in TypeScript; JavaScript
4
+ * projects get a transpiled copy.
5
+ *
6
+ * A file that already exists is refreshed only when Refino can prove the
7
+ * bytes on disk are still the ones it wrote (generated.ts); otherwise it is
8
+ * left exactly as it is and reported. Each entry therefore declares who owns
9
+ * it -- `machine` for Refino's runtime code, `scaffold` for a page the owner
10
+ * is invited to restyle -- and, when its body has changed since a released
11
+ * version, the byte-exact body that version wrote.
6
12
  */
7
- import ts from "typescript";
8
13
  import { BOILERPLATE_DIR } from "./plan.js";
14
+ import { createTemplateFactory } from "./template-file.js";
9
15
  import { hostedFiles } from "./templates-hosted.js";
10
16
  const BOILERPLATE_DIR_NAME = BOILERPLATE_DIR;
11
17
  function relativeImport(fromFile, toFile) {
@@ -601,71 +607,56 @@ COPY_GITHUB_REPO=owner/name
601
607
  COPY_GITHUB_BRANCH=main
602
608
  COPY_FILE_PATH=
603
609
  `;
604
- function transpileToJs(path, source) {
605
- const output = ts.transpileModule(source, {
606
- compilerOptions: {
607
- target: ts.ScriptTarget.ES2022,
608
- module: ts.ModuleKind.ESNext,
609
- jsx: ts.JsxEmit.Preserve,
610
- removeComments: false,
611
- verbatimModuleSyntax: false,
612
- },
613
- fileName: path,
614
- });
615
- return { path: path.replace(/\.tsx$/, ".jsx").replace(/\.ts$/, ".js"), content: output.outputText.replace(/^export \{\};\s*$/m, "").replace(/\n{3,}/g, "\n\n") };
616
- }
617
610
  /** Files the apply step may create for a plan, in TS or JS as the project requires. */
618
611
  export function boilerplateFiles(plan) {
619
- if (plan.refino) {
620
- const files = hostedFiles(plan);
621
- return plan.repository.language === "javascript" ? files.map((file) => transpileToJs(file.path, file.content)) : files;
622
- }
612
+ const make = createTemplateFactory(plan.repository.language);
613
+ if (plan.refino)
614
+ return hostedFiles(plan, make);
623
615
  const dir = plan.integration.boilerplateDir;
624
616
  const strategy = plan.integration.provider.strategy;
625
617
  const host = plan.integration.host;
626
618
  // Server-rendered hosts pass `editing` from a cookie check; everything else probes the session in the browser.
627
619
  const serverRenderedEditing = strategy === "next-app-root-layout" || strategy === "next-pages-app";
628
620
  const handlers = `${dir}/request-handlers.ts`;
621
+ // The self-hosted templates below are byte-identical to every release so
622
+ // far, so none of them carries a `previous` rendering yet.
629
623
  const files = [
630
- { path: `${dir}/editor-auth.ts`, content: EDITOR_AUTH },
631
- { path: `${dir}/content-adapter.ts`, content: CONTENT_ADAPTER(plan.contentFile.path) },
632
- { path: handlers, content: REQUEST_HANDLERS },
633
- { path: `${dir}/copy-editing.tsx`, content: serverRenderedEditing ? COPY_EDITING : COPY_EDITING_CLIENT_SESSION },
624
+ make.machine(`${dir}/editor-auth.ts`, EDITOR_AUTH),
625
+ make.machine(`${dir}/content-adapter.ts`, CONTENT_ADAPTER(plan.contentFile.path)),
626
+ make.machine(handlers, REQUEST_HANDLERS),
627
+ make.machine(`${dir}/copy-editing.tsx`, serverRenderedEditing ? COPY_EDITING : COPY_EDITING_CLIENT_SESSION),
634
628
  ];
635
629
  if (strategy === "next-app-root-layout-client-session") {
636
630
  const routesDir = plan.files.find((file) => /\/edit\/page\.\w+$/.test(file.path))?.path.replace(/\/edit\/page\.\w+$/, "") ?? "app";
637
- files.push({ path: `${routesDir}/edit/layout.tsx`, content: EDIT_LAYOUT_STATIC });
638
- files.push({ path: `${routesDir}/edit/page.tsx`, content: EDIT_PAGE_STATIC });
631
+ files.push(make.scaffold(`${routesDir}/edit/layout.tsx`, EDIT_LAYOUT_STATIC));
632
+ files.push(make.scaffold(`${routesDir}/edit/page.tsx`, EDIT_PAGE_STATIC));
639
633
  }
640
634
  const copyFile = host.copyEndpoint.replace(/\.\w+$/, ".ts");
641
635
  const sessionFile = host.sessionEndpoint.replace(/\.\w+$/, ".ts");
642
636
  switch (host.kind) {
643
637
  case "next-route-handlers": {
644
638
  const editPage = `${copyFile.replace(/\/api\/copy\/route\.ts$/, "")}/edit/page.tsx`;
645
- files.push({ path: `${dir}/editor-session.ts`, content: EDITOR_SESSION_NEXT });
646
- files.push({ path: copyFile, content: COPY_ROUTE_NEXT(relativeImport(copyFile, handlers)) });
647
- files.push({ path: sessionFile, content: SESSION_ROUTE_NEXT(relativeImport(sessionFile, handlers)) });
648
- files.push({ path: editPage, content: EDIT_PAGE_NEXT(relativeImport(editPage, `${dir}/editor-session.ts`)) });
639
+ files.push(make.machine(`${dir}/editor-session.ts`, EDITOR_SESSION_NEXT));
640
+ files.push(make.machine(copyFile, COPY_ROUTE_NEXT(relativeImport(copyFile, handlers))));
641
+ files.push(make.machine(sessionFile, SESSION_ROUTE_NEXT(relativeImport(sessionFile, handlers))));
642
+ files.push(make.scaffold(editPage, EDIT_PAGE_NEXT(relativeImport(editPage, `${dir}/editor-session.ts`))));
649
643
  break;
650
644
  }
651
645
  case "cloudflare-pages-functions":
652
- files.push({ path: copyFile, content: CLOUDFLARE_FUNCTION("copy", relativeImport(copyFile, handlers)) });
653
- files.push({ path: sessionFile, content: CLOUDFLARE_FUNCTION("edit-session", relativeImport(sessionFile, handlers)) });
646
+ files.push(make.machine(copyFile, CLOUDFLARE_FUNCTION("copy", relativeImport(copyFile, handlers))));
647
+ files.push(make.machine(sessionFile, CLOUDFLARE_FUNCTION("edit-session", relativeImport(sessionFile, handlers))));
654
648
  break;
655
649
  case "netlify-functions":
656
- files.push({ path: copyFile, content: NETLIFY_FUNCTION("copy", relativeImport(copyFile, handlers)) });
657
- files.push({ path: sessionFile, content: NETLIFY_FUNCTION("edit-session", relativeImport(sessionFile, handlers)) });
650
+ files.push(make.machine(copyFile, NETLIFY_FUNCTION("copy", relativeImport(copyFile, handlers))));
651
+ files.push(make.machine(sessionFile, NETLIFY_FUNCTION("edit-session", relativeImport(sessionFile, handlers))));
658
652
  break;
659
653
  case "vercel-functions":
660
- files.push({ path: copyFile, content: VERCEL_FUNCTION("copy", relativeImport(copyFile, handlers)) });
661
- files.push({ path: sessionFile, content: VERCEL_FUNCTION("edit-session", relativeImport(sessionFile, handlers)) });
654
+ files.push(make.machine(copyFile, VERCEL_FUNCTION("copy", relativeImport(copyFile, handlers))));
655
+ files.push(make.machine(sessionFile, VERCEL_FUNCTION("edit-session", relativeImport(sessionFile, handlers))));
662
656
  break;
663
657
  default:
664
658
  // next-api-routes, custom-server, unknown: the agent adapts request-handlers (see the instructions).
665
659
  break;
666
660
  }
667
- if (plan.repository.language === "javascript") {
668
- return files.map((file) => transpileToJs(file.path, file.content));
669
- }
670
661
  return files;
671
662
  }
package/dist/types.d.ts CHANGED
@@ -143,6 +143,68 @@ export interface HostIntegration {
143
143
  /** Runtime facts the agent and the owner need (filesystem, compatibility flags, variables). */
144
144
  readonly notes: readonly string[];
145
145
  }
146
+ /**
147
+ * Who owns a file Refino generated.
148
+ *
149
+ * `machine` Refino's own runtime code. It carries behaviour the editor and
150
+ * the hosted service depend on, holds nothing a site is meant to
151
+ * hand-write, and Refino refreshes it whenever it can prove the
152
+ * bytes on disk are still the ones it wrote.
153
+ * `scaffold` A generated starting point the owner is invited to restyle (the
154
+ * /edit pages). Refino refreshes an untouched one and never
155
+ * argues with a restyled one.
156
+ *
157
+ * Everything Refino merges rather than owns -- the copy file, package.json,
158
+ * .env.example, refino.config.json -- is customer-owned and is not in this
159
+ * vocabulary at all: it is never overwritten, only merged.
160
+ */
161
+ export type GeneratedOwnership = "machine" | "scaffold";
162
+ /**
163
+ * What became of one generated file on an init run, or what verification
164
+ * found there.
165
+ *
166
+ * `create` it did not exist; Refino wrote it.
167
+ * `current` the bytes on disk are exactly what this version generates.
168
+ * `update` stale, and provably untouched since Refino wrote it, so it was
169
+ * (or can be) refreshed in place.
170
+ * `customized` changed locally, and Refino's own version of it has not moved
171
+ * since: nothing to upgrade, nothing to warn about.
172
+ * `review` changed locally *and* stale, or impossible to prove untouched.
173
+ * Never overwritten; always reported.
174
+ */
175
+ export type GeneratedFileStatus = "create" | "current" | "update" | "customized" | "review";
176
+ /** One generated file, its ownership, and where its bytes came from. */
177
+ export interface GeneratedFileState {
178
+ readonly path: string;
179
+ readonly ownership: GeneratedOwnership;
180
+ readonly status: GeneratedFileStatus;
181
+ /** Refino version whose output the bytes on disk match, when that is knowable. */
182
+ readonly from: string | null;
183
+ /** Refino version generating the desired bytes. */
184
+ readonly to: string;
185
+ /** Why this status, in one sentence. Always set for `review`. */
186
+ readonly reason: string;
187
+ /**
188
+ * For `review`: the repository-relative file Refino wrote the current
189
+ * template to, beside the original, so the difference can be read and
190
+ * merged by hand. The original is never touched.
191
+ */
192
+ readonly comparisonPath?: string;
193
+ }
194
+ /** `.refino/generated.json`: what Refino wrote, so a later run can tell. */
195
+ export interface GeneratedManifest {
196
+ readonly version: 1;
197
+ /** Refino version that last wrote this manifest. */
198
+ readonly tool: string;
199
+ readonly files: Readonly<Record<string, GeneratedManifestEntry>>;
200
+ }
201
+ export interface GeneratedManifestEntry {
202
+ readonly ownership: GeneratedOwnership;
203
+ /** Refino version that wrote these bytes. */
204
+ readonly toolVersion: string;
205
+ /** sha256, hex, of the exact bytes Refino wrote. */
206
+ readonly sha256: string;
207
+ }
146
208
  export interface PlannedFile {
147
209
  readonly path: string;
148
210
  readonly action: "create" | "merge" | "append" | "modify";
@@ -272,6 +334,19 @@ export interface ApplyResult {
272
334
  readonly reason: string;
273
335
  }[];
274
336
  readonly installCommand: string | null;
337
+ /**
338
+ * Every file Refino generates, and what this run did with it. `written`
339
+ * and `skipped` stay what they always were (the files this run touched or
340
+ * left alone); this is the upgrade story, and the only place a stale or
341
+ * locally modified generated file is visible.
342
+ */
343
+ readonly generated: readonly GeneratedFileState[];
344
+ /**
345
+ * True when a generated file was left in place although Refino's version
346
+ * of it has moved on. An installation with this set is not current, and no
347
+ * caller may report it as one.
348
+ */
349
+ readonly needsReview: boolean;
275
350
  }
276
351
  export type CheckStatus = "pass" | "fail" | "warn" | "skip";
277
352
  export interface VerificationCheck {