Repository: intuit/auto (ts)

Issue:
Is there a way to publish from a specific directory?
I use a tool like [pmsandhu/copy-pkg-json-webpack-plugin](https://github.com/pmsandhu/copy-pkg-json-webpack-plugin) which allows me to strip out dependencies and devDependencies since the archives are built. 

Is there a way to get auto to change directory before publish?

Pull request description:
feat(plugins/npm): add support for passing publishFolder.
# What Changed

Added support for specifying a custom 'publishFolder' when using the NPM Plugin.

## Why

Closes #1423 without adding much in the way of branching logic or overhead to the `auto` codebase. Since this type of implementation may generally be considered `edge` case, all we're doing is surfacing the capability, while requiring consumers own any logic required to ensure their `publishFolder` package version aligns with the version auto has generated.

Todo:

- [x] Add tests
- [x] Add docs

## Change Type

Indicate the type of change your pull request is:

- [ ] `documentation`
- [ ] `patch`
- [x] `minor`
- [ ] `major`

<!-- GITHUB_RELEASE PR BODY: canary-assets -->
:baby_chick: Download canary assets:

[auto-linux--canary.2115.25151.gz](https://github.com/intuit/auto/releases/download/v0.0.0-canary/auto-linux--canary.2115.25151.gz)  
[auto-macos--canary.2115.25151.gz](https://github.com/intuit/auto/releases/download/v0.0.0-canary/auto-macos--canary.2115.25151.gz)  
[auto-win.exe--canary.2115.25151.gz](https://github.com/intuit/auto/releases/download/v0.0.0-canary/auto-win.exe--canary.2115.25151.gz)
<!-- GITHUB_RELEASE PR BODY: canary-assets -->

<!-- GITHUB_RELEASE PR BODY: canary-version -->
<details>
  <summary>📦 Published PR as canary version: <code>under canary scope @auto-canary@10.33.0--canary.2115.25151.0</code></summary>
  <br />

  :sparkles: Test out this PR locally via:
  
  ```bash
  npm install @auto-canary/bot-list@10.33.0--canary.2115.25151.0
  npm install @auto-canary/auto@10.33.0--canary.2115.25151.0
  npm install @auto-canary/core@10.33.0--canary.2115.25151.0
  npm install @auto-canary/package-json-utils@10.33.0--canary.2115.25151.0
  npm install @auto-canary/all-contributors@10.33.0--canary.2115.25151.0
  npm install @auto-canary/brew@10.33.0--canary.2115.25151.0
  npm install @auto-canary/chrome@10.33.0--canary.2115.25151.0
  npm install @auto-canary/cocoapods@10.33.0--canary.2115.25151.0
  npm install @auto-canary/conventional-commits@10.33.0--canary.2115.25151.0
  npm install @auto-canary/crates@10.33.0--canary.2115.25151.0
  npm install @auto-canary/docker@10.33.0--canary.2115.25151.0
  npm install @auto-canary/exec@10.33.0--canary.2115.25151.0
  npm install @auto-canary/first-time-contributor@10.33.0--canary.2115.25151.0
  npm install @auto-canary/gem@10.33.0--canary.2115.25151.0
  npm install @auto-canary/gh-pages@10.33.0--canary.2115.25151.0
  npm install @auto-canary/git-tag@10.33.0--canary.2115.25151.0
  npm install @auto-canary/gradle@10.33.0--canary.2115.25151.0
  npm install @auto-canary/jira@10.33.0--canary.2115.25151.0
  npm install @auto-canary/magic-zero@10.33.0--canary.2115.25151.0
  npm install @auto-canary/maven@10.33.0--canary.2115.25151.0
  npm install @auto-canary/microsoft-teams@10.33.0--canary.2115.25151.0
  npm install @auto-canary/npm@10.33.0--canary.2115.25151.0
  npm install @auto-canary/omit-commits@10.33.0--canary.2115.25151.0
  npm install @auto-canary/omit-release-notes@10.33.0--canary.2115.25151.0
  npm install @auto-canary/pr-body-labels@10.33.0--canary.2115.25151.0
  npm install @auto-canary/released@10.33.0--canary.2115.25151.0
  npm install @auto-canary/s3@10.33.0--canary.2115.25151.0
  npm install @auto-canary/sbt@10.33.0--canary.2115.25151.0
  npm install @auto-canary/slack@10.33.0--canary.2115.25151.0
  npm install @auto-canary/twitter@10.33.0--canary.2115.25151.0
  npm install @auto-canary/upload-assets@10.33.0--canary.2115.25151.0
  npm install @auto-canary/vscode@10.33.0--canary.2115.25151.0
  # or 
  yarn add @auto-canary/bot-list@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/auto@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/core@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/package-json-utils@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/all-contributors@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/brew@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/chrome@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/cocoapods@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/conventional-commits@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/crates@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/docker@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/exec@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/first-time-contributor@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/gem@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/gh-pages@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/git-tag@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/gradle@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/jira@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/magic-zero@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/maven@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/microsoft-teams@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/npm@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/omit-commits@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/omit-release-notes@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/pr-body-labels@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/released@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/s3@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/sbt@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/slack@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/twitter@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/upload-assets@10.33.0--canary.2115.25151.0
  yarn add @auto-canary/vscode@10.33.0--canary.2115.25151.0
  ```
</details>
<!-- GITHUB_RELEASE PR BODY: canary-version -->


Patch:
diff --git a/plugins/npm/README.md b/plugins/npm/README.md
index 5cb98010..316c2ae8 100644
--- a/plugins/npm/README.md
+++ b/plugins/npm/README.md
@@ -178,6 +178,49 @@ Set `$NPM_TOKEN` to the "Base64 version of username:password".
 }
 ```
 
+### publishFolder
+
+When publishing packages from a folder other than the project root the `publishFolder` config can be set.
+
+When used with `npm` generates command similar to `npm publish <publishFolder> ...` more info can be found here: [npm-publish](https://docs.npmjs.com/cli/v8/commands/npm-publish).
+
+When used with `lerna` in a monorepo, this functionality implements the `--contents <publishFolder>` flag, more info can be found here: [lerna publish --contents](https://github.com/lerna/lerna/tree/main/commands/publish#--contents-dir).
+
+> :exclamation: **NOTE**: This functionality should be combined with a valid npm [Life Cycle](https://docs.npmjs.com/cli/v8/using-npm/scripts#life-cycle-scripts) script, 
+> otherwise the `version` of the produced package will not align with the calculated/expected version generated by `auto`. 
+> See [Life Cycle Scripts](#life-cycle-scripts) for additional info.
+
+```json
+{
+  "plugins": [
+    [
+      "npm",
+      {
+        "publishFolder": "dist/some-dir"
+      }
+    ]
+  ]
+}
+```
+
+#### Life Cycle Scripts
+
+During the publish routine, this plugin will execute (`npm version`|`npx lerna version`), and then immediately follow-up with (`npm publish`|`npx lerna publish`). If you're attempting to publish a package from a directory other than the project root, its likely you're doing some sort of repository or package.json manipulation in an attempt to cleanup (remove devDependencies, scripts, fields) or otherwise manually construct your published package. Since this process is likely invoked prior to the `version` and `publish` event enacted by this plugin, the `version` in your processed `package.json` would not match the new `version` the plugin is attempting to publish (unless you've accounted for this in some other fashion).  This simply means we need to insert ourselves between the `version` and `publish` stages of this plugin and re-process or otherwise update the `package.json` file in our `publishFolder`.
+
+Appropriate life cycle scripts (which operate after `version` but before `publish`) would include [`postversion`, `prepublishOnly`, or `prepack`].  Its worth noting the `postversion` life cycle script will be enacted in the context of your root `package.json` file, while `prepublishOnly` and `prepack` would be enacted in the context of your `publishFolder` `package.json` file.
+
+Example life cycle script which is triggered after (`npm version`|`npx lerna version`) and before (`npm publish`|`npx lerna publish`):
+
+```
+{
+  "scripts": {
+    "postversion": "cd <publishFolder> && npm version $npm_package_version --no-git-tag-version --no-commit-hooks --ignore-scripts",
+  }
+}
+```
+
+> :warning: Warning! Use caution when pairing long-running life cyle scripts with auto. [Read more here.](https://intuit.github.io/auto/docs/welcome/quick-merge#beware-long-publishes)
+
 ### canaryScope
 
 Publishing canary versions comes with some security risks.
diff --git a/plugins/npm/src/index.ts b/plugins/npm/src/index.ts
index e95a4a37..f9dc7e4c 100644
--- a/plugins/npm/src/index.ts
+++ b/plugins/npm/src/index.ts
@@ -207,7 +207,7 @@ async function bumpLatest(
   return latestVersion ? inc(latestVersion, version as ReleaseType) : version;
 }
 
-interface GetLegacyAuthArgsOptions {
+interface GetArgsOptions {
   /** whether the project is a monorepo */
   isMonorepo?: boolean;
 }
@@ -215,7 +215,7 @@ interface GetLegacyAuthArgsOptions {
 /** Get the args to use legacy auth */
 function getLegacyAuthArgs(
   useLegacy: boolean,
-  options: GetLegacyAuthArgsOptions = {}
+  options: GetArgsOptions = {}
 ) {
   if (!useLegacy) {
     return [];
@@ -226,6 +226,20 @@ function getLegacyAuthArgs(
     : [`--_auth=${process.env.NPM_TOKEN}`];
 }
 
+/** Get args for publishFolder */
+function getPublishFolderArgs(
+  publishFolder: string | undefined,
+  options: GetArgsOptions = {}
+) {
+  if (!publishFolder) {
+    return [];
+  }
+
+  return options.isMonorepo
+    ? ["--contents", publishFolder]
+    : [publishFolder];
+}
+
 /** Get the args to set the registry. Only used with lerna */
 async function getRegistryArgs() {
   const registry = await getRegistry();
@@ -255,6 +269,12 @@ const pluginOptions = t.partial({
   legacyAuth: t.boolean,
   /** Whether to add package information to monorepo changelogs */
   monorepoChangelog: t.boolean,
+  /** 
+   * Path used when publishing packages. 
+   * When used with npm this is equivalent to npm publish <publishFolder>
+   * When used with lerna this is equivalent to lerna publish --contents <publishFolder>
+   */
+  publishFolder: t.string,
 });
 
 export type INpmConfig = t.TypeOf<typeof pluginOptions>;
@@ -567,6 +587,8 @@ export default class NPMPlugin implements IPlugin {
   private readonly legacyAuth: boolean;
   /** Whether to use legacy auth for npm */
   private readonly commitNextVersion: boolean;
+  /** Path used when publishing packages */
+  private readonly publishFolder: string | undefined;
 
   /** Initialize the plugin with it's options */
   constructor(config: INpmConfig = {}) {
@@ -578,6 +600,7 @@ export default class NPMPlugin implements IPlugin {
     this.forcePublish = config.forcePublish ?? true;
     this.commitNextVersion = config.commitNextVersion ?? false;
     this.canaryScope = config.canaryScope || undefined;
+    this.publishFolder = config.publishFolder || undefined;
   }
 
   /** A memoized version of getLernaPackages */
@@ -1099,6 +1122,7 @@ export default class NPMPlugin implements IPlugin {
             "--dist-tag",
             "canary",
             ...(await getRegistryArgs()),
+            ...getPublishFolderArgs(this.publishFolder, { isMonorepo: true }),
             !isIndependent && "--force-publish",
             ...getLegacyAuthArgs(this.legacyAuth, { isMonorepo: true }),
             "--yes", // skip prompts,
@@ -1187,6 +1211,7 @@ export default class NPMPlugin implements IPlugin {
         const publishArgs = ["--tag", "canary"];
         await execPromise("npm", [
           "publish",
+          ...getPublishFolderArgs(this.publishFolder), // this is a positional parameter
           ...publishArgs,
           ...verboseArgs,
           ...getLegacyAuthArgs(this.legacyAuth),
@@ -1279,6 +1304,7 @@ export default class NPMPlugin implements IPlugin {
             // you always want a next version to publish
             !isIndependent && "--force-publish",
             ...(await getRegistryArgs()),
+            ...getPublishFolderArgs(this.publishFolder, { isMonorepo: true }),
             ...getLegacyAuthArgs(this.legacyAuth, { isMonorepo: true }),
             // skip prompts
             "--yes",
@@ -1359,6 +1385,7 @@ export default class NPMPlugin implements IPlugin {
           } else {
             await execPromise("npm", [
               "publish",
+              ...getPublishFolderArgs(this.publishFolder), // this is a positional parameter
               "--tag",
               prereleaseBranch,
               ...verboseArgs,
@@ -1412,6 +1439,7 @@ export default class NPMPlugin implements IPlugin {
           this.exact && "--exact",
           ...verboseArgs,
           ...(await getRegistryArgs()),
+          ...getPublishFolderArgs(this.publishFolder, { isMonorepo: true }),
           ...getLegacyAuthArgs(this.legacyAuth, { isMonorepo: true }),
         ]);
       } else {
@@ -1422,6 +1450,7 @@ export default class NPMPlugin implements IPlugin {
         } else {
           await execPromise("npm", [
             "publish",
+            ...getPublishFolderArgs(this.publishFolder), // this is a positional parameter
             ...tag,
             ...verboseArgs,
             ...getLegacyAuthArgs(this.legacyAuth),


Test patch:
diff --git a/plugins/npm/__tests__/npm-next.test.ts b/plugins/npm/__tests__/npm-next.test.ts
index 82d3ca6f..188e7809 100644
--- a/plugins/npm/__tests__/npm-next.test.ts
+++ b/plugins/npm/__tests__/npm-next.test.ts
@@ -231,6 +231,61 @@ describe("next", () => {
     ]);
   });
 
+  test("works with publishFolder", async () => {
+    mockFs({
+      "package.json": `
+      {
+        "name": "test",
+        "version": "1.2.4-next.0"
+      }
+      `,
+    });
+    const plugin = new NPMPlugin({ publishFolder: "dist/publish-folder" });
+    const hooks = makeHooks();
+
+    plugin.apply(({
+      config: { prereleaseBranches: ["next"] },
+      hooks,
+      remote: "origin",
+      baseBranch: "main",
+      logger: dummyLog(),
+      getCurrentVersion: () => "1.2.3",
+      prefixRelease: (v: string) => v,
+      git: {
+        getLatestRelease: () => "1.0.0",
+        getLastTagNotInBaseBranch: () => "1.2.3",
+      },
+    } as unknown) as Auto.Auto);
+
+    expect(
+      await hooks.next.promise([], { bump: Auto.SEMVER.patch } as any)
+    ).toStrictEqual(["1.2.4-next.0"]);
+
+    expect(execPromise).toHaveBeenCalledWith("npm", [
+      "version",
+      "1.2.4-next.0",
+      "--no-git-tag-version",
+    ]);
+    expect(execPromise).toHaveBeenCalledWith("git", [
+      "tag",
+      "1.2.4-next.0",
+      "-m",
+      '"Update version to 1.2.4-next.0"',
+    ]);
+    expect(execPromise).toHaveBeenCalledWith("git", [
+      "push",
+      "origin",
+      "next",
+      "--tags",
+    ]);
+    expect(execPromise).toHaveBeenCalledWith("npm", [
+      "publish",
+      "dist/publish-folder",
+      "--tag",
+      "next",
+    ]);
+  });
+
   test("works in monorepo", async () => {
     mockFs({
       "package.json": `
@@ -464,6 +519,81 @@ describe("next", () => {
     ]);
   });
 
+  test("works in monorepo - publishFolder", async () => {
+    mockFs({
+      "package.json": `
+      {
+        "name": "test",
+        "version": "1.2.3"
+      }
+      `,
+      "lerna.json": `
+        {
+          "version": "1.2.3"
+        }
+      `,
+    });
+    const plugin = new NPMPlugin({ publishFolder: "dist/publish-folder" });
+    const hooks = makeHooks();
+
+    // isMonorepo
+    monorepoPackages.mockReturnValueOnce([
+      {
+        path: "packages/a",
+        name: "@packages/a",
+        package: { version: "1.2.3" },
+      },
+      {
+        path: "packages/b",
+        name: "@packages/b",
+        package: { version: "1.2.4-next.0" },
+      },
+    ]);
+    execPromise.mockResolvedValueOnce("");
+    execPromise.mockResolvedValueOnce("");
+    execPromise.mockResolvedValueOnce("1.2.4-next.0");
+
+    plugin.apply(({
+      config: { prereleaseBranches: ["next"] },
+      hooks,
+      remote: "origin",
+      baseBranch: "main",
+      logger: dummyLog(),
+      getCurrentVersion: () => "1.2.3",
+      prefixRelease: (v: string) => v,
+      git: {
+        getLatestRelease: () => "1.0.0",
+        getLastTagNotInBaseBranch: () => "1.2.3",
+      },
+    } as unknown) as Auto.Auto);
+
+    expect(
+      await hooks.next.promise([], { bump: Auto.SEMVER.patch } as any)
+    ).toStrictEqual(["1.2.4-next.0"]);
+
+    expect(execPromise).toHaveBeenCalledWith(
+      "npx",
+      expect.arrayContaining([
+      "lerna", 
+      "publish", 
+      "1.2.4-next.0",
+      "--contents",
+      "dist/publish-folder"
+    ])
+    );
+    expect(execPromise).toHaveBeenCalledWith("git", [
+      "reset",
+      "--hard",
+      "HEAD~1",
+    ]);
+    expect(execPromise).toHaveBeenCalledWith("git", [
+      "push",
+      "origin",
+      "next",
+      "--tags",
+    ]);
+  });
+
   test("works in monorepo - independent", async () => {
     mockFs({
       "package.json": `
@@ -690,4 +820,4 @@ describe("next", () => {
       await hooks.next.promise([], { bump: Auto.SEMVER.patch } as any)
     ).toStrictEqual(["@foo/foo@1.0.0-next.0"]);
   });
-});
+});
\ No newline at end of file
diff --git a/plugins/npm/__tests__/npm.test.ts b/plugins/npm/__tests__/npm.test.ts
index 58c3908e..276e26e8 100644
--- a/plugins/npm/__tests__/npm.test.ts
+++ b/plugins/npm/__tests__/npm.test.ts
@@ -725,6 +725,30 @@ describe("publish", () => {
     ]);
   });
 
+  test("monorepo - should use publishFolder", async () => {
+    const plugin = new NPMPlugin({ publishFolder: "dist/publish-folder" });
+    const hooks = makeHooks();
+
+    plugin.apply({
+      config: { prereleaseBranches: ["next"] },
+      hooks,
+      remote: "origin",
+      baseBranch: "main",
+      logger: dummyLog(),
+    } as Auto.Auto);
+
+    await hooks.publish.promise({ bump: Auto.SEMVER.patch });
+    expect(execPromise).toHaveBeenCalledWith("npx", [
+      "lerna",
+      "publish",
+      "--yes",
+      "from-package",
+      false,
+      "--contents",
+      "dist/publish-folder",
+    ]);
+  });
+
   test("should use legacy", async () => {
     mockFs({
       "package.json": `
@@ -755,6 +779,35 @@ describe("publish", () => {
     ]);
   });
 
+  test("should use publishFolder", async () => {
+    mockFs({
+      "package.json": `
+        {
+          "name": "test",
+          "version": "0.0.0"
+        }
+      `,
+    });
+    const plugin = new NPMPlugin({ publishFolder: "dist/publish-folder" });
+    const hooks = makeHooks();
+
+    plugin.apply({
+      config: { prereleaseBranches: ["next"] },
+      hooks,
+      remote: "origin",
+      baseBranch: "main",
+      logger: dummyLog(),
+    } as Auto.Auto);
+
+    execPromise.mockReturnValueOnce("1.0.0");
+
+    await hooks.publish.promise({ bump: Auto.SEMVER.patch });
+    expect(execPromise).toHaveBeenCalledWith("npm", [
+      "publish",
+      "dist/publish-folder",
+    ]);
+  });
+
   test("should bump published version", async () => {
     mockFs({
       "package.json": `
@@ -1024,6 +1077,95 @@ describe("canary", () => {
     ]);
   });
 
+  test("should use publishFolder", async () => {
+    mockFs({
+      "package.json": `
+        {
+          "name": "test"
+        }
+      `,
+    });
+    const plugin = new NPMPlugin({ publishFolder: "dist/publish-folder" });
+    const hooks = makeHooks();
+
+    plugin.apply(({
+      config: { prereleaseBranches: ["next"] },
+      hooks,
+      remote: "origin",
+      baseBranch: "main",
+      logger: dummyLog(),
+      getCurrentVersion: () => "1.2.3",
+      git: {
+        getLatestRelease: () => "1.2.3",
+        getLatestTagInBranch: () => Promise.resolve("1.2.3"),
+      },
+    } as unknown) as Auto.Auto);
+
+    await hooks.canary.promise({
+      bump: Auto.SEMVER.patch,
+      canaryIdentifier: "canary.123.1",
+    });
+    expect(execPromise).toHaveBeenCalledWith("npm", [
+      "publish",
+      "dist/publish-folder",
+      "--tag",
+      "canary",
+    ]);
+  });
+
+  test("monorepo - should use publishFolder", async () => {
+    const plugin = new NPMPlugin({ publishFolder: "dist/publish-folder" });
+    const hooks = makeHooks();
+
+    plugin.apply({
+      config: { prereleaseBranches: ["next"] },
+      hooks,
+      remote: "origin",
+      baseBranch: "main",
+      logger: dummyLog(),
+      git: {
+        getLatestRelease: () => Promise.resolve("1.2.3"),
+        getLatestTagInBranch: () => Promise.resolve("1.2.3"),
+      },
+    } as any);
+
+    const packages = [
+      {
+        path: "path/to/package",
+        name: "@foobar/app",
+        version: "1.2.3-canary.0+abcd",
+      },
+      {
+        path: "path/to/package",
+        name: "@foobar/lib",
+        version: "1.2.3-canary.0+abcd",
+      },
+    ];
+
+    monorepoPackages.mockReturnValueOnce(packages.map((p) => ({ package: p })));
+    getLernaPackages.mockImplementation(async () => Promise.resolve(packages));
+
+    await hooks.canary.promise({
+      bump: Auto.SEMVER.patch,
+      canaryIdentifier: "",
+    });
+    expect(execPromise).toHaveBeenCalledWith("npx", [
+      "lerna",
+      "publish",
+      "1.2.4-0",
+      "--dist-tag",
+      "canary",
+      "--contents",
+      "dist/publish-folder",
+      "--force-publish",
+      "--yes",
+      "--no-git-reset",
+      "--no-git-tag-version",
+      "--exact",
+      "--no-verify-access"
+    ]);
+  });
+
   test("use handles repos with no tags", async () => {
     mockFs({
       "package.json": `