Compare commits

..

19 Commits

Author SHA1 Message Date
Nathan Rajlich
1edc2d06c9 Publish Stable
- @vercel/hydrogen@0.0.1
2022-07-06 13:51:43 -07:00
Nathan Rajlich
fdb15b2539 [hydrogen] Add @vercel/hydrogen Builder (#8071)
Adds a new `@vercel/hydrogen` Builder package so that Vercel can support Shopify Hydrogen projects with zero config. It outputs an Edge Function for the server-side render code and includes a catch-all route to invoke that function after a `handle: "filesystem"` to serve static files that were generated by the build command.

**Examples:**

 * [`hello-world-ts` template](https://hydrogen-hello-world-otm2vmw6w-tootallnate.vercel.app/)
 * [`demo-store-ts` template](https://hydrogen-demo-store-1gko2fst3-tootallnate.vercel.app/)
2022-07-06 20:06:45 +00:00
Matthew Stanciu
32ebcd83a7 [cli] Add vc project connect command (#8014)
This PR adds a new subcommand, `vc project connect`, which connects a Git provider repository to the current project. Previously, this could only be done via the Dashboard.

This is the first part of a larger project—the goal is to include this functionality within `vc link`, so that you never have to leave the CLI if you want to set up a new Vercel project that's connected to Git.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-06 19:49:08 +00:00
Matthew Stanciu
2e43b2b88a [cli] Remove redundant mock project endpoint (#8089)
- https://github.com/vercel/vercel/pull/8053

While writing tests for this PR, I added a mock project endpoint that always returned a default project. This was probably incorrect and no longer needed (tests pass without it). I should have removed it before merging #8053, but I didn't catch this before merging.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-06 19:17:33 +00:00
Matthew Stanciu
f83d432fcd [cli] MAJOR: Scope vc ls to linked project (#8053)
Currently, `vc ls` is scoped to your team, and you have to type out a project name if you want to see deployments for a project. This PR instead scopes it to the linked project.

Under these changes, `vc ls` still works similarly to how it currently works: users can still specify a project name to get the deployments for a project. The difference is that:

1. The selected team is the one that the linked project belongs to, instead of the one that the user has selected.
2. `vc ls` with no arguments displays the latest deployments for the linked project.

This is the first part of a larger effort to change the behavior of `vc ls`, plucked from https://github.com/vercel/vercel/pull/7993. More PRs to follow.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-06 18:42:51 +00:00
Matthew Stanciu
87fc38e860 [cli] MAJOR: remove vc deploy clipboard copy feature (#8085)
https://vercel.slack.com/archives/C03F2CMNGKG/p1656971502881949

Right now, `vc deploy` automatically copies the deploy url to your clipboard after the deployment has finished. You can opt out via the `--no-clipboard` flag, but the feature is enabled by default.

This is strange behavior—there's no indication that the CLI will hijack your clipboard, and you don't know it's been hijacked until after it happens.

This PR removes the clipboard copying feature as well as the `--no-clipboard` flag.

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-06 18:04:13 +00:00
JJ Kasper
afc4388fc0 [tests] Update canary dist-tag on publish (#8084)
This ensures we update the canary dist-tag when publishing stable releases as we no longer need to do separate canary publishes.  

### Related Issues

x-ref: [slack thread](https://vercel.slack.com/archives/C65QW9PN1/p1657049930618119?thread_ts=1656362480.574099&cid=C65QW9PN1)

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [ ] The code changed/added as part of this PR has been covered with tests
- [ ] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
2022-07-05 23:45:45 +00:00
Steven
3c48b40b43 Publish Canary
- @vercel/build-utils@5.0.1-canary.0
 - vercel@26.0.1-canary.1
 - @vercel/client@12.0.5-canary.0
 - @vercel/go@2.0.5-canary.0
 - @vercel/next@3.1.4-canary.1
 - @vercel/node@2.4.1-canary.0
 - @vercel/python@3.0.5-canary.0
 - @vercel/redwood@1.0.6-canary.0
 - @vercel/remix@1.0.6-canary.0
 - @vercel/ruby@1.3.13-canary.0
 - @vercel/static-build@1.0.5-canary.0
2022-07-05 17:44:26 -04:00
JJ Kasper
ce89f00328 Publish Canary
- vercel@26.0.1-canary.0
 - @vercel/next@3.1.4-canary.0
2022-07-05 14:17:51 -05:00
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
531 changed files with 93426 additions and 928 deletions

View File

@@ -19,6 +19,9 @@ packages/cli/src/util/dev/templates/*.ts
packages/client/tests/fixtures
packages/client/lib
# hydrogen
packages/hydrogen/edge-entry.js
# next
packages/next/test/integration/middleware
packages/next/test/integration/middleware-eval

View File

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

View File

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

View File

@@ -177,7 +177,7 @@ export async function getNodeBinPath({
cwd,
}: {
cwd: string;
}): Promise<string | undefined> {
}): Promise<string> {
const { code, stdout, stderr } = await execAsync('npm', ['bin'], {
cwd,
prettyCommand: 'npm bin',
@@ -233,10 +233,23 @@ export function getSpawnOptions(
};
if (!meta.isDev) {
// Ensure that the selected Node version is at the beginning of the `$PATH`
opts.env.PATH = `/node${nodeVersion.major}/bin${path.delimiter}${
opts.env.PATH || process.env.PATH
}`;
let found = false;
const oldPath = 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;
@@ -474,20 +487,31 @@ export function getEnvForPackageManager({
env: { [x: string]: string | undefined };
}) {
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 (
typeof lockfileVersion === 'number' &&
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`
newEnv.PATH = `/node16/bin-npm7${path.delimiter}${env.PATH}`;
console.log('Detected `package-lock.json` generated by npm 7...');
newEnv.PATH = `${npm7}${path.delimiter}${oldPath}`;
console.log('Detected `package-lock.json` generated by npm 7+...');
}
} 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`
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...');
}
} else {

View File

@@ -80,16 +80,6 @@ export {
};
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 { normalizePath } from './fs/normalize-path';
@@ -97,35 +87,3 @@ export * from './should-serve';
export * from './schemas';
export * from './types';
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,
// @ts-ignore
} from '../../../test/lib/deployment/test-deployment';
import { glob, detectBuilders } from '../src';
jest.setTimeout(4 * 60 * 1000);
@@ -32,11 +31,6 @@ const skipFixtures: string[] = [
'08-zero-config-middleman',
'21-npm-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
@@ -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

@@ -38,6 +38,38 @@ describe('Test `getEnvForPackageManager()`', () => {
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',
args: {
@@ -101,6 +133,38 @@ describe('Test `getEnvForPackageManager()`', () => {
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',
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

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

View File

@@ -1,6 +1,6 @@
{
"name": "vercel",
"version": "25.2.3",
"version": "26.0.1-canary.1",
"preferGlobal": true,
"license": "Apache-2.0",
"description": "The command-line interface for Vercel",
@@ -42,15 +42,15 @@
"node": ">= 14"
},
"dependencies": {
"@vercel/build-utils": "4.2.1",
"@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",
"@vercel/build-utils": "5.0.1-canary.0",
"@vercel/go": "2.0.5-canary.0",
"@vercel/next": "3.1.4-canary.1",
"@vercel/node": "2.4.1-canary.0",
"@vercel/python": "3.0.5-canary.0",
"@vercel/redwood": "1.0.6-canary.0",
"@vercel/remix": "1.0.6-canary.0",
"@vercel/ruby": "1.3.13-canary.0",
"@vercel/static-build": "1.0.5-canary.0",
"update-notifier": "5.1.0"
},
"devDependencies": {
@@ -95,8 +95,9 @@
"@types/which": "1.3.2",
"@types/write-json-file": "2.2.1",
"@types/yauzl-promise": "2.1.0",
"@vercel/client": "12.0.3",
"@vercel/client": "12.0.5-canary.0",
"@vercel/frameworks": "1.0.2",
"@vercel/fs-detectors": "1.0.0",
"@vercel/ncc": "0.24.0",
"@zeit/fun": "0.11.2",
"@zeit/source-map-support": "0.6.2",
@@ -113,7 +114,6 @@
"chalk": "4.1.0",
"chance": "1.1.7",
"chokidar": "3.3.1",
"clipboardy": "2.1.0",
"codecov": "3.8.2",
"cpy": "7.2.0",
"credit-card": "3.0.1",

View File

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

View File

@@ -174,7 +174,7 @@ export default async client => {
)} ${chalk.gray(`[${elapsed}]`)}`;
const choices = buildInquirerChoices(cards);
cardId = await listInput({
cardId = await listInput(client, {
message,
choices,
separator: true,
@@ -251,7 +251,7 @@ export default async client => {
)} under ${chalk.bold(contextName)} ${chalk.gray(`[${elapsed}]`)}`;
const choices = buildInquirerChoices(cards);
cardId = await listInput({
cardId = await listInput(client, {
message,
choices,
separator: true,

View File

@@ -14,7 +14,6 @@ import logo from '../../util/output/logo';
import getArgs from '../../util/get-args';
import Client from '../../util/client';
import { getPkgName } from '../../util/pkg-name';
import { Output } from '../../util/output';
import { Deployment, PaginationOptions } from '../../types';
import { normalizeURL } from '../../util/bisect/normalize-url';
@@ -86,10 +85,10 @@ export default async function main(client: Client): Promise<number> {
let 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 =
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 run = argv['--run'] || '';
const openEnabled = argv['--open'] || false;
@@ -143,7 +142,7 @@ export default async function main(client: Client): Promise<number> {
if (!subpath) {
subpath = await prompt(
output,
client,
`Specify the URL subpath where the bug occurs:`
);
}
@@ -391,10 +390,10 @@ function getCommit(deployment: DeploymentV6) {
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
while (true) {
const { val } = await inquirer.prompt({
const { val } = await client.prompt({
type: 'input',
name: 'val',
message,
@@ -402,7 +401,7 @@ async function prompt(output: Output, message: string): Promise<string> {
if (val) {
return val;
} 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 { join, normalize, relative, resolve } from 'path';
import {
detectBuilders,
normalizePath,
Files,
FileFsRef,
@@ -17,6 +16,7 @@ import {
BuildResultV3,
NowBuildError,
} from '@vercel/build-utils';
import { detectBuilders } from '@vercel/fs-detectors';
import minimatch from 'minimatch';
import {
appendRoutesToPhase,

View File

@@ -68,7 +68,6 @@ export const help = () => `
-m, --meta Add metadata for the deployment (e.g.: ${chalk.dim(
'`-m KEY=value`'
)}). Can appear many times.
-C, --no-clipboard Do not attempt to copy URL to clipboard
-S, --scope Set a custom scope
--regions Set default regions to enable the deployment on
--prod Create a production deployment

View File

@@ -10,7 +10,6 @@ import { readLocalConfig } from '../../util/config/files';
import getArgs from '../../util/get-args';
import { handleError } from '../../util/error';
import Client from '../../util/client';
import { write as copy } from 'clipboardy';
import { getPrettyError } from '@vercel/build-utils';
import toHumanPath from '../../util/humanize-path';
import Now from '../../util';
@@ -65,7 +64,7 @@ import { help } from './args';
import { getDeploymentChecks } from '../../util/deploy/get-deployment-checks';
import parseTarget from '../../util/deploy/parse-target';
import getPrebuiltJson from '../../util/deploy/get-prebuilt-json';
import { createGitMeta } from '../../util/deploy/create-git-meta';
import { createGitMeta } from '../../util/create-git-meta';
export default async (client: Client) => {
const { output } = client;
@@ -77,7 +76,6 @@ export default async (client: Client) => {
'--force': Boolean,
'--with-cache': Boolean,
'--public': Boolean,
'--no-clipboard': Boolean,
'--env': [String],
'--build-env': [String],
'--meta': [String],
@@ -91,7 +89,6 @@ export default async (client: Client) => {
'-p': '--public',
'-e': '--env',
'-b': '--build-env',
'-C': '--no-clipboard',
'-m': '--meta',
'-c': '--confirm',
@@ -160,9 +157,9 @@ 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
const pathValidation = await validatePaths(client, paths);
@@ -686,13 +683,7 @@ export default async (client: Client) => {
return 1;
}
return printDeploymentStatus(
output,
client,
deployment,
deployStamp,
!argv['--no-clipboard']
);
return printDeploymentStatus(output, client, deployment, deployStamp);
};
function handleCreateDeployError(
@@ -825,8 +816,7 @@ const printDeploymentStatus = async (
action?: string;
};
},
deployStamp: () => string,
isClipboardEnabled: boolean
deployStamp: () => string
) => {
indications = indications || [];
const isProdDeployment = target === 'production';
@@ -847,40 +837,23 @@ const printDeploymentStatus = async (
} else {
// print preview/production url
let previewUrl: string;
let isWildcard: boolean;
if (Array.isArray(aliasList) && aliasList.length > 0) {
const previewUrlInfo = await getPreferredPreviewURL(client, aliasList);
if (previewUrlInfo) {
isWildcard = previewUrlInfo.isWildcard;
previewUrl = previewUrlInfo.previewUrl;
} else {
isWildcard = false;
previewUrl = `https://${deploymentUrl}`;
}
} else {
// fallback to deployment url
isWildcard = false;
previewUrl = `https://${deploymentUrl}`;
}
// copy to clipboard
let isCopiedToClipboard = false;
if (isClipboardEnabled && !isWildcard) {
try {
await copy(previewUrl);
isCopiedToClipboard = true;
} catch (err) {
output.debug(`Error copyind to clipboard: ${err}`);
}
}
output.print(
prependEmoji(
`${isProdDeployment ? 'Production' : 'Preview'}: ${chalk.bold(
previewUrl
)}${
isCopiedToClipboard ? chalk.gray(` [copied to clipboard]`) : ''
} ${deployStamp()}`,
)} ${deployStamp()}`,
emoji('success')
) + `\n`
);

View File

@@ -46,7 +46,11 @@ export default async function init(
const exampleList = examples.filter(x => x.visible).map(x => x.name);
if (!name) {
const chosen = await chooseFromDropdown('Select example:', exampleList);
const chosen = await chooseFromDropdown(
client,
'Select example:',
exampleList
);
if (!chosen) {
output.log('Aborted');
@@ -90,14 +94,18 @@ async function fetchExampleList(client: Client) {
/**
* 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 => ({
name,
value: name,
short: name,
}));
return listInput({
return listInput(client, {
message,
choices,
});

View File

@@ -8,7 +8,6 @@ import cmd from '../util/output/cmd';
import logo from '../util/output/logo';
import elapsed from '../util/output/elapsed';
import strlen from '../util/strlen';
import getScope from '../util/get-scope';
import toHost from '../util/to-host';
import parseMeta from '../util/parse-meta';
import { isValidName } from '../util/is-valid-name';
@@ -16,6 +15,10 @@ import getCommandFlags from '../util/get-command-flags';
import { getPkgName, getCommandName } from '../util/pkg-name';
import Client from '../util/client';
import { Deployment } from '../types';
import validatePaths from '../util/validate-paths';
import { getLinkedProject } from '../util/projects/link';
import { ensureLink } from '../util/ensure-link';
import getScope from '../util/get-scope';
const help = () => {
console.log(`
@@ -31,6 +34,7 @@ const help = () => {
'DIR'
)} Path to the global ${'`.vercel`'} directory
-d, --debug Debug mode [off]
--confirm Skip the confirmation prompt
-t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline(
'TOKEN'
)} Login token
@@ -42,12 +46,14 @@ const help = () => {
${chalk.dim('Examples:')}
${chalk.gray('')} List all deployments
${chalk.gray('')} List all deployments for the currently linked project
${chalk.cyan(`$ ${getPkgName()} ls`)}
${chalk.gray('')} List all deployments for the app ${chalk.dim('`my-app`')}
${chalk.gray('')} List all deployments for the project ${chalk.dim(
'`my-app`'
)} in the team of the currently linked project
${chalk.cyan(`$ ${getPkgName()} ls my-app`)}
${chalk.gray('')} Filter deployments by metadata
@@ -71,6 +77,7 @@ export default async function main(client: Client) {
'-m': '--meta',
'--next': Number,
'-N': '--next',
'--confirm': Boolean,
});
} catch (err) {
handleError(err);
@@ -86,18 +93,64 @@ export default async function main(client: Client) {
return 1;
}
let app: string | undefined = argv._[1];
let host: string | undefined = undefined;
if (argv['--help']) {
help();
return 2;
}
const meta = parseMeta(argv['--meta']);
const { currentTeam, includeScheme } = config;
const yes = argv['--confirm'] || false;
let contextName = null;
const meta = parseMeta(argv['--meta']);
const { includeScheme } = config;
let paths = [process.cwd()];
const pathValidation = await validatePaths(client, paths);
if (!pathValidation.valid) {
return pathValidation.exitCode;
}
const { path } = pathValidation;
// retrieve `project` and `org` from .vercel
let link = await getLinkedProject(client, path);
if (link.status === 'error') {
return link.exitCode;
}
let { org, project, status } = link;
const appArg: string | undefined = argv._[1];
let app: string | undefined = appArg || project?.name;
let host: string | undefined = undefined;
if (app && !isValidName(app)) {
error(`The provided argument "${app}" is not a valid project name`);
return 1;
}
// If there's no linked project and user doesn't pass `app` arg,
// prompt to link their current directory.
if (status === 'not_linked' && !app) {
const linkedProject = await ensureLink('list', client, path, yes);
if (typeof linkedProject === 'number') {
return linkedProject;
}
link.org = linkedProject.org;
link.project = linkedProject.project;
}
let { contextName, team } = await getScope(client);
// If user passed in a custom scope, update the current team & context name
if (argv['--scope']) {
client.config.currentTeam = team?.id || undefined;
if (team?.slug) contextName = team.slug;
} else {
client.config.currentTeam = org?.type === 'team' ? org.id : undefined;
if (org?.slug) contextName = org.slug;
}
const { currentTeam } = config;
try {
({ contextName } = await getScope(client));
@@ -152,6 +205,7 @@ export default async function main(client: Client) {
}
debug('Fetching deployments');
const response = await now.list(app, {
version: 6,
meta,
@@ -194,17 +248,18 @@ export default async function main(client: Client) {
deployments = deployments.filter(deployment => deployment.url === host);
}
// we don't output the table headers if we have no deployments
if (!deployments.length) {
log(`No deployments found.`);
return 0;
}
log(
`Deployments under ${chalk.bold(contextName)} ${elapsed(
Date.now() - start
)}`
);
// we don't output the table headers if we have no deployments
if (!deployments.length) {
return 0;
}
// information to help the user find other deployments or instances
if (app == null) {
log(
@@ -216,7 +271,7 @@ export default async function main(client: Client) {
print('\n');
console.log(
client.output.print(
`${table(
[
['project', 'latest deployment', 'state', 'age', 'username'].map(
@@ -247,7 +302,7 @@ export default async function main(client: Client) {
hsep: ' '.repeat(4),
stringLength: strlen,
}
).replace(/^/gm, ' ')}\n`
).replace(/^/gm, ' ')}\n\n`
);
if (pagination && pagination.count === 20) {
@@ -270,7 +325,7 @@ function getProjectName(d: Deployment) {
}
// renders the state string
function stateString(s: string) {
export function stateString(s: string) {
switch (s) {
case 'INITIALIZING':
return chalk.yellow(s);

View File

@@ -10,6 +10,20 @@ import getScope from '../util/get-scope';
import getCommandFlags from '../util/get-command-flags';
import { getPkgName, getCommandName } from '../util/pkg-name';
import Client from '../util/client';
import validatePaths from '../util/validate-paths';
import { ensureLink } from '../util/ensure-link';
import { parseGitConfig, pluckRemoteUrl } from '../util/create-git-meta';
import {
connectGitProvider,
disconnectGitProvider,
formatProvider,
parseRepoUrl,
} from '../util/projects/connect-git-provider';
import { join } from 'path';
import { Team, User } from '../types';
import confirm from '../util/input/confirm';
import { Output } from '../util/output';
import link from '../util/output/link';
const e = encodeURIComponent;
@@ -20,6 +34,7 @@ const help = () => {
${chalk.dim('Commands:')}
ls Show all projects in the selected team/user
connect Connect a Git provider to your project
add [name] Add a new project
rm [name] Remove a project
@@ -54,6 +69,7 @@ const main = async (client: Client) => {
argv = getArgs(client.argv.slice(2), {
'--next': Number,
'-N': '--next',
'--yes': Boolean,
});
} catch (error) {
handleError(error);
@@ -71,10 +87,10 @@ const main = async (client: Client) => {
const { output } = client;
let contextName = null;
let scope = null;
try {
({ contextName } = await getScope(client));
scope = await getScope(client);
} catch (err) {
if (err.code === 'NOT_AUTHORIZED' || err.code === 'TEAM_DELETED') {
output.error(err.message);
@@ -84,17 +100,12 @@ const main = async (client: Client) => {
throw err;
}
try {
await run({ client, contextName });
} catch (err) {
handleError(err);
exit(1);
}
return await run({ client, scope });
};
export default async (client: Client) => {
try {
await main(client);
return await main(client);
} catch (err) {
handleError(err);
process.exit(1);
@@ -103,16 +114,148 @@ export default async (client: Client) => {
async function run({
client,
contextName,
scope,
}: {
client: Client;
contextName: string;
scope: {
contextName: string;
team: Team | null;
user: User;
};
}) {
const { output } = client;
const { contextName, team } = scope;
const args = argv._.slice(1);
const start = Date.now();
if (subcommand === 'connect') {
const yes = Boolean(argv['--yes']);
if (args.length !== 0) {
output.error(
`Invalid number of arguments. Usage: ${chalk.cyan(
`${getCommandName('project connect')}`
)}`
);
return exit(2);
}
let paths = [process.cwd()];
const validate = await validatePaths(client, paths);
if (!validate.valid) {
return validate.exitCode;
}
const { path } = validate;
const linkedProject = await ensureLink(
'project connect',
client,
path,
yes
);
if (typeof linkedProject === 'number') {
return linkedProject;
}
const { project, org } = linkedProject;
const gitProviderLink = project.link;
client.config.currentTeam = org.type === 'team' ? org.id : undefined;
// get project from .git
const gitConfigPath = join(path, '.git/config');
const gitConfig = await parseGitConfig(gitConfigPath, output);
if (!gitConfig) {
output.error(
`No local git repo found. Run ${chalk.cyan(
'`git clone <url>`'
)} to clone a remote Git repository first.`
);
return 1;
}
const remoteUrl = pluckRemoteUrl(gitConfig);
if (!remoteUrl) {
output.error(
`No remote origin URL found in your Git config. Make sure you've connected your local Git repo to a Git provider first.`
);
return 1;
}
const parsedUrl = parseRepoUrl(remoteUrl);
if (!parsedUrl) {
output.error(
`Failed to parse Git repo data from the following remote URL in your Git config: ${link(
remoteUrl
)}`
);
return 1;
}
const { provider, org: gitOrg, repo } = parsedUrl;
const repoPath = `${gitOrg}/${repo}`;
let connectedRepoPath;
if (!gitProviderLink) {
const connect = await connectGitProvider(
client,
team,
project.id,
provider,
repoPath
);
if (typeof connect === 'number') {
return connect;
}
} else {
const connectedProvider = gitProviderLink.type;
const connectedOrg = gitProviderLink.org;
const connectedRepo = gitProviderLink.repo;
connectedRepoPath = `${connectedOrg}/${connectedRepo}`;
const isSameRepo =
connectedProvider === provider &&
connectedOrg === gitOrg &&
connectedRepo === repo;
if (isSameRepo) {
output.log(
`${chalk.cyan(
connectedRepoPath
)} is already connected to your project.`
);
return 1;
}
const shouldReplaceRepo = await confirmRepoConnect(
client,
output,
yes,
connectedRepoPath
);
if (!shouldReplaceRepo) {
return 0;
}
await disconnectGitProvider(client, team, project.id);
const connect = await connectGitProvider(
client,
team,
project.id,
provider,
repoPath
);
if (typeof connect === 'number') {
return connect;
}
}
output.log(
`Connected ${formatProvider(provider)} repository ${chalk.cyan(
repoPath
)}!`
);
return 0;
}
if (subcommand === 'ls' || subcommand === 'list') {
if (args.length !== 0) {
console.error(
@@ -271,7 +414,7 @@ async function run({
return;
}
console.error(error('Please specify a valid subcommand: ls | add | rm'));
output.error('Please specify a valid subcommand: ls | connect | add | rm');
help();
exit(2);
}
@@ -281,6 +424,28 @@ process.on('uncaughtException', err => {
exit(1);
});
async function confirmRepoConnect(
client: Client,
output: Output,
yes: boolean,
connectedRepoPath: string
) {
let shouldReplaceProject = yes;
if (!shouldReplaceProject) {
shouldReplaceProject = await confirm(
client,
`Looks like you already have a repository connected: ${chalk.cyan(
connectedRepoPath
)}. Do you want to replace it?`,
true
);
if (!shouldReplaceProject) {
output.log(`Aborted. Repo not connected.`);
}
}
return shouldReplaceProject;
}
function readConfirmation(projectName: string) {
return new Promise(resolve => {
process.stdout.write(

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import type { Readable, Writable } from 'stream';
export type ProjectSettings = import('@vercel/build-utils').ProjectSettings;
export type Primitive =
@@ -128,6 +130,8 @@ export type Deployment = {
version?: number;
created: number;
createdAt: number;
ready?: number;
buildingAt?: number;
creator: { uid: string; username: string };
target: string | null;
ownerId: string;
@@ -244,12 +248,34 @@ export interface ProjectEnvVariable {
gitBranch?: string;
}
export interface DeployHook {
createdAt: number;
id: string;
name: string;
ref: string;
url: string;
}
export interface ProjectLinkData {
type: string;
repo: string;
repoId: number;
org?: string;
gitCredentialId: string;
productionBranch?: string | null;
sourceless: boolean;
createdAt: number;
updatedAt: number;
deployHooks?: DeployHook[];
}
export interface Project extends ProjectSettings {
id: string;
name: string;
accountId: string;
updatedAt: number;
createdAt: number;
link?: ProjectLinkData;
alias?: ProjectAliasTarget[];
latestDeployments?: Partial<Deployment>[];
}
@@ -442,3 +468,19 @@ export interface BuildOutput {
layers?: string[];
} | 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

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

@@ -3,8 +3,8 @@ import { join } from 'path';
import ini from 'ini';
import git from 'git-last-commit';
import { exec } from 'child_process';
import { GitMetadata } from '../../types';
import { Output } from '../output';
import { GitMetadata } from '../types';
import { Output } from './output';
export function isDirty(directory: string): Promise<boolean> {
return new Promise((resolve, reject) => {
@@ -33,21 +33,31 @@ function getLastCommit(directory: string): Promise<git.Commit> {
});
}
export async function parseGitConfig(configPath: string, output: Output) {
try {
return ini.parse(await fs.readFile(configPath, 'utf-8'));
} catch (error) {
output.debug(`Error while parsing repo data: ${error.message}`);
}
}
export function pluckRemoteUrl(gitConfig: {
[key: string]: any;
}): string | undefined {
// Assuming "origin" is the remote url that the user would want to use
return gitConfig['remote "origin"']?.url;
}
export async function getRemoteUrl(
configPath: string,
output: Output
): Promise<string | null> {
let gitConfig;
try {
gitConfig = ini.parse(await fs.readFile(configPath, 'utf-8'));
} catch (error) {
output.debug(`Error while parsing repo data: ${error.message}`);
}
let gitConfig = await parseGitConfig(configPath, output);
if (!gitConfig) {
return null;
}
const originUrl: string = gitConfig['remote "origin"']?.url;
const originUrl = pluckRemoteUrl(gitConfig);
if (originUrl) {
return originUrl;
}

View File

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

View File

@@ -36,12 +36,14 @@ import {
StartDevServerResult,
FileFsRef,
PackageJson,
spawnCommand,
} from '@vercel/build-utils';
import {
detectBuilders,
detectApiDirectory,
detectApiExtensions,
spawnCommand,
isOfficialRuntime,
} from '@vercel/build-utils';
} from '@vercel/fs-detectors';
import frameworkList from '@vercel/frameworks';
import cmd from '../output/cmd';

View File

@@ -0,0 +1,44 @@
import { Org, Project } from '../types';
import Client from './client';
import setupAndLink from './link/setup-and-link';
import param from './output/param';
import { getCommandName } from './pkg-name';
import { getLinkedProject } from './projects/link';
type LinkResult = {
org: Org;
project: Project;
};
export async function ensureLink(
commandName: string,
client: Client,
cwd: string,
yes: boolean
): Promise<LinkResult | number> {
let link = await getLinkedProject(client, cwd);
if (link.status === 'not_linked') {
link = await setupAndLink(client, cwd, {
autoConfirm: yes,
successEmoji: 'link',
setupMsg: 'Set up',
});
if (link.status === 'not_linked') {
// User aborted project linking questions
return 0;
}
}
if (link.status === 'error') {
if (link.reason === 'HEADLESS') {
client.output.error(
`Command ${getCommandName(
commandName
)} requires confirmation. Use option ${param('--yes')} to confirm.`
);
}
return link.exitCode;
}
return { org: link.org, project: link.project };
}

View File

@@ -1,5 +1,5 @@
import { resolve } from 'path';
import fs from 'fs-extra';
import { resolve } from 'path';
import { getVercelIgnore } from '@vercel/client';
import uniqueStrings from './unique-strings';
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 || ''}`,
fetch(`${this._apiUrl}${_url}`, { ...opts, body })
);
printIndications(res);
printIndications(this._client, res);
return res;
}

View File

@@ -1,4 +1,3 @@
import inquirer from 'inquirer';
import Client from '../client';
export default async function confirm(
@@ -8,12 +7,7 @@ export default async function confirm(
): Promise<boolean> {
require('./patch-inquirer');
const prompt = inquirer.createPromptModule({
input: client.stdin,
output: client.stdout,
});
const answers = await prompt({
const answers = await client.prompt({
type: 'confirm',
name: 'value',
message,

View File

@@ -1,5 +1,4 @@
import Client from '../client';
import inquirer from 'inquirer';
import confirm from './confirm';
import getProjectByIdOrName from '../projects/get-project-by-id-or-name';
import chalk from 'chalk';
@@ -79,11 +78,7 @@ export default async function inputProject(
let project: Project | ProjectNotFound | null = null;
while (!project || project instanceof ProjectNotFound) {
const prompt = inquirer.createPromptModule({
input: client.stdin,
output: client.stdout,
});
const answers = await prompt({
const answers = await client.prompt({
type: 'input',
name: 'existingProjectName',
message: `Whats the name of your existing project?`,
@@ -114,7 +109,7 @@ export default async function inputProject(
let newProjectName: string | null = null;
while (!newProjectName) {
const answers = await inquirer.prompt({
const answers = await client.prompt({
type: 'input',
name: 'newProjectName',
message: `Whats your projects name?`,

View File

@@ -1,6 +1,5 @@
import path from 'path';
import chalk from 'chalk';
import inquirer from 'inquirer';
import { validateRootDirectory } from '../validate-paths';
import Client from '../client';
@@ -15,11 +14,7 @@ export async function inputRootDirectory(
// eslint-disable-next-line no-constant-condition
while (true) {
const prompt = inquirer.createPromptModule({
input: client.stdin,
output: client.stdout,
});
const { rootDirectory } = await prompt({
const { rootDirectory } = await client.prompt({
type: 'input',
name: 'rootDirectory',
message: `In which directory is your code located?`,

View File

@@ -1,5 +1,6 @@
import inquirer from 'inquirer';
import stripAnsi from 'strip-ansi';
import Client from '../client';
import eraseLines from '../output/erase-lines';
interface ListEntry {
@@ -35,21 +36,24 @@ function getLength(input: string): number {
return biggestLength;
}
export default async function list({
message = 'the question',
// eslint-disable-line no-unused-vars
choices: _choices = [
{
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`,
eraseFinalAnswer = false, // If true, the line with the final answer that inquirer prints will be erased before returning
}: ListOptions): Promise<string> {
export default async function list(
client: Client,
{
message = 'the question',
// eslint-disable-line no-unused-vars
choices: _choices = [
{
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`,
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');
let biggestLength = 0;
@@ -106,7 +110,7 @@ export default async function list({
choices.push(abortSeparator, _abort);
}
const answer = await inquirer.prompt({
const answer = await client.prompt({
name: 'value',
type: 'list',
default: selected,

View File

@@ -1,12 +1,13 @@
import chalk from 'chalk';
import type { ReadableTTY, WritableTTY } from '../../types';
type Options = {
abortSequences?: Set<string>;
defaultValue?: boolean;
noChar?: string;
resolveChars?: Set<string>;
stdin: NodeJS.ReadStream;
stdout: NodeJS.WriteStream;
stdin: ReadableTTY;
stdout: WritableTTY;
trailing?: string;
yesChar?: string;
};

View File

@@ -1,5 +1,7 @@
import type { ReadableTTY } from '../../types';
export default async function readStandardInput(
stdin: NodeJS.ReadStream
stdin: ReadableTTY
): Promise<string> {
return new Promise<string>(resolve => {
setTimeout(() => resolve(''), 500);

View File

@@ -1,4 +1,3 @@
import inquirer from 'inquirer';
import Client from '../client';
import getUser from '../get-user';
import getTeams from '../teams/get-teams';
@@ -43,7 +42,7 @@ export default async function selectOrg(
return choices[defaultOrgIndex].value;
}
const answers = await inquirer.prompt({
const answers = await client.prompt({
type: 'list',
name: 'org',
message: question,

View File

@@ -56,7 +56,7 @@ export default async function setupAndLink(
return { status: 'error', exitCode: 1, reason: 'PATH_IS_FILE' };
}
const link = await getLinkedProject(client, path);
const isTTY = process.stdout.isTTY;
const isTTY = client.stdin.isTTY;
const quiet = !isTTY;
let rootDirectory: string | null = null;
let sourceFilesOutsideRootDirectory = true;

View File

@@ -176,7 +176,7 @@ async function getVerificationTokenOutOfBand(client: Client, url: URL) {
output.log(
`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));
// 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 error from '../output/error';
import listInput from '../input/list';
@@ -32,7 +31,7 @@ export default async function prompt(
choices.pop();
}
const choice = await listInput({
const choice = await listInput(client, {
message: 'Log in to Vercel',
choices,
});
@@ -44,22 +43,26 @@ export default async function prompt(
} else if (choice === 'bitbucket') {
result = await doBitbucketLogin(client, outOfBand, ssoUserId);
} 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);
} 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);
}
return result;
}
export async function readInput(message: string): Promise<string> {
export async function readInput(
client: Client,
message: string
): Promise<string> {
let input;
while (!input) {
try {
const { val } = await inquirer.prompt({
const { val } = await client.prompt({
type: 'input',
name: 'val',
message,

View File

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

View File

@@ -8,14 +8,19 @@ export interface StopSpinner {
}
export default function wait(
msg: string,
delay: number = 300,
_ora = ora
opts: ora.Options,
delay: number = 300
): 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(() => {
spinner = _ora(chalk.gray(msg));
spinner = ora(opts);
spinner.text = chalk.gray(text);
spinner.color = 'gray';
spinner.start();
}, 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
Object.defineProperty(stop, 'text', {
get() {
return msg;
return text;
},
set(v: string) {
msg = v;
text = v;
if (spinner) {
spinner.text = chalk.gray(v);
}
},
});
// @ts-ignore
process.once('nowExit', stop);
return stop;
}

View File

@@ -1,11 +1,10 @@
import chalk from 'chalk';
import { Response } from 'node-fetch';
import { emoji, EmojiLabel, prependEmoji } from './emoji';
import createOutput from './output';
import Client from './client';
import linkStyle from './output/link';
import { emoji, EmojiLabel, prependEmoji } from './emoji';
export default function printIndications(res: Response) {
const _output = createOutput();
export default function printIndications(client: Client, res: Response) {
const indications = new Set(['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)}`) +
newline;
}
_output.print(message + finalLink);
client.output.print(message + finalLink);
}
}
}

View File

@@ -0,0 +1,117 @@
import Client from '../client';
import { stringify } from 'qs';
import { Team } from '../../types';
import chalk from 'chalk';
import link from '../output/link';
export async function disconnectGitProvider(
client: Client,
team: Team | null,
projectId: string
) {
const fetchUrl = `/v4/projects/${projectId}/link?${stringify({
teamId: team?.id,
})}`;
return client.fetch(fetchUrl, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
}
export async function connectGitProvider(
client: Client,
team: Team | null,
projectId: string,
type: string,
repo: string
) {
const fetchUrl = `/v4/projects/${projectId}/link?${stringify({
teamId: team?.id,
})}`;
return client
.fetch(fetchUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type,
repo,
}),
})
.catch(err => {
if (
err.meta?.action === 'Install GitHub App' ||
err.code === 'repo_not_found'
) {
client.output.error(
`Failed to link ${chalk.cyan(
repo
)}. Make sure there aren't any typos and that you have access to the repository if it's private.`
);
} else if (err.action === 'Add a Login Connection') {
client.output.error(
err.message.replace(repo, chalk.cyan(repo)) +
`\nVisit ${link(err.link)} for more information.`
);
} else {
client.output.error(
`Failed to connect the ${formatProvider(
type
)} repository ${repo}.\n${err}`
);
}
return 1;
});
}
export function formatProvider(type: string): string {
switch (type) {
case 'github':
return 'GitHub';
case 'gitlab':
return 'GitLab';
case 'bitbucket':
return 'Bitbucket';
default:
return type;
}
}
export function parseRepoUrl(originUrl: string): {
provider: string;
org: string;
repo: string;
} | null {
const isSSH = originUrl.startsWith('git@');
// Matches all characters between (// or @) and (.com or .org)
// eslint-disable-next-line prefer-named-capture-group
const provider = /(?<=(\/\/|@)).*(?=(\.com|\.org))/.exec(originUrl);
if (!provider) {
return null;
}
let org;
let repo;
if (isSSH) {
org = originUrl.split(':')[1].split('/')[0];
repo = originUrl.split('/')[1]?.replace('.git', '');
} else {
// Assume https:// or git://
org = originUrl.split('/')[3];
repo = originUrl.split('/')[4]?.replace('.git', '');
}
if (!org || !repo) {
return null;
}
return {
provider: provider[0],
org,
repo,
};
}

View File

@@ -4,7 +4,15 @@ export const config = {
};
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
response.headers.set('x-modified-edge', 'true');

View File

@@ -511,7 +511,20 @@ test(
testFixtureStdio('middleware-matchers', async (testPath: any) => {
await testPath(404, '/');
await testPath(404, '/another');
await testPath(200, '/about/page', 'middleware response');
await testPath(200, '/dashboard/home', 'middleware response');
await testPath(
200,
'/about/page',
'{"pathname":"/about/page","search":"","fromMiddleware":true}'
);
await testPath(
200,
'/dashboard/home',
'{"pathname":"/dashboard/home","search":"","fromMiddleware":true}'
);
await testPath(
200,
'/dashboard/home?a=b',
'{"pathname":"/dashboard/home","search":"?a=b","fromMiddleware":true}'
);
})
);

View File

@@ -348,7 +348,6 @@ function testFixtureStdio(
: []),
'deploy',
'--public',
'--no-clipboard',
'--debug',
],
{ cwd, stdio: 'pipe', reject: false }

View File

@@ -0,0 +1 @@
!.vercel

View File

@@ -0,0 +1,4 @@
{
"projectId": "with-team",
"orgId": "team_dummy"
}

View File

@@ -0,0 +1 @@
ref: refs/heads/master

View File

@@ -0,0 +1,10 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = bababooey
fetch = +refs/heads/*:refs/remotes/origin/*

View File

@@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@@ -0,0 +1 @@
ref: refs/heads/master

View File

@@ -0,0 +1,10 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/user2/repo2
fetch = +refs/heads/*:refs/remotes/origin/*

View File

@@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@@ -0,0 +1 @@
ref: refs/heads/master

View File

@@ -0,0 +1,10 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/laksfj/asdgklsadkl
fetch = +refs/heads/*:refs/remotes/origin/*

View File

@@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@@ -0,0 +1 @@
ref: refs/heads/master

View File

@@ -0,0 +1,10 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/user/repo
fetch = +refs/heads/*:refs/remotes/origin/*

View File

@@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@@ -0,0 +1 @@
ref: refs/heads/master

View File

@@ -0,0 +1,7 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true

View File

@@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@@ -0,0 +1 @@
ref: refs/heads/master

View File

@@ -0,0 +1,10 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/user/repo
fetch = +refs/heads/*:refs/remotes/origin/*

View File

@@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@@ -0,0 +1 @@
.vercel

View File

@@ -0,0 +1 @@
ref: refs/heads/master

View File

@@ -0,0 +1,10 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/user/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*

View File

@@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@@ -553,8 +553,8 @@ test('default command should warn when deploying with conflicting subdirectory',
/Did you mean to deploy the subdirectory "list"\? Use `vc --cwd list` instead./
);
const listHeader = /project +latest deployment +state +age +username/;
t.regex(stdout || '', listHeader); // ensure `list` command still ran
const listHeader = /No deployments found/;
t.regex(stderr || '', listHeader); // ensure `list` command still ran
});
test('deploy command should not warn when deploying with conflicting subdirectory and using --cwd', async t => {
@@ -577,8 +577,8 @@ test('deploy command should not warn when deploying with conflicting subdirector
/Did you mean to deploy the subdirectory "list"\? Use `vc --cwd list` instead./
);
const listHeader = /project +latest deployment +state +age +username/;
t.regex(stdout || '', listHeader); // ensure `list` command still ran
const listHeader = /No deployments found/;
t.regex(stderr || '', listHeader); // ensure `list` command still ran
});
test('default command should work with --cwd option', async t => {
@@ -1813,31 +1813,6 @@ test('remove the wildcard alias', async t => {
});
*/
test('ensure username in list is right', async t => {
const { stdout, stderr, exitCode } = await execa(
binaryPath,
['ls', ...defaultArgs],
{
reject: false,
}
);
console.log(stderr);
console.log(stdout);
console.log(exitCode);
// Ensure the exit code is right
t.is(exitCode, 0);
const line = stdout
.split('\n')
.find(line => line.includes('.now.sh') || line.includes('.vercel.app'));
const columns = line.split(/\s+/);
// Ensure username column have username
t.truthy(columns.pop().includes(contextName));
});
test('ensure we render a warning for deployments with no files', async t => {
const directory = fixture('empty-directory');
@@ -1958,7 +1933,7 @@ test('ensure we render a prompt when deploying home directory', async t => {
t.is(exitCode, 0);
t.true(
stdout.includes(
stderr.includes(
'You are deploying your home directory. Do you want to continue? [y/N]'
)
);

View File

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

View File

@@ -7,7 +7,11 @@ import { Build, User } from '../../src/types';
let deployments = new Map<string, Deployment>();
let deploymentBuilds = new Map<Deployment, Build[]>();
export function useDeployment({ creator }: { creator: Pick<User, 'id'> }) {
export function useDeployment({
creator,
}: {
creator: Pick<User, 'id' | 'email' | 'name'>;
}) {
const createdAt = Date.now();
const url = new URL(chance().url());
const deployment: Deployment = {
@@ -23,6 +27,11 @@ export function useDeployment({ creator }: { creator: Pick<User, 'id'> }) {
createdAt,
createdIn: 'sfo1',
ownerId: creator.id,
creator: {
uid: creator.id,
email: creator.email,
username: creator.name,
},
readyState: 'READY',
env: {},
build: { env: {} },
@@ -77,4 +86,9 @@ beforeEach(() => {
const builds = deploymentBuilds.get(deployment);
res.json({ builds });
});
client.scenario.get('/:version/now/deployments', (req, res) => {
const deploymentsList = Array.from(deployments.values());
res.json({ deployments: deploymentsList });
});
});

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

@@ -1,5 +1,6 @@
import { client } from './client';
import { Project } from '../../src/types';
import { formatProvider } from '../../src/util/projects/connect-git-provider';
const envs = [
{
@@ -157,6 +158,49 @@ export function useProject(project: Partial<Project> = defaultProject) {
res.json({ envs });
});
client.scenario.post(`/v4/projects/${project.id}/link`, (req, res) => {
const { type, repo, org } = req.body;
if (
(type === 'github' || type === 'gitlab' || type === 'bitbucket') &&
(repo === 'user/repo' || repo === 'user2/repo2')
) {
project.link = {
type,
repo,
repoId: 1010,
org,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
};
res.json(project);
} else {
if (type === 'github') {
res.status(400).json({
message: `To link a GitHub repository, you need to install the GitHub integration first. (400)\nInstall GitHub App: https://github.com/apps/vercel`,
meta: {
action: 'Install GitHub App',
link: 'https://github.com/apps/vercel',
repo,
},
});
} else {
res.status(400).json({
code: 'repo_not_found',
message: `The repository "${repo}" couldn't be found in your linked ${formatProvider(
type
)} account.`,
});
}
}
});
client.scenario.delete(`/v4/projects/${project.id}/link`, (req, res) => {
if (project.link) {
project.link = undefined;
}
res.json(project);
});
return { project, envs };
}

View File

@@ -10,38 +10,38 @@ import { useUser } from '../../mocks/user';
describe('deploy', () => {
it('should reject deploying a single file', async () => {
client.setArgv('deploy', __filename);
const exitCode = await deploy(client);
expect(exitCode).toEqual(1);
expect(client.outputBuffer).toEqual(
const exitCodePromise = deploy(client);
await expect(client.stderr).toOutput(
`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 () => {
client.setArgv('deploy', __filename, join(__dirname, 'inspect.test.ts'));
const exitCode = await deploy(client);
expect(exitCode).toEqual(1);
expect(client.outputBuffer).toEqual(
const exitCodePromise = deploy(client);
await expect(client.stderr).toOutput(
`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 () => {
client.setArgv('deploy', 'does-not-exists');
const exitCode = await deploy(client);
expect(exitCode).toEqual(1);
expect(client.outputBuffer).toEqual(
const exitCodePromise = deploy(client);
await expect(client.stderr).toOutput(
`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 () => {
client.setArgv('deploy', __dirname, '--prebuilt');
const exitCode = await deploy(client);
expect(exitCode).toEqual(1);
expect(client.outputBuffer).toEqual(
const exitCodePromise = deploy(client);
await expect(client.stderr).toOutput(
'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 () => {
@@ -56,14 +56,14 @@ describe('deploy', () => {
});
client.setArgv('deploy', cwd, '--prebuilt', '--prod');
const exitCode = await deploy(client);
expect(exitCode).toEqual(1);
expect(client.outputBuffer).toEqual(
const exitCodePromise = deploy(client);
await expect(client.stderr).toOutput(
'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".' +
' Please run `vercel --prebuilt`.\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 () => {
@@ -78,14 +78,14 @@ describe('deploy', () => {
});
client.setArgv('deploy', cwd, '--prebuilt');
const exitCode = await deploy(client);
expect(exitCode).toEqual(1);
expect(client.outputBuffer).toEqual(
const exitCodePromise = deploy(client);
await expect(client.stderr).toOutput(
'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".' +
' Please run `vercel --prebuilt --prod`.\n' +
'Learn More: https://vercel.link/prebuilt-environment-mismatch\n'
);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should reject deploying "version: 1"', async () => {
@@ -94,11 +94,11 @@ describe('deploy', () => {
[fileNameSymbol]: 'vercel.json',
version: 1,
};
const exitCode = await deploy(client);
expect(exitCode).toEqual(1);
expect(client.outputBuffer).toEqual(
const exitCodePromise = deploy(client);
await expect(client.stderr).toOutput(
'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 () => {
@@ -108,10 +108,10 @@ describe('deploy', () => {
// @ts-ignore
version: {},
};
const exitCode = await deploy(client);
expect(exitCode).toEqual(1);
expect(client.outputBuffer).toEqual(
const exitCodePromise = deploy(client);
await expect(client.stderr).toOutput(
'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',
});
client.setArgv('env', 'pull', '--yes', '--cwd', cwd);
const exitCode = await env(client);
expect(exitCode, client.outputBuffer).toEqual(0);
const exitCodePromise = env(client);
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'));
@@ -39,8 +43,12 @@ describe('env', () => {
name: 'vercel-env-pull',
});
client.setArgv('env', 'pull', 'other.env', '--yes', '--cwd', cwd);
const exitCode = await env(client);
expect(exitCode, client.outputBuffer).toEqual(0);
const exitCodePromise = env(client);
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'));
@@ -61,8 +69,12 @@ describe('env', () => {
});
client.setArgv('env', 'pull', 'other.env', '--yes', '--cwd', cwd);
const exitCode = await env(client);
expect(exitCode, client.outputBuffer).toEqual(0);
const exitCodePromise = env(client);
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'));

View File

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

View File

@@ -0,0 +1,139 @@
import { client, MockClient } from '../../mocks/client';
import { useUser } from '../../mocks/user';
import list, { stateString } from '../../../src/commands/list';
import { join } from 'path';
import { useTeams } from '../../mocks/team';
import { defaultProject, useProject } from '../../mocks/project';
import { useDeployment } from '../../mocks/deployment';
const fixture = (name: string) =>
join(__dirname, '../../fixtures/unit/commands/list', name);
describe('list', () => {
const originalCwd = process.cwd();
let teamSlug: string = '';
it('should get deployments from a project linked by a directory', async () => {
const cwd = fixture('with-team');
try {
process.chdir(cwd);
const user = useUser();
const team = useTeams('team_dummy');
teamSlug = team[0].slug;
useProject({
...defaultProject,
id: 'with-team',
name: 'with-team',
});
const deployment = useDeployment({ creator: user });
await list(client);
const output = await readOutputStream(client);
const { org } = getDataFromIntro(output.split('\n')[0]);
const header: string[] = parseTable(output.split('\n')[2]);
const data: string[] = parseTable(output.split('\n')[3]);
data.splice(2, 1);
expect(org).toEqual(team[0].slug);
expect(header).toEqual([
'project',
'latest deployment',
'state',
'age',
'username',
]);
expect(data).toEqual([
deployment.url,
stateString(deployment.state || ''),
user.name,
]);
} finally {
process.chdir(originalCwd);
}
});
it('should get the deployments for a specified project', async () => {
const cwd = fixture('with-team');
try {
process.chdir(cwd);
const user = useUser();
useTeams('team_dummy');
useProject({
...defaultProject,
id: 'with-team',
name: 'with-team',
});
const deployment = useDeployment({ creator: user });
client.setArgv(deployment.name);
await list(client);
const output = await readOutputStream(client);
const { org } = getDataFromIntro(output.split('\n')[0]);
const header: string[] = parseTable(output.split('\n')[2]);
const data: string[] = parseTable(output.split('\n')[3]);
data.splice(2, 1);
expect(org).toEqual(teamSlug);
expect(header).toEqual([
'project',
'latest deployment',
'state',
'age',
'username',
]);
expect(data).toEqual([
deployment.url,
stateString(deployment.state || ''),
user.name,
]);
} finally {
process.chdir(originalCwd);
}
});
});
function getDataFromIntro(output: string): {
project: string | undefined;
org: string | undefined;
} {
const project = output.match(/(?<=Deployments for )(.*)(?= under)/);
const org = output.match(/(?<=under )(.*)(?= \[)/);
return {
project: project?.[0],
org: org?.[0],
};
}
function parseTable(output: string): string[] {
return output
.trim()
.replace(/ {3} +/g, ',')
.split(',');
}
function readOutputStream(client: MockClient): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const timeout = setTimeout(() => {
reject();
}, 3000);
client.stderr.resume();
client.stderr.on('data', chunk => {
chunks.push(chunk);
if (chunks.length === 3) {
clearTimeout(timeout);
resolve(chunks.toString().replace(/,/g, ''));
}
});
client.stderr.on('error', reject);
});
}

View File

@@ -5,22 +5,45 @@ import { useUser } from '../../mocks/user';
describe('login', () => {
it('should not allow the `--token` flag', async () => {
client.setArgv('login', '--token', 'foo');
const exitCode = await login(client);
expect(exitCode).toEqual(2);
expect(client.outputBuffer).toEqual(
const exitCodePromise = login(client);
await expect(client.stderr).toOutput(
'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 () => {
const user = useUser();
client.setArgv('login', user.email);
const exitCode = await login(client);
expect(exitCode).toEqual(0);
expect(
client.outputBuffer.includes(
const exitCodePromise = login(client);
await expect(client.stderr).toOutput(
`Success! Email authentication complete for ${user.email}`
);
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}`
)
).toEqual(true);
);
await expect(exitCodePromise).resolves.toEqual(0);
});
});
});

View File

@@ -0,0 +1,272 @@
import { join } from 'path';
import fs from 'fs-extra';
import projects from '../../../src/commands/projects';
import { useUser } from '../../mocks/user';
import { useTeams } from '../../mocks/team';
import { defaultProject, useProject } from '../../mocks/project';
import { client } from '../../mocks/client';
import { Project } from '../../../src/types';
describe('projects', () => {
describe('connect', () => {
const originalCwd = process.cwd();
const fixture = (name: string) =>
join(__dirname, '../../fixtures/unit/commands/projects/connect', name);
it('connects an unlinked project', async () => {
const cwd = fixture('unlinked');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useTeams('team_dummy');
useProject({
...defaultProject,
id: 'unlinked',
name: 'unlinked',
});
client.setArgv('projects', 'connect');
const projectsPromise = projects(client);
await expect(client.stderr).toOutput('Set up');
client.stdin.write('y\n');
await expect(client.stderr).toOutput(
'Which scope should contain your project?'
);
client.stdin.write('\r');
await expect(client.stderr).toOutput('Found project');
client.stdin.write('y\n');
const exitCode = await projectsPromise;
await expect(client.stderr).toOutput(
'Connected GitHub repository user/repo!'
);
expect(exitCode).toEqual(0);
const project: Project = await client.fetch(`/v8/projects/unlinked`);
expect(project.link).toMatchObject({
type: 'github',
repo: 'user/repo',
repoId: 1010,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
});
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should fail when there is no git config', async () => {
const cwd = fixture('no-git-config');
try {
process.chdir(cwd);
useUser();
useTeams('team_dummy');
useProject({
...defaultProject,
id: 'no-git-config',
name: 'no-git-config',
});
client.setArgv('projects', 'connect', '--yes');
const exitCode = await projects(client);
expect(exitCode).toEqual(1);
await expect(client.stderr).toOutput(
`Error! No local git repo found. Run \`git clone <url>\` to clone a remote Git repository first.\n`
);
} finally {
process.chdir(originalCwd);
}
});
it('should fail when there is no remote url', async () => {
const cwd = fixture('no-remote-url');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useTeams('team_dummy');
useProject({
...defaultProject,
id: 'no-remote-url',
name: 'no-remote-url',
});
client.setArgv('projects', 'connect', '--yes');
const exitCode = await projects(client);
expect(exitCode).toEqual(1);
await expect(client.stderr).toOutput(
`Error! No remote origin URL found in your Git config. Make sure you've connected your local Git repo to a Git provider first.\n`
);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should fail when the remote url is bad', async () => {
const cwd = fixture('bad-remote-url');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useTeams('team_dummy');
useProject({
...defaultProject,
id: 'bad-remote-url',
name: 'bad-remote-url',
});
client.setArgv('projects', 'connect', '--yes');
const exitCode = await projects(client);
expect(exitCode).toEqual(1);
await expect(client.stderr).toOutput(
`Error! Failed to parse Git repo data from the following remote URL in your Git config: bababooey\n`
);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should connect a repo to a project that is not already connected', async () => {
const cwd = fixture('new-connection');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useTeams('team_dummy');
useProject({
...defaultProject,
id: 'new-connection',
name: 'new-connection',
});
client.setArgv('projects', 'connect', '--yes');
const exitCode = await projects(client);
const project: Project = await client.fetch(
`/v8/projects/new-connection`
);
expect(project.link).toMatchObject({
type: 'github',
repo: 'user/repo',
repoId: 1010,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
});
expect(client.stderr).toOutput(
`> Connected GitHub repository user/repo!\n`
);
expect(exitCode).toEqual(0);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should replace an old connection with a new one', async () => {
const cwd = fixture('existing-connection');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useTeams('team_dummy');
const project = useProject({
...defaultProject,
id: 'existing-connection',
name: 'existing-connection',
});
project.project.link = {
type: 'github',
repo: 'repo',
org: 'user',
repoId: 1010,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
};
client.setArgv('projects', 'connect', '--yes');
const exitCode = await projects(client);
const newProjectData: Project = await client.fetch(
`/v8/projects/existing-connection`
);
expect(newProjectData.link).toMatchObject({
type: 'github',
repo: 'user2/repo2',
repoId: 1010,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
});
await expect(client.stderr).toOutput(
`> Connected GitHub repository user2/repo2!\n`
);
expect(exitCode).toEqual(0);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should exit when an already-connected repo is connected', async () => {
const cwd = fixture('new-connection');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useTeams('team_dummy');
const project = useProject({
...defaultProject,
id: 'new-connection',
name: 'new-connection',
});
project.project.link = {
type: 'github',
repo: 'repo',
org: 'user',
repoId: 1010,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
};
client.setArgv('projects', 'connect', '--yes');
const exitCode = await projects(client);
expect(exitCode).toEqual(1);
await expect(client.stderr).toOutput(
`> user/repo is already connected to your project.\n`
);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
it('should fail when it cannot find the repository', async () => {
const cwd = fixture('invalid-repo');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useTeams('team_dummy');
useProject({
...defaultProject,
id: 'invalid-repo',
name: 'invalid-repo',
});
client.setArgv('projects', 'connect', '--yes');
const exitCode = await projects(client);
expect(exitCode).toEqual(1);
await expect(client.stderr).toOutput(
`Failed to link laksfj/asdgklsadkl. Make sure there aren't any typos and that you have access to the repository if it's private.`
);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
});
});

View File

@@ -18,8 +18,17 @@ describe('pull', () => {
name: 'vercel-pull-next',
});
client.setArgv('pull', cwd);
const exitCode = await pull(client);
expect(exitCode, client.outputBuffer).toEqual(0);
const exitCodePromise = pull(client);
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(
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 () => {
try {
process.stdout.isTTY = undefined;
client.stdin.isTTY = false;
const cwd = setupFixture('vercel-pull-unlinked');
useUser();
useTeams('team_dummy');
const cwd = setupFixture('vercel-pull-unlinked');
useUser();
useTeams('team_dummy');
client.setArgv('pull', cwd);
const exitCode = await pull(client);
expect(exitCode, client.outputBuffer).toEqual(1);
expect(client.outputBuffer).toMatch(
/Command `vercel pull` requires confirmation. Use option "--yes" to confirm./gm
);
} finally {
process.stdout.isTTY = true;
}
client.setArgv('pull', cwd);
const exitCodePromise = pull(client);
await expect(client.stderr).toOutput(
'Command `vercel pull` requires confirmation. Use option "--yes" to confirm.'
);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should fail without message to pull without a link and with --env', async () => {
@@ -54,12 +58,11 @@ describe('pull', () => {
useTeams('team_dummy');
client.setArgv('pull', cwd, '--yes');
const exitCode = await pull(client);
expect(exitCode, client.outputBuffer).toEqual(1);
expect(client.outputBuffer).not.toMatch(
/Command `vercel pull` requires confirmation. Use option "--yes" to confirm./gm
const exitCodePromise = pull(client);
await expect(client.stderr).not.toOutput(
'Command `vercel pull` requires confirmation. Use option "--yes" to confirm.'
);
await expect(exitCodePromise).resolves.toEqual(1);
});
it('should handle pulling with env vars (headless mode)', async () => {
@@ -81,8 +84,17 @@ describe('pull', () => {
name: 'vercel-pull-next',
});
client.setArgv('pull', cwd);
const exitCode = await pull(client);
expect(exitCode, client.outputBuffer).toEqual(0);
const exitCodePromise = pull(client);
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'));
expect(config).toMatchInlineSnapshot(`
@@ -108,8 +120,17 @@ describe('pull', () => {
name: 'vercel-pull-next',
});
client.setArgv('pull', '--environment=preview', cwd);
const exitCode = await pull(client);
expect(exitCode).toEqual(0);
const exitCodePromise = pull(client);
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(
path.join(cwd, '.vercel', '.env.preview.local')
@@ -130,8 +151,17 @@ describe('pull', () => {
name: 'vercel-pull-next',
});
client.setArgv('pull', '--environment=production', cwd);
const exitCode = await pull(client);
expect(exitCode).toEqual(0);
const exitCodePromise = pull(client);
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(
path.join(cwd, '.vercel', '.env.production.local')

View File

@@ -14,14 +14,14 @@ describe('whoami', () => {
const user = useUser();
const exitCode = await whoami(client);
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 () => {
const user = useUser();
client.output.isTTY = false;
client.stdout.isTTY = false;
const exitCode = await whoami(client);
expect(exitCode).toEqual(0);
expect(client.outputBuffer).toEqual(`${user.username}\n`);
await expect(client.stdout).toOutput(`${user.username}\n`);
});
});

View File

@@ -1,25 +0,0 @@
import confirm from '../../src/util/input/confirm';
import { client } from '../mocks/client';
describe('MockClient', () => {
it('should mock `confirm()`', async () => {
// true
let confirmedPromise = confirm(client, 'Do the thing?', false);
client.stdin.write('yes\n');
client.stdout.setEncoding('utf8');
client.stdout.on('data', d => console.log({ d }));
let confirmed = await confirmedPromise;
expect(confirmed).toEqual(true);
// false
confirmedPromise = confirm(client, 'Do the thing?', false);
client.stdin.write('no\n');
confirmed = await confirmedPromise;
expect(confirmed).toEqual(false);
});
});

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