@remogram/cli 0.1.0-beta.0 → 0.1.0-beta.10

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/cli-argv.js ADDED
@@ -0,0 +1,50 @@
1
+ import { ERROR_CODES, forgeError } from '@remogram/core';
2
+
3
+ export const REPEATABLE_FLAGS = new Set(['allowed_path']);
4
+
5
+ export function parseAllowedPathFlags(flags) {
6
+ if (flags.allowed_path == null) return undefined;
7
+ return Array.isArray(flags.allowed_path) ? flags.allowed_path : [flags.allowed_path];
8
+ }
9
+
10
+ export function parsePositiveInt(value, name) {
11
+ if (value == null) return undefined;
12
+ const n = Number(value);
13
+ if (!Number.isInteger(n) || n <= 0) {
14
+ throw Object.assign(new Error(`Invalid ${name}`), {
15
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, `${name} must be a positive integer`),
16
+ });
17
+ }
18
+ return n;
19
+ }
20
+
21
+ export function parseCliArgv(argv) {
22
+ const positional = [];
23
+ let asJson = false;
24
+ const flags = {};
25
+
26
+ for (let i = 0; i < argv.length; i += 1) {
27
+ const arg = argv[i];
28
+ if (arg === '--json') asJson = true;
29
+ else if (arg.startsWith('--')) {
30
+ const key = arg.slice(2).replace(/-/g, '_');
31
+ const next = argv[i + 1];
32
+ if (REPEATABLE_FLAGS.has(key)) {
33
+ if (!flags[key]) flags[key] = [];
34
+ if (next != null && !next.startsWith('--')) {
35
+ flags[key].push(next);
36
+ i += 1;
37
+ }
38
+ } else if (next != null && !next.startsWith('--')) {
39
+ flags[key] = next;
40
+ i += 1;
41
+ } else {
42
+ flags[key] = true;
43
+ }
44
+ } else {
45
+ positional.push(arg);
46
+ }
47
+ }
48
+
49
+ return { positional, asJson, flags };
50
+ }
@@ -0,0 +1,585 @@
1
+ import {
2
+ forgePacket,
3
+ PACKET_TYPES,
4
+ ERROR_CODES,
5
+ forgeError,
6
+ sanitizeField,
7
+ assertGitRef,
8
+ assertGitRemote,
9
+ assertCrOpenBranchRef,
10
+ throwIfStaleHeadByNumber,
11
+ FACT_INVENTORY_PACKET_TYPES,
12
+ forgeFactInventoryPacket,
13
+ assertWriteCommandConfigured,
14
+ parseSinceObservedAt,
15
+ decodeForgeChangesCursor,
16
+ paginateForgeChangesBody,
17
+ DEFAULT_FORGE_CHANGES_PAGE_SIZE,
18
+ normalizeAllowedPaths,
19
+ assertExpectedSha,
20
+ buildMergeExecuteBeforeFacts,
21
+ collectMergeExecuteBlockers,
22
+ buildCrMergeBlockedBody,
23
+ buildCrMergedBody,
24
+ buildMergeExecuteAfterFacts,
25
+ buildMergeExecuteMergeFacts,
26
+ mergeExecuteViewFacts,
27
+ isOpenPrState,
28
+ bindIdempotencyScope,
29
+ } from '@remogram/core';
30
+ import { parseAllowedPathFlags, parsePositiveInt } from './cli-argv.js';
31
+
32
+ export async function dispatchForgeCommand({ group, sub, flags, positional, ctx, provider }) {
33
+ if (group === 'provider' && sub === 'capabilities') {
34
+ return forgePacket(
35
+ PACKET_TYPES.PROVIDER_CAPABILITIES,
36
+ ctx,
37
+ await provider.providerCapabilities(ctx),
38
+ );
39
+ }
40
+ if (group === 'repo' && sub === 'status') {
41
+ return forgePacket(PACKET_TYPES.REPO_STATUS, ctx, await provider.repoStatus(ctx));
42
+ }
43
+ if (group === 'refs' && sub === 'compare') {
44
+ if (!flags.base || !flags.head) {
45
+ throw Object.assign(new Error('--base and --head required'), {
46
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--base and --head required'),
47
+ });
48
+ }
49
+ assertGitRef(flags.base, '--base');
50
+ assertGitRef(flags.head, '--head');
51
+ return forgePacket(
52
+ PACKET_TYPES.REF_COMPARE,
53
+ ctx,
54
+ await provider.refsCompare(ctx, flags.base, flags.head),
55
+ );
56
+ }
57
+ if (group === 'refs' && sub === 'inventory') {
58
+ if (typeof provider.refsInventory !== 'function') {
59
+ throw Object.assign(new Error('refs inventory not implemented for provider'), {
60
+ forgeError: forgeError(
61
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
62
+ 'refs inventory not implemented for provider',
63
+ ),
64
+ });
65
+ }
66
+ return forgeFactInventoryPacket(
67
+ FACT_INVENTORY_PACKET_TYPES.REF_INVENTORY,
68
+ ctx,
69
+ await provider.refsInventory(ctx),
70
+ );
71
+ }
72
+ if (group === 'cr' && sub === 'inventory') {
73
+ if (typeof provider.crInventory !== 'function') {
74
+ throw Object.assign(new Error('cr inventory not implemented for provider'), {
75
+ forgeError: forgeError(
76
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
77
+ 'cr inventory not implemented for provider',
78
+ ),
79
+ });
80
+ }
81
+ const inventoryBody = await provider.crInventory(ctx, {
82
+ slice_ref: flags.slice_ref,
83
+ limit: parsePositiveInt(flags.limit, '--limit'),
84
+ sort: flags.sort,
85
+ cursor: flags.cursor,
86
+ });
87
+ if (inventoryBody.list_truncated === true && !flags.cursor) {
88
+ throw Object.assign(new Error('Open CR list incomplete'), {
89
+ forgeError: forgeError(
90
+ ERROR_CODES.INVENTORY_LIST_INCOMPLETE,
91
+ 'Open change request list could not be proved complete within pagination bounds',
92
+ null,
93
+ {
94
+ inventory_list: {
95
+ entry_count: inventoryBody.entry_count,
96
+ },
97
+ },
98
+ ),
99
+ });
100
+ }
101
+ return forgeFactInventoryPacket(
102
+ FACT_INVENTORY_PACKET_TYPES.CR_INVENTORY_SLICE,
103
+ ctx,
104
+ inventoryBody,
105
+ );
106
+ }
107
+ if (group === 'cr' && sub === 'files') {
108
+ const number = parsePositiveInt(flags.number, '--number');
109
+ if (number == null) {
110
+ throw Object.assign(new Error('--number required'), {
111
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--number required for cr files'),
112
+ });
113
+ }
114
+ if (typeof provider.crFiles !== 'function') {
115
+ throw Object.assign(new Error('cr files not implemented for provider'), {
116
+ forgeError: forgeError(
117
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
118
+ 'cr files not implemented for provider',
119
+ ),
120
+ });
121
+ }
122
+ return forgePacket(
123
+ PACKET_TYPES.CR_FILES,
124
+ ctx,
125
+ await provider.crFiles(ctx, { number }),
126
+ );
127
+ }
128
+ if (group === 'cr' && sub === 'comments') {
129
+ const number = parsePositiveInt(flags.number, '--number');
130
+ if (number == null) {
131
+ throw Object.assign(new Error('--number required'), {
132
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--number required for cr comments'),
133
+ });
134
+ }
135
+ if (typeof provider.crComments !== 'function') {
136
+ throw Object.assign(new Error('cr comments not implemented for provider'), {
137
+ forgeError: forgeError(
138
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
139
+ 'cr comments not implemented for provider',
140
+ ),
141
+ });
142
+ }
143
+ return forgePacket(
144
+ PACKET_TYPES.CR_COMMENTS,
145
+ ctx,
146
+ await provider.crComments(ctx, { number }),
147
+ );
148
+ }
149
+ if (group === 'forge' && sub === 'changes') {
150
+ if (typeof provider.forgeChanges !== 'function') {
151
+ throw Object.assign(new Error('forge changes not implemented for provider'), {
152
+ forgeError: forgeError(
153
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
154
+ 'forge changes not implemented for provider',
155
+ ),
156
+ });
157
+ }
158
+ let sinceIso;
159
+ let cursorOffset = 0;
160
+ const pageLimit = parsePositiveInt(flags.limit, '--limit') ?? DEFAULT_FORGE_CHANGES_PAGE_SIZE;
161
+ if (flags.cursor) {
162
+ const decoded = decodeForgeChangesCursor(flags.cursor, { since: flags.since });
163
+ sinceIso = decoded.since;
164
+ cursorOffset = decoded.offset;
165
+ } else {
166
+ sinceIso = parseSinceObservedAt(flags.since);
167
+ }
168
+ const body = await provider.forgeChanges(ctx, { since: sinceIso });
169
+ const paginated = paginateForgeChangesBody(body, { offset: cursorOffset, limit: pageLimit });
170
+ return forgePacket(PACKET_TYPES.FORGE_CHANGES, ctx, paginated);
171
+ }
172
+ if (group === 'cr' && sub === 'open') {
173
+ if (typeof provider.crOpen !== 'function') {
174
+ throw Object.assign(new Error('cr open not implemented for provider'), {
175
+ forgeError: forgeError(
176
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
177
+ 'cr open not implemented for provider',
178
+ ),
179
+ });
180
+ }
181
+ if (!flags.head || !flags.base || !flags.title) {
182
+ throw Object.assign(new Error('--head, --base, and --title required'), {
183
+ forgeError: forgeError(
184
+ ERROR_CODES.INVALID_ARGS,
185
+ '--head, --base, and --title required for cr open',
186
+ ),
187
+ });
188
+ }
189
+ assertCrOpenBranchRef(flags.head, '--head');
190
+ assertCrOpenBranchRef(flags.base, '--base');
191
+ assertWriteCommandConfigured(ctx.writePolicy ?? ctx.config, 'cr_open');
192
+ const idempotencyFingerprint = flags.idempotency_key
193
+ ? bindIdempotencyScope(ctx.repo_id, flags.idempotency_key, [flags.head, flags.base])
194
+ : null;
195
+ return forgePacket(
196
+ PACKET_TYPES.CHANGE_REQUEST_OPENED,
197
+ ctx,
198
+ await provider.crOpen(ctx, {
199
+ head: flags.head,
200
+ base: flags.base,
201
+ title: flags.title,
202
+ body: flags.body,
203
+ idempotencyFingerprint,
204
+ }),
205
+ );
206
+ }
207
+ if (group === 'issue' && sub === 'open') {
208
+ if (typeof provider.issueOpen !== 'function') {
209
+ throw Object.assign(new Error('issue open not implemented for provider'), {
210
+ forgeError: forgeError(
211
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
212
+ 'issue open not implemented for provider',
213
+ ),
214
+ });
215
+ }
216
+ if (!flags.title) {
217
+ throw Object.assign(new Error('--title required'), {
218
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--title required for issue open'),
219
+ });
220
+ }
221
+ assertWriteCommandConfigured(ctx.writePolicy ?? ctx.config, 'issue_open');
222
+ const idempotencyFingerprint = flags.idempotency_key
223
+ ? bindIdempotencyScope(ctx.repo_id, flags.idempotency_key, [flags.title])
224
+ : null;
225
+ return forgePacket(
226
+ PACKET_TYPES.ISSUE_OPENED,
227
+ ctx,
228
+ await provider.issueOpen(ctx, {
229
+ title: flags.title,
230
+ body: flags.body,
231
+ idempotencyFingerprint,
232
+ }),
233
+ );
234
+ }
235
+ if (group === 'status' && sub === 'set') {
236
+ if (typeof provider.statusSet !== 'function') {
237
+ throw Object.assign(new Error('status set not implemented for provider'), {
238
+ forgeError: forgeError(
239
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
240
+ 'status set not implemented for provider',
241
+ ),
242
+ });
243
+ }
244
+ assertWriteCommandConfigured(ctx.writePolicy ?? ctx.config, 'status_set');
245
+ const idempotencyFingerprint = flags.idempotency_key
246
+ ? bindIdempotencyScope(ctx.repo_id, flags.idempotency_key, [
247
+ flags.sha,
248
+ flags.context,
249
+ flags.state,
250
+ ])
251
+ : null;
252
+ return forgePacket(
253
+ PACKET_TYPES.COMMIT_STATUS_SET,
254
+ ctx,
255
+ await provider.statusSet(ctx, {
256
+ sha: flags.sha,
257
+ context: flags.context,
258
+ state: flags.state,
259
+ target_url: flags.target_url,
260
+ description: flags.description,
261
+ idempotencyFingerprint,
262
+ }),
263
+ );
264
+ }
265
+ if (group === 'pr' && sub === 'view') {
266
+ const number = parsePositiveInt(flags.number, '--number');
267
+ if (number == null) {
268
+ throw Object.assign(new Error('--number required'), {
269
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--number required for pr view'),
270
+ });
271
+ }
272
+ const body = await provider.prView(ctx, { number });
273
+ throwIfStaleHeadByNumber(
274
+ ctx,
275
+ PACKET_TYPES.PR_STATUS,
276
+ body,
277
+ body.forge_source_branch_ref,
278
+ body.forge_source_sha,
279
+ );
280
+ return forgePacket(PACKET_TYPES.PR_STATUS, ctx, body);
281
+ }
282
+ if (group === 'pr' && sub === 'checks') {
283
+ const number = parsePositiveInt(flags.number, '--number');
284
+ if (number == null && !flags.ref) {
285
+ throw Object.assign(new Error('--number or --ref required'), {
286
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--number or --ref required for pr checks'),
287
+ });
288
+ }
289
+ if (flags.ref) assertGitRef(flags.ref, '--ref');
290
+ if (number != null && !flags.ref) {
291
+ const view = await provider.prView(ctx, { number });
292
+ throwIfStaleHeadByNumber(
293
+ ctx,
294
+ PACKET_TYPES.PR_CHECKS,
295
+ { forge_source_sha: view.forge_source_sha },
296
+ view.forge_source_branch_ref,
297
+ view.forge_source_sha,
298
+ );
299
+ }
300
+ return forgePacket(
301
+ PACKET_TYPES.PR_CHECKS,
302
+ ctx,
303
+ await provider.prChecks(ctx, { number, ref: flags.ref }),
304
+ );
305
+ }
306
+ if (group === 'merge' && sub === 'plan') {
307
+ const number = parsePositiveInt(flags.number, '--number');
308
+ if (number == null) {
309
+ throw Object.assign(new Error('--number required'), {
310
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--number required for merge plan'),
311
+ });
312
+ }
313
+ const allowedPaths = normalizeAllowedPaths(parseAllowedPathFlags(flags) ?? []);
314
+ return forgePacket(
315
+ PACKET_TYPES.MERGE_PLAN,
316
+ ctx,
317
+ await provider.mergePlan(ctx, {
318
+ number,
319
+ ...(allowedPaths ? { allowed_paths: allowedPaths } : {}),
320
+ }),
321
+ );
322
+ }
323
+ if (group === 'merge' && sub === 'execute') {
324
+ if (typeof provider.mergeExecute !== 'function') {
325
+ throw Object.assign(new Error('merge execute not implemented for provider'), {
326
+ forgeError: forgeError(
327
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
328
+ 'merge execute not implemented for provider',
329
+ ),
330
+ });
331
+ }
332
+ const number = parsePositiveInt(flags.number, '--number');
333
+ if (number == null) {
334
+ throw Object.assign(new Error('--number required'), {
335
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--number required for merge execute'),
336
+ });
337
+ }
338
+ if (!flags.expected_base_sha || !flags.expected_head_sha) {
339
+ throw Object.assign(new Error('expected SHAs required'), {
340
+ forgeError: forgeError(
341
+ ERROR_CODES.INVALID_ARGS,
342
+ '--expected-base-sha and --expected-head-sha required for merge execute',
343
+ ),
344
+ });
345
+ }
346
+ const method = flags.method ? String(flags.method).toLowerCase() : 'merge';
347
+ if (method !== 'merge') {
348
+ throw Object.assign(new Error('Unsupported merge method'), {
349
+ forgeError: forgeError(
350
+ ERROR_CODES.INVALID_ARGS,
351
+ 'Only --method merge is supported in v1',
352
+ ),
353
+ });
354
+ }
355
+ const expectedBaseSha = assertExpectedSha(flags.expected_base_sha, '--expected-base-sha');
356
+ const expectedHeadSha = assertExpectedSha(flags.expected_head_sha, '--expected-head-sha');
357
+ assertWriteCommandConfigured(ctx.writePolicy ?? ctx.config, 'merge');
358
+
359
+ const view = await provider.prView(ctx, { number });
360
+ const checks = await provider.prChecks(ctx, { number });
361
+ const mergePlanBody = await provider.mergePlan(ctx, { number });
362
+ const expected = { baseSha: expectedBaseSha, headSha: expectedHeadSha };
363
+ const viewFacts = mergeExecuteViewFacts(view);
364
+
365
+ let forgeHeadRefSha = null;
366
+ const headRef = viewFacts.sourceBranchRef ? String(viewFacts.sourceBranchRef).trim() : '';
367
+ if (!headRef && isOpenPrState(view.state)) {
368
+ const before = buildMergeExecuteBeforeFacts(
369
+ view,
370
+ checks,
371
+ mergePlanBody,
372
+ null,
373
+ ctx.mergePolicy,
374
+ );
375
+ return forgePacket(
376
+ PACKET_TYPES.CR_MERGE_BLOCKED,
377
+ ctx,
378
+ buildCrMergeBlockedBody({
379
+ prNumber: number,
380
+ expected,
381
+ before,
382
+ blockers: ['head_ref_missing'],
383
+ }),
384
+ forgeError(ERROR_CODES.MERGE_BLOCKED, 'Open change request missing head branch ref'),
385
+ );
386
+ }
387
+ if (headRef) {
388
+ if (typeof provider.branchHeadSha !== 'function') {
389
+ const before = buildMergeExecuteBeforeFacts(
390
+ view,
391
+ checks,
392
+ mergePlanBody,
393
+ null,
394
+ ctx.mergePolicy,
395
+ );
396
+ return forgePacket(
397
+ PACKET_TYPES.CR_MERGE_BLOCKED,
398
+ ctx,
399
+ buildCrMergeBlockedBody({
400
+ prNumber: number,
401
+ expected,
402
+ before,
403
+ blockers: ['head_ref_unverified'],
404
+ }),
405
+ forgeError(
406
+ ERROR_CODES.MERGE_BLOCKED,
407
+ 'Forge head branch verification not implemented for provider',
408
+ ),
409
+ );
410
+ }
411
+ try {
412
+ assertGitRef(headRef, 'head_ref');
413
+ } catch (err) {
414
+ const before = buildMergeExecuteBeforeFacts(
415
+ view,
416
+ checks,
417
+ mergePlanBody,
418
+ null,
419
+ ctx.mergePolicy,
420
+ );
421
+ return forgePacket(
422
+ PACKET_TYPES.CR_MERGE_BLOCKED,
423
+ ctx,
424
+ buildCrMergeBlockedBody({
425
+ prNumber: number,
426
+ expected,
427
+ before,
428
+ blockers: ['head_ref_invalid'],
429
+ }),
430
+ forgeError(
431
+ ERROR_CODES.INVALID_ARGS,
432
+ sanitizeField(err.forgeError?.message || err.message || err.invalidArgs)
433
+ || 'Head branch ref invalid',
434
+ ),
435
+ );
436
+ }
437
+ try {
438
+ forgeHeadRefSha = await provider.branchHeadSha(ctx, headRef, {
439
+ repoId: view.forge_source_repo_id ?? null,
440
+ });
441
+ } catch (err) {
442
+ if (err.forgeError?.code === ERROR_CODES.INVALID_ARGS) {
443
+ throw err;
444
+ }
445
+ const before = buildMergeExecuteBeforeFacts(
446
+ view,
447
+ checks,
448
+ mergePlanBody,
449
+ null,
450
+ ctx.mergePolicy,
451
+ );
452
+ return forgePacket(
453
+ PACKET_TYPES.CR_MERGE_BLOCKED,
454
+ ctx,
455
+ buildCrMergeBlockedBody({
456
+ prNumber: number,
457
+ expected,
458
+ before,
459
+ blockers: ['head_ref_unreadable'],
460
+ }),
461
+ forgeError(
462
+ ERROR_CODES.MERGE_BLOCKED,
463
+ sanitizeField(err.forgeError?.message || err.message) || 'Head branch ref unreadable',
464
+ err.status ?? err.forgeError?.status ?? null,
465
+ ),
466
+ );
467
+ }
468
+ }
469
+
470
+ const before = buildMergeExecuteBeforeFacts(
471
+ view,
472
+ checks,
473
+ mergePlanBody,
474
+ forgeHeadRefSha,
475
+ ctx.mergePolicy,
476
+ );
477
+ const blockers = collectMergeExecuteBlockers(
478
+ view,
479
+ checks,
480
+ mergePlanBody,
481
+ expected,
482
+ { forgeHeadRefSha, mergePolicy: ctx.mergePolicy },
483
+ );
484
+
485
+ if (blockers.length > 0) {
486
+ return forgePacket(
487
+ PACKET_TYPES.CR_MERGE_BLOCKED,
488
+ ctx,
489
+ buildCrMergeBlockedBody({ prNumber: number, expected, before, blockers }),
490
+ forgeError(ERROR_CODES.MERGE_BLOCKED, 'Merge blocked by preflight'),
491
+ );
492
+ }
493
+
494
+ try {
495
+ const providerResult = await provider.mergeExecute(ctx, {
496
+ number,
497
+ method,
498
+ expectedHeadSha: expected.headSha,
499
+ });
500
+ const merge = buildMergeExecuteMergeFacts(method, providerResult);
501
+ const after = buildMergeExecuteAfterFacts(view, providerResult);
502
+ return forgePacket(
503
+ PACKET_TYPES.CR_MERGED,
504
+ ctx,
505
+ buildCrMergedBody({ prNumber: number, expected, before, merge, after }),
506
+ );
507
+ } catch (err) {
508
+ const status = err.status ?? err.forgeError?.status ?? null;
509
+ const blockers =
510
+ Array.isArray(err.mergeBlockedBlockers) && err.mergeBlockedBlockers.length > 0
511
+ ? err.mergeBlockedBlockers
512
+ : ['merge_endpoint_failed'];
513
+ const fe =
514
+ err.forgeError
515
+ ?? forgeError(
516
+ ERROR_CODES.MERGE_ENDPOINT_FAILED,
517
+ sanitizeField(err.message) || 'Forge merge request failed',
518
+ status,
519
+ );
520
+ return forgePacket(
521
+ PACKET_TYPES.CR_MERGE_BLOCKED,
522
+ ctx,
523
+ buildCrMergeBlockedBody({
524
+ prNumber: number,
525
+ expected,
526
+ before,
527
+ blockers,
528
+ }),
529
+ fe,
530
+ );
531
+ }
532
+ }
533
+ if (group === 'branch' && sub === 'protection') {
534
+ const branchRef = flags.branch_ref;
535
+ if (!branchRef) {
536
+ throw Object.assign(new Error('--branch-ref required'), {
537
+ forgeError: forgeError(
538
+ ERROR_CODES.INVALID_ARGS,
539
+ '--branch-ref required for branch protection',
540
+ ),
541
+ });
542
+ }
543
+ assertGitRef(branchRef, '--branch-ref');
544
+ if (typeof provider.branchProtection !== 'function') {
545
+ throw Object.assign(new Error('branch protection not implemented for provider'), {
546
+ forgeError: forgeError(
547
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
548
+ 'branch protection not implemented for provider',
549
+ ),
550
+ });
551
+ }
552
+ return forgePacket(
553
+ PACKET_TYPES.BRANCH_PROTECTION,
554
+ ctx,
555
+ await provider.branchProtection(ctx, { branchRef }),
556
+ );
557
+ }
558
+ if (group === 'whoami' && sub == null) {
559
+ if (typeof provider.whoami !== 'function') {
560
+ throw Object.assign(new Error('whoami not implemented for provider'), {
561
+ forgeError: forgeError(
562
+ ERROR_CODES.PROVIDER_UNSUPPORTED,
563
+ 'whoami not implemented for provider',
564
+ ),
565
+ });
566
+ }
567
+ return forgePacket(PACKET_TYPES.PROVIDER_IDENTITY, ctx, await provider.whoami(ctx));
568
+ }
569
+ if (group === 'sync' && sub === 'plan') {
570
+ const remote = flags.remote || ctx.config.remote;
571
+ assertGitRemote(remote, '--remote');
572
+ return forgePacket(
573
+ PACKET_TYPES.SYNC_PLAN,
574
+ ctx,
575
+ await provider.syncPlan(ctx, remote),
576
+ );
577
+ }
578
+
579
+ throw Object.assign(new Error(`Unknown command: ${positional.join(' ')}`), {
580
+ forgeError: forgeError(
581
+ ERROR_CODES.INVALID_ARGS,
582
+ 'Unknown command. Try: provider capabilities, repo status, refs compare, refs inventory, cr inventory, cr files, cr comments, cr open, status set, forge changes, pr view, pr checks, merge plan, merge execute, sync plan, whoami, branch protection',
583
+ ),
584
+ });
585
+ }
package/cli-doctor.js ADDED
@@ -0,0 +1,358 @@
1
+ import {
2
+ loadConfig,
3
+ findConfigPath,
4
+ gitRemoteUrl,
5
+ parseRemoteUrl,
6
+ trustedBaseUrl,
7
+ normalizedForgeOrigin,
8
+ assertConfigMatchesRemote,
9
+ forgePacket,
10
+ unknownForgeContext,
11
+ PACKET_TYPES,
12
+ ERROR_CODES,
13
+ forgeError,
14
+ sanitizeField,
15
+ assertGitRemote,
16
+ getEffectiveIngestMaxBytes,
17
+ FORGE_INGEST_MAX_BYTES_ENV,
18
+ MAX_FORGE_INGEST_ENV_BYTES,
19
+ resolveMergePolicy,
20
+ ALLOW_MISSING_CHECKS_ENV,
21
+ ALLOW_PENDING_CHECKS_ENV,
22
+ buildWriteReadiness,
23
+ writeReadinessHasWarnings,
24
+ buildApiReachabilityCheck,
25
+ resolveWritePolicyForForge,
26
+ forgeWriteFieldCapabilityFacts,
27
+ resolveEffectiveWriteFieldPolicy,
28
+ loadOperatorConfig,
29
+ WRITE_FIELD_MAX_BYTES_ENV,
30
+ MAX_WRITE_FIELD_ENV_BYTES,
31
+ } from '@remogram/core';
32
+ import { contextFromConfig } from './cli-io.js';
33
+
34
+ export function doctorCheck(name, status, message, details = null) {
35
+ return {
36
+ name,
37
+ status,
38
+ message: sanitizeField(message),
39
+ ...(details ? { details } : {}),
40
+ };
41
+ }
42
+
43
+ export function doctorSummary(checks) {
44
+ if (checks.some((check) => check.status === 'fail')) return 'fail';
45
+ if (checks.some((check) => check.status === 'warn')) return 'warn';
46
+ return 'pass';
47
+ }
48
+
49
+ function finalizeDoctorPacket(ctx, checks, providerCapabilities, writeConfig = null) {
50
+ const summary = doctorSummary(checks);
51
+ const error =
52
+ summary === 'fail'
53
+ ? forgeError(ERROR_CODES.CONFIG_INVALID, 'Doctor checks failed')
54
+ : null;
55
+ const body = {
56
+ summary,
57
+ checks,
58
+ provider_capabilities: providerCapabilities,
59
+ };
60
+ if (writeConfig) body.write_config = writeConfig;
61
+ return forgePacket(PACKET_TYPES.PROVIDER_DOCTOR, ctx, body, error);
62
+ }
63
+
64
+ export async function buildDoctorPacket(cwd, providers, options = {}) {
65
+ const { live = false, operatorConfigPath = null } = options;
66
+ const checks = [];
67
+ const configPath = findConfigPath(cwd);
68
+ let loaded = null;
69
+ let config = null;
70
+ let parsed = null;
71
+ let ctx = unknownForgeContext();
72
+ let providerCapabilities = null;
73
+
74
+ if (!configPath) {
75
+ checks.push(doctorCheck('config', 'fail', 'No .remogram.json found'));
76
+ return finalizeDoctorPacket(ctx, checks, null);
77
+ }
78
+
79
+ try {
80
+ loaded = loadConfig(cwd);
81
+ config = loaded.config;
82
+ ctx = contextFromConfig(config, loaded.cwd);
83
+ checks.push(doctorCheck('config', 'pass', '.remogram.json is present and valid'));
84
+ } catch (err) {
85
+ checks.push(doctorCheck('config', 'fail', err.forgeError?.message || err.message));
86
+ return finalizeDoctorPacket(ctx, checks, null);
87
+ }
88
+
89
+ const provider = providers[config.provider];
90
+ if (!provider) {
91
+ checks.push(doctorCheck('provider', 'fail', `Unsupported provider: ${config.provider}`));
92
+ }
93
+
94
+ try {
95
+ assertGitRemote(config.remote, 'config.remote');
96
+ const remoteUrl = gitRemoteUrl(loaded.cwd, config.remote);
97
+ parsed = parseRemoteUrl(remoteUrl);
98
+ if (!parsed) {
99
+ checks.push(doctorCheck('remote', 'fail', 'Could not parse git remote URL'));
100
+ } else {
101
+ ctx = contextFromConfig(config, loaded.cwd, parsed);
102
+ checks.push(doctorCheck('remote', 'pass', 'Git remote URL parses successfully', {
103
+ host: sanitizeField(parsed.host),
104
+ owner: sanitizeField(parsed.owner),
105
+ repo: sanitizeField(parsed.repo),
106
+ }));
107
+ }
108
+ } catch (err) {
109
+ checks.push(doctorCheck('remote', 'fail', err.forgeError?.message || err.message));
110
+ }
111
+
112
+ if (parsed) {
113
+ try {
114
+ assertConfigMatchesRemote(config, parsed);
115
+ checks.push(doctorCheck('repo_match', 'pass', 'Config owner/repo matches git remote'));
116
+ } catch (err) {
117
+ checks.push(doctorCheck('repo_match', 'fail', err.forgeError?.message || err.message));
118
+ }
119
+
120
+ if (config.baseUrl && !trustedBaseUrl(config, parsed.host)) {
121
+ checks.push(
122
+ doctorCheck('host_binding', 'fail', `baseUrl host does not match remote host ${parsed.host}`),
123
+ );
124
+ } else {
125
+ checks.push(doctorCheck('host_binding', 'pass', 'Configured host binding is trusted'));
126
+ if (config.baseUrl) {
127
+ ctx = { ...ctx, baseUrl: normalizedForgeOrigin(config) };
128
+ }
129
+ }
130
+ }
131
+
132
+ if (parsed && loaded) {
133
+ const operatorLoad = loadOperatorConfig({ cliPath: operatorConfigPath, forgeContext: ctx });
134
+ ctx.writeFieldPolicy = resolveEffectiveWriteFieldPolicy(config, operatorLoad);
135
+ }
136
+
137
+ if (provider) {
138
+ if (typeof provider.providerCapabilities === 'function') {
139
+ providerCapabilities = await provider.providerCapabilities(ctx);
140
+ const stubProvider =
141
+ providerCapabilities.commands?.length > 0
142
+ && providerCapabilities.commands.every((command) => command.implemented === false);
143
+ checks.push(
144
+ doctorCheck(
145
+ 'provider',
146
+ stubProvider ? 'warn' : 'pass',
147
+ stubProvider
148
+ ? `${config.provider} is not fully supported in v1; use an *-api provider`
149
+ : `${config.provider} is registered`,
150
+ ),
151
+ );
152
+ checks.push(doctorCheck('capabilities', 'pass', 'Provider capabilities are available'));
153
+ } else {
154
+ checks.push(doctorCheck('provider', 'pass', `${config.provider} is registered`));
155
+ checks.push(doctorCheck('capabilities', 'fail', 'Provider capabilities are not implemented'));
156
+ }
157
+ }
158
+
159
+ let writeConfig = null;
160
+
161
+ if (providerCapabilities) {
162
+ const envNames = providerCapabilities.auth_envs || [];
163
+ const presentEnv = envNames.find((name) => Boolean(process.env[name])) || null;
164
+ const authPresent = Boolean(presentEnv);
165
+ checks.push(
166
+ doctorCheck(
167
+ 'auth',
168
+ presentEnv ? 'pass' : 'warn',
169
+ presentEnv ? `${presentEnv} is present` : 'No provider auth environment variable is set',
170
+ { env_names: envNames, present_env: presentEnv },
171
+ ),
172
+ );
173
+
174
+ if (providerCapabilities.write_support) {
175
+ const writePolicy = resolveWritePolicyForForge(ctx, { operatorConfigPath });
176
+ writeConfig = buildWriteReadiness(writePolicy, providerCapabilities, { authPresent });
177
+ const warn = writeReadinessHasWarnings(writeConfig);
178
+ const notReady = writeConfig.commands.filter(
179
+ (entry) => entry.provider_supported && !entry.ready,
180
+ );
181
+ const missingConfig = notReady.filter((entry) => !entry.configured).map((entry) => entry.id);
182
+ if (writePolicy.operatorMeta?.discovered_via && writePolicy.operatorMeta.discovered_via !== 'none') {
183
+ if (writePolicy.operatorError) {
184
+ checks.push(
185
+ doctorCheck(
186
+ 'operator_config',
187
+ 'fail',
188
+ writePolicy.operatorError.message,
189
+ {
190
+ ...writePolicy.operatorMeta,
191
+ ...(writePolicy.operatorError.fields ? { bind_error: writePolicy.operatorError.fields } : {}),
192
+ },
193
+ ),
194
+ );
195
+ } else {
196
+ checks.push(
197
+ doctorCheck(
198
+ 'operator_config',
199
+ 'pass',
200
+ 'Operator write overlay loaded and bound to forge identity',
201
+ writePolicy.operatorMeta,
202
+ ),
203
+ );
204
+ }
205
+ } else {
206
+ checks.push(
207
+ doctorCheck(
208
+ 'operator_config',
209
+ 'pass',
210
+ 'No operator write overlay configured (repo-only write policy)',
211
+ { discovered_via: 'none', path: null, bind_ok: null },
212
+ ),
213
+ );
214
+ }
215
+ checks.push(
216
+ doctorCheck(
217
+ 'write_config',
218
+ warn ? 'warn' : 'pass',
219
+ warn
220
+ ? missingConfig.length
221
+ ? `Provider supports write commands but effective write_commands omits: ${missingConfig.join(', ')}. Add ids to .remogram.json or a bound operator config (REMOGRAM_OPERATOR_CONFIG / --operator-config) for Remogram CLI/MCP writes, or use forge/CI tooling for those actions outside Remogram.`
222
+ : writePolicy.operatorError
223
+ ? 'Operator write overlay failed validation; writes are blocked until bind and schema checks pass'
224
+ : 'One or more configured write commands are not ready (check auth or provider support)'
225
+ : 'All provider write commands are configured and ready',
226
+ writeConfig,
227
+ ),
228
+ );
229
+ }
230
+
231
+ if (!providerCapabilities.check_sources?.length) {
232
+ checks.push(doctorCheck('checks', 'warn', 'Provider does not report forge check sources'));
233
+ } else {
234
+ checks.push(doctorCheck('checks', 'pass', 'Provider reports forge check sources', {
235
+ sources: providerCapabilities.check_sources,
236
+ }));
237
+ }
238
+ }
239
+
240
+ const { bytes: ingestCapBytes, envOverride: ingestEnvOverride, invalidEnv: ingestInvalidEnv, clamped: ingestClamped } =
241
+ getEffectiveIngestMaxBytes();
242
+ if (ingestInvalidEnv) {
243
+ checks.push(
244
+ doctorCheck(
245
+ 'forge_ingest_cap',
246
+ 'warn',
247
+ `${FORGE_INGEST_MAX_BYTES_ENV} is invalid; using default 8192 bytes`,
248
+ { effective_bytes: ingestCapBytes, env_override: false },
249
+ ),
250
+ );
251
+ } else if (ingestEnvOverride) {
252
+ checks.push(
253
+ doctorCheck(
254
+ 'forge_ingest_cap',
255
+ 'warn',
256
+ ingestClamped
257
+ ? `${FORGE_INGEST_MAX_BYTES_ENV} exceeds max ${MAX_FORGE_INGEST_ENV_BYTES}; clamped — agent-safe guarantee is weakened`
258
+ : `${FORGE_INGEST_MAX_BYTES_ENV} overrides default ingest cap; agent-safe guarantee is weakened`,
259
+ { effective_bytes: ingestCapBytes, env_override: true, ...(ingestClamped ? { clamped: true } : {}) },
260
+ ),
261
+ );
262
+ } else {
263
+ checks.push(
264
+ doctorCheck(
265
+ 'forge_ingest_cap',
266
+ 'pass',
267
+ 'Forge HTTP ingest cap is default 8192 bytes',
268
+ { effective_bytes: ingestCapBytes, env_override: false },
269
+ ),
270
+ );
271
+ }
272
+
273
+ const writeFieldFacts = forgeWriteFieldCapabilityFacts(ctx.writeFieldPolicy);
274
+ if (writeFieldFacts.write_field_env_invalid) {
275
+ checks.push(
276
+ doctorCheck(
277
+ 'forge_write_field_cap',
278
+ 'warn',
279
+ `${WRITE_FIELD_MAX_BYTES_ENV} is invalid; using configured/default write field cap`,
280
+ writeFieldFacts,
281
+ ),
282
+ );
283
+ } else if (writeFieldFacts.write_field_env_override) {
284
+ checks.push(
285
+ doctorCheck(
286
+ 'forge_write_field_cap',
287
+ 'warn',
288
+ writeFieldFacts.write_field_cap_clamped
289
+ ? `${WRITE_FIELD_MAX_BYTES_ENV} exceeds max ${MAX_WRITE_FIELD_ENV_BYTES}; clamped — agent-safe guarantee is weakened`
290
+ : writeFieldFacts.write_field_uncapped
291
+ ? `${WRITE_FIELD_MAX_BYTES_ENV} disables forge write field cap; agent-safe guarantee is weakened`
292
+ : `${WRITE_FIELD_MAX_BYTES_ENV} overrides forge write field cap; agent-safe guarantee is weakened`,
293
+ writeFieldFacts,
294
+ ),
295
+ );
296
+ } else if (writeFieldFacts.write_field_uncapped) {
297
+ checks.push(
298
+ doctorCheck(
299
+ 'forge_write_field_cap',
300
+ 'warn',
301
+ 'Forge write bodies are uncapped for this binding (read packets remain capped at 512 bytes)',
302
+ writeFieldFacts,
303
+ ),
304
+ );
305
+ } else {
306
+ checks.push(
307
+ doctorCheck(
308
+ 'forge_write_field_cap',
309
+ 'pass',
310
+ `Forge write field cap is ${writeFieldFacts.write_field_max_bytes} bytes (read cap fixed at 512)`,
311
+ writeFieldFacts,
312
+ ),
313
+ );
314
+ }
315
+
316
+ const mergePolicy = resolveMergePolicy(config);
317
+ if (mergePolicy.allow_missing_checks || mergePolicy.allow_pending_checks) {
318
+ checks.push(
319
+ doctorCheck(
320
+ 'merge_policy',
321
+ 'warn',
322
+ 'Merge policy relaxes check blockers for merge plan and merge execute',
323
+ {
324
+ allow_missing_checks: mergePolicy.allow_missing_checks,
325
+ allow_pending_checks: mergePolicy.allow_pending_checks,
326
+ source: mergePolicy.source,
327
+ env_names: [ALLOW_MISSING_CHECKS_ENV, ALLOW_PENDING_CHECKS_ENV],
328
+ },
329
+ ),
330
+ );
331
+ } else {
332
+ checks.push(
333
+ doctorCheck(
334
+ 'merge_policy',
335
+ 'pass',
336
+ 'Default merge policy — missing and pending checks block merge',
337
+ {
338
+ allow_missing_checks: false,
339
+ allow_pending_checks: false,
340
+ source: mergePolicy.source,
341
+ },
342
+ ),
343
+ );
344
+ }
345
+
346
+ const hostBindingPass = checks.some(
347
+ (check) => check.name === 'host_binding' && check.status === 'pass',
348
+ );
349
+ const configPass = checks.some((check) => check.name === 'config' && check.status === 'pass');
350
+ checks.push(
351
+ await buildApiReachabilityCheck(ctx, provider, {
352
+ live,
353
+ prerequisitesPass: live && configPass && hostBindingPass && parsed != null,
354
+ }),
355
+ );
356
+
357
+ return finalizeDoctorPacket(ctx, checks, providerCapabilities, writeConfig);
358
+ }
package/cli-io.js ADDED
@@ -0,0 +1,53 @@
1
+ import {
2
+ forgePacket,
3
+ forgeErrorPacket,
4
+ forgeError,
5
+ ERROR_CODES,
6
+ normalizedForgeOrigin,
7
+ trustedBaseUrl,
8
+ resolveMergePolicy,
9
+ } from '@remogram/core';
10
+
11
+ export function output(packet, asJson) {
12
+ console.log(JSON.stringify(packet, null, asJson ? 2 : 0));
13
+ }
14
+
15
+ export function handleError(err, ctx, asJson) {
16
+ const fe =
17
+ err.forgeError
18
+ || (err.invalidArgs
19
+ ? forgeError(ERROR_CODES.INVALID_ARGS, err.invalidArgs)
20
+ : {
21
+ code: ERROR_CODES.API_ERROR,
22
+ message: err.message,
23
+ status: err.status,
24
+ });
25
+ const baseCtx = ctx || {
26
+ providerId: 'unknown',
27
+ remoteName: 'origin',
28
+ repoId: 'unknown/unknown',
29
+ };
30
+ if (err.staleHeadPacket) {
31
+ output(forgePacket(err.staleHeadPacket.type, baseCtx, err.staleHeadPacket.body, fe), asJson);
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+ output(forgeErrorPacket(baseCtx, fe), asJson);
36
+ process.exitCode = 1;
37
+ }
38
+
39
+ export function contextFromConfig(config, cwd, parsed = null) {
40
+ const ctx = {
41
+ providerId: config.provider,
42
+ remoteName: config.remote,
43
+ repoId: parsed ? `${parsed.owner}/${parsed.repo}` : `${config.owner}/${config.repo}`,
44
+ config,
45
+ cwd,
46
+ parsed,
47
+ };
48
+ if (config.baseUrl && (!parsed || trustedBaseUrl(config, parsed.host))) {
49
+ ctx.baseUrl = normalizedForgeOrigin(config);
50
+ }
51
+ ctx.mergePolicy = resolveMergePolicy(config);
52
+ return ctx;
53
+ }
package/index.js CHANGED
@@ -1,27 +1,19 @@
1
1
  import {
2
2
  loadConfig,
3
- findConfigPath,
4
3
  assertForgeReady,
5
- gitRemoteUrl,
6
- parseRemoteUrl,
7
- trustedBaseUrl,
8
- assertConfigMatchesRemote,
9
4
  forgeContext,
10
- forgePacket,
11
- forgeErrorPacket,
12
- unknownForgeContext,
13
- PACKET_TYPES,
14
5
  ERROR_CODES,
15
6
  forgeError,
16
- sanitizeField,
17
- assertGitRef,
18
- assertGitRemote,
19
7
  } from '@remogram/core';
20
8
  import { provider as giteaApi } from '@remogram/provider-gitea-api';
21
9
  import { provider as githubApi } from '@remogram/provider-github-api';
22
10
  import { provider as gitlabApi } from '@remogram/provider-gitlab-api';
23
11
  import { provider as giteaTea } from '@remogram/provider-gitea-tea';
24
12
  import { provider as githubGh } from '@remogram/provider-github-gh';
13
+ import { output, handleError } from './cli-io.js';
14
+ import { parseCliArgv } from './cli-argv.js';
15
+ import { buildDoctorPacket } from './cli-doctor.js';
16
+ import { dispatchForgeCommand } from './cli-dispatch.js';
25
17
 
26
18
  const PROVIDERS = {
27
19
  'gitea-api': giteaApi,
@@ -31,208 +23,28 @@ const PROVIDERS = {
31
23
  'github-gh': githubGh,
32
24
  };
33
25
 
34
- function parsePositiveInt(value, name) {
35
- if (value == null) return undefined;
36
- const n = Number(value);
37
- if (!Number.isInteger(n) || n <= 0) {
38
- throw Object.assign(new Error(`Invalid ${name}`), {
39
- forgeError: forgeError(ERROR_CODES.INVALID_ARGS, `${name} must be a positive integer`),
40
- });
41
- }
42
- return n;
43
- }
44
-
45
- function output(packet, asJson) {
46
- console.log(JSON.stringify(packet, null, asJson ? 2 : 0));
47
- }
48
-
49
- function handleError(err, ctx, asJson) {
50
- const fe = err.forgeError || {
51
- code: ERROR_CODES.API_ERROR,
52
- message: err.message,
53
- status: err.status,
54
- };
55
- const baseCtx = ctx || {
56
- providerId: 'unknown',
57
- remoteName: 'origin',
58
- repoId: 'unknown/unknown',
59
- };
60
- output(forgeErrorPacket(baseCtx, fe), asJson);
61
- process.exitCode = 1;
62
- }
63
-
64
- function doctorCheck(name, status, message, details = null) {
65
- return {
66
- name,
67
- status,
68
- message: sanitizeField(message),
69
- ...(details ? { details } : {}),
70
- };
71
- }
72
-
73
- function doctorSummary(checks) {
74
- if (checks.some((check) => check.status === 'fail')) return 'fail';
75
- if (checks.some((check) => check.status === 'warn')) return 'warn';
76
- return 'pass';
77
- }
78
-
79
- function contextFromConfig(config, cwd, parsed = null) {
80
- return {
81
- providerId: config.provider,
82
- remoteName: config.remote,
83
- repoId: parsed ? `${parsed.owner}/${parsed.repo}` : `${config.owner}/${config.repo}`,
84
- config,
85
- cwd,
86
- parsed,
87
- };
88
- }
89
-
90
- async function buildDoctorPacket(cwd, providers) {
91
- const checks = [];
92
- const configPath = findConfigPath(cwd);
93
- let loaded = null;
94
- let config = null;
95
- let parsed = null;
96
- let ctx = unknownForgeContext();
97
- let providerCapabilities = null;
98
-
99
- if (!configPath) {
100
- checks.push(doctorCheck('config', 'fail', 'No .remogram.json found'));
101
- return forgePacket(PACKET_TYPES.PROVIDER_DOCTOR, ctx, {
102
- summary: doctorSummary(checks),
103
- checks,
104
- provider_capabilities: null,
105
- });
106
- }
107
-
108
- try {
109
- loaded = loadConfig(cwd);
110
- config = loaded.config;
111
- ctx = contextFromConfig(config, loaded.cwd);
112
- checks.push(doctorCheck('config', 'pass', '.remogram.json is present and valid'));
113
- } catch (err) {
114
- checks.push(doctorCheck('config', 'fail', err.forgeError?.message || err.message));
115
- return forgePacket(PACKET_TYPES.PROVIDER_DOCTOR, ctx, {
116
- summary: doctorSummary(checks),
117
- checks,
118
- provider_capabilities: null,
119
- });
120
- }
121
-
122
- const provider = providers[config.provider];
123
- if (!provider) {
124
- checks.push(doctorCheck('provider', 'fail', `Unsupported provider: ${config.provider}`));
125
- } else {
126
- checks.push(doctorCheck('provider', 'pass', `${config.provider} is registered`));
127
- if (typeof provider.providerCapabilities === 'function') {
128
- providerCapabilities = await provider.providerCapabilities(ctx);
129
- checks.push(doctorCheck('capabilities', 'pass', 'Provider capabilities are available'));
130
- } else {
131
- checks.push(doctorCheck('capabilities', 'fail', 'Provider capabilities are not implemented'));
132
- }
133
- }
134
-
135
- let remoteUrl = null;
136
- try {
137
- assertGitRemote(config.remote, 'config.remote');
138
- remoteUrl = gitRemoteUrl(loaded.cwd, config.remote);
139
- parsed = parseRemoteUrl(remoteUrl);
140
- if (!parsed) {
141
- checks.push(doctorCheck('remote', 'fail', 'Could not parse git remote URL'));
142
- } else {
143
- ctx = contextFromConfig(config, loaded.cwd, parsed);
144
- checks.push(doctorCheck('remote', 'pass', 'Git remote URL parses successfully', {
145
- host: sanitizeField(parsed.host),
146
- owner: sanitizeField(parsed.owner),
147
- repo: sanitizeField(parsed.repo),
148
- }));
149
- }
150
- } catch (err) {
151
- checks.push(doctorCheck('remote', 'fail', err.forgeError?.message || err.message));
152
- }
153
-
154
- if (parsed) {
155
- try {
156
- assertConfigMatchesRemote(config, parsed);
157
- checks.push(doctorCheck('repo_match', 'pass', 'Config owner/repo matches git remote'));
158
- } catch (err) {
159
- checks.push(doctorCheck('repo_match', 'fail', err.forgeError?.message || err.message));
160
- }
161
-
162
- if (config.baseUrl && !trustedBaseUrl(config, parsed.host)) {
163
- checks.push(
164
- doctorCheck('host_binding', 'fail', `baseUrl host does not match remote host ${parsed.host}`),
165
- );
166
- } else {
167
- checks.push(doctorCheck('host_binding', 'pass', 'Configured host binding is trusted'));
168
- }
169
- }
170
-
171
- if (providerCapabilities) {
172
- const envNames = providerCapabilities.auth_envs || [];
173
- const presentEnv = envNames.find((name) => Boolean(process.env[name])) || null;
174
- checks.push(
175
- doctorCheck(
176
- 'auth',
177
- presentEnv ? 'pass' : 'warn',
178
- presentEnv ? `${presentEnv} is present` : 'No provider auth environment variable is set',
179
- { env_names: envNames, present_env: presentEnv },
180
- ),
181
- );
182
-
183
- if (!providerCapabilities.check_sources?.length) {
184
- checks.push(doctorCheck('checks', 'warn', 'Provider does not report forge check sources'));
185
- } else {
186
- checks.push(doctorCheck('checks', 'pass', 'Provider reports forge check sources', {
187
- sources: providerCapabilities.check_sources,
188
- }));
189
- }
190
- }
191
-
192
- checks.push(doctorCheck('api_reachability', 'skipped', 'Live API reachability is not checked by default'));
193
-
194
- return forgePacket(PACKET_TYPES.PROVIDER_DOCTOR, ctx, {
195
- summary: doctorSummary(checks),
196
- checks,
197
- provider_capabilities: providerCapabilities,
198
- });
199
- }
200
-
201
26
  export async function runCli(argv, options = {}) {
202
27
  const cwd = options.cwd ?? process.env.REMOGRAM_CWD ?? process.cwd();
203
28
  const providers = options.providers ?? PROVIDERS;
204
- const positional = [];
205
- let asJson = false;
206
- const flags = {};
207
-
208
- for (let i = 0; i < argv.length; i += 1) {
209
- const arg = argv[i];
210
- if (arg === '--json') asJson = true;
211
- else if (arg.startsWith('--')) {
212
- const key = arg.slice(2).replace(/-/g, '_');
213
- const next = argv[i + 1];
214
- if (next != null && !next.startsWith('--')) {
215
- flags[key] = next;
216
- i += 1;
217
- } else {
218
- flags[key] = true;
219
- }
220
- } else {
221
- positional.push(arg);
222
- }
223
- }
224
-
29
+ const { positional, asJson, flags } = parseCliArgv(argv);
30
+ const operatorConfigPath =
31
+ options.operatorConfigPath ?? flags.operator_config ?? null;
225
32
  const [group, sub] = positional;
226
33
 
227
34
  if (group === 'doctor' && sub == null) {
228
- output(await buildDoctorPacket(cwd, providers), asJson);
35
+ const packet = await buildDoctorPacket(cwd, providers, {
36
+ live: flags.live === true,
37
+ operatorConfigPath,
38
+ });
39
+ output(packet, asJson);
40
+ if (!packet.ok) process.exitCode = 1;
229
41
  return;
230
42
  }
231
43
 
232
44
  let ctx;
233
45
  try {
234
46
  const loaded = assertForgeReady(loadConfig(cwd));
235
- ctx = forgeContext(loaded);
47
+ ctx = forgeContext(loaded, { operatorConfigPath });
236
48
  } catch (err) {
237
49
  handleError(err, null, asJson);
238
50
  return;
@@ -255,74 +67,9 @@ export async function runCli(argv, options = {}) {
255
67
  }
256
68
 
257
69
  try {
258
- let packet;
259
- if (group === 'provider' && sub === 'capabilities') {
260
- packet = forgePacket(
261
- PACKET_TYPES.PROVIDER_CAPABILITIES,
262
- ctx,
263
- await provider.providerCapabilities(ctx),
264
- );
265
- } else if (group === 'repo' && sub === 'status') {
266
- packet = forgePacket(PACKET_TYPES.REPO_STATUS, ctx, await provider.repoStatus(ctx));
267
- } else if (group === 'refs' && sub === 'compare') {
268
- if (!flags.base || !flags.head) {
269
- throw Object.assign(new Error('--base and --head required'), {
270
- forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--base and --head required'),
271
- });
272
- }
273
- assertGitRef(flags.base, '--base');
274
- assertGitRef(flags.head, '--head');
275
- packet = forgePacket(
276
- PACKET_TYPES.REF_COMPARE,
277
- ctx,
278
- await provider.refsCompare(ctx, flags.base, flags.head),
279
- );
280
- } else if (group === 'pr' && sub === 'view') {
281
- const number = parsePositiveInt(flags.number, '--number');
282
- if (number == null) {
283
- throw Object.assign(new Error('--number required'), {
284
- forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--number required for pr view'),
285
- });
286
- }
287
- packet = forgePacket(PACKET_TYPES.PR_STATUS, ctx, await provider.prView(ctx, { number }));
288
- } else if (group === 'pr' && sub === 'checks') {
289
- const number = parsePositiveInt(flags.number, '--number');
290
- if (number == null && !flags.ref) {
291
- throw Object.assign(new Error('--number or --ref required'), {
292
- forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--number or --ref required for pr checks'),
293
- });
294
- }
295
- if (flags.ref) assertGitRef(flags.ref, '--ref');
296
- packet = forgePacket(
297
- PACKET_TYPES.PR_CHECKS,
298
- ctx,
299
- await provider.prChecks(ctx, { number, ref: flags.ref }),
300
- );
301
- } else if (group === 'merge' && sub === 'plan') {
302
- const number = parsePositiveInt(flags.number, '--number');
303
- if (number == null) {
304
- throw Object.assign(new Error('--number required'), {
305
- forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--number required for merge plan'),
306
- });
307
- }
308
- packet = forgePacket(PACKET_TYPES.MERGE_PLAN, ctx, await provider.mergePlan(ctx, { number }));
309
- } else if (group === 'sync' && sub === 'plan') {
310
- const remote = flags.remote || ctx.config.remote;
311
- assertGitRemote(remote, '--remote');
312
- packet = forgePacket(
313
- PACKET_TYPES.SYNC_PLAN,
314
- ctx,
315
- await provider.syncPlan(ctx, remote),
316
- );
317
- } else {
318
- throw Object.assign(new Error(`Unknown command: ${positional.join(' ')}`), {
319
- forgeError: forgeError(
320
- ERROR_CODES.INVALID_ARGS,
321
- 'Unknown command. Try: provider capabilities, repo status, refs compare, pr view, pr checks, merge plan, sync plan',
322
- ),
323
- });
324
- }
70
+ const packet = await dispatchForgeCommand({ group, sub, flags, positional, ctx, provider });
325
71
  output(packet, asJson);
72
+ if (!packet.ok) process.exitCode = 1;
326
73
  } catch (err) {
327
74
  handleError(err, ctx, asJson);
328
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/cli",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-beta.10",
4
4
  "description": "Remogram forge boundary CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,11 +27,11 @@
27
27
  "node": ">=20"
28
28
  },
29
29
  "dependencies": {
30
- "@remogram/core": "0.1.0-beta.0",
31
- "@remogram/provider-gitea-api": "0.1.0-beta.0",
32
- "@remogram/provider-github-api": "0.1.0-beta.0",
33
- "@remogram/provider-gitlab-api": "0.1.0-beta.0",
34
- "@remogram/provider-gitea-tea": "0.1.0-beta.0",
35
- "@remogram/provider-github-gh": "0.1.0-beta.0"
30
+ "@remogram/core": "0.1.0-beta.10",
31
+ "@remogram/provider-gitea-api": "0.1.0-beta.10",
32
+ "@remogram/provider-github-api": "0.1.0-beta.10",
33
+ "@remogram/provider-gitlab-api": "0.1.0-beta.10",
34
+ "@remogram/provider-gitea-tea": "0.1.0-beta.10",
35
+ "@remogram/provider-github-gh": "0.1.0-beta.10"
36
36
  }
37
37
  }