@tinyrack/devsync 1.0.0

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.
@@ -0,0 +1,383 @@
1
+ import { join } from "node:path";
2
+
3
+ import {
4
+ findOwningSyncEntry,
5
+ type ResolvedSyncConfigEntry,
6
+ type ResolvedSyncOverride,
7
+ readSyncConfig,
8
+ resolveEntryRelativeRepoPath,
9
+ resolveRelativeSyncMode,
10
+ type SyncMode,
11
+ } from "#app/config/sync.ts";
12
+
13
+ import {
14
+ createSyncConfigDocument,
15
+ createSyncConfigDocumentEntry,
16
+ sortSyncConfigEntries,
17
+ writeValidatedSyncConfig,
18
+ } from "./config-file.ts";
19
+ import { DevsyncError } from "./error.ts";
20
+ import { getPathStats } from "./filesystem.ts";
21
+ import {
22
+ buildRepoPathWithinRoot,
23
+ isExplicitLocalPath,
24
+ resolveCommandTargetPath,
25
+ tryBuildRepoPathWithinRoot,
26
+ tryNormalizeRepoPathInput,
27
+ } from "./paths.ts";
28
+ import { ensureSyncRepository, type SyncContext } from "./runtime.ts";
29
+
30
+ export type SyncSetRequest = Readonly<{
31
+ recursive: boolean;
32
+ state: SyncMode;
33
+ target: string;
34
+ }>;
35
+
36
+ type SyncSetScope = "default" | "exact" | "subtree";
37
+ type SyncSetAction = "added" | "removed" | "unchanged" | "updated";
38
+
39
+ export type SyncSetResult = Readonly<{
40
+ action: SyncSetAction;
41
+ configPath: string;
42
+ entryRepoPath: string;
43
+ localPath: string;
44
+ mode: SyncMode;
45
+ repoPath: string;
46
+ scope: SyncSetScope;
47
+ syncDirectory: string;
48
+ }>;
49
+
50
+ const resolveTargetPath = async (
51
+ target: string,
52
+ entry: ResolvedSyncConfigEntry,
53
+ context: Pick<SyncContext, "cwd" | "environment" | "paths">,
54
+ ) => {
55
+ if (isExplicitLocalPath(target)) {
56
+ const localPath = resolveCommandTargetPath(
57
+ target,
58
+ context.environment,
59
+ context.cwd,
60
+ );
61
+ const stats = await getPathStats(localPath);
62
+
63
+ if (stats === undefined) {
64
+ throw new DevsyncError(`Sync set target does not exist: ${localPath}`);
65
+ }
66
+
67
+ return {
68
+ localPath,
69
+ repoPath: buildRepoPathWithinRoot(
70
+ localPath,
71
+ context.paths.homeDirectory,
72
+ "Sync set target",
73
+ ),
74
+ stats,
75
+ };
76
+ }
77
+
78
+ const repoPath = tryNormalizeRepoPathInput(target);
79
+
80
+ if (repoPath === undefined) {
81
+ throw new DevsyncError(
82
+ `Sync set target must be a local path or repository path: ${target}`,
83
+ );
84
+ }
85
+
86
+ const relativePath = resolveEntryRelativeRepoPath(entry, repoPath);
87
+ const localPath =
88
+ relativePath === undefined || relativePath === ""
89
+ ? entry.localPath
90
+ : join(entry.localPath, ...relativePath.split("/"));
91
+
92
+ return {
93
+ localPath,
94
+ repoPath,
95
+ stats: await getPathStats(localPath),
96
+ };
97
+ };
98
+
99
+ const resolveSetTarget = async (
100
+ target: string,
101
+ config: Awaited<ReturnType<typeof readSyncConfig>>,
102
+ context: Pick<SyncContext, "cwd" | "environment" | "paths">,
103
+ ) => {
104
+ const trimmedTarget = target.trim();
105
+
106
+ if (trimmedTarget.length === 0) {
107
+ throw new DevsyncError("Target path is required.");
108
+ }
109
+
110
+ const homeDirectory = context.paths.homeDirectory;
111
+ const explicitLocalPath = isExplicitLocalPath(trimmedTarget);
112
+ const localTargetPath = resolveCommandTargetPath(
113
+ trimmedTarget,
114
+ context.environment,
115
+ context.cwd,
116
+ );
117
+ const localRepoPath = explicitLocalPath
118
+ ? buildRepoPathWithinRoot(localTargetPath, homeDirectory, "Sync set target")
119
+ : tryBuildRepoPathWithinRoot(
120
+ localTargetPath,
121
+ homeDirectory,
122
+ "Sync set target",
123
+ );
124
+
125
+ if (localRepoPath !== undefined) {
126
+ const localStats = await getPathStats(localTargetPath);
127
+
128
+ if (explicitLocalPath && localStats === undefined) {
129
+ throw new DevsyncError(
130
+ `Sync set target does not exist: ${localTargetPath}`,
131
+ );
132
+ }
133
+
134
+ const entry = findOwningSyncEntry(config, localRepoPath);
135
+
136
+ if (entry?.kind === "directory") {
137
+ const relativePath = resolveEntryRelativeRepoPath(entry, localRepoPath);
138
+
139
+ if (relativePath !== undefined) {
140
+ return {
141
+ entry,
142
+ localPath: localTargetPath,
143
+ relativePath,
144
+ repoPath: localRepoPath,
145
+ stats: localStats,
146
+ };
147
+ }
148
+ }
149
+
150
+ if (explicitLocalPath) {
151
+ throw new DevsyncError(
152
+ `Sync set target must be inside a tracked directory entry: ${trimmedTarget}`,
153
+ );
154
+ }
155
+ }
156
+
157
+ const repoPath = tryNormalizeRepoPathInput(trimmedTarget);
158
+
159
+ if (repoPath === undefined) {
160
+ throw new DevsyncError(
161
+ `Sync set target must be a local path or repository path: ${trimmedTarget}`,
162
+ );
163
+ }
164
+
165
+ const entry = findOwningSyncEntry(config, repoPath);
166
+
167
+ if (entry === undefined || entry.kind !== "directory") {
168
+ throw new DevsyncError(
169
+ `Sync set target must be inside a tracked directory entry: ${trimmedTarget}`,
170
+ );
171
+ }
172
+
173
+ const resolvedTarget = await resolveTargetPath(trimmedTarget, entry, context);
174
+ const relativePath = resolveEntryRelativeRepoPath(
175
+ entry,
176
+ resolvedTarget.repoPath,
177
+ );
178
+
179
+ if (relativePath === undefined) {
180
+ throw new DevsyncError(
181
+ `Sync set target must be inside a tracked directory entry: ${trimmedTarget}`,
182
+ );
183
+ }
184
+
185
+ return {
186
+ entry,
187
+ localPath: resolvedTarget.localPath,
188
+ relativePath,
189
+ repoPath: resolvedTarget.repoPath,
190
+ stats: resolvedTarget.stats,
191
+ };
192
+ };
193
+
194
+ const updateEntryMode = (
195
+ entry: ResolvedSyncConfigEntry,
196
+ mode: SyncMode,
197
+ ): {
198
+ action: SyncSetAction;
199
+ entry: ResolvedSyncConfigEntry;
200
+ } => {
201
+ if (entry.mode === mode) {
202
+ return {
203
+ action: "unchanged",
204
+ entry,
205
+ };
206
+ }
207
+
208
+ return {
209
+ action: "updated",
210
+ entry: {
211
+ ...entry,
212
+ mode,
213
+ },
214
+ };
215
+ };
216
+
217
+ const updateChildOverride = (
218
+ entry: ResolvedSyncConfigEntry,
219
+ input: Readonly<{
220
+ match: Extract<SyncSetScope, "exact" | "subtree">;
221
+ mode: SyncMode;
222
+ relativePath: string;
223
+ }>,
224
+ ): {
225
+ action: SyncSetAction;
226
+ entry: ResolvedSyncConfigEntry;
227
+ } => {
228
+ const existingOverride = entry.overrides.find((override) => {
229
+ return (
230
+ override.match === input.match && override.path === input.relativePath
231
+ );
232
+ });
233
+ const remainingOverrides = entry.overrides.filter((override) => {
234
+ return !(
235
+ override.match === input.match && override.path === input.relativePath
236
+ );
237
+ });
238
+ const inheritedMode = resolveRelativeSyncMode(
239
+ entry.mode,
240
+ remainingOverrides,
241
+ input.relativePath,
242
+ );
243
+
244
+ if (input.mode === inheritedMode) {
245
+ if (existingOverride === undefined) {
246
+ return {
247
+ action: "unchanged",
248
+ entry,
249
+ };
250
+ }
251
+
252
+ return {
253
+ action: "removed",
254
+ entry: {
255
+ ...entry,
256
+ overrides: remainingOverrides,
257
+ },
258
+ };
259
+ }
260
+
261
+ if (existingOverride?.mode === input.mode) {
262
+ return {
263
+ action: "unchanged",
264
+ entry,
265
+ };
266
+ }
267
+
268
+ const nextOverride = {
269
+ match: input.match,
270
+ mode: input.mode,
271
+ path: input.relativePath,
272
+ } satisfies ResolvedSyncOverride;
273
+
274
+ return {
275
+ action: existingOverride === undefined ? "added" : "updated",
276
+ entry: {
277
+ ...entry,
278
+ overrides: [...remainingOverrides, nextOverride],
279
+ },
280
+ };
281
+ };
282
+
283
+ export const setSyncTargetMode = async (
284
+ request: SyncSetRequest,
285
+ context: SyncContext,
286
+ ): Promise<SyncSetResult> => {
287
+ await ensureSyncRepository(context);
288
+
289
+ const config = await readSyncConfig(
290
+ context.paths.syncDirectory,
291
+ context.environment,
292
+ );
293
+ const target = await resolveSetTarget(request.target, config, context);
294
+
295
+ if (target.relativePath === "") {
296
+ if (!request.recursive) {
297
+ throw new DevsyncError(
298
+ "Tracked directory roots require --recursive to update the entry mode.",
299
+ );
300
+ }
301
+
302
+ const update = updateEntryMode(target.entry, request.state);
303
+ const nextConfig = createSyncConfigDocument(config);
304
+
305
+ nextConfig.entries = sortSyncConfigEntries(
306
+ nextConfig.entries.map((entry) => {
307
+ if (entry.repoPath !== target.entry.repoPath) {
308
+ return entry;
309
+ }
310
+
311
+ return createSyncConfigDocumentEntry(update.entry);
312
+ }),
313
+ );
314
+
315
+ if (update.action !== "unchanged") {
316
+ await writeValidatedSyncConfig(context.paths.syncDirectory, nextConfig, {
317
+ environment: context.environment,
318
+ });
319
+ }
320
+
321
+ return {
322
+ action: update.action,
323
+ configPath: context.paths.configPath,
324
+ entryRepoPath: target.entry.repoPath,
325
+ localPath: target.localPath,
326
+ mode: request.state,
327
+ repoPath: target.repoPath,
328
+ scope: "default",
329
+ syncDirectory: context.paths.syncDirectory,
330
+ };
331
+ }
332
+
333
+ if (target.stats?.isDirectory() && !request.recursive) {
334
+ throw new DevsyncError(
335
+ "Directory targets require --recursive. Use a file path for exact overrides.",
336
+ );
337
+ }
338
+
339
+ if (
340
+ request.recursive &&
341
+ target.stats !== undefined &&
342
+ !target.stats.isDirectory()
343
+ ) {
344
+ throw new DevsyncError(
345
+ "--recursive can only be used with directories or tracked directory roots.",
346
+ );
347
+ }
348
+
349
+ const scope = request.recursive ? "subtree" : "exact";
350
+ const update = updateChildOverride(target.entry, {
351
+ match: scope,
352
+ mode: request.state,
353
+ relativePath: target.relativePath,
354
+ });
355
+ const nextConfig = createSyncConfigDocument(config);
356
+
357
+ nextConfig.entries = sortSyncConfigEntries(
358
+ nextConfig.entries.map((entry) => {
359
+ if (entry.repoPath !== target.entry.repoPath) {
360
+ return entry;
361
+ }
362
+
363
+ return createSyncConfigDocumentEntry(update.entry);
364
+ }),
365
+ );
366
+
367
+ if (update.action !== "unchanged") {
368
+ await writeValidatedSyncConfig(context.paths.syncDirectory, nextConfig, {
369
+ environment: context.environment,
370
+ });
371
+ }
372
+
373
+ return {
374
+ action: update.action,
375
+ configPath: context.paths.configPath,
376
+ entryRepoPath: target.entry.repoPath,
377
+ localPath: target.localPath,
378
+ mode: request.state,
379
+ repoPath: target.repoPath,
380
+ scope,
381
+ syncDirectory: context.paths.syncDirectory,
382
+ };
383
+ };