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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,8 +29,16 @@ holds no credential — the deployed site's `/edit` flow is what checks that, an
29
29
  says so when the id is stale.
30
30
 
31
31
  All results are plain JSON. No network calls, no model calls. It never rewrites
32
- JSX. It overwrites no existing file except the generated Refino constants
33
- module and the `refino` block of `refino.config.json`, and those only when a
34
- plan carries a different site id — how a reconnected site is re-recorded.
32
+ JSX, and it never overwrites a file the customer owns: the copy file,
33
+ `package.json` and `.env.example` are merged, never replaced.
34
+
35
+ Files Refino itself generates are different, because upgrading them is the
36
+ point. `applyPlan` refreshes one only when it can prove the bytes on disk are
37
+ still the ones Refino wrote — the sha256 recorded in `.refino/generated.json`,
38
+ or the byte-exact output of a released template for sites installed before that
39
+ file existed. A generated file with local changes is never overwritten: it is
40
+ returned in `ApplyResult.generated` with status `review`, `needsReview` is
41
+ true, and the current template is written beside it as `<file>.refino-new`.
42
+ `verifyIntegration` reports the same three states without changing anything.
35
43
 
36
44
  <https://refino.dev>
package/dist/agent.d.ts CHANGED
@@ -1,7 +1,3 @@
1
- /**
2
- * Render repository-specific instructions for a coding agent from a plan.
3
- * Every fact in the output comes from the plan; nothing is generic filler.
4
- */
5
1
  import { INSTRUCTIONS_FILE } from "./plan.js";
6
2
  import type { MigrationPlan } from "./types.js";
7
3
  export declare function renderAgentInstructions(plan: MigrationPlan): string;
package/dist/agent.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Render repository-specific instructions for a coding agent from a plan.
3
3
  * Every fact in the output comes from the plan; nothing is generic filler.
4
4
  */
5
+ import { GENERATED_MANIFEST_FILE } from "./generated.js";
5
6
  import { BOILERPLATE_DIR, CONFIG_FILE, INSTRUCTIONS_FILE, PLAN_FILE } from "./plan.js";
6
7
  function bullet(items) {
7
8
  return items.map((item) => `- ${item}`).join("\n");
@@ -198,7 +199,7 @@ ${bullet([
198
199
  "Edit mode must come from an authenticated server check of the session: either a server-rendered `editing` flag or the generated wrapper's `GET /api/edit-session` probe. Never from `?edit=1`, localStorage or a hard-coded `true`.",
199
200
  "Keep the existing deployment setup. Persistence defaults to the local file in development; production uses `COPY_ADAPTER=github` with the variables documented in `.env.example`.",
200
201
  ]),
201
- `Do not edit \`${CONFIG_FILE}\`, \`${PLAN_FILE}\` or this file.`,
202
+ `Do not edit \`${CONFIG_FILE}\`, \`${PLAN_FILE}\`, \`${GENERATED_MANIFEST_FILE}\` or this file. They are the tool's record of what it did; \`${GENERATED_MANIFEST_FILE}\` in particular is how a later \`init\` tells a generated file nobody touched from one somebody did, so commit it with the rest.`,
202
203
  ])}
203
204
 
204
205
  ## Report format
package/dist/apply.js CHANGED
@@ -1,16 +1,24 @@
1
1
  /**
2
- * Safe automatic changes. Everything here is additive and deterministic:
3
- * new files are written only if absent, the copy file is merged (existing
4
- * values never change), package.json gains dependencies, .env.example
5
- * gains a documented block. No JSX is rewritten.
2
+ * Safe automatic changes. Everything here is deterministic, and nothing the
3
+ * customer owns is ever overwritten: the copy file is merged (existing values
4
+ * never change), package.json gains dependencies, .env.example gains a
5
+ * documented block, and no JSX is rewritten.
6
+ *
7
+ * Refino's own generated files are different, and deliberately so. They used
8
+ * to be skipped whenever they existed, which is why a site that upgraded its
9
+ * `@getrefino/*` packages kept the previous release's generated runtime code
10
+ * and was still told the install had succeeded. They are now refreshed
11
+ * whenever Refino can prove the bytes on disk are the ones it wrote, left
12
+ * untouched when it cannot, and reported either way (generated.ts).
6
13
  */
7
14
  import { mkdirSync, writeFileSync } from "node:fs";
8
15
  import { dirname, join } from "node:path";
9
16
  import { parseCopy, serializeCopy } from "@getrefino/core";
10
17
  import { renderAgentInstructions } from "./agent.js";
11
18
  import { exists, readJson, readText } from "./fs.js";
19
+ import { GENERATED_MANIFEST_FILE, classifyGeneratedFiles, needsReview, nextGeneratedManifest, readGeneratedManifest, serializeGeneratedManifest, } from "./generated.js";
12
20
  import { packageManagerInstall } from "./inspect.js";
13
- import { CONFIG_FILE, INSTRUCTIONS_FILE, PLAN_FILE, dependencyRange, planContent } from "./plan.js";
21
+ import { CONFIG_FILE, INSTRUCTIONS_FILE, PLAN_FILE, TOOL_VERSION, dependencyRange, planContent } from "./plan.js";
14
22
  import { ENV_EXAMPLE_BLOCK, boilerplateFiles } from "./templates.js";
15
23
  function writeFile(root, relativePath, content, dryRun) {
16
24
  if (dryRun)
@@ -19,6 +27,19 @@ function writeFile(root, relativePath, content, dryRun) {
19
27
  mkdirSync(dirname(absolute), { recursive: true });
20
28
  writeFileSync(absolute, content, "utf8");
21
29
  }
30
+ /**
31
+ * Why a generated file was rewritten. The site id matters as much as the
32
+ * version here: `--refino-site <id>` on a reconnected site is the only record
33
+ * the repository has of which Refino site it is, and a reader of the output
34
+ * needs to see that it moved.
35
+ */
36
+ function describeUpdate(plan, state) {
37
+ const version = state.from && state.from !== state.to ? `${state.from} → ${state.to}` : `refreshed to ${state.to}`;
38
+ if (plan.refino && /(^|\/)refino-site\.[cm]?[jt]sx?$/.test(state.path)) {
39
+ return `${version}; names Refino site ${plan.refino.siteId} at ${plan.refino.appUrl}.`;
40
+ }
41
+ return `${version}; it was unchanged since Refino wrote it.`;
42
+ }
22
43
  export function applyPlan(appDir, plan, options = {}) {
23
44
  const dryRun = options.dryRun ?? false;
24
45
  const written = [];
@@ -147,35 +168,51 @@ export function applyPlan(appDir, plan, options = {}) {
147
168
  else {
148
169
  skipped.push({ path: ".env.example", reason: "Already documents Refino variables." });
149
170
  }
150
- // 6. Boilerplate: only files that do not exist. The one exception is the
151
- // generated Refino constants module: it holds no hand-written code, only the
152
- // public site id and app URL, and a site reconnected in the dashboard has a
153
- // new id. Keeping the old one there silently breaks editing, and `init
154
- // --refino-site <id>` is the documented way to record the new one, so the
155
- // stale copy is rewritten instead of skipped.
156
- const site = plan.refino;
157
- const siteModule = site ? `${plan.integration.boilerplateDir}/refino-site` : null;
158
- for (const file of boilerplateFiles(plan)) {
159
- const absolute = join(appDir, file.path);
160
- if (exists(absolute)) {
161
- const isSiteModule = site !== null && siteModule !== null && file.path.replace(/\.[cm]?[jt]sx?$/, "") === siteModule;
162
- const currentText = isSiteModule ? (readText(absolute) ?? "") : "";
163
- const namesThisSite = isSiteModule && currentText.includes(JSON.stringify(site.siteId)) && currentText.includes(JSON.stringify(site.appUrl));
164
- if (!isSiteModule || namesThisSite) {
165
- skipped.push({ path: file.path, reason: "Exists; not overwritten." });
166
- continue;
167
- }
168
- const previous = /REFINO_SITE_ID\s*=\s*"(site_[a-f0-9]{32})"/.exec(currentText)?.[1];
169
- writeFile(appDir, file.path, file.content, dryRun);
170
- written.push({
171
- path: file.path,
172
- action: "modify",
173
- description: `Now names Refino site ${site.siteId} at ${site.appUrl}${previous && previous !== site.siteId ? ` (was ${previous})` : ""}.`,
174
- });
175
- continue;
171
+ // 6. Generated boilerplate. Three outcomes, and which one applies is a
172
+ // fact about the bytes, never a guess:
173
+ //
174
+ // absent, or provably still Refino's -> written (created or refreshed)
175
+ // already this version's output -> skipped, nothing to do
176
+ // edited since Refino wrote it -> left alone, reported, and the
177
+ // current template dropped beside
178
+ // it so the difference is readable
179
+ //
180
+ // The manifest that makes the middle case decidable is written last, so a
181
+ // preserved file keeps the record of the version Refino *did* write and
182
+ // stays detectable on every later run.
183
+ const templates = boilerplateFiles(plan);
184
+ const manifest = readGeneratedManifest(appDir);
185
+ const generated = classifyGeneratedFiles(appDir, templates, manifest);
186
+ const byPath = new Map(templates.map((file) => [file.path, file]));
187
+ for (const state of generated) {
188
+ const template = byPath.get(state.path);
189
+ switch (state.status) {
190
+ case "create":
191
+ writeFile(appDir, state.path, template.content, dryRun);
192
+ written.push({ path: state.path, action: "create", description: "Generated boilerplate." });
193
+ break;
194
+ case "update":
195
+ writeFile(appDir, state.path, template.content, dryRun);
196
+ written.push({ path: state.path, action: "modify", description: describeUpdate(plan, state) });
197
+ break;
198
+ case "current":
199
+ skipped.push({ path: state.path, reason: `Already the ${state.to} version.` });
200
+ break;
201
+ case "customized":
202
+ skipped.push({ path: state.path, reason: state.reason });
203
+ break;
204
+ case "review":
205
+ // Never overwritten. The comparison copy carries the extension of the
206
+ // real file plus a suffix, so nothing compiles, lints or scans it.
207
+ writeFile(appDir, state.comparisonPath, template.content, dryRun);
208
+ skipped.push({ path: state.path, reason: `${state.reason} Refino's ${state.to} version is in ${state.comparisonPath} for comparison.` });
209
+ break;
176
210
  }
177
- writeFile(appDir, file.path, file.content, dryRun);
178
- written.push({ path: file.path, action: "create", description: "Generated boilerplate." });
179
211
  }
180
- return { dryRun, written, skipped, installCommand };
212
+ // 7. The record of what was generated, so the next run can tell an
213
+ // untouched file from an edited one without reproducing old templates.
214
+ const updatedManifest = nextGeneratedManifest(templates, generated, manifest, TOOL_VERSION);
215
+ writeFile(appDir, GENERATED_MANIFEST_FILE, serializeGeneratedManifest(updatedManifest), dryRun);
216
+ written.push({ path: GENERATED_MANIFEST_FILE, action: manifest ? "modify" : "create", description: "What Refino generated, so an upgrade can tell untouched files from edited ones." });
217
+ return { dryRun, written, skipped, installCommand, generated, needsReview: needsReview(generated) };
181
218
  }
@@ -0,0 +1,30 @@
1
+ import type { TemplateFile } from "./templates.js";
2
+ import type { GeneratedFileState, GeneratedManifest } from "./types.js";
3
+ /** The repository's record of what Refino generated. Machine-owned, committed. */
4
+ export declare const GENERATED_MANIFEST_FILE = ".refino/generated.json";
5
+ /**
6
+ * Suffix for the copy of the current template Refino leaves beside a file it
7
+ * refused to overwrite. It goes *after* the real extension on purpose: a
8
+ * `.tsx.refino-new` file is not compiled, not linted and not scanned, so it
9
+ * cannot break a build while it waits to be read.
10
+ */
11
+ export declare const COMPARISON_SUFFIX = ".refino-new";
12
+ export declare function hashContent(text: string): string;
13
+ export declare function readGeneratedManifest(appDir: string): GeneratedManifest | null;
14
+ export declare function serializeGeneratedManifest(manifest: GeneratedManifest): string;
15
+ /**
16
+ * Classify one generated file against the repository. Read-only: deciding and
17
+ * writing are separate so `verify` can ask the same question `init` asks
18
+ * without touching anything.
19
+ */
20
+ export declare function classifyGeneratedFile(appDir: string, file: TemplateFile, manifest: GeneratedManifest | null): GeneratedFileState;
21
+ export declare function classifyGeneratedFiles(appDir: string, files: readonly TemplateFile[], manifest: GeneratedManifest | null): GeneratedFileState[];
22
+ /** A file whose bytes Refino is leaving behind although its template moved on. */
23
+ export declare function needsReview(states: readonly GeneratedFileState[]): boolean;
24
+ /**
25
+ * The manifest a run produces: what it wrote now, plus the entries for files
26
+ * it did not write. A preserved file keeps the record of the version Refino
27
+ * *did* write, which is exactly what makes its modification detectable on the
28
+ * next run.
29
+ */
30
+ export declare function nextGeneratedManifest(files: readonly TemplateFile[], states: readonly GeneratedFileState[], previous: GeneratedManifest | null, toolVersion: string): GeneratedManifest;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Ownership and versioning of the files Refino generates.
3
+ *
4
+ * The problem this solves: `init` used to skip every file that already
5
+ * existed, so a site that upgraded its `@getrefino/*` dependencies kept the
6
+ * previous release's generated runtime code and was told the install had
7
+ * succeeded. Package versions cannot answer the question -- installing a
8
+ * package does not refresh a file that was copied into the repository months
9
+ * earlier -- so the repository has to carry its own record.
10
+ *
11
+ * That record is `.refino/generated.json`: for every file Refino wrote, the
12
+ * sha256 of the exact bytes it wrote and the version that wrote them. With
13
+ * it, three states are decidable without guessing:
14
+ *
15
+ * on disk == generated now -> current, leave it alone
16
+ * on disk == what Refino wrote -> stale but untouched, refresh it
17
+ * anything else -> the owner's file, never overwritten
18
+ *
19
+ * Sites installed before the manifest existed have no record, so each
20
+ * template also carries the byte-exact renderings earlier releases produced
21
+ * (`previous`, from templates-legacy.ts). Reproducing those bytes is proof of
22
+ * the same kind: a file that still matches a released rendering has not been
23
+ * edited, whatever the manifest does or does not say.
24
+ *
25
+ * Nothing here ever writes over a file it cannot prove is Refino's.
26
+ */
27
+ import { createHash } from "node:crypto";
28
+ import { join } from "node:path";
29
+ import { exists, readJson, readText } from "./fs.js";
30
+ import { PLAN_DIR } from "./plan.js";
31
+ /** The repository's record of what Refino generated. Machine-owned, committed. */
32
+ export const GENERATED_MANIFEST_FILE = `${PLAN_DIR}/generated.json`;
33
+ /**
34
+ * Suffix for the copy of the current template Refino leaves beside a file it
35
+ * refused to overwrite. It goes *after* the real extension on purpose: a
36
+ * `.tsx.refino-new` file is not compiled, not linted and not scanned, so it
37
+ * cannot break a build while it waits to be read.
38
+ */
39
+ export const COMPARISON_SUFFIX = ".refino-new";
40
+ export function hashContent(text) {
41
+ return createHash("sha256").update(text, "utf8").digest("hex");
42
+ }
43
+ export function readGeneratedManifest(appDir) {
44
+ const manifest = readJson(join(appDir, GENERATED_MANIFEST_FILE));
45
+ if (!manifest || manifest.version !== 1 || typeof manifest.files !== "object" || manifest.files === null)
46
+ return null;
47
+ return manifest;
48
+ }
49
+ export function serializeGeneratedManifest(manifest) {
50
+ const files = Object.fromEntries(Object.keys(manifest.files).sort().map((path) => [path, manifest.files[path]]));
51
+ return `${JSON.stringify({ ...manifest, files }, null, 2)}\n`;
52
+ }
53
+ /**
54
+ * Which release's output the bytes on disk are, or null when they are not any
55
+ * release's output. `file.recognize` exists for templates whose bytes depend
56
+ * on the site (the Refino constants module carries the site id): it reads the
57
+ * per-site values back out of the file and re-renders, so "unmodified" stays
58
+ * a byte comparison rather than a resemblance.
59
+ */
60
+ function matchRendering(file, current) {
61
+ if (current === file.content)
62
+ return { version: file.version };
63
+ for (const rendering of file.previous ?? []) {
64
+ if (current === rendering.content)
65
+ return { version: rendering.version };
66
+ }
67
+ return file.recognize?.(current) ?? null;
68
+ }
69
+ /** Was `sha` written by a rendering whose bytes are still what this version generates? */
70
+ function renderingIsCurrent(file, sha) {
71
+ if (sha === hashContent(file.content))
72
+ return true;
73
+ for (const rendering of file.previous ?? []) {
74
+ if (sha === hashContent(rendering.content))
75
+ return false;
76
+ }
77
+ return null;
78
+ }
79
+ /**
80
+ * Classify one generated file against the repository. Read-only: deciding and
81
+ * writing are separate so `verify` can ask the same question `init` asks
82
+ * without touching anything.
83
+ */
84
+ export function classifyGeneratedFile(appDir, file, manifest) {
85
+ const to = file.version;
86
+ const base = { path: file.path, ownership: file.ownership, to };
87
+ const absolute = join(appDir, file.path);
88
+ if (!exists(absolute)) {
89
+ return { ...base, status: "create", from: null, reason: "Not present; generated by this run." };
90
+ }
91
+ const current = readText(absolute) ?? "";
92
+ const recorded = manifest?.files[file.path];
93
+ if (current === file.content) {
94
+ return { ...base, status: "current", from: to, reason: `Already the ${to} version.` };
95
+ }
96
+ // Untouched since Refino wrote it: the manifest says so, or the bytes still
97
+ // reproduce a released rendering. Either is proof; neither is a heuristic.
98
+ if (recorded && hashContent(current) === recorded.sha256) {
99
+ return { ...base, status: "update", from: recorded.toolVersion, reason: `Unchanged since Refino ${recorded.toolVersion} wrote it.` };
100
+ }
101
+ const rendering = matchRendering(file, current);
102
+ if (rendering) {
103
+ return { ...base, status: "update", from: rendering.version, reason: `Byte-identical to the ${rendering.version} template.` };
104
+ }
105
+ // The file has been edited. Whether that matters depends on whether
106
+ // Refino's own version of it moved since: a customized file generated from
107
+ // a template that has not changed is simply the owner's file, and saying
108
+ // anything about it would be noise.
109
+ if (recorded) {
110
+ const stillCurrent = renderingIsCurrent(file, recorded.sha256);
111
+ if (stillCurrent === true) {
112
+ return { ...base, status: "customized", from: recorded.toolVersion, reason: `Edited since Refino ${recorded.toolVersion} wrote it; that template has not changed since.` };
113
+ }
114
+ if (stillCurrent === false) {
115
+ return {
116
+ ...base,
117
+ status: "review",
118
+ from: recorded.toolVersion,
119
+ reason: `Edited since Refino ${recorded.toolVersion} wrote it, and Refino's version has changed (${recorded.toolVersion} → ${to}). Refreshing it would discard those edits.`,
120
+ comparisonPath: `${file.path}${COMPARISON_SUFFIX}`,
121
+ };
122
+ }
123
+ return {
124
+ ...base,
125
+ status: "review",
126
+ from: recorded.toolVersion,
127
+ reason: `Neither the bytes on disk nor the bytes Refino ${recorded.toolVersion} recorded match any template this version knows, so it cannot be proved safe to refresh.`,
128
+ comparisonPath: `${file.path}${COMPARISON_SUFFIX}`,
129
+ };
130
+ }
131
+ return {
132
+ ...base,
133
+ status: "review",
134
+ from: null,
135
+ reason: `Differs from every Refino template and predates ${GENERATED_MANIFEST_FILE}, so it cannot be proved untouched.`,
136
+ comparisonPath: `${file.path}${COMPARISON_SUFFIX}`,
137
+ };
138
+ }
139
+ export function classifyGeneratedFiles(appDir, files, manifest) {
140
+ return files.map((file) => classifyGeneratedFile(appDir, file, manifest));
141
+ }
142
+ /** A file whose bytes Refino is leaving behind although its template moved on. */
143
+ export function needsReview(states) {
144
+ return states.some((state) => state.status === "review");
145
+ }
146
+ /**
147
+ * The manifest a run produces: what it wrote now, plus the entries for files
148
+ * it did not write. A preserved file keeps the record of the version Refino
149
+ * *did* write, which is exactly what makes its modification detectable on the
150
+ * next run.
151
+ */
152
+ export function nextGeneratedManifest(files, states, previous, toolVersion) {
153
+ const byPath = new Map(files.map((file) => [file.path, file]));
154
+ const entries = {};
155
+ for (const state of states) {
156
+ const file = byPath.get(state.path);
157
+ if (!file)
158
+ continue;
159
+ if (state.status === "create" || state.status === "update" || state.status === "current") {
160
+ entries[state.path] = { ownership: file.ownership, toolVersion: file.version, sha256: hashContent(file.content) };
161
+ continue;
162
+ }
163
+ const recorded = previous?.files[state.path];
164
+ if (recorded)
165
+ entries[state.path] = recorded;
166
+ }
167
+ return { version: 1, tool: toolVersion, files: entries };
168
+ }
package/dist/index.d.ts CHANGED
@@ -8,11 +8,12 @@ export { renderAgentInstructions } from "./agent.js";
8
8
  export { applyPlan } from "./apply.js";
9
9
  export type { ApplyOptions, RefinoConfig } from "./apply.js";
10
10
  export { boilerplateFiles, ENV_EXAMPLE_BLOCK } from "./templates.js";
11
- export type { TemplateFile } from "./templates.js";
11
+ export type { TemplateFile, TemplateRendering } from "./templates.js";
12
+ export { COMPARISON_SUFFIX, GENERATED_MANIFEST_FILE, classifyGeneratedFile, classifyGeneratedFiles, hashContent, needsReview, nextGeneratedManifest, readGeneratedManifest, serializeGeneratedManifest, } from "./generated.js";
12
13
  export { INSTALL_METHODS, isInstallMethod, resolveInstallMethod } from "./install-method.js";
13
14
  export type { InstallMethod } from "./install-method.js";
14
15
  export { DEFAULT_REFINO_APP_URL, REFINO_SITE_ID_PATTERN, analyticsFramework, analyticsHosting, hostedFiles } from "./templates-hosted.js";
15
16
  export type { RefinoSiteConfig } from "./templates-hosted.js";
16
17
  export { verifyIntegration } from "./verify.js";
17
18
  export type { VerifyOptions } from "./verify.js";
18
- export type { ApplyResult, CandidateStatus, CheckStatus, CopyCandidate, CopyClassification, DetectedPackage, Framework, GitState, HostIntegration, HostKind, Language, MigrationPlan, PackageManager, PlanOptions, PlannedFile, RefinoSite, RepositoryInspection, RouteInfo, Router, ScanResult, ScriptSet, VerificationCheck, VerificationResult, } from "./types.js";
19
+ export type { ApplyResult, CandidateStatus, CheckStatus, CopyCandidate, CopyClassification, DetectedPackage, Framework, GeneratedFileState, GeneratedFileStatus, GeneratedManifest, GeneratedManifestEntry, GeneratedOwnership, GitState, HostIntegration, HostKind, Language, MigrationPlan, PackageManager, PlanOptions, PlannedFile, RefinoSite, RepositoryInspection, RouteInfo, Router, ScanResult, ScriptSet, VerificationCheck, VerificationResult, } from "./types.js";
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { buildPlan, planMigration, planContent, defaultContentFile, integrationP
5
5
  export { renderAgentInstructions } from "./agent.js";
6
6
  export { applyPlan } from "./apply.js";
7
7
  export { boilerplateFiles, ENV_EXAMPLE_BLOCK } from "./templates.js";
8
+ export { COMPARISON_SUFFIX, GENERATED_MANIFEST_FILE, classifyGeneratedFile, classifyGeneratedFiles, hashContent, needsReview, nextGeneratedManifest, readGeneratedManifest, serializeGeneratedManifest, } from "./generated.js";
8
9
  export { INSTALL_METHODS, isInstallMethod, resolveInstallMethod } from "./install-method.js";
9
10
  export { DEFAULT_REFINO_APP_URL, REFINO_SITE_ID_PATTERN, analyticsFramework, analyticsHosting, hostedFiles } from "./templates-hosted.js";
10
11
  export { verifyIntegration } from "./verify.js";
@@ -0,0 +1,38 @@
1
+ import type { GeneratedOwnership, Language } from "./types.js";
2
+ export interface TemplateRendering {
3
+ /** Refino version that wrote these bytes. */
4
+ readonly version: string;
5
+ readonly content: string;
6
+ }
7
+ export interface TemplateFile {
8
+ readonly path: string;
9
+ readonly content: string;
10
+ readonly ownership: GeneratedOwnership;
11
+ /** Refino version that generates `content`. */
12
+ readonly version: string;
13
+ /** Byte-exact bodies earlier releases wrote for this path, newest first. */
14
+ readonly previous?: readonly TemplateRendering[];
15
+ /**
16
+ * For templates whose bytes depend on the site (the Refino constants module
17
+ * carries the site id and the install facts): read those values back out of
18
+ * the file, re-render, and report which release's output the file is -- or
19
+ * null when it is nobody's output. Never a resemblance test.
20
+ */
21
+ readonly recognize?: (current: string) => TemplateRendering | null;
22
+ }
23
+ /** As above, but handed the language-appropriate renderer for candidate bytes. */
24
+ export type TemplateRecognizer = (current: string, render: (source: string) => string) => TemplateRendering | null;
25
+ export interface TemplateExtras {
26
+ readonly previous?: readonly TemplateRendering[];
27
+ readonly recognize?: TemplateRecognizer;
28
+ }
29
+ /**
30
+ * Builds the generated files for one project. Everything a caller needs to
31
+ * say is the ownership class; the version stamp and the language emit are the
32
+ * same for every file and are applied here.
33
+ */
34
+ export interface TemplateFactory {
35
+ machine(path: string, content: string, extras?: TemplateExtras): TemplateFile;
36
+ scaffold(path: string, content: string, extras?: TemplateExtras): TemplateFile;
37
+ }
38
+ export declare function createTemplateFactory(language: Language): TemplateFactory;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * How a generated file is described: its bytes, who owns it, the Refino
3
+ * version that produced it, and enough history to recognise the bytes an
4
+ * earlier release would have written.
5
+ *
6
+ * Templates are authored once in TypeScript and emitted per project, so the
7
+ * language choice is made here rather than after the fact: a JavaScript
8
+ * project's history and recognizers are transpiled with exactly the same
9
+ * function as its current content, which is what keeps "these bytes are an
10
+ * earlier Refino's output" a byte comparison in both languages.
11
+ */
12
+ import ts from "typescript";
13
+ import { TOOL_VERSION } from "./plan.js";
14
+ function transpile(path, source) {
15
+ const output = ts.transpileModule(source, {
16
+ compilerOptions: {
17
+ target: ts.ScriptTarget.ES2022,
18
+ module: ts.ModuleKind.ESNext,
19
+ jsx: ts.JsxEmit.Preserve,
20
+ removeComments: false,
21
+ verbatimModuleSyntax: false,
22
+ },
23
+ fileName: path,
24
+ });
25
+ return output.outputText.replace(/^export \{\};\s*$/m, "").replace(/\n{3,}/g, "\n\n");
26
+ }
27
+ function jsPath(path) {
28
+ return path.replace(/\.tsx$/, ".jsx").replace(/\.ts$/, ".js");
29
+ }
30
+ export function createTemplateFactory(language) {
31
+ const javascript = language === "javascript";
32
+ const build = (ownership) => (path, content, extras = {}) => {
33
+ const render = (source) => (javascript ? transpile(path, source) : source);
34
+ return {
35
+ path: javascript ? jsPath(path) : path,
36
+ content: render(content),
37
+ ownership,
38
+ version: TOOL_VERSION,
39
+ ...(extras.previous ? { previous: extras.previous.map((rendering) => ({ version: rendering.version, content: render(rendering.content) })) } : {}),
40
+ ...(extras.recognize ? { recognize: (current) => extras.recognize(current, render) } : {}),
41
+ };
42
+ };
43
+ return { machine: build("machine"), scaffold: build("scaffold") };
44
+ }
@@ -1,17 +1,6 @@
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
1
  import type { InstallMethod } from "./install-method.js";
2
+ import type { TemplateFactory, TemplateFile } from "./template-file.js";
13
3
  import type { MigrationPlan } from "./types.js";
14
- import type { TemplateFile } from "./templates.js";
15
4
  export declare const DEFAULT_REFINO_APP_URL = "https://app.refino.dev";
16
5
  export interface RefinoSiteConfig {
17
6
  readonly siteId: string;
@@ -46,4 +35,4 @@ export declare const EDIT_LAYOUT_HOSTED = "import type { Metadata } from \"next\
46
35
  export declare function analyticsFramework(framework: MigrationPlan["repository"]["framework"]): string;
47
36
  export declare function analyticsHosting(hosting: string | null): string;
48
37
  /** Files for a Refino-connected site. No server code, no secrets, in any framework. */
49
- export declare function hostedFiles(plan: MigrationPlan): TemplateFile[];
38
+ export declare function hostedFiles(plan: MigrationPlan, factory?: TemplateFactory): TemplateFile[];
@@ -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 {
package/dist/verify.js CHANGED
@@ -8,9 +8,12 @@ import { join } from "node:path";
8
8
  import { isContentId, parseCopy } from "@getrefino/core";
9
9
  import ts from "typescript";
10
10
  import { exists, isFile, isTestOrStoryFile, readJson, readText, rel, walkFiles } from "./fs.js";
11
+ import { COMPARISON_SUFFIX, classifyGeneratedFiles, readGeneratedManifest } from "./generated.js";
11
12
  import { parseSource, readPathAliases, reachableFiles, resolveImport } from "./graph.js";
12
13
  import { LEGACY_CONFIG_FILE_NAME, appDirectory, inspectRepository } from "./inspect.js";
13
- import { BOILERPLATE_DIR, CONFIG_FILE, verificationCommands } from "./plan.js";
14
+ import { isInstallMethod } from "./install-method.js";
15
+ import { BOILERPLATE_DIR, CONFIG_FILE, TOOL_VERSION, buildPlan, verificationCommands } from "./plan.js";
16
+ import { boilerplateFiles } from "./templates.js";
14
17
  const SECRET_NAMES = ["COPY_GITHUB_TOKEN", "EDITOR_SESSION_SECRET", "EDITOR_PASSWORD"];
15
18
  const SERVER_ONLY_IMPORTS = ["@getrefino/github", "@getrefino/core/local-file"];
16
19
  function collectEditableTextUses(appDir, files) {
@@ -189,6 +192,74 @@ function hostedChecks(appDir, inspection, hosted, sourceFiles, rendersProvider,
189
192
  add({ id: "hosted-provider", status: "fail", message: `${file} decides edit mode on the server; a Refino-hosted site decides it in the browser.`, fix: `Render <CopyEditing content={copy}> from ${BOILERPLATE_DIR}/copy-editing without an editing prop and without next/headers.` });
190
193
  }
191
194
  }
195
+ /**
196
+ * Is the Refino runtime code in this repository the code this Refino
197
+ * generates?
198
+ *
199
+ * Package versions cannot answer that. Upgrading `@getrefino/*` replaces what
200
+ * `node_modules` holds and leaves every file Refino copied into the
201
+ * repository exactly where it was, which is how a site ran rc.2 packages
202
+ * against rc.1 generated code and passed verification. So the files
203
+ * themselves are regenerated in memory and compared, and the answer is one of
204
+ * three: current, stale but provably untouched (one command fixes it), or
205
+ * stale and locally edited (a person has to look).
206
+ *
207
+ * Read-only, like the rest of verification: nothing here writes.
208
+ */
209
+ function generatedChecks(appDir, inspection, contentFile, hosted, installMethod, add) {
210
+ let states;
211
+ try {
212
+ const plan = buildPlan(inspection, { filesScanned: [], candidates: [] }, {
213
+ contentFile,
214
+ ...(hosted ? { refino: { siteId: hosted.siteId, appUrl: hosted.appUrl, ...(isInstallMethod(installMethod) ? { installMethod } : {}) } } : {}),
215
+ });
216
+ states = classifyGeneratedFiles(appDir, boilerplateFiles(plan), readGeneratedManifest(appDir));
217
+ }
218
+ catch {
219
+ // A repository this build cannot plan for has no generated files to judge.
220
+ return;
221
+ }
222
+ const present = states.filter((state) => state.status !== "create");
223
+ if (present.length === 0)
224
+ return;
225
+ const upgrade = hosted
226
+ ? `npx @getrefino/cli@${TOOL_VERSION} init --agent --yes --refino-site ${hosted.siteId}`
227
+ : `npx @getrefino/cli@${TOOL_VERSION} init --agent --yes`;
228
+ const stale = present.filter((state) => state.status === "update");
229
+ if (stale.length > 0) {
230
+ add({
231
+ id: "generated-stale",
232
+ status: "fail",
233
+ message: `${stale.length} generated Refino runtime file(s) are from an earlier release, although the installed packages are not.`,
234
+ details: stale.map((state) => `${state.path} ${state.from ?? "unknown"} → ${state.to}`),
235
+ fix: `Run \`${upgrade}\`. Each file listed is byte-identical to what Refino wrote, so it is refreshed in place; nothing you wrote is touched.`,
236
+ });
237
+ }
238
+ const review = present.filter((state) => state.status === "review");
239
+ if (review.length > 0) {
240
+ // A restyled /edit page is a normal thing to own; Refino's own runtime
241
+ // modules are not, and a stale one of those is a real problem.
242
+ const machine = review.some((state) => state.ownership === "machine");
243
+ add({
244
+ id: "generated-review",
245
+ status: machine ? "fail" : "warn",
246
+ message: `${review.length} generated Refino file(s) have local changes and are from an earlier release, so Refino will not refresh them.`,
247
+ details: review.map((state) => `${state.path} ${state.reason}`),
248
+ fix: `Run \`${upgrade}\`: it leaves these files alone and writes Refino's current version beside each one as \`<file>${COMPARISON_SUFFIX}\`. Move your changes onto that version, replace the original with it, delete the \`${COMPARISON_SUFFIX}\` file, then run init again.`,
249
+ });
250
+ }
251
+ if (stale.length === 0 && review.length === 0) {
252
+ const customized = present.filter((state) => state.status === "customized");
253
+ add({
254
+ id: "generated-current",
255
+ status: "pass",
256
+ message: `Generated Refino runtime files are the ${TOOL_VERSION} ones (${present.length} file(s)).`,
257
+ ...(customized.length > 0
258
+ ? { details: customized.map((state) => `${state.path} has local changes, and Refino's version of it has not changed since — nothing to upgrade.`) }
259
+ : {}),
260
+ });
261
+ }
262
+ }
192
263
  export function verifyIntegration(rootPath, options = {}) {
193
264
  const inspection = inspectRepository(rootPath, options);
194
265
  const appDir = appDirectory(inspection);
@@ -399,6 +470,8 @@ export function verifyIntegration(rootPath, options = {}) {
399
470
  add({ id: "env-example", status: "pass", message: ".env.example documents the Refino variables." });
400
471
  else
401
472
  add({ id: "env-example", status: "warn", message: ".env.example does not document EDITOR_*/COPY_* variables.", fix: "Run `refino init --agent` or add the block by hand." });
473
+ // Generated runtime files: current, refreshable, or the owner's now.
474
+ generatedChecks(appDir, inspection, contentFile, hosted, config?.refino?.installMethod, add);
402
475
  // Scripts (plus a tsc fallback when a TypeScript project has no typecheck script).
403
476
  const commands = [];
404
477
  for (const command of verificationCommands(inspection)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrefino/onboarding",
3
- "version": "0.1.0-rc.2",
3
+ "version": "0.1.0-rc.3",
4
4
  "description": "The engine behind `npx @getrefino/cli init`: deterministic repository inspection, copy discovery, migration planning, agent instructions and verification for adding Refino to an existing React site. Development tooling only; never part of a site's runtime.",
5
5
  "keywords": [
6
6
  "refino",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "typescript": ">=5.5 <7",
41
- "@getrefino/core": "0.1.0-rc.2"
41
+ "@getrefino/core": "0.1.0-rc.3"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "22.20.2",