Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 290 additions & 0 deletions src/lib/ignore-matcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';

export type IgnoreKind = 'docker';

/**
* Interface for ignore pattern matching.
* `matches(path)` returns true if the path should be ignored.
*/
export interface IgnoreMatcher {
matches(filePath: string): boolean;
}

/**
* Create an IgnoreMatcher from raw patterns for a given kind.
* Currently only supports 'docker' (.dockerignore semantics).
*/
export function createIgnoreMatcher(patterns: string[], kind: IgnoreKind = 'docker'): IgnoreMatcher | null {
const cleaned = normalizePatterns(patterns);
if (cleaned.length === 0) return null;

switch (kind) {
case 'docker':
default:
return new DockerIgnoreMatcher(cleaned);
}
}

/**
* Load an ignore matcher from a directory.
* For 'docker', this looks for `.dockerignore` in `dir`.
*/
export async function loadIgnoreMatcher(
dir: string,
kind: IgnoreKind = 'docker',
): Promise<IgnoreMatcher | null> {
const filename = kind === 'docker' ? '.dockerignore' : null;
if (!filename) return null;

const ignorePath = path.join(dir, filename);

try {
const stats = await fs.stat(ignorePath);
if (typeof stats.isFile === 'function' && !stats.isFile()) {
return null;
}
} catch {
// No .dockerignore file – nothing to ignore
return null;
}

try {
const content = await fs.readFile(ignorePath, 'utf-8');
const rawPatterns = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));

return createIgnoreMatcher(rawPatterns, kind);
} catch {
// If we can't read/parse, fail open (no ignores)
return null;
}
}

function normalizePatterns(patterns: string[]): string[] {
return patterns
.map((p) => p.trim())
.filter(Boolean)
.map((p) => {
// .dockerignore uses forward slashes
let pat = p.replace(/\\/g, '/');
// Leading "/" anchors to context root; since we only ever match
// paths relative to the context root (no leading slash), we can
// drop this while preserving Docker semantics.
if (pat.startsWith('/')) pat = pat.slice(1);
// Remove redundant leading "./"
if (pat.startsWith('./')) pat = pat.slice(2);
// Remove trailing slash except root
if (pat.endsWith('/') && pat !== '/') pat = pat.slice(0, -1);
return pat;
});
}

/**
* Load an ignore matcher from an explicit ignore file path.
*/
export async function loadIgnoreMatcherFromFile(
ignoreFilePath: string,
kind: IgnoreKind = 'docker',
): Promise<IgnoreMatcher | null> {
try {
const stats = await fs.stat(ignoreFilePath);
if (typeof stats.isFile === 'function' && !stats.isFile()) {
return null;
}
} catch {
return null;
}

try {
const content = await fs.readFile(ignoreFilePath, 'utf-8');
const rawPatterns = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));

return createIgnoreMatcher(rawPatterns, kind);
} catch {
return null;
}
}

