@pleri/olam-cli 0.1.7

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 (196) hide show
  1. package/dist/__tests__/auth-status.test.d.ts +2 -0
  2. package/dist/__tests__/auth-status.test.d.ts.map +1 -0
  3. package/dist/__tests__/auth-status.test.js +290 -0
  4. package/dist/__tests__/auth-status.test.js.map +1 -0
  5. package/dist/__tests__/auth-upgrade.test.d.ts +9 -0
  6. package/dist/__tests__/auth-upgrade.test.d.ts.map +1 -0
  7. package/dist/__tests__/auth-upgrade.test.js +161 -0
  8. package/dist/__tests__/auth-upgrade.test.js.map +1 -0
  9. package/dist/__tests__/create-app-urls.test.d.ts +2 -0
  10. package/dist/__tests__/create-app-urls.test.d.ts.map +1 -0
  11. package/dist/__tests__/create-app-urls.test.js +102 -0
  12. package/dist/__tests__/create-app-urls.test.js.map +1 -0
  13. package/dist/__tests__/enter.test.d.ts +2 -0
  14. package/dist/__tests__/enter.test.d.ts.map +1 -0
  15. package/dist/__tests__/enter.test.js +90 -0
  16. package/dist/__tests__/enter.test.js.map +1 -0
  17. package/dist/__tests__/host-cp-gh-token.test.d.ts +9 -0
  18. package/dist/__tests__/host-cp-gh-token.test.d.ts.map +1 -0
  19. package/dist/__tests__/host-cp-gh-token.test.js +119 -0
  20. package/dist/__tests__/host-cp-gh-token.test.js.map +1 -0
  21. package/dist/__tests__/host-cp.test.d.ts +9 -0
  22. package/dist/__tests__/host-cp.test.d.ts.map +1 -0
  23. package/dist/__tests__/host-cp.test.js +254 -0
  24. package/dist/__tests__/host-cp.test.js.map +1 -0
  25. package/dist/__tests__/keys.test.d.ts +9 -0
  26. package/dist/__tests__/keys.test.d.ts.map +1 -0
  27. package/dist/__tests__/keys.test.js +145 -0
  28. package/dist/__tests__/keys.test.js.map +1 -0
  29. package/dist/__tests__/logs.test.d.ts +9 -0
  30. package/dist/__tests__/logs.test.d.ts.map +1 -0
  31. package/dist/__tests__/logs.test.js +124 -0
  32. package/dist/__tests__/logs.test.js.map +1 -0
  33. package/dist/__tests__/ps.test.d.ts +2 -0
  34. package/dist/__tests__/ps.test.d.ts.map +1 -0
  35. package/dist/__tests__/ps.test.js +172 -0
  36. package/dist/__tests__/ps.test.js.map +1 -0
  37. package/dist/__tests__/status-app-urls.test.d.ts +2 -0
  38. package/dist/__tests__/status-app-urls.test.d.ts.map +1 -0
  39. package/dist/__tests__/status-app-urls.test.js +125 -0
  40. package/dist/__tests__/status-app-urls.test.js.map +1 -0
  41. package/dist/__tests__/upgrade.test.d.ts +9 -0
  42. package/dist/__tests__/upgrade.test.d.ts.map +1 -0
  43. package/dist/__tests__/upgrade.test.js +262 -0
  44. package/dist/__tests__/upgrade.test.js.map +1 -0
  45. package/dist/commands/__tests__/carry-uncommitted.test.d.ts +14 -0
  46. package/dist/commands/__tests__/carry-uncommitted.test.d.ts.map +1 -0
  47. package/dist/commands/__tests__/carry-uncommitted.test.js +83 -0
  48. package/dist/commands/__tests__/carry-uncommitted.test.js.map +1 -0
  49. package/dist/commands/__tests__/openHostCpUrl.test.d.ts +2 -0
  50. package/dist/commands/__tests__/openHostCpUrl.test.d.ts.map +1 -0
  51. package/dist/commands/__tests__/openHostCpUrl.test.js +63 -0
  52. package/dist/commands/__tests__/openHostCpUrl.test.js.map +1 -0
  53. package/dist/commands/__tests__/refresh.test.d.ts +13 -0
  54. package/dist/commands/__tests__/refresh.test.d.ts.map +1 -0
  55. package/dist/commands/__tests__/refresh.test.js +170 -0
  56. package/dist/commands/__tests__/refresh.test.js.map +1 -0
  57. package/dist/commands/auth-status.d.ts +43 -0
  58. package/dist/commands/auth-status.d.ts.map +1 -0
  59. package/dist/commands/auth-status.js +208 -0
  60. package/dist/commands/auth-status.js.map +1 -0
  61. package/dist/commands/auth-upgrade.d.ts +47 -0
  62. package/dist/commands/auth-upgrade.d.ts.map +1 -0
  63. package/dist/commands/auth-upgrade.js +277 -0
  64. package/dist/commands/auth-upgrade.js.map +1 -0
  65. package/dist/commands/auth.d.ts +16 -0
  66. package/dist/commands/auth.d.ts.map +1 -0
  67. package/dist/commands/auth.js +283 -0
  68. package/dist/commands/auth.js.map +1 -0
  69. package/dist/commands/create.d.ts +8 -0
  70. package/dist/commands/create.d.ts.map +1 -0
  71. package/dist/commands/create.js +512 -0
  72. package/dist/commands/create.js.map +1 -0
  73. package/dist/commands/crystallize.d.ts +8 -0
  74. package/dist/commands/crystallize.d.ts.map +1 -0
  75. package/dist/commands/crystallize.js +101 -0
  76. package/dist/commands/crystallize.js.map +1 -0
  77. package/dist/commands/destroy.d.ts +6 -0
  78. package/dist/commands/destroy.d.ts.map +1 -0
  79. package/dist/commands/destroy.js +54 -0
  80. package/dist/commands/destroy.js.map +1 -0
  81. package/dist/commands/dispatch.d.ts +9 -0
  82. package/dist/commands/dispatch.d.ts.map +1 -0
  83. package/dist/commands/dispatch.js +94 -0
  84. package/dist/commands/dispatch.js.map +1 -0
  85. package/dist/commands/enter.d.ts +63 -0
  86. package/dist/commands/enter.d.ts.map +1 -0
  87. package/dist/commands/enter.js +206 -0
  88. package/dist/commands/enter.js.map +1 -0
  89. package/dist/commands/host-cp.d.ts +191 -0
  90. package/dist/commands/host-cp.d.ts.map +1 -0
  91. package/dist/commands/host-cp.js +797 -0
  92. package/dist/commands/host-cp.js.map +1 -0
  93. package/dist/commands/init.d.ts +9 -0
  94. package/dist/commands/init.d.ts.map +1 -0
  95. package/dist/commands/init.js +143 -0
  96. package/dist/commands/init.js.map +1 -0
  97. package/dist/commands/install.d.ts +22 -0
  98. package/dist/commands/install.d.ts.map +1 -0
  99. package/dist/commands/install.js +203 -0
  100. package/dist/commands/install.js.map +1 -0
  101. package/dist/commands/keys.d.ts +26 -0
  102. package/dist/commands/keys.d.ts.map +1 -0
  103. package/dist/commands/keys.js +151 -0
  104. package/dist/commands/keys.js.map +1 -0
  105. package/dist/commands/lanes.d.ts +18 -0
  106. package/dist/commands/lanes.d.ts.map +1 -0
  107. package/dist/commands/lanes.js +122 -0
  108. package/dist/commands/lanes.js.map +1 -0
  109. package/dist/commands/list.d.ts +6 -0
  110. package/dist/commands/list.d.ts.map +1 -0
  111. package/dist/commands/list.js +39 -0
  112. package/dist/commands/list.js.map +1 -0
  113. package/dist/commands/logs.d.ts +38 -0
  114. package/dist/commands/logs.d.ts.map +1 -0
  115. package/dist/commands/logs.js +177 -0
  116. package/dist/commands/logs.js.map +1 -0
  117. package/dist/commands/observe.d.ts +9 -0
  118. package/dist/commands/observe.d.ts.map +1 -0
  119. package/dist/commands/observe.js +34 -0
  120. package/dist/commands/observe.js.map +1 -0
  121. package/dist/commands/policy-check.d.ts +14 -0
  122. package/dist/commands/policy-check.d.ts.map +1 -0
  123. package/dist/commands/policy-check.js +76 -0
  124. package/dist/commands/policy-check.js.map +1 -0
  125. package/dist/commands/pr.d.ts +17 -0
  126. package/dist/commands/pr.d.ts.map +1 -0
  127. package/dist/commands/pr.js +148 -0
  128. package/dist/commands/pr.js.map +1 -0
  129. package/dist/commands/ps.d.ts +25 -0
  130. package/dist/commands/ps.d.ts.map +1 -0
  131. package/dist/commands/ps.js +164 -0
  132. package/dist/commands/ps.js.map +1 -0
  133. package/dist/commands/refresh-helpers.d.ts +25 -0
  134. package/dist/commands/refresh-helpers.d.ts.map +1 -0
  135. package/dist/commands/refresh-helpers.js +56 -0
  136. package/dist/commands/refresh-helpers.js.map +1 -0
  137. package/dist/commands/refresh.d.ts +23 -0
  138. package/dist/commands/refresh.d.ts.map +1 -0
  139. package/dist/commands/refresh.js +237 -0
  140. package/dist/commands/refresh.js.map +1 -0
  141. package/dist/commands/status.d.ts +6 -0
  142. package/dist/commands/status.d.ts.map +1 -0
  143. package/dist/commands/status.js +51 -0
  144. package/dist/commands/status.js.map +1 -0
  145. package/dist/commands/upgrade.d.ts +67 -0
  146. package/dist/commands/upgrade.d.ts.map +1 -0
  147. package/dist/commands/upgrade.js +358 -0
  148. package/dist/commands/upgrade.js.map +1 -0
  149. package/dist/commands/workspace.d.ts +23 -0
  150. package/dist/commands/workspace.d.ts.map +1 -0
  151. package/dist/commands/workspace.js +198 -0
  152. package/dist/commands/workspace.js.map +1 -0
  153. package/dist/commands/world-snapshot.d.ts +18 -0
  154. package/dist/commands/world-snapshot.d.ts.map +1 -0
  155. package/dist/commands/world-snapshot.js +327 -0
  156. package/dist/commands/world-snapshot.js.map +1 -0
  157. package/dist/context.d.ts +26 -0
  158. package/dist/context.d.ts.map +1 -0
  159. package/dist/context.js +51 -0
  160. package/dist/context.js.map +1 -0
  161. package/dist/index.d.ts +9 -0
  162. package/dist/index.d.ts.map +1 -0
  163. package/dist/index.js +18007 -0
  164. package/dist/index.js.map +1 -0
  165. package/dist/mcp-server.js +32236 -0
  166. package/dist/output.d.ts +10 -0
  167. package/dist/output.d.ts.map +1 -0
  168. package/dist/output.js +31 -0
  169. package/dist/output.js.map +1 -0
  170. package/host-cp/compose.yaml +126 -0
  171. package/host-cp/src/auth-secret-hint.mjs +45 -0
  172. package/host-cp/src/auth.mjs +155 -0
  173. package/host-cp/src/compose-worlds-sources.mjs +170 -0
  174. package/host-cp/src/container-secret-fetcher.mjs +163 -0
  175. package/host-cp/src/docker-events.mjs +184 -0
  176. package/host-cp/src/local-worlds-source.mjs +83 -0
  177. package/host-cp/src/plan-orchestrator.mjs +829 -0
  178. package/host-cp/src/plan-progress.mjs +282 -0
  179. package/host-cp/src/pr-cache.mjs +201 -0
  180. package/host-cp/src/pr-merge-poller.mjs +154 -0
  181. package/host-cp/src/process-poller.mjs +250 -0
  182. package/host-cp/src/proxy.mjs +245 -0
  183. package/host-cp/src/pylon-worlds-source.mjs +68 -0
  184. package/host-cp/src/redact.mjs +67 -0
  185. package/host-cp/src/secret-cache.mjs +104 -0
  186. package/host-cp/src/server.mjs +2215 -0
  187. package/host-cp/src/sse-gate.mjs +117 -0
  188. package/host-cp/src/version-status.mjs +209 -0
  189. package/host-cp/src/workspace-catalog.mjs +149 -0
  190. package/host-cp/src/world-names-store.mjs +176 -0
  191. package/host-cp/src/world-pr-state.mjs +97 -0
  192. package/host-cp/src/world-progress.mjs +322 -0
  193. package/host-cp/src/world-tunnel-manager.mjs +288 -0
  194. package/host-cp/src/worlds-db-source.mjs +191 -0
  195. package/host-cp/src/worlds-source.mjs +59 -0
  196. package/package.json +38 -0
