From 1f9f395a8a402cb3cb3e615965663511667e4711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Thu, 24 Sep 2026 19:02:09 +0200 Subject: [PATCH 1/2] feat: add --profile, auth switch, auth list and per-account logout Stage 3 of multi-account support. Commands that call the API take --profile to pick a stored account. auth switch sets the active one, auth list shows them all offline, and logout takes --profile or --all. APIFY_TOKEN still wins, so --profile and auth switch fail while it is set. Co-Authored-By: Claude Opus 5.5 --- docs/reference.md | 382 +++++++++++++----- scripts/generate-cli-docs.ts | 2 + src/commands/actors/call.ts | 2 + src/commands/actors/info.ts | 2 + src/commands/actors/ls.ts | 2 + src/commands/actors/pull.ts | 2 + src/commands/actors/push.ts | 2 + src/commands/actors/rm.ts | 2 + src/commands/actors/start.ts | 2 + src/commands/api.ts | 2 + src/commands/auth/_index.ts | 12 +- src/commands/auth/list.ts | 100 +++++ src/commands/auth/logout.ts | 114 +++++- src/commands/auth/switch.ts | 103 +++++ src/commands/auth/token.ts | 2 + src/commands/builds/add-tag.ts | 2 + src/commands/builds/create.ts | 2 + src/commands/builds/info.ts | 2 + src/commands/builds/log.ts | 2 + src/commands/builds/ls.ts | 2 + src/commands/builds/remove-tag.ts | 2 + src/commands/builds/rm.ts | 2 + src/commands/builds/wait.ts | 2 + src/commands/datasets/create.ts | 2 + src/commands/datasets/get-items.ts | 2 + src/commands/datasets/info.ts | 2 + src/commands/datasets/ls.ts | 2 + src/commands/datasets/push-items.ts | 2 + src/commands/datasets/rename.ts | 2 + src/commands/datasets/rm.ts | 2 + src/commands/info.ts | 5 +- src/commands/key-value-stores/create.ts | 2 + src/commands/key-value-stores/delete-value.ts | 2 + src/commands/key-value-stores/get-value.ts | 2 + src/commands/key-value-stores/info.ts | 2 + src/commands/key-value-stores/keys.ts | 2 + src/commands/key-value-stores/ls.ts | 2 + src/commands/key-value-stores/rename.ts | 2 + src/commands/key-value-stores/rm.ts | 2 + src/commands/key-value-stores/set-value.ts | 2 + src/commands/run.ts | 2 + src/commands/runs/abort.ts | 2 + src/commands/runs/info.ts | 2 + src/commands/runs/log.ts | 2 + src/commands/runs/ls.ts | 2 + src/commands/runs/resurrect.ts | 2 + src/commands/runs/rm.ts | 2 + src/commands/runs/wait.ts | 2 + src/commands/task/publish.ts | 2 + src/commands/task/run.ts | 2 + src/commands/task/unpublish.ts | 2 + src/lib/auth-file.ts | 55 ++- src/lib/auth.ts | 81 +++- src/lib/command-framework/apify-command.ts | 19 + src/lib/command-framework/help/CommandHelp.ts | 19 + src/lib/credentials.ts | 5 +- .../user-confirmations/useSelectFromList.ts | 4 +- src/lib/utils.ts | 10 +- test/local/commands/auth-profiles.test.ts | 310 ++++++++++++++ test/local/lib/auth-file.test.ts | 6 +- test/local/lib/auth.test.ts | 14 +- 61 files changed, 1181 insertions(+), 148 deletions(-) create mode 100644 src/commands/auth/list.ts create mode 100644 src/commands/auth/switch.ts create mode 100644 test/local/commands/auth-profiles.test.ts diff --git a/docs/reference.md b/docs/reference.md index 6e123da75..ec889ba72 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -61,7 +61,7 @@ DESCRIPTION USAGE $ apify api [methodOrEndpoint] [endpoint] [-d ] [--describe | -l | -s ] [-H ] - [-X GET|POST|PUT|PATCH|DELETE] [-p ] + [-X GET|POST|PUT|PATCH|DELETE] [-p ] [--profile ] ARGUMENTS methodOrEndpoint The API endpoint path (e.g. "acts", @@ -90,6 +90,8 @@ FLAGS -p, --params= Query parameters as a JSON object, e.g. '{"limit": 1, "desc": true}'. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". -s, --search= Filter results returned by --list-endpoints. The query is case-insensitive and split into tokens by spaces. For an endpoint to be returned, @@ -141,14 +143,19 @@ Use these commands to manage your Apify account authentication, access tokens, a ```sh DESCRIPTION - Log in, log out, and inspect your stored Apify API token. Also available as - `apify login` / `apify logout`. + Log in, log out, switch between stored accounts, and inspect your stored Apify + API token. Also available as `apify login` / `apify logout`. SUBCOMMANDS auth login Authenticates your Apify account and saves credentials to '~/.apify/auth.json'. auth logout Logs out of the active account by deleting its API token and account information from '~/.apify/auth.json'. + auth list Lists the stored Apify accounts and marks the active + one. Reads only the local login, so it works offline and does not + check that the tokens are still valid. + auth switch Sets the stored account that commands use. Without an + argument, prompts you to pick one of the stored accounts. auth token Prints the API token the CLI authenticates with, resolved from APIFY_TOKEN or the token from 'apify login'. ``` @@ -183,7 +190,44 @@ DESCRIPTION Run 'apify login' to authenticate again. USAGE - $ apify auth logout + $ apify auth logout [--all | --profile ] [-y] + +FLAGS + --all Log out of every stored account. + --profile= The stored account to log out of, by + name or user ID. See "apify auth list". + -y, --yes Automatic yes to prompts; assume + "yes" as answer to all prompts. +``` + +##### `apify auth list` + +```sh +DESCRIPTION + Lists the stored Apify accounts and marks the active one. Reads only the local + login, so it works offline and does not check that the tokens are still + valid. + +USAGE + $ apify auth list [--json] + +FLAGS + --json Format the command output as JSON. +``` + +##### `apify auth switch` + +```sh +DESCRIPTION + Sets the stored account that commands use. Without an argument, prompts you to + pick one of the stored accounts. + +USAGE + $ apify auth switch [profile] + +ARGUMENTS + profile The stored account to make active, by name or user ID. See + "apify auth list". ``` ##### `apify auth token` @@ -194,7 +238,11 @@ DESCRIPTION the token from 'apify login'. USAGE - $ apify auth token + $ apify auth token [--profile ] + +FLAGS + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify info` @@ -204,7 +252,11 @@ DESCRIPTION Prints details about your currently authenticated Apify account. USAGE - $ apify info + $ apify info [--profile ] + +FLAGS + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify secrets` @@ -407,7 +459,8 @@ DESCRIPTION USAGE $ apify run [--allow-missing-secrets] [--entrypoint ] - [-i | --input-file ] [-p | --resurrect] + [-i | --input-file ] [--profile ] + [-p | --resurrect] FLAGS --allow-missing-secrets Allow the command to @@ -426,6 +479,9 @@ FLAGS JSON input to be given to the Actor. The file must be a valid JSON file. You can also specify `-` to read from standard input. + --profile= The stored account to use + for this command, by name or user ID. See "apify auth + list". -p, --purge Whether to purge the default request queue, dataset and key-value store before the run starts. @@ -705,15 +761,17 @@ DESCRIPTION USAGE $ apify actors ls [--desc] [--json] [--limit ] [--my] - [--offset ] + [--offset ] [--profile ] FLAGS - --desc Sort Actors in descending order. - --json Format the command output as JSON. - --limit= Number of Actors that will be listed. - --my Whether to list Actors made by the logged - in user. - --offset= Number of Actors that will be skipped. + --desc Sort Actors in descending order. + --json Format the command output as JSON. + --limit= Number of Actors that will be listed. + --my Whether to list Actors made by the + logged in user. + --offset= Number of Actors that will be skipped. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify actors search` @@ -764,14 +822,16 @@ DESCRIPTION Permanently removes an Actor from your account. USAGE - $ apify actors rm [-y] + $ apify actors rm [--profile ] [-y] ARGUMENTS actorId The Actor ID to delete. FLAGS - -y, --yes Automatic yes to prompts; assume "yes" as answer to all - prompts. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + -y, --yes Automatic yes to prompts; assume + "yes" as answer to all prompts. ``` @@ -796,7 +856,8 @@ DESCRIPTION USAGE $ apify actors push [actorId] [--allow-missing-secrets] [--apply-env-vars-to-build] [-b ] [--dir ] - [-f] [--json] [--open] [-v ] [-w ] + [-f] [--json] [--open] [--profile ] [-v ] + [-w ] ARGUMENTS actorId Name or ID of the Actor to push (e.g. "apify/hello-world" or @@ -828,6 +889,9 @@ FLAGS output as JSON. --open Whether to open the browser automatically to the Actor details page. + --profile= The stored account to + use for this command, by name or user ID. See + "apify auth list". -v, --version= Actor version number to which the files should be pushed. By default, it is taken from the '.actor/actor.json' file. @@ -849,7 +913,8 @@ DESCRIPTION Actor files based on the source type. USAGE - $ apify actors pull [actorId] [--dir ] [-v ] + $ apify actors pull [actorId] [--dir ] + [--profile ] [-v ] ARGUMENTS actorId Name or ID of the Actor to run (e.g. "apify/hello-world" or @@ -860,6 +925,8 @@ ARGUMENTS FLAGS --dir= Directory where the Actor should be pulled to. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". -v, --version= Actor version number which will be pulled, e.g. 1.2. Default: the highest version. ``` @@ -875,8 +942,8 @@ DESCRIPTION USAGE $ apify actors call [actorId] [-b ] - [-i | -f ] [--json] [-m ] [-o] [-s] - [-t ] + [-i | -f ] [--json] [-m ] [-o] + [--profile ] [-s] [-t ] ARGUMENTS actorId Name or ID of the Actor to run (e.g. "my-actor", @@ -899,6 +966,8 @@ FLAGS the Actor run, in megabytes. -o, --output-dataset Prints out the entire default dataset on successful run of the Actor. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". -s, --silent Prevents printing the logs of the Actor run to the console. -t, --timeout= Timeout for the Actor run in @@ -915,7 +984,7 @@ DESCRIPTION USAGE $ apify actors start [actorId] [-b ] [-i | --input-file ] [--json] [-m ] - [-t ] + [--profile ] [-t ] ARGUMENTS actorId Name or ID of the Actor to run (e.g. "my-actor", @@ -935,6 +1004,8 @@ FLAGS --json Format the command output as JSON. -m, --memory= Amount of memory allocated for the Actor run, in megabytes. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". -t, --timeout= Timeout for the Actor run in seconds. Zero value means there is no timeout. ``` @@ -947,14 +1018,17 @@ DESCRIPTION USAGE $ apify actors info [--input | --readme] [--json] + [--profile ] ARGUMENTS actorId The ID of the Actor to return information about. FLAGS - --input Return the Actor input schema. - --json Format the command output as JSON. - --readme Return the Actor README. + --input Return the Actor input schema. + --json Format the command output as JSON. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + --readme Return the Actor README. ``` @@ -992,10 +1066,13 @@ DESCRIPTION USAGE $ apify builds add-tag -b -t + [--profile ] FLAGS - -b, --build= The build ID to tag. - -t, --tag= The tag to add to the build. + -b, --build= The build ID to tag. + -t, --tag= The tag to add to the build. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify builds create` / `apify actors build` @@ -1006,7 +1083,8 @@ DESCRIPTION USAGE $ apify builds create [actorId] [--json] [--log] - [--tag ] [--version ] [--wait] + [--profile ] [--tag ] [--version ] + [--wait] ARGUMENTS actorId Optional Actor ID or Name to trigger a build for. By default, @@ -1016,6 +1094,8 @@ FLAGS --json Format the command output as JSON. --log Whether to print out the build log after the build is triggered. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". --tag= Build tag to be applied to the successful Actor build. By default, this is "latest". --version= Optional Actor Version to build. By @@ -1032,13 +1112,15 @@ DESCRIPTION Prints information about a specific build. USAGE - $ apify builds info [--json] + $ apify builds info [--json] [--profile ] ARGUMENTS buildId The build ID to get information about. FLAGS - --json Format the command output as JSON. + --json Format the command output as JSON. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify builds log` @@ -1048,10 +1130,14 @@ DESCRIPTION Prints the log of a specific build. USAGE - $ apify builds log + $ apify builds log [--profile ] ARGUMENTS buildId The build ID to get the log from. + +FLAGS + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify builds ls` @@ -1062,18 +1148,20 @@ DESCRIPTION USAGE $ apify builds ls [actorId] [-c] [--desc] [--json] - [--limit ] [--offset ] + [--limit ] [--offset ] [--profile ] ARGUMENTS actorId Optional Actor ID or Name to list runs for. By default, it will use the Actor from the current directory. FLAGS - -c, --compact Display a compact table. - --desc Sort builds in descending order. - --json Format the command output as JSON. - --limit= Number of builds that will be listed. - --offset= Number of builds that will be skipped. + -c, --compact Display a compact table. + --desc Sort builds in descending order. + --json Format the command output as JSON. + --limit= Number of builds that will be listed. + --offset= Number of builds that will be skipped. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify builds remove-tag` @@ -1083,13 +1171,16 @@ DESCRIPTION Removes a tag from a specific Actor build. USAGE - $ apify builds remove-tag -b -t [-y] + $ apify builds remove-tag -b -t + [--profile ] [-y] FLAGS - -b, --build= The build ID to remove the tag from. - -t, --tag= The tag to remove from the build. - -y, --yes Automatic yes to prompts; assume "yes" - as answer to all prompts. + -b, --build= The build ID to remove the tag from. + -t, --tag= The tag to remove from the build. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + -y, --yes Automatic yes to prompts; assume + "yes" as answer to all prompts. ``` ##### `apify builds rm` @@ -1099,14 +1190,16 @@ DESCRIPTION Permanently removes an Actor build from the Apify platform. USAGE - $ apify builds rm [-y] + $ apify builds rm [--profile ] [-y] ARGUMENTS buildId The build ID to delete. FLAGS - -y, --yes Automatic yes to prompts; assume "yes" as answer to all - prompts. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + -y, --yes Automatic yes to prompts; assume + "yes" as answer to all prompts. ``` ##### `apify builds wait` @@ -1120,7 +1213,7 @@ DESCRIPTION USAGE $ apify builds wait [--json] - [--poll-interval ] [-t ] + [--poll-interval ] [--profile ] [-t ] ARGUMENTS buildId The build ID to wait for. @@ -1130,6 +1223,9 @@ FLAGS JSON. --poll-interval= In seconds, how often to poll the platform. Defaults to 2. + --profile= The stored account to use + for this command, by name or user ID. See "apify auth + list". -t, --timeout= In seconds, how long to wait before giving up. If skipped, it waits indefinitely. @@ -1169,15 +1265,17 @@ DESCRIPTION Aborts an Actor run. USAGE - $ apify runs abort [-f] [--json] + $ apify runs abort [-f] [--json] [--profile ] ARGUMENTS runId The run ID to abort. FLAGS - -f, --force Whether to force the run to abort immediately, instead - of gracefully. - --json Format the command output as JSON. + -f, --force Whether to force the run to abort + immediately, instead of gracefully. + --json Format the command output as JSON. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify runs info` @@ -1187,15 +1285,17 @@ DESCRIPTION Prints information about an Actor run. USAGE - $ apify runs info [--json] [-v] + $ apify runs info [--json] [--profile ] [-v] ARGUMENTS runId The run ID to print information about. FLAGS - --json Format the command output as JSON. - -v, --verbose Prints more in-depth information about the Actor - run. + --json Format the command output as JSON. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + -v, --verbose Prints more in-depth information + about the Actor run. ``` ##### `apify runs log` @@ -1205,10 +1305,14 @@ DESCRIPTION Prints the log of a specific run. USAGE - $ apify runs log + $ apify runs log [--profile ] ARGUMENTS runId The run ID to get the log from. + +FLAGS + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify runs ls` @@ -1219,18 +1323,20 @@ DESCRIPTION USAGE $ apify runs ls [actorId] [-c] [--desc] [--json] - [--limit ] [--offset ] + [--limit ] [--offset ] [--profile ] ARGUMENTS actorId Optional Actor ID or Name to list runs for. By default, it will use the Actor from the current directory. FLAGS - -c, --compact Display a compact table. - --desc Sort runs in descending order. - --json Format the command output as JSON. - --limit= Number of runs that will be listed. - --offset= Number of runs that will be skipped. + -c, --compact Display a compact table. + --desc Sort runs in descending order. + --json Format the command output as JSON. + --limit= Number of runs that will be listed. + --offset= Number of runs that will be skipped. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify runs resurrect` @@ -1241,12 +1347,15 @@ DESCRIPTION USAGE $ apify runs resurrect [--json] + [--profile ] ARGUMENTS runId The run ID to resurrect. FLAGS - --json Format the command output as JSON. + --json Format the command output as JSON. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify runs rm` @@ -1256,14 +1365,16 @@ DESCRIPTION Deletes an Actor Run. USAGE - $ apify runs rm [-y] + $ apify runs rm [--profile ] [-y] ARGUMENTS runId The run ID to delete. FLAGS - -y, --yes Automatic yes to prompts; assume "yes" as answer to all - prompts. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + -y, --yes Automatic yes to prompts; assume + "yes" as answer to all prompts. ``` ##### `apify runs wait` @@ -1277,7 +1388,7 @@ DESCRIPTION USAGE $ apify runs wait [--json] [--poll-interval ] - [-t ] + [--profile ] [-t ] ARGUMENTS runId The run ID to wait for. @@ -1287,6 +1398,9 @@ FLAGS JSON. --poll-interval= How often to poll the platform, in seconds. Defaults to 2. + --profile= The stored account to use + for this command, by name or user ID. See "apify auth + list". -t, --timeout= Maximum seconds to wait before giving up. Without this flag the command waits indefinitely. @@ -1334,12 +1448,15 @@ DESCRIPTION USAGE $ apify datasets create [datasetName] [--json] + [--profile ] ARGUMENTS datasetName Optional name for the Dataset. FLAGS - --json Format the command output as JSON. + --json Format the command output as JSON. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify datasets get-items` @@ -1352,6 +1469,7 @@ USAGE $ apify datasets get-items [--format json|jsonl|csv|html|rss|xml|xlsx] [--limit ] [--offset ] + [--profile ] ARGUMENTS datasetId The ID of the Dataset to export the items for. @@ -1364,6 +1482,8 @@ FLAGS dataset. By default, it will return all available items. --offset= The offset in the dataset where to start getting items. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify datasets info` @@ -1374,12 +1494,15 @@ DESCRIPTION USAGE $ apify datasets info [--json] + [--profile ] ARGUMENTS storeId The dataset store ID to print information about. FLAGS - --json Format the command output as JSON. + --json Format the command output as JSON. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify datasets ls` @@ -1390,14 +1513,17 @@ DESCRIPTION USAGE $ apify datasets ls [--desc] [--json] [--limit ] - [--offset ] [--unnamed] + [--offset ] [--profile ] [--unnamed] FLAGS - --desc Sorts datasets in descending order. - --json Format the command output as JSON. - --limit= Number of datasets that will be listed. - --offset= Number of datasets that will be skipped. - --unnamed Lists datasets that don't have a name set. + --desc Sorts datasets in descending order. + --json Format the command output as JSON. + --limit= Number of datasets that will be listed. + --offset= Number of datasets that will be skipped. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + --unnamed Lists datasets that don't have a name + set. ``` ##### `apify datasets push-items` @@ -1409,10 +1535,15 @@ DESCRIPTION USAGE $ apify datasets push-items [item] + [--profile ] ARGUMENTS nameOrId The dataset ID or name to push the objects to. item The object or array of objects to be pushed. + +FLAGS + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify datasets rename` @@ -1422,14 +1553,17 @@ DESCRIPTION Change the dataset name or remove the name with --unname flag. USAGE - $ apify datasets rename [newName] [--unname] + $ apify datasets rename [newName] + [--profile ] [--unname] ARGUMENTS nameOrId The dataset ID or name to delete. newName The new name for the dataset. FLAGS - --unname Removes the unique name of the dataset. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + --unname Removes the unique name of the dataset. ``` ##### `apify datasets rm` @@ -1439,14 +1573,17 @@ DESCRIPTION Permanently removes a dataset. USAGE - $ apify datasets rm [-y] + $ apify datasets rm [--profile ] + [-y] ARGUMENTS datasetNameOrId The dataset ID or name to delete. FLAGS - -y, --yes Automatic yes to prompts; assume "yes" as answer to all - prompts. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + -y, --yes Automatic yes to prompts; assume + "yes" as answer to all prompts. ``` @@ -1498,13 +1635,16 @@ DESCRIPTION USAGE $ apify key-value-stores create [key-value store name] [--json] + [--profile ] ARGUMENTS key-value store name Optional name for the key-value store. FLAGS - --json Format the command output as JSON. + --json Format the command output as JSON. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify key-value-stores delete-value` @@ -1515,15 +1655,18 @@ DESCRIPTION USAGE $ apify key-value-stores delete-value - [-y] + [--profile ] + [-y] ARGUMENTS store id The key-value store ID to delete the value from. itemKey The key of the item in the key-value store. FLAGS - -y, --yes Automatic yes to prompts; assume "yes" as answer to all - prompts. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + -y, --yes Automatic yes to prompts; assume + "yes" as answer to all prompts. ``` ##### `apify key-value-stores get-value` @@ -1536,7 +1679,7 @@ DESCRIPTION USAGE $ apify key-value-stores get-value - [--only-content-type] + [--only-content-type] [--profile ] ARGUMENTS keyValueStoreId The key-value store ID to get the value from. @@ -1545,6 +1688,8 @@ ARGUMENTS FLAGS --only-content-type Only return the content type of the specified key. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify key-value-stores info` @@ -1555,12 +1700,15 @@ DESCRIPTION USAGE $ apify key-value-stores info [--json] + [--profile ] ARGUMENTS storeId The key-value store ID to print information about. FLAGS - --json Format the command output as JSON. + --json Format the command output as JSON. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify key-value-stores keys` @@ -1572,7 +1720,7 @@ DESCRIPTION USAGE $ apify key-value-stores keys [--exclusive-start-key ] [--json] - [--limit ] + [--limit ] [--profile ] ARGUMENTS storeId The key-value store ID to list keys for. @@ -1584,6 +1732,9 @@ FLAGS command output as JSON. --limit= The maximum number of keys to return. + --profile= The stored + account to use for this command, by name or + user ID. See "apify auth list". ``` ##### `apify key-value-stores ls` @@ -1594,18 +1745,21 @@ DESCRIPTION USAGE $ apify key-value-stores ls [--desc] [--json] - [--limit ] [--offset ] [--unnamed] + [--limit ] [--offset ] + [--profile ] [--unnamed] FLAGS - --desc Sorts key-value stores in descending - order. - --json Format the command output as JSON. - --limit= Number of key-value stores that will be - listed. - --offset= Number of key-value stores that will be - skipped. - --unnamed Lists key-value stores that don't have a - name set. + --desc Sorts key-value stores in descending + order. + --json Format the command output as JSON. + --limit= Number of key-value stores that will be + listed. + --offset= Number of key-value stores that will be + skipped. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + --unnamed Lists key-value stores that don't have a + name set. ``` ##### `apify key-value-stores rename` @@ -1616,7 +1770,8 @@ DESCRIPTION USAGE $ apify key-value-stores rename - [newName] [--unname] + [newName] + [--profile ] [--unname] ARGUMENTS keyValueStoreNameOrId The key-value store ID or name to @@ -1625,7 +1780,10 @@ ARGUMENTS store. FLAGS - --unname Removes the unique name of the key-value store. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + --unname Removes the unique name of the key-value + store. ``` ##### `apify key-value-stores rm` @@ -1636,15 +1794,17 @@ DESCRIPTION USAGE $ apify key-value-stores rm - [-y] + [--profile ] [-y] ARGUMENTS keyValueStoreNameOrId The key-value store ID or name to delete. FLAGS - -y, --yes Automatic yes to prompts; assume "yes" as answer to all - prompts. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". + -y, --yes Automatic yes to prompts; assume + "yes" as answer to all prompts. ``` ##### `apify key-value-stores set-value` @@ -1656,6 +1816,7 @@ DESCRIPTION USAGE $ apify key-value-stores set-value [value] [--content-type ] + [--profile ] ARGUMENTS storeId The key-value store ID to set the value in. @@ -1665,6 +1826,9 @@ ARGUMENTS FLAGS --content-type= The MIME content type of the value. By default, "application/json" is assumed. + --profile= The stored account to use for + this command, by name or user ID. See "apify auth + list". ``` @@ -1717,7 +1881,7 @@ DESCRIPTION USAGE $ apify task run [-b ] [--json] [-m ] - [-t ] + [--profile ] [-t ] ARGUMENTS taskId Name or ID of the Task to run (e.g. "my-task" or @@ -1729,6 +1893,8 @@ FLAGS --json Format the command output as JSON. -m, --memory= Amount of memory allocated for the Task run, in megabytes. + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". -t, --timeout= Timeout for the Task run in seconds. Zero value means there is no timeout. ``` @@ -1743,11 +1909,15 @@ DESCRIPTION write access to the task and to its Actor. USAGE - $ apify task publish + $ apify task publish [--profile ] ARGUMENTS taskId Name of the task to publish. For example, "my-task" or "username/my-task". + +FLAGS + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` ##### `apify task unpublish` @@ -1759,11 +1929,15 @@ DESCRIPTION again later. Requires write access to the task and to its Actor. USAGE - $ apify task unpublish + $ apify task unpublish [--profile ] ARGUMENTS taskId Name of the task to unpublish. For example, "my-task" or "username/my-task". + +FLAGS + --profile= The stored account to use for this + command, by name or user ID. See "apify auth list". ``` diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index d1e22bb0b..3e23cd4c0 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -11,6 +11,8 @@ const categories: Record = { { command: Commands.auth }, { command: Commands.authLogin, aliases: [Commands.login] }, { command: Commands.authLogout, aliases: [Commands.logout] }, + { command: Commands.authList }, + { command: Commands.authSwitch }, { command: Commands.authToken }, { command: Commands.info }, { command: Commands.secrets }, diff --git a/src/commands/actors/call.ts b/src/commands/actors/call.ts index 01363f7ba..1829c607c 100644 --- a/src/commands/actors/call.ts +++ b/src/commands/actors/call.ts @@ -25,6 +25,8 @@ import { getLocalConfig, getCurrentUserInfo, getLoggedClientOrThrow, TimestampFo export class ActorsCallCommand extends ApifyCommand { static override name = 'call' as const; + static override enableProfileFlag = true; + static override description = 'Executes Actor remotely using your authenticated account.\n' + 'Reads input from local key-value store by default.\n' + diff --git a/src/commands/actors/info.ts b/src/commands/actors/info.ts index 839ea250f..8729cfd30 100644 --- a/src/commands/actors/info.ts +++ b/src/commands/actors/info.ts @@ -45,6 +45,8 @@ const payPerEventTable = new ResponsiveTable({ export class ActorsInfoCommand extends ApifyCommand { static override name = 'info' as const; + static override enableProfileFlag = true; + static override description = 'Get information about an Actor.'; static override examples = [ diff --git a/src/commands/actors/ls.ts b/src/commands/actors/ls.ts index fee970d26..0b4edb224 100644 --- a/src/commands/actors/ls.ts +++ b/src/commands/actors/ls.ts @@ -97,6 +97,8 @@ interface HydratedListData { export class ActorsLsCommand extends ApifyCommand { static override name = 'ls' as const; + static override enableProfileFlag = true; + static override description = 'Prints a list of recently executed Actors or Actors you own.'; static override examples = [ diff --git a/src/commands/actors/pull.ts b/src/commands/actors/pull.ts index feb526e60..4ceae4ba2 100644 --- a/src/commands/actors/pull.ts +++ b/src/commands/actors/pull.ts @@ -23,6 +23,8 @@ const extractGitHubZip = async (url: string, directoryPath: string) => { export class ActorsPullCommand extends ApifyCommand { static override name = 'pull' as const; + static override enableProfileFlag = true; + static override description = 'Download Actor code to current directory. ' + 'Clones Git repositories or fetches Actor files based on the source type.'; diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 64cca1880..03c25e4ab 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -156,6 +156,8 @@ const confirmGitSourceSwitch = async ({ export class ActorsPushCommand extends ApifyCommand { static override name = 'push' as const; + static override enableProfileFlag = true; + static override description = `Deploys Actor to Apify platform using settings from '${LOCAL_CONFIG_PATH}'.\n` + `Files under '${MAX_MULTIFILE_BYTES / 1024 ** 2}' MB upload as "Multiple source files"; ` + diff --git a/src/commands/actors/rm.ts b/src/commands/actors/rm.ts index 88f75c135..81ccb372f 100644 --- a/src/commands/actors/rm.ts +++ b/src/commands/actors/rm.ts @@ -10,6 +10,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class ActorsRmCommand extends ApifyCommand { static override name = 'rm' as const; + static override enableProfileFlag = true; + static override description = 'Permanently removes an Actor from your account.'; static override interactive = true; diff --git a/src/commands/actors/start.ts b/src/commands/actors/start.ts index 3ff5cb45e..e04b91797 100644 --- a/src/commands/actors/start.ts +++ b/src/commands/actors/start.ts @@ -18,6 +18,8 @@ import { ActorsCallCommand } from './call.js'; export class ActorsStartCommand extends ApifyCommand { static override name = 'start' as const; + static override enableProfileFlag = true; + static override description = 'Starts Actor remotely and returns run details immediately.\n' + 'Uses authenticated account and local key-value store for input.'; diff --git a/src/commands/api.ts b/src/commands/api.ts index 25ed0ad7d..b35a2afe9 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -140,6 +140,8 @@ export class ApiCommand extends ApifyCommand { static override name = 'api' as const; + static override enableProfileFlag = true; + static override description = 'Makes an authenticated HTTP request to the Apify API and prints the response.\n' + 'The endpoint can be a relative path (e.g. "acts", "v2/acts", or "/v2/acts"); ' + diff --git a/src/commands/auth/_index.ts b/src/commands/auth/_index.ts index dcca51b67..cf11455bd 100644 --- a/src/commands/auth/_index.ts +++ b/src/commands/auth/_index.ts @@ -1,19 +1,27 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { AuthListCommand } from './list.js'; import { AuthLoginCommand } from './login.js'; import { AuthLogoutCommand } from './logout.js'; +import { AuthSwitchCommand } from './switch.js'; import { AuthTokenCommand } from './token.js'; export class AuthIndexCommand extends ApifyCommand { static override name = 'auth' as const; static override description = - 'Log in, log out, and inspect your stored Apify API token. Also available as `apify login` / `apify logout`.'; + 'Log in, log out, switch between stored accounts, and inspect your stored Apify API token. Also available as `apify login` / `apify logout`.'; static override group = 'Authentication'; static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-auth'; - static override subcommands = [AuthLoginCommand, AuthLogoutCommand, AuthTokenCommand]; + static override subcommands = [ + AuthLoginCommand, + AuthLogoutCommand, + AuthListCommand, + AuthSwitchCommand, + AuthTokenCommand, + ]; async run() { this.printHelp(); diff --git a/src/commands/auth/list.ts b/src/commands/auth/list.ts new file mode 100644 index 000000000..42ff4be0e --- /dev/null +++ b/src/commands/auth/list.ts @@ -0,0 +1,100 @@ +import chalk from 'chalk'; + +import { APIFY_ENV_VARS } from '@apify/consts'; + +import { + ensureAuthFileCurrent, + getActiveProfileId, + listProfiles, + profileLabel, + readAuthFile, +} from '../../lib/auth-file.js'; +import { invalidEnvTokenMessage, readEnvToken } from '../../lib/auth.js'; +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; +import { ensureMigrated, ensureSecretsKeyed } from '../../lib/credentials.js'; +import { simpleLog, warning } from '../../lib/outputs.js'; +import { printJsonToStdout, TimestampFormatter } from '../../lib/utils.js'; + +const table = new ResponsiveTable({ + allColumns: ['Name', 'User ID', 'Type', 'Last login', 'Storage'], + mandatoryColumns: ['Name', 'User ID'], +}); + +export class AuthListCommand extends ApifyCommand { + static override name = 'list' as const; + + static override description = + 'Lists the stored Apify accounts and marks the active one. Reads only the local login, so it works offline and does not check that the tokens are still valid.'; + + static override enableJsonFlag = true; + + static override group = 'Authentication'; + + static override examples = [ + { + description: 'List the stored accounts.', + command: 'apify auth list', + }, + { + description: 'List the stored accounts as JSON, for scripts.', + command: 'apify auth list --json', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-auth-list'; + + async run() { + await ensureMigrated(); + await ensureAuthFileCurrent(); + await ensureSecretsKeyed(); + + const file = readAuthFile(); + const activeId = getActiveProfileId(); + const envToken = readEnvToken(); + + const profiles = listProfiles().map((profile) => ({ + id: profile.id, + name: profileLabel(profile), + username: profile.username ?? null, + active: profile.id === activeId, + isOrganization: Boolean(profile.organizationOwnerUserId), + organizationOwnerUserId: profile.organizationOwnerUserId ?? null, + loggedInAt: profile.loggedInAt, + // A file without the marker predates the keyring, so its secrets are still in it. + secretsBackend: profile.secretsBackend ?? file.secretsBackend ?? 'file', + })); + + if (this.flags.json) { + printJsonToStdout({ envTokenInUse: envToken.kind !== 'unset', profiles }); + return; + } + + if (envToken.kind === 'token') { + warning({ + message: `${APIFY_ENV_VARS.TOKEN} is set, so commands use it instead of the active account.`, + }); + } else if (envToken.kind === 'invalid') { + warning({ message: invalidEnvTokenMessage(envToken.raw) }); + } + + if (!profiles.length) { + simpleLog({ message: 'No accounts are stored. Run "apify login" to add one.', stdout: true }); + return; + } + + for (const profile of profiles) { + table.pushRow({ + Name: profile.active ? `${chalk.bold(profile.name)} ${chalk.green('(active)')}` : profile.name, + 'User ID': chalk.gray(profile.id), + Type: profile.isOrganization ? 'Organization' : 'Personal', + 'Last login': profile.loggedInAt + ? TimestampFormatter.display(new Date(profile.loggedInAt)) + : chalk.gray('Unknown'), + Storage: profile.secretsBackend === 'keyring' ? 'OS keyring' : 'auth.json', + }); + } + + simpleLog({ message: table.render(CompactMode.WebLikeCompact), stdout: true }); + } +} diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 8587dd606..d9b0d7bcc 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -2,13 +2,21 @@ import process from 'node:process'; import { APIFY_ENV_VARS } from '@apify/consts'; -import { getActiveProfileId, profileLabel, removeActiveProfile } from '../../lib/auth-file.js'; -import { invalidEnvTokenMessage, readEnvToken } from '../../lib/auth.js'; +import { + getActiveProfileId, + listProfiles, + profileLabel, + removeAllProfiles, + removeProfile, +} from '../../lib/auth-file.js'; +import { invalidEnvTokenMessage, readEnvToken, requireProfile } from '../../lib/auth.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Flags, YesFlag } from '../../lib/command-framework/flags.js'; import { AUTH_FILE_PATH, CommandExitCodes } from '../../lib/consts.js'; import { clearKeyringSecrets } from '../../lib/credentials.js'; import { updateUserId } from '../../lib/hooks/telemetry/useTelemetryState.js'; -import { error, success, warning } from '../../lib/outputs.js'; +import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; +import { error, info, success, warning } from '../../lib/outputs.js'; import { tildify } from '../../lib/utils.js'; export class AuthLogoutCommand extends ApifyCommand { @@ -26,37 +34,80 @@ export class AuthLogoutCommand extends ApifyCommand { description: 'Remove the stored Apify credentials.', command: 'apify logout', }, + { + description: 'Log out of one stored account and keep the active one.', + command: 'apify logout --profile my-org', + }, + { + description: 'Log out of every stored account without a prompt.', + command: 'apify logout --all --yes', + }, ]; + static override flags = { + profile: Flags.string({ + description: 'The stored account to log out of, by name or user ID. See "apify auth list".', + exclusive: ['all'], + }), + all: Flags.boolean({ + description: 'Log out of every stored account.', + exclusive: ['profile'], + }), + ...YesFlag(), + }; + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-logout'; async run() { + const done = this.flags.all ? await this.logOutOfAll() : await this.logOutOf(this.flags.profile); + if (!done) return; + + const envToken = readEnvToken(); + if (envToken.kind === 'token') { + warning({ + message: `${APIFY_ENV_VARS.TOKEN} is still set, so commands stay authenticated with that token.`, + }); + } else if (envToken.kind === 'invalid') { + warning({ message: invalidEnvTokenMessage(envToken.raw) }); + } + } + + private async logOutOf(nameOrId: string | undefined): Promise { // Read before either step runs: once the profile is gone, nothing names the keyring entries it owns. const activeProfileId = getActiveProfileId(); + const targetId = nameOrId ? (await requireProfile(nameOrId)).id : activeProfileId; + const isActive = targetId === activeProfileId; // Both steps are attempted even when the first one fails, so neither the secrets nor the // profile are left behind just because the other could not be removed. - const keyringError = await clearKeyringSecrets(activeProfileId).then( + const keyringError = await clearKeyringSecrets(targetId, { keepLegacy: !isActive }).then( () => null, (err: unknown) => err, ); let profileError: unknown = null; - let result: ReturnType = {}; + let result: ReturnType = {}; try { - result = removeActiveProfile(); + result = removeProfile(targetId); } catch (err) { profileError = err; } if (keyringError || profileError) { - error({ message: partialLogoutMessage(activeProfileId, keyringError, profileError) }); + error({ message: partialLogoutMessage(targetId, keyringError, profileError) }); process.exitCode = CommandExitCodes.RunFailed; - return; + return false; } const { removed, active } = result; + if (!isActive) { + success({ + message: `You are logged out of ${profileLabel(removed!)}.${active ? ` ${profileLabel(active)} is still the active account.` : ''}`, + }); + return true; + } + await updateUserId(active?.id ?? null); if (active) { @@ -67,14 +118,45 @@ export class AuthLogoutCommand extends ApifyCommand { success({ message: 'You are logged out from your Apify account.' }); } - const envToken = readEnvToken(); - if (envToken.kind === 'token') { - warning({ - message: `${APIFY_ENV_VARS.TOKEN} is still set, so commands stay authenticated with that token.`, + return true; + } + + private async logOutOfAll(): Promise { + const profiles = listProfiles(); + + if (profiles.length && !this.flags.yes) { + const confirmed = await useYesNoConfirm({ + message: `Log out of ${profiles.length === 1 ? 'your stored account' : `all ${profiles.length} stored accounts`}?`, + errorMessageForStdin: 'Use --yes to log out of every stored account without a prompt.', }); - } else if (envToken.kind === 'invalid') { - warning({ message: invalidEnvTokenMessage(envToken.raw) }); + + if (!confirmed) { + info({ message: 'Logout was cancelled.' }); + return false; + } + } + + const keyringErrors: unknown[] = []; + for (const id of new Set([getActiveProfileId(), ...profiles.map((p) => p.id)])) { + await clearKeyringSecrets(id).catch((err: unknown) => keyringErrors.push(err)); } + + let profileError: unknown = null; + try { + removeAllProfiles(); + } catch (err) { + profileError = err; + } + + if (keyringErrors.length || profileError) { + error({ message: partialLogoutMessage(undefined, keyringErrors[0], profileError) }); + process.exitCode = CommandExitCodes.RunFailed; + return false; + } + + await updateUserId(null); + success({ message: 'You are logged out of all your Apify accounts.' }); + return true; } } @@ -82,9 +164,9 @@ function reasonOf(err: unknown) { return err instanceof Error ? err.message : String(err); } -function partialLogoutMessage(activeProfileId: string | undefined, keyringError: unknown, profileError: unknown) { +function partialLogoutMessage(profileId: string | undefined, keyringError: unknown, profileError: unknown) { const keyringPart = keyringError - ? `Your secrets are still in the OS keyring${activeProfileId ? ` under the account ${activeProfileId}` : ''}; delete them with your OS keyring app.` + ? `Your secrets are still in the OS keyring${profileId ? ` under the account ${profileId}` : ''}; delete them with your OS keyring app.` : 'Your secrets were removed from the OS keyring.'; const profilePart = profileError diff --git a/src/commands/auth/switch.ts b/src/commands/auth/switch.ts new file mode 100644 index 000000000..00305c0c1 --- /dev/null +++ b/src/commands/auth/switch.ts @@ -0,0 +1,103 @@ +import process from 'node:process'; + +import { + ensureAuthFileCurrent, + getActiveProfileId, + listProfiles, + profileLabel, + setActiveProfile, +} from '../../lib/auth-file.js'; +import { + envTokenOverridesProfileMessage, + missingProfileTokenMessage, + readEnvToken, + requireProfile, +} from '../../lib/auth.js'; +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; +import { CommandExitCodes } from '../../lib/consts.js'; +import { ensureMigrated, ensureSecretsKeyed, getSecret } from '../../lib/credentials.js'; +import { updateUserId } from '../../lib/hooks/telemetry/useTelemetryState.js'; +import { useSelectFromList } from '../../lib/hooks/user-confirmations/useSelectFromList.js'; +import { info, success } from '../../lib/outputs.js'; + +export class AuthSwitchCommand extends ApifyCommand { + static override name = 'switch' as const; + + static override description = + 'Sets the stored account that commands use. Without an argument, prompts you to pick one of the stored accounts.'; + + static override group = 'Authentication'; + + static override interactive = true; + + static override interactiveNote = + 'Prompts for the account when called without an argument. Pass the name or user ID to skip the prompt.'; + + static override args = { + profile: Args.string({ + description: 'The stored account to make active, by name or user ID. See "apify auth list".', + }), + }; + + static override examples = [ + { + description: 'Pick the active account from a list.', + command: 'apify auth switch', + }, + { + description: 'Make the "my-org" account active.', + command: 'apify auth switch my-org', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-auth-switch'; + + async run() { + if (readEnvToken().kind !== 'unset') { + process.exitCode = CommandExitCodes.InvalidInput; + throw new Error(envTokenOverridesProfileMessage('the active account')); + } + + const profile = await requireProfile(this.args.profile ?? (await this.pickProfile())); + + // Local read, so a profile whose secret was removed is refused before it becomes active. + if (!(await getSecret(profile.id, 'token'))) { + process.exitCode = CommandExitCodes.MissingAuth; + throw new Error(missingProfileTokenMessage(profile)); + } + + if (profile.id === getActiveProfileId()) { + info({ message: `${profileLabel(profile)} is already the active account.` }); + return; + } + + setActiveProfile(profile.id); + await updateUserId(profile.id); + + success({ message: `${profileLabel(profile)} is now the active account.` }); + } + + private async pickProfile(): Promise { + await ensureMigrated(); + await ensureAuthFileCurrent(); + await ensureSecretsKeyed(); + + const profiles = listProfiles(); + if (!profiles.length) { + process.exitCode = CommandExitCodes.MissingAuth; + throw new Error('No accounts are stored. Run "apify login" to add one.'); + } + + return useSelectFromList({ + message: 'Which account do you want to use?', + choices: profiles.map((profile) => ({ + name: `${profileLabel(profile)} (${profile.id})`, + value: profile.id, + })), + default: getActiveProfileId(), + errorMessageForStdin: + 'Pass the account to switch to, for example "apify auth switch ". Run "apify auth list" to see the stored accounts.', + }); + } +} diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index 3ddb644fd..cb44a9bac 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -6,6 +6,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class AuthTokenCommand extends ApifyCommand { static override name = 'token' as const; + static override enableProfileFlag = true; + static override description = `Prints the API token the CLI authenticates with, resolved from APIFY_TOKEN or the token from 'apify login'.`; static override examples = [ diff --git a/src/commands/builds/add-tag.ts b/src/commands/builds/add-tag.ts index a450be39f..398a89d4e 100644 --- a/src/commands/builds/add-tag.ts +++ b/src/commands/builds/add-tag.ts @@ -9,6 +9,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class BuildsAddTagCommand extends ApifyCommand { static override name = 'add-tag' as const; + static override enableProfileFlag = true; + static override description = 'Adds a tag to a specific Actor build.'; static override examples = [ diff --git a/src/commands/builds/create.ts b/src/commands/builds/create.ts index 7e386b6ce..fba44a4af 100644 --- a/src/commands/builds/create.ts +++ b/src/commands/builds/create.ts @@ -21,6 +21,8 @@ import { getLoggedClientOrThrow, objectGroupBy, outputJobLog, printJsonToStdout export class BuildsCreateCommand extends ApifyCommand { static override name = 'create' as const; + static override enableProfileFlag = true; + static override description = 'Creates a new build of the Actor.'; static override examples = [ diff --git a/src/commands/builds/info.ts b/src/commands/builds/info.ts index 1c1b0b1b5..82322cd42 100644 --- a/src/commands/builds/info.ts +++ b/src/commands/builds/info.ts @@ -11,6 +11,8 @@ import { DurationFormatter, getLoggedClientOrThrow, printJsonToStdout, Timestamp export class BuildsInfoCommand extends ApifyCommand { static override name = 'info' as const; + static override enableProfileFlag = true; + static override description = 'Prints information about a specific build.'; static override examples = [ diff --git a/src/commands/builds/log.ts b/src/commands/builds/log.ts index 5375234e7..3329e1b5b 100644 --- a/src/commands/builds/log.ts +++ b/src/commands/builds/log.ts @@ -6,6 +6,8 @@ import { getLoggedClientOrThrow, outputJobLog } from '../../lib/utils.js'; export class BuildsLogCommand extends ApifyCommand { static override name = 'log' as const; + static override enableProfileFlag = true; + static override description = 'Prints the log of a specific build.'; static override examples = [ diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts index db7f9c1cc..8b32cf9ef 100644 --- a/src/commands/builds/ls.ts +++ b/src/commands/builds/ls.ts @@ -22,6 +22,8 @@ const tableFactory = () => export class BuildsLsCommand extends ApifyCommand { static override name = 'ls' as const; + static override enableProfileFlag = true; + static override description = 'Lists all builds of the Actor.'; static override examples = [ diff --git a/src/commands/builds/remove-tag.ts b/src/commands/builds/remove-tag.ts index b474b8f07..6256798ce 100644 --- a/src/commands/builds/remove-tag.ts +++ b/src/commands/builds/remove-tag.ts @@ -10,6 +10,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class BuildsRemoveTagCommand extends ApifyCommand { static override name = 'remove-tag' as const; + static override enableProfileFlag = true; + static override description = 'Removes a tag from a specific Actor build.'; static override examples = [ diff --git a/src/commands/builds/rm.ts b/src/commands/builds/rm.ts index d69fe6c6b..e11f2df09 100644 --- a/src/commands/builds/rm.ts +++ b/src/commands/builds/rm.ts @@ -11,6 +11,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class BuildsRmCommand extends ApifyCommand { static override name = 'rm' as const; + static override enableProfileFlag = true; + static override description = 'Permanently removes an Actor build from the Apify platform.'; static override interactive = true; diff --git a/src/commands/builds/wait.ts b/src/commands/builds/wait.ts index 5f147a519..2e8f6b26d 100644 --- a/src/commands/builds/wait.ts +++ b/src/commands/builds/wait.ts @@ -18,6 +18,8 @@ import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; export class BuildsWaitCommand extends ApifyCommand { static override name = 'wait' as const; + static override enableProfileFlag = true; + static override description = 'Waits for an Actor build to reach a terminal status (SUCCEEDED, FAILED, ABORTED, TIMED-OUT).\n' + 'Returns exit code 0 only when the build SUCCEEDED. Designed for CI and agentic workflows.'; diff --git a/src/commands/datasets/create.ts b/src/commands/datasets/create.ts index 33efd6cbd..d11a63afa 100644 --- a/src/commands/datasets/create.ts +++ b/src/commands/datasets/create.ts @@ -9,6 +9,8 @@ import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; export class DatasetsCreateCommand extends ApifyCommand { static override name = 'create' as const; + static override enableProfileFlag = true; + static override description = 'Creates a new dataset for storing structured data on your account.'; static override examples = [ diff --git a/src/commands/datasets/get-items.ts b/src/commands/datasets/get-items.ts index d06cf9555..e70570451 100644 --- a/src/commands/datasets/get-items.ts +++ b/src/commands/datasets/get-items.ts @@ -19,6 +19,8 @@ const downloadFormatToContentType: Record = { export class DatasetsGetItems extends ApifyCommand { static override name = 'get-items' as const; + static override enableProfileFlag = true; + static override description = 'Retrieves dataset items in specified format (JSON, CSV, etc).'; static override examples = [ diff --git a/src/commands/datasets/info.ts b/src/commands/datasets/info.ts index 431a2aa2d..7150b4b28 100644 --- a/src/commands/datasets/info.ts +++ b/src/commands/datasets/info.ts @@ -18,6 +18,8 @@ const consoleLikeTable = new ResponsiveTable({ export class DatasetsInfoCommand extends ApifyCommand { static override name = 'info' as const; + static override enableProfileFlag = true; + static override description = 'Prints information about a specific dataset.'; static override examples = [ diff --git a/src/commands/datasets/ls.ts b/src/commands/datasets/ls.ts index 793647a0a..f2aeca97c 100644 --- a/src/commands/datasets/ls.ts +++ b/src/commands/datasets/ls.ts @@ -18,6 +18,8 @@ const table = new ResponsiveTable({ export class DatasetsLsCommand extends ApifyCommand { static override name = 'ls' as const; + static override enableProfileFlag = true; + static override description = 'Prints all datasets on your account.'; static override examples = [ diff --git a/src/commands/datasets/push-items.ts b/src/commands/datasets/push-items.ts index dff4d30c8..37be7fc16 100644 --- a/src/commands/datasets/push-items.ts +++ b/src/commands/datasets/push-items.ts @@ -11,6 +11,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class DatasetsPushDataCommand extends ApifyCommand { static override name = 'push-items' as const; + static override enableProfileFlag = true; + static override description = 'Adds data items to specified dataset. Accepts single object or array of objects.'; static override examples = [ diff --git a/src/commands/datasets/rename.ts b/src/commands/datasets/rename.ts index 274468923..6cc4ac823 100644 --- a/src/commands/datasets/rename.ts +++ b/src/commands/datasets/rename.ts @@ -11,6 +11,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class DatasetsRenameCommand extends ApifyCommand { static override name = 'rename' as const; + static override enableProfileFlag = true; + static override description = 'Change the dataset name or remove the name with --unname flag.'; static override examples = [ diff --git a/src/commands/datasets/rm.ts b/src/commands/datasets/rm.ts index b81b66f0d..2e5de990c 100644 --- a/src/commands/datasets/rm.ts +++ b/src/commands/datasets/rm.ts @@ -12,6 +12,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class DatasetsRmCommand extends ApifyCommand { static override name = 'rm' as const; + static override enableProfileFlag = true; + static override description = 'Permanently removes a dataset.'; static override interactive = true; diff --git a/src/commands/info.ts b/src/commands/info.ts index df10969e3..a61718106 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -7,6 +7,8 @@ import { getCurrentUserInfo, getLoggedClientOrThrow } from '../lib/utils.js'; export class InfoCommand extends ApifyCommand { static override name = 'info' as const; + static override enableProfileFlag = true; + static override description = 'Prints details about your currently authenticated Apify account.'; static override group = 'Apify Console'; @@ -28,7 +30,8 @@ export class InfoCommand extends ApifyCommand { const rows = { 'username': info.username, 'userId': info.id, - 'token source': TOKEN_SOURCE_LABELS[auth!.source], + 'token source': this.flags.profile ? '--profile flag' : TOKEN_SOURCE_LABELS[auth!.source], + ...(auth!.profile ? { profile: auth!.profile.label } : {}), }; for (const [key, value] of Object.entries(rows)) { diff --git a/src/commands/key-value-stores/create.ts b/src/commands/key-value-stores/create.ts index 3dbe7cb65..075b92149 100644 --- a/src/commands/key-value-stores/create.ts +++ b/src/commands/key-value-stores/create.ts @@ -9,6 +9,8 @@ import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; export class KeyValueStoresCreateCommand extends ApifyCommand { static override name = 'create' as const; + static override enableProfileFlag = true; + static override description = 'Creates a new key-value store on your account.'; static override examples = [ diff --git a/src/commands/key-value-stores/delete-value.ts b/src/commands/key-value-stores/delete-value.ts index 23ba46c37..a05439f30 100644 --- a/src/commands/key-value-stores/delete-value.ts +++ b/src/commands/key-value-stores/delete-value.ts @@ -12,6 +12,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class KeyValueStoresDeleteValueCommand extends ApifyCommand { static override name = 'delete-value' as const; + static override enableProfileFlag = true; + static override description = 'Delete a value from a key-value store.'; static override interactive = true; diff --git a/src/commands/key-value-stores/get-value.ts b/src/commands/key-value-stores/get-value.ts index 44f06bf6a..7afdbaa32 100644 --- a/src/commands/key-value-stores/get-value.ts +++ b/src/commands/key-value-stores/get-value.ts @@ -8,6 +8,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class KeyValueStoresGetValueCommand extends ApifyCommand { static override name = 'get-value' as const; + static override enableProfileFlag = true; + static override description = 'Retrieves stored value for specified key. Use --only-content-type to check MIME type.'; static override examples = [ diff --git a/src/commands/key-value-stores/info.ts b/src/commands/key-value-stores/info.ts index b20f18d27..3a1e0be01 100644 --- a/src/commands/key-value-stores/info.ts +++ b/src/commands/key-value-stores/info.ts @@ -18,6 +18,8 @@ const consoleLikeTable = new ResponsiveTable({ export class KeyValueStoresInfoCommand extends ApifyCommand { static override name = 'info' as const; + static override enableProfileFlag = true; + static override description = 'Shows information about a key-value store.'; static override examples = [ diff --git a/src/commands/key-value-stores/keys.ts b/src/commands/key-value-stores/keys.ts index f21b296c6..38cb21467 100644 --- a/src/commands/key-value-stores/keys.ts +++ b/src/commands/key-value-stores/keys.ts @@ -15,6 +15,8 @@ const table = new ResponsiveTable({ export class KeyValueStoresKeysCommand extends ApifyCommand { static override name = 'keys' as const; + static override enableProfileFlag = true; + static override description = 'Lists all keys in a key-value store.'; static override examples = [ diff --git a/src/commands/key-value-stores/ls.ts b/src/commands/key-value-stores/ls.ts index e516df91d..7de4c7f21 100644 --- a/src/commands/key-value-stores/ls.ts +++ b/src/commands/key-value-stores/ls.ts @@ -15,6 +15,8 @@ const table = new ResponsiveTable({ export class KeyValueStoresLsCommand extends ApifyCommand { static override name = 'ls' as const; + static override enableProfileFlag = true; + static override description = 'Lists all key-value stores on your account.'; static override examples = [ diff --git a/src/commands/key-value-stores/rename.ts b/src/commands/key-value-stores/rename.ts index 197e41d2f..9ed57b505 100644 --- a/src/commands/key-value-stores/rename.ts +++ b/src/commands/key-value-stores/rename.ts @@ -11,6 +11,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class KeyValueStoresRenameCommand extends ApifyCommand { static override name = 'rename' as const; + static override enableProfileFlag = true; + static override description = 'Renames a key-value store, or removes its unique name.'; static override examples = [ diff --git a/src/commands/key-value-stores/rm.ts b/src/commands/key-value-stores/rm.ts index 4c390ed06..70612c470 100644 --- a/src/commands/key-value-stores/rm.ts +++ b/src/commands/key-value-stores/rm.ts @@ -12,6 +12,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class KeyValueStoresRmCommand extends ApifyCommand { static override name = 'rm' as const; + static override enableProfileFlag = true; + static override description = 'Permanently removes a key-value store.'; static override interactive = true; diff --git a/src/commands/key-value-stores/set-value.ts b/src/commands/key-value-stores/set-value.ts index c5be28899..febccb3dc 100644 --- a/src/commands/key-value-stores/set-value.ts +++ b/src/commands/key-value-stores/set-value.ts @@ -10,6 +10,8 @@ import { getLoggedClientOrThrow } from '../../lib/utils.js'; export class KeyValueStoresSetValueCommand extends ApifyCommand { static override name = 'set-value' as const; + static override enableProfileFlag = true; + static override description = 'Stores value with specified key. Set content-type with --content-type flag.'; static override examples = [ diff --git a/src/commands/run.ts b/src/commands/run.ts index a943d10da..7320e4260 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -84,6 +84,8 @@ enum RunType { export class RunCommand extends ApifyCommand { static override name = 'run' as const; + static override enableProfileFlag = true; + static override description = `Executes Actor locally with simulated Apify environment variables.\n` + `Stores data in local '${DEFAULT_LOCAL_STORAGE_DIR}' directory.\n\n` + diff --git a/src/commands/runs/abort.ts b/src/commands/runs/abort.ts index 669d0cc6d..41025d808 100644 --- a/src/commands/runs/abort.ts +++ b/src/commands/runs/abort.ts @@ -15,6 +15,8 @@ const abortingStatuses = [ACTOR_JOB_STATUSES.ABORTING, ACTOR_JOB_STATUSES.TIMING export class RunsAbortCommand extends ApifyCommand { static override name = 'abort' as const; + static override enableProfileFlag = true; + static override description = 'Aborts an Actor run.'; static override examples = [ diff --git a/src/commands/runs/info.ts b/src/commands/runs/info.ts index f9ae01a28..806d291df 100644 --- a/src/commands/runs/info.ts +++ b/src/commands/runs/info.ts @@ -43,6 +43,8 @@ const usageMapping: Record = { export class RunsInfoCommand extends ApifyCommand { static override name = 'info' as const; + static override enableProfileFlag = true; + static override description = 'Prints information about an Actor run.'; static override examples = [ diff --git a/src/commands/runs/log.ts b/src/commands/runs/log.ts index cc8fcfe24..271ae5c1a 100644 --- a/src/commands/runs/log.ts +++ b/src/commands/runs/log.ts @@ -6,6 +6,8 @@ import { getLoggedClientOrThrow, outputJobLog } from '../../lib/utils.js'; export class RunsLogCommand extends ApifyCommand { static override name = 'log' as const; + static override enableProfileFlag = true; + static override description = 'Prints the log of a specific run.'; static override examples = [ diff --git a/src/commands/runs/ls.ts b/src/commands/runs/ls.ts index e4808979d..0fbfe16c2 100644 --- a/src/commands/runs/ls.ts +++ b/src/commands/runs/ls.ts @@ -28,6 +28,8 @@ const table = new ResponsiveTable({ export class RunsLsCommand extends ApifyCommand { static override name = 'ls' as const; + static override enableProfileFlag = true; + static override description = 'Lists all runs of the Actor.'; static override examples = [ diff --git a/src/commands/runs/resurrect.ts b/src/commands/runs/resurrect.ts index a41c87300..52888bd1c 100644 --- a/src/commands/runs/resurrect.ts +++ b/src/commands/runs/resurrect.ts @@ -17,6 +17,8 @@ const resurrectStatuses = [ export class RunsResurrectCommand extends ApifyCommand { static override name = 'resurrect' as const; + static override enableProfileFlag = true; + static override description = 'Resurrects an aborted or finished Actor Run.'; static override examples = [ diff --git a/src/commands/runs/rm.ts b/src/commands/runs/rm.ts index 1c69e8418..07675d224 100644 --- a/src/commands/runs/rm.ts +++ b/src/commands/runs/rm.ts @@ -19,6 +19,8 @@ const deletableStatuses = [ export class RunsRmCommand extends ApifyCommand { static override name = 'rm' as const; + static override enableProfileFlag = true; + static override description = 'Deletes an Actor Run.'; static override interactive = true; diff --git a/src/commands/runs/wait.ts b/src/commands/runs/wait.ts index 92e65547e..72b124eb3 100644 --- a/src/commands/runs/wait.ts +++ b/src/commands/runs/wait.ts @@ -18,6 +18,8 @@ import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; export class RunsWaitCommand extends ApifyCommand { static override name = 'wait' as const; + static override enableProfileFlag = true; + static override description = 'Waits for an Actor run to reach a terminal status (SUCCEEDED, FAILED, ABORTED, TIMED-OUT).\n' + 'Returns exit code 0 only when the run SUCCEEDED. Designed for CI and agentic workflows.'; diff --git a/src/commands/task/publish.ts b/src/commands/task/publish.ts index 297f49fa3..e321e5543 100644 --- a/src/commands/task/publish.ts +++ b/src/commands/task/publish.ts @@ -12,6 +12,8 @@ import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskPublishCommand extends ApifyCommand { static override name = 'publish' as const; + static override enableProfileFlag = true; + static override description = 'Publishes the task on its public landing page.\n' + 'The task must belong to a public Actor and have its public display configuration set up ' + diff --git a/src/commands/task/run.ts b/src/commands/task/run.ts index 1e6d2055f..0f625e5d5 100644 --- a/src/commands/task/run.ts +++ b/src/commands/task/run.ts @@ -9,6 +9,8 @@ import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskRunCommand extends ApifyCommand { static override name = 'run' as const; + static override enableProfileFlag = true; + static override description = 'Executes predefined Actor task remotely using local key-value store for input.\n' + 'Customize with --memory and --timeout flags.\n'; diff --git a/src/commands/task/unpublish.ts b/src/commands/task/unpublish.ts index b272dc60c..68592016c 100644 --- a/src/commands/task/unpublish.ts +++ b/src/commands/task/unpublish.ts @@ -12,6 +12,8 @@ import { getCurrentUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskUnpublishCommand extends ApifyCommand { static override name = 'unpublish' as const; + static override enableProfileFlag = true; + static override description = 'Unpublishes the task from its public landing page.\n' + 'The public display configuration is preserved, so the task can be published again later. ' + diff --git a/src/lib/auth-file.ts b/src/lib/auth-file.ts index 6e2c47787..94d165c1a 100644 --- a/src/lib/auth-file.ts +++ b/src/lib/auth-file.ts @@ -259,6 +259,35 @@ export function profileLabel(profile: AuthProfile & { id: string }) { return profile.name ?? profile.username ?? profile.id; } +/** A profile together with the user ID it is keyed by. */ +export type StoredProfile = AuthProfile & { id: string }; + +/** Every stored profile, in file order. Reads the pre-profile shape as its one account. */ +export function listProfiles(): StoredProfile[] { + const file = readAuthFile(); + if (file.version !== AUTH_FILE_VERSION) { + const { profile } = lookUpActiveProfile(); + return profile ? [profile] : []; + } + + return Object.entries(file.profiles ?? {}).map(([id, profile]) => ({ id, ...profile })); +} + +/** The profile a `--profile` value names: a user ID first, then the label `profileLabel` shows. */ +export function findProfile(nameOrId: string): StoredProfile | undefined { + const profiles = listProfiles(); + return profiles.find(({ id }) => id === nameOrId) ?? profiles.find((p) => profileLabel(p) === nameOrId); +} + +/** Makes a stored profile active. A user ID the file does not hold is ignored. */ +export function setActiveProfile(userId: string) { + const file = readAuthFile(); + if (!file.profiles?.[userId]) return; + + file.activeProfile = userId; + writeAuthFile(file); +} + export function getActiveProfile(): (AuthProfile & { id: string }) | undefined { return lookUpActiveProfile().profile; } @@ -378,13 +407,13 @@ export function upsertProfile(userId: string, profile: AuthProfile, backend: Cre } /** - * Drops the active profile together with the secrets stored beside it. The profile with the most - * recent `loggedInAt` becomes active. The file and the v1 backup go away once no profile is left, - * so logging out leaves no token on disk. + * Drops a profile together with the secrets stored beside it; the active one when no ID is given. + * Removing the active profile makes the one with the most recent `loggedInAt` active. The file and + * the v1 backup go away once no profile is left, so logging out leaves no token on disk. */ -export function removeActiveProfile(): { - removed?: AuthProfile & { id: string }; - active?: AuthProfile & { id: string }; +export function removeProfile(userId?: string): { + removed?: StoredProfile; + active?: StoredProfile; } { const file = readAuthFile(); @@ -395,11 +424,18 @@ export function removeActiveProfile(): { return {}; } - const removedId = file.activeProfile; + const removedId = userId ?? file.activeProfile; const removedProfile = removedId ? file.profiles?.[removedId] : undefined; const removed = removedId && removedProfile ? { id: removedId, ...removedProfile } : undefined; if (removedId && file.profiles) delete file.profiles[removedId]; + + if (removedId !== file.activeProfile) { + writeAuthFile(file); + const activeProfile = file.activeProfile ? file.profiles?.[file.activeProfile] : undefined; + return { removed, active: activeProfile && { id: file.activeProfile!, ...activeProfile } }; + } + delete file.activeProfile; delete file.token; delete file.proxy; @@ -418,6 +454,11 @@ export function removeActiveProfile(): { return { removed, active: { id: nextId, ...file.profiles![nextId] } }; } +/** Deletes the auth file and the v1 backup. The caller clears the keyring first, while the file still indexes it. */ +export function removeAllProfiles() { + discardAuthFiles(); +} + function discardAuthFiles() { rmSync(AUTH_FILE_PATH(), { force: true, maxRetries: 10, retryDelay: 100 }); rmSync(AUTH_BACKUP_FILE_PATH(), { force: true, maxRetries: 10, retryDelay: 100 }); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 5a3305948..35e16800b 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -6,7 +6,16 @@ import { AxiosHeaders } from 'axios'; import { APIFY_ENV_VARS } from '@apify/consts'; -import { ensureAuthFileCurrent, getActiveProfileId, upsertProfile } from './auth-file.js'; +import { + ensureAuthFileCurrent, + findProfile, + getActiveProfile, + getActiveProfileId, + listProfiles, + profileLabel, + type StoredProfile, + upsertProfile, +} from './auth-file.js'; import { APIFY_CLIENT_DEFAULT_HEADERS, AUTH_FILE_PATH, CommandExitCodes } from './consts.js'; import { clearKeyringSecrets, @@ -26,6 +35,8 @@ export type TokenSource = 'env' | 'stored'; export interface ResolvedAuth { token: string; source: TokenSource; + /** The stored profile the token belongs to. Unset for `APIFY_TOKEN`. */ + profile?: { id: string; label: string }; } /** @@ -61,9 +72,49 @@ export const TOKEN_SOURCE_LABELS: Record = { let authPromise: Promise | undefined; +let selectedProfile: StoredProfile | undefined; + /** Test-only: drop the resolved token so each test resolves afresh. */ export function __resetAuthForTests() { authPromise = undefined; + selectedProfile = undefined; +} + +/** One wording for a profile selection that `APIFY_TOKEN` would override. */ +export function envTokenOverridesProfileMessage(what: string): string { + return `${APIFY_ENV_VARS.TOKEN} is set, so commands ignore ${what}. Unset ${APIFY_ENV_VARS.TOKEN} and try again.`; +} + +/** The stored profile a `--profile` value names, or an error listing the ones that exist. */ +export async function requireProfile(nameOrId: string): Promise { + await ensureMigrated(); + await ensureAuthFileCurrent(); + await ensureSecretsKeyed(); + + const profile = findProfile(nameOrId); + if (profile) return profile; + + process.exitCode = CommandExitCodes.InvalidInput; + const stored = listProfiles().map(profileLabel); + throw new Error( + stored.length + ? `No stored account is called "${nameOrId}". Stored accounts: ${stored.join(', ')}.` + : `No stored account is called "${nameOrId}". Run "apify login" to add one.`, + ); +} + +/** + * Makes {@link resolveAuth} use this profile instead of the active one, for this process only. + * Throws when `APIFY_TOKEN` is set, even to the same token: the variable would win silently. + */ +export async function selectProfile(nameOrId: string): Promise { + if (readEnvToken().kind !== 'unset') { + process.exitCode = CommandExitCodes.InvalidInput; + throw new Error(envTokenOverridesProfileMessage('--profile')); + } + + selectedProfile = await requireProfile(nameOrId); + authPromise = undefined; } /** @@ -101,9 +152,22 @@ export const resolveAuth = async (): Promise => { await ensureAuthFileCurrent(); await ensureSecretsKeyed(); + if (selectedProfile) { + const token = await getSecret(selectedProfile.id, 'token'); + if (!token) { + process.exitCode = CommandExitCodes.MissingAuth; + throw new Error(missingProfileTokenMessage(selectedProfile)); + } + + return { token, source: 'stored', profile: profileRef(selectedProfile) } as const; + } + const userId = getActiveProfileId(); const storedToken = userId ? await getSecret(userId, 'token') : undefined; - return storedToken ? ({ token: storedToken, source: 'stored' } as const) : undefined; + if (!storedToken) return undefined; + + const profile = getActiveProfile(); + return { token: storedToken, source: 'stored', ...(profile ? { profile: profileRef(profile) } : {}) } as const; })(); try { @@ -115,6 +179,15 @@ export const resolveAuth = async (): Promise => { } }; +function profileRef(profile: StoredProfile) { + return { id: profile.id, label: profileLabel(profile) }; +} + +/** One wording for a stored profile that has no token, for `--profile` and `auth switch`. */ +export function missingProfileTokenMessage(profile: StoredProfile): string { + return `No API token is stored for ${profileLabel(profile)}. Run "apify login" to log in to ${profileLabel(profile)} again.`; +} + export function describeAuthFailure(auth: ResolvedAuth | undefined, error?: unknown): string { if (!auth) { return 'You are not logged in with your Apify account. Call "apify login" to fix that.'; @@ -131,7 +204,9 @@ export function describeAuthFailure(auth: ResolvedAuth | undefined, error?: unkn case 'env': return `The API token in ${APIFY_ENV_VARS.TOKEN} was rejected. Unset it to use your stored login instead.`; default: - return 'Your stored API token was rejected. Call "apify login" to log in again.'; + return auth.profile + ? `The stored API token for ${auth.profile.label} was rejected. Run "apify login" to log in to ${auth.profile.label} again.` + : 'Your stored API token was rejected. Call "apify login" to log in again.'; } } diff --git a/src/lib/command-framework/apify-command.ts b/src/lib/command-framework/apify-command.ts index 5a9a92ef9..f6b94f60e 100644 --- a/src/lib/command-framework/apify-command.ts +++ b/src/lib/command-framework/apify-command.ts @@ -10,6 +10,7 @@ import widestLine from 'widest-line'; import wrapAnsi from 'wrap-ansi'; import { cachedStdinInput } from '../../entrypoints/_shared.js'; +import { selectProfile } from '../auth.js'; import { keepStdoutClean } from '../exec.js'; import { detectAiAgent, detectCi, detectIsInteractive } from '../hooks/telemetry/detectEnvironment.js'; import type { TrackEventMap } from '../hooks/telemetry/trackEvent.js'; @@ -122,6 +123,7 @@ type InferFlagsFromCommand< ? Record : _InferFlagsFromCommand, OptionalIfHasDefault>) & { json: boolean; + profile?: string; }; export function camelCaseString(str: string): string { @@ -147,6 +149,11 @@ const jsonFlagDefinition = { multiple: false, } as const satisfies ParseArgsOptionDescriptor; +const profileFlagDefinition = { + type: 'string', + multiple: false, +} as const satisfies ParseArgsOptionDescriptor; + const userAgentFlagDefinition = { type: 'string', multiple: false, @@ -213,6 +220,9 @@ export abstract class ApifyCommand { if (typeof a[1] === 'string') { diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index 6e3771377..a4c2c83ad 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -247,10 +247,11 @@ export async function deleteSecret(userId: string, kind: SecretKind): Promise { +export async function clearKeyringSecrets(userId?: string, { keepLegacy = false } = {}): Promise { for (const kind of SECRET_KINDS) { if (userId) await deleteKeyring(keyringKey(userId, kind)); - await deleteKeyring(legacyKeyringKey(kind)); + // The fixed-name entries belong to the active account, so a non-active profile leaves them. + if (!keepLegacy) await deleteKeyring(legacyKeyringKey(kind)); } } diff --git a/src/lib/hooks/user-confirmations/useSelectFromList.ts b/src/lib/hooks/user-confirmations/useSelectFromList.ts index 82230fbea..aa3de5ac9 100644 --- a/src/lib/hooks/user-confirmations/useSelectFromList.ts +++ b/src/lib/hooks/user-confirmations/useSelectFromList.ts @@ -1,6 +1,6 @@ import select from '@inquirer/select'; -import { promptContext, stdinCheckWrapper } from './_stdinCheckWrapper.js'; +import { promptContext, type StdinCheckWrapperInput, stdinCheckWrapper } from './_stdinCheckWrapper.js'; export type ChoicesType = Parameters>[0]['choices']; @@ -21,4 +21,4 @@ export const useSelectFromList = stdinCheckWrapper( { errorMessageForStdin: 'Please provide the selection using the command options.', }, -) as (input: UseSelectFromListInput) => Promise; +) as (input: UseSelectFromListInput & StdinCheckWrapperInput) => Promise; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 08bcb94a1..a14689cd9 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -32,7 +32,7 @@ import { SOURCE_FILE_FORMATS, } from '@apify/consts'; -import { ensureAuthFileCurrent, lookUpActiveProfile } from './auth-file.js'; +import { ensureAuthFileCurrent, findProfile, lookUpActiveProfile } from './auth-file.js'; import { describeAuthFailure, getApifyClientOptionsForToken, resolveAuth, type ResolvedAuth } from './auth.js'; import { AUTH_FILE_PATH, @@ -86,15 +86,15 @@ export const getLocalRequestQueuePath = (storeId?: string) => { }; /** - * The active profile in the flat shape the CLI consumes, or an empty object when nothing is + * The given profile, or the active one, in the flat shape the CLI consumes, or an empty object when nothing is * stored. Secrets come from whichever backend holds them; the metadata comes from auth.json. */ -export const getLocalUserInfo = async (): Promise => { +export const getLocalUserInfo = async (userId?: string): Promise => { await ensureMigrated(); await ensureAuthFileCurrent(); await ensureSecretsKeyed(); - const { profile, missingProfile } = lookUpActiveProfile(); + const { profile, missingProfile } = userId ? { profile: findProfile(userId) } : lookUpActiveProfile(); // Reported rather than swallowed: the commands that build `/` lookups would // otherwise fail with a misleading "not found". @@ -171,7 +171,7 @@ export async function getCurrentUserInfo(): Promise { if (!auth) return getLocalUserInfo(); if (cachedUserInfo?.token === auth.token) return cachedUserInfo.userInfo; - if (auth.source === 'stored') return getLocalUserInfo(); + if (auth.source === 'stored') return getLocalUserInfo(auth.profile?.id); const apifyClient = new ApifyClient({ ...getApifyClientOptionsForToken(auth.token), diff --git a/test/local/commands/auth-profiles.test.ts b/test/local/commands/auth-profiles.test.ts new file mode 100644 index 000000000..0fc396e14 --- /dev/null +++ b/test/local/commands/auth-profiles.test.ts @@ -0,0 +1,310 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import process from 'node:process'; + +import type { AuthFile, AuthProfile } from '../../../src/lib/auth-file.js'; +import { AUTH_FILE_PATH, CommandExitCodes, GLOBAL_CONFIGS_FOLDER } from '../../../src/lib/consts.js'; +import { resetApifyClientMock } from '../../__setup__/apify-client-mock.js'; +import { readAuthFile } from '../../__setup__/auth-file.js'; +import { useAuthSetup } from '../../__setup__/hooks/useAuthSetup.js'; +import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; + +// Prompts take the non-interactive path, as they do in CI. +vi.mock('ci-info', async (importOriginal) => ({ ...(await importOriginal()), isCI: true })); + +vi.mock('@napi-rs/keyring', () => import('../../__setup__/keyring-mock.js')); + +vi.mock('apify-client', async (importOriginal) => ({ + ...(await importOriginal()), + ApifyClient: (await import('../../__setup__/apify-client-mock.js')).FakeApifyClient, +})); + +useAuthSetup(); +const { lastLogMessage, lastErrorMessage, logSpy } = useConsoleSpy(); + +const { AuthListCommand } = await import('../../../src/commands/auth/list.js'); +const { AuthLogoutCommand } = await import('../../../src/commands/auth/logout.js'); +const { AuthSwitchCommand } = await import('../../../src/commands/auth/switch.js'); +const { AuthTokenCommand } = await import('../../../src/commands/auth/token.js'); +const { CreateCommand } = await import('../../../src/commands/create.js'); +const { InfoCommand } = await import('../../../src/commands/info.js'); +const { InitCommand } = await import('../../../src/commands/init.js'); +const { describeAuthFailure, resolveAuth, selectProfile } = await import('../../../src/lib/auth.js'); +const { registerCommandForHelpGeneration, renderHelpForCommand } = + await import('../../../src/lib/command-framework/help.js'); +const { testRunCommand } = await import('../../../src/lib/command-framework/apify-command.js'); +const { getCurrentUserInfo } = await import('../../../src/lib/utils.js'); + +const PROFILE: AuthProfile = { + name: null, + authMethod: 'token', + expiresAt: null, + hasRefreshToken: false, + loggedInAt: null, +}; + +const twoProfiles = (overrides: Partial = {}): AuthFile => ({ + version: 2, + activeProfile: 'uid', + secretsBackend: 'file', + profiles: { + uid: { + ...PROFILE, + username: 'me', + name: 'me', + loggedInAt: '2026-03-01T00:00:00.000Z', + token: 't-me', + proxy: { password: 'pw-me' }, + }, + org: { + ...PROFILE, + username: 'my-org', + name: 'my-org', + organizationOwnerUserId: 'uid', + loggedInAt: '2026-02-01T00:00:00.000Z', + token: 't-org', + proxy: { password: 'pw-org' }, + }, + }, + ...overrides, +}); + +const writeAuthFile = (data: unknown) => { + mkdirSync(GLOBAL_CONFIGS_FOLDER(), { recursive: true }); + writeFileSync(AUTH_FILE_PATH(), JSON.stringify(data)); +}; + +const takeExitCode = () => { + const code = process.exitCode; + process.exitCode = 0; + return code; +}; + +describe('multi-account UX', () => { + beforeEach(() => { + resetApifyClientMock({ id: 'uid', username: 'me' }); + writeAuthFile(twoProfiles()); + }); + + describe('--profile', () => { + it('selects the named account for one command and leaves the active one alone', async () => { + await testRunCommand(AuthTokenCommand, { flags_profile: 'my-org' }); + + expect(lastLogMessage()).toBe('t-org'); + expect(readAuthFile().activeProfile).toBe('uid'); + }); + + it('accepts a user ID', async () => { + await testRunCommand(AuthTokenCommand, { flags_profile: 'org' }); + + expect(lastLogMessage()).toBe('t-org'); + }); + + it('matches a migrated v1 profile by its username', async () => { + const file = twoProfiles(); + file.profiles!.org.name = null; + writeAuthFile(file); + + await testRunCommand(AuthTokenCommand, { flags_profile: 'my-org' }); + + expect(lastLogMessage()).toBe('t-org'); + }); + + it('fails fast and lists the stored profiles when the name is unknown', async () => { + await testRunCommand(AuthTokenCommand, { flags_profile: 'nope' }); + + expect(lastErrorMessage()).toContain('No stored account is called "nope". Stored accounts: me, my-org.'); + expect(takeExitCode()).toBe(CommandExitCodes.InvalidInput); + }); + + it('errors when APIFY_TOKEN is set, even to the same token', async () => { + vitest.stubEnv('APIFY_TOKEN', 't-org'); + + await testRunCommand(AuthTokenCommand, { flags_profile: 'my-org' }); + + expect(lastErrorMessage()).toContain('APIFY_TOKEN is set, so commands ignore --profile'); + expect(takeExitCode()).toBe(CommandExitCodes.InvalidInput); + }); + + it('gives the selected account proxy password to `apify run`', async () => { + await selectProfile('my-org'); + + expect(await getCurrentUserInfo()).toMatchObject({ id: 'org', proxy: { password: 'pw-org' } }); + }); + + it('names the profile when its token is rejected', async () => { + await selectProfile('my-org'); + const auth = await resolveAuth(); + + expect(describeAuthFailure(auth)).toBe( + 'The stored API token for my-org was rejected. Run "apify login" to log in to my-org again.', + ); + }); + + it('says which profile has no token instead of saying the CLI is logged out', async () => { + const file = twoProfiles(); + delete file.profiles!.org.token; + writeAuthFile(file); + + await testRunCommand(AuthTokenCommand, { flags_profile: 'my-org' }); + + expect(lastErrorMessage()).toContain('No API token is stored for my-org.'); + expect(takeExitCode()).toBe(CommandExitCodes.MissingAuth); + }); + + it('shows up in `info` as the token source', async () => { + resetApifyClientMock({ id: 'org', username: 'my-org' }); + + await testRunCommand(InfoCommand, { flags_profile: 'my-org' }); + + const output = logSpy().mock.calls.flat().join('\n'); + expect(output).toContain('--profile flag'); + expect(output).toContain('my-org'); + }); + + it('is not offered on offline commands', () => { + expect(CreateCommand.enableProfileFlag).toBe(false); + expect(InitCommand.enableProfileFlag).toBe(false); + + registerCommandForHelpGeneration('apify', InitCommand); + registerCommandForHelpGeneration('apify', InfoCommand); + expect(renderHelpForCommand(InitCommand)).not.toContain('--profile'); + expect(renderHelpForCommand(InfoCommand)).toContain('--profile'); + }); + }); + + describe('auth switch', () => { + it('makes the named account active', async () => { + await testRunCommand(AuthSwitchCommand, { args_profile: 'my-org' }); + + expect(readAuthFile().activeProfile).toBe('org'); + expect(lastErrorMessage()).toContain('my-org is now the active account.'); + + await testRunCommand(AuthTokenCommand, {}); + expect(lastLogMessage()).toBe('t-org'); + }); + + it('errors when APIFY_TOKEN is set', async () => { + vitest.stubEnv('APIFY_TOKEN', 't-env'); + + await testRunCommand(AuthSwitchCommand, { args_profile: 'my-org' }); + + expect(lastErrorMessage()).toContain('APIFY_TOKEN is set, so commands ignore the active account'); + expect(takeExitCode()).toBe(CommandExitCodes.InvalidInput); + expect(readAuthFile().activeProfile).toBe('uid'); + }); + + it('errors instead of prompting in a non-interactive shell', async () => { + await testRunCommand(AuthSwitchCommand, {}); + + expect(lastErrorMessage()).toContain('Pass the account to switch to'); + expect(readAuthFile().activeProfile).toBe('uid'); + takeExitCode(); + }); + + it('refuses an account whose token is gone', async () => { + const file = twoProfiles(); + delete file.profiles!.org.token; + writeAuthFile(file); + + await testRunCommand(AuthSwitchCommand, { args_profile: 'my-org' }); + + expect(lastErrorMessage()).toContain('No API token is stored for my-org.'); + expect(takeExitCode()).toBe(CommandExitCodes.MissingAuth); + expect(readAuthFile().activeProfile).toBe('uid'); + }); + }); + + describe('auth list', () => { + it('prints every profile from the file as JSON without calling the API', async () => { + resetApifyClientMock({}); + const { clientState } = await import('../../__setup__/apify-client-mock.js'); + clientState.fail = true; + + await testRunCommand(AuthListCommand, { flags_json: true }); + + expect(JSON.parse(lastLogMessage())).toEqual({ + envTokenInUse: false, + profiles: [ + { + id: 'uid', + name: 'me', + username: 'me', + active: true, + isOrganization: false, + organizationOwnerUserId: null, + loggedInAt: '2026-03-01T00:00:00.000Z', + secretsBackend: 'file', + }, + { + id: 'org', + name: 'my-org', + username: 'my-org', + active: false, + isOrganization: true, + organizationOwnerUserId: 'uid', + loggedInAt: '2026-02-01T00:00:00.000Z', + secretsBackend: 'file', + }, + ], + }); + }); + + it('marks the active profile and labels organizations', async () => { + await testRunCommand(AuthListCommand, {}); + + const output = lastLogMessage(); + expect(output).toContain('(active)'); + expect(output).toContain('Organization'); + }); + + it('reports a file a newer CLI wrote instead of listing no accounts', async () => { + writeAuthFile({ version: 99, profiles: { uid: PROFILE } }); + + await testRunCommand(AuthListCommand, {}); + + expect(lastErrorMessage()).toContain('written by a newer Apify CLI'); + takeExitCode(); + }); + + it('says APIFY_TOKEN overrides the active profile', async () => { + vitest.stubEnv('APIFY_TOKEN', 't-env'); + + await testRunCommand(AuthListCommand, {}); + + expect(lastErrorMessage()).toContain('APIFY_TOKEN is set, so commands use it instead of the active account.'); + }); + }); + + describe('logout', () => { + it('--profile removes that account and leaves the active one alone', async () => { + await testRunCommand(AuthLogoutCommand, { flags_profile: 'my-org' }); + + const file = readAuthFile(); + expect(file.activeProfile).toBe('uid'); + expect(file.profiles).not.toHaveProperty('org'); + expect(lastErrorMessage()).toContain('You are logged out of my-org. me is still the active account.'); + }); + + it('--profile with the active account behaves like a plain logout', async () => { + await testRunCommand(AuthLogoutCommand, { flags_profile: 'me' }); + + expect(readAuthFile().activeProfile).toBe('org'); + expect(lastErrorMessage()).toContain('You are logged out of me. my-org is now the active account.'); + }); + + it('--all with --yes removes every account', async () => { + await testRunCommand(AuthLogoutCommand, { flags_all: true, flags_yes: true }); + + expect(existsSync(AUTH_FILE_PATH())).toBe(false); + expect(lastErrorMessage()).toContain('You are logged out of all your Apify accounts.'); + }); + + it('--all without --yes needs a confirmation', async () => { + await testRunCommand(AuthLogoutCommand, { flags_all: true }); + + expect(lastErrorMessage()).toContain('Use --yes'); + expect(Object.keys(readAuthFile().profiles!)).toEqual(['uid', 'org']); + takeExitCode(); + }); + }); +}); diff --git a/test/local/lib/auth-file.test.ts b/test/local/lib/auth-file.test.ts index 7d92a0e69..40f3400b9 100644 --- a/test/local/lib/auth-file.test.ts +++ b/test/local/lib/auth-file.test.ts @@ -8,7 +8,7 @@ import { ensureAuthFileCurrent, getActiveProfile, lookUpActiveProfile, - removeActiveProfile, + removeProfile, upsertProfile, } from '../../../src/lib/auth-file.js'; import { resolveAuth } from '../../../src/lib/auth.js'; @@ -139,7 +139,7 @@ describe('auth.json v2', () => { await ensureAuthFileCurrent(); expect(existsSync(AUTH_BACKUP_FILE_PATH())).toBe(true); - removeActiveProfile(); + removeProfile(); expect(existsSync(AUTH_FILE_PATH())).toBe(false); expect(existsSync(AUTH_BACKUP_FILE_PATH())).toBe(false); @@ -282,7 +282,7 @@ describe('auth.json v2', () => { it('is discarded by a logout', () => { write({ version: 3, activeProfile: 'uid', profiles: { uid: { username: 'me' } }, token: 'tok' }); - removeActiveProfile(); + removeProfile(); expect(existsSync(AUTH_FILE_PATH())).toBe(false); }); diff --git a/test/local/lib/auth.test.ts b/test/local/lib/auth.test.ts index 0ce3d90f1..b4304a290 100644 --- a/test/local/lib/auth.test.ts +++ b/test/local/lib/auth.test.ts @@ -48,7 +48,11 @@ describe('auth', () => { it('resolves the stored login when nothing overrides it', async () => { await loginWithToken(STORED); - await expect(resolveAuth()).resolves.toEqual({ token: STORED, source: 'stored' }); + await expect(resolveAuth()).resolves.toEqual({ + token: STORED, + source: 'stored', + profile: { id: 'uid', label: 'me' }, + }); }); it('prefers APIFY_TOKEN over the stored login', async () => { @@ -81,7 +85,11 @@ describe('auth', () => { await loginWithToken(STORED); vitest.stubEnv('APIFY_TOKEN', blank); - await expect(resolveAuth()).resolves.toEqual({ token: STORED, source: 'stored' }); + await expect(resolveAuth()).resolves.toEqual({ + token: STORED, + source: 'stored', + profile: { id: 'uid', label: 'me' }, + }); expect(lastErrorMessage()).toBeUndefined(); }, ); @@ -177,7 +185,7 @@ describe('auth', () => { clientState.fail = true; clientState.failWith = apiError(403); - await expect(getLoggedClientOrThrow()).rejects.toThrow('Your stored API token was rejected'); + await expect(getLoggedClientOrThrow()).rejects.toThrow('The stored API token for me was rejected'); }); it('does not blame the token when the API request itself failed', async () => { From 626298944707cc722b6f37ac9bae14b7d0afb824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Thu, 24 Sep 2026 19:55:06 +0200 Subject: [PATCH 2/2] feat: give create the --profile flag for Git-source runs Co-Authored-By: Claude Opus 5.5 --- docs/reference.md | 9 ++++++--- src/commands/create.ts | 2 ++ test/local/commands/auth-profiles.test.ts | 5 +++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index ec889ba72..425937b51 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -336,9 +336,9 @@ USAGE $ apify create [actorName] [--auto-build on|off] [--git-repo ] [--json] [-l javascript|js|typescript|ts|python|py] - [--omit-optional-deps] [--skip-dependency-install] - [--skip-git-init] [--source apify|github|gitlab|bitbucket] - [-t ] + [--omit-optional-deps] [--profile ] + [--skip-dependency-install] [--skip-git-init] + [--source apify|github|gitlab|bitbucket] [-t ] [-u web-scraper|ai-agent|data-pipeline|browser-automation] ARGUMENTS @@ -367,6 +367,9 @@ FLAGS --omit-optional-deps Skip installing optional dependencies. + --profile= The stored account to + use for this command, by name or user ID. See + "apify auth list". --skip-dependency-install Skip installing Actor dependencies. --skip-git-init Skip initializing a git diff --git a/src/commands/create.ts b/src/commands/create.ts index f0b6ae75e..8abce12b5 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -62,6 +62,8 @@ import { export class CreateCommand extends ApifyCommand { static override name = 'create' as const; + static override enableProfileFlag = true; + static override description = 'Creates an Actor project from a template in a new directory. The command automatically initializes a git repository in the newly created Actor directory.'; diff --git a/test/local/commands/auth-profiles.test.ts b/test/local/commands/auth-profiles.test.ts index 0fc396e14..fa380f1aa 100644 --- a/test/local/commands/auth-profiles.test.ts +++ b/test/local/commands/auth-profiles.test.ts @@ -161,14 +161,15 @@ describe('multi-account UX', () => { expect(output).toContain('my-org'); }); - it('is not offered on offline commands', () => { - expect(CreateCommand.enableProfileFlag).toBe(false); + it('is offered on commands that call the API and not on offline ones', () => { expect(InitCommand.enableProfileFlag).toBe(false); registerCommandForHelpGeneration('apify', InitCommand); registerCommandForHelpGeneration('apify', InfoCommand); + registerCommandForHelpGeneration('apify', CreateCommand); expect(renderHelpForCommand(InitCommand)).not.toContain('--profile'); expect(renderHelpForCommand(InfoCommand)).toContain('--profile'); + expect(renderHelpForCommand(CreateCommand)).toContain('--profile'); }); });