@intentius/chant 0.89.0 → 0.90.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/dist/cli/handlers/serve.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +10 -5
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +13 -5
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +53 -0
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
- package/dist/op/op-verb-class.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +42 -2
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/conformance/vitest.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +3 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +35 -3
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +4 -1
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/source-block.d.ts +85 -0
- package/dist/workspace/source-block.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/serve.ts +2 -1
- package/src/cli/mcp/docs-parity.test.ts +20 -2
- package/src/cli/mcp/server.ts +23 -6
- package/src/cli/mcp/types.ts +15 -2
- package/src/cli/mcp/workspace-tools.test.ts +198 -0
- package/src/cli/mcp/workspace-tools.ts +405 -0
- package/src/op/op-verb-class.ts +6 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
- package/src/workspace/conformance/index.mjs +3 -0
- package/src/workspace/conformance/index.ts +185 -7
- package/src/workspace/conformance/vitest.ts +17 -8
- package/src/workspace/reason-codes.ts +3 -0
- package/src/workspace/records-amend.schema.json +2 -1
- package/src/workspace/records-close.schema.json +2 -1
- package/src/workspace/records-new.schema.json +4 -1
- package/src/workspace/records-review.schema.json +2 -1
- package/src/workspace/records-write.ts +85 -7
- package/src/workspace/records.schema.json +1 -0
- package/src/workspace/records.ts +28 -0
- package/src/workspace/source-block.test.ts +167 -0
- package/src/workspace/source-block.ts +129 -0
package/src/cli/mcp/server.ts
CHANGED
|
@@ -8,7 +8,9 @@ import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
|
|
|
8
8
|
import { searchTool, createSearchHandler } from "./tools/search";
|
|
9
9
|
import { compositesTool, createCompositesHandler } from "./tools/composites";
|
|
10
10
|
import type { LexiconPlugin } from "../../lexicon";
|
|
11
|
-
import type { McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
|
|
11
|
+
import type { McpClientInfo, McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
|
|
12
|
+
import { findWorkspaceRoot } from "../../project-root";
|
|
13
|
+
import { createWorkspaceTools, type WorkspaceToolsOptions } from "./workspace-tools";
|
|
12
14
|
import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
|
|
13
15
|
import { setGateOrigin } from "../../lifecycle/gate-origin";
|
|
14
16
|
import { createOpListTool, createOpRunTool, createOpStatusTool, createOpApproveTool, createOpReportTool } from "./op-tools";
|
|
@@ -47,14 +49,14 @@ export function negotiateProtocolVersion(requested: string | undefined): string
|
|
|
47
49
|
* sends on `initialize`. Read-side only — the server holds no handshake
|
|
48
50
|
* state to update (#1194).
|
|
49
51
|
*/
|
|
50
|
-
export function parseMeta(params: Record<string, unknown>): { protocolVersion?: string; clientInfo?:
|
|
52
|
+
export function parseMeta(params: Record<string, unknown>): { protocolVersion?: string; clientInfo?: McpClientInfo } {
|
|
51
53
|
const meta = (params._meta ?? {}) as McpRequestMeta;
|
|
52
54
|
const protocolVersion =
|
|
53
55
|
(typeof meta.protocolVersion === "string" ? meta.protocolVersion : undefined) ??
|
|
54
56
|
(typeof params.protocolVersion === "string" ? params.protocolVersion : undefined);
|
|
55
57
|
const clientInfo =
|
|
56
58
|
meta["io.modelcontextprotocol/clientInfo"] ??
|
|
57
|
-
(params.clientInfo as
|
|
59
|
+
(params.clientInfo as McpClientInfo | undefined);
|
|
58
60
|
return { protocolVersion, clientInfo };
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -94,14 +96,20 @@ export class McpServer {
|
|
|
94
96
|
private pluginResources: Map<string, { definition: ResourceDefinition; handler: () => Promise<string> }> = new Map();
|
|
95
97
|
private plugins: LexiconPlugin[];
|
|
96
98
|
private instructions: string | undefined;
|
|
99
|
+
/** The `clientInfo` the client gave on `initialize`, for a request that carries none in `_meta` (#2707). */
|
|
100
|
+
private clientInfo: McpClientInfo | undefined;
|
|
97
101
|
|
|
98
102
|
/**
|
|
99
103
|
* `options.instructions` is sent as the `initialize` result's
|
|
100
104
|
* `instructions`, the text a client may give its model about this server.
|
|
101
105
|
* Only a workspace root with no lexicon of its own sets it (#2700), to say
|
|
102
106
|
* which members' lexicons were loaded.
|
|
107
|
+
*
|
|
108
|
+
* `options.workspace` names the directory the server serves (#2707). When
|
|
109
|
+
* it is at or inside a declared workspace, the workspace read-contract and
|
|
110
|
+
* record-write tools are served too.
|
|
103
111
|
*/
|
|
104
|
-
constructor(plugins?: LexiconPlugin[], options: { instructions?: string } = {}) {
|
|
112
|
+
constructor(plugins?: LexiconPlugin[], options: { instructions?: string; workspace?: WorkspaceToolsOptions } = {}) {
|
|
105
113
|
this.plugins = plugins ?? [];
|
|
106
114
|
this.instructions = options.instructions;
|
|
107
115
|
// Register core tools
|
|
@@ -126,6 +134,11 @@ export class McpServer {
|
|
|
126
134
|
this.registerTool(t.definition, t.handler);
|
|
127
135
|
}
|
|
128
136
|
|
|
137
|
+
// Workspace reads and record writes (#2707), inside a declared workspace only.
|
|
138
|
+
if (options.workspace && findWorkspaceRoot(options.workspace.cwd)) {
|
|
139
|
+
for (const t of createWorkspaceTools(options.workspace)) this.registerTool(t.definition, t.handler);
|
|
140
|
+
}
|
|
141
|
+
|
|
129
142
|
// Register plugin contributions
|
|
130
143
|
if (plugins) {
|
|
131
144
|
for (const plugin of plugins) {
|
|
@@ -244,6 +257,8 @@ export class McpServer {
|
|
|
244
257
|
private async dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
245
258
|
switch (method) {
|
|
246
259
|
case "initialize":
|
|
260
|
+
// Kept for a write's source block (#2707): a prior-revision client names itself only here.
|
|
261
|
+
this.clientInfo = parseMeta(params).clientInfo ?? this.clientInfo;
|
|
247
262
|
// Answered for prior-revision clients too — negotiated, not hard-coded (#1194).
|
|
248
263
|
return this.buildInitializeResult(params);
|
|
249
264
|
|
|
@@ -290,7 +305,9 @@ export class McpServer {
|
|
|
290
305
|
}
|
|
291
306
|
|
|
292
307
|
try {
|
|
293
|
-
|
|
308
|
+
// The client, from this request's _meta (2026-07-28) or else from initialize.
|
|
309
|
+
const clientInfo = parseMeta(params).clientInfo ?? this.clientInfo;
|
|
310
|
+
const result = await handler(toolParams, clientInfo ? { clientInfo } : {});
|
|
294
311
|
const isStructured = typeof result === "object" && result !== null;
|
|
295
312
|
return {
|
|
296
313
|
content: [
|
|
@@ -374,6 +391,6 @@ export async function startMcpServer(): Promise<void> {
|
|
|
374
391
|
// Start without plugins if resolution fails
|
|
375
392
|
}
|
|
376
393
|
|
|
377
|
-
const server = new McpServer(plugins);
|
|
394
|
+
const server = new McpServer(plugins, { workspace: { cwd: process.cwd() } });
|
|
378
395
|
server.start();
|
|
379
396
|
}
|
package/src/cli/mcp/types.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface McpResponse {
|
|
|
32
32
|
*/
|
|
33
33
|
export interface McpRequestMeta {
|
|
34
34
|
protocolVersion?: string;
|
|
35
|
-
"io.modelcontextprotocol/clientInfo"?:
|
|
35
|
+
"io.modelcontextprotocol/clientInfo"?: McpClientInfo;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
@@ -68,4 +68,17 @@ export interface ResourceDefinition {
|
|
|
68
68
|
mimeType?: string;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/** The MCP client's `clientInfo`, as it gave it on `initialize` or in a request's `_meta`. */
|
|
72
|
+
export interface McpClientInfo {
|
|
73
|
+
name: string;
|
|
74
|
+
version?: string;
|
|
75
|
+
title?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What a handler knows about the call beyond its arguments (#2707). */
|
|
79
|
+
export interface ToolContext {
|
|
80
|
+
/** The client that made the call, when it said. */
|
|
81
|
+
clientInfo?: McpClientInfo;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type ToolHandler = (params: Record<string, unknown>, context?: ToolContext) => Promise<unknown>;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2707 — chant serve mcp's workspace tools: served at or inside a declared
|
|
3
|
+
* workspace, reads that return the CLI's documents, and writes that keep the
|
|
4
|
+
* CLI's rules and say they came through MCP in the record's source block
|
|
5
|
+
* (#2708). Run against the workspace the reader conformance suite generates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
13
|
+
import { createConformanceWorkspace, defaultChantCommand, mcpToolCall, type ConformanceWorkspace } from "../../workspace/conformance";
|
|
14
|
+
import { McpServer } from "./server";
|
|
15
|
+
import { workspaceReadTools, workspaceWriteTools } from "./workspace-tools";
|
|
16
|
+
|
|
17
|
+
const KIND = "decisions/decision.kind.mjs";
|
|
18
|
+
const chant = defaultChantCommand();
|
|
19
|
+
|
|
20
|
+
let ws: ConformanceWorkspace;
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
ws = createConformanceWorkspace({ chantCommand: chant });
|
|
23
|
+
}, 300_000);
|
|
24
|
+
afterAll(() => ws?.dispose());
|
|
25
|
+
|
|
26
|
+
type Result = { isError?: boolean; structuredContent?: Record<string, unknown>; content: { text: string }[] };
|
|
27
|
+
|
|
28
|
+
function server(cwd = ws.dir): McpServer {
|
|
29
|
+
return new McpServer([], { workspace: { cwd, chantCommand: chant } });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let nextId = 1;
|
|
33
|
+
async function rpc(s: McpServer, method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
|
34
|
+
const res = await s.handleRequest({ jsonrpc: "2.0", id: nextId++, method, params });
|
|
35
|
+
if (res.error) throw new Error(res.error.message);
|
|
36
|
+
return res.result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function call(s: McpServer, name: string, args: Record<string, unknown>, meta?: Record<string, unknown>): Promise<Result> {
|
|
40
|
+
return (await rpc(s, "tools/call", { name, arguments: args, ...(meta ? { _meta: meta } : {}) })) as Result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cli(argv: string[], env: NodeJS.ProcessEnv = {}): unknown {
|
|
44
|
+
let out: string;
|
|
45
|
+
try {
|
|
46
|
+
out = execFileSync(chant[0], [...chant.slice(1), ...argv], { cwd: ws.dir, encoding: "utf-8", env: { ...process.env, NO_COLOR: "1", ...env }, stdio: ["ignore", "pipe", "pipe"] });
|
|
47
|
+
} catch (e) {
|
|
48
|
+
out = String((e as { stdout?: string }).stdout ?? "");
|
|
49
|
+
}
|
|
50
|
+
return JSON.parse(out);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A proposed decision's fields, with no id, state or source. */
|
|
54
|
+
function proposal(title: string): Record<string, unknown> {
|
|
55
|
+
return {
|
|
56
|
+
schema: 1,
|
|
57
|
+
title,
|
|
58
|
+
area: "delivery",
|
|
59
|
+
question: "Where do the app's logs go?",
|
|
60
|
+
options: [
|
|
61
|
+
{ id: "a", label: "stdout", how: "The app writes to stdout and the runtime collects it.", tradeoff: "Nothing to configure." },
|
|
62
|
+
{ id: "b", label: "a file", how: "The app writes a file.", tradeoff: "A volume to manage." },
|
|
63
|
+
],
|
|
64
|
+
choice: null,
|
|
65
|
+
rejected: [],
|
|
66
|
+
supersedes: [],
|
|
67
|
+
evidence: [],
|
|
68
|
+
decided_by: null,
|
|
69
|
+
decided_on: null,
|
|
70
|
+
reviews: [],
|
|
71
|
+
constrains: ["member:app"],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type RecordView = { id: string; state: string; valid: boolean; data: Record<string, unknown>; quorum?: { agreed: number; met: boolean } };
|
|
76
|
+
function recordsOf(doc: unknown): RecordView[] {
|
|
77
|
+
return (doc as { records: RecordView[] }).records;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("which servers have the workspace tools", () => {
|
|
81
|
+
const names = [...workspaceReadTools, ...workspaceWriteTools].map((t) => t.name);
|
|
82
|
+
|
|
83
|
+
test("a server at or inside a declared workspace lists them", async () => {
|
|
84
|
+
for (const cwd of [ws.dir, join(ws.dir, "decisions")]) {
|
|
85
|
+
const { tools } = (await rpc(server(cwd), "tools/list")) as { tools: { name: string }[] };
|
|
86
|
+
expect(tools.map((t) => t.name)).toEqual(expect.arrayContaining(names));
|
|
87
|
+
}
|
|
88
|
+
expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-records", "records-new", "records-amend", "records-review", "records-close"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a server outside any workspace, or given none, does not", async () => {
|
|
92
|
+
const outside = realpathSync(mkdtempSync(join(tmpdir(), "chant-mcp-no-ws-")));
|
|
93
|
+
try {
|
|
94
|
+
for (const s of [server(outside), new McpServer([])]) {
|
|
95
|
+
const { tools } = (await rpc(s, "tools/list")) as { tools: { name: string }[] };
|
|
96
|
+
expect(tools.map((t) => t.name).filter((n) => names.includes(n))).toEqual([]);
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(outside, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the descriptions say records are proposals and by names who decided", () => {
|
|
104
|
+
for (const t of workspaceWriteTools) expect(t.description).toMatch(/proposals until they are reviewed.*by must name the person or agent that actually decided/s);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("reads", () => {
|
|
109
|
+
test("workspace-records returns the document chant workspace records --json prints", async () => {
|
|
110
|
+
const s = server();
|
|
111
|
+
const res = await call(s, "workspace-records", { kind: KIND });
|
|
112
|
+
expect(res.isError).toBeUndefined();
|
|
113
|
+
expect(res.structuredContent).toEqual(cli(["workspace", "records", "--kind", KIND, "--json"]));
|
|
114
|
+
const only = await call(s, "workspace-records", { kind: KIND, id: "fix-001" });
|
|
115
|
+
expect(recordsOf(only.structuredContent).map((r) => r.id)).toEqual(["fix-001"]);
|
|
116
|
+
}, 120_000);
|
|
117
|
+
|
|
118
|
+
test("an error document is returned as a document, and a flag-shaped value is refused before chant runs", async () => {
|
|
119
|
+
const s = server();
|
|
120
|
+
const missing = await call(s, "workspace-records", { kind: "nowhere/none.kind.mjs" });
|
|
121
|
+
expect(missing.structuredContent).toMatchObject({ error: { code: "kind-unreadable" } });
|
|
122
|
+
const flag = await call(s, "workspace-ls", { at: "--output=/tmp/x" });
|
|
123
|
+
expect(flag.isError).toBe(true);
|
|
124
|
+
expect(flag.content[0].text).toMatch(/at may not start with -/);
|
|
125
|
+
}, 120_000);
|
|
126
|
+
|
|
127
|
+
test("the conformance suite's argv maps to the tool that answers it", () => {
|
|
128
|
+
expect(mcpToolCall(["workspace", "records", "--kind", KIND, "--json"])).toEqual({ name: "workspace-records", arguments: { kind: KIND } });
|
|
129
|
+
expect(mcpToolCall(["workspace", "status", "dev", "--json"])).toEqual({ name: "workspace-status", arguments: { env: "dev" } });
|
|
130
|
+
expect(mcpToolCall(["workspace", "graph", "--intent", "a.mjs:1", "--kind", KIND, "--json"])).toEqual({ name: "workspace-graph", arguments: { intent: "a.mjs:1", kind: [KIND] } });
|
|
131
|
+
expect(mcpToolCall(["workspace", "graph", "--composites", "--json"])).toEqual({ name: "workspace-graph", arguments: { composites: true } });
|
|
132
|
+
expect(mcpToolCall(["workspace", "check", "--format", "json"])).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("writes", () => {
|
|
137
|
+
test("records-new proposes a decision with MCP in its source, records reads it, and reviews move its quorum", async () => {
|
|
138
|
+
const s = server();
|
|
139
|
+
await rpc(s, "initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "claude-code", version: "2.1.0" } });
|
|
140
|
+
const made = await call(s, "records-new", { kind: KIND, record: proposal("Where the logs go") });
|
|
141
|
+
expect(made.structuredContent).toMatchObject({ id: "fix-002", dryRun: false });
|
|
142
|
+
const read = recordsOf(cli(["workspace", "records", "--kind", KIND, "--json"])).find((r) => r.id === "fix-002")!;
|
|
143
|
+
expect(read).toMatchObject({ state: "proposed", valid: true });
|
|
144
|
+
expect(read.data.source).toEqual({ via: "mcp", client: { name: "claude-code", version: "2.1.0" } });
|
|
145
|
+
|
|
146
|
+
for (const by of ["bob", "carol"]) {
|
|
147
|
+
const res = await call(s, "records-review", { kind: KIND, id: "fix-002", verdict: "agree", by });
|
|
148
|
+
expect(res.structuredContent).toMatchObject({ review: { reviewer: by, verdict: "agree" } });
|
|
149
|
+
}
|
|
150
|
+
const after = await call(s, "workspace-records", { kind: KIND, id: "fix-002" });
|
|
151
|
+
expect(recordsOf(after.structuredContent)[0].quorum).toMatchObject({ agreed: 2, met: true });
|
|
152
|
+
}, 180_000);
|
|
153
|
+
|
|
154
|
+
test("a second client is recorded as itself, from the request's _meta", async () => {
|
|
155
|
+
const s = server();
|
|
156
|
+
await rpc(s, "initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "claude-code", version: "2.1.0" } });
|
|
157
|
+
const made = await call(s, "records-new", { kind: KIND, record: { ...proposal("Where the metrics go"), source: { kind: "workspace", member: "app" } } }, {
|
|
158
|
+
"io.modelcontextprotocol/clientInfo": { name: "codex", version: "0.40.0" },
|
|
159
|
+
});
|
|
160
|
+
const id = (made.structuredContent as { id: string }).id;
|
|
161
|
+
const read = recordsOf(cli(["workspace", "records", "--kind", KIND, "--json"])).find((r) => r.id === id)!;
|
|
162
|
+
expect(read.data.source).toEqual({ kind: "workspace", member: "app", via: "mcp", client: { name: "codex", version: "0.40.0" } });
|
|
163
|
+
}, 120_000);
|
|
164
|
+
|
|
165
|
+
test("keeps the CLI's rules: a record opens proposed, a decided record's reasoning stays, by and sign", async () => {
|
|
166
|
+
const s = server();
|
|
167
|
+
const decided = await call(s, "records-new", {
|
|
168
|
+
kind: KIND,
|
|
169
|
+
record: { ...proposal("Decided at once"), state: "decided", choice: { option: "a", reason: "Simplest." }, decided_by: "lex00", decided_on: "2026-09-25" },
|
|
170
|
+
});
|
|
171
|
+
expect(decided.structuredContent).toMatchObject({ error: { code: "record-state-not-initial", message: expect.stringContaining("opens proposed") } });
|
|
172
|
+
|
|
173
|
+
const reasoning = await call(s, "records-amend", { kind: KIND, id: "fix-001", fields: { question: "Something else?" } });
|
|
174
|
+
expect(reasoning.structuredContent).toMatchObject({ error: { code: "amend-supersede-instead" } });
|
|
175
|
+
|
|
176
|
+
const both = await call(s, "records-new", { kind: KIND, record: { ...proposal("Two authors"), decided_by: "alice" }, by: "bob", dryRun: true });
|
|
177
|
+
expect(both.structuredContent).toMatchObject({ error: { code: "write-input-invalid" } });
|
|
178
|
+
const by = await call(s, "records-new", { kind: KIND, record: proposal("One author"), by: "alice", dryRun: true });
|
|
179
|
+
expect(by.structuredContent, JSON.stringify(by.structuredContent)).toHaveProperty("text");
|
|
180
|
+
expect((by.structuredContent as { text: string }).text).toContain('decided_by: "alice"');
|
|
181
|
+
|
|
182
|
+
// No key configured on this host: the CLI's refusal and remedy.
|
|
183
|
+
const home = { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
|
|
184
|
+
const saved = { ...process.env };
|
|
185
|
+
Object.assign(process.env, home);
|
|
186
|
+
try {
|
|
187
|
+
const signed = await call(s, "records-new", { kind: KIND, record: proposal("Signed"), by: "alice", sign: true, dryRun: true });
|
|
188
|
+
expect(signed.structuredContent).toMatchObject({ error: { code: "record-sign-failed", message: expect.stringContaining("pass --sign <key file>") } });
|
|
189
|
+
} finally {
|
|
190
|
+
for (const k of Object.keys(home)) {
|
|
191
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
192
|
+
else process.env[k] = saved[k];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Nothing above wrote a file.
|
|
196
|
+
expect(readFileSync(join(ws.dir, "decisions", "fix-001-how-the-app-is-deployed.md"), "utf-8")).toContain('question: "What declares the app\'s deployment?"');
|
|
197
|
+
}, 180_000);
|
|
198
|
+
});
|