@fourier-labs/harbour 0.1.8 → 0.1.9

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.
@@ -4,8 +4,10 @@ import { productionise } from "./productionise.js";
4
4
  import { safeError, CliError } from "./output.js";
5
5
  import { CLI_VERSION } from "./version.js";
6
6
  import { login, logout, refreshStoredToken } from "./auth.js";
7
+ import { connect, loadConfig, resolveConfig } from "./config.js";
7
8
  const args = process.argv.slice(2);
8
9
  const command = args[0];
10
+ const connectUrl = args[1];
9
11
  const rootIndex = args.indexOf("--app-root");
10
12
  const root = rootIndex >= 0 ? args[rootIndex + 1] : undefined;
11
13
  const includePaths = [];
@@ -21,17 +23,15 @@ for (let index = 0; index < args.length; index += 1) {
21
23
  index += 1;
22
24
  }
23
25
  }
24
- const url = process.env.HARBOUR_MCP_URL;
25
26
  const explicitToken = process.env.HARBOUR_TOKEN?.trim() ?? "";
26
- const tenant = process.env.HARBOUR_TENANT ?? "";
27
- const usage = "Usage: harbour login | logout | productionise --app-root <path> [--include <relative-path>]... [--json]\nSet HARBOUR_MCP_URL and HARBOUR_TENANT. HARBOUR_TOKEN is a controlled non-production override.\n";
27
+ const usage = "Usage: harbour connect <company-start-url> | login | logout | productionise --app-root <path> [--include <relative-path>]... [--json]\nRun `harbour connect <company-start-url>` once, then sign in when Harbour asks.\n";
28
28
  if (command === "--version" || command === "version") {
29
29
  process.stdout.write(`${CLI_VERSION}\n`);
30
30
  }
31
31
  else if (!command || command === "help" || command === "--help" || args.includes("-h")) {
32
32
  process.stdout.write(usage);
33
33
  }
