Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 155 additions & 110 deletions packages/cli/src/digitalocean/digitalocean.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -600,28 +600,25 @@ async function tryRefreshDoToken(): Promise<string | null> {
return r.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}
/** Mutable state shared between the OAuth callback server and the wait loop. */
interface OAuthCallbackState {
code: string | null;
denied: boolean;
}

const csrfState = generateCsrfState();
let oauthCode: string | null = null;
let oauthDenied = false;
let server: ReturnType<typeof Bun.serve> | null = null;
/** Start a local HTTP server to receive the DigitalOcean OAuth callback.
* Tries ports in range [DO_OAUTH_CALLBACK_PORT, +10). Returns the server,
* actual port, and a shared state object that the callback handler mutates. */
function startOAuthCallbackServer(csrfState: string): {
server: ReturnType<typeof Bun.serve>;
port: number;
state: OAuthCallbackState;
} | null {
const cbState: OAuthCallbackState = {
code: null,
denied: false,
};

// Try ports in range
let actualPort = DO_OAUTH_CALLBACK_PORT;
for (let p = DO_OAUTH_CALLBACK_PORT; p < DO_OAUTH_CALLBACK_PORT + 10; p++) {
const serveResult = tryCatch(() =>
Bun.serve({
Expand All@@ -635,7 +632,7 @@ async function tryDoOAuth(): Promise<string | null> {
if (error) {
const desc = url.searchParams.get("error_description") || error;
logError(`DigitalOcean authorization denied: ${desc}`);
oauthDenied = true;
cbState.denied = true;
return new Response(OAUTH_ERROR_HTML, {
status: 403,
headers: {
Expand DownExpand Up@@ -677,7 +674,7 @@ async function tryDoOAuth(): Promise<string | null> {
});
}

oauthCode = code;
cbState.code = code;
return new Response(OAUTH_SUCCESS_HTML, {
headers: {
"Content-Type": "text/html",
Expand All@@ -696,126 +693,114 @@ async function tryDoOAuth(): Promise<string | null> {
if (!serveResult.ok) {
continue;
}
server = serveResult.data;
actualPort = p;
break;
}

if (!server) {
logWarn(
`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`,
);
return null;
return {
server: serveResult.data,
port: p,
state: cbState,
};
}

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);
logWarn(`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`);
return null;
}

/** Poll for the OAuth callback, handling the initial 120s timeout, non-interactive
* timeout, and interactive extended-wait with Escape-key detection.
* Returns "code" | "denied" | "manual" | "timeout". */
async function waitForOAuthCallback(
cbState: OAuthCallbackState,
server: ReturnType<typeof Bun.serve>,
): Promise<"code" | "denied" | "manual" | "timeout"> {
// Initial wait window (after this, interactive TTY keeps the OAuth server up until callback or Escape)
logStep("Waiting for authorization in browser (extended-wait hint after 120s)...");
const initialDeadline = Date.now() + 120_000;
while (!oauthCode && !oauthDenied && Date.now() < initialDeadline) {
while (!cbState.code && !cbState.denied && Date.now() < initialDeadline) {
await sleep(500);
}

if (!oauthCode && !oauthDenied && process.env.SPAWN_NON_INTERACTIVE === "1") {
if (cbState.code) {
return "code";
}
if (cbState.denied) {
return "denied";
}

if (process.env.SPAWN_NON_INTERACTIVE === "1") {
server.stop(true);
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
return "timeout";
}

// Past the initial window without callback: keep OAuth server up and keep waiting
logWarn("Still waiting for you to complete authorization in your browser.");
let manualTokenRequested = false;
if (!oauthCode && !oauthDenied) {
logWarn("Still waiting for you to complete authorization in your browser.");
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!oauthCode && !oauthDenied && !manualTokenRequested) {
await sleep(500);
}
});
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
} else {
while (!oauthCode && !oauthDenied) {
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!cbState.code && !cbState.denied && !manualTokenRequested) {
await sleep(500);
}
});
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
}
} else {
while (!cbState.code && !cbState.denied) {
await sleep(500);
}
}

server.stop(true);

if (oauthDenied) {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (cbState.code) {
return "code";
}

if (manualTokenRequested) {
logInfo("Switching to manual API token entry.");
return null;
if (cbState.denied) {
return "denied";
}

if (!oauthCode) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (manualTokenRequested) {
return "manual";
}
return "timeout";
}

// Exchange code for token
/** Exchange an OAuth authorization code for an access token and persist it. */
async function exchangeOAuthCode(code: string, redirectUri: string): Promise<string | null> {
logStep("Exchanging authorization code for access token...");
const code = oauthCode; // capture for closure (TS can't narrow `let` across async boundaries)
const exchangeResult = await asyncTryCatch(async () => {
const body = new URLSearchParams({
grant_type: "authorization_code",
Expand DownExpand Up@@ -861,6 +846,66 @@ async function tryDoOAuth(): Promise<string | null> {
return exchangeResult.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}

const csrfState = generateCsrfState();
const serverResult = startOAuthCallbackServer(csrfState);
if (!serverResult) {
return null;
}
const { server, port: actualPort, state: cbState } = serverResult;

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);

const outcome = await waitForOAuthCallback(cbState, server);
server.stop(true);

if (outcome === "denied") {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}
if (outcome === "manual") {
logInfo("Switching to manual API token entry.");
return null;
}
if (outcome === "timeout" || !cbState.code) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}

return exchangeOAuthCode(cbState.code, redirectUri);
}

// ─── Authentication ──────────────────────────────────────────────────────────

/** Returns true if browser OAuth was triggered (so caller can delay before next OAuth). */
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 155 additions & 110 deletions packages/cli/src/digitalocean/digitalocean.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -600,28 +600,25 @@ async function tryRefreshDoToken(): Promise<string | null> {
return r.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}
/** Mutable state shared between the OAuth callback server and the wait loop. */
interface OAuthCallbackState {
code: string | null;
denied: boolean;
}

const csrfState = generateCsrfState();
let oauthCode: string | null = null;
let oauthDenied = false;
let server: ReturnType<typeof Bun.serve> | null = null;
/** Start a local HTTP server to receive the DigitalOcean OAuth callback.
* Tries ports in range [DO_OAUTH_CALLBACK_PORT, +10). Returns the server,
* actual port, and a shared state object that the callback handler mutates. */
function startOAuthCallbackServer(csrfState: string): {
server: ReturnType<typeof Bun.serve>;
port: number;
state: OAuthCallbackState;
} | null {
const cbState: OAuthCallbackState = {
code: null,
denied: false,
};

// Try ports in range
let actualPort = DO_OAUTH_CALLBACK_PORT;
for (let p = DO_OAUTH_CALLBACK_PORT; p < DO_OAUTH_CALLBACK_PORT + 10; p++) {
const serveResult = tryCatch(() =>
Bun.serve({
Expand All@@ -635,7 +632,7 @@ async function tryDoOAuth(): Promise<string | null> {
if (error) {
const desc = url.searchParams.get("error_description") || error;
logError(`DigitalOcean authorization denied: ${desc}`);
oauthDenied = true;
cbState.denied = true;
return new Response(OAUTH_ERROR_HTML, {
status: 403,
headers: {
Expand DownExpand Up@@ -677,7 +674,7 @@ async function tryDoOAuth(): Promise<string | null> {
});
}

oauthCode = code;
cbState.code = code;
return new Response(OAUTH_SUCCESS_HTML, {
headers: {
"Content-Type": "text/html",
Expand All@@ -696,126 +693,114 @@ async function tryDoOAuth(): Promise<string | null> {
if (!serveResult.ok) {
continue;
}
server = serveResult.data;
actualPort = p;
break;
}

if (!server) {
logWarn(
`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`,
);
return null;
return {
server: serveResult.data,
port: p,
state: cbState,
};
}

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);
logWarn(`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`);
return null;
}

/** Poll for the OAuth callback, handling the initial 120s timeout, non-interactive
* timeout, and interactive extended-wait with Escape-key detection.
* Returns "code" | "denied" | "manual" | "timeout". */
async function waitForOAuthCallback(
cbState: OAuthCallbackState,
server: ReturnType<typeof Bun.serve>,
): Promise<"code" | "denied" | "manual" | "timeout"> {
// Initial wait window (after this, interactive TTY keeps the OAuth server up until callback or Escape)
logStep("Waiting for authorization in browser (extended-wait hint after 120s)...");
const initialDeadline = Date.now() + 120_000;
while (!oauthCode && !oauthDenied && Date.now() < initialDeadline) {
while (!cbState.code && !cbState.denied && Date.now() < initialDeadline) {
await sleep(500);
}

if (!oauthCode && !oauthDenied && process.env.SPAWN_NON_INTERACTIVE === "1") {
if (cbState.code) {
return "code";
}
if (cbState.denied) {
return "denied";
}

if (process.env.SPAWN_NON_INTERACTIVE === "1") {
server.stop(true);
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
return "timeout";
}

// Past the initial window without callback: keep OAuth server up and keep waiting
logWarn("Still waiting for you to complete authorization in your browser.");
let manualTokenRequested = false;
if (!oauthCode && !oauthDenied) {
logWarn("Still waiting for you to complete authorization in your browser.");
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!oauthCode && !oauthDenied && !manualTokenRequested) {
await sleep(500);
}
});
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
} else {
while (!oauthCode && !oauthDenied) {
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!cbState.code && !cbState.denied && !manualTokenRequested) {
await sleep(500);
}
});
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
}
} else {
while (!cbState.code && !cbState.denied) {
await sleep(500);
}
}

server.stop(true);

if (oauthDenied) {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (cbState.code) {
return "code";
}

if (manualTokenRequested) {
logInfo("Switching to manual API token entry.");
return null;
if (cbState.denied) {
return "denied";
}

if (!oauthCode) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (manualTokenRequested) {
return "manual";
}
return "timeout";
}

// Exchange code for token
/** Exchange an OAuth authorization code for an access token and persist it. */
async function exchangeOAuthCode(code: string, redirectUri: string): Promise<string | null> {
logStep("Exchanging authorization code for access token...");
const code = oauthCode; // capture for closure (TS can't narrow `let` across async boundaries)
const exchangeResult = await asyncTryCatch(async () => {
const body = new URLSearchParams({
grant_type: "authorization_code",
Expand DownExpand Up@@ -861,6 +846,66 @@ async function tryDoOAuth(): Promise<string | null> {
return exchangeResult.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}

const csrfState = generateCsrfState();
const serverResult = startOAuthCallbackServer(csrfState);
if (!serverResult) {
return null;
}
const { server, port: actualPort, state: cbState } = serverResult;

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);

const outcome = await waitForOAuthCallback(cbState, server);
server.stop(true);

if (outcome === "denied") {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}
if (outcome === "manual") {
logInfo("Switching to manual API token entry.");
return null;
}
if (outcome === "timeout" || !cbState.code) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}

return exchangeOAuthCode(cbState.code, redirectUri);
}

// ─── Authentication ──────────────────────────────────────────────────────────

/** Returns true if browser OAuth was triggered (so caller can delay before next OAuth). */
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 155 additions & 110 deletions packages/cli/src/digitalocean/digitalocean.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -600,28 +600,25 @@ async function tryRefreshDoToken(): Promise<string | null> {
return r.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}
/** Mutable state shared between the OAuth callback server and the wait loop. */
interface OAuthCallbackState {
code: string | null;
denied: boolean;
}

const csrfState = generateCsrfState();
let oauthCode: string | null = null;
let oauthDenied = false;
let server: ReturnType<typeof Bun.serve> | null = null;
/** Start a local HTTP server to receive the DigitalOcean OAuth callback.
* Tries ports in range [DO_OAUTH_CALLBACK_PORT, +10). Returns the server,
* actual port, and a shared state object that the callback handler mutates. */
function startOAuthCallbackServer(csrfState: string): {
server: ReturnType<typeof Bun.serve>;
port: number;
state: OAuthCallbackState;
} | null {
const cbState: OAuthCallbackState = {
code: null,
denied: false,
};

// Try ports in range
let actualPort = DO_OAUTH_CALLBACK_PORT;
for (let p = DO_OAUTH_CALLBACK_PORT; p < DO_OAUTH_CALLBACK_PORT + 10; p++) {
const serveResult = tryCatch(() =>
Bun.serve({
Expand All@@ -635,7 +632,7 @@ async function tryDoOAuth(): Promise<string | null> {
if (error) {
const desc = url.searchParams.get("error_description") || error;
logError(`DigitalOcean authorization denied: ${desc}`);
oauthDenied = true;
cbState.denied = true;
return new Response(OAUTH_ERROR_HTML, {
status: 403,
headers: {
Expand DownExpand Up@@ -677,7 +674,7 @@ async function tryDoOAuth(): Promise<string | null> {
});
}

oauthCode = code;
cbState.code = code;
return new Response(OAUTH_SUCCESS_HTML, {
headers: {
"Content-Type": "text/html",
Expand All@@ -696,126 +693,114 @@ async function tryDoOAuth(): Promise<string | null> {
if (!serveResult.ok) {
continue;
}
server = serveResult.data;
actualPort = p;
break;
}

if (!server) {
logWarn(
`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`,
);
return null;
return {
server: serveResult.data,
port: p,
state: cbState,
};
}

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);
logWarn(`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`);
return null;
}

/** Poll for the OAuth callback, handling the initial 120s timeout, non-interactive
* timeout, and interactive extended-wait with Escape-key detection.
* Returns "code" | "denied" | "manual" | "timeout". */
async function waitForOAuthCallback(
cbState: OAuthCallbackState,
server: ReturnType<typeof Bun.serve>,
): Promise<"code" | "denied" | "manual" | "timeout"> {
// Initial wait window (after this, interactive TTY keeps the OAuth server up until callback or Escape)
logStep("Waiting for authorization in browser (extended-wait hint after 120s)...");
const initialDeadline = Date.now() + 120_000;
while (!oauthCode && !oauthDenied && Date.now() < initialDeadline) {
while (!cbState.code && !cbState.denied && Date.now() < initialDeadline) {
await sleep(500);
}

if (!oauthCode && !oauthDenied && process.env.SPAWN_NON_INTERACTIVE === "1") {
if (cbState.code) {
return "code";
}
if (cbState.denied) {
return "denied";
}

if (process.env.SPAWN_NON_INTERACTIVE === "1") {
server.stop(true);
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
return "timeout";
}

// Past the initial window without callback: keep OAuth server up and keep waiting
logWarn("Still waiting for you to complete authorization in your browser.");
let manualTokenRequested = false;
if (!oauthCode && !oauthDenied) {
logWarn("Still waiting for you to complete authorization in your browser.");
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!oauthCode && !oauthDenied && !manualTokenRequested) {
await sleep(500);
}
});
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
} else {
while (!oauthCode && !oauthDenied) {
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!cbState.code && !cbState.denied && !manualTokenRequested) {
await sleep(500);
}
});
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
}
} else {
while (!cbState.code && !cbState.denied) {
await sleep(500);
}
}

server.stop(true);

if (oauthDenied) {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (cbState.code) {
return "code";
}

if (manualTokenRequested) {
logInfo("Switching to manual API token entry.");
return null;
if (cbState.denied) {
return "denied";
}

if (!oauthCode) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (manualTokenRequested) {
return "manual";
}
return "timeout";
}

// Exchange code for token
/** Exchange an OAuth authorization code for an access token and persist it. */
async function exchangeOAuthCode(code: string, redirectUri: string): Promise<string | null> {
logStep("Exchanging authorization code for access token...");
const code = oauthCode; // capture for closure (TS can't narrow `let` across async boundaries)
const exchangeResult = await asyncTryCatch(async () => {
const body = new URLSearchParams({
grant_type: "authorization_code",
Expand DownExpand Up@@ -861,6 +846,66 @@ async function tryDoOAuth(): Promise<string | null> {
return exchangeResult.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}

const csrfState = generateCsrfState();
const serverResult = startOAuthCallbackServer(csrfState);
if (!serverResult) {
return null;
}
const { server, port: actualPort, state: cbState } = serverResult;

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);

const outcome = await waitForOAuthCallback(cbState, server);
server.stop(true);

if (outcome === "denied") {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}
if (outcome === "manual") {
logInfo("Switching to manual API token entry.");
return null;
}
if (outcome === "timeout" || !cbState.code) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}

return exchangeOAuthCode(cbState.code, redirectUri);
}

// ─── Authentication ──────────────────────────────────────────────────────────

/** Returns true if browser OAuth was triggered (so caller can delay before next OAuth). */
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 155 additions & 110 deletions packages/cli/src/digitalocean/digitalocean.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -600,28 +600,25 @@ async function tryRefreshDoToken(): Promise<string | null> {
return r.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}
/** Mutable state shared between the OAuth callback server and the wait loop. */
interface OAuthCallbackState {
code: string | null;
denied: boolean;
}

const csrfState = generateCsrfState();
let oauthCode: string | null = null;
let oauthDenied = false;
let server: ReturnType<typeof Bun.serve> | null = null;
/** Start a local HTTP server to receive the DigitalOcean OAuth callback.
* Tries ports in range [DO_OAUTH_CALLBACK_PORT, +10). Returns the server,
* actual port, and a shared state object that the callback handler mutates. */
function startOAuthCallbackServer(csrfState: string): {
server: ReturnType<typeof Bun.serve>;
port: number;
state: OAuthCallbackState;
} | null {
const cbState: OAuthCallbackState = {
code: null,
denied: false,
};

// Try ports in range
let actualPort = DO_OAUTH_CALLBACK_PORT;
for (let p = DO_OAUTH_CALLBACK_PORT; p < DO_OAUTH_CALLBACK_PORT + 10; p++) {
const serveResult = tryCatch(() =>
Bun.serve({
Expand All@@ -635,7 +632,7 @@ async function tryDoOAuth(): Promise<string | null> {
if (error) {
const desc = url.searchParams.get("error_description") || error;
logError(`DigitalOcean authorization denied: ${desc}`);
oauthDenied = true;
cbState.denied = true;
return new Response(OAUTH_ERROR_HTML, {
status: 403,
headers: {
Expand DownExpand Up@@ -677,7 +674,7 @@ async function tryDoOAuth(): Promise<string | null> {
});
}

oauthCode = code;
cbState.code = code;
return new Response(OAUTH_SUCCESS_HTML, {
headers: {
"Content-Type": "text/html",
Expand All@@ -696,126 +693,114 @@ async function tryDoOAuth(): Promise<string | null> {
if (!serveResult.ok) {
continue;
}
server = serveResult.data;
actualPort = p;
break;
}

if (!server) {
logWarn(
`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`,
);
return null;
return {
server: serveResult.data,
port: p,
state: cbState,
};
}

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);
logWarn(`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`);
return null;
}

/** Poll for the OAuth callback, handling the initial 120s timeout, non-interactive
* timeout, and interactive extended-wait with Escape-key detection.
* Returns "code" | "denied" | "manual" | "timeout". */
async function waitForOAuthCallback(
cbState: OAuthCallbackState,
server: ReturnType<typeof Bun.serve>,
): Promise<"code" | "denied" | "manual" | "timeout"> {
// Initial wait window (after this, interactive TTY keeps the OAuth server up until callback or Escape)
logStep("Waiting for authorization in browser (extended-wait hint after 120s)...");
const initialDeadline = Date.now() + 120_000;
while (!oauthCode && !oauthDenied && Date.now() < initialDeadline) {
while (!cbState.code && !cbState.denied && Date.now() < initialDeadline) {
await sleep(500);
}

if (!oauthCode && !oauthDenied && process.env.SPAWN_NON_INTERACTIVE === "1") {
if (cbState.code) {
return "code";
}
if (cbState.denied) {
return "denied";
}

if (process.env.SPAWN_NON_INTERACTIVE === "1") {
server.stop(true);
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
return "timeout";
}

// Past the initial window without callback: keep OAuth server up and keep waiting
logWarn("Still waiting for you to complete authorization in your browser.");
let manualTokenRequested = false;
if (!oauthCode && !oauthDenied) {
logWarn("Still waiting for you to complete authorization in your browser.");
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!oauthCode && !oauthDenied && !manualTokenRequested) {
await sleep(500);
}
});
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
} else {
while (!oauthCode && !oauthDenied) {
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!cbState.code && !cbState.denied && !manualTokenRequested) {
await sleep(500);
}
});
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
}
} else {
while (!cbState.code && !cbState.denied) {
await sleep(500);
}
}

server.stop(true);

if (oauthDenied) {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (cbState.code) {
return "code";
}

if (manualTokenRequested) {
logInfo("Switching to manual API token entry.");
return null;
if (cbState.denied) {
return "denied";
}

if (!oauthCode) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (manualTokenRequested) {
return "manual";
}
return "timeout";
}

// Exchange code for token
/** Exchange an OAuth authorization code for an access token and persist it. */
async function exchangeOAuthCode(code: string, redirectUri: string): Promise<string | null> {
logStep("Exchanging authorization code for access token...");
const code = oauthCode; // capture for closure (TS can't narrow `let` across async boundaries)
const exchangeResult = await asyncTryCatch(async () => {
const body = new URLSearchParams({
grant_type: "authorization_code",
Expand DownExpand Up@@ -861,6 +846,66 @@ async function tryDoOAuth(): Promise<string | null> {
return exchangeResult.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}

const csrfState = generateCsrfState();
const serverResult = startOAuthCallbackServer(csrfState);
if (!serverResult) {
return null;
}
const { server, port: actualPort, state: cbState } = serverResult;

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);

const outcome = await waitForOAuthCallback(cbState, server);
server.stop(true);

if (outcome === "denied") {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}
if (outcome === "manual") {
logInfo("Switching to manual API token entry.");
return null;
}
if (outcome === "timeout" || !cbState.code) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}

return exchangeOAuthCode(cbState.code, redirectUri);
}

// ─── Authentication ──────────────────────────────────────────────────────────

/** Returns true if browser OAuth was triggered (so caller can delay before next OAuth). */
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 155 additions & 110 deletions packages/cli/src/digitalocean/digitalocean.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -600,28 +600,25 @@ async function tryRefreshDoToken(): Promise<string | null> {
return r.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}
/** Mutable state shared between the OAuth callback server and the wait loop. */
interface OAuthCallbackState {
code: string | null;
denied: boolean;
}

const csrfState = generateCsrfState();
let oauthCode: string | null = null;
let oauthDenied = false;
let server: ReturnType<typeof Bun.serve> | null = null;
/** Start a local HTTP server to receive the DigitalOcean OAuth callback.
* Tries ports in range [DO_OAUTH_CALLBACK_PORT, +10). Returns the server,
* actual port, and a shared state object that the callback handler mutates. */
function startOAuthCallbackServer(csrfState: string): {
server: ReturnType<typeof Bun.serve>;
port: number;
state: OAuthCallbackState;
} | null {
const cbState: OAuthCallbackState = {
code: null,
denied: false,
};

// Try ports in range
let actualPort = DO_OAUTH_CALLBACK_PORT;
for (let p = DO_OAUTH_CALLBACK_PORT; p < DO_OAUTH_CALLBACK_PORT + 10; p++) {
const serveResult = tryCatch(() =>
Bun.serve({
Expand All@@ -635,7 +632,7 @@ async function tryDoOAuth(): Promise<string | null> {
if (error) {
const desc = url.searchParams.get("error_description") || error;
logError(`DigitalOcean authorization denied: ${desc}`);
oauthDenied = true;
cbState.denied = true;
return new Response(OAUTH_ERROR_HTML, {
status: 403,
headers: {
Expand DownExpand Up@@ -677,7 +674,7 @@ async function tryDoOAuth(): Promise<string | null> {
});
}

oauthCode = code;
cbState.code = code;
return new Response(OAUTH_SUCCESS_HTML, {
headers: {
"Content-Type": "text/html",
Expand All@@ -696,126 +693,114 @@ async function tryDoOAuth(): Promise<string | null> {
if (!serveResult.ok) {
continue;
}
server = serveResult.data;
actualPort = p;
break;
}

if (!server) {
logWarn(
`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`,
);
return null;
return {
server: serveResult.data,
port: p,
state: cbState,
};
}

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);
logWarn(`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`);
return null;
}

/** Poll for the OAuth callback, handling the initial 120s timeout, non-interactive
* timeout, and interactive extended-wait with Escape-key detection.
* Returns "code" | "denied" | "manual" | "timeout". */
async function waitForOAuthCallback(
cbState: OAuthCallbackState,
server: ReturnType<typeof Bun.serve>,
): Promise<"code" | "denied" | "manual" | "timeout"> {
// Initial wait window (after this, interactive TTY keeps the OAuth server up until callback or Escape)
logStep("Waiting for authorization in browser (extended-wait hint after 120s)...");
const initialDeadline = Date.now() + 120_000;
while (!oauthCode && !oauthDenied && Date.now() < initialDeadline) {
while (!cbState.code && !cbState.denied && Date.now() < initialDeadline) {
await sleep(500);
}

if (!oauthCode && !oauthDenied && process.env.SPAWN_NON_INTERACTIVE === "1") {
if (cbState.code) {
return "code";
}
if (cbState.denied) {
return "denied";
}

if (process.env.SPAWN_NON_INTERACTIVE === "1") {
server.stop(true);
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
return "timeout";
}

// Past the initial window without callback: keep OAuth server up and keep waiting
logWarn("Still waiting for you to complete authorization in your browser.");
let manualTokenRequested = false;
if (!oauthCode && !oauthDenied) {
logWarn("Still waiting for you to complete authorization in your browser.");
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!oauthCode && !oauthDenied && !manualTokenRequested) {
await sleep(500);
}
});
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
} else {
while (!oauthCode && !oauthDenied) {
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!cbState.code && !cbState.denied && !manualTokenRequested) {
await sleep(500);
}
});
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
}
} else {
while (!cbState.code && !cbState.denied) {
await sleep(500);
}
}

server.stop(true);

if (oauthDenied) {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (cbState.code) {
return "code";
}

if (manualTokenRequested) {
logInfo("Switching to manual API token entry.");
return null;
if (cbState.denied) {
return "denied";
}

if (!oauthCode) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (manualTokenRequested) {
return "manual";
}
return "timeout";
}

// Exchange code for token
/** Exchange an OAuth authorization code for an access token and persist it. */
async function exchangeOAuthCode(code: string, redirectUri: string): Promise<string | null> {
logStep("Exchanging authorization code for access token...");
const code = oauthCode; // capture for closure (TS can't narrow `let` across async boundaries)
const exchangeResult = await asyncTryCatch(async () => {
const body = new URLSearchParams({
grant_type: "authorization_code",
Expand DownExpand Up@@ -861,6 +846,66 @@ async function tryDoOAuth(): Promise<string | null> {
return exchangeResult.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}

const csrfState = generateCsrfState();
const serverResult = startOAuthCallbackServer(csrfState);
if (!serverResult) {
return null;
}
const { server, port: actualPort, state: cbState } = serverResult;

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);

const outcome = await waitForOAuthCallback(cbState, server);
server.stop(true);

if (outcome === "denied") {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}
if (outcome === "manual") {
logInfo("Switching to manual API token entry.");
return null;
}
if (outcome === "timeout" || !cbState.code) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}

return exchangeOAuthCode(cbState.code, redirectUri);
}

// ─── Authentication ──────────────────────────────────────────────────────────

/** Returns true if browser OAuth was triggered (so caller can delay before next OAuth). */
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 155 additions & 110 deletions packages/cli/src/digitalocean/digitalocean.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -600,28 +600,25 @@ async function tryRefreshDoToken(): Promise<string | null> {
return r.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}
/** Mutable state shared between the OAuth callback server and the wait loop. */
interface OAuthCallbackState {
code: string | null;
denied: boolean;
}

const csrfState = generateCsrfState();
let oauthCode: string | null = null;
let oauthDenied = false;
let server: ReturnType<typeof Bun.serve> | null = null;
/** Start a local HTTP server to receive the DigitalOcean OAuth callback.
* Tries ports in range [DO_OAUTH_CALLBACK_PORT, +10). Returns the server,
* actual port, and a shared state object that the callback handler mutates. */
function startOAuthCallbackServer(csrfState: string): {
server: ReturnType<typeof Bun.serve>;
port: number;
state: OAuthCallbackState;
} | null {
const cbState: OAuthCallbackState = {
code: null,
denied: false,
};

// Try ports in range
let actualPort = DO_OAUTH_CALLBACK_PORT;
for (let p = DO_OAUTH_CALLBACK_PORT; p < DO_OAUTH_CALLBACK_PORT + 10; p++) {
const serveResult = tryCatch(() =>
Bun.serve({
Expand All@@ -635,7 +632,7 @@ async function tryDoOAuth(): Promise<string | null> {
if (error) {
const desc = url.searchParams.get("error_description") || error;
logError(`DigitalOcean authorization denied: ${desc}`);
oauthDenied = true;
cbState.denied = true;
return new Response(OAUTH_ERROR_HTML, {
status: 403,
headers: {
Expand DownExpand Up@@ -677,7 +674,7 @@ async function tryDoOAuth(): Promise<string | null> {
});
}

oauthCode = code;
cbState.code = code;
return new Response(OAUTH_SUCCESS_HTML, {
headers: {
"Content-Type": "text/html",
Expand All@@ -696,126 +693,114 @@ async function tryDoOAuth(): Promise<string | null> {
if (!serveResult.ok) {
continue;
}
server = serveResult.data;
actualPort = p;
break;
}

if (!server) {
logWarn(
`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`,
);
return null;
return {
server: serveResult.data,
port: p,
state: cbState,
};
}

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);
logWarn(`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`);
return null;
}

/** Poll for the OAuth callback, handling the initial 120s timeout, non-interactive
* timeout, and interactive extended-wait with Escape-key detection.
* Returns "code" | "denied" | "manual" | "timeout". */
async function waitForOAuthCallback(
cbState: OAuthCallbackState,
server: ReturnType<typeof Bun.serve>,
): Promise<"code" | "denied" | "manual" | "timeout"> {
// Initial wait window (after this, interactive TTY keeps the OAuth server up until callback or Escape)
logStep("Waiting for authorization in browser (extended-wait hint after 120s)...");
const initialDeadline = Date.now() + 120_000;
while (!oauthCode && !oauthDenied && Date.now() < initialDeadline) {
while (!cbState.code && !cbState.denied && Date.now() < initialDeadline) {
await sleep(500);
}

if (!oauthCode && !oauthDenied && process.env.SPAWN_NON_INTERACTIVE === "1") {
if (cbState.code) {
return "code";
}
if (cbState.denied) {
return "denied";
}

if (process.env.SPAWN_NON_INTERACTIVE === "1") {
server.stop(true);
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
return "timeout";
}

// Past the initial window without callback: keep OAuth server up and keep waiting
logWarn("Still waiting for you to complete authorization in your browser.");
let manualTokenRequested = false;
if (!oauthCode && !oauthDenied) {
logWarn("Still waiting for you to complete authorization in your browser.");
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!oauthCode && !oauthDenied && !manualTokenRequested) {
await sleep(500);
}
});
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
} else {
while (!oauthCode && !oauthDenied) {
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!cbState.code && !cbState.denied && !manualTokenRequested) {
await sleep(500);
}
});
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
}
} else {
while (!cbState.code && !cbState.denied) {
await sleep(500);
}
}

server.stop(true);

if (oauthDenied) {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (cbState.code) {
return "code";
}

if (manualTokenRequested) {
logInfo("Switching to manual API token entry.");
return null;
if (cbState.denied) {
return "denied";
}

if (!oauthCode) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (manualTokenRequested) {
return "manual";
}
return "timeout";
}

// Exchange code for token
/** Exchange an OAuth authorization code for an access token and persist it. */
async function exchangeOAuthCode(code: string, redirectUri: string): Promise<string | null> {
logStep("Exchanging authorization code for access token...");
const code = oauthCode; // capture for closure (TS can't narrow `let` across async boundaries)
const exchangeResult = await asyncTryCatch(async () => {
const body = new URLSearchParams({
grant_type: "authorization_code",
Expand DownExpand Up@@ -861,6 +846,66 @@ async function tryDoOAuth(): Promise<string | null> {
return exchangeResult.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}

const csrfState = generateCsrfState();
const serverResult = startOAuthCallbackServer(csrfState);
if (!serverResult) {
return null;
}
const { server, port: actualPort, state: cbState } = serverResult;

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);

const outcome = await waitForOAuthCallback(cbState, server);
server.stop(true);

if (outcome === "denied") {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}
if (outcome === "manual") {
logInfo("Switching to manual API token entry.");
return null;
}
if (outcome === "timeout" || !cbState.code) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}

return exchangeOAuthCode(cbState.code, redirectUri);
}

// ─── Authentication ──────────────────────────────────────────────────────────

/** Returns true if browser OAuth was triggered (so caller can delay before next OAuth). */
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 155 additions & 110 deletions packages/cli/src/digitalocean/digitalocean.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -600,28 +600,25 @@ async function tryRefreshDoToken(): Promise<string | null> {
return r.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}
/** Mutable state shared between the OAuth callback server and the wait loop. */
interface OAuthCallbackState {
code: string | null;
denied: boolean;
}

const csrfState = generateCsrfState();
let oauthCode: string | null = null;
let oauthDenied = false;
let server: ReturnType<typeof Bun.serve> | null = null;
/** Start a local HTTP server to receive the DigitalOcean OAuth callback.
* Tries ports in range [DO_OAUTH_CALLBACK_PORT, +10). Returns the server,
* actual port, and a shared state object that the callback handler mutates. */
function startOAuthCallbackServer(csrfState: string): {
server: ReturnType<typeof Bun.serve>;
port: number;
state: OAuthCallbackState;
} | null {
const cbState: OAuthCallbackState = {
code: null,
denied: false,
};

// Try ports in range
let actualPort = DO_OAUTH_CALLBACK_PORT;
for (let p = DO_OAUTH_CALLBACK_PORT; p < DO_OAUTH_CALLBACK_PORT + 10; p++) {
const serveResult = tryCatch(() =>
Bun.serve({
Expand All@@ -635,7 +632,7 @@ async function tryDoOAuth(): Promise<string | null> {
if (error) {
const desc = url.searchParams.get("error_description") || error;
logError(`DigitalOcean authorization denied: ${desc}`);
oauthDenied = true;
cbState.denied = true;
return new Response(OAUTH_ERROR_HTML, {
status: 403,
headers: {
Expand DownExpand Up@@ -677,7 +674,7 @@ async function tryDoOAuth(): Promise<string | null> {
});
}

oauthCode = code;
cbState.code = code;
return new Response(OAUTH_SUCCESS_HTML, {
headers: {
"Content-Type": "text/html",
Expand All@@ -696,126 +693,114 @@ async function tryDoOAuth(): Promise<string | null> {
if (!serveResult.ok) {
continue;
}
server = serveResult.data;
actualPort = p;
break;
}

if (!server) {
logWarn(
`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`,
);
return null;
return {
server: serveResult.data,
port: p,
state: cbState,
};
}

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);
logWarn(`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`);
return null;
}

/** Poll for the OAuth callback, handling the initial 120s timeout, non-interactive
* timeout, and interactive extended-wait with Escape-key detection.
* Returns "code" | "denied" | "manual" | "timeout". */
async function waitForOAuthCallback(
cbState: OAuthCallbackState,
server: ReturnType<typeof Bun.serve>,
): Promise<"code" | "denied" | "manual" | "timeout"> {
// Initial wait window (after this, interactive TTY keeps the OAuth server up until callback or Escape)
logStep("Waiting for authorization in browser (extended-wait hint after 120s)...");
const initialDeadline = Date.now() + 120_000;
while (!oauthCode && !oauthDenied && Date.now() < initialDeadline) {
while (!cbState.code && !cbState.denied && Date.now() < initialDeadline) {
await sleep(500);
}

if (!oauthCode && !oauthDenied && process.env.SPAWN_NON_INTERACTIVE === "1") {
if (cbState.code) {
return "code";
}
if (cbState.denied) {
return "denied";
}

if (process.env.SPAWN_NON_INTERACTIVE === "1") {
server.stop(true);
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
return "timeout";
}

// Past the initial window without callback: keep OAuth server up and keep waiting
logWarn("Still waiting for you to complete authorization in your browser.");
let manualTokenRequested = false;
if (!oauthCode && !oauthDenied) {
logWarn("Still waiting for you to complete authorization in your browser.");
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!oauthCode && !oauthDenied && !manualTokenRequested) {
await sleep(500);
}
});
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
} else {
while (!oauthCode && !oauthDenied) {
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!cbState.code && !cbState.denied && !manualTokenRequested) {
await sleep(500);
}
});
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
}
} else {
while (!cbState.code && !cbState.denied) {
await sleep(500);
}
}

server.stop(true);

if (oauthDenied) {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (cbState.code) {
return "code";
}

if (manualTokenRequested) {
logInfo("Switching to manual API token entry.");
return null;
if (cbState.denied) {
return "denied";
}

if (!oauthCode) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (manualTokenRequested) {
return "manual";
}
return "timeout";
}

// Exchange code for token
/** Exchange an OAuth authorization code for an access token and persist it. */
async function exchangeOAuthCode(code: string, redirectUri: string): Promise<string | null> {
logStep("Exchanging authorization code for access token...");
const code = oauthCode; // capture for closure (TS can't narrow `let` across async boundaries)
const exchangeResult = await asyncTryCatch(async () => {
const body = new URLSearchParams({
grant_type: "authorization_code",
Expand DownExpand Up@@ -861,6 +846,66 @@ async function tryDoOAuth(): Promise<string | null> {
return exchangeResult.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}

const csrfState = generateCsrfState();
const serverResult = startOAuthCallbackServer(csrfState);
if (!serverResult) {
return null;
}
const { server, port: actualPort, state: cbState } = serverResult;

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);

const outcome = await waitForOAuthCallback(cbState, server);
server.stop(true);

if (outcome === "denied") {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}
if (outcome === "manual") {
logInfo("Switching to manual API token entry.");
return null;
}
if (outcome === "timeout" || !cbState.code) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}

return exchangeOAuthCode(cbState.code, redirectUri);
}

// ─── Authentication ──────────────────────────────────────────────────────────

/** Returns true if browser OAuth was triggered (so caller can delay before next OAuth). */
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 155 additions & 110 deletions packages/cli/src/digitalocean/digitalocean.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -600,28 +600,25 @@ async function tryRefreshDoToken(): Promise<string | null> {
return r.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}
/** Mutable state shared between the OAuth callback server and the wait loop. */
interface OAuthCallbackState {
code: string | null;
denied: boolean;
}

const csrfState = generateCsrfState();
let oauthCode: string | null = null;
let oauthDenied = false;
let server: ReturnType<typeof Bun.serve> | null = null;
/** Start a local HTTP server to receive the DigitalOcean OAuth callback.
* Tries ports in range [DO_OAUTH_CALLBACK_PORT, +10). Returns the server,
* actual port, and a shared state object that the callback handler mutates. */
function startOAuthCallbackServer(csrfState: string): {
server: ReturnType<typeof Bun.serve>;
port: number;
state: OAuthCallbackState;
} | null {
const cbState: OAuthCallbackState = {
code: null,
denied: false,
};

// Try ports in range
let actualPort = DO_OAUTH_CALLBACK_PORT;
for (let p = DO_OAUTH_CALLBACK_PORT; p < DO_OAUTH_CALLBACK_PORT + 10; p++) {
const serveResult = tryCatch(() =>
Bun.serve({
Expand All@@ -635,7 +632,7 @@ async function tryDoOAuth(): Promise<string | null> {
if (error) {
const desc = url.searchParams.get("error_description") || error;
logError(`DigitalOcean authorization denied: ${desc}`);
oauthDenied = true;
cbState.denied = true;
return new Response(OAUTH_ERROR_HTML, {
status: 403,
headers: {
Expand DownExpand Up@@ -677,7 +674,7 @@ async function tryDoOAuth(): Promise<string | null> {
});
}

oauthCode = code;
cbState.code = code;
return new Response(OAUTH_SUCCESS_HTML, {
headers: {
"Content-Type": "text/html",
Expand All@@ -696,126 +693,114 @@ async function tryDoOAuth(): Promise<string | null> {
if (!serveResult.ok) {
continue;
}
server = serveResult.data;
actualPort = p;
break;
}

if (!server) {
logWarn(
`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`,
);
return null;
return {
server: serveResult.data,
port: p,
state: cbState,
};
}

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);
logWarn(`Failed to start OAuth server — ports ${DO_OAUTH_CALLBACK_PORT}-${DO_OAUTH_CALLBACK_PORT + 9} may be in use`);
return null;
}

/** Poll for the OAuth callback, handling the initial 120s timeout, non-interactive
* timeout, and interactive extended-wait with Escape-key detection.
* Returns "code" | "denied" | "manual" | "timeout". */
async function waitForOAuthCallback(
cbState: OAuthCallbackState,
server: ReturnType<typeof Bun.serve>,
): Promise<"code" | "denied" | "manual" | "timeout"> {
// Initial wait window (after this, interactive TTY keeps the OAuth server up until callback or Escape)
logStep("Waiting for authorization in browser (extended-wait hint after 120s)...");
const initialDeadline = Date.now() + 120_000;
while (!oauthCode && !oauthDenied && Date.now() < initialDeadline) {
while (!cbState.code && !cbState.denied && Date.now() < initialDeadline) {
await sleep(500);
}

if (!oauthCode && !oauthDenied && process.env.SPAWN_NON_INTERACTIVE === "1") {
if (cbState.code) {
return "code";
}
if (cbState.denied) {
return "denied";
}

if (process.env.SPAWN_NON_INTERACTIVE === "1") {
server.stop(true);
logError("OAuth authentication timed out after 120 seconds");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
return "timeout";
}

// Past the initial window without callback: keep OAuth server up and keep waiting
logWarn("Still waiting for you to complete authorization in your browser.");
let manualTokenRequested = false;
if (!oauthCode && !oauthDenied) {
logWarn("Still waiting for you to complete authorization in your browser.");
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!oauthCode && !oauthDenied && !manualTokenRequested) {
await sleep(500);
}
});
if (isInteractiveTTY()) {
logInfo("Press Escape to enter a DigitalOcean API token instead.");

let pendingEscTimer: ReturnType<typeof setTimeout> | null = null;
const onData = (data: Buffer | string) => {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
if (buf.length === 0) {
return;
}
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
pendingEscTimer = null;
return;
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
if (buf[0] === 0x1b && buf.length === 1) {
pendingEscTimer = setTimeout(() => {
pendingEscTimer = null;
manualTokenRequested = true;
}, 75);
return;
}
} else {
while (!oauthCode && !oauthDenied) {
if (buf[0] === 0x1b && buf.length > 1 && (buf[1] === 0x5b || buf[1] === 0x4f)) {
return;
}
};

process.stdin.resume();
process.stdin.setRawMode?.(true);
process.stdin.on("data", onData);
const waitResult = await asyncTryCatch(async () => {
while (!cbState.code && !cbState.denied && !manualTokenRequested) {
await sleep(500);
}
});
if (pendingEscTimer) {
clearTimeout(pendingEscTimer);
}
process.stdin.off("data", onData);
process.stdin.setRawMode?.(false);
process.stdin.pause();
if (!waitResult.ok) {
throw waitResult.error;
}
} else {
while (!cbState.code && !cbState.denied) {
await sleep(500);
}
}

server.stop(true);

if (oauthDenied) {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (cbState.code) {
return "code";
}

if (manualTokenRequested) {
logInfo("Switching to manual API token entry.");
return null;
if (cbState.denied) {
return "denied";
}

if (!oauthCode) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
if (manualTokenRequested) {
return "manual";
}
return "timeout";
}

// Exchange code for token
/** Exchange an OAuth authorization code for an access token and persist it. */
async function exchangeOAuthCode(code: string, redirectUri: string): Promise<string | null> {
logStep("Exchanging authorization code for access token...");
const code = oauthCode; // capture for closure (TS can't narrow `let` across async boundaries)
const exchangeResult = await asyncTryCatch(async () => {
const body = new URLSearchParams({
grant_type: "authorization_code",
Expand DownExpand Up@@ -861,6 +846,66 @@ async function tryDoOAuth(): Promise<string | null> {
return exchangeResult.data;
}

async function tryDoOAuth(): Promise<string | null> {
logStep("Attempting DigitalOcean OAuth authentication...");

// Check connectivity to DigitalOcean
const connCheck = await asyncTryCatch(() =>
fetch("https://cloud.digitalocean.com", {
method: "HEAD",
signal: AbortSignal.timeout(5_000),
}),
);
if (!connCheck.ok) {
logWarn("Cannot reach cloud.digitalocean.com — network may be unavailable");
return null;
}

const csrfState = generateCsrfState();
const serverResult = startOAuthCallbackServer(csrfState);
if (!serverResult) {
return null;
}
const { server, port: actualPort, state: cbState } = serverResult;

logInfo(`OAuth server listening on port ${actualPort}`);

const redirectUri = `http://localhost:${actualPort}/callback`;
const authParams = new URLSearchParams({
client_id: DO_CLIENT_ID,
redirect_uri: redirectUri,
response_type: "code",
scope: DO_SCOPES,
state: csrfState,
});
const authUrl = `${DO_OAUTH_AUTHORIZE}?${authParams.toString()}`;

logStep("Opening browser to authorize with DigitalOcean...");
openBrowser(authUrl);

const outcome = await waitForOAuthCallback(cbState, server);
server.stop(true);

if (outcome === "denied") {
logError("OAuth authorization was denied by the user");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}
if (outcome === "manual") {
logInfo("Switching to manual API token entry.");
return null;
}
if (outcome === "timeout" || !cbState.code) {
logError("OAuth authentication did not complete");
logError("Alternative: Use a manual API token instead");
logError(" export DIGITALOCEAN_ACCESS_TOKEN=dop_v1_...");
return null;
}

return exchangeOAuthCode(cbState.code, redirectUri);
}

// ─── Authentication ──────────────────────────────────────────────────────────

/** Returns true if browser OAuth was triggered (so caller can delay before next OAuth). */
Expand Down
Loading