percher 0.2.10 → 0.3.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 (54) hide show
  1. package/dist/bin/percher.js +6803 -2955
  2. package/dist/package.json +45 -0
  3. package/dist/src/ci-env.d.ts +6 -0
  4. package/dist/src/ci-env.d.ts.map +1 -0
  5. package/dist/src/ci-env.js +35 -0
  6. package/dist/src/ci-env.js.map +1 -0
  7. package/dist/src/cli-utils.d.ts +44 -0
  8. package/dist/src/cli-utils.d.ts.map +1 -0
  9. package/dist/src/cli-utils.js +129 -0
  10. package/dist/src/cli-utils.js.map +1 -0
  11. package/dist/src/commands/account.d.ts +4 -0
  12. package/dist/src/commands/account.d.ts.map +1 -0
  13. package/dist/src/commands/account.js +280 -0
  14. package/dist/src/commands/account.js.map +1 -0
  15. package/dist/src/commands/apps.d.ts +13 -0
  16. package/dist/src/commands/apps.d.ts.map +1 -0
  17. package/dist/src/commands/apps.js +467 -0
  18. package/dist/src/commands/apps.js.map +1 -0
  19. package/dist/src/commands/auth.d.ts +4 -0
  20. package/dist/src/commands/auth.d.ts.map +1 -0
  21. package/dist/src/commands/auth.js +21 -0
  22. package/dist/src/commands/auth.js.map +1 -0
  23. package/dist/src/commands/data.d.ts +4 -0
  24. package/dist/src/commands/data.d.ts.map +1 -0
  25. package/dist/src/commands/data.js +148 -0
  26. package/dist/src/commands/data.js.map +1 -0
  27. package/dist/src/commands/deploys.d.ts +4 -0
  28. package/dist/src/commands/deploys.d.ts.map +1 -0
  29. package/dist/src/commands/deploys.js +197 -0
  30. package/dist/src/commands/deploys.js.map +1 -0
  31. package/dist/src/commands/env.d.ts +4 -0
  32. package/dist/src/commands/env.d.ts.map +1 -0
  33. package/dist/src/commands/env.js +66 -0
  34. package/dist/src/commands/env.js.map +1 -0
  35. package/dist/src/commands/github.d.ts +4 -0
  36. package/dist/src/commands/github.d.ts.map +1 -0
  37. package/dist/src/commands/github.js +93 -0
  38. package/dist/src/commands/github.js.map +1 -0
  39. package/dist/src/env-set-input.d.ts +13 -0
  40. package/dist/src/env-set-input.d.ts.map +1 -0
  41. package/dist/src/env-set-input.js +59 -0
  42. package/dist/src/env-set-input.js.map +1 -0
  43. package/dist/src/index.d.ts.map +1 -1
  44. package/dist/src/index.js +17 -761
  45. package/dist/src/index.js.map +1 -1
  46. package/dist/src/prompt-hidden.d.ts +15 -0
  47. package/dist/src/prompt-hidden.d.ts.map +1 -0
  48. package/dist/src/prompt-hidden.js +104 -0
  49. package/dist/src/prompt-hidden.js.map +1 -0
  50. package/dist/src/telemetry.d.ts +1 -1
  51. package/dist/src/telemetry.d.ts.map +1 -1
  52. package/dist/src/telemetry.js +8 -1
  53. package/dist/src/telemetry.js.map +1 -1
  54. package/package.json +7 -6
package/dist/src/index.js CHANGED
@@ -1,770 +1,27 @@
1
1
  import * as core from "@percher/core";
2
2
  import cac from "cac";
3
- import pc from "picocolors";
3
+ import { SilentExitError } from "./cli-utils";
4
+ import { registerAccountCommands } from "./commands/account";
5
+ import { registerAppsCommands } from "./commands/apps";
6
+ import { registerAuthCommands } from "./commands/auth";
7
+ import { registerDataCommands } from "./commands/data";
8
+ import { registerDeploysCommands } from "./commands/deploys";
9
+ import { registerEnvCommands } from "./commands/env";
10
+ import { registerGithubCommands } from "./commands/github";
4
11
  import { createCliContext } from "./context";
5
12
  import { renderError } from "./output";
6
- import { showTelemetryNotice, trackEvent } from "./telemetry";
13
+ import { VERSION, showTelemetryNotice, trackEvent } from "./telemetry";
7
14
  import { checkForUpdates } from "./version-check";
