@pithy-sh/ui-react-native 0.0.0-stage → 0.1.0

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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/dist/generatedConfig.d.ts +195 -0
  3. package/dist/generatedConfig.d.ts.map +1 -0
  4. package/dist/generatedConfig.js +323 -0
  5. package/dist/templates.d.ts +48 -0
  6. package/dist/templates.d.ts.map +1 -0
  7. package/dist/templates.js +85 -0
  8. package/dist/version.d.ts +5 -0
  9. package/dist/version.d.ts.map +1 -0
  10. package/dist/version.js +10 -0
  11. package/package.json +83 -4
  12. package/src/generatedConfig.ts +419 -0
  13. package/src/templates.ts +92 -0
  14. package/src/version.ts +13 -0
  15. package/templates/app/[...pithy].tsx +7 -0
  16. package/templates/app/_layout.tsx +31 -0
  17. package/templates/app/index.tsx +4 -0
  18. package/templates/app.config.ts +56 -0
  19. package/templates/metro-proxy.cjs +289 -0
  20. package/templates/metro.config.js +6 -0
  21. package/templates/src/payments-store.native.ts +20 -0
  22. package/templates/src/payments-store.ts +16 -0
  23. package/templates/src/payments.tsx +769 -0
  24. package/templates/src/pithy-config.generated.ts +99 -0
  25. package/templates/src/pithy-config.ts +169 -0
  26. package/templates/src/pithy-locale.tsx +90 -0
  27. package/templates/src/platform.ts +111 -0
  28. package/templates/src/route-modules.ts +76 -0
  29. package/templates/src/router.tsx +387 -0
  30. package/templates/src/routes/app/home.bare.tsx +75 -0
  31. package/templates/src/routes/app/home.tsx +55 -0
  32. package/templates/src/routes/pithy/account-deleted.tsx +69 -0
  33. package/templates/src/routes/pithy/account-suspended.tsx +70 -0
  34. package/templates/src/routes/pithy/agent-consent.tsx +260 -0
  35. package/templates/src/routes/pithy/callback.tsx +74 -0
  36. package/templates/src/routes/pithy/choose-organization.tsx +230 -0
  37. package/templates/src/routes/pithy/connected-applications.tsx +153 -0
  38. package/templates/src/routes/pithy/consent.tsx +453 -0
  39. package/templates/src/routes/pithy/invitation.tsx +239 -0
  40. package/templates/src/routes/pithy/organization-suspended.tsx +51 -0
  41. package/templates/src/routes/pithy/otp.tsx +196 -0
  42. package/templates/src/routes/pithy/paywall.tsx +253 -0
  43. package/templates/src/routes/pithy/pricing.tsx +317 -0
  44. package/templates/src/routes/pithy/sign-in.tsx +673 -0
  45. package/templates/src/routes/pithy/subscription.tsx +240 -0
  46. package/templates/src/session.tsx +290 -0
  47. package/templates/src/theme.ts +100 -0
  48. package/templates/src/transport.ts +290 -0
  49. package/templates/src/turnstile.tsx +117 -0
  50. package/templates/src/useAppFocus.ts +20 -0
  51. package/templates/src/useStoreTransactions.ts +16 -0
  52. package/templates/src/useTurnstileWeb.ts +104 -0
  53. package/templates/tsconfig.json +10 -0
  54. package/README.md +0 -3
