diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d408d0a..27586af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,14 +41,21 @@ jobs: cache: npm - run: npm ci - run: npm run build - # @arboretum/shared est une dépendance runtime non publiée : on packe les - # deux tarballs et on les installe ensemble dans un projet vierge. - - name: Pack tarballs + # @arboretum/shared (dépendance runtime non publiée) est embarquée dans le tarball + # via bundleDependencies (matérialisée au prepack). On packe donc UN SEUL tarball + # et on l'installe seul, comme un vrai consommateur depuis le registre. + - name: Pack tarball run: | mkdir -p /tmp/tarballs - npm pack -w @arboretum/shared -w git-arboretum --pack-destination /tmp/tarballs + npm pack -w @johanleroy/git-arboretum --pack-destination /tmp/tarballs ls -l /tmp/tarballs - - name: Install tarballs in an empty project + - name: Assert @arboretum/shared is bundled in the tarball + run: | + tgz=$(ls /tmp/tarballs/*.tgz) + tar -tzf "$tgz" | grep -q 'node_modules/@arboretum/shared/dist/index.js' \ + || { echo "ERREUR: @arboretum/shared non embarqué dans $tgz"; exit 1; } + echo "OK: bundleDependency @arboretum/shared présente dans $tgz" + - name: Install tarball in an empty project run: | mkdir /tmp/smoke cd /tmp/smoke diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4bfd8f1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +# Publication du paquet @johanleroy/git-arboretum sur le registre npm du Gitea, +# déclenchée UNIQUEMENT par un tag vX.Y.Z (jamais sur un push de branche). +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: read + packages: write + +jobs: + publish: + name: Publish to Gitea npm registry + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: 'https://git.lidge.fr/api/packages/johanleroy/npm/' + scope: '@johanleroy' + cache: npm + - run: npm ci + - run: npm run build + # Garde-fou : le tag (sans le "v") doit correspondre à la version du paquet. + # GITHUB_REF_NAME est une variable d'env du runner (pas d'interpolation ${{ }} dans le shell). + - name: Verify tag matches package version + run: | + pkg=$(node -p "require('./packages/server/package.json').version") + tag="${GITHUB_REF_NAME#v}" + if [ "$pkg" != "$tag" ]; then + echo "ERREUR: tag '$tag' != version paquet '$pkg'" + exit 1 + fi + echo "OK: tag $tag == version $pkg" + - run: npm publish -w @johanleroy/git-arboretum + env: + # Mapper le secret du registre (PAT Gitea write:package, ou le token auto + # GITEA_TOKEN s'il a ce scope) sur NODE_AUTH_TOKEN lu par le .npmrc de setup-node. + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 7d39877..94b04bd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ packages/server/public/ *.tgz *.tsbuildinfo +# README/LICENSE copiés dans le paquet au prepack (générés depuis la racine) +packages/server/README.md +packages/server/LICENSE + # Test / coverage coverage/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7d2c831 --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +# Route le scope @johanleroy vers le registre npm intégré du Gitea self-hosted. +# Les dépendances publiques (fastify, node-pty…) restent résolues depuis npmjs. +# Le token d'auth (_authToken) ne se commit JAMAIS : il vit dans ~/.npmrc (local) +# ou dans un secret CI (NODE_AUTH_TOKEN écrit par setup-node). +@johanleroy:registry=https://git.lidge.fr/api/packages/johanleroy/npm/ diff --git a/README.md b/README.md index dc051d0..fe9614e 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,17 @@ A single Node.js daemon you run on your dev machine (`npx git-arboretum`), servi ### Run it (recommended) +Arboretum is published to a private Gitea npm registry. Point the `@johanleroy` scope at it once per machine — add to `~/.npmrc`: + +``` +@johanleroy:registry=https://git.lidge.fr/api/packages/johanleroy/npm/ +//git.lidge.fr/api/packages/johanleroy/npm/:_authToken= +``` + +(The `_authToken` line is only needed if the package is private.) Then run it from anywhere: + ```bash -npx git-arboretum +npx @johanleroy/git-arboretum ``` On first start, Arboretum prints a one-time **access token** and the URL to open: @@ -89,13 +98,45 @@ tailscale serve --bg 7317 Then start Arboretum allowing your tailnet origin (the strict Origin check needs to know about it): ```bash -npx git-arboretum --allow-origin https://..ts.net +npx @johanleroy/git-arboretum --allow-origin https://..ts.net ``` Open `https://..ts.net` from any device on your tailnet. **Web Push requires HTTPS**, so Tailscale Serve (or another HTTPS front) is also what enables mobile notifications. On **iOS**, install the app to your home screen first, then allow notifications. > ⚠️ A web terminal is remote code execution **by design**. Never expose Arboretum directly to the public internet. +## Running it as a background service + +For a daemon that survives logout and restarts on boot, run it under a **systemd user service**. Install a pinned version globally (`npm i -g @johanleroy/git-arboretum`), then create `~/.config/systemd/user/arboretum.service`: + +```ini +[Unit] +Description=Arboretum — git worktree & Claude Code dashboard +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=%h/.local/bin/arboretum --port 7317 --allow-origin https://MACHINE.TAILNET.ts.net +Restart=on-failure +RestartSec=5 +KillSignal=SIGTERM +TimeoutStopSec=10 +Environment=NODE_ENV=production + +[Install] +WantedBy=default.target +``` + +```bash +which arboretum # adjust ExecStart to the real path if needed +systemctl --user daemon-reload +systemctl --user enable --now arboretum +loginctl enable-linger "$USER" # start the service at boot, without an open session +journalctl --user -u arboretum -f # logs +``` + +> The one-time **access token is printed only on the very first run** (empty database). When running as a service, capture it from `journalctl`, or do one manual run before enabling the service. The token is hashed and never shown again. + ## Configuration All options are CLI flags: @@ -141,7 +182,7 @@ Arboretum wraps the **interactive** Claude Code CLI in a PTY — the same thing ## Development -Arboretum is an npm-workspaces monorepo: `@arboretum/shared` (WS/REST protocol, source of truth), `git-arboretum` (the Fastify daemon, the published package), and `@arboretum/web` (the Vue 3 SPA). +Arboretum is an npm-workspaces monorepo: `@arboretum/shared` (WS/REST protocol, source of truth), `@johanleroy/git-arboretum` (the Fastify daemon, the published package), and `@arboretum/web` (the Vue 3 SPA). ```bash npm run build # build shared → server → web (order matters) diff --git a/package-lock.json b/package-lock.json index 09a1be3..7c72b15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -848,6 +848,10 @@ "node": ">=18" } }, + "node_modules/@johanleroy/git-arboretum": { + "resolved": "packages/server", + "link": true + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3003,10 +3007,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/git-arboretum": { - "resolved": "packages/server", - "link": true - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -4959,8 +4959,11 @@ } }, "packages/server": { - "name": "git-arboretum", - "version": "0.1.0", + "name": "@johanleroy/git-arboretum", + "version": "1.0.0", + "bundleDependencies": [ + "@arboretum/shared" + ], "license": "MIT", "dependencies": { "@arboretum/shared": "0.1.0", diff --git a/package.json b/package.json index f82d786..ff86b98 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ "node": ">=22.16" }, "scripts": { - "build": "npm run build -w @arboretum/shared -w git-arboretum -w @arboretum/web", + "build": "npm run build -w @arboretum/shared -w @johanleroy/git-arboretum -w @arboretum/web", "typecheck": "tsc -b packages/shared packages/server", - "pack": "npm run build && npm pack -w git-arboretum", + "pack": "npm run build && npm pack -w @johanleroy/git-arboretum", "test": "vitest run", - "dev:server": "npm run dev -w git-arboretum", + "dev:server": "npm run dev -w @johanleroy/git-arboretum", "dev:web": "npm run dev -w @arboretum/web" }, "devDependencies": { diff --git a/packages/server/package.json b/packages/server/package.json index 63ba700..2216045 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,26 +1,55 @@ { - "name": "git-arboretum", - "version": "0.1.0", + "name": "@johanleroy/git-arboretum", + "version": "1.0.0", "description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them", "license": "MIT", "type": "module", + "author": "Johan LEROY ", + "homepage": "https://git.lidge.fr/johanleroy/arboretum#readme", + "repository": { + "type": "git", + "url": "git+https://git.lidge.fr/johanleroy/arboretum.git", + "directory": "packages/server" + }, + "bugs": { + "url": "https://git.lidge.fr/johanleroy/arboretum/issues" + }, + "keywords": [ + "git", + "worktree", + "claude", + "claude-code", + "dashboard", + "self-hosted", + "pty", + "terminal", + "cli", + "daemon", + "pwa" + ], "bin": { "arboretum": "./dist/index.js" }, "main": "./dist/app.js", "files": [ - "dist", + "dist/**/*.js", "public" ], "engines": { "node": ">=22.16" }, + "publishConfig": { + "registry": "https://git.lidge.fr/api/packages/johanleroy/npm/" + }, "scripts": { "build": "tsc -b", "dev": "tsc -b --watch & node --watch dist/index.js", - "prepack": "node scripts/copy-web.mjs", + "prepack": "node scripts/copy-web.mjs && node scripts/vendor-shared.mjs && node scripts/copy-meta.mjs", "test": "vitest run" }, + "bundleDependencies": [ + "@arboretum/shared" + ], "dependencies": { "@arboretum/shared": "0.1.0", "@fastify/cookie": "^11.0.0", diff --git a/packages/server/scripts/copy-meta.mjs b/packages/server/scripts/copy-meta.mjs new file mode 100644 index 0000000..4519e61 --- /dev/null +++ b/packages/server/scripts/copy-meta.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Copie le README et la LICENSE racine dans packages/server/ avant le pack, pour que +// la page du paquet (registre Gitea) ne soit pas nue et que la licence soit jointe. +// npm inclut automatiquement README* et LICENSE* dans le tarball s'ils sont présents. +// Branché sur le hook "prepack". Ces copies sont gitignorées (générées, non versionnées). +import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const rootDir = join(serverDir, '..', '..'); + +// README : réécrit les chemins d'images relatifs (brand/…) en URL absolue Gitea raw, +// sinon le logo est cassé hors du repo (sur la page du paquet). +const readmeSrc = join(rootDir, 'README.md'); +if (existsSync(readmeSrc)) { + const rawBase = 'https://git.lidge.fr/johanleroy/arboretum/raw/branch/main/'; + const readme = readFileSync(readmeSrc, 'utf8').replaceAll('src="brand/', `src="${rawBase}brand/`); + writeFileSync(join(serverDir, 'README.md'), readme); + console.log('copy-meta: README.md copié (chemins images réécrits en absolu)'); +} else { + console.error(`copy-meta: ${readmeSrc} introuvable`); + process.exit(1); +} + +const licenseSrc = join(rootDir, 'LICENSE'); +if (existsSync(licenseSrc)) { + copyFileSync(licenseSrc, join(serverDir, 'LICENSE')); + console.log('copy-meta: LICENSE copié'); +} else { + console.error(`copy-meta: ${licenseSrc} introuvable`); + process.exit(1); +} diff --git a/packages/server/scripts/vendor-shared.mjs b/packages/server/scripts/vendor-shared.mjs new file mode 100644 index 0000000..2fe2a92 --- /dev/null +++ b/packages/server/scripts/vendor-shared.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// Embarque @arboretum/shared (dépendance runtime non publiée) DANS le tarball npm. +// Branché sur le hook "prepack", aux côtés de bundleDependencies dans package.json. +// +// Pourquoi un vrai dossier et pas le symlink workspace : sous npm workspaces, shared +// est seulement symlinké dans le node_modules racine ; `npm pack` n'embarque une +// bundleDependency que si elle existe comme VRAI dossier dans le node_modules du +// paquet packé (packages/server/node_modules/@arboretum/shared). On le matérialise ici. +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const sharedDir = join(serverDir, '..', 'shared'); +const sharedDist = join(sharedDir, 'dist'); +const target = join(serverDir, 'node_modules', '@arboretum', 'shared'); + +if (!existsSync(sharedDist)) { + console.error( + `vendor-shared: ${sharedDist} introuvable — lance "npm run build" (shared doit être compilé) avant le pack.`, + ); + process.exit(1); +} + +// package.json minimal et déterministe : on reprend les champs de résolution réels de +// shared (version incluse, pour rester synchro), sans scripts ni files inutiles au runtime. +const shared = JSON.parse(readFileSync(join(sharedDir, 'package.json'), 'utf8')); +const minimal = { + name: shared.name, + version: shared.version, + type: shared.type, + main: shared.main, + types: shared.types, + exports: shared.exports, +}; + +// Idempotent : on repart d'un dossier propre à chaque pack. +rmSync(target, { recursive: true, force: true }); +mkdirSync(target, { recursive: true }); +writeFileSync(join(target, 'package.json'), JSON.stringify(minimal, null, 2) + '\n'); +cpSync(sharedDist, join(target, 'dist'), { recursive: true }); + +console.log(`vendor-shared: embarqué ${shared.name}@${shared.version} -> ${target}`);