@allocator-one/harness-cli 1.1.0 → 1.2.1
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
|
@@ -73,6 +73,24 @@ Auth resolution is identical to the CLI. Without a credential, tools return
|
|
|
73
73
|
an error telling the agent to run `harness login` in a terminal — the
|
|
74
74
|
server never opens a browser itself.
|
|
75
75
|
|
|
76
|
+
## Repo config
|
|
77
|
+
|
|
78
|
+
A committed `.harness-cli/config.json`, discovered git-style by walking up
|
|
79
|
+
from the working directory, gives every clone of a repo its defaults:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{ "team": "CLI" }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Field | Effect |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `team` | Default team key. Precedence: `--team` > `HARNESS_TEAM` > repo config. |
|
|
88
|
+
| `url` | Target host. `HARNESS_API_URL` wins. Tokens stay host-bound: a stored login only serves the host that minted it, an env `HARNESS_API_TOKEN` refuses to pair with a repo-named host (set `HARNESS_API_URL` explicitly), and before the interactive auto-login opens a browser at a host named only by the repo, the CLI asks once per host (remembered in `~/.config/harness/trusted-hosts.json`). |
|
|
89
|
+
| `org` | Reserved for Harness org-scoping; parsed today, `url` wins meanwhile. |
|
|
90
|
+
|
|
91
|
+
This is config, not state — issues stay server-side. The escript does not
|
|
92
|
+
read this file (TS-CLI-only).
|
|
93
|
+
|
|
76
94
|
## Development
|
|
77
95
|
|
|
78
96
|
```sh
|
|
@@ -1337,18 +1337,119 @@ function deleteCredentials(path2) {
|
|
|
1337
1337
|
rmSync2(path2, { force: true });
|
|
1338
1338
|
}
|
|
1339
1339
|
|
|
1340
|
+
// src/core/repo-config.ts
|
|
1341
|
+
import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
1342
|
+
import { homedir as homedir2 } from "os";
|
|
1343
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, parse } from "path";
|
|
1344
|
+
var CONFIG_RELATIVE_PATH = join3(".harness-cli", "config.json");
|
|
1345
|
+
function findRepoConfig(startDir = process.cwd()) {
|
|
1346
|
+
let dir = startDir;
|
|
1347
|
+
const { root } = parse(startDir);
|
|
1348
|
+
for (; ; ) {
|
|
1349
|
+
const candidate = join3(dir, CONFIG_RELATIVE_PATH);
|
|
1350
|
+
if (existsSync(candidate)) {
|
|
1351
|
+
return readRepoConfig(candidate);
|
|
1352
|
+
}
|
|
1353
|
+
if (dir === root) return { config: null, warnings: [] };
|
|
1354
|
+
dir = dirname3(dir);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
function readRepoConfig(path2) {
|
|
1358
|
+
let parsed;
|
|
1359
|
+
try {
|
|
1360
|
+
parsed = JSON.parse(readFileSync4(path2, "utf8"));
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1363
|
+
return { config: null, warnings: [`harness: ignoring malformed ${path2} (${detail})`] };
|
|
1364
|
+
}
|
|
1365
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1366
|
+
return { config: null, warnings: [`harness: ignoring ${path2} \u2014 expected a JSON object`] };
|
|
1367
|
+
}
|
|
1368
|
+
const record = parsed;
|
|
1369
|
+
const config = { path: path2 };
|
|
1370
|
+
const warnings = [];
|
|
1371
|
+
for (const field2 of ["team", "url", "org"]) {
|
|
1372
|
+
const value = record[field2];
|
|
1373
|
+
if (value === void 0) continue;
|
|
1374
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
1375
|
+
config[field2] = value.trim();
|
|
1376
|
+
} else {
|
|
1377
|
+
warnings.push(`harness: ignoring "${field2}" in ${path2} \u2014 expected a non-empty string`);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
return { config, warnings };
|
|
1381
|
+
}
|
|
1382
|
+
function trustedHostsPath(env = process.env) {
|
|
1383
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
1384
|
+
const base = xdg !== void 0 && xdg !== "" && isAbsolute2(xdg) ? xdg : join3(homedir2(), ".config");
|
|
1385
|
+
return join3(base, "harness", "trusted-hosts.json");
|
|
1386
|
+
}
|
|
1387
|
+
function readTrustedHosts(env) {
|
|
1388
|
+
try {
|
|
1389
|
+
const parsed = JSON.parse(readFileSync4(trustedHostsPath(env), "utf8"));
|
|
1390
|
+
if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed["hosts"])) {
|
|
1391
|
+
return parsed["hosts"].filter(
|
|
1392
|
+
(host) => typeof host === "string"
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
} catch {
|
|
1396
|
+
}
|
|
1397
|
+
return [];
|
|
1398
|
+
}
|
|
1399
|
+
function isHostTrusted(baseUrl, env) {
|
|
1400
|
+
return readTrustedHosts(env).includes(baseUrl);
|
|
1401
|
+
}
|
|
1402
|
+
function trustHost(baseUrl, env) {
|
|
1403
|
+
const hosts = readTrustedHosts(env);
|
|
1404
|
+
if (hosts.includes(baseUrl)) return;
|
|
1405
|
+
const path2 = trustedHostsPath(env);
|
|
1406
|
+
mkdirSync3(dirname3(path2), { recursive: true });
|
|
1407
|
+
writeFileSync3(path2, JSON.stringify({ hosts: [...hosts, baseUrl] }, null, 2) + "\n");
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1340
1410
|
// src/core/config.ts
|
|
1341
|
-
import { homedir as
|
|
1342
|
-
import { isAbsolute as
|
|
1411
|
+
import { homedir as homedir3, tmpdir } from "os";
|
|
1412
|
+
import { isAbsolute as isAbsolute3, join as join4 } from "path";
|
|
1343
1413
|
var DEFAULT_BASE_URL = "https://harness.infra.one";
|
|
1344
|
-
function resolveConfig(env = process.env, credentialsPath) {
|
|
1414
|
+
function resolveConfig(env = process.env, credentialsPath, repo) {
|
|
1415
|
+
let warnings = [];
|
|
1416
|
+
if (repo === void 0) {
|
|
1417
|
+
const lookup = findRepoConfig();
|
|
1418
|
+
repo = lookup.config;
|
|
1419
|
+
warnings = lookup.warnings;
|
|
1420
|
+
}
|
|
1345
1421
|
const path2 = credentialsPath ?? defaultCredentialsPath(env);
|
|
1346
1422
|
const stored = readCredentials(path2);
|
|
1347
1423
|
const envUrl = blankToNull(env["HARNESS_API_URL"]);
|
|
1348
1424
|
const envToken = blankToNull(env["HARNESS_API_TOKEN"]);
|
|
1349
|
-
const
|
|
1425
|
+
const repoUrl = repo?.url?.replace(/\/+$/, "") ?? null;
|
|
1426
|
+
let rawUrl;
|
|
1427
|
+
let hostSource;
|
|
1428
|
+
if (envUrl !== null) {
|
|
1429
|
+
rawUrl = envUrl;
|
|
1430
|
+
hostSource = "env";
|
|
1431
|
+
} else if (envToken !== null) {
|
|
1432
|
+
if (repoUrl !== null && normalize(repoUrl) !== normalize(DEFAULT_BASE_URL)) {
|
|
1433
|
+
return {
|
|
1434
|
+
kind: "error",
|
|
1435
|
+
message: `this repo's config (${repo?.path}) targets ${repoUrl}, but HARNESS_API_TOKEN only travels to the host the environment names \u2014 set HARNESS_API_URL explicitly to pair them`,
|
|
1436
|
+
warnings
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
rawUrl = DEFAULT_BASE_URL;
|
|
1440
|
+
hostSource = "default";
|
|
1441
|
+
} else if (repoUrl !== null) {
|
|
1442
|
+
rawUrl = repoUrl;
|
|
1443
|
+
hostSource = "repo-config";
|
|
1444
|
+
} else if (stored !== null) {
|
|
1445
|
+
rawUrl = stored.url;
|
|
1446
|
+
hostSource = "stored-login";
|
|
1447
|
+
} else {
|
|
1448
|
+
rawUrl = DEFAULT_BASE_URL;
|
|
1449
|
+
hostSource = "default";
|
|
1450
|
+
}
|
|
1350
1451
|
const validated = validateBaseUrl(rawUrl);
|
|
1351
|
-
if (!validated.ok) return { kind: "error", message: validated.message };
|
|
1452
|
+
if (!validated.ok) return { kind: "error", message: validated.message, warnings };
|
|
1352
1453
|
const baseUrl = rawUrl.replace(/\/+$/, "");
|
|
1353
1454
|
let token;
|
|
1354
1455
|
if (envToken !== null) {
|
|
@@ -1356,7 +1457,9 @@ function resolveConfig(env = process.env, credentialsPath) {
|
|
|
1356
1457
|
} else if (stored !== null && normalize(stored.url) !== null && normalize(stored.url) === normalize(baseUrl)) {
|
|
1357
1458
|
token = stored.token;
|
|
1358
1459
|
} else {
|
|
1359
|
-
|
|
1460
|
+
const result = { kind: "needs-login", baseUrl, hostSource, warnings };
|
|
1461
|
+
if (hostSource === "repo-config" && repo?.path !== void 0) result.repoConfigPath = repo.path;
|
|
1462
|
+
return result;
|
|
1360
1463
|
}
|
|
1361
1464
|
return {
|
|
1362
1465
|
kind: "ok",
|
|
@@ -1364,19 +1467,23 @@ function resolveConfig(env = process.env, credentialsPath) {
|
|
|
1364
1467
|
baseUrl,
|
|
1365
1468
|
token,
|
|
1366
1469
|
actor: blankToNull(env["HARNESS_ACTOR"]),
|
|
1367
|
-
team:
|
|
1368
|
-
|
|
1369
|
-
|
|
1470
|
+
// The committed team is the fallback: the environment (and, above it,
|
|
1471
|
+
// the --team flag at the command layer) stays authoritative.
|
|
1472
|
+
team: blankToNull(env["HARNESS_TEAM"]) ?? repo?.team ?? null,
|
|
1473
|
+
cacheDir: resolveCacheDir(env),
|
|
1474
|
+
hostSource
|
|
1475
|
+
},
|
|
1476
|
+
warnings
|
|
1370
1477
|
};
|
|
1371
1478
|
}
|
|
1372
1479
|
function resolveCacheDir(env = process.env) {
|
|
1373
|
-
return absolute(env["HARNESS_CACHE_DIR"]) ??
|
|
1480
|
+
return absolute(env["HARNESS_CACHE_DIR"]) ?? join4(cacheHome(env), "harness", "attachments");
|
|
1374
1481
|
}
|
|
1375
1482
|
function absolute(value) {
|
|
1376
|
-
return value !== void 0 && value !== "" &&
|
|
1483
|
+
return value !== void 0 && value !== "" && isAbsolute3(value) ? value : null;
|
|
1377
1484
|
}
|
|
1378
1485
|
function cacheHome(env) {
|
|
1379
|
-
return absolute(env["XDG_CACHE_HOME"]) ??
|
|
1486
|
+
return absolute(env["XDG_CACHE_HOME"]) ?? join4(homedir3() || tmpdir(), ".cache");
|
|
1380
1487
|
}
|
|
1381
1488
|
function normalize(url) {
|
|
1382
1489
|
let parsed;
|
|
@@ -1483,6 +1590,9 @@ export {
|
|
|
1483
1590
|
readCredentials,
|
|
1484
1591
|
writeCredentials,
|
|
1485
1592
|
deleteCredentials,
|
|
1593
|
+
findRepoConfig,
|
|
1594
|
+
isHostTrusted,
|
|
1595
|
+
trustHost,
|
|
1486
1596
|
DEFAULT_BASE_URL,
|
|
1487
1597
|
resolveConfig,
|
|
1488
1598
|
validateBaseUrl,
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
docList,
|
|
24
24
|
docShow,
|
|
25
25
|
docUpdate,
|
|
26
|
+
findRepoConfig,
|
|
27
|
+
isHostTrusted,
|
|
26
28
|
json,
|
|
27
29
|
labelCreate,
|
|
28
30
|
labelList,
|
|
@@ -47,6 +49,7 @@ import {
|
|
|
47
49
|
stats,
|
|
48
50
|
teamCreate,
|
|
49
51
|
teamList,
|
|
52
|
+
trustHost,
|
|
50
53
|
update,
|
|
51
54
|
upload,
|
|
52
55
|
userList,
|
|
@@ -54,9 +57,10 @@ import {
|
|
|
54
57
|
version,
|
|
55
58
|
workflowStates,
|
|
56
59
|
writeCredentials
|
|
57
|
-
} from "./chunk-
|
|
60
|
+
} from "./chunk-JMGBMBN6.js";
|
|
58
61
|
|
|
59
62
|
// src/cli.ts
|
|
63
|
+
import { createInterface as createInterface2 } from "readline";
|
|
60
64
|
import { Command, CommanderError, InvalidArgumentError } from "commander";
|
|
61
65
|
|
|
62
66
|
// src/core/auth.ts
|
|
@@ -314,10 +318,23 @@ async function whoami(config, credentialSource) {
|
|
|
314
318
|
throw error;
|
|
315
319
|
}
|
|
316
320
|
const serverVersion = await client.serverVersion().catch(() => "unknown");
|
|
321
|
+
const hostSourceLabels = {
|
|
322
|
+
env: "HARNESS_API_URL",
|
|
323
|
+
"repo-config": ".harness-cli/config.json",
|
|
324
|
+
"stored-login": "stored login",
|
|
325
|
+
default: "workspace default"
|
|
326
|
+
};
|
|
327
|
+
const via = config.hostSource !== void 0 ? ` (via ${hostSourceLabels[config.hostSource]})` : "";
|
|
317
328
|
return {
|
|
318
329
|
ok: true,
|
|
319
|
-
json: {
|
|
320
|
-
|
|
330
|
+
json: {
|
|
331
|
+
url: config.baseUrl,
|
|
332
|
+
source: credentialSource,
|
|
333
|
+
verified: true,
|
|
334
|
+
server_version: serverVersion,
|
|
335
|
+
host_source: config.hostSource
|
|
336
|
+
},
|
|
337
|
+
human: `Host: ${config.baseUrl}${via}
|
|
321
338
|
Credential: ${credentialSource}
|
|
322
339
|
Verified: ok (server API ${serverVersion})
|
|
323
340
|
`
|
|
@@ -354,6 +371,15 @@ function defaultOpenBrowser(url) {
|
|
|
354
371
|
}
|
|
355
372
|
|
|
356
373
|
// src/cli.ts
|
|
374
|
+
function promptLine(question) {
|
|
375
|
+
const readline = createInterface2({ input: process.stdin, output: process.stderr });
|
|
376
|
+
return new Promise((resolve) => {
|
|
377
|
+
readline.question(question, (answer) => {
|
|
378
|
+
readline.close();
|
|
379
|
+
resolve(answer);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
}
|
|
357
383
|
function exitWith(code) {
|
|
358
384
|
const drain = (stream) => new Promise((resolve) => stream.write("", () => resolve()));
|
|
359
385
|
return Promise.all([drain(process.stdout), drain(process.stderr)]).then(() => process.exit(code));
|
|
@@ -377,6 +403,7 @@ function interactive() {
|
|
|
377
403
|
}
|
|
378
404
|
async function withConfig(jsonFlag, mutating, run) {
|
|
379
405
|
const resolved = resolveConfig();
|
|
406
|
+
for (const warning of resolved.warnings) process.stderr.write(warning + "\n");
|
|
380
407
|
if (resolved.kind === "error") {
|
|
381
408
|
process.stderr.write(`harness: ${resolved.message}
|
|
382
409
|
`);
|
|
@@ -387,6 +414,19 @@ async function withConfig(jsonFlag, mutating, run) {
|
|
|
387
414
|
process.stderr.write(loginHelp(resolved.baseUrl));
|
|
388
415
|
return exitWith(2);
|
|
389
416
|
}
|
|
417
|
+
if (resolved.hostSource === "repo-config" && !isHostTrusted(resolved.baseUrl)) {
|
|
418
|
+
const answer = await promptLine(
|
|
419
|
+
`This repo's config (${resolved.repoConfigPath ?? ".harness-cli/config.json"}) targets ${resolved.baseUrl} \u2014 log in there? [y/N] `
|
|
420
|
+
);
|
|
421
|
+
if (answer.trim().toLowerCase() !== "y") {
|
|
422
|
+
process.stderr.write(
|
|
423
|
+
`harness: not logging in to ${resolved.baseUrl} \u2014 set HARNESS_API_URL to override the repo config
|
|
424
|
+
`
|
|
425
|
+
);
|
|
426
|
+
return exitWith(2);
|
|
427
|
+
}
|
|
428
|
+
trustHost(resolved.baseUrl);
|
|
429
|
+
}
|
|
390
430
|
const loginResult = await login(resolved.baseUrl, {});
|
|
391
431
|
if (!loginResult.ok) {
|
|
392
432
|
process.stderr.write(`harness: ${clean(loginResult.message)}
|
|
@@ -658,11 +698,18 @@ program.command("upload").description("upload a file; prints markdown to referen
|
|
|
658
698
|
});
|
|
659
699
|
});
|
|
660
700
|
program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
|
|
661
|
-
const { startMcpServer } = await import("./server-
|
|
701
|
+
const { startMcpServer } = await import("./server-H3U2Y6R7.js");
|
|
662
702
|
await startMcpServer();
|
|
663
703
|
});
|
|
664
704
|
program.command("login").description("sign in via the browser (infra.one session)").option("--url <url>", "host to sign in to").option("--no-browser", "print the URL and prompt for a pasted token instead").option("--timeout <ms>", "how long to wait for the browser callback", integer).option("--json", "emit JSON").action(async (opts) => {
|
|
665
|
-
const
|
|
705
|
+
const repoLookup = findRepoConfig();
|
|
706
|
+
for (const warning of repoLookup.warnings) process.stderr.write(warning + "\n");
|
|
707
|
+
const repoUrl = repoLookup.config?.url;
|
|
708
|
+
const url = opts.url ?? blankToUndefined(process.env["HARNESS_API_URL"]) ?? repoUrl ?? storedLoginUrl() ?? DEFAULT_BASE_URL;
|
|
709
|
+
if (url === repoUrl && opts.url === void 0 && blankToUndefined(process.env["HARNESS_API_URL"]) === void 0) {
|
|
710
|
+
process.stderr.write(`harness: host ${url} comes from ${repoLookup.config?.path}
|
|
711
|
+
`);
|
|
712
|
+
}
|
|
666
713
|
const options = { noBrowser: !opts.browser };
|
|
667
714
|
if (opts.timeout !== void 0) options.timeoutMs = opts.timeout;
|
|
668
715
|
await finishAuth(await login(url, options), opts.json === true);
|
|
@@ -672,6 +719,7 @@ program.command("logout").description("revoke and forget the stored login").opti
|
|
|
672
719
|
});
|
|
673
720
|
program.command("whoami").description("show the resolved host and credential source").option("--json", "emit JSON").action(async (opts) => {
|
|
674
721
|
const resolved = resolveConfig();
|
|
722
|
+
for (const warning of resolved.warnings) process.stderr.write(warning + "\n");
|
|
675
723
|
if (resolved.kind === "error") {
|
|
676
724
|
process.stderr.write(`harness: ${resolved.message}
|
|
677
725
|
`);
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
upload,
|
|
39
39
|
userList,
|
|
40
40
|
workflowStates
|
|
41
|
-
} from "./chunk-
|
|
41
|
+
} from "./chunk-JMGBMBN6.js";
|
|
42
42
|
|
|
43
43
|
// src/mcp/server.ts
|
|
44
44
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -48,6 +48,7 @@ var NeedsLoginError = class extends Error {
|
|
|
48
48
|
};
|
|
49
49
|
function context() {
|
|
50
50
|
const resolved = resolveConfig();
|
|
51
|
+
for (const warning of resolved.warnings) process.stderr.write(warning + "\n");
|
|
51
52
|
if (resolved.kind === "error") throw new NeedsLoginError(resolved.message);
|
|
52
53
|
if (resolved.kind === "needs-login") {
|
|
53
54
|
throw new NeedsLoginError(
|
|
@@ -386,7 +387,7 @@ var TOOLS = [
|
|
|
386
387
|
},
|
|
387
388
|
{
|
|
388
389
|
name: "team_create",
|
|
389
|
-
description: "Create a team. The key becomes the identifier prefix for
|
|
390
|
+
description: "Create a team. The key becomes the identifier prefix for the team's issues (upcased, letters/digits, letter first); numbering starts above any issues already carrying that prefix. The server seeds the default workflow states, so the team is immediately usable.",
|
|
390
391
|
readOnly: false,
|
|
391
392
|
shape: {
|
|
392
393
|
key: z.string().describe("team key, e.g. IOS"),
|