@cardstack/boxel-cli 0.1.3 → 0.1.4
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/dist/index.js +85 -75
- package/package.json +3 -3
- package/src/commands/file/index.ts +1 -1
- package/src/commands/realm/index.ts +2 -0
- package/src/commands/realm/milestone.ts +375 -0
- package/src/commands/realm/watch/index.ts +12 -0
- package/src/commands/realm/{watch.ts → watch/start.ts} +28 -13
- package/src/commands/realm/watch/stop.ts +151 -0
- package/src/lib/boxel-cli-client.ts +12 -0
- package/src/lib/checkpoint-manager.ts +67 -34
- package/src/lib/profile-manager.ts +27 -1
- package/src/lib/realm-sync-base.ts +9 -7
- package/src/lib/watch-lock.ts +1 -1
- package/src/lib/watch-process-registry.ts +85 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cardstack/boxel-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "CLI tools for Boxel workspace management",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
"vite": "^6.3.2",
|
|
53
53
|
"vitest": "^2.1.9",
|
|
54
54
|
"@cardstack/local-types": "0.0.0",
|
|
55
|
-
"@cardstack/
|
|
56
|
-
"@cardstack/
|
|
55
|
+
"@cardstack/runtime-common": "1.0.0",
|
|
56
|
+
"@cardstack/postgres": "0.0.0"
|
|
57
57
|
},
|
|
58
58
|
"publishConfig": {
|
|
59
59
|
"access": "public",
|
|
@@ -9,7 +9,7 @@ import { registerWriteCommand } from './write';
|
|
|
9
9
|
export function registerFileCommand(program: Command): void {
|
|
10
10
|
let file = program
|
|
11
11
|
.command('file')
|
|
12
|
-
.description('Read, write,
|
|
12
|
+
.description('Read, write, and manage files in a realm');
|
|
13
13
|
|
|
14
14
|
registerDeleteCommand(file);
|
|
15
15
|
registerListCommand(file);
|
|
@@ -3,6 +3,7 @@ import { registerCancelIndexingCommand } from './cancel-indexing';
|
|
|
3
3
|
import { registerCreateCommand } from './create';
|
|
4
4
|
import { registerHistoryCommand } from './history';
|
|
5
5
|
import { registerListCommand } from './list';
|
|
6
|
+
import { registerMilestoneCommand } from './milestone';
|
|
6
7
|
import { registerPullCommand } from './pull';
|
|
7
8
|
import { registerPushCommand } from './push';
|
|
8
9
|
import { registerRemoveCommand } from './remove';
|
|
@@ -19,6 +20,7 @@ export function registerRealmCommand(program: Command): void {
|
|
|
19
20
|
registerCreateCommand(realm);
|
|
20
21
|
registerHistoryCommand(realm);
|
|
21
22
|
registerListCommand(realm);
|
|
23
|
+
registerMilestoneCommand(realm);
|
|
22
24
|
registerPullCommand(realm);
|
|
23
25
|
registerPushCommand(realm);
|
|
24
26
|
registerRemoveCommand(realm);
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import type { Command } from 'commander';
|
|
3
|
+
import {
|
|
4
|
+
CheckpointManager,
|
|
5
|
+
type Checkpoint,
|
|
6
|
+
} from '../../lib/checkpoint-manager';
|
|
7
|
+
import { cliLog } from '../../lib/cli-log';
|
|
8
|
+
import { findCheckpoint } from '../../lib/find-checkpoint';
|
|
9
|
+
import {
|
|
10
|
+
BOLD,
|
|
11
|
+
DIM,
|
|
12
|
+
FG_CYAN,
|
|
13
|
+
FG_GREEN,
|
|
14
|
+
FG_MAGENTA,
|
|
15
|
+
FG_RED,
|
|
16
|
+
FG_YELLOW,
|
|
17
|
+
RESET,
|
|
18
|
+
} from '../../lib/colors';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LIMIT = 100;
|
|
21
|
+
|
|
22
|
+
export interface MilestoneOptions {
|
|
23
|
+
/** A 1-based index, short hash, or full hash to mark as milestone. Requires `name`. */
|
|
24
|
+
mark?: string;
|
|
25
|
+
/** Name for the milestone (required when `mark` is given). */
|
|
26
|
+
name?: string;
|
|
27
|
+
/** A 1-based index, short hash, or full hash whose milestone tag to remove. */
|
|
28
|
+
remove?: string;
|
|
29
|
+
/** Max checkpoints to consider for ref resolution. Defaults to 100. */
|
|
30
|
+
limit?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MilestoneResult {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
/** Populated in list mode. */
|
|
36
|
+
milestones?: Checkpoint[];
|
|
37
|
+
/** Populated when a milestone was marked. */
|
|
38
|
+
marked?: Checkpoint;
|
|
39
|
+
/** Populated when a milestone was removed. */
|
|
40
|
+
removed?: boolean;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface MilestoneCliOptions {
|
|
45
|
+
mark?: string;
|
|
46
|
+
name?: string;
|
|
47
|
+
remove?: string;
|
|
48
|
+
limit?: string;
|
|
49
|
+
json?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type StepResult<T> = ({ ok: true } & T) | { ok: false; error: string };
|
|
53
|
+
|
|
54
|
+
function errorMessage(e: unknown): string {
|
|
55
|
+
return e instanceof Error ? e.message : String(e);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatRelativeDate(date: Date): string {
|
|
59
|
+
const diffMs = Date.now() - date.getTime();
|
|
60
|
+
const minutes = Math.floor(diffMs / 60_000);
|
|
61
|
+
const hours = Math.floor(minutes / 60);
|
|
62
|
+
const days = Math.floor(hours / 24);
|
|
63
|
+
if (days > 7)
|
|
64
|
+
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
|
|
65
|
+
if (days > 0) return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
66
|
+
if (hours > 0) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
67
|
+
if (minutes > 0) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
|
68
|
+
return 'just now';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function resolveRef(
|
|
72
|
+
workspaceDir: string,
|
|
73
|
+
ref: string,
|
|
74
|
+
limit: number,
|
|
75
|
+
): Promise<StepResult<{ target: Checkpoint }>> {
|
|
76
|
+
try {
|
|
77
|
+
const manager = new CheckpointManager(workspaceDir);
|
|
78
|
+
if (!(await manager.isInitialized())) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error:
|
|
82
|
+
'No checkpoint history found for this workspace. ' +
|
|
83
|
+
'Checkpoints are created automatically during sync operations.',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const checkpoints = await manager.getCheckpoints(limit);
|
|
87
|
+
const found = findCheckpoint(ref, checkpoints);
|
|
88
|
+
if (found.kind === 'none') {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: `Checkpoint not found: ${ref}. Use a number (1-${checkpoints.length}) or a commit hash.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (found.kind === 'ambiguous') {
|
|
95
|
+
const sample = found.matches
|
|
96
|
+
.slice(0, 5)
|
|
97
|
+
.map((cp) => cp.shortHash)
|
|
98
|
+
.join(', ');
|
|
99
|
+
const more = found.matches.length > 5 ? ', …' : '';
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: `Ambiguous reference: ${ref} matches ${found.matches.length} checkpoints (${sample}${more}). Use a longer prefix or full hash.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, target: found.target };
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
error: `Failed to read checkpoints: ${errorMessage(e)}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function listMilestonesStep(
|
|
115
|
+
workspaceDir: string,
|
|
116
|
+
): Promise<StepResult<{ milestones: Checkpoint[] }>> {
|
|
117
|
+
if (!fs.existsSync(workspaceDir)) {
|
|
118
|
+
return { ok: false, error: `Directory not found: ${workspaceDir}` };
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const manager = new CheckpointManager(workspaceDir);
|
|
122
|
+
if (!(await manager.isInitialized())) {
|
|
123
|
+
return { ok: true, milestones: [] };
|
|
124
|
+
}
|
|
125
|
+
const milestones = await manager.getMilestones();
|
|
126
|
+
return { ok: true, milestones };
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
error: `Failed to read milestones: ${errorMessage(e)}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function markMilestoneStep(
|
|
136
|
+
workspaceDir: string,
|
|
137
|
+
ref: string,
|
|
138
|
+
name: string,
|
|
139
|
+
limit: number,
|
|
140
|
+
): Promise<StepResult<{ marked: Checkpoint }>> {
|
|
141
|
+
if (!fs.existsSync(workspaceDir)) {
|
|
142
|
+
return { ok: false, error: `Directory not found: ${workspaceDir}` };
|
|
143
|
+
}
|
|
144
|
+
const trimmedName = name.trim();
|
|
145
|
+
if (!trimmedName) {
|
|
146
|
+
return { ok: false, error: '--name must not be empty.' };
|
|
147
|
+
}
|
|
148
|
+
const resolved = await resolveRef(workspaceDir, ref, limit);
|
|
149
|
+
if (!resolved.ok) return resolved;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const manager = new CheckpointManager(workspaceDir);
|
|
153
|
+
const result = await manager.markMilestone(
|
|
154
|
+
resolved.target.hash,
|
|
155
|
+
trimmedName,
|
|
156
|
+
);
|
|
157
|
+
if (!result) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: 'Could not mark milestone. The checkpoint may already have one.',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const checkpoints = await manager.getCheckpoints(limit);
|
|
164
|
+
const marked = checkpoints.find((cp) => cp.hash === resolved.target.hash);
|
|
165
|
+
if (!marked) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: 'Milestone created but checkpoint could not be re-read.',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, marked };
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return { ok: false, error: `Failed to mark milestone: ${errorMessage(e)}` };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function removeMilestoneStep(
|
|
178
|
+
workspaceDir: string,
|
|
179
|
+
ref: string,
|
|
180
|
+
limit: number,
|
|
181
|
+
): Promise<StepResult<{ removed: boolean }>> {
|
|
182
|
+
if (!fs.existsSync(workspaceDir)) {
|
|
183
|
+
return { ok: false, error: `Directory not found: ${workspaceDir}` };
|
|
184
|
+
}
|
|
185
|
+
const resolved = await resolveRef(workspaceDir, ref, limit);
|
|
186
|
+
if (!resolved.ok) return resolved;
|
|
187
|
+
|
|
188
|
+
const target = resolved.target;
|
|
189
|
+
if (!target.isMilestone) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
error: `Checkpoint ${target.shortHash} is not marked as a milestone.`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const manager = new CheckpointManager(workspaceDir);
|
|
198
|
+
const success = await manager.unmarkMilestone(target.hash);
|
|
199
|
+
return { ok: true, removed: success };
|
|
200
|
+
} catch (e) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
error: `Failed to remove milestone: ${errorMessage(e)}`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* List, mark, or remove milestones in a workspace's local `.boxel-history/` git repo.
|
|
210
|
+
* Pure local — does not touch the realm server.
|
|
211
|
+
*/
|
|
212
|
+
export async function realmMilestone(
|
|
213
|
+
workspaceDir: string,
|
|
214
|
+
options: MilestoneOptions = {},
|
|
215
|
+
): Promise<MilestoneResult> {
|
|
216
|
+
if (options.mark !== undefined && options.remove !== undefined) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: 'Only one of --mark or --remove may be specified.',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (
|
|
223
|
+
options.limit !== undefined &&
|
|
224
|
+
(!Number.isInteger(options.limit) || options.limit <= 0)
|
|
225
|
+
) {
|
|
226
|
+
return { ok: false, error: 'limit must be a positive integer.' };
|
|
227
|
+
}
|
|
228
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
229
|
+
|
|
230
|
+
if (options.mark !== undefined) {
|
|
231
|
+
if (options.name === undefined) {
|
|
232
|
+
return { ok: false, error: '--name is required when using --mark.' };
|
|
233
|
+
}
|
|
234
|
+
const r = await markMilestoneStep(
|
|
235
|
+
workspaceDir,
|
|
236
|
+
options.mark,
|
|
237
|
+
options.name,
|
|
238
|
+
limit,
|
|
239
|
+
);
|
|
240
|
+
return r.ok
|
|
241
|
+
? { ok: true, marked: r.marked }
|
|
242
|
+
: { ok: false, error: r.error };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (options.remove !== undefined) {
|
|
246
|
+
const r = await removeMilestoneStep(workspaceDir, options.remove, limit);
|
|
247
|
+
return r.ok
|
|
248
|
+
? { ok: true, removed: r.removed }
|
|
249
|
+
: { ok: false, error: r.error };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const r = await listMilestonesStep(workspaceDir);
|
|
253
|
+
return r.ok
|
|
254
|
+
? { ok: true, milestones: r.milestones }
|
|
255
|
+
: { ok: false, error: r.error };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function printMilestones(milestones: Checkpoint[], workspaceDir: string): void {
|
|
259
|
+
if (milestones.length === 0) {
|
|
260
|
+
console.log('\nNo milestones marked yet.\n');
|
|
261
|
+
console.log(
|
|
262
|
+
`Use ${FG_CYAN}boxel realm milestone <local-dir> --mark <ref> --name <name>${RESET} to mark a checkpoint.`,
|
|
263
|
+
);
|
|
264
|
+
console.log(
|
|
265
|
+
`Use ${FG_CYAN}boxel realm history <local-dir>${RESET} to see available checkpoints.\n`,
|
|
266
|
+
);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log(`\n${BOLD}Milestones${RESET} ${DIM}(${workspaceDir})${RESET}\n`);
|
|
271
|
+
for (const cp of milestones) {
|
|
272
|
+
const sourceIcon =
|
|
273
|
+
cp.source === 'local' ? '↑' : cp.source === 'remote' ? '↓' : '●';
|
|
274
|
+
const sourceColor =
|
|
275
|
+
cp.source === 'local'
|
|
276
|
+
? FG_GREEN
|
|
277
|
+
: cp.source === 'remote'
|
|
278
|
+
? FG_CYAN
|
|
279
|
+
: FG_MAGENTA;
|
|
280
|
+
console.log(
|
|
281
|
+
` ${FG_YELLOW}⭐${RESET} ` +
|
|
282
|
+
`${FG_YELLOW}${cp.shortHash}${RESET} ` +
|
|
283
|
+
`${sourceColor}${sourceIcon}${RESET} ` +
|
|
284
|
+
`${FG_MAGENTA}[${cp.milestoneName}]${RESET} ` +
|
|
285
|
+
`${cp.message}`,
|
|
286
|
+
);
|
|
287
|
+
console.log(` ${DIM}${formatRelativeDate(cp.date)}${RESET}`);
|
|
288
|
+
}
|
|
289
|
+
console.log();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function parseLimit(raw: string | undefined): number | null {
|
|
293
|
+
if (raw === undefined) return DEFAULT_LIMIT;
|
|
294
|
+
if (!/^\d+$/.test(raw)) return null;
|
|
295
|
+
const n = parseInt(raw, 10);
|
|
296
|
+
return n > 0 ? n : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function bailout(msg: string): never {
|
|
300
|
+
console.error(`${FG_RED}Error:${RESET} ${msg}`);
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function registerMilestoneCommand(realm: Command): void {
|
|
305
|
+
realm
|
|
306
|
+
.command('milestone')
|
|
307
|
+
.description(
|
|
308
|
+
'List, mark, or remove milestones in the local .boxel-history/ checkpoint log',
|
|
309
|
+
)
|
|
310
|
+
.argument('<local-dir>', 'The local workspace directory')
|
|
311
|
+
.option(
|
|
312
|
+
'--mark <ref>',
|
|
313
|
+
'Mark a checkpoint as a milestone (1-based index, short hash, or full hash)',
|
|
314
|
+
)
|
|
315
|
+
.option('--name <name>', 'Name for the milestone (required with --mark)')
|
|
316
|
+
.option(
|
|
317
|
+
'--remove <ref>',
|
|
318
|
+
'Remove the milestone tag from a checkpoint (1-based index, short hash, or full hash)',
|
|
319
|
+
)
|
|
320
|
+
.option(
|
|
321
|
+
'--limit <n>',
|
|
322
|
+
`Maximum number of checkpoints to consider for ref resolution (default: ${DEFAULT_LIMIT})`,
|
|
323
|
+
)
|
|
324
|
+
.option('--json', 'Output result as JSON')
|
|
325
|
+
.action(async (localDir: string, opts: MilestoneCliOptions) => {
|
|
326
|
+
if (opts.mark !== undefined && opts.remove !== undefined) {
|
|
327
|
+
bailout('Only one of --mark or --remove may be specified.');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const limit = parseLimit(opts.limit);
|
|
331
|
+
if (limit === null) {
|
|
332
|
+
bailout('--limit must be a positive integer.');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (opts.mark !== undefined && opts.name === undefined) {
|
|
336
|
+
bailout('--name is required when using --mark.');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const result = await realmMilestone(localDir, {
|
|
340
|
+
mark: opts.mark,
|
|
341
|
+
name: opts.name,
|
|
342
|
+
remove: opts.remove,
|
|
343
|
+
limit,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
if (opts.json) {
|
|
347
|
+
cliLog.output(JSON.stringify(result, null, 2));
|
|
348
|
+
if (!result.ok) process.exit(1);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!result.ok) {
|
|
353
|
+
bailout(result.error!);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (result.marked) {
|
|
357
|
+
const cp = result.marked;
|
|
358
|
+
console.log(
|
|
359
|
+
`\n${FG_GREEN}✓${RESET} ${FG_YELLOW}⭐${RESET} Milestone created: ${FG_MAGENTA}${cp.milestoneName}${RESET}`,
|
|
360
|
+
);
|
|
361
|
+
console.log(
|
|
362
|
+
` Checkpoint: ${FG_YELLOW}${cp.shortHash}${RESET} ${cp.message}`,
|
|
363
|
+
);
|
|
364
|
+
console.log();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (result.removed !== undefined) {
|
|
369
|
+
console.log(`${FG_GREEN}✓${RESET} Milestone removed`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
printMilestones(result.milestones!, localDir);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { registerStartCommand } from './start';
|
|
3
|
+
import { registerStopCommand } from './stop';
|
|
4
|
+
|
|
5
|
+
export function registerWatchCommand(realm: Command): void {
|
|
6
|
+
const watch = realm
|
|
7
|
+
.command('watch')
|
|
8
|
+
.description('Watch a Boxel realm; subcommands manage watch processes');
|
|
9
|
+
|
|
10
|
+
registerStartCommand(watch);
|
|
11
|
+
registerStopCommand(watch);
|
|
12
|
+
}
|
|
@@ -1,27 +1,31 @@
|
|
|
1
1
|
import { InvalidArgumentError, type Command } from 'commander';
|
|
2
2
|
import * as fs from 'fs/promises';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import { RealmSyncBase, isProtectedFile } from '
|
|
4
|
+
import { RealmSyncBase, isProtectedFile } from '../../../lib/realm-sync-base';
|
|
5
5
|
import {
|
|
6
6
|
CheckpointManager,
|
|
7
7
|
type Checkpoint,
|
|
8
8
|
type CheckpointChange,
|
|
9
|
-
} from '
|
|
9
|
+
} from '../../../lib/checkpoint-manager';
|
|
10
10
|
import {
|
|
11
11
|
type SyncManifest,
|
|
12
12
|
computeFileHash,
|
|
13
13
|
loadManifest,
|
|
14
14
|
saveManifest,
|
|
15
|
-
} from '
|
|
16
|
-
import type { ProfileManager } from '
|
|
17
|
-
import type { RealmAuthenticator } from '
|
|
18
|
-
import { resolveRealmAuthenticator } from '
|
|
19
|
-
import { resolveRealmSecretSeed } from '
|
|
15
|
+
} from '../../../lib/sync-manifest';
|
|
16
|
+
import type { ProfileManager } from '../../../lib/profile-manager';
|
|
17
|
+
import type { RealmAuthenticator } from '../../../lib/realm-authenticator';
|
|
18
|
+
import { resolveRealmAuthenticator } from '../../../lib/auth-resolver';
|
|
19
|
+
import { resolveRealmSecretSeed } from '../../../lib/prompt';
|
|
20
20
|
import {
|
|
21
21
|
acquireWatchLock,
|
|
22
22
|
releaseWatchLock,
|
|
23
23
|
type WatchLockInfo,
|
|
24
|
-
} from '
|
|
24
|
+
} from '../../../lib/watch-lock';
|
|
25
|
+
import {
|
|
26
|
+
registerProcess,
|
|
27
|
+
unregisterCurrentProcess,
|
|
28
|
+
} from '../../../lib/watch-process-registry';
|
|
25
29
|
import {
|
|
26
30
|
FG_CYAN,
|
|
27
31
|
FG_GREEN,
|
|
@@ -29,7 +33,7 @@ import {
|
|
|
29
33
|
FG_YELLOW,
|
|
30
34
|
DIM,
|
|
31
35
|
RESET,
|
|
32
|
-
} from '
|
|
36
|
+
} from '../../../lib/colors';
|
|
33
37
|
|
|
34
38
|
export interface WatchRealmSpec {
|
|
35
39
|
realmUrl: string;
|
|
@@ -472,6 +476,12 @@ export async function watchRealms(
|
|
|
472
476
|
}, intervalMs);
|
|
473
477
|
};
|
|
474
478
|
|
|
479
|
+
try {
|
|
480
|
+
await registerProcess(specs.map((s) => s.localDir).join(', '));
|
|
481
|
+
} catch {
|
|
482
|
+
// Best effort — registry failures must never block the watch.
|
|
483
|
+
}
|
|
484
|
+
|
|
475
485
|
await tickAll();
|
|
476
486
|
scheduleNextTick();
|
|
477
487
|
|
|
@@ -496,6 +506,11 @@ export async function watchRealms(
|
|
|
496
506
|
// Best effort \u2014 a leftover lock will be detected as stale next run.
|
|
497
507
|
}
|
|
498
508
|
}
|
|
509
|
+
try {
|
|
510
|
+
await unregisterCurrentProcess();
|
|
511
|
+
} catch {
|
|
512
|
+
// Best effort \u2014 leftover entries are pruned on next read.
|
|
513
|
+
}
|
|
499
514
|
resolve();
|
|
500
515
|
};
|
|
501
516
|
|
|
@@ -572,11 +587,11 @@ function parseNonNegativeSeconds(name: string): (value: string) => number {
|
|
|
572
587
|
};
|
|
573
588
|
}
|
|
574
589
|
|
|
575
|
-
export function
|
|
576
|
-
|
|
577
|
-
.command('
|
|
590
|
+
export function registerStartCommand(watch: Command): void {
|
|
591
|
+
watch
|
|
592
|
+
.command('start')
|
|
578
593
|
.description(
|
|
579
|
-
'
|
|
594
|
+
'Start watching a Boxel realm for server-side changes and pull them into a local directory',
|
|
580
595
|
)
|
|
581
596
|
.argument(
|
|
582
597
|
'<realm-url>',
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import type { Command } from 'commander';
|
|
3
|
+
import { listRegisteredProcesses } from '../../../lib/watch-process-registry';
|
|
4
|
+
import { DIM, FG_GREEN, FG_RED, RESET } from '../../../lib/colors';
|
|
5
|
+
|
|
6
|
+
export interface StoppedProcess {
|
|
7
|
+
pid: number;
|
|
8
|
+
workspace: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StopResult {
|
|
12
|
+
stopped: StoppedProcess[];
|
|
13
|
+
failed: StoppedProcess[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SETTLE_MS = 200;
|
|
17
|
+
|
|
18
|
+
function sleep(ms: number): Promise<void> {
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function signalProcess(pid: number): { ok: boolean; alreadyGone: boolean } {
|
|
23
|
+
try {
|
|
24
|
+
if (process.platform === 'win32') {
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid);
|
|
27
|
+
} catch {
|
|
28
|
+
execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' });
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
process.kill(pid, 'SIGINT');
|
|
32
|
+
}
|
|
33
|
+
return { ok: true, alreadyGone: false };
|
|
34
|
+
} catch (err: any) {
|
|
35
|
+
if (err?.code === 'ESRCH') {
|
|
36
|
+
return { ok: true, alreadyGone: true };
|
|
37
|
+
}
|
|
38
|
+
return { ok: false, alreadyGone: false };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface PsHit {
|
|
43
|
+
pid: number;
|
|
44
|
+
workspace: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findViaProcessTable(): PsHit[] {
|
|
48
|
+
if (process.platform === 'win32') return [];
|
|
49
|
+
let output: string;
|
|
50
|
+
try {
|
|
51
|
+
output = execSync(
|
|
52
|
+
'ps aux | grep -E "(tsx[[:space:]].*src/index\\.ts[[:space:]]+realm[[:space:]]+watch[[:space:]]+start|[[:space:]]boxel[[:space:]]+realm[[:space:]]+watch[[:space:]]+start|node[[:space:]].*boxel[[:space:]]+realm[[:space:]]+watch[[:space:]]+start)" | grep -v grep | grep -v "[[:space:]]stop"',
|
|
53
|
+
{ encoding: 'utf8' },
|
|
54
|
+
).trim();
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
if (!output) return [];
|
|
59
|
+
|
|
60
|
+
const hits: PsHit[] = [];
|
|
61
|
+
const seen = new Set<number>();
|
|
62
|
+
for (const line of output.split('\n')) {
|
|
63
|
+
if (!line) continue;
|
|
64
|
+
const parts = line.trim().split(/\s+/);
|
|
65
|
+
const pid = Number.parseInt(parts[1] ?? '', 10);
|
|
66
|
+
if (!Number.isFinite(pid) || seen.has(pid)) continue;
|
|
67
|
+
seen.add(pid);
|
|
68
|
+
|
|
69
|
+
let workspace = '.';
|
|
70
|
+
const match = line.match(/\bstart\s+\S+\s+(\S+)/);
|
|
71
|
+
if (match && match[1] && !match[1].startsWith('-')) {
|
|
72
|
+
workspace = match[1];
|
|
73
|
+
}
|
|
74
|
+
hits.push({ pid, workspace });
|
|
75
|
+
}
|
|
76
|
+
return hits;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function stopWatchProcesses(): Promise<StopResult> {
|
|
80
|
+
const stopped: StoppedProcess[] = [];
|
|
81
|
+
const failed: StoppedProcess[] = [];
|
|
82
|
+
const targetedPids = new Set<number>();
|
|
83
|
+
|
|
84
|
+
const registered = await listRegisteredProcesses();
|
|
85
|
+
for (const proc of registered) {
|
|
86
|
+
if (proc.pid === process.pid) continue;
|
|
87
|
+
targetedPids.add(proc.pid);
|
|
88
|
+
const result = signalProcess(proc.pid);
|
|
89
|
+
const record: StoppedProcess = { pid: proc.pid, workspace: proc.workspace };
|
|
90
|
+
if (result.ok) {
|
|
91
|
+
stopped.push(record);
|
|
92
|
+
} else {
|
|
93
|
+
failed.push(record);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const hit of findViaProcessTable()) {
|
|
98
|
+
if (hit.pid === process.pid) continue;
|
|
99
|
+
if (targetedPids.has(hit.pid)) continue;
|
|
100
|
+
targetedPids.add(hit.pid);
|
|
101
|
+
const result = signalProcess(hit.pid);
|
|
102
|
+
const record: StoppedProcess = { pid: hit.pid, workspace: hit.workspace };
|
|
103
|
+
if (result.ok) {
|
|
104
|
+
stopped.push(record);
|
|
105
|
+
} else {
|
|
106
|
+
failed.push(record);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (stopped.length > 0) {
|
|
111
|
+
await sleep(SETTLE_MS);
|
|
112
|
+
// Trigger another prune so the registry doesn't keep stale entries
|
|
113
|
+
// for processes that exited cleanly above.
|
|
114
|
+
await listRegisteredProcesses();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { stopped, failed };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function printResult(result: StopResult): void {
|
|
121
|
+
if (result.stopped.length === 0 && result.failed.length === 0) {
|
|
122
|
+
console.log('No running watch processes found.');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
for (const proc of result.stopped) {
|
|
126
|
+
console.log(
|
|
127
|
+
` ${DIM}⇅${RESET} Stopped: boxel realm watch ${proc.workspace} (PID ${proc.pid})`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
for (const proc of result.failed) {
|
|
131
|
+
console.log(
|
|
132
|
+
` ${FG_RED}×${RESET} Failed to stop: boxel realm watch ${proc.workspace} (PID ${proc.pid})`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (result.stopped.length > 0) {
|
|
136
|
+
const plural = result.stopped.length > 1 ? 'es' : '';
|
|
137
|
+
console.log(
|
|
138
|
+
`\n${FG_GREEN}✓ Stopped ${result.stopped.length} process${plural}${RESET}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function registerStopCommand(watch: Command): void {
|
|
144
|
+
watch
|
|
145
|
+
.command('stop')
|
|
146
|
+
.description('Stop all running boxel realm watch processes')
|
|
147
|
+
.action(async () => {
|
|
148
|
+
const result = await stopWatchProcesses();
|
|
149
|
+
printResult(result);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -450,6 +450,18 @@ export class BoxelCLIClient {
|
|
|
450
450
|
return this.pm.authedRealmServerFetch(input, init);
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Return the realm-server JWT, fetching one via Matrix login if no token
|
|
455
|
+
* is cached. Use only when you need to hand the bare token to a downstream
|
|
456
|
+
* client that can't go through `authedServerFetch` (e.g. opencode's
|
|
457
|
+
* static-Authorization provider config). Prefer `authedServerFetch` for
|
|
458
|
+
* server endpoints called from JS — it handles per-request 401 retries
|
|
459
|
+
* that this getter cannot.
|
|
460
|
+
*/
|
|
461
|
+
async getServerToken(): Promise<string> {
|
|
462
|
+
return this.pm.getOrRefreshServerToken();
|
|
463
|
+
}
|
|
464
|
+
|
|
453
465
|
async pull(
|
|
454
466
|
realmUrl: string,
|
|
455
467
|
localDir: string,
|