@cardstack/boxel-cli 0.0.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.
@@ -0,0 +1,169 @@
1
+ import type { SyncManifest } from './sync-manifest';
2
+
3
+ export type SideStatus = 'unchanged' | 'changed' | 'added' | 'deleted';
4
+ export type SyncAction =
5
+ | 'push'
6
+ | 'pull'
7
+ | 'push-delete'
8
+ | 'pull-delete'
9
+ | 'conflict'
10
+ | 'noop';
11
+
12
+ export interface FileClassification {
13
+ relativePath: string;
14
+ localStatus: SideStatus;
15
+ remoteStatus: SideStatus;
16
+ action: SyncAction;
17
+ }
18
+
19
+ export type ConflictStrategy =
20
+ | 'prefer-local'
21
+ | 'prefer-remote'
22
+ | 'prefer-newest';
23
+
24
+ export interface SyncOptions {
25
+ deleteSync?: boolean;
26
+ preferLocal?: boolean;
27
+ preferRemote?: boolean;
28
+ }
29
+
30
+ export function classifyLocal(
31
+ relativePath: string,
32
+ localHashes: Map<string, string>,
33
+ manifest: SyncManifest | null,
34
+ ): SideStatus {
35
+ const hasLocal = localHashes.has(relativePath);
36
+ const inManifest = manifest?.files[relativePath] !== undefined;
37
+
38
+ if (hasLocal && inManifest) {
39
+ return localHashes.get(relativePath) === manifest!.files[relativePath]
40
+ ? 'unchanged'
41
+ : 'changed';
42
+ }
43
+ if (hasLocal && !inManifest) return 'added';
44
+ if (!hasLocal && inManifest) return 'deleted';
45
+ // Not local, not in manifest — this file only exists remotely
46
+ return 'unchanged'; // not relevant on local side
47
+ }
48
+
49
+ export function classifyRemote(
50
+ relativePath: string,
51
+ remoteMtimes: Map<string, number>,
52
+ manifest: SyncManifest | null,
53
+ ): SideStatus {
54
+ const hasRemote = remoteMtimes.has(relativePath);
55
+ const inManifestMtimes = manifest?.remoteMtimes?.[relativePath] !== undefined;
56
+ // Use manifest.files as secondary known-paths set when remoteMtimes is missing
57
+ const inManifestFiles = manifest?.files[relativePath] !== undefined;
58
+ const knownInManifest = inManifestMtimes || inManifestFiles;
59
+
60
+ if (hasRemote && inManifestMtimes) {
61
+ return remoteMtimes.get(relativePath) ===
62
+ manifest!.remoteMtimes![relativePath]
63
+ ? 'unchanged'
64
+ : 'changed';
65
+ }
66
+ // Known in manifest.files but no mtime to compare — treat as changed, not added
67
+ if (hasRemote && inManifestFiles) return 'changed';
68
+ if (hasRemote && !knownInManifest) return 'added';
69
+ if (!hasRemote && knownInManifest) return 'deleted';
70
+ // Not remote, not in manifest — only exists locally
71
+ return 'unchanged'; // not relevant on remote side
72
+ }
73
+
74
+ export function determineAction(
75
+ local: SideStatus,
76
+ remote: SideStatus,
77
+ syncOptions: SyncOptions,
78
+ ): SyncAction {
79
+ // Both unchanged
80
+ if (local === 'unchanged' && remote === 'unchanged') return 'noop';
81
+
82
+ // One side changed, other unchanged
83
+ if (local === 'changed' && remote === 'unchanged') return 'push';
84
+ if (local === 'unchanged' && remote === 'changed') return 'pull';
85
+
86
+ // One side added, other doesn't exist
87
+ if (local === 'added' && remote === 'unchanged') return 'push';
88
+ if (local === 'unchanged' && remote === 'added') return 'pull';
89
+
90
+ // Both changed or both added — conflict
91
+ if (
92
+ (local === 'changed' && remote === 'changed') ||
93
+ (local === 'added' && remote === 'added')
94
+ ) {
95
+ return 'conflict';
96
+ }
97
+
98
+ // Cross-state conflicts (e.g., manifest missing remoteMtimes)
99
+ if (
100
+ (local === 'changed' && remote === 'added') ||
101
+ (local === 'added' && remote === 'changed')
102
+ ) {
103
+ return 'conflict';
104
+ }
105
+
106
+ // Deletions
107
+ if (local === 'deleted' && remote === 'unchanged') {
108
+ return syncOptions.deleteSync || syncOptions.preferLocal
109
+ ? 'push-delete'
110
+ : 'noop';
111
+ }
112
+ if (local === 'unchanged' && remote === 'deleted') {
113
+ return syncOptions.deleteSync || syncOptions.preferRemote
114
+ ? 'pull-delete'
115
+ : 'noop';
116
+ }
117
+
118
+ // Delete vs change conflicts
119
+ if (local === 'deleted' && remote === 'changed') return 'conflict';
120
+ if (local === 'changed' && remote === 'deleted') return 'conflict';
121
+
122
+ // Both deleted
123
+ if (local === 'deleted' && remote === 'deleted') return 'noop';
124
+
125
+ // Added vs deleted (shouldn't normally happen but handle gracefully)
126
+ if (local === 'added' && remote === 'deleted') return 'push';
127
+ if (local === 'deleted' && remote === 'added') return 'pull';
128
+
129
+ return 'noop';
130
+ }
131
+
132
+ export function resolveConflict(
133
+ classification: FileClassification,
134
+ localFilesWithMtimes: Map<string, { path: string; mtime: number }>,
135
+ remoteMtimes: Map<string, number>,
136
+ strategy: ConflictStrategy | null,
137
+ ): SyncAction | null {
138
+ const { localStatus, remoteStatus, relativePath } = classification;
139
+
140
+ if (!strategy) return null; // skip — no strategy
141
+
142
+ switch (strategy) {
143
+ case 'prefer-local':
144
+ if (localStatus === 'deleted') return 'push-delete';
145
+ return 'push';
146
+
147
+ case 'prefer-remote':
148
+ if (remoteStatus === 'deleted') return 'pull-delete';
149
+ return 'pull';
150
+
151
+ case 'prefer-newest': {
152
+ // For delete-vs-change, the change always wins
153
+ if (localStatus === 'deleted' && remoteStatus === 'changed')
154
+ return 'pull';
155
+ if (localStatus === 'changed' && remoteStatus === 'deleted')
156
+ return 'push';
157
+
158
+ const localInfo = localFilesWithMtimes.get(relativePath);
159
+ const remoteMtime = remoteMtimes.get(relativePath);
160
+
161
+ if (localInfo && remoteMtime !== undefined) {
162
+ // Remote mtimes are in seconds (epoch), local mtimes are in ms
163
+ return localInfo.mtime > remoteMtime * 1000 ? 'push' : 'pull';
164
+ }
165
+ // If we can't compare, prefer local (it's what the user has)
166
+ return 'push';
167
+ }
168
+ }
169
+ }
@@ -0,0 +1,81 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import * as crypto from 'crypto';
4
+
5
+ export interface SyncManifest {
6
+ realmUrl: string;
7
+ files: Record<string, string>; // relativePath -> contentHash
8
+ remoteMtimes?: Record<string, number>; // relativePath -> last-seen server mtime
9
+ }
10
+
11
+ export function isValidManifest(value: unknown): value is SyncManifest {
12
+ if (typeof value !== 'object' || value === null) return false;
13
+ const v = value as Record<string, unknown>;
14
+ if (typeof v.realmUrl !== 'string') return false;
15
+ if (typeof v.files !== 'object' || v.files === null) return false;
16
+ for (const hash of Object.values(v.files as Record<string, unknown>)) {
17
+ if (typeof hash !== 'string') return false;
18
+ }
19
+ if (v.remoteMtimes !== undefined) {
20
+ if (typeof v.remoteMtimes !== 'object' || v.remoteMtimes === null) {
21
+ return false;
22
+ }
23
+ for (const mtime of Object.values(
24
+ v.remoteMtimes as Record<string, unknown>,
25
+ )) {
26
+ if (typeof mtime !== 'number') return false;
27
+ }
28
+ }
29
+ return true;
30
+ }
31
+
32
+ export async function pathExists(p: string): Promise<boolean> {
33
+ try {
34
+ await fs.access(p);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ export async function computeFileHash(filePath: string): Promise<string> {
42
+ const content = await fs.readFile(filePath);
43
+ return crypto.createHash('md5').update(content).digest('hex');
44
+ }
45
+
46
+ export async function loadManifest(
47
+ localDir: string,
48
+ ): Promise<SyncManifest | null> {
49
+ const manifestPath = path.join(localDir, '.boxel-sync.json');
50
+ let content: string;
51
+ try {
52
+ content = await fs.readFile(manifestPath, 'utf8');
53
+ } catch (err: any) {
54
+ if (err.code === 'ENOENT') return null;
55
+ throw err;
56
+ }
57
+
58
+ let parsed: unknown;
59
+ try {
60
+ parsed = JSON.parse(content);
61
+ } catch {
62
+ return null;
63
+ }
64
+
65
+ if (!isValidManifest(parsed)) {
66
+ console.warn(
67
+ 'Warning: .boxel-sync.json is malformed or has an unexpected shape; falling back to a full upload.',
68
+ );
69
+ return null;
70
+ }
71
+
72
+ return parsed;
73
+ }
74
+
75
+ export async function saveManifest(
76
+ localDir: string,
77
+ manifest: SyncManifest,
78
+ ): Promise<void> {
79
+ const manifestPath = path.join(localDir, '.boxel-sync.json');
80
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
81
+ }