@harness-lab/cli 0.1.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 +105 -0
- package/bin/harness.js +11 -0
- package/package.json +39 -0
- package/src/client.js +101 -0
- package/src/config.js +14 -0
- package/src/io.js +19 -0
- package/src/run-cli.js +518 -0
- package/src/session-store.js +348 -0
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Harness CLI
|
|
2
|
+
|
|
3
|
+
Small facilitator-facing CLI for Harness Lab.
|
|
4
|
+
|
|
5
|
+
Current shipped scope:
|
|
6
|
+
|
|
7
|
+
- `harness auth login`
|
|
8
|
+
- `harness auth logout`
|
|
9
|
+
- `harness auth status`
|
|
10
|
+
- `harness workshop status`
|
|
11
|
+
- `harness workshop archive`
|
|
12
|
+
- `harness workshop phase set <phase-id>`
|
|
13
|
+
|
|
14
|
+
Current implementation posture:
|
|
15
|
+
|
|
16
|
+
- targets the existing shared dashboard facilitator APIs
|
|
17
|
+
- defaults to a browser/device approval flow backed by dashboard-side facilitator broker sessions
|
|
18
|
+
- keeps `--auth basic` and `--auth neon` as explicit local-dev/bootstrap fallback modes
|
|
19
|
+
- stores session material in macOS Keychain, Windows Credential Manager, or Linux Secret Service by default
|
|
20
|
+
- only uses file storage under `HARNESS_CLI_HOME` or `~/.harness` when `HARNESS_SESSION_STORAGE=file` is set explicitly
|
|
21
|
+
- supports brokered facilitator commands over the same workshop APIs used by the dashboard
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Participant-facing default install:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install -g @harness-lab/cli
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Verify the binary:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
harness --help
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Development or fallback install from this repository:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install -g ./harness-cli
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
or:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cd harness-cli
|
|
49
|
+
npm link
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Default device/browser login:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
harness auth login \
|
|
56
|
+
--dashboard-url https://harness-lab-dashboard.vercel.app
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The CLI prints a verification URL plus user code, optionally opens the browser when supported, then polls until the facilitator approves the request on `/admin/device`.
|
|
60
|
+
|
|
61
|
+
Explicit local file-mode / Basic Auth fallback:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
harness auth login \
|
|
65
|
+
--auth basic \
|
|
66
|
+
--dashboard-url http://localhost:3000 \
|
|
67
|
+
--username facilitator \
|
|
68
|
+
--password secret
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Explicit Neon email/password bootstrap fallback:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
harness auth login \
|
|
75
|
+
--auth neon \
|
|
76
|
+
--dashboard-url https://harness-lab-dashboard.vercel.app \
|
|
77
|
+
--email facilitator@example.com
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Workshop commands:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
harness auth status
|
|
84
|
+
harness workshop status
|
|
85
|
+
harness workshop phase set rotation
|
|
86
|
+
harness workshop archive --notes "Manual archive"
|
|
87
|
+
harness auth logout
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Environment variables:
|
|
91
|
+
|
|
92
|
+
- `HARNESS_DASHBOARD_URL`
|
|
93
|
+
- `HARNESS_AUTH_MODE`
|
|
94
|
+
- `HARNESS_ADMIN_USERNAME`
|
|
95
|
+
- `HARNESS_ADMIN_PASSWORD`
|
|
96
|
+
- `HARNESS_FACILITATOR_EMAIL`
|
|
97
|
+
- `HARNESS_FACILITATOR_PASSWORD`
|
|
98
|
+
- `HARNESS_CLI_HOME`
|
|
99
|
+
- `HARNESS_SESSION_STORAGE` (`keychain`, `credential-manager`, `secret-service`, or `file`)
|
|
100
|
+
|
|
101
|
+
## Release Gate
|
|
102
|
+
|
|
103
|
+
Public npm publication is controlled by the release gate in
|
|
104
|
+
[docs/harness-cli-publication-gate.md](/Users/ondrejsvec/projects/Bobo/harness-lab/docs/harness-cli-publication-gate.md).
|
|
105
|
+
Normal development should still happen from this repository; npm is the participant-facing distribution path, not a substitute for repo-local development.
|
package/bin/harness.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runCli } from "../src/run-cli.js";
|
|
3
|
+
|
|
4
|
+
const exitCode = await runCli(process.argv.slice(2), {
|
|
5
|
+
stdin: process.stdin,
|
|
6
|
+
stdout: process.stdout,
|
|
7
|
+
stderr: process.stderr,
|
|
8
|
+
env: process.env,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
process.exitCode = exitCode;
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@harness-lab/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Participant-facing Harness Lab CLI for facilitator auth and workshop operations",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ondrej-svec/harness-lab.git",
|
|
10
|
+
"directory": "harness-cli"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/ondrej-svec/harness-lab/tree/main/harness-cli",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/ondrej-svec/harness-lab/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"harness-lab",
|
|
18
|
+
"cli",
|
|
19
|
+
"workshop",
|
|
20
|
+
"ai-agents"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=24"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin",
|
|
30
|
+
"src",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"bin": {
|
|
34
|
+
"harness": "./bin/harness.js"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export class HarnessApiError extends Error {
|
|
2
|
+
constructor(message, details = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "HarnessApiError";
|
|
5
|
+
this.status = details.status ?? null;
|
|
6
|
+
this.payload = details.payload ?? null;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeBaseUrl(url) {
|
|
11
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function parseJsonResponse(response) {
|
|
15
|
+
try {
|
|
16
|
+
return await response.json();
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createHarnessClient({ fetchFn, session }) {
|
|
23
|
+
if (!session?.dashboardUrl) {
|
|
24
|
+
throw new HarnessApiError("Missing session configuration");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const baseUrl = normalizeBaseUrl(session.dashboardUrl);
|
|
28
|
+
const authHeaders = {};
|
|
29
|
+
|
|
30
|
+
if (session.authorizationHeader) {
|
|
31
|
+
authHeaders.authorization = session.authorizationHeader;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (session.cookieHeader) {
|
|
35
|
+
authHeaders.cookie = session.cookieHeader;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (session.accessToken) {
|
|
39
|
+
authHeaders.authorization = `Bearer ${session.accessToken}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function request(path, options = {}) {
|
|
43
|
+
const response = await fetchFn(`${baseUrl}${path}`, {
|
|
44
|
+
method: options.method ?? "GET",
|
|
45
|
+
headers: {
|
|
46
|
+
accept: "application/json",
|
|
47
|
+
...authHeaders,
|
|
48
|
+
...(options.body ? { "content-type": "application/json" } : {}),
|
|
49
|
+
...(options.headers ?? {}),
|
|
50
|
+
},
|
|
51
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const payload = await parseJsonResponse(response);
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const message =
|
|
57
|
+
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
|
|
58
|
+
? payload.error
|
|
59
|
+
: `Request failed with status ${response.status}`;
|
|
60
|
+
throw new HarnessApiError(message, { status: response.status, payload });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return payload;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
verifyAccess() {
|
|
68
|
+
return request("/api/workshop");
|
|
69
|
+
},
|
|
70
|
+
getAuthSession() {
|
|
71
|
+
return request("/api/auth/get-session");
|
|
72
|
+
},
|
|
73
|
+
signOutAuthSession() {
|
|
74
|
+
return request("/api/auth/sign-out", { method: "POST" });
|
|
75
|
+
},
|
|
76
|
+
startDeviceAuthorization() {
|
|
77
|
+
return request("/api/auth/device/start", { method: "POST" });
|
|
78
|
+
},
|
|
79
|
+
pollDeviceAuthorization(deviceCode) {
|
|
80
|
+
return request("/api/auth/device/poll", { method: "POST", body: { deviceCode } });
|
|
81
|
+
},
|
|
82
|
+
getDeviceSession() {
|
|
83
|
+
return request("/api/auth/device/session");
|
|
84
|
+
},
|
|
85
|
+
signOutDeviceSession() {
|
|
86
|
+
return request("/api/auth/device/logout", { method: "POST" });
|
|
87
|
+
},
|
|
88
|
+
getWorkshopStatus() {
|
|
89
|
+
return request("/api/workshop");
|
|
90
|
+
},
|
|
91
|
+
getAgenda() {
|
|
92
|
+
return request("/api/agenda");
|
|
93
|
+
},
|
|
94
|
+
setCurrentPhase(currentId) {
|
|
95
|
+
return request("/api/agenda", { method: "PATCH", body: { currentId } });
|
|
96
|
+
},
|
|
97
|
+
archiveWorkshop(notes) {
|
|
98
|
+
return request("/api/workshop/archive", { method: "POST", body: notes ? { notes } : {} });
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function getDefaultDashboardUrl(env) {
|
|
5
|
+
return env.HARNESS_DASHBOARD_URL ?? "http://localhost:3000";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getCliHome(env) {
|
|
9
|
+
return env.HARNESS_CLI_HOME ?? path.join(os.homedir(), ".harness");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getSessionFilePath(env) {
|
|
13
|
+
return path.join(getCliHome(env), "session.json");
|
|
14
|
+
}
|
package/src/io.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
|
|
3
|
+
export async function prompt(io, label) {
|
|
4
|
+
const rl = readline.createInterface({
|
|
5
|
+
input: io.stdin,
|
|
6
|
+
output: io.stderr,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
const value = await rl.question(label);
|
|
11
|
+
return value.trim();
|
|
12
|
+
} finally {
|
|
13
|
+
rl.close();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function writeLine(stream, line = "") {
|
|
18
|
+
stream.write(`${line}\n`);
|
|
19
|
+
}
|
package/src/run-cli.js
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { getDefaultDashboardUrl } from "./config.js";
|
|
2
|
+
import { createHarnessClient, HarnessApiError } from "./client.js";
|
|
3
|
+
import { prompt, writeLine } from "./io.js";
|
|
4
|
+
import { deleteSession, readSession, sanitizeSession, writeSession, getSessionStorageMode, SessionStoreError } from "./session-store.js";
|
|
5
|
+
|
|
6
|
+
function sleep(ms) {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const positionals = [];
|
|
12
|
+
const flags = {};
|
|
13
|
+
|
|
14
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
15
|
+
const value = argv[index];
|
|
16
|
+
if (value.startsWith("--")) {
|
|
17
|
+
const key = value.slice(2);
|
|
18
|
+
const next = argv[index + 1];
|
|
19
|
+
if (!next || next.startsWith("--")) {
|
|
20
|
+
flags[key] = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
flags[key] = next;
|
|
24
|
+
index += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
positionals.push(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { positionals, flags };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildBasicAuthorizationHeader(username, password) {
|
|
34
|
+
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildCookieHeader(setCookieValue) {
|
|
38
|
+
return setCookieValue
|
|
39
|
+
.split(/,(?=[^;]+=[^;]+)/)
|
|
40
|
+
.map((cookie) => cookie.split(";")[0]?.trim())
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.join("; ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readJson(response) {
|
|
46
|
+
try {
|
|
47
|
+
return await response.json();
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function printUsage(io) {
|
|
54
|
+
writeLine(io.stdout, "Usage:");
|
|
55
|
+
writeLine(io.stdout, " harness auth login [--auth device|basic|neon] [--dashboard-url URL] [--username USER] [--email EMAIL] [--password PASS] [--no-open]");
|
|
56
|
+
writeLine(io.stdout, " harness auth logout");
|
|
57
|
+
writeLine(io.stdout, " harness auth status");
|
|
58
|
+
writeLine(io.stdout, " harness workshop status");
|
|
59
|
+
writeLine(io.stdout, " harness workshop archive [--notes TEXT]");
|
|
60
|
+
writeLine(io.stdout, " harness workshop phase set <phase-id>");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatStorageError(error) {
|
|
64
|
+
if (error instanceof SessionStoreError) {
|
|
65
|
+
return error.message;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return "Harness CLI could not access the configured session store.";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function persistSession(io, env, session) {
|
|
72
|
+
try {
|
|
73
|
+
await writeSession(env, session);
|
|
74
|
+
return true;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
writeLine(io.stderr, `Session storage failed: ${formatStorageError(error)}`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function handleBasicAuthLogin(io, env, flags, deps) {
|
|
82
|
+
const dashboardUrl = String(flags["dashboard-url"] ?? getDefaultDashboardUrl(env));
|
|
83
|
+
const username = String(flags.username ?? env.HARNESS_ADMIN_USERNAME ?? (await prompt(io, "Username: ")));
|
|
84
|
+
const password = String(flags.password ?? env.HARNESS_ADMIN_PASSWORD ?? (await prompt(io, "Password: ")));
|
|
85
|
+
|
|
86
|
+
if (!username || !password) {
|
|
87
|
+
writeLine(io.stderr, "Username and password are required.");
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const session = {
|
|
92
|
+
authType: "basic",
|
|
93
|
+
mode: "local-dev",
|
|
94
|
+
dashboardUrl,
|
|
95
|
+
username,
|
|
96
|
+
authorizationHeader: buildBasicAuthorizationHeader(username, password),
|
|
97
|
+
loggedInAt: new Date().toISOString(),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const payload = await client.verifyAccess();
|
|
104
|
+
if (!(await persistSession(io, env, session))) {
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
writeLine(io.stdout, `Logged in to ${dashboardUrl}`);
|
|
108
|
+
writeLine(io.stdout, `Session storage: ${getSessionStorageMode(env)}`);
|
|
109
|
+
if (payload?.workshopId) {
|
|
110
|
+
writeLine(io.stdout, `Workshop: ${payload.workshopId}`);
|
|
111
|
+
}
|
|
112
|
+
return 0;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof HarnessApiError) {
|
|
115
|
+
writeLine(io.stderr, `Login failed: ${error.message}`);
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function handleNeonAuthLogin(io, env, flags, deps) {
|
|
123
|
+
const dashboardUrl = String(flags["dashboard-url"] ?? getDefaultDashboardUrl(env));
|
|
124
|
+
const email = String(flags.email ?? env.HARNESS_FACILITATOR_EMAIL ?? (await prompt(io, "Email: ")));
|
|
125
|
+
const password = String(flags.password ?? env.HARNESS_FACILITATOR_PASSWORD ?? (await prompt(io, "Password: ")));
|
|
126
|
+
|
|
127
|
+
if (!email || !password) {
|
|
128
|
+
writeLine(io.stderr, "Email and password are required.");
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const signInResponse = await deps.fetchFn(`${dashboardUrl.replace(/\/$/, "")}/api/auth/sign-in/email`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: {
|
|
135
|
+
accept: "application/json",
|
|
136
|
+
"content-type": "application/json",
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify({ email, password }),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const payload = await readJson(signInResponse);
|
|
142
|
+
if (!signInResponse.ok) {
|
|
143
|
+
const message =
|
|
144
|
+
payload && typeof payload === "object" && "message" in payload && typeof payload.message === "string"
|
|
145
|
+
? payload.message
|
|
146
|
+
: payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
|
|
147
|
+
? payload.error
|
|
148
|
+
: `Login failed with status ${signInResponse.status}`;
|
|
149
|
+
writeLine(io.stderr, `Login failed: ${message}`);
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const setCookie = signInResponse.headers?.get?.("set-cookie");
|
|
154
|
+
if (!setCookie) {
|
|
155
|
+
writeLine(io.stderr, "Login failed: auth response did not include a session cookie.");
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const session = {
|
|
160
|
+
authType: "neon",
|
|
161
|
+
mode: "session-cookie",
|
|
162
|
+
dashboardUrl,
|
|
163
|
+
email,
|
|
164
|
+
cookieHeader: buildCookieHeader(setCookie),
|
|
165
|
+
loggedInAt: new Date().toISOString(),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
|
|
170
|
+
const authSession = await client.getAuthSession();
|
|
171
|
+
if (!(await persistSession(io, env, session))) {
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
writeLine(io.stdout, `Logged in to ${dashboardUrl}`);
|
|
175
|
+
writeLine(io.stdout, `Session storage: ${getSessionStorageMode(env)}`);
|
|
176
|
+
if (authSession?.user?.email) {
|
|
177
|
+
writeLine(io.stdout, `Facilitator: ${authSession.user.email}`);
|
|
178
|
+
}
|
|
179
|
+
return 0;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error instanceof HarnessApiError) {
|
|
182
|
+
writeLine(io.stderr, `Session verification failed: ${error.message}`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function handleDeviceAuthLogin(io, env, flags, deps) {
|
|
190
|
+
const dashboardUrl = String(flags["dashboard-url"] ?? getDefaultDashboardUrl(env));
|
|
191
|
+
const client = createHarnessClient({
|
|
192
|
+
fetchFn: deps.fetchFn,
|
|
193
|
+
session: { dashboardUrl },
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const deviceAuth = await client.startDeviceAuthorization();
|
|
198
|
+
writeLine(io.stdout, `Open: ${deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri}`);
|
|
199
|
+
writeLine(io.stdout, `Code: ${deviceAuth.userCode}`);
|
|
200
|
+
writeLine(io.stdout, `Expires: ${deviceAuth.expiresAt}`);
|
|
201
|
+
writeLine(io.stdout, "Approve the login in a browser, then the CLI will continue automatically.");
|
|
202
|
+
|
|
203
|
+
if (flags["no-open"] !== true && typeof deps.openUrl === "function") {
|
|
204
|
+
await deps.openUrl(deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
while (Date.now() < Date.parse(deviceAuth.expiresAt)) {
|
|
208
|
+
const result = await client.pollDeviceAuthorization(deviceAuth.deviceCode);
|
|
209
|
+
|
|
210
|
+
if (result.status === "authorization_pending") {
|
|
211
|
+
await (deps.sleepFn ?? sleep)(Number(result.intervalSeconds ?? deviceAuth.intervalSeconds ?? 5) * 1000);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (result.status === "authorized") {
|
|
216
|
+
const session = {
|
|
217
|
+
authType: "device",
|
|
218
|
+
mode: "broker-token",
|
|
219
|
+
dashboardUrl,
|
|
220
|
+
accessToken: result.accessToken,
|
|
221
|
+
neonUserId: result.session?.neonUserId ?? null,
|
|
222
|
+
role: result.session?.role ?? null,
|
|
223
|
+
loggedInAt: new Date().toISOString(),
|
|
224
|
+
expiresAt: result.expiresAt,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
if (!(await persistSession(io, env, session))) {
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
writeLine(io.stdout, `Logged in to ${dashboardUrl}`);
|
|
232
|
+
writeLine(io.stdout, `Session storage: ${getSessionStorageMode(env)}`);
|
|
233
|
+
if (result.session?.role) {
|
|
234
|
+
writeLine(io.stdout, `Facilitator role: ${result.session.role}`);
|
|
235
|
+
}
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (result.status === "access_denied") {
|
|
240
|
+
writeLine(io.stderr, "Login failed: device authorization was denied.");
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (result.status === "expired_token") {
|
|
245
|
+
writeLine(io.stderr, "Login failed: device authorization expired.");
|
|
246
|
+
return 1;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
writeLine(io.stderr, "Login failed: device authorization could not be completed.");
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
writeLine(io.stderr, "Login failed: device authorization expired.");
|
|
254
|
+
return 1;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (error instanceof HarnessApiError) {
|
|
257
|
+
writeLine(io.stderr, `Login failed: ${error.message}`);
|
|
258
|
+
return 1;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (error instanceof SessionStoreError) {
|
|
262
|
+
writeLine(io.stderr, `Session storage failed: ${error.message}`);
|
|
263
|
+
return 1;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function handleAuthLogin(io, env, flags, deps) {
|
|
271
|
+
const authMode = String(flags.auth ?? env.HARNESS_AUTH_MODE ?? "device");
|
|
272
|
+
|
|
273
|
+
if (authMode === "device") {
|
|
274
|
+
return handleDeviceAuthLogin(io, env, flags, deps);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (authMode === "neon") {
|
|
278
|
+
return handleNeonAuthLogin(io, env, flags, deps);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return handleBasicAuthLogin(io, env, flags, deps);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function requireSession(io, env) {
|
|
285
|
+
try {
|
|
286
|
+
const session = await readSession(env);
|
|
287
|
+
if (!session) {
|
|
288
|
+
writeLine(io.stderr, "No active session. Run `harness auth login` first.");
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
return session;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
writeLine(io.stderr, `Session storage failed: ${formatStorageError(error)}`);
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function handleAuthStatus(io, env, deps) {
|
|
299
|
+
let session;
|
|
300
|
+
try {
|
|
301
|
+
session = await readSession(env);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
writeLine(io.stderr, `Session storage failed: ${formatStorageError(error)}`);
|
|
304
|
+
return 1;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!session) {
|
|
308
|
+
writeLine(io.stdout, "Not logged in.");
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (session.authType === "device") {
|
|
313
|
+
try {
|
|
314
|
+
const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
|
|
315
|
+
const deviceSession = await client.getDeviceSession();
|
|
316
|
+
writeLine(
|
|
317
|
+
io.stdout,
|
|
318
|
+
JSON.stringify({ ok: true, session: sanitizeSession(session, env), remoteSession: deviceSession.session }, null, 2),
|
|
319
|
+
);
|
|
320
|
+
return 0;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error instanceof HarnessApiError) {
|
|
323
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, session: sanitizeSession(session, env), error: error.message }, null, 2));
|
|
324
|
+
return 1;
|
|
325
|
+
}
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (session.authType === "neon") {
|
|
331
|
+
try {
|
|
332
|
+
const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
|
|
333
|
+
const authSession = await client.getAuthSession();
|
|
334
|
+
writeLine(
|
|
335
|
+
io.stdout,
|
|
336
|
+
JSON.stringify({ ok: true, session: sanitizeSession(session, env), remoteSession: authSession }, null, 2),
|
|
337
|
+
);
|
|
338
|
+
return 0;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error instanceof HarnessApiError) {
|
|
341
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, session: sanitizeSession(session, env), error: error.message }, null, 2));
|
|
342
|
+
return 1;
|
|
343
|
+
}
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, session: sanitizeSession(session, env) }, null, 2));
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function handleAuthLogout(io, env, deps) {
|
|
353
|
+
let session;
|
|
354
|
+
try {
|
|
355
|
+
session = await readSession(env);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
writeLine(io.stderr, `Session storage failed: ${formatStorageError(error)}`);
|
|
358
|
+
return 1;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (session?.authType === "device") {
|
|
362
|
+
try {
|
|
363
|
+
const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
|
|
364
|
+
await client.signOutDeviceSession();
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (!(error instanceof HarnessApiError)) {
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (session?.authType === "neon") {
|
|
373
|
+
try {
|
|
374
|
+
const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
|
|
375
|
+
await client.signOutAuthSession();
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (!(error instanceof HarnessApiError)) {
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
await deleteSession(env);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
writeLine(io.stderr, `Session storage failed: ${formatStorageError(error)}`);
|
|
387
|
+
return 1;
|
|
388
|
+
}
|
|
389
|
+
writeLine(io.stdout, "Logged out.");
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function handleWorkshopStatus(io, env, deps) {
|
|
394
|
+
const session = await requireSession(io, env);
|
|
395
|
+
if (!session) {
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
|
|
401
|
+
const [workshop, agenda] = await Promise.all([client.getWorkshopStatus(), client.getAgenda()]);
|
|
402
|
+
writeLine(
|
|
403
|
+
io.stdout,
|
|
404
|
+
JSON.stringify(
|
|
405
|
+
{
|
|
406
|
+
ok: true,
|
|
407
|
+
workshopId: workshop.workshopId,
|
|
408
|
+
workshopMeta: workshop.workshopMeta,
|
|
409
|
+
currentPhase: agenda.phase,
|
|
410
|
+
templates: workshop.templates,
|
|
411
|
+
},
|
|
412
|
+
null,
|
|
413
|
+
2,
|
|
414
|
+
),
|
|
415
|
+
);
|
|
416
|
+
return 0;
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if (error instanceof HarnessApiError) {
|
|
419
|
+
writeLine(io.stderr, `Workshop status failed: ${error.message}`);
|
|
420
|
+
return 1;
|
|
421
|
+
}
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function handleWorkshopArchive(io, env, flags, deps) {
|
|
427
|
+
const session = await requireSession(io, env);
|
|
428
|
+
if (!session) {
|
|
429
|
+
return 1;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
|
|
434
|
+
const result = await client.archiveWorkshop(typeof flags.notes === "string" ? flags.notes : undefined);
|
|
435
|
+
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
436
|
+
return 0;
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (error instanceof HarnessApiError) {
|
|
439
|
+
writeLine(io.stderr, `Archive failed: ${error.message}`);
|
|
440
|
+
return 1;
|
|
441
|
+
}
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function handleWorkshopPhaseSet(io, env, positionals, deps) {
|
|
447
|
+
const phaseId = positionals[3];
|
|
448
|
+
if (!phaseId) {
|
|
449
|
+
writeLine(io.stderr, "Phase id is required.");
|
|
450
|
+
return 1;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const session = await requireSession(io, env);
|
|
454
|
+
if (!session) {
|
|
455
|
+
return 1;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const client = createHarnessClient({ fetchFn: deps.fetchFn, session });
|
|
460
|
+
const result = await client.setCurrentPhase(phaseId);
|
|
461
|
+
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
462
|
+
return 0;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (error instanceof HarnessApiError) {
|
|
465
|
+
writeLine(io.stderr, `Phase update failed: ${error.message}`);
|
|
466
|
+
return 1;
|
|
467
|
+
}
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export async function runCli(argv, io, deps = {}) {
|
|
473
|
+
const fetchFn = deps.fetchFn ?? globalThis.fetch;
|
|
474
|
+
if (typeof fetchFn !== "function") {
|
|
475
|
+
throw new Error("Fetch is required to run the harness CLI.");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const mergedDeps = { fetchFn, sleepFn: deps.sleepFn, openUrl: deps.openUrl };
|
|
479
|
+
const { positionals, flags } = parseArgs(argv);
|
|
480
|
+
const [scope, action, subaction] = positionals;
|
|
481
|
+
|
|
482
|
+
if (flags.help === true) {
|
|
483
|
+
printUsage(io);
|
|
484
|
+
return 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!scope) {
|
|
488
|
+
printUsage(io);
|
|
489
|
+
return 1;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (scope === "auth" && action === "login") {
|
|
493
|
+
return handleAuthLogin(io, io.env, flags, mergedDeps);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (scope === "auth" && action === "logout") {
|
|
497
|
+
return handleAuthLogout(io, io.env, mergedDeps);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (scope === "auth" && action === "status") {
|
|
501
|
+
return handleAuthStatus(io, io.env, mergedDeps);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (scope === "workshop" && action === "status") {
|
|
505
|
+
return handleWorkshopStatus(io, io.env, mergedDeps);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (scope === "workshop" && action === "archive") {
|
|
509
|
+
return handleWorkshopArchive(io, io.env, flags, mergedDeps);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (scope === "workshop" && action === "phase" && subaction === "set") {
|
|
513
|
+
return handleWorkshopPhaseSet(io, io.env, positionals, mergedDeps);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
printUsage(io);
|
|
517
|
+
return 1;
|
|
518
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFile as nodeExecFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { getCliHome, getSessionFilePath } from "./config.js";
|
|
7
|
+
|
|
8
|
+
const execFile = promisify(nodeExecFile);
|
|
9
|
+
const serviceName = "harness-cli.session";
|
|
10
|
+
const accountName = "active";
|
|
11
|
+
|
|
12
|
+
export class SessionStoreError extends Error {
|
|
13
|
+
constructor(message, details = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "SessionStoreError";
|
|
16
|
+
this.code = details.code ?? "session_store_error";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let testDeps = null;
|
|
21
|
+
|
|
22
|
+
function getDeps() {
|
|
23
|
+
return (
|
|
24
|
+
testDeps ?? {
|
|
25
|
+
platform: os.platform(),
|
|
26
|
+
execFile,
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function ensureCliHome(env) {
|
|
32
|
+
await fs.mkdir(getCliHome(env), { recursive: true, mode: 0o700 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readFileSession(env) {
|
|
36
|
+
const filePath = getSessionFilePath(env);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
40
|
+
return JSON.parse(raw);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function writeFileSession(env, session) {
|
|
50
|
+
await ensureCliHome(env);
|
|
51
|
+
const filePath = getSessionFilePath(env);
|
|
52
|
+
await fs.writeFile(filePath, JSON.stringify(session, null, 2) + "\n", { mode: 0o600 });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function deleteFileSession(env) {
|
|
56
|
+
const filePath = getSessionFilePath(env);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await fs.rm(filePath);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readKeychainSession() {
|
|
69
|
+
try {
|
|
70
|
+
const { stdout } = await getDeps().execFile("/usr/bin/security", [
|
|
71
|
+
"find-generic-password",
|
|
72
|
+
"-a",
|
|
73
|
+
accountName,
|
|
74
|
+
"-s",
|
|
75
|
+
serviceName,
|
|
76
|
+
"-w",
|
|
77
|
+
]);
|
|
78
|
+
return JSON.parse(stdout.trim());
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr) : "";
|
|
81
|
+
if (stderr.includes("could not be found")) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
throw new SessionStoreError("macOS Keychain is unavailable for Harness CLI session storage.", {
|
|
85
|
+
code: "keychain_unavailable",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function writeKeychainSession(session) {
|
|
91
|
+
try {
|
|
92
|
+
await getDeps().execFile("/usr/bin/security", [
|
|
93
|
+
"add-generic-password",
|
|
94
|
+
"-U",
|
|
95
|
+
"-a",
|
|
96
|
+
accountName,
|
|
97
|
+
"-s",
|
|
98
|
+
serviceName,
|
|
99
|
+
"-w",
|
|
100
|
+
JSON.stringify(session),
|
|
101
|
+
]);
|
|
102
|
+
} catch {
|
|
103
|
+
throw new SessionStoreError("Failed to write the Harness CLI session to macOS Keychain.", {
|
|
104
|
+
code: "keychain_write_failed",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function deleteKeychainSession() {
|
|
110
|
+
try {
|
|
111
|
+
await getDeps().execFile("/usr/bin/security", [
|
|
112
|
+
"delete-generic-password",
|
|
113
|
+
"-a",
|
|
114
|
+
accountName,
|
|
115
|
+
"-s",
|
|
116
|
+
serviceName,
|
|
117
|
+
]);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr) : "";
|
|
120
|
+
if (stderr.includes("could not be found")) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
throw new SessionStoreError("Failed to remove the Harness CLI session from macOS Keychain.", {
|
|
124
|
+
code: "keychain_delete_failed",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getWindowsCredentialCommand(action, value = "") {
|
|
130
|
+
const safeValue = value.replace(/'/g, "''");
|
|
131
|
+
|
|
132
|
+
if (action === "read") {
|
|
133
|
+
return [
|
|
134
|
+
"-NoProfile",
|
|
135
|
+
"-Command",
|
|
136
|
+
`$vault = New-Object Windows.Security.Credentials.PasswordVault; try { $credential = $vault.Retrieve('${serviceName}', '${accountName}'); $credential.RetrievePassword(); Write-Output $credential.Password } catch { exit 3 }`,
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (action === "write") {
|
|
141
|
+
return [
|
|
142
|
+
"-NoProfile",
|
|
143
|
+
"-Command",
|
|
144
|
+
`$vault = New-Object Windows.Security.Credentials.PasswordVault; try { $existing = $vault.Retrieve('${serviceName}', '${accountName}'); $vault.Remove($existing) } catch {}; $vault.Add((New-Object Windows.Security.Credentials.PasswordCredential('${serviceName}', '${accountName}', '${safeValue}')))` ,
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return [
|
|
149
|
+
"-NoProfile",
|
|
150
|
+
"-Command",
|
|
151
|
+
`$vault = New-Object Windows.Security.Credentials.PasswordVault; try { $existing = $vault.Retrieve('${serviceName}', '${accountName}'); $vault.Remove($existing) } catch { exit 0 }`,
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readCredentialManagerSession() {
|
|
156
|
+
try {
|
|
157
|
+
const { stdout } = await getDeps().execFile("powershell", getWindowsCredentialCommand("read"));
|
|
158
|
+
return JSON.parse(stdout.trim());
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error && typeof error === "object" && "code" in error && error.code === 3) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
throw new SessionStoreError(
|
|
164
|
+
"Windows Credential Manager is unavailable. Use HARNESS_SESSION_STORAGE=file only if you need an explicit insecure fallback.",
|
|
165
|
+
{ code: "credential_manager_unavailable" },
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function writeCredentialManagerSession(session) {
|
|
171
|
+
try {
|
|
172
|
+
await getDeps().execFile("powershell", getWindowsCredentialCommand("write", JSON.stringify(session)));
|
|
173
|
+
} catch {
|
|
174
|
+
throw new SessionStoreError("Failed to write the Harness CLI session to Windows Credential Manager.", {
|
|
175
|
+
code: "credential_manager_write_failed",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function deleteCredentialManagerSession() {
|
|
181
|
+
try {
|
|
182
|
+
await getDeps().execFile("powershell", getWindowsCredentialCommand("delete"));
|
|
183
|
+
} catch {
|
|
184
|
+
throw new SessionStoreError("Failed to remove the Harness CLI session from Windows Credential Manager.", {
|
|
185
|
+
code: "credential_manager_delete_failed",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function readSecretServiceSession() {
|
|
191
|
+
try {
|
|
192
|
+
const { stdout } = await getDeps().execFile("/bin/sh", [
|
|
193
|
+
"-lc",
|
|
194
|
+
`secret-tool lookup service '${serviceName}' account '${accountName}'`,
|
|
195
|
+
]);
|
|
196
|
+
const value = stdout.trim();
|
|
197
|
+
return value ? JSON.parse(value) : null;
|
|
198
|
+
} catch {
|
|
199
|
+
throw new SessionStoreError(
|
|
200
|
+
"Linux Secret Service is unavailable. Use HARNESS_SESSION_STORAGE=file only if you need an explicit insecure fallback.",
|
|
201
|
+
{ code: "secret_service_unavailable" },
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function writeSecretServiceSession(session) {
|
|
207
|
+
try {
|
|
208
|
+
await getDeps().execFile("/bin/sh", [
|
|
209
|
+
"-lc",
|
|
210
|
+
`printf '%s' "$HARNESS_SESSION_JSON" | secret-tool store --label='Harness CLI session' service '${serviceName}' account '${accountName}'`,
|
|
211
|
+
], {
|
|
212
|
+
env: {
|
|
213
|
+
...process.env,
|
|
214
|
+
HARNESS_SESSION_JSON: JSON.stringify(session),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
throw new SessionStoreError("Failed to write the Harness CLI session to Linux Secret Service.", {
|
|
219
|
+
code: "secret_service_write_failed",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function deleteSecretServiceSession() {
|
|
225
|
+
try {
|
|
226
|
+
await getDeps().execFile("/bin/sh", [
|
|
227
|
+
"-lc",
|
|
228
|
+
`secret-tool clear service '${serviceName}' account '${accountName}'`,
|
|
229
|
+
]);
|
|
230
|
+
} catch {
|
|
231
|
+
throw new SessionStoreError("Failed to remove the Harness CLI session from Linux Secret Service.", {
|
|
232
|
+
code: "secret_service_delete_failed",
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getSessionStorageMode(env) {
|
|
238
|
+
const requested = env.HARNESS_SESSION_STORAGE;
|
|
239
|
+
if (requested === "file" || requested === "keychain" || requested === "credential-manager" || requested === "secret-service") {
|
|
240
|
+
return requested;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (getDeps().platform === "darwin") {
|
|
244
|
+
return "keychain";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (getDeps().platform === "win32") {
|
|
248
|
+
return "credential-manager";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (getDeps().platform === "linux") {
|
|
252
|
+
return "secret-service";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
throw new SessionStoreError(
|
|
256
|
+
"Harness CLI does not know a secure session store for this platform. Set HARNESS_SESSION_STORAGE=file only if you need an explicit insecure fallback.",
|
|
257
|
+
{ code: "unsupported_platform" },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function getStorageHint(storage) {
|
|
262
|
+
if (storage === "file") {
|
|
263
|
+
return "file storage";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (storage === "keychain") {
|
|
267
|
+
return "macOS Keychain";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (storage === "credential-manager") {
|
|
271
|
+
return "Windows Credential Manager";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return "Linux Secret Service";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function readSession(env) {
|
|
278
|
+
const storage = getSessionStorageMode(env);
|
|
279
|
+
if (storage === "keychain") {
|
|
280
|
+
return readKeychainSession();
|
|
281
|
+
}
|
|
282
|
+
if (storage === "credential-manager") {
|
|
283
|
+
return readCredentialManagerSession();
|
|
284
|
+
}
|
|
285
|
+
if (storage === "secret-service") {
|
|
286
|
+
return readSecretServiceSession();
|
|
287
|
+
}
|
|
288
|
+
return readFileSession(env);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function writeSession(env, session) {
|
|
292
|
+
const storage = getSessionStorageMode(env);
|
|
293
|
+
if (storage === "keychain") {
|
|
294
|
+
return writeKeychainSession(session);
|
|
295
|
+
}
|
|
296
|
+
if (storage === "credential-manager") {
|
|
297
|
+
return writeCredentialManagerSession(session);
|
|
298
|
+
}
|
|
299
|
+
if (storage === "secret-service") {
|
|
300
|
+
return writeSecretServiceSession(session);
|
|
301
|
+
}
|
|
302
|
+
return writeFileSession(env, session);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function deleteSession(env) {
|
|
306
|
+
const storage = getSessionStorageMode(env);
|
|
307
|
+
if (storage === "keychain") {
|
|
308
|
+
return deleteKeychainSession();
|
|
309
|
+
}
|
|
310
|
+
if (storage === "credential-manager") {
|
|
311
|
+
return deleteCredentialManagerSession();
|
|
312
|
+
}
|
|
313
|
+
if (storage === "secret-service") {
|
|
314
|
+
return deleteSecretServiceSession();
|
|
315
|
+
}
|
|
316
|
+
return deleteFileSession(env);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function sessionExists(env) {
|
|
320
|
+
return (await readSession(env)) !== null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function sanitizeSession(session, env) {
|
|
324
|
+
if (!session) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
dashboardUrl: session.dashboardUrl,
|
|
330
|
+
authType: session.authType,
|
|
331
|
+
username: session.username ?? null,
|
|
332
|
+
email: session.email ?? null,
|
|
333
|
+
loggedInAt: session.loggedInAt,
|
|
334
|
+
expiresAt: session.expiresAt ?? null,
|
|
335
|
+
mode: session.mode ?? "local-dev",
|
|
336
|
+
storage: getSessionStorageMode(env),
|
|
337
|
+
storageLabel: getStorageHint(getSessionStorageMode(env)),
|
|
338
|
+
sessionHealth: session.expiresAt ? (Date.parse(session.expiresAt) > Date.now() ? "active" : "expired") : "active",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function resolveProjectRelativePath(env, relativePath) {
|
|
343
|
+
return path.join(getCliHome(env), relativePath);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function setSessionStoreDepsForTests(deps) {
|
|
347
|
+
testDeps = deps;
|
|
348
|
+
}
|