@zdreamer/forge 0.1.0 → 0.1.2
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 +162 -9
- package/dist/server/src/app/collection.d.ts +10 -0
- package/dist/server/src/app/collection.js +29 -0
- package/dist/server/src/app/config.d.ts +12 -0
- package/dist/server/src/app/config.js +52 -0
- package/dist/server/src/app/http.d.ts +13 -0
- package/dist/server/src/app/http.js +19 -0
- package/dist/server/src/app/listener.d.ts +9 -0
- package/dist/server/src/app/listener.js +61 -0
- package/dist/server/src/app/startup.d.ts +16 -0
- package/dist/server/src/app/startup.js +60 -0
- package/dist/server/src/app/wire.d.ts +11 -0
- package/dist/server/src/app/wire.js +29 -0
- package/dist/server/src/app/workspace-routes.d.ts +11 -0
- package/dist/server/src/app/workspace-routes.js +15 -0
- package/dist/server/src/components/api-contract/index.d.ts +1 -0
- package/dist/server/src/components/api-contract/internal/types.d.ts +27 -0
- package/dist/server/src/components/diagnostic-ui/index.d.ts +1 -0
- package/dist/server/src/components/diagnostic-ui/index.js +1 -0
- package/dist/server/src/components/diagnostic-ui/internal/page.d.ts +1 -0
- package/dist/server/src/components/diagnostic-ui/internal/page.js +129 -0
- package/dist/server/src/components/diagnostic-ui/internal/routes.d.ts +2 -0
- package/dist/server/src/components/diagnostic-ui/internal/routes.js +11 -0
- package/dist/server/src/components/workspace-changes/index.d.ts +2 -0
- package/dist/server/src/components/workspace-changes/index.js +1 -0
- package/dist/server/src/components/workspace-changes/internal/boundaries.d.ts +3 -0
- package/dist/server/src/components/workspace-changes/internal/boundaries.js +56 -0
- package/dist/server/src/components/workspace-changes/internal/collect.d.ts +13 -0
- package/dist/server/src/components/workspace-changes/internal/collect.js +157 -0
- package/dist/server/src/components/workspace-changes/internal/comparison.d.ts +13 -0
- package/dist/server/src/components/workspace-changes/internal/comparison.js +229 -0
- package/dist/server/src/components/workspace-changes/internal/types.d.ts +10 -0
- package/dist/server/src/components/workspace-changes/internal/types.js +1 -0
- package/dist/server/src/components/workspace-policy/authoring/index.d.ts +1 -0
- package/dist/server/src/components/workspace-policy/authoring/index.js +1 -0
- package/dist/{components/workspace-policy → server/src/components/workspace-policy/authoring}/internal/types.d.ts +4 -2
- package/dist/server/src/components/workspace-policy/authoring/internal/types.js +1 -0
- package/dist/{components → server/src/components}/workspace-policy/index.d.ts +2 -1
- package/dist/{components → server/src/components}/workspace-policy/internal/load.d.ts +1 -1
- package/dist/{components → server/src/components}/workspace-policy/internal/load.js +3 -0
- package/dist/{components → server/src/components}/workspace-policy/internal/repositories.d.ts +3 -2
- package/dist/{components → server/src/components}/workspace-policy/internal/repositories.js +12 -6
- package/dist/server/src/lib/git/index.d.ts +2 -0
- package/dist/server/src/lib/git/index.js +1 -0
- package/dist/server/src/lib/git/internal/process.d.ts +16 -0
- package/dist/server/src/lib/git/internal/process.js +166 -0
- package/dist/server/src/lib/loopback-access/index.d.ts +1 -0
- package/dist/server/src/lib/loopback-access/index.js +1 -0
- package/dist/server/src/lib/loopback-access/internal/request-guard.d.ts +9 -0
- package/dist/server/src/lib/loopback-access/internal/request-guard.js +17 -0
- package/dist/server/src/main.js +33 -0
- package/dist/server/src/workspace.d.ts +1 -0
- package/dist/server/src/workspace.js +1 -0
- package/package.json +10 -5
- package/dist/main.js +0 -23
- package/dist/workspace.d.ts +0 -1
- /package/dist/{app → server/src/app}/inspect.d.ts +0 -0
- /package/dist/{app → server/src/app}/inspect.js +0 -0
- /package/dist/{components/workspace-policy/internal/types.js → server/src/components/api-contract/index.js} +0 -0
- /package/dist/{workspace.js → server/src/components/api-contract/internal/types.js} +0 -0
- /package/dist/{components → server/src/components}/workspace-policy/index.js +0 -0
- /package/dist/{main.d.ts → server/src/main.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,13 +1,36 @@
|
|
|
1
1
|
# @zdreamer/forge
|
|
2
2
|
|
|
3
|
-
Provisional
|
|
4
|
-
|
|
5
|
-
types
|
|
3
|
+
Provisional Forge workspace application. The unreleased source supports a
|
|
4
|
+
foreground read-only workspace server, a minimal browser diagnostic and the
|
|
5
|
+
policy-loading diagnostic. Registry release `0.1.1` supplies the diagnostic and policy types; it does not contain the
|
|
6
|
+
server. Executable: `forge`; public authoring types: `@zdreamer/forge/workspace`.
|
|
7
|
+
Licensed under [MIT](LICENSE).
|
|
6
8
|
|
|
7
9
|
The `0.x` policy contract is provisional. Breaking authoring or semantic changes
|
|
8
10
|
use a minor release with migration notes; exact-pin the application and use its
|
|
9
11
|
own policy types from the same installation. No separate policy-version field or
|
|
10
|
-
compatibility layer is provided. See [release operations](https://github.com/ZDreamer2/forge/blob/
|
|
12
|
+
compatibility layer is provided. See [release operations](https://github.com/ZDreamer2/forge/blob/main/docs/releasing.md).
|
|
13
|
+
|
|
14
|
+
## Source layout
|
|
15
|
+
|
|
16
|
+
One package and installation own all dependencies. Its directories separate source
|
|
17
|
+
and runtime responsibilities; they are not workspace packages:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
server/src/ CLI, HTTP composition, domain components, public API contract and libs
|
|
21
|
+
ui/src/ reserved standalone React application root (no React/Vite scaffold yet)
|
|
22
|
+
docs/ application architecture and installation guidance
|
|
23
|
+
tools/ tooling/release and installed-consumer checks
|
|
24
|
+
dist/ normal TypeScript output, preserving server/src paths
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The server retains its service conventions and local entries at
|
|
28
|
+
`server/docs/forge/`. UI implementation and its browser tooling remain future work;
|
|
29
|
+
the existing vanilla diagnostic is still server-owned. There are no member
|
|
30
|
+
manifests or duplicated dependency declarations. [Architecture](https://github.com/ZDreamer2/forge/blob/main/docs/architecture.md)
|
|
31
|
+
owns source dependencies and build boundaries. The application-owned
|
|
32
|
+
[HTTP API contract](https://github.com/ZDreamer2/forge/blob/main/server/docs/protocol.md)
|
|
33
|
+
specifies wire behavior independently of the currently implemented operations.
|
|
11
34
|
|
|
12
35
|
## Develop
|
|
13
36
|
|
|
@@ -20,6 +43,11 @@ corepack pnpm --config.verify-deps-before-run=error run build
|
|
|
20
43
|
corepack pnpm --config.verify-deps-before-run=error run test:workspace
|
|
21
44
|
```
|
|
22
45
|
|
|
46
|
+
For server-only acceptance, run
|
|
47
|
+
`corepack pnpm --config.verify-deps-before-run=error run check:server` from this
|
|
48
|
+
root. It includes server/contract typechecks, server-scoped Biome checks,
|
|
49
|
+
architectural dependency rules and server tests.
|
|
50
|
+
|
|
23
51
|
The independent installation uses published `@zdreamer/forge-ts-service@0.1.1`
|
|
24
52
|
presets, their exact-pinned tool peers, the seven-day third-party release-age
|
|
25
53
|
floor, and explicit SWC script permission. `.cache/pnpm` is the portable default;
|
|
@@ -27,14 +55,120 @@ a hosting checkout can override the store during installation.
|
|
|
27
55
|
|
|
28
56
|
`test:workspace` checks public types and the installed executable through an
|
|
29
57
|
isolated tarball consumer, without publishing or running install scripts. An
|
|
30
|
-
optional `workspace.ts` argument also checks a consuming project's policy.
|
|
58
|
+
optional `workspace.ts` argument also checks a consuming project's policy. Run
|
|
59
|
+
aggregate commands at the repository root: the gate delegates service-local checks
|
|
60
|
+
to `server/`, including its public API contract's browser-safety checks, and verifies
|
|
61
|
+
cross-runtime boundaries at the root.
|
|
62
|
+
Build is a clean followed by ordinary TypeScript compilation, without output
|
|
63
|
+
flattening or import rewriting. Root `bin` and `exports` select the emitted paths;
|
|
64
|
+
use the command/public export instead of depending on physical dist paths.
|
|
65
|
+
|
|
66
|
+
## Run the source server
|
|
67
|
+
|
|
68
|
+
After building, run from this repository:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
node dist/server/src/main.js serve --workspace /absolute/path/to/checkout
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The CLI form is `forge serve --workspace <path>` once a server-capable release
|
|
75
|
+
is installed. Relative workspace paths resolve against the invocation directory;
|
|
76
|
+
the selected directory is canonicalized as the authoritative checkout.
|
|
77
|
+
|
|
78
|
+
Startup loads exactly `<guidance-folder>/workspace.ts`. The default folder is
|
|
79
|
+
`docs/forge/workspace` inside the selected checkout. A nonempty process
|
|
80
|
+
`ZD_AI_FORGE_WORKSPACE_GUIDE` selects a different folder, relative to that checkout
|
|
81
|
+
or absolute; `.env` cannot set this override. Invalid explicit choices fail
|
|
82
|
+
without ancestor search or fallback. The standalone server requires the trusted
|
|
83
|
+
TypeScript module; it does not consume the Python workflow's
|
|
84
|
+
`on-codebase-selection.md` or concrete/type registrations in `forge-workspace.yaml`.
|
|
85
|
+
Its policy membership rules are not yet aligned with Python's concrete-first
|
|
86
|
+
routing. External trusted guidance folders are allowed.
|
|
87
|
+
|
|
88
|
+
Set `ZD_AI_FORGE_PORT` in the process environment or selected checkout's `.env`.
|
|
89
|
+
A nonempty process value takes precedence, including invalid values; an empty
|
|
90
|
+
process value falls back to `.env`. Ports use one to five ASCII decimal digits
|
|
91
|
+
and range from 1024 through 65535. There is no default, allocation or occupied-port
|
|
92
|
+
fallback. The narrow `.env` syntax supports whole-line comments, optional
|
|
93
|
+
`export`, whitespace and surrounding quotes; last assignment wins and malformed
|
|
94
|
+
lines are ignored. It performs no interpolation, multiline or escape expansion;
|
|
95
|
+
only the port setting is imported. A missing `.env` is allowed, while unreadable
|
|
96
|
+
or invalid-encoding files fail startup.
|
|
97
|
+
|
|
98
|
+
After listener readiness, the foreground process prints its package version,
|
|
99
|
+
JSON-quoted executable and authoritative workspace paths, then
|
|
100
|
+
`http://127.0.0.1:<port>` on its own line. It prints no environment values.
|
|
101
|
+
The `inspect-workspace` diagnostic retains its JSON-only stdout contract.
|
|
102
|
+
Open that address to see workspace/repository identity and current changed files,
|
|
103
|
+
including Git-reported rename sources and concrete
|
|
104
|
+
codebase memberships. Workspace names, paths and guidance are displayed literally
|
|
105
|
+
as text. The browser title uses `🛠️ <workspace name>` from server identity;
|
|
106
|
+
it starts as `Forge` and returns to `Forge` when the identity request fails.
|
|
107
|
+
The page shows loading, empty and error states; Refresh retries failed requests and clears the
|
|
108
|
+
previous change list while loading. Successful workspace identity is retained
|
|
109
|
+
when a request fails. Refresh reads current changes but does not reload startup
|
|
110
|
+
policy or repository participation. There is no polling.
|
|
111
|
+
|
|
112
|
+
`GET /api/workspace` returns the workspace root, resolved display name and declared
|
|
113
|
+
available/missing repositories in declaration order. Invalid existing repositories, Git/access
|
|
114
|
+
failures and policy/configuration errors refuse startup. Requests require that
|
|
115
|
+
exact Host and, when present, Origin; CLI requests may omit Origin. Restart to
|
|
116
|
+
reload configuration, policy and repository identity. Stop with SIGINT or
|
|
117
|
+
SIGTERM; shutdown closes the listener and releases the port.
|
|
118
|
+
|
|
119
|
+
`GET /api/changes` returns
|
|
120
|
+
`{ codebases: { id: { guidance: "path" } }, changes: [...] }`. Changes carry repo,
|
|
121
|
+
workspace-relative path, added/modified/deleted/renamed kind, optional rename
|
|
122
|
+
`from` and unique codebase ID arrays. Definitions occur once per response;
|
|
123
|
+
conflicting guidance for one ID fails collection. No binary/line counts.
|
|
124
|
+
|
|
125
|
+
Comparison uses one bulk HEAD/working `diff --raw --numstat -z` and non-ignored untracked listing,
|
|
126
|
+
with targeted normalized hash/mode reconciliation for recreated deletion/rename
|
|
127
|
+
sources. Index refresh is disabled; numstat presence removes raw stat noise while
|
|
128
|
+
mode/type changes, zero-count changes and binaries remain. Counts stay internal.
|
|
129
|
+
Identical staged reversals disappear. Git owns attributes, filters and
|
|
130
|
+
index flags; hidden flag edits can remain hidden, and ignored recreations absent
|
|
131
|
+
from the untracked list remain deletions. Unstaged moves stay deletion/addition.
|
|
132
|
+
Unborn HEAD is supported; conflicts fail even with no diff. This is practical Git
|
|
133
|
+
comparison, not exhaustive physical equivalence or a replacement for dirt detection.
|
|
134
|
+
|
|
135
|
+
The observed command baseline is Git 2.34.1. Complete local repositories are
|
|
136
|
+
supported; partial/promisor repositories fail before object-demanding comparison
|
|
137
|
+
to prevent silent lazy fetch. Normal trusted clean/process filters are allowed;
|
|
138
|
+
external diff/textconv, optional writes, fsmonitor and hooks are disabled. Ambient
|
|
139
|
+
Git repository/index/worktree overrides do not select another checkout. Server
|
|
140
|
+
commands do not stage/commit/write indexes, refs or objects. Trusted policy and
|
|
141
|
+
filters can execute code with side effects; this is not an effects sandbox.
|
|
142
|
+
|
|
143
|
+
Missing-at-startup repositories remain excluded; loss of an available repository,
|
|
144
|
+
Git failures or invalid/throwing routing results fail the whole request. Paths
|
|
145
|
+
stay inside the workspace even when its Git root extends above it. Declared
|
|
146
|
+
nested repositories are distinct; undeclared clones and Git internals are excluded.
|
|
147
|
+
Results are live observations, without atomic cross-repository snapshots.
|
|
148
|
+
Overlapping requests share one active collection, with no result cache. A
|
|
149
|
+
30-second collection deadline bounds waiting; shutdown aborts and reaps active
|
|
150
|
+
Git children before completion. The browser allows 35 seconds per request, then restores retry.
|
|
151
|
+
|
|
152
|
+
For a direct check while the server runs, replace the example address with the
|
|
153
|
+
address printed by the server:
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
FORGE_ADDRESS=http://127.0.0.1:12345
|
|
157
|
+
curl "$FORGE_ADDRESS/api/workspace"
|
|
158
|
+
curl "$FORGE_ADDRESS/api/changes"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The server and browser page are read-only: no staging, commits, diff rendering,
|
|
162
|
+
file browsing or agent interaction. Registry `0.1.1` remains the diagnostic-only
|
|
163
|
+
release; source and installed-tarball verification do not constitute publication
|
|
164
|
+
or registry adoption.
|
|
31
165
|
|
|
32
166
|
## Workspace authoring contract (provisional)
|
|
33
167
|
|
|
34
168
|
Projects can exact-pin Forge as a local development dependency:
|
|
35
169
|
|
|
36
170
|
```sh
|
|
37
|
-
pnpm add --save-dev --save-exact @zdreamer/forge@0.1.
|
|
171
|
+
pnpm add --save-dev --save-exact @zdreamer/forge@0.1.1
|
|
38
172
|
```
|
|
39
173
|
|
|
40
174
|
```ts
|
|
@@ -46,6 +180,24 @@ export default {
|
|
|
46
180
|
} satisfies WorkspacePolicy;
|
|
47
181
|
```
|
|
48
182
|
|
|
183
|
+
The unreleased source also accepts optional `displayName` policy metadata. An
|
|
184
|
+
explicit value must be a nonempty string and is preserved exactly, without
|
|
185
|
+
trimming or HTML interpretation. The server resolves an omitted name to the
|
|
186
|
+
canonical workspace root's basename, or the root itself if it has no basename,
|
|
187
|
+
and returns it as `workspace.name` in identity. Restart to reload the name.
|
|
188
|
+
Registry `0.1.1` does not declare this field in its authoring types. To retain
|
|
189
|
+
metadata while typechecking against that exact pin, use a structurally checked
|
|
190
|
+
variable:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
const policy = {
|
|
194
|
+
displayName: 'Example workspace',
|
|
195
|
+
repositories: [{ path: '.' }],
|
|
196
|
+
codebasesForChange: () => [],
|
|
197
|
+
};
|
|
198
|
+
export default policy satisfies WorkspacePolicy;
|
|
199
|
+
```
|
|
200
|
+
|
|
49
201
|
Module lookup follows the workspace file's ancestors, not the executable's
|
|
50
202
|
installation. A global executable alone does not supply editor/typechecker
|
|
51
203
|
resolution. Keep the dependency in an installation above the workspace module;
|
|
@@ -62,10 +214,11 @@ Repository IDs use `.` for the main repo and normalized workspace-relative POSIX
|
|
|
62
214
|
paths for nested repos. Change paths are workspace-relative; legacy CLI changes
|
|
63
215
|
can include `../` paths when the main Git root lies above the workspace.
|
|
64
216
|
`codebasesForChange` resolves membership lexically, without requiring files to
|
|
65
|
-
exist, and includes both rename
|
|
217
|
+
exist, and includes both rename sides. Several concrete codebases can share
|
|
66
218
|
guidance; membership does not imply verification.
|
|
67
219
|
|
|
68
220
|
Policy modules are trusted executable code, not sandboxed; installation does not
|
|
69
|
-
load them.
|
|
70
|
-
|
|
221
|
+
load them. The changes collector validates callback results at runtime; the diagnostic
|
|
222
|
+
uses the authored types without that validation. See
|
|
223
|
+
[architecture](https://github.com/ZDreamer2/forge/blob/main/docs/architecture.md) for loading limits and repository resolution,
|
|
71
224
|
which the diagnostic does not invoke.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type Deps<T> = {
|
|
2
|
+
collect: (signal: AbortSignal) => Promise<T>;
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
};
|
|
5
|
+
/** One live operation shared by callers; settled results are never cached. */
|
|
6
|
+
export declare function createCollection<T>({ collect, timeoutMs }: Deps<T>): {
|
|
7
|
+
collect(): Promise<T>;
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** One live operation shared by callers; settled results are never cached. */
|
|
2
|
+
export function createCollection({ collect, timeoutMs = 30_000 }) {
|
|
3
|
+
let accepting = true;
|
|
4
|
+
let active;
|
|
5
|
+
return {
|
|
6
|
+
collect() {
|
|
7
|
+
if (!accepting)
|
|
8
|
+
return Promise.reject(new Error("Forge is shutting down"));
|
|
9
|
+
if (active)
|
|
10
|
+
return active.promise;
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const timer = setTimeout(() => controller.abort(new Error("Workspace collection timed out")), timeoutMs);
|
|
13
|
+
const promise = Promise.resolve()
|
|
14
|
+
.then(() => collect(controller.signal))
|
|
15
|
+
.finally(() => {
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
active = undefined;
|
|
18
|
+
});
|
|
19
|
+
active = { controller, promise };
|
|
20
|
+
return promise;
|
|
21
|
+
},
|
|
22
|
+
async close() {
|
|
23
|
+
accepting = false;
|
|
24
|
+
const pending = active;
|
|
25
|
+
pending?.controller.abort(new Error("Forge is shutting down"));
|
|
26
|
+
await pending?.promise.catch(() => { });
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
readonly root: string;
|
|
3
|
+
readonly policyPath: string;
|
|
4
|
+
readonly port: number;
|
|
5
|
+
}
|
|
6
|
+
interface ConfigInput {
|
|
7
|
+
workspace: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
10
|
+
}
|
|
11
|
+
export declare function loadConfig({ workspace, cwd, env }: ConfigInput): Promise<Readonly<Config>>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
export async function loadConfig({ workspace, cwd, env }) {
|
|
4
|
+
const guidance = env.ZD_AI_FORGE_WORKSPACE_GUIDE || "docs/forge/workspace";
|
|
5
|
+
const processPort = env.ZD_AI_FORGE_PORT;
|
|
6
|
+
const selected = resolve(cwd, workspace);
|
|
7
|
+
if (!(await stat(selected)).isDirectory()) {
|
|
8
|
+
throw new Error("Workspace must be a directory");
|
|
9
|
+
}
|
|
10
|
+
const root = await realpath(selected);
|
|
11
|
+
const policyPath = join(resolve(root, guidance), "workspace.ts");
|
|
12
|
+
if (!(await stat(policyPath)).isFile()) {
|
|
13
|
+
throw new Error("Workspace policy must be a workspace.ts file in the selected guidance folder");
|
|
14
|
+
}
|
|
15
|
+
const dotenvPort = await readDotenvPort(join(root, ".env"));
|
|
16
|
+
const value = processPort || dotenvPort || "";
|
|
17
|
+
if (!/^[0-9]{1,5}$/.test(value) || Number(value) < 1024 || Number(value) > 65535) {
|
|
18
|
+
throw new Error("ZD_AI_FORGE_PORT must be an ASCII decimal TCP port from 1024 through 65535");
|
|
19
|
+
}
|
|
20
|
+
return Object.freeze({ root, policyPath, port: Number(value) });
|
|
21
|
+
}
|
|
22
|
+
async function readDotenvPort(path) {
|
|
23
|
+
let text;
|
|
24
|
+
try {
|
|
25
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(await readFile(path));
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
29
|
+
return undefined;
|
|
30
|
+
throw new Error("Cannot read the workspace .env file as UTF-8");
|
|
31
|
+
}
|
|
32
|
+
let port;
|
|
33
|
+
// Match the workspace's narrow dotenv syntax: no interpolation, escapes,
|
|
34
|
+
// inline comments or multiline values; the last assignment wins.
|
|
35
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Python splitlines recognizes these separators.
|
|
36
|
+
for (const raw of text.split(/\r\n|[\n\r\v\f\x1c-\x1e\u0085\u2028\u2029]/)) {
|
|
37
|
+
let line = raw.trim();
|
|
38
|
+
if (!line || line.startsWith("#"))
|
|
39
|
+
continue;
|
|
40
|
+
if (line.startsWith("export "))
|
|
41
|
+
line = line.slice(7).trimStart();
|
|
42
|
+
const separator = line.indexOf("=");
|
|
43
|
+
if (separator < 0 || line.slice(0, separator).trim() !== "ZD_AI_FORGE_PORT")
|
|
44
|
+
continue;
|
|
45
|
+
let value = line.slice(separator + 1).trim();
|
|
46
|
+
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) {
|
|
47
|
+
value = value.slice(1, -1);
|
|
48
|
+
}
|
|
49
|
+
port = value;
|
|
50
|
+
}
|
|
51
|
+
return port;
|
|
52
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { CollectedWorkspaceChanges } from "../components/workspace-changes/index.ts";
|
|
3
|
+
import type { RepositoryResolution } from "../components/workspace-policy/index.ts";
|
|
4
|
+
type Deps = {
|
|
5
|
+
root: string;
|
|
6
|
+
name: string;
|
|
7
|
+
repositories: readonly RepositoryResolution[];
|
|
8
|
+
port: number;
|
|
9
|
+
collectChanges: () => Promise<CollectedWorkspaceChanges>;
|
|
10
|
+
reportError?: (error: Error) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function createHttpApp({ root, name, repositories, port, collectChanges, reportError }: Deps): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { createDiagnosticRoutes } from "../components/diagnostic-ui/index.js";
|
|
3
|
+
import { AccessRejectedError, createRequestGuard } from "../lib/loopback-access/index.js";
|
|
4
|
+
import { apiError } from "./wire.js";
|
|
5
|
+
import { createWorkspaceRoutes } from "./workspace-routes.js";
|
|
6
|
+
export function createHttpApp({ root, name, repositories, port, collectChanges, reportError }) {
|
|
7
|
+
const app = new Hono();
|
|
8
|
+
app.onError((error, context) => {
|
|
9
|
+
if (error instanceof AccessRejectedError)
|
|
10
|
+
return context.json(apiError("forbidden"), 403);
|
|
11
|
+
reportError?.(error);
|
|
12
|
+
return context.json(apiError("internal_error"), 500);
|
|
13
|
+
});
|
|
14
|
+
app.use("*", createRequestGuard({ port }));
|
|
15
|
+
app.route("/", createDiagnosticRoutes());
|
|
16
|
+
app.route("/api", createWorkspaceRoutes({ root, name, repositories, collectChanges }));
|
|
17
|
+
app.notFound((context) => context.json(apiError("not_found"), 404));
|
|
18
|
+
return app;
|
|
19
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { getRequestListener, RequestError } from "@hono/node-server";
|
|
3
|
+
import { apiError } from "./wire.js";
|
|
4
|
+
export async function createListener({ fetch, port }) {
|
|
5
|
+
const server = createServer(getRequestListener(fetch, {
|
|
6
|
+
overrideGlobalObjects: false,
|
|
7
|
+
errorHandler: (error) => Response.json(apiError(error instanceof RequestError ? "forbidden" : "internal_error"), {
|
|
8
|
+
status: error instanceof RequestError ? 403 : 500,
|
|
9
|
+
}),
|
|
10
|
+
}));
|
|
11
|
+
const sockets = new Set();
|
|
12
|
+
const onConnection = (socket) => {
|
|
13
|
+
sockets.add(socket);
|
|
14
|
+
socket.once("close", () => sockets.delete(socket));
|
|
15
|
+
};
|
|
16
|
+
server.on("connection", onConnection);
|
|
17
|
+
try {
|
|
18
|
+
await new Promise((resolve, reject) => {
|
|
19
|
+
const onError = (error) => {
|
|
20
|
+
server.off("listening", onListening);
|
|
21
|
+
reject(error);
|
|
22
|
+
};
|
|
23
|
+
const onListening = () => {
|
|
24
|
+
server.off("error", onError);
|
|
25
|
+
resolve();
|
|
26
|
+
};
|
|
27
|
+
server.once("error", onError);
|
|
28
|
+
server.once("listening", onListening);
|
|
29
|
+
server.listen(port, "127.0.0.1");
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
server.off("connection", onConnection);
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
const address = server.address();
|
|
37
|
+
if (!address || typeof address === "string")
|
|
38
|
+
throw new Error("Missing listener address");
|
|
39
|
+
let closing;
|
|
40
|
+
return {
|
|
41
|
+
address: `http://127.0.0.1:${address.port}`,
|
|
42
|
+
close() {
|
|
43
|
+
closing ??= new Promise((resolve, reject) => {
|
|
44
|
+
// Bound draining so an incomplete request cannot keep foreground shutdown alive.
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
for (const socket of sockets)
|
|
47
|
+
socket.destroy();
|
|
48
|
+
}, 500);
|
|
49
|
+
server.close((error) => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
server.off("connection", onConnection);
|
|
52
|
+
if (error)
|
|
53
|
+
reject(error);
|
|
54
|
+
else
|
|
55
|
+
resolve();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
return closing;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type Options = {
|
|
2
|
+
workspace: string;
|
|
3
|
+
cwd: string;
|
|
4
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
5
|
+
};
|
|
6
|
+
export declare function start(options: Options): Promise<{
|
|
7
|
+
address: string;
|
|
8
|
+
close: () => Promise<void>;
|
|
9
|
+
snapshot: Readonly<{
|
|
10
|
+
root: string;
|
|
11
|
+
name: string;
|
|
12
|
+
policy: import("../components/workspace-policy/index.ts").WorkspacePolicy;
|
|
13
|
+
repositories: readonly import("../components/workspace-policy/index.ts").RepositoryResolution[];
|
|
14
|
+
}>;
|
|
15
|
+
}>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { createWorkspaceChanges } from "../components/workspace-changes/index.js";
|
|
3
|
+
import { loadWorkspacePolicy, resolveRepositories } from "../components/workspace-policy/index.js";
|
|
4
|
+
import { createGitProcess } from "../lib/git/index.js";
|
|
5
|
+
import { createCollection } from "./collection.js";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { createHttpApp } from "./http.js";
|
|
8
|
+
import { createListener } from "./listener.js";
|
|
9
|
+
export async function start(options) {
|
|
10
|
+
const config = await loadConfig(options);
|
|
11
|
+
const policy = await loadWorkspacePolicy(config.policyPath);
|
|
12
|
+
const git = createGitProcess(options.env);
|
|
13
|
+
const repositories = await resolveRepositories(config.root, policy.repositories, git);
|
|
14
|
+
const name = policy.displayName ?? (basename(config.root) || config.root);
|
|
15
|
+
const snapshot = Object.freeze({ root: config.root, name, policy, repositories });
|
|
16
|
+
const changes = createWorkspaceChanges({
|
|
17
|
+
root: config.root,
|
|
18
|
+
repositories,
|
|
19
|
+
codebasesForChange: (change) => policy.codebasesForChange(change),
|
|
20
|
+
git,
|
|
21
|
+
});
|
|
22
|
+
const collection = createCollection({ collect: (signal) => changes.collect(signal) });
|
|
23
|
+
const app = createHttpApp({
|
|
24
|
+
...snapshot,
|
|
25
|
+
collectChanges: collection.collect,
|
|
26
|
+
port: config.port,
|
|
27
|
+
reportError: (error) => {
|
|
28
|
+
const causes = [];
|
|
29
|
+
let current = error;
|
|
30
|
+
while (current instanceof Error && causes.length < 3) {
|
|
31
|
+
causes.push(current.message);
|
|
32
|
+
current = current.cause;
|
|
33
|
+
}
|
|
34
|
+
const message = causes
|
|
35
|
+
.join(": ")
|
|
36
|
+
.replace(/\p{Cc}/gu, " ")
|
|
37
|
+
.slice(0, 1600);
|
|
38
|
+
console.error(`Forge workspace request failed: ${message}`);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const listener = await createListener({ fetch: app.fetch, port: config.port });
|
|
42
|
+
let disposal;
|
|
43
|
+
function close() {
|
|
44
|
+
if (disposal === undefined) {
|
|
45
|
+
process.off("SIGINT", terminate);
|
|
46
|
+
process.off("SIGTERM", terminate);
|
|
47
|
+
disposal = Promise.all([collection.close(), listener.close()]).then(() => { });
|
|
48
|
+
}
|
|
49
|
+
return disposal;
|
|
50
|
+
}
|
|
51
|
+
function terminate() {
|
|
52
|
+
void close().catch(() => {
|
|
53
|
+
console.error("Forge shutdown failed");
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
process.on("SIGINT", terminate);
|
|
58
|
+
process.on("SIGTERM", terminate);
|
|
59
|
+
return { address: listener.address, close, snapshot };
|
|
60
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ApiErrorCode, WorkspaceChanges, WorkspaceIdentity } from "../components/api-contract/index.ts";
|
|
2
|
+
import type { CollectedWorkspaceChanges } from "../components/workspace-changes/index.ts";
|
|
3
|
+
import type { RepositoryResolution } from "../components/workspace-policy/index.ts";
|
|
4
|
+
export declare function apiError(code: ApiErrorCode): {
|
|
5
|
+
error: {
|
|
6
|
+
code: ApiErrorCode;
|
|
7
|
+
message: string;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
export declare function workspaceIdentity(root: string, name: string, repositories: readonly RepositoryResolution[]): WorkspaceIdentity;
|
|
11
|
+
export declare function workspaceChanges(result: CollectedWorkspaceChanges): WorkspaceChanges;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function apiError(code) {
|
|
2
|
+
const messages = {
|
|
3
|
+
forbidden: "Request forbidden",
|
|
4
|
+
not_found: "Not found",
|
|
5
|
+
method_not_allowed: "Method not allowed",
|
|
6
|
+
internal_error: "Internal server error",
|
|
7
|
+
};
|
|
8
|
+
return { error: { code, message: messages[code] } };
|
|
9
|
+
}
|
|
10
|
+
export function workspaceIdentity(root, name, repositories) {
|
|
11
|
+
return {
|
|
12
|
+
workspace: { root, name },
|
|
13
|
+
repositories: repositories.map((repository) => repository.status === "available"
|
|
14
|
+
? { path: repository.path, status: "available", gitRoot: repository.gitRoot }
|
|
15
|
+
: { path: repository.path, status: "missing" }),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function workspaceChanges(result) {
|
|
19
|
+
return {
|
|
20
|
+
codebases: Object.fromEntries(Object.entries(result.codebases).map(([id, definition]) => [id, { guidance: definition.guidance }])),
|
|
21
|
+
changes: result.changes.map((change) => ({
|
|
22
|
+
repo: change.repo,
|
|
23
|
+
path: change.path,
|
|
24
|
+
kind: change.kind,
|
|
25
|
+
...(change.kind === "renamed" && change.from !== undefined ? { from: change.from } : {}),
|
|
26
|
+
codebases: [...change.codebases],
|
|
27
|
+
})),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { CollectedWorkspaceChanges } from "../components/workspace-changes/index.ts";
|
|
3
|
+
import type { RepositoryResolution } from "../components/workspace-policy/index.ts";
|
|
4
|
+
type Deps = {
|
|
5
|
+
root: string;
|
|
6
|
+
name: string;
|
|
7
|
+
repositories: readonly RepositoryResolution[];
|
|
8
|
+
collectChanges: () => Promise<CollectedWorkspaceChanges>;
|
|
9
|
+
};
|
|
10
|
+
export declare function createWorkspaceRoutes({ root, name, repositories, collectChanges }: Deps): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { apiError, workspaceChanges, workspaceIdentity } from "./wire.js";
|
|
3
|
+
export function createWorkspaceRoutes({ root, name, repositories, collectChanges }) {
|
|
4
|
+
const identity = workspaceIdentity(root, name, repositories);
|
|
5
|
+
const routes = new Hono();
|
|
6
|
+
routes.get("/workspace", (context) => context.json(identity));
|
|
7
|
+
routes.get("/changes", async (context) => context.json(workspaceChanges(await collectChanges())));
|
|
8
|
+
for (const path of ["/workspace", "/changes"]) {
|
|
9
|
+
routes.all(path, (context) => {
|
|
10
|
+
context.header("Allow", "GET, HEAD");
|
|
11
|
+
return context.json(apiError("method_not_allowed"), 405);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return routes;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { ApiErrorCode, WorkspaceChanges, WorkspaceIdentity } from "./internal/types.ts";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type WorkspaceIdentity = {
|
|
2
|
+
workspace: {
|
|
3
|
+
root: string;
|
|
4
|
+
name: string;
|
|
5
|
+
};
|
|
6
|
+
repositories: ({
|
|
7
|
+
path: string;
|
|
8
|
+
status: "available";
|
|
9
|
+
gitRoot: string;
|
|
10
|
+
} | {
|
|
11
|
+
path: string;
|
|
12
|
+
status: "missing";
|
|
13
|
+
})[];
|
|
14
|
+
};
|
|
15
|
+
export type ApiErrorCode = "forbidden" | "not_found" | "method_not_allowed" | "internal_error";
|
|
16
|
+
export type WorkspaceChanges = {
|
|
17
|
+
codebases: Record<string, {
|
|
18
|
+
guidance: string;
|
|
19
|
+
}>;
|
|
20
|
+
changes: {
|
|
21
|
+
repo: string;
|
|
22
|
+
path: string;
|
|
23
|
+
kind: "added" | "modified" | "deleted" | "renamed";
|
|
24
|
+
from?: string;
|
|
25
|
+
codebases: string[];
|
|
26
|
+
}[];
|
|
27
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createDiagnosticRoutes } from "./internal/routes.ts";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createDiagnosticRoutes } from "./internal/routes.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const page = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<link rel=\"icon\" href=\"data:,\">\n<title>Forge</title>\n<style>\nbody { font: 16px system-ui, sans-serif; margin: 2rem auto; padding: 0 1rem; max-width: 75rem; color: #202830; background: #fafbfc; }\nbutton { font: inherit; padding: .5rem 1rem; cursor: pointer; }\nbutton:disabled { cursor: wait; }\nli { margin: .75rem 0; overflow-wrap: anywhere; }\n.path { font-family: ui-monospace, monospace; }\n.error { color: #a02020; }\nsection { border-top: 1px solid #ccd3da; margin-top: 1.5rem; }\n</style>\n</head>\n<body>\n<h1>Forge workspace</h1>\n<p>Read-only workspace diagnostic. Refresh reads current changes. Restart the server to reload policy and repository participation.</p>\n<button id=\"refresh\" type=\"button\">Refresh</button>\n<p id=\"status\" role=\"status\" aria-live=\"polite\">Loading\u2026</p>\n<section aria-labelledby=\"identity-heading\">\n<h2 id=\"identity-heading\">Workspace and repositories</h2>\n<p id=\"identity-error\" class=\"error\" role=\"alert\"></p>\n<div id=\"identity\"></div>\n</section>\n<section aria-labelledby=\"changes-heading\">\n<h2 id=\"changes-heading\">Current changes</h2>\n<p id=\"changes-error\" class=\"error\" role=\"alert\"></p>\n<div id=\"changes\"></div>\n</section>\n<script>\n\"use strict\";\nconst refreshButton = document.getElementById(\"refresh\");\nconst status = document.getElementById(\"status\");\nconst identity = document.getElementById(\"identity\");\nconst changes = document.getElementById(\"changes\");\nconst identityError = document.getElementById(\"identity-error\");\nconst changesError = document.getElementById(\"changes-error\");\nfunction textElement(tag, text, className) {\n const element = document.createElement(tag);\n element.textContent = text;\n if (className) element.className = className;\n return element;\n}\nasync function request(path) {\n const controller = new AbortController();\n let timer;\n try {\n return await Promise.race([\n (async () => {\n const response = await fetch(path, { cache: \"no-store\", signal: controller.signal });\n if (!response.ok) throw new Error(\"Request failed (HTTP \" + response.status + \")\");\n return response.json();\n })(),\n new Promise((_, reject) => {\n // Allow the server's 30-second collection deadline to return its response first.\n timer = setTimeout(() => {\n reject(new Error(\"Request timed out\"));\n controller.abort();\n }, 35000);\n })\n ]);\n } finally {\n clearTimeout(timer);\n }\n}\nfunction showIdentity(data) {\n document.title = \"\uD83D\uDEE0\uFE0F \" + data.workspace.name;\n const root = textElement(\"p\", data.workspace.root, \"path\");\n const list = document.createElement(\"ul\");\n for (const repo of data.repositories) {\n list.append(textElement(\"li\", repo.path + \" \u2014 \" + repo.status + (repo.gitRoot ? \" \u2014 \" + repo.gitRoot : \"\"), \"path\"));\n }\n identity.replaceChildren(root, list);\n}\nfunction showChanges(data) {\n if (data.changes.length === 0) {\n changes.replaceChildren(textElement(\"p\", \"No uncommitted changes.\"));\n return;\n }\n const list = document.createElement(\"ul\");\n for (const change of data.changes) {\n const item = document.createElement(\"li\");\n item.append(textElement(\"p\", change.repo + \" \u00B7 \" + change.path + \" \u2014 \" + change.kind, \"path\"));\n if (change.from !== undefined) item.append(textElement(\"p\", \"From: \" + change.from, \"path\"));\n const memberships = document.createElement(\"ul\");\n for (const id of change.codebases) {\n memberships.append(textElement(\"li\", id + \" \u2014 \" + data.codebases[id].guidance, \"path\"));\n }\n item.append(change.codebases.length ? memberships : textElement(\"p\", \"No codebase membership.\"));\n list.append(item);\n }\n changes.replaceChildren(list);\n}\nasync function refresh() {\n if (refreshButton.disabled) return;\n refreshButton.disabled = true;\n status.textContent = \"Loading\u2026\";\n changes.replaceChildren();\n identityError.textContent = \"\";\n changesError.textContent = \"\";\n try {\n async function load(path, show, region, label) {\n try {\n show(await request(path));\n return true;\n } catch (error) {\n if (path === \"/api/workspace\") document.title = \"Forge\";\n region.textContent = label + \": \" + error.message + \". Use Refresh to retry.\";\n status.textContent = \"Refresh failed. Use Refresh to retry.\";\n return false;\n }\n }\n const results = await Promise.all([\n load(\"/api/workspace\", showIdentity, identityError, \"Workspace\"),\n load(\"/api/changes\", showChanges, changesError, \"Changes\")\n ]);\n status.textContent = results.every(Boolean) ? \"Up to date.\" : \"Refresh failed. Use Refresh to retry.\";\n } finally {\n refreshButton.disabled = false;\n }\n}\nrefreshButton.addEventListener(\"click\", refresh);\nrefresh();\n</script>\n</body>\n</html>";
|