Skip to content

Commit 6fd90e7

Browse files
committed
added ignore matcher
1 parent 99f81b6 commit 6fd90e7

5 files changed

Lines changed: 486 additions & 12 deletions

File tree

src/lib/ignore-matcher.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import * as fs from 'node:fs/promises';
2+
import * as path from 'node:path';
3+
4+
export type IgnoreKind = 'docker';
5+
6+
/**
7+
* Interface for ignore pattern matching.
8+
* `matches(path)` returns true if the path should be ignored.
9+
*/
10+
export interface IgnoreMatcher {
11+
matches(filePath: string): boolean;
12+
}
13+
14+
/**
15+
* Create an IgnoreMatcher from raw patterns for a given kind.
16+
* Currently only supports 'docker' (.dockerignore semantics).
17+
*/
18+
export function createIgnoreMatcher(patterns: string[], kind: IgnoreKind = 'docker'): IgnoreMatcher | null {
19+
const cleaned = normalizePatterns(patterns);
20+
if (cleaned.length === 0) return null;
21+
22+
switch (kind) {
23+
case 'docker':
24+
default:
25+
return new DockerIgnoreMatcher(cleaned);
26+
}
27+
}
28+
29+
/**
30+
* Load an ignore matcher from a directory.
31+
* For 'docker', this looks for `.dockerignore` in `dir`.
32+
*/
33+
export async function loadIgnoreMatcher(
34+
dir: string,
35+
kind: IgnoreKind = 'docker',
36+
): Promise<IgnoreMatcher | null> {
37+
const filename = kind === 'docker' ? '.dockerignore' : null;
38+
if (!filename) return null;
39+
40+
const ignorePath = path.join(dir, filename);
41+
42+
try {
43+
const stats = await fs.stat(ignorePath);
44+
if (typeof stats.isFile === 'function' && !stats.isFile()) {
45+
return null;
46+
}
47+
} catch {
48+
// No .dockerignore file – nothing to ignore
49+
return null;
50+
}
51+
52+
try {
53+
const content = await fs.readFile(ignorePath, 'utf-8');
54+
const rawPatterns = content
55+
.split(/\r?\n/)
56+
.map((line) => line.trim())
57+
.filter((line) => line && !line.startsWith('#'));
58+
59+
return createIgnoreMatcher(rawPatterns, kind);
60+
} catch {
61+
// If we can't read/parse, fail open (no ignores)
62+
return null;
63+
}
64+
}
65+
66+
function normalizePatterns(patterns: string[]): string[] {
67+
return patterns
68+
.map((p) => p.trim())
69+
.filter(Boolean)
70+
.map((p) => {
71+
// .dockerignore uses forward slashes
72+
let pat = p.replace(/\\/g, '/');
73+
// Leading "/" anchors to context root; since we only ever match
74+
// paths relative to the context root (no leading slash), we can
75+
// drop this while preserving Docker semantics.
76+
if (pat.startsWith('/')) pat = pat.slice(1);
77+
// Remove redundant leading "./"
78+
if (pat.startsWith('./')) pat = pat.slice(2);
79+
// Remove trailing slash except root
80+
if (pat.endsWith('/') && pat !== '/') pat = pat.slice(0, -1);
81+
return pat;
82+
});
83+
}
84+
85+
/**
86+
* Load an ignore matcher from an explicit ignore file path.
87+
*/
88+
export async function loadIgnoreMatcherFromFile(
89+
ignoreFilePath: string,
90+
kind: IgnoreKind = 'docker',
91+
): Promise<IgnoreMatcher | null> {
92+
try {
93+
const stats = await fs.stat(ignoreFilePath);
94+
if (typeof stats.isFile === 'function' && !stats.isFile()) {
95+
return null;
96+
}
97+
} catch {
98+
return null;
99+
}
100+
101+
try {
102+
const content = await fs.readFile(ignoreFilePath, 'utf-8');
103+
const rawPatterns = content
104+
.split(/\r?\n/)
105+
.map((line) => line.trim())
106+
.filter((line) => line && !line.startsWith('#'));
107+
108+
return createIgnoreMatcher(rawPatterns, kind);
109+
} catch {
110+
return null;
111+
}
112+
}
113+
114+
/**
115+
* Docker-style matcher implementing the core behavior of moby/patternmatcher:
116+
* - glob patterns with *, **, ?, and character classes
117+
* - '!' exclusion patterns
118+
* - parent-directory matches (MatchesOrParentMatches semantics)
119+
*/
120+
export class DockerIgnoreMatcher implements IgnoreMatcher {
121+
private patterns: CompiledPattern[];
122+
123+
constructor(patterns: string[]) {
124+
this.patterns = patterns.map((raw) => new CompiledPattern(raw));
125+
}
126+
127+
matches(filePath: string): boolean {
128+
// Expect paths relative to context root, forward-slash separated
129+
let normalized = filePath.replace(/\\/g, '/');
130+
131+
if (normalized.startsWith('./')) normalized = normalized.slice(2);
132+
if (!normalized) normalized = '.';
133+
134+
const parts = normalized.split('/');
135+
const parentPaths: string[] = [];
136+
for (let i = 0; i < parts.length - 1; i++) {
137+
parentPaths.push(parts.slice(0, i + 1).join('/'));
138+
}
139+
140+
let matched = false;
141+
142+
for (const pattern of this.patterns) {
143+
// If we are already ignored (matched=true), we only care about exceptions (exclusion=true).
144+
// If we are NOT ignored (matched=false), we only care about ignores (exclusion=false).
145+
if (pattern.exclusion !== matched) {
146+
continue;
147+
}
148+
149+
let isMatch = pattern.match(normalized);
150+
151+
if (!isMatch && parentPaths.length > 0) {
152+
for (const parent of parentPaths) {
153+
if (pattern.match(parent)) {
154+
isMatch = true;
155+
break;
156+
}
157+
}
158+
}
159+
160+
if (isMatch) {
161+
matched = !pattern.exclusion;
162+
}
163+
}
164+
165+
return matched;
166+
}
167+
}
168+
169+
class CompiledPattern {
170+
readonly raw: string;
171+
readonly exclusion: boolean;
172+
private readonly cleaned: string;
173+
private readonly regexp: RegExp;
174+
175+
constructor(pattern: string) {
176+
if (pattern.startsWith('!')) {
177+
this.exclusion = true;
178+
this.raw = pattern;
179+
this.cleaned = pattern.slice(1);
180+
} else {
181+
this.exclusion = false;
182+
this.raw = pattern;
183+
this.cleaned = pattern;
184+
}
185+
186+
this.regexp = globToRegExp(this.cleaned);
187+
}
188+
189+
match(target: string): boolean {
190+
return this.regexp.test(target);
191+
}
192+
}
193+
194+
/**
195+
* Convert a Docker-style glob pattern to a RegExp.
196+
* Roughly mirrors moby/patternmatcher.compile:
197+
* - '*' => any sequence except '/'
198+
* - '**' => any sequence including '/'
199+
* - '?' => any single char except '/'
200+
* - character classes [] are passed through
201+
*/
202+
function globToRegExp(pattern: string): RegExp {
203+
let re = '^';
204+
const len = pattern.length;
205+
206+
for (let i = 0; i < len; i++) {
207+
const ch = pattern[i]!;
208+
const next = i + 1 < len ? pattern[i + 1]! : '';
209+
210+
if (ch === '*') {
211+
if (next === '*') {
212+
// "**"
213+
i++;
214+
const afterNext = i + 1 < len ? pattern[i + 1]! : '';
215+
216+
if (afterNext === '/') {
217+
// "**/" => any number of directories (including none)
218+
i++;
219+
re += '(?:.*/)?';
220+
} else if (i + 1 === len) {
221+
// trailing "**"
222+
re += '.*';
223+
} else {
224+
// general "**" in middle
225+
re += '.*';
226+
}
227+
} else {
228+
// "*" => anything but '/'
229+
re += '[^/]*';
230+
}
231+
} else if (ch === '?') {
232+
re += '[^/]';
233+
} else if (ch === '[') {
234+
// Character class: copy through until closing ']'
235+
let j = i + 1;
236+
let cls = '[';
237+
while (j < len && pattern[j] !== ']') {
238+
const c = pattern[j]!;
239+
cls += c === '\\' ? '\\\\' : c;
240+
j++;
241+
}
242+
if (j < len && pattern[j] === ']') {
243+
cls += ']';
244+
re += cls;
245+
i = j;
246+
} else {
247+
// Unterminated '[', treat literally
248+
re += '\\[';
249+
}
250+
} else if ('().+|{}^$\\'.includes(ch)) {
251+
re += '\\' + ch;
252+
} else {
253+
re += ch;
254+
}
255+
}
256+
257+
re += '$';
258+
return new RegExp(re);
259+
}

src/sdk/blueprint.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,54 @@ import type {
77
} from '../resources/blueprints';
88
import type { DevboxCreateParams, DevboxView } from '../resources/devboxes/devboxes';
99
import type { PollingOptions } from '../lib/polling';
10+
import type { IgnoreMatcher } from '../lib/ignore-matcher';
1011
import { Devbox } from './devbox';
1112
import { StorageObject } from './storage-object';
1213

14+
export interface BuildContextDirOptions {
15+
/**
16+
* Path to the directory to use as build context (Node.js only).
17+
*/
18+
path: string;
19+
20+
/**
21+
* Optional ignore specification:
22+
* - an IgnoreMatcher instance, or
23+
* - an array of docker-style glob patterns (as in .dockerignore).
24+
*/
25+
ignore?: IgnoreMatcher | string[];
26+
27+
/**
28+
* Optional path to a specific .dockerignore-style file to use instead of the
29+
* default `<path>/.dockerignore`.
30+
*/
31+
dockerignorePath?: string;
32+
33+
/**
34+
* TTL (ms) for the backing StorageObject. Defaults to 1 hour if omitted.
35+
*/
36+
ttlMs?: number;
37+
}
38+
1339
export type CreateParams = Omit<BlueprintCreateParams, 'build_context'> & {
1440
/**
1541
* A build context to attach to the Blueprint build.
1642
* Enables the use of `COPY` Dockerfile directives.
1743
*/
1844
build_context?: StorageObject | BlueprintCreateParams.BuildContext | null;
45+
46+
/**
47+
* Configure a local directory build context.
48+
*
49+
* If provided, the SDK will:
50+
* 1. Create and upload a gzipped tarball of `path` as a StorageObject
51+
* (honoring .dockerignore or provided ignore patterns),
52+
* 2. Set `build_context` on the Blueprint to reference that object.
53+
*
54+
* If both build_context and build_context_dir are provided, build_context_dir
55+
* takes precedence.
56+
*/
57+
build_context_dir?: string | BuildContextDirOptions | null;
1958
};
2059

