Compare commits

..

2 Commits

Author SHA1 Message Date
undergroundwires
58f902216b Fix disabling of Microsoft Defender $170
- Change naming from Windows Defender to Microsoft Defender to match
  latest branding.
- Add more extensive documentation.
- Add more scripts extending ways to disable Defender.
- Disable "Windows Security Center Service"
- Add missing `SetMpPreference` commands
- New disabling:
  - Disabling of Windows features related to Defender.
  - Disable Antimalware Scan Interface (AMSI)

TODO: Soft delete Defender directories, like
`$env:programdata\Microsoft\Windows Defender`

TODO: Add from here: https://learn.microsoft.com/en-us/mem/intune/protect/antivirus-security-experience-windows-settings

New scripts:

- Disable "Windows Security Center" service
- Kill SmartScreen process
- Disable "Microsoft Security Core Boot" service

Improved scripts:

- Disable Intrusion Prevention System (IPS): Add CLI command to disable
  it.

TODO: These to separate commit

TODO:

- Improve disabling of `RenameSystemFile` AsTrustedInstaller and get
  back all commented out code.
2024-07-18 09:48:06 +02:00
undergroundwires
48d6dbd700 Refactor to use string IDs for executables #262
This commit unifies the concepts of executables having same ID
structure. It paves the way for more complex ID structure and using IDs
in collection files as part of new ID solution (#262). Using string IDs
also leads to more expressive test code.

This commit also refactors the rest of the code to adopt to the changes.

This commit:

- Separate concerns from entities for data access (in repositories) and
  executables. Executables use `Identifiable` meanwhile repositories use
  `RepositoryEntity`.
- Refactor unnecessary generic parameters for enttities and ids,
  enforcing string gtype everwyhere.
- Changes numeric IDs to string IDs for categories to unify the
  retrieval and construction for executables, using pseudo-ids (their
  names) just like scripts.
- Remove `BaseEntity` for simplicity.
- Simplify usage and construction of executable objects.
  Move factories responsible for creation of category/scripts to domain
  layer. Do not longer export `CollectionCategorY` and
  `CollectionScript`.
- Use named typed for string IDs for better differentation of different
  ID contexts in code.
2024-07-08 23:23:05 +02:00
366 changed files with 12761 additions and 33729 deletions

View File

@@ -1,12 +0,0 @@
inputs:
project-root:
required: false
default: '.'
runs:
using: composite
steps:
-
name: Install ImageMagick
shell: bash
run: ./.github/actions/install-imagemagick/install-imagemagick.sh
working-directory: ${{ inputs.project-root }}

View File

@@ -1,56 +0,0 @@
#!/usr/bin/env bash
main() {
local install_command
if ! install_command=$(get_install_command); then
fatal_error 'Could not find available command to install'
fi
if ! eval "$install_command"; then
echo "Failed to install ImageMagick. Command: ${install_command}"
exit 1
fi
echo 'ImageMagick installation completed successfully'
}
get_install_command() {
case "$OSTYPE" in
darwin*)
ensure_command_exists 'brew'
echo 'brew install imagemagick'
;;
linux-gnu*)
if is_ubuntu; then
ensure_command_exists 'apt'
echo 'sudo apt install -y imagemagick'
else
fatal_error 'Unsupported Linux distribution'
fi
;;
msys*|cygwin*)
ensure_command_exists 'choco'
echo 'choco install -y imagemagick'
;;
*)
fatal_error "Unsupported operating system: $OSTYPE"
;;
esac
}
ensure_command_exists() {
local -r command="$1"
if ! command -v "$command" >/dev/null 2>&1; then
fatal_error "Command missing: $command"
fi
}
fatal_error() {
local -r error_message="$1"
>&2 echo "$error_message"
exit 1
}
is_ubuntu() {
[ -f /etc/os-release ] && grep -qi 'ubuntu' /etc/os-release
}
main

View File

@@ -26,12 +26,9 @@ jobs:
- -
name: Install dependencies name: Install dependencies
uses: ./.github/actions/npm-install-dependencies uses: ./.github/actions/npm-install-dependencies
-
name: Install ImageMagick # For screenshots
uses: ./.github/actions/install-imagemagick
- -
name: Configure Ubuntu name: Configure Ubuntu
if: contains(matrix.os, 'ubuntu') if: contains(matrix.os, 'ubuntu') # macOS runner is missing Docker
shell: bash shell: bash
run: |- run: |-
sudo apt update sudo apt update
@@ -59,20 +56,11 @@ jobs:
sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
echo "DISPLAY=:99" >> $GITHUB_ENV echo "DISPLAY=:99" >> $GITHUB_ENV
# Install ImageMagick for screenshots
sudo apt install -y imagemagick
# Install xdotool and xprop (from x11-utils) for window title capturing # Install xdotool and xprop (from x11-utils) for window title capturing
sudo apt install -y xdotool x11-utils sudo apt install -y xdotool x11-utils
# Workaround for Electron AppImage apps failing to initialize on Ubuntu 24.04 due to AppArmor restrictions
# Disables unprivileged user namespaces restriction to allow Electron apps to run
# Reference: https://github.com/electron/electron/issues/42510
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
# Workaround for Mesa driver issues on Ubuntu 24.04
# Installs latest Mesa drivers from Kisak PPA
# Reference: https://askubuntu.com/q/1516040
sudo add-apt-repository ppa:kisak/kisak-mesa
sudo apt update
sudo apt upgrade
- -
name: Test name: Test
shell: bash shell: bash

View File

@@ -11,9 +11,8 @@ jobs:
- npm run lint:eslint - npm run lint:eslint
- npm run lint:yaml - npm run lint:yaml
- npm run lint:md - npm run lint:md
- npm run lint:md:consistency
- npm run lint:md:relative-urls - npm run lint:md:relative-urls
- npm run lint:md:external-urls - npm run lint:md:consistency
os: [ macos, ubuntu, windows ] os: [ macos, ubuntu, windows ]
fail-fast: false # Still interested to see results from other combinations fail-fast: false # Still interested to see results from other combinations
steps: steps:

View File

@@ -9,15 +9,16 @@ jobs:
runs-on: ${{ matrix.os }}-latest runs-on: ${{ matrix.os }}-latest
strategy: strategy:
matrix: matrix:
os: [macos, ubuntu, windows] os: [ macos, ubuntu, windows ]
fail-fast: false # Still interested to see results from other combinations fail-fast: false # Still interested to see results from other combinations
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- -
name: Install ImageMagick name: Install ImageMagick on macOS
uses: ./.github/actions/install-imagemagick if: matrix.os == 'macos'
run: brew install imagemagick
- -
name: Setup node name: Setup node
uses: ./.github/actions/setup-node uses: ./.github/actions/setup-node

View File

