mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Links a self-hosted instance to a SaaS team over an ordinary redirect, and leaves the admin's browser holding a Stirling session at the same time. ## The problem A self-hosted server needs a device credential bound to a SaaS team, and the admin's Supabase JWT must never reach the instance backend. Three things ruled out the obvious approaches: - **A customer hostname can never be in Supabase's redirect allow-list**, so the sign-in cannot happen on the instance's own origin. That is why SSO and sign-up did not work for linking at all. - **A device credential identifies a server, not a person.** Every attended portal read (Usage, Billing, Documents, Infrastructure) goes through `getPortalSaasToken()` and needs a *user* session, so a credential-only link left all of them asking for a second sign-in. - **The previous design relayed a JWT** from the browser into the instance, which is the thing we wanted to avoid. That path is deleted here. ## The solution Redirect and nonce, modelled on desktop's `authService.loginWithSelfHostedOAuth`: mint a nonce, hand the browser off, accept only a callback carrying that nonce back. Desktop has the OS route the reply; self-hosted has no OS hop, so our own approval page performs it. That is the point — the human half happens on an origin we control. ``` instance SaaS admin's browser | POST connect/request | | | (name, callback, nonce, | | | claim-secret hash) | | |-------------------------->| | | <- requestId + authorizeUrl | | | GET /link?request=... | | |<-------------------------------| | | sign in (SSO works here), | | | see ACCOUNT + ORIGIN, approve | | |------------------------------->| | | 302 callback#nonce+session | | POST connect/claim | | | (requestId, claim secret)| | |-------------------------->| | | <- device credential | | ``` Four properties carry the safety, and each is stated in the code because each is easy to lose in a refactor: - **The redirect target is never caller-supplied.** Validated once at creation, then read back from the stored row, so nothing in the approval page's URL can steer the token elsewhere. - **Approval and minting are separate.** Approval records the team and hands out nothing usable; the credential is minted only on claim, authenticated by a secret that never entered a browser. - **A re-authentication cannot move a server between teams.** The team is pinned at creation from the credential only that instance holds, so an approver from another team gets `WRONG_TEAM` instead of a rebind. - **The approver has to confirm what they are binding.** The page shows the address and the signed-in account, with a way to switch, and a checkbox naming the address gates the approve button. The name the server reports is deliberately not shown: the requester picks it on an unauthenticated endpoint, and its honest value is the hostname already in the address. The session rides the URL fragment, so it stays out of access logs and `Referer`, and is stripped before anything awaits. The claim is row-locked, so one approval mints once. A request lives 30 minutes; a settled one is not offered again, since approving it fails server-side. Signing in mid-flow no longer loses the request. The id is kept on the SaaS origin and resumed after any sign-in, which is what makes creating an account work: the confirmation email opens a new tab, where the `next` parameter is gone. Reading it does not consume it — the request may be open in two tabs — and only a recorded decision retires it. The result lands as a modal over the portal the admin started from, and the portal re-reads its link status so the page behind agrees with the modal. Plaintext `http://` callbacks are accepted rather than refused, because many self-hosted instances legitimately run plain HTTP on a private network; the address carries a warning icon explaining the risk, derived server-side so a requester cannot suppress it. Hard-refusing `http://` to a public IP literal is a reasonable follow-up; a bare hostname can't be classified without a DNS lookup, so the warning stays the general mechanism. ## Configuration Four surfaces. Placeholders below, not values. **SaaS backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | The connect controller and service are `@ConditionalOnProperty` with no default, so without it the endpoints do not exist. | | `system.frontendUrl` | Only when the approval page is not on the API's own origin | Where the approver is sent. Must include the app's base path if it is served under one, or the redirect misses `/link`. | **SaaS frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Its own sign-in. Must be the project the SaaS backend validates tokens against. | | `RUN_SUBPATH` | Only if served under a subpath | Moves the approval page to `<base>/<subpath>/link`, so `system.frontendUrl` has to agree. | **Self-hosted backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | Defaults to `false`. | | `stirling.billing.account-link.saas-base-url` | Yes | Origin of the SaaS API it links to. Not the SaaS frontend. | | `system.frontendUrl` | Optional | Externally reachable base URL for the callback. Otherwise derived from the request's `Origin`, which is right for ordinary deployments and wrong behind a rewriting proxy. | **Self-hosted frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Accepts the session handed over in the callback fragment. | | `VITE_SAAS_API_URL` | For Usage and Billing | Attended reads go to the SaaS API with the admin's token. Absent, those surfaces stay on the mock. | | `VITE_INCLUDE_PORTAL` | Production builds | Dev builds include the portal automatically; without it there is no link UI and no callback route. | Two things worth stating because neither fails loudly: - **Both frontends must use the URL *and* key of the same Supabase project**, and the same one the SaaS backend validates against. A key from one project with a URL from another is accepted by the browser and rejected by Supabase, which surfaces much later as "session expired" on Usage rather than as an error at hand-over. - **The Supabase redirect allow-list must contain the SaaS app's `/auth/callback`**, since a confirmation email returns through it. Entries are matched exactly. - **`system.frontendUrl` is the existing setting for this**, not a new one, so each side reads its own value and there is nothing extra to configure. It also gates share links, so on a stack with storage and sharing already on, setting it here turns those on too. The self-hosted side deliberately does **not** configure where the approval page lives — SaaS answers that in the connect-request reply, being the only party that knows. Also here, because testing this needs two stacks side by side: `linked:staging` / `linked:dev` (which derive `system.frontendUrl` and `RUN_SUBPATH` themselves), the missing `frontend:staging:saas`, and a per-mode vite `cacheDir` — two dev servers in different modes otherwise re-optimise over one shared dep cache. ## How to test Automated and green: `task frontend:check:all` plus both backend modules. `ConnectRequestServiceTest` covers callback validation, the per-IP cap, single-use approval, claim outcomes, expiry, `WRONG_TEAM` and reauth confirming without minting; `ConnectServiceTest` covers callback-resolution precedence including a foreign-origin callback being discarded; `ConnectControllerTest` covers the authorize URL, including the forwarded-header path and only the first hop being trusted; `ConnectCallback.test.tsx` covers the fragment being stripped synchronously and malformed fragments refused; `LinkAccountModal.test.tsx` covers link and reauth hitting different endpoints. Manual walkthrough: 1. `task linked:staging` — added here; brings up a SaaS stack and a self-hosted instance pointed at it, on discovered ports, and prints the four addresses. 2. Open the link-account modal in the self-hosted portal and continue. Expect the SaaS approval page at `/link?request=<id>`. 3. Sign in as a team leader, or create an account and confirm the email. Either way you should come back to the approval page. 4. Tick the acknowledgement and approve. Expect the fragment gone from the address bar immediately, a result modal over the portal, the portal showing linked without a reload, and attended reads (Usage, Billing) working without a second sign-in. 5. Repeat, approving as a member of a different team. Expect a refusal, not a rebind. ## Outstanding - #7415 to be reworked against this design once this lands. - **No SaaS-side UI to disconnect a server.** `GET /account-link/instances` and `POST /account-link/instances/{id}/revoke` are already team-scoped and leader-gated, and the portal has a panel that uses them, but `portal-saas/components/settings/accountLinkSettings.tsx` exports `null` on the reasoning that "SaaS has no account-link concept". That held when linking was a self-hosted admin managing their own instance; here a leader approves a server they may not administer, and has no way to withdraw it. The seam to fill is that one file. Expected to land with the CTA work in #7415. --------- Co-authored-by: James Brunton <jbrunton96@gmail.com>
302 lines
12 KiB
YAML
302 lines
12 KiB
YAML
version: '3'
|
|
|
|
# Gradle invocation strategy:
|
|
# - Linux/macOS: `./gradlew` runs the POSIX shell wrapper natively.
|
|
# - Windows: `cmd /c ".\gradlew.bat ..."` invokes the .bat wrapper through
|
|
# cmd.exe so it inherits the user's Windows-side `JAVA_HOME`
|
|
# and PATH. Routing through `bash gradlew` on Windows ends up
|
|
# under WSL or Git-Bash, neither of which inherits
|
|
# Adoptium/Temurin's default Windows-only Java env - gradlew
|
|
# then errors with "JAVA_HOME is not set and no 'java' command
|
|
# could be found".
|
|
#
|
|
# The entire `.\gradlew.bat ...` payload is double-quoted so
|
|
# the leading `.\` survives mvdan/sh's POSIX backslash
|
|
# stripping; cmd.exe also requires `.\` (not bare `gradlew.bat`)
|
|
# because modern Windows excludes cwd from cmd's search path.
|
|
|
|
tasks:
|
|
dev:
|
|
desc: "Start backend dev server"
|
|
cmds:
|
|
- task: dev:proprietary
|
|
vars:
|
|
PORT: '{{.PORT}}'
|
|
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
|
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
|
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
|
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
|
|
|
|
dev:proprietary:
|
|
desc: "Start backend dev server in proprietary mode"
|
|
# `dotenv:` reads from the root Taskfile's directory (".") because this
|
|
# subtaskfile is included with `dir: .`. Local overrides in
|
|
# .env.proprietary.local win over the committed .env.proprietary defaults.
|
|
dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
|
|
ignore_error: true
|
|
vars:
|
|
PORT: '{{.PORT | default "8080"}}'
|
|
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
|
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
|
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
|
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
|
# Set by dev:linked. Inline rather than in `env:` so an empty value emits nothing
|
|
# and cannot blank the committed default.
|
|
ACCOUNT_LINK_SAAS_BASE_URL: '{{.ACCOUNT_LINK_SAAS_BASE_URL | default ""}}'
|
|
env:
|
|
SERVER_PORT: '{{.PORT}}'
|
|
cmds:
|
|
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
|
platforms: [windows]
|
|
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}{{if .ACCOUNT_LINK_SAAS_BASE_URL}}STIRLING_BILLING_ACCOUNT_LINK_ENABLED=true STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL={{.ACCOUNT_LINK_SAAS_BASE_URL}} {{end}}./gradlew :stirling-pdf:bootRun'
|
|
platforms: [linux, darwin]
|
|
|
|
dev:bundled:
|
|
desc: "Clean + bootRun with frontend bundled into the backend (single :8080 server)"
|
|
ignore_error: true
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat clean bootRun -PbuildWithFrontend=true"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew clean bootRun -PbuildWithFrontend=true
|
|
platforms: [linux, darwin]
|
|
|
|
# SaaS backend. dev:saas -> the PR's preview branch, staging:saas -> shared v3,
|
|
# PROFILES=none -> production against your own SAAS_DB_*. Production has no named
|
|
# task on purpose. Use `none`, not an empty value: Go template `default` treats ""
|
|
# as absent and would resolve back to dev.
|
|
|
|
dev:saas:
|
|
desc: "Start SaaS backend against the current PR's Supabase preview branch"
|
|
dotenv: ['app/.env.saas.local', 'app/.env.saas']
|
|
vars:
|
|
PROFILES: '{{.PROFILES | default "dev"}}'
|
|
cmds:
|
|
# Don't move this check into a `sh:` var: dotenv is visible in cmds but not
|
|
# during var evaluation, so the test would always see an empty value.
|
|
- cmd: |
|
|
if [ "{{.PROFILES}}" = "dev" ] && [ -z "${SAAS_DEV_PROJECT_REF:-}" ]; then
|
|
echo ">> SAAS_DEV_PROJECT_REF is not set."
|
|
echo ">> Testing a SaaS PR? Put its ref, DB password and publishable key in app/.env.saas.local."
|
|
echo ">> Wanted the shared v3 project? Use 'task backend:staging:saas' instead."
|
|
exit 1
|
|
fi
|
|
- task: _run:saas
|
|
vars:
|
|
PORT: '{{.PORT}}'
|
|
PROFILES: '{{.PROFILES}}'
|
|
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
|
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
|
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
|
APP_BASE_URL: '{{.APP_BASE_URL}}'
|
|
BASE_PATH: '{{.BASE_PATH}}'
|
|
|
|
staging:saas:
|
|
desc: "Start SaaS backend against the shared v3 staging project"
|
|
cmds:
|
|
- task: _run:saas
|
|
vars:
|
|
PORT: '{{.PORT}}'
|
|
PROFILES: staging
|
|
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
|
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
|
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
|
APP_BASE_URL: '{{.APP_BASE_URL}}'
|
|
BASE_PATH: '{{.BASE_PATH}}'
|
|
|
|
dev:linked:
|
|
desc: "Self-hosted backend linked to a locally running SaaS backend (see task linked:*)"
|
|
ignore_error: true
|
|
vars:
|
|
PORT: '{{.PORT | default "8080"}}'
|
|
SAAS_BASE_URL: '{{.SAAS_BASE_URL | default "http://localhost:8081"}}'
|
|
cmds:
|
|
- 'echo ">> self-hosted :{{.PORT}} linking to SaaS at {{.SAAS_BASE_URL}}"'
|
|
# The two backends run different STIRLING_FLAVOURs, which are different Gradle
|
|
# project graphs sharing one build/ tree. Waiting avoids overlapping builds; it
|
|
# does not make the sharing safe, so avoid rebuilding one while the other runs.
|
|
- cmd: |
|
|
n=0
|
|
while [ "$n" -lt 150 ]; do
|
|
if curl -s -m 2 "{{.SAAS_BASE_URL}}" >/dev/null 2>&1; then
|
|
echo ">> SaaS backend is up, starting self-hosted"
|
|
break
|
|
fi
|
|
n=$((n + 1))
|
|
{{if eq OS "windows"}}powershell -NoProfile -Command "Start-Sleep -Seconds 2"{{else}}sleep 2{{end}}
|
|
done
|
|
if [ "$n" -ge 150 ]; then
|
|
echo ">> SaaS backend never answered; starting anyway"
|
|
fi
|
|
- task: dev:proprietary
|
|
vars:
|
|
PORT: '{{.PORT}}'
|
|
ACCOUNT_LINK_SAAS_BASE_URL: '{{.SAAS_BASE_URL}}'
|
|
|
|
_run:saas:
|
|
internal: true
|
|
# The frontend files are here only for RUN_SUBPATH, which the authorize URL needs.
|
|
# Last, because dotenv is set-if-absent: app/* still decides everything else.
|
|
dotenv:
|
|
- 'app/.env.saas.local'
|
|
- 'app/.env.saas'
|
|
- 'frontend/editor/.env.saas.local'
|
|
- 'frontend/editor/.env.saas'
|
|
ignore_error: true
|
|
vars:
|
|
PORT: '{{.PORT | default "8080"}}'
|
|
PROFILES: '{{.PROFILES | default "dev"}}'
|
|
# Built here rather than inline in the cmds below: the Windows line is an
|
|
# unquoted YAML scalar wrapping a cmd.exe string, so a nested {{if ne .X
|
|
# "none"}} needs escaped quotes that reach the Go template as literal
|
|
# backslashes and fail with `unexpected "\" in operand`.
|
|
PROFILE_ARGS: '{{if ne .PROFILES "none"}}--spring.profiles.include={{.PROFILES}}{{end}}'
|
|
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
|
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
|
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
|
# Empty is the same as unset: the property defaults to empty and is blank-checked.
|
|
APP_BASE_URL: '{{.APP_BASE_URL | default ""}}'
|
|
# Relocates configs/pipeline/logs, for a second backend in the same directory.
|
|
# Empty is the same as unset: the reader blank-checks it.
|
|
BASE_PATH: '{{.BASE_PATH | default ""}}'
|
|
env:
|
|
SERVER_PORT: '{{.PORT}}'
|
|
STIRLING_FLAVOR: saas
|
|
STIRLING_BASE_PATH: '{{.BASE_PATH}}'
|
|
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
|
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
|
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
|
# Appends RUN_SUBPATH: the approval page is at <base>/link, so a subpath build
|
|
# serves it at <base>/app/link. An explicit value still wins.
|
|
SYSTEM_FRONTENDURL:
|
|
sh: |
|
|
if [ -n "${SYSTEM_FRONTENDURL:-}" ]; then
|
|
echo "${SYSTEM_FRONTENDURL}"
|
|
elif [ -n "{{.APP_BASE_URL}}" ] && [ -n "${RUN_SUBPATH:-}" ]; then
|
|
echo "{{.APP_BASE_URL}}/${RUN_SUBPATH}"
|
|
else
|
|
echo "{{.APP_BASE_URL}}"
|
|
fi
|
|
cmds:
|
|
# PROFILE_ARGS is empty when PROFILES=none, i.e. the bare `saas` profile
|
|
# against SAAS_DB_* (production).
|
|
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args=\"{{.PROFILE_ARGS}}\"{{end}}"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILE_ARGS}}--args='{{.PROFILE_ARGS}}'{{end}}
|
|
platforms: [linux, darwin]
|
|
|
|
build:
|
|
desc: "Full backend build"
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat clean build"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew clean build
|
|
platforms: [linux, darwin]
|
|
|
|
build:fast:
|
|
desc: "Build without tests"
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat clean build -x test"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew clean build -x test
|
|
platforms: [linux, darwin]
|
|
|
|
build:ci:
|
|
desc: "Build for CI (formatting checked separately)"
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat build -PnoSpotless"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew build -PnoSpotless
|
|
platforms: [linux, darwin]
|
|
|
|
test:
|
|
desc: "Run backend tests"
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat test"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew test
|
|
platforms: [linux, darwin]
|
|
|
|
test:force:
|
|
desc: "Run backend tests, ignoring cached results"
|
|
aliases: [test:no-cache]
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat cleanTest test --no-build-cache"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew cleanTest test --no-build-cache
|
|
platforms: [linux, darwin]
|
|
|
|
format:
|
|
desc: "Auto-fix code formatting"
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat spotlessApply"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew spotlessApply
|
|
platforms: [linux, darwin]
|
|
|
|
format:check:
|
|
desc: "Check code formatting"
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat spotlessCheck"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew spotlessCheck
|
|
platforms: [linux, darwin]
|
|
|
|
fix:
|
|
desc: "Auto-fix backend"
|
|
cmds:
|
|
- task: format
|
|
|
|
swagger:
|
|
desc: "Generate OpenAPI docs"
|
|
run: once
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:copySwaggerDoc"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew :stirling-pdf:copySwaggerDoc
|
|
platforms: [linux, darwin]
|
|
sources:
|
|
- app/core/src/main/java/**/*.java
|
|
- app/proprietary/src/main/java/**/*.java
|
|
- app/common/src/main/java/**/*.java
|
|
generates:
|
|
- SwaggerDoc.json
|
|
|
|
check:
|
|
desc: "Backend quality gate"
|
|
cmds:
|
|
- task: format:check
|
|
- task: test
|
|
|
|
version:
|
|
desc: "Print project version"
|
|
silent: true
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat printVersion --quiet" | tail -1
|
|
platforms: [windows]
|
|
- cmd: ./gradlew printVersion --quiet | tail -1
|
|
platforms: [linux, darwin]
|
|
|
|
licenses:check:
|
|
desc: "Check dependency licenses"
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat checkLicense --no-parallel"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew checkLicense --no-parallel
|
|
platforms: [linux, darwin]
|
|
|
|
licenses:generate:
|
|
desc: "Check and generate dependency license report"
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat checkLicense generateLicenseReport --no-parallel"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew checkLicense generateLicenseReport --no-parallel
|
|
platforms: [linux, darwin]
|
|
|
|
clean:
|
|
desc: "Clean build artifacts"
|
|
cmds:
|
|
- cmd: cmd /c ".\gradlew.bat clean"
|
|
platforms: [windows]
|
|
- cmd: ./gradlew clean
|
|
platforms: [linux, darwin]
|