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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,15 @@ const markdown = renderAgentInstructions(plan);
22
22
  const result = verifyIntegration("/path/to/site");
23
23
  ```
24
24
 
25
+ `verifyIntegration` is entirely local: it proves that the files in the
26
+ repository agree with each other, including which Refino site they name. It
27
+ cannot prove that site still exists, because it makes no network calls and
28
+ holds no credential — the deployed site's `/edit` flow is what checks that, and
29
+ says so when the id is stale.
30
+
25
31
  All results are plain JSON. No network calls, no model calls. It never rewrites
26
- JSX and never overwrites an existing file.
32
+ JSX. It overwrites no existing file except the generated Refino constants
33
+ module and the `refino` block of `refino.config.json`, and those only when a
34
+ plan carries a different site id — how a reconnected site is re-recorded.
27
35
 
28
36
  <https://refino.dev>
package/dist/apply.js CHANGED
@@ -25,7 +25,11 @@ export function applyPlan(appDir, plan, options = {}) {
25
25
  const skipped = [];
26
26
  const version = options.packageVersion ?? dependencyRange();
27
27
  // 1. Plan + instructions (always rewritten: they describe this run).
28
- writeFile(appDir, PLAN_FILE, `${JSON.stringify(plan, null, 2)}\n`, dryRun);
28
+ // The plan is committed to the customer's repository, so it must not carry
29
+ // the absolute path of whichever machine happened to run init. Relative to
30
+ // the file itself the repository root is ".", which is what a reader needs.
31
+ const portablePlan = { ...plan, repository: { ...plan.repository, root: "." } };
32
+ writeFile(appDir, PLAN_FILE, `${JSON.stringify(portablePlan, null, 2)}\n`, dryRun);
29
33
  written.push({ path: PLAN_FILE, action: "create", description: "Machine-readable migration plan." });
30
34
  writeFile(appDir, INSTRUCTIONS_FILE, renderAgentInstructions(plan), dryRun);
31
35
  written.push({ path: INSTRUCTIONS_FILE, action: "create", description: "Repository-specific agent instructions." });
@@ -76,10 +80,24 @@ export function applyPlan(appDir, plan, options = {}) {
76
80
  }
77
81
  else {
78
82
  const routes = [...new Set([...(existingConfig.routes ?? []), ...config.routes])];
79
- const merged = { ...config, ...existingConfig, routes };
83
+ // The existing file wins for everything the owner may have tuned, but not
84
+ // for which Refino site this is: `--refino-site` is an explicit
85
+ // instruction, and a site reconnected in the dashboard has a new id. Left
86
+ // to the old values the site keeps asking Refino to authorize a site that
87
+ // no longer exists, which is refused as editor_site.
88
+ const connection = plan.refino
89
+ ? { refino: config.refino, endpoint: config.endpoint, ...(config.sessionEndpoint ? { sessionEndpoint: config.sessionEndpoint } : {}) }
90
+ : {};
91
+ const merged = { ...config, ...existingConfig, ...connection, routes };
80
92
  if (JSON.stringify(merged) !== JSON.stringify(existingConfig)) {
93
+ const previousSiteId = existingConfig.refino?.siteId;
94
+ const reconnected = plan.refino != null && previousSiteId !== undefined && previousSiteId !== plan.refino.siteId;
81
95
  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." });
96
+ written.push({
97
+ path: CONFIG_FILE,
98
+ action: "modify",
99
+ description: reconnected ? `Now connected to Refino site ${plan.refino.siteId} (was ${previousSiteId}).` : "Recorded the routes selected in this run.",
100
+ });
83
101
  }
84
102
  else {
85
103
  skipped.push({ path: CONFIG_FILE, reason: "Up to date." });
@@ -129,10 +147,31 @@ export function applyPlan(appDir, plan, options = {}) {
129
147
  else {
130
148
  skipped.push({ path: ".env.example", reason: "Already documents Refino variables." });
131
149
  }
132
- // 6. Boilerplate: only files that do not exist.
150
+ // 6. Boilerplate: only files that do not exist. The one exception is the
151
+ // generated Refino constants module: it holds no hand-written code, only the
152
+ // public site id and app URL, and a site reconnected in the dashboard has a
153
+ // new id. Keeping the old one there silently breaks editing, and `init
154
+ // --refino-site <id>` is the documented way to record the new one, so the
155
+ // stale copy is rewritten instead of skipped.
156
+ const site = plan.refino;
157
+ const siteModule = site ? `${plan.integration.boilerplateDir}/refino-site` : null;
133
158
  for (const file of boilerplateFiles(plan)) {
134
- if (exists(join(appDir, file.path))) {
135
- skipped.push({ path: file.path, reason: "Exists; not overwritten." });
159
+ const absolute = join(appDir, file.path);
160
+ if (exists(absolute)) {
161
+ const isSiteModule = site !== null && siteModule !== null && file.path.replace(/\.[cm]?[jt]sx?$/, "") === siteModule;
162
+ const currentText = isSiteModule ? (readText(absolute) ?? "") : "";
163
+ const namesThisSite = isSiteModule && currentText.includes(JSON.stringify(site.siteId)) && currentText.includes(JSON.stringify(site.appUrl));
164
+ if (!isSiteModule || namesThisSite) {
165
+ skipped.push({ path: file.path, reason: "Exists; not overwritten." });
166
+ continue;
167
+ }
168
+ const previous = /REFINO_SITE_ID\s*=\s*"(site_[a-f0-9]{32})"/.exec(currentText)?.[1];
169
+ writeFile(appDir, file.path, file.content, dryRun);
170
+ written.push({
171
+ path: file.path,
172
+ action: "modify",
173
+ description: `Now names Refino site ${site.siteId} at ${site.appUrl}${previous && previous !== site.siteId ? ` (was ${previous})` : ""}.`,
174
+ });
136
175
  continue;
137
176
  }
138
177
  writeFile(appDir, file.path, file.content, dryRun);
package/dist/index.d.ts CHANGED
@@ -9,7 +9,9 @@ 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
11
  export type { TemplateFile } from "./templates.js";
12
- export { DEFAULT_REFINO_APP_URL, REFINO_SITE_ID_PATTERN, hostedFiles } from "./templates-hosted.js";
12
+ export { INSTALL_METHODS, isInstallMethod, resolveInstallMethod } from "./install-method.js";
13
+ export type { InstallMethod } from "./install-method.js";
14
+ export { DEFAULT_REFINO_APP_URL, REFINO_SITE_ID_PATTERN, analyticsFramework, analyticsHosting, hostedFiles } from "./templates-hosted.js";
13
15
  export type { RefinoSiteConfig } from "./templates-hosted.js";
14
16
  export { verifyIntegration } from "./verify.js";
15
17
  export type { VerifyOptions } from "./verify.js";
package/dist/index.js CHANGED
@@ -5,5 +5,6 @@ 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 { DEFAULT_REFINO_APP_URL, REFINO_SITE_ID_PATTERN, hostedFiles } from "./templates-hosted.js";
8
+ export { INSTALL_METHODS, isInstallMethod, resolveInstallMethod } from "./install-method.js";
9
+ export { DEFAULT_REFINO_APP_URL, REFINO_SITE_ID_PATTERN, analyticsFramework, analyticsHosting, hostedFiles } from "./templates-hosted.js";
9
10
  export { verifyIntegration } from "./verify.js";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * How Refino was installed into a site.
3
+ *
4
+ * The value is never inferred. Environment variables that a coding agent
5
+ * happens to set are not evidence: they are set by wrappers, inherited by
6
+ * subshells, and copied between tools. So the only sources are an explicit
7
+ * `refino init --via <method>` and an explicit `REFINO_INSTALL_METHOD` in
8
+ * the environment, and anything else stays `unknown`.
9
+ *
10
+ * The value ends up in the site's public `refino/refino-site.ts`, next to
11
+ * the site id: committed, readable, and changeable by the owner. It is not
12
+ * a secret and it identifies a tool, never a person.
13
+ *
14
+ * This list must stay identical to `INSTALL_METHODS` in
15
+ * `packages/analytics/src/taxonomy.ts`; `scripts/install-method.test.mjs`
16
+ * checks that it does.
17
+ */
18
+ export declare const INSTALL_METHODS: readonly ["claude", "codex", "gemini", "grok", "manual", "cli", "other", "unknown"];
19
+ export type InstallMethod = (typeof INSTALL_METHODS)[number];
20
+ export declare function isInstallMethod(value: unknown): value is InstallMethod;
21
+ /**
22
+ * Resolve `--via`, then `REFINO_INSTALL_METHOD`, then `unknown`. An
23
+ * unrecognised name is `other`: the installer said something, and pretending
24
+ * they said nothing would be as wrong as guessing which agent they meant.
25
+ */
26
+ export declare function resolveInstallMethod(flag: string | undefined, env?: Readonly<Record<string, string | undefined>>): InstallMethod;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * How Refino was installed into a site.
3
+ *
4
+ * The value is never inferred. Environment variables that a coding agent
5
+ * happens to set are not evidence: they are set by wrappers, inherited by
6
+ * subshells, and copied between tools. So the only sources are an explicit
7
+ * `refino init --via <method>` and an explicit `REFINO_INSTALL_METHOD` in
8
+ * the environment, and anything else stays `unknown`.
9
+ *
10
+ * The value ends up in the site's public `refino/refino-site.ts`, next to
11
+ * the site id: committed, readable, and changeable by the owner. It is not
12
+ * a secret and it identifies a tool, never a person.
13
+ *
14
+ * This list must stay identical to `INSTALL_METHODS` in
15
+ * `packages/analytics/src/taxonomy.ts`; `scripts/install-method.test.mjs`
16
+ * checks that it does.
17
+ */
18
+ export const INSTALL_METHODS = ["claude", "codex", "gemini", "grok", "manual", "cli", "other", "unknown"];
19
+ export function isInstallMethod(value) {
20
+ return typeof value === "string" && INSTALL_METHODS.includes(value);
21
+ }
22
+ /**
23
+ * Resolve `--via`, then `REFINO_INSTALL_METHOD`, then `unknown`. An
24
+ * unrecognised name is `other`: the installer said something, and pretending
25
+ * they said nothing would be as wrong as guessing which agent they meant.
26
+ */
27
+ export function resolveInstallMethod(flag, env = {}) {
28
+ const raw = (flag ?? env.REFINO_INSTALL_METHOD ?? "").trim().toLowerCase();
29
+ if (raw === "")
30
+ return "unknown";
31
+ return isInstallMethod(raw) ? raw : "other";
32
+ }
package/dist/plan.js CHANGED
@@ -60,7 +60,7 @@ export function resolveRefinoSite(option) {
60
60
  catch {
61
61
  throw new Error(`Refino app URL must be an https origin such as ${DEFAULT_REFINO_APP_URL}, got ${JSON.stringify(raw)}.`);
62
62
  }
63
- return { siteId: option.siteId, appUrl };
63
+ return { siteId: option.siteId, appUrl, installMethod: option.installMethod ?? "unknown" };
64
64
  }
65
65
  export function hostLabel(kind) {
66
66
  switch (kind) {
@@ -9,20 +9,28 @@
9
9
  * → POST Refino /api/editor/token → 12 h editor token in sessionStorage
10
10
  * → <RefinoProvider endpoint={Refino copy API} headers={bearer}>
11
11
  */
12
+ import type { InstallMethod } from "./install-method.js";
12
13
  import type { MigrationPlan } from "./types.js";
13
14
  import type { TemplateFile } from "./templates.js";
14
15
  export declare const DEFAULT_REFINO_APP_URL = "https://app.refino.dev";
15
16
  export interface RefinoSiteConfig {
16
17
  readonly siteId: string;
17
18
  readonly appUrl: string;
19
+ /** What `refino init --via <method>` was told. "unknown" when it was told nothing. */
20
+ readonly installMethod: InstallMethod;
21
+ /** Framework, as the analytics vocabulary names it. */
22
+ readonly framework: string;
23
+ /** Hosting provider, as the analytics vocabulary names it. */
24
+ readonly hosting: string;
25
+ readonly packageVersion: string;
18
26
  }
19
27
  export declare const REFINO_SITE_ID_PATTERN: RegExp;
20
28
  /** Public configuration; safe to ship to the browser and to commit. */
21
29
  export declare const REFINO_SITE: (config: RefinoSiteConfig) => string;
22
30
  /** Browser-only client: PKCE, the authorization round trip and the session in sessionStorage. */
23
- export declare const REFINO_CLIENT = "/**\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";
31
+ export declare const REFINO_CLIENT = "/**\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_INSTALL, 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({\n grant_type: \"authorization_code\",\n code,\n code_verifier: pending.verifier,\n site_id: REFINO_SITE_ID,\n redirect_uri: callbackUrl(),\n // The public install facts from refino-site.ts, sent once per\n // sign-in. Refino records them the first time and ignores them\n // afterwards. Remove this line and everything still works.\n install: REFINO_INSTALL,\n }),\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\n/**\n * The three editor events this site reports, and the only ones it ever\n * sends. There is no analytics SDK on this site: no autocapture, no session\n * recording, no third-party script, no cookie. Each event is one small POST\n * to Refino carrying an event name and, for a revert, whether it was one\n * word or all of them \u2014 never your page, your copy, your visitors or your\n * repository. `edit_saved` is not in this list on purpose: a save is a\n * commit, and Refino records that on its own side where it cannot be faked\n * or lost.\n */\nconst REPORTABLE_EVENTS = [\"editor_opened\", \"edit_started\", \"edit_reverted\"] as const;\n\ntype ReportableEvent = (typeof REPORTABLE_EVENTS)[number];\n\ninterface EditorEventLike {\n readonly name: string;\n readonly properties: Readonly<Record<string, unknown>>;\n}\n\n/**\n * Fire and forget. A failure here is invisible and harmless: nothing in the\n * editor waits for it, retries it, or behaves differently when it fails.\n */\nexport function reportEditorEvent(event: EditorEventLike): void {\n const session = readEditorSession();\n if (!session) return;\n if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;\n const name = event.name as ReportableEvent;\n const scope = event.properties.scope;\n try {\n void fetch(`${REFINO_APP_URL}/api/editor/events`, {\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n keepalive: true,\n headers: { \"content-type\": \"application/json\", authorization: `Bearer ${session.token}` },\n body: JSON.stringify(scope === \"one\" || scope === \"all\" ? { event: name, scope } : { event: name }),\n }).catch(() => {});\n } catch {\n // Never let reporting affect editing.\n }\n}\n";
24
32
  /** Provider wrapper: edit mode is decided in the browser from the stored session. */
25
- export declare const COPY_EDITING_HOSTED = "\"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";
33
+ export declare const COPY_EDITING_HOSTED = "\"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, reportEditorEvent } 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} onEvent={reportEditorEvent}>\n {children}\n </RefinoProvider>\n );\n}\n";
26
34
  /** The /edit page: starts the flow, finishes it on return, shows state. Works as a static export. */
27
35
  export declare const EDIT_PAGE_HOSTED = "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { beginAuthorization, clearEditorSession, completeAuthorization, readEditorSession, safeReturnTo } from \"./refino-client\";\n\ntype Phase = { kind: \"loading\" } | { kind: \"signed-in\" } | { kind: \"error\"; message: string };\n\n/**\n * Owner entry point for Refino-hosted editing. Visiting /edit sends the\n * owner to Refino to sign in and authorize this site; Refino sends them\n * back here with a one-time code, which is exchanged for an editor session\n * and scrubbed from the URL. Restyle freely; keep the behaviour.\n */\nexport default function EditPage() {\n const [phase, setPhase] = useState<Phase>({ kind: \"loading\" });\n\n useEffect(() => {\n let cancelled = false;\n const params = new URLSearchParams(window.location.search);\n if (params.has(\"code\") || params.has(\"state\")) {\n void completeAuthorization().then((result) => {\n if (cancelled) return;\n if (result.ok) window.location.assign(result.returnTo);\n else setPhase({ kind: \"error\", message: result.error });\n });\n } else if (readEditorSession()) {\n setPhase({ kind: \"signed-in\" });\n } else if (params.get(\"error\")) {\n setPhase({ kind: \"error\", message: params.get(\"error\") ?? \"Sign-in failed.\" });\n } else {\n void beginAuthorization(safeReturnTo(params.get(\"return_to\")));\n }\n return () => {\n cancelled = true;\n };\n }, []);\n\n return (\n <main style={{ maxWidth: 420, margin: \"10vh auto\", padding: 24, fontFamily: \"system-ui, sans-serif\" }}>\n {phase.kind === \"loading\" && <p>Signing you in with Refino\u2026</p>}\n {phase.kind === \"signed-in\" && (\n <>\n <h1>Edit mode is on</h1>\n <p>Go to the site, click any text, and press Save when you are done.</p>\n <p>\n <a href=\"/\">Open the site</a>\n </p>\n <button\n type=\"button\"\n onClick={() => {\n clearEditorSession();\n setPhase({ kind: \"loading\" });\n void beginAuthorization(\"/\");\n }}\n >\n Sign in as someone else\n </button>\n </>\n )}\n {phase.kind === \"error\" && (\n <>\n <h1>Could not start editing</h1>\n <p role=\"alert\">{phase.message}</p>\n <button type=\"button\" onClick={() => void beginAuthorization(\"/\")}>\n Try again\n </button>\n </>\n )}\n </main>\n );\n}\n";
28
36
  /** Next.js App Router route file for /edit: re-exports the generated client page. */
@@ -30,5 +38,12 @@ export declare const EDIT_ROUTE_NEXT_HOSTED: (pageImport: string) => string;
30
38
  /** Next.js Pages Router page for /edit. */
31
39
  export declare const EDIT_PAGE_NEXT_PAGES_HOSTED: (pageImport: string) => string;
32
40
  export declare const EDIT_LAYOUT_HOSTED = "import type { Metadata } from \"next\";\nimport type { ReactNode } from \"react\";\n\n/** /edit is the owner's entry point, not a public page; the page itself is a client component and cannot export metadata. */\nexport const metadata: Metadata = { title: \"Edit this site\", robots: { index: false, follow: false } };\n\nexport default function EditLayout({ children }: { children: ReactNode }) {\n return children;\n}\n";
41
+ /**
42
+ * The framework and hosting names Refino's analytics vocabulary uses. A
43
+ * provider the inspector did not recognise is "other" and an absent one is
44
+ * "unknown"; neither is ever guessed from something else.
45
+ */
46
+ export declare function analyticsFramework(framework: MigrationPlan["repository"]["framework"]): string;
47
+ export declare function analyticsHosting(hosting: string | null): string;
33
48
  /** Files for a Refino-connected site. No server code, no secrets, in any framework. */
34
49
  export declare function hostedFiles(plan: MigrationPlan): TemplateFile[];
@@ -1,14 +1,31 @@
1
+ import { TOOL_VERSION } from "./plan.js";
1
2
  export const DEFAULT_REFINO_APP_URL = "https://app.refino.dev";
2
3
  export const REFINO_SITE_ID_PATTERN = /^site_[a-f0-9]{32}$/;
3
4
  /** Public configuration; safe to ship to the browser and to commit. */
4
5
  export const REFINO_SITE = (config) => `/**
5
- * Public Refino configuration for this site. Neither value is a secret:
6
- * the site id appears in editor URLs and the app URL is where owners sign
7
- * in. Generated by Refino; change it only when the site is
8
- * reconnected to Refino.
6
+ * Public Refino configuration for this site. Nothing here is a secret: the
7
+ * site id appears in editor URLs and the app URL is where owners sign in.
8
+ * Generated by Refino; change it only when the site is reconnected to
9
+ * Refino.
9
10
  */
10
11
  export const REFINO_SITE_ID = ${JSON.stringify(config.siteId)};
11
12
  export const REFINO_APP_URL = ${JSON.stringify(config.appUrl)};
13
+
14
+ /**
15
+ * How this site was set up, recorded once at install time and sent to
16
+ * Refino when the owner signs in to edit. It tells Refino which
17
+ * installation routes work, nothing more: there is no personal data here,
18
+ * no repository content, and no page content. \`method\` is whatever
19
+ * \`refino init --via <method>\` was told; it is "unknown" when it was told
20
+ * nothing, and it is never guessed. Edit or delete these values freely —
21
+ * the editor works exactly the same either way.
22
+ */
23
+ export const REFINO_INSTALL = {
24
+ method: ${JSON.stringify(config.installMethod)},
25
+ framework: ${JSON.stringify(config.framework)},
26
+ hosting: ${JSON.stringify(config.hosting)},
27
+ packageVersion: ${JSON.stringify(config.packageVersion)},
28
+ } as const;
12
29
  `;
13
30
  /** Browser-only client: PKCE, the authorization round trip and the session in sessionStorage. */
14
31
  export const REFINO_CLIENT = `/**
@@ -18,7 +35,7 @@ export const REFINO_CLIENT = `/**
18
35
  * this site, and Refino re-checks the account's entitlement on every load
19
36
  * and save. Never import this from server code.
20
37
  */
21
- import { REFINO_APP_URL, REFINO_SITE_ID } from "./refino-site";
38
+ import { REFINO_APP_URL, REFINO_INSTALL, REFINO_SITE_ID } from "./refino-site";
22
39
 
23
40
  const PKCE_KEY = "refino.pkce";
24
41
  const SESSION_KEY = "refino.editor";
@@ -142,7 +159,17 @@ export async function completeAuthorization(search: string = window.location.sea
142
159
  mode: "cors",
143
160
  credentials: "omit",
144
161
  headers: { "content-type": "application/json", accept: "application/json" },
145
- body: JSON.stringify({ grant_type: "authorization_code", code, code_verifier: pending.verifier, site_id: REFINO_SITE_ID, redirect_uri: callbackUrl() }),
162
+ body: JSON.stringify({
163
+ grant_type: "authorization_code",
164
+ code,
165
+ code_verifier: pending.verifier,
166
+ site_id: REFINO_SITE_ID,
167
+ redirect_uri: callbackUrl(),
168
+ // The public install facts from refino-site.ts, sent once per
169
+ // sign-in. Refino records them the first time and ignores them
170
+ // afterwards. Remove this line and everything still works.
171
+ install: REFINO_INSTALL,
172
+ }),
146
173
  });
147
174
  } catch {
148
175
  return { ok: false, error: "Could not reach Refino to finish signing in." };
@@ -155,6 +182,49 @@ export async function completeAuthorization(search: string = window.location.sea
155
182
  storage()?.setItem(SESSION_KEY, JSON.stringify(session));
156
183
  return { ok: true, returnTo: pending.returnTo };
157
184
  }
185
+
186
+ /**
187
+ * The three editor events this site reports, and the only ones it ever
188
+ * sends. There is no analytics SDK on this site: no autocapture, no session
189
+ * recording, no third-party script, no cookie. Each event is one small POST
190
+ * to Refino carrying an event name and, for a revert, whether it was one
191
+ * word or all of them — never your page, your copy, your visitors or your
192
+ * repository. \`edit_saved\` is not in this list on purpose: a save is a
193
+ * commit, and Refino records that on its own side where it cannot be faked
194
+ * or lost.
195
+ */
196
+ const REPORTABLE_EVENTS = ["editor_opened", "edit_started", "edit_reverted"] as const;
197
+
198
+ type ReportableEvent = (typeof REPORTABLE_EVENTS)[number];
199
+
200
+ interface EditorEventLike {
201
+ readonly name: string;
202
+ readonly properties: Readonly<Record<string, unknown>>;
203
+ }
204
+
205
+ /**
206
+ * Fire and forget. A failure here is invisible and harmless: nothing in the
207
+ * editor waits for it, retries it, or behaves differently when it fails.
208
+ */
209
+ export function reportEditorEvent(event: EditorEventLike): void {
210
+ const session = readEditorSession();
211
+ if (!session) return;
212
+ if (!(REPORTABLE_EVENTS as readonly string[]).includes(event.name)) return;
213
+ const name = event.name as ReportableEvent;
214
+ const scope = event.properties.scope;
215
+ try {
216
+ void fetch(\`\${REFINO_APP_URL}/api/editor/events\`, {
217
+ method: "POST",
218
+ mode: "cors",
219
+ credentials: "omit",
220
+ keepalive: true,
221
+ headers: { "content-type": "application/json", authorization: \`Bearer \${session.token}\` },
222
+ body: JSON.stringify(scope === "one" || scope === "all" ? { event: name, scope } : { event: name }),
223
+ }).catch(() => {});
224
+ } catch {
225
+ // Never let reporting affect editing.
226
+ }
227
+ }
158
228
  `;
159
229
  /** Provider wrapper: edit mode is decided in the browser from the stored session. */
160
230
  export const COPY_EDITING_HOSTED = `"use client";
@@ -164,7 +234,7 @@ import { RefinoProvider } from "@getrefino/react";
164
234
  import type { ReactNode } from "react";
165
235
  import { useCallback, useEffect, useState } from "react";
166
236
 
167
- import { clearEditorSession, editorEndpoint, readEditorSession } from "./refino-client";
237
+ import { clearEditorSession, editorEndpoint, readEditorSession, reportEditorEvent } from "./refino-client";
168
238
  import type { EditorSession } from "./refino-client";
169
239
 
170
240
  interface CopyEditingProps {
@@ -200,7 +270,7 @@ export function CopyEditing({ content, children }: CopyEditingProps) {
200
270
  }, []);
201
271
 
202
272
  return (
203
- <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit}>
273
+ <RefinoProvider content={content} editing={session !== null} endpoint={editorEndpoint()} headers={headers} signInHref="/edit" onExit={onExit} onEvent={reportEditorEvent}>
204
274
  {children}
205
275
  </RefinoProvider>
206
276
  );
@@ -306,6 +376,28 @@ function relativeImport(fromFile, toFile) {
306
376
  const up = fromParts.length === 0 ? "." : fromParts.map(() => "..").join("/");
307
377
  return `${up}/${toParts.join("/")}`.replace(/\.(tsx?|jsx?)$/, "");
308
378
  }
379
+ /**
380
+ * The framework and hosting names Refino's analytics vocabulary uses. A
381
+ * provider the inspector did not recognise is "other" and an absent one is
382
+ * "unknown"; neither is ever guessed from something else.
383
+ */
384
+ export function analyticsFramework(framework) {
385
+ switch (framework) {
386
+ case "next":
387
+ return "next";
388
+ case "vite-react":
389
+ return "vite";
390
+ case "react":
391
+ return "react";
392
+ default:
393
+ return "other";
394
+ }
395
+ }
396
+ export function analyticsHosting(hosting) {
397
+ if (!hosting)
398
+ return "unknown";
399
+ return ["cloudflare", "vercel", "netlify"].includes(hosting) ? hosting : "other";
400
+ }
309
401
  /** Files for a Refino-connected site. No server code, no secrets, in any framework. */
310
402
  export function hostedFiles(plan) {
311
403
  const refino = plan.refino;
@@ -313,7 +405,15 @@ export function hostedFiles(plan) {
313
405
  return [];
314
406
  const dir = plan.integration.boilerplateDir;
315
407
  const files = [
316
- { path: `${dir}/refino-site.ts`, content: REFINO_SITE(refino) },
408
+ {
409
+ path: `${dir}/refino-site.ts`,
410
+ content: REFINO_SITE({
411
+ ...refino,
412
+ framework: analyticsFramework(plan.repository.framework),
413
+ hosting: analyticsHosting(plan.repository.hosting),
414
+ packageVersion: TOOL_VERSION,
415
+ }),
416
+ },
317
417
  { path: `${dir}/refino-client.ts`, content: REFINO_CLIENT },
318
418
  { path: `${dir}/copy-editing.tsx`, content: COPY_EDITING_HOSTED },
319
419
  { path: `${dir}/edit-page.tsx`, content: EDIT_PAGE_HOSTED },
package/dist/types.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Structured results shared by the CLI and any future hosted onboarding.
3
3
  * Everything here is plain JSON-serializable data. No secrets, ever.
4
4
  */
5
+ import type { InstallMethod } from "./install-method.js";
5
6
  export type PackageManager = "pnpm" | "npm" | "yarn" | "bun" | "unknown";
6
7
  export type Framework = "next" | "vite-react" | "react" | "unsupported";
7
8
  export type Router = "next-app" | "next-pages" | "react-router" | "single-page" | "unknown";
@@ -236,17 +237,23 @@ export interface MigrationPlan {
236
237
  };
237
238
  readonly unresolved: readonly string[];
238
239
  }
239
- /** Public identifiers of a site connected to Refino. Neither is a secret. */
240
+ /** Public identifiers of a site connected to Refino. None of these is a secret. */
240
241
  export interface RefinoSite {
241
242
  readonly siteId: string;
242
243
  /** Origin of the Refino app, e.g. "https://app.refino.dev". */
243
244
  readonly appUrl: string;
245
+ /**
246
+ * What `refino init --via <method>` was told, or "unknown". Committed to
247
+ * the repository with the site id so the owner can see and change it.
248
+ */
249
+ readonly installMethod: InstallMethod;
244
250
  }
245
251
  export interface PlanOptions {
246
252
  /** Connect to Refino (hosted mode): the site id from the Refino dashboard, and optionally the app origin. */
247
253
  readonly refino?: {
248
254
  readonly siteId: string;
249
255
  readonly appUrl?: string;
256
+ readonly installMethod?: InstallMethod;
250
257
  };
251
258
  /** Routes to migrate. Defaults to ["/"] when the route exists. */
252
259
  readonly routes?: readonly string[];
package/dist/verify.js CHANGED
@@ -143,7 +143,20 @@ function hostedChecks(appDir, inspection, hosted, sourceFiles, rendersProvider,
143
143
  else if (!siteText.includes(JSON.stringify(hosted.siteId)) || !siteText.includes(JSON.stringify(hosted.appUrl)))
144
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
145
  else
146
- add({ id: "refino-site", status: "pass", message: `Connected to Refino site ${hosted.siteId} at ${hosted.appUrl}.` });
146
+ add({
147
+ id: "refino-site",
148
+ status: "pass",
149
+ message: `Connected to Refino site ${hosted.siteId} at ${hosted.appUrl}.`,
150
+ // Deliberately a local check. Verification makes no network calls and
151
+ // holds no credential, so it can prove the repository agrees with
152
+ // itself and nothing more: whether this site id is still the one Refino
153
+ // has is a question only an authorized request can answer, and /edit
154
+ // asks it on every load.
155
+ details: [
156
+ "Checked in this repository only: Refino is never contacted from here, so this cannot prove the site id still exists.",
157
+ `If /edit says the site is not connected to your account, compare ${hosted.siteId} with the site id on your Refino dashboard and re-run \`refino init --agent --yes --refino-site <id>\` if they differ.`,
158
+ ],
159
+ });
147
160
  const missing = ["refino-client", "copy-editing", "edit-page"].filter((name) => !find(`${BOILERPLATE_DIR}/${name}`));
148
161
  if (missing.length > 0)
149
162
  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." });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getrefino/onboarding",
3
- "version": "0.1.0-rc.1",
3
+ "version": "0.1.0-rc.2",
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.1"
41
+ "@getrefino/core": "0.1.0-rc.2"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "22.20.2",