appduct 0.9.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.
Files changed (190) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/bin.js +2 -0
  4. package/dist/artifact-inspect.d.ts +110 -0
  5. package/dist/artifact-inspect.d.ts.map +1 -0
  6. package/dist/artifact-inspect.js +315 -0
  7. package/dist/bin.d.ts +3 -0
  8. package/dist/bin.d.ts.map +1 -0
  9. package/dist/bin.js +4 -0
  10. package/dist/cli/command-options.d.ts +44 -0
  11. package/dist/cli/command-options.d.ts.map +1 -0
  12. package/dist/cli/command-options.js +95 -0
  13. package/dist/cli/create-cli.d.ts +2 -0
  14. package/dist/cli/create-cli.d.ts.map +1 -0
  15. package/dist/cli/create-cli.js +56 -0
  16. package/dist/cli/dispatch.d.ts +3 -0
  17. package/dist/cli/dispatch.d.ts.map +1 -0
  18. package/dist/cli/dispatch.js +275 -0
  19. package/dist/cli/open-target.d.ts +112 -0
  20. package/dist/cli/open-target.d.ts.map +1 -0
  21. package/dist/cli/open-target.js +438 -0
  22. package/dist/cli/result-types.d.ts +194 -0
  23. package/dist/cli/result-types.d.ts.map +1 -0
  24. package/dist/cli/result-types.js +6 -0
  25. package/dist/cli/runner.d.ts +35 -0
  26. package/dist/cli/runner.d.ts.map +1 -0
  27. package/dist/cli/runner.js +111 -0
  28. package/dist/cli/types.d.ts +20 -0
  29. package/dist/cli/types.d.ts.map +1 -0
  30. package/dist/cli/types.js +10 -0
  31. package/dist/cli.d.ts +4 -0
  32. package/dist/cli.d.ts.map +1 -0
  33. package/dist/cli.js +2 -0
  34. package/dist/client/app-client.d.ts +126 -0
  35. package/dist/client/app-client.d.ts.map +1 -0
  36. package/dist/client/app-client.js +223 -0
  37. package/dist/client/bootstrap.d.ts +48 -0
  38. package/dist/client/bootstrap.d.ts.map +1 -0
  39. package/dist/client/bootstrap.js +108 -0
  40. package/dist/client/connect.d.ts +17 -0
  41. package/dist/client/connect.d.ts.map +1 -0
  42. package/dist/client/connect.js +38 -0
  43. package/dist/client/errors.d.ts +26 -0
  44. package/dist/client/errors.d.ts.map +1 -0
  45. package/dist/client/errors.js +40 -0
  46. package/dist/client/index.d.ts +19 -0
  47. package/dist/client/index.d.ts.map +1 -0
  48. package/dist/client/index.js +16 -0
  49. package/dist/commands/daemon.d.ts +22 -0
  50. package/dist/commands/daemon.d.ts.map +1 -0
  51. package/dist/commands/daemon.js +128 -0
  52. package/dist/commands/doctor.d.ts +23 -0
  53. package/dist/commands/doctor.d.ts.map +1 -0
  54. package/dist/commands/doctor.js +42 -0
  55. package/dist/commands/events.d.ts +37 -0
  56. package/dist/commands/events.d.ts.map +1 -0
  57. package/dist/commands/events.js +76 -0
  58. package/dist/commands/init.d.ts +51 -0
  59. package/dist/commands/init.d.ts.map +1 -0
  60. package/dist/commands/init.js +231 -0
  61. package/dist/commands/invoke.d.ts +22 -0
  62. package/dist/commands/invoke.d.ts.map +1 -0
  63. package/dist/commands/invoke.js +54 -0
  64. package/dist/commands/keygen.d.ts +23 -0
  65. package/dist/commands/keygen.d.ts.map +1 -0
  66. package/dist/commands/keygen.js +46 -0
  67. package/dist/commands/link.d.ts +30 -0
  68. package/dist/commands/link.d.ts.map +1 -0
  69. package/dist/commands/link.js +43 -0
  70. package/dist/commands/ls.d.ts +9 -0
  71. package/dist/commands/ls.d.ts.map +1 -0
  72. package/dist/commands/ls.js +7 -0
  73. package/dist/commands/mcp.d.ts +49 -0
  74. package/dist/commands/mcp.d.ts.map +1 -0
  75. package/dist/commands/mcp.js +98 -0
  76. package/dist/commands/revoke.d.ts +12 -0
  77. package/dist/commands/revoke.d.ts.map +1 -0
  78. package/dist/commands/revoke.js +7 -0
  79. package/dist/commands/tools.d.ts +23 -0
  80. package/dist/commands/tools.d.ts.map +1 -0
  81. package/dist/commands/tools.js +52 -0
  82. package/dist/daemon/address.d.ts +30 -0
  83. package/dist/daemon/address.d.ts.map +1 -0
  84. package/dist/daemon/address.js +71 -0
  85. package/dist/daemon/audit.d.ts +107 -0
  86. package/dist/daemon/audit.d.ts.map +1 -0
  87. package/dist/daemon/audit.js +237 -0
  88. package/dist/daemon/calls.d.ts +84 -0
  89. package/dist/daemon/calls.d.ts.map +1 -0
  90. package/dist/daemon/calls.js +158 -0
  91. package/dist/daemon/config.d.ts +79 -0
  92. package/dist/daemon/config.d.ts.map +1 -0
  93. package/dist/daemon/config.js +170 -0
  94. package/dist/daemon/daemon.d.ts +43 -0
  95. package/dist/daemon/daemon.d.ts.map +1 -0
  96. package/dist/daemon/daemon.js +560 -0
  97. package/dist/daemon/event-bus.d.ts +49 -0
  98. package/dist/daemon/event-bus.d.ts.map +1 -0
  99. package/dist/daemon/event-bus.js +111 -0
  100. package/dist/daemon/links.d.ts +54 -0
  101. package/dist/daemon/links.d.ts.map +1 -0
  102. package/dist/daemon/links.js +118 -0
  103. package/dist/daemon/listener.d.ts +34 -0
  104. package/dist/daemon/listener.d.ts.map +1 -0
  105. package/dist/daemon/listener.js +132 -0
  106. package/dist/daemon/log-rotation.d.ts +82 -0
  107. package/dist/daemon/log-rotation.d.ts.map +1 -0
  108. package/dist/daemon/log-rotation.js +136 -0
  109. package/dist/daemon/pidfile.d.ts +38 -0
  110. package/dist/daemon/pidfile.d.ts.map +1 -0
  111. package/dist/daemon/pidfile.js +90 -0
  112. package/dist/daemon/policy.d.ts +22 -0
  113. package/dist/daemon/policy.d.ts.map +1 -0
  114. package/dist/daemon/policy.js +24 -0
  115. package/dist/daemon/registry.d.ts +19 -0
  116. package/dist/daemon/registry.d.ts.map +1 -0
  117. package/dist/daemon/registry.js +31 -0
  118. package/dist/daemon/rpc-server.d.ts +54 -0
  119. package/dist/daemon/rpc-server.d.ts.map +1 -0
  120. package/dist/daemon/rpc-server.js +203 -0
  121. package/dist/daemon/sessions.d.ts +86 -0
  122. package/dist/daemon/sessions.d.ts.map +1 -0
  123. package/dist/daemon/sessions.js +429 -0
  124. package/dist/daemon/socket-probe.d.ts +16 -0
  125. package/dist/daemon/socket-probe.d.ts.map +1 -0
  126. package/dist/daemon/socket-probe.js +28 -0
  127. package/dist/daemon/state-dir.d.ts +31 -0
  128. package/dist/daemon/state-dir.d.ts.map +1 -0
  129. package/dist/daemon/state-dir.js +43 -0
  130. package/dist/daemon/timers.d.ts +16 -0
  131. package/dist/daemon/timers.d.ts.map +1 -0
  132. package/dist/daemon/timers.js +12 -0
  133. package/dist/daemon/tls.d.ts +58 -0
  134. package/dist/daemon/tls.d.ts.map +1 -0
  135. package/dist/daemon/tls.js +79 -0
  136. package/dist/errors.d.ts +28 -0
  137. package/dist/errors.d.ts.map +1 -0
  138. package/dist/errors.js +162 -0
  139. package/dist/host-certificate.d.ts +8 -0
  140. package/dist/host-certificate.d.ts.map +1 -0
  141. package/dist/host-certificate.js +111 -0
  142. package/dist/index.d.ts +4 -0
  143. package/dist/index.d.ts.map +1 -0
  144. package/dist/index.js +3 -0
  145. package/dist/key-material.d.ts +14 -0
  146. package/dist/key-material.d.ts.map +1 -0
  147. package/dist/key-material.js +42 -0
  148. package/dist/link.d.ts +76 -0
  149. package/dist/link.d.ts.map +1 -0
  150. package/dist/link.js +117 -0
  151. package/dist/mcp/connect-tool.d.ts +115 -0
  152. package/dist/mcp/connect-tool.d.ts.map +1 -0
  153. package/dist/mcp/connect-tool.js +396 -0
  154. package/dist/mcp/daemon-tools.d.ts +13 -0
  155. package/dist/mcp/daemon-tools.d.ts.map +1 -0
  156. package/dist/mcp/daemon-tools.js +29 -0
  157. package/dist/mcp/events-tool.d.ts +111 -0
  158. package/dist/mcp/events-tool.d.ts.map +1 -0
  159. package/dist/mcp/events-tool.js +322 -0
  160. package/dist/mcp/server.d.ts +58 -0
  161. package/dist/mcp/server.d.ts.map +1 -0
  162. package/dist/mcp/server.js +538 -0
  163. package/dist/mcp/tool-mapping.d.ts +58 -0
  164. package/dist/mcp/tool-mapping.d.ts.map +1 -0
  165. package/dist/mcp/tool-mapping.js +143 -0
  166. package/dist/mcp/tool-namespace.d.ts +40 -0
  167. package/dist/mcp/tool-namespace.d.ts.map +1 -0
  168. package/dist/mcp/tool-namespace.js +65 -0
  169. package/dist/native-scheme.d.ts +59 -0
  170. package/dist/native-scheme.d.ts.map +1 -0
  171. package/dist/native-scheme.js +465 -0
  172. package/dist/output.d.ts +28 -0
  173. package/dist/output.d.ts.map +1 -0
  174. package/dist/output.js +342 -0
  175. package/dist/package-version.d.ts +30 -0
  176. package/dist/package-version.d.ts.map +1 -0
  177. package/dist/package-version.js +47 -0
  178. package/dist/qr-terminal.d.ts +13 -0
  179. package/dist/qr-terminal.d.ts.map +1 -0
  180. package/dist/qr-terminal.js +45 -0
  181. package/dist/rpc/client.d.ts +137 -0
  182. package/dist/rpc/client.d.ts.map +1 -0
  183. package/dist/rpc/client.js +819 -0
  184. package/dist/scheme.d.ts +181 -0
  185. package/dist/scheme.d.ts.map +1 -0
  186. package/dist/scheme.js +321 -0
  187. package/dist/spki-pin.d.ts +3 -0
  188. package/dist/spki-pin.d.ts.map +1 -0
  189. package/dist/spki-pin.js +14 -0
  190. package/package.json +65 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `appduct mcp` (ARCHITECTURE.md §9, §10): starts a stdio MCP server that proxies the daemon RPC
