@narumitw/pi-sync 0.13.0 → 0.13.1

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/README.md CHANGED
@@ -187,14 +187,15 @@ Before updating `latest.json`, pi-sync re-reads the current pointer and rejects
187
187
  ```txt
188
188
  extensions/pi-sync/
189
189
  ├── src/
190
- │ └── sync.ts
190
+ │ ├── sync.ts # Pi entrypoint and command orchestration
191
+ │ └── *.ts # Package-local config, snapshot, path, and S3 modules
191
192
  ├── README.md
192
193
  ├── LICENSE
193
194
  ├── tsconfig.json
194
195
  └── package.json
195
196
  ```
196
197
 
197
- The package exposes its Pi extension through `package.json`:
198
+ Only `sync.ts` is a Pi entrypoint; the other source modules are internal. The package exposes its Pi extension through `package.json`:
198
199
 
199
200
  ```json
200
201
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-sync",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Pi extension that syncs Pi configuration through Cloudflare R2 or S3-compatible storage.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/command.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type { CommandArgumentCompletion, CommandOptions } from "./types.js";
2
+
3
+ const YES_FLAG_COMPLETIONS: readonly CommandArgumentCompletion[] = [
4
+ { value: "--yes", label: "--yes", description: "Skip confirmation prompts" },
5
+ { value: "-y", label: "-y", description: "Skip confirmation prompts" },
6
+ ];
7
+ const SYNC_COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
8
+ { value: "help", label: "help", description: "Show command usage" },
9
+ { value: "init", label: "init", description: "Create local config template" },
10
+ { value: "config", label: "config", description: "Show resolved configuration" },
11
+ { value: "status", label: "status", description: "Show sync status" },
12
+ { value: "diff", label: "diff", description: "Show local/remote diff" },
13
+ { value: "doctor", label: "doctor", description: "Check config, secrets, and lock state" },
14
+ { value: "push", label: "push", description: "Upload local settings" },
15
+ { value: "pull", label: "pull", description: "Apply remote settings" },
16
+ { value: "sync", label: "sync", description: "Push or pull as needed" },
17
+ { value: "history", label: "history", description: "Show recent remote snapshots" },
18
+ { value: "rollback", label: "rollback", description: "Apply a previous snapshot" },
19
+ { value: "unlock", label: "unlock", description: "Remove a stale local lock" },
20
+ ];
21
+ const SYNC_FLAG_COMPLETIONS: Record<string, readonly CommandArgumentCompletion[]> = {
22
+ push: [
23
+ ...YES_FLAG_COMPLETIONS,
24
+ { value: "--force", label: "--force", description: "Overwrite visible remote changes" },
25
+ ],
26
+ pull: [
27
+ ...YES_FLAG_COMPLETIONS,
28
+ { value: "--force", label: "--force", description: "Overwrite local changes" },
29
+ ],
30
+ sync: [
31
+ ...YES_FLAG_COMPLETIONS,
32
+ { value: "--force", label: "--force", description: "Resolve conflicts by forcing action" },
33
+ ],
34
+ rollback: YES_FLAG_COMPLETIONS,
35
+ unlock: [{ value: "--stale", label: "--stale", description: "Remove only a stale lock" }],
36
+ };
37
+
38
+ export function splitArgs(input: string) {
39
+ return input.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((arg) => arg.replace(/^['"]|['"]$/g, "")) ?? [];
40
+ }
41
+
42
+ export function parseOptions(args: string[]): CommandOptions {
43
+ return {
44
+ yes: args.includes("--yes") || args.includes("-y"),
45
+ force: args.includes("--force"),
46
+ stale: args.includes("--stale"),
47
+ silent: false,
48
+ reload: true,
49
+ auto: false,
50
+ args: args.filter((arg) => !arg.startsWith("-")),
51
+ };
52
+ }
53
+
54
+ export function completeSyncArguments(argumentPrefix: string): CommandArgumentCompletion[] | null {
55
+ const prefix = argumentPrefix.trimStart();
56
+ if (prefix === "") return [...SYNC_COMMAND_COMPLETIONS];
57
+
58
+ const trailingSpace = /\s$/.test(prefix);
59
+ const tokens = splitArgs(prefix);
60
+ if (tokens.length === 0) return [...SYNC_COMMAND_COMPLETIONS];
61
+
62
+ const [command] = tokens;
63
+ if (tokens.length === 1 && !trailingSpace) {
64
+ const matches = SYNC_COMMAND_COMPLETIONS.filter((item) => item.value.startsWith(command));
65
+ return matches.length > 0 ? [...matches] : null;
66
+ }
67
+
68
+ const flagCompletions = SYNC_FLAG_COMPLETIONS[command];
69
+ if (!flagCompletions) return null;
70
+
71
+ const args = tokens.slice(1);
72
+ const completedArgs = trailingSpace ? args : args.slice(0, -1);
73
+ const completedValues = completedArgs.filter((arg) => !arg.startsWith("-"));
74
+ if (command === "rollback" ? completedValues.length > 1 : completedValues.length > 0) {
75
+ return null;
76
+ }
77
+
78
+ const current = trailingSpace ? "" : (args.at(-1) ?? "");
79
+ if (current && !current.startsWith("-")) return null;
80
+
81
+ const currentRaw = trailingSpace ? "" : (prefix.match(/\S+$/)?.[0] ?? "");
82
+ const completionPrefix = trailingSpace
83
+ ? prefix
84
+ : prefix.slice(0, prefix.length - currentRaw.length);
85
+ const matches = flagCompletions.filter((item) => item.value.startsWith(current));
86
+ return matches.length > 0
87
+ ? matches.map((item) => ({ ...item, value: `${completionPrefix}${item.value}` }))
88
+ : null;
89
+ }
90
+
91
+ export function usage() {
92
+ return [
93
+ "Usage: /pisync <command>",
94
+ "Commands: init, config, status, diff, doctor, push, pull, sync, history, rollback <snapshot>, unlock --stale",
95
+ "Config: set PI_SYNC_ENDPOINT, PI_SYNC_BUCKET, PI_SYNC_ACCESS_KEY_ID, PI_SYNC_SECRET_ACCESS_KEY, optional PI_SYNC_SESSION_TOKEN, PI_SYNC_SESSIONS/syncSessions, region/profile/prefix, or edit ~/.pi/agent/pi-sync.local.json (or $PI_CODING_AGENT_DIR/pi-sync.local.json when PI_CODING_AGENT_DIR is set).",
96
+ ].join("\n");
97
+ }
package/src/config.ts ADDED
@@ -0,0 +1,338 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Dirent } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { isDeniedPath, safeName } from "./paths.js";
8
+ import type { LockFile, PartialConfig, Snapshot, SyncConfig, SyncState } from "./types.js";
9
+
10
+ const VERSION = 1;
11
+ const DEFAULT_PROFILE = "default";
12
+ const DEFAULT_PREFIX = "pi-sync";
13
+ const DEFAULT_REGION = "auto";
14
+ const LOCK_STALE_MS = 30 * 60 * 1000;
15
+ const TOP_LEVEL_FILES = new Set(["settings.json", "keybindings.json", "models.json", "AGENTS.md", "APPEND_SYSTEM.md"]);
16
+ const TOP_LEVEL_FILE_NAMES = new Set([...TOP_LEVEL_FILES].map((name) => name.toLowerCase()));
17
+ const TOP_LEVEL_DIRS = new Set(["skills", "prompts", "themes", "extensions"]);
18
+ const RESERVED_TOP_LEVEL_NAMES = new Set([...TOP_LEVEL_DIRS, "sessions"]);
19
+
20
+ function trimSlashes(value: string) {
21
+ return value.replace(/^\/+|\/+$/g, "");
22
+ }
23
+
24
+ function decodeBase64Strict(value: string, filePath: string) {
25
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) {
26
+ throw new Error(`Invalid base64 content in snapshot file: ${filePath}`);
27
+ }
28
+ return Buffer.from(value, "base64");
29
+ }
30
+
31
+ function sessionDirFromContext(ctx: ExtensionCommandContext | ExtensionContext) {
32
+ const manager = ctx.sessionManager as typeof ctx.sessionManager & {
33
+ usesDefaultSessionDir?: () => boolean;
34
+ };
35
+ const usesDefaultSessionDir = manager.usesDefaultSessionDir;
36
+ if (typeof usesDefaultSessionDir === "function" && usesDefaultSessionDir.call(manager)) {
37
+ return undefined;
38
+ }
39
+ const getSessionDir = manager.getSessionDir;
40
+ return typeof getSessionDir === "function"
41
+ ? (getSessionDir.call(manager) as string | undefined)
42
+ : undefined;
43
+ }
44
+
45
+ export async function withLock<T>(command: string, fn: () => Promise<T>): Promise<T> {
46
+ await ensureStateDir();
47
+ const lock: LockFile = {
48
+ id: randomUUID(),
49
+ pid: process.pid,
50
+ command,
51
+ startedAt: new Date().toISOString(),
52
+ };
53
+ let handle: fs.FileHandle | undefined;
54
+ try {
55
+ handle = await fs.open(lockPath(), "wx");
56
+ await handle.writeFile(JSON.stringify(lock, null, "\t"));
57
+ await handle.close();
58
+ handle = undefined;
59
+ return await fn();
60
+ } catch (error) {
61
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
62
+ const current = await readLock();
63
+ if (current && isStaleLock(current)) {
64
+ throw new Error(`pi-sync lock is stale (pid ${current.pid}). Run /pisync unlock --stale, then retry.`);
65
+ }
66
+ throw new Error(`pi-sync is already running${current ? ` (${current.command}, pid ${current.pid}, started ${current.startedAt})` : ""}.`);
67
+ }
68
+ throw error;
69
+ } finally {
70
+ await handle?.close();
71
+ const current = await readLock();
72
+ if (current?.id === lock.id) await fs.rm(lockPath(), { force: true });
73
+ }
74
+ }
75
+
76
+ export async function loadConfigInternal(): Promise<SyncConfig> {
77
+ const partial = await loadPartialConfig();
78
+ const endpoint = partial.endpoint;
79
+ const bucket = partial.bucket;
80
+ const accessKeyId = partial.accessKeyId;
81
+ const secretAccessKey = partial.secretAccessKey;
82
+ const missing = [
83
+ ["endpoint", endpoint],
84
+ ["bucket", bucket],
85
+ ["accessKeyId", accessKeyId],
86
+ ["secretAccessKey", secretAccessKey],
87
+ ]
88
+ .filter(([, value]) => !value)
89
+ .map(([name]) => name);
90
+ if (missing.length > 0) {
91
+ throw new Error(`Missing pi-sync config: ${missing.join(", ")}. Run /pisync init or set PI_SYNC_* environment variables.`);
92
+ }
93
+
94
+ return {
95
+ endpoint: endpoint!,
96
+ bucket: bucket!,
97
+ region: partial.region ?? DEFAULT_REGION,
98
+ accessKeyId: accessKeyId!,
99
+ secretAccessKey: secretAccessKey!,
100
+ sessionToken: partial.sessionToken,
101
+ profile: partial.profile ?? DEFAULT_PROFILE,
102
+ prefix: trimSlashes(partial.prefix ?? DEFAULT_PREFIX),
103
+ syncSessions: isExplicitlyEnabled(partial.syncSessions),
104
+ extraFiles: normalizeExtraFiles(partial.extraFiles),
105
+ };
106
+ }
107
+
108
+ export async function loadConfig(): Promise<SyncConfig> {
109
+ return loadConfigInternal();
110
+ }
111
+
112
+ export async function loadPartialConfig(): Promise<PartialConfig> {
113
+ const fileConfig = (await readJsonIfExists<PartialConfig>(localConfigPath())) ?? {};
114
+ return {
115
+ ...fileConfig,
116
+ endpoint: process.env.PI_SYNC_ENDPOINT ?? process.env.R2_ENDPOINT ?? fileConfig.endpoint,
117
+ bucket: process.env.PI_SYNC_BUCKET ?? process.env.R2_BUCKET ?? fileConfig.bucket,
118
+ region: process.env.PI_SYNC_REGION ?? process.env.AWS_REGION ?? fileConfig.region,
119
+ accessKeyId: process.env.PI_SYNC_ACCESS_KEY_ID ?? process.env.AWS_ACCESS_KEY_ID ?? fileConfig.accessKeyId,
120
+ secretAccessKey: process.env.PI_SYNC_SECRET_ACCESS_KEY ?? process.env.AWS_SECRET_ACCESS_KEY ?? fileConfig.secretAccessKey,
121
+ sessionToken: selectSessionToken(fileConfig.sessionToken),
122
+ profile: process.env.PI_SYNC_PROFILE ?? fileConfig.profile,
123
+ prefix: process.env.PI_SYNC_PREFIX ?? fileConfig.prefix,
124
+ autoSync: process.env.PI_SYNC_AUTO_SYNC ?? fileConfig.autoSync,
125
+ syncSessions: process.env.PI_SYNC_SESSIONS ?? fileConfig.syncSessions,
126
+ extraFiles: fileConfig.extraFiles,
127
+ };
128
+ }
129
+
130
+ export async function configuredSessionDir() {
131
+ const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
132
+ if (envSessionDir) return expandHome(envSessionDir);
133
+ const settings = await readJsonIfExists<{ sessionDir?: string }>(path.join(agentDir(), "settings.json"));
134
+ return settings?.sessionDir ? expandHome(settings.sessionDir) : undefined;
135
+ }
136
+
137
+ export async function sessionDirForApply(ctx: ExtensionCommandContext | ExtensionContext, snapshot: Snapshot) {
138
+ const contextSessionDir = sessionDirFromContext(ctx);
139
+ const envSessionDir = normalizeOptionalString(process.env.PI_CODING_AGENT_SESSION_DIR);
140
+ if (envSessionDir) return contextSessionDir ?? expandHome(envSessionDir);
141
+
142
+ const localSessionDir = await configuredSessionDir();
143
+ if (contextSessionDir && path.resolve(contextSessionDir) !== path.resolve(localSessionDir ?? "")) {
144
+ return contextSessionDir;
145
+ }
146
+ return sessionDirFromSnapshot(snapshot) ?? contextSessionDir;
147
+ }
148
+
149
+ function sessionDirFromSnapshot(snapshot: Snapshot) {
150
+ const settingsFile = snapshot.files.find((file) => file.path === "settings.json");
151
+ if (!settingsFile) return undefined;
152
+ try {
153
+ const settings = JSON.parse(
154
+ decodeBase64Strict(settingsFile.contentBase64, settingsFile.path).toString("utf8"),
155
+ ) as { sessionDir?: string };
156
+ return settings.sessionDir ? expandHome(settings.sessionDir) : undefined;
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ }
161
+
162
+ export async function readState(profile: string): Promise<SyncState> {
163
+ return (
164
+ (await readJsonIfExists<SyncState>(statePath(profile))) ?? {
165
+ version: VERSION,
166
+ profile,
167
+ lastFileHashes: {},
168
+ }
169
+ );
170
+ }
171
+
172
+ export async function writeState(profile: string, state: SyncState) {
173
+ await writeJson(statePath(profile), state);
174
+ }
175
+
176
+ export function agentDir() {
177
+ const configured = normalizeOptionalString(process.env.PI_CODING_AGENT_DIR);
178
+ return configured ? expandHome(configured) : path.join(os.homedir(), ".pi", "agent");
179
+ }
180
+
181
+ function expandHome(value: string) {
182
+ return value === "~" || value.startsWith("~/")
183
+ ? path.join(os.homedir(), value.slice(2))
184
+ : value;
185
+ }
186
+
187
+ export function stateDir() {
188
+ return path.join(agentDir(), ".pisync");
189
+ }
190
+
191
+ export function localConfigPath() {
192
+ return path.join(agentDir(), "pi-sync.local.json");
193
+ }
194
+
195
+ function statePath(profile: string) {
196
+ return path.join(stateDir(), `${safeName(profile)}.state.json`);
197
+ }
198
+
199
+ export function lockPath() {
200
+ return path.join(stateDir(), "lock");
201
+ }
202
+
203
+ export async function ensureStateDir() {
204
+ await fs.mkdir(stateDir(), { recursive: true });
205
+ }
206
+
207
+ export async function readLock() {
208
+ return readJsonIfExists<LockFile>(lockPath());
209
+ }
210
+
211
+ export function isStaleLock(lock: LockFile) {
212
+ if (!Number.isInteger(lock.pid) || lock.pid <= 0) return true;
213
+ try {
214
+ process.kill(lock.pid, 0);
215
+ return false;
216
+ } catch (error) {
217
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return true;
218
+ return Date.now() - Date.parse(lock.startedAt) > LOCK_STALE_MS;
219
+ }
220
+ }
221
+
222
+ async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
223
+ try {
224
+ return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
225
+ } catch (error) {
226
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
227
+ throw error;
228
+ }
229
+ }
230
+
231
+ export async function writeJson(filePath: string, value: unknown) {
232
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
233
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, "\t")}\n`);
234
+ }
235
+
236
+ function selectSessionToken(fileSessionToken: string | undefined) {
237
+ if (hasEnv("PI_SYNC_SESSION_TOKEN")) return normalizeOptionalString(process.env.PI_SYNC_SESSION_TOKEN);
238
+ return normalizeOptionalString(process.env.AWS_SESSION_TOKEN) ?? normalizeOptionalString(fileSessionToken);
239
+ }
240
+
241
+ export function sessionTokenWarnings(config: { endpoint?: string; sessionToken?: string }) {
242
+ if (!isCloudflareR2Endpoint(config.endpoint) || !config.sessionToken) return [];
243
+ return [
244
+ "session token: configured for Cloudflare R2; if R2 rejects X-Amz-Security-Token, pi-sync retries once without it. R2 static access keys usually do not need a session token.",
245
+ ];
246
+ }
247
+
248
+ export function syncSessionsWarnings(config: { syncSessions?: boolean }) {
249
+ if (!config.syncSessions) return [];
250
+ return [
251
+ "sessions: enabled; Pi session JSONL can contain prompts, tool output, file paths, images, and secrets. Sync sessions only to storage you trust.",
252
+ ];
253
+ }
254
+
255
+ function isSecurityTokenInvalidArgument(text: string) {
256
+ return (
257
+ text.includes("<Code>InvalidArgument</Code>") &&
258
+ text.includes("<Message>X-Amz-Security-Token</Message>")
259
+ );
260
+ }
261
+
262
+ export function isCloudflareR2Endpoint(endpoint: string | undefined) {
263
+ const value = endpoint?.trim();
264
+ if (!value) return false;
265
+ try {
266
+ const hostname = new URL(value).hostname.toLowerCase();
267
+ return hostname === "r2.cloudflarestorage.com" || hostname.endsWith(".r2.cloudflarestorage.com");
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ function normalizeOptionalString(value: string | undefined) {
274
+ const normalized = value?.trim();
275
+ return normalized ? normalized : undefined;
276
+ }
277
+
278
+ export function normalizeExtraFiles(value: unknown) {
279
+ if (!Array.isArray(value)) return [];
280
+ const seen = new Set<string>();
281
+ return value
282
+ .filter((item): item is string => typeof item === "string")
283
+ .map((item) => item.trim())
284
+ .filter((item) => {
285
+ const lower = item.toLowerCase();
286
+ if (
287
+ item === "" ||
288
+ item === "." ||
289
+ item === ".." ||
290
+ item.includes("/") ||
291
+ item.includes("\\") ||
292
+ TOP_LEVEL_FILE_NAMES.has(lower) ||
293
+ isDeniedPath(item) ||
294
+ RESERVED_TOP_LEVEL_NAMES.has(lower) ||
295
+ seen.has(lower)
296
+ ) {
297
+ return false;
298
+ }
299
+ seen.add(lower);
300
+ return true;
301
+ });
302
+ }
303
+
304
+ export function extraFilePathsByLower(value: unknown) {
305
+ return new Map(normalizeExtraFiles(value).map((fileName) => [fileName.toLowerCase(), fileName]));
306
+ }
307
+
308
+ function selectTopLevelFileEntry(entries: Dirent[], fileName: string) {
309
+ const exact = entries.find((entry) => entry.isFile() && entry.name === fileName);
310
+ if (exact) return exact;
311
+ const lower = fileName.toLowerCase();
312
+ return entries
313
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase() === lower)
314
+ .sort((left, right) => left.name.localeCompare(right.name))[0];
315
+ }
316
+
317
+ function hasEnv(name: string) {
318
+ return Object.prototype.hasOwnProperty.call(process.env, name);
319
+ }
320
+
321
+ export function isEnabled(value: boolean | string | undefined, defaultValue: boolean) {
322
+ if (value === undefined) return defaultValue;
323
+ if (typeof value === "boolean") return value;
324
+ return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
325
+ }
326
+
327
+ export function isExplicitlyEnabled(value: boolean | string | undefined) {
328
+ if (typeof value === "boolean") return value;
329
+ return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
330
+ }
331
+
332
+ export function isMissingConfigError(error: unknown) {
333
+ return error instanceof Error && error.message.startsWith("Missing pi-sync config:");
334
+ }
335
+
336
+ function errorMessage(error: unknown) {
337
+ return error instanceof Error ? error.message : String(error);
338
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+
3
+ export function isDeniedPath(relativePath: string) {
4
+ const normalized = toPosix(relativePath);
5
+ const lower = normalized.toLowerCase();
6
+ const segments = lower.split("/");
7
+ const base = path.posix.basename(lower);
8
+ return (
9
+ segments.includes("node_modules") ||
10
+ segments.includes(".git") ||
11
+ segments.includes(".pisync") ||
12
+ base === ".env" ||
13
+ base.startsWith(".env.") ||
14
+ base.endsWith(".env") ||
15
+ base.includes("secret") ||
16
+ base.includes("token") ||
17
+ base === "pi-sync.local.json"
18
+ );
19
+ }
20
+
21
+ export function isPathInside(parent: string, child: string) {
22
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
23
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
24
+ }
25
+
26
+ export function safeJoin(root: string, relativePath: string) {
27
+ const target = path.resolve(root, relativePath);
28
+ assertWithinRoot(root, target, relativePath);
29
+ return target;
30
+ }
31
+
32
+ export function assertWithinRoot(root: string, target: string, label = target) {
33
+ const resolvedRoot = path.resolve(root);
34
+ const resolvedTarget = path.resolve(target);
35
+ if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) {
36
+ throw new Error(`Unsafe path in snapshot: ${label}`);
37
+ }
38
+ }
39
+
40
+ export function encodeKey(key: string) {
41
+ return key.split("/").map(encodeURIComponent).join("/");
42
+ }
43
+
44
+ export function posixJoin(...parts: string[]) {
45
+ return parts.map((part) => trimSlashes(part)).filter(Boolean).join("/");
46
+ }
47
+
48
+ export function parentPaths(relativePath: string) {
49
+ const results: string[] = [];
50
+ let index = relativePath.lastIndexOf("/");
51
+ while (index > 0) {
52
+ results.push(relativePath.slice(0, index));
53
+ index = relativePath.lastIndexOf("/", index - 1);
54
+ }
55
+ return results;
56
+ }
57
+
58
+ export function toPosix(value: string) {
59
+ return value.split(path.sep).join("/");
60
+ }
61
+
62
+ function trimSlashes(value: string) {
63
+ return value.replace(/^\/+|\/+$/g, "");
64
+ }
65
+
66
+ export function safeName(value: string) {
67
+ return value.replace(/[^A-Za-z0-9._-]/g, "_");
68
+ }