@rse/ase 1.0.7 → 2.0.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 (279) hide show
  1. package/dst/ase-agent-definition-api.d.ts +184 -0
  2. package/dst/ase-agent-definition-api.js +6 -0
  3. package/dst/ase-agent-definition-builtin/condenser.js +42 -0
  4. package/dst/ase-agent-definition-builtin/editor.js +45 -0
  5. package/dst/ase-agent-definition-builtin/estimator.js +98 -0
  6. package/dst/ase-agent-definition-builtin/implementer.js +121 -0
  7. package/dst/ase-agent-definition-builtin/integrator.js +56 -0
  8. package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
  9. package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
  10. package/dst/ase-agent-definition-builtin/researcher.js +77 -0
  11. package/dst/ase-agent-definition-core.js +827 -0
  12. package/dst/ase-agent-fleet.js +772 -0
  13. package/dst/ase-agent-process.js +232 -0
  14. package/dst/ase-agent-relay-client.js +509 -0
  15. package/dst/ase-agent-relay-core.js +213 -0
  16. package/dst/ase-agent-relay-server-bind.js +242 -0
  17. package/dst/ase-agent-relay-server-cli.js +50 -0
  18. package/dst/ase-agent-runtime-claim.js +189 -0
  19. package/dst/ase-agent-runtime-engine.js +1100 -0
  20. package/dst/ase-agent-runtime-exec.js +275 -0
  21. package/dst/ase-agent-runtime-phase.js +581 -0
  22. package/dst/ase-agent-runtime-request.js +272 -0
  23. package/dst/ase-agent-runtime-tool.js +207 -0
  24. package/dst/ase-agent-runtime.js +1316 -0
  25. package/dst/ase-agent.js +65 -0
  26. package/dst/ase-artifact.js +6 -5
  27. package/dst/ase-config-core.js +465 -0
  28. package/dst/ase-config-schema.js +351 -0
  29. package/dst/ase-config-scope.js +172 -0
  30. package/dst/ase-config.js +25 -585
  31. package/dst/ase-hook.js +228 -111
  32. package/dst/ase-lib-daemon-cli.js +130 -0
  33. package/dst/ase-lib-hash.js +20 -0
  34. package/dst/{ase-log.js → ase-lib-log.js} +40 -7
  35. package/dst/ase-lib-server-bind.js +115 -0
  36. package/dst/ase-lib-server-cli.js +474 -0
  37. package/dst/{ase-table.js → ase-lib-table.js} +1 -1
  38. package/dst/{ase-version.js → ase-lib-version.js} +3 -0
  39. package/dst/ase-man.js +96 -0
  40. package/dst/ase-mcp.js +166 -13
  41. package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
  42. package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
  43. package/dst/ase-service-otel.js +1246 -0
  44. package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
  45. package/dst/ase-service-state.js +101 -0
  46. package/dst/ase-service.js +251 -76
  47. package/dst/ase-setup-common.js +141 -0
  48. package/dst/ase-setup-mcp.js +359 -0
  49. package/dst/ase-setup-settings.js +837 -0
  50. package/dst/ase-setup.js +113 -869
  51. package/dst/ase-spec.js +31 -19
  52. package/dst/ase-statusline.js +108 -43
  53. package/dst/ase-task-board-core.js +739 -0
  54. package/dst/ase-task-board-filter.js +127 -0
  55. package/dst/ase-task-board-flow.js +237 -0
  56. package/dst/ase-task-board-graph.js +497 -0
  57. package/dst/ase-task-board-stats.js +263 -0
  58. package/dst/ase-task-board-tui-control.js +775 -0
  59. package/dst/ase-task-board-tui-model.js +1008 -0
  60. package/dst/ase-task-board-tui-popup.js +626 -0
  61. package/dst/ase-task-board-tui-style.js +106 -0
  62. package/dst/ase-task-board-tui-view.js +847 -0
  63. package/dst/ase-task-board-tui.js +189 -0
  64. package/dst/ase-task-board-web-client.css +1 -0
  65. package/dst/ase-task-board-web-client.html +20 -0
  66. package/dst/ase-task-board-web-client.js +81 -0
  67. package/dst/ase-task-board-web-server.js +823 -0
  68. package/dst/ase-task-board.js +134 -0
  69. package/dst/ase-task-format.js +757 -0
  70. package/dst/ase-task-request.js +208 -0
  71. package/dst/ase-task-store-client.js +495 -0
  72. package/dst/ase-task-store-core.js +804 -0
  73. package/dst/ase-task-store-issues.js +237 -0
  74. package/dst/ase-task-store-plugin-api.js +6 -0
  75. package/dst/ase-task-store-plugin-ase.js +484 -0
  76. package/dst/ase-task-store-plugin-delegate.js +137 -0
  77. package/dst/ase-task-store-plugin-gitea.js +666 -0
  78. package/dst/ase-task-store-plugin-github.js +696 -0
  79. package/dst/ase-task-store-plugin-gitlab.js +668 -0
  80. package/dst/ase-task-store-server-bind.js +538 -0
  81. package/dst/ase-task-store-server-cli.js +154 -0
  82. package/dst/ase-task.js +1335 -357
  83. package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
  84. package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
  85. package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
  86. package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
  87. package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
  88. package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
  89. package/dst/ase-util.js +20 -0
  90. package/dst/ase.js +108 -32
  91. package/dst/man/ase-agent-adhoc.1 +515 -0
  92. package/dst/man/ase-agent-condenser.1 +193 -0
  93. package/dst/man/ase-agent-editor.1 +212 -0
  94. package/dst/man/ase-agent-estimator.1 +214 -0
  95. package/dst/man/ase-agent-fleet.1 +185 -0
  96. package/dst/man/ase-agent-implementer.1 +233 -0
  97. package/dst/man/ase-agent-integrator.1 +213 -0
  98. package/dst/man/ase-agent-qualifier.1 +214 -0
  99. package/dst/man/ase-agent-rebooter.1 +193 -0
  100. package/dst/man/ase-agent-relay.1 +128 -0
  101. package/dst/man/ase-agent-researcher.1 +217 -0
  102. package/dst/man/ase-agent.1 +264 -0
  103. package/dst/man/ase-artifact-list.1 +38 -0
  104. package/dst/man/ase-artifact-name.1 +39 -0
  105. package/dst/man/ase-artifact.1 +56 -0
  106. package/dst/man/ase-config-delete.1 +45 -0
  107. package/dst/man/ase-config-edit.1 +48 -0
  108. package/dst/man/ase-config-get.1 +46 -0
  109. package/dst/man/ase-config-init.1 +60 -0
  110. package/dst/man/ase-config-list.1 +42 -0
  111. package/dst/man/ase-config-set.1 +52 -0
  112. package/dst/man/ase-config.1 +225 -0
  113. package/dst/man/ase-hook-permission-request.1 +36 -0
  114. package/dst/man/ase-hook-pre-tool-use.1 +59 -0
  115. package/dst/man/ase-hook-session-end.1 +47 -0
  116. package/dst/man/ase-hook-session-persona.1 +37 -0
  117. package/dst/man/ase-hook-session-start.1 +81 -0
  118. package/dst/man/ase-hook-stop.1 +34 -0
  119. package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
  120. package/dst/man/ase-hook.1 +51 -0
  121. package/dst/man/ase-man.1 +67 -0
  122. package/dst/man/ase-mcp.1 +61 -0
  123. package/dst/man/ase-service-log.1 +43 -0
  124. package/dst/man/ase-service-send.1 +47 -0
  125. package/dst/man/ase-service-start.1 +54 -0
  126. package/dst/man/ase-service-status.1 +46 -0
  127. package/dst/man/ase-service-stop.1 +37 -0
  128. package/dst/man/ase-service.1 +85 -0
  129. package/dst/man/ase-setup-disable.1 +44 -0
  130. package/dst/man/ase-setup-enable.1 +45 -0
  131. package/dst/man/ase-setup-install.1 +73 -0
  132. package/dst/man/ase-setup-mcp.1 +124 -0
  133. package/dst/man/ase-setup-status.1 +60 -0
  134. package/dst/man/ase-setup-statusline.1 +92 -0
  135. package/dst/man/ase-setup-telemetry.1 +92 -0
  136. package/dst/man/ase-setup-uninstall.1 +59 -0
  137. package/dst/man/ase-setup-update.1 +64 -0
  138. package/dst/man/ase-setup.1 +93 -0
  139. package/dst/man/ase-spec-export.1 +62 -0
  140. package/dst/man/ase-spec-lint.1 +40 -0
  141. package/dst/man/ase-spec-preview.1 +40 -0
  142. package/dst/man/ase-spec.1 +46 -0
  143. package/dst/man/ase-statusline.1 +189 -0
  144. package/dst/man/ase-task-assign.1 +71 -0
  145. package/dst/man/ase-task-board.1 +384 -0
  146. package/dst/man/ase-task-delete.1 +35 -0
  147. package/dst/man/ase-task-edit.1 +46 -0
  148. package/dst/man/ase-task-idscheme.1 +65 -0
  149. package/dst/man/ase-task-lifecycle.1 +57 -0
  150. package/dst/man/ase-task-list.1 +47 -0
  151. package/dst/man/ase-task-load.1 +36 -0
  152. package/dst/man/ase-task-newid.1 +61 -0
  153. package/dst/man/ase-task-purge.1 +37 -0
  154. package/dst/man/ase-task-rename.1 +44 -0
  155. package/dst/man/ase-task-request.1 +61 -0
  156. package/dst/man/ase-task-save.1 +48 -0
  157. package/dst/man/ase-task-status.1 +55 -0
  158. package/dst/man/ase-task-store.1 +194 -0
  159. package/dst/man/ase-task-view.1 +40 -0
  160. package/dst/man/ase-task.1 +160 -0
  161. package/dst/man/ase-util-compat.1 +25 -0
  162. package/dst/man/ase-util-diagram.1 +76 -0
  163. package/dst/man/ase-util-meta.1 +45 -0
  164. package/dst/man/ase-util-metric.1 +54 -0
  165. package/dst/man/ase-util-mint.1 +59 -0
  166. package/dst/man/ase-util-worktree.1 +72 -0
  167. package/dst/man/ase-util.1 +50 -0
  168. package/dst/man/ase.1 +124 -0
  169. package/package.json +79 -21
  170. package/plugin/.claude-plugin/plugin.json +1 -1
  171. package/plugin/.codex-plugin/plugin.json +1 -1
  172. package/plugin/.github/plugin/plugin.json +1 -1
  173. package/plugin/agents/ase-meta-diagram.md +12 -8
  174. package/plugin/agents/ase-repo-review.md +322 -0
  175. package/plugin/hooks/hooks-codex.json +5 -0
  176. package/plugin/hooks/hooks-copilot.json +5 -0
  177. package/plugin/hooks/hooks.json +8 -0
  178. package/plugin/meta/ase-common-approach.md +117 -0
  179. package/plugin/meta/ase-common-changeset.md +256 -0
  180. package/plugin/meta/ase-common-code.md +10 -173
  181. package/plugin/meta/ase-common-grill.md +317 -0
  182. package/plugin/meta/ase-common-resolve.md +305 -0
  183. package/plugin/meta/ase-common-task.md +83 -4
  184. package/plugin/meta/ase-constitution.md +0 -2
  185. package/plugin/meta/ase-format-specbook.md +208 -25
  186. package/plugin/meta/ase-format-specbook.yaml +242 -851
  187. package/plugin/meta/ase-format-task.md +669 -125
  188. package/plugin/meta/ase-persona.md +3 -4
  189. package/plugin/meta/ase-skill.md +9 -0
  190. package/plugin/mods/ase-mods.ts +409 -0
  191. package/plugin/mods/claude-code.d.ts +13417 -0
  192. package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
  193. package/plugin/skills/ase-arch-analyze/help.md +2 -2
  194. package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
  195. package/plugin/skills/ase-code-analyze/help.md +29 -4
  196. package/plugin/skills/ase-code-edit/SKILL.md +107 -181
  197. package/plugin/skills/ase-code-edit/help.md +118 -31
  198. package/plugin/skills/ase-code-lint/SKILL.md +64 -13
  199. package/plugin/skills/ase-code-lint/help.md +32 -2
  200. package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
  201. package/plugin/skills/ase-docs-proofread/help.md +1 -1
  202. package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
  203. package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
  204. package/plugin/skills/ase-help-intent/data.md +2164 -1087
  205. package/plugin/skills/ase-help-intent/help.md +1 -1
  206. package/plugin/skills/ase-help-skill/catalog.md +12 -9
  207. package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
  208. package/plugin/skills/ase-meta-config/SKILL.md +3 -4
  209. package/plugin/skills/ase-meta-config/help.md +31 -10
  210. package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
  211. package/plugin/skills/ase-meta-mint/help.md +10 -5
  212. package/plugin/skills/ase-meta-why/help.md +1 -1
  213. package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
  214. package/plugin/skills/ase-meta-workflow/help.md +4 -4
  215. package/plugin/skills/ase-meta-workflow/sample.md +3 -3
  216. package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
  217. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
  218. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
  219. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
  220. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
  221. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
  222. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
  223. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
  224. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
  225. package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
  226. package/plugin/skills/ase-repo-merge/help.md +107 -0
  227. package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
  228. package/plugin/skills/ase-repo-resolve/help.md +102 -0
  229. package/plugin/skills/ase-repo-review/SKILL.md +406 -0
  230. package/plugin/skills/ase-repo-review/help.md +181 -0
  231. package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
  232. package/plugin/skills/ase-spec-edit/help.md +107 -26
  233. package/plugin/skills/ase-sync-reconcile/help.md +1 -1
  234. package/plugin/skills/ase-task-assign/SKILL.md +111 -0
  235. package/plugin/skills/ase-task-assign/help.md +119 -0
  236. package/plugin/skills/ase-task-condense/SKILL.md +21 -21
  237. package/plugin/skills/ase-task-condense/help.md +12 -10
  238. package/plugin/skills/ase-task-delete/SKILL.md +1 -1
  239. package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
  240. package/plugin/skills/ase-task-dissect/help.md +10 -9
  241. package/plugin/skills/ase-task-edit/SKILL.md +379 -94
  242. package/plugin/skills/ase-task-edit/help.md +95 -22
  243. package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
  244. package/plugin/skills/ase-task-estimate/help.md +158 -0
  245. package/plugin/skills/ase-task-grill/SKILL.md +246 -138
  246. package/plugin/skills/ase-task-grill/help.md +129 -26
  247. package/plugin/skills/ase-task-id/SKILL.md +8 -1
  248. package/plugin/skills/ase-task-id/help.md +6 -3
  249. package/plugin/skills/ase-task-implement/SKILL.md +535 -145
  250. package/plugin/skills/ase-task-implement/help.md +138 -47
  251. package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
  252. package/plugin/skills/ase-task-integrate/help.md +187 -0
  253. package/plugin/skills/ase-task-list/SKILL.md +55 -23
  254. package/plugin/skills/ase-task-list/help.md +45 -21
  255. package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
  256. package/plugin/skills/ase-task-reboot/help.md +28 -8
  257. package/plugin/skills/ase-task-rename/SKILL.md +10 -2
  258. package/plugin/skills/ase-task-research/SKILL.md +386 -0
  259. package/plugin/skills/ase-task-research/help.md +102 -0
  260. package/plugin/skills/ase-task-status/SKILL.md +137 -0
  261. package/plugin/skills/ase-task-status/help.md +80 -0
  262. package/plugin/skills/ase-task-view/SKILL.md +49 -24
  263. package/plugin/skills/ase-task-view/help.md +19 -9
  264. package/plugin/agents/ase-meta-review.md +0 -187
  265. package/plugin/skills/ase-code-craft/SKILL.md +0 -264
  266. package/plugin/skills/ase-code-craft/help.md +0 -122
  267. package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
  268. package/plugin/skills/ase-code-refactor/help.md +0 -109
  269. package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
  270. package/plugin/skills/ase-code-resolve/help.md +0 -116
  271. package/plugin/skills/ase-meta-review/SKILL.md +0 -210
  272. package/plugin/skills/ase-meta-review/help.md +0 -96
  273. package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
  274. package/plugin/skills/ase-task-preflight/help.md +0 -82
  275. /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
  276. /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
  277. /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
  278. /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
  279. /package/dst/{ase-timestamp.js → ase-service-timestamp.js} +0 -0
@@ -13,24 +13,30 @@ import { ofetch } from "ofetch";
13
13
  import { isMap } from "yaml";
14
14
  import prettyMs from "pretty-ms";
15
15
  import * as v from "valibot";
16
+ import { Tail } from "tail";
16
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
18
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
- import { Config, configSchema, ConfigMCP } from "./ase-config.js";
19
- import { isLogLevel } from "./ase-log.js";
20
- import { CompatMCP } from "./ase-compat.js";
21
- import { DiagramMCP } from "./ase-diagram.js";
22
- import { TaskMCP } from "./ase-task.js";
23
- import { MarkdownMCP } from "./ase-markdown.js";
19
+ import { ConfigMCP } from "./ase-config.js";
20
+ import { Config } from "./ase-config-core.js";
21
+ import { configSchema } from "./ase-config-schema.js";
22
+ import { ensureAseGitignore } from "./ase-config-scope.js";
23
+ import { isLogLevel } from "./ase-lib-log.js";
24
+ import { CompatMCP } from "./ase-util-compat.js";
25
+ import { DiagramMCP } from "./ase-util-diagram.js";
26
+ import { Task, TaskMCP } from "./ase-task.js";
27
+ import { MarkdownMCP } from "./ase-service-markdown.js";
24
28
  import { ArtifactMCP } from "./ase-artifact.js";