@@ -0,0 +1,117 @@
1
+ // Phase F-2-B (B5): SSE concurrent-connection gate + path detection.
2
+ //
3
+ // Background. Each open SSE proxy holds:
4
+ // - A Node http.ClientRequest to the per-world CP (one fd)
5
+ // - The browser's incoming socket (one fd)
6
+ // Plus the Node event loop wakes on every chunk. With N worlds × M tabs
7
+ // × Sse-per-tab, the FD budget grows linearly. P3 budgets ≤100 concurrent
8
+ // SSE proxies; P4 caps at 50 + returns 503 with Retry-After: 30 above
9
+ // that. Below the cap there's no impact.
10
+ //
11
+ // Cap semantics:
12
+ // - increment() returns true if we're allowed to open; false → reject.
13
+ // - decrement() is idempotent + fire-once via the FiredFlag pattern
14
+ // because Node emits both 'close' and 'finish' on a normal stream
15
+ // end. Without idempotency the counter would underflow.
16
+ //
17
+ // SSE detection is path-based (cheap; runs before opening upstream).
18
+ // Two patterns are SSE today:
19
+ // /api/stream — per-world CP's existing SSE feed
20
+ // /api/world/<id>/bootstrap-progress — placeholder for B7's UI strip
21
+ // (per-world CP route lands later)
22
+
23
+ const SSE_PATH_PATTERNS = [
24
+ /\/api\/stream(?:\/|$|\?)/,
25
+ /\/bootstrap-progress(?:\/|$|\?)/,
26
+ /\/api\/logs(?:\/|$|\?)/,
27
+ ];
28
+
29
+ /**
30
+ * Detect whether an upstream subPath represents an SSE stream. The
31
+ * subPath is the value emitted by `parseProxyPath()` — i.e., everything
32
+ * AFTER `/api/world/<id>`. So we match on the inner route, not the
33
+ * `/api/world/<id>` prefix.
34
+ *
35
+ * @param {string} subPath
36
+ * @returns {boolean}
37
+ */
38
+ export function isSsePath(subPath) {
39
+ return SSE_PATH_PATTERNS.some((re) => re.test(subPath));
40
+ }
41
+
42
+ export class SseGate {
43
+ /**
44
+ * @param {object} opts
45
+ * @param {number} [opts.maxConcurrent] default 50 (P4 cap)
46
+ * @param {(message: string) => void} [opts.log]
47
+ */
48
+ constructor({ maxConcurrent = 50, log = console.log } = {}) {
49
+ if (maxConcurrent < 1) {
50
+ throw new Error('SseGate: maxConcurrent must be >= 1');
51
+ }
52
+ this.maxConcurrent = maxConcurrent;
53
+ this.active = 0;
54
+ this.log = log;
55
+ }
56
+
57
+ /**
58
+ * Try to acquire a slot. If at cap, returns null + writes a 503 to
59
+ * res. Caller MUST check the return value.
60
+ *
61
+ * @param {import('node:http').ServerResponse} res
62
+ * @returns {{ release: () => void } | null}
63
+ */
64
+ acquire(res) {
65
+ if (this.active >= this.maxConcurrent) {
66
+ res.writeHead(503, {
67
+ 'Content-Type': 'application/json; charset=utf-8',
68
+ 'Retry-After': '30',
69
+ });
70
+ res.end(JSON.stringify({
71
+ error: 'sse_capacity_reached',
72
+ active: this.active,
73
+ cap: this.maxConcurrent,
74
+ retry_after_sec: 30,
75
+ message: 'host CP has reached the SSE concurrent-connection cap. Retry after the indicated delay or close idle SPA tabs.',
76
+ }));
77
+ this.log(`sse-gate: 503 — cap reached (active=${this.active}, cap=${this.maxConcurrent})`);
78
+ return null;
79
+ }
80
+ this.active++;
81
+ let released = false;
82
+ const release = () => {
83
+ if (released) return;
84
+ released = true;
85
+ this.active--;
86
+ };
87
+ return { release };
88
+ }
89
+
90
+ /** Diagnostics for /health. */
91
+ stats() {
92
+ return {
93
+ active: this.active,
94
+ cap: this.maxConcurrent,
95
+ };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Wire SSE-gate teardown to a ServerResponse's lifecycle. Node's
101
+ * http response emits 'close' (client disconnected) AND 'finish'
102
+ * (response.end() called) on different code paths. We want decrement
103
+ * exactly once per acquire(), regardless of which event fires first.
104
+ *
105
+ * The release closure is already idempotent (released flag). Wiring
106
+ * both events covers every termination path:
107
+ * - browser closes tab → 'close' on res
108
+ * - upstream EOF + res.end → 'finish' on res
109
+ * - error in proxy → 'close' on res (Node fires close on errors)
110
+ *
111
+ * @param {import('node:http').ServerResponse} res
112
+ * @param {() => void} release
113
+ */
114
+ export function wireRelease(res, release) {
115
+ res.on('close', release);
116
+ res.on('finish', release);
117
+ }
@@ -0,0 +1,209 @@
1
+ // Version detection for Phase 1 of self-upgrade.
2
+ //
3
+ // Compares each component's baked OLAM_BUILD_SHA against the operator's
4
+ // local repo HEAD (mounted read-only at /operator-repo). Reports upgrade
5
+ // availability without triggering any automatic action — Phase 1 is
6
+ // detection only.
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ /** @typedef {'ok' | 'behind' | 'unknown'} VersionState */
12
+
13
+ /**
14
+ * @typedef {Object} ComponentVersion
15
+ * @property {string} running - SHA baked into the running image
16
+ * @property {string} latest - SHA of operator's local HEAD (or 'unknown')
17
+ * @property {boolean} upgradeAvailable
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} VersionSnapshot
22
+ * @property {ComponentVersion} hostCp
23
+ * @property {ComponentVersion} authService
24
+ * @property {ComponentVersion} devbox
25
+ * @property {string} operatorHead - resolved HEAD or 'unknown'
26
+ * @property {string} checkedAt - ISO timestamp
27
+ */
28
+
29
+ /**
30
+ * Read the operator's local repo HEAD.
31
+ *
32
+ * Tries OLAM_REPO_PATH env var first, then /operator-repo (the compose-
33
+ * mounted path), then $HOME/Projects/ein-sof/olam as a bare-node fallback.
34
+ *
35
+ * Returns 'unknown' on any read error.
36
+ *
37
+ * @returns {string}
38
+ */
39
+ export function readOperatorHead() {
40
+ const candidates = [
41
+ process.env.OLAM_REPO_PATH,
42
+ '/operator-repo',
43
+ ].filter(Boolean);
44
+
45
+ for (const repoPath of candidates) {
46
+ try {
47
+ // Read HEAD to find the current branch ref (e.g. "ref: refs/heads/main")
48
+ // then resolve to the SHA.
49
+ const headFile = path.join(repoPath, '.git', 'HEAD');
50
+ if (!fs.existsSync(headFile)) continue;
51
+
52
+ const headContent = fs.readFileSync(headFile, 'utf-8').trim();
53
+
54
+ if (headContent.startsWith('ref: ')) {
55
+ // Symbolic ref → resolve to SHA via the packed-refs or loose ref.
56
+ const refPath = headContent.slice('ref: '.length);
57
+ const looseRef = path.join(repoPath, '.git', refPath);
58
+ if (fs.existsSync(looseRef)) {
59
+ return fs.readFileSync(looseRef, 'utf-8').trim();
60
+ }
61
+ // Try packed-refs fallback.
62
+ const packedRefs = path.join(repoPath, '.git', 'packed-refs');
63
+ if (fs.existsSync(packedRefs)) {
64
+ const lines = fs.readFileSync(packedRefs, 'utf-8').split('\n');
65
+ for (const line of lines) {
66
+ if (line.startsWith('#')) continue;
67
+ const [sha, ref] = line.trim().split(' ');
68
+ if (ref === refPath) return sha;
69
+ }
70
+ }
71
+ } else if (/^[0-9a-f]{40}$/i.test(headContent)) {
72
+ // Detached HEAD — use the SHA directly.
73
+ return headContent;
74
+ }
75
+ } catch {
76
+ // silently try next candidate
77
+ }
78
+ }
79
+ return 'unknown';
80
+ }
81
+
82
+ /**
83
+ * Compare two SHAs. Returns true when they differ and both are known.
84
+ * If either is 'unknown' we cannot assert an upgrade is available.
85
+ *
86
+ * @param {string} running
87
+ * @param {string} latest
88
+ * @returns {boolean}
89
+ */
90
+ export function isUpgradeAvailable(running, latest) {
91
+ if (running === 'unknown' || latest === 'unknown') return false;
92
+ // SHAs may be full (40 hex chars) or short (7+ hex chars from --short).
93
+ // Compare by checking if one is a prefix of the other.
94
+ const a = running.toLowerCase();
95
+ const b = latest.toLowerCase();
96
+ return !a.startsWith(b) && !b.startsWith(a);
97
+ }
98
+
99
+ /**
100
+ * Fetch the auth-service's /health endpoint and extract buildSha.
101
+ *
102
+ * @param {string} authServiceUrl
103
+ * @returns {Promise<string>}
104
+ */
105
+ export async function fetchAuthServiceSha(authServiceUrl) {
106
+ try {
107
+ const res = await fetch(`${authServiceUrl}/health`, {
108
+ signal: AbortSignal.timeout(5000),
109
+ });
110
+ if (!res.ok) return 'unknown';
111
+ const data = /** @type {unknown} */ (await res.json());
112
+ if (data && typeof data === 'object' && 'buildSha' in data) {
113
+ const sha = /** @type {Record<string, unknown>} */ (data)['buildSha'];
114
+ return typeof sha === 'string' ? sha : 'unknown';
115
+ }
116
+ return 'unknown';
117
+ } catch {
118
+ return 'unknown';
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Fetch the devbox image SHA. We check the running devbox container's
124
+ * OLAM_BUILD_SHA env var via the docker socket proxy (inspect endpoint).
125
+ * Returns 'unknown' if any step fails.
126
+ *
127
+ * @param {string} dockerApiBase e.g. "http://docker-socket-proxy:2375" or "http://localhost:2375"
128
+ * @returns {Promise<string>}
129
+ */
130
+ export async function fetchDevboxImageSha(dockerApiBase) {
131
+ try {
132
+ // List containers named olam-*-devbox and grab the first one.
133
+ const listRes = await fetch(
134
+ `${dockerApiBase}/containers/json?filters=${encodeURIComponent(JSON.stringify({ name: ['olam-devbox'] }))}`,
135
+ { signal: AbortSignal.timeout(5000) },
136
+ );
137
+ if (!listRes.ok) return 'unknown';
138
+ const containers = /** @type {unknown} */ (await listRes.json());
139
+ if (!Array.isArray(containers) || containers.length === 0) return 'unknown';
140
+
141
+ // Use the most recently-created devbox container's image ID.
142
+ // Inspect the image for OLAM_BUILD_SHA label or env.
143
+ const container = /** @type {Record<string, unknown>} */ (containers[0]);
144
+ const imageId = typeof container['ImageID'] === 'string' ? container['ImageID'] : null;
145
+ if (!imageId) return 'unknown';
146
+
147
+ const inspectRes = await fetch(
148
+ `${dockerApiBase}/images/${encodeURIComponent(imageId)}/json`,
149
+ { signal: AbortSignal.timeout(5000) },
150
+ );
151
+ if (!inspectRes.ok) return 'unknown';
152
+ const image = /** @type {unknown} */ (await inspectRes.json());
153
+ if (!image || typeof image !== 'object') return 'unknown';
154
+
155
+ const config = /** @type {Record<string, unknown>} */ (image)['Config'];
156
+ if (!config || typeof config !== 'object') return 'unknown';
157
+ const env = /** @type {Record<string, unknown>} */ (config)['Env'];
158
+ if (!Array.isArray(env)) return 'unknown';
159
+
160
+ for (const e of env) {
161
+ if (typeof e === 'string' && e.startsWith('OLAM_BUILD_SHA=')) {
162
+ return e.slice('OLAM_BUILD_SHA='.length);
163
+ }
164
+ }
165
+ return 'unknown';
166
+ } catch {
167
+ return 'unknown';
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Build a full VersionSnapshot from all available sources.
173
+ *
174
+ * @param {{
175
+ * authServiceUrl: string;
176
+ * dockerApiBase: string;
177
+ * }} opts
178
+ * @returns {Promise<VersionSnapshot>}
179
+ */
180
+ export async function buildVersionSnapshot({ authServiceUrl, dockerApiBase }) {
181
+ const operatorHead = readOperatorHead();
182
+
183
+ const [authSha, devboxSha] = await Promise.all([
184
+ fetchAuthServiceSha(authServiceUrl),
185
+ fetchDevboxImageSha(dockerApiBase),
186
+ ]);
187
+
188
+ const hostCpRunning = process.env.OLAM_BUILD_SHA ?? 'unknown';
189
+
190
+ return {
191
+ hostCp: {
192
+ running: hostCpRunning,
193
+ latest: operatorHead,
194
+ upgradeAvailable: isUpgradeAvailable(hostCpRunning, operatorHead),
195
+ },
196
+ authService: {
197
+ running: authSha,
198
+ latest: operatorHead,
199
+ upgradeAvailable: isUpgradeAvailable(authSha, operatorHead),
200
+ },
201
+ devbox: {
202
+ running: devboxSha,
203
+ latest: operatorHead,
204
+ upgradeAvailable: isUpgradeAvailable(devboxSha, operatorHead),
205
+ },
206
+ operatorHead,
207
+ checkedAt: new Date().toISOString(),
208
+ };
209
+ }
@@ -0,0 +1,149 @@
1
+ // Phase F-2-B (B6): workspace + project catalog for host CP.
2
+ //
3
+ // Reads workspace YAML files from `~/.olam/workspaces/*.yaml` (mounted
4
+ // at `/data/workspaces` inside the host-cp container per compose.yaml).
5
+ // Provides three endpoints' worth of data:
6
+ //
7
+ // 1. /api/workspaces — list all workspaces (redacted)
8
+ // 2. /api/projects — deduplicated project union
9
+ // 3. POST /api/workspaces/match — exact set-equality matching
10
+ // for D13's project-first
11
+ // create-world flow
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import YAML from 'yaml';
16
+ import { redactSensitive } from './redact.mjs';
17
+
18
+ /**
19
+ * @typedef {object} Project
20
+ * @property {string} name
21
+ * @property {string} [url]
22
+ * @property {string} [path]
23
+ * @property {string} [branch]
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} Workspace
28
+ * @property {string} name
29
+ * @property {Project[]} repos project list (called `repos` in YAML)
30
+ * @property {Record<string, unknown>} [defaults]
31
+ * @property {Record<string, unknown>} [services]
32
+ * @property {Record<string, unknown>} [image]
33
+ * @property {Record<string, unknown>} [host_ui]
34
+ * @property {number} [updatedAt]
35
+ */
36
+
37
+ /**
38
+ * Load all workspace YAMLs from a directory. Returns an array, sorted
39
+ * by name. Invalid YAMLs are logged + skipped (don't bring down the
40
+ * whole list because one file is malformed).
41
+ *
42
+ * @param {string} dir
43
+ * @param {(message: string) => void} [log]
44
+ * @returns {Workspace[]}
45
+ */
46
+ export function loadWorkspaces(dir, log = console.log) {
47
+ if (!fs.existsSync(dir)) {
48
+ log(`workspace-catalog: directory ${dir} does not exist`);
49
+ return [];
50
+ }
51
+ /** @type {Workspace[]} */
52
+ const out = [];
53
+ for (const entry of fs.readdirSync(dir)) {
54
+ if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue;
55
+ const filePath = path.join(dir, entry);
56
+ try {
57
+ const raw = fs.readFileSync(filePath, 'utf-8');
58
+ const parsed = YAML.parse(raw);
59
+ if (parsed && typeof parsed === 'object' && parsed.name) {
60
+ // Normalize: ensure `repos` is at least an empty array.
61
+ out.push({ ...parsed, repos: parsed.repos ?? [] });
62
+ } else {
63
+ log(`workspace-catalog: skipping ${entry} (no .name field)`);
64
+ }
65
+ } catch (err) {
66
+ log(`workspace-catalog: failed to parse ${entry}: ${err.message}`);
67
+ }
68
+ }
69
+ return out.sort((a, b) => a.name.localeCompare(b.name));
70
+ }
71
+
72
+ /**
73
+ * /api/workspaces response: redacted workspace list.
74
+ *
75
+ * @param {Workspace[]} workspaces
76
+ * @returns {Workspace[]}
77
+ */
78
+ export function workspacesForApi(workspaces) {
79
+ return /** @type {Workspace[]} */ (redactSensitive(workspaces));
80
+ }
81
+
82
+ /**
83
+ * /api/projects response: deduplicated project union across all
84
+ * workspaces. Dedup key is project name (case-sensitive — Atlas Core
85
+ * and atlas-core would be distinct, which matches the workspace YAML
86
+ * convention of using kebab-case throughout).
87
+ *
88
+ * Per-project metadata: takes the FIRST occurrence's url/path/branch.
89
+ * Subsequent occurrences with the same name are ignored. This keeps
90
+ * the response stable across reorderings within individual workspace
91
+ * YAMLs.
92
+ *
93
+ * @param {Workspace[]} workspaces
94
+ * @returns {Project[]}
95
+ */
96
+ export function projectsFromWorkspaces(workspaces) {
97
+ /** @type {Map<string, Project>} */
98
+ const byName = new Map();
99
+ for (const ws of workspaces) {
100
+ for (const repo of ws.repos ?? []) {
101
+ if (!repo?.name) continue;
102
+ if (!byName.has(repo.name)) {
103
+ byName.set(repo.name, { ...repo });
104
+ }
105
+ }
106
+ }
107
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
108
+ }
109
+
110
+ /**
111
+ * POST /api/workspaces/match request body: { projects: string[] }.
112
+ * Returns workspaces whose project-name set EXACTLY equals the input
113
+ * set (no subset, no superset). Sorted by name for response stability.
114
+ *
115
+ * Algorithm: O(W × P) where W = #workspaces, P = average projects per
116
+ * workspace. Workspaces are small (<10 projects each); fine for direct
117
+ * iteration.
118
+ *
119
+ * @param {Workspace[]} workspaces
120
+ * @param {string[]} projectNames
121
+ * @returns {Workspace[]}
122
+ */
123
+ export function matchWorkspacesByProjects(workspaces, projectNames) {
124
+ const target = new Set(projectNames);
125
+ /** @type {Workspace[]} */
126
+ const matches = [];
127
+ for (const ws of workspaces) {
128
+ const wsNames = new Set((ws.repos ?? []).map((r) => r.name).filter(Boolean));
129
+ if (setsEqual(target, wsNames)) {
130
+ matches.push(ws);
131
+ }
132
+ }
133
+ return matches.sort((a, b) => a.name.localeCompare(b.name));
134
+ }
135
+
136
+ /**
137
+ * Set equality. Two sets are equal iff same size + same members.
138
+ *
139
+ * @param {Set<string>} a
140
+ * @param {Set<string>} b
141
+ * @returns {boolean}
142
+ */
143
+ function setsEqual(a, b) {
144
+ if (a.size !== b.size) return false;
145
+ for (const x of a) {
146
+ if (!b.has(x)) return false;
147
+ }
148
+ return true;
149
+ }
@@ -0,0 +1,176 @@
1
+ // Phase F-2-D follow-up: persistent world-name store.
2
+ //
3
+ // Background: world.id is the docker container suffix (e.g. `gold-arc-1454`)
4
+ // and is immutable. Operators want a separate human-friendly `name`
5
+ // (e.g. "Refactor the auth module") so the worlds list reads like a
6
+ // task board instead of a string of CSS-color-words.
7
+ //
8
+ // Storage: a single JSON file at /data/world-names.json (mounted from
9
+ // ~/.olam/world-names.json on the host). Atomic write via tmp+rename so
10
+ // concurrent PATCHes can't half-write the file. Read-on-demand with a
11
+ // tiny in-process cache keyed off mtime so steady-state GET /api/worlds
12
+ // doesn't reread the file every poll.
13
+ //
14
+ // Schema:
15
+ // { "<worldId>": "<name>", ... }
16
+ //
17
+ // Names are arbitrary UTF-8 strings, capped at NAME_MAX_LEN to keep
18
+ // the file small + the UI sane.
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+
23
+ const NAME_MAX_LEN = 120;
24
+
25
+ /**
26
+ * @typedef {object} WorldNamesStore
27
+ * @property {() => Record<string, string>} all
28
+ * @property {(id: string) => string | null} get
29
+ * @property {(id: string, name: string) => string} set
30
+ * @property {(id: string) => void} remove
31
+ */
32
+
33
+ /**
34
+ * Create a JSON-backed world-names store rooted at `filePath`.
35
+ * Resilient to a missing file (treats as empty); resilient to a
36
+ * malformed file (logs + treats as empty).
37
+ *
38
+ * @param {string} filePath
39
+ * @returns {WorldNamesStore}
40
+ */
41
+ export function createWorldNamesStore(filePath) {
42
+ /** @type {Record<string, string>} */
43
+ let cache = {};
44
+ let cacheMtimeMs = -1;
45
+
46
+ function readFromDisk() {
47
+ if (!fs.existsSync(filePath)) {
48
+ cache = {};
49
+ cacheMtimeMs = 0;
50
+ return;
51
+ }
52
+ try {
53
+ const stat = fs.statSync(filePath);
54
+ if (stat.mtimeMs === cacheMtimeMs) return; // cache hit
55
+ const raw = fs.readFileSync(filePath, 'utf-8');
56
+ const parsed = JSON.parse(raw);
57
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
58
+ const next = {};
59
+ for (const [k, v] of Object.entries(parsed)) {
60
+ if (typeof v === 'string') next[k] = v;
61
+ }
62
+ cache = next;
63
+ } else {
64
+ cache = {};
65
+ }
66
+ cacheMtimeMs = stat.mtimeMs;
67
+ } catch (err) {
68
+ console.error(`world-names-store: failed to read ${filePath}: ${err.message}`);
69
+ cache = {};
70
+ cacheMtimeMs = 0;
71
+ }
72
+ }
73
+
74
+ function writeToDisk() {
75
+ const dir = path.dirname(filePath);
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
78
+ fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), 'utf-8');
79
+ fs.renameSync(tmp, filePath);
80
+ try {
81
+ const stat = fs.statSync(filePath);
82
+ cacheMtimeMs = stat.mtimeMs;
83
+ } catch {
84
+ cacheMtimeMs = 0;
85
+ }
86
+ }
87
+
88
+ /** @returns {Record<string, string>} */
89
+ function all() {
90
+ readFromDisk();
91
+ return { ...cache };
92
+ }
93
+
94
+ /**
95
+ * @param {string} id
96
+ * @returns {string | null}
97
+ */
98
+ function get(id) {
99
+ readFromDisk();
100
+ return cache[id] ?? null;
101
+ }
102
+
103
+ /**
104
+ * @param {string} id
105
+ * @param {string} name
106
+ * @returns {string} the normalized name actually stored
107
+ */
108
+ function set(id, name) {
109
+ if (typeof id !== 'string' || id.length === 0) {
110
+ throw new Error('worldId must be a non-empty string');
111
+ }
112
+ const normalized = normalizeName(name);
113
+ if (normalized === null) {
114
+ throw new Error('name must be a non-empty string (after trim)');
115
+ }
116
+ readFromDisk();
117
+ cache = { ...cache, [id]: normalized };
118
+ writeToDisk();
119
+ return normalized;
120
+ }
121
+
122
+ /**
123
+ * @param {string} id
124
+ */
125
+ function remove(id) {
126
+ readFromDisk();
127
+ if (!(id in cache)) return;
128
+ const next = { ...cache };
129
+ delete next[id];
130
+ cache = next;
131
+ writeToDisk();
132
+ }
133
+
134
+ return { all, get, set, remove };
135
+ }
136
+
137
+ /**
138
+ * Normalize a name input. Trims, collapses internal whitespace, caps
139
+ * length. Returns null for empty/whitespace-only input.
140
+ *
141
+ * @param {unknown} input
142
+ * @returns {string | null}
143
+ */
144
+ export function normalizeName(input) {
145
+ if (typeof input !== 'string') return null;
146
+ const trimmed = input.replace(/\s+/g, ' ').trim();
147
+ if (trimmed.length === 0) return null;
148
+ return trimmed.length > NAME_MAX_LEN
149
+ ? trimmed.slice(0, NAME_MAX_LEN).trimEnd()
150
+ : trimmed;
151
+ }
152
+
153
+ /**
154
+ * Derive a human-friendly name from an initial task / dispatch text.
155
+ * Takes the first sentence (split on `.`/`?`/`!`/newline), trims, caps
156
+ * at ~60 chars at a word boundary so the UI doesn't truncate mid-word.
157
+ * Returns null for empty input — caller falls back to id.
158
+ *
159
+ * @param {unknown} taskText
160
+ * @returns {string | null}
161
+ */
162
+ export function inferNameFromTask(taskText) {
163
+ if (typeof taskText !== 'string') return null;
164
+ const cleaned = taskText.replace(/\s+/g, ' ').trim();
165
+ if (cleaned.length === 0) return null;
166
+ // First sentence terminator wins; otherwise the whole string.
167
+ const firstSentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? cleaned;
168
+ const SOFT_CAP = 60;
169
+ if (firstSentence.length <= SOFT_CAP) return firstSentence || null;
170
+ // Cap at a word boundary close to SOFT_CAP so we don't dangle
171
+ // half a word + an ellipsis.
172
+ const head = firstSentence.slice(0, SOFT_CAP);
173
+ const lastSpace = head.lastIndexOf(' ');
174
+ const truncated = lastSpace > 30 ? head.slice(0, lastSpace) : head;
175
+ return truncated.replace(/[\s,;:—–-]+$/u, '');
176
+ }