@frumu/tandem-panel 0.4.42 → 0.4.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -23,6 +23,10 @@ TANDEM_CONTROL_PANEL_AUTO_START_ENGINE=1
23
23
  TANDEM_STATE_DIR=
24
24
  TANDEM_CONTROL_PANEL_STATE_DIR=
25
25
 
26
+ # Optional editable workspace root for the Files page workspace explorer.
27
+ # Hosted/provisioned installs mount this at /workspace/repos.
28
+ TANDEM_CONTROL_PANEL_WORKSPACE_ROOT=
29
+
26
30
  # Engine API token used when control panel auto-starts a local engine.
27
31
  # If unset, the panel generates one at startup and prints it in logs.
28
32
  TANDEM_CONTROL_PANEL_ENGINE_TOKEN=tk_change_me
package/bin/setup.js CHANGED
@@ -262,6 +262,7 @@ const SESSION_TTL_MS =
262
262
  const FILES_ROOT = resolve(
263
263
  process.env.TANDEM_CONTROL_PANEL_FILES_ROOT || resolveDefaultChannelUploadsRoot()
264
264
  );
265
+ const WORKSPACE_FILES_ROOT_ENV = String(process.env.TANDEM_CONTROL_PANEL_WORKSPACE_ROOT || "").trim();
265
266
  const MAX_UPLOAD_BYTES = Math.max(
266
267
  1,
267
268
  Number.parseInt(
@@ -1346,6 +1347,7 @@ async function getInstallProfile({ acaAvailable = false, acaReason = "" } = {})
1346
1347
  acaAvailable,
1347
1348
  });
1348
1349
  const summary = summarizeControlPanelConfig(config);
1350
+ const workspaceFilesRoot = resolveWorkspaceFilesRoot();
1349
1351
  return {
1350
1352
  control_panel_mode: mode.mode,
1351
1353
  control_panel_mode_source: mode.source,
@@ -1364,6 +1366,8 @@ async function getInstallProfile({ acaAvailable = false, acaReason = "" } = {})
1364
1366
  hosted_release_version: String(summary.hosted?.release_version || "").trim(),
1365
1367
  hosted_release_channel: String(summary.hosted?.release_channel || "").trim(),
1366
1368
  hosted_update_policy: String(summary.hosted?.update_policy || "").trim(),
1369
+ workspace_files_root: workspaceFilesRoot || "",
1370
+ workspace_files_available: !!workspaceFilesRoot,
1367
1371
  aca_integration: !!acaAvailable,
1368
1372
  aca_reason: acaReason || "",
1369
1373
  };
@@ -1671,6 +1675,46 @@ function toSafeRelPath(raw, allowEmpty = true) {
1671
1675
  return visible;
1672
1676
  }
1673
1677
 
1678
+ function resolveWorkspaceFilesRoot() {
1679
+ const candidate = WORKSPACE_FILES_ROOT_ENV || (isHostedManagedControlPanel() ? "/workspace/repos" : "");
1680
+ if (!candidate) return null;
1681
+ return resolve(candidate);
1682
+ }
1683
+
1684
+ function normalizeWorkspaceFilesPath(raw, allowEmpty = true) {
1685
+ const root = resolveWorkspaceFilesRoot();
1686
+ if (!root) return null;
1687
+ const input = String(raw || "")
1688
+ .trim()
1689
+ .replace(/\\/g, "/");
1690
+ if (!input) return allowEmpty ? "" : null;
1691
+ if (input.includes("\0")) return null;
1692
+ const parts = input.split("/").filter(Boolean);
1693
+ if (!parts.length) return allowEmpty ? "" : null;
1694
+ if (parts.some((part) => part === "." || part === "..")) return null;
1695
+ const full = input.startsWith("/") ? resolve(input) : resolve(root, input);
1696
+ if (full !== root && !full.startsWith(`${root}/`)) return null;
1697
+ const rel = relative(root, full).replace(/\\/g, "/");
1698
+ if (!rel) return allowEmpty ? "" : null;
1699
+ return rel;
1700
+ }
1701
+
1702
+ function workspaceRelToFullPath(relPath) {
1703
+ const root = resolveWorkspaceFilesRoot();
1704
+ if (!root) return null;
1705
+ const full = resolve(root, String(relPath || ""));
1706
+ if (full !== root && !full.startsWith(`${root}/`)) return null;
1707
+ return full;
1708
+ }
1709
+
1710
+ function parentWorkspacePath(raw) {
1711
+ const normalized = normalizeWorkspaceFilesPath(raw, true);
1712
+ if (!normalized) return null;
1713
+ const idx = normalized.lastIndexOf("/");
1714
+ if (idx < 0) return "";
1715
+ return normalized.slice(0, idx);
1716
+ }
1717
+
1674
1718
  function toSafeRelFileName(rawName) {
1675
1719
  const cleaned = basename(String(rawName || "").trim()).replace(/[\0]/g, "");
1676
1720
  if (!cleaned || cleaned === "." || cleaned === "..") return null;
@@ -1723,6 +1767,60 @@ async function ensureUniqueVisibleRelPath(visiblePath) {
1723
1767
  }
1724
1768
  }