25
29
  import { SpecMCP } from "./ase-spec.js";
26
- import { KVMCP } from "./ase-kv.js";
27
- import { TimestampMCP } from "./ase-timestamp.js";
28
- import { SleepMCP } from "./ase-sleep.js";
29
- import { GetoptMCP } from "./ase-getopt.js";
30
- import { SkillsMCP } from "./ase-skills.js";
31
- import { WorktreeMCP } from "./ase-worktree.js";
32
- import { MintMCP } from "./ase-mint.js";
33
- import { MetricMCP } from "./ase-metric.js";
30
+ import { KVMCP } from "./ase-service-kv.js";
31
+ import { StateMCP } from "./ase-service-state.js";
32
+ import { TimestampMCP } from "./ase-service-timestamp.js";
33
+ import { SleepMCP } from "./ase-service-sleep.js";
34
+ import { GetoptMCP } from "./ase-service-getopt.js";
35
+ import { SkillsMCP } from "./ase-service-skills.js";
36
+ import { WorktreeMCP } from "./ase-util-worktree.js";
37
+ import { MintMCP } from "./ase-util-mint.js";
38
+ import { MetricMCP } from "./ase-util-metric.js";
39
+ import { UsageTracker } from "./ase-service-otel.js";
34
40
  import pkg from "../package.json" with { type: "json" };
