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