@fourier-labs/harbour 0.1.13-rc.1 → 0.1.13

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.
@@ -78,18 +78,33 @@ export async function login(mcpUrl, tenant, output) {
78
78
  callback.close();
79
79
  }
80
80
  }
81
- export async function refreshStoredToken(mcpUrl, tenant, path = tokenStorePath()) {
81
+ /** Refresh only when the access token is within this margin of expiry. */
82
+ const REFRESH_MARGIN_MS = 5 * 60_000;
83
+ /**
84
+ * Returns a usable access token, refreshing only when the stored one is about
85
+ * to expire. Refresh tokens are single-use, so every CLI process refreshing on
86
+ * start meant two processes at once (a `productionise` still polling and a
87
+ * `status` beside it) spent the same refresh token and one was told to sign in
88
+ * again. A refresh that fails now re-reads the store once: if a sibling
89
+ * process rotated the pair in the meantime, its newer token is used instead.
90
+ */
91
+ export async function refreshStoredToken(mcpUrl, tenant, path = tokenStorePath(), now = Date.now) {
82
92
  const stored = await loadStoredToken(mcpUrl, tenant, path);
83
93
  if (!stored)
84
94
  return undefined;
85
95
  if (!stored.refreshToken)
86
96
  return stored.accessToken;
97
+ if (stored.accessTokenExpiresAt !== undefined && stored.accessTokenExpiresAt - REFRESH_MARGIN_MS > now())
98
+ return stored.accessToken;
87
99
  try {
88
100
  const refreshed = await token(stored.tokenEndpoint, { grant_type: "refresh_token", client_id: stored.clientId, refresh_token: stored.refreshToken });
89
101
  await saveStoredToken(mcpUrl, tenant, { ...stored, ...refreshed, refreshToken: refreshed.refreshToken ?? stored.refreshToken }, path);
90
102
  return refreshed.accessToken;
91
103
  }
92
104
  catch {
105
+ const rotated = await loadStoredToken(mcpUrl, tenant, path);
106
+ if (rotated && rotated.refreshToken !== stored.refreshToken && (rotated.accessTokenExpiresAt === undefined || rotated.accessTokenExpiresAt - REFRESH_MARGIN_MS > now()))
107
+ return rotated.accessToken;
93
108
  throw new Error("Harbour sign-in expired or was revoked. Run `harbour login` again.");
94
109
  }
95
110
  }
@@ -119,7 +134,7 @@ async function token(endpoint, values) {
119
134
  const response = await json(endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(values), redirect: "error" });
120
135
  if (!response.access_token)
121
136
  throw new Error("Harbour sign-in did not return an access token.");
122
- return { accessToken: response.access_token, ...(response.refresh_token ? { refreshToken: response.refresh_token } : {}) };
137
+ return { accessToken: response.access_token, ...(response.refresh_token ? { refreshToken: response.refresh_token } : {}), ...(typeof response.expires_in === "number" && response.expires_in > 0 ? { accessTokenExpiresAt: Date.now() + response.expires_in * 1_000 } : {}) };
123
138
  }
124
139
  async function json(url, init) { const response = await fetch(url, { ...init, redirect: "error" }); if (!response.ok)
125
140
  throw new Error(`Harbour sign-in request failed (${response.status}).`); return response.json(); }
@@ -62,9 +62,26 @@ export async function waitForSettled(client, operationRef, output, options = {})
62
62
  const graceMs = options.noDeploymentGraceMs ?? 90_000;
63
63
  const startedAt = now();
64
64
  let lastLine = "";
65
+ let consecutiveFailures = 0;
65
66
  while (true) {
66
67
  const callStarted = now();
67
- const status = await fetchStatus(client, operationRef, 15);
68
+ let status;
69
+ try {
70
+ status = await fetchStatus(client, operationRef, 15);
71
+ consecutiveFailures = 0;
72
+ }
73
+ catch (error) {
74
+ // A deployment takes minutes and one status call out of dozens can fail
75
+ // transiently (gateway timeout, dropped connection). The operation is
76
+ // unaffected by our polling, so keep watching; give up only after a run
77
+ // of failures, and say what the last one was instead of a generic error.
78
+ consecutiveFailures += 1;
79
+ if (consecutiveFailures >= 5)
80
+ throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Harbour stopped answering status checks (${safeMessage(error)}). The deployment may still be running; check again with \`harbour status\`.`, operationRef);
81
+ output("Harbour did not answer that status check; trying again.");
82
+ await sleep(Math.min(15_000, 3_000 * consecutiveFailures));
83
+ continue;
84
+ }
68
85
  if (isSettled(status))
69
86
  return status;