2160
/**
@@ -49,14 +88,46 @@ export class Blueprint {
4988
polling?: Partial<PollingOptions<BlueprintView>>;
5089
},
5190
): Promise<Blueprint> {
52-
let rawParams = { ...params };
53-
if (params.build_context instanceof StorageObject) {
54-
rawParams.build_context = { type: 'object', object_id: params.build_context.id };
91+
const { build_context, build_context_dir, ...other } = params as any;
92+
let rawParams: BlueprintCreateParams;
93+
94+
if (build_context_dir) {
95+
const dirConfig: BuildContextDirOptions =
96+
typeof build_context_dir === 'string' ? { path: build_context_dir } : build_context_dir;
97+
98+
const ttlMs = dirConfig.ttlMs ?? 3600000;
99+
100+
const storageObject = await StorageObject.uploadFromDir(
101+
client,
102+
dirConfig.path,
103+
{
104+
name: `build-context-${params.name}`,
105+
ttl_ms: ttlMs,
106+
},
107+
{
108+
...(options ?? {}),
109+
ignore: dirConfig.ignore,
110+
dockerignorePath: dirConfig.dockerignorePath,
111+
} as any,
112+
);
113+
114+
rawParams = {
115+
...other,
116+
build_context: { type: 'object', object_id: storageObject.id },
117+
} as BlueprintCreateParams;
118+
} else if (build_context instanceof StorageObject) {
119+
rawParams = {
120+
...other,
121+
build_context: { type: 'object', object_id: build_context.id },
122+
} as BlueprintCreateParams;
123+
} else {
124+
rawParams = {
125+
...other,
126+
build_context,
127+
} as BlueprintCreateParams;
55128
}
56-
const blueprintData = await client.blueprints.createAndAwaitBuildCompleted(
57-
rawParams as BlueprintCreateParams,
58-
options,
59-
);
129+
130+
const blueprintData = await client.blueprints.createAndAwaitBuildCompleted(rawParams, options);
60131
return new Blueprint(client, blueprintData.id);
61132
}
62133

0 commit comments

Comments
 (0)