Skip to content

Commit 87dd140

Browse files
l2yshoclaude
andauthored
fix: connect the right Apify account in apify create --source github (#1371)
> [!NOTE] > **TL;DR** — `apify create --source github` connected GitHub to the wrong Apify account for anyone logged in as an organization. The connect link omitted the `routePrefix` query parameter, so Console verified the OAuth code as the personal account behind the organization and the CLI waited for an integration that could never appear. Fixed by sending the prefix, and by naming the account the CLI is connecting so a grant that lands elsewhere is visible instead of silent. Fixes #1370. ### What changed `src/lib/git-source/gitSource.ts` - `getGitConnectUrl` sends `routePrefix=/organization/<id>` for an organization login, encoded the way Console encodes it. The callback page decodes it and navigates to the organization route, so the code is verified as the organization. - New `GitAccount` type and `toGitAccount()`, which keeps id, username and `organizationOwnerUserId` and leaves the token out of the flow options. - The wait loop re-reads which grant is missing on every poll. Authorizing makes the integration appear with no workspaces, and only installing the app fills them, so the install URL is offered as soon as authorization lands instead of after the 3-minute timeout. A URL that has already been opened is never opened twice. - The wait line and the `notAuthorized` / `noWorkspace` errors name the Apify account, so a grant that landed on another account is visible instead of silent. - `getGitStopUrl` and `buildGitSourceNextSteps` take the account too, so the recovery URL is not the same dead end. `src/commands/create.ts` reads the account after `getLoggedClientOrThrow()`, which refreshes `auth.json` from the token the run resolved, and threads it through the flow, the next steps and the `--json` `gitConnectUrl`. ### Reviewer notes - The prefix is double-encoded on the wire. This matches Console's own implementation, which does `encodeURIComponent(prefix)` and then encodes again into the query it builds; the callback page decodes once by parsing the query and once more with `decodeURIComponent`. Verified against the deployed Console bundle, not inferred. - `type: USER` routes each get a generated `/organization/:viewedUserId` twin, and `viewedUserId` from the pathname selects the token the callback verifies under. That is what binds the grant to the organization. - Nothing else changes for a personal login. There is a test asserting the URL stays byte-identical to before. - Console-side, not fixed here: the callback page calls `window.close()`, which browsers ignore for a tab the CLI opened with `open()`. Console's own flow uses a 900×700 popup, where close works. The CLI cannot get a popup, so the user is left on a page with a dead Close button. ### Verification Verified live on an organization login, with Console's UI left on the personal account to prove the routing does not depend on it. `apify create --source github` sends the grant to the organization, and workspace selection proceeds. ### Follow-up A second bug surfaced while testing this one: the CLI guesses which GitHub grant is missing from an API state where the two possibilities are indistinguishable, so it can offer a link that never resolves. That is unrelated to redirects and hits personal accounts too. It is fixed in a follow-up PR stacked on this branch. ### Checks `lint`, `format`, `build`, `test:local` (493 passed, 4 skipped). No docs regen — no flag, arg or description changed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d3f7102 commit 87dd140

3 files changed

Lines changed: 114 additions & 15 deletions

File tree

‎src/commands/create.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
promptGitSource,
3939
logGitSourceOutcome,
4040
runGitSourceFlow,
41+
toGitAccount,
4142
} from '../lib/git-source/gitSource.js';
4243
import { usePythonRuntime } from '../lib/hooks/runtimes/python.js';
4344
import { getInstallCommandSuggestion } from '../lib/hooks/runtimes/utils.js';
@@ -49,6 +50,7 @@ import { LANGUAGE_FLAG_CHOICES, USE_CASE_FLAG_CHOICES } from '../lib/templates/c
4950
import {
5051
downloadAndUnzip,
5152
getJsonFileContent,
53+
getLocalUserInfo,
5254
getLoggedClientOrThrow,
5355
isNodeVersionSupported,
5456
isPythonVersionSupported,
@@ -302,6 +304,8 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
302304
? {
303305
provider: gitProvider,
304306
client: await getLoggedClientOrThrow(),
307+
// Read after the client, which refreshes auth.json from the token the run resolved.
308+
account: toGitAccount(await getLocalUserInfo()),
305309
...parseGitRepoFlag(gitRepo, actorName),
306310
}
307311
: null;
@@ -343,6 +347,7 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
343347
templateArchiveUrl,
344348
client: gitSetup.client,
345349
isInteractive,
350+
account: gitSetup.account,
346351
customize: applyLocalConfig,
347352
});
348353
}
@@ -567,6 +572,7 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
567572
repoName: gitSetup.repoName,
568573
scaffolded: gitResult.scaffolded,
569574
actorId: gitResult.actorId,
575+
account: gitSetup.account,
570576
})
571577
: buildNextSteps({ actorName, dependenciesInstalled, installCommandSuggestion });
572578

