Skip to content
Merged
Show file tree
Hide file tree
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
12 changes: 12 additions & 0 deletions src/lib/command-framework/apify-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { trackEvent } from '../hooks/telemetry/trackEvent.js';
import { checkAndUpdateLastCommand } from '../hooks/telemetry/useTelemetryState.js';
import { useCLIMetadata } from '../hooks/useCLIMetadata.js';
import { ProjectLanguage, useCwdProject } from '../hooks/useCwdProject.js';
import { useRentalSunsetNotice } from '../hooks/useRentalSunsetNotice.js';
import { error } from '../outputs.js';
import type { ArgTag, TaggedArgBuilder } from './args.js';
import { CommandError, CommandErrorCode } from './CommandError.js';
Expand Down Expand Up @@ -263,6 +264,8 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B

protected skipTelemetry = false;

protected skipNotices = false;

public constructor(entrypoint: string, commandString: string, aliasUsed: string, subcommandAliasUsed?: string) {
this.entrypoint = entrypoint;
this.commandString = commandString;
Expand Down Expand Up @@ -420,6 +423,12 @@ export abstract class ApifyCommand<T extends typeof BuiltApifyCommand = typeof B

await trackEvent('cli_command', this.telemetryData);
}

// A notice stacked on top of an error message is noise the user did not ask for, and it
// would also make an interrupted command wait on the Store lookup before exiting.
if (!this.skipNotices && !process.exitCode) {
await useRentalSunsetNotice();
}
}
}

