@thebaycloud/cli 1.0.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/CHANGELOG.md +224 -0
- package/README.md +53 -0
- package/index.js +1269 -0
- package/lib/brand.js +124 -0
- package/lib/bundle.js +399 -0
- package/lib/check.js +281 -0
- package/lib/confirm.js +46 -0
- package/lib/draft.js +488 -0
- package/lib/envfile.js +158 -0
- package/lib/exec-args.js +40 -0
- package/lib/prebuilt.js +86 -0
- package/lib/resolver.js +63 -0
- package/lib/who.js +14 -0
- package/package.json +48 -0
- package/vendor/README.md +6 -0
- package/vendor/detector.js +423 -0
- package/vendor/inputs.json +21 -0
- package/vendor/resolve.js +2156 -0
package/lib/brand.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What this CLI is called, where it keeps things, and what it reads from the
|
|
9
|
+
* environment.
|
|
10
|
+
*
|
|
11
|
+
* ## Why every lookup here has two answers
|
|
12
|
+
*
|
|
13
|
+
* This is the one part of the rename that runs on other people's machines. A
|
|
14
|
+
* published CLI is not a service: it is a copy of the code sitting in somebody's
|
|
15
|
+
* global npm directory, and it stays there until they upgrade — which may be
|
|
16
|
+
* never. So nothing here may simply be renamed.
|
|
17
|
+
*
|
|
18
|
+
* Three things would break silently if it were:
|
|
19
|
+
*
|
|
20
|
+
* - `~/.supersonic/config.json` holds the sign-in token. Reading only `~/.bay`
|
|
21
|
+
* would sign everybody out and look, from the outside, exactly like a session
|
|
22
|
+
* that expired.
|
|
23
|
+
* - `SUPERSONIC_URL` and `SUPERSONIC_TOKEN` are in people's CI configuration and
|
|
24
|
+
* in agent scripts. Reading only the new names turns a working pipeline into
|
|
25
|
+
* one that deploys to production instead of staging, or to nowhere.
|
|
26
|
+
* - `supersonic.json` sits in repositories. Reading only `bay.json` makes the
|
|
27
|
+
* CLI forget which app a folder belongs to and reserve a second slug.
|
|
28
|
+
*
|
|
29
|
+
* So: the new name is written, and either name is read. The old name goes away
|
|
30
|
+
* when the logs show nobody is using it, and not before.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const BRAND = "Bay";
|
|
34
|
+
const CLI = "bay";
|
|
35
|
+
const DOMAIN = "thebay.cloud";
|
|
36
|
+
const DEFAULT_URL = "https://app.supersonic.cv";
|
|
37
|
+
|
|
38
|
+
const NEW_DIR = path.join(os.homedir(), ".bay");
|
|
39
|
+
const OLD_DIR = path.join(os.homedir(), ".supersonic");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where config lives.
|
|
43
|
+
*
|
|
44
|
+
* The new directory once it exists; the old one while it is the only one there.
|
|
45
|
+
* A brand-new install gets the new path and never sees the old name.
|
|
46
|
+
*/
|
|
47
|
+
function configDir() {
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(NEW_DIR)) return NEW_DIR;
|
|
50
|
+
if (fs.existsSync(OLD_DIR)) return OLD_DIR;
|
|
51
|
+
} catch { /* unreadable home — fall through */ }
|
|
52
|
+
return NEW_DIR;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Move an existing config across, once.
|
|
57
|
+
*
|
|
58
|
+
* Copy rather than move, and only when the new location is empty: an older CLI
|
|
59
|
+
* on the same machine still reads the old path, and taking the file out from
|
|
60
|
+
* under it would sign that copy out to fix this one.
|
|
61
|
+
*
|
|
62
|
+
* Best-effort in every direction. A migration that throws would break `deploy`
|
|
63
|
+
* for a reason that has nothing to do with deploying.
|
|
64
|
+
*/
|
|
65
|
+
function migrateConfig() {
|
|
66
|
+
try {
|
|
67
|
+
if (fs.existsSync(NEW_DIR)) return;
|
|
68
|
+
if (!fs.existsSync(path.join(OLD_DIR, "config.json"))) return;
|
|
69
|
+
fs.mkdirSync(NEW_DIR, { recursive: true });
|
|
70
|
+
fs.copyFileSync(path.join(OLD_DIR, "config.json"), path.join(NEW_DIR, "config.json"));
|
|
71
|
+
} catch { /* best effort */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* An environment variable under either name, new first.
|
|
76
|
+
*
|
|
77
|
+
* `envAny("URL")` reads BAY_URL, then SUPERSONIC_URL.
|
|
78
|
+
*/
|
|
79
|
+
function envAny(suffix, env) {
|
|
80
|
+
const e = env || process.env;
|
|
81
|
+
const fresh = e[`BAY_${suffix}`];
|
|
82
|
+
if (fresh !== undefined && String(fresh).trim() !== "") return fresh;
|
|
83
|
+
const legacy = e[`SUPERSONIC_${suffix}`];
|
|
84
|
+
if (legacy !== undefined && String(legacy).trim() !== "") return legacy;
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A per-project file under either name, new first.
|
|
90
|
+
*
|
|
91
|
+
* Returns the path that exists, or the new-name path when neither does — so a
|
|
92
|
+
* first write always creates the new name.
|
|
93
|
+
*/
|
|
94
|
+
function projectFile(dir, base) {
|
|
95
|
+
const fresh = path.join(dir, `bay.${base}`);
|
|
96
|
+
const legacy = path.join(dir, `supersonic.${base}`);
|
|
97
|
+
try {
|
|
98
|
+
if (fs.existsSync(fresh)) return fresh;
|
|
99
|
+
if (fs.existsSync(legacy)) return legacy;
|
|
100
|
+
} catch { /* fall through */ }
|
|
101
|
+
return fresh;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A protocol header under both names.
|
|
106
|
+
*
|
|
107
|
+
* Sending both, rather than switching, makes this CLI independent of which
|
|
108
|
+
* server it happens to be talking to. A control plane that only knows
|
|
109
|
+
* `x-supersonic-*` still understands it; one that prefers `x-bay-*` gets that.
|
|
110
|
+
* The alternative — switch the CLI and require the server to be deployed first
|
|
111
|
+
* — turns every publish into an ordering problem, and gets it wrong once.
|
|
112
|
+
*
|
|
113
|
+
* Costs a few dozen bytes per request. The old half comes out when the server
|
|
114
|
+
* has stopped reading it.
|
|
115
|
+
*/
|
|
116
|
+
function protoHeaders(name, value) {
|
|
117
|
+
return { [`x-bay-${name}`]: value, [`x-supersonic-${name}`]: value };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
BRAND, CLI, DOMAIN, DEFAULT_URL,
|
|
122
|
+
NEW_DIR, OLD_DIR,
|
|
123
|
+
configDir, migrateConfig, envAny, projectFile, protoHeaders,
|
|
124
|
+
};
|
package/lib/bundle.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Choosing what goes into a source bundle, and saying so out loud.
|
|
4
|
+
*
|
|
5
|
+
* This was nineteen `--exclude=` patterns plus `--exclude-from=.gitignore` handed to
|
|
6
|
+
* `tar`, and it was wrong three ways at once:
|
|
7
|
+
*
|
|
8
|
+
* - tar's patterns match a *basename at any depth*, so `dist`, `build`, `out`,
|
|
9
|
+
* `vendor`, `target` and `.cache` also matched a module named `src/build/`, a
|
|
10
|
+
* Composer or `go mod vendor` `app/vendor/`, and a committed `dist/` that *is*
|
|
11
|
+
* the deliverable. Nothing logged it, so the tree arrived incomplete and the
|
|
12
|
+
* first anyone heard of it was "module not found" three stages later.
|
|
13
|
+
* - tar does not speak gitignore. Given a .gitignore it treats `!keep.js` as a
|
|
14
|
+
* literal pattern rather than a negation, never matches an anchored `/dist`,
|
|
15
|
+
* and reads `**\/` differently from git. Tracked files were dropped.
|
|
16
|
+
* - the folder path and the git path shipped different trees for the same repo.
|
|
17
|
+
*
|
|
18
|
+
* git already answers this question, correctly, in one command:
|
|
19
|
+
*
|
|
20
|
+
* git ls-files -z --cached --others --exclude-standard
|
|
21
|
+
*
|
|
22
|
+
* Tracked files plus untracked-and-not-ignored files, with the real semantics —
|
|
23
|
+
* negations, anchors, nested .gitignore files, .git/info/exclude, the user's global
|
|
24
|
+
* excludes. The denylist survives only as the fallback for a folder that is not a
|
|
25
|
+
* git repository at all, where there is no gitignore to honour and imitating one is
|
|
26
|
+
* the bug rather than the fix.
|
|
27
|
+
*/
|
|
28
|
+
const fs = require("node:fs");
|
|
29
|
+
const os = require("node:os");
|
|
30
|
+
const path = require("node:path");
|
|
31
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The fallback list, used only when there is no `.git`.
|
|
35
|
+
*
|
|
36
|
+
* Note what is *not* here any more: `--exclude-from=.gitignore`. A folder with no
|
|
37
|
+
* repository has no gitignore semantics anyone can evaluate, and tar pretending to
|
|
38
|
+
* is what dropped tracked files in the first place.
|
|
39
|
+
*/
|
|
40
|
+
const DENYLIST = ["node_modules", ".git", "dist", "build", ".next", ".nuxt", ".svelte-kit",
|
|
41
|
+
"target", ".venv", "venv", "__pycache__", "vendor", ".DS_Store", "._*", ".env", ".env.*",
|
|
42
|
+
"*.pyc", ".turbo", ".cache", "out"];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Paths that never ship, whatever git says about them.
|
|
46
|
+
*
|
|
47
|
+
* `.env` and every variant of it: the values are not lost by holding them back —
|
|
48
|
+
* readEnvFiles carries them up separately and they land as env vars on the service,
|
|
49
|
+
* which is the entire point of sending them out of band. A value baked into a build
|
|
50
|
+
* bundle cannot be rotated. `.env.production` is the file most likely to hold real
|
|
51
|
+
* credentials and is exactly the one a per-name list keeps missing, so this matches
|
|
52
|
+
* the prefix instead of naming variants.
|
|
53
|
+
*
|
|
54
|
+
* `._*` and `.DS_Store` are macOS AppleDouble litter; `._page.js` extracts on the
|
|
55
|
+
* Linux build side and Next tries to compile it.
|
|
56
|
+
*/
|
|
57
|
+
function heldBack(rel) {
|
|
58
|
+
const base = rel.split("/").pop();
|
|
59
|
+
if (base === ".env" || base.startsWith(".env.")) return "env";
|
|
60
|
+
if (base === ".DS_Store" || base.startsWith("._")) return "macos";
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function splitNul(s) {
|
|
65
|
+
return s.split("\0").filter(Boolean);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True when `dir` is inside a git working tree we can ask questions of. */
|
|
69
|
+
function isGitRepo(dir) {
|
|
70
|
+
const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir, encoding: "utf8" });
|
|
71
|
+
return r.status === 0 && String(r.stdout).trim() === "true";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function git(dir, args) {
|
|
75
|
+
// 256 MB: `ls-files --others --ignored` over a fat node_modules is genuinely large,
|
|
76
|
+
// and the default 1 MB buffer truncates it into a wrong answer rather than an error.
|
|
77
|
+
return spawnSync("git", args, { cwd: dir, encoding: "utf8", maxBuffer: 1 << 28 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What git would ship: tracked plus untracked-and-not-ignored, relative to `dir`.
|
|
82
|
+
* Null when this is not a repository, which is the caller's signal to fall back.
|
|
83
|
+
*/
|
|
84
|
+
function gitFileList(dir) {
|
|
85
|
+
if (!isGitRepo(dir)) return null;
|
|
86
|
+
const r = git(dir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
|
|
87
|
+
if (r.status !== 0) return null;
|
|
88
|
+
return splitNul(r.stdout);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What git ignored, as a count and the few top-level directories responsible.
|
|
93
|
+
*
|
|
94
|
+
* A file dump helps nobody — the answer is usually forty thousand paths under
|
|
95
|
+
* node_modules. The categories are the part a person can act on when something they
|
|
96
|
+
* expected to ship did not.
|
|
97
|
+
*/
|
|
98
|
+
function ignoredSummary(dir) {
|
|
99
|
+
const r = git(dir, ["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--directory"]);
|
|
100
|
+
if (r.status !== 0) return { count: 0, top: [] };
|
|
101
|
+
const paths = splitNul(r.stdout);
|
|
102
|
+
const byTop = new Map();
|
|
103
|
+
for (const p of paths) {
|
|
104
|
+
const top = p.split("/")[0] || p;
|
|
105
|
+
byTop.set(top, (byTop.get(top) || 0) + 1);
|
|
106
|
+
}
|
|
107
|
+
const top = [...byTop.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([name]) => name);
|
|
108
|
+
return { count: paths.length, top };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Paths that are one file on this Mac and two files on Linux.
|
|
113
|
+
*
|
|
114
|
+
* `src/Button.tsx` and `src/button.tsx` are the same file on a case-insensitive
|
|
115
|
+
* volume, so whichever the editor wrote last is the content both entries carry into
|
|
116
|
+
* the archive. It builds here and breaks there, and the error names an import that
|
|
117
|
+
* looks perfectly correct in the editor.
|
|
118
|
+
*/
|
|
119
|
+
function caseCollisions(paths) {
|
|
120
|
+
const byLower = new Map();
|
|
121
|
+
for (const p of paths) {
|
|
122
|
+
const k = p.toLowerCase();
|
|
123
|
+
if (!byLower.has(k)) byLower.set(k, []);
|
|
124
|
+
byLower.get(k).push(p);
|
|
125
|
+
}
|
|
126
|
+
return [...byLower.values()].filter((g) => g.length > 1).map((g) => g.sort());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const LFS_MAGIC = "version https://git-lfs.github.com/spec/v1";
|
|
130
|
+
|
|
131
|
+
/** True for the ~130-byte text stub git-lfs leaves in place of the real bytes. */
|
|
132
|
+
function isLfsPointer(buf) {
|
|
133
|
+
return buf.slice(0, LFS_MAGIC.length).toString("latin1") === LFS_MAGIC;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Files that are LFS pointers rather than content.
|
|
138
|
+
*
|
|
139
|
+
* A repo storing video in LFS without a smudge filter having run ships 130-byte text
|
|
140
|
+
* files with the right names and extensions. They upload fast, extract cleanly, and
|
|
141
|
+
* serve as a corrupt MP4 — the whole pipeline reports success and the app is broken
|
|
142
|
+
* in a way no build log mentions. Pointers are always tiny, so the size check keeps
|
|
143
|
+
* this from being a read of every file in the tree.
|
|
144
|
+
*/
|
|
145
|
+
function findLfsPointers(dir, files) {
|
|
146
|
+
const hits = [];
|
|
147
|
+
for (const rel of files) {
|
|
148
|
+
let st;
|
|
149
|
+
try { st = fs.statSync(path.join(dir, rel)); } catch { continue; }
|
|
150
|
+
if (!st.isFile() || st.size < LFS_MAGIC.length || st.size > 1024) continue;
|
|
151
|
+
let fd;
|
|
152
|
+
try {
|
|
153
|
+
fd = fs.openSync(path.join(dir, rel), "r");
|
|
154
|
+
const buf = Buffer.alloc(LFS_MAGIC.length);
|
|
155
|
+
fs.readSync(fd, buf, 0, buf.length, 0);
|
|
156
|
+
if (isLfsPointer(buf)) hits.push(rel);
|
|
157
|
+
} catch { /* unreadable — not our problem here */ } finally {
|
|
158
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return hits;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Files git records as executable whose working copy is not.
|
|
166
|
+
*
|
|
167
|
+
* `core.fileMode=false`, a checkout onto an exFAT volume or a Docker bind mount all
|
|
168
|
+
* produce this, and tar archives the mode it finds on disk. The result is an
|
|
169
|
+
* entrypoint that arrives 0644 and a container that exits with "permission denied"
|
|
170
|
+
* against a script that is plainly present and plainly correct.
|
|
171
|
+
*/
|
|
172
|
+
function lostExecBits(dir) {
|
|
173
|
+
const r = git(dir, ["ls-files", "-z", "--stage"]);
|
|
174
|
+
if (r.status !== 0) return [];
|
|
175
|
+
const lost = [];
|
|
176
|
+
for (const line of splitNul(r.stdout)) {
|
|
177
|
+
const tab = line.indexOf("\t");
|
|
178
|
+
if (tab < 0) continue;
|
|
179
|
+
if (!line.startsWith("100755 ")) continue;
|
|
180
|
+
const rel = line.slice(tab + 1);
|
|
181
|
+
try {
|
|
182
|
+
if (!(fs.statSync(path.join(dir, rel)).mode & 0o100)) lost.push(rel);
|
|
183
|
+
} catch { /* deleted in the worktree; handled elsewhere */ }
|
|
184
|
+
}
|
|
185
|
+
return lost;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Build backends that read the git history for the version number.
|
|
190
|
+
*
|
|
191
|
+
* setuptools-scm, hatch-vcs, versioneer and pdm/poetry's scm plugins all derive the
|
|
192
|
+
* version from `git describe`. Without `.git` they do not warn — setuptools-scm
|
|
193
|
+
* produces `0.0.0`, versioneer produces `0+unknown`, and hatch-vcs fails the build
|
|
194
|
+
* outright — so the package that gets installed is silently the wrong version, or
|
|
195
|
+
* there is no package at all. These are the only builds worth paying for `.git`.
|
|
196
|
+
*/
|
|
197
|
+
const VCS_VERSIONING = [
|
|
198
|
+
[/setuptools[-_]scm|use_scm_version/, "setuptools-scm"],
|
|
199
|
+
[/hatch-vcs|source\s*=\s*["']vcs["']/, "hatch-vcs"],
|
|
200
|
+
[/versioneer/, "versioneer"],
|
|
201
|
+
[/pdm-backend|poetry-dynamic-versioning/, "an scm version plugin"],
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
/** Which VCS-versioning backend this project declares, if any. */
|
|
205
|
+
function wantsGitMetadata(dir) {
|
|
206
|
+
for (const name of ["pyproject.toml", "setup.py", "setup.cfg"]) {
|
|
207
|
+
let text;
|
|
208
|
+
try { text = fs.readFileSync(path.join(dir, name), "utf8"); } catch { continue; }
|
|
209
|
+
for (const [re, label] of VCS_VERSIONING) {
|
|
210
|
+
if (re.test(text)) return { file: name, backend: label };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Roughly how many bytes `.git` will add, from git's own accounting. */
|
|
217
|
+
function gitDirBytes(dir) {
|
|
218
|
+
const r = git(dir, ["count-objects", "-v"]);
|
|
219
|
+
if (r.status !== 0) return Infinity;
|
|
220
|
+
let kib = 0;
|
|
221
|
+
for (const line of String(r.stdout).split("\n")) {
|
|
222
|
+
const m = line.match(/^(?:size|size-pack):\s*(\d+)/);
|
|
223
|
+
if (m) kib += Number(m[1]);
|
|
224
|
+
}
|
|
225
|
+
return kib * 1024;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// `.git` is worth carrying for a version string; it is not worth carrying a decade of
|
|
229
|
+
// history through a 32 MiB upload ceiling. Past this we say so and let the build take
|
|
230
|
+
// 0.0.0, which is at least a visible outcome.
|
|
231
|
+
const GIT_META_LIMIT = 64 * 1024 * 1024;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Decide the contents of the bundle for `dir`.
|
|
235
|
+
*
|
|
236
|
+
* Returns the file list tar will be given plus everything worth reporting about how
|
|
237
|
+
* it was arrived at. Split out from the packing so the decision can be tested against
|
|
238
|
+
* real repositories without producing a tarball.
|
|
239
|
+
*/
|
|
240
|
+
function planBundle(dir) {
|
|
241
|
+
const listed = gitFileList(dir);
|
|
242
|
+
if (listed === null) {
|
|
243
|
+
return { source: "denylist", files: null, held: {}, ignored: null, missing: 0, collisions: [], lostExec: [], gitMeta: null };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const files = [];
|
|
247
|
+
const held = {};
|
|
248
|
+
let missing = 0;
|
|
249
|
+
for (const rel of listed) {
|
|
250
|
+
const why = heldBack(rel);
|
|
251
|
+
if (why) { (held[why] = held[why] || []).push(rel); continue; }
|
|
252
|
+
// `--cached` lists files deleted from the working tree; handing one to tar aborts
|
|
253
|
+
// the whole archive over a file the deploy never needed.
|
|
254
|
+
if (!fs.existsSync(path.join(dir, rel))) { missing++; continue; }
|
|
255
|
+
files.push(rel);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const gitMeta = wantsGitMetadata(dir);
|
|
259
|
+
if (gitMeta) {
|
|
260
|
+
gitMeta.bytes = gitDirBytes(dir);
|
|
261
|
+
gitMeta.include = gitMeta.bytes <= GIT_META_LIMIT;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
source: "git",
|
|
266
|
+
files,
|
|
267
|
+
held,
|
|
268
|
+
ignored: ignoredSummary(dir),
|
|
269
|
+
missing,
|
|
270
|
+
collisions: caseCollisions(files),
|
|
271
|
+
lostExec: lostExecBits(dir),
|
|
272
|
+
gitMeta,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function plural(n, one, many) { return n === 1 ? one : (many || one + "s"); }
|
|
277
|
+
function mb(bytes) { return (bytes / 1048576).toFixed(1) + " MB"; }
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The lines a person sees about what is and is not in their upload.
|
|
281
|
+
*
|
|
282
|
+
* Returned rather than printed so the wording is testable, and so index.js keeps
|
|
283
|
+
* ownership of colour and of the deploy log.
|
|
284
|
+
*/
|
|
285
|
+
function describeBundle(plan) {
|
|
286
|
+
const out = [];
|
|
287
|
+
if (plan.source === "denylist") {
|
|
288
|
+
out.push({ level: "info", text: "no git repository here — packing the folder minus the usual build junk" });
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
out.push({ level: "info", text: `${plan.files.length} ${plural(plan.files.length, "file")} from git` });
|
|
292
|
+
|
|
293
|
+
if (plan.ignored && plan.ignored.count) {
|
|
294
|
+
const cats = plan.ignored.top.length ? ` (${plan.ignored.top.join(" · ")})` : "";
|
|
295
|
+
out.push({ level: "detail", text: `skipped ${plan.ignored.count} ignored ${plural(plan.ignored.count, "path")}${cats}` });
|
|
296
|
+
}
|
|
297
|
+
const env = (plan.held.env || []).length;
|
|
298
|
+
if (env) out.push({ level: "detail", text: `held back ${env} .env ${plural(env, "file")} — sent as env vars instead, so the values stay rotatable` });
|
|
299
|
+
const mac = (plan.held.macos || []).length;
|
|
300
|
+
if (mac) out.push({ level: "detail", text: `dropped ${mac} macOS metadata ${plural(mac, "file")}` });
|
|
301
|
+
if (plan.missing) out.push({ level: "detail", text: `skipped ${plan.missing} tracked ${plural(plan.missing, "file")} deleted from the working tree` });
|
|
302
|
+
|
|
303
|
+
if (plan.gitMeta && plan.gitMeta.include) {
|
|
304
|
+
out.push({ level: "detail", text: `+ .git (${mb(plan.gitMeta.bytes)}) — ${plan.gitMeta.file} uses ${plan.gitMeta.backend}, which reads the version out of the history` });
|
|
305
|
+
} else if (plan.gitMeta) {
|
|
306
|
+
out.push({ level: "warn", text: `${plan.gitMeta.file} uses ${plan.gitMeta.backend} but .git is ${mb(plan.gitMeta.bytes)} — leaving it out. The build will version this 0.0.0; set the version explicitly if that matters.` });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
for (const group of plan.collisions) {
|
|
310
|
+
out.push({ level: "warn", text: `these differ only by case — one file here, two on Linux: ${group.join(" · ")}` });
|
|
311
|
+
}
|
|
312
|
+
if (plan.lostExec.length) {
|
|
313
|
+
out.push({ level: "warn", text: `git records ${plural(plan.lostExec.length, "this", "these")} as executable but the working copy is not — the build will get 0644: ${plan.lostExec.slice(0, 5).join(", ")}` });
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Resolve LFS pointers, or refuse to ship them.
|
|
320
|
+
*
|
|
321
|
+
* Fetching is the fix when git-lfs is installed. When it is not, failing here is the
|
|
322
|
+
* only honest outcome: every stage after this one will report success over files that
|
|
323
|
+
* are text stubs wearing an .mp4 extension.
|
|
324
|
+
*/
|
|
325
|
+
function resolveLfs(dir, files, log) {
|
|
326
|
+
let hits = findLfsPointers(dir, files);
|
|
327
|
+
if (!hits.length) return;
|
|
328
|
+
log({ level: "warn", text: `${hits.length} ${plural(hits.length, "file")} ${plural(hits.length, "is", "are")} a git-lfs pointer, not content — fetching` });
|
|
329
|
+
const r = spawnSync("git", ["lfs", "pull"], { cwd: dir, stdio: ["ignore", "ignore", "pipe"] });
|
|
330
|
+
if (r.status === 0) hits = findLfsPointers(dir, files);
|
|
331
|
+
if (!hits.length) return;
|
|
332
|
+
const shown = hits.slice(0, 8).join("\n ");
|
|
333
|
+
const more = hits.length > 8 ? `\n …and ${hits.length - 8} more` : "";
|
|
334
|
+
throw new Error(
|
|
335
|
+
`${hits.length} ${plural(hits.length, "file")} in this project ${plural(hits.length, "is", "are")} a git-lfs pointer and not the real content:\n ${shown}${more}\n` +
|
|
336
|
+
`Deploying them would ship 130-byte text files with media names. Install git-lfs and run \`git lfs pull\`, then deploy again.`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Pack `dir` into a temp .tgz and return its path.
|
|
342
|
+
*
|
|
343
|
+
* `log` receives {level, text} records; the caller decides how they look.
|
|
344
|
+
*/
|
|
345
|
+
// `async` on purpose: planning throws (LFS pointers with no way to fetch them), and a
|
|
346
|
+
// synchronous throw out of a function everything else awaits escapes the caller's
|
|
347
|
+
// error handling entirely.
|
|
348
|
+
async function packageFolder(dir, log = () => {}) {
|
|
349
|
+
const plan = planBundle(dir);
|
|
350
|
+
for (const line of describeBundle(plan)) log(line);
|
|
351
|
+
if (plan.files) resolveLfs(dir, plan.files, log);
|
|
352
|
+
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
const out = path.join(os.tmpdir(), "ss-deploy-" + process.pid + ".tgz");
|
|
355
|
+
// COPYFILE_DISABLE=1 stops macOS `tar` from synthesizing AppleDouble `._*`
|
|
356
|
+
// entries, which otherwise extract on the Linux build side and break framework
|
|
357
|
+
// builds (Next tries to compile `._page.js`). No-op off macOS.
|
|
358
|
+
const env = { ...process.env, COPYFILE_DISABLE: "1" };
|
|
359
|
+
const args = ["--exclude=._*", "-czf", out];
|
|
360
|
+
|
|
361
|
+
if (plan.files) {
|
|
362
|
+
// `.git/lfs` is content we already resolved into the tree, and `.git/hooks` is a
|
|
363
|
+
// pile of executable shell nobody asked to run on a build server.
|
|
364
|
+
if (plan.gitMeta && plan.gitMeta.include) args.push("--exclude=.git/lfs", "--exclude=.git/hooks");
|
|
365
|
+
// No `-C`: tar runs in `dir`, so the paths git printed are already correct, and
|
|
366
|
+
// the mode bits tar reads off disk are the ones the archive carries.
|
|
367
|
+
args.push("--null", "-T", "-");
|
|
368
|
+
} else {
|
|
369
|
+
for (const e of DENYLIST) args.push("--exclude=" + e);
|
|
370
|
+
args.push(".");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const p = spawn("tar", args, { cwd: dir, env, stdio: [plan.files ? "pipe" : "ignore", "ignore", "pipe"] });
|
|
374
|
+
let err = ""; p.stderr.on("data", (d) => (err += d));
|
|
375
|
+
p.on("error", () => reject(new Error("could not run `tar` — is it installed?")));
|
|
376
|
+
p.on("close", () => (fs.existsSync(out) ? resolve(out) : reject(new Error("packaging failed: " + err.trim()))));
|
|
377
|
+
|
|
378
|
+
if (plan.files) {
|
|
379
|
+
const names = plan.files.slice();
|
|
380
|
+
if (plan.gitMeta && plan.gitMeta.include) names.push(".git");
|
|
381
|
+
p.stdin.on("error", () => { /* tar died; `close` reports it */ });
|
|
382
|
+
p.stdin.end(names.join("\0") + "\0");
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = {
|
|
388
|
+
DENYLIST,
|
|
389
|
+
caseCollisions,
|
|
390
|
+
describeBundle,
|
|
391
|
+
findLfsPointers,
|
|
392
|
+
gitFileList,
|
|
393
|
+
heldBack,
|
|
394
|
+
isLfsPointer,
|
|
395
|
+
lostExecBits,
|
|
396
|
+
packageFolder,
|
|
397
|
+
planBundle,
|
|
398
|
+
wantsGitMetadata,
|
|
399
|
+
};
|