@@ -1,38 +1,5 @@
# Changelog # Changelog
## 0.13.6 (2024-08-13)
* win: improve service disabling as TrustedInstaller | [5d365f6](https://github.com/undergroundwires/privacy.sexy/commit/5d365f65fa0e34925b16b2eac2af53c31e34e99a)
* Fix documentation button spacing on small screens | [70959cc](https://github.com/undergroundwires/privacy.sexy/commit/70959ccadafac5abcfa83e90cdb0537890b05f14)
* Fix close button overlap by scrollbar | [19ea8db](https://github.com/undergroundwires/privacy.sexy/commit/19ea8dbc5bc2dc436200cd40bf2a84c3fc3c6471)
* win: refactor version-specific actions | [0239b52](https://github.com/undergroundwires/privacy.sexy/commit/0239b523859d5c2b80033cc03f0248a9af35f28f)
* win: support Microsoft Store Firefox installations | [8d7a7eb](https://github.com/undergroundwires/privacy.sexy/commit/8d7a7eb434b2d83e32fa758db7e6798849bad41c)
* Refactor text utilities and expand their usage | [851917e](https://github.com/undergroundwires/privacy.sexy/commit/851917e049c41c679644ddbe8ad4b6e45e5c8f35)
* Bump dependencies to latest | [dd7239b](https://github.com/undergroundwires/privacy.sexy/commit/dd7239b8c14027274926279a4c8c7e5845b55558)
* Refactor styles to match new CSS nesting behavior | [abe03ce](https://github.com/undergroundwires/privacy.sexy/commit/abe03cef3f691f6e56faee991cd2da9c45244279)
* Improve compiler error display for latest Chromium | [b16e136](https://github.com/undergroundwires/privacy.sexy/commit/b16e13678ce1b8a6871eba8196e82bb321410067)
* Fix intermittent `ModalDialog` unit test failures | [a650558](https://github.com/undergroundwires/privacy.sexy/commit/a6505587bf4a448f5f3de930004a95ee203416b8)
* Ensure tests do not log warning or errors | [ae0165f](https://github.com/undergroundwires/privacy.sexy/commit/ae0165f1fe7dba9dd8ddaa1afa722a939772d3b6)
* win: improve disabling SmartScreen #385 | [11e566d](https://github.com/undergroundwires/privacy.sexy/commit/11e566d0e5177214a2600f3fd2097aea62373b24)
* win: unify registry setting as TrustedInstaller | [8526d25](https://github.com/undergroundwires/privacy.sexy/commit/8526d2510b34cbd7e79342f79d444419f601b186)
* win: improve, fix, restructure CEIP disabling | [c2d3cdd](https://github.com/undergroundwires/privacy.sexy/commit/c2d3cddc47d8d4b34bff63d959612919fa971012)
* win: centralize, improve Defender data collection | [b185255](https://github.com/undergroundwires/privacy.sexy/commit/b185255a0a72d5bfa96d6cf60f868ecc67149d68)
* win: fix and document VStudio license removal | [109fc01](https://github.com/undergroundwires/privacy.sexy/commit/109fc01c9a047002c4309e7f8a2ca4647c494a8a)
* win: improve registry/recent cleaning | [48d97af](https://github.com/undergroundwires/privacy.sexy/commit/48d97afdf6c2964cab7951208e1b0a02c3fd4c9b)
* Relax linting to allow null recommendation | [6fbc816](https://github.com/undergroundwires/privacy.sexy/commit/6fbc81675f7f063c4ee2502b8d9f169aacb39ae4)
* Refactor executable IDs to use strings #262 | [ded55a6](https://github.com/undergroundwires/privacy.sexy/commit/ded55a66d6044a03d4e18330e146b69d159509a3)
* win: fix, improve and unify Windows version logic | [f89c232](https://github.com/undergroundwires/privacy.sexy/commit/f89c2322b05d19b82914b20416ecefd7bc7e3702)
* Fix PowerShell code block inlining in compiler | [d77c3cb](https://github.com/undergroundwires/privacy.sexy/commit/d77c3cbbe212d9929e083181cc331b45d01e2883)
* win: improve registry value deletion #381 | [55c23e9](https://github.com/undergroundwires/privacy.sexy/commit/55c23e9d4cee3b7f74c26a4ac8516535048d67f2)
* win: improve folder hiding in "This PC" #16 | [e8add5e](https://github.com/undergroundwires/privacy.sexy/commit/e8add5ec08d2e8b7636cc9c8f0f9a33e4b004265)
* win: improve Microsoft Edge associations removal | [c2f4b68](https://github.com/undergroundwires/privacy.sexy/commit/c2f4b6878635e97f9c4be7bf2ee194a2deebb38a)
* win: unify registry data setting, fix #380 | [4cea6b2](https://github.com/undergroundwires/privacy.sexy/commit/4cea6b26ec2717c792c2471cc587f370274f90c4)
* win: improve disabling NCSI #189, #216, #279 | [c7e57b8](https://github.com/undergroundwires/privacy.sexy/commit/c7e57b8913f409a1c149ba598dc2f8786df0f9a9)
* win, mac: fix minor typos, formatting, dead URLs | [29e1069](https://github.com/undergroundwires/privacy.sexy/commit/29e1069bf2bc317e3c255b38c1ba0ab078b42d98)
* win: fix, constrain and document WNS #227 #314 | [50ba00b](https://github.com/undergroundwires/privacy.sexy/commit/50ba00b0af6232fc9187532635b04c4d9d9a68af)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.13.5...0.13.6)
## 0.13.5 (2024-06-26) ## 0.13.5 (2024-06-26)
* ci/cd: centralize and bump artifact uploads | [22d6c79](https://github.com/undergroundwires/privacy.sexy/commit/22d6c7991eb2c138578a7d41950f301906dbf703) * ci/cd: centralize and bump artifact uploads | [22d6c79](https://github.com/undergroundwires/privacy.sexy/commit/22d6c7991eb2c138578a7d41950f301906dbf703)

View File

@@ -122,7 +122,7 @@
## Get started ## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy). - 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-Setup-0.13.6.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-0.13.6.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-0.13.6.AppImage). For more options, see [here](#additional-install-options). - 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-Setup-0.13.5.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-0.13.5.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-0.13.5.AppImage). For more options, see [here](#additional-install-options).
See also: See also:

View File

@@ -30,7 +30,7 @@ Related documentation:
### Executables ### Executables
They represent independently executable actions with documentation and reversibility. They represent independently executable tweaks with documentation and reversibility.
An Executable is a logical entity that can An Executable is a logical entity that can

View File

@@ -34,10 +34,8 @@ The desktop version ensures secure delivery through cryptographic signatures and
[Security is a top priority](./../../SECURITY.md#update-security-and-integrity) at privacy.sexy. [Security is a top priority](./../../SECURITY.md#update-security-and-integrity) at privacy.sexy.
> **Note for macOS users:** > **Note for macOS users:** On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs.
> On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs.
> Users get notified about updates but might need to complete the installation manually. > Users get notified about updates but might need to complete the installation manually.
> Updater stores update installation files temporarily at `$HOME/Library/Application Support/privacy.sexy/updates`.
> Consider [donating](https://github.com/sponsors/undergroundwires) to help improve this process ❤️. > Consider [donating](https://github.com/sponsors/undergroundwires) to help improve this process ❤️.
### Logging ### Logging

View File

@@ -39,7 +39,6 @@ See [ci-cd.md](./ci-cd.md) for more information.
- Markdown: `npm run lint:md` - Markdown: `npm run lint:md`
- Markdown consistency `npm run lint:md:consistency` - Markdown consistency `npm run lint:md:consistency`
- Markdown relative URLs: `npm run lint:md:relative-urls` - Markdown relative URLs: `npm run lint:md:relative-urls`
- Markdown external URLs: `npm run lint:md:external-urls`
- JavaScript/TypeScript: `npm run lint:eslint` - JavaScript/TypeScript: `npm run lint:eslint`
- Yaml: `npm run lint:yaml` - Yaml: `npm run lint:yaml`

10061
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.13.6", "version": "0.13.5",
"private": true, "private": true,
"slogan": "Privacy is sexy", "slogan": "Privacy is sexy",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.", "description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.",
@@ -14,7 +14,7 @@
"test:integration": "vitest run --dir tests/integration", "test:integration": "vitest run --dir tests/integration",
"test:cy:run": "start-server-and-test \"vite build && vite preview --port 7070\" http://localhost:7070 \"cypress run --config baseUrl=http://localhost:7070\"", "test:cy:run": "start-server-and-test \"vite build && vite preview --port 7070\" http://localhost:7070 \"cypress run --config baseUrl=http://localhost:7070\"",
"test:cy:open": "start-server-and-test \"vite --port 7070 --mode production\" http://localhost:7070 \"cypress open --config baseUrl=http://localhost:7070\"", "test:cy:open": "start-server-and-test \"vite --port 7070 --mode production\" http://localhost:7070 \"cypress open --config baseUrl=http://localhost:7070\"",
"lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:md:external-urls && npm run lint:eslint && npm run lint:yaml && npm run lint:pylint", "lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml && npm run lint:pylint",
"install-deps": "node scripts/npm-install.js", "install-deps": "node scripts/npm-install.js",
"icons:build": "node scripts/logo-update.js", "icons:build": "node scripts/logo-update.js",
"check:desktop": "vitest run --dir tests/checks/desktop-runtime-errors --environment node", "check:desktop": "vitest run --dir tests/checks/desktop-runtime-errors --environment node",
@@ -28,61 +28,60 @@
"lint:md": "markdownlint **/*.md --ignore node_modules", "lint:md": "markdownlint **/*.md --ignore node_modules",
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent", "lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
"lint:md:relative-urls": "remark . --frail --use remark-validate-links", "lint:md:relative-urls": "remark . --frail --use remark-validate-links",
"lint:md:external-urls": "remark . --frail --use remark-lint-no-dead-urls",
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml", "lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
"lint:pylint": "pylint **/*.py", "lint:pylint": "pylint **/*.py",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps" "postuninstall": "electron-builder install-app-deps"
}, },
"dependencies": { "dependencies": {
"@floating-ui/vue": "^1.1.1", "@floating-ui/vue": "^1.0.6",
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.35.3", "ace-builds": "^1.33.0",
"electron-log": "^5.1.6", "electron-log": "^5.1.2",
"electron-progressbar": "^2.2.1", "electron-progressbar": "^2.2.1",
"electron-updater": "^6.2.1", "electron-updater": "^6.1.9",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"vue": "^3.4.32" "vue": "^3.4.27"
}, },
"devDependencies": { "devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.1.0", "@modyfi/vite-plugin-yaml": "^1.1.0",
"@rushstack/eslint-patch": "^1.10.3", "@rushstack/eslint-patch": "^1.10.2",
"@types/ace": "^0.0.52", "@types/ace": "^0.0.52",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/markdown-it": "^14.1.1", "@types/markdown-it": "^14.0.1",
"@typescript-eslint/eslint-plugin": "6.21.0", "@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0", "@typescript-eslint/parser": "6.21.0",
"@vitejs/plugin-legacy": "^5.4.1", "@vitejs/plugin-legacy": "^5.3.2",
"@vitejs/plugin-vue": "^5.0.5", "@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-airbnb-with-typescript": "^8.0.0", "@vue/eslint-config-airbnb-with-typescript": "^8.0.0",
"@vue/eslint-config-typescript": "12.0.0", "@vue/eslint-config-typescript": "12.0.0",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.5",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"cypress": "^13.13.1", "cypress": "^13.7.3",
"electron": "^31.2.1", "electron": "^31.0.2",
"electron-builder": "^24.13.3", "electron-builder": "^24.13.3",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-vite": "^2.3.0", "electron-vite": "^2.1.0",
"eslint": "8.57.0", "eslint": "8.57.0",
"eslint-plugin-cypress": "^3.3.0", "eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-vue": "^9.27.0", "eslint-plugin-vue": "^9.25.0",
"eslint-plugin-vuejs-accessibility": "^2.4.0", "eslint-plugin-vuejs-accessibility": "^2.2.1",
"jsdom": "^24.1.0", "jsdom": "^24.0.0",
"markdownlint-cli": "^0.41.0", "markdownlint-cli": "^0.39.0",
"postcss": "^8.4.39", "postcss": "^8.4.38",
"remark-cli": "^12.0.1", "remark-cli": "^12.0.0",
"remark-lint-no-dead-urls": "^2.0.0", "remark-lint-no-dead-urls": "^1.1.0",
"remark-preset-lint-consistent": "^6.0.0", "remark-preset-lint-consistent": "^6.0.0",
"remark-validate-links": "^13.0.1", "remark-validate-links": "^13.0.1",
"sass": "~1.79.4", "sass": "^1.75.0",
"start-server-and-test": "^2.0.4", "start-server-and-test": "^2.0.3",
"terser": "^5.31.3", "terser": "^5.30.3",
"tslib": "^2.6.3", "tslib": "^2.6.2",
"typescript": "~5.5.4", "typescript": "^5.4.5",
"vite": "^5.4.8", "vite": "^5.2.8",
"vitest": "^2.0.3", "vitest": "^1.5.0",
"vue-tsc": "^2.0.26", "vue-tsc": "^2.0.13",
"yaml-lint": "^1.7.0" "yaml-lint": "^1.7.0"
}, },
"//devDependencies": { "//devDependencies": {

View File

@@ -91,7 +91,7 @@ async function verifyFilesExist(directoryPath, filePatterns) {
if (!match) { if (!match) {
die( die(
`No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``, `No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``,
`\nFiles in directory:\n${files.map((file) => `- ${file}`).join('\n')}`, `\nFiles in directory:\n${files.map((file) => `\t- ${file}`).join('\n')}`,
); );
} }
} }

View File

@@ -33,25 +33,23 @@ function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
if (!casedValue) { if (!casedValue) {
throw new Error(`unknown ${enumName}: "${value}"`); throw new Error(`unknown ${enumName}: "${value}"`);
} }
return enumVariable[casedValue as keyof EnumVariable<T, TEnumValue>]; return enumVariable[casedValue as keyof typeof enumVariable];
} }
export function getEnumNames export function getEnumNames
<T extends EnumType, TEnumValue extends EnumType>( <T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>,
): (string & keyof EnumVariable<T, TEnumValue>)[] { ): string[] {
return Object return Object
.values(enumVariable) .values(enumVariable)
.filter(( .filter((enumMember): enumMember is string => isString(enumMember));
enumMember,
): enumMember is string & (keyof EnumVariable<T, TEnumValue>) => isString(enumMember));
} }
export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>( export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>,
): TEnumValue[] { ): TEnumValue[] {
return getEnumNames(enumVariable) return getEnumNames(enumVariable)
.map((name) => enumVariable[name]) as TEnumValue[]; .map((level) => enumVariable[level]) as TEnumValue[];
} }
export function assertInRange<T extends EnumType, TEnumValue extends EnumType>( export function assertInRange<T extends EnumType, TEnumValue extends EnumType>(

View File

@@ -1,25 +0,0 @@
import { isArray } from '@/TypeHelpers';
export type OptionalString = string | undefined | null;
export function filterEmptyStrings(
texts: readonly OptionalString[],
isArrayType: typeof isArray = isArray,
): string[] {
if (!isArrayType(texts)) {
throw new Error(`Invalid input: Expected an array, but received type ${typeof texts}.`);
}
assertArrayItemsAreStringLike(texts);
return texts
.filter((title): title is string => Boolean(title));
}
function assertArrayItemsAreStringLike(
texts: readonly unknown[],
): asserts texts is readonly OptionalString[] {
const invalidItems = texts.filter((item) => !(typeof item === 'string' || item === undefined || item === null));
if (invalidItems.length > 0) {
const invalidTypes = invalidItems.map((item) => typeof item).join(', ');
throw new Error(`Invalid array items: Expected items as string, undefined, or null. Received invalid types: ${invalidTypes}.`);
}
}

View File

@@ -1,29 +0,0 @@
import { isString } from '@/TypeHelpers';
import { splitTextIntoLines } from './SplitTextIntoLines';
export function indentText(
text: string,
indentLevel = 1,
utilities: TextIndentationUtilities = DefaultUtilities,
): string {
if (!utilities.isStringType(text)) {
throw new Error(`Indentation error: The input must be a string. Received type: ${typeof text}.`);
}
if (indentLevel <= 0) {
throw new Error(`Indentation error: The indent level must be a positive integer. Received: ${indentLevel}.`);
}
const indentation = '\t'.repeat(indentLevel);
return utilities.splitIntoLines(text)
.map((line) => (line ? `${indentation}${line}` : line))
.join('\n');
}
interface TextIndentationUtilities {
readonly splitIntoLines: typeof splitTextIntoLines;
readonly isStringType: typeof isString;
}
const DefaultUtilities: TextIndentationUtilities = {
splitIntoLines: splitTextIntoLines,
isStringType: isString,
};

View File

@@ -1,11 +0,0 @@
import { isString } from '@/TypeHelpers';
export function splitTextIntoLines(
text: string,
isStringType = isString,
): string[] {
if (!isStringType(text)) {
throw new Error(`Line splitting error: Expected a string but received type '${typeof text}'.`);
}
return text.split(/\r\n|\r|\n/);
}

View File

@@ -17,7 +17,7 @@ export class ApplicationContext implements IApplicationContext {
public currentOs: OperatingSystem; public currentOs: OperatingSystem;
public get state(): ICategoryCollectionState { public get state(): ICategoryCollectionState {
return this.getState(this.collection.os); return this.states[this.collection.os];
} }
private readonly states: StateMachine; private readonly states: StateMachine;
@@ -26,51 +26,30 @@ export class ApplicationContext implements IApplicationContext {
public readonly app: IApplication, public readonly app: IApplication,
initialContext: OperatingSystem, initialContext: OperatingSystem,
) { ) {
this.setContext(initialContext);
this.states = initializeStates(app); this.states = initializeStates(app);
this.changeContext(initialContext);
} }
public changeContext(os: OperatingSystem): void { public changeContext(os: OperatingSystem): void {
assertInRange(os, OperatingSystem);
if (this.currentOs === os) { if (this.currentOs === os) {
return; return;
} }
const collection = this.app.getCollection(os);
this.collection = collection;
const event: IApplicationContextChangedEvent = { const event: IApplicationContextChangedEvent = {
newState: this.getState(os), newState: this.states[os],
oldState: this.getState(this.currentOs), oldState: this.states[this.currentOs],
}; };
this.setContext(os);
this.contextChanged.notify(event); this.contextChanged.notify(event);
}
private setContext(os: OperatingSystem): void {
validateOperatingSystem(os, this.app);
this.collection = this.app.getCollection(os);
this.currentOs = os; this.currentOs = os;
} }
private getState(os: OperatingSystem): ICategoryCollectionState {
const state = this.states.get(os);
if (!state) {
throw new Error(`Operating system "${OperatingSystem[os]}" state is unknown.`);
}
return state;
}
}
function validateOperatingSystem(
os: OperatingSystem,
app: IApplication,
): void {
assertInRange(os, OperatingSystem);
if (!app.getSupportedOsList().includes(os)) {
throw new Error(`Operating system "${OperatingSystem[os]}" is not supported.`);
}
} }
function initializeStates(app: IApplication): StateMachine { function initializeStates(app: IApplication): StateMachine {
const machine = new Map<OperatingSystem, ICategoryCollectionState>(); const machine = new Map<OperatingSystem, ICategoryCollectionState>();
for (const collection of app.collections) { for (const collection of app.collections) {
machine.set(collection.os, new CategoryCollectionState(collection)); machine[collection.os] = new CategoryCollectionState(collection);
} }
return machine; return machine;
} }

View File

@@ -1,8 +1,6 @@
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import type { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import type { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { ICodeChangedEvent } from './ICodeChangedEvent'; import type { ICodeChangedEvent } from './ICodeChangedEvent';
export class CodeChangedEvent implements ICodeChangedEvent { export class CodeChangedEvent implements ICodeChangedEvent {
@@ -41,7 +39,7 @@ export class CodeChangedEvent implements ICodeChangedEvent {
return this.getPositionById(script.executableId); return this.getPositionById(script.executableId);
} }
private getPositionById(scriptId: ExecutableId): ICodePosition { private getPositionById(scriptId: string): ICodePosition {
const position = [...this.scripts.entries()] const position = [...this.scripts.entries()]
.filter(([s]) => s.executableId === scriptId) .filter(([s]) => s.executableId === scriptId)
.map(([, pos]) => pos) .map(([, pos]) => pos)
@@ -54,12 +52,12 @@ export class CodeChangedEvent implements ICodeChangedEvent {
} }
function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) { function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) {
const totalLines = splitTextIntoLines(script).length; const totalLines = script.split(/\r\n|\r|\n/).length;
const missingPositions = positions.filter((position) => position.endLine > totalLines); const missingPositions = positions.filter((position) => position.endLine > totalLines);
if (missingPositions.length > 0) { if (missingPositions.length > 0) {
throw new Error( throw new Error(
`Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"` `Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"`
+ ` (total code lines: ${totalLines}).`, + `(total code lines: ${totalLines}).`,
); );
} }
} }

View File

@@ -1,4 +1,3 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ICodeBuilder } from './ICodeBuilder'; import type { ICodeBuilder } from './ICodeBuilder';
const TotalFunctionSeparatorChars = 58; const TotalFunctionSeparatorChars = 58;
@@ -16,7 +15,7 @@ export abstract class CodeBuilder implements ICodeBuilder {
this.lines.push(''); this.lines.push('');
return this; return this;
} }
const lines = splitTextIntoLines(code); const lines = code.match(/[^\r\n]+/g);
if (lines) { if (lines) {
this.lines.push(...lines); this.lines.push(...lines);
} }

View File

@@ -4,7 +4,6 @@ import { EventSource } from '@/infrastructure/Events/EventSource';
import type { ReadonlyRepository, Repository } from '@/application/Repository/Repository'; import type { ReadonlyRepository, Repository } from '@/application/Repository/Repository';
import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection'; import type { ICategoryCollection } from '@/domain/Collection/ICategoryCollection';
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce'; import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import { UserSelectedScript } from './UserSelectedScript'; import { UserSelectedScript } from './UserSelectedScript';
import type { ScriptSelection } from './ScriptSelection'; import type { ScriptSelection } from './ScriptSelection';
import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange'; import type { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange';
@@ -39,8 +38,8 @@ export class DebouncedScriptSelection implements ScriptSelection {
); );
} }
public isSelected(scriptExecutableId: ExecutableId): boolean { public isSelected(scriptId: string): boolean {
return this.scripts.exists(scriptExecutableId); return this.scripts.exists(scriptId);
} }
public get selectedScripts(): readonly SelectedScript[] { public get selectedScripts(): readonly SelectedScript[] {
@@ -122,7 +121,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
return this.removeScript(script.executableId); return this.removeScript(script.executableId);
} }
private addOrUpdateScript(scriptId: ExecutableId, revert: boolean): number { private addOrUpdateScript(scriptId: string, revert: boolean): number {
const script = this.collection.getScript(scriptId); const script = this.collection.getScript(scriptId);
const selectedScript = new UserSelectedScript(script, revert); const selectedScript = new UserSelectedScript(script, revert);
if (!this.scripts.exists(selectedScript.id)) { if (!this.scripts.exists(selectedScript.id)) {
@@ -137,7 +136,7 @@ export class DebouncedScriptSelection implements ScriptSelection {
return 1; return 1;
} }
private removeScript(scriptId: ExecutableId): number { private removeScript(scriptId: string): number {
if (!this.scripts.exists(scriptId)) { if (!this.scripts.exists(scriptId)) {
return 0; return 0;
} }

View File

@@ -1,13 +1,12 @@
import type { IEventSource } from '@/infrastructure/Events/IEventSource'; import type { IEventSource } from '@/infrastructure/Events/IEventSource';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import type { ExecutableId } from '@/domain/Executables/Identifiable';
import type { SelectedScript } from './SelectedScript'; import type { SelectedScript } from './SelectedScript';
import type { ScriptSelectionChangeCommand } from './ScriptSelectionChange'; import type { ScriptSelectionChangeCommand } from './ScriptSelectionChange';
export interface ReadonlyScriptSelection { export interface ReadonlyScriptSelection {
readonly changed: IEventSource<readonly SelectedScript[]>; readonly changed: IEventSource<readonly SelectedScript[]>;
readonly selectedScripts: readonly SelectedScript[]; readonly selectedScripts: readonly SelectedScript[];
isSelected(scriptExecutableId: ExecutableId): boolean; isSelected(scriptId: string): boolean;
} }
export interface ScriptSelection extends ReadonlyScriptSelection { export interface ScriptSelection extends ReadonlyScriptSelection {

View File

@@ -1,5 +1,3 @@
import type { ExecutableId } from '@/domain/Executables/Identifiable';
export type ScriptSelectionStatus = { export type ScriptSelectionStatus = {
readonly isSelected: true; readonly isSelected: true;
readonly isReverted: boolean; readonly isReverted: boolean;
@@ -9,7 +7,7 @@ export type ScriptSelectionStatus = {
}; };
export interface ScriptSelectionChange { export interface ScriptSelectionChange {
readonly scriptId: ExecutableId; readonly scriptId: string;
readonly newStatus: ScriptSelectionStatus; readonly newStatus: ScriptSelectionStatus;
} }

View File

@@ -31,7 +31,7 @@ function validateCollectionsData(
) { ) {
validator.assertNonEmptyCollection({ validator.assertNonEmptyCollection({
value: collections, value: collections,
valueName: 'Collections', valueName: 'collections',
}); });
} }

View File

@@ -7,7 +7,7 @@ import { createEnumParser, type EnumParser } from '../Common/Enum';
import { parseCategory, type CategoryParser } from './Executable/CategoryParser'; import { parseCategory, type CategoryParser } from './Executable/CategoryParser';
import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser'; import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser';
import { createTypeValidator, type TypeValidator } from './Common/TypeValidator'; import { createTypeValidator, type TypeValidator } from './Common/TypeValidator';
import { createCategoryCollectionContext, type CategoryCollectionContextFactory } from './Executable/CategoryCollectionContext'; import { createCollectionUtilities, type CategoryCollectionSpecificUtilitiesFactory } from './Executable/CategoryCollectionSpecificUtilities';
export const parseCategoryCollection: CategoryCollectionParser = ( export const parseCategoryCollection: CategoryCollectionParser = (
content, content,
@@ -16,9 +16,9 @@ export const parseCategoryCollection: CategoryCollectionParser = (
) => { ) => {
validateCollection(content, utilities.validator); validateCollection(content, utilities.validator);
const scripting = utilities.parseScriptingDefinition(content.scripting, projectDetails); const scripting = utilities.parseScriptingDefinition(content.scripting, projectDetails);
const collectionContext = utilities.createContext(content.functions, scripting.language); const collectionUtilities = utilities.createUtilities(content.functions, scripting);
const categories = content.actions.map( const categories = content.actions.map(
(action) => utilities.parseCategory(action, collectionContext), (action) => utilities.parseCategory(action, collectionUtilities),
); );
const os = utilities.osParser.parseEnum(content.os, 'os'); const os = utilities.osParser.parseEnum(content.os, 'os');
const collection = utilities.createCategoryCollection({ const collection = utilities.createCategoryCollection({
@@ -45,14 +45,14 @@ function validateCollection(
): void { ): void {
validator.assertObject({ validator.assertObject({
value: content, value: content,
valueName: 'Collection', valueName: 'collection',
allowedProperties: [ allowedProperties: [
'os', 'scripting', 'actions', 'functions', 'os', 'scripting', 'actions', 'functions',
], ],
}); });
validator.assertNonEmptyCollection({ validator.assertNonEmptyCollection({
value: content.actions, value: content.actions,
valueName: '\'actions\' in collection', valueName: '"actions" in collection',
}); });
} }
@@ -60,7 +60,7 @@ interface CategoryCollectionParserUtilities {
readonly osParser: EnumParser<OperatingSystem>; readonly osParser: EnumParser<OperatingSystem>;
readonly validator: TypeValidator; readonly validator: TypeValidator;
readonly parseScriptingDefinition: ScriptingDefinitionParser; readonly parseScriptingDefinition: ScriptingDefinitionParser;
readonly createContext: CategoryCollectionContextFactory; readonly createUtilities: CategoryCollectionSpecificUtilitiesFactory;
readonly parseCategory: CategoryParser; readonly parseCategory: CategoryParser;
readonly createCategoryCollection: CategoryCollectionFactory; readonly createCategoryCollection: CategoryCollectionFactory;
} }
@@ -69,7 +69,7 @@ const DefaultUtilities: CategoryCollectionParserUtilities = {
osParser: createEnumParser(OperatingSystem), osParser: createEnumParser(OperatingSystem),
validator: createTypeValidator(), validator: createTypeValidator(),
parseScriptingDefinition, parseScriptingDefinition,
createContext: createCategoryCollectionContext, createUtilities: createCollectionUtilities,
parseCategory, parseCategory,
createCategoryCollection: (...args) => new CategoryCollection(...args), createCategoryCollection: (...args) => new CategoryCollection(...args),
}; };

View File

@@ -1,116 +1,42 @@
import { CustomError } from '@/application/Common/CustomError'; import { CustomError } from '@/application/Common/CustomError';
import { indentText } from '@/application/Common/Text/IndentText';
export interface ErrorWithContextWrapper { export interface ErrorWithContextWrapper {
( (
innerError: Error, error: Error,
additionalContext: string, additionalContext: string,
): Error; ): Error;
} }
export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = ( export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = (
innerError, error: Error,
additionalContext, additionalContext: string,
) => { ) => {
if (!additionalContext) { return (error instanceof ContextualError ? error : new ContextualError(error))
throw new Error('Missing additional context'); .withAdditionalContext(additionalContext);
}
return new ContextualError({
innerError,
additionalContext,
});
}; };
/** /* AggregateError is similar but isn't well-serialized or displayed by browsers */
* Class for building a detailed error trace.
*
* Alternatives considered:
* - `AggregateError`:
* Similar but not well-serialized or displayed by browsers such as Chromium (last tested v126).
* - `cause` property:
* Not displayed by all browsers (last tested v126).
* Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
*
* This is immutable where the constructor sets the values because using getter functions such as
* `get cause()`, `get message()` does not work on Chromium (last tested v126), but works fine on
* Firefox (last tested v127).
*/
class ContextualError extends CustomError { class ContextualError extends CustomError {
constructor(public readonly context: ErrorContext) { private readonly additionalContext = new Array<string>();
super(
generateDetailedErrorMessageWithContext(context), constructor(
{ public readonly innerError: Error,
cause: context.innerError, ) {
}, super();
); }
public withAdditionalContext(additionalContext: string): this {
this.additionalContext.push(additionalContext);
return this;
}
public get message(): string { // toString() is not used when Chromium logs it on console
return [
'\n',
this.innerError.message,
'\n',
'Additional context:',
...this.additionalContext.map((context, index) => `${index + 1}: ${context}`),
].join('\n');
} }
} }
interface ErrorContext {
readonly innerError: Error;
readonly additionalContext: string;
}
function generateDetailedErrorMessageWithContext(
context: ErrorContext,
): string {
return [
'\n',
// Display the current error message first, then the root cause.
// This prevents repetitive main messages for errors with a `cause:` chain,
// aligning with browser error display conventions.
context.additionalContext,
'\n',
'Error Trace (starting from root cause):',
indentText(
formatErrorTrace(
// Displaying contexts from the top frame (deepest, most recent) aligns with
// common debugger/compiler standard.
extractErrorTraceAscendingFromDeepest(context),
),
),
'\n',
].join('\n');
}
function extractErrorTraceAscendingFromDeepest(
context: ErrorContext,
): string[] {
const originalError = findRootError(context.innerError);
const contextsDescendingFromMostRecent: string[] = [
context.additionalContext,
...gatherContextsFromErrorChain(context.innerError),
originalError.toString(),
];
const contextsAscendingFromDeepest = contextsDescendingFromMostRecent.reverse();
return contextsAscendingFromDeepest;
}
function findRootError(error: Error): Error {
if (error instanceof ContextualError) {
return findRootError(error.context.innerError);
}
return error;
}
function gatherContextsFromErrorChain(
error: Error,
accumulatedContexts: string[] = [],
): string[] {
if (error instanceof ContextualError) {
accumulatedContexts.push(error.context.additionalContext);
return gatherContextsFromErrorChain(error.context.innerError, accumulatedContexts);
}
return accumulatedContexts;
}
function formatErrorTrace(
errorMessages: readonly string[],
): string {
if (errorMessages.length === 1) {
return errorMessages[0];
}
return errorMessages
.map((context, index) => `${index + 1}.${indentText(context)}`)
.join('\n');
}

View File

@@ -108,7 +108,7 @@ function assertArray(
valueName: string, valueName: string,
): asserts value is Array<unknown> { ): asserts value is Array<unknown> {
if (!isArray(value)) { if (!isArray(value)) {
throw new Error(`${valueName} should be of type 'array', but is of type '${typeof value}'.`); throw new Error(`'${valueName}' should be of type 'array', but is of type '${typeof value}'.`);
} }
} }
@@ -117,7 +117,7 @@ function assertString(
valueName: string, valueName: string,
): asserts value is string { ): asserts value is string {
if (!isString(value)) { if (!isString(value)) {
throw new Error(`${valueName} should be of type 'string', but is of type '${typeof value}'.`); throw new Error(`'${valueName}' should be of type 'string', but is of type '${typeof value}'.`);
} }
} }

View File

@@ -1,33 +0,0 @@
import type { FunctionData } from '@/application/collections/';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { createScriptCompiler, type ScriptCompilerFactory } from './Script/Compiler/ScriptCompilerFactory';
import type { ScriptCompiler } from './Script/Compiler/ScriptCompiler';
export interface CategoryCollectionContext {
readonly compiler: ScriptCompiler;
readonly language: ScriptingLanguage;
}
export interface CategoryCollectionContextFactory {
(
functionsData: ReadonlyArray<FunctionData> | undefined,
language: ScriptingLanguage,
compilerFactory?: ScriptCompilerFactory,
): CategoryCollectionContext;
}
export const createCategoryCollectionContext: CategoryCollectionContextFactory = (
functionsData: ReadonlyArray<FunctionData> | undefined,
language: ScriptingLanguage,
compilerFactory: ScriptCompilerFactory = createScriptCompiler,
) => {
return {
compiler: compilerFactory({
categoryContext: {
functions: functionsData ?? [],
language,
},
}),
language,
};
};

View File

@@ -0,0 +1,35 @@
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import type { FunctionData } from '@/application/collections/';
import { ScriptCompiler } from './Script/Compiler/ScriptCompiler';
import { SyntaxFactory } from './Script/Validation/Syntax/SyntaxFactory';
import type { IScriptCompiler } from './Script/Compiler/IScriptCompiler';
import type { ILanguageSyntax } from './Script/Validation/Syntax/ILanguageSyntax';
import type { ISyntaxFactory } from './Script/Validation/Syntax/ISyntaxFactory';
export interface CategoryCollectionSpecificUtilities {
readonly compiler: IScriptCompiler;
readonly syntax: ILanguageSyntax;
}
export const createCollectionUtilities: CategoryCollectionSpecificUtilitiesFactory = (
functionsData: ReadonlyArray<FunctionData> | undefined,
scripting: IScriptingDefinition,
syntaxFactory: ISyntaxFactory = new SyntaxFactory(),
) => {
const syntax = syntaxFactory.create(scripting.language);
return {
compiler: new ScriptCompiler({
functions: functionsData ?? [],
syntax,
}),
syntax,
};
};
export interface CategoryCollectionSpecificUtilitiesFactory {
(
functionsData: ReadonlyArray<FunctionData> | undefined,
scripting: IScriptingDefinition,
syntaxFactory?: ISyntaxFactory,
): CategoryCollectionSpecificUtilities;
}

View File

@@ -9,16 +9,16 @@ import { parseDocs, type DocsParser } from './DocumentationParser';
import { parseScript, type ScriptParser } from './Script/ScriptParser'; import { parseScript, type ScriptParser } from './Script/ScriptParser';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator';
import { ExecutableType } from './Validation/ExecutableType'; import { ExecutableType } from './Validation/ExecutableType';
import type { CategoryCollectionContext } from './CategoryCollectionContext'; import type { CategoryCollectionSpecificUtilities } from './CategoryCollectionSpecificUtilities';
export const parseCategory: CategoryParser = ( export const parseCategory: CategoryParser = (
category: CategoryData, category: CategoryData,
collectionContext: CategoryCollectionContext, collectionUtilities: CategoryCollectionSpecificUtilities,
categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities, categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities,
) => { ) => {
return parseCategoryRecursively({ return parseCategoryRecursively({
categoryData: category, categoryData: category,
collectionContext, collectionUtilities,
categoryUtilities, categoryUtilities,
}); });
}; };
@@ -26,14 +26,14 @@ export const parseCategory: CategoryParser = (
export interface CategoryParser { export interface CategoryParser {
( (
category: CategoryData, category: CategoryData,
collectionContext: CategoryCollectionContext, collectionUtilities: CategoryCollectionSpecificUtilities,
categoryUtilities?: CategoryParserUtilities, categoryUtilities?: CategoryParserUtilities,
): Category; ): Category;
} }
interface CategoryParseContext { interface CategoryParseContext {
readonly categoryData: CategoryData; readonly categoryData: CategoryData;
readonly collectionContext: CategoryCollectionContext; readonly collectionUtilities: CategoryCollectionSpecificUtilities;
readonly parentCategory?: CategoryData; readonly parentCategory?: CategoryData;
readonly categoryUtilities: CategoryParserUtilities; readonly categoryUtilities: CategoryParserUtilities;
} }
@@ -52,12 +52,12 @@ function parseCategoryRecursively(
children, children,
parent: context.categoryData, parent: context.categoryData,
categoryUtilities: context.categoryUtilities, categoryUtilities: context.categoryUtilities,
collectionContext: context.collectionContext, collectionUtilities: context.collectionUtilities,
}); });
} }
try { try {
return context.categoryUtilities.createCategory({ return context.categoryUtilities.createCategory({
executableId: context.categoryData.category, // Pseudo-ID for uniqueness until real ID support executableId: context.categoryData.category, // arbitrary ID
name: context.categoryData.category, name: context.categoryData.category,
docs: context.categoryUtilities.parseDocs(context.categoryData), docs: context.categoryUtilities.parseDocs(context.categoryData),
subcategories: children.subcategories, subcategories: children.subcategories,
@@ -82,7 +82,7 @@ function ensureValidCategory(
}); });
validator.assertType((v) => v.assertObject({ validator.assertType((v) => v.assertObject({
value: category, value: category,
valueName: category.category ? `Category '${category.category}'` : 'Category', valueName: category.category ?? 'category',
allowedProperties: [ allowedProperties: [
'docs', 'children', 'category', 'docs', 'children', 'category',
], ],
@@ -104,7 +104,7 @@ interface ExecutableParseContext {
readonly data: ExecutableData; readonly data: ExecutableData;
readonly children: CategoryChildren; readonly children: CategoryChildren;
readonly parent: CategoryData; readonly parent: CategoryData;
readonly collectionContext: CategoryCollectionContext; readonly collectionUtilities: CategoryCollectionSpecificUtilities;
readonly categoryUtilities: CategoryParserUtilities; readonly categoryUtilities: CategoryParserUtilities;
} }
@@ -124,13 +124,13 @@ function parseUnknownExecutable(context: ExecutableParseContext) {
if (isCategory(context.data)) { if (isCategory(context.data)) {
const subCategory = parseCategoryRecursively({ const subCategory = parseCategoryRecursively({
categoryData: context.data, categoryData: context.data,
collectionContext: context.collectionContext, collectionUtilities: context.collectionUtilities,
parentCategory: context.parent, parentCategory: context.parent,
categoryUtilities: context.categoryUtilities, categoryUtilities: context.categoryUtilities,
}); });
context.children.subcategories.push(subCategory); context.children.subcategories.push(subCategory);
} else { // A script } else { // A script
const script = context.categoryUtilities.parseScript(context.data, context.collectionContext); const script = context.categoryUtilities.parseScript(context.data, context.collectionUtilities);
context.children.subscripts.push(script); context.children.subscripts.push(script);
} }
} }

View File

@@ -1,4 +1,4 @@
export interface Pipe { export interface IPipe {
readonly name: string; readonly name: string;
apply(input: string): string; apply(input: string): string;
} }

View File

@@ -1,11 +1,11 @@
import type { Pipe } from '../Pipe'; import type { IPipe } from '../IPipe';
export class EscapeDoubleQuotes implements Pipe { export class EscapeDoubleQuotes implements IPipe {
public readonly name: string = 'escapeDoubleQuotes'; public readonly name: string = 'escapeDoubleQuotes';
public apply(raw: string): string { public apply(raw: string): string {
if (!raw) { if (!raw) {
return ''; return raw;
} }
return raw.replaceAll('"', '"^""'); return raw.replaceAll('"', '"^""');
/* eslint-disable vue/max-len */ /* eslint-disable vue/max-len */

View File

@@ -1,7 +1,6 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; import type { IPipe } from '../IPipe';
import type { Pipe } from '../Pipe';
export class InlinePowerShell implements Pipe { export class InlinePowerShell implements IPipe {
public readonly name: string = 'inlinePowerShell'; public readonly name: string = 'inlinePowerShell';
public apply(code: string): string { public apply(code: string): string {
@@ -9,11 +8,9 @@ export class InlinePowerShell implements Pipe {
return code; return code;
} }
const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent" const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent"
// Order is important
inlineComments, inlineComments,
mergeHereStrings,
mergeLinesWithBacktick, mergeLinesWithBacktick,
mergeLinesWithBracketCodeBlocks, mergeHereStrings,
mergeNewLines, mergeNewLines,
]).reduce((a, b) => (data) => b(a(data))); ]).reduce((a, b) => (data) => b(a(data)));
const newCode = processor(code); const newCode = processor(code);
@@ -92,6 +89,10 @@ function inlineComments(code: string): string {
*/ */
} }
function getLines(code: string): string[] {
return (code?.split(/\r\n|\r|\n/) || []);
}
/* /*
Merges inline here-strings to a single lined string with Windows line terminator (\r\n) Merges inline here-strings to a single lined string with Windows line terminator (\r\n)
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.4#here-strings
@@ -101,18 +102,18 @@ function mergeHereStrings(code: string) {
return code.replaceAll(regex, (_$, quotes, scope) => { return code.replaceAll(regex, (_$, quotes, scope) => {
const newString = getHereStringHandler(quotes); const newString = getHereStringHandler(quotes);
const escaped = scope.replaceAll(quotes, newString.escapedQuotes); const escaped = scope.replaceAll(quotes, newString.escapedQuotes);
const lines = splitTextIntoLines(escaped); const lines = getLines(escaped);
const inlined = lines.join(newString.separator); const inlined = lines.join(newString.separator);
const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`; const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`;
return quoted; return quoted;
}); });
} }
interface InlinedHereString { interface IInlinedHereString {
readonly quotesAround: string; readonly quotesAround: string;
readonly escapedQuotes: string; readonly escapedQuotes: string;
readonly separator: string; readonly separator: string;
} }
function getHereStringHandler(quotes: string): InlinedHereString { function getHereStringHandler(quotes: string): IInlinedHereString {
/* /*
We handle @' and @" differently. We handle @' and @" differently.
Single quotes are interpreted literally and doubles are expandable. Single quotes are interpreted literally and doubles are expandable.
@@ -157,33 +158,9 @@ function mergeLinesWithBacktick(code: string) {
return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' '); return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' ');
} }
/**
* Inlines code blocks in PowerShell scripts while preserving correct syntax.
* It removes unnecessary newlines and spaces around brackets,
* inlining the code where possible.
* This prevents syntax errors like "Unexpected token '}'" when inlining brackets.
*/
function mergeLinesWithBracketCodeBlocks(code: string): string {
return code
// Opening bracket: [whitespace] Opening bracket (newline)
.replace(/(?<=.*)\s*{[\r\n][\s\r\n]*/g, ' { ')
// Closing bracket: [whitespace] Closing bracket (newline) (continuation keyword)
.replace(/\s*}[\r\n][\s\r\n]*(?=elseif|else|catch|finally|until)/g, ' } ')
.replace(/(?<=do\s*{.*)[\r\n\s]*}[\r\n][\r\n\s]*(?=while)/g, ' } '); // Do-While
}
function mergeNewLines(code: string) { function mergeNewLines(code: string) {
const nonEmptyLines = splitTextIntoLines(code) return getLines(code)
.map((line) => line.trim()) .map((line) => line.trim())
.filter((line) => line.length > 0); .filter((line) => line.length > 0)
.join('; ');
return nonEmptyLines
.map((line, index) => {
const isLastLine = index === nonEmptyLines.length - 1;
if (isLastLine) {
return line;
}
return line.endsWith(';') ? line : `${line};`;
})
.join(' ');
} }

View File

@@ -1,6 +1,6 @@
import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell'; import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell';
import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes'; import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes';
import type { Pipe } from './Pipe'; import type { IPipe } from './IPipe';
const RegisteredPipes = [ const RegisteredPipes = [
new EscapeDoubleQuotes(), new EscapeDoubleQuotes(),
@@ -8,19 +8,19 @@ const RegisteredPipes = [
]; ];
export interface IPipeFactory { export interface IPipeFactory {
get(pipeName: string): Pipe; get(pipeName: string): IPipe;
} }
export class PipeFactory implements IPipeFactory { export class PipeFactory implements IPipeFactory {
private readonly pipes = new Map<string, Pipe>(); private readonly pipes = new Map<string, IPipe>();
constructor(pipes: readonly Pipe[] = RegisteredPipes) { constructor(pipes: readonly IPipe[] = RegisteredPipes) {
for (const pipe of pipes) { for (const pipe of pipes) {
this.registerPipe(pipe); this.registerPipe(pipe);
} }
} }
public get(pipeName: string): Pipe { public get(pipeName: string): IPipe {
validatePipeName(pipeName); validatePipeName(pipeName);
const pipe = this.pipes.get(pipeName); const pipe = this.pipes.get(pipeName);
if (!pipe) { if (!pipe) {
@@ -29,7 +29,7 @@ export class PipeFactory implements IPipeFactory {
return pipe; return pipe;
} }
private registerPipe(pipe: Pipe): void { private registerPipe(pipe: IPipe): void {
validatePipeName(pipe.name); validatePipeName(pipe.name);
if (this.pipes.has(pipe.name)) { if (this.pipes.has(pipe.name)) {
throw new Error(`Pipe name must be unique: "${pipe.name}"`); throw new Error(`Pipe name must be unique: "${pipe.name}"`);

View File

@@ -22,7 +22,7 @@ export const createFunctionCallArgument: FunctionCallArgumentFactory = (
utilities.validateParameterName(parameterName); utilities.validateParameterName(parameterName);
utilities.typeValidator.assertNonEmptyString({ utilities.typeValidator.assertNonEmptyString({
value: argumentValue, value: argumentValue,
valueName: `Function parameter '${parameterName}'`, valueName: `Missing argument value for the parameter "${parameterName}".`,
}); });
return { return {
parameterName, parameterName,

View File

@@ -1,4 +1,3 @@
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import type { CompiledCode } from '../CompiledCode'; import type { CompiledCode } from '../CompiledCode';
import type { CodeSegmentMerger } from './CodeSegmentMerger'; import type { CodeSegmentMerger } from './CodeSegmentMerger';
@@ -9,9 +8,11 @@ export class NewlineCodeSegmentMerger implements CodeSegmentMerger {
} }
return { return {
code: joinCodeParts(codeSegments.map((f) => f.code)), code: joinCodeParts(codeSegments.map((f) => f.code)),
revertCode: joinCodeParts(filterEmptyStrings( revertCode: joinCodeParts(
codeSegments.map((f) => f.revertCode), codeSegments
)), .map((f) => f.revertCode)
.filter((code): code is string => Boolean(code)),
),
}; };
} }
} }

View File

@@ -3,7 +3,6 @@ import type { IExpressionsCompiler } from '@/application/Parser/Executable/Scrip
import { FunctionBodyType, type ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction'; import { FunctionBodyType, type ISharedFunction } from '@/application/Parser/Executable/Script/Compiler/Function/ISharedFunction';
import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall'; import type { FunctionCall } from '@/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCall';
import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode'; import type { CompiledCode } from '@/application/Parser/Executable/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { indentText } from '@/application/Common/Text/IndentText';
import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy'; import type { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy { export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
@@ -23,12 +22,10 @@ export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
if (calledFunction.body.type !== FunctionBodyType.Code) { if (calledFunction.body.type !== FunctionBodyType.Code) {
throw new Error([ throw new Error([
'Unexpected function body type.', 'Unexpected function body type.',
indentText([ `\tExpected: "${FunctionBodyType[FunctionBodyType.Code]}"`,
`Expected: "${FunctionBodyType[FunctionBodyType.Code]}"`, `\tActual: "${FunctionBodyType[calledFunction.body.type]}"`,
`Actual: "${FunctionBodyType[calledFunction.body.type]}"`,
].join('\n')),
'Function:', 'Function:',
indentText(JSON.stringify(callToFunction)), `\t${JSON.stringify(callToFunction)}`,
].join('\n')); ].join('\n'));
} }
const { code } = calledFunction.body; const { code } = calledFunction.body;

View File

@@ -42,7 +42,7 @@ function getCallSequence(calls: FunctionCallsData, validator: TypeValidator): Fu
if (isArray(calls)) { if (isArray(calls)) {
validator.assertNonEmptyCollection({ validator.assertNonEmptyCollection({
value: calls, value: calls,
valueName: 'Function call sequence', valueName: 'function call sequence',
}); });
return calls as FunctionCallData[]; return calls as FunctionCallData[];
} }
@@ -56,7 +56,7 @@ function parseFunctionCall(
): FunctionCall { ): FunctionCall {
utilities.typeValidator.assertObject({ utilities.typeValidator.assertObject({
value: call, value: call,
valueName: 'Function call', valueName: 'function call',
allowedProperties: ['function', 'parameters'], allowedProperties: ['function', 'parameters'],
}); });
const callArgs = parseArgs(call.parameters, utilities.createCallArgument); const callArgs = parseArgs(call.parameters, utilities.createCallArgument);

View File

@@ -13,7 +13,7 @@ export const validateParameterName = (
) => { ) => {
typeValidator.assertNonEmptyString({ typeValidator.assertNonEmptyString({
value: parameterName, value: parameterName,
valueName: 'Parameter name', valueName: 'parameter name',
rule: { rule: {
expectedMatch: /^[0-9a-zA-Z]+$/, expectedMatch: /^[0-9a-zA-Z]+$/,
errorMessage: `parameter name must be alphanumeric but it was "${parameterName}".`, errorMessage: `parameter name must be alphanumeric but it was "${parameterName}".`,

View File

@@ -2,12 +2,13 @@ import type {
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData,
CallInstruction, ParameterDefinitionData, CallInstruction, ParameterDefinitionData,
} from '@/application/collections/'; } from '@/application/collections/';
import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers'; import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction'; import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
import { SharedFunctionCollection } from './SharedFunctionCollection'; import { SharedFunctionCollection } from './SharedFunctionCollection';
import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser'; import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser';
@@ -21,14 +22,14 @@ import type { ISharedFunction } from './ISharedFunction';
export interface SharedFunctionsParser { export interface SharedFunctionsParser {
( (
functions: readonly FunctionData[], functions: readonly FunctionData[],
language: ScriptingLanguage, syntax: ILanguageSyntax,
utilities?: SharedFunctionsParsingUtilities, utilities?: SharedFunctionsParsingUtilities,
): ISharedFunctionCollection; ): ISharedFunctionCollection;
} }
export const parseSharedFunctions: SharedFunctionsParser = ( export const parseSharedFunctions: SharedFunctionsParser = (
functions: readonly FunctionData[], functions: readonly FunctionData[],
language: ScriptingLanguage, syntax: ILanguageSyntax,
utilities = DefaultUtilities, utilities = DefaultUtilities,
) => { ) => {
const collection = new SharedFunctionCollection(); const collection = new SharedFunctionCollection();
@@ -37,7 +38,7 @@ export const parseSharedFunctions: SharedFunctionsParser = (
} }
ensureValidFunctions(functions); ensureValidFunctions(functions);
return functions return functions
.map((func) => parseFunction(func, language, utilities)) .map((func) => parseFunction(func, syntax, utilities))
.reduce((acc, func) => { .reduce((acc, func) => {
acc.addFunction(func); acc.addFunction(func);
return acc; return acc;
@@ -47,7 +48,7 @@ export const parseSharedFunctions: SharedFunctionsParser = (
const DefaultUtilities: SharedFunctionsParsingUtilities = { const DefaultUtilities: SharedFunctionsParsingUtilities = {
wrapError: wrapErrorWithAdditionalContext, wrapError: wrapErrorWithAdditionalContext,
parseParameter: parseFunctionParameter, parseParameter: parseFunctionParameter,
codeValidator: validateCode, codeValidator: CodeValidator.instance,
createParameterCollection: createFunctionParameterCollection, createParameterCollection: createFunctionParameterCollection,
parseFunctionCalls, parseFunctionCalls,
}; };
@@ -55,20 +56,20 @@ const DefaultUtilities: SharedFunctionsParsingUtilities = {
interface SharedFunctionsParsingUtilities { interface SharedFunctionsParsingUtilities {
readonly wrapError: ErrorWithContextWrapper; readonly wrapError: ErrorWithContextWrapper;
readonly parseParameter: FunctionParameterParser; readonly parseParameter: FunctionParameterParser;
readonly codeValidator: CodeValidator; readonly codeValidator: ICodeValidator;
readonly createParameterCollection: FunctionParameterCollectionFactory; readonly createParameterCollection: FunctionParameterCollectionFactory;
readonly parseFunctionCalls: FunctionCallsParser; readonly parseFunctionCalls: FunctionCallsParser;
} }
function parseFunction( function parseFunction(
data: FunctionData, data: FunctionData,
language: ScriptingLanguage, syntax: ILanguageSyntax,
utilities: SharedFunctionsParsingUtilities, utilities: SharedFunctionsParsingUtilities,
): ISharedFunction { ): ISharedFunction {
const { name } = data; const { name } = data;
const parameters = parseParameters(data, utilities); const parameters = parseParameters(data, utilities);
if (hasCode(data)) { if (hasCode(data)) {
validateNonEmptyCode(data, language, utilities.codeValidator); validateCode(data, syntax, utilities.codeValidator);
return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode); return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode);
} }
// Has call // Has call
@@ -76,20 +77,17 @@ function parseFunction(
return createCallerFunction(name, parameters, calls); return createCallerFunction(name, parameters, calls);
} }
function validateNonEmptyCode( function validateCode(
data: CodeFunctionData, data: CodeFunctionData,
language: ScriptingLanguage, syntax: ILanguageSyntax,
validate: CodeValidator, validator: ICodeValidator,
): void { ): void {
filterEmptyStrings([data.code, data.revertCode]) [data.code, data.revertCode]
.filter((code): code is string => Boolean(code))
.forEach( .forEach(
(code) => validate( (code) => validator.throwIfInvalid(
code, code,
language, [new NoEmptyLines(), new NoDuplicatedLines(syntax)],
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoDuplicatedLines,
],
), ),
); );
} }
@@ -206,9 +204,9 @@ function ensureNoDuplicateCode(functions: readonly FunctionData[]) {
if (duplicateCodes.length > 0) { if (duplicateCodes.length > 0) {
throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`); throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`);
} }
const duplicateRevertCodes = getDuplicates(filterEmptyStrings( const duplicateRevertCodes = getDuplicates(callFunctions
callFunctions.map((func) => func.revertCode), .map((func) => func.revertCode)
)); .filter((code): code is string => Boolean(code)));
if (duplicateRevertCodes.length > 0) { if (duplicateRevertCodes.length > 0) {
throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`); throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`);
} }

View File

@@ -0,0 +1,7 @@
import type { ScriptData } from '@/application/collections/';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
export interface IScriptCompiler {
canCompile(script: ScriptData): boolean;
compile(script: ScriptData): ScriptCode;
}

View File

@@ -1,7 +1,87 @@
import type { ScriptData } from '@/application/collections/'; import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
import type { CompiledCode } from './Function/Call/Compiler/CompiledCode';
import type { IScriptCompiler } from './IScriptCompiler';
import type { ISharedFunctionCollection } from './Function/ISharedFunctionCollection';
import type { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompiler';
export interface ScriptCompiler { interface ScriptCompilerUtilities {
canCompile(script: ScriptData): boolean; readonly sharedFunctionsParser: SharedFunctionsParser;
compile(script: ScriptData): ScriptCode; readonly callCompiler: FunctionCallCompiler;
readonly codeValidator: ICodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly scriptCodeFactory: ScriptCodeFactory;
}
const DefaultUtilities: ScriptCompilerUtilities = {
sharedFunctionsParser: parseSharedFunctions,
callCompiler: FunctionCallSequenceCompiler.instance,
codeValidator: CodeValidator.instance,
wrapError: wrapErrorWithAdditionalContext,
scriptCodeFactory: createScriptCode,
};
interface CategoryCollectionDataContext {
readonly functions: readonly FunctionData[];
readonly syntax: ILanguageSyntax;
}
export class ScriptCompiler implements IScriptCompiler {
private readonly functions: ISharedFunctionCollection;
constructor(
categoryContext: CategoryCollectionDataContext,
private readonly utilities: ScriptCompilerUtilities = DefaultUtilities,
) {
this.functions = this.utilities.sharedFunctionsParser(
categoryContext.functions,
categoryContext.syntax,
);
}
public canCompile(script: ScriptData): boolean {
return hasCall(script);
}
public compile(script: ScriptData): ScriptCode {
try {
if (!hasCall(script)) {
throw new Error('Script does include any calls.');
}
const calls = parseFunctionCalls(script.call);
const compiledCode = this.utilities.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(compiledCode, this.utilities.codeValidator);
return this.utilities.scriptCodeFactory(
compiledCode.code,
compiledCode.revertCode,
);
} catch (error) {
throw this.utilities.wrapError(error, `Failed to compile script: ${script.name}`);
}
}
}
function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void {
[compiledCode.code, compiledCode.revertCode]
.filter((code): code is string => Boolean(code))
.map((code) => code as string)
.forEach(
(code) => validator.throwIfInvalid(
code,
[new NoEmptyLines()],
),
);
}
function hasCall(data: ScriptData): data is ScriptData & CallInstruction {
return (data as CallInstruction).call !== undefined;
} }

View File

@@ -1,119 +0,0 @@
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
import type { CompiledCode } from './Function/Call/Compiler/CompiledCode';
import type { ScriptCompiler } from './ScriptCompiler';
import type { ISharedFunctionCollection } from './Function/ISharedFunctionCollection';
import type { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompiler';
export interface ScriptCompilerInitParameters {
readonly categoryContext: CategoryCollectionDataContext;
readonly utilities?: ScriptCompilerUtilities;
}
export interface ScriptCompilerFactory {
(parameters: ScriptCompilerInitParameters): ScriptCompiler;
}
export const createScriptCompiler: ScriptCompilerFactory = (
parameters,
) => {
return new FunctionCallScriptCompiler(
parameters.categoryContext,
parameters.utilities ?? DefaultUtilities,
);
};
interface ScriptCompilerUtilities {
readonly sharedFunctionsParser: SharedFunctionsParser;
readonly callCompiler: FunctionCallCompiler;
readonly codeValidator: CodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly scriptCodeFactory: ScriptCodeFactory;
}
const DefaultUtilities: ScriptCompilerUtilities = {
sharedFunctionsParser: parseSharedFunctions,
callCompiler: FunctionCallSequenceCompiler.instance,
codeValidator: validateCode,
wrapError: wrapErrorWithAdditionalContext,
scriptCodeFactory: createScriptCode,
};
interface CategoryCollectionDataContext {
readonly functions: readonly FunctionData[];
readonly language: ScriptingLanguage;
}
class FunctionCallScriptCompiler implements ScriptCompiler {
private readonly functions: ISharedFunctionCollection;
private readonly language: ScriptingLanguage;
constructor(
categoryContext: CategoryCollectionDataContext,
private readonly utilities: ScriptCompilerUtilities = DefaultUtilities,
) {
this.functions = this.utilities.sharedFunctionsParser(
categoryContext.functions,
categoryContext.language,
);
this.language = categoryContext.language;
}
public canCompile(script: ScriptData): boolean {
return hasCall(script);
}
public compile(script: ScriptData): ScriptCode {
try {
if (!hasCall(script)) {
throw new Error('Script does include any calls.');
}
const calls = parseFunctionCalls(script.call);
const compiledCode = this.utilities.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(
compiledCode,
this.language,
this.utilities.codeValidator,
);
return this.utilities.scriptCodeFactory(
compiledCode.code,
compiledCode.revertCode,
);
} catch (error) {
throw this.utilities.wrapError(error, `Failed to compile script: ${script.name}`);
}
}
}
function validateCompiledCode(
compiledCode: CompiledCode,
language: ScriptingLanguage,
validate: CodeValidator,
): void {
filterEmptyStrings([compiledCode.code, compiledCode.revertCode])
.forEach(
(code) => validate(
code,
language,
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoTooLongLines,
// Allow duplicated lines to enable calling same function multiple times
],
),
);
}
function hasCall(data: ScriptData): data is ScriptData & CallInstruction {
return (data as CallInstruction).call !== undefined;
}

View File

@@ -1,32 +1,33 @@
import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/'; import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator'; import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import type { Script } from '@/domain/Executables/Script/Script'; import type { Script } from '@/domain/Executables/Script/Script';
import { createEnumParser, type EnumParser } from '@/application/Common/Enum'; import { createEnumParser, type EnumParser } from '@/application/Common/Enum';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory'; import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { parseDocs, type DocsParser } from '../DocumentationParser'; import { parseDocs, type DocsParser } from '../DocumentationParser';
import { ExecutableType } from '../Validation/ExecutableType'; import { ExecutableType } from '../Validation/ExecutableType';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator';
import type { CategoryCollectionContext } from '../CategoryCollectionContext'; import { CodeValidator } from './Validation/CodeValidator';
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
import type { CategoryCollectionSpecificUtilities } from '../CategoryCollectionSpecificUtilities';
export interface ScriptParser { export interface ScriptParser {
( (
data: ScriptData, data: ScriptData,
collectionContext: CategoryCollectionContext, collectionUtilities: CategoryCollectionSpecificUtilities,
scriptUtilities?: ScriptParserUtilities, scriptUtilities?: ScriptParserUtilities,
): Script; ): Script;
} }
export const parseScript: ScriptParser = ( export const parseScript: ScriptParser = (
data, data,
collectionContext, collectionUtilities,
scriptUtilities = DefaultUtilities, scriptUtilities = DefaultUtilities,
) => { ) => {
const validator = scriptUtilities.createValidator({ const validator = scriptUtilities.createValidator({
@@ -36,11 +37,11 @@ export const parseScript: ScriptParser = (
validateScript(data, validator); validateScript(data, validator);
try { try {
const script = scriptUtilities.createScript({ const script = scriptUtilities.createScript({
executableId: data.name, // Pseudo-ID for uniqueness until real ID support executableId: data.name, // arbitrary ID
name: data.name, name: data.name,
code: parseCode( code: parseCode(
data, data,
collectionContext, collectionUtilities,
scriptUtilities.codeValidator, scriptUtilities.codeValidator,
scriptUtilities.createCode, scriptUtilities.createCode,
), ),
@@ -68,34 +69,30 @@ function parseLevel(
function parseCode( function parseCode(
script: ScriptData, script: ScriptData,
collectionContext: CategoryCollectionContext, collectionUtilities: CategoryCollectionSpecificUtilities,
codeValidator: CodeValidator, codeValidator: ICodeValidator,
createCode: ScriptCodeFactory, createCode: ScriptCodeFactory,
): ScriptCode { ): ScriptCode {
if (collectionContext.compiler.canCompile(script)) { if (collectionUtilities.compiler.canCompile(script)) {
return collectionContext.compiler.compile(script); return collectionUtilities.compiler.compile(script);
} }
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled
const code = createCode(codeScript.code, codeScript.revertCode); const code = createCode(codeScript.code, codeScript.revertCode);
validateHardcodedCodeWithoutCalls(code, codeValidator, collectionContext.language); validateHardcodedCodeWithoutCalls(code, codeValidator, collectionUtilities.syntax);
return code; return code;
} }
function validateHardcodedCodeWithoutCalls( function validateHardcodedCodeWithoutCalls(
scriptCode: ScriptCode, scriptCode: ScriptCode,
validate: CodeValidator, validator: ICodeValidator,
language: ScriptingLanguage, syntax: ILanguageSyntax,
) { ) {
filterEmptyStrings([scriptCode.execute, scriptCode.revert]) [scriptCode.execute, scriptCode.revert]
.filter((code): code is string => Boolean(code))
.forEach( .forEach(
(code) => validate( (code) => validator.throwIfInvalid(
code, code,
language, [new NoEmptyLines(), new NoDuplicatedLines(syntax)],
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoDuplicatedLines,
CodeValidationRule.NoTooLongLines,
],
), ),
); );
} }
@@ -106,7 +103,7 @@ function validateScript(
): asserts script is NonNullable<ScriptData> { ): asserts script is NonNullable<ScriptData> {
validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({ validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({
value: script, value: script,
valueName: script.name ? `Script '${script.name}'` : 'Script', valueName: script.name ?? 'script',
allowedProperties: [ allowedProperties: [
'name', 'recommend', 'code', 'revertCode', 'call', 'docs', 'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
], ],
@@ -129,7 +126,7 @@ function validateScript(
interface ScriptParserUtilities { interface ScriptParserUtilities {
readonly levelParser: EnumParser<RecommendationLevel>; readonly levelParser: EnumParser<RecommendationLevel>;
readonly createScript: ScriptFactory; readonly createScript: ScriptFactory;
readonly codeValidator: CodeValidator; readonly codeValidator: ICodeValidator;
readonly wrapError: ErrorWithContextWrapper; readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: ExecutableValidatorFactory; readonly createValidator: ExecutableValidatorFactory;
readonly createCode: ScriptCodeFactory; readonly createCode: ScriptCodeFactory;
@@ -139,7 +136,7 @@ interface ScriptParserUtilities {
const DefaultUtilities: ScriptParserUtilities = { const DefaultUtilities: ScriptParserUtilities = {
levelParser: createEnumParser(RecommendationLevel), levelParser: createEnumParser(RecommendationLevel),
createScript, createScript,
codeValidator: validateCode, codeValidator: CodeValidator.instance,
wrapError: wrapErrorWithAdditionalContext, wrapError: wrapErrorWithAdditionalContext,
createValidator: createExecutableDataValidator, createValidator: createExecutableDataValidator,
createCode: createScriptCode, createCode: createScriptCode,

View File

@@ -1,63 +0,0 @@
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { createSyntax, type SyntaxFactory } from './Syntax/SyntaxFactory';
import type { CodeLine, CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export type DuplicateLinesAnalyzer = CodeValidationAnalyzer & {
(
...args: [
...Parameters<CodeValidationAnalyzer>,
syntaxFactory?: SyntaxFactory,
]
): ReturnType<CodeValidationAnalyzer>;
};
export const analyzeDuplicateLines: DuplicateLinesAnalyzer = (
lines: readonly CodeLine[],
language: ScriptingLanguage,
syntaxFactory: SyntaxFactory = createSyntax,
) => {
const syntax = syntaxFactory(language);
return lines
.map((line): CodeLineWithDuplicateOccurrences => ({
lineNumber: line.lineNumber,
shouldBeIgnoredInAnalysis: shouldIgnoreLine(line.text, syntax),
duplicateLineNumbers: lines
.filter((other) => other.text === line.text)
.map((duplicatedLine) => duplicatedLine.lineNumber),
}))
.filter((line) => isNonIgnorableDuplicateLine(line))
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: `Line is duplicated at line numbers ${line.duplicateLineNumbers.join(',')}.`,
}));
};
interface CodeLineWithDuplicateOccurrences {
readonly lineNumber: number;
readonly duplicateLineNumbers: readonly number[];
readonly shouldBeIgnoredInAnalysis: boolean;
}
function isNonIgnorableDuplicateLine(line: CodeLineWithDuplicateOccurrences): boolean {
return !line.shouldBeIgnoredInAnalysis && line.duplicateLineNumbers.length > 1;
}
function shouldIgnoreLine(codeLine: string, syntax: LanguageSyntax): boolean {
return isCommentLine(codeLine, syntax)
|| isLineComposedEntirelyOfCommonCodeParts(codeLine, syntax);
}
function isCommentLine(codeLine: string, syntax: LanguageSyntax): boolean {
return syntax.commentDelimiters.some(
(delimiter) => codeLine.startsWith(delimiter),
);
}
function isLineComposedEntirelyOfCommonCodeParts(
codeLine: string,
syntax: LanguageSyntax,
): boolean {
const codeLineParts = codeLine.toLowerCase().trim().split(' ');
return codeLineParts.every((part) => syntax.commonCodeParts.includes(part));
}

View File

@@ -1,24 +0,0 @@
import type { CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export const analyzeEmptyLines: CodeValidationAnalyzer = (
lines,
) => {
return lines
.filter((line) => isEmptyLine(line.text))
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: (() => {
if (!line.text) {
return 'Empty line';
}
const markedText = line.text
.replaceAll(' ', '{whitespace}')
.replaceAll('\t', '{tab}');
return `Empty line: "${markedText}"`;
})(),
}));
};
function isEmptyLine(line: string): boolean {
return line.trim().length === 0;
}

View File

@@ -1,44 +0,0 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export const analyzeTooLongLines: CodeValidationAnalyzer = (
lines,
language,
) => {
const maxLineLength = getMaxAllowedLineLength(language);
return lines
.filter((line) => line.text.length > maxLineLength)
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: [
`Line is too long (${line.text.length}).`,
`It exceed maximum allowed length ${maxLineLength} by ${line.text.length - maxLineLength} characters.`,
'This may cause bugs due to unintended trimming by operating system, shells or terminal emulators.',
].join(' '),
}));
};
function getMaxAllowedLineLength(language: ScriptingLanguage): number {
switch (language) {
case ScriptingLanguage.batchfile:
/*
The maximum length of the string that you can use at the command prompt is 8191 characters.
https://web.archive.org/web/20240815120224/https://learn.microsoft.com/en-us/troubleshoot/windows-client/shell-experience/command-line-string-limitation
*/
return 8191;
case ScriptingLanguage.shellscript:
/*
Tests show:
| OS | Command | Value |
| --- | ------- | ----- |
| Pop!_OS 22.04 | xargs --show-limits | 2088784 |
| macOS Sonoma 14.3 on Intel | getconf ARG_MAX | 1048576 |
| macOS Sonoma 14.3 on Apple Silicon M1 | getconf ARG_MAX | 1048576 |
| Android 12 (4.14.180) with Termux | xargs --show-limits | 2087244 |
*/
return 1048576; // Minimum value for reliability
default:
throw new Error(`Unsupported language: ${ScriptingLanguage[language]} (${language})`);
}
}

View File

@@ -1,18 +0,0 @@
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
export interface CodeValidationAnalyzer {
(
lines: readonly CodeLine[],
language: ScriptingLanguage,
): InvalidCodeLine[];
}
export interface InvalidCodeLine {
readonly lineNumber: number;
readonly error: string;
}
export interface CodeLine {
readonly lineNumber: number;
readonly text: string;
}

View File

@@ -1,4 +0,0 @@
export interface LanguageSyntax {
readonly commentDelimiters: readonly string[];
readonly commonCodeParts: readonly string[];
}

View File

@@ -1,7 +0,0 @@
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
export class ShellScriptSyntax implements LanguageSyntax {
public readonly commentDelimiters = ['#'];
public readonly commonCodeParts = ['(', ')', 'else', 'fi', 'done'];
}

View File

@@ -1,19 +0,0 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
import { BatchFileSyntax } from './BatchFileSyntax';
import { ShellScriptSyntax } from './ShellScriptSyntax';
export interface SyntaxFactory {
(language: ScriptingLanguage): LanguageSyntax;
}
export const createSyntax: SyntaxFactory = (language: ScriptingLanguage): LanguageSyntax => {
switch (language) {
case ScriptingLanguage.batchfile:
return new BatchFileSyntax();
case ScriptingLanguage.shellscript:
return new ShellScriptSyntax();
default:
throw new RangeError(`Invalid language: "${ScriptingLanguage[language]}"`);
}
};

View File

@@ -1,5 +0,0 @@
export enum CodeValidationRule {
NoEmptyLines,
NoDuplicatedLines,
NoTooLongLines,
}

View File

@@ -1,78 +1,46 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; import type { ICodeLine } from './ICodeLine';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule';
import { createValidationAnalyzers, type ValidationRuleAnalyzerFactory } from './ValidationRuleAnalyzerFactory'; import type { ICodeValidator } from './ICodeValidator';
import type { CodeLine, InvalidCodeLine } from './Analyzers/CodeValidationAnalyzer';
import type { CodeValidationRule } from './CodeValidationRule';
export interface CodeValidator { export class CodeValidator implements ICodeValidator {
( public static readonly instance: ICodeValidator = new CodeValidator();
public throwIfInvalid(
code: string, code: string,
language: ScriptingLanguage, rules: readonly ICodeValidationRule[],
rules: readonly CodeValidationRule[], ): void {
analyzerFactory?: ValidationRuleAnalyzerFactory, if (rules.length === 0) { throw new Error('missing rules'); }
): void; if (!code) {
return;
}
const lines = extractLines(code);
const invalidLines = rules.flatMap((rule) => rule.analyze(lines));
if (invalidLines.length === 0) {
return;
}
const errorText = `Errors with the code.\n${printLines(lines, invalidLines)}`;
throw new Error(errorText);
}
} }
export const validateCode: CodeValidator = ( function extractLines(code: string): ICodeLine[] {
code, return code
language, .split(/\r\n|\r|\n/)
rules, .map((lineText, lineIndex): ICodeLine => ({
analyzerFactory = createValidationAnalyzers, index: lineIndex + 1,
) => { text: lineText,
const analyzers = analyzerFactory(rules); }));
if (!code) {
return;
}
const lines = extractLines(code);
const invalidLines = analyzers.flatMap((analyze) => analyze(lines, language));
if (invalidLines.length === 0) {
return;
}
const errorText = `Errors with the code.\n${formatLines(lines, invalidLines)}`;
throw new Error(errorText);
};
function extractLines(code: string): CodeLine[] {
const lines = splitTextIntoLines(code);
return lines.map((lineText, lineIndex): CodeLine => ({
lineNumber: lineIndex + 1,
text: lineText,
}));
} }
function formatLines( function printLines(
lines: readonly CodeLine[], lines: readonly ICodeLine[],
invalidLines: readonly InvalidCodeLine[], invalidLines: readonly IInvalidCodeLine[],
): string { ): string {
return lines.map((line) => { return lines.map((line) => {
const badLine = invalidLines.find((invalidLine) => invalidLine.lineNumber === line.lineNumber); const badLine = invalidLines.find((invalidLine) => invalidLine.index === line.index);
return formatLine({ if (!badLine) {
lineNumber: line.lineNumber, return `[${line.index}] ✅ ${line.text}`;
text: line.text, }
error: badLine?.error, return `[${badLine.index}] ❌ ${line.text}\n\t⟶ ${badLine.error}`;
});
}).join('\n'); }).join('\n');
} }
function formatLine(
line: {
readonly lineNumber: number;
readonly text: string;
readonly error?: string;
},
): string {
let text = `[${line.lineNumber}] `;
text += line.error ? '❌' : '✅';
text += ` ${trimLine(line.text)}`;
if (line.error) {
text += `\n\t⟶ ${line.error}`;
}
return text;
}
function trimLine(line: string) {
const maxLength = 500;
if (line.length > maxLength) {
line = `${line.substring(0, maxLength)}... [Rest of the line trimmed]`;
}
return line;
}

View File

@@ -0,0 +1,4 @@
export interface ICodeLine {
readonly index: number;
readonly text: string;
}

View File

@@ -0,0 +1,10 @@
import type { ICodeLine } from './ICodeLine';
export interface IInvalidCodeLine {
readonly index: number;
readonly error: string;
}
export interface ICodeValidationRule {
analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[];
}

View File

@@ -0,0 +1,8 @@
import type { ICodeValidationRule } from './ICodeValidationRule';
export interface ICodeValidator {
throwIfInvalid(
code: string,
rules: readonly ICodeValidationRule[],
): void;
}

View File

@@ -0,0 +1,45 @@
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import type { ICodeLine } from '../ICodeLine';
import type { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
export class NoDuplicatedLines implements ICodeValidationRule {
constructor(private readonly syntax: ILanguageSyntax) { }
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
return lines
.map((line): IDuplicateAnalyzedLine => ({
index: line.index,
isIgnored: shouldIgnoreLine(line.text, this.syntax),
occurrenceIndices: lines
.filter((other) => other.text === line.text)
.map((duplicatedLine) => duplicatedLine.index),
}))
.filter((line) => hasInvalidDuplicates(line))
.map((line): IInvalidCodeLine => ({
index: line.index,
error: `Line is duplicated at line numbers ${line.occurrenceIndices.join(',')}.`,
}));
}
}
interface IDuplicateAnalyzedLine {
readonly index: number;
readonly occurrenceIndices: readonly number[];
readonly isIgnored: boolean;
}
function hasInvalidDuplicates(line: IDuplicateAnalyzedLine): boolean {
return !line.isIgnored && line.occurrenceIndices.length > 1;
}
function shouldIgnoreLine(codeLine: string, syntax: ILanguageSyntax): boolean {
const lowerCaseCodeLine = codeLine.toLowerCase();
const isCommentLine = () => syntax.commentDelimiters.some(
(delimiter) => lowerCaseCodeLine.startsWith(delimiter),
);
const consistsOfFrequentCommands = () => {
const trimmed = lowerCaseCodeLine.trim().split(' ');
return trimmed.every((part) => syntax.commonCodeParts.includes(part));
};
return isCommentLine() || consistsOfFrequentCommands();
}

View File

@@ -0,0 +1,21 @@
import type { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
import type { ICodeLine } from '../ICodeLine';
export class NoEmptyLines implements ICodeValidationRule {
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
return lines
.filter((line) => (line.text?.trim().length ?? 0) === 0)
.map((line): IInvalidCodeLine => ({
index: line.index,
error: (() => {
if (!line.text) {
return 'Empty line';
}
const markedText = line.text
.replaceAll(' ', '{whitespace}')
.replaceAll('\t', '{tab}');
return `Empty line: "${markedText}"`;
})(),
}));
}
}

View File

@@ -1,9 +1,9 @@
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax'; import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
const BatchFileCommonCodeParts = ['(', ')', 'else', '||']; const BatchFileCommonCodeParts = ['(', ')', 'else', '||'];
const PowerShellCommonCodeParts = ['{', '}']; const PowerShellCommonCodeParts = ['{', '}'];
export class BatchFileSyntax implements LanguageSyntax { export class BatchFileSyntax implements ILanguageSyntax {
public readonly commentDelimiters = ['REM', '::']; public readonly commentDelimiters = ['REM', '::'];
public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts]; public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts];

View File

@@ -0,0 +1,4 @@
export interface ILanguageSyntax {
readonly commentDelimiters: string[];
readonly commonCodeParts: string[];
}

View File

@@ -0,0 +1,4 @@
import type { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory';
import type { ILanguageSyntax } from './ILanguageSyntax';
export type ISyntaxFactory = IScriptingLanguageFactory<ILanguageSyntax>;

View File

@@ -0,0 +1,7 @@
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
export class ShellScriptSyntax implements ILanguageSyntax {
public readonly commentDelimiters = ['#'];
public readonly commonCodeParts = ['(', ')', 'else', 'fi', 'done'];
}

View File

@@ -0,0 +1,16 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { ScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/ScriptingLanguageFactory';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { BatchFileSyntax } from './BatchFileSyntax';
import { ShellScriptSyntax } from './ShellScriptSyntax';
import type { ISyntaxFactory } from './ISyntaxFactory';
export class SyntaxFactory
extends ScriptingLanguageFactory<ILanguageSyntax>
implements ISyntaxFactory {
constructor() {
super();
this.registerGetter(ScriptingLanguage.batchfile, () => new BatchFileSyntax());
this.registerGetter(ScriptingLanguage.shellscript, () => new ShellScriptSyntax());
}
}

View File

@@ -1,47 +0,0 @@
import { CodeValidationRule } from './CodeValidationRule';
import { analyzeDuplicateLines } from './Analyzers/AnalyzeDuplicateLines';
import { analyzeEmptyLines } from './Analyzers/AnalyzeEmptyLines';
import { analyzeTooLongLines } from './Analyzers/AnalyzeTooLongLines';
import type { CodeValidationAnalyzer } from './Analyzers/CodeValidationAnalyzer';
export interface ValidationRuleAnalyzerFactory {
(
rules: readonly CodeValidationRule[],
): CodeValidationAnalyzer[];
}
export const createValidationAnalyzers: ValidationRuleAnalyzerFactory = (
rules,
): CodeValidationAnalyzer[] => {
if (rules.length === 0) { throw new Error('missing rules'); }
validateUniqueRules(rules);
return rules.map((rule) => createValidationRule(rule));
};
function createValidationRule(rule: CodeValidationRule): CodeValidationAnalyzer {
switch (rule) {
case CodeValidationRule.NoEmptyLines:
return analyzeEmptyLines;
case CodeValidationRule.NoDuplicatedLines:
return analyzeDuplicateLines;
case CodeValidationRule.NoTooLongLines:
return analyzeTooLongLines;
default:
throw new Error(`Unknown rule: ${rule}`);
}
}
function validateUniqueRules(
rules: readonly CodeValidationRule[],
): void {
const ruleCounts = new Map<CodeValidationRule, number>();
rules.forEach((rule) => {
ruleCounts.set(rule, (ruleCounts.get(rule) || 0) + 1);
});
const duplicates = Array.from(ruleCounts.entries())
.filter(([, count]) => count > 1)
.map(([rule, count]) => `${CodeValidationRule[rule]} (${count} times)`);
if (duplicates.length > 0) {
throw new Error(`Duplicate rules are not allowed. Duplicates found: ${duplicates.join(', ')}`);
}
}

View File

@@ -37,7 +37,7 @@ function validateData(
): void { ): void {
validator.assertObject({ validator.assertObject({
value: data, value: data,
valueName: 'Scripting definition', valueName: 'scripting definition',
allowedProperties: ['language', 'fileExtension', 'startCode', 'endCode'], allowedProperties: ['language', 'fileExtension', 'startCode', 'endCode'],
}); });
} }

View File

@@ -69,12 +69,6 @@ definitions:
- $ref: '#/definitions/CodeScript' - $ref: '#/definitions/CodeScript'
- $ref: '#/definitions/CallScript' - $ref: '#/definitions/CallScript'
RecommendationLevel:
oneOf:
- type: string
enum: [standard, strict]
- type: 'null'
ScriptDefinition: ScriptDefinition:
type: object type: object
allOf: allOf:
@@ -84,7 +78,8 @@ definitions:
name: name:
type: string type: string
recommend: recommend:
$ref: '#/definitions/RecommendationLevel' type: string
enum: [standard, strict]
CodeScript: CodeScript:
type: object type: object

View File

@@ -1800,7 +1800,7 @@ actions:
# References for spctl --master-disable # References for spctl --master-disable
- https://web.archive.org/web/20240523173608/https://www.manpagez.com/man/8/spctl/ - https://web.archive.org/web/20240523173608/https://www.manpagez.com/man/8/spctl/
# References for /var/db/SystemPolicy-prefs.plist # References for /var/db/SystemPolicy-prefs.plist
- https://web.archive.org/web/20240810103202/https://krypted.com/mac-security/manage-gatekeeper-from-the-command-line-in-mountain-lion/ - https://krypted.com/mac-security/manage-gatekeeper-from-the-command-line-in-mountain-lion/
- https://community.jamf.com/t5/jamf-pro/users-can-t-change-password-greyed-out/m-p/54228 - https://community.jamf.com/t5/jamf-pro/users-can-t-change-password-greyed-out/m-p/54228
code: |- code: |-
os_major_ver=$(sw_vers -productVersion | awk -F "." '{print $1}') os_major_ver=$(sw_vers -productVersion | awk -F "." '{print $1}')
@@ -1842,10 +1842,10 @@ actions:
fi fi
- -
name: Disable library validation entitlement (library signature validation) name: Disable library validation entitlement (library signature validation)
docs: |- docs:
- [Disable Library Validation Entitlement | Apple Developer Documentation | developer.apple.com](https://archive.ph/2024.07.19-101811/https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_cs_disable-library-validation) - https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_cs_disable-library-validation
- [Forbidden Commands to Speed Up macOS | www.naut.ca](https://web.archive.org/web/20240625020749/https://www.naut.ca/blog/2020/11/13/forbidden-commands-to-liberate-macos/) - https://www.macenhance.com/docs/general/sip-library-validation.html
- [macEnhance | macEnhance.com](https://web.archive.org/web/20220622212008/https://www.macenhance.com/docs/general/sip-library-validation.html) - https://www.naut.ca/blog/2020/11/13/forbidden-commands-to-liberate-macos/
code: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool true code: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool true
revertCode: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool false revertCode: sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist 'DisableLibraryValidation' -bool false
- -

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,11 @@
import { getEnumValues, assertInRange } from '@/application/Common/Enum'; import { getEnumValues, assertInRange } from '@/application/Common/Enum';
import { RecommendationLevel } from '../Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '../Executables/Script/RecommendationLevel';
import { OperatingSystem } from '../OperatingSystem'; import { OperatingSystem } from '../OperatingSystem';
import { validateCategoryCollection } from './Validation/CompositeCategoryCollectionValidator'; import type { ExecutableId, Identifiable } from '../Executables/Identifiable';
import type { ExecutableId } from '../Executables/Identifiable';
import type { Category } from '../Executables/Category/Category'; import type { Category } from '../Executables/Category/Category';
import type { Script } from '../Executables/Script/Script'; import type { Script } from '../Executables/Script/Script';
import type { IScriptingDefinition } from '../IScriptingDefinition'; import type { IScriptingDefinition } from '../IScriptingDefinition';
import type { ICategoryCollection } from './ICategoryCollection'; import type { ICategoryCollection } from './ICategoryCollection';
import type { CategoryCollectionValidator } from './Validation/CategoryCollectionValidator';
export class CategoryCollection implements ICategoryCollection { export class CategoryCollection implements ICategoryCollection {
public readonly os: OperatingSystem; public readonly os: OperatingSystem;
@@ -24,18 +22,16 @@ export class CategoryCollection implements ICategoryCollection {
constructor( constructor(
parameters: CategoryCollectionInitParameters, parameters: CategoryCollectionInitParameters,
validate: CategoryCollectionValidator = validateCategoryCollection,
) { ) {
this.os = parameters.os; this.os = parameters.os;
this.actions = parameters.actions; this.actions = parameters.actions;
this.scripting = parameters.scripting; this.scripting = parameters.scripting;
this.queryable = makeQueryable(this.actions); this.queryable = makeQueryable(this.actions);
validate({ assertInRange(this.os, OperatingSystem);
allScripts: this.queryable.allScripts, ensureValid(this.queryable);
allCategories: this.queryable.allCategories, ensureNoDuplicateIds(this.queryable.allCategories);
operatingSystem: this.os, ensureNoDuplicateIds(this.queryable.allScripts);
});
} }
public getCategory(executableId: ExecutableId): Category { public getCategory(executableId: ExecutableId): Category {
@@ -52,10 +48,10 @@ export class CategoryCollection implements ICategoryCollection {
return scripts ?? []; return scripts ?? [];
} }
public getScript(executableId: ExecutableId): Script { public getScript(executableId: string): Script {
const script = this.queryable.allScripts.find((s) => s.executableId === executableId); const script = this.queryable.allScripts.find((s) => s.executableId === executableId);
if (!script) { if (!script) {
throw new Error(`Missing script: ${executableId}`); throw new Error(`missing script: ${executableId}`);
} }
return script; return script;
} }
@@ -69,6 +65,18 @@ export class CategoryCollection implements ICategoryCollection {
} }
} }
function ensureNoDuplicateIds(executables: ReadonlyArray<Identifiable>) { // TODO: Unit test this
const duplicatedIds = executables
.map((e) => e.executableId)
.filter((id, index, array) => array.findIndex((otherId) => otherId === id) !== index);
if (duplicatedIds.length > 0) {
const duplicatedIdsText = duplicatedIds.map((id) => `"${id}"`).join(',');
throw new Error(
`Duplicate executables are detected with following id(s): ${duplicatedIdsText}`,
);
}
}
export interface CategoryCollectionInitParameters { export interface CategoryCollectionInitParameters {
readonly os: OperatingSystem; readonly os: OperatingSystem;
readonly actions: ReadonlyArray<Category>; readonly actions: ReadonlyArray<Category>;
@@ -81,12 +89,35 @@ interface QueryableCollection {
readonly scriptsByLevel: Map<RecommendationLevel, readonly Script[]>; readonly scriptsByLevel: Map<RecommendationLevel, readonly Script[]>;
} }
function flattenCategoryHierarchy( function ensureValid(application: QueryableCollection) {
ensureValidCategories(application.allCategories);
ensureValidScripts(application.allScripts);
}
function ensureValidCategories(allCategories: readonly Category[]) {
if (!allCategories.length) {
throw new Error('must consist of at least one category');
}
}
function ensureValidScripts(allScripts: readonly Script[]) {
if (!allScripts.length) {
throw new Error('must consist of at least one script');
}
const missingRecommendationLevels = getEnumValues(RecommendationLevel)
.filter((level) => allScripts.every((script) => script.level !== level));
if (missingRecommendationLevels.length > 0) {
throw new Error('none of the scripts are recommended as'
+ ` "${missingRecommendationLevels.map((level) => RecommendationLevel[level]).join(', "')}".`);
}
}
function flattenApplication(
categories: ReadonlyArray<Category>, categories: ReadonlyArray<Category>,
): [Category[], Script[]] { ): [Category[], Script[]] {
const [subCategories, subScripts] = (categories || []) const [subCategories, subScripts] = (categories || [])
// Parse children // Parse children
.map((category) => flattenCategoryHierarchy(category.subcategories)) .map((category) => flattenApplication(category.subcategories))
// Flatten results // Flatten results
.reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => { .reduce(([previousCategories, previousScripts], [currentCategories, currentScripts]) => {
return [ return [
@@ -109,7 +140,7 @@ function flattenCategoryHierarchy(
function makeQueryable( function makeQueryable(
actions: ReadonlyArray<Category>, actions: ReadonlyArray<Category>,
): QueryableCollection { ): QueryableCollection {
const flattened = flattenCategoryHierarchy(actions); const flattened = flattenApplication(actions);
return { return {
allCategories: flattened[0], allCategories: flattened[0],
allScripts: flattened[1], allScripts: flattened[1],

View File

@@ -1,15 +0,0 @@
import type { Category } from '@/domain/Executables/Category/Category';
import type { Script } from '@/domain/Executables/Script/Script';
import type { OperatingSystem } from '@/domain/OperatingSystem';
export interface CategoryCollectionValidationContext {
readonly allScripts: readonly Script[];
readonly allCategories: readonly Category[];
readonly operatingSystem: OperatingSystem;
}
export interface CategoryCollectionValidator {
(
context: CategoryCollectionValidationContext,
): void;
}

View File

@@ -1,33 +0,0 @@
import { ensurePresenceOfAtLeastOneScript } from './Rules/EnsurePresenceOfAtLeastOneScript';
import { ensurePresenceOfAtLeastOneCategory } from './Rules/EnsurePresenceOfAtLeastOneCategory';
import { ensureUniqueIdsAcrossExecutables } from './Rules/EnsureUniqueIdsAcrossExecutables';
import { ensureKnownOperatingSystem } from './Rules/EnsureKnownOperatingSystem';
import type { CategoryCollectionValidationContext, CategoryCollectionValidator } from './CategoryCollectionValidator';
export type CompositeCategoryCollectionValidator = CategoryCollectionValidator & {
(
...args: [
...Parameters<CategoryCollectionValidator>,
(readonly CategoryCollectionValidator[])?,
]
): ReturnType<CategoryCollectionValidator>;
};
export const validateCategoryCollection: CompositeCategoryCollectionValidator = (
context: CategoryCollectionValidationContext,
validators: readonly CategoryCollectionValidator[] = DefaultValidators,
) => {
if (!validators.length) {
throw new Error('No validators provided.');
}
for (const validate of validators) {
validate(context);
}
};
const DefaultValidators: readonly CategoryCollectionValidator[] = [
ensureKnownOperatingSystem,
ensurePresenceOfAtLeastOneScript,
ensurePresenceOfAtLeastOneCategory,
ensureUniqueIdsAcrossExecutables,
];

View File

@@ -1,9 +0,0 @@
import { assertInRange } from '@/application/Common/Enum';
import { OperatingSystem } from '@/domain/OperatingSystem';
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensureKnownOperatingSystem: CategoryCollectionValidator = (
context,
) => {
assertInRange(context.operatingSystem, OperatingSystem);
};

View File

@@ -1,35 +0,0 @@
import { getEnumValues } from '@/application/Common/Enum';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { Script } from '@/domain/Executables/Script/Script';
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensurePresenceOfAllRecommendationLevels: CategoryCollectionValidator = (
context,
) => {
const unrepresentedRecommendationLevels = getUnrepresentedRecommendationLevels(
context.allScripts,
);
if (unrepresentedRecommendationLevels.length === 0) {
return;
}
const formattedRecommendationLevels = unrepresentedRecommendationLevels
.map((level) => getDisplayName(level))
.join(', ');
throw new Error(`Missing recommendation levels: ${formattedRecommendationLevels}.`);
};
function getUnrepresentedRecommendationLevels(
scripts: readonly Script[],
): (RecommendationLevel | undefined)[] {
const expectedLevels = [
undefined,
...getEnumValues(RecommendationLevel),
];
return expectedLevels.filter(
(level) => scripts.every((script) => script.level !== level),
);
}
function getDisplayName(level: RecommendationLevel | undefined): string {
return level === undefined ? 'None' : RecommendationLevel[level];
}

View File

@@ -1,9 +0,0 @@
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensurePresenceOfAtLeastOneCategory: CategoryCollectionValidator = (
context,
) => {
if (!context.allCategories.length) {
throw new Error('Collection must have at least one category');
}
};

View File

@@ -1,9 +0,0 @@
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensurePresenceOfAtLeastOneScript: CategoryCollectionValidator = (
context,
) => {
if (!context.allScripts.length) {
throw new Error('Collection must have at least one script');
}
};

View File

@@ -1,43 +0,0 @@
import type { Identifiable } from '@/domain/Executables/Identifiable';
import type { CategoryCollectionValidator } from '../CategoryCollectionValidator';
export const ensureUniqueIdsAcrossExecutables: CategoryCollectionValidator = (
context,
) => {
const allExecutables: readonly Identifiable[] = [
...context.allCategories,
...context.allScripts,
];
ensureNoDuplicateIds(allExecutables);
};
function ensureNoDuplicateIds(
executables: readonly Identifiable[],
) {
const duplicateExecutables = getExecutablesWithDuplicateIds(executables);
if (duplicateExecutables.length === 0) {
return;
}
const formattedDuplicateIds = duplicateExecutables.map(
(executable) => `"${executable.executableId}"`,
).join(', ');
throw new Error(`Duplicate executable IDs found: ${formattedDuplicateIds}`);
}
function getExecutablesWithDuplicateIds(
executables: readonly Identifiable[],
): Identifiable[] {
return executables
.filter(
(executable, index, array) => {
const otherIndex = array.findIndex(
(otherExecutable) => haveIdenticalIds(executable, otherExecutable),
);
return otherIndex !== index;
},
);
}
function haveIdenticalIds(first: Identifiable, second: Identifiable): boolean {
return first.executableId === second.executableId;
}

View File

@@ -1,10 +1,9 @@
import { RecommendationLevel } from './RecommendationLevel'; import { RecommendationLevel } from './RecommendationLevel';
import type { ScriptCode } from './Code/ScriptCode'; import type { ScriptCode } from './Code/ScriptCode';
import type { Script } from './Script'; import type { Script } from './Script';
import type { ExecutableId } from '../Identifiable';
export interface ScriptInitParameters { export interface ScriptInitParameters {
readonly executableId: ExecutableId; readonly executableId: string;
readonly name: string; readonly name: string;
readonly code: ScriptCode; readonly code: ScriptCode;
readonly docs: ReadonlyArray<string>; readonly docs: ReadonlyArray<string>;
@@ -20,7 +19,7 @@ export const createScript: ScriptFactory = (parameters) => {
}; };
class CollectionScript implements Script { class CollectionScript implements Script {
public readonly executableId: ExecutableId; public readonly executableId: string;
public readonly name: string; public readonly name: string;

View File

@@ -1,37 +1,32 @@
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations'; import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import type { import { NodeElectronSystemOperations } from '../../System/NodeElectronSystemOperations';
DirectoryCreationOutcome, ApplicationDirectoryProvider, DirectoryType, import type { SystemOperations } from '../../System/SystemOperations';
DirectoryCreationError, DirectoryCreationErrorType, import type { ScriptDirectoryOutcome, ScriptDirectoryProvider } from './ScriptDirectoryProvider';
} from './ApplicationDirectoryProvider';
import type { FileSystemOperations } from '../FileSystemOperations';
export const SubdirectoryNames: Record<DirectoryType, string> = { export const ExecutionSubdirectory = 'runs';
'script-runs': 'runs',
'update-installation-files': 'updates',
};
/** /**
* Provides persistent directories. * Provides a dedicated directory for script execution.
* Benefits of using a persistent directory: * Benefits of using a persistent directory:
* - Antivirus Exclusions: Easier antivirus configuration. * - Antivirus Exclusions: Easier antivirus configuration.
* - Auditability: Stores script execution history for troubleshooting. * - Auditability: Stores script execution history for troubleshooting.
* - Reliability: Avoids issues with directory clean-ups during execution, * - Reliability: Avoids issues with directory clean-ups during execution,
* seen in Windows Pro Azure VMs when stored on Windows temporary directory. * seen in Windows Pro Azure VMs when stored on Windows temporary directory.
*/ */
export class PersistentApplicationDirectoryProvider implements ApplicationDirectoryProvider { export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
constructor( constructor(
private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations, private readonly system: SystemOperations = new NodeElectronSystemOperations(),
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }
public async provideDirectory(type: DirectoryType): Promise<DirectoryCreationOutcome> { public async provideScriptDirectory(): Promise<ScriptDirectoryOutcome> {
const { const {
success: isPathConstructed, success: isPathConstructed,
error: pathConstructionError, error: pathConstructionError,
directoryPath, directoryPath,
} = this.constructScriptDirectoryPath(type); } = this.constructScriptDirectoryPath();
if (!isPathConstructed) { if (!isPathConstructed) {
return { return {
success: false, success: false,
@@ -57,7 +52,7 @@ export class PersistentApplicationDirectoryProvider implements ApplicationDirect
private async createDirectory(directoryPath: string): Promise<DirectoryPathCreationOutcome> { private async createDirectory(directoryPath: string): Promise<DirectoryPathCreationOutcome> {
try { try {
this.logger.info(`Attempting to create script directory at path: ${directoryPath}`); this.logger.info(`Attempting to create script directory at path: ${directoryPath}`);
await this.fileSystem.createDirectory(directoryPath, true); await this.system.fileSystem.createDirectory(directoryPath, true);
this.logger.info(`Script directory successfully created at: ${directoryPath}`); this.logger.info(`Script directory successfully created at: ${directoryPath}`);
return { return {
success: true, success: true,
@@ -65,26 +60,17 @@ export class PersistentApplicationDirectoryProvider implements ApplicationDirect
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: this.handleError(error, 'DirectoryWriteError'), error: this.handleException(error, 'DirectoryCreationError'),
}; };
} }
} }
private constructScriptDirectoryPath(type: DirectoryType): DirectoryPathConstructionOutcome { private constructScriptDirectoryPath(): DirectoryPathConstructionOutcome {
let parentDirectory: string;
try { try {
parentDirectory = this.fileSystem.getUserDataDirectory(); const parentDirectory = this.system.operatingSystem.getUserDataDirectory();
} catch (error) { const scriptDirectory = this.system.location.combinePaths(
return {
success: false,
error: this.handleError(error, 'UserDataFolderRetrievalError'),
};
}
try {
const subdirectoryName = SubdirectoryNames[type];
const scriptDirectory = this.fileSystem.combinePaths(
parentDirectory, parentDirectory,
subdirectoryName, ExecutionSubdirectory,
); );
return { return {
success: true, success: true,
@@ -93,15 +79,15 @@ export class PersistentApplicationDirectoryProvider implements ApplicationDirect
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: this.handleError(error, 'PathConstructionError'), error: this.handleException(error, 'DirectoryCreationError'),
}; };
} }
} }
private handleError( private handleException(
exception: Error, exception: Error,
errorType: DirectoryCreationErrorType, errorType: CodeRunErrorType,
): DirectoryCreationError { ): CodeRunError {
const errorMessage = 'Error during script directory creation'; const errorMessage = 'Error during script directory creation';
this.logger.error(errorType, errorMessage, exception); this.logger.error(errorType, errorMessage, exception);
return { return {
@@ -113,7 +99,7 @@ export class PersistentApplicationDirectoryProvider implements ApplicationDirect
type DirectoryPathConstructionOutcome = { type DirectoryPathConstructionOutcome = {
readonly success: false; readonly success: false;
readonly error: DirectoryCreationError; readonly error: CodeRunError;
readonly directoryPath?: undefined; readonly directoryPath?: undefined;
} | { } | {
readonly success: true; readonly success: true;
@@ -123,7 +109,7 @@ type DirectoryPathConstructionOutcome = {
type DirectoryPathCreationOutcome = { type DirectoryPathCreationOutcome = {
readonly success: false; readonly success: false;
readonly error: DirectoryCreationError; readonly error: CodeRunError;
} | { } | {
readonly success: true; readonly success: true;
readonly error?: undefined; readonly error?: undefined;

View File

@@ -0,0 +1,23 @@
import type { CodeRunError } from '@/application/CodeRunner/CodeRunner';
export interface ScriptDirectoryProvider {
provideScriptDirectory(): Promise<ScriptDirectoryOutcome>;
}
export type ScriptDirectoryOutcome = SuccessfulDirectoryCreation | FailedDirectoryCreation;
interface ScriptDirectoryCreationStatus {
readonly success: boolean;
readonly directoryAbsolutePath?: string;
readonly error?: CodeRunError;
}
interface SuccessfulDirectoryCreation extends ScriptDirectoryCreationStatus {
readonly success: true;
readonly directoryAbsolutePath: string;
}
interface FailedDirectoryCreation extends ScriptDirectoryCreationStatus {
readonly success: false;
readonly error: CodeRunError;
}

View File

@@ -1,22 +1,21 @@
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner'; import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter'; import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter'; import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations'; import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator'; import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator';
import { PersistentDirectoryProvider } from './Directory/PersistentDirectoryProvider';
import type { SystemOperations } from '../System/SystemOperations';
import type { FilenameGenerator } from './Filename/FilenameGenerator'; import type { FilenameGenerator } from './Filename/FilenameGenerator';
import type { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator'; import type { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator';
import type { ScriptDirectoryProvider } from './Directory/ScriptDirectoryProvider';
export class ScriptFileCreationOrchestrator implements ScriptFileCreator { export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
constructor( constructor(
private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations, private readonly system: SystemOperations = new NodeElectronSystemOperations(),
private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(), private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(),
private readonly directoryProvider: ApplicationDirectoryProvider private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(),
= new PersistentApplicationDirectoryProvider(),
private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(), private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(),
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }
@@ -27,12 +26,9 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
): Promise<ScriptFileCreationOutcome> { ): Promise<ScriptFileCreationOutcome> {
const { const {
success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath, success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath,
} = await this.directoryProvider.provideDirectory('script-runs'); } = await this.directoryProvider.provideScriptDirectory();
if (!isDirectoryCreated) { if (!isDirectoryCreated) {
return createFailure({ return createFailure(directoryCreationError);
type: 'DirectoryCreationError',
message: `[${directoryCreationError.type}] ${directoryCreationError.message}`,
});
} }
const { const {
success: isFilePathConstructed, error: filePathGenerationError, filePath, success: isFilePathConstructed, error: filePathGenerationError, filePath,
@@ -58,7 +54,7 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
): FilePathConstructionOutcome { ): FilePathConstructionOutcome {
try { try {
const filename = this.filenameGenerator.generateFilename(scriptFilenameParts); const filename = this.filenameGenerator.generateFilename(scriptFilenameParts);
const filePath = this.fileSystem.combinePaths(directoryPath, filename); const filePath = this.system.location.combinePaths(directoryPath, filename);
return { success: true, filePath }; return { success: true, filePath };
} catch (error) { } catch (error) {
return { return {

View File

@@ -7,7 +7,7 @@ import type { ExecutablePermissionSetter } from './ExecutablePermissionSetter';
export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter { export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter {
constructor( constructor(
private readonly system: SystemOperations = NodeElectronSystemOperations, private readonly system: SystemOperations = new NodeElectronSystemOperations(),
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }

View File

@@ -7,7 +7,7 @@ import type { ShellCommandOutcome, ShellCommandRunner } from './ShellCommandRunn
export class LoggingNodeShellCommandRunner implements ShellCommandRunner { export class LoggingNodeShellCommandRunner implements ShellCommandRunner {
constructor( constructor(
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
private readonly systemOps: SystemOperations = NodeElectronSystemOperations, private readonly systemOps: SystemOperations = new NodeElectronSystemOperations(),
) { ) {
} }

View File

@@ -1,13 +1,57 @@
import { join } from 'node:path';
import { chmod, mkdir } from 'node:fs/promises';
import { exec } from 'node:child_process'; import { exec } from 'node:child_process';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations'; import { app } from 'electron/main';
import type { SystemOperations } from './SystemOperations'; import type {
CommandOps, FileSystemOps, LocationOps, OperatingSystemOps, SystemOperations,
} from './SystemOperations';
/** /**
* Thin wrapper for Node and Electron APIs. * Thin wrapper for Node and Electron APIs.
*/ */
export const NodeElectronSystemOperations: SystemOperations = { export class NodeElectronSystemOperations implements SystemOperations {
fileSystem: NodeElectronFileSystemOperations, public readonly operatingSystem: OperatingSystemOps = {
command: { /*
This method returns the directory for storing app's configuration files.
It appends your app's name to the default appData directory.
Conventionally, you should store user data files in this directory.
However, avoid writing large files here as some environments might back up this directory
to cloud storage, potentially causing issues with file size.
Based on tests it returns:
- Windows: `%APPDATA%\privacy.sexy`
- Linux: `$HOME/.config/privacy.sexy/runs`
- macOS: `$HOME/Library/Application Support/privacy.sexy/runs`
For more details, refer to the Electron documentation: https://web.archive.org/web/20240104154857/https://www.electronjs.org/docs/latest/api/app#appgetpathname
*/
getUserDataDirectory: () => {
return app.getPath('userData');
},
};
public readonly location: LocationOps = {
combinePaths: (...pathSegments) => join(...pathSegments),
};
public readonly fileSystem: FileSystemOps = {
setFilePermissions: (
filePath: string,
mode: string | number,
) => chmod(filePath, mode),
createDirectory: async (
directoryPath: string,
isRecursive?: boolean,
) => {
await mkdir(directoryPath, { recursive: isRecursive });
// Ignoring the return value from `mkdir`, which is the first directory created
// when `recursive` is true, or empty return value.
// See https://github.com/nodejs/node/pull/31530
},
};
public readonly command: CommandOps = {
exec, exec,
}, };
}; }

View File

@@ -1,11 +1,25 @@
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { exec } from 'node:child_process'; import type { exec } from 'node:child_process';
export interface SystemOperations { export interface SystemOperations {
readonly fileSystem: FileSystemOperations; readonly operatingSystem: OperatingSystemOps;
readonly location: LocationOps;
readonly fileSystem: FileSystemOps;
readonly command: CommandOps; readonly command: CommandOps;
} }
export interface OperatingSystemOps {
getUserDataDirectory(): string;
}
export interface LocationOps {
combinePaths(...pathSegments: string[]): string;
}
export interface CommandOps { export interface CommandOps {
exec(command: string): ReturnType<typeof exec>; exec(command: string): ReturnType<typeof exec>;
} }
export interface FileSystemOps {
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
}

View File

@@ -5,8 +5,8 @@ import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { import {
FileType, type SaveFileError, type SaveFileErrorType, type SaveFileOutcome, FileType, type SaveFileError, type SaveFileErrorType, type SaveFileOutcome,
} from '@/presentation/common/Dialog'; } from '@/presentation/common/Dialog';
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter'; import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter'; import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
import type { ElectronSaveFileDialog } from './ElectronSaveFileDialog'; import type { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog { export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {

View File

@@ -1,30 +0,0 @@
export interface ApplicationDirectoryProvider {
provideDirectory(type: DirectoryType): Promise<DirectoryCreationOutcome>;
}
export type DirectoryType = 'update-installation-files' | 'script-runs';
export type DirectoryCreationOutcome = SuccessfulDirectoryCreation | FailedDirectoryCreation;
export type DirectoryCreationErrorType = 'PathConstructionError' | 'DirectoryWriteError' | 'UserDataFolderRetrievalError';
export interface DirectoryCreationError {
readonly type: DirectoryCreationErrorType;
readonly message: string;
}
interface DirectoryCreationStatus {
readonly success: boolean;
readonly directoryAbsolutePath?: string;
readonly error?: DirectoryCreationError;
}
interface SuccessfulDirectoryCreation extends DirectoryCreationStatus {
readonly success: true;
readonly directoryAbsolutePath: string;
}
interface FailedDirectoryCreation extends DirectoryCreationStatus {
readonly success: false;
readonly error: DirectoryCreationError;
}

View File

@@ -1,20 +0,0 @@
export interface FileSystemOperations {
getUserDataDirectory(): string;
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
isFileAvailable(filePath: string): Promise<boolean>;
isDirectoryAvailable(filePath: string): Promise<boolean>;
deletePath(filePath: string): Promise<void>;
listDirectoryContents(directoryPath: string): Promise<string[]>;
combinePaths(...pathSegments: string[]): string;
readFile: (filePath: string, encoding: NodeJS.BufferEncoding) => Promise<string>;
writeFile: (
filePath: string,
fileContents: string,
encoding: NodeJS.BufferEncoding,
) => Promise<void>;
}

View File

@@ -1,68 +0,0 @@
import { join } from 'node:path';
import {
chmod, mkdir,
readdir, rm, stat,
readFile, writeFile,
} from 'node:fs/promises';
import { app } from 'electron/main';
import type { FileSystemOperations } from './FileSystemOperations';
import type { Stats } from 'node:original-fs';
/**
* Thin wrapper for Node and Electron APIs.
*/
export const NodeElectronFileSystemOperations: FileSystemOperations = {
combinePaths: (...pathSegments) => join(...pathSegments),
setFilePermissions: (
filePath: string,
mode: string | number,
) => chmod(filePath, mode),
createDirectory: async (
directoryPath: string,
isRecursive?: boolean,
) => {
await mkdir(directoryPath, { recursive: isRecursive });
// Ignoring the return value from `mkdir`, which is the first directory created
// when `recursive` is true, or empty return value.
// See https://github.com/nodejs/node/pull/31530
},
isFileAvailable: async (path) => isPathAvailable(path, (stats) => stats.isFile()),
isDirectoryAvailable: async (path) => isPathAvailable(path, (stats) => stats.isDirectory()),
deletePath: (path) => rm(path, { recursive: true, force: true }),
listDirectoryContents: (directoryPath) => readdir(directoryPath),
getUserDataDirectory: () => {
/*
This method returns the directory for storing app's configuration files.
It appends your app's name to the default appData directory.
Conventionally, you should store user data files in this directory.
However, avoid writing large files here as some environments might back up this directory
to cloud storage, potentially causing issues with file size.
Based on tests it returns:
- Windows: `%APPDATA%\privacy.sexy`
- Linux: `$HOME/.config/privacy.sexy/runs`
- macOS: `$HOME/Library/Application Support/privacy.sexy/runs`
For more details, refer to the Electron documentation: https://web.archive.org/web/20240104154857/https://www.electronjs.org/docs/latest/api/app#appgetpathname
*/
return app.getPath('userData');
},
writeFile,
readFile,
};
async function isPathAvailable(
path: string,
condition: (stats: Stats) => boolean,
): Promise<boolean> {
try {
const stats = await stat(path);
return condition(stats);
} catch (error) {
if (error.code === 'ENOENT') {
return false; // path does not exist
}
throw error;
}
}

View File

@@ -1,18 +1,22 @@
import { writeFile, access, readFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '../../Log/ElectronLogger'; import { ElectronLogger } from '../Log/ElectronLogger';
import { NodeElectronFileSystemOperations } from '../NodeElectronFileSystemOperations';
import type { import type {
FailedFileWrite, ReadbackFileWriter, FileWriteErrorType, FailedFileWrite, ReadbackFileWriter, FileWriteErrorType,
FileWriteOutcome, SuccessfulFileWrite, FileWriteOutcome, SuccessfulFileWrite,
} from './ReadbackFileWriter'; } from './ReadbackFileWriter';
import type { FileSystemOperations } from '../FileSystemOperations';
const FILE_ENCODING: NodeJS.BufferEncoding = 'utf-8'; const FILE_ENCODING: NodeJS.BufferEncoding = 'utf-8';
export class NodeReadbackFileWriter implements ReadbackFileWriter { export class NodeReadbackFileWriter implements ReadbackFileWriter {
constructor( constructor(
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations, private readonly fileSystem: FileReadWriteOperations = {
writeFile,
readFile: (path, encoding) => readFile(path, encoding),
access,
},
) { } ) { }
public async writeAndVerifyFile( public async writeAndVerifyFile(
@@ -51,9 +55,7 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter {
filePath: string, filePath: string,
): Promise<FileWriteOutcome> { ): Promise<FileWriteOutcome> {
try { try {
if (!(await this.fileSystem.isFileAvailable(filePath))) { await this.fileSystem.access(filePath, constants.F_OK);
return this.reportFailure('FileExistenceVerificationFailed', 'File does not exist.');
}
return this.reportSuccess('Verified file existence without reading.'); return this.reportSuccess('Verified file existence without reading.');
} catch (error) { } catch (error) {
return this.reportFailure('FileExistenceVerificationFailed', error); return this.reportFailure('FileExistenceVerificationFailed', error);
@@ -105,3 +107,9 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter {
}; };
} }
} }
export interface FileReadWriteOperations {
readonly writeFile: typeof writeFile;
readonly access: typeof access;
readFile: (filePath: string, encoding: NodeJS.BufferEncoding) => Promise<string>;
}

View File

@@ -1,5 +1,5 @@
import type { Repository } from '../../application/Repository/Repository'; import type { Repository } from '../../application/Repository/Repository';
import type { RepositoryEntity, RepositoryEntityId } from '../../application/Repository/RepositoryEntity'; import type { RepositoryEntity } from '../../application/Repository/RepositoryEntity';
export class InMemoryRepository<TEntity extends RepositoryEntity> export class InMemoryRepository<TEntity extends RepositoryEntity>
implements Repository<TEntity> { implements Repository<TEntity> {
@@ -20,7 +20,7 @@ implements Repository<TEntity> {
return predicate ? this.items.filter(predicate) : this.items; return predicate ? this.items.filter(predicate) : this.items;
} }
public getById(id: RepositoryEntityId): TEntity { public getById(id: string): TEntity {
const items = this.getItems((entity) => entity.id === id); const items = this.getItems((entity) => entity.id === id);
if (!items.length) { if (!items.length) {
throw new Error(`missing item: ${id}`); throw new Error(`missing item: ${id}`);
@@ -42,7 +42,7 @@ implements Repository<TEntity> {
this.items.push(item); this.items.push(item);
} }
public removeItem(id: RepositoryEntityId): void { public removeItem(id: string): void {
const index = this.items.findIndex((item) => item.id === id); const index = this.items.findIndex((item) => item.id === id);
if (index === -1) { if (index === -1) {
throw new Error(`Cannot remove (id: ${id}) as it does not exist`); throw new Error(`Cannot remove (id: ${id}) as it does not exist`);
@@ -50,7 +50,7 @@ implements Repository<TEntity> {
this.items.splice(index, 1); this.items.splice(index, 1);
} }
public exists(id: RepositoryEntityId): boolean { public exists(id: string): boolean {
const index = this.items.findIndex((item) => item.id === id); const index = this.items.findIndex((item) => item.id === id);
return index !== -1; return index !== -1;
} }

View File

@@ -1,16 +1,16 @@
import type { SanityValidator } from './SanityValidator'; import type { ISanityValidator } from './ISanityValidator';
import type { SanityCheckOptions } from './SanityCheckOptions'; import type { ISanityCheckOptions } from './ISanityCheckOptions';
export type FactoryFunction<T> = () => T; export type FactoryFunction<T> = () => T;
export abstract class FactoryValidator<T> implements SanityValidator { export abstract class FactoryValidator<T> implements ISanityValidator {
private readonly factory: FactoryFunction<T>; private readonly factory: FactoryFunction<T>;
protected constructor(factory: FactoryFunction<T>) { protected constructor(factory: FactoryFunction<T>) {
this.factory = factory; this.factory = factory;
} }
public abstract shouldValidate(options: SanityCheckOptions): boolean; public abstract shouldValidate(options: ISanityCheckOptions): boolean;
public abstract name: string; public abstract name: string;

View File

@@ -1,4 +1,4 @@
export interface SanityCheckOptions { export interface ISanityCheckOptions {
readonly validateEnvironmentVariables: boolean; readonly validateEnvironmentVariables: boolean;
readonly validateWindowVariables: boolean; readonly validateWindowVariables: boolean;
} }

View File

@@ -0,0 +1,7 @@
import type { ISanityCheckOptions } from './ISanityCheckOptions';
export interface ISanityValidator {
readonly name: string;
shouldValidate(options: ISanityCheckOptions): boolean;
collectErrors(): Iterable<string>;
}

View File

@@ -1,7 +0,0 @@
import type { SanityCheckOptions } from './SanityCheckOptions';
export interface SanityValidator {
readonly name: string;
shouldValidate(options: SanityCheckOptions): boolean;
collectErrors(): Iterable<string>;
}

View File

@@ -1,23 +1,16 @@
import { EnvironmentVariablesValidator } from './Validators/EnvironmentVariablesValidator'; import { EnvironmentVariablesValidator } from './Validators/EnvironmentVariablesValidator';
import type { SanityCheckOptions } from './Common/SanityCheckOptions'; import type { ISanityCheckOptions } from './Common/ISanityCheckOptions';
import type { SanityValidator } from './Common/SanityValidator'; import type { ISanityValidator } from './Common/ISanityValidator';
const DefaultSanityValidators: SanityValidator[] = [ const DefaultSanityValidators: ISanityValidator[] = [
new EnvironmentVariablesValidator(), new EnvironmentVariablesValidator(),
]; ];
export interface RuntimeSanityValidator {
(
options: SanityCheckOptions,
validators?: readonly SanityValidator[],
): void;
}
/* Helps to fail-fast on errors */ /* Helps to fail-fast on errors */
export const validateRuntimeSanity: RuntimeSanityValidator = ( export function validateRuntimeSanity(
options: SanityCheckOptions, options: ISanityCheckOptions,
validators: readonly SanityValidator[] = DefaultSanityValidators, validators: readonly ISanityValidator[] = DefaultSanityValidators,
) => { ): void {
if (!validators.length) { if (!validators.length) {
throw new Error('missing validators'); throw new Error('missing validators');
} }
@@ -33,9 +26,9 @@ export const validateRuntimeSanity: RuntimeSanityValidator = (
if (errorMessages.length > 0) { if (errorMessages.length > 0) {
throw new Error(`Sanity check failed.\n${errorMessages.join('\n---\n')}`); throw new Error(`Sanity check failed.\n${errorMessages.join('\n---\n')}`);
} }
}; }
function getErrorMessage(validator: SanityValidator): string | undefined { function getErrorMessage(validator: ISanityValidator): string | undefined {
const errorMessages = [...validator.collectErrors()]; const errorMessages = [...validator.collectErrors()];
if (!errorMessages.length) { if (!errorMessages.length) {
return undefined; return undefined;

View File

@@ -1,7 +1,7 @@
import type { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables'; import type { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory'; import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { FactoryValidator, type FactoryFunction } from '../Common/FactoryValidator'; import { FactoryValidator, type FactoryFunction } from '../Common/FactoryValidator';
import type { SanityCheckOptions } from '../Common/SanityCheckOptions'; import type { ISanityCheckOptions } from '../Common/ISanityCheckOptions';
export class EnvironmentVariablesValidator extends FactoryValidator<IEnvironmentVariables> { export class EnvironmentVariablesValidator extends FactoryValidator<IEnvironmentVariables> {
constructor( constructor(
@@ -14,7 +14,7 @@ export class EnvironmentVariablesValidator extends FactoryValidator<IEnvironment
public override name = 'environment variables'; public override name = 'environment variables';
public override shouldValidate(options: SanityCheckOptions): boolean { public override shouldValidate(options: ISanityCheckOptions): boolean {
return options.validateEnvironmentVariables; return options.validateEnvironmentVariables;
} }
} }

View File

@@ -1,6 +1,6 @@
import type { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; import type { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { FactoryValidator, type FactoryFunction } from '../Common/FactoryValidator'; import { FactoryValidator, type FactoryFunction } from '../Common/FactoryValidator';
import type { SanityCheckOptions } from '../Common/SanityCheckOptions'; import type { ISanityCheckOptions } from '../Common/ISanityCheckOptions';
export class WindowVariablesValidator extends FactoryValidator<WindowVariables> { export class WindowVariablesValidator extends FactoryValidator<WindowVariables> {
constructor(factory: FactoryFunction<WindowVariables> = () => window) { constructor(factory: FactoryFunction<WindowVariables> = () => window) {
@@ -9,7 +9,7 @@ export class WindowVariablesValidator extends FactoryValidator<WindowVariables>
public override name = 'window variables'; public override name = 'window variables';
public override shouldValidate(options: SanityCheckOptions): boolean { public override shouldValidate(options: ISanityCheckOptions): boolean {
return options.validateWindowVariables; return options.validateWindowVariables;
} }
} }

View File

@@ -1,22 +1,19 @@
import type { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector'; import type { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector';
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory'; import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider'; import { PersistentDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/PersistentDirectoryProvider';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider'; import type { ScriptDirectoryProvider } from '../CodeRunner/Creation/Directory/ScriptDirectoryProvider';
export class ScriptEnvironmentDiagnosticsCollector implements ScriptDiagnosticsCollector { export class ScriptEnvironmentDiagnosticsCollector implements ScriptDiagnosticsCollector {
constructor( constructor(
private readonly directoryProvider: ApplicationDirectoryProvider private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(),
= new PersistentApplicationDirectoryProvider(),
private readonly environment: RuntimeEnvironment = CurrentEnvironment, private readonly environment: RuntimeEnvironment = CurrentEnvironment,
) { } ) { }
public async collectDiagnosticInformation(): Promise<ScriptDiagnosticData> { public async collectDiagnosticInformation(): Promise<ScriptDiagnosticData> {
const { const { directoryAbsolutePath } = await this.directoryProvider.provideScriptDirectory();
directoryAbsolutePath: scriptsDirectory,
} = await this.directoryProvider.provideDirectory('script-runs');
return { return {
scriptsDirectoryAbsolutePath: scriptsDirectory, scriptsDirectoryAbsolutePath: directoryAbsolutePath,
currentOperatingSystem: this.environment.os, currentOperatingSystem: this.environment.os,
}; };
} }

View File

@@ -6,20 +6,19 @@
- Darker than default: Shades are named as `color-{name}-dark`, `color-{name}-darker`, or `color-{name}-darkest`. - Darker than default: Shades are named as `color-{name}-dark`, `color-{name}-darker`, or `color-{name}-darkest`.
- Lighter than default: Tints are named as `color-{name}-light`, `color-{name}-lighter`, or `color-{name}-lightest`. - Lighter than default: Tints are named as `color-{name}-light`, `color-{name}-lighter`, or `color-{name}-lightest`.
*/ */
@use 'sass:color';
// --- Primary | The color displayed most frequently across screens and components // --- Primary | The color displayed most frequently across screens and components
$color-primary : #3a65ab; $color-primary : #3a65ab;
$color-primary-light : color.adjust($color-primary, $lightness: 30%); $color-primary-light : lighten($color-primary, 30%);
$color-primary-dark : color.adjust($color-primary, $lightness: -18%); $color-primary-dark : darken($color-primary, 18%);
$color-primary-darker : color.adjust($color-primary, $lightness: -32%); $color-primary-darker : darken($color-primary, 32%);
$color-primary-darkest : color.adjust($color-primary, $lightness: -44%); $color-primary-darkest : darken($color-primary, 44%);
// Text/iconography color that is usable on top of primary color // Text/iconography color that is usable on top of primary color
$color-on-primary : #e4f1fe; $color-on-primary : #e4f1fe;
// --- Secondary | Accent color, should be applied sparingly to accent select parts of UI // --- Secondary | Accent color, should be applied sparingly to accent select parts of UI
$color-secondary : #00D1AD; $color-secondary : #00D1AD;
$color-secondary-light : color.adjust($color-secondary, $lightness: 48%); $color-secondary-light : lighten($color-secondary, 48%);
// Text/iconography color that is usable on top of secondary color // Text/iconography color that is usable on top of secondary color
$color-on-secondary : #005051; $color-on-secondary : #005051;

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