@dynamicweb/cli 2.0.0-beta.0 → 2.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -440,7 +440,7 @@ Download the latest Swift release, a specific tag, or the nightly build.
440
440
 
441
441
  ```sh
442
442
  dw swift -l
443
- dw swift . --tag v1.25.1 --force
443
+ dw swift . --tag v2.3.0 --force
444
444
  dw swift . --nightly --force
445
445
  ```
446
446
 
@@ -465,6 +465,15 @@ dw query HealthCheck \
465
465
 
466
466
  For longer-lived runners, you can configure a saved environment once with `dw login --oauth`. Full CI/CD guidance will be expanded in the documentation.
467
467
 
468
+ ## QA Smoke Testing
469
+
470
+ This repository now includes a reusable QA harness in [qa/README.md](qa/README.md). It runs the CLI against a real DynamicWeb solution using OAuth client credentials, keeps its own isolated `HOME`, and can exercise both:
471
+
472
+ - saved-environment developer flow
473
+ - ephemeral CI/CD-style flow with `--host --auth oauth`
474
+
475
+ The harness currently excludes `database` and `swift`.
476
+
468
477
  ## Using Git Bash
469
478
 
470
479
  Git Bash can rewrite relative paths in a way that interferes with CLI file operations. If you see the path-conversion warning, disable it for the session before running file commands:
@@ -8,24 +8,24 @@ const exclude = ['_', '$0', 'command', 'list', 'json', 'verbose', 'v', 'host', '
8
8
 
9
9
  export function commandCommand() {
10
10
  return {
11
- command: 'command [command]',
12
- describe: 'Runs the given command',
11
+ command: 'command [command]',
12
+ describe: 'Runs the given command',
13
13
  builder: (yargs) => {
14
14
  return yargs
15
- .positional('command', {
16
- describe: 'The command to execute'
17
- })
18
- .option('json', {
19
- describe: 'Literal json or location of json file to send'
20
- })
21
- .option('list', {
22
- alias: 'l',
23
- describe: 'Lists all the properties for the command, currently not working'
24
- })
25
- .option('output', {
26
- choices: ['json'],
27
- describe: 'Outputs a single JSON response for automation-friendly parsing'
28
- })
15
+ .positional('command', {
16
+ describe: 'The command to execute'
17
+ })
18
+ .option('json', {
19
+ describe: 'Literal json or location of json file to send'
20
+ })
21
+ .option('list', {
22
+ alias: 'l',
23
+ describe: 'Lists all the properties for the command, currently not working'
24
+ })
25
+ .option('output', {
26
+ choices: ['json'],
27
+ describe: 'Outputs a single JSON response for automation-friendly parsing'
28
+ })
29
29
  },
30
30
  handler: async (argv) => {
31
31
  const output = createCommandOutput(argv);
@@ -61,19 +61,22 @@ async function getProperties(env, user, command) {
61
61
  throw createCommandError('The --list option is not currently implemented for commands.');
62
62
  }
63
63
 
64
- function getQueryParams(argv) {
64
+ export function getQueryParams(argv) {
65
65
  let params = {}
66
66
  Object.keys(argv).filter(k => !exclude.includes(k)).forEach(k => params['Command.' + k] = argv[k])
67
67
  return params
68
68
  }
69
69
 
70
- function parseJsonOrPath(json) {
70
+ export function parseJsonOrPath(json) {
71
71
  if (!json) return
72
+ const trimmed = json.trim();
73
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
74
+ return JSON.parse(trimmed);
75
+ }
72
76
  if (fs.existsSync(json)) {
73
- return JSON.parse(fs.readFileSync(path.resolve(json)))
74
- } else {
75
- return JSON.parse(json)
77
+ return JSON.parse(fs.readFileSync(path.resolve(json)));
76
78
  }
79
+ return JSON.parse(json);
77
80
  }
78
81
 
79
82
  async function runCommand(env, user, command, queryParams, data) {
@@ -27,10 +27,22 @@ export function setupConfig() {
27
27
  }
28
28
 
29
29
  export function getConfig() {
30
- return localConfig;
30
+ return localConfig || {};
31
+ }
32
+
33
+ /**
34
+ * Overrides the in-memory config for testing.
35
+ * @param {Object} config - Must be a plain, non-null object; handleConfig writes keys directly onto it.
36
+ */
37
+ export function setConfigForTests(config) {
38
+ if (config === null || typeof config !== 'object') {
39
+ throw new Error('setConfigForTests: config must be a plain object');
40
+ }
41
+ localConfig = config;
31
42
  }
32
43
 
33
44
  export function handleConfig(argv) {
45
+ localConfig = localConfig || {};
34
46
  Object.keys(argv).forEach(a => {
35
47
  if (a != '_' && a != '$0') {
36
48
  localConfig[a] = resolveConfig(a, argv[a], localConfig[a] || {});
@@ -52,4 +64,4 @@ function resolveConfig(key, obj, conf) {
52
64
  conf[a] = resolveConfig(key, obj[a], conf[a]);
53
65
  })
54
66
  return conf;
55
- }
67
+ }
@@ -22,6 +22,30 @@ export function getAgent(protocol) {
22
22
  return protocol === 'http' ? httpAgent : httpsAgent;
23
23
  }
24
24
 
25
+ export function parseHostInput(hostValue) {
26
+ if (!hostValue || typeof hostValue !== 'string' || !hostValue.trim()) {
27
+ throw createCommandError(`Invalid host value: ${hostValue}`);
28
+ }
29
+ hostValue = hostValue.trim();
30
+ const hostSplit = hostValue.split('://');
31
+
32
+ if (hostSplit.length === 1) {
33
+ return {
34
+ protocol: 'https',
35
+ host: hostSplit[0]
36
+ };
37
+ }
38
+
39
+ if (hostSplit.length === 2) {
40
+ return {
41
+ protocol: hostSplit[0],
42
+ host: hostSplit[1]
43
+ };
44
+ }
45
+
46
+ throw createCommandError(`Issues resolving host ${hostValue}`);
47
+ }
48
+
25
49
  export function envCommand() {
26
50
  return {
27
51
  command: 'env [env]',
@@ -61,7 +85,9 @@ export function envCommand() {
61
85
  }
62
86
  }
63
87
 
64
- export async function setupEnv(argv, output = null) {
88
+ export async function setupEnv(argv, output = null, deps = {}) {
89
+ const interactiveEnvFn = deps.interactiveEnvFn || interactiveEnv;
90
+ const cfg = getConfig();
65
91
  let env = {};
66
92
  let askEnv = true;
67
93
 
@@ -75,8 +101,8 @@ export async function setupEnv(argv, output = null) {
75
101
  }
76
102
  }
77
103
 
78
- if (askEnv && getConfig().env) {
79
- env = getConfig().env[argv.env] || getConfig().env[getConfig()?.current?.env];
104
+ if (askEnv && cfg.env) {
105
+ env = cfg.env[argv.env] || cfg.env[cfg?.current?.env];
80
106
  if (env && !env.protocol) {
81
107
  logMessage(argv, 'Protocol for environment not set, defaulting to https');
82
108
  env.protocol = 'https';
@@ -88,7 +114,7 @@ export async function setupEnv(argv, output = null) {
88
114
  }
89
115
 
90
116
  logMessage(argv, 'Current environment not set, please set it');
91
- await interactiveEnv(argv, {
117
+ await interactiveEnvFn(argv, {
92
118
  environment: {
93
119
  type: 'input'
94
120
  },
@@ -96,7 +122,8 @@ export async function setupEnv(argv, output = null) {
96
122
  default: true
97
123
  }
98
124
  }, output)
99
- env = getConfig().env[getConfig()?.current?.env];
125
+ const updatedConfig = getConfig();
126
+ env = updatedConfig.env?.[updatedConfig?.current?.env];
100
127
  }
101
128
 
102
129
  if (!env || Object.keys(env).length === 0) {
@@ -119,7 +146,9 @@ async function handleEnv(argv, output) {
119
146
  output.log(`Users in environment ${env}: ${users}`);
120
147
  } else if (argv.env) {
121
148
  const result = await changeEnv(argv, output);
122
- output.addData(result);
149
+ if (result !== null) {
150
+ output.addData(result);
151
+ }
123
152
  } else if (argv.list) {
124
153
  const environments = Object.keys(getConfig().env || {});
125
154
  output.addData({ environments });
@@ -160,16 +189,9 @@ export async function interactiveEnv(argv, options, output) {
160
189
  }
161
190
  getConfig().env[result.environment] = getConfig().env[result.environment] || {};
162
191
  if (result.host) {
163
- const hostSplit = result.host.split("://");
164
- if (hostSplit.length == 1) {
165
- getConfig().env[result.environment].protocol = 'https';
166
- getConfig().env[result.environment].host = hostSplit[0];
167
- } else if (hostSplit.length == 2) {
168
- getConfig().env[result.environment].protocol = hostSplit[0];
169
- getConfig().env[result.environment].host = hostSplit[1];
170
- } else {
171
- throw createCommandError(`Issues resolving host ${result.host}`);
172
- }
192
+ const resolvedHost = parseHostInput(result.host);
193
+ getConfig().env[result.environment].protocol = resolvedHost.protocol;
194
+ getConfig().env[result.environment].host = resolvedHost.host;
173
195
  }
174
196
  if (result.environment) {
175
197
  getConfig().current = getConfig().current || {};
@@ -182,7 +204,7 @@ export async function interactiveEnv(argv, options, output) {
182
204
  const currentEnv = getConfig().env[result.environment];
183
205
  const data = {
184
206
  environment: result.environment,
185
- protocol: currentEnv.protocol,
207
+ protocol: currentEnv.protocol || null,
186
208
  host: currentEnv.host || null,
187
209
  current: getConfig().current.env
188
210
  };
@@ -197,13 +219,13 @@ export async function interactiveEnv(argv, options, output) {
197
219
  async function changeEnv(argv, output) {
198
220
  const environments = getConfig().env || {};
199
221
 
200
- if (!Object.keys(environments).includes(argv.env)) {
222
+ if (!Object.hasOwn(environments, argv.env)) {
201
223
  if (isJsonOutput(argv)) {
202
224
  throw createCommandError(`The specified environment ${argv.env} doesn't exist, please create it`, 404);
203
225
  }
204
226
 
205
227
  logMessage(argv, `The specified environment ${argv.env} doesn't exist, please create it`);
206
- return await interactiveEnv(argv, {
228
+ await interactiveEnv(argv, {
207
229
  environment: {
208
230
  type: 'input',
209
231
  default: argv.env,
@@ -218,6 +240,7 @@ async function changeEnv(argv, output) {
218
240
  default: true
219
241
  }
220
242
  }, output)
243
+ return null;
221
244
  } else {
222
245
  getConfig().current.env = argv.env;
223
246
  updateConfig();
@@ -226,7 +249,10 @@ async function changeEnv(argv, output) {
226
249
  current: getConfig().current.env
227
250
  };
228
251
  logMessage(argv, `Your current environment is now ${getConfig().current.env}`);
229
- return data;
252
+ if (output) {
253
+ output.addData(data);
254
+ }
255
+ return null;
230
256
  }
231
257
  }
232
258
 
@@ -10,102 +10,102 @@ import { extractWithProgress } from '../extractor.js';
10
10
 
11
11
  export function filesCommand() {
12
12
  return {
13
- command: 'files [dirPath] [outPath]',
14
- describe: 'Handles files',
13
+ command: 'files [dirPath] [outPath]',
14
+ describe: 'Handles files',
15
15
  builder: (yargs) => {
16
16
  return yargs
17
- .positional('dirPath', {
18
- describe: 'The directory to list or export'
19
- })
20
- .positional('outPath', {
21
- describe: 'The directory to export the specified directory to',
22
- default: '.'
23
- })
24
- .option('list', {
25
- alias: 'l',
26
- type: 'boolean',
27
- describe: 'Lists all directories and files'
28
- })
29
- .option('export', {
30
- alias: 'e',
31
- type: 'boolean',
32
- describe: 'Exports the specified directory and all subdirectories at [dirPath] to [outPath]'
33
- })
34
- .option('import', {
35
- alias: 'i',
36
- type: 'boolean',
37
- describe: 'Imports the file at [dirPath] to [outPath]'
38
- })
39
- .option('overwrite', {
40
- alias: 'o',
41
- type: 'boolean',
42
- describe: 'Used with import, will overwrite existing files at destination if set to true'
43
- })
44
- .option('createEmpty', {
45
- type: 'boolean',
46
- describe: 'Used with import, will create a file even if its empty'
47
- })
48
- .option('includeFiles', {
49
- alias: 'f',
50
- type: 'boolean',
51
- describe: 'Used with export, includes files in list of directories and files'
52
- })
53
- .option('recursive', {
54
- alias: 'r',
55
- type: 'boolean',
56
- describe: 'Used with list, import and export, handles all directories recursively'
57
- })
58
- .option('raw', {
59
- type: 'boolean',
60
- describe: 'Used with export, keeps zip file instead of unpacking it'
61
- })
62
- .option('dangerouslyIncludeLogsAndCache', {
63
- type: 'boolean',
64
- describe: 'Includes log and cache folders during export. Risky and usually not recommended'
65
- })
66
- .option('iamstupid', {
67
- type: 'boolean',
68
- hidden: true,
69
- describe: 'Deprecated alias for --dangerouslyIncludeLogsAndCache'
70
- })
71
- .option('delete', {
72
- alias: 'd',
73
- type: 'boolean',
74
- describe: 'Deletes the file or directory at [dirPath]. Detects type from path (use --asFile/--asDirectory to override)'
75
- })
76
- .option('empty', {
77
- type: 'boolean',
78
- describe: 'Used with --delete, empties a directory instead of deleting it'
79
- })
80
- .option('copy', {
81
- type: 'string',
82
- describe: 'Copies the file or directory at [dirPath] to the given destination path'
83
- })
84
- .option('move', {
85
- type: 'string',
86
- describe: 'Moves the file or directory at [dirPath] to the given destination path'
87
- })
88
- .option('asFile', {
89
- type: 'boolean',
90
- alias: 'af',
91
- describe: 'Forces the command to treat the path as a single file, even if it has no extension.',
92
- conflicts: 'asDirectory'
93
- })
94
- .option('asDirectory', {
95
- type: 'boolean',
96
- alias: 'ad',
97
- describe: 'Forces the command to treat the path as a directory, even if its name contains a dot.',
98
- conflicts: 'asFile'
99
- })
100
- .option('output', {
101
- choices: ['json'],
102
- describe: 'Outputs a single JSON response for automation-friendly parsing'
103
- })
104
- .option('json', {
105
- type: 'boolean',
106
- hidden: true,
107
- describe: 'Deprecated alias for --output json'
108
- })
17
+ .positional('dirPath', {
18
+ describe: 'The directory to list or export'
19
+ })
20
+ .positional('outPath', {
21
+ describe: 'The directory to export the specified directory to',
22
+ default: '.'
23
+ })
24
+ .option('list', {
25
+ alias: 'l',
26
+ type: 'boolean',
27
+ describe: 'Lists all directories and files'
28
+ })
29
+ .option('export', {
30
+ alias: 'e',
31
+ type: 'boolean',
32
+ describe: 'Exports the specified directory and all subdirectories at [dirPath] to [outPath]'
33
+ })
34
+ .option('import', {
35
+ alias: 'i',
36
+ type: 'boolean',
37
+ describe: 'Imports the file at [dirPath] to [outPath]'
38
+ })
39
+ .option('overwrite', {
40
+ alias: 'o',
41
+ type: 'boolean',
42
+ describe: 'Used with import, will overwrite existing files at destination if set to true'
43
+ })
44
+ .option('createEmpty', {
45
+ type: 'boolean',
46
+ describe: 'Used with import, will create a file even if its empty'
47
+ })
48
+ .option('includeFiles', {
49
+ alias: 'f',
50
+ type: 'boolean',
51
+ describe: 'Used with export, includes files in list of directories and files'
52
+ })
53
+ .option('recursive', {
54
+ alias: 'r',
55
+ type: 'boolean',
56
+ describe: 'Used with list, import and export, handles all directories recursively'
57
+ })
58
+ .option('raw', {
59
+ type: 'boolean',
60
+ describe: 'Used with export, keeps zip file instead of unpacking it'
61
+ })
62
+ .option('dangerouslyIncludeLogsAndCache', {
63
+ type: 'boolean',
64
+ describe: 'Includes log and cache folders during export. Risky and usually not recommended'
65
+ })
66
+ .option('iamstupid', {
67
+ type: 'boolean',
68
+ hidden: true,
69
+ describe: 'Deprecated alias for --dangerouslyIncludeLogsAndCache'
70
+ })
71
+ .option('delete', {
72
+ alias: 'd',
73
+ type: 'boolean',
74
+ describe: 'Deletes the file or directory at [dirPath]. Detects type from path (use --asFile/--asDirectory to override)'
75
+ })
76
+ .option('empty', {
77
+ type: 'boolean',
78
+ describe: 'Used with --delete, empties a directory instead of deleting it'
79
+ })
80
+ .option('copy', {
81
+ type: 'string',
82
+ describe: 'Copies the file or directory at [dirPath] to the given destination path'
83
+ })
84
+ .option('move', {
85
+ type: 'string',
86
+ describe: 'Moves the file or directory at [dirPath] to the given destination path'
87
+ })
88
+ .option('asFile', {
89
+ type: 'boolean',
90
+ alias: 'af',
91
+ describe: 'Forces the command to treat the path as a single file, even if it has no extension.',
92
+ conflicts: 'asDirectory'
93
+ })
94
+ .option('asDirectory', {
95
+ type: 'boolean',
96
+ alias: 'ad',
97
+ describe: 'Forces the command to treat the path as a directory, even if its name contains a dot.',
98
+ conflicts: 'asFile'
99
+ })
100
+ .option('output', {
101
+ choices: ['json'],
102
+ describe: 'Outputs a single JSON response for automation-friendly parsing'
103
+ })
104
+ .option('json', {
105
+ type: 'boolean',
106
+ hidden: true,
107
+ describe: 'Deprecated alias for --output json'
108
+ })
109
109
  },
110
110
  handler: async (argv) => {
111
111
  if (argv.json && !argv.output) {
@@ -149,13 +149,13 @@ async function handleFiles(argv, output) {
149
149
 
150
150
  if (argv.export) {
151
151
  if (argv.dirPath) {
152
-
152
+
153
153
  const isFile = isFilePath(argv, argv.dirPath);
154
154
 
155
155
  if (isFile) {
156
- let parentDirectory = path.dirname(argv.dirPath);
156
+ let parentDirectory = path.dirname(argv.dirPath);
157
157
  parentDirectory = parentDirectory === '.' ? '/' : parentDirectory;
158
-
158
+
159
159
  await download(env, user, parentDirectory, argv.outPath, false, null, true, argv.dangerouslyIncludeLogsAndCache, [argv.dirPath], true, output);
160
160
  } else {
161
161
  await download(env, user, argv.dirPath, argv.outPath, true, null, argv.raw, argv.dangerouslyIncludeLogsAndCache, [], false, output);
@@ -231,9 +231,9 @@ async function handleFiles(argv, output) {
231
231
  }
232
232
 
233
233
  function getFilesInDirectory(dirPath) {
234
- return fs.statSync(dirPath).isFile() ? [ dirPath ] : fs.readdirSync(dirPath)
235
- .map(file => path.join(dirPath, file))
236
- .filter(file => fs.statSync(file).isFile());
234
+ return fs.statSync(dirPath).isFile() ? [dirPath] : fs.readdirSync(dirPath)
235
+ .map(file => path.join(dirPath, file))
236
+ .filter(file => fs.statSync(file).isFile());
237
237
  }
238
238
 
239
239
  async function processDirectory(env, user, dirPath, outPath, originalDir, createEmpty, isRoot = false, overwrite = false, output) {
@@ -242,8 +242,8 @@ async function processDirectory(env, user, dirPath, outPath, originalDir, create
242
242
  await uploadFiles(env, user, filesInDir, isRoot ? outPath : path.join(outPath, path.basename(dirPath)), createEmpty, overwrite, output);
243
243
 
244
244
  const subDirectories = fs.readdirSync(dirPath)
245
- .map(subDir => path.join(dirPath, subDir))
246
- .filter(subDir => fs.statSync(subDir).isDirectory());
245
+ .map(subDir => path.join(dirPath, subDir))
246
+ .filter(subDir => fs.statSync(subDir).isDirectory());
247
247
  for (let subDir of subDirectories) {
248
248
  await processDirectory(env, user, subDir, isRoot ? outPath : path.join(outPath, path.basename(dirPath)), originalDir, createEmpty, false, overwrite, output);
249
249
  }
@@ -278,7 +278,7 @@ function resolveTree(dirs, indentLevel, parentHasFiles, output) {
278
278
  resolveTree(dir.files?.data ?? [], indentLevel + '│\t', false, output);
279
279
  } else {
280
280
  resolveTree(dir.directories ?? [], indentLevel + '\t', hasFiles, output);
281
- resolveTree(dir.files?.data ?? [], indentLevel + '\t', false, output);
281
+ resolveTree(dir.files?.data ?? [], indentLevel + '\t', false, output);
282
282
  }
283
283
  }
284
284
  }
@@ -342,7 +342,7 @@ async function download(env, user, dirPath, outPath, recursive, outname, raw, da
342
342
  await extractArchive(filename, filePath, outPath, raw, output);
343
343
  }
344
344
 
345
- function prepareDownloadCommandData(directoryPath, excludeDirectories, fileNames, recursive, singleFileMode) {
345
+ export function prepareDownloadCommandData(directoryPath, excludeDirectories, fileNames, recursive, singleFileMode) {
346
346
  const data = {
347
347
  'DirectoryPath': directoryPath ?? '/',
348
348
  'ExcludeDirectories': [excludeDirectories],
@@ -397,10 +397,8 @@ async function extractArchive(filename, filePath, outPath, raw, output) {
397
397
  }
398
398
  output.log(`Finished extracting ${filename} to ${outPath}\n`);
399
399
 
400
- fs.unlink(filePath, function(err) {
401
- if (err) {
402
- output.verboseLog(`Warning: Failed to delete temporary archive ${filePath}: ${err.message}`);
403
- }
400
+ await fs.promises.unlink(filePath).catch(err => {
401
+ output.verboseLog(`Warning: Failed to delete temporary archive ${filePath}: ${err.message}`);
404
402
  });
405
403
  }
406
404
 
@@ -577,13 +575,13 @@ async function uploadChunk(env, user, filePathsChunk, destinationPath, createEmp
577
575
  form.append('path', destinationPath);
578
576
  form.append('skipExistingFiles', String(!overwrite));
579
577
  form.append('allowOverwrite', String(overwrite));
580
-
578
+
581
579
  filePathsChunk.forEach(fileToUpload => {
582
580
  output.log(`${fileToUpload}`)
583
581
  form.append('files', fs.createReadStream(path.resolve(fileToUpload)));
584
582
  });
585
583
 
586
- const res = await fetch(`${env.protocol}://${env.host}/Admin/Api/Upload?` + new URLSearchParams({"createEmptyFiles": createEmpty, "createMissingDirectories": true}), {
584
+ const res = await fetch(`${env.protocol}://${env.host}/Admin/Api/Upload?` + new URLSearchParams({ "createEmptyFiles": createEmpty, "createMissingDirectories": true }), {
587
585
  method: 'POST',
588
586
  body: form,
589
587
  headers: {
@@ -591,7 +589,7 @@ async function uploadChunk(env, user, filePathsChunk, destinationPath, createEmp
591
589
  },
592
590
  agent: getAgent(env.protocol)
593
591
  });
594
-
592
+
595
593
  if (res.ok) {
596
594
  return await res.json();
597
595
  }
@@ -604,22 +602,21 @@ export function resolveFilePath(filePath) {
604
602
  let p = path.parse(path.resolve(filePath))
605
603
  let regex = wildcardToRegExp(p.base);
606
604
  let resolvedPath = fs.readdirSync(p.dir).filter((allFilesPaths) => allFilesPaths.match(regex) !== null)[0]
607
- if (resolvedPath === undefined)
608
- {
605
+ if (resolvedPath === undefined) {
609
606
  throw createCommandError(`Could not find any files with the name ${filePath}`, 1);
610
607
  }
611
608
  return path.join(p.dir, resolvedPath);
612
609
  }
613
610
 
614
611
 
615
- function isFilePath(argv, dirPath) {
612
+ export function isFilePath(argv, dirPath) {
616
613
  if (argv.asFile || argv.asDirectory) {
617
- return argv.asFile;
614
+ return Boolean(argv.asFile);
618
615
  }
619
616
  return path.extname(dirPath) !== '';
620
617
  }
621
618
 
622
- function wildcardToRegExp(wildcard) {
619
+ export function wildcardToRegExp(wildcard) {
623
620
  const escaped = wildcard.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
624
621
  return new RegExp('^' + escaped.replace(/\*/g, '.*') + '$');
625
622
  }
@@ -678,7 +675,7 @@ function createFilesOutput(argv) {
678
675
  };
679
676
  }
680
677
 
681
- function getFilesOperation(argv) {
678
+ export function getFilesOperation(argv) {
682
679
  if (argv.list) {
683
680
  return 'list';
684
681
  }
@@ -6,22 +6,22 @@ import { uploadFiles, resolveFilePath } from './files.js';
6
6
 
7
7
  export function installCommand() {
8
8
  return {
9
- command: 'install [filePath]',
9
+ command: 'install <filePath>',
10
10
  describe: 'Installs the addin on the given path, allowed file extensions are .dll, .nupkg',
11
11
  builder: (yargs) => {
12
12
  return yargs
13
- .positional('filePath', {
14
- describe: 'Path to the file to install'
15
- })
16
- .option('queue', {
17
- alias: 'q',
18
- type: 'boolean',
19
- describe: 'Queues the install for next Dynamicweb recycle'
20
- })
21
- .option('output', {
22
- choices: ['json'],
23
- describe: 'Outputs a single JSON response for automation-friendly parsing'
24
- })
13
+ .positional('filePath', {
14
+ describe: 'Path to the file to install'
15
+ })
16
+ .option('queue', {
17
+ alias: 'q',
18
+ type: 'boolean',
19
+ describe: 'Queues the install for next Dynamicweb recycle'
20
+ })
21
+ .option('output', {
22
+ choices: ['json'],
23
+ describe: 'Outputs a single JSON response for automation-friendly parsing'
24
+ })
25
25
  },
26
26
  handler: async (argv) => {
27
27
  const output = createInstallOutput(argv);
@@ -43,7 +43,7 @@ async function handleInstall(argv, output) {
43
43
  let env = await setupEnv(argv, output);
44
44
  let user = await setupUser(argv, env);
45
45
  let resolvedPath = resolveFilePath(argv.filePath);
46
- await uploadFiles(env, user, [ resolvedPath ], 'System/AddIns/Local', false, true, output);
46
+ await uploadFiles(env, user, [resolvedPath], 'System/AddIns/Local', false, true, output);
47
47
  await installAddin(env, user, resolvedPath, argv.queue, output);
48
48
  }
49
49
 
@@ -56,15 +56,29 @@ async function installAddin(env, user, resolvedPath, queue, output) {
56
56
  `${filename.substring(0, filename.lastIndexOf('.')) || filename}|${path.extname(resolvedPath)}`
57
57
  ]
58
58
  }
59
- let res = await fetch(`${env.protocol}://${env.host}/Admin/Api/AddinInstall`, {
60
- method: 'POST',
61
- body: JSON.stringify(data),
62
- headers: {
63
- 'Content-Type': 'application/json',
64
- 'Authorization': `Bearer ${user.apiKey}`
65
- },
66
- agent: getAgent(env.protocol)
67
- });
59
+ const controller = new AbortController();
60
+ const timeoutId = setTimeout(() => controller.abort(), 30_000);
61
+
62
+ let res;
63
+ try {
64
+ res = await fetch(`${env.protocol}://${env.host}/Admin/Api/AddinInstall`, {
65
+ method: 'POST',
66
+ body: JSON.stringify(data),
67
+ headers: {
68
+ 'Content-Type': 'application/json',
69
+ 'Authorization': `Bearer ${user.apiKey}`
70
+ },
71
+ agent: getAgent(env.protocol),
72
+ signal: controller.signal
73
+ });
74
+ } catch (err) {
75
+ if (err.name === 'AbortError') {
76
+ throw createCommandError('Addin installation request timed out.', 408);
77
+ }
78
+ throw err;
79
+ } finally {
80
+ clearTimeout(timeoutId);
81
+ }
68
82
 
69
83
  if (res.ok) {
70
84
  const body = await parseJsonSafe(res);
@@ -216,7 +216,7 @@ async function login(username, password, env, protocol, verbose) {
216
216
  }
217
217
  }
218
218
 
219
- function parseCookies (cookieHeader) {
219
+ export function parseCookies (cookieHeader) {
220
220
  const list = {};
221
221
  if (!cookieHeader) {
222
222
  return list;
@@ -415,7 +415,7 @@ async function authenticateWithOAuth(argv, env) {
415
415
  };
416
416
  }
417
417
 
418
- function shouldUseOAuth(argv, env = {}) {
418
+ export function shouldUseOAuth(argv, env = {}) {
419
419
  if (argv.auth === 'user') {
420
420
  return false;
421
421
  }
@@ -435,7 +435,7 @@ function shouldUseOAuth(argv, env = {}) {
435
435
  return env?.auth?.type === 'oauth_client_credentials';
436
436
  }
437
437
 
438
- function resolveOAuthConfig(argv, env = {}, requireCredentials = true) {
438
+ export function resolveOAuthConfig(argv, env = {}, requireCredentials = true) {
439
439
  const authConfig = env?.auth || {};
440
440
  const clientIdEnv = argv.clientIdEnv || authConfig.clientIdEnv || DEFAULT_CLIENT_ID_ENV;
441
441
  const clientSecretEnv = argv.clientSecretEnv || authConfig.clientSecretEnv || DEFAULT_CLIENT_SECRET_ENV;
@@ -74,33 +74,57 @@ async function getProperties(env, user, query) {
74
74
  if (body?.model?.properties?.groups === undefined) {
75
75
  throw createCommandError('Unable to fetch query parameters.', res.status, body);
76
76
  }
77
- return body.model.properties.groups.filter(g => g.name === 'Properties')[0].fields.map(field => `${field.name} (${field.typeName})`)
77
+ return extractQueryPropertyPrompts(body);
78
78
  }
79
79
 
80
80
  throw createCommandError('Unable to fetch query parameters.', res.status, await parseJsonSafe(res));
81
81
  }
82
82
 
83
- async function getQueryParams(env, user, argv, output) {
83
+ export async function getQueryParams(env, user, argv, output, deps = {}) {
84
84
  let params = {}
85
+ const getPropertiesFn = deps.getPropertiesFn || getProperties;
86
+ const promptFn = deps.promptFn || input;
85
87
  if (argv.interactive) {
86
- let properties = await getProperties(env, user, argv.query);
88
+ let properties = await getPropertiesFn(env, user, argv.query);
87
89
  output.log('The following properties will be requested:')
88
90
  output.log(properties)
89
- for (const p of properties) {
90
- const value = await input({ message: p });
91
- if (value) {
92
- const fieldName = p.split(' (')[0];
93
- params[fieldName] = value;
94
- }
95
- }
91
+ params = await buildInteractiveQueryParams(properties, promptFn);
96
92
  } else {
97
- Object.keys(argv).filter(k => !exclude.includes(k)).forEach(k => params[k] = argv[k])
93
+ params = buildQueryParamsFromArgv(argv);
94
+ }
95
+ return params
96
+ }
97
+
98
+ export function extractQueryPropertyPrompts(body) {
99
+ const fields = body?.model?.properties?.groups?.find(g => g.name === 'Properties')?.fields || [];
100
+ return fields.map(field => `${field.name} (${field.typeName})`);
101
+ }
102
+
103
+ export function getFieldNameFromPropertyPrompt(prompt) {
104
+ return prompt.replace(/\s+\([^)]+\)$/, '');
105
+ }
106
+
107
+ export async function buildInteractiveQueryParams(properties, promptFn = input) {
108
+ const params = {};
109
+
110
+ for (const propertyPrompt of properties) {
111
+ const value = await promptFn({ message: propertyPrompt });
112
+ if (value) {
113
+ params[getFieldNameFromPropertyPrompt(propertyPrompt)] = value;
114
+ }
98
115
  }
116
+
117
+ return params;
118
+ }
119
+
120
+ export function buildQueryParamsFromArgv(argv) {
121
+ let params = {}
122
+ Object.keys(argv).filter(k => !exclude.includes(k)).forEach(k => params[k] = argv[k])
99
123
  return params
100
124
  }
101
125
 
102
126
  async function runQuery(env, user, query, params) {
103
- let res = await fetch(`${env.protocol}://${env.host}/Admin/Api/${query}?` + new URLSearchParams(params), {
127
+ let res = await fetch(`${env.protocol}://${env.host}/Admin/Api/${encodeURIComponent(query)}?` + new URLSearchParams(params), {
104
128
  method: 'GET',
105
129
  headers: {
106
130
  'Authorization': `Bearer ${user.apiKey}`
package/bin/index.js CHANGED
@@ -84,8 +84,12 @@ function baseCommand() {
84
84
  } else if (currentEnv?.current?.user) {
85
85
  console.log(`User: ${currentEnv.current.user}`);
86
86
  }
87
- console.log(`Protocol: ${currentEnv.protocol}`);
88
- console.log(`Host: ${currentEnv.host}`);
87
+ if (currentEnv.protocol) {
88
+ console.log(`Protocol: ${currentEnv.protocol}`);
89
+ }
90
+ if (currentEnv.host) {
91
+ console.log(`Host: ${currentEnv.host}`);
92
+ }
89
93
  }
90
94
  }
91
95
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@dynamicweb/cli",
3
3
  "type": "module",
4
4
  "description": "CLI for interacting with Dynamicweb 10 solutions from the command line.",
5
- "version": "2.0.0-beta.0",
5
+ "version": "2.0.0-beta.2",
6
6
  "main": "bin/index.js",
7
7
  "files": [
8
8
  "bin/**"
@@ -15,7 +15,8 @@
15
15
  "devops"
16
16
  ],
17
17
  "scripts": {
18
- "test": "echo \"Error: no test specified\" && exit 1"
18
+ "test": "node --test",
19
+ "qa:smoke": "node qa/run-smoke.mjs"
19
20
  },
20
21
  "author": "Dynamicweb A/S (https://www.dynamicweb.com)",
21
22
  "repository": {