@automattic/vip 4.0.3 → 4.0.6

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.
Files changed (35) hide show
  1. package/AGENTS.md +2 -0
  2. package/dist/bin/vip-app-deploy-token-generate.js +102 -0
  3. package/dist/bin/vip-app-deploy-token.js +20 -0
  4. package/dist/bin/vip-app-deploy.js +7 -1
  5. package/dist/bin/vip-app.js +1 -1
  6. package/dist/bin/vip-dev-env-update.js +1 -5
  7. package/dist/commands/dev-env-import-sql.js +4 -1
  8. package/dist/commands/dev-env-sync-sql.js +3 -4
  9. package/dist/lib/analytics/clients/tracks.js +2 -2
  10. package/dist/lib/api/http.js +19 -14
  11. package/dist/lib/api.js +1 -2
  12. package/dist/lib/cli/command.js +37 -1
  13. package/dist/lib/cli/internal-bin-loader.js +2 -0
  14. package/dist/lib/client-file-uploader.js +110 -52
  15. package/dist/lib/custom-deploy/custom-deploy.js +37 -0
  16. package/dist/lib/dev-environment/dev-environment-core.js +5 -5
  17. package/dist/lib/dev-environment/dev-environment-lando.js +7 -3
  18. package/dist/lib/http/download-file.js +6 -1
  19. package/dist/lib/http/proxy-dispatcher.js +97 -0
  20. package/dist/lib/keychain/secure.js +4 -4
  21. package/dist/lib/keychain.js +1 -1
  22. package/dist/lib/media-import/status.js +6 -1
  23. package/dist/lib/retry.js +3 -2
  24. package/dist/lib/search-and-replace.js +3 -4
  25. package/dist/lib/validations/custom-deploy.js +60 -4
  26. package/dist/lib/validations/line-by-line.js +4 -9
  27. package/dist/lib/validations/sql.js +2 -2
  28. package/docs/COMMANDER-MIGRATION.md +1 -0
  29. package/docs/RELEASING.md +2 -0
  30. package/docs/TESTING.md +16 -1
  31. package/helpers/build-sea.js +2 -2
  32. package/helpers/release-smoke-test.js +869 -0
  33. package/npm-shrinkwrap.json +873 -1043
  34. package/package.json +23 -17
  35. package/test-utils/undici-mock.js +25 -0
package/AGENTS.md CHANGED
@@ -96,3 +96,5 @@ Guide for future agents working on this codebase. Focus on traps, cross-cutting
96
96
  - Respect exit semantics: uncaught exceptions routed to `exit.withError`; GraphQL errors call `process.exit(1)` unless explicitly disabled; 401 auth errors exit with guidance.
97
97
  - Watch shared state: current implementation mutates a global; ensure new parser avoids cross-command bleeding when multiple command instances run in-process (tests/CLI wrappers).
98
98
  - Login guardrails in `vip.js` let certain commands bypass auth; preserve equivalent shortcuts or add explicit non-interactive flags for CI.
99
+ - Normalize short-option equals syntax: rewrite `-x=value` to `-x value` before parsing for short options that expect values; track which short options take values to avoid breaking boolean flags that should remain flag-only.
100
+ - Apply tracked defaults after parsing: when an option has both a default and a custom parser function, apply the default only when `opts[optionName] === undefined` after the parser runs, so that explicit values and defaults maintain parity with the parser contract (see test `parses short options using equals syntax without prefixing values` in `__tests__/lib/cli/command.js`).
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ exports.__esModule = true;
5
+ exports.appDeployTokenGenerateCmd = appDeployTokenGenerateCmd;
6
+ var _graphqlTag = _interopRequireDefault(require("graphql-tag"));
7
+ var _api = _interopRequireDefault(require("../lib/api"));
8
+ var _command = _interopRequireDefault(require("../lib/cli/command"));
9
+ var exit = _interopRequireWildcard(require("../lib/cli/exit"));
10
+ var _tracker = require("../lib/tracker");
11
+ var _utils = require("../lib/utils");
12
+ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
13
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
14
+ const usage = 'vip app deploy-token generate';
15
+ const examples = [{
16
+ usage: 'vip @example-app.develop app deploy-token generate',
17
+ description: 'Generate a custom deploy access token for the selected environment.'
18
+ }, {
19
+ usage: 'vip @example-app.develop app deploy-token generate --format=json',
20
+ description: 'Generate a custom deploy access token and print the result as JSON.'
21
+ }];
22
+ const appQuery = `
23
+ id,
24
+ name,
25
+ environments{
26
+ id
27
+ appId
28
+ type
29
+ name
30
+ uniqueLabel
31
+ }
32
+ `;
33
+ const GENERATE_CUSTOM_DEPLOY_ACCESS_MUTATION = (0, _graphqlTag.default)`
34
+ mutation GenerateCustomDeployAccess($input: GenerateCustomDeployAccessInput) {
35
+ generateCustomDeployAccess(input: $input) {
36
+ token
37
+ expiresAt
38
+ }
39
+ }
40
+ `;
41
+ async function appDeployTokenGenerateCmd(_arg, {
42
+ app,
43
+ env
44
+ }) {
45
+ const trackerFn = (0, _tracker.makeCommandTracker)('deploy_token_generate', {
46
+ app: app.id,
47
+ env: env.uniqueLabel
48
+ });
49
+ await trackerFn('execute');
50
+ const envId = env.id;
51
+ if (typeof envId !== 'number') {
52
+ await trackerFn('error', {
53
+ error: 'Missing environment id'
54
+ });
55
+ exit.withError('Failed to generate deploy token: environment id is missing.');
56
+ return;
57
+ }
58
+ const api = (0, _api.default)();
59
+ let response;
60
+ try {
61
+ response = await api.mutate({
62
+ mutation: GENERATE_CUSTOM_DEPLOY_ACCESS_MUTATION,
63
+ variables: {
64
+ input: {
65
+ environmentIds: [envId]
66
+ }
67
+ }
68
+ });
69
+ } catch (err) {
70
+ const message = (0, _utils.parseApiError)(err) ?? 'Unknown error';
71
+ await trackerFn('error', {
72
+ error: message
73
+ });
74
+ exit.withError(`Failed to generate deploy token: ${message}`);
75
+ return;
76
+ }
77
+ const tokenPayload = response.data?.generateCustomDeployAccess;
78
+ if (!tokenPayload?.token || !tokenPayload.expiresAt) {
79
+ await trackerFn('error', {
80
+ error: 'Missing generateCustomDeployAccess payload'
81
+ });
82
+ exit.withError('Failed to generate deploy token: API returned an empty response.');
83
+ return;
84
+ }
85
+ await trackerFn('success');
86
+ return [{
87
+ appId: app.id,
88
+ appName: app.name,
89
+ envId,
90
+ env: env.uniqueLabel,
91
+ token: tokenPayload.token,
92
+ expiresAt: tokenPayload.expiresAt
93
+ }];
94
+ }
95
+ void (0, _command.default)({
96
+ appContext: true,
97
+ appQuery,
98
+ envContext: true,
99
+ format: true,
100
+ requiredArgs: 0,
101
+ usage
102
+ }).examples(examples).argv(process.argv, appDeployTokenGenerateCmd);
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ var _command = _interopRequireDefault(require("../lib/cli/command"));
5
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
6
+ const usage = 'vip app deploy-token';
7
+ const exampleUsage = 'vip @example-app.develop app deploy-token';
8
+ const examples = [{
9
+ usage: `${exampleUsage} generate`,
10
+ description: 'Generate a custom deploy access token for an environment that already has Custom Deployment enabled.'
11
+ }, {
12
+ usage: `${exampleUsage} generate --format=json | jq -r '.[0].token'`,
13
+ description: 'Generate a token and extract the token value for scripting workflows.'
14
+ }];
15
+ void (0, _command.default)({
16
+ requiredArgs: 0,
17
+ usage
18
+ }).command('generate', 'Generate a custom deploy access token for an environment.').examples(examples).argv(process.argv, () => {
19
+ process.exit(0);
20
+ });
@@ -79,6 +79,9 @@ async function appDeployCmd(arg = [], opts = {}) {
79
79
  const track = _tracker.trackEventWithEnv.bind(null, appId, envId);
80
80
  debug('Validating file...');
81
81
  await (0, _customDeploy.validateFile)(appId, envId, fileMeta);
82
+ if (!opts.skipLargeFileValidation) {
83
+ await (0, _customDeploy.validateLargeArchiveFiles)(appId, envId, fileMeta);
84
+ }
82
85
  await track('deploy_app_command_execute');
83
86
 
84
87
  // Upload file as different name to avoid overwriting existing same named files
@@ -229,8 +232,11 @@ const examples = [
229
232
  }, {
230
233
  usage: 'WPVIP_DEPLOY_TOKEN=1234 vip @example-app.develop app deploy file.tar.gz --skip-confirmation',
231
234
  description: 'Skip the confirmation prompt for the Custom Deployment of the archived file named "file.tar.gz" to the environment.'
235
+ }, {
236
+ usage: 'WPVIP_DEPLOY_TOKEN=1234 vip @example-app.develop app deploy file.zip --skip-large-file-validation',
237
+ description: 'Skip checking the deploy archive for repository archive files larger than 20 MB.'
232
238
  }];
