@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.
- package/README.md +161 -0
- package/askpass/pple-askpass.mjs +18 -0
- package/dist/api.js +62 -0
- package/dist/bin.js +16 -0
- package/dist/cli.js +103 -0
- package/dist/commands/auth.js +32 -0
- package/dist/commands/deploy.js +106 -0
- package/dist/commands/init.js +77 -0
- package/dist/commands/upgrade.js +29 -0
- package/dist/credentials.js +53 -0
- package/dist/deps.js +87 -0
- package/dist/device-flow.js +115 -0
- package/dist/errors.js +16 -0
- package/dist/git.js +127 -0
- package/dist/link.js +49 -0
- package/dist/node-deps.js +84 -0
- package/dist/npm.js +127 -0
- package/dist/scaffold.js +58 -0
- package/dist/session.js +29 -0
- package/dist/skills.js +86 -0
- package/dist/ui.js +12 -0
- package/dist/update.js +142 -0
- package/package.json +35 -0
- package/skills/pple-platform/SKILL.md +102 -0
- package/skills/pple-platform/evals/evals.json +29 -0
- package/skills/pple-platform/references/deployment.md +61 -0
- package/skills/pple-platform/references/design-system.md +50 -0
- package/skills/pple-platform/references/mini-app.md +74 -0
- package/skills/pple-platform/references/pple-id-auth.md +85 -0
- package/skills/pple-platform/references/project-scaffold.md +75 -0
- package/templates/golden-path/.dev.vars.example +17 -0
- package/templates/golden-path/.env.example +8 -0
- package/templates/golden-path/.github/workflows/ci.yml +22 -0
- package/templates/golden-path/CLAUDE.md +60 -0
- package/templates/golden-path/README.md +60 -0
- package/templates/golden-path/gitignore +6 -0
- package/templates/golden-path/index.html +16 -0
- package/templates/golden-path/migrations/0001_init.sql +9 -0
- package/templates/golden-path/package-lock.json +4283 -0
- package/templates/golden-path/package.json +38 -0
- package/templates/golden-path/shared/api.ts +56 -0
- package/templates/golden-path/src/auth.ts +53 -0
- package/templates/golden-path/src/db.ts +32 -0
- package/templates/golden-path/src/env.ts +19 -0
- package/templates/golden-path/src/files.ts +38 -0
- package/templates/golden-path/src/index.ts +104 -0
- package/templates/golden-path/src/notify.ts +64 -0
- package/templates/golden-path/test/apply-migrations.ts +4 -0
- package/templates/golden-path/test/env.d.ts +17 -0
- package/templates/golden-path/test/harness.ts +57 -0
- package/templates/golden-path/test/smoke.test.ts +155 -0
- package/templates/golden-path/tsconfig.json +20 -0
- package/templates/golden-path/tsconfig.worker.json +21 -0
- package/templates/golden-path/vite.config.ts +29 -0
- package/templates/golden-path/vitest.config.ts +26 -0
- package/templates/golden-path/web/App.tsx +250 -0
- package/templates/golden-path/web/NotInApp.tsx +23 -0
- package/templates/golden-path/web/app.css +112 -0
- package/templates/golden-path/web/main.tsx +40 -0
- package/templates/golden-path/web/pple.ts +73 -0
- package/templates/golden-path/web/vite-env.d.ts +10 -0
- package/templates/golden-path/wrangler.jsonc +53 -0
package/dist/session.js
ADDED
|
@@ -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
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.
|