@zdreamer/forge 0.1.1 → 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.
Files changed (62) hide show
  1. package/README.md +161 -8
  2. package/dist/server/src/app/collection.d.ts +10 -0
  3. package/dist/server/src/app/collection.js +29 -0
  4. package/dist/server/src/app/config.d.ts +12 -0
  5. package/dist/server/src/app/config.js +52 -0
  6. package/dist/server/src/app/http.d.ts +13 -0
  7. package/dist/server/src/app/http.js +19 -0
  8. package/dist/server/src/app/listener.d.ts +9 -0
  9. package/dist/server/src/app/listener.js +61 -0
  10. package/dist/server/src/app/startup.d.ts +16 -0
  11. package/dist/server/src/app/startup.js +60 -0
  12. package/dist/server/src/app/wire.d.ts +11 -0
  13. package/dist/server/src/app/wire.js +29 -0
  14. package/dist/server/src/app/workspace-routes.d.ts +11 -0
  15. package/dist/server/src/app/workspace-routes.js +15 -0
  16. package/dist/server/src/components/api-contract/index.d.ts +1 -0
  17. package/dist/server/src/components/api-contract/internal/types.d.ts +27 -0
  18. package/dist/server/src/components/diagnostic-ui/index.d.ts +1 -0
  19. package/dist/server/src/components/diagnostic-ui/index.js +1 -0
  20. package/dist/server/src/components/diagnostic-ui/internal/page.d.ts +1 -0
  21. package/dist/server/src/components/diagnostic-ui/internal/page.js +129 -0
  22. package/dist/server/src/components/diagnostic-ui/internal/routes.d.ts +2 -0
  23. package/dist/server/src/components/diagnostic-ui/internal/routes.js +11 -0
  24. package/dist/server/src/components/workspace-changes/index.d.ts +2 -0
  25. package/dist/server/src/components/workspace-changes/index.js +1 -0
  26. package/dist/server/src/components/workspace-changes/internal/boundaries.d.ts +3 -0
  27. package/dist/server/src/components/workspace-changes/internal/boundaries.js +56 -0
  28. package/dist/server/src/components/workspace-changes/internal/collect.d.ts +13 -0
  29. package/dist/server/src/components/workspace-changes/internal/collect.js +157 -0
  30. package/dist/server/src/components/workspace-changes/internal/comparison.d.ts +13 -0
  31. package/dist/server/src/components/workspace-changes/internal/comparison.js +229 -0
  32. package/dist/server/src/components/workspace-changes/internal/types.d.ts +10 -0
  33. package/dist/server/src/components/workspace-changes/internal/types.js +1 -0
  34. package/dist/server/src/components/workspace-policy/authoring/index.d.ts +1 -0
  35. package/dist/server/src/components/workspace-policy/authoring/index.js +1 -0
  36. package/dist/{components/workspace-policy → server/src/components/workspace-policy/authoring}/internal/types.d.ts +4 -2
  37. package/dist/server/src/components/workspace-policy/authoring/internal/types.js +1 -0
  38. package/dist/{components → server/src/components}/workspace-policy/index.d.ts +2 -1
  39. package/dist/{components → server/src/components}/workspace-policy/internal/load.d.ts +1 -1
  40. package/dist/{components → server/src/components}/workspace-policy/internal/load.js +3 -0
  41. package/dist/{components → server/src/components}/workspace-policy/internal/repositories.d.ts +3 -2
  42. package/dist/{components → server/src/components}/workspace-policy/internal/repositories.js +12 -6
  43. package/dist/server/src/lib/git/index.d.ts +2 -0
  44. package/dist/server/src/lib/git/index.js +1 -0
  45. package/dist/server/src/lib/git/internal/process.d.ts +16 -0
  46. package/dist/server/src/lib/git/internal/process.js +166 -0
  47. package/dist/server/src/lib/loopback-access/index.d.ts +1 -0
  48. package/dist/server/src/lib/loopback-access/index.js +1 -0
  49. package/dist/server/src/lib/loopback-access/internal/request-guard.d.ts +9 -0
  50. package/dist/server/src/lib/loopback-access/internal/request-guard.js +17 -0
  51. package/dist/server/src/main.js +33 -0
  52. package/dist/server/src/workspace.d.ts +1 -0
  53. package/dist/server/src/workspace.js +1 -0
  54. package/package.json +10 -5
  55. package/dist/main.js +0 -23
  56. package/dist/workspace.d.ts +0 -1
  57. /package/dist/{app → server/src/app}/inspect.d.ts +0 -0
  58. /package/dist/{app → server/src/app}/inspect.js +0 -0
  59. /package/dist/{components/workspace-policy/internal/types.js → server/src/components/api-contract/index.js} +0 -0
  60. /package/dist/{workspace.js → server/src/components/api-contract/internal/types.js} +0 -0
  61. /package/dist/{components → server/src/components}/workspace-policy/index.js +0 -0
  62. /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 prototype of the Forge workspace application: a policy-loading
4
- diagnostic, not a server or review UI. Executable: `forge`; public authoring
5
- types: `@zdreamer/forge/workspace`. Licensed under [MIT](LICENSE).
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/master/docs/releasing.md).
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,7 +55,113 @@ 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
 
@@ -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/copy sides. Several concrete codebases can share
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. Types do not validate callback results at runtime. See
70
- [architecture](https://github.com/ZDreamer2/forge/blob/master/docs/architecture.md) for loading limits and repository resolution,
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,9 @@
1
+ type Deps = {
2
+ fetch: (request: Request) => Response | Promise<Response>;
3
+ port: number;
4
+ };
5
+ export declare function createListener({ fetch, port }: Deps): Promise<{
6
+ address: string;
7
+ close(): Promise<void>;
8
+ }>;
9
+ export {};
@@ -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>";