@remogram/core 0.1.0-beta.0 → 0.1.0-beta.2

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.
@@ -0,0 +1,44 @@
1
+ /** Per-command auth requirements for structured provider capabilities. */
2
+ export const AUTH_CLASS = {
3
+ NONE: 'none',
4
+ GIT_ONLY: 'git_only',
5
+ TOKEN_REQUIRED: 'token_required',
6
+ };
7
+
8
+ const AUTH_CLASS_VALUES = new Set(Object.values(AUTH_CLASS));
9
+
10
+ /** Runtime auth requirements for fully implemented API providers. */
11
+ export const API_PROVIDER_COMMAND_AUTH = {
12
+ repo_status: AUTH_CLASS.NONE,
13
+ ref_compare: AUTH_CLASS.GIT_ONLY,
14
+ pr_status: AUTH_CLASS.TOKEN_REQUIRED,
15
+ pr_checks: AUTH_CLASS.TOKEN_REQUIRED,
16
+ merge_plan: AUTH_CLASS.TOKEN_REQUIRED,
17
+ sync_plan: AUTH_CLASS.GIT_ONLY,
18
+ };
19
+
20
+ export function commandCapability(name, { implemented = true } = {}) {
21
+ const auth_class = API_PROVIDER_COMMAND_AUTH[name];
22
+ if (!auth_class) {
23
+ throw new Error(`Unknown command: ${name}`);
24
+ }
25
+ return { name, implemented, auth_class };
26
+ }
27
+
28
+ export function apiProviderCommands() {
29
+ return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) =>
30
+ commandCapability(name, { implemented: true }),
31
+ );
32
+ }
33
+
34
+ export function stubProviderCommands() {
35
+ return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) =>
36
+ commandCapability(name, { implemented: false }),
37
+ );
38
+ }
39
+
40
+ export function assertAuthClass(value) {
41
+ if (!AUTH_CLASS_VALUES.has(value)) {
42
+ throw new Error(`Invalid auth_class: ${value}`);
43
+ }
44
+ }
package/caps.js CHANGED
@@ -1,5 +1,24 @@
1
1
  export const DEFAULT_MAX_BYTES = 8192;
2
2
  export const DEFAULT_FIELD_MAX_BYTES = 512;
3
+ export const FORGE_INGEST_MAX_BYTES_ENV = 'REMOGRAM_FORGE_INGEST_MAX_BYTES';
4
+
5
+ export function getEffectiveIngestMaxBytes() {
6
+ const raw = process.env[FORGE_INGEST_MAX_BYTES_ENV];
7
+ if (raw == null || raw === '') {
8
+ return { bytes: DEFAULT_MAX_BYTES, envOverride: false };
9
+ }
10
+ const parsed = Number.parseInt(String(raw), 10);
11
+ if (!Number.isFinite(parsed) || parsed <= 0) {
12
+ return { bytes: DEFAULT_MAX_BYTES, envOverride: false, invalidEnv: true };
13
+ }
14
+ return { bytes: parsed, envOverride: true };
15
+ }
16
+
17
+ /** Facts for provider capabilities packets (forge ingest policy). */
18
+ export function forgeIngestCapabilityFacts() {
19
+ const { bytes } = getEffectiveIngestMaxBytes();
20
+ return { forge_ingest_cap_bytes: bytes };
21
+ }
3
22
 
4
23
  export function capText(text, maxBytes = DEFAULT_MAX_BYTES) {
5
24
  if (!text) return { text: '', truncated: false, bytes: 0 };
@@ -19,7 +38,17 @@ export function sanitizeField(value, maxBytes = DEFAULT_FIELD_MAX_BYTES) {
19
38
  .replace(/[\x00-\x1f\x7f]/g, ' ')
20
39
  .replace(/\r?\n/g, ' ')
21
40
  .trim();
22
- return capText(singleLine, maxBytes).text;
41
+ const redacted = redactSecretPatterns(singleLine);
42
+ return capText(redacted, maxBytes).text;
43
+ }
44
+
45
+ function redactSecretPatterns(text) {
46
+ return text
47
+ .replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]')
48
+ .replace(/\bghp_[A-Za-z0-9]+\b/g, '[REDACTED]')
49
+ .replace(/\bgho_[A-Za-z0-9]+\b/g, '[REDACTED]')
50
+ .replace(/\bglpat-[A-Za-z0-9_-]+\b/g, '[REDACTED]')
51
+ .replace(/\b(GITHUB_TOKEN|GH_TOKEN|GITLAB_TOKEN|GITEA_TOKEN)\b/gi, '[REDACTED]');
23
52
  }
