feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { mkdirSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = join(__dirname, "..", "..")
|
||||
const baseVersion = process.env.BASE_VERSION ?? "1.0.0"
|
||||
const outputDir = process.env.RELEASE_OUTPUT_DIR
|
||||
? join(repoRoot, process.env.RELEASE_OUTPUT_DIR)
|
||||
: join(repoRoot, ".ci", "release")
|
||||
const repository = process.env.GITEA_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? ""
|
||||
const serverUrl = (process.env.GITEA_SERVER_URL ?? "https://git.shx.one").replace(/\/$/, "")
|
||||
|
||||
const MANIFEST_COMMIT_LIMIT = Number(process.env.RELEASE_MANIFEST_COMMIT_LIMIT ?? 6)
|
||||
const CONVENTIONAL_RE =
|
||||
/^(feat|fix|chore|docs|refactor|style|test|build|ci)(\([^)]+\))?!?:\s*(.+)$/i
|
||||
const MINOR_TYPES = new Set(["feat"])
|
||||
const PATCH_TYPES = new Set([
|
||||
"fix",
|
||||
"chore",
|
||||
"docs",
|
||||
"refactor",
|
||||
"style",
|
||||
"test",
|
||||
"build",
|
||||
"ci",
|
||||
])
|
||||
|
||||
function git(...args) {
|
||||
return execFileSync("git", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function parseSemver(version) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version)
|
||||
if (!match) {
|
||||
throw new Error(`Invalid semver: ${version}`)
|
||||
}
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
}
|
||||
}
|
||||
|
||||
function formatSemver(parts) {
|
||||
return `${parts.major}.${parts.minor}.${parts.patch}`
|
||||
}
|
||||
|
||||
function bumpVersion(version, bump) {
|
||||
const parts = parseSemver(version)
|
||||
if (bump === "major") {
|
||||
parts.major += 1
|
||||
parts.minor = 0
|
||||
parts.patch = 0
|
||||
return formatSemver(parts)
|
||||
}
|
||||
if (bump === "minor") {
|
||||
parts.minor += 1
|
||||
parts.patch = 0
|
||||
return formatSemver(parts)
|
||||
}
|
||||
parts.patch += 1
|
||||
return formatSemver(parts)
|
||||
}
|
||||
|
||||
function parseCommit(subject, body = "") {
|
||||
const match = CONVENTIONAL_RE.exec(subject.trim())
|
||||
const breaking =
|
||||
/BREAKING[ -]CHANGE:/i.test(body) ||
|
||||
(match !== null && /^[^:]+!:\s/.test(subject.trim()))
|
||||
|
||||
if (!match) {
|
||||
return { type: "other", scope: null, description: subject.trim(), bump: "patch" }
|
||||
}
|
||||
|
||||
const type = match[1].toLowerCase()
|
||||
const scope = match[2]?.slice(1, -1) ?? null
|
||||
const description = match[3].trim()
|
||||
let bump = "patch"
|
||||
|
||||
if (breaking) {
|
||||
bump = "major"
|
||||
} else if (MINOR_TYPES.has(type)) {
|
||||
bump = "minor"
|
||||
} else if (PATCH_TYPES.has(type)) {
|
||||
bump = "patch"
|
||||
}
|
||||
|
||||
return { type, scope, description, bump }
|
||||
}
|
||||
|
||||
function getAllTags() {
|
||||
try {
|
||||
return git("tag", "--list", "v*.*.*", "--sort=-v:refname")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviousTag(tag) {
|
||||
const tags = getAllTags()
|
||||
const index = tags.indexOf(tag)
|
||||
return index >= 0 && index + 1 < tags.length ? tags[index + 1] : null
|
||||
}
|
||||
|
||||
function getTagPublishedAt(tag) {
|
||||
try {
|
||||
return git("log", "-1", "--format=%aI", tag)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toManifestCommits(commits) {
|
||||
return commits.map((commit) => ({
|
||||
sha: commit.sha,
|
||||
shortSha: commit.shortSha,
|
||||
subject: commit.subject,
|
||||
author: commit.author,
|
||||
type: commit.type,
|
||||
}))
|
||||
}
|
||||
|
||||
function writeManifest(manifest) {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
writeFileSync(join(outputDir, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8")
|
||||
}
|
||||
|
||||
function getLatestTag() {
|
||||
return getAllTags()[0] ?? null
|
||||
}
|
||||
|
||||
function getCommitsSince(tag) {
|
||||
const range = tag ? `${tag}..HEAD` : "HEAD"
|
||||
const raw = git(
|
||||
"log",
|
||||
range,
|
||||
"--pretty=format:%H%x1f%h%x1f%an%x1f%s%x1f%b%x1e",
|
||||
)
|
||||
if (!raw) return []
|
||||
|
||||
return parseCommitLog(raw)
|
||||
}
|
||||
|
||||
function getRecentCommits(limit = MANIFEST_COMMIT_LIMIT) {
|
||||
const raw = git(
|
||||
"log",
|
||||
"-n",
|
||||
String(limit),
|
||||
"HEAD",
|
||||
"--pretty=format:%H%x1f%h%x1f%an%x1f%s%x1f%b%x1e",
|
||||
)
|
||||
if (!raw) return []
|
||||
|
||||
return parseCommitLog(raw)
|
||||
}
|
||||
|
||||
function parseCommitLog(raw) {
|
||||
return raw
|
||||
.split("\x1e")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const [sha, shortSha, author, subject, body] = entry.split("\x1f")
|
||||
const parsed = parseCommit(subject, body)
|
||||
return {
|
||||
sha,
|
||||
shortSha,
|
||||
author,
|
||||
subject,
|
||||
type: parsed.type,
|
||||
scope: parsed.scope,
|
||||
bump: parsed.bump,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function groupCommits(commits) {
|
||||
const groups = new Map()
|
||||
for (const commit of commits) {
|
||||
const key = commit.type
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key).push(commit)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function renderReleaseNotes({ tag, version, commits }) {
|
||||
const lines = [`# ${tag}`, "", `Версия **${version}**.`, ""]
|
||||
const groups = groupCommits(commits)
|
||||
const order = ["feat", "fix", "chore", "docs", "refactor", "style", "test", "build", "ci", "other"]
|
||||
|
||||
for (const type of order) {
|
||||
const items = groups.get(type)
|
||||
if (!items?.length) continue
|
||||
lines.push(`## ${type}`, "")
|
||||
for (const item of items) {
|
||||
lines.push(`- ${item.subject} (\`${item.shortSha}\`, ${item.author})`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
return `${lines.join("\n").trim()}\n`
|
||||
}
|
||||
|
||||
function buildReleaseUrl(tag) {
|
||||
if (!repository) return ""
|
||||
const [owner, repo] = repository.split("/")
|
||||
if (!owner || !repo) return ""
|
||||
return `${serverUrl}/${owner}/${repo}/releases/tag/${encodeURIComponent(tag)}`
|
||||
}
|
||||
|
||||
function writeGithubOutput(values) {
|
||||
const outputFile = process.env.GITHUB_OUTPUT
|
||||
if (!outputFile) return
|
||||
const lines = Object.entries(values)
|
||||
.map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "%0A")}`)
|
||||
.join("\n")
|
||||
writeFileSync(outputFile, `${lines}\n`, { encoding: "utf8", flag: "a" })
|
||||
}
|
||||
|
||||
function main() {
|
||||
const latestTag = getLatestTag()
|
||||
const baseFromTag = latestTag?.replace(/^v/, "") ?? baseVersion
|
||||
const commits = getCommitsSince(latestTag)
|
||||
|
||||
if (commits.length === 0) {
|
||||
const deployTag = latestTag ?? `v${baseFromTag}`
|
||||
const deployVersion = baseFromTag
|
||||
const displayCommits = getRecentCommits()
|
||||
const publishedAt = latestTag ? getTagPublishedAt(latestTag) : null
|
||||
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
writeFileSync(join(outputDir, "release_notes.md"), "", "utf8")
|
||||
writeManifest({
|
||||
version: deployVersion,
|
||||
tag: deployTag,
|
||||
publishedAt,
|
||||
releaseUrl: latestTag ? buildReleaseUrl(latestTag) : "",
|
||||
commits: toManifestCommits(displayCommits),
|
||||
})
|
||||
|
||||
writeGithubOutput({
|
||||
should_release: "false",
|
||||
version: deployVersion,
|
||||
tag: deployTag,
|
||||
bump: "none",
|
||||
release_url: latestTag ? buildReleaseUrl(latestTag) : "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const bump = commits.some((commit) => commit.bump === "major")
|
||||
? "major"
|
||||
: commits.some((commit) => commit.bump === "minor") ? "minor" : "patch"
|
||||
const version = bumpVersion(baseFromTag, bump)
|
||||
const tag = `v${version}`
|
||||
const publishedAt = new Date().toISOString()
|
||||
const releaseUrl = buildReleaseUrl(tag)
|
||||
const releaseNotes = renderReleaseNotes({ tag, version, commits })
|
||||
const manifest = {
|
||||
version,
|
||||
tag,
|
||||
publishedAt,
|
||||
releaseUrl,
|
||||
commits: toManifestCommits(getRecentCommits()),
|
||||
}
|
||||
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
writeFileSync(join(outputDir, "release_notes.md"), releaseNotes, "utf8")
|
||||
writeManifest(manifest)
|
||||
|
||||
writeGithubOutput({
|
||||
should_release: "true",
|
||||
version,
|
||||
tag,
|
||||
bump,
|
||||
release_url: releaseUrl,
|
||||
})
|
||||
|
||||
process.stdout.write(`${tag}\n`)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { basename, dirname, join, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import test from "node:test"
|
||||
|
||||
const source = fileURLToPath(new URL("./compute-release.mjs", import.meta.url))
|
||||
|
||||
const cases = [
|
||||
{ name: "исправление повышает patch", commits: [["fix(ui): исправить отображение"]], version: "1.47.7", bump: "patch" },
|
||||
{ name: "новая возможность повышает minor", commits: [["feat(ui): добавить раздел"]], version: "1.48.0", bump: "minor" },
|
||||
{ name: "breaking со scope повышает major", commits: [["chore(release)!: подготовить вторую основную версию"]], version: "2.0.0", bump: "major" },
|
||||
{ name: "breaking без scope повышает major", commits: [["feat!: заменить контракт"]], version: "2.0.0", bump: "major" },
|
||||
{ name: "footer со пробелом повышает major", commits: [["fix(api): заменить контракт", "BREAKING CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
|
||||
{ name: "footer с дефисом повышает major", commits: [["fix(api): заменить контракт", "BREAKING-CHANGE: изменён контракт"]], version: "2.0.0", bump: "major" },
|
||||
{ name: "major имеет приоритет в смешанной истории", commits: [["chore(release)!: подготовить выпуск"], ["feat(ui): добавить раздел"], ["fix(ui): исправить отображение"]], version: "2.0.0", bump: "major" },
|
||||
{ name: "после второй версии сохраняется patch", base: "2.0.0", commits: [["fix(ui): исправить отображение"]], version: "2.0.1", bump: "patch" },
|
||||
{ name: "после второй версии сохраняется minor", base: "2.0.0", commits: [["feat(ui): добавить раздел"]], version: "2.1.0", bump: "minor" },
|
||||
{ name: "готовый тег не выпускается повторно", base: "2.0.0", commits: [], version: "2.0.0", bump: "none" },
|
||||
]
|
||||
|
||||
for (const scenario of cases) test(scenario.name, () => {
|
||||
const parent = resolve(tmpdir())
|
||||
const root = mkdtempSync(join(parent, "mm-release-"))
|
||||
const git = (...args) => execFileSync("git", args, { cwd: root, stdio: "pipe" })
|
||||
try {
|
||||
mkdirSync(join(root, ".ci/scripts"), { recursive: true })
|
||||
copyFileSync(source, join(root, ".ci/scripts/compute-release.mjs"))
|
||||
git("init", "--initial-branch=main")
|
||||
git("config", "user.name", "Release Test")
|
||||
git("config", "user.email", "release@example.invalid")
|
||||
git("config", "core.hooksPath", ".git/hooks")
|
||||
writeFileSync(join(root, "fixture"), "baseline")
|
||||
git("add", "fixture")
|
||||
git("commit", "-m", "chore(test): создать исходную базу")
|
||||
git("tag", `v${scenario.base ?? "1.47.6"}`)
|
||||
for (const [index, [subject, body]] of scenario.commits.entries()) {
|
||||
writeFileSync(join(root, "fixture"), `change ${index}`)
|
||||
git("add", "fixture")
|
||||
git("commit", "-m", subject, ...(body ? ["-m", body] : []))
|
||||
}
|
||||
execFileSync(process.execPath, [join(root, ".ci/scripts/compute-release.mjs")], {
|
||||
cwd: root, stdio: "pipe",
|
||||
env: { ...process.env, BASE_VERSION: "1.0.0", RELEASE_OUTPUT_DIR: ".ci/release", GITHUB_OUTPUT: join(root, "outputs"), GITEA_REPOSITORY: "test/repo", GITEA_SERVER_URL: "https://example.invalid" },
|
||||
})
|
||||
const manifest = JSON.parse(readFileSync(join(root, ".ci/release/release-manifest.json"), "utf8"))
|
||||
const output = readFileSync(join(root, "outputs"), "utf8")
|
||||
assert.equal(manifest.version, scenario.version)
|
||||
assert.equal(manifest.tag, `v${scenario.version}`)
|
||||
assert.equal(manifest.releaseUrl, `https://example.invalid/test/repo/releases/tag/v${scenario.version}`)
|
||||
assert.match(output, new RegExp(`^bump=${scenario.bump}$`, "m"))
|
||||
assert.match(output, new RegExp(`^should_release=${scenario.bump !== "none"}$`, "m"))
|
||||
if (scenario.bump !== "none") assert.match(readFileSync(join(root, ".ci/release/release_notes.md"), "utf8"), new RegExp(`# v${scenario.version.replaceAll(".", "\\.")}`))
|
||||
} finally {
|
||||
assert.equal(dirname(resolve(root)), parent)
|
||||
assert.ok(basename(root).startsWith("mm-release-"))
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync, spawnSync } from "node:child_process"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..")
|
||||
const gitCheck = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
})
|
||||
|
||||
if (gitCheck.status !== 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const topLevel = gitCheck.stdout.trim()
|
||||
|
||||
execFileSync("git", ["config", "core.hooksPath", ".githooks"], {
|
||||
cwd: topLevel,
|
||||
stdio: "ignore",
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
text = text.replace(
|
||||
'import { Fragment, useEffect, useMemo, useState } from "react"\n'
|
||||
'import { PageHeader } from "@/components/page-header"\n'
|
||||
'import { StatusBadge } from "@/components/status-badge"',
|
||||
'import { useEffect, useMemo, useState } from "react"\n'
|
||||
'import { PageHeader } from "@/components/page-header"\n'
|
||||
'import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"\n'
|
||||
'import { DataPageToolbar } from "@/components/data-page-toolbar"\n'
|
||||
'import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"',
|
||||
1,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
"""import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
""",
|
||||
"""import {
|
||||
Stepper,
|
||||
StepperContent,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperNav,
|
||||
StepperPanel,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
} from "@/components/reui/stepper"
|
||||
""",
|
||||
1,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
"""import {
|
||||
SearchIcon, RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||
MoreHorizontalIcon, EyeIcon, EyeOffIcon,
|
||||
ChevronRightIcon, ChevronDownIcon,
|
||||
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||
ShieldIcon, WifiIcon, PencilIcon, PowerIcon, Trash2Icon, ExternalLinkIcon,
|
||||
HomeIcon, ServerIcon, NetworkIcon,
|
||||
} from "lucide-react\"""",
|
||||
"""import {
|
||||
RefreshCwIcon, DownloadIcon, PlusIcon, TrashIcon,
|
||||
EyeIcon, EyeOffIcon,
|
||||
ChevronRightIcon, ChevronDownIcon,
|
||||
CheckCircleIcon, XCircleIcon, LoaderCircleIcon,
|
||||
ShieldIcon, WifiIcon,
|
||||
HomeIcon, ServerIcon, NetworkIcon,
|
||||
} from "lucide-react\"""",
|
||||
1,
|
||||
)
|
||||
|
||||
text = re.sub(
|
||||
r"// ─── RouterOS version utilities.*?// ─── Countries ─",
|
||||
"// ─── Countries ─",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
|
||||
text = re.sub(
|
||||
r"// ─── Type config ─.*?// ─── Shared small components ─",
|
||||
"// ─── Shared small components ─",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
|
||||
text = re.sub(
|
||||
r"function Field\(\{ label, hint, required, children \}:.*?^}\n\n// ─── Country field",
|
||||
"// ─── Country field",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.S | re.M,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
"const [expandedId, setExpandedId] = useState<string | null>(null)",
|
||||
"const [sheetStep, setSheetStep] = useState(1)",
|
||||
1,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setOpen(true)',
|
||||
'setForm(defaultForm); setTestState("idle"); setTestMsg("")\n setSheetStep(1)\n setOpen(true)',
|
||||
1,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
'setTestState("idle"); setTestMsg(""); setOpen(true)',
|
||||
'setTestState("idle"); setTestMsg(""); setSheetStep(1); setOpen(true)',
|
||||
1,
|
||||
)
|
||||
|
||||
text = re.sub(r"<Field\b", "<FormField", text)
|
||||
text = re.sub(r"</Field>", "</FormField>", text)
|
||||
text = re.sub(r"<Toggle\b", "<FormToggle", text)
|
||||
|
||||
new_table = """ {/* Table */}
|
||||
<Card>
|
||||
<DataPageToolbar
|
||||
segmented={{
|
||||
value: typeFilter,
|
||||
onChange: setTypeFilter,
|
||||
options: tabs.map((tab) => ({
|
||||
value: tab.value,
|
||||
label: tab.label,
|
||||
count: tab.value === "all" ? counts.all : counts[tab.value as ServerType],
|
||||
})),
|
||||
}}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Поиск по имени, хосту…"
|
||||
countLabel={`${filtered.length} серверов`}
|
||||
/>
|
||||
<ServersDataGrid
|
||||
servers={filtered}
|
||||
isLive={isLive}
|
||||
pollingIds={pollingIds}
|
||||
onPoll={handlePoll}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
/>
|
||||
</Card>
|
||||
"""
|
||||
|
||||
text = re.sub(
|
||||
r" \{/\* Table \*/\}\n <Card>.*?</Card>\n",
|
||||
new_table,
|
||||
text,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
|
||||
path.write_text(text, encoding="utf-8")
|
||||
print("patched", path)
|
||||
@@ -0,0 +1,144 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(__file__).resolve().parents[2] / "app" / "(main)" / "servers" / "page.tsx"
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
# Wrap sheet form in stepper
|
||||
text = text.replace(
|
||||
""" <div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
|
||||
{/* 1. Основные */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>""",
|
||||
""" <Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
||||
<StepperNav className="mb-5">
|
||||
<StepperItem step={1}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator>1</StepperIndicator>
|
||||
<StepperTitle className="sr-only">Основные</StepperTitle>
|
||||
</StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
<StepperItem step={2}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator>2</StepperIndicator>
|
||||
<StepperTitle className="sr-only">WAN</StepperTitle>
|
||||
</StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
<StepperItem step={3}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator>3</StepperIndicator>
|
||||
<StepperTitle className="sr-only">API</StepperTitle>
|
||||
</StepperTrigger>
|
||||
<StepperSeparator />
|
||||
</StepperItem>
|
||||
<StepperItem step={4}>
|
||||
<StepperTrigger>
|
||||
<StepperIndicator>4</StepperIndicator>
|
||||
<StepperTitle className="sr-only">Дополнительно</StepperTitle>
|
||||
</StepperTrigger>
|
||||
</StepperItem>
|
||||
</StepperNav>
|
||||
<StepperPanel className="flex-1 overflow-y-auto">
|
||||
<StepperContent value={1} className="flex flex-col gap-4">""",
|
||||
1,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
""" </div>
|
||||
|
||||
{/* 2. WAN-аплинки (только для home-router) */}
|
||||
{isHomeRouter && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>WAN-аплинки</SectionTitle>""",
|
||||
""" </StepperContent>
|
||||
<StepperContent value={2} className="flex flex-col gap-4">
|
||||
<SectionTitle>WAN-аплинки</SectionTitle>""",
|
||||
1,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
""" </div>
|
||||
)}
|
||||
|
||||
{/* 3. Подключение (API) */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
|
||||
""" </StepperContent>
|
||||
<StepperContent value={3} className="flex flex-col gap-4">
|
||||
<SectionTitle>Подключение (RouterOS REST API)</SectionTitle>""",
|
||||
1,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
""" </div>
|
||||
|
||||
{/* 4. Дополнительно */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
|
||||
""" </StepperContent>
|
||||
<StepperContent value={4} className="flex flex-col gap-4">
|
||||
<button type="button" onClick={() => set("showAdvanced", !form.showAdvanced)}""",
|
||||
1,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
""" </div>
|
||||
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||
<Button className="flex-1" onClick={handleSave}>
|
||||
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||
</Button>
|
||||
</SheetFooter>""",
|
||||
""" </StepperContent>
|
||||
</StepperPanel>
|
||||
</Stepper>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" />}>Отмена</SheetClose>
|
||||
{sheetStep > 1 && (
|
||||
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||
)}
|
||||
{sheetStep < 4 ? (
|
||||
<Button className="ml-auto" onClick={() => setSheetStep((s) => s + 1)}>Далее</Button>
|
||||
) : (
|
||||
<Button className="ml-auto" onClick={handleSave}>
|
||||
{sheetMode === "edit" ? "Сохранить" : "Добавить сервер"}
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>""",
|
||||
1,
|
||||
)
|
||||
|
||||
# WAN step 2: show message when not home router
|
||||
text = text.replace(
|
||||
""" <StepperContent value={2} className="flex flex-col gap-4">
|
||||
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||
<WanUplinkEditor""",
|
||||
""" <StepperContent value={2} className="flex flex-col gap-4">
|
||||
<SectionTitle>WAN-аплинки</SectionTitle>
|
||||
{!isHomeRouter ? (
|
||||
<p className="text-sm text-muted-foreground">WAN-аплинки доступны только для типа Home Router.</p>
|
||||
) : (
|
||||
<WanUplinkEditor""",
|
||||
1,
|
||||
)
|
||||
|
||||
text = text.replace(
|
||||
""" />
|
||||
</StepperContent>
|
||||
<StepperContent value={3}""",
|
||||
""" />
|
||||
)}
|
||||
</StepperContent>
|
||||
<StepperContent value={3}""",
|
||||
1,
|
||||
)
|
||||
|
||||
path.write_text(text, encoding="utf-8")
|
||||
print("stepper patched")
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs"
|
||||
|
||||
const messagePath = process.argv[2]
|
||||
|
||||
if (!messagePath) {
|
||||
console.error("validate-commit-message: не указан файл сообщения коммита")
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
if (process.env.SKIP_COMMIT_MESSAGE_RU === "1") {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(messagePath, "utf8")
|
||||
const subject = content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && !line.startsWith("#"))
|
||||
|
||||
if (!subject) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const skipPatterns = [
|
||||
/^Merge\b/i,
|
||||
/^Revert\b/i,
|
||||
/^fixup!/i,
|
||||
/^squash!/i,
|
||||
/^amend!/i,
|
||||
]
|
||||
|
||||
if (skipPatterns.some((pattern) => pattern.test(subject))) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const cyrillic = /[\u0400-\u04FF\u0500-\u052F]/
|
||||
|
||||
if (!cyrillic.test(subject)) {
|
||||
console.error("")
|
||||
console.error("Коммит отклонён: subject должен быть на русском.")
|
||||
console.error("Формат: fix(scope): краткое описание на русском")
|
||||
console.error("Правила: .cursor/rules/commit-messages-ru.mdc")
|
||||
console.error("Обход только для аварий: SKIP_COMMIT_MESSAGE_RU=1")
|
||||
console.error("")
|
||||
console.error(`Subject: ${subject}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const conventional =
|
||||
/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?:\s*(.+)$/
|
||||
|
||||
const match = subject.match(conventional)
|
||||
|
||||
if (match && !cyrillic.test(match[3])) {
|
||||
console.error("")
|
||||
console.error("Коммит отклонён: текст после «:» должен быть на русском.")
|
||||
console.error(`Subject: ${subject}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.exit(0)
|
||||
Reference in new issue
Block a user