8
- const VERSION = "0.2.10";
9
- /** Thrown after output is already printed, to signal non-zero exit without an error message. */
10
- class SilentExitError {
11
- exitCode;
12
- constructor(exitCode = 1) {
13
- this.exitCode = exitCode;
14
- }
15
- }
16
- function parseKeyValue(pairs) {
17
- const out = {};
18
- for (const p of pairs) {
19
- const idx = p.indexOf("=");
20
- if (idx === -1)
21
- throw new Error(`Expected KEY=VALUE, got "${p}"`);
22
- out[p.slice(0, idx)] = p.slice(idx + 1);
23
- }
24
- return out;
25
- }
26
- async function writeLine(ctx, line) {
27
- const writer = ctx.stdout.getWriter();
28
- try {
29
- await writer.write(new TextEncoder().encode(`${line}\n`));
30
- }
31
- finally {
32
- writer.releaseLock();
33
- }
34
- }
35
- /** ISO timestamp → "YYYY-MM-DD HH:MM:SS" in UTC for stable, sortable output. */
36
- function formatDeployTimestamp(iso) {
37
- const d = new Date(iso);
38
- if (Number.isNaN(d.getTime()))
39
- return iso;
40
- const pad = (n) => n.toString().padStart(2, "0");
41
- return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
42
- }
43
- function formatStatus(status) {
44
- if (status === "live")
45
- return pc.green(status);
46
- if (status === "failed")
47
- return pc.red(status);
48
- return pc.yellow(status);
49
- }
50
- /**
51
- * Pick the first non-flag positional in `argv` after index `from`. Used by
52
- * the manual subcommand dispatch (cac doesn't natively support multi-token
53
- * command names) to find the user's app/id argument regardless of whether
54
- * flags came before or after it. Mirrors cac's own positional-resolution
55
- * logic so the dispatch path stays consistent with the rest of the CLI.
56
- */
57
- function firstNonFlagAfter(argv, from) {
58
- for (let i = from + 1; i < argv.length; i++) {
59
- const t = argv[i];
60
- if (t && !t.startsWith("-"))
61
- return t;
62
- }
63
- return undefined;
64
- }
65
- /**
66
- * Print the result of a superuser rotation. The password is only present
67
- * when the user explicitly opted in with `--reveal`; without it the CLI
68
- * confirms the rotation but doesn't print any credential.
69
- */
70
- async function printSuperuserResetResult(ctx, result) {
71
- await writeLine(ctx, `${pc.green("✓")} ${result.message}`);
72
- if (result.password) {
73
- await writeLine(ctx, "");
74
- await writeLine(ctx, ` ${pc.bold("Email:")} ${result.email}`);
75
- await writeLine(ctx, ` ${pc.bold("Password:")} ${result.password}`);
76
- await writeLine(ctx, "");
77
- await writeLine(ctx, pc.yellow(" ⚠ This password is shown only once. Save it now if you need it."));
78
- }
79
- }
80
- /**
81
- * Shared body of the `deploys inspect [id]` command. Extracted so the
82
- * subcommand-dispatch path in the parent `deploys` action can reuse it
83
- * without duplicating output formatting.
84
- */
85
- async function runDeploysInspect(ctx, input) {
86
- const result = await core.deploysInspect(ctx, input);
87
- const { deploy, buildLog } = result;
88
- await writeLine(ctx, `${pc.bold("Deploy:")} ${deploy.id}`);
89
- await writeLine(ctx, `${pc.bold("Status:")} ${formatStatus(deploy.status)}`);
90
- if (deploy.type) {
91
- await writeLine(ctx, `${pc.bold("Type:")} ${deploy.type}`);
92
- }
93
- await writeLine(ctx, `${pc.bold("Created:")} ${formatDeployTimestamp(deploy.createdAt)}`);
94
- if (deploy.url) {
95
- await writeLine(ctx, `${pc.bold("URL:")} ${deploy.url}`);
96
- }
97
- if (deploy.previewUrl) {
98
- await writeLine(ctx, `${pc.bold("Preview:")} ${deploy.previewUrl}`);
99
- }
100
- if (deploy.errorMessage) {
101
- await writeLine(ctx, "");
102
- await writeLine(ctx, pc.bold(pc.red("Error:")));
103
- for (const line of deploy.errorMessage.split("\n")) {
104
- await writeLine(ctx, ` ${line}`);
105
- }
106
- }
107
- if (buildLog) {
108
- await writeLine(ctx, "");
109
- const lines = buildLog.split("\n");
110
- const tail = lines.slice(-200);
111
- const header = lines.length > tail.length
112
- ? `${pc.bold("Build log")} ${pc.dim(`(last 200 of ${lines.length} lines)`)}`
113
- : pc.bold("Build log");
114
- await writeLine(ctx, header);
115
- for (const line of tail) {
116
- await writeLine(ctx, ` ${line}`);
117
- }
118
- }
119
- else if (deploy.status === "failed" || deploy.status === "live") {
120
- await writeLine(ctx, "");
121
- await writeLine(ctx, pc.dim("(no build log available for this deploy)"));
122
- }
123
- }
124
15
  export async function run(argv, ctxOverride) {
125
16
  const cli = cac("percher");
126
- cli
127
- .command("init", "Generate percher.toml for this project")
128
- .option("--force", "Overwrite existing percher.toml")
129
- .option("--name <name>", "Override detected app name")
130
- .action(async (options) => {
131
- const ctx = ctxOverride ?? (await createCliContext());
132
- const result = await core.init(ctx, { force: options.force, name: options.name });
133
- await writeLine(ctx, `${pc.green("✓")} wrote ${result.path}`);
134
- });
135
- cli
136
- .command("publish", "Publish this project to Percher (recommended deploy command)")
137
- .option("--force", "Skip size warnings")
138
- .option("--preview", "Deploy as preview (does not replace the live version)")
139
- .option("-m, --message <msg>", "Deploy note (visible in deploy history)")
140
- .option("--dry-run", "Show what would be deployed (file count, bytes) without uploading")
141
- .action(async (options) => {
142
- const ctx = ctxOverride ?? (await createCliContext());
143
- const result = await core.publish(ctx, {
144
- force: options.force,
145
- preview: options.preview,
146
- message: options.message,
147
- dryRun: options.dryRun,
148
- });
149
- if (result.status === "dry_run") {
150
- await writeLine(ctx, `Dry run: ${result.fileCount} files, ${(result.bytes / 1024).toFixed(1)} KB`);
151
- return;
152
- }
153
- if (result.status === "needs_login") {
154
- if (result.loginUrl) {
155
- await writeLine(ctx, `${pc.yellow("!")} Not logged in. Authorize here:`);
156
- await writeLine(ctx, ` ${result.loginUrl}`);
157
- }
158
- else {
159
- await writeLine(ctx, `${pc.red("✗")} Not logged in. Run: percher login`);
160
- }
161
- throw new SilentExitError();
162
- }
163
- if (result.status === "failed") {
164
- await writeLine(ctx, `${pc.red("✗")} ${result.error?.title ?? "Deployment failed"}`);
165
- if (result.error?.explanation) {
166
- await writeLine(ctx, ` ${result.error.explanation}`);
167
- }
168
- if (result.error?.rawErrorMessage) {
169
- // The classifier turns "Worker /deployer/run failed (502): upstream
170
- // timeout after 30s" into a friendly explanation; keep the raw
171
- // string visible too because it carries the load-bearing details
172
- // (status code, hostname) support will ask for.
173
- await writeLine(ctx, ` ${pc.dim(`Details: ${result.error.rawErrorMessage}`)}`);
174
- }
175
- if (result.error?.suggestion) {
176
- await writeLine(ctx, ` ${pc.dim(result.error.suggestion)}`);
177
- }
178
- if (result.error?.missingEnvVars && result.error.missingEnvVars.length > 0) {
179
- await writeLine(ctx, "");
180
- await writeLine(ctx, pc.bold("Missing env vars:"));
181
- for (const k of result.error.missingEnvVars) {
182
- await writeLine(ctx, ` ${k}`);
183
- }
184
- await writeLine(ctx, pc.dim(` Set with: percher env set ${result.error.missingEnvVars[0]}=...`));
185
- }
186
- if (result.error?.relevantFiles && result.error.relevantFiles.length > 0) {
187
- await writeLine(ctx, "");
188
- await writeLine(ctx, pc.bold("Check these files:"));
189
- for (const f of result.error.relevantFiles) {
190
- await writeLine(ctx, ` ${f}`);
191
- }
192
- }
193
- if (result.error?.buildLogTail) {
194
- await writeLine(ctx, "");
195
- const lines = result.error.buildLogTail.split("\n");
196
- await writeLine(ctx, `${pc.bold("Build log")} ${pc.dim(`(last ${lines.length} lines)`)}`);
197
- for (const line of lines) {
198
- await writeLine(ctx, ` ${line}`);
199
- }
200
- if (result.app?.id && result.deployment?.id) {
201
- await writeLine(ctx, "");
202
- await writeLine(ctx, pc.dim(`Full log: percher deploys inspect ${result.deployment.id} --app ${result.app.name}`));
203
- }
204
- }
205
- throw new SilentExitError();
206
- }
207
- const timing = result.timing ? ` (${result.timing.totalSeconds}s)` : "";
208
- await writeLine(ctx, `${pc.green("✓")} Published to ${result.url}${timing}`);
209
- if (result.replacedPreview) {
210
- await writeLine(ctx, pc.dim(" (replaced previous preview — free plan keeps 1 preview slot per app)"));
211
- }
212
- // Show QR code for preview deploys
213
- if (options.preview && result.url) {
214
- try {
215
- const qr = await core.qrToTerminal(result.url);
216
- await writeLine(ctx, "");
217
- await writeLine(ctx, pc.dim("Scan to preview on mobile:"));
218
- await writeLine(ctx, qr);
219
- }
220
- catch {
221
- /* QR generation is nice-to-have */
222
- }
223
- }
224
- });
225
- cli
226
- .command("push", "Package and deploy this project")
227
- .option("--dry-run", "Show what would be deployed, don't upload")
228
- .option("--force", "Skip size warnings")
229
- .option("--preview", "Deploy as preview (does not replace the live version)")
230
- .action(async (options) => {
231
- const ctx = ctxOverride ?? (await createCliContext());
232
- const result = await core.push(ctx, {
233
- dryRun: options.dryRun,
234
- force: options.force,
235
- preview: options.preview,
236
- });
237
- if (result.dryRun) {
238
- await writeLine(ctx, `Dry run: ${result.fileCount} files, ${(result.bytes / 1024).toFixed(1)} KB`);
239
- }
240
- else {
241
- const url = result.deployment?.previewUrl ?? result.deployment?.url ?? result.app.url;
242
- const label = result.deployment?.previewUrl ? "preview live at" : "deployed to";
243
- await writeLine(ctx, `${pc.green("✓")} ${label} ${url}`);
244
- }
245
- });
246
- cli
247
- .command("logs [app]", "Stream application logs")
248
- .option("--tail <n>", "Return the last N lines and exit", { type: [Number] })
249
- .option("--no-follow", "Don't stream new lines")
250
- .option("--build [deployId]", "Show the BUILD log instead of runtime logs (defaults to latest failed deploy)")
251
- .action(async (app, options) => {
252
- const ctx = ctxOverride ?? (await createCliContext());
253
- // --build routes to the same logic as `deploys inspect`. We default
254
- // to latest-failed when no deploy id is given because that's the
255
- // common case (debugging the build that just failed).
256
- if (options.build !== undefined) {
257
- const deployId = typeof options.build === "string" && options.build.length > 0 ? options.build : undefined;
258
- const result = await core.deploysInspect(ctx, {
259
- app,
260
- id: deployId,
261
- latestFailed: deployId ? undefined : true,
262
- });
263
- const { deploy, buildLog } = result;
264
- await writeLine(ctx, `${pc.bold("Deploy:")} ${deploy.id}`);
265
- await writeLine(ctx, `${pc.bold("Status:")} ${formatStatus(deploy.status)}`);
266
- await writeLine(ctx, `${pc.bold("Created:")} ${formatDeployTimestamp(deploy.createdAt)}`);
267
- if (deploy.errorMessage) {
268
- await writeLine(ctx, "");
269
- await writeLine(ctx, pc.bold(pc.red("Error:")));
270
- for (const line of deploy.errorMessage.split("\n")) {
271
- await writeLine(ctx, ` ${line}`);
272
- }
273
- }
274
- if (buildLog) {
275
- await writeLine(ctx, "");
276
- const lines = buildLog.split("\n");
277
- const tailN = options.tail ? Number(options.tail) : 200;
278
- const tail = lines.slice(-tailN);
279
- const header = lines.length > tail.length
280
- ? `${pc.bold("Build log")} ${pc.dim(`(last ${tail.length} of ${lines.length} lines)`)}`
281
- : pc.bold("Build log");
282
- await writeLine(ctx, header);
283
- for (const line of tail) {
284
- await writeLine(ctx, ` ${line}`);
285
- }
286
- }
287
- else {
288
- await writeLine(ctx, pc.dim("(no build log available for this deploy)"));
289
- }
290
- return;
291
- }
292
- await core.logs(ctx, {
293
- app,
294
- tail: options.tail ? Number(options.tail) : undefined,
295
- follow: options.follow !== false,
296
- });
297
- });
298
- cli
299
- .command("env <action> [...args]", "Manage env vars (set KEY=VALUE | unset KEY | list)")
300
- .option("--app <name>", "App name (overrides percher.toml)")
301
- .action(async (action, args, options) => {
302
- const ctx = ctxOverride ?? (await createCliContext());
303
- if (action === "set") {
304
- await core.envSet(ctx, { app: options.app, values: parseKeyValue(args) });
305
- await writeLine(ctx, `${pc.green("✓")} env vars updated`);
306
- }
307
- else if (action === "unset") {
308
- const key = args[0];
309
- if (!key)
310
- throw new Error("Expected key to unset");
311
- await core.envUnset(ctx, { app: options.app, key });
312
- await writeLine(ctx, `${pc.green("✓")} unset ${key}`);
313
- }
314
- else if (action === "list") {
315
- const env = await core.envList(ctx, { app: options.app });
316
- for (const [k, v] of Object.entries(env)) {
317
- await writeLine(ctx, `${k}=${v}`);
318
- }
319
- }
320
- else {
321
- throw new Error(`Unknown env action "${action}". Use set | unset | list.`);
322
- }
323
- });
324
- cli
325
- .command("open [app]", "Open the app URL in your browser")
326
- .action(async (app) => {
327
- const ctx = ctxOverride ?? (await createCliContext());
328
- const url = await core.open(ctx, { app });
329
- await writeLine(ctx, url);
330
- });
331
- cli
332
- .command("login", "Authenticate with Percher")
333
- .option("--token <token>", "Dev: skip browser, use a token")
334
- .option("--api-url <url>", "Override API URL")
335
- .action(async (options) => {
336
- const ctx = ctxOverride ?? (await createCliContext());
337
- await core.login(ctx, { token: options.token, apiUrl: options.apiUrl });
338
- });
339
- cli
340
- .command("rename <name> [app]", "Rename an app (changes its subdomain)")
341
- .action(async (name, app) => {
342
- const ctx = ctxOverride ?? (await createCliContext());
343
- const result = await core.rename(ctx, { app, name });
344
- await writeLine(ctx, `${pc.green("✓")} Renamed to ${result.name} — now live at ${result.url}`);
345
- await writeLine(ctx, ` Previous name: ${result.previousName} (reserved for 60 days)`);
346
- await writeLine(ctx, ` ${pc.dim(`Next rename available in ${result.cooldownDays} days`)}`);
347
- });
348
- cli
349
- .command("versions [app]", "List deployed versions for an app")
350
- .action(async (app) => {
351
- const ctx = ctxOverride ?? (await createCliContext());
352
- const list = await core.versions(ctx, { app });
353
- for (const v of list) {
354
- await writeLine(ctx, `${v.sha.slice(0, 10)} ${v.timestamp} ${v.message}`);
355
- }
356
- });
357
- cli
358
- .command("mcp", "Print the MCP server config for AI assistants (Claude Code, Cursor, Windsurf)")
359
- .option("--json", "Output JSON only (paste-ready)")
360
- .action(async (options) => {
361
- const ctx = ctxOverride ?? (await createCliContext());
362
- const result = core.mcp(ctx, {});
363
- if (options.json) {
364
- await writeLine(ctx, JSON.stringify(result.config, null, 2));
365
- return;
366
- }
367
- await writeLine(ctx, pc.bold("Percher MCP server"));
368
- await writeLine(ctx, pc.dim("Add this entry to your MCP-aware client:"));
369
- await writeLine(ctx, "");
370
- await writeLine(ctx, JSON.stringify(result.config, null, 2));
371
- await writeLine(ctx, "");
372
- await writeLine(ctx, pc.bold("Where to paste:"));
373
- for (const loc of result.installLocations) {
374
- await writeLine(ctx, ` ${loc.client.padEnd(28)} ${pc.dim(loc.path)}`);
375
- }
376
- await writeLine(ctx, "");
377
- await writeLine(ctx, pc.dim("Once installed, your assistant gets percher_publish + 28 other tools."));
378
- });
379
- cli
380
- .command("deploys [app]", "List recent deploys (id, status, created, error) for an app")
381
- .option("--limit <n>", "Number of deploys to show (default: 10, max: 100)")
382
- .action(async (app, options) => {
383
- // Subcommand collision: `percher deploys inspect <id>` would otherwise
384
- // hit this handler with app="inspect" because cac matches on first
385
- // token only. Dispatch the documented form to the inspect handler.
386
- if (app === "inspect") {
387
- // Same flag-skip rule as the data reset-superuser dispatch — both
388
- // `deploys inspect <id> --latest-failed` and `deploys inspect
389
- // --latest-failed` should work.
390
- const idx = argv.indexOf("inspect");
391
- const id = idx >= 0 ? firstNonFlagAfter(argv, idx) : undefined;
392
- const ctx = ctxOverride ?? (await createCliContext());
393
- await runDeploysInspect(ctx, {
394
- id,
395
- // Forward --app + --latest-failed if the user passed them. Their
396
- // flag values land in `options` even though we registered them
397
- // on the sibling command, because cac merges argv flags onto the
398
- // matched command's options.
399
- app: options.app,
400
- latestFailed: Boolean(options.latestFailed),
401
- });
402
- return;
403
- }
404
- const ctx = ctxOverride ?? (await createCliContext());
405
- const limit = options.limit ? Number.parseInt(options.limit, 10) : undefined;
406
- const list = await core.deploysList(ctx, { app, limit });
407
- if (list.length === 0) {
408
- await writeLine(ctx, pc.dim("No deploys yet."));
409
- return;
410
- }
411
- await writeLine(ctx, `${pc.bold("ID".padEnd(22))} ${pc.bold("STATUS".padEnd(10))} ${pc.bold("CREATED".padEnd(20))} ${pc.bold("ERROR")}`);
412
- for (const d of list) {
413
- const id = d.id.padEnd(22);
414
- const status = d.status.padEnd(10);
415
- const created = formatDeployTimestamp(d.createdAt).padEnd(20);
416
- const err = d.errorMessage
417
- ? (d.errorMessage.split("\n")[0]?.slice(0, 60) ?? "—")
418
- : pc.dim("—");
419
- const colored = d.status === "live"
420
- ? pc.green(status)
421
- : d.status === "failed"
422
- ? pc.red(status)
423
- : pc.yellow(status);
424
- await writeLine(ctx, `${pc.dim(id)} ${colored} ${pc.dim(created)} ${err}`);
425
- }
426
- });
427
- cli
428
- .command("deploys inspect [id]", "Inspect a deploy: status, error, build log")
429
- .option("--app <name>", "App name (defaults to percher.toml in cwd)")
430
- .option("--latest-failed", "Resolve to the most recent failed deploy")
431
- .action(async (id, options) => {
432
- // Reachable only via the in-action dispatch in the parent `deploys`
433
- // command above. Kept registered so cac surfaces it in `--help`.
434
- const ctx = ctxOverride ?? (await createCliContext());
435
- await runDeploysInspect(ctx, { id, app: options.app, latestFailed: options.latestFailed });
436
- });
437
- cli
438
- .command("rollback <sha> [app]", "Roll back to a previous version")
439
- .action(async (sha, app) => {
440
- const ctx = ctxOverride ?? (await createCliContext());
441
- const result = await core.rollback(ctx, { app, commitSha: sha });
442
- await writeLine(ctx, `${pc.green("✓")} rolled back to ${sha.slice(0, 10)} (status: ${result.status})`);
443
- });
444
- cli
445
- .command("generate", "Generate .env.example and TypeScript types from percher.toml")
446
- .action(async () => {
447
- const ctx = ctxOverride ?? (await createCliContext());
448
- const result = await core.generate(ctx);
449
- for (const f of result.files) {
450
- await writeLine(ctx, `${pc.green("✓")} ${f}`);
451
- }
452
- });
453
- cli
454
- .command("data [app]", "Show app data status and collections")
455
- .option("--json", "Output as JSON")
456
- .option("--reveal", "(reset-superuser only) also print the plaintext password once")
457
- .action(async (app, options) => {
458
- // cac matches commands by single first-token equality, so
459
- // `percher data reset-superuser <app>` would otherwise hit this
460
- // handler with app="reset-superuser" and silently drop the real
461
- // app name. Detect the documented subcommand here and dispatch
462
- // to the reset-superuser handler instead. The separate
463
- // `data reset-superuser [app]` registration below still exists
464
- // so `--help` lists the subcommand, but its action body is
465
- // unreachable in practice via that route.
466
- if (app === "reset-superuser") {
467
- // Skip flag tokens when looking for the app name so both orderings
468
- // work: `... reset-superuser my-app --reveal` AND `... reset-superuser
469
- // --reveal my-app`. cac would handle this if it parsed multi-token
470
- // commands natively; since we route manually, we mirror cac's
471
- // "first non-flag positional" rule here.
472
- const idx = argv.indexOf("reset-superuser");
473
- const realApp = idx >= 0 ? firstNonFlagAfter(argv, idx) : undefined;
474
- const reveal = argv.includes("--reveal");
475
- const ctx = ctxOverride ?? (await createCliContext());
476
- const r = await core.resetSuperuser(ctx, { app: realApp, reveal });
477
- await printSuperuserResetResult(ctx, r);
478
- return;
479
- }
480
- const ctx = ctxOverride ?? (await createCliContext());
481
- const result = await core.data(ctx, { app, json: options.json });
482
- if (options.json) {
483
- await writeLine(ctx, JSON.stringify(result, null, 2));
484
- }
485
- else {
486
- const version = result.version ? ` Version: ${result.version}` : "";
487
- await writeLine(ctx, `${pc.bold("Data")} Mode: ${result.mode} Status: ${result.status}${version}`);
488
- await writeLine(ctx, "");
489
- if (result.collections.length > 0) {
490
- await writeLine(ctx, pc.bold("Collections"));
491
- for (const col of result.collections) {
492
- await writeLine(ctx, ` ${col.name.padEnd(20)} ${pc.dim(col.type)}`);
493
- }
494
- await writeLine(ctx, "");
495
- }
496
- await writeLine(ctx, `Collections: ${result.stats.collections} Records: ${result.stats.totalRecords}`);
497
- if (result.adminUrl) {
498
- await writeLine(ctx, "");
499
- await writeLine(ctx, `Admin: ${result.adminUrl}`);
500
- }
501
- if (result.warning) {
502
- await writeLine(ctx, "");
503
- await writeLine(ctx, pc.yellow(`⚠ ${result.warning}`));
504
- }
505
- }
506
- });
507
- cli
508
- .command("data reset-superuser [app]", "Reset PocketBase superuser and inject new credentials")
509
- .option("--reveal", "Also print the plaintext password once (default: hidden)")
510
- .action(async (app, options) => {
511
- const ctx = ctxOverride ?? (await createCliContext());
512
- const result = await core.resetSuperuser(ctx, { app, reveal: options.reveal });
513
- await printSuperuserResetResult(ctx, result);
514
- });
515
- cli
516
- .command("dev [app]", "Watch files and deploy on changes")
517
- .option("--confirm", "Prompt before each push (default: auto-push)")
518
- .action(async (app, options) => {
519
- const ctx = ctxOverride ?? (await createCliContext());
520
- const controller = new AbortController();
521
- // Handle Ctrl+C gracefully
522
- const onSignal = () => controller.abort();
523
- process.on("SIGINT", onSignal);
524
- process.on("SIGTERM", onSignal);
525
- await writeLine(ctx, pc.bold("Percher Dev"));
526
- await writeLine(ctx, "───────────");
527
- await writeLine(ctx, "");
528
- try {
529
- await core.dev(ctx, { app }, {
530
- signal: controller.signal,
531
- async writeLine(line) {
532
- const timestamp = new Date().toLocaleTimeString("sv-SE", { hour12: false });
533
- await writeLine(ctx, `${pc.dim(`[${timestamp}]`)} ${line}`);
534
- },
535
- async onFilesChanged(_files) {
536
- if (options.confirm) {
537
- const answer = await ctx.prompt("Push changes? [Y/n]", { default: "y" });
538
- return /^y(es)?$|^$/i.test(answer.trim());
539
- }
540
- return true; // auto-push by default
541
- },
542
- });
543
- }
544
- finally {
545
- process.removeListener("SIGINT", onSignal);
546
- process.removeListener("SIGTERM", onSignal);
547
- }
548
- await writeLine(ctx, "");
549
- await writeLine(ctx, pc.dim("Dev session ended."));
550
- });
551
- cli
552
- .command("doctor [app]", "Check app health and connectivity")
553
- .option("--json", "Output as JSON")
554
- .action(async (app, options) => {
555
- const ctx = ctxOverride ?? (await createCliContext());
556
- const result = await core.doctor(ctx, { app, json: options.json });
557
- if (options.json) {
558
- await writeLine(ctx, JSON.stringify(result, null, 2));
559
- }
560
- else {
561
- await writeLine(ctx, `${pc.bold("Percher Doctor")}`);
562
- await writeLine(ctx, "──────────────");
563
- await writeLine(ctx, "");
564
- for (const check of result.checks) {
565
- const icon = check.status === "pass"
566
- ? pc.green("✓")
567
- : check.status === "fail"
568
- ? pc.red("✗")
569
- : check.status === "warn"
570
- ? pc.yellow("!")
571
- : pc.dim("—");
572
- await writeLine(ctx, `${icon} ${pc.bold(check.name.padEnd(20))} ${check.message}`);
573
- }
574
- await writeLine(ctx, "");
575
- const summary = `${result.passed}/${result.total} checks passed`;
576
- if (result.failed > 0) {
577
- await writeLine(ctx, pc.red(summary));
578
- }
579
- else if (result.warned > 0) {
580
- await writeLine(ctx, pc.yellow(summary));
581
- }
582
- else {
583
- await writeLine(ctx, pc.green(summary));
584
- }
585
- }
586
- // Signal non-zero exit for CI/agent use
587
- if (result.failed > 0) {
588
- throw new SilentExitError();
589
- }
590
- });
591
- cli
592
- .command("create [name]", "Create a new app from a template")
593
- .option("--template <template>", "Template to use (node, python, static, nextjs, hono, express)")
594
- .option("--force", "Overwrite existing files in target directory")
595
- .option("--list-templates", "List available templates")
596
- .action(async (name, options) => {
597
- const ctx = ctxOverride ?? (await createCliContext());
598
- if (options.listTemplates) {
599
- const templates = core.listTemplates();
600
- await writeLine(ctx, pc.bold("Available templates:"));
601
- for (const t of templates) {
602
- await writeLine(ctx, ` ${t}`);
603
- }
604
- return;
605
- }
606
- if (!name) {
607
- throw new Error("App name is required. Usage: percher create <name> --template <template>");
608
- }
609
- if (!options.template) {
610
- const templates = core.listTemplates();
611
- await writeLine(ctx, `${pc.red("✗")} --template is required. Available: ${templates.join(", ")}`);
612
- return;
613
- }
614
- const result = await core.create(ctx, {
615
- name,
616
- template: options.template,
617
- force: options.force,
618
- });
619
- await writeLine(ctx, `${pc.green("✓")} created ${result.name} from ${result.template}`);
620
- for (const f of result.files) {
621
- await writeLine(ctx, ` ${pc.dim(f)}`);
622
- }
623
- await writeLine(ctx, "");
624
- await writeLine(ctx, "Next steps:");
625
- await writeLine(ctx, ` cd ${result.name}`);
626
- await writeLine(ctx, " bunx percher publish");
627
- });
628
- cli
629
- .command("preview <action> [deployId]", "Manage preview deploys (promote | discard)")
630
- .option("--app <name>", "App name (overrides percher.toml)")
631
- .action(async (action, deployId, options) => {
632
- const ctx = ctxOverride ?? (await createCliContext());
633
- const appRef = options.app ?? core.readPercherTomlAppName(ctx.cwd);
634
- if (!appRef)
635
- throw new Error("App name required. Use --app or run from a project directory.");
636
- if (action === "promote") {
637
- if (!deployId)
638
- throw new Error("Deploy ID required. Usage: percher preview promote <id>");
639
- const result = await ctx.client.apps.promotePreview(appRef, deployId);
640
- await writeLine(ctx, `${pc.green("✓")} Preview promoted to live. Status: ${result.status}`);
641
- }
642
- else if (action === "discard") {
643
- if (!deployId)
644
- throw new Error("Deploy ID required. Usage: percher preview discard <id>");
645
- await ctx.client.apps.discardPreview(appRef, deployId);
646
- await writeLine(ctx, `${pc.green("✓")} Preview discarded.`);
647
- }
648
- else {
649
- throw new Error(`Unknown preview action "${action}". Use promote | discard.`);
650
- }
651
- });
652
- cli
653
- .command("domains <action> [domain]", "Manage custom domains (add | verify | list | remove)")
654
- .option("--app <name>", "App name (overrides percher.toml)")
655
- .action(async (action, domain, options) => {
656
- const ctx = ctxOverride ?? (await createCliContext());
657
- if (action === "add") {
658
- if (!domain)
659
- throw new Error("Domain is required. Usage: percher domains add <domain>");
660
- const result = await core.domainAdd(ctx, { app: options.app, domain });
661
- await writeLine(ctx, `${pc.green("✓")} Domain ${result.domain} added.`);
662
- await writeLine(ctx, "");
663
- await writeLine(ctx, "Add these DNS records:");
664
- await writeLine(ctx, ` TXT ${result.dnsInstructions.txt.name} ${result.dnsInstructions.txt.value}`);
665
- await writeLine(ctx, ` CNAME ${result.domain} ${result.dnsInstructions.cname.value}`);
666
- if (result.dnsInstructions.a) {
667
- await writeLine(ctx, ` A ${result.domain} ${result.dnsInstructions.a.values.join(", ")}`);
668
- }
669
- await writeLine(ctx, "");
670
- await writeLine(ctx, "Then run:");
671
- await writeLine(ctx, ` percher domains verify ${result.domain}${options.app ? ` --app ${options.app}` : ""}`);
672
- }
673
- else if (action === "verify") {
674
- if (!domain)
675
- throw new Error("Domain is required. Usage: percher domains verify <domain>");
676
- const result = await core.domainVerify(ctx, { app: options.app, domain });
677
- if (result.status === "verified") {
678
- await writeLine(ctx, `${pc.green("✓")} Domain verified. ${result.domain} is now live.`);
679
- }
680
- else {
681
- await writeLine(ctx, `${pc.yellow("!")} ${result.domain}: ${result.failureReason}`);
682
- }
683
- }
684
- else if (action === "list") {
685
- const list = await core.domainList(ctx, { app: options.app });
686
- if (list.length === 0) {
687
- await writeLine(ctx, "No custom domains.");
688
- }
689
- else {
690
- for (const d of list) {
691
- await writeLine(ctx, `${d.domain.padEnd(40)} ${d.status}`);
692
- }
693
- }
694
- }
695
- else if (action === "remove") {
696
- if (!domain)
697
- throw new Error("Domain is required. Usage: percher domains remove <domain>");
698
- await core.domainRemove(ctx, { app: options.app, domain });
699
- await writeLine(ctx, `${pc.green("✓")} Domain removed.`);
700
- }
701
- else {
702
- throw new Error(`Unknown domains action "${action}". Use add | verify | list | remove.`);
703
- }
704
- });
705
- cli
706
- .command("resume [app]", "Resume an app suspended for inactivity")
707
- .action(async (app) => {
708
- const ctx = ctxOverride ?? (await createCliContext());
709
- const result = await core.resume(ctx, { app });
710
- await writeLine(ctx, `${pc.green("✓")} ${result.message}`);
711
- });
712
- cli
713
- .command("import", "Convert Vercel or Netlify config to percher.toml")
714
- .option("--from <provider>", "Provider: vercel | netlify (auto-detected if omitted)")
715
- .action(async (options) => {
716
- const ctx = ctxOverride ?? (await createCliContext());
717
- const result = await core.importProject(ctx, {
718
- from: options.from,
719
- });
720
- if (result.configGenerated) {
721
- await writeLine(ctx, `${pc.green("✓")} Imported from ${result.provider} — percher.toml generated`);
722
- }
723
- for (const w of result.warnings) {
724
- await writeLine(ctx, `${pc.yellow("!")} ${w}`);
725
- }
726
- if (result.envVars.length > 0) {
727
- await writeLine(ctx, "");
728
- await writeLine(ctx, "Set these env vars before publishing:");
729
- for (const v of result.envVars) {
730
- await writeLine(ctx, ` bunx percher env set ${v}=your-value`);
731
- }
732
- }
733
- if (result.configGenerated) {
734
- await writeLine(ctx, "");
735
- await writeLine(ctx, "Next: review percher.toml, then run:");
736
- await writeLine(ctx, " bunx percher publish");
737
- }
738
- });
739
- cli
740
- .command("delete [app]", "Delete an app permanently")
741
- .option("--yes", "Skip confirmation prompt")
742
- .action(async (app, options) => {
743
- const ctx = ctxOverride ?? (await createCliContext());
744
- const name = app ?? core.readPercherTomlAppName(ctx.cwd);
745
- if (!name) {
746
- await writeLine(ctx, `${pc.red("✗")} No app specified and no percher.toml found.`);
747
- throw new SilentExitError();
748
- }
749
- if (!options.yes) {
750
- const answer = await ctx.prompt(`${pc.red("!")} This will permanently delete ${pc.bold(name)} and all its data. Type the app name to confirm:`);
751
- if (answer.trim() !== name) {
752
- await writeLine(ctx, "Cancelled.");
753
- return;
754
- }
755
- }
756
- await core.deleteApp(ctx, { app: name, confirm: true });
757
- await writeLine(ctx, `${pc.green("✓")} Deleted ${name}`);
758
- });
759
- cli.command("whoami", "Show the signed-in user").action(async () => {
760
- const ctx = ctxOverride ?? (await createCliContext());
761
- if (!ctx.client.token) {
762
- await writeLine(ctx, `${pc.red("✗")} Not logged in. Run: percher login`);
763
- throw new SilentExitError();
764
- }
765
- const me = await core.whoami(ctx);
766
- await writeLine(ctx, `Signed in as ${me.email} (${me.userId})`);
767
- });
17
+ const ctx = ctxOverride ?? (await createCliContext());
18
+ registerAppsCommands(cli, ctx);
19
+ registerDeploysCommands(cli, ctx, argv);
20
+ registerEnvCommands(cli, ctx);
21
+ registerDataCommands(cli, ctx, argv);
22
+ registerAuthCommands(cli, ctx);
23
+ registerAccountCommands(cli, ctx);
24
+ registerGithubCommands(cli, ctx);
768
25
  cli.help();
769
26
  cli.version(VERSION);
770
27
  const command = argv[0] ?? "help";
@@ -784,7 +41,6 @@ export async function run(argv, ctxOverride) {
784
41
  }
785
42
  const errName = err instanceof Error ? err.constructor.name : "unknown";
786
43
  trackEvent(command, "error", errName);
787
- const ctx = ctxOverride ?? (await createCliContext());
788
44
  await renderError(ctx.stderr, err);
789
45
  return 1;
790
46
  }