@hexclave/cli 1.0.61 → 1.0.63
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/dist/index.js +647 -138
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/config-file.ts +37 -29
- package/src/commands/deploy.test.ts +221 -0
- package/src/commands/deploy.ts +407 -0
- package/src/commands/dev.ts +8 -37
- package/src/commands/init.ts +16 -9
- package/src/commands/project.ts +6 -3
- package/src/commands/whoami.ts +6 -2
- package/src/index.ts +5 -1
- package/src/lib/create-project.ts +11 -8
- package/src/lib/dashboard-release.ts +9 -3
- package/src/lib/ignore-rules.test.ts +84 -0
- package/src/lib/ignore-rules.ts +133 -0
- package/src/lib/progress.test.ts +73 -0
- package/src/lib/progress.ts +82 -0
- package/src/lib/source-packaging.test.ts +129 -0
- package/src/lib/source-packaging.ts +153 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Packages a source directory into a gzipped ustar tarball for
|
|
2
|
+
// `hexclave deploy`. Respects .gitignore and .vercelignore files (at every
|
|
3
|
+
// directory level, like git), and always drops node_modules, .git, and
|
|
4
|
+
// symlinks. The output is deterministic for identical input trees (sorted
|
|
5
|
+
// entries, fixed mtime in the tar writer), which makes retried deploys upload
|
|
6
|
+
// byte-identical tarballs.
|
|
7
|
+
|
|
8
|
+
import { createTar, type TarEntry } from "@hexclave/shared/dist/utils/tar";
|
|
9
|
+
import { StatusError } from "@hexclave/shared/dist/utils/errors";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { gzipSync } from "node:zlib";
|
|
13
|
+
import { CliError } from "./errors.js";
|
|
14
|
+
import { type IgnoreRule, parseIgnoreFile } from "./ignore-rules.js";
|
|
15
|
+
|
|
16
|
+
const IGNORE_FILE_NAMES = [".gitignore", ".vercelignore"];
|
|
17
|
+
const ALWAYS_EXCLUDED_DIR_NAMES = new Set(["node_modules", ".git"]);
|
|
18
|
+
|
|
19
|
+
type IgnoreScope = {
|
|
20
|
+
// Absolute path of the directory containing the ignore file. Keeping this
|
|
21
|
+
// absolute lets a service rooted in a monorepo subdirectory still inherit
|
|
22
|
+
// ignore files from the config/repository root.
|
|
23
|
+
baseDirectory: string,
|
|
24
|
+
rules: IgnoreRule[],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function relativeToScope(scope: IgnoreScope, absolutePath: string): string | undefined {
|
|
28
|
+
const relativePath = path.relative(scope.baseDirectory, absolutePath);
|
|
29
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return relativePath.split(path.sep).join("/");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isIgnored(scopes: IgnoreScope[], absolutePath: string, isDirectory: boolean): boolean {
|
|
36
|
+
// Deeper ignore files take precedence, mirroring git: evaluate scopes
|
|
37
|
+
// outermost-first and let later (deeper) matches override earlier ones.
|
|
38
|
+
let ignored = false;
|
|
39
|
+
for (const scope of scopes) {
|
|
40
|
+
const scopedPath = relativeToScope(scope, absolutePath);
|
|
41
|
+
if (scopedPath === undefined) continue;
|
|
42
|
+
for (const rule of scope.rules) {
|
|
43
|
+
if (rule.dirOnly && !isDirectory) continue;
|
|
44
|
+
if (rule.regex.test(scopedPath)) {
|
|
45
|
+
ignored = !rule.negated;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return ignored;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type PackagedSource = {
|
|
53
|
+
tarballGzipped: Buffer,
|
|
54
|
+
fileCount: number,
|
|
55
|
+
totalBytes: number,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function readIgnoreScopes(directory: string): IgnoreScope[] {
|
|
59
|
+
const scopes: IgnoreScope[] = [];
|
|
60
|
+
for (const ignoreFileName of IGNORE_FILE_NAMES) {
|
|
61
|
+
const ignoreFilePath = path.join(directory, ignoreFileName);
|
|
62
|
+
if (fs.existsSync(ignoreFilePath) && fs.statSync(ignoreFilePath).isFile()) {
|
|
63
|
+
scopes.push({ baseDirectory: directory, rules: parseIgnoreFile(fs.readFileSync(ignoreFilePath, "utf-8")) });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return scopes;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Packages `rootDirectory`. `ignoreRootDirectory` is the outermost directory
|
|
71
|
+
* whose ignore files apply; deploy passes the config directory so a service in
|
|
72
|
+
* a monorepo subdirectory inherits the repository-level .gitignore and
|
|
73
|
+
* .vercelignore rules.
|
|
74
|
+
*/
|
|
75
|
+
export function packageSourceDirectory(rootDirectory: string, ignoreRootDirectory: string = rootDirectory): PackagedSource {
|
|
76
|
+
const absoluteRootDirectory = path.resolve(rootDirectory);
|
|
77
|
+
const absoluteIgnoreRootDirectory = path.resolve(ignoreRootDirectory);
|
|
78
|
+
const rootStat = fs.statSync(absoluteRootDirectory, { throwIfNoEntry: false });
|
|
79
|
+
if (rootStat == null || !rootStat.isDirectory()) {
|
|
80
|
+
throw new CliError(`Source directory not found: ${absoluteRootDirectory}`);
|
|
81
|
+
}
|
|
82
|
+
const relativeRootFromIgnoreRoot = path.relative(absoluteIgnoreRootDirectory, absoluteRootDirectory);
|
|
83
|
+
if (relativeRootFromIgnoreRoot === ".." || relativeRootFromIgnoreRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRootFromIgnoreRoot)) {
|
|
84
|
+
throw new CliError(`Source directory ${absoluteRootDirectory} must be inside the config directory ${absoluteIgnoreRootDirectory}.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const entries: TarEntry[] = [];
|
|
88
|
+
let totalBytes = 0;
|
|
89
|
+
|
|
90
|
+
const walk = (absoluteDir: string, relativeDir: string, parentScopes: IgnoreScope[]) => {
|
|
91
|
+
const scopes = [...parentScopes, ...readIgnoreScopes(absoluteDir)];
|
|
92
|
+
|
|
93
|
+
const dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
|
|
94
|
+
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
95
|
+
for (const dirent of dirents) {
|
|
96
|
+
const relativePath = relativeDir === "" ? dirent.name : `${relativeDir}/${dirent.name}`;
|
|
97
|
+
const absolutePath = path.join(absoluteDir, dirent.name);
|
|
98
|
+
if (dirent.isSymbolicLink()) {
|
|
99
|
+
// Symlinks are dropped: our tar subset doesn't represent them, and
|
|
100
|
+
// following them risks packaging files outside the source directory.
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (dirent.isDirectory()) {
|
|
104
|
+
if (ALWAYS_EXCLUDED_DIR_NAMES.has(dirent.name)) continue;
|
|
105
|
+
if (isIgnored(scopes, absolutePath, true)) continue;
|
|
106
|
+
walk(absolutePath, relativePath, scopes);
|
|
107
|
+
} else if (dirent.isFile()) {
|
|
108
|
+
if (isIgnored(scopes, absolutePath, false)) continue;
|
|
109
|
+
const data = fs.readFileSync(absolutePath);
|
|
110
|
+
totalBytes += data.length;
|
|
111
|
+
entries.push({ path: relativePath, data });
|
|
112
|
+
}
|
|
113
|
+
// Sockets, FIFOs, devices: silently skipped.
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const ancestorScopes: IgnoreScope[] = [];
|
|
118
|
+
const relativeSegments = relativeRootFromIgnoreRoot === "" ? [] : relativeRootFromIgnoreRoot.split(path.sep);
|
|
119
|
+
let currentDirectory = absoluteIgnoreRootDirectory;
|
|
120
|
+
for (const segment of relativeSegments) {
|
|
121
|
+
ancestorScopes.push(...readIgnoreScopes(currentDirectory));
|
|
122
|
+
const childDirectory = path.join(currentDirectory, segment);
|
|
123
|
+
// Git cannot re-include a directory once a parent ignore file prunes it.
|
|
124
|
+
if (isIgnored(ancestorScopes, childDirectory, true)) {
|
|
125
|
+
throw new CliError(`No files to deploy in ${absoluteRootDirectory} (the source directory is ignored by a parent .gitignore or .vercelignore).`);
|
|
126
|
+
}
|
|
127
|
+
currentDirectory = childDirectory;
|
|
128
|
+
}
|
|
129
|
+
walk(absoluteRootDirectory, "", ancestorScopes);
|
|
130
|
+
|
|
131
|
+
if (entries.length === 0) {
|
|
132
|
+
throw new CliError(`No files to deploy in ${absoluteRootDirectory} (everything is ignored or the directory is empty).`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let tarball;
|
|
136
|
+
try {
|
|
137
|
+
tarball = createTar(entries);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof StatusError) {
|
|
140
|
+
// The shared tar writer throws StatusError (it also runs on the
|
|
141
|
+
// backend); in the CLI that class isn't handled by main()'s
|
|
142
|
+
// CliError/AuthError catch and would crash with a Sentry report, so
|
|
143
|
+
// rewrap it as a normal user-facing CLI error.
|
|
144
|
+
throw new CliError(error.message);
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
tarballGzipped: gzipSync(tarball),
|
|
150
|
+
fileCount: entries.length,
|
|
151
|
+
totalBytes,
|
|
152
|
+
};
|
|
153
|
+
}
|