@allocator-one/harness-cli 1.1.0 → 1.2.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 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 homedir2, tmpdir } from "os";
1342
- import { isAbsolute as isAbsolute2, join as join3 } from "path";
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 rawUrl = envUrl ?? (envToken === null && stored !== null ? stored.url : DEFAULT_BASE_URL);
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
- return { kind: "needs-login", baseUrl };
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: blankToNull(env["HARNESS_TEAM"]),
1368
- cacheDir: resolveCacheDir(env)
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"]) ?? join3(cacheHome(env), "harness", "attachments");
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 !== "" && isAbsolute2(value) ? value : null;
1483
+ return value !== void 0 && value !== "" && isAbsolute3(value) ? value : null;
1377
1484
  }
1378
1485
  function cacheHome(env) {
1379
- return absolute(env["XDG_CACHE_HOME"]) ?? join3(homedir2() || tmpdir(), ".cache");
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-JOT3TGQQ.js";
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: { url: config.baseUrl, source: credentialSource, verified: true, server_version: serverVersion },
320
- human: `Host: ${config.baseUrl}
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-GYP5FUKD.js");
701
+ const { startMcpServer } = await import("./server-W4SXUKVP.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 url = opts.url ?? blankToUndefined(process.env["HARNESS_API_URL"]) ?? storedLoginUrl() ?? DEFAULT_BASE_URL;
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-JOT3TGQQ.js";
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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "CLI and MCP server for Harness, allocator.one's issue tracker",
5
5
  "type": "module",
6
6
  "license": "MIT",