34
- else if (!url || !tenant || !["login", "logout", "productionise"].includes(command) || (command === "productionise" && (optionError || !root || root.startsWith("--")))) {
34
+ else if (!["connect", "login", "logout", "productionise"].includes(command) || (command === "connect" && (!connectUrl || connectUrl.startsWith("--"))) || (command === "productionise" && (optionError || !root || root.startsWith("--")))) {
35
35
  if (optionError)
36
36
  process.stderr.write(`${optionError}\n`);
37
37
  process.stderr.write(usage);
@@ -39,24 +39,36 @@ else if (!url || !tenant || !["login", "logout", "productionise"].includes(comma
39
39
  }
40
40
  else {
41
41
  try {
42
- if (command === "login") {
43
- await login(url, tenant, message => process.stderr.write(`${message}\n`));
44
- process.stdout.write("Harbour sign-in complete.\n");
45
- process.exitCode = 0;
46
- }
47
- else if (command === "logout") {
48
- await logout(url, tenant);
49
- process.stdout.write("Harbour sign-in removed from this device.\n");
42
+ if (command === "connect") {
43
+ const saved = await connect(connectUrl);
44
+ process.stdout.write(`Harbour is connected for ${saved.tenantId}.\n`);
50
45
  process.exitCode = 0;
51
46
  }
52
47
  else {
53
- const token = explicitToken || await refreshStoredToken(url, tenant);
54
- if (!token)
55
- throw new CliError("AUTH_REQUIRED", "Sign in with `harbour login` before productionising an app.");
56
- const client = new RemoteMcpClient(url, token, tenant);
57
- const result = await productionise(root, client, message => { process.stderr.write(`${message}\n`); }, tenant, includePaths);
58
- const envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: true, operationRef: result.operationRef, result: result.result };
59
- process.stdout.write(`${JSON.stringify(envelope)}\n`);
48
+ const config = resolveConfig(process.env, await loadConfig());
49
+ if (!config)
50
+ throw new CliError("CONFIG_REQUIRED", "Connect Harbour first with `harbour connect <company-start-url>`.");
51
+ const url = config.mcpUrl;
52
+ const tenant = config.tenantId;
53
+ if (command === "login") {
54
+ await login(url, tenant, message => process.stderr.write(`${message}\n`));
55
+ process.stdout.write("Harbour sign-in complete.\n");
56
+ process.exitCode = 0;
57
+ }
58
+ else if (command === "logout") {
59
+ await logout(url, tenant);
60
+ process.stdout.write("Harbour sign-in removed from this device.\n");
61
+ process.exitCode = 0;
62
+ }
63
+ else {
64
+ const token = explicitToken || await refreshStoredToken(url, tenant);
65
+ if (!token)
66
+ throw new CliError("AUTH_REQUIRED", "Please sign in to Harbour with `harbour login`.");
67
+ const client = new RemoteMcpClient(url, token, tenant);
68
+ const result = await productionise(root, client, message => { process.stderr.write(`${message}\n`); }, tenant, includePaths);
69
+ const envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: true, operationRef: result.operationRef, result: result.result };
70
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
71
+ }
60
72
  }
61
73
  }
62
74
  catch (error) {
@@ -0,0 +1,69 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ export function configPath(env = process.env) {
5
+ return join(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "harbour", "config.json");
6
+ }
7
+ export async function loadConfig(path = configPath()) {
8
+ try {
9
+ const parsed = JSON.parse(await readFile(path, "utf8"));
10
+ return validConfig(parsed) ? parsed : undefined;
11
+ }
12
+ catch (error) {
13
+ if (isMissing(error))
14
+ return undefined;
15
+ throw new Error("Harbour could not read its saved connection. Run `harbour connect <start-url>` again.");
16
+ }
17
+ }
18
+ export async function saveConfig(config, path = configPath()) {
19
+ if (!validConfig(config))
20
+ throw new Error("Harbour received an incomplete connection profile.");
21
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
22
+ await chmod(dirname(path), 0o700);
23
+ await writeFile(path, JSON.stringify(config, null, 2), { mode: 0o600 });
24
+ await chmod(path, 0o600);
25
+ }
26
+ export async function connect(startUrl, path = configPath()) {
27
+ const url = new URL(startUrl);
28
+ if (!/^https?:$/.test(url.protocol))
29
+ throw new Error("Harbour connection URL must be an HTTPS URL.");
30
+ if (!url.pathname.endsWith(".json")) {
31
+ url.pathname = `${url.pathname.replace(/\/$/, "")}.json`;
32
+ }
33
+ const response = await fetch(url, { headers: { accept: "application/json" }, redirect: "error" });
34
+ if (!response.ok)
35
+ throw new Error("Harbour could not read this company connection profile.");
36
+ const body = await response.json();
37
+ if (!isRecord(body) || body.schema !== "harbour.tenant-start/1.0")
38
+ throw new Error("This is not a Harbour connection profile.");
39
+ const config = {
40
+ schema: "harbour.cli-config/1.0",
41
+ tenantId: String(body.tenantId ?? ""),
42
+ mcpUrl: String(body.mcpUrl ?? ""),
43
+ ...(typeof body.uploadUrl === "string" ? { uploadUrl: body.uploadUrl } : {})
44
+ };
45
+ await saveConfig(config, path);
46
+ return config;
47
+ }
48
+ export function resolveConfig(env, saved) {
49
+ const mcpUrl = env.HARBOUR_MCP_URL?.trim() || saved?.mcpUrl;
50
+ const tenantId = env.HARBOUR_TENANT?.trim() || saved?.tenantId;
51
+ if (!mcpUrl || !tenantId)
52
+ return undefined;
53
+ return { schema: "harbour.cli-config/1.0", tenantId, mcpUrl, ...(saved?.uploadUrl ? { uploadUrl: saved.uploadUrl } : {}) };
54
+ }
55
+ function validConfig(value) {
56
+ return Boolean(isRecord(value)
57
+ && value.schema === "harbour.cli-config/1.0"
58
+ && typeof value.tenantId === "string"
59
+ && value.tenantId.length > 0
60
+ && typeof value.mcpUrl === "string"
61
+ && /^https?:\/\//.test(value.mcpUrl)
62
+ && (value.uploadUrl === undefined || typeof value.uploadUrl === "string"));
63
+ }
64
+ function isRecord(value) {
65
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
66
+ }
67
+ function isMissing(error) {
68
+ return Boolean(error && typeof error === "object" && error.code === "ENOENT");
69
+ }
@@ -1,6 +1,7 @@
1
1
  export function safeError(error) {
2
2
  const code = error instanceof CliError ? error.code : "CLI_FAILED";
3
- const raw = error instanceof CliError ? error.message : "Harbour could not complete the request.";
3
+ const original = error instanceof Error ? error.message : "";
4
+ const raw = error instanceof CliError ? error.message : friendlyMessage(original);
4
5
  return { code, message: raw.replace(/https?:\/\/\S+|Bearer\s+\S+|(?:x-harbour|authorization|content-type)[^\n]*/gi, "").replace(/\s+/g, " ").trim().slice(0, 240) || "Harbour could not complete the request." };
5
6
  }
6
7
  export class CliError extends Error {
@@ -12,3 +13,14 @@ export class CliError extends Error {
12
13
  this.operationRef = operationRef;
13
14
  }
14
15
  }
16
+ function friendlyMessage(message) {
17
+ if (/connect|connection profile|tenant|company/i.test(message))
18
+ return "This company is not connected to Harbour yet.";
19
+ if (/sign-in|oauth|authorize|token|invalid_scope|redirect|callback|403|401/i.test(message))
20
+ return "Please sign in to Harbour. If sign-in still fails, your account may not be allowed for this company yet.";
21
+ if (/unsafe|secret|changed|digest|manifest|archive/i.test(message))
22
+ return "Harbour found unsafe or changed files and stopped safely.";
23
+ if (/source|save|worker|provider|github/i.test(message))
24
+ return "Saving to company code storage failed safely; nothing was deployed.";
25
+ return "Harbour could not complete the request.";
26
+ }
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.8";
1
+ export const CLI_VERSION = "0.1.9";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": { "harbour": "./dist/packages/harbour-cli/src/cli.js" },