/**
* Docker-style matcher implementing the core behavior of moby/patternmatcher:
* - glob patterns with *, **, ?, and character classes
* - '!' exclusion patterns
* - parent-directory matches (MatchesOrParentMatches semantics)
*/
export class DockerIgnoreMatcher implements IgnoreMatcher {
private patterns: CompiledPattern[];

constructor(patterns: string[]) {
this.patterns = patterns
.map((raw) => {
const trimmed = raw.trim();
if (!trimmed || trimmed.startsWith('#')) return null;

// Handle exclusion prefix
let isExclusion = false;
let cleaned = trimmed;
if (cleaned.startsWith('!')) {
isExclusion = true;
cleaned = cleaned.slice(1);
}

// Normalize path separators
cleaned = cleaned.replace(/\\/g, '/');

// Remove leading/trailing formatting
if (cleaned.startsWith('/')) cleaned = cleaned.slice(1);
if (cleaned.startsWith('./')) cleaned = cleaned.slice(2);
if (cleaned.endsWith('/') && cleaned !== '/') cleaned = cleaned.slice(0, -1);

const pattern = isExclusion ? '!' + cleaned : cleaned;
return new CompiledPattern(pattern, raw);
})
.filter((p): p is CompiledPattern => p !== null);
}

matches(filePath: string): boolean {
// Always ignore the .dockerignore file itself
if (filePath === '.dockerignore') return true;

// Expect paths relative to context root, forward-slash separated
let normalized = filePath.replace(/\\/g, '/');

// Strip leading ./ if present
if (normalized.startsWith('./')) normalized = normalized.slice(2);

// Strip trailing slash if present (unless it's just root)
if (normalized.endsWith('/') && normalized !== '/') normalized = normalized.slice(0, -1);

if (!normalized) normalized = '.';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The matcher currently treats .dockerignore like any other file, so unless explicitly excluded by a pattern it will be included in the uploaded context, which diverges from Docker CLI behavior where the .dockerignore file itself is always excluded from the build context. [logic error]

Severity Level: Minor ⚠️

Suggested change
// Always ignore the .dockerignore file itself, matching Docker CLI behavior.
if (normalized === '.dockerignore') {
return true;
}
Why it matters? ⭐

Docker's CLI always excludes the .dockerignore file from the build context. The current implementation applies only the provided patterns and therefore will include '.dockerignore' unless a user pattern excludes it. Adding a small explicit check to always ignore '.dockerignore' reproduces Docker behavior and prevents accidental inclusion of the ignore file in contexts or archives produced by the tooling.

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/ignore-matcher.ts
**Line:** 133:133
**Comment:**
	*Logic Error: The matcher currently treats `.dockerignore` like any other file, so unless explicitly excluded by a pattern it will be included in the uploaded context, which diverges from Docker CLI behavior where the `.dockerignore` file itself is always excluded from the build context.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.

const parts = normalized.split('/');
const parentPaths: string[] = [];
for (let i = 0; i < parts.length - 1; i++) {
parentPaths.push(parts.slice(0, i + 1).join('/'));
}

let matched = false;

for (const pattern of this.patterns) {
// If we are already ignored (matched=true), we only care about exceptions (exclusion=true).
// If we are NOT ignored (matched=false), we only care about ignores (exclusion=false).
if (pattern.exclusion !== matched) {
continue;
}

let isMatch = pattern.match(normalized);

if (!isMatch && parentPaths.length > 0) {
for (const parent of parentPaths) {
if (pattern.match(parent)) {
isMatch = true;
break;
}
}
}

if (isMatch) {
matched = !pattern.exclusion;
}
}

return matched;
}
}

class CompiledPattern {
readonly raw: string;
readonly exclusion: boolean;
private readonly cleaned: string;
private readonly regexp: RegExp;

constructor(pattern: string, raw?: string) {
this.raw = raw || pattern;
if (pattern.startsWith('!')) {
this.exclusion = true;
this.cleaned = pattern.slice(1);
} else {
this.exclusion = false;
this.cleaned = pattern;
}

this.regexp = globToRegExp(this.cleaned);
}

match(target: string): boolean {
return this.regexp.test(target);
}
}