1725
1769
 
1770
+ async function ensureUniqueWorkspaceRelPath(workspacePath) {
1771
+ const ext = extname(workspacePath);
1772
+ const stem = ext ? workspacePath.slice(0, -ext.length) : workspacePath;
1773
+ let candidate = workspacePath;
1774
+ let counter = 1;
1775
+ while (true) {
1776
+ const full = workspaceRelToFullPath(candidate);
1777
+ if (!full) throw new Error("Invalid workspace path.");
1778
+ try {
1779
+ await stat(full);
1780
+ counter += 1;
1781
+ candidate = `${stem}-${counter}${ext}`;
1782
+ } catch {
1783
+ return candidate;
1784
+ }
1785
+ }
1786
+ }
1787
+
1788
+ function decodeHeaderValue(value) {
1789
+ const raw = Array.isArray(value) ? String(value[0] || "") : String(value || "");
1790
+ try {
1791
+ return decodeURIComponent(raw);
1792
+ } catch {
1793
+ return raw;
1794
+ }
1795
+ }
1796
+
1797
+ async function workspaceFileEntry(relPath, info = null) {
1798
+ const full = workspaceRelToFullPath(relPath);
1799
+ if (!full) return null;
1800
+ const details = info || (await stat(full).catch(() => null));
1801
+ if (!details) return null;
1802
+ const name = basename(full);
1803
+ if (details.isDirectory()) {
1804
+ return {
1805
+ name,
1806
+ path: relPath,
1807
+ updatedAt: Number(details.mtimeMs || 0),
1808
+ previewKind: "directory",
1809
+ };
1810
+ }
1811
+ if (!details.isFile()) return null;
1812
+ const mime = inferFileMime(relPath || name);
1813
+ return {
1814
+ name,
1815
+ path: relPath,
1816
+ size: Number(details.size || 0),
1817
+ updatedAt: Number(details.mtimeMs || 0),
1818
+ mime,
1819
+ previewKind: inferFilePreviewKind(relPath || name, mime),
1820
+ downloadUrl: `/api/workspace/files/download?path=${encodeURIComponent(relPath)}`,
1821
+ };
1822
+ }
1823
+
1726
1824
  async function handleFilesApi(req, res, _session) {
1727
1825
  const url = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`);
1728
1826
  const pathname = url.pathname;
@@ -2022,6 +2120,255 @@ async function handleFilesApi(req, res, _session) {
2022
2120
  return false;
2023
2121
  }
2024
2122
 
2123
+ async function handleWorkspaceFilesApi(req, res, _session) {
2124
+ const url = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`);
2125
+ const pathname = url.pathname;
2126
+ const workspaceRoot = resolveWorkspaceFilesRoot();
2127
+ if (!workspaceRoot) {
2128
+ sendJson(res, 503, {
2129
+ ok: false,
2130
+ error: "Workspace files root is not configured.",
2131
+ });
2132
+ return true;
2133
+ }
2134
+
2135
+ if (pathname === "/api/workspace/files/list" && req.method === "GET") {
2136
+ const dirRel = normalizeWorkspaceFilesPath(url.searchParams.get("dir") || "", true);
2137
+ if (dirRel === null) {
2138
+ sendJson(res, 400, { ok: false, error: "Invalid workspace directory path." });
2139
+ return true;
2140
+ }
2141
+ try {
2142
+ await mkdir(workspaceRoot, { recursive: true });
2143
+ const dirFull = workspaceRelToFullPath(dirRel);
2144
+ if (!dirFull) throw new Error("Invalid workspace directory path.");
2145
+ const info = await stat(dirFull);
2146
+ if (!info.isDirectory()) throw new Error("Workspace path is not a directory.");
2147
+ const entries = await readdir(dirFull, { withFileTypes: true });
2148
+ const directories = [];
2149
+ const files = [];
2150
+ for (const entry of entries) {
2151
+ const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
2152
+ const childFull = workspaceRelToFullPath(rel);
2153
+ if (!childFull) continue;
2154
+ const childInfo = await stat(childFull).catch(() => null);
2155
+ if (!childInfo) continue;
2156
+ const row = await workspaceFileEntry(rel, childInfo);
2157
+ if (!row) continue;
2158
+ if (entry.isDirectory()) directories.push(row);
2159
+ else if (entry.isFile()) files.push(row);
2160
+ }
2161
+ directories.sort((a, b) => String(a.name).localeCompare(String(b.name)));
2162
+ files.sort(
2163
+ (a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0) || String(a.name).localeCompare(String(b.name))
2164
+ );
2165
+ sendJson(res, 200, {
2166
+ ok: true,
2167
+ root: workspaceRoot,
2168
+ dir: dirRel,
2169
+ parent: parentWorkspacePath(dirRel),
2170
+ directories,
2171
+ files,
2172
+ });
2173
+ } catch (e) {
2174
+ sendJson(res, 404, { ok: false, error: e instanceof Error ? e.message : String(e) });
2175
+ }
2176
+ return true;
2177
+ }
2178
+
2179
+ if (pathname === "/api/workspace/files/upload" && req.method === "POST") {
2180
+ const rawName = decodeHeaderValue(req.headers["x-file-name"]);
2181
+ const rawRelativePath = decodeHeaderValue(req.headers["x-relative-path"]);
2182
+ const uploadPathRaw = String(rawRelativePath || rawName || "").trim();
2183
+ if (!uploadPathRaw || uploadPathRaw.startsWith("/") || uploadPathRaw.includes("\0")) {
2184
+ sendJson(res, 400, { ok: false, error: "Missing or invalid upload path." });
2185
+ return true;
2186
+ }
2187
+ const uploadRel = normalizeWorkspaceFilesPath(uploadPathRaw, false);
2188
+ const dirRel = normalizeWorkspaceFilesPath(url.searchParams.get("dir") || "", true);
2189
+ if (uploadRel === null || dirRel === null) {
2190
+ sendJson(res, 400, { ok: false, error: "Invalid upload path." });
2191
+ return true;
2192
+ }
2193
+ const relRaw = dirRel ? `${dirRel}/${uploadRel}` : uploadRel;
2194
+ let relPath = normalizeWorkspaceFilesPath(relRaw, false);
2195
+ if (!relPath) {
2196
+ sendJson(res, 400, { ok: false, error: "Invalid upload path." });
2197
+ return true;
2198
+ }
2199
+ try {
2200
+ await mkdir(workspaceRoot, { recursive: true });
2201
+ relPath = await ensureUniqueWorkspaceRelPath(relPath);
2202
+ const fullPath = workspaceRelToFullPath(relPath);
2203
+ if (!fullPath) throw new Error("Invalid upload path.");
2204
+ await mkdir(dirname(fullPath), { recursive: true });
2205
+ let bytes = 0;
2206
+ const guard = new Transform({
2207
+ transform(chunk, _enc, cb) {
2208
+ bytes += chunk.length;
2209
+ if (bytes > MAX_UPLOAD_BYTES) {
2210
+ cb(new Error(`Upload exceeds limit of ${MAX_UPLOAD_BYTES} bytes.`));
2211
+ return;
2212
+ }
2213
+ cb(null, chunk);
2214
+ },
2215
+ });
2216
+ await pipeline(req, guard, createWriteStream(fullPath, { flags: "wx" }));
2217
+ const meta = await stat(fullPath);
2218
+ const mime = inferFileMime(relPath);
2219
+ sendJson(res, 200, {
2220
+ ok: true,
2221
+ root: workspaceRoot,
2222
+ name: basename(fullPath),
2223
+ path: relPath,
2224
+ absPath: fullPath,
2225
+ size: meta.size,
2226
+ mime,
2227
+ previewKind: inferFilePreviewKind(relPath, mime),
2228
+ downloadUrl: `/api/workspace/files/download?path=${encodeURIComponent(relPath)}`,
2229
+ });
2230
+ } catch (e) {
2231
+ if (e && typeof e === "object" && "code" in e && e.code === "EEXIST") {
2232
+ sendJson(res, 409, { ok: false, error: "File already exists." });
2233
+ } else {
2234
+ sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
2235
+ }
2236
+ }
2237
+ return true;
2238
+ }
2239
+
2240
+ if (pathname === "/api/workspace/files/read" && req.method === "GET") {
2241
+ const rel = normalizeWorkspaceFilesPath(url.searchParams.get("path") || "", false);
2242
+ if (!rel) {
2243
+ sendJson(res, 400, { ok: false, error: "Missing or invalid workspace file path." });
2244
+ return true;
2245
+ }
2246
+ const full = workspaceRelToFullPath(rel);
2247
+ if (!full) {
2248
+ sendJson(res, 400, { ok: false, error: "Invalid workspace file path." });
2249
+ return true;
2250
+ }
2251
+ try {
2252
+ const info = await stat(full);
2253
+ if (!info.isFile()) throw new Error("Not a file");
2254
+ const mime = inferFileMime(rel);
2255
+ const previewKind = inferFilePreviewKind(rel, mime);
2256
+ const previewable = ["text", "markdown", "json", "yaml"].includes(previewKind);
2257
+ if (!previewable || info.size > MAX_PREVIEW_BYTES) {
2258
+ sendJson(res, 200, {
2259
+ ok: true,
2260
+ root: workspaceRoot,
2261
+ path: rel,
2262
+ absPath: full,
2263
+ name: basename(full),
2264
+ size: info.size,
2265
+ mime,
2266
+ previewKind,
2267
+ previewable: false,
2268
+ reason: !previewable ? "not_previewable" : "too_large",
2269
+ downloadUrl: `/api/workspace/files/download?path=${encodeURIComponent(rel)}`,
2270
+ });
2271
+ return true;
2272
+ }
2273
+ const text = await readFile(full, "utf8");
2274
+ sendJson(res, 200, {
2275
+ ok: true,
2276
+ root: workspaceRoot,
2277
+ path: rel,
2278
+ absPath: full,
2279
+ name: basename(full),
2280
+ size: info.size,
2281
+ mime,
2282
+ previewKind,
2283
+ previewable: true,
2284
+ downloadUrl: `/api/workspace/files/download?path=${encodeURIComponent(rel)}`,
2285
+ text,
2286
+ });
2287
+ } catch {
2288
+ sendJson(res, 404, { ok: false, error: "File not found." });
2289
+ }
2290
+ return true;
2291
+ }
2292
+
2293
+ if (pathname === "/api/workspace/files/mkdir" && req.method === "POST") {
2294
+ try {
2295
+ const body = await readJsonBody(req);
2296
+ const rel = normalizeWorkspaceFilesPath(body?.path || "", false);
2297
+ if (!rel) {
2298
+ sendJson(res, 400, { ok: false, error: "Missing or invalid workspace directory path." });
2299
+ return true;
2300
+ }
2301
+ const full = workspaceRelToFullPath(rel);
2302
+ if (!full) throw new Error("Invalid workspace directory path.");
2303
+ await mkdir(full, { recursive: true });
2304
+ const info = await stat(full);
2305
+ sendJson(res, 200, {
2306
+ ok: true,
2307
+ root: workspaceRoot,
2308
+ path: rel,
2309
+ absPath: full,
2310
+ updatedAt: info.mtimeMs,
2311
+ previewKind: "directory",
2312
+ });
2313
+ } catch (e) {
2314
+ sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
2315
+ }
2316
+ return true;
2317
+ }
2318
+
2319
+ if (pathname === "/api/workspace/files/download" && req.method === "GET") {
2320
+ const rel = normalizeWorkspaceFilesPath(url.searchParams.get("path") || "", false);
2321
+ if (!rel) {
2322
+ sendJson(res, 400, { ok: false, error: "Missing or invalid workspace file path." });
2323
+ return true;
2324
+ }
2325
+ const full = workspaceRelToFullPath(rel);
2326
+ if (!full) {
2327
+ sendJson(res, 400, { ok: false, error: "Invalid workspace file path." });
2328
+ return true;
2329
+ }
2330
+ try {
2331
+ const info = await stat(full);
2332
+ if (!info.isFile()) throw new Error("Not a file");
2333
+ const mime = inferFileMime(rel);
2334
+ res.writeHead(200, {
2335
+ "content-type": mime,
2336
+ "content-length": String(info.size),
2337
+ "content-disposition": `attachment; filename="${basename(full).replace(/"/g, "")}"`,
2338
+ });
2339
+ createReadStream(full).pipe(res);
2340
+ } catch {
2341
+ sendJson(res, 404, { ok: false, error: "File not found." });
2342
+ }
2343
+ return true;
2344
+ }
2345
+
2346
+ if (pathname === "/api/workspace/files/delete" && req.method === "POST") {
2347
+ try {
2348
+ const body = await readJsonBody(req);
2349
+ const rel = normalizeWorkspaceFilesPath(body?.path || "", false);
2350
+ if (!rel) {
2351
+ sendJson(res, 400, { ok: false, error: "Missing or invalid workspace file path." });
2352
+ return true;
2353
+ }
2354
+ const full = workspaceRelToFullPath(rel);
2355
+ if (!full) throw new Error("Invalid workspace file path.");
2356
+ const info = await stat(full);
2357
+ if (!info.isFile()) {
2358
+ sendJson(res, 400, { ok: false, error: "Only files can be deleted from this view." });
2359
+ return true;
2360
+ }
2361
+ await rm(full, { force: true });
2362
+ sendJson(res, 200, { ok: true, root: workspaceRoot, path: rel });
2363
+ } catch (e) {
2364
+ sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
2365
+ }
2366
+ return true;
2367
+ }
2368
+
2369
+ return false;
2370
+ }
2371
+
2025
2372
  async function handleAuthLogin(req, res) {
2026
2373
  try {
2027
2374
  const body = await readJsonBody(req);
@@ -5392,6 +5739,12 @@ async function handleApi(req, res) {
5392
5739
  return handleFilesApi(req, res, session);
5393
5740
  }
5394
5741
 
5742
+ if (pathname.startsWith("/api/workspace/files")) {
5743
+ const session = requireSession(req, res);
5744
+ if (!session) return true;
5745
+ return handleWorkspaceFilesApi(req, res, session);
5746
+ }
5747
+
5395
5748
  if (pathname.startsWith("/api/engine")) {
5396
5749
  const session = requireSession(req, res);
5397
5750
  if (!session) return true;
@@ -5499,6 +5852,7 @@ async function main() {
5499
5852
  log(`Engine mode: ${isLocalEngineUrl(ENGINE_URL) ? "local" : "remote"}`);
5500
5853
  log(`Files root: ${FILES_ROOT}`);
5501
5854
  log(`Files buckets: uploads, artifacts, exports`);
5855
+ log(`Workspace root:${resolveWorkspaceFilesRoot() || "not configured"}`);
5502
5856
  log(`Build: ${CONTROL_PANEL_BUILD_FINGERPRINT}`);
5503
5857
  log("=========================================");
5504
5858
  });