@remogram/core 0.1.0-beta.0
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/caps.js +57 -0
- package/config-schema.js +37 -0
- package/contracts/envelope.js +71 -0
- package/contracts/errors.js +19 -0
- package/git-args.js +37 -0
- package/git-local.js +35 -0
- package/http.js +84 -0
- package/index.js +17 -0
- package/package.json +29 -0
- package/resolve.js +137 -0
- package/stub-provider.js +37 -0
package/caps.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const DEFAULT_MAX_BYTES = 8192;
|
|
2
|
+
export const DEFAULT_FIELD_MAX_BYTES = 512;
|
|
3
|
+
|
|
4
|
+
export function capText(text, maxBytes = DEFAULT_MAX_BYTES) {
|
|
5
|
+
if (!text) return { text: '', truncated: false, bytes: 0 };
|
|
6
|
+
const buf = Buffer.from(text, 'utf8');
|
|
7
|
+
if (buf.length <= maxBytes) {
|
|
8
|
+
return { text, truncated: false, bytes: buf.length };
|
|
9
|
+
}
|
|
10
|
+
let end = maxBytes;
|
|
11
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) end -= 1;
|
|
12
|
+
const slice = buf.subarray(0, end).toString('utf8');
|
|
13
|
+
return { text: slice, truncated: true, bytes: end };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sanitizeField(value, maxBytes = DEFAULT_FIELD_MAX_BYTES) {
|
|
17
|
+
if (value == null) return null;
|
|
18
|
+
const singleLine = String(value)
|
|
19
|
+
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
20
|
+
.replace(/\r?\n/g, ' ')
|
|
21
|
+
.trim();
|
|
22
|
+
return capText(singleLine, maxBytes).text;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sanitizeUrl(value, maxBytes = DEFAULT_FIELD_MAX_BYTES) {
|
|
26
|
+
if (value == null) return null;
|
|
27
|
+
try {
|
|
28
|
+
const u = new URL(String(value));
|
|
29
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
30
|
+
return sanitizeField(u.href, maxBytes);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readStreamCapped(stream, maxBytes = DEFAULT_MAX_BYTES) {
|
|
37
|
+
const chunks = [];
|
|
38
|
+
let total = 0;
|
|
39
|
+
let truncated = false;
|
|
40
|
+
|
|
41
|
+
for await (const chunk of stream) {
|
|
42
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
43
|
+
if (total + buf.length > maxBytes) {
|
|
44
|
+
const remaining = maxBytes - total;
|
|
45
|
+
if (remaining > 0) chunks.push(buf.subarray(0, remaining));
|
|
46
|
+
truncated = true;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
chunks.push(buf);
|
|
50
|
+
total += buf.length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const combined = Buffer.concat(chunks);
|
|
54
|
+
let end = combined.length;
|
|
55
|
+
while (end > 0 && (combined[end - 1] & 0xc0) === 0x80) end -= 1;
|
|
56
|
+
return { text: combined.subarray(0, end).toString('utf8'), truncated, bytes: end };
|
|
57
|
+
}
|
package/config-schema.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const providerSchema = z.enum([
|
|
4
|
+
'gitea-api',
|
|
5
|
+
'github-api',
|
|
6
|
+
'gitlab-api',
|
|
7
|
+
'gitea-tea',
|
|
8
|
+
'github-gh',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const repoSegmentSchema = z
|
|
12
|
+
.string()
|
|
13
|
+
.min(1)
|
|
14
|
+
.refine((s) => !/[/%]/.test(s) && !s.includes('..') && !s.includes('/'), {
|
|
15
|
+
message: 'owner/repo must not contain /, .., or %',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const configSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
version: z.literal('1'),
|
|
21
|
+
provider: providerSchema,
|
|
22
|
+
remote: z.string().min(1).default('origin'),
|
|
23
|
+
owner: repoSegmentSchema,
|
|
24
|
+
repo: repoSegmentSchema,
|
|
25
|
+
baseUrl: z.string().url().optional(),
|
|
26
|
+
})
|
|
27
|
+
.strict();
|
|
28
|
+
|
|
29
|
+
export function parseConfigFile(raw) {
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error('Invalid JSON in .remogram.json');
|
|
35
|
+
}
|
|
36
|
+
return configSchema.parse(parsed);
|
|
37
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { sanitizeField } from '../caps.js';
|
|
2
|
+
|
|
3
|
+
export const SCHEMA_VERSION = 1;
|
|
4
|
+
|
|
5
|
+
export const PACKET_TYPES = {
|
|
6
|
+
REPO_STATUS: 'repo_status',
|
|
7
|
+
REF_COMPARE: 'ref_compare',
|
|
8
|
+
PR_STATUS: 'pr_status',
|
|
9
|
+
PR_CHECKS: 'pr_checks',
|
|
10
|
+
MERGE_PLAN: 'merge_plan',
|
|
11
|
+
SYNC_PLAN: 'sync_plan',
|
|
12
|
+
PROVIDER_CAPABILITIES: 'provider_capabilities',
|
|
13
|
+
PROVIDER_DOCTOR: 'provider_doctor',
|
|
14
|
+
FORGE_ERROR: 'forge_error',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const FORBIDDEN_PACKET_KEYS = new Set([
|
|
18
|
+
'goal_branch',
|
|
19
|
+
'lane',
|
|
20
|
+
'sdlc_task',
|
|
21
|
+
'queue_selectable',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function assertNoForbiddenKeys(value) {
|
|
25
|
+
if (value == null || typeof value !== 'object') return;
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
for (const item of value) assertNoForbiddenKeys(item);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
31
|
+
if (FORBIDDEN_PACKET_KEYS.has(key)) {
|
|
32
|
+
throw new Error(`Forbidden Topogram concept in remogram output: ${key}`);
|
|
33
|
+
}
|
|
34
|
+
assertNoForbiddenKeys(nested);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function forgePacket(type, context, body = {}, error = null) {
|
|
39
|
+
assertNoForbiddenKeys(body);
|
|
40
|
+
|
|
41
|
+
const packet = {
|
|
42
|
+
...body,
|
|
43
|
+
type,
|
|
44
|
+
schema_version: SCHEMA_VERSION,
|
|
45
|
+
provider_id: context.providerId,
|
|
46
|
+
remote_name: context.remoteName,
|
|
47
|
+
repo_id: context.repoId,
|
|
48
|
+
observed_at: new Date().toISOString(),
|
|
49
|
+
ok: error == null,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (error) {
|
|
53
|
+
packet.error_code = error.code;
|
|
54
|
+
packet.error_message = sanitizeField(error.message);
|
|
55
|
+
if (error.status != null) packet.error_status = error.status;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return packet;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function forgeErrorPacket(context, error, type = PACKET_TYPES.FORGE_ERROR) {
|
|
62
|
+
return forgePacket(type, context, {}, error);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function unknownForgeContext() {
|
|
66
|
+
return {
|
|
67
|
+
providerId: 'unknown',
|
|
68
|
+
remoteName: 'origin',
|
|
69
|
+
repoId: 'unknown/unknown',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const ERROR_CODES = {
|
|
2
|
+
STALE_HEAD: 'stale_head',
|
|
3
|
+
MISSING_REF: 'missing_ref',
|
|
4
|
+
DIVERGENT_REMOTES: 'divergent_remotes',
|
|
5
|
+
UNPARSEABLE_PROVIDER_OUTPUT: 'unparseable_provider_output',
|
|
6
|
+
OVERSIZED_RAW_OUTPUT: 'oversized_raw_output',
|
|
7
|
+
UNAUTHENTICATED_PROVIDER: 'unauthenticated_provider',
|
|
8
|
+
UNTRUSTED_BASE_URL: 'untrusted_base_url',
|
|
9
|
+
CONFIG_INVALID: 'config_invalid',
|
|
10
|
+
PROVIDER_UNSUPPORTED: 'provider_unsupported',
|
|
11
|
+
CONFIG_NOT_FOUND: 'config_not_found',
|
|
12
|
+
INVALID_ARGS: 'invalid_args',
|
|
13
|
+
API_ERROR: 'api_error',
|
|
14
|
+
REMOTE_INFER_FAILED: 'remote_infer_failed',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function forgeError(code, message, status = null) {
|
|
18
|
+
return { code, message, ...(status != null ? { status } : {}) };
|
|
19
|
+
}
|
package/git-args.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
2
|
+
|
|
3
|
+
const GIT_REF_RE = /^[A-Za-z0-9._/+-]+$/;
|
|
4
|
+
const GIT_REMOTE_RE = /^[A-Za-z0-9._-]+$/;
|
|
5
|
+
|
|
6
|
+
function invalidArgs(message) {
|
|
7
|
+
return Object.assign(new Error(message), {
|
|
8
|
+
forgeError: forgeError(ERROR_CODES.INVALID_ARGS, message),
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function assertGitRef(ref, label = 'ref') {
|
|
13
|
+
if (typeof ref !== 'string' || !ref.trim()) {
|
|
14
|
+
throw invalidArgs(`${label} is required`);
|
|
15
|
+
}
|
|
16
|
+
if (ref.startsWith('-')) {
|
|
17
|
+
throw invalidArgs(`${label} must not start with '-'`);
|
|
18
|
+
}
|
|
19
|
+
if (ref.includes('..')) {
|
|
20
|
+
throw invalidArgs(`${label} must not contain '..'`);
|
|
21
|
+
}
|
|
22
|
+
if (!GIT_REF_RE.test(ref)) {
|
|
23
|
+
throw invalidArgs(`${label} contains invalid characters`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assertGitRemote(name, label = 'remote') {
|
|
28
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
29
|
+
throw invalidArgs(`${label} is required`);
|
|
30
|
+
}
|
|
31
|
+
if (name.startsWith('-')) {
|
|
32
|
+
throw invalidArgs(`${label} must not start with '-'`);
|
|
33
|
+
}
|
|
34
|
+
if (!GIT_REMOTE_RE.test(name)) {
|
|
35
|
+
throw invalidArgs(`${label} contains invalid characters`);
|
|
36
|
+
}
|
|
37
|
+
}
|
package/git-local.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { assertGitRef } from './git-args.js';
|
|
3
|
+
|
|
4
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
5
|
+
|
|
6
|
+
function gitExec(cwd, args) {
|
|
7
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function gitRevParse(cwd, ref) {
|
|
11
|
+
assertGitRef(ref);
|
|
12
|
+
try {
|
|
13
|
+
return gitExec(cwd, ['rev-parse', ref]);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function gitCurrentBranch(cwd) {
|
|
20
|
+
try {
|
|
21
|
+
return gitExec(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function gitAheadBehind(cwd, base, head) {
|
|
28
|
+
try {
|
|
29
|
+
const out = gitExec(cwd, ['rev-list', '--left-right', '--count', `${base}...${head}`]);
|
|
30
|
+
const [behind, ahead] = out.split(/\s+/).map(Number);
|
|
31
|
+
return { ahead_by: ahead, behind_by: behind };
|
|
32
|
+
} catch {
|
|
33
|
+
return { ahead_by: null, behind_by: null };
|
|
34
|
+
}
|
|
35
|
+
}
|
package/http.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
2
|
+
import { readStreamCapped, DEFAULT_MAX_BYTES, sanitizeField } from './caps.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
const DEFAULT_JSON_MAX_BYTES = DEFAULT_MAX_BYTES;
|
|
6
|
+
|
|
7
|
+
export async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
8
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
9
|
+
return fetch(url, { ...options, signal, redirect: 'manual' });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function readResponseTextCapped(res, maxBytes) {
|
|
13
|
+
if (!res.body) return '';
|
|
14
|
+
const capped = await readStreamCapped(res.body, maxBytes);
|
|
15
|
+
if (capped.truncated) {
|
|
16
|
+
throw Object.assign(new Error('Provider output exceeded cap'), {
|
|
17
|
+
forgeError: forgeError(ERROR_CODES.OVERSIZED_RAW_OUTPUT, 'Provider response exceeded byte cap'),
|
|
18
|
+
status: res.status,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return capped.text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function fetchJson(
|
|
25
|
+
url,
|
|
26
|
+
options = {},
|
|
27
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
28
|
+
maxBytes = DEFAULT_JSON_MAX_BYTES,
|
|
29
|
+
) {
|
|
30
|
+
const res = await fetchWithTimeout(url, options, timeoutMs);
|
|
31
|
+
if (res.status >= 300 && res.status < 400) {
|
|
32
|
+
const message = 'HTTP redirect rejected';
|
|
33
|
+
throw Object.assign(new Error(message), {
|
|
34
|
+
forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
|
|
35
|
+
status: res.status,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const text = await readResponseTextCapped(res, maxBytes);
|
|
39
|
+
let body;
|
|
40
|
+
try {
|
|
41
|
+
body = text ? JSON.parse(text) : null;
|
|
42
|
+
} catch {
|
|
43
|
+
throw Object.assign(new Error('Unparseable JSON from provider'), {
|
|
44
|
+
forgeError: forgeError(ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT, 'Provider returned invalid JSON'),
|
|
45
|
+
status: res.status,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const raw = body?.message || body?.error || res.statusText || 'API error';
|
|
50
|
+
const message = sanitizeField(raw) || 'API error';
|
|
51
|
+
throw Object.assign(new Error(message), {
|
|
52
|
+
forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
|
|
53
|
+
status: res.status,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return body;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function fetchTextCapped(url, options = {}, maxBytes = DEFAULT_MAX_BYTES) {
|
|
60
|
+
const res = await fetchWithTimeout(url, options);
|
|
61
|
+
if (res.status >= 300 && res.status < 400) {
|
|
62
|
+
const message = 'HTTP redirect rejected';
|
|
63
|
+
throw Object.assign(new Error(message), {
|
|
64
|
+
forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
|
|
65
|
+
status: res.status,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const raw = await readResponseTextCapped(res, maxBytes).catch(() => res.statusText);
|
|
70
|
+
const message = sanitizeField(raw) || 'API error';
|
|
71
|
+
throw Object.assign(new Error(message), {
|
|
72
|
+
forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
|
|
73
|
+
status: res.status,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (!res.body) return { text: '', truncated: false, bytes: 0 };
|
|
77
|
+
const capped = await readStreamCapped(res.body, maxBytes);
|
|
78
|
+
if (capped.truncated) {
|
|
79
|
+
throw Object.assign(new Error('Provider output exceeded cap'), {
|
|
80
|
+
forgeError: forgeError(ERROR_CODES.OVERSIZED_RAW_OUTPUT, 'Provider response exceeded byte cap'),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return capped;
|
|
84
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { SCHEMA_VERSION, PACKET_TYPES, forgePacket, forgeErrorPacket, unknownForgeContext, FORBIDDEN_PACKET_KEYS } from './contracts/envelope.js';
|
|
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';
|
|
4
|
+
export { assertGitRef, assertGitRemote } from './git-args.js';
|
|
5
|
+
export { gitRevParse, gitCurrentBranch, gitAheadBehind } from './git-local.js';
|
|
6
|
+
export { parseConfigFile, configSchema } from './config-schema.js';
|
|
7
|
+
export {
|
|
8
|
+
findConfigPath,
|
|
9
|
+
loadConfig,
|
|
10
|
+
gitRemoteUrl,
|
|
11
|
+
parseRemoteUrl,
|
|
12
|
+
trustedBaseUrl,
|
|
13
|
+
assertConfigMatchesRemote,
|
|
14
|
+
assertForgeReady,
|
|
15
|
+
forgeContext,
|
|
16
|
+
} from './resolve.js';
|
|
17
|
+
export { fetchWithTimeout, fetchJson, fetchTextCapped } from './http.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remogram/core",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Remogram forge envelope, config, caps, and HTTP utilities",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/attebury/remogram.git",
|
|
10
|
+
"directory": "packages/remogram-core"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"*.js",
|
|
17
|
+
"contracts/"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./index.js",
|
|
21
|
+
"./stub-provider.js": "./stub-provider.js"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"zod": "^3.25.76"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/resolve.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { parseConfigFile } from './config-schema.js';
|
|
5
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
6
|
+
import { assertGitRemote } from './git-args.js';
|
|
7
|
+
|
|
8
|
+
const HOST_ALIASES = new Map([
|
|
9
|
+
['localhost:3000', '127.0.0.1:3000'],
|
|
10
|
+
['127.0.0.1:3000', 'localhost:3000'],
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export function findConfigPath(startDir = process.cwd()) {
|
|
14
|
+
let dir = startDir;
|
|
15
|
+
while (true) {
|
|
16
|
+
const candidate = join(dir, '.remogram.json');
|
|
17
|
+
if (existsSync(candidate)) return candidate;
|
|
18
|
+
const parent = dirname(dir);
|
|
19
|
+
if (parent === dir) break;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function loadConfig(cwd = process.cwd()) {
|
|
26
|
+
const path = findConfigPath(cwd);
|
|
27
|
+
if (!path) {
|
|
28
|
+
throw Object.assign(new Error('No .remogram.json found'), {
|
|
29
|
+
forgeError: forgeError(ERROR_CODES.CONFIG_NOT_FOUND, 'No .remogram.json found'),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const raw = readFileSync(path, 'utf8');
|
|
34
|
+
return { path, config: parseConfigFile(raw), cwd: dirname(path) };
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw Object.assign(new Error(err.message), {
|
|
37
|
+
forgeError: forgeError(ERROR_CODES.CONFIG_INVALID, err.message),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function gitRemoteUrl(cwd, remote = 'origin') {
|
|
43
|
+
assertGitRemote(remote);
|
|
44
|
+
try {
|
|
45
|
+
return execFileSync('git', ['remote', 'get-url', remote], {
|
|
46
|
+
cwd,
|
|
47
|
+
encoding: 'utf8',
|
|
48
|
+
timeout: 10_000,
|
|
49
|
+
}).trim();
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseRemoteUrl(url) {
|
|
56
|
+
if (!url) return null;
|
|
57
|
+
const ssh = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
|
58
|
+
if (ssh) {
|
|
59
|
+
const [, host, path] = ssh;
|
|
60
|
+
const parts = path.split('/').filter(Boolean);
|
|
61
|
+
if (parts.length < 2) return null;
|
|
62
|
+
const repo = parts.pop();
|
|
63
|
+
const owner = parts.join('/');
|
|
64
|
+
return { host, owner, repo };
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const u = new URL(url.replace(/\.git$/, ''));
|
|
68
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
69
|
+
if (parts.length < 2) return null;
|
|
70
|
+
const repo = parts.pop();
|
|
71
|
+
const owner = parts.join('/');
|
|
72
|
+
return { host: u.host, owner, repo, protocol: u.protocol };
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hostsEquivalent(a, b) {
|
|
79
|
+
if (a === b) return true;
|
|
80
|
+
return HOST_ALIASES.get(a) === b || HOST_ALIASES.get(b) === a;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function trustedBaseUrl(config, remoteHost) {
|
|
84
|
+
if (!config.baseUrl) return true;
|
|
85
|
+
let configHost;
|
|
86
|
+
try {
|
|
87
|
+
configHost = new URL(config.baseUrl).host;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return configHost === remoteHost || hostsEquivalent(configHost, remoteHost);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function assertConfigMatchesRemote(config, parsed) {
|
|
95
|
+
if (config.owner !== parsed.owner || config.repo !== parsed.repo) {
|
|
96
|
+
throw Object.assign(new Error('Config owner/repo does not match git remote'), {
|
|
97
|
+
forgeError: forgeError(
|
|
98
|
+
ERROR_CODES.CONFIG_INVALID,
|
|
99
|
+
`Config repo ${config.owner}/${config.repo} does not match remote ${parsed.owner}/${parsed.repo}`,
|
|
100
|
+
),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function assertForgeReady(loaded) {
|
|
106
|
+
const { config, cwd } = loaded;
|
|
107
|
+
assertGitRemote(config.remote, 'config.remote');
|
|
108
|
+
const remoteUrl = gitRemoteUrl(cwd, config.remote);
|
|
109
|
+
const parsed = parseRemoteUrl(remoteUrl);
|
|
110
|
+
if (!parsed) {
|
|
111
|
+
throw Object.assign(new Error('Could not parse git remote'), {
|
|
112
|
+
forgeError: forgeError(ERROR_CODES.REMOTE_INFER_FAILED, 'Could not parse git remote URL'),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
assertConfigMatchesRemote(config, parsed);
|
|
116
|
+
if (config.baseUrl && !trustedBaseUrl(config, parsed.host)) {
|
|
117
|
+
throw Object.assign(new Error('baseUrl host not trusted'), {
|
|
118
|
+
forgeError: forgeError(
|
|
119
|
+
ERROR_CODES.UNTRUSTED_BASE_URL,
|
|
120
|
+
`baseUrl host does not match remote host ${parsed.host}`,
|
|
121
|
+
),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return { ...loaded, remoteUrl, parsed };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function forgeContext(loaded) {
|
|
128
|
+
const { config, parsed } = loaded;
|
|
129
|
+
return {
|
|
130
|
+
providerId: config.provider,
|
|
131
|
+
remoteName: config.remote,
|
|
132
|
+
repoId: `${parsed.owner}/${parsed.repo}`,
|
|
133
|
+
config,
|
|
134
|
+
cwd: loaded.cwd,
|
|
135
|
+
parsed,
|
|
136
|
+
};
|
|
137
|
+
}
|
package/stub-provider.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ERROR_CODES, forgeError } from './contracts/errors.js';
|
|
2
|
+
|
|
3
|
+
export function createStubProvider(id) {
|
|
4
|
+
function unsupported() {
|
|
5
|
+
const err = new Error('Provider not implemented');
|
|
6
|
+
err.forgeError = forgeError(ERROR_CODES.PROVIDER_UNSUPPORTED, 'Provider not implemented in v1');
|
|
7
|
+
throw err;
|
|
8
|
+
}
|
|
9
|
+
function providerCapabilities() {
|
|
10
|
+
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
|
+
],
|
|
19
|
+
auth_envs: [],
|
|
20
|
+
check_sources: [],
|
|
21
|
+
mergeability_confidence: 'unknown',
|
|
22
|
+
host_binding: 'unsupported',
|
|
23
|
+
pagination: 'unsupported',
|
|
24
|
+
write_support: false,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
providerCapabilities,
|
|
30
|
+
repoStatus: unsupported,
|
|
31
|
+
refsCompare: unsupported,
|
|
32
|
+
prView: unsupported,
|
|
33
|
+
prChecks: unsupported,
|
|
34
|
+
mergePlan: unsupported,
|
|
35
|
+
syncPlan: unsupported,
|
|
36
|
+
};
|
|
37
|
+
}
|