lemonade-host 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 +69 -0
- package/bin/lemonade.mjs +110 -0
- package/package.json +33 -0
- package/src/commands.mjs +302 -0
- package/src/config.mjs +87 -0
- package/src/crc32.mjs +32 -0
- package/src/messages.mjs +60 -0
- package/src/zip.mjs +212 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# lemonade-host
|
|
2
|
+
|
|
3
|
+
Publish a folder to [Lemonade Host](https://lemonadehost.com) from your terminal.
|
|
4
|
+
|
|
5
|
+
> The package is **`lemonade-host`**. The plain `lemonade` name on npm
|
|
6
|
+
> belongs to somebody else, so `npx lemonade …` would download and run their
|
|
7
|
+
> package, not this one. Installed globally the command is still `lemonade`.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx lemonade-host deploy
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That's the whole thing. It zips the current folder, uploads it, and prints
|
|
14
|
+
the live URL.
|
|
15
|
+
|
|
16
|
+
## Setup, once
|
|
17
|
+
|
|
18
|
+
Mint a deploy token in your dashboard — **Deploy tools → Deploy token** —
|
|
19
|
+
and save it:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx lemonade-host login
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It asks for the token and the site, and writes them to `~/.lemonade`
|
|
26
|
+
with permissions only you can read. Or set `LEMONADE_TOKEN` and
|
|
27
|
+
`LEMONADE_SITE` in the environment instead, which is what CI wants.
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
| | |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `lemonade deploy [dir]` | Zip a folder and publish it. Defaults to `.`, and prefers `dist/`, `build/`, `out/` or `public/` if one exists |
|
|
34
|
+
| `lemonade status` | The last few deploys for this site |
|
|
35
|
+
| `lemonade login` | Save a token and site id to `~/.lemonade` |
|
|
36
|
+
| `lemonade whoami` | Which site the current token points at |
|
|
37
|
+
|
|
38
|
+
Useful flags: `--token`, `--site`, `--origin` (for staging), `--yes` (skip
|
|
39
|
+
the confirmation), `--json` (machine-readable output).
|
|
40
|
+
|
|
41
|
+
## A token reaches exactly one site
|
|
42
|
+
|
|
43
|
+
A `lm_` token is minted for a single site and can do two things: publish a
|
|
44
|
+
release to that site, and read that site's deploy history. It cannot spend
|
|
45
|
+
money, cancel anything, change an address, or see another site — so leaving
|
|
46
|
+
one in a CI secret is a bounded risk rather than an open door.
|
|
47
|
+
|
|
48
|
+
Revoke one any time from the same screen you minted it on.
|
|
49
|
+
|
|
50
|
+
## In CI
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
- run: npx lemonade-host deploy ./dist --yes
|
|
54
|
+
env:
|
|
55
|
+
LEMONADE_TOKEN: ${{ secrets.LEMONADE_TOKEN }}
|
|
56
|
+
LEMONADE_SITE: ${{ secrets.LEMONADE_SITE }}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Exit code is 0 on a successful publish and non-zero on anything else, so a
|
|
60
|
+
failed deploy fails the job.
|
|
61
|
+
|
|
62
|
+
## Limits
|
|
63
|
+
|
|
64
|
+
256 MB per upload, 25 MB per file, 20,000 files, 1 GB extracted. Dotfiles,
|
|
65
|
+
`node_modules`, `.git` and friends are skipped unless you pass
|
|
66
|
+
`--include-hidden`.
|
|
67
|
+
|
|
68
|
+
Static files only — Lemonade Host serves what you give it and never runs a
|
|
69
|
+
build. Build locally or in CI, then point this at the output folder.
|
package/bin/lemonade.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `npx lemonade …` — the whole entry point.
|
|
4
|
+
*
|
|
5
|
+
* Argument parsing is by hand and stays that way: a flag parser is a
|
|
6
|
+
* dependency, and this package's promise is that `npx lemonade deploy`
|
|
7
|
+
* downloads one thing and runs.
|
|
8
|
+
*
|
|
9
|
+
* EVERY ERROR PATH PRINTS SOMETHING A PERSON CAN ACT ON. An unexpected
|
|
10
|
+
* exception still prints its stack, because hiding one from whoever has to
|
|
11
|
+
* report it helps nobody — but everything we anticipated is a sentence and
|
|
12
|
+
* a fix, never a stack trace at somebody publishing their first website.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { deploy, login, Refused, status, whoami } from "../src/commands.mjs";
|
|
16
|
+
|
|
17
|
+
const USAGE = `lemonade — publish a folder to Lemonade Host
|
|
18
|
+
|
|
19
|
+
lemonade deploy [folder] zip it and publish it (defaults to the built output)
|
|
20
|
+
lemonade status the last few deploys for this site
|
|
21
|
+
lemonade login save a deploy token and site id to ~/.lemonade
|
|
22
|
+
lemonade whoami check the token, and which site it reaches
|
|
23
|
+
|
|
24
|
+
Options
|
|
25
|
+
--token lm_… a deploy token (or set LEMONADE_TOKEN)
|
|
26
|
+
--site st_… which site (or set LEMONADE_SITE)
|
|
27
|
+
--origin URL point at another instance, e.g. staging
|
|
28
|
+
--yes, -y do not ask before publishing
|
|
29
|
+
--json machine-readable output
|
|
30
|
+
--include-hidden publish dotfiles and node_modules too
|
|
31
|
+
--version, -v print the version
|
|
32
|
+
--help, -h this
|
|
33
|
+
|
|
34
|
+
A token reaches exactly one site and can only publish to it and read its
|
|
35
|
+
deploy history. It cannot spend money, cancel anything, or see another site.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const BOOLEAN = new Set(["yes", "y", "json", "include-hidden", "help", "h", "version", "v"]);
|
|
39
|
+
const VALUED = new Set(["token", "site", "origin"]);
|
|
40
|
+
|
|
41
|
+
function parse(argv) {
|
|
42
|
+
const args = [];
|
|
43
|
+
const flags = {};
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const arg = argv[i];
|
|
46
|
+
if (!arg.startsWith("-")) {
|
|
47
|
+
args.push(arg);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const [rawName, inline] = arg.replace(/^-+/, "").split("=");
|
|
51
|
+
const name = rawName;
|
|
52
|
+
if (VALUED.has(name)) {
|
|
53
|
+
const value = inline ?? argv[++i];
|
|
54
|
+
if (value === undefined) throw new Refused(`--${name} needs a value.`);
|
|
55
|
+
flags[name] = value;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (BOOLEAN.has(name)) {
|
|
59
|
+
flags[name] = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
throw new Refused(`Unknown option: ${arg}\n\n${USAGE}`);
|
|
63
|
+
}
|
|
64
|
+
return { args, flags };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const COMMANDS = { deploy, status, login, whoami };
|
|
68
|
+
|
|
69
|
+
async function main() {
|
|
70
|
+
const { args, flags } = parse(process.argv.slice(2));
|
|
71
|
+
|
|
72
|
+
if (flags.version || flags.v) {
|
|
73
|
+
const { default: pkg } = await import("../package.json", { with: { type: "json" } });
|
|
74
|
+
console.log(pkg.version);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
const command = args.shift();
|
|
78
|
+
// Asking for help is not an error: `lemonade --help` exits 0 so a script
|
|
79
|
+
// that pipes it does not fail. Running `lemonade` with nothing at all IS
|
|
80
|
+
// a usage error, and exits 1.
|
|
81
|
+
if (flags.help || flags.h) {
|
|
82
|
+
console.log(USAGE);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
if (!command) {
|
|
86
|
+
console.log(USAGE);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
const run = COMMANDS[command];
|
|
90
|
+
if (!run) {
|
|
91
|
+
console.error(`Unknown command: ${command}\n`);
|
|
92
|
+
console.error(USAGE);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
// The two spellings of the same intent, normalised once.
|
|
96
|
+
flags.yes = flags.yes || flags.y || false;
|
|
97
|
+
flags.includeHidden = flags["include-hidden"] || false;
|
|
98
|
+
return (await run(args, flags)) ?? 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
main()
|
|
102
|
+
.then((code) => process.exit(code ?? 0))
|
|
103
|
+
.catch((err) => {
|
|
104
|
+
if (err instanceof Refused || err?.expected) {
|
|
105
|
+
console.error(`\n${err.message}`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
console.error(err);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lemonade-host",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Publish a folder to Lemonade Host from your terminal.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"lemonade",
|
|
7
|
+
"lemonade-host",
|
|
8
|
+
"static",
|
|
9
|
+
"hosting",
|
|
10
|
+
"deploy"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://lemonadehost.com/deploy/cli/",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "mailto:support@lemonadehost.com"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Analytix Media LLC",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"bin": {
|
|
20
|
+
"lemonade": "bin/lemonade.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test test/*.test.mjs"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four commands, and the one HTTP call underneath them.
|
|
3
|
+
*
|
|
4
|
+
* Everything here talks to the deploy API a browser session also uses — the
|
|
5
|
+
* CLI is an adapter onto it, not a second way in, so the gate order it meets
|
|
6
|
+
* (401 → concealed 404 → 409 → 429 → 413 → the gauntlet) is the same one the
|
|
7
|
+
* portal meets and is pinned by the app's own specs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import readline from "node:readline/promises";
|
|
13
|
+
import { stdin, stdout } from "node:process";
|
|
14
|
+
import { CONFIG_PATH, looksLikeToken, normalizeSite, resolve, save } from "./config.mjs";
|
|
15
|
+
import { explain } from "./messages.mjs";
|
|
16
|
+
import { collect, zipFiles } from "./zip.mjs";
|
|
17
|
+
|
|
18
|
+
const MAX_UPLOAD = 256 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
/** Folders that hold BUILT output, in the order we would guess. */
|
|
21
|
+
const BUILD_DIRS = ["dist", "build", "out", "public", "_site"];
|
|
22
|
+
|
|
23
|
+
const bold = (s) => (stdout.isTTY ? `\u001b[1m${s}\u001b[0m` : s);
|
|
24
|
+
const dim = (s) => (stdout.isTTY ? `\u001b[2m${s}\u001b[0m` : s);
|
|
25
|
+
const green = (s) => (stdout.isTTY ? `\u001b[32m${s}\u001b[0m` : s);
|
|
26
|
+
|
|
27
|
+
function human(bytes) {
|
|
28
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
29
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
30
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A CLI error that is the user's situation, not a stack trace. */
|
|
34
|
+
export class Refused extends Error {
|
|
35
|
+
constructor(message, { code = null } = {}) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.expected = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function requireAuth(flags) {
|
|
43
|
+
const cfg = resolve(flags);
|
|
44
|
+
if (!cfg.token) {
|
|
45
|
+
throw new Refused(
|
|
46
|
+
"No deploy token.\n" +
|
|
47
|
+
" Mint one in your dashboard under Deploy tools, then run: lemonade login\n" +
|
|
48
|
+
" Or set LEMONADE_TOKEN in the environment (which is what CI wants).",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (!looksLikeToken(cfg.token)) {
|
|
52
|
+
throw new Refused(
|
|
53
|
+
`That does not look like a deploy token (they start with lm_).\n` +
|
|
54
|
+
` It came from ${cfg.from.token}.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (!cfg.site) {
|
|
58
|
+
throw new Refused(
|
|
59
|
+
"No site id.\n" +
|
|
60
|
+
" Pass --site st_… , set LEMONADE_SITE, or run: lemonade login\n" +
|
|
61
|
+
" The id is in the address bar of the site's page in your dashboard.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const site = normalizeSite(cfg.site);
|
|
65
|
+
if (!site) {
|
|
66
|
+
throw new Refused(`"${cfg.site}" is not a site id — they look like st_….`);
|
|
67
|
+
}
|
|
68
|
+
return { ...cfg, site };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Which folder to publish.
|
|
73
|
+
*
|
|
74
|
+
* An explicit argument always wins. With none, a build folder is preferred
|
|
75
|
+
* over the project root — publishing a repository root uploads the source,
|
|
76
|
+
* the README and often a stray .env, and the thing a customer meant is
|
|
77
|
+
* nearly always the built output. It says which one it chose, every time.
|
|
78
|
+
*/
|
|
79
|
+
export function chooseDir(arg, cwd = process.cwd()) {
|
|
80
|
+
if (arg) {
|
|
81
|
+
const abs = path.resolve(cwd, arg);
|
|
82
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
|
|
83
|
+
throw new Refused(`${abs} is not a folder.`);
|
|
84
|
+
}
|
|
85
|
+
return { dir: abs, guessed: false };
|
|
86
|
+
}
|
|
87
|
+
for (const name of BUILD_DIRS) {
|
|
88
|
+
const abs = path.join(cwd, name);
|
|
89
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
90
|
+
const hasIndex = fs.existsSync(path.join(abs, "index.html"));
|
|
91
|
+
if (hasIndex) return { dir: abs, guessed: true };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { dir: cwd, guessed: false };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function confirm(question) {
|
|
98
|
+
if (!stdin.isTTY) return true;
|
|
99
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
100
|
+
const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
|
|
101
|
+
rl.close();
|
|
102
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function call(cfg, url, init = {}) {
|
|
106
|
+
let res;
|
|
107
|
+
try {
|
|
108
|
+
res = await fetch(url, {
|
|
109
|
+
...init,
|
|
110
|
+
headers: { authorization: `Bearer ${cfg.token}`, ...(init.headers ?? {}) },
|
|
111
|
+
});
|
|
112
|
+
} catch (err) {
|
|
113
|
+
throw new Refused(
|
|
114
|
+
`Could not reach ${cfg.origin}.\n ${err.message}\n` +
|
|
115
|
+
` Nothing was published. Check the address, or your connection.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const text = await res.text();
|
|
119
|
+
let body = null;
|
|
120
|
+
try {
|
|
121
|
+
body = text ? JSON.parse(text) : null;
|
|
122
|
+
} catch {
|
|
123
|
+
/* a proxy or an error page — handled below */
|
|
124
|
+
}
|
|
125
|
+
if (!res.ok || (body && body.ok === false)) {
|
|
126
|
+
const code = body?.error ?? body?.errorCode ?? null;
|
|
127
|
+
throw new Refused(explain(code, body?.message ?? null), { code });
|
|
128
|
+
}
|
|
129
|
+
if (!body) {
|
|
130
|
+
throw new Refused(
|
|
131
|
+
`${cfg.origin} answered with something that was not JSON (HTTP ${res.status}).`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return body;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ── deploy ──────────────────────────────────────────────────────────────── */
|
|
138
|
+
|
|
139
|
+
export async function deploy(args, flags) {
|
|
140
|
+
const cfg = requireAuth(flags);
|
|
141
|
+
const { dir, guessed } = chooseDir(args[0]);
|
|
142
|
+
|
|
143
|
+
const { files, skipped } = collect(dir, { includeHidden: flags.includeHidden });
|
|
144
|
+
if (files.length === 0) {
|
|
145
|
+
throw new Refused(
|
|
146
|
+
`${dir} has no files to publish.\n` +
|
|
147
|
+
` If everything in it is hidden, pass --include-hidden.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const bytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
152
|
+
const hasIndex = files.some((f) => f.rel === "index.html");
|
|
153
|
+
|
|
154
|
+
if (!flags.json) {
|
|
155
|
+
console.log(`${bold("Publishing")} ${dir}`);
|
|
156
|
+
if (guessed) console.log(dim(` chose the built output — pass a folder to override`));
|
|
157
|
+
console.log(
|
|
158
|
+
dim(
|
|
159
|
+
` ${files.length} file${files.length === 1 ? "" : "s"}, ${human(bytes)}` +
|
|
160
|
+
(skipped.hidden || skipped.symlink || skipped.secret
|
|
161
|
+
? ` · skipped ${skipped.hidden} hidden, ${skipped.symlink} link${skipped.symlink === 1 ? "" : "s"}, ${skipped.secret} secret${skipped.secret === 1 ? "" : "s"}`
|
|
162
|
+
: ""),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
// Said BEFORE the upload, not after. A site with no index.html serves a
|
|
166
|
+
// 404 at its own address, and finding that out from the live URL is a
|
|
167
|
+
// worse minute than finding it out here.
|
|
168
|
+
if (!hasIndex) {
|
|
169
|
+
console.log(
|
|
170
|
+
` ${bold("No index.html at the top level")} — visitors will get a 404 at the site's address.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (bytes > MAX_UPLOAD) {
|
|
176
|
+
throw new Refused(
|
|
177
|
+
`That folder is ${human(bytes)}, over the 256 MB upload limit.\n` +
|
|
178
|
+
` Publish the built output rather than the whole project.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!flags.yes && !flags.json && !(await confirm(`Publish to ${cfg.site}?`))) {
|
|
183
|
+
console.log("Nothing was published.");
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const zip = zipFiles(files);
|
|
188
|
+
const body = await call(cfg, `${cfg.origin}/api/deploy/${cfg.site}`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "content-type": "application/zip" },
|
|
191
|
+
body: zip,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (flags.json) {
|
|
195
|
+
console.log(JSON.stringify(body, null, 2));
|
|
196
|
+
return body.blocked ? 1 : 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Truthful about the two states a "successful" upload can land in. The
|
|
200
|
+
// app's own route learned this the hard way: a publish that the safety
|
|
201
|
+
// scan blocked used to come back as a plain success, and every door
|
|
202
|
+
// printed the live URL of a site that was now offline.
|
|
203
|
+
if (body.blocked) {
|
|
204
|
+
console.log("");
|
|
205
|
+
console.log(bold("Uploaded, but this version is offline."));
|
|
206
|
+
console.log(` ${body.message ?? "Our safety check flagged it and it is under review."}`);
|
|
207
|
+
console.log(dim(" Nothing was deleted. Check your email."));
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log("");
|
|
212
|
+
console.log(green(bold("Live.")));
|
|
213
|
+
if (body.url) console.log(` ${body.url}`);
|
|
214
|
+
console.log(
|
|
215
|
+
dim(` ${body.fileCount} file${body.fileCount === 1 ? "" : "s"} · ${body.deploymentId}`),
|
|
216
|
+
);
|
|
217
|
+
if (body.serving === false) {
|
|
218
|
+
console.log("");
|
|
219
|
+
console.log(
|
|
220
|
+
` ${bold("Not serving yet")} — this site has not been published from your dashboard.`,
|
|
221
|
+
);
|
|
222
|
+
console.log(dim(` Your files are here and will go live the moment it is.`));
|
|
223
|
+
}
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* ── status ──────────────────────────────────────────────────────────────── */
|
|
228
|
+
|
|
229
|
+
export async function status(_args, flags) {
|
|
230
|
+
const cfg = requireAuth(flags);
|
|
231
|
+
const body = await call(cfg, `${cfg.origin}/api/deploy/${cfg.site}/deployments`);
|
|
232
|
+
const rows = body.deployments ?? [];
|
|
233
|
+
|
|
234
|
+
if (flags.json) {
|
|
235
|
+
console.log(JSON.stringify(body, null, 2));
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
if (rows.length === 0) {
|
|
239
|
+
console.log("No deploys yet.");
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
for (const row of rows.slice(0, 10)) {
|
|
243
|
+
const when = row.createdAt ? new Date(row.createdAt).toISOString().replace("T", " ").slice(0, 16) : "";
|
|
244
|
+
const mark = row.status === "active" ? green("●") : row.status === "failed" ? "✗" : dim("○");
|
|
245
|
+
console.log(
|
|
246
|
+
`${mark} ${when} ${String(row.status).padEnd(10)} ${dim(`${row.source ?? ""} ${row.fileCount ?? ""} files`)}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* ── whoami ──────────────────────────────────────────────────────────────── */
|
|
253
|
+
|
|
254
|
+
export async function whoami(_args, flags) {
|
|
255
|
+
const cfg = requireAuth(flags);
|
|
256
|
+
// Asking the SERVER rather than reading the config back: the question is
|
|
257
|
+
// "does this token still work, and what does it reach", and only one of
|
|
258
|
+
// those two can be answered locally.
|
|
259
|
+
await call(cfg, `${cfg.origin}/api/deploy/${cfg.site}/deployments`);
|
|
260
|
+
if (flags.json) {
|
|
261
|
+
console.log(JSON.stringify({ ok: true, site: cfg.site, origin: cfg.origin }, null, 2));
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
console.log(`Token is valid and reaches ${bold(cfg.site)}`);
|
|
265
|
+
console.log(dim(` ${cfg.origin} · token from ${cfg.from.token}, site from ${cfg.from.site}`));
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* ── login ───────────────────────────────────────────────────────────────── */
|
|
270
|
+
|
|
271
|
+
export async function login(_args, flags) {
|
|
272
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
273
|
+
try {
|
|
274
|
+
console.log("Mint a deploy token in your dashboard, under Deploy tools.");
|
|
275
|
+
console.log(dim("It is shown once — there is no copy on our side to read back.\n"));
|
|
276
|
+
|
|
277
|
+
const token = (flags.token ?? (await rl.question("Deploy token (lm_…): "))).trim();
|
|
278
|
+
if (!looksLikeToken(token)) {
|
|
279
|
+
throw new Refused("That does not look like a deploy token — they start with lm_.");
|
|
280
|
+
}
|
|
281
|
+
const siteRaw = flags.site ?? (await rl.question("Site id (st_…, or paste the URL): "));
|
|
282
|
+
const site = normalizeSite(siteRaw);
|
|
283
|
+
if (!site) throw new Refused("That is not a site id — they look like st_….");
|
|
284
|
+
|
|
285
|
+
const origin = (flags.origin ?? process.env.LEMONADE_ORIGIN ?? "https://app.lemonadehost.com").replace(/\/+$/, "");
|
|
286
|
+
|
|
287
|
+
// Proved before it is saved. Writing an unverified token means the
|
|
288
|
+
// first real deploy is where you learn it was wrong, and by then the
|
|
289
|
+
// one place the raw token existed has usually been closed.
|
|
290
|
+
const probe = { token, site, origin, from: { token: "prompt", site: "prompt" } };
|
|
291
|
+
await call(probe, `${origin}/api/deploy/${site}/deployments`);
|
|
292
|
+
|
|
293
|
+
const where = save({ token, site, origin });
|
|
294
|
+
console.log(`\n${green("Saved.")} ${dim(where)} (readable only by you)`);
|
|
295
|
+
console.log(dim("Now: lemonade deploy"));
|
|
296
|
+
return 0;
|
|
297
|
+
} finally {
|
|
298
|
+
rl.close();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export const CONFIG_FILE = CONFIG_PATH;
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the token comes from, and where it is allowed to be written.
|
|
3
|
+
*
|
|
4
|
+
* PRECEDENCE, highest first: a flag, then the environment, then `~/.lemonade`.
|
|
5
|
+
* CI sets the environment and must beat a stale file left on a shared
|
|
6
|
+
* runner; a human typing `--site` means this run and must beat both.
|
|
7
|
+
*
|
|
8
|
+
* THE FILE IS 0600 AND IS CHECKED. A deploy token in a world-readable file
|
|
9
|
+
* in a home directory is the failure this whole shape exists to avoid, so
|
|
10
|
+
* `load()` REFUSES a file anyone else can read rather than quietly using
|
|
11
|
+
* it — the same posture the app takes with its own credentials file. It
|
|
12
|
+
* says how to fix it in one line the reader can paste.
|
|
13
|
+
*
|
|
14
|
+
* AND IT IS NEVER PRINTED. Nothing in this package writes a raw token to
|
|
15
|
+
* stdout, stderr or an error message. A token echoed once is a token in a
|
|
16
|
+
* terminal's scrollback, a CI log and whatever screenshot follows.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
export const CONFIG_PATH = path.join(os.homedir(), ".lemonade");
|
|
24
|
+
export const DEFAULT_ORIGIN = "https://app.lemonadehost.com";
|
|
25
|
+
|
|
26
|
+
/** Everything the commands need, resolved. Never contains a printed token. */
|
|
27
|
+
export function resolve(flags = {}) {
|
|
28
|
+
const file = load();
|
|
29
|
+
const origin =
|
|
30
|
+
flags.origin ?? process.env.LEMONADE_ORIGIN ?? file.origin ?? DEFAULT_ORIGIN;
|
|
31
|
+
return {
|
|
32
|
+
token: flags.token ?? process.env.LEMONADE_TOKEN ?? file.token ?? null,
|
|
33
|
+
site: flags.site ?? process.env.LEMONADE_SITE ?? file.site ?? null,
|
|
34
|
+
origin: origin.replace(/\/+$/, ""),
|
|
35
|
+
// Where each one came from, for the error messages. Saying "no token"
|
|
36
|
+
// to somebody who has one in ~/.lemonade and a typo in the env is not
|
|
37
|
+
// an answer.
|
|
38
|
+
from: {
|
|
39
|
+
token: flags.token ? "--token" : process.env.LEMONADE_TOKEN ? "LEMONADE_TOKEN" : file.token ? CONFIG_PATH : null,
|
|
40
|
+
site: flags.site ? "--site" : process.env.LEMONADE_SITE ? "LEMONADE_SITE" : file.site ? CONFIG_PATH : null,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function load() {
|
|
46
|
+
if (!fs.existsSync(CONFIG_PATH)) return {};
|
|
47
|
+
const stat = fs.statSync(CONFIG_PATH);
|
|
48
|
+
// Group or other can read it. On Windows the mode bits are not meaningful,
|
|
49
|
+
// so this only bites where it means something.
|
|
50
|
+
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`${CONFIG_PATH} is readable by other users, and it holds a deploy token.\n` +
|
|
53
|
+
` Fix it with: chmod 600 ${CONFIG_PATH}\n` +
|
|
54
|
+
` Or revoke the token in your dashboard and mint a new one.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
59
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`${CONFIG_PATH} is not valid JSON. Delete it and run: lemonade login`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function save(next) {
|
|
66
|
+
const body = JSON.stringify({ ...load(), ...next }, null, 2) + "\n";
|
|
67
|
+
// Written 0600 from the first byte — creating it 0644 and chmod-ing after
|
|
68
|
+
// leaves a window in which it is readable, and that window is exactly
|
|
69
|
+
// when a backup or a file watcher would see it.
|
|
70
|
+
fs.writeFileSync(CONFIG_PATH, body, { mode: 0o600 });
|
|
71
|
+
if (process.platform !== "win32") fs.chmodSync(CONFIG_PATH, 0o600);
|
|
72
|
+
return CONFIG_PATH;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `lm_` + 40 base64url chars, as minted by the dashboard. */
|
|
76
|
+
export function looksLikeToken(raw) {
|
|
77
|
+
return typeof raw === "string" && /^lm_[A-Za-z0-9_-]{20,120}$/.test(raw.trim());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `st_` + an opaque id. Accepts a pasted URL and pulls the id out of it. */
|
|
81
|
+
export function normalizeSite(raw) {
|
|
82
|
+
if (typeof raw !== "string") return null;
|
|
83
|
+
const trimmed = raw.trim();
|
|
84
|
+
const fromUrl = trimmed.match(/\/sites\/(st_[A-Za-z0-9_-]+)/);
|
|
85
|
+
const id = fromUrl ? fromUrl[1] : trimmed;
|
|
86
|
+
return /^st_[A-Za-z0-9_-]+$/.test(id) ? id : null;
|
|
87
|
+
}
|
package/src/crc32.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRC-32, the one the zip format wants (IEEE 802.3 polynomial, reflected).
|
|
3
|
+
*
|
|
4
|
+
* Here rather than from a package for the reason in zip.mjs: this file is
|
|
5
|
+
* twenty lines and a dependency is a supply chain. The table is built once
|
|
6
|
+
* on first use rather than shipped as a literal, because a 256-entry array
|
|
7
|
+
* of magic numbers is unreviewable and this is not.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
let TABLE = null;
|
|
11
|
+
|
|
12
|
+
function table() {
|
|
13
|
+
if (TABLE) return TABLE;
|
|
14
|
+
TABLE = new Int32Array(256);
|
|
15
|
+
for (let i = 0; i < 256; i++) {
|
|
16
|
+
let c = i;
|
|
17
|
+
for (let k = 0; k < 8; k++) {
|
|
18
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
19
|
+
}
|
|
20
|
+
TABLE[i] = c;
|
|
21
|
+
}
|
|
22
|
+
return TABLE;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function crc32(buf) {
|
|
26
|
+
const t = table();
|
|
27
|
+
let c = 0 ^ -1;
|
|
28
|
+
for (let i = 0; i < buf.length; i++) {
|
|
29
|
+
c = (c >>> 8) ^ t[(c ^ buf[i]) & 0xff];
|
|
30
|
+
}
|
|
31
|
+
return (c ^ -1) >>> 0;
|
|
32
|
+
}
|
package/src/messages.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server's error codes, in words somebody can act on.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE. The deploy API's gauntlet is precise and its codes are
|
|
5
|
+
* short — `zip_slip`, `absolute_path`, `too_many_entries`. Printing those
|
|
6
|
+
* verbatim to somebody publishing their first website tells them nothing
|
|
7
|
+
* except that they have done something wrong. Every line here names the
|
|
8
|
+
* cause and the fix, in that order, and never blames the customer for a
|
|
9
|
+
* limit they were never shown.
|
|
10
|
+
*
|
|
11
|
+
* Anything unmapped falls through to the raw code, which is still better
|
|
12
|
+
* than a spinner that stops. New codes on the server should appear here,
|
|
13
|
+
* but a missing one degrades to legible rather than to silence.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const MESSAGES = {
|
|
17
|
+
/* auth and scope */
|
|
18
|
+
unauthorized:
|
|
19
|
+
"That token was refused. Mint a fresh one in your dashboard under Deploy tools, then run: lemonade login",
|
|
20
|
+
not_found:
|
|
21
|
+
"No site with that id, or this token was minted for a different one. A token reaches exactly one site — check the id with: lemonade whoami",
|
|
22
|
+
|
|
23
|
+
/* traffic */
|
|
24
|
+
deploy_in_progress:
|
|
25
|
+
"A deploy for this site is already running. Wait for it to finish — it will not be long.",
|
|
26
|
+
rate_limited:
|
|
27
|
+
"Too many deploys in a row for this site. Wait a minute and try again.",
|
|
28
|
+
too_large:
|
|
29
|
+
"That folder is over the 256 MB upload limit. Publish the built output rather than the whole project — dist/ or build/ — or drop the largest files.",
|
|
30
|
+
empty_body: "There was nothing to upload.",
|
|
31
|
+
upload_interrupted:
|
|
32
|
+
"The upload stopped part-way. Nothing was published; run it again.",
|
|
33
|
+
|
|
34
|
+
/* the gauntlet — a valid request with content we will not extract */
|
|
35
|
+
zip_slip:
|
|
36
|
+
"One of the paths in that folder points outside itself. Re-export it from your editor or build tool rather than zipping it by hand.",
|
|
37
|
+
absolute_path:
|
|
38
|
+
"One of the paths is absolute. Deploy from inside the folder you want published, not from its parent.",
|
|
39
|
+
symlink:
|
|
40
|
+
"That folder contains a symbolic link. Links are not published — replace it with the real file.",
|
|
41
|
+
too_many_entries:
|
|
42
|
+
"Over 20,000 files. Publish the built output rather than the whole project.",
|
|
43
|
+
file_too_large:
|
|
44
|
+
"One file is over 25 MB. A video or a raw photo usually — host it elsewhere and link to it.",
|
|
45
|
+
quota_exceeded:
|
|
46
|
+
"That release would take the site over its 1 GB of storage.",
|
|
47
|
+
not_a_zip: "The upload did not arrive as a readable archive. Try again.",
|
|
48
|
+
|
|
49
|
+
/* the box */
|
|
50
|
+
site_not_found: "That site no longer exists.",
|
|
51
|
+
failed:
|
|
52
|
+
"The deploy failed on our side. Nothing changed — your previous version is still live. If it happens twice, email support@lemonadehost.com.",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function explain(code, fallbackMessage) {
|
|
56
|
+
if (code && MESSAGES[code]) return MESSAGES[code];
|
|
57
|
+
if (fallbackMessage) return fallbackMessage;
|
|
58
|
+
if (code) return `The server refused it: ${code}`;
|
|
59
|
+
return "The server refused it.";
|
|
60
|
+
}
|
package/src/zip.mjs
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A zip file, built with nothing installed.
|
|
3
|
+
*
|
|
4
|
+
* WHY NOT A LIBRARY. This package's whole promise is `npx lemonade deploy`
|
|
5
|
+
* — one command, nothing to install, on a machine that may be a laptop or a
|
|
6
|
+
* CI runner with a cold cache. Every dependency is a download before the
|
|
7
|
+
* first byte of the customer's site moves, a version to keep patched, and
|
|
8
|
+
* one more thing that can be yanked or compromised between them and their
|
|
9
|
+
* website going live. The zip format's stored (uncompressed) mode is about
|
|
10
|
+
* a hundred lines, so this package has ZERO dependencies and always will.
|
|
11
|
+
*
|
|
12
|
+
* STORED, NOT DEFLATED, and that is a deliberate trade. Deflate would need
|
|
13
|
+
* zlib streaming plumbed through the central directory; Node has zlib, so
|
|
14
|
+
* it is possible, but the win is bandwidth on an upload that is already
|
|
15
|
+
* capped at 256 MB and usually a few hundred KB of HTML. Correctness that
|
|
16
|
+
* anyone can read beats a smaller upload nobody can audit.
|
|
17
|
+
*
|
|
18
|
+
* THE ONE THING THAT MUST NOT BE WRONG: paths. The server's gauntlet
|
|
19
|
+
* rejects `..`, absolute paths, backslashes, NUL bytes and every symlink —
|
|
20
|
+
* so a bug here surfaces as a rejected deploy rather than a traversal. It
|
|
21
|
+
* is still written to emit only forward-slashed relative paths, because
|
|
22
|
+
* "the other side would catch it" is not a reason to send something wrong.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { crc32 } from "./crc32.mjs";
|
|
28
|
+
|
|
29
|
+
/** Directories nobody means to publish. Skipped unless --include-hidden. */
|
|
30
|
+
const SKIP_DIRS = new Set([
|
|
31
|
+
"node_modules",
|
|
32
|
+
".git",
|
|
33
|
+
".svn",
|
|
34
|
+
".hg",
|
|
35
|
+
".next",
|
|
36
|
+
".nuxt",
|
|
37
|
+
".cache",
|
|
38
|
+
".turbo",
|
|
39
|
+
".vercel",
|
|
40
|
+
".netlify",
|
|
41
|
+
".DS_Store",
|
|
42
|
+
"__pycache__",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** Files that are somebody's secret, not their website. */
|
|
46
|
+
const SKIP_FILES = new Set([
|
|
47
|
+
".env",
|
|
48
|
+
".env.local",
|
|
49
|
+
".env.production",
|
|
50
|
+
".env.development",
|
|
51
|
+
".npmrc",
|
|
52
|
+
".netrc",
|
|
53
|
+
"id_rsa",
|
|
54
|
+
"id_ed25519",
|
|
55
|
+
".DS_Store",
|
|
56
|
+
"Thumbs.db",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every file under `root`, as { rel, abs, size }.
|
|
61
|
+
*
|
|
62
|
+
* Symlinks are SKIPPED, not followed. Following one would either escape the
|
|
63
|
+
* folder (which the server refuses anyway) or silently duplicate a file;
|
|
64
|
+
* skipping is the only behaviour that is the same on both machines.
|
|
65
|
+
*/
|
|
66
|
+
export function collect(root, { includeHidden = false } = {}) {
|
|
67
|
+
const out = [];
|
|
68
|
+
const skipped = { hidden: 0, symlink: 0, secret: 0 };
|
|
69
|
+
|
|
70
|
+
const walk = (dir, prefix) => {
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
throw new Error(`cannot read ${dir}: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
78
|
+
const name = entry.name;
|
|
79
|
+
const abs = path.join(dir, name);
|
|
80
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
81
|
+
|
|
82
|
+
if (entry.isSymbolicLink()) {
|
|
83
|
+
skipped.symlink++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!includeHidden && SKIP_FILES.has(name)) {
|
|
87
|
+
skipped.secret++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
if (!includeHidden && (SKIP_DIRS.has(name) || name.startsWith("."))) {
|
|
92
|
+
skipped.hidden++;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
walk(abs, rel);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (!entry.isFile()) continue;
|
|
99
|
+
if (!includeHidden && name.startsWith(".") && name !== ".well-known") {
|
|
100
|
+
skipped.hidden++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const stat = fs.statSync(abs);
|
|
104
|
+
out.push({ rel, abs, size: stat.size });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
walk(root, "");
|
|
109
|
+
return { files: out, skipped };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ── the zip itself ──────────────────────────────────────────────────────── */
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* General-purpose bit 11: "the filename in this entry is UTF-8".
|
|
116
|
+
*
|
|
117
|
+
* Without it a reader is entitled to decode names as CP437, and Info-ZIP
|
|
118
|
+
* does — `café.html` came out of the system unzip as `caf+?.html` and then
|
|
119
|
+
* failed to create at all with "Illegal byte sequence". Every name here is
|
|
120
|
+
* written as UTF-8, so every entry says so.
|
|
121
|
+
*
|
|
122
|
+
* Found by round-tripping through a real unzip rather than through this
|
|
123
|
+
* file's own reader, which is the entire reason that test shells out.
|
|
124
|
+
*/
|
|
125
|
+
const UTF8_NAMES = 0x0800;
|
|
126
|
+
|
|
127
|
+
const LOCAL_SIG = 0x04034b50;
|
|
128
|
+
const CENTRAL_SIG = 0x02014b50;
|
|
129
|
+
const END_SIG = 0x06054b50;
|
|
130
|
+
|
|
131
|
+
/** DOS date/time. Zip predates Unix timestamps and still wants 1980-based. */
|
|
132
|
+
function dosTime(date) {
|
|
133
|
+
const year = Math.max(1980, date.getFullYear());
|
|
134
|
+
return {
|
|
135
|
+
time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1),
|
|
136
|
+
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Build the archive in memory.
|
|
142
|
+
*
|
|
143
|
+
* In memory on purpose: the upload cap is 256 MB and the practical size of
|
|
144
|
+
* a static site is orders of magnitude under it, so a temp file would buy
|
|
145
|
+
* nothing and would leave a copy of the customer's site on disk if the
|
|
146
|
+
* process died mid-upload.
|
|
147
|
+
*/
|
|
148
|
+
export function zipFiles(files, { now = new Date() } = {}) {
|
|
149
|
+
const chunks = [];
|
|
150
|
+
const central = [];
|
|
151
|
+
let offset = 0;
|
|
152
|
+
|
|
153
|
+
for (const file of files) {
|
|
154
|
+
const nameBuf = Buffer.from(file.rel, "utf8");
|
|
155
|
+
const data = fs.readFileSync(file.abs);
|
|
156
|
+
const crc = crc32(data);
|
|
157
|
+
const { time, date } = dosTime(now);
|
|
158
|
+
|
|
159
|
+
const local = Buffer.alloc(30);
|
|
160
|
+
local.writeUInt32LE(LOCAL_SIG, 0);
|
|
161
|
+
local.writeUInt16LE(20, 4); // version needed
|
|
162
|
+
local.writeUInt16LE(UTF8_NAMES, 6); // no encryption, no data descriptor, UTF-8 names
|
|
163
|
+
local.writeUInt16LE(0, 8); // method 0 = stored
|
|
164
|
+
local.writeUInt16LE(time, 10);
|
|
165
|
+
local.writeUInt16LE(date, 12);
|
|
166
|
+
local.writeUInt32LE(crc, 14);
|
|
167
|
+
local.writeUInt32LE(data.length, 18);
|
|
168
|
+
local.writeUInt32LE(data.length, 22);
|
|
169
|
+
local.writeUInt16LE(nameBuf.length, 26);
|
|
170
|
+
local.writeUInt16LE(0, 28); // no extra field
|
|
171
|
+
|
|
172
|
+
chunks.push(local, nameBuf, data);
|
|
173
|
+
|
|
174
|
+
const entry = Buffer.alloc(46);
|
|
175
|
+
entry.writeUInt32LE(CENTRAL_SIG, 0);
|
|
176
|
+
entry.writeUInt16LE(20, 4); // version made by
|
|
177
|
+
entry.writeUInt16LE(20, 6); // version needed
|
|
178
|
+
entry.writeUInt16LE(UTF8_NAMES, 8);
|
|
179
|
+
entry.writeUInt16LE(0, 10); // method 0 = stored
|
|
180
|
+
entry.writeUInt16LE(time, 12);
|
|
181
|
+
entry.writeUInt16LE(date, 14);
|
|
182
|
+
entry.writeUInt32LE(crc, 16);
|
|
183
|
+
entry.writeUInt32LE(data.length, 20);
|
|
184
|
+
entry.writeUInt32LE(data.length, 24);
|
|
185
|
+
entry.writeUInt16LE(nameBuf.length, 28);
|
|
186
|
+
entry.writeUInt16LE(0, 30); // extra
|
|
187
|
+
entry.writeUInt16LE(0, 32); // comment
|
|
188
|
+
entry.writeUInt16LE(0, 34); // disk
|
|
189
|
+
entry.writeUInt16LE(0, 36); // internal attrs
|
|
190
|
+
// 0644 in the high 16 bits, the way every unix zip writer does it. The
|
|
191
|
+
// server does not read this, but a customer who downloads their own
|
|
192
|
+
// archive should not find every file marked executable.
|
|
193
|
+
entry.writeUInt32LE((0o100644 << 16) >>> 0, 38);
|
|
194
|
+
entry.writeUInt32LE(offset, 42);
|
|
195
|
+
central.push(entry, nameBuf);
|
|
196
|
+
|
|
197
|
+
offset += local.length + nameBuf.length + data.length;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const centralBuf = Buffer.concat(central);
|
|
201
|
+
const end = Buffer.alloc(22);
|
|
202
|
+
end.writeUInt32LE(END_SIG, 0);
|
|
203
|
+
end.writeUInt16LE(0, 4);
|
|
204
|
+
end.writeUInt16LE(0, 6);
|
|
205
|
+
end.writeUInt16LE(files.length, 8);
|
|
206
|
+
end.writeUInt16LE(files.length, 10);
|
|
207
|
+
end.writeUInt32LE(centralBuf.length, 12);
|
|
208
|
+
end.writeUInt32LE(offset, 16);
|
|
209
|
+
end.writeUInt16LE(0, 20);
|
|
210
|
+
|
|
211
|
+
return Buffer.concat([...chunks, centralBuf, end]);
|
|
212
|
+
}
|