diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 000000000..faa330e5d --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,28 @@ +name-template: '$RESOLVED_VERSION' +tag-template: '$RESOLVED_VERSION' +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&' +no-changes-template: '- No merged pull requests in this release window.' +categories: + - title: Features + labels: + - feature + - enhancement + - title: Fixes + labels: + - bug + - fix + - regression + - title: Maintenance + labels: + - chore + - dependencies + - maintenance +template: | + ## Changes + + $CHANGES + + ## Contributors + + $CONTRIBUTORS diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 805e80e2a..fd271cb25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,8 @@ jobs: name: Tests runs-on: ubuntu-latest timeout-minutes: 30 + env: + SKIP_ELECTRON_REBUILD: '1' steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/.github/workflows/electron-freeze.yml b/.github/workflows/electron-freeze.yml deleted file mode 100644 index 9fac553ff..000000000 --- a/.github/workflows/electron-freeze.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Electron Freeze Guard - -# The path filter scopes this workflow to PRs that touch apps/desktop/ OR any -# shared workspace package that apps/desktop/ consumes. Without guarding the -# shared packages, a PR could alter Electron runtime behavior (e.g. tweaking -# @memry/contracts or @memry/rpc) while never touching apps/desktop/**, and -# slip past the freeze entirely. -# -# The `labeled`/`unlabeled` event types are required so adding a bypass label -# after a failed check reruns the workflow — with the default pull_request -# triggers (opened/synchronize/reopened), a label applied post-fail would -# leave the check red until a new commit or manual rerun. -on: - pull_request: - types: [opened, synchronize, reopened, labeled, unlabeled] - branches: [main] - paths: - - 'apps/desktop/**' - - 'packages/contracts/**' - - 'packages/db-schema/**' - - 'packages/domain-inbox/**' - - 'packages/domain-notes/**' - - 'packages/domain-tasks/**' - - 'packages/rpc/**' - - 'packages/shared/**' - - 'packages/storage-data/**' - - 'packages/storage-vault/**' - - 'packages/sync-core/**' - -jobs: - check-frozen: - runs-on: ubuntu-latest - steps: - - name: Allow if cutover or emergency-fix label present - id: check-labels - uses: actions/github-script@v7 - with: - script: | - const labels = context.payload.pull_request.labels.map(l => l.name); - const allowed = [ - 'migration/m10-cutover', - 'migration/emergency-fix', - 'migration/tauri-shared-change' - ]; - const match = labels.find(l => allowed.includes(l)); - if (match) { - core.info(`Allowed by label: ${match}`); - core.setOutput('allowed', 'true'); - } else { - core.setOutput('allowed', 'false'); - } - - - name: Fail if freeze-guarded paths modified without bypass label - if: steps.check-labels.outputs.allowed != 'true' - run: | - echo "::error::apps/desktop/ and shared packages it consumes are FROZEN during the Tauri migration." - echo "::error::Use 'migration/m10-cutover' for the M10 cutover PR," - echo "::error::'migration/emergency-fix' for a rare Electron-only exception," - echo "::error::or 'migration/tauri-shared-change' when the change is for Tauri work in shared packages." - echo "::error::Otherwise, direct to apps/desktop-tauri/." - echo "::error::See apps/desktop/README.md for details." - exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab5b0ff9d..78eae95a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,17 +3,18 @@ name: Desktop Release on: workflow_dispatch: inputs: - version: - description: Calendar version in YYYY.M.D format. Leave blank to use the UTC date. + release_date: + description: Release date in YYYY.M.D format. Leave blank to use the UTC date. required: false type: string release_notes: description: Markdown release notes prepended to GitHub-generated release notes. - required: true + required: false type: string permissions: contents: write + pull-requests: read concurrency: group: release-${{ github.workflow }}-${{ github.ref }} @@ -27,11 +28,15 @@ jobs: name: Prepare release metadata runs-on: ubuntu-latest outputs: - version: ${{ steps.version.outputs.version }} - release_name: ${{ steps.version.outputs.release_name }} + app_version: ${{ steps.metadata.outputs.app_version }} + release_date: ${{ steps.metadata.outputs.release_date }} + release_name: ${{ steps.metadata.outputs.release_name }} + release_tag: ${{ steps.metadata.outputs.release_tag }} steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Require main branch if: github.ref_name != 'main' @@ -44,17 +49,28 @@ jobs: with: node-version-file: .nvmrc - - name: Resolve version - id: version + - name: Resolve release metadata + id: metadata shell: bash + env: + GH_TOKEN: ${{ github.token }} run: | - VERSION="${{ inputs.version }}" - if [ -z "$VERSION" ]; then - VERSION="$(date -u '+%Y.%-m.%-d')" + RELEASE_DATE="${{ inputs.release_date }}" + if [ -z "$RELEASE_DATE" ]; then + RELEASE_DATE="$(date -u '+%Y.%-m.%-d')" fi - node scripts/set-desktop-version.mjs --validate-only "$VERSION" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "release_name=Memry $VERSION" >> "$GITHUB_OUTPUT" + + git fetch --tags --force + { + git tag -l 'v*' + gh release list --limit 200 --json tagName --jq '.[].tagName' + } | sort -u > "$RUNNER_TEMP/desktop-release-tags.txt" + + node scripts/desktop-release-metadata.mjs \ + --resolve \ + --date "$RELEASE_DATE" \ + --existing-tags-file "$RUNNER_TEMP/desktop-release-tags.txt" \ + --github-output "$GITHUB_OUTPUT" build: name: Build ${{ matrix.label }} @@ -70,25 +86,25 @@ jobs: arch: x64 os: macos-15-intel artifact_name: release-mac-x64 - build_command: pnpm --filter @memry/desktop exec electron-builder --config config/electron-builder.yml --mac --x64 --publish never + build_command: node apps/desktop/scripts/build-packaged-app.js --mac --x64 --publish never - label: mac arm64 platform: mac arch: arm64 os: macos-15 artifact_name: release-mac-arm64 - build_command: pnpm --filter @memry/desktop exec electron-builder --config config/electron-builder.yml --mac --arm64 --publish never + build_command: node apps/desktop/scripts/build-packaged-app.js --mac --arm64 --publish never - label: win x64 platform: win arch: x64 os: windows-latest artifact_name: release-win - build_command: pnpm --filter @memry/desktop exec electron-builder --config config/electron-builder.yml --win --x64 --publish never + build_command: node apps/desktop/scripts/build-packaged-app.js --win --x64 --publish never - label: linux x64 platform: linux arch: x64 os: ubuntu-latest artifact_name: release-linux - build_command: pnpm --filter @memry/desktop exec electron-builder --config config/electron-builder.yml --linux AppImage deb --x64 --publish never + build_command: node apps/desktop/scripts/build-packaged-app.js --linux AppImage deb --x64 --publish never steps: - name: Checkout uses: actions/checkout@v4 @@ -102,7 +118,7 @@ jobs: cache: pnpm - name: Set release version - run: node scripts/set-desktop-version.mjs "${{ needs.prepare.outputs.version }}" + run: node scripts/set-desktop-version.mjs "${{ needs.prepare.outputs.app_version }}" - name: Install Linux system dependencies if: matrix.platform == 'linux' @@ -124,6 +140,10 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.platform == 'mac' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }} APPLE_TEAM_ID: ${{ matrix.platform == 'mac' && secrets.APPLE_TEAM_ID || '' }} + - name: Check packaged runtime + if: matrix.platform == 'mac' + run: pnpm --filter @memry/desktop check:packaged-runtime + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: @@ -132,7 +152,7 @@ jobs: if-no-files-found: error publish: - name: Publish GitHub Release + name: Create draft GitHub Release runs-on: ubuntu-latest needs: - prepare @@ -183,13 +203,49 @@ jobs: File.write(root.join('latest-mac.yml'), YAML.dump(base)) RUBY - - name: Create GitHub release - uses: softprops/action-gh-release@v2 + - name: Collect release assets + shell: bash + run: | + mkdir -p release-assets + find artifacts -type f \ + \( \ + -name '*.AppImage' \ + -o -name '*.blockmap' \ + -o -name '*.deb' \ + -o -name '*.dmg' \ + -o -name '*.exe' \ + -o -name '*.yml' \ + -o -name '*.zip' \ + \) \ + -print0 | while IFS= read -r -d '' file; do + cp "$file" "release-assets/$(basename "$file")" + done + + asset_count="$(find release-assets -maxdepth 1 -type f | wc -l | tr -d ' ')" + if [ "$asset_count" -eq 0 ]; then + echo "No release assets collected" + exit 1 + fi + + find release-assets -maxdepth 1 -type f -print | sort + + - name: Draft GitHub release + id: release_drafter + uses: release-drafter/release-drafter@v7 with: - tag_name: ${{ needs.prepare.outputs.version }} + tag: ${{ needs.prepare.outputs.release_tag }} name: ${{ needs.prepare.outputs.release_name }} - target_commitish: ${{ github.sha }} - files: artifacts/**/* - body: ${{ inputs.release_notes }} - generate_release_notes: true - fail_on_unmatched_files: false + version: ${{ needs.prepare.outputs.app_version }} + publish: false + latest: false + commitish: ${{ github.sha }} + header: ${{ inputs.release_notes }} + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Upload release assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ steps.release_drafter.outputs.tag_name }}" release-assets/* --clobber diff --git a/apps/desktop/README.md b/apps/desktop/README.md index fcc6cbcbf..cbda5392e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -1,14 +1,15 @@ -# ⚠️ FROZEN — Electron desktop app (being migrated to Tauri) +# Electron desktop app -**Status:** Frozen as of 2026-04-24. No new commits to `apps/desktop/**` until deletion. +**Status:** Active. New desktop work targets this Electron app. -**Migration target:** `apps/desktop-tauri/` +## Development -**Why frozen:** Memry is migrating from Electron to Tauri 2.x as a complete -greenfield rewrite. This directory is preserved only so the Tauri build can -reference the Electron renderer for source parity during M1 (see migration -spec: `docs/superpowers/specs/2026-04-24-electron-to-tauri-full-migration-design.md`). +```bash +pnpm --filter @memry/desktop dev +pnpm --filter @memry/desktop build +``` -**For emergency bug fixes to Electron:** contact Kaan. Do not push directly. +## Release Builds -**Scheduled deletion:** At M10 of the Tauri migration. +Release packaging is handled through the desktop release workflow and +`apps/desktop/scripts/build-packaged-app.js`. diff --git a/apps/desktop/config/electron-builder.staged-local-mac.yml b/apps/desktop/config/electron-builder.staged-local-mac.yml new file mode 100644 index 000000000..e253b6c47 --- /dev/null +++ b/apps/desktop/config/electron-builder.staged-local-mac.yml @@ -0,0 +1,22 @@ +extends: file:config/electron-builder.local-mac.yml +beforeBuild: ./scripts/before-build-external-node-modules.cjs +npmRebuild: true +dmg: + size: 3g +extraResources: + - from: .env.staging + to: .env + - from: node_modules + to: node_modules + filter: + - '**/*' + - '.pnpm' + - '.pnpm/**/*' + - '.modules.yaml' + - '!**/*.map' + - '!**/{coverage,doc,docs,example,examples,test,tests,__tests__}/**' + - '!**/re2/vendor/**' + - '!electron' + - '!electron/**/*' + - '!**/node_modules/electron' + - '!**/node_modules/electron/**/*' diff --git a/apps/desktop/config/electron-builder.staged.yml b/apps/desktop/config/electron-builder.staged.yml new file mode 100644 index 000000000..5a17be28a --- /dev/null +++ b/apps/desktop/config/electron-builder.staged.yml @@ -0,0 +1,20 @@ +extends: file:config/electron-builder.yml +beforeBuild: ./scripts/before-build-external-node-modules.cjs +npmRebuild: true +dmg: + size: 3g +extraResources: + - from: node_modules + to: node_modules + filter: + - '**/*' + - '.pnpm' + - '.pnpm/**/*' + - '.modules.yaml' + - '!**/*.map' + - '!**/{coverage,doc,docs,example,examples,test,tests,__tests__}/**' + - '!**/re2/vendor/**' + - '!electron' + - '!electron/**/*' + - '!**/node_modules/electron' + - '!**/node_modules/electron/**/*' diff --git a/apps/desktop/config/electron-builder.yml b/apps/desktop/config/electron-builder.yml index a4c5be5f2..23b08b95d 100644 --- a/apps/desktop/config/electron-builder.yml +++ b/apps/desktop/config/electron-builder.yml @@ -12,6 +12,7 @@ files: - '!src/*' - '!config/*' - '!electron.vite.config.{js,ts,mjs,cjs}' + - '!electron-builder.env' - '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 59acc4bb0..9739c88d9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -62,11 +62,12 @@ "db:seed:inbox": "bash scripts/ensure-native.sh node && npx tsx scripts/seed-inbox.ts", "db:seed:calendar": "bash scripts/ensure-native.sh node && npx tsx scripts/seed-calendar.ts", "seed:notes": "node --experimental-strip-types --experimental-transform-types scripts/seed-notes.ts", - "build:unpack": "pnpm build && electron-builder --config config/electron-builder.yml --dir", + "build:unpack": "pnpm build && node scripts/build-packaged-app.js --dir", "build:win": "pnpm build && electron-builder --config config/electron-builder.yml --win", - "build:mac": "pnpm build && electron-builder --config config/electron-builder.yml --mac", + "build:mac": "pnpm build && node scripts/build-packaged-app.js --mac", "build:mac:signed:local": "node scripts/build-mac-signed-local.mjs", "build:linux": "pnpm build && electron-builder --config config/electron-builder.yml --linux", + "check:packaged-runtime": "node scripts/check-packaged-runtime-deps.js", "test": "pnpm exec vitest run --config config/vitest.config.ts", "test:watch": "pnpm exec vitest watch --config config/vitest.config.ts", "test:ui": "pnpm exec vitest --ui --config config/vitest.config.ts", @@ -126,6 +127,7 @@ "pako": "^2.1.0", "react": "^19.2.3", "react-dom": "^19.2.3", + "safe-buffer": "^5.2.1", "sharp": "0.34.5", "sqlite-vec": "0.1.7-alpha.2", "ws": "^8.19.0", diff --git a/apps/desktop/scripts/before-build-external-node-modules.cjs b/apps/desktop/scripts/before-build-external-node-modules.cjs new file mode 100644 index 000000000..8226e966e --- /dev/null +++ b/apps/desktop/scripts/before-build-external-node-modules.cjs @@ -0,0 +1,3 @@ +module.exports = async function beforeBuildExternalNodeModules() { + return false +} diff --git a/apps/desktop/scripts/build-mac-signed-local.mjs b/apps/desktop/scripts/build-mac-signed-local.mjs index 22afa548b..9cc1bafd4 100644 --- a/apps/desktop/scripts/build-mac-signed-local.mjs +++ b/apps/desktop/scripts/build-mac-signed-local.mjs @@ -12,7 +12,9 @@ const defaultSyncServerUrl = 'https://sync-staging.memrynote.com' if (!existsSync(envPath)) { console.error('Missing apps/desktop/electron-builder.env') - console.error('Create it from apps/desktop/electron-builder.env.example and fill in local secrets.') + console.error( + 'Create it from apps/desktop/electron-builder.env.example and fill in local secrets.' + ) process.exit(1) } @@ -59,7 +61,9 @@ if (placeholderEnv.length > 0) { if (buildEnv.CSC_IDENTITY_AUTO_DISCOVERY === 'false' && !buildEnv.CSC_NAME?.trim()) { console.error('CSC_IDENTITY_AUTO_DISCOVERY=false requires CSC_NAME for signed mac builds.') console.error('Without CSC_NAME, electron-builder falls back to ad-hoc signing on arm64.') - console.error('Set CSC_IDENTITY_AUTO_DISCOVERY=true or add CSC_NAME in apps/desktop/electron-builder.env.') + console.error( + 'Set CSC_IDENTITY_AUTO_DISCOVERY=true or add CSC_NAME in apps/desktop/electron-builder.env.' + ) process.exit(1) } @@ -103,11 +107,10 @@ function run(command, args) { } run('pnpm', ['build']) -run('pnpm', [ - 'exec', - 'electron-builder', +run(process.execPath, [ + 'scripts/build-packaged-app.js', '--config', - 'config/electron-builder.local-mac.yml', + 'config/electron-builder.staged-local-mac.yml', '--mac', '--arm64', '--publish', diff --git a/apps/desktop/scripts/build-packaged-app.js b/apps/desktop/scripts/build-packaged-app.js new file mode 100644 index 000000000..80f272377 --- /dev/null +++ b/apps/desktop/scripts/build-packaged-app.js @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const { createRequire } = require('node:module') +const os = require('node:os') +const path = require('node:path') + +const appRoot = path.resolve(__dirname, '..') +const repoRoot = path.resolve(appRoot, '..', '..') +const appRequire = createRequire(path.join(appRoot, 'package.json')) +const electronBuilderCli = appRequire.resolve('electron-builder/cli.js') +const electronVersion = appRequire('electron/package.json').version +const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memry-desktop-package-')) +const distDir = path.join(appRoot, 'dist') +const defaultConfigPath = 'config/electron-builder.staged.yml' +const nativeModules = ['better-sqlite3', 'classic-level', 'keytar'] + +function removePath(targetPath) { + const stat = fs.lstatSync(targetPath, { throwIfNoEntry: false }) + if (!stat) { + return + } + + if (stat.isSymbolicLink()) { + fs.unlinkSync(targetPath) + return + } + + fs.rmSync(targetPath, { force: true, recursive: true }) +} + +function run(command, args, options = {}) { + execFileSync(command, args, { stdio: 'inherit', ...options }) +} + +function parseElectronBuilderArgs(argv) { + const args = [] + let configPath = defaultConfigPath + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + + if (arg === '--config' || arg === '-c') { + const value = argv[index + 1] + if (!value) { + throw new Error(`${arg} requires a config path`) + } + + configPath = value + index += 1 + continue + } + + if (arg.startsWith('--config=')) { + configPath = arg.slice('--config='.length) + continue + } + + args.push(arg) + } + + return { args, configPath } +} + +function syncIntoStage(relativePath, { optional = false } = {}) { + const sourcePath = path.join(appRoot, relativePath) + const destinationPath = path.join(stageDir, relativePath) + + if (!fs.existsSync(sourcePath)) { + if (optional) { + return + } + + throw new Error(`Missing required packaging path: ${sourcePath}`) + } + + removePath(destinationPath) + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }) + fs.cpSync(sourcePath, destinationPath, { + dereference: false, + force: true, + recursive: true, + verbatimSymlinks: true + }) +} + +function relativizeInternalSymlinks(rootPath) { + const entries = fs.readdirSync(rootPath, { withFileTypes: true }) + + for (const entry of entries) { + const entryPath = path.join(rootPath, entry.name) + + if (entry.isSymbolicLink()) { + const targetPath = fs.readlinkSync(entryPath) + if (!path.isAbsolute(targetPath) || !targetPath.startsWith(stageDir)) { + continue + } + + const relativeTargetPath = path.relative(path.dirname(entryPath), targetPath) + fs.unlinkSync(entryPath) + fs.symlinkSync(relativeTargetPath || '.', entryPath) + continue + } + + if (entry.isDirectory()) { + relativizeInternalSymlinks(entryPath) + } + } +} + +function main() { + const { args, configPath } = parseElectronBuilderArgs(process.argv.slice(2)) + + if (args.length === 0) { + throw new Error( + 'Usage: node scripts/build-packaged-app.js [--config path] ' + ) + } + + run('pnpm', ['--filter', '@memry/desktop', 'deploy', '--legacy', '--prod', stageDir], { + cwd: repoRoot, + env: { + ...process.env, + SKIP_ELECTRON_REBUILD: '1' + } + }) + + syncIntoStage('build') + syncIntoStage('config') + syncIntoStage('out') + syncIntoStage('scripts') + syncIntoStage('.env.staging', { optional: true }) + removePath(path.join(stageDir, 'node_modules', '@memry', 'desktop')) + removePath(path.join(stageDir, 'electron-builder.env')) + run( + 'pnpm', + [ + '--dir', + appRoot, + 'exec', + 'electron-rebuild', + '--force', + '--only', + nativeModules.join(','), + '--module-dir', + stageDir, + '--version', + electronVersion + ], + { + cwd: repoRoot + } + ) + relativizeInternalSymlinks(path.join(stageDir, 'node_modules')) + + run(process.execPath, [electronBuilderCli, '--config', configPath, ...args], { + cwd: stageDir, + env: { + ...process.env, + MEMRY_PACKAGED_STAGE_DIR: stageDir + } + }) + + removePath(distDir) + fs.cpSync(path.join(stageDir, 'dist'), distDir, { + dereference: false, + force: true, + recursive: true, + verbatimSymlinks: true + }) +} + +try { + main() +} finally { + if (process.env.MEMRY_KEEP_STAGED_PACKAGE_DIR === '1') { + console.error(`Kept staged package dir: ${stageDir}`) + } else { + removePath(stageDir) + } +} diff --git a/apps/desktop/scripts/check-packaged-runtime-deps.js b/apps/desktop/scripts/check-packaged-runtime-deps.js new file mode 100644 index 000000000..7b3ad09ac --- /dev/null +++ b/apps/desktop/scripts/check-packaged-runtime-deps.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +const { spawnSync } = require('node:child_process') +const fs = require('node:fs') +const { createRequire } = require('node:module') +const os = require('node:os') +const path = require('node:path') + +const appRoot = path.resolve(__dirname, '..') +const appRequire = createRequire(path.join(appRoot, 'package.json')) +const productName = 'memry' +const requiredModules = [ + '@tiptap/core', + '@tiptap/pm/model', + '@tiptap/pm/transform', + 'better-sqlite3', + 'orderedmap', + 'prosemirror-model', + 'readable-stream', + 'safe-buffer', + 'string_decoder/', + 'y-leveldb' +] + +function getElectronExecutable() { + const electronExecutable = appRequire('electron') + if (typeof electronExecutable !== 'string') { + throw new Error('Unable to resolve Electron executable from the desktop package') + } + + return electronExecutable +} + +function fail(message) { + console.error(message) + process.exitCode = 1 +} + +function findDefaultAppBundle() { + const candidates = [ + path.join(appRoot, 'dist', `mac-${process.arch}`, `${productName}.app`), + path.join(appRoot, 'dist', 'mac-arm64', `${productName}.app`), + path.join(appRoot, 'dist', 'mac', `${productName}.app`) + ] + + return candidates.find((candidate) => fs.existsSync(candidate)) +} + +function resolveResourcesPath(inputPath) { + if (!inputPath) { + const appBundle = findDefaultAppBundle() + if (!appBundle) { + throw new Error('No packaged mac app found under apps/desktop/dist') + } + + return path.join(appBundle, 'Contents', 'Resources') + } + + const absolutePath = path.resolve(inputPath) + if (absolutePath.endsWith('.app')) { + return path.join(absolutePath, 'Contents', 'Resources') + } + + if (path.basename(absolutePath) === 'Resources') { + return absolutePath + } + + return path.join(absolutePath, 'Contents', 'Resources') +} + +function findPackageRoot(resolvedPath, packageName) { + let current = fs.statSync(resolvedPath).isDirectory() ? resolvedPath : path.dirname(resolvedPath) + const root = path.parse(current).root + + while (current !== root) { + const packageJsonPath = path.join(current, 'package.json') + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) + if (packageJson.name === packageName) { + return current + } + } + + current = path.dirname(current) + } + + throw new Error(`Unable to locate ${packageName} package root from ${resolvedPath}`) +} + +function assertPackagedPath(moduleName, resolvedPath, resourcesPath) { + const realResolvedPath = fs.realpathSync(resolvedPath) + const realResourcesPath = fs.realpathSync(resourcesPath) + + if (!realResolvedPath.startsWith(`${realResourcesPath}${path.sep}`)) { + fail(`Packaged runtime module "${moduleName}" resolved outside the app: ${realResolvedPath}`) + } +} + +function runElectronNativeSmoke(resourcesPath) { + const electronExecutable = getElectronExecutable() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memry-packaged-native-smoke-')) + const smokeScriptPath = path.join(tempDir, 'smoke.cjs') + const appMainPath = path.join(resourcesPath, 'app.asar', 'out', 'main', 'index.js') + + fs.writeFileSync( + smokeScriptPath, + ` +const { createRequire } = require('node:module') + +const packagedRequire = createRequire(process.env.MEMRY_PACKAGED_MAIN) +const Database = require(packagedRequire.resolve('better-sqlite3')) +const database = new Database(':memory:') +database.close() +require(packagedRequire.resolve('keytar')) +console.log(\`Electron native runtime ABI \${process.versions.modules}\`) +`.trimStart() + ) + + try { + const result = spawnSync(electronExecutable, [smokeScriptPath], { + encoding: 'utf8', + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + MEMRY_PACKAGED_MAIN: appMainPath + } + }) + + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim() + fail(`Packaged native modules do not load under Electron:\n${output}`) + return + } + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }) + } +} + +function main() { + const resourcesPath = resolveResourcesPath(process.argv[2]) + const appAsarPath = path.join(resourcesPath, 'app.asar') + const externalNodeModulesPath = path.join(resourcesPath, 'node_modules') + + if (!fs.existsSync(appAsarPath)) { + fail(`Missing packaged app.asar: ${appAsarPath}`) + } + + if (!fs.existsSync(externalNodeModulesPath)) { + fail(`Missing external production node_modules: ${externalNodeModulesPath}`) + } + + if (process.exitCode) { + return + } + + const packagedRequire = createRequire(path.join(appAsarPath, 'out', 'main', 'index.js')) + const resolvedModules = new Map() + + for (const moduleName of requiredModules) { + try { + const resolvedPath = packagedRequire.resolve(moduleName) + resolvedModules.set(moduleName, resolvedPath) + assertPackagedPath(moduleName, resolvedPath, resourcesPath) + } catch (error) { + fail(`Cannot resolve packaged runtime module "${moduleName}": ${error.message}`) + } + } + + if (process.exitCode) { + return + } + + const betterSqliteRoot = findPackageRoot(resolvedModules.get('better-sqlite3'), 'better-sqlite3') + const betterSqliteBinary = path.join(betterSqliteRoot, 'build', 'Release', 'better_sqlite3.node') + if (!fs.existsSync(betterSqliteBinary)) { + fail(`Missing packaged better-sqlite3 binary: ${betterSqliteBinary}`) + } + + const directElectronPath = path.join(externalNodeModulesPath, 'electron') + if (fs.existsSync(directElectronPath)) { + fail(`Packaged external node_modules should not include Electron: ${directElectronPath}`) + } + + runElectronNativeSmoke(resourcesPath) + + if (!process.exitCode) { + console.log(`Packaged runtime dependencies resolved from ${resourcesPath}`) + } +} + +main() diff --git a/apps/desktop/scripts/prune-packaged-app.mjs b/apps/desktop/scripts/prune-packaged-app.mjs index 48df207fe..cd5a2d8d2 100644 --- a/apps/desktop/scripts/prune-packaged-app.mjs +++ b/apps/desktop/scripts/prune-packaged-app.mjs @@ -1,5 +1,13 @@ -import { existsSync, rmSync } from 'node:fs' -import { join } from 'node:path' +import { + existsSync, + lstatSync, + readdirSync, + readlinkSync, + rmSync, + symlinkSync, + unlinkSync +} from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' const ARCH_NAMES = new Map([ [0, 'ia32'], @@ -67,16 +75,46 @@ function pruneBetterSqliteBuildArtifacts(nodeModulesDir) { removePath(join(betterSqliteRoot, 'build', 'Release', 'test_extension.node')) } -export default async function prunePackagedApp(context) { - const resourcesDir = getResourcesDir(context) - const nodeModulesDir = join(resourcesDir, 'app.asar.unpacked', 'node_modules') +function relativizeInternalSymlinks(rootPath) { + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + const entryPath = join(rootPath, entry.name) - if (!existsSync(nodeModulesDir)) { - return + if (entry.isSymbolicLink()) { + const targetPath = readlinkSync(entryPath) + if (!isAbsolute(targetPath) || !targetPath.startsWith(rootPath)) { + continue + } + + unlinkSync(entryPath) + symlinkSync(relative(dirname(entryPath), targetPath) || '.', entryPath) + continue + } + + if (entry.isDirectory()) { + relativizeInternalSymlinks(entryPath) + } } +} +export default async function prunePackagedApp(context) { + const resourcesDir = resolve(getResourcesDir(context)) const archName = resolveArchName(context.arch) + const nodeModulesDirs = [ + join(resourcesDir, 'app.asar.unpacked', 'node_modules'), + join(resourcesDir, 'node_modules') + ] + + for (const nodeModulesDir of nodeModulesDirs) { + if (!existsSync(nodeModulesDir)) { + continue + } - pruneOnnxRuntime(nodeModulesDir, context.electronPlatformName, archName) - pruneBetterSqliteBuildArtifacts(nodeModulesDir) + const stat = lstatSync(nodeModulesDir) + if (stat.isDirectory()) { + relativizeInternalSymlinks(nodeModulesDir) + } + + pruneOnnxRuntime(nodeModulesDir, context.electronPlatformName, archName) + pruneBetterSqliteBuildArtifacts(nodeModulesDir) + } } diff --git a/apps/desktop/src/main/runtime-dependencies.test.ts b/apps/desktop/src/main/runtime-dependencies.test.ts new file mode 100644 index 000000000..26ba1be40 --- /dev/null +++ b/apps/desktop/src/main/runtime-dependencies.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const packageJson = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')) as { + dependencies?: Record +} + +describe('runtime dependencies', () => { + it('keeps packaged main-process dependencies in production dependencies', () => { + const dependencies = packageJson.dependencies ?? {} + + expect(dependencies).toHaveProperty('better-sqlite3') + expect(dependencies).toHaveProperty('safe-buffer') + }) +}) diff --git a/package.json b/package.json index c98d4b93b..f238d37bb 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,10 @@ "picomatch": ">=4.0.4", "vite": "^7.3.2", "markdown-it": ">=14.1.1", - "qs": ">=6.14.2" + "qs": ">=6.14.2", + "protobufjs": "7.5.5", + "serialize-javascript": "7.0.5", + "@xmldom/xmldom": "0.8.13" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e17653012..9547ab59a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,12 +18,14 @@ overrides: undici: '>=7.24.0' flatted: '>=3.4.2' lodash: '>=4.18.1' - '@xmldom/xmldom': 0.8.12 + '@xmldom/xmldom': 0.8.13 path-to-regexp: '>=8.4.0' picomatch: '>=4.0.4' vite: ^7.3.2 markdown-it: '>=14.1.1' qs: '>=6.14.2' + protobufjs: 7.5.5 + serialize-javascript: 7.0.5 importers: @@ -158,6 +160,9 @@ importers: react-dom: specifier: ^19.2.3 version: 19.2.4(react@19.2.4) + safe-buffer: + specifier: ^5.2.1 + version: 5.2.1 sharp: specifier: 0.34.5 version: 0.34.5 @@ -4732,10 +4737,9 @@ packages: resolution: {integrity: sha512-sumk8m5wzOPMs8TizfQkWG0MTqe0p1yfu77ouz+xy1hNW+gaSf99uiU3lvz4rSghloM1esKfqRCFQibJI4+d/w==} engines: {node: '>=18'} - '@xmldom/xmldom@0.8.12': - resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} - deprecated: this version has critical issues, please update to the latest version '@zip.js/zip.js@2.8.26': resolution: {integrity: sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==} @@ -8581,8 +8585,8 @@ packages: prosemirror-view@1.41.6: resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==} - protobufjs@7.5.4: - resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + protobufjs@7.5.5: + resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} engines: {node: '>=12.0.0'} proxy-addr@2.0.7: @@ -8643,9 +8647,6 @@ packages: '@types/react-dom': optional: true - randombytes@2.1.0: - resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} - range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -9048,8 +9049,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} - serialize-javascript@6.0.2: - resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + serialize-javascript@7.0.5: + resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} + engines: {node: '>=20.0.0'} serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} @@ -13999,7 +14001,7 @@ snapshots: dependencies: '@wdio/logger': 9.18.0 - '@xmldom/xmldom@0.8.12': {} + '@xmldom/xmldom@0.8.13': {} '@zip.js/zip.js@2.8.26': {} @@ -17774,7 +17776,7 @@ snapshots: log-symbols: 4.1.0 minimatch: 5.1.8 ms: 2.1.3 - serialize-javascript: 6.0.2 + serialize-javascript: 7.0.5 strip-json-comments: 3.1.1 supports-color: 8.1.1 workerpool: 6.5.1 @@ -18039,7 +18041,7 @@ snapshots: long: 5.3.2 onnxruntime-common: 1.22.0-dev.20250409-89f8206ba4 platform: 1.3.6 - protobufjs: 7.5.4 + protobufjs: 7.5.5 open@11.0.0: dependencies: @@ -18259,7 +18261,7 @@ snapshots: plist@3.1.0: dependencies: - '@xmldom/xmldom': 0.8.12 + '@xmldom/xmldom': 0.8.13 base64-js: 1.5.1 xmlbuilder: 15.1.1 @@ -18480,7 +18482,7 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.11.0 - protobufjs@7.5.4: + protobufjs@7.5.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -18603,10 +18605,6 @@ snapshots: '@types/react': 19.2.13 '@types/react-dom': 19.2.3(@types/react@19.2.13) - randombytes@2.1.0: - dependencies: - safe-buffer: 5.2.1 - range-parser@1.2.1: {} raw-body@3.0.2: @@ -19128,9 +19126,7 @@ snapshots: dependencies: type-fest: 0.13.1 - serialize-javascript@6.0.2: - dependencies: - randombytes: 2.1.0 + serialize-javascript@7.0.5: {} serve-static@2.2.1: dependencies: diff --git a/scripts/desktop-release-metadata.mjs b/scripts/desktop-release-metadata.mjs new file mode 100644 index 000000000..bcfd8e84b --- /dev/null +++ b/scripts/desktop-release-metadata.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node + +import { appendFileSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const releaseTagPattern = /^v(\d{4}\.\d{1,2}\.\d{1,2})(?:-(\d{3}))?$/ +const releaseDatePattern = /^(\d{4})\.(\d{1,2})\.(\d{1,2})$/ +const appVersionPattern = /^(\d{4})\.(\d{3,4})\.(\d+)$/ + +export function validateReleaseDate(input) { + const match = releaseDatePattern.exec(input) + if (!match) { + throw new Error('Release date must match YYYY.M.D') + } + + const [, yearText, monthText, dayText] = match + if (hasLeadingZero(monthText) || hasLeadingZero(dayText)) { + throw new Error('Release date month and day must not be zero-padded') + } + + const year = Number(yearText) + const month = Number(monthText) + const day = Number(dayText) + + if (month < 1 || month > 12) { + throw new Error('Release date month must be between 1 and 12') + } + + const candidate = new Date(Date.UTC(year, month - 1, day)) + const isValidDate = + candidate.getUTCFullYear() === year && + candidate.getUTCMonth() === month - 1 && + candidate.getUTCDate() === day + + if (!isValidDate) { + throw new Error('Release date day is not valid for the given month/year') + } + + return `${year}.${month}.${day}` +} + +export function validateAppVersion(input) { + const match = appVersionPattern.exec(input) + if (!match) { + throw new Error('Desktop app version must match semver-safe YYYY.MDD.N') + } + + const releaseIndex = Number(match[3]) + if (releaseIndex < 1) { + throw new Error('Desktop app version release index must be at least 1') + } + + return input +} + +export function parseReleaseTag(tag) { + const match = releaseTagPattern.exec(tag) + if (!match) { + throw new Error(`Invalid desktop release tag: ${tag}`) + } + + const date = validateReleaseDate(match[1]) + const index = match[2] ? Number(match[2]) : 1 + if (index < 2 && match[2]) { + throw new Error(`Invalid desktop release tag suffix: ${tag}`) + } + + return { date, index, tag } +} + +export function resolveReleaseMetadata({ date, existingTags }) { + const releaseDate = validateReleaseDate(date) + const sameDayIndexes = existingTags.flatMap((tag) => { + try { + const parsed = parseReleaseTag(tag) + return parsed.date === releaseDate ? [parsed.index] : [] + } catch { + return [] + } + }) + + const releaseIndex = sameDayIndexes.length === 0 ? 1 : Math.max(...sameDayIndexes) + 1 + const releaseTag = formatReleaseTag(releaseDate, releaseIndex) + const appVersion = formatAppVersion(releaseDate, releaseIndex) + + return { + appVersion, + releaseDate, + releaseIndex, + releaseName: `Memry ${releaseTag}`, + releaseTag + } +} + +function formatReleaseTag(date, index) { + if (index === 1) { + return `v${date}` + } + + return `v${date}-${String(index).padStart(3, '0')}` +} + +function formatAppVersion(date, index) { + const [year, month, day] = date.split('.').map(Number) + return `${year}.${Number(`${month}${String(day).padStart(2, '0')}`)}.${index}` +} + +function hasLeadingZero(value) { + return value.length > 1 && value.startsWith('0') +} + +function parseArgs(argv) { + const options = { + existingTags: [] + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + + if (arg === '--validate-date') { + options.mode = 'validate-date' + options.date = readRequiredValue(argv, index, arg) + index += 1 + continue + } + + if (arg === '--validate-app-version') { + options.mode = 'validate-app-version' + options.appVersion = readRequiredValue(argv, index, arg) + index += 1 + continue + } + + if (arg === '--resolve') { + options.mode = 'resolve' + continue + } + + if (arg === '--date') { + options.date = readRequiredValue(argv, index, arg) + index += 1 + continue + } + + if (arg === '--existing-tag') { + options.existingTags.push(readRequiredValue(argv, index, arg)) + index += 1 + continue + } + + if (arg === '--existing-tags-file') { + const tagsPath = readRequiredValue(argv, index, arg) + options.existingTags.push(...readTagsFile(tagsPath)) + index += 1 + continue + } + + if (arg === '--github-output') { + options.githubOutput = readRequiredValue(argv, index, arg) + index += 1 + continue + } + + throw new Error(`Unknown argument: ${arg}`) + } + + return options +} + +function readRequiredValue(argv, index, flag) { + const value = argv[index + 1] + if (!value) { + throw new Error(`${flag} requires a value`) + } + + return value +} + +function readTagsFile(tagsPath) { + return readFileSync(tagsPath, 'utf8') + .split(/\r?\n/) + .map((tag) => tag.trim()) + .filter(Boolean) +} + +function writeGitHubOutputs(outputPath, metadata) { + const lines = [ + `app_version=${metadata.appVersion}`, + `release_date=${metadata.releaseDate}`, + `release_index=${metadata.releaseIndex}`, + `release_name=${metadata.releaseName}`, + `release_tag=${metadata.releaseTag}` + ] + + appendFileSync(outputPath, `${lines.join('\n')}\n`) +} + +function main() { + const options = parseArgs(process.argv.slice(2)) + + if (options.mode === 'validate-date') { + console.log(validateReleaseDate(options.date)) + return + } + + if (options.mode === 'validate-app-version') { + console.log(validateAppVersion(options.appVersion)) + return + } + + if (options.mode === 'resolve') { + const metadata = resolveReleaseMetadata({ + date: options.date, + existingTags: options.existingTags + }) + + if (options.githubOutput) { + writeGitHubOutputs(options.githubOutput, metadata) + } + + console.log(JSON.stringify(metadata, null, 2)) + return + } + + throw new Error( + 'Usage: node scripts/desktop-release-metadata.mjs --resolve --date [--existing-tags-file path] [--github-output path]' + ) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main() +} diff --git a/scripts/desktop-release-metadata.test.mjs b/scripts/desktop-release-metadata.test.mjs new file mode 100644 index 000000000..70b3189f8 --- /dev/null +++ b/scripts/desktop-release-metadata.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + parseReleaseTag, + resolveReleaseMetadata, + validateAppVersion, + validateReleaseDate +} from './desktop-release-metadata.mjs' + +describe('desktop release metadata', () => { + it('allocates the first release tag and semver-safe app version for a date', () => { + const metadata = resolveReleaseMetadata({ + date: '2026.4.27', + existingTags: [] + }) + + assert.deepEqual(metadata, { + appVersion: '2026.427.1', + releaseDate: '2026.4.27', + releaseIndex: 1, + releaseName: 'Memry v2026.4.27', + releaseTag: 'v2026.4.27' + }) + }) + + it('allocates zero-padded same-day release suffixes after the first release', () => { + const metadata = resolveReleaseMetadata({ + date: '2026.4.27', + existingTags: ['v2026.4.27'] + }) + + assert.equal(metadata.releaseTag, 'v2026.4.27-002') + assert.equal(metadata.appVersion, '2026.427.2') + assert.equal(metadata.releaseIndex, 2) + }) + + it('increments from the highest same-day release tag or draft release', () => { + const metadata = resolveReleaseMetadata({ + date: '2026.4.27', + existingTags: ['v2026.4.27', 'v2026.4.27-002', 'v2026.4.26-004', 'not-a-release'] + }) + + assert.equal(metadata.releaseTag, 'v2026.4.27-003') + assert.equal(metadata.appVersion, '2026.427.3') + assert.equal(metadata.releaseIndex, 3) + }) + + it('parses supported release tag formats', () => { + assert.deepEqual(parseReleaseTag('v2026.4.27'), { + date: '2026.4.27', + index: 1, + tag: 'v2026.4.27' + }) + assert.deepEqual(parseReleaseTag('v2026.4.27-002'), { + date: '2026.4.27', + index: 2, + tag: 'v2026.4.27-002' + }) + }) + + it('rejects invalid release dates and tag suffixes', () => { + assert.throws(() => validateReleaseDate('2026.04.27'), /zero-padded/) + assert.throws(() => validateReleaseDate('2026.2.31'), /valid/) + assert.throws(() => parseReleaseTag('v2026.4.27-2'), /release tag/) + assert.throws(() => parseReleaseTag('2026.4.27'), /release tag/) + }) + + it('validates semver-safe app versions derived from release metadata', () => { + assert.equal(validateAppVersion('2026.427.1'), '2026.427.1') + assert.throws(() => validateAppVersion('2026.4.27-002'), /app version/) + assert.throws(() => validateAppVersion('2026.427.0'), /release index/) + }) +}) diff --git a/scripts/set-desktop-version.mjs b/scripts/set-desktop-version.mjs index 50d86f2de..ee7bba7bc 100644 --- a/scripts/set-desktop-version.mjs +++ b/scripts/set-desktop-version.mjs @@ -1,17 +1,19 @@ import { readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' +import { validateAppVersion } from './desktop-release-metadata.mjs' + const [, , maybeFlag, maybeVersion] = process.argv const validateOnly = maybeFlag === '--validate-only' const version = validateOnly ? maybeVersion : maybeFlag if (!version) { - console.error('Usage: node scripts/set-desktop-version.mjs [--validate-only] ') + console.error('Usage: node scripts/set-desktop-version.mjs [--validate-only] ') process.exit(1) } -validateCalendarVersion(version) +validateAppVersion(version) if (validateOnly) { console.log(version) @@ -26,32 +28,3 @@ packageJson.version = version writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`) console.log(version) - -function validateCalendarVersion(input) { - if (input.startsWith('v')) { - throw new Error('Version must not include a v prefix') - } - - const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})$/.exec(input) - if (!match) { - throw new Error('Version must match YYYY.M.D') - } - - const year = Number(match[1]) - const month = Number(match[2]) - const day = Number(match[3]) - - if (month < 1 || month > 12) { - throw new Error('Month must be between 1 and 12') - } - - const candidate = new Date(Date.UTC(year, month - 1, day)) - const isValidDate = - candidate.getUTCFullYear() === year && - candidate.getUTCMonth() === month - 1 && - candidate.getUTCDate() === day - - if (!isValidDate) { - throw new Error('Day is not valid for the given month/year') - } -}