@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
|
@@ -166,7 +166,7 @@ async function sha256File(path: string): Promise<string> {
|
|
|
166
166
|
return hash.digest("hex");
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
async function downloadDashboardRelease(manifest: DashboardManifest): Promise<void> {
|
|
169
|
+
async function downloadDashboardRelease(manifest: DashboardManifest, onProgress?: (message: string) => void): Promise<void> {
|
|
170
170
|
const cacheRoot = dashboardCacheRoot();
|
|
171
171
|
mkdirSync(cacheRoot, { recursive: true });
|
|
172
172
|
// Unique temp names so parallel runs don't collide; publish is an atomic rename.
|
|
@@ -175,6 +175,7 @@ async function downloadDashboardRelease(manifest: DashboardManifest): Promise<vo
|
|
|
175
175
|
const tmpDir = join(cacheRoot, `.extract-${manifest.version}-${suffix}`);
|
|
176
176
|
const targetDir = dashboardVersionDir(manifest.version);
|
|
177
177
|
try {
|
|
178
|
+
onProgress?.(`Downloading Hexclave dashboard ${manifest.version}`);
|
|
178
179
|
const response = await fetch(manifest.url, { redirect: "follow", signal: AbortSignal.timeout(DASHBOARD_DOWNLOAD_TIMEOUT_MS) });
|
|
179
180
|
// The manifest URL passed isAllowedDownloadUrl, but redirects can land on a
|
|
180
181
|
// different host/scheme; re-check the final URL before streaming the archive.
|
|
@@ -186,6 +187,7 @@ async function downloadDashboardRelease(manifest: DashboardManifest): Promise<vo
|
|
|
186
187
|
}
|
|
187
188
|
await pipeline(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]), createWriteStream(tmpZip));
|
|
188
189
|
|
|
190
|
+
onProgress?.(`Verifying Hexclave dashboard ${manifest.version}`);
|
|
189
191
|
const digest = await sha256File(tmpZip);
|
|
190
192
|
if (digest !== manifest.sha256) {
|
|
191
193
|
throw new CliError(`Dashboard ${manifest.version} failed its integrity check (expected ${manifest.sha256}, got ${digest}).`);
|
|
@@ -193,6 +195,7 @@ async function downloadDashboardRelease(manifest: DashboardManifest): Promise<vo
|
|
|
193
195
|
|
|
194
196
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
195
197
|
mkdirSync(tmpDir, { recursive: true });
|
|
198
|
+
onProgress?.(`Extracting Hexclave dashboard ${manifest.version}`);
|
|
196
199
|
await extractZip(tmpZip, { dir: tmpDir });
|
|
197
200
|
if (!existsSync(join(tmpDir, DASHBOARD_SERVER_RELATIVE_PATH))) {
|
|
198
201
|
throw new CliError(`Dashboard ${manifest.version} archive is missing its server entrypoint.`);
|
|
@@ -225,7 +228,10 @@ async function downloadDashboardRelease(manifest: DashboardManifest): Promise<vo
|
|
|
225
228
|
|
|
226
229
|
// Resolve the build to launch: on-disk override → manifest version (downloaded if
|
|
227
230
|
// not cached) → newest cached (offline). Throws only when nothing is usable.
|
|
228
|
-
export async function resolveDashboardRuntime(opts: {
|
|
231
|
+
export async function resolveDashboardRuntime(opts: {
|
|
232
|
+
manifest?: DashboardManifest | null,
|
|
233
|
+
onProgress?: (message: string) => void,
|
|
234
|
+
} = {}): Promise<ResolvedDashboard> {
|
|
229
235
|
const override = dashboardDirOverride();
|
|
230
236
|
if (override != null) {
|
|
231
237
|
if (!existsSync(join(override, DASHBOARD_SERVER_RELATIVE_PATH))) {
|
|
@@ -240,7 +246,7 @@ export async function resolveDashboardRuntime(opts: { manifest?: DashboardManife
|
|
|
240
246
|
return { root: dashboardVersionDir(manifest.version), version: manifest.version };
|
|
241
247
|
}
|
|
242
248
|
try {
|
|
243
|
-
await downloadDashboardRelease(manifest);
|
|
249
|
+
await downloadDashboardRelease(manifest, opts.onProgress);
|
|
244
250
|
return { root: dashboardVersionDir(manifest.version), version: manifest.version };
|
|
245
251
|
} catch (error) {
|
|
246
252
|
const cached = latestCachedDashboardVersion();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isIgnoredByRules, parseIgnoreFile, parseIgnorePattern } from "./ignore-rules.js";
|
|
3
|
+
|
|
4
|
+
const check = (content: string, relativePath: string, isDirectory = false) =>
|
|
5
|
+
isIgnoredByRules(parseIgnoreFile(content), relativePath, isDirectory);
|
|
6
|
+
|
|
7
|
+
describe("parseIgnorePattern", () => {
|
|
8
|
+
it("skips comments and blank lines", () => {
|
|
9
|
+
expect(parseIgnorePattern("# a comment")).toBeUndefined();
|
|
10
|
+
expect(parseIgnorePattern("")).toBeUndefined();
|
|
11
|
+
expect(parseIgnorePattern(" ")).toBeUndefined();
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("isIgnoredByRules", () => {
|
|
16
|
+
it("matches basenames at any depth for slash-less patterns", () => {
|
|
17
|
+
expect(check("*.log", "error.log")).toBe(true);
|
|
18
|
+
expect(check("*.log", "deep/nested/error.log")).toBe(true);
|
|
19
|
+
expect(check("*.log", "error.log.txt")).toBe(false);
|
|
20
|
+
expect(check("dist", "dist", true)).toBe(true);
|
|
21
|
+
expect(check("dist", "packages/a/dist", true)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("anchors patterns that contain a slash", () => {
|
|
25
|
+
expect(check("build/output.js", "build/output.js")).toBe(true);
|
|
26
|
+
expect(check("build/output.js", "nested/build/output.js")).toBe(false);
|
|
27
|
+
expect(check("/top.txt", "top.txt")).toBe(true);
|
|
28
|
+
expect(check("/top.txt", "nested/top.txt")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("handles directory-only patterns", () => {
|
|
32
|
+
expect(check("cache/", "cache", true)).toBe(true);
|
|
33
|
+
expect(check("cache/", "cache", false)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("handles negation with last-match-wins", () => {
|
|
37
|
+
const content = "*.env\n!example.env";
|
|
38
|
+
expect(check(content, "secret.env")).toBe(true);
|
|
39
|
+
expect(check(content, "example.env")).toBe(false);
|
|
40
|
+
// Reversed order: the ignore comes later, so it wins.
|
|
41
|
+
expect(check("!example.env\n*.env", "example.env")).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("handles ** wildcards", () => {
|
|
45
|
+
expect(check("docs/**", "docs/a.md")).toBe(true);
|
|
46
|
+
expect(check("docs/**", "docs/deep/b.md")).toBe(true);
|
|
47
|
+
expect(check("docs/**", "docs", true)).toBe(false);
|
|
48
|
+
expect(check("**/temp", "temp", true)).toBe(true);
|
|
49
|
+
expect(check("**/temp", "a/b/temp", true)).toBe(true);
|
|
50
|
+
expect(check("a/**/z.txt", "a/z.txt")).toBe(true);
|
|
51
|
+
expect(check("a/**/z.txt", "a/b/c/z.txt")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("handles ? wildcards without crossing directories", () => {
|
|
55
|
+
expect(check("file?.txt", "file1.txt")).toBe(true);
|
|
56
|
+
expect(check("file?.txt", "file12.txt")).toBe(false);
|
|
57
|
+
expect(check("a?c", "a/c")).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not match partial segments", () => {
|
|
61
|
+
expect(check("foo", "foobar")).toBe(false);
|
|
62
|
+
expect(check("foo", "bar/foo")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("handles character classes", () => {
|
|
66
|
+
expect(check("file[0-9].txt", "file1.txt")).toBe(true);
|
|
67
|
+
expect(check("file[0-9].txt", "fileA.txt")).toBe(false);
|
|
68
|
+
expect(check("file[!0-9].txt", "fileA.txt")).toBe(true);
|
|
69
|
+
expect(check("file[!0-9].txt", "file1.txt")).toBe(false);
|
|
70
|
+
expect(check("[ab]c", "ac")).toBe(true);
|
|
71
|
+
expect(check("[ab]c", "cc")).toBe(false);
|
|
72
|
+
// Unterminated bracket is a literal.
|
|
73
|
+
expect(check("foo[bar", "foo[bar")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("handles backslash escapes", () => {
|
|
77
|
+
expect(check("\\#not-a-comment", "#not-a-comment")).toBe(true);
|
|
78
|
+
expect(check("\\!not-negated", "!not-negated")).toBe(true);
|
|
79
|
+
expect(check("star\\*lit", "star*lit")).toBe(true);
|
|
80
|
+
expect(check("star\\*lit", "starXlit")).toBe(false);
|
|
81
|
+
// Escaped trailing space is preserved.
|
|
82
|
+
expect(check("ends-with-space\\ ", "ends-with-space ")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// A small .gitignore/.vercelignore matcher for `hexclave deploy`'s source
|
|
2
|
+
// packaging. Implements the commonly-used gitignore syntax: comments, blank
|
|
3
|
+
// lines, negation (!), directory-only patterns (trailing /), anchoring
|
|
4
|
+
// (patterns containing a slash are relative to the ignore file's directory),
|
|
5
|
+
// the *, ?, ** wildcards, backslash escapes (\#, \!, \ ), and character
|
|
6
|
+
// classes ([abc], [a-z], [!a-z]). Matching what users' .gitignore files
|
|
7
|
+
// actually mean matters here: a pattern we under-match would make the CLI
|
|
8
|
+
// upload files the user believes are ignored.
|
|
9
|
+
|
|
10
|
+
export type IgnoreRule = {
|
|
11
|
+
negated: boolean,
|
|
12
|
+
dirOnly: boolean,
|
|
13
|
+
regex: RegExp,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function escapeRegExpChar(char: string): string {
|
|
17
|
+
return /[.*+?^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Converts one glob segment ("*.log", "foo?", "[a-z]bc", ...) to a regex
|
|
21
|
+
// fragment that never crosses a "/" boundary.
|
|
22
|
+
function globSegmentToRegex(segment: string): string {
|
|
23
|
+
let result = "";
|
|
24
|
+
for (let i = 0; i < segment.length; i++) {
|
|
25
|
+
const char = segment[i];
|
|
26
|
+
if (char === "\\" && i + 1 < segment.length) {
|
|
27
|
+
// Backslash escape: the next character is literal (gitignore uses this
|
|
28
|
+
// for \#, \!, and trailing "\ ").
|
|
29
|
+
result += escapeRegExpChar(segment[i + 1]);
|
|
30
|
+
i++;
|
|
31
|
+
} else if (char === "*") {
|
|
32
|
+
result += "[^/]*";
|
|
33
|
+
} else if (char === "?") {
|
|
34
|
+
result += "[^/]";
|
|
35
|
+
} else if (char === "[") {
|
|
36
|
+
// Character class: find the closing bracket (a ']' directly after the
|
|
37
|
+
// opening '[' or '[!' is literal, per fnmatch). An unterminated '[' is
|
|
38
|
+
// treated as a literal bracket.
|
|
39
|
+
let j = i + 1;
|
|
40
|
+
let negatedClass = false;
|
|
41
|
+
if (segment[j] === "!" || segment[j] === "^") {
|
|
42
|
+
negatedClass = true;
|
|
43
|
+
j++;
|
|
44
|
+
}
|
|
45
|
+
let classEnd = segment[j] === "]" ? segment.indexOf("]", j + 1) : segment.indexOf("]", j);
|
|
46
|
+
if (classEnd === -1) {
|
|
47
|
+
result += escapeRegExpChar(char);
|
|
48
|
+
} else {
|
|
49
|
+
const inner = segment.slice(j, classEnd);
|
|
50
|
+
// Keep '-' ranges; escape everything regex-special except '-'.
|
|
51
|
+
const safeInner = [...inner].map((c) => (c === "-" ? "-" : escapeRegExpChar(c))).join("");
|
|
52
|
+
result += `[${negatedClass ? "^" : ""}${safeInner}]`;
|
|
53
|
+
i = classEnd;
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
result += escapeRegExpChar(char);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseIgnorePattern(line: string): IgnoreRule | undefined {
|
|
63
|
+
let pattern = line.replace(/\r$/, "");
|
|
64
|
+
// Trailing spaces are ignored unless backslash-escaped (git semantics); the
|
|
65
|
+
// lookbehind keeps an escaped "\ " so the escape handling below can turn it
|
|
66
|
+
// into a literal space.
|
|
67
|
+
pattern = pattern.replace(/(?<!\\) +$/, "");
|
|
68
|
+
if (pattern === "" || pattern.startsWith("#")) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
let negated = false;
|
|
72
|
+
if (pattern.startsWith("!")) {
|
|
73
|
+
negated = true;
|
|
74
|
+
pattern = pattern.slice(1);
|
|
75
|
+
}
|
|
76
|
+
let dirOnly = false;
|
|
77
|
+
if (pattern.endsWith("/")) {
|
|
78
|
+
dirOnly = true;
|
|
79
|
+
pattern = pattern.slice(0, -1);
|
|
80
|
+
}
|
|
81
|
+
if (pattern === "") {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
// A pattern containing a slash (after stripping the trailing one) is
|
|
85
|
+
// anchored to the ignore file's directory; otherwise it matches at any depth.
|
|
86
|
+
const anchored = pattern.includes("/");
|
|
87
|
+
if (pattern.startsWith("/")) {
|
|
88
|
+
pattern = pattern.slice(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const segments = pattern.split("/");
|
|
92
|
+
const regexParts: string[] = [];
|
|
93
|
+
for (let i = 0; i < segments.length; i++) {
|
|
94
|
+
const segment = segments[i];
|
|
95
|
+
if (segment === "**") {
|
|
96
|
+
// "**" as a full segment matches zero or more path segments.
|
|
97
|
+
regexParts.push(i === segments.length - 1 ? ".*" : "(?:[^/]+/)*");
|
|
98
|
+
} else {
|
|
99
|
+
regexParts.push(globSegmentToRegex(segment) + (i === segments.length - 1 ? "" : "/"));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const body = regexParts.join("");
|
|
103
|
+
const prefix = anchored ? "" : "(?:[^/]+/)*";
|
|
104
|
+
return {
|
|
105
|
+
negated,
|
|
106
|
+
dirOnly,
|
|
107
|
+
regex: new RegExp(`^${prefix}${body}$`),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseIgnoreFile(content: string): IgnoreRule[] {
|
|
112
|
+
return content
|
|
113
|
+
.split("\n")
|
|
114
|
+
.map(parseIgnorePattern)
|
|
115
|
+
.filter((rule): rule is IgnoreRule => rule !== undefined);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Evaluates the rules against a path relative to the directory the rules are
|
|
120
|
+
* anchored at (POSIX separators, no leading "/"). Last matching rule wins,
|
|
121
|
+
* like git. Note that like git, a negation can't rescue a file inside an
|
|
122
|
+
* ignored directory — the walker prunes ignored directories entirely.
|
|
123
|
+
*/
|
|
124
|
+
export function isIgnoredByRules(rules: IgnoreRule[], relativePath: string, isDirectory: boolean): boolean {
|
|
125
|
+
let ignored = false;
|
|
126
|
+
for (const rule of rules) {
|
|
127
|
+
if (rule.dirOnly && !isDirectory) continue;
|
|
128
|
+
if (rule.regex.test(relativePath)) {
|
|
129
|
+
ignored = !rule.negated;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return ignored;
|
|
133
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { startProgress, withProgress } from "./progress.js";
|
|
3
|
+
|
|
4
|
+
function createStream(isTTY: boolean) {
|
|
5
|
+
const chunks: string[] = [];
|
|
6
|
+
return {
|
|
7
|
+
chunks,
|
|
8
|
+
stream: {
|
|
9
|
+
isTTY,
|
|
10
|
+
write(chunk: string) {
|
|
11
|
+
chunks.push(chunk);
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("startProgress", () => {
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.useRealTimers();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders and clears an animated line in interactive terminals", () => {
|
|
23
|
+
vi.useFakeTimers();
|
|
24
|
+
const { chunks, stream } = createStream(true);
|
|
25
|
+
const progress = startProgress("Loading config", { stream });
|
|
26
|
+
|
|
27
|
+
vi.advanceTimersByTime(80);
|
|
28
|
+
progress.update("Pushing config");
|
|
29
|
+
progress.stop();
|
|
30
|
+
|
|
31
|
+
expect(chunks.join("")).toContain("⠋ Loading config");
|
|
32
|
+
expect(chunks.join("")).toContain("⠙ Loading config");
|
|
33
|
+
expect(chunks.join("")).toContain("Pushing config");
|
|
34
|
+
expect(chunks.at(-1)).toBe("\r\x1b[2K");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("writes durable phase lines when stderr is redirected", () => {
|
|
38
|
+
const { chunks, stream } = createStream(false);
|
|
39
|
+
const progress = startProgress("Loading config", { prefix: "[Hexclave] ", stream });
|
|
40
|
+
|
|
41
|
+
progress.update("Pushing config");
|
|
42
|
+
progress.stop("Config pushed");
|
|
43
|
+
|
|
44
|
+
expect(chunks).toEqual([
|
|
45
|
+
"[Hexclave] Loading config...\n",
|
|
46
|
+
"[Hexclave] Pushing config...\n",
|
|
47
|
+
"[Hexclave] Config pushed\n",
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("stops idempotently", () => {
|
|
52
|
+
const { chunks, stream } = createStream(false);
|
|
53
|
+
const progress = startProgress("Working", { stream });
|
|
54
|
+
|
|
55
|
+
progress.stop("Done");
|
|
56
|
+
progress.stop("Done again");
|
|
57
|
+
|
|
58
|
+
expect(chunks).toEqual(["Working...\n", "Done\n"]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("withProgress", () => {
|
|
63
|
+
it("clears progress when the operation rejects", async () => {
|
|
64
|
+
const { chunks, stream } = createStream(true);
|
|
65
|
+
const error = new Error("nope");
|
|
66
|
+
|
|
67
|
+
await expect(withProgress("Working", async () => {
|
|
68
|
+
throw error;
|
|
69
|
+
}, { stream })).rejects.toBe(error);
|
|
70
|
+
|
|
71
|
+
expect(chunks.at(-1)).toBe("\r\x1b[2K");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
2
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
3
|
+
|
|
4
|
+
type ProgressStream = {
|
|
5
|
+
isTTY?: boolean,
|
|
6
|
+
write: (chunk: string) => unknown,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type Progress = {
|
|
10
|
+
update: (message: string) => void,
|
|
11
|
+
stop: (finalMessage?: string) => void,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type ProgressOptions = {
|
|
15
|
+
prefix?: string,
|
|
16
|
+
stream?: ProgressStream,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reports long-running CLI work without contaminating stdout. Interactive
|
|
21
|
+
* terminals get a single animated line, while redirected output gets durable
|
|
22
|
+
* lines that remain useful in CI logs.
|
|
23
|
+
*/
|
|
24
|
+
export function startProgress(initialMessage: string, options: ProgressOptions = {}): Progress {
|
|
25
|
+
const stream = options.stream ?? process.stderr;
|
|
26
|
+
const prefix = options.prefix ?? "";
|
|
27
|
+
let message = initialMessage;
|
|
28
|
+
let stopped = false;
|
|
29
|
+
|
|
30
|
+
if (!stream.isTTY) {
|
|
31
|
+
stream.write(`${prefix}${message}...\n`);
|
|
32
|
+
return {
|
|
33
|
+
update(nextMessage) {
|
|
34
|
+
if (stopped || nextMessage === message) return;
|
|
35
|
+
message = nextMessage;
|
|
36
|
+
stream.write(`${prefix}${message}...\n`);
|
|
37
|
+
},
|
|
38
|
+
stop(finalMessage) {
|
|
39
|
+
if (stopped) return;
|
|
40
|
+
stopped = true;
|
|
41
|
+
if (finalMessage != null) {
|
|
42
|
+
stream.write(`${prefix}${finalMessage}\n`);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let frameIndex = 0;
|
|
49
|
+
const render = () => {
|
|
50
|
+
stream.write(`\r\x1b[2K${prefix}${SPINNER_FRAMES[frameIndex]} ${message}`);
|
|
51
|
+
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
|
52
|
+
};
|
|
53
|
+
render();
|
|
54
|
+
const timer = setInterval(render, SPINNER_INTERVAL_MS);
|
|
55
|
+
timer.unref();
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
update(nextMessage) {
|
|
59
|
+
if (stopped || nextMessage === message) return;
|
|
60
|
+
message = nextMessage;
|
|
61
|
+
render();
|
|
62
|
+
},
|
|
63
|
+
stop(finalMessage) {
|
|
64
|
+
if (stopped) return;
|
|
65
|
+
stopped = true;
|
|
66
|
+
clearInterval(timer);
|
|
67
|
+
stream.write("\r\x1b[2K");
|
|
68
|
+
if (finalMessage != null) {
|
|
69
|
+
stream.write(`${prefix}${finalMessage}\n`);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function withProgress<T>(message: string, operation: () => Promise<T>, options?: ProgressOptions): Promise<T> {
|
|
76
|
+
const progress = startProgress(message, options);
|
|
77
|
+
try {
|
|
78
|
+
return await operation();
|
|
79
|
+
} finally {
|
|
80
|
+
progress.stop();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { parseTar } from "@hexclave/shared/dist/utils/tar";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { gunzipSync } from "node:zlib";
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { packageSourceDirectory } from "./source-packaging.js";
|
|
8
|
+
|
|
9
|
+
const tempDirs: string[] = [];
|
|
10
|
+
const makeTempDir = () => {
|
|
11
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hexclave-package-test-"));
|
|
12
|
+
tempDirs.push(dir);
|
|
13
|
+
return dir;
|
|
14
|
+
};
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
for (const dir of tempDirs.splice(0)) {
|
|
17
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const write = (root: string, relativePath: string, content: string) => {
|
|
22
|
+
const absolute = path.join(root, relativePath);
|
|
23
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
24
|
+
fs.writeFileSync(absolute, content);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const packagedPaths = (root: string) => {
|
|
28
|
+
const packaged = packageSourceDirectory(root);
|
|
29
|
+
const entries = parseTar(gunzipSync(packaged.tarballGzipped), { maxEntries: 10_000, maxTotalBytes: 100 * 1024 * 1024 });
|
|
30
|
+
return entries.map((entry) => entry.path);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("packageSourceDirectory", () => {
|
|
34
|
+
it("packages files with deterministic ordering and roundtrips content", () => {
|
|
35
|
+
const root = makeTempDir();
|
|
36
|
+
write(root, "package.json", `{"name":"x"}`);
|
|
37
|
+
write(root, "src/index.ts", "export {}\n");
|
|
38
|
+
write(root, "src/a.ts", "// a\n");
|
|
39
|
+
|
|
40
|
+
const packaged = packageSourceDirectory(root);
|
|
41
|
+
const entries = parseTar(gunzipSync(packaged.tarballGzipped), { maxEntries: 100, maxTotalBytes: 1024 * 1024 });
|
|
42
|
+
expect(entries.map((entry) => entry.path)).toEqual(["package.json", "src/a.ts", "src/index.ts"]);
|
|
43
|
+
expect(new TextDecoder().decode(entries[0].data)).toBe(`{"name":"x"}`);
|
|
44
|
+
expect(packaged.fileCount).toBe(3);
|
|
45
|
+
|
|
46
|
+
// Byte-identical when repackaged (fixed mtimes, sorted entries).
|
|
47
|
+
expect(Buffer.compare(packageSourceDirectory(root).tarballGzipped, packaged.tarballGzipped)).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("always drops node_modules and .git, even without ignore files", () => {
|
|
51
|
+
const root = makeTempDir();
|
|
52
|
+
write(root, "index.js", "");
|
|
53
|
+
write(root, "node_modules/pkg/index.js", "");
|
|
54
|
+
write(root, ".git/HEAD", "");
|
|
55
|
+
write(root, "nested/node_modules/pkg/index.js", "");
|
|
56
|
+
expect(packagedPaths(root)).toEqual(["index.js"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("respects .gitignore and .vercelignore including negation", () => {
|
|
60
|
+
const root = makeTempDir();
|
|
61
|
+
write(root, ".gitignore", "*.log\ndist/\n!keep.log\n");
|
|
62
|
+
write(root, ".vercelignore", "*.md\n");
|
|
63
|
+
write(root, "app.js", "");
|
|
64
|
+
write(root, "error.log", "");
|
|
65
|
+
write(root, "keep.log", "");
|
|
66
|
+
write(root, "README.md", "");
|
|
67
|
+
write(root, "dist/out.js", "");
|
|
68
|
+
expect(packagedPaths(root)).toEqual([".gitignore", ".vercelignore", "app.js", "keep.log"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("applies nested .gitignore files relative to their directory", () => {
|
|
72
|
+
const root = makeTempDir();
|
|
73
|
+
write(root, "sub/.gitignore", "local-only.txt\n");
|
|
74
|
+
write(root, "sub/local-only.txt", "");
|
|
75
|
+
write(root, "sub/kept.txt", "");
|
|
76
|
+
write(root, "local-only.txt", "");
|
|
77
|
+
expect(packagedPaths(root)).toEqual(["local-only.txt", "sub/.gitignore", "sub/kept.txt"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("inherits ignore files above a monorepo service root", () => {
|
|
81
|
+
const root = makeTempDir();
|
|
82
|
+
write(root, ".gitignore", "*.log\npackages/api/generated/\n");
|
|
83
|
+
write(root, ".vercelignore", "packages/api/private.txt\n");
|
|
84
|
+
write(root, "packages/api/index.ts", "");
|
|
85
|
+
write(root, "packages/api/error.log", "");
|
|
86
|
+
write(root, "packages/api/generated/client.ts", "");
|
|
87
|
+
write(root, "packages/api/private.txt", "");
|
|
88
|
+
|
|
89
|
+
const packaged = packageSourceDirectory(path.join(root, "packages/api"), root);
|
|
90
|
+
const entries = parseTar(gunzipSync(packaged.tarballGzipped), { maxEntries: 100, maxTotalBytes: 1024 * 1024 });
|
|
91
|
+
expect(entries.map((entry) => entry.path)).toEqual(["index.ts"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("allows a nested ignore file to override an inherited file rule", () => {
|
|
95
|
+
const root = makeTempDir();
|
|
96
|
+
write(root, ".gitignore", "*.log\n");
|
|
97
|
+
write(root, "packages/api/.gitignore", "!keep.log\n");
|
|
98
|
+
write(root, "packages/api/drop.log", "");
|
|
99
|
+
write(root, "packages/api/keep.log", "");
|
|
100
|
+
|
|
101
|
+
const packaged = packageSourceDirectory(path.join(root, "packages/api"), root);
|
|
102
|
+
const entries = parseTar(gunzipSync(packaged.tarballGzipped), { maxEntries: 100, maxTotalBytes: 1024 * 1024 });
|
|
103
|
+
expect(entries.map((entry) => entry.path)).toEqual([".gitignore", "keep.log"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects a source directory outside the ignore/config root", () => {
|
|
107
|
+
const configRoot = makeTempDir();
|
|
108
|
+
const sourceRoot = makeTempDir();
|
|
109
|
+
write(sourceRoot, "index.ts", "");
|
|
110
|
+
expect(() => packageSourceDirectory(sourceRoot, configRoot)).toThrow("must be inside the config directory");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("skips symlinks", () => {
|
|
114
|
+
const root = makeTempDir();
|
|
115
|
+
write(root, "real.txt", "hi");
|
|
116
|
+
fs.symlinkSync(path.join(root, "real.txt"), path.join(root, "link.txt"));
|
|
117
|
+
expect(packagedPaths(root)).toEqual(["real.txt"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("errors on a missing directory", () => {
|
|
121
|
+
expect(() => packageSourceDirectory(path.join(makeTempDir(), "nope"))).toThrow("Source directory not found");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("errors when everything is ignored", () => {
|
|
125
|
+
const root = makeTempDir();
|
|
126
|
+
write(root, "node_modules/pkg/index.js", "");
|
|
127
|
+
expect(() => packageSourceDirectory(root)).toThrow("No files to deploy");
|
|
128
|
+
});
|
|
129
|
+
});
|