@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,245 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { RealmSyncBase, type SyncOptions } from '../../lib/realm-sync-base';
|
|
3
|
+
import {
|
|
4
|
+
CheckpointManager,
|
|
5
|
+
type CheckpointChange,
|
|
6
|
+
} from '../../lib/checkpoint-manager';
|
|
7
|
+
import {
|
|
8
|
+
getProfileManager,
|
|
9
|
+
type ProfileManager,
|
|
10
|
+
} from '../../lib/profile-manager';
|
|
11
|
+
import * as fs from 'fs/promises';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
|
|
14
|
+
interface PullOptions extends SyncOptions {
|
|
15
|
+
deleteLocal?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class RealmPuller extends RealmSyncBase {
|
|
19
|
+
hasError = false;
|
|
20
|
+
downloadedFiles: string[] = [];
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private pullOptions: PullOptions,
|
|
24
|
+
profileManager: ProfileManager,
|
|
25
|
+
) {
|
|
26
|
+
super(pullOptions, profileManager);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async sync(): Promise<void> {
|
|
30
|
+
console.log(
|
|
31
|
+
`Starting pull from ${this.options.realmUrl} to ${this.options.localDir}`,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
console.log('Testing realm access...');
|
|
35
|
+
try {
|
|
36
|
+
await this.getRemoteFileList('');
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('Failed to access realm:', error);
|
|
39
|
+
throw new Error(
|
|
40
|
+
'Cannot proceed with pull: Authentication or access failed. ' +
|
|
41
|
+
'Please check your Matrix credentials and realm permissions.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
console.log('Realm access verified');
|
|
45
|
+
|
|
46
|
+
const [remoteFiles, localFiles] = await Promise.all([
|
|
47
|
+
this.getRemoteFileList(),
|
|
48
|
+
this.getLocalFileList(),
|
|
49
|
+
]);
|
|
50
|
+
console.log(`Found ${remoteFiles.size} files in remote realm`);
|
|
51
|
+
console.log(`Found ${localFiles.size} files in local directory`);
|
|
52
|
+
|
|
53
|
+
if (this.options.dryRun) {
|
|
54
|
+
try {
|
|
55
|
+
await fs.access(this.options.localDir);
|
|
56
|
+
} catch {
|
|
57
|
+
console.log(
|
|
58
|
+
`[DRY RUN] Would create directory: ${this.options.localDir}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
await fs.mkdir(this.options.localDir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const filesToDelete = new Set<string>();
|
|
66
|
+
if (this.pullOptions.deleteLocal) {
|
|
67
|
+
for (const relativePath of localFiles.keys()) {
|
|
68
|
+
if (!remoteFiles.has(relativePath)) {
|
|
69
|
+
filesToDelete.add(relativePath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const checkpointManager = new CheckpointManager(this.options.localDir);
|
|
75
|
+
|
|
76
|
+
if (filesToDelete.size > 0 && !this.options.dryRun) {
|
|
77
|
+
const deleteChanges: CheckpointChange[] = Array.from(filesToDelete).map(
|
|
78
|
+
(f) => ({
|
|
79
|
+
file: f,
|
|
80
|
+
status: 'deleted' as const,
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
const preDeleteCheckpoint = await checkpointManager.createCheckpoint(
|
|
84
|
+
'remote',
|
|
85
|
+
deleteChanges,
|
|
86
|
+
`Pre-delete checkpoint: ${filesToDelete.size} files not on server`,
|
|
87
|
+
);
|
|
88
|
+
if (preDeleteCheckpoint) {
|
|
89
|
+
console.log(
|
|
90
|
+
`\nCheckpoint created before deletion: ${preDeleteCheckpoint.shortHash}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const downloadResults = await Promise.all(
|
|
96
|
+
Array.from(remoteFiles.keys()).map((relativePath) =>
|
|
97
|
+
this.remoteLimit(async () => {
|
|
98
|
+
try {
|
|
99
|
+
const localPath = path.join(this.options.localDir, relativePath);
|
|
100
|
+
await this.downloadFile(relativePath, localPath);
|
|
101
|
+
return relativePath;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
this.hasError = true;
|
|
104
|
+
console.error(`Error downloading ${relativePath}:`, error);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
this.downloadedFiles = downloadResults.filter(
|
|
111
|
+
(f): f is string => f !== null,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
let deletedFiles: string[] = [];
|
|
115
|
+
if (filesToDelete.size > 0) {
|
|
116
|
+
console.log(
|
|
117
|
+
`\nDeleting ${filesToDelete.size} local files that don't exist in realm...`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const deleteResults = await Promise.all(
|
|
121
|
+
Array.from(filesToDelete).map(async (relativePath) => {
|
|
122
|
+
try {
|
|
123
|
+
const localPath = localFiles.get(relativePath);
|
|
124
|
+
if (localPath) {
|
|
125
|
+
await this.deleteLocalFile(localPath);
|
|
126
|
+
console.log(` Deleted: ${relativePath}`);
|
|
127
|
+
return relativePath;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
this.hasError = true;
|
|
132
|
+
console.error(`Error deleting local file ${relativePath}:`, error);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
deletedFiles = deleteResults.filter((f): f is string => f !== null);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
!this.options.dryRun &&
|
|
142
|
+
this.downloadedFiles.length + deletedFiles.length > 0
|
|
143
|
+
) {
|
|
144
|
+
const pullChanges: CheckpointChange[] = [
|
|
145
|
+
...this.downloadedFiles.map((f) => ({
|
|
146
|
+
file: f,
|
|
147
|
+
status: 'modified' as const,
|
|
148
|
+
})),
|
|
149
|
+
...deletedFiles.map((f) => ({
|
|
150
|
+
file: f,
|
|
151
|
+
status: 'deleted' as const,
|
|
152
|
+
})),
|
|
153
|
+
];
|
|
154
|
+
const checkpoint = await checkpointManager.createCheckpoint(
|
|
155
|
+
'remote',
|
|
156
|
+
pullChanges,
|
|
157
|
+
);
|
|
158
|
+
if (checkpoint) {
|
|
159
|
+
const tag = checkpoint.isMajor ? '[MAJOR]' : '[minor]';
|
|
160
|
+
console.log(
|
|
161
|
+
`\nCheckpoint created: ${checkpoint.shortHash} ${tag} ${checkpoint.message}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
console.log('Pull completed');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface PullCommandOptions {
|
|
171
|
+
delete?: boolean;
|
|
172
|
+
dryRun?: boolean;
|
|
173
|
+
profileManager?: ProfileManager;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function registerPullCommand(realm: Command): void {
|
|
177
|
+
realm
|
|
178
|
+
.command('pull')
|
|
179
|
+
.description('Pull files from a Boxel realm to a local directory')
|
|
180
|
+
.argument(
|
|
181
|
+
'<realm-url>',
|
|
182
|
+
'The URL of the source realm (e.g., https://app.boxel.ai/demo/)',
|
|
183
|
+
)
|
|
184
|
+
.argument('<local-dir>', 'The local directory to sync files to')
|
|
185
|
+
.option('--delete', 'Delete local files that do not exist in the realm')
|
|
186
|
+
.option('--dry-run', 'Show what would be done without making changes')
|
|
187
|
+
.action(
|
|
188
|
+
async (
|
|
189
|
+
realmUrl: string,
|
|
190
|
+
localDir: string,
|
|
191
|
+
options: { delete?: boolean; dryRun?: boolean },
|
|
192
|
+
) => {
|
|
193
|
+
let result = await pull(realmUrl, localDir, options);
|
|
194
|
+
if (result.error) {
|
|
195
|
+
console.error(`Error: ${result.error}`);
|
|
196
|
+
process.exit(result.files.length > 0 ? 2 : 1);
|
|
197
|
+
}
|
|
198
|
+
console.log('Pull completed successfully');
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function pull(
|
|
204
|
+
realmUrl: string,
|
|
205
|
+
localDir: string,
|
|
206
|
+
options: PullCommandOptions,
|
|
207
|
+
): Promise<{ files: string[]; error?: string }> {
|
|
208
|
+
let pm = options.profileManager ?? getProfileManager();
|
|
209
|
+
let active = pm.getActiveProfile();
|
|
210
|
+
if (!active) {
|
|
211
|
+
return {
|
|
212
|
+
files: [],
|
|
213
|
+
error: 'No active profile. Run `boxel profile add` to create one.',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const puller = new RealmPuller(
|
|
219
|
+
{
|
|
220
|
+
realmUrl,
|
|
221
|
+
localDir,
|
|
222
|
+
deleteLocal: options.delete,
|
|
223
|
+
dryRun: options.dryRun,
|
|
224
|
+
},
|
|
225
|
+
pm,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
await puller.sync();
|
|
229
|
+
|
|
230
|
+
if (puller.hasError) {
|
|
231
|
+
return {
|
|
232
|
+
files: puller.downloadedFiles.sort(),
|
|
233
|
+
error:
|
|
234
|
+
'Pull completed with errors. Some files may not have been downloaded.',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { files: puller.downloadedFiles.sort() };
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return {
|
|
241
|
+
files: [],
|
|
242
|
+
error: `Pull failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import {
|
|
3
|
+
RealmSyncBase,
|
|
4
|
+
isProtectedFile,
|
|
5
|
+
type SyncOptions,
|
|
6
|
+
} from '../../lib/realm-sync-base';
|
|
7
|
+
import {
|
|
8
|
+
CheckpointManager,
|
|
9
|
+
type CheckpointChange,
|
|
10
|
+
} from '../../lib/checkpoint-manager';
|
|
11
|
+
import {
|
|
12
|
+
getProfileManager,
|
|
13
|
+
type ProfileManager,
|
|
14
|
+
} from '../../lib/profile-manager';
|
|
15
|
+
import {
|
|
16
|
+
type SyncManifest,
|
|
17
|
+
computeFileHash,
|
|
18
|
+
loadManifest,
|
|
19
|
+
saveManifest,
|
|
20
|
+
pathExists,
|
|
21
|
+
} from '../../lib/sync-manifest';
|
|
22
|
+
|
|
23
|
+
interface PushOptions extends SyncOptions {
|
|
24
|
+
deleteRemote?: boolean;
|
|
25
|
+
force?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class RealmPusher extends RealmSyncBase {
|
|
29
|
+
hasError = false;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
private pushOptions: PushOptions,
|
|
33
|
+
profileManager: ProfileManager,
|
|
34
|
+
) {
|
|
35
|
+
super(pushOptions, profileManager);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async sync(): Promise<void> {
|
|
39
|
+
console.log(
|
|
40
|
+
`Starting push from ${this.options.localDir} to ${this.options.realmUrl}`,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
console.log('Testing realm access...');
|
|
44
|
+
let initialRemoteFiles: Map<string, boolean>;
|
|
45
|
+
try {
|
|
46
|
+
initialRemoteFiles = await this.getRemoteFileList('');
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('Failed to access realm:', error);
|
|
49
|
+
throw new Error(
|
|
50
|
+
'Cannot proceed with push: Authentication or access failed. ' +
|
|
51
|
+
'Please check your credentials and realm permissions.',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
console.log('Realm access verified');
|
|
55
|
+
|
|
56
|
+
const localFiles = await this.getLocalFileList();
|
|
57
|
+
console.log(`Found ${localFiles.size} files in local directory`);
|
|
58
|
+
|
|
59
|
+
const manifest = await loadManifest(this.options.localDir);
|
|
60
|
+
const newManifest: SyncManifest = {
|
|
61
|
+
realmUrl: this.normalizedRealmUrl,
|
|
62
|
+
files: {},
|
|
63
|
+
remoteMtimes: {},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const filesToUpload: Map<string, string> = new Map();
|
|
67
|
+
const driftedPaths: Set<string> = new Set();
|
|
68
|
+
|
|
69
|
+
const canGoIncremental =
|
|
70
|
+
!this.pushOptions.force &&
|
|
71
|
+
manifest !== null &&
|
|
72
|
+
manifest.realmUrl === this.normalizedRealmUrl;
|
|
73
|
+
|
|
74
|
+
if (!canGoIncremental) {
|
|
75
|
+
if (this.pushOptions.force) {
|
|
76
|
+
console.log('Force mode: uploading all files');
|
|
77
|
+
} else if (!manifest) {
|
|
78
|
+
console.log('No sync manifest found, will upload all files');
|
|
79
|
+
} else {
|
|
80
|
+
console.log('Realm URL changed, will upload all files');
|
|
81
|
+
}
|
|
82
|
+
for (const [relativePath, localPath] of localFiles) {
|
|
83
|
+
if (isProtectedFile(relativePath)) continue;
|
|
84
|
+
filesToUpload.set(relativePath, localPath);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
console.log('Checking for changed files...');
|
|
88
|
+
let skipped = 0;
|
|
89
|
+
|
|
90
|
+
const [remoteMtimes, hashResults] = await Promise.all([
|
|
91
|
+
this.getRemoteMtimes(),
|
|
92
|
+
Promise.all(
|
|
93
|
+
Array.from(localFiles.entries()).map(
|
|
94
|
+
async ([relativePath, localPath]) => {
|
|
95
|
+
if (isProtectedFile(relativePath)) {
|
|
96
|
+
return {
|
|
97
|
+
relativePath,
|
|
98
|
+
localPath,
|
|
99
|
+
currentHash: '',
|
|
100
|
+
protected: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const currentHash = await computeFileHash(localPath);
|
|
104
|
+
return {
|
|
105
|
+
relativePath,
|
|
106
|
+
localPath,
|
|
107
|
+
currentHash,
|
|
108
|
+
protected: false,
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
for (const entry of hashResults) {
|
|
116
|
+
if (entry.protected) {
|
|
117
|
+
skipped++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const previousHash = manifest!.files[entry.relativePath];
|
|
121
|
+
const prevMtime = manifest!.remoteMtimes?.[entry.relativePath];
|
|
122
|
+
const currMtime = remoteMtimes.get(entry.relativePath);
|
|
123
|
+
|
|
124
|
+
const localChanged = previousHash !== entry.currentHash;
|
|
125
|
+
const remoteMissing =
|
|
126
|
+
previousHash !== undefined &&
|
|
127
|
+
!initialRemoteFiles.has(entry.relativePath);
|
|
128
|
+
const remoteMtimeChanged =
|
|
129
|
+
prevMtime !== undefined &&
|
|
130
|
+
currMtime !== undefined &&
|
|
131
|
+
currMtime !== prevMtime;
|
|
132
|
+
|
|
133
|
+
if (localChanged || remoteMissing || remoteMtimeChanged) {
|
|
134
|
+
filesToUpload.set(entry.relativePath, entry.localPath);
|
|
135
|
+
if (!localChanged && (remoteMissing || remoteMtimeChanged)) {
|
|
136
|
+
driftedPaths.add(entry.relativePath);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
skipped++;
|
|
140
|
+
newManifest.files[entry.relativePath] = entry.currentHash;
|
|
141
|
+
if (prevMtime !== undefined) {
|
|
142
|
+
newManifest.remoteMtimes![entry.relativePath] = prevMtime;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (skipped > 0) {
|
|
148
|
+
console.log(`Skipping ${skipped} unchanged files`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (driftedPaths.size > 0) {
|
|
152
|
+
const list = Array.from(driftedPaths);
|
|
153
|
+
const preview = list.slice(0, 5).join(', ');
|
|
154
|
+
const suffix = list.length > 5 ? ', ...' : '';
|
|
155
|
+
console.warn(
|
|
156
|
+
`Warning: ${driftedPaths.size} file(s) changed on the realm since your last push; your local versions will overwrite them: ${preview}${suffix}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let uploadFailed = false;
|
|
162
|
+
|
|
163
|
+
if (filesToUpload.size === 0) {
|
|
164
|
+
console.log('No files to upload - everything is up to date');
|
|
165
|
+
} else {
|
|
166
|
+
console.log(`Uploading ${filesToUpload.size} file(s) via /_atomic...`);
|
|
167
|
+
|
|
168
|
+
// Choose `op: add` vs `op: update` per file. When we have a
|
|
169
|
+
// manifest, the choice reflects our *intent* so the atomic
|
|
170
|
+
// endpoint can surface concurrent creation (409) and concurrent
|
|
171
|
+
// deletion (404):
|
|
172
|
+
// - File not in our manifest → op: add
|
|
173
|
+
// - File in manifest, remote-missing → op: add (drift re-create)
|
|
174
|
+
// - File in manifest, on the remote → op: update
|
|
175
|
+
// With `--force`, or when there is no manifest (first push, or
|
|
176
|
+
// recovery from a malformed manifest), we defer to the actual
|
|
177
|
+
// remote state to avoid spurious 409/404s from intent the user
|
|
178
|
+
// never expressed.
|
|
179
|
+
const addPaths = new Set<string>();
|
|
180
|
+
const deferToRemote = this.pushOptions.force || !manifest;
|
|
181
|
+
for (const relativePath of filesToUpload.keys()) {
|
|
182
|
+
if (deferToRemote) {
|
|
183
|
+
if (!initialRemoteFiles.has(relativePath)) {
|
|
184
|
+
addPaths.add(relativePath);
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
const knownToManifest = manifest!.files[relativePath] !== undefined;
|
|
188
|
+
const knownMissing =
|
|
189
|
+
knownToManifest && !initialRemoteFiles.has(relativePath);
|
|
190
|
+
if (!knownToManifest || knownMissing) {
|
|
191
|
+
addPaths.add(relativePath);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const result = await this.uploadFilesAtomic(filesToUpload, addPaths);
|
|
197
|
+
|
|
198
|
+
if (result.error) {
|
|
199
|
+
uploadFailed = true;
|
|
200
|
+
this.hasError = true;
|
|
201
|
+
console.error(result.error.message);
|
|
202
|
+
for (const entry of result.error.perFile) {
|
|
203
|
+
let hint: string;
|
|
204
|
+
if (entry.status === 409) {
|
|
205
|
+
hint = `${entry.path} was created on the realm concurrently — run with --force to overwrite.`;
|
|
206
|
+
} else if (entry.status === 404) {
|
|
207
|
+
hint = `${entry.path} was removed from the realm concurrently — run with --force to re-create it from your local copy.`;
|
|
208
|
+
} else {
|
|
209
|
+
hint = `${entry.path}: ${entry.title}`;
|
|
210
|
+
}
|
|
211
|
+
console.error(` ${hint}`);
|
|
212
|
+
}
|
|
213
|
+
} else if (result.succeeded.length > 0) {
|
|
214
|
+
const uploaded = await Promise.all(
|
|
215
|
+
result.succeeded.map(async (rel) => ({
|
|
216
|
+
rel,
|
|
217
|
+
hash: await computeFileHash(filesToUpload.get(rel)!),
|
|
218
|
+
})),
|
|
219
|
+
);
|
|
220
|
+
for (const { rel, hash } of uploaded) {
|
|
221
|
+
newManifest.files[rel] = hash;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (this.pushOptions.deleteRemote) {
|
|
227
|
+
const filesToDelete = new Set(initialRemoteFiles.keys());
|
|
228
|
+
|
|
229
|
+
for (const relativePath of filesToDelete) {
|
|
230
|
+
if (isProtectedFile(relativePath)) {
|
|
231
|
+
filesToDelete.delete(relativePath);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const relativePath of localFiles.keys()) {
|
|
236
|
+
filesToDelete.delete(relativePath);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (filesToDelete.size > 0) {
|
|
240
|
+
console.log(
|
|
241
|
+
`Deleting ${filesToDelete.size} remote files that don't exist locally`,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
await Promise.all(
|
|
245
|
+
Array.from(filesToDelete).map(async (relativePath) => {
|
|
246
|
+
try {
|
|
247
|
+
await this.deleteFile(relativePath);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
this.hasError = true;
|
|
250
|
+
console.error(`Error deleting ${relativePath}:`, error);
|
|
251
|
+
}
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!this.options.dryRun && !uploadFailed && filesToUpload.size > 0) {
|
|
258
|
+
try {
|
|
259
|
+
const freshMtimes = await this.getRemoteMtimes();
|
|
260
|
+
for (const rel of Object.keys(newManifest.files)) {
|
|
261
|
+
const mtime = freshMtimes.get(rel);
|
|
262
|
+
if (mtime !== undefined) {
|
|
263
|
+
newManifest.remoteMtimes![rel] = mtime;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.warn('Could not refresh remote mtimes after upload:', error);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (
|
|
272
|
+
newManifest.remoteMtimes &&
|
|
273
|
+
Object.keys(newManifest.remoteMtimes).length === 0
|
|
274
|
+
) {
|
|
275
|
+
delete newManifest.remoteMtimes;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!this.options.dryRun && !uploadFailed) {
|
|
279
|
+
await saveManifest(this.options.localDir, newManifest);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!this.options.dryRun && filesToUpload.size > 0 && !uploadFailed) {
|
|
283
|
+
const checkpointManager = new CheckpointManager(this.options.localDir);
|
|
284
|
+
const pushChanges: CheckpointChange[] = Array.from(
|
|
285
|
+
filesToUpload.keys(),
|
|
286
|
+
).map((f) => ({
|
|
287
|
+
file: f,
|
|
288
|
+
status: 'modified' as const,
|
|
289
|
+
}));
|
|
290
|
+
const checkpoint = await checkpointManager.createCheckpoint(
|
|
291
|
+
'local',
|
|
292
|
+
pushChanges,
|
|
293
|
+
);
|
|
294
|
+
if (checkpoint) {
|
|
295
|
+
const tag = checkpoint.isMajor ? '[MAJOR]' : '[minor]';
|
|
296
|
+
console.log(
|
|
297
|
+
`\nCheckpoint created: ${checkpoint.shortHash} ${tag} ${checkpoint.message}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
console.log('Push completed');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface PushCommandOptions {
|
|
307
|
+
delete?: boolean;
|
|
308
|
+
dryRun?: boolean;
|
|
309
|
+
force?: boolean;
|
|
310
|
+
profileManager?: ProfileManager;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function registerPushCommand(realm: Command): void {
|
|
314
|
+
realm
|
|
315
|
+
.command('push')
|
|
316
|
+
.description('Push local files to a Boxel realm')
|
|
317
|
+
.argument('<local-dir>', 'The local directory containing files to sync')
|
|
318
|
+
.argument(
|
|
319
|
+
'<realm-url>',
|
|
320
|
+
'The URL of the target realm (e.g., https://app.boxel.ai/demo/)',
|
|
321
|
+
)
|
|
322
|
+
.option('--delete', 'Delete remote files that do not exist locally')
|
|
323
|
+
.option('--dry-run', 'Show what would be done without making changes')
|
|
324
|
+
.option('--force', 'Upload all files, even if unchanged')
|
|
325
|
+
.action(
|
|
326
|
+
async (
|
|
327
|
+
localDir: string,
|
|
328
|
+
realmUrl: string,
|
|
329
|
+
options: { delete?: boolean; dryRun?: boolean; force?: boolean },
|
|
330
|
+
) => {
|
|
331
|
+
await pushCommand(localDir, realmUrl, options);
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export async function pushCommand(
|
|
337
|
+
localDir: string,
|
|
338
|
+
realmUrl: string,
|
|
339
|
+
options: PushCommandOptions,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
let pm = options.profileManager ?? getProfileManager();
|
|
342
|
+
let active = pm.getActiveProfile();
|
|
343
|
+
if (!active) {
|
|
344
|
+
console.error(
|
|
345
|
+
'Error: no active profile. Run `boxel profile add` to create one.',
|
|
346
|
+
);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (!(await pathExists(localDir))) {
|
|
351
|
+
console.error(`Local directory does not exist: ${localDir}`);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const pusher = new RealmPusher(
|
|
357
|
+
{
|
|
358
|
+
realmUrl,
|
|
359
|
+
localDir,
|
|
360
|
+
deleteRemote: options.delete,
|
|
361
|
+
dryRun: options.dryRun,
|
|
362
|
+
force: options.force,
|
|
363
|
+
},
|
|
364
|
+
pm,
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
await pusher.sync();
|
|
368
|
+
|
|
369
|
+
if (pusher.hasError) {
|
|
370
|
+
console.log('Push did not complete successfully. View logs for details');
|
|
371
|
+
process.exit(2);
|
|
372
|
+
} else {
|
|
373
|
+
console.log('Push completed successfully');
|
|
374
|
+
}
|
|
375
|
+
} catch (error) {
|
|
376
|
+
console.error('Push failed:', error);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
}
|