pilotswarm-cli 0.1.14 → 0.1.15

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/README.md CHANGED
@@ -22,6 +22,9 @@ npx pilotswarm local --env .env --plugin ./plugin --worker ./worker-module.js
22
22
 
23
23
  `pilotswarm-cli` provides the shipped TUI. Your app customizes it with `plugin/plugin.json`, `plugin/agents/*.agent.md`, `plugin/skills/*/SKILL.md`, and optional worker-side tools.
24
24
 
25
+ Portal/runtime helpers that are intentionally shared with `pilotswarm-web`
26
+ are exported from `pilotswarm-cli/portal`.
27
+
25
28
  Common docs:
26
29
 
27
30
  - CLI apps: `https://github.com/affandar/PilotSwarm/blob/main/docs/cli/building-cli-apps.md`
package/bin/tui.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { parseCliIntoEnv } from "../src/bootstrap-env.js";
4
- import { startTuiApp } from "../src/index.js";
3
+ // Force the shipped TUI onto production React/Ink unless the caller
4
+ // explicitly opts into another environment for debugging.
5
+ process.env.NODE_ENV ??= "production";
5
6
 
7
+ const { parseCliIntoEnv } = await import("../src/bootstrap-env.js");
6
8
  const config = parseCliIntoEnv(process.argv.slice(2));
9
+ const { startTuiApp } = await import("../src/index.js");
7
10
  await startTuiApp(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm-cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Terminal UI for PilotSwarm.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  },
10
10
  "main": "./src/index.js",
11
11
  "exports": {
12
- ".": "./src/index.js"
12
+ ".": "./src/index.js",
13
+ "./portal": "./src/portal.js"
13
14
  },
14
15
  "scripts": {
15
16
  "build": "echo 'pilotswarm-cli: no build step (plain JS)'",
@@ -36,7 +37,7 @@
36
37
  },
37
38
  "dependencies": {
38
39
  "ink": "^6.8.0",
39
- "pilotswarm-sdk": "^0.1.12",
40
+ "pilotswarm-sdk": "^0.1.15",
40
41
  "pilotswarm-ui-core": "0.1.0",
41
42
  "pilotswarm-ui-react": "0.1.0",
42
43
  "react": "^19.2.4"
package/src/app.js CHANGED
@@ -231,6 +231,7 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
231
231
  const focus = controller.getState().ui.focusRegion;
232
232
  const modal = controller.getState().ui.modal;
233
233
  const inspectorTab = controller.getState().ui.inspectorTab;
234
+ const fullscreenPane = controller.getState().ui.fullscreenPane || null;
234
235
  const plainShortcut = isPlainShortcutKey(key);
235
236
  const matchesCtrlKey = (name, controlChar) => key.ctrl
236
237
  && (key.name === name || input === name || input === controlChar);
@@ -357,6 +358,9 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
357
358
  return;
358
359
  }
359
360
  if (key.tab) {
361
+ if (focus === "prompt" && controller.acceptPromptReferenceAutocomplete()) {
362
+ return;
363
+ }
360
364
  controller.handleCommand(UI_COMMANDS.FOCUS_NEXT).catch(() => {});
361
365
  return;
362
366
  }
@@ -364,6 +368,15 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
364
368
  controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {});
365
369
  return;
366
370
  }