24
53
 
25
54
  export function sanitizeUrl(value, maxBytes = DEFAULT_FIELD_MAX_BYTES) {
@@ -27,6 +56,8 @@ export function sanitizeUrl(value, maxBytes = DEFAULT_FIELD_MAX_BYTES) {
27
56
  try {
28
57
  const u = new URL(String(value));
29
58
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
59
+ u.username = '';
60
+ u.password = '';
30
61
  return sanitizeField(u.href, maxBytes);
31
62
  } catch {
32
63
  return null;
package/git-local.js CHANGED
@@ -24,7 +24,17 @@ export function gitCurrentBranch(cwd) {
24
24
  }
25
25
  }
26
26
 
27
+ export function gitRepoRoot(cwd) {
28
+ try {
29
+ return gitExec(cwd, ['rev-parse', '--show-toplevel']);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
27
35
  export function gitAheadBehind(cwd, base, head) {
36
+ assertGitRef(base, 'base');
37
+ assertGitRef(head, 'head');
28
38
  try {
29
39
  const out = gitExec(cwd, ['rev-list', '--left-right', '--count', `${base}...${head}`]);
30
40
  const [behind, ahead] = out.split(/\s+/).map(Number);
package/http.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { ERROR_CODES, forgeError } from './contracts/errors.js';
2
- import { readStreamCapped, DEFAULT_MAX_BYTES, sanitizeField } from './caps.js';
2
+ import { readStreamCapped, getEffectiveIngestMaxBytes, sanitizeField } from './caps.js';
3
3
 
4
4
  const DEFAULT_TIMEOUT_MS = 30_000;
5
- const DEFAULT_JSON_MAX_BYTES = DEFAULT_MAX_BYTES;
5
+ const DEFAULT_JSON_MAX_BYTES = () => getEffectiveIngestMaxBytes().bytes;
6
6
 
7
7
  export async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
8
8
  const signal = AbortSignal.timeout(timeoutMs);
@@ -25,7 +25,7 @@ export async function fetchJson(
25
25
  url,
26
26
  options = {},
27
27
  timeoutMs = DEFAULT_TIMEOUT_MS,
28
- maxBytes = DEFAULT_JSON_MAX_BYTES,
28
+ maxBytes = DEFAULT_JSON_MAX_BYTES(),
29
29
  ) {
30
30
  const res = await fetchWithTimeout(url, options, timeoutMs);
31
31
  if (res.status >= 300 && res.status < 400) {
@@ -56,7 +56,52 @@ export async function fetchJson(
56
56
  return body;
57
57
  }
58
58
 
59
- export async function fetchTextCapped(url, options = {}, maxBytes = DEFAULT_MAX_BYTES) {
59
+ export function parseLinkHeader(linkHeader) {
60
+ if (!linkHeader) return {};
61
+ const links = {};
62
+ for (const segment of String(linkHeader).split(',')) {
63
+ const match = segment.trim().match(/^<([^>]+)>;\s*rel="([^"]+)"/);
64
+ if (match) links[match[2]] = match[1];
65
+ }
66
+ return links;
67
+ }
68
+
69
+ export async function fetchJsonWithMeta(
70
+ url,
71
+ options = {},
72
+ timeoutMs = DEFAULT_TIMEOUT_MS,
73
+ maxBytes = DEFAULT_JSON_MAX_BYTES(),
74
+ ) {
75
+ const res = await fetchWithTimeout(url, options, timeoutMs);
76
+ if (res.status >= 300 && res.status < 400) {
77
+ const message = 'HTTP redirect rejected';
78
+ throw Object.assign(new Error(message), {
79
+ forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
80
+ status: res.status,
81
+ });
82
+ }
83
+ const text = await readResponseTextCapped(res, maxBytes);
84
+ let body;
85
+ try {
86
+ body = text ? JSON.parse(text) : null;
87
+ } catch {
88
+ throw Object.assign(new Error('Unparseable JSON from provider'), {
89
+ forgeError: forgeError(ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT, 'Provider returned invalid JSON'),
90
+ status: res.status,
91
+ });
92
+ }
93
+ if (!res.ok) {
94
+ const raw = body?.message || body?.error || res.statusText || 'API error';
95
+ const message = sanitizeField(raw) || 'API error';
96
+ throw Object.assign(new Error(message), {
97
+ forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
98
+ status: res.status,
99
+ });
100
+ }
101
+ return { body, headers: res.headers, status: res.status };
102
+ }
103
+
104
+ export async function fetchTextCapped(url, options = {}, maxBytes = getEffectiveIngestMaxBytes().bytes) {
60
105
  const res = await fetchWithTimeout(url, options);
61
106
  if (res.status >= 300 && res.status < 400) {
62
107
  const message = 'HTTP redirect rejected';
package/index.js CHANGED
@@ -1,8 +1,26 @@
1
1
  export { SCHEMA_VERSION, PACKET_TYPES, forgePacket, forgeErrorPacket, unknownForgeContext, FORBIDDEN_PACKET_KEYS } from './contracts/envelope.js';
2
2
  export { ERROR_CODES, forgeError } from './contracts/errors.js';
3
- export { capText, sanitizeField, sanitizeUrl, readStreamCapped, DEFAULT_MAX_BYTES, DEFAULT_FIELD_MAX_BYTES } from './caps.js';
3
+ export {
4
+ capText,
5
+ sanitizeField,
6
+ sanitizeUrl,
7
+ readStreamCapped,
8
+ DEFAULT_MAX_BYTES,
9
+ DEFAULT_FIELD_MAX_BYTES,
10
+ FORGE_INGEST_MAX_BYTES_ENV,
11
+ getEffectiveIngestMaxBytes,
12
+ forgeIngestCapabilityFacts,
13
+ } from './caps.js';
4
14
  export { assertGitRef, assertGitRemote } from './git-args.js';
5
- export { gitRevParse, gitCurrentBranch, gitAheadBehind } from './git-local.js';
15
+ export { gitRevParse, gitCurrentBranch, gitAheadBehind, gitRepoRoot } from './git-local.js';
16
+ export {
17
+ localHeadShaForPr,
18
+ staleHeadDetails,
19
+ staleHeadForgeError,
20
+ staleHeadForgeError as staleHeadError,
21
+ STALE_HEAD_MESSAGE,
22
+ throwIfStaleHeadByNumber,
23
+ } from './pr-head-reconcile.js';
6
24
  export { parseConfigFile, configSchema } from './config-schema.js';
7
25
  export {
8
26
  findConfigPath,
@@ -14,4 +32,12 @@ export {
14
32
  assertForgeReady,
15
33
  forgeContext,
16
34
  } from './resolve.js';
17
- export { fetchWithTimeout, fetchJson, fetchTextCapped } from './http.js';
35
+ export { fetchWithTimeout, fetchJson, fetchJsonWithMeta, parseLinkHeader, fetchTextCapped } from './http.js';
36
+ export {
37
+ AUTH_CLASS,
38
+ API_PROVIDER_COMMAND_AUTH,
39
+ commandCapability,
40
+ apiProviderCommands,
41
+ stubProviderCommands,
42
+ assertAuthClass,
43
+ } from './auth-classes.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/core",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-beta.2",
4
4
  "description": "Remogram forge envelope, config, caps, and HTTP utilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,38 @@
1
+ import { assertGitRemote } from './git-args.js';
2
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
+ import { gitRevParse } from './git-local.js';
4
+
5
+ export const STALE_HEAD_MESSAGE =
6
+ 'Forge PR head SHA diverges from locally resolved git; fetch or refresh before trusting head_sha';
7
+
8
+ export function localHeadShaForPr(cwd, remoteName, headRef) {
9
+ if (!headRef) return null;
10
+ assertGitRemote(remoteName, 'remote');
11
+ const trackingRef = `${remoteName}/${headRef}`;
12
+ return gitRevParse(cwd, trackingRef) ?? gitRevParse(cwd, headRef);
13
+ }
14
+
15
+ export function staleHeadDetails(cwd, remoteName, headRef, forgeHeadSha) {
16
+ if (!headRef || !forgeHeadSha) return null;
17
+ const localHeadSha = localHeadShaForPr(cwd, remoteName, headRef);
18
+ if (!localHeadSha) return null;
19
+ if (localHeadSha.toLowerCase() === String(forgeHeadSha).toLowerCase()) return null;
20
+ return {
21
+ head_ref: headRef,
22
+ head_sha: forgeHeadSha,
23
+ local_head_sha: localHeadSha,
24
+ };
25
+ }
26
+
27
+ export function staleHeadForgeError() {
28
+ return forgeError(ERROR_CODES.STALE_HEAD, STALE_HEAD_MESSAGE);
29
+ }
30
+
31
+ export function throwIfStaleHeadByNumber(ctx, packetType, body, headRef, forgeHeadSha) {
32
+ const details = staleHeadDetails(ctx.cwd, ctx.config?.remote ?? ctx.remoteName, headRef, forgeHeadSha);
33
+ if (!details) return;
34
+ const err = new Error(STALE_HEAD_MESSAGE);
35
+ err.forgeError = staleHeadForgeError();
36
+ err.staleHeadPacket = { type: packetType, body: { ...body, ...details } };
37
+ throw err;
38
+ }
package/resolve.js CHANGED
@@ -1,20 +1,35 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { readFileSync, existsSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { readFileSync, existsSync, realpathSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
4
  import { parseConfigFile } from './config-schema.js';
5
5
  import { ERROR_CODES, forgeError } from './contracts/errors.js';
6
6
  import { assertGitRemote } from './git-args.js';
7
+ import { gitRepoRoot } from './git-local.js';
7
8
 
8
9
  const HOST_ALIASES = new Map([
9
10
  ['localhost:3000', '127.0.0.1:3000'],
10
11
  ['127.0.0.1:3000', 'localhost:3000'],
11
12
  ]);
12
13
 
14
+ function samePath(a, b) {
15
+ try {
16
+ return realpathSync(a) === realpathSync(b);
17
+ } catch {
18
+ return resolve(a) === resolve(b);
19
+ }
20
+ }
21
+
13
22
  export function findConfigPath(startDir = process.cwd()) {
23
+ const repoRoot = gitRepoRoot(startDir);
14
24
  let dir = startDir;
15
25
  while (true) {
16
26
  const candidate = join(dir, '.remogram.json');
17
27
  if (existsSync(candidate)) return candidate;
28
+ if (repoRoot) {
29
+ if (samePath(dir, repoRoot)) break;
30
+ } else {
31
+ break;
32
+ }
18
33
  const parent = dirname(dir);
19
34
  if (parent === dir) break;
20
35
  dir = parent;
package/stub-provider.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ERROR_CODES, forgeError } from './contracts/errors.js';
2
+ import { stubProviderCommands } from './auth-classes.js';
2
3
 
3
4
  export function createStubProvider(id) {
4
5
  function unsupported() {
@@ -8,14 +9,7 @@ export function createStubProvider(id) {
8
9
  }
9
10
  function providerCapabilities() {
10
11
  return {
11
- commands: [
12
- { name: 'repo_status', implemented: false },
13
- { name: 'ref_compare', implemented: false },
14
- { name: 'pr_status', implemented: false },
15
- { name: 'pr_checks', implemented: false },
16
- { name: 'merge_plan', implemented: false },
17
- { name: 'sync_plan', implemented: false },
18
- ],
12
+ commands: stubProviderCommands(),
19
13
  auth_envs: [],
20
14
  check_sources: [],
21
15
  mergeability_confidence: 'unknown',