@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.
- package/dist/packages/harbour-cli/src/auth.js +17 -2
- package/dist/packages/harbour-cli/src/operations.js +22 -1
- package/dist/packages/harbour-cli/src/output.js +7 -5
- package/dist/packages/harbour-cli/src/productionise.js +12 -1
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -78,18 +78,33 @@ export async function login(mcpUrl, tenant, output) {
|
|
|
78
78
|
callback.close();
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
1
|
+
export const CLI_VERSION = "0.1.13";
|