3
+ * (auto-spawn applies, same as every other command). This is a hosted command like `daemon run`/
4
+ * `events` — it runs until the transport closes (client disconnected) or the process is
5
+ * interrupted — and its stdout is reserved entirely for MCP protocol frames, so unlike every other
6
+ * command it must never let `executeHostedCommand`'s default bootstrap render reach stdout (the
7
+ * dispatcher wires an `"interactive"` reporter for exactly this reason, same as `events`).
8
+ *
9
+ * The deep-link scheme (`--scheme`, `APPDUCT_SCHEME`, project/state config, `app.json` — see
10
+ * `scheme.ts`) is resolved once here at startup, and *not* finding one is not an error: see
11
+ * {@link resolveSchemeForServer}.
12
+ */
13
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { loadConfig } from "../daemon/config.js";
15
+ import { getStateDirPaths } from "../daemon/state-dir.js";
16
+ import { createMcpServer } from "../mcp/server.js";
17
+ import { resolveScheme } from "../scheme.js";
18
+ /**
19
+ * Resolves the deep-link scheme for the server, *never* throwing.
20
+ *
21
+ * Unlike `appduct link`, an unresolved (or unresolvable) scheme must not stop `appduct mcp`
22
+ * from starting: the server is still fully useful for proxying an app's tools to a session that was
23
+ * paired some other way (a QR scan, `appduct link` in another terminal), and an MCP client that
24
+ * cannot start its server gets a much worse failure than one whose `appduct_connect` call
25
+ * returns a clear `invalid_request`. So a scheme problem here is downgraded to an extra entry in
26
+ * the `tried` list, which `appduct_connect` renders if and when it is actually called.
27
+ *
28
+ * It is *also* reported on stderr at startup, because an explicitly wrong `--scheme myapp://` or
29
+ * `APPDUCT_SCHEME` is a typo the operator wants to hear about immediately, not only if and when
30
+ * some agent happens to call `appduct_connect`. Stderr, never stdout: stdout carries MCP
31
+ * protocol frames exclusively, and MCP clients surface a server's stderr in their logs.
32
+ */
33
+ const resolveSchemeForServer = async (context, configScheme, paths) => {
34
+ try {
35
+ return await resolveScheme({
36
+ flagScheme: context.scheme,
37
+ env: context.schemeEnv,
38
+ cwd: context.cwd,
39
+ configScheme,
40
+ stateConfigPath: paths.configPath,
41
+ stateDirRoot: paths.root,
42
+ });
43
+ }
44
+ catch (error) {
45
+ const message = error.message;
46
+ (context.stderr ?? process.stderr).write(`appduct mcp: could not resolve a deep-link scheme: ${message}\n` +
47
+ "The server is starting anyway; appduct_connect will fail until this is fixed.\n");
48
+ return { tried: [`(scheme resolution failed: ${message})`] };
49
+ }
50
+ };
51
+ export const handleMcpCommand = async (context) => {
52
+ const paths = getStateDirPaths(context.stateDir);
53
+ const config = await loadConfig(paths);
54
+ const { scheme, tried } = await resolveSchemeForServer(context, config.scheme, paths);
55
+ const handle = await createMcpServer({
56
+ stateDir: context.stateDir,
57
+ spawn: context.spawn,
58
+ checkVersion: context.checkVersion,
59
+ scheme,
60
+ schemeTried: tried,
61
+ iosBundleId: context.iosBundleId ?? config.iosBundleId,
62
+ exec: context.exec,
63
+ env: context.env,
64
+ });
65
+ let resolveCompletion;
66
+ const completion = new Promise((resolve) => {
67
+ resolveCompletion = resolve;
68
+ });
69
+ let stopped = false;
70
+ const stop = () => {
71
+ if (stopped) {
72
+ return;
73
+ }
74
+ stopped = true;
75
+ void handle.close().finally(resolveCompletion);
76
+ };
77
+ // The client disconnecting (stdin closed) ends the transport, which must end the command
78
+ // gracefully rather than hang forever waiting for a Ctrl-C that will never come.
79
+ handle.server.onclose = stop;
80
+ handle.server.onerror = (error) => {
81
+ console.error("appduct mcp: transport error:", error);
82
+ };
83
+ try {
84
+ await handle.connect(new StdioServerTransport(context.stdin, context.stdout));
85
+ }
86
+ catch (error) {
87
+ await handle.close();
88
+ throw error;
89
+ }
90
+ return {
91
+ // Never rendered: the dispatcher suppresses the bootstrap render for this command (see the
92
+ // doc comment above) so stdout carries only MCP protocol frames.
93
+ result: { ok: true, data: undefined },
94
+ completion,
95
+ stop,
96
+ handle,
97
+ };
98
+ };
@@ -0,0 +1,12 @@
1
+ /** `appduct revoke` (ARCHITECTURE.md §10): revokes a session, optionally by selector. */
2
+ import type { CliResult, RevokeCommandData } from "../cli/result-types.js";
3
+ import { type SpawnFn } from "../rpc/client.js";
4
+ export type RevokeCommandOptions = {
5
+ selector?: string;
6
+ };
7
+ export type RevokeCommandContext = {
8
+ stateDir: string;
9
+ spawn?: SpawnFn;
10
+ };
11
+ export declare const handleRevokeCommand: (options: RevokeCommandOptions, context: RevokeCommandContext) => Promise<CliResult<RevokeCommandData>>;
12
+ //# sourceMappingURL=revoke.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revoke.d.ts","sourceRoot":"","sources":["../../src/commands/revoke.ts"],"names":[],"mappings":"AAAA,yFAAyF;AAIzF,OAAO,KAAK,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3E,OAAO,EAAc,KAAK,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAE5D,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAC9B,SAAS,oBAAoB,EAC7B,SAAS,oBAAoB,KAC5B,OAAO,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAQtC,CAAC"}
@@ -0,0 +1,7 @@
1
+ /** `appduct revoke` (ARCHITECTURE.md §10): revokes a session, optionally by selector. */
2
+ import { RPC_METHODS } from "@appduct/shared";
3
+ import { callDaemon } from "../rpc/client.js";
4
+ export const handleRevokeCommand = async (options, context) => {
5
+ await callDaemon(RPC_METHODS.sessionsRevoke, { selector: options.selector }, { stateDir: context.stateDir, spawn: context.spawn });
6
+ return { ok: true, data: { ok: true } };
7
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `appduct tools` (ARCHITECTURE.md §10): `tools [selector] [--full]` lists tools for a session;
3
+ * `tools [selector] <name>` shows one tool's full schema/annotations.
4
+ *
5
+ * The command table gives both forms a leading optional `[selector]`, which makes a single
6
+ * positional argument inherently ambiguous (is it the selector, or the tool name in `tools <name>`
7
+ * with the selector omitted?). This resolves it the same way a human reading the table would:
8
+ * first try the arg as a tool name in the implicit-selector session's registry; if no such tool
9
+ * exists there (or the implicit selector doesn't resolve, e.g. `ambiguous_session`), fall back to
10
+ * treating it as a selector and list that session's tools instead.
11
+ */
12
+ import type { CliResult, ToolsCommandData } from "../cli/result-types.js";
13
+ import { type SpawnFn } from "../rpc/client.js";
14
+ export type ToolsCommandOptions = {
15
+ selector?: string;
16
+ name?: string;
17
+ };
18
+ export type ToolsCommandContext = {
19
+ stateDir: string;
20
+ spawn?: SpawnFn;
21
+ };
22
+ export declare const handleToolsCommand: (options: ToolsCommandOptions, context: ToolsCommandContext) => Promise<CliResult<ToolsCommandData>>;
23
+ //# sourceMappingURL=tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../src/commands/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,OAAO,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE1E,OAAO,EAA8B,KAAK,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAE5E,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAiBF,eAAO,MAAM,kBAAkB,GAC7B,SAAS,mBAAmB,EAC5B,SAAS,mBAAmB,KAC3B,OAAO,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAyCrC,CAAC"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `appduct tools` (ARCHITECTURE.md §10): `tools [selector] [--full]` lists tools for a session;
3
+ * `tools [selector] <name>` shows one tool's full schema/annotations.
4
+ *
5
+ * The command table gives both forms a leading optional `[selector]`, which makes a single
6
+ * positional argument inherently ambiguous (is it the selector, or the tool name in `tools <name>`
7
+ * with the selector omitted?). This resolves it the same way a human reading the table would:
8
+ * first try the arg as a tool name in the implicit-selector session's registry; if no such tool
9
+ * exists there (or the implicit selector doesn't resolve, e.g. `ambiguous_session`), fall back to
10
+ * treating it as a selector and list that session's tools instead.
11
+ */
12
+ import { RPC_METHODS } from "@appduct/shared";
13
+ import { usageError } from "../errors.js";
14
+ import { callDaemon, DaemonRpcError } from "../rpc/client.js";
15
+ const listTools = (selector, context) => {
16
+ return callDaemon(RPC_METHODS.toolsList, { selector }, { stateDir: context.stateDir, spawn: context.spawn });
17
+ };
18
+ const findTool = (tools, name) => {
19
+ return tools.find((tool) => tool.name === name);
20
+ };
21
+ export const handleToolsCommand = async (options, context) => {
22
+ if (options.selector !== undefined && options.name !== undefined) {
23
+ const tools = await listTools(options.selector, context);
24
+ const tool = findTool(tools, options.name);
25
+ if (!tool) {
26
+ throw usageError(`Tool "${options.name}" is not registered on session "${options.selector}".`, { available: tools.map((entry) => entry.name) });
27
+ }
28
+ return { ok: true, data: tool };
29
+ }
30
+ if (options.selector !== undefined) {
31
+ // A single positional arg: try it as the implicit session's tool name first.
32
+ let implicitTools;
33
+ try {
34
+ implicitTools = await listTools(undefined, context);
35
+ }
36
+ catch (error) {
37
+ if (!(error instanceof DaemonRpcError)) {
38
+ throw error;
39
+ }
40
+ }
41
+ if (implicitTools) {
42
+ const tool = findTool(implicitTools, options.selector);
43
+ if (tool) {
44
+ return { ok: true, data: tool };
45
+ }
46
+ }
47
+ // Not a tool name on the implicit session (or there is no implicit session): treat the arg as
48
+ // a selector and list that session's tools instead.
49
+ return { ok: true, data: await listTools(options.selector, context) };
50
+ }
51
+ return { ok: true, data: await listTools(undefined, context) };
52
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Advertised-address detection (ARCHITECTURE.md §2, §8 bootstrap endpoint). Prefers a
3
+ * non-internal private IPv4 address, falling back to IPv6, then loopback. Fixes v1's
4
+ * `detectHostIp`, which happily returned a Docker bridge address (`172.17.x.x`, inside the RFC
5
+ * 1918 172.16/12 block v1 treated as "private") — that address is unreachable from a physical
6
+ * device and made the printed deep link useless. Also excludes the IPv4 CGNAT range
7
+ * (`100.64.0.0/10`), which is routable-looking but not device-reachable in the common case.
8
+ */
9
+ import { type AddressFamily } from "@appduct/shared";
10
+ export type AdvertisedAddress = {
11
+ family: AddressFamily;
12
+ address: string;
13
+ };
14
+ /** Docker's default bridge network (`docker0`), the exact defect v1 shipped with. */
15
+ export declare const isDockerDefaultBridgeAddress: (address: string) => boolean;
16
+ /** Carrier-grade NAT shared address space (RFC 6598): `100.64.0.0/10`. */
17
+ export declare const isCgnatIpv4Address: (address: string) => boolean;
18
+ export declare const isExcludedIpv4Address: (address: string) => boolean;
19
+ export type DetectAdvertisedAddressOptions = {
20
+ /** Explicit operator override (config or flag); skips interface enumeration entirely. */
21
+ override?: string;
22
+ };
23
+ /**
24
+ * Enumerates non-internal interface addresses and picks the best advertisable candidate: a
25
+ * private (RFC 1918) IPv4 address first (excluding Docker/CGNAT), then any other non-internal
26
+ * IPv4, then a unique-local (`fd00::/8`) IPv6 address, then any other non-internal, non-link-local
27
+ * IPv6 address, finally falling back to loopback if nothing else is available.
28
+ */
29
+ export declare const detectAdvertisedAddress: (options?: DetectAdvertisedAddressOptions) => AdvertisedAddress;
30
+ //# sourceMappingURL=address.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"address.d.ts","sourceRoot":"","sources":["../../src/daemon/address.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,EAIL,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAC;AAEzB,MAAM,MAAM,iBAAiB,GAAG;IAC9B,MAAM,EAAE,aAAa,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,qFAAqF;AACrF,eAAO,MAAM,4BAA4B,GAAI,SAAS,MAAM,KAAG,OAE9D,CAAC;AAEF,0EAA0E;AAC1E,eAAO,MAAM,kBAAkB,GAAI,SAAS,MAAM,KAAG,OAWpD,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAAI,SAAS,MAAM,KAAG,OAEvD,CAAC;AAEF,MAAM,MAAM,8BAA8B,GAAG;IAC3C,yFAAyF;IACzF,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GAClC,UAAS,8BAAmC,KAC3C,iBA+CF,CAAC"}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Advertised-address detection (ARCHITECTURE.md §2, §8 bootstrap endpoint). Prefers a
3
+ * non-internal private IPv4 address, falling back to IPv6, then loopback. Fixes v1's
4
+ * `detectHostIp`, which happily returned a Docker bridge address (`172.17.x.x`, inside the RFC
5
+ * 1918 172.16/12 block v1 treated as "private") — that address is unreachable from a physical
6
+ * device and made the printed deep link useless. Also excludes the IPv4 CGNAT range
7
+ * (`100.64.0.0/10`), which is routable-looking but not device-reachable in the common case.
8
+ */
9
+ import { networkInterfaces } from "node:os";
10
+ import { isLinkLocalIpv6Address, isPrivateIpv4Address, isUniqueLocalIpv6Address, } from "@appduct/shared";
11
+ /** Docker's default bridge network (`docker0`), the exact defect v1 shipped with. */
12
+ export const isDockerDefaultBridgeAddress = (address) => {
13
+ return address.startsWith("172.17.");
14
+ };
15
+ /** Carrier-grade NAT shared address space (RFC 6598): `100.64.0.0/10`. */
16
+ export const isCgnatIpv4Address = (address) => {
17
+ const parts = address.split(".");
18
+ if (parts.length !== 4) {
19
+ return false;
20
+ }
21
+ const first = Number(parts[0]);
22
+ const second = Number(parts[1]);
23
+ return first === 100 && Number.isInteger(second) && second >= 64 && second <= 127;
24
+ };
25
+ export const isExcludedIpv4Address = (address) => {
26
+ return isDockerDefaultBridgeAddress(address) || isCgnatIpv4Address(address);
27
+ };
28
+ /**
29
+ * Enumerates non-internal interface addresses and picks the best advertisable candidate: a
30
+ * private (RFC 1918) IPv4 address first (excluding Docker/CGNAT), then any other non-internal
31
+ * IPv4, then a unique-local (`fd00::/8`) IPv6 address, then any other non-internal, non-link-local
32
+ * IPv6 address, finally falling back to loopback if nothing else is available.
33
+ */
34
+ export const detectAdvertisedAddress = (options = {}) => {
35
+ if (options.override) {
36
+ return {
37
+ family: options.override.includes(":") ? 6 : 4,
38
+ address: options.override,
39
+ };
40
+ }
41
+ const ipv4Candidates = [];
42
+ const ipv6Candidates = [];
43
+ for (const entries of Object.values(networkInterfaces())) {
44
+ for (const entry of entries ?? []) {
45
+ if (entry.internal) {
46
+ continue;
47
+ }
48
+ if (entry.family === "IPv4") {
49
+ if (isExcludedIpv4Address(entry.address)) {
50
+ continue;
51
+ }
52
+ ipv4Candidates.push(entry.address);
53
+ }
54
+ else if (entry.family === "IPv6") {
55
+ if (isLinkLocalIpv6Address(entry.address)) {
56
+ continue;
57
+ }
58
+ ipv6Candidates.push(entry.address);
59
+ }
60
+ }
61
+ }
62
+ const preferredIpv4 = ipv4Candidates.find((address) => isPrivateIpv4Address(address)) ?? ipv4Candidates[0];
63
+ if (preferredIpv4) {
64
+ return { family: 4, address: preferredIpv4 };
65
+ }
66
+ const preferredIpv6 = ipv6Candidates.find((address) => isUniqueLocalIpv6Address(address)) ?? ipv6Candidates[0];
67
+ if (preferredIpv6) {
68
+ return { family: 6, address: preferredIpv6 };
69
+ }
70
+ return { family: 4, address: "127.0.0.1" };
71
+ };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Append-only per-day audit log (ARCHITECTURE.md §3, §12): one JSONL line per `tools.call`
3
+ * attempt (CLI and MCP alike), regardless of outcome. Raw args are **never** written — only a hex
4
+ * sha256 digest of their canonical JSON, so an operator can correlate repeated/identical calls
5
+ * without the log becoming a second copy of whatever secrets a tool's arguments carried.
6
+ *
7
+ * Writes are serialized through an internal promise queue so a slow disk never blocks the
8
+ * `tools.call` response path: {@link AuditLogger.record} enqueues and returns immediately. A write
9
+ * failure never throws back into the call path — it is logged to stderr and counted in
10
+ * {@link AuditLogger.failedWrites} (surfaced via `daemon.status`, ARCHITECTURE.md §12 item 4).
11
+ * {@link AuditLogger.flush} lets the daemon's shutdown teardown wait for the queue to drain.
12
+ *
13
+ * Retention (ARCHITECTURE.md §3): day files older than `config.auditRetentionDays` are deleted by
14
+ * {@link AuditLogger.prune}, which the daemon runs at startup and once a day thereafter. Pruning
15
+ * runs on the same queue as the writes, so it can never race an in-flight append, and its failures
16
+ * are counted in {@link AuditLogger.failedPrunes} and warned rather than thrown — exactly like a
17
+ * failed write.
18
+ */
19
+ import type { ErrorType } from "@appduct/shared";
20
+ import type { Clock } from "../cli/types.js";
21
+ export type AuditOutcome = "ok" | "error" | "denied" | "cancelled";
22
+ export type AuditCaller = "cli" | "mcp" | "client";
23
+ export type AuditRecord = {
24
+ /** ISO 8601. */
25
+ ts: string;
26
+ sessionId: string;
27
+ alias: string;
28
+ tool: string;
29
+ /** Hex sha256 of the canonical JSON of the call's `args` — raw args are never logged. */
30
+ argsSha256: string;
31
+ outcome: AuditOutcome;
32
+ errorType?: ErrorType;
33
+ /**
34
+ * Set only when `outcome === "denied"`: `"policy"` for an ordinary `policy.*: "deny"` denial,
35
+ * `"no_consent_channel"` for a `"prompt"`-policy call that had no confirmed human gate
36
+ * (ARCHITECTURE.md §12). Without this an operator reading `audit/*.jsonl` can't tell a
37
+ * misconfigured `"deny"` from a `"prompt"` tool nobody has gated yet — both otherwise look like
38
+ * an identical `{ outcome: "denied" }` line.
39
+ */
40
+ deniedReason?: "policy" | "no_consent_channel";
41
+ durationMs: number;
42
+ caller: AuditCaller;
43
+ /**
44
+ * Set only when a `"prompt"`-policy call actually proceeded because the MCP server confirmed a
45
+ * consent gate (ARCHITECTURE.md §12). Deliberately distinct from a plain `"ok"`: the daemon
46
+ * never observed either channel's client-side behavior itself, only that the call arrived
47
+ * carrying this marker. The value distinguishes which gate fired:
48
+ * - `"client"` (issue #14): the flag-based gate — the weaker of the two, evidence only that
49
+ * `_meta["anthropic/requiresUserInteraction"]` was emitted and the client is one known to
50
+ * enforce it, not that a human actually answered a prompt.
51
+ * - `"elicitation"` (issue #10): the client replied `action: "accept"` to a live
52
+ * `elicitation/create` request sent for this call — an observed decision, not merely an armed
53
+ * flag, though still not independently verifiable by the daemon (see the field-level trust
54
+ * caveat on `ToolsCallParams.consent` in `@appduct/shared`).
55
+ */
56
+ consent?: "client" | "elicitation";
57
+ };
58
+ /**
59
+ * Audit directory footprint, surfaced by `daemon.status` so the growth is visible (issue #32).
60
+ * Both fields are absent — not zero — when the directory could not be read at all; "we could not
61
+ * measure it" and "it is empty" are different facts and `daemon.status` reports them differently.
62
+ */
63
+ export type AuditStats = {
64
+ /** Number of `<YYYY-MM-DD>.jsonl` day files currently on disk. */
65
+ files?: number;
66
+ /** Total bytes across those files. */
67
+ bytes?: number;
68
+ };
69
+ export type AuditPruneResult = {
70
+ /** Day files deleted by this run. */
71
+ deleted: number;
72
+ /** Day files this run tried and failed to delete (also added to {@link AuditLogger.failedPrunes}). */
73
+ failed: number;
74
+ };
75
+ export type AuditLogger = {
76
+ /** Enqueues one JSONL line for the day derived from the current clock; returns immediately
77
+ * (fire-and-forget from the caller's perspective — see the module doc comment). */
78
+ record: (entry: Omit<AuditRecord, "ts">) => void;
79
+ /** Count of write failures since the logger was created (never resets). */
80
+ failedWrites: () => number;
81
+ /** Count of prune failures since the logger was created (never resets). */
82
+ failedPrunes: () => number;
83
+ /**
84
+ * Deletes every `<YYYY-MM-DD>.jsonl` day file older than the retention window — never today's,
85
+ * never anything else in the directory. Runs on the write queue and never rejects: failures are
86
+ * counted and warned.
87
+ */
88
+ prune: () => Promise<AuditPruneResult>;
89
+ /** Current day-file count and total size; `{ files: 0, bytes: 0 }` when the directory does not
90
+ * exist yet or cannot be read. */
91
+ stats: () => Promise<AuditStats>;
92
+ /** Resolves once every queued write so far has settled (success or logged failure). */
93
+ flush: () => Promise<void>;
94
+ };
95
+ export type AuditLoggerOptions = {
96
+ auditDir: string;
97
+ clock?: Clock;
98
+ /** Days of history {@link AuditLogger.prune} keeps; defaults to
99
+ * {@link DEFAULT_AUDIT_RETENTION_DAYS}. */
100
+ retentionDays?: number;
101
+ /** Sink for write-failure diagnostics; defaults to `process.stderr`. Injectable for tests. */
102
+ warn?: (message: string) => void;
103
+ };
104
+ export declare const canonicalArgsJson: (args: Record<string, unknown>) => string;
105
+ export declare const argsSha256: (args: Record<string, unknown>) => string;
106
+ export declare const createAuditLogger: (options: AuditLoggerOptions) => AuditLogger;
107
+ //# sourceMappingURL=audit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../src/daemon/audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAMH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAG7C,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,OAAO,GAAG,QAAQ,GAAG,WAAW,CAAC;AACnE,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEnD,MAAM,MAAM,WAAW,GAAG;IACxB,gBAAgB;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,yFAAyF;IACzF,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,YAAY,CAAC;IACtB,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,QAAQ,GAAG,oBAAoB,CAAC;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,WAAW,CAAC;IACpB;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,EAAE,QAAQ,GAAG,aAAa,CAAC;CACpC,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,kEAAkE;IAClE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,sGAAsG;IACtG,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB;uFACmF;IACnF,MAAM,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,KAAK,IAAI,CAAC;IACjD,2EAA2E;IAC3E,YAAY,EAAE,MAAM,MAAM,CAAC;IAC3B,2EAA2E;IAC3E,YAAY,EAAE,MAAM,MAAM,CAAC;IAC3B;;;;OAIG;IACH,KAAK,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACvC;sCACkC;IAClC,KAAK,EAAE,MAAM,OAAO,CAAC,UAAU,CAAC,CAAC;IACjC,uFAAuF;IACvF,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,KAAK,CAAC;IACd;+CAC2C;IAC3C,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8FAA8F;IAC9F,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC,CAAC;AAiBF,eAAO,MAAM,iBAAiB,GAAI,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,MAEjE,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,MAE1D,CAAC;AA8DF,eAAO,MAAM,iBAAiB,GAAI,SAAS,kBAAkB,KAAG,WA4K/D,CAAC"}
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Append-only per-day audit log (ARCHITECTURE.md §3, §12): one JSONL line per `tools.call`
3
+ * attempt (CLI and MCP alike), regardless of outcome. Raw args are **never** written — only a hex
4
+ * sha256 digest of their canonical JSON, so an operator can correlate repeated/identical calls
5
+ * without the log becoming a second copy of whatever secrets a tool's arguments carried.
6
+ *
7
+ * Writes are serialized through an internal promise queue so a slow disk never blocks the
8
+ * `tools.call` response path: {@link AuditLogger.record} enqueues and returns immediately. A write
9
+ * failure never throws back into the call path — it is logged to stderr and counted in
10
+ * {@link AuditLogger.failedWrites} (surfaced via `daemon.status`, ARCHITECTURE.md §12 item 4).
11
+ * {@link AuditLogger.flush} lets the daemon's shutdown teardown wait for the queue to drain.
12
+ *
13
+ * Retention (ARCHITECTURE.md §3): day files older than `config.auditRetentionDays` are deleted by
14
+ * {@link AuditLogger.prune}, which the daemon runs at startup and once a day thereafter. Pruning
15
+ * runs on the same queue as the writes, so it can never race an in-flight append, and its failures
16
+ * are counted in {@link AuditLogger.failedPrunes} and warned rather than thrown — exactly like a
17
+ * failed write.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { chmod, mkdir, open, readdir, rm, stat } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+ import { DEFAULT_AUDIT_RETENTION_DAYS } from "./config.js";
23
+ /** Recursively sorts object keys so semantically-identical args always hash identically,
24
+ * regardless of the property insertion order the caller happened to use. */
25
+ const canonicalize = (value) => {
26
+ if (Array.isArray(value)) {
27
+ return value.map(canonicalize);
28
+ }
29
+ if (value !== null && typeof value === "object") {
30
+ const record = value;
31
+ return Object.fromEntries(Object.keys(record).sort().map((key) => [key, canonicalize(record[key])]));
32
+ }
33
+ return value;
34
+ };
35
+ export const canonicalArgsJson = (args) => {
36
+ return JSON.stringify(canonicalize(args));
37
+ };
38
+ export const argsSha256 = (args) => {
39
+ return createHash("sha256").update(canonicalArgsJson(args)).digest("hex");
40
+ };
41
+ /** The UTC day stamp a record written at `date` lands in. UTC (not local time) because that is
42
+ * exactly what the file names have always been derived from — see {@link auditFilePathForDate}. */
43
+ const dayStamp = (date) => {
44
+ return date.toISOString().slice(0, 10); // YYYY-MM-DD
45
+ };
46
+ /** Matches only the day files this module writes. Anything else in `audit/` — an operator's
47
+ * notes, a partial copy, a subdirectory — is never a pruning candidate. */
48
+ const AUDIT_DAY_FILE = /^(\d{4}-\d{2}-\d{2})\.jsonl$/u;
49
+ /** The captured day stamp when `name` is one of our day files *and* the stamp is a real calendar
50
+ * date (so a plausible-looking `2026-13-45.jsonl` is left alone rather than compared as a string). */
51
+ const dayStampOfAuditFile = (name) => {
52
+ const match = AUDIT_DAY_FILE.exec(name);
53
+ if (!match) {
54
+ return undefined;
55
+ }
56
+ const stamp = match[1];
57
+ const parsed = new Date(`${stamp}T00:00:00.000Z`);
58
+ return Number.isNaN(parsed.getTime()) || dayStamp(parsed) !== stamp ? undefined : stamp;
59
+ };
60
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
61
+ /**
62
+ * The oldest day stamp retention keeps: `retentionDays` UTC days before the day containing `now`.
63
+ * A day file is pruned when its stamp sorts strictly before this (ISO stamps are zero-padded, so
64
+ * lexicographic order is chronological order), i.e. once it is *more* than `retentionDays` days
65
+ * behind today. Today's file therefore can never be a candidate for any `retentionDays >= 1`.
66
+ */
67
+ const oldestRetainedStamp = (now, retentionDays) => {
68
+ const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
69
+ const cutoff = new Date(todayUtcMidnight - retentionDays * MS_PER_DAY);
70
+ // `auditRetentionDays` is validated as a positive integer, not a *small* one: a window wide
71
+ // enough to run off the end of the `Date` range would make `toISOString` throw. That config
72
+ // means "keep everything", so answer with a stamp no real day file can sort before.
73
+ return Number.isNaN(cutoff.getTime()) ? "0000-00-00" : dayStamp(cutoff);
74
+ };
75
+ const auditFilePathForDate = (auditDir, date) => {
76
+ return join(auditDir, `${dayStamp(date)}.jsonl`);
77
+ };
78
+ const appendLine = async (filePath, line) => {
79
+ const handle = await open(filePath, "a", 0o600);
80
+ try {
81
+ await handle.appendFile(line);
82
+ // `open`'s mode only applies at creation and is further subject to umask; chmod explicitly so
83
+ // an existing (or umask-widened) file always ends up at the ARCHITECTURE.md §3 mode.
84
+ await chmod(filePath, 0o600);
85
+ }
86
+ finally {
87
+ await handle.close();
88
+ }
89
+ };
90
+ export const createAuditLogger = (options) => {
91
+ const clock = options.clock ?? { now: () => new Date() };
92
+ const warn = options.warn ??
93
+ ((message) => {
94
+ process.stderr.write(`${message}\n`);
95
+ });
96
+ const retentionDays = options.retentionDays ?? DEFAULT_AUDIT_RETENTION_DAYS;
97
+ let failedWrites = 0;
98
+ let failedPrunes = 0;
99
+ let queue = Promise.resolve();
100
+ /**
101
+ * Lists the day files in the audit directory. A *missing* directory is genuinely empty — the
102
+ * daemon creates it lazily — and reports `failed: false`. Any other error (EACCES on a
103
+ * tightened-up directory, ENOTDIR where a file now sits, EIO on a failing disk) is a refusal to
104
+ * answer, not an answer of zero, and is reported as such: callers must not turn "we could not
105
+ * look" into "there is nothing there".
106
+ */
107
+ const listDayFiles = async () => {
108
+ let entries;
109
+ try {
110
+ entries = await readdir(options.auditDir, { withFileTypes: true });
111
+ }
112
+ catch (error) {
113
+ if (error.code === "ENOENT") {
114
+ return { files: [], failed: false };
115
+ }
116
+ return { files: [], failed: true, error: error };
117
+ }
118
+ const files = [];
119
+ for (const entry of entries) {
120
+ if (!entry.isFile()) {
121
+ continue;
122
+ }
123
+ const stamp = dayStampOfAuditFile(entry.name);
124
+ if (stamp !== undefined) {
125
+ files.push({ name: entry.name, stamp });
126
+ }
127
+ }
128
+ return { files, failed: false };
129
+ };
130
+ const pruneOnce = async () => {
131
+ const today = dayStamp(clock.now());
132
+ const cutoff = oldestRetainedStamp(clock.now(), retentionDays);
133
+ let deleted = 0;
134
+ let failed = 0;
135
+ const listing = await listDayFiles();
136
+ if (listing.failed) {
137
+ // A sweep that could not read the directory did not prune anything and cannot know whether
138
+ // it needed to — counted and warned like any other prune failure, so a permanently
139
+ // unreadable `audit/` shows up in `daemon.status` instead of looking like a tidy one.
140
+ failedPrunes += 1;
141
+ warn(`appduct daemon: failed to read the audit directory to prune it: ${listing.error?.message}`);
142
+ return { deleted: 0, failed: 1 };
143
+ }
144
+ for (const file of listing.files) {
145
+ // Belt and braces: `cutoff` is already strictly behind `today` for every valid
146
+ // `retentionDays`, but today's file is the one file that must never disappear from under an
147
+ // open append, so it is excluded explicitly (issue #32).
148
+ if (file.stamp === today || file.stamp >= cutoff) {
149
+ continue;
150
+ }
151
+ try {
152
+ await rm(join(options.auditDir, file.name), { force: true });
153
+ deleted += 1;
154
+ }
155
+ catch (error) {
156
+ failed += 1;
157
+ failedPrunes += 1;
158
+ warn(`appduct daemon: failed to prune the audit record "${file.name}": ${error.message}`);
159
+ }
160
+ }
161
+ return { deleted, failed };
162
+ };
163
+ const record = (entry) => {
164
+ const now = clock.now();
165
+ const full = { ts: now.toISOString(), ...entry };
166
+ const line = `${JSON.stringify(full)}\n`;
167
+ const filePath = auditFilePathForDate(options.auditDir, now);
168
+ // Chained onto the existing queue tail so writes for the same (or different) day never race
169
+ // each other; a failure is swallowed here (never rejects the chain) so one bad write can't
170
+ // poison every audit record after it.
171
+ queue = queue.then(async () => {
172
+ try {
173
+ await mkdir(options.auditDir, { recursive: true });
174
+ await appendLine(filePath, line);
175
+ }
176
+ catch (error) {
177
+ failedWrites += 1;
178
+ warn(`appduct daemon: failed to write an audit record: ${error.message}`);
179
+ }
180
+ });
181
+ };
182
+ /**
183
+ * Chained onto the write queue for the same reason the writes are chained onto each other: a
184
+ * prune must never interleave with the append it would otherwise delete out from under. The
185
+ * returned promise resolves with this prune's own result while the queue itself stays a
186
+ * never-rejecting `Promise<void>` that `flush` can keep awaiting.
187
+ */
188
+ const prune = () => {
189
+ let settle;
190
+ const result = new Promise((resolve) => {
191
+ settle = resolve;
192
+ });
193
+ queue = queue.then(async () => {
194
+ try {
195
+ settle(await pruneOnce());
196
+ }
197
+ catch (error) {
198
+ // `pruneOnce` handles per-file failures itself; this only catches a failure of the listing
199
+ // step, which must still never reject the queue.
200
+ failedPrunes += 1;
201
+ warn(`appduct daemon: failed to prune the audit log: ${error.message}`);
202
+ settle({ deleted: 0, failed: 1 });
203
+ }
204
+ });
205
+ return result;
206
+ };
207
+ const stats = async () => {
208
+ const listing = await listDayFiles();
209
+ if (listing.failed) {
210
+ // Reported as "not measured", never as an empty directory: `daemon.status` would otherwise
211
+ // tell an operator that `audit/` holds nothing at exactly the moment it has become
212
+ // unreadable — the moment they most need to know otherwise.
213
+ return {};
214
+ }
215
+ let files = 0;
216
+ let bytes = 0;
217
+ for (const file of listing.files) {
218
+ try {
219
+ bytes += (await stat(join(options.auditDir, file.name))).size;
220
+ files += 1;
221
+ }
222
+ catch {
223
+ // Raced with a prune (ours or an operator's `rm`); an audit record that is gone by the
224
+ // time it is measured simply does not count toward the reported footprint.
225
+ }
226
+ }
227
+ return { files, bytes };
228
+ };
229
+ return {
230
+ record,
231
+ failedWrites: () => failedWrites,
232
+ failedPrunes: () => failedPrunes,
233
+ prune,
234
+ stats,
235
+ flush: () => queue,
236
+ };
237
+ };