/**
* Convert a Docker-style glob pattern to a RegExp.
* Roughly mirrors moby/patternmatcher.compile:
* - '*' => any sequence except '/'
* - '**' => any sequence including '/'
* - '?' => any single char except '/'
* - character classes [] are passed through
*/
function globToRegExp(pattern: string): RegExp {
let re = '^';
const len = pattern.length;

for (let i = 0; i < len; i++) {
const ch = pattern[i]!;
const next = i + 1 < len ? pattern[i + 1]! : '';

if (ch === '*') {
if (next === '*') {
// "**"
i++;
const afterNext = i + 1 < len ? pattern[i + 1]! : '';

if (afterNext === '/') {
// "**/" => any number of directories (including none)
i++;
re += '(?:.*/)?';
} else if (i + 1 === len) {
// trailing "**"
re += '.*';
} else {
// general "**" in middle
re += '.*';
}
} else {
// "*" => anything but '/'
re += '[^/]*';
}
} else if (ch === '?') {
re += '[^/]';
} else if (ch === '[') {
// Character class: copy through until closing ']'
let j = i + 1;
let cls = '[';
while (j < len && pattern[j] !== ']') {
const c = pattern[j]!;
cls += c === '\\' ? '\\\\' : c;
j++;
}
if (j < len && pattern[j] === ']') {
cls += ']';
re += cls;
i = j;
} else {
// Unterminated '[', treat literally
re += '\\[';
}
} else if ('().+|{}^$\\'.includes(ch)) {
re += '\\' + ch;
} else {
re += ch;
}
}

re += '$';
return new RegExp(re);
}
85 changes: 78 additions & 7 deletions src/sdk/blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,54 @@ import type {
} from '../resources/blueprints';
import type { DevboxCreateParams, DevboxView } from '../resources/devboxes/devboxes';
import type { PollingOptions } from '../lib/polling';
import type { IgnoreMatcher } from '../lib/ignore-matcher';
import { Devbox } from './devbox';
import { StorageObject } from './storage-object';

export interface BuildContextDirOptions {
/**
* Path to the directory to use as build context (Node.js only).
*/
path: string;

/**
* Optional ignore specification:
* - an IgnoreMatcher instance, or
* - an array of docker-style glob patterns (as in .dockerignore).
*/
ignore?: IgnoreMatcher | string[];

/**
* Optional path to a specific .dockerignore-style file to use instead of the
* default `<path>/.dockerignore`.
*/
dockerignorePath?: string;

/**
* TTL (ms) for the backing StorageObject. Defaults to 1 hour if omitted.
*/
ttlMs?: number;
}

export type CreateParams = Omit<BlueprintCreateParams, 'build_context'> & {
/**
* A build context to attach to the Blueprint build.
* Enables the use of `COPY` Dockerfile directives.
*/
build_context?: StorageObject | BlueprintCreateParams.BuildContext | null;

/**
* Configure a local directory build context.
*
* If provided, the SDK will:
* 1. Create and upload a gzipped tarball of `path` as a StorageObject
* (honoring .dockerignore or provided ignore patterns),
* 2. Set `build_context` on the Blueprint to reference that object.
*
* If both build_context and build_context_dir are provided, build_context_dir
* takes precedence.
*/
build_context_dir?: string | BuildContextDirOptions | null;
};

/**
Expand Down Expand Up @@ -49,14 +88,46 @@ export class Blueprint {
polling?: Partial<PollingOptions<BlueprintView>>;
},
): Promise<Blueprint> {
let rawParams = { ...params };
if (params.build_context instanceof StorageObject) {
rawParams.build_context = { type: 'object', object_id: params.build_context.id };
const { build_context, build_context_dir, ...other } = params as any;
let rawParams: BlueprintCreateParams;

if (build_context_dir) {
const dirConfig: BuildContextDirOptions =
typeof build_context_dir === 'string' ? { path: build_context_dir } : build_context_dir;

const ttlMs = dirConfig.ttlMs ?? 3600000;

const storageObject = await StorageObject.uploadFromDir(
client,
dirConfig.path,
{
name: `build-context-${params.name}`,
ttl_ms: ttlMs,
},
{
...(options ?? {}),
ignore: dirConfig.ignore,
dockerignorePath: dirConfig.dockerignorePath,
} as any,
);

rawParams = {
...other,
build_context: { type: 'object', object_id: storageObject.id },
} as BlueprintCreateParams;
} else if (build_context instanceof StorageObject) {
rawParams = {
...other,
build_context: { type: 'object', object_id: build_context.id },
} as BlueprintCreateParams;
} else {
rawParams = {
...other,
build_context,
} as BlueprintCreateParams;
}
const blueprintData = await client.blueprints.createAndAwaitBuildCompleted(
rawParams as BlueprintCreateParams,
options,
);

const blueprintData = await client.blueprints.createAndAwaitBuildCompleted(rawParams, options);
return new Blueprint(client, blueprintData.id);
}

Expand Down
Loading
Loading