@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.
- package/README.md +112 -0
- package/package.json +41 -0
- 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
|
+
}
|