@pplethai/cli 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 (62) hide show
  1. package/README.md +161 -0
  2. package/askpass/pple-askpass.mjs +18 -0
  3. package/dist/api.js +62 -0
  4. package/dist/bin.js +16 -0
  5. package/dist/cli.js +103 -0
  6. package/dist/commands/auth.js +32 -0
  7. package/dist/commands/deploy.js +106 -0
  8. package/dist/commands/init.js +77 -0
  9. package/dist/commands/upgrade.js +29 -0
  10. package/dist/credentials.js +53 -0
  11. package/dist/deps.js +87 -0
  12. package/dist/device-flow.js +115 -0
  13. package/dist/errors.js +16 -0
  14. package/dist/git.js +127 -0
  15. package/dist/link.js +49 -0
  16. package/dist/node-deps.js +84 -0
  17. package/dist/npm.js +127 -0
  18. package/dist/scaffold.js +58 -0
  19. package/dist/session.js +29 -0
  20. package/dist/skills.js +86 -0
  21. package/dist/ui.js +12 -0
  22. package/dist/update.js +142 -0
  23. package/package.json +35 -0
  24. package/skills/pple-platform/SKILL.md +102 -0
  25. package/skills/pple-platform/evals/evals.json +29 -0
  26. package/skills/pple-platform/references/deployment.md +61 -0
  27. package/skills/pple-platform/references/design-system.md +50 -0
  28. package/skills/pple-platform/references/mini-app.md +74 -0
  29. package/skills/pple-platform/references/pple-id-auth.md +85 -0
  30. package/skills/pple-platform/references/project-scaffold.md +75 -0
  31. package/templates/golden-path/.dev.vars.example +17 -0
  32. package/templates/golden-path/.env.example +8 -0
  33. package/templates/golden-path/.github/workflows/ci.yml +22 -0
  34. package/templates/golden-path/CLAUDE.md +60 -0
  35. package/templates/golden-path/README.md +60 -0
  36. package/templates/golden-path/gitignore +6 -0
  37. package/templates/golden-path/index.html +16 -0
  38. package/templates/golden-path/migrations/0001_init.sql +9 -0
  39. package/templates/golden-path/package-lock.json +4283 -0
  40. package/templates/golden-path/package.json +38 -0
  41. package/templates/golden-path/shared/api.ts +56 -0
  42. package/templates/golden-path/src/auth.ts +53 -0
  43. package/templates/golden-path/src/db.ts +32 -0
  44. package/templates/golden-path/src/env.ts +19 -0
  45. package/templates/golden-path/src/files.ts +38 -0
  46. package/templates/golden-path/src/index.ts +104 -0
  47. package/templates/golden-path/src/notify.ts +64 -0
  48. package/templates/golden-path/test/apply-migrations.ts +4 -0
  49. package/templates/golden-path/test/env.d.ts +17 -0
  50. package/templates/golden-path/test/harness.ts +57 -0
  51. package/templates/golden-path/test/smoke.test.ts +155 -0
  52. package/templates/golden-path/tsconfig.json +20 -0
  53. package/templates/golden-path/tsconfig.worker.json +21 -0
  54. package/templates/golden-path/vite.config.ts +29 -0
  55. package/templates/golden-path/vitest.config.ts +26 -0
  56. package/templates/golden-path/web/App.tsx +250 -0
  57. package/templates/golden-path/web/NotInApp.tsx +23 -0
  58. package/templates/golden-path/web/app.css +112 -0
  59. package/templates/golden-path/web/main.tsx +40 -0
  60. package/templates/golden-path/web/pple.ts +73 -0
  61. package/templates/golden-path/web/vite-env.d.ts +10 -0
  62. package/templates/golden-path/wrangler.jsonc +53 -0
