diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000..b751976 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,192 @@ +name: Desktop Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Existing release tag" + required: true + default: "v1.0.0" + +permissions: + contents: write + +env: + VERSION: ${{ inputs.version || github.ref_name }} + +jobs: + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - platform: macos + os: macos-latest + script: package:mac + artifact: desktop-macos + - platform: windows + os: windows-latest + script: package:win + artifact: desktop-windows + - platform: linux + os: ubuntu-latest + script: package:linux + artifact: desktop-linux + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ env.VERSION }} + + - uses: actions/setup-node@v5 + with: + node-version: 20.19.0 + cache: npm + + - name: Install Linux packaging dependencies + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y libfuse2 + + - run: npm ci + + - name: Write Apple notarization API key + if: matrix.platform == 'macos' + shell: bash + env: + CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + if [ -z "${CSC_LINK:-}" ] || + [ -z "${CSC_KEY_PASSWORD:-}" ] || + [ -z "${APPLE_API_KEY_BASE64:-}" ] || + [ -z "${APPLE_API_KEY_ID:-}" ] || + [ -z "${APPLE_API_ISSUER:-}" ]; then + echo "Skipping macOS notarization setup because signing secrets are incomplete." + exit 0 + fi + + api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf "%s" "$APPLE_API_KEY_BASE64" | base64 --decode > "$api_key_path" + chmod 600 "$api_key_path" + echo "APPLE_API_KEY=$api_key_path" >> "$GITHUB_ENV" + echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID" >> "$GITHUB_ENV" + echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" >> "$GITHUB_ENV" + + - name: Build desktop artifacts + env: + CSC_LINK: ${{ matrix.platform == 'macos' && secrets.MAC_CSC_LINK || '' }} + CSC_KEY_PASSWORD: ${{ matrix.platform == 'macos' && secrets.MAC_CSC_KEY_PASSWORD || '' }} + WIN_CSC_LINK: ${{ matrix.platform == 'windows' && secrets.WIN_CSC_LINK || '' }} + WIN_CSC_KEY_PASSWORD: ${{ matrix.platform == 'windows' && secrets.WIN_CSC_KEY_PASSWORD || '' }} + run: npm run ${{ matrix.script }} --workspace @specdock/desktop + + - name: Verify macOS signature + if: matrix.platform == 'macos' + shell: bash + env: + CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + run: | + if [ -z "${CSC_LINK:-}" ]; then + echo "Skipping macOS signature verification because MAC_CSC_LINK is not configured." + exit 0 + fi + app_path="$(find apps/desktop/release/desktop -name "SpecDock.app" -type d | head -n 1)" + test -n "$app_path" + codesign --verify --deep --strict --verbose=2 "$app_path" + spctl --assess --type execute --verbose "$app_path" + + - name: Verify Windows signatures + if: matrix.platform == 'windows' + shell: pwsh + env: + WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} + run: | + if (-not $env:WIN_CSC_LINK) { + Write-Host "Skipping Windows signature verification because WIN_CSC_LINK is not configured." + exit 0 + } + Get-ChildItem apps/desktop/release/desktop -Recurse -File -Filter *.exe | + ForEach-Object { + $signature = Get-AuthenticodeSignature $_.FullName + if ($signature.Status -ne "Valid") { + throw "Invalid signature for $($_.FullName): $($signature.Status)" + } + } + + - name: Collect desktop artifacts + if: matrix.platform != 'windows' + shell: bash + run: | + mkdir -p release-upload + find apps/desktop/release/desktop -maxdepth 1 -type f \( \ + -name "*.dmg" -o \ + -name "*.zip" -o \ + -name "*.AppImage" -o \ + -name "*.tar.gz" -o \ + -name "*.blockmap" -o \ + -name "latest*.yml" \ + \) -exec cp {} release-upload/ \; + + - name: Collect desktop artifacts + if: matrix.platform == 'windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force release-upload + Get-ChildItem apps/desktop/release/desktop -File | + Where-Object { + $_.Extension -in ".exe", ".zip", ".blockmap", ".yml" -or + $_.Name -like "latest*.yml" + } | + Copy-Item -Destination release-upload + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: release-upload/* + if-no-files-found: error + + publish: + name: Publish GitHub release + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ env.VERSION }} + + - uses: actions/setup-node@v5 + with: + node-version: 20.19.0 + cache: npm + + - uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + + - name: Generate checksums + run: npm run release:checksums -- release-assets + + - name: Create or update release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$VERSION" >/dev/null 2>&1; then + gh release upload "$VERSION" release-assets/* --clobber + else + gh release create "$VERSION" \ + --verify-tag \ + --title "SpecDock $VERSION" \ + --notes-file "docs/release-notes/${VERSION}.md" \ + release-assets/* + fi diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a82ab66..df52757 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -9,7 +9,7 @@ on: version: description: "Docker image version tag" required: true - default: "v0.2.3" + default: "v1.0.0" env: DOCKERHUB_IMAGE: docker.io/d8vik/specdock diff --git a/.gitignore b/.gitignore index 05283f4..c341bad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules dist build +!apps/desktop/build/ +!apps/desktop/build/** coverage .history .env @@ -15,3 +17,6 @@ coverage docs_deprecated docs/BOOTSTRAP_REPOSITORY.md docs/TASKS.md +apps/desktop/release +apps/api/src/**/*.js +apps/api/src/**/*.js.map diff --git a/Dockerfile b/Dockerfile index 67f58c3..f3e6d73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,11 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules COPY . . -RUN npm run build +RUN npm run build --workspace @specdock/core \ + && npm run build --workspace @specdock/generator \ + && npm run build --workspace @specdock/ui \ + && npm run build --workspace @specdock/api \ + && npm run build --workspace @specdock/web FROM node:20.19-alpine AS runner WORKDIR /app diff --git a/README.md b/README.md index 899fdd6..e49a719 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ docker run -d --name specdock \ -p 127.0.0.1:3000:3000 \ -e PUBLIC_DEMO=true \ -e PROXY_ENABLED=false \ - docker.io/d8vik/specdock:v0.5.0 + docker.io/d8vik/specdock:v1.0.0 ``` Or keep configuration in a local env file: @@ -96,7 +96,7 @@ MOCK_SERVER_ENABLED=false docker run -d --name specdock \ -p 127.0.0.1:3000:3000 \ --env-file ./specdock.env \ - docker.io/d8vik/specdock:v0.5.0 + docker.io/d8vik/specdock:v1.0.0 ``` If you prefer Compose with the published image, create your own @@ -105,7 +105,7 @@ If you prefer Compose with the published image, create your own ```yaml services: specdock: - image: docker.io/d8vik/specdock:v0.5.0 + image: docker.io/d8vik/specdock:v1.0.0 ports: - "127.0.0.1:3000:3000" environment: @@ -125,7 +125,7 @@ Check health: curl -fsS http://127.0.0.1:3000/api/health ``` -Use immutable version tags such as `docker.io/d8vik/specdock:v0.5.0`. +Use immutable version tags such as `docker.io/d8vik/specdock:v1.0.0`. The project does not rely on `latest` for releases. ## Configuration diff --git a/apps/api/package.json b/apps/api/package.json index bb9d587..dcb10e8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@specdock/api", - "version": "0.5.0", + "version": "1.0.0", "private": true, "type": "module", "scripts": { @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@specdock/core": "0.5.0", - "@specdock/generator": "0.5.0", + "@specdock/core": "1.0.0", + "@specdock/generator": "1.0.0", "fastify": "5.8.5", "tsx": "4.22.4" }, diff --git a/apps/api/src/errors.ts b/apps/api/src/errors.ts index 5d6afe1..3350a57 100644 --- a/apps/api/src/errors.ts +++ b/apps/api/src/errors.ts @@ -40,6 +40,10 @@ export const generationErrorCode = (error: unknown): string => { return "GENERATION_TOO_COMPLEX"; } + if (error.message.includes("Generated file path")) { + return "VALIDATION_ERROR"; + } + if (error.message.includes("Specification")) { return "INVALID_SPEC"; } diff --git a/apps/api/src/generate-routes.test.ts b/apps/api/src/generate-routes.test.ts index ef22a9b..0c7fd00 100644 --- a/apps/api/src/generate-routes.test.ts +++ b/apps/api/src/generate-routes.test.ts @@ -44,7 +44,14 @@ describe("generate routes", () => { expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ files: [{ path: "generated/client.ts" }], - meta: { fileCount: 1 } + meta: { + fileCount: 1, + outputPlan: { + outputRoot: "generated", + fileCount: 1, + files: [{ path: "generated/client.ts", relativePath: "client.ts" }] + } + } }); }); @@ -138,6 +145,16 @@ describe("generate routes", () => { expect(errorCode(response)).toBe("GENERATED_OUTPUT_TOO_LARGE"); }); + it("rejects generated files that escape the output root", async () => { + const response = await injectGenerate(async () => ({ + kind: "files", + files: [{ path: "generated/../secret.ts", content: "" }] + })); + + expect(response.statusCode).toBe(400); + expect(errorCode(response)).toBe("VALIDATION_ERROR"); + }); + it("maps child stdout size errors", async () => { const response = await injectGenerate(async () => { throw new GeneratedOutputTooLargeError(); diff --git a/apps/api/src/generate-routes.ts b/apps/api/src/generate-routes.ts index 002308d..b2f732f 100644 --- a/apps/api/src/generate-routes.ts +++ b/apps/api/src/generate-routes.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { LIMITS, type GenerateRequest } from "@specdock/core"; -import { GENERATOR_VERSION } from "@specdock/generator"; +import { createGeneratedOutputPlan, GENERATOR_VERSION } from "@specdock/generator"; import { generationErrorCode, sendError } from "./errors.js"; import { resolveGenerateOptions } from "./generation.js"; import { @@ -59,7 +59,8 @@ export const registerGenerateRoutes = ( meta: { fileCount: result.files.length, generatedAt: new Date().toISOString(), - generatorVersion: GENERATOR_VERSION + generatorVersion: GENERATOR_VERSION, + outputPlan: createGeneratedOutputPlan(result.files, resolvedOptions) } }; } catch (error) { diff --git a/apps/desktop/build/entitlements.mac.inherit.plist b/apps/desktop/build/entitlements.mac.inherit.plist new file mode 100644 index 0000000..aa882e0 --- /dev/null +++ b/apps/desktop/build/entitlements.mac.inherit.plist @@ -0,0 +1,13 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist new file mode 100644 index 0000000..aa882e0 --- /dev/null +++ b/apps/desktop/build/entitlements.mac.plist @@ -0,0 +1,13 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/apps/desktop/build/icon.icns b/apps/desktop/build/icon.icns new file mode 100644 index 0000000..f3478dd Binary files /dev/null and b/apps/desktop/build/icon.icns differ diff --git a/apps/desktop/build/icon.ico b/apps/desktop/build/icon.ico new file mode 100644 index 0000000..bb29b45 Binary files /dev/null and b/apps/desktop/build/icon.ico differ diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png new file mode 100644 index 0000000..c0aeba7 Binary files /dev/null and b/apps/desktop/build/icon.png differ diff --git a/apps/desktop/build/icon.svg b/apps/desktop/build/icon.svg new file mode 100644 index 0000000..fef3139 --- /dev/null +++ b/apps/desktop/build/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/desktop/build/icons/128x128.png b/apps/desktop/build/icons/128x128.png new file mode 100644 index 0000000..6d7eae9 Binary files /dev/null and b/apps/desktop/build/icons/128x128.png differ diff --git a/apps/desktop/build/icons/16x16.png b/apps/desktop/build/icons/16x16.png new file mode 100644 index 0000000..343067c Binary files /dev/null and b/apps/desktop/build/icons/16x16.png differ diff --git a/apps/desktop/build/icons/256x256.png b/apps/desktop/build/icons/256x256.png new file mode 100644 index 0000000..ae81a02 Binary files /dev/null and b/apps/desktop/build/icons/256x256.png differ diff --git a/apps/desktop/build/icons/32x32.png b/apps/desktop/build/icons/32x32.png new file mode 100644 index 0000000..2028b63 Binary files /dev/null and b/apps/desktop/build/icons/32x32.png differ diff --git a/apps/desktop/build/icons/512x512.png b/apps/desktop/build/icons/512x512.png new file mode 100644 index 0000000..5824cf7 Binary files /dev/null and b/apps/desktop/build/icons/512x512.png differ diff --git a/apps/desktop/build/icons/64x64.png b/apps/desktop/build/icons/64x64.png new file mode 100644 index 0000000..7f269b8 Binary files /dev/null and b/apps/desktop/build/icons/64x64.png differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..56fdb6d --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,79 @@ +{ + "name": "@specdock/desktop", + "version": "1.0.0", + "description": "SpecDock Electron desktop workspace beta.", + "private": true, + "author": "SpecDock contributors", + "license": "MIT", + "type": "module", + "main": "dist/main/main.js", + "scripts": { + "build": "tsc --noEmit -p tsconfig.json && node scripts/build.mjs", + "dev": "npm run build && node scripts/run.mjs", + "lint": "eslint .", + "package:dir": "node scripts/package.mjs --dir", + "package:linux": "node scripts/package.mjs --linux", + "package:mac": "node scripts/package.mjs --mac", + "package:win": "node scripts/package.mjs --win", + "test": "vitest run --passWithNoTests --exclude='dist/**'", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@specdock/api": "1.0.0", + "@specdock/core": "1.0.0", + "@specdock/generator": "1.0.0" + }, + "devDependencies": { + "electron": "41.7.1", + "electron-builder": "26.15.3", + "esbuild": "0.27.2", + "typescript": "5.9.3", + "vitest": "4.1.9" + }, + "build": { + "appId": "dev.specdock.app", + "productName": "SpecDock", + "icon": "build/icon.png", + "directories": { + "output": "release/desktop" + }, + "files": [ + "dist/**/*", + "package.json" + ], + "asar": true, + "mac": { + "category": "public.app-category.developer-tools", + "icon": "build/icon.icns", + "hardenedRuntime": true, + "entitlements": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.inherit.plist", + "notarize": true, + "target": [ + "dmg", + "zip" + ] + }, + "win": { + "icon": "build/icon.ico", + "signtoolOptions": { + "signingHashAlgorithms": [ + "sha256" + ], + "rfc3161TimeStampServer": "http://timestamp.digicert.com" + }, + "target": [ + "nsis", + "zip" + ] + }, + "linux": { + "category": "Development", + "icon": "build/icons", + "target": [ + "AppImage", + "tar.gz" + ] + } + } +} diff --git a/apps/desktop/scripts/build.mjs b/apps/desktop/scripts/build.mjs new file mode 100644 index 0000000..125991c --- /dev/null +++ b/apps/desktop/scripts/build.mjs @@ -0,0 +1,43 @@ +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const desktopRoot = resolve(scriptDir, ".."); +const repoRoot = resolve(desktopRoot, "..", ".."); +const distDir = join(desktopRoot, "dist"); +const webDistDir = join(repoRoot, "apps", "web", "dist"); + +rmSync(distDir, { force: true, recursive: true }); +mkdirSync(distDir, { recursive: true }); + +const sharedOptions = { + bundle: true, + external: ["electron"], + logLevel: "warning", + platform: "node", + sourcemap: true, + target: "node20.19" +}; + +await build({ + ...sharedOptions, + banner: { + js: 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);' + }, + entryPoints: [join(desktopRoot, "src", "main", "main.ts")], + format: "esm", + outfile: join(distDir, "main", "main.js") +}); + +await build({ + ...sharedOptions, + entryPoints: [join(desktopRoot, "src", "preload", "preload.ts")], + format: "cjs", + outfile: join(distDir, "preload", "preload.cjs") +}); + +if (existsSync(webDistDir)) { + cpSync(webDistDir, join(distDir, "web"), { recursive: true }); +} diff --git a/apps/desktop/scripts/package.mjs b/apps/desktop/scripts/package.mjs new file mode 100644 index 0000000..fd261d4 --- /dev/null +++ b/apps/desktop/scripts/package.mjs @@ -0,0 +1,41 @@ +import { spawn } from "node:child_process"; + +const target = process.argv[2] ?? "--dir"; +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const builderArgs = target === "--dir" ? ["electron-builder", "--dir"] : ["electron-builder", target]; +const hasSigningConfig = + process.env.CSC_LINK || + process.env.CSC_NAME || + process.env.WIN_CSC_LINK; +const builderEnv = hasSigningConfig ? {} : { CSC_IDENTITY_AUTO_DISCOVERY: "false" }; + +await run(npmCommand, ["run", "build", "--workspace", "@specdock/web"]); +await run(npmCommand, ["run", "build"]); +await run(npmCommand, ["exec", "--", ...builderArgs], builderEnv); + +function run(command, args, extraEnv = {}) { + return new Promise((resolve, reject) => { + const env = { + ...process.env, + ...extraEnv + }; + + delete env.ELECTRON_RUN_AS_NODE; + + const child = spawn(command, args, { + env, + shell: false, + stdio: "inherit" + }); + + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`)); + }); + }); +} diff --git a/apps/desktop/scripts/run.mjs b/apps/desktop/scripts/run.mjs new file mode 100644 index 0000000..e1e8ebf --- /dev/null +++ b/apps/desktop/scripts/run.mjs @@ -0,0 +1,27 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const electronPath = require("electron"); +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const desktopRoot = resolve(scriptDir, ".."); +const env = { ...process.env }; + +delete env.ELECTRON_RUN_AS_NODE; + +const child = spawn(electronPath, ["."], { + cwd: desktopRoot, + env, + stdio: "inherit" +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 0); +}); diff --git a/apps/desktop/src/main/backend.test.ts b/apps/desktop/src/main/backend.test.ts new file mode 100644 index 0000000..8cbde32 --- /dev/null +++ b/apps/desktop/src/main/backend.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + createDesktopApiEnv, + DESKTOP_API_HOST, + formatDesktopApiBaseUrl +} from "./backend.js"; + +describe("desktop backend", () => { + it("forces the embedded local API to use loopback with proxy features disabled", () => { + const env = createDesktopApiEnv( + { + APP_IP: "0.0.0.0", + PROXY_ENABLED: "true", + MOCK_SERVER_ENABLED: "true", + TRUST_PROXY: "1" + }, + 43125, + "/tmp/specdock-web-dist" + ); + + expect(env.APP_IP).toBe(DESKTOP_API_HOST); + expect(env.HOST).toBe(DESKTOP_API_HOST); + expect(env.APP_PORT).toBe("43125"); + expect(env.PORT).toBe("43125"); + expect(env.PROXY_ENABLED).toBe("false"); + expect(env.MOCK_SERVER_ENABLED).toBe("false"); + expect(env.TRUST_PROXY).toBe("false"); + expect(env.WEB_DIST_DIR).toBe("/tmp/specdock-web-dist"); + }); + + it("formats a loopback-only API base URL", () => { + expect(formatDesktopApiBaseUrl(43126)).toBe("http://127.0.0.1:43126"); + }); +}); diff --git a/apps/desktop/src/main/backend.ts b/apps/desktop/src/main/backend.ts new file mode 100644 index 0000000..48794a9 --- /dev/null +++ b/apps/desktop/src/main/backend.ts @@ -0,0 +1,148 @@ +import { createServer } from "node:net"; +import type { FastifyInstance } from "fastify"; +import { buildApp } from "../../../api/src/app.js"; +import { + LIMITS, + type GeneratedFile +} from "@specdock/core"; +import { + generateSdk, + generateSdkZip +} from "@specdock/generator"; +import type { + GenerationJob, + GenerationResult, + GenerationRunner +} from "../../../api/src/generation-runner.js"; + +export const DESKTOP_API_HOST = "127.0.0.1"; + +export type DesktopApiProcess = { + baseUrl: string; + stop: () => void; +}; + +export type StartDesktopApiOptions = { + env?: NodeJS.ProcessEnv; + port?: number; + webDistDir: string; +}; + +export function formatDesktopApiBaseUrl(port: number): string { + return `http://${DESKTOP_API_HOST}:${port}`; +} + +export function createDesktopApiEnv( + baseEnv: NodeJS.ProcessEnv, + port: number, + webDistDir: string +): NodeJS.ProcessEnv { + return { + ...baseEnv, + APP_IP: DESKTOP_API_HOST, + HOST: DESKTOP_API_HOST, + APP_PORT: String(port), + PORT: String(port), + PROXY_ENABLED: "false", + MOCK_SERVER_ENABLED: "false", + TRUST_PROXY: "false", + WEB_DIST_DIR: webDistDir + }; +} + +export async function findFreeLoopbackPort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer(); + + server.once("error", reject); + server.listen(0, DESKTOP_API_HOST, () => { + const address = server.address(); + + server.close(() => { + if (typeof address === "object" && address) { + resolve(address.port); + return; + } + + reject(new Error("Unable to allocate desktop API port.")); + }); + }); + }); +} + +export async function startDesktopApi( + options: StartDesktopApiOptions +): Promise { + const port = options.port ?? (await findFreeLoopbackPort()); + const baseUrl = formatDesktopApiBaseUrl(port); + const env = createDesktopApiEnv( + options.env ?? process.env, + port, + options.webDistDir + ); + applyDesktopApiEnv(env); + + const server = buildApp({ + generationRunner: runDesktopGenerationJob, + logger: false, + webDistDir: options.webDistDir + }); + + await server.listen({ port, host: DESKTOP_API_HOST }); + + return { + baseUrl, + stop: () => stopServer(server) + }; +} + +function applyDesktopApiEnv(env: NodeJS.ProcessEnv): void { + process.env.APP_IP = env.APP_IP; + process.env.HOST = env.HOST; + process.env.APP_PORT = env.APP_PORT; + process.env.PORT = env.PORT; + process.env.PROXY_ENABLED = env.PROXY_ENABLED; + process.env.MOCK_SERVER_ENABLED = env.MOCK_SERVER_ENABLED; + process.env.TRUST_PROXY = env.TRUST_PROXY; + process.env.WEB_DIST_DIR = env.WEB_DIST_DIR; +} + +function stopServer(server: FastifyInstance): void { + void server.close(); +} + +const textEncoder = new TextEncoder(); + +const runDesktopGenerationJob: GenerationRunner = async ( + job: GenerationJob, + signal: AbortSignal +): Promise => { + if (signal.aborted) { + throw new Error("SDK generation was aborted."); + } + + if (job.kind === "zip") { + const archive = await generateSdkZip(job.spec, job.options); + + if (archive.byteLength > LIMITS.maxGeneratedZipBytes) { + throw new Error("Generated SDK ZIP exceeds the configured size limit."); + } + + return { kind: "zip", archive }; + } + + const files = generateSdk(job.spec, job.options); + assertGeneratedFilesSize(files); + return { kind: "files", files }; +}; + +function assertGeneratedFilesSize(files: GeneratedFile[]): void { + const bytes = files.reduce( + (total, file) => total + textEncoder.encode(file.content).byteLength, + 0 + ); + + if (bytes > LIMITS.maxGeneratedBytes) { + throw new Error("Generated SDK output exceeds the configured size limit."); + } +} diff --git a/apps/desktop/src/main/desktop-files.test.ts b/apps/desktop/src/main/desktop-files.test.ts new file mode 100644 index 0000000..a93371c --- /dev/null +++ b/apps/desktop/src/main/desktop-files.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + validateProjectExportContent, + validateProjectExportPath +} from "./desktop-files.js"; + +const validProjectExport = JSON.stringify({ + format: "specdock.project", + version: 2, + redactionPolicyVersion: 1, + exportedAt: "2026-06-21T00:00:00.000Z", + project: { + metadata: { name: "SpecDock Demo" }, + source: { type: "sample" }, + specFormat: "openapi3", + spec: { + openapi: "3.1.0", + info: { title: "SpecDock Demo", version: "1.0.0" }, + paths: {} + } + }, + preferences: { + requestStates: {}, + generateOptions: {} + } +}); + +describe("desktop project files", () => { + it("accepts a valid portable SpecDock project export", () => { + expect(() => validateProjectExportContent(validProjectExport)).not.toThrow(); + }); + + it("rejects malformed project exports before native file writes", () => { + expect(() => + validateProjectExportContent(JSON.stringify({ version: 2 })) + ).toThrow(); + }); + + it("requires JSON project export paths for native file access", () => { + expect(() => + validateProjectExportPath("/tmp/project.specdock.json") + ).not.toThrow(); + expect(() => validateProjectExportPath("/tmp/project.txt")).toThrow(); + }); +}); diff --git a/apps/desktop/src/main/desktop-files.ts b/apps/desktop/src/main/desktop-files.ts new file mode 100644 index 0000000..b66e8cf --- /dev/null +++ b/apps/desktop/src/main/desktop-files.ts @@ -0,0 +1,52 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { extname } from "node:path"; +import { parseProjectExport } from "@specdock/core"; + +const MAX_DESKTOP_PROJECT_BYTES = 5 * 1024 * 1024; + +export type DesktopOpenProjectResult = + | { canceled: true } + | { + canceled: false; + content: string; + filePath: string; + }; + +export type DesktopSaveProjectResult = + | { canceled: true } + | { + canceled: false; + filePath: string; + }; + +export async function readProjectExportFile( + filePath: string +): Promise { + validateProjectExportPath(filePath); + const content = await readFile(filePath, "utf8"); + validateProjectExportContent(content); + return content; +} + +export async function writeProjectExportFile( + filePath: string, + content: string +): Promise { + validateProjectExportPath(filePath); + validateProjectExportContent(content); + await writeFile(filePath, content, "utf8"); +} + +export function validateProjectExportPath(filePath: string): void { + if (extname(filePath).toLowerCase() !== ".json") { + throw new Error("SpecDock project export file must use a .json extension."); + } +} + +export function validateProjectExportContent(content: string): void { + if (Buffer.byteLength(content, "utf8") > MAX_DESKTOP_PROJECT_BYTES) { + throw new Error("SpecDock project export is too large."); + } + + parseProjectExport(content); +} diff --git a/apps/desktop/src/main/electron-main.d.ts b/apps/desktop/src/main/electron-main.d.ts new file mode 100644 index 0000000..4d9ca69 --- /dev/null +++ b/apps/desktop/src/main/electron-main.d.ts @@ -0,0 +1,5 @@ +declare module "electron/main" { + import electron from "electron"; + + export default electron; +} diff --git a/apps/desktop/src/main/ipc-handlers.ts b/apps/desktop/src/main/ipc-handlers.ts new file mode 100644 index 0000000..f5d0003 --- /dev/null +++ b/apps/desktop/src/main/ipc-handlers.ts @@ -0,0 +1,201 @@ +import electron from "electron/main"; +import type { GeneratedFile } from "@specdock/core"; +import { + readProjectExportFile, + writeProjectExportFile, + type DesktopOpenProjectResult, + type DesktopSaveProjectResult +} from "./desktop-files.js"; +import { + readProjectFolder, + writeProjectFolder, + type DesktopProjectFolderResult, + type DesktopSaveProjectFolderResult +} from "./project-folder.js"; +import { + writeSdkOutputDirectory, + type DesktopSdkOutputResult +} from "./sdk-output.js"; + +const { app, dialog, ipcMain } = electron; + +export function registerIpcHandlers(getApiBaseUrl: () => string | undefined): void { + ipcMain.handle("specdock:getInfo", () => ({ + apiBaseUrl: getApiBaseUrl(), + version: app.getVersion() + })); + + ipcMain.handle( + "specdock:openProjectFile", + async (): Promise => { + const result = await dialog.showOpenDialog({ + filters: [{ name: "SpecDock project", extensions: ["json"] }], + properties: ["openFile"] + }); + const [filePath] = result.filePaths; + + if (result.canceled || !filePath) { + return { canceled: true }; + } + + return { + canceled: false, + filePath, + content: await readProjectExportFile(filePath) + }; + } + ); + + ipcMain.handle( + "specdock:saveProjectFile", + async (_event, input: unknown): Promise => { + const content = readString(input, "content", "SpecDock project export content is required."); + const result = await dialog.showSaveDialog({ + defaultPath: readOptionalString(input, "defaultFileName") ?? "specdock-project.specdock.json", + filters: [{ name: "SpecDock project", extensions: ["json"] }] + }); + + if (result.canceled || !result.filePath) { + return { canceled: true }; + } + + await writeProjectExportFile(result.filePath, content); + + return { + canceled: false, + filePath: result.filePath + }; + } + ); + + ipcMain.handle( + "specdock:openProjectFolder", + async (): Promise => { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory"] + }); + const [directoryPath] = result.filePaths; + + if (result.canceled || !directoryPath) { + return { canceled: true }; + } + + return { + canceled: false, + directoryPath, + content: await readProjectFolder(directoryPath) + }; + } + ); + + ipcMain.handle( + "specdock:saveProjectFolder", + async (_event, input: unknown): Promise => { + const content = readString(input, "content", "SpecDock project export content is required."); + const result = await dialog.showOpenDialog({ + properties: ["openDirectory", "createDirectory"] + }); + const [directoryPath] = result.filePaths; + + if (result.canceled || !directoryPath) { + return { canceled: true }; + } + + await writeProjectFolder(directoryPath, content, readBoolean(input, "overwrite")); + + return { + canceled: false, + directoryPath + }; + } + ); + + ipcMain.handle( + "specdock:writeSdkOutput", + async (_event, input: unknown): Promise => { + const files = readGeneratedFiles(input); + const outputRoot = readString(input, "outputRoot"); + const result = await dialog.showOpenDialog({ + properties: ["openDirectory", "createDirectory"] + }); + const [directoryPath] = result.filePaths; + + if (result.canceled || !directoryPath) { + return { canceled: true }; + } + + const summary = await writeSdkOutputDirectory({ + directoryPath, + files, + outputRoot, + overwrite: readBoolean(input, "overwrite") + }); + + return { + canceled: false, + directoryPath, + ...summary + }; + } + ); +} + +function readGeneratedFiles(input: unknown): GeneratedFile[] { + if (typeof input !== "object" || input === null || !("files" in input)) { + throw new Error("Generated SDK files are required."); + } + + if (!Array.isArray(input.files)) { + throw new Error("Generated SDK files must be an array."); + } + + return input.files.map((file) => { + if ( + typeof file !== "object" || + file === null || + !("path" in file) || + !("content" in file) || + typeof file.path !== "string" || + typeof file.content !== "string" + ) { + throw new Error("Generated SDK file entries must include path and content."); + } + + return { + path: file.path, + content: file.content + }; + }); +} + +function readString(input: unknown, key: string, message?: string): string { + const value = readOptionalString(input, key); + + if (value === undefined) { + throw new Error(message ?? `Desktop IPC string field is required: ${key}.`); + } + + return value; +} + +function readOptionalString(input: unknown, key: string): string | undefined { + if ( + typeof input !== "object" || + input === null || + !(key in input) || + typeof input[key as keyof typeof input] !== "string" + ) { + return undefined; + } + + return input[key as keyof typeof input]; +} + +function readBoolean(input: unknown, key: string): boolean { + return ( + typeof input === "object" && + input !== null && + key in input && + input[key as keyof typeof input] === true + ); +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts new file mode 100644 index 0000000..76a493e --- /dev/null +++ b/apps/desktop/src/main/main.ts @@ -0,0 +1,44 @@ +import electron from "electron/main"; +import { registerIpcHandlers } from "./ipc-handlers.js"; +import { resolveDesktopPaths } from "./paths.js"; +import { startDesktopApi, type DesktopApiProcess } from "./backend.js"; +import { createDesktopWindowOptions } from "./window-options.js"; + +const { app, BrowserWindow } = electron; + +let apiProcess: DesktopApiProcess | undefined; + +async function createMainWindow(): Promise { + const paths = resolveDesktopPaths(); + + apiProcess = await startDesktopApi({ + webDistDir: paths.webDistDir + }); + + const window = new BrowserWindow( + createDesktopWindowOptions(paths.preloadPath) + ); + + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event, url) => { + if (!url.startsWith(apiProcess?.baseUrl ?? "")) { + event.preventDefault(); + } + }); + window.once("ready-to-show", () => window.show()); + + await window.loadURL(apiProcess.baseUrl); +} + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); + +app.on("before-quit", () => { + apiProcess?.stop(); +}); + +registerIpcHandlers(() => apiProcess?.baseUrl); +void app.whenReady().then(createMainWindow); diff --git a/apps/desktop/src/main/paths.test.ts b/apps/desktop/src/main/paths.test.ts new file mode 100644 index 0000000..64f21ed --- /dev/null +++ b/apps/desktop/src/main/paths.test.ts @@ -0,0 +1,25 @@ +import { fileURLToPath, pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { resolveDesktopPaths } from "./paths.js"; + +describe("desktop paths", () => { + it("resolves repo, web dist, and preload paths from compiled main output", () => { + const moduleUrl = pathToFileURL( + "/repo/apps/desktop/dist/main/main.js" + ).href; + + expect(resolveDesktopPaths(moduleUrl)).toEqual({ + preloadPath: "/repo/apps/desktop/dist/preload/preload.cjs", + repoRoot: "/repo", + webDistDir: "/repo/apps/desktop/dist/web" + }); + }); + + it("uses a file URL-compatible module path", () => { + const paths = resolveDesktopPaths(import.meta.url); + + expect(fileURLToPath(pathToFileURL(paths.preloadPath))).toBe( + paths.preloadPath + ); + }); +}); diff --git a/apps/desktop/src/main/paths.ts b/apps/desktop/src/main/paths.ts new file mode 100644 index 0000000..f8112d1 --- /dev/null +++ b/apps/desktop/src/main/paths.ts @@ -0,0 +1,22 @@ +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type DesktopPaths = { + preloadPath: string; + repoRoot: string; + webDistDir: string; +}; + +export function resolveDesktopPaths( + moduleUrl = import.meta.url +): DesktopPaths { + const moduleDir = dirname(fileURLToPath(moduleUrl)); + const desktopRoot = resolve(moduleDir, "..", ".."); + const repoRoot = resolve(desktopRoot, "..", ".."); + + return { + preloadPath: join(desktopRoot, "dist", "preload", "preload.cjs"), + repoRoot, + webDistDir: join(desktopRoot, "dist", "web") + }; +} diff --git a/apps/desktop/src/main/project-folder.test.ts b/apps/desktop/src/main/project-folder.test.ts new file mode 100644 index 0000000..f2633d2 --- /dev/null +++ b/apps/desktop/src/main/project-folder.test.ts @@ -0,0 +1,58 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "vitest"; +import { readProjectFolder, writeProjectFolder } from "./project-folder.js"; + +const projectExport = JSON.stringify({ + format: "specdock.project", + version: 2, + exportedAt: "2026-06-21T00:00:00.000Z", + redactionPolicyVersion: 1, + project: { + metadata: { name: "Folder API" }, + source: { type: "sample" }, + specFormat: "openapi3", + spec: { + openapi: "3.1.0", + info: { title: "Folder API", version: "1.0.0" }, + paths: {} + } + }, + preferences: { + requestStates: {}, + generateOptions: {} + } +}); + +describe("desktop project folders", () => { + it("writes and reopens a portable project folder", async () => { + const directoryPath = await mkdtemp(join(tmpdir(), "specdock-folder-")); + + try { + await writeProjectFolder(directoryPath, projectExport); + const reopened = JSON.parse(await readProjectFolder(directoryPath)) as { + project: { metadata: { name: string } }; + }; + + expect(reopened.project.metadata.name).toBe("Folder API"); + } finally { + await rm(directoryPath, { force: true, recursive: true }); + } + }); + + it("does not overwrite project folder files unless explicitly requested", async () => { + const directoryPath = await mkdtemp(join(tmpdir(), "specdock-folder-")); + + try { + await writeProjectFolder(directoryPath, projectExport); + + await expect(writeProjectFolder(directoryPath, projectExport)).rejects.toThrow(); + await expect( + writeProjectFolder(directoryPath, projectExport, true) + ).resolves.toBeUndefined(); + } finally { + await rm(directoryPath, { force: true, recursive: true }); + } + }); +}); diff --git a/apps/desktop/src/main/project-folder.ts b/apps/desktop/src/main/project-folder.ts new file mode 100644 index 0000000..8f7f6db --- /dev/null +++ b/apps/desktop/src/main/project-folder.ts @@ -0,0 +1,127 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + CURRENT_PROJECT_EXPORT_VERSION, + CURRENT_REDACTION_POLICY_VERSION, + PROJECT_EXPORT_FORMAT, + parseProjectExport +} from "@specdock/core"; + +const PROJECT_FOLDER_FORMAT = "specdock.folder"; +const PROJECT_FOLDER_VERSION = 1; +const PROJECT_MANIFEST_FILE = "specdock.project.json"; +const PROJECT_SPEC_FILE = "openapi.json"; + +type ProjectFolderManifest = { + format: typeof PROJECT_FOLDER_FORMAT; + version: typeof PROJECT_FOLDER_VERSION; + savedAt: string; + project: { + name: string; + source: unknown; + specFile: typeof PROJECT_SPEC_FILE; + specFormat?: "openapi3" | "swagger2"; + }; + preferences: ReturnType["preferences"]; +}; + +export type DesktopProjectFolderResult = + | { canceled: true } + | { + canceled: false; + content: string; + directoryPath: string; + }; + +export type DesktopSaveProjectFolderResult = + | { canceled: true } + | { + canceled: false; + directoryPath: string; + }; + +export async function writeProjectFolder( + directoryPath: string, + exportContent: string, + overwrite = false +): Promise { + const parsed = parseProjectExport(exportContent); + const manifest: ProjectFolderManifest = { + format: PROJECT_FOLDER_FORMAT, + version: PROJECT_FOLDER_VERSION, + savedAt: new Date().toISOString(), + project: { + name: parsed.project.name, + source: parsed.project.source, + specFile: PROJECT_SPEC_FILE, + specFormat: parsed.project.specFormat + }, + preferences: parsed.preferences + }; + + await mkdir(directoryPath, { recursive: true }); + await writeProjectFolderFile( + join(directoryPath, PROJECT_MANIFEST_FILE), + JSON.stringify(manifest, null, 2), + overwrite + ); + await writeProjectFolderFile( + join(directoryPath, PROJECT_SPEC_FILE), + JSON.stringify(parsed.project.spec, null, 2), + overwrite + ); +} + +export async function readProjectFolder(directoryPath: string): Promise { + const manifest = parseProjectFolderManifest( + await readFile(join(directoryPath, PROJECT_MANIFEST_FILE), "utf8") + ); + const spec = JSON.parse( + await readFile(join(directoryPath, manifest.project.specFile), "utf8") + ) as unknown; + const exportContent = JSON.stringify({ + format: PROJECT_EXPORT_FORMAT, + version: CURRENT_PROJECT_EXPORT_VERSION, + exportedAt: manifest.savedAt, + redactionPolicyVersion: CURRENT_REDACTION_POLICY_VERSION, + project: { + metadata: { name: manifest.project.name }, + source: manifest.project.source, + specFormat: manifest.project.specFormat, + spec + }, + preferences: manifest.preferences + }); + + parseProjectExport(exportContent); + return exportContent; +} + +function parseProjectFolderManifest(text: string): ProjectFolderManifest { + const manifest = JSON.parse(text) as Partial; + + if ( + manifest.format !== PROJECT_FOLDER_FORMAT || + manifest.version !== PROJECT_FOLDER_VERSION || + typeof manifest.savedAt !== "string" || + !manifest.project || + typeof manifest.project.name !== "string" || + manifest.project.specFile !== PROJECT_SPEC_FILE || + !manifest.preferences + ) { + throw new Error("Invalid SpecDock project folder manifest."); + } + + return manifest as ProjectFolderManifest; +} + +async function writeProjectFolderFile( + filePath: string, + content: string, + overwrite: boolean +): Promise { + await writeFile(filePath, content, { + encoding: "utf8", + flag: overwrite ? "w" : "wx" + }); +} diff --git a/apps/desktop/src/main/sdk-output.test.ts b/apps/desktop/src/main/sdk-output.test.ts new file mode 100644 index 0000000..e4a6d59 --- /dev/null +++ b/apps/desktop/src/main/sdk-output.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "vitest"; +import { writeSdkOutputDirectory } from "./sdk-output.js"; + +const generatedFiles = [ + { path: "sdk/client.ts", content: "export const client = {};\n" }, + { path: "sdk/index.ts", content: "export * from './client';\n" } +]; + +describe("desktop SDK output", () => { + it("writes generated files inside the selected directory", async () => { + const directoryPath = await mkdtemp(join(tmpdir(), "specdock-sdk-")); + + try { + const result = await writeSdkOutputDirectory({ + directoryPath, + files: generatedFiles, + outputRoot: "sdk" + }); + + await expect(readFile(join(directoryPath, "client.ts"), "utf8")).resolves.toContain( + "client" + ); + expect(result.fileCount).toBe(2); + } finally { + await rm(directoryPath, { force: true, recursive: true }); + } + }); + + it("rejects generated file paths that escape the output root", async () => { + const directoryPath = await mkdtemp(join(tmpdir(), "specdock-sdk-")); + + try { + await expect( + writeSdkOutputDirectory({ + directoryPath, + files: [{ path: "../client.ts", content: "" }], + outputRoot: "sdk" + }) + ).rejects.toThrow(); + } finally { + await rm(directoryPath, { force: true, recursive: true }); + } + }); + + it("does not overwrite files unless explicitly requested", async () => { + const directoryPath = await mkdtemp(join(tmpdir(), "specdock-sdk-")); + + try { + await writeFile(join(directoryPath, "client.ts"), "existing", "utf8"); + + await expect( + writeSdkOutputDirectory({ + directoryPath, + files: generatedFiles, + outputRoot: "sdk" + }) + ).rejects.toThrow(); + await expect( + writeSdkOutputDirectory({ + directoryPath, + files: generatedFiles, + outputRoot: "sdk", + overwrite: true + }) + ).resolves.toEqual({ fileCount: 2, totalBytes: 52 }); + } finally { + await rm(directoryPath, { force: true, recursive: true }); + } + }); +}); diff --git a/apps/desktop/src/main/sdk-output.ts b/apps/desktop/src/main/sdk-output.ts new file mode 100644 index 0000000..4ad997b --- /dev/null +++ b/apps/desktop/src/main/sdk-output.ts @@ -0,0 +1,102 @@ +import { lstat, mkdir, writeFile } from "node:fs/promises"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { createGeneratedOutputPlan } from "@specdock/generator"; +import { defaultGenerateOptions, type GeneratedFile } from "@specdock/core"; + +export type DesktopSdkOutputResult = + | { canceled: true } + | { + canceled: false; + directoryPath: string; + fileCount: number; + totalBytes: number; + }; + +export type DesktopSdkOutputSummary = { + fileCount: number; + totalBytes: number; +}; + +export async function writeSdkOutputDirectory({ + directoryPath, + files, + outputRoot, + overwrite = false +}: { + directoryPath: string; + files: GeneratedFile[]; + outputRoot: string; + overwrite?: boolean; +}): Promise { + const root = resolve(directoryPath); + const plan = createGeneratedOutputPlan(files, { + ...defaultGenerateOptions, + outputPath: outputRoot + }); + + await mkdir(root, { recursive: true }); + + for (const entry of plan.files) { + const file = files.find((candidate) => candidate.path === entry.path); + + if (!file) { + throw new Error("Generated output plan references a missing file."); + } + + const target = resolve(root, entry.relativePath); + assertWithinDirectory(root, target); + await assertNoSymlinkParents(root, entry.relativePath); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, file.content, { + encoding: "utf8", + flag: overwrite ? "w" : "wx" + }); + } + + return { + fileCount: plan.fileCount, + totalBytes: plan.totalBytes + }; +} + +function assertWithinDirectory(root: string, target: string): void { + const targetRelativeToRoot = relative(root, target); + + if ( + targetRelativeToRoot.startsWith("..") || + targetRelativeToRoot.includes(`..${sep}`) || + targetRelativeToRoot === "" || + /^[A-Za-z]:/.test(targetRelativeToRoot) + ) { + throw new Error("Generated SDK output escapes the selected directory."); + } +} + +async function assertNoSymlinkParents( + root: string, + relativePath: string +): Promise { + const segments = relativePath.split("/"); + let current = root; + + for (const segment of segments.slice(0, -1)) { + current = join(current, segment); + + try { + if ((await lstat(current)).isSymbolicLink()) { + throw new Error("Generated SDK output parent path is a symlink."); + } + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) { + return; + } + + throw error; + } + } +} diff --git a/apps/desktop/src/main/window-options.test.ts b/apps/desktop/src/main/window-options.test.ts new file mode 100644 index 0000000..c10d501 --- /dev/null +++ b/apps/desktop/src/main/window-options.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { createDesktopWindowOptions } from "./window-options.js"; + +describe("desktop window options", () => { + it("keeps the renderer isolated from Node.js APIs", () => { + const options = createDesktopWindowOptions("/tmp/preload.js"); + + expect(options.webPreferences).toMatchObject({ + preload: "/tmp/preload.js", + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false + }); + }); +}); diff --git a/apps/desktop/src/main/window-options.ts b/apps/desktop/src/main/window-options.ts new file mode 100644 index 0000000..35cfec4 --- /dev/null +++ b/apps/desktop/src/main/window-options.ts @@ -0,0 +1,22 @@ +import type { BrowserWindowConstructorOptions } from "electron"; + +export function createDesktopWindowOptions( + preloadPath: string +): BrowserWindowConstructorOptions { + return { + width: 1280, + height: 900, + minWidth: 1024, + minHeight: 720, + show: false, + backgroundColor: "#0f172a", + webPreferences: { + preload: preloadPath, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false + } + }; +} diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts new file mode 100644 index 0000000..f2fa666 --- /dev/null +++ b/apps/desktop/src/preload/preload.ts @@ -0,0 +1,31 @@ +import { contextBridge, ipcRenderer } from "electron"; + +const desktopBridge = { + getInfo: () => ipcRenderer.invoke("specdock:getInfo"), + openProjectFile: () => ipcRenderer.invoke("specdock:openProjectFile"), + openProjectFolder: () => ipcRenderer.invoke("specdock:openProjectFolder"), + saveProjectFile: (defaultFileName: string, content: string) => + ipcRenderer.invoke("specdock:saveProjectFile", { + content, + defaultFileName + }), + saveProjectFolder: (content: string, overwrite = false) => + ipcRenderer.invoke("specdock:saveProjectFolder", { + content, + overwrite + }), + writeSdkOutput: ( + files: Array<{ path: string; content: string }>, + outputRoot: string, + overwrite = false + ) => + ipcRenderer.invoke("specdock:writeSdkOutput", { + files, + outputRoot, + overwrite + }) +}; + +contextBridge.exposeInMainWorld("specdockDesktop", desktopBridge); + +export type SpecDockDesktopBridge = typeof desktopBridge; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..f16e54e --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/apps/web/package.json b/apps/web/package.json index 894602e..2ac7d48 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@specdock/web", - "version": "0.5.0", + "version": "1.0.0", "private": true, "type": "module", "scripts": { @@ -11,19 +11,19 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@specdock/core": "0.5.0", - "@specdock/generator": "0.5.0", - "@specdock/ui": "0.5.0", + "@specdock/core": "1.0.0", + "@specdock/generator": "1.0.0", + "@specdock/ui": "1.0.0", "@tailwindcss/vite": "4.3.1", + "@tanstack/react-query": "5.101.0", "@vitejs/plugin-react": "6.0.2", - "tailwindcss": "4.3.1", - "vite": "8.0.16", + "lucide-react": "0.468.0", "react": "19.2.7", "react-dom": "19.2.7", - "@tanstack/react-query": "5.101.0", "react-hook-form": "7.79.0", - "zod": "3.25.76", - "lucide-react": "0.468.0" + "tailwindcss": "4.3.1", + "vite": "8.0.16", + "zod": "3.25.76" }, "devDependencies": { "@types/react": "19.2.17", diff --git a/apps/web/src/app/project-transfer.ts b/apps/web/src/app/project-transfer.ts index 3550271..9425bde 100644 --- a/apps/web/src/app/project-transfer.ts +++ b/apps/web/src/app/project-transfer.ts @@ -1,96 +1,5 @@ -import { z } from "zod"; -import { - defaultGenerateOptions, - type GenerateOptions, - type OpenApiProject, - type RequestState +export { + createProjectExport, + parseProjectExport, + type ProjectImportPayload } from "@specdock/core"; -import { - hydrateStoredRequestStates, - sanitizeRequestStatesForStorage -} from "./request-state-storage.js"; - -const requestStateSchema = z.object({ - operationId: z.string(), - authProfileId: z.string().optional(), - pathParams: z.record(z.string()), - queryParams: z.record(z.string()), - requestMode: z.enum(["direct", "proxy"]) -}); - -const exportSchema = z.object({ - format: z.literal("specdock.project"), - version: z.literal(1), - exportedAt: z.string(), - project: z.object({ - name: z.string(), - source: z.unknown(), - specFormat: z.enum(["openapi3", "swagger2"]).optional(), - spec: z.unknown() - }), - preferences: z.object({ - baseUrl: z.string().optional(), - requestStates: z.record(requestStateSchema).default({}), - generateOptions: z.object({ - language: z.enum(["typescript", "python", "go", "java", "csharp", "php"]).default(defaultGenerateOptions.language), - client: z.enum(["fetch", "axios"]).default(defaultGenerateOptions.client), - generateTypes: z.boolean().default(defaultGenerateOptions.generateTypes), - generateReactQuery: z.boolean().default(defaultGenerateOptions.generateReactQuery), - generateZod: z.boolean().default(defaultGenerateOptions.generateZod), - outputPath: z.string().default(defaultGenerateOptions.outputPath), - namingStyle: z.enum(["operationId", "camelCase"]).default(defaultGenerateOptions.namingStyle), - packageName: z.string().default(defaultGenerateOptions.packageName), - clientName: z.string().default(defaultGenerateOptions.clientName), - baseUrlStrategy: z.enum(["constructor", "perRequest"]).default(defaultGenerateOptions.baseUrlStrategy) - }).partial().default({}) - }) -}); - -export type ProjectImportPayload = z.infer; - -export const createProjectExport = ({ - project, - baseUrl, - requestStates, - generateOptions -}: { - project: OpenApiProject; - baseUrl?: string; - requestStates: Record; - generateOptions: GenerateOptions; -}): string => { - const operationPrefix = `${project.id}::`; - const safeRequestStates = Object.fromEntries( - Object.entries(sanitizeRequestStatesForStorage(requestStates)) - .filter(([key]) => key.startsWith(operationPrefix)) - .map(([key, value]) => [key.slice(operationPrefix.length), value]) - ); - - return JSON.stringify({ - format: "specdock.project", - version: 1, - exportedAt: new Date().toISOString(), - project: { - name: project.name, - source: project.source, - specFormat: project.specFormat, - spec: project.spec - }, - preferences: { - baseUrl, - requestStates: safeRequestStates, - generateOptions - } - }, null, 2); -}; - -export const parseProjectExport = (text: string): ProjectImportPayload => { - const parsed = exportSchema.parse(JSON.parse(text)) as ProjectImportPayload; - return { - ...parsed, - preferences: { - ...parsed.preferences, - requestStates: hydrateStoredRequestStates(parsed.preferences.requestStates) - } - }; -}; diff --git a/apps/web/src/app/request-state-storage.ts b/apps/web/src/app/request-state-storage.ts index 347da0a..dcaf41d 100644 --- a/apps/web/src/app/request-state-storage.ts +++ b/apps/web/src/app/request-state-storage.ts @@ -1,79 +1,5 @@ -import { isSensitiveParameterName, type RequestState } from "@specdock/core"; -import type { RequestStateMap } from "./types.js"; - -export type PersistedRequestState = Pick< - RequestState, - "operationId" | "authProfileId" | "pathParams" | "queryParams" | "requestMode" ->; - -export const sanitizeRequestStatesForStorage = ( - requestStates: RequestStateMap -): Record => { - return Object.fromEntries( - Object.entries(requestStates).map(([key, state]) => [ - key, - sanitizeRequestStateForStorage(state) - ]) - ); -}; - -export const hydrateStoredRequestStates = ( - requestStates: Partial>> -): RequestStateMap => { - return Object.fromEntries( - Object.entries(requestStates).flatMap(([key, state]) => { - if (!state?.operationId) { - return []; - } - - return [ - [ - key, - { - operationId: state.operationId, - authProfileId: - typeof state.authProfileId === "string" - ? state.authProfileId - : undefined, - pathParams: safeRecord(state.pathParams), - queryParams: safeQueryParams(state.queryParams), - headers: {}, - body: undefined, - requestMode: state.requestMode === "proxy" ? "proxy" : "direct" - } - ] - ]; - }) - ); -}; - -const sanitizeRequestStateForStorage = ( - state: RequestState -): PersistedRequestState => ({ - operationId: state.operationId, - authProfileId: state.authProfileId, - pathParams: safeRecord(state.pathParams), - queryParams: safeQueryParams(state.queryParams), - requestMode: state.requestMode -}); - -const safeRecord = ( - values: Record | undefined -): Record => { - return Object.fromEntries( - Object.entries(values ?? {}).filter( - ([name, value]) => name.trim() && typeof value === "string" - ) - ); -}; - -const safeQueryParams = ( - values: Record | undefined -): Record => { - return Object.fromEntries( - Object.entries(safeRecord(values)).map(([name, value]) => [ - name, - isSensitiveParameterName(name) ? "" : value - ]) - ); -}; +export { + hydrateStoredRequestStates, + sanitizeRequestStatesForStorage, + type PersistedRequestState +} from "@specdock/core"; diff --git a/apps/web/src/app/types.ts b/apps/web/src/app/types.ts index 138c152..05f9dd7 100644 --- a/apps/web/src/app/types.ts +++ b/apps/web/src/app/types.ts @@ -1,6 +1,7 @@ import type { ApiOperation, GeneratedFile, + GeneratedOutputPlan, GenerateOptions, MockRouteSummary, MockResponseResult, @@ -16,6 +17,7 @@ export type GenerateApiResponse = { fileCount: number; generatedAt: string; generatorVersion: string; + outputPlan: GeneratedOutputPlan; }; }; diff --git a/apps/web/src/app/useSpecDockState.ts b/apps/web/src/app/useSpecDockState.ts index 0707ffc..810bc95 100644 --- a/apps/web/src/app/useSpecDockState.ts +++ b/apps/web/src/app/useSpecDockState.ts @@ -2,13 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import type { AuthProfile, GeneratedFile, GenerateOptions, OpenApiDiffReport, OpenApiProject, OpenApiSource, RequestState } from "@specdock/core"; import { createRequestState } from "../request.js"; import { createWorkspaceStorage } from "../workspace.js"; -import { - readLocalJson, - readLocalString, - removeLocalValue, - writeLocalJson, - writeLocalString -} from "./local-storage.js"; +import { readLocalJson, readLocalString, removeLocalValue, writeLocalJson, writeLocalString } from "./local-storage.js"; import { sampleSpec } from "./sample-spec.js"; import { baseUrlsStorageKey, @@ -83,7 +77,11 @@ export const useSpecDockState = () => { ); const [mockServerState, setMockServerState] = useState({}); const appConfig = useAppConfig(); - const [status, setStatus] = useState("Ready"); + const [status, setStatus] = useState(() => + storageAdapter.getDiagnostics().some((diagnostic) => diagnostic.code !== "missing-key") + ? "Recovered local workspace storage. Some invalid saved data was reset." + : "Ready" + ); const [isImportingUrl, setIsImportingUrl] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [isDownloadingZip, setIsDownloadingZip] = useState(false); diff --git a/apps/web/src/components/GeneratePanel.tsx b/apps/web/src/components/GeneratePanel.tsx index 36db4b4..bc15904 100644 --- a/apps/web/src/components/GeneratePanel.tsx +++ b/apps/web/src/components/GeneratePanel.tsx @@ -124,7 +124,11 @@ export const GeneratePanel = ({
- {fileCount > 0 ? `${fileCount} generated files ready` : "Generate SDK files from the active OpenAPI spec"} + {meta + ? `${meta.outputPlan.fileCount} files in ${meta.outputPlan.outputRoot} - ${formatBytes(meta.outputPlan.totalBytes)}` + : fileCount > 0 + ? `${fileCount} generated files ready` + : "Generate SDK files from the active OpenAPI spec"}