diff --git a/.github/workflows/onPushToMain.yml b/.github/workflows/onPushToMain.yml index c2babc3..d3c3a77 100644 --- a/.github/workflows/onPushToMain.yml +++ b/.github/workflows/onPushToMain.yml @@ -11,6 +11,11 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 + with: + node-version: latest + - run: | + npm install -g corepack --force + corepack enable - name: Check if version already exists id: version-check run: | diff --git a/.github/workflows/onRelease.yml b/.github/workflows/onRelease.yml index 12c9cf2..680809c 100644 --- a/.github/workflows/onRelease.yml +++ b/.github/workflows/onRelease.yml @@ -12,6 +12,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: latest + - run: | + npm install -g corepack --force + corepack enable - run: yarn - uses: JS-DevTools/npm-publish@19c28f1ef146469e409470805ea4279d47c3d35c with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0a2b4a..b10cf29 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node_version }} - cache: yarn + - run: | + npm install -g corepack --force + corepack enable - run: yarn - run: yarn build - run: yarn test diff --git a/src/commands/commit/index.ts b/src/commands/commit/index.ts index 5488938..5579b5c 100644 --- a/src/commands/commit/index.ts +++ b/src/commands/commit/index.ts @@ -43,81 +43,90 @@ export default class Commit extends Command { this.log(`[tf-patch] Found module "${module}"`) - dir({keep: false, unsafeCleanup: true}, async (err, dirA, cleanup) => { - if (err) { - throw err - } - - await fse.copy(moduleDir, dirA) - await fse.remove(path.join(dirA, '.git')) - - this.log(`[tf-patch] Diffing source module and patch dir: \nA: ${dirA}\nB: ${dirB}`) - // Using yarn as reference - https://github.com/yarnpkg/berry/blob/7fab4f101d1b8a98691efe14696d18ce00ecd234/packages/plugin-patch/sources/patchUtils.ts#L281-L298 - const gitDiff = await spawn( - `git`, - [ - `-c`, - `core.safecrlf=false`, - `diff`, - `--src-prefix=a/`, - `--dst-prefix=b/`, - `--ignore-cr-at-eol`, - `--full-index`, - `--no-index`, - `--no-renames`, - `--text`, - dirA, - dirB, - ], - { - env: { - ...process.env, - // #region Predictable output - // These variables aim to ignore the global git config so we get predictable output - // https://git-scm.com/docs/git#Documentation/git.txt-codeGITCONFIGNOSYSTEMcode - GIT_CONFIG_NOSYSTEM: `1`, - HOME: ``, - USERPROFILE: ``, - XDG_CONFIG_HOME: ``, - // #endregion - }, - }, - ) - const stderr = (await gitDiff.stderr.toArray()).join('\n') - // we cannot rely on exit code, because --no-index implies --exit-code - // i.e. git diff will exit with 1 if there were differences - if (stderr.length > 0) { - throw new Error( - `Unable to diff directories. Make sure you have a recent version of 'git' available in PATH.\nThe following error was reported by 'git':\n${stderr}`, - ) - } - - const controller = new AbortController() - const timer = setTimeout(() => { - this.log('[tf-patch] Timed out creating patch') - controller.abort() - }, 10_000) - const stdoutArr = await gitDiff.stdout.toArray({signal: controller.signal}) - clearTimeout(timer) - - const stdout = stdoutArr - .join('\n') - .replace(new RegExp(`(a|b)(${_.escapeRegExp(`/${normalizePath(dirA)}/`)})`, `g`), `$1/`) - .replace(new RegExp(`(a|b)${_.escapeRegExp(`/${normalizePath(dirB)}/`)}`, `g`), `$1/`) - .replace(new RegExp(_.escapeRegExp(`${dirA}/`), `g`), ``) - .replace(new RegExp(_.escapeRegExp(`${dirB}/`), `g`), ``) - - if (!stdout.trim()) { - throw new Error(`No changes detected in module "${module}"`) - } - - await fse.ensureDir(PATCHES_DIR) - const patchFile = path.join(PATCHES_DIR, `${module}.patch`) - await writeFile(patchFile, stdout) - this.log(`[tf-patch] Created patch file: ${patchFile}`) - this.log(`[tf-patch] Now you can apply the patches after "terraform init" by running "tf-patch init"`) - - cleanup() + await new Promise((resolve, reject) => { + dir({keep: false, unsafeCleanup: true}, async (err, dirA, cleanup) => { + if (err) { + reject(err) + return + } + + try { + await fse.copy(moduleDir, dirA) + await fse.remove(path.join(dirA, '.git')) + + this.log(`[tf-patch] Diffing source module and patch dir: \nA: ${dirA}\nB: ${dirB}`) + // Using yarn as reference - https://github.com/yarnpkg/berry/blob/7fab4f101d1b8a98691efe14696d18ce00ecd234/packages/plugin-patch/sources/patchUtils.ts#L281-L298 + const gitDiff = await spawn( + `git`, + [ + `-c`, + `core.safecrlf=false`, + `diff`, + `--src-prefix=a/`, + `--dst-prefix=b/`, + `--ignore-cr-at-eol`, + `--full-index`, + `--no-index`, + `--no-renames`, + `--text`, + dirA, + dirB, + ], + { + env: { + ...process.env, + // #region Predictable output + // These variables aim to ignore the global git config so we get predictable output + // https://git-scm.com/docs/git#Documentation/git.txt-codeGITCONFIGNOSYSTEMcode + GIT_CONFIG_NOSYSTEM: `1`, + HOME: ``, + USERPROFILE: ``, + XDG_CONFIG_HOME: ``, + // #endregion + }, + }, + ) + const stderr = (await gitDiff.stderr.toArray()).join('\n') + // we cannot rely on exit code, because --no-index implies --exit-code + // i.e. git diff will exit with 1 if there were differences + if (stderr.length > 0) { + throw new Error( + `Unable to diff directories. Make sure you have a recent version of 'git' available in PATH.\nThe following error was reported by 'git':\n${stderr}`, + ) + } + + const controller = new AbortController() + const timer = setTimeout(() => { + this.log('[tf-patch] Timed out creating patch') + controller.abort() + }, 10_000) + const stdoutArr = await gitDiff.stdout.toArray({signal: controller.signal}) + clearTimeout(timer) + + const stdout = stdoutArr + .join('\n') + .replace(new RegExp(`(a|b)(${_.escapeRegExp(`/${normalizePath(dirA)}/`)})`, `g`), `$1/`) + .replace(new RegExp(`(a|b)${_.escapeRegExp(`/${normalizePath(dirB)}/`)}`, `g`), `$1/`) + .replace(new RegExp(_.escapeRegExp(`${dirA}/`), `g`), ``) + .replace(new RegExp(_.escapeRegExp(`${dirB}/`), `g`), ``) + + if (!stdout.trim()) { + throw new Error(`No changes detected in module "${module}"`) + } + + await fse.ensureDir(PATCHES_DIR) + const patchFile = path.join(PATCHES_DIR, `${module}.patch`) + await writeFile(patchFile, stdout) + this.log(`[tf-patch] Created patch file: ${patchFile}`) + this.log(`[tf-patch] Now you can apply the patches after "terraform init" by running "tf-patch init"`) + + cleanup() + resolve() + } catch (error) { + cleanup() + reject(error) + } + }) }) } } diff --git a/src/commands/patch/index.ts b/src/commands/patch/index.ts index e6721a7..9461bc5 100644 --- a/src/commands/patch/index.ts +++ b/src/commands/patch/index.ts @@ -37,27 +37,35 @@ intended to be editable at will.` throw new Error(`Module "${module}" not found in terraform modules directory`) } - dir(async (err, tmpDir) => { - if (err) { - throw err - } - - await writeFile( - path.join(tmpDir, PATCH_DATA_FILE), - JSON.stringify({ - module, - }), - ) - - const tmpModule = path.join(tmpDir, 'module') - await fse.ensureDir(tmpModule) - await fse.copy(moduleDir, tmpModule) - await fse.remove(path.join(tmpModule, '.git')) - - this.log(`[tf-patch] You can now edit the following folder: ${tmpModule}`) - this.log( - `[tf-patch] Once you are done run "tf-patch commit ${tmpModule}" and we will store a patchfile based on your changes.`, - ) + await new Promise((resolve, reject) => { + dir(async (err, tmpDir) => { + if (err) { + reject(err) + return + } + + try { + await writeFile( + path.join(tmpDir, PATCH_DATA_FILE), + JSON.stringify({ + module, + }), + ) + + const tmpModule = path.join(tmpDir, 'module') + await fse.ensureDir(tmpModule) + await fse.copy(moduleDir, tmpModule) + await fse.remove(path.join(tmpModule, '.git')) + + this.log(`[tf-patch] You can now edit the following folder: ${tmpModule}`) + this.log( + `[tf-patch] Once you are done run "tf-patch commit ${tmpModule}" and we will store a patchfile based on your changes.`, + ) + resolve() + } catch (error) { + reject(error) + } + }) }) } } diff --git a/test/commands/commit/index.test.ts b/test/commands/commit/index.test.ts new file mode 100644 index 0000000..f563bd7 --- /dev/null +++ b/test/commands/commit/index.test.ts @@ -0,0 +1,61 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import * as fse from 'fs-extra' +import {mkdtempSync} from 'node:fs' +import {readFile, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import path from 'node:path' + +import {PATCHES_DIR} from '../../../src/commands/commit/index.js' +import {PATCH_DATA_FILE, TERRAFORM_MODULES_DIR} from '../../../src/commands/patch/index.js' + +describe('commit', () => { + let tmpDir: string + let originalCwd: string + + before(() => { + originalCwd = process.cwd() + tmpDir = mkdtempSync(path.join(tmpdir(), 'tf-patch-commit-test-')) + }) + + after(async () => { + process.chdir(originalCwd) + await fse.remove(tmpDir) + }) + + it('throws an error when folder is not found', async () => { + const {error} = await runCommand('commit /nonexistent-folder-xyz-abc') + expect(error?.message).to.contain('not found') + }) + + it('creates a patch file from a modified module', async () => { + process.chdir(tmpDir) + + const module = 'test-module' + const moduleDir = path.join(tmpDir, TERRAFORM_MODULES_DIR, module) + await fse.ensureDir(moduleDir) + await writeFile(path.join(moduleDir, 'main.tf'), 'resource "null_resource" "original" {}\n') + + // Create the patched folder that commit expects + const patchedDir = mkdtempSync(path.join(tmpdir(), 'tf-patch-patched-')) + const moduleFolder = path.join(patchedDir, 'module') + await fse.ensureDir(moduleFolder) + await writeFile(path.join(moduleFolder, 'main.tf'), 'resource "null_resource" "modified" {}\n') + await writeFile( + path.join(patchedDir, PATCH_DATA_FILE), + JSON.stringify({module}), + ) + + try { + const {stdout} = await runCommand(`commit ${moduleFolder}`) + expect(stdout).to.contain('Created patch file') + const patchFile = path.join(tmpDir, PATCHES_DIR, `${module}.patch`) + expect(await fse.pathExists(patchFile)).to.equal(true) + const patchContent = await readFile(patchFile, 'utf8') + expect(patchContent).to.contain('original') + expect(patchContent).to.contain('modified') + } finally { + await fse.remove(patchedDir) + } + }) +}) diff --git a/test/commands/hello/index.test.ts b/test/commands/hello/index.test.ts deleted file mode 100644 index dad0ac3..0000000 --- a/test/commands/hello/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {runCommand} from '@oclif/test' -import {expect} from 'chai' - -describe('hello', () => { - it('runs hello', async () => { - const {stdout} = await runCommand('hello friend --from oclif') - expect(stdout).to.contain('hello friend from oclif!') - }) -}) diff --git a/test/commands/hello/world.test.ts b/test/commands/hello/world.test.ts deleted file mode 100644 index 0f5e90f..0000000 --- a/test/commands/hello/world.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {runCommand} from '@oclif/test' -import {expect} from 'chai' - -describe('hello world', () => { - it('runs hello world cmd', async () => { - const {stdout} = await runCommand('hello world') - expect(stdout).to.contain('hello world!') - }) -}) diff --git a/test/commands/init/index.test.ts b/test/commands/init/index.test.ts new file mode 100644 index 0000000..826d552 --- /dev/null +++ b/test/commands/init/index.test.ts @@ -0,0 +1,83 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import * as fse from 'fs-extra' +import {mkdtempSync} from 'node:fs' +import {readFile, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import path from 'node:path' + +import {PATCHES_DIR} from '../../../src/commands/commit/index.js' +import {TERRAFORM_MODULES_DIR} from '../../../src/commands/patch/index.js' + +const SAMPLE_PATCH = `diff --git a/main.tf b/main.tf +index abc1234..def5678 100644 +--- a/main.tf ++++ b/main.tf +@@ -1,3 +1,3 @@ + resource "null_resource" "example" { +- triggers = {} ++ triggers = { always_run = true } + } +` + +describe('init', () => { + let tmpDir: string + let originalCwd: string + + before(() => { + originalCwd = process.cwd() + }) + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'tf-patch-init-test-')) + process.chdir(tmpDir) + }) + + afterEach(async () => { + process.chdir(originalCwd) + await fse.remove(tmpDir) + }) + + it('throws an error when no patches are found', async () => { + const {error} = await runCommand('init') + expect(error?.message).to.contain('No terraform module patches found') + }) + + it('throws an error when module does not exist', async () => { + await fse.ensureDir(path.join(tmpDir, PATCHES_DIR)) + await writeFile(path.join(tmpDir, PATCHES_DIR, 'missing-module.patch'), SAMPLE_PATCH) + + const {error} = await runCommand('init') + expect(error?.message).to.contain('missing-module') + expect(error?.message).to.contain('does not exist') + }) + + it('applies a patch to a terraform module', async () => { + const module = 'apply-test-module' + const moduleDir = path.join(tmpDir, TERRAFORM_MODULES_DIR, module) + await fse.ensureDir(moduleDir) + await writeFile( + path.join(moduleDir, 'main.tf'), + 'resource "null_resource" "example" {\n triggers = {}\n}\n', + ) + + const patch = `diff --git a/main.tf b/main.tf +index abc1234..def5678 100644 +--- a/main.tf ++++ b/main.tf +@@ -1,3 +1,3 @@ + resource "null_resource" "example" { +- triggers = {} ++ triggers = { always_run = true } + } +` + await fse.ensureDir(path.join(tmpDir, PATCHES_DIR)) + await writeFile(path.join(tmpDir, PATCHES_DIR, `${module}.patch`), patch) + + const {stdout} = await runCommand('init') + expect(stdout).to.contain(`Updated module, "${module}"`) + + const updated = await readFile(path.join(moduleDir, 'main.tf'), 'utf8') + expect(updated).to.contain('always_run = true') + }) +}) diff --git a/test/commands/patch/index.test.ts b/test/commands/patch/index.test.ts new file mode 100644 index 0000000..534ef63 --- /dev/null +++ b/test/commands/patch/index.test.ts @@ -0,0 +1,41 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import * as fse from 'fs-extra' +import {mkdtempSync} from 'node:fs' +import {writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import path from 'node:path' + +import {TERRAFORM_MODULES_DIR} from '../../../src/commands/patch/index.js' + +describe('patch', () => { + let tmpDir: string + let originalCwd: string + + before(() => { + originalCwd = process.cwd() + tmpDir = mkdtempSync(path.join(tmpdir(), 'tf-patch-test-')) + }) + + after(async () => { + process.chdir(originalCwd) + await fse.remove(tmpDir) + }) + + it('throws an error when module is not found', async () => { + process.chdir(tmpDir) + const {error} = await runCommand('patch nonexistent-module-xyz') + expect(error?.message).to.contain('not found in terraform modules directory') + }) + + it('creates a tmp module folder and patch data file for a valid module', async () => { + process.chdir(tmpDir) + const moduleDir = path.join(tmpDir, TERRAFORM_MODULES_DIR, 'my-module') + await fse.ensureDir(moduleDir) + await writeFile(path.join(moduleDir, 'main.tf'), 'resource "null_resource" "example" {}') + + const {stdout} = await runCommand('patch my-module') + expect(stdout).to.contain('You can now edit the following folder') + expect(stdout).to.contain('tf-patch commit') + }) +})