70
87
  if (!status.deployment && !status.production) {
@@ -84,6 +101,10 @@ export async function waitForSettled(client, operationRef, output, options = {})
84
101
  await sleep(3_000 - elapsed);
85
102
  }
86
103
  }
104
+ function safeMessage(error) {
105
+ const raw = error instanceof Error ? error.message : String(error);
106
+ return raw.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").replace(/\s+/g, " ").trim().slice(0, 160) || "no details";
107
+ }
87
108
  function progressLine(status) {
88
109
  if (status.production)
89
110
  return `Harbour is promoting the app to production${status.production.message ? ` (${status.production.message})` : ""}.`;
@@ -29,8 +29,8 @@ export function renderSummary(envelope) {
29
29
  lines.push(`Next: ${result.nextStep}`);
30
30
  const setup = envelope.result?.setup;
31
31
  const secrets = setup?.secrets ?? envelope.result?.secrets;
32
- if (setup) {
33
- lines.push(`App name: ${setup.profile?.displayName ?? "(none)"}${setup.profile?.confirmed ? " (confirmed)" : setup.profile?.suggested?.displayName ? ` — suggested: ${setup.profile.suggested.displayName}` : " (not confirmed)"}`);
32
+ if (setup?.profile) {
33
+ lines.push(`App name: ${setup.profile.displayName ?? "(none)"}${setup.profile?.confirmed ? " (confirmed)" : setup.profile?.suggested?.displayName ? ` — suggested: ${setup.profile.suggested.displayName}` : " (not confirmed)"}`);
34
34
  if (setup.profile?.description || setup.profile?.suggested?.description)
35
35
  lines.push(`Description: ${setup.profile?.confirmed ? setup.profile.description : setup.profile?.suggested?.description ?? setup.profile?.description}`);
36
36
  if (setup.audience?.available === false)
@@ -47,9 +47,11 @@ export function renderSummary(envelope) {
47
47
  for (const ask of secrets.asks)
48
48
  lines.push(`Secret ${ask.name}: ${ask.status === "UNSET" ? "needs a value" : ask.status === "SET" ? "set" : "not needed"}${ask.scope === "personal" ? " (personal)" : ""}${ask.prefilledFromPath ? ` (from ${ask.prefilledFromPath})` : ""}`);
49
49
  }
50
- if (setup?.pending?.length)
51
- lines.push(`Next: ${setup.nextAction?.plainEnglish ?? setup.pending.join(", ")}`);
52
- else if (setup)
50
+ // productionise carries only the open steps; the setup commands carry the full view.
51
+ const openSteps = setup?.pending?.length ? setup.pending.map(step => step === "confirm_app_profile" ? "confirm the app's name and description" : step === "confirm_app_audience" ? "confirm who may open it" : step === "provide_secrets" ? "provide the secrets it asked for" : step) : [];
52
+ if (openSteps.length)
53
+ lines.push(`Still needed: ${openSteps.join("; ")}${envelope.result.setup?.plainEnglish && !setup?.profile ? ` — run \`harbour setup --operation ${envelope.operationRef ?? "<reference>"}\`` : ""}`);
54
+ else if (setup?.profile)
53
55
  lines.push("Setup complete.");
54
56
  return `${lines.join("\n")}\n`;
55
57
  }
@@ -79,7 +79,18 @@ export async function productionise(rootArg, client, output, tenantId, includePa
79
79
  // The saved app is not the finished product. The same source save that the
80
80
  // console follows to "live" starts a preview deployment; follow it here so
81
81
  // the maker gets the app's real protected link, not the example one.
82
- const outcome = outcomeFor(await waitForSettled(client, operationRef, output, options.waitOptions), operationRef);
82
+ // Past this point the app is saved and verified; a failure here is about
83
+ // following the deployment, not about the save, and must say so.
84
+ let settled;
85
+ try {
86
+ settled = await waitForSettled(client, operationRef, output, options.waitOptions);
87
+ }
88
+ catch (error) {
89
+ if (error instanceof CliError)
90
+ throw error;
91
+ throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Harbour saved and verified the app but could not follow its deployment (${error instanceof Error ? error.message.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").slice(0, 160) : "unknown error"}). Check again with \`harbour status\`.`, operationRef);
92
+ }
93
+ const outcome = outcomeFor(settled, operationRef);
83
94
  output(outcomeLine(outcome));
84
95
  // The console asks for the app's name, audience and secrets before it
85
96
  // offers promotion; say what is still open so a CLI-only builder knows.
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.13-rc.1";
1
+ export const CLI_VERSION = "0.1.13";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.13-rc.1",
3
+ "version": "0.1.13",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": { "harbour": "./dist/packages/harbour-cli/src/cli.js" },