Compare commits

...

2 Commits

Author SHA1 Message Date
cheney
e11814048c feat: 内容源管理优化与条目编辑功能
All checks were successful
Build and Deploy / deploy (push) Successful in 51s
- 内容源管理:去掉人工录入卡片,AI 密钥保存后可返显并支持小眼睛切换,布局优化
- 内容管理:新增编辑按钮与编辑页,可调整条目全部字段与标签
2026-08-31 18:02:32 +08:00
cheney
2422ae452b refactor: 改用 start.sh 直接部署,去掉 docker compose 2026-08-31 15:05:44 +08:00
13 changed files with 250 additions and 112 deletions

View File

@ -7,48 +7,23 @@ on:
branches: [main] branches: [main]
jobs: jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: |
docker build -t awesome-index:${{ github.sha }} .
docker tag awesome-index:${{ github.sha }} awesome-index:latest
deploy: deploy:
needs: build
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: 114.134.187.170
username: root
password: ${{ secrets.SERVER_PASSWORD }}
script: |
# 创建部署目录
mkdir -p /app/awesome/data /app/awesome/logs
- name: Copy files to server - name: Copy files to server
uses: appleboy/scp-action@master uses: appleboy/scp-action@master
with: with:
host: 114.134.187.170 host: 114.134.187.170
username: root username: root
password: ${{ secrets.SERVER_PASSWORD }} password: ${{ secrets.SERVER_PASSWORD }}
source: "docker-compose.yml,start.sh" source: "package.json,package-lock.json,src,public,.env.example,start.sh"
target: "/app/awesome/" target: "/app/awesome/"
- name: Load and start container - name: Deploy and start service
uses: appleboy/ssh-action@master uses: appleboy/ssh-action@master
with: with:
host: 114.134.187.170 host: 114.134.187.170
@ -57,14 +32,9 @@ jobs:
script: | script: |
cd /app/awesome cd /app/awesome
# 停止旧容器 # 如不存在 .env 则从 example 复制
docker compose down --remove-orphans 2>/dev/null || true [ ! -f .env ] && cp .env.example .env
# 启动新容器 # 执行启动脚本
docker compose up -d chmod +x start.sh
bash start.sh
# 等待启动
sleep 10
# 健康检查
curl -sf http://localhost:3000/healthz && echo " - 服务已启动" || echo " - 启动失败"

View File

@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
# Awesome Index 部署脚本 # Awesome Index 部署脚本(直接部署,不使用 Docker
# 服务器: 114.134.187.170 # 服务器: 114.134.187.170
set -e set -e
@ -23,64 +23,18 @@ echo "1. 创建部署目录..."
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \ sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \
"mkdir -p $DEPLOY_PATH/data $DEPLOY_PATH/logs" "mkdir -p $DEPLOY_PATH/data $DEPLOY_PATH/logs"
echo "2. 上传文件..." echo "2. 上传源码..."
sshpass -p "$REMOTE_PASS" scp -o StrictHostKeyChecking=no \ sshpass -p "$REMOTE_PASS" scp -o StrictHostKeyChecking=no -r \
docker-compose.yml start.sh .env.example \ package.json package-lock.json src public .env.example start.sh \
"$REMOTE_USER@$REMOTE_HOST:$DEPLOY_PATH/" "$REMOTE_USER@$REMOTE_HOST:$DEPLOY_PATH/"
echo "3. 重命名 .env.example 为 .env..." echo "3. 重命名 .env.example 为 .env(如不存在)..."
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \ sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \
"cd $DEPLOY_PATH && [ ! -f .env ] && cp .env.example .env" "cd $DEPLOY_PATH && [ ! -f .env ] && cp .env.example .env"
echo "4. 在服务器上构建并启动..." echo "4. 在服务器上执行启动脚本..."
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" << 'EOF' sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no "$REMOTE_USER@$REMOTE_HOST" \
cd /app/awesome "cd $DEPLOY_PATH && chmod +x start.sh && bash start.sh"
# 创建 Dockerfile 用于服务器构建
cat > Dockerfile << 'DOCKERFILE'
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
COPY public ./public
VOLUME ["/app/data"]
EXPOSE 3000
USER node
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
CMD node -e "fetch('http://localhost:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "src/index.js"]
DOCKERFILE
# 构建镜像
docker build -t awesome-index:latest .
# 停止旧容器
docker compose down --remove-orphans 2>/dev/null || true
# 启动新容器
docker compose up -d
echo "等待服务启动..."
sleep 10
# 健康检查
if curl -sf http://localhost:3000/healthz > /dev/null 2>&1; then
echo "✓ 服务启动成功!"
echo " 访问: http://$REMOTE_HOST:3000"
echo " 管理: http://$REMOTE_HOST:3000/admin"
else
echo "✗ 服务启动失败,查看日志:"
docker compose logs
exit 1
fi
EOF
echo "" echo ""
echo "=== 部署完成 ===" echo "=== 部署完成 ==="

View File

@ -1594,6 +1594,32 @@ button.chip:hover {
.field .input { .field .input {
width: 100%; width: 100%;
} }
.ai-key-field {
position: relative;
flex: 1;
min-width: 0;
}
.ai-key-field .input {
width: 100%;
padding-right: 42px;
}
.eye-toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: 0;
background: none;
cursor: pointer;
color: var(--ink-dim);
}
.eye-toggle:hover {
color: var(--ink);
}
.auth-row { .auth-row {
margin-top: 14px; margin-top: 14px;
display: flex; display: flex;

View File

@ -139,6 +139,19 @@
}); });
} }
document.querySelectorAll("[data-eye-toggle]").forEach(function (btn) {
btn.addEventListener("click", function () {
var input = btn.closest("form").querySelector("input[name=key]");
var show = input.type === "password";
input.type = show ? "text" : "password";
btn.setAttribute("aria-label", show ? "隐藏密钥" : "显示密钥");
var open = btn.querySelector(".eye-open");
var closed = btn.querySelector(".eye-closed");
if (open) open.hidden = show;
if (closed) closed.hidden = !show;
});
});
document.querySelectorAll("form.source-config").forEach(function (form) { document.querySelectorAll("form.source-config").forEach(function (form) {
form.addEventListener("submit", function (e) { form.addEventListener("submit", function (e) {
e.preventDefault(); e.preventDefault();

View File

@ -80,6 +80,7 @@ export async function seedTagsAndEntries() {
export async function runMigrate(config) { export async function runMigrate(config) {
await runSchema(); await runSchema();
await run(`ALTER TABLE sources ADD COLUMN IF NOT EXISTS api_key VARCHAR`);
const admin = await findByUsername("admin"); const admin = await findByUsername("admin");
if (!admin) { if (!admin) {

View File

@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS sources (
adapter VARCHAR, adapter VARCHAR,
cron_expr VARCHAR DEFAULT '0 9 * * 1', cron_expr VARCHAR DEFAULT '0 9 * * 1',
api_key_hash VARCHAR, api_key_hash VARCHAR,
api_key VARCHAR,
direct_publish BOOLEAN NOT NULL DEFAULT FALSE, direct_publish BOOLEAN NOT NULL DEFAULT FALSE,
last_run_at TIMESTAMPTZ, last_run_at TIMESTAMPTZ,
last_run_status VARCHAR last_run_status VARCHAR

View File

@ -6,7 +6,6 @@ import * as commentService from "../services/comment.service.js";
import * as sourceService from "../services/source.service.js"; import * as sourceService from "../services/source.service.js";
import * as userService from "../services/user.service.js"; import * as userService from "../services/user.service.js";
import { ENTRY_TYPES } from "../services/entry.service.js"; import { ENTRY_TYPES } from "../services/entry.service.js";
export const pagesRouter = Router(); export const pagesRouter = Router();
function csv(v) { function csv(v) {
@ -131,3 +130,51 @@ pagesRouter.get("/admin", async (req, res, next) => {
next(e); next(e);
} }
}); });
pagesRouter.get("/admin/entries/:id/edit", async (req, res, next) => {
try {
if (!req.session.userId) return res.redirect("/login");
const entry = await entryService.getById(Number(req.params.id));
if (!entry) throw Object.assign(new Error("条目不存在"), { status: 404 });
const tagTreeAdmin = await tagService.buildTree();
res.render("admin-entry-edit", {
title: `编辑 · ${entry.title} · Awesome Index`,
user: { username: req.session.username, role: req.session.role },
isAdmin: req.session.role === "admin",
entry,
tagTreeAdmin,
types: ENTRY_TYPES,
});
} catch (e) {
next(e);
}
});
pagesRouter.post("/admin/entries/:id/edit", async (req, res, next) => {
try {
if (!req.session.userId) return res.redirect("/login");
const id = Number(req.params.id);
const tags = Array.isArray(req.body.tags) ? req.body.tags : req.body.tags ? [req.body.tags] : [];
const newTags = String(req.body.new_tags || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const patch = {
title: String(req.body.title || "").trim(),
type: req.body.type,
description_md: String(req.body.description_md || ""),
url: String(req.body.url || "").trim(),
license: String(req.body.license || "").trim(),
stars: Number(req.body.stars) || 0,
tags: [...new Set([...tags, ...newTags])],
};
await entryService.updateEntry(id, patch, { isHumanEditor: true });
const status = req.body.status;
if (status && entryService.STATUSES.includes(status)) {
await entryService.setStatusDirect(id, status);
}
res.redirect("/admin#sec-content");
} catch (e) {
next(e);
}
});

View File

@ -144,6 +144,19 @@ export async function setStatus(id, status) {
return getById(id); return getById(id);
} }
export async function setStatusDirect(id, status) {
const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]);
if (!entry) throw httpError(404, "条目不存在");
if (!STATUSES.includes(status)) throw httpError(400, `未知状态:${status}`);
await run(
`UPDATE entries SET status = ?, updated_at = now(),
verified_at = CASE WHEN ? = 'active' THEN now() ELSE verified_at END
WHERE id = ?`,
[status, status, id]
);
return getById(id);
}
export async function deleteEntry(id) { export async function deleteEntry(id) {
await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [id]); await run(`DELETE FROM entry_tags WHERE entry_id = ?`, [id]);
await run(`DELETE FROM comments WHERE entry_id = ?`, [id]); await run(`DELETE FROM comments WHERE entry_id = ?`, [id]);

View File

@ -13,9 +13,9 @@ export async function ensureBuiltins({ aiIngestKey }) {
const ai = await get(`SELECT id FROM sources WHERE name = 'AI 接口'`); const ai = await get(`SELECT id FROM sources WHERE name = 'AI 接口'`);
if (!ai) { if (!ai) {
await run( await run(
`INSERT INTO sources (kind, name, enabled, api_key_hash, direct_publish) `INSERT INTO sources (kind, name, enabled, api_key_hash, api_key, direct_publish)
VALUES ('ai', 'AI 接口', ?, ?, FALSE)`, VALUES ('ai', 'AI 接口', ?, ?, ?, FALSE)`,
[Boolean(aiIngestKey), keyHash(aiIngestKey)] [Boolean(aiIngestKey), keyHash(aiIngestKey), aiIngestKey]
); );
} }
} }
@ -68,9 +68,10 @@ export async function updateSource(id, patch) {
export async function setAiKey(key) { export async function setAiKey(key) {
const ai = await get(`SELECT id FROM sources WHERE kind = 'ai'`); const ai = await get(`SELECT id FROM sources WHERE kind = 'ai'`);
if (!ai) return; if (!ai) return;
await run(`UPDATE sources SET enabled = ?, api_key_hash = ? WHERE id = ?`, [ await run(`UPDATE sources SET enabled = ?, api_key_hash = ?, api_key = ? WHERE id = ?`, [
Boolean(key), Boolean(key),
keyHash(key), keyHash(key),
key,
ai.id, ai.id,
]); ]);
} }

View File

@ -0,0 +1,90 @@
<%- include('partials/head') %>
<%- include('partials/topbar', { active: 'admin' }) %>
<div class="admin-shell">
<nav class="admin-nav" aria-label="管理分区">
<h4>管理台 · <%= user.username %></h4>
<a href="/admin" style="display:flex;align-items:center;gap:10px;text-decoration:none;color:inherit;border-radius:8px;padding:10px 12px;font-size:14px;font-weight:600;background:var(--ink);color:var(--paper)">← 返回管理台</a>
</nav>
<main class="admin-main">
<p class="anno">// edit · entry #<%= entry.id %> · 保存后即时生效</p>
<h1>编辑条目</h1>
<p class="admin-desc"><a href="/entries/<%= entry.slug %>" target="_blank">预览:<%= entry.title %></a></p>
<form id="entry-edit-form" method="post" action="/admin/entries/<%= entry.id %>/edit" style="margin-top:22px;display:grid;gap:18px;max-width:820px">
<div class="panel-box">
<h3>基础信息</h3>
<div class="form-row">
<div class="field" style="flex:2"><label for="f-title">标题</label>
<input class="input" id="f-title" name="title" value="<%= entry.title %>" required /></div>
<div class="field" style="flex:1"><label for="f-type">类型</label>
<select class="select" id="f-type" name="type" style="width:100%">
<% for (const t of types) { %>
<option value="<%= t %>" <%= entry.type === t ? 'selected' : '' %>><%= t %></option>
<% } %>
</select></div>
</div>
<div class="form-row">
<div class="field" style="flex:2"><label for="f-url">链接 URL</label>
<input class="input" id="f-url" name="url" type="url" value="<%= entry.url %>" required /></div>
<div class="field" style="flex:1"><label for="f-license">License</label>
<input class="input" id="f-license" name="license" value="<%= entry.license || '' %>" /></div>
</div>
<div class="form-row">
<div class="field" style="flex:1"><label for="f-stars">Stars</label>
<input class="input" id="f-stars" name="stars" type="number" min="0" value="<%= entry.stars || 0 %>" /></div>
<div class="field" style="flex:1"><label for="f-status">状态</label>
<select class="select" id="f-status" name="status" style="width:100%">
<% for (const s of ['active','greyed','pending']) { %>
<option value="<%= s %>" <%= entry.status === s ? 'selected' : '' %>><%= s %></option>
<% } %>
</select></div>
</div>
</div>
<div class="panel-box">
<h3>内容描述Markdown</h3>
<textarea class="textarea" id="f-desc" name="description_md" rows="16"><%= entry.description_md || '' %></textarea>
<p style="font-size:12px;color:var(--ink-dim);margin-top:6px;font-family:var(--font-mono)">// 前台展示原文;保存后详情页与搜索都会生效</p>
</div>
<div class="panel-box">
<h3>标签</h3>
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:10px">勾选已有的标签;也可以把新标签名填到下方输入框(逗号分隔)</p>
<div style="border:1.5px solid var(--ink-faint);border-radius:8px;padding:14px;max-height:340px;overflow:auto">
<ul class="tag-tree" style="padding:0;margin:0">
<% for (const root of tagTreeAdmin) { %><%- include('partials/tag-check', { node: root, entry }) %><% } %>
</ul>
</div>
<div class="field" style="margin-top:12px"><label for="f-newtags">新增标签</label>
<input class="input" id="f-newtags" name="new_tags" placeholder="例如:自托管, Docker 就绪(留空则不改动)" />
</div>
</div>
<div style="display:flex;gap:10px;align-items:center">
<button class="btn btn-primary" type="submit">保存修改</button>
<a class="btn" href="/admin#sec-content">取消</a>
</div>
</form>
</main>
</div>
<script>
(function () {
var form = document.getElementById("entry-edit-form");
if (!form) return;
form.addEventListener("submit", function (e) {
var box = document.querySelector('input[name="tags"]');
if (!box) {
var hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = "tags";
hidden.value = "";
form.appendChild(hidden);
}
});
})();
</script>
<%- include('partials/footer-bar') %>

View File

@ -37,6 +37,7 @@
<td class="mono"><%= fmtDate(e.updated_at) %></td> <td class="mono"><%= fmtDate(e.updated_at) %></td>
<td> <td>
<div class="row-actions"> <div class="row-actions">
<a class="btn btn-sm" href="/admin/entries/<%= e.id %>/edit">编辑</a>
<% if (e.status !== 'active') { %> <% if (e.status !== 'active') { %>
<button class="btn btn-sm" type="button" data-action="entry-status" data-id="<%= e.id %>" data-status="active">上架</button> <button class="btn btn-sm" type="button" data-action="entry-status" data-id="<%= e.id %>" data-status="active">上架</button>
<% } else { %> <% } else { %>
@ -106,25 +107,29 @@
<p class="anno">// sources: human + ai + feed · 自动拉取经 ingest/ 采集层进入待核验队列</p> <p class="anno">// sources: human + ai + feed · 自动拉取经 ingest/ 采集层进入待核验队列</p>
<h1>内容源管理</h1> <h1>内容源管理</h1>
<% for (const s of sources) { %> <% for (const s of sources) { if (s.kind === 'human') continue; %>
<div class="panel-box" style="margin-top:20px"> <div class="panel-box" style="margin-top:20px">
<h3 style="display:flex;align-items:center;gap:10px"> <h3 style="display:flex;align-items:center;gap:10px">
<span class="dot <%= s.enabled ? '' : 'dot-off' %>"></span> <%= s.name %> <span class="dot <%= s.enabled ? '' : 'dot-off' %>"></span> <%= s.name %>
<span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">kind: <%= s.kind %> · 已收录 <%= Number(s.entry_count) %> 条</span> <span class="mono" style="font-weight:400;font-size:12px;color:var(--ink-dim)">kind: <%= s.kind %> · 已收录 <%= Number(s.entry_count) %> 条</span>
</h3> </h3>
<% if (s.kind === 'human') { %> <% if (s.kind === 'ai') { %>
<p style="font-size:13.5px;color:var(--ink-dim)">管理员与编辑在条目接口直接写入,始终可用,改动写入审计日志。</p>
<% } else if (s.kind === 'ai') { %>
<div class="switchline"> <div class="switchline">
AI 录入接口启用 AI 录入接口启用
<span class="mono"><%= s.enabled ? 'ON' : 'OFF' %></span> <span class="mono"><%= s.enabled ? 'ON' : 'OFF' %></span>
</div> </div>
<form id="ai-key-form"> <form id="ai-key-form" style="margin-top:12px">
<p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">设置新的 X-Ingest-Key留空 = 关闭通道)</p> <p style="font-size:13px;color:var(--ink-dim);margin-bottom:6px">设置新的 X-Ingest-Key留空 = 关闭通道)</p>
<div style="display:flex;gap:10px;max-width:420px"> <div style="display:flex;gap:10px;align-items:center;flex-wrap:nowrap">
<input class="input" name="key" type="text" placeholder="sk-…" /> <div class="ai-key-field">
<button class="btn btn-sm" type="submit">保存密钥</button> <input class="input" name="key" type="password" value="<%= s.api_key || '' %>" placeholder="sk-…" autocomplete="off" />
<button type="button" class="eye-toggle" data-eye-toggle aria-label="显示密钥">
<svg class="eye-open" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="eye-closed" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" hidden><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
</div>
<button class="btn btn-sm" type="submit" style="flex:none">保存密钥</button>
</div> </div>
</form> </form>
<p class="mono" style="margin-top:12px;font-size:12px;color:var(--ink-dim)"> <p class="mono" style="margin-top:12px;font-size:12px;color:var(--ink-dim)">

View File

@ -0,0 +1,12 @@
<li>
<label class="checkline" style="padding:3px 0">
<input type="checkbox" name="tags" value="<%= node.name %>" <%= (entry.tags || []).includes(node.name) ? 'checked' : '' %> />
<span><%= node.name %></span>
<% if (node.count !== undefined) { %><span class="cnt"><%= node.count %></span><% } %>
</label>
<% if (node.children && node.children.length) { %>
<ul class="tag-tree" style="padding-left:18px;margin:0">
<% for (const child of node.children) { %><%- include('tag-check', { node: child, entry }) %><% } %>
</ul>
<% } %>
</li>

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Awesome Index 启动脚本 # Awesome Index 启动脚本(直接部署,不使用 Docker
DEPLOY_PATH="$(cd "$(dirname "$0")" && pwd)" DEPLOY_PATH="$(cd "$(dirname "$0")" && pwd)"
cd "$DEPLOY_PATH" cd "$DEPLOY_PATH"
@ -11,17 +11,22 @@ if [ ! -f .env ]; then
exit 1 exit 1
fi fi
# 停止旧容器 # 安装依赖
echo "停止旧容器..." echo "安装依赖..."
docker compose down --remove-orphans 2>/dev/null || true npm ci --omit=dev --no-audit --no-fund
# 启动新容器 # 停止旧进程
echo "停止旧进程..."
pkill -f "node src/index.js" 2>/dev/null || true
sleep 1
# 启动服务
echo "启动 Awesome Index..." echo "启动 Awesome Index..."
docker compose up -d nohup node src/index.js > logs/app.log 2>&1 &
# 健康检查 # 健康检查
echo "等待服务就绪..." echo "等待服务就绪..."
sleep 5 sleep 2
MAX_RETRIES=10 MAX_RETRIES=10
RETRY_COUNT=0 RETRY_COUNT=0
@ -37,5 +42,5 @@ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
sleep 2 sleep 2
done done
echo "✗ 服务启动失败,请检查日志: docker compose logs" echo "✗ 服务启动失败,请检查日志: tail -50 logs/app.log"
exit 1 exit 1