@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
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # pple CLI
2
+
3
+ The Builder's terminal tool: three commands between a PPLE ID and a live app.
4
+
5
+ ```bash
6
+ pple auth login # sign in with your PPLE ID (browser, no password in the terminal)
7
+ pple init brave-otter # set up the app's code in ./brave-otter, linked to the app
8
+ pple deploy -m "Add the form" # send the changes and put them live
9
+ pple upgrade # update pple itself from npm
10
+ ```
11
+
12
+ Git is an implementation detail, and so is GitHub. A Builder is told what
13
+ happened to *their app* — "Sending your changes…", "Volunteer Sign-up is live
14
+ at https://brave-otter.ppleth.ai" — never what happened to a branch, and never
15
+ that there is an account somewhere they should go and get. Only a genuine
16
+ conflict surfaces the tools underneath, and even then with an instruction
17
+ rather than a stack trace.
18
+
19
+ ## Reaching the app's code
20
+
21
+ App repos are private and the Builder's machine has no credential for them.
22
+ Before each command that touches code, the CLI asks the Provisioner for a
23
+ token scoped to that one repo, keeps it in memory, and hands it to `git`
24
+ through the child process's environment: `GIT_ASKPASS` points at
25
+ [`askpass/pple-askpass.mjs`](askpass/pple-askpass.mjs), which reads the token
26
+ back out of that environment when git asks for a password. `origin` stays a
27
+ plain URL, so the token never reaches argv (world-readable via `ps`),
28
+ `.git/config`, or any other file. Commits are authored as the Builder's PPLE
29
+ ID identity, which the same response supplies — the pusher is the platform's
30
+ GitHub App, and would otherwise be the only name in an app's history. The
31
+ reasoning is [ADR-0005](../docs/adr/0005-builders-never-hold-github-credentials.md).
32
+
33
+ ## How the commands work
34
+
35
+ **`pple auth login`** runs the OAuth device authorization grant against PPLE
36
+ ID: the CLI prints a code, opens the verification page, and polls until the
37
+ Builder approves it. Tokens land in `$XDG_CONFIG_HOME/pple/credentials.json`
38
+ (mode `0600`) — never inside an app folder, which is a repo the platform
39
+ owns. `pple whoami` renews an expiring token silently via the refresh token.
40
+
41
+ **`pple init <app>`** takes an app the Provisioner already created (ADR-0001
42
+ — the CLI never creates repos), fetches its code, scaffolds the golden-path
43
+ template into it under the app's own name, writes `.pple/app.json`
44
+ linking the folder to the app, and installs the platform Skill overlay the
45
+ Builder's AI coding agent can load (ADR-0006).
46
+
47
+ **`pple deploy`** refreshes that same Skill overlay, records everything in
48
+ the folder, sends it to the app's repo — merging in a Collaborator's work if
49
+ there is any — then watches the platform build it and reports the live URL,
50
+ or the build output that explains why nothing changed. It needs no
51
+ arguments: the link file says which app. Account-only commands
52
+ (`auth login` / `logout` / `whoami`) never touch Skills.
53
+
54
+ **`pple upgrade`** asks npm what the latest `@pplethai/cli` is and, when
55
+ this copy is one npm's global root holds, installs it — which is also how
56
+ Skills and the golden-path template move forward (ADR-0006, ADR-0007). Any
57
+ other install channel is told the manual `npm install -g @pplethai/cli`
58
+ line rather than having a second copy installed over the first, and so is a
59
+ failed install, carrying npm's own reason. The CLI never uses `sudo`: a global
60
+ folder the Builder can't write to is theirs to fix, not the CLI's to force.
61
+
62
+ ## Staying current
63
+
64
+ About once a day, before running the command it was given, the CLI asks npm
65
+ whether there is a newer `pple`. It runs *before* the command because that is
66
+ the only moment an upgrade is free: nothing is half-done, so the new version
67
+ can simply be handed the same command line. At a terminal the Builder is
68
+ asked, and on a yes the CLI installs the new version and re-runs the original
69
+ command on it. With nobody at the keyboard — the AFK agent runs the CLI is
70
+ built to serve — it prints one line about the new version and gets on with the
71
+ command; it never asks a question that could hang.
72
+
73
+ The check is written to fail towards the command. A registry that can't be
74
+ reached says nothing at all and costs one request, not one per command:
75
+ `update-check.json` in the CLI's config directory records that npm was
76
+ *asked*, not what it answered. A failed upgrade prints npm's own reason and
77
+ the manual line, then offers to carry on with the current CLI (unattended:
78
+ carries on). Two ways to turn the whole thing off:
79
+
80
+ ```bash
81
+ PPLE_NO_UPDATE_CHECK=1 pple deploy # this run, or exported in CI
82
+ echo '{ "updateCheck": false }' > ~/.config/pple/config.json # this machine
83
+ ```
84
+
85
+ ## Layout
86
+
87
+ | File | What it holds |
88
+ | ---- | ------------- |
89
+ | `src/cli.ts` | argument parsing and the command table |
90
+ | `src/commands/` | one file per command, each taking only `Deps` |
91
+ | `src/deps.ts` | every seam: fetch, clock, subprocesses, output, template/skills dirs |
92
+ | `src/device-flow.ts` | RFC 8628 against PPLE ID |
93
+ | `src/api.ts` | the Provisioner API, and the only place that knows its error codes |
94
+ | `src/git.ts` | git, and the only place that names it or holds the repo token |
95
+ | `src/scaffold.ts` | copying the golden-path template |
96
+ | `src/skills.ts` | refreshing the platform Skill overlay into an app folder |
97
+ | `src/npm.ts` | npm: the registry's latest version, and the install channel |
98
+ | `src/update.ts` | the daily update check, its cadence state, and its opt-outs |
99
+ | `askpass/pple-askpass.mjs` | what git runs to be told the app's repo token |
100
+
101
+ ## Development
102
+
103
+ ```bash
104
+ npm install
105
+ npm test # drives whole commands against a fake Provisioner and real git
106
+ npm run typecheck
107
+ npm run build # bundles the template and Skills, compiles to dist/
108
+ ```
109
+
110
+ Tests run the real command path — real `git` in a temp directory, a fake
111
+ platform behind `Deps.fetch`, and a virtual clock so polling loops finish
112
+ instantly. `test/support/harness.ts` is the entry point.
113
+
114
+ App repos are served in tests by `git http-backend` behind Basic auth
115
+ (`test/support/git-server.ts`), accepting only tokens the fake platform
116
+ minted. A local path would let every clone and push succeed with no credential
117
+ at all, and nothing would show that the platform's token is what opens a
118
+ private repo.
119
+
120
+ The published package carries its own copies of the golden-path template
121
+ (`npm run build:template` copies `../template`) and platform Skills
122
+ (`npm run build:skills` copies `../skills/pple-platform`). Running from a
123
+ checkout falls back to those repo paths directly. `PPLE_TEMPLATE_DIR`,
124
+ `PPLE_SKILLS_DIR`, `PPLE_API_BASE`, `PPLE_SSO_BASE`, `PPLE_CLIENT_ID`,
125
+ `PPLE_REPO_BASE`, and `PPLE_REGISTRY_BASE` override the defaults for staging
126
+ work.
127
+
128
+ ## CLI release
129
+
130
+ A full Phase 1 launch includes a **CLI release**: shipping
131
+ `@pplethai/cli` (bin: `pple`) to the npm `latest` dist-tag, which is what
132
+ Builders install and self-update from. This is **not** part of the Cloudflare
133
+ Worker deploy — a Worker rollback must never ship or unship a CLI version.
134
+
135
+ Releases happen through the **CLI release** workflow
136
+ (`.github/workflows/cli-release.yml`): anyone with repository write dispatches
137
+ it on `main`, and it re-runs typecheck, test, and build, asks the release gate
138
+ whether the version in `package.json` may reach the registry, publishes, and
139
+ only then tags the commit `cli-vX.Y.Z`. The one exception is the bootstrap
140
+ publish that creates the package on npm, which a human runs once — see
141
+ [docs/deploy-phase1.md](../docs/deploy-phase1.md) §3 for both.
142
+
143
+ The version comes from `package.json` on `main` — the workflow takes no
144
+ version input, and refuses to release a version npm already holds. Nobody
145
+ edits that version by hand: a PR changing what this package ships carries a
146
+ Changeset (`npx changeset` at the repo root, or a `skip-changeset` label if it
147
+ ships nothing), and the Version Packages PR spends those Changesets into
148
+ `package.json` and `CHANGELOG.md`. So `main` may sit ahead of npm — a version
149
+ here is prepared, not shipped. See [`.changeset/README.md`](../.changeset/README.md)
150
+ and [ADR-0008](../docs/adr/0008-cli-release-is-a-deliberate-act.md).
151
+
152
+ Past the bootstrap publish that creates the package, there is no supported way
153
+ to release from a laptop. The release gate is `scripts/release-gate.mjs`, and
154
+ it answers locally too:
155
+
156
+ ```bash
157
+ node scripts/release-gate.mjs release # may this version reach npm?
158
+ npm publish --access public --dry-run # inspect the tarball, upload nothing
159
+ ```
160
+
161
+ Builders install with `npm install -g @pplethai/cli`.
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ // What git asks when it needs a credential for the app's repo.
3
+ //
4
+ // git calls this with the prompt as its only argument and reads the answer
5
+ // from stdout. The token reaches it through the environment of the git process
6
+ // the CLI spawned — never through a command line, which `ps` makes readable to
7
+ // everyone on the machine, and never through a file (ADR-0005).
8
+ //
9
+ // Plain .mjs rather than compiled output, shipped with its executable bit set:
10
+ // git execs GIT_ASKPASS directly rather than through a shell, so the shebang
11
+ // above is what finds node, and there is no build step to keep in step.
12
+
13
+ const prompt = process.argv[2] ?? "";
14
+ const answer = /username/i.test(prompt)
15
+ ? (process.env.PPLE_GIT_USER ?? "")
16
+ : (process.env.PPLE_GIT_TOKEN ?? "");
17
+
18
+ process.stdout.write(`${answer}\n`);
package/dist/api.js ADDED
@@ -0,0 +1,62 @@
1
+ // The Provisioner API as the CLI sees it. Every non-2xx answer becomes a
2
+ // sentence a Builder can act on — this is the only place in the CLI that
3
+ // knows the platform's error codes.
4
+ import { CliError, SIGN_IN_EXPIRED } from "./errors.js";
5
+ export class ProvisionerClient {
6
+ deps;
7
+ token;
8
+ constructor(deps, token) {
9
+ this.deps = deps;
10
+ this.token = token;
11
+ }
12
+ me() {
13
+ return this.request("GET", "/me");
14
+ }
15
+ app(slug) {
16
+ return this.request("GET", `/apps/${encodeURIComponent(slug)}`, { slug });
17
+ }
18
+ repoToken(slug) {
19
+ return this.request("POST", `/apps/${encodeURIComponent(slug)}/repo-token`, { slug });
20
+ }
21
+ deploys(slug) {
22
+ return this.request("GET", `/apps/${encodeURIComponent(slug)}/deploys`, { slug }).then((r) => r.deploys);
23
+ }
24
+ async request(method, path, context = {}) {
25
+ const url = `${this.deps.config.apiBase}${path}`;
26
+ const res = await this.deps
27
+ .fetch(url, {
28
+ method,
29
+ headers: { authorization: `Bearer ${this.token}` },
30
+ })
31
+ .catch(() => null);
32
+ if (!res) {
33
+ throw new CliError("Couldn't reach the PPLE Platform.", "Check your internet connection and try again.");
34
+ }
35
+ if (res.ok)
36
+ return (await res.json());
37
+ const body = (await res.json().catch(() => ({})));
38
+ throw platformError(res.status, body.error, context.slug);
39
+ }
40
+ }
41
+ function platformError(status, code, slug) {
42
+ if (status === 401)
43
+ return SIGN_IN_EXPIRED;
44
+ if (code === "not_org_member") {
45
+ return new CliError("Your PPLE ID isn't part of the party organization, so it can't build apps.", "Ask your team lead to add you, then try again.");
46
+ }
47
+ if (code === "agreement_required") {
48
+ return new CliError("You haven't accepted the Builder agreement yet.", "Accept it at https://console.ppleth.ai and run this command again.");
49
+ }
50
+ if (code === "app_has_no_repo") {
51
+ return new CliError(`${slug ? `"${slug}"` : "That app"} isn't finished being set up yet.`, "Wait a moment and try again; if it stays like this, ask the platform team.");
52
+ }
53
+ if (status === 404) {
54
+ return new CliError(slug
55
+ ? `There's no app called "${slug}" that belongs to you.`
56
+ : "That doesn't exist on the platform.", "Check the app list at https://console.ppleth.ai.");
57
+ }
58
+ if (status >= 500 || code === "sso_unavailable") {
59
+ return new CliError("The PPLE Platform is having trouble right now.", "Wait a moment and try again; if it keeps happening, tell the platform team.");
60
+ }
61
+ return new CliError("The PPLE Platform refused that request.", code ? `It said: ${code}` : undefined);
62
+ }
package/dist/bin.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import os from "node:os";
3
+ import { run } from "./cli.js";
4
+ import { nodeDeps } from "./node-deps.js";
5
+ import { consoleOutput } from "./ui.js";
6
+ const out = consoleOutput((text) => process.stdout.write(text), (text) => process.stderr.write(text));
7
+ try {
8
+ process.exitCode = await run(process.argv.slice(2), nodeDeps(out, os.homedir()));
9
+ }
10
+ catch (e) {
11
+ // Not a failure the CLI knows how to explain — say so plainly instead of
12
+ // dumping a stack trace on a Builder who can't act on it.
13
+ process.stderr.write("Something went wrong inside the pple CLI. Please send this to the platform team:\n");
14
+ process.stderr.write(`${e instanceof Error ? (e.stack ?? e.message) : String(e)}\n`);
15
+ process.exitCode = 1;
16
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,103 @@
1
+ import * as auth from "./commands/auth.js";
2
+ import { deploy } from "./commands/deploy.js";
3
+ import { init } from "./commands/init.js";
4
+ import { upgrade } from "./commands/upgrade.js";
5
+ import { CliError } from "./errors.js";
6
+ import { checkForUpdate, wantsUpdateCheck } from "./update.js";
7
+ const USAGE = `pple — build and ship apps on the PPLE Platform
8
+
9
+ pple auth login sign in with your PPLE ID
10
+ pple auth logout forget this computer's sign-in
11
+ pple whoami show who you're signed in as
12
+
13
+ pple init <app> set up an app's code on this computer
14
+ pple deploy [-m "note"] send your changes and put them live
15
+
16
+ pple upgrade update pple itself to the newest version
17
+
18
+ Options
19
+ -m, --message <note> what changed, shown in the app's history
20
+ --dir <folder> where to put the app's code (pple init)
21
+ -h, --help show this help
22
+ `;
23
+ /**
24
+ * Parse and run one command line. Returns the process exit code; expected
25
+ * failures come back as 1 with a Builder-language message already printed,
26
+ * and anything else throws for the entry point to report as a CLI bug.
27
+ */
28
+ export async function run(argv, deps) {
29
+ try {
30
+ // Before the command, never during it: an upgrade mid-deploy would swap
31
+ // the CLI under its own feet, and this is the one point where handing the
32
+ // whole command line to the new version costs nothing.
33
+ if (wantsUpdateCheck(argv)) {
34
+ const handled = await checkForUpdate(deps, argv);
35
+ if (handled !== null)
36
+ return handled;
37
+ }
38
+ await dispatch(argv, deps);
39
+ return 0;
40
+ }
41
+ catch (e) {
42
+ if (e instanceof CliError) {
43
+ deps.out.fail(e);
44
+ return 1;
45
+ }
46
+ throw e;
47
+ }
48
+ }
49
+ async function dispatch(argv, deps) {
50
+ const args = [...argv];
51
+ if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
52
+ deps.out.say(USAGE);
53
+ return;
54
+ }
55
+ const command = args.shift();
56
+ switch (command) {
57
+ case "auth": {
58
+ const sub = args.shift();
59
+ if (sub === "login")
60
+ return auth.login(deps);
61
+ if (sub === "logout")
62
+ return auth.logout(deps);
63
+ if (sub === "whoami")
64
+ return auth.whoami(deps);
65
+ throw unknown(`auth ${sub ?? ""}`.trim());
66
+ }
67
+ case "whoami":
68
+ return auth.whoami(deps);
69
+ case "init": {
70
+ const dir = takeOption(args, "--dir");
71
+ const slug = args.shift();
72
+ if (!slug) {
73
+ throw new CliError("Which app? `pple init` needs the app's name.", "Create the app at https://console.ppleth.ai — it shows you the exact command to run.");
74
+ }
75
+ return init(deps, { slug, dir });
76
+ }
77
+ case "deploy": {
78
+ const message = takeOption(args, "--message", "-m");
79
+ return deploy(deps, { message });
80
+ }
81
+ case "upgrade":
82
+ return upgrade(deps);
83
+ case "help":
84
+ deps.out.say(USAGE);
85
+ return;
86
+ default:
87
+ throw unknown(command ?? "");
88
+ }
89
+ }
90
+ function unknown(command) {
91
+ return new CliError(`There's no \`pple ${command}\` command.`, "Run `pple --help` to see what you can do.");
92
+ }
93
+ /** Pull `--name value` out of the argument list, wherever it appears. */
94
+ function takeOption(args, long, short) {
95
+ const index = args.findIndex((arg) => arg === long || (short && arg === short));
96
+ if (index === -1)
97
+ return undefined;
98
+ const [, value] = args.splice(index, 2);
99
+ if (value === undefined || value.startsWith("-")) {
100
+ throw new CliError(`\`${long}\` needs a value after it.`);
101
+ }
102
+ return value;
103
+ }
@@ -0,0 +1,32 @@
1
+ import { ProvisionerClient } from "../api.js";
2
+ import { clearCredentials, saveCredentials } from "../credentials.js";
3
+ import { awaitToken, requestDeviceCode } from "../device-flow.js";
4
+ import { client } from "../session.js";
5
+ export async function login(deps) {
6
+ const device = await requestDeviceCode(deps);
7
+ deps.out.say("To sign in, open this page and enter the code:");
8
+ deps.out.say();
9
+ deps.out.say(` ${device.verificationUri}`);
10
+ deps.out.say(` code: ${device.userCode}`);
11
+ deps.out.say();
12
+ deps.out.say("Waiting for you to approve the sign-in…");
13
+ await deps.openBrowser(device.verificationUriComplete ?? device.verificationUri);
14
+ const tokens = await awaitToken(deps, device);
15
+ await saveCredentials(deps, { ...tokens, ssoBase: deps.config.ssoBase });
16
+ const me = await new ProvisionerClient(deps, tokens.accessToken).me();
17
+ deps.out.say(`Signed in as ${me.name ?? me.sub}.`);
18
+ if (!me.isBuilder) {
19
+ deps.out.say("One more step before you can create apps: accept the Builder agreement at https://console.ppleth.ai");
20
+ }
21
+ }
22
+ export async function logout(deps) {
23
+ const had = await clearCredentials(deps);
24
+ deps.out.say(had ? "Signed out." : "You weren't signed in.");
25
+ }
26
+ export async function whoami(deps) {
27
+ const me = await (await client(deps)).me();
28
+ deps.out.say(`Signed in as ${me.name ?? me.sub}.`);
29
+ if (!me.isBuilder) {
30
+ deps.out.say("You haven't accepted the Builder agreement yet — do that at https://console.ppleth.ai");
31
+ }
32
+ }
@@ -0,0 +1,106 @@
1
+ import { CliError } from "../errors.js";
2
+ import { hasLocalChanges, headCommit, isUpToDateWithRemote, recordChanges, sendChanges, } from "../git.js";
3
+ import { findLink } from "../link.js";
4
+ import { client } from "../session.js";
5
+ import { refreshSkills } from "../skills.js";
6
+ /** How often to ask the platform how the build is going. */
7
+ const POLL_INTERVAL_MS = 3000;
8
+ /** How long to wait for CI to pick the push up before saying so. */
9
+ const BUILD_START_TIMEOUT_MS = 3 * 60_000;
10
+ /** How long a build itself may take before the CLI stops watching. */
11
+ const BUILD_TIMEOUT_MS = 10 * 60_000;
12
+ /** How much of a failed build's log to show; the rest is in the console. */
13
+ const LOG_TAIL_LINES = 20;
14
+ /**
15
+ * Put the Builder's current work live: record it, send it, and watch the
16
+ * platform build it — reporting the live URL, or a failure they can read.
17
+ */
18
+ export async function deploy(deps, options) {
19
+ const link = await findLink(deps);
20
+ if (link.apiBase !== "" && link.apiBase !== deps.config.apiBase) {
21
+ throw new CliError("This app belongs to a different PPLE Platform than the one you're signed in to.", `It was set up against ${link.apiBase}.`);
22
+ }
23
+ const api = await client(deps);
24
+ const app = await api.app(link.slug);
25
+ if (app.status === "retired") {
26
+ throw new CliError(`"${app.name}" has been retired.`, "A retired app can't be deployed to. Create a new app in the Console if you want to pick this work back up.");
27
+ }
28
+ if (app.status !== "ready") {
29
+ throw new CliError(`"${app.name}" isn't ready to receive changes yet.`, app.error
30
+ ? `Setting it up ran into a problem: ${app.error}`
31
+ : "Check on it at https://console.ppleth.ai and try again in a moment.");
32
+ }
33
+ // One token for the whole command, kept in memory: it says both what this
34
+ // machine may push and whose name the work goes out under (ADR-0005).
35
+ const access = await api.repoToken(link.slug);
36
+ // Keep the local Skill overlay current even when there is nothing new to
37
+ // send — including first seed for folders set up before Skills shipped.
38
+ await refreshSkills(deps, link.dir);
39
+ if (await hasLocalChanges(deps, link.dir)) {
40
+ deps.out.say("Saving your changes…");
41
+ await recordChanges(deps, link.dir, options.message ?? `Update ${app.name}`, access);
42
+ }
43
+ else if (await isUpToDateWithRemote(deps, link.dir, access)) {
44
+ deps.out.say(`Nothing new to send — ${app.name} is already live at ${app.url}`);
45
+ return;
46
+ }
47
+ deps.out.say("Sending your changes…");
48
+ await sendChanges(deps, link.dir, access);
49
+ const commit = await headCommit(deps, link.dir);
50
+ deps.out.say("Building your app… (this usually takes a minute)");
51
+ const result = await watchDeploy(deps, api, link.slug, commit);
52
+ report(deps, app, result);
53
+ }
54
+ async function watchDeploy(deps, api, slug, commit) {
55
+ const started = deps.now();
56
+ let build;
57
+ for (;;) {
58
+ const deploys = await api.deploys(slug);
59
+ const found = deploys.find((d) => d.commit === commit);
60
+ if (found) {
61
+ build = found;
62
+ if (found.status !== "building")
63
+ return found;
64
+ }
65
+ const waited = deps.now() - started;
66
+ // Before CI has opened a deploy there is nothing to watch; after it has,
67
+ // the clock that matters is the build's.
68
+ const limit = build ? BUILD_TIMEOUT_MS : BUILD_START_TIMEOUT_MS;
69
+ if (waited >= limit) {
70
+ throw new CliError(build
71
+ ? "Your changes were sent, but the build is taking longer than expected."
72
+ : "Your changes were sent, but the platform hasn't started building them.", `Watch it finish at https://console.ppleth.ai/apps/${slug}.`);
73
+ }
74
+ await deps.sleep(POLL_INTERVAL_MS);
75
+ }
76
+ }
77
+ /** How the Provisioner marks a deploy that a newer one overtook. */
78
+ const SUPERSEDED = "superseded by a newer deploy";
79
+ function report(deps, app, build) {
80
+ if (build.status === "failed") {
81
+ if (build.error === SUPERSEDED) {
82
+ throw new CliError("A newer deploy of this app replaced the one you were waiting for.", `Watch that one finish at https://console.ppleth.ai/apps/${app.slug}.`);
83
+ }
84
+ // A build that compiled badly explains itself in its log; a deploy that
85
+ // failed after the build — a bad bundle, a rejected upload — only has the
86
+ // platform's own reason, and the Builder must still be told something.
87
+ const detail = build.log ? tail(build.log, LOG_TAIL_LINES) : build.error;
88
+ if (detail) {
89
+ deps.out.say();
90
+ deps.out.say(detail);
91
+ deps.out.say();
92
+ }
93
+ throw new CliError(`${app.name} couldn't be built, so what's live hasn't changed.`, detail
94
+ ? "Show the message above to your AI coding agent — it says what to fix."
95
+ : `See what happened at https://console.ppleth.ai/apps/${app.slug}.`);
96
+ }
97
+ if (build.status === "unverified") {
98
+ deps.out.say(`${app.name} was updated, but it hasn't answered yet.`);
99
+ deps.out.say(`Give it a minute, then open ${app.url}`);
100
+ return;
101
+ }
102
+ deps.out.say(`${app.name} is live at ${app.url}`);
103
+ }
104
+ function tail(log, lines) {
105
+ return log.split("\n").slice(-lines).join("\n").trim();
106
+ }
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { CliError } from "../errors.js";
4
+ import { cloneApp, hasLocalChanges, recordChanges } from "../git.js";
5
+ import { writeLink } from "../link.js";
6
+ import { scaffold } from "../scaffold.js";
7
+ import { client } from "../session.js";
8
+ import { refreshSkills } from "../skills.js";
9
+ /**
10
+ * Bring an app that already exists on the platform (ADR-0001: the Provisioner
11
+ * creates it, not the CLI) onto this computer, with the golden-path template
12
+ * in place and linked, ready for the Builder's AI agent to work in.
13
+ */
14
+ export async function init(deps, options) {
15
+ const api = await client(deps);
16
+ const app = await api.app(options.slug);
17
+ if (!app.repo) {
18
+ throw new CliError(`"${app.name}" isn't finished being set up yet.`, "Wait a moment and run `pple init` again; if it stays like this, ask the platform team.");
19
+ }
20
+ if (app.status === "provisioning") {
21
+ deps.out.say("Your app is still being set up on the platform…");
22
+ }
23
+ const targetDir = path.resolve(deps.cwd, options.dir ?? app.slug);
24
+ await ensureAvailable(targetDir);
25
+ // The Builder's machine holds no credential of its own; the platform hands
26
+ // out one that opens this app's code and nothing else (ADR-0005).
27
+ const access = await api.repoToken(app.slug);
28
+ deps.out.say(`Setting up "${app.name}" in ${path.basename(targetDir)}/…`);
29
+ await cloneApp(deps, repoUrl(deps, app.repo), targetDir, access);
30
+ if (await isAlreadyBuilt(targetDir)) {
31
+ throw new CliError(`"${app.name}" already has code, so there's nothing to set up.`, `Work in that folder and run \`pple deploy\` to put changes live.`);
32
+ }
33
+ await scaffold(deps.templateDir, targetDir, app.slug);
34
+ await writeLink(targetDir, {
35
+ slug: app.slug,
36
+ apiBase: deps.config.apiBase,
37
+ url: app.url,
38
+ });
39
+ // Skills are a local overlay (ADR-0006), not app source — refresh before the
40
+ // first commit so the ignore rule is in place and Skill files stay untracked.
41
+ await refreshSkills(deps, targetDir);
42
+ if (await hasLocalChanges(deps, targetDir)) {
43
+ await recordChanges(deps, targetDir, "Start from the PPLE golden-path template", access);
44
+ }
45
+ report(deps, app, targetDir);
46
+ }
47
+ /** A repo the Builder may not have cloned before; https works with any helper. */
48
+ function repoUrl(deps, repoFullName) {
49
+ return `${deps.config.repoBase}/${repoFullName}.git`;
50
+ }
51
+ async function ensureAvailable(targetDir) {
52
+ const entries = await fs.readdir(targetDir).catch(() => null);
53
+ if (entries === null)
54
+ return;
55
+ if (entries.length > 0) {
56
+ throw new CliError(`The folder ${path.basename(targetDir)}/ already has something in it.`, "Run `pple init` somewhere else, or pass `--dir <folder>` to pick another folder.");
57
+ }
58
+ }
59
+ /** A repo that already carries an app is one somebody has already set up. */
60
+ async function isAlreadyBuilt(targetDir) {
61
+ return fs
62
+ .access(path.join(targetDir, "package.json"))
63
+ .then(() => true)
64
+ .catch(() => false);
65
+ }
66
+ function report(deps, app, targetDir) {
67
+ const folder = path.basename(targetDir);
68
+ deps.out.say();
69
+ deps.out.say(`"${app.name}" is ready to work on in ${folder}/`);
70
+ deps.out.say(`It will be live at ${app.url}`);
71
+ deps.out.say();
72
+ deps.out.say("Next:");
73
+ deps.out.say(` cd ${folder}`);
74
+ deps.out.say(" npm install");
75
+ deps.out.say(" npm run dev # see it on this computer");
76
+ deps.out.say(" pple deploy # put it live");
77
+ }
@@ -0,0 +1,29 @@
1
+ // `pple upgrade` — update the CLI itself, which is also how Skills and the
2
+ // golden-path template move forward (ADR-0006). It replaces only an install
3
+ // npm put there, never with sudo: a global folder the Builder can't write to
4
+ // is theirs to fix, not the CLI's to force.
5
+ import { CliError } from "../errors.js";
6
+ import { installGlobally, isNewer, isNpmGlobalInstall, latestVersion, MANUAL_INSTALL, } from "../npm.js";
7
+ import { announceUpdate, manualHint, sayOtherChannel } from "../update.js";
8
+ export async function upgrade(deps) {
9
+ const latest = await latestVersion(deps);
10
+ if (latest === null) {
11
+ throw new CliError("Couldn't reach npm to check for a newer pple.", `Check your internet connection, or upgrade directly:\n\n ${MANUAL_INSTALL}`);
12
+ }
13
+ if (!isNewer(latest, deps.version)) {
14
+ deps.out.say(`You're on the latest pple (${deps.version}).`);
15
+ return;
16
+ }
17
+ if (!(await isNpmGlobalInstall(deps))) {
18
+ announceUpdate(deps, latest);
19
+ deps.out.say();
20
+ sayOtherChannel(deps);
21
+ return;
22
+ }
23
+ deps.out.say(`Updating pple ${deps.version} → ${latest}…`);
24
+ const result = await installGlobally(deps, latest);
25
+ if (result.code !== 0) {
26
+ throw new CliError(`Couldn't update pple to ${latest}.`, manualHint(result));
27
+ }
28
+ deps.out.say(`pple is now ${latest}.`);
29
+ }
@@ -0,0 +1,53 @@
1
+ // Where the Builder's PPLE ID tokens live between commands.
2
+ //
3
+ // One file, owner-readable only, outside any app repo — a token that lands in
4
+ // a working copy is a token that gets pushed to a party-owned repo.
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ const FILE = "credentials.json";
8
+ const OWNER_ONLY_FILE = 0o600;
9
+ const OWNER_ONLY_DIR = 0o700;
10
+ function credentialsPath(deps) {
11
+ return path.join(deps.configDir, FILE);
12
+ }
13
+ export async function loadCredentials(deps) {
14
+ const raw = await fs.readFile(credentialsPath(deps), "utf8").catch(() => null);
15
+ if (raw === null)
16
+ return null;
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(raw);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ if (typeof parsed.accessToken !== "string")
25
+ return null;
26
+ // A file written against another PPLE ID is as good as no file: it would
27
+ // only produce confusing 401s against the current one.
28
+ if (parsed.ssoBase !== deps.config.ssoBase)
29
+ return null;
30
+ return {
31
+ accessToken: parsed.accessToken,
32
+ refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : null,
33
+ expiresAt: typeof parsed.expiresAt === "number" ? parsed.expiresAt : null,
34
+ ssoBase: parsed.ssoBase,
35
+ };
36
+ }
37
+ export async function saveCredentials(deps, credentials) {
38
+ await fs.mkdir(deps.configDir, { recursive: true, mode: OWNER_ONLY_DIR });
39
+ await fs.writeFile(credentialsPath(deps), `${JSON.stringify(credentials, null, 2)}\n`, { mode: OWNER_ONLY_FILE });
40
+ // mkdir/writeFile only apply the mode when they create; an existing file
41
+ // keeps whatever it had, so tighten it explicitly.
42
+ await fs.chmod(credentialsPath(deps), OWNER_ONLY_FILE);
43
+ }
44
+ /** Returns whether there was anything to forget. */
45
+ export async function clearCredentials(deps) {
46
+ try {
47
+ await fs.unlink(credentialsPath(deps));
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }