Giter Club home page Giter Club logo

blackbaud-to-google-group-sync's Introduction

Blackbaud-to-Google Group Sync

Sync membership of Blackbaud LMS community groups to Google Groups

The basic idea of this script is that it will perform a one-way sync of membership in a subset of Blackbaud community groups into specific Google Groups. Thus, Blackbaud community groups can be set up with SmartGroup rosters that automatically refresh, the sync process runs regularly, and Google Group memberships are updated automatically to match, allowing for the creation of SIS-driven, role-based groups in Google.

Setup

You need to prep the repository from a development workstation. You will need the following toolchain installed:

In your shell:

git clone https://github.com/groton-school/blackbaud-to-google-group-sync.git
cd blackbaud-to-google-group-sync
npm install
composer install
npm run setup

The setup script will prompt you with a series of interactive questions to enter credentials from Blackbaud SKY and to make choices about configuration in Google Cloud, including app access.

Calling the setup script with the --help flag describes its usage (which includes the ability to pass in all user-configurable values from the command line) -- although it will still confirm those values interactively as it runs.

./scripts/setup.js --help

blackbaud-to-google-group-sync's People

Contributors

battis avatar dependabot[bot] avatar

Watchers

 avatar  avatar

blackbaud-to-google-group-sync's Issues

max 10 keys available

Need to delete one to create one

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/005060d2298556b60ef3ba9a414efd20f53aa3e2/scripts/setup.js#L227

  /*
   * FIXME max 10 keys available
   *  Need to delete one to create one
   */
  const credentialsPath = `.cache/${serviceAccount.uniqueId}.json`;
  gcloud.invoke(
    `iam service-accounts keys create ${credentialsPath} --iam-account=${serviceAccount.email}`
  );
  if (!delegated) {
    const url =
      'https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md';
    open(url);
    await confirm({
      message: `The Service Account Unique ID is ${lib.value(
        serviceAccount.uniqueId
      )}
Confirm that ${lib.value(
        delegatedAdmin
      )} has followed the directions at ${lib.url(url)}`
    });
  }

need to handle _not_ being able to refresh!

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/c6f88d74420b3f7666bfa2bacecd5ad04fcd59e1/src/server/Blackbaud/SKY.php#L105

        return self::$cache;
    }

    public static function getToken(
        $server,
        $session,
        $get,
        $interactive = true
    ) {
        $token = new AccessToken(Secrets::get(self::Bb_TOKEN, true));
        // acquire a Bb SKY API access token
        /** @var AccessToken|null $token **/
        if (empty($token)) {
            if ($interactive) {
                // interactively acquire a new Bb access token
                if (false === isset($get[self::CODE])) {
                    $authorizationUrl = self::api()->getAuthorizationUrl();
                    $session[self::OAuth2_STATE] = self::api()->getState();
                    // TODO wipe existing token?
                    self::cache()->set(
                        self::Request_URI,
                        $server['REQUEST_URI']
                    );
                    header("Location: $authorizationUrl");
                    exit();
                } elseif (
                    empty($get[self::STATE]) ||
                    (isset($session[self::OAuth2_STATE]) &&
                        $get[self::STATE] !== $session[self::OAuth2_STATE])
                ) {
                    if (isset($session[self::OAuth2_STATE])) {
                        unset($session[self::OAuth2_STATE]);
                    }

                    throw new Exception(
                        json_encode(['error' => 'invalid state'])
                    );
                } else {
                    $token = self::api()->getAccessToken(
                        self::AUTHORIZATION_CODE,
                        [
                            self::CODE => $get[self::CODE],
                        ]
                    );
                    Secrets::set(self::Bb_TOKEN, $token);
                }
            } else {
                return null;
            }
        } elseif ($token->hasExpired()) {
            // use refresh token to get new Bb access token
            $newToken = self::api()->getAccessToken(self::REFRESH_TOKEN, [
                self::REFRESH_TOKEN => $token->getRefreshToken(),
            ]);
            // FIXME need to handle _not_ being able to refresh!
            Secrets::set(self::Bb_TOKEN, $newToken);
            $token = $newToken;
        } else {
            self::api()->setAccessToken($token);

deal with pagination (1000 rows per page, probably not an immediate huge deal)

// TODO deal with pagination (1000 rows per page, probably not an immediate huge deal)

            step($bbGroup->getName());
            dump($bbGroup, "bbGroup");
            $response = $school->get("lists/advanced/{$list["id"]}");
            // TODO deal with pagination (1000 rows per page, probably not an immediate huge deal)
            /** @var Member[] */
            $bbMembers = [];
            foreach ($response["results"]["rows"] as $data) {

output directions/links

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/d87e266cf36df1864a097c23f7cfa7ac256f2f22/scripts/setup.js#L176

(async () => {
  const process = require('process');
  const fs = require('fs');
  const child = require('child_process');
  const { rword } = require('rword');
  const path = require('path');
  const readline = require('readline/promises').createInterface({
    input: process.stdin,
    output: process.stdout
  });

  rword.load('big');
  function createProjectId() {
    const word1 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * 7)
    });
    const word2 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * (30 - 8 - word1.length - 4))
    });
    return `${word1}-${word2}-${Math.floor(99999 + Math.random() * 900001)}`;
  }

  const exec = (command) => child.execSync(command, { stdio: 'inherit' });

  function versionTest({
    name,
    download = undefined,
    command = undefined,
    fail = true
  }) {
    command = command || `${name} --version`;
    if (!/\d+\.\d/.test(child.execSync(command))) {
      if (fail) {
        console.error(
          `${name} is required${download ? `, install from ${download}` : ''}`
        );
        process.exit(1);
      } else {
        return false;
      }
    }
    return true;
  }

  // TODO configure project name
  const appName = 'Blackbaud-to-Google Group Sync';
  // TODO configure project ID
  const projectId = createProjectId();

  const flags = '--quiet --format=json';
  const flagsWithProject = `${flags} --project=${projectId}`;
  function gcloud(command, withProjectId = true) {
    let actualFlags = flagsWithProject;
    if (withProjectId === null) {
      actualFlags = '';
    } else if (actualFlags === false) {
      actualFlags = flags;
    }
    const result = child.execSync(`gcloud ${command} ${actualFlags} `);
    try {
      return JSON.parse(result);
    } catch (e) {
      return result;
    }
  }

  async function choiceFrom({ prompt, list, display, defaultChoice = 1 }) {
    switch (list.length) {
      case 0:
        throw new Error('empty list');
      case 1:
        defaultChoice = 1;
        break;
      default:
        if (defaultChoice > list.length) {
          defaultChoice = undefined;
        }
    }
    console.log(prompt);
    list.forEach((item, i) => console.log(`  ${i}. ${item[display]}`));

    let choice;
    let question = `(${defaultChoice > 1 ? 1 : '[1]'}-${defaultChoice < list.length ? `[${defaultChoice}]` : ''
      }-${defaultChoice === list.length ? `[${defaultChoice}]` : list.length})`;
    do {
      choice = await readline.question(question);
      if (choice.length) {
        choice = (parseInt(choice) || 0) - 1;
      } else {
        choice = defaultChoice - 1;
      }
    } while (choice < 0 || choice >= list.length);
    return list[choice];
  }

  async function nonEmpty({ prompt }) {
    let response;
    do {
      response = await readline.question(prompt);
    } while (response.length === 0);
    return response;
  }

  async function untilBlank({ prompt }) {
    const responses = [];
    let response;
    do {
      response = await readline.question(`${prompt} [<Enter> to end]`);
      if (response.length > 0) {
        responses.push(response);
      }
    } while (response.length > 0);
    return responses;
  }

  // set project root as cwd
  process.chdir(path.join(__dirname, '..'));

  // test for CLI dependencies
  versionTest({
    name: 'npm',
    download: 'https://nodejs.org/'
  });
  versionTest({
    name: 'composer',
    download: 'https://getcomposer.org/'
  });
  versionTest({
    name: 'gcloud',
    download: 'https://cloud.google.com/sdk/docs/install'
  });
  const pnpm = versionTest({
    name: 'pnpm',
    dowload: 'https://pnpm.io/',
    fail: false
  });

  // install dependencies
  exec(`${pnpm ? 'pnpm' : 'npm'} install`);
  exec('composer install');

  // create a new project
  let response = gcloud(
    `projects create --name="${appName}" ${projectId}`,
    false
  );
  if (/error/i.test(response)) {
    console.error(response);
    process.exit(1);
  }

  // enable billing
  gcloud(`components install beta`, null);
  const accountId = path.basename(
    (
      await choiceFrom({
        prompt: 'Select a billing account for this project',
        list: gcloud(`beta billing accounts list --filter=open=true`),
        display: 'displayName'
      })
    ).name
  );
  gcloud(
    `beta billing projects link ${projectId} --billing-account="${accountId}`,
    false
  );

  // enable APIs
  gcloud(`services enable admin.googleapis.com`);
  gcloud(`services enable iap.googleapis.com`);
  gcloud(`services enable secretmanager.googleapis.com`);
  gcloud(`services enable cloudscheduler.googleapis.com`);
  gcloud(`services enable appengine.googleapis.com`);

  // configure workspace admin as owner
  // TODO output directions/links
  const googleDelegatedAdmin = await readline.question(
    'Enter the Google ID for a Workspace Admin who will delegate authority for this app'
  );
  gcloud(
    `projects add-iam-policy-binding ${projectId} --member="user:${googleDelegatedAdmin}" --role="roles/owner"`,
    false
  );

  // create App Engine instance
  // TODO set default region us-east4
  const region = await choiceFrom({
    prompt: 'Select a region for the app engine instance',
    list: gcloud(`app regions list`),
    display: 'region'
  }).region;
  gcloud(`app create --region=${region}`);
  const url = `https://${gcloud(`app describe`).defaultHostname}`;
  fs.writeFileSync(
    '.env',
    `PROJECT=${projectId}
URL=${url}`
  );

  // create default instance so IAP can be configured
  exec(`npm run build`);
  exec(`npm run deploy`);

  // configure IAP (and OAuth consent screen)
  const supportEmail = await nonEmpty({
    prompt: 'Enter a support email address for the app'
  });
  const brand = gcloud(
    `iap oauth-brands create --application_title${appName} --support_email=${supportEmail}`
  ).name;
  const oauth = gcloud(
    `iap oauth-clients create ${brand} --display_name=IAP-App-Engine-app`
  );
  gcloud(
    `iap web enable --resource-type=app-engine --oauth2-client-id=${path.basename(
      oauth.name
    )} --oauth2-client-secret=${oauth.secret}`
  );
  (
    await untilBlank({
      prompt: 'Email address of user who can access the app interface'
    })
  ).forEach((userEmail) =>
    gcloud(
      `projects add-iam-policy-binding ${projectId} --member="user:${userEmail}" --role="roles/iap.httpsResourceAccessor"`,
      false
    )
  );

  // configure Blackbaud SKY app
  const blackbaudAccessKey = await nonEmpty({
    prompt:
      'Enter a subscription access key from https://developer.blackbaud.com/subscriptions'
  });
  console.log('Create a new app at https://developer.blackbaud.com/apps');
  const blackbaudClientId = await nonEmpty({
    prompt: "Enter the app's OAuth client ID"
  });
  const blackbaudClientSecret = await nonEmpty({
    prompt: "Enter one of the app's OAuth secrets"
  });
  const blackbaudRedirectUrl = `${url}/redirect`;
  console.log(`Configure ${blackbaudRedirectUrl} as the app's redirect URL`);
  // TODO pause here?
  // TODO directions for limiting scope of app

  // configure delegated admin service account
  const serviceAccount = gcloud(
    `iam service-accounts create ${appName
      .toLowerCase()
      .replace(/[^a-z]/g, '-')
      .replace(/--/g, '-')} --display-name="Google Delegated Admin"`
  );
  console.log(
    `${googleDelegatedAdmin} needs to follow the directions at https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md`
  );
  const credentials = `${serviceAccount.uniqueId}.json`;
  gcloud(
    `iam service-accounts keys create ${credentials} --iam-account=${serviceAccount.email}`
  );
  console.log(`The Service Account Unique ID is ${serviceAccount.uniqueId}`);
  // TODO pause here?

  // store secrets
  exec(
    `echo "${blackbaudAccessKey}" | gcloud secrets create BLACKBAUD_ACCESS_KEY --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "null" | gcloud secrets create BLACKBAUD_API_TOKEN --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientId}" | gcloud secrets create BLACKBAUD_CLIENT_ID --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientSecret}" | gcloud secrets create BLACKBAUD_CLIENT_SECRET --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudRedirectUrl}" | gcloud secrets create BLACKBAUD_REDIRECT_URL --data-file=- ${flagsWithProject}`
  );
  gcloud(`secrets create GOOGLE_CREDENTIALS --data-file=${credentials}`);
  fs.unlinkSync(credentials);

  exec(
    `echo "${googleDelegatedAdmin}" | gcloud secrets create GOOGLE_DELEGATED_ADMIN --data-file=- ${flagsWithProject}`
  );

  console.log(`Authorize the app at ${url}`);
  // TODO pause here?

  // schedule daily sync
  // TODO configurable schedule
  // TODO configurable job name
  gcloud(
    `scheduler jobs create app-engine daily-blackbaud-to-google-sync --schedule="0 1 * * *" --relative-url="/sync"`
  );
})();

wipe existing token?

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/c6f88d74420b3f7666bfa2bacecd5ad04fcd59e1/src/server/Blackbaud/SKY.php#L69

        return self::$cache;
    }

    public static function getToken(
        $server,
        $session,
        $get,
        $interactive = true
    ) {
        $token = new AccessToken(Secrets::get(self::Bb_TOKEN, true));
        // acquire a Bb SKY API access token
        /** @var AccessToken|null $token **/
        if (empty($token)) {
            if ($interactive) {
                // interactively acquire a new Bb access token
                if (false === isset($get[self::CODE])) {
                    $authorizationUrl = self::api()->getAuthorizationUrl();
                    $session[self::OAuth2_STATE] = self::api()->getState();
                    // TODO wipe existing token?
                    self::cache()->set(
                        self::Request_URI,
                        $server['REQUEST_URI']
                    );
                    header("Location: $authorizationUrl");
                    exit();
                } elseif (
                    empty($get[self::STATE]) ||
                    (isset($session[self::OAuth2_STATE]) &&
                        $get[self::STATE] !== $session[self::OAuth2_STATE])
                ) {
                    if (isset($session[self::OAuth2_STATE])) {
                        unset($session[self::OAuth2_STATE]);
                    }

                    throw new Exception(
                        json_encode(['error' => 'invalid state'])
                    );
                } else {
                    $token = self::api()->getAccessToken(
                        self::AUTHORIZATION_CODE,
                        [
                            self::CODE => $get[self::CODE],
                        ]
                    );
                    Secrets::set(self::Bb_TOKEN, $token);
                }
            } else {
                return null;
            }
        } elseif ($token->hasExpired()) {
            // use refresh token to get new Bb access token
            $newToken = self::api()->getAccessToken(self::REFRESH_TOKEN, [
                self::REFRESH_TOKEN => $token->getRefreshToken(),
            ]);
            // FIXME need to handle _not_ being able to refresh!
            Secrets::set(self::Bb_TOKEN, $newToken);
            $token = $newToken;
        } else {
            self::api()->setAccessToken($token);

max 10 keys available

Need to delete one to create one

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/71b9ad9e10552e0e3e18a12e5d6b73593e0297b8/scripts/actions/guideWorkspaceAdminDelegation.js#L45

import { confirm, input } from '@inquirer/prompts';
import open from 'open';
import cli from '../lib/cli.js';
import gcloud from '../lib/gcloud.js';
import { options } from '../lib/options.js';
import validators from '../lib/validators.js';

export default async function guideGoogleWorkspaceAdminDelegation({
  projectName,
  delegatedAdmin = undefined,
  delegated = false
}) {
  delegatedAdmin = await input({
    message: options.delegatedAdmin.description,
    validate: validators.email,
    default: delegatedAdmin
  });
  gcloud.invoke(
    `projects add-iam-policy-binding ${gcloud.getProjectId()} --member="user:${delegatedAdmin}" --role="roles/owner"`,
    false
  );
  gcloud.invoke('services enable admin.googleapis.com');
  const name = projectName
    .toLowerCase()
    .replace(/[^a-z0-9]/g, '-')
    .replace(/--/g, '-');
  let serviceAccount = gcloud.invoke(
    `iam service-accounts list --filter=email=${name}@${gcloud.getProjectId()}.iam.gserviceaccount.com`
  )[0];
  if (!serviceAccount) {
    serviceAccount = gcloud.invoke(
      `iam service-accounts create ${name} --display-name="Google Delegated Admin"`
    );
  }
  /*
   * FIXME use Workload Identity Federation
   *  Service account keys could pose a security risk if compromised. We
   *  recommend you avoid downloading service account keys and instead use the
   *  Workload Identity Federation . You can learn more about the best way to
   *  authenticate service accounts on Google Cloud here.
   *  https://cloud.google.com/iam/docs/workload-identity-federation
   *  https://cloud.google.com/blog/products/identity-security/how-to-authenticate-service-accounts-to-help-keep-applications-secure
   */
  /*
   * FIXME max 10 keys available
   *  Need to delete one to create one
   */
  const credentialsPath = `.cache/${serviceAccount.uniqueId}.json`;
  gcloud.invoke(
    `iam service-accounts keys create ${credentialsPath} --iam-account=${serviceAccount.email}`
  );
  if (!delegated) {
    const url =
      'https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md';
    open(url);
    await confirm({
      message: `The Service Account Unique ID is ${cli.value(
        serviceAccount.uniqueId
      )}
Confirm that ${cli.value(
        delegatedAdmin
      )} has followed the directions at ${cli.url(url)}`
    });
  }

  return { delegatedAdmin, credentialsPath };
}

actually purge (after setting up the dangerously-purge-google-group-owneers para...

// TODO actually purge (after setting up the dangerously-purge-google-group-owneers param)

            step("purge members not present in Bb group");
            foreach ($purge as $gMember) {
                step("purge " . $gMember->getEmail());
                // TODO actually purge (after setting up the dangerously-purge-google-group-owneers param)
                /*dump(
                        $directory->members->delete(
                            $bbGroup->getParamEmail(),

directions for limiting scope of app

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/d87e266cf36df1864a097c23f7cfa7ac256f2f22/scripts/setup.js#L245

  // configure Blackbaud SKY app
  const blackbaudAccessKey = await nonEmpty({
    prompt:
      'Enter a subscription access key from https://developer.blackbaud.com/subscriptions'
  });
  console.log('Create a new app at https://developer.blackbaud.com/apps');
  const blackbaudClientId = await nonEmpty({
    prompt: "Enter the app's OAuth client ID"
  });
  const blackbaudClientSecret = await nonEmpty({
    prompt: "Enter one of the app's OAuth secrets"
  });
  const blackbaudRedirectUrl = `${url}/redirect`;
  console.log(`Configure ${blackbaudRedirectUrl} as the app's redirect URL`);
  // TODO directions for limiting scope of app

use Workload Identity Federation

Service account keys could pose a security risk if compromised. We

recommend you avoid downloading service account keys and instead use the

Workload Identity Federation . You can learn more about the best way to

authenticate service accounts on Google Cloud here.

https://cloud.google.com/iam/docs/workload-identity-federation

https://cloud.google.com/blog/products/identity-security/how-to-authenticate-service-accounts-to-help-keep-applications-secure

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/71b9ad9e10552e0e3e18a12e5d6b73593e0297b8/scripts/actions/guideWorkspaceAdminDelegation.js#L36

import { confirm, input } from '@inquirer/prompts';
import open from 'open';
import cli from '../lib/cli.js';
import gcloud from '../lib/gcloud.js';
import { options } from '../lib/options.js';
import validators from '../lib/validators.js';

export default async function guideGoogleWorkspaceAdminDelegation({
  projectName,
  delegatedAdmin = undefined,
  delegated = false
}) {
  delegatedAdmin = await input({
    message: options.delegatedAdmin.description,
    validate: validators.email,
    default: delegatedAdmin
  });
  gcloud.invoke(
    `projects add-iam-policy-binding ${gcloud.getProjectId()} --member="user:${delegatedAdmin}" --role="roles/owner"`,
    false
  );
  gcloud.invoke('services enable admin.googleapis.com');
  const name = projectName
    .toLowerCase()
    .replace(/[^a-z0-9]/g, '-')
    .replace(/--/g, '-');
  let serviceAccount = gcloud.invoke(
    `iam service-accounts list --filter=email=${name}@${gcloud.getProjectId()}.iam.gserviceaccount.com`
  )[0];
  if (!serviceAccount) {
    serviceAccount = gcloud.invoke(
      `iam service-accounts create ${name} --display-name="Google Delegated Admin"`
    );
  }
  /*
   * FIXME use Workload Identity Federation
   *  Service account keys could pose a security risk if compromised. We
   *  recommend you avoid downloading service account keys and instead use the
   *  Workload Identity Federation . You can learn more about the best way to
   *  authenticate service accounts on Google Cloud here.
   *  https://cloud.google.com/iam/docs/workload-identity-federation
   *  https://cloud.google.com/blog/products/identity-security/how-to-authenticate-service-accounts-to-help-keep-applications-secure
   */
  /*
   * FIXME max 10 keys available
   *  Need to delete one to create one
   */
  const credentialsPath = `.cache/${serviceAccount.uniqueId}.json`;
  gcloud.invoke(
    `iam service-accounts keys create ${credentialsPath} --iam-account=${serviceAccount.email}`
  );
  if (!delegated) {
    const url =
      'https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md';
    open(url);
    await confirm({
      message: `The Service Account Unique ID is ${cli.value(
        serviceAccount.uniqueId
      )}
Confirm that ${cli.value(
        delegatedAdmin
      )} has followed the directions at ${cli.url(url)}`
    });
  }

  return { delegatedAdmin, credentialsPath };
}

process update-name parameter to update the group name

// TODO process update-name parameter to update the group name

                $directory->members->listMembers($bbGroup->getParamEmail())
                as $gMember
            ) {
                // TODO process update-name parameter to update the group name
                /** @var DirectoryMember $gMember */
                /** @var DirectoryMember[] */
                // TODO process dangerously-purge-google-group-owners parameter
                if (array_key_exists($gMember->getEmail(), $bbMembers)) {
                    unset($bbMembers[$gMember->getEmail()]);
                } else {

#42

Appears that listMembers() returns an array of Member-like

objects, but not actually members. They are missing the

`delivery_settings` field. Which means that to get the

delivery settings for each member, each member would need to

be individually queried per group, which is prohibitively

resource expensive.

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/6c30da3acbb2f3f1756fa7df850b0dc4daa18750/src/server/Workflows/sync.php#L125

            /** @var DirectoryMember $gMember */
            if (array_key_exists($gMember->getEmail(), $bbMembers)) {
                unset($bbMembers[$gMember->getEmail()]);
            /*
             * TODO #42
             *   Appears that listMembers() returns an array of Member-like
             *   objects, but not actually members. They are missing the
             *   `delivery_settings` field. Which means that to get the
             *   delivery settings for each member, each member would need to
             *   be individually queried per group, which is prohibitively
             *   resource expensive.
             */
                /*
            if ($gMember->getDeliverySettings() != $deliverySettings) {
                $oldDeliverySettings = $gMember->getDeliverySettings();
                $gMember->setDeliverySettings($deliverySettings);
                $gMember = $directory->members->update(
                    $bbGroup->getParamEmail(),
                    $gMember->getId(),
                    $gMember
                );
                $listProgress->setStatus(
                    "Updated {$gMember->email} delivery_settings from to '{$gMember->getDeliverySettings()}'"
                );
            }
            */
            } else {
                if (
                    ($gMember->getRole() !== 'OWNER' ||

configurable job name

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/d87e266cf36df1864a097c23f7cfa7ac256f2f22/scripts/setup.js#L292

(async () => {
  const process = require('process');
  const fs = require('fs');
  const child = require('child_process');
  const { rword } = require('rword');
  const path = require('path');
  const readline = require('readline/promises').createInterface({
    input: process.stdin,
    output: process.stdout
  });

  rword.load('big');
  function createProjectId() {
    const word1 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * 7)
    });
    const word2 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * (30 - 8 - word1.length - 4))
    });
    return `${word1}-${word2}-${Math.floor(99999 + Math.random() * 900001)}`;
  }

  const exec = (command) => child.execSync(command, { stdio: 'inherit' });

  function versionTest({
    name,
    download = undefined,
    command = undefined,
    fail = true
  }) {
    command = command || `${name} --version`;
    if (!/\d+\.\d/.test(child.execSync(command))) {
      if (fail) {
        console.error(
          `${name} is required${download ? `, install from ${download}` : ''}`
        );
        process.exit(1);
      } else {
        return false;
      }
    }
    return true;
  }

  // TODO configure project name
  const appName = 'Blackbaud-to-Google Group Sync';
  // TODO configure project ID
  const projectId = createProjectId();

  const flags = '--quiet --format=json';
  const flagsWithProject = `${flags} --project=${projectId}`;
  function gcloud(command, withProjectId = true) {
    let actualFlags = flagsWithProject;
    if (withProjectId === null) {
      actualFlags = '';
    } else if (actualFlags === false) {
      actualFlags = flags;
    }
    const result = child.execSync(`gcloud ${command} ${actualFlags} `);
    try {
      return JSON.parse(result);
    } catch (e) {
      return result;
    }
  }

  async function choiceFrom({ prompt, list, display, defaultChoice = 1 }) {
    switch (list.length) {
      case 0:
        throw new Error('empty list');
      case 1:
        defaultChoice = 1;
        break;
      default:
        if (defaultChoice > list.length) {
          defaultChoice = undefined;
        }
    }
    console.log(prompt);
    list.forEach((item, i) => console.log(`  ${i}. ${item[display]}`));

    let choice;
    let question = `(${defaultChoice > 1 ? 1 : '[1]'}-${defaultChoice < list.length ? `[${defaultChoice}]` : ''
      }-${defaultChoice === list.length ? `[${defaultChoice}]` : list.length})`;
    do {
      choice = await readline.question(question);
      if (choice.length) {
        choice = (parseInt(choice) || 0) - 1;
      } else {
        choice = defaultChoice - 1;
      }
    } while (choice < 0 || choice >= list.length);
    return list[choice];
  }

  async function nonEmpty({ prompt }) {
    let response;
    do {
      response = await readline.question(prompt);
    } while (response.length === 0);
    return response;
  }

  async function untilBlank({ prompt }) {
    const responses = [];
    let response;
    do {
      response = await readline.question(`${prompt} [<Enter> to end]`);
      if (response.length > 0) {
        responses.push(response);
      }
    } while (response.length > 0);
    return responses;
  }

  // set project root as cwd
  process.chdir(path.join(__dirname, '..'));

  // test for CLI dependencies
  versionTest({
    name: 'npm',
    download: 'https://nodejs.org/'
  });
  versionTest({
    name: 'composer',
    download: 'https://getcomposer.org/'
  });
  versionTest({
    name: 'gcloud',
    download: 'https://cloud.google.com/sdk/docs/install'
  });
  const pnpm = versionTest({
    name: 'pnpm',
    dowload: 'https://pnpm.io/',
    fail: false
  });

  // install dependencies
  exec(`${pnpm ? 'pnpm' : 'npm'} install`);
  exec('composer install');

  // create a new project
  let response = gcloud(
    `projects create --name="${appName}" ${projectId}`,
    false
  );
  if (/error/i.test(response)) {
    console.error(response);
    process.exit(1);
  }

  // enable billing
  gcloud(`components install beta`, null);
  const accountId = path.basename(
    (
      await choiceFrom({
        prompt: 'Select a billing account for this project',
        list: gcloud(`beta billing accounts list --filter=open=true`),
        display: 'displayName'
      })
    ).name
  );
  gcloud(
    `beta billing projects link ${projectId} --billing-account="${accountId}`,
    false
  );

  // enable APIs
  gcloud(`services enable admin.googleapis.com`);
  gcloud(`services enable iap.googleapis.com`);
  gcloud(`services enable secretmanager.googleapis.com`);
  gcloud(`services enable cloudscheduler.googleapis.com`);
  gcloud(`services enable appengine.googleapis.com`);

  // configure workspace admin as owner
  // TODO output directions/links
  const googleDelegatedAdmin = await readline.question(
    'Enter the Google ID for a Workspace Admin who will delegate authority for this app'
  );
  gcloud(
    `projects add-iam-policy-binding ${projectId} --member="user:${googleDelegatedAdmin}" --role="roles/owner"`,
    false
  );

  // create App Engine instance
  // TODO set default region us-east4
  const region = await choiceFrom({
    prompt: 'Select a region for the app engine instance',
    list: gcloud(`app regions list`),
    display: 'region'
  }).region;
  gcloud(`app create --region=${region}`);
  const url = `https://${gcloud(`app describe`).defaultHostname}`;
  fs.writeFileSync(
    '.env',
    `PROJECT=${projectId}
URL=${url}`
  );

  // create default instance so IAP can be configured
  exec(`npm run build`);
  exec(`npm run deploy`);

  // configure IAP (and OAuth consent screen)
  const supportEmail = await nonEmpty({
    prompt: 'Enter a support email address for the app'
  });
  const brand = gcloud(
    `iap oauth-brands create --application_title${appName} --support_email=${supportEmail}`
  ).name;
  const oauth = gcloud(
    `iap oauth-clients create ${brand} --display_name=IAP-App-Engine-app`
  );
  gcloud(
    `iap web enable --resource-type=app-engine --oauth2-client-id=${path.basename(
      oauth.name
    )} --oauth2-client-secret=${oauth.secret}`
  );
  (
    await untilBlank({
      prompt: 'Email address of user who can access the app interface'
    })
  ).forEach((userEmail) =>
    gcloud(
      `projects add-iam-policy-binding ${projectId} --member="user:${userEmail}" --role="roles/iap.httpsResourceAccessor"`,
      false
    )
  );

  // configure Blackbaud SKY app
  const blackbaudAccessKey = await nonEmpty({
    prompt:
      'Enter a subscription access key from https://developer.blackbaud.com/subscriptions'
  });
  console.log('Create a new app at https://developer.blackbaud.com/apps');
  const blackbaudClientId = await nonEmpty({
    prompt: "Enter the app's OAuth client ID"
  });
  const blackbaudClientSecret = await nonEmpty({
    prompt: "Enter one of the app's OAuth secrets"
  });
  const blackbaudRedirectUrl = `${url}/redirect`;
  console.log(`Configure ${blackbaudRedirectUrl} as the app's redirect URL`);
  // TODO pause here?
  // TODO directions for limiting scope of app

  // configure delegated admin service account
  const serviceAccount = gcloud(
    `iam service-accounts create ${appName
      .toLowerCase()
      .replace(/[^a-z]/g, '-')
      .replace(/--/g, '-')} --display-name="Google Delegated Admin"`
  );
  console.log(
    `${googleDelegatedAdmin} needs to follow the directions at https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md`
  );
  const credentials = `${serviceAccount.uniqueId}.json`;
  gcloud(
    `iam service-accounts keys create ${credentials} --iam-account=${serviceAccount.email}`
  );
  console.log(`The Service Account Unique ID is ${serviceAccount.uniqueId}`);
  // TODO pause here?

  // store secrets
  exec(
    `echo "${blackbaudAccessKey}" | gcloud secrets create BLACKBAUD_ACCESS_KEY --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "null" | gcloud secrets create BLACKBAUD_API_TOKEN --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientId}" | gcloud secrets create BLACKBAUD_CLIENT_ID --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientSecret}" | gcloud secrets create BLACKBAUD_CLIENT_SECRET --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudRedirectUrl}" | gcloud secrets create BLACKBAUD_REDIRECT_URL --data-file=- ${flagsWithProject}`
  );
  gcloud(`secrets create GOOGLE_CREDENTIALS --data-file=${credentials}`);
  fs.unlinkSync(credentials);

  exec(
    `echo "${googleDelegatedAdmin}" | gcloud secrets create GOOGLE_DELEGATED_ADMIN --data-file=- ${flagsWithProject}`
  );

  console.log(`Authorize the app at ${url}`);
  // TODO pause here?

  // schedule daily sync
  // TODO configurable schedule
  // TODO configurable job name
  gcloud(
    `scheduler jobs create app-engine daily-blackbaud-to-google-sync --schedule="0 1 * * *" --relative-url="/sync"`
  );
})();

set default region us-east4

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/d87e266cf36df1864a097c23f7cfa7ac256f2f22/scripts/setup.js#L186

(async () => {
  const process = require('process');
  const fs = require('fs');
  const child = require('child_process');
  const { rword } = require('rword');
  const path = require('path');
  const readline = require('readline/promises').createInterface({
    input: process.stdin,
    output: process.stdout
  });

  rword.load('big');
  function createProjectId() {
    const word1 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * 7)
    });
    const word2 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * (30 - 8 - word1.length - 4))
    });
    return `${word1}-${word2}-${Math.floor(99999 + Math.random() * 900001)}`;
  }

  const exec = (command) => child.execSync(command, { stdio: 'inherit' });

  function versionTest({
    name,
    download = undefined,
    command = undefined,
    fail = true
  }) {
    command = command || `${name} --version`;
    if (!/\d+\.\d/.test(child.execSync(command))) {
      if (fail) {
        console.error(
          `${name} is required${download ? `, install from ${download}` : ''}`
        );
        process.exit(1);
      } else {
        return false;
      }
    }
    return true;
  }

  // TODO configure project name
  const appName = 'Blackbaud-to-Google Group Sync';
  // TODO configure project ID
  const projectId = createProjectId();

  const flags = '--quiet --format=json';
  const flagsWithProject = `${flags} --project=${projectId}`;
  function gcloud(command, withProjectId = true) {
    let actualFlags = flagsWithProject;
    if (withProjectId === null) {
      actualFlags = '';
    } else if (actualFlags === false) {
      actualFlags = flags;
    }
    const result = child.execSync(`gcloud ${command} ${actualFlags} `);
    try {
      return JSON.parse(result);
    } catch (e) {
      return result;
    }
  }

  async function choiceFrom({ prompt, list, display, defaultChoice = 1 }) {
    switch (list.length) {
      case 0:
        throw new Error('empty list');
      case 1:
        defaultChoice = 1;
        break;
      default:
        if (defaultChoice > list.length) {
          defaultChoice = undefined;
        }
    }
    console.log(prompt);
    list.forEach((item, i) => console.log(`  ${i}. ${item[display]}`));

    let choice;
    let question = `(${defaultChoice > 1 ? 1 : '[1]'}-${defaultChoice < list.length ? `[${defaultChoice}]` : ''
      }-${defaultChoice === list.length ? `[${defaultChoice}]` : list.length})`;
    do {
      choice = await readline.question(question);
      if (choice.length) {
        choice = (parseInt(choice) || 0) - 1;
      } else {
        choice = defaultChoice - 1;
      }
    } while (choice < 0 || choice >= list.length);
    return list[choice];
  }

  async function nonEmpty({ prompt }) {
    let response;
    do {
      response = await readline.question(prompt);
    } while (response.length === 0);
    return response;
  }

  async function untilBlank({ prompt }) {
    const responses = [];
    let response;
    do {
      response = await readline.question(`${prompt} [<Enter> to end]`);
      if (response.length > 0) {
        responses.push(response);
      }
    } while (response.length > 0);
    return responses;
  }

  // set project root as cwd
  process.chdir(path.join(__dirname, '..'));

  // test for CLI dependencies
  versionTest({
    name: 'npm',
    download: 'https://nodejs.org/'
  });
  versionTest({
    name: 'composer',
    download: 'https://getcomposer.org/'
  });
  versionTest({
    name: 'gcloud',
    download: 'https://cloud.google.com/sdk/docs/install'
  });
  const pnpm = versionTest({
    name: 'pnpm',
    dowload: 'https://pnpm.io/',
    fail: false
  });

  // install dependencies
  exec(`${pnpm ? 'pnpm' : 'npm'} install`);
  exec('composer install');

  // create a new project
  let response = gcloud(
    `projects create --name="${appName}" ${projectId}`,
    false
  );
  if (/error/i.test(response)) {
    console.error(response);
    process.exit(1);
  }

  // enable billing
  gcloud(`components install beta`, null);
  const accountId = path.basename(
    (
      await choiceFrom({
        prompt: 'Select a billing account for this project',
        list: gcloud(`beta billing accounts list --filter=open=true`),
        display: 'displayName'
      })
    ).name
  );
  gcloud(
    `beta billing projects link ${projectId} --billing-account="${accountId}`,
    false
  );

  // enable APIs
  gcloud(`services enable admin.googleapis.com`);
  gcloud(`services enable iap.googleapis.com`);
  gcloud(`services enable secretmanager.googleapis.com`);
  gcloud(`services enable cloudscheduler.googleapis.com`);
  gcloud(`services enable appengine.googleapis.com`);

  // configure workspace admin as owner
  // TODO output directions/links
  const googleDelegatedAdmin = await readline.question(
    'Enter the Google ID for a Workspace Admin who will delegate authority for this app'
  );
  gcloud(
    `projects add-iam-policy-binding ${projectId} --member="user:${googleDelegatedAdmin}" --role="roles/owner"`,
    false
  );

  // create App Engine instance
  // TODO set default region us-east4
  const region = await choiceFrom({
    prompt: 'Select a region for the app engine instance',
    list: gcloud(`app regions list`),
    display: 'region'
  }).region;
  gcloud(`app create --region=${region}`);
  const url = `https://${gcloud(`app describe`).defaultHostname}`;
  fs.writeFileSync(
    '.env',
    `PROJECT=${projectId}
URL=${url}`
  );

  // create default instance so IAP can be configured
  exec(`npm run build`);
  exec(`npm run deploy`);

  // configure IAP (and OAuth consent screen)
  const supportEmail = await nonEmpty({
    prompt: 'Enter a support email address for the app'
  });
  const brand = gcloud(
    `iap oauth-brands create --application_title${appName} --support_email=${supportEmail}`
  ).name;
  const oauth = gcloud(
    `iap oauth-clients create ${brand} --display_name=IAP-App-Engine-app`
  );
  gcloud(
    `iap web enable --resource-type=app-engine --oauth2-client-id=${path.basename(
      oauth.name
    )} --oauth2-client-secret=${oauth.secret}`
  );
  (
    await untilBlank({
      prompt: 'Email address of user who can access the app interface'
    })
  ).forEach((userEmail) =>
    gcloud(
      `projects add-iam-policy-binding ${projectId} --member="user:${userEmail}" --role="roles/iap.httpsResourceAccessor"`,
      false
    )
  );

  // configure Blackbaud SKY app
  const blackbaudAccessKey = await nonEmpty({
    prompt:
      'Enter a subscription access key from https://developer.blackbaud.com/subscriptions'
  });
  console.log('Create a new app at https://developer.blackbaud.com/apps');
  const blackbaudClientId = await nonEmpty({
    prompt: "Enter the app's OAuth client ID"
  });
  const blackbaudClientSecret = await nonEmpty({
    prompt: "Enter one of the app's OAuth secrets"
  });
  const blackbaudRedirectUrl = `${url}/redirect`;
  console.log(`Configure ${blackbaudRedirectUrl} as the app's redirect URL`);
  // TODO pause here?
  // TODO directions for limiting scope of app

  // configure delegated admin service account
  const serviceAccount = gcloud(
    `iam service-accounts create ${appName
      .toLowerCase()
      .replace(/[^a-z]/g, '-')
      .replace(/--/g, '-')} --display-name="Google Delegated Admin"`
  );
  console.log(
    `${googleDelegatedAdmin} needs to follow the directions at https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md`
  );
  const credentials = `${serviceAccount.uniqueId}.json`;
  gcloud(
    `iam service-accounts keys create ${credentials} --iam-account=${serviceAccount.email}`
  );
  console.log(`The Service Account Unique ID is ${serviceAccount.uniqueId}`);
  // TODO pause here?

  // store secrets
  exec(
    `echo "${blackbaudAccessKey}" | gcloud secrets create BLACKBAUD_ACCESS_KEY --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "null" | gcloud secrets create BLACKBAUD_API_TOKEN --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientId}" | gcloud secrets create BLACKBAUD_CLIENT_ID --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientSecret}" | gcloud secrets create BLACKBAUD_CLIENT_SECRET --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudRedirectUrl}" | gcloud secrets create BLACKBAUD_REDIRECT_URL --data-file=- ${flagsWithProject}`
  );
  gcloud(`secrets create GOOGLE_CREDENTIALS --data-file=${credentials}`);
  fs.unlinkSync(credentials);

  exec(
    `echo "${googleDelegatedAdmin}" | gcloud secrets create GOOGLE_DELEGATED_ADMIN --data-file=- ${flagsWithProject}`
  );

  console.log(`Authorize the app at ${url}`);
  // TODO pause here?

  // schedule daily sync
  // TODO configurable schedule
  // TODO configurable job name
  gcloud(
    `scheduler jobs create app-engine daily-blackbaud-to-google-sync --schedule="0 1 * * *" --relative-url="/sync"`
  );
})();

process dangerously-purge-google-group-owners parameter

// TODO process dangerously-purge-google-group-owners parameter

                $directory->members->listMembers($bbGroup->getParamEmail())
                as $gMember
            ) {
                // TODO process update-name parameter to update the group name
                /** @var DirectoryMember $gMember */
                /** @var DirectoryMember[] */
                // TODO process dangerously-purge-google-group-owners parameter
                if (array_key_exists($gMember->getEmail(), $bbMembers)) {
                    unset($bbMembers[$gMember->getEmail()]);
                } else {

give service account secrets-accessor role

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/dff5afce9d18c4616c42c1e316a13fabce1fee91/scripts/setup.js#L146

#!/usr/bin/env node
import { confirm, input, select } from '@inquirer/prompts';
import dotenv from 'dotenv';
import emailValidator from 'email-validator';
import fs from 'fs';
import path from 'path';
import process from 'process';
import { fileURLToPath } from 'url';
import gcloud from './gcloud.js';
import lib from './lib.js';

const nonEmptyValidator = (val) => val && val.length > 0;

async function initializeApp() {
  let appName = 'Blackbaud-to-Google Group Sync';
  if (!(await confirm({ message: `Use '${appName}' as the app name?` }))) {
    appName = await input({
      message: 'App name',
      validate: (name) => nonEmptyValidator(name) && name.length <= 30
    });
  }
  if (
    !(await confirm({
      message: `Use '${gcloud.getProjectId()}' as project ID?`
    }))
  ) {
    gcloud.setProjectId(
      await input({
        message: 'Project ID',
        validate: (id) => nonEmptyValidator(id) && id.length <= 30
      })
    );
  }
  return appName;
}

async function installDependencies() {
  lib.versionTest({
    name: 'npm',
    download: 'https://nodejs.org/'
  });
  lib.versionTest({
    name: 'composer',
    download: 'https://getcomposer.org/'
  });
  lib.versionTest({
    name: 'gcloud',
    download: 'https://cloud.google.com/sdk/docs/install'
  });
  const pnpm = lib.versionTest({
    name: 'pnpm',
    dowload: 'https://pnpm.io/',
    fail: false
  });

  console.log(`Installing Node dependencies${pnpm ? ' (using pnpm)' : ''}`);
  lib.exec(`${pnpm ? 'pnpm' : 'npm'} install`);
  console.log('Installing PHP dependencies');
  lib.exec('composer install');
}

async function createProject(appName) {
  let response = gcloud.invoke(
    `projects create --name="${appName}" ${gcloud.getProjectId()}`,
    false
  );
  if (/error/i.test(response)) {
    throw new Error(response);
  }
}

async function enableBilling() {
  let accountId;
  const choices = gcloud
    .invokeBeta(`billing accounts list --filter=open=true`)
    .map((account) => ({
      name: account.displayName,
      value: path.basename(account.name)
    }));
  if (choices.length > 1) {
    accountId = await select({
      message: 'Select a billing account for this project',
      choices
    });
  } else if (
    choices.length === 1 &&
    (await confirm({ message: `Use ${choices[0].name} billing account?` }))
  ) {
    accountId = choices[0].value;
  }
  if (accountId) {
    gcloud.invokeBeta(
      `billing projects link ${gcloud.getProjectId()} --billing-account="${accountId}"`,
      false
    );
  } else {
    open(
      `https://console.cloud.google.com/billing/?project=${gcloud.getProjectId()}`
    );
    await confirm({
      message:
        'Confirm that you have created a billing account for this project'
    });
  }
}

async function enableAPIs() {
  gcloud.invoke(`services enable admin.googleapis.com`);
  gcloud.invoke(`services enable iap.googleapis.com`);
  gcloud.invoke(`services enable secretmanager.googleapis.com`);
  gcloud.invoke(`services enable cloudscheduler.googleapis.com`);
  gcloud.invoke(`services enable appengine.googleapis.com`);
}

async function guideGoogleWorkspaceAdminDelegation(appName) {
  const delegatedAdmin = await input({
    message:
      'Enter the Google ID for a Workspace Admin who will delegate authority for this app',
    validate: emailValidator
  });
  gcloud.invoke(
    `projects add-iam-policy-binding ${gcloud.getProjectId()} --member="user:${delegatedAdmin}" --role="roles/owner"`,
    false
  );

  const serviceAccount = gcloud.invoke(
    `iam service-accounts create ${appName
      .toLowerCase()
      .replace(/[^a-z]/g, '-')
      .replace(/--/g, '-')} --display-name="Google Delegated Admin"`
  );
  const credentialsPath = `${serviceAccount.uniqueId}.json`;
  gcloud.invoke(
    `iam service-accounts keys create ${credentialsPath} --iam-account=${serviceAccount.email}`
  );
  await confirm({
    message: `Confirm that ${delegatedAdmin} has followed the directions at https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md

  The Service Account Unique ID is ${serviceAccount.uniqueId}`
  });

  return { delegatedAdmin, credentialsPath };
}

async function createAppEngineInstance() {
  // FIXME give service account secrets-accessor role
  const region = await select({
    message: 'Select a region for the app engine instance',
    choices: gcloud
      .invoke(`app regions list`)
      .map((region) => ({ value: region.region }))
  });
  gcloud.invoke(`app create --region=${region}`);

  const url = `https://${gcloud.invoke(`app describe`).defaultHostname}`;
  fs.writeFileSync(
    '.env',
    `PROJECT=${gcloud.getProjectId()}
  URL=${url}`
  );

  // create default instance so IAP can be configured
  lib.exec(`npm run build`);
  lib.exec(`npm run deploy`);

  return url;
}

async function enableIdentityAwareProxy(appName) {
  const supportEmail = await input({
    message: 'Enter a support email address for the app',
    validate: emailValidator
  });
  const brand = gcloud.invoke(
    `iap oauth-brands create --application_title${appName} --support_email=${supportEmail}`
  ).name;
  const oauth = gcloud.invoke(
    `iap oauth-clients create ${brand} --display_name=IAP-App-Engine-app`
  );
  gcloud.invoke(
    `iap web enable --resource-type=app-engine --oauth2-client-id=${path.basename(
      oauth.name
    )} --oauth2-client-secret=${oauth.secret}`
  );
  const users = [];
  let done = false;
  do {
    const user = await input({
      message:
        'Email address of user who can access the app interface (blank to end)',
      validate: (u) => u.length === 0 || emailValidator(u)
    });
    if (nonEmptyValidator(user)) {
      users.push(user);
    } else {
      done = true;
    }
  } while (!done);
  users.forEach((user) =>
    gcloud.invoke(
      `projects add-iam-policy-binding ${gcloud.getProjectId()} --member="user:${user}" --role="roles/iap.httpsResourceAccessor"`,
      false
    )
  );
}

async function guideBlackbaudAppCreation(url) {
  const accessKey = await input({
    message:
      'Enter a subscription access key from https://developer.blackbaud.com/subscriptions',
    validate: nonEmptyValidator
  });
  console.log('Create a new app at https://developer.blackbaud.com/apps');
  const clientId = await input({
    message: "Enter the app's OAuth client ID",
    validate: nonEmptyValidator
  });
  const clientSecret = await input({
    message: "Enter one of the app's OAuth secrets",
    validate: nonEmptyValidator
  });
  const redirectUrl = `${url}/redirect`;
  await confirm({
    message: `Configure ${redirectUrl} as the app's redirect URL`
  });
  // TODO directions for limiting scope of app
  return { accessKey, clientId, clientSecret, redirectUrl };
}

async function initializeSecretManager({ blackbaud, googleWorkspace }) {
  gcloud.secrets.create('BLACKBAUD_ACCESS_KEY', blackbaud.accessKey);
  gcloud.secrets.create('BLACKBAUD_API_TOKEN', 'null');
  gcloud.secrets.create('BLACKBAUD_CLIENT_ID', blackbaud.clientId);
  gcloud.secrets.create('BLACKBAUD_CLIENT_SECRET', blackbaud.clientSecret);
  gcloud.secrets.create('BLACKBAUD_REDIRECT_URL', blackbaud.redirectUrl);
  gcloud.secrets.create(
    'GOOGLE_DELEGATED_ADMIN',
    googleWorkspace.delegatedAdmin
  );
  gcloud.secrets.create(
    'GOOGLE_CREDENTIALS',
    googleWorkspace.credentialsPath,
    true
  );
  fs.unlinkSync(googleWorkspace.credentialsPath);
}

async function guideAuthorizeApp(url) {
  await open(url);
  await confirm({
    message: `Confirm that you have authorized the app at ${url}`
  });
}

async function scheduleSync() {
  // TODO configurable schedule
  // TODO configurable job name
  gcloud.invoke(
    `scheduler jobs create app-engine daily-blackbaud-to-google-sync --schedule="0 1 * * *" --relative-url="/sync"`
  );
}

(async () => {
  // eslint-disable-next-line
  process.chdir(path.join(path.dirname(fileURLToPath(import.meta.url)), '..'));
  dotenv.config();

  const appName = await initializeApp();
  await installDependencies();
  await createProject(appName);
  await enableBilling();
  await enableAPIs();
  const googleWorkspace = guideGoogleWorkspaceAdminDelegation(appName);
  const url = await createAppEngineInstance();
  await enableIdentityAwareProxy(appName);
  const blackbaud = await guideBlackbaudAppCreation(url);
  await initializeSecretManager({ blackbaud, googleWorkspace });
  await guideAuthorizeApp(url);
  await scheduleSync();
})();

configure project name

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/d87e266cf36df1864a097c23f7cfa7ac256f2f22/scripts/setup.js#L45

(async () => {
  const process = require('process');
  const fs = require('fs');
  const child = require('child_process');
  const { rword } = require('rword');
  const path = require('path');
  const readline = require('readline/promises').createInterface({
    input: process.stdin,
    output: process.stdout
  });

  rword.load('big');
  function createProjectId() {
    const word1 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * 7)
    });
    const word2 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * (30 - 8 - word1.length - 4))
    });
    return `${word1}-${word2}-${Math.floor(99999 + Math.random() * 900001)}`;
  }

  const exec = (command) => child.execSync(command, { stdio: 'inherit' });

  function versionTest({
    name,
    download = undefined,
    command = undefined,
    fail = true
  }) {
    command = command || `${name} --version`;
    if (!/\d+\.\d/.test(child.execSync(command))) {
      if (fail) {
        console.error(
          `${name} is required${download ? `, install from ${download}` : ''}`
        );
        process.exit(1);
      } else {
        return false;
      }
    }
    return true;
  }

  // TODO configure project name
  const appName = 'Blackbaud-to-Google Group Sync';
  // TODO configure project ID
  const projectId = createProjectId();

  const flags = '--quiet --format=json';
  const flagsWithProject = `${flags} --project=${projectId}`;
  function gcloud(command, withProjectId = true) {
    let actualFlags = flagsWithProject;
    if (withProjectId === null) {
      actualFlags = '';
    } else if (actualFlags === false) {
      actualFlags = flags;
    }
    const result = child.execSync(`gcloud ${command} ${actualFlags} `);
    try {
      return JSON.parse(result);
    } catch (e) {
      return result;
    }
  }

  async function choiceFrom({ prompt, list, display, defaultChoice = 1 }) {
    switch (list.length) {
      case 0:
        throw new Error('empty list');
      case 1:
        defaultChoice = 1;
        break;
      default:
        if (defaultChoice > list.length) {
          defaultChoice = undefined;
        }
    }
    console.log(prompt);
    list.forEach((item, i) => console.log(`  ${i}. ${item[display]}`));

    let choice;
    let question = `(${defaultChoice > 1 ? 1 : '[1]'}-${defaultChoice < list.length ? `[${defaultChoice}]` : ''
      }-${defaultChoice === list.length ? `[${defaultChoice}]` : list.length})`;
    do {
      choice = await readline.question(question);
      if (choice.length) {
        choice = (parseInt(choice) || 0) - 1;
      } else {
        choice = defaultChoice - 1;
      }
    } while (choice < 0 || choice >= list.length);
    return list[choice];
  }

  async function nonEmpty({ prompt }) {
    let response;
    do {
      response = await readline.question(prompt);
    } while (response.length === 0);
    return response;
  }

  async function untilBlank({ prompt }) {
    const responses = [];
    let response;
    do {
      response = await readline.question(`${prompt} [<Enter> to end]`);
      if (response.length > 0) {
        responses.push(response);
      }
    } while (response.length > 0);
    return responses;
  }

  // set project root as cwd
  process.chdir(path.join(__dirname, '..'));

  // test for CLI dependencies
  versionTest({
    name: 'npm',
    download: 'https://nodejs.org/'
  });
  versionTest({
    name: 'composer',
    download: 'https://getcomposer.org/'
  });
  versionTest({
    name: 'gcloud',
    download: 'https://cloud.google.com/sdk/docs/install'
  });
  const pnpm = versionTest({
    name: 'pnpm',
    dowload: 'https://pnpm.io/',
    fail: false
  });

  // install dependencies
  exec(`${pnpm ? 'pnpm' : 'npm'} install`);
  exec('composer install');

  // create a new project
  let response = gcloud(
    `projects create --name="${appName}" ${projectId}`,
    false
  );
  if (/error/i.test(response)) {
    console.error(response);
    process.exit(1);
  }

  // enable billing
  gcloud(`components install beta`, null);
  const accountId = path.basename(
    (
      await choiceFrom({
        prompt: 'Select a billing account for this project',
        list: gcloud(`beta billing accounts list --filter=open=true`),
        display: 'displayName'
      })
    ).name
  );
  gcloud(
    `beta billing projects link ${projectId} --billing-account="${accountId}`,
    false
  );

  // enable APIs
  gcloud(`services enable admin.googleapis.com`);
  gcloud(`services enable iap.googleapis.com`);
  gcloud(`services enable secretmanager.googleapis.com`);
  gcloud(`services enable cloudscheduler.googleapis.com`);
  gcloud(`services enable appengine.googleapis.com`);

  // configure workspace admin as owner
  // TODO output directions/links
  const googleDelegatedAdmin = await readline.question(
    'Enter the Google ID for a Workspace Admin who will delegate authority for this app'
  );
  gcloud(
    `projects add-iam-policy-binding ${projectId} --member="user:${googleDelegatedAdmin}" --role="roles/owner"`,
    false
  );

  // create App Engine instance
  // TODO set default region us-east4
  const region = await choiceFrom({
    prompt: 'Select a region for the app engine instance',
    list: gcloud(`app regions list`),
    display: 'region'
  }).region;
  gcloud(`app create --region=${region}`);
  const url = `https://${gcloud(`app describe`).defaultHostname}`;
  fs.writeFileSync(
    '.env',
    `PROJECT=${projectId}
URL=${url}`
  );

  // create default instance so IAP can be configured
  exec(`npm run build`);
  exec(`npm run deploy`);

  // configure IAP (and OAuth consent screen)
  const supportEmail = await nonEmpty({
    prompt: 'Enter a support email address for the app'
  });
  const brand = gcloud(
    `iap oauth-brands create --application_title${appName} --support_email=${supportEmail}`
  ).name;
  const oauth = gcloud(
    `iap oauth-clients create ${brand} --display_name=IAP-App-Engine-app`
  );
  gcloud(
    `iap web enable --resource-type=app-engine --oauth2-client-id=${path.basename(
      oauth.name
    )} --oauth2-client-secret=${oauth.secret}`
  );
  (
    await untilBlank({
      prompt: 'Email address of user who can access the app interface'
    })
  ).forEach((userEmail) =>
    gcloud(
      `projects add-iam-policy-binding ${projectId} --member="user:${userEmail}" --role="roles/iap.httpsResourceAccessor"`,
      false
    )
  );

  // configure Blackbaud SKY app
  const blackbaudAccessKey = await nonEmpty({
    prompt:
      'Enter a subscription access key from https://developer.blackbaud.com/subscriptions'
  });
  console.log('Create a new app at https://developer.blackbaud.com/apps');
  const blackbaudClientId = await nonEmpty({
    prompt: "Enter the app's OAuth client ID"
  });
  const blackbaudClientSecret = await nonEmpty({
    prompt: "Enter one of the app's OAuth secrets"
  });
  const blackbaudRedirectUrl = `${url}/redirect`;
  console.log(`Configure ${blackbaudRedirectUrl} as the app's redirect URL`);
  // TODO pause here?
  // TODO directions for limiting scope of app

  // configure delegated admin service account
  const serviceAccount = gcloud(
    `iam service-accounts create ${appName
      .toLowerCase()
      .replace(/[^a-z]/g, '-')
      .replace(/--/g, '-')} --display-name="Google Delegated Admin"`
  );
  console.log(
    `${googleDelegatedAdmin} needs to follow the directions at https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md`
  );
  const credentials = `${serviceAccount.uniqueId}.json`;
  gcloud(
    `iam service-accounts keys create ${credentials} --iam-account=${serviceAccount.email}`
  );
  console.log(`The Service Account Unique ID is ${serviceAccount.uniqueId}`);
  // TODO pause here?

  // store secrets
  exec(
    `echo "${blackbaudAccessKey}" | gcloud secrets create BLACKBAUD_ACCESS_KEY --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "null" | gcloud secrets create BLACKBAUD_API_TOKEN --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientId}" | gcloud secrets create BLACKBAUD_CLIENT_ID --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientSecret}" | gcloud secrets create BLACKBAUD_CLIENT_SECRET --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudRedirectUrl}" | gcloud secrets create BLACKBAUD_REDIRECT_URL --data-file=- ${flagsWithProject}`
  );
  gcloud(`secrets create GOOGLE_CREDENTIALS --data-file=${credentials}`);
  fs.unlinkSync(credentials);

  exec(
    `echo "${googleDelegatedAdmin}" | gcloud secrets create GOOGLE_DELEGATED_ADMIN --data-file=- ${flagsWithProject}`
  );

  console.log(`Authorize the app at ${url}`);
  // TODO pause here?

  // schedule daily sync
  // TODO configurable schedule
  // TODO configurable job name
  gcloud(
    `scheduler jobs create app-engine daily-blackbaud-to-google-sync --schedule="0 1 * * *" --relative-url="/sync"`
  );
})();

#42

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/6c30da3acbb2f3f1756fa7df850b0dc4daa18750/src/server/Workflows/sync.php#L125

            /** @var DirectoryMember $gMember */
            if (array_key_exists($gMember->getEmail(), $bbMembers)) {
                unset($bbMembers[$gMember->getEmail()]);
            /*
             * TODO #42
             *   Appears that listMembers() returns an array of Member-like
             *   objects, but not actually members. They are missing the
             *   `delivery_settings` field. Which means that to get the
             *   delivery settings for each member, each member would need to
             *   be individually queried per group, which is prohibitively
             *   resource expensive.
             */
                /*
            if ($gMember->getDeliverySettings() != $deliverySettings) {
                $oldDeliverySettings = $gMember->getDeliverySettings();
                $gMember->setDeliverySettings($deliverySettings);
                $gMember = $directory->members->update(
                    $bbGroup->getParamEmail(),
                    $gMember->getId(),
                    $gMember
                );
                $listProgress->setStatus(
                    "Updated {$gMember->email} delivery_settings from to '{$gMember->getDeliverySettings()}'"
                );
            }
            */
            } else {
                if (
                    ($gMember->getRole() !== 'OWNER' ||

Retry if SKY API isn't available

I've now seen this happen several times in the last 48 hours, when a request is made to the SKY API and it either doesn't respond or can't be found. There needs to be some sort of retry interval in that scenario. This is somewhat related to #17, I think.

It looks like this is basically what happened last night: cURL received no response from the SKY API, so it timed out
2023-06-16_01-00-00_-_01-20-00.log

messaging options not being set

#12 seems to be less closed than I thought, need to include the current setting that's being reset in the context, and double-check the documentation to make sure I'm not missing a "save" step.

configurable schedule

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/d87e266cf36df1864a097c23f7cfa7ac256f2f22/scripts/setup.js#L291

(async () => {
  const process = require('process');
  const fs = require('fs');
  const child = require('child_process');
  const { rword } = require('rword');
  const path = require('path');
  const readline = require('readline/promises').createInterface({
    input: process.stdin,
    output: process.stdout
  });

  rword.load('big');
  function createProjectId() {
    const word1 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * 7)
    });
    const word2 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * (30 - 8 - word1.length - 4))
    });
    return `${word1}-${word2}-${Math.floor(99999 + Math.random() * 900001)}`;
  }

  const exec = (command) => child.execSync(command, { stdio: 'inherit' });

  function versionTest({
    name,
    download = undefined,
    command = undefined,
    fail = true
  }) {
    command = command || `${name} --version`;
    if (!/\d+\.\d/.test(child.execSync(command))) {
      if (fail) {
        console.error(
          `${name} is required${download ? `, install from ${download}` : ''}`
        );
        process.exit(1);
      } else {
        return false;
      }
    }
    return true;
  }

  // TODO configure project name
  const appName = 'Blackbaud-to-Google Group Sync';
  // TODO configure project ID
  const projectId = createProjectId();

  const flags = '--quiet --format=json';
  const flagsWithProject = `${flags} --project=${projectId}`;
  function gcloud(command, withProjectId = true) {
    let actualFlags = flagsWithProject;
    if (withProjectId === null) {
      actualFlags = '';
    } else if (actualFlags === false) {
      actualFlags = flags;
    }
    const result = child.execSync(`gcloud ${command} ${actualFlags} `);
    try {
      return JSON.parse(result);
    } catch (e) {
      return result;
    }
  }

  async function choiceFrom({ prompt, list, display, defaultChoice = 1 }) {
    switch (list.length) {
      case 0:
        throw new Error('empty list');
      case 1:
        defaultChoice = 1;
        break;
      default:
        if (defaultChoice > list.length) {
          defaultChoice = undefined;
        }
    }
    console.log(prompt);
    list.forEach((item, i) => console.log(`  ${i}. ${item[display]}`));

    let choice;
    let question = `(${defaultChoice > 1 ? 1 : '[1]'}-${defaultChoice < list.length ? `[${defaultChoice}]` : ''
      }-${defaultChoice === list.length ? `[${defaultChoice}]` : list.length})`;
    do {
      choice = await readline.question(question);
      if (choice.length) {
        choice = (parseInt(choice) || 0) - 1;
      } else {
        choice = defaultChoice - 1;
      }
    } while (choice < 0 || choice >= list.length);
    return list[choice];
  }

  async function nonEmpty({ prompt }) {
    let response;
    do {
      response = await readline.question(prompt);
    } while (response.length === 0);
    return response;
  }

  async function untilBlank({ prompt }) {
    const responses = [];
    let response;
    do {
      response = await readline.question(`${prompt} [<Enter> to end]`);
      if (response.length > 0) {
        responses.push(response);
      }
    } while (response.length > 0);
    return responses;
  }

  // set project root as cwd
  process.chdir(path.join(__dirname, '..'));

  // test for CLI dependencies
  versionTest({
    name: 'npm',
    download: 'https://nodejs.org/'
  });
  versionTest({
    name: 'composer',
    download: 'https://getcomposer.org/'
  });
  versionTest({
    name: 'gcloud',
    download: 'https://cloud.google.com/sdk/docs/install'
  });
  const pnpm = versionTest({
    name: 'pnpm',
    dowload: 'https://pnpm.io/',
    fail: false
  });

  // install dependencies
  exec(`${pnpm ? 'pnpm' : 'npm'} install`);
  exec('composer install');

  // create a new project
  let response = gcloud(
    `projects create --name="${appName}" ${projectId}`,
    false
  );
  if (/error/i.test(response)) {
    console.error(response);
    process.exit(1);
  }

  // enable billing
  gcloud(`components install beta`, null);
  const accountId = path.basename(
    (
      await choiceFrom({
        prompt: 'Select a billing account for this project',
        list: gcloud(`beta billing accounts list --filter=open=true`),
        display: 'displayName'
      })
    ).name
  );
  gcloud(
    `beta billing projects link ${projectId} --billing-account="${accountId}`,
    false
  );

  // enable APIs
  gcloud(`services enable admin.googleapis.com`);
  gcloud(`services enable iap.googleapis.com`);
  gcloud(`services enable secretmanager.googleapis.com`);
  gcloud(`services enable cloudscheduler.googleapis.com`);
  gcloud(`services enable appengine.googleapis.com`);

  // configure workspace admin as owner
  // TODO output directions/links
  const googleDelegatedAdmin = await readline.question(
    'Enter the Google ID for a Workspace Admin who will delegate authority for this app'
  );
  gcloud(
    `projects add-iam-policy-binding ${projectId} --member="user:${googleDelegatedAdmin}" --role="roles/owner"`,
    false
  );

  // create App Engine instance
  // TODO set default region us-east4
  const region = await choiceFrom({
    prompt: 'Select a region for the app engine instance',
    list: gcloud(`app regions list`),
    display: 'region'
  }).region;
  gcloud(`app create --region=${region}`);
  const url = `https://${gcloud(`app describe`).defaultHostname}`;
  fs.writeFileSync(
    '.env',
    `PROJECT=${projectId}
URL=${url}`
  );

  // create default instance so IAP can be configured
  exec(`npm run build`);
  exec(`npm run deploy`);

  // configure IAP (and OAuth consent screen)
  const supportEmail = await nonEmpty({
    prompt: 'Enter a support email address for the app'
  });
  const brand = gcloud(
    `iap oauth-brands create --application_title${appName} --support_email=${supportEmail}`
  ).name;
  const oauth = gcloud(
    `iap oauth-clients create ${brand} --display_name=IAP-App-Engine-app`
  );
  gcloud(
    `iap web enable --resource-type=app-engine --oauth2-client-id=${path.basename(
      oauth.name
    )} --oauth2-client-secret=${oauth.secret}`
  );
  (
    await untilBlank({
      prompt: 'Email address of user who can access the app interface'
    })
  ).forEach((userEmail) =>
    gcloud(
      `projects add-iam-policy-binding ${projectId} --member="user:${userEmail}" --role="roles/iap.httpsResourceAccessor"`,
      false
    )
  );

  // configure Blackbaud SKY app
  const blackbaudAccessKey = await nonEmpty({
    prompt:
      'Enter a subscription access key from https://developer.blackbaud.com/subscriptions'
  });
  console.log('Create a new app at https://developer.blackbaud.com/apps');
  const blackbaudClientId = await nonEmpty({
    prompt: "Enter the app's OAuth client ID"
  });
  const blackbaudClientSecret = await nonEmpty({
    prompt: "Enter one of the app's OAuth secrets"
  });
  const blackbaudRedirectUrl = `${url}/redirect`;
  console.log(`Configure ${blackbaudRedirectUrl} as the app's redirect URL`);
  // TODO pause here?
  // TODO directions for limiting scope of app

  // configure delegated admin service account
  const serviceAccount = gcloud(
    `iam service-accounts create ${appName
      .toLowerCase()
      .replace(/[^a-z]/g, '-')
      .replace(/--/g, '-')} --display-name="Google Delegated Admin"`
  );
  console.log(
    `${googleDelegatedAdmin} needs to follow the directions at https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md`
  );
  const credentials = `${serviceAccount.uniqueId}.json`;
  gcloud(
    `iam service-accounts keys create ${credentials} --iam-account=${serviceAccount.email}`
  );
  console.log(`The Service Account Unique ID is ${serviceAccount.uniqueId}`);
  // TODO pause here?

  // store secrets
  exec(
    `echo "${blackbaudAccessKey}" | gcloud secrets create BLACKBAUD_ACCESS_KEY --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "null" | gcloud secrets create BLACKBAUD_API_TOKEN --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientId}" | gcloud secrets create BLACKBAUD_CLIENT_ID --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientSecret}" | gcloud secrets create BLACKBAUD_CLIENT_SECRET --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudRedirectUrl}" | gcloud secrets create BLACKBAUD_REDIRECT_URL --data-file=- ${flagsWithProject}`
  );
  gcloud(`secrets create GOOGLE_CREDENTIALS --data-file=${credentials}`);
  fs.unlinkSync(credentials);

  exec(
    `echo "${googleDelegatedAdmin}" | gcloud secrets create GOOGLE_DELEGATED_ADMIN --data-file=- ${flagsWithProject}`
  );

  console.log(`Authorize the app at ${url}`);
  // TODO pause here?

  // schedule daily sync
  // TODO configurable schedule
  // TODO configurable job name
  gcloud(
    `scheduler jobs create app-engine daily-blackbaud-to-google-sync --schedule="0 1 * * *" --relative-url="/sync"`
  );
})();

pause here?

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/d87e266cf36df1864a097c23f7cfa7ac256f2f22/scripts/setup.js#L244

(async () => {
  const process = require('process');
  const fs = require('fs');
  const child = require('child_process');
  const { rword } = require('rword');
  const path = require('path');
  const readline = require('readline/promises').createInterface({
    input: process.stdin,
    output: process.stdout
  });

  rword.load('big');
  function createProjectId() {
    const word1 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * 7)
    });
    const word2 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * (30 - 8 - word1.length - 4))
    });
    return `${word1}-${word2}-${Math.floor(99999 + Math.random() * 900001)}`;
  }

  const exec = (command) => child.execSync(command, { stdio: 'inherit' });

  function versionTest({
    name,
    download = undefined,
    command = undefined,
    fail = true
  }) {
    command = command || `${name} --version`;
    if (!/\d+\.\d/.test(child.execSync(command))) {
      if (fail) {
        console.error(
          `${name} is required${download ? `, install from ${download}` : ''}`
        );
        process.exit(1);
      } else {
        return false;
      }
    }
    return true;
  }

  // TODO configure project name
  const appName = 'Blackbaud-to-Google Group Sync';
  // TODO configure project ID
  const projectId = createProjectId();

  const flags = '--quiet --format=json';
  const flagsWithProject = `${flags} --project=${projectId}`;
  function gcloud(command, withProjectId = true) {
    let actualFlags = flagsWithProject;
    if (withProjectId === null) {
      actualFlags = '';
    } else if (actualFlags === false) {
      actualFlags = flags;
    }
    const result = child.execSync(`gcloud ${command} ${actualFlags} `);
    try {
      return JSON.parse(result);
    } catch (e) {
      return result;
    }
  }

  async function choiceFrom({ prompt, list, display, defaultChoice = 1 }) {
    switch (list.length) {
      case 0:
        throw new Error('empty list');
      case 1:
        defaultChoice = 1;
        break;
      default:
        if (defaultChoice > list.length) {
          defaultChoice = undefined;
        }
    }
    console.log(prompt);
    list.forEach((item, i) => console.log(`  ${i}. ${item[display]}`));

    let choice;
    let question = `(${defaultChoice > 1 ? 1 : '[1]'}-${defaultChoice < list.length ? `[${defaultChoice}]` : ''
      }-${defaultChoice === list.length ? `[${defaultChoice}]` : list.length})`;
    do {
      choice = await readline.question(question);
      if (choice.length) {
        choice = (parseInt(choice) || 0) - 1;
      } else {
        choice = defaultChoice - 1;
      }
    } while (choice < 0 || choice >= list.length);
    return list[choice];
  }

  async function nonEmpty({ prompt }) {
    let response;
    do {
      response = await readline.question(prompt);
    } while (response.length === 0);
    return response;
  }

  async function untilBlank({ prompt }) {
    const responses = [];
    let response;
    do {
      response = await readline.question(`${prompt} [<Enter> to end]`);
      if (response.length > 0) {
        responses.push(response);
      }
    } while (response.length > 0);
    return responses;
  }

  // set project root as cwd
  process.chdir(path.join(__dirname, '..'));

  // test for CLI dependencies
  versionTest({
    name: 'npm',
    download: 'https://nodejs.org/'
  });
  versionTest({
    name: 'composer',
    download: 'https://getcomposer.org/'
  });
  versionTest({
    name: 'gcloud',
    download: 'https://cloud.google.com/sdk/docs/install'
  });
  const pnpm = versionTest({
    name: 'pnpm',
    dowload: 'https://pnpm.io/',
    fail: false
  });

  // install dependencies
  exec(`${pnpm ? 'pnpm' : 'npm'} install`);
  exec('composer install');

  // create a new project
  let response = gcloud(
    `projects create --name="${appName}" ${projectId}`,
    false
  );
  if (/error/i.test(response)) {
    console.error(response);
    process.exit(1);
  }

  // enable billing
  gcloud(`components install beta`, null);
  const accountId = path.basename(
    (
      await choiceFrom({
        prompt: 'Select a billing account for this project',
        list: gcloud(`beta billing accounts list --filter=open=true`),
        display: 'displayName'
      })
    ).name
  );
  gcloud(
    `beta billing projects link ${projectId} --billing-account="${accountId}`,
    false
  );

  // enable APIs
  gcloud(`services enable admin.googleapis.com`);
  gcloud(`services enable iap.googleapis.com`);
  gcloud(`services enable secretmanager.googleapis.com`);
  gcloud(`services enable cloudscheduler.googleapis.com`);
  gcloud(`services enable appengine.googleapis.com`);

  // configure workspace admin as owner
  // TODO output directions/links
  const googleDelegatedAdmin = await readline.question(
    'Enter the Google ID for a Workspace Admin who will delegate authority for this app'
  );
  gcloud(
    `projects add-iam-policy-binding ${projectId} --member="user:${googleDelegatedAdmin}" --role="roles/owner"`,
    false
  );

  // create App Engine instance
  // TODO set default region us-east4
  const region = await choiceFrom({
    prompt: 'Select a region for the app engine instance',
    list: gcloud(`app regions list`),
    display: 'region'
  }).region;
  gcloud(`app create --region=${region}`);
  const url = `https://${gcloud(`app describe`).defaultHostname}`;
  fs.writeFileSync(
    '.env',
    `PROJECT=${projectId}
URL=${url}`
  );

  // create default instance so IAP can be configured
  exec(`npm run build`);
  exec(`npm run deploy`);

  // configure IAP (and OAuth consent screen)
  const supportEmail = await nonEmpty({
    prompt: 'Enter a support email address for the app'
  });
  const brand = gcloud(
    `iap oauth-brands create --application_title${appName} --support_email=${supportEmail}`
  ).name;
  const oauth = gcloud(
    `iap oauth-clients create ${brand} --display_name=IAP-App-Engine-app`
  );
  gcloud(
    `iap web enable --resource-type=app-engine --oauth2-client-id=${path.basename(
      oauth.name
    )} --oauth2-client-secret=${oauth.secret}`
  );
  (
    await untilBlank({
      prompt: 'Email address of user who can access the app interface'
    })
  ).forEach((userEmail) =>
    gcloud(
      `projects add-iam-policy-binding ${projectId} --member="user:${userEmail}" --role="roles/iap.httpsResourceAccessor"`,
      false
    )
  );

  // configure Blackbaud SKY app
  const blackbaudAccessKey = await nonEmpty({
    prompt:
      'Enter a subscription access key from https://developer.blackbaud.com/subscriptions'
  });
  console.log('Create a new app at https://developer.blackbaud.com/apps');
  const blackbaudClientId = await nonEmpty({
    prompt: "Enter the app's OAuth client ID"
  });
  const blackbaudClientSecret = await nonEmpty({
    prompt: "Enter one of the app's OAuth secrets"
  });
  const blackbaudRedirectUrl = `${url}/redirect`;
  console.log(`Configure ${blackbaudRedirectUrl} as the app's redirect URL`);
  // TODO pause here?
  // TODO directions for limiting scope of app

  // configure delegated admin service account
  const serviceAccount = gcloud(
    `iam service-accounts create ${appName
      .toLowerCase()
      .replace(/[^a-z]/g, '-')
      .replace(/--/g, '-')} --display-name="Google Delegated Admin"`
  );
  console.log(
    `${googleDelegatedAdmin} needs to follow the directions at https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md`
  );
  const credentials = `${serviceAccount.uniqueId}.json`;
  gcloud(
    `iam service-accounts keys create ${credentials} --iam-account=${serviceAccount.email}`
  );
  console.log(`The Service Account Unique ID is ${serviceAccount.uniqueId}`);
  // TODO pause here?

  // store secrets
  exec(
    `echo "${blackbaudAccessKey}" | gcloud secrets create BLACKBAUD_ACCESS_KEY --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "null" | gcloud secrets create BLACKBAUD_API_TOKEN --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientId}" | gcloud secrets create BLACKBAUD_CLIENT_ID --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientSecret}" | gcloud secrets create BLACKBAUD_CLIENT_SECRET --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudRedirectUrl}" | gcloud secrets create BLACKBAUD_REDIRECT_URL --data-file=- ${flagsWithProject}`
  );
  gcloud(`secrets create GOOGLE_CREDENTIALS --data-file=${credentials}`);
  fs.unlinkSync(credentials);

  exec(
    `echo "${googleDelegatedAdmin}" | gcloud secrets create GOOGLE_DELEGATED_ADMIN --data-file=- ${flagsWithProject}`
  );

  console.log(`Authorize the app at ${url}`);
  // TODO pause here?

  // schedule daily sync
  // TODO configurable schedule
  // TODO configurable job name
  gcloud(
    `scheduler jobs create app-engine daily-blackbaud-to-google-sync --schedule="0 1 * * *" --relative-url="/sync"`
  );
})();

need to test for existence of Google Group and create if not present

// TODO need to test for existence of Google Group and create if not present

            dump($bbMembers, "bbMembers");

            step("compare to Google membership");
            // TODO need to test for existence of Google Group and create if not present
            // TODO should have a param that determines if Google Groups are created if not found
            $purge = [];
            foreach (
                $directory->members->listMembers($bbGroup->getParamEmail())

configure project ID

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/d87e266cf36df1864a097c23f7cfa7ac256f2f22/scripts/setup.js#L47

(async () => {
  const process = require('process');
  const fs = require('fs');
  const child = require('child_process');
  const { rword } = require('rword');
  const path = require('path');
  const readline = require('readline/promises').createInterface({
    input: process.stdin,
    output: process.stdout
  });

  rword.load('big');
  function createProjectId() {
    const word1 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * 7)
    });
    const word2 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * (30 - 8 - word1.length - 4))
    });
    return `${word1}-${word2}-${Math.floor(99999 + Math.random() * 900001)}`;
  }

  const exec = (command) => child.execSync(command, { stdio: 'inherit' });

  function versionTest({
    name,
    download = undefined,
    command = undefined,
    fail = true
  }) {
    command = command || `${name} --version`;
    if (!/\d+\.\d/.test(child.execSync(command))) {
      if (fail) {
        console.error(
          `${name} is required${download ? `, install from ${download}` : ''}`
        );
        process.exit(1);
      } else {
        return false;
      }
    }
    return true;
  }

  // TODO configure project name
  const appName = 'Blackbaud-to-Google Group Sync';
  // TODO configure project ID
  const projectId = createProjectId();

  const flags = '--quiet --format=json';
  const flagsWithProject = `${flags} --project=${projectId}`;
  function gcloud(command, withProjectId = true) {
    let actualFlags = flagsWithProject;
    if (withProjectId === null) {
      actualFlags = '';
    } else if (actualFlags === false) {
      actualFlags = flags;
    }
    const result = child.execSync(`gcloud ${command} ${actualFlags} `);
    try {
      return JSON.parse(result);
    } catch (e) {
      return result;
    }
  }

  async function choiceFrom({ prompt, list, display, defaultChoice = 1 }) {
    switch (list.length) {
      case 0:
        throw new Error('empty list');
      case 1:
        defaultChoice = 1;
        break;
      default:
        if (defaultChoice > list.length) {
          defaultChoice = undefined;
        }
    }
    console.log(prompt);
    list.forEach((item, i) => console.log(`  ${i}. ${item[display]}`));

    let choice;
    let question = `(${defaultChoice > 1 ? 1 : '[1]'}-${defaultChoice < list.length ? `[${defaultChoice}]` : ''
      }-${defaultChoice === list.length ? `[${defaultChoice}]` : list.length})`;
    do {
      choice = await readline.question(question);
      if (choice.length) {
        choice = (parseInt(choice) || 0) - 1;
      } else {
        choice = defaultChoice - 1;
      }
    } while (choice < 0 || choice >= list.length);
    return list[choice];
  }

  async function nonEmpty({ prompt }) {
    let response;
    do {
      response = await readline.question(prompt);
    } while (response.length === 0);
    return response;
  }

  async function untilBlank({ prompt }) {
    const responses = [];
    let response;
    do {
      response = await readline.question(`${prompt} [<Enter> to end]`);
      if (response.length > 0) {
        responses.push(response);
      }
    } while (response.length > 0);
    return responses;
  }

  // set project root as cwd
  process.chdir(path.join(__dirname, '..'));

  // test for CLI dependencies
  versionTest({
    name: 'npm',
    download: 'https://nodejs.org/'
  });
  versionTest({
    name: 'composer',
    download: 'https://getcomposer.org/'
  });
  versionTest({
    name: 'gcloud',
    download: 'https://cloud.google.com/sdk/docs/install'
  });
  const pnpm = versionTest({
    name: 'pnpm',
    dowload: 'https://pnpm.io/',
    fail: false
  });

  // install dependencies
  exec(`${pnpm ? 'pnpm' : 'npm'} install`);
  exec('composer install');

  // create a new project
  let response = gcloud(
    `projects create --name="${appName}" ${projectId}`,
    false
  );
  if (/error/i.test(response)) {
    console.error(response);
    process.exit(1);
  }

  // enable billing
  gcloud(`components install beta`, null);
  const accountId = path.basename(
    (
      await choiceFrom({
        prompt: 'Select a billing account for this project',
        list: gcloud(`beta billing accounts list --filter=open=true`),
        display: 'displayName'
      })
    ).name
  );
  gcloud(
    `beta billing projects link ${projectId} --billing-account="${accountId}`,
    false
  );

  // enable APIs
  gcloud(`services enable admin.googleapis.com`);
  gcloud(`services enable iap.googleapis.com`);
  gcloud(`services enable secretmanager.googleapis.com`);
  gcloud(`services enable cloudscheduler.googleapis.com`);
  gcloud(`services enable appengine.googleapis.com`);

  // configure workspace admin as owner
  // TODO output directions/links
  const googleDelegatedAdmin = await readline.question(
    'Enter the Google ID for a Workspace Admin who will delegate authority for this app'
  );
  gcloud(
    `projects add-iam-policy-binding ${projectId} --member="user:${googleDelegatedAdmin}" --role="roles/owner"`,
    false
  );

  // create App Engine instance
  // TODO set default region us-east4
  const region = await choiceFrom({
    prompt: 'Select a region for the app engine instance',
    list: gcloud(`app regions list`),
    display: 'region'
  }).region;
  gcloud(`app create --region=${region}`);
  const url = `https://${gcloud(`app describe`).defaultHostname}`;
  fs.writeFileSync(
    '.env',
    `PROJECT=${projectId}
URL=${url}`
  );

  // create default instance so IAP can be configured
  exec(`npm run build`);
  exec(`npm run deploy`);

  // configure IAP (and OAuth consent screen)
  const supportEmail = await nonEmpty({
    prompt: 'Enter a support email address for the app'
  });
  const brand = gcloud(
    `iap oauth-brands create --application_title${appName} --support_email=${supportEmail}`
  ).name;
  const oauth = gcloud(
    `iap oauth-clients create ${brand} --display_name=IAP-App-Engine-app`
  );
  gcloud(
    `iap web enable --resource-type=app-engine --oauth2-client-id=${path.basename(
      oauth.name
    )} --oauth2-client-secret=${oauth.secret}`
  );
  (
    await untilBlank({
      prompt: 'Email address of user who can access the app interface'
    })
  ).forEach((userEmail) =>
    gcloud(
      `projects add-iam-policy-binding ${projectId} --member="user:${userEmail}" --role="roles/iap.httpsResourceAccessor"`,
      false
    )
  );

  // configure Blackbaud SKY app
  const blackbaudAccessKey = await nonEmpty({
    prompt:
      'Enter a subscription access key from https://developer.blackbaud.com/subscriptions'
  });
  console.log('Create a new app at https://developer.blackbaud.com/apps');
  const blackbaudClientId = await nonEmpty({
    prompt: "Enter the app's OAuth client ID"
  });
  const blackbaudClientSecret = await nonEmpty({
    prompt: "Enter one of the app's OAuth secrets"
  });
  const blackbaudRedirectUrl = `${url}/redirect`;
  console.log(`Configure ${blackbaudRedirectUrl} as the app's redirect URL`);
  // TODO pause here?
  // TODO directions for limiting scope of app

  // configure delegated admin service account
  const serviceAccount = gcloud(
    `iam service-accounts create ${appName
      .toLowerCase()
      .replace(/[^a-z]/g, '-')
      .replace(/--/g, '-')} --display-name="Google Delegated Admin"`
  );
  console.log(
    `${googleDelegatedAdmin} needs to follow the directions at https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md`
  );
  const credentials = `${serviceAccount.uniqueId}.json`;
  gcloud(
    `iam service-accounts keys create ${credentials} --iam-account=${serviceAccount.email}`
  );
  console.log(`The Service Account Unique ID is ${serviceAccount.uniqueId}`);
  // TODO pause here?

  // store secrets
  exec(
    `echo "${blackbaudAccessKey}" | gcloud secrets create BLACKBAUD_ACCESS_KEY --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "null" | gcloud secrets create BLACKBAUD_API_TOKEN --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientId}" | gcloud secrets create BLACKBAUD_CLIENT_ID --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientSecret}" | gcloud secrets create BLACKBAUD_CLIENT_SECRET --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudRedirectUrl}" | gcloud secrets create BLACKBAUD_REDIRECT_URL --data-file=- ${flagsWithProject}`
  );
  gcloud(`secrets create GOOGLE_CREDENTIALS --data-file=${credentials}`);
  fs.unlinkSync(credentials);

  exec(
    `echo "${googleDelegatedAdmin}" | gcloud secrets create GOOGLE_DELEGATED_ADMIN --data-file=- ${flagsWithProject}`
  );

  console.log(`Authorize the app at ${url}`);
  // TODO pause here?

  // schedule daily sync
  // TODO configurable schedule
  // TODO configurable job name
  gcloud(
    `scheduler jobs create app-engine daily-blackbaud-to-google-sync --schedule="0 1 * * *" --relative-url="/sync"`
  );
})();

check if accountId arg exists/is open

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/a5798ad17c340786114c6de2733970f1cf1cb48d/scripts/setup.js#L140

#!/usr/bin/env node
// @ts-nocheck (because it _really_ doesn't like CancelablePromise)
import { confirm, input, select } from '@inquirer/prompts';
import dotenv from 'dotenv';
import email from 'email-validator';
import fs from 'fs';
import { jack } from 'jackspeak';
import open from 'open';
import path from 'path';
import process from 'process';
import { fileURLToPath } from 'url';
import gcloud from './gcloud.js';
import lib from './lib.js';

const nonEmptyValidator = (value) =>
  (value && value.length > 0) || 'May not be empty';
const maxLengthValidator = (maxLength, value) =>
  (nonEmptyValidator(value) && value && value.length <= maxLength) ||
  `Must be ${maxLength} characters or fewer`;
const emailValidator = (value) =>
  (nonEmptyValidator(value) && email.validate(value)) ||
  'Must be a valid mail address';

async function verifyExternalDependencies() {
  lib.versionTest({
    name: 'npm',
    download: 'https://nodejs.org/'
  });
  lib.versionTest({
    name: 'gcloud',
    download: 'https://cloud.google.com/sdk/docs/install'
  });
}

async function parseArguments() {
  const j = jack({ envPrefix: 'ARG' })
    .flag({
      help: {
        short: 'h'
      }
    })
    .opt({
      project: {
        short: 'p',
        description: 'Google Cloud project unique identifier'
      },
      name: {
        short: 'n',
        description: 'Google Cloud project name',
        default: 'Blackbaud-to-Google Group Sync'
      },
      billing: {
        short: 'b',
        description: 'Google Cloud billing account ID for this project'
      },
      delegatedAdmin: {
        short: 'a',
        description:
          'Google Workspace admin account that will delegate access to Admin SDK API'
      },
      region: {
        short: 'r',
        description:
          'Google Cloud region in which to create App Engine instance'
      },
      supportEmail: {
        short: 'e',
        description: 'Support email address for app OAuth consent screen'
      },
      scheduleName: {
        short: 's',
        description: 'Google Cloud Scheduler task name for automatic sync',
        default: 'daily-blackbaud-to-google-group-sync'
      },
      scheduleCron: {
        short: 'c',
        description:
          'Google Cloud Scheduler crontab definition for automatic sync',
        default: '0 1 * * *'
      }
    })
    .optList({
      user: {
        short: 'u',
        description: 'Google ID of user who can access the app through IAP',
        delim: ',',
        default: []
      }
    });
  const { values } = await j.parse();
  if (values.help) {
    lib.log(j.usage());
    process.exit(0);
  }
  return values;
}

async function initializeProject({ projectName, projectId = undefined }) {
  projectName = await input({
    message: 'Google Cloud project name',
    validate: maxLengthValidator.bind(null, 30),
    default: projectName
  });
  gcloud.setProjectId(
    await input({
      message: 'Google Cloud project ID',
      validate: maxLengthValidator.bind(null, 30),
      default: projectId || gcloud.getProjectId()
    })
  );
  return projectName;
}

async function createProject({ projectName }) {
  const [project] = gcloud.invoke(
    `projects list --filter=projectId=${gcloud.getProjectId()}`
  );
  if (project) {
    if (
      !(await confirm({
        message: `(Re)configure existing project ${lib.value(
          project.projectId
        )}?`
      }))
    ) {
      throw new Error('must create or reuse project');
    }
  } else {
    let response = gcloud.invoke(
      `projects create --name="${projectName}" ${gcloud.getProjectId()}`,
      false
    );
    if (/error/i.test(response)) {
      throw new Error(response);
    }
  }
}

async function enableBilling({ accountId = undefined }) {
  // TODO check if accountId arg exists/is open
  if (!accountId) {
    const choices = gcloud
      .invokeBeta(`billing accounts list --filter=open=true`)
      .map((account) => ({
        name: account.displayName,
        value: path.basename(account.name)
      }));
    if (choices.length > 1) {
      accountId = await select({
        message: 'Select a billing account for this project',
        choices
      });
    } else if (
      choices.length === 1 &&
      (await confirm({
        message: `Use billing account ${lib.value(choices[0].name)}?`
      }))
    ) {
      accountId = choices[0].value;
    }
  }

  if (accountId) {
    gcloud.invokeBeta(
      `billing projects link ${gcloud.getProjectId()} --billing-account="${accountId}"`,
      false
    );
  } else {
    await open(
      `https://console.cloud.google.com/billing/?project=${gcloud.getProjectId()}`
    );
    await confirm({

Case-insensitive email comparison

Right now Blackbaud is delivering some emails capitalized, but Google delivers all emails in lowercase, causing the comparison to mismatch and users to get deleted and re-added. No data loss, but unnecessary roster updates. We need to treat email addresses case-insensitively.

should have a param that determines if Google Groups are created if not found

// TODO should have a param that determines if Google Groups are created if not found

            dump($bbMembers, "bbMembers");

            step("compare to Google membership");
            // TODO need to test for existence of Google Group and create if not present
            // TODO should have a param that determines if Google Groups are created if not found
            $purge = [];
            foreach (
                $directory->members->listMembers($bbGroup->getParamEmail())

Clearer progress information

Use the /progress/:sync_id endpoint to generate a GUI progress bar. Update the progress so that the current group being synced is always in view (currently "Parsing" replaces it).

filter users arg for email addresses

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/005060d2298556b60ef3ba9a414efd20f53aa3e2/scripts/setup.js#L314

    `projects add-iam-policy-binding ${gcloud.getProjectId()} --member="user:${delegatedAdmin}" --role="roles/owner"`,
    false
  );
  gcloud.invoke('services enable admin.googleapis.com');
  const name = projectName
    .toLowerCase()
    .replace(/[^a-z0-9]/g, '-')
    .replace(/--/g, '-');
  let serviceAccount = gcloud.invoke(
    `iam service-accounts list --filter=email=${name}@${gcloud.getProjectId()}.iam.gserviceaccount.com`
  )[0];
  if (!serviceAccount) {
    serviceAccount = gcloud.invoke(
      `iam service-accounts create ${name} --display-name="Google Delegated Admin"`
    );
  }
  /*
   * FIXME use Workload Identity Federation
   *  Service account keys could pose a security risk if compromised. We
   *  recommend you avoid downloading service account keys and instead use the
   *  Workload Identity Federation . You can learn more about the best way to
   *  authenticate service accounts on Google Cloud here.
   *  https://cloud.google.com/iam/docs/workload-identity-federation
   *  https://cloud.google.com/blog/products/identity-security/how-to-authenticate-service-accounts-to-help-keep-applications-secure
   */
  /*
   * FIXME max 10 keys available
   *  Need to delete one to create one
   */
  const credentialsPath = `.cache/${serviceAccount.uniqueId}.json`;
  gcloud.invoke(
    `iam service-accounts keys create ${credentialsPath} --iam-account=${serviceAccount.email}`
  );
  if (!delegated) {
    const url =
      'https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md';
    open(url);
    await confirm({
      message: `The Service Account Unique ID is ${lib.value(
        serviceAccount.uniqueId
      )}
Confirm that ${lib.value(
        delegatedAdmin
      )} has followed the directions at ${lib.url(url)}`
    });
  }

  return { delegatedAdmin, credentialsPath };
}

async function createAppEngineInstance({ region = undefined }) {
  gcloud.invoke('services enable appengine.googleapis.com');
  let app = gcloud.invoke('app describe');
  if (typeof instance === 'string') {
    region =
      region ||
      (await select({
        message: options.region.description,
        choices: gcloud
          .invoke(`app regions list`)
          .map((region) => ({ value: region.region }))
      }));
    gcloud.invoke(`app create --region=${region}`);
    app = gcloud.invoke('app describe');
  }

  const url = `https://${app.defaultHostname}`;
  fs.writeFileSync(
    '.env',
    `PROJECT=${gcloud.getProjectId()}
URL=${url}`
  );

  return app;
}

async function enableIdentityAwareProxy({
  projectName,
  supportEmail = undefined,
  users = undefined
}) {
  gcloud.invoke(`services enable iap.googleapis.com`);
  supportEmail =
    supportEmail ||
    (await input({
      message: options.supportEmail.description,
      validate: emailValidator
    }));
  const project = gcloud.invoke(
    `projects list --filter=projectId=${gcloud.getProjectId()}`,
    false
  )[0];
  let brand = gcloud.invoke(
    `iap oauth-brands list --filter=name=projects/${project.projectNumber}/brands/${project.projectNumber}`
  );
  brand = brand && brand.length && brand[0];
  if (!brand) {
    brand = gcloud.invoke(
      `iap oauth-brands create --application_title="${projectName}" --support_email=${supportEmail}`
    ).name;
  }
  let oauth = gcloud.invoke(`iap oauth-clients list ${brand.name}`);
  oauth = oauth && oauth.length && oauth[0];
  if (!oauth) {
    oauth = gcloud.invoke(
      `iap oauth-clients create ${brand} --display_name=IAP-App-Engine-app`
    );
  }
  gcloud.invoke(
    `iap web enable --resource-type=app-engine --oauth2-client-id=${path.basename(
      oauth.name
    )} --oauth2-client-secret=${oauth.secret}`
  );
  // FIXME filter users arg for email addresses
  users = (
    await input({
      message: options.users.description,
      validate: (value) =>
        value
          .split(',')
          .map((val) => val.trim())
          .reduce(
            (cond, val) =>
              cond && nonEmptyValidator(val) && emailValidator(val),
            true
          ) || 'all entries must be email addresses',
      default: users
    })
  )
    .split(',')
    .map((value) => value.trim());
  users.forEach((user) =>
    gcloud.invoke(
      `projects add-iam-policy-binding ${gcloud.getProjectId()} --member="user:${user}" --role="roles/iap.httpsResourceAccessor"`,

Do we want to think about how to update the actual email address?

// TODO Do we want to think about how to update the actual email address?

                    )
                );
            }

            // TODO Do we want to think about how to update the actual email address?
            step("update name");
            dump($bbGroup->getParamUpdateName(), "update-name");
            if ($bbGroup->getParamUpdateName()) {
                $gGroup = $directory->groups->get($bbGroup->getParamEmail());
                if ($gGroup->getName() != $bbGroup->getName()) {
                    $gGroup->setName($bbGroup->getName());
                    dump($gGroup, "gGroup");
                    dump($directory->groups->update($gGroup->getId(), $gGroup));
                }
            }
        }
    }
    step("complete");

pause here?

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/d87e266cf36df1864a097c23f7cfa7ac256f2f22/scripts/setup.js#L244

(async () => {
  const process = require('process');
  const fs = require('fs');
  const child = require('child_process');
  const { rword } = require('rword');
  const path = require('path');
  const readline = require('readline/promises').createInterface({
    input: process.stdin,
    output: process.stdout
  });

  rword.load('big');
  function createProjectId() {
    const word1 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * 7)
    });
    const word2 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * (30 - 8 - word1.length - 4))
    });
    return `${word1}-${word2}-${Math.floor(99999 + Math.random() * 900001)}`;
  }

  const exec = (command) => child.execSync(command, { stdio: 'inherit' });

  function versionTest({
    name,
    download = undefined,
    command = undefined,
    fail = true
  }) {
    command = command || `${name} --version`;
    if (!/\d+\.\d/.test(child.execSync(command))) {
      if (fail) {
        console.error(
          `${name} is required${download ? `, install from ${download}` : ''}`
        );
        process.exit(1);
      } else {
        return false;
      }
    }
    return true;
  }

  // TODO configure project name
  const appName = 'Blackbaud-to-Google Group Sync';
  // TODO configure project ID
  const projectId = createProjectId();

  const flags = '--quiet --format=json';
  const flagsWithProject = `${flags} --project=${projectId}`;
  function gcloud(command, withProjectId = true) {
    let actualFlags = flagsWithProject;
    if (withProjectId === null) {
      actualFlags = '';
    } else if (actualFlags === false) {
      actualFlags = flags;
    }
    const result = child.execSync(`gcloud ${command} ${actualFlags} `);
    try {
      return JSON.parse(result);
    } catch (e) {
      return result;
    }
  }

  async function choiceFrom({ prompt, list, display, defaultChoice = 1 }) {
    switch (list.length) {
      case 0:
        throw new Error('empty list');
      case 1:
        defaultChoice = 1;
        break;
      default:
        if (defaultChoice > list.length) {
          defaultChoice = undefined;
        }
    }
    console.log(prompt);
    list.forEach((item, i) => console.log(`  ${i}. ${item[display]}`));

    let choice;
    let question = `(${defaultChoice > 1 ? 1 : '[1]'}-${defaultChoice < list.length ? `[${defaultChoice}]` : ''
      }-${defaultChoice === list.length ? `[${defaultChoice}]` : list.length})`;
    do {
      choice = await readline.question(question);
      if (choice.length) {
        choice = (parseInt(choice) || 0) - 1;
      } else {
        choice = defaultChoice - 1;
      }
    } while (choice < 0 || choice >= list.length);
    return list[choice];
  }

  async function nonEmpty({ prompt }) {
    let response;
    do {
      response = await readline.question(prompt);
    } while (response.length === 0);
    return response;
  }

  async function untilBlank({ prompt }) {
    const responses = [];
    let response;
    do {
      response = await readline.question(`${prompt} [<Enter> to end]`);
      if (response.length > 0) {
        responses.push(response);
      }
    } while (response.length > 0);
    return responses;
  }

  // set project root as cwd
  process.chdir(path.join(__dirname, '..'));

  // test for CLI dependencies
  versionTest({
    name: 'npm',
    download: 'https://nodejs.org/'
  });
  versionTest({
    name: 'composer',
    download: 'https://getcomposer.org/'
  });
  versionTest({
    name: 'gcloud',
    download: 'https://cloud.google.com/sdk/docs/install'
  });
  const pnpm = versionTest({
    name: 'pnpm',
    dowload: 'https://pnpm.io/',
    fail: false
  });

  // install dependencies
  exec(`${pnpm ? 'pnpm' : 'npm'} install`);
  exec('composer install');

  // create a new project
  let response = gcloud(
    `projects create --name="${appName}" ${projectId}`,
    false
  );
  if (/error/i.test(response)) {
    console.error(response);
    process.exit(1);
  }

  // enable billing
  gcloud(`components install beta`, null);
  const accountId = path.basename(
    (
      await choiceFrom({
        prompt: 'Select a billing account for this project',
        list: gcloud(`beta billing accounts list --filter=open=true`),
        display: 'displayName'
      })
    ).name
  );
  gcloud(
    `beta billing projects link ${projectId} --billing-account="${accountId}`,
    false
  );

  // enable APIs
  gcloud(`services enable admin.googleapis.com`);
  gcloud(`services enable iap.googleapis.com`);
  gcloud(`services enable secretmanager.googleapis.com`);
  gcloud(`services enable cloudscheduler.googleapis.com`);
  gcloud(`services enable appengine.googleapis.com`);

  // configure workspace admin as owner
  // TODO output directions/links
  const googleDelegatedAdmin = await readline.question(
    'Enter the Google ID for a Workspace Admin who will delegate authority for this app'
  );
  gcloud(
    `projects add-iam-policy-binding ${projectId} --member="user:${googleDelegatedAdmin}" --role="roles/owner"`,
    false
  );

  // create App Engine instance
  // TODO set default region us-east4
  const region = await choiceFrom({
    prompt: 'Select a region for the app engine instance',
    list: gcloud(`app regions list`),
    display: 'region'
  }).region;
  gcloud(`app create --region=${region}`);
  const url = `https://${gcloud(`app describe`).defaultHostname}`;
  fs.writeFileSync(
    '.env',
    `PROJECT=${projectId}
URL=${url}`
  );

  // create default instance so IAP can be configured
  exec(`npm run build`);
  exec(`npm run deploy`);

  // configure IAP (and OAuth consent screen)
  const supportEmail = await nonEmpty({
    prompt: 'Enter a support email address for the app'
  });
  const brand = gcloud(
    `iap oauth-brands create --application_title${appName} --support_email=${supportEmail}`
  ).name;
  const oauth = gcloud(
    `iap oauth-clients create ${brand} --display_name=IAP-App-Engine-app`
  );
  gcloud(
    `iap web enable --resource-type=app-engine --oauth2-client-id=${path.basename(
      oauth.name
    )} --oauth2-client-secret=${oauth.secret}`
  );
  (
    await untilBlank({
      prompt: 'Email address of user who can access the app interface'
    })
  ).forEach((userEmail) =>
    gcloud(
      `projects add-iam-policy-binding ${projectId} --member="user:${userEmail}" --role="roles/iap.httpsResourceAccessor"`,
      false
    )
  );

  // configure Blackbaud SKY app
  const blackbaudAccessKey = await nonEmpty({
    prompt:
      'Enter a subscription access key from https://developer.blackbaud.com/subscriptions'
  });
  console.log('Create a new app at https://developer.blackbaud.com/apps');
  const blackbaudClientId = await nonEmpty({
    prompt: "Enter the app's OAuth client ID"
  });
  const blackbaudClientSecret = await nonEmpty({
    prompt: "Enter one of the app's OAuth secrets"
  });
  const blackbaudRedirectUrl = `${url}/redirect`;
  console.log(`Configure ${blackbaudRedirectUrl} as the app's redirect URL`);
  // TODO pause here?
  // TODO directions for limiting scope of app

  // configure delegated admin service account
  const serviceAccount = gcloud(
    `iam service-accounts create ${appName
      .toLowerCase()
      .replace(/[^a-z]/g, '-')
      .replace(/--/g, '-')} --display-name="Google Delegated Admin"`
  );
  console.log(
    `${googleDelegatedAdmin} needs to follow the directions at https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md`
  );
  const credentials = `${serviceAccount.uniqueId}.json`;
  gcloud(
    `iam service-accounts keys create ${credentials} --iam-account=${serviceAccount.email}`
  );
  console.log(`The Service Account Unique ID is ${serviceAccount.uniqueId}`);
  // TODO pause here?

  // store secrets
  exec(
    `echo "${blackbaudAccessKey}" | gcloud secrets create BLACKBAUD_ACCESS_KEY --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "null" | gcloud secrets create BLACKBAUD_API_TOKEN --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientId}" | gcloud secrets create BLACKBAUD_CLIENT_ID --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientSecret}" | gcloud secrets create BLACKBAUD_CLIENT_SECRET --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudRedirectUrl}" | gcloud secrets create BLACKBAUD_REDIRECT_URL --data-file=- ${flagsWithProject}`
  );
  gcloud(`secrets create GOOGLE_CREDENTIALS --data-file=${credentials}`);
  fs.unlinkSync(credentials);

  exec(
    `echo "${googleDelegatedAdmin}" | gcloud secrets create GOOGLE_DELEGATED_ADMIN --data-file=- ${flagsWithProject}`
  );

  console.log(`Authorize the app at ${url}`);
  // TODO pause here?

  // schedule daily sync
  // TODO configurable schedule
  // TODO configurable job name
  gcloud(
    `scheduler jobs create app-engine daily-blackbaud-to-google-sync --schedule="0 1 * * *" --relative-url="/sync"`
  );
})();

