@remogram/cli 0.1.0-beta.1 → 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-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
+ }