@@ -0,0 +1,21 @@
1
+ // TypeScript config for the Worker (src/) and its tests (test/). Uses the
2
+ // Cloudflare Workers runtime types, not the browser DOM.
3
+ {
4
+ "compilerOptions": {
5
+ "target": "es2022",
6
+ "lib": ["es2022"],
7
+ "module": "es2022",
8
+ "moduleResolution": "bundler",
9
+ "types": [
10
+ "@cloudflare/workers-types",
11
+ "@cloudflare/vitest-pool-workers/types"
12
+ ],
13
+ "strict": true,
14
+ "noUncheckedIndexedAccess": true,
15
+ "noEmit": true,
16
+ "skipLibCheck": true,
17
+ "isolatedModules": true,
18
+ "forceConsistentCasingInFileNames": true
19
+ },
20
+ "include": ["src", "test", "shared"]
21
+ }
@@ -0,0 +1,29 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { cloudflare } from "@cloudflare/vite-plugin";
3
+ import react from "@vitejs/plugin-react";
4
+ import { defineConfig } from "vite";
5
+
6
+ // The Cloudflare plugin reads wrangler.jsonc, runs the Worker in the real
7
+ // Workers runtime during `vite dev`, and builds a deployable bundle with
8
+ // `vite build`. `vite dev` serves the React app and the /api/* Worker from a
9
+ // single origin, so no CORS or proxy config is needed.
10
+ export default defineConfig({
11
+ plugins: [react(), cloudflare()],
12
+ resolve: {
13
+ alias: [
14
+ // @pplethai/components ships a "development" export condition pointing at
15
+ // its (unpublished) source, which Vite would otherwise pick during dev
16
+ // and fail to resolve. Pin the bare import to the built entry; the
17
+ // /styles.css and /form subpaths still resolve normally.
18
+ {
19
+ find: /^@pplethai\/components$/,
20
+ replacement: fileURLToPath(
21
+ new URL(
22
+ "./node_modules/@pplethai/components/dist/index.js",
23
+ import.meta.url,
24
+ ),
25
+ ),
26
+ },
27
+ ],
28
+ },
29
+ });
@@ -0,0 +1,26 @@
1
+ import path from "node:path";
2
+ import {
3
+ cloudflareTest,
4
+ readD1Migrations,
5
+ } from "@cloudflare/vitest-pool-workers";
6
+ import { defineConfig } from "vitest/config";
7
+
8
+ // Runs the smoke tests inside the real Workers runtime (workerd) with local
9
+ // D1 and R2, so the tests exercise the same code paths as production. The D1
10
+ // migrations are applied to the test database in test/apply-migrations.ts.
11
+ export default defineConfig(async () => {
12
+ const migrations = await readD1Migrations(path.join(__dirname, "migrations"));
13
+ return {
14
+ plugins: [
15
+ cloudflareTest({
16
+ wrangler: { configPath: "./wrangler.jsonc" },
17
+ miniflare: {
18
+ bindings: { TEST_MIGRATIONS: migrations },
19
+ },
20
+ }),
21
+ ],
22
+ test: {
23
+ setupFiles: ["./test/apply-migrations.ts"],
24
+ },
25
+ };
26
+ });
@@ -0,0 +1,250 @@
1
+ import {
2
+ Badge,
3
+ Button,
4
+ Card,
5
+ CardContent,
6
+ CardDescription,
7
+ CardHeader,
8
+ CardTitle,
9
+ Input,
10
+ Logo,
11
+ Spinner,
12
+ Textarea,
13
+ showToast,
14
+ } from "@pplethai/components";
15
+ import { useEffect, useRef, useState } from "react";
16
+ import type { Me, Note, UploadedFile } from "../shared/api";
17
+ import { api, apiJson } from "./pple";
18
+
19
+ function bytes(n: number): string {
20
+ if (n < 1024) return `${n} B`;
21
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
22
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
23
+ }
24
+
25
+ function message(e: unknown): string {
26
+ return e instanceof Error ? e.message : String(e);
27
+ }
28
+
29
+ export function App() {
30
+ const [me, setMe] = useState<Me | null>(null);
31
+
32
+ useEffect(() => {
33
+ apiJson<Me>("/api/me").then(setMe).catch((e) => showToast({
34
+ title: "Could not load your profile",
35
+ description: message(e),
36
+ variant: "error",
37
+ }));
38
+ }, []);
39
+
40
+ return (
41
+ <main className="screen">
42
+ <header className="appbar">
43
+ <Logo className="appbar__logo" title="People's Party" />
44
+ <span className="appbar__title">PPLE App</span>
45
+ {me && <Badge>{me.name ?? me.sub}</Badge>}
46
+ </header>
47
+
48
+ <div className="stack">
49
+ <p className="muted">
50
+ This is the golden-path template. Each card below exercises one
51
+ platform capability. Replace them with your own app.
52
+ </p>
53
+ <NotesCard />
54
+ <FilesCard />
55
+ <NotifyCard />
56
+ </div>
57
+ </main>
58
+ );
59
+ }
60
+
61
+ // --- D1: a shared notes list ---------------------------------------------
62
+ function NotesCard() {
63
+ const [notes, setNotes] = useState<Note[] | null>(null);
64
+ const [body, setBody] = useState("");
65
+ const [saving, setSaving] = useState(false);
66
+
67
+ const load = () =>
68
+ apiJson<{ notes: Note[] }>("/api/notes")
69
+ .then((r) => setNotes(r.notes))
70
+ .catch((e) => showToast({ title: "Could not load notes", description: message(e), variant: "error" }));
71
+
72
+ useEffect(() => void load(), []);
73
+
74
+ async function add() {
75
+ if (!body.trim()) return;
76
+ setSaving(true);
77
+ try {
78
+ await apiJson<Note>("/api/notes", {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json" },
81
+ body: JSON.stringify({ body }),
82
+ });
83
+ setBody("");
84
+ await load();
85
+ } catch (e) {
86
+ showToast({ title: "Could not save note", description: message(e), variant: "error" });
87
+ } finally {
88
+ setSaving(false);
89
+ }
90
+ }
91
+
92
+ return (
93
+ <Card className="card">
94
+ <CardHeader>
95
+ <CardTitle>Notes</CardTitle>
96
+ <CardDescription>Reads and writes your app's D1 database.</CardDescription>
97
+ </CardHeader>
98
+ <CardContent>
99
+ <div className="stack">
100
+ <div className="row">
101
+ <Input
102
+ value={body}
103
+ placeholder="Write a note…"
104
+ onChange={(e) => setBody(e.target.value)}
105
+ onKeyDown={(e) => e.key === "Enter" && add()}
106
+ />
107
+ <Button onClick={add} disabled={saving || !body.trim()}>
108
+ {saving ? <Spinner /> : "Add"}
109
+ </Button>
110
+ </div>
111
+ {notes === null ? (
112
+ <Spinner />
113
+ ) : notes.length === 0 ? (
114
+ <p className="muted">No notes yet.</p>
115
+ ) : (
116
+ <ul className="list">
117
+ {notes.map((n) => (
118
+ <li key={n.id} className="list__item">
119
+ <span>{n.body}</span>
120
+ <span className="muted">{new Date(n.createdAt).toLocaleString()}</span>
121
+ </li>
122
+ ))}
123
+ </ul>
124
+ )}
125
+ </div>
126
+ </CardContent>
127
+ </Card>
128
+ );
129
+ }
130
+
131
+ // --- R2: file upload ------------------------------------------------------
132
+ function FilesCard() {
133
+ const [files, setFiles] = useState<UploadedFile[] | null>(null);
134
+ const [uploading, setUploading] = useState(false);
135
+ const inputRef = useRef<HTMLInputElement>(null);
136
+
137
+ const load = () =>
138
+ apiJson<{ files: UploadedFile[] }>("/api/files")
139
+ .then((r) => setFiles(r.files))
140
+ .catch((e) => showToast({ title: "Could not load files", description: message(e), variant: "error" }));
141
+
142
+ useEffect(() => void load(), []);
143
+
144
+ async function upload() {
145
+ const file = inputRef.current?.files?.[0];
146
+ if (!file) return;
147
+ setUploading(true);
148
+ try {
149
+ const fd = new FormData();
150
+ fd.append("file", file);
151
+ const res = await api("/api/files", { method: "POST", body: fd });
152
+ if (!res.ok) throw new Error(`upload failed (${res.status})`);
153
+ if (inputRef.current) inputRef.current.value = "";
154
+ await load();
155
+ } catch (e) {
156
+ showToast({ title: "Could not upload file", description: message(e), variant: "error" });
157
+ } finally {
158
+ setUploading(false);
159
+ }
160
+ }
161
+
162
+ return (
163
+ <Card className="card">
164
+ <CardHeader>
165
+ <CardTitle>Files</CardTitle>
166
+ <CardDescription>Uploads to your app's R2 bucket.</CardDescription>
167
+ </CardHeader>
168
+ <CardContent>
169
+ <div className="stack">
170
+ <div className="row">
171
+ <input ref={inputRef} type="file" className="file-input" onChange={upload} />
172
+ {uploading && <Spinner />}
173
+ </div>
174
+ {files === null ? (
175
+ <Spinner />
176
+ ) : files.length === 0 ? (
177
+ <p className="muted">No files yet.</p>
178
+ ) : (
179
+ <ul className="list">
180
+ {files.map((f) => (
181
+ <li key={f.key} className="list__item">
182
+ <span>{f.name}</span>
183
+ <span className="muted">{bytes(f.size)}</span>
184
+ </li>
185
+ ))}
186
+ </ul>
187
+ )}
188
+ </div>
189
+ </CardContent>
190
+ </Card>
191
+ );
192
+ }
193
+
194
+ // --- Audience-bound notifications ----------------------------------------
195
+ function NotifyCard() {
196
+ const [title, setTitle] = useState("");
197
+ const [body, setBody] = useState("");
198
+ const [sending, setSending] = useState(false);
199
+
200
+ async function send() {
201
+ if (!title.trim() || !body.trim()) return;
202
+ setSending(true);
203
+ try {
204
+ const res = await api("/api/notify", {
205
+ method: "POST",
206
+ headers: { "content-type": "application/json" },
207
+ body: JSON.stringify({ title, body }),
208
+ });
209
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
210
+ if (res.ok) {
211
+ showToast({ title: "Notification sent", variant: "success" });
212
+ setTitle("");
213
+ setBody("");
214
+ } else if (data.error === "notifications_not_configured") {
215
+ showToast({
216
+ title: "Notifications not configured",
217
+ description: "Set NOTIFY_API_BASE and NOTIFY_API_KEY to send for real.",
218
+ variant: "warning",
219
+ });
220
+ } else {
221
+ showToast({ title: "Could not send", description: data.error, variant: "error" });
222
+ }
223
+ } catch (e) {
224
+ showToast({ title: "Could not send", description: message(e), variant: "error" });
225
+ } finally {
226
+ setSending(false);
227
+ }
228
+ }
229
+
230
+ return (
231
+ <Card className="card">
232
+ <CardHeader>
233
+ <CardTitle>Notify your users</CardTitle>
234
+ <CardDescription>
235
+ Sends an audience-bound push to your App Users — content only, no
236
+ phone numbers.
237
+ </CardDescription>
238
+ </CardHeader>
239
+ <CardContent>
240
+ <div className="stack">
241
+ <Input value={title} placeholder="Title" onChange={(e) => setTitle(e.target.value)} />
242
+ <Textarea value={body} placeholder="Message" onChange={(e) => setBody(e.target.value)} />
243
+ <Button onClick={send} disabled={sending || !title.trim() || !body.trim()}>
244
+ {sending ? <Spinner /> : "Send notification"}
245
+ </Button>
246
+ </div>
247
+ </CardContent>
248
+ </Card>
249
+ );
250
+ }
@@ -0,0 +1,23 @@
1
+ import { Card, CardContent, Logo } from "@pplethai/components";
2
+
3
+ // Shown when the app is opened outside PPLE Today (a plain browser). There is
4
+ // no sign-in here on purpose: mini-apps authenticate through PPLE Today.
5
+ export function NotInApp() {
6
+ return (
7
+ <main className="screen screen--center">
8
+ <Card className="card">
9
+ <CardContent>
10
+ <div className="notinapp">
11
+ <Logo className="notinapp__logo" title="People's Party" />
12
+ <h1 className="notinapp__title">เปิดในแอป PPLE Today</h1>
13
+ <p>
14
+ แอปนี้ทำงานภายในแอป PPLE Today เท่านั้น
15
+ กรุณาเปิดจากแอป PPLE Today เพื่อเข้าสู่ระบบ
16
+ </p>
17
+ <p className="muted">Please open this mini-app from inside PPLE Today to sign in.</p>
18
+ </div>
19
+ </CardContent>
20
+ </Card>
21
+ </main>
22
+ );
23
+ }
@@ -0,0 +1,112 @@
1
+ /* App layout. The design system (@pplethai/components) styles the components;
2
+ this file only handles page layout, and stays mobile-first: one column,
3
+ comfortable tap targets, safe-area padding for the PPLE Today WebView. */
4
+
5
+ * {
6
+ box-sizing: border-box;
7
+ }
8
+
9
+ body {
10
+ margin: 0;
11
+ font-family: "IBM Plex Sans Thai Looped", system-ui, -apple-system, sans-serif;
12
+ background: #f5f5f5;
13
+ color: #212121;
14
+ }
15
+
16
+ .screen {
17
+ max-width: 640px;
18
+ margin: 0 auto;
19
+ padding: 16px;
20
+ padding-bottom: max(16px, env(safe-area-inset-bottom));
21
+ min-height: 100vh;
22
+ }
23
+
24
+ .screen--center {
25
+ display: grid;
26
+ place-items: center;
27
+ }
28
+
29
+ .appbar {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 12px;
33
+ padding: 8px 4px 16px;
34
+ }
35
+
36
+ .appbar__logo {
37
+ height: 28px;
38
+ width: auto;
39
+ color: #ff6a13; /* brand primary */
40
+ }
41
+
42
+ .appbar__title {
43
+ font-weight: 600;
44
+ font-size: 18px;
45
+ margin-right: auto;
46
+ }
47
+
48
+ .stack {
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: 16px;
52
+ }
53
+
54
+ .row {
55
+ display: flex;
56
+ gap: 8px;
57
+ align-items: center;
58
+ }
59
+
60
+ .row > :first-child {
61
+ flex: 1;
62
+ }
63
+
64
+ .card {
65
+ width: 100%;
66
+ }
67
+
68
+ .list {
69
+ list-style: none;
70
+ margin: 0;
71
+ padding: 0;
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: 8px;
75
+ }
76
+
77
+ .list__item {
78
+ display: flex;
79
+ justify-content: space-between;
80
+ gap: 12px;
81
+ align-items: baseline;
82
+ }
83
+
84
+ .muted {
85
+ color: #6b6b6b;
86
+ font-size: 14px;
87
+ }
88
+
89
+ .file-input {
90
+ flex: 1;
91
+ min-width: 0;
92
+ }
93
+
94
+ .notinapp {
95
+ text-align: center;
96
+ display: flex;
97
+ flex-direction: column;
98
+ gap: 12px;
99
+ align-items: center;
100
+ padding: 16px;
101
+ }
102
+
103
+ .notinapp__logo {
104
+ height: 48px;
105
+ width: auto;
106
+ color: #ff6a13;
107
+ }
108
+
109
+ .notinapp__title {
110
+ margin: 0;
111
+ font-size: 20px;
112
+ }
@@ -0,0 +1,40 @@
1
+ // App entry point. Import the design-system styles first, then our own.
2
+ import "@pplethai/components/styles.css";
3
+ import "./app.css";
4
+
5
+ import { Alert, AlertDescription, AlertTitle, Toaster } from "@pplethai/components";
6
+ import { StrictMode } from "react";
7
+ import { createRoot } from "react-dom/client";
8
+ import { App } from "./App";
9
+ import { NotInApp } from "./NotInApp";
10
+ import { bootstrap } from "./pple";
11
+
12
+ function ErrorScreen({ message }: { message: string }) {
13
+ return (
14
+ <main className="screen screen--center">
15
+ <Alert variant="destructive" className="card">
16
+ <AlertTitle>Could not start</AlertTitle>
17
+ <AlertDescription>{message}</AlertDescription>
18
+ </Alert>
19
+ </main>
20
+ );
21
+ }
22
+
23
+ const root = createRoot(document.getElementById("root")!);
24
+
25
+ // Establish the session (PPLE Today token, dev bypass, or "not in app") before
26
+ // rendering, so the first paint is the right screen.
27
+ bootstrap().then((result) => {
28
+ root.render(
29
+ <StrictMode>
30
+ <Toaster />
31
+ {result.kind === "signed-in" ? (
32
+ <App />
33
+ ) : result.kind === "not-in-app" ? (
34
+ <NotInApp />
35
+ ) : (
36
+ <ErrorScreen message={result.message} />
37
+ )}
38
+ </StrictMode>,
39
+ );
40
+ });
@@ -0,0 +1,73 @@
1
+ // The single place the app talks to PPLE Today and to its own API.
2
+ //
3
+ // Three ways the app can start:
4
+ // 1. Local dev (`vite dev`) — bypass PPLE Today, use the DEV_AUTH token so
5
+ // the Worker accepts you as a fake user.
6
+ // 2. Inside PPLE Today — the SDK captures the access token from the launch
7
+ // URL; we attach it to every API call.
8
+ // 3. A plain browser — not inside PPLE Today, so we show the NOT_IN_APP
9
+ // screen instead of trying to sign in.
10
+ import { isInMiniAppUserAgent } from "@pplethai/components";
11
+ import { PPLEMiniApp } from "@pplethai/pple-today-miniapp-sdk";
12
+
13
+ // Provisioned per app, injected at build time. Only needed inside PPLE Today.
14
+ const OAUTH_URL = import.meta.env.VITE_OAUTH_URL ?? "";
15
+ const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID ?? "";
16
+ const OAUTH_REDIRECT_URI =
17
+ import.meta.env.VITE_OAUTH_REDIRECT_URI ?? window.location.origin;
18
+
19
+ // Must match DEV_AUTH in .dev.vars so the Worker's dev bypass accepts it.
20
+ const DEV_TOKEN = "dev-token";
21
+
22
+ export type Bootstrap =
23
+ | { kind: "signed-in" }
24
+ | { kind: "not-in-app" }
25
+ | { kind: "error"; message: string };
26
+
27
+ let token: string | null = null;
28
+
29
+ /** Establish the session. Call once at startup, before rendering the app. */
30
+ export async function bootstrap(): Promise<Bootstrap> {
31
+ try {
32
+ if (import.meta.env.DEV) {
33
+ token = DEV_TOKEN;
34
+ return { kind: "signed-in" };
35
+ }
36
+
37
+ if (!isInMiniAppUserAgent(navigator.userAgent)) {
38
+ return { kind: "not-in-app" };
39
+ }
40
+
41
+ const sdk = new PPLEMiniApp({
42
+ oauthUrl: OAUTH_URL,
43
+ oauthClientId: OAUTH_CLIENT_ID,
44
+ oauthRedirectUri: OAUTH_REDIRECT_URI,
45
+ });
46
+ await sdk.init();
47
+ token = await sdk.getAccessToken();
48
+ return { kind: "signed-in" };
49
+ } catch (e) {
50
+ return { kind: "error", message: e instanceof Error ? e.message : String(e) };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Call your Worker API with the bearer token attached. Paths are relative
56
+ * ("/api/notes"), since the SPA and the Worker share one origin.
57
+ */
58
+ export async function api(path: string, init: RequestInit = {}): Promise<Response> {
59
+ if (!token) throw new Error("not signed in — call bootstrap() first");
60
+ const headers = new Headers(init.headers);
61
+ headers.set("authorization", `Bearer ${token}`);
62
+ return fetch(path, { ...init, headers });
63
+ }
64
+
65
+ /** Small helper: call the API and parse JSON, throwing on a non-2xx status. */
66
+ export async function apiJson<T>(path: string, init: RequestInit = {}): Promise<T> {
67
+ const res = await api(path, init);
68
+ if (!res.ok) {
69
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
70
+ throw new Error(body.error ?? `request failed (${res.status})`);
71
+ }
72
+ return res.json() as Promise<T>;
73
+ }
@@ -0,0 +1,10 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // Build-time OIDC config for the mini-app SDK. These are public values (a
4
+ // client id and public OAuth URLs), safe to ship in the bundle. The platform
5
+ // injects them at deploy time; see .env.example.
6
+ interface ImportMetaEnv {
7
+ readonly VITE_OAUTH_URL?: string;
8
+ readonly VITE_OAUTH_CLIENT_ID?: string;
9
+ readonly VITE_OAUTH_REDIRECT_URI?: string;
10
+ }
@@ -0,0 +1,53 @@
1
+ // Your Builder App's Cloudflare configuration. The platform provisions the
2
+ // real D1/R2 resources and injects secrets at deploy time; the values below
3
+ // work for local development out of the box.
4
+ //
5
+ // Everything here is meant to be read top-to-bottom: one Worker serves the
6
+ // API under /api/* and the React SPA for everything else.
7
+ {
8
+ "name": "pple-app",
9
+ "main": "src/index.ts",
10
+ "compatibility_date": "2026-06-01",
11
+
12
+ // The React SPA (built to dist/client by Vite) is served for any request
13
+ // that isn't worker-first. "/api/*" is the app's API; "/.pple/*" is reserved
14
+ // for the platform deploy probe. "single-page-application" makes every
15
+ // other path return index.html so client-side routing works.
16
+ "assets": {
17
+ "not_found_handling": "single-page-application",
18
+ "run_worker_first": ["/api/*", "/.pple/*"]
19
+ },
20
+
21
+ // Your app's private SQLite database (D1). Migrations live in ./migrations
22
+ // and are applied with `npm run db:migrate:local` / `:remote`.
23
+ "d1_databases": [
24
+ {
25
+ "binding": "DB",
26
+ "database_name": "pple-app",
27
+ "database_id": "00000000-0000-0000-0000-000000000000",
28
+ "migrations_dir": "migrations"
29
+ }
30
+ ],
31
+
32
+ // Your app's private file storage (R2).
33
+ "r2_buckets": [
34
+ {
35
+ "binding": "FILES",
36
+ "bucket_name": "pple-app"
37
+ }
38
+ ],
39
+
40
+ "vars": {
41
+ // PPLE ID (identity). The Worker validates bearer tokens against this.
42
+ "SSO_BASE_URL": "https://sso.peoplesparty.or.th",
43
+ // Audience-bound notification endpoint (see src/notify.ts): the base that
44
+ // `${NOTIFY_API_BASE}/notifications` is posted to. The platform sets the
45
+ // real value; empty locally so notify is a no-op unless you wire it up in
46
+ // .dev.vars.
47
+ "NOTIFY_API_BASE": ""
48
+ }
49
+
50
+ // Secrets (never checked in): set with `wrangler secret put <NAME>`.
51
+ // NOTIFY_API_KEY — your app's audience-bound notification key (platform-provisioned)
52
+ // DEV_AUTH — local dev only; set in .dev.vars, never in production
53
+ }