@@ -586,7 +592,9 @@ export class CreateCommand extends ApifyCommand<typeof CreateCommand> {
586592
actorId: gitResult?.actorId ?? null,
587593
stopReason: gitResult?.stopReason ?? null,
588594
error: gitResult?.error ?? null,
589-
gitConnectUrl: gitProvider ? getGitStopUrl(gitProvider, gitResult?.stopReason ?? null) : null,
595+
gitConnectUrl: gitProvider
596+
? getGitStopUrl(gitProvider, gitResult?.stopReason ?? null, gitSetup?.account)
597+
: null,
590598
workspaces: gitResult?.workspaces ?? null,
591599
});
592600
} else if (reportSuccess) {

‎src/lib/git-source/gitSource.ts‎

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { getConsoleUrl } from '../console-url.js';
1111
import { useSelectFromList } from '../hooks/user-confirmations/useSelectFromList.js';
1212
import { useUserInput } from '../hooks/user-confirmations/useUserInput.js';
1313
import { info, warning } from '../outputs.js';
14+
import type { AuthJSON } from '../types.js';
1415
import { cliDebugPrint } from '../utils/cliDebugPrint.js';
1516

1617
/** Where a new Actor's source code lives. `apify` is the existing, Git-less path. */
@@ -84,11 +85,37 @@ const GIT_PROVIDERS = {
8485
},
8586
} satisfies Record<GitProvider, GitProviderConfig>;
8687

