pilotswarm 0.0.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -1
- package/mcp/README.md +484 -0
- package/mcp/dist/bin/pilotswarm-mcp.d.ts +3 -0
- package/mcp/dist/bin/pilotswarm-mcp.d.ts.map +1 -0
- package/mcp/dist/bin/pilotswarm-mcp.js +367 -0
- package/mcp/dist/bin/pilotswarm-mcp.js.map +1 -0
- package/mcp/dist/src/auth.d.ts +7 -0
- package/mcp/dist/src/auth.d.ts.map +1 -0
- package/mcp/dist/src/auth.js +99 -0
- package/mcp/dist/src/auth.js.map +1 -0
- package/mcp/dist/src/context.d.ts +48 -0
- package/mcp/dist/src/context.d.ts.map +1 -0
- package/mcp/dist/src/context.js +83 -0
- package/mcp/dist/src/context.js.map +1 -0
- package/mcp/dist/src/index.d.ts +4 -0
- package/mcp/dist/src/index.d.ts.map +1 -0
- package/mcp/dist/src/index.js +3 -0
- package/mcp/dist/src/index.js.map +1 -0
- package/mcp/dist/src/prompts/skills.d.ts +4 -0
- package/mcp/dist/src/prompts/skills.d.ts.map +1 -0
- package/mcp/dist/src/prompts/skills.js +11 -0
- package/mcp/dist/src/prompts/skills.js.map +1 -0
- package/mcp/dist/src/resources/agents.d.ts +4 -0
- package/mcp/dist/src/resources/agents.d.ts.map +1 -0
- package/mcp/dist/src/resources/agents.js +64 -0
- package/mcp/dist/src/resources/agents.js.map +1 -0
- package/mcp/dist/src/resources/facts.d.ts +4 -0
- package/mcp/dist/src/resources/facts.d.ts.map +1 -0
- package/mcp/dist/src/resources/facts.js +125 -0
- package/mcp/dist/src/resources/facts.js.map +1 -0
- package/mcp/dist/src/resources/models.d.ts +4 -0
- package/mcp/dist/src/resources/models.d.ts.map +1 -0
- package/mcp/dist/src/resources/models.js +43 -0
- package/mcp/dist/src/resources/models.js.map +1 -0
- package/mcp/dist/src/resources/sessions.d.ts +4 -0
- package/mcp/dist/src/resources/sessions.d.ts.map +1 -0
- package/mcp/dist/src/resources/sessions.js +190 -0
- package/mcp/dist/src/resources/sessions.js.map +1 -0
- package/mcp/dist/src/resources/subscriptions.d.ts +9 -0
- package/mcp/dist/src/resources/subscriptions.d.ts.map +1 -0
- package/mcp/dist/src/resources/subscriptions.js +157 -0
- package/mcp/dist/src/resources/subscriptions.js.map +1 -0
- package/mcp/dist/src/server.d.ts +4 -0
- package/mcp/dist/src/server.d.ts.map +1 -0
- package/mcp/dist/src/server.js +59 -0
- package/mcp/dist/src/server.js.map +1 -0
- package/mcp/dist/src/tools/agents.d.ts +4 -0
- package/mcp/dist/src/tools/agents.d.ts.map +1 -0
- package/mcp/dist/src/tools/agents.js +317 -0
- package/mcp/dist/src/tools/agents.js.map +1 -0
- package/mcp/dist/src/tools/facts.d.ts +4 -0
- package/mcp/dist/src/tools/facts.d.ts.map +1 -0
- package/mcp/dist/src/tools/facts.js +151 -0
- package/mcp/dist/src/tools/facts.js.map +1 -0
- package/mcp/dist/src/tools/models.d.ts +4 -0
- package/mcp/dist/src/tools/models.d.ts.map +1 -0
- package/mcp/dist/src/tools/models.js +256 -0
- package/mcp/dist/src/tools/models.js.map +1 -0
- package/mcp/dist/src/tools/sessions.d.ts +4 -0
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -0
- package/mcp/dist/src/tools/sessions.js +606 -0
- package/mcp/dist/src/tools/sessions.js.map +1 -0
- package/mcp/dist/src/util/command.d.ts +52 -0
- package/mcp/dist/src/util/command.d.ts.map +1 -0
- package/mcp/dist/src/util/command.js +78 -0
- package/mcp/dist/src/util/command.js.map +1 -0
- package/package.json +81 -6
- package/tui/README.md +35 -0
- package/tui/bin/tui.js +30 -0
- package/tui/plugins/.mcp.json +7 -0
- package/tui/plugins/plugin.json +13 -0
- package/tui/plugins/session-policy.json +8 -0
- package/tui/src/app.js +850 -0
- package/tui/src/auth/cli.js +111 -0
- package/tui/src/auth/entra-auth.js +218 -0
- package/tui/src/bootstrap-env.js +176 -0
- package/tui/src/embedded-workers.js +79 -0
- package/tui/src/http-transport-host.js +106 -0
- package/tui/src/index.js +340 -0
- package/tui/src/node-sdk-transport.js +1793 -0
- package/tui/src/platform.js +984 -0
- package/tui/src/plugin-config.js +239 -0
- package/tui/src/portal.js +7 -0
- package/tui/src/version.js +7 -0
- package/tui/tui-splash.txt +11 -0
- package/ui/core/README.md +6 -0
- package/ui/core/src/commands.js +93 -0
- package/ui/core/src/context-usage.js +212 -0
- package/ui/core/src/controller.js +6104 -0
- package/ui/core/src/formatting.js +1036 -0
- package/ui/core/src/history.js +932 -0
- package/ui/core/src/index.js +13 -0
- package/ui/core/src/layout.js +332 -0
- package/ui/core/src/reducer.js +1935 -0
- package/ui/core/src/selectors.js +5409 -0
- package/ui/core/src/session-errors.js +14 -0
- package/ui/core/src/session-tree.js +151 -0
- package/ui/core/src/state.js +248 -0
- package/ui/core/src/store.js +23 -0
- package/ui/core/src/system-titles.js +24 -0
- package/ui/core/src/themes/catppuccin-mocha.js +56 -0
- package/ui/core/src/themes/cobalt2.js +56 -0
- package/ui/core/src/themes/dark-high-contrast.js +56 -0
- package/ui/core/src/themes/daylight.js +62 -0
- package/ui/core/src/themes/dracula.js +56 -0
- package/ui/core/src/themes/github-dark.js +56 -0
- package/ui/core/src/themes/github-light.js +59 -0
- package/ui/core/src/themes/gruvbox-dark.js +56 -0
- package/ui/core/src/themes/hacker-x-matrix.js +56 -0
- package/ui/core/src/themes/hacker-x-orion-prime.js +56 -0
- package/ui/core/src/themes/helpers.js +79 -0
- package/ui/core/src/themes/high-contrast-mono.js +59 -0
- package/ui/core/src/themes/index.js +52 -0
- package/ui/core/src/themes/light-high-contrast.js +62 -0
- package/ui/core/src/themes/noctis-obscuro.js +56 -0
- package/ui/core/src/themes/noctis.js +56 -0
- package/ui/core/src/themes/paper-ink.js +62 -0
- package/ui/core/src/themes/solarized-ops.js +59 -0
- package/ui/core/src/themes/terminal-green.js +59 -0
- package/ui/core/src/themes/tokyo-night.js +56 -0
- package/ui/react/README.md +5 -0
- package/ui/react/src/chat-status.js +39 -0
- package/ui/react/src/components.js +1989 -0
- package/ui/react/src/index.js +4 -0
- package/ui/react/src/platform.js +15 -0
- package/ui/react/src/use-controller-state.js +38 -0
- package/ui/react/src/web-app.js +4390 -0
- package/web/README.md +198 -0
- package/web/api/router.js +196 -0
- package/web/api/ws.js +152 -0
- package/web/auth/authz/engine.js +204 -0
- package/web/auth/config.js +115 -0
- package/web/auth/index.js +175 -0
- package/web/auth/normalize/entra.js +22 -0
- package/web/auth/providers/entra.js +76 -0
- package/web/auth/providers/none.js +24 -0
- package/web/auth.js +10 -0
- package/web/bin/serve.js +53 -0
- package/web/config.js +20 -0
- package/web/dist/app.js +469 -0
- package/web/dist/assets/index-CBgQQk-j.css +1 -0
- package/web/dist/assets/index-DMefB7Wb.js +24 -0
- package/web/dist/assets/msal-CytV9RFv.js +7 -0
- package/web/dist/assets/pilotswarm-DyBlqVTY.js +87 -0
- package/web/dist/assets/react-BZwsW8pe.js +1 -0
- package/web/dist/index.html +16 -0
- package/web/runtime.js +454 -0
- package/web/server.js +276 -0
- package/index.js +0 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import {
|
|
3
|
+
createTokenProvider,
|
|
4
|
+
fetchAuthConfig,
|
|
5
|
+
getSignedInAccount,
|
|
6
|
+
signIn,
|
|
7
|
+
signOut,
|
|
8
|
+
} from "./entra-auth.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `pilotswarm auth login|status|logout --api-url <url>`
|
|
12
|
+
*
|
|
13
|
+
* Pre-provision or inspect the per-origin token cache used by the TUI's
|
|
14
|
+
* API mode. The TUI triggers the same sign-in lazily on start, so
|
|
15
|
+
* `auth login` is optional.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function resolveApiUrl(flags) {
|
|
19
|
+
const apiUrl = String(flags["api-url"] || process.env.PILOTSWARM_API_URL || "").trim();
|
|
20
|
+
if (!apiUrl) {
|
|
21
|
+
throw new Error("An API URL is required: pass --api-url <url> or set PILOTSWARM_API_URL.");
|
|
22
|
+
}
|
|
23
|
+
return apiUrl.replace(/\/+$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the deployment's auth mode and, for Entra, run sign-in (silent
|
|
28
|
+
* first, then device code) and return a token supplier. Used by both the
|
|
29
|
+
* auth subcommands and TUI startup — runs before the Ink app renders.
|
|
30
|
+
*/
|
|
31
|
+
export async function bootstrapApiAuth(apiUrl, { output = console.error, interactive = true, useDeviceCode = false } = {}) {
|
|
32
|
+
const authConfig = await fetchAuthConfig(apiUrl);
|
|
33
|
+
if (!authConfig?.enabled || authConfig?.provider === "none") {
|
|
34
|
+
return { authConfig, getAccessToken: async () => null };
|
|
35
|
+
}
|
|
36
|
+
if (authConfig.provider !== "entra") {
|
|
37
|
+
throw new Error(`Unsupported auth provider '${authConfig.provider}' reported by ${apiUrl}.`);
|
|
38
|
+
}
|
|
39
|
+
if (interactive) {
|
|
40
|
+
await signIn(apiUrl, authConfig, { output, useDeviceCode });
|
|
41
|
+
}
|
|
42
|
+
const getAccessToken = await createTokenProvider(apiUrl, authConfig);
|
|
43
|
+
return { authConfig, getAccessToken };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runAuthCommand(argv) {
|
|
47
|
+
const { values: flags, positionals } = parseArgs({
|
|
48
|
+
options: {
|
|
49
|
+
"api-url": { type: "string" },
|
|
50
|
+
"device-code": { type: "boolean" },
|
|
51
|
+
env: { type: "string", short: "e" },
|
|
52
|
+
help: { type: "boolean", short: "h" },
|
|
53
|
+
},
|
|
54
|
+
allowPositionals: true,
|
|
55
|
+
strict: false,
|
|
56
|
+
args: argv,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const action = positionals[0];
|
|
60
|
+
if (flags.help || !["login", "status", "logout"].includes(action)) {
|
|
61
|
+
console.log(`
|
|
62
|
+
pilotswarm auth — manage Web API sign-in
|
|
63
|
+
|
|
64
|
+
USAGE
|
|
65
|
+
pilotswarm auth login --api-url <url> Sign in (opens your browser) and cache tokens
|
|
66
|
+
pilotswarm auth status --api-url <url> Show the signed-in account
|
|
67
|
+
pilotswarm auth logout --api-url <url> Drop cached tokens for that origin
|
|
68
|
+
|
|
69
|
+
FLAGS
|
|
70
|
+
--device-code Use the device-code flow instead of the browser
|
|
71
|
+
(headless hosts; only where the tenant permits it —
|
|
72
|
+
many corp tenants block device code via Conditional Access)
|
|
73
|
+
|
|
74
|
+
PILOTSWARM_API_URL can replace --api-url.
|
|
75
|
+
`.trim());
|
|
76
|
+
return flags.help ? 0 : 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const apiUrl = resolveApiUrl(flags);
|
|
80
|
+
|
|
81
|
+
if (action === "logout") {
|
|
82
|
+
const cacheFile = await signOut(apiUrl);
|
|
83
|
+
console.log(`Signed out of ${apiUrl} (removed ${cacheFile}).`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const authConfig = await fetchAuthConfig(apiUrl);
|
|
88
|
+
if (!authConfig?.enabled || authConfig?.provider === "none") {
|
|
89
|
+
console.log(`${apiUrl} does not require sign-in (auth provider: ${authConfig?.provider || "none"}).`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (action === "login") {
|
|
94
|
+
const result = await signIn(apiUrl, authConfig, { output: console.log, useDeviceCode: Boolean(flags["device-code"]) });
|
|
95
|
+
const username = result?.account?.username || result?.account?.name || "signed in";
|
|
96
|
+
console.log(`Signed in to ${apiUrl} as ${username}.`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// status
|
|
101
|
+
const account = await getSignedInAccount(apiUrl, authConfig);
|
|
102
|
+
if (!account) {
|
|
103
|
+
console.log(`Not signed in to ${apiUrl}. Run: pilotswarm auth login --api-url ${apiUrl}`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
console.log(`Signed in to ${apiUrl} as ${account.username || account.name}.`);
|
|
107
|
+
const getAccessToken = await createTokenProvider(apiUrl, authConfig);
|
|
108
|
+
const token = await getAccessToken();
|
|
109
|
+
console.log(token ? "Token cache is valid (silent acquisition succeeded)." : "Token cache is stale — run: pilotswarm auth login");
|
|
110
|
+
return token ? 0 : 1;
|
|
111
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ApiClient } from "pilotswarm-sdk/api";
|
|
5
|
+
import { openUrlInDefaultBrowser } from "../http-transport-host.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Entra ID sign-in for the TUI's API mode.
|
|
9
|
+
*
|
|
10
|
+
* Default flow: OAuth 2.0 authorization code + PKCE via msal-node's
|
|
11
|
+
* `acquireTokenInteractive`. It opens the system browser to a normal Entra
|
|
12
|
+
* sign-in and captures the code on a loopback (`http://localhost:<port>`)
|
|
13
|
+
* redirect — the same interactive flow `az login`, the GitHub CLI, and the
|
|
14
|
+
* browser portal use. Crucially, it satisfies Conditional Access policies
|
|
15
|
+
* that block the device-code flow (common in hardened corporate tenants;
|
|
16
|
+
* Microsoft's own tenant returns AADSTS53003 for device code).
|
|
17
|
+
*
|
|
18
|
+
* Fallback flow: device code (`useDeviceCode`) for headless hosts with no
|
|
19
|
+
* browser — only usable where the tenant permits it.
|
|
20
|
+
*
|
|
21
|
+
* The TUI never carries tenant/client ids in local config — they are
|
|
22
|
+
* discovered from the deployment's public `GET /api/v1/auth/config`, so
|
|
23
|
+
* app-registration rotation never breaks clients. Tokens are cached per API
|
|
24
|
+
* origin at `~/.config/pilotswarm/auth/<origin>.json` (0600). MSAL owns
|
|
25
|
+
* refresh; `getAccessToken` runs acquireTokenSilent per call.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const AUTH_CACHE_DIR = path.join(
|
|
29
|
+
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
|
|
30
|
+
"pilotswarm",
|
|
31
|
+
"auth",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const BROWSER_RESULT_HTML = (heading, body) => `<!doctype html><html><head><meta charset="utf-8">`
|
|
35
|
+
+ `<title>PilotSwarm</title><style>body{font-family:-apple-system,Segoe UI,sans-serif;`
|
|
36
|
+
+ `background:#0d1117;color:#e5edf5;display:flex;align-items:center;justify-content:center;`
|
|
37
|
+
+ `height:100vh;margin:0}.card{text-align:center;max-width:28rem;padding:2rem}`
|
|
38
|
+
+ `h1{font-size:1.25rem;margin:0 0 .5rem}p{color:#8b949e}</style></head>`
|
|
39
|
+
+ `<body><div class="card"><h1>${heading}</h1><p>${body}</p></div></body></html>`;
|
|
40
|
+
|
|
41
|
+
const SUCCESS_TEMPLATE = BROWSER_RESULT_HTML("Signed in to PilotSwarm", "You can close this tab and return to the terminal.");
|
|
42
|
+
const ERROR_TEMPLATE = BROWSER_RESULT_HTML("Sign-in failed", "Return to the terminal for details.");
|
|
43
|
+
|
|
44
|
+
function cacheFileForOrigin(apiUrl) {
|
|
45
|
+
const origin = new URL(apiUrl).origin.replace(/[^\w.-]+/g, "_");
|
|
46
|
+
return path.join(AUTH_CACHE_DIR, `${origin}.json`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createCachePlugin(cacheFile) {
|
|
50
|
+
return {
|
|
51
|
+
beforeCacheAccess: async (cacheContext) => {
|
|
52
|
+
try {
|
|
53
|
+
cacheContext.tokenCache.deserialize(await fs.promises.readFile(cacheFile, "utf8"));
|
|
54
|
+
} catch {}
|
|
55
|
+
},
|
|
56
|
+
afterCacheAccess: async (cacheContext) => {
|
|
57
|
+
if (!cacheContext.cacheHasChanged) return;
|
|
58
|
+
await fs.promises.mkdir(AUTH_CACHE_DIR, { recursive: true, mode: 0o700 });
|
|
59
|
+
await fs.promises.writeFile(cacheFile, cacheContext.tokenCache.serialize(), { mode: 0o600 });
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function fetchAuthConfig(apiUrl) {
|
|
65
|
+
const api = new ApiClient({ apiUrl });
|
|
66
|
+
return api.getAuthConfig();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function requireEntraClientConfig(authConfig, apiUrl) {
|
|
70
|
+
const client = authConfig?.client;
|
|
71
|
+
if (!client?.clientId || !client?.authority) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`The deployment at ${apiUrl} reports Entra auth but no public client configuration. `
|
|
74
|
+
+ "Check PORTAL_AUTH_ENTRA_TENANT_ID / PORTAL_AUTH_ENTRA_CLIENT_ID on the server.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return client;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function createPublicClientApp(authConfig, apiUrl) {
|
|
81
|
+
const client = requireEntraClientConfig(authConfig, apiUrl);
|
|
82
|
+
const { PublicClientApplication } = await import("@azure/msal-node");
|
|
83
|
+
const app = new PublicClientApplication({
|
|
84
|
+
auth: {
|
|
85
|
+
clientId: client.clientId,
|
|
86
|
+
authority: client.authority,
|
|
87
|
+
},
|
|
88
|
+
cache: {
|
|
89
|
+
cachePlugin: createCachePlugin(cacheFileForOrigin(apiUrl)),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
return { app, scopes: [`${client.clientId}/.default`] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function firstCachedAccount(app) {
|
|
96
|
+
const accounts = await app.getTokenCache().getAllAccounts();
|
|
97
|
+
return accounts[0] || null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// MSAL's loopback listener never times out on its own; bound the wait so a
|
|
101
|
+
// browser that never opens (headless/SSH, or a failed launch) surfaces an
|
|
102
|
+
// actionable error instead of hanging TUI startup forever. Generous enough
|
|
103
|
+
// for interactive MFA.
|
|
104
|
+
const INTERACTIVE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
105
|
+
|
|
106
|
+
async function acquireInteractive(app, scopes, apiUrl, output) {
|
|
107
|
+
output("");
|
|
108
|
+
output(` Opening your browser to sign in to ${apiUrl} …`);
|
|
109
|
+
output("");
|
|
110
|
+
const request = {
|
|
111
|
+
scopes,
|
|
112
|
+
successTemplate: SUCCESS_TEMPLATE,
|
|
113
|
+
errorTemplate: ERROR_TEMPLATE,
|
|
114
|
+
openBrowser: async (url) => {
|
|
115
|
+
// Print the URL too so the user can copy it if the browser does
|
|
116
|
+
// not open on its own (SSH, restricted shells).
|
|
117
|
+
output(` If your browser does not open, visit:\n ${url}`);
|
|
118
|
+
output("");
|
|
119
|
+
try {
|
|
120
|
+
await openUrlInDefaultBrowser(url);
|
|
121
|
+
} catch {
|
|
122
|
+
output(" (Could not launch a browser automatically — paste the URL above, or");
|
|
123
|
+
output(" press Ctrl-C and re-run with --device-code on a headless host.)");
|
|
124
|
+
output("");
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
let timer;
|
|
130
|
+
const timeout = new Promise((_, reject) => {
|
|
131
|
+
timer = setTimeout(() => {
|
|
132
|
+
reject(new Error(
|
|
133
|
+
"Browser sign-in timed out. If your browser did not open, paste the printed URL "
|
|
134
|
+
+ "into a browser on this machine, or re-run with --device-code.",
|
|
135
|
+
));
|
|
136
|
+
}, INTERACTIVE_TIMEOUT_MS);
|
|
137
|
+
timer.unref?.();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
let result;
|
|
141
|
+
try {
|
|
142
|
+
result = await Promise.race([app.acquireTokenInteractive(request), timeout]);
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
146
|
+
if (!result?.accessToken) {
|
|
147
|
+
throw new Error("Entra sign-in did not produce an access token.");
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function acquireDeviceCode(app, scopes, apiUrl, output) {
|
|
153
|
+
const result = await app.acquireTokenByDeviceCode({
|
|
154
|
+
scopes,
|
|
155
|
+
deviceCodeCallback: (deviceCode) => {
|
|
156
|
+
output("");
|
|
157
|
+
output(` Sign in to ${apiUrl}`);
|
|
158
|
+
output(` Visit ${deviceCode.verificationUri} and enter code ${deviceCode.userCode}`);
|
|
159
|
+
output("");
|
|
160
|
+
openUrlInDefaultBrowser(deviceCode.verificationUri).catch(() => {});
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
if (!result?.accessToken) {
|
|
164
|
+
throw new Error("Entra sign-in did not produce an access token.");
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Interactive sign-in: silent first, then the browser (auth-code+PKCE) flow —
|
|
171
|
+
* or the device-code flow when `useDeviceCode` is set.
|
|
172
|
+
*/
|
|
173
|
+
export async function signIn(apiUrl, authConfig, { output = console.error, useDeviceCode = false } = {}) {
|
|
174
|
+
const { app, scopes } = await createPublicClientApp(authConfig, apiUrl);
|
|
175
|
+
|
|
176
|
+
const account = await firstCachedAccount(app);
|
|
177
|
+
if (account) {
|
|
178
|
+
try {
|
|
179
|
+
const silent = await app.acquireTokenSilent({ account, scopes });
|
|
180
|
+
if (silent?.accessToken) return silent;
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return useDeviceCode
|
|
185
|
+
? acquireDeviceCode(app, scopes, apiUrl, output)
|
|
186
|
+
: acquireInteractive(app, scopes, apiUrl, output);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Build the `getAccessToken` callback for an Entra deployment. Runs the
|
|
191
|
+
* silent flow per call (MSAL caches in memory); if silent acquisition fails
|
|
192
|
+
* the caller sees null and the server's 401 tells the user to run
|
|
193
|
+
* `pilotswarm auth login`.
|
|
194
|
+
*/
|
|
195
|
+
export async function createTokenProvider(apiUrl, authConfig) {
|
|
196
|
+
const { app, scopes } = await createPublicClientApp(authConfig, apiUrl);
|
|
197
|
+
return async () => {
|
|
198
|
+
const account = await firstCachedAccount(app);
|
|
199
|
+
if (!account) return null;
|
|
200
|
+
try {
|
|
201
|
+
const result = await app.acquireTokenSilent({ account, scopes });
|
|
202
|
+
return result?.accessToken || null;
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function getSignedInAccount(apiUrl, authConfig) {
|
|
210
|
+
const { app } = await createPublicClientApp(authConfig, apiUrl);
|
|
211
|
+
return firstCachedAccount(app);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function signOut(apiUrl) {
|
|
215
|
+
const cacheFile = cacheFileForOrigin(apiUrl);
|
|
216
|
+
await fs.promises.rm(cacheFile, { force: true });
|
|
217
|
+
return cacheFile;
|
|
218
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { resolveTuiBranding } from "./plugin-config.js";
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const pkgRoot = path.resolve(__dirname, "..");
|
|
10
|
+
|
|
11
|
+
function resolvePluginDir(flags) {
|
|
12
|
+
if (flags.plugin) return path.resolve(flags.plugin);
|
|
13
|
+
if (process.env.PLUGIN_DIRS) {
|
|
14
|
+
const dirs = process.env.PLUGIN_DIRS.split(",").map((value) => value.trim()).filter(Boolean);
|
|
15
|
+
return dirs[0] || null;
|
|
16
|
+
}
|
|
17
|
+
const cwdPlugin = path.resolve("plugins");
|
|
18
|
+
if (fs.existsSync(cwdPlugin)) return cwdPlugin;
|
|
19
|
+
const bundledPlugin = path.join(pkgRoot, "plugins");
|
|
20
|
+
if (fs.existsSync(bundledPlugin)) return bundledPlugin;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveSystemMessage(flags) {
|
|
25
|
+
if (flags.system) {
|
|
26
|
+
if (fs.existsSync(flags.system)) {
|
|
27
|
+
return fs.readFileSync(flags.system, "utf-8").trim();
|
|
28
|
+
}
|
|
29
|
+
return flags.system;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pluginDir = resolvePluginDir(flags);
|
|
33
|
+
if (pluginDir) {
|
|
34
|
+
const systemMd = path.join(pluginDir, "system.md");
|
|
35
|
+
if (fs.existsSync(systemMd)) {
|
|
36
|
+
return fs.readFileSync(systemMd, "utf-8").trim();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (process.env.SYSTEM_MESSAGE) return process.env.SYSTEM_MESSAGE;
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadEnvFile(envFile) {
|
|
45
|
+
if (!fs.existsSync(envFile)) return;
|
|
46
|
+
const envContent = fs.readFileSync(envFile, "utf-8");
|
|
47
|
+
for (const line of envContent.split("\n")) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
50
|
+
const eqIdx = trimmed.indexOf("=");
|
|
51
|
+
if (eqIdx === -1) continue;
|
|
52
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
53
|
+
const value = trimmed.slice(eqIdx + 1).trim();
|
|
54
|
+
if (!process.env[key]) {
|
|
55
|
+
process.env[key] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureGithubToken() {
|
|
61
|
+
if (process.env.GITHUB_TOKEN) return;
|
|
62
|
+
try {
|
|
63
|
+
const token = execFileSync("gh", ["auth", "token"], {
|
|
64
|
+
encoding: "utf-8",
|
|
65
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
66
|
+
}).trim();
|
|
67
|
+
if (token) {
|
|
68
|
+
process.env.GITHUB_TOKEN = token;
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseCliIntoEnv(argv) {
|
|
74
|
+
const { values: flags, positionals } = parseArgs({
|
|
75
|
+
options: {
|
|
76
|
+
store: { type: "string", short: "s" },
|
|
77
|
+
"api-url": { type: "string" },
|
|
78
|
+
"device-code": { type: "boolean" },
|
|
79
|
+
env: { type: "string", short: "e" },
|
|
80
|
+
plugin: { type: "string", short: "p" },
|
|
81
|
+
worker: { type: "string", short: "w" },
|
|
82
|
+
workers: { type: "string", short: "n" },
|
|
83
|
+
model: { type: "string", short: "m" },
|
|
84
|
+
system: { type: "string" },
|
|
85
|
+
context: { type: "string", short: "c" },
|
|
86
|
+
namespace: { type: "string" },
|
|
87
|
+
label: { type: "string" },
|
|
88
|
+
help: { type: "boolean", short: "h" },
|
|
89
|
+
},
|
|
90
|
+
allowPositionals: true,
|
|
91
|
+
strict: false,
|
|
92
|
+
args: argv,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (flags.help) {
|
|
96
|
+
console.log(`
|
|
97
|
+
pilotswarm — PilotSwarm terminal UI
|
|
98
|
+
|
|
99
|
+
USAGE
|
|
100
|
+
npx pilotswarm [local|remote] [flags]
|
|
101
|
+
npx pilotswarm auth login|status|logout --api-url <url>
|
|
102
|
+
|
|
103
|
+
MODES
|
|
104
|
+
local Run everything locally (embedded workers)
|
|
105
|
+
remote Connect to a deployment's Web API (--api-url; the
|
|
106
|
+
supported remote mode) or directly to its database
|
|
107
|
+
(--store; internal/operator use)
|
|
108
|
+
|
|
109
|
+
FLAGS
|
|
110
|
+
--api-url <url> Web API base URL for remote mode (or PILOTSWARM_API_URL)
|
|
111
|
+
--device-code Entra sign-in via device code instead of the browser
|
|
112
|
+
(headless; only where the tenant allows it)
|
|
113
|
+
-e, --env <file> Env file
|
|
114
|
+
-p, --plugin <dir> Plugin directory
|
|
115
|
+
-w, --worker <module> Worker tools module (local mode)
|
|
116
|
+
-n, --workers <count> Embedded worker count (local mode)
|
|
117
|
+
-m, --model <name> Initial model
|
|
118
|
+
-c, --context <ctx> K8s context (direct remote mode only)
|
|
119
|
+
--namespace <ns> K8s namespace (direct remote mode only)
|
|
120
|
+
--label <selector> K8s pod label (direct remote mode only)
|
|
121
|
+
-h, --help Show help
|
|
122
|
+
`.trim());
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const mode = positionals[0] === "remote" ? "remote" : "local";
|
|
127
|
+
const envFile = flags.env || (mode === "remote" ? ".env.remote" : ".env");
|
|
128
|
+
loadEnvFile(envFile);
|
|
129
|
+
ensureGithubToken();
|
|
130
|
+
|
|
131
|
+
const apiUrl = String(flags["api-url"] || process.env.PILOTSWARM_API_URL || "").trim().replace(/\/+$/, "");
|
|
132
|
+
if (apiUrl && mode !== "remote") {
|
|
133
|
+
throw new Error("--api-url is only valid in remote mode: npx pilotswarm remote --api-url <url>");
|
|
134
|
+
}
|
|
135
|
+
if (apiUrl && flags.store) {
|
|
136
|
+
throw new Error("Pass either --api-url (Web API remote mode) or --store (direct remote mode), not both.");
|
|
137
|
+
}
|
|
138
|
+
if (apiUrl && (flags.context || flags.namespace || flags.label)) {
|
|
139
|
+
console.warn("[pilotswarm] K8s log-tail flags are ignored in API mode — the server tails logs in-cluster.");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
process.env.DATABASE_URL = flags.store || process.env.DATABASE_URL || "sqlite::memory:";
|
|
143
|
+
process.env.WORKERS = mode === "remote" ? "0" : (flags.workers ?? process.env.WORKERS ?? "4");
|
|
144
|
+
process.env.COPILOT_MODEL = flags.model || process.env.COPILOT_MODEL || "";
|
|
145
|
+
process.env.K8S_CONTEXT = flags.context || process.env.K8S_CONTEXT || "";
|
|
146
|
+
process.env.K8S_NAMESPACE = flags.namespace || process.env.K8S_NAMESPACE || "copilot-runtime";
|
|
147
|
+
process.env.K8S_POD_LABEL = flags.label || process.env.K8S_POD_LABEL || "app.kubernetes.io/component=worker";
|
|
148
|
+
|
|
149
|
+
const pluginDir = resolvePluginDir(flags);
|
|
150
|
+
if (pluginDir) {
|
|
151
|
+
process.env.PLUGIN_DIRS = pluginDir;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const branding = resolveTuiBranding(pluginDir);
|
|
155
|
+
process.env._TUI_TITLE = branding.title;
|
|
156
|
+
process.env._TUI_SPLASH = branding.splash;
|
|
157
|
+
|
|
158
|
+
const systemMessage = resolveSystemMessage(flags);
|
|
159
|
+
if (systemMessage) process.env._TUI_SYSTEM_MESSAGE = systemMessage;
|
|
160
|
+
|
|
161
|
+
if (mode === "local" && flags.worker) {
|
|
162
|
+
const resolvedWorker = path.resolve(flags.worker);
|
|
163
|
+
if (!fs.existsSync(resolvedWorker)) {
|
|
164
|
+
throw new Error(`Worker module not found: ${resolvedWorker}`);
|
|
165
|
+
}
|
|
166
|
+
process.env._TUI_WORKER_MODULE = resolvedWorker;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
mode,
|
|
171
|
+
store: process.env.DATABASE_URL,
|
|
172
|
+
apiUrl: apiUrl || null,
|
|
173
|
+
deviceCode: Boolean(flags["device-code"]),
|
|
174
|
+
branding,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { PilotSwarmWorker, horizonConfigFromEnv } from "pilotswarm-sdk";
|
|
5
|
+
import { getPluginDirsFromEnv } from "./plugin-config.js";
|
|
6
|
+
|
|
7
|
+
export async function startEmbeddedWorkers({ count, store }) {
|
|
8
|
+
const workers = [];
|
|
9
|
+
if (!count || count <= 0) return workers;
|
|
10
|
+
|
|
11
|
+
const pluginDirs = getPluginDirsFromEnv();
|
|
12
|
+
|
|
13
|
+
let workerModuleConfig = {};
|
|
14
|
+
if (process.env._TUI_WORKER_MODULE) {
|
|
15
|
+
const imported = await import(process.env._TUI_WORKER_MODULE);
|
|
16
|
+
workerModuleConfig = imported.default || imported;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sessionStateDir = process.env.SESSION_STATE_DIR || path.join(os.homedir(), ".copilot", "session-state");
|
|
20
|
+
const originalConsole = {
|
|
21
|
+
log: console.log,
|
|
22
|
+
warn: console.warn,
|
|
23
|
+
error: console.error,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
for (let index = 0; index < count; index++) {
|
|
27
|
+
console.log = () => {};
|
|
28
|
+
console.warn = () => {};
|
|
29
|
+
console.error = () => {};
|
|
30
|
+
try {
|
|
31
|
+
const worker = new PilotSwarmWorker({
|
|
32
|
+
store,
|
|
33
|
+
githubToken: process.env.GITHUB_TOKEN,
|
|
34
|
+
logLevel: process.env.LOG_LEVEL || "error",
|
|
35
|
+
sessionStateDir,
|
|
36
|
+
workerNodeId: `local-${index}`,
|
|
37
|
+
systemMessage: workerModuleConfig.systemMessage || process.env._TUI_SYSTEM_MESSAGE || undefined,
|
|
38
|
+
pluginDirs,
|
|
39
|
+
// Bicep-deploy MI flow (set in worker-env ConfigMap by the
|
|
40
|
+
// overlay .env). Unset for legacy `scripts/deploy-aks.sh`,
|
|
41
|
+
// local Docker, and CI — those keep using the password
|
|
42
|
+
// URL via `store` (and AZURE_STORAGE_CONNECTION_STRING for
|
|
43
|
+
// blobs). See packages/sdk/src/pg-pool-factory.ts.
|
|
44
|
+
useManagedIdentity: ["1", "true", "yes", "on"].includes(
|
|
45
|
+
(process.env.PILOTSWARM_USE_MANAGED_IDENTITY || "").trim().toLowerCase(),
|
|
46
|
+
),
|
|
47
|
+
cmsFactsDatabaseUrl: process.env.PILOTSWARM_CMS_FACTS_DATABASE_URL || undefined,
|
|
48
|
+
aadDbUser: process.env.PILOTSWARM_DB_AAD_USER || undefined,
|
|
49
|
+
blobAccountUrl: process.env.AZURE_STORAGE_ACCOUNT_URL || undefined,
|
|
50
|
+
blobConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING || undefined,
|
|
51
|
+
blobContainer: process.env.AZURE_STORAGE_CONTAINER || undefined,
|
|
52
|
+
// Optional EnhancedFactStore + knowledge graph (HorizonDB). Empty
|
|
53
|
+
// unless HORIZON_DATABASE_URL is set, so default deployments are
|
|
54
|
+
// unaffected (plain PgFactStore).
|
|
55
|
+
...horizonConfigFromEnv(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const workerTools = typeof workerModuleConfig.createTools === "function"
|
|
59
|
+
? await workerModuleConfig.createTools({ workerNodeId: `local-${index}`, workerIndex: index })
|
|
60
|
+
: workerModuleConfig.tools;
|
|
61
|
+
if (workerTools?.length) {
|
|
62
|
+
worker.registerTools(workerTools);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await worker.start();
|
|
66
|
+
workers.push(worker);
|
|
67
|
+
} finally {
|
|
68
|
+
console.log = originalConsole.log;
|
|
69
|
+
console.warn = originalConsole.warn;
|
|
70
|
+
console.error = originalConsole.error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return workers;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function stopEmbeddedWorkers(workers) {
|
|
78
|
+
await Promise.allSettled((workers || []).map((worker) => worker.stop()));
|
|
79
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { HttpApiTransport } from "pilotswarm-sdk/api";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* TUI host for the Web API transport: HttpApiTransport plus the local-machine
|
|
9
|
+
* conveniences the shared UI expects from a Node environment (artifact export
|
|
10
|
+
* to disk, open-in-default-app/browser). All data flows through the API —
|
|
11
|
+
* this file only touches the local filesystem and OS openers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function expandUserPath(filePath) {
|
|
15
|
+
const raw = String(filePath || "");
|
|
16
|
+
if (raw === "~") return os.homedir();
|
|
17
|
+
if (raw.startsWith("~/")) return path.join(os.homedir(), raw.slice(2));
|
|
18
|
+
return raw;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const EXPORTS_DIR = path.resolve(
|
|
22
|
+
expandUserPath(process.env.PILOTSWARM_EXPORT_DIR || path.join(os.homedir(), "pilotswarm-exports")),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
function spawnDetached(command, args) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
try {
|
|
28
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
29
|
+
child.once("error", reject);
|
|
30
|
+
child.unref();
|
|
31
|
+
resolve();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
reject(error);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function openWithPlatformOpener(target) {
|
|
39
|
+
if (process.platform === "darwin") {
|
|
40
|
+
await spawnDetached("open", [target]);
|
|
41
|
+
} else if (process.platform === "win32") {
|
|
42
|
+
await spawnDetached("cmd", ["/c", "start", "", target]);
|
|
43
|
+
} else {
|
|
44
|
+
await spawnDetached("xdg-open", [target]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function saveArtifactDownload(transport, sessionId, filename) {
|
|
49
|
+
const response = await transport.api.downloadArtifactResponse(sessionId, filename);
|
|
50
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
51
|
+
const sessionDir = path.join(EXPORTS_DIR, String(sessionId || "").slice(0, 8));
|
|
52
|
+
await fs.promises.mkdir(sessionDir, { recursive: true });
|
|
53
|
+
const localPath = path.join(sessionDir, path.basename(filename));
|
|
54
|
+
await fs.promises.writeFile(localPath, body);
|
|
55
|
+
return { localPath, filename };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function uploadArtifactFromPath(transport, sessionId, filePath) {
|
|
59
|
+
const resolvedPath = path.resolve(expandUserPath(filePath));
|
|
60
|
+
const content = await fs.promises.readFile(resolvedPath);
|
|
61
|
+
return transport.uploadArtifactContent(
|
|
62
|
+
sessionId,
|
|
63
|
+
path.basename(resolvedPath),
|
|
64
|
+
content.toString("base64"),
|
|
65
|
+
undefined,
|
|
66
|
+
"base64",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function openPathInDefaultApp(targetPath) {
|
|
71
|
+
const resolvedPath = path.resolve(expandUserPath(targetPath));
|
|
72
|
+
const stat = await fs.promises.stat(resolvedPath).catch(() => null);
|
|
73
|
+
if (!stat || !stat.isFile()) {
|
|
74
|
+
throw new Error(`File not found: ${targetPath}`);
|
|
75
|
+
}
|
|
76
|
+
await openWithPlatformOpener(resolvedPath);
|
|
77
|
+
return { localPath: resolvedPath };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function openUrlInDefaultBrowser(targetUrl) {
|
|
81
|
+
const href = String(targetUrl || "").trim();
|
|
82
|
+
if (!href) throw new Error("URL cannot be empty.");
|
|
83
|
+
const parsedUrl = new URL(href);
|
|
84
|
+
if (!/^https?:$/i.test(parsedUrl.protocol)) {
|
|
85
|
+
throw new Error(`Unsupported URL protocol: ${parsedUrl.protocol}`);
|
|
86
|
+
}
|
|
87
|
+
await openWithPlatformOpener(parsedUrl.toString());
|
|
88
|
+
return { url: parsedUrl.toString() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createHttpTransportHost({ apiUrl, getAccessToken, onUnauthorized, onForbidden }) {
|
|
92
|
+
fs.mkdirSync(EXPORTS_DIR, { recursive: true });
|
|
93
|
+
return new HttpApiTransport({
|
|
94
|
+
apiUrl,
|
|
95
|
+
getAccessToken,
|
|
96
|
+
onUnauthorized,
|
|
97
|
+
onForbidden,
|
|
98
|
+
host: {
|
|
99
|
+
saveArtifactDownload,
|
|
100
|
+
uploadArtifactFromPath,
|
|
101
|
+
openPathInDefaultApp,
|
|
102
|
+
openUrlInDefaultBrowser,
|
|
103
|
+
artifactExportDirectory: EXPORTS_DIR,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|