@samaksh/git-worker 0.0.0-stage → 0.2.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/README.md +114 -2
- package/dist/app.d.ts +2 -0
- package/dist/app.js +48 -0
- package/dist/cli/commands.d.ts +6 -0
- package/dist/cli/commands.js +111 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +115 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +44 -0
- package/dist/config/load.d.ts +3 -0
- package/dist/config/load.js +65 -0
- package/dist/config/schema.d.ts +200 -0
- package/dist/config/schema.js +45 -0
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.js +34 -0
- package/dist/core/pipeline.d.ts +3 -0
- package/dist/core/pipeline.js +14 -0
- package/dist/core/types.d.ts +153 -0
- package/dist/core/types.js +16 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/services/editor/index.d.ts +9 -0
- package/dist/services/editor/index.js +59 -0
- package/dist/services/git/ops.d.ts +30 -0
- package/dist/services/git/ops.js +139 -0
- package/dist/services/git/reader.d.ts +14 -0
- package/dist/services/git/reader.js +195 -0
- package/dist/services/report/index.d.ts +18 -0
- package/dist/services/report/index.js +68 -0
- package/dist/services/review/index.d.ts +30 -0
- package/dist/services/review/index.js +59 -0
- package/dist/services/state/index.d.ts +20 -0
- package/dist/services/state/index.js +49 -0
- package/dist/services/template/env.d.ts +23 -0
- package/dist/services/template/env.js +146 -0
- package/dist/services/template/interpolator.d.ts +30 -0
- package/dist/services/template/interpolator.js +75 -0
- package/dist/services/template/transform.d.ts +22 -0
- package/dist/services/template/transform.js +75 -0
- package/dist/services/template/validator.d.ts +5 -0
- package/dist/services/template/validator.js +68 -0
- package/dist/services/workspace/cache.d.ts +32 -0
- package/dist/services/workspace/cache.js +107 -0
- package/dist/services/workspace/manager.d.ts +44 -0
- package/dist/services/workspace/manager.js +301 -0
- package/dist/stages/apply.d.ts +3 -0
- package/dist/stages/apply.js +161 -0
- package/dist/stages/edit-template.d.ts +3 -0
- package/dist/stages/edit-template.js +198 -0
- package/dist/stages/eligibility.d.ts +3 -0
- package/dist/stages/eligibility.js +68 -0
- package/dist/stages/load-template.d.ts +3 -0
- package/dist/stages/load-template.js +168 -0
- package/dist/stages/plan.d.ts +3 -0
- package/dist/stages/plan.js +270 -0
- package/dist/stages/push.d.ts +3 -0
- package/dist/stages/push.js +99 -0
- package/dist/stages/report.d.ts +3 -0
- package/dist/stages/report.js +103 -0
- package/dist/stages/resolve-project.d.ts +3 -0
- package/dist/stages/resolve-project.js +294 -0
- package/dist/stages/review.d.ts +3 -0
- package/dist/stages/review.js +197 -0
- package/dist/stages/select.d.ts +13 -0
- package/dist/stages/select.js +265 -0
- package/dist/stages/sync.d.ts +3 -0
- package/dist/stages/sync.js +47 -0
- package/dist/utils/hash.d.ts +2 -0
- package/dist/utils/hash.js +7 -0
- package/dist/utils/ui.d.ts +21 -0
- package/dist/utils/ui.js +113 -0
- package/dist/utils/which.d.ts +5 -0
- package/dist/utils/which.js +16 -0
- package/docs/FEATURES.md +84 -0
- package/docs/SERVICES.md +57 -0
- package/docs/architecture.md +38 -0
- package/docs/usage.md +337 -0
- package/package.json +58 -4
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { ok, err } from "../../core/types.js";
|
|
2
|
+
import { templateError } from "../../core/errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* Parse a .env template file into structured assignments.
|
|
5
|
+
* Template uses {KEY} for dynamic placeholders, KEY=value for static.
|
|
6
|
+
*/
|
|
7
|
+
export function parseEnvTemplate(raw) {
|
|
8
|
+
const lines = raw.replace(/\r\n/g, "\n").split("\n");
|
|
9
|
+
const assignments = [];
|
|
10
|
+
const placeholders = new Set();
|
|
11
|
+
const seenKeys = new Map();
|
|
12
|
+
for (let i = 0; i < lines.length; i++) {
|
|
13
|
+
const line = lines[i];
|
|
14
|
+
const trimmed = line.trim();
|
|
15
|
+
// Preserve blank lines and full-line comments — skip for assignment parsing
|
|
16
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
// Handle `export KEY=...`
|
|
20
|
+
let workingLine = trimmed;
|
|
21
|
+
if (workingLine.startsWith("export ")) {
|
|
22
|
+
workingLine = workingLine.slice(7).trimStart();
|
|
23
|
+
}
|
|
24
|
+
// Find = sign
|
|
25
|
+
const eqIndex = workingLine.indexOf("=");
|
|
26
|
+
if (eqIndex === -1) {
|
|
27
|
+
return err(templateError(`Line ${i + 1}: missing '=' in assignment`));
|
|
28
|
+
}
|
|
29
|
+
const key = workingLine.slice(0, eqIndex).trimEnd();
|
|
30
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
31
|
+
return err(templateError(`Line ${i + 1}: invalid key name '${key}'`));
|
|
32
|
+
}
|
|
33
|
+
if (seenKeys.has(key)) {
|
|
34
|
+
return err(templateError(`Duplicate key '${key}' at line ${i + 1} (first seen at line ${seenKeys.get(key) + 1})`));
|
|
35
|
+
}
|
|
36
|
+
seenKeys.set(key, i);
|
|
37
|
+
// Value: everything after the first '=', no trimming (preserve quoting)
|
|
38
|
+
const value = workingLine.slice(eqIndex + 1);
|
|
39
|
+
// Check for placeholder {KEY} — must be the entire value
|
|
40
|
+
const placeholderMatch = value.match(/^\{([A-Za-z_][A-Za-z0-9_]*)\}$/);
|
|
41
|
+
if (placeholderMatch) {
|
|
42
|
+
const placeholderName = placeholderMatch[1];
|
|
43
|
+
placeholders.add(placeholderName);
|
|
44
|
+
assignments.push({
|
|
45
|
+
key,
|
|
46
|
+
value,
|
|
47
|
+
isStatic: false,
|
|
48
|
+
isPlaceholder: true,
|
|
49
|
+
placeholderName,
|
|
50
|
+
originalLine: line,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
assignments.push({
|
|
55
|
+
key,
|
|
56
|
+
value,
|
|
57
|
+
isStatic: true,
|
|
58
|
+
isPlaceholder: false,
|
|
59
|
+
originalLine: line,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return ok({
|
|
64
|
+
raw,
|
|
65
|
+
assignments,
|
|
66
|
+
placeholders: Array.from(placeholders),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Parse a .env file into a map of key → EnvEntry (preserving export, quoting, lineIndex).
|
|
71
|
+
* Returns an error if duplicate keys are found.
|
|
72
|
+
* Lines that cannot be parsed as assignments are tracked but not included in the map.
|
|
73
|
+
*/
|
|
74
|
+
export function parseEnvFile(content) {
|
|
75
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
76
|
+
const map = new Map();
|
|
77
|
+
const seen = new Map();
|
|
78
|
+
for (let i = 0; i < lines.length; i++) {
|
|
79
|
+
const line = lines[i];
|
|
80
|
+
const trimmed = line.trim();
|
|
81
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
82
|
+
continue;
|
|
83
|
+
let workingLine = trimmed;
|
|
84
|
+
let hasExport = false;
|
|
85
|
+
if (workingLine.startsWith("export ")) {
|
|
86
|
+
hasExport = true;
|
|
87
|
+
workingLine = workingLine.slice(7).trimStart();
|
|
88
|
+
}
|
|
89
|
+
const eqIndex = workingLine.indexOf("=");
|
|
90
|
+
if (eqIndex === -1) {
|
|
91
|
+
// Not a valid assignment — preserve line as-is, do not fail
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const key = workingLine.slice(0, eqIndex).trimEnd();
|
|
95
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
96
|
+
continue;
|
|
97
|
+
if (seen.has(key)) {
|
|
98
|
+
return err(templateError(`Duplicate key '${key}' at line ${i + 1} (first seen at line ${seen.get(key) + 1}) in branch file`));
|
|
99
|
+
}
|
|
100
|
+
seen.set(key, i);
|
|
101
|
+
// Value: everything after '=', preserve as-is (no trim)
|
|
102
|
+
const value = workingLine.slice(eqIndex + 1);
|
|
103
|
+
map.set(key, { key, value, lineIndex: i, originalLine: line, hasExport });
|
|
104
|
+
}
|
|
105
|
+
return ok({ map, lines });
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Synthesize a first-run template draft from common keys across branches.
|
|
109
|
+
* Uniform values → static assignment. Varying values → dynamic placeholder.
|
|
110
|
+
*/
|
|
111
|
+
export function synthesizeTemplate(branchContents) {
|
|
112
|
+
if (branchContents.length === 0) {
|
|
113
|
+
return "# Git Worker Template\n# Use KEY=value for static values enforced on all branches\n# Use KEY={KEY} to read existing value from each branch\n";
|
|
114
|
+
}
|
|
115
|
+
// Collect all keys and their values per branch
|
|
116
|
+
const keyValues = new Map(); // key → branchName → value
|
|
117
|
+
for (const { branchName, content } of branchContents) {
|
|
118
|
+
const parsed = parseEnvFile(content);
|
|
119
|
+
if (!parsed.ok)
|
|
120
|
+
continue;
|
|
121
|
+
for (const [key, entry] of parsed.value.map) {
|
|
122
|
+
if (!keyValues.has(key))
|
|
123
|
+
keyValues.set(key, new Map());
|
|
124
|
+
keyValues.get(key).set(branchName, entry.value);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Build template lines
|
|
128
|
+
const lines = [
|
|
129
|
+
"# Git Worker Template",
|
|
130
|
+
"# Static: KEY=value — enforced on every branch",
|
|
131
|
+
"# Dynamic: KEY={KEY} — reads existing value from each branch",
|
|
132
|
+
"",
|
|
133
|
+
];
|
|
134
|
+
for (const [key, branchMap] of keyValues) {
|
|
135
|
+
const values = Array.from(branchMap.values());
|
|
136
|
+
const allSame = values.every((v) => v === values[0]);
|
|
137
|
+
if (allSame) {
|
|
138
|
+
lines.push(`${key}=${values[0]}`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
lines.push(`# Varies by branch — using dynamic placeholder`);
|
|
142
|
+
lines.push(`${key}={${key}}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return lines.join("\n") + "\n";
|
|
146
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Result } from "../../core/types.js";
|
|
2
|
+
export type TemplateVariable = {
|
|
3
|
+
name: string;
|
|
4
|
+
fallback?: string;
|
|
5
|
+
raw: string;
|
|
6
|
+
};
|
|
7
|
+
export type BranchContext = {
|
|
8
|
+
branchName: string;
|
|
9
|
+
repoName?: string;
|
|
10
|
+
commitSha?: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Convert a branch name into a DNS/Docker-safe slug.
|
|
14
|
+
* e.g. "uat/payments-service" -> "uat-payments-service"
|
|
15
|
+
*/
|
|
16
|
+
export declare function slugifyBranchName(branchName: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Extract all ${VAR} and ${VAR:-default} placeholders from template text.
|
|
19
|
+
*/
|
|
20
|
+
export declare function extractTemplateVariables(templateText: string): TemplateVariable[];
|
|
21
|
+
/**
|
|
22
|
+
* Interpolate a template with variables from:
|
|
23
|
+
* 1. Built-in branch metadata (BRANCH_NAME, BRANCH_SLUG, REPO_NAME, COMMIT_SHA, SHORT_SHA)
|
|
24
|
+
* 2. Custom variables resolved from the branch's .env file
|
|
25
|
+
* 3. Fallback defaults (${VAR:-default})
|
|
26
|
+
*/
|
|
27
|
+
export declare function interpolateTemplate(templateText: string, envMap: Map<string, string>, context: BranchContext): Result<{
|
|
28
|
+
content: string;
|
|
29
|
+
missingVars: string[];
|
|
30
|
+
}>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ok } from "../../core/types.js";
|
|
2
|
+
const VARIABLE_REGEX = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
3
|
+
/**
|
|
4
|
+
* Convert a branch name into a DNS/Docker-safe slug.
|
|
5
|
+
* e.g. "uat/payments-service" -> "uat-payments-service"
|
|
6
|
+
*/
|
|
7
|
+
export function slugifyBranchName(branchName) {
|
|
8
|
+
return branchName
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
11
|
+
.replace(/^-+|-+$/g, "");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Extract all ${VAR} and ${VAR:-default} placeholders from template text.
|
|
15
|
+
*/
|
|
16
|
+
export function extractTemplateVariables(templateText) {
|
|
17
|
+
const vars = [];
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
VARIABLE_REGEX.lastIndex = 0;
|
|
20
|
+
let match;
|
|
21
|
+
while ((match = VARIABLE_REGEX.exec(templateText)) !== null) {
|
|
22
|
+
const raw = match[0];
|
|
23
|
+
const name = match[1];
|
|
24
|
+
const fallback = match[2];
|
|
25
|
+
const key = `${name}::${fallback ?? ""}`;
|
|
26
|
+
if (!seen.has(key)) {
|
|
27
|
+
seen.add(key);
|
|
28
|
+
vars.push({ name, fallback, raw });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return vars;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Interpolate a template with variables from:
|
|
35
|
+
* 1. Built-in branch metadata (BRANCH_NAME, BRANCH_SLUG, REPO_NAME, COMMIT_SHA, SHORT_SHA)
|
|
36
|
+
* 2. Custom variables resolved from the branch's .env file
|
|
37
|
+
* 3. Fallback defaults (${VAR:-default})
|
|
38
|
+
*/
|
|
39
|
+
export function interpolateTemplate(templateText, envMap, context) {
|
|
40
|
+
const branchName = context.branchName;
|
|
41
|
+
const branchSlug = slugifyBranchName(branchName);
|
|
42
|
+
const repoName = context.repoName ?? "";
|
|
43
|
+
const commitSha = context.commitSha ?? "";
|
|
44
|
+
const shortSha = commitSha.slice(0, 7);
|
|
45
|
+
const builtins = new Map([
|
|
46
|
+
["BRANCH_NAME", branchName],
|
|
47
|
+
["BRANCH_SLUG", branchSlug],
|
|
48
|
+
["REPO_NAME", repoName],
|
|
49
|
+
["COMMIT_SHA", commitSha],
|
|
50
|
+
["SHORT_SHA", shortSha],
|
|
51
|
+
]);
|
|
52
|
+
const missingSet = new Set();
|
|
53
|
+
VARIABLE_REGEX.lastIndex = 0;
|
|
54
|
+
const content = templateText.replace(VARIABLE_REGEX, (fullMatch, varName, fallback) => {
|
|
55
|
+
// 1. Built-in variables
|
|
56
|
+
if (builtins.has(varName)) {
|
|
57
|
+
return builtins.get(varName);
|
|
58
|
+
}
|
|
59
|
+
// 2. Variables from branch .env
|
|
60
|
+
if (envMap.has(varName) && envMap.get(varName) !== undefined) {
|
|
61
|
+
return envMap.get(varName);
|
|
62
|
+
}
|
|
63
|
+
// 3. Fallback default (${VAR:-default})
|
|
64
|
+
if (fallback !== undefined) {
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
// 4. Missing required variable without fallback
|
|
68
|
+
missingSet.add(varName);
|
|
69
|
+
return fullMatch;
|
|
70
|
+
});
|
|
71
|
+
return ok({
|
|
72
|
+
content,
|
|
73
|
+
missingVars: Array.from(missingSet),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { TemplateDocument, Result } from "../../core/types.js";
|
|
2
|
+
export type TransformResult = {
|
|
3
|
+
content: string;
|
|
4
|
+
changed: boolean;
|
|
5
|
+
addedKeys: string[];
|
|
6
|
+
updatedKeys: string[];
|
|
7
|
+
};
|
|
8
|
+
export type TemplateService = {
|
|
9
|
+
transform(branchContent: string, template: TemplateDocument, branchValues: Map<string, string>): Result<TransformResult>;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Apply the template to a branch's existing .env content.
|
|
13
|
+
*
|
|
14
|
+
* Rules:
|
|
15
|
+
* - Static key: update the value in-place (preserve `export` prefix if present)
|
|
16
|
+
* - Dynamic key (placeholder): if key exists in branch, leave it as-is; if missing, use branchValues
|
|
17
|
+
* - Keys not mentioned in template: preserved untouched
|
|
18
|
+
* - Preserve key ordering, comments, blank lines
|
|
19
|
+
* - Appended keys are added at end
|
|
20
|
+
*/
|
|
21
|
+
export declare function transformEnv(branchContent: string, template: TemplateDocument, branchValues: Map<string, string>): Result<TransformResult>;
|
|
22
|
+
export declare function createTemplateService(): TemplateService;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ok, err } from "../../core/types.js";
|
|
2
|
+
import { templateError } from "../../core/errors.js";
|
|
3
|
+
import { parseEnvFile } from "./env.js";
|
|
4
|
+
/**
|
|
5
|
+
* Apply the template to a branch's existing .env content.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* - Static key: update the value in-place (preserve `export` prefix if present)
|
|
9
|
+
* - Dynamic key (placeholder): if key exists in branch, leave it as-is; if missing, use branchValues
|
|
10
|
+
* - Keys not mentioned in template: preserved untouched
|
|
11
|
+
* - Preserve key ordering, comments, blank lines
|
|
12
|
+
* - Appended keys are added at end
|
|
13
|
+
*/
|
|
14
|
+
export function transformEnv(branchContent, template, branchValues) {
|
|
15
|
+
const parseResult = parseEnvFile(branchContent);
|
|
16
|
+
if (!parseResult.ok)
|
|
17
|
+
return parseResult;
|
|
18
|
+
const { map: existingMap, lines } = parseResult.value;
|
|
19
|
+
const resultLines = [...lines];
|
|
20
|
+
const updatedKeys = [];
|
|
21
|
+
const addedKeys = [];
|
|
22
|
+
for (const assignment of template.assignments) {
|
|
23
|
+
if (assignment.isPlaceholder && assignment.placeholderName) {
|
|
24
|
+
// Dynamic: read from branch
|
|
25
|
+
if (existingMap.has(assignment.key)) {
|
|
26
|
+
// Key exists — preserve exactly as-is, do nothing
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
// Key missing — append with provided value
|
|
31
|
+
const val = branchValues.get(assignment.key);
|
|
32
|
+
if (val === undefined) {
|
|
33
|
+
return err(templateError(`Missing value for '${assignment.key}' — not found in branch and no value provided`));
|
|
34
|
+
}
|
|
35
|
+
resultLines.push(`${assignment.key}=${val}`);
|
|
36
|
+
addedKeys.push(assignment.key);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// Static: enforce the template value
|
|
41
|
+
if (existingMap.has(assignment.key)) {
|
|
42
|
+
const existing = existingMap.get(assignment.key);
|
|
43
|
+
// Reconstruct line, preserving `export` keyword if it was there
|
|
44
|
+
const prefix = existing.hasExport ? "export " : "";
|
|
45
|
+
const newLine = `${prefix}${assignment.key}=${assignment.value}`;
|
|
46
|
+
if (resultLines[existing.lineIndex] !== newLine) {
|
|
47
|
+
resultLines[existing.lineIndex] = newLine;
|
|
48
|
+
updatedKeys.push(assignment.key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
resultLines.push(`${assignment.key}=${assignment.value}`);
|
|
53
|
+
addedKeys.push(assignment.key);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Reconstruct preserving trailing newline convention
|
|
58
|
+
const hadTrailingNewline = branchContent.endsWith("\n");
|
|
59
|
+
let newContent = resultLines.join("\n");
|
|
60
|
+
if (hadTrailingNewline && !newContent.endsWith("\n")) {
|
|
61
|
+
newContent += "\n";
|
|
62
|
+
}
|
|
63
|
+
const changed = newContent !== branchContent;
|
|
64
|
+
return ok({
|
|
65
|
+
content: newContent,
|
|
66
|
+
changed,
|
|
67
|
+
addedKeys,
|
|
68
|
+
updatedKeys,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export function createTemplateService() {
|
|
72
|
+
return {
|
|
73
|
+
transform: transformEnv,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ok, err } from "../../core/types.js";
|
|
2
|
+
import { templateError } from "../../core/errors.js";
|
|
3
|
+
import { parseEnvTemplate } from "./env.js";
|
|
4
|
+
import yaml from "yaml";
|
|
5
|
+
function checkUnclosedDelimiters(content) {
|
|
6
|
+
let idx = 0;
|
|
7
|
+
while ((idx = content.indexOf("${", idx)) !== -1) {
|
|
8
|
+
const closeIdx = content.indexOf("}", idx);
|
|
9
|
+
const newlineIdx = content.indexOf("\n", idx);
|
|
10
|
+
if (closeIdx === -1 || (newlineIdx !== -1 && closeIdx > newlineIdx)) {
|
|
11
|
+
const lineNum = content.slice(0, idx).split("\n").length;
|
|
12
|
+
return err(templateError(`Unclosed variable delimiter '\${' at line ${lineNum}`));
|
|
13
|
+
}
|
|
14
|
+
idx = closeIdx + 1;
|
|
15
|
+
}
|
|
16
|
+
return ok(undefined);
|
|
17
|
+
}
|
|
18
|
+
function validateJson(content) {
|
|
19
|
+
// Replace ${...} placeholders with a dummy string token to check JSON syntax
|
|
20
|
+
const sanitized = content.replace(/\$\{([^}]*)\}/g, "__GW_PLACEHOLDER__");
|
|
21
|
+
try {
|
|
22
|
+
JSON.parse(sanitized);
|
|
23
|
+
return ok(undefined);
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
return err(templateError(`JSON syntax error: ${e.message}`));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function validateYaml(content) {
|
|
30
|
+
// Replace ${...} placeholders with safe string to check YAML syntax
|
|
31
|
+
const sanitized = content.replace(/\$\{([^}]*)\}/g, "__GW_PLACEHOLDER__");
|
|
32
|
+
try {
|
|
33
|
+
yaml.parse(sanitized);
|
|
34
|
+
return ok(undefined);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return err(templateError(`YAML syntax error: ${e.message}`));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Validate the syntax of a template based on its file format and delimiters.
|
|
42
|
+
*/
|
|
43
|
+
export function validateTemplateSyntax(filePath, content) {
|
|
44
|
+
// Check unclosed delimiters first for all template formats
|
|
45
|
+
const delimiterCheck = checkUnclosedDelimiters(content);
|
|
46
|
+
if (!delimiterCheck.ok) {
|
|
47
|
+
return delimiterCheck;
|
|
48
|
+
}
|
|
49
|
+
const fileName = filePath.split("/").pop() ?? filePath;
|
|
50
|
+
// 1. .env files
|
|
51
|
+
if (fileName === ".env" || fileName.startsWith(".env.")) {
|
|
52
|
+
const envParse = parseEnvTemplate(content);
|
|
53
|
+
if (!envParse.ok) {
|
|
54
|
+
return envParse;
|
|
55
|
+
}
|
|
56
|
+
return ok(undefined);
|
|
57
|
+
}
|
|
58
|
+
// 2. JSON files
|
|
59
|
+
if (fileName.endsWith(".json")) {
|
|
60
|
+
return validateJson(content);
|
|
61
|
+
}
|
|
62
|
+
// 3. YAML files
|
|
63
|
+
if (fileName.endsWith(".yaml") || fileName.endsWith(".yml")) {
|
|
64
|
+
return validateYaml(content);
|
|
65
|
+
}
|
|
66
|
+
// Other text/Dockerfile: valid syntax if delimiters are closed
|
|
67
|
+
return ok(undefined);
|
|
68
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Result } from "../../core/types.js";
|
|
2
|
+
import { extractRepoName } from "./manager.js";
|
|
3
|
+
import { AppConfig } from "../../config/schema.js";
|
|
4
|
+
export { extractRepoName };
|
|
5
|
+
export type CachedProjectInfo = {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
sourceRepoPath: string;
|
|
9
|
+
remoteUrl?: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
gitWorkerVersion?: string;
|
|
12
|
+
projectDir: string;
|
|
13
|
+
hasRepo: boolean;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* List all cached projects in the git-worker workspace root (~/.git-worker/projects)
|
|
17
|
+
*/
|
|
18
|
+
export declare function listCachedProjects(config?: AppConfig): Promise<CachedProjectInfo[]>;
|
|
19
|
+
/**
|
|
20
|
+
* Clear a specific project cache by project ID, name, remote URL, or path.
|
|
21
|
+
*/
|
|
22
|
+
export declare function clearProjectCache(target: string, config?: AppConfig): Promise<Result<{
|
|
23
|
+
removed: string;
|
|
24
|
+
projectDir: string;
|
|
25
|
+
}>>;
|
|
26
|
+
/**
|
|
27
|
+
* Clear all cached projects and workspaces in ~/.git-worker/projects.
|
|
28
|
+
*/
|
|
29
|
+
export declare function clearAllCache(config?: AppConfig): Promise<Result<{
|
|
30
|
+
count: number;
|
|
31
|
+
root: string;
|
|
32
|
+
}>>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { readdir, readFile, rm, mkdir } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { ok, err } from "../../core/types.js";
|
|
5
|
+
import { workspaceError } from "../../core/errors.js";
|
|
6
|
+
import { getWorkspaceRoot, extractRepoName } from "./manager.js";
|
|
7
|
+
export { extractRepoName };
|
|
8
|
+
/**
|
|
9
|
+
* List all cached projects in the git-worker workspace root (~/.git-worker/projects)
|
|
10
|
+
*/
|
|
11
|
+
export async function listCachedProjects(config) {
|
|
12
|
+
const root = getWorkspaceRoot(config);
|
|
13
|
+
const projectsDir = join(root, "projects");
|
|
14
|
+
if (!existsSync(projectsDir)) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const entries = await readdir(projectsDir, { withFileTypes: true });
|
|
19
|
+
const projects = [];
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (!entry.isDirectory())
|
|
22
|
+
continue;
|
|
23
|
+
const projectDir = join(projectsDir, entry.name);
|
|
24
|
+
const markerPath = join(projectDir, ".git-worker-marker");
|
|
25
|
+
if (existsSync(markerPath)) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await readFile(markerPath, "utf-8");
|
|
28
|
+
const data = JSON.parse(raw);
|
|
29
|
+
const hasRepo = existsSync(join(projectDir, "repo"));
|
|
30
|
+
const name = data.name ||
|
|
31
|
+
extractRepoName(data.remoteUrl || data.sourceRepoPath, entry.name);
|
|
32
|
+
projects.push({
|
|
33
|
+
id: data.id || entry.name,
|
|
34
|
+
name,
|
|
35
|
+
sourceRepoPath: data.sourceRepoPath || "",
|
|
36
|
+
remoteUrl: data.remoteUrl || undefined,
|
|
37
|
+
createdAt: data.createdAt || "",
|
|
38
|
+
gitWorkerVersion: data.gitWorkerVersion,
|
|
39
|
+
projectDir,
|
|
40
|
+
hasRepo,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// malformed marker, still list with id
|
|
45
|
+
projects.push({
|
|
46
|
+
id: entry.name,
|
|
47
|
+
name: entry.name,
|
|
48
|
+
sourceRepoPath: "",
|
|
49
|
+
projectDir,
|
|
50
|
+
createdAt: "",
|
|
51
|
+
hasRepo: existsSync(join(projectDir, "repo")),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return projects.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Clear a specific project cache by project ID, name, remote URL, or path.
|
|
64
|
+
*/
|
|
65
|
+
export async function clearProjectCache(target, config) {
|
|
66
|
+
const projects = await listCachedProjects(config);
|
|
67
|
+
const trimmed = target.trim();
|
|
68
|
+
const match = projects.find((p) => p.id === trimmed ||
|
|
69
|
+
p.id.startsWith(trimmed) ||
|
|
70
|
+
p.name.toLowerCase() === trimmed.toLowerCase() ||
|
|
71
|
+
(p.remoteUrl && (p.remoteUrl === trimmed || p.remoteUrl.includes(trimmed))) ||
|
|
72
|
+
p.sourceRepoPath === trimmed ||
|
|
73
|
+
p.sourceRepoPath.includes(trimmed));
|
|
74
|
+
if (!match) {
|
|
75
|
+
return err(workspaceError(`No cached project found matching '${target}'. Run 'git-worker cache list' to view all cached projects.`));
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await rm(match.projectDir, { recursive: true, force: true });
|
|
79
|
+
return ok({
|
|
80
|
+
removed: `${match.name} (${match.remoteUrl || match.sourceRepoPath || match.id})`,
|
|
81
|
+
projectDir: match.projectDir,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return err(workspaceError(`Failed to remove project cache: ${e.message}`, e));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Clear all cached projects and workspaces in ~/.git-worker/projects.
|
|
90
|
+
*/
|
|
91
|
+
export async function clearAllCache(config) {
|
|
92
|
+
const root = getWorkspaceRoot(config);
|
|
93
|
+
const projectsDir = join(root, "projects");
|
|
94
|
+
if (!existsSync(projectsDir)) {
|
|
95
|
+
return ok({ count: 0, root: projectsDir });
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const projects = await listCachedProjects(config);
|
|
99
|
+
const count = projects.length;
|
|
100
|
+
await rm(projectsDir, { recursive: true, force: true });
|
|
101
|
+
await mkdir(projectsDir, { recursive: true });
|
|
102
|
+
return ok({ count, root: projectsDir });
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return err(workspaceError(`Failed to clear cache: ${e.message}`, e));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Result, ManagedProject } from "../../core/types.js";
|
|
2
|
+
import { AppConfig } from "../../config/schema.js";
|
|
3
|
+
export type RepoInfo = {
|
|
4
|
+
isRepo: boolean;
|
|
5
|
+
remoteUrl?: string;
|
|
6
|
+
remoteName?: string;
|
|
7
|
+
};
|
|
8
|
+
export type WorkspaceManager = {
|
|
9
|
+
resolveProject(sourceRepoPath: string, remote: string | undefined, config: AppConfig): Promise<Result<ManagedProject>>;
|
|
10
|
+
checkLocalRepo(dirPath: string, preferredRemote?: string): Promise<RepoInfo>;
|
|
11
|
+
ensureManagedClone(project: ManagedProject): Promise<Result<{
|
|
12
|
+
isCached: boolean;
|
|
13
|
+
}>>;
|
|
14
|
+
verifyOwnership(project: ManagedProject): Result<void>;
|
|
15
|
+
acquireLock(project: ManagedProject, timeoutMs: number): Promise<Result<() => void>>;
|
|
16
|
+
releaseLock(project: ManagedProject): void;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Extract a human-readable repository name from a remote URL or file path.
|
|
20
|
+
* e.g. "https://github.com/org/repo.git" -> "repo"
|
|
21
|
+
* e.g. "git@github.com:org/repo.git" -> "repo"
|
|
22
|
+
* e.g. "/path/to/my-repo" -> "my-repo"
|
|
23
|
+
*/
|
|
24
|
+
export declare function extractRepoName(urlOrPath?: string, fallbackId?: string): string;
|
|
25
|
+
export declare function getWorkspaceRoot(config?: AppConfig): string;
|
|
26
|
+
/**
|
|
27
|
+
* Verify that the given path is inside our workspace and the marker file is valid.
|
|
28
|
+
* This is the ownership guard that must run before any destructive ops.
|
|
29
|
+
*/
|
|
30
|
+
export declare function verifyOwnership(project: ManagedProject): Result<void>;
|
|
31
|
+
export declare function checkLocalRepo(dirPath: string, preferredRemote?: string): Promise<RepoInfo>;
|
|
32
|
+
export declare function resolveProject(sourceRepoPath: string, remote: string | undefined, config: AppConfig): Promise<Result<ManagedProject>>;
|
|
33
|
+
export declare function ensureManagedClone(project: ManagedProject): Promise<Result<{
|
|
34
|
+
isCached: boolean;
|
|
35
|
+
}>>;
|
|
36
|
+
export declare function releaseLock(project: ManagedProject): void;
|
|
37
|
+
/**
|
|
38
|
+
* Acquire a file-based lock on the managed checkout.
|
|
39
|
+
* Returns a release function to call when done.
|
|
40
|
+
* Checks for and cleans up stale locks from terminated processes.
|
|
41
|
+
* Waits up to timeoutMs for the lock to become available.
|
|
42
|
+
*/
|
|
43
|
+
export declare function acquireLock(project: ManagedProject, timeoutMs: number): Promise<Result<() => void>>;
|
|
44
|
+
export declare function createWorkspaceManager(): WorkspaceManager;
|