feat: add update command, bump to 0.2.0, doc version check
Some checks failed
Secrets CLI - Build & Release / 探测 Runner (push) Successful in 1s
Secrets CLI - Build & Release / 版本 & Release (push) Successful in 3s
Secrets CLI - Build & Release / 质量检查 (fmt / clippy / test) (push) Failing after 21s
Secrets CLI - Build & Release / Build (x86_64-unknown-linux-musl) (push) Has been skipped
Secrets CLI - Build & Release / Build (aarch64-apple-darwin) (push) Has been skipped
Secrets CLI - Build & Release / 发布草稿 Release (push) Has been cancelled
Secrets CLI - Build & Release / 通知 (push) Has been cancelled
Secrets CLI - Build & Release / Build (x86_64-pc-windows-msvc) (push) Has been cancelled

- add secrets update: incremental merge for tags/metadata/encrypted
- AGENTS.md: 提交前检查增加版本号与 git tag 说明
- README/AGENTS: update 命令文档与示例
- Cargo.toml 0.1.0 -> 0.2.0 (secrets-0.1.0 已存在)

Made-with: Cursor
This commit is contained in:
voson
2026-03-18 15:40:44 +08:00
parent f87cf3fd20
commit c1d86bc96d
9 changed files with 293 additions and 16 deletions

View File