371
+ if (key.escape && fullscreenPane) {
372
+ if (focus === "prompt") {
373
+ controller.setPrompt("");
374
+ controller.setFocus(fullscreenPane);
375
+ return;
376
+ }
377
+ controller.handleCommand(UI_COMMANDS.TOGGLE_PANE_FULLSCREEN).catch(() => {});
378
+ return;
379
+ }
367
380
  if (key.escape) {
368
381
  if (focus === "prompt") {
369
382
  controller.setPrompt("");
@@ -397,6 +410,14 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
397
410
  controller.handleCommand(UI_COMMANDS.OPEN_FILES_FILTER).catch(() => {});
398
411
  return;
399
412
  }
413
+ if (focus === "inspector" && inspectorTab === "files" && input === "u") {
414
+ controller.handleCommand(UI_COMMANDS.OPEN_ARTIFACT_UPLOAD).catch(() => {});
415
+ return;
416
+ }
417
+ if (focus === "inspector" && inspectorTab === "files" && input === "a") {
418
+ controller.handleCommand(UI_COMMANDS.DOWNLOAD_SELECTED_FILE).catch(() => {});
419
+ return;
420
+ }
400
421
  if (focus === "inspector" && inspectorTab === "history" && input === "f") {
401
422
  controller.handleCommand(UI_COMMANDS.OPEN_HISTORY_FORMAT).catch(() => {});
402
423
  return;
@@ -413,6 +434,10 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
413
434
  controller.handleCommand(UI_COMMANDS.TOGGLE_FILE_PREVIEW_FULLSCREEN).catch(() => {});
414
435
  return;
415
436
  }
437
+ if (focus !== "prompt" && input === "v") {
438
+ controller.handleCommand(UI_COMMANDS.TOGGLE_PANE_FULLSCREEN).catch(() => {});
439
+ return;
440
+ }
416
441
  if (focus === "inspector" && inspectorTab === "files" && plainShortcut && input === "o") {
417
442
  controller.handleCommand(UI_COMMANDS.OPEN_SELECTED_FILE).catch(() => {});
418
443
  return;
@@ -436,6 +461,14 @@ export function PilotSwarmTuiApp({ controller, platform, onRequestExit }) {
436
461
  controller.handleCommand(UI_COMMANDS.GROW_RIGHT_PANE).catch(() => {});
437
462
  return;
438
463
  }
464
+ if (focus !== "prompt" && input === "{") {
465
+ controller.handleCommand(UI_COMMANDS.SHRINK_SESSION_PANE).catch(() => {});
466
+ return;
467
+ }
468
+ if (focus !== "prompt" && input === "}") {
469
+ controller.handleCommand(UI_COMMANDS.GROW_SESSION_PANE).catch(() => {});
470
+ return;
471
+ }
439
472
 
440
473
  if (focus === "prompt") {
441
474
  if (isCtrlA) {
@@ -3,21 +3,10 @@ import { execFileSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { resolveTuiBranding } from "./plugin-config.js";
6
7
 
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
9
  const pkgRoot = path.resolve(__dirname, "..");
9
- const defaultTuiSplashPath = path.join(pkgRoot, "tui-splash.txt");
10
-
11
- function readPluginMetadata(pluginDir) {
12
- if (!pluginDir) return null;
13
- const pluginJsonPath = path.join(pluginDir, "plugin.json");
14
- if (!fs.existsSync(pluginJsonPath)) return null;
15
- try {
16
- return JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8"));
17
- } catch (error) {
18
- throw new Error(`Failed to parse plugin metadata: ${pluginJsonPath}: ${error.message}`);
19
- }
20
- }
21
10
 
22
11
  function resolvePluginDir(flags) {
23
12
  if (flags.plugin) return path.resolve(flags.plugin);
@@ -52,31 +41,6 @@ function resolveSystemMessage(flags) {
52
41
  return undefined;
53
42
  }
54
43
 
55
- function resolveTuiBranding(pluginDir) {
56
- const pluginMeta = readPluginMetadata(pluginDir);
57
- const tui = pluginMeta?.tui;
58
- let defaultSplash = "{bold}{cyan-fg}PilotSwarm{/cyan-fg}{/bold}";
59
- if (fs.existsSync(defaultTuiSplashPath)) {
60
- defaultSplash = fs.readFileSync(defaultTuiSplashPath, "utf-8").trimEnd();
61
- }
62
- if (!tui || typeof tui !== "object") {
63
- return { title: "PilotSwarm", splash: defaultSplash };
64
- }
65
-
66
- const title = typeof tui.title === "string" && tui.title.trim() ? tui.title.trim() : "PilotSwarm";
67
- let splash = defaultSplash;
68
- if (typeof tui.splash === "string" && tui.splash.trim()) {
69
- splash = tui.splash;
70
- } else if (typeof tui.splashFile === "string" && tui.splashFile.trim()) {
71
- const splashPath = path.resolve(pluginDir, tui.splashFile);
72
- if (!fs.existsSync(splashPath)) {
73
- throw new Error(`TUI splash file not found: ${splashPath}`);
74
- }
75
- splash = fs.readFileSync(splashPath, "utf-8").trimEnd();
76
- }
77
- return { title, splash };
78
- }
79
-
80
44
  function loadEnvFile(envFile) {
81
45
  if (!fs.existsSync(envFile)) return;
82
46
  const envContent = fs.readFileSync(envFile, "utf-8");
@@ -1,5 +1,6 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
+ import https from "node:https";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import {
@@ -10,9 +11,65 @@ import {
10
11
  SessionBlobStore,
11
12
  } from "pilotswarm-sdk";
12
13
  import { startEmbeddedWorkers, stopEmbeddedWorkers } from "./embedded-workers.js";
14
+ import { getPluginDirsFromEnv } from "./plugin-config.js";
13
15
 
14
16
  const EXPORTS_DIR = path.join(os.homedir(), "pilotswarm-exports");
15
17
  fs.mkdirSync(EXPORTS_DIR, { recursive: true });
18
+ const K8S_SERVICE_ACCOUNT_DIR = "/var/run/secrets/kubernetes.io/serviceaccount";
19
+
20
+ function fileExists(filePath) {
21
+ try {
22
+ return fs.existsSync(filePath);
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function getInClusterK8sPaths() {
29
+ const baseDir = process.env.PILOTSWARM_K8S_SERVICE_ACCOUNT_DIR || K8S_SERVICE_ACCOUNT_DIR;
30
+ return {
31
+ tokenPath: process.env.PILOTSWARM_K8S_TOKEN_PATH || path.join(baseDir, "token"),
32
+ caPath: process.env.PILOTSWARM_K8S_CA_PATH || path.join(baseDir, "ca.crt"),
33
+ namespacePath: process.env.PILOTSWARM_K8S_NAMESPACE_PATH || path.join(baseDir, "namespace"),
34
+ };
35
+ }
36
+
37
+ function readOptionalTextFile(filePath) {
38
+ try {
39
+ return fs.readFileSync(filePath, "utf8").trim();
40
+ } catch {
41
+ return "";
42
+ }
43
+ }
44
+
45
+ function hasInClusterK8sAccess() {
46
+ const { tokenPath, caPath } = getInClusterK8sPaths();
47
+ return Boolean(process.env.KUBERNETES_SERVICE_HOST)
48
+ && fileExists(tokenPath)
49
+ && fileExists(caPath);
50
+ }
51
+
52
+ function getInClusterK8sConfig() {
53
+ if (!hasInClusterK8sAccess()) return null;
54
+
55
+ const { tokenPath, caPath, namespacePath } = getInClusterK8sPaths();
56
+ return {
57
+ host: String(process.env.KUBERNETES_SERVICE_HOST || "").trim(),
58
+ port: Number(process.env.KUBERNETES_SERVICE_PORT || 443) || 443,
59
+ token: readOptionalTextFile(tokenPath),
60
+ ca: fs.readFileSync(caPath),
61
+ namespace: String(process.env.K8S_NAMESPACE || "").trim() || readOptionalTextFile(namespacePath) || "default",
62
+ };
63
+ }
64
+
65
+ function hasExplicitKubectlConfig() {
66
+ return Boolean((process.env.K8S_CONTEXT || "").trim() || (process.env.KUBECONFIG || "").trim());
67
+ }
68
+
69
+ function isKubectlAvailable() {
70
+ const result = spawnSync("kubectl", ["version", "--client=true"], { stdio: "ignore" });
71
+ return !result.error;
72
+ }
16
73
 
17
74
  function stripAnsi(value) {
18
75
  return String(value || "").replace(/\x1b\[[0-9;]*m/g, "");
@@ -105,9 +162,14 @@ function buildLogEntry(line, counter) {
105
162
  const prefixMatch = line.match(/^\[pod\/([^/\]]+)/);
106
163
  const podName = prefixMatch ? prefixMatch[1] : "unknown";
107
164
  const rawLine = trimLogText(stripAnsi(line.replace(/^\[pod\/[^\]]+\]\s*/, "")).trim());
108
- const orchMatch = rawLine.match(/\b(instance_id|orchestration_id)=(session-[^\s,]+)/i)
165
+ const orchMatch = rawLine.match(/\b(?:instance_id|orchestration_id|orch)=(session-[^\s,]+)/i)
109
166
  || rawLine.match(/\b(session-[0-9a-f-]{8,})\b/i);
110
- const orchId = orchMatch ? orchMatch[2] || orchMatch[1] : null;
167
+ const parsedOrchId = orchMatch ? orchMatch[1] : null;
168
+ const sessionIdMatch = rawLine.match(/\b(?:sessionId|session|durableSessionId)=([0-9a-f-]{8,})\b/i);
169
+ const sessionId = sessionIdMatch
170
+ ? sessionIdMatch[1]
171
+ : (parsedOrchId && parsedOrchId.startsWith("session-") ? parsedOrchId.slice("session-".length) : null);
172
+ const orchId = parsedOrchId || (sessionId ? `session-${sessionId}` : null);
111
173
  const category = rawLine.includes("duroxide::activity")
112
174
  ? "activity"
113
175
  : rawLine.includes("duroxide::orchestration") || rawLine.includes("::orchestration")
@@ -120,6 +182,7 @@ function buildLogEntry(line, counter) {
120
182
  podName,
121
183
  level: normalizeLogLevel(rawLine),
122
184
  orchId,
185
+ sessionId,
123
186
  category,
124
187
  rawLine,
125
188
  message: extractPrettyLogMessage(rawLine),
@@ -127,6 +190,22 @@ function buildLogEntry(line, counter) {
127
190
  };
128
191
  }
129
192
 
193
+ function buildSyntheticLogEntry({ message, level = "info", podName = "k8s", counter = 0 }) {
194
+ const safeMessage = trimLogText(String(message || "").trim());
195
+ return {
196
+ id: `log:${Date.now()}:${counter}`,
197
+ time: extractLogTime(safeMessage),
198
+ podName,
199
+ level,
200
+ orchId: null,
201
+ sessionId: null,
202
+ category: "log",
203
+ rawLine: safeMessage,
204
+ message: safeMessage,
205
+ prettyMessage: safeMessage,
206
+ };
207
+ }
208
+
130
209
  function sanitizeArtifactFilename(filename) {
131
210
  return String(filename || "").replace(/[/\\]/g, "_");
132
211
  }
@@ -179,13 +258,6 @@ function isTerminalSendError(error) {
179
258
  return /instance is terminal|terminal orchestration|cannot accept new messages/i.test(message);
180
259
  }
181
260
 
182
- function getPluginDirsFromEnv() {
183
- return String(process.env.PLUGIN_DIRS || "")
184
- .split(",")
185
- .map((value) => value.trim())
186
- .filter(Boolean);
187
- }
188
-
189
261
  function normalizeCreatableAgent(agent) {
190
262
  const name = String(agent?.name || "").trim();
191
263
  if (!name) return null;
@@ -235,8 +307,8 @@ function loadSessionCreationMetadataFromPluginDirs(pluginDirs = []) {
235
307
  }
236
308
 
237
309
  function buildTerminalSendError(sessionId, session) {
238
- if (session?.status === "failed" || session?.orchestrationStatus === "Failed") {
239
- return `Session ${sessionId.slice(0, 8)} is a failed terminal orchestration and cannot accept new messages.`;
310
+ if (session?.status === "failed" || session?.status === "cancelled" || session?.orchestrationStatus === "Failed") {
311
+ return `Session ${sessionId.slice(0, 8)} is a terminal orchestration and cannot accept new messages.`;
240
312
  }
241
313
 
242
314
  const statusLabel = String(session?.orchestrationStatus || session?.status || "Unknown");
@@ -256,10 +328,12 @@ export class NodeSdkTransport {
256
328
  this.allowedAgentNames = [];
257
329
  this.creatableAgents = [];
258
330
  this.logProc = null;
331
+ this.logTailHandle = null;
259
332
  this.logBuffer = "";
260
333
  this.logRestartTimer = null;
261
334
  this.logSubscribers = new Set();
262
335
  this.logEntryCounter = 0;
336
+ this.kubectlAvailable = null;
263
337
  }
264
338
 
265
339
  async start() {
@@ -314,12 +388,30 @@ export class NodeSdkTransport {
314
388
  }
315
389
 
316
390
  getLogConfig() {
317
- const hasK8sConfig = Boolean((process.env.K8S_CONTEXT || "").trim() || (process.env.KUBECONFIG || "").trim());
391
+ const hasInClusterConfig = hasInClusterK8sAccess();
392
+ const hasKubectlConfig = hasExplicitKubectlConfig();
393
+ if (hasInClusterConfig) {
394
+ return {
395
+ available: true,
396
+ availabilityReason: "",
397
+ };
398
+ }
399
+
400
+ if (hasKubectlConfig) {
401
+ if (this.kubectlAvailable == null) {
402
+ this.kubectlAvailable = isKubectlAvailable();
403
+ }
404
+ return {
405
+ available: this.kubectlAvailable,
406
+ availabilityReason: this.kubectlAvailable
407
+ ? ""
408
+ : "Log tailing disabled: kubectl is not installed in this environment.",
409
+ };
410
+ }
411
+
318
412
  return {
319
- available: hasK8sConfig,
320
- availabilityReason: hasK8sConfig
321
- ? ""
322
- : "Log tailing disabled: no K8S_CONTEXT configured in the env file.",
413
+ available: false,
414
+ availabilityReason: "Log tailing disabled: no K8S_CONTEXT/KUBECONFIG or in-cluster Kubernetes access detected.",
323
415
  };
324
416
  }
325
417
 
@@ -375,11 +467,11 @@ export class NodeSdkTransport {
375
467
  if (!session) {
376
468
  throw new Error(`Session ${sessionId.slice(0, 8)} was not found.`);
377
469
  }
378
- if (session.status === "failed" || session.orchestrationStatus === "Failed") {
470
+ if (session.status === "failed" || session.status === "cancelled" || session.orchestrationStatus === "Failed") {
379
471
  throw new Error(buildTerminalSendError(sessionId, session));
380
472
  }
381
473
  if (
382
- session.status === "completed"
474
+ (session.status === "completed" || session.status === "cancelled")
383
475
  && session.parentSessionId
384
476
  && !session.isSystem
385
477
  && !session.cronActive
@@ -480,6 +572,36 @@ export class NodeSdkTransport {
480
572
  };
481
573
  }
482
574
 
575
+ async uploadArtifactContent(sessionId, filename, content, contentType = guessArtifactContentType(filename)) {
576
+ if (!this.artifactStore) {
577
+ throw new Error("Artifact store is not available for this transport.");
578
+ }
579
+ const safeSessionId = String(sessionId || "").trim();
580
+ const safeFilename = path.basename(String(filename || "").trim());
581
+ const safeContent = typeof content === "string" ? content : String(content || "");
582
+ if (!safeSessionId) {
583
+ throw new Error("Session id is required for artifact upload.");
584
+ }
585
+ if (!safeFilename) {
586
+ throw new Error("Filename is required for artifact upload.");
587
+ }
588
+
589
+ await this.artifactStore.uploadArtifact(
590
+ safeSessionId,
591
+ safeFilename,
592
+ safeContent,
593
+ contentType || guessArtifactContentType(safeFilename),
594
+ );
595
+
596
+ return {
597
+ sessionId: safeSessionId,
598
+ filename: safeFilename,
599
+ resolvedPath: safeFilename,
600
+ sizeBytes: Buffer.byteLength(safeContent, "utf8"),
601
+ contentType: contentType || guessArtifactContentType(safeFilename),
602
+ };
603
+ }
604
+
483
605
  getArtifactExportDirectory() {
484
606
  return EXPORTS_DIR;
485
607
  }
@@ -575,10 +697,19 @@ export class NodeSdkTransport {
575
697
  }
576
698
 
577
699
  emitLogEntry(entry) {
578
- for (const handler of this.logSubscribers) {
579
- try {
580
- handler(entry);
581
- } catch {}
700
+ if (!this._logBatch) this._logBatch = [];
701
+ this._logBatch.push(entry);
702
+ if (!this._logBatchTimer) {
703
+ this._logBatchTimer = setTimeout(() => {
704
+ const batch = this._logBatch;
705
+ this._logBatch = [];
706
+ this._logBatchTimer = null;
707
+ for (const handler of this.logSubscribers) {
708
+ try {
709
+ handler(batch);
710
+ } catch {}
711
+ }
712
+ }, 250);
582
713
  }
583
714
  }
584
715
 
@@ -592,9 +723,208 @@ export class NodeSdkTransport {
592
723
  }, 5000);
593
724
  }
594
725
 
595
- startLogProcess() {
726
+ emitSyntheticLogMessage(message, level = "info", podName = "k8s") {
727
+ this.logEntryCounter += 1;
728
+ this.emitLogEntry(buildSyntheticLogEntry({
729
+ message,
730
+ level,
731
+ podName,
732
+ counter: this.logEntryCounter,
733
+ }));
734
+ }
735
+
736
+ async listPodsFromKubeApi(config, labelSelector) {
737
+ const params = new URLSearchParams();
738
+ if (labelSelector) params.set("labelSelector", labelSelector);
739
+ const pathName = `/api/v1/namespaces/${encodeURIComponent(config.namespace)}/pods${params.size > 0 ? `?${params.toString()}` : ""}`;
740
+
741
+ return await new Promise((resolve, reject) => {
742
+ const req = https.request({
743
+ method: "GET",
744
+ hostname: config.host,
745
+ port: config.port,
746
+ path: pathName,
747
+ ca: config.ca,
748
+ headers: {
749
+ Authorization: `Bearer ${config.token}`,
750
+ Accept: "application/json",
751
+ },
752
+ }, (res) => {
753
+ let body = "";
754
+ res.setEncoding("utf8");
755
+ res.on("data", (chunk) => {
756
+ body += chunk;
757
+ });
758
+ res.on("end", () => {
759
+ if ((res.statusCode || 0) >= 400) {
760
+ reject(new Error(
761
+ `Kubernetes API pod list failed (${res.statusCode}): ${trimLogText(body || res.statusMessage || "unknown error")}`,
762
+ ));
763
+ return;
764
+ }
765
+ try {
766
+ const payload = JSON.parse(body || "{}");
767
+ const items = Array.isArray(payload?.items) ? payload.items : [];
768
+ resolve(items
769
+ .map((item) => String(item?.metadata?.name || "").trim())
770
+ .filter(Boolean));
771
+ } catch (error) {
772
+ reject(error);
773
+ }
774
+ });
775
+ });
776
+
777
+ req.on("error", reject);
778
+ req.end();
779
+ });
780
+ }
781
+
782
+ streamPodLogsFromKubeApi(config, podName, handle, options = {}) {
783
+ const params = new URLSearchParams({
784
+ follow: "true",
785
+ timestamps: "true",
786
+ tailLines: String(options.tailLines ?? 500),
787
+ });
788
+ const pathName = `/api/v1/namespaces/${encodeURIComponent(config.namespace)}/pods/${encodeURIComponent(podName)}/log?${params.toString()}`;
789
+
790
+ return new Promise((resolve, reject) => {
791
+ let buffer = "";
792
+ let settled = false;
793
+ let response = null;
794
+
795
+ const finish = (error = null) => {
796
+ if (settled) return;
797
+ settled = true;
798
+
799
+ if (buffer.trim()) {
800
+ this.logEntryCounter += 1;
801
+ this.emitLogEntry(buildLogEntry(`[pod/${podName}] ${buffer.trim()}`, this.logEntryCounter));
802
+ buffer = "";
803
+ }
804
+
805
+ if (response) {
806
+ handle.responses.delete(response);
807
+ }
808
+ handle.requests.delete(request);
809
+
810
+ if (error) reject(error);
811
+ else resolve();
812
+ };
813
+
814
+ const request = https.request({
815
+ method: "GET",
816
+ hostname: config.host,
817
+ port: config.port,
818
+ path: pathName,
819
+ ca: config.ca,
820
+ headers: {
821
+ Authorization: `Bearer ${config.token}`,
822
+ Accept: "*/*",
823
+ },
824
+ }, (res) => {
825
+ response = res;
826
+ handle.responses.add(res);
827
+
828
+ if ((res.statusCode || 0) >= 400) {
829
+ let body = "";
830
+ res.setEncoding("utf8");
831
+ res.on("data", (chunk) => {
832
+ body += chunk;
833
+ });
834
+ res.on("end", () => {
835
+ finish(new Error(
836
+ `Kubernetes log stream failed for ${podName} (${res.statusCode}): ${trimLogText(body || res.statusMessage || "unknown error")}`,
837
+ ));
838
+ });
839
+ return;
840
+ }
841
+
842
+ res.setEncoding("utf8");
843
+ res.on("data", (chunk) => {
844
+ buffer += chunk;
845
+ const lines = buffer.split("\n");
846
+ buffer = lines.pop() || "";
847
+ for (const line of lines) {
848
+ if (!line.trim()) continue;
849
+ this.logEntryCounter += 1;
850
+ this.emitLogEntry(buildLogEntry(`[pod/${podName}] ${line}`, this.logEntryCounter));
851
+ }
852
+ });
853
+ res.on("end", () => finish());
854
+ res.on("close", () => finish());
855
+ res.on("error", (error) => finish(error));
856
+ });
857
+
858
+ handle.requests.add(request);
859
+ request.on("error", (error) => finish(error));
860
+ request.end();
861
+ });
862
+ }
863
+
864
+ startInClusterLogProcess() {
865
+ const config = getInClusterK8sConfig();
866
+ if (!config || this.logTailHandle) return;
867
+
868
+ const labelSelector = process.env.K8S_POD_LABEL || "app.kubernetes.io/component=worker";
869
+ const handle = {
870
+ stopped: false,
871
+ requests: new Set(),
872
+ responses: new Set(),
873
+ stop: () => {
874
+ if (handle.stopped) return;
875
+ handle.stopped = true;
876
+ for (const response of handle.responses) {
877
+ try { response.destroy(); } catch {}
878
+ }
879
+ handle.responses.clear();
880
+ for (const request of handle.requests) {
881
+ try { request.destroy(); } catch {}
882
+ }
883
+ handle.requests.clear();
884
+ },
885
+ };
886
+ this.logTailHandle = handle;
887
+
888
+ this.listPodsFromKubeApi(config, labelSelector)
889
+ .then(async (podNames) => {
890
+ if (handle.stopped || this.logTailHandle !== handle) return;
891
+ if (podNames.length === 0) {
892
+ this.emitSyntheticLogMessage(
893
+ `No pods matched label selector ${JSON.stringify(labelSelector)} in namespace ${config.namespace}.`,
894
+ "warn",
895
+ );
896
+ return;
897
+ }
898
+
899
+ const results = await Promise.allSettled(
900
+ podNames.map((podName) => this.streamPodLogsFromKubeApi(config, podName, handle)),
901
+ );
902
+
903
+ if (handle.stopped || this.logTailHandle !== handle) return;
904
+ for (const result of results) {
905
+ if (result.status === "fulfilled") continue;
906
+ this.emitSyntheticLogMessage(result.reason?.message || String(result.reason), "error");
907
+ }
908
+ })
909
+ .catch((error) => {
910
+ if (handle.stopped || this.logTailHandle !== handle) return;
911
+ this.emitSyntheticLogMessage(error?.message || String(error), "error");
912
+ })
913
+ .finally(() => {
914
+ if (this.logTailHandle === handle) {
915
+ this.logTailHandle = null;
916
+ }
917
+ if (!handle.stopped) {
918
+ this.scheduleLogRestart();
919
+ }
920
+ });
921
+ }
922
+
923
+ startKubectlLogProcess() {
924
+ if (this.logProc) return;
925
+
596
926
  const config = this.getLogConfig();
597
- if (!config.available || this.logProc) return;
927
+ if (!config.available) return;
598
928
 
599
929
  const k8sContext = process.env.K8S_CONTEXT || "";
600
930
  const k8sNamespace = process.env.K8S_NAMESPACE || "copilot-runtime";
@@ -626,53 +956,32 @@ export class NodeSdkTransport {
626
956
  this.logProc.stderr.on("data", (chunk) => {
627
957
  const text = stripAnsi(chunk.toString()).trim();
628
958
  if (!text) return;
629
- this.logEntryCounter += 1;
630
- this.emitLogEntry({
631
- id: `log:${Date.now()}:${this.logEntryCounter}`,
632
- time: extractLogTime(text),
633
- podName: "kubectl",
634
- level: "warn",
635
- orchId: null,
636
- category: "log",
637
- rawLine: trimLogText(text),
638
- message: trimLogText(text),
639
- prettyMessage: trimLogText(text),
640
- });
959
+ this.emitSyntheticLogMessage(text, "warn", "kubectl");
641
960
  });
642
961
 
643
962
  this.logProc.on("error", (error) => {
644
- this.logEntryCounter += 1;
645
- this.emitLogEntry({
646
- id: `log:${Date.now()}:${this.logEntryCounter}`,
647
- time: extractLogTime(""),
648
- podName: "kubectl",
649
- level: "error",
650
- orchId: null,
651
- category: "log",
652
- rawLine: trimLogText(`kubectl error: ${error.message}`),
653
- message: trimLogText(`kubectl error: ${error.message}`),
654
- prettyMessage: trimLogText(`kubectl error: ${error.message}`),
655
- });
963
+ this.emitSyntheticLogMessage(`kubectl error: ${error.message}`, "error", "kubectl");
656
964
  });
657
965
 
658
966
  this.logProc.on("exit", (code, signal) => {
659
967
  this.logProc = null;
660
- this.logEntryCounter += 1;
661
- this.emitLogEntry({
662
- id: `log:${Date.now()}:${this.logEntryCounter}`,
663
- time: extractLogTime(""),
664
- podName: "kubectl",
665
- level: "warn",
666
- orchId: null,
667
- category: "log",
668
- rawLine: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
669
- message: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
670
- prettyMessage: trimLogText(`kubectl exited (code=${code} signal=${signal})`),
671
- });
968
+ this.emitSyntheticLogMessage(`kubectl exited (code=${code} signal=${signal})`, "warn", "kubectl");
672
969
  this.scheduleLogRestart();
673
970
  });
674
971
  }
675
972
 
973
+ startLogProcess() {
974
+ const config = this.getLogConfig();
975
+ if (!config.available || this.logProc || this.logTailHandle) return;
976
+
977
+ if (hasInClusterK8sAccess()) {
978
+ this.startInClusterLogProcess();
979
+ return;
980
+ }
981
+
982
+ this.startKubectlLogProcess();
983
+ }
984
+
676
985
  startLogTail(handler) {
677
986
  if (typeof handler === "function") {
678
987
  this.logSubscribers.add(handler);
@@ -690,10 +999,21 @@ export class NodeSdkTransport {
690
999
  }
691
1000
 
692
1001
  async stopLogTail() {
1002
+ if (this._logBatchTimer) {
1003
+ clearTimeout(this._logBatchTimer);
1004
+ this._logBatchTimer = null;
1005
+ this._logBatch = [];
1006
+ }
693
1007
  if (this.logRestartTimer) {
694
1008
  clearTimeout(this.logRestartTimer);
695
1009
  this.logRestartTimer = null;
696
1010
  }
1011
+ if (this.logTailHandle) {
1012
+ try {
1013
+ this.logTailHandle.stop();
1014
+ } catch {}
1015
+ this.logTailHandle = null;
1016
+ }
697
1017
  if (this.logProc) {
698
1018
  try {
699
1019
  this.logProc.kill("SIGKILL");
package/src/platform.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { Box, Text } from "ink";
4
- import { DEFAULT_THEME_ID, getTheme, parseTerminalMarkupRuns } from "pilotswarm-ui-core";
4
+ import { DEFAULT_THEME_ID, getTheme, isThemeLight, parseTerminalMarkupRuns } from "pilotswarm-ui-core";
5
5
 
6
6
  const MAX_PROMPT_INPUT_ROWS = 3;
7
7
  const SELECTION_BACKGROUND = "selectionBackground";
@@ -38,8 +38,12 @@ function resolveColorToken(color) {
38
38
  return theme?.tui?.[color] || color;
39
39
  }
40
40
 
41
+ export function shouldDimGrayTextForTheme(theme = getCurrentTheme()) {
42
+ return !isThemeLight(theme);
43
+ }
44
+
41
45
  function isDimColorToken(color) {
42
- return color === "gray";
46
+ return color === "gray" && shouldDimGrayTextForTheme();
43
47
  }
44
48
 
45
49
  function trimText(value, width) {
@@ -361,19 +365,46 @@ function renderPanelRow(line, rowKey, contentWidth, borderColor, scrollIndicator
361
365
  : selectedRuns.length > 0
362
366
  ? renderInlineRuns(selectedRuns, `inline:${rowKey}`)
363
367
  : React.createElement(Text, null, "")),
364
- React.createElement(Text, { color: scrollIndicator ? resolveColorToken("gray") : undefined, dimColor: Boolean(scrollIndicator) }, scrollChar),
368
+ React.createElement(Text, {
369
+ color: scrollIndicator ? resolveColorToken("gray") : undefined,
370
+ dimColor: Boolean(scrollIndicator) && shouldDimGrayTextForTheme(),
371
+ }, scrollChar),
365
372
  React.createElement(Text, { color: resolveColorToken(borderColor) }, "│"));
366
373
  }
367
374
 
368
- function renderBorderTop(title, color, width) {
375
+ function renderBorderTop(title, color, width, titleRight = null) {
369
376
  const safeWidth = Math.max(8, Number(width) || 40);
370
- const safeTitleRuns = trimRuns(normalizeTitleRuns(title, color), Math.max(1, safeWidth - 6));
371
- const fill = Math.max(0, safeWidth - titleRunLength(safeTitleRuns) - 5);
377
+ const normalizedTitleRuns = normalizeTitleRuns(title, color);
378
+ const normalizedRightRuns = titleRight ? normalizeTitleRuns(titleRight, "gray") : [];
379
+ let safeTitleRuns = trimRuns(normalizedTitleRuns, Math.max(1, safeWidth - 6));
380
+ let safeRightRuns = trimRuns(normalizedRightRuns, Math.max(0, safeWidth - 8));
381
+ let titleWidth = titleRunLength(safeTitleRuns);
382
+ let rightWidth = titleRunLength(safeRightRuns);
383
+ const hasRightTitle = rightWidth > 0;
384
+ const chromeWidth = hasRightTitle ? 6 : 5;
385
+ const availableTitleWidth = Math.max(1, safeWidth - chromeWidth);
386
+
387
+ if (titleWidth + rightWidth > availableTitleWidth) {
388
+ safeTitleRuns = trimRuns(normalizedTitleRuns, Math.max(1, availableTitleWidth - rightWidth));
389
+ titleWidth = titleRunLength(safeTitleRuns);
390
+ if (titleWidth + rightWidth > availableTitleWidth) {
391
+ safeRightRuns = trimRuns(normalizedRightRuns, Math.max(0, availableTitleWidth - titleWidth));
392
+ rightWidth = titleRunLength(safeRightRuns);
393
+ }
394
+ }
395
+
396
+ const fill = Math.max(0, safeWidth - titleWidth - rightWidth - chromeWidth);
372
397
 
373
398
  return React.createElement(Box, null,
374
399
  React.createElement(Text, { color: resolveColorToken(color) }, "╭─ "),
375
400
  renderInlineRuns(safeTitleRuns, "title"),
376
- React.createElement(Text, { color: resolveColorToken(color) }, ` ${"─".repeat(fill)}╮`));
401
+ hasRightTitle
402
+ ? [
403
+ React.createElement(Text, { key: "title-fill", color: resolveColorToken(color) }, ` ${"─".repeat(fill)} `),
404
+ ...renderInlineRuns(safeRightRuns, "title-right"),
405
+ React.createElement(Text, { key: "title-end", color: resolveColorToken(color) }, "╮"),
406
+ ]
407
+ : React.createElement(Text, { color: resolveColorToken(color) }, ` ${"─".repeat(fill)}╮`));
377
408
  }
378
409
 
379
410
  function renderBorderBottom(color, width) {
@@ -576,11 +607,12 @@ function Header({ title, subtitle }) {
576
607
  justifyContent: "space-between",
577
608
  },
578
609
  React.createElement(Text, { bold: true, color: resolveColorToken("cyan") }, title),
579
- React.createElement(Text, { color: resolveColorToken("gray"), dimColor: true }, subtitle || ""));
610
+ React.createElement(Text, { color: resolveColorToken("gray"), dimColor: shouldDimGrayTextForTheme() }, subtitle || ""));
580
611
  }
581
612
 
582
613
  function Panel({
583
614
  title,
615
+ titleRight,
584
616
  color = "white",
585
617
  focused = false,
586
618
  width,
@@ -649,7 +681,7 @@ function Panel({
649
681
  flexGrow,
650
682
  flexBasis,
651
683
  },
652
- renderBorderTop(title, borderColor, safeWidth),
684
+ renderBorderTop(title, borderColor, safeWidth, titleRight),
653
685
  lines
654
686
  ? React.createElement(Box, { flexDirection: "column", flexGrow: 1, backgroundColor: fillColor || undefined },
655
687
  [
@@ -773,7 +805,7 @@ function Input({ label, value, focused, placeholder, rows = 1, cursorIndex = 0 }
773
805
  ? [
774
806
  renderPromptRow(placeholder || "Type a message and press Enter", focused ? 0 : null, {
775
807
  color: resolveColorToken("gray"),
776
- dimColor: true,
808
+ dimColor: shouldDimGrayTextForTheme(),
777
809
  showCursor: Boolean(focused),
778
810
  keyPrefix: "prompt-line:0",
779
811
  prefix: labelPrefix,
@@ -800,7 +832,7 @@ function StatusLine({ left, right }) {
800
832
  justifyContent: "space-between",
801
833
  },
802
834
  React.createElement(Text, { color: resolveColorToken("white") }, left || ""),
803
- React.createElement(Text, { color: resolveColorToken("gray"), dimColor: true }, right || ""));
835
+ React.createElement(Text, { color: resolveColorToken("gray"), dimColor: shouldDimGrayTextForTheme() }, right || ""));
804
836
  }
805
837
 
806
838
  function Overlay({ children }) {
@@ -0,0 +1,239 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const pkgRoot = path.resolve(__dirname, "..");
7
+ const defaultTuiSplashPath = path.join(pkgRoot, "tui-splash.txt");
8
+
9
+ function fileExists(filePath) {
10
+ try {
11
+ return fs.existsSync(filePath);
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ function readOptionalTextFile(filePath) {
18
+ try {
19
+ return fs.readFileSync(filePath, "utf-8").trimEnd();
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function getObject(value) {
26
+ return value && typeof value === "object" && !Array.isArray(value)
27
+ ? value
28
+ : {};
29
+ }
30
+
31
+ function firstNonEmptyString(...values) {
32
+ for (const value of values) {
33
+ if (typeof value === "string" && value.trim()) {
34
+ return value.trim();
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function resolveRelativePath(baseDir, relativePath) {
41
+ if (!baseDir || typeof relativePath !== "string" || !relativePath.trim()) return null;
42
+ const basePath = path.resolve(baseDir);
43
+ const filePath = path.resolve(basePath, relativePath);
44
+ if (filePath !== basePath && !filePath.startsWith(`${basePath}${path.sep}`)) {
45
+ return null;
46
+ }
47
+ return filePath;
48
+ }
49
+
50
+ function readRelativeTextFile(baseDir, relativePath) {
51
+ const filePath = resolveRelativePath(baseDir, relativePath);
52
+ if (!filePath) return null;
53
+ return readOptionalTextFile(filePath);
54
+ }
55
+
56
+ function resolveRelativeAssetFile(baseDir, relativePath) {
57
+ const filePath = resolveRelativePath(baseDir, relativePath);
58
+ if (!filePath || !fileExists(filePath)) return null;
59
+ return filePath;
60
+ }
61
+
62
+ function firstAssetUrl(...values) {
63
+ for (const value of values) {
64
+ if (typeof value !== "string" || !value.trim()) continue;
65
+ const trimmed = value.trim();
66
+ if (/^(https?:\/\/|\/|data:|blob:)/iu.test(trimmed)) {
67
+ return trimmed;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function resolvePortalAsset(baseDir, { file, url }) {
74
+ const directUrl = firstAssetUrl(url);
75
+ if (directUrl) {
76
+ return { filePath: null, publicUrl: directUrl };
77
+ }
78
+ const filePath = resolveRelativeAssetFile(baseDir, file);
79
+ if (!filePath) {
80
+ return { filePath: null, publicUrl: null };
81
+ }
82
+ return { filePath, publicUrl: null };
83
+ }
84
+
85
+ function readSplashValue(baseDir, config, fallback) {
86
+ if (typeof config?.splash === "string" && config.splash.trim()) {
87
+ return config.splash;
88
+ }
89
+ if (typeof config?.splashFile === "string" && config.splashFile.trim()) {
90
+ const fileText = readRelativeTextFile(baseDir, config.splashFile);
91
+ if (fileText != null) return fileText;
92
+ }
93
+ return fallback;
94
+ }
95
+
96
+ function getDefaultSplash() {
97
+ return readOptionalTextFile(defaultTuiSplashPath) || "{bold}{cyan-fg}PilotSwarm{/cyan-fg}{/bold}";
98
+ }
99
+
100
+ export function readPluginMetadata(pluginDir) {
101
+ if (!pluginDir) return null;
102
+ const pluginJsonPath = path.join(pluginDir, "plugin.json");
103
+ if (!fileExists(pluginJsonPath)) return null;
104
+ try {
105
+ return JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8"));
106
+ } catch (error) {
107
+ throw new Error(`Failed to parse plugin metadata: ${pluginJsonPath}: ${error.message}`);
108
+ }
109
+ }
110
+
111
+ export function getPluginDirsFromEnv() {
112
+ const envDirs = String(process.env.PLUGIN_DIRS || "")
113
+ .split(",")
114
+ .map((value) => value.trim())
115
+ .filter(Boolean)
116
+ .map((value) => path.resolve(value));
117
+ if (envDirs.length > 0) return envDirs;
118
+
119
+ const cwdPlugin = path.resolve(process.cwd(), "plugins");
120
+ if (fileExists(cwdPlugin)) return [cwdPlugin];
121
+
122
+ const bundledPlugin = path.join(pkgRoot, "plugins");
123
+ if (fileExists(bundledPlugin)) return [bundledPlugin];
124
+
125
+ return [];
126
+ }
127
+
128
+ export function resolveTuiBranding(pluginDir) {
129
+ const pluginMeta = readPluginMetadata(pluginDir);
130
+ const tui = pluginMeta?.tui;
131
+ const defaultSplash = getDefaultSplash();
132
+ if (!tui || typeof tui !== "object") {
133
+ return { title: "PilotSwarm", splash: defaultSplash };
134
+ }
135
+
136
+ const title = firstNonEmptyString(tui.title, "PilotSwarm") || "PilotSwarm";
137
+ const splash = readSplashValue(pluginDir, tui, defaultSplash);
138
+ return { title, splash };
139
+ }
140
+
141
+ export function resolvePortalConfigBundleFromPluginDirs(pluginDirs = []) {
142
+ const defaultSplash = getDefaultSplash();
143
+ const defaults = {
144
+ branding: {
145
+ title: "PilotSwarm",
146
+ pageTitle: "PilotSwarm",
147
+ splash: defaultSplash,
148
+ logoUrl: null,
149
+ faviconUrl: null,
150
+ },
151
+ ui: {
152
+ loadingMessage: "Preparing your workspace",
153
+ loadingCopy: "Connecting the shared workspace and live session feeds...",
154
+ },
155
+ auth: {
156
+ provider: null,
157
+ providers: {},
158
+ signInTitle: "Sign in to PilotSwarm",
159
+ signInMessage: null,
160
+ signInLabel: "Sign In",
161
+ },
162
+ };
163
+
164
+ for (const pluginDir of pluginDirs) {
165
+ const absDir = path.resolve(pluginDir);
166
+ const pluginMeta = readPluginMetadata(absDir);
167
+ if (!pluginMeta) continue;
168
+
169
+ const portal = getObject(pluginMeta?.portal);
170
+ const portalBranding = getObject(portal.branding);
171
+ const portalUi = getObject(portal.ui);
172
+ const portalAuth = getObject(portal.auth);
173
+ const tui = getObject(pluginMeta?.tui);
174
+
175
+ const title = firstNonEmptyString(portalBranding.title, portal.title, tui.title, defaults.branding.title) || defaults.branding.title;
176
+ const pageTitle = firstNonEmptyString(portalBranding.pageTitle, portal.pageTitle, title, defaults.branding.pageTitle) || defaults.branding.pageTitle;
177
+ const splash = readSplashValue(
178
+ absDir,
179
+ portalBranding,
180
+ readSplashValue(absDir, portal, readSplashValue(absDir, tui, defaults.branding.splash)),
181
+ );
182
+ const logoAsset = resolvePortalAsset(absDir, {
183
+ file: firstNonEmptyString(portalBranding.logoFile, portal.logoFile),
184
+ url: firstNonEmptyString(portalBranding.logoUrl, portal.logoUrl),
185
+ });
186
+ const faviconAsset = resolvePortalAsset(absDir, {
187
+ file: firstNonEmptyString(portalBranding.faviconFile, portal.faviconFile, portalBranding.logoFile, portal.logoFile),
188
+ url: firstNonEmptyString(portalBranding.faviconUrl, portal.faviconUrl, portalBranding.logoUrl, portal.logoUrl),
189
+ });
190
+
191
+ const assetFiles = {};
192
+ const branding = {
193
+ title,
194
+ pageTitle,
195
+ splash,
196
+ logoUrl: logoAsset.publicUrl || null,
197
+ faviconUrl: faviconAsset.publicUrl || null,
198
+ };
199
+
200
+ if (logoAsset.filePath) {
201
+ assetFiles.logo = logoAsset.filePath;
202
+ branding.logoUrl = "/api/portal-assets/logo";
203
+ }
204
+ if (faviconAsset.filePath) {
205
+ assetFiles.favicon = faviconAsset.filePath;
206
+ branding.faviconUrl = "/api/portal-assets/favicon";
207
+ }
208
+ if (!branding.faviconUrl && branding.logoUrl) {
209
+ branding.faviconUrl = branding.logoUrl;
210
+ }
211
+
212
+ return {
213
+ portalConfig: {
214
+ branding,
215
+ ui: {
216
+ loadingMessage: firstNonEmptyString(portalUi.loadingMessage, portal.loadingMessage, defaults.ui.loadingMessage) || defaults.ui.loadingMessage,
217
+ loadingCopy: firstNonEmptyString(portalUi.loadingCopy, portal.loadingCopy, defaults.ui.loadingCopy) || defaults.ui.loadingCopy,
218
+ },
219
+ auth: {
220
+ provider: firstNonEmptyString(portalAuth.provider, portal.provider),
221
+ providers: getObject(portalAuth.providers),
222
+ signInTitle: firstNonEmptyString(portalAuth.signInTitle, portal.signInTitle, `Sign in to ${title}`) || `Sign in to ${title}`,
223
+ signInMessage: firstNonEmptyString(portalAuth.signInMessage, portal.signInMessage, defaults.auth.signInMessage),
224
+ signInLabel: firstNonEmptyString(portalAuth.signInLabel, defaults.auth.signInLabel) || defaults.auth.signInLabel,
225
+ },
226
+ },
227
+ assetFiles,
228
+ };
229
+ }
230
+
231
+ return {
232
+ portalConfig: defaults,
233
+ assetFiles: {},
234
+ };
235
+ }
236
+
237
+ export function resolvePortalConfigFromPluginDirs(pluginDirs = []) {
238
+ return resolvePortalConfigBundleFromPluginDirs(pluginDirs).portalConfig;
239
+ }
package/src/portal.js ADDED
@@ -0,0 +1,7 @@
1
+ export { NodeSdkTransport } from "./node-sdk-transport.js";
2
+ export {
3
+ getPluginDirsFromEnv,
4
+ readPluginMetadata,
5
+ resolvePortalConfigBundleFromPluginDirs,
6
+ resolvePortalConfigFromPluginDirs,
7
+ } from "./plugin-config.js";