@cardstack/boxel-cli 0.1.3 → 0.2.0-unstable.294

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardstack/boxel-cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.0-unstable.294",
4
4
  "license": "MIT",
5
5
  "description": "CLI tools for Boxel workspace management",
6
6
  "main": "./dist/index.js",
@@ -1,11 +1,13 @@
1
1
  import { Command } from 'commander';
2
2
  import { profileCommand } from './commands/profile';
3
+ import { registerConsolidateWorkspacesCommand } from './commands/consolidate-workspaces';
3
4
  import { registerReadTranspiledCommand } from './commands/read-transpiled';
4
5
  import { registerRealmCommand } from './commands/realm/index';
5
6
  import { registerFileCommand } from './commands/file/index';
6
7
  import { registerRunCommand } from './commands/run-command';
7
8
  import { registerSearchCommand } from './commands/search';
8
9
  import { setQuiet } from './lib/cli-log';
10
+ import { warnIfMisplacedLocalRealmDirs } from './lib/realm-local-paths';
9
11
 
10
12
  /**
11
13
  * Construct the boxel CLI program with every command registered. Pure builder
@@ -30,6 +32,7 @@ export function buildBoxelProgram(version: string): Command {
30
32
  if (opts.quiet) {
31
33
  setQuiet(true);
32
34
  }
35
+ warnIfMisplacedLocalRealmDirs(process.cwd());
33
36
  });
34
37
 
35
38
  program
@@ -86,6 +89,7 @@ Environment variables (for 'add'):
86
89
  registerRunCommand(program);
87
90
  registerSearchCommand(program);
88
91
  registerReadTranspiledCommand(program);
92
+ registerConsolidateWorkspacesCommand(program);
89
93
 
90
94
  return program;
91
95
  }
@@ -0,0 +1,104 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import type { Command } from 'commander';
4
+ import { findMisplacedLocalRealmDirs } from '../lib/realm-local-paths';
5
+
6
+ export interface ConsolidateWorkspacesOptions {
7
+ dryRun?: boolean;
8
+ }
9
+
10
+ function ensureDir(dirPath: string): void {
11
+ if (!fs.existsSync(dirPath)) {
12
+ fs.mkdirSync(dirPath, { recursive: true });
13
+ }
14
+ }
15
+
16
+ function moveDir(from: string, to: string): void {
17
+ try {
18
+ fs.renameSync(from, to);
19
+ } catch (error) {
20
+ const err = error as NodeJS.ErrnoException;
21
+ if (err.code !== 'EXDEV') {
22
+ throw err;
23
+ }
24
+ fs.cpSync(from, to, { recursive: true });
25
+ fs.rmSync(from, { recursive: true, force: true });
26
+ }
27
+ }
28
+
29
+ export async function consolidateWorkspacesCommand(
30
+ rootDirInput: string | undefined,
31
+ options: ConsolidateWorkspacesOptions,
32
+ ): Promise<void> {
33
+ const rootDir = path.resolve(rootDirInput || '.');
34
+ const entries = findMisplacedLocalRealmDirs(rootDir);
35
+
36
+ if (entries.length === 0) {
37
+ console.log(`No misplaced local realm paths found under ${rootDir}`);
38
+ return;
39
+ }
40
+
41
+ console.log(`Found ${entries.length} misplaced local realm path(s):\n`);
42
+
43
+ let moved = 0;
44
+ let skipped = 0;
45
+
46
+ for (const entry of entries) {
47
+ const from = path.relative(rootDir, entry.currentDir) || '.';
48
+ const to = path.relative(rootDir, entry.expectedDir) || '.';
49
+ console.log(`- ${from} -> ${to}`);
50
+
51
+ if (options.dryRun) {
52
+ continue;
53
+ }
54
+
55
+ if (fs.existsSync(entry.expectedDir)) {
56
+ console.warn(' Skipping: target path already exists');
57
+ skipped += 1;
58
+ continue;
59
+ }
60
+
61
+ ensureDir(path.dirname(entry.expectedDir));
62
+ try {
63
+ moveDir(entry.currentDir, entry.expectedDir);
64
+ moved += 1;
65
+ } catch (error) {
66
+ const message = error instanceof Error ? error.message : String(error);
67
+ console.warn(` Skipping: failed to move (${message})`);
68
+ skipped += 1;
69
+ }
70
+ }
71
+
72
+ if (options.dryRun) {
73
+ console.log('\n[DRY RUN] No directories moved.');
74
+ return;
75
+ }
76
+
77
+ console.log(`\nMoved ${moved} director${moved === 1 ? 'y' : 'ies'}.`);
78
+ if (skipped > 0) {
79
+ console.log(
80
+ `Skipped ${skipped} due to existing target paths or move failures.`,
81
+ );
82
+ }
83
+ }
84
+
85
+ export function registerConsolidateWorkspacesCommand(program: Command): void {
86
+ program
87
+ .command('consolidate-workspaces')
88
+ .description(
89
+ 'Move local realm mirror directories into the canonical <root>/<domain>/<owner>/<realm> layout',
90
+ )
91
+ .argument(
92
+ '[root-dir]',
93
+ 'Root directory to scan (default: current directory)',
94
+ )
95
+ .option('--dry-run', 'Preview without moving anything')
96
+ .action(
97
+ async (
98
+ rootDir: string | undefined,
99
+ opts: ConsolidateWorkspacesOptions,
100
+ ) => {
101
+ await consolidateWorkspacesCommand(rootDir, opts);
102
+ },
103
+ );
104
+ }
@@ -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, search, and manage files in a realm');
12
+ .description('Read, write, and manage files in a realm');
13
13
 
14
14
  registerDeleteCommand(file);
15
15
  registerListCommand(file);
@@ -3,9 +3,11 @@ 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';
10
+ import { registerStatusCommand } from './status';
9
11
  import { registerSyncCommand } from './sync';
10
12
  import { registerWaitForReadyCommand } from './wait-for-ready';
11
13
  import { registerWatchCommand } from './watch';
@@ -19,10 +21,12 @@ export function registerRealmCommand(program: Command): void {
19
21
  registerCreateCommand(realm);
20
22
  registerHistoryCommand(realm);
21
23
  registerListCommand(realm);
24
+ registerMilestoneCommand(realm);
22
25
  registerPullCommand(realm);
23
26
  registerPushCommand(realm);
24
27
  registerRemoveCommand(realm);
25
- registerSyncCommand(realm);
28
+ const sync = registerSyncCommand(realm);
29
+ registerStatusCommand(sync);
26
30
  registerWaitForReadyCommand(realm);
27
31
  registerWatchCommand(realm);
28
32
  }
@@ -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
+ }