├── .husky ├── .gitignore ├── pre-commit └── post-merge ├── .npmrc ├── .prettierrc ├── .gitignore ├── lib ├── read.js ├── delete.js ├── move.js ├── unzip.js ├── mkdirp.js ├── branch.js ├── mkdir.js ├── write.js ├── log.js ├── find.js ├── zip.js └── api.js ├── .editorconfig ├── .eslintrc.cjs ├── dw-cli.json.example ├── commands ├── remove.js ├── index.js ├── extract.js ├── init.js ├── activate.js ├── versions.js ├── job.js ├── keygen.js ├── clean.js ├── push.js ├── watch.js └── log.js ├── bin └── post-merge ├── index.d.ts ├── package.json ├── cli.js └── README.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | loglevel=error 2 | save=true 3 | save-exact=true 4 | progress=true 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "quoteProps": "consistent" 4 | } 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | bash bin/post-merge 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dw.json 3 | dw-cli.json 4 | test/data/* 5 | npm-debug.log 6 | .vscode 7 | jsconfig.json 8 | .history/ 9 | -------------------------------------------------------------------------------- /lib/read.js: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | 3 | export default async (file, options) => { 4 | return await got(file, options).text(); 5 | }; 6 | -------------------------------------------------------------------------------- /lib/delete.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import got from 'got'; 3 | 4 | export default (file, options) => { 5 | return got.delete(path.normalize(file), { 6 | ...options, 7 | throwHttpErrors: false, 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /lib/move.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import got from 'got'; 3 | 4 | export default (file, destination, options) => { 5 | return got(path.normalize(file), { 6 | ...options, 7 | method: 'MOVE', 8 | headers: { 9 | Destination: (options.baseURL + destination).replace( 10 | /([^:]\/)\/+/g, 11 | '$1' 12 | ), 13 | }, 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /lib/unzip.js: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | import path from 'node:path'; 3 | import got from 'got'; 4 | 5 | debug('unzip'); 6 | 7 | export default (file, options) => { 8 | const url = path.join(file); 9 | 10 | debug(`Unzipping ${url}`); 11 | 12 | return got.post(url, { 13 | ...options, 14 | throwHttpErrors: false, 15 | searchParams: { method: 'UNZIP' }, 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: false, 5 | es2021: true, 6 | node: true, 7 | }, 8 | extends: [ 9 | "eslint:recommended", 10 | "plugin:unicorn/recommended", 11 | "plugin:prettier/recommended", 12 | ], 13 | plugins: ["prettier"], 14 | parserOptions: { 15 | ecmaVersion: 13, 16 | sourceType: "module", 17 | }, 18 | rules: { 19 | "unicorn/no-process-exit": "off", 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /lib/mkdirp.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import mkdir from './mkdir.js'; 3 | 4 | export default async (directory, options) => { 5 | const folders = path 6 | .normalize(directory) 7 | .split('/') 8 | .filter((folder) => folder.length); 9 | 10 | let index = 0; 11 | for (const folder of folders) { 12 | await mkdir( 13 | index > 0 ? folders.slice(0, index + 1).join('/') : folder, 14 | options 15 | ); 16 | index++; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /lib/branch.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | // @ts-ignore 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 7 | 8 | export default () => { 9 | try { 10 | return execSync('git rev-parse --abbrev-ref HEAD', { 11 | stdio: ['pipe', 'pipe', 'ignore'], 12 | encoding: 'utf8', 13 | }) 14 | .split('\n') 15 | .join(''); 16 | } catch { 17 | return path.dirname(__dirname); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /dw-cli.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "username": "default-user", 3 | "password": "default-pass", 4 | 5 | "cartridges": "cartridges-root-folder", 6 | "apiVersion": "v16_6", 7 | 8 | "clientId": "client-id-from-account-dashboard", 9 | "clientPassword": "client-password-from-account-dashboard", 10 | 11 | "instances": { 12 | "staging": { 13 | "hostname": "stage.hostname.com", 14 | "webdav": "cert.staging.region.brand.demandware.net", 15 | "key": "./user.key", 16 | "cert": "./user.pem", 17 | "ca": "./staging.cert" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /commands/remove.js: -------------------------------------------------------------------------------- 1 | import ora from 'ora'; 2 | import del from '../lib/delete.js'; 3 | import log from '../lib/log.js'; 4 | 5 | export default async (argv) => { 6 | const { codeVersion, webdav, request } = argv; 7 | log.info(`Removing ${codeVersion} from ${webdav}`); 8 | const spinner = ora(); 9 | 10 | try { 11 | spinner.start(); 12 | spinner.text = `Removing`; 13 | await del(`Cartridges/${codeVersion}`, request); 14 | spinner.succeed(); 15 | log.success('Success'); 16 | } catch (error) { 17 | spinner.fail(); 18 | log.error(error); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /lib/mkdir.js: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | 3 | export default async (url, options) => { 4 | try { 5 | const response = await got(url, { 6 | ...options, 7 | method: 'MKCOL', 8 | throwHttpErrors: false, 9 | }); 10 | return response; 11 | } catch (error) { 12 | return new Promise((resolve, reject) => { 13 | if (!error.response) { 14 | reject(new Error(error)); 15 | } else if (error.response.status === 405) { 16 | resolve(); 17 | } else { 18 | reject(new Error(error.response.statusText)); 19 | } 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /commands/index.js: -------------------------------------------------------------------------------- 1 | import activate from './activate.js'; 2 | import clean from './clean.js'; 3 | import extract from './extract.js'; 4 | import init from './init.js'; 5 | import job from './job.js'; 6 | import keygen from './keygen.js'; 7 | import log from './log.js'; 8 | import push from './push.js'; 9 | import remove from './remove.js'; 10 | import versions from './versions.js'; 11 | import watch from './watch.js'; 12 | 13 | export const commands = { 14 | activate, 15 | clean, 16 | extract, 17 | init, 18 | job, 19 | keygen, 20 | log, 21 | push, 22 | remove, 23 | versions, 24 | watch, 25 | }; 26 | -------------------------------------------------------------------------------- /bin/post-merge: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # MIT © Sindre Sorhus - sindresorhus.com 3 | 4 | # git hook to run a command after `git pull` if a specified file was changed 5 | # Run `chmod +x post-merge` to make it executable then put it into `.git/hooks/`. 6 | 7 | changed_files="$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)" 8 | 9 | check_run() { 10 | if echo "$changed_files" | grep -q $1; then 11 | echo "$1 changed. Running '$2'" 12 | eval "$2" 13 | echo "" 14 | fi 15 | # echo "$changed_files" | grep --quiet "$1" && eval "$2" 16 | } 17 | 18 | check_run package-lock.json "npm i" 19 | 20 | exit 0 21 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import https from 'node:https'; 2 | 3 | export interface DWArgv { 4 | username: string; 5 | password: string; 6 | hostname: string; 7 | apiVersion: string; 8 | clientId: string; 9 | clientPassword: string; 10 | codeVersion: string; 11 | cartridges: string; 12 | webdav: string; 13 | key: string; 14 | cert: string; 15 | ca: string; 16 | p12: string; 17 | passphrase: string; 18 | request: ArgvRequest; 19 | 20 | silent: boolean; 21 | spinner: boolean; 22 | remove: boolean; 23 | } 24 | 25 | type ArgvRequest = { 26 | baseURL: string; 27 | auth: {username: string; password: string}; 28 | httpsAgent: https.Agent; 29 | }; 30 | -------------------------------------------------------------------------------- /commands/extract.js: -------------------------------------------------------------------------------- 1 | import ora from 'ora'; 2 | import notifier from 'node-notifier'; 3 | import unzip from '../lib/unzip.js'; 4 | import log from '../lib/log.js'; 5 | 6 | export default async (argv) => { 7 | const { file, request } = argv; 8 | log.info(`Extracting ${file}`); 9 | const spinner = ora(); 10 | 11 | try { 12 | spinner.start(); 13 | spinner.text = `Unzipping ${file}`; 14 | await unzip(file, request); 15 | spinner.succeed(); 16 | 17 | log.success('Success'); 18 | notifier.notify({ 19 | title: 'Push', 20 | message: 'Success', 21 | }); 22 | } catch (error) { 23 | spinner.fail(); 24 | log.error(error); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /commands/init.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'fs-extra'; 3 | import { fileURLToPath } from 'node:url'; 4 | import log from '../lib/log.js'; 5 | 6 | // @ts-ignore 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 | 9 | export default async () => { 10 | try { 11 | await fs.stat(path.join(process.cwd(), 'dw-cli.json')); 12 | log.error(`'dw-cli.json' already exists`); 13 | } catch { 14 | const template = await fs.readFile( 15 | path.join(__dirname, '../dw-cli.json.example'), 16 | 'utf8' 17 | ); 18 | await fs.writeFile('dw-cli.json', template.trim(), 'utf8'); 19 | log.success(`'dw-cli.json' created`); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /lib/write.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import debug from 'debug'; 4 | import got from 'got'; 5 | 6 | debug('write'); 7 | 8 | export default async (source, destination, requestOptions, writeOptions) => { 9 | debug(`Uploading ${source}`); 10 | 11 | const url = path.join(destination, path.basename(source)); 12 | const stats = fs.statSync(source); 13 | 14 | await got 15 | .put(url, { 16 | body: fs.createReadStream(source), 17 | ...requestOptions, 18 | }) 19 | .on('uploadProgress', (progress) => { 20 | const percent = ((progress.transferred / stats.size) * 100).toFixed(2); 21 | writeOptions?.onProgress?.({ 22 | transferred: progress.transferred, 23 | total: stats.size, 24 | percent, 25 | }); 26 | }); 27 | 28 | return url; 29 | }; 30 | -------------------------------------------------------------------------------- /commands/activate.js: -------------------------------------------------------------------------------- 1 | import ora from 'ora'; 2 | import api from '../lib/api.js'; 3 | import log from '../lib/log.js'; 4 | 5 | /** 6 | * @param {import('../index.js').DWArgv} argv 7 | */ 8 | export default async (argv) => { 9 | const { clientId, clientPassword, hostname, apiVersion, codeVersion } = argv; 10 | log.info(`Activating ${codeVersion} on ${hostname}`); 11 | const spinner = ora().start(); 12 | 13 | try { 14 | const method = 'PATCH'; 15 | const endpoint = `https://${hostname}/s/-/dw/data/${apiVersion}/code_versions/${codeVersion}`; 16 | const body = { active: true }; 17 | spinner.text = 'Activating'; 18 | await api({ clientId, clientPassword, method, endpoint, body }); 19 | spinner.succeed(); 20 | log.success('Success'); 21 | } catch (error) { 22 | spinner.fail(); 23 | log.error(error); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /lib/log.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | const time = Date.now(); 4 | 5 | function makePrefix(string) { 6 | const date = new Date().toLocaleTimeString('en-US', { hour12: false }); 7 | return chalk.reset(`[${date}] ${string}`); 8 | } 9 | 10 | function makeSuffix(string) { 11 | const date = (Date.now() - time) / 1000; 12 | return chalk.reset(`${string} ${date}s`); 13 | } 14 | 15 | export default { 16 | plain(message, color) { 17 | if (color) { 18 | message = chalk[color](message); 19 | } 20 | console.log(message); 21 | }, 22 | info(message) { 23 | console.log(makePrefix(chalk.blue(message))); 24 | }, 25 | success(message) { 26 | console.log(makePrefix(makeSuffix(chalk.green(message)))); 27 | }, 28 | warn(message) { 29 | console.log(makePrefix(makeSuffix(chalk.yellow(message)))); 30 | }, 31 | error(message) { 32 | console.log(makePrefix(makeSuffix(chalk.red(message)))); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /lib/find.js: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | import { parseString } from 'xml2js'; 3 | import { get, forEach } from 'lodash-es'; 4 | import log from '../lib/log.js'; 5 | 6 | export default async (file, options) => { 7 | try { 8 | const data = await got(file, { 9 | ...options, 10 | headers: { 11 | Depth: 1, 12 | }, 13 | method: 'PROPFIND', 14 | }).text(); 15 | return await new Promise((resolve, reject) => { 16 | parseString(data, (error, response) => { 17 | if (error) { 18 | return reject(error); 19 | } 20 | 21 | resolve( 22 | response.multistatus.response.map((file) => { 23 | const info = get(file, 'propstat.0.prop.0'); 24 | forEach(info, (value, name) => { 25 | info[name] = get(value, '0'); 26 | }); 27 | return info; 28 | }) 29 | ); 30 | }); 31 | }); 32 | } catch (error) { 33 | log.error(error); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /commands/versions.js: -------------------------------------------------------------------------------- 1 | import ora from 'ora'; 2 | import api from '../lib/api.js'; 3 | import log from '../lib/log.js'; 4 | 5 | /** 6 | * @param {import('../index.js').DWArgv} argv 7 | */ 8 | export default async (argv) => { 9 | const { clientId, clientPassword, hostname, apiVersion } = argv; 10 | log.info(`Reading code versions on ${hostname}`); 11 | const spinner = ora().start(); 12 | 13 | try { 14 | spinner.text = 'Reading'; 15 | const method = 'GET'; 16 | const endpoint = `https://${hostname}/s/-/dw/data/${apiVersion}/code_versions`; 17 | await api({ clientId, clientPassword, method, endpoint }); 18 | const { data } = await api({ clientId, clientPassword, method, endpoint }); 19 | // spinner.succeed(); 20 | log.plain('-------------------'); 21 | for (const version of data) { 22 | spinner.start(); 23 | spinner.text = version.id; 24 | if (version.active) { 25 | spinner.succeed(); 26 | } else { 27 | spinner.fail(); 28 | } 29 | } 30 | log.plain('-------------------'); 31 | log.success('Success'); 32 | } catch (error) { 33 | spinner.fail(); 34 | log.error(error); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /commands/job.js: -------------------------------------------------------------------------------- 1 | import ora from 'ora'; 2 | import api from '../lib/api.js'; 3 | import log from '../lib/log.js'; 4 | 5 | export default async (argv) => { 6 | const { clientId, clientPassword, hostname, apiVersion, jobId } = argv; 7 | log.info(`Running job ${jobId} on ${hostname}`); 8 | const spinner = ora().start(); 9 | 10 | try { 11 | const endpoint = `https://${hostname}/s/-/dw/data/${apiVersion}/jobs/${jobId}/executions`; 12 | const { id } = await api({ 13 | clientId, 14 | clientPassword, 15 | method: 'post', 16 | endpoint, 17 | contentType: 'application/json; charset=UTF-8', 18 | }); 19 | 20 | do { 21 | var { status } = await api({ 22 | clientId, 23 | clientPassword, 24 | method: 'get', 25 | endpoint: `${endpoint}/${id}`, 26 | contentType: 'application/json; charset=UTF-8', 27 | }); 28 | spinner.text = `Job Status: ${status}`; 29 | } while (['PENDING', 'RUNNING'].includes(status)); 30 | 31 | status == 'OK' ? spinner.succeed() : spinner.fail(); 32 | log.success('Success'); 33 | } catch (error) { 34 | spinner.fail(); 35 | log.error(error); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /commands/keygen.js: -------------------------------------------------------------------------------- 1 | import log from '../lib/log.js'; 2 | import { execSync } from 'node:child_process'; 3 | 4 | export default function (argv) { 5 | const { user, crt, key, srl, days } = argv; 6 | log.info( 7 | `Generating a staging certificate for stage instance user account ${user}` 8 | ); 9 | 10 | if ( 11 | !execSync('which openssl', { 12 | stdio: ['pipe', 'pipe', 'ignore'], 13 | encoding: 'utf8', 14 | }) 15 | .split('\n') 16 | .join('') 17 | ) { 18 | log.error( 19 | 'Missing openssl package, install openssl to continue (i.e. `brew install openssl`)' 20 | ); 21 | process.exit(); 22 | } 23 | 24 | const userKeyCommand = `openssl req -new -sha256 -newkey rsa:2048 -nodes -out ${user}.req -keyout ${user}.key -subj "/C=CO/ST=State/L=Local/O=Demandware/OU=Technology/CN=${user}"`; 25 | log.info(userKeyCommand); 26 | execSync(userKeyCommand, { encoding: 'utf8' }); 27 | 28 | const signCommand = `openssl x509 -CA '${crt}' -CAkey '${key}' -CAserial '${srl}' -req -in ${user}.req -out ${user}.pem -days ${days}`; 29 | log.info(signCommand); 30 | execSync(signCommand, { encoding: 'utf8' }); 31 | 32 | log.success('Files generated.'); 33 | } 34 | -------------------------------------------------------------------------------- /lib/zip.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'node:path'; 3 | import debug from 'debug'; 4 | import archiver from 'archiver'; 5 | 6 | debug('zip'); 7 | 8 | export default (source, destination) => { 9 | return new Promise((resolve) => { 10 | const archive = archiver('zip', { 11 | zlib: { level: 9 }, 12 | }); 13 | const file = path.resolve(process.cwd(), destination, 'archive.zip'); 14 | const output = fs.createWriteStream(file); 15 | const stats = fs.lstatSync(source); 16 | let fullSource = source; 17 | 18 | debug(`Zipping ${source} to ${file}`); 19 | 20 | if (!path.isAbsolute(source)) { 21 | fullSource = path.join(process.cwd(), source); 22 | } 23 | 24 | output.on('close', () => { 25 | resolve(file); 26 | }); 27 | 28 | archive.on('error', (error) => { 29 | throw error; 30 | }); 31 | 32 | archive.pipe(output); 33 | 34 | if (stats.isDirectory()) { 35 | archive.glob('**/*', { 36 | cwd: fullSource, 37 | ignore: [ 38 | '**/node_modules/**', 39 | '**/*.dw.json', 40 | '**/*.dw-cli.json', 41 | '**/dw.json', 42 | '**/dw-cli.json', 43 | '**/jsconfig.json', 44 | ], 45 | }); 46 | } else { 47 | archive.append(fs.createReadStream(fullSource), { name: source }); 48 | } 49 | 50 | archive.finalize(); 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /commands/clean.js: -------------------------------------------------------------------------------- 1 | import ora from 'ora'; 2 | import del from '../lib/delete.js'; 3 | import log from '../lib/log.js'; 4 | import api from '../lib/api.js'; 5 | 6 | /** 7 | * @param {import('../index.js').DWArgv} argv 8 | */ 9 | export default async (argv) => { 10 | const { clientId, clientPassword, hostname, apiVersion, webdav, request } = 11 | argv; 12 | log.info(`Cleaning up ${webdav}`); 13 | const spinner = ora(); 14 | 15 | try { 16 | spinner.text = 'Reading'; 17 | const method = 'get'; 18 | const endpoint = `https://${hostname}/s/-/dw/data/${apiVersion}/code_versions`; 19 | const { data } = await api({ clientId, clientPassword, method, endpoint }); 20 | if (data.length === 1) { 21 | spinner.text = `Already clean`; 22 | spinner.succeed(); 23 | } else { 24 | spinner.succeed(); 25 | log.plain('-------------------'); 26 | spinner.text = 'Removing'; 27 | spinner.start(); 28 | for (const key of Object.keys(data)) { 29 | const version = data[key]; 30 | if (!version.active) { 31 | await del(`/Cartridges/${version.id}`, request); 32 | spinner.text = `Removed ${version.id}`; 33 | spinner.succeed(); 34 | spinner.text = 'Removing'; 35 | spinner.start(); 36 | } 37 | } 38 | spinner.stop(); 39 | log.plain('-------------------'); 40 | } 41 | log.success('Success'); 42 | } catch (error) { 43 | spinner.fail(); 44 | log.error(error); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | import got from 'got'; 2 | import { get } from 'lodash-es'; 3 | import log from './log.js'; 4 | let retryCount = 0; 5 | 6 | /** 7 | * @param {Object} options 8 | * @param {string} options.clientId 9 | * @param {string} options.clientPassword 10 | * @param {string} options.endpoint 11 | * @param {'GET'|'POST'|'PATCH'} options.method 12 | * @param {string} [options.contentType] 13 | * @param {any?} [options.body] 14 | */ 15 | export default async function apiRequest(options) { 16 | try { 17 | const body = await got 18 | .post('https://account.demandware.com/dw/oauth2/access_token', { 19 | form: { 20 | grant_type: 'client_credentials', // eslint-disable-line camelcase 21 | }, 22 | username: options.clientId, 23 | password: options.clientPassword, 24 | }) 25 | .json(); 26 | 27 | let headers = { 28 | Authorization: `Bearer ${body.access_token}`, 29 | }; 30 | 31 | if (options.contentType) { 32 | headers['Content-Type'] = options.contentType; 33 | } 34 | 35 | const response = await got(options.endpoint, { 36 | timeout: { request: 20_000 }, 37 | method: options.method, 38 | json: options.body, 39 | headers, 40 | }).json(); 41 | 42 | return response; 43 | } catch (error) { 44 | if (!error.response) { 45 | throw new Error(error); 46 | } 47 | 48 | const status = error.response.statusCode; 49 | const body = JSON.parse(error.response.body); 50 | 51 | if (status === 401 && retryCount < 3) { 52 | retryCount++; 53 | log.warn(get(body, 'fault.message', 'Error, retrying')); 54 | return apiRequest(options); 55 | } 56 | 57 | if (status >= 400 && get(body, 'fault.message')) { 58 | throw new Error(body.fault.message); 59 | } 60 | 61 | if (get(body, 'error_description')) { 62 | throw new Error(body.error_description); 63 | } 64 | 65 | console.error(error); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dw-cli", 3 | "version": "3.1.0", 4 | "description": "", 5 | "exports": "./cli.js", 6 | "type": "module", 7 | "bin": { 8 | "dw": "cli.js" 9 | }, 10 | "engines": { 11 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 12 | }, 13 | "scripts": { 14 | "test": "eslint ." 15 | }, 16 | "lint-staged": { 17 | "*.js": [ 18 | "eslint . --fix" 19 | ] 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/mzwallace/dw-cli.git" 24 | }, 25 | "keywords": [ 26 | "demandware", 27 | "dw-cli", 28 | "demandware cli", 29 | "demandware command-line interface", 30 | "salesforce commerce cloud" 31 | ], 32 | "author": "", 33 | "license": "ISC", 34 | "bugs": { 35 | "url": "https://github.com/mzwallace/dw-cli/issues" 36 | }, 37 | "homepage": "https://github.com/mzwallace/dw-cli#readme", 38 | "dependencies": { 39 | "archiver": "5.3.1", 40 | "bluebird": "3.7.2", 41 | "chalk": "5.0.1", 42 | "chokidar": "3.5.3", 43 | "cwait": "1.1.2", 44 | "debug": "4.3.4", 45 | "dotenv": "16.0.1", 46 | "follow-redirects": "1.15.1", 47 | "fs-extra": "10.1.0", 48 | "globby": "13.1.2", 49 | "got": "12.1.0", 50 | "lodash": "4.17.21", 51 | "lodash-es": "4.17.21", 52 | "node-notifier": "10.0.1", 53 | "ora": "6.1.2", 54 | "p-retry": "5.1.1", 55 | "xml2js": "0.4.23", 56 | "yargs": "17.5.1" 57 | }, 58 | "devDependencies": { 59 | "eslint": "8.18.0", 60 | "eslint-config-prettier": "8.5.0", 61 | "eslint-plugin-import": "2.26.0", 62 | "eslint-plugin-prettier": "4.1.0", 63 | "eslint-plugin-unicorn": "42.0.0", 64 | "is-core-module": "2.9.0", 65 | "lint-staged": "13.0.3", 66 | "prettier": "2.7.1" 67 | }, 68 | "eslintConfig": { 69 | "parser": "babel-eslint", 70 | "extends": [ 71 | "eslint:recommended", 72 | "plugin:unicorn/recommended", 73 | "plugin:prettier/recommended" 74 | ], 75 | "env": { 76 | "es6": true, 77 | "node": true, 78 | "browser": false 79 | }, 80 | "rules": { 81 | "prettier/prettier": [ 82 | "error", 83 | { 84 | "singleQuote": true, 85 | "bracketSpacing": false 86 | } 87 | ], 88 | "no-empty": [ 89 | "error", 90 | { 91 | "allowEmptyCatch": true 92 | } 93 | ], 94 | "no-console": 0, 95 | "unicorn/no-process-exit": 0 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /commands/push.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable require-atomic-updates */ 2 | import fs from 'fs-extra'; 3 | import path from 'node:path'; 4 | import ora from 'ora'; 5 | import chalk from 'chalk'; 6 | import { globby } from 'globby'; 7 | import { get } from 'lodash-es'; 8 | import notifier from 'node-notifier'; 9 | import zip from '../lib/zip.js'; 10 | import unzip from '../lib/unzip.js'; 11 | import write from '../lib/write.js'; 12 | import mkdir from '../lib/mkdir.js'; 13 | import mkdirp from '../lib/mkdirp.js'; 14 | import del from '../lib/delete.js'; 15 | import log from '../lib/log.js'; 16 | import find from '../lib/find.js'; 17 | 18 | export default async (options) => { 19 | const { cartridges, codeVersion, webdav, request } = options; 20 | 21 | try { 22 | fs.accessSync(cartridges); 23 | } catch { 24 | log.error(`'${cartridges}' is not a valid folder`); 25 | process.exit(1); 26 | } 27 | 28 | log.info(`Pushing ${codeVersion} to ${webdav}`); 29 | const spinner = ora(); 30 | const destination = `Cartridges/${codeVersion}`; 31 | 32 | try { 33 | if (options.zip) { 34 | spinner.start(); 35 | spinner.text = `Zipping '${cartridges}'`; 36 | let zipped = await zip(cartridges, get(process, 'env.TMPDIR', '.')); 37 | spinner.succeed(); 38 | 39 | spinner.start(); 40 | spinner.text = `Creating remote folder ${destination}`; 41 | await mkdir(destination, request); 42 | spinner.succeed(); 43 | 44 | spinner.start(); 45 | spinner.text = `Cleaning remote folder ${destination}`; 46 | let files = await find(destination, request); 47 | files = files 48 | .map((file) => file.displayname) 49 | .filter((file) => file !== codeVersion); 50 | await Promise.all( 51 | files.map((file) => del(path.join(destination, file), request)) 52 | ); 53 | files = await find(destination, request); 54 | files = files 55 | .map((file) => file.displayname) 56 | .filter((file) => file !== codeVersion); 57 | await Promise.all( 58 | files.map((file) => 59 | del(path.join(destination, file), request).catch(() => {}) 60 | ) 61 | ); // Sometimes it doesn't delete, so I'm doing it twice... there must be a better way... 62 | spinner.succeed(); 63 | 64 | spinner.start(); 65 | spinner.text = `Uploading ${destination}/archive.zip`; 66 | zipped = await write(zipped, destination, request, { 67 | onProgress({ percent, total, transferred }) { 68 | const sizeInMegabytes = (total / 1_000_000).toFixed(2); 69 | const uploadedInMegabytes = (transferred / 1_000_000).toFixed(2); 70 | const prettyPercent = chalk.yellow.bold(`${percent}%`); 71 | const prettySize = chalk.cyan.bold(`${sizeInMegabytes}MB`); 72 | const prettyUploaded = chalk.cyan.bold(`${uploadedInMegabytes}MB`); 73 | spinner.text = `Uploading ${destination}/archive.zip - ${prettyUploaded} / ${prettySize} - ${prettyPercent}`; 74 | }, 75 | }); 76 | spinner.succeed(); 77 | 78 | spinner.start(); 79 | spinner.text = `Unzipping ${zipped}`; 80 | await unzip(zipped, request); 81 | spinner.succeed(); 82 | 83 | spinner.start(); 84 | spinner.text = `Removing ${zipped}`; 85 | await del(zipped, request); 86 | spinner.succeed(); 87 | } else { 88 | spinner.start(); 89 | spinner.text = 'Uploading files individually'; 90 | const files = await globby(path.join(cartridges, '**'), { 91 | onlyFiles: true, 92 | }); 93 | let uploaded = 0; 94 | const upload = async (file) => { 95 | try { 96 | const source = path.relative(process.cwd(), file); 97 | const directory = path 98 | .dirname(source) 99 | .replace(path.normalize(cartridges), ''); 100 | const destination_ = path.join('Cartridges', codeVersion, directory); 101 | const filename = path.basename(source); 102 | try { 103 | await mkdirp(destination_, request); 104 | await write(source, destination_, request); 105 | } catch { 106 | try { 107 | await mkdirp(destination_, request); 108 | await write(source, destination_, request); 109 | } catch { 110 | await mkdirp(destination_, request); 111 | await write(source, destination_, request); 112 | } 113 | } 114 | spinner.text = `${uploaded}/${files.length} ${filename} uploaded`; 115 | uploaded++; 116 | } catch (error) { 117 | spinner.text = error.message; 118 | spinner.fail(); 119 | } 120 | }; 121 | 122 | for (const file of files) { 123 | upload(file); 124 | } 125 | spinner.succeed(); 126 | } 127 | 128 | log.success('Success'); 129 | notifier.notify({ 130 | title: 'Push', 131 | message: 'Success', 132 | }); 133 | } catch (error) { 134 | spinner.fail(); 135 | log.error(error); 136 | } 137 | }; 138 | -------------------------------------------------------------------------------- /commands/watch.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable require-atomic-updates */ 2 | import path from 'node:path'; 3 | import chokidar from 'chokidar'; 4 | import ora from 'ora'; 5 | import { debounce } from 'lodash-es'; 6 | import notifier from 'node-notifier'; 7 | import pRetry from 'p-retry'; 8 | import write from '../lib/write.js'; 9 | import del from '../lib/delete.js'; 10 | import mkdirp from '../lib/mkdirp.js'; 11 | import log from '../lib/log.js'; 12 | 13 | /** 14 | * @param {import('../index.js').DWArgv} argv 15 | */ 16 | export default (argv) => { 17 | const { cartridges, codeVersion, webdav, request, silent } = argv; 18 | 19 | try { 20 | log.info(`Pushing ${codeVersion} changes to ${webdav}`); 21 | 22 | const debouncedNotify = debounce( 23 | (arguments_) => notifier.notify(arguments_), 24 | 150 25 | ); 26 | const uploading = new Set(); 27 | const removing = new Set(); 28 | let spinner; 29 | let text; 30 | 31 | const watcher = chokidar.watch('dir', { 32 | ignored: [/[/\\]\./, '**/node_modules/**'], 33 | ignoreInitial: true, 34 | persistent: true, 35 | atomic: true, 36 | }); 37 | 38 | if (argv.spinner) { 39 | text = `Watching '${cartridges}' for ${webdav} [Ctrl-C to Cancel]`; 40 | spinner = ora(text).start(); 41 | } 42 | 43 | watcher.add(path.join(process.cwd(), cartridges)); 44 | 45 | const upload = async (file) => { 46 | const source = path.relative(process.cwd(), file); // Keep the same as in remove 47 | const directory = path 48 | .dirname(source) 49 | .replace(path.normalize(cartridges), ''); 50 | const destination = path.join('Cartridges', codeVersion, directory); 51 | 52 | if (!uploading.has(source)) { 53 | uploading.add(source); 54 | if (!silent) { 55 | debouncedNotify({ 56 | title: 'File Changed', 57 | message: source, 58 | }); 59 | } 60 | if (spinner) { 61 | spinner.stopAndPersist({ text: `${source} changed` }); 62 | spinner.text = text; 63 | spinner.start(); 64 | } 65 | 66 | try { 67 | await pRetry(() => mkdirp(destination, request), { retries: 5 }); 68 | await pRetry(() => write(source, destination, request), { 69 | retries: 5, 70 | }); 71 | if (!silent) { 72 | debouncedNotify({ 73 | title: 'File Uploaded', 74 | message: `${path.basename(source)} => ${destination}`, 75 | }); 76 | } 77 | if (spinner) { 78 | spinner.text = `${path.basename(source)} pushed to ${destination}`; 79 | spinner.succeed(); 80 | } 81 | } catch (error) { 82 | console.log(error); 83 | if (spinner) { 84 | spinner.text = error.message; 85 | spinner.fail(); 86 | } 87 | } 88 | 89 | if (spinner) { 90 | spinner.text = text; 91 | spinner.start(); 92 | } 93 | uploading.delete(source); 94 | } 95 | }; 96 | 97 | const remove = async (file) => { 98 | const source = path.relative(process.cwd(), file); // Keep the same as in upload 99 | const directory = path 100 | .dirname(source) 101 | .replace(path.normalize(cartridges), ''); 102 | const destination = path.join('Cartridges', codeVersion, directory); 103 | const url = path.join(destination, path.basename(source)); 104 | 105 | if (!removing.has(source) && !uploading.has(source)) { 106 | removing.add(source); 107 | if (!silent) { 108 | debouncedNotify({ 109 | title: 'Local file removed', 110 | message: source, 111 | }); 112 | } 113 | if (spinner) { 114 | spinner.stopAndPersist({ text: `${source} removed locally` }); 115 | spinner.text = text; 116 | spinner.start(); 117 | } 118 | 119 | try { 120 | await del(url, request); 121 | // const tryToRemove = () => del(url, request); 122 | // await pRetry(tryToRemove, {retries: 5}); 123 | if (!silent) { 124 | debouncedNotify({ 125 | title: 'Remote file removed', 126 | message: url, 127 | }); 128 | } 129 | if (spinner) { 130 | spinner.text = `${path.basename( 131 | source 132 | )} removed from ${destination}`; 133 | spinner.succeed(); 134 | } 135 | } catch (error) { 136 | if (spinner) { 137 | spinner.text = `Couldn't remove ${url}: ${error.message}`; 138 | spinner.fail(); 139 | } 140 | } 141 | 142 | if (spinner) { 143 | spinner.text = text; 144 | spinner.start(); 145 | } 146 | removing.delete(source); 147 | } 148 | }; 149 | 150 | watcher.on('change', upload); 151 | watcher.on('add', upload); 152 | if (argv.remove) watcher.on('unlink', remove); 153 | } catch (error) { 154 | log.error(error); 155 | } 156 | }; 157 | -------------------------------------------------------------------------------- /commands/log.js: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | import { 3 | groupBy, 4 | sortBy, 5 | forEach, 6 | pickBy, 7 | map, 8 | keys, 9 | truncate, 10 | compact, 11 | } from 'lodash-es'; 12 | import ora from 'ora'; 13 | import chalk from 'chalk'; 14 | import log from '../lib/log.js'; 15 | import read from '../lib/read.js'; 16 | import find from '../lib/find.js'; 17 | 18 | debug('log'); 19 | 20 | export default async (argv) => { 21 | const { webdav, request, options } = argv; 22 | const verb = options.search ? 'Searching' : 'Streaming'; 23 | const text = 24 | `${verb} log files from ${webdav} ` + 25 | (verb == 'Searching' && options.filter ? `for '${options.filter}' ` : '') + 26 | `[Ctrl-C to Cancel]`; 27 | const spinner = ora(text); 28 | const output = (function_) => { 29 | spinner.stop(); 30 | function_(); 31 | spinner.start(); 32 | }; 33 | 34 | try { 35 | // all files 36 | let files = await find('Logs', request); 37 | 38 | // only log files 39 | files = files.filter(({ displayname }) => displayname.includes('.log')); 40 | 41 | // group by log type 42 | let groups = groupBy( 43 | files, 44 | ({ displayname }) => displayname.split('-blade')[0] 45 | ); 46 | 47 | if (options.list) { 48 | spinner.stop(); 49 | log.info('Levels:'); 50 | forEach(keys(groups).sort(), (group) => { 51 | log.plain(group); 52 | }); 53 | process.exit(); 54 | } 55 | 56 | if (options.include.length > 0) { 57 | groups = pickBy(groups, (group, name) => 58 | options.include.some((level) => { 59 | return new RegExp(level).test(name); 60 | }) 61 | ); 62 | } 63 | 64 | if (options.exclude.length > 0) { 65 | groups = pickBy( 66 | groups, 67 | (group, name) => 68 | options.exclude.filter((level) => { 69 | return new RegExp(level).test(name); 70 | }).length === 0 71 | ); 72 | } 73 | 74 | // setup logs 75 | const logs = []; 76 | forEach(groups, (files, name) => { 77 | logs[name] = []; 78 | }); 79 | 80 | // sort groups by last modified 81 | forEach(groups, (files, name) => { 82 | groups[name] = sortBy( 83 | files, 84 | (file) => new Date(file.getlastmodified) 85 | ).reverse(); 86 | }); 87 | 88 | debug('yas leave me here'); 89 | 90 | const search = async () => { 91 | const promiseGroups = map(groups, (files, name) => { 92 | return map(files, async (file) => { 93 | const displayname = file.displayname; 94 | try { 95 | const response = await read(`Logs/${displayname}`, request); 96 | return { response, name }; 97 | } catch (error) { 98 | output(() => log.error(error)); 99 | } 100 | }); 101 | }); 102 | 103 | for (const promises of promiseGroups) { 104 | const results = await Promise.all(promises); 105 | 106 | for (const { response, name } of compact(results)) { 107 | let lines = response.split('\n'); 108 | lines.pop(); // last line is empty 109 | if (options.numLines) lines = lines.slice(-options.numLines); 110 | 111 | for (let line of lines) { 112 | if (line) { 113 | if (options.timestamp) { 114 | line = line.replace(/\[(.+)\sGMT]/g, (exp, match) => { 115 | const date = new Date(Date.parse(match + 'Z')); 116 | return chalk.magenta( 117 | `[${date.toLocaleDateString()} ${date.toLocaleTimeString()}]` 118 | ); 119 | }); 120 | } 121 | // if there's a filter and it doesn't pass .,., the ignore line 122 | if ( 123 | options.filter && 124 | !new RegExp(options.filter, 'ig').test(line) 125 | ) { 126 | continue; 127 | } 128 | // highlight the matching parts of the line 129 | if (options.filter) { 130 | line = line.replace(new RegExp(options.filter, 'ig'), (exp) => { 131 | return chalk.yellow(exp); 132 | }); 133 | } 134 | 135 | if (options.length > 0) { 136 | line = truncate(line.trim(), { 137 | length: options.length, 138 | omission: '', 139 | }); 140 | } 141 | 142 | output(() => log.plain(`${chalk.white(name)} ${line}`, 'blue')); 143 | } 144 | } 145 | } 146 | } 147 | 148 | // spinner.stop(); 149 | spinner.text = 150 | `Search of ${webdav} ` + 151 | (options.filter ? `for '${options.filter}' ` : '') + 152 | `complete`; 153 | spinner.succeed(); 154 | process.exit(); 155 | }; 156 | 157 | const tail = async () => { 158 | const promises = map(groups, async (files, name) => { 159 | const displayname = files[0].displayname; 160 | try { 161 | const response = await read(`Logs/${displayname}`, request); 162 | return { response, name }; 163 | } catch (error) { 164 | output(() => log.error(error)); 165 | } 166 | }); 167 | 168 | const results = await Promise.all(promises); 169 | 170 | for (const { response, name } of compact(results)) { 171 | let lines = response.split('\n'); 172 | lines.pop(); // last line is empty 173 | if (options.numLines) lines = lines.slice(-options.numLines); 174 | 175 | for (let line of lines) { 176 | if (line && !logs[name].includes(line)) { 177 | logs[name].push(line); 178 | if (options.filter && !new RegExp(options.filter).test(line)) { 179 | continue; 180 | } 181 | if (options.length > 0) { 182 | line = truncate(line.trim(), { 183 | length: options.length, 184 | omission: '', 185 | }); 186 | } 187 | if (options.timestamp) { 188 | line = line.replace(/\[(.+)\sGMT]/g, (exp, match) => { 189 | const date = new Date(Date.parse(match + 'Z')); 190 | return chalk.magenta( 191 | `[${date.toLocaleDateString()} ${date.toLocaleTimeString()}]` 192 | ); 193 | }); 194 | } 195 | if (options.filter) { 196 | line = line.replace(new RegExp(options.filter, 'g'), (exp) => { 197 | return chalk.yellow(exp); 198 | }); 199 | } 200 | output(() => log.plain(`${chalk.white(name)} ${line}`, 'blue')); 201 | } 202 | } 203 | } 204 | 205 | setTimeout(tail, options.pollInterval * 1000); 206 | }; 207 | 208 | spinner.start(); 209 | options.search ? search() : tail(); 210 | } catch (error) { 211 | output(() => log.error(error)); 212 | } 213 | }; 214 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import 'dotenv/config'; 4 | import yargs from 'yargs'; 5 | import { hideBin } from 'yargs/helpers'; 6 | import debug from 'debug'; 7 | import path from 'node:path'; 8 | import fs from 'node:fs'; 9 | import https from 'node:https'; 10 | import codeVersion from './lib/branch.js'; 11 | import { commands } from './commands/index.js'; 12 | debug('cli'); 13 | 14 | const jsonPath = path.join(process.cwd(), 'dw-cli.json'); 15 | 16 | yargs(hideBin(process.argv)) 17 | .env('DW') 18 | .middleware(configure) 19 | .usage('Usage: $0 [options] --switches') 20 | .command({ 21 | command: 'init', 22 | describe: 'Create a dw-cli.json file', 23 | handler: commands.init, 24 | }) 25 | .command({ 26 | command: 'versions ', 27 | describe: 'List code versions on an instance', 28 | handler: commands.versions, 29 | }) 30 | .command({ 31 | command: 'activate [code-version]', 32 | describe: 'Activate code version on an instance', 33 | handler: commands.activate, 34 | }) 35 | .command({ 36 | command: 'push [code-version]', 37 | describe: 'Push code version to an instance', 38 | builder: { 39 | zip: { 40 | alias: 'options.zip', 41 | describe: 42 | 'Upload as zip file, use --no-zip to upload as individual files', 43 | default: true, 44 | }, 45 | }, 46 | handler: commands.push, 47 | }) 48 | .command({ 49 | command: 'remove [code-version]', 50 | describe: 'Remove code version from an instance', 51 | handler: commands.remove, 52 | }) 53 | .command({ 54 | command: 'watch [code-version]', 55 | describe: 'Push changes to an instance', 56 | builder: { 57 | spinner: { 58 | alias: 'options.spinner', 59 | describe: 'Show the watch spinner, use --no-spinner to hide', 60 | type: 'boolean', 61 | default: true, 62 | }, 63 | silent: { 64 | alias: 'options.silent', 65 | describe: 'Hide file upload notifications', 66 | type: 'boolean', 67 | default: false, 68 | }, 69 | remove: { 70 | alias: 'options.remove', 71 | describe: 'Remove locally deleted files from the remote filesystem', 72 | type: 'boolean', 73 | default: false, 74 | }, 75 | }, 76 | handler: commands.watch, 77 | }) 78 | .command({ 79 | command: 'job ', 80 | describe: 'Run a job on an instance', 81 | handler: commands.job, 82 | }) 83 | .command({ 84 | command: 'clean ', 85 | describe: 'Remove inactive code versions on instance', 86 | handler: commands.clean, 87 | }) 88 | .command({ 89 | command: 'extract ', 90 | describe: 'Extract a file on an instance', 91 | handler: commands.extract, 92 | }) 93 | .command({ 94 | command: 'log ', 95 | describe: 'Stream log files from an instance', 96 | builder: { 97 | 'poll-interval': { 98 | alias: 'options.pollInterval', 99 | describe: 'Polling interval for log (seconds)', 100 | default: 2, 101 | }, 102 | 'num-lines': { 103 | alias: 'options.numLines', 104 | describe: 'Number of lines to print on each tail', 105 | default: 100, 106 | }, 107 | 'include': { 108 | alias: 'options.include', 109 | describe: 'Log levels to include', 110 | type: 'array', 111 | default: [], 112 | }, 113 | 'exclude': { 114 | alias: 'options.exclude', 115 | describe: 'Log levels to exclude', 116 | type: 'array', 117 | default: [], 118 | }, 119 | 'list': { 120 | alias: 'options.list', 121 | describe: 'Output a list of log types found on the remote filesystem', 122 | type: 'boolean', 123 | default: false, 124 | }, 125 | 'filter': { 126 | alias: 'options.filter', 127 | describe: 'Filter log messages by regexp', 128 | default: undefined, 129 | }, 130 | 'length': { 131 | alias: 'options.length', 132 | describe: 'Length to truncate a log message', 133 | default: undefined, 134 | }, 135 | 'search': { 136 | alias: 'options.search', 137 | describe: 138 | 'Instead of a tail, this will execute a search on all log files (useful for Production)', 139 | type: 'boolean', 140 | default: false, 141 | }, 142 | 'timestamp': { 143 | alias: 'options.timestamp', 144 | describe: 145 | 'Convert the timestamp in each log message to your local computer timezone, use --no-timestamp to disable', 146 | type: 'boolean', 147 | default: true, 148 | }, 149 | }, 150 | handler: commands.log, 151 | }) 152 | .command({ 153 | command: 'keygen ', 154 | describe: 155 | 'Generate a staging certificate for a stage instance user account', 156 | builder: { 157 | days: { 158 | alias: 'options.days', 159 | describe: 'Number of days until the certificate expires', 160 | default: 365, 161 | }, 162 | }, 163 | handler: commands.keygen, 164 | }) 165 | .example('$0 versions dev01', 'List code versions on dev01') 166 | .example('$0 activate dev01', `Activate branch name as code version on dev01`) 167 | .example('$0 push dev01 version1', 'Push version1 to dev01') 168 | .example('$0 remove dev01', 'Remove branch name as code version from dev01') 169 | .example( 170 | '$0 watch dev01 version1', 171 | 'Push changes in cwd to version1 on dev01' 172 | ) 173 | .example('$0 clean dev01', 'Remove all inactive code versions on dev01') 174 | .example('$0 log dev01', 'Stream log files from the dev01') 175 | .option('username', { describe: 'Username for instance' }) 176 | .option('password', { alias: 'p', describe: 'Password for instance' }) 177 | .option('hostname', { alias: 'h', describe: 'Hostname for instance' }) 178 | .option('cartridges', { alias: 'c', describe: 'Path to cartridges' }) 179 | .option('api-version', { describe: 'Demandware API Version' }) 180 | .option('code-version', { alias: 'v', default: codeVersion() }) 181 | .option('client-id', { describe: 'Demandware API Client ID' }) 182 | .option('client-password', { describe: 'Demandware API Client Password' }) 183 | .config({ extends: fs.existsSync(jsonPath) ? jsonPath : {} }) 184 | .demandCommand(1) 185 | .help() 186 | .version().argv; 187 | 188 | /** 189 | * Configure argv and fallback options 190 | * 191 | * @type {import('yargs').MiddlewareFunction} 192 | */ 193 | function configure(argv) { 194 | const instance = 195 | argv.instances && 196 | typeof argv.instances[String(argv.instance)] !== 'undefined' 197 | ? argv.instances[String(argv.instance)] 198 | : {}; 199 | 200 | // Required for API commands (versions, job) 201 | argv.username = process.env.DW_USERNAME || instance.username || argv.username; 202 | argv.password = process.env.DW_PASSWORD || instance.password || argv.password; 203 | argv.hostname = process.env.DW_HOSTNAME || instance.hostname || argv.hostname; 204 | argv.apiVersion = 205 | process.env.DW_API_VERSION || instance.apiVersion || argv.apiVersion; 206 | argv.clientId = 207 | process.env.DW_CLIENT_ID || instance.clientId || argv.clientId; 208 | argv.clientPassword = 209 | process.env.DW_CLIENT_PASSWORD || 210 | instance.clientPassword || 211 | argv.clientPassword; 212 | 213 | // Required for WebDAV commands (push, watch, clean) 214 | 215 | argv.webdav = 216 | process.env.DW_WEBDAV || instance.webdav || argv.webdav || argv.hostname; 217 | 218 | argv.key = process.env.DW_KEY || instance.key || argv.key; 219 | 220 | argv.cert = process.env.DW_CERT || instance.cert || argv.cert; 221 | 222 | argv.ca = process.env.DW_CA || instance.ca || argv.ca; 223 | 224 | argv.p12 = process.env.DW_P12 || instance.p12 || argv.p12; 225 | 226 | argv.passphrase = 227 | process.env.DW_PASSPHRASE || instance.passphrase || argv.passphrase; 228 | 229 | argv.request = { 230 | prefixUrl: `https://${argv.webdav}/on/demandware.servlet/webdav/Sites`, 231 | username: argv.username, 232 | password: argv.password, 233 | agent: { 234 | https: new https.Agent({ 235 | key: argv.key ? fs.readFileSync(String(argv.key)) : undefined, 236 | cert: argv.cert ? fs.readFileSync(String(argv.cert)) : undefined, 237 | ca: argv.ca ? fs.readFileSync(String(argv.ca)) : undefined, 238 | pfx: argv.p12 ? fs.readFileSync(String(argv.p12)) : undefined, 239 | passphrase: argv.passphrase ? String(argv.passphrase) : undefined, 240 | }), 241 | }, 242 | }; 243 | } 244 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dw-cli 2 | A command line utility to help make your development experience on the Salesforce Commerce Cloud (Demandware) platform a more straight forward, intuitive, and productive experience. The goal of this cli tool is to help you avoid Eclipse and the Business Manager and keep you focused in the IDE of your choice (atom, vscode, sublime, etc). 3 | 4 | Straight from the command line, you can push code directly to any configured instance, activate any code version, trigger a watch proces to push files in real-time, stream logs (with an excellent number of filtering options), and much more. 5 | ``` 6 | Usage: dw [options] --switches 7 | 8 | Commands: 9 | init Create a dw-cli.json file 10 | versions List code versions on an instance 11 | activate [code-version] Activate code version on an instance 12 | push [code-version] Push code version to an instance 13 | remove [code-version] Remove code version from an instance 14 | watch [code-version] Push changes to an instance 15 | job Run a job on an instance 16 | clean Remove inactive code versions on instance 17 | extract Extract a file on an instance 18 | log Stream log files from an instance 19 | keygen Generate a staging certificate for a stage 20 | instance user account 21 | 22 | Options: 23 | --username, -u Username for instance 24 | --password, -p Password for instance 25 | --hostname, -h Hostname for instance 26 | --cartridges, -c Path to cartridges 27 | --api-version Demandware API Version 28 | --client-id Demandware API Client ID 29 | --client-password Demandware API Client Password 30 | --help Show help [boolean] 31 | --version Show version number [boolean] 32 | 33 | Examples: 34 | dw versions dev01 List code versions on dev01 35 | dw activate dev01 Activate branch name as code version on dev01 36 | dw push dev01 version1 Push version1 to dev01 37 | dw remove dev01 Remove branch name as code version from dev01 38 | dw watch dev01 version1 Push changes in cwd to version1 on dev01 39 | dw clean dev01 Remove all inactive code versions on dev01 40 | dw log dev01 Stream log files from the dev01 41 | ``` 42 | ## Examples 43 | Activate, push, remove, and watch assume the 'code version' is the git branch of the cwd unless it is declared in the command arguments. 44 | #### dw push 45 | ``` 46 | user@computer:~/Sites/site$ dw push dev01 47 | [23:21:06] Pushing current-branch-name to dev01-region-brand.demandware.net 48 | ✔ Zipping 'cartridges' 49 | ✔ Creating remote folder /Cartridges/current-branch-name 50 | ✔ Uploading /Cartridges/current-branch-name/archive.zip 51 | ✔ Unzipping /Cartridges/current-branch-name/archive.zip 52 | ✔ Removing /Cartridges/current-branch-name/archive.zip 53 | [23:21:42] Success 30.142s 54 | ``` 55 | #### dw activate 56 | ``` 57 | user@computer:~/Sites/site$ dw activate dev01 current-branch-name 58 | [23:22:00] Activating current-branch-name on dev01-region-brand.demandware.net 59 | ✔ Activating 60 | [23:22:04] Success 0.976s 61 | ``` 62 | #### dw versions 63 | ``` 64 | user@computer:~/Sites/site$ dw versions dev01 65 | [23:22:06] Reading code versions on dev01-region-brand.demandware.net 66 | ✔ Reading 67 | ------------------- 68 | ✔ current-branch-name 69 | ✖ master 70 | ✖ develop 71 | ------------------- 72 | [23:22:08] Success 0.754s 73 | ``` 74 | #### dw remove 75 | ``` 76 | user@computer:~/Sites/site$ dw remove dev01 version1 77 | [16:40:51] Removing develop from dev01-region-brand.demandware.net 78 | ✔ Removing 79 | [16:40:57] Success 5.762s 80 | ``` 81 | #### dw clean 82 | ``` 83 | user@computer:~/Sites/site$ dw clean dev01 84 | [16:42:05] Cleaning up dev01-region-brand.demandware.net 85 | ✔ Reading 86 | ------------------- 87 | ✔ Removed version2 88 | ✔ Removed version1 89 | ✔ Removed version3 90 | ------------------- 91 | [16:42:06] Success 1.025s 92 | ``` 93 | #### dw watch 94 | ``` 95 | user@computer:~/Sites/site$ dw watch dev01 96 | [23:22:25] Pushing current-branch-name changes to dev01-region-brand.demandware.net 97 | cartridges/app_controllers/README.md changed 98 | ✔ cartridges/app_controllers/README.md pushed to Cartridges/current-branch-name/app_controllers 99 | cartridges/app_controllers/cartridge/controllers/Home.js changed 100 | ✔ cartridges/app_controllers/cartridge/controllers/Home.js pushed to Cartridges/current-branch-name/app_controllers/cartridge/controllers 101 | ⠙ Watching 'cartridges' [Ctrl-C to Cancel] 102 | ``` 103 | #### dw log 104 | ``` 105 | user@computer:~/Sites/site$ dw log help 106 | dw log 107 | 108 | Options: 109 | --help Show help [boolean] 110 | --version Show version number [boolean] 111 | --poll-interval Polling interval for log (seconds) [default: 2] 112 | --num-lines Number of lines to print on each tail [default: 100] 113 | --include Log levels to include [array] [default: []] 114 | --exclude Log levels to exclude [array] [default: []] 115 | --list Output a list of available log levels [default: false] 116 | --filter Filter log messages by regexp [default: null] 117 | --length Length to truncate a log message [default: null] 118 | --no-timestamp Stop converting timestamps to computer locale [default: false] 119 | --search Instead of a tail, this will execute a search [default: false] 120 | on all log files (useful for Production) 121 | ``` 122 | ``` 123 | user@computer:~/Sites/site$ dw log dev01 124 | [23:23:28] Streaming log files from dev01-region-brand.demandware.net 125 | customerror [2016-12-30 18:49:49.212 GMT] ERROR PipelineCallServlet|12129246|Sites-Site|Product-HitTile|PipelineCall|Gl5mgZN_FjcBOi1siIw8AAPAMkRF7fycxl5GKt-wIdKVBUMYxGFRD1k-EtRw7gCSoVy0GgkT_Mw4Xju3W6a4Gg== custom.ProductImageSO.ds Image doesn't exist: "default/images/hi-res/2111319/1.jpg". Product ID: "Black BE 126 | error at org.apache.tomcat.util.buf.ByteChunk.append(ByteChunk.java:366) 127 | error at org.apache.coyote.http11.InternalOutputBuffer$OutputStreamOutputBuffer.doWrite(InternalOutputBuffer.java:240) 128 | error at org.apache.coyote.http11.filters.IdentityOutputFilter.doWrite(IdentityOutputFilter.java:84) 129 | error at org.apache.coyote.http11.AbstractOutputBuffer.doWrite(AbstractOutputBuffer.java:192) 130 | error at org.apache.coyote.Response.doWrite(Response.java:499) 131 | error at org.apache.catalina.connector.OutputBuffer.realWriteBytes(OutputBuffer.java:402) 132 | error ... 42 more 133 | wwd [02:15:01.610] DEBUG Completed DR backup. 134 | wwd [02:45:01.604] DEBUG Starting DR backup. 135 | wwd [02:45:01.611] DEBUG Completed DR backup. 136 | syslog [2016-12-31 04:22:03.465 GMT] User system activates code version 'current-branch-name'. 137 | syslog [2016-12-31 04:22:03.920 GMT] Code version 'current-branch-name' activated. 138 | syslog [2016-12-31 04:22:03.920 GMT] User system clears pipeline page cache of root 139 | analyticsengine [2016-12-31 02:02:01.592 GMT] Splitter only runs on production instance 140 | analyticsengine [2016-12-31 02:32:01.595 GMT] Base directory is: /remote/bbhd/bbhd_s08/sharedata/adl 141 | api [2016-12-30 20:07:51.158 GMT] PipelineDictionary usage violation: WARN: deprecated getAlias() PIPELET: com.demandware.pipelet.common.Assign 142 | api [2016-12-30 21:18:40.095 GMT] PipelineDictionary usage violation: WARN: deprecated getAliasKey() PIPELET: com.demandware.component.foundation.pipelet.common.DispatchFormAction 143 | sysevent [2016-12-31 04:22:03.485 GMT] Using '/remote/bbhd/bbhd_s08/sharedata/cartridges/current-branch-name/bm_paypal/cartridge' as main cartridge directory for 'bm_paypal'. 144 | sysevent [2016-12-31 04:22:03.486 GMT] Using '/remote/bbhd/bbhd_s08/sharedata/cartridges/current-branch-name/int_cybersource/cartridge' as main cartridge directory for 'int_cybersource'. 145 | jobs [2016-12-31 04:23:01.597 GMT] Created Job configuration for domain [system]. Job type [1]. Job Configuration [, de4ba8565c1ee2d1998142d8bc] 146 | jobs [2016-12-31 04:23:01.598 GMT] Created Job configuration for Schedule [RealTimeQuotaAlert, 5243faf4c73317f2ac12e375df] 147 | ⠙ Streaming [Ctrl-C to Cancel] 148 | ``` 149 | ``` 150 | user@computer:~/Sites/site$ dw log dev01 --include error,warn --filter '42|402' --length 100 --poll-interval 1 --num-lines 100 151 | [16:15:34] Streaming log files from dev01-region-brand.demandware.net 152 | error at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:423) 153 | error at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142) 154 | error at org.apache.catalina.connector.OutputBuffer.realWriteBytes(OutputBuffer.java:402) 155 | error ... 42 more 156 | error at org.apache.catalina.connector.OutputBuffer.write(OutputBuffer.java:420) 157 | warn [2017-01-13 07:56:01.464 GMT] WARN JobThread|14022147|Export Analytics Configuration|ExportAnalytics 158 | warn [2017-01-13 08:15:01.623 GMT] WARN wwd-pool.2 com.demandware.wwd.dr.DRBackupMgr system JOB 4eb780bd 159 | warn [2017-01-13 08:56:01.490 GMT] WARN JobThread|24209416|Export Analytics Configuration|ExportAnalytics 160 | warn [2017-01-13 09:15:01.475 GMT] WARN wwd-pool.0 com.demandware.wwd.dr.DRBackupMgr system JOB 4eb780bd 161 | warn [2017-01-13 09:45:01.482 GMT] WARN wwd-pool.2 com.demandware.wwd.dr.DRBackupMgr system JOB 4eb780bd 162 | warn [2017-01-13 10:15:01.477 GMT] WARN wwd-pool.0 com.demandware.wwd.dr.DRBackupMgr system JOB 4eb780bd 163 | warn [2017-01-13 10:45:01.494 GMT] WARN wwd-pool.1 com.demandware.wwd.dr.DRBackupMgr system JOB 4eb780bd 164 | warn [2017-01-13 10:45:01.495 GMT] WARN wwd-pool.2 com.demandware.wwd.dr.DRBackupMgr system JOB 4eb780bd 165 | warn [2017-01-13 10:56:01.467 GMT] WARN JobThread|24209416|Export Analytics Configuration|ExportAnalytics 166 | warn [2017-01-13 11:15:01.474 GMT] WARN wwd-pool.2 com.demandware.wwd.dr.DRBackupMgr system JOB 4eb780bd 167 | warn [2017-01-13 11:56:01.467 GMT] WARN JobThread|14022147|Export Analytics Configuration|ExportAnalytics 168 | ⠴ Streaming log files from dev01-region-brand.demandware.net [Ctrl-C to Cancel] 169 | ``` 170 | ``` 171 | user@computer:~/Sites/site$ dw log dev01 --filter 'sailthru|email' --search 172 | ⠴ Searching log files from dev01-region-brand.demandware.net for 'sailthru|email' [Ctrl-C to Cancel] 173 | service-Sailthru_User_API [2018-3-25 14:27:35] ERROR PipelineCallServlet|400482723|Sites-Site|EmailSignup-EmailForm|PipelineCall|n_LJ-AsvYiGimwbRqrZXxOwz6bLew3cc1iRjwFwEZsGzYkD4Nrm95RDMkLAwH7fpqY3-sRa8_PWEXYXktoQDtQ== custom.service.sailthru.http.user.HEAD [] service=sailthru.http.user status=ERROR errorCode=400 errorMessage={"error":99,"errormsg":"User not found with email: test@example.com"} 174 | service-Sailthru_User_API [2018-3-25 15:27:44] ERROR PipelineCallServlet|502093428|Sites-Site|EmailSignup-EmailForm|PipelineCall|UGie3fDj5pp77A49r3ZHWk1ebjdAju6Qs0PMag104Pi4sY1-l9QTPZ2JEM8lL2d9XzBT3bvNb4JgTTE_F-mtbQ== custom.service.sailthru.http.user.HEAD [] service=sailthru.http.user status=ERROR errorCode=400 errorMessage={"error":99,"errormsg":"User not found with email: test@example.com"} 175 | service-Sailthru_User_API [2018-3-25 16:02:36] ERROR PipelineCallServlet|502093428|Sites-Site|EmailSignup-EmailForm|PipelineCall|CTB_qwunFBBzELq1rwox4QzSf0v_Q1noPnbAHgZlOTUNd1KZ1lfBUTyKVx_pzjNUYSshsybjr5J2mDplVmp_AQ== custom.service.sailthru.http.user.HEAD [] service=sailthru.http.user status=ERROR errorCode=400 errorMessage={"error":99,"errormsg":"User not found with email: test@example.com"} 176 | service-Sailthru_User_API [2018-3-25 17:01:53] ERROR PipelineCallServlet|676028419|Sites-Site|EmailSignup-EmailForm|PipelineCall|etSBKa0l9L4lqCZoL5aVxWV_GPlm34ISH-534tLgk7o5on_Ov_qraWPMsgAbr9Qdh6_l-bQoLqYWDqHZP8cVOQ== custom.service.sailthru.http.user.HEAD [] service=sailthru.http.user status=ERROR errorCode=400 errorMessage={"error":99,"errormsg":"User not found with email: test@example.com"} 177 | service-Sailthru_User_API [2018-3-25 17:03:34] ERROR PipelineCallServlet|676028419|Sites-Site|EmailSignup-EmailForm|PipelineCall|etSBKa0l9L4lqCZoL5aVxWV_GPlm34ISH-534tLgk7o5on_Ov_qraWPMsgAbr9Qdh6_l-bQoLqYWDqHZP8cVOQ== custom.service.sailthru.http.user.HEAD [] service=sailthru.http.user status=ERROR errorCode=400 errorMessage={"error":99,"errormsg":"User not found with email: test@example.com"} 178 | service-Sailthru_User_API [2018-3-25 17:04:23] ERROR PipelineCallServlet|1923185397|Sites-Site|EmailSignup-EmailForm|PipelineCall|etSBKa0l9L4lqCZoL5aVxWV_GPlm34ISH-534tLgk7o5on_Ov_qraWPMsgAbr9Qdh6_l-bQoLqYWDqHZP8cVOQ== custom.service.sailthru.http.user.HEAD [] service=sailthru.http.user status=ERROR errorCode=400 errorMessage={"error":99,"errormsg":"User not found with email: test@example.com"} 179 | ✔ Search of dev01-region-brand.demandware.net for 'sailthru|email' complete 180 | ``` 181 | ## Installation 182 | #### Install via NPM 183 | ``` 184 | user@computer:~/Sites/site$ npm install -g dw-cli 185 | ``` 186 | #### The way config works 187 | Place a dw-cli.json file in your project root directory or use `dw init`. 188 | * Regular file config comes first. 189 | * If instance config exists in the file it overrides regular config when using a particular instance in your command. 190 | * Command line arguments override the config file. 191 | #### Sandbox Dev Example 192 | Working on a single sandbox and your cartidges are in a 'cartridges' folder in the project root? 193 | ```json 194 | { 195 | "username": "default-user", 196 | "password": "default-pass", 197 | "cartridges": "cartridges", 198 | "apiVersion": "v16_6", 199 | "clientId": "client-id-from-account-dashboard", 200 | "clientPassword": "client-password-from-account-dashboard", 201 | "instances": { 202 | "staging": { 203 | "hostname": "stage.hostname.com" 204 | } 205 | } 206 | } 207 | ``` 208 | #### Another Example 209 | Working on several sandboxes and a staging instance with two-factor auth? 210 | ```json 211 | { 212 | "username": "default-user", 213 | "password": "default-pass", 214 | "apiVersion": "v16_6", 215 | "clientId": "client-id-from-account-dashboard", 216 | "clientPassword": "client-password-from-account-dashboard", 217 | 218 | "instances": { 219 | "dev02": { 220 | "hostname": "dev02-region-brand.demandware.net", 221 | "password": "different-pass" 222 | }, 223 | 224 | "staging": { 225 | "hostname": "stage.hostname.com", 226 | "webdav": "cert.staging.us.brand.demandware.net", 227 | "key": "./user.key", 228 | "cert": "./user.pem", 229 | "ca": "./staging.cert" 230 | } 231 | } 232 | } 233 | ``` 234 | #### All Possible Config Options 235 | ```json 236 | { 237 | "username": "default-user", 238 | "password": "default-pass", 239 | "cartridges": "cartridges-root-folder", 240 | "apiVersion": "v16_6", 241 | "clientId": "client-id-from-account-dashboard", 242 | "clientPassword": "client-password-from-account-dashboard", 243 | "webdav": "cert.staging.region.brand.demandware.net", 244 | "key": "./user.key", 245 | "cert": "./user.pem", 246 | "ca": "./staging.cert", 247 | "instances": { 248 | "staging": { 249 | "hostname": "stage.hostname.com" 250 | } 251 | } 252 | } 253 | ``` 254 | #### Sandbox Instances 255 | For sandbox instances, I try to keep all of mine consistent as far as usernames and passwords go, so that is why we have the global default 'hostname' postfix, 'username', and 'password' config fields. When you type in something like `dw push dev01`, the 'hostname' will converted to 'dev01-region-brand.demandware.net'. If you need to override any settings per instance, you can do that in 'instances', as seen above with a scenario where the dev02 sandbox instance has a different password than the rest (yay strict af demandware password policy). 256 | #### Staging 257 | For staging, if you are using a custom hostname, you can fill that into 'hostname'. 258 | ##### Two-factor Auth and WebDAV 259 | If two-factor auth is configured on staging, the special hostname required for webdav can be filled into 'webdav' as well as key, cert, and ca — these are all generated using the staging cert given to you from support. The process of creating the user key and pem from the staging cert is outlined in the support documentation. 260 | #### Versions, Activate, and Job commands 261 | To get access to 'versions', 'activate', and 'job', you will need to setup your Open Commerce API Settings (Global, not Site) on each instance. A Client ID and Client Password can be created in the Account Center (account.demandware.com). 262 | 263 | ```json 264 | { 265 | "_v":"19.3", 266 | "clients": 267 | [ 268 | { 269 | "client_id":"client-id-from-account-dashboard", 270 | "resources": 271 | [ 272 | { 273 | "resource_id":"/code_versions", 274 | "methods":["get"], 275 | "read_attributes":"(**)", 276 | "write_attributes":"(**)" 277 | }, 278 | { 279 | "resource_id":"/code_versions/*", 280 | "methods":["get", "patch", "put"], 281 | "read_attributes":"(**)", 282 | "write_attributes":"(**)" 283 | }, 284 | { 285 | "resource_id":"/jobs/*/executions", 286 | "methods":["post"], 287 | "read_attributes":"(**)", 288 | "write_attributes":"(**)" 289 | }, 290 | { 291 | "resource_id":"/jobs/*/executions/*", 292 | "methods":["get"], 293 | "read_attributes":"(**)", 294 | "write_attributes":"(**)" 295 | } 296 | ] 297 | } 298 | ] 299 | } 300 | ``` 301 | --------------------------------------------------------------------------------