@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,29 @@
1
+ import { loadCredentials, saveCredentials } from "./credentials.js";
2
+ import { refresh } from "./device-flow.js";
3
+ import { SIGN_IN_EXPIRED, SIGNED_OUT } from "./errors.js";
4
+ import { ProvisionerClient } from "./api.js";
5
+ /** Refresh this long before expiry, so a command doesn't die mid-flight. */
6
+ const EXPIRY_MARGIN_MS = 60_000;
7
+ /**
8
+ * The access token for this command, refreshed if it is about to expire. The
9
+ * only way a command gets to talk to the platform.
10
+ */
11
+ export async function accessToken(deps) {
12
+ const stored = await loadCredentials(deps);
13
+ if (!stored)
14
+ throw SIGNED_OUT;
15
+ const expiring = stored.expiresAt !== null && stored.expiresAt - deps.now() < EXPIRY_MARGIN_MS;
16
+ if (!expiring)
17
+ return stored.accessToken;
18
+ if (stored.refreshToken) {
19
+ const renewed = await refresh(deps, stored.refreshToken);
20
+ if (renewed) {
21
+ await saveCredentials(deps, { ...renewed, ssoBase: deps.config.ssoBase });
22
+ return renewed.accessToken;
23
+ }
24
+ }
25
+ throw SIGN_IN_EXPIRED;
26
+ }
27
+ export async function client(deps) {
28
+ return new ProvisionerClient(deps, await accessToken(deps));
29
+ }
package/dist/skills.js ADDED
@@ -0,0 +1,86 @@
1
+ // Platform Skills as a local overlay in each Builder App workspace.
2
+ // Overwrites only `.claude/skills/pple-platform/`; sibling skills are left alone.
3
+ import { createHash } from "node:crypto";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { CliError } from "./errors.js";
7
+ import { MANUAL_INSTALL } from "./npm.js";
8
+ /** Where the platform Skill lands inside a Builder App workspace. */
9
+ export const PLATFORM_SKILL_REL = path.join(".claude", "skills", "pple-platform");
10
+ /** The only Skills path that belongs in the app's `.gitignore` (posix form). */
11
+ export const PLATFORM_SKILL_IGNORE = `${PLATFORM_SKILL_REL.split(path.sep).join("/")}/`;
12
+ /**
13
+ * Refresh the platform Skill overlay from the CLI bundle. Prints only when
14
+ * the managed content actually changed (first install or update).
15
+ */
16
+ export async function refreshSkills(deps, appDir) {
17
+ const source = deps.skillsDir;
18
+ const entries = await fs.readdir(source).catch(() => null);
19
+ if (entries === null) {
20
+ throw new CliError("The PPLE platform Skills are missing from this installation of the CLI.", `Reinstall the CLI (\`${MANUAL_INSTALL}\`) and try again.`);
21
+ }
22
+ const destination = path.join(appDir, PLATFORM_SKILL_REL);
23
+ const before = await contentFingerprint(destination);
24
+ await replaceDir(source, destination);
25
+ await ensureIgnored(appDir);
26
+ const after = await contentFingerprint(destination);
27
+ if (before === after)
28
+ return;
29
+ deps.out.say(before === null ? "Installed platform Skills." : "Updated platform Skills.");
30
+ }
31
+ async function replaceDir(from, to) {
32
+ await fs.rm(to, { recursive: true, force: true });
33
+ await copyDir(from, to);
34
+ }
35
+ async function copyDir(from, to) {
36
+ await fs.mkdir(to, { recursive: true });
37
+ for (const entry of await fs.readdir(from, { withFileTypes: true })) {
38
+ if (entry.name === ".DS_Store")
39
+ continue;
40
+ const src = path.join(from, entry.name);
41
+ const dst = path.join(to, entry.name);
42
+ if (entry.isDirectory())
43
+ await copyDir(src, dst);
44
+ else
45
+ await fs.copyFile(src, dst);
46
+ }
47
+ }
48
+ /** Stable hash of every file under `dir`, or null when the tree is missing. */
49
+ async function contentFingerprint(dir) {
50
+ const files = await listFiles(dir).catch(() => null);
51
+ if (files === null)
52
+ return null;
53
+ const hash = createHash("sha256");
54
+ for (const relative of files.sort()) {
55
+ hash.update(relative);
56
+ hash.update("\0");
57
+ hash.update(await fs.readFile(path.join(dir, relative)));
58
+ hash.update("\0");
59
+ }
60
+ return hash.digest("hex");
61
+ }
62
+ async function listFiles(dir, prefix = "") {
63
+ const entries = await fs.readdir(dir, { withFileTypes: true });
64
+ const files = [];
65
+ for (const entry of entries) {
66
+ if (entry.name === ".DS_Store")
67
+ continue;
68
+ const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
69
+ if (entry.isDirectory()) {
70
+ files.push(...(await listFiles(path.join(dir, entry.name), relative)));
71
+ }
72
+ else {
73
+ files.push(relative);
74
+ }
75
+ }
76
+ return files;
77
+ }
78
+ async function ensureIgnored(appDir) {
79
+ const ignorePath = path.join(appDir, ".gitignore");
80
+ const existing = await fs.readFile(ignorePath, "utf8").catch(() => "");
81
+ const lines = existing.split("\n");
82
+ if (lines.some((line) => line.trim() === PLATFORM_SKILL_IGNORE))
83
+ return;
84
+ const body = existing.endsWith("\n") || existing === "" ? existing : `${existing}\n`;
85
+ await fs.writeFile(ignorePath, `${body}${PLATFORM_SKILL_IGNORE}\n`);
86
+ }
package/dist/ui.js ADDED
@@ -0,0 +1,12 @@
1
+ export function consoleOutput(write, writeError) {
2
+ return {
3
+ say(line = "") {
4
+ write(`${line}\n`);
5
+ },
6
+ fail(error) {
7
+ writeError(`${error.message}\n`);
8
+ if (error.hint)
9
+ writeError(`${error.hint}\n`);
10
+ },
11
+ };
12
+ }
package/dist/update.js ADDED
@@ -0,0 +1,142 @@
1
+ // The CLI keeping itself current: the daily check that rides along with
2
+ // ordinary commands, and the words both it and `pple upgrade` use — one
3
+ // upgrade story reaches a Builder, however they arrived at it.
4
+ //
5
+ // The check runs before the command the Builder typed, which is the only
6
+ // moment an upgrade is safe: nothing is half-done yet, so the new CLI can
7
+ // simply be handed the same command line. Everything here is written to fail
8
+ // towards the command — a quiet npm, a missing config directory, a refused
9
+ // upgrade all end with `pple deploy` deploying.
10
+ import fs from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { installGlobally, isNewer, isNpmGlobalInstall, latestVersion, MANUAL_INSTALL, npmMessage, } from "./npm.js";
13
+ /** About once a day, per machine — not per app, and not per command. */
14
+ const CHECK_INTERVAL = 24 * 60 * 60 * 1000;
15
+ /** Cadence state, beside credentials.json and well away from any app repo. */
16
+ const STATE_FILE = "update-check.json";
17
+ /** The Builder's own settings for this CLI, the other half of the opt-out. */
18
+ const CONFIG_FILE = "config.json";
19
+ /**
20
+ * Commands that already answer the version question themselves, or that
21
+ * print and exit. `upgrade` doing its own check twice would be silly; help
22
+ * should never touch the network.
23
+ */
24
+ const UNCHECKED = new Set(["upgrade", "help", "--help", "-h"]);
25
+ export function wantsUpdateCheck(argv) {
26
+ return argv.length > 0 && !UNCHECKED.has(argv[0]);
27
+ }
28
+ /** The one line that opens every "there's a newer pple" conversation. */
29
+ export function announceUpdate(deps, latest) {
30
+ deps.out.say(`There's a newer pple (${latest}). You have ${deps.version}.`);
31
+ }
32
+ /**
33
+ * What to say about an install npm's global root doesn't hold. Installing
34
+ * over it would leave the Builder with two pples and no way to tell which
35
+ * one just ran (ADR-0007).
36
+ */
37
+ export function sayOtherChannel(deps) {
38
+ deps.out.say("This copy of pple didn't come from npm, so updating it here");
39
+ deps.out.say("could leave you with two versions. Update it the way you");
40
+ deps.out.say(`installed it, or install it with npm:\n\n ${MANUAL_INSTALL}`);
41
+ }
42
+ /** Why npm gave up, and the line the Builder can always run themselves. */
43
+ export function manualHint(result) {
44
+ return `${npmMessage(result)}\n\nTry running it yourself:\n\n ${MANUAL_INSTALL}`;
45
+ }
46
+ /**
47
+ * Look for a newer CLI, and act on it as far as this session allows. Returns
48
+ * an exit code when the command has already been dealt with — the CLI
49
+ * upgraded and re-ran it, or the Builder chose to stop — and null to let the
50
+ * command the Builder typed run as usual.
51
+ */
52
+ export async function checkForUpdate(deps, argv) {
53
+ if (!(await isDue(deps)))
54
+ return null;
55
+ await recordCheck(deps);
56
+ const latest = await latestVersion(deps);
57
+ // A quiet registry is not the Builder's problem: no message, no delay
58
+ // tomorrow — the command simply carries on.
59
+ if (latest === null || !isNewer(latest, deps.version))
60
+ return null;
61
+ announceUpdate(deps, latest);
62
+ // The channel comes before the terminal: an install npm doesn't own can't
63
+ // be upgraded by anyone here, so pointing at `pple upgrade` — which would
64
+ // only refuse — helps nobody, attended or not.
65
+ if (!(await isNpmGlobalInstall(deps))) {
66
+ sayOtherChannel(deps);
67
+ deps.out.say();
68
+ return null;
69
+ }
70
+ if (!deps.interactive) {
71
+ deps.out.say("Run `pple upgrade` when you're ready.");
72
+ deps.out.say();
73
+ return null;
74
+ }
75
+ if (!(await deps.confirm("Update pple now?"))) {
76
+ deps.out.say();
77
+ return null;
78
+ }
79
+ const result = await installGlobally(deps, latest);
80
+ if (result.code !== 0)
81
+ return await afterFailedUpgrade(deps, latest, result);
82
+ deps.out.say(`pple is now ${latest}. Picking up where you left off…`);
83
+ deps.out.say();
84
+ return deps.reexec(argv);
85
+ }
86
+ /**
87
+ * A failed upgrade must not strand the work the Builder came to do. Say what
88
+ * npm said, leave them the line they can run themselves, and offer to get on
89
+ * with the command on the CLI they already have. Only an attended run can
90
+ * arrive here at all — an unattended one is never upgrading in the first
91
+ * place — so carrying on is a question, not a default.
92
+ */
93
+ async function afterFailedUpgrade(deps, latest, result) {
94
+ deps.out.say(`Couldn't update pple to ${latest}.`);
95
+ deps.out.say(manualHint(result));
96
+ deps.out.say();
97
+ if (await deps.confirm(`Carry on with pple ${deps.version} for now?`)) {
98
+ deps.out.say();
99
+ return null;
100
+ }
101
+ deps.out.say("Stopped. Nothing has changed.");
102
+ return 1;
103
+ }
104
+ async function isDue(deps) {
105
+ if (!deps.updateChecks)
106
+ return false;
107
+ if (!(await configAllowsChecks(deps)))
108
+ return false;
109
+ const state = await readJson(path.join(deps.configDir, STATE_FILE));
110
+ if (typeof state?.lastCheckedAt !== "number")
111
+ return true;
112
+ return deps.now() - state.lastCheckedAt >= CHECK_INTERVAL;
113
+ }
114
+ /**
115
+ * Record the attempt, not the outcome: a registry that is down today should
116
+ * cost one request, not one per command until it comes back. Unwritable
117
+ * config is not worth a word to the Builder — checking again is harmless.
118
+ */
119
+ async function recordCheck(deps) {
120
+ const state = { lastCheckedAt: deps.now() };
121
+ await fs
122
+ .mkdir(deps.configDir, { recursive: true })
123
+ .then(() => fs.writeFile(path.join(deps.configDir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`))
124
+ .catch(() => undefined);
125
+ }
126
+ /** `{ "updateCheck": false }` turns the check off for this machine. */
127
+ async function configAllowsChecks(deps) {
128
+ const config = await readJson(path.join(deps.configDir, CONFIG_FILE));
129
+ return config?.updateCheck !== false;
130
+ }
131
+ /** Parsed JSON, or null for a file that is missing, unreadable, or malformed. */
132
+ async function readJson(file) {
133
+ const raw = await fs.readFile(file, "utf8").catch(() => null);
134
+ if (raw === null)
135
+ return null;
136
+ try {
137
+ return JSON.parse(raw);
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@pplethai/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/PPLEThai/pple-platform.git",
8
+ "directory": "cli"
9
+ },
10
+ "bin": {
11
+ "pple": "dist/bin.js"
12
+ },
13
+ "files": [
14
+ "askpass",
15
+ "dist",
16
+ "skills",
17
+ "templates"
18
+ ],
19
+ "scripts": {
20
+ "build": "npm run build:template && npm run build:skills && tsc -p tsconfig.build.json",
21
+ "build:template": "node scripts/bundle-template.mjs",
22
+ "build:skills": "node scripts/bundle-skills.mjs",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.10.0",
29
+ "typescript": "^5.8.0",
30
+ "vitest": "^4.1.10"
31
+ },
32
+ "engines": {
33
+ "node": ">=22"
34
+ }
35
+ }
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: pple-platform
3
+ description: >
4
+ Build Builder Apps on the PPLE Platform — the People's Party (พรรคประชาชน)
5
+ digital infrastructure: PPLE ID single sign-on, PPLE Today mini-apps and
6
+ audience-bound push notifications, the @pplethai/components design system,
7
+ and managed Cloudflare Workers + D1 + R2 hosting via the pple CLI. Use this
8
+ skill whenever a task mentions PPLE, PPLE ID, PPLE Today, mini-app,
9
+ พรรคประชาชน, People's Party, @pplethai packages, Builder App, party
10
+ internal tools, or when scaffolding, authenticating, styling, notifying, or
11
+ deploying any app in the PPLE ecosystem — even if the user doesn't name the
12
+ platform explicitly.
13
+ ---
14
+
15
+ # Building on the PPLE Platform
16
+
17
+ The PPLE Platform lets party workforce members (Builders) ship mini-apps
18
+ without holding cloud or GitHub credentials. Every new **Builder App** plugs
19
+ into three pillars on party-managed infrastructure:
20
+
21
+ 1. **PPLE ID** — OIDC single sign-on. End users authenticate in **PPLE Today**;
22
+ the mini-app receives a bearer token via the mini-app SDK and the Worker
23
+ validates it against the OIDC `userinfo` endpoint. Builders also use PPLE
24
+ ID for the Console (browser login) and `pple auth login` (device flow).
25
+ 2. **Mini-app integration** — apps run inside PPLE Today's WebView using
26
+ `@pplethai/pple-today-miniapp-sdk`, and send audience-bound notifications
27
+ (title/body, optional path-only `linkPath` into this app) through the
28
+ platform-provisioned notify helper.
29
+ 3. **PPLE Design System** — `@pplethai/components`, a shadcn/ui-based React
30
+ library with party branding. Docs: https://components.pplethai.org/
31
+
32
+ ## Standard tech stack (Builder Apps)
33
+
34
+ Use this stack for every new Builder App unless the user is working on a
35
+ central-team core service — uniformity is deliberate (ADR-0002):
36
+
37
+ | Layer | Choice |
38
+ | ------------ | ------ |
39
+ | Runtime | One Cloudflare Worker serves `/api/*` and the React SPA |
40
+ | Frontend | Vite + React 19 + TypeScript + `@pplethai/components` |
41
+ | Backend | Hono + TypeScript on the Workers runtime |
42
+ | Database | D1 (SQLite) via bound queries + SQL migrations |
43
+ | Files | R2 |
44
+ | Validation | Zod schemas in `shared/` |
45
+ | Auth | PPLE ID (OIDC `userinfo`); local `DEV_AUTH` bypass only |
46
+ | Notifications| Audience-bound via `src/notify.ts` (`title`/`body`, optional `linkPath`) |
47
+ | Deploy | `pple deploy` (platform build → tenant Worker); never personal cloud |
48
+
49
+ **Not the Builder App path** (do not resurrect these for new apps): Express +
50
+ Prisma + PostgreSQL, Docker / Cloud Build / Artifact Registry, or a separate
51
+ Cloudflare Pages project for the SPA. Those belong to older reference apps
52
+ and central-team services, not the Workers golden path.
53
+
54
+ Source of truth for new apps:
55
+
56
+ - **`pple-platform/template`** — the golden-path seed `pple init` copies.
57
+ Prefer this over obsolete examples (`pple-erp`, `mini-app-example`).
58
+
59
+ ## Which reference to read
60
+
61
+ Read only the file(s) relevant to the task at hand:
62
+
63
+ | Task | Read |
64
+ | ---- | ---- |
65
+ | Start a new app / local layout & commands | [references/project-scaffold.md](references/project-scaffold.md) |
66
+ | Login, tokens, NOT_IN_APP, DEV_AUTH | [references/pple-id-auth.md](references/pple-id-auth.md) |
67
+ | Mini-app SDK behavior, push notifications | [references/mini-app.md](references/mini-app.md) |
68
+ | UI, styling, components | [references/design-system.md](references/design-system.md) |
69
+ | Create → init → deploy on managed hosting | [references/deployment.md](references/deployment.md) |
70
+
71
+ ## Golden path for a new app
72
+
73
+ 1. **Create the app** in the Console (`console.ppleth.ai`) — one click
74
+ provisions the slug, Worker, D1, R2, OAuth client, and hosting.
75
+ 2. **Sign in on the machine**: `pple auth login` (PPLE ID device flow; no
76
+ personal cloud credentials — ADR-0005).
77
+ 3. **Scaffold locally**: `pple init <slug>` copies the golden-path template
78
+ and links the folder to the app.
79
+ 4. **Build** against the template helpers (`auth`, `db`, `files`, `notify`)
80
+ and `@pplethai/components` (see the reference files).
81
+ 5. **Ship**: `pple deploy` — the platform builds and puts the app live.
82
+
83
+ ## Invariants — things that are true no matter what you build
84
+
85
+ - **Mini-apps authenticate through PPLE Today, not a plain browser.** In
86
+ production, a normal browser shows the friendly NOT_IN_APP screen. Local
87
+ development uses `DEV_AUTH` in `.dev.vars` only — never provisioned in
88
+ production. (Builders separately hold tokens from Console login and
89
+ `pple auth login`; that does not change how end users enter a mini-app.)
90
+ - **Never mention git, branches, remotes, GitHub, Cloudflare accounts, or
91
+ personal deploy credentials** in the happy path. Talk about the app's
92
+ code and `pple init` / `pple deploy` only.
93
+ - **Notifications are audience-bound** (`title` / `body`, optional
94
+ `linkPath` into this app only). Never send phone numbers, user ids, or
95
+ free-form / cross-app destinations; the platform resolves the audience and
96
+ joins `linkPath` to this app's entry. Wire shape lives in `src/notify.ts`.
97
+ - **Stack is Workers + D1 + R2.** Bind D1 parameters; never concatenate SQL.
98
+ One Worker, same origin — no CORS between SPA and API.
99
+ - **`DEV_AUTH` must be unset in production.** Its absence is what forces real
100
+ PPLE ID validation.
101
+ - **Design tokens are CSS variables** from `@pplethai/components/styles.css`.
102
+ Prefer design-system components over hardcoding brand colors.
@@ -0,0 +1,29 @@
1
+ {
2
+ "skill_name": "pple-platform",
3
+ "evals": [
4
+ {
5
+ "id": 1,
6
+ "prompt": "I created an app called brave-otter in the Console. Help me get it running locally and understand the project layout so I can add features with my AI agent.",
7
+ "expected_output": "pple auth login → pple init brave-otter; single-package Workers template (src/ Hono Worker, web/ React, shared/ Zod, migrations/ D1); npm install, .dev.vars from example, db:migrate:local, npm run dev; DEV_AUTH local bypass; no Express/Prisma/Pages scaffold",
8
+ "files": []
9
+ },
10
+ {
11
+ "id": 2,
12
+ "prompt": "Add a feature that sends a push notification to our users when a note is saved. Use the PPLE notification service the platform wired up.",
13
+ "expected_output": "Use src/notify.ts / NotificationSchema with title+body and optional path-only linkPath (self-link into this app); POST to NOTIFY_API_BASE/notifications with NOTIFY_API_KEY; no phone numbers, user ids, free-form cross-app links, or PHONE_NUMBER audience; no legacy /external/notifications/send with raw numbers",
14
+ "files": []
15
+ },
16
+ {
17
+ "id": 3,
18
+ "prompt": "How do I put my changes live? Set up deployment for this Builder App.",
19
+ "expected_output": "pple deploy (after pple auth login); managed Workers+D1+R2; no Dockerfile, Cloud Build, Artifact Registry, or separate Cloudflare Pages project; no git/GitHub credential instructions in the happy path",
20
+ "files": []
21
+ },
22
+ {
23
+ "id": 4,
24
+ "prompt": "The app should work when I open it in Chrome on my laptop. Add a normal browser login with PPLE ID.",
25
+ "expected_output": "Refuse browser login for the mini-app; show NOT_IN_APP in plain browsers; real tokens via PPLE Today; local DEV_AUTH only for vite dev — do not claim tokens only exist inside PPLE Today as a platform-wide absolute (Console/CLI hold Builder tokens)",
26
+ "files": []
27
+ }
28
+ ]
29
+ }
@@ -0,0 +1,61 @@
1
+ # Deployment (managed Workers hosting)
2
+
3
+ Builder Apps run only on party-managed Cloudflare infrastructure
4
+ (ADR-0001, ADR-0002). Builders never hold cloud or GitHub credentials
5
+ (ADR-0005). The happy path uses the Console and the `pple` CLI — not git
6
+ vocabulary, Docker, Cloud Build, or personal Cloudflare/Pages projects.
7
+
8
+ ## Happy path
9
+
10
+ ```bash
11
+ # 1. Create the app in the Console (console.ppleth.ai)
12
+ # → slug, Worker, D1, R2, OAuth client, hosting — all provisioned
13
+
14
+ pple auth login # PPLE ID device flow on this machine
15
+ pple init <slug> # scaffold template + App Link (.pple/app.json)
16
+ # …edit locally with npm run dev…
17
+ pple deploy -m "short note" # send changes; platform builds and goes live
18
+ ```
19
+
20
+ What the Builder hears: the app's name and live URL (e.g.
21
+ `https://brave-otter.ppleth.ai`). Not branches, remotes, registries, or
22
+ source-control jargon.
23
+
24
+ | Command | Meaning for the Builder |
25
+ | ------- | ----------------------- |
26
+ | `pple auth login` | Sign in with PPLE ID |
27
+ | `pple init <slug>` | Set up this app's code on this computer |
28
+ | `pple deploy` | Send changes and put them live |
29
+ | `pple whoami` / `pple auth logout` | Check / forget this machine's sign-in |
30
+
31
+ ## What the platform owns
32
+
33
+ - **Hosting**: Workers for Platforms dispatch namespace; one tenant Worker per
34
+ app serving API + static assets; D1 database; R2 bucket.
35
+ - **Identity bindings**: `SSO_BASE_URL`, OAuth client env for the SPA,
36
+ audience-bound `NOTIFY_API_BASE` / `NOTIFY_API_KEY`.
37
+ - **The app's code**: stored and shipped by the platform. `pple init` /
38
+ `pple deploy` authenticate under the hood — the Builder never configures
39
+ a source-control account or pastes a deploy token.
40
+ - **Config / secrets**: set in the Console; applied as Worker bindings on the
41
+ **next** deploy. Secrets are write-only (set/unset, never read back).
42
+
43
+ ## Local vs production auth
44
+
45
+ - Local: `DEV_AUTH` in `.dev.vars` (see [pple-id-auth.md](pple-id-auth.md)).
46
+ - Production: `DEV_AUTH` must be absent. The platform never provisions it.
47
+
48
+ ## Archived / do-not-use for Builder Apps
49
+
50
+ These were the previous golden path. Do not set them up for new Builder Apps:
51
+
52
+ - Separate **Cloudflare Pages** project for a Vite SPA + Dockerized Express API
53
+ - **Google Cloud Build** / Kaniko / Artifact Registry Docker deploys
54
+ - Asking the Builder to sign into GitHub, create a Cloudflare account, or
55
+ paste long-lived deploy tokens
56
+ - `api/Dockerfile` + `api/cloudbuild.yaml` monorepo layouts copied from
57
+ older Express-era apps
58
+
59
+ If the user is maintaining a central-team service that still uses those
60
+ tools, say so explicitly — that is outside the Builder App path this skill
61
+ steers.
@@ -0,0 +1,50 @@
1
+ # PPLE Design System (@pplethai/components)
2
+
3
+ `@pplethai/components` is the party's React component library, built on
4
+ shadcn/ui. Live docs: **https://components.pplethai.org/**
5
+
6
+ Builder Apps start with the golden-path template (`pple-platform/template`),
7
+ which already imports the library. Prefer editing that wiring over
8
+ reinventing a design-system setup from `pple-erp` or other older apps.
9
+
10
+ ## Consumer setup (already in the template)
11
+
12
+ ```tsx
13
+ // web/main.tsx — design-system CSS first, then local layout
14
+ import "@pplethai/components/styles.css";
15
+ import "./app.css";
16
+
17
+ import { Button, Card, CardContent, Logo } from "@pplethai/components";
18
+ ```
19
+
20
+ Package entry points:
21
+
22
+ | Import | Contents |
23
+ | ------ | -------- |
24
+ | `@pplethai/components` | UI primitives, layout, brand (`Logo`, …), `cn()`, toasts |
25
+ | `@pplethai/components/form` | react-hook-form helpers when you need forms |
26
+ | `@pplethai/components/styles.css` | Brand tokens — import once |
27
+
28
+ Vite note (template already pins this): the package's `"development"` export
29
+ can point at unpublished source. Keep the template's resolve alias that pins
30
+ the bare `@pplethai/components` import to `dist/index.js`.
31
+
32
+ ## Layout conventions in Builder Apps
33
+
34
+ - Put **page layout** in `web/app.css` (mobile-first, safe-area padding for
35
+ the WebView). Do not fight the design-system stylesheet with a second
36
+ full Tailwind stack unless you need it.
37
+ - Compose screens from design-system components (`Card`, `Button`, `Input`,
38
+ `Textarea`, `Badge`, `Alert`, …) like the demo cards in `web/App.tsx`.
39
+ - **Colors and type** come from CSS variables in `styles.css`
40
+ (`--primary`, fonts, gradients). Prefer components and those tokens over
41
+ hardcoded hex brand colors.
42
+ - Typography: headings **Anakotmai**, body **IBM Plex Sans Thai Looped** —
43
+ both arrive with `styles.css`.
44
+
45
+ ## Filling gaps
46
+
47
+ When the library lacks a component, generate a local shadcn/ui piece into
48
+ your app and keep tokens aligned — or stick to plain markup + `app.css`.
49
+ Prefer `@pplethai/components` whenever an equivalent exists so apps stay
50
+ visually consistent.
@@ -0,0 +1,74 @@
1
+ # Mini-app integration & audience-bound notifications
2
+
3
+ ## Running inside PPLE Today
4
+
5
+ Builder Apps ship as **mini-apps**: web apps opened in PPLE Today's WebView.
6
+ `@pplethai/pple-today-miniapp-sdk` captures the access token — see
7
+ [pple-id-auth.md](pple-id-auth.md). Facts that shape your design:
8
+
9
+ - `sdk.init()` must complete before any other SDK call.
10
+ - Outside PPLE Today (plain browser), show NOT_IN_APP — that is expected, not
11
+ an error. Locally, `DEV_AUTH` covers development.
12
+ - The OAuth client and mini-app URL are provisioned when the app is created in
13
+ the Console; Builders do not register them manually with the Today team.
14
+ - Design mobile-first; test at ~375 px width.
15
+
16
+ ## Push notifications (audience-bound)
17
+
18
+ Builder Apps send push notifications through the helper in `src/notify.ts`.
19
+ The privacy model is fixed by the platform:
20
+
21
+ - Your code sends a `title` and `body`, plus an optional path-only self-link
22
+ (`linkPath`).
23
+ - **Never** send phone numbers, user ids, or an audience list.
24
+ - The platform resolves recipients as this app's App Users ∩ its current
25
+ publication-tier audience, and enforces the per-app rate limit.
26
+ - `NOTIFY_API_BASE` and `NOTIFY_API_KEY` are provisioned bindings. The key
27
+ *is* the address — no app id and no recipients on the wire.
28
+
29
+ ### App-facing shape (`shared/api.ts`)
30
+
31
+ ```ts
32
+ import { NotificationSchema } from "../shared/api";
33
+ // { title: string, body: string, linkPath?: string }
34
+ // linkPath must start with a single `/` (e.g. "/check-in")
35
+ ```
36
+
37
+ Optional `linkPath` is a deep link into **this** app only. The platform joins
38
+ it to the app's known entry and rejects absolute URLs, schemes, `..`, or
39
+ anything that would leave this app. Free-form destinations that could address
40
+ another mini app (e.g. `content.link`) are not available on this route.
41
+ Omit `linkPath` for content-only sends — tapping then opens the notification
42
+ centre, as before.
43
+
44
+ ### Wire shape (`src/notify.ts`)
45
+
46
+ Keep the platform vocabulary translation in `notify.ts` only:
47
+
48
+ ```ts
49
+ await fetch(`${env.NOTIFY_API_BASE}/notifications`, {
50
+ method: "POST",
51
+ headers: {
52
+ authorization: `Bearer ${env.NOTIFY_API_KEY}`,
53
+ "content-type": "application/json",
54
+ },
55
+ body: JSON.stringify({
56
+ content: { header: input.title, message: input.body },
57
+ ...(input.linkPath !== undefined ? { linkPath: input.linkPath } : {}),
58
+ }),
59
+ });
60
+ ```
61
+
62
+ - Missing config → fail loud (`notifications_not_configured` / 503), not silent.
63
+ - HTTP 429 → `rate_limited`.
64
+ - Prefer fire-and-forget from route handlers with error logging — do not fail
65
+ the user's primary action solely because notify is slow.
66
+
67
+ ### What not to do
68
+
69
+ Do **not** call the legacy central-team endpoint that accepts a
70
+ `PHONE_NUMBER` audience (`/external/notifications/send` with raw numbers).
71
+ That path is not the Builder App contract. If you see older docs or
72
+ `mini-app-example` code addressing phones, ignore them for new apps.
73
+ Do **not** invent free-form link / absolute URL fields — self-links use
74
+ `linkPath` only.