233
239
  void (0, _command.default)({
234
240
  requiredArgs: 1,
235
241
  usage: baseUsage
236
- }).command('validate', 'Validate the directory structure of an archived file.').examples(examples).option('message', 'Add a description of a deployment.').option('skip-confirmation', 'Skip the confirmation prompt.').option('force', 'Skip confirmation prompt (deprecated)').option('app', 'Target an application. Accepts a string value for the application name or an integer for the application ID.').option('env', 'Target an environment. Accepts a string value for the environment type.').argv(process.argv, appDeployCmd);
242
+ }).command('validate', 'Validate the directory structure of an archived file.').examples(examples).option('message', 'Add a description of a deployment.').option('skip-confirmation', 'Skip the confirmation prompt.').option('skip-large-file-validation', 'Skip checking for repository archive files over 20 MB.', false).option('force', 'Skip confirmation prompt (deprecated)').option('app', 'Target an application. Accepts a string value for the application name or an integer for the application ID.').option('env', 'Target an environment. Accepts a string value for the environment type.').argv(process.argv, appDeployCmd);
@@ -11,7 +11,7 @@ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e
11
11
  (0, _command.default)({
12
12
  requiredArgs: 1,
13
13
  format: true
14
- }).example('vip app list', 'Retrieve a list of applications that can be accessed by the current authenticated VIP-CLI user.').example('vip app example-app', 'Retrieve information about the application named "example-app" and its environments.').example('WPVIP_DEPLOY_TOKEN=1234 vip @example-app.develop app deploy file.zip', 'Deploy a local archived file named "file.zip" that contains application code to a VIP Platform environment that has Custom Deployment enabled.').command('list', 'Retrieve a list of applications that can be accessed by the current authenticated VIP-CLI user.').command('deploy', 'Deploy an archived file of application code to an environment that has Custom Deployment enabled.').argv(process.argv, async arg => {
14
+ }).example('vip app list', 'Retrieve a list of applications that can be accessed by the current authenticated VIP-CLI user.').example('vip app example-app', 'Retrieve information about the application named "example-app" and its environments.').example('WPVIP_DEPLOY_TOKEN=1234 vip @example-app.develop app deploy file.zip', 'Deploy a local archived file named "file.zip" that contains application code to a VIP Platform environment that has Custom Deployment enabled.').example('vip @example-app.develop app deploy-token generate --format=json', 'Generate a custom deploy token for the environment and print it in JSON format for use with tools like jq.').command('list', 'Retrieve a list of applications that can be accessed by the current authenticated VIP-CLI user.').command('deploy', 'Deploy an archived file of application code to an environment that has Custom Deployment enabled.').command('deploy-token', 'Generate and manage custom deploy access tokens for environments that have Custom Deployment enabled.').argv(process.argv, async arg => {
15
15
  await (0, _tracker.trackEvent)('app_command_execute');
16
16
  let res;
17
17
  try {
@@ -53,7 +53,6 @@ cmd.argv(process.argv, async (arg, opt) => {
53
53
  ...opt
54
54
  };
55
55
  const configurationFileOptions = await (0, _devEnvironmentConfigurationFile.getConfigurationFileOptions)();
56
- const thereAreOptionsFromConfigFile = Object.keys(configurationFileOptions).length > 0;
57
56
  const finalPreselectedOptions = (0, _devEnvironmentConfigurationFile.mergeConfigurationFileOptions)(preselectedOptions, configurationFileOptions);
58
57
 
59
58
  /** @type {InstanceOptions} */
@@ -74,10 +73,7 @@ cmd.argv(process.argv, async (arg, opt) => {
74
73
  cron: currentInstanceData.cron,
75
74
  adminPassword: currentInstanceData.adminPassword
76
75
  };
77
- const providedOptions = Object.keys(opt).filter(option => option.length > 1) // Filter out single letter aliases
78
- .filter(option => !['debug', 'help', 'slug'].includes(option)); // Filter out options that are not related to instance configuration
79
-
80
- const suppressPrompts = providedOptions.length > 0 || thereAreOptionsFromConfigFile;
76
+ const suppressPrompts = !process.stdin.isTTY;
81
77
  const instanceData = await (0, _devEnvironmentCli.promptForArguments)(finalPreselectedOptions, defaultOptions, suppressPrompts, false);
82
78
  instanceData.siteSlug = slug;
83
79
  await (0, _devEnvironmentCore.updateEnvironment)(lando, instanceData);
@@ -115,7 +115,10 @@ class DevEnvImportSQLCommand {
115
115
  if (!dumpDetails.sourceDb) {
116
116
  console.log(`${_chalk.default.yellow('!')} Unable to identify source DB. Skipping myloader source-db option`);
117
117
  }
118
- importArg = ['db-myloader', '--overwrite-tables', ...(dumpDetails.sourceDb ? [`--source-db=${dumpDetails.sourceDb}`] : []), `--threads=${threadCount}`, '--max-threads-for-schema-creation=10', '--max-threads-for-index-creation=10', '--skip-triggers', '--skip-post', '--optimize-keys', '--checksum=SKIP', '--metadata-refresh-interval=2000000', '--stream'].concat(this.options.quiet ? ['--verbose=0'] : ['--verbose=3']);
118
+ importArg = ['db-myloader',
119
+ // Drop existing tables before restoring. Do not use the long form:
120
+ // `--overwrite-tables` is a silent no-op on myloader >= 0.20.
121
+ '-o', ...(dumpDetails.sourceDb ? [`--source-db=${dumpDetails.sourceDb}`] : []), `--threads=${threadCount}`, '--max-threads-for-schema-creation=10', '--max-threads-for-index-creation=10', '--skip-triggers', '--skip-post', '--optimize-keys', '--checksum=SKIP', '--metadata-refresh-interval=2000000', '--stream'].concat(this.options.quiet ? ['--verbose=0'] : ['--verbose=3']);
119
122
  }
120
123
  return importArg;
121
124
  }
@@ -180,12 +180,11 @@ class DevEnvSyncSQLCommand {
180
180
  const readStream = _fs.default.createReadStream(this.sqlFile);
181
181
  const replacedStream = await (0, _vipSearchReplace.replace)(readStream, replacements);
182
182
  const outputFile = `${this.tmpDir}/sql-export-sr.sql`;
183
- const streams = [replacedStream];
183
+ const transforms = [];
184
184
  if (this.getSqlDumpType() === _database.SqlDumpType.MYDUMPER) {
185
- streams.push((0, _database.fixMyDumperTransform)());
185
+ transforms.push((0, _database.fixMyDumperTransform)());
186
186
  }
187
- streams.push(_fs.default.createWriteStream(outputFile));
188
- await (0, _promises.pipeline)(streams);
187
+ await (0, _promises.pipeline)(replacedStream, ...transforms, _fs.default.createWriteStream(outputFile));
189
188
  _fs.default.renameSync(outputFile, this.sqlFile);
190
189
  }
191
190
  getSiteUrlsFromSDS() {
@@ -3,8 +3,8 @@
3
3
  exports.__esModule = true;
4
4
  exports.default = void 0;
5
5
  var _debug = _interopRequireDefault(require("debug"));
6
- var _nodeFetch = _interopRequireDefault(require("node-fetch"));
7
6
  var _querystring = _interopRequireDefault(require("querystring"));
7
+ var _undici = require("undici");
8
8
  var _apiConfig = require("../../cli/apiConfig");
9
9
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
10
10
  const debug = (0, _debug.default)('@automattic/vip:analytics:clients:tracks');
@@ -94,7 +94,7 @@ class Tracks {
94
94
  'User-Agent': this.userAgent
95
95
  };
96
96
  debug('send()', body);
97
- return (0, _nodeFetch.default)(Tracks.ENDPOINT, {
97
+ return (0, _undici.fetch)(Tracks.ENDPOINT, {
98
98
  method,
99
99
  body,
100
100
  headers
@@ -3,19 +3,25 @@
3
3
  exports.__esModule = true;
4
4
  exports.default = void 0;
5
5
  var _debug = _interopRequireDefault(require("debug"));
6
- var _nodeFetch = _interopRequireDefault(require("node-fetch"));
6
+ var _undici = require("undici");
7
7
  var _api = require("../../lib/api");
8
8
  var _env = _interopRequireDefault(require("../../lib/env"));
9
- var _proxyAgent = require("../../lib/http/proxy-agent");
9
+ var _proxyDispatcher = require("../../lib/http/proxy-dispatcher");
10
10
  var _token = _interopRequireDefault(require("../../lib/token"));
11
11
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
12
12
  const debug = (0, _debug.default)('@automattic/vip:http');
13
+ const isPlainObjectBody = value => {
14
+ if (!value || typeof value !== 'object') {
15
+ return false;
16
+ }
17
+ const prototype = Object.getPrototypeOf(value);
18
+ return prototype === Object.prototype || prototype === null;
19
+ };
20
+
13
21
  /**
14
22
  * Call the Public API with an arbitrary path (e.g. to connect to REST endpoints).
15
23
  * This will include the token in an Authorization header so requests are "logged-in."
16
24
  *
17
- * This is simply a wrapper around node-fetch
18
- *
19
25
  * @param {string} path API path to pass to `fetch` -- will be prefixed by the API_HOST
20
26
  * @param {Object} options options to pass to `fetch`
21
27
  * @return {Promise} Return value of the `fetch` call
@@ -29,26 +35,25 @@ var _default = async (path, options = {}) => {
29
35
  url = `${_api.API_HOST}${path}`;
30
36
  }
31
37
  const authToken = await _token.default.get();
32
- const proxyAgent = (0, _proxyAgent.createProxyAgent)(url);
38
+ const proxyDispatcher = (0, _proxyDispatcher.createProxyDispatcher)(url);
39
+ const shouldSerializeJsonBody = isPlainObjectBody(options.body);
33
40
  debug('running fetch', url);
34
- const headers = new Headers({
35
- ...options.headers
36
- });
41
+ const headers = new _undici.Headers(options.headers);
37
42
  if (!headers.has('Authorization')) {
38
43
  headers.set('Authorization', `Bearer ${authToken.raw}`);
39
44
  }
40
45
  if (!headers.has('User-Agent')) {
41
46
  headers.set('User-Agent', _env.default.userAgent);
42
47
  }
43
- if (!headers.has('Content-Type') && options.method !== 'GET') {
48
+ if (!headers.has('Content-Type') && options.method !== 'GET' && shouldSerializeJsonBody) {
44
49
  headers.set('Content-Type', 'application/json');
45
50
  }
46
- const opts = {
51
+ const requestBody = shouldSerializeJsonBody ? JSON.stringify(options.body) : options.body;
52
+ return (0, _undici.fetch)(url, {
47
53
  ...options,
48
- agent: proxyAgent ?? undefined,
54
+ dispatcher: proxyDispatcher ?? undefined,
49
55
  headers,
50
- body: typeof options.body === 'object' ? JSON.stringify(options.body) : options.body
51
- };
52
- return (0, _nodeFetch.default)(url, opts);
56
+ body: requestBody
57
+ });
53
58
  };
54
59
  exports.default = _default;
package/dist/lib/api.js CHANGED
@@ -12,7 +12,6 @@ var _retry = require("@apollo/client/link/retry");
12
12
  var _chalk = _interopRequireDefault(require("chalk"));
13
13
  var _debug = _interopRequireDefault(require("debug"));
14
14
  var _graphql = require("graphql");
15
- var _nodeFetch = require("node-fetch");
16
15
  var _http = _interopRequireDefault(require("./api/http"));
17
16
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
18
17
  // Config
@@ -41,7 +40,7 @@ function shouldRetryRequest(attempt, operation, error) {
41
40
  debug(`Request failed and max retry attempts reached. ${debugSuffix}`, error);
42
41
  return false;
43
42
  }
44
- if (error instanceof _nodeFetch.FetchError && error.code === 'ECONNREFUSED') {
43
+ if (error?.code === 'ECONNREFUSED') {
45
44
  debug(`Request failed. Retrying request due to connection refused error. ${debugSuffix}`);
46
45
  return true;
47
46
  }
@@ -323,6 +323,41 @@ class CommanderArgsCompat {
323
323
  }
324
324
  return null;
325
325
  }
326
+ hasWpCommandPayload(subcommandArgs) {
327
+ const wpFlagsWithoutValue = new Set(['--help', '-h', '--version', '-v', '--yes', '-y']);
328
+ let expectOptionValue = false;
329
+ for (const arg of subcommandArgs) {
330
+ if (expectOptionValue) {
331
+ if (!isOptionToken(arg)) {
332
+ expectOptionValue = false;
333
+ continue;
334
+ }
335
+ expectOptionValue = false;
336
+ }
337
+ if (!isOptionToken(arg)) {
338
+ return true;
339
+ }
340
+ if (arg.startsWith('--')) {
341
+ if (arg.includes('=') || wpFlagsWithoutValue.has(arg)) {
342
+ continue;
343
+ }
344
+ expectOptionValue = true;
345
+ continue;
346
+ }
347
+ if (/^-[A-Za-z0-9]$/.test(arg)) {
348
+ if (wpFlagsWithoutValue.has(arg)) {
349
+ continue;
350
+ }
351
+ expectOptionValue = true;
352
+ continue;
353
+ }
354
+ if (/^-[A-Za-z0-9]+=/.test(arg)) {
355
+ continue;
356
+ }
357
+ return true;
358
+ }
359
+ return false;
360
+ }
326
361
  async executeSubcommand(argv, parsedAlias, subcommand) {
327
362
  const currentScript = argv[1];
328
363
  const subcommandName = subcommand.name;
@@ -334,8 +369,9 @@ class CommanderArgsCompat {
334
369
  const hasSeparatorBeforeSubcommand = rawArgsBeforeSubcommand.includes('--');
335
370
  const argsBeforeSubcommand = rawArgsBeforeSubcommand.filter(arg => arg !== '--');
336
371
  const subcommandArgs = parsedAlias.argv.slice(subcommand.index + 1);
372
+ const hasWpCommandPayload = this.hasWpCommandPayload(subcommandArgs);
337
373
  const hasSeparator = argv.includes('--');
338
- if (subcommandName === 'wp' && subcommandArgs.length && !hasSeparator) {
374
+ if (subcommandName === 'wp' && hasWpCommandPayload && !hasSeparator) {
339
375
  exit.withError('A double dash ("--") must separate the arguments of "vip" from those of "wp". Run "vip wp --help" for examples.');
340
376
  }
341
377
  let childArgs = [...argsBeforeSubcommand, ...(hasSeparatorBeforeSubcommand ? ['--'] : []), ...subcommandArgs];
@@ -8,6 +8,8 @@ const internalBinLoaders = {
8
8
  vip: () => import('../../bin/vip'),
9
9
  'vip-app': () => import('../../bin/vip-app'),
10
10
  'vip-app-deploy': () => import('../../bin/vip-app-deploy'),
11
+ 'vip-app-deploy-token': () => import('../../bin/vip-app-deploy-token'),
12
+ 'vip-app-deploy-token-generate': () => import('../../bin/vip-app-deploy-token-generate'),
11
13
  'vip-app-deploy-validate': () => import('../../bin/vip-app-deploy-validate'),
12
14
  'vip-app-list': () => import('../../bin/vip-app-list'),
13
15
  'vip-backup': () => import('../../bin/vip-backup'),
@@ -4,12 +4,14 @@ exports.__esModule = true;
4
4
  exports.MULTIPART_THRESHOLD = exports.COMPRESS_THRESHOLD = void 0;
5
5
  exports.checkFileAccess = checkFileAccess;
6
6
  exports.detectCompressedMimeType = detectCompressedMimeType;
7
+ exports.fetchWithRetry = fetchWithRetry;
7
8
  exports.getFileHash = void 0;
8
9
  exports.getFileMeta = getFileMeta;
9
10
  exports.getFileSize = getFileSize;
10
11
  exports.getPartBoundaries = getPartBoundaries;
11
12
  exports.getSignedUploadRequestData = getSignedUploadRequestData;
12
13
  exports.isFile = isFile;
14
+ exports.parseEtagHeader = parseEtagHeader;
13
15
  exports.unzipFile = void 0;
14
16
  exports.uploadImportFileToS3 = uploadImportFileToS3;
15
17
  exports.uploadParts = uploadParts;
@@ -17,28 +19,66 @@ var _chalk = _interopRequireDefault(require("chalk"));
17
19
  var _crypto = require("crypto");
18
20
  var _debug = _interopRequireDefault(require("debug"));
19
21
  var _fs = require("fs");
20
- var _nodeFetch = _interopRequireDefault(require("node-fetch"));
21
22
  var _promises = require("node:fs/promises");
22
23
  var _promises2 = require("node:stream/promises");
24
+ var _nodeTimers = require("node:timers");
25
+ var _promises3 = require("node:timers/promises");
23
26
  var _os = _interopRequireDefault(require("os"));
24
27
  var _path = _interopRequireDefault(require("path"));
25
28
  var _stream = require("stream");
29
+ var _undici = require("undici");
26
30
  var _xml2js = require("xml2js");
27
31
  var _zlib = require("zlib");
28
32
  var _http = _interopRequireDefault(require("../lib/api/http"));
29
33
  var _fileSize = require("../lib/constants/file-size");
30
34
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
31
- // Need to use CommonJS imports here as the `fetch-retry` typedefs are messed up and throwing TypeJS errors when using `import`
32
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
33
- const fetchWithRetry =
34
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-require-imports
35
- require('fetch-retry')(_nodeFetch.default, {
36
- // Set default retry options
37
- retries: 3,
38
- retryDelay: attempt => {
39
- return Math.pow(2, attempt) * 1000; // 1000, 2000, 4000
35
+ function parseEtagHeader(etag) {
36
+ const normalizedEtag = etag.replace(/^W\//u, '').trim();
37
+ if (normalizedEtag.startsWith('"') && normalizedEtag.endsWith('"')) {
38
+ try {
39
+ return JSON.parse(normalizedEtag);
40
+ } catch (err) {
41
+ debug(`Unable to JSON.parse ETag header, falling back to raw value: ${err.message}`);
42
+ }
43
+ }
44
+ return normalizedEtag.replace(/^"(.*)"$/u, '$1');
45
+ }
46
+ /**
47
+ * Wraps `fetch` with exponential-backoff retries.
48
+ *
49
+ * A request body that is a Node stream can only be consumed once. If the body
50
+ * is a stream and we retried with the same `init`, the second attempt would
51
+ * pass an already-consumed (disturbed/locked) stream to `fetch`, which throws
52
+ * the misleading `Response body object should not be disturbed or locked` error
53
+ * and masks the original network failure.
54
+ *
55
+ * To retry safely, callers that send a stream body should pass `createBody`,
56
+ * which is invoked to produce a fresh body for every attempt. If a stream body
57
+ * is supplied without a `createBody` factory, the request is attempted only
58
+ * once so the underlying error surfaces instead of the misleading undici one.
59
+ */
60
+ async function fetchWithRetry(input, init = {}, retries = 3, createBody) {
61
+ const bodyIsStream = typeof init.body?.pipe === 'function';
62
+ // Only retry when we can hand `fetch` a fresh, replayable body each attempt.
63
+ const maxAttempts = createBody || !bodyIsStream ? retries : 0;
64
+ for (let attempt = 0; attempt <= maxAttempts; attempt++) {
65
+ const requestInit = createBody ? {
66
+ ...init,
67
+ body: createBody()
68
+ } : init;
69
+ try {
70
+ // eslint-disable-next-line no-await-in-loop
71
+ return await (0, _undici.fetch)(input, requestInit);
72
+ } catch (err) {
73
+ if (attempt === maxAttempts) {
74
+ throw err;
75
+ }
76
+ // eslint-disable-next-line no-await-in-loop
77
+ await (0, _promises3.setTimeout)(Math.pow(2, attempt) * 1000); // 1000, 2000, 4000
78
+ }
40
79
  }
41
- });
80
+ throw new Error('unreachable');
81
+ }
42
82
  const debug = (0, _debug.default)('vip:lib/client-file-uploader');
43
83
 
44
84
  // Files smaller than COMPRESS_THRESHOLD will not be compressed before upload
@@ -203,20 +243,28 @@ async function uploadUsingPutObject({
203
243
  ...fetchOptions.headers,
204
244
  'Content-Length': `${fileSize}` // This has to be a string
205
245
  };
206
- let readBytes = 0;
207
- const progressPassThrough = new _stream.PassThrough();
208
- progressPassThrough.on('data', data => {
209
- readBytes += data.length;
210
- const percentage = `${Math.floor(100 * readBytes / fileSize)}%`;
211
- debug(percentage);
212
- if (typeof progressCallback === 'function') {
213
- progressCallback(percentage);
246
+
247
+ // Build a fresh request body for every attempt. The upload streams the file
248
+ // from disk, and a stream can only be consumed once; recreating it per
249
+ // attempt lets `fetchWithRetry` retry without reusing a consumed stream
250
+ // (which would otherwise throw `... should not be disturbed or locked`).
251
+ const createBody = () => {
252
+ if (fileContent) {
253
+ return fileContent;
214
254
  }
215
- });
216
- const response = await fetchWithRetry(presignedRequest.url, {
217
- ...fetchOptions,
218
- body: fileContent ?? (0, _fs.createReadStream)(fileName).pipe(progressPassThrough)
219
- });
255
+ let readBytes = 0;
256
+ const progressPassThrough = new _stream.PassThrough();
257
+ progressPassThrough.on('data', data => {
258
+ readBytes += data.length;
259
+ const percentage = `${Math.floor(100 * readBytes / fileSize)}%`;
260
+ debug(percentage);
261
+ if (typeof progressCallback === 'function') {
262
+ progressCallback(percentage);
263
+ }
264
+ });
265
+ return (0, _fs.createReadStream)(fileName).pipe(progressPassThrough);
266
+ };
267
+ const response = await fetchWithRetry(presignedRequest.url, fetchOptions, 3, createBody);
220
268
  if (response.status === 200) {
221
269
  return 'ok';
222
270
  }
@@ -411,18 +459,19 @@ async function uploadParts({
411
459
  progressCallback
412
460
  }) {
413
461
  let uploadsInProgress = 0;
414
- let totalBytesRead = 0;
415
462
  const partPercentages = new Array(parts.length).fill(0);
463
+ const partBytesRead = new Array(parts.length).fill(0);
416
464
  const readyForPartUpload = () => new Promise(resolve => {
417
- const canDoInterval = setInterval(() => {
465
+ const canDoInterval = (0, _nodeTimers.setInterval)(() => {
418
466
  if (uploadsInProgress < MAX_CONCURRENT_PART_UPLOADS) {
419
467
  uploadsInProgress++;
420
- clearInterval(canDoInterval);
468
+ (0, _nodeTimers.clearInterval)(canDoInterval);
421
469
  resolve();
422
470
  }
423
471
  }, 300);
424
472
  });
425
473
  const updateProgress = () => {
474
+ const totalBytesRead = partBytesRead.reduce((sum, bytes) => sum + bytes, 0);
426
475
  const percentage = `${Math.floor(100 * totalBytesRead / fileMeta.fileSize)}%`;
427
476
  if (typeof progressCallback === 'function') {
428
477
  progressCallback(percentage);
@@ -434,32 +483,46 @@ async function uploadParts({
434
483
  return `Part # ${index}: ${partPercentage}% of ${(partSize / _fileSize.MB_IN_BYTES).toFixed(2)}MB`;
435
484
  }).join('\n') + `\n\nOverall Progress: ${percentage}% of ${(fileMeta.fileSize / _fileSize.MB_IN_BYTES).toFixed(2)}MB`);
436
485
  };
437
- const updateProgressInterval = setInterval(updateProgress, 500);
486
+ const updateProgressInterval = (0, _nodeTimers.setInterval)(updateProgress, 500);
438
487
  const allDone = await Promise.all(parts.map(async part => {
439
488
  const {
440
489
  index,
441
- partSize
490
+ partSize,
491
+ start,
492
+ end
442
493
  } = part;
443
- const progressPassThrough = new _stream.PassThrough();
444
- let partBytesRead = 0;
445
- progressPassThrough.on('data', data => {
446
- totalBytesRead += data.length;
447
- partBytesRead += data.length;
448
- partPercentages[index] = Math.floor(100 * partBytesRead / partSize);
449
- });
494
+
495
+ // Build a fresh request body for every attempt. A stream can only be
496
+ // consumed once, so recreating the ranged read stream (and its progress
497
+ // PassThrough) per attempt lets `fetchWithRetry` retry without reusing a
498
+ // disturbed/locked stream. Reset this part's progress counters so a retry
499
+ // re-streams the part without double-counting bytes.
500
+ const createBody = () => {
501
+ partBytesRead[index] = 0;
502
+ partPercentages[index] = 0;
503
+ const progressPassThrough = new _stream.PassThrough();
504
+ progressPassThrough.on('data', data => {
505
+ partBytesRead[index] += data.length;
506
+ partPercentages[index] = Math.floor(100 * partBytesRead[index] / partSize);
507
+ });
508
+ return (0, _fs.createReadStream)(fileMeta.fileName, {
509
+ start,
510
+ end
511
+ }).pipe(progressPassThrough);
512
+ };
450
513
  await readyForPartUpload();
451
514
  const uploadResult = await uploadPart({
452
515
  app,
453
516
  env,
454
517
  fileMeta,
455
518
  part,
456
- progressPassThrough,
519
+ createBody,
457
520
  uploadId
458
521
  });
459
522
  uploadsInProgress--;
460
523
  return uploadResult;
461
524
  }));
462
- clearInterval(updateProgressInterval);
525
+ (0, _nodeTimers.clearInterval)(updateProgressInterval);
463
526
  updateProgress();
464
527
  return allDone;
465
528
  }
@@ -467,18 +530,15 @@ async function uploadPart({
467
530
  app,
468
531
  env,
469
532
  fileMeta: {
470
- basename,
471
- fileName
533
+ basename
472
534
  },
473
535
  part,
474
- progressPassThrough,
536
+ createBody,
475
537
  uploadId
476
538
  }) {
477
539
  const {
478
- end,
479
540
  index,
480
- partSize,
481
- start
541
+ partSize
482
542
  } = part;
483
543
  const s3PartNumber = index + 1; // S3 multipart is indexed from 1
484
544
 
@@ -504,15 +564,13 @@ async function uploadPart({
504
564
  * ...so it may not be feasible to include with presigned requests.
505
565
  */
506
566
  };
507
- fetchOptions.body = (0, _fs.createReadStream)(fileName, {
508
- start,
509
- end
510
- }).pipe(progressPassThrough);
511
- const fetchResponse = await fetchWithRetry(partUploadRequestData.url, fetchOptions);
567
+ const fetchResponse = await fetchWithRetry(partUploadRequestData.url, fetchOptions, 3, createBody);
512
568
  if (fetchResponse.status === 200) {
513
- const responseHeaders = fetchResponse.headers.raw();
514
- const [etag] = responseHeaders.etag;
515
- return JSON.parse(etag);
569
+ const etag = fetchResponse.headers.get('etag');
570
+ if (!etag) {
571
+ throw new Error('Unable to upload file part. Missing ETag response header.');
572
+ }
573
+ return parseEtagHeader(etag);
516
574
  }
517
575
  const result = await fetchResponse.text();
518
576