@canner-ca/cli 0.4.0 → 0.5.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 +231 -207
- package/package.json +42 -41
- package/src/cli.mjs +2543 -1257
package/src/cli.mjs
CHANGED
|
@@ -1,1257 +1,2543 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// @canner-ca/cli — canner deploy CLI.
|
|
3
|
-
//
|
|
4
|
-
// Single-file ESM. Zero transitive deps beyond `tar` (one MIT package).
|
|
5
|
-
// Works in any terminal (macOS / Linux / Windows / Cursor). Authenticates
|
|
6
|
-
// with a `cnr_*` API token from the dashboard; deploys by tarring the
|
|
7
|
-
// current directory and POSTing to the same /projects/:slug/upload
|
|
8
|
-
// endpoint the dashboard's drag-drop uses.
|
|
9
|
-
//
|
|
10
|
-
// Commands:
|
|
11
|
-
// canner login store an API token
|
|
12
|
-
// canner logout remove the stored token from this machine
|
|
13
|
-
// canner whoami show the account this CLI is signed in as
|
|
14
|
-
// canner init [--slug X] create a project, write canner.json
|
|
15
|
-
// canner deploy [path] [--slug X] [--follow]
|
|
16
|
-
// tar + upload + optionally stream the build log
|
|
17
|
-
//
|
|
18
|
-
// Exit codes: 0 on success, 1 on user/network/auth error, 2 on bad CLI
|
|
19
|
-
// invocation. `canner deploy --follow` exits with the build's success
|
|
20
|
-
// (0 = live, 1 = failed) so it composes with CI pipelines.
|
|
21
|
-
|
|
22
|
-
import { existsSync, openAsBlob } from 'node:fs';
|
|
23
|
-
import { mkdir, readFile, writeFile, stat, chmod, unlink } from 'node:fs/promises';
|
|
24
|
-
import { homedir, tmpdir } from 'node:os';
|
|
25
|
-
import { join, resolve, basename } from 'node:path';
|
|
26
|
-
import { createInterface } from 'node:readline/promises';
|
|
27
|
-
import { randomUUID } from 'node:crypto';
|
|
28
|
-
import { stdin as input, stdout as output } from 'node:process';
|
|
29
|
-
import { create as tarCreate } from 'tar';
|
|
30
|
-
|
|
31
|
-
const VERSION = '0.
|
|
32
|
-
const DEFAULT_API_BASE = 'https://api.canner.ca';
|
|
33
|
-
const CONFIG_DIR = join(homedir(), '.canner');
|
|
34
|
-
const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials');
|
|
35
|
-
const PROJECT_CONFIG_FILE = 'canner.json';
|
|
36
|
-
|
|
37
|
-
// Stuff we never ship even if the user doesn't have a .cannerignore. These
|
|
38
|
-
// are "build noise" — heavy directories that the server-side build pipeline
|
|
39
|
-
// rebuilds, plus OS / IDE droppings.
|
|
40
|
-
const HARD_EXCLUDES = new Set([
|
|
41
|
-
'node_modules',
|
|
42
|
-
'.git',
|
|
43
|
-
'.next',
|
|
44
|
-
'.nuxt',
|
|
45
|
-
'.turbo',
|
|
46
|
-
'.vercel',
|
|
47
|
-
'.svelte-kit',
|
|
48
|
-
'.DS_Store',
|
|
49
|
-
'Thumbs.db',
|
|
50
|
-
]);
|
|
51
|
-
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
|
-
// Tiny ANSI styling. No chalk dep — eight inlined escape codes cover us.
|
|
54
|
-
// Disabled when stdout isn't a TTY (CI, redirected to file).
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
const isTTY = output.isTTY === true;
|
|
57
|
-
const c = (code) => (s) => isTTY ? `\x1b[${code}m${s}\x1b[0m` : String(s);
|
|
58
|
-
const bold = c('1');
|
|
59
|
-
const dim = c('2');
|
|
60
|
-
const red = c('31');
|
|
61
|
-
const green = c('32');
|
|
62
|
-
const yellow = c('33');
|
|
63
|
-
const cyan = c('36');
|
|
64
|
-
|
|
65
|
-
// Machine-readable output mode (`--json`). Set once in main from argv. In JSON
|
|
66
|
-
// mode every command prints a single JSON value to stdout and nothing else, so
|
|
67
|
-
// the output pipes straight into `jq` or an agent; errors become
|
|
68
|
-
// {"error":"..."} on stderr with a non-zero exit. Human-facing helpers
|
|
69
|
-
// (info/muted) go silent so they can't corrupt the JSON stream.
|
|
70
|
-
let JSON_MODE = false;
|
|
71
|
-
|
|
72
|
-
function emitJSON(value) {
|
|
73
|
-
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function die(msg, code = 1) {
|
|
77
|
-
if (JSON_MODE) {
|
|
78
|
-
process.stderr.write(JSON.stringify({ error: String(msg) }) + '\n');
|
|
79
|
-
} else {
|
|
80
|
-
process.stderr.write(`${red('error:')} ${msg}\n`);
|
|
81
|
-
}
|
|
82
|
-
process.exit(code);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function info(msg) {
|
|
86
|
-
if (JSON_MODE) return;
|
|
87
|
-
process.stdout.write(`${msg}\n`);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function muted(msg) {
|
|
91
|
-
if (JSON_MODE) return;
|
|
92
|
-
process.stdout.write(`${dim(msg)}\n`);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
// Credential storage (~/.canner/credentials)
|
|
97
|
-
//
|
|
98
|
-
// JSON with shape: { api_base: string, token: string }. Mode 0600 on
|
|
99
|
-
// platforms that respect it. We default api_base to https://api.canner.ca
|
|
100
|
-
// but allow override via CANNER_API_BASE env var (handy for self-host
|
|
101
|
-
// later, and for local dev pointing at localhost:4001 via tunnel).
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
async function loadCredentials() {
|
|
104
|
-
try {
|
|
105
|
-
const raw = await readFile(CREDENTIALS_PATH, 'utf8');
|
|
106
|
-
const parsed = JSON.parse(raw);
|
|
107
|
-
return {
|
|
108
|
-
apiBase: process.env.CANNER_API_BASE || parsed.api_base || DEFAULT_API_BASE,
|
|
109
|
-
token: parsed.token,
|
|
110
|
-
};
|
|
111
|
-
} catch {
|
|
112
|
-
return {
|
|
113
|
-
apiBase: process.env.CANNER_API_BASE || DEFAULT_API_BASE,
|
|
114
|
-
token: process.env.CANNER_TOKEN || null,
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function saveCredentials({ apiBase, token }) {
|
|
120
|
-
await mkdir(CONFIG_DIR, { recursive: true });
|
|
121
|
-
await writeFile(
|
|
122
|
-
CREDENTIALS_PATH,
|
|
123
|
-
JSON.stringify({ api_base: apiBase, token }, null, 2),
|
|
124
|
-
{ mode: 0o600 }
|
|
125
|
-
);
|
|
126
|
-
try {
|
|
127
|
-
await chmod(CREDENTIALS_PATH, 0o600);
|
|
128
|
-
} catch {
|
|
129
|
-
// Windows doesn't honor Unix mode; fine.
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
// HTTP helpers. fetch is built-in on Node 20+.
|
|
135
|
-
// ---------------------------------------------------------------------------
|
|
136
|
-
async function apiRequest(creds, method, path, options = {}) {
|
|
137
|
-
if (!creds.token) {
|
|
138
|
-
die("not signed in. Run `canner login` first.");
|
|
139
|
-
}
|
|
140
|
-
const url = `${creds.apiBase}${path}`;
|
|
141
|
-
const headers = {
|
|
142
|
-
Authorization: `Bearer ${creds.token}`,
|
|
143
|
-
Accept: 'application/json',
|
|
144
|
-
...(options.headers ?? {}),
|
|
145
|
-
};
|
|
146
|
-
const init = {
|
|
147
|
-
method,
|
|
148
|
-
headers,
|
|
149
|
-
body: options.body,
|
|
150
|
-
// Long-lived ops (deploy upload, SSE) shouldn't time out at the
|
|
151
|
-
// fetch level. Per-call options.signal can still abort.
|
|
152
|
-
};
|
|
153
|
-
if (options.signal) init.signal = options.signal;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
async function
|
|
249
|
-
const path = join(cwd,
|
|
250
|
-
if (!existsSync(path)) return
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
info(
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const me = await apiJSON(
|
|
293
|
-
if (!me?.account) {
|
|
294
|
-
die('
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
if (
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (
|
|
413
|
-
const
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
const
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
if (
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
if (
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
};
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if (sub === '
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
if (
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
if (sub === '
|
|
656
|
-
const
|
|
657
|
-
if (!
|
|
658
|
-
const
|
|
659
|
-
|
|
660
|
-
if (JSON_MODE) return emitJSON(
|
|
661
|
-
info(`${green('
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
(
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
if (
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
if (sub === '
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
if (
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
const
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
await apiJSON(creds, '
|
|
819
|
-
if (JSON_MODE) return emitJSON(
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
if (
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
if (
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
const
|
|
908
|
-
const
|
|
909
|
-
if (!
|
|
910
|
-
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
info(`${
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
`);
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
const
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
const
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @canner-ca/cli — canner deploy CLI.
|
|
3
|
+
//
|
|
4
|
+
// Single-file ESM. Zero transitive deps beyond `tar` (one MIT package).
|
|
5
|
+
// Works in any terminal (macOS / Linux / Windows / Cursor). Authenticates
|
|
6
|
+
// with a `cnr_*` API token from the dashboard; deploys by tarring the
|
|
7
|
+
// current directory and POSTing to the same /projects/:slug/upload
|
|
8
|
+
// endpoint the dashboard's drag-drop uses.
|
|
9
|
+
//
|
|
10
|
+
// Commands:
|
|
11
|
+
// canner login store an API token
|
|
12
|
+
// canner logout remove the stored token from this machine
|
|
13
|
+
// canner whoami show the account this CLI is signed in as
|
|
14
|
+
// canner init [--slug X] create a project, write canner.json
|
|
15
|
+
// canner deploy [path] [--slug X] [--follow]
|
|
16
|
+
// tar + upload + optionally stream the build log
|
|
17
|
+
//
|
|
18
|
+
// Exit codes: 0 on success, 1 on user/network/auth error, 2 on bad CLI
|
|
19
|
+
// invocation. `canner deploy --follow` exits with the build's success
|
|
20
|
+
// (0 = live, 1 = failed) so it composes with CI pipelines.
|
|
21
|
+
|
|
22
|
+
import { existsSync, openAsBlob } from 'node:fs';
|
|
23
|
+
import { mkdir, readFile, writeFile, stat, chmod, unlink } from 'node:fs/promises';
|
|
24
|
+
import { homedir, tmpdir } from 'node:os';
|
|
25
|
+
import { join, resolve, basename } from 'node:path';
|
|
26
|
+
import { createInterface } from 'node:readline/promises';
|
|
27
|
+
import { randomUUID } from 'node:crypto';
|
|
28
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
29
|
+
import { create as tarCreate } from 'tar';
|
|
30
|
+
|
|
31
|
+
const VERSION = '0.5.0';
|
|
32
|
+
const DEFAULT_API_BASE = 'https://api.canner.ca';
|
|
33
|
+
const CONFIG_DIR = join(homedir(), '.canner');
|
|
34
|
+
const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials');
|
|
35
|
+
const PROJECT_CONFIG_FILE = 'canner.json';
|
|
36
|
+
|
|
37
|
+
// Stuff we never ship even if the user doesn't have a .cannerignore. These
|
|
38
|
+
// are "build noise" — heavy directories that the server-side build pipeline
|
|
39
|
+
// rebuilds, plus OS / IDE droppings.
|
|
40
|
+
const HARD_EXCLUDES = new Set([
|
|
41
|
+
'node_modules',
|
|
42
|
+
'.git',
|
|
43
|
+
'.next',
|
|
44
|
+
'.nuxt',
|
|
45
|
+
'.turbo',
|
|
46
|
+
'.vercel',
|
|
47
|
+
'.svelte-kit',
|
|
48
|
+
'.DS_Store',
|
|
49
|
+
'Thumbs.db',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Tiny ANSI styling. No chalk dep — eight inlined escape codes cover us.
|
|
54
|
+
// Disabled when stdout isn't a TTY (CI, redirected to file).
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
const isTTY = output.isTTY === true;
|
|
57
|
+
const c = (code) => (s) => isTTY ? `\x1b[${code}m${s}\x1b[0m` : String(s);
|
|
58
|
+
const bold = c('1');
|
|
59
|
+
const dim = c('2');
|
|
60
|
+
const red = c('31');
|
|
61
|
+
const green = c('32');
|
|
62
|
+
const yellow = c('33');
|
|
63
|
+
const cyan = c('36');
|
|
64
|
+
|
|
65
|
+
// Machine-readable output mode (`--json`). Set once in main from argv. In JSON
|
|
66
|
+
// mode every command prints a single JSON value to stdout and nothing else, so
|
|
67
|
+
// the output pipes straight into `jq` or an agent; errors become
|
|
68
|
+
// {"error":"..."} on stderr with a non-zero exit. Human-facing helpers
|
|
69
|
+
// (info/muted) go silent so they can't corrupt the JSON stream.
|
|
70
|
+
let JSON_MODE = false;
|
|
71
|
+
|
|
72
|
+
function emitJSON(value) {
|
|
73
|
+
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function die(msg, code = 1) {
|
|
77
|
+
if (JSON_MODE) {
|
|
78
|
+
process.stderr.write(JSON.stringify({ error: String(msg) }) + '\n');
|
|
79
|
+
} else {
|
|
80
|
+
process.stderr.write(`${red('error:')} ${msg}\n`);
|
|
81
|
+
}
|
|
82
|
+
process.exit(code);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function info(msg) {
|
|
86
|
+
if (JSON_MODE) return;
|
|
87
|
+
process.stdout.write(`${msg}\n`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function muted(msg) {
|
|
91
|
+
if (JSON_MODE) return;
|
|
92
|
+
process.stdout.write(`${dim(msg)}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Credential storage (~/.canner/credentials)
|
|
97
|
+
//
|
|
98
|
+
// JSON with shape: { api_base: string, token: string }. Mode 0600 on
|
|
99
|
+
// platforms that respect it. We default api_base to https://api.canner.ca
|
|
100
|
+
// but allow override via CANNER_API_BASE env var (handy for self-host
|
|
101
|
+
// later, and for local dev pointing at localhost:4001 via tunnel).
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
async function loadCredentials() {
|
|
104
|
+
try {
|
|
105
|
+
const raw = await readFile(CREDENTIALS_PATH, 'utf8');
|
|
106
|
+
const parsed = JSON.parse(raw);
|
|
107
|
+
return {
|
|
108
|
+
apiBase: process.env.CANNER_API_BASE || parsed.api_base || DEFAULT_API_BASE,
|
|
109
|
+
token: parsed.token,
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
return {
|
|
113
|
+
apiBase: process.env.CANNER_API_BASE || DEFAULT_API_BASE,
|
|
114
|
+
token: process.env.CANNER_TOKEN || null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function saveCredentials({ apiBase, token }) {
|
|
120
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
121
|
+
await writeFile(
|
|
122
|
+
CREDENTIALS_PATH,
|
|
123
|
+
JSON.stringify({ api_base: apiBase, token }, null, 2),
|
|
124
|
+
{ mode: 0o600 }
|
|
125
|
+
);
|
|
126
|
+
try {
|
|
127
|
+
await chmod(CREDENTIALS_PATH, 0o600);
|
|
128
|
+
} catch {
|
|
129
|
+
// Windows doesn't honor Unix mode; fine.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// HTTP helpers. fetch is built-in on Node 20+.
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
async function apiRequest(creds, method, path, options = {}) {
|
|
137
|
+
if (!creds.token) {
|
|
138
|
+
die("not signed in. Run `canner login` first.");
|
|
139
|
+
}
|
|
140
|
+
const url = `${creds.apiBase}${path}`;
|
|
141
|
+
const headers = {
|
|
142
|
+
Authorization: `Bearer ${creds.token}`,
|
|
143
|
+
Accept: 'application/json',
|
|
144
|
+
...(options.headers ?? {}),
|
|
145
|
+
};
|
|
146
|
+
const init = {
|
|
147
|
+
method,
|
|
148
|
+
headers,
|
|
149
|
+
body: options.body,
|
|
150
|
+
// Long-lived ops (deploy upload, SSE) shouldn't time out at the
|
|
151
|
+
// fetch level. Per-call options.signal can still abort.
|
|
152
|
+
};
|
|
153
|
+
if (options.signal) init.signal = options.signal;
|
|
154
|
+
// A streamed request body (database imports) needs duplex: 'half' in Node's fetch.
|
|
155
|
+
if (options.duplex) init.duplex = options.duplex;
|
|
156
|
+
const res = await fetch(url, init).catch((err) => {
|
|
157
|
+
die(`network: ${err.message}`);
|
|
158
|
+
});
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
let body = null;
|
|
161
|
+
try { body = await res.json(); } catch { /* not JSON */ }
|
|
162
|
+
if (res.status === 401) {
|
|
163
|
+
die("authentication failed. Your token may be revoked. Run `canner login` again.");
|
|
164
|
+
}
|
|
165
|
+
if (res.status === 403) {
|
|
166
|
+
// Surface the server's specific reason: a token missing a scope
|
|
167
|
+
// (insufficient_scope) or the member's role/permission in the org
|
|
168
|
+
// (insufficient_permission) both send a human `message`.
|
|
169
|
+
const detail =
|
|
170
|
+
body?.message ||
|
|
171
|
+
(body?.error === 'insufficient_scope'
|
|
172
|
+
? 'the API token is missing a required scope'
|
|
173
|
+
: body?.error === 'insufficient_permission'
|
|
174
|
+
? "your role in this organization doesn't permit this action"
|
|
175
|
+
: "the API token doesn't have access to this resource");
|
|
176
|
+
die(`forbidden: ${detail}`);
|
|
177
|
+
}
|
|
178
|
+
const msg = body?.message || body?.error || `${res.status} ${res.statusText}`;
|
|
179
|
+
die(`${method} ${path}: ${msg}`);
|
|
180
|
+
}
|
|
181
|
+
return res;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function apiJSON(creds, method, path, body) {
|
|
185
|
+
const init = {};
|
|
186
|
+
if (body !== undefined) {
|
|
187
|
+
init.headers = { 'Content-Type': 'application/json' };
|
|
188
|
+
init.body = JSON.stringify(body);
|
|
189
|
+
}
|
|
190
|
+
const res = await apiRequest(creds, method, path, init);
|
|
191
|
+
if (res.status === 204) return null;
|
|
192
|
+
return res.json();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// .cannerignore parsing — simple line-based excludes.
|
|
197
|
+
// One pattern per line. '#' comments, blank lines ignored. Supports:
|
|
198
|
+
// - exact-basename match anywhere ('build', 'coverage')
|
|
199
|
+
// - trailing-slash directory match ('logs/' matches only directories)
|
|
200
|
+
// - leading '/' anchors to project root ('/dist' = only the root dist/)
|
|
201
|
+
// - basic glob: '*' inside a single path segment
|
|
202
|
+
// No negation ('!') support — keep the surface tight.
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
function parseIgnoreFile(text) {
|
|
205
|
+
const patterns = [];
|
|
206
|
+
for (const lineRaw of text.split('\n')) {
|
|
207
|
+
const line = lineRaw.trim();
|
|
208
|
+
if (!line || line.startsWith('#')) continue;
|
|
209
|
+
const dirOnly = line.endsWith('/');
|
|
210
|
+
const anchored = line.startsWith('/');
|
|
211
|
+
const body = line.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
212
|
+
const glob = body.includes('*');
|
|
213
|
+
patterns.push({ pattern: body, dirOnly, anchored, glob });
|
|
214
|
+
}
|
|
215
|
+
return patterns;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function matchesIgnore(relPath, isDir, patterns) {
|
|
219
|
+
// relPath is POSIX-normalized, no leading slash, no './'.
|
|
220
|
+
const parts = relPath.split('/');
|
|
221
|
+
for (const p of patterns) {
|
|
222
|
+
if (p.dirOnly && !isDir) continue;
|
|
223
|
+
if (p.anchored) {
|
|
224
|
+
if (matchSegment(p, parts.join('/'))) return true;
|
|
225
|
+
} else {
|
|
226
|
+
// Unanchored: match the pattern against any segment (basename match)
|
|
227
|
+
// OR against any suffix of the path.
|
|
228
|
+
for (let i = 0; i < parts.length; i++) {
|
|
229
|
+
if (matchSegment(p, parts.slice(i).join('/'))) return true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function matchSegment(p, candidate) {
|
|
237
|
+
if (!p.glob) return p.pattern === candidate;
|
|
238
|
+
// Compile glob to regex on first use. Cache on the pattern object.
|
|
239
|
+
if (!p._re) {
|
|
240
|
+
const escaped = p.pattern
|
|
241
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
242
|
+
.replace(/\*/g, '[^/]*');
|
|
243
|
+
p._re = new RegExp(`^${escaped}$`);
|
|
244
|
+
}
|
|
245
|
+
return p._re.test(candidate);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function loadIgnorePatterns(cwd) {
|
|
249
|
+
const path = join(cwd, '.cannerignore');
|
|
250
|
+
if (!existsSync(path)) return [];
|
|
251
|
+
const text = await readFile(path, 'utf8');
|
|
252
|
+
return parseIgnoreFile(text);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Project config (canner.json) — what `canner init` writes and `canner
|
|
257
|
+
// deploy` reads. Currently just {slug}. Future fields: a `path` override
|
|
258
|
+
// for monorepos pointing at a subdir, an optional `before_upload` hook.
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
async function loadProjectConfig(cwd) {
|
|
261
|
+
const path = join(cwd, PROJECT_CONFIG_FILE);
|
|
262
|
+
if (!existsSync(path)) return null;
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
265
|
+
} catch (err) {
|
|
266
|
+
die(`could not parse ${PROJECT_CONFIG_FILE}: ${err.message}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function writeProjectConfig(cwd, cfg) {
|
|
271
|
+
await writeFile(join(cwd, PROJECT_CONFIG_FILE), JSON.stringify(cfg, null, 2) + '\n');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Commands
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
async function cmdLogin(_args) {
|
|
279
|
+
const rl = createInterface({ input, output });
|
|
280
|
+
const existing = await loadCredentials();
|
|
281
|
+
const apiBase = process.env.CANNER_API_BASE || existing.apiBase || DEFAULT_API_BASE;
|
|
282
|
+
info(bold('Sign in to Canner'));
|
|
283
|
+
info(`Create an API token at ${cyan(apiBase.replace('api.', '') + '/dashboard/account')}`);
|
|
284
|
+
info(`Then paste it below. It starts with ${dim('cnr_')}`);
|
|
285
|
+
const token = (await rl.question('token: ')).trim();
|
|
286
|
+
rl.close();
|
|
287
|
+
if (!token.startsWith('cnr_')) {
|
|
288
|
+
die("that doesn't look like a Canner API token (expected prefix 'cnr_').");
|
|
289
|
+
}
|
|
290
|
+
await saveCredentials({ apiBase, token });
|
|
291
|
+
// Verify by hitting /auth/me.
|
|
292
|
+
const me = await apiJSON({ apiBase, token }, 'GET', '/auth/me');
|
|
293
|
+
if (!me?.account) {
|
|
294
|
+
die("token didn't authenticate. Check it was copied correctly.");
|
|
295
|
+
}
|
|
296
|
+
info(`${green('Signed in as')} ${bold(me.account.email)}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function cmdWhoami() {
|
|
300
|
+
const creds = await loadCredentials();
|
|
301
|
+
if (!creds.token) {
|
|
302
|
+
die("not signed in. Run `canner login`.");
|
|
303
|
+
}
|
|
304
|
+
const me = await apiJSON(creds, 'GET', '/auth/me');
|
|
305
|
+
if (!me?.account) {
|
|
306
|
+
die('authenticated but /auth/me returned no account.');
|
|
307
|
+
}
|
|
308
|
+
// Projects count from /me/plan (cheap call we already have).
|
|
309
|
+
let plan = null;
|
|
310
|
+
try {
|
|
311
|
+
plan = await apiJSON(creds, 'GET', '/me/plan');
|
|
312
|
+
} catch {
|
|
313
|
+
// /me/plan is optional context; not fatal if it fails.
|
|
314
|
+
}
|
|
315
|
+
if (JSON_MODE) {
|
|
316
|
+
return emitJSON({ account: me.account, api_base: creds.apiBase, plan: plan ?? null });
|
|
317
|
+
}
|
|
318
|
+
info(`${bold(me.account.email)}`);
|
|
319
|
+
muted(`api: ${creds.apiBase}`);
|
|
320
|
+
if (plan?.plan) {
|
|
321
|
+
muted(
|
|
322
|
+
`plan: ${plan.plan.display_name} (${plan.project_count}/${plan.project_count_limit} projects)`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Remove the stored credential from this machine.
|
|
328
|
+
//
|
|
329
|
+
// `npm uninstall -g @canner-ca/cli` leaves ~/.canner/credentials behind — a
|
|
330
|
+
// live bearer token sitting in the user's home directory with no obvious way
|
|
331
|
+
// to clear it. That's the gap this closes: uninstalling should not leave a
|
|
332
|
+
// working credential on disk.
|
|
333
|
+
//
|
|
334
|
+
// Deliberately local-only. We delete the file we wrote; we do NOT revoke the
|
|
335
|
+
// token server-side, because the same token may be in use by CI, the MCP
|
|
336
|
+
// server, or another machine, and silently killing those from a `logout` on
|
|
337
|
+
// one laptop would be a surprising side effect. The output says so plainly and
|
|
338
|
+
// points at `canner token revoke` for people who do want it gone everywhere.
|
|
339
|
+
async function cmdLogout() {
|
|
340
|
+
const creds = await loadCredentials();
|
|
341
|
+
let removed = false;
|
|
342
|
+
try {
|
|
343
|
+
await unlink(CREDENTIALS_PATH);
|
|
344
|
+
removed = true;
|
|
345
|
+
} catch (err) {
|
|
346
|
+
// ENOENT just means there was nothing stored — that's a success, not an
|
|
347
|
+
// error. Anything else (EACCES, EPERM) the user needs to know about.
|
|
348
|
+
if (err?.code !== 'ENOENT') {
|
|
349
|
+
die(`could not remove ${CREDENTIALS_PATH}: ${err.message}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (JSON_MODE) {
|
|
353
|
+
return emitJSON({ removed, path: CREDENTIALS_PATH });
|
|
354
|
+
}
|
|
355
|
+
if (!removed) {
|
|
356
|
+
info('Nothing to remove — no stored credential on this machine.');
|
|
357
|
+
// A token in the environment survives logout, and a user who doesn't know
|
|
358
|
+
// that will think they've signed out when they haven't.
|
|
359
|
+
if (process.env.CANNER_TOKEN) {
|
|
360
|
+
muted('Note: CANNER_TOKEN is set in your environment and still applies.');
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
info(`${green('Signed out.')} Removed ${CREDENTIALS_PATH}`);
|
|
365
|
+
if (creds.token) {
|
|
366
|
+
muted('The token itself is still valid. To revoke it everywhere, run');
|
|
367
|
+
muted(` canner token revoke <name> ${dim('(or use the dashboard)')}`);
|
|
368
|
+
}
|
|
369
|
+
if (process.env.CANNER_TOKEN) {
|
|
370
|
+
muted('Note: CANNER_TOKEN is set in your environment and still applies.');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function cmdInit(args) {
|
|
375
|
+
const cwd = process.cwd();
|
|
376
|
+
const existing = await loadProjectConfig(cwd);
|
|
377
|
+
if (existing?.slug) {
|
|
378
|
+
die(`already initialized — ${PROJECT_CONFIG_FILE} has slug=${existing.slug}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const creds = await loadCredentials();
|
|
382
|
+
if (!creds.token) {
|
|
383
|
+
die("not signed in. Run `canner login` first.");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const rl = createInterface({ input, output });
|
|
387
|
+
const defaultName = basename(cwd);
|
|
388
|
+
const name = (await rl.question(`Project name [${defaultName}]: `)).trim() || defaultName;
|
|
389
|
+
const defaultSlug = sluggify(name);
|
|
390
|
+
const slug =
|
|
391
|
+
args.slug ||
|
|
392
|
+
(await rl.question(`Slug [${defaultSlug}]: `)).trim() ||
|
|
393
|
+
defaultSlug;
|
|
394
|
+
rl.close();
|
|
395
|
+
|
|
396
|
+
if (!/^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/.test(slug)) {
|
|
397
|
+
die(`invalid slug "${slug}" — must be 3–50 lowercase letters/digits/hyphens, no leading or trailing hyphen.`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Server validates uniqueness + reserved-slug rules; we just surface them.
|
|
401
|
+
const body = await apiJSON(creds, 'POST', '/projects', { name, slug });
|
|
402
|
+
if (!body) die('project creation returned no body.');
|
|
403
|
+
await writeProjectConfig(cwd, { slug });
|
|
404
|
+
info(`${green('Created')} ${bold(slug)}`);
|
|
405
|
+
muted(`Will be served at https://${slug}.canner.app after the first deploy.`);
|
|
406
|
+
muted(`Wrote ${PROJECT_CONFIG_FILE}. Run \`canner deploy\` to ship.`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Resolve a project slug from --slug flag → canner.json → error. Used by
|
|
410
|
+
// every command that needs one. Returns the slug string (never null).
|
|
411
|
+
async function resolveSlug(args) {
|
|
412
|
+
if (args.slug) return args.slug;
|
|
413
|
+
const cfg = await loadProjectConfig(process.cwd());
|
|
414
|
+
if (cfg?.slug) return cfg.slug;
|
|
415
|
+
die(`no project slug. Pass --slug=X or run \`canner init\` to create ${PROJECT_CONFIG_FILE}.`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Format an ISO timestamp as a short relative-time string (e.g. "2m ago",
|
|
419
|
+
// "3h ago", "5d ago"). Falls back to absolute YYYY-MM-DD for dates > 30d.
|
|
420
|
+
function relTime(iso) {
|
|
421
|
+
if (!iso) return dim('—');
|
|
422
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
423
|
+
const s = Math.floor(ms / 1000);
|
|
424
|
+
if (s < 60) return `${s}s ago`;
|
|
425
|
+
const m = Math.floor(s / 60);
|
|
426
|
+
if (m < 60) return `${m}m ago`;
|
|
427
|
+
const h = Math.floor(m / 60);
|
|
428
|
+
if (h < 24) return `${h}h ago`;
|
|
429
|
+
const d = Math.floor(h / 24);
|
|
430
|
+
if (d < 30) return `${d}d ago`;
|
|
431
|
+
return iso.slice(0, 10);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Right-pad a string to width N, ANSI-stripped for length math.
|
|
435
|
+
function pad(s, n) {
|
|
436
|
+
const visible = String(s).replace(/\x1b\[[0-9;]*m/g, '');
|
|
437
|
+
return String(s) + ' '.repeat(Math.max(0, n - visible.length));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Color a deployment status string for table output.
|
|
441
|
+
function colorStatus(s) {
|
|
442
|
+
if (s === 'live') return green(s);
|
|
443
|
+
if (s === 'failed') return red(s);
|
|
444
|
+
if (s === 'queued' || s === 'building' || s === 'deploying') return yellow(s);
|
|
445
|
+
return dim(s);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// canner projects — list owned + team projects
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
async function cmdProjects() {
|
|
452
|
+
const creds = await loadCredentials();
|
|
453
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
454
|
+
const body = await apiJSON(creds, 'GET', '/projects');
|
|
455
|
+
const rows = body?.projects ?? body ?? [];
|
|
456
|
+
if (JSON_MODE) return emitJSON(rows);
|
|
457
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
458
|
+
muted('no projects yet. Run `canner init` to create one.');
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
info(`${pad(bold('SLUG'), 24)}${pad(bold('STATUS'), 14)}${pad(bold('URL'), 40)}${bold('LAST REQUEST')}`);
|
|
462
|
+
for (const p of rows) {
|
|
463
|
+
const url = `https://${p.slug}.canner.app`;
|
|
464
|
+
const lastReq = relTime(p.last_request_at);
|
|
465
|
+
info(`${pad(p.slug, 24)}${pad(colorStatus(p.status), 14)}${pad(dim(url), 40)}${dim(lastReq)}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// canner status [--slug X] — single project detail
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
async function cmdStatus(args) {
|
|
473
|
+
const creds = await loadCredentials();
|
|
474
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
475
|
+
const slug = await resolveSlug(args);
|
|
476
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}`);
|
|
477
|
+
const p = body?.project ?? body;
|
|
478
|
+
if (!p?.slug) die('unexpected response from /projects/:slug');
|
|
479
|
+
if (JSON_MODE) return emitJSON(p);
|
|
480
|
+
info(`${bold(p.slug)} ${dim(`(${p.name ?? ''})`)}`);
|
|
481
|
+
info(` status: ${colorStatus(p.status)}${p.is_cold ? dim(' (cold)') : ''}`);
|
|
482
|
+
info(` url: ${cyan(`https://${p.slug}.canner.app`)}`);
|
|
483
|
+
info(` source: ${p.is_connected ? `${dim('github')} ${p.repo_url ?? ''} (branch ${p.repo_branch})` : dim('upload-only')}`);
|
|
484
|
+
info(` memory: ${p.memory_limit_mb}M${p.always_warm ? ` ${dim('(always-warm)')}` : ''}`);
|
|
485
|
+
info(` last request: ${relTime(p.last_request_at)}`);
|
|
486
|
+
info(` updated: ${relTime(p.updated_at)}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// canner deployments [--slug X] — list
|
|
491
|
+
// canner deployments <id> [--slug X] [--follow] — view one (optionally tail)
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
async function cmdDeployments(args) {
|
|
494
|
+
const creds = await loadCredentials();
|
|
495
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
496
|
+
const slug = await resolveSlug(args);
|
|
497
|
+
const id = args._[0];
|
|
498
|
+
if (id) return viewDeployment(creds, slug, id, args.follow === true);
|
|
499
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
500
|
+
const rows = body?.deployments ?? [];
|
|
501
|
+
if (JSON_MODE) return emitJSON(rows);
|
|
502
|
+
if (rows.length === 0) {
|
|
503
|
+
muted('no deployments yet.');
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
info(`${pad(bold('ID'), 12)}${pad(bold('STATUS'), 14)}${pad(bold('WHEN'), 14)}${pad(bold('DURATION'), 12)}${bold('COMMIT')}`);
|
|
507
|
+
for (const d of rows.slice(0, 20)) {
|
|
508
|
+
const sha = (d.commit_sha || '').slice(0, 7) || dim('—');
|
|
509
|
+
const msg = (d.commit_message || '').split('\n')[0].slice(0, 60);
|
|
510
|
+
const dur = d.build_duration_ms != null ? `${Math.round(d.build_duration_ms / 1000)}s` : dim('—');
|
|
511
|
+
const star = d.is_current ? green('●') : ' ';
|
|
512
|
+
info(`${star} ${pad(dim(d.id.slice(0, 8)), 10)}${pad(colorStatus(d.status), 14)}${pad(dim(relTime(d.created_at)), 14)}${pad(dur, 12)}${sha} ${dim(msg)}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function viewDeployment(creds, slug, idPrefix, follow) {
|
|
517
|
+
// Allow the user to pass an 8-char prefix — disambiguate against the list.
|
|
518
|
+
let fullId = idPrefix;
|
|
519
|
+
if (idPrefix.length < 36) {
|
|
520
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
521
|
+
const matches = (body?.deployments ?? []).filter((d) => d.id.startsWith(idPrefix));
|
|
522
|
+
if (matches.length === 0) die(`no deployment matching "${idPrefix}"`);
|
|
523
|
+
if (matches.length > 1) die(`ambiguous "${idPrefix}" — matches ${matches.length} deployments`);
|
|
524
|
+
fullId = matches[0].id;
|
|
525
|
+
}
|
|
526
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments/${fullId}`);
|
|
527
|
+
const d = body?.deployment;
|
|
528
|
+
if (!d) die('deployment not found');
|
|
529
|
+
if (JSON_MODE) return emitJSON(d);
|
|
530
|
+
info(`${bold(d.id.slice(0, 8))}${dim(d.id.slice(8))} ${colorStatus(d.status)}`);
|
|
531
|
+
if (d.commit_sha) info(` ${dim('commit')} ${d.commit_sha.slice(0, 12)} ${dim((d.commit_message || '').split('\n')[0])}`);
|
|
532
|
+
if (d.commit_author) info(` ${dim('author')} ${d.commit_author}`);
|
|
533
|
+
info(` ${dim('queued')} ${relTime(d.created_at)}`);
|
|
534
|
+
if (d.deployed_at) info(` ${dim('done')} ${relTime(d.deployed_at)}`);
|
|
535
|
+
if (d.error_message) info(` ${dim('error')} ${red(d.error_message)}`);
|
|
536
|
+
if (d.build_log) {
|
|
537
|
+
info(dim('─── build log ───────────────────────────────────'));
|
|
538
|
+
process.stdout.write(d.build_log);
|
|
539
|
+
if (!d.build_log.endsWith('\n')) process.stdout.write('\n');
|
|
540
|
+
}
|
|
541
|
+
if (follow && !['live', 'failed', 'superseded'].includes(d.status)) {
|
|
542
|
+
info(dim('─── streaming (still in progress) ────────────────'));
|
|
543
|
+
const code = await tailDeploymentLogs(creds, slug, fullId);
|
|
544
|
+
process.exit(code);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// canner logs [--slug X] [--lines N] [--follow]
|
|
550
|
+
// Stream the live tenant runtime logs via SSE. Without --follow, exits
|
|
551
|
+
// after the initial history burst settles (~1s quiet window).
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
async function cmdLogs(args) {
|
|
554
|
+
const creds = await loadCredentials();
|
|
555
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
556
|
+
const slug = await resolveSlug(args);
|
|
557
|
+
const lines = Number(args.lines) || 200;
|
|
558
|
+
const url = `${creds.apiBase}/projects/${encodeURIComponent(slug)}/runtime-logs?lines=${lines}`;
|
|
559
|
+
const res = await fetch(url, {
|
|
560
|
+
headers: { Authorization: `Bearer ${creds.token}`, Accept: 'text/event-stream' },
|
|
561
|
+
}).catch((err) => die(`could not open log stream: ${err.message}`));
|
|
562
|
+
if (!res.ok || !res.body) die(`log stream returned ${res.status} ${res.statusText}`);
|
|
563
|
+
|
|
564
|
+
const decoder = new TextDecoder('utf-8');
|
|
565
|
+
let buffer = '';
|
|
566
|
+
let quietTimer = null;
|
|
567
|
+
const armQuiet = () => {
|
|
568
|
+
if (!args.follow) {
|
|
569
|
+
clearTimeout(quietTimer);
|
|
570
|
+
// 1.5s with no new data after the history burst → assume we're caught up.
|
|
571
|
+
quietTimer = setTimeout(() => process.exit(0), 1500);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
armQuiet();
|
|
575
|
+
|
|
576
|
+
for await (const chunk of res.body) {
|
|
577
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
578
|
+
let idx;
|
|
579
|
+
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
580
|
+
const raw = buffer.slice(0, idx);
|
|
581
|
+
buffer = buffer.slice(idx + 2);
|
|
582
|
+
if (raw.startsWith(':')) continue; // heartbeat
|
|
583
|
+
let event = 'message';
|
|
584
|
+
let data = '';
|
|
585
|
+
for (const line of raw.split('\n')) {
|
|
586
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
587
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
588
|
+
}
|
|
589
|
+
if (!data) continue;
|
|
590
|
+
let parsed;
|
|
591
|
+
try { parsed = JSON.parse(data); } catch { continue; }
|
|
592
|
+
if (event === 'log') {
|
|
593
|
+
// runtime-logs uses {line:...}, build-logs uses {log:...} — handle both.
|
|
594
|
+
const text = parsed.line ?? parsed.log;
|
|
595
|
+
if (text) process.stdout.write(text + (text.endsWith('\n') ? '' : '\n'));
|
|
596
|
+
armQuiet();
|
|
597
|
+
} else if (event === 'done') {
|
|
598
|
+
process.exit(0);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
// canner cancel [<id>] [--slug X]
|
|
606
|
+
// Cancel an in-flight build. With no id, finds the latest non-terminal
|
|
607
|
+
// deployment for the project and cancels it.
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
async function cmdCancel(args) {
|
|
610
|
+
const creds = await loadCredentials();
|
|
611
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
612
|
+
const slug = await resolveSlug(args);
|
|
613
|
+
let id = args._[0];
|
|
614
|
+
if (!id) {
|
|
615
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
616
|
+
const inflight = (body?.deployments ?? []).find((d) =>
|
|
617
|
+
['queued', 'building', 'deploying'].includes(d.status)
|
|
618
|
+
);
|
|
619
|
+
if (!inflight) die('no in-flight deployment to cancel. Pass an id to cancel a specific one.');
|
|
620
|
+
id = inflight.id;
|
|
621
|
+
} else if (id.length < 36) {
|
|
622
|
+
const body = await apiJSON(creds, 'GET', `/projects/${encodeURIComponent(slug)}/deployments`);
|
|
623
|
+
const matches = (body?.deployments ?? []).filter((d) => d.id.startsWith(id));
|
|
624
|
+
if (matches.length === 0) die(`no deployment matching "${id}"`);
|
|
625
|
+
if (matches.length > 1) die(`ambiguous "${id}" — matches ${matches.length} deployments`);
|
|
626
|
+
id = matches[0].id;
|
|
627
|
+
}
|
|
628
|
+
await apiJSON(creds, 'POST', `/projects/${encodeURIComponent(slug)}/deployments/${id}/cancel`);
|
|
629
|
+
if (JSON_MODE) return emitJSON({ ok: true, cancelled: id });
|
|
630
|
+
info(`${green('cancelled')} ${dim(id.slice(0, 8))}`);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
// canner token — list API tokens
|
|
635
|
+
// canner token create <name> — mint a token (plaintext printed once)
|
|
636
|
+
// canner token revoke <id|prefix> — revoke a token
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
async function cmdToken(args) {
|
|
639
|
+
const creds = await loadCredentials();
|
|
640
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
641
|
+
const sub = args._[0] ?? 'list';
|
|
642
|
+
|
|
643
|
+
if (sub === 'list') {
|
|
644
|
+
const body = await apiJSON(creds, 'GET', '/tokens');
|
|
645
|
+
const rows = body?.tokens ?? [];
|
|
646
|
+
if (JSON_MODE) return emitJSON(rows);
|
|
647
|
+
if (rows.length === 0) { muted('no API tokens. Create one with `canner token create <name>`.'); return; }
|
|
648
|
+
info(`${pad(bold('ID'), 12)}${pad(bold('NAME'), 28)}${pad(bold('PREFIX'), 18)}${bold('LAST USED')}`);
|
|
649
|
+
for (const t of rows) {
|
|
650
|
+
info(`${pad(dim(t.id.slice(0, 8)), 12)}${pad(t.name ?? dim('—'), 28)}${pad(dim(`${t.prefix}…`), 18)}${dim(relTime(t.last_used_at))}`);
|
|
651
|
+
}
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (sub === 'create') {
|
|
656
|
+
const name = args._[1] || (typeof args.name === 'string' ? args.name : null);
|
|
657
|
+
if (!name) die('usage: canner token create <name>', 2);
|
|
658
|
+
const tok = await apiJSON(creds, 'POST', '/tokens', { name });
|
|
659
|
+
if (!tok?.token) die('token creation returned no secret.');
|
|
660
|
+
if (JSON_MODE) return emitJSON(tok);
|
|
661
|
+
info(`${green('created')} ${bold(tok.name)}`);
|
|
662
|
+
info(tok.token);
|
|
663
|
+
muted('Copy this now — the secret is not shown again.');
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (sub === 'revoke') {
|
|
668
|
+
const ref = args._[1];
|
|
669
|
+
if (!ref) die('usage: canner token revoke <id|prefix|name>', 2);
|
|
670
|
+
const id = await resolveTokenId(creds, ref);
|
|
671
|
+
await apiJSON(creds, 'DELETE', `/tokens/${encodeURIComponent(id)}`);
|
|
672
|
+
if (JSON_MODE) return emitJSON({ ok: true, revoked: id });
|
|
673
|
+
info(`${green('revoked')} ${dim(id.slice(0, 8))}`);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
die(`unknown token subcommand "${sub}". Use: list | create | revoke.`, 2);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function resolveTokenId(creds, ref) {
|
|
681
|
+
if (ref.length >= 36) return ref; // full UUID
|
|
682
|
+
const body = await apiJSON(creds, 'GET', '/tokens');
|
|
683
|
+
const rows = body?.tokens ?? [];
|
|
684
|
+
const matches = rows.filter(
|
|
685
|
+
(t) => t.id.startsWith(ref) || t.name === ref || (t.prefix && t.prefix.startsWith(ref))
|
|
686
|
+
);
|
|
687
|
+
if (matches.length === 0) die(`no token matching "${ref}"`);
|
|
688
|
+
if (matches.length > 1) die(`ambiguous "${ref}" — matches ${matches.length} tokens`);
|
|
689
|
+
return matches[0].id;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
// canner env [--reveal] [--slug X] — list env vars
|
|
694
|
+
// (sensitive values redacted by default, in text AND --json; --reveal opts out)
|
|
695
|
+
// canner env set KEY=VALUE [--env production|preview] [--sensitive] [--slug X]
|
|
696
|
+
// canner env rm <KEY|id> [--env ...] [--slug X] — delete an env var
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
async function cmdEnv(args) {
|
|
699
|
+
const creds = await loadCredentials();
|
|
700
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
701
|
+
const slug = await resolveSlug(args);
|
|
702
|
+
const sub = args._[0] ?? 'list';
|
|
703
|
+
const base = `/projects/${encodeURIComponent(slug)}/env-vars`;
|
|
704
|
+
|
|
705
|
+
if (sub === 'list') {
|
|
706
|
+
const body = await apiJSON(creds, 'GET', base);
|
|
707
|
+
const rows = body?.env_vars ?? [];
|
|
708
|
+
// Sensitive values are redacted by default in BOTH output modes — the API
|
|
709
|
+
// returns them decrypted (the owner can read their own secrets back), but
|
|
710
|
+
// a plain `env list` (human or --json) is a read-only config check, not a
|
|
711
|
+
// secret-reveal action. --reveal is the explicit opt-in for scripts that
|
|
712
|
+
// actually need the value (e.g. syncing it elsewhere).
|
|
713
|
+
const reveal = args.reveal === true;
|
|
714
|
+
if (JSON_MODE) {
|
|
715
|
+
return emitJSON(
|
|
716
|
+
reveal ? rows : rows.map((v) => (v.is_sensitive ? { ...v, value: null } : v))
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
if (rows.length === 0) { muted('no environment variables.'); return; }
|
|
720
|
+
info(`${pad(bold('KEY'), 28)}${pad(bold('ENV'), 14)}${pad(bold('SENSITIVE'), 12)}${bold('VALUE')}`);
|
|
721
|
+
for (const v of rows) {
|
|
722
|
+
const val = v.is_sensitive && !reveal ? dim('••••••') : (v.value ?? '').split('\n')[0].slice(0, 48);
|
|
723
|
+
info(`${pad(v.key, 28)}${pad(dim(v.environment ?? 'all'), 14)}${pad(v.is_sensitive ? yellow('yes') : dim('no'), 12)}${dim(val)}`);
|
|
724
|
+
}
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (sub === 'set') {
|
|
729
|
+
const kv = args._[1];
|
|
730
|
+
if (!kv || !kv.includes('=')) die('usage: canner env set KEY=VALUE', 2);
|
|
731
|
+
const eq = kv.indexOf('=');
|
|
732
|
+
const key = kv.slice(0, eq);
|
|
733
|
+
const value = kv.slice(eq + 1);
|
|
734
|
+
const environment = normalizeEnvScope(args.env);
|
|
735
|
+
const isSensitive = args.sensitive === true;
|
|
736
|
+
const result = await apiJSON(creds, 'POST', base, {
|
|
737
|
+
key, value, environment, is_sensitive: isSensitive,
|
|
738
|
+
});
|
|
739
|
+
if (JSON_MODE) return emitJSON(result);
|
|
740
|
+
if ((result?.created ?? []).length > 0) {
|
|
741
|
+
info(`${green('set')} ${bold(key)}${environment ? dim(` (${environment})`) : ''}`);
|
|
742
|
+
} else {
|
|
743
|
+
// A duplicate (same key+scope) is skipped, not overwritten — surface that
|
|
744
|
+
// so the user isn't surprised the value didn't change.
|
|
745
|
+
die(`${key} already exists for that environment. Delete it first (canner env rm ${key}), then set again.`);
|
|
746
|
+
}
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
|
|
751
|
+
const ref = args._[1];
|
|
752
|
+
if (!ref) die('usage: canner env rm <KEY|id>', 2);
|
|
753
|
+
const id = await resolveEnvVarId(creds, base, ref, normalizeEnvScope(args.env));
|
|
754
|
+
await apiJSON(creds, 'DELETE', `${base}/${encodeURIComponent(id)}`);
|
|
755
|
+
if (JSON_MODE) return emitJSON({ ok: true, deleted: id });
|
|
756
|
+
info(`${green('deleted')} ${bold(ref)}`);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
die(`unknown env subcommand "${sub}". Use: list | set | rm.`, 2);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Map a --env flag to the API's environment field. Accepts production/preview;
|
|
764
|
+
// anything else (or absent) means "all environments" (null).
|
|
765
|
+
function normalizeEnvScope(flag) {
|
|
766
|
+
if (flag === 'production' || flag === 'preview') return flag;
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function resolveEnvVarId(creds, base, ref, environment) {
|
|
771
|
+
if (ref.length >= 36 && ref.includes('-')) return ref; // looks like a UUID
|
|
772
|
+
const body = await apiJSON(creds, 'GET', base);
|
|
773
|
+
const rows = body?.env_vars ?? [];
|
|
774
|
+
const matches = rows.filter((v) => {
|
|
775
|
+
if (v.id.startsWith(ref)) return true;
|
|
776
|
+
if (v.key !== ref) return false;
|
|
777
|
+
// If a scope was given, narrow to it; otherwise match any scope.
|
|
778
|
+
return environment === null ? true : v.environment === environment;
|
|
779
|
+
});
|
|
780
|
+
if (matches.length === 0) die(`no env var matching "${ref}"`);
|
|
781
|
+
if (matches.length > 1) die(`ambiguous "${ref}" — matches ${matches.length} vars (pass --env to disambiguate)`);
|
|
782
|
+
return matches[0].id;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
// canner domains [--slug X] — list custom domains
|
|
787
|
+
// canner domains add <hostname> [--slug X] — attach a domain (pending)
|
|
788
|
+
// canner domains verify <host|id> [--slug X] — re-check DNS + provision TLS
|
|
789
|
+
// canner domains rm <host|id> [--slug X] — detach a domain
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
async function cmdDomains(args) {
|
|
792
|
+
const creds = await loadCredentials();
|
|
793
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
794
|
+
const slug = await resolveSlug(args);
|
|
795
|
+
const sub = args._[0] ?? 'list';
|
|
796
|
+
const base = `/projects/${encodeURIComponent(slug)}/domains`;
|
|
797
|
+
|
|
798
|
+
if (sub === 'list') {
|
|
799
|
+
const body = await apiJSON(creds, 'GET', base);
|
|
800
|
+
const rows = body?.domains ?? [];
|
|
801
|
+
if (JSON_MODE) return emitJSON(rows);
|
|
802
|
+
if (rows.length === 0) {
|
|
803
|
+
muted(`no custom domains. Add one with \`canner domains add <hostname>\`.`);
|
|
804
|
+
if (body?.platform_ip) muted(`Point an A record at ${body.platform_ip}, then run \`canner domains verify\`.`);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
info(`${pad(bold('HOSTNAME'), 36)}${pad(bold('STATUS'), 16)}${bold('VERIFIED')}`);
|
|
808
|
+
for (const d of rows) {
|
|
809
|
+
info(`${pad(d.hostname, 36)}${pad(colorDomainStatus(d.status), 16)}${dim(relTime(d.verified_at))}`);
|
|
810
|
+
}
|
|
811
|
+
if (body?.platform_ip) muted(`A record target: ${body.platform_ip}`);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (sub === 'add') {
|
|
816
|
+
const hostname = args._[1];
|
|
817
|
+
if (!hostname) die('usage: canner domains add <hostname>', 2);
|
|
818
|
+
const body = await apiJSON(creds, 'POST', base, { hostname });
|
|
819
|
+
if (JSON_MODE) return emitJSON(body);
|
|
820
|
+
const d = body?.domain ?? {};
|
|
821
|
+
info(`${green('added')} ${bold(d.hostname ?? hostname)} ${dim(`(${d.status ?? 'pending'})`)}`);
|
|
822
|
+
if (body?.platform_ip) muted(`Point an A record at ${body.platform_ip}, then run \`canner domains verify ${hostname}\`.`);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (sub === 'verify') {
|
|
827
|
+
const ref = args._[1];
|
|
828
|
+
if (!ref) die('usage: canner domains verify <hostname|id>', 2);
|
|
829
|
+
const id = await resolveDomainId(creds, base, ref);
|
|
830
|
+
const body = await apiJSON(creds, 'POST', `${base}/${encodeURIComponent(id)}/verify`);
|
|
831
|
+
if (JSON_MODE) return emitJSON(body);
|
|
832
|
+
const d = body?.domain ?? {};
|
|
833
|
+
info(`${green('verified')} ${bold(d.hostname ?? ref)} ${dim(`(${d.status ?? 'verified'})`)}`);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
|
|
838
|
+
const ref = args._[1];
|
|
839
|
+
if (!ref) die('usage: canner domains rm <hostname|id>', 2);
|
|
840
|
+
const id = await resolveDomainId(creds, base, ref);
|
|
841
|
+
await apiJSON(creds, 'DELETE', `${base}/${encodeURIComponent(id)}`);
|
|
842
|
+
if (JSON_MODE) return emitJSON({ ok: true, deleted: id });
|
|
843
|
+
info(`${green('removed')} ${bold(ref)}`);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
die(`unknown domains subcommand "${sub}". Use: list | add | verify | rm.`, 2);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function colorDomainStatus(s) {
|
|
851
|
+
if (s === 'verified') return green(s);
|
|
852
|
+
if (s === 'failed') return red(s);
|
|
853
|
+
if (s === 'pending') return yellow(s);
|
|
854
|
+
return dim(s);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function resolveDomainId(creds, base, ref) {
|
|
858
|
+
if (ref.length >= 36 && ref.includes('-')) return ref; // UUID
|
|
859
|
+
const body = await apiJSON(creds, 'GET', base);
|
|
860
|
+
const rows = body?.domains ?? [];
|
|
861
|
+
const matches = rows.filter((d) => d.id.startsWith(ref) || d.hostname === ref);
|
|
862
|
+
if (matches.length === 0) die(`no domain matching "${ref}"`);
|
|
863
|
+
if (matches.length > 1) die(`ambiguous "${ref}" — matches ${matches.length} domains`);
|
|
864
|
+
return matches[0].id;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ---------------------------------------------------------------------------
|
|
868
|
+
// canner open [--slug X] [--dashboard]
|
|
869
|
+
// Open the tenant URL (default) or the dashboard project page in the
|
|
870
|
+
// user's default browser.
|
|
871
|
+
// ---------------------------------------------------------------------------
|
|
872
|
+
async function cmdOpen(args) {
|
|
873
|
+
const slug = await resolveSlug(args);
|
|
874
|
+
// Strict slug validation — never trust user input as part of a shell arg
|
|
875
|
+
// even though the runner below uses argv (not bash -c). Belt and braces.
|
|
876
|
+
if (!/^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/.test(slug)) {
|
|
877
|
+
die(`invalid slug "${slug}"`);
|
|
878
|
+
}
|
|
879
|
+
const creds = await loadCredentials();
|
|
880
|
+
const dashboardOrigin = (creds.apiBase || DEFAULT_API_BASE).replace(/^https?:\/\/api\./, 'https://');
|
|
881
|
+
const url = args.dashboard
|
|
882
|
+
? `${dashboardOrigin}/dashboard/projects/${slug}`
|
|
883
|
+
: `https://${slug}.canner.app`;
|
|
884
|
+
info(`opening ${dim(url)}`);
|
|
885
|
+
const { spawn } = await import('node:child_process');
|
|
886
|
+
const platform = process.platform;
|
|
887
|
+
let cmd, cmdArgs;
|
|
888
|
+
if (platform === 'win32') { cmd = 'cmd'; cmdArgs = ['/c', 'start', '""', url]; }
|
|
889
|
+
else if (platform === 'darwin') { cmd = 'open'; cmdArgs = [url]; }
|
|
890
|
+
else { cmd = 'xdg-open'; cmdArgs = [url]; }
|
|
891
|
+
const child = spawn(cmd, cmdArgs, { stdio: 'ignore', detached: true });
|
|
892
|
+
child.on('error', () => die(`could not launch browser (${cmd} not found)`));
|
|
893
|
+
child.unref();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
// canner redeploy [--slug X] [--follow]
|
|
898
|
+
// Trigger a rebuild from the GitHub repo's current HEAD — no local upload.
|
|
899
|
+
// Only works for projects that are GitHub-connected via the Canner Deploy
|
|
900
|
+
// GitHub App. For upload-only projects, use `canner deploy` instead.
|
|
901
|
+
// ---------------------------------------------------------------------------
|
|
902
|
+
async function cmdRedeploy(args) {
|
|
903
|
+
const creds = await loadCredentials();
|
|
904
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
905
|
+
const slug = await resolveSlug(args);
|
|
906
|
+
const res = await apiRequest(creds, 'POST', `/projects/${encodeURIComponent(slug)}/deploy`);
|
|
907
|
+
const body = await res.json();
|
|
908
|
+
const deploymentId = body.deployment_id;
|
|
909
|
+
if (!deploymentId) die('redeploy returned no deployment_id.');
|
|
910
|
+
if (JSON_MODE) return emitJSON({ deployment_id: deploymentId, status: body.status ?? 'queued', slug });
|
|
911
|
+
info(`${green('queued')} deployment ${dim(deploymentId.slice(0, 8))} ${dim('(rebuild from GitHub HEAD)')}`);
|
|
912
|
+
if (!args.follow) {
|
|
913
|
+
muted(`Tail with: canner deployments ${deploymentId.slice(0, 8)} --follow`);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const exitCode = await tailDeploymentLogs(creds, slug, deploymentId);
|
|
917
|
+
process.exit(exitCode);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async function cmdDeploy(args) {
|
|
921
|
+
const projectPath = resolve(args._[0] || '.');
|
|
922
|
+
if (!existsSync(projectPath)) {
|
|
923
|
+
die(`path not found: ${projectPath}`);
|
|
924
|
+
}
|
|
925
|
+
const cwdStat = await stat(projectPath);
|
|
926
|
+
if (!cwdStat.isDirectory()) {
|
|
927
|
+
die(`path is not a directory: ${projectPath}`);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const cfg = await loadProjectConfig(projectPath);
|
|
931
|
+
const slug = args.slug || cfg?.slug;
|
|
932
|
+
if (!slug) {
|
|
933
|
+
die(`no project slug. Pass --slug=X or run \`canner init\` to create ${PROJECT_CONFIG_FILE}.`);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const creds = await loadCredentials();
|
|
937
|
+
if (!creds.token) {
|
|
938
|
+
die("not signed in. Run `canner login`.");
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Build the ignore patterns: hard excludes + user .cannerignore.
|
|
942
|
+
const userPatterns = await loadIgnorePatterns(projectPath);
|
|
943
|
+
|
|
944
|
+
// Build the tarball as a temp file. We use a temp file (not piping directly
|
|
945
|
+
// into the upload) so we know the final size before POST + so a tar error
|
|
946
|
+
// surfaces cleanly before we touch the network.
|
|
947
|
+
const tarPath = join(tmpdir(), `canner-${randomUUID()}.tar.gz`);
|
|
948
|
+
let fileCount = 0;
|
|
949
|
+
info(`${dim('packaging')} ${projectPath}`);
|
|
950
|
+
try {
|
|
951
|
+
await tarCreate(
|
|
952
|
+
{
|
|
953
|
+
gzip: true,
|
|
954
|
+
file: tarPath,
|
|
955
|
+
cwd: projectPath,
|
|
956
|
+
// Strict portable-file-only — refuse symlinks pointing outside the
|
|
957
|
+
// project, etc. Matches what the server-side safe-extractor enforces.
|
|
958
|
+
portable: true,
|
|
959
|
+
filter: (path) => {
|
|
960
|
+
// path is relative to cwd, POSIX-normalized by tar.
|
|
961
|
+
const rel = path.replace(/^\.\//, '').replace(/^\/+/, '');
|
|
962
|
+
if (!rel) return true; // root
|
|
963
|
+
const parts = rel.split('/');
|
|
964
|
+
// Hard excludes match by basename anywhere in the tree.
|
|
965
|
+
for (const part of parts) {
|
|
966
|
+
if (HARD_EXCLUDES.has(part)) return false;
|
|
967
|
+
}
|
|
968
|
+
// User .cannerignore.
|
|
969
|
+
const isDir = path.endsWith('/');
|
|
970
|
+
if (matchesIgnore(rel, isDir, userPatterns)) return false;
|
|
971
|
+
fileCount++;
|
|
972
|
+
return true;
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
['./']
|
|
976
|
+
);
|
|
977
|
+
} catch (err) {
|
|
978
|
+
await unlink(tarPath).catch(() => undefined);
|
|
979
|
+
die(`tar failed: ${err.message}`);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const tarStat = await stat(tarPath);
|
|
983
|
+
const sizeMb = (tarStat.size / 1024 / 1024).toFixed(2);
|
|
984
|
+
info(`${dim('packaged')} ${fileCount} files (${sizeMb} MB compressed)`);
|
|
985
|
+
|
|
986
|
+
// Upload. fs.openAsBlob() backs the Blob by the on-disk file lazily —
|
|
987
|
+
// bytes only flow when fetch consumes them, so a 500 MB Live upload
|
|
988
|
+
// doesn't buffer into RAM on the user's machine. Node fetch has no
|
|
989
|
+
// upload-progress hook as of 2026, so we just print the size we're
|
|
990
|
+
// sending; the dashboard shows real-time progress for browser uploads.
|
|
991
|
+
info(`${dim('uploading')} to ${slug}…`);
|
|
992
|
+
const form = new FormData();
|
|
993
|
+
const blob = await openAsBlob(tarPath, { type: 'application/gzip' });
|
|
994
|
+
form.append('archive', blob, `${slug}.tar.gz`);
|
|
995
|
+
|
|
996
|
+
let response;
|
|
997
|
+
try {
|
|
998
|
+
response = await apiRequest(creds, 'POST', `/projects/${slug}/upload`, { body: form });
|
|
999
|
+
} finally {
|
|
1000
|
+
await unlink(tarPath).catch(() => undefined);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const body = await response.json();
|
|
1004
|
+
const deploymentId = body.deployment_id;
|
|
1005
|
+
if (!deploymentId) {
|
|
1006
|
+
die('upload succeeded but the response had no deployment_id.');
|
|
1007
|
+
}
|
|
1008
|
+
if (JSON_MODE) {
|
|
1009
|
+
// JSON mode never streams the SSE build log (it would corrupt the
|
|
1010
|
+
// single-value contract). Emit the queued result; the caller can poll
|
|
1011
|
+
// `canner deployments <id> --json` for terminal status.
|
|
1012
|
+
return emitJSON({ deployment_id: deploymentId, status: body.status ?? 'queued', slug, files: fileCount, size_mb: Number(sizeMb) });
|
|
1013
|
+
}
|
|
1014
|
+
info(`${green('queued')} deployment ${dim(deploymentId.slice(0, 8))}`);
|
|
1015
|
+
|
|
1016
|
+
if (!args.follow) {
|
|
1017
|
+
muted(`Tail with: canner deploy --follow --slug=${slug} (or visit the dashboard)`);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// SSE tail until terminal.
|
|
1022
|
+
const exitCode = await tailDeploymentLogs(creds, slug, deploymentId);
|
|
1023
|
+
process.exit(exitCode);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ---------------------------------------------------------------------------
|
|
1027
|
+
// SSE consumer for /projects/:slug/deployments/:id/logs.
|
|
1028
|
+
// Parses the wire format manually (no eventsource dep). Returns exit code:
|
|
1029
|
+
// 0 if status ended at 'live', 1 if 'failed', 2 if connection died early.
|
|
1030
|
+
// ---------------------------------------------------------------------------
|
|
1031
|
+
async function tailDeploymentLogs(creds, slug, deploymentId) {
|
|
1032
|
+
info(dim(`streaming build log…`));
|
|
1033
|
+
const url = `${creds.apiBase}/projects/${slug}/deployments/${deploymentId}/logs`;
|
|
1034
|
+
const res = await fetch(url, {
|
|
1035
|
+
headers: {
|
|
1036
|
+
Authorization: `Bearer ${creds.token}`,
|
|
1037
|
+
Accept: 'text/event-stream',
|
|
1038
|
+
},
|
|
1039
|
+
}).catch((err) => {
|
|
1040
|
+
die(`could not open log stream: ${err.message}`);
|
|
1041
|
+
});
|
|
1042
|
+
if (!res.ok || !res.body) {
|
|
1043
|
+
die(`log stream returned ${res.status} ${res.statusText}`);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const decoder = new TextDecoder('utf-8');
|
|
1047
|
+
let buffer = '';
|
|
1048
|
+
let lastStatus = 'queued';
|
|
1049
|
+
let printedHistory = false;
|
|
1050
|
+
|
|
1051
|
+
for await (const chunk of res.body) {
|
|
1052
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
1053
|
+
let idx;
|
|
1054
|
+
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
1055
|
+
const raw = buffer.slice(0, idx);
|
|
1056
|
+
buffer = buffer.slice(idx + 2);
|
|
1057
|
+
// Skip heartbeat comments (": ping").
|
|
1058
|
+
if (raw.startsWith(':')) continue;
|
|
1059
|
+
let event = 'message';
|
|
1060
|
+
let data = '';
|
|
1061
|
+
for (const line of raw.split('\n')) {
|
|
1062
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
1063
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
1064
|
+
}
|
|
1065
|
+
if (!data) continue;
|
|
1066
|
+
let parsed;
|
|
1067
|
+
try { parsed = JSON.parse(data); } catch { continue; }
|
|
1068
|
+
if (event === 'history') {
|
|
1069
|
+
if (!printedHistory && parsed.log) {
|
|
1070
|
+
process.stdout.write(parsed.log);
|
|
1071
|
+
}
|
|
1072
|
+
printedHistory = true;
|
|
1073
|
+
} else if (event === 'log') {
|
|
1074
|
+
if (parsed.log) process.stdout.write(parsed.log);
|
|
1075
|
+
} else if (event === 'status') {
|
|
1076
|
+
lastStatus = parsed.status ?? lastStatus;
|
|
1077
|
+
} else if (event === 'done') {
|
|
1078
|
+
lastStatus = parsed.status ?? lastStatus;
|
|
1079
|
+
break;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (lastStatus === 'live') {
|
|
1085
|
+
info(`\n${green('live')} at https://${slug}.canner.app`);
|
|
1086
|
+
return 0;
|
|
1087
|
+
}
|
|
1088
|
+
if (lastStatus === 'failed') {
|
|
1089
|
+
info(`\n${red('failed')} (status=${lastStatus})`);
|
|
1090
|
+
return 1;
|
|
1091
|
+
}
|
|
1092
|
+
info(`\n${yellow('stream ended')} (status=${lastStatus})`);
|
|
1093
|
+
return 2;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// ---------------------------------------------------------------------------
|
|
1097
|
+
// Slug derivation mirrors the server's regex: ^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$
|
|
1098
|
+
// ---------------------------------------------------------------------------
|
|
1099
|
+
function sluggify(s) {
|
|
1100
|
+
return s
|
|
1101
|
+
.toLowerCase()
|
|
1102
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
1103
|
+
.replace(/^-+|-+$/g, '')
|
|
1104
|
+
.replace(/-+/g, '-')
|
|
1105
|
+
.slice(0, 50);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
// Argv parser. Tiny, no dep. Supports:
|
|
1110
|
+
// canner cmd → { _: [], cmd }
|
|
1111
|
+
// canner cmd value → { _: ['value'] }
|
|
1112
|
+
// canner cmd --flag → { flag: true }
|
|
1113
|
+
// canner cmd --key value → { key: 'value' }
|
|
1114
|
+
// canner cmd --key=value → { key: 'value' }
|
|
1115
|
+
// canner cmd -v → { v: true }
|
|
1116
|
+
// ---------------------------------------------------------------------------
|
|
1117
|
+
// Flags that never take a value, so `canner db import --replace dump.dump` keeps
|
|
1118
|
+
// dump.dump as the positional argument instead of reading it as --replace's value.
|
|
1119
|
+
const BOOLEAN_FLAGS = new Set([
|
|
1120
|
+
'json', 'reveal', 'sensitive', 'follow', 'dashboard', 'version', 'help',
|
|
1121
|
+
'replace', 'yes', 'no-wait', 'enable', 'disable', 'restore', 'download', 'rm', 'delete', 'instance', 'pitr',
|
|
1122
|
+
]);
|
|
1123
|
+
|
|
1124
|
+
// A flag repeated on the command line (e.g. `--allow a --allow b`) accumulates
|
|
1125
|
+
// into an array instead of the last one silently winning.
|
|
1126
|
+
function setArg(out, key, value) {
|
|
1127
|
+
if (key in out) {
|
|
1128
|
+
out[key] = Array.isArray(out[key]) ? [...out[key], value] : [out[key], value];
|
|
1129
|
+
} else {
|
|
1130
|
+
out[key] = value;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function parseArgs(argv) {
|
|
1135
|
+
const out = { _: [] };
|
|
1136
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1137
|
+
const a = argv[i];
|
|
1138
|
+
if (a.startsWith('--')) {
|
|
1139
|
+
const eq = a.indexOf('=');
|
|
1140
|
+
if (eq !== -1) {
|
|
1141
|
+
setArg(out, a.slice(2, eq), a.slice(eq + 1));
|
|
1142
|
+
} else {
|
|
1143
|
+
const key = a.slice(2);
|
|
1144
|
+
const next = argv[i + 1];
|
|
1145
|
+
if (next && !next.startsWith('-') && !BOOLEAN_FLAGS.has(key)) {
|
|
1146
|
+
setArg(out, key, next);
|
|
1147
|
+
i++;
|
|
1148
|
+
} else {
|
|
1149
|
+
setArg(out, key, true);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
} else if (a.startsWith('-') && a.length === 2) {
|
|
1153
|
+
out[a.slice(1)] = true;
|
|
1154
|
+
} else {
|
|
1155
|
+
out._.push(a);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return out;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// ---------------------------------------------------------------------------
|
|
1162
|
+
// canner db … — project databases
|
|
1163
|
+
//
|
|
1164
|
+
// canner db [list] [--slug X] list the project's databases
|
|
1165
|
+
// canner db init [--pg 17|18] [--slug X] provision the primary database
|
|
1166
|
+
// canner db create <name> [--pg 17|18] add another database (paid plans)
|
|
1167
|
+
// canner db info [<name>] [--reveal] connection details (password hidden by default)
|
|
1168
|
+
// canner db url [<name>] print just the connection string
|
|
1169
|
+
// canner db rm <name> --yes delete an additional database
|
|
1170
|
+
// canner db pg-version <name> <17|18> --yes move a database to another Postgres version
|
|
1171
|
+
// canner db export [<name>] [--format custom|plain] [--out FILE]
|
|
1172
|
+
// canner db import <file> [--to <name>] [--replace] restore a pg_dump custom-format file
|
|
1173
|
+
// canner db migrate-in [--to <name>] --from <url>|--from-env VAR|--from - [--replace]
|
|
1174
|
+
// canner db migrate-out [--from <name>] --to-url <url>|--to-env VAR|--to-url - --yes
|
|
1175
|
+
// canner db copy <from> <to> [--to-project SLUG] [--replace]
|
|
1176
|
+
// canner db ops [<id>] recent operations / one operation
|
|
1177
|
+
//
|
|
1178
|
+
// Long operations (export, import, migrate, copy) run on the platform: the CLI
|
|
1179
|
+
// starts one, then waits and shows progress. Pass --no-wait to return the
|
|
1180
|
+
// operation id immediately (poll with `canner db ops <id>`).
|
|
1181
|
+
// ---------------------------------------------------------------------------
|
|
1182
|
+
const DB_OP_POLL_MS = 2000;
|
|
1183
|
+
|
|
1184
|
+
function dbBase(slug) {
|
|
1185
|
+
return `/projects/${encodeURIComponent(slug)}`;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function fmtBytes(n) {
|
|
1189
|
+
if (n == null) return '—';
|
|
1190
|
+
const u = ['B', 'KB', 'MB', 'GB'];
|
|
1191
|
+
let i = 0;
|
|
1192
|
+
let v = Number(n);
|
|
1193
|
+
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
|
1194
|
+
return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// A connection URL is a secret: never take it from a positional argument that
|
|
1198
|
+
// ends up in shell history unless the user insists. --from/--to-url take a URL,
|
|
1199
|
+
// --from-env/--to-env read an environment variable, and "-" reads one line from stdin.
|
|
1200
|
+
async function readSecretArg(args, urlFlag, envFlag, what) {
|
|
1201
|
+
if (args[envFlag]) {
|
|
1202
|
+
const v = process.env[String(args[envFlag])];
|
|
1203
|
+
if (!v) die(`environment variable ${args[envFlag]} is not set.`, 2);
|
|
1204
|
+
return v.trim();
|
|
1205
|
+
}
|
|
1206
|
+
const v = args[urlFlag];
|
|
1207
|
+
if (v === '-' || v === true) {
|
|
1208
|
+
const rl = createInterface({ input, output: process.stderr, terminal: false });
|
|
1209
|
+
const line = await rl.question(`${what} (postgres://user:password@host:port/db): `);
|
|
1210
|
+
rl.close();
|
|
1211
|
+
if (!line.trim()) die(`no ${what} given.`, 2);
|
|
1212
|
+
return line.trim();
|
|
1213
|
+
}
|
|
1214
|
+
if (typeof v === 'string' && v) return v;
|
|
1215
|
+
die(`give the ${what} with --${urlFlag} <url>, --${envFlag} <VAR>, or --${urlFlag} - to type it.`, 2);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function requireYes(args, message) {
|
|
1219
|
+
if (args.yes === true || args.y === true) return;
|
|
1220
|
+
die(`${message} Re-run with --yes to confirm.`, 2);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
async function waitForOperation(creds, base, id, args) {
|
|
1224
|
+
if (args['no-wait'] === true) return null;
|
|
1225
|
+
const path = `${base}/database-operations/${encodeURIComponent(id)}`;
|
|
1226
|
+
let last = '';
|
|
1227
|
+
for (;;) {
|
|
1228
|
+
const body = await apiJSON(creds, 'GET', path);
|
|
1229
|
+
const op = body?.operation;
|
|
1230
|
+
if (!op) die('lost track of the operation.');
|
|
1231
|
+
if (op.state === 'succeeded') {
|
|
1232
|
+
if (!JSON_MODE && last) process.stderr.write('\r' + ' '.repeat(last.length) + '\r');
|
|
1233
|
+
return op;
|
|
1234
|
+
}
|
|
1235
|
+
if (op.state === 'failed' || op.state === 'cancelled') {
|
|
1236
|
+
if (!JSON_MODE && last) process.stderr.write('\r' + ' '.repeat(last.length) + '\r');
|
|
1237
|
+
die(op.error || `the operation ${op.state}.`);
|
|
1238
|
+
}
|
|
1239
|
+
if (!JSON_MODE) {
|
|
1240
|
+
const p = op.progress ?? {};
|
|
1241
|
+
const bit = p.bytes != null ? ` ${fmtBytes(p.bytes)}` : p.size_mb != null ? ` ${p.size_mb} MB` : '';
|
|
1242
|
+
last = ` ${op.state}…${bit}`;
|
|
1243
|
+
process.stderr.write('\r' + last);
|
|
1244
|
+
}
|
|
1245
|
+
await new Promise((r) => setTimeout(r, DB_OP_POLL_MS));
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
async function waitForBackup(creds, base, label, id, args) {
|
|
1250
|
+
if (args['no-wait'] === true) return null;
|
|
1251
|
+
const path = `${base}/databases/${encodeURIComponent(label)}/backups/${encodeURIComponent(id)}`;
|
|
1252
|
+
let last = '';
|
|
1253
|
+
for (;;) {
|
|
1254
|
+
const body = await apiJSON(creds, 'GET', path);
|
|
1255
|
+
const b = body?.backup;
|
|
1256
|
+
if (!b) die('lost track of the backup.');
|
|
1257
|
+
if (b.state === 'succeeded') {
|
|
1258
|
+
if (!JSON_MODE && last) process.stderr.write('\r' + ' '.repeat(last.length) + '\r');
|
|
1259
|
+
return b;
|
|
1260
|
+
}
|
|
1261
|
+
if (b.state === 'failed') {
|
|
1262
|
+
if (!JSON_MODE && last) process.stderr.write('\r' + ' '.repeat(last.length) + '\r');
|
|
1263
|
+
die(b.error || 'the backup failed.');
|
|
1264
|
+
}
|
|
1265
|
+
if (!JSON_MODE) {
|
|
1266
|
+
last = ' running…';
|
|
1267
|
+
process.stderr.write('\r' + last);
|
|
1268
|
+
}
|
|
1269
|
+
await new Promise((r) => setTimeout(r, DB_OP_POLL_MS));
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
async function startAndWait(creds, base, args, method, path, body, verb) {
|
|
1274
|
+
const started = await apiJSON(creds, method, path, body);
|
|
1275
|
+
const op = started?.operation;
|
|
1276
|
+
if (!op) die('the platform did not return an operation.');
|
|
1277
|
+
if (args['no-wait'] === true) {
|
|
1278
|
+
if (JSON_MODE) emitJSON(op);
|
|
1279
|
+
else { info(`${green('started')} ${verb} ${dim(op.id)}`); muted(`Check it with: canner db ops ${op.id}`); }
|
|
1280
|
+
return null;
|
|
1281
|
+
}
|
|
1282
|
+
if (!JSON_MODE) info(`${verb}…`);
|
|
1283
|
+
return waitForOperation(creds, base, op.id, args);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
async function cmdDb(args) {
|
|
1287
|
+
const creds = await loadCredentials();
|
|
1288
|
+
if (!creds.token) die("not signed in. Run `canner login`.");
|
|
1289
|
+
const slug = await resolveSlug(args);
|
|
1290
|
+
const base = dbBase(slug);
|
|
1291
|
+
const sub = args._[0] ?? 'list';
|
|
1292
|
+
const name = (i = 1) => args._[i] ?? 'primary';
|
|
1293
|
+
|
|
1294
|
+
if (sub === 'list' || sub === 'ls') {
|
|
1295
|
+
const body = await apiJSON(creds, 'GET', `${base}/databases`);
|
|
1296
|
+
if (JSON_MODE) return emitJSON(body);
|
|
1297
|
+
const rows = body?.databases ?? [];
|
|
1298
|
+
if (rows.length === 0) { muted('no database yet. Create one with `canner db init`.'); return; }
|
|
1299
|
+
info(`${pad(bold('NAME'), 22)}${pad(bold('POSTGRES'), 11)}${pad(bold('SIZE'), 11)}${bold('STATUS')}`);
|
|
1300
|
+
for (const d of rows) {
|
|
1301
|
+
const status = d.over_quota_at ? red('write-locked (over quota)') : green('ok');
|
|
1302
|
+
info(`${pad(d.is_primary ? `${d.name} ${dim('(DATABASE_URL)')}` : d.name, 22)}${pad(d.pg_version, 11)}${pad(`${d.size_mb} MB`, 11)}${status}`);
|
|
1303
|
+
}
|
|
1304
|
+
const l = body?.limits;
|
|
1305
|
+
if (l && !l.advanced_enabled) muted('Additional databases, import and migration are available on paid plans.');
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
if (sub === 'init' || sub === 'provision') {
|
|
1310
|
+
const body = await apiJSON(creds, 'POST', `${base}/database`, args.pg ? { pg_version: String(args.pg) } : {});
|
|
1311
|
+
if (JSON_MODE) return emitJSON(body);
|
|
1312
|
+
const d = body?.database;
|
|
1313
|
+
info(`${green('provisioned')} ${bold('primary')} ${dim(`(Postgres ${d?.pg_version_full ?? d?.pg_version})`)}`);
|
|
1314
|
+
muted('The connection string is injected as DATABASE_URL on your next deploy.');
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
if (sub === 'create' || sub === 'add') {
|
|
1319
|
+
const label = args._[1];
|
|
1320
|
+
if (!label) die('usage: canner db create <name> [--pg 17|18]', 2);
|
|
1321
|
+
const body = await apiJSON(creds, 'POST', `${base}/databases`, {
|
|
1322
|
+
name: label,
|
|
1323
|
+
...(args.pg ? { pg_version: String(args.pg) } : {}),
|
|
1324
|
+
...(args.instance === true ? { instance: true } : {}),
|
|
1325
|
+
});
|
|
1326
|
+
if (JSON_MODE) return emitJSON(body);
|
|
1327
|
+
const d = body?.database;
|
|
1328
|
+
info(`${green('created')} ${bold(label)} ${dim(`(Postgres ${d?.pg_version_full ?? d?.pg_version})`)}`);
|
|
1329
|
+
muted(`Injected into your app as DATABASE_URL_${label.toUpperCase()} on your next deploy.`);
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (sub === 'info' || sub === 'show') {
|
|
1334
|
+
const body = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(name())}`);
|
|
1335
|
+
const d = body?.database;
|
|
1336
|
+
if (JSON_MODE) return emitJSON(args.reveal === true ? d : { ...d, password: null, connection_string: null });
|
|
1337
|
+
info(`${bold(d.name)} ${dim(`Postgres ${d.pg_version_full ?? d.pg_version} · ${d.size_mb} MB`)}`);
|
|
1338
|
+
info(` user ${d.db_user}`);
|
|
1339
|
+
info(` database ${d.db_name}`);
|
|
1340
|
+
info(` password ${args.reveal === true ? d.password : dim('•••••• (--reveal to show)')}`);
|
|
1341
|
+
if (args.reveal === true) info(` url ${d.connection_string}`);
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (sub === 'url') {
|
|
1346
|
+
const body = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(name())}`);
|
|
1347
|
+
const d = body?.database;
|
|
1348
|
+
if (JSON_MODE) return emitJSON({ url: d.connection_string });
|
|
1349
|
+
process.stdout.write(`${d.connection_string}\n`);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
if (sub === 'rm' || sub === 'delete') {
|
|
1354
|
+
const label = args._[1];
|
|
1355
|
+
if (!label) die('usage: canner db rm <name> --yes', 2);
|
|
1356
|
+
requireYes(args, `This permanently deletes the database "${label}" and everything in it.`);
|
|
1357
|
+
await apiJSON(creds, 'DELETE', `${base}/databases/${encodeURIComponent(label)}`);
|
|
1358
|
+
if (JSON_MODE) return emitJSON({ ok: true, deleted: label });
|
|
1359
|
+
info(`${green('deleted')} ${bold(label)}`);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
if (sub === 'pg-version') {
|
|
1364
|
+
const label = args._[1];
|
|
1365
|
+
const target = args._[2];
|
|
1366
|
+
if (!label || !target) die('usage: canner db pg-version <name> <17|18> --yes', 2);
|
|
1367
|
+
requireYes(args, 'Changing the Postgres version dumps and restores the database, with downtime while it runs.');
|
|
1368
|
+
const body = await apiJSON(creds, 'PATCH', `${base}/databases/${encodeURIComponent(label)}/pg-version`, { target_version: String(target), confirm: true });
|
|
1369
|
+
if (JSON_MODE) return emitJSON(body);
|
|
1370
|
+
info(`${green('moved')} ${bold(label)} from Postgres ${body.from_version} to ${body.to_version}. The connection string is unchanged.`);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
if (sub === 'export') {
|
|
1375
|
+
const label = name();
|
|
1376
|
+
const format = args.format === 'plain' ? 'plain' : 'custom';
|
|
1377
|
+
const done = await startAndWait(creds, base, args, 'POST', `${base}/databases/${encodeURIComponent(label)}/exports`, { format }, 'exporting');
|
|
1378
|
+
if (!done) return;
|
|
1379
|
+
const out = resolve(typeof args.out === 'string' ? args.out : done.result.file_name);
|
|
1380
|
+
const res = await apiRequest(creds, 'GET', `${base}/database-operations/${encodeURIComponent(done.id)}/download`);
|
|
1381
|
+
const { Readable } = await import('node:stream');
|
|
1382
|
+
const { pipeline } = await import('node:stream/promises');
|
|
1383
|
+
const { createWriteStream } = await import('node:fs');
|
|
1384
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(out, { mode: 0o600 }));
|
|
1385
|
+
if (JSON_MODE) return emitJSON({ file: out, bytes: done.result.bytes, sha256: done.result.sha256, format });
|
|
1386
|
+
info(`${green('exported')} ${bold(label)} → ${cyan(out)} ${dim(`(${fmtBytes(done.result.bytes)}, sha256 ${done.result.sha256.slice(0, 12)}…)`)}`);
|
|
1387
|
+
muted(`Restore with: ${done.result.restore_hint}`);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
if (sub === 'import') {
|
|
1392
|
+
const file = args._[1];
|
|
1393
|
+
if (!file) die('usage: canner db import <file.dump> [--to <name>] [--replace]', 2);
|
|
1394
|
+
const to = typeof args.to === 'string' ? args.to : 'primary';
|
|
1395
|
+
const size = (await stat(resolve(file))).size;
|
|
1396
|
+
const { createReadStream } = await import('node:fs');
|
|
1397
|
+
if (!JSON_MODE) info(`uploading ${basename(file)} (${fmtBytes(size)})…`);
|
|
1398
|
+
const res = await apiRequest(creds, 'POST',
|
|
1399
|
+
`${base}/databases/${encodeURIComponent(to)}/imports${args.replace === true ? '?replace=true' : ''}`,
|
|
1400
|
+
{ headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(size) }, body: createReadStream(resolve(file)), duplex: 'half' });
|
|
1401
|
+
const started = await res.json();
|
|
1402
|
+
const opId = started?.operation?.id;
|
|
1403
|
+
if (args['no-wait'] === true) {
|
|
1404
|
+
if (JSON_MODE) return emitJSON(started.operation);
|
|
1405
|
+
info(`${green('started')} import ${dim(opId)}`);
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (!JSON_MODE) info('restoring…');
|
|
1409
|
+
const done = await waitForOperation(creds, base, opId, args);
|
|
1410
|
+
if (JSON_MODE) return emitJSON(done);
|
|
1411
|
+
info(`${green('imported')} into ${bold(to)}`);
|
|
1412
|
+
if (done.result?.snapshot) muted(`A snapshot of the previous contents was kept for 7 days (${fmtBytes(done.result.snapshot.bytes)}).`);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (sub === 'migrate-in') {
|
|
1417
|
+
const to = typeof args.to === 'string' ? args.to : 'primary';
|
|
1418
|
+
const url = await readSecretArg(args, 'from', 'from-env', 'source connection URL');
|
|
1419
|
+
const done = await startAndWait(creds, base, args, 'POST', `${base}/databases/${encodeURIComponent(to)}/migrate-in`,
|
|
1420
|
+
{ url, replace: args.replace === true }, 'migrating in');
|
|
1421
|
+
if (!done) return;
|
|
1422
|
+
if (JSON_MODE) return emitJSON(done);
|
|
1423
|
+
info(`${green('migrated')} ${dim(done.result?.source_host ?? '')} → ${bold(to)}`);
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (sub === 'migrate-out') {
|
|
1428
|
+
const from = typeof args.from === 'string' ? args.from : 'primary';
|
|
1429
|
+
requireYes(args, 'This writes this database into the server you name.');
|
|
1430
|
+
const url = await readSecretArg(args, 'to-url', 'to-env', 'target connection URL');
|
|
1431
|
+
const done = await startAndWait(creds, base, args, 'POST', `${base}/databases/${encodeURIComponent(from)}/migrate-out`,
|
|
1432
|
+
{ url, replace: args.replace === true, confirm: true }, 'migrating out');
|
|
1433
|
+
if (!done) return;
|
|
1434
|
+
if (JSON_MODE) return emitJSON(done);
|
|
1435
|
+
info(`${green('migrated')} ${bold(from)} → ${dim(done.result?.target_host ?? '')}`);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
if (sub === 'copy') {
|
|
1440
|
+
const from = args._[1];
|
|
1441
|
+
const to = args._[2];
|
|
1442
|
+
if (!from || !to) die('usage: canner db copy <from> <to> [--to-project SLUG] [--replace]', 2);
|
|
1443
|
+
const done = await startAndWait(creds, base, args, 'POST', `${base}/databases/${encodeURIComponent(from)}/copy`,
|
|
1444
|
+
{ target_name: to, ...(args['to-project'] ? { target_project: String(args['to-project']) } : {}), replace: args.replace === true }, 'copying');
|
|
1445
|
+
if (!done) return;
|
|
1446
|
+
if (JSON_MODE) return emitJSON(done);
|
|
1447
|
+
info(`${green('copied')} ${bold(from)} → ${bold(to)}`);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
if (sub === 'ops' || sub === 'operations') {
|
|
1452
|
+
const id = args._[1];
|
|
1453
|
+
if (id) {
|
|
1454
|
+
const body = await apiJSON(creds, 'GET', `${base}/database-operations/${encodeURIComponent(id)}`);
|
|
1455
|
+
if (JSON_MODE) return emitJSON(body.operation);
|
|
1456
|
+
const o = body.operation;
|
|
1457
|
+
info(`${bold(o.kind)} ${dim(o.id)} ${o.state === 'succeeded' ? green(o.state) : o.state === 'failed' ? red(o.state) : yellow(o.state)}`);
|
|
1458
|
+
if (o.error) info(` ${red(o.error)}`);
|
|
1459
|
+
if (o.progress?.bytes != null) info(` ${fmtBytes(o.progress.bytes)} written`);
|
|
1460
|
+
if (o.progress?.size_mb != null) info(` target is ${o.progress.size_mb} MB`);
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const body = await apiJSON(creds, 'GET', `${base}/database-operations`);
|
|
1464
|
+
const rows = body?.operations ?? [];
|
|
1465
|
+
if (JSON_MODE) return emitJSON(rows);
|
|
1466
|
+
if (rows.length === 0) { muted('no database operations yet.'); return; }
|
|
1467
|
+
info(`${pad(bold('ID'), 12)}${pad(bold('KIND'), 14)}${pad(bold('STATE'), 12)}${bold('WHEN')}`);
|
|
1468
|
+
for (const o of rows) {
|
|
1469
|
+
info(`${pad(o.id.slice(0, 8), 12)}${pad(o.kind, 14)}${pad(o.state === 'succeeded' ? green(o.state) : o.state === 'failed' ? red(o.state) : yellow(o.state), 12)}${dim(relTime(o.created_at))}`);
|
|
1470
|
+
}
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (sub === 'external' || sub === 'external-access') {
|
|
1475
|
+
const label = args._[1] ?? 'primary';
|
|
1476
|
+
const path = `${base}/databases/${encodeURIComponent(label)}/external-access`;
|
|
1477
|
+
|
|
1478
|
+
if (args.disable === true) {
|
|
1479
|
+
const body = await apiJSON(creds, 'PATCH', path, { enabled: false });
|
|
1480
|
+
if (JSON_MODE) return emitJSON(body.external_access);
|
|
1481
|
+
info(`${green('disabled')} external access on ${bold(label)}`);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
if (args.enable === true || args.allow !== undefined || args['allow-all'] === true) {
|
|
1485
|
+
const allowlist = args.allow === undefined ? [] : (Array.isArray(args.allow) ? args.allow : [args.allow]).map(String);
|
|
1486
|
+
if (args['allow-all'] === true) {
|
|
1487
|
+
requireYes(args, 'This allows connections to this database from anywhere on the internet.');
|
|
1488
|
+
allowlist.push('0.0.0.0/0', '::/0');
|
|
1489
|
+
}
|
|
1490
|
+
if (allowlist.length === 0) die('give at least one --allow <cidr>, or --allow-all --yes.', 2);
|
|
1491
|
+
const body = await apiJSON(creds, 'PATCH', path, { enabled: true, allowlist, allow_open: args['allow-all'] === true });
|
|
1492
|
+
if (JSON_MODE) return emitJSON(body.external_access);
|
|
1493
|
+
const ea = body.external_access;
|
|
1494
|
+
info(`${green('enabled')} external access on ${bold(label)}`);
|
|
1495
|
+
info(` ${ea.host}:${ea.port}`);
|
|
1496
|
+
info(` allowed: ${ea.allowlist.join(', ')}`);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
// No mutating flag: show current status.
|
|
1500
|
+
const body = await apiJSON(creds, 'GET', path);
|
|
1501
|
+
const ea = body.external_access;
|
|
1502
|
+
if (JSON_MODE) return emitJSON(ea);
|
|
1503
|
+
info(`${bold(label)} ${ea.enabled ? green('enabled') : dim('disabled')}`);
|
|
1504
|
+
info(` ${ea.host}:${ea.port}${!ea.configured ? dim(' (not available on this platform instance yet)') : ''}`);
|
|
1505
|
+
if (ea.enabled) info(` allowed: ${ea.allowlist.length ? ea.allowlist.join(', ') : dim('(none)')}`);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
if (sub === 'rotate' || sub === 'rotate-credentials') {
|
|
1510
|
+
const label = args._[1] ?? 'primary';
|
|
1511
|
+
requireYes(args, 'This changes the password — your app needs the new DATABASE_URL from the next deploy.');
|
|
1512
|
+
const body = await apiJSON(creds, 'POST', `${base}/databases/${encodeURIComponent(label)}/rotate-credentials`);
|
|
1513
|
+
if (JSON_MODE) return emitJSON(body.database);
|
|
1514
|
+
info(`${green('rotated')} the password for ${bold(label)}.`);
|
|
1515
|
+
muted('Redeploy to pick up the new DATABASE_URL.');
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (sub === 'backup-policy') {
|
|
1520
|
+
const label = args._[1] ?? 'primary';
|
|
1521
|
+
const path = `${base}/databases/${encodeURIComponent(label)}/backup-policy`;
|
|
1522
|
+
|
|
1523
|
+
if (args.disable === true) {
|
|
1524
|
+
const body = await apiJSON(creds, 'PATCH', path, { enabled: false });
|
|
1525
|
+
if (JSON_MODE) return emitJSON(body.policy);
|
|
1526
|
+
info(`${green('disabled')} scheduled backups on ${bold(label)}`);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
const setting = args.enable === true || args.every !== undefined || args.keep !== undefined || args['keep-count'] !== undefined || args.offsite !== undefined;
|
|
1530
|
+
if (setting) {
|
|
1531
|
+
const patch = {};
|
|
1532
|
+
if (args.enable === true) patch.enabled = true;
|
|
1533
|
+
if (args.every !== undefined) patch.interval_hours = Number(args.every);
|
|
1534
|
+
if (args.keep !== undefined) patch.retention_days = Number(args.keep);
|
|
1535
|
+
if (args['keep-count'] !== undefined) patch.retention_count = args['keep-count'] === 'none' ? null : Number(args['keep-count']);
|
|
1536
|
+
if (args.offsite !== undefined) {
|
|
1537
|
+
if (args.offsite === 'off' || args.offsite === 'none') patch.offsite_destination_id = null;
|
|
1538
|
+
else if (typeof args.offsite === 'string' && args.offsite !== 'on') {
|
|
1539
|
+
const all = (await apiJSON(creds, 'GET', `${base}/backup-destinations`)).destinations;
|
|
1540
|
+
const hit = all.find((d) => d.id === args.offsite || d.name.toLowerCase() === args.offsite.toLowerCase());
|
|
1541
|
+
if (!hit) die(`no destination "${args.offsite}". List them with: canner db offsite`, 1);
|
|
1542
|
+
patch.offsite_destination_id = hit.id;
|
|
1543
|
+
} else die('--offsite takes a destination name (see: canner db offsite) or off.', 2);
|
|
1544
|
+
}
|
|
1545
|
+
const body = await apiJSON(creds, 'PATCH', path, patch);
|
|
1546
|
+
if (JSON_MODE) return emitJSON(body.policy);
|
|
1547
|
+
const p = body.policy;
|
|
1548
|
+
info(`${green('set')} backup policy for ${bold(label)}: every ${p.interval_hours}h, keep ${p.retention_days}d${p.retention_count ? ` (max ${p.retention_count})` : ''}`);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
const body = await apiJSON(creds, 'GET', path);
|
|
1552
|
+
const p = body.policy;
|
|
1553
|
+
if (JSON_MODE) return emitJSON(p);
|
|
1554
|
+
info(`${bold(label)} ${p.enabled ? green('enabled') : dim('disabled')}`);
|
|
1555
|
+
info(` every ${p.interval_hours}h, keep ${p.retention_days}d${p.retention_count ? `, max ${p.retention_count} backups` : ''}`);
|
|
1556
|
+
info(` off-site copy ${p.offsite ? green('on') : dim('off')}`);
|
|
1557
|
+
if (p.enabled && p.next_run_at) info(` next run ${dim(relTime(p.next_run_at))}`);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (sub === 'backup') {
|
|
1562
|
+
const label = name();
|
|
1563
|
+
const started = await apiJSON(creds, 'POST', `${base}/databases/${encodeURIComponent(label)}/backups`);
|
|
1564
|
+
const b = started?.backup;
|
|
1565
|
+
if (!b) die('the platform did not return a backup.');
|
|
1566
|
+
if (args['no-wait'] === true) {
|
|
1567
|
+
if (JSON_MODE) return emitJSON(b);
|
|
1568
|
+
info(`${green('started')} backup ${dim(b.id)}`);
|
|
1569
|
+
muted(`Check it with: canner db backups ${label} ${b.id}`);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
if (!JSON_MODE) info('backing up…');
|
|
1573
|
+
const done = await waitForBackup(creds, base, label, b.id, args);
|
|
1574
|
+
if (JSON_MODE) return emitJSON(done);
|
|
1575
|
+
info(`${green('backed up')} ${bold(label)} ${dim(`(${fmtBytes(done.bytes)}, sha256 ${done.sha256.slice(0, 12)}…)`)}`);
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
if (sub === 'backups') {
|
|
1580
|
+
const label = args._[1] ?? 'primary';
|
|
1581
|
+
const id = args._[2];
|
|
1582
|
+
const listPath = `${base}/databases/${encodeURIComponent(label)}/backups`;
|
|
1583
|
+
|
|
1584
|
+
if (!id) {
|
|
1585
|
+
const body = await apiJSON(creds, 'GET', listPath);
|
|
1586
|
+
const rows = body?.backups ?? [];
|
|
1587
|
+
if (JSON_MODE) return emitJSON(rows);
|
|
1588
|
+
if (rows.length === 0) { muted(`no backups yet for ${label}. Create one with \`canner db backup ${label}\`.`); return; }
|
|
1589
|
+
info(`${pad(bold('ID'), 12)}${pad(bold('KIND'), 11)}${pad(bold('STATE'), 12)}${pad(bold('SIZE'), 11)}${bold('WHEN')}`);
|
|
1590
|
+
for (const b of rows) {
|
|
1591
|
+
const state = b.state === 'succeeded' ? green(b.state) : b.state === 'failed' ? red(b.state) : yellow(b.state);
|
|
1592
|
+
info(`${pad(b.id.slice(0, 8), 12)}${pad(b.kind, 11)}${pad(state, 12)}${pad(b.bytes != null ? fmtBytes(b.bytes) : '—', 11)}${dim(relTime(b.created_at))}`);
|
|
1593
|
+
}
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
const itemPath = `${listPath}/${encodeURIComponent(id)}`;
|
|
1598
|
+
|
|
1599
|
+
if (args.rm === true || args.delete === true) {
|
|
1600
|
+
requireYes(args, 'This permanently deletes the backup — its encryption key is destroyed immediately, so this cannot be undone.');
|
|
1601
|
+
await apiJSON(creds, 'DELETE', itemPath);
|
|
1602
|
+
if (JSON_MODE) return emitJSON({ ok: true, deleted: id });
|
|
1603
|
+
info(`${green('deleted')} backup ${dim(id.slice(0, 8))}`);
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (args.restore === true) {
|
|
1608
|
+
const to = typeof args.to === 'string' ? args.to : undefined;
|
|
1609
|
+
if (to) requireYes(args, `This replaces the contents of "${to}" with this backup (Canner takes a snapshot first).`);
|
|
1610
|
+
const inspect = args.inspect === true;
|
|
1611
|
+
if (inspect && to) die('--inspect restores into a new temporary database; it cannot be combined with --to.', 2);
|
|
1612
|
+
const body = await apiJSON(creds, 'POST', `${itemPath}/restore`, to ? { target_name: to, confirm: true } : inspect ? { inspect: true } : {});
|
|
1613
|
+
const opId = body?.operation_id;
|
|
1614
|
+
if (args['no-wait'] === true) {
|
|
1615
|
+
if (JSON_MODE) return emitJSON(body);
|
|
1616
|
+
info(`${green('started')} restore ${dim(opId)} → ${bold(body.database.name)}`);
|
|
1617
|
+
muted(`Check it with: canner db ops ${opId}`);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (!JSON_MODE) info('restoring…');
|
|
1621
|
+
const done = await waitForOperation(creds, base, opId, args);
|
|
1622
|
+
if (JSON_MODE) return emitJSON({ ...done, database: body.database });
|
|
1623
|
+
info(`${green('restored')} into ${bold(body.database.name)}`);
|
|
1624
|
+
if (inspect) muted(`Temporary inspect database (deleted ${dim(relTime(body.inspect_expires_at))}). Compare it: canner db diff ${body.database.name}. Keep it: canner db inspect ${body.database.name} --keep`);
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
if (args.offsite !== undefined) {
|
|
1629
|
+
const body = {};
|
|
1630
|
+
if (typeof args.offsite === 'string') {
|
|
1631
|
+
const all = (await apiJSON(creds, 'GET', `${base}/backup-destinations`)).destinations;
|
|
1632
|
+
const hit = all.find((d) => d.id === args.offsite || d.name.toLowerCase() === args.offsite.toLowerCase());
|
|
1633
|
+
if (!hit) die(`no destination "${args.offsite}". List them with: canner db offsite`, 1);
|
|
1634
|
+
body.destination_id = hit.id;
|
|
1635
|
+
}
|
|
1636
|
+
await apiJSON(creds, 'POST', `${itemPath}/offsite`, body);
|
|
1637
|
+
if (JSON_MODE) return emitJSON({ ok: true });
|
|
1638
|
+
info(`${green('copied')} backup ${dim(id.slice(0, 8))} to your off-site bucket`);
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
if (args.verify === true) {
|
|
1643
|
+
const body = await apiJSON(creds, 'POST', `${itemPath}/verify`);
|
|
1644
|
+
const opId = body?.operation_id;
|
|
1645
|
+
if (args['no-wait'] === true) {
|
|
1646
|
+
if (JSON_MODE) return emitJSON(body);
|
|
1647
|
+
info(`${green('started')} test restore ${dim(opId)}`);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
if (!JSON_MODE) info('test-restoring…');
|
|
1651
|
+
const done = await waitForOperation(creds, base, opId, args);
|
|
1652
|
+
if (JSON_MODE) return emitJSON(done);
|
|
1653
|
+
info(`${green('verified')} backup ${dim(id.slice(0, 8))}: ${done?.result?.tables ?? '?'} tables restored and readable`);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
if (args.download === true) {
|
|
1658
|
+
const out = resolve(typeof args.out === 'string' ? args.out : `${label}-${id.slice(0, 8)}.dump`);
|
|
1659
|
+
const res = await apiRequest(creds, 'GET', `${itemPath}/download`);
|
|
1660
|
+
const { Readable } = await import('node:stream');
|
|
1661
|
+
const { pipeline } = await import('node:stream/promises');
|
|
1662
|
+
const { createWriteStream } = await import('node:fs');
|
|
1663
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(out, { mode: 0o600 }));
|
|
1664
|
+
if (JSON_MODE) return emitJSON({ file: out });
|
|
1665
|
+
info(`${green('downloaded')} → ${cyan(out)}`);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const body = await apiJSON(creds, 'GET', itemPath);
|
|
1670
|
+
const b = body?.backup;
|
|
1671
|
+
if (JSON_MODE) return emitJSON(b);
|
|
1672
|
+
info(`${bold(b.id)} ${dim(b.kind)} ${b.state === 'succeeded' ? green(b.state) : b.state === 'failed' ? red(b.state) : yellow(b.state)}`);
|
|
1673
|
+
if (b.error) info(` ${red(b.error)}`);
|
|
1674
|
+
if (b.bytes != null) info(` ${fmtBytes(b.bytes)}`);
|
|
1675
|
+
if (b.expires_at) info(` expires ${dim(relTime(b.expires_at))}`);
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
if (sub === 'diff') {
|
|
1680
|
+
const label = args._[1];
|
|
1681
|
+
if (!label) die('usage: canner db diff <name> [--against <name>]', 2);
|
|
1682
|
+
const q = typeof args.against === 'string' ? `?against=${encodeURIComponent(args.against)}` : '';
|
|
1683
|
+
const d = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(label)}/diff${q}`);
|
|
1684
|
+
if (JSON_MODE) return emitJSON(d);
|
|
1685
|
+
info(`${bold(d.to)} compared with ${bold(d.from)}: ${d.summary.added} only in ${d.to}, ${d.summary.removed} missing, ${d.summary.changed} different, ${d.summary.same} same`);
|
|
1686
|
+
for (const t of d.tables.filter((x) => x.status !== 'same')) {
|
|
1687
|
+
const tag = t.status === 'added' ? green('added ') : t.status === 'removed' ? red('missing') : yellow('changed');
|
|
1688
|
+
info(` ${tag} ${pad(t.table, 32)} ${dim(`${t.rows_from ?? '?'} → ${t.rows_to ?? '?'} rows`)}`);
|
|
1689
|
+
}
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
if (sub === 'inspect') {
|
|
1694
|
+
const label = args._[1];
|
|
1695
|
+
if (!label) die('usage: canner db inspect <name> --keep | --extend HOURS', 2);
|
|
1696
|
+
const body = args.keep === true ? { keep: true } : args.extend !== undefined ? { hours: Number(args.extend) } : null;
|
|
1697
|
+
if (!body) die('use --keep to make it permanent, or --extend HOURS to push the expiry out.', 2);
|
|
1698
|
+
await apiJSON(creds, 'PATCH', `${base}/databases/${encodeURIComponent(label)}/inspect`, body);
|
|
1699
|
+
if (JSON_MODE) return emitJSON({ ok: true });
|
|
1700
|
+
info(`${green('updated')} ${bold(label)}`);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
if (sub === 'resources') {
|
|
1705
|
+
const path = `${base}/instance/resources`;
|
|
1706
|
+
const action = args._[1];
|
|
1707
|
+
const mb = (n) => (n >= 1024 ? `${(n / 1024).toFixed(n % 1024 === 0 ? 0 : 1)} GB` : `${n} MB`);
|
|
1708
|
+
if (action === 'apply') {
|
|
1709
|
+
requireYes(args, 'Applying restarts your dedicated instance; connections drop for a few seconds.');
|
|
1710
|
+
const r = await apiJSON(creds, 'POST', `${path}/apply`, { confirm: true });
|
|
1711
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1712
|
+
info(`${green('applied')} the memory budget${r.limits_applied ? '' : yellow(' (hard limit pending on the host)')}`);
|
|
1713
|
+
if (r.redeploy_recommended) muted('Your app memory limit was lowered to its new ceiling. Redeploy the app to use it.');
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
if (action === 'set') {
|
|
1717
|
+
const patch = {};
|
|
1718
|
+
if (args.share !== undefined) patch.pg_share_pct = Number(args.share);
|
|
1719
|
+
if (args['app-reserved'] !== undefined) {
|
|
1720
|
+
const cur = (await apiJSON(creds, 'GET', path)).resources.budget;
|
|
1721
|
+
patch.reserved_to_pg_mb = cur.reserved_purchased_mb - Math.round(Number(args['app-reserved']) * 1024);
|
|
1722
|
+
}
|
|
1723
|
+
if (Object.keys(patch).length === 0) die('nothing to change. Use --share PCT (10-90) or --app-reserved GB.', 2);
|
|
1724
|
+
const r = await apiJSON(creds, 'PATCH', path, patch);
|
|
1725
|
+
if (JSON_MODE) return emitJSON(r.resources);
|
|
1726
|
+
info(`${green('saved')} the split. It takes effect with: canner db resources apply --yes`);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
const r = (await apiJSON(creds, 'GET', path)).resources;
|
|
1730
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1731
|
+
const b = r.budget;
|
|
1732
|
+
info(`${bold('postgres')} ${mb(b.pg.total_mb)} (${mb(b.pg.reserved_mb)} guaranteed, ${mb(b.pg.pooled_mb)} shared)`);
|
|
1733
|
+
info(`${bold('app')} ${mb(b.app.ceiling_mb)} (${mb(b.app.reserved_mb)} guaranteed, ${mb(b.app.pooled_mb)} shared)`);
|
|
1734
|
+
info(` Postgres takes ${b.pg_share_pct}% of the shared memory; reserved RAM bought: ${mb(b.reserved_purchased_mb)} (+ ${mb(b.reserved_included_mb)} included)`);
|
|
1735
|
+
info(` ${r.applied ? `in force: ${mb(r.applied.max_mb)} limit, ${dim(relTime(r.applied.at))}` : 'not applied yet'}${r.pending ? yellow(' (changes pending: canner db resources apply --yes)') : ''}`);
|
|
1736
|
+
if (r.applied?.error) info(` ${yellow(r.applied.error)}`);
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
if (sub === 'extensions') {
|
|
1741
|
+
const label = args._[1] ?? 'primary';
|
|
1742
|
+
const action = args._[2];
|
|
1743
|
+
const path = `${base}/databases/${encodeURIComponent(label)}/extensions`;
|
|
1744
|
+
if (action === 'install') {
|
|
1745
|
+
const name = args._[3];
|
|
1746
|
+
if (!name) die('usage: canner db extensions <db> install <extension> [--window] [--yes]', 2);
|
|
1747
|
+
const needsRestart = name === 'pg_cron';
|
|
1748
|
+
if (needsRestart) requireYes(args, `${name} loads a library at startup: installing it restarts your instance${args.window === true ? ' in the next maintenance window' : ' now; connections drop for a few seconds'}.`);
|
|
1749
|
+
const r = await apiJSON(creds, 'POST', path, { extension: name, when: args.window === true ? 'window' : 'now', confirm: needsRestart ? true : undefined });
|
|
1750
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1751
|
+
if (r.restart && !r.restart.restarted) info(`${yellow('scheduled')} ${bold(name)}: the instance restarts at the next maintenance window (${r.restart.scheduled_for ?? 'unknown'}) and installs it then.`);
|
|
1752
|
+
else info(`${green('installed')} ${bold(name)}`);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (action === 'update') {
|
|
1756
|
+
const name = args._[3];
|
|
1757
|
+
if (!name) die('usage: canner db extensions <db> update <extension>', 2);
|
|
1758
|
+
const r = await apiJSON(creds, 'POST', `${path}/${encodeURIComponent(name)}/update`);
|
|
1759
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1760
|
+
info(`${green('updated')} ${bold(name)}`);
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
if (action === 'rm') {
|
|
1764
|
+
const name = args._[3];
|
|
1765
|
+
if (!name) die('usage: canner db extensions <db> rm <extension> --yes', 2);
|
|
1766
|
+
requireYes(args, `This removes ${name} from the database. Nothing is removed if objects still use it.`);
|
|
1767
|
+
const r = await apiJSON(creds, 'DELETE', `${path}/${encodeURIComponent(name)}`);
|
|
1768
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1769
|
+
info(`${green('removed')} ${bold(name)}${r.restart_pending ? dim(' (the library stops loading at the next restart)') : ''}`);
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
const r = await apiJSON(creds, 'GET', path);
|
|
1773
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1774
|
+
for (const e of r.extensions) {
|
|
1775
|
+
const state = e.installed_version ? green(`installed ${e.installed_version}`) : e.available ? dim('available') : red('unavailable');
|
|
1776
|
+
info(`${pad(bold(e.id), 24)}${pad(state, 24)}${e.update_available ? yellow(`update to ${e.default_version} `) : ''}${dim(e.description)}${e.preload ? dim(' (restarts the instance)') : ''}`);
|
|
1777
|
+
}
|
|
1778
|
+
if (r.pg_cron_database) muted(`pg_cron runs in the database "${r.pg_cron_database}".`);
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
if (sub === 'tuning') {
|
|
1783
|
+
const path = `${base}/instance/config`;
|
|
1784
|
+
const patch = {};
|
|
1785
|
+
if (typeof args.preset === 'string') patch.preset = args.preset.replace(/-/g, '_');
|
|
1786
|
+
const ov = {};
|
|
1787
|
+
if (args['statement-timeout'] !== undefined) ov.statement_timeout_ms = Math.round(Number(args['statement-timeout']) * 1000);
|
|
1788
|
+
if (args['idle-timeout'] !== undefined) ov.idle_in_transaction_timeout_ms = Math.round(Number(args['idle-timeout']) * 1000);
|
|
1789
|
+
if (args['lock-timeout'] !== undefined) ov.lock_timeout_ms = Math.round(Number(args['lock-timeout']) * 1000);
|
|
1790
|
+
if (Object.keys(ov).length > 0) {
|
|
1791
|
+
const cur = (await apiJSON(creds, 'GET', path)).config.overrides;
|
|
1792
|
+
patch.overrides = { ...cur, ...ov };
|
|
1793
|
+
}
|
|
1794
|
+
if (args['window-day'] !== undefined || args['window-hour'] !== undefined || args['window-tz'] !== undefined) {
|
|
1795
|
+
const cur = (await apiJSON(creds, 'GET', path)).config.window;
|
|
1796
|
+
patch.window = {
|
|
1797
|
+
day: args['window-day'] === undefined ? cur.day : args['window-day'] === 'all' ? null : Number(args['window-day']),
|
|
1798
|
+
hour: args['window-hour'] === undefined ? cur.hour : Number(args['window-hour']),
|
|
1799
|
+
tz: typeof args['window-tz'] === 'string' ? args['window-tz'] : cur.tz,
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
const r = Object.keys(patch).length > 0 ? await apiJSON(creds, 'PATCH', path, patch) : await apiJSON(creds, 'GET', path);
|
|
1803
|
+
if (JSON_MODE) return emitJSON(r.config);
|
|
1804
|
+
const c = r.config;
|
|
1805
|
+
if (Object.keys(patch).length > 0) info(`${green('saved')}. No restart needed.`);
|
|
1806
|
+
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
1807
|
+
const secs = (ms) => (ms ? `${ms / 1000} s` : 'none');
|
|
1808
|
+
info(`${bold('preset')} ${c.preset} ${dim(`(${c.presets.join(' | ')})`)}`);
|
|
1809
|
+
info(`${bold('timeouts')} statement ${secs(c.overrides.statement_timeout_ms)}, idle in transaction ${secs(c.overrides.idle_in_transaction_timeout_ms)}, lock wait ${secs(c.overrides.lock_timeout_ms)}`);
|
|
1810
|
+
info(`${bold('window')} ${c.window.day === null ? 'every day' : days[c.window.day]} ${String(c.window.hour).padStart(2, '0')}:00 for two hours, ${c.window.tz}${c.window_open ? green(' (open now)') : c.next_window ? dim(` (next ${relTime(c.next_window)})`) : ''}`);
|
|
1811
|
+
if (c.restart.waiting_for_window) info(yellow(` a restart is waiting for the window; do it now with: canner db restart --yes`));
|
|
1812
|
+
if (c.restart.settings.length > 0) info(yellow(` waiting for a restart: ${c.restart.settings.join(', ')}`));
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
if (sub === 'restart') {
|
|
1817
|
+
requireYes(args, 'Restarting your dedicated instance drops connections for a few seconds.');
|
|
1818
|
+
const r = await apiJSON(creds, 'POST', `${base}/instance/restart`, { confirm: true });
|
|
1819
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1820
|
+
info(`${green('restarted')} the instance${r.failed_actions ? yellow(` (${r.failed_actions} queued action(s) failed)`) : ''}`);
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
if (sub === 'replication') {
|
|
1825
|
+
const label = args._[1] ?? 'primary';
|
|
1826
|
+
const action = args._[2];
|
|
1827
|
+
const path = `${base}/databases/${encodeURIComponent(label)}/replication`;
|
|
1828
|
+
const inst = `${base}/instance/replication`;
|
|
1829
|
+
if (action === 'on') {
|
|
1830
|
+
requireYes(args, `Turning logical replication on restarts your instance${args.window === true ? ' in the next maintenance window' : ' now; connections drop for a few seconds'}.`);
|
|
1831
|
+
const body = { enabled: true, when: args.window === true ? 'window' : 'now', confirm: true };
|
|
1832
|
+
if (args.cap !== undefined) body.cap_mb = Number(args.cap);
|
|
1833
|
+
const r = await apiJSON(creds, 'POST', inst, body);
|
|
1834
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1835
|
+
info(r.restart && !r.restart.restarted ? `${yellow('scheduled')}: the instance restarts at the next maintenance window (${r.restart.scheduled_for ?? 'unknown'}).` : `${green('on')} logical replication`);
|
|
1836
|
+
muted(`Next: create a login with \`canner db roles ${label} create <name> --replication\` and a publication with \`canner db replication ${label} pub <name> --all\`.`);
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
if (action === 'off') {
|
|
1840
|
+
requireYes(args, 'Turning logical replication off restarts your instance for a few seconds. Remove every slot and replication role first.');
|
|
1841
|
+
const r = await apiJSON(creds, 'POST', inst, { enabled: false, when: args.window === true ? 'window' : 'now', confirm: true });
|
|
1842
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1843
|
+
info(`${green('off')} logical replication`);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
if (action === 'cap') {
|
|
1847
|
+
const mb = Number(args._[3]);
|
|
1848
|
+
if (!Number.isFinite(mb)) die('usage: canner db replication <db> cap <MB>', 2);
|
|
1849
|
+
const r = await apiJSON(creds, 'PATCH', inst, { cap_mb: mb });
|
|
1850
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1851
|
+
info(`${green('set')} the limit on WAL held back per slot to ${mb} MB (no restart)`);
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
if (action === 'pub') {
|
|
1855
|
+
const name = args._[3];
|
|
1856
|
+
if (!name) die('usage: canner db replication <db> pub <name> --all | --tables schema.table,schema.table', 2);
|
|
1857
|
+
const body = args.all === true ? { name, all_tables: true } : { name, tables: String(args.tables ?? '').split(',').map((x) => x.trim()).filter(Boolean) };
|
|
1858
|
+
if (args.all !== true && body.tables.length === 0) die('give --all or --tables schema.table,schema.table.', 2);
|
|
1859
|
+
const r = await apiJSON(creds, 'POST', `${path}/publications`, body);
|
|
1860
|
+
if (JSON_MODE) return emitJSON(r.replication);
|
|
1861
|
+
info(`${green('created')} publication ${bold(name)}`);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (action === 'unpub' || action === 'drop-slot') {
|
|
1865
|
+
const name = args._[3];
|
|
1866
|
+
if (!name) die(`usage: canner db replication <db> ${action} <name> --yes`, 2);
|
|
1867
|
+
requireYes(args, action === 'unpub' ? `This drops the publication "${name}"; subscribers that use it stop receiving changes.` : `This drops the slot "${name}" and disconnects its subscriber, which cannot resume.`);
|
|
1868
|
+
const r = await apiJSON(creds, 'DELETE', `${path}/${action === 'unpub' ? 'publications' : 'slots'}/${encodeURIComponent(name)}`);
|
|
1869
|
+
if (JSON_MODE) return emitJSON(r.replication);
|
|
1870
|
+
info(`${green('dropped')} ${action === 'unpub' ? 'publication' : 'slot'} ${bold(name)}`);
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
const r = (await apiJSON(creds, 'GET', path)).replication;
|
|
1874
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1875
|
+
info(`logical replication ${r.active ? green('on') : r.enabled ? yellow('saved, waiting for a restart') : dim('off')} ${dim(`(limit ${r.cap_mb} MB of WAL per slot)`)}`);
|
|
1876
|
+
for (const p of r.publications) info(` publication ${bold(p.name)} ${dim(p.all_tables ? 'all tables' : p.tables.join(', '))}`);
|
|
1877
|
+
for (const x of r.slots) {
|
|
1878
|
+
const tone = x.health === 'ok' ? green : x.health === 'lost' || x.health === 'critical' ? red : yellow;
|
|
1879
|
+
info(` slot ${bold(x.slot_name)} ${tone(x.health)} ${dim(`${x.retained_mb} MB held back (${x.percent_of_cap}% of the limit)`)}`);
|
|
1880
|
+
}
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
if (sub === 'subscriptions') {
|
|
1885
|
+
const label = args._[1] ?? 'primary';
|
|
1886
|
+
const action = args._[2];
|
|
1887
|
+
const path = `${base}/databases/${encodeURIComponent(label)}/subscriptions`;
|
|
1888
|
+
if (action === 'add') {
|
|
1889
|
+
const name = args._[3];
|
|
1890
|
+
if (!name || typeof args.publication !== 'string') die('usage: canner db subscriptions <db> add <name> --from <url>|--from-env VAR|--from - --publication p1,p2 [--no-copy]', 2);
|
|
1891
|
+
const url = await readSecretArg(args, 'from', 'from-env', 'connection URL of the server to follow');
|
|
1892
|
+
const body = { name, url, publications: args.publication.split(',').map((x) => x.trim()).filter(Boolean), copy_data: args['no-copy'] !== true };
|
|
1893
|
+
const r = await apiJSON(creds, 'POST', path, body);
|
|
1894
|
+
if (JSON_MODE) return emitJSON(r.subscription);
|
|
1895
|
+
info(`${green('following')} ${bold(r.subscription.publications.join(', '))} on ${r.subscription.host}:${r.subscription.port}/${r.subscription.remote_db} as ${bold(name)}`);
|
|
1896
|
+
muted(`The existing rows are copied first. Watch it with: canner db subscriptions ${label}`);
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
if (action === 'refresh' || action === 'pause' || action === 'resume') {
|
|
1900
|
+
const name = args._[3];
|
|
1901
|
+
if (!name) die(`usage: canner db subscriptions <db> ${action} <name>`, 2);
|
|
1902
|
+
const r = action === 'refresh'
|
|
1903
|
+
? await apiJSON(creds, 'POST', `${path}/${encodeURIComponent(name)}/refresh`)
|
|
1904
|
+
: await apiJSON(creds, 'PATCH', `${path}/${encodeURIComponent(name)}`, { enabled: action === 'resume' });
|
|
1905
|
+
if (JSON_MODE) return emitJSON(r.subscriptions);
|
|
1906
|
+
info(`${green(action === 'refresh' ? 'refreshed' : action === 'pause' ? 'paused' : 'resumed')} ${bold(name)}`);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
if (action === 'rm') {
|
|
1910
|
+
const name = args._[3];
|
|
1911
|
+
if (!name) die('usage: canner db subscriptions <db> rm <name> [--force] --yes', 2);
|
|
1912
|
+
requireYes(args, `This stops following with "${name}" and removes its replication slot on the other server. Rows already copied stay.`);
|
|
1913
|
+
await apiJSON(creds, 'DELETE', `${path}/${encodeURIComponent(name)}${args.force === true ? '?force=true' : ''}`);
|
|
1914
|
+
if (JSON_MODE) return emitJSON({ ok: true, removed: name });
|
|
1915
|
+
info(`${green('stopped')} following with ${bold(name)}`);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
const r = await apiJSON(creds, 'GET', path);
|
|
1919
|
+
if (JSON_MODE) return emitJSON(r);
|
|
1920
|
+
if (r.subscriptions.length === 0) { muted(`not following any server. Start with: canner db subscriptions ${label} add <name> --from-env URL_VAR --publication <name>`); return; }
|
|
1921
|
+
for (const x of r.subscriptions) {
|
|
1922
|
+
const tone = x.health === 'streaming' ? green : x.health === 'error' ? red : x.health === 'disabled' ? yellow : cyan;
|
|
1923
|
+
info(`${pad(bold(x.name), 22)}${tone(x.health)} ${dim(`${x.remote_user}@${x.host}:${x.port}/${x.remote_db} ${x.publications.join(',')}`)}`);
|
|
1924
|
+
info(` ${x.tables_ready}/${x.tables_total} tables in step${x.last_message_at ? `, last change ${relTime(x.last_message_at)}` : ''}${x.apply_errors ? red(`, ${x.apply_errors} error(s)`) : ''}`);
|
|
1925
|
+
}
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
if (sub === 'metrics') {
|
|
1930
|
+
const label = args._[1] ?? 'primary';
|
|
1931
|
+
const range = typeof args.range === 'string' ? args.range : '24h';
|
|
1932
|
+
const m = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(label)}/metrics?range=${encodeURIComponent(range)}`);
|
|
1933
|
+
if (JSON_MODE) return emitJSON(m);
|
|
1934
|
+
const pts = m.database.points;
|
|
1935
|
+
if (m.database.samples === 0) { muted('no samples yet: metrics appear a few minutes after a database is created.'); return; }
|
|
1936
|
+
const tx = pts.reduce((n, p) => n + p.transactions, 0);
|
|
1937
|
+
const conns = pts.map((p) => p.connections).filter((v) => v !== null);
|
|
1938
|
+
const hits = pts.map((p) => p.cache_hit).filter((v) => v !== null);
|
|
1939
|
+
info(`${bold(label)} last ${range}`);
|
|
1940
|
+
info(` transactions ${tx.toLocaleString()} (${pts.reduce((n, p) => n + p.rollbacks, 0).toLocaleString()} rolled back)`);
|
|
1941
|
+
info(` connections peak ${conns.length ? Math.max(...conns) : '—'}`);
|
|
1942
|
+
info(` cache hit ${hits.length ? (100 * hits.reduce((a, b) => a + b, 0) / hits.length).toFixed(1) + '%' : '—'}`);
|
|
1943
|
+
const last = [...pts].reverse().find((p) => p.size_bytes !== null);
|
|
1944
|
+
if (last) info(` size ${fmtBytes(last.size_bytes)}`);
|
|
1945
|
+
if (m.instance) {
|
|
1946
|
+
const ip = m.instance.points;
|
|
1947
|
+
const mem = ip.map((p) => p.mem_current).filter((v) => v !== null);
|
|
1948
|
+
const cpu = ip.map((p) => p.cpu_cores).filter((v) => v !== null);
|
|
1949
|
+
info(` instance memory peak ${mem.length ? fmtBytes(Math.max(...mem)) : '—'}, cpu peak ${cpu.length ? Math.max(...cpu).toFixed(2) + ' cores' : '—'}`);
|
|
1950
|
+
}
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
if (sub === 'monitoring') {
|
|
1955
|
+
const path = `${base}/instance/monitoring`;
|
|
1956
|
+
const action = args._[1];
|
|
1957
|
+
if (action === 'on' || action === 'off') {
|
|
1958
|
+
const on = action === 'on';
|
|
1959
|
+
requireYes(args, on ? 'Turning monitoring on restarts your instance; connections drop for a few seconds.' : 'Turning monitoring off removes query statistics and logs and restarts your instance for a few seconds.');
|
|
1960
|
+
const body = { enabled: on, confirm: true };
|
|
1961
|
+
if (args['slow-ms'] !== undefined) body.slow_ms = Number(args['slow-ms']);
|
|
1962
|
+
const r = await apiJSON(creds, 'POST', path, body);
|
|
1963
|
+
if (JSON_MODE) return emitJSON(r.monitoring);
|
|
1964
|
+
info(`${green(on ? 'on' : 'off')} query insights and logs${on ? `, logging statements slower than ${r.monitoring.slow_ms} ms` : ''}`);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
if (args['slow-ms'] !== undefined) {
|
|
1968
|
+
const r = await apiJSON(creds, 'POST', path, { enabled: true, slow_ms: Number(args['slow-ms']) });
|
|
1969
|
+
if (JSON_MODE) return emitJSON(r.monitoring);
|
|
1970
|
+
info(`${green('set')} slow-query threshold to ${r.monitoring.slow_ms} ms`);
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
const r = await apiJSON(creds, 'GET', path);
|
|
1974
|
+
if (JSON_MODE) return emitJSON(r.monitoring);
|
|
1975
|
+
info(`query insights and logs ${r.monitoring.enabled ? green('on') : dim('off')} (slow threshold ${r.monitoring.slow_ms} ms)`);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
if (sub === 'insights') {
|
|
1980
|
+
const label = args._[1] ?? 'primary';
|
|
1981
|
+
const path = `${base}/databases/${encodeURIComponent(label)}/insights`;
|
|
1982
|
+
if (args.reset === true) {
|
|
1983
|
+
requireYes(args, 'This clears the collected query statistics for this database.');
|
|
1984
|
+
await apiJSON(creds, 'POST', `${path}/reset`);
|
|
1985
|
+
if (JSON_MODE) return emitJSON({ ok: true });
|
|
1986
|
+
info(`${green('reset')} query statistics for ${bold(label)}`);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
const sort = typeof args.sort === 'string' ? args.sort : 'total';
|
|
1990
|
+
const r = await apiJSON(creds, 'GET', `${path}?sort=${encodeURIComponent(sort)}&limit=${Number(args.limit ?? 10)}`);
|
|
1991
|
+
if (JSON_MODE) return emitJSON(r.statements);
|
|
1992
|
+
if (r.statements.length === 0) { muted('no statements yet.'); return; }
|
|
1993
|
+
for (const st of r.statements) {
|
|
1994
|
+
info(`${dim(`${st.calls.toLocaleString()}x avg ${st.mean_ms} ms total ${Math.round(st.total_ms).toLocaleString()} ms max ${st.max_ms} ms`)}`);
|
|
1995
|
+
info(` ${st.query.replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
1996
|
+
}
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
if (sub === 'logs') {
|
|
2001
|
+
const label = args._[1] ?? 'primary';
|
|
2002
|
+
const q = new URLSearchParams();
|
|
2003
|
+
if (typeof args.level === 'string') q.set('level', args.level);
|
|
2004
|
+
if (args['min-ms'] !== undefined) q.set('min_ms', String(args['min-ms']));
|
|
2005
|
+
if (typeof args.search === 'string') q.set('q', args.search);
|
|
2006
|
+
q.set('limit', String(Number(args.limit ?? 50)));
|
|
2007
|
+
const r = await apiJSON(creds, 'GET', `${base}/databases/${encodeURIComponent(label)}/logs?${q.toString()}`);
|
|
2008
|
+
if (JSON_MODE) return emitJSON(r.entries);
|
|
2009
|
+
if (r.entries.length === 0) { muted('no matching log lines.'); return; }
|
|
2010
|
+
for (const e of r.entries) {
|
|
2011
|
+
const lvl = e.level === 'ERROR' || e.level === 'FATAL' || e.level === 'PANIC' ? red(e.level) : e.level === 'WARNING' ? yellow(e.level) : dim(e.level);
|
|
2012
|
+
info(`${dim(e.ts)} ${lvl} ${e.message.replace(/\s+/g, ' ')}`);
|
|
2013
|
+
if (e.detail) info(` ${dim(e.detail.replace(/\s+/g, ' '))}`);
|
|
2014
|
+
}
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
if (sub === 'roles') {
|
|
2019
|
+
const label = args._[1] ?? 'primary';
|
|
2020
|
+
const action = args._[2];
|
|
2021
|
+
const path = `${base}/databases/${encodeURIComponent(label)}/roles`;
|
|
2022
|
+
if (action === 'create') {
|
|
2023
|
+
const name = args._[3];
|
|
2024
|
+
if (!name) die('usage: canner db roles <db> create <name> --read-only | --read-write | --replication', 2);
|
|
2025
|
+
const picked = ['read-only', 'read-write', 'replication'].filter((k) => args[k] === true);
|
|
2026
|
+
if (picked.length !== 1) die('choose exactly one of --read-only, --read-write or --replication.', 2);
|
|
2027
|
+
const r = await apiJSON(creds, 'POST', path, { label: name, kind: picked[0].replace('-', '_') });
|
|
2028
|
+
if (JSON_MODE) return emitJSON(r.role);
|
|
2029
|
+
info(`${green('created')} ${r.role.kind === 'read_write' ? 'read-write' : r.role.kind === 'replication' ? 'replication' : 'read-only'} role ${bold(r.role.label)}`);
|
|
2030
|
+
info(` ${cyan(r.role.connection_string)}`);
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
if (action === 'rotate' || action === 'rm') {
|
|
2034
|
+
const name = args._[3];
|
|
2035
|
+
if (!name) die(`usage: canner db roles <db> ${action} <name> --yes`, 2);
|
|
2036
|
+
requireYes(args, action === 'rm' ? `This drops the role "${name}"; anything using its connection string stops working.` : `This gives "${name}" a new password; anything using the old one stops working.`);
|
|
2037
|
+
if (action === 'rm') {
|
|
2038
|
+
await apiJSON(creds, 'DELETE', `${path}/${encodeURIComponent(name)}`);
|
|
2039
|
+
if (JSON_MODE) return emitJSON({ ok: true, deleted: name });
|
|
2040
|
+
info(`${green('deleted')} role ${bold(name)}`);
|
|
2041
|
+
} else {
|
|
2042
|
+
const r = await apiJSON(creds, 'POST', `${path}/${encodeURIComponent(name)}/rotate`);
|
|
2043
|
+
if (JSON_MODE) return emitJSON(r.role);
|
|
2044
|
+
info(`${green('rotated')} ${bold(name)}: ${cyan(r.role.connection_string)}`);
|
|
2045
|
+
}
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
const r = await apiJSON(creds, 'GET', path);
|
|
2049
|
+
if (JSON_MODE) return emitJSON(r.roles);
|
|
2050
|
+
if (r.roles.length === 0) { muted(`no roles on ${label}. Create one with \`canner db roles ${label} create <name> --read-only\`.`); return; }
|
|
2051
|
+
for (const x of r.roles) {
|
|
2052
|
+
info(`${pad(bold(x.label), 22)}${x.kind === 'read_write' ? yellow('read-write') : x.kind === 'replication' ? cyan('replication') : green('read-only')} ${dim(relTime(x.created_at))}`);
|
|
2053
|
+
info(` ${cyan(x.connection_string)}`);
|
|
2054
|
+
if (x.external_connection_string) info(` ${cyan(x.external_connection_string)}`);
|
|
2055
|
+
}
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
if (sub === 'offsite') {
|
|
2060
|
+
const action = args._[1];
|
|
2061
|
+
const path = `${base}/backup-destinations`;
|
|
2062
|
+
const listAll = async () => (await apiJSON(creds, 'GET', path)).destinations;
|
|
2063
|
+
const find = async (ref) => {
|
|
2064
|
+
const all = await listAll();
|
|
2065
|
+
const hit = all.find((d) => d.id === ref || d.name.toLowerCase() === String(ref).toLowerCase());
|
|
2066
|
+
if (!hit) die(`no destination "${ref}". List them with: canner db offsite`, 1);
|
|
2067
|
+
return hit;
|
|
2068
|
+
};
|
|
2069
|
+
const fields = (body) => {
|
|
2070
|
+
if (typeof args.endpoint === 'string') body.endpoint = args.endpoint;
|
|
2071
|
+
if (typeof args.bucket === 'string') body.bucket = args.bucket;
|
|
2072
|
+
if (typeof args.region === 'string') body.region = args.region;
|
|
2073
|
+
if (typeof args.folder === 'string') body.prefix = args.folder;
|
|
2074
|
+
if (typeof args['access-key'] === 'string') body.access_key = args['access-key'];
|
|
2075
|
+
if (typeof args['secret-key'] === 'string') body.secret_key = args['secret-key'];
|
|
2076
|
+
return body;
|
|
2077
|
+
};
|
|
2078
|
+
if (action === 'add') {
|
|
2079
|
+
const name = args._[2];
|
|
2080
|
+
const body = fields({ name });
|
|
2081
|
+
if (!name || !body.endpoint || !body.bucket || !body.access_key || !body.secret_key) {
|
|
2082
|
+
die('usage: canner db offsite add <name> --endpoint HOST --bucket NAME --access-key KEY --secret-key SECRET [--region R] [--folder F]', 2);
|
|
2083
|
+
}
|
|
2084
|
+
const r = await apiJSON(creds, 'POST', path, body);
|
|
2085
|
+
if (JSON_MODE) return emitJSON(r.destination);
|
|
2086
|
+
info(`${green('added')} off-site destination ${bold(r.destination.name)} (${r.destination.bucket} at ${r.destination.endpoint}). Test it: canner db offsite test ${r.destination.name}`);
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
if (action === 'edit') {
|
|
2090
|
+
if (!args._[2]) die('usage: canner db offsite edit <name> [--new-name N] [--endpoint HOST] [--bucket NAME] [--access-key KEY] [--secret-key SECRET] [--region R] [--folder F]', 2);
|
|
2091
|
+
const cur = await find(args._[2]);
|
|
2092
|
+
const body = fields({ name: typeof args['new-name'] === 'string' ? args['new-name'] : cur.name });
|
|
2093
|
+
body.endpoint ??= cur.endpoint; body.bucket ??= cur.bucket; body.access_key ??= cur.access_key; body.region ??= cur.region; body.prefix ??= cur.prefix;
|
|
2094
|
+
const r = await apiJSON(creds, 'PUT', `${path}/${cur.id}`, body);
|
|
2095
|
+
if (JSON_MODE) return emitJSON(r.destination);
|
|
2096
|
+
info(`${green('saved')} ${bold(r.destination.name)}. Test it: canner db offsite test ${r.destination.name}`);
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
if (action === 'test') {
|
|
2100
|
+
if (!args._[2]) die('usage: canner db offsite test <name>', 2);
|
|
2101
|
+
const cur = await find(args._[2]);
|
|
2102
|
+
const r = await apiJSON(creds, 'POST', `${path}/${cur.id}/test`);
|
|
2103
|
+
if (JSON_MODE) return emitJSON(r);
|
|
2104
|
+
if (r.ok) info(green(`${cur.name}: the bucket accepted a test file.`));
|
|
2105
|
+
else die(`${cur.name}: the test failed: ${r.error}`);
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
if (action === 'rm') {
|
|
2109
|
+
if (!args._[2]) die('usage: canner db offsite rm <name> --yes', 2);
|
|
2110
|
+
const cur = await find(args._[2]);
|
|
2111
|
+
requireYes(args, `This removes "${cur.name}"${cur.databases ? `; ${cur.databases} database(s) stop copying backups off-site` : ''}. Files already in your bucket stay there but can no longer be used to restore.`);
|
|
2112
|
+
await apiJSON(creds, 'DELETE', `${path}/${cur.id}`);
|
|
2113
|
+
if (JSON_MODE) return emitJSON({ ok: true });
|
|
2114
|
+
info(`${green('removed')} ${bold(cur.name)}`);
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
const all = await listAll();
|
|
2118
|
+
if (JSON_MODE) return emitJSON(all);
|
|
2119
|
+
if (all.length === 0) { muted('no off-site destinations. Add one with `canner db offsite add <name> --endpoint HOST --bucket NAME --access-key KEY --secret-key SECRET`.'); return; }
|
|
2120
|
+
for (const d of all) {
|
|
2121
|
+
info(`${bold(d.name)} ${d.bucket} at ${cyan(d.endpoint)} region ${d.region} folder ${d.prefix || dim('(root)')}`);
|
|
2122
|
+
info(` access key ${d.access_key} ${d.last_test_at ? (d.last_test_ok ? green('test ok') : red('test failed')) + ' ' + dim(relTime(d.last_test_at)) : dim('not tested')} ${d.databases ? dim(`${d.databases} database(s)`) : ''}`);
|
|
2123
|
+
}
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
if (sub === 'alerts') {
|
|
2128
|
+
const action = args._[1];
|
|
2129
|
+
const path = `${base}/database-alerts`;
|
|
2130
|
+
if (action === 'test') {
|
|
2131
|
+
const r = await apiJSON(creds, 'POST', `${path}/test`);
|
|
2132
|
+
if (JSON_MODE) return emitJSON(r);
|
|
2133
|
+
info(`${green('sent')} ${r.emails} email${r.emails === 1 ? '' : 's'}${r.webhook === 'sent' ? ' and the webhook' : r.webhook === 'failed' ? `; the webhook ${red('failed')}: ${r.error}` : ''}`);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
if (action === 'set') {
|
|
2137
|
+
const patch = {};
|
|
2138
|
+
const onOff = (v, name) => { if (v !== 'on' && v !== 'off') die(`--${name} takes on or off.`, 2); return v === 'on'; };
|
|
2139
|
+
if (args.email !== undefined) patch.email_enabled = onOff(args.email, 'email');
|
|
2140
|
+
if (args.verify !== undefined) patch.verify_backups = onOff(args.verify, 'verify');
|
|
2141
|
+
if (args.recipients !== undefined) patch.recipients = args.recipients === 'all' ? [] : String(args.recipients).split(',').map((x) => x.trim()).filter(Boolean);
|
|
2142
|
+
if (args.webhook !== undefined) patch.webhook_url = args.webhook === 'none' ? null : String(args.webhook);
|
|
2143
|
+
if (args['rotate-secret'] === true) patch.rotate_webhook_secret = true;
|
|
2144
|
+
if (Object.keys(patch).length === 0) die('nothing to change. Use --email, --recipients, --webhook, --rotate-secret or --verify.', 2);
|
|
2145
|
+
const r = await apiJSON(creds, 'PATCH', path, patch);
|
|
2146
|
+
if (JSON_MODE) return emitJSON(r);
|
|
2147
|
+
info(`${green('saved')} alert settings`);
|
|
2148
|
+
if (r.webhook_secret) info(` webhook signing secret (shown once): ${cyan(r.webhook_secret)}`);
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
const r = await apiJSON(creds, 'GET', path);
|
|
2152
|
+
if (JSON_MODE) return emitJSON(r);
|
|
2153
|
+
const c = r.settings;
|
|
2154
|
+
info(`${bold('email')} ${c.email_enabled ? green('on') : dim('off')} → ${r.effective_recipients.join(', ') || dim('(nobody)')}${c.recipients.length ? '' : dim(' (all org admins)')}`);
|
|
2155
|
+
info(`${bold('webhook')} ${c.webhook_url ? cyan(c.webhook_url) : dim('none')}`);
|
|
2156
|
+
info(`${bold('verify')} ${c.verify_backups ? green('on') : dim('off')} ${dim('(test-restore backups overnight)')}`);
|
|
2157
|
+
const open = r.incidents.filter((i) => i.state === 'open');
|
|
2158
|
+
if (open.length === 0) muted('no open problems.');
|
|
2159
|
+
for (const i of open) info(` ${i.severity === 'critical' ? red(i.kind) : yellow(i.kind)} ${i.database ?? ''} ${dim(relTime(i.first_seen_at))}${i.detail?.error ? ' ' + dim(i.detail.error) : ''}`);
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
if (sub === 'instance') {
|
|
2164
|
+
const action = args._[1];
|
|
2165
|
+
const path = `${base}/instance`;
|
|
2166
|
+
|
|
2167
|
+
if (!action || action === 'show') {
|
|
2168
|
+
const body = await apiJSON(creds, 'GET', path);
|
|
2169
|
+
if (JSON_MODE) return emitJSON(body);
|
|
2170
|
+
const i = body?.instance;
|
|
2171
|
+
if (!i) {
|
|
2172
|
+
muted(body?.allowed ? 'no dedicated instance yet. Create one with `canner db instance create`.' : 'A dedicated Postgres instance is a paid add-on for this project.');
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
info(`${bold('dedicated instance')} ${i.state === 'running' ? green(i.state) : yellow(i.state)} ${dim(`Postgres ${i.pg_version} · ${i.databases} database${i.databases === 1 ? '' : 's'}`)}`);
|
|
2176
|
+
info(` point-in-time recovery ${i.pitr_enabled ? green(`on (${i.pitr_window_days}-day window)`) : dim('off')}${i.pitr_enabled ? dim(` archive ${i.archive_mb} MB`) : ''}`);
|
|
2177
|
+
if (i.error) info(` ${red(i.error)}`);
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
if (action === 'create') {
|
|
2182
|
+
const body = await apiJSON(creds, 'POST', path, {
|
|
2183
|
+
...(args.pg ? { pg_version: String(args.pg) } : {}),
|
|
2184
|
+
...(args.pitr === true ? { pitr: true } : {}),
|
|
2185
|
+
...(args.window ? { window_days: Number(args.window) } : {}),
|
|
2186
|
+
});
|
|
2187
|
+
if (JSON_MODE) return emitJSON(body);
|
|
2188
|
+
const i = body?.instance;
|
|
2189
|
+
info(`${green('created')} a dedicated Postgres ${i.pg_version} instance${i.pitr_enabled ? ` with point-in-time recovery (${i.pitr_window_days}-day window)` : ''}`);
|
|
2190
|
+
muted('Add databases to it with `canner db create <name> --instance`, or move one with `canner db move <name> --to instance --yes`.');
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
if (action === 'stop' || action === 'start') {
|
|
2195
|
+
if (action === 'stop') requireYes(args, 'Stopping takes every database on the instance offline (data is kept).');
|
|
2196
|
+
const body = await apiJSON(creds, 'POST', `${path}/power`, { action, ...(action === 'stop' ? { confirm: true } : {}) });
|
|
2197
|
+
if (JSON_MODE) return emitJSON(body);
|
|
2198
|
+
info(`${green(action === 'stop' ? 'stopped' : 'started')} the dedicated instance`);
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
if (action === 'destroy') {
|
|
2203
|
+
requireYes(args, 'This permanently destroys the instance and its point-in-time-recovery archive. Every database on it must be moved or deleted first.');
|
|
2204
|
+
await apiJSON(creds, 'DELETE', `${path}?confirm=true`);
|
|
2205
|
+
if (JSON_MODE) return emitJSON({ ok: true });
|
|
2206
|
+
info(`${green('destroyed')} the dedicated instance`);
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
die('usage: canner db instance [show] | create [--pg 17|18] [--pitr] [--window DAYS] | start | stop --yes | destroy --yes', 2);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
if (sub === 'pitr') {
|
|
2214
|
+
const path = `${base}/instance/pitr`;
|
|
2215
|
+
if (args.now === true) {
|
|
2216
|
+
const started = await apiJSON(creds, 'POST', `${base}/instance/base-backups`);
|
|
2217
|
+
const op = started?.operation;
|
|
2218
|
+
if (!op) die('the platform did not return an operation.');
|
|
2219
|
+
if (args['no-wait'] === true) { if (JSON_MODE) return emitJSON(op); info(`${green('started')} base backup ${dim(op.id)}`); return; }
|
|
2220
|
+
if (!JSON_MODE) info('taking a base backup…');
|
|
2221
|
+
const done = await waitForOperation(creds, base, op.id, args);
|
|
2222
|
+
if (JSON_MODE) return emitJSON(done);
|
|
2223
|
+
info(`${green('done')} ${done.result?.bytes != null ? dim(`(${fmtBytes(done.result.bytes)})`) : ''}`);
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
if (args.offsite !== undefined) {
|
|
2227
|
+
// --offsite <destination name|id> | --offsite none
|
|
2228
|
+
let destination_id = null;
|
|
2229
|
+
if (args.offsite !== 'none') {
|
|
2230
|
+
const dl = await apiJSON(creds, 'GET', `${base}/backup-destinations`);
|
|
2231
|
+
const want = String(args.offsite);
|
|
2232
|
+
const hit = dl.destinations.find((d) => d.id === want || d.name.toLowerCase() === want.toLowerCase());
|
|
2233
|
+
if (!hit) die(`no off-site destination named "${want}". Add one first: canner db offsite add ...`, 2);
|
|
2234
|
+
destination_id = hit.id;
|
|
2235
|
+
}
|
|
2236
|
+
const body = await apiJSON(creds, 'PATCH', `${base}/instance/archive-offsite`, { destination_id });
|
|
2237
|
+
if (JSON_MODE) return emitJSON(body);
|
|
2238
|
+
const o = body.archive_offsite;
|
|
2239
|
+
info(o.destination_id ? `${green('set')} the recovery archive is copied off-site to ${bold(o.destination_name)}` : `${green('set')} the recovery archive is no longer copied off-site`);
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
const setting = args.enable === true || args.disable === true || args.window !== undefined || args.every !== undefined;
|
|
2243
|
+
if (setting) {
|
|
2244
|
+
if (args.disable === true) requireYes(args, 'Turning off point-in-time recovery permanently deletes the recovery archive.');
|
|
2245
|
+
const patch = {};
|
|
2246
|
+
if (args.enable === true) patch.enabled = true;
|
|
2247
|
+
if (args.disable === true) { patch.enabled = false; patch.confirm = true; }
|
|
2248
|
+
if (args.window !== undefined) patch.window_days = Number(args.window);
|
|
2249
|
+
if (args.every !== undefined) patch.base_backup_interval_hours = Number(args.every);
|
|
2250
|
+
const body = await apiJSON(creds, 'PATCH', path, patch);
|
|
2251
|
+
if (JSON_MODE) return emitJSON(body);
|
|
2252
|
+
const i = body.instance;
|
|
2253
|
+
info(`${green('set')} point-in-time recovery: ${i.pitr_enabled ? `on, ${i.pitr_window_days}-day window, base backup every ${i.base_backup_interval_hours}h` : 'off'}`);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
const body = await apiJSON(creds, 'GET', path);
|
|
2257
|
+
if (JSON_MODE) return emitJSON(body);
|
|
2258
|
+
const p = body.pitr;
|
|
2259
|
+
info(`${bold('point-in-time recovery')} ${p.enabled ? green('on') : dim('off')}`);
|
|
2260
|
+
if (p.enabled) {
|
|
2261
|
+
info(` restorable from ${p.earliest ? cyan(p.earliest) : dim('(first base backup pending)')} to ${p.latest_archived ? cyan(p.latest_archived) : dim('now')}`);
|
|
2262
|
+
info(` ${p.window_days}-day window · base backup every ${p.base_backup_interval_hours}h · ${p.base_backups} base backup${p.base_backups === 1 ? '' : 's'} · archive ${p.archive_mb} MB`);
|
|
2263
|
+
if (p.archive_failures > 0) info(` ${red(`${p.archive_failures} WAL archive failure(s) — contact support`)}`);
|
|
2264
|
+
const o = body.archive_offsite;
|
|
2265
|
+
if (o?.destination_id) {
|
|
2266
|
+
info(` off-site copy: ${bold(o.destination_name)} · ${o.files} file${o.files === 1 ? '' : 's'} (${fmtBytes(o.bytes)})${o.pending ? ` · ${o.pending} waiting` : ''}${o.last_at ? ` · last ${relTime(o.last_at)}` : ''}`);
|
|
2267
|
+
if (o.error) info(` ${red(`last copy failed: ${o.error}`)}`);
|
|
2268
|
+
} else if (p.archive_encrypted) {
|
|
2269
|
+
info(` off-site copy: ${dim('off (canner db pitr --offsite <destination>)')}`);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
if (sub === 'pitr-restore') {
|
|
2276
|
+
const label = args._[1];
|
|
2277
|
+
const to = typeof args.to === 'string' ? args.to : null;
|
|
2278
|
+
if (!label || !to) die('usage: canner db pitr-restore <name> --to <ISO-8601 time | latest>', 2);
|
|
2279
|
+
const started = await apiJSON(creds, 'POST', `${base}/databases/${encodeURIComponent(label)}/pitr-restore`, { target: to });
|
|
2280
|
+
const op = started?.operation;
|
|
2281
|
+
const into = started?.database?.name;
|
|
2282
|
+
if (!op) die('the platform did not return an operation.');
|
|
2283
|
+
if (args['no-wait'] === true) {
|
|
2284
|
+
if (JSON_MODE) return emitJSON(started);
|
|
2285
|
+
info(`${green('started')} recovery ${dim(op.id)} → ${bold(into)}`);
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
if (!JSON_MODE) info(`recovering ${label} as of ${to} into ${into}…`);
|
|
2289
|
+
const done = await waitForOperation(creds, base, op.id, args);
|
|
2290
|
+
if (JSON_MODE) return emitJSON({ ...done, database: started.database });
|
|
2291
|
+
info(`${green('recovered')} ${bold(label)} as of ${to} → new database ${bold(into)}`);
|
|
2292
|
+
muted(`Nothing existing was touched. To swap it in: canner db copy ${into} ${label} --replace`);
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
if (sub === 'move') {
|
|
2297
|
+
const label = args._[1];
|
|
2298
|
+
const to = typeof args.to === 'string' ? args.to : null;
|
|
2299
|
+
if (!label || (to !== 'instance' && to !== 'shared')) die('usage: canner db move <name> --to instance|shared --yes', 2);
|
|
2300
|
+
requireYes(args, 'Moving a database has real downtime while it runs (the connection string does not change; a failed move leaves it untouched).');
|
|
2301
|
+
const done = await startAndWait(creds, base, args, 'POST', `${base}/databases/${encodeURIComponent(label)}/move`, { to, confirm: true }, 'moving');
|
|
2302
|
+
if (!done) return;
|
|
2303
|
+
if (JSON_MODE) return emitJSON(done);
|
|
2304
|
+
info(`${green('moved')} ${bold(label)} to ${to === 'instance' ? 'the dedicated instance' : 'shared hosting'}`);
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
die(`unknown db subcommand "${sub}". Use: list | init | create | info | url | rm | pg-version | export | import | migrate-in | migrate-out | copy | ops | external | rotate | backup-policy | backup | backups | diff | inspect | alerts | roles | offsite | metrics | monitoring | insights | logs | resources | extensions | tuning | restart | replication | subscriptions | instance | pitr | pitr-restore | move.`, 2);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
function printHelp() {
|
|
2312
|
+
process.stdout.write(`${bold('canner')} ${dim('— deploy to Canner from any terminal')}
|
|
2313
|
+
|
|
2314
|
+
${bold('Usage:')}
|
|
2315
|
+
canner <command> [options]
|
|
2316
|
+
|
|
2317
|
+
${bold('Setup:')}
|
|
2318
|
+
${bold('login')} Store your Canner API token (one-time).
|
|
2319
|
+
${bold('logout')} Remove the stored token from this machine.
|
|
2320
|
+
${bold('whoami')} Show the account this CLI is signed in as.
|
|
2321
|
+
|
|
2322
|
+
${bold('Projects:')}
|
|
2323
|
+
${bold('projects')} List your projects.
|
|
2324
|
+
${bold('init')} [--slug X] Create a project and write ${cyan(PROJECT_CONFIG_FILE)}.
|
|
2325
|
+
${bold('status')} [--slug X] Show project state (URL, status, source, last request).
|
|
2326
|
+
${bold('open')} [--slug X] [--dashboard]
|
|
2327
|
+
Open the tenant URL (or dashboard page) in your browser.
|
|
2328
|
+
|
|
2329
|
+
${bold('Deploys:')}
|
|
2330
|
+
${bold('deploy')} [path] [--slug X] [--follow]
|
|
2331
|
+
Tar the current directory and upload. Works for any
|
|
2332
|
+
project (GitHub-connected or upload-only).
|
|
2333
|
+
${bold('redeploy')} [--slug X] [--follow]
|
|
2334
|
+
Trigger a rebuild from the GitHub repo's HEAD without
|
|
2335
|
+
uploading. GitHub-connected projects only.
|
|
2336
|
+
${bold('deployments')} [--slug X]
|
|
2337
|
+
List the project's recent deployments.
|
|
2338
|
+
${bold('deployments')} <id> [--slug X] [--follow]
|
|
2339
|
+
Show one deployment's build log (--follow streams if
|
|
2340
|
+
it's still in progress).
|
|
2341
|
+
${bold('cancel')} [<id>] [--slug X]
|
|
2342
|
+
Abort the in-flight build (or a specific one by id).
|
|
2343
|
+
${bold('logs')} [--slug X] [--lines N] [--follow]
|
|
2344
|
+
Stream the running tenant's runtime logs (last 200
|
|
2345
|
+
lines by default, --follow to keep streaming).
|
|
2346
|
+
|
|
2347
|
+
${bold('Resources:')}
|
|
2348
|
+
${bold('token')} list | create <name> | revoke <id|name>
|
|
2349
|
+
Manage the cnr_ API tokens that authenticate this CLI.
|
|
2350
|
+
${bold('env')} [list] [--reveal] [--slug X]
|
|
2351
|
+
${bold('env')} set KEY=VALUE [--env production|preview] [--sensitive] [--slug X]
|
|
2352
|
+
${bold('env')} rm <KEY|id> [--env ...] [--slug X]
|
|
2353
|
+
Manage a project's environment variables. Sensitive
|
|
2354
|
+
values are redacted as •••••• (or null in --json) on
|
|
2355
|
+
list; pass --reveal to see them in cleartext.
|
|
2356
|
+
${bold('domains')} [list] [--slug X]
|
|
2357
|
+
${bold('domains')} add <hostname> [--slug X]
|
|
2358
|
+
${bold('domains')} verify <hostname|id> [--slug X]
|
|
2359
|
+
${bold('domains')} rm <hostname|id> [--slug X]
|
|
2360
|
+
Manage a project's custom domains.
|
|
2361
|
+
|
|
2362
|
+
${bold('Databases:')}
|
|
2363
|
+
${bold('db')} [list] [--slug X] List the project's databases.
|
|
2364
|
+
${bold('db')} init | create <name> [--pg 17|18]
|
|
2365
|
+
Provision the primary database, or add another (paid).
|
|
2366
|
+
${bold('db')} info [<name>] [--reveal] | url [<name>]
|
|
2367
|
+
Connection details (password hidden unless --reveal).
|
|
2368
|
+
${bold('db')} export [<name>] [--format custom|plain] [--out FILE]
|
|
2369
|
+
${bold('db')} import <file> [--to <name>] [--replace]
|
|
2370
|
+
${bold('db')} migrate-in --from <url>|--from-env VAR [--to <name>] [--replace]
|
|
2371
|
+
${bold('db')} migrate-out --to-url <url>|--to-env VAR [--from <name>] --yes
|
|
2372
|
+
${bold('db')} copy <from> <to> [--to-project SLUG] [--replace]
|
|
2373
|
+
${bold('db')} rm <name> --yes | pg-version <name> <17|18> --yes | ops [<id>]
|
|
2374
|
+
Move data in and out, copy between databases, and
|
|
2375
|
+
manage them. Long operations run on Canner; the CLI
|
|
2376
|
+
waits (or use --no-wait).
|
|
2377
|
+
${bold('db')} external [<name>] Show external-access status.
|
|
2378
|
+
${bold('db')} external <name> --enable --allow CIDR [--allow CIDR ...]
|
|
2379
|
+
Turn on public access, allowed from only these
|
|
2380
|
+
addresses (repeat --allow; or --allow-all --yes for
|
|
2381
|
+
0.0.0.0/0 — not recommended).
|
|
2382
|
+
${bold('db')} external <name> --disable Turn public access off.
|
|
2383
|
+
${bold('db')} rotate <name> --yes Rotate the database password.
|
|
2384
|
+
${bold('db')} backup-policy [<name>] Show a database's backup schedule.
|
|
2385
|
+
${bold('db')} backup-policy <name> --enable --every HOURS --keep DAYS [--keep-count N|none]
|
|
2386
|
+
${bold('db')} backup-policy <name> --disable
|
|
2387
|
+
Turn scheduled backups on/off and set how often and how
|
|
2388
|
+
long they're kept (paid plans).
|
|
2389
|
+
${bold('db')} backup [<name>] Take a manual backup now (waits by
|
|
2390
|
+
default, or use --no-wait).
|
|
2391
|
+
${bold('db')} backups [<name>] List backups for a database.
|
|
2392
|
+
${bold('db')} backups <name> <id> Show one backup.
|
|
2393
|
+
${bold('db')} backups <name> <id> --download [--out FILE]
|
|
2394
|
+
Download the decrypted dump.
|
|
2395
|
+
${bold('db')} backups <name> <id> --restore --inspect
|
|
2396
|
+
Restore into a temporary inspect database (own
|
|
2397
|
+
connection string, never injected, expires).
|
|
2398
|
+
${bold('db')} backups <name> <id> --verify
|
|
2399
|
+
Test-restore the backup into a scratch database.
|
|
2400
|
+
${bold('db')} diff <name> [--against <name>]
|
|
2401
|
+
Compare tables, columns and row counts (default: the
|
|
2402
|
+
database an inspect database was restored from).
|
|
2403
|
+
${bold('db')} inspect <name> --keep | --extend HOURS
|
|
2404
|
+
${bold('db')} resources Show the memory budget shared by the app and the dedicated instance.
|
|
2405
|
+
${bold('db')} resources set [--share PCT] [--app-reserved GB] Postgres's share of the shared memory (10-90); reserved RAM kept for the app.
|
|
2406
|
+
${bold('db')} resources apply --yes Size Postgres for it and set its memory limit (restarts the instance).
|
|
2407
|
+
${bold('db')} extensions [<name>] [install|update|rm <ext>] [--window] [--yes] Extensions on the dedicated instance (pgvector, PostGIS, pg_trgm, pg_cron, ...).
|
|
2408
|
+
${bold('db')} tuning [--preset balanced|read-heavy|write-heavy|analytics] [--statement-timeout S] [--idle-timeout S] [--lock-timeout S]
|
|
2409
|
+
[--window-day 0-6|all] [--window-hour 0-23] [--window-tz ZONE] Workload preset, timeouts and the maintenance window.
|
|
2410
|
+
${bold('db')} restart --yes Restart the dedicated instance now (also applies settings waiting for the window).
|
|
2411
|
+
${bold('db')} replication [<name>] [on [--cap MB] [--window] --yes | off --yes | cap MB | pub <n> --all|--tables a.b,c.d | unpub <n> --yes | drop-slot <n> --yes]
|
|
2412
|
+
Logical replication: turn on (restarts), the WAL cap per slot, publications, slots. Subscribers sign in with \`db roles <name> create <n> --replication\`.
|
|
2413
|
+
${bold('db')} subscriptions [<name>] [add <n> --from <url>|--from-env VAR --publication p1,p2 [--no-copy] | refresh|pause|resume <n> | rm <n> [--force] --yes]
|
|
2414
|
+
Follow publications on an external server (live copy / no-downtime migration).
|
|
2415
|
+
${bold('db')} metrics [<name>] [--range 1h|24h|7d|30d] Transactions, connections, cache hit, size (and instance memory/CPU).
|
|
2416
|
+
${bold('db')} monitoring [on|off --yes] [--slow-ms N] Query insights + logs on the dedicated instance (restarts it).
|
|
2417
|
+
${bold('db')} insights [<name>] [--sort total|mean|calls|max] [--limit N] | --reset --yes
|
|
2418
|
+
${bold('db')} logs [<name>] [--level ERROR,WARNING] [--min-ms N] [--search TEXT] [--limit N]
|
|
2419
|
+
${bold('db')} roles [<name>] List a database's roles (read-only / read-write logins).
|
|
2420
|
+
${bold('db')} roles <name> create <role> --read-only | --read-write
|
|
2421
|
+
${bold('db')} roles <name> rotate <role> --yes
|
|
2422
|
+
${bold('db')} roles <name> rm <role> --yes
|
|
2423
|
+
${bold('db')} offsite List the off-site backup destinations (S3-compatible buckets).
|
|
2424
|
+
${bold('db')} offsite add <name> --endpoint HOST --bucket NAME --access-key KEY --secret-key SECRET [--region R] [--folder F]
|
|
2425
|
+
${bold('db')} offsite edit <name> [--new-name N] [--endpoint HOST] [--bucket NAME] [--access-key KEY] [--secret-key SECRET] [--region R] [--folder F]
|
|
2426
|
+
${bold('db')} offsite test <name> | rm <name> --yes
|
|
2427
|
+
${bold('db')} backup-policy <name> --offsite <destination>|off Copy this database's backups off-site to that destination.
|
|
2428
|
+
${bold('db')} backups <name> <id> --offsite [destination] Copy one backup off-site now (default: the policy's destination).
|
|
2429
|
+
${bold('db')} alerts Show alert settings and open problems.
|
|
2430
|
+
${bold('db')} alerts set [--email on|off] [--recipients a@x.com,b@y.com|all]
|
|
2431
|
+
[--webhook URL|none] [--rotate-secret] [--verify on|off]
|
|
2432
|
+
${bold('db')} alerts test Send a test alert.
|
|
2433
|
+
${bold('db')} backups <name> <id> --restore [--no-wait]
|
|
2434
|
+
Restore into a brand-new database (safe default).
|
|
2435
|
+
${bold('db')} backups <name> <id> --restore --to <name> --yes
|
|
2436
|
+
Restore IN PLACE into an existing database, replacing
|
|
2437
|
+
its contents (Canner snapshots it first).
|
|
2438
|
+
${bold('db')} backups <name> <id> --rm --yes
|
|
2439
|
+
Delete a backup (destroys its key immediately).
|
|
2440
|
+
${bold('db')} instance [create [--pg 17|18] [--pitr] [--window DAYS] | start | stop --yes | destroy --yes]
|
|
2441
|
+
A dedicated Postgres server for this project (paid add-on).
|
|
2442
|
+
${bold('db')} create <name> --instance Create a database on the dedicated instance.
|
|
2443
|
+
${bold('db')} move <name> --to instance|shared --yes
|
|
2444
|
+
Move a database onto/off the instance (same connection string).
|
|
2445
|
+
${bold('db')} pitr [--enable | --disable --yes] [--window DAYS] [--every HOURS] [--now] [--offsite <destination>|none]
|
|
2446
|
+
Point-in-time recovery: status, settings, or a base backup now.
|
|
2447
|
+
${bold('db')} pitr-restore <name> --to <ISO time | latest>
|
|
2448
|
+
Recover a database as of a moment into a NEW database.
|
|
2449
|
+
|
|
2450
|
+
${bold('Global:')}
|
|
2451
|
+
${bold('--json')} Emit machine-readable JSON (for scripts, CI, agents).
|
|
2452
|
+
Suppresses tables + progress; --follow log streaming is
|
|
2453
|
+
skipped. Errors go to stderr as {"error":"..."}.
|
|
2454
|
+
|
|
2455
|
+
${bold('Examples:')}
|
|
2456
|
+
canner login
|
|
2457
|
+
cd my-site && canner init
|
|
2458
|
+
canner deploy --follow ${dim('# build to live, streaming logs')}
|
|
2459
|
+
canner redeploy --follow ${dim('# rebuild from GitHub HEAD')}
|
|
2460
|
+
canner deployments ${dim('# list recent deploys')}
|
|
2461
|
+
canner deployments 7bf4862e --follow ${dim('# inspect a specific one')}
|
|
2462
|
+
canner logs --follow ${dim('# tail tenant runtime logs')}
|
|
2463
|
+
canner cancel ${dim('# abort the current build')}
|
|
2464
|
+
canner open --dashboard ${dim('# open dashboard project page')}
|
|
2465
|
+
canner token create ci ${dim('# mint a token named "ci"')}
|
|
2466
|
+
canner env set API_KEY=secret --sensitive ${dim('# add a hidden env var')}
|
|
2467
|
+
canner env list --reveal ${dim('# show sensitive values in cleartext')}
|
|
2468
|
+
canner domains add www.example.com ${dim('# attach a custom domain')}
|
|
2469
|
+
canner db export --out backup.dump ${dim('# download a pg_dump of the primary database')}
|
|
2470
|
+
canner db import backup.dump --to analytics ${dim('# restore a dump into another database')}
|
|
2471
|
+
canner projects --json | jq '.[].slug' ${dim('# machine-readable output')}
|
|
2472
|
+
|
|
2473
|
+
${bold('Environment:')}
|
|
2474
|
+
CANNER_TOKEN Override stored token (handy for CI).
|
|
2475
|
+
CANNER_API_BASE Override API endpoint (default ${DEFAULT_API_BASE}).
|
|
2476
|
+
|
|
2477
|
+
${bold('Files:')}
|
|
2478
|
+
~/.canner/credentials Stored token (mode 0600).
|
|
2479
|
+
canner.json Per-project slug (committed alongside source).
|
|
2480
|
+
.cannerignore Optional excludes (in addition to node_modules,
|
|
2481
|
+
.git, .next, .nuxt, .turbo, .vercel, .svelte-kit,
|
|
2482
|
+
.DS_Store, Thumbs.db which are always excluded).
|
|
2483
|
+
|
|
2484
|
+
Docs: https://canner.ca/cli
|
|
2485
|
+
Version: ${VERSION}
|
|
2486
|
+
`);
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// ---------------------------------------------------------------------------
|
|
2490
|
+
// Main
|
|
2491
|
+
// ---------------------------------------------------------------------------
|
|
2492
|
+
const args = parseArgs(process.argv.slice(2));
|
|
2493
|
+
const command = args._.shift();
|
|
2494
|
+
|
|
2495
|
+
// Global --json: machine-readable output for scripts, CI, and agents. Set
|
|
2496
|
+
// before dispatch so every helper (info/muted/die) honors it.
|
|
2497
|
+
JSON_MODE = args.json === true;
|
|
2498
|
+
|
|
2499
|
+
if (args.version || args.v || command === 'version') {
|
|
2500
|
+
process.stdout.write(`canner ${VERSION}\n`);
|
|
2501
|
+
process.exit(0);
|
|
2502
|
+
}
|
|
2503
|
+
if (!command || args.help || args.h || command === 'help') {
|
|
2504
|
+
printHelp();
|
|
2505
|
+
process.exit(0);
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
const dispatch = {
|
|
2509
|
+
login: cmdLogin,
|
|
2510
|
+
logout: cmdLogout,
|
|
2511
|
+
whoami: cmdWhoami,
|
|
2512
|
+
init: cmdInit,
|
|
2513
|
+
deploy: cmdDeploy,
|
|
2514
|
+
redeploy: cmdRedeploy,
|
|
2515
|
+
projects: cmdProjects,
|
|
2516
|
+
status: cmdStatus,
|
|
2517
|
+
logs: cmdLogs,
|
|
2518
|
+
deployments: cmdDeployments,
|
|
2519
|
+
cancel: cmdCancel,
|
|
2520
|
+
open: cmdOpen,
|
|
2521
|
+
token: cmdToken,
|
|
2522
|
+
tokens: cmdToken,
|
|
2523
|
+
env: cmdEnv,
|
|
2524
|
+
domains: cmdDomains,
|
|
2525
|
+
db: cmdDb,
|
|
2526
|
+
database: cmdDb,
|
|
2527
|
+
databases: cmdDb,
|
|
2528
|
+
};
|
|
2529
|
+
const handler = dispatch[command];
|
|
2530
|
+
if (!handler) {
|
|
2531
|
+
process.stderr.write(`${red('error:')} unknown command "${command}"\n\n`);
|
|
2532
|
+
printHelp();
|
|
2533
|
+
process.exit(2);
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
try {
|
|
2537
|
+
await handler(args);
|
|
2538
|
+
} catch (err) {
|
|
2539
|
+
if (err && err.code === 'ERR_UNHANDLED_ERROR') {
|
|
2540
|
+
die(err.message);
|
|
2541
|
+
}
|
|
2542
|
+
die(err?.message ?? String(err));
|
|
2543
|
+
}
|