@netnodeag/kraftwerk 0.36.0 → 0.37.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 +39 -1
- package/dist/cli/doctor.js +18 -1
- package/dist/cli/init.js +16 -9
- package/dist/cli/kraftwerk.js +3 -0
- package/dist/cli/projects.d.ts +14 -0
- package/dist/cli/projects.js +1 -1
- package/dist/cli/repos.d.ts +2 -0
- package/dist/cli/repos.js +115 -0
- package/dist/cli/routines.d.ts +10 -0
- package/dist/cli/routines.js +1 -1
- package/dist/config.d.ts +27 -0
- package/dist/config.js +55 -1
- package/dist/inspector/chat/sessions.js +29 -2
- package/dist/inspector/git.d.ts +14 -0
- package/dist/inspector/git.js +11 -9
- package/dist/inspector/repos.d.ts +118 -0
- package/dist/inspector/repos.js +249 -0
- package/dist/inspector/server.js +43 -1
- package/dist/inspector/settings.d.ts +7 -1
- package/dist/inspector/settings.js +32 -1
- package/inspector/dist/assets/{editor-7ImnDC3D.js → editor-vM6JSj5h.js} +3 -3
- package/inspector/dist/assets/{index-DbmSSHvr.js → index-BAAjszPI.js} +1 -1
- package/inspector/dist/assets/{index-DjRBLY9N.js → index-BKcOp35r.js} +1 -1
- package/inspector/dist/assets/index-Ba8VU6Wz.js +217 -0
- package/inspector/dist/assets/{index-C3Ob1O7W.js → index-Bb08wK-l.js} +1 -1
- package/inspector/dist/assets/{index-Ct_jZSyD.js → index-BrCwDkJZ.js} +1 -1
- package/inspector/dist/assets/{index-DKrBjjGQ.js → index-Bs1U0rWW.js} +1 -1
- package/inspector/dist/assets/{index-BC7aIJ-h.js → index-C1UXlW32.js} +1 -1
- package/inspector/dist/assets/{index-CIjv7yxE.js → index-CQ2XKqmD.js} +1 -1
- package/inspector/dist/assets/{index-BuRajvjQ.js → index-CReepirH.js} +1 -1
- package/inspector/dist/assets/{index-YZtBJhWD.js → index-CUGfxoYt.js} +1 -1
- package/inspector/dist/assets/{index-B_CIhKa4.js → index-CaKmxcD6.js} +1 -1
- package/inspector/dist/assets/{index-BZeYO5wT.js → index-CtMGH62C.js} +1 -1
- package/inspector/dist/assets/{index-BA7nx4V5.css → index-D4_mLTSH.css} +1 -1
- package/inspector/dist/assets/{index-DPBAv_kj.js → index-D_11IZBV.js} +1 -1
- package/inspector/dist/assets/{index-CRAwmMfk.js → index-DfZT0IoM.js} +1 -1
- package/inspector/dist/assets/{index-DL_bJiOj.js → index-DykDNrYm.js} +1 -1
- package/inspector/dist/assets/{index-BIXYBiw7.js → index-MWv5H1oP.js} +1 -1
- package/inspector/dist/assets/{index-fWUFhIGR.js → index-oIQqfbwm.js} +1 -1
- package/inspector/dist/assets/{index--2I7vpcS.js → index-yGZsomcy.js} +1 -1
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +15 -0
- package/inspector/dist/assets/index-B2ThRS2S.js +0 -217
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type Project } from "../config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Repositories: the git clones an agent works on, kept under one root
|
|
4
|
+
* inside the project (`repos.root` in kraftwerk.yml, default repos/). The
|
|
5
|
+
* folder is the registry: whatever has a .git directory directly under the
|
|
6
|
+
* root is a repository, whoever put it there — this module, the CLI, or an
|
|
7
|
+
* agent running plain `git clone`. Everything shown about a clone is read
|
|
8
|
+
* from git on request, so nothing here can go stale.
|
|
9
|
+
*
|
|
10
|
+
* The root lives inside the project on purpose: chat sessions run in the
|
|
11
|
+
* project root, so every harness reaches the clones without extra grants.
|
|
12
|
+
* It must stay out of the workspace git: `kraftwerk init` ignores it, and
|
|
13
|
+
* the workspace sync never stages it.
|
|
14
|
+
*
|
|
15
|
+
* Like git.ts, every call passes an argument array (nothing reaches a
|
|
16
|
+
* shell), never prompts, and times out — a clone from a web UI must fail
|
|
17
|
+
* loudly, not park on a credential prompt nobody sees.
|
|
18
|
+
*/
|
|
19
|
+
export interface RepoInfo {
|
|
20
|
+
/** Folder name under the root; the id everywhere. */
|
|
21
|
+
slug: string;
|
|
22
|
+
/** Absolute path of the clone. */
|
|
23
|
+
path: string;
|
|
24
|
+
/** origin url, absent when the clone has no origin. */
|
|
25
|
+
url?: string;
|
|
26
|
+
/** Checked-out branch, or "HEAD" when detached. */
|
|
27
|
+
branch?: string;
|
|
28
|
+
/** Short hash of HEAD. */
|
|
29
|
+
head?: string;
|
|
30
|
+
/** Subject of the HEAD commit. */
|
|
31
|
+
subject?: string;
|
|
32
|
+
/** ISO timestamp of the HEAD commit. */
|
|
33
|
+
committedAt?: string;
|
|
34
|
+
/** Modified, added, deleted or untracked paths (porcelain count). */
|
|
35
|
+
dirty: number;
|
|
36
|
+
/** Commits not on the upstream, or not on any remote when the branch tracks nothing. Absent when git could not tell. */
|
|
37
|
+
ahead?: number;
|
|
38
|
+
/** Commits on the upstream not here. Absent when the branch tracks nothing. */
|
|
39
|
+
behind?: number;
|
|
40
|
+
/** Set when git could not be read for this folder. */
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface ReposView {
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
root?: string;
|
|
46
|
+
repos: RepoInfo[];
|
|
47
|
+
/** Set when the feature is on but unusable, or off. */
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface AddRepoInput {
|
|
51
|
+
url: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
branch?: string;
|
|
54
|
+
/** Shallow clone with this many commits of history (`git clone --depth`). */
|
|
55
|
+
depth?: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A clone is the one call here that can legitimately take long: a large
|
|
59
|
+
* repository on a slow link is minutes, not seconds. Prompts are already
|
|
60
|
+
* disabled (git.ts), so a long timeout only ever cuts a transfer that is
|
|
61
|
+
* still running, and the partial clone is deleted when it does.
|
|
62
|
+
*/
|
|
63
|
+
export declare const CLONE_TIMEOUT: number;
|
|
64
|
+
export declare function safeRepoSlug(slug: string): string;
|
|
65
|
+
/** "https://github.com/org/repo.git" → "repo"; "git@host:org/repo" → "repo". */
|
|
66
|
+
export declare function slugFromUrl(url: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* The url git gets. `github:org/repo` is the same shorthand `--from`
|
|
69
|
+
* accepts. Anything starting with a dash would be read as an option, and
|
|
70
|
+
* empty is empty.
|
|
71
|
+
*/
|
|
72
|
+
export declare function cloneUrl(input: string): string;
|
|
73
|
+
/** Resolve the project and its repos block. `off` when the feature is not on. */
|
|
74
|
+
export declare function openRepos(): Promise<{
|
|
75
|
+
project?: Project;
|
|
76
|
+
root?: string;
|
|
77
|
+
off?: boolean;
|
|
78
|
+
error?: string;
|
|
79
|
+
}>;
|
|
80
|
+
/** Every clone under the root, alphabetically. Folders without .git are skipped. */
|
|
81
|
+
export declare function listRepos(): Promise<ReposView>;
|
|
82
|
+
/**
|
|
83
|
+
* Clone into <root>/<slug>. The folder is claimed with a plain mkdir first:
|
|
84
|
+
* it fails when anything is there already, whatever it is, and two adds of
|
|
85
|
+
* the same name racing each other cannot both win. Only what this call
|
|
86
|
+
* created is deleted when the clone fails.
|
|
87
|
+
*/
|
|
88
|
+
export declare function addRepo(input: AddRepoInput): Promise<RepoInfo>;
|
|
89
|
+
/**
|
|
90
|
+
* Fetch, then fast-forward when the clone is behind and clean. Never merges:
|
|
91
|
+
* a diverged or dirty clone is reported, and the agent (or a terminal)
|
|
92
|
+
* sorts it out inside the repo.
|
|
93
|
+
*/
|
|
94
|
+
export declare function updateRepo(slug: string): Promise<{
|
|
95
|
+
ok: boolean;
|
|
96
|
+
error?: string;
|
|
97
|
+
off?: boolean;
|
|
98
|
+
repo?: RepoInfo;
|
|
99
|
+
}>;
|
|
100
|
+
/**
|
|
101
|
+
* Delete the clone. Refuses while it holds unpushed or uncommitted work, or
|
|
102
|
+
* while git cannot tell, unless forced: the clone may be the only copy.
|
|
103
|
+
*/
|
|
104
|
+
export declare function removeRepo(slug: string, force?: boolean): Promise<{
|
|
105
|
+
ok: boolean;
|
|
106
|
+
error?: string;
|
|
107
|
+
off?: boolean;
|
|
108
|
+
conflict?: boolean;
|
|
109
|
+
}>;
|
|
110
|
+
/**
|
|
111
|
+
* Make sure the root exists and is ignored by the workspace git: a clone
|
|
112
|
+
* inside a tracked tree shows up as an untracked gitlink for everyone.
|
|
113
|
+
* Best effort — a missing .gitignore is created, one that already covers
|
|
114
|
+
* the root is left alone. Runs before every clone as well as when the
|
|
115
|
+
* feature is switched on, so a root enabled by editing kraftwerk.yml is
|
|
116
|
+
* ignored by the time the first clone lands.
|
|
117
|
+
*/
|
|
118
|
+
export declare function ensureReposRoot(): Promise<void>;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { gitignoreHas, ignoreEntryFor, reposRootFor, resolveProject } from "../config.js";
|
|
4
|
+
import { getProjectRoot } from "./context.js";
|
|
5
|
+
import { git, gitNet, sshCommandFor } from "./git.js";
|
|
6
|
+
/**
|
|
7
|
+
* A clone is the one call here that can legitimately take long: a large
|
|
8
|
+
* repository on a slow link is minutes, not seconds. Prompts are already
|
|
9
|
+
* disabled (git.ts), so a long timeout only ever cuts a transfer that is
|
|
10
|
+
* still running, and the partial clone is deleted when it does.
|
|
11
|
+
*/
|
|
12
|
+
export const CLONE_TIMEOUT = 30 * 60_000;
|
|
13
|
+
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
14
|
+
export function safeRepoSlug(slug) {
|
|
15
|
+
if (!SLUG_RE.test(slug) || slug === "." || slug === "..")
|
|
16
|
+
throw new Error(`invalid repository name "${slug}"`);
|
|
17
|
+
return slug;
|
|
18
|
+
}
|
|
19
|
+
/** "https://github.com/org/repo.git" → "repo"; "git@host:org/repo" → "repo". */
|
|
20
|
+
export function slugFromUrl(url) {
|
|
21
|
+
const last = url.replace(/[/:]+$/, "").split(/[/:]/).pop() ?? "";
|
|
22
|
+
const slug = last.replace(/\.git$/, "");
|
|
23
|
+
return safeRepoSlug(slug);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The url git gets. `github:org/repo` is the same shorthand `--from`
|
|
27
|
+
* accepts. Anything starting with a dash would be read as an option, and
|
|
28
|
+
* empty is empty.
|
|
29
|
+
*/
|
|
30
|
+
export function cloneUrl(input) {
|
|
31
|
+
const url = input.trim();
|
|
32
|
+
if (!url)
|
|
33
|
+
throw new Error("url is required");
|
|
34
|
+
if (url.startsWith("-"))
|
|
35
|
+
throw new Error(`invalid url "${url}"`);
|
|
36
|
+
const gh = /^github:([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(url);
|
|
37
|
+
if (gh)
|
|
38
|
+
return `https://github.com/${gh[1]}.git`;
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
/** Resolve the project and its repos block. `off` when the feature is not on. */
|
|
42
|
+
export async function openRepos() {
|
|
43
|
+
let project;
|
|
44
|
+
try {
|
|
45
|
+
project = await resolveProject(getProjectRoot());
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
return { error: err.message };
|
|
49
|
+
}
|
|
50
|
+
const root = reposRootFor(project);
|
|
51
|
+
if (!root)
|
|
52
|
+
return { off: true, error: "repositories are off (enable them in settings or add `repos:` to kraftwerk.yml)" };
|
|
53
|
+
return { project, root };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Everything about a clone in three git calls. `status --porcelain=v2
|
|
57
|
+
* --branch` carries the branch, its upstream, ahead/behind and the change
|
|
58
|
+
* list in one spawn; `log` the head; `remote` the origin. Only a branch
|
|
59
|
+
* without upstream costs one more call.
|
|
60
|
+
*/
|
|
61
|
+
async function inspect(root, slug) {
|
|
62
|
+
const dir = path.join(root, slug);
|
|
63
|
+
const info = { slug, path: dir, dirty: 0 };
|
|
64
|
+
const [status, log, origin] = await Promise.all([
|
|
65
|
+
git(["status", "--porcelain=v2", "--branch", "--untracked-files=normal"], dir),
|
|
66
|
+
git(["log", "-1", "--format=%h%x00%s%x00%cI"], dir),
|
|
67
|
+
git(["remote", "get-url", "origin"], dir),
|
|
68
|
+
]);
|
|
69
|
+
if (!status.ok)
|
|
70
|
+
return { ...info, error: status.stderr || "not a git repository" };
|
|
71
|
+
if (origin.ok)
|
|
72
|
+
info.url = origin.stdout.trim();
|
|
73
|
+
if (log.ok) {
|
|
74
|
+
const [head, subject, committedAt] = log.stdout.trim().split("\0");
|
|
75
|
+
info.head = head;
|
|
76
|
+
info.subject = subject;
|
|
77
|
+
info.committedAt = committedAt;
|
|
78
|
+
}
|
|
79
|
+
let hasUpstream = false;
|
|
80
|
+
for (const line of status.stdout.split("\n")) {
|
|
81
|
+
if (!line.trim())
|
|
82
|
+
continue;
|
|
83
|
+
if (!line.startsWith("# ")) {
|
|
84
|
+
info.dirty++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const [key, ...rest] = line.slice(2).split(" ");
|
|
88
|
+
const value = rest.join(" ");
|
|
89
|
+
if (key === "branch.head")
|
|
90
|
+
info.branch = value === "(detached)" ? "HEAD" : value;
|
|
91
|
+
else if (key === "branch.upstream")
|
|
92
|
+
hasUpstream = true;
|
|
93
|
+
else if (key === "branch.ab") {
|
|
94
|
+
const m = /^\+(\d+) -(\d+)$/.exec(value);
|
|
95
|
+
if (m) {
|
|
96
|
+
info.ahead = Number(m[1]);
|
|
97
|
+
info.behind = Number(m[2]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!hasUpstream || info.ahead === undefined) {
|
|
102
|
+
// No upstream (a branch made locally, a detached HEAD): count what no
|
|
103
|
+
// remote has, so the remove guard still knows about unpushed work.
|
|
104
|
+
const local = await git(["rev-list", "--count", "HEAD", "--not", "--remotes"], dir);
|
|
105
|
+
if (local.ok && /^\d+$/.test(local.stdout.trim()))
|
|
106
|
+
info.ahead = Number(local.stdout.trim());
|
|
107
|
+
}
|
|
108
|
+
return info;
|
|
109
|
+
}
|
|
110
|
+
/** Every clone under the root, alphabetically. Folders without .git are skipped. */
|
|
111
|
+
export async function listRepos() {
|
|
112
|
+
const opened = await openRepos();
|
|
113
|
+
if (!opened.root)
|
|
114
|
+
return { enabled: false, repos: [], error: opened.error };
|
|
115
|
+
const root = opened.root;
|
|
116
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
117
|
+
const slugs = [];
|
|
118
|
+
for (const e of entries) {
|
|
119
|
+
if (!e.isDirectory() || !SLUG_RE.test(e.name))
|
|
120
|
+
continue;
|
|
121
|
+
if (await fs.stat(path.join(root, e.name, ".git")).catch(() => null))
|
|
122
|
+
slugs.push(e.name);
|
|
123
|
+
}
|
|
124
|
+
slugs.sort((a, b) => a.localeCompare(b));
|
|
125
|
+
const repos = await Promise.all(slugs.map((s) => inspect(root, s)));
|
|
126
|
+
return { enabled: true, root, repos };
|
|
127
|
+
}
|
|
128
|
+
const cloneError = (r) => (r.stderr || r.stdout || "git clone failed").split("\n").filter((l) => l.trim()).slice(-3).join(" ");
|
|
129
|
+
/**
|
|
130
|
+
* Clone into <root>/<slug>. The folder is claimed with a plain mkdir first:
|
|
131
|
+
* it fails when anything is there already, whatever it is, and two adds of
|
|
132
|
+
* the same name racing each other cannot both win. Only what this call
|
|
133
|
+
* created is deleted when the clone fails.
|
|
134
|
+
*/
|
|
135
|
+
export async function addRepo(input) {
|
|
136
|
+
const opened = await openRepos();
|
|
137
|
+
if (!opened.root || !opened.project)
|
|
138
|
+
throw new Error(opened.error ?? "repositories are off");
|
|
139
|
+
const url = cloneUrl(input.url);
|
|
140
|
+
const slug = input.name?.trim() ? safeRepoSlug(input.name.trim()) : slugFromUrl(url);
|
|
141
|
+
const branch = input.branch?.trim();
|
|
142
|
+
if (branch && (branch.startsWith("-") || /\s/.test(branch)))
|
|
143
|
+
throw new Error(`invalid branch "${branch}"`);
|
|
144
|
+
if (input.depth !== undefined && (!Number.isInteger(input.depth) || input.depth < 1))
|
|
145
|
+
throw new Error(`invalid depth "${input.depth}"`);
|
|
146
|
+
const dest = path.join(opened.root, slug);
|
|
147
|
+
await prepareRoot(opened.project, opened.root);
|
|
148
|
+
try {
|
|
149
|
+
await fs.mkdir(dest);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (err.code === "EEXIST")
|
|
153
|
+
throw new Error(`"${slug}" already exists under ${opened.root}`);
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
const args = [
|
|
157
|
+
"-c", "protocol.ext.allow=never", "clone",
|
|
158
|
+
...(branch ? ["--branch", branch] : []),
|
|
159
|
+
...(input.depth ? ["--depth", String(input.depth)] : []),
|
|
160
|
+
"--", url, dest,
|
|
161
|
+
];
|
|
162
|
+
const r = await git(args, opened.root, CLONE_TIMEOUT, { GIT_SSH_COMMAND: await sshCommandFor(opened.project.root) });
|
|
163
|
+
if (!r.ok) {
|
|
164
|
+
await fs.rm(dest, { recursive: true, force: true }).catch(() => { });
|
|
165
|
+
throw new Error(cloneError(r));
|
|
166
|
+
}
|
|
167
|
+
return inspect(opened.root, slug);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Fetch, then fast-forward when the clone is behind and clean. Never merges:
|
|
171
|
+
* a diverged or dirty clone is reported, and the agent (or a terminal)
|
|
172
|
+
* sorts it out inside the repo.
|
|
173
|
+
*/
|
|
174
|
+
export async function updateRepo(slug) {
|
|
175
|
+
safeRepoSlug(slug);
|
|
176
|
+
const opened = await openRepos();
|
|
177
|
+
if (!opened.root)
|
|
178
|
+
return { ok: false, off: true, error: opened.error };
|
|
179
|
+
const dir = path.join(opened.root, slug);
|
|
180
|
+
if (!(await fs.stat(path.join(dir, ".git")).catch(() => null)))
|
|
181
|
+
return { ok: false, error: `no repository "${slug}"` };
|
|
182
|
+
const fetched = await gitNet(["fetch", "--prune"], dir);
|
|
183
|
+
if (!fetched.ok)
|
|
184
|
+
return { ok: false, error: fetched.stderr || "git fetch failed", repo: await inspect(opened.root, slug) };
|
|
185
|
+
let repo = await inspect(opened.root, slug);
|
|
186
|
+
if (repo.behind && !repo.ahead && repo.dirty === 0) {
|
|
187
|
+
const pulled = await git(["merge", "--ff-only", "@{upstream}"], dir);
|
|
188
|
+
if (!pulled.ok)
|
|
189
|
+
return { ok: false, error: pulled.stderr || "fast-forward failed", repo };
|
|
190
|
+
repo = await inspect(opened.root, slug);
|
|
191
|
+
}
|
|
192
|
+
else if (repo.behind && (repo.ahead || repo.dirty)) {
|
|
193
|
+
return { ok: true, error: repo.ahead ? "diverged from upstream, not fast-forwarded" : "local changes, not fast-forwarded", repo };
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, repo };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Delete the clone. Refuses while it holds unpushed or uncommitted work, or
|
|
199
|
+
* while git cannot tell, unless forced: the clone may be the only copy.
|
|
200
|
+
*/
|
|
201
|
+
export async function removeRepo(slug, force = false) {
|
|
202
|
+
safeRepoSlug(slug);
|
|
203
|
+
const opened = await openRepos();
|
|
204
|
+
if (!opened.root)
|
|
205
|
+
return { ok: false, off: true, error: opened.error };
|
|
206
|
+
const dir = path.join(opened.root, slug);
|
|
207
|
+
if (!(await fs.stat(path.join(dir, ".git")).catch(() => null)))
|
|
208
|
+
return { ok: false, error: `no repository "${slug}"` };
|
|
209
|
+
if (!force) {
|
|
210
|
+
const repo = await inspect(opened.root, slug);
|
|
211
|
+
if (repo.error)
|
|
212
|
+
return { ok: false, conflict: true, error: `"${slug}" cannot be read (${repo.error}); remove it with --force` };
|
|
213
|
+
if (repo.dirty)
|
|
214
|
+
return { ok: false, conflict: true, error: `"${slug}" has ${repo.dirty} uncommitted change(s)` };
|
|
215
|
+
if (repo.ahead === undefined)
|
|
216
|
+
return { ok: false, conflict: true, error: `"${slug}": cannot tell whether it has unpushed commits; remove it with --force` };
|
|
217
|
+
if (repo.ahead)
|
|
218
|
+
return { ok: false, conflict: true, error: `"${slug}" has ${repo.ahead} unpushed commit(s)` };
|
|
219
|
+
}
|
|
220
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
221
|
+
return { ok: true };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Make sure the root exists and is ignored by the workspace git: a clone
|
|
225
|
+
* inside a tracked tree shows up as an untracked gitlink for everyone.
|
|
226
|
+
* Best effort — a missing .gitignore is created, one that already covers
|
|
227
|
+
* the root is left alone. Runs before every clone as well as when the
|
|
228
|
+
* feature is switched on, so a root enabled by editing kraftwerk.yml is
|
|
229
|
+
* ignored by the time the first clone lands.
|
|
230
|
+
*/
|
|
231
|
+
export async function ensureReposRoot() {
|
|
232
|
+
const project = await resolveProject(getProjectRoot());
|
|
233
|
+
const root = reposRootFor(project);
|
|
234
|
+
if (!root)
|
|
235
|
+
return;
|
|
236
|
+
await prepareRoot(project, root);
|
|
237
|
+
}
|
|
238
|
+
async function prepareRoot(project, root) {
|
|
239
|
+
await fs.mkdir(root, { recursive: true });
|
|
240
|
+
const entry = ignoreEntryFor(project.root, root);
|
|
241
|
+
if (!entry)
|
|
242
|
+
return;
|
|
243
|
+
const ignorePath = path.join(project.root, ".gitignore");
|
|
244
|
+
const existing = await fs.readFile(ignorePath, "utf8").catch(() => null);
|
|
245
|
+
if (existing !== null && gitignoreHas(existing, entry))
|
|
246
|
+
return;
|
|
247
|
+
const line = `${entry}/\n`;
|
|
248
|
+
await fs.writeFile(ignorePath, existing === null ? line : `${existing}${existing.endsWith("\n") ? "" : "\n"}${line}`).catch(() => { });
|
|
249
|
+
}
|
package/dist/inspector/server.js
CHANGED
|
@@ -13,6 +13,7 @@ import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, sav
|
|
|
13
13
|
import { discoverWorkspaces, forgetProject, listWorkspacesDetailed, markProjectStopped, registerInstance, registerProject, startProject, stopProject, tildify, unregisterInstance, } from "./instances.js";
|
|
14
14
|
import { gitCommit, gitDiff, gitFetch, gitPull, gitPush, gitStatus, startGitSync, } from "./git.js";
|
|
15
15
|
import { getSettings, saveSettings } from "./settings.js";
|
|
16
|
+
import { addRepo, listRepos, openRepos, removeRepo, updateRepo } from "./repos.js";
|
|
16
17
|
import { searchAgents } from "./search.js";
|
|
17
18
|
import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutineScheduler, } from "./routines.js";
|
|
18
19
|
/**
|
|
@@ -215,6 +216,7 @@ async function handleApi(req, res, url) {
|
|
|
215
216
|
projectRoot: project?.root ?? getProjectRoot(),
|
|
216
217
|
projectRootLabel: tildify(project?.root ?? getProjectRoot()),
|
|
217
218
|
git: (project?.config.git && project.config.git.enabled !== false) === true,
|
|
219
|
+
repos: (project?.config.repos && project.config.repos.enabled !== false) === true,
|
|
218
220
|
switcher,
|
|
219
221
|
});
|
|
220
222
|
}
|
|
@@ -250,6 +252,46 @@ async function handleApi(req, res, url) {
|
|
|
250
252
|
const result = await run();
|
|
251
253
|
return json(res, result, result.ok ? 200 : 409);
|
|
252
254
|
}
|
|
255
|
+
// GET /api/repos — every clone under the repos root, read live from git
|
|
256
|
+
if (seg.length === 2 && seg[1] === "repos" && method === "GET") {
|
|
257
|
+
return json(res, await listRepos());
|
|
258
|
+
}
|
|
259
|
+
// POST /api/repos {url, name?, branch?, depth?} — clone into the root
|
|
260
|
+
if (seg.length === 2 && seg[1] === "repos" && method === "POST") {
|
|
261
|
+
if ((await openRepos()).off)
|
|
262
|
+
return json(res, { error: "repositories are off" }, 409);
|
|
263
|
+
try {
|
|
264
|
+
const body = JSON.parse(await readBody(req));
|
|
265
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
266
|
+
const depth = body.depth === undefined ? undefined : typeof body.depth === "number" ? body.depth : Number.NaN;
|
|
267
|
+
const repo = await addRepo({ url: str(body.url) ?? "", name: str(body.name), branch: str(body.branch), depth });
|
|
268
|
+
return json(res, repo, 201);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
return json(res, { error: err.message }, 400);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// POST /api/repos/<slug>/update — fetch, fast-forward when clean
|
|
275
|
+
if (seg.length === 4 && seg[1] === "repos" && seg[3] === "update" && method === "POST") {
|
|
276
|
+
try {
|
|
277
|
+
const result = await updateRepo(seg[2]);
|
|
278
|
+
return json(res, result, result.ok ? 200 : result.repo || result.off ? 409 : 404);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
return json(res, { error: err.message }, 400);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// DELETE /api/repos/<slug>[?force=1] — remove the clone; 409 while it holds unpushed or uncommitted work
|
|
285
|
+
if (seg.length === 3 && seg[1] === "repos" && method === "DELETE") {
|
|
286
|
+
try {
|
|
287
|
+
const force = ["1", "true"].includes(url.searchParams.get("force") ?? "");
|
|
288
|
+
const result = await removeRepo(seg[2], force);
|
|
289
|
+
return json(res, result, result.ok ? 200 : result.conflict || result.off ? 409 : 404);
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
return json(res, { error: err.message }, 400);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
253
295
|
// GET /api/search/agents — active agents of every reachable workspace (the ⌘K palette)
|
|
254
296
|
if (seg.length === 3 && seg[1] === "search" && seg[2] === "agents" && method === "GET") {
|
|
255
297
|
return json(res, await searchAgents());
|
|
@@ -301,7 +343,7 @@ async function handleApi(req, res, url) {
|
|
|
301
343
|
if (seg.length === 2 && seg[1] === "settings" && method === "GET") {
|
|
302
344
|
return json(res, await getSettings());
|
|
303
345
|
}
|
|
304
|
-
// PUT /api/settings — write the UI-editable subset (name, icon, switcher, git) back
|
|
346
|
+
// PUT /api/settings — write the UI-editable subset (name, icon, switcher, git, repos) back
|
|
305
347
|
if (seg.length === 2 && seg[1] === "settings" && method === "PUT") {
|
|
306
348
|
try {
|
|
307
349
|
const input = JSON.parse(await readBody(req));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ProjectConfig, type SwitcherEntry } from "../config.js";
|
|
2
2
|
/**
|
|
3
3
|
* Workspace settings: read kraftwerk.yml for the UI and write the
|
|
4
|
-
* UI-editable subset (name, icon, switcher, git) back. Edits go through the
|
|
4
|
+
* UI-editable subset (name, icon, switcher, git, repos) back. Edits go through the
|
|
5
5
|
* yaml Document API so comments and every other key survive untouched;
|
|
6
6
|
* a missing kraftwerk.yml is created at the project root on first save.
|
|
7
7
|
*/
|
|
@@ -24,6 +24,12 @@ export interface SaveSettingsInput {
|
|
|
24
24
|
switcher?: SwitcherEntry[];
|
|
25
25
|
/** Omitted = untouched. See cleanGit for how the block is written. */
|
|
26
26
|
git?: GitSettingsInput;
|
|
27
|
+
/** Omitted = untouched. Same rules as git: off with defaults = no block. */
|
|
28
|
+
repos?: ReposSettingsInput;
|
|
29
|
+
}
|
|
30
|
+
export interface ReposSettingsInput {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
root?: string;
|
|
27
33
|
}
|
|
28
34
|
/** The git form as the settings screen posts it. Every field but `enabled` may be blank. */
|
|
29
35
|
export interface GitSettingsInput {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseDocument } from "yaml";
|
|
4
|
-
import { resolveProject } from "../config.js";
|
|
4
|
+
import { ignoreEntryFor, REPOS_DEFAULT_ROOT, resolveProject } from "../config.js";
|
|
5
5
|
import { getProjectRoot } from "./context.js";
|
|
6
|
+
import { ensureReposRoot } from "./repos.js";
|
|
6
7
|
const GIT_DEFAULTS = { remote: "origin", interval: 300, autosync: "pull" };
|
|
7
8
|
export async function getSettings() {
|
|
8
9
|
const project = await resolveProject(getProjectRoot());
|
|
@@ -69,6 +70,25 @@ function cleanGit(value) {
|
|
|
69
70
|
return null;
|
|
70
71
|
return { enabled: false, ...out };
|
|
71
72
|
}
|
|
73
|
+
/** The repos block to write; null when the feature is off and nothing else is set. */
|
|
74
|
+
function cleanRepos(value, projectRoot) {
|
|
75
|
+
if (typeof value !== "object" || value === null)
|
|
76
|
+
throw new Error("repos must be an object");
|
|
77
|
+
if (typeof value.enabled !== "boolean")
|
|
78
|
+
throw new Error("repos.enabled must be true or false");
|
|
79
|
+
const out = {};
|
|
80
|
+
const root = typeof value.root === "string" ? value.root.trim().replace(/\/+$/, "") : "";
|
|
81
|
+
if (root && !ignoreEntryFor(projectRoot, root)) {
|
|
82
|
+
throw new Error('repos.root must be a directory inside the project (not ".", ".." or an absolute path outside it)');
|
|
83
|
+
}
|
|
84
|
+
if (root && root !== REPOS_DEFAULT_ROOT)
|
|
85
|
+
out.root = root;
|
|
86
|
+
if (value.enabled)
|
|
87
|
+
return out;
|
|
88
|
+
if (Object.keys(out).length === 0)
|
|
89
|
+
return null;
|
|
90
|
+
return { enabled: false, ...out };
|
|
91
|
+
}
|
|
72
92
|
export async function saveSettings(input) {
|
|
73
93
|
if (input.name !== undefined && typeof input.name !== "string")
|
|
74
94
|
throw new Error("name must be a string");
|
|
@@ -99,6 +119,17 @@ export async function saveSettings(input) {
|
|
|
99
119
|
else
|
|
100
120
|
doc.set("git", git);
|
|
101
121
|
}
|
|
122
|
+
if (input.repos !== undefined) {
|
|
123
|
+
const repos = cleanRepos(input.repos, project.root);
|
|
124
|
+
if (repos === null)
|
|
125
|
+
doc.delete("repos");
|
|
126
|
+
else
|
|
127
|
+
doc.set("repos", repos);
|
|
128
|
+
}
|
|
102
129
|
await fs.writeFile(configPath, doc.toString());
|
|
130
|
+
// Turning repositories on prepares the folder so the first clone and the
|
|
131
|
+
// workspace git both find it in the right state.
|
|
132
|
+
if (input.repos?.enabled)
|
|
133
|
+
await ensureReposRoot().catch(() => { });
|
|
103
134
|
return getSettings();
|
|
104
135
|
}
|