@canner-ca/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +112 -0
  2. package/package.json +41 -0
  3. package/src/cli.mjs +616 -0
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # @canner-ca/cli
2
+
3
+ Deploy to [Canner](https://canner.ca) from any terminal. Works in macOS, Linux, Windows, and Cursor's integrated terminal. No git required.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @canner-ca/cli
9
+ ```
10
+
11
+ Or run without installing:
12
+
13
+ ```bash
14
+ npx @canner-ca/cli@latest deploy --follow
15
+ ```
16
+
17
+ Requires Node.js 20 or later.
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ # Sign in once (paste an API token from canner.ca/dashboard/account)
23
+ canner login
24
+
25
+ # In your project directory, create a Canner project
26
+ canner init
27
+
28
+ # Ship it
29
+ canner deploy --follow
30
+ ```
31
+
32
+ `--follow` streams the build log live and exits with the build's success code, so it works as the last step of a CI pipeline.
33
+
34
+ ## Commands
35
+
36
+ | Command | What it does |
37
+ |---|---|
38
+ | `canner login` | Prompt for an API token, store it at `~/.canner/credentials` (mode 0600). |
39
+ | `canner whoami` | Show the email + plan this CLI is signed in as. |
40
+ | `canner init [--slug X]` | Create a new Canner project and write `canner.json` in the current directory. |
41
+ | `canner deploy [path] [--slug X] [--follow]` | Tar the directory and upload it. `--follow` streams the build log and exits with the build's success code. |
42
+
43
+ ## What gets uploaded
44
+
45
+ The CLI tars the current directory into a `.tar.gz`, excludes the "build noise" defaults, and streams it to the same upload endpoint the dashboard's drag-drop uses. The server-side build pipeline detects the framework and runs install / build / deploy just like a `git push`.
46
+
47
+ **Always excluded** (no opt-out): `node_modules/`, `.git/`, `.next/`, `.nuxt/`, `.turbo/`, `.vercel/`, `.svelte-kit/`, `.DS_Store`, `Thumbs.db`. The server-side pipeline rebuilds these from your `package.json` anyway.
48
+
49
+ **Additionally excluded if present**: anything in `.cannerignore`. Format is similar to `.gitignore` but simpler:
50
+
51
+ ```
52
+ # comment lines start with #
53
+ build
54
+ coverage/
55
+ *.log
56
+ /dist # leading slash anchors to project root
57
+ ```
58
+
59
+ Supported: basename matches anywhere in the tree, trailing-slash for directory-only, leading-slash for root-anchored, single-segment `*` glob. No negation.
60
+
61
+ ## Per-project config: `canner.json`
62
+
63
+ `canner init` writes this in your project root. Commit it alongside your source so `canner deploy` knows the target slug:
64
+
65
+ ```json
66
+ {
67
+ "slug": "my-app"
68
+ }
69
+ ```
70
+
71
+ Override the slug per-run with `--slug=other-app` if you maintain multiple environments.
72
+
73
+ ## Environment variables
74
+
75
+ | Variable | Purpose |
76
+ |---|---|
77
+ | `CANNER_TOKEN` | Use this token instead of the one in `~/.canner/credentials`. Handy for CI. |
78
+ | `CANNER_API_BASE` | Override the API endpoint. Default `https://api.canner.ca`. |
79
+
80
+ ## CI / GitHub Actions
81
+
82
+ ```yaml
83
+ - uses: actions/setup-node@v4
84
+ with: { node-version: 22 }
85
+ - run: npx @canner-ca/cli@latest deploy --follow
86
+ env:
87
+ CANNER_TOKEN: ${{ secrets.CANNER_TOKEN }}
88
+ ```
89
+
90
+ `--follow` exits with the build's success code, so a failed build fails the workflow.
91
+
92
+ ## Cursor / AI agents
93
+
94
+ Works in any terminal, including Cursor's integrated terminal. Agents can invoke it as a shell tool — no extension or MCP server required:
95
+
96
+ ```
97
+ canner deploy --follow
98
+ ```
99
+
100
+ ## Plan size caps
101
+
102
+ The CLI uploads under the same per-tier compressed-archive cap as the dashboard:
103
+
104
+ - **Starter** — 50 MB
105
+ - **Basic** — 200 MB
106
+ - **Pro** — 500 MB
107
+
108
+ If your project is bigger than that compressed, drop heavier directories into `.cannerignore` or upgrade your plan. `node_modules` is already excluded; the typical thing that pushes a project over the cap is a `public/` folder full of media.
109
+
110
+ ## License
111
+
112
+ MIT.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@canner-ca/cli",
3
+ "version": "0.1.0",
4
+ "description": "Canner deploy CLI — git push from any terminal, no GitHub required",
5
+ "type": "module",
6
+ "bin": {
7
+ "canner": "./src/cli.mjs"
8
+ },
9
+ "files": [
10
+ "src/",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "start": "node src/cli.mjs",
15
+ "prepublishOnly": "node -e \"require('fs').accessSync('src/cli.mjs')\""
16
+ },
17
+ "dependencies": {
18
+ "tar": "^7.4.3"
19
+ },
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "keywords": [
24
+ "canner",
25
+ "deploy",
26
+ "cli",
27
+ "canadian",
28
+ "hosting"
29
+ ],
30
+ "author": "Canner",
31
+ "license": "MIT",
32
+ "homepage": "https://canner.ca/cli",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/tzshand/canner.git",
36
+ "directory": "tools/cli"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/tzshand/canner/issues"
40
+ }
41
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,616 @@
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 whoami show the account this CLI is signed in as
13
+ // canner init [--slug X] create a project, write canner.json
14
+ // canner deploy [path] [--slug X] [--follow]
15
+ // tar + upload + optionally stream the build log
16
+ //
17
+ // Exit codes: 0 on success, 1 on user/network/auth error, 2 on bad CLI
18
+ // invocation. `canner deploy --follow` exits with the build's success
19
+ // (0 = live, 1 = failed) so it composes with CI pipelines.
20
+
21
+ import { existsSync, openAsBlob } from 'node:fs';
22
+ import { mkdir, readFile, writeFile, stat, chmod, unlink } from 'node:fs/promises';
23
+ import { homedir, tmpdir } from 'node:os';
24
+ import { join, resolve, basename } from 'node:path';
25
+ import { createInterface } from 'node:readline/promises';
26
+ import { randomUUID } from 'node:crypto';
27
+ import { stdin as input, stdout as output } from 'node:process';
28
+ import { create as tarCreate } from 'tar';
29
+
30
+ const VERSION = '0.1.0';
31
+ const DEFAULT_API_BASE = 'https://api.canner.ca';
32
+ const CONFIG_DIR = join(homedir(), '.canner');
33
+ const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials');
34
+ const PROJECT_CONFIG_FILE = 'canner.json';
35
+
36
+ // Stuff we never ship even if the user doesn't have a .cannerignore. These
37
+ // are "build noise" — heavy directories that the server-side build pipeline
38
+ // rebuilds, plus OS / IDE droppings.
39
+ const HARD_EXCLUDES = new Set([
40
+ 'node_modules',
41
+ '.git',
42
+ '.next',
43
+ '.nuxt',
44
+ '.turbo',
45
+ '.vercel',
46
+ '.svelte-kit',
47
+ '.DS_Store',
48
+ 'Thumbs.db',
49
+ ]);
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Tiny ANSI styling. No chalk dep — eight inlined escape codes cover us.
53
+ // Disabled when stdout isn't a TTY (CI, redirected to file).
54
+ // ---------------------------------------------------------------------------
55
+ const isTTY = output.isTTY === true;
56
+ const c = (code) => (s) => isTTY ? `\x1b[${code}m${s}\x1b[0m` : String(s);
57
+ const bold = c('1');
58
+ const dim = c('2');
59
+ const red = c('31');
60
+ const green = c('32');
61
+ const yellow = c('33');
62
+ const cyan = c('36');
63
+
64
+ function die(msg, code = 1) {
65
+ process.stderr.write(`${red('error:')} ${msg}\n`);
66
+ process.exit(code);
67
+ }
68
+
69
+ function info(msg) {
70
+ process.stdout.write(`${msg}\n`);
71
+ }
72
+
73
+ function muted(msg) {
74
+ process.stdout.write(`${dim(msg)}\n`);
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Credential storage (~/.canner/credentials)
79
+ //
80
+ // JSON with shape: { api_base: string, token: string }. Mode 0600 on
81
+ // platforms that respect it. We default api_base to https://api.canner.ca
82
+ // but allow override via CANNER_API_BASE env var (handy for self-host
83
+ // later, and for local dev pointing at localhost:4001 via tunnel).
84
+ // ---------------------------------------------------------------------------
85
+ async function loadCredentials() {
86
+ try {
87
+ const raw = await readFile(CREDENTIALS_PATH, 'utf8');
88
+ const parsed = JSON.parse(raw);
89
+ return {
90
+ apiBase: process.env.CANNER_API_BASE || parsed.api_base || DEFAULT_API_BASE,
91
+ token: parsed.token,
92
+ };
93
+ } catch {
94
+ return {
95
+ apiBase: process.env.CANNER_API_BASE || DEFAULT_API_BASE,
96
+ token: process.env.CANNER_TOKEN || null,
97
+ };
98
+ }
99
+ }
100
+
101
+ async function saveCredentials({ apiBase, token }) {
102
+ await mkdir(CONFIG_DIR, { recursive: true });
103
+ await writeFile(
104
+ CREDENTIALS_PATH,
105
+ JSON.stringify({ api_base: apiBase, token }, null, 2),
106
+ { mode: 0o600 }
107
+ );
108
+ try {
109
+ await chmod(CREDENTIALS_PATH, 0o600);
110
+ } catch {
111
+ // Windows doesn't honor Unix mode; fine.
112
+ }
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // HTTP helpers. fetch is built-in on Node 20+.
117
+ // ---------------------------------------------------------------------------
118
+ async function apiRequest(creds, method, path, options = {}) {
119
+ if (!creds.token) {
120
+ die("not signed in. Run `canner login` first.");
121
+ }
122
+ const url = `${creds.apiBase}${path}`;
123
+ const headers = {
124
+ Authorization: `Bearer ${creds.token}`,
125
+ Accept: 'application/json',
126
+ ...(options.headers ?? {}),
127
+ };
128
+ const init = {
129
+ method,
130
+ headers,
131
+ body: options.body,
132
+ // Long-lived ops (deploy upload, SSE) shouldn't time out at the
133
+ // fetch level. Per-call options.signal can still abort.
134
+ };
135
+ if (options.signal) init.signal = options.signal;
136
+ const res = await fetch(url, init).catch((err) => {
137
+ die(`network: ${err.message}`);
138
+ });
139
+ if (!res.ok) {
140
+ let body = null;
141
+ try { body = await res.json(); } catch { /* not JSON */ }
142
+ if (res.status === 401) {
143
+ die("authentication failed. Your token may be revoked. Run `canner login` again.");
144
+ }
145
+ if (res.status === 403) {
146
+ die("forbidden. The API token doesn't have access to this resource.");
147
+ }
148
+ const msg = body?.message || body?.error || `${res.status} ${res.statusText}`;
149
+ die(`${method} ${path}: ${msg}`);
150
+ }
151
+ return res;
152
+ }
153
+
154
+ async function apiJSON(creds, method, path, body) {
155
+ const init = {};
156
+ if (body !== undefined) {
157
+ init.headers = { 'Content-Type': 'application/json' };
158
+ init.body = JSON.stringify(body);
159
+ }
160
+ const res = await apiRequest(creds, method, path, init);
161
+ if (res.status === 204) return null;
162
+ return res.json();
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // .cannerignore parsing — simple line-based excludes.
167
+ // One pattern per line. '#' comments, blank lines ignored. Supports:
168
+ // - exact-basename match anywhere ('build', 'coverage')
169
+ // - trailing-slash directory match ('logs/' matches only directories)
170
+ // - leading '/' anchors to project root ('/dist' = only the root dist/)
171
+ // - basic glob: '*' inside a single path segment
172
+ // No negation ('!') support — keep the surface tight.
173
+ // ---------------------------------------------------------------------------
174
+ function parseIgnoreFile(text) {
175
+ const patterns = [];
176
+ for (const lineRaw of text.split('\n')) {
177
+ const line = lineRaw.trim();
178
+ if (!line || line.startsWith('#')) continue;
179
+ const dirOnly = line.endsWith('/');
180
+ const anchored = line.startsWith('/');
181
+ const body = line.replace(/^\/+/, '').replace(/\/+$/, '');
182
+ const glob = body.includes('*');
183
+ patterns.push({ pattern: body, dirOnly, anchored, glob });
184
+ }
185
+ return patterns;
186
+ }
187
+
188
+ function matchesIgnore(relPath, isDir, patterns) {
189
+ // relPath is POSIX-normalized, no leading slash, no './'.
190
+ const parts = relPath.split('/');
191
+ for (const p of patterns) {
192
+ if (p.dirOnly && !isDir) continue;
193
+ if (p.anchored) {
194
+ if (matchSegment(p, parts.join('/'))) return true;
195
+ } else {
196
+ // Unanchored: match the pattern against any segment (basename match)
197
+ // OR against any suffix of the path.
198
+ for (let i = 0; i < parts.length; i++) {
199
+ if (matchSegment(p, parts.slice(i).join('/'))) return true;
200
+ }
201
+ }
202
+ }
203
+ return false;
204
+ }
205
+
206
+ function matchSegment(p, candidate) {
207
+ if (!p.glob) return p.pattern === candidate;
208
+ // Compile glob to regex on first use. Cache on the pattern object.
209
+ if (!p._re) {
210
+ const escaped = p.pattern
211
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
212
+ .replace(/\*/g, '[^/]*');
213
+ p._re = new RegExp(`^${escaped}$`);
214
+ }
215
+ return p._re.test(candidate);
216
+ }
217
+
218
+ async function loadIgnorePatterns(cwd) {
219
+ const path = join(cwd, '.cannerignore');
220
+ if (!existsSync(path)) return [];
221
+ const text = await readFile(path, 'utf8');
222
+ return parseIgnoreFile(text);
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Project config (canner.json) — what `canner init` writes and `canner
227
+ // deploy` reads. Currently just {slug}. Future fields: a `path` override
228
+ // for monorepos pointing at a subdir, an optional `before_upload` hook.
229
+ // ---------------------------------------------------------------------------
230
+ async function loadProjectConfig(cwd) {
231
+ const path = join(cwd, PROJECT_CONFIG_FILE);
232
+ if (!existsSync(path)) return null;
233
+ try {
234
+ return JSON.parse(await readFile(path, 'utf8'));
235
+ } catch (err) {
236
+ die(`could not parse ${PROJECT_CONFIG_FILE}: ${err.message}`);
237
+ }
238
+ }
239
+
240
+ async function writeProjectConfig(cwd, cfg) {
241
+ await writeFile(join(cwd, PROJECT_CONFIG_FILE), JSON.stringify(cfg, null, 2) + '\n');
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Commands
246
+ // ---------------------------------------------------------------------------
247
+
248
+ async function cmdLogin(_args) {
249
+ const rl = createInterface({ input, output });
250
+ const existing = await loadCredentials();
251
+ const apiBase = process.env.CANNER_API_BASE || existing.apiBase || DEFAULT_API_BASE;
252
+ info(bold('Sign in to Canner'));
253
+ info(`Create an API token at ${cyan(apiBase.replace('api.', '') + '/dashboard/account')}`);
254
+ info(`Then paste it below. It starts with ${dim('cnr_')}`);
255
+ const token = (await rl.question('token: ')).trim();
256
+ rl.close();
257
+ if (!token.startsWith('cnr_')) {
258
+ die("that doesn't look like a Canner API token (expected prefix 'cnr_').");
259
+ }
260
+ await saveCredentials({ apiBase, token });
261
+ // Verify by hitting /auth/me.
262
+ const me = await apiJSON({ apiBase, token }, 'GET', '/auth/me');
263
+ if (!me?.account) {
264
+ die("token didn't authenticate. Check it was copied correctly.");
265
+ }
266
+ info(`${green('Signed in as')} ${bold(me.account.email)}`);
267
+ }
268
+
269
+ async function cmdWhoami() {
270
+ const creds = await loadCredentials();
271
+ if (!creds.token) {
272
+ die("not signed in. Run `canner login`.");
273
+ }
274
+ const me = await apiJSON(creds, 'GET', '/auth/me');
275
+ if (!me?.account) {
276
+ die('authenticated but /auth/me returned no account.');
277
+ }
278
+ info(`${bold(me.account.email)}`);
279
+ muted(`api: ${creds.apiBase}`);
280
+ // Projects count from /me/plan (cheap call we already have).
281
+ try {
282
+ const plan = await apiJSON(creds, 'GET', '/me/plan');
283
+ if (plan?.plan) {
284
+ muted(
285
+ `plan: ${plan.plan.display_name} (${plan.project_count}/${plan.project_count_limit} projects)`
286
+ );
287
+ }
288
+ } catch {
289
+ // /me/plan is optional context; not fatal if it fails.
290
+ }
291
+ }
292
+
293
+ async function cmdInit(args) {
294
+ const cwd = process.cwd();
295
+ const existing = await loadProjectConfig(cwd);
296
+ if (existing?.slug) {
297
+ die(`already initialized — ${PROJECT_CONFIG_FILE} has slug=${existing.slug}`);
298
+ }
299
+
300
+ const creds = await loadCredentials();
301
+ if (!creds.token) {
302
+ die("not signed in. Run `canner login` first.");
303
+ }
304
+
305
+ const rl = createInterface({ input, output });
306
+ const defaultName = basename(cwd);
307
+ const name = (await rl.question(`Project name [${defaultName}]: `)).trim() || defaultName;
308
+ const defaultSlug = sluggify(name);
309
+ const slug =
310
+ args.slug ||
311
+ (await rl.question(`Slug [${defaultSlug}]: `)).trim() ||
312
+ defaultSlug;
313
+ rl.close();
314
+
315
+ if (!/^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/.test(slug)) {
316
+ die(`invalid slug "${slug}" — must be 3–50 lowercase letters/digits/hyphens, no leading or trailing hyphen.`);
317
+ }
318
+
319
+ // Server validates uniqueness + reserved-slug rules; we just surface them.
320
+ const body = await apiJSON(creds, 'POST', '/projects', { name, slug });
321
+ if (!body) die('project creation returned no body.');
322
+ await writeProjectConfig(cwd, { slug });
323
+ info(`${green('Created')} ${bold(slug)}`);
324
+ muted(`Will be served at https://${slug}.app.canner.ca after the first deploy.`);
325
+ muted(`Wrote ${PROJECT_CONFIG_FILE}. Run \`canner deploy\` to ship.`);
326
+ }
327
+
328
+ async function cmdDeploy(args) {
329
+ const projectPath = resolve(args._[0] || '.');
330
+ if (!existsSync(projectPath)) {
331
+ die(`path not found: ${projectPath}`);
332
+ }
333
+ const cwdStat = await stat(projectPath);
334
+ if (!cwdStat.isDirectory()) {
335
+ die(`path is not a directory: ${projectPath}`);
336
+ }
337
+
338
+ const cfg = await loadProjectConfig(projectPath);
339
+ const slug = args.slug || cfg?.slug;
340
+ if (!slug) {
341
+ die(`no project slug. Pass --slug=X or run \`canner init\` to create ${PROJECT_CONFIG_FILE}.`);
342
+ }
343
+
344
+ const creds = await loadCredentials();
345
+ if (!creds.token) {
346
+ die("not signed in. Run `canner login`.");
347
+ }
348
+
349
+ // Build the ignore patterns: hard excludes + user .cannerignore.
350
+ const userPatterns = await loadIgnorePatterns(projectPath);
351
+
352
+ // Build the tarball as a temp file. We use a temp file (not piping directly
353
+ // into the upload) so we know the final size before POST + so a tar error
354
+ // surfaces cleanly before we touch the network.
355
+ const tarPath = join(tmpdir(), `canner-${randomUUID()}.tar.gz`);
356
+ let fileCount = 0;
357
+ info(`${dim('packaging')} ${projectPath}`);
358
+ try {
359
+ await tarCreate(
360
+ {
361
+ gzip: true,
362
+ file: tarPath,
363
+ cwd: projectPath,
364
+ // Strict portable-file-only — refuse symlinks pointing outside the
365
+ // project, etc. Matches what the server-side safe-extractor enforces.
366
+ portable: true,
367
+ filter: (path) => {
368
+ // path is relative to cwd, POSIX-normalized by tar.
369
+ const rel = path.replace(/^\.\//, '').replace(/^\/+/, '');
370
+ if (!rel) return true; // root
371
+ const parts = rel.split('/');
372
+ // Hard excludes match by basename anywhere in the tree.
373
+ for (const part of parts) {
374
+ if (HARD_EXCLUDES.has(part)) return false;
375
+ }
376
+ // User .cannerignore.
377
+ const isDir = path.endsWith('/');
378
+ if (matchesIgnore(rel, isDir, userPatterns)) return false;
379
+ fileCount++;
380
+ return true;
381
+ },
382
+ },
383
+ ['./']
384
+ );
385
+ } catch (err) {
386
+ await unlink(tarPath).catch(() => undefined);
387
+ die(`tar failed: ${err.message}`);
388
+ }
389
+
390
+ const tarStat = await stat(tarPath);
391
+ const sizeMb = (tarStat.size / 1024 / 1024).toFixed(2);
392
+ info(`${dim('packaged')} ${fileCount} files (${sizeMb} MB compressed)`);
393
+
394
+ // Upload. fs.openAsBlob() backs the Blob by the on-disk file lazily —
395
+ // bytes only flow when fetch consumes them, so a 500 MB Pro upload
396
+ // doesn't buffer into RAM on the user's machine. Node fetch has no
397
+ // upload-progress hook as of 2026, so we just print the size we're
398
+ // sending; the dashboard shows real-time progress for browser uploads.
399
+ info(`${dim('uploading')} to ${slug}…`);
400
+ const form = new FormData();
401
+ const blob = await openAsBlob(tarPath, { type: 'application/gzip' });
402
+ form.append('archive', blob, `${slug}.tar.gz`);
403
+
404
+ let response;
405
+ try {
406
+ response = await apiRequest(creds, 'POST', `/projects/${slug}/upload`, { body: form });
407
+ } finally {
408
+ await unlink(tarPath).catch(() => undefined);
409
+ }
410
+
411
+ const body = await response.json();
412
+ const deploymentId = body.deployment_id;
413
+ if (!deploymentId) {
414
+ die('upload succeeded but the response had no deployment_id.');
415
+ }
416
+ info(`${green('queued')} deployment ${dim(deploymentId.slice(0, 8))}`);
417
+
418
+ if (!args.follow) {
419
+ muted(`Tail with: canner deploy --follow --slug=${slug} (or visit the dashboard)`);
420
+ return;
421
+ }
422
+
423
+ // SSE tail until terminal.
424
+ const exitCode = await tailDeploymentLogs(creds, slug, deploymentId);
425
+ process.exit(exitCode);
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // SSE consumer for /projects/:slug/deployments/:id/logs.
430
+ // Parses the wire format manually (no eventsource dep). Returns exit code:
431
+ // 0 if status ended at 'live', 1 if 'failed', 2 if connection died early.
432
+ // ---------------------------------------------------------------------------
433
+ async function tailDeploymentLogs(creds, slug, deploymentId) {
434
+ info(dim(`streaming build log…`));
435
+ const url = `${creds.apiBase}/projects/${slug}/deployments/${deploymentId}/logs`;
436
+ const res = await fetch(url, {
437
+ headers: {
438
+ Authorization: `Bearer ${creds.token}`,
439
+ Accept: 'text/event-stream',
440
+ },
441
+ }).catch((err) => {
442
+ die(`could not open log stream: ${err.message}`);
443
+ });
444
+ if (!res.ok || !res.body) {
445
+ die(`log stream returned ${res.status} ${res.statusText}`);
446
+ }
447
+
448
+ const decoder = new TextDecoder('utf-8');
449
+ let buffer = '';
450
+ let lastStatus = 'queued';
451
+ let printedHistory = false;
452
+
453
+ for await (const chunk of res.body) {
454
+ buffer += decoder.decode(chunk, { stream: true });
455
+ let idx;
456
+ while ((idx = buffer.indexOf('\n\n')) !== -1) {
457
+ const raw = buffer.slice(0, idx);
458
+ buffer = buffer.slice(idx + 2);
459
+ // Skip heartbeat comments (": ping").
460
+ if (raw.startsWith(':')) continue;
461
+ let event = 'message';
462
+ let data = '';
463
+ for (const line of raw.split('\n')) {
464
+ if (line.startsWith('event:')) event = line.slice(6).trim();
465
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
466
+ }
467
+ if (!data) continue;
468
+ let parsed;
469
+ try { parsed = JSON.parse(data); } catch { continue; }
470
+ if (event === 'history') {
471
+ if (!printedHistory && parsed.log) {
472
+ process.stdout.write(parsed.log);
473
+ }
474
+ printedHistory = true;
475
+ } else if (event === 'log') {
476
+ if (parsed.log) process.stdout.write(parsed.log);
477
+ } else if (event === 'status') {
478
+ lastStatus = parsed.status ?? lastStatus;
479
+ } else if (event === 'done') {
480
+ lastStatus = parsed.status ?? lastStatus;
481
+ break;
482
+ }
483
+ }
484
+ }
485
+
486
+ if (lastStatus === 'live') {
487
+ info(`\n${green('live')} at https://${slug}.app.canner.ca`);
488
+ return 0;
489
+ }
490
+ if (lastStatus === 'failed') {
491
+ info(`\n${red('failed')} (status=${lastStatus})`);
492
+ return 1;
493
+ }
494
+ info(`\n${yellow('stream ended')} (status=${lastStatus})`);
495
+ return 2;
496
+ }
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // Slug derivation mirrors the server's regex: ^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$
500
+ // ---------------------------------------------------------------------------
501
+ function sluggify(s) {
502
+ return s
503
+ .toLowerCase()
504
+ .replace(/[^a-z0-9-]+/g, '-')
505
+ .replace(/^-+|-+$/g, '')
506
+ .replace(/-+/g, '-')
507
+ .slice(0, 50);
508
+ }
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // Argv parser. Tiny, no dep. Supports:
512
+ // canner cmd → { _: [], cmd }
513
+ // canner cmd value → { _: ['value'] }
514
+ // canner cmd --flag → { flag: true }
515
+ // canner cmd --key value → { key: 'value' }
516
+ // canner cmd --key=value → { key: 'value' }
517
+ // canner cmd -v → { v: true }
518
+ // ---------------------------------------------------------------------------
519
+ function parseArgs(argv) {
520
+ const out = { _: [] };
521
+ for (let i = 0; i < argv.length; i++) {
522
+ const a = argv[i];
523
+ if (a.startsWith('--')) {
524
+ const eq = a.indexOf('=');
525
+ if (eq !== -1) {
526
+ out[a.slice(2, eq)] = a.slice(eq + 1);
527
+ } else {
528
+ const key = a.slice(2);
529
+ const next = argv[i + 1];
530
+ if (next && !next.startsWith('-')) {
531
+ out[key] = next;
532
+ i++;
533
+ } else {
534
+ out[key] = true;
535
+ }
536
+ }
537
+ } else if (a.startsWith('-') && a.length === 2) {
538
+ out[a.slice(1)] = true;
539
+ } else {
540
+ out._.push(a);
541
+ }
542
+ }
543
+ return out;
544
+ }
545
+
546
+ function printHelp() {
547
+ process.stdout.write(`${bold('canner')} ${dim('— deploy to Canner from any terminal')}
548
+
549
+ ${bold('Usage:')}
550
+ canner <command> [options]
551
+
552
+ ${bold('Commands:')}
553
+ ${bold('login')} Store your Canner API token (one-time).
554
+ ${bold('whoami')} Show the account this CLI is signed in as.
555
+ ${bold('init')} [--slug X] Create a project and write ${cyan(PROJECT_CONFIG_FILE)}.
556
+ ${bold('deploy')} [path] [--slug X] [--follow]
557
+ Tar the current directory and ship it.
558
+
559
+ ${bold('Examples:')}
560
+ canner login
561
+ cd my-site && canner init
562
+ canner deploy --follow ${dim('# build to live, streaming logs')}
563
+ canner deploy ./web --slug my-app ${dim('# deploy a sibling directory')}
564
+
565
+ ${bold('Environment:')}
566
+ CANNER_TOKEN Override stored token (handy for CI).
567
+ CANNER_API_BASE Override API endpoint (default ${DEFAULT_API_BASE}).
568
+
569
+ ${bold('Files:')}
570
+ ~/.canner/credentials Stored token (mode 0600).
571
+ canner.json Per-project slug (committed alongside source).
572
+ .cannerignore Optional excludes (in addition to node_modules,
573
+ .git, .next, .nuxt, .turbo, .vercel, .svelte-kit,
574
+ .DS_Store, Thumbs.db which are always excluded).
575
+
576
+ Docs: https://canner.ca/cli
577
+ Version: ${VERSION}
578
+ `);
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // Main
583
+ // ---------------------------------------------------------------------------
584
+ const args = parseArgs(process.argv.slice(2));
585
+ const command = args._.shift();
586
+
587
+ if (args.version || args.v || command === 'version') {
588
+ process.stdout.write(`canner ${VERSION}\n`);
589
+ process.exit(0);
590
+ }
591
+ if (!command || args.help || args.h || command === 'help') {
592
+ printHelp();
593
+ process.exit(0);
594
+ }
595
+
596
+ const dispatch = {
597
+ login: cmdLogin,
598
+ whoami: cmdWhoami,
599
+ init: cmdInit,
600
+ deploy: cmdDeploy,
601
+ };
602
+ const handler = dispatch[command];
603
+ if (!handler) {
604
+ process.stderr.write(`${red('error:')} unknown command "${command}"\n\n`);
605
+ printHelp();
606
+ process.exit(2);
607
+ }
608
+
609
+ try {
610
+ await handler(args);
611
+ } catch (err) {
612
+ if (err && err.code === 'ERR_UNHANDLED_ERROR') {
613
+ die(err.message);
614
+ }
615
+ die(err?.message ?? String(err));
616
+ }