@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.
@@ -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: { manifest?: DashboardManifest | null } = {}): Promise<ResolvedDashboard> {
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
+ });