@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.
@@ -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
+ }