88+
/**
89+
* The Apify account the CLI token belongs to, as `auth.json` records it. An organization login is the case
90+
* that matters: Console completes the exchange as whichever account its own route prefix selects, so
91+
* without that prefix the grant lands on the personal account behind the organization instead.
92+
*/
93+
export interface GitAccount {
94+
id?: string;
95+
username?: string;
96+
organizationOwnerUserId?: string;
97+
}
98+
99+
/** Narrows the stored login to what the handoff needs, so the token is not carried around with it. */
100+
export const toGitAccount = ({ id, username, organizationOwnerUserId }: AuthJSON): GitAccount => ({
101+
id,
102+
username,
103+
organizationOwnerUserId,
104+
});
105+
106+
/** The Console route the callback page has to run under to complete the exchange as this account. */
107+
const getConsoleRoutePrefix = (account?: GitAccount) =>
108+
account?.organizationOwnerUserId && account.id ? `/organization/${account.id}` : null;
109+
87110
/** Where to send a user who has not connected this provider to Apify yet. */
88-
export const getGitConnectUrl = (provider: GitProvider) => {
111+
export const getGitConnectUrl = (provider: GitProvider, account?: GitAccount) => {
89112
const { authorizeEndpoint, defaultClientId, clientIdEnvVar, redirectPath } = GIT_PROVIDERS[provider];
90113

91114
const redirectUri = new URL(redirectPath, getConsoleUrl());
115+
const routePrefix = getConsoleRoutePrefix(account);
116+
// Encoded twice on purpose, matching Console: it encodes the prefix into the query it builds, and the
117+
// callback page decodes it once of its own accord before navigating there.
118+
if (routePrefix) redirectUri.searchParams.set('routePrefix', encodeURIComponent(routePrefix));
92119

93120
const url = new URL(authorizeEndpoint);
94121
url.searchParams.set('client_id', process.env[clientIdEnvVar] || defaultClientId);
@@ -208,7 +235,7 @@ const POLL_TIMEOUT_MS = 3 * 60_000;
208235
*/
209236
const ensureUsableIntegration = async (
210237
provider: GitProvider,
211-
{ client, isInteractive }: ApiCallOptions & { isInteractive: boolean },
238+
{ client, isInteractive, account }: ApiCallOptions & { isInteractive: boolean; account?: GitAccount },
212239
): Promise<GitProviderIntegration | null> => {
213240
const load = async () => findIntegration(await fetchGitIntegrations({ client }), provider);
214241

@@ -223,13 +250,27 @@ const ensureUsableIntegration = async (
223250
// Agents and CI must never be parked on a browser; the caller emits the URL and stops instead.
224251
if (!isInteractive) return integration ?? null;
225252

226-
const url = integration ? integration.addWorkspaceUrl || getAddWorkspaceUrl(provider) : getGitConnectUrl(provider);
227-
const what = integration ? `Give Apify access to a ${provider} account` : `Connect your ${provider} account to Apify`;
253+
// Which grant is missing decides the URL, and authorizing changes the answer: the integration then
254+
// exists with no workspaces, and only installing the app fills them. So this is re-read every poll,
255+
// and a URL that has already been opened is never opened twice.
256+
let openedUrl: string | null = null;
257+
const offer = async (current: GitProviderIntegration | undefined) => {
258+
const url = current ? current.addWorkspaceUrl || getAddWorkspaceUrl(provider) : getGitConnectUrl(provider, account);
259+
if (url === openedUrl) return;
260+
openedUrl = url;
261+
262+
const what = current ? `Give Apify access to a ${provider} account` : `Connect your ${provider} account to Apify`;
263+
info({ message: `${what}: ${url}` });
264+
// Printed above as well — a headless session, or a machine with no usable default browser, still needs it.
265+
await open(url).catch(() => undefined);
266+
info({
267+
message: account?.username
268+
? `Waiting for authorization to complete in your browser. It connects ${provider} to the Apify account ${account.username}.`
269+
: 'Waiting for authorization to complete in your browser...',
270+
});
271+
};
228272

229-
info({ message: `${what}: ${url}` });
230-
// Printed above as well — a headless session, or a machine with no usable default browser, still needs it.
231-
await open(url).catch(() => undefined);
232-
info({ message: 'Waiting for authorization to complete in your browser...' });
273+
await offer(integration);
233274

234275
const deadline = Date.now() + POLL_TIMEOUT_MS;
235276
let interval = POLL_INTERVAL_START_MS;
@@ -238,6 +279,7 @@ const ensureUsableIntegration = async (
238279
interval = Math.min(Math.round(interval * 1.5), POLL_INTERVAL_MAX_MS);
239280
integration = await load();
240281
if (integration?.workspaces.length) return integration;
282+
await offer(integration);
241283
}
242284

243285
return integration ?? null;
@@ -426,6 +468,8 @@ export interface RunGitSourceFlowOptions extends ApiCallOptions {
426468
isPrivate: boolean;
427469
templateArchiveUrl: string;
428470
isInteractive: boolean;
471+
/** The account the run authorizes as, so an organization login connects the organization. */
472+
account?: GitAccount;
429473
/** Local-only setup for the clone. Runs between the clone and the commit, so its changes land in git. */
430474
customize: (dir: string) => Promise<void>;
431475
}
@@ -444,6 +488,7 @@ export const runGitSourceFlow = async ({
444488
isPrivate,
445489
templateArchiveUrl,
446490
isInteractive,
491+
account,
447492
customize,
448493
}: RunGitSourceFlowOptions): Promise<GitSourceResult> => {
449494
let scaffolded = false;
@@ -465,16 +510,17 @@ export const runGitSourceFlow = async ({
465510

466511
let integration: GitProviderIntegration | null;
467512
try {
468-
integration = await ensureUsableIntegration(provider, { client, isInteractive });
513+
integration = await ensureUsableIntegration(provider, { client, isInteractive, account });
469514
} catch (err) {
470515
return stopped('lookupFailed', err);
471516
}
472517

518+
const who = account?.username ? `The Apify account ${account.username}` : 'Apify';
473519
if (!integration) {
474-
return stopped('notAuthorized', new Error(`Apify is not authorized to access your ${provider} account.`));
520+
return stopped('notAuthorized', new Error(`${who} is not authorized to access ${provider}.`));
475521
}
476522
if (!integration.workspaces.length) {
477-
return stopped('noWorkspace', new Error(`Apify has no ${provider} account to create the repository in.`));
523+
return stopped('noWorkspace', new Error(`${who} has no ${provider} account to create the repository in.`));
478524
}
479525

480526
const workspaces = integration.workspaces.map(({ id }) => id);
@@ -573,8 +619,12 @@ export const runGitSourceFlow = async ({
573619
* The URL that unblocks a stop, when one exists — `gitConnectUrl` in the `--json` payload. Authorization
574620
* and installation are separate grants, so the two stops resolve to different places.
575621
*/
576-
export const getGitStopUrl = (provider: GitProvider, stopReason: GitSourceStopReason | null): string | null => {
577-
if (stopReason === 'notAuthorized') return getGitConnectUrl(provider);
622+
export const getGitStopUrl = (
623+
provider: GitProvider,
624+
stopReason: GitSourceStopReason | null,
625+
account?: GitAccount,
626+
): string | null => {
627+
if (stopReason === 'notAuthorized') return getGitConnectUrl(provider, account);
578628
if (stopReason === 'noWorkspace') return getAddWorkspaceUrl(provider);
579629
return null;
580630
};
@@ -592,6 +642,7 @@ export interface GitSourceNextStepsOptions {
592642
scaffolded: boolean;
593643
/** Set once the Actor exists, so a later stop can link to it. */
594644
actorId?: string | null;
645+
account?: GitAccount;
595646
}
596647

597648
/** What to tell the user, or hand an agent in `--json`, once the Git wiring has stopped. */
@@ -604,6 +655,7 @@ export const buildGitSourceNextSteps = ({
604655
repoName,
605656
scaffolded,
606657
actorId,
658+
account,
607659
}: GitSourceNextStepsOptions): string[] => {
608660
const enter = `cd "${actorName}"`;
609661

@@ -613,7 +665,10 @@ export const buildGitSourceNextSteps = ({
613665
case 'lookupFailed':
614666
return ['Re-run with APIFY_CLI_DEBUG=1 to see the failing request'];
615667
case 'notAuthorized':
616-
return [`Connect your ${provider} account to Apify: ${getGitConnectUrl(provider)}`, 'then re-run apify create'];
668+
return [
669+
`Connect your ${provider} account to Apify: ${getGitConnectUrl(provider, account)}`,
670+
'then re-run apify create',
671+
];
617672
case 'noWorkspace':
618673
return [`Give Apify access to an account: ${getAddWorkspaceUrl(provider)}`, 'then re-run apify create'];
619674
case 'unknownWorkspace':

‎test/local/lib/gitSource.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,31 @@ describe('getGitConnectUrl', () => {
7070
);
7171
});
7272

73+
// The callback page verifies the code as the account its route prefix selects, so an organization
74+
// login without this connects the personal account behind it and the CLI waits for nothing.
75+
it('sends an organization login to its own Console route', () => {
76+
const url = new URL(
77+
getGitConnectUrl('github', {
78+
id: 'qTyaZThN7mnbef6iQ',
79+
username: 'balrog',
80+
organizationOwnerUserId: 'eCJxAGafqfxEVvmjx',
81+
}),
82+
);
83+
const redirectUri = new URL(url.searchParams.get('redirect_uri')!);
84+
85+
// Double-encoded, as Console builds it: the page decodes the value once before navigating there.
86+
expect(redirectUri.searchParams.get('routePrefix')).toBe('%2Forganization%2FqTyaZThN7mnbef6iQ');
87+
expect(decodeURIComponent(redirectUri.searchParams.get('routePrefix')!)).toBe('/organization/qTyaZThN7mnbef6iQ');
88+
});
89+
90+
it('leaves a personal login on the root Console route', () => {
91+
const url = new URL(getGitConnectUrl('github', { id: 'eCJxAGafqfxEVvmjx', username: 'l2ysho' }));
92+
93+
expect(url.searchParams.get('redirect_uri')).toBe(
94+
'https://console.apify.com/actors/new/git/connected?service=github',
95+
);
96+
});
97+
7398
it('honours the environment overrides', () => {
7499
process.env.APIFY_GITHUB_APP_CLIENT_ID = 'Iv1.staging';
75100
process.env.APIFY_CONSOLE_URL = 'https://console.staging.example.com';
@@ -157,6 +182,17 @@ describe('buildGitSourceNextSteps', () => {
157182
expect(noWorkspace).toContainEqual(expect.stringContaining('installations/new'));
158183
});
159184

185+
// The recovery URL has to carry the prefix too, or the re-run repeats the same dead end.
186+
it('keeps an organization login on its own route in the recovery steps', () => {
187+
const steps = buildGitSourceNextSteps({
188+
...base,
189+
stopReason: 'notAuthorized',
190+
account: { id: 'qTyaZThN7mnbef6iQ', username: 'balrog', organizationOwnerUserId: 'eCJxAGafqfxEVvmjx' },
191+
});
192+
193+
expect(steps.join('\n')).toContain('routePrefix');
194+
});
195+
160196
// A clone that landed already has its remote, so re-adding one would exit with "remote origin
161197
// already exists" — the only thing left undone is the local configuration.
162198
it('does not rebuild the clone when only the local configuration failed', () => {

0 commit comments

Comments
 (0)