@@ -107,7 +107,7 @@ jobs:
--arg tag "$tag" \
--arg name "${{ env.BINARY_NAME }} ${version}" \
--arg body "$body" \
'{tag_name: $tag, name: $name, body: $body}')
'{tag_name: $tag, name: $name, body: $body, draft: true}')
http_code=$(curl -sS -o /tmp/create-release.json -w '%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" \
@@ -120,7 +120,7 @@ jobs:
fi
if [ -n "$release_id" ]; then
echo "已创建 Release: ${release_id}"
echo "已创建草稿 Release: ${release_id}"
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
else
echo "⚠ 创建 Release 失败 (HTTP ${http_code}),跳过产物上传"
@@ -169,12 +169,15 @@ jobs:
(.runners // .data // . // [])
| any(
(
(.status // (if (.online // false) then "online" else "offline" end))
| ascii_downcase
) == "online"
(.online == true)
or (
((.status // "") | ascii_downcase)
| IN("online", "idle", "busy", "active")
)
)
and (
(.labels // [])
| map(if type == "object" then (.name // .label // "") else tostring end)
| map(if type == "object" then (.name // .label // "") else tostring end | ascii_downcase)
| index($label)
) != null
)
@@ -182,7 +185,7 @@ jobs:
}
for pair in "debian:has_linux" "darwin-arm64:has_macos" "windows:has_windows"; do
label="${pair%%:*}"; key="${pair##*:}"
label="$(printf '%s' "${pair%%:*}" | tr '[:upper:]' '[:lower:]')"; key="${pair##*:}"
if has_runner "$label"; then
echo "${key}=true" >> "$GITHUB_OUTPUT"
else
@@ -228,7 +231,6 @@ jobs:
if: needs.probe-runners.outputs.has_linux == 'true'
runs-on: debian
timeout-minutes: 1
continue-on-error: true
steps:
- name: 安装依赖
run: |
@@ -278,7 +280,6 @@ jobs:
if: needs.probe-runners.outputs.has_macos == 'true'
runs-on: darwin-arm64
timeout-minutes: 1
continue-on-error: true
steps:
- name: 安装依赖
run: |
@@ -326,7 +327,6 @@ jobs:
if: needs.probe-runners.outputs.has_windows == 'true'
runs-on: windows
timeout-minutes: 1
continue-on-error: true
steps:
- name: 安装依赖
shell: pwsh
@@ -372,9 +372,51 @@ jobs:
-Headers @{ "Authorization" = "token $env:RELEASE_TOKEN" } `
-Form @{ attachment = Get-Item $archive }
publish-release:
name: 发布草稿 Release
needs: [version, check, build-linux, build-macos, build-windows]
if: always() && needs.version.outputs.release_id != ''
runs-on: debian
timeout-minutes: 2
steps:
- name: 发布草稿
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
[ -z "$RELEASE_TOKEN" ] && exit 0
version_r="${{ needs.version.result }}"
check_r="${{ needs.check.result }}"
linux_r="${{ needs.build-linux.result }}"
macos_r="${{ needs.build-macos.result }}"
windows_r="${{ needs.build-windows.result }}"
for result in "$version_r" "$check_r" "$linux_r" "$macos_r" "$windows_r"; do
case "$result" in
success|skipped) ;;
*)
echo "存在失败或取消的 job保留草稿 Release"
exit 0
;;
esac
done
release_api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/${{ needs.version.outputs.release_id }}"
http_code=$(curl -sS -o /tmp/publish-release.json -w '%{http_code}' \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-X PATCH "$release_api" \
-d '{"draft":false}')
if [ "$http_code" != "200" ]; then
echo "发布草稿 Release 失败 (HTTP ${http_code})"
cat /tmp/publish-release.json 2>/dev/null || true
exit 1
fi
notify:
name: 通知
needs: [version, probe-runners, check, build-linux, build-macos, build-windows]
needs: [version, probe-runners, check, build-linux, build-macos, build-windows, publish-release]
if: always() && github.event_name == 'push'
runs-on: debian
timeout-minutes: 1
@@ -399,8 +441,13 @@ jobs:
linux_r="${{ needs.build-linux.result }}"
macos_r="${{ needs.build-macos.result }}"
windows_r="${{ needs.build-windows.result }}"
publish_r="${{ needs.publish-release.result }}"
if [ "$version_r" = "success" ] && [ "$check_r" = "success" ]; then
if [ "$version_r" = "success" ] && [ "$check_r" = "success" ] \
&& [ "$linux_r" != "failure" ] && [ "$linux_r" != "cancelled" ] \
&& [ "$macos_r" != "failure" ] && [ "$macos_r" != "cancelled" ] \
&& [ "$windows_r" != "failure" ] && [ "$windows_r" != "cancelled" ] \
&& [ "$publish_r" != "failure" ] && [ "$publish_r" != "cancelled" ]; then
status="构建成功 ✅"
else
status="构建失败 ❌"
@@ -425,6 +472,7 @@ jobs:
msg="${msg}
构建结果linux$(icon "$linux_r") macOS$(icon "$macos_r") windows$(icon "$windows_r")
Release$(icon "$publish_r")
提交:${commit}
作者:${{ github.actor }}
详情:${url}"

View File

@@ -77,6 +77,15 @@ secrets add -n <namespace> --kind <kind> --name <name> \
secrets search [-n <namespace>] [--kind <kind>] [--tag <tag>] [-q <keyword>] [--show-secrets]
# -q 匹配范围name、namespace、kind、metadata 全文内容、tags
# 增量更新已有记录(合并语义,记录不存在则报错)
secrets update -n <namespace> --kind <kind> --name <name> \
[--add-tag <tag>]... # 添加标签(不影响已有标签)
[--remove-tag <tag>]... # 移除标签
[-m key=value]... # 新增或覆盖 metadata 字段(不影响其他字段)
[--remove-meta <key>]... # 删除 metadata 字段
[-s key=value]... # 新增或覆盖 encrypted 字段(不影响其他字段)
[--remove-secret <key>]... # 删除 encrypted 字段
# 删除
secrets delete -n <namespace> --kind <kind> --name <name>
```
@@ -104,6 +113,19 @@ secrets search -n refining --kind service --show-secrets
# 按 tag 筛选
secrets search --tag hongkong
# 只更新一个 IP不影响其他 metadata/secrets/tags
secrets update -n refining --kind server --name i-uf63f2uookgs5uxmrdyc \
-m ip=10.0.0.1
# 给一条记录新增 tag 并轮换密码
secrets update -n refining --kind service --name gitea \
--add-tag production \
-s token=<new-token>
# 移除一个废弃的 metadata 字段
secrets update -n refining --kind service --name mqtt \
--remove-meta old_port
```
## 代码规范
@@ -116,7 +138,23 @@ secrets search --tag hongkong
## 提交前检查(必须全部通过)
每次提交代码前,请在本地依次执行以下三项检查,**全部通过后再 push**
每次提交代码前,请在本地依次执行以下检查,**全部通过后再 push**
### 1. 版本号(按需)
若本次改动需要发版,请先确认 `Cargo.toml` 中的 `version` 已提升,避免 CI 打出的 Tag 与已有版本重复。可通过 git tag 判断:
```bash
# 查看当前 Cargo.toml 版本
grep '^version' Cargo.toml
# 查看是否已存在该版本对应的 tagCI 使用格式 secrets-<version>
git tag -l 'secrets-*'
```
若当前版本已被 tag例如已有 `secrets-0.1.0``Cargo.toml` 仍为 `0.1.0`),则应在 `Cargo.toml` 中 bump 版本号后再提交,以便 CI 自动打新 Tag 并发布 Release。
### 2. 格式、Lint、测试
```bash
cargo fmt -- --check # 格式检查(不通过则运行 cargo fmt 修复)

2
Cargo.lock generated
View File

@@ -1163,7 +1163,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secrets"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anyhow",
"chrono",

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[dependencies]

View File

@@ -33,6 +33,7 @@ secrets -h
secrets help add
secrets help search
secrets help delete
secrets help update
# 添加服务器
secrets add -n refining --kind server --name my-server \
@@ -53,6 +54,11 @@ secrets search --tag hongkong
secrets search -q mqtt # 关键词匹配 name / metadata / tags
secrets search -n refining --kind service --name gitea --show-secrets
# 增量更新已有记录(合并语义,记录不存在则报错)
secrets update -n refining --kind server --name my-server -m ip=10.0.0.1
secrets update -n refining --kind service --name gitea --add-tag production -s token=<new-token>
secrets update -n refining --kind service --name mqtt --remove-meta old_port --remove-secret old_key
# 删除
secrets delete -n refining --kind server --name my-server
```
@@ -83,6 +89,7 @@ src/
add.rs # upsert
search.rs # 多条件查询
delete.rs # 删除
update.rs # 增量更新(合并 tags/metadata/encrypted
scripts/
seed-data.sh # 导入 refining / ricnsmart 全量数据
```

View File

@@ -4,7 +4,7 @@ use sqlx::PgPool;
use std::fs;
/// Parse "key=value" entries. Value starting with '@' reads from file.
fn parse_kv(entry: &str) -> Result<(String, String)> {
pub(crate) fn parse_kv(entry: &str) -> Result<(String, String)> {
let (key, raw_val) = entry.split_once('=').ok_or_else(|| {
anyhow::anyhow!(
"Invalid format '{}'. Expected: key=value or key=@file",

View File

@@ -1,3 +1,4 @@
pub mod add;
pub mod delete;
pub mod search;
pub mod update;

125
src/commands/update.rs Normal file
View File

@@ -0,0 +1,125 @@
use anyhow::Result;
use serde_json::{Map, Value};
use sqlx::PgPool;
use super::add::parse_kv;
pub struct UpdateArgs<'a> {
pub namespace: &'a str,
pub kind: &'a str,
pub name: &'a str,
pub add_tags: &'a [String],
pub remove_tags: &'a [String],
pub meta_entries: &'a [String],
pub remove_meta: &'a [String],
pub secret_entries: &'a [String],
pub remove_secrets: &'a [String],
}
pub async fn run(pool: &PgPool, args: UpdateArgs<'_>) -> Result<()> {
let row = sqlx::query!(
r#"
SELECT id, tags, metadata, encrypted
FROM secrets
WHERE namespace = $1 AND kind = $2 AND name = $3
"#,
args.namespace,
args.kind,
args.name,
)
.fetch_optional(pool)
.await?;
let row = row.ok_or_else(|| {
anyhow::anyhow!(
"Not found: [{}/{}] {}. Use `add` to create it first.",
args.namespace,
args.kind,
args.name
)
})?;
// Merge tags
let mut tags: Vec<String> = row.tags;
for t in args.add_tags {
if !tags.contains(t) {
tags.push(t.clone());
}
}
tags.retain(|t| !args.remove_tags.contains(t));
// Merge metadata
let mut meta_map: Map<String, Value> = match row.metadata {
Value::Object(m) => m,
_ => Map::new(),
};
for entry in args.meta_entries {
let (key, value) = parse_kv(entry)?;
meta_map.insert(key, Value::String(value));
}
for key in args.remove_meta {
meta_map.remove(key);
}
let metadata = Value::Object(meta_map);
// Merge encrypted
let mut enc_map: Map<String, Value> = match row.encrypted {
Value::Object(m) => m,
_ => Map::new(),
};
for entry in args.secret_entries {
let (key, value) = parse_kv(entry)?;
enc_map.insert(key, Value::String(value));
}
for key in args.remove_secrets {
enc_map.remove(key);
}
let encrypted = Value::Object(enc_map);
sqlx::query!(
r#"
UPDATE secrets
SET tags = $1, metadata = $2, encrypted = $3, updated_at = NOW()
WHERE id = $4
"#,
&tags,
metadata,
encrypted,
row.id,
)
.execute(pool)
.await?;
println!("Updated: [{}/{}] {}", args.namespace, args.kind, args.name);
if !args.add_tags.is_empty() {
println!(" +tags: {}", args.add_tags.join(", "));
}
if !args.remove_tags.is_empty() {
println!(" -tags: {}", args.remove_tags.join(", "));
}
if !args.meta_entries.is_empty() {
let keys: Vec<&str> = args
.meta_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" +metadata: {}", keys.join(", "));
}
if !args.remove_meta.is_empty() {
println!(" -metadata: {}", args.remove_meta.join(", "));
}
if !args.secret_entries.is_empty() {
let keys: Vec<&str> = args
.secret_entries
.iter()
.filter_map(|s| s.split_once('=').map(|(k, _)| k))
.collect();
println!(" +secrets: {}", keys.join(", "));
}
if !args.remove_secrets.is_empty() {
println!(" -secrets: {}", args.remove_secrets.join(", "));
}
Ok(())
}

View File

@@ -76,6 +76,37 @@ enum Commands {
#[arg(long)]
name: String,
},
/// Incrementally update an existing record (merge semantics)
Update {
/// Namespace (e.g. refining, ricnsmart)
#[arg(short, long)]
namespace: String,
/// Kind of record (server, service, key, ...)
#[arg(long)]
kind: String,
/// Human-readable name
#[arg(long)]
name: String,
/// Add a tag (repeatable)
#[arg(long = "add-tag")]
add_tags: Vec<String>,
/// Remove a tag (repeatable)
#[arg(long = "remove-tag")]
remove_tags: Vec<String>,
/// Set or overwrite a metadata field: key=value (repeatable, @file supported)
#[arg(long = "meta", short = 'm')]
meta: Vec<String>,
/// Remove a metadata field by key (repeatable)
#[arg(long = "remove-meta")]
remove_meta: Vec<String>,
/// Set or overwrite a secret field: key=value (repeatable, @file supported)
#[arg(long = "secret", short = 's')]
secrets: Vec<String>,
/// Remove a secret field by key (repeatable)
#[arg(long = "remove-secret")]
remove_secrets: Vec<String>,
},
}
#[tokio::main]
@@ -130,6 +161,33 @@ async fn main() -> Result<()> {
} => {
commands::delete::run(&pool, namespace, kind, name).await?;
}
Commands::Update {
namespace,
kind,
name,
add_tags,
remove_tags,
meta,
remove_meta,
secrets,
remove_secrets,
} => {
commands::update::run(
&pool,
commands::update::UpdateArgs {
namespace,
kind,
name,
add_tags,
remove_tags,
meta_entries: meta,
remove_meta,
secret_entries: secrets,
remove_secrets,
},
)
.await?;
}
}
Ok(())