@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/dist/index.js +101 -85
- package/package.json +1 -1
- package/src/build-program.ts +4 -0
- package/src/commands/consolidate-workspaces.ts +104 -0
- package/src/commands/file/index.ts +1 -1
- package/src/commands/realm/index.ts +5 -1
- package/src/commands/realm/milestone.ts +375 -0
- package/src/commands/realm/status.ts +668 -0
- package/src/commands/realm/sync.ts +3 -2
- 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-local-paths.ts +243 -0
- 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
package/src/build-program.ts
CHANGED
|
@@ -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,
|
|
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
|
+
}
|