@getrefino/onboarding 0.1.0-rc.1
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/LICENSE +21 -0
- package/README.md +28 -0
- package/dist/agent.d.ts +8 -0
- package/dist/agent.js +219 -0
- package/dist/apply.d.ts +23 -0
- package/dist/apply.js +142 -0
- package/dist/dataflow.d.ts +96 -0
- package/dist/dataflow.js +333 -0
- package/dist/fs.d.ts +20 -0
- package/dist/fs.js +131 -0
- package/dist/graph.d.ts +45 -0
- package/dist/graph.js +299 -0
- package/dist/ids.d.ts +20 -0
- package/dist/ids.js +367 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/inspect.d.ts +19 -0
- package/dist/inspect.js +471 -0
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +447 -0
- package/dist/scan.d.ts +67 -0
- package/dist/scan.js +1486 -0
- package/dist/templates-hosted.d.ts +34 -0
- package/dist/templates-hosted.js +332 -0
- package/dist/templates.d.ts +8 -0
- package/dist/templates.js +671 -0
- package/dist/types.d.ts +286 -0
- package/dist/types.js +5 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.js +403 -0
- package/package.json +54 -0
package/dist/verify.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify an integrated site without modifying it. Produces a list of
|
|
3
|
+
* checks with fixes an agent can act on, and optionally runs the
|
|
4
|
+
* repository's own verification scripts.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { isContentId, parseCopy } from "@getrefino/core";
|
|
9
|
+
import ts from "typescript";
|
|
10
|
+
import { exists, isFile, isTestOrStoryFile, readJson, readText, rel, walkFiles } from "./fs.js";
|
|
11
|
+
import { parseSource, readPathAliases, reachableFiles, resolveImport } from "./graph.js";
|
|
12
|
+
import { LEGACY_CONFIG_FILE_NAME, appDirectory, inspectRepository } from "./inspect.js";
|
|
13
|
+
import { BOILERPLATE_DIR, CONFIG_FILE, verificationCommands } from "./plan.js";
|
|
14
|
+
const SECRET_NAMES = ["COPY_GITHUB_TOKEN", "EDITOR_SESSION_SECRET", "EDITOR_PASSWORD"];
|
|
15
|
+
const SERVER_ONLY_IMPORTS = ["@getrefino/github", "@getrefino/core/local-file"];
|
|
16
|
+
function collectEditableTextUses(appDir, files) {
|
|
17
|
+
const uses = [];
|
|
18
|
+
for (const file of files) {
|
|
19
|
+
const text = readText(file);
|
|
20
|
+
if (!text || !text.includes("EditableText"))
|
|
21
|
+
continue;
|
|
22
|
+
const source = parseSource(file, text);
|
|
23
|
+
const visit = (node) => {
|
|
24
|
+
if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && ts.isIdentifier(node.tagName) && node.tagName.text === "EditableText") {
|
|
25
|
+
const { line } = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
26
|
+
const attr = node.attributes.properties.find((prop) => ts.isJsxAttribute(prop) && ts.isIdentifier(prop.name) && prop.name.text === "id");
|
|
27
|
+
let id = null;
|
|
28
|
+
let pattern = null;
|
|
29
|
+
if (attr?.initializer) {
|
|
30
|
+
if (ts.isStringLiteral(attr.initializer))
|
|
31
|
+
id = attr.initializer.text;
|
|
32
|
+
else if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
|
|
33
|
+
const expression = attr.initializer.expression;
|
|
34
|
+
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression))
|
|
35
|
+
id = expression.text;
|
|
36
|
+
else if (ts.isTemplateExpression(expression)) {
|
|
37
|
+
const HOLE = "__REFINO_HOLE__";
|
|
38
|
+
const literal = expression.head.text + expression.templateSpans.map((span) => `${HOLE}${span.literal.text}`).join("");
|
|
39
|
+
pattern = new RegExp(`^${literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").split(HOLE).join("[A-Za-z0-9_-]+")}$`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
uses.push({ file: rel(appDir, file), line: line + 1, id, pattern });
|
|
44
|
+
}
|
|
45
|
+
ts.forEachChild(node, visit);
|
|
46
|
+
};
|
|
47
|
+
visit(source);
|
|
48
|
+
}
|
|
49
|
+
return uses;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Components a provider file renders outside its <CopyEditing>/<RefinoProvider>
|
|
53
|
+
* whose render tree contains EditableText: their ids would show up as visible text.
|
|
54
|
+
*/
|
|
55
|
+
function uncoveredComponents(appDir, providerFile, editableFiles) {
|
|
56
|
+
const absolute = join(appDir, providerFile);
|
|
57
|
+
const text = readText(absolute);
|
|
58
|
+
if (!text)
|
|
59
|
+
return [];
|
|
60
|
+
const source = parseSource(absolute, text);
|
|
61
|
+
const providerRanges = [];
|
|
62
|
+
const outsideTags = new Map();
|
|
63
|
+
const isProviderTag = (node) => ts.isIdentifier(node.tagName) && /^(CopyEditing|RefinoProvider)$/.test(node.tagName.text);
|
|
64
|
+
const collectProviders = (node) => {
|
|
65
|
+
if (ts.isJsxElement(node) && isProviderTag(node.openingElement))
|
|
66
|
+
providerRanges.push([node.getStart(source), node.getEnd()]);
|
|
67
|
+
ts.forEachChild(node, collectProviders);
|
|
68
|
+
};
|
|
69
|
+
collectProviders(source);
|
|
70
|
+
if (providerRanges.length === 0)
|
|
71
|
+
return [];
|
|
72
|
+
const collectOutside = (node) => {
|
|
73
|
+
if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && ts.isIdentifier(node.tagName) && /^[A-Z]/.test(node.tagName.text) && !isProviderTag(node)) {
|
|
74
|
+
const start = node.getStart(source);
|
|
75
|
+
if (!providerRanges.some(([from, to]) => start >= from && start < to))
|
|
76
|
+
outsideTags.set(node.tagName.text, true);
|
|
77
|
+
}
|
|
78
|
+
ts.forEachChild(node, collectOutside);
|
|
79
|
+
};
|
|
80
|
+
collectOutside(source);
|
|
81
|
+
if (outsideTags.size === 0)
|
|
82
|
+
return [];
|
|
83
|
+
const aliases = readPathAliases(appDir);
|
|
84
|
+
const importedFrom = new Map();
|
|
85
|
+
for (const statement of source.statements) {
|
|
86
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.importClause)
|
|
87
|
+
continue;
|
|
88
|
+
const resolved = resolveImport(absolute, statement.moduleSpecifier.text, appDir, aliases);
|
|
89
|
+
if (!resolved)
|
|
90
|
+
continue;
|
|
91
|
+
if (statement.importClause.name)
|
|
92
|
+
importedFrom.set(statement.importClause.name.text, resolved);
|
|
93
|
+
const bindings = statement.importClause.namedBindings;
|
|
94
|
+
if (bindings && ts.isNamedImports(bindings))
|
|
95
|
+
for (const element of bindings.elements)
|
|
96
|
+
importedFrom.set(element.name.text, resolved);
|
|
97
|
+
}
|
|
98
|
+
const cache = new Map();
|
|
99
|
+
const problems = [];
|
|
100
|
+
for (const tag of outsideTags.keys()) {
|
|
101
|
+
const file = importedFrom.get(tag);
|
|
102
|
+
if (!file)
|
|
103
|
+
continue;
|
|
104
|
+
const reached = reachableFiles(file, appDir, aliases, cache, { renderTreeOnly: true });
|
|
105
|
+
const hit = [...reached].find((candidate) => editableFiles.has(candidate));
|
|
106
|
+
if (hit)
|
|
107
|
+
problems.push(`<${tag} /> in ${providerFile} renders EditableText (${rel(appDir, hit)}) but sits outside the provider`);
|
|
108
|
+
}
|
|
109
|
+
return problems;
|
|
110
|
+
}
|
|
111
|
+
function duplicateKeys(text) {
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
const duplicates = new Set();
|
|
114
|
+
for (const match of text.matchAll(/^\s*"((?:[^"\\]|\\.)*)"\s*:/gm)) {
|
|
115
|
+
const key = match[1];
|
|
116
|
+
if (seen.has(key))
|
|
117
|
+
duplicates.add(key);
|
|
118
|
+
seen.add(key);
|
|
119
|
+
}
|
|
120
|
+
return [...duplicates];
|
|
121
|
+
}
|
|
122
|
+
function isClientFile(text, framework) {
|
|
123
|
+
if (/^\s*["']use client["']/.test(text))
|
|
124
|
+
return true;
|
|
125
|
+
// Outside Next.js every component file is a browser file.
|
|
126
|
+
return framework !== "next" && /from ["']react["']/.test(text);
|
|
127
|
+
}
|
|
128
|
+
const SELF_HOSTED_MODULES = ["request-handlers", "editor-auth", "content-adapter", "editor-session"];
|
|
129
|
+
const SELF_HOSTED_ENDPOINTS = ["app/api/copy/route", "src/app/api/copy/route", "app/api/edit-session/route", "src/app/api/edit-session/route", "functions/api/copy", "functions/api/edit-session", "api/copy", "api/edit-session", "netlify/functions/copy", "netlify/functions/edit-session", "pages/api/copy", "pages/api/edit-session"];
|
|
130
|
+
const SITE_SECRET_NAMES = ["EDITOR_PASSWORD", "EDITOR_SESSION_SECRET", "COPY_GITHUB_TOKEN", "COPY_ADAPTER"];
|
|
131
|
+
/**
|
|
132
|
+
* A Refino-hosted site must hold the public constants and the browser
|
|
133
|
+
* client, and nothing of the self-hosted path: no endpoint files, no
|
|
134
|
+
* adapter, no secrets, no server-side edit decision.
|
|
135
|
+
*/
|
|
136
|
+
function hostedChecks(appDir, inspection, hosted, sourceFiles, rendersProvider, add) {
|
|
137
|
+
const relFiles = sourceFiles.map((file) => rel(appDir, file));
|
|
138
|
+
const find = (base) => relFiles.find((file) => new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.(tsx?|jsx?)$`).test(file));
|
|
139
|
+
const siteFile = find(`${BOILERPLATE_DIR}/refino-site`);
|
|
140
|
+
const siteText = siteFile ? (readText(join(appDir, siteFile)) ?? "") : "";
|
|
141
|
+
if (!siteFile)
|
|
142
|
+
add({ id: "refino-site", status: "fail", message: `No ${BOILERPLATE_DIR}/refino-site module with the public Refino constants.`, fix: `Run \`refino init --agent --refino-site ${hosted.siteId}\` to generate it.` });
|
|
143
|
+
else if (!siteText.includes(JSON.stringify(hosted.siteId)) || !siteText.includes(JSON.stringify(hosted.appUrl)))
|
|
144
|
+
add({ id: "refino-site", status: "fail", message: `${siteFile} does not name site ${hosted.siteId} at ${hosted.appUrl} as ${CONFIG_FILE} does.`, fix: "Regenerate the module or fix the config so both agree." });
|
|
145
|
+
else
|
|
146
|
+
add({ id: "refino-site", status: "pass", message: `Connected to Refino site ${hosted.siteId} at ${hosted.appUrl}.` });
|
|
147
|
+
const missing = ["refino-client", "copy-editing", "edit-page"].filter((name) => !find(`${BOILERPLATE_DIR}/${name}`));
|
|
148
|
+
if (missing.length > 0)
|
|
149
|
+
add({ id: "refino-client", status: "fail", message: `Missing generated hosted modules: ${missing.map((name) => `${BOILERPLATE_DIR}/${name}`).join(", ")}.`, fix: "Run `refino init --agent --refino-site …` again; existing files are kept." });
|
|
150
|
+
else
|
|
151
|
+
add({ id: "refino-client", status: "pass", message: "Browser-side Refino client, provider wrapper and /edit page are present." });
|
|
152
|
+
const editRoute = inspection.router === "next-app" ? find(`${inspection.routesDir ?? "app"}/edit/page`) : inspection.router === "next-pages" ? find(`${inspection.routesDir ?? "pages"}/edit`) : relFiles.find((file) => !file.startsWith(`${BOILERPLATE_DIR}/`) && /from ["'][^"']*edit-page["']/.test(readText(join(appDir, file)) ?? ""));
|
|
153
|
+
if (editRoute)
|
|
154
|
+
add({ id: "edit-route", status: "pass", message: `/edit is served by ${editRoute}.` });
|
|
155
|
+
else
|
|
156
|
+
add({ id: "edit-route", status: "fail", message: "Nothing serves /edit, the owner's entry point for Refino sign-in.", fix: inspection.router === "next-app" || inspection.router === "next-pages" ? "Run `refino init --agent --refino-site …` to generate the /edit route." : `Add a route for /edit rendering the default export of ${BOILERPLATE_DIR}/edit-page.` });
|
|
157
|
+
const leftovers = [...SELF_HOSTED_MODULES.map((name) => find(`${BOILERPLATE_DIR}/${name}`)), ...SELF_HOSTED_ENDPOINTS.map((base) => find(base))].filter((file) => Boolean(file));
|
|
158
|
+
if (leftovers.length > 0)
|
|
159
|
+
add({ id: "hosted-leftovers", status: "fail", message: "Self-hosted endpoint or auth files exist although Refino hosts the copy API.", details: leftovers, fix: "Delete them: a Refino-hosted site runs no editor endpoints and holds no secrets." });
|
|
160
|
+
else
|
|
161
|
+
add({ id: "hosted-leftovers", status: "pass", message: "No self-hosted endpoints or auth modules remain." });
|
|
162
|
+
const mentions = [];
|
|
163
|
+
for (const file of relFiles) {
|
|
164
|
+
const text = readText(join(appDir, file)) ?? "";
|
|
165
|
+
for (const name of SITE_SECRET_NAMES)
|
|
166
|
+
if (text.includes(name))
|
|
167
|
+
mentions.push(`${file} mentions ${name}`);
|
|
168
|
+
}
|
|
169
|
+
if (mentions.length > 0)
|
|
170
|
+
add({ id: "hosted-secrets", status: "fail", message: "The site references editor or repository secrets that hosted mode must not use.", details: mentions, fix: "Remove them; Refino holds the credential for this site." });
|
|
171
|
+
else
|
|
172
|
+
add({ id: "hosted-secrets", status: "pass", message: "No editor password, session secret, repository token or copy adapter in the site." });
|
|
173
|
+
for (const file of rendersProvider) {
|
|
174
|
+
const text = readText(join(appDir, file)) ?? "";
|
|
175
|
+
if (/from ["']next\/headers["']/.test(text) || /editor-session["']/.test(text) || /editing=\{/.test(text))
|
|
176
|
+
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.` });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export function verifyIntegration(rootPath, options = {}) {
|
|
180
|
+
const inspection = inspectRepository(rootPath, options);
|
|
181
|
+
const appDir = appDirectory(inspection);
|
|
182
|
+
const checks = [];
|
|
183
|
+
const add = (check) => checks.push(check);
|
|
184
|
+
// A site onboarded before the Refino rename still has the legacy config file.
|
|
185
|
+
const config = readJson(join(appDir, CONFIG_FILE)) ?? readJson(join(appDir, LEGACY_CONFIG_FILE_NAME));
|
|
186
|
+
const contentFile = options.contentFile ?? config?.contentFile ?? inspection.refino.contentFile ?? "content/copy.json";
|
|
187
|
+
const hosted = typeof config?.refino?.siteId === "string" && typeof config.refino.appUrl === "string" ? { siteId: config.refino.siteId, appUrl: config.refino.appUrl } : null;
|
|
188
|
+
// Packages
|
|
189
|
+
const installed = inspection.refino.packages.map((pkg) => pkg.name);
|
|
190
|
+
const legacyInstalled = installed.filter((name) => name.startsWith("@inline-copy/"));
|
|
191
|
+
const missingPackages = ["@getrefino/core", "@getrefino/react"].filter((name) => !installed.includes(name));
|
|
192
|
+
if (missingPackages.length > 0 && legacyInstalled.length > 0) {
|
|
193
|
+
add({
|
|
194
|
+
id: "packages",
|
|
195
|
+
status: "fail",
|
|
196
|
+
message: `This site still depends on the pre-rename packages (${legacyInstalled.join(", ")}).`,
|
|
197
|
+
fix: "Replace every @inline-copy/* dependency and import with the matching @getrefino/* package, then install.",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
else if (missingPackages.length > 0) {
|
|
201
|
+
add({ id: "packages", status: "fail", message: `Missing dependencies: ${missingPackages.join(", ")}.`, fix: `Add them to package.json dependencies and install.` });
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const versions = inspection.refino.packages.map((pkg) => pkg.version.replace(/^[\^~]/, "").split(".")[0]);
|
|
205
|
+
const majors = new Set(versions);
|
|
206
|
+
if (majors.size > 1)
|
|
207
|
+
add({ id: "packages", status: "warn", message: `@getrefino packages have different major versions (${inspection.refino.packages.map((p) => `${p.name}@${p.version}`).join(", ")}).`, fix: "Align them to the same version." });
|
|
208
|
+
else
|
|
209
|
+
add({ id: "packages", status: "pass", message: `Installed: ${inspection.refino.packages.map((p) => `${p.name}@${p.version}`).join(", ")}.` });
|
|
210
|
+
if (!exists(join(appDir, "node_modules", "@getrefino", "core")))
|
|
211
|
+
add({ id: "packages-installed", status: "warn", message: "node_modules does not contain @getrefino/core yet.", fix: "Run the package manager install." });
|
|
212
|
+
}
|
|
213
|
+
// Content file
|
|
214
|
+
const contentPath = join(appDir, contentFile);
|
|
215
|
+
let content = null;
|
|
216
|
+
const contentText = readText(contentPath);
|
|
217
|
+
if (contentText === null) {
|
|
218
|
+
add({ id: "content-file", status: "fail", message: `Canonical copy file not found at ${contentFile}.`, fix: "Create it (a flat JSON object of id -> string) or set contentFile in refino.config.json." });
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
try {
|
|
222
|
+
content = { ...parseCopy(contentText) };
|
|
223
|
+
add({ id: "content-file", status: "pass", message: `${contentFile} parses; ${Object.keys(content).length} ids.` });
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
add({ id: "content-file", status: "fail", message: `${contentFile} is invalid: ${error.message}`, fix: "Make it a flat JSON object whose keys are dotted ids and values are plain strings." });
|
|
227
|
+
}
|
|
228
|
+
const duplicates = duplicateKeys(contentText);
|
|
229
|
+
if (duplicates.length > 0)
|
|
230
|
+
add({ id: "content-duplicates", status: "fail", message: `Duplicate ids in ${contentFile}: ${duplicates.join(", ")}.`, fix: "Keep one entry per id." });
|
|
231
|
+
else
|
|
232
|
+
add({ id: "content-duplicates", status: "pass", message: "No duplicate ids." });
|
|
233
|
+
if (content) {
|
|
234
|
+
const invalid = Object.keys(content).filter((id) => !isContentId(id));
|
|
235
|
+
if (invalid.length > 0)
|
|
236
|
+
add({ id: "content-ids", status: "fail", message: `Invalid ids: ${invalid.join(", ")}.`, fix: "Use dotted segments of letters, digits, - and _ only." });
|
|
237
|
+
else
|
|
238
|
+
add({ id: "content-ids", status: "pass", message: "All ids are valid." });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Source scan
|
|
242
|
+
const sourceFiles = walkFiles(appDir).filter((file) => !isTestOrStoryFile(rel(appDir, file)));
|
|
243
|
+
const uses = collectEditableTextUses(appDir, sourceFiles);
|
|
244
|
+
if (uses.length === 0) {
|
|
245
|
+
add({ id: "editable-text", status: "fail", message: "No <EditableText> usages found.", fix: "Replace the selected strings with <EditableText id=\"…\" as=\"tag\" /> as described in .refino/AGENT_INSTRUCTIONS.md." });
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
add({ id: "editable-text", status: "pass", message: `${uses.length} <EditableText> usage(s) in ${new Set(uses.map((use) => use.file)).size} file(s).` });
|
|
249
|
+
const withoutId = uses.filter((use) => !use.id && !use.pattern);
|
|
250
|
+
if (withoutId.length > 0)
|
|
251
|
+
add({ id: "editable-text-ids", status: "warn", message: `${withoutId.length} <EditableText> usage(s) have a non-literal id and cannot be checked statically.`, details: withoutId.map((use) => `${use.file}:${use.line}`) });
|
|
252
|
+
if (content) {
|
|
253
|
+
const ids = new Set(Object.keys(content));
|
|
254
|
+
const missing = uses.filter((use) => use.id && !ids.has(use.id));
|
|
255
|
+
if (missing.length > 0)
|
|
256
|
+
add({ id: "missing-ids", status: "fail", message: `${missing.length} EditableText id(s) are not in ${contentFile}.`, details: missing.map((use) => `${use.id} (${use.file}:${use.line})`), fix: "Add the id to the copy file with the original text, or fix the id in the component." });
|
|
257
|
+
else
|
|
258
|
+
add({ id: "missing-ids", status: "pass", message: "Every literal EditableText id exists in the copy file." });
|
|
259
|
+
const referenced = new Set();
|
|
260
|
+
for (const use of uses) {
|
|
261
|
+
if (use.id)
|
|
262
|
+
referenced.add(use.id);
|
|
263
|
+
if (use.pattern)
|
|
264
|
+
for (const id of ids)
|
|
265
|
+
if (use.pattern.test(id))
|
|
266
|
+
referenced.add(id);
|
|
267
|
+
}
|
|
268
|
+
const orphans = [...ids].filter((id) => !referenced.has(id));
|
|
269
|
+
const hasDynamicIds = uses.some((use) => !use.id && !use.pattern);
|
|
270
|
+
if (orphans.length > 0)
|
|
271
|
+
add({ id: "orphan-ids", status: hasDynamicIds ? "warn" : "warn", message: `${orphans.length} id(s) in ${contentFile} are not referenced by any EditableText${hasDynamicIds ? " (some usages have dynamic ids, so this may be fine)" : ""}.`, details: orphans, fix: "Remove unused ids or reference them." });
|
|
272
|
+
else
|
|
273
|
+
add({ id: "orphan-ids", status: "pass", message: "Every id is referenced." });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Provider: generated boilerplate (refino/copy-editing) does not count until something renders it.
|
|
277
|
+
const boilerplatePrefix = `${BOILERPLATE_DIR}/`;
|
|
278
|
+
const rendersProvider = sourceFiles
|
|
279
|
+
.map((file) => rel(appDir, file))
|
|
280
|
+
.filter((file) => !file.startsWith(boilerplatePrefix))
|
|
281
|
+
.filter((file) => {
|
|
282
|
+
const text = readText(join(appDir, file)) ?? "";
|
|
283
|
+
return text.includes("RefinoProvider") || /from ["'][^"']*copy-editing["']/.test(text);
|
|
284
|
+
});
|
|
285
|
+
if (rendersProvider.length === 0) {
|
|
286
|
+
add({ id: "provider", status: "fail", message: inspection.refino.providerFiles.length > 0 ? "RefinoProvider exists only in generated boilerplate; nothing renders it yet." : "RefinoProvider is not rendered anywhere.", fix: "Wrap the app root (layout, _app or App) with the provider (CopyEditing from refino/copy-editing); see the instructions." });
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
add({ id: "provider", status: "pass", message: `Provider rendered from ${rendersProvider.join(", ")}.` });
|
|
290
|
+
const editableFiles = new Set(uses.map((use) => join(appDir, use.file)));
|
|
291
|
+
const uncovered = rendersProvider.flatMap((file) => uncoveredComponents(appDir, file, editableFiles));
|
|
292
|
+
if (uncovered.length > 0)
|
|
293
|
+
add({ id: "provider-coverage", status: "fail", message: "Some editable chrome is rendered outside the provider; its ids would be displayed as text.", details: uncovered, fix: "Move those components inside <CopyEditing> (wrap everything the layout renders, not only {children})." });
|
|
294
|
+
else
|
|
295
|
+
add({ id: "provider-coverage", status: "pass", message: "Every component the provider file renders is inside the provider." });
|
|
296
|
+
for (const file of rendersProvider) {
|
|
297
|
+
const text = readText(join(appDir, file)) ?? "";
|
|
298
|
+
if (/editing(?:=\{true\}|=\{!0\}|\s*:\s*true\b)/.test(text))
|
|
299
|
+
add({ id: "provider-editing-literal", status: "fail", message: `${file} hard-codes editing={true}.`, fix: "Compute editing from a server-side session check." });
|
|
300
|
+
if (/searchParams[^\n]*edit|\?edit=|get\(["']edit["']\)/.test(text))
|
|
301
|
+
add({ id: "provider-editing-query", status: "fail", message: `${file} appears to derive edit mode from the URL.`, fix: "Edit mode must come from an authenticated server check, never a query parameter." });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (hosted) {
|
|
305
|
+
hostedChecks(appDir, inspection, hosted, sourceFiles, rendersProvider, add);
|
|
306
|
+
}
|
|
307
|
+
// Endpoint (self-hosted only; a hosted site has none)
|
|
308
|
+
const endpointPath = !hosted && config?.endpoint ? join(appDir, config.endpoint) : null;
|
|
309
|
+
if (hosted) {
|
|
310
|
+
// Checked above.
|
|
311
|
+
}
|
|
312
|
+
else if (endpointPath && isFile(endpointPath)) {
|
|
313
|
+
add({ id: "endpoint", status: "pass", message: `Copy endpoint exists at ${config.endpoint}.` });
|
|
314
|
+
}
|
|
315
|
+
else if (inspection.refino.endpointFiles.length > 0) {
|
|
316
|
+
add({ id: "endpoint", status: "pass", message: `createContentApi used in ${inspection.refino.endpointFiles.join(", ")}.` });
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
add({ id: "endpoint", status: "fail", message: "No copy endpoint found (nothing calls createContentApi).", fix: `Create an authenticated GET/POST endpoint at ${config?.endpoint ?? "/api/copy"} wrapping createContentApi.` });
|
|
320
|
+
}
|
|
321
|
+
// The session endpoint (GET probe, POST login, DELETE logout) must exist next to the copy endpoint;
|
|
322
|
+
// static sites decide edit mode in the browser by asking it, server-rendered sites log in through it.
|
|
323
|
+
const sessionFile = hosted ? null : (config?.sessionEndpoint ?? (inspection.server.kind === "static" && config?.endpoint ? config.endpoint.replace(/\/copy(\.\w+)$/, "/edit-session$1") : null));
|
|
324
|
+
if (sessionFile && sessionFile !== config?.endpoint) {
|
|
325
|
+
if (isFile(join(appDir, sessionFile)))
|
|
326
|
+
add({ id: "session-endpoint", status: "pass", message: `Session endpoint exists at ${sessionFile}.` });
|
|
327
|
+
else
|
|
328
|
+
add({ id: "session-endpoint", status: "fail", message: `No session endpoint at ${sessionFile}.`, fix: "Create the /api/edit-session function (GET 204/401, POST login, DELETE logout) as described in .refino/AGENT_INSTRUCTIONS.md; without it edit mode never turns on." });
|
|
329
|
+
}
|
|
330
|
+
// Next.js static export: nothing dynamic may be left under app/ or the build fails.
|
|
331
|
+
if (inspection.nextConfig.output === "export") {
|
|
332
|
+
const routesDir = inspection.routesDir ?? "app";
|
|
333
|
+
const leftoverHandlers = sourceFiles
|
|
334
|
+
.map((file) => rel(appDir, file))
|
|
335
|
+
.filter((file) => new RegExp(`^${routesDir}/api/(copy|edit-session)/route\\.`).test(file));
|
|
336
|
+
if (leftoverHandlers.length > 0)
|
|
337
|
+
add({ id: "static-export-route-handlers", status: "fail", message: `Route handlers exist but ${inspection.nextConfig.file} sets output: "export".`, details: leftoverHandlers, fix: `Delete them and serve /api/copy and /api/edit-session from ${config?.endpoint ? `${config.endpoint} and its edit-session sibling` : "the host's serverless functions"}.` });
|
|
338
|
+
for (const file of rendersProvider) {
|
|
339
|
+
const text = readText(join(appDir, file)) ?? "";
|
|
340
|
+
if (/from ["']next\/headers["']/.test(text) || /editor-session["']/.test(text))
|
|
341
|
+
add({ id: "static-export-provider", status: "fail", message: `${file} reads request state (next/headers or editor-session), which a static export cannot do.`, fix: "Render <CopyEditing content={copy}> without an editing prop; the generated wrapper probes GET /api/edit-session in the browser." });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const adapterUsed = sourceFiles.some((file) => /create(GitHub|LocalFile)ContentAdapter/.test(readText(file) ?? ""));
|
|
345
|
+
if (hosted) {
|
|
346
|
+
if (adapterUsed)
|
|
347
|
+
add({ id: "adapter", status: "fail", message: "A persistence adapter is constructed although Refino hosts the copy API.", fix: "Remove the local adapter and any repository credential; Refino writes to the repository." });
|
|
348
|
+
else
|
|
349
|
+
add({ id: "adapter", status: "pass", message: "No persistence adapter in the site: Refino writes to the repository." });
|
|
350
|
+
}
|
|
351
|
+
else if (!adapterUsed)
|
|
352
|
+
add({ id: "adapter", status: "fail", message: "No persistence adapter is constructed.", fix: "Use createLocalFileContentAdapter (dev) and createGitHubContentAdapter (production) behind the endpoint." });
|
|
353
|
+
else
|
|
354
|
+
add({ id: "adapter", status: "pass", message: "A persistence adapter is constructed server-side." });
|
|
355
|
+
// Secret safety
|
|
356
|
+
const leaks = [];
|
|
357
|
+
for (const file of sourceFiles) {
|
|
358
|
+
const text = readText(file) ?? "";
|
|
359
|
+
const relative = rel(appDir, file);
|
|
360
|
+
if (/NEXT_PUBLIC_(COPY_GITHUB|EDITOR_)|VITE_(COPY_GITHUB|EDITOR_)/.test(text))
|
|
361
|
+
leaks.push(`${relative}: exposes a Refino secret with a public prefix`);
|
|
362
|
+
if (!isClientFile(text, inspection.framework))
|
|
363
|
+
continue;
|
|
364
|
+
for (const name of SERVER_ONLY_IMPORTS)
|
|
365
|
+
if (text.includes(`"${name}"`) || text.includes(`'${name}'`))
|
|
366
|
+
leaks.push(`${relative}: client file imports ${name}`);
|
|
367
|
+
for (const secret of SECRET_NAMES)
|
|
368
|
+
if (text.includes(secret))
|
|
369
|
+
leaks.push(`${relative}: client file mentions ${secret}`);
|
|
370
|
+
if (/from ["'][^"']*(content-adapter|editor-auth|request-handlers)["']/.test(text))
|
|
371
|
+
leaks.push(`${relative}: client file imports server-only Refino module`);
|
|
372
|
+
}
|
|
373
|
+
if (leaks.length > 0)
|
|
374
|
+
add({ id: "secrets", status: "fail", message: "Persistence code or secrets reachable from the browser.", details: leaks, fix: "Move adapter construction and secrets into server-only modules." });
|
|
375
|
+
else
|
|
376
|
+
add({ id: "secrets", status: "pass", message: "No adapters or secrets in client code." });
|
|
377
|
+
// Env documentation (self-hosted only)
|
|
378
|
+
const envExample = readText(join(appDir, ".env.example"));
|
|
379
|
+
if (hosted) {
|
|
380
|
+
if (envExample && /EDITOR_PASSWORD|COPY_GITHUB_TOKEN|COPY_ADAPTER/.test(envExample))
|
|
381
|
+
add({ id: "env-example", status: "warn", message: ".env.example still documents self-hosted EDITOR_*/COPY_* variables that a Refino-hosted site does not use.", fix: "Remove the Refino block from .env.example." });
|
|
382
|
+
else
|
|
383
|
+
add({ id: "env-example", status: "pass", message: "No editor or repository variables are needed: Refino hosts them." });
|
|
384
|
+
}
|
|
385
|
+
else if (envExample && envExample.includes("COPY_GITHUB_TOKEN"))
|
|
386
|
+
add({ id: "env-example", status: "pass", message: ".env.example documents the Refino variables." });
|
|
387
|
+
else
|
|
388
|
+
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." });
|
|
389
|
+
// Scripts (plus a tsc fallback when a TypeScript project has no typecheck script).
|
|
390
|
+
const commands = [];
|
|
391
|
+
for (const command of verificationCommands(inspection)) {
|
|
392
|
+
if (!options.run) {
|
|
393
|
+
commands.push({ command, ran: false, ok: null });
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const [bin, ...args] = command.split(" ");
|
|
397
|
+
const result = spawnSync(bin, args, { cwd: appDir, encoding: "utf8", env: { ...process.env, CI: "1" } });
|
|
398
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim().split("\n").slice(-30).join("\n");
|
|
399
|
+
commands.push({ command, ran: true, ok: result.status === 0, output });
|
|
400
|
+
}
|
|
401
|
+
const ok = checks.every((check) => check.status !== "fail") && commands.every((command) => command.ok !== false);
|
|
402
|
+
return { ok, checks, commands };
|
|
403
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@getrefino/onboarding",
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"refino",
|
|
7
|
+
"onboarding",
|
|
8
|
+
"codemod",
|
|
9
|
+
"migration",
|
|
10
|
+
"agent",
|
|
11
|
+
"copy"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"homepage": "https://refino.dev",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/forant/inline-copy.git",
|
|
18
|
+
"directory": "packages/onboarding"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"provenance": false
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"files": [
|
|
27
|
+
"dist/**/*.js",
|
|
28
|
+
"dist/**/*.d.ts",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"default": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"typescript": ">=5.5 <7",
|
|
41
|
+
"@getrefino/core": "0.1.0-rc.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "22.20.2",
|
|
45
|
+
"vitest": "5.0.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
49
|
+
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
|
50
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"clean": "rm -rf dist"
|
|
53
|
+
}
|
|
54
|
+
}
|