@samaksh/git-worker 0.1.0 → 0.2.1

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.
Files changed (47) hide show
  1. package/dist/cli/index.js +1 -1
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/services/git/reader.js +105 -34
  5. package/dist/services/workspace/manager.js +1 -1
  6. package/dist/stages/eligibility.js +38 -14
  7. package/dist/stages/select.js +46 -37
  8. package/package.json +11 -1
  9. package/GIT_WORKER_PROJECT_PLAN.md +0 -499
  10. package/demo/README.md +0 -58
  11. package/demo/index.ts +0 -526
  12. package/src/app.ts +0 -69
  13. package/src/cli/commands.ts +0 -142
  14. package/src/cli/index.ts +0 -123
  15. package/src/config/defaults.ts +0 -46
  16. package/src/config/load.ts +0 -70
  17. package/src/config/schema.ts +0 -48
  18. package/src/core/errors.ts +0 -45
  19. package/src/core/pipeline.ts +0 -17
  20. package/src/core/types.ts +0 -188
  21. package/src/services/editor/index.ts +0 -85
  22. package/src/services/git/ops.ts +0 -170
  23. package/src/services/git/reader.ts +0 -148
  24. package/src/services/report/index.ts +0 -104
  25. package/src/services/review/index.ts +0 -80
  26. package/src/services/state/index.ts +0 -77
  27. package/src/services/template/env.ts +0 -169
  28. package/src/services/template/interpolator.ts +0 -109
  29. package/src/services/template/transform.ts +0 -97
  30. package/src/services/template/validator.ts +0 -75
  31. package/src/services/workspace/cache.ts +0 -144
  32. package/src/services/workspace/manager.ts +0 -370
  33. package/src/stages/apply.ts +0 -194
  34. package/src/stages/edit-template.ts +0 -227
  35. package/src/stages/eligibility.ts +0 -67
  36. package/src/stages/load-template.ts +0 -188
  37. package/src/stages/plan.ts +0 -362
  38. package/src/stages/push.ts +0 -120
  39. package/src/stages/report.ts +0 -126
  40. package/src/stages/resolve-project.ts +0 -341
  41. package/src/stages/review.ts +0 -228
  42. package/src/stages/select.ts +0 -302
  43. package/src/stages/sync.ts +0 -63
  44. package/src/utils/hash.ts +0 -15
  45. package/src/utils/ui.ts +0 -137
  46. package/src/utils/which.ts +0 -18
  47. package/tsconfig.json +0 -18
