Compare commits

..

22 Commits

Author SHA1 Message Date
Steven
621b53bc49 Publish Stable
- @vercel/build-utils@5.0.0
 - vercel@26.0.0
 - @vercel/client@12.0.4
 - @vercel/fs-detectors@1.0.0
 - @vercel/go@2.0.4
 - @vercel/next@3.1.3
 - @vercel/node@2.4.0
 - @vercel/python@3.0.4
 - @vercel/redwood@1.0.5
 - @vercel/remix@1.0.5
 - @vercel/ruby@1.3.12
 - @vercel/static-build@1.0.4
2022-07-05 08:47:49 -04:00
JJ Kasper
728b620355 Update trailing slash handling for _next/data resolving (#8080) 2022-07-04 09:52:08 -05:00
Luc Leray
7d16395038 [build-utils] Stricter getNodeBinPath return type (#8082)
In https://github.com/vercel/vercel/pull/8058, I made `getNodeBinPath` return type too broad. The function can never return `undefined` so we can make it more strict.
2022-07-04 13:22:35 +00:00
Lee Robinson
59e1259688 [examples] Remove stray console.log. (#8070) 2022-07-01 22:47:36 +00:00
Nathan Rajlich
169242157e [cli] Fix Middleware "matchers" with query string in vc dev (#8069)
`matchers` config in Middleware was not being properly… matched… in `vc dev` when a query string was present.
2022-07-01 20:38:02 +00:00
Steven
db10ffd679 [build-utils][next][redwood][remix][static-build] Fix corepack path prepend (#8065)
This PR fixes a bug where corepack (#7871) was not correctly setup because the lockfile autodetection and node version autodetection was overriding the PATH.

It also fixes a bug where the log output was printed twice because we incorrectly prepended the PATH twice.
2022-07-01 19:33:21 +00:00
Steven
c0d0744c4e [tests] Add yarn.lock to saber test fixture (#8068)
The Saber example had a lock file but the test did not.

This PR copies the lock file since the latest version of Saber doesn't work with the latest Vue.

```
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './package' is not defined by "exports" in /vercel/path0/node_modules/vue/package.json
  at new NodeError (node:internal/errors:372:5)
  at throwExportsNotFound (node:internal/modules/esm/resolve:472:9)
  at packageExportsResolve (node:internal/modules/esm/resolve:753:3)
  at resolveExports (node:internal/modules/cjs/loader:482:36)
  at Function.Module._findPath (node:internal/modules/cjs/loader:522:31)
  at Function.Module._resolveFilename (node:internal/modules/cjs/loader:919:27)
  at Function.resolve (node:internal/modules/cjs/helpers:108:19)
  at module.exports (/vercel/path0/node_modules/saber/dist/webpack/webpack.config.js:30:58)
  at Saber.getWebpackConfig (/vercel/path0/node_modules/saber/dist/index.js:195:58)
  at VueRenderer.build (/vercel/path0/node_modules/saber/dist/vue-renderer/index.js:232:39)
```
2022-07-01 19:04:44 +00:00
Steven
9da67423a5 [node] Fix public TypeScript types (#8064)
There was a regression some time between [1.12.2-canary.6](https://unpkg.com/browse/@vercel/node@1.12.2-canary.6/dist/index.d.ts) and [1.12.2-canary.7](https://unpkg.com/browse/@vercel/node@1.12.2-canary.7/dist/index.d.ts) where the `index.d.ts` types changed.

This PR reverts back to the old behavior so users can properly use

```ts
import { VercelRequest, VercelResponse } from '@vercel/node';
```

The existing test was also changed to use the result of the build rather than copy at the beginning

cfae7ec3c2/packages/node/test/fixtures/15-helpers/ts/index.ts (L1)

- Fixes https://github.com/vercel/vercel/issues/7951
2022-06-30 22:45:49 +00:00
Steven
51fe09d5e9 [build-utils][fs-detectors][cli] MAJOR: move some of build-utils into new fs-detectors (#8054)
The `@vercel/build-utils` package was meant be shared functions necessary for writing a Vercel Builder (aka Runtime).

This package has since bloated into the catch-all package for anything that wasn't a Builder.

This PR removes the bloat in favor of a new package, `@vercel/fs-detectors`. It also removes the need for `@vercel/build-utils` to have a dependency on `@vercel/frameworks`.

- Related to #7951
2022-06-30 21:14:07 +00:00
Nathan Rajlich
695bfbdd60 [cli] Add full stdio mockability for unit tests (#8052)
This PR is a follow-up to #8039, which provides an intuitive syntax for writing unit tests for interactive CLI commands.

The heart of this is the new `await expect(stream).toOutput(test)` custom Jest matcher, which is intended for use with the mock Client `stdout` and `stderr` stream properties. The `test` is a string that will wait for the stream to output via "data" events until a match is found, or it will timeout (after 3 seconds by default). The timeout error has nice Jest-style formatting so that you can easily identify what was output:

<img width="553" alt="Screen Shot 2022-06-29 at 10 33 06 PM" src="https://user-images.githubusercontent.com/71256/176600324-cb1ebecb-e891-42d9-bdc9-4864d3594a8c.png">

Below is an example of a unit test that was added for an interactive `vc login` session:

```typescript
it('should allow login via email', async () => {
  const user = useUser();

  const exitCodePromise = login(client);

  // Wait for login interactive prompt
  await expect(client.stderr).toOutput(`> Log in to Vercel`);

  // Move down to "Email" option
  client.stdin.write('\x1B[B'); // Down arrow
  client.stdin.write('\x1B[B'); // Down arrow
  client.stdin.write('\x1B[B'); // Down arrow
  client.stdin.write('\r'); // Return key

  // Wait for email input prompt
  await expect(client.stderr).toOutput('> Enter your email address:');

  // Write user email address into prompt
  client.stdin.write(`${user.email}\n`);

  // Wait for login success message
  await expect(client.stderr).toOutput(
    `Success! Email authentication complete for ${user.email}`
  );

  // Assert that the `login()` command returned 0 exit code
  await expect(exitCodePromise).resolves.toEqual(0);
});
```

**Note:**  as a consequence of this PR, prompts are now written to stderr instead of stdout, so this change _may_ be considered a breaking change, in which case we should tag it for major release.
2022-06-30 20:17:22 +00:00
Nathan Rajlich
547e88228e Publish Stable
- @vercel/build-utils@4.2.1
 - vercel@25.2.3
 - @vercel/client@12.0.3
 - @vercel/go@2.0.3
 - @vercel/next@3.1.2
 - @vercel/node@2.3.3
 - @vercel/python@3.0.3
 - @vercel/redwood@1.0.4
 - @vercel/remix@1.0.4
 - @vercel/ruby@1.3.11
 - @vercel/static-build@1.0.3
2022-06-30 12:24:13 -07:00
Luc Leray
9bfb5dd535 [build-utils] Handle npm bin exit code 7 (#8058)
In some rare cases, `npm bin` exits with code 7, but still outputs the right bin path.

To reproduce, try:
```
npm init -y
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
vc
# enter "echo build" for the build command, leave the other configuration as default
```

The build will fail with `Error: Command exited with 7` because `npm bin` fails with code 7, for some reason.

In this PR, we do 2 things:
(1) Ignore exit codes from `npm bin`. It still outputs the right path when it exits with code 7 so we just read the output and check if it's a valid path.
(2) Throw a more specific error message when `npm bin` fails to give us the bin path. The current error was hard to debug because it looked like it was coming from the install commmand. We can do better by emitting a custom error.

Alternative considered for (2): Do not throw errors. If `npm bin` fails, emit a warning and let the build continue.

Related Issues:
- https://github.com/vercel/customer-issues/issues/585 (internal)
2022-06-30 17:27:52 +00:00
Nathan Rajlich
81ea84fae8 [cli] Fix vc build lambda serialization when there's a broken symlink (#8050)
There's some cleanup directory walking logic that was choking when
a Lambda outputs a file with a broken symlink. We shouldn't need to
traverse into those directories in the case of a symlink anyways, so use
`lstat()` instead of `stat()` to prevent that filesystem call from
throwing an error.
2022-06-29 16:04:31 -07:00
Nathan Rajlich
fa8bf07be4 [cli] Add Client#stdin / Client#stdout (#8039)
This will allow for mockability of the input streams (i.e. prompts)
for CLI commands in unit tests.

**Example:**

```typescript
import confirm from '../../src/util/input/confirm';
import { client } from '../mocks/client';

describe('MockClient', () => {
  it('should mock `confirm()`', async () => {
    const confirmedPromise = confirm(client, 'Do the thing?', false);

    client.stdin.write('yes\n');

    const confirmed = await confirmedPromise;
    expect(confirmed).toEqual(true);
  });
});
```
2022-06-29 16:03:56 -07:00
JJ Kasper
cc9dce73ad [next] Ensure uncompressed limit is correct (#8049)
* Revert "Revert "[next] Update max size warning to handle initial layer better" (#8047)"

This reverts commit 8c62de16ce.

* Ensure uncompressed limit is correct

* apply suggestion
2022-06-29 15:13:15 -05:00
Sean Massa
bba7cbd411 [cli][dev] fix: creating "api/some-func.js" after "vc dev" now works (#8041)
If there is no `api` directory, then you run `vc dev`, then you create a new function `api/some-func.js`, then this file would not be served as a new function.

This was being caused by incomplete "new file" handling logic. This PR ensures that the proper detection is done in each new file (`getVercelConfig`) that populates key properties (`apiDir`, `apiExtensions`, and extensionless `files`) for determining when a file is used to serve a request.
2022-06-29 18:37:48 +00:00
Steven
9a3739bebd Publish Stable
- vercel@25.2.2
 - @vercel/next@3.1.1
 - @vercel/node@2.3.2
 - @vercel/redwood@1.0.3
 - @vercel/remix@1.0.3
2022-06-29 09:27:34 -04:00
Gal Schlezinger
8c62de16ce Revert "[next] Update max size warning to handle initial layer better" (#8047)
Revert "[next] Update max size warning to handle initial layer better (#8013)"

This reverts commit f20703b15d.
2022-06-29 09:26:29 -04:00
Steven
e9333988d7 [next][node][redwood][remix] Bump @vercel/nft to 0.20.1 (#8042)
- https://github.com/vercel/nft/releases/tag/0.20.0
- https://github.com/vercel/nft/releases/tag/0.20.1
2022-06-29 00:21:14 +00:00
Nathan Rajlich
fb001ce7eb [tests] Remove TODO comments from Middleware matchers vc dev test (#8037) 2022-06-28 18:26:56 +00:00
Sean Massa
b399fe7037 Publish Stable
- vercel@25.2.1
 - @vercel/node@2.3.1
2022-06-28 12:04:57 -05:00
Sean Massa
88385b3c84 [cli][node] switch to esbuild for compiling edge functions (#8032) 2022-06-28 11:27:56 -05:00
249 changed files with 17181 additions and 988 deletions

View File

@@ -24,7 +24,6 @@ export function sendToVercelAnalytics(metric) {
speed: getConnectionSpeed(), speed: getConnectionSpeed(),
}; };
console.log({ body });
const blob = new Blob([new URLSearchParams(body).toString()], { const blob = new Blob([new URLSearchParams(body).toString()], {
// This content type is necessary for `sendBeacon` // This content type is necessary for `sendBeacon`
type: 'application/x-www-form-urlencoded', type: 'application/x-www-form-urlencoded',

View File

@@ -1,6 +1,6 @@
{ {
"name": "@vercel/build-utils", "name": "@vercel/build-utils",
"version": "4.2.0", "version": "5.0.0",
"license": "MIT", "license": "MIT",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.js", "types": "./dist/index.d.js",
@@ -31,7 +31,6 @@
"@types/node-fetch": "^2.1.6", "@types/node-fetch": "^2.1.6",
"@types/semver": "6.0.0", "@types/semver": "6.0.0",
"@types/yazl": "2.4.2", "@types/yazl": "2.4.2",
"@vercel/frameworks": "1.0.2",
"@vercel/ncc": "0.24.0", "@vercel/ncc": "0.24.0",
"aggregate-error": "3.0.1", "aggregate-error": "3.0.1",
"async-retry": "1.2.3", "async-retry": "1.2.3",

View File

@@ -61,6 +61,13 @@ export interface SpawnOptionsExtended extends SpawnOptions {
* Pretty formatted command that is being spawned for logging purposes. * Pretty formatted command that is being spawned for logging purposes.
*/ */
prettyCommand?: string; prettyCommand?: string;
/**
* Returns instead of throwing an error when the process exits with a
* non-0 exit code. When relevant, the returned object will include
* the error code, stdout and stderr.
*/
ignoreNon0Exit?: boolean;
} }
export function spawnAsync( export function spawnAsync(
@@ -79,7 +86,7 @@ export function spawnAsync(
child.on('error', reject); child.on('error', reject);
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
if (code === 0) { if (code === 0 || opts.ignoreNon0Exit) {
return resolve(); return resolve();
} }
@@ -123,24 +130,24 @@ export function execAsync(
child.on('error', reject); child.on('error', reject);
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
if (code !== 0) { if (code === 0 || opts.ignoreNon0Exit) {
const cmd = opts.prettyCommand return resolve({
? `Command "${opts.prettyCommand}"` code,
: 'Command'; stdout: Buffer.concat(stdoutList).toString(),
stderr: Buffer.concat(stderrList).toString(),
return reject( });
new NowBuildError({
code: `BUILD_UTILS_EXEC_${code || signal}`,
message: `${cmd} exited with ${code || signal}`,
})
);
} }
return resolve({ const cmd = opts.prettyCommand
code, ? `Command "${opts.prettyCommand}"`
stdout: Buffer.concat(stdoutList).toString(), : 'Command';
stderr: Buffer.concat(stderrList).toString(),
}); return reject(
new NowBuildError({
code: `BUILD_UTILS_EXEC_${code || signal}`,
message: `${cmd} exited with ${code || signal}`,
})
);
}); });
} }
); );
@@ -166,9 +173,30 @@ export async function execCommand(command: string, options: SpawnOptions = {}) {
return true; return true;
} }
export async function getNodeBinPath({ cwd }: { cwd: string }) { export async function getNodeBinPath({
const { stdout } = await execAsync('npm', ['bin'], { cwd }); cwd,
return stdout.trim(); }: {
cwd: string;
}): Promise<string> {
const { code, stdout, stderr } = await execAsync('npm', ['bin'], {
cwd,
prettyCommand: 'npm bin',
// in some rare cases, we saw `npm bin` exit with a non-0 code, but still
// output the right bin path, so we ignore the exit code
ignoreNon0Exit: true,
});
const nodeBinPath = stdout.trim();
if (path.isAbsolute(nodeBinPath)) {
return nodeBinPath;
}
throw new NowBuildError({
code: `BUILD_UTILS_GET_NODE_BIN_PATH`,
message: `Running \`npm bin\` failed to return a valid bin path (code=${code}, stdout=${stdout}, stderr=${stderr})`,
});
} }
async function chmodPlusX(fsPath: string) { async function chmodPlusX(fsPath: string) {
@@ -205,10 +233,23 @@ export function getSpawnOptions(
}; };
if (!meta.isDev) { if (!meta.isDev) {
// Ensure that the selected Node version is at the beginning of the `$PATH` let found = false;
opts.env.PATH = `/node${nodeVersion.major}/bin${path.delimiter}${ const oldPath = opts.env.PATH || process.env.PATH || '';
opts.env.PATH || process.env.PATH
}`; const pathSegments = oldPath.split(path.delimiter).map(segment => {
if (/^\/node[0-9]+\/bin/.test(segment)) {
found = true;
return `/node${nodeVersion.major}/bin`;
}
return segment;
});
if (!found) {
// If we didn't find & replace, prepend at beginning of PATH
pathSegments.unshift(`/node${nodeVersion.major}/bin`);
}
opts.env.PATH = pathSegments.filter(Boolean).join(path.delimiter);
} }
return opts; return opts;
@@ -446,20 +487,31 @@ export function getEnvForPackageManager({
env: { [x: string]: string | undefined }; env: { [x: string]: string | undefined };
}) { }) {
const newEnv: { [x: string]: string | undefined } = { ...env }; const newEnv: { [x: string]: string | undefined } = { ...env };
const oldPath = env.PATH + '';
const npm7 = '/node16/bin-npm7';
const pnpm7 = '/pnpm7/node_modules/.bin';
const corepackEnabled = env.ENABLE_EXPERIMENTAL_COREPACK === '1';
if (cliType === 'npm') { if (cliType === 'npm') {
if ( if (
typeof lockfileVersion === 'number' && typeof lockfileVersion === 'number' &&
lockfileVersion >= 2 && lockfileVersion >= 2 &&
(nodeVersion?.major || 0) < 16 (nodeVersion?.major || 0) < 16 &&
!oldPath.includes(npm7) &&
!corepackEnabled
) { ) {
// Ensure that npm 7 is at the beginning of the `$PATH` // Ensure that npm 7 is at the beginning of the `$PATH`
newEnv.PATH = `/node16/bin-npm7${path.delimiter}${env.PATH}`; newEnv.PATH = `${npm7}${path.delimiter}${oldPath}`;
console.log('Detected `package-lock.json` generated by npm 7...'); console.log('Detected `package-lock.json` generated by npm 7+...');
} }
} else if (cliType === 'pnpm') { } else if (cliType === 'pnpm') {
if (typeof lockfileVersion === 'number' && lockfileVersion === 5.4) { if (
typeof lockfileVersion === 'number' &&
lockfileVersion === 5.4 &&
!oldPath.includes(pnpm7) &&
!corepackEnabled
) {
// Ensure that pnpm 7 is at the beginning of the `$PATH` // Ensure that pnpm 7 is at the beginning of the `$PATH`
newEnv.PATH = `/pnpm7/node_modules/.bin${path.delimiter}${env.PATH}`; newEnv.PATH = `${pnpm7}${path.delimiter}${oldPath}`;
console.log('Detected `pnpm-lock.yaml` generated by pnpm 7...'); console.log('Detected `pnpm-lock.yaml` generated by pnpm 7...');
} }
} else { } else {

View File

@@ -80,16 +80,6 @@ export {
}; };
export { EdgeFunction } from './edge-function'; export { EdgeFunction } from './edge-function';
export {
detectBuilders,
detectOutputDirectory,
detectApiDirectory,
detectApiExtensions,
} from './detect-builders';
export { detectFileSystemAPI } from './detect-file-system-api';
export { detectFramework } from './detect-framework';
export { getProjectPaths } from './get-project-paths';
export { DetectorFilesystem } from './detectors/filesystem';
export { readConfigFile } from './fs/read-config-file'; export { readConfigFile } from './fs/read-config-file';
export { normalizePath } from './fs/normalize-path'; export { normalizePath } from './fs/normalize-path';
@@ -97,35 +87,3 @@ export * from './should-serve';
export * from './schemas'; export * from './schemas';
export * from './types'; export * from './types';
export * from './errors'; export * from './errors';
/**
* Helper function to support both `@vercel` and legacy `@now` official Runtimes.
*/
export const isOfficialRuntime = (desired: string, name?: string): boolean => {
if (typeof name !== 'string') {
return false;
}
return (
name === `@vercel/${desired}` ||
name === `@now/${desired}` ||
name.startsWith(`@vercel/${desired}@`) ||
name.startsWith(`@now/${desired}@`)
);
};
export const isStaticRuntime = (name?: string): boolean => {
return isOfficialRuntime('static', name);
};
export { workspaceManagers } from './workspaces/workspace-managers';
export {
getWorkspaces,
GetWorkspaceOptions,
Workspace,
WorkspaceType,
} from './workspaces/get-workspaces';
export {
getWorkspacePackagePaths,
GetWorkspacePackagePathsOptions,
} from './workspaces/get-workspace-package-paths';
export { monorepoManagers } from './monorepos/monorepo-managers';

View File

@@ -1,10 +0,0 @@
{
"functions": {
"api/users.rb": {
"memory": 3008
},
"api/doesnt-exist.rb": {
"memory": 768
}
}
}

View File

@@ -1 +0,0 @@
# project/[aid]/[bid]/index.py

View File

@@ -1 +0,0 @@
This file should also be included

View File

@@ -1,7 +0,0 @@
{
"functions": {
"api/users/post.py": {
"memory": 3008
}
}
}

View File

@@ -5,7 +5,6 @@ import {
testDeployment, testDeployment,
// @ts-ignore // @ts-ignore
} from '../../../test/lib/deployment/test-deployment'; } from '../../../test/lib/deployment/test-deployment';
import { glob, detectBuilders } from '../src';
jest.setTimeout(4 * 60 * 1000); jest.setTimeout(4 * 60 * 1000);
@@ -32,11 +31,6 @@ const skipFixtures: string[] = [
'08-zero-config-middleman', '08-zero-config-middleman',
'21-npm-workspaces', '21-npm-workspaces',
'23-pnpm-workspaces', '23-pnpm-workspaces',
'27-yarn-workspaces',
'28-turborepo-with-yarn-workspaces',
'29-nested-workspaces',
'30-double-nested-workspaces',
'31-turborepo-in-package-json',
]; ];
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
@@ -83,145 +77,3 @@ for (const builder of buildersToTestWith) {
} }
} }
} }
it('Test `detectBuilders` and `detectRoutes`', async () => {
const fixture = path.join(__dirname, 'fixtures', '01-zero-config-api');
const pkg = await fs.readJSON(path.join(fixture, 'package.json'));
const fileList = await glob('**', fixture);
const files = Object.keys(fileList);
const probes = [
{
path: '/api/my-endpoint',
mustContain: 'my-endpoint',
status: 200,
},
{
path: '/api/other-endpoint',
mustContain: 'other-endpoint',
status: 200,
},
{
path: '/api/team/zeit',
mustContain: 'team/zeit',
status: 200,
},
{
path: '/api/user/myself',
mustContain: 'user/myself',
status: 200,
},
{
path: '/api/not-okay/',
status: 404,
},
{
path: '/api',
status: 404,
},
{
path: '/api/',
status: 404,
},
{
path: '/',
mustContain: 'hello from index.txt',
},
];
const { builders, defaultRoutes } = await detectBuilders(files, pkg);
const nowConfig = { builds: builders, routes: defaultRoutes, probes };
await fs.writeFile(
path.join(fixture, 'now.json'),
JSON.stringify(nowConfig, null, 2)
);
const deployment = await testDeployment(
{ builderUrl, buildUtilsUrl },
fixture
);
expect(deployment).toBeDefined();
});
it('Test `detectBuilders` with `index` files', async () => {
const fixture = path.join(__dirname, 'fixtures', '02-zero-config-api');
const pkg = await fs.readJSON(path.join(fixture, 'package.json'));
const fileList = await glob('**', fixture);
const files = Object.keys(fileList);
const probes = [
{
path: '/api/not-okay',
status: 404,
},
{
path: '/api',
mustContain: 'hello from api/index.js',
status: 200,
},
{
path: '/api/',
mustContain: 'hello from api/index.js',
status: 200,
},
{
path: '/api/index',
mustContain: 'hello from api/index.js',
status: 200,
},
{
path: '/api/index.js',
mustContain: 'hello from api/index.js',
status: 200,
},
{
path: '/api/date.js',
mustContain: 'hello from api/date.js',
status: 200,
},
{
// Someone might expect this to be `date.js`,
// but I doubt that there is any case were both
// `date/index.js` and `date.js` exists,
// so it is not special cased
path: '/api/date',
mustContain: 'hello from api/date/index.js',
status: 200,
},
{
path: '/api/date/',
mustContain: 'hello from api/date/index.js',
status: 200,
},
{
path: '/api/date/index',
mustContain: 'hello from api/date/index.js',
status: 200,
},
{
path: '/api/date/index.js',
mustContain: 'hello from api/date/index.js',
status: 200,
},
{
path: '/',
mustContain: 'hello from index.txt',
},
];
const { builders, defaultRoutes } = await detectBuilders(files, pkg);
const nowConfig = { builds: builders, routes: defaultRoutes, probes };
await fs.writeFile(
path.join(fixture, 'now.json'),
JSON.stringify(nowConfig, null, 2)
);
const deployment = await testDeployment(
{ builderUrl, buildUtilsUrl },
fixture
);
expect(deployment).toBeDefined();
});

View File

@@ -0,0 +1,29 @@
import { execAsync, NowBuildError } from '../src';
it('should execute a command', async () => {
const { code, stdout, stderr } = await execAsync('echo', ['hello']);
expect(code).toBe(0);
expect(stdout).toContain('hello');
expect(stderr).toBe('');
});
it('should throw if the command exits with non-0 code', async () => {
await expect(execAsync('find', ['unknown-file'])).rejects.toBeInstanceOf(
NowBuildError
);
});
it('should return if the command exits with non-0 code and ignoreNon0Exit=true', async () => {
const { code, stdout, stderr } = await execAsync('find', ['unknown-file'], {
ignoreNon0Exit: true,
});
expect(code).toBe(process.platform === 'win32' ? 2 : 1);
expect(stdout).toBe('');
expect(stderr).toContain(
process.platform === 'win32'
? 'Parameter format not correct'
: 'No such file or directory'
);
});

View File

@@ -38,6 +38,38 @@ describe('Test `getEnvForPackageManager()`', () => {
PATH: `/node16/bin-npm7${delimiter}foo`, PATH: `/node16/bin-npm7${delimiter}foo`,
}, },
}, },
{
name: 'should not set npm path if corepack enabled',
args: {
cliType: 'npm',
nodeVersion: { major: 14, range: '14.x', runtime: 'nodejs14.x' },
lockfileVersion: 2,
env: {
FOO: 'bar',
ENABLE_EXPERIMENTAL_COREPACK: '1',
},
},
want: {
FOO: 'bar',
ENABLE_EXPERIMENTAL_COREPACK: '1',
},
},
{
name: 'should not prepend npm path again if already detected',
args: {
cliType: 'npm',
nodeVersion: { major: 14, range: '14.x', runtime: 'nodejs14.x' },
lockfileVersion: 2,
env: {
FOO: 'bar',
PATH: `/node16/bin-npm7${delimiter}foo`,
},
},
want: {
FOO: 'bar',
PATH: `/node16/bin-npm7${delimiter}foo`,
},
},
{ {
name: 'should not set path if node is 16 and npm 7+ is detected', name: 'should not set path if node is 16 and npm 7+ is detected',
args: { args: {
@@ -101,6 +133,38 @@ describe('Test `getEnvForPackageManager()`', () => {
PATH: `/pnpm7/node_modules/.bin${delimiter}foo`, PATH: `/pnpm7/node_modules/.bin${delimiter}foo`,
}, },
}, },
{
name: 'should not set pnpm path if corepack is enabled',
args: {
cliType: 'pnpm',
nodeVersion: { major: 16, range: '16.x', runtime: 'nodejs16.x' },
lockfileVersion: 5.4,
env: {
FOO: 'bar',
ENABLE_EXPERIMENTAL_COREPACK: '1',
},
},
want: {
FOO: 'bar',
ENABLE_EXPERIMENTAL_COREPACK: '1',
},
},
{
name: 'should not prepend pnpm path again if already detected',
args: {
cliType: 'pnpm',
nodeVersion: { major: 16, range: '16.x', runtime: 'nodejs16.x' },
lockfileVersion: 5.4,
env: {
FOO: 'bar',
PATH: `/pnpm7/node_modules/.bin${delimiter}foo`,
},
},
want: {
FOO: 'bar',
PATH: `/pnpm7/node_modules/.bin${delimiter}foo`,
},
},
{ {
name: 'should not set path if pnpm 6 is detected', name: 'should not set path if pnpm 6 is detected',
args: { args: {

View File

@@ -0,0 +1,111 @@
import { delimiter } from 'path';
import { getSpawnOptions } from '../src';
describe('Test `getSpawnOptions()`', () => {
const origProcessEnvPath = process.env.PATH;
beforeEach(() => {
process.env.PATH = undefined;
});
afterEach(() => {
process.env.PATH = origProcessEnvPath;
});
const cases: Array<{
name: string;
args: Parameters<typeof getSpawnOptions>;
envPath: string | undefined;
want: string | undefined;
}> = [
{
name: 'should do nothing when isDev and node14',
args: [
{ isDev: true },
{ major: 14, range: '14.x', runtime: 'nodejs14.x' },
],
envPath: '/foo',
want: '/foo',
},
{
name: 'should do nothing when isDev and node16',
args: [
{ isDev: true },
{ major: 16, range: '16.x', runtime: 'nodejs16.x' },
],
envPath: '/foo',
want: '/foo',
},
{
name: 'should replace 14 with 16 when only path',
args: [
{ isDev: false },
{ major: 16, range: '16.x', runtime: 'nodejs16.x' },
],
envPath: '/node14/bin',
want: '/node16/bin',
},
{
name: 'should replace 14 with 16 at beginning',
args: [
{ isDev: false },
{ major: 16, range: '16.x', runtime: 'nodejs16.x' },
],
envPath: `/node14/bin${delimiter}/foo`,
want: `/node16/bin${delimiter}/foo`,
},
{
name: 'should replace 14 with 16 at end',
args: [
{ isDev: false },
{ major: 16, range: '16.x', runtime: 'nodejs16.x' },
],
envPath: `/foo${delimiter}/node14/bin`,
want: `/foo${delimiter}/node16/bin`,
},
{
name: 'should replace 14 with 16 in middle',
args: [
{ isDev: false },
{ major: 16, range: '16.x', runtime: 'nodejs16.x' },
],
envPath: `/foo${delimiter}/node14/bin${delimiter}/bar`,
want: `/foo${delimiter}/node16/bin${delimiter}/bar`,
},
{
name: 'should prepend 16 at beginning when nothing to replace',
args: [
{ isDev: false },
{ major: 16, range: '16.x', runtime: 'nodejs16.x' },
],
envPath: `/foo`,
want: `/node16/bin${delimiter}/foo`,
},
{
name: 'should prepend 16 at beginning no path input',
args: [
{ isDev: false },
{ major: 16, range: '16.x', runtime: 'nodejs16.x' },
],
envPath: '',
want: `/node16/bin`,
},
{
name: 'should replace 12 with 14 when only path',
args: [
{ isDev: false },
{ major: 14, range: '14.x', runtime: 'nodejs14.x' },
],
envPath: '/node12/bin',
want: '/node14/bin',
},
];
for (const { name, args, envPath, want } of cases) {
it(name, () => {
process.env.PATH = envPath;
const opts = getSpawnOptions(...args);
expect(opts.env?.PATH).toBe(want);
});
}
});

View File

@@ -0,0 +1,21 @@
import { spawnAsync, NowBuildError } from '../src';
it('should execute a command', async () => {
// should resolve (it doesn't return anything, so it resolves with "undefined")
await expect(spawnAsync('echo', ['hello'])).resolves.toBeUndefined();
});
it('should throw if the command exits with non-0 code', async () => {
await expect(spawnAsync('find', ['unknown-file'])).rejects.toBeInstanceOf(
NowBuildError
);
});
it('should return if the command exits with non-0 code and ignoreNon0Exit=true', async () => {
// should resolve (it doesn't return anything, so it resolves with "undefined")
await expect(
spawnAsync('find', ['unknown-file'], {
ignoreNon0Exit: true,
})
).resolves.toBeUndefined();
});

View File

@@ -18,6 +18,8 @@ import {
Meta, Meta,
} from '../src'; } from '../src';
jest.setTimeout(7 * 1000);
async function expectBuilderError(promise: Promise<any>, pattern: string) { async function expectBuilderError(promise: Promise<any>, pattern: string) {
let result; let result;
try { try {

View File

@@ -1,6 +1,6 @@
{ {
"name": "vercel", "name": "vercel",
"version": "25.2.0", "version": "26.0.0",
"preferGlobal": true, "preferGlobal": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "The command-line interface for Vercel", "description": "The command-line interface for Vercel",
@@ -42,15 +42,15 @@
"node": ">= 14" "node": ">= 14"
}, },
"dependencies": { "dependencies": {
"@vercel/build-utils": "4.2.0", "@vercel/build-utils": "5.0.0",
"@vercel/go": "2.0.2", "@vercel/go": "2.0.4",
"@vercel/next": "3.1.0", "@vercel/next": "3.1.3",
"@vercel/node": "2.3.0", "@vercel/node": "2.4.0",
"@vercel/python": "3.0.2", "@vercel/python": "3.0.4",
"@vercel/redwood": "1.0.2", "@vercel/redwood": "1.0.5",
"@vercel/remix": "1.0.2", "@vercel/remix": "1.0.5",
"@vercel/ruby": "1.3.10", "@vercel/ruby": "1.3.12",
"@vercel/static-build": "1.0.2", "@vercel/static-build": "1.0.4",
"update-notifier": "5.1.0" "update-notifier": "5.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -95,8 +95,9 @@
"@types/which": "1.3.2", "@types/which": "1.3.2",
"@types/write-json-file": "2.2.1", "@types/write-json-file": "2.2.1",
"@types/yauzl-promise": "2.1.0", "@types/yauzl-promise": "2.1.0",
"@vercel/client": "12.0.2", "@vercel/client": "12.0.4",
"@vercel/frameworks": "1.0.2", "@vercel/frameworks": "1.0.2",
"@vercel/fs-detectors": "1.0.0",
"@vercel/ncc": "0.24.0", "@vercel/ncc": "0.24.0",
"@zeit/fun": "0.11.2", "@zeit/fun": "0.11.2",
"@zeit/source-map-support": "0.6.2", "@zeit/source-map-support": "0.6.2",

View File

@@ -10,7 +10,6 @@ import confirm from '../../util/input/confirm';
import findAliasByAliasOrId from '../../util/alias/find-alias-by-alias-or-id'; import findAliasByAliasOrId from '../../util/alias/find-alias-by-alias-or-id';
import { Alias } from '../../types'; import { Alias } from '../../types';
import { Output } from '../../util/output';
import { isValidName } from '../../util/is-valid-name'; import { isValidName } from '../../util/is-valid-name';
import { getCommandName } from '../../util/pkg-name'; import { getCommandName } from '../../util/pkg-name';
@@ -71,7 +70,7 @@ export default async function rm(
} }
const removeStamp = stamp(); const removeStamp = stamp();
if (!opts['--yes'] && !(await confirmAliasRemove(output, alias))) { if (!opts['--yes'] && !(await confirmAliasRemove(client, alias))) {
output.log('Aborted'); output.log('Aborted');
return 0; return 0;
} }
@@ -85,7 +84,7 @@ export default async function rm(
return 0; return 0;
} }
async function confirmAliasRemove(output: Output, alias: Alias) { async function confirmAliasRemove(client: Client, alias: Alias) {
const srcUrl = alias.deployment const srcUrl = alias.deployment
? chalk.underline(alias.deployment.url) ? chalk.underline(alias.deployment.url)
: null; : null;
@@ -104,7 +103,7 @@ async function confirmAliasRemove(output: Output, alias: Alias) {
} }
); );
output.log(`The following alias will be removed permanently`); client.output.log(`The following alias will be removed permanently`);
output.print(` ${tbl}\n`); client.output.print(` ${tbl}\n`);
return confirm(chalk.red('Are you sure?'), false); return confirm(client, chalk.red('Are you sure?'), false);
} }

View File

@@ -127,7 +127,7 @@ export default async function ({ creditCards, clear = false, contextName }) {
} }
console.log(''); // New line console.log(''); // New line
const stopSpinner = wait('Saving card'); const stopSpinner = wait(process.stderr, 'Saving card');
try { try {
const res = await creditCards.add({ const res = await creditCards.add({

View File

@@ -174,7 +174,7 @@ export default async client => {
)} ${chalk.gray(`[${elapsed}]`)}`; )} ${chalk.gray(`[${elapsed}]`)}`;
const choices = buildInquirerChoices(cards); const choices = buildInquirerChoices(cards);
cardId = await listInput({ cardId = await listInput(client, {
message, message,
choices, choices,
separator: true, separator: true,
@@ -187,6 +187,7 @@ export default async client => {
if (cardId) { if (cardId) {
const label = `Are you sure that you to set this card as the default?`; const label = `Are you sure that you to set this card as the default?`;
const confirmation = await promptBool(label, { const confirmation = await promptBool(label, {
...client,
trailing: '\n', trailing: '\n',
}); });
@@ -250,7 +251,7 @@ export default async client => {
)} under ${chalk.bold(contextName)} ${chalk.gray(`[${elapsed}]`)}`; )} under ${chalk.bold(contextName)} ${chalk.gray(`[${elapsed}]`)}`;
const choices = buildInquirerChoices(cards); const choices = buildInquirerChoices(cards);
cardId = await listInput({ cardId = await listInput(client, {
message, message,
choices, choices,
separator: true, separator: true,
@@ -262,7 +263,7 @@ export default async client => {
// typed `vercel billing rm <some-id>`) is valid // typed `vercel billing rm <some-id>`) is valid
if (cardId) { if (cardId) {
const label = `Are you sure that you want to remove this card?`; const label = `Are you sure that you want to remove this card?`;
const confirmation = await promptBool(label); const confirmation = await promptBool(label, client);
if (!confirmation) { if (!confirmation) {
console.log('Aborted'); console.log('Aborted');
break; break;

View File

@@ -14,7 +14,6 @@ import logo from '../../util/output/logo';
import getArgs from '../../util/get-args'; import getArgs from '../../util/get-args';
import Client from '../../util/client'; import Client from '../../util/client';
import { getPkgName } from '../../util/pkg-name'; import { getPkgName } from '../../util/pkg-name';
import { Output } from '../../util/output';
import { Deployment, PaginationOptions } from '../../types'; import { Deployment, PaginationOptions } from '../../types';
import { normalizeURL } from '../../util/bisect/normalize-url'; import { normalizeURL } from '../../util/bisect/normalize-url';
@@ -86,10 +85,10 @@ export default async function main(client: Client): Promise<number> {
let bad = let bad =
argv['--bad'] || argv['--bad'] ||
(await prompt(output, `Specify a URL where the bug occurs:`)); (await prompt(client, `Specify a URL where the bug occurs:`));
let good = let good =
argv['--good'] || argv['--good'] ||
(await prompt(output, `Specify a URL where the bug does not occur:`)); (await prompt(client, `Specify a URL where the bug does not occur:`));
let subpath = argv['--path'] || ''; let subpath = argv['--path'] || '';
let run = argv['--run'] || ''; let run = argv['--run'] || '';
const openEnabled = argv['--open'] || false; const openEnabled = argv['--open'] || false;
@@ -143,7 +142,7 @@ export default async function main(client: Client): Promise<number> {
if (!subpath) { if (!subpath) {
subpath = await prompt( subpath = await prompt(
output, client,
`Specify the URL subpath where the bug occurs:` `Specify the URL subpath where the bug occurs:`
); );
} }
@@ -391,10 +390,10 @@ function getCommit(deployment: DeploymentV6) {
return { sha, message }; return { sha, message };
} }
async function prompt(output: Output, message: string): Promise<string> { async function prompt(client: Client, message: string): Promise<string> {
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
const { val } = await inquirer.prompt({ const { val } = await client.prompt({
type: 'input', type: 'input',
name: 'val', name: 'val',
message, message,
@@ -402,7 +401,7 @@ async function prompt(output: Output, message: string): Promise<string> {
if (val) { if (val) {
return val; return val;
} else { } else {
output.error('A value must be specified'); client.output.error('A value must be specified');
} }
} }
} }

View File

@@ -3,7 +3,6 @@ import chalk from 'chalk';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { join, normalize, relative, resolve } from 'path'; import { join, normalize, relative, resolve } from 'path';
import { import {
detectBuilders,
normalizePath, normalizePath,
Files, Files,
FileFsRef, FileFsRef,
@@ -17,6 +16,7 @@ import {
BuildResultV3, BuildResultV3,
NowBuildError, NowBuildError,
} from '@vercel/build-utils'; } from '@vercel/build-utils';
import { detectBuilders } from '@vercel/fs-detectors';
import minimatch from 'minimatch'; import minimatch from 'minimatch';
import { import {
appendRoutesToPhase, appendRoutesToPhase,
@@ -140,6 +140,7 @@ export default async function main(client: Client): Promise<number> {
} }
confirmed = await confirm( confirmed = await confirm(
client,
`No Project Settings found locally. Run ${cli.getCommandName( `No Project Settings found locally. Run ${cli.getCommandName(
'pull' 'pull'
)} for retrieving them?`, )} for retrieving them?`,

View File

@@ -160,12 +160,12 @@ export default async (client: Client) => {
} }
} }
const { log, debug, error, prettyError, isTTY } = output; const { log, debug, error, prettyError } = output;
const quiet = !isTTY; const quiet = !client.stdout.isTTY;
// check paths // check paths
const pathValidation = await validatePaths(output, paths); const pathValidation = await validatePaths(client, paths);
if (!pathValidation.valid) { if (!pathValidation.valid) {
return pathValidation.exitCode; return pathValidation.exitCode;
@@ -243,6 +243,7 @@ export default async (client: Client) => {
const shouldStartSetup = const shouldStartSetup =
autoConfirm || autoConfirm ||
(await confirm( (await confirm(
client,
`Set up and deploy ${chalk.cyan(`${toHumanPath(path)}`)}?`, `Set up and deploy ${chalk.cyan(`${toHumanPath(path)}`)}?`,
true true
)); ));
@@ -287,7 +288,7 @@ export default async (client: Client) => {
if (typeof projectOrNewProjectName === 'string') { if (typeof projectOrNewProjectName === 'string') {
newProjectName = projectOrNewProjectName; newProjectName = projectOrNewProjectName;
rootDirectory = await inputRootDirectory(path, output, autoConfirm); rootDirectory = await inputRootDirectory(client, path, autoConfirm);
} else { } else {
project = projectOrNewProjectName; project = projectOrNewProjectName;
rootDirectory = project.rootDirectory; rootDirectory = project.rootDirectory;
@@ -521,7 +522,7 @@ export default async (client: Client) => {
} }
const settings = await editProjectSettings( const settings = await editProjectSettings(
output, client,
projectSettings, projectSettings,
framework, framework,
false, false,

View File

@@ -46,7 +46,7 @@ export default async function add(
const addStamp = stamp(); const addStamp = stamp();
const { domain, data: argData } = parsedParams; const { domain, data: argData } = parsedParams;
const data = await getDNSData(output, argData); const data = await getDNSData(client, argData);
if (!data) { if (!data) {
output.log(`Aborted`); output.log(`Aborted`);
return 1; return 1;

View File

@@ -87,7 +87,8 @@ export default async function buy(
!(await promptBool( !(await promptBool(
`Buy now for ${chalk.bold(`$${price}`)} (${`${period}yr${ `Buy now for ${chalk.bold(`$${price}`)} (${`${period}yr${
period > 1 ? 's' : '' period > 1 ? 's' : ''
}`})?` }`})?`,
client
)) ))
) { ) {
return 0; return 0;
@@ -99,7 +100,7 @@ export default async function buy(
: `Auto renew every ${renewalPrice.period} years for ${chalk.bold( : `Auto renew every ${renewalPrice.period} years for ${chalk.bold(
`$${price}` `$${price}`
)}?`, )}?`,
{ defaultValue: true } { ...client, defaultValue: true }
); );
let buyResult; let buyResult;

View File

@@ -77,7 +77,8 @@ export default async function move(
!(await promptBool( !(await promptBool(
`Are you sure you want to move ${param(domainName)} to ${param( `Are you sure you want to move ${param(domainName)} to ${param(
destination destination
)}?` )}?`,
client
)) ))
) { ) {
output.log('Aborted'); output.log('Aborted');
@@ -95,7 +96,8 @@ export default async function move(
); );
if ( if (
!(await promptBool( !(await promptBool(
`Are you sure you want to move ${param(domainName)}?` `Are you sure you want to move ${param(domainName)}?`,
client
)) ))
) { ) {
output.log('Aborted'); output.log('Aborted');

View File

@@ -92,7 +92,10 @@ export default async function rm(
const skipConfirmation = opts['--yes'] || false; const skipConfirmation = opts['--yes'] || false;
if ( if (
!skipConfirmation && !skipConfirmation &&
!(await promptBool(`Are you sure you want to remove ${param(domainName)}?`)) !(await promptBool(
`Are you sure you want to remove ${param(domainName)}?`,
client
))
) { ) {
output.log('Aborted'); output.log('Aborted');
return 0; return 0;
@@ -230,7 +233,7 @@ async function removeDomain(
if ( if (
!skipConfirmation && !skipConfirmation &&
!(await promptBool(`Remove conflicts associated with domain?`)) !(await promptBool(`Remove conflicts associated with domain?`, client))
) { ) {
output.log('Aborted'); output.log('Aborted');
return 0; return 0;

View File

@@ -81,7 +81,8 @@ export default async function transferIn(
const shouldTransfer = await promptBool( const shouldTransfer = await promptBool(
transferPolicy === 'no-change' transferPolicy === 'no-change'
? `Transfer now for ${chalk.bold(`$${price}`)}?` ? `Transfer now for ${chalk.bold(`$${price}`)}?`
: `Transfer now with 1yr renewal for ${chalk.bold(`$${price}`)}?` : `Transfer now with 1yr renewal for ${chalk.bold(`$${price}`)}?`,
client
); );
if (!shouldTransfer) { if (!shouldTransfer) {
return 0; return 0;

View File

@@ -31,7 +31,7 @@ export default async function add(
// improve the way we show inquirer prompts // improve the way we show inquirer prompts
require('../../util/input/patch-inquirer'); require('../../util/input/patch-inquirer');
const stdInput = await readStandardInput(); const stdInput = await readStandardInput(client.stdin);
let [envName, envTargetArg, envGitBranch] = args; let [envName, envTargetArg, envGitBranch] = args;
if (args.length > 3) { if (args.length > 3) {

View File

@@ -74,6 +74,7 @@ export default async function pull(
exists && exists &&
!skipConfirmation && !skipConfirmation &&
!(await confirm( !(await confirm(
client,
`Found existing file ${param(filename)}. Do you want to overwrite?`, `Found existing file ${param(filename)}. Do you want to overwrite?`,
false false
)) ))

View File

@@ -104,6 +104,7 @@ export default async function rm(
if ( if (
!skipConfirmation && !skipConfirmation &&
!(await confirm( !(await confirm(
client,
`Removing Environment Variable ${param(env.key)} from ${formatEnvTarget( `Removing Environment Variable ${param(env.key)} from ${formatEnvTarget(
env env
)} in Project ${chalk.bold(project.name)}. Are you sure?`, )} in Project ${chalk.bold(project.name)}. Are you sure?`,

View File

@@ -46,7 +46,11 @@ export default async function init(
const exampleList = examples.filter(x => x.visible).map(x => x.name); const exampleList = examples.filter(x => x.visible).map(x => x.name);
if (!name) { if (!name) {
const chosen = await chooseFromDropdown('Select example:', exampleList); const chosen = await chooseFromDropdown(
client,
'Select example:',
exampleList
);
if (!chosen) { if (!chosen) {
output.log('Aborted'); output.log('Aborted');
@@ -65,7 +69,7 @@ export default async function init(
return extractExample(client, name, dir, force, 'v1'); return extractExample(client, name, dir, force, 'v1');
} }
const found = await guess(exampleList, name); const found = await guess(client, exampleList, name);
if (typeof found === 'string') { if (typeof found === 'string') {
return extractExample(client, found, dir, force); return extractExample(client, found, dir, force);
@@ -90,14 +94,18 @@ async function fetchExampleList(client: Client) {
/** /**
* Prompt user for choosing which example to init * Prompt user for choosing which example to init
*/ */
async function chooseFromDropdown(message: string, exampleList: string[]) { async function chooseFromDropdown(
client: Client,
message: string,
exampleList: string[]
) {
const choices = exampleList.map(name => ({ const choices = exampleList.map(name => ({
name, name,
value: name, value: name,
short: name, short: name,
})); }));
return listInput({ return listInput(client, {
message, message,
choices, choices,
}); });
@@ -194,7 +202,7 @@ function prepareFolder(cwd: string, folder: string, force?: boolean) {
/** /**
* Guess which example user try to init * Guess which example user try to init
*/ */
async function guess(exampleList: string[], name: string) { async function guess(client: Client, exampleList: string[], name: string) {
const GuessError = new Error( const GuessError = new Error(
`No example found for ${chalk.bold(name)}, run ${getCommandName( `No example found for ${chalk.bold(name)}, run ${getCommandName(
`init` `init`
@@ -208,7 +216,7 @@ async function guess(exampleList: string[], name: string) {
const found = didYouMean(name, exampleList, 0.7); const found = didYouMean(name, exampleList, 0.7);
if (typeof found === 'string') { if (typeof found === 'string') {
if (await promptBool(`Did you mean ${chalk.bold(found)}?`)) { if (await promptBool(`Did you mean ${chalk.bold(found)}?`, client)) {
return found; return found;
} }
} else { } else {

View File

@@ -83,7 +83,7 @@ export default async function main(client: Client, desiredSlug?: string) {
]; ];
output.stopSpinner(); output.stopSpinner();
desiredSlug = await listInput({ desiredSlug = await listInput(client, {
message: 'Switch to:', message: 'Switch to:',
choices, choices,
eraseFinalAnswer: true, eraseFinalAnswer: true,

View File

@@ -54,12 +54,12 @@ export default async (client: Client): Promise<number> => {
throw err; throw err;
} }
if (output.isTTY) { if (client.stdout.isTTY) {
output.log(contextName); output.log(contextName);
} else { } else {
// If stdout is not a TTY, then only print the username // If stdout is not a TTY, then only print the username
// to support piping the output to another file / exe // to support piping the output to another file / exe
output.print(`${contextName}\n`, { w: process.stdout }); client.stdout.write(`${contextName}\n`);
} }
return 0; return 0;

View File

@@ -23,7 +23,7 @@ import * as Sentry from '@sentry/node';
import hp from './util/humanize-path'; import hp from './util/humanize-path';
import commands from './commands'; import commands from './commands';
import pkg from './util/pkg'; import pkg from './util/pkg';
import createOutput from './util/output'; import { Output } from './util/output';
import cmd from './util/output/cmd'; import cmd from './util/output/cmd';
import info from './util/output/info'; import info from './util/output/info';
import error from './util/output/error'; import error from './util/output/error';
@@ -109,7 +109,7 @@ const main = async () => {
} }
const isDebugging = argv['--debug']; const isDebugging = argv['--debug'];
const output = createOutput({ debug: isDebugging }); const output = new Output(process.stderr, { debug: isDebugging });
debug = output.debug; debug = output.debug;
@@ -387,6 +387,9 @@ const main = async () => {
// Shared API `Client` instance for all sub-commands to utilize // Shared API `Client` instance for all sub-commands to utilize
client = new Client({ client = new Client({
apiUrl, apiUrl,
stdin: process.stdin,
stdout: process.stdout,
stderr: output.stream,
output, output,
config, config,
authConfig, authConfig,
@@ -796,7 +799,5 @@ process.on('uncaughtException', handleUnexpected);
main() main()
.then(exitCode => { .then(exitCode => {
process.exitCode = exitCode; process.exitCode = exitCode;
// @ts-ignore - "nowExit" is a non-standard event name
process.emit('nowExit');
}) })
.catch(handleUnexpected); .catch(handleUnexpected);

View File

@@ -1,3 +1,5 @@
import type { Readable, Writable } from 'stream';
export type ProjectSettings = import('@vercel/build-utils').ProjectSettings; export type ProjectSettings = import('@vercel/build-utils').ProjectSettings;
export type Primitive = export type Primitive =
@@ -442,3 +444,19 @@ export interface BuildOutput {
layers?: string[]; layers?: string[];
} | null; } | null;
} }
export interface ReadableTTY extends Readable {
isTTY?: boolean;
isRaw?: boolean;
setRawMode?: (mode: boolean) => void;
}
export interface WritableTTY extends Writable {
isTTY?: boolean;
}
export interface Stdio {
stdin: ReadableTTY;
stdout: WritableTTY;
stderr: WritableTTY;
}

View File

@@ -385,8 +385,14 @@ export async function* findDirs(
} }
for (const path of paths) { for (const path of paths) {
const abs = join(dir, path); const abs = join(dir, path);
const s = await fs.stat(abs); let stat: fs.Stats;
if (s.isDirectory()) { try {
stat = await fs.lstat(abs);
} catch (err: any) {
if (err.code === 'ENOENT') continue;
throw err;
}
if (stat.isDirectory()) {
if (path === name) { if (path === name) {
yield relative(root, abs); yield relative(root, abs);
} else { } else {

View File

@@ -1,3 +1,5 @@
import { bold } from 'chalk';
import inquirer from 'inquirer';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { URLSearchParams } from 'url'; import { URLSearchParams } from 'url';
import { parse as parseUrl } from 'url'; import { parse as parseUrl } from 'url';
@@ -11,10 +13,16 @@ import printIndications from './print-indications';
import reauthenticate from './login/reauthenticate'; import reauthenticate from './login/reauthenticate';
import { SAMLError } from './login/types'; import { SAMLError } from './login/types';
import { writeToAuthConfigFile } from './config/files'; import { writeToAuthConfigFile } from './config/files';
import { AuthConfig, GlobalConfig, JSONObject } from '../types'; import type {
AuthConfig,
GlobalConfig,
JSONObject,
Stdio,
ReadableTTY,
WritableTTY,
} from '../types';
import { sharedPromise } from './promise'; import { sharedPromise } from './promise';
import { APIError } from './errors-ts'; import { APIError } from './errors-ts';
import { bold } from 'chalk';
const isSAMLError = (v: any): v is SAMLError => { const isSAMLError = (v: any): v is SAMLError => {
return v && v.saml; return v && v.saml;
@@ -28,7 +36,7 @@ export interface FetchOptions extends Omit<RequestInit, 'body'> {
accountId?: string; accountId?: string;
} }
export interface ClientOptions { export interface ClientOptions extends Stdio {
argv: string[]; argv: string[];
apiUrl: string; apiUrl: string;
authConfig: AuthConfig; authConfig: AuthConfig;
@@ -41,13 +49,17 @@ export const isJSONObject = (v: any): v is JSONObject => {
return v && typeof v == 'object' && v.constructor === Object; return v && typeof v == 'object' && v.constructor === Object;
}; };
export default class Client extends EventEmitter { export default class Client extends EventEmitter implements Stdio {
argv: string[]; argv: string[];
apiUrl: string; apiUrl: string;
authConfig: AuthConfig; authConfig: AuthConfig;
stdin: ReadableTTY;
stdout: WritableTTY;
stderr: WritableTTY;
output: Output; output: Output;
config: GlobalConfig; config: GlobalConfig;
localConfig?: VercelConfig; localConfig?: VercelConfig;
prompt!: inquirer.PromptModule;
private requestIdCounter: number; private requestIdCounter: number;
constructor(opts: ClientOptions) { constructor(opts: ClientOptions) {
@@ -55,10 +67,14 @@ export default class Client extends EventEmitter {
this.argv = opts.argv; this.argv = opts.argv;
this.apiUrl = opts.apiUrl; this.apiUrl = opts.apiUrl;
this.authConfig = opts.authConfig; this.authConfig = opts.authConfig;
this.stdin = opts.stdin;
this.stdout = opts.stdout;
this.stderr = opts.stderr;
this.output = opts.output; this.output = opts.output;
this.config = opts.config; this.config = opts.config;
this.localConfig = opts.localConfig; this.localConfig = opts.localConfig;
this.requestIdCounter = 1; this.requestIdCounter = 1;
this._createPromptModule();
} }
retry<T>(fn: RetryFunction<T>, { retries = 3, maxTimeout = Infinity } = {}) { retry<T>(fn: RetryFunction<T>, { retries = 3, maxTimeout = Infinity } = {}) {
@@ -124,7 +140,7 @@ export default class Client extends EventEmitter {
return this.retry(async bail => { return this.retry(async bail => {
const res = await this._fetch(url, opts); const res = await this._fetch(url, opts);
printIndications(res); printIndications(this, res);
if (!res.ok) { if (!res.ok) {
const error = await responseError(res); const error = await responseError(res);
@@ -180,4 +196,11 @@ export default class Client extends EventEmitter {
_onRetry = (error: Error) => { _onRetry = (error: Error) => {
this.output.debug(`Retrying: ${error}\n${error.stack}`); this.output.debug(`Retrying: ${error}\n${error.stack}`);
}; };
_createPromptModule() {
this.prompt = inquirer.createPromptModule({
input: this.stdin as NodeJS.ReadStream,
output: this.stderr as NodeJS.WriteStream,
});
}
} }

View File

@@ -13,8 +13,8 @@ import {
Lambda, Lambda,
FileBlob, FileBlob,
FileFsRef, FileFsRef,
isOfficialRuntime,
} from '@vercel/build-utils'; } from '@vercel/build-utils';
import { isOfficialRuntime } from '@vercel/fs-detectors';
import plural from 'pluralize'; import plural from 'pluralize';
import minimatch from 'minimatch'; import minimatch from 'minimatch';

View File

@@ -36,12 +36,14 @@ import {
StartDevServerResult, StartDevServerResult,
FileFsRef, FileFsRef,
PackageJson, PackageJson,
spawnCommand,
} from '@vercel/build-utils';
import {
detectBuilders, detectBuilders,
detectApiDirectory, detectApiDirectory,
detectApiExtensions, detectApiExtensions,
spawnCommand,
isOfficialRuntime, isOfficialRuntime,
} from '@vercel/build-utils'; } from '@vercel/fs-detectors';
import frameworkList from '@vercel/frameworks'; import frameworkList from '@vercel/frameworks';
import cmd from '../output/cmd'; import cmd from '../output/cmd';
@@ -329,6 +331,8 @@ export default class DevServer {
): Promise<void> { ): Promise<void> {
const name = relative(this.cwd, fsPath); const name = relative(this.cwd, fsPath);
try { try {
await this.getVercelConfig();
this.files[name] = await FileFsRef.fromFsPath({ fsPath }); this.files[name] = await FileFsRef.fromFsPath({ fsPath });
const extensionless = this.getExtensionlessFile(name); const extensionless = this.getExtensionlessFile(name);
if (extensionless) { if (extensionless) {

View File

@@ -2,26 +2,29 @@ import chalk from 'chalk';
import { DNSRecordData } from '../../types'; import { DNSRecordData } from '../../types';
import textInput from '../input/text'; import textInput from '../input/text';
import promptBool from '../input/prompt-bool'; import promptBool from '../input/prompt-bool';
import { Output } from '../output'; import Client from '../client';
const RECORD_TYPES = ['A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'SRV', 'TXT']; const RECORD_TYPES = ['A', 'AAAA', 'ALIAS', 'CAA', 'CNAME', 'MX', 'SRV', 'TXT'];
export default async function getDNSData( export default async function getDNSData(
output: Output, client: Client,
data: null | DNSRecordData data: null | DNSRecordData
): Promise<DNSRecordData | null> { ): Promise<DNSRecordData | null> {
if (data) { if (data) {
return data; return data;
} }
const { output } = client;
try { try {
// first ask for type, branch from there // first ask for type, branch from there
const possibleTypes = new Set(RECORD_TYPES); const possibleTypes = new Set(RECORD_TYPES);
const type = (await textInput({ const type = (
label: `- Record type (${RECORD_TYPES.join(', ')}): `, await textInput({
validateValue: (v: string) => label: `- Record type (${RECORD_TYPES.join(', ')}): `,
Boolean(v && possibleTypes.has(v.trim().toUpperCase())) validateValue: (v: string) =>
})) Boolean(v && possibleTypes.has(v.trim().toUpperCase())),
})
)
.trim() .trim()
.toUpperCase(); .toUpperCase();
@@ -39,7 +42,7 @@ export default async function getDNSData(
target target
)}.` )}.`
); );
return (await verifyData()) return (await verifyData(client))
? { ? {
name, name,
type, type,
@@ -47,8 +50,8 @@ export default async function getDNSData(
priority, priority,
weight, weight,
port, port,
target target,
} },
} }
: null; : null;
} }
@@ -61,23 +64,23 @@ export default async function getDNSData(
`${mxPriority}` `${mxPriority}`
)} ${chalk.cyan(value)}` )} ${chalk.cyan(value)}`
); );
return (await verifyData()) return (await verifyData(client))
? { ? {
name, name,
type, type,
value, value,
mxPriority mxPriority,
} }
: null; : null;
} }
const value = await getTrimmedString(`- ${type} value: `); const value = await getTrimmedString(`- ${type} value: `);
output.log(`${chalk.cyan(name)} ${chalk.bold(type)} ${chalk.cyan(value)}`); output.log(`${chalk.cyan(name)} ${chalk.bold(type)} ${chalk.cyan(value)}`);
return (await verifyData()) return (await verifyData(client))
? { ? {
name, name,
type, type,
value value,
} }
: null; : null;
} catch (error) { } catch (error) {
@@ -85,13 +88,13 @@ export default async function getDNSData(
} }
} }
async function verifyData() { async function verifyData(client: Client) {
return promptBool('Is this correct?'); return promptBool('Is this correct?', client);
} }
async function getRecordName(type: string) { async function getRecordName(type: string) {
const input = await textInput({ const input = await textInput({
label: `- ${type} name: ` label: `- ${type} name: `,
}); });
return input === '@' ? '' : input; return input === '@' ? '' : input;
} }
@@ -100,14 +103,14 @@ async function getNumber(label: string) {
return Number( return Number(
await textInput({ await textInput({
label, label,
validateValue: v => Boolean(v && Number(v)) validateValue: v => Boolean(v && Number(v)),
}) })
); );
} }
async function getTrimmedString(label: string) { async function getTrimmedString(label: string) {
const res = await textInput({ const res = await textInput({
label, label,
validateValue: v => Boolean(v && v.trim().length > 0) validateValue: v => Boolean(v && v.trim().length > 0),
}); });
return res.trim(); return res.trim();
} }

View File

@@ -54,7 +54,8 @@ export default async function purchaseDomainIfAvailable(
!(await promptBool( !(await promptBool(
`Buy ${chalk.underline(domain)} for ${chalk.bold( `Buy ${chalk.underline(domain)} for ${chalk.bold(
`$${price}` `$${price}`
)} (${plural('yr', period, true)})?` )} (${plural('yr', period, true)})?`,
client
)) ))
) { ) {
output.print(eraseLines(1)); output.print(eraseLines(1));

View File

@@ -1,5 +1,5 @@
import { resolve } from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { resolve } from 'path';
import { getVercelIgnore } from '@vercel/client'; import { getVercelIgnore } from '@vercel/client';
import uniqueStrings from './unique-strings'; import uniqueStrings from './unique-strings';
import { Output } from './output/create-output'; import { Output } from './output/create-output';

View File

@@ -530,7 +530,7 @@ export default class Now extends EventEmitter {
`${opts.method || 'GET'} ${this._apiUrl}${_url} ${opts.body || ''}`, `${opts.method || 'GET'} ${this._apiUrl}${_url} ${opts.body || ''}`,
fetch(`${this._apiUrl}${_url}`, { ...opts, body }) fetch(`${this._apiUrl}${_url}`, { ...opts, body })
); );
printIndications(res); printIndications(this._client, res);
return res; return res;
} }

View File

@@ -1,12 +1,13 @@
import inquirer from 'inquirer'; import Client from '../client';
export default async function confirm( export default async function confirm(
client: Client,
message: string, message: string,
preferred: boolean preferred: boolean
): Promise<boolean> { ): Promise<boolean> {
require('./patch-inquirer'); require('./patch-inquirer');
const answers = await inquirer.prompt({ const answers = await client.prompt({
type: 'confirm', type: 'confirm',
name: 'value', name: 'value',
message, message,

View File

@@ -1,8 +1,8 @@
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import confirm from './confirm'; import confirm from './confirm';
import chalk from 'chalk'; import chalk from 'chalk';
import { Output } from '../output';
import frameworkList, { Framework } from '@vercel/frameworks'; import frameworkList, { Framework } from '@vercel/frameworks';
import Client from '../client';
import { isSettingValue } from '../is-setting-value'; import { isSettingValue } from '../is-setting-value';
import { ProjectSettings } from '../../types'; import { ProjectSettings } from '../../types';
@@ -22,12 +22,14 @@ const settingKeys = Object.keys(settingMap).sort() as unknown as readonly [
export type PartialProjectSettings = Pick<ProjectSettings, ConfigKeys>; export type PartialProjectSettings = Pick<ProjectSettings, ConfigKeys>;
export default async function editProjectSettings( export default async function editProjectSettings(
output: Output, client: Client,
projectSettings: PartialProjectSettings | null, projectSettings: PartialProjectSettings | null,
framework: Framework | null, framework: Framework | null,
autoConfirm: boolean, autoConfirm: boolean,
localConfigurationOverrides: PartialProjectSettings | null localConfigurationOverrides: PartialProjectSettings | null
): Promise<ProjectSettings> { ): Promise<ProjectSettings> {
const { output } = client;
// Create initial settings object defaulting everything to `null` and assigning what may exist in `projectSettings` // Create initial settings object defaulting everything to `null` and assigning what may exist in `projectSettings`
const settings: ProjectSettings = Object.assign( const settings: ProjectSettings = Object.assign(
{ {
@@ -118,7 +120,7 @@ export default async function editProjectSettings(
// Prompt the user if they want to modify any settings not defined by local configuration. // Prompt the user if they want to modify any settings not defined by local configuration.
if ( if (
autoConfirm || autoConfirm ||
!(await confirm('Want to modify these settings?', false)) !(await confirm(client, 'Want to modify these settings?', false))
) { ) {
return settings; return settings;
} }

View File

@@ -1,5 +1,4 @@
import Client from '../client'; import Client from '../client';
import inquirer from 'inquirer';
import confirm from './confirm'; import confirm from './confirm';
import getProjectByIdOrName from '../projects/get-project-by-id-or-name'; import getProjectByIdOrName from '../projects/get-project-by-id-or-name';
import chalk from 'chalk'; import chalk from 'chalk';
@@ -47,11 +46,16 @@ export default async function inputProject(
if (!detectedProject) { if (!detectedProject) {
// did not auto-detect a project to link // did not auto-detect a project to link
shouldLinkProject = await confirm(`Link to existing project?`, false); shouldLinkProject = await confirm(
client,
`Link to existing project?`,
false
);
} else { } else {
// auto-detected a project to link // auto-detected a project to link
if ( if (
await confirm( await confirm(
client,
`Found project ${chalk.cyan( `Found project ${chalk.cyan(
`${org.slug}/${detectedProject.name}` `${org.slug}/${detectedProject.name}`
)}. Link to it?`, )}. Link to it?`,
@@ -63,6 +67,7 @@ export default async function inputProject(
// user doesn't want to link the auto-detected project // user doesn't want to link the auto-detected project
shouldLinkProject = await confirm( shouldLinkProject = await confirm(
client,
`Link to different existing project?`, `Link to different existing project?`,
true true
); );
@@ -73,7 +78,7 @@ export default async function inputProject(
let project: Project | ProjectNotFound | null = null; let project: Project | ProjectNotFound | null = null;
while (!project || project instanceof ProjectNotFound) { while (!project || project instanceof ProjectNotFound) {
const answers = await inquirer.prompt({ const answers = await client.prompt({
type: 'input', type: 'input',
name: 'existingProjectName', name: 'existingProjectName',
message: `Whats the name of your existing project?`, message: `Whats the name of your existing project?`,
@@ -104,7 +109,7 @@ export default async function inputProject(
let newProjectName: string | null = null; let newProjectName: string | null = null;
while (!newProjectName) { while (!newProjectName) {
const answers = await inquirer.prompt({ const answers = await client.prompt({
type: 'input', type: 'input',
name: 'newProjectName', name: 'newProjectName',
message: `Whats your projects name?`, message: `Whats your projects name?`,

View File

@@ -1,12 +1,11 @@
import path from 'path'; import path from 'path';
import chalk from 'chalk'; import chalk from 'chalk';
import inquirer from 'inquirer';
import { Output } from '../output';
import { validateRootDirectory } from '../validate-paths'; import { validateRootDirectory } from '../validate-paths';
import Client from '../client';
export async function inputRootDirectory( export async function inputRootDirectory(
client: Client,
cwd: string, cwd: string,
output: Output,
autoConfirm = false autoConfirm = false
) { ) {
if (autoConfirm) { if (autoConfirm) {
@@ -15,7 +14,7 @@ export async function inputRootDirectory(
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
const { rootDirectory } = await inquirer.prompt({ const { rootDirectory } = await client.prompt({
type: 'input', type: 'input',
name: 'rootDirectory', name: 'rootDirectory',
message: `In which directory is your code located?`, message: `In which directory is your code located?`,
@@ -38,7 +37,7 @@ export async function inputRootDirectory(
if ( if (
(await validateRootDirectory( (await validateRootDirectory(
output, client.output,
cwd, cwd,
fullPath, fullPath,
'Please choose a different one.' 'Please choose a different one.'

View File

@@ -1,5 +1,6 @@
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import stripAnsi from 'strip-ansi'; import stripAnsi from 'strip-ansi';
import Client from '../client';
import eraseLines from '../output/erase-lines'; import eraseLines from '../output/erase-lines';
interface ListEntry { interface ListEntry {
@@ -35,21 +36,24 @@ function getLength(input: string): number {
return biggestLength; return biggestLength;
} }
export default async function list({ export default async function list(
message = 'the question', client: Client,
// eslint-disable-line no-unused-vars {
choices: _choices = [ message = 'the question',
{ // eslint-disable-line no-unused-vars
name: 'something\ndescription\ndetails\netc', choices: _choices = [
value: 'something unique', {
short: 'generally the first line of `name`', name: 'something\ndescription\ndetails\netc',
}, value: 'something unique',
], short: 'generally the first line of `name`',
pageSize = 15, // Show 15 lines without scrolling (~4 credit cards) },
separator = false, // Puts a blank separator between each choice ],
abort = 'end', // Whether the `abort` option will be at the `start` or the `end`, pageSize = 15, // Show 15 lines without scrolling (~4 credit cards)
eraseFinalAnswer = false, // If true, the line with the final answer that inquirer prints will be erased before returning separator = false, // Puts a blank separator between each choice
}: ListOptions): Promise<string> { abort = 'end', // Whether the `abort` option will be at the `start` or the `end`,
eraseFinalAnswer = false, // If true, the line with the final answer that inquirer prints will be erased before returning
}: ListOptions
): Promise<string> {
require('./patch-inquirer-legacy'); require('./patch-inquirer-legacy');
let biggestLength = 0; let biggestLength = 0;
@@ -106,7 +110,7 @@ export default async function list({
choices.push(abortSeparator, _abort); choices.push(abortSeparator, _abort);
} }
const answer = await inquirer.prompt({ const answer = await client.prompt({
name: 'value', name: 'value',
type: 'list', type: 'list',
default: selected, default: selected,

View File

@@ -1,25 +1,26 @@
import chalk from 'chalk'; import chalk from 'chalk';
import type { ReadableTTY, WritableTTY } from '../../types';
type Options = { type Options = {
abortSequences?: Set<string>; abortSequences?: Set<string>;
defaultValue?: boolean; defaultValue?: boolean;
noChar?: string; noChar?: string;
resolveChars?: Set<string>; resolveChars?: Set<string>;
stdin?: NodeJS.ReadStream; stdin: ReadableTTY;
stdout?: NodeJS.WriteStream; stdout: WritableTTY;
trailing?: string; trailing?: string;
yesChar?: string; yesChar?: string;
}; };
export default async function promptBool(label: string, options: Options = {}) { export default async function promptBool(label: string, options: Options) {
const { const {
stdin,
stdout,
defaultValue = false, defaultValue = false,
abortSequences = new Set(['\u0003']), abortSequences = new Set(['\u0003']),
resolveChars = new Set(['\r']), resolveChars = new Set(['\r']),
yesChar = 'y', yesChar = 'y',
noChar = 'n', noChar = 'n',
stdin = process.stdin,
stdout = process.stdout,
trailing = '', trailing = '',
} = options; } = options;

View File

@@ -1,13 +1,17 @@
export default async function readStandardInput(): Promise<string> { import type { ReadableTTY } from '../../types';
export default async function readStandardInput(
stdin: ReadableTTY
): Promise<string> {
return new Promise<string>(resolve => { return new Promise<string>(resolve => {
setTimeout(() => resolve(''), 500); setTimeout(() => resolve(''), 500);
if (process.stdin.isTTY) { if (stdin.isTTY) {
// found tty so we know there is nothing piped to stdin // found tty so we know there is nothing piped to stdin
resolve(''); resolve('');
} else { } else {
process.stdin.setEncoding('utf8'); stdin.setEncoding('utf8');
process.stdin.once('data', resolve); stdin.once('data', resolve);
} }
}); });
} }

View File

@@ -56,7 +56,7 @@ export default async function setupAndLink(
return { status: 'error', exitCode: 1, reason: 'PATH_IS_FILE' }; return { status: 'error', exitCode: 1, reason: 'PATH_IS_FILE' };
} }
const link = await getLinkedProject(client, path); const link = await getLinkedProject(client, path);
const isTTY = process.stdout.isTTY; const isTTY = client.stdin.isTTY;
const quiet = !isTTY; const quiet = !isTTY;
let rootDirectory: string | null = null; let rootDirectory: string | null = null;
let sourceFilesOutsideRootDirectory = true; let sourceFilesOutsideRootDirectory = true;
@@ -80,6 +80,7 @@ export default async function setupAndLink(
const shouldStartSetup = const shouldStartSetup =
autoConfirm || autoConfirm ||
(await confirm( (await confirm(
client,
`${setupMsg} ${chalk.cyan(`${toHumanPath(path)}`)}?`, `${setupMsg} ${chalk.cyan(`${toHumanPath(path)}`)}?`,
true true
)); ));
@@ -120,7 +121,7 @@ export default async function setupAndLink(
if (typeof projectOrNewProjectName === 'string') { if (typeof projectOrNewProjectName === 'string') {
newProjectName = projectOrNewProjectName; newProjectName = projectOrNewProjectName;
rootDirectory = await inputRootDirectory(path, output, autoConfirm); rootDirectory = await inputRootDirectory(client, path, autoConfirm);
} else { } else {
const project = projectOrNewProjectName; const project = projectOrNewProjectName;
@@ -224,7 +225,7 @@ export default async function setupAndLink(
const { projectSettings, framework } = deployment; const { projectSettings, framework } = deployment;
settings = await editProjectSettings( settings = await editProjectSettings(
output, client,
projectSettings, projectSettings,
framework, framework,
autoConfirm, autoConfirm,

View File

@@ -176,7 +176,7 @@ async function getVerificationTokenOutOfBand(client: Client, url: URL) {
output.log( output.log(
`After login is complete, enter the verification code printed in your browser.` `After login is complete, enter the verification code printed in your browser.`
); );
const verificationToken = await readInput('Verification code:'); const verificationToken = await readInput(client, 'Verification code:');
output.print(eraseLines(6)); output.print(eraseLines(6));
// If the pasted token begins with "saml_", then the `ssoUserId` was returned. // If the pasted token begins with "saml_", then the `ssoUserId` was returned.

View File

@@ -1,4 +1,3 @@
import inquirer from 'inquirer';
import Client from '../client'; import Client from '../client';
import error from '../output/error'; import error from '../output/error';
import listInput from '../input/list'; import listInput from '../input/list';
@@ -32,7 +31,7 @@ export default async function prompt(
choices.pop(); choices.pop();
} }
const choice = await listInput({ const choice = await listInput(client, {
message: 'Log in to Vercel', message: 'Log in to Vercel',
choices, choices,
}); });
@@ -44,22 +43,26 @@ export default async function prompt(
} else if (choice === 'bitbucket') { } else if (choice === 'bitbucket') {
result = await doBitbucketLogin(client, outOfBand, ssoUserId); result = await doBitbucketLogin(client, outOfBand, ssoUserId);
} else if (choice === 'email') { } else if (choice === 'email') {
const email = await readInput('Enter your email address:'); const email = await readInput(client, 'Enter your email address:');
result = await doEmailLogin(client, email, ssoUserId); result = await doEmailLogin(client, email, ssoUserId);
} else if (choice === 'saml') { } else if (choice === 'saml') {
const slug = error?.teamId || (await readInput('Enter your Team slug:')); const slug =
error?.teamId || (await readInput(client, 'Enter your Team slug:'));
result = await doSamlLogin(client, slug, outOfBand, ssoUserId); result = await doSamlLogin(client, slug, outOfBand, ssoUserId);
} }
return result; return result;
} }
export async function readInput(message: string): Promise<string> { export async function readInput(
client: Client,
message: string
): Promise<string> {
let input; let input;
while (!input) { while (!input) {
try { try {
const { val } = await inquirer.prompt({ const { val } = await client.prompt({
type: 'input', type: 'input',
name: 'val', name: 'val',
message, message,

View File

@@ -14,7 +14,7 @@ export default async function reauthenticate(
client.output.log( client.output.log(
`You must re-authenticate with SAML to use ${bold(error.scope)} scope.` `You must re-authenticate with SAML to use ${bold(error.scope)} scope.`
); );
if (await confirm(`Log in with SAML?`, true)) { if (await confirm(client, `Log in with SAML?`, true)) {
return doSamlLogin(client, error.teamId); return doSamlLogin(client, error.teamId);
} }
} else { } else {

View File

@@ -1,41 +1,39 @@
import chalk from 'chalk'; import chalk from 'chalk';
import renderLink from './link'; import renderLink from './link';
import wait, { StopSpinner } from './wait'; import wait, { StopSpinner } from './wait';
import { Writable } from 'stream'; import type { WritableTTY } from '../../types';
export interface OutputOptions { export interface OutputOptions {
debug?: boolean; debug?: boolean;
} }
export interface PrintOptions { export interface LogOptions {
w?: Writable;
}
export interface LogOptions extends PrintOptions {
color?: typeof chalk; color?: typeof chalk;
} }
export class Output { export class Output {
stream: WritableTTY;
debugEnabled: boolean; debugEnabled: boolean;
private spinnerMessage: string; private spinnerMessage: string;
private _spinner: StopSpinner | null; private _spinner: StopSpinner | null;
isTTY: boolean;
constructor({ debug: debugEnabled = false }: OutputOptions = {}) { constructor(
stream: WritableTTY,
{ debug: debugEnabled = false }: OutputOptions = {}
) {
this.stream = stream;
this.debugEnabled = debugEnabled; this.debugEnabled = debugEnabled;
this.spinnerMessage = ''; this.spinnerMessage = '';
this._spinner = null; this._spinner = null;
this.isTTY = process.stdout.isTTY || false;
} }
isDebugEnabled = () => { isDebugEnabled = () => {
return this.debugEnabled; return this.debugEnabled;
}; };
print = (str: string, { w }: PrintOptions = { w: process.stderr }) => { print = (str: string) => {
this.stopSpinner(); this.stopSpinner();
const stream: Writable = w || process.stderr; this.stream.write(str);
stream.write(str);
}; };
log = (str: string, color = chalk.grey) => { log = (str: string, color = chalk.grey) => {
@@ -111,11 +109,17 @@ export class Output {
this.debug(`Spinner invoked (${message}) with a ${delay}ms delay`); this.debug(`Spinner invoked (${message}) with a ${delay}ms delay`);
return; return;
} }
if (this.isTTY) { if (this.stream.isTTY) {
if (this._spinner) { if (this._spinner) {
this._spinner.text = message; this._spinner.text = message;
} else { } else {
this._spinner = wait(message, delay); this._spinner = wait(
{
text: message,
stream: this.stream,
},
delay
);
} }
} else { } else {
this.print(`${message}\n`); this.print(`${message}\n`);
@@ -157,7 +161,3 @@ export class Output {
return promise; return promise;
}; };
} }
export default function createOutput(opts?: OutputOptions) {
return new Output(opts);
}

View File

@@ -1,2 +1,2 @@
export { default, Output } from './create-output'; export { Output } from './create-output';
export { StopSpinner } from './wait'; export { StopSpinner } from './wait';

View File

@@ -8,14 +8,19 @@ export interface StopSpinner {
} }
export default function wait( export default function wait(
msg: string, opts: ora.Options,
delay: number = 300, delay: number = 300
_ora = ora
): StopSpinner { ): StopSpinner {
let spinner: ReturnType<typeof _ora> | null = null; let text = opts.text;
let spinner: ora.Ora | null = null;
if (typeof text !== 'string') {
throw new Error(`"text" is required for Ora spinner`);
}
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
spinner = _ora(chalk.gray(msg)); spinner = ora(opts);
spinner.text = chalk.gray(text);
spinner.color = 'gray'; spinner.color = 'gray';
spinner.start(); spinner.start();
}, delay); }, delay);
@@ -29,23 +34,21 @@ export default function wait(
} }
}; };
stop.text = msg; stop.text = text;
// Allow `text` property to update the text while the spinner is in action // Allow `text` property to update the text while the spinner is in action
Object.defineProperty(stop, 'text', { Object.defineProperty(stop, 'text', {
get() { get() {
return msg; return text;
}, },
set(v: string) { set(v: string) {
msg = v; text = v;
if (spinner) { if (spinner) {
spinner.text = chalk.gray(v); spinner.text = chalk.gray(v);
} }
}, },
}); });
// @ts-ignore
process.once('nowExit', stop);
return stop; return stop;
} }

View File

@@ -1,11 +1,10 @@
import chalk from 'chalk'; import chalk from 'chalk';
import { Response } from 'node-fetch'; import { Response } from 'node-fetch';
import { emoji, EmojiLabel, prependEmoji } from './emoji'; import Client from './client';
import createOutput from './output';
import linkStyle from './output/link'; import linkStyle from './output/link';
import { emoji, EmojiLabel, prependEmoji } from './emoji';
export default function printIndications(res: Response) { export default function printIndications(client: Client, res: Response) {
const _output = createOutput();
const indications = new Set(['warning', 'notice', 'tip']); const indications = new Set(['warning', 'notice', 'tip']);
const regex = /^x-(?:vercel|now)-(warning|notice|tip)-(.*)$/; const regex = /^x-(?:vercel|now)-(warning|notice|tip)-(.*)$/;
@@ -25,7 +24,7 @@ export default function printIndications(res: Response) {
chalk.dim(`${action || 'Learn More'}: ${linkStyle(link)}`) + chalk.dim(`${action || 'Learn More'}: ${linkStyle(link)}`) +
newline; newline;
} }
_output.print(message + finalLink); client.output.print(message + finalLink);
} }
} }
} }

View File

@@ -5,6 +5,7 @@ import chalk from 'chalk';
import { homedir } from 'os'; import { homedir } from 'os';
import confirm from './input/confirm'; import confirm from './input/confirm';
import toHumanPath from './humanize-path'; import toHumanPath from './humanize-path';
import Client from './client';
const stat = promisify(lstatRaw); const stat = promisify(lstatRaw);
@@ -51,9 +52,11 @@ export async function validateRootDirectory(
} }
export default async function validatePaths( export default async function validatePaths(
output: Output, client: Client,
paths: string[] paths: string[]
): Promise<{ valid: true; path: string } | { valid: false; exitCode: number }> { ): Promise<{ valid: true; path: string } | { valid: false; exitCode: number }> {
const { output } = client;
// can't deploy more than 1 path // can't deploy more than 1 path
if (paths.length > 1) { if (paths.length > 1) {
output.print(`${chalk.red('Error!')} Can't deploy more than one path.\n`); output.print(`${chalk.red('Error!')} Can't deploy more than one path.\n`);
@@ -85,6 +88,7 @@ export default async function validatePaths(
// ask confirmation if the directory is home // ask confirmation if the directory is home
if (path === homedir()) { if (path === homedir()) {
const shouldDeployHomeDirectory = await confirm( const shouldDeployHomeDirectory = await confirm(
client,
`You are deploying your home directory. Do you want to continue?`, `You are deploying your home directory. Do you want to continue?`,
false false
); );

View File

@@ -2,7 +2,7 @@ export const config = {
runtime: 'experimental-edge' runtime: 'experimental-edge'
} }
export default async function edge(request: Request, event: Event) { export default async function edge(request, event) {
return new Response('some response body'); return new Response('some response body');
// intentional missing closing bracket to produce syntax error // intentional missing closing bracket to produce syntax error

View File

@@ -4,7 +4,15 @@ export const config = {
}; };
export default function middleware(request, _event) { export default function middleware(request, _event) {
const response = new Response('middleware response'); const url = new URL(request.url);
const response = new Response(
JSON.stringify({
pathname: url.pathname,
search: url.search,
fromMiddleware: true,
})
);
// Set custom header // Set custom header
response.headers.set('x-modified-edge', 'true'); response.headers.set('x-modified-edge', 'true');

View File

@@ -0,0 +1,3 @@
{
"version": 2
}

View File

@@ -161,14 +161,13 @@ test('[vercel dev] should handle startup errors thrown in edge functions', async
}); });
validateResponseHeaders(res); validateResponseHeaders(res);
const { stdout, stderr } = await dev.kill('SIGTERM'); const { stderr } = await dev.kill('SIGTERM');
expect(await res.text()).toMatch( expect(await res.text()).toMatch(
/<strong>500<\/strong>: INTERNAL_SERVER_ERROR/g /<strong>500<\/strong>: INTERNAL_SERVER_ERROR/g
); );
expect(stdout).toMatch( expect(stderr).toMatch(/Failed to instantiate edge runtime./g);
/Failed to instantiate edge runtime: intentional startup error/g expect(stderr).toMatch(/intentional startup error/g);
);
expect(stderr).toMatch( expect(stderr).toMatch(
/Failed to complete request to \/api\/edge-error-startup: Error: socket hang up/g /Failed to complete request to \/api\/edge-error-startup: Error: socket hang up/g
); );
@@ -193,14 +192,13 @@ test('[vercel dev] should handle syntax errors thrown in edge functions', async
}); });
validateResponseHeaders(res); validateResponseHeaders(res);
const { stdout, stderr } = await dev.kill('SIGTERM'); const { stderr } = await dev.kill('SIGTERM');
expect(await res.text()).toMatch( expect(await res.text()).toMatch(
/<strong>500<\/strong>: INTERNAL_SERVER_ERROR/g /<strong>500<\/strong>: INTERNAL_SERVER_ERROR/g
); );
expect(stdout).toMatch( expect(stderr).toMatch(/Failed to instantiate edge runtime./g);
/Failed to instantiate edge runtime: Module parse failed: Unexpected token/g expect(stderr).toMatch(/Unexpected end of file/g);
);
expect(stderr).toMatch( expect(stderr).toMatch(
/Failed to complete request to \/api\/edge-error-syntax: Error: socket hang up/g /Failed to complete request to \/api\/edge-error-syntax: Error: socket hang up/g
); );
@@ -228,13 +226,13 @@ test('[vercel dev] should handle import errors thrown in edge functions', async
); );
validateResponseHeaders(res); validateResponseHeaders(res);
const { stdout, stderr } = await dev.kill('SIGTERM'); const { stderr } = await dev.kill('SIGTERM');
expect(await res.text()).toMatch( expect(await res.text()).toMatch(
/<strong>500<\/strong>: INTERNAL_SERVER_ERROR/g /<strong>500<\/strong>: INTERNAL_SERVER_ERROR/g
); );
expect(stdout).toMatch( expect(stderr).toMatch(
/Failed to instantiate edge runtime: Code generation from strings disallowed for this context/g /Could not resolve "unknown-module-893427589372458934795843"/g
); );
expect(stderr).toMatch( expect(stderr).toMatch(
/Failed to complete request to \/api\/edge-error-unknown-import: Error: socket hang up/g /Failed to complete request to \/api\/edge-error-unknown-import: Error: socket hang up/g
@@ -244,7 +242,7 @@ test('[vercel dev] should handle import errors thrown in edge functions', async
} }
}); });
test('[vercel dev] should handle import errors thrown in edge functions', async () => { test('[vercel dev] should handle missing handler errors thrown in edge functions', async () => {
const dir = fixture('edge-function-error'); const dir = fixture('edge-function-error');
const { dev, port, readyResolver } = await testFixture(dir); const { dev, port, readyResolver } = await testFixture(dir);

View File

@@ -1,7 +1,17 @@
// eslint-disable-next-line // eslint-disable-next-line
import path from 'path'; import { join } from 'path';
import ms from 'ms';
import fs, { mkdirp } from 'fs-extra';
const { exec, fixture, testFixture, testFixtureStdio } = require('./utils.js'); const {
exec,
fetch,
fixture,
sleep,
testFixture,
testFixtureStdio,
validateResponseHeaders,
} = require('./utils.js');
test('[vercel dev] validate redirects', async () => { test('[vercel dev] validate redirects', async () => {
const directory = fixture('invalid-redirects'); const directory = fixture('invalid-redirects');
@@ -334,3 +344,44 @@ test(
await testPath(200, '/', /A simple deployment with the Vercel API!/m); await testPath(200, '/', /A simple deployment with the Vercel API!/m);
}) })
); );
test(
'[vercel dev] add a `api/fn.ts` when `api` does not exist at startup`',
testFixtureStdio('no-api', async (_testPath: any, port: any) => {
const directory = fixture('no-api');
const apiDir = join(directory, 'api');
try {
{
const response = await fetch(`http://localhost:${port}/api/new-file`);
validateResponseHeaders(response);
expect(response.status).toBe(404);
}
const fileContents = `
export const config = {
runtime: 'experimental-edge'
}
export default async function edge(request, event) {
return new Response('from new file');
}
`;
await mkdirp(apiDir);
await fs.writeFile(join(apiDir, 'new-file.js'), fileContents);
// Wait until file events have been processed
await sleep(ms('1s'));
{
const response = await fetch(`http://localhost:${port}/api/new-file`);
validateResponseHeaders(response);
const body = await response.text();
expect(body.trim()).toBe('from new file');
}
} finally {
await fs.remove(apiDir);
}
})
);

View File

@@ -508,18 +508,23 @@ test(
test( test(
'[vercel dev] Middleware with `matchers` config', '[vercel dev] Middleware with `matchers` config',
testFixtureStdio( testFixtureStdio('middleware-matchers', async (testPath: any) => {
'middleware-matchers', await testPath(404, '/');
async (testPath: any) => { await testPath(404, '/another');
// TODO: remove once latest `@vercel/node` is shipped to stable with `matchers` support (fails because `directoryListing`) await testPath(
//await testPath(404, '/'); 200,
await testPath(404, '/another'); '/about/page',
await testPath(200, '/about/page', 'middleware response'); '{"pathname":"/about/page","search":"","fromMiddleware":true}'
await testPath(200, '/dashboard/home', 'middleware response'); );
}, await testPath(
{ 200,
// TODO: remove once latest `@vercel/node` is shipped to stable with `matchers` support '/dashboard/home',
skipDeploy: true, '{"pathname":"/dashboard/home","search":"","fromMiddleware":true}'
} );
) await testPath(
200,
'/dashboard/home?a=b',
'{"pathname":"/dashboard/home","search":"?a=b","fromMiddleware":true}'
);
})
); );

View File

@@ -1958,7 +1958,7 @@ test('ensure we render a prompt when deploying home directory', async t => {
t.is(exitCode, 0); t.is(exitCode, 0);
t.true( t.true(
stdout.includes( stderr.includes(
'You are deploying your home directory. Do you want to continue? [y/N]' 'You are deploying your home directory. Do you want to continue? [y/N]'
) )
); );

View File

@@ -1,4 +1,8 @@
// Register Jest matcher extensions for CLI unit tests
import './matchers';
import chalk from 'chalk'; import chalk from 'chalk';
import { PassThrough } from 'stream';
import { createServer, Server } from 'http'; import { createServer, Server } from 'http';
import express, { Express, Router } from 'express'; import express, { Express, Router } from 'express';
import listen from 'async-listen'; import listen from 'async-listen';
@@ -11,23 +15,42 @@ chalk.level = 0;
export type Scenario = Router; export type Scenario = Router;
class MockStream extends PassThrough {
isTTY: boolean;
constructor() {
super();
this.isTTY = true;
}
// These is for the `ora` module
clearLine() {}
cursorTo() {}
}
export class MockClient extends Client { export class MockClient extends Client {
mockServer?: Server; stdin!: MockStream;
mockOutput: jest.Mock<void, Parameters<Output['print']>>; stdout!: MockStream;
private app: Express; stderr!: MockStream;
scenario: Scenario; scenario: Scenario;
mockServer?: Server;
private app: Express;
constructor() { constructor() {
super({ super({
argv: [],
// Gets populated in `startMockServer()` // Gets populated in `startMockServer()`
apiUrl: '', apiUrl: '',
// Gets re-initialized for every test in `reset()`
argv: [],
authConfig: {}, authConfig: {},
output: new Output(),
config: {}, config: {},
localConfig: {}, localConfig: {},
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
output: new Output(new PassThrough()),
}); });
this.mockOutput = jest.fn();
this.app = express(); this.app = express();
this.app.use(express.json()); this.app.use(express.json());
@@ -53,27 +76,29 @@ export class MockClient extends Client {
} }
reset() { reset() {
this.output = new Output(); this.stdin = new MockStream();
this.mockOutput = jest.fn();
this.output.print = s => { this.stdout = new MockStream();
return this.mockOutput(s); this.stdout.setEncoding('utf8');
}; this.stdout.end = () => {};
this.stdout.pause();
this.stderr = new MockStream();
this.stderr.setEncoding('utf8');
this.stderr.end = () => {};
this.stderr.pause();
this.stderr.isTTY = true;
this._createPromptModule();
this.output = new Output(this.stderr);
this.argv = []; this.argv = [];
this.authConfig = {}; this.authConfig = {};
this.config = {}; this.config = {};
this.localConfig = {}; this.localConfig = {};
// Just make this one silent
this.output.spinner = () => {};
this.scenario = Router(); this.scenario = Router();
this.output.isTTY = true;
}
get outputBuffer() {
return this.mockOutput.mock.calls.map(c => c[0]).join('');
} }
async startMockServer() { async startMockServer() {

View File

@@ -0,0 +1,65 @@
/**
* This file registers the custom Jest "matchers" that are useful for
* writing CLI unit tests, and sets them up to be recognized by TypeScript.
*
* References:
* - https://haspar.us/notes/adding-jest-custom-matchers-in-typescript
* - https://gist.github.com/hasparus/4ebaa17ec5d3d44607f522bcb1cda9fb
*/
/// <reference types="@types/jest" />
import * as matchers from './matchers';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type Tail<T extends unknown[]> = T extends [infer _Head, ...infer Tail]
? Tail
: never;
type AnyFunction = (...args: any[]) => any;
type PromiseFunction = (...args: any[]) => Promise<any>;
type GetMatcherType<TP, TResult> = TP extends PromiseFunction
? (...args: Tail<Parameters<TP>>) => Promise<TResult>
: TP extends AnyFunction
? (...args: Tail<Parameters<TP>>) => TResult
: TP;
//type T = GetMatcherType<typeof matchers['toOutput'], void>;
type GetMatchersType<TMatchers, TResult> = {
[P in keyof TMatchers]: GetMatcherType<TMatchers[P], TResult>;
};
type FirstParam<T extends AnyFunction> = Parameters<T>[0];
type OnlyMethodsWhereFirstArgIsOfType<TObject, TWantedFirstArg> = {
[P in keyof TObject]: TObject[P] extends AnyFunction
? TWantedFirstArg extends FirstParam<TObject[P]>
? TObject[P]
: [
`Error: this function is present only when received is:`,
FirstParam<TObject[P]>
]
: TObject[P];
};
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Matchers<R, T = {}>
extends GetMatchersType<
OnlyMethodsWhereFirstArgIsOfType<typeof matchers, T>,
R
> {}
}
}
const jestExpect = (global as any).expect;
if (jestExpect !== undefined) {
jestExpect.extend(matchers);
} else {
console.error("Couldn't find Jest's global expect.");
}

View File

@@ -0,0 +1 @@
export * from './to-output';

View File

@@ -0,0 +1,67 @@
import {
getLabelPrinter,
matcherHint,
printExpected,
printReceived,
} from 'jest-matcher-utils';
import type { Readable } from 'stream';
import type { MatcherState } from 'expect';
import type { MatcherHintOptions } from 'jest-matcher-utils';
export async function toOutput(
this: MatcherState,
stream: Readable,
test: string,
timeout = 3000
) {
const { isNot } = this;
const matcherName = 'toOutput';
const matcherHintOptions: MatcherHintOptions = {
isNot,
promise: this.promise,
};
return new Promise(resolve => {
let output = '';
let timeoutId = setTimeout(onTimeout, timeout);
const message = () => {
const labelExpected = 'Expected output';
const labelReceived = 'Received output';
const printLabel = getLabelPrinter(labelExpected, labelReceived);
const hint =
matcherHint(matcherName, 'stream', 'test', matcherHintOptions) + '\n\n';
return (
hint +
printLabel(labelExpected) +
(isNot ? 'not ' : '') +
printExpected(test) +
'\n' +
printLabel(labelReceived) +
(isNot ? ' ' : '') +
printReceived(output)
);
};
function onData(data: string) {
output += data;
if (output.includes(test)) {
cleanup();
resolve({ pass: true, message });
}
}
function onTimeout() {
cleanup();
resolve({ pass: false, message });
}
function cleanup() {
clearTimeout(timeoutId);
stream.removeListener('data', onData);
stream.pause();
}
stream.on('data', onData);
stream.resume();
});
}

View File

@@ -10,38 +10,38 @@ import { useUser } from '../../mocks/user';
describe('deploy', () => { describe('deploy', () => {
it('should reject deploying a single file', async () => { it('should reject deploying a single file', async () => {
client.setArgv('deploy', __filename); client.setArgv('deploy', __filename);
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
`Error! Support for single file deployments has been removed.\nLearn More: https://vercel.link/no-single-file-deployments\n` `Error! Support for single file deployments has been removed.\nLearn More: https://vercel.link/no-single-file-deployments\n`
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying multiple files', async () => { it('should reject deploying multiple files', async () => {
client.setArgv('deploy', __filename, join(__dirname, 'inspect.test.ts')); client.setArgv('deploy', __filename, join(__dirname, 'inspect.test.ts'));
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
`Error! Can't deploy more than one path.\n` `Error! Can't deploy more than one path.\n`
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying a directory that does not exist', async () => { it('should reject deploying a directory that does not exist', async () => {
client.setArgv('deploy', 'does-not-exists'); client.setArgv('deploy', 'does-not-exists');
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
`Error! The specified file or directory "does-not-exists" does not exist.\n` `Error! The specified file or directory "does-not-exists" does not exist.\n`
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying a directory that does not contain ".vercel/output" when `--prebuilt` is used', async () => { it('should reject deploying a directory that does not contain ".vercel/output" when `--prebuilt` is used', async () => {
client.setArgv('deploy', __dirname, '--prebuilt'); client.setArgv('deploy', __dirname, '--prebuilt');
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! The "--prebuilt" option was used, but no prebuilt output found in ".vercel/output". Run `vercel build` to generate a local build.\n' 'Error! The "--prebuilt" option was used, but no prebuilt output found in ".vercel/output". Run `vercel build` to generate a local build.\n'
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying a directory that was built with a different target environment when `--prebuilt --prod` is used on "preview" output', async () => { it('should reject deploying a directory that was built with a different target environment when `--prebuilt --prod` is used on "preview" output', async () => {
@@ -56,14 +56,14 @@ describe('deploy', () => {
}); });
client.setArgv('deploy', cwd, '--prebuilt', '--prod'); client.setArgv('deploy', cwd, '--prebuilt', '--prod');
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! The "--prebuilt" option was used with the target environment "production",' + 'Error! The "--prebuilt" option was used with the target environment "production",' +
' but the prebuilt output found in ".vercel/output" was built with target environment "preview".' + ' but the prebuilt output found in ".vercel/output" was built with target environment "preview".' +
' Please run `vercel --prebuilt`.\n' + ' Please run `vercel --prebuilt`.\n' +
'Learn More: https://vercel.link/prebuilt-environment-mismatch\n' 'Learn More: https://vercel.link/prebuilt-environment-mismatch\n'
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying a directory that was built with a different target environment when `--prebuilt` is used on "production" output', async () => { it('should reject deploying a directory that was built with a different target environment when `--prebuilt` is used on "production" output', async () => {
@@ -78,14 +78,14 @@ describe('deploy', () => {
}); });
client.setArgv('deploy', cwd, '--prebuilt'); client.setArgv('deploy', cwd, '--prebuilt');
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! The "--prebuilt" option was used with the target environment "preview",' + 'Error! The "--prebuilt" option was used with the target environment "preview",' +
' but the prebuilt output found in ".vercel/output" was built with target environment "production".' + ' but the prebuilt output found in ".vercel/output" was built with target environment "production".' +
' Please run `vercel --prebuilt --prod`.\n' + ' Please run `vercel --prebuilt --prod`.\n' +
'Learn More: https://vercel.link/prebuilt-environment-mismatch\n' 'Learn More: https://vercel.link/prebuilt-environment-mismatch\n'
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying "version: 1"', async () => { it('should reject deploying "version: 1"', async () => {
@@ -94,11 +94,11 @@ describe('deploy', () => {
[fileNameSymbol]: 'vercel.json', [fileNameSymbol]: 'vercel.json',
version: 1, version: 1,
}; };
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! The value of the `version` property within vercel.json can only be `2`.\n' 'Error! The value of the `version` property within vercel.json can only be `2`.\n'
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should reject deploying "version: {}"', async () => { it('should reject deploying "version: {}"', async () => {
@@ -108,10 +108,10 @@ describe('deploy', () => {
// @ts-ignore // @ts-ignore
version: {}, version: {},
}; };
const exitCode = await deploy(client); const exitCodePromise = deploy(client);
expect(exitCode).toEqual(1); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! The `version` property inside your vercel.json file must be a number.\n' 'Error! The `version` property inside your vercel.json file must be a number.\n'
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
}); });

View File

@@ -19,8 +19,12 @@ describe('env', () => {
name: 'vercel-env-pull', name: 'vercel-env-pull',
}); });
client.setArgv('env', 'pull', '--yes', '--cwd', cwd); client.setArgv('env', 'pull', '--yes', '--cwd', cwd);
const exitCode = await env(client); const exitCodePromise = env(client);
expect(exitCode, client.outputBuffer).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "development" Environment Variables for Project vercel-env-pull'
);
await expect(client.stderr).toOutput('Created .env file');
await expect(exitCodePromise).resolves.toEqual(0);
const rawDevEnv = await fs.readFile(path.join(cwd, '.env')); const rawDevEnv = await fs.readFile(path.join(cwd, '.env'));
@@ -39,8 +43,12 @@ describe('env', () => {
name: 'vercel-env-pull', name: 'vercel-env-pull',
}); });
client.setArgv('env', 'pull', 'other.env', '--yes', '--cwd', cwd); client.setArgv('env', 'pull', 'other.env', '--yes', '--cwd', cwd);
const exitCode = await env(client); const exitCodePromise = env(client);
expect(exitCode, client.outputBuffer).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "development" Environment Variables for Project vercel-env-pull'
);
await expect(client.stderr).toOutput('Created other.env file');
await expect(exitCodePromise).resolves.toEqual(0);
const rawDevEnv = await fs.readFile(path.join(cwd, 'other.env')); const rawDevEnv = await fs.readFile(path.join(cwd, 'other.env'));
@@ -61,8 +69,12 @@ describe('env', () => {
}); });
client.setArgv('env', 'pull', 'other.env', '--yes', '--cwd', cwd); client.setArgv('env', 'pull', 'other.env', '--yes', '--cwd', cwd);
const exitCode = await env(client); const exitCodePromise = env(client);
expect(exitCode, client.outputBuffer).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "development" Environment Variables for Project vercel-env-pull'
);
await expect(client.stderr).toOutput('Created other.env file');
await expect(exitCodePromise).resolves.toEqual(0);
const rawDevEnv = await fs.readFile(path.join(cwd, 'other.env')); const rawDevEnv = await fs.readFile(path.join(cwd, 'other.env'));

View File

@@ -10,11 +10,9 @@ describe('inspect', () => {
client.setArgv('inspect', deployment.url); client.setArgv('inspect', deployment.url);
const exitCode = await inspect(client); const exitCode = await inspect(client);
expect(exitCode).toEqual(0); expect(exitCode).toEqual(0);
expect( await expect(client.stderr).toOutput(
client.mockOutput.mock.calls[0][0].startsWith( `> Fetched deployment "${deployment.url}" in ${user.username}`
`> Fetched deployment "${deployment.url}" in ${user.username}` );
)
).toBeTruthy();
}); });
it('should print error when deployment not found', async () => { it('should print error when deployment not found', async () => {
@@ -23,7 +21,7 @@ describe('inspect', () => {
client.setArgv('inspect', 'bad.com'); client.setArgv('inspect', 'bad.com');
const exitCode = await inspect(client); const exitCode = await inspect(client);
expect(exitCode).toEqual(1); expect(exitCode).toEqual(1);
expect(client.outputBuffer).toEqual( await expect(client.stderr).toOutput(
`Error! Failed to find deployment "bad.com" in ${user.username}\n` `Error! Failed to find deployment "bad.com" in ${user.username}\n`
); );
}); });

View File

@@ -5,22 +5,45 @@ import { useUser } from '../../mocks/user';
describe('login', () => { describe('login', () => {
it('should not allow the `--token` flag', async () => { it('should not allow the `--token` flag', async () => {
client.setArgv('login', '--token', 'foo'); client.setArgv('login', '--token', 'foo');
const exitCode = await login(client); const exitCodePromise = login(client);
expect(exitCode).toEqual(2); await expect(client.stderr).toOutput(
expect(client.outputBuffer).toEqual(
'Error! `--token` may not be used with the "login" command\n' 'Error! `--token` may not be used with the "login" command\n'
); );
await expect(exitCodePromise).resolves.toEqual(2);
}); });
it('should allow login via email as argument', async () => { it('should allow login via email as argument', async () => {
const user = useUser(); const user = useUser();
client.setArgv('login', user.email); client.setArgv('login', user.email);
const exitCode = await login(client); const exitCodePromise = login(client);
expect(exitCode).toEqual(0); await expect(client.stderr).toOutput(
expect( `Success! Email authentication complete for ${user.email}`
client.outputBuffer.includes( );
await expect(exitCodePromise).resolves.toEqual(0);
});
describe('interactive', () => {
it('should allow login via email', async () => {
const user = useUser();
client.setArgv('login');
const exitCodePromise = login(client);
await expect(client.stderr).toOutput(`> Log in to Vercel`);
// Move down to "Email" option
client.stdin.write('\x1B[B'); // Down arrow
client.stdin.write('\x1B[B'); // Down arrow
client.stdin.write('\x1B[B'); // Down arrow
client.stdin.write('\r'); // Return key
await expect(client.stderr).toOutput('> Enter your email address:');
client.stdin.write(`${user.email}\n`);
await expect(client.stderr).toOutput(
`Success! Email authentication complete for ${user.email}` `Success! Email authentication complete for ${user.email}`
) );
).toEqual(true);
await expect(exitCodePromise).resolves.toEqual(0);
});
}); });
}); });

View File

@@ -18,8 +18,17 @@ describe('pull', () => {
name: 'vercel-pull-next', name: 'vercel-pull-next',
}); });
client.setArgv('pull', cwd); client.setArgv('pull', cwd);
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode, client.outputBuffer).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "development" Environment Variables for Project vercel-pull-next'
);
await expect(client.stderr).toOutput(
`Created .vercel${path.sep}.env.development.local file`
);
await expect(client.stderr).toOutput(
`Downloaded project settings to .vercel${path.sep}project.json`
);
await expect(exitCodePromise).resolves.toEqual(0);
const rawDevEnv = await fs.readFile( const rawDevEnv = await fs.readFile(
path.join(cwd, '.vercel', '.env.development.local') path.join(cwd, '.vercel', '.env.development.local')
@@ -29,23 +38,18 @@ describe('pull', () => {
}); });
it('should fail with message to pull without a link and without --env', async () => { it('should fail with message to pull without a link and without --env', async () => {
try { client.stdin.isTTY = false;
process.stdout.isTTY = undefined;
const cwd = setupFixture('vercel-pull-unlinked'); const cwd = setupFixture('vercel-pull-unlinked');
useUser(); useUser();
useTeams('team_dummy'); useTeams('team_dummy');
client.setArgv('pull', cwd); client.setArgv('pull', cwd);
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode, client.outputBuffer).toEqual(1); await expect(client.stderr).toOutput(
'Command `vercel pull` requires confirmation. Use option "--yes" to confirm.'
expect(client.outputBuffer).toMatch( );
/Command `vercel pull` requires confirmation. Use option "--yes" to confirm./gm await expect(exitCodePromise).resolves.toEqual(1);
);
} finally {
process.stdout.isTTY = true;
}
}); });
it('should fail without message to pull without a link and with --env', async () => { it('should fail without message to pull without a link and with --env', async () => {
@@ -54,12 +58,11 @@ describe('pull', () => {
useTeams('team_dummy'); useTeams('team_dummy');
client.setArgv('pull', cwd, '--yes'); client.setArgv('pull', cwd, '--yes');
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode, client.outputBuffer).toEqual(1); await expect(client.stderr).not.toOutput(
'Command `vercel pull` requires confirmation. Use option "--yes" to confirm.'
expect(client.outputBuffer).not.toMatch(
/Command `vercel pull` requires confirmation. Use option "--yes" to confirm./gm
); );
await expect(exitCodePromise).resolves.toEqual(1);
}); });
it('should handle pulling with env vars (headless mode)', async () => { it('should handle pulling with env vars (headless mode)', async () => {
@@ -81,8 +84,17 @@ describe('pull', () => {
name: 'vercel-pull-next', name: 'vercel-pull-next',
}); });
client.setArgv('pull', cwd); client.setArgv('pull', cwd);
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode, client.outputBuffer).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "development" Environment Variables for Project vercel-pull-next'
);
await expect(client.stderr).toOutput(
`Created .vercel${path.sep}.env.development.local file`
);
await expect(client.stderr).toOutput(
`Downloaded project settings to .vercel${path.sep}project.json`
);
await expect(exitCodePromise).resolves.toEqual(0);
const config = await fs.readJSON(path.join(cwd, '.vercel/project.json')); const config = await fs.readJSON(path.join(cwd, '.vercel/project.json'));
expect(config).toMatchInlineSnapshot(` expect(config).toMatchInlineSnapshot(`
@@ -108,8 +120,17 @@ describe('pull', () => {
name: 'vercel-pull-next', name: 'vercel-pull-next',
}); });
client.setArgv('pull', '--environment=preview', cwd); client.setArgv('pull', '--environment=preview', cwd);
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "preview" Environment Variables for Project vercel-pull-next'
);
await expect(client.stderr).toOutput(
`Created .vercel${path.sep}.env.preview.local file`
);
await expect(client.stderr).toOutput(
`Downloaded project settings to .vercel${path.sep}project.json`
);
await expect(exitCodePromise).resolves.toEqual(0);
const rawPreviewEnv = await fs.readFile( const rawPreviewEnv = await fs.readFile(
path.join(cwd, '.vercel', '.env.preview.local') path.join(cwd, '.vercel', '.env.preview.local')
@@ -130,8 +151,17 @@ describe('pull', () => {
name: 'vercel-pull-next', name: 'vercel-pull-next',
}); });
client.setArgv('pull', '--environment=production', cwd); client.setArgv('pull', '--environment=production', cwd);
const exitCode = await pull(client); const exitCodePromise = pull(client);
expect(exitCode).toEqual(0); await expect(client.stderr).toOutput(
'Downloading "production" Environment Variables for Project vercel-pull-next'
);
await expect(client.stderr).toOutput(
`Created .vercel${path.sep}.env.production.local file`
);
await expect(client.stderr).toOutput(
`Downloaded project settings to .vercel${path.sep}project.json`
);
await expect(exitCodePromise).resolves.toEqual(0);
const rawProdEnv = await fs.readFile( const rawProdEnv = await fs.readFile(
path.join(cwd, '.vercel', '.env.production.local') path.join(cwd, '.vercel', '.env.production.local')

View File

@@ -14,14 +14,14 @@ describe('whoami', () => {
const user = useUser(); const user = useUser();
const exitCode = await whoami(client); const exitCode = await whoami(client);
expect(exitCode).toEqual(0); expect(exitCode).toEqual(0);
expect(client.outputBuffer).toEqual(`> ${user.username}\n`); await expect(client.stderr).toOutput(`> ${user.username}\n`);
}); });
it('should print only the Vercel username when output is not a TTY', async () => { it('should print only the Vercel username when output is not a TTY', async () => {
const user = useUser(); const user = useUser();
client.output.isTTY = false; client.stdout.isTTY = false;
const exitCode = await whoami(client); const exitCode = await whoami(client);
expect(exitCode).toEqual(0); expect(exitCode).toEqual(0);
expect(client.outputBuffer).toEqual(`${user.username}\n`); await expect(client.stdout).toOutput(`${user.username}\n`);
}); });
}); });

View File

@@ -0,0 +1,34 @@
import confirm from '../../../src/util/input/confirm';
import { client } from '../../mocks/client';
describe('confirm()', () => {
it('should work with multiple prompts', async () => {
// true (explicit)
let confirmedPromise = confirm(client, 'Explictly true?', false);
await expect(client.stderr).toOutput('Explictly true? [y/N]');
client.stdin.write('yes\n');
let confirmed = await confirmedPromise;
expect(confirmed).toEqual(true);
// false (explicit)
confirmedPromise = confirm(client, 'Explcitly false?', true);
await expect(client.stderr).toOutput('Explcitly false? [Y/n]');
client.stdin.write('no\n');
confirmed = await confirmedPromise;
expect(confirmed).toEqual(false);
// true (default)
confirmedPromise = confirm(client, 'Default true?', true);
await expect(client.stderr).toOutput('Default true? [Y/n]');
client.stdin.write('\n');
confirmed = await confirmedPromise;
expect(confirmed).toEqual(true);
// false (default)
confirmedPromise = confirm(client, 'Default false?', false);
await expect(client.stderr).toOutput('Default false? [y/N]');
client.stdin.write('\n');
confirmed = await confirmedPromise;
expect(confirmed).toEqual(false);
});
});

View File

@@ -22,10 +22,7 @@ describe('getRemoteUrl', () => {
client.output.debugEnabled = true; client.output.debugEnabled = true;
const data = await getRemoteUrl(join(dir, 'git/config'), client.output); const data = await getRemoteUrl(join(dir, 'git/config'), client.output);
expect(data).toBeNull(); expect(data).toBeNull();
expect( await expect(client.stderr).toOutput('Error while parsing repo data');
client.outputBuffer.includes('Error while parsing repo data'),
'Debug message was not found'
).toBeTruthy();
}); });
}); });

View File

@@ -1,18 +1,15 @@
import { join, sep } from 'path'; import { join, sep } from 'path';
// @ts-ignore - Missing types for "alpha-sort" // @ts-ignore - Missing types for "alpha-sort"
import { asc as alpha } from 'alpha-sort'; import { asc as alpha } from 'alpha-sort';
import createOutput from '../../../src/util/output';
import { staticFiles as getStaticFiles_ } from '../../../src/util/get-files'; import { staticFiles as getStaticFiles_ } from '../../../src/util/get-files';
import { client } from '../../mocks/client';
const output = createOutput({ debug: false });
const prefix = `${join(__dirname, '../../fixtures/unit')}${sep}`; const prefix = `${join(__dirname, '../../fixtures/unit')}${sep}`;
const base = (path: string) => path.replace(prefix, ''); const base = (path: string) => path.replace(prefix, '');
const fixture = (name: string) => join(prefix, name); const fixture = (name: string) => join(prefix, name);
const getStaticFiles = async (dir: string) => { const getStaticFiles = async (dir: string) => {
const files = await getStaticFiles_(dir, { const files = await getStaticFiles_(dir, client);
output,
});
return normalizeWindowsPaths(files); return normalizeWindowsPaths(files);
}; };

View File

@@ -1,13 +1,6 @@
import { Framework, frameworks } from '@vercel/frameworks'; import { Framework, frameworks } from '@vercel/frameworks';
import editProjectSettings from '../../../../src/util/input/edit-project-settings'; import editProjectSettings from '../../../../src/util/input/edit-project-settings';
import { Output } from '../../../../src/util/output'; import { client } from '../../../mocks/client';
let output: Output;
beforeEach(() => {
output = new Output();
output.print = jest.fn();
});
const otherFramework = frameworks.find( const otherFramework = frameworks.find(
fwk => fwk.name === 'Other' fwk => fwk.name === 'Other'
@@ -20,7 +13,7 @@ describe('editProjectSettings', () => {
describe('with no settings, "Other" framework, and no overrides provided', () => { describe('with no settings, "Other" framework, and no overrides provided', () => {
test('should default all settings to `null` and print user default framework settings', async () => { test('should default all settings to `null` and print user default framework settings', async () => {
const settings = await editProjectSettings( const settings = await editProjectSettings(
output, client,
null, null,
otherFramework, otherFramework,
true, true,
@@ -34,22 +27,13 @@ describe('editProjectSettings', () => {
installCommand: null, installCommand: null,
outputDirectory: null, outputDirectory: null,
}); });
expect((output.print as jest.Mock).mock.calls.length).toBe(5); await expect(client.stderr).toOutput(
expect((output.print as jest.Mock).mock.calls[0][0]).toMatch( 'No framework detected. Default Project Settings:'
/No framework detected. Default Project Settings:/
);
expect((output.print as jest.Mock).mock.calls[1][0]).toMatch(
/Build Command/
);
expect((output.print as jest.Mock).mock.calls[2][0]).toMatch(
/Development Command/
);
expect((output.print as jest.Mock).mock.calls[3][0]).toMatch(
/Install Command/
);
expect((output.print as jest.Mock).mock.calls[4][0]).toMatch(
/Output Directory/
); );
await expect(client.stderr).toOutput('Build Command');
await expect(client.stderr).toOutput('Development Command');
await expect(client.stderr).toOutput('Install Command');
await expect(client.stderr).toOutput('Output Directory');
}); });
}); });
@@ -63,29 +47,20 @@ describe('editProjectSettings', () => {
outputDirectory: 'OUTPUT_DIRECTORY', outputDirectory: 'OUTPUT_DIRECTORY',
}; };
const settings = await editProjectSettings( const settings = await editProjectSettings(
output, client,
projectSettings, projectSettings,
otherFramework, otherFramework,
true, true,
null null
); );
expect(settings).toStrictEqual({ ...projectSettings, framework: null }); expect(settings).toStrictEqual({ ...projectSettings, framework: null });
expect((output.print as jest.Mock).mock.calls.length).toBe(5); await expect(client.stderr).toOutput(
expect((output.print as jest.Mock).mock.calls[0][0]).toMatch( 'No framework detected. Default Project Settings:'
/No framework detected. Default Project Settings:/
);
expect((output.print as jest.Mock).mock.calls[1][0]).toMatch(
/Build Command/
);
expect((output.print as jest.Mock).mock.calls[2][0]).toMatch(
/Development Command/
);
expect((output.print as jest.Mock).mock.calls[3][0]).toMatch(
/Install Command/
);
expect((output.print as jest.Mock).mock.calls[4][0]).toMatch(
/Output Directory/
); );
await expect(client.stderr).toOutput('Build Command');
await expect(client.stderr).toOutput('Development Command');
await expect(client.stderr).toOutput('Install Command');
await expect(client.stderr).toOutput('Output Directory');
}); });
}); });
@@ -99,32 +74,21 @@ describe('editProjectSettings', () => {
outputDirectory: 'OUTPUT_DIRECTORY', outputDirectory: 'OUTPUT_DIRECTORY',
}; };
const settings = await editProjectSettings( const settings = await editProjectSettings(
output, client,
projectSettings, projectSettings,
nextJSFramework, nextJSFramework,
true, true,
null null
); );
expect((output.print as jest.Mock).mock.calls.length).toBe(5);
expect((output.print as jest.Mock).mock.calls[0][0]).toMatch(
/Auto-detected Project Settings/
);
expect((output.print as jest.Mock).mock.calls[1][0]).toMatch(
/Build Command/
);
expect((output.print as jest.Mock).mock.calls[2][0]).toMatch(
/Development Command/
);
expect((output.print as jest.Mock).mock.calls[3][0]).toMatch(
/Install Command/
);
expect((output.print as jest.Mock).mock.calls[4][0]).toMatch(
/Output Directory/
);
expect(settings).toStrictEqual({ expect(settings).toStrictEqual({
...projectSettings, ...projectSettings,
framework: nextJSFramework.slug, framework: nextJSFramework.slug,
}); });
await expect(client.stderr).toOutput('Auto-detected Project Settings');
await expect(client.stderr).toOutput('Build Command');
await expect(client.stderr).toOutput('Development Command');
await expect(client.stderr).toOutput('Install Command');
await expect(client.stderr).toOutput('Output Directory');
}); });
}); });
@@ -146,42 +110,26 @@ describe('editProjectSettings', () => {
outputDirectory: 'OUTPUT_DIRECTORY', outputDirectory: 'OUTPUT_DIRECTORY',
}; };
const settings = await editProjectSettings( const settings = await editProjectSettings(
output, client,
projectSettings, projectSettings,
nextJSFramework, nextJSFramework,
true, true,
overrides overrides
); );
expect((output.print as jest.Mock).mock.calls.length).toBe(9);
expect((output.print as jest.Mock).mock.calls[0][0]).toMatch(
/Local settings detected in vercel.json:/
);
expect((output.print as jest.Mock).mock.calls[1][0]).toMatch(
/Build Command:/
);
expect((output.print as jest.Mock).mock.calls[2][0]).toMatch(
/Ignore Command:/
);
expect((output.print as jest.Mock).mock.calls[3][0]).toMatch(
/Development Command:/
);
expect((output.print as jest.Mock).mock.calls[4][0]).toMatch(
/Framework:/
);
expect((output.print as jest.Mock).mock.calls[5][0]).toMatch(
/Install Command:/
);
expect((output.print as jest.Mock).mock.calls[6][0]).toMatch(
/Output Directory:/
);
expect((output.print as jest.Mock).mock.calls[7][0]).toMatch(
/Merging default Project Settings for Svelte. Previously listed overrides are prioritized./
);
expect((output.print as jest.Mock).mock.calls[8][0]).toMatch(
/Auto-detected Project Settings/
);
expect(settings).toStrictEqual(overrides); expect(settings).toStrictEqual(overrides);
await expect(client.stderr).toOutput(
'Local settings detected in vercel.json:'
);
await expect(client.stderr).toOutput('Build Command:');
await expect(client.stderr).toOutput('Ignore Command:');
await expect(client.stderr).toOutput('Development Command:');
await expect(client.stderr).toOutput('Framework:');
await expect(client.stderr).toOutput('Install Command:');
await expect(client.stderr).toOutput('Output Directory:');
await expect(client.stderr).toOutput(
'Merging default Project Settings for Svelte. Previously listed overrides are prioritized.'
);
await expect(client.stderr).toOutput('Auto-detected Project Settings');
}); });
}); });
@@ -196,41 +144,26 @@ describe('editProjectSettings', () => {
outputDirectory: 'OUTPUT_DIRECTORY', outputDirectory: 'OUTPUT_DIRECTORY',
}; };
const settings = await editProjectSettings( const settings = await editProjectSettings(
output, client,
null, null,
nextJSFramework, nextJSFramework,
true, true,
overrides overrides
); );
expect((output.print as jest.Mock).mock.calls.length).toBe(9);
expect((output.print as jest.Mock).mock.calls[0][0]).toMatch(
/Local settings detected in vercel.json:/
);
expect((output.print as jest.Mock).mock.calls[1][0]).toMatch(
/Build Command:/
);
expect((output.print as jest.Mock).mock.calls[2][0]).toMatch(
/Ignore Command:/
);
expect((output.print as jest.Mock).mock.calls[3][0]).toMatch(
/Development Command:/
);
expect((output.print as jest.Mock).mock.calls[4][0]).toMatch(
/Framework:/
);
expect((output.print as jest.Mock).mock.calls[5][0]).toMatch(
/Install Command:/
);
expect((output.print as jest.Mock).mock.calls[6][0]).toMatch(
/Output Directory:/
);
expect((output.print as jest.Mock).mock.calls[7][0]).toMatch(
/Merging default Project Settings for Svelte. Previously listed overrides are prioritized./
);
expect((output.print as jest.Mock).mock.calls[8][0]).toMatch(
/Auto-detected Project Settings/
);
expect(settings).toStrictEqual(overrides); expect(settings).toStrictEqual(overrides);
await expect(client.stderr).toOutput(
'Local settings detected in vercel.json:'
);
await expect(client.stderr).toOutput('Build Command:');
await expect(client.stderr).toOutput('Ignore Command:');
await expect(client.stderr).toOutput('Development Command:');
await expect(client.stderr).toOutput('Framework:');
await expect(client.stderr).toOutput('Install Command:');
await expect(client.stderr).toOutput('Output Directory:');
await expect(client.stderr).toOutput(
'Merging default Project Settings for Svelte. Previously listed overrides are prioritized.'
);
await expect(client.stderr).toOutput('Auto-detected Project Settings');
}); });
}); });
@@ -245,42 +178,26 @@ describe('editProjectSettings', () => {
outputDirectory: 'OUTPUT_DIRECTORY', outputDirectory: 'OUTPUT_DIRECTORY',
}; };
const settings = await editProjectSettings( const settings = await editProjectSettings(
output, client,
null, null,
null, null,
true, true,
overrides overrides
); );
expect((output.print as jest.Mock).mock.calls.length).toBe(9);
expect((output.print as jest.Mock).mock.calls[0][0]).toMatch(
/Local settings detected in vercel.json:/
);
expect((output.print as jest.Mock).mock.calls[1][0]).toMatch(
/Build Command:/
);
expect((output.print as jest.Mock).mock.calls[2][0]).toMatch(
/Ignore Command:/
);
expect((output.print as jest.Mock).mock.calls[3][0]).toMatch(
/Development Command:/
);
expect((output.print as jest.Mock).mock.calls[4][0]).toMatch(
/Framework:/
);
expect((output.print as jest.Mock).mock.calls[5][0]).toMatch(
/Install Command:/
);
expect((output.print as jest.Mock).mock.calls[6][0]).toMatch(
/Output Directory:/
);
expect((output.print as jest.Mock).mock.calls[7][0]).toMatch(
/Merging default Project Settings for Svelte. Previously listed overrides are prioritized./
);
expect((output.print as jest.Mock).mock.calls[8][0]).toMatch(
/Auto-detected Project Settings/
);
expect(settings).toStrictEqual(overrides); expect(settings).toStrictEqual(overrides);
await expect(client.stderr).toOutput(
'Local settings detected in vercel.json:'
);
await expect(client.stderr).toOutput('Build Command:');
await expect(client.stderr).toOutput('Ignore Command:');
await expect(client.stderr).toOutput('Development Command:');
await expect(client.stderr).toOutput('Framework:');
await expect(client.stderr).toOutput('Install Command:');
await expect(client.stderr).toOutput('Output Directory:');
await expect(client.stderr).toOutput(
'Merging default Project Settings for Svelte. Previously listed overrides are prioritized.'
);
await expect(client.stderr).toOutput('Auto-detected Project Settings');
}); });
}); });
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@vercel/client", "name": "@vercel/client",
"version": "12.0.2", "version": "12.0.4",
"main": "dist/index.js", "main": "dist/index.js",
"typings": "dist/index.d.ts", "typings": "dist/index.d.ts",
"homepage": "https://vercel.com", "homepage": "https://vercel.com",
@@ -42,7 +42,8 @@
] ]
}, },
"dependencies": { "dependencies": {
"@vercel/build-utils": "4.2.0", "@vercel/build-utils": "5.0.0",
"@vercel/routing-utils": "1.13.5",
"@zeit/fetch": "5.2.0", "@zeit/fetch": "5.2.0",
"async-retry": "1.2.3", "async-retry": "1.2.3",
"async-sema": "3.0.0", "async-sema": "3.0.0",

View File

@@ -1,9 +1,9 @@
import { import type {
Builder, Builder,
BuilderFunctions, BuilderFunctions,
ProjectSettings, ProjectSettings,
} from '@vercel/build-utils'; } from '@vercel/build-utils';
import { Header, Route, Redirect, Rewrite } from '@vercel/routing-utils'; import type { Header, Route, Redirect, Rewrite } from '@vercel/routing-utils';
export { DeploymentEventType } from './utils'; export { DeploymentEventType } from './utils';

1
packages/fs-detectors/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/dist

View File

@@ -0,0 +1,5 @@
/** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

View File

@@ -0,0 +1,40 @@
{
"name": "@vercel/fs-detectors",
"version": "1.0.0",
"description": "Vercel filesystem detectors",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "https://github.com/vercel/vercel.git",
"directory": "packages/fs-detectors"
},
"license": "MIT",
"scripts": {
"prepublishOnly": "tsc",
"build": "tsc",
"test": "yarn jest --env node --verbose --runInBand --bail test/unit.*test.*",
"test-unit": "yarn test"
},
"dependencies": {
"@vercel/frameworks": "1.0.2",
"@vercel/routing-utils": "1.13.5",
"glob": "8.0.3",
"js-yaml": "4.1.0",
"minimatch": "3.0.4",
"semver": "6.1.1"
},
"devDependencies": {
"@types/glob": "7.2.0",
"@types/jest": "27.5.1",
"@types/js-yaml": "4.0.5",
"@types/minimatch": "3.0.5",
"@types/node": "12.12.20",
"@types/semver": "7.3.10",
"@vercel/build-utils": "4.2.0",
"typescript": "4.3.4"
}
}

View File

@@ -1,16 +1,16 @@
import minimatch from 'minimatch'; import minimatch from 'minimatch';
import { valid as validSemver } from 'semver'; import { valid as validSemver } from 'semver';
import { parse as parsePath, extname } from 'path'; import { parse as parsePath, extname } from 'path';
import { Route, Source } from '@vercel/routing-utils'; import type { Route, Source } from '@vercel/routing-utils';
import frameworkList, { Framework } from '@vercel/frameworks'; import frameworkList, { Framework } from '@vercel/frameworks';
import { import type {
PackageJson, PackageJson,
Builder, Builder,
Config, Config,
BuilderFunctions, BuilderFunctions,
ProjectSettings, ProjectSettings,
} from './types'; } from '@vercel/build-utils';
import { isOfficialRuntime } from './'; import { isOfficialRuntime } from './is-official-runtime';
const slugToFramework = new Map<string | null, Framework>( const slugToFramework = new Map<string | null, Framework>(
frameworkList.map(f => [f.slug, f]) frameworkList.map(f => [f.slug, f])
); );

View File

@@ -5,7 +5,7 @@ import type {
BuilderFunctions, BuilderFunctions,
PackageJson, PackageJson,
ProjectSettings, ProjectSettings,
} from './types'; } from '@vercel/build-utils';
interface Metadata { interface Metadata {
plugins: string[]; plugins: string[];

View File

@@ -1,4 +1,4 @@
import { Framework, FrameworkDetectionItem } from '@vercel/frameworks'; import type { Framework, FrameworkDetectionItem } from '@vercel/frameworks';
import { DetectorFilesystem } from './detectors/filesystem'; import { DetectorFilesystem } from './detectors/filesystem';
interface BaseFramework { interface BaseFramework {

View File

@@ -0,0 +1,23 @@
export {
detectBuilders,
detectOutputDirectory,
detectApiDirectory,
detectApiExtensions,
} from './detect-builders';
export { detectFileSystemAPI } from './detect-file-system-api';
export { detectFramework } from './detect-framework';
export { getProjectPaths } from './get-project-paths';
export { DetectorFilesystem } from './detectors/filesystem';
export { workspaceManagers } from './workspaces/workspace-managers';
export {
getWorkspaces,
GetWorkspaceOptions,
Workspace,
WorkspaceType,
} from './workspaces/get-workspaces';
export {
getWorkspacePackagePaths,
GetWorkspacePackagePathsOptions,
} from './workspaces/get-workspace-package-paths';
export { monorepoManagers } from './monorepos/monorepo-managers';
export { isOfficialRuntime, isStaticRuntime } from './is-official-runtime';

View File

@@ -0,0 +1,21 @@
/*
* Helper function to support both `@vercel` and legacy `@now` official Runtimes.
*/
export const isOfficialRuntime = (desired: string, name?: string): boolean => {
if (typeof name !== 'string') {
return false;
}
return (
name === `@vercel/${desired}` ||
name === `@now/${desired}` ||
name.startsWith(`@vercel/${desired}@`) ||
name.startsWith(`@now/${desired}@`)
);
};
/*
* Helper function to detect both `@vercel/static` and legacy `@now/static` official Runtimes.
*/
export const isStaticRuntime = (name?: string): boolean => {
return isOfficialRuntime('static', name);
};

View File

@@ -6,8 +6,8 @@ import type { Framework } from '@vercel/frameworks';
* This list is designed to work with the @see {@link detectFramework} function. * This list is designed to work with the @see {@link detectFramework} function.
* *
* @example * @example
* import { monorepoManagers as frameworkList } from '@vercel/build-utils' * import { monorepoManagers as frameworkList } from '@vercel/fs-detectors'
* import { detectFramework } from '@vercel/build-utils' * import { detectFramework } from '@vercel/fs-detectors'
* *
* const fs = new GitDetectorFilesystem(...) * const fs = new GitDetectorFilesystem(...)
* detectFramwork({ fs, frameworkList }) // returns the 'slug' field if detected, otherwise null * detectFramwork({ fs, frameworkList }) // returns the 'slug' field if detected, otherwise null

View File

@@ -3,7 +3,7 @@ import { DetectorFilesystem } from '../detectors/filesystem';
type GlobFs = typeof fs; type GlobFs = typeof fs;
function normalizePath(path: string) { function removeWindowsPrefix(path: string) {
// on windows, this will return a path like // on windows, this will return a path like
// D:/c/package.json // D:/c/package.json
// since we abstract the filesystem, we need to remove windows specific info from the path // since we abstract the filesystem, we need to remove windows specific info from the path
@@ -18,7 +18,7 @@ export function getGlobFs(_fs: DetectorFilesystem): GlobFs {
callback: (err: NodeJS.ErrnoException | null, files: string[]) => void callback: (err: NodeJS.ErrnoException | null, files: string[]) => void
): void => { ): void => {
_fs _fs
.readdir(normalizePath(String(path))) .readdir(removeWindowsPrefix(String(path)))
.then(stats => .then(stats =>
callback( callback(
null, null,
@@ -36,7 +36,7 @@ export function getGlobFs(_fs: DetectorFilesystem): GlobFs {
) => void ) => void
): void => { ): void => {
_fs _fs
.isFile(normalizePath(String(path))) .isFile(removeWindowsPrefix(String(path)))
.then(isPathAFile => { .then(isPathAFile => {
callback(null, { callback(null, {
ino: 0, ino: 0,

Some files were not shown because too many files have changed in this diff Show More