@@ -0,0 +1,85 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ //#region src/templates.ts
6
+ /**
7
+ * The React Native templates `pithy ui add react-native` copies into an Expo app.
8
+ *
9
+ * A template library, not a runtime library: it ships text, and nothing imports it at runtime except the CLI's
10
+ * scaffolder. The screens need React Native's toolchain to be checked — its types, a program with no DOM lib, and
11
+ * `react-native-web` to render them under test — so they live here with it rather than in the CLI.
12
+ *
13
+ * The tree mirrors the scaffolded layout exactly, so a template's relative imports resolve here as they will in
14
+ * the app, and the whole library typechecks in place.
15
+ */
16
+ /** Absolute path to the template tree. Resolved from this module, so it holds in a workspace and once published. */
17
+ const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "templates");
18
+ /**
19
+ * The named file groups this library offers, each keyed by path within {@link TEMPLATE_DIR}, grouped as
20
+ * `@pithy-sh/ui-react`'s are so a screen set maps one to one.
21
+ *
22
+ * `base` is written on every scaffold: the Expo app's entry and config, the router, the transport, the locale and
23
+ * the theme. Every other group is a screen set that rides on a capability being composed, with the helpers only
24
+ * those screens use. The home screen is not in a group: {@link HOME_SCREEN} picks one of its two variants.
25
+ *
26
+ * Tests for these files live in this package's `src/`, not in the tree, so nothing here is a test.
27
+ */
28
+ const TEMPLATE_GROUPS = {
29
+ base: [
30
+ "app.config.ts",
31
+ "metro.config.js",
32
+ "metro-proxy.cjs",
33
+ "tsconfig.json",
34
+ "app/_layout.tsx",
35
+ "app/index.tsx",
36
+ "app/[...pithy].tsx",
37
+ "src/theme.ts",
38
+ "src/platform.ts",
39
+ "src/pithy-config.ts",
40
+ "src/pithy-config.generated.ts",
41
+ "src/pithy-locale.tsx",
42
+ "src/router.tsx",
43
+ "src/route-modules.ts",
44
+ "src/transport.ts"
45
+ ],
46
+ auth: [
47
+ "src/session.tsx",
48
+ "src/turnstile.tsx",
49
+ "src/useTurnstileWeb.ts",
50
+ "src/routes/pithy/sign-in.tsx",
51
+ "src/routes/pithy/otp.tsx",
52
+ "src/routes/pithy/callback.tsx",
53
+ "src/routes/pithy/account-suspended.tsx",
54
+ "src/routes/pithy/account-deleted.tsx",
55
+ "src/routes/pithy/organization-suspended.tsx"
56
+ ],
57
+ organization: ["src/routes/pithy/choose-organization.tsx", "src/routes/pithy/invitation.tsx"],
58
+ mcp: ["src/routes/pithy/agent-consent.tsx", "src/routes/pithy/connected-applications.tsx"],
59
+ payments: [
60
+ "src/payments.tsx",
61
+ "src/payments-store.ts",
62
+ "src/payments-store.native.ts",
63
+ "src/useStoreTransactions.ts",
64
+ "src/useAppFocus.ts",
65
+ "src/routes/pithy/paywall.tsx",
66
+ "src/routes/pithy/pricing.tsx",
67
+ "src/routes/pithy/subscription.tsx"
68
+ ],
69
+ consent: ["src/routes/pithy/consent.tsx"]
70
+ };
71
+ /**
72
+ * The adopter's own home screen, in two variants: the auth one carries the signed-in guard and a sign-out, the
73
+ * bare one asks the Worker's health route. Exactly one is written, always to the same place.
74
+ */
75
+ const HOME_SCREEN = {
76
+ target: "src/routes/app/home.tsx",
77
+ auth: "src/routes/app/home.tsx",
78
+ bare: "src/routes/app/home.bare.tsx"
79
+ };
80
+ /** The token the templates use where the name of the Worker the app calls belongs. */
81
+ const WORKER_TOKEN = "__PITHY_WORKER__";
82
+ /** The token `app.config.ts` uses where the app's own name belongs: its `apps/<name>` directory. */
83
+ const APP_TOKEN = "__PITHY_APP__";
84
+ //#endregion
85
+ export { APP_TOKEN, HOME_SCREEN, TEMPLATE_DIR, TEMPLATE_GROUPS, WORKER_TOKEN };
@@ -0,0 +1,5 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /** This package's own version, read from its manifest beside `src/` and `dist/` alike. Node only: the scaffolder reads it. */
4
+ export declare const UI_REACT_NATIVE_VERSION: string;
5
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAOA,8HAA8H;AAC9H,eAAO,MAAM,uBAAuB,EAAE,MAI7B,CAAC"}
@@ -0,0 +1,10 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { readFileSync } from "node:fs";
6
+ //#region src/version.ts
7
+ /** This package's own version, read from its manifest beside `src/` and `dist/` alike. Node only: the scaffolder reads it. */
8
+ const UI_REACT_NATIVE_VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
9
+ //#endregion
10
+ export { UI_REACT_NATIVE_VERSION };
package/package.json CHANGED
@@ -1,6 +1,85 @@
1
1
  {
2
2
  "name": "@pithy-sh/ui-react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/pithy-sh/pithy.git",
8
+ "directory": "packages/ui-react-native"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "src",
13
+ "templates",
14
+ "!src/**/*.test.*",
15
+ "!dist/**/*.test.*"
16
+ ],
17
+ "type": "module",
18
+ "engines": {
19
+ "node": ">=22"
20
+ },
21
+ "exports": {
22
+ "./src/*": {
23
+ "types": "./dist/*.d.ts",
24
+ "default": "./dist/*.js"
25
+ }
26
+ },
27
+ "scripts": {
28
+ "build": "tsdown && tsc -p tsconfig.build.json && bun ../../tooling/build/src/stampDeclarations.ts",
29
+ "typecheck": "bun check -p tsconfig.json && bun check -p tsconfig.templates.json && bun check -p tsconfig.native.json",
30
+ "test": "vitest run",
31
+ "test:node": "vitest run --project=node --project=native",
32
+ "clean": "rm -rf dist .turbo",
33
+ "reset": "bun run clean && rm -rf node_modules"
34
+ },
35
+ "peerDependencies": {
36
+ "react": "^19.0.0",
37
+ "zod": "^4.4.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "react": {
41
+ "optional": true
42
+ },
43
+ "zod": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@pithy-sh/auth": "workspace:*",
49
+ "@pithy-sh/consent": "workspace:*",
50
+ "@pithy-sh/core": "workspace:*",
51
+ "@pithy-sh/i18n": "workspace:*",
52
+ "@pithy-sh/organization": "workspace:*",
53
+ "@pithy-sh/payments": "workspace:*",
54
+ "@pithy-sh/tsconfig": "workspace:*",
55
+ "@pithy-sh/turnstile": "workspace:*",
56
+ "@pithy-sh/ui-core": "workspace:*",
57
+ "@react-native-async-storage/async-storage": "2.2.0",
58
+ "@react-native-google-signin/google-signin": "16.2.0",
59
+ "@testing-library/react": "16.3.3",
60
+ "@types/node": "^22.15.0",
61
+ "@types/react": "^19.2.17",
62
+ "@types/react-dom": "^19.2.3",
63
+ "@vitest/coverage-v8": "^4.1.0",
64
+ "expo": "57.0.31",
65
+ "expo-apple-authentication": "57.0.2",
66
+ "expo-auth-session": "57.0.16",
67
+ "expo-crypto": "57.0.4",
68
+ "expo-iap": "6.3.0",
69
+ "expo-linking": "57.0.13",
70
+ "expo-localization": "57.0.2",
71
+ "expo-router": "57.0.29",
72
+ "expo-secure-store": "57.0.4",
73
+ "expo-web-browser": "57.0.3",
74
+ "happy-dom": "^20.11.1",
75
+ "react": "^19.2.8",
76
+ "react-dom": "^19.2.8",
77
+ "react-native": "0.86.3",
78
+ "react-native-web": "0.21.4",
79
+ "react-native-webview": "13.16.1",
80
+ "tsdown": "^0.23.0",
81
+ "typescript": "^7.0.2",
82
+ "vitest": "^4.1.0",
83
+ "zod": "^4.4.0"
84
+ }
85
+ }
@@ -0,0 +1,419 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * The config an Expo app reads its Worker from: `src/pithy-config.generated.ts`.
8
+ *
9
+ * A Vite app reads the composed capabilities' projections from `virtual:pithy/*` modules at build time. Metro
10
+ * cannot resolve a virtual module, so `pithy ui add react-native` writes the same projections into a file, and
11
+ * `pithy ui sync` rewrites it from the Worker's composed config. A few fields are the adopter's choice rather than
12
+ * the Worker's — which rail each platform sells through, the app's scheme, its universal-link origin, its Google
13
+ * client ids — and a sync keeps those as they were edited ({@link keepAdopterChoices}).
14
+ *
15
+ * The Worker answers on one origin per environment, so the file carries each one, and the app picks one when it is
16
+ * built: {@link ENVIRONMENT_VARIABLE} names the environment, and Expo inlines it into the bundle.
17
+ *
18
+ * This module owns the file's format: {@link renderGeneratedConfig} writes it and {@link readGeneratedConfig} reads
19
+ * it back, so the scaffold, the sync and the template in this package are one shape. Node only; the CLI imports it.
20
+ */
21
+
22
+ /** Where the file sits in the app, relative to the app's directory. */
23
+ export const GENERATED_CONFIG_PATH = "src/pithy-config.generated.ts";
24
+
25
+ /** An absolute http(s) origin: a scheme and a host, no path and no trailing slash. */
26
+ const ORIGIN = /^https?:\/\/[^/?#@\s]+$/;
27
+
28
+ /**
29
+ * The variable an app build reads its environment from: `dev`, `staging` or `prod`, `dev` when unset. Expo inlines
30
+ * every `EXPO_PUBLIC_*` variable into the bundle at build time, so a store build made with it set to `prod` calls
31
+ * the production origin and nothing else.
32
+ */
33
+ export const ENVIRONMENT_VARIABLE = "EXPO_PUBLIC_PITHY_ENV";
34
+
35
+ /** The environments an app build can name, `dev` first. */
36
+ export const APP_ENVIRONMENTS = ["dev", "staging", "prod"] as const;
37
+ export type AppEnvironment = (typeof APP_ENVIRONMENTS)[number];
38
+
39
+ /** Where the Worker answers in each environment. */
40
+ export const Origins = z
41
+ .object({
42
+ dev: z
43
+ .string()
44
+ .regex(ORIGIN)
45
+ .describe(
46
+ "The Worker's local origin under `pithy dev`, such as `http://localhost:8787`. On Android a loopback origin is reached through the emulator's `10.0.2.2`. Set your machine's network address here for a device.",
47
+ ),
48
+ staging: z
49
+ .string()
50
+ .regex(ORIGIN)
51
+ .nullable()
52
+ .optional()
53
+ .describe(
54
+ "The Worker's staging origin. Null when Pithy could not find one: set it by hand. Absent when the project does not deploy to staging.",
55
+ ),
56
+ prod: z
57
+ .string()
58
+ .regex(ORIGIN)
59
+ .nullable()
60
+ .optional()
61
+ .describe(
62
+ "The Worker's production origin. Null when Pithy could not find one: set it by hand. Absent when the project does not deploy to prod.",
63
+ ),
64
+ })
65
+ .describe(
66
+ `Where the Worker answers in each environment. A build calls the one \`${ENVIRONMENT_VARIABLE}\` names, \`dev\` when it is unset.`,
67
+ );
68
+ export type Origins = z.infer<typeof Origins>;
69
+
70
+ /** A rail one platform sells through, or `none` for a build that shows plans and sells nothing. */
71
+ export const RailChoice = z
72
+ .enum(["apple", "google", "stripe", "lemonSqueezy", "paddle", "none"])
73
+ .describe("A rail one platform sells through, or `none` for a build that shows plans and sells nothing.");
74
+ export type RailChoice = z.infer<typeof RailChoice>;
75
+
76
+ /** The rail each platform sells through. */
77
+ export const PlatformRails = z
78
+ .object({
79
+ ios: RailChoice.describe("The rail the iOS app sells through. `apple` by default: StoreKit."),
80
+ android: RailChoice.describe("The rail the Android app sells through. `google` by default: Play Billing."),
81
+ web: RailChoice.describe("The rail the web build sells through. The project's web rail by default."),
82
+ })
83
+ .describe(
84
+ "The rail each platform sells through. A web rail on a phone opens checkout in the system browser and returns through the universal link.",
85
+ );
86
+ export type PlatformRails = z.infer<typeof PlatformRails>;
87
+
88
+ /** The Google OAuth client ids native Google sign-in needs. Public values; never a secret. */
89
+ export const GoogleClientIds = z
90
+ .object({
91
+ webClientId: z
92
+ .string()
93
+ .nullable()
94
+ .describe(
95
+ "The web client id. Native Google sign-in asks for its id token with this audience, the one the Worker checks. Null turns native Google sign-in off.",
96
+ ),
97
+ iosClientId: z
98
+ .string()
99
+ .nullable()
100
+ .describe("The iOS client id. iOS needs it, and its reversed form as a URL scheme. Null on an Android-only app."),
101
+ })
102
+ .describe("The Google OAuth client ids native Google sign-in needs. Public values, never a secret.");
103
+ export type GoogleClientIds = z.infer<typeof GoogleClientIds>;
104
+
105
+ /** One capability's client projection, as the Worker composed it: every field kept, `enabled` checked. */
106
+ const Projection = (capability: string, environment: AppEnvironment) =>
107
+ z
108
+ .looseObject({
109
+ enabled: z.boolean().describe(`Whether ${capability} is composed into the Worker the app calls.`),
110
+ })
111
+ .describe(
112
+ `${capability}'s client projection for ${environment}, exactly as the Worker composes it: the same value a Vite app built for ${environment} reads from \`virtual:pithy/${capability}\`.`,
113
+ );
114
+
115
+ /**
116
+ * One capability's client projection in each environment, keyed like {@link Origins}: a sitekey or a catalog can
117
+ * differ between environments, so a build reads the one for the environment it names.
118
+ */
119
+ const Projections = (capability: string) =>
120
+ z
121
+ .object({
122
+ dev: Projection(capability, "dev"),
123
+ staging: Projection(capability, "staging")
124
+ .optional()
125
+ .describe(`${capability}'s projection for staging. Absent when the project does not deploy to staging.`),
126
+ prod: Projection(capability, "prod")
127
+ .optional()
128
+ .describe(`${capability}'s projection for prod. Absent when the project does not deploy to prod.`),
129
+ })
130
+ .describe(
131
+ `${capability}'s client projection in each environment. A build reads the one \`${ENVIRONMENT_VARIABLE}\` names, \`dev\` when it is unset.`,
132
+ );
133
+
134
+ /** The whole file. */
135
+ export const GeneratedConfig = z
136
+ .object({
137
+ origins: Origins,
138
+ appScheme: z
139
+ .string()
140
+ .regex(/^[a-z][a-z0-9+.-]*$/)
141
+ .describe(
142
+ "The app's own URL scheme, such as `myapp`. A sign-in returns to a link on it, so `trustedOrigins` names it.",
143
+ ),
144
+ universalLinkOrigin: z
145
+ .string()
146
+ .regex(ORIGIN)
147
+ .refine((origin) => origin.startsWith("https://"), "A universal link is https.")
148
+ .nullable()
149
+ .describe(
150
+ "The https origin whose links open the app, or null for none. A checkout in the system browser returns here, since a store will not return to a custom scheme, and so does a sign-in when it is set.",
151
+ ),
152
+ rails: PlatformRails,
153
+ google: GoogleClientIds,
154
+ workerPaths: z
155
+ .array(z.string().regex(/^\/[^/?#\s]+$/))
156
+ .optional()
157
+ .describe(
158
+ "Every base path the Worker's route table answers under, such as `/auth` or `/organizations`, as Pithy composed it. Under `pithy dev` the web build's proxy sends these to the Worker. Absent when Pithy has not listed them: the proxy then takes the kit's default paths.",
159
+ ),
160
+ auth: Projections("auth"),
161
+ i18n: Projections("i18n"),
162
+ payments: Projections("payments"),
163
+ turnstile: Projections("turnstile"),
164
+ consent: Projections("consent"),
165
+ })
166
+ .describe("The config an Expo app reads its Worker from, written by `pithy ui add react-native`.");
167
+ export type GeneratedConfig = z.infer<typeof GeneratedConfig>;
168
+
169
+ /**
170
+ * The fields a sync keeps as the adopter left them. `origins` is shared: {@link keepAdopterChoices} keeps the ones
171
+ * the adopter set, and everything else is the Worker's, and a sync rewrites it.
172
+ */
173
+ export const ADOPTER_CHOICES = [
174
+ "appScheme",
175
+ "universalLinkOrigin",
176
+ "rails",
177
+ "google",
178
+ ] as const satisfies readonly (keyof GeneratedConfig)[];
179
+
180
+ /** The line the value starts on, and the line after it. The format is these two lines and an object literal between. */
181
+ const OPEN = "const config: GeneratedPithyConfig = ";
182
+ const CLOSE = ";\n\nexport default config;\n";
183
+
184
+ /** The width the scaffolded `biome.jsonc` formats at. */
185
+ const LINE_WIDTH = 120;
186
+
187
+ /** A key Biome prints without quotes: an identifier. */
188
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
189
+
190
+ /**
191
+ * `value` as an object literal, printed the way the scaffolded Biome prints one: a key in quotes only when it needs
192
+ * them, every object one entry per line, an array on one line when it fits and holds no object, and a trailing comma
193
+ * after the last entry of anything broken across lines. So the file Pithy writes is one `biome check` passes as it is.
194
+ */
195
+ function printLiteral(value: unknown, indent: string, prefix: number): string {
196
+ if (typeof value === "string") return printString(value);
197
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
198
+ const inner = `${indent} `;
199
+ if (Array.isArray(value)) {
200
+ if (value.length === 0) return "[]";
201
+ const flat = value.every((item) => item === null || typeof item !== "object" || isEmpty(item));
202
+ if (flat) {
203
+ const line = `[${value.map((item) => printLiteral(item, inner, 0)).join(", ")}]`;
204
+ // The closing `,` or `;` after the array counts toward the width too.
205
+ if (prefix + line.length + 1 <= LINE_WIDTH) return line;
206
+ }
207
+ const items = value.map((item) => `${inner}${printLiteral(item, inner, inner.length)},`);
208
+ return `[\n${items.join("\n")}\n${indent}]`;
209
+ }
210
+ const entries = Object.entries(value);
211
+ if (entries.length === 0) return "{}";
212
+ const lines = entries.map(([key, entry]) => {
213
+ const name = IDENTIFIER.test(key) ? key : JSON.stringify(key);
214
+ return `${inner}${name}: ${printLiteral(entry, inner, inner.length + name.length + 2)},`;
215
+ });
216
+ return `{\n${lines.join("\n")}\n${indent}}`;
217
+ }
218
+
219
+ /** A string in double quotes, or in single quotes when it holds more double quotes than single, as Biome picks. */
220
+ function printString(value: string): string {
221
+ const doubles = value.split('"').length;
222
+ const singles = value.split("'").length;
223
+ if (doubles <= singles) return JSON.stringify(value);
224
+ let body = "";
225
+ for (const char of value) {
226
+ body += char === "'" ? "\\'" : char === '"' ? char : JSON.stringify(char).slice(1, -1);
227
+ }
228
+ return `'${body}'`;
229
+ }
230
+
231
+ function isEmpty(value: object): boolean {
232
+ return Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0;
233
+ }
234
+
235
+ /**
236
+ * The file's text for `config`, naming the Worker it came from. The value is a plain literal, so it reads back
237
+ * without evaluating anything; the comment above it says which fields are the adopter's and how to change each rail.
238
+ */
239
+ export function renderGeneratedConfig(config: GeneratedConfig, worker: string): string {
240
+ return [
241
+ `// Written from apps/${worker}'s composed config. Pithy's \`ui sync --app\` rewrites it when that config`,
242
+ "// changes, and keeps the fields below that are yours.",
243
+ "//",
244
+ "// Yours to edit, and kept by a sync: `appScheme`, `universalLinkOrigin`, `google` and `rails`.",
245
+ "//",
246
+ '// `rails.ios`, `rails.android` and `rails.web` name the rail each platform sells through: "apple", "google",',
247
+ '// "stripe", "lemonSqueezy", "paddle", or "none" to show plans and sell nothing. Change one here, or pass',
248
+ "// `--ios-rail`, `--android-rail` or `--web-rail` to `ui sync --app`. A rail the Worker does not enable is",
249
+ "// refused. A web rail on a phone opens checkout in the system browser.",
250
+ "//",
251
+ "// `origins` holds where the Worker answers in each environment, and a build calls the one",
252
+ `// \`${ENVIRONMENT_VARIABLE}\` names: dev, staging or prod, dev when it is unset. An origin is null when Pithy`,
253
+ "// could not find it: a Worker that answers only on workers.dev, or one that declares no domain. Set",
254
+ "// `origins.staging` or `origins.prod` by hand then, and a sync keeps it until the Worker declares one.",
255
+ "// A dev origin that is not this machine's loopback, such as your network address for a device, is kept too.",
256
+ "//",
257
+ "// `auth`, `i18n`, `payments`, `turnstile` and `consent` hold each capability's projection in each environment",
258
+ "// `origins` names, and a build reads the one for its environment: a sitekey or a catalog can differ between them.",
259
+ "//",
260
+ "// Everything else is the Worker's. Change its pithy.config.ts and run `ui sync --app` again.",
261
+ "// The value stays a plain literal: strings, numbers, true, false, null, arrays and objects. A sync reads it",
262
+ "// back without running it, so it holds no expression, no variable and no import.",
263
+ 'import type { GeneratedPithyConfig } from "./pithy-config";',
264
+ "",
265
+ `${OPEN}${printLiteral(config, "", OPEN.length)}${CLOSE}`,
266
+ ].join("\n");
267
+ }
268
+
269
+ /** A literal the reader could not read. */
270
+ class Unreadable extends Error {}
271
+
272
+ /**
273
+ * Read the object literal in `text`: JSON, plus what a formatter or a hand edit leaves in a TypeScript literal, which
274
+ * is a key without quotes, a single-quoted string, a trailing comma and a comment. Anything else, such as an
275
+ * expression, is unreadable: the file is read, never run.
276
+ */
277
+ function parseLiteral(text: string): unknown {
278
+ let at = 0;
279
+ const skip = (): void => {
280
+ for (;;) {
281
+ while (at < text.length && /\s/.test(text[at] ?? "")) at += 1;
282
+ if (text.startsWith("//", at)) {
283
+ const end = text.indexOf("\n", at);
284
+ at = end === -1 ? text.length : end + 1;
285
+ } else if (text.startsWith("/*", at)) {
286
+ const end = text.indexOf("*/", at + 2);
287
+ if (end === -1) throw new Unreadable();
288
+ at = end + 2;
289
+ } else return;
290
+ }
291
+ };
292
+ const consume = (char: string): void => {
293
+ skip();
294
+ if (text[at] !== char) throw new Unreadable();
295
+ at += 1;
296
+ };
297
+ const string = (): string => {
298
+ const quote = text[at];
299
+ let end = at + 1;
300
+ let raw = "";
301
+ while (end < text.length && text[end] !== quote) {
302
+ const char = text[end] ?? "";
303
+ if (char === "\n") throw new Unreadable();
304
+ if (char === "\\") {
305
+ const next = text[end + 1] ?? "";
306
+ // A single-quoted string may escape its quote; JSON has no `\'`.
307
+ raw += next === "'" ? "'" : char + next;
308
+ end += 2;
309
+ continue;
310
+ }
311
+ raw += char === '"' ? '\\"' : char;
312
+ end += 1;
313
+ }
314
+ if (end >= text.length) throw new Unreadable();
315
+ at = end + 1;
316
+ try {
317
+ return JSON.parse(`"${raw}"`) as string;
318
+ } catch {
319
+ throw new Unreadable();
320
+ }
321
+ };
322
+ const value = (): unknown => {
323
+ skip();
324
+ const char = text[at];
325
+ if (char === "{") {
326
+ at += 1;
327
+ const object: Record<string, unknown> = {};
328
+ for (;;) {
329
+ skip();
330
+ if (text[at] === "}") {
331
+ at += 1;
332
+ return object;
333
+ }
334
+ let key: string;
335
+ if (text[at] === '"' || text[at] === "'") key = string();
336
+ else {
337
+ const name = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(text.slice(at))?.[0];
338
+ if (name === undefined) throw new Unreadable();
339
+ key = name;
340
+ at += name.length;
341
+ }
342
+ consume(":");
343
+ Object.defineProperty(object, key, { value: value(), enumerable: true, writable: true, configurable: true });
344
+ skip();
345
+ if (text[at] === ",") at += 1;
346
+ else if (text[at] !== "}") throw new Unreadable();
347
+ }
348
+ }
349
+ if (char === "[") {
350
+ at += 1;
351
+ const array: unknown[] = [];
352
+ for (;;) {
353
+ skip();
354
+ if (text[at] === "]") {
355
+ at += 1;
356
+ return array;
357
+ }
358
+ array.push(value());
359
+ skip();
360
+ if (text[at] === ",") at += 1;
361
+ else if (text[at] !== "]") throw new Unreadable();
362
+ }
363
+ }
364
+ if (char === '"' || char === "'") return string();
365
+ const word = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)(?![A-Za-z0-9_$.])/.exec(
366
+ text.slice(at),
367
+ )?.[0];
368
+ if (word === undefined) throw new Unreadable();
369
+ at += word.length;
370
+ return JSON.parse(word) as unknown;
371
+ };
372
+ const parsed = value();
373
+ skip();
374
+ if (at !== text.length) throw new Unreadable();
375
+ return parsed;
376
+ }
377
+
378
+ /**
379
+ * The config a file holds, or `null` when it is not a file {@link renderGeneratedConfig} wrote or its value does not
380
+ * parse. A file the project's formatter has reformatted, or an adopter has edited by hand, still reads.
381
+ */
382
+ export function readGeneratedConfig(text: string): GeneratedConfig | null {
383
+ const start = text.indexOf(OPEN);
384
+ const close = /;\s*export default config;\s*$/.exec(text);
385
+ if (start === -1 || close === null || close.index < start) return null;
386
+ let value: unknown;
387
+ try {
388
+ value = parseLiteral(text.slice(start + OPEN.length, close.index));
389
+ } catch {
390
+ return null;
391
+ }
392
+ const parsed = GeneratedConfig.safeParse(value);
393
+ return parsed.success ? parsed.data : null;
394
+ }
395
+
396
+ /** A host that is this machine: what `pithy dev` writes, and what a sync may move to another port. */
397
+ const LOOPBACK = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
398
+
399
+ /**
400
+ * The origins a sync writes. A deployed environment is the Worker's when the Worker names one, and otherwise the
401
+ * adopter's, as long as the project still deploys there. A loopback dev origin follows the port `pithy dev` pins,
402
+ * and any other dev origin is one the adopter set, for a device on the network, and is kept.
403
+ */
404
+ function keepOrigins(previous: Origins, next: Origins): Origins {
405
+ const kept: Origins = { dev: LOOPBACK.test(previous.dev) ? next.dev : previous.dev };
406
+ for (const environment of ["staging", "prod"] as const) {
407
+ if (!(environment in next)) continue;
408
+ kept[environment] = next[environment] ?? previous[environment] ?? null;
409
+ }
410
+ return kept;
411
+ }
412
+
413
+ /** `next`, with the adopter's choices carried over from what was written before. */
414
+ export function keepAdopterChoices(previous: GeneratedConfig | null, next: GeneratedConfig): GeneratedConfig {
415
+ if (previous === null) return next;
416
+ const kept: GeneratedConfig = { ...next, origins: keepOrigins(previous.origins, next.origins) };
417
+ for (const key of ADOPTER_CHOICES) Object.assign(kept, { [key]: previous[key] });
418
+ return kept;
419
+ }