ai
This commit is contained in:
parent
8f2f5e4fa9
commit
a20264bcaf
112 changed files with 12567 additions and 2694 deletions
|
|
@ -56,15 +56,16 @@ vaultlink \
|
|||
|
||||
### Optional
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------------------------ | ------- | -------------------------------------- |
|
||||
| `--sync-concurrency <number>` | `1` | Concurrent sync operations |
|
||||
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
|
||||
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
|
||||
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
|
||||
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||
| `-h, --help` | - | Show help |
|
||||
| `-V, --version` | - | Show version |
|
||||
| Option | Default | Description |
|
||||
| ------------------------------------ | ------- | ---------------------------------------------------- |
|
||||
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
|
||||
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
|
||||
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
|
||||
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||
| `--line-endings <mode>` | `auto` | Line ending style: auto, lf, crlf |
|
||||
| `-q, --quiet` | - | Suppress startup banner for non-interactive use |
|
||||
| `-h, --help` | - | Show help |
|
||||
| `-V, --version` | - | Show version |
|
||||
|
||||
### Auto-Ignored Patterns
|
||||
|
||||
|
|
@ -83,16 +84,23 @@ With ignore patterns:
|
|||
|
||||
```bash
|
||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||
--ignore-pattern "*.tmp" \
|
||||
--ignore-pattern "**/*.tmp" \
|
||||
--ignore-pattern ".DS_Store" \
|
||||
--ignore-pattern "node_modules/**"
|
||||
```
|
||||
|
||||
With debug logging:
|
||||
With debug logging and quiet startup:
|
||||
|
||||
```bash
|
||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||
--log-level DEBUG
|
||||
--log-level DEBUG --quiet
|
||||
```
|
||||
|
||||
Force LF line endings (useful for cross-platform vaults):
|
||||
|
||||
```bash
|
||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||
--line-endings lf
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
|
|
|||
|
|
@ -55,13 +55,10 @@ test("parseArgs - parse with optional arguments", () => {
|
|||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--sync-concurrency",
|
||||
"5",
|
||||
"--max-file-size-mb",
|
||||
"20"
|
||||
]);
|
||||
|
||||
assert.equal(args.syncConcurrency, 5);
|
||||
assert.equal(args.maxFileSizeMB, 20);
|
||||
});
|
||||
|
||||
|
|
@ -292,3 +289,162 @@ test("parseArgs - reads log level from environment variable", () => {
|
|||
delete process.env.VAULTLINK_LOG_LEVEL;
|
||||
}
|
||||
});
|
||||
|
||||
test("parseArgs - quiet defaults to false", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
|
||||
assert.equal(args.quiet, false);
|
||||
});
|
||||
|
||||
test("parseArgs - parse --quiet flag", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--quiet"
|
||||
]);
|
||||
|
||||
assert.equal(args.quiet, true);
|
||||
});
|
||||
|
||||
test("parseArgs - parse -q short flag", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"-q"
|
||||
]);
|
||||
|
||||
assert.equal(args.quiet, true);
|
||||
});
|
||||
|
||||
test("parseArgs - line-endings defaults to auto", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
|
||||
assert.equal(args.lineEndings, "auto");
|
||||
});
|
||||
|
||||
test("parseArgs - parse --line-endings lf", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--line-endings",
|
||||
"lf"
|
||||
]);
|
||||
|
||||
assert.equal(args.lineEndings, "lf");
|
||||
});
|
||||
|
||||
test("parseArgs - parse --line-endings crlf", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--line-endings",
|
||||
"crlf"
|
||||
]);
|
||||
|
||||
assert.equal(args.lineEndings, "crlf");
|
||||
});
|
||||
|
||||
test("parseArgs - throws on invalid remote URI protocol", () => {
|
||||
assert.throws(() => {
|
||||
parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"ftp://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
}, /Invalid remote URI/);
|
||||
});
|
||||
|
||||
test("parseArgs - accepts http:// remote URI", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"http://localhost:3000",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
|
||||
assert.equal(args.remoteUri, "http://localhost:3000");
|
||||
});
|
||||
|
||||
test("parseArgs - accepts wss:// remote URI", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"wss://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
]);
|
||||
|
||||
assert.equal(args.remoteUri, "wss://sync.example.com");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,20 +2,25 @@ import { Command, Option } from "commander";
|
|||
import packageJson from "../package.json";
|
||||
import { LogLevel } from "sync-client";
|
||||
|
||||
export type LineEndingMode = "auto" | "lf" | "crlf";
|
||||
|
||||
export interface CliArgs {
|
||||
remoteUri: string;
|
||||
token: string;
|
||||
vaultName: string;
|
||||
localPath: string;
|
||||
syncConcurrency?: number;
|
||||
maxFileSizeMB?: number;
|
||||
ignorePatterns?: string[];
|
||||
webSocketRetryIntervalMs?: number;
|
||||
logLevel: LogLevel;
|
||||
health?: string;
|
||||
enableTelemetry?: boolean;
|
||||
quiet: boolean;
|
||||
lineEndings: LineEndingMode;
|
||||
}
|
||||
|
||||
const VALID_URI_PREFIXES = ["http://", "https://", "ws://", "wss://"];
|
||||
|
||||
export function parseArgs(argv: string[]): CliArgs {
|
||||
const program = new Command();
|
||||
|
||||
|
|
@ -49,14 +54,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||
"Vault name"
|
||||
).env("VAULTLINK_VAULT_NAME")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--sync-concurrency <number>",
|
||||
"[OPTIONAL] Number of concurrent sync operations"
|
||||
)
|
||||
.argParser(parseInt)
|
||||
.env("VAULTLINK_SYNC_CONCURRENCY")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--max-file-size-mb <number>",
|
||||
|
|
@ -99,15 +96,30 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||
"[OPTIONAL] Enable telemetry (disabled by default)"
|
||||
).env("VAULTLINK_ENABLE_TELEMETRY")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"-q, --quiet",
|
||||
"[OPTIONAL] Suppress startup banner for non-interactive use"
|
||||
).env("VAULTLINK_QUIET")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--line-endings <mode>",
|
||||
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
|
||||
)
|
||||
.default("auto")
|
||||
.choices(["auto", "lf", "crlf"])
|
||||
.env("VAULTLINK_LINE_ENDINGS")
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
`
|
||||
Examples:
|
||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
|
||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
|
||||
--ignore-pattern ".git/**" --ignore-pattern "**/*.tmp"
|
||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||
--log-level DEBUG
|
||||
--log-level DEBUG --quiet
|
||||
|
||||
Environment variables:
|
||||
All options can be configured via VAULTLINK_ prefixed environment variables.
|
||||
|
|
@ -123,7 +135,6 @@ Environment variables:
|
|||
const remoteUri = opts.remoteUri as string | undefined;
|
||||
const token = opts.token as string | undefined;
|
||||
const vaultName = opts.vaultName as string | undefined;
|
||||
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
||||
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
||||
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
||||
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
||||
|
|
@ -132,6 +143,8 @@ Environment variables:
|
|||
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
||||
const health = opts.health as string | undefined;
|
||||
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
||||
const quiet = (opts.quiet as boolean | undefined) ?? false;
|
||||
const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto";
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
|
||||
const requireOption = <T>(
|
||||
|
|
@ -142,9 +155,12 @@ Environment variables:
|
|||
const option = program.options.find(
|
||||
(o) => o.attributeName() === name
|
||||
);
|
||||
const envHint =
|
||||
option?.envVar !== undefined
|
||||
? ` (or set ${option.envVar})`
|
||||
: "";
|
||||
throw new Error(
|
||||
`required option '${option?.flags ?? name}' not specified` +
|
||||
(option?.envVar ? ` (or set ${option.envVar})` : "")
|
||||
`required option '${option?.flags ?? name}' not specified${envHint}`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
|
|
@ -155,6 +171,17 @@ Environment variables:
|
|||
const requiredToken = requireOption(token, "token");
|
||||
const requiredVaultName = requireOption(vaultName, "vaultName");
|
||||
|
||||
// Validate remote URI protocol
|
||||
if (
|
||||
!VALID_URI_PREFIXES.some((prefix) =>
|
||||
requiredRemoteUri.startsWith(prefix)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_URI_PREFIXES.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate and parse log level
|
||||
const logLevelUpper = logLevelStr.toUpperCase();
|
||||
const validLogLevels = Object.values(LogLevel);
|
||||
|
|
@ -168,17 +195,21 @@ Environment variables:
|
|||
}
|
||||
const logLevel = logLevelUpper;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const lineEndings = lineEndingsStr as LineEndingMode;
|
||||
|
||||
return {
|
||||
localPath: requiredLocalPath,
|
||||
remoteUri: requiredRemoteUri,
|
||||
token: requiredToken,
|
||||
vaultName: requiredVaultName,
|
||||
syncConcurrency,
|
||||
maxFileSizeMB: maxFileSizeMb,
|
||||
ignorePatterns: ignorePattern,
|
||||
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
||||
logLevel,
|
||||
health,
|
||||
enableTelemetry
|
||||
enableTelemetry,
|
||||
quiet,
|
||||
lineEndings
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,20 @@ const LOG_LEVEL_ORDER = {
|
|||
};
|
||||
|
||||
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
|
||||
const PROGRESS_LOG_INTERVAL_MS = 2000;
|
||||
|
||||
function resolveLineEndings(
|
||||
mode: "auto" | "lf" | "crlf"
|
||||
): string {
|
||||
switch (mode) {
|
||||
case "lf":
|
||||
return "\n";
|
||||
case "crlf":
|
||||
return "\r\n";
|
||||
case "auto":
|
||||
return process.platform === "win32" ? "\r\n" : "\n";
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv);
|
||||
|
|
@ -64,21 +78,28 @@ async function main(): Promise<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
styleText("VaultLink Local CLI", "bold", "cyan") +
|
||||
colorize(` v${packageJson.version}`, "dim")
|
||||
);
|
||||
console.log(colorize("=".repeat(50), "dim"));
|
||||
console.log(
|
||||
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
|
||||
);
|
||||
console.log(
|
||||
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
|
||||
);
|
||||
console.log(
|
||||
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
|
||||
);
|
||||
console.log("");
|
||||
if (!args.quiet) {
|
||||
console.log(
|
||||
styleText("VaultLink Local CLI", "bold", "cyan") +
|
||||
colorize(` v${packageJson.version}`, "dim")
|
||||
);
|
||||
console.log(colorize("=".repeat(50), "dim"));
|
||||
console.log(
|
||||
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
|
||||
);
|
||||
console.log(
|
||||
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
|
||||
);
|
||||
console.log(
|
||||
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
|
||||
);
|
||||
if (args.lineEndings !== "auto") {
|
||||
console.log(
|
||||
`${colorize("Line endings:", "dim")} ${colorize(args.lineEndings.toUpperCase(), "green")}`
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
const dataDir = path.join(absolutePath, ".vaultlink");
|
||||
const dataFile = path.join(dataDir, "sync-data.json");
|
||||
|
|
@ -98,8 +119,6 @@ async function main(): Promise<void> {
|
|||
remoteUri: args.remoteUri,
|
||||
token: args.token,
|
||||
vaultName: args.vaultName,
|
||||
syncConcurrency:
|
||||
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
|
||||
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
||||
ignorePatterns,
|
||||
webSocketRetryIntervalMs:
|
||||
|
|
@ -141,7 +160,7 @@ async function main(): Promise<void> {
|
|||
);
|
||||
}
|
||||
},
|
||||
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
||||
nativeLineEndings: resolveLineEndings(args.lineEndings)
|
||||
});
|
||||
|
||||
if (args.health !== undefined) {
|
||||
|
|
@ -183,11 +202,32 @@ async function main(): Promise<void> {
|
|||
);
|
||||
});
|
||||
|
||||
// Throttled progress reporting
|
||||
let syncBatchSize = 0;
|
||||
let totalSyncOps = 0;
|
||||
let lastProgressLogTime = 0;
|
||||
|
||||
client.onRemainingOperationsCountChanged.add((remaining) => {
|
||||
if (remaining > syncBatchSize) {
|
||||
syncBatchSize = remaining;
|
||||
}
|
||||
|
||||
if (remaining === 0) {
|
||||
client.logger.info("All sync operations completed");
|
||||
if (syncBatchSize > 0) {
|
||||
totalSyncOps += syncBatchSize;
|
||||
client.logger.info(
|
||||
`Sync batch complete (${syncBatchSize} operations)`
|
||||
);
|
||||
syncBatchSize = 0;
|
||||
}
|
||||
} else {
|
||||
client.logger.info(`${remaining} sync operations remaining`);
|
||||
const now = Date.now();
|
||||
if (now - lastProgressLogTime >= PROGRESS_LOG_INTERVAL_MS) {
|
||||
client.logger.info(
|
||||
`Syncing: ${remaining} operations remaining`
|
||||
);
|
||||
lastProgressLogTime = now;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -208,7 +248,17 @@ async function main(): Promise<void> {
|
|||
fileWatcher.stop();
|
||||
await client.waitUntilFinished();
|
||||
await client.destroy();
|
||||
console.log(colorize("Shutdown complete", "green"));
|
||||
|
||||
if (totalSyncOps > 0) {
|
||||
console.log(
|
||||
colorize(
|
||||
`Shutdown complete (${totalSyncOps} operations synced)`,
|
||||
"green"
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.log(colorize("Shutdown complete", "green"));
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
|
|
@ -231,9 +281,13 @@ async function main(): Promise<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${colorize("✓", "green")} Server connection successful`);
|
||||
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
||||
console.log("");
|
||||
if (!args.quiet) {
|
||||
console.log(
|
||||
`${colorize("✓", "green")} Server connection successful`
|
||||
);
|
||||
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
await client.start();
|
||||
fileWatcher.start();
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
import Watcher from "watcher";
|
||||
import * as path from "path";
|
||||
import type { SyncClient, RelativePath } from "sync-client";
|
||||
import { toUnixPath, compileGlobPattern } from "./path-utils";
|
||||
|
||||
export class FileWatcher {
|
||||
private watcher: Watcher | undefined;
|
||||
private isRunning = false;
|
||||
private readonly compiledPatterns: RegExp[];
|
||||
|
||||
public constructor(
|
||||
private readonly basePath: string,
|
||||
private readonly client: SyncClient,
|
||||
private readonly ignorePatterns: string[] = []
|
||||
) {}
|
||||
ignorePatterns: string[] = []
|
||||
) {
|
||||
this.compiledPatterns = ignorePatterns.map(compileGlobPattern);
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.isRunning) {
|
||||
|
|
@ -24,7 +28,8 @@ export class FileWatcher {
|
|||
renameDetection: true,
|
||||
renameTimeout: 125,
|
||||
ignoreInitial: true,
|
||||
ignore: (filePath: string) => this.shouldIgnore(filePath)
|
||||
ignore: (filePath: string): boolean =>
|
||||
this.shouldIgnore(filePath)
|
||||
});
|
||||
|
||||
this.watcher.on("add", (filePath: string) => {
|
||||
|
|
@ -59,16 +64,8 @@ export class FileWatcher {
|
|||
}
|
||||
|
||||
private shouldIgnore(filePath: string): boolean {
|
||||
const rel = path
|
||||
.relative(this.basePath, filePath)
|
||||
.replace(/\\/g, "/");
|
||||
return this.ignorePatterns.some((pattern) => {
|
||||
if (pattern.endsWith("/**")) {
|
||||
const prefix = pattern.slice(0, -3);
|
||||
return rel === prefix || rel.startsWith(prefix + "/");
|
||||
}
|
||||
return rel === pattern;
|
||||
});
|
||||
const rel = toUnixPath(path.relative(this.basePath, filePath));
|
||||
return this.compiledPatterns.some((regex) => regex.test(rel));
|
||||
}
|
||||
|
||||
private handleCreate(relativePath: RelativePath): void {
|
||||
|
|
@ -116,18 +113,7 @@ export class FileWatcher {
|
|||
}
|
||||
|
||||
private toRelativePath(absolutePath: string): RelativePath {
|
||||
const relative = path.relative(this.basePath, absolutePath);
|
||||
return this.toUnixPath(relative);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a native platform path to forward slashes
|
||||
*/
|
||||
private toUnixPath(nativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return nativePath.replace(/\\/g, "/");
|
||||
}
|
||||
return nativePath;
|
||||
return toUnixPath(path.relative(this.basePath, absolutePath));
|
||||
}
|
||||
|
||||
private formatError(err: unknown): string {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
RelativePath,
|
||||
TextWithCursors
|
||||
} from "sync-client";
|
||||
import { toUnixPath, toNativePath } from "./path-utils";
|
||||
|
||||
export class NodeFileSystemOperations implements FileSystemOperations {
|
||||
public constructor(private readonly basePath: string) {}
|
||||
|
|
@ -15,7 +16,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
): Promise<RelativePath[]> {
|
||||
const files: RelativePath[] = [];
|
||||
await this.walkDirectory(
|
||||
directory !== undefined ? this.toNativePath(directory) : "",
|
||||
directory !== undefined ? toNativePath(directory) : "",
|
||||
files
|
||||
);
|
||||
return files;
|
||||
|
|
@ -24,7 +25,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
return await fs.readFile(fullPath);
|
||||
|
|
@ -41,7 +42,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
): Promise<void> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
toNativePath(relativePath)
|
||||
);
|
||||
const dir = path.dirname(fullPath);
|
||||
|
||||
|
|
@ -61,7 +62,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
): Promise<string> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
toNativePath(relativePath)
|
||||
);
|
||||
|
||||
try {
|
||||
|
|
@ -79,7 +80,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
const stats = await fs.stat(fullPath);
|
||||
|
|
@ -94,7 +95,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
public async exists(relativePath: RelativePath): Promise<boolean> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
|
|
@ -107,7 +108,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.mkdir(fullPath, { recursive: false });
|
||||
|
|
@ -121,7 +122,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
public async delete(relativePath: RelativePath): Promise<void> {
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
|
|
@ -138,11 +139,11 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
): Promise<void> {
|
||||
const oldFullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(oldPath)
|
||||
toNativePath(oldPath)
|
||||
);
|
||||
const newFullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(newPath)
|
||||
toNativePath(newPath)
|
||||
);
|
||||
const newDir = path.dirname(newFullPath);
|
||||
|
||||
|
|
@ -192,28 +193,9 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
await this.walkDirectory(entryRelativePath, files);
|
||||
} else if (entry.isFile()) {
|
||||
// Always return forward slashes
|
||||
files.push(this.toUnixPath(entryRelativePath));
|
||||
files.push(toUnixPath(entryRelativePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a forward-slash path to native platform path separators
|
||||
*/
|
||||
private toNativePath(relativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return relativePath.replace(/\//g, "\\");
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a native platform path to forward slashes
|
||||
*/
|
||||
private toUnixPath(nativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return nativePath.replace(/\\/g, "/");
|
||||
}
|
||||
return nativePath;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
frontend/local-client-cli/src/path-utils.test.ts
Normal file
65
frontend/local-client-cli/src/path-utils.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { test } from "node:test";
|
||||
import * as assert from "node:assert/strict";
|
||||
import { compileGlobPattern, toUnixPath } from "./path-utils";
|
||||
|
||||
function matches(path: string, pattern: string): boolean {
|
||||
return compileGlobPattern(pattern).test(path);
|
||||
}
|
||||
|
||||
test("compileGlobPattern - exact match", () => {
|
||||
assert.equal(matches(".DS_Store", ".DS_Store"), true);
|
||||
assert.equal(matches("other", ".DS_Store"), false);
|
||||
});
|
||||
|
||||
test("compileGlobPattern - dir/** matches directory and contents", () => {
|
||||
assert.equal(matches(".git", ".git/**"), true);
|
||||
assert.equal(matches(".git/config", ".git/**"), true);
|
||||
assert.equal(matches(".git/refs/heads/main", ".git/**"), true);
|
||||
assert.equal(matches(".gitignore", ".git/**"), false);
|
||||
});
|
||||
|
||||
test("compileGlobPattern - * matches within a single segment", () => {
|
||||
assert.equal(matches("foo.tmp", "*.tmp"), true);
|
||||
assert.equal(matches("bar.tmp", "*.tmp"), true);
|
||||
assert.equal(matches("foo.md", "*.tmp"), false);
|
||||
// * does NOT cross path separators
|
||||
assert.equal(matches("dir/foo.tmp", "*.tmp"), false);
|
||||
});
|
||||
|
||||
test("compileGlobPattern - **/*.ext matches at any depth", () => {
|
||||
assert.equal(matches("foo.tmp", "**/*.tmp"), true);
|
||||
assert.equal(matches("dir/foo.tmp", "**/*.tmp"), true);
|
||||
assert.equal(matches("a/b/c/foo.tmp", "**/*.tmp"), true);
|
||||
assert.equal(matches("foo.md", "**/*.tmp"), false);
|
||||
});
|
||||
|
||||
test("compileGlobPattern - ? matches single character", () => {
|
||||
assert.equal(matches("a.md", "?.md"), true);
|
||||
assert.equal(matches("ab.md", "?.md"), false);
|
||||
assert.equal(matches(".md", "?.md"), false);
|
||||
});
|
||||
|
||||
test("compileGlobPattern - dots are escaped", () => {
|
||||
assert.equal(matches(".DS_Store", ".DS_Store"), true);
|
||||
assert.equal(matches("xDS_Store", ".DS_Store"), false);
|
||||
});
|
||||
|
||||
test("compileGlobPattern - node_modules/** matches directory tree", () => {
|
||||
assert.equal(matches("node_modules", "node_modules/**"), true);
|
||||
assert.equal(matches("node_modules/foo", "node_modules/**"), true);
|
||||
assert.equal(
|
||||
matches("node_modules/foo/bar/baz.js", "node_modules/**"),
|
||||
true
|
||||
);
|
||||
assert.equal(matches("not_node_modules", "node_modules/**"), false);
|
||||
});
|
||||
|
||||
test("compileGlobPattern - **/ prefix matches zero or more segments", () => {
|
||||
assert.equal(matches("test.log", "**/test.log"), true);
|
||||
assert.equal(matches("dir/test.log", "**/test.log"), true);
|
||||
assert.equal(matches("a/b/test.log", "**/test.log"), true);
|
||||
});
|
||||
|
||||
test("toUnixPath - forward slashes unchanged", () => {
|
||||
assert.equal(toUnixPath("foo/bar/baz"), "foo/bar/baz");
|
||||
});
|
||||
74
frontend/local-client-cli/src/path-utils.ts
Normal file
74
frontend/local-client-cli/src/path-utils.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Convert a native platform path to forward slashes.
|
||||
* On non-Windows platforms this is a no-op.
|
||||
*/
|
||||
export function toUnixPath(nativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return nativePath.replace(/\\/g, "/");
|
||||
}
|
||||
return nativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a forward-slash path to native platform path separators.
|
||||
* On non-Windows platforms this is a no-op.
|
||||
*/
|
||||
export function toNativePath(forwardSlashPath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return forwardSlashPath.replace(/\//g, "\\");
|
||||
}
|
||||
return forwardSlashPath;
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a glob pattern into a RegExp for repeated matching.
|
||||
* Supports:
|
||||
* - `*` matches any characters within a single path segment
|
||||
* - `**` matches zero or more path segments
|
||||
* - `?` matches a single character (not `/`)
|
||||
* - `dir/**` matches the directory itself and all its contents
|
||||
* - combined with `*.ext` matches files with the extension at any depth
|
||||
*/
|
||||
export function compileGlobPattern(pattern: string): RegExp {
|
||||
// Trailing /** matches the directory itself and all its contents
|
||||
if (pattern.endsWith("/**")) {
|
||||
const prefix = escapeRegex(pattern.slice(0, -3));
|
||||
return new RegExp(`^${prefix}(/.*)?$`);
|
||||
}
|
||||
|
||||
let result = "^";
|
||||
let i = 0;
|
||||
while (i < pattern.length) {
|
||||
const c = pattern[i];
|
||||
if (c === "*" && pattern[i + 1] === "*") {
|
||||
if (pattern[i + 2] === "/") {
|
||||
// **/ matches zero or more directory segments
|
||||
result += "(?:.+/)?";
|
||||
i += 3;
|
||||
} else {
|
||||
result += ".*";
|
||||
i += 2;
|
||||
}
|
||||
} else if (c === "*") {
|
||||
result += "[^/]*";
|
||||
i++;
|
||||
} else if (c === "?") {
|
||||
result += "[^/]";
|
||||
i++;
|
||||
} else if (".+^${}()|[]\\".includes(c)) {
|
||||
result += "\\" + c;
|
||||
i++;
|
||||
} else {
|
||||
result += c;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
result += "$";
|
||||
return new RegExp(result);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue