diff --git a/docs/reference.md b/docs/reference.md index e260a721b..6a56fa1db 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -753,12 +753,28 @@ DESCRIPTION Manages Actor build processes and versioning. SUBCOMMANDS - builds rm Permanently removes an Actor build from the Apify - platform. - builds ls Lists all builds of the Actor. - builds log Prints the log of a specific build. - builds info Prints information about a specific build. - builds create Creates a new build of the Actor. + builds add-tag Adds a tag to a specific Actor build. + builds remove-tag Removes a tag from a specific Actor build. + builds rm Permanently removes an Actor build from + the Apify platform. + builds ls Lists all builds of the Actor. + builds log Prints the log of a specific build. + builds info Prints information about a specific build. + builds create Creates a new build of the Actor. +``` + +##### `apify builds add-tag` + +```sh +DESCRIPTION + Adds a tag to a specific Actor build. + +USAGE + $ apify builds add-tag -b -t + +FLAGS + -b, --build= The build ID to tag. + -t, --tag= The tag to add to the build. ``` ##### `apify builds create` / `apify actors build` @@ -837,6 +853,22 @@ FLAGS --offset= Number of builds that will be skipped. ``` +##### `apify builds remove-tag` + +```sh +DESCRIPTION + Removes a tag from a specific Actor build. + +USAGE + $ apify builds remove-tag -b -t [-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. +``` + ##### `apify builds rm` ```sh diff --git a/package.json b/package.json index d3f0a1f48..b87e60ee3 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@skyra/jaro-winkler": "^1.1.1", "adm-zip": "~0.5.15", "ajv": "~8.17.1", - "apify-client": "^2.14.0", + "apify-client": "~2.22.0", "archiver": "~7.0.1", "axios": "^1.11.0", "chalk": "~5.6.0", diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index aa5b4d3c8..5fbb13b41 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -51,10 +51,12 @@ const categories: Record = { 'actor-build': [ // { command: Commands.builds }, + { command: Commands.buildsAddTag }, { command: Commands.buildsCreate, aliases: [Commands.actorsBuild] }, { command: Commands.buildsInfo }, { command: Commands.buildsLog }, { command: Commands.buildsLs }, + { command: Commands.buildsRemoveTag }, { command: Commands.buildsRm }, ], 'actor-run': [ diff --git a/src/commands/builds/_index.ts b/src/commands/builds/_index.ts index 8cc466903..936397bb4 100644 --- a/src/commands/builds/_index.ts +++ b/src/commands/builds/_index.ts @@ -1,8 +1,10 @@ import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { BuildsAddTagCommand } from './add-tag.js'; import { BuildsCreateCommand } from './create.js'; import { BuildsInfoCommand } from './info.js'; import { BuildsLogCommand } from './log.js'; import { BuildsLsCommand } from './ls.js'; +import { BuildsRemoveTagCommand } from './remove-tag.js'; import { BuildsRmCommand } from './rm.js'; export class BuildsIndexCommand extends ApifyCommand { @@ -12,6 +14,8 @@ export class BuildsIndexCommand extends ApifyCommand static override subcommands = [ // + BuildsAddTagCommand, + BuildsRemoveTagCommand, BuildsRmCommand, BuildsLsCommand, BuildsLogCommand, diff --git a/src/commands/builds/add-tag.ts b/src/commands/builds/add-tag.ts new file mode 100644 index 000000000..1472f97c4 --- /dev/null +++ b/src/commands/builds/add-tag.ts @@ -0,0 +1,92 @@ +import type { ActorTaggedBuild, ApifyApiError } from 'apify-client'; +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Flags } from '../../lib/command-framework/flags.js'; +import { error, success, warning } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow } from '../../lib/utils.js'; + +export class BuildsAddTagCommand extends ApifyCommand { + static override name = 'add-tag' as const; + + static override description = 'Adds a tag to a specific Actor build.'; + + static override flags = { + build: Flags.string({ + char: 'b', + description: 'The build ID to tag.', + required: true, + }), + tag: Flags.string({ + char: 't', + description: 'The tag to add to the build.', + required: true, + }), + }; + + async run() { + const { build: buildId, tag } = this.flags; + + const apifyClient = await getLoggedClientOrThrow(); + + const build = await apifyClient.build(buildId).get(); + + if (!build) { + error({ message: `Build with ID "${buildId}" was not found on your account.`, stdout: true }); + return; + } + + if (build.status !== 'SUCCEEDED') { + error({ + message: `Build with ID "${buildId}" has status "${build.status}". Only successful builds can be tagged.`, + stdout: true, + }); + return; + } + + const actor = await apifyClient.actor(build.actId).get(); + + if (!actor) { + error({ message: `Actor with ID "${build.actId}" was not found.`, stdout: true }); + return; + } + + // Check if this tag already points to the same build + const existingTaggedBuilds = (actor.taggedBuilds ?? {}) as Record; + const existingTagData = existingTaggedBuilds[tag]; + + if (existingTagData?.buildId === buildId) { + warning({ + message: `Build "${buildId}" is already tagged as "${tag}".`, + stdout: true, + }); + return; + } + + try { + // Update only the specific tag + await apifyClient.actor(build.actId).update({ + taggedBuilds: { + [tag]: { + buildId: build.id, + }, + }, + } as never); + + const previousBuildInfo = existingTagData?.buildNumber + ? ` (previously pointed to build ${chalk.gray(existingTagData.buildNumber)})` + : ''; + + success({ + message: `Tag "${chalk.yellow(tag)}" added to build ${chalk.gray(build.buildNumber)} (${chalk.gray(buildId)})${previousBuildInfo}`, + stdout: true, + }); + } catch (err) { + const casted = err as ApifyApiError; + error({ + message: `Failed to add tag "${tag}" to build "${buildId}".\n ${casted.message || casted}`, + stdout: true, + }); + } + } +} diff --git a/src/commands/builds/remove-tag.ts b/src/commands/builds/remove-tag.ts new file mode 100644 index 000000000..6ca252e8e --- /dev/null +++ b/src/commands/builds/remove-tag.ts @@ -0,0 +1,107 @@ +import type { ActorTaggedBuild, ApifyApiError } from 'apify-client'; +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Flags } from '../../lib/command-framework/flags.js'; +import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; +import { error, info, success } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow } from '../../lib/utils.js'; + +export class BuildsRemoveTagCommand extends ApifyCommand { + static override name = 'remove-tag' as const; + + static override description = 'Removes a tag from a specific Actor build.'; + + static override flags = { + build: Flags.string({ + char: 'b', + description: 'The build ID to remove the tag from.', + required: true, + }), + tag: Flags.string({ + char: 't', + description: 'The tag to remove from the build.', + required: true, + }), + yes: Flags.boolean({ + char: 'y', + description: 'Automatic yes to prompts; assume "yes" as answer to all prompts.', + default: false, + }), + }; + + async run() { + const { build: buildId, tag, yes } = this.flags; + + const apifyClient = await getLoggedClientOrThrow(); + + const build = await apifyClient.build(buildId).get(); + + if (!build) { + error({ message: `Build with ID "${buildId}" was not found on your account.`, stdout: true }); + return; + } + + const actor = await apifyClient.actor(build.actId).get(); + + if (!actor) { + error({ message: `Actor with ID "${build.actId}" was not found.`, stdout: true }); + return; + } + + const existingTaggedBuilds = (actor.taggedBuilds ?? {}) as Record; + const existingTagData = existingTaggedBuilds[tag]; + + // Check if the tag exists + if (!existingTagData) { + error({ + message: `Tag "${tag}" does not exist on Actor "${actor.name}".`, + stdout: true, + }); + return; + } + + // Check if the tag points to this build + if (existingTagData.buildId !== buildId) { + error({ + message: `Tag "${tag}" is not associated with build "${buildId}". It points to build "${existingTagData.buildNumber}" (${existingTagData.buildId}).`, + stdout: true, + }); + return; + } + + // Confirm removal + const confirmed = await useYesNoConfirm({ + message: `Are you sure you want to remove tag "${chalk.yellow(tag)}" from build ${chalk.gray(build.buildNumber)}?`, + providedConfirmFromStdin: yes || undefined, + }); + + if (!confirmed) { + info({ + message: `Tag removal was canceled.`, + stdout: true, + }); + return; + } + + try { + // To remove a tag, set it to null + await apifyClient.actor(build.actId).update({ + taggedBuilds: { + [tag]: null, + }, + } as never); + + success({ + message: `Tag "${chalk.yellow(tag)}" removed from build ${chalk.gray(build.buildNumber)} (${chalk.gray(buildId)})`, + stdout: true, + }); + } catch (err) { + const casted = err as ApifyApiError; + error({ + message: `Failed to remove tag "${tag}" from build "${buildId}".\n ${casted.message || casted}`, + stdout: true, + }); + } + } +} diff --git a/test/api/commands/builds/tags.test.ts b/test/api/commands/builds/tags.test.ts new file mode 100644 index 000000000..709bc6c67 --- /dev/null +++ b/test/api/commands/builds/tags.test.ts @@ -0,0 +1,201 @@ +import type { Actor, Build } from 'apify-client'; + +import { testRunCommand } from '../../../../src/lib/command-framework/apify-command.js'; +import { waitForBuildToFinish } from '../../../__setup__/build-utils.js'; +import { testUserClient } from '../../../__setup__/config.js'; +import { safeLogin, useAuthSetup } from '../../../__setup__/hooks/useAuthSetup.js'; +import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js'; +import { useUniqueId } from '../../../__setup__/hooks/useUniqueId.js'; + +const { BuildsAddTagCommand } = await import('../../../../src/commands/builds/add-tag.js'); +const { BuildsRemoveTagCommand } = await import('../../../../src/commands/builds/remove-tag.js'); + +const ACTOR_NAME = useUniqueId('cli-builds-tag-test'); + +useAuthSetup({ perTest: false }); + +const { lastLogMessage } = useConsoleSpy(); + +const TEST_TIMEOUT = 120_000; + +describe('[api] apify builds add-tag / remove-tag', () => { + let testActor: Actor; + let testBuild: Build; + + beforeAll(async () => { + await safeLogin(); + + // Create a test actor with a simple source + testActor = await testUserClient.actors().create({ + name: ACTOR_NAME, + versions: [ + { + versionNumber: '0.0', + sourceType: 'SOURCE_FILES' as any, + buildTag: 'latest', + sourceFiles: [ + { + name: 'Dockerfile', + format: 'TEXT', + content: 'FROM apify/actor-node:20\nCOPY . ./\nCMD ["node", "main.js"]', + }, + { + name: 'main.js', + format: 'TEXT', + content: 'console.log("Hello");', + }, + ], + }, + ], + }); + + // Build the actor and wait for it to finish + const buildStarted = await testUserClient.actor(testActor.id).build('0.0'); + testBuild = (await waitForBuildToFinish(testUserClient, buildStarted.id))!; + }, TEST_TIMEOUT); + + afterAll(async () => { + if (testActor) { + await testUserClient.actor(testActor.id).delete(); + } + }); + + describe('builds add-tag', () => { + it('should add a tag to a build', async () => { + await testRunCommand(BuildsAddTagCommand, { + flags_build: testBuild.id, + flags_tag: 'beta', + }); + + expect(lastLogMessage().toLowerCase()).toContain('tag "beta" added'); + + // Verify via API + const actor = await testUserClient.actor(testActor.id).get(); + expect(actor?.taggedBuilds?.beta?.buildId).toBe(testBuild.id); + }); + + it('should show error when build is already tagged with the same tag', async () => { + // First, ensure the tag exists + await testRunCommand(BuildsAddTagCommand, { + flags_build: testBuild.id, + flags_tag: 'existing-tag', + }); + + // Try to add the same tag again + await testRunCommand(BuildsAddTagCommand, { + flags_build: testBuild.id, + flags_tag: 'existing-tag', + }); + + expect(lastLogMessage()).include('already tagged'); + }); + + it('should show error when build does not exist', async () => { + await testRunCommand(BuildsAddTagCommand, { + flags_build: 'nonexistent-build-id', + flags_tag: 'test-tag', + }); + + expect(lastLogMessage()).include('not found'); + }); + + it( + 'should show previous build info when reassigning a tag', + async () => { + // Tag the build with 'reassign-test' + await testRunCommand(BuildsAddTagCommand, { + flags_build: testBuild.id, + flags_tag: 'reassign-test', + }); + + // Create another build + const buildStarted2 = await testUserClient.actor(testActor.id).build('0.0'); + const testBuild2 = (await waitForBuildToFinish(testUserClient, buildStarted2.id))!; + + // Reassign the tag to the new build + await testRunCommand(BuildsAddTagCommand, { + flags_build: testBuild2.id, + flags_tag: 'reassign-test', + }); + + expect(lastLogMessage()).include('previously pointed to build'); + + // Verify via API + const actor = await testUserClient.actor(testActor.id).get(); + expect(actor?.taggedBuilds?.['reassign-test']?.buildId).toBe(testBuild2.id); + }, + TEST_TIMEOUT, + ); + }); + + describe('builds remove-tag', () => { + it('should remove a tag from a build', async () => { + // First add a tag + await testRunCommand(BuildsAddTagCommand, { + flags_build: testBuild.id, + flags_tag: 'to-remove', + }); + + // Verify it was added + let actor = await testUserClient.actor(testActor.id).get(); + expect(actor?.taggedBuilds?.['to-remove']?.buildId).toBe(testBuild.id); + + // Remove the tag with --yes flag to skip confirmation + await testRunCommand(BuildsRemoveTagCommand, { + flags_build: testBuild.id, + flags_tag: 'to-remove', + flags_yes: true, + }); + + expect(lastLogMessage()).include('Tag "to-remove" removed'); + + // Verify via API + actor = await testUserClient.actor(testActor.id).get(); + expect(actor?.taggedBuilds?.['to-remove']).toBeUndefined(); + }); + + it('should show error when tag does not exist', async () => { + await testRunCommand(BuildsRemoveTagCommand, { + flags_build: testBuild.id, + flags_tag: 'nonexistent-tag', + flags_yes: true, + }); + + expect(lastLogMessage()).include('does not exist'); + }); + + it( + 'should show error when tag points to a different build', + async () => { + // Create another build and tag it + const buildStarted2 = await testUserClient.actor(testActor.id).build('0.0'); + const testBuild2 = (await waitForBuildToFinish(testUserClient, buildStarted2.id))!; + + await testRunCommand(BuildsAddTagCommand, { + flags_build: testBuild2.id, + flags_tag: 'other-build-tag', + }); + + // Try to remove the tag using the first build's ID + await testRunCommand(BuildsRemoveTagCommand, { + flags_build: testBuild.id, + flags_tag: 'other-build-tag', + flags_yes: true, + }); + + expect(lastLogMessage()).include('not associated with build'); + }, + TEST_TIMEOUT, + ); + + it('should show error when build does not exist', async () => { + await testRunCommand(BuildsRemoveTagCommand, { + flags_build: 'nonexistent-build-id', + flags_tag: 'test-tag', + flags_yes: true, + }); + + expect(lastLogMessage()).include('not found'); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index c7b025a78..6b6023b48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2300,7 +2300,7 @@ __metadata: adm-zip: "npm:~0.5.15" ajv: "npm:~8.17.1" apify: "npm:^3.2.4" - apify-client: "npm:^2.14.0" + apify-client: "npm:~2.22.0" archiver: "npm:~7.0.1" axios: "npm:^1.11.0" chalk: "npm:~5.6.0" @@ -2348,7 +2348,7 @@ __metadata: languageName: unknown linkType: soft -"apify-client@npm:^2.14.0, apify-client@npm:^2.17.0": +"apify-client@npm:^2.17.0": version: 2.21.0 resolution: "apify-client@npm:2.21.0" dependencies: @@ -2368,6 +2368,26 @@ __metadata: languageName: node linkType: hard +"apify-client@npm:~2.22.0": + version: 2.22.0 + resolution: "apify-client@npm:2.22.0" + dependencies: + "@apify/consts": "npm:^2.42.0" + "@apify/log": "npm:^2.2.6" + "@apify/utilities": "npm:^2.23.2" + "@crawlee/types": "npm:^3.3.0" + ansi-colors: "npm:^4.1.1" + async-retry: "npm:^1.3.3" + axios: "npm:^1.6.7" + content-type: "npm:^1.0.5" + ow: "npm:^0.28.2" + proxy-agent: "npm:^6.5.0" + tslib: "npm:^2.5.0" + type-fest: "npm:^4.0.0" + checksum: 10c0/9fb6f22665c7dd8ea19b2e92a7ce9ee3bdcd9af200acb251d6ca1ddd4d6bdcba532d7934da540a6f2447df633590f8a03c4312e1393460c9bf5219b71177cede + languageName: node + linkType: hard + "apify@npm:^3.2.4": version: 3.5.3 resolution: "apify@npm:3.5.3"