@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Refino contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @getrefino/onboarding
2
+
3
+ The engine behind `npx @getrefino/cli init`: deterministic repository
4
+ inspection, copy discovery, migration planning, agent-instruction generation,
5
+ safe setup and verification for installing **Refino** into an existing React
6
+ site.
7
+
8
+ Development tooling only — nothing here runs inside a site. Most people want
9
+ the CLI instead:
10
+
11
+ ```bash
12
+ npx @getrefino/cli init
13
+ ```
14
+
15
+ ```ts
16
+ import { inspectRepository, planMigration, applyPlan, renderAgentInstructions, verifyIntegration } from "@getrefino/onboarding";
17
+
18
+ const inspection = inspectRepository("/path/to/site");
19
+ const plan = planMigration(inspection, { routes: ["/"] });
20
+ applyPlan(appDirectory(inspection), plan, { dryRun: true });
21
+ const markdown = renderAgentInstructions(plan);
22
+ const result = verifyIntegration("/path/to/site");
23
+ ```
24
+
25
+ All results are plain JSON. No network calls, no model calls. It never rewrites
26
+ JSX and never overwrites an existing file.
27
+
28
+ <https://refino.dev>
@@ -0,0 +1,8 @@
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
+ import { INSTRUCTIONS_FILE } from "./plan.js";
6
+ import type { MigrationPlan } from "./types.js";
7
+ export declare function renderAgentInstructions(plan: MigrationPlan): string;
8
+ export { INSTRUCTIONS_FILE };
package/dist/agent.js ADDED
@@ -0,0 +1,219 @@
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
+ import { BOILERPLATE_DIR, CONFIG_FILE, INSTRUCTIONS_FILE, PLAN_FILE } from "./plan.js";
6
+ function bullet(items) {
7
+ return items.map((item) => `- ${item}`).join("\n");
8
+ }
9
+ function table(rows, header) {
10
+ const escape = (cell) => cell.replace(/\|/g, "\\|").replace(/\n/g, " ");
11
+ return [`| ${header.join(" | ")} |`, `| ${header.map(() => "---").join(" | ")} |`, ...rows.map((row) => `| ${row.map(escape).join(" | ")} |`)].join("\n");
12
+ }
13
+ function truncate(text, max = 60) {
14
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
15
+ }
16
+ function location(candidate) {
17
+ return `${candidate.file}:${candidate.line}`;
18
+ }
19
+ /**
20
+ * The endpoint files for the detected host. When the tool generated them the
21
+ * agent only deploys them; otherwise it mounts the generated request handlers
22
+ * (one Request in, one Response out) in the host's own format.
23
+ */
24
+ function endpointSteps(plan) {
25
+ const { repository, integration } = plan;
26
+ const host = integration.host;
27
+ const ext = repository.language === "typescript" ? "ts" : "js";
28
+ const handlersModule = `${BOILERPLATE_DIR}/request-handlers.${ext}`;
29
+ const facts = host.notes.map((note) => `Runtime facts: ${note}`);
30
+ if (plan.refino) {
31
+ return [
32
+ `There is **no copy endpoint and no login to write**: this site is connected to Refino (site \`${plan.refino.siteId}\` at ${plan.refino.appUrl}). The browser loads and saves through \`${host.copyEndpoint}\` with a short-lived editor token that \`${BOILERPLATE_DIR}/refino-client.${ext}\` obtains from Refino, and Refino commits to the repository. Do not create \`/api/copy\`, \`/api/edit-session\`, serverless functions, an editor password, a session secret or a repository token, and do not add any \`EDITOR_*\` or \`COPY_*\` environment variable.`,
33
+ ...facts,
34
+ ];
35
+ }
36
+ if (host.generated) {
37
+ return [
38
+ `The endpoint files were generated: \`${host.copyEndpoint}\` (GET/POST /api/copy) and \`${host.sessionEndpoint}\` (GET probe, POST login, DELETE logout for /api/edit-session). Each is a few lines that pass the request and the runtime's environment object to \`${handlersModule}\`, which holds the session check, cross-site rejection, password login and persistence. Read them once; do not rewrite them and do not add handlers of your own next to them.`,
39
+ ...facts,
40
+ ];
41
+ }
42
+ const contract = `The behaviour is already implemented in \`${handlersModule}\`: \`handleCopyRequest(request, env)\` and \`handleEditSessionRequest(request, env)\` take a standard \`Request\` and an environment object (\`process.env\`, or the runtime's per-request env) and return a \`Response\`. Do not reimplement the session check, cookie or persistence logic; only adapt the host's signature.`;
43
+ switch (host.kind) {
44
+ case "next-api-routes":
45
+ return [
46
+ `Create \`${host.copyEndpoint}\` and \`${host.sessionEndpoint}\` as Pages Router API routes. ${contract} In each route build a \`Request\` from \`req\` (method, \`http://\${req.headers.host}\${req.url}\`, headers, and the raw body for POST; export \`config = { api: { bodyParser: false } }\` so the body is available as a stream), call the handler with \`process.env\`, then copy the Response's status, headers (including \`set-cookie\`) and body to \`res\`.`,
47
+ ...facts,
48
+ ];
49
+ case "custom-server":
50
+ return [
51
+ `In the existing server (${repository.server}), mount \`GET/POST /api/copy\` and \`GET/POST/DELETE /api/edit-session\` at \`${host.copyEndpoint}\` and \`${host.sessionEndpoint}\`. ${contract} Frameworks with Web-standard handlers (Hono, h3, Bun) pass the request straight through; Express-style servers build a \`Request\` from \`req\` and write the \`Response\` back.`,
52
+ ...facts,
53
+ ];
54
+ default:
55
+ return [
56
+ `This site has no server and no hosting provider was identified. Add one function per route, \`${host.copyEndpoint}\` for /api/copy and \`${host.sessionEndpoint}\` for /api/edit-session, in the format your host expects. ${contract} If no serverless option exists, stop and report that saving requires a server.`,
57
+ ...facts,
58
+ ];
59
+ }
60
+ }
61
+ function frameworkSteps(plan) {
62
+ const { integration, repository } = plan;
63
+ const ext = repository.language === "typescript" ? "ts" : "js";
64
+ const jsx = repository.language === "typescript" ? "tsx" : "jsx";
65
+ const steps = [];
66
+ if (plan.refino) {
67
+ const editPage = plan.files.find((file) => /(^|\/)edit(\/page)?\.\w+$/.test(file.path) && !file.path.startsWith(`${BOILERPLATE_DIR}/`))?.path;
68
+ steps.push(`Open \`${integration.provider.file ?? "the root component"}\`. Import \`copy\` from \`${plan.contentFile.path}\` and \`CopyEditing\` from \`${BOILERPLATE_DIR}/copy-editing.${jsx}\`, and wrap everything it renders in \`<CopyEditing content={copy}>\` (\`{children}\` **and every component the layout or root renders itself**, because an \`EditableText\` outside the provider displays its id as text). Pass no \`editing\` prop and do not import \`next/headers\`: edit mode is decided in the browser from the Refino editor session, so the server never reads a cookie and pages can stay static. Change nothing else there.`, editPage
69
+ ? `The owner's entry point \`${editPage}\` was generated (it renders \`${BOILERPLATE_DIR}/edit-page.${jsx}\`, a client component that sends the owner to Refino and finishes the authorization when Refino sends them back). Read it once; do not rewrite it.`
70
+ : `Add a route for \`/edit\` that renders the default export of \`${BOILERPLATE_DIR}/edit-page.${jsx}\` (for react-router: \`<Route path="/edit" element={<EditPage />} />\`; for a single-page app: render it when \`window.location.pathname === "/edit"\`). It is the owner's entry point: it sends them to Refino and finishes the authorization when Refino sends them back to \`/edit?code=…&state=…\`. Do not put it behind any other navigation.`, ...endpointSteps(plan));
71
+ return steps;
72
+ }
73
+ if (integration.provider.strategy === "next-app-root-layout-client-session") {
74
+ steps.push(`Open \`${integration.provider.file}\`. Import \`copy\` from \`${plan.contentFile.path}\` and \`CopyEditing\` from \`${BOILERPLATE_DIR}/copy-editing.${jsx}\`, and wrap the body's content in \`<CopyEditing content={copy}>\`: \`{children}\` **and every component the layout renders itself** (a header, nav or footer placed next to \`{children}\`), because an \`EditableText\` outside the provider displays its id as text. Pass no \`editing\` prop and do not import \`next/headers\`: ${repository.serverNotes[0] ?? "the site is a static export"} The generated wrapper asks \`GET /api/edit-session\` in the browser and turns edit mode on only for a 204. Change nothing else in the layout.`, `The static login page \`${plan.files.find((file) => /\/edit\/page\.\w+$/.test(file.path))?.path ?? "app/edit/page.tsx"}\` and its \`layout\` (noindex) were generated; read them once, do not rewrite them. Do not create route handlers under \`app/api\`: they cannot be exported.`, ...endpointSteps(plan));
75
+ return steps;
76
+ }
77
+ switch (repository.router) {
78
+ case "next-app":
79
+ steps.push(`Open \`${integration.provider.file}\`. Import \`copy\` from \`${plan.contentFile.path}\`, \`CopyEditing\` from \`${BOILERPLATE_DIR}/copy-editing.${jsx}\` and \`isEditorAuthenticated\` from \`${BOILERPLATE_DIR}/editor-session.${ext}\`. Make the layout \`async\`, compute \`const editing = await isEditorAuthenticated()\`, and wrap the body's content in \`<CopyEditing content={copy} editing={editing}>\`: \`{children}\` **and every component the layout renders itself** (a header, nav or footer placed next to \`{children}\`), because an \`EditableText\` outside the provider displays its id as text. Change nothing else in the layout.`, ...endpointSteps(plan), `The \`/edit\` login page was generated next to the route handlers. If the site already has an owner login, replace the body of \`isEditorAuthenticated()\` in \`${BOILERPLATE_DIR}/editor-session.${ext}\` and of \`isEditorRequest()\` in \`${BOILERPLATE_DIR}/request-handlers.${ext}\` with that check, and delete the generated login page and edit-session route.`);
80
+ break;
81
+ case "next-pages":
82
+ steps.push(`Open \`${integration.provider.file}\`. Wrap \`<Component {...pageProps} />\` with \`<RefinoProvider content={copy} editing={pageProps.refinoEditing === true} endpoint="/api/copy" onExit={...}>\`. Add \`getServerSideProps\` (or extend the existing one) on the selected pages to set \`refinoEditing\` to \`verifySessionToken(config, sessionTokenFromCookieHeader(req.headers.cookie))\` with \`config = readEditorAuthConfig()\` (false when the config is null), all from \`${BOILERPLATE_DIR}/editor-auth.${ext}\`.`, ...endpointSteps(plan));
83
+ break;
84
+ default:
85
+ steps.push(`Open \`${integration.provider.file ?? "the root component"}\`. Wrap the app's root element with \`<CopyEditing content={copy}>\` from \`${BOILERPLATE_DIR}/copy-editing.${jsx}\`, where \`copy\` is imported from \`${plan.contentFile.path}\`. The wrapper asks \`GET /api/edit-session\` in the browser and turns edit mode on only for a 204 response. Never derive edit mode from the URL.`);
86
+ steps.push(...endpointSteps(plan));
87
+ }
88
+ return steps;
89
+ }
90
+ export function renderAgentInstructions(plan) {
91
+ const selected = plan.copy.filter((candidate) => candidate.status === "selected");
92
+ const review = plan.copy.filter((candidate) => candidate.status === "needs-review");
93
+ const unsupported = plan.copy.filter((candidate) => candidate.status === "unsupported");
94
+ const filesToEdit = [...new Set([...selected, ...review].map((candidate) => candidate.file))].sort();
95
+ const { repository, integration } = plan;
96
+ const sections = [];
97
+ sections.push(`# Make this site visually editable with Refino
98
+
99
+ These instructions were generated by \`refino\` from an inspection of this
100
+ repository on ${plan.generatedAt.slice(0, 10)}. The facts below were read from the
101
+ files themselves. Trust them unless the source in front of you contradicts them,
102
+ and if it does, follow the source and mention the discrepancy in your report.
103
+
104
+ Machine-readable version of this plan: \`${PLAN_FILE}\`.
105
+
106
+ ## What you are doing
107
+
108
+ Installing Refino into this site. Refino lets the site owner edit visible
109
+ website copy in the browser while this repository stays the source of truth.
110
+
111
+ After this work the owner can sign in, click any migrated sentence on the real
112
+ page, rewrite it, and save it back to \`${plan.contentFile.path}\`, which is the
113
+ single source of truth for that copy. An edit is a commit. There is no CMS, no
114
+ database and no sync step. You are not changing what the site says, how it
115
+ looks, or how it works.
116
+
117
+ These instructions are authoritative. Follow them rather than inventing your own
118
+ integration: the inspection, the plan and the generated files above were
119
+ produced by \`npx @getrefino/cli init\` from this exact repository.
120
+
121
+ ## Facts about this repository
122
+
123
+ ${bullet([
124
+ `Framework: ${repository.framework}${repository.next ? ` (next ${repository.next})` : ""}, router: ${repository.router}`,
125
+ `Language: ${repository.language}; React ${repository.react ?? "unknown"}`,
126
+ `Package manager: ${repository.packageManager}`,
127
+ `App root: \`${repository.appRoot}\`${repository.appRoot !== "." ? " (monorepo; all paths below are relative to it)" : ""}`,
128
+ `Provider location: \`${integration.provider.file ?? "unknown"}\` (${integration.provider.strategy})`,
129
+ `Mode: ${plan.refino ? `Refino-hosted (site \`${plan.refino.siteId}\`, app ${plan.refino.appUrl}; no site secrets, no server code)` : "self-hosted (this deployment runs its own login and copy endpoint)"}`,
130
+ `Copy endpoint: \`${integration.endpoint.path}\` (${integration.endpoint.kind})`,
131
+ `Host integration: ${integration.host.kind}${plan.refino ? " (Refino serves the copy API; nothing to deploy)" : integration.host.generated ? " (endpoint files generated)" : " (agent mounts the generated request handlers)"}`,
132
+ plan.refino ? `Server capability: ${repository.server} (not needed for editing: Refino serves the copy API)` : `Server capability: ${repository.server}${repository.serverNotes.length > 0 ? ` — ${repository.serverNotes.join(" ")}` : ""}`,
133
+ `Authentication: ${integration.auth.existing.length > 0 ? integration.auth.existing.join(", ") : "none detected"} → strategy \`${integration.auth.strategy}\``,
134
+ `Hosting: ${repository.hosting ?? "not identified"}`,
135
+ `Git: ${repository.git.isRepository ? `branch ${repository.git.branch ?? "?"}${repository.git.github ? `, GitHub ${repository.git.github.owner}/${repository.git.github.repo}` : ""}${repository.git.clean === false ? ", uncommitted changes present" : ""}` : "not a git repository"}`,
136
+ `Verification commands: ${plan.verification.commands.length > 0 ? plan.verification.commands.map((command) => `\`${command}\``).join(", ") : "none defined in package.json scripts"}`,
137
+ ])}
138
+
139
+ ## Scope
140
+
141
+ Migrate only: ${plan.scope.description}
142
+
143
+ ${selected.length} strings are selected, ${review.length} need your judgment, ${unsupported.length} are unsupported as plain text, ${plan.summary.excluded} were excluded as not copy.
144
+
145
+ Files you will edit for copy: ${filesToEdit.map((file) => `\`${file}\``).join(", ") || "none"}.`);
146
+ sections.push(`## Already done for you (generated files)
147
+
148
+ ${bullet(plan.files.map((file) => `\`${file.path}\` — ${file.action}: ${file.description}`))}
149
+
150
+ If any of these files are missing, run \`npx @getrefino/cli init --agent --yes\` in \`${repository.appRoot}\` before continuing, or create them by hand from \`${PLAN_FILE}\`.`);
151
+ sections.push(`## Steps
152
+
153
+ 1. **Install dependencies.** \`${plan.repository.packageManager === "unknown" ? "npm install" : plan.repository.packageManager === "pnpm" ? "pnpm install" : plan.repository.packageManager === "yarn" ? "yarn install" : plan.repository.packageManager === "bun" ? "bun install" : "npm install"}\` (the ${plan.refino ? "two" : "three"} \`@getrefino/*\` packages were added to \`${plan.files.find((file) => file.path.endsWith("package.json"))?.path ?? "package.json"}\`).
154
+ 2. **Wire the provider and endpoint.**
155
+ ${frameworkSteps(plan)
156
+ .map((step) => ` - ${step}`)
157
+ .join("\n")}
158
+ 3. **Replace the selected strings with \`EditableText\`.** For each row in the table below, open the file at the line, and replace the literal with \`<EditableText id="<id>" as="<tag>" ...sameProps />\` keeping the exact element type, className and every other prop. Import \`EditableText\` from \`@getrefino/react\`. The text itself must not change; it already lives in \`${plan.contentFile.path}\` under that id.
159
+ - For text that is the child of a component you must not replace (\`<Button>Get started</Button>\`, a \`<Link>\`, a role component such as \`<Eyebrow>\`), pass \`<EditableText id="…" />\` as the child. It renders an attribute-less inline \`<span>\` in normal mode; that span is expected and changes nothing visible, in layout or for assistive technology. Do not restructure the component to avoid it.
160
+ - For \`jsx-attribute\` rows (a string passed as a prop to a component), change the component so it renders that prop through \`EditableText\`, or pass \`<EditableText id="…" />\` as the prop value if the component renders it as children. Keep the element the component uses.
161
+ - For \`data-object\` rows (strings inside an array the file maps over), either replace the string field with the id and render it through \`<EditableText id={item.titleId} …/>\`, or keep the array and render \`<EditableText id={\`${"${prefix}"}.\${item.key}.title\`} />\` when the ids follow a pattern. Do not duplicate the text in both places.
162
+ 4. **Decide the needs-review rows.** Each one says why it needs you: usually a placeholder id (ordinal suffix or text-derived key). Rename the id to something semantic, keep the same prefix, and update \`${plan.contentFile.path}\` to match. Skip a row entirely if it is not user-visible copy.
163
+ 5. **Leave unsupported rows in code.** They mix markup or interpolation. Do not restructure sentences to make them editable.
164
+ 6. **Run verification:** \`npx @getrefino/cli verify\`${plan.verification.commands.length > 0 ? ` then ${plan.verification.commands.map((command) => `\`${command}\``).join(", ")}` : ""}. Fix only what verification reports, then run it again until it passes.
165
+ 7. **Report** using the format at the end.`);
166
+ sections.push(`## Selected strings (${selected.length})
167
+
168
+ ${selected.length > 0 ? table(selected.map((candidate) => [candidate.id ?? "", location(candidate), candidate.element ?? candidate.key ?? "", candidate.source, truncate(candidate.text)]), ["id", "file:line", "element", "source", "text"]) : "_none_"}`);
169
+ if (review.length > 0) {
170
+ sections.push(`## Needs your judgment (${review.length})
171
+
172
+ ${table(review.map((candidate) => [candidate.id ?? "(none)", location(candidate), candidate.element ?? candidate.key ?? "", truncate(candidate.text, 40), candidate.reason]), ["proposed id", "file:line", "element", "text", "why"])}`);
173
+ }
174
+ if (unsupported.length > 0) {
175
+ sections.push(`## Unsupported as plain text (${unsupported.length})
176
+
177
+ ${table(unsupported.map((candidate) => [location(candidate), candidate.element ?? "", truncate(candidate.text, 40), candidate.classification]), ["file:line", "element", "text", "why"])}
178
+
179
+ Leave these as they are. Mention them in your report so the owner knows they are not editable yet.`);
180
+ }
181
+ sections.push(`## Rules
182
+
183
+ ${bullet([
184
+ "Do not change any copy. The strings move from JSX to the JSON file byte for byte.",
185
+ "Do not change styling, element types, class names, layout or behavior. `EditableText` in normal mode renders exactly the element you give it.",
186
+ "Do not touch files outside the ones named here except to add imports the steps require.",
187
+ "Do not migrate strings that are not listed, and do not migrate needs-review or unsupported rows just to increase coverage.",
188
+ `Do not create a second copy of the content anywhere (no context objects, no CMS, no duplicated constants). \`${plan.contentFile.path}\` is canonical.`,
189
+ ...(plan.refino
190
+ ? [
191
+ `Do not add any server-side editor code, environment variable or credential: Refino provides authentication, authorization, entitlement and the copy API for site \`${plan.refino.siteId}\`. Never import \`@getrefino/github\` or \`@getrefino/core/local-file\`.`,
192
+ "Edit mode comes only from the Refino editor session that `/edit` establishes in the browser (sessionStorage, 12 hours, one site). Never from `?edit=1`, localStorage or a hard-coded `true`.",
193
+ "Keep the existing deployment setup; the site keeps working as a static export.",
194
+ ]
195
+ : [
196
+ "Do not import `@getrefino/github`, `@getrefino/core/local-file`, or the generated `content-adapter`/`editor-auth`/`request-handlers` modules from any client component or browser bundle.",
197
+ "Never expose `COPY_GITHUB_TOKEN`, `EDITOR_PASSWORD` or `EDITOR_SESSION_SECRET` to the client, and never prefix them with `NEXT_PUBLIC_` or `VITE_`.",
198
+ "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
+ "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
+ `Do not edit \`${CONFIG_FILE}\`, \`${PLAN_FILE}\` or this file.`,
202
+ ])}
203
+
204
+ ## Report format
205
+
206
+ When finished, report:
207
+
208
+ ${bullet([
209
+ "Every file you changed and what changed in it.",
210
+ "The final list of content ids and where each is rendered.",
211
+ "Ids you renamed from the proposals, with the new names.",
212
+ "Strings you left unmigrated and why.",
213
+ "The exact output of `npx @getrefino/cli verify` and of each verification command.",
214
+ "Anything in these instructions that did not match the repository.",
215
+ ])}
216
+ `);
217
+ return sections.join("\n\n");
218
+ }
219
+ export { INSTRUCTIONS_FILE };
@@ -0,0 +1,23 @@
1
+ import type { ApplyResult, MigrationPlan } from "./types.js";
2
+ export interface ApplyOptions {
3
+ readonly dryRun?: boolean;
4
+ /** Version range written for the @getrefino packages. */
5
+ readonly packageVersion?: string;
6
+ }
7
+ export interface RefinoConfig {
8
+ readonly version: 1;
9
+ readonly contentFile: string;
10
+ /** File serving /api/copy; in hosted mode the Refino copy API URL. */
11
+ readonly endpoint: string;
12
+ /** File serving /api/edit-session (probe, login, logout); in hosted mode the Refino token exchange URL. */
13
+ readonly sessionEndpoint?: string;
14
+ /** Present when the site is connected to Refino (hosted mode). Public values. */
15
+ readonly refino?: {
16
+ readonly siteId: string;
17
+ readonly appUrl: string;
18
+ };
19
+ readonly framework: string;
20
+ readonly router: string;
21
+ readonly routes: readonly string[];
22
+ }
23
+ export declare function applyPlan(appDir: string, plan: MigrationPlan, options?: ApplyOptions): ApplyResult;
package/dist/apply.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Safe automatic changes. Everything here is additive and deterministic:
3
+ * new files are written only if absent, the copy file is merged (existing
4
+ * values never change), package.json gains dependencies, .env.example
5
+ * gains a documented block. No JSX is rewritten.
6
+ */
7
+ import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { parseCopy, serializeCopy } from "@getrefino/core";
10
+ import { renderAgentInstructions } from "./agent.js";
11
+ import { exists, readJson, readText } from "./fs.js";
12
+ import { packageManagerInstall } from "./inspect.js";
13
+ import { CONFIG_FILE, INSTRUCTIONS_FILE, PLAN_FILE, dependencyRange, planContent } from "./plan.js";
14
+ import { ENV_EXAMPLE_BLOCK, boilerplateFiles } from "./templates.js";
15
+ function writeFile(root, relativePath, content, dryRun) {
16
+ if (dryRun)
17
+ return;
18
+ const absolute = join(root, relativePath);
19
+ mkdirSync(dirname(absolute), { recursive: true });
20
+ writeFileSync(absolute, content, "utf8");
21
+ }
22
+ export function applyPlan(appDir, plan, options = {}) {
23
+ const dryRun = options.dryRun ?? false;
24
+ const written = [];
25
+ const skipped = [];
26
+ const version = options.packageVersion ?? dependencyRange();
27
+ // 1. Plan + instructions (always rewritten: they describe this run).
28
+ writeFile(appDir, PLAN_FILE, `${JSON.stringify(plan, null, 2)}\n`, dryRun);
29
+ written.push({ path: PLAN_FILE, action: "create", description: "Machine-readable migration plan." });
30
+ writeFile(appDir, INSTRUCTIONS_FILE, renderAgentInstructions(plan), dryRun);
31
+ written.push({ path: INSTRUCTIONS_FILE, action: "create", description: "Repository-specific agent instructions." });
32
+ // 2. Canonical copy file: create, or merge missing keys only.
33
+ const contentPath = join(appDir, plan.contentFile.path);
34
+ const selected = planContent(plan);
35
+ if (exists(contentPath)) {
36
+ const existingText = readText(contentPath) ?? "{}";
37
+ let existing;
38
+ try {
39
+ existing = { ...parseCopy(existingText) };
40
+ }
41
+ catch (error) {
42
+ skipped.push({ path: plan.contentFile.path, reason: `Existing copy file is invalid: ${error.message}` });
43
+ existing = {};
44
+ }
45
+ const added = Object.keys(selected).filter((id) => !(id in existing));
46
+ if (added.length > 0 && Object.keys(existing).length > 0) {
47
+ const merged = { ...existing };
48
+ for (const id of added)
49
+ merged[id] = selected[id];
50
+ writeFile(appDir, plan.contentFile.path, serializeCopy(merged), dryRun);
51
+ written.push({ path: plan.contentFile.path, action: "merge", description: `Added ${added.length} id(s); existing values untouched.` });
52
+ }
53
+ else if (added.length === 0) {
54
+ skipped.push({ path: plan.contentFile.path, reason: "Already contains every selected id." });
55
+ }
56
+ }
57
+ else {
58
+ writeFile(appDir, plan.contentFile.path, serializeCopy(selected), dryRun);
59
+ written.push({ path: plan.contentFile.path, action: "create", description: `${Object.keys(selected).length} selected string(s).` });
60
+ }
61
+ // 3. Config file for verify.
62
+ const config = {
63
+ version: 1,
64
+ contentFile: plan.contentFile.path,
65
+ endpoint: plan.integration.endpoint.path,
66
+ sessionEndpoint: plan.integration.host.sessionEndpoint,
67
+ ...(plan.refino ? { refino: plan.refino } : {}),
68
+ framework: plan.repository.framework,
69
+ router: plan.repository.router,
70
+ routes: plan.scope.routes,
71
+ };
72
+ const existingConfig = readJson(join(appDir, CONFIG_FILE));
73
+ if (!existingConfig) {
74
+ writeFile(appDir, CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, dryRun);
75
+ written.push({ path: CONFIG_FILE, action: "create", description: "Locations for refino verify." });
76
+ }
77
+ else {
78
+ const routes = [...new Set([...(existingConfig.routes ?? []), ...config.routes])];
79
+ const merged = { ...config, ...existingConfig, routes };
80
+ if (JSON.stringify(merged) !== JSON.stringify(existingConfig)) {
81
+ writeFile(appDir, CONFIG_FILE, `${JSON.stringify(merged, null, 2)}\n`, dryRun);
82
+ written.push({ path: CONFIG_FILE, action: "modify", description: "Recorded the routes selected in this run." });
83
+ }
84
+ else {
85
+ skipped.push({ path: CONFIG_FILE, reason: "Up to date." });
86
+ }
87
+ }
88
+ // 4. package.json dependencies.
89
+ const packageJsonPath = join(appDir, "package.json");
90
+ const pkg = readJson(packageJsonPath);
91
+ let installCommand = null;
92
+ if (pkg) {
93
+ const dependencies = { ...(pkg.dependencies ?? {}) };
94
+ const wanted = plan.refino ? ["@getrefino/core", "@getrefino/react"] : ["@getrefino/core", "@getrefino/react", "@getrefino/github"];
95
+ const missing = wanted.filter((name) => !dependencies[name]);
96
+ if (missing.length > 0) {
97
+ for (const name of missing)
98
+ dependencies[name] = version;
99
+ const ordered = Object.fromEntries(Object.keys(dependencies).sort().map((key) => [key, dependencies[key]]));
100
+ const text = readText(packageJsonPath) ?? "";
101
+ const indent = text.match(/^(\s+)"/m)?.[1] ?? " ";
102
+ const next = { ...pkg, dependencies: ordered };
103
+ writeFile(appDir, "package.json", `${JSON.stringify(next, null, indent)}\n`, dryRun);
104
+ written.push({ path: "package.json", action: "modify", description: `Added ${missing.join(", ")}.` });
105
+ installCommand = packageManagerInstall(plan.repository.packageManager);
106
+ }
107
+ else {
108
+ skipped.push({ path: "package.json", reason: "Refino packages already listed." });
109
+ }
110
+ }
111
+ else {
112
+ skipped.push({ path: "package.json", reason: "Not found or not valid JSON." });
113
+ }
114
+ // 5. .env.example block (self-hosted only: a hosted site has no variables to document).
115
+ const envPath = join(appDir, ".env.example");
116
+ const envText = readText(envPath);
117
+ const envBlock = ENV_EXAMPLE_BLOCK.replace("COPY_FILE_PATH=", `COPY_FILE_PATH=${plan.repository.appRoot === "." ? plan.contentFile.path : `${plan.repository.appRoot}/${plan.contentFile.path}`}`);
118
+ if (plan.refino) {
119
+ // Nothing to document.
120
+ }
121
+ else if (envText === null) {
122
+ writeFile(appDir, ".env.example", `${envBlock.trimStart()}`, dryRun);
123
+ written.push({ path: ".env.example", action: "create", description: "Documented editor and persistence variables." });
124
+ }
125
+ else if (!envText.includes("COPY_GITHUB_TOKEN")) {
126
+ writeFile(appDir, ".env.example", `${envText.replace(/\s*$/, "\n")}${envBlock}`, dryRun);
127
+ written.push({ path: ".env.example", action: "append", description: "Appended Refino variables." });
128
+ }
129
+ else {
130
+ skipped.push({ path: ".env.example", reason: "Already documents Refino variables." });
131
+ }
132
+ // 6. Boilerplate: only files that do not exist.
133
+ for (const file of boilerplateFiles(plan)) {
134
+ if (exists(join(appDir, file.path))) {
135
+ skipped.push({ path: file.path, reason: "Exists; not overwritten." });
136
+ continue;
137
+ }
138
+ writeFile(appDir, file.path, file.content, dryRun);
139
+ written.push({ path: file.path, action: "create", description: "Generated boilerplate." });
140
+ }
141
+ return { dryRun, written, skipped, installCommand };
142
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Static data flow from a string literal to the screen.
3
+ *
4
+ * The scanner only reports a string when there is evidence in the source
5
+ * that it reaches a reader. Beyond literal JSX text that means following a
6
+ * few well-defined shapes: a constant interpolated into JSX, an array the
7
+ * file maps into elements, a record indexed then mapped, a prop a component
8
+ * renders, a default for such a prop, a conditional between two literals,
9
+ * and a state value a handler sets to a literal. Everything here is
10
+ * syntactic; nothing is executed and no type checker is used.
11
+ *
12
+ * The rule that keeps this conservative: start from a *render sink* (a JSX
13
+ * expression child, or a prop a component renders) and walk backwards to
14
+ * literals. Never start from a constant and guess that it might be shown.
15
+ */
16
+ import ts from "typescript";
17
+ import type { PathAliases } from "./graph.js";
18
+ /** What a component does with a prop it receives. */
19
+ export type PropRole =
20
+ /** Rendered as a JSX child: `<p>{feature}</p>`. */
21
+ "text"
22
+ /** Mapped into elements whose text is the item: `items.map((i) => <li>{i}</li>)`. */
23
+ | "list";
24
+ export interface PropRenderInfo {
25
+ readonly role: PropRole;
26
+ /** Literal default from the destructuring pattern, when the prop has one. */
27
+ readonly defaultText: string | null;
28
+ /** Where the default is written, for reporting. */
29
+ readonly defaultNode: ts.Node | null;
30
+ }
31
+ export interface ComponentRenderInfo {
32
+ /** Props the component renders as visible text, by prop name. */
33
+ readonly props: ReadonlyMap<string, PropRenderInfo>;
34
+ readonly sourceFile: ts.SourceFile;
35
+ /** Repository-relative file that defines the component. */
36
+ readonly file: string;
37
+ }
38
+ export interface DataflowContext {
39
+ readonly appDir: string;
40
+ readonly aliases: PathAliases;
41
+ /** Component render maps, keyed by `file#Component`. */
42
+ readonly components: Map<string, ComponentRenderInfo | null>;
43
+ }
44
+ export declare function createDataflowContext(appDir: string, aliases: PathAliases): DataflowContext;
45
+ export declare function unwrap(expression: ts.Expression): ts.Expression;
46
+ export declare function literalText(expression: ts.Expression | undefined): string | null;
47
+ /** Function-like nodes that can be a component body. */
48
+ type FunctionLike = ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression;
49
+ /** Which props of a component in this module render as visible text. */
50
+ export declare function componentRenderInfo(sourceFile: ts.SourceFile, file: string, name: string): ComponentRenderInfo | null;
51
+ /** Where a JSX tag used in `sourceFile` is defined, following one local import. */
52
+ export declare function resolveComponent(context: DataflowContext, sourceFile: ts.SourceFile, absoluteFile: string, tag: string): ComponentRenderInfo | null;
53
+ export interface ResolvedString {
54
+ readonly text: string;
55
+ /** Node to report the position of (the literal itself). */
56
+ readonly node: ts.Node;
57
+ readonly sourceFile: ts.SourceFile;
58
+ /** Repository-relative file holding the literal. */
59
+ readonly file: string;
60
+ /** Variable, prop or record name the value came from. */
61
+ readonly collection: string | null;
62
+ /** A stable, non-text key for the value when the source provides one. */
63
+ readonly itemKey: string | null;
64
+ /** Position within an array, when that is the only distinguishing feature. */
65
+ readonly index: number | null;
66
+ /** Object field the value was written under. */
67
+ readonly fieldKey: string | null;
68
+ /** Sentence fragment explaining how the value reaches the screen. */
69
+ readonly why: string;
70
+ /** True when a human should confirm the id or the inclusion. */
71
+ readonly ambiguous: boolean;
72
+ }
73
+ /** The nearest enclosing function, else the source file. */
74
+ export declare function enclosingScope(node: ts.Node): ts.Node;
75
+ /** Find `const NAME = …` visible from `node`, searching inner scopes first. */
76
+ export declare function findBinding(node: ts.Node, name: string): ts.VariableDeclaration | null;
77
+ /** Elements of an array literal that are all plain string literals. */
78
+ export declare function arrayOfStrings(expression: ts.Expression | undefined): ts.Expression[] | null;
79
+ /** An object literal whose every value is an array of strings, keyed by property name. */
80
+ export declare function recordOfStringArrays(expression: ts.Expression | undefined): Map<string, ts.Expression[]> | null;
81
+ /**
82
+ * Literals a `useState` value can hold: its initializer and every literal
83
+ * passed to its setter. Only recognised for the exact
84
+ * `const [value, setValue] = useState(...)` shape.
85
+ */
86
+ export declare function stateLiterals(scope: ts.Node, name: string): ts.Expression[] | null;
87
+ /** The callback of `<target>.map(cb)`, when the call maps `target`. */
88
+ export declare function mapCallbackOf(node: ts.Node): {
89
+ target: ts.Expression;
90
+ callback: FunctionLike;
91
+ } | null;
92
+ /** Does this callback render its first parameter directly as visible text? */
93
+ export declare function callbackRendersItem(callback: FunctionLike): boolean;
94
+ /** The object-literal field an identifier in a map callback refers to (`item.answer`). */
95
+ export declare function fieldNameOf(expression: ts.Expression, paramName: string | null, destructured: ReadonlySet<string>): string | null;
96
+ export {};