@@ -1,85 +0,0 @@
1
- import { Result, ok, err } from "../../core/types.js";
2
- import { editorError, inputCancelledError } from "../../core/errors.js";
3
- import { spawn } from "child_process";
4
- import { AppConfig } from "../../config/schema.js";
5
- import { existsSync } from "fs";
6
- import { which } from "../../utils/which.js";
7
-
8
- export type EditorService = {
9
- openForEdit(templatePath: string, config: AppConfig): Promise<Result<void>>;
10
- openForDiff(beforePath: string, afterPath: string, config: AppConfig): Promise<Result<void>>;
11
- };
12
-
13
- /**
14
- * Substitute {file}, {before}, {after} placeholders in a command template.
15
- */
16
- function buildCommand(
17
- template: string[],
18
- vars: Record<string, string>
19
- ): string[] {
20
- return template.map((part) => {
21
- let result = part;
22
- for (const [k, v] of Object.entries(vars)) {
23
- result = result.replace(`{${k}}`, v);
24
- }
25
- return result;
26
- });
27
- }
28
-
29
- /**
30
- * Spawn a command and wait for it to exit.
31
- * Returns an error if the process exits with a non-zero code.
32
- */
33
- async function runCommand(
34
- cmd: string[],
35
- description: string
36
- ): Promise<Result<void>> {
37
- return new Promise((resolve) => {
38
- const [bin, ...args] = cmd;
39
- const proc = spawn(bin, args, { stdio: "inherit" });
40
-
41
- proc.on("error", (e: any) => {
42
- if (e.code === "ENOENT") {
43
- resolve(err(editorError(`Editor '${bin}' not found. Install it or set a different editor via --editor or git-worker.config.json.`)));
44
- } else {
45
- resolve(err(editorError(`${description} failed: ${e.message}`)));
46
- }
47
- });
48
-
49
- proc.on("close", (code) => {
50
- if (code === 0) {
51
- resolve(ok(undefined));
52
- } else {
53
- // Some editors (like VS Code) return non-zero on cancel
54
- resolve(ok(undefined));
55
- }
56
- });
57
- });
58
- }
59
-
60
- export async function openForEdit(
61
- templatePath: string,
62
- config: AppConfig
63
- ): Promise<Result<void>> {
64
- const cmd = buildCommand(config.editor.editCommand, { file: templatePath });
65
- return runCommand(cmd, "Editor");
66
- }
67
-
68
- export async function openForDiff(
69
- beforePath: string,
70
- afterPath: string,
71
- config: AppConfig
72
- ): Promise<Result<void>> {
73
- const cmd = buildCommand(config.editor.diffCommand, {
74
- before: beforePath,
75
- after: afterPath,
76
- });
77
- return runCommand(cmd, "Diff viewer");
78
- }
79
-
80
- export function createEditorService(): EditorService {
81
- return {
82
- openForEdit,
83
- openForDiff,
84
- };
85
- }
@@ -1,170 +0,0 @@
1
- import { simpleGit, SimpleGit } from "simple-git";
2
- import { Result, ok, err } from "../../core/types.js";
3
- import { gitError } from "../../core/errors.js";
4
- import { writeFile, mkdir, rename, mkdtemp } from "fs/promises";
5
- import { dirname, join } from "path";
6
- import { tmpdir } from "os";
7
-
8
- export type GitOps = {
9
- ensureRepo(repoPath: string): Promise<Result<void>>;
10
- checkoutBranch(repoPath: string, branchName: string): Promise<Result<void>>;
11
- resetHard(repoPath: string): Promise<Result<void>>;
12
- clean(repoPath: string, mode?: "tracked-and-untracked" | "tracked-only"): Promise<Result<void>>;
13
- writeAndStage(repoPath: string, filePath: string, content: string): Promise<Result<void>>;
14
- getStagedFiles(repoPath: string): Promise<Result<string[]>>;
15
- commit(repoPath: string, message: string): Promise<Result<string>>;
16
- push(repoPath: string, remote: string, branchName: string): Promise<Result<void>>;
17
- getBranchSha(repoPath: string, branchName: string): Promise<Result<string>>;
18
- };
19
-
20
- export async function ensureRepo(repoPath: string): Promise<Result<void>> {
21
- try {
22
- const git: SimpleGit = simpleGit(repoPath);
23
- const isRepo = await git.checkIsRepo();
24
- if (!isRepo) return err(gitError("Not a git repository"));
25
- return ok(undefined);
26
- } catch (e: any) {
27
- return err(gitError("Failed to check repository", e));
28
- }
29
- }
30
-
31
- export async function checkoutBranch(
32
- repoPath: string,
33
- branchName: string
34
- ): Promise<Result<void>> {
35
- try {
36
- const git: SimpleGit = simpleGit(repoPath);
37
- // Align local branch to origin/branchName if remote branch exists
38
- try {
39
- await git.checkout(["-B", branchName, `origin/${branchName}`]);
40
- } catch {
41
- // Fallback for local-only branch
42
- await git.checkout(branchName);
43
- }
44
- return ok(undefined);
45
- } catch (e: any) {
46
- return err(gitError(`Failed to checkout '${branchName}'`, e));
47
- }
48
- }
49
-
50
- export async function resetHard(repoPath: string): Promise<Result<void>> {
51
- try {
52
- const git: SimpleGit = simpleGit(repoPath);
53
- await git.reset(["--hard"]);
54
- return ok(undefined);
55
- } catch (e: any) {
56
- return err(gitError("Failed to reset --hard", e));
57
- }
58
- }
59
-
60
- export async function clean(
61
- repoPath: string,
62
- mode: "tracked-and-untracked" | "tracked-only" = "tracked-and-untracked"
63
- ): Promise<Result<void>> {
64
- try {
65
- const git: SimpleGit = simpleGit(repoPath);
66
- if (mode === "tracked-and-untracked") {
67
- await git.clean("f", ["-d"]);
68
- }
69
- // tracked-only: git reset --hard already handles it
70
- return ok(undefined);
71
- } catch (e: any) {
72
- return err(gitError("Failed to clean working tree", e));
73
- }
74
- }
75
-
76
- /**
77
- * Atomically write a file (via temp file + rename) and stage it.
78
- * Only stages the specific path — no glob or wildcard.
79
- */
80
- export async function writeAndStage(
81
- repoPath: string,
82
- filePath: string,
83
- content: string
84
- ): Promise<Result<void>> {
85
- try {
86
- const fullPath = join(repoPath, filePath);
87
- await mkdir(dirname(fullPath), { recursive: true });
88
-
89
- // Atomic write: write to temp, then rename
90
- const tmp = fullPath + ".git-worker-tmp";
91
- await writeFile(tmp, content, "utf-8");
92
- await rename(tmp, fullPath);
93
-
94
- const git: SimpleGit = simpleGit(repoPath);
95
- await git.add([filePath]);
96
- return ok(undefined);
97
- } catch (e: any) {
98
- return err(gitError(`Failed to write/stage '${filePath}'`, e));
99
- }
100
- }
101
-
102
- /**
103
- * Returns the list of staged file paths.
104
- * Used to verify only the intended path is staged before committing.
105
- */
106
- export async function getStagedFiles(repoPath: string): Promise<Result<string[]>> {
107
- try {
108
- const git: SimpleGit = simpleGit(repoPath);
109
- const diff = await git.diff(["--cached", "--name-only"]);
110
- const files = diff
111
- .split("\n")
112
- .map((l) => l.trim())
113
- .filter(Boolean);
114
- return ok(files);
115
- } catch (e: any) {
116
- return err(gitError("Failed to get staged files", e));
117
- }
118
- }
119
-
120
- export async function commit(repoPath: string, message: string): Promise<Result<string>> {
121
- try {
122
- const git: SimpleGit = simpleGit(repoPath);
123
- const result = await git.commit(message);
124
- return ok(result.commit);
125
- } catch (e: any) {
126
- return err(gitError(`Failed to commit: ${e.message}`, e));
127
- }
128
- }
129
-
130
- export async function push(
131
- repoPath: string,
132
- remote: string,
133
- branchName: string
134
- ): Promise<Result<void>> {
135
- try {
136
- const git: SimpleGit = simpleGit(repoPath);
137
- // Non-force push only
138
- await git.push([remote, `${branchName}:${branchName}`]);
139
- return ok(undefined);
140
- } catch (e: any) {
141
- return err(gitError(`Failed to push '${branchName}' to '${remote}': ${e.message}`, e));
142
- }
143
- }
144
-
145
- export async function getBranchSha(
146
- repoPath: string,
147
- branchName: string
148
- ): Promise<Result<string>> {
149
- try {
150
- const git: SimpleGit = simpleGit(repoPath);
151
- const sha = await git.revparse([branchName]);
152
- return ok(sha.trim());
153
- } catch (e: any) {
154
- return err(gitError(`Failed to get SHA for '${branchName}'`, e));
155
- }
156
- }
157
-
158
- export function createGitOps(): GitOps {
159
- return {
160
- ensureRepo,
161
- checkoutBranch,
162
- resetHard,
163
- clean,
164
- writeAndStage,
165
- getStagedFiles,
166
- commit,
167
- push,
168
- getBranchSha,
169
- };
170
- }
@@ -1,148 +0,0 @@
1
- import { simpleGit, SimpleGit } from "simple-git";
2
- import { Result, ok, err, BranchRef } from "../../core/types.js";
3
- import { gitError } from "../../core/errors.js";
4
-
5
- export type GitReader = {
6
- readFileFromBranch(repoPath: string, branch: BranchRef, filePath: string): Promise<Result<string>>;
7
- listBranches(repoPath: string, remote?: string): Promise<Result<BranchRef[]>>;
8
- getCurrentBranch(repoPath: string): Promise<Result<string>>;
9
- fetch(repoPath: string, remote?: string, prune?: boolean): Promise<Result<void>>;
10
- getRemoteUrl(repoPath: string, remote?: string): Promise<Result<string>>;
11
- };
12
-
13
- export async function readFileFromBranch(
14
- repoPath: string,
15
- branch: BranchRef,
16
- filePath: string
17
- ): Promise<Result<string>> {
18
- try {
19
- const git: SimpleGit = simpleGit(repoPath);
20
- // For remote branches, use the full remote ref; for local use name
21
- const ref =
22
- branch.isRemote && branch.remote
23
- ? `${branch.remote}/${branch.name}`
24
- : branch.name;
25
- const content = await git.show([`${ref}:${filePath}`]);
26
- return ok(content);
27
- } catch (e: any) {
28
- if (
29
- e.message?.includes("does not exist") ||
30
- e.message?.includes("fatal") ||
31
- e.message?.includes("not a valid object")
32
- ) {
33
- return err(gitError(`File '${filePath}' not found in branch '${branch.name}'`, e));
34
- }
35
- return err(gitError(`Failed to read '${filePath}' from '${branch.name}'`, e));
36
- }
37
- }
38
-
39
- export async function listBranches(
40
- repoPath: string,
41
- remote?: string
42
- ): Promise<Result<BranchRef[]>> {
43
- try {
44
- const git: SimpleGit = simpleGit(repoPath);
45
- const branches: BranchRef[] = [];
46
- const seen = new Set<string>();
47
-
48
- // Remote branches (source of truth for managed clone)
49
- try {
50
- const remoteBranches = await git.branch(["-r"]);
51
- for (const fullName of remoteBranches.all) {
52
- if (fullName.includes("HEAD")) continue;
53
- const parts = fullName.trim().split("/");
54
- if (parts.length < 2) continue;
55
- const remoteName = parts[0];
56
- const branchName = parts.slice(1).join("/");
57
-
58
- if (remote && remoteName !== remote) continue;
59
- if (seen.has(branchName)) continue;
60
-
61
- try {
62
- const sha = await git.revparse([fullName.trim()]);
63
- seen.add(branchName);
64
- branches.push({
65
- name: branchName,
66
- remote: remoteName,
67
- sha: sha.trim(),
68
- isRemote: true,
69
- });
70
- } catch {
71
- // skip branches we can't resolve
72
- }
73
- }
74
- } catch {
75
- // No remotes is ok
76
- }
77
-
78
- // Local branches (only if not already discovered on remote)
79
- try {
80
- const local = await git.branchLocal();
81
- for (const name of local.all) {
82
- if (!seen.has(name)) {
83
- const sha = await git.revparse([name]);
84
- seen.add(name);
85
- branches.push({ name, sha: sha.trim(), isRemote: false });
86
- }
87
- }
88
- } catch {
89
- // ignore
90
- }
91
-
92
- return ok(branches);
93
- } catch (e: any) {
94
- return err(gitError("Failed to list branches", e));
95
- }
96
- }
97
-
98
- export async function getCurrentBranch(repoPath: string): Promise<Result<string>> {
99
- try {
100
- const git: SimpleGit = simpleGit(repoPath);
101
- const branch = await git.revparse(["--abbrev-ref", "HEAD"]);
102
- return ok(branch.trim());
103
- } catch (e: any) {
104
- return err(gitError("Failed to get current branch", e));
105
- }
106
- }
107
-
108
- export async function fetch(
109
- repoPath: string,
110
- remote: string = "origin",
111
- prune: boolean = true
112
- ): Promise<Result<void>> {
113
- try {
114
- const git: SimpleGit = simpleGit(repoPath);
115
- const args = prune ? [remote, "--prune"] : [remote];
116
- await git.fetch(args);
117
- return ok(undefined);
118
- } catch (e: any) {
119
- return err(gitError(`Failed to fetch from '${remote}'`, e));
120
- }
121
- }
122
-
123
- export async function getRemoteUrl(
124
- repoPath: string,
125
- remote: string = "origin"
126
- ): Promise<Result<string>> {
127
- try {
128
- const git: SimpleGit = simpleGit(repoPath);
129
- const remotes = await git.getRemotes(true);
130
- const target = remotes.find((r) => r.name === remote) || remotes[0];
131
- if (!target?.refs?.fetch) {
132
- return err(gitError(`No remote '${remote}' found`));
133
- }
134
- return ok(target.refs.fetch);
135
- } catch (e: any) {
136
- return err(gitError("Failed to get remote URL", e));
137
- }
138
- }
139
-
140
- export function createGitReader(): GitReader {
141
- return {
142
- readFileFromBranch,
143
- listBranches,
144
- getCurrentBranch,
145
- fetch,
146
- getRemoteUrl,
147
- };
148
- }
@@ -1,104 +0,0 @@
1
- import { BranchResult, PushResult, BranchPlan } from "../../core/types.js";
2
- import { AppConfig } from "../../config/schema.js";
3
- import { writeFile, mkdir } from "fs/promises";
4
- import { join } from "path";
5
-
6
- export type RunReport = {
7
- runId: string;
8
- targetPath: string;
9
- startedAt: string;
10
- completedAt: string;
11
- plans: BranchPlan[];
12
- results: BranchResult[];
13
- pushResults: PushResult[];
14
- };
15
-
16
- export type ReportService = {
17
- saveJson(reportsDir: string, report: RunReport, config: AppConfig): Promise<void>;
18
- saveMarkdown(reportsDir: string, report: RunReport, config: AppConfig): Promise<void>;
19
- };
20
-
21
- function redactContent(text: string): string {
22
- // Replace value portions of assignments with [REDACTED]
23
- return text.replace(/^([A-Za-z_][A-Za-z0-9_]*)=.+$/gm, "$1=[REDACTED]");
24
- }
25
-
26
- export async function saveJson(
27
- reportsDir: string,
28
- report: RunReport,
29
- config: AppConfig
30
- ): Promise<void> {
31
- if (!config.report.writeJson) return;
32
- await mkdir(reportsDir, { recursive: true });
33
- const filePath = join(reportsDir, `${report.runId}.json`);
34
-
35
- let data = report;
36
- if (config.report.redactValues) {
37
- data = {
38
- ...report,
39
- plans: report.plans.map((p) => ({
40
- ...p,
41
- beforeContent: redactContent(p.beforeContent),
42
- afterContent: redactContent(p.afterContent),
43
- diff: redactContent(p.diff),
44
- })),
45
- };
46
- }
47
-
48
- await writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
49
- }
50
-
51
- export async function saveMarkdown(
52
- reportsDir: string,
53
- report: RunReport,
54
- config: AppConfig
55
- ): Promise<void> {
56
- if (!config.report.writeMarkdown) return;
57
- await mkdir(reportsDir, { recursive: true });
58
- const filePath = join(reportsDir, `${report.runId}.md`);
59
-
60
- const statusEmoji: Record<string, string> = {
61
- committed: "✅",
62
- failed: "❌",
63
- rejected: "⊘",
64
- unchanged: "○",
65
- skipped: "–",
66
- };
67
-
68
- const lines = [
69
- `# Git Worker Run Report`,
70
- ``,
71
- `**Run ID:** ${report.runId} `,
72
- `**Target:** ${report.targetPath} `,
73
- `**Started:** ${report.startedAt} `,
74
- `**Completed:** ${report.completedAt} `,
75
- ``,
76
- `## Results`,
77
- ``,
78
- `| Branch | Status | Commit |`,
79
- `|--------|--------|--------|`,
80
- ...report.results.map(
81
- (r) =>
82
- `| ${r.branch.name} | ${statusEmoji[r.status] ?? r.status} ${r.status} | ${r.commitSha ? `\`${r.commitSha.slice(0, 8)}\`` : "—"} |`
83
- ),
84
- ``,
85
- ];
86
-
87
- if (report.pushResults.length > 0) {
88
- lines.push(`## Push Results`, ``);
89
- lines.push(`| Branch | Status |`, `|--------|--------|`);
90
- for (const pr of report.pushResults) {
91
- lines.push(`| ${pr.branch.name} | ${pr.status} |`);
92
- }
93
- lines.push(``);
94
- }
95
-
96
- await writeFile(filePath, lines.join("\n"), "utf-8");
97
- }
98
-
99
- export function createReportService(): ReportService {
100
- return {
101
- saveJson,
102
- saveMarkdown,
103
- };
104
- }
@@ -1,80 +0,0 @@
1
- import { Result, ok, err, BranchPlan } from "../../core/types.js";
2
- import { workspaceError } from "../../core/errors.js";
3
- import { writeFile, mkdir } from "fs/promises";
4
- import { join } from "path";
5
- import { createPatch } from "diff";
6
-
7
- export type ReviewService = {
8
- writeSnapshots(
9
- stagingDir: string,
10
- runId: string,
11
- plan: BranchPlan
12
- ): Promise<Result<{ beforePath: string; afterPath: string }>>;
13
- computeDiff(before: string, after: string, filePath: string): string;
14
- };
15
-
16
- /**
17
- * Write before/after snapshots to the staging directory for a plan.
18
- * Returns the file paths for opening in a diff viewer.
19
- */
20
- export async function writeSnapshots(
21
- stagingDir: string,
22
- runId: string,
23
- plan: BranchPlan
24
- ): Promise<Result<{ beforePath: string; afterPath: string }>> {
25
- try {
26
- const planDir = join(stagingDir, runId, plan.branch.name.replace(/\//g, "__"));
27
- await mkdir(planDir, { recursive: true });
28
-
29
- const beforePath = join(planDir, `before.${plan.path.split("/").pop()}`);
30
- const afterPath = join(planDir, `after.${plan.path.split("/").pop()}`);
31
-
32
- await writeFile(beforePath, plan.beforeContent, "utf-8");
33
- await writeFile(afterPath, plan.afterContent, "utf-8");
34
-
35
- return ok({ beforePath, afterPath });
36
- } catch (e: any) {
37
- return err(workspaceError(`Failed to write snapshots: ${e.message}`));
38
- }
39
- }
40
-
41
- /**
42
- * Compute a unified diff string between before and after content.
43
- * Uses the `diff` package for accurate line-level diffing.
44
- */
45
- export function computeDiff(
46
- before: string,
47
- after: string,
48
- filePath: string
49
- ): string {
50
- if (before === after) return "";
51
- return createPatch(filePath, before, after, "before", "after");
52
- }
53
-
54
- /**
55
- * Format a diff for terminal display with color markers.
56
- * Returns an array of lines with +/- prefixes for coloring.
57
- */
58
- export function parseDiffLines(
59
- diff: string
60
- ): Array<{ line: string; type: "added" | "removed" | "header" | "context" }> {
61
- const lines = diff.split("\n");
62
- return lines.map((line) => {
63
- if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@")) {
64
- return { line, type: "header" as const };
65
- } else if (line.startsWith("+")) {
66
- return { line, type: "added" as const };
67
- } else if (line.startsWith("-")) {
68
- return { line, type: "removed" as const };
69
- } else {
70
- return { line, type: "context" as const };
71
- }
72
- });
73
- }
74
-
75
- export function createReviewService(): ReviewService {
76
- return {
77
- writeSnapshots,
78
- computeDiff,
79
- };
80
- }
@@ -1,77 +0,0 @@
1
- import { Result, ok, err } from "../../core/types.js";
2
- import { workspaceError } from "../../core/errors.js";
3
- import { readFile, writeFile, mkdir } from "fs/promises";
4
- import { join } from "path";
5
- import { existsSync } from "fs";
6
-
7
- export type TemplateState = {
8
- schemaVersion: 1;
9
- targetPath: string;
10
- template: string;
11
- updatedAt: string;
12
- lastRunSummary?: {
13
- committed: number;
14
- failed: number;
15
- skipped: number;
16
- total: number;
17
- };
18
- };
19
-
20
- export type StateService = {
21
- loadTemplate(stateDir: string, targetPath: string): Promise<Result<TemplateState | null>>;
22
- saveTemplate(stateDir: string, targetPath: string, templateRaw: string, summary?: TemplateState["lastRunSummary"]): Promise<Result<void>>;
23
- };
24
-
25
- function stateFileName(targetPath: string): string {
26
- return targetPath.replace(/[/\\]/g, "__") + ".state.json";
27
- }
28
-
29
- export async function loadTemplate(
30
- stateDir: string,
31
- targetPath: string
32
- ): Promise<Result<TemplateState | null>> {
33
- const filePath = join(stateDir, stateFileName(targetPath));
34
- if (!existsSync(filePath)) {
35
- return ok(null);
36
- }
37
- try {
38
- const raw = await readFile(filePath, "utf-8");
39
- const data = JSON.parse(raw) as TemplateState;
40
- if (data.schemaVersion !== 1) {
41
- return ok(null); // Unknown schema — start fresh
42
- }
43
- return ok(data);
44
- } catch (e: any) {
45
- return err(workspaceError(`Failed to load template state: ${e.message}`));
46
- }
47
- }
48
-
49
- export async function saveTemplate(
50
- stateDir: string,
51
- targetPath: string,
52
- templateRaw: string,
53
- summary?: TemplateState["lastRunSummary"]
54
- ): Promise<Result<void>> {
55
- try {
56
- await mkdir(stateDir, { recursive: true });
57
- const filePath = join(stateDir, stateFileName(targetPath));
58
- const state: TemplateState = {
59
- schemaVersion: 1,
60
- targetPath,
61
- template: templateRaw,
62
- updatedAt: new Date().toISOString(),
63
- ...(summary ? { lastRunSummary: summary } : {}),
64
- };
65
- await writeFile(filePath, JSON.stringify(state, null, 2), "utf-8");
66
- return ok(undefined);
67
- } catch (e: any) {
68
- return err(workspaceError(`Failed to save template state: ${e.message}`));
69
- }
70
- }
71
-
72
- export function createStateService(): StateService {
73
- return {
74
- loadTemplate,
75
- saveTemplate,
76
- };
77
- }