Expand Down Expand Up @@ -962,6 +971,9 @@ export async function internalRunCommand<Cmd extends typeof BuiltApifyCommand>(
// eslint-disable-next-line dot-notation
instance['skipTelemetry'] = true;

// eslint-disable-next-line dot-notation
instance['skipNotices'] = true;

// eslint-disable-next-line dot-notation
await instance['_run'](rawObject);

Expand Down
10 changes: 10 additions & 0 deletions src/lib/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ export const EMPTY_LOCAL_CONFIG = {

export const CHECK_VERSION_EVERY_MILLIS = 24 * 60 * 60 * 1000; // Once a day

export const CHECK_RENTAL_ACTORS_EVERY_MILLIS = 24 * 60 * 60 * 1000; // Once a day

export const RENTAL_SUNSET_NOTICE_EVERY_MILLIS = 24 * 60 * 60 * 1000; // Once a day

/**
* Rental Actors are fully retired on this date, so there is nothing left to warn about afterwards.
* Old CLI versions keep running for a long time, so the notice expires on its own.
*/
export const RENTAL_SUNSET_NOTICE_UNTIL = Date.UTC(2026, 9, 1); // 2026-10-01

// Signals representing user-initiated interruption that long-running commands
// should react to (aborting platform jobs, forwarding to local subprocesses).
export const INTERRUPT_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
Expand Down
6 changes: 6 additions & 0 deletions src/lib/hooks/useLocalState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export interface LocalStateV1 {
lastChecked: number;
lastVersion?: string;
};
rentalSunset?: {
lastChecked: number;
username: string;
rentalActorCount: number;
lastNotifiedAt?: number;
};
}

function migrateStateV0ToV1(state: LocalState) {
Expand Down
253 changes: 253 additions & 0 deletions src/lib/hooks/useRentalSunsetNotice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import { readFile } from 'node:fs/promises';
import process from 'node:process';

import axios from 'axios';
import chalk from 'chalk';
import { isCI } from 'ci-info';

import {
APIFY_CLIENT_DEFAULT_HEADERS,
AUTH_FILE_PATH,
CHECK_RENTAL_ACTORS_EVERY_MILLIS,
RENTAL_SUNSET_NOTICE_EVERY_MILLIS,
RENTAL_SUNSET_NOTICE_UNTIL,
} from '../consts.js';
import { simpleLog, warning } from '../outputs.js';
import type { AuthJSON } from '../types.js';
import { cliDebugPrint } from '../utils/cliDebugPrint.js';
import { useCLIMetadata } from './useCLIMetadata.js';
import { type LatestState, updateLocalState, useLocalState } from './useLocalState.js';

const RENTAL_PRICING_MODEL = 'FLAT_PRICE_PER_MONTH';

const DEFAULT_API_BASE_URL = 'https://api.apify.com';

/** The notice runs after the command the user actually asked for, so the lookup gets one short attempt. */
const STORE_LOOKUP_TIMEOUT_MILLIS = 3_000;

const APIFY_DISCORD_URL = 'https://apify.com/discord';

const PAY_PER_EVENT_MIGRATION_URL = 'https://blog.apify.com/migrating-to-pay-per-event-pricing/';

export interface RentalSunsetGateInput {
now: number;
isCi: boolean;
skipEnvValue?: string;
}

/**
* Decides whether the rental sunset notice should be suppressed. Kept pure so the gating rules can
* be tested without touching the network or the local state file.
*/
export function shouldSkipRentalSunsetNotice({ now, isCi, skipEnvValue }: RentalSunsetGateInput) {
if (skipEnvValue && !['0', 'false'].includes(skipEnvValue)) {
return true;
}

// The notice is aimed at a human reading their terminal, printing it into CI logs is just noise.
if (isCi) {
return true;
}

if (now >= RENTAL_SUNSET_NOTICE_UNTIL) {
return true;
}

return false;
}

/**
* The daily throttle is per account - logging in as somebody else must not inherit the previous
* user's "already warned today" timestamp.
*/
export function wasNotifiedRecently(cached: LatestState['rentalSunset'], username: string, now: number) {
if (!cached?.lastNotifiedAt || cached.username !== username) {
return false;
}

return now - cached.lastNotifiedAt < RENTAL_SUNSET_NOTICE_EVERY_MILLIS;
}

export function renderRentalSunsetNotice(rentalActorCount: number) {
const actorWord = rentalActorCount === 1 ? 'rental Actor' : 'rental Actors';

return [
chalk.bold('Rental model sunset'),
'',
`You have ${rentalActorCount} ${actorWord} published in Apify Store. Apify is sunsetting the rental pricing model.`,
'',
` ${chalk.bold('April 1, 2026')} Publishing new rental Actors and pricing changes on existing ones were disabled.`,
` ${chalk.bold('October 1, 2026')} Rental Actors are fully retired. All remaining Actors move to pay-per-usage pricing.`,
'',
`Switch your ${actorWord} to pay-per-event before October 1 to keep control over what you charge.`,
`This guide walks through picking events, setting prices, and shipping the change:`,
` ${chalk.cyan(PAY_PER_EVENT_MIGRATION_URL)}`,
'',
`Questions? Ask in the ${chalk.cyan('#project-rentals')} channel on Apify Discord:`,
` ${chalk.cyan(APIFY_DISCORD_URL)}`,
'',
chalk.dim('To silence this notice, set APIFY_CLI_SKIP_RENTAL_SUNSET_NOTICE=1.'),
'',
].join('\n');
}

/**
* Reads the logged in username straight from auth.json instead of going through `getLocalUserInfo`,
* which resolves the token from the OS keyring and would trigger a keychain prompt on commands that
* do not need authentication at all.
*/
async function getLocalUsername() {
try {
const raw = await readFile(AUTH_FILE_PATH(), 'utf-8');

return (JSON.parse(raw) as AuthJSON).username;
} catch {
return undefined;
}
}

/**
* Returns `null` for every kind of failed lookup - a refused or timed out request, a non-2xx status,
* or a 200 that is not a Store response. Callers need "we could not find out" to be a value rather
* than an exception, so that a stale count can still be used and the failure can still be cached.
*/
async function fetchRentalActorCount(username: string) {
const metadata = useCLIMetadata();

const url = new URL('/v2/store', process.env.APIFY_CLIENT_BASE_URL || DEFAULT_API_BASE_URL);

try {
// axios rather than `fetch`, so the lookup honors HTTP_PROXY/HTTPS_PROXY/NO_PROXY like every
// other request the CLI makes - Node's global fetch ignores them.
const { data } = await axios.get<{ data?: { total?: number } }>(url.href, {
params: {
username,
pricingModel: RENTAL_PRICING_MODEL,
// We only need the `total`, not the Actors themselves.
limit: 1,
},
timeout: STORE_LOOKUP_TIMEOUT_MILLIS,
headers: {
// Same origin headers every other CLI request to the Apify API carries, so this lookup is
// attributed like the rest of the CLI rather than as anonymous traffic.
...APIFY_CLIENT_DEFAULT_HEADERS,
'User-Agent': `Apify CLI/${metadata.version} (https://github.com/apify/apify-cli)`,
},
});

// A 200 without a numeric `total` means something other than the Store answered (a captive
// portal, a proxy interstitial, a schema change). Treating it as zero would cache the notice away.
if (typeof data?.data?.total !== 'number') {
cliDebugPrint('useRentalSunsetNotice', 'Store response has no data.total', { body: data });

return null;
}

return data.data.total;
} catch (err) {
// Covers a refused or timed out request and, since axios rejects non-2xx by default, HTTP errors.
cliDebugPrint('useRentalSunsetNotice', 'Failed to look up rental Actors', err);

return null;
}
}

interface ResolvedRentalActorCount {
rentalActorCount: number;
/** When the Store was last actually queried. Carried over unchanged on a cache hit. */
lastChecked: number;
/** True when no request was made, so there is nothing new to persist. */
fromCache: boolean;
}

async function resolveRentalActorCount(
cached: LatestState['rentalSunset'],
username: string,
now: number,
): Promise<ResolvedRentalActorCount> {
const cachedForUser = cached?.username === username ? cached : undefined;

if (cachedForUser && now - cachedForUser.lastChecked < CHECK_RENTAL_ACTORS_EVERY_MILLIS) {
return {
rentalActorCount: cachedForUser.rentalActorCount,
lastChecked: cachedForUser.lastChecked,
fromCache: true,
};
}

const fetched = await fetchRentalActorCount(username);

// A failed lookup still counts as checked. Without that, every command would retry - and a
// connection that hangs instead of refusing costs the full request timeout each time.
return {
rentalActorCount: fetched ?? cachedForUser?.rentalActorCount ?? 0,
lastChecked: now,
fromCache: false,
};
}

/**
* Warns users who publish rental Actors in Apify Store that the rental pricing model is going away.
* Runs after every command, but only prints once a day and only checks the API once a day.
*
* Never throws - a broken notice must not break the command the user actually asked for.
*/
export async function useRentalSunsetNotice() {
try {
const now = Date.now();
const state = useLocalState();

if (
shouldSkipRentalSunsetNotice({
now,
isCi: isCI,
skipEnvValue: process.env.APIFY_CLI_SKIP_RENTAL_SUNSET_NOTICE,
})
) {
return;
}

const username = await getLocalUsername();

if (!username) {
cliDebugPrint('useRentalSunsetNotice', 'Not logged in, skipping the check');

return;
}

if (wasNotifiedRecently(state.rentalSunset, username, now)) {
return;
}

const { rentalActorCount, lastChecked, fromCache } = await resolveRentalActorCount(
state.rentalSunset,
username,
now,
);

const shouldNotify = rentalActorCount > 0;

// Nothing was fetched and nothing will be printed, so the state file is already up to date.
if (fromCache && !shouldNotify) {
return;
}

updateLocalState(state, (stateToUpdate) => {
stateToUpdate.rentalSunset = {
lastChecked,
username,
rentalActorCount,
...(shouldNotify ? { lastNotifiedAt: now } : {}),
};
});

if (!shouldNotify) {
return;
}

simpleLog({ message: '' });
warning({ message: renderRentalSunsetNotice(rentalActorCount) });
} catch (err) {
cliDebugPrint('useRentalSunsetNotice', 'Failed to run the rental sunset check', err);
}
}
1 change: 1 addition & 0 deletions test/e2e/__helpers__/run-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export async function runCli(
env: {
APIFY_CLI_DISABLE_TELEMETRY: '1',
APIFY_CLI_SKIP_UPDATE_CHECK: '1',
APIFY_CLI_SKIP_RENTAL_SUNSET_NOTICE: '1',
// Pin the file backend so e2e subprocesses don't share the host's OS keyring across tests.
APIFY_DISABLE_KEYRING: '1',
...options.env,
Expand Down
Loading