From 1c0860625d0df2f920e1e6ef26a4cf4f2227752e Mon Sep 17 00:00:00 2001 From: Mark Robustelli <137117976+mark-robustelli@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:24:06 -0700 Subject: [PATCH 1/7] adding workflow test on pull-request --- .github/workflows/integration-tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 208a6b9..97473e2 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -2,6 +2,10 @@ name: Integration Tests on: workflow_dispatch: + pull_request: + branches: + - main + - mcr/add-test-to-commit jobs: integration-tests: From 3a4b5b214afd7fee71265f09ec4101c5bd1b7c72 Mon Sep 17 00:00:00 2001 From: Mark Robustelli <137117976+mark-robustelli@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:34:59 -0700 Subject: [PATCH 2/7] updating with only main branch for test --- .github/workflows/integration-tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 97473e2..3f98774 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -5,7 +5,6 @@ on: pull_request: branches: - main - - mcr/add-test-to-commit jobs: integration-tests: From 6558d43a015ffc4d947551adebc471a84648ba20 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Tue, 15 Sep 2026 16:13:00 -0400 Subject: [PATCH 3/7] Working state --- package-lock.json | 4 +- src/commands/application-update/index.ts | 1 + src/commands/application-update/update.ts | 130 ++++++++++++++ src/commands/index.ts | 1 + src/utils.ts | 197 +++++++++++++++++++++- 5 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 src/commands/application-update/index.ts create mode 100644 src/commands/application-update/update.ts diff --git a/package-lock.json b/package-lock.json index 5f6ec05..a9655a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fusionauth/cli", - "version": "1.8.4", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fusionauth/cli", - "version": "1.8.4", + "version": "1.9.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/src/commands/application-update/index.ts b/src/commands/application-update/index.ts new file mode 100644 index 0000000..fb7048c --- /dev/null +++ b/src/commands/application-update/index.ts @@ -0,0 +1 @@ +export * from "./update.js"; diff --git a/src/commands/application-update/update.ts b/src/commands/application-update/update.ts new file mode 100644 index 0000000..50c1b4e --- /dev/null +++ b/src/commands/application-update/update.ts @@ -0,0 +1,130 @@ +import { Command } from "@commander-js/extra-typings"; +import { __dirname, logEvent } from '../../utils.js' +import { + ApplyOptions, + ExecutionMetrics, + StepResult, + StepStatus, + ErrorCategory, +} from '../../utilities/apply/types.js'; +import { HTTPClient } from '../../utilities/apply/http-client.js'; +import { apiKeyOption, hostOption } from '../../options.js'; +import path from "node:path"; +import { readFileSync, writeFileSync } from "node:fs"; +import chalk from "chalk"; +import { exampleApplicationBody } from "../../utils.js"; + +function getData(file: string) { + const fileLoc = path.resolve(file) + + const contentBuffer = readFileSync(fileLoc).toString('utf-8') + const contents = JSON.parse(contentBuffer) + + return contents + +} + +function setNestedProps(obj: any, path: string, value: any) { + /* Takes object and dynamically applies a property at any depth + myprop.somedepth.key = "value" coverts to {myprop: {somedepth: {key: value}}} + */ + let schema = obj; + const pList = path.split('.'); + const len = pList.length; + for(var i = 0; i < len-1; i++) { + var elem = pList[i]; + if( !schema[elem] ) schema[elem] = {} + schema = schema[elem]; + } + + schema[pList[len-1]] = value; + + return schema +} + + +function displaySuccess() { + + console.log(chalk.green("Successfully submitted Application update")) + +} + + + + +export function convertOptionsToApiBody(options: any) { + let body: Record = { + application: {} + } + console.log({ options }) + if (options.redirectUrl) { + if (!body?.application?.oauthConfiguration) body.application.oauthConfiguration = {} + body.application.oauthConfiguration.authorizedRedirectURLs = [options.redirectUrl] + } + + console.log(body) + return body +} + +function splitProp(prop: string) { + const [key,value] = prop.split("=") + return {key, value} +} + +const action = async function (id: string, options: Record): Promise { + const { + host = 'http://localhost:9011', + key + } = options + const httpClient = new HTTPClient(host, key); + + if (options?.example) { + console.log(chalk.yellow("Generating example file in current directory")) + writeFileSync('./application.example.json', JSON.stringify(exampleApplicationBody, null, 2)) + console.log(chalk.green(`File created at ${path.resolve('./application.example.json')}`)) + return + } + + if (options?.prop) { + let data = { application: {}} + const {key, value} = splitProp(options.prop) + data.application = await setNestedProps(data.application, `${key}`, value) + console.log(data) + const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) + console.log(response) + return + } + + try { + if (options?.data) { + const data = await getData(options.data) + await httpClient.executeRequest('PATCH', `/api/application/${id}`, { application: data }) + displaySuccess() + return + } + + } catch (e) { + console.log(e) + } + + try { + const apiBody = convertOptionsToApiBody(options) + + const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, apiBody) + console.log(response) + return + } catch (err) { + console.log(err) + } +} +export const appUpdate = new Command() + .command('application:update') + .argument('id', "The FusionAuth Application ID to update") + .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") + .option('--redirect-url ', 'Oauth2.0 Authorized URL') + .option('-p, --prop ') + .option('--example', "Generate an example JSON document showing much of what can be updated via application:update") + .addOption(hostOption) + .addOption(apiKeyOption) + .description('Sets a global config value to allow telemetry to be collected') + .action(action) diff --git a/src/commands/index.ts b/src/commands/index.ts index 07f0f21..16789d9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,3 +1,4 @@ +export * from './application-update/index.js' export * from './check-common-config.js'; export * from './email-create.js'; export * from './email-download.js'; diff --git a/src/utils.ts b/src/utils.ts index ec2da2b..d5250b5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -324,4 +324,199 @@ async function updateGlobalConfig(propertiesToAdd: PropertyToAdd | PropertyToAdd } fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2)) -} \ No newline at end of file +} + +export const exampleApplicationBody = { + "accessControlConfiguration": {}, + "active": true, + "authenticationTokenConfiguration": { + "enabled": false + }, + "data": {}, + "emailConfiguration": {}, + "externalIdentifierConfiguration": {}, + "formConfiguration": { + "adminRegistrationFormId": "UUID", + "selfServiceFormConfiguration": { + "requireCurrentPasswordOnPasswordChange": false + } + }, + "id": "UUID", + "insertInstant": 1234, + "jwtConfiguration": { + "accessTokenKeyId": "UUID", + "enabled": true, + "idTokenKeyId": "e73fe48a-1527-43cf-9b66-9eaa4c44d909", + "refreshTokenExpirationPolicy": "Fixed", + "refreshTokenOneTimeUseConfiguration": { + "gracePeriodInSeconds": 0 + }, + "refreshTokenSlidingWindowConfiguration": { + "maximumTimeToLiveInMinutes": 43200 + }, + "refreshTokenTimeToLiveInMinutes": 43200, + "refreshTokenUsagePolicy": "Reusable", + "timeToLiveInSeconds": 3600 + }, + "lambdaConfiguration": {}, + "lastUpdateInstant": 1789396293195, + "loginConfiguration": { + "allowTokenRefresh": false, + "generateRefreshTokens": false, + "requireAuthentication": true + }, + "multiFactorConfiguration": { + "email": {}, + "sms": {}, + "voice": {} + }, + "name": "Name string", + "oauthConfiguration": { + "authorizedOriginURLs": [ + "http://localhost:3000" + ], + "authorizedRedirectURLs": [ + "http://localhost:1002" + ], + "authorizedResourceUris": [ + "http://localhost:3000" + ], + "authorizedURLValidationPolicy": "ExactMatch", + "clientAuthenticationPolicy": "NotRequiredWhenUsingPKCE", + "clientId": "UUID", + "clientSecret": "super-secret-secret-that-should-be-regenerated-for-production", + "consentMode": "AlwaysPrompt", + "debug": true, + "enabledGrants": [ + "authorization_code", + "refresh_token" + ], + "generateRefreshTokens": true, + "logoutBehavior": "AllApplications", + "logoutURL": "http://localhost:3000", + "proofKeyForCodeExchangePolicy": "Required", + "providedScopePolicy": { + "address": { + "enabled": true, + "required": false + }, + "email": { + "enabled": true, + "required": false + }, + "phone": { + "enabled": true, + "required": false + }, + "profile": { + "enabled": true, + "required": false + } + }, + "relationship": "FirstParty", + "requireClientAuthentication": true, + "requireRegistration": true, + "scopeHandlingPolicy": "Strict", + "unknownScopePolicy": "Reject" + }, + "passwordlessConfiguration": { + "emailLoginStrategy": "ClickableLink", + "enabled": false, + "phoneLoginStrategy": "FormField" + }, + "phoneConfiguration": {}, + "registrationConfiguration": { + "birthDate": { + "enabled": false, + "required": false + }, + "completeRegistration": false, + "confirmPassword": false, + "enabled": true, + "firstName": { + "enabled": false, + "required": false + }, + "fullName": { + "enabled": false, + "required": false + }, + "lastName": { + "enabled": false, + "required": false + }, + "loginIdType": "email", + "middleName": { + "enabled": false, + "required": false + }, + "mobilePhone": { + "enabled": false, + "required": false + }, + "preferredLanguages": { + "enabled": false, + "required": false + }, + "type": "basic" + }, + "registrationDeletePolicy": { + "unverified": { + "enabled": false, + "numberOfDaysToRetain": 120 + } + }, + "roles": [], + "samlv2Configuration": { + "assertionEncryptionConfiguration": { + "digestAlgorithm": "SHA256", + "enabled": false, + "encryptionAlgorithm": "AES256GCM", + "keyLocation": "Child", + "keyTransportAlgorithm": "RSA_OAEP", + "maskGenerationFunction": "MGF1_SHA1" + }, + "authorizedRedirectURLs": [], + "debug": false, + "enabled": false, + "initiatedLogin": { + "enabled": false, + "nameIdFormat": "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + }, + "loginHintConfiguration": { + "enabled": true, + "parameterName": "login_hint" + }, + "logout": { + "behavior": "AllParticipants", + "requireSignedRequests": false, + "singleLogout": { + "enabled": false, + "xmlSignatureC14nMethod": "exclusive_with_comments" + }, + "xmlSignatureC14nMethod": "exclusive_with_comments" + }, + "requireSignedRequests": false, + "xmlSignatureC14nMethod": "exclusive_with_comments", + "xmlSignatureLocation": "Assertion" + }, + "scopes": [], + "state": "Active", + "tenantId": "d7d09513-a3f5-401c-9685-34ab6c552453", + "universalConfiguration": { + "universal": false + }, + "unverified": { + "behavior": "Allow" + }, + "verifyRegistration": false, + "webAuthnConfiguration": { + "bootstrapWorkflow": { + "enabled": false + }, + "enabled": false, + "reauthenticationWorkflow": { + "enabled": false + } + } +} \ No newline at end of file From 82d2f8c7a3ab625bf4e76f67cdb513b6ef964e97 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Wed, 16 Sep 2026 14:36:01 -0400 Subject: [PATCH 4/7] Feedback version --- src/commands/application-update/update.ts | 48 ++++++++++------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/src/commands/application-update/update.ts b/src/commands/application-update/update.ts index 50c1b4e..4a21917 100644 --- a/src/commands/application-update/update.ts +++ b/src/commands/application-update/update.ts @@ -78,24 +78,23 @@ const action = async function (id: string, options: Record): Promis } = options const httpClient = new HTTPClient(host, key); - if (options?.example) { - console.log(chalk.yellow("Generating example file in current directory")) - writeFileSync('./application.example.json', JSON.stringify(exampleApplicationBody, null, 2)) - console.log(chalk.green(`File created at ${path.resolve('./application.example.json')}`)) - return - } + try { - if (options?.prop) { - let data = { application: {}} - const {key, value} = splitProp(options.prop) - data.application = await setNestedProps(data.application, `${key}`, value) - console.log(data) - const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) - console.log(response) - return - } + if (options?.example) { + console.log(chalk.yellow("Generating example file in current directory")) + writeFileSync('./application.example.json', JSON.stringify(exampleApplicationBody, null, 2)) + console.log(chalk.green(`File created at ${path.resolve('./application.example.json')}`)) + return + } + + if (options?.prop) { + let data = { application: {}} + const {key, value} = splitProp(options.prop) + data.application = await setNestedProps(data.application, `${key}`, value) + const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) + return + } - try { if (options?.data) { const data = await getData(options.data) await httpClient.executeRequest('PATCH', `/api/application/${id}`, { application: data }) @@ -103,28 +102,23 @@ const action = async function (id: string, options: Record): Promis return } + const apiBody = convertOptionsToApiBody(options) + await httpClient.executeRequest('PATCH', `/api/application/${id}`, apiBody) + return + } catch (e) { console.log(e) } - try { - const apiBody = convertOptionsToApiBody(options) - - const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, apiBody) - console.log(response) - return - } catch (err) { - console.log(err) - } } export const appUpdate = new Command() .command('application:update') .argument('id', "The FusionAuth Application ID to update") .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") .option('--redirect-url ', 'Oauth2.0 Authorized URL') - .option('-p, --prop ') + .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') .option('--example', "Generate an example JSON document showing much of what can be updated via application:update") .addOption(hostOption) .addOption(apiKeyOption) - .description('Sets a global config value to allow telemetry to be collected') + .description('Updates an application with data provided via a file, a property, or a command flag.') .action(action) From aabc3cb0ff3d800d4cb9b2cdc3d456598e6c20fb Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Wed, 16 Sep 2026 14:42:18 -0400 Subject: [PATCH 5/7] Adds readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 884cffe..d44a0aa 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ fusionauth --help; ``` Currently, the CLI supports the following commands: +- Application Update + - `fusionauth application:update ` - Updates an application with provided data + - `--host` - Required. Provide a FusionAuth host URL or add it via an environment variable (`FUSIONAUTH_HOST`) + - `--key` - Required. Provide an API key with permissions for updating the given application or add via an environment variable( `FUSIONAUTH_API_KEY`) + - `-d, --data ` - Provide a data file containing all the properties you wish to update constructed like the body of an application update + - `-p, --prop ` - Update a single property in the application + - `--redirect-url ` - Update the Authorized redirect URL for your applicatoin + - `--example` - Create an example file with editable properties to use in conjunction with the `--data` flag - Common config check - `fusionauth check:common-config` - Checks to make sure common configuration settings are set. - Emails From 1de5c5477f47741e3c7510ba4b916e5b4df984a9 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 17 Sep 2026 13:55:29 -0400 Subject: [PATCH 6/7] fix(update): adjust id argument into named required option --- src/commands/application-update/update.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/commands/application-update/update.ts b/src/commands/application-update/update.ts index 4a21917..9a88e1e 100644 --- a/src/commands/application-update/update.ts +++ b/src/commands/application-update/update.ts @@ -71,10 +71,11 @@ function splitProp(prop: string) { return {key, value} } -const action = async function (id: string, options: Record): Promise { +const action = async function (options: Record): Promise { const { host = 'http://localhost:9011', - key + key, + id } = options const httpClient = new HTTPClient(host, key); @@ -113,7 +114,7 @@ const action = async function (id: string, options: Record): Promis } export const appUpdate = new Command() .command('application:update') - .argument('id', "The FusionAuth Application ID to update") + .requiredOption('-i, --id ', "The FusionAuth Application ID to update") .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") .option('--redirect-url ', 'Oauth2.0 Authorized URL') .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') From c00a2fed122c2f11f84db74f9e533178e0381918 Mon Sep 17 00:00:00 2001 From: Bryan Robinson Date: Thu, 17 Sep 2026 16:25:14 -0400 Subject: [PATCH 7/7] fix(update): allow for multiple --prop variables --- src/commands/application-update/update.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/application-update/update.ts b/src/commands/application-update/update.ts index 9a88e1e..5b2c4ab 100644 --- a/src/commands/application-update/update.ts +++ b/src/commands/application-update/update.ts @@ -90,8 +90,8 @@ const action = async function (options: Record): Promise { if (options?.prop) { let data = { application: {}} - const {key, value} = splitProp(options.prop) - data.application = await setNestedProps(data.application, `${key}`, value) + const splitprops = options.prop.map((prop:string) => splitProp(prop)) + splitprops.forEach((prop:any) => setNestedProps(data.application, prop.key, prop.value)) const response = await httpClient.executeRequest('PATCH', `/api/application/${id}`, data) return } @@ -117,7 +117,7 @@ export const appUpdate = new Command() .requiredOption('-i, --id ', "The FusionAuth Application ID to update") .option('-d, --data ', "Apply changes from a named file of JSON that matches the API body for an application update (ignores other flags)") .option('--redirect-url ', 'Oauth2.0 Authorized URL') - .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') + .option('-p, --prop ', 'Updates a single property from the application --prop name="My New Name" or --prop oauthConfiguration.authorizedOriginURLs="http://localhost:9011" ') .option('--example', "Generate an example JSON document showing much of what can be updated via application:update") .addOption(hostOption) .addOption(apiKeyOption)