use Workload Identity Federation

Service account keys could pose a security risk if compromised. We recommend you avoid downloading service account keys and instead use the Workload Identity Federation. You can learn more about the best way to authenticate service accounts on Google Cloud here.

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/005060d2298556b60ef3ba9a414efd20f53aa3e2/scripts/setup.js#L218

  /*
   * FIXME use Workload Identity Federation
   *  Service account keys could pose a security risk if compromised. We
   *  recommend you avoid downloading service account keys and instead use the
   *  Workload Identity Federation . You can learn more about the best way to
   *  authenticate service accounts on Google Cloud here.
   *  https://cloud.google.com/iam/docs/workload-identity-federation
   *  https://cloud.google.com/blog/products/identity-security/how-to-authenticate-service-accounts-to-help-keep-applications-secure
   */
  /*
   * FIXME max 10 keys available
   *  Need to delete one to create one
   */
  const credentialsPath = `.cache/${serviceAccount.uniqueId}.json`;
  gcloud.invoke(
    `iam service-accounts keys create ${credentialsPath} --iam-account=${serviceAccount.email}`
  );
  if (!delegated) {
    const url =
      'https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md';
    open(url);
    await confirm({
      message: `The Service Account Unique ID is ${lib.value(
        serviceAccount.uniqueId
      )}
Confirm that ${lib.value(
        delegatedAdmin
      )} has followed the directions at ${lib.url(url)}`
    });
  }

pause here?

https://api.github.com/groton-school/blackbaud-to-google-group-sync/blob/d87e266cf36df1864a097c23f7cfa7ac256f2f22/scripts/setup.js#L244

(async () => {
  const process = require('process');
  const fs = require('fs');
  const child = require('child_process');
  const { rword } = require('rword');
  const path = require('path');
  const readline = require('readline/promises').createInterface({
    input: process.stdin,
    output: process.stdout
  });

  rword.load('big');
  function createProjectId() {
    const word1 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * 7)
    });
    const word2 = rword.generate(1, {
      length: 4 + Math.floor(Math.random() * (30 - 8 - word1.length - 4))
    });
    return `${word1}-${word2}-${Math.floor(99999 + Math.random() * 900001)}`;
  }

  const exec = (command) => child.execSync(command, { stdio: 'inherit' });

  function versionTest({
    name,
    download = undefined,
    command = undefined,
    fail = true
  }) {
    command = command || `${name} --version`;
    if (!/\d+\.\d/.test(child.execSync(command))) {
      if (fail) {
        console.error(
          `${name} is required${download ? `, install from ${download}` : ''}`
        );
        process.exit(1);
      } else {
        return false;
      }
    }
    return true;
  }

  // TODO configure project name
  const appName = 'Blackbaud-to-Google Group Sync';
  // TODO configure project ID
  const projectId = createProjectId();

  const flags = '--quiet --format=json';
  const flagsWithProject = `${flags} --project=${projectId}`;
  function gcloud(command, withProjectId = true) {
    let actualFlags = flagsWithProject;
    if (withProjectId === null) {
      actualFlags = '';
    } else if (actualFlags === false) {
      actualFlags = flags;
    }
    const result = child.execSync(`gcloud ${command} ${actualFlags} `);
    try {
      return JSON.parse(result);
    } catch (e) {
      return result;
    }
  }

  async function choiceFrom({ prompt, list, display, defaultChoice = 1 }) {
    switch (list.length) {
      case 0:
        throw new Error('empty list');
      case 1:
        defaultChoice = 1;
        break;
      default:
        if (defaultChoice > list.length) {
          defaultChoice = undefined;
        }
    }
    console.log(prompt);
    list.forEach((item, i) => console.log(`  ${i}. ${item[display]}`));

    let choice;
    let question = `(${defaultChoice > 1 ? 1 : '[1]'}-${defaultChoice < list.length ? `[${defaultChoice}]` : ''
      }-${defaultChoice === list.length ? `[${defaultChoice}]` : list.length})`;
    do {
      choice = await readline.question(question);
      if (choice.length) {
        choice = (parseInt(choice) || 0) - 1;
      } else {
        choice = defaultChoice - 1;
      }
    } while (choice < 0 || choice >= list.length);
    return list[choice];
  }

  async function nonEmpty({ prompt }) {
    let response;
    do {
      response = await readline.question(prompt);
    } while (response.length === 0);
    return response;
  }

  async function untilBlank({ prompt }) {
    const responses = [];
    let response;
    do {
      response = await readline.question(`${prompt} [<Enter> to end]`);
      if (response.length > 0) {
        responses.push(response);
      }
    } while (response.length > 0);
    return responses;
  }

  // set project root as cwd
  process.chdir(path.join(__dirname, '..'));

  // test for CLI dependencies
  versionTest({
    name: 'npm',
    download: 'https://nodejs.org/'
  });
  versionTest({
    name: 'composer',
    download: 'https://getcomposer.org/'
  });
  versionTest({
    name: 'gcloud',
    download: 'https://cloud.google.com/sdk/docs/install'
  });
  const pnpm = versionTest({
    name: 'pnpm',
    dowload: 'https://pnpm.io/',
    fail: false
  });

  // install dependencies
  exec(`${pnpm ? 'pnpm' : 'npm'} install`);
  exec('composer install');

  // create a new project
  let response = gcloud(
    `projects create --name="${appName}" ${projectId}`,
    false
  );
  if (/error/i.test(response)) {
    console.error(response);
    process.exit(1);
  }

  // enable billing
  gcloud(`components install beta`, null);
  const accountId = path.basename(
    (
      await choiceFrom({
        prompt: 'Select a billing account for this project',
        list: gcloud(`beta billing accounts list --filter=open=true`),
        display: 'displayName'
      })
    ).name
  );
  gcloud(
    `beta billing projects link ${projectId} --billing-account="${accountId}`,
    false
  );

  // enable APIs
  gcloud(`services enable admin.googleapis.com`);
  gcloud(`services enable iap.googleapis.com`);
  gcloud(`services enable secretmanager.googleapis.com`);
  gcloud(`services enable cloudscheduler.googleapis.com`);
  gcloud(`services enable appengine.googleapis.com`);

  // configure workspace admin as owner
  // TODO output directions/links
  const googleDelegatedAdmin = await readline.question(
    'Enter the Google ID for a Workspace Admin who will delegate authority for this app'
  );
  gcloud(
    `projects add-iam-policy-binding ${projectId} --member="user:${googleDelegatedAdmin}" --role="roles/owner"`,
    false
  );

  // create App Engine instance
  // TODO set default region us-east4
  const region = await choiceFrom({
    prompt: 'Select a region for the app engine instance',
    list: gcloud(`app regions list`),
    display: 'region'
  }).region;
  gcloud(`app create --region=${region}`);
  const url = `https://${gcloud(`app describe`).defaultHostname}`;
  fs.writeFileSync(
    '.env',
    `PROJECT=${projectId}
URL=${url}`
  );

  // create default instance so IAP can be configured
  exec(`npm run build`);
  exec(`npm run deploy`);

  // configure IAP (and OAuth consent screen)
  const supportEmail = await nonEmpty({
    prompt: 'Enter a support email address for the app'
  });
  const brand = gcloud(
    `iap oauth-brands create --application_title${appName} --support_email=${supportEmail}`
  ).name;
  const oauth = gcloud(
    `iap oauth-clients create ${brand} --display_name=IAP-App-Engine-app`
  );
  gcloud(
    `iap web enable --resource-type=app-engine --oauth2-client-id=${path.basename(
      oauth.name
    )} --oauth2-client-secret=${oauth.secret}`
  );
  (
    await untilBlank({
      prompt: 'Email address of user who can access the app interface'
    })
  ).forEach((userEmail) =>
    gcloud(
      `projects add-iam-policy-binding ${projectId} --member="user:${userEmail}" --role="roles/iap.httpsResourceAccessor"`,
      false
    )
  );

  // configure Blackbaud SKY app
  const blackbaudAccessKey = await nonEmpty({
    prompt:
      'Enter a subscription access key from https://developer.blackbaud.com/subscriptions'
  });
  console.log('Create a new app at https://developer.blackbaud.com/apps');
  const blackbaudClientId = await nonEmpty({
    prompt: "Enter the app's OAuth client ID"
  });
  const blackbaudClientSecret = await nonEmpty({
    prompt: "Enter one of the app's OAuth secrets"
  });
  const blackbaudRedirectUrl = `${url}/redirect`;
  console.log(`Configure ${blackbaudRedirectUrl} as the app's redirect URL`);
  // TODO pause here?
  // TODO directions for limiting scope of app

  // configure delegated admin service account
  const serviceAccount = gcloud(
    `iam service-accounts create ${appName
      .toLowerCase()
      .replace(/[^a-z]/g, '-')
      .replace(/--/g, '-')} --display-name="Google Delegated Admin"`
  );
  console.log(
    `${googleDelegatedAdmin} needs to follow the directions at https://github.com/groton-school/blackbaud-to-google-group-sync/blob/main/docs/google-workspace-admin.md`
  );
  const credentials = `${serviceAccount.uniqueId}.json`;
  gcloud(
    `iam service-accounts keys create ${credentials} --iam-account=${serviceAccount.email}`
  );
  console.log(`The Service Account Unique ID is ${serviceAccount.uniqueId}`);
  // TODO pause here?

  // store secrets
  exec(
    `echo "${blackbaudAccessKey}" | gcloud secrets create BLACKBAUD_ACCESS_KEY --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "null" | gcloud secrets create BLACKBAUD_API_TOKEN --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientId}" | gcloud secrets create BLACKBAUD_CLIENT_ID --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudClientSecret}" | gcloud secrets create BLACKBAUD_CLIENT_SECRET --data-file=- ${flagsWithProject}`
  );
  exec(
    `echo "${blackbaudRedirectUrl}" | gcloud secrets create BLACKBAUD_REDIRECT_URL --data-file=- ${flagsWithProject}`
  );
  gcloud(`secrets create GOOGLE_CREDENTIALS --data-file=${credentials}`);
  fs.unlinkSync(credentials);

  exec(
    `echo "${googleDelegatedAdmin}" | gcloud secrets create GOOGLE_DELEGATED_ADMIN --data-file=- ${flagsWithProject}`
  );

  console.log(`Authorize the app at ${url}`);
  // TODO pause here?

  // schedule daily sync
  // TODO configurable schedule
  // TODO configurable job name
  gcloud(
    `scheduler jobs create app-engine daily-blackbaud-to-google-sync --schedule="0 1 * * *" --relative-url="/sync"`
  );
})();

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.