@zerotal/arch 1.7.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/src/mcp/stdio.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stdio binding: read newline-delimited JSON-RPC from stdin, write replies
|
|
3
|
+
* to stdout.
|
|
4
|
+
*
|
|
5
|
+
* ## Two rules this file exists to keep
|
|
6
|
+
*
|
|
7
|
+
* **Nothing but MCP frames reaches stdout.** The spec states it as a MUST NOT,
|
|
8
|
+
* and the failure mode is not a warning — a stray banner on stdout desyncs the
|
|
9
|
+
* client's line parser and every frame after it is garbage. Diagnostics go to
|
|
10
|
+
* stderr, which the spec explicitly allows and clients are told not to read as
|
|
11
|
+
* failure.
|
|
12
|
+
*
|
|
13
|
+
* **Requests are dispatched concurrently, not in lockstep.** Awaiting each
|
|
14
|
+
* request before reading the next would make `notifications/cancelled`
|
|
15
|
+
* unreachable: the cancellation for a running tool is by definition sent while
|
|
16
|
+
* that tool is still running. Each frame is written in a single call, so replies
|
|
17
|
+
* may interleave in time but never within a message.
|
|
18
|
+
*
|
|
19
|
+
* The loop ends when stdin reaches EOF. That is the only portable shutdown
|
|
20
|
+
* signal — the spec names closing stdin as the primary one, and on Windows
|
|
21
|
+
* there is no POSIX signal to fall back to.
|
|
22
|
+
*/
|
|
23
|
+
import { decodeFrame, encodeFrame } from "./jsonrpc.ts";
|
|
24
|
+
import type { McpServer } from "./server.ts";
|
|
25
|
+
import type { JsonRpcResponse } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
export interface StdioOptions {
|
|
28
|
+
server: McpServer;
|
|
29
|
+
/** Byte source. Defaults to this process's stdin. */
|
|
30
|
+
input?: ReadableStream<Uint8Array>;
|
|
31
|
+
/** Frame sink. Defaults to this process's stdout. Injected in tests. */
|
|
32
|
+
write?: (frame: string) => void;
|
|
33
|
+
/** Diagnostics sink. Defaults to stderr — never stdout. */
|
|
34
|
+
log?: (message: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Serve MCP over a byte stream until it ends.
|
|
39
|
+
*
|
|
40
|
+
* Resolves once the input is exhausted *and* every request still in flight has
|
|
41
|
+
* been answered, so a caller can exit the process knowing nothing is pending.
|
|
42
|
+
*/
|
|
43
|
+
export async function serveStdio(options: StdioOptions): Promise<void> {
|
|
44
|
+
const { server } = options;
|
|
45
|
+
const input = options.input ?? Bun.stdin.stream();
|
|
46
|
+
const write = options.write ?? ((frame: string) => void process.stdout.write(frame));
|
|
47
|
+
const log = options.log ?? ((message: string) => void process.stderr.write(message + "\n"));
|
|
48
|
+
|
|
49
|
+
const decoder = new TextDecoder();
|
|
50
|
+
const pending = new Set<Promise<void>>();
|
|
51
|
+
let buffer = "";
|
|
52
|
+
|
|
53
|
+
const dispatch = (line: string): void => {
|
|
54
|
+
const decoded = decodeFrame(line);
|
|
55
|
+
if (decoded === undefined) return;
|
|
56
|
+
if (!decoded.ok) {
|
|
57
|
+
send(decoded.failure);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const task = server
|
|
62
|
+
.handle(decoded.request)
|
|
63
|
+
.then((response) => {
|
|
64
|
+
if (response !== undefined) send(response);
|
|
65
|
+
})
|
|
66
|
+
.catch((error: unknown) => {
|
|
67
|
+
// Reaching here means the dispatcher itself failed, not a tool — tools
|
|
68
|
+
// have their own catch. There is no useful reply to send for it, so the
|
|
69
|
+
// honest thing is to say so on stderr and keep serving.
|
|
70
|
+
log(`[arch] dispatch failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
71
|
+
})
|
|
72
|
+
.finally(() => {
|
|
73
|
+
pending.delete(task);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pending.add(task);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const send = (response: JsonRpcResponse): void => {
|
|
80
|
+
try {
|
|
81
|
+
write(encodeFrame(response));
|
|
82
|
+
} catch (error) {
|
|
83
|
+
log(`[arch] could not encode a reply: ${error instanceof Error ? error.message : error}`);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Read through the reader rather than `for await`: Bun's streams are async
|
|
88
|
+
// iterable at runtime, but the DOM `ReadableStream` type this repo compiles
|
|
89
|
+
// against does not declare it, and the alternative is a cast in the one file
|
|
90
|
+
// whose whole job is to be trustworthy.
|
|
91
|
+
const reader = input.getReader();
|
|
92
|
+
for (;;) {
|
|
93
|
+
const { done, value } = await reader.read();
|
|
94
|
+
if (done) break;
|
|
95
|
+
buffer += decoder.decode(value, { stream: true });
|
|
96
|
+
|
|
97
|
+
let newline = buffer.indexOf("\n");
|
|
98
|
+
while (newline !== -1) {
|
|
99
|
+
const line = buffer.slice(0, newline);
|
|
100
|
+
buffer = buffer.slice(newline + 1);
|
|
101
|
+
dispatch(line);
|
|
102
|
+
newline = buffer.indexOf("\n");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A final line with no trailing newline is still a message. Clients should not
|
|
107
|
+
// send one, but a shell heredoc or a hand-written fixture will.
|
|
108
|
+
if (buffer.trim().length > 0) dispatch(buffer);
|
|
109
|
+
|
|
110
|
+
while (pending.size > 0) await Promise.all([...pending]);
|
|
111
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary shared by the transport and the tools — and the seam between
|
|
3
|
+
* them.
|
|
4
|
+
*
|
|
5
|
+
* A tool is a plain object: a name, a description, a JSON Schema, and a `run`.
|
|
6
|
+
* It knows nothing about JSON-RPC, and nothing in `mcp/` knows what a Zerotal
|
|
7
|
+
* app is. That separation is what lets every tool be tested with no transport
|
|
8
|
+
* and the transport be tested with no app, and it is what leaves room for a
|
|
9
|
+
* second transport later without touching a single tool.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ── The tool contract ─────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A JSON Schema document, kept deliberately loose.
|
|
16
|
+
*
|
|
17
|
+
* The 2026-07-28 revision widened `inputSchema` to allow any JSON Schema
|
|
18
|
+
* 2020-12 keyword, so a narrow hand-written type here would reject valid
|
|
19
|
+
* schemas rather than catch mistakes.
|
|
20
|
+
*/
|
|
21
|
+
export type JsonSchema = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
/** What a tool returns. Rendered into MCP content blocks by the server. */
|
|
24
|
+
export interface ToolOutcome {
|
|
25
|
+
/** Prose for the model. Always present, even when `data` carries the detail. */
|
|
26
|
+
text: string;
|
|
27
|
+
/**
|
|
28
|
+
* The machine-readable payload, matching the tool's `outputSchema`.
|
|
29
|
+
* Serialised into `structuredContent`.
|
|
30
|
+
*/
|
|
31
|
+
data?: unknown;
|
|
32
|
+
/**
|
|
33
|
+
* The tool ran and could not do what was asked — a missing file, an app that
|
|
34
|
+
* would not boot, a package that is not installed.
|
|
35
|
+
*
|
|
36
|
+
* Reported as `isError: true` on an otherwise ordinary result, never as a
|
|
37
|
+
* JSON-RPC error: the spec is explicit that execution errors are the ones a
|
|
38
|
+
* model can read and self-correct from, while protocol errors are not.
|
|
39
|
+
*/
|
|
40
|
+
failed?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One tool the server exposes. */
|
|
44
|
+
export interface ArchTool {
|
|
45
|
+
/** Wire name. Lowercase with underscores, to match the rest of the ecosystem. */
|
|
46
|
+
name: string;
|
|
47
|
+
/** Human-readable name for display. */
|
|
48
|
+
title: string;
|
|
49
|
+
/**
|
|
50
|
+
* What the tool does, written for a model deciding whether to call it.
|
|
51
|
+
*
|
|
52
|
+
* This is the single most load-bearing string in the package: a tool the
|
|
53
|
+
* agent never reaches for is a tool that does not exist.
|
|
54
|
+
*/
|
|
55
|
+
description: string;
|
|
56
|
+
/** JSON Schema for `arguments`. Use `{type: "object", additionalProperties: false}` for none. */
|
|
57
|
+
inputSchema: JsonSchema;
|
|
58
|
+
/** JSON Schema for `data`. Present on every tool here — the shape is the point. */
|
|
59
|
+
outputSchema: JsonSchema;
|
|
60
|
+
run(args: Record<string, unknown>, signal: AbortSignal): Promise<ToolOutcome>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── JSON-RPC 2.0 ──────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** A JSON-RPC id. `null` is reserved for replies to unparseable input. */
|
|
66
|
+
export type JsonRpcId = string | number;
|
|
67
|
+
|
|
68
|
+
/** An inbound message. A request has an `id`; a notification does not. */
|
|
69
|
+
export interface JsonRpcRequest {
|
|
70
|
+
jsonrpc: "2.0";
|
|
71
|
+
id?: JsonRpcId;
|
|
72
|
+
method: string;
|
|
73
|
+
params?: Record<string, unknown>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface JsonRpcSuccess {
|
|
77
|
+
jsonrpc: "2.0";
|
|
78
|
+
id: JsonRpcId;
|
|
79
|
+
result: Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface JsonRpcFailure {
|
|
83
|
+
jsonrpc: "2.0";
|
|
84
|
+
id: JsonRpcId | null;
|
|
85
|
+
error: { code: number; message: string; data?: unknown };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type JsonRpcResponse = JsonRpcSuccess | JsonRpcFailure;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* JSON-RPC error codes, plus the one MCP allocates.
|
|
92
|
+
*
|
|
93
|
+
* The 2026-07-28 revision partitioned the server-error range: `-32000`…`-32019`
|
|
94
|
+
* stays implementation-defined and `-32020`…`-32099` is reserved for the spec,
|
|
95
|
+
* which is why `UNSUPPORTED_PROTOCOL_VERSION` is `-32022` and not the `-32004`
|
|
96
|
+
* an earlier draft used.
|
|
97
|
+
*/
|
|
98
|
+
export const RpcError = {
|
|
99
|
+
PARSE: -32700,
|
|
100
|
+
INVALID_REQUEST: -32600,
|
|
101
|
+
METHOD_NOT_FOUND: -32601,
|
|
102
|
+
INVALID_PARAMS: -32602,
|
|
103
|
+
INTERNAL: -32603,
|
|
104
|
+
UNSUPPORTED_PROTOCOL_VERSION: -32022,
|
|
105
|
+
} as const;
|
|
106
|
+
|
|
107
|
+
// ── Protocol versions ─────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The revision that made MCP stateless: no `initialize`, per-request `_meta`,
|
|
111
|
+
* a mandatory `server/discover`.
|
|
112
|
+
*/
|
|
113
|
+
export const MODERN_VERSION = "2026-07-28";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Handshake-based revisions, newest first.
|
|
117
|
+
*
|
|
118
|
+
* These are still what shipping clients open with — the modern revision was
|
|
119
|
+
* published on 28 July 2026 — so serving them is not a courtesy, it is the
|
|
120
|
+
* only way the server is usable today. The list is the negotiation preference
|
|
121
|
+
* order for `initialize`.
|
|
122
|
+
*/
|
|
123
|
+
export const LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] as const;
|
|
124
|
+
|
|
125
|
+
/** Everything this server speaks, newest first. Advertised by `server/discover`. */
|
|
126
|
+
export const SUPPORTED_VERSIONS = [MODERN_VERSION, ...LEGACY_VERSIONS] as const;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `_meta` keys the modern revision defines. Prefixed, per the naming rules.
|
|
130
|
+
*/
|
|
131
|
+
export const Meta = {
|
|
132
|
+
PROTOCOL_VERSION: "io.modelcontextprotocol/protocolVersion",
|
|
133
|
+
CLIENT_INFO: "io.modelcontextprotocol/clientInfo",
|
|
134
|
+
CLIENT_CAPABILITIES: "io.modelcontextprotocol/clientCapabilities",
|
|
135
|
+
SERVER_INFO: "io.modelcontextprotocol/serverInfo",
|
|
136
|
+
} as const;
|
|
137
|
+
|
|
138
|
+
/** Which era a request is speaking. Selected per request, not per connection. */
|
|
139
|
+
export type Era = "modern" | "legacy";
|
|
140
|
+
|
|
141
|
+
/** Identity this server reports in `serverInfo`. */
|
|
142
|
+
export interface ServerIdentity {
|
|
143
|
+
name: string;
|
|
144
|
+
title: string;
|
|
145
|
+
version: string;
|
|
146
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bun zt arch:probe <topic>` — the one place the agent surface crosses into a
|
|
3
|
+
* booted application.
|
|
4
|
+
*
|
|
5
|
+
* The MCP server never boots an app itself: it must keep stdout clean for the
|
|
6
|
+
* protocol, and more importantly its answers have to reflect the code as it is
|
|
7
|
+
* *now*, because the caller is an agent that edits that code between calls. A
|
|
8
|
+
* long-lived booted app would answer `routes` from the state it booted with,
|
|
9
|
+
* which is the same class of mistake as running code that is not the code on
|
|
10
|
+
* disk.
|
|
11
|
+
*
|
|
12
|
+
* So each tool spawns this command, it boots like any other `zt` command, prints
|
|
13
|
+
* one JSON document, and exits. One command rather than a `--json` flag on
|
|
14
|
+
* `doctor`, `route:list` and the rest: it leaves those untouched, keeps every
|
|
15
|
+
* app-shaped read in {@link probe}, and gives the subprocess boundary exactly
|
|
16
|
+
* one shape to test.
|
|
17
|
+
*/
|
|
18
|
+
import { Command } from "@zerotal/core";
|
|
19
|
+
import type { Application } from "@zerotal/core";
|
|
20
|
+
import { PROBE_SENTINEL } from "./sentinel.ts";
|
|
21
|
+
import { PROBE_TOPICS, isProbeTopic, probe } from "./topics.ts";
|
|
22
|
+
|
|
23
|
+
export class ArchProbeCommand extends Command {
|
|
24
|
+
static override commandName = "arch:probe";
|
|
25
|
+
static override description = "Print a JSON report about this app for the agent surface";
|
|
26
|
+
static override needsApp = true;
|
|
27
|
+
static override args = [{ name: "topic", required: true }];
|
|
28
|
+
static override flags = [];
|
|
29
|
+
|
|
30
|
+
async run(): Promise<void> {
|
|
31
|
+
const topic = this.args["topic"] ?? "";
|
|
32
|
+
if (!isProbeTopic(topic)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Unknown probe topic "${topic}". Expected one of: ${PROBE_TOPICS.join(", ")}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const app = this.app as Application | undefined;
|
|
39
|
+
if (!app) throw new Error("arch:probe needs a booted application.");
|
|
40
|
+
|
|
41
|
+
const payload = await probe(topic, app);
|
|
42
|
+
|
|
43
|
+
// Written raw, through `write` rather than the colouring helpers: the reader
|
|
44
|
+
// parses this and an escape sequence in the middle of it is not JSON.
|
|
45
|
+
this.write(`\n${PROBE_SENTINEL}\n`);
|
|
46
|
+
this.write(JSON.stringify(payload) + "\n");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marker that separates a probe's answer from everything else on stdout.
|
|
3
|
+
*
|
|
4
|
+
* `bun zt arch:probe` boots the whole application, and a booted application
|
|
5
|
+
* prints — dev banners, provider notices, a warning from a package that noticed
|
|
6
|
+
* something. None of that is knowable in advance, so the reader does not try to
|
|
7
|
+
* suppress it: the command emits this line, then one line of JSON, and the
|
|
8
|
+
* reader takes the last occurrence and parses what follows.
|
|
9
|
+
*
|
|
10
|
+
* Shared by the writer and the reader so the two can never disagree, and long
|
|
11
|
+
* and specific enough that no plausible log line collides with it.
|
|
12
|
+
*/
|
|
13
|
+
export const PROBE_SENTINEL = "<<<zerotal:arch:probe:json>>>";
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four reads that need a booted application, and the only code in this
|
|
3
|
+
* package that runs inside one.
|
|
4
|
+
*
|
|
5
|
+
* Everything here answers from the framework's own structured sources rather
|
|
6
|
+
* than by parsing a printed table: `runDoctor()` already returns
|
|
7
|
+
* `{status, message, fix}` per check, `Router.routes` is a map of route
|
|
8
|
+
* definitions, and the ORM's `ModelInspector` reads decorator metadata. There is
|
|
9
|
+
* no scraping anywhere in this file, and there should never be — the moment a
|
|
10
|
+
* topic starts parsing human output it will disagree with the framework the
|
|
11
|
+
* first time a column is widened.
|
|
12
|
+
*
|
|
13
|
+
* Each topic's return value is the tool's `structuredContent`, so its shape is a
|
|
14
|
+
* published contract: change one and the matching `outputSchema` in `tools/`
|
|
15
|
+
* changes with it.
|
|
16
|
+
*/
|
|
17
|
+
import { Router, deployEnv, runDoctor } from "@zerotal/core";
|
|
18
|
+
import type { Application } from "@zerotal/core";
|
|
19
|
+
|
|
20
|
+
/** The topics `zt arch:probe` can be asked for. */
|
|
21
|
+
export const PROBE_TOPICS = ["doctor", "routes", "schema", "app-info"] as const;
|
|
22
|
+
|
|
23
|
+
export type ProbeTopic = (typeof PROBE_TOPICS)[number];
|
|
24
|
+
|
|
25
|
+
export function isProbeTopic(value: string): value is ProbeTopic {
|
|
26
|
+
return (PROBE_TOPICS as readonly string[]).includes(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Shapes ────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export interface DoctorFinding {
|
|
32
|
+
id: string;
|
|
33
|
+
label: string;
|
|
34
|
+
status: "ok" | "warn" | "fail";
|
|
35
|
+
message: string;
|
|
36
|
+
/** The command or edit that resolves it. Absent when the finding is `ok`. */
|
|
37
|
+
fix?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DoctorReport {
|
|
41
|
+
findings: DoctorFinding[];
|
|
42
|
+
counts: { ok: number; warn: number; fail: number; total: number };
|
|
43
|
+
/** True when nothing is broken outright. Warnings do not clear this. */
|
|
44
|
+
healthy: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RouteEntry {
|
|
48
|
+
method: string;
|
|
49
|
+
path: string;
|
|
50
|
+
controller: string;
|
|
51
|
+
action: string;
|
|
52
|
+
/** The name `route()` takes, when the route has one. */
|
|
53
|
+
name?: string;
|
|
54
|
+
middleware: string[];
|
|
55
|
+
domain?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RouteReport {
|
|
59
|
+
routes: RouteEntry[];
|
|
60
|
+
total: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SchemaColumn {
|
|
64
|
+
name: string;
|
|
65
|
+
type: string;
|
|
66
|
+
nullable: boolean;
|
|
67
|
+
primary: boolean;
|
|
68
|
+
unique: boolean;
|
|
69
|
+
indexed: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SchemaModel {
|
|
73
|
+
table: string;
|
|
74
|
+
primaryKey: string;
|
|
75
|
+
timestamps: boolean;
|
|
76
|
+
softDeletes: boolean;
|
|
77
|
+
columns: SchemaColumn[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SchemaReport {
|
|
81
|
+
models: SchemaModel[];
|
|
82
|
+
total: number;
|
|
83
|
+
/** Why the list is empty, when it is — an app with no ORM is not an error. */
|
|
84
|
+
note?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface InstalledPackage {
|
|
88
|
+
name: string;
|
|
89
|
+
version: string;
|
|
90
|
+
/** The package's compatibility promise: `stable`, `beta` or `experimental`. */
|
|
91
|
+
maturity?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface AppInfo {
|
|
95
|
+
bun: string;
|
|
96
|
+
environment: string;
|
|
97
|
+
appEnv: string;
|
|
98
|
+
url?: string;
|
|
99
|
+
providers: string[];
|
|
100
|
+
packages: InstalledPackage[];
|
|
101
|
+
webSocketPaths: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Dispatch ──────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** Run one topic against a booted app and return its report. */
|
|
107
|
+
export async function probe(topic: ProbeTopic, app: Application): Promise<unknown> {
|
|
108
|
+
switch (topic) {
|
|
109
|
+
case "doctor":
|
|
110
|
+
return doctorReport(app);
|
|
111
|
+
case "routes":
|
|
112
|
+
return routeReport();
|
|
113
|
+
case "schema":
|
|
114
|
+
return schemaReport();
|
|
115
|
+
case "app-info":
|
|
116
|
+
return appInfo(app);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Topics ────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Every check the app and its providers contribute, with the fix beside each.
|
|
124
|
+
*
|
|
125
|
+
* This is the tool an agent is meant to end a task with, which is why `healthy`
|
|
126
|
+
* is computed here rather than left to the caller: "3 warnings, 0 failures" is a
|
|
127
|
+
* judgement, and it should be the same judgement every time.
|
|
128
|
+
*/
|
|
129
|
+
export async function doctorReport(app: Application): Promise<DoctorReport> {
|
|
130
|
+
const entries = await runDoctor(app);
|
|
131
|
+
const findings: DoctorFinding[] = entries.map(({ check, result }) => ({
|
|
132
|
+
id: check.id,
|
|
133
|
+
label: check.label,
|
|
134
|
+
status: result.status,
|
|
135
|
+
message: result.message,
|
|
136
|
+
...(result.fix !== undefined ? { fix: result.fix } : {}),
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
const counts = {
|
|
140
|
+
ok: findings.filter((f) => f.status === "ok").length,
|
|
141
|
+
warn: findings.filter((f) => f.status === "warn").length,
|
|
142
|
+
fail: findings.filter((f) => f.status === "fail").length,
|
|
143
|
+
total: findings.length,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return { findings, counts, healthy: counts.fail === 0 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The routes the app actually registered.
|
|
151
|
+
*
|
|
152
|
+
* Read after boot, so routes a provider added programmatically are here
|
|
153
|
+
* alongside the ones in `routes/` — which is the difference between this and
|
|
154
|
+
* reading the route files.
|
|
155
|
+
*/
|
|
156
|
+
export function routeReport(): RouteReport {
|
|
157
|
+
const nameByPath = new Map<string, string>();
|
|
158
|
+
for (const [name, path] of Router.namedRoutes) nameByPath.set(path, name);
|
|
159
|
+
|
|
160
|
+
const routes: RouteEntry[] = [...Router.routes.values()]
|
|
161
|
+
.map((route) => {
|
|
162
|
+
const name = route.name ?? nameByPath.get(route.path);
|
|
163
|
+
return {
|
|
164
|
+
method: route.method,
|
|
165
|
+
path: route.path,
|
|
166
|
+
controller: controllerLabel(route.controller.name),
|
|
167
|
+
action: route.action,
|
|
168
|
+
...(name !== undefined ? { name } : {}),
|
|
169
|
+
middleware: route.middleware.map((m) => m.name).filter((n) => n.length > 0),
|
|
170
|
+
...(route.domain !== undefined ? { domain: route.domain } : {}),
|
|
171
|
+
};
|
|
172
|
+
})
|
|
173
|
+
.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
|
174
|
+
|
|
175
|
+
return { routes, total: routes.length };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Model schemas as the ORM understands them.
|
|
180
|
+
*
|
|
181
|
+
* `@zerotal/orm` is imported dynamically because an app is not required to have
|
|
182
|
+
* one. A missing ORM is a report with a note, not a failure — the alternative is
|
|
183
|
+
* a hard dependency that every API-only app pays for.
|
|
184
|
+
*/
|
|
185
|
+
export async function schemaReport(): Promise<SchemaReport> {
|
|
186
|
+
let inspector: typeof import("@zerotal/orm").ModelInspector;
|
|
187
|
+
try {
|
|
188
|
+
({ ModelInspector: inspector } = await import("@zerotal/orm"));
|
|
189
|
+
} catch {
|
|
190
|
+
return { models: [], total: 0, note: "@zerotal/orm is not installed in this app." };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const models: SchemaModel[] = inspector
|
|
194
|
+
.all()
|
|
195
|
+
.map((schema) => ({
|
|
196
|
+
table: schema.table,
|
|
197
|
+
primaryKey: schema.primaryKey,
|
|
198
|
+
timestamps: schema.timestamps,
|
|
199
|
+
softDeletes: schema.softDeletes,
|
|
200
|
+
columns: schema.columns.map((column) => ({
|
|
201
|
+
name: column.name,
|
|
202
|
+
// `@column()` with no `type` means `string` — that is the documented
|
|
203
|
+
// default and what `autoMigrate` generates. Reporting the raw absence
|
|
204
|
+
// would tell a reader the column has no type when it has the commonest
|
|
205
|
+
// one, and this is read by something about to write a migration.
|
|
206
|
+
type: column.type ?? "string",
|
|
207
|
+
nullable: column.nullable,
|
|
208
|
+
primary: column.primary,
|
|
209
|
+
unique: column.unique === true,
|
|
210
|
+
indexed: column.index === true,
|
|
211
|
+
})),
|
|
212
|
+
}))
|
|
213
|
+
.sort((a, b) => a.table.localeCompare(b.table));
|
|
214
|
+
|
|
215
|
+
if (models.length === 0) {
|
|
216
|
+
return {
|
|
217
|
+
models: [],
|
|
218
|
+
total: 0,
|
|
219
|
+
note:
|
|
220
|
+
"No models are registered. Models self-register when their file is imported, which the " +
|
|
221
|
+
"`models` convention does at boot — check app/models/ exists and conventions are enabled.",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return { models, total: models.length };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** What this app is: runtime, environment, providers, and the packages behind them. */
|
|
228
|
+
export async function appInfo(app: Application): Promise<AppInfo> {
|
|
229
|
+
const url = readConfig(app, "app.url");
|
|
230
|
+
return {
|
|
231
|
+
bun: Bun.version,
|
|
232
|
+
// Two different things that both get called "environment", reported apart:
|
|
233
|
+
// `_env` is the runtime mode this process booted as (console, here, since a
|
|
234
|
+
// probe runs under the CLI), while `deployEnv()` is the deployment name —
|
|
235
|
+
// local, staging, production. `Bun.env.APP_ENV` is not the second one: the
|
|
236
|
+
// CLI overwrites it with the runtime mode before the app is imported.
|
|
237
|
+
environment: String(app._env),
|
|
238
|
+
appEnv: String(readConfig(app, "app.env") ?? deployEnv()),
|
|
239
|
+
...(typeof url === "string" ? { url } : {}),
|
|
240
|
+
providers: (app._activeProviders ?? []).map((provider) => provider.constructor.name).sort(),
|
|
241
|
+
packages: await installedPackages(),
|
|
242
|
+
webSocketPaths: app.webSocketPaths(),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
function readConfig(app: Application, key: string): unknown {
|
|
249
|
+
try {
|
|
250
|
+
const config = app.container.makeSync("config") as { get(k: string): unknown };
|
|
251
|
+
return config.get(key);
|
|
252
|
+
} catch {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The installed `@zerotal/*` packages and their maturity.
|
|
259
|
+
*
|
|
260
|
+
* Read from disk rather than from a hard-coded list: the point is to report the
|
|
261
|
+
* versions this app is actually running, including ones this package has never
|
|
262
|
+
* heard of.
|
|
263
|
+
*/
|
|
264
|
+
export async function installedPackages(root = process.cwd()): Promise<InstalledPackage[]> {
|
|
265
|
+
const found: InstalledPackage[] = [];
|
|
266
|
+
const glob = new Bun.Glob("node_modules/{zerotal,@zerotal/*}/package.json");
|
|
267
|
+
|
|
268
|
+
for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {
|
|
269
|
+
try {
|
|
270
|
+
const manifest = (await Bun.file(`${root}/${file}`).json()) as Record<string, unknown>;
|
|
271
|
+
const name = manifest["name"];
|
|
272
|
+
const version = manifest["version"];
|
|
273
|
+
if (typeof name !== "string" || typeof version !== "string") continue;
|
|
274
|
+
const maturity = manifest["maturity"];
|
|
275
|
+
found.push({
|
|
276
|
+
name,
|
|
277
|
+
version,
|
|
278
|
+
...(typeof maturity === "string" ? { maturity } : {}),
|
|
279
|
+
});
|
|
280
|
+
} catch {
|
|
281
|
+
/* an unreadable manifest is one package missing from a report, not a failure */
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return found.sort((a, b) => a.name.localeCompare(b.name));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** File-based route controllers are named `FileRoute<METHOD /path>` — say "file" instead. */
|
|
289
|
+
function controllerLabel(rawName: string): string {
|
|
290
|
+
return rawName.startsWith("FileRoute<") ? "file" : rawName;
|
|
291
|
+
}
|