@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/deps.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
export const DEFAULT_CONFIG = {
|
|
5
|
+
apiBase: "https://api.ppleth.ai",
|
|
6
|
+
ssoBase: "https://sso.peoplesparty.or.th",
|
|
7
|
+
clientId: "382216793067947795",
|
|
8
|
+
repoBase: "https://github.com",
|
|
9
|
+
registryBase: "https://registry.npmjs.org",
|
|
10
|
+
};
|
|
11
|
+
export function platformConfig(env) {
|
|
12
|
+
return {
|
|
13
|
+
apiBase: env.PPLE_API_BASE ?? DEFAULT_CONFIG.apiBase,
|
|
14
|
+
ssoBase: env.PPLE_SSO_BASE ?? DEFAULT_CONFIG.ssoBase,
|
|
15
|
+
clientId: env.PPLE_CLIENT_ID ?? DEFAULT_CONFIG.clientId,
|
|
16
|
+
repoBase: env.PPLE_REPO_BASE ?? DEFAULT_CONFIG.repoBase,
|
|
17
|
+
registryBase: env.PPLE_REGISTRY_BASE ?? DEFAULT_CONFIG.registryBase,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** XDG-style config home, so credentials never land in the app's repo. */
|
|
21
|
+
export function configDir(env, homeDir) {
|
|
22
|
+
const base = env.XDG_CONFIG_HOME?.trim();
|
|
23
|
+
return path.join(base && base !== "" ? base : path.join(homeDir, ".config"), "pple");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Whether the CLI may check npm for a newer version. `PPLE_NO_UPDATE_CHECK`
|
|
27
|
+
* is the scripted-environment escape hatch; a local config file is the other
|
|
28
|
+
* half of the opt-out, and lives with the check itself.
|
|
29
|
+
*/
|
|
30
|
+
export function updateChecksAllowed(env) {
|
|
31
|
+
const raw = env.PPLE_NO_UPDATE_CHECK?.trim().toLowerCase();
|
|
32
|
+
return !raw || raw === "0" || raw === "false";
|
|
33
|
+
}
|
|
34
|
+
/** The CLI's own package directory — one above `src/` in a checkout, `dist/` once built. */
|
|
35
|
+
export function packageRoot() {
|
|
36
|
+
return path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The version this CLI was published as, read from its own `package.json`.
|
|
40
|
+
* A checkout mid-build can be missing or unreadable — an unknown version
|
|
41
|
+
* simply means upgrade treats anything on npm as newer.
|
|
42
|
+
*/
|
|
43
|
+
export function packageVersion() {
|
|
44
|
+
try {
|
|
45
|
+
const manifest = readFileSync(path.join(packageRoot(), "package.json"), "utf8");
|
|
46
|
+
return String(JSON.parse(manifest).version ?? "0.0.0");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return "0.0.0";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The template ships inside the published package (`npm run build:template`
|
|
54
|
+
* copies it there); running from a checkout, it is the repo's own directory.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveTemplateDir(env) {
|
|
57
|
+
const override = env.PPLE_TEMPLATE_DIR?.trim();
|
|
58
|
+
if (override)
|
|
59
|
+
return override;
|
|
60
|
+
const bundled = path.join(packageRoot(), "templates", "golden-path");
|
|
61
|
+
if (existsSync(bundled))
|
|
62
|
+
return bundled;
|
|
63
|
+
// Running from a checkout of the platform repo, before `build:template`.
|
|
64
|
+
return path.join(path.dirname(packageRoot()), "template");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Platform Skills ship inside the published package (`npm run build:skills`
|
|
68
|
+
* copies them there); running from a checkout, they are the repo's own
|
|
69
|
+
* `skills/pple-platform/`.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveSkillsDir(env) {
|
|
72
|
+
const override = env.PPLE_SKILLS_DIR?.trim();
|
|
73
|
+
if (override)
|
|
74
|
+
return override;
|
|
75
|
+
const bundled = path.join(packageRoot(), "skills", "pple-platform");
|
|
76
|
+
if (existsSync(bundled))
|
|
77
|
+
return bundled;
|
|
78
|
+
return path.join(path.dirname(packageRoot()), "skills", "pple-platform");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The helper GIT_ASKPASS points at, which feeds git the app's repo token.
|
|
82
|
+
* git execs it directly rather than through a shell, so it ships with its
|
|
83
|
+
* executable bit set and finds node from its own shebang.
|
|
84
|
+
*/
|
|
85
|
+
export function askpassHelper() {
|
|
86
|
+
return path.join(packageRoot(), "askpass", "pple-askpass.mjs");
|
|
87
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// OAuth 2.0 device authorization grant (RFC 8628) against PPLE ID.
|
|
2
|
+
//
|
|
3
|
+
// A terminal cannot receive a redirect, so the CLI asks PPLE ID for a code,
|
|
4
|
+
// the Builder approves it in a browser (on any device), and the CLI polls
|
|
5
|
+
// until the token is issued. Nothing sensitive is ever typed into the
|
|
6
|
+
// terminal, and the CLI never handles the Builder's password.
|
|
7
|
+
import { CliError } from "./errors.js";
|
|
8
|
+
/** Zitadel's OAuth endpoints; the userinfo path the Provisioner uses too. */
|
|
9
|
+
const DEVICE_AUTHORIZATION_PATH = "/oauth/v2/device_authorization";
|
|
10
|
+
const TOKEN_PATH = "/oauth/v2/token";
|
|
11
|
+
const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
12
|
+
/** `offline_access` is what makes a refresh token — and a durable login. */
|
|
13
|
+
const SCOPES = "openid profile offline_access";
|
|
14
|
+
function form(fields) {
|
|
15
|
+
return {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
18
|
+
body: new URLSearchParams(fields).toString(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const UNREACHABLE = new CliError("Couldn't reach PPLE ID to sign you in.", "Check your internet connection and try `pple auth login` again.");
|
|
22
|
+
export async function requestDeviceCode(deps) {
|
|
23
|
+
const { ssoBase, clientId } = deps.config;
|
|
24
|
+
const res = await deps
|
|
25
|
+
.fetch(`${ssoBase}${DEVICE_AUTHORIZATION_PATH}`, form({ client_id: clientId, scope: SCOPES }))
|
|
26
|
+
.catch(() => null);
|
|
27
|
+
if (!res || !res.ok)
|
|
28
|
+
throw UNREACHABLE;
|
|
29
|
+
const body = (await res.json().catch(() => ({})));
|
|
30
|
+
if (!body.device_code || !body.user_code || !body.verification_uri) {
|
|
31
|
+
throw UNREACHABLE;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
deviceCode: body.device_code,
|
|
35
|
+
userCode: body.user_code,
|
|
36
|
+
verificationUri: body.verification_uri,
|
|
37
|
+
verificationUriComplete: body.verification_uri_complete ?? null,
|
|
38
|
+
expiresInMs: (body.expires_in ?? 600) * 1000,
|
|
39
|
+
// RFC 8628 says 5 seconds when the server doesn't specify.
|
|
40
|
+
intervalMs: (body.interval ?? 5) * 1000,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Poll until the Builder approves in the browser. Backs off when told to
|
|
45
|
+
* (`slow_down`), and gives up when the code expires rather than looping
|
|
46
|
+
* forever in front of someone who walked away.
|
|
47
|
+
*/
|
|
48
|
+
export async function awaitToken(deps, device) {
|
|
49
|
+
const { ssoBase, clientId } = deps.config;
|
|
50
|
+
const deadline = deps.now() + device.expiresInMs;
|
|
51
|
+
let intervalMs = device.intervalMs;
|
|
52
|
+
while (deps.now() < deadline) {
|
|
53
|
+
await deps.sleep(intervalMs);
|
|
54
|
+
const res = await deps
|
|
55
|
+
.fetch(`${ssoBase}${TOKEN_PATH}`, form({
|
|
56
|
+
client_id: clientId,
|
|
57
|
+
grant_type: DEVICE_CODE_GRANT,
|
|
58
|
+
device_code: device.deviceCode,
|
|
59
|
+
}))
|
|
60
|
+
.catch(() => null);
|
|
61
|
+
if (!res)
|
|
62
|
+
throw UNREACHABLE;
|
|
63
|
+
const body = (await res.json().catch(() => ({})));
|
|
64
|
+
if (res.ok && body.access_token)
|
|
65
|
+
return toTokenSet(deps, body);
|
|
66
|
+
switch (body.error) {
|
|
67
|
+
case "authorization_pending":
|
|
68
|
+
break;
|
|
69
|
+
case "slow_down":
|
|
70
|
+
intervalMs += 5000;
|
|
71
|
+
break;
|
|
72
|
+
case "access_denied":
|
|
73
|
+
throw new CliError("Sign-in was declined in the browser.", "Run `pple auth login` again if that wasn't you.");
|
|
74
|
+
case "expired_token":
|
|
75
|
+
throw expiredCode();
|
|
76
|
+
default:
|
|
77
|
+
throw new CliError("PPLE ID refused the sign-in.", body.error_description ?? "Try `pple auth login` again.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw expiredCode();
|
|
81
|
+
}
|
|
82
|
+
function expiredCode() {
|
|
83
|
+
return new CliError("The sign-in code expired before it was approved.", "Run `pple auth login` again — the browser step has to finish within a few minutes.");
|
|
84
|
+
}
|
|
85
|
+
/** Trade a refresh token for a fresh access token; null if it no longer works. */
|
|
86
|
+
export async function refresh(deps, refreshToken) {
|
|
87
|
+
const { ssoBase, clientId } = deps.config;
|
|
88
|
+
const res = await deps
|
|
89
|
+
.fetch(`${ssoBase}${TOKEN_PATH}`, form({
|
|
90
|
+
client_id: clientId,
|
|
91
|
+
grant_type: "refresh_token",
|
|
92
|
+
refresh_token: refreshToken,
|
|
93
|
+
scope: SCOPES,
|
|
94
|
+
}))
|
|
95
|
+
.catch(() => null);
|
|
96
|
+
if (!res || !res.ok)
|
|
97
|
+
return null;
|
|
98
|
+
const body = (await res.json().catch(() => ({})));
|
|
99
|
+
if (!body.access_token)
|
|
100
|
+
return null;
|
|
101
|
+
return {
|
|
102
|
+
...toTokenSet(deps, body),
|
|
103
|
+
// Rotation is optional: keep the current token when none comes back.
|
|
104
|
+
refreshToken: body.refresh_token ?? refreshToken,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function toTokenSet(deps, body) {
|
|
108
|
+
return {
|
|
109
|
+
accessToken: body.access_token,
|
|
110
|
+
refreshToken: body.refresh_token ?? null,
|
|
111
|
+
expiresAt: typeof body.expires_in === "number"
|
|
112
|
+
? deps.now() + body.expires_in * 1000
|
|
113
|
+
: null,
|
|
114
|
+
};
|
|
115
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every failure a Builder can see is a CliError: a sentence about their app,
|
|
3
|
+
* optionally followed by the one thing to do next. Anything else escaping to
|
|
4
|
+
* the top level is a bug in the CLI and is reported as such.
|
|
5
|
+
*/
|
|
6
|
+
export class CliError extends Error {
|
|
7
|
+
hint;
|
|
8
|
+
constructor(message, hint) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.hint = hint;
|
|
11
|
+
this.name = "CliError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Both ends of the sign-in — the stored token, and what the platform says. */
|
|
15
|
+
export const SIGNED_OUT = new CliError("You're not signed in.", "Run `pple auth login` first.");
|
|
16
|
+
export const SIGN_IN_EXPIRED = new CliError("Your sign-in has expired.", "Run `pple auth login` to sign in again.");
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Git, masked.
|
|
2
|
+
//
|
|
3
|
+
// Builders are not expected to know git, so nothing above this module names
|
|
4
|
+
// it: `pple init` "sets up the app's code", `pple deploy` "sends changes".
|
|
5
|
+
// Every failure that a Builder could plausibly hit is translated here; the
|
|
6
|
+
// raw git output only survives in the hint, where it helps whoever is asked
|
|
7
|
+
// for help next.
|
|
8
|
+
import { CliError } from "./errors.js";
|
|
9
|
+
/** Builder Apps deploy from main; the workflow builds nothing else. */
|
|
10
|
+
export const MAIN_BRANCH = "main";
|
|
11
|
+
/** The username half of the Basic credential; GitHub ignores its value. */
|
|
12
|
+
const TOKEN_USERNAME = "x-access-token";
|
|
13
|
+
/**
|
|
14
|
+
* Every git invocation runs with the app's token in its environment and none
|
|
15
|
+
* of the machine's own credentials in play:
|
|
16
|
+
*
|
|
17
|
+
* - `GIT_ASKPASS` is how the token reaches git. Passing it in the URL would
|
|
18
|
+
* put it in argv, which `ps` shows to every user on the machine, and in
|
|
19
|
+
* `.git/config` once git wrote the remote down.
|
|
20
|
+
* - `credential.helper=` empties the helper list, so a keychain entry left
|
|
21
|
+
* over from someone's own GitHub account can't answer ahead of the platform.
|
|
22
|
+
* - `GIT_TERMINAL_PROMPT=0` stops git asking a Builder for a password it is
|
|
23
|
+
* the platform's job to supply.
|
|
24
|
+
*/
|
|
25
|
+
async function git(deps, dir, args, access) {
|
|
26
|
+
return deps.run("git", access ? ["-c", "credential.helper=", ...args] : args, {
|
|
27
|
+
cwd: dir,
|
|
28
|
+
env: {
|
|
29
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
30
|
+
...(access
|
|
31
|
+
? {
|
|
32
|
+
GIT_ASKPASS: deps.gitAskpass,
|
|
33
|
+
PPLE_GIT_USER: TOKEN_USERNAME,
|
|
34
|
+
PPLE_GIT_TOKEN: access.token,
|
|
35
|
+
}
|
|
36
|
+
: {}),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Author every commit as the Builder, since the pusher is the platform's bot. */
|
|
41
|
+
function authorArgs(access) {
|
|
42
|
+
return [
|
|
43
|
+
"-c",
|
|
44
|
+
`user.name=${access.author.name}`,
|
|
45
|
+
"-c",
|
|
46
|
+
`user.email=${access.author.email}`,
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* git failed because it was not allowed near the app's repo. The token is the
|
|
51
|
+
* platform's to mint, so this is the platform having declined — not something
|
|
52
|
+
* a Builder fixes with a credential of their own.
|
|
53
|
+
*/
|
|
54
|
+
function isAccessFailure(result) {
|
|
55
|
+
return /authentication|could not read|403|denied|not found/i.test(`${result.stdout}\n${result.stderr}`);
|
|
56
|
+
}
|
|
57
|
+
function noAccess(action, command) {
|
|
58
|
+
return new CliError(`The platform wouldn't let this computer ${action}.`, `Check that the app is still yours at https://console.ppleth.ai, then run \`${command}\` again.`);
|
|
59
|
+
}
|
|
60
|
+
function unreadableFolder(result) {
|
|
61
|
+
return new CliError("Couldn't read your app's folder.", result.stderr.trim());
|
|
62
|
+
}
|
|
63
|
+
async function gitOrThrow(deps, dir, args, onFailure, access) {
|
|
64
|
+
const result = await git(deps, dir, args, access).catch(() => {
|
|
65
|
+
throw new CliError("This computer doesn't have the tools the PPLE CLI needs.", "Install git (https://git-scm.com/downloads) and run the command again.");
|
|
66
|
+
});
|
|
67
|
+
if (result.code !== 0)
|
|
68
|
+
throw onFailure(result);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
/** Fetch an app's code into `targetDir`, which must not exist yet. */
|
|
72
|
+
export async function cloneApp(deps, repoUrl, targetDir, access) {
|
|
73
|
+
await gitOrThrow(deps, deps.cwd, ["clone", repoUrl, targetDir], (result) => isAccessFailure(result)
|
|
74
|
+
? noAccess("reach your app's code", "pple init")
|
|
75
|
+
: new CliError("Couldn't fetch your app's code.", result.stderr.trim()), access);
|
|
76
|
+
}
|
|
77
|
+
/** True when the working copy holds changes that haven't been recorded yet. */
|
|
78
|
+
export async function hasLocalChanges(deps, dir) {
|
|
79
|
+
const result = await gitOrThrow(deps, dir, ["status", "--porcelain"], unreadableFolder);
|
|
80
|
+
return result.stdout.trim() !== "";
|
|
81
|
+
}
|
|
82
|
+
/** Record every change in the folder as one entry in the app's history. */
|
|
83
|
+
export async function recordChanges(deps, dir, message, access) {
|
|
84
|
+
await gitOrThrow(deps, dir, ["add", "-A"], unreadableFolder);
|
|
85
|
+
await gitOrThrow(deps, dir, [...authorArgs(access), "commit", "-m", message], (r) => new CliError("Couldn't save your changes.", r.stderr.trim()));
|
|
86
|
+
}
|
|
87
|
+
export async function headCommit(deps, dir) {
|
|
88
|
+
const result = await gitOrThrow(deps, dir, ["rev-parse", "HEAD"], (r) => new CliError("Couldn't read your app's history.", r.stderr.trim()));
|
|
89
|
+
return result.stdout.trim();
|
|
90
|
+
}
|
|
91
|
+
/** True when the local branch already matches what the platform has. */
|
|
92
|
+
export async function isUpToDateWithRemote(deps, dir, access) {
|
|
93
|
+
await git(deps, dir, ["fetch", "origin", MAIN_BRANCH], access);
|
|
94
|
+
const local = await git(deps, dir, ["rev-parse", "HEAD"]);
|
|
95
|
+
const remote = await git(deps, dir, ["rev-parse", `origin/${MAIN_BRANCH}`]);
|
|
96
|
+
if (local.code !== 0 || remote.code !== 0)
|
|
97
|
+
return false;
|
|
98
|
+
return local.stdout.trim() === remote.stdout.trim();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Send the recorded changes to the platform. When the app has changes this
|
|
102
|
+
* copy hasn't seen — a Collaborator deployed meanwhile — they are merged in
|
|
103
|
+
* first, and only a genuine conflict reaches the Builder.
|
|
104
|
+
*/
|
|
105
|
+
export async function sendChanges(deps, dir, access) {
|
|
106
|
+
const push = ["push", "origin", `HEAD:${MAIN_BRANCH}`];
|
|
107
|
+
const pushed = await git(deps, dir, push, access);
|
|
108
|
+
if (pushed.code === 0)
|
|
109
|
+
return;
|
|
110
|
+
const output = `${pushed.stdout}\n${pushed.stderr}`;
|
|
111
|
+
if (!/rejected|non-fast-forward|fetch first/i.test(output)) {
|
|
112
|
+
if (isAccessFailure(pushed)) {
|
|
113
|
+
throw noAccess("send changes to your app", "pple deploy");
|
|
114
|
+
}
|
|
115
|
+
throw new CliError("Couldn't send your changes.", pushed.stderr.trim());
|
|
116
|
+
}
|
|
117
|
+
deps.out.say("Someone else changed this app — bringing their work in first…");
|
|
118
|
+
const rebase = await git(deps, dir, [...authorArgs(access), "pull", "--rebase", "origin", MAIN_BRANCH], access);
|
|
119
|
+
if (rebase.code !== 0) {
|
|
120
|
+
await git(deps, dir, ["rebase", "--abort"]);
|
|
121
|
+
throw new CliError("Your changes clash with changes someone else already deployed.", "Ask your AI coding agent to reconcile the two versions, then run `pple deploy` again.");
|
|
122
|
+
}
|
|
123
|
+
const retried = await git(deps, dir, push, access);
|
|
124
|
+
if (retried.code !== 0) {
|
|
125
|
+
throw new CliError("Couldn't send your changes.", retried.stderr.trim());
|
|
126
|
+
}
|
|
127
|
+
}
|
package/dist/link.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The link between a folder on a Builder's computer and an app on the
|
|
2
|
+
// platform. `pple init` writes it, `pple deploy` reads it — which is why
|
|
3
|
+
// deploy needs no arguments and cannot ship the wrong app.
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { CliError } from "./errors.js";
|
|
7
|
+
export const LINK_DIR = ".pple";
|
|
8
|
+
export const LINK_FILE = "app.json";
|
|
9
|
+
export async function writeLink(dir, link) {
|
|
10
|
+
await fs.mkdir(path.join(dir, LINK_DIR), { recursive: true });
|
|
11
|
+
await fs.writeFile(path.join(dir, LINK_DIR, LINK_FILE), `${JSON.stringify(link, null, 2)}\n`);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Find the app this command is about by walking up from the current folder,
|
|
15
|
+
* so `pple deploy` works from anywhere inside the app.
|
|
16
|
+
*/
|
|
17
|
+
export async function findLink(deps) {
|
|
18
|
+
let dir = path.resolve(deps.cwd);
|
|
19
|
+
for (;;) {
|
|
20
|
+
const candidate = path.join(dir, LINK_DIR, LINK_FILE);
|
|
21
|
+
const raw = await fs.readFile(candidate, "utf8").catch(() => null);
|
|
22
|
+
if (raw !== null) {
|
|
23
|
+
const link = parseLink(raw);
|
|
24
|
+
if (link)
|
|
25
|
+
return { ...link, dir };
|
|
26
|
+
throw new CliError("This app's platform link is damaged.", "Run `pple init <app>` in a fresh folder to set it up again.");
|
|
27
|
+
}
|
|
28
|
+
const parent = path.dirname(dir);
|
|
29
|
+
if (parent === dir)
|
|
30
|
+
break;
|
|
31
|
+
dir = parent;
|
|
32
|
+
}
|
|
33
|
+
throw new CliError("This folder isn't a PPLE app.", "Move into your app's folder, or run `pple init <app>` to set one up.");
|
|
34
|
+
}
|
|
35
|
+
function parseLink(raw) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(raw);
|
|
38
|
+
if (typeof parsed.slug !== "string" || parsed.slug === "")
|
|
39
|
+
return null;
|
|
40
|
+
return {
|
|
41
|
+
slug: parsed.slug,
|
|
42
|
+
apiBase: typeof parsed.apiBase === "string" ? parsed.apiBase : "",
|
|
43
|
+
url: typeof parsed.url === "string" ? parsed.url : "",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The Node implementations behind `Deps` — the only part of the CLI that
|
|
2
|
+
// touches the real filesystem, the real clock, and real subprocesses.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import readline from "node:readline/promises";
|
|
5
|
+
import { askpassHelper, configDir, packageRoot, packageVersion, platformConfig, resolveSkillsDir, resolveTemplateDir, updateChecksAllowed, } from "./deps.js";
|
|
6
|
+
export function spawnRun(command, args, options) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn(command, args, {
|
|
9
|
+
cwd: options.cwd,
|
|
10
|
+
env: { ...process.env, ...options.env },
|
|
11
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
12
|
+
});
|
|
13
|
+
let stdout = "";
|
|
14
|
+
let stderr = "";
|
|
15
|
+
child.stdout.on("data", (chunk) => (stdout += String(chunk)));
|
|
16
|
+
child.stderr.on("data", (chunk) => (stderr += String(chunk)));
|
|
17
|
+
child.on("error", reject);
|
|
18
|
+
child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr }));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/** Best-effort browser launch: login still works from the printed URL. */
|
|
22
|
+
async function openBrowser(url) {
|
|
23
|
+
const command = process.platform === "darwin"
|
|
24
|
+
? "open"
|
|
25
|
+
: process.platform === "win32"
|
|
26
|
+
? "start"
|
|
27
|
+
: "xdg-open";
|
|
28
|
+
await spawnRun(command, [url], { cwd: process.cwd() }).catch(() => undefined);
|
|
29
|
+
}
|
|
30
|
+
/** A yes/no question at the terminal. Anything but yes means no. */
|
|
31
|
+
async function confirm(question) {
|
|
32
|
+
const rl = readline.createInterface({
|
|
33
|
+
input: process.stdin,
|
|
34
|
+
output: process.stdout,
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
38
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
rl.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Run the Builder's command line again on the `pple` that is installed now.
|
|
46
|
+
* The child inherits the terminal, so a re-run deploy still prompts and
|
|
47
|
+
* prints as if the Builder had typed it themselves.
|
|
48
|
+
*
|
|
49
|
+
* It re-runs the same script path deliberately: a global install replaces
|
|
50
|
+
* what that path resolves to, so the old path is exactly how to reach the new
|
|
51
|
+
* version — and the path this process was started from is the one the Builder
|
|
52
|
+
* chose, whatever else is on PATH.
|
|
53
|
+
*/
|
|
54
|
+
function reexec(argv) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const child = spawn(process.execPath, [process.argv[1], ...argv], {
|
|
57
|
+
stdio: "inherit",
|
|
58
|
+
});
|
|
59
|
+
child.on("error", reject);
|
|
60
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export function nodeDeps(out, homeDir) {
|
|
64
|
+
return {
|
|
65
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
66
|
+
now: () => Date.now(),
|
|
67
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
68
|
+
cwd: process.cwd(),
|
|
69
|
+
configDir: configDir(process.env, homeDir),
|
|
70
|
+
out,
|
|
71
|
+
openBrowser,
|
|
72
|
+
run: spawnRun,
|
|
73
|
+
templateDir: resolveTemplateDir(process.env),
|
|
74
|
+
skillsDir: resolveSkillsDir(process.env),
|
|
75
|
+
packageDir: packageRoot(),
|
|
76
|
+
version: packageVersion(),
|
|
77
|
+
interactive: process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
78
|
+
confirm,
|
|
79
|
+
reexec,
|
|
80
|
+
updateChecks: updateChecksAllowed(process.env),
|
|
81
|
+
gitAskpass: askpassHelper(),
|
|
82
|
+
config: platformConfig(process.env),
|
|
83
|
+
};
|
|
84
|
+
}
|
package/dist/npm.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// npm, and the only place that names it: what the registry says the latest
|
|
2
|
+
// CLI is, and whether this copy is one npm's global root holds. Everything
|
|
3
|
+
// here answers a question — nothing here talks to the Builder.
|
|
4
|
+
import { realpath } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
export const PACKAGE = "@pplethai/cli";
|
|
7
|
+
/** The line a Builder can always fall back to, whatever went wrong. */
|
|
8
|
+
export const MANUAL_INSTALL = `npm install -g ${PACKAGE}`;
|
|
9
|
+
/** What npm's `latest` dist-tag points at, or null when npm can't be reached. */
|
|
10
|
+
export async function latestVersion(deps) {
|
|
11
|
+
const response = await deps
|
|
12
|
+
.fetch(`${deps.config.registryBase}/${PACKAGE}/latest`, {
|
|
13
|
+
headers: { accept: "application/json" },
|
|
14
|
+
})
|
|
15
|
+
.catch(() => null);
|
|
16
|
+
if (!response?.ok)
|
|
17
|
+
return null;
|
|
18
|
+
const body = (await response.json().catch(() => null));
|
|
19
|
+
return typeof body?.version === "string" && body.version !== ""
|
|
20
|
+
? body.version
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Whether the running CLI is the copy npm's global root holds. Anything else
|
|
25
|
+
* — a Homebrew wrapper, a checkout, another package manager — is a channel
|
|
26
|
+
* the CLI doesn't own and must not install over.
|
|
27
|
+
*/
|
|
28
|
+
export async function isNpmGlobalInstall(deps) {
|
|
29
|
+
const result = await deps
|
|
30
|
+
.run("npm", ["root", "-g"], { cwd: deps.cwd })
|
|
31
|
+
.catch(() => null);
|
|
32
|
+
if (!result || result.code !== 0)
|
|
33
|
+
return false;
|
|
34
|
+
const globalRoot = result.stdout.trim();
|
|
35
|
+
if (globalRoot === "")
|
|
36
|
+
return false;
|
|
37
|
+
return contains(globalRoot, deps.packageDir);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Install a specific version globally. Never with sudo — see ADR-0007.
|
|
41
|
+
*
|
|
42
|
+
* An npm that can't be started at all (not on PATH, not executable) comes
|
|
43
|
+
* back as a failed run rather than a rejection: to a Builder that is the same
|
|
44
|
+
* event as an install that failed, and it deserves the same explanation
|
|
45
|
+
* instead of a stack trace.
|
|
46
|
+
*/
|
|
47
|
+
export function installGlobally(deps, version) {
|
|
48
|
+
return deps
|
|
49
|
+
.run("npm", ["install", "-g", `${PACKAGE}@${version}`], { cwd: deps.cwd })
|
|
50
|
+
.catch((e) => ({
|
|
51
|
+
code: 1,
|
|
52
|
+
stdout: "",
|
|
53
|
+
stderr: e instanceof Error ? e.message : String(e),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Whether `child` sits inside `parent`. Both sides are resolved through
|
|
58
|
+
* symlinks first: a Node install managed by nvm or Volta reaches the CLI by a
|
|
59
|
+
* symlinked path, and comparing the two spellings would call a perfectly
|
|
60
|
+
* ordinary npm-global install "some other channel".
|
|
61
|
+
*/
|
|
62
|
+
async function contains(parent, child) {
|
|
63
|
+
const [from, to] = await Promise.all([resolve(parent), resolve(child)]);
|
|
64
|
+
const relative = path.relative(from, to);
|
|
65
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
66
|
+
}
|
|
67
|
+
/** Real path where one exists; the literal path where it doesn't. */
|
|
68
|
+
async function resolve(dir) {
|
|
69
|
+
return realpath(dir).catch(() => path.resolve(dir));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Compare two npm versions. Prereleases sort below their own release, dotted
|
|
73
|
+
* prerelease segments compare numerically where both sides are numbers, and
|
|
74
|
+
* anything unparseable counts as older so a broken version never wins.
|
|
75
|
+
*/
|
|
76
|
+
export function isNewer(candidate, current) {
|
|
77
|
+
const a = parseVersion(candidate);
|
|
78
|
+
const b = parseVersion(current);
|
|
79
|
+
if (!a)
|
|
80
|
+
return false;
|
|
81
|
+
if (!b)
|
|
82
|
+
return true;
|
|
83
|
+
for (let i = 0; i < 3; i += 1) {
|
|
84
|
+
if (a.parts[i] !== b.parts[i])
|
|
85
|
+
return a.parts[i] > b.parts[i];
|
|
86
|
+
}
|
|
87
|
+
return comparePrerelease(a.prerelease, b.prerelease) > 0;
|
|
88
|
+
}
|
|
89
|
+
function parseVersion(version) {
|
|
90
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim());
|
|
91
|
+
if (!match)
|
|
92
|
+
return null;
|
|
93
|
+
return {
|
|
94
|
+
parts: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
95
|
+
prerelease: match[4] ? match[4].split(".") : [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Semver's prerelease rules: a release outranks any prerelease of itself. */
|
|
99
|
+
function comparePrerelease(a, b) {
|
|
100
|
+
if (a.length === 0 && b.length === 0)
|
|
101
|
+
return 0;
|
|
102
|
+
if (a.length === 0)
|
|
103
|
+
return 1;
|
|
104
|
+
if (b.length === 0)
|
|
105
|
+
return -1;
|
|
106
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
107
|
+
const left = a[i];
|
|
108
|
+
const right = b[i];
|
|
109
|
+
if (left === undefined)
|
|
110
|
+
return -1;
|
|
111
|
+
if (right === undefined)
|
|
112
|
+
return 1;
|
|
113
|
+
const numeric = /^\d+$/.test(left) && /^\d+$/.test(right);
|
|
114
|
+
if (left === right)
|
|
115
|
+
continue;
|
|
116
|
+
return numeric ? (Number(left) > Number(right) ? 1 : -1) : left > right ? 1 : -1;
|
|
117
|
+
}
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
/** The most useful line npm printed, without the noise around it. */
|
|
121
|
+
export function npmMessage(result) {
|
|
122
|
+
const lines = `${result.stderr}\n${result.stdout}`
|
|
123
|
+
.split("\n")
|
|
124
|
+
.map((line) => line.replace(/^npm (ERR!|error|warn)\s*/i, "").trim())
|
|
125
|
+
.filter((line) => line !== "");
|
|
126
|
+
return lines.slice(0, 3).join("\n") || "it stopped without saying why.";
|
|
127
|
+
}
|
package/dist/scaffold.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Copying the golden-path template into an app's folder.
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CliError } from "./errors.js";
|
|
5
|
+
import { MANUAL_INSTALL } from "./npm.js";
|
|
6
|
+
/** Build output and local secrets are never part of a fresh app. */
|
|
7
|
+
const SKIP = new Set([
|
|
8
|
+
".git",
|
|
9
|
+
"node_modules",
|
|
10
|
+
"dist",
|
|
11
|
+
".wrangler",
|
|
12
|
+
".dev.vars",
|
|
13
|
+
".env",
|
|
14
|
+
]);
|
|
15
|
+
/**
|
|
16
|
+
* npm strips a file literally named `.gitignore` from published packages, so
|
|
17
|
+
* the bundled template carries it under a safe name and it is restored here.
|
|
18
|
+
*/
|
|
19
|
+
const RESTORED_NAMES = new Map([["gitignore", ".gitignore"]]);
|
|
20
|
+
/** The name the template uses for itself everywhere it names something. */
|
|
21
|
+
const TEMPLATE_SLUG = "pple-app";
|
|
22
|
+
/** Files whose contents name the app: package name, D1 database, R2 bucket. */
|
|
23
|
+
const PERSONALIZED = new Set(["package.json", "package-lock.json", "wrangler.jsonc"]);
|
|
24
|
+
export async function scaffold(templateDir, targetDir, slug) {
|
|
25
|
+
const entries = await fs.readdir(templateDir).catch(() => null);
|
|
26
|
+
if (entries === null) {
|
|
27
|
+
throw new CliError("The PPLE app template is missing from this installation of the CLI.", `Reinstall the CLI (\`${MANUAL_INSTALL}\`) and try again.`);
|
|
28
|
+
}
|
|
29
|
+
const written = [];
|
|
30
|
+
await copyDir({ from: templateDir, to: targetDir, prefix: "", slug, written });
|
|
31
|
+
return written.sort();
|
|
32
|
+
}
|
|
33
|
+
async function copyDir(step) {
|
|
34
|
+
for (const entry of await fs.readdir(step.from, { withFileTypes: true })) {
|
|
35
|
+
if (SKIP.has(entry.name))
|
|
36
|
+
continue;
|
|
37
|
+
const name = RESTORED_NAMES.get(entry.name) ?? entry.name;
|
|
38
|
+
const source = path.join(step.from, entry.name);
|
|
39
|
+
const destination = path.join(step.to, name);
|
|
40
|
+
const relative = step.prefix === "" ? name : `${step.prefix}/${name}`;
|
|
41
|
+
if (entry.isDirectory()) {
|
|
42
|
+
await fs.mkdir(destination, { recursive: true });
|
|
43
|
+
await copyDir({ ...step, from: source, to: destination, prefix: relative });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (PERSONALIZED.has(name)) {
|
|
47
|
+
const contents = await fs.readFile(source, "utf8");
|
|
48
|
+
await fs.writeFile(destination, replaceAll(contents, TEMPLATE_SLUG, step.slug));
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
await fs.copyFile(source, destination);
|
|
52
|
+
}
|
|
53
|
+
step.written.push(relative);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function replaceAll(text, from, to) {
|
|
57
|
+
return text.split(from).join(to);
|
|
58
|
+
}
|