@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 +11 -3
- package/dist/agent.d.ts +0 -4
- package/dist/agent.js +2 -1
- package/dist/apply.js +71 -34
- package/dist/generated.d.ts +30 -0
- package/dist/generated.js +168 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -0
- package/dist/template-file.d.ts +38 -0
- package/dist/template-file.js +44 -0
- package/dist/templates-hosted.d.ts +2 -13
- package/dist/templates-hosted.js +65 -16
- package/dist/templates-legacy.d.ts +25 -0
- package/dist/templates-legacy.js +221 -0
- package/dist/templates.d.ts +2 -4
- package/dist/templates.js +32 -41
- package/dist/types.d.ts +75 -0
- package/dist/verify.js +74 -1
- package/package.json +2 -2
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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.
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
path:
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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[];
|
package/dist/templates-hosted.js
CHANGED
|
@@ -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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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(
|
|
425
|
-
files.push(
|
|
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(
|
|
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
|
+
`;
|
package/dist/templates.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
+
import type { TemplateFile } from "./template-file.js";
|
|
1
2
|
import type { MigrationPlan } from "./types.js";
|
|
2
|
-
export
|
|
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 --
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
return plan
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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(
|
|
638
|
-
files.push(
|
|
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(
|
|
646
|
-
files.push(
|
|
647
|
-
files.push(
|
|
648
|
-
files.push(
|
|
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(
|
|
653
|
-
files.push(
|
|
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(
|
|
657
|
-
files.push(
|
|
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(
|
|
661
|
-
files.push(
|
|
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 {
|
|
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.
|
|
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.
|
|
41
|
+
"@getrefino/core": "0.1.0-rc.3"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/node": "22.20.2",
|