chore: migrate to v3, Doppler secrets, clean up legacy code
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped
- Remove all pre-v3 code: scraper, ui-v2, backend v1, ios v1+v2, legacy CI workflows - Flatten v3/ contents to repo root - Add Doppler secrets management (project=libnovel, config=prd) - Add justfile with doppler run wrappers for all docker compose commands - Strip hardcoded env fallbacks from docker-compose.yml - Add minimal README.md - Clean up .gitignore
This commit is contained in:
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
|
||||
"id": 11,
|
||||
"uuid": "d5d04e0a-572c-46c0-83be-405508948391",
|
||||
"name": "runner-mac-1",
|
||||
"token": "ddf214ce148b4673a186f29cb684b407cb8c2ecc",
|
||||
"address": "https://gitea.kalekber.cc/",
|
||||
"labels": [
|
||||
"macos-latest:host",
|
||||
"macos-14:host"
|
||||
],
|
||||
"ephemeral": false
|
||||
}
|
||||
423
scripts/e2e-test.mjs
Normal file
423
scripts/e2e-test.mjs
Normal file
@@ -0,0 +1,423 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* e2e-test.mjs — End-to-end tests for the LibNovel v3 stack.
|
||||
*
|
||||
* Hits live services via https://localhost (self-signed cert, TLS verify skipped).
|
||||
* Requires: Node 18+ (built-in fetch with TLS options via --experimental-fetch or
|
||||
* native in Node 21+). Run with: node --experimental-vm-modules scripts/e2e-test.mjs
|
||||
* or simply: node scripts/e2e-test.mjs
|
||||
*
|
||||
* Services tested:
|
||||
* - Caddy / UI https://localhost
|
||||
* - Go backend via UI proxy routes
|
||||
* - PocketBase via UI server-side (indirect)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/e2e-test.mjs
|
||||
* node scripts/e2e-test.mjs --verbose
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:https';
|
||||
import { request as httpRequest } from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
const BASE = 'https://localhost';
|
||||
const VERBOSE = process.argv.includes('--verbose');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const failures = [];
|
||||
|
||||
function log(...args) {
|
||||
if (VERBOSE) console.log(...args);
|
||||
}
|
||||
|
||||
function pass(name) {
|
||||
passed++;
|
||||
console.log(` ✓ ${name}`);
|
||||
}
|
||||
|
||||
function fail(name, reason) {
|
||||
failed++;
|
||||
const msg = ` ✗ ${name}: ${reason}`;
|
||||
console.log(msg);
|
||||
failures.push({ name, reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch() that ignores TLS certificate errors (self-signed cert on localhost).
|
||||
*/
|
||||
async function get(path, { headers = {}, followRedirects = false } = {}) {
|
||||
const url = path.startsWith('http') ? path : `${BASE}${path}`;
|
||||
const res = await fetch(url, {
|
||||
redirect: followRedirects ? 'follow' : 'manual',
|
||||
headers,
|
||||
// Node 18/19 uses undici which respects NODE_TLS_REJECT_UNAUTHORIZED
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
async function post(path, body, { headers = {}, cookie = '' } = {}) {
|
||||
const url = path.startsWith('http') ? path : `${BASE}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
async function del(path, { cookie = '' } = {}) {
|
||||
const url = path.startsWith('http') ? path : `${BASE}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
redirect: 'manual',
|
||||
headers: cookie ? { Cookie: cookie } : {},
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Extract Set-Cookie header value(s) as a single cookie string. */
|
||||
function extractCookies(res) {
|
||||
const raw = res.headers.getSetCookie?.() ?? [];
|
||||
return raw.map((c) => c.split(';')[0]).join('; ');
|
||||
}
|
||||
|
||||
async function assert(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
pass(name);
|
||||
} catch (e) {
|
||||
fail(name, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function expect(val, label) {
|
||||
return {
|
||||
toBe(expected) {
|
||||
if (val !== expected) throw new Error(`${label}: expected ${expected}, got ${val}`);
|
||||
},
|
||||
toBeOneOf(...options) {
|
||||
if (!options.includes(val)) throw new Error(`${label}: expected one of [${options.join(', ')}], got ${val}`);
|
||||
},
|
||||
toBeOk() {
|
||||
if (!val) throw new Error(`${label} was falsy`);
|
||||
},
|
||||
toContainKey(key) {
|
||||
if (!(key in val)) throw new Error(`${label}: missing key "${key}"`);
|
||||
},
|
||||
toBeArray() {
|
||||
if (!Array.isArray(val)) throw new Error(`${label}: expected array, got ${typeof val}`);
|
||||
},
|
||||
toBeAbove(n) {
|
||||
if (!(val > n)) throw new Error(`${label}: expected > ${n}, got ${val}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Test suite ───────────────────────────────────────────────────────────────
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
// Pick a known book slug from the database (first available)
|
||||
let TEST_SLUG = null;
|
||||
|
||||
console.log('\nLibNovel v3 — End-to-End Tests');
|
||||
console.log('================================\n');
|
||||
|
||||
// ── 1. Health checks ──────────────────────────────────────────────────────────
|
||||
console.log('1. Health checks');
|
||||
|
||||
await assert('UI health endpoint returns 200', async () => {
|
||||
const res = await get('/health');
|
||||
expect(res.status, 'status').toBe(200);
|
||||
});
|
||||
|
||||
await assert('Home page returns 200', async () => {
|
||||
const res = await get('/', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const html = await res.text();
|
||||
expect(html.includes('<html') || html.includes('<!DOCTYPE'), 'has html').toBe(true);
|
||||
});
|
||||
|
||||
await assert('Browse page returns 200', async () => {
|
||||
const res = await get('/browse', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
});
|
||||
|
||||
// ── 2. Home API ───────────────────────────────────────────────────────────────
|
||||
console.log('\n2. Home API');
|
||||
|
||||
let homeData = null;
|
||||
|
||||
await assert('GET /api/home returns continue_reading and recently_updated', async () => {
|
||||
const res = await get('/api/home', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
homeData = await res.json();
|
||||
expect(homeData, 'data').toContainKey('continue_reading');
|
||||
expect(homeData, 'data').toContainKey('recently_updated');
|
||||
expect(homeData.continue_reading, 'continue_reading').toBeArray();
|
||||
expect(homeData.recently_updated, 'recently_updated').toBeArray();
|
||||
});
|
||||
|
||||
await assert('GET /api/home stats has totalBooks and totalChapters', async () => {
|
||||
if (!homeData) {
|
||||
const res = await get('/api/home', { followRedirects: true });
|
||||
homeData = await res.json();
|
||||
}
|
||||
expect(homeData, 'data').toContainKey('stats');
|
||||
expect(typeof homeData.stats.totalBooks, 'totalBooks type').toBe('number');
|
||||
expect(typeof homeData.stats.totalChapters, 'totalChapters type').toBe('number');
|
||||
});
|
||||
|
||||
// ── 3. Browse / ranking / search ──────────────────────────────────────────────
|
||||
console.log('\n3. Browse / ranking / search');
|
||||
|
||||
await assert('GET /api/browse-page returns novels array', async () => {
|
||||
const res = await get('/api/browse-page?page=1', { followRedirects: true });
|
||||
expect(res.status, 'status').toBeOneOf(200, 503);
|
||||
if (res.status === 200) {
|
||||
const data = await res.json();
|
||||
// Accepts { novels: [...] } or { error: ... } (when MinIO cache is empty)
|
||||
expect(typeof data, 'response type').toBe('object');
|
||||
}
|
||||
});
|
||||
|
||||
await assert('GET /api/ranking returns array or 502 (no data yet)', async () => {
|
||||
const res = await get('/api/ranking', { followRedirects: true });
|
||||
// 200 = ranking data exists; 502 = no ranking data scraped yet — both are expected
|
||||
expect(res.status, 'status').toBeOneOf(200, 502);
|
||||
if (res.status === 200) {
|
||||
const data = await res.json();
|
||||
expect(data, 'data').toBeArray();
|
||||
}
|
||||
});
|
||||
|
||||
await assert('GET /api/search?q=shadow returns results object', async () => {
|
||||
const res = await get('/api/search?q=shadow', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
expect(typeof data, 'response type').toBe('object');
|
||||
});
|
||||
|
||||
// ── 4. Books ──────────────────────────────────────────────────────────────────
|
||||
console.log('\n4. Books');
|
||||
|
||||
// Find a real slug from /api/home
|
||||
await assert('GET /api/home books are accessible', async () => {
|
||||
if (!homeData) {
|
||||
const res = await get('/api/home', { followRedirects: true });
|
||||
homeData = await res.json();
|
||||
}
|
||||
const books = homeData?.recently_updated ?? [];
|
||||
if (books.length > 0) {
|
||||
TEST_SLUG = books[0].slug;
|
||||
log(` Using test slug: ${TEST_SLUG}`);
|
||||
}
|
||||
// Pass regardless — we just want to find a slug
|
||||
});
|
||||
|
||||
if (TEST_SLUG) {
|
||||
await assert(`GET /api/book/${TEST_SLUG} returns book metadata`, async () => {
|
||||
const res = await get(`/api/book/${TEST_SLUG}`, { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
// Returns { book: { slug, ... }, chapters: [...] }
|
||||
expect(data, 'data').toContainKey('book');
|
||||
expect(data.book, 'book').toContainKey('slug');
|
||||
expect(data.book.slug, 'slug').toBe(TEST_SLUG);
|
||||
});
|
||||
|
||||
await assert(`Book detail page /${TEST_SLUG} returns 200`, async () => {
|
||||
const res = await get(`/${TEST_SLUG}`, { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠ No books in database — skipping book-specific tests');
|
||||
}
|
||||
|
||||
// ── 5. Voices ─────────────────────────────────────────────────────────────────
|
||||
console.log('\n5. Voices');
|
||||
|
||||
await assert('GET /api/voices returns voices array', async () => {
|
||||
const res = await get('/api/voices', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
// Returns { voices: [...] }
|
||||
expect(data, 'data').toContainKey('voices');
|
||||
expect(data.voices, 'voices').toBeArray();
|
||||
expect(data.voices.length, 'voice count').toBeAbove(0);
|
||||
});
|
||||
|
||||
// ── 6. Auth flow ──────────────────────────────────────────────────────────────
|
||||
console.log('\n6. Auth flow');
|
||||
|
||||
const TEST_USER = `e2e_test_${Date.now()}`;
|
||||
const TEST_PASS = 'E2eTestPassword1!';
|
||||
let authCookie = '';
|
||||
|
||||
await assert('POST /api/auth/register creates new user', async () => {
|
||||
const res = await post('/api/auth/register', { username: TEST_USER, password: TEST_PASS });
|
||||
expect(res.status, 'status').toBeOneOf(200, 201);
|
||||
const data = await res.json();
|
||||
// Returns { token: "...", user: { id, username, role } }
|
||||
expect(data, 'response').toContainKey('user');
|
||||
expect(data.user, 'user').toContainKey('username');
|
||||
expect(data.user.username, 'username').toBe(TEST_USER);
|
||||
// Build cookie from token
|
||||
if (data.token) {
|
||||
authCookie = `libnovel_auth=${data.token}`;
|
||||
} else {
|
||||
authCookie = extractCookies(res);
|
||||
}
|
||||
log(` Auth cookie: ${authCookie.slice(0, 40)}...`);
|
||||
});
|
||||
|
||||
await assert('GET /api/auth/me returns current user when logged in', async () => {
|
||||
const res = await get('/api/auth/me', { followRedirects: true, headers: { Cookie: authCookie } });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data, 'data').toContainKey('username');
|
||||
expect(data.username, 'username').toBe(TEST_USER);
|
||||
});
|
||||
|
||||
await assert('POST /api/auth/logout clears session', async () => {
|
||||
const res = await post('/api/auth/logout', {}, { cookie: authCookie });
|
||||
expect(res.status, 'status').toBeOneOf(200, 204);
|
||||
});
|
||||
|
||||
await assert('POST /api/auth/login works after register', async () => {
|
||||
const res = await post('/api/auth/login', { username: TEST_USER, password: TEST_PASS });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
// Returns { token: "...", user: { id, username, role } }
|
||||
expect(data, 'response').toContainKey('user');
|
||||
expect(data.user.username, 'username').toBe(TEST_USER);
|
||||
if (data.token) {
|
||||
authCookie = `libnovel_auth=${data.token}`;
|
||||
} else {
|
||||
authCookie = extractCookies(res);
|
||||
}
|
||||
});
|
||||
|
||||
await assert('GET /api/auth/me unauthenticated returns 401', async () => {
|
||||
const res = await get('/api/auth/me', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(401);
|
||||
});
|
||||
|
||||
// ── 7. Progress ───────────────────────────────────────────────────────────────
|
||||
console.log('\n7. Progress');
|
||||
|
||||
// /api/progress (root) is POST-only. Per-slug is GET/POST/DELETE via /api/progress/[slug].
|
||||
|
||||
if (TEST_SLUG) {
|
||||
await assert(`POST /api/progress/${TEST_SLUG} sets progress`, async () => {
|
||||
const res = await post(`/api/progress/${TEST_SLUG}`, { chapter: 1 });
|
||||
expect(res.status, 'status').toBeOneOf(200, 201);
|
||||
const data = await res.json();
|
||||
expect(data, 'data').toContainKey('ok');
|
||||
});
|
||||
|
||||
await assert(`DELETE /api/progress/${TEST_SLUG} removes progress`, async () => {
|
||||
const res = await del(`/api/progress/${TEST_SLUG}`);
|
||||
expect(res.status, 'status').toBeOneOf(200, 204);
|
||||
});
|
||||
} else {
|
||||
console.log(' ⚠ No books — skipping progress tests');
|
||||
}
|
||||
|
||||
// ── 8. Library ────────────────────────────────────────────────────────────────
|
||||
console.log('\n8. Library');
|
||||
|
||||
await assert('GET /api/library returns object', async () => {
|
||||
const res = await get('/api/library', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
expect(typeof data, 'data type').toBe('object');
|
||||
});
|
||||
|
||||
if (TEST_SLUG) {
|
||||
await assert(`POST /api/library/${TEST_SLUG} saves book`, async () => {
|
||||
const res = await post(`/api/library/${TEST_SLUG}`, {});
|
||||
expect(res.status, 'status').toBeOneOf(200, 201);
|
||||
});
|
||||
|
||||
await assert(`DELETE /api/library/${TEST_SLUG} removes book`, async () => {
|
||||
const res = await del(`/api/library/${TEST_SLUG}`);
|
||||
expect(res.status, 'status').toBeOneOf(200, 204);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 9. Settings ───────────────────────────────────────────────────────────────
|
||||
console.log('\n9. Settings');
|
||||
|
||||
await assert('GET /api/settings returns settings object', async () => {
|
||||
const res = await get('/api/settings', { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
expect(typeof data, 'data type').toBe('object');
|
||||
});
|
||||
|
||||
// ── 10. Sessions ──────────────────────────────────────────────────────────────
|
||||
console.log('\n10. Sessions');
|
||||
|
||||
await assert('GET /api/sessions (authenticated) returns sessions array', async () => {
|
||||
const res = await get('/api/sessions', { followRedirects: true, headers: { Cookie: authCookie } });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
// Returns { sessions: [...] }
|
||||
expect(data, 'data').toContainKey('sessions');
|
||||
expect(data.sessions, 'sessions').toBeArray();
|
||||
});
|
||||
|
||||
// ── 11. Comments ──────────────────────────────────────────────────────────────
|
||||
console.log('\n11. Comments');
|
||||
|
||||
if (TEST_SLUG) {
|
||||
await assert(`GET /api/comments/${TEST_SLUG} returns comments`, async () => {
|
||||
const res = await get(`/api/comments/${TEST_SLUG}`, { followRedirects: true });
|
||||
expect(res.status, 'status').toBe(200);
|
||||
const data = await res.json();
|
||||
// Returns { comments: [...], myVotes: {}, avatarUrls: {} }
|
||||
expect(data, 'data').toContainKey('comments');
|
||||
expect(data.comments, 'comments').toBeArray();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 12. Chapter endpoints ─────────────────────────────────────────────────────
|
||||
console.log('\n12. Chapter endpoints');
|
||||
|
||||
if (TEST_SLUG) {
|
||||
await assert(`GET /api/chapter/${TEST_SLUG}/1 returns 200 or 404`, async () => {
|
||||
const res = await get(`/api/chapter/${TEST_SLUG}/1`, { followRedirects: true });
|
||||
// 200 if chapter exists in MinIO, 404 if not
|
||||
expect(res.status, 'status').toBeOneOf(200, 404);
|
||||
});
|
||||
|
||||
await assert(`GET /api/chapter-text-preview/${TEST_SLUG}/1 returns 200 or error`, async () => {
|
||||
const res = await get(`/api/chapter-text-preview/${TEST_SLUG}/1`, { followRedirects: true });
|
||||
expect(res.status, 'status').toBeOneOf(200, 404, 500, 503);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||
console.log('\n─────────────────────────────────────');
|
||||
console.log(`Results: ${passed} passed, ${failed} failed`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log('\nFailures:');
|
||||
for (const f of failures) {
|
||||
console.log(` ✗ ${f.name}`);
|
||||
console.log(` ${f.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('─────────────────────────────────────\n');
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -1,99 +0,0 @@
|
||||
// ==UserScript==
|
||||
// @name Link URL Tooltip
|
||||
// @namespace https://github.com/kalekber/libnovel-v2
|
||||
// @version 1.0.0
|
||||
// @description Show the destination URL near the cursor when hovering over any link
|
||||
// @author kalekber
|
||||
// @match *://*/*
|
||||
// @run-at document-idle
|
||||
// @grant none
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// --- Inject styles ---
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#lnk-tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
pointer-events: none;
|
||||
z-index: 2147483647;
|
||||
white-space: nowrap;
|
||||
max-width: 600px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// --- Inject tooltip element ---
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.id = 'lnk-tooltip';
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
// --- Helpers ---
|
||||
function getAnchor(target) {
|
||||
// Walk up the DOM to find the nearest <a href="...">
|
||||
// (handles clicks on nested elements like <a><span>text</span></a>)
|
||||
return target.closest('a[href]');
|
||||
}
|
||||
|
||||
function show(anchor, clientX, clientY) {
|
||||
tooltip.textContent = anchor.href;
|
||||
tooltip.style.display = 'block';
|
||||
position(clientX, clientY);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
tooltip.style.display = 'none';
|
||||
}
|
||||
|
||||
function position(clientX, clientY) {
|
||||
const offset = 12;
|
||||
const tw = tooltip.offsetWidth;
|
||||
const th = tooltip.offsetHeight;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let x = clientX + offset;
|
||||
let y = clientY + offset;
|
||||
|
||||
// Flip horizontally if it would overflow the right edge
|
||||
if (x + tw > vw - 4) {
|
||||
x = clientX - tw - offset;
|
||||
}
|
||||
// Flip vertically if it would overflow the bottom edge
|
||||
if (y + th > vh - 4) {
|
||||
y = clientY - th - offset;
|
||||
}
|
||||
|
||||
tooltip.style.left = Math.max(0, x) + 'px';
|
||||
tooltip.style.top = Math.max(0, y) + 'px';
|
||||
}
|
||||
|
||||
// --- Event delegation on document ---
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const anchor = getAnchor(e.target);
|
||||
if (anchor) show(anchor, e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (tooltip.style.display === 'block') {
|
||||
position(e.clientX, e.clientY);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', (e) => {
|
||||
const anchor = getAnchor(e.target);
|
||||
if (anchor) hide();
|
||||
});
|
||||
})();
|
||||
@@ -1,257 +0,0 @@
|
||||
#!/bin/sh
|
||||
# pb-init-v2.sh — idempotent PocketBase collection bootstrap for the v2 stack
|
||||
#
|
||||
# Creates all collections required by libnovel v2 (backend + runner + ui-v2).
|
||||
# Safe to re-run: POST returns 400/422 when a collection already exists; both
|
||||
# are treated as success. The ensure_field helper adds fields to existing
|
||||
# instances without touching fields that are already present.
|
||||
#
|
||||
# Collections created:
|
||||
# books — book metadata
|
||||
# chapters_idx — per-chapter index (title, number)
|
||||
# ranking — novelfire ranking snapshots
|
||||
# progress — per-session reading progress
|
||||
# scraping_tasks — scrape job queue (runner ↔ backend)
|
||||
# audio_jobs — TTS job queue (runner ↔ backend)
|
||||
#
|
||||
# Required env vars (with defaults matching docker-compose-new.yml):
|
||||
# POCKETBASE_URL http://pocketbase:8090
|
||||
# POCKETBASE_ADMIN_EMAIL admin@libnovel.local
|
||||
# POCKETBASE_ADMIN_PASSWORD changeme123
|
||||
|
||||
set -e
|
||||
|
||||
PB_URL="${POCKETBASE_URL:-http://pocketbase:8090}"
|
||||
PB_EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
PB_PASSWORD="${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
|
||||
log() { echo "[pb-init-v2] $*"; }
|
||||
|
||||
# ─── 0. Ensure curl and python3 are available ────────────────────────────────
|
||||
if ! command -v curl > /dev/null 2>&1; then
|
||||
apk add --no-cache curl > /dev/null 2>&1
|
||||
fi
|
||||
if ! command -v python3 > /dev/null 2>&1; then
|
||||
apk add --no-cache python3 > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# ─── 1. Wait for PocketBase to be ready ──────────────────────────────────────
|
||||
log "waiting for PocketBase at $PB_URL ..."
|
||||
until curl -sf "$PB_URL/api/health" > /dev/null 2>&1; do
|
||||
sleep 2
|
||||
done
|
||||
log "PocketBase is up"
|
||||
|
||||
# ─── 2. Ensure the superuser exists ──────────────────────────────────────────
|
||||
#
|
||||
# On a fresh install PocketBase v0.23+ exposes a one-time install token in the
|
||||
# /_/ redirect Location header. Use it to create the superuser if needed; on
|
||||
# subsequent runs the token is gone and we fall through to normal auth.
|
||||
|
||||
log "ensuring superuser $PB_EMAIL exists ..."
|
||||
|
||||
LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB_URL/_/" 2>/dev/null || true)
|
||||
if echo "$LOCATION" | grep -q "pbinstal/"; then
|
||||
INSTALL_TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n')
|
||||
log "install token found — creating superuser via install endpoint"
|
||||
curl -sf -X POST "$PB_URL/api/collections/_superusers/records" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $INSTALL_TOKEN" \
|
||||
-d "{\"email\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\",\"passwordConfirm\":\"$PB_PASSWORD\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
log "superuser create attempted (may already exist)"
|
||||
fi
|
||||
|
||||
# ─── 3. Authenticate and obtain a superuser token ────────────────────────────
|
||||
log "authenticating as $PB_EMAIL ..."
|
||||
AUTH_RESPONSE=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}")
|
||||
|
||||
TOKEN=$(echo "$AUTH_RESPONSE" | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then
|
||||
log "ERROR: failed to obtain auth token. Response: $AUTH_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
log "auth token obtained"
|
||||
|
||||
# ─── 4. Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
# create_collection NAME JSON_BODY
|
||||
# POSTs to /api/collections. 400/422 = already exists → treated as success.
|
||||
create_collection() {
|
||||
NAME="$1"
|
||||
BODY="$2"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST "$PB_URL/api/collections" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "$BODY")
|
||||
case "$STATUS" in
|
||||
200|201) log "created collection: $NAME" ;;
|
||||
400|422) log "collection already exists (skipped): $NAME" ;;
|
||||
*) log "WARNING: unexpected status $STATUS for collection: $NAME" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ensure_field COLLECTION FIELD_NAME FIELD_TYPE
|
||||
#
|
||||
# Uses python3 to parse the collection schema, then PATCHes the full fields
|
||||
# array with the new field appended — only if it is not already present.
|
||||
# python3 is required to correctly extract the top-level collection id from
|
||||
# the JSON response (sed-based extraction is unreliable on multi-field schemas
|
||||
# because the greedy pattern picks up a field id instead of the collection id).
|
||||
ensure_field() {
|
||||
COLL="$1"
|
||||
FIELD_NAME="$2"
|
||||
FIELD_TYPE="$3"
|
||||
|
||||
SCHEMA=$(curl -sf \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
"$PB_URL/api/collections/$COLL" 2>/dev/null)
|
||||
|
||||
PARSED=$(echo "$SCHEMA" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
fields = d.get('fields', [])
|
||||
exists = any(f.get('name') == '$FIELD_NAME' for f in fields)
|
||||
print('exists=' + str(exists))
|
||||
print('id=' + d.get('id', ''))
|
||||
if not exists:
|
||||
fields.append({'name': '$FIELD_NAME', 'type': '$FIELD_TYPE'})
|
||||
print('fields=' + json.dumps(fields))
|
||||
except Exception as e:
|
||||
print('error=' + str(e))
|
||||
" 2>/dev/null)
|
||||
|
||||
if echo "$PARSED" | grep -q "^exists=True"; then
|
||||
log "field $COLL.$FIELD_NAME already exists — skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
COLLECTION_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
|
||||
if [ -z "$COLLECTION_ID" ]; then
|
||||
log "WARNING: could not get id for collection $COLL — skipping ensure_field"
|
||||
return
|
||||
fi
|
||||
|
||||
NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//')
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X PATCH "$PB_URL/api/collections/$COLLECTION_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "{\"fields\":${NEW_FIELDS}}")
|
||||
case "$STATUS" in
|
||||
200|201) log "patched $COLL — added field: $FIELD_NAME ($FIELD_TYPE)" ;;
|
||||
*) log "WARNING: patch returned $STATUS when adding $FIELD_NAME to $COLL" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── 5. Collections ───────────────────────────────────────────────────────────
|
||||
|
||||
# books — one record per scraped novel
|
||||
create_collection "books" '{
|
||||
"name": "books",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "title", "type": "text", "required": true},
|
||||
{"name": "author", "type": "text"},
|
||||
{"name": "cover", "type": "text"},
|
||||
{"name": "status", "type": "text"},
|
||||
{"name": "genres", "type": "json"},
|
||||
{"name": "summary", "type": "text"},
|
||||
{"name": "total_chapters", "type": "number"},
|
||||
{"name": "source_url", "type": "text"},
|
||||
{"name": "ranking", "type": "number"}
|
||||
]
|
||||
}'
|
||||
|
||||
# chapters_idx — lightweight chapter list (no content; content lives in MinIO)
|
||||
create_collection "chapters_idx" '{
|
||||
"name": "chapters_idx",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "number", "type": "number", "required": true},
|
||||
{"name": "title", "type": "text"}
|
||||
]
|
||||
}'
|
||||
|
||||
# ranking — periodic novelfire ranking snapshots
|
||||
create_collection "ranking" '{
|
||||
"name": "ranking",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "rank", "type": "number", "required": true},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "title", "type": "text"},
|
||||
{"name": "author", "type": "text"},
|
||||
{"name": "cover", "type": "text"},
|
||||
{"name": "status", "type": "text"},
|
||||
{"name": "genres", "type": "json"},
|
||||
{"name": "source_url", "type": "text"}
|
||||
]
|
||||
}'
|
||||
|
||||
# progress — per-session reading progress (no user accounts required)
|
||||
create_collection "progress" '{
|
||||
"name": "progress",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "chapter", "type": "number"}
|
||||
]
|
||||
}'
|
||||
|
||||
# scraping_tasks — scrape job queue consumed by the runner
|
||||
create_collection "scraping_tasks" '{
|
||||
"name": "scraping_tasks",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "kind", "type": "text"},
|
||||
{"name": "target_url", "type": "text"},
|
||||
{"name": "from_chapter", "type": "number"},
|
||||
{"name": "to_chapter", "type": "number"},
|
||||
{"name": "worker_id", "type": "text"},
|
||||
{"name": "status", "type": "text", "required": true},
|
||||
{"name": "books_found", "type": "number"},
|
||||
{"name": "chapters_scraped", "type": "number"},
|
||||
{"name": "chapters_skipped", "type": "number"},
|
||||
{"name": "errors", "type": "number"},
|
||||
{"name": "error_message", "type": "text"},
|
||||
{"name": "started", "type": "date"},
|
||||
{"name": "finished", "type": "date"},
|
||||
{"name": "heartbeat_at", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
# audio_jobs — TTS generation queue consumed by the runner
|
||||
create_collection "audio_jobs" '{
|
||||
"name": "audio_jobs",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "cache_key", "type": "text", "required": true},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "chapter", "type": "number", "required": true},
|
||||
{"name": "voice", "type": "text"},
|
||||
{"name": "worker_id", "type": "text"},
|
||||
{"name": "status", "type": "text", "required": true},
|
||||
{"name": "error_message", "type": "text"},
|
||||
{"name": "started", "type": "date"},
|
||||
{"name": "finished", "type": "date"},
|
||||
{"name": "heartbeat_at", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
# ─── 6. Schema migrations (idempotent — safe to re-run on existing instances) ─
|
||||
#
|
||||
# heartbeat_at was added after the initial v2 deploy. ensure_field is a no-op
|
||||
# if the field already exists (e.g. fresh installs that ran this script from
|
||||
# the start already have it from the create_collection call above).
|
||||
ensure_field "scraping_tasks" "heartbeat_at" "date"
|
||||
ensure_field "audio_jobs" "heartbeat_at" "date"
|
||||
|
||||
log "all collections ready"
|
||||
250
scripts/pb-init-v3.sh
Executable file
250
scripts/pb-init-v3.sh
Executable file
@@ -0,0 +1,250 @@
|
||||
#!/bin/sh
|
||||
# pb-init-v3.sh — idempotent PocketBase bootstrap for the v3 stack.
|
||||
#
|
||||
# Safe to re-run: existing collections and fields are silently skipped.
|
||||
#
|
||||
# Env vars (defaults match docker-compose.yml):
|
||||
# POCKETBASE_URL http://pocketbase:8090
|
||||
# POCKETBASE_ADMIN_EMAIL admin@libnovel.local
|
||||
# POCKETBASE_ADMIN_PASSWORD changeme123
|
||||
|
||||
set -e
|
||||
|
||||
PB="${POCKETBASE_URL:-http://pocketbase:8090}"
|
||||
EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
PASS="${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
|
||||
log() { printf '[pb-init] %s\n' "$*"; }
|
||||
|
||||
# ── 0. Ensure dependencies ────────────────────────────────────────────────────
|
||||
command -v curl > /dev/null 2>&1 || apk add --no-cache curl > /dev/null 2>&1
|
||||
command -v python3 > /dev/null 2>&1 || apk add --no-cache python3 > /dev/null 2>&1
|
||||
|
||||
# ── 1. Wait for PocketBase ────────────────────────────────────────────────────
|
||||
log "waiting for PocketBase..."
|
||||
until curl -sf "$PB/api/health" > /dev/null 2>&1; do sleep 2; done
|
||||
log "PocketBase ready"
|
||||
|
||||
# ── 2. Bootstrap superuser (first-run only) ───────────────────────────────────
|
||||
LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB/_/" 2>/dev/null || true)
|
||||
if echo "$LOCATION" | grep -q "pbinstal/"; then
|
||||
TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n')
|
||||
curl -sf -X POST "$PB/api/collections/_superusers/records" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"passwordConfirm\":\"$PASS\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
log "superuser created"
|
||||
fi
|
||||
|
||||
# ── 3. Authenticate ───────────────────────────────────────────────────────────
|
||||
AUTH=$(curl -sf -X POST "$PB/api/collections/_superusers/auth-with-password" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"identity\":\"$EMAIL\",\"password\":\"$PASS\"}")
|
||||
TOK=$(echo "$AUTH" | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
||||
[ -z "$TOK" ] || [ "$TOK" = "$AUTH" ] && { log "ERROR: auth failed"; exit 1; }
|
||||
log "authenticated"
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# create NAME BODY — POST collection; 400/422 = already exists, treated as ok.
|
||||
create() {
|
||||
NAME="$1"; BODY="$2"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST "$PB/api/collections" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOK" \
|
||||
-d "$BODY")
|
||||
case "$STATUS" in
|
||||
200|201) log "created: $NAME" ;;
|
||||
400|422) log "exists (skip): $NAME" ;;
|
||||
*) log "WARNING: $NAME returned $STATUS" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# add_field COLLECTION FIELD_NAME FIELD_TYPE
|
||||
# Fetches current schema, appends field if absent, PATCHes collection.
|
||||
# Requires python3 for safe JSON manipulation.
|
||||
add_field() {
|
||||
COLL="$1"; FIELD="$2"; TYPE="$3"
|
||||
SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null)
|
||||
# Check existence and extract collection id + fields via python3
|
||||
PARSED=$(echo "$SCHEMA" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
fields = d.get('fields', [])
|
||||
exists = any(f.get('name') == '$FIELD' for f in fields)
|
||||
print('exists=' + str(exists))
|
||||
print('id=' + d.get('id', ''))
|
||||
if not exists:
|
||||
fields.append({'name': '$FIELD', 'type': '$TYPE'})
|
||||
print('fields=' + json.dumps(fields))
|
||||
" 2>/dev/null)
|
||||
if echo "$PARSED" | grep -q "^exists=True"; then
|
||||
log "field exists (skip): $COLL.$FIELD"; return
|
||||
fi
|
||||
COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
|
||||
[ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; }
|
||||
NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//')
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X PATCH "$PB/api/collections/$COLL_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOK" \
|
||||
-d "{\"fields\":${NEW_FIELDS}}")
|
||||
case "$STATUS" in
|
||||
200|201) log "added field: $COLL.$FIELD ($TYPE)" ;;
|
||||
*) log "WARNING: add_field $COLL.$FIELD returned $STATUS" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── 4. Collections ────────────────────────────────────────────────────────────
|
||||
|
||||
create "books" '{
|
||||
"name":"books","type":"base","fields":[
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"title", "type":"text", "required":true},
|
||||
{"name":"author", "type":"text"},
|
||||
{"name":"cover", "type":"text"},
|
||||
{"name":"status", "type":"text"},
|
||||
{"name":"genres", "type":"json"},
|
||||
{"name":"summary", "type":"text"},
|
||||
{"name":"total_chapters","type":"number"},
|
||||
{"name":"source_url", "type":"text"},
|
||||
{"name":"ranking", "type":"number"},
|
||||
{"name":"meta_updated", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "chapters_idx" '{
|
||||
"name":"chapters_idx","type":"base","fields":[
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"number","type":"number", "required":true},
|
||||
{"name":"title", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "ranking" '{
|
||||
"name":"ranking","type":"base","fields":[
|
||||
{"name":"rank", "type":"number","required":true},
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"title", "type":"text"},
|
||||
{"name":"author", "type":"text"},
|
||||
{"name":"cover", "type":"text"},
|
||||
{"name":"status", "type":"text"},
|
||||
{"name":"genres", "type":"json"},
|
||||
{"name":"source_url","type":"text"}
|
||||
]}'
|
||||
|
||||
create "progress" '{
|
||||
"name":"progress","type":"base","fields":[
|
||||
{"name":"session_id","type":"text", "required":true},
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"chapter", "type":"number"},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"audio_time","type":"number"},
|
||||
{"name":"updated", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "scraping_tasks" '{
|
||||
"name":"scraping_tasks","type":"base","fields":[
|
||||
{"name":"kind", "type":"text"},
|
||||
{"name":"target_url", "type":"text"},
|
||||
{"name":"from_chapter", "type":"number"},
|
||||
{"name":"to_chapter", "type":"number"},
|
||||
{"name":"worker_id", "type":"text"},
|
||||
{"name":"status", "type":"text","required":true},
|
||||
{"name":"books_found", "type":"number"},
|
||||
{"name":"chapters_scraped", "type":"number"},
|
||||
{"name":"chapters_skipped", "type":"number"},
|
||||
{"name":"errors", "type":"number"},
|
||||
{"name":"error_message", "type":"text"},
|
||||
{"name":"started", "type":"date"},
|
||||
{"name":"finished", "type":"date"},
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "audio_jobs" '{
|
||||
"name":"audio_jobs","type":"base","fields":[
|
||||
{"name":"cache_key", "type":"text", "required":true},
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"chapter", "type":"number","required":true},
|
||||
{"name":"voice", "type":"text"},
|
||||
{"name":"worker_id", "type":"text"},
|
||||
{"name":"status", "type":"text", "required":true},
|
||||
{"name":"error_message","type":"text"},
|
||||
{"name":"started", "type":"date"},
|
||||
{"name":"finished", "type":"date"},
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "app_users" '{
|
||||
"name":"app_users","type":"base","fields":[
|
||||
{"name":"username", "type":"text","required":true},
|
||||
{"name":"password_hash","type":"text"},
|
||||
{"name":"role", "type":"text"},
|
||||
{"name":"avatar_url", "type":"text"},
|
||||
{"name":"created", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_sessions" '{
|
||||
"name":"user_sessions","type":"base","fields":[
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"session_id","type":"text","required":true},
|
||||
{"name":"user_agent","type":"text"},
|
||||
{"name":"ip", "type":"text"},
|
||||
{"name":"created_at","type":"text"},
|
||||
{"name":"last_seen", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_library" '{
|
||||
"name":"user_library","type":"base","fields":[
|
||||
{"name":"session_id","type":"text","required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"slug", "type":"text","required":true},
|
||||
{"name":"saved_at", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_settings" '{
|
||||
"name":"user_settings","type":"base","fields":[
|
||||
{"name":"session_id","type":"text","required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"auto_next","type":"bool"},
|
||||
{"name":"voice", "type":"text"},
|
||||
{"name":"speed", "type":"number"},
|
||||
{"name":"updated", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "user_subscriptions" '{
|
||||
"name":"user_subscriptions","type":"base","fields":[
|
||||
{"name":"follower_id","type":"text","required":true},
|
||||
{"name":"followee_id","type":"text","required":true},
|
||||
{"name":"created", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "book_comments" '{
|
||||
"name":"book_comments","type":"base","fields":[
|
||||
{"name":"slug", "type":"text","required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"username", "type":"text"},
|
||||
{"name":"body", "type":"text"},
|
||||
{"name":"upvotes", "type":"number"},
|
||||
{"name":"downvotes","type":"number"},
|
||||
{"name":"parent_id","type":"text"},
|
||||
{"name":"created", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "comment_votes" '{
|
||||
"name":"comment_votes","type":"base","fields":[
|
||||
{"name":"comment_id","type":"text","required":true},
|
||||
{"name":"user_id", "type":"text"},
|
||||
{"name":"session_id","type":"text"},
|
||||
{"name":"vote", "type":"text"}
|
||||
]}'
|
||||
|
||||
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
|
||||
add_field "scraping_tasks" "heartbeat_at" "date"
|
||||
add_field "audio_jobs" "heartbeat_at" "date"
|
||||
add_field "progress" "user_id" "text"
|
||||
add_field "progress" "audio_time" "number"
|
||||
add_field "progress" "updated" "text"
|
||||
add_field "books" "meta_updated" "text"
|
||||
|
||||
log "done"
|
||||
@@ -1,345 +0,0 @@
|
||||
#!/bin/sh
|
||||
# pb-init.sh — idempotent PocketBase collection bootstrap
|
||||
#
|
||||
# Creates all collections required by libnovel. Safe to re-run: POST returns
|
||||
# 400/422 when a collection already exists; both are treated as success.
|
||||
#
|
||||
# Required env vars (with defaults):
|
||||
# POCKETBASE_URL http://pocketbase:8090
|
||||
# POCKETBASE_ADMIN_EMAIL admin@libnovel.local
|
||||
# POCKETBASE_ADMIN_PASSWORD changeme123
|
||||
|
||||
set -e
|
||||
|
||||
PB_URL="${POCKETBASE_URL:-http://pocketbase:8090}"
|
||||
PB_EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
||||
PB_PASSWORD="${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
||||
|
||||
log() { echo "[pb-init] $*"; }
|
||||
|
||||
# ─── 0. Ensure curl is available ─────────────────────────────────────────────
|
||||
if ! command -v curl > /dev/null 2>&1; then
|
||||
apk add --no-cache curl > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# ─── 1. Wait for PocketBase to be ready ──────────────────────────────────────
|
||||
log "waiting for PocketBase at $PB_URL ..."
|
||||
until curl -sf "$PB_URL/api/health" > /dev/null 2>&1; do
|
||||
sleep 2
|
||||
done
|
||||
log "PocketBase is up"
|
||||
|
||||
# ─── 2. Ensure the superuser exists, then authenticate ───────────────────────
|
||||
#
|
||||
# The muchobien/pocketbase image does NOT auto-create a superuser from env vars.
|
||||
# On a fresh install PocketBase exposes a one-time install JWT in its log output
|
||||
# at /pb_data/logs/ — but we can't read that from here.
|
||||
#
|
||||
# Strategy:
|
||||
# a) Try to auth normally (works on subsequent runs once the account exists).
|
||||
# b) If that returns 400/401, PocketBase is fresh. Use the install token
|
||||
# obtained from the /_/ redirect Location header (PocketBase v0.23+).
|
||||
|
||||
log "ensuring superuser $PB_EMAIL exists ..."
|
||||
|
||||
# Try to get the install token from the /_/ redirect Location header.
|
||||
LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB_URL/_/" 2>/dev/null || true)
|
||||
if echo "$LOCATION" | grep -q "pbinstal/"; then
|
||||
INSTALL_TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n')
|
||||
log "install token found — creating superuser via install endpoint"
|
||||
curl -sf -X POST "$PB_URL/api/collections/_superusers/records" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $INSTALL_TOKEN" \
|
||||
-d "{\"email\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\",\"passwordConfirm\":\"$PB_PASSWORD\"}" \
|
||||
> /dev/null 2>&1 || true
|
||||
log "superuser create attempted (may already exist)"
|
||||
fi
|
||||
|
||||
# ─── 3. Authenticate and obtain a superuser token ────────────────────────────
|
||||
log "authenticating as $PB_EMAIL ..."
|
||||
AUTH_RESPONSE=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}")
|
||||
|
||||
TOKEN=$(echo "$AUTH_RESPONSE" | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then
|
||||
log "ERROR: failed to obtain auth token. Response: $AUTH_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
log "auth token obtained"
|
||||
|
||||
# ─── 4. Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
create_collection() {
|
||||
NAME="$1"
|
||||
BODY="$2"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST "$PB_URL/api/collections" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "$BODY")
|
||||
case "$STATUS" in
|
||||
200|201) log "created collection: $NAME" ;;
|
||||
400|422) log "collection already exists (skipped): $NAME" ;;
|
||||
*) log "WARNING: unexpected status $STATUS for collection: $NAME" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ensure_field COLLECTION FIELD_NAME FIELD_TYPE
|
||||
#
|
||||
# Checks whether FIELD_NAME exists in COLLECTION's schema. If it is missing,
|
||||
# sends a PATCH with the full current fields list plus the new field appended.
|
||||
ensure_field() {
|
||||
COLL="$1"
|
||||
FIELD_NAME="$2"
|
||||
FIELD_TYPE="$3"
|
||||
|
||||
SCHEMA=$(curl -sf \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
"$PB_URL/api/collections/$COLL" 2>/dev/null)
|
||||
|
||||
# Use python3 to reliably parse the JSON schema.
|
||||
PARSED=$(echo "$SCHEMA" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
fields = d.get('fields', [])
|
||||
exists = any(f.get('name') == '$FIELD_NAME' for f in fields)
|
||||
print('exists=' + str(exists))
|
||||
print('id=' + d.get('id', ''))
|
||||
if not exists:
|
||||
fields.append({'name': '$FIELD_NAME', 'type': '$FIELD_TYPE'})
|
||||
print('fields=' + json.dumps(fields))
|
||||
except Exception as e:
|
||||
print('error=' + str(e))
|
||||
" 2>/dev/null)
|
||||
|
||||
if echo "$PARSED" | grep -q "^exists=True"; then
|
||||
log "field $COLL.$FIELD_NAME already exists — skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
COLLECTION_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
|
||||
if [ -z "$COLLECTION_ID" ]; then
|
||||
log "WARNING: could not get id for collection $COLL — skipping ensure_field"
|
||||
return
|
||||
fi
|
||||
|
||||
NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//')
|
||||
PATCH_BODY="{\"fields\":${NEW_FIELDS}}"
|
||||
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X PATCH "$PB_URL/api/collections/$COLLECTION_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "$PATCH_BODY")
|
||||
case "$STATUS" in
|
||||
200|201) log "patched $COLL — added field: $FIELD_NAME ($FIELD_TYPE)" ;;
|
||||
*) log "WARNING: patch returned $STATUS when adding $FIELD_NAME to $COLL" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── 5. Create collections (idempotent — skips if already exist) ─────────────
|
||||
|
||||
create_collection "books" '{
|
||||
"name": "books",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "title", "type": "text", "required": true},
|
||||
{"name": "author", "type": "text"},
|
||||
{"name": "cover", "type": "text"},
|
||||
{"name": "status", "type": "text"},
|
||||
{"name": "genres", "type": "json"},
|
||||
{"name": "summary", "type": "text"},
|
||||
{"name": "total_chapters", "type": "number"},
|
||||
{"name": "source_url", "type": "text"},
|
||||
{"name": "ranking", "type": "number"},
|
||||
{"name": "meta_updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "chapters_idx" '{
|
||||
"name": "chapters_idx",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "number", "type": "number", "required": true},
|
||||
{"name": "title", "type": "text"},
|
||||
{"name": "date_label", "type": "text"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "ranking" '{
|
||||
"name": "ranking",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "rank", "type": "number", "required": true},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "title", "type": "text"},
|
||||
{"name": "author", "type": "text"},
|
||||
{"name": "cover", "type": "text"},
|
||||
{"name": "status", "type": "text"},
|
||||
{"name": "genres", "type": "json"},
|
||||
{"name": "source_url", "type": "text"},
|
||||
{"name": "updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "progress" '{
|
||||
"name": "progress",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "chapter", "type": "number"},
|
||||
{"name": "updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "audio_cache" '{
|
||||
"name": "audio_cache",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "cache_key", "type": "text", "required": true},
|
||||
{"name": "filename", "type": "text"},
|
||||
{"name": "updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "app_users" '{
|
||||
"name": "app_users",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "username", "type": "text", "required": true},
|
||||
{"name": "password_hash", "type": "text", "required": true},
|
||||
{"name": "role", "type": "text"},
|
||||
{"name": "created", "type": "date"},
|
||||
{"name": "avatar_url", "type": "text"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "user_settings" '{
|
||||
"name": "user_settings",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "auto_next", "type": "bool"},
|
||||
{"name": "voice", "type": "text"},
|
||||
{"name": "speed", "type": "number"},
|
||||
{"name": "updated", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
# ─── 6. Schema migrations (idempotent field additions) ───────────────────────
|
||||
# Ensures fields added after initial deploy are present in existing instances.
|
||||
|
||||
ensure_field "progress" "user_id" "text"
|
||||
ensure_field "progress" "audio_time" "number"
|
||||
ensure_field "user_settings" "user_id" "text"
|
||||
ensure_field "app_users" "avatar_url" "text"
|
||||
|
||||
create_collection "book_comments" '{
|
||||
"name": "book_comments",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "username", "type": "text"},
|
||||
{"name": "body", "type": "text", "required": true},
|
||||
{"name": "upvotes", "type": "number"},
|
||||
{"name": "downvotes", "type": "number"},
|
||||
{"name": "created", "type": "date"},
|
||||
{"name": "parent_id", "type": "text"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "comment_votes" '{
|
||||
"name": "comment_votes",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "comment_id", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "vote", "type": "text", "required": true}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "scraping_tasks" '{
|
||||
"name": "scraping_tasks",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "kind", "type": "text"},
|
||||
{"name": "target_url", "type": "text"},
|
||||
{"name": "from_chapter", "type": "number"},
|
||||
{"name": "to_chapter", "type": "number"},
|
||||
{"name": "worker_id", "type": "text"},
|
||||
{"name": "status", "type": "text", "required": true},
|
||||
{"name": "books_found", "type": "number"},
|
||||
{"name": "chapters_scraped", "type": "number"},
|
||||
{"name": "chapters_skipped", "type": "number"},
|
||||
{"name": "errors", "type": "number"},
|
||||
{"name": "error_message", "type": "text"},
|
||||
{"name": "started", "type": "date"},
|
||||
{"name": "finished", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "audio_jobs" '{
|
||||
"name": "audio_jobs",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "cache_key", "type": "text", "required": true},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "chapter", "type": "number", "required": true},
|
||||
{"name": "voice", "type": "text"},
|
||||
{"name": "worker_id", "type": "text"},
|
||||
{"name": "status", "type": "text", "required": true},
|
||||
{"name": "error_message", "type": "text"},
|
||||
{"name": "started", "type": "date"},
|
||||
{"name": "finished", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "user_library" '{
|
||||
"name": "user_library",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "user_id", "type": "text"},
|
||||
{"name": "slug", "type": "text", "required": true},
|
||||
{"name": "saved_at", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "user_sessions" '{
|
||||
"name": "user_sessions",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "user_id", "type": "text", "required": true},
|
||||
{"name": "session_id", "type": "text", "required": true},
|
||||
{"name": "user_agent", "type": "text"},
|
||||
{"name": "ip", "type": "text"},
|
||||
{"name": "created_at", "type": "date"},
|
||||
{"name": "last_seen", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
create_collection "user_subscriptions" '{
|
||||
"name": "user_subscriptions",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "follower_id", "type": "text", "required": true},
|
||||
{"name": "followee_id", "type": "text", "required": true},
|
||||
{"name": "created", "type": "date"}
|
||||
]
|
||||
}'
|
||||
|
||||
# ─── 7. Post-initial-deploy field additions ───────────────────────────────────
|
||||
# heartbeat_at is used by the backend runner to detect stale tasks.
|
||||
ensure_field "scraping_tasks" "heartbeat_at" "date"
|
||||
ensure_field "audio_jobs" "heartbeat_at" "date"
|
||||
|
||||
log "all collections ready"
|
||||
@@ -1,39 +0,0 @@
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
file: .runner
|
||||
capacity: 1
|
||||
envs: {}
|
||||
env_file: .env
|
||||
timeout: 3h
|
||||
shutdown_timeout: 0s
|
||||
insecure: false
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
github_mirror: ''
|
||||
labels:
|
||||
- "macos-latest:host"
|
||||
- "macos-14:host"
|
||||
|
||||
cache:
|
||||
enabled: true
|
||||
dir: ""
|
||||
host: "__HOST_IP__"
|
||||
port: 8088
|
||||
external_server: ""
|
||||
|
||||
container:
|
||||
network: ""
|
||||
privileged: false
|
||||
options: ""
|
||||
workdir_parent: ""
|
||||
valid_volumes: []
|
||||
docker_host: ""
|
||||
force_pull: false
|
||||
force_rebuild: false
|
||||
require_docker: false
|
||||
docker_timeout: 0s
|
||||
|
||||
host:
|
||||
workdir_parent: ""
|
||||
@@ -1,109 +0,0 @@
|
||||
# Example configuration file, it's safe to copy this as the default config file without any modification.
|
||||
|
||||
# You don't have to copy this file to your instance,
|
||||
# just run `./act_runner generate-config > config.yaml` to generate a config file.
|
||||
|
||||
log:
|
||||
# The level of logging, can be trace, debug, info, warn, error, fatal
|
||||
level: info
|
||||
|
||||
runner:
|
||||
# Where to store the registration result.
|
||||
file: .runner
|
||||
# Execute how many tasks concurrently at the same time.
|
||||
capacity: 1
|
||||
# Extra environment variables to run jobs.
|
||||
envs:
|
||||
# Extra environment variables to run jobs from a file.
|
||||
# It will be ignored if it's empty or the file doesn't exist.
|
||||
env_file: .env
|
||||
# The timeout for a job to be finished.
|
||||
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
|
||||
# So the job could be stopped by the Gitea instance if its timeout is shorter than this.
|
||||
timeout: 3h
|
||||
# The timeout for the runner to wait for running jobs to finish when shutting down.
|
||||
# Any running jobs that haven't finished after this timeout will be cancelled.
|
||||
shutdown_timeout: 0s
|
||||
# Whether skip verifying the TLS certificate of the Gitea instance.
|
||||
insecure: false
|
||||
# The timeout for fetching the job from the Gitea instance.
|
||||
fetch_timeout: 5s
|
||||
# The interval for fetching the job from the Gitea instance.
|
||||
fetch_interval: 2s
|
||||
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
|
||||
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
|
||||
# and github_mirror is not empty. In this case,
|
||||
# it replaces https://github.com with the value here, which is useful for some special network environments.
|
||||
github_mirror: ''
|
||||
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
||||
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||
# Find more images provided by Gitea at https://gitea.com/gitea/runner-images .
|
||||
# If it's empty when registering, it will ask for inputting labels.
|
||||
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
||||
labels:
|
||||
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||
- "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
|
||||
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
||||
|
||||
cache:
|
||||
# Enable cache server to use actions/cache.
|
||||
enabled: true
|
||||
# The directory to store the cache data.
|
||||
# If it's empty, the cache data will be stored in $HOME/.cache/actcache.
|
||||
dir: ""
|
||||
# The host of the cache server.
|
||||
# It's not for the address to listen, but the address to connect from job containers.
|
||||
# So 0.0.0.0 is a bad choice, leave it empty to detect automatically.
|
||||
host: ""
|
||||
# The port of the cache server.
|
||||
# 0 means to use a random available port.
|
||||
port: 8088
|
||||
# The external cache server URL. Valid only when enable is true.
|
||||
# If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
|
||||
# The URL should generally end with "/".
|
||||
external_server: ""
|
||||
|
||||
container:
|
||||
# Specifies the network to which the container will connect.
|
||||
# Could be host, bridge or the name of a custom network.
|
||||
# If it's empty, act_runner will create a network automatically.
|
||||
network: ""
|
||||
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
||||
privileged: false
|
||||
# Any other options to be used when the container is started (e.g., --add-host=my.gitea.url:host-gateway).
|
||||
options:
|
||||
|
||||
# The parent directory of a job's working directory.
|
||||
# NOTE: There is no need to add the first '/' of the path as act_runner will add it automatically.
|
||||
# If the path starts with '/', the '/' will be trimmed.
|
||||
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
|
||||
# If it's empty, /workspace will be used.
|
||||
workdir_parent:
|
||||
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
|
||||
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
|
||||
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, youshould change the config to:
|
||||
# valid_volumes:
|
||||
# - data
|
||||
# - /src/*.json
|
||||
# If you want to allow any volume, please use the following configuration:
|
||||
# valid_volumes:
|
||||
# - '**'
|
||||
valid_volumes: []
|
||||
# Overrides the docker client host with the specified one.
|
||||
# If it's empty, act_runner will find an available docker host automatically.
|
||||
# If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
||||
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
|
||||
docker_host: ""
|
||||
# Pull docker image(s) even if already present
|
||||
force_pull: false
|
||||
# Rebuild docker image(s) even if already present
|
||||
force_rebuild: false
|
||||
# Always require a reachable docker daemon, even if not required by act_runner
|
||||
require_docker: false
|
||||
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner
|
||||
docker_timeout: 0s
|
||||
|
||||
host:
|
||||
# The parent directory of a job's working directory.
|
||||
# If it's empty, $HOME/.cache/act/ will be used.
|
||||
workdir_parent:
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── usage ─────────────────────────────────────────────────────────────────────
|
||||
usage() {
|
||||
echo "Usage: $0 <runner-name>"
|
||||
echo " runner-name: runner-node-1 | runner-node-2 | runner-node-3"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -ne 1 ]] && usage
|
||||
|
||||
RUNNER_NAME="$1"
|
||||
|
||||
# validate
|
||||
case "$RUNNER_NAME" in
|
||||
runner-node-1|runner-node-2|runner-node-3) ;;
|
||||
*) echo "ERROR: unknown runner name '$RUNNER_NAME'"; usage ;;
|
||||
esac
|
||||
|
||||
# ── config ────────────────────────────────────────────────────────────────────
|
||||
CACHE_PORT=8088
|
||||
GITEA_URL="https://gitea.kalekber.cc/"
|
||||
REGISTRATION_TOKEN="AboxpDKWx7gizwJ9xeheHVqKjj9J9N9BgyX96wvu"
|
||||
IMAGE="docker.io/gitea/act_runner:latest"
|
||||
DATA_DIR="$PWD/data/$RUNNER_NAME"
|
||||
CFG_PATH="$DATA_DIR/config.yaml"
|
||||
|
||||
# ── detect THIS machine's LAN IP ──────────────────────────────────────────────
|
||||
HOST_IP=$(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1); exit}')
|
||||
if [[ -z "$HOST_IP" ]]; then
|
||||
echo "ERROR: could not detect host LAN IP" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Host LAN IP: $HOST_IP"
|
||||
|
||||
# ── generate config.yaml ──────────────────────────────────────────────────────
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
docker run --rm --entrypoint="" "$IMAGE" \
|
||||
act_runner generate-config > "$CFG_PATH"
|
||||
|
||||
awk -v host="$HOST_IP" -v port="$CACHE_PORT" '
|
||||
/^cache:/ { in_cache=1 }
|
||||
in_cache && /enabled:/ { $0 = " enabled: true" }
|
||||
in_cache && /dir:/ { $0 = " dir: \"/data/cache\"" }
|
||||
in_cache && /host:/ { $0 = " host: \"" host "\"" }
|
||||
in_cache && /port:/ { $0 = " port: " port; in_cache=0 }
|
||||
{ print }
|
||||
' "$CFG_PATH" > "${CFG_PATH}.tmp" && mv "${CFG_PATH}.tmp" "$CFG_PATH"
|
||||
|
||||
echo "Config written to $CFG_PATH (cache $HOST_IP:$CACHE_PORT)"
|
||||
|
||||
# ── stop + remove old container if exists ────────────────────────────────────
|
||||
if docker inspect "$RUNNER_NAME" &>/dev/null; then
|
||||
echo "Removing existing $RUNNER_NAME..."
|
||||
docker stop "$RUNNER_NAME" || true
|
||||
docker rm "$RUNNER_NAME" || true
|
||||
fi
|
||||
|
||||
# ── start runner ──────────────────────────────────────────────────────────────
|
||||
docker run \
|
||||
-v "$DATA_DIR:/data" \
|
||||
-v "$CFG_PATH:/config.yaml" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e CONFIG_FILE=/config.yaml \
|
||||
-e GITEA_INSTANCE_URL="$GITEA_URL" \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN="$REGISTRATION_TOKEN" \
|
||||
-e GITEA_RUNNER_NAME="$RUNNER_NAME" \
|
||||
-p "${CACHE_PORT}:${CACHE_PORT}" \
|
||||
--restart unless-stopped \
|
||||
--name "$RUNNER_NAME" \
|
||||
-d "$IMAGE"
|
||||
|
||||
echo "Runner $RUNNER_NAME started"
|
||||
docker ps --filter "name=$RUNNER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── setup_runner_mac.sh ───────────────────────────────────────────────────────
|
||||
# Sets up act_runner as a host-mode runner on macOS for iOS CI/CD.
|
||||
# Installs the binary, generates a config, registers against Gitea,
|
||||
# and installs a LaunchDaemon so the runner starts at boot.
|
||||
#
|
||||
# Usage: sudo ./setup_runner_mac.sh <runner-name>
|
||||
# Example: sudo ./setup_runner_mac.sh mac-runner-1
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
echo "Usage: sudo $0 <runner-name>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -ne 1 ]] && usage
|
||||
[[ "$EUID" -ne 0 ]] && { echo "ERROR: run with sudo"; exit 1; }
|
||||
|
||||
RUNNER_NAME="$1"
|
||||
GITEA_URL="https://gitea.kalekber.cc/"
|
||||
REGISTRATION_TOKEN="AboxpDKWx7gizwJ9xeheHVqKjj9J9N9BgyX96wvu"
|
||||
CACHE_PORT=8088
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
WORK_DIR="/var/lib/act_runner"
|
||||
CONFIG_PATH="/etc/act_runner/config.yaml"
|
||||
LAUNCHDAEMON_PLIST="/Library/LaunchDaemons/com.gitea.act_runner.plist"
|
||||
|
||||
# ── detect Mac LAN IP ─────────────────────────────────────────────────────────
|
||||
HOST_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "")
|
||||
if [[ -z "$HOST_IP" ]]; then
|
||||
echo "ERROR: could not detect LAN IP via en0/en1. Set cache.host manually in $CONFIG_PATH"
|
||||
HOST_IP="127.0.0.1"
|
||||
fi
|
||||
echo "Host LAN IP: $HOST_IP"
|
||||
|
||||
# ── download act_runner binary ────────────────────────────────────────────────
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$ARCH" == "arm64" ]]; then
|
||||
BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-arm64"
|
||||
else
|
||||
BINARY_URL="https://gitea.com/gitea/act_runner/releases/download/v0.3.0/act_runner-0.3.0-darwin-amd64"
|
||||
fi
|
||||
|
||||
echo "Downloading act_runner for $ARCH..."
|
||||
curl -fsSL "$BINARY_URL" -o "$INSTALL_DIR/act_runner"
|
||||
chmod +x "$INSTALL_DIR/act_runner"
|
||||
echo "Installed: $("$INSTALL_DIR/act_runner" --version)"
|
||||
|
||||
# ── create working directory ──────────────────────────────────────────────────
|
||||
mkdir -p "$WORK_DIR"
|
||||
mkdir -p "$(dirname "$CONFIG_PATH")"
|
||||
|
||||
# ── install config ────────────────────────────────────────────────────────────
|
||||
# Use the checked-in static config and substitute the LAN IP placeholder.
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
sed "s/__HOST_IP__/$HOST_IP/" "$SCRIPT_DIR/runner-config-mac.yaml" > "$CONFIG_PATH"
|
||||
echo "Config written: labels=macos-latest:host, cache=$HOST_IP:$CACHE_PORT"
|
||||
|
||||
# ── register runner ───────────────────────────────────────────────────────────
|
||||
echo "Registering runner '$RUNNER_NAME'..."
|
||||
"$INSTALL_DIR/act_runner" register \
|
||||
--no-interactive \
|
||||
--config "$CONFIG_PATH" \
|
||||
--instance "$GITEA_URL" \
|
||||
--token "$REGISTRATION_TOKEN" \
|
||||
--name "$RUNNER_NAME" \
|
||||
--labels "macos-latest:host,macos-14:host"
|
||||
|
||||
# Copy .runner file to work dir if it was created in cwd
|
||||
[[ -f ".runner" ]] && cp .runner "$WORK_DIR/.runner"
|
||||
|
||||
# ── install LaunchDaemon ──────────────────────────────────────────────────────
|
||||
# PATH must include Homebrew + Xcode tools so xcodebuild, xcrun, npm, etc. are found.
|
||||
HOMEBREW_PREFIX=$([ "$ARCH" = "arm64" ] && echo "/opt/homebrew" || echo "/usr/local")
|
||||
|
||||
cat > "$LAUNCHDAEMON_PLIST" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.gitea.act_runner</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${INSTALL_DIR}/act_runner</string>
|
||||
<string>daemon</string>
|
||||
<string>--config</string>
|
||||
<string>${CONFIG_PATH}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${WORK_DIR}</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${WORK_DIR}/act_runner.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${WORK_DIR}/act_runner.err</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>${HOMEBREW_PREFIX}/bin:${HOMEBREW_PREFIX}/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Applications/Xcode.app/Contents/Developer/usr/bin</string>
|
||||
<key>HOME</key>
|
||||
<string>${WORK_DIR}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
echo "LaunchDaemon written to $LAUNCHDAEMON_PLIST"
|
||||
|
||||
# ── load the daemon ───────────────────────────────────────────────────────────
|
||||
launchctl unload "$LAUNCHDAEMON_PLIST" 2>/dev/null || true
|
||||
launchctl load "$LAUNCHDAEMON_PLIST"
|
||||
echo "Runner '$RUNNER_NAME' started via LaunchDaemon"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " View logs: tail -f $WORK_DIR/act_runner.log"
|
||||
echo " Stop runner: sudo launchctl unload $LAUNCHDAEMON_PLIST"
|
||||
echo " Start runner: sudo launchctl load $LAUNCHDAEMON_PLIST"
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../ios/LibNovel"
|
||||
|
||||
echo "=== Testing CI-like signing process ==="
|
||||
|
||||
# 1. Install provisioning profile (simulate CI)
|
||||
PP_PATH=~/Downloads/LibNovel_Distribution.mobileprovision
|
||||
UUID=$(security cms -D -i "$PP_PATH" | plutil -extract UUID raw -)
|
||||
PROFILE_NAME=$(security cms -D -i "$PP_PATH" | plutil -extract Name raw -)
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp "$PP_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||
|
||||
echo "Installed profile: $PROFILE_NAME (UUID: $UUID)"
|
||||
|
||||
# 2. Generate Xcode project
|
||||
echo "Generating Xcode project..."
|
||||
xcodegen generate --spec project.yml --project .
|
||||
|
||||
# 3. List available provisioning profiles
|
||||
echo -e "\n=== Available provisioning profiles ==="
|
||||
ls -la ~/Library/MobileDevice/Provisioning\ Profiles/
|
||||
|
||||
# 4. Try building with xcodebuild using manual signing
|
||||
echo -e "\n=== Attempting archive with manual signing ==="
|
||||
xcodebuild archive \
|
||||
-scheme LibNovel \
|
||||
-project LibNovel.xcodeproj \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath /tmp/LibNovel.xcarchive \
|
||||
CODE_SIGN_STYLE=Manual \
|
||||
CODE_SIGN_IDENTITY="Apple Distribution: Kamil Alekberov (GHZXC6FVMU)" \
|
||||
DEVELOPMENT_TEAM=GHZXC6FVMU \
|
||||
PROVISIONING_PROFILE_SPECIFIER="$UUID" \
|
||||
| xcpretty || true
|
||||
|
||||
echo -e "\n=== Build complete ==="
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Simple iOS build test without fastlane
|
||||
# Run from project root: ./scripts/test-ios-build-simple.sh /path/to/profile.mobileprovision
|
||||
|
||||
set -e
|
||||
|
||||
PROFILE_PATH="$1"
|
||||
|
||||
if [ -z "$PROFILE_PATH" ]; then
|
||||
echo "Usage: $0 /path/to/profile.mobileprovision"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Step 1: Extract profile info ==="
|
||||
UUID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract UUID raw -)
|
||||
PROFILE_NAME=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Name raw -)
|
||||
TEAM_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract TeamIdentifier.0 raw -)
|
||||
|
||||
echo "Profile Name: $PROFILE_NAME"
|
||||
echo "UUID: $UUID"
|
||||
echo "Team ID: $TEAM_ID"
|
||||
|
||||
echo ""
|
||||
echo "=== Step 2: Install profile ==="
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||
echo "✓ Installed"
|
||||
|
||||
echo ""
|
||||
echo "=== Step 3: Check signing identities ==="
|
||||
security find-identity -v -p codesigning
|
||||
|
||||
echo ""
|
||||
echo "=== Step 4: Generate Xcode project ==="
|
||||
cd ios/LibNovel
|
||||
export USER=runner
|
||||
xcodegen generate --spec project.yml --project .
|
||||
echo "✓ Project generated"
|
||||
|
||||
echo ""
|
||||
echo "=== Step 5: Try automatic signing build ==="
|
||||
xcodebuild archive \
|
||||
-project LibNovel.xcodeproj \
|
||||
-scheme LibNovel \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath ./build/LibNovel.xcarchive \
|
||||
-allowProvisioningUpdates \
|
||||
CODE_SIGN_STYLE=Automatic \
|
||||
DEVELOPMENT_TEAM="$TEAM_ID"
|
||||
|
||||
echo ""
|
||||
echo "=== ✓ BUILD SUCCEEDED! ==="
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Local build test script
|
||||
# Run from the project root: ./scripts/test-ios-build.sh /path/to/your/profile.mobileprovision
|
||||
|
||||
set -e
|
||||
|
||||
PROFILE_PATH="$1"
|
||||
|
||||
if [ -z "$PROFILE_PATH" ]; then
|
||||
echo "Usage: $0 /path/to/profile.mobileprovision"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$PROFILE_PATH" ]; then
|
||||
echo "Error: Profile not found at $PROFILE_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Extracting profile info ==="
|
||||
UUID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract UUID raw -)
|
||||
PROFILE_NAME=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Name raw -)
|
||||
BUNDLE_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract Entitlements.application-identifier raw - 2>/dev/null | sed 's/.*\.//')
|
||||
TEAM_ID=$(security cms -D -i "$PROFILE_PATH" | plutil -extract TeamIdentifier.0 raw -)
|
||||
|
||||
echo "Profile Name: $PROFILE_NAME"
|
||||
echo "UUID: $UUID"
|
||||
echo "Bundle ID: $BUNDLE_ID"
|
||||
echo "Team ID: $TEAM_ID"
|
||||
|
||||
echo ""
|
||||
echo "=== Installing provisioning profile ==="
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
|
||||
echo "Installed to: ~/Library/MobileDevice/Provisioning Profiles/$UUID.mobileprovision"
|
||||
|
||||
echo ""
|
||||
echo "=== Listing signing identities ==="
|
||||
security find-identity -v -p codesigning
|
||||
|
||||
echo ""
|
||||
echo "=== Navigating to iOS project ==="
|
||||
cd ios/LibNovel
|
||||
|
||||
echo ""
|
||||
echo "=== Generating Xcode project ==="
|
||||
xcodegen generate --spec project.yml --project .
|
||||
|
||||
echo ""
|
||||
echo "=== Testing fastlane build ==="
|
||||
export USER=runner
|
||||
export BUILD_NUMBER=999
|
||||
export PROVISIONING_PROFILE_NAME="$PROFILE_NAME"
|
||||
|
||||
# Run fastlane beta lane
|
||||
fastlane beta --verbose
|
||||
|
||||
echo ""
|
||||
echo "=== Build succeeded! ==="
|
||||
Reference in New Issue
Block a user