35
41
  /* shared service host */
36
42
  export const SERVICE_HOST = "127.0.0.1";
@@ -88,7 +94,7 @@ export const loadServiceContext = (log) => {
88
94
  const svc = new Config("service", serviceSchema, log);
89
95
  svc.read();
90
96
  /* determine project id */
91
- const projectId = cfg.get("project.id") ?? path.basename(process.cwd());
97
+ const projectId = cfg.get("project.id") || Task.projectIdOf(Task.projectRoot());
92
98
  /* determine service port */
93
99
  const port = svc.get("port") ?? null;
94
100
  /* determine path to ".ase" directory */
@@ -122,6 +128,7 @@ export class Service {
122
128
  }
123
129
  /* persist an allocated port into ".ase/service.yaml" */
124
130
  static persistPort(svc, port) {
131
+ ensureAseGitignore(path.dirname(svc.filename));
125
132
  svc.lock(() => {
126
133
  svc.read();
127
134
  svc.set("port", port);
@@ -159,6 +166,7 @@ export class Service {
159
166
  /* spawn the current executable detached as a background service */
160
167
  static spawnDetached(aseDir, port, logLevel) {
161
168
  fs.mkdirSync(aseDir, { recursive: true });
169
+ ensureAseGitignore(aseDir);
162
170
  const logFile = path.join(aseDir, "service.log");
163
171
  /* trim the log before handing it to the service, as the detached
164
172
  service inherits the file descriptor for its entire lifetime and
@@ -208,6 +216,34 @@ export class Service {
208
216
  fs.closeSync(fd);
209
217
  }
210
218
  }
219
+ /* show a log file on stdout, either completely or its last <lines> lines only, and
220
+ optionally follow it for newly appended lines (default: the last 10 lines first),
221
+ where each line is passed through <transform> (e.g. to colorize parts of it) */
222
+ static async showLog(logFile, opts, transform = (line) => line) {
223
+ const lines = opts.lines !== undefined ? Number(opts.lines) : null;
224
+ if (lines !== null && (!Number.isInteger(lines) || lines < 1))
225
+ throw new Error(`invalid number of lines: ${opts.lines}`);
226
+ if (!fs.existsSync(logFile))
227
+ return 0;
228
+ if (opts.follow) {
229
+ /* follow until interrupted (on log trimming,
230
+ "tail" continues at the new end of the file) */
231
+ const tail = new Tail(logFile, { nLines: lines ?? 10 });
232
+ tail.on("line", (line) => {
233
+ process.stdout.write(`${transform(line)}\n`);
234
+ });
235
+ return await new Promise((_resolve, reject) => {
236
+ tail.on("error", (err) => {
237
+ reject(err instanceof Error ? err : new Error(String(err)));
238
+ });
239
+ });
240
+ }
241
+ const text = lines !== null ?
242
+ Service.readLogTail(logFile, lines) :
243
+ fs.readFileSync(logFile, "utf8").replace(/\n$/, "");
244
+ process.stdout.write(text.length > 0 ? `${text.split("\n").map(transform).join("\n")}\n` : "");
245
+ return 0;
246
+ }
211
247
  }
212
248
  /* MCP registration entry point for service-identity tools */
213
249
  export class ServiceMCP {
@@ -243,8 +279,56 @@ export default class ServiceCommand {
243
279
  loadContext() {
244
280
  return loadServiceContext(this.log);
245
281
  }
246
- /* service-side: bind HAPI server until "/stop" command is received or idle timeout happens */
247
- async runService(ctx) {
282
+ /* build a fresh MCP server instance with all registered tools
283
+ (also used by the "ase mcp" bridge for answering the MCP handshake
284
+ and tool listing in-process before the service is started) */
285
+ static buildMcpServer(log, ctx, startTime, usage) {
286
+ const mcp = new McpServer({ name: "ase", version: pkg.version });
287
+ new ServiceMCP({ projectId: ctx.projectId, port: ctx.port, startTime }).register(mcp);
288
+ new CompatMCP().register(mcp);
289
+ new DiagramMCP().register(mcp);
290
+ new TaskMCP(log, usage).register(mcp);
291
+ new MarkdownMCP().register(mcp);
292
+ new ArtifactMCP(log).register(mcp);
293
+ new SpecMCP(log).register(mcp);
294
+ new KVMCP().register(mcp);
295
+ new TimestampMCP().register(mcp);
296
+ new SleepMCP().register(mcp);
297
+ new GetoptMCP().register(mcp);
298
+ new SkillsMCP().register(mcp);
299
+ new WorktreeMCP().register(mcp);
300
+ new MintMCP().register(mcp);
301
+ new MetricMCP().register(mcp);
302
+ new ConfigMCP(log).register(mcp);
303
+ new StateMCP(log).register(mcp);
304
+ return mcp;
305
+ }
306
+ /* summarize the method, tool name, and capped tool arguments of an MCP request body */
307
+ static mcpBodyInfo(body) {
308
+ const b = body;
309
+ const bParams = b?.params;
310
+ const bMethod = typeof b?.method === "string" ? b.method : null;
311
+ const bName = typeof bParams?.name === "string" ? bParams.name : null;
312
+ const bArgs = bParams?.arguments !== undefined ? bParams.arguments : null;
313
+ let info = "";
314
+ if (bMethod !== null) {
315
+ info = ` [${bMethod}]`;
316
+ if (bName !== null) {
317
+ info += ` ${bName}`;
318
+ if (bArgs !== null) {
319
+ /* cap the arguments, as payload-carrying tool calls
320
+ (task plans, key/value batches, etc) would
321
+ otherwise dominate the entire log file */
322
+ const args = JSON.stringify(bArgs);
323
+ info += ` ${args.length > LOG_ARGS_MAX ? `${args.slice(0, LOG_ARGS_MAX)}…` : args}`;
324
+ }
325
+ }
326
+ }
327
+ return { method: bMethod, info };
328
+ }
329
+ /* service-side: bind HAPI server until "/stop" command is received or idle timeout happens
330
+ (or, in the foreground, until "/stop" command is received or a termination signal arrives) */
331
+ async runService(ctx, foreground = false) {
248
332
  /* establish HAPI HTTP/REST service */
249
333
  const server = Hapi.server({ host: HOST, port: ctx.port });
250
334
  /* track start time and last activity */
@@ -252,9 +336,14 @@ export default class ServiceCommand {
252
336
  let lastActivity = Date.now();
253
337
  let inFlight = 0;
254
338
  let stopping = false;
255
- server.ext("onRequest", (_request, h) => {
339
+ /* accept only loopback "Host" headers, to defeat DNS rebinding attacks from browser pages */
340
+ const allowedHosts = new Set([`${HOST}:${ctx.port}`, `localhost:${ctx.port}`]);
341
+ server.ext("onRequest", (request, h) => {
256
342
  inFlight++;
257
343
  lastActivity = Date.now();
344
+ const host = (request.headers.host ?? "").toLowerCase();
345
+ if (!allowedHosts.has(host))
346
+ return h.response({ error: "invalid Host header" }).code(421).takeover();
258
347
  return h.continue;
259
348
  });
260
349
  server.ext("onPreResponse", (_request, h) => {
@@ -262,27 +351,10 @@ export default class ServiceCommand {
262
351
  lastActivity = Date.now();
263
352
  return h.continue;
264
353
  });
265
- /* build a fresh MCP server instance with all registered tools */
266
- const buildMcpServer = () => {
267
- const mcp = new McpServer({ name: "ase", version: pkg.version });
268
- new ServiceMCP({ projectId: ctx.projectId, port: ctx.port, startTime }).register(mcp);
269
- new CompatMCP().register(mcp);
270
- new DiagramMCP().register(mcp);
271
- new TaskMCP(this.log).register(mcp);
272
- new MarkdownMCP().register(mcp);
273
- new ArtifactMCP(this.log).register(mcp);
274
- new SpecMCP(this.log).register(mcp);
275
- new KVMCP().register(mcp);
276
- new TimestampMCP().register(mcp);
277
- new SleepMCP().register(mcp);
278
- new GetoptMCP().register(mcp);
279
- new SkillsMCP().register(mcp);
280
- new WorktreeMCP().register(mcp);
281
- new MintMCP().register(mcp);
282
- new MetricMCP().register(mcp);
283
- new ConfigMCP(this.log).register(mcp);
284
- return mcp;
285
- };
354
+ /* establish the usage tracker of the AI usage accounting (and the OTLP ingress) */
355
+ const usage = new UsageTracker(this.log, Task.projectRoot(), ctx.projectId, ctx.aseDir, () => {
356
+ lastActivity = Date.now();
357
+ });
286
358
  /* listen to HTTP/REST endpoints */
287
359
  server.route({
288
360
  method: "OPTIONS",
@@ -292,9 +364,14 @@ export default class ServiceCommand {
292
364
  }
293
365
  });
294
366
  server.route({
295
- method: "GET",
367
+ method: "POST",
296
368
  path: "/stop",
297
- handler: (_request, h) => {
369
+ handler: (request, h) => {
370
+ /* require a JSON content type, as browsers cannot send it cross-origin
371
+ without a CORS preflight (which this service never grants) */
372
+ const ct = (request.headers["content-type"] ?? "").toLowerCase();
373
+ if (!ct.startsWith("application/json"))
374
+ return h.response({ error: "unsupported media type" }).code(415);
298
375
  this.log.write("info", "service: stop requested");
299
376
  setImmediate(async () => {
300
377
  try {
@@ -304,38 +381,21 @@ export default class ServiceCommand {
304
381
  const e = err;
305
382
  this.log.write("error", `service: stop failed: ${e.message}`);
306
383
  }
384
+ await usage.stop();
307
385
  process.exit(0);
308
386
  });
309
387
  return h.response({ ok: true }).code(200);
310
388
  }
311
389
  });
312
390
  const mcpHandler = async (request, h, body) => {
313
- const b = body;
314
- const bParams = b?.params;
315
- const bMethod = typeof b?.method === "string" ? b.method : null;
316
- const bName = typeof bParams?.name === "string" ? bParams.name : null;
317
- const bArgs = bParams?.arguments !== undefined ? bParams.arguments : null;
318
- let bodyInfo = "";
319
- if (bMethod !== null) {
320
- bodyInfo = ` [${bMethod}]`;
321
- if (bName !== null) {
322
- bodyInfo += ` ${bName}`;
323
- if (bArgs !== null) {
324
- /* cap the arguments, as payload-carrying tool calls
325
- (task plans, key/value batches, etc) would
326
- otherwise dominate the entire log file */
327
- const args = JSON.stringify(bArgs);
328
- bodyInfo += ` ${args.length > LOG_ARGS_MAX ? `${args.slice(0, LOG_ARGS_MAX)}…` : args}`;
329
- }
330
- }
331
- }
391
+ const { method: bMethod, info: bodyInfo } = ServiceCommand.mcpBodyInfo(body);
332
392
  /* log tool calls regularly, but all remaining MCP traffic
333
393
  (session handshakes, notifications, SSE stream opens) at
334
394
  debug level only, as it carries no diagnostic value */
335
395
  const level = bMethod === "tools/call" ? "info" : "debug";
336
396
  this.log.write(level, `mcp: ${request.method.toUpperCase()} ${request.path}${bodyInfo}`);
337
397
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
338
- const mcp = buildMcpServer();
398
+ const mcp = ServiceCommand.buildMcpServer(this.log, ctx, startTime, usage);
339
399
  request.raw.res.on("close", () => {
340
400
  /* "h.abandon" (see below) bypasses "onPreResponse",
341
401
  so undo the "onRequest" accounting here instead */
@@ -390,11 +450,77 @@ export default class ServiceCommand {
390
450
  return h.response({ error: "unknown command", command: cmd }).code(400);
391
451
  }
392
452
  });
453
+ /* listen to the endpoints of the AI usage accounting: the usage records forwarded
454
+ by the OTLP ingress, the session end, the task assignment on behalf of a session,
455
+ and the task rename */
456
+ server.route({
457
+ method: "POST",
458
+ path: "/usage/records",
459
+ options: { payload: { parse: true, allow: "application/json", maxBytes: 32 * 1024 * 1024 } },
460
+ handler: (request, h) => {
461
+ const payload = request.payload;
462
+ if (!Array.isArray(payload?.records))
463
+ return h.response({ error: "missing or invalid 'records' field" }).code(400);
464
+ usage.ingest(payload.records);
465
+ return h.response({ ok: true }).code(200);
466
+ }
467
+ });
468
+ server.route({
469
+ method: "POST",
470
+ path: "/usage/detach",
471
+ options: { payload: { parse: true, allow: "application/json" } },
472
+ handler: (request, h) => {
473
+ const payload = request.payload;
474
+ if (typeof payload?.session !== "string")
475
+ return h.response({ error: "missing or invalid 'session' field" }).code(400);
476
+ usage.detach(payload.session);
477
+ return h.response({ ok: true }).code(200);
478
+ }
479
+ });
480
+ server.route({
481
+ method: "POST",
482
+ path: "/usage/rename",
483
+ options: { payload: { parse: true, allow: "application/json" } },
484
+ handler: (request, h) => {
485
+ const payload = request.payload;
486
+ if (typeof payload?.old !== "string" || typeof payload.new !== "string")
487
+ return h.response({ error: "missing or invalid 'old' or 'new' field" }).code(400);
488
+ try {
489
+ usage.rename(payload.old, payload.new);
490
+ }
491
+ catch (err) {
492
+ return h.response({ error: err instanceof Error ? err.message : String(err) }).code(422);
493
+ }
494
+ return h.response({ ok: true }).code(200);
495
+ }
496
+ });
497
+ server.route({
498
+ method: "POST",
499
+ path: "/usage/assign",
500
+ options: { payload: { parse: true, allow: "application/json" } },
501
+ handler: async (request, h) => {
502
+ const payload = request.payload;
503
+ if (typeof payload?.id !== "string" || typeof payload.assignee !== "string" || typeof payload.session !== "string")
504
+ return h.response({ error: "missing or invalid 'id', 'assignee', or 'session' field" }).code(400);
505
+ try {
506
+ return h.response(await usage.assign(payload.id, payload.assignee, payload.session)).code(200);
507
+ }
508
+ catch (err) {
509
+ return h.response({ error: err instanceof Error ? err.message : String(err) }).code(422);
510
+ }
511
+ }
512
+ });
513
+ /* serve the web board of the project (imported dynamically to keep it out of all
514
+ other CLI commands; its rendering dependencies marked, MRCS, and ELK load on
515
+ first request only, its board core dependencies at service startup) */
516
+ const { registerBoardRoutes } = await import("./ase-task-board-web-server.js");
517
+ registerBoardRoutes(server, this.log);
393
518
  /* start service */
394
519
  try {
395
520
  await server.start();
396
521
  Service.persistPort(ctx.svc, ctx.port);
397
522
  this.log.write("info", `service: listening on port ${ctx.port}`);
523
+ usage.start();
398
524
  }
399
525
  catch (err) {
400
526
  const e = err;
@@ -410,31 +536,60 @@ export default class ServiceCommand {
410
536
  this.log.write("error", `service: ${e.message}`);
411
537
  process.exit(1);
412
538
  }
539
+ /* stop service in the foreground on termination signal */
540
+ if (foreground) {
541
+ const shutdown = async (signal) => {
542
+ if (stopping)
543
+ return;
544
+ stopping = true;
545
+ this.log.write("info", `service: received ${signal}, stopping`);
546
+ try {
547
+ await server.stop({ timeout: 1000 });
548
+ }
549
+ catch (err) {
550
+ const e = err;
551
+ this.log.write("error", `service: stop failed: ${e.message}`);
552
+ }
553
+ await usage.stop();
554
+ Service.clearPort(ctx.svc);
555
+ process.exit(0);
556
+ };
557
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
558
+ process.on("SIGINT", () => shutdown("SIGINT"));
559
+ process.on("SIGHUP", () => shutdown("SIGHUP"));
560
+ process.on("SIGQUIT", () => shutdown("SIGQUIT"));
561
+ return;
562
+ }
413
563
  /* stop service after idle timeout */
414
564
  setInterval(async () => {
415
565
  if (stopping)
416
566
  return;
417
567
  if (inFlight > 0)
418
568
  return;
569
+ /* never idle out while holding usage windows, as their sessions keep on reporting */
570
+ if (usage.active())
571
+ return;
419
572
  if (Date.now() - lastActivity > IDLE_MS) {
420
573
  stopping = true;
421
574
  this.log.write("info", "service: idle timeout reached, stopping");
422
575
  try {
423
576
  await server.stop({ timeout: 1000 });
577
+ await usage.stop();
424
578
  Service.clearPort(ctx.svc);
425
579
  process.exit(0);
426
580
  }
427
581
  catch (err) {
428
582
  const e = err;
429
583
  this.log.write("error", `service: stop failed: ${e.message}`);
584
+ await usage.stop();
430
585
  Service.clearPort(ctx.svc);
431
586
  process.exit(1);
432
587
  }
433
588
  }
434
589
  }, TICK_MS).unref();
435
590
  }
436
- /* start flow: ensure port, probe, optionally detach */
437
- async doStart() {
591
+ /* start flow: ensure port, probe, optionally detach (or run in the foreground) */
592
+ async doStart(opts = {}) {
438
593
  const ctx = this.loadContext();
439
594
  let port = ctx.port;
440
595
  if (process.env[SERVE_ENV] === "1") {
@@ -451,10 +606,18 @@ export default class ServiceCommand {
451
606
  if (port !== null) {
452
607
  const match = await probe(port, ctx.projectId).catch(() => null);
453
608
  if (match === true) {
609
+ if (opts.foreground)
610
+ throw new Error(`service: already running on port ${port} (stop it first with "ase service stop")`);
454
611
  this.log.write("info", `service: already running on port ${port}`);
455
612
  return 0;
456
613
  }
457
614
  }
615
+ /* run the service in the foreground, logging to the terminal and without idle timeout */
616
+ if (opts.foreground) {
617
+ port = await Service.allocatePort();
618
+ await this.runService({ ...ctx, port }, true);
619
+ return await new Promise(() => { });
620
+ }
458
621
  /* bounded retry across the bind/start TOCTOU window: on each attempt
459
622
  re-allocate, re-spawn; early-break on foreign listener */
460
623
  let lastErr = new Error("service failed to start within timeout");
@@ -524,12 +687,12 @@ export default class ServiceCommand {
524
687
  }
525
688
  throw lastErr;
526
689
  }
527
- /* build the "POST /command" request options for a command token */
528
- commandRequest(cmd, timeoutMs) {
690
+ /* build the JSON "POST" request options for a service endpoint */
691
+ postRequest(body, timeoutMs) {
529
692
  return {
530
693
  method: "POST",
531
694
  headers: { "Content-Type": "application/json" },
532
- body: { command: cmd },
695
+ body,
533
696
  signal: AbortSignal.timeout(timeoutMs),
534
697
  ignoreResponseError: true
535
698
  };
@@ -543,7 +706,7 @@ export default class ServiceCommand {
543
706
  }
544
707
  const match = await probe(ctx.port, ctx.projectId).catch(() => null);
545
708
  if (match === true) {
546
- const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, this.commandRequest("status", 2000));
709
+ const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, this.postRequest({ command: "status" }, 2000));
547
710
  const d = r._data;
548
711
  const uptimeMs = typeof d?.uptimeMs === "number" ? d.uptimeMs : 0;
549
712
  const uptime = prettyMs(uptimeMs, { verbose: true });
@@ -569,13 +732,18 @@ export default class ServiceCommand {
569
732
  if (await probe(ctx.port, ctx.projectId).catch(() => null) !== true)
570
733
  throw new Error(`service not responding on port ${ctx.port} after auto-start`);
571
734
  }
572
- const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, { ...this.commandRequest(cmd, 5000), responseType: "text" });
735
+ const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, { ...this.postRequest({ command: cmd }, 5000), responseType: "text" });
573
736
  const body = typeof r._data === "string" ? r._data : JSON.stringify(r._data);
574
737
  process.stdout.write(body);
575
738
  if (!body.endsWith("\n"))
576
739
  process.stdout.write("\n");
577
740
  return r.status >= 200 && r.status < 300 ? 0 : 1;
578
741
  }
742
+ /* log flow: show the (tail of the) log file and optionally follow it */
743
+ async doLog(opts) {
744
+ const ctx = this.loadContext();
745
+ return Service.showLog(path.join(ctx.aseDir, "service.log"), opts);
746
+ }
579
747
  /* stop flow: no-op if no port configured or connection refused */
580
748
  async doStop() {
581
749
  const ctx = this.loadContext();
@@ -593,11 +761,7 @@ export default class ServiceCommand {
593
761
  Service.clearPort(ctx.svc);
594
762
  return 0;
595
763
  }
596
- const r = await ofetch.raw(`http://${HOST}:${ctx.port}/stop`, {
597
- method: "GET",
598
- signal: AbortSignal.timeout(5000),
599
- ignoreResponseError: true
600
- });
764
+ const r = await ofetch.raw(`http://${HOST}:${ctx.port}/stop`, this.postRequest({}, 5000));
601
765
  const ok = r.status >= 200 && r.status < 300;
602
766
  Service.clearPort(ctx.svc);
603
767
  return ok ? 0 : 1;
@@ -615,9 +779,11 @@ export default class ServiceCommand {
615
779
  /* register CLI sub-command "ase service start" */
616
780
  service
617
781
  .command("start")
618
- .description("Start the background service")
619
- .action(async () => {
620
- process.exit(await this.doStart());
782
+ .description("Start the background service (or run it in the foreground)")
783
+ .option("-f, --foreground", "run the service in the foreground, logging to the terminal, " +
784
+ "until it is interrupted or stopped (without idle timeout)")
785
+ .action(async (opts) => {
786
+ process.exit(await this.doStart(opts));
621
787
  });
622
788
  /* register CLI sub-command "ase service status" */
623
789
  service
@@ -634,6 +800,15 @@ export default class ServiceCommand {
634
800
  .action(async (cmd) => {
635
801
  process.exit(await this.doSend(cmd));
636
802
  });
803
+ /* register CLI sub-command "ase service log" */
804
+ service
805
+ .command("log")
806
+ .description("Show the log of the background service")
807
+ .option("-f, --follow", "keep following the log for newly appended lines")
808
+ .option("-n, --lines <n>", "show the last <n> lines only (default: all, or 10 with --follow)")
809
+ .action(async (opts) => {
810
+ process.exit(await this.doLog(opts));
811
+ });
637
812
  /* register CLI sub-command "ase service stop" */
638
813
  service
639
814
  .command("stop")
@@ -0,0 +1,141 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { execa } from "execa";
9
+ import which from "which";
10
+ export const toolSpecs = {
11
+ "claude": { cli: "claude", label: "Anthropic Claude Code CLI", pInstall: "install", pRemove: "uninstall", pUpdate: "update" },
12
+ "copilot": { cli: "copilot", label: "GitHub Copilot CLI", pInstall: "install", pRemove: "uninstall", pUpdate: "update" },
13
+ "codex": { cli: "codex", label: "OpenAI Codex CLI", pInstall: "add", pRemove: "remove", pUpdate: "upgrade" }
14
+ };
15
+ /* parse and validate the --tool option */
16
+ export const parseTool = (value) => {
17
+ if (value !== "claude" && value !== "copilot" && value !== "codex")
18
+ throw new Error(`invalid --tool value: "${value}" (expected "claude", "copilot", or "codex")`);
19
+ return value;
20
+ };
21
+ /* parse and validate the --scope option */
22
+ export const parseScope = (value) => {
23
+ if (value !== "user" && value !== "project" && value !== "local")
24
+ throw new Error(`invalid --scope value: "${value}" (expected "user", "project", or "local")`);
25
+ return value;
26
+ };
27
+ /* reject a non-default --scope for tools which have no scope concept
28
+ at all (only Anthropic Claude Code CLI supports plugin/MCP scopes) */
29
+ export const requireClaudeScope = (tool, scope) => {
30
+ if (tool !== "claude" && scope !== "user")
31
+ throw new Error("--scope is only supported for --tool claude");
32
+ };
33
+ /* sub-process runner of "ase setup" */
34
+ export class SetupRunner {
35
+ log;
36
+ constructor(log) {
37
+ this.log = log;
38
+ }
39
+ /* ensure a tool is available */
40
+ async ensureTool(tool) {
41
+ return which(tool).catch(() => {
42
+ throw new Error(`mandatory tool "${tool}" not found in $PATH`);
43
+ });
44
+ }
45
+ /* determine whether a global "npm" operation requires "sudo" by
46
+ checking whether the npm global install root is writable by the
47
+ current user; on Windows or when already running as root, no
48
+ elevation is needed */
49
+ async npmGlobalNeedsSudo() {
50
+ /* Windows has no "sudo" concept here */
51
+ if (process.platform === "win32")
52
+ return false;
53
+ /* already running as root */
54
+ const getuid = process.getuid;
55
+ if (typeof getuid === "function" && getuid.call(process) === 0)
56
+ return false;
57
+ /* determine the npm global prefix and probe writability of the
58
+ directories that "npm -g" actually mutates */
59
+ let prefix;
60
+ try {
61
+ const result = await execa("npm", ["prefix", "-g"], { stdio: "pipe" });
62
+ prefix = result.stdout.trim();
63
+ }
64
+ catch {
65
+ /* if we cannot determine the prefix, fall back to "no sudo" */
66
+ return false;
67
+ }
68
+ if (prefix === "")
69
+ return false;
70
+ const candidates = [
71
+ prefix,
72
+ path.join(prefix, "bin"),
73
+ path.join(prefix, "lib", "node_modules")
74
+ ];
75
+ const accessible = (dir, mode) => fs.access(dir, mode).then(() => true, () => false);
76
+ for (const dir of candidates) {
77
+ /* a writable directory needs no elevation at all */
78
+ if (await accessible(dir, fs.constants.W_OK))
79
+ continue;
80
+ /* the directory exists, but is not writable: require sudo */
81
+ if (await accessible(dir, fs.constants.F_OK))
82
+ return true;
83
+ /* the directory does not exist: require sudo unless it can
84
+ be created inside a writable parent directory */
85
+ if (!(await accessible(path.dirname(dir), fs.constants.W_OK)))
86
+ return true;
87
+ }
88
+ return false;
89
+ }
90
+ /* build the (cmd, args) pair for an "npm" invocation, prefixing
91
+ with "sudo" when necessary for global operations */
92
+ async npmCmd(args, global) {
93
+ if (global && await this.npmGlobalNeedsSudo()) {
94
+ const sudo = await which("sudo").catch(() => "");
95
+ if (sudo !== "") {
96
+ this.log.write("info", "setup: npm global install root not writable: using \"sudo\"");
97
+ return { cmd: "sudo", args: ["npm", ...args] };
98
+ }
99
+ this.log.write("warning", "setup: npm global install root is not writable by current user " +
100
+ "and \"sudo\" not found in $PATH: attempting without elevation");
101
+ }
102
+ return { cmd: "npm", args };
103
+ }
104
+ /* run a sub-process, suppressing output on success and emitting it on failure */
105
+ async run(cmd, args, opts = {}) {
106
+ const { cwd, quiet = false, retries = 1, ignoreError } = opts;
107
+ const argsLog = args.map((arg) => arg.replace(/(\w*key=)([^\s&]+)/gi, (_, k, v) => k + "*".repeat(v.length)));
108
+ this.log.write("info", `setup: $ ${cmd} ${argsLog.join(" ")}` +
109
+ (cwd !== undefined ? ` (cwd: ${cwd})` : ""));
110
+ for (let i = 0; i < retries; i++) {
111
+ const final = (i === retries - 1);
112
+ try {
113
+ await execa(cmd, args, { stdio: quiet ? "ignore" : "pipe", cwd });
114
+ return;
115
+ }
116
+ catch (err) {
117
+ if (!final) {
118
+ this.log.write("info", `setup: attempt ${i + 1}/${retries} failed for "${cmd} ${argsLog.join(" ")}": retrying...`);
119
+ await new Promise((resolve) => setTimeout(resolve, 1000));
120
+ continue;
121
+ }
122
+ if (ignoreError !== undefined) {
123
+ this.log.write("info", `setup: ${ignoreError} (skipped)`);
124
+ return;
125
+ }
126
+ const e = err;
127
+ const exitCode = typeof e.exitCode === "number" ? e.exitCode : -1;
128
+ this.log.write("error", `setup: command failed: exit code: ${exitCode}`);
129
+ if (typeof e.stdout === "string" && e.stdout.length > 0) {
130
+ this.log.write("error", "setup: command failed: stdout:");
131
+ process.stdout.write(e.stdout);
132
+ }
133
+ if (typeof e.stderr === "string" && e.stderr.length > 0) {
134
+ this.log.write("error", "setup: command failed: stderr:");
135
+ process.stderr.write(e.stderr);
136
+ }
137
+ throw err;
138
+ }
139
+ }
140
+ }
141
+ }