aidevops 3.21.12 → 3.22.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 +1 -1
- package/VERSION +1 -1
- package/aidevops.sh +3 -2
- package/bun.lock +573 -0
- package/package.json +12 -1
- package/packages/gui-api/package.json +9 -0
- package/packages/gui-api/src/app.ts +49 -0
- package/packages/gui-api/src/file-adapter.ts +207 -0
- package/packages/gui-api/src/server.ts +12 -0
- package/packages/gui-api/src/status-adapter-utils.ts +108 -0
- package/packages/gui-api/src/status-adapter.ts +484 -0
- package/packages/gui-api/src/status-local-repos.ts +239 -0
- package/packages/gui-desktop/package.json +6 -0
- package/packages/gui-desktop/scripts/install-macos-app.sh +1020 -0
- package/packages/gui-shared/package.json +9 -0
- package/packages/gui-shared/src/contracts.ts +341 -0
- package/packages/gui-shared/src/fixtures.ts +250 -0
- package/packages/gui-shared/src/index.ts +2 -0
- package/packages/gui-web/index.html +24 -0
- package/packages/gui-web/package.json +25 -0
- package/packages/gui-web/src/App.tsx +269 -0
- package/packages/gui-web/src/AppNavigation.tsx +379 -0
- package/packages/gui-web/src/AppWorkspace.tsx +114 -0
- package/packages/gui-web/src/FileExplorerSurface.tsx +153 -0
- package/packages/gui-web/src/InventorySurfaces.tsx +117 -0
- package/packages/gui-web/src/PathActions.tsx +17 -0
- package/packages/gui-web/src/StatusSurfaces.tsx +396 -0
- package/packages/gui-web/src/app-model.ts +546 -0
- package/packages/gui-web/src/dashboard.ts +79 -0
- package/packages/gui-web/src/main.tsx +24 -0
- package/packages/gui-web/src/status-client.ts +93 -0
- package/packages/gui-web/src/styles.css +1540 -0
- package/packages/gui-web/vite.config.ts +14 -0
- package/setup.sh +78 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aidevops",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.22.0",
|
|
4
4
|
"description": "AI DevOps Framework - AI-assisted development workflows, code quality, and deployment automation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -83,6 +83,17 @@
|
|
|
83
83
|
"aidevops-*-lib.sh",
|
|
84
84
|
"setup.sh",
|
|
85
85
|
"setup-modules/",
|
|
86
|
+
"bun.lock",
|
|
87
|
+
"packages/gui-shared/package.json",
|
|
88
|
+
"packages/gui-shared/src/",
|
|
89
|
+
"packages/gui-api/package.json",
|
|
90
|
+
"packages/gui-api/src/",
|
|
91
|
+
"packages/gui-web/package.json",
|
|
92
|
+
"packages/gui-web/index.html",
|
|
93
|
+
"packages/gui-web/src/",
|
|
94
|
+
"packages/gui-web/vite.config.ts",
|
|
95
|
+
"packages/gui-desktop/package.json",
|
|
96
|
+
"packages/gui-desktop/scripts/",
|
|
86
97
|
"VERSION",
|
|
87
98
|
"scripts/npm-postinstall.cjs",
|
|
88
99
|
"templates/"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { BANNED_ROUTE_PATTERNS, FILE_EXPLORER_ROUTE_MANIFEST, STATUS_ROUTE_MANIFEST } from "../../gui-shared/src";
|
|
3
|
+
import { readFileExplorer } from "./file-adapter";
|
|
4
|
+
import { readStatus } from "./status-adapter";
|
|
5
|
+
|
|
6
|
+
export function createGuiApiApp() {
|
|
7
|
+
const app = new Hono();
|
|
8
|
+
|
|
9
|
+
app.get(STATUS_ROUTE_MANIFEST.route, (context) => {
|
|
10
|
+
return context.json(readStatus());
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
app.get(FILE_EXPLORER_ROUTE_MANIFEST.route, (context) => {
|
|
14
|
+
const root = context.req.param("root");
|
|
15
|
+
const path = context.req.query("path") ?? "";
|
|
16
|
+
const response = readFileExplorer(root, path);
|
|
17
|
+
const status = response.ok ? 200 : response.errors.includes("unknown_file_root") ? 404 : 400;
|
|
18
|
+
|
|
19
|
+
return context.json(response, status);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
for (const route of BANNED_ROUTE_PATTERNS) {
|
|
23
|
+
app.post(route, (context) => {
|
|
24
|
+
return context.json(
|
|
25
|
+
{
|
|
26
|
+
ok: false,
|
|
27
|
+
operation_id: "capabilities.read",
|
|
28
|
+
errors: ["write_actions_disabled"],
|
|
29
|
+
},
|
|
30
|
+
405,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
app.notFound((context) => {
|
|
36
|
+
return context.json(
|
|
37
|
+
{
|
|
38
|
+
ok: false,
|
|
39
|
+
operation_id: "capabilities.read",
|
|
40
|
+
errors: ["unknown_route"],
|
|
41
|
+
},
|
|
42
|
+
404,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return app;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const app = createGuiApiApp();
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
assertNoSecretSentinels,
|
|
5
|
+
createEnvelope,
|
|
6
|
+
GUI_FILE_ROOTS,
|
|
7
|
+
type GuiFileEntry,
|
|
8
|
+
type GuiFileExplorerData,
|
|
9
|
+
type GuiFilePreview,
|
|
10
|
+
type GuiFileRootDefinition,
|
|
11
|
+
type GuiResponseEnvelope,
|
|
12
|
+
} from "../../gui-shared/src";
|
|
13
|
+
|
|
14
|
+
const MAX_ENTRIES = 80;
|
|
15
|
+
const MAX_PREVIEW_CHARS = 48_000;
|
|
16
|
+
|
|
17
|
+
interface FileEnvelopeInput {
|
|
18
|
+
currentRelativePath: string;
|
|
19
|
+
entries: GuiFileEntry[];
|
|
20
|
+
errors: string[];
|
|
21
|
+
observedAt?: string;
|
|
22
|
+
root: GuiFileRootDefinition;
|
|
23
|
+
selectedPreview: GuiFilePreview | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readFileExplorer(
|
|
27
|
+
rootId: string,
|
|
28
|
+
requestedPath = "",
|
|
29
|
+
observedAt?: string,
|
|
30
|
+
): GuiResponseEnvelope<GuiFileExplorerData> {
|
|
31
|
+
const root = GUI_FILE_ROOTS.find((entry) => entry.id === rootId);
|
|
32
|
+
if (root === undefined) {
|
|
33
|
+
return createFileEnvelope({
|
|
34
|
+
root: unknownRoot(),
|
|
35
|
+
currentRelativePath: "",
|
|
36
|
+
entries: [],
|
|
37
|
+
selectedPreview: null,
|
|
38
|
+
errors: ["unknown_file_root"],
|
|
39
|
+
observedAt,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rootPath = resolvePathRef(root.path_ref);
|
|
44
|
+
const targetPath = resolve(rootPath, requestedPath);
|
|
45
|
+
if (!isInsideRoot(rootPath, targetPath)) {
|
|
46
|
+
return createFileEnvelope({
|
|
47
|
+
root,
|
|
48
|
+
currentRelativePath: "",
|
|
49
|
+
entries: [],
|
|
50
|
+
selectedPreview: blockedPreview(root, "Path is outside the allowed root."),
|
|
51
|
+
errors: ["path_outside_root"],
|
|
52
|
+
observedAt,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!existsSync(rootPath)) {
|
|
57
|
+
return createFileEnvelope({
|
|
58
|
+
root,
|
|
59
|
+
currentRelativePath: "",
|
|
60
|
+
entries: [],
|
|
61
|
+
selectedPreview: null,
|
|
62
|
+
errors: ["root_missing"],
|
|
63
|
+
observedAt,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const targetExists = existsSync(targetPath);
|
|
68
|
+
const targetStats = targetExists ? statSync(targetPath) : null;
|
|
69
|
+
const browsePath = targetStats?.isFile() ? dirname(targetPath) : targetPath;
|
|
70
|
+
const currentRelativePath = toRelativePath(rootPath, browsePath);
|
|
71
|
+
const entries = targetExists && existsSync(browsePath) ? readEntries(root, rootPath, browsePath) : [];
|
|
72
|
+
const preview = targetStats?.isFile()
|
|
73
|
+
? readPreview(root, rootPath, targetPath)
|
|
74
|
+
: null;
|
|
75
|
+
|
|
76
|
+
return createFileEnvelope({
|
|
77
|
+
root,
|
|
78
|
+
currentRelativePath,
|
|
79
|
+
entries,
|
|
80
|
+
selectedPreview: preview,
|
|
81
|
+
errors: targetExists ? [] : ["path_missing"],
|
|
82
|
+
observedAt,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createFileEnvelope(input: FileEnvelopeInput): GuiResponseEnvelope<GuiFileExplorerData> {
|
|
87
|
+
const data: GuiFileExplorerData = {
|
|
88
|
+
root: input.root,
|
|
89
|
+
current_path_ref: toPathRef(input.root.path_ref, input.currentRelativePath),
|
|
90
|
+
current_relative_path: input.currentRelativePath,
|
|
91
|
+
entries: input.entries,
|
|
92
|
+
selected_preview: input.selectedPreview,
|
|
93
|
+
entry_limit: MAX_ENTRIES,
|
|
94
|
+
};
|
|
95
|
+
const envelope = createEnvelope({
|
|
96
|
+
operation_id: "filesystem.read",
|
|
97
|
+
source: {
|
|
98
|
+
surface: "filesystem",
|
|
99
|
+
authority: "local read-only allowlist",
|
|
100
|
+
path_refs: [input.root.path_ref],
|
|
101
|
+
},
|
|
102
|
+
data,
|
|
103
|
+
errors: input.errors,
|
|
104
|
+
observed_at: input.observedAt,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
assertNoSecretSentinels(envelope);
|
|
108
|
+
return envelope;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readEntries(root: GuiFileRootDefinition, rootPath: string, browsePath: string): GuiFileEntry[] {
|
|
112
|
+
return readdirSync(browsePath, { withFileTypes: true })
|
|
113
|
+
.filter((entry) => !entry.name.startsWith("."))
|
|
114
|
+
.sort((first, second) => sortEntry(first.isDirectory(), first.name, second.isDirectory(), second.name))
|
|
115
|
+
.slice(0, MAX_ENTRIES)
|
|
116
|
+
.map((entry) => {
|
|
117
|
+
const entryPath = join(browsePath, entry.name);
|
|
118
|
+
const relativePath = toRelativePath(rootPath, entryPath);
|
|
119
|
+
const extension = entry.isDirectory() ? "" : extname(entry.name).toLowerCase();
|
|
120
|
+
return {
|
|
121
|
+
name: entry.name,
|
|
122
|
+
kind: entry.isDirectory() ? "directory" : "file",
|
|
123
|
+
path_ref: toPathRef(root.path_ref, relativePath),
|
|
124
|
+
relative_path: relativePath,
|
|
125
|
+
extension,
|
|
126
|
+
preview_allowed: root.id === "agents" && entry.isFile(),
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readPreview(root: GuiFileRootDefinition, rootPath: string, targetPath: string): GuiFilePreview {
|
|
132
|
+
const relativePath = toRelativePath(rootPath, targetPath);
|
|
133
|
+
const pathRef = toPathRef(root.path_ref, relativePath);
|
|
134
|
+
if (root.preview_policy !== "agents_markdown_and_code") {
|
|
135
|
+
return {
|
|
136
|
+
path_ref: pathRef,
|
|
137
|
+
relative_path: relativePath,
|
|
138
|
+
mode: "blocked",
|
|
139
|
+
language: "",
|
|
140
|
+
content: "",
|
|
141
|
+
truncated: false,
|
|
142
|
+
reason: "Content preview is disabled for this root until redaction and write-action policies land.",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const content = readFileSync(targetPath, "utf8");
|
|
147
|
+
const extension = extname(targetPath).toLowerCase();
|
|
148
|
+
return {
|
|
149
|
+
path_ref: pathRef,
|
|
150
|
+
relative_path: relativePath,
|
|
151
|
+
mode: extension === ".md" ? "markdown" : extension.length > 0 ? "code" : "text",
|
|
152
|
+
language: extension.replace(/^\./, "") || "text",
|
|
153
|
+
content: content.length > MAX_PREVIEW_CHARS ? content.slice(0, MAX_PREVIEW_CHARS) : content,
|
|
154
|
+
truncated: content.length > MAX_PREVIEW_CHARS,
|
|
155
|
+
reason: "",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function blockedPreview(root: GuiFileRootDefinition, reason: string): GuiFilePreview {
|
|
160
|
+
return {
|
|
161
|
+
path_ref: root.path_ref,
|
|
162
|
+
relative_path: "",
|
|
163
|
+
mode: "blocked",
|
|
164
|
+
language: "",
|
|
165
|
+
content: "",
|
|
166
|
+
truncated: false,
|
|
167
|
+
reason,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function unknownRoot(): GuiFileRootDefinition {
|
|
172
|
+
return {
|
|
173
|
+
id: "agents",
|
|
174
|
+
label: "Unknown root",
|
|
175
|
+
path_ref: "~",
|
|
176
|
+
description: "The requested file root is not in the GUI allowlist.",
|
|
177
|
+
preview_policy: "metadata_only",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function resolvePathRef(pathRef: string): string {
|
|
182
|
+
if (!pathRef.startsWith("~/")) {
|
|
183
|
+
return resolve(pathRef);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return resolve(join(process.env.HOME ?? "", pathRef.slice(2)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isInsideRoot(rootPath: string, targetPath: string): boolean {
|
|
190
|
+
return targetPath === rootPath || targetPath.startsWith(`${rootPath}${sep}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toRelativePath(rootPath: string, targetPath: string): string {
|
|
194
|
+
return relative(rootPath, targetPath).split(sep).filter(Boolean).join("/");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function toPathRef(rootPathRef: string, relativePath: string): string {
|
|
198
|
+
return relativePath.length > 0 ? `${rootPathRef}/${relativePath}` : rootPathRef;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sortEntry(firstIsDirectory: boolean, firstName: string, secondIsDirectory: boolean, secondName: string): number {
|
|
202
|
+
if (firstIsDirectory !== secondIsDirectory) {
|
|
203
|
+
return firstIsDirectory ? -1 : 1;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return firstName.localeCompare(secondName);
|
|
207
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { serve } from "bun";
|
|
2
|
+
import { app } from "./app";
|
|
3
|
+
|
|
4
|
+
const port = Number.parseInt(process.env.AIDEVOPS_GUI_API_PORT ?? "8787", 10);
|
|
5
|
+
|
|
6
|
+
serve({
|
|
7
|
+
fetch: app.fetch,
|
|
8
|
+
hostname: "127.0.0.1",
|
|
9
|
+
port,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
console.log(`aidevops GUI API listening read-only on http://127.0.0.1:${port}`);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export function readJsonObject(pathName: string): { health: "present" | "missing" | "invalid"; value: Record<string, unknown> } {
|
|
5
|
+
if (!existsSync(pathName)) {
|
|
6
|
+
return { health: "missing", value: {} };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(readFileSync(pathName, "utf8"));
|
|
11
|
+
return isRecord(parsed) ? { health: "present", value: parsed } : { health: "invalid", value: {} };
|
|
12
|
+
} catch {
|
|
13
|
+
return { health: "invalid", value: {} };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function safeRealpath(pathName: string): string | null {
|
|
18
|
+
try {
|
|
19
|
+
return realpathSync(pathName);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function collapseHome(pathName: string): string {
|
|
26
|
+
const home = process.env.HOME ?? "";
|
|
27
|
+
if (home.length > 0 && pathName === home) {
|
|
28
|
+
return "~";
|
|
29
|
+
}
|
|
30
|
+
if (home.length > 0 && pathName.startsWith(`${home}/`)) {
|
|
31
|
+
return `~/${pathName.slice(home.length + 1)}`;
|
|
32
|
+
}
|
|
33
|
+
return pathName;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function booleanField(value: Record<string, unknown>, key: string): boolean | null {
|
|
37
|
+
const field = value[key];
|
|
38
|
+
return typeof field === "boolean" ? field : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function numberField(value: Record<string, unknown>, key: string): number | null {
|
|
42
|
+
const field = value[key];
|
|
43
|
+
return typeof field === "number" && Number.isFinite(field) ? field : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function featureList(value: unknown): string[] {
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0).sort();
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === "string" && value.length > 0) {
|
|
51
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean).sort();
|
|
52
|
+
}
|
|
53
|
+
if (isRecord(value)) {
|
|
54
|
+
return Object.entries(value)
|
|
55
|
+
.filter(([, enabled]) => enabled === true)
|
|
56
|
+
.map(([feature]) => feature)
|
|
57
|
+
.sort();
|
|
58
|
+
}
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatNullableEpochField(value: unknown): string | null {
|
|
63
|
+
if (value === null || value === undefined || value === 0) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return formatEpochField(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatEpochField(value: unknown): string {
|
|
70
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
71
|
+
return "unknown";
|
|
72
|
+
}
|
|
73
|
+
const millis = value > 9_999_999_999 ? value : value * 1000;
|
|
74
|
+
return new Date(millis).toISOString();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function cooldownReady(cooldownUntil: string | null, now: number): boolean {
|
|
78
|
+
if (cooldownUntil === null) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const parsed = Date.parse(cooldownUntil);
|
|
82
|
+
return Number.isNaN(parsed) || parsed <= now;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
86
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function stringField(value: Record<string, unknown>, key: string): string | undefined {
|
|
90
|
+
const field = value[key];
|
|
91
|
+
return typeof field === "string" && field.length > 0 ? field : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function expandHome(pathRef: string): string {
|
|
95
|
+
if (!pathRef.startsWith("~/")) {
|
|
96
|
+
return pathRef;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return join(process.env.HOME ?? "", pathRef.slice(2));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readOptionalText(pathName: string): string | null {
|
|
103
|
+
if (!existsSync(pathName)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return readFileSync(pathName, "utf8").trim();
|
|
108
|
+
}
|