@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.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/api.ts +24 -0
- package/dist/index.js +75 -0
- package/package.json +78 -0
- package/src/commands/profile.ts +457 -0
- package/src/commands/realm/create.ts +245 -0
- package/src/commands/realm/index.ts +16 -0
- package/src/commands/realm/pull.ts +245 -0
- package/src/commands/realm/push.ts +379 -0
- package/src/commands/realm/sync.ts +587 -0
- package/src/commands/run-command.ts +186 -0
- package/src/index.ts +47 -0
- package/src/lib/auth.ts +169 -0
- package/src/lib/boxel-cli-client.ts +631 -0
- package/src/lib/checkpoint-manager.ts +609 -0
- package/src/lib/colors.ts +9 -0
- package/src/lib/profile-manager.ts +583 -0
- package/src/lib/realm-sync-base.ts +647 -0
- package/src/lib/sync-logic.ts +169 -0
- package/src/lib/sync-manifest.ts +81 -0
|
@@ -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
|
+
}
|