claude-nb 0.4.0 → 0.5.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/Makefile +8 -2
  2. package/README.md +262 -55
  3. package/VERSION +1 -1
  4. package/bin/_pip_entry.py +1 -1
  5. package/bin/board +119 -34
  6. package/bin/check-changelog +98 -0
  7. package/bin/check-npm-package +89 -0
  8. package/bin/check-readme-sync +69 -0
  9. package/bin/cnb +150 -38
  10. package/bin/configure-godaddy-pages-dns +203 -0
  11. package/bin/dispatcher +25 -11
  12. package/bin/doctor +55 -6
  13. package/bin/init +70 -9
  14. package/bin/notify +278 -0
  15. package/bin/registry +8 -23
  16. package/bin/secret-scan +165 -0
  17. package/bin/shutdown +68 -0
  18. package/bin/sync-version +131 -0
  19. package/lib/board_admin.py +18 -24
  20. package/lib/board_bbs.py +28 -19
  21. package/lib/board_bug.py +11 -22
  22. package/lib/board_db.py +37 -6
  23. package/lib/board_lock.py +16 -12
  24. package/lib/board_mail.py +212 -0
  25. package/lib/board_mailbox.py +41 -4
  26. package/lib/board_msg.py +88 -25
  27. package/lib/board_own.py +288 -0
  28. package/lib/board_pending.py +236 -0
  29. package/lib/board_pulse.py +14 -0
  30. package/lib/board_task.py +45 -17
  31. package/lib/board_tui.py +37 -23
  32. package/lib/board_view.py +113 -57
  33. package/lib/board_vote.py +12 -15
  34. package/lib/build_lock.py +9 -9
  35. package/lib/cli.py +3 -3
  36. package/lib/common.py +67 -7
  37. package/lib/concerns/__init__.py +4 -1
  38. package/lib/concerns/coral.py +1 -1
  39. package/lib/concerns/digest_scheduler.py +128 -0
  40. package/lib/concerns/file_watcher.py +84 -69
  41. package/lib/concerns/health.py +1 -1
  42. package/lib/concerns/helpers.py +17 -37
  43. package/lib/concerns/notification_push.py +178 -0
  44. package/lib/concerns/notifications.py +58 -3
  45. package/lib/concerns/nudge_coordinator.py +148 -0
  46. package/lib/digest.py +142 -0
  47. package/lib/github_issues_sync.py +173 -0
  48. package/lib/global_registry.py +183 -0
  49. package/lib/health.py +2 -2
  50. package/lib/inject.py +23 -15
  51. package/lib/migrate.py +8 -4
  52. package/lib/monitor.py +33 -13
  53. package/lib/notification_config.py +121 -0
  54. package/lib/notification_delivery.py +95 -0
  55. package/lib/panel.py +6 -2
  56. package/lib/resources.py +6 -3
  57. package/lib/shift_report.py +223 -0
  58. package/lib/shutdown.py +198 -0
  59. package/lib/swarm.py +43 -35
  60. package/lib/swarm_backend.py +63 -29
  61. package/lib/theme_profiles.py +89 -0
  62. package/lib/tmux_utils.py +52 -0
  63. package/lib/token_usage.py +148 -0
  64. package/migrations/004_heartbeat.sql +1 -0
  65. package/migrations/005_notification_log.sql +12 -0
  66. package/migrations/006_pending_actions.sql +15 -0
  67. package/migrations/007_mail.sql +15 -0
  68. package/migrations/008_ownership.sql +10 -0
  69. package/package.json +38 -4
  70. package/pyproject.toml +3 -2
  71. package/registry/0005-ritchie.json +12 -0
  72. package/registry/README.md +9 -0
  73. package/registry/pubkeys.json +83 -3
  74. package/schema.sql +54 -1
package/bin/board CHANGED
@@ -14,7 +14,7 @@ CLAUDES_HOME = Path(__file__).resolve().parent.parent
14
14
  sys.path.insert(0, str(CLAUDES_HOME))
15
15
 
16
16
  from lib.board_db import BoardDB
17
- from lib.common import ClaudesEnv, parse_flags
17
+ from lib.common import ClaudesEnv, parse_flags, validate_identity
18
18
 
19
19
  # ---------------------------------------------------------------------------
20
20
  # Command registry
@@ -33,14 +33,15 @@ class Command:
33
33
  needs_identity: bool = True
34
34
  takes_rest: bool = True
35
35
  aliases: list[str] = field(default_factory=list)
36
+ hidden: bool = False
36
37
 
37
38
 
38
39
  COMMANDS: list[Command] = [
39
40
  # ── messaging ──
40
- Command("send", "lib.board_msg", "cmd_send", "send a message", "send <to> <msg> [--attach <f>]"),
41
- Command("status", "lib.board_msg", "cmd_status", "update your current task", "status <description>"),
42
- Command("inbox", "lib.board_msg", "cmd_inbox", "check unread messages", "inbox", takes_rest=False),
43
- Command("ack", "lib.board_msg", "cmd_ack", "clear inbox", "ack", takes_rest=False),
41
+ Command("send", "lib.board_msg", "cmd_send", "send a message", "send <to> <msg> [--attach <f>]", hidden=True),
42
+ Command("status", "lib.board_msg", "cmd_status", "update your current task", "status <description>", hidden=True),
43
+ Command("inbox", "lib.board_msg", "cmd_inbox", "check unread messages", "inbox", takes_rest=False, hidden=True),
44
+ Command("ack", "lib.board_msg", "cmd_ack", "clear inbox", "ack", takes_rest=False, hidden=True),
44
45
  Command("log", "lib.board_msg", "cmd_log", "message history", "log [n] [--mine]"),
45
46
  # ── views ──
46
47
  Command("view", "lib.board_view", "cmd_view", "session dashboard", "view", takes_rest=False),
@@ -72,6 +73,7 @@ COMMANDS: list[Command] = [
72
73
  "pre-build",
73
74
  needs_identity=False,
74
75
  takes_rest=False,
76
+ hidden=True,
75
77
  ),
76
78
  Command(
77
79
  "dirty",
@@ -81,12 +83,37 @@ COMMANDS: list[Command] = [
81
83
  "dirty",
82
84
  needs_identity=False,
83
85
  takes_rest=False,
86
+ hidden=True,
84
87
  ),
85
88
  Command(
86
- "files", "lib.board_view", "cmd_files", "list shared files", "files", needs_identity=False, takes_rest=False
89
+ "files",
90
+ "lib.board_view",
91
+ "cmd_files",
92
+ "list shared files",
93
+ "files",
94
+ needs_identity=False,
95
+ takes_rest=False,
96
+ hidden=True,
97
+ ),
98
+ Command(
99
+ "get",
100
+ "lib.board_view",
101
+ "cmd_get",
102
+ "view shared file content",
103
+ "get <hash|name>",
104
+ needs_identity=False,
105
+ hidden=True,
106
+ ),
107
+ Command(
108
+ "roster",
109
+ "lib.board_view",
110
+ "cmd_roster",
111
+ "team roster",
112
+ "roster",
113
+ needs_identity=False,
114
+ takes_rest=False,
115
+ hidden=True,
87
116
  ),
88
- Command("get", "lib.board_view", "cmd_get", "view shared file content", "get <hash|name>", needs_identity=False),
89
- Command("roster", "lib.board_view", "cmd_roster", "team roster", "roster", needs_identity=False, takes_rest=False),
90
117
  Command(
91
118
  "history",
92
119
  "lib.board_view",
@@ -94,6 +121,7 @@ COMMANDS: list[Command] = [
94
121
  "session message history",
95
122
  "history <session> [limit]",
96
123
  needs_identity=False,
124
+ hidden=True,
97
125
  ),
98
126
  Command(
99
127
  "freshness",
@@ -103,6 +131,7 @@ COMMANDS: list[Command] = [
103
131
  "freshness",
104
132
  needs_identity=False,
105
133
  takes_rest=False,
134
+ hidden=True,
106
135
  ),
107
136
  Command(
108
137
  "relations",
@@ -112,11 +141,20 @@ COMMANDS: list[Command] = [
112
141
  "relations",
113
142
  needs_identity=False,
114
143
  takes_rest=False,
144
+ hidden=True,
115
145
  ),
116
146
  # ── BBS ──
117
- Command("post", "lib.board_bbs", "cmd_post", "BBS: create new thread", "post <标题> <内容>"),
118
- Command("reply", "lib.board_bbs", "cmd_reply", "BBS: reply to thread", "reply <帖子ID> <内容>"),
119
- Command("thread", "lib.board_bbs", "cmd_thread", "BBS: view thread", "thread <帖子ID>", needs_identity=False),
147
+ Command("post", "lib.board_bbs", "cmd_post", "BBS: create new thread", "post <标题> <内容>", hidden=True),
148
+ Command("reply", "lib.board_bbs", "cmd_reply", "BBS: reply to thread", "reply <帖子ID> <内容>", hidden=True),
149
+ Command(
150
+ "thread",
151
+ "lib.board_bbs",
152
+ "cmd_thread",
153
+ "BBS: view thread",
154
+ "thread <帖子ID>",
155
+ needs_identity=False,
156
+ hidden=True,
157
+ ),
120
158
  Command(
121
159
  "threads",
122
160
  "lib.board_bbs",
@@ -125,19 +163,41 @@ COMMANDS: list[Command] = [
125
163
  "threads",
126
164
  needs_identity=False,
127
165
  takes_rest=False,
166
+ hidden=True,
128
167
  ),
129
168
  # ── bug ──
130
- Command("bug", "lib.board_bug", "cmd_bug", "bug tracker", "bug {report|assign|fix|list|overdue}"),
169
+ Command("bug", "lib.board_bug", "cmd_bug", "bug tracker", "bug {report|assign|fix|list|overdue}", hidden=True),
131
170
  # ── task ──
132
- Command("task", "lib.board_task", "cmd_task", "task queue management", "task {add|done|list|next}"),
171
+ Command("task", "lib.board_task", "cmd_task", "task queue management", "task {add|done|list|next}", hidden=True),
172
+ # ── ownership ──
173
+ Command("own", "lib.board_own", "cmd_own", "ownership registry", "own {claim|list|disown|map}"),
174
+ Command("scan", "lib.board_own", "cmd_scan", "scan issues/CI for owners", "scan"),
175
+ # ── heartbeat ──
176
+ Command(
177
+ "pulse", "lib.board_pulse", "cmd_pulse", "heartbeat + unread count", "pulse", takes_rest=False, hidden=True
178
+ ),
133
179
  # ── voting ──
134
- Command("propose", "lib.board_vote", "cmd_propose", "create a proposal", "propose <内容> [--type S]"),
135
- Command("vote", "lib.board_vote", "cmd_vote", "vote on a proposal", "vote <N> <SUPPORT|OBJECT> <reason>"),
136
- Command("tally", "lib.board_vote", "cmd_tally", "recount votes", "tally <N>", needs_identity=False),
180
+ Command("propose", "lib.board_vote", "cmd_propose", "create a proposal", "propose <内容> [--type S]", hidden=True),
181
+ Command(
182
+ "vote", "lib.board_vote", "cmd_vote", "vote on a proposal", "vote <N> <SUPPORT|OBJECT> <reason>", hidden=True
183
+ ),
184
+ Command("tally", "lib.board_vote", "cmd_tally", "recount votes", "tally <N>", needs_identity=False, hidden=True),
137
185
  # ── mailbox (encrypted) ──
138
- Command("keygen", "lib.board_mailbox", "cmd_keygen", "generate encryption keypair", "keygen", takes_rest=False),
139
- Command("seal", "lib.board_mailbox", "cmd_seal", "send encrypted message", "seal <recipient> <message>"),
140
- Command("unseal", "lib.board_mailbox", "cmd_unseal", "read encrypted inbox", "unseal", takes_rest=False),
186
+ Command(
187
+ "keygen",
188
+ "lib.board_mailbox",
189
+ "cmd_keygen",
190
+ "generate encryption keypair",
191
+ "keygen",
192
+ takes_rest=False,
193
+ hidden=True,
194
+ ),
195
+ Command(
196
+ "seal", "lib.board_mailbox", "cmd_seal", "send encrypted message", "seal <recipient> <message>", hidden=True
197
+ ),
198
+ Command(
199
+ "unseal", "lib.board_mailbox", "cmd_unseal", "read encrypted inbox", "unseal", takes_rest=False, hidden=True
200
+ ),
141
201
  Command(
142
202
  "mailbox-log",
143
203
  "lib.board_mailbox",
@@ -145,9 +205,22 @@ COMMANDS: list[Command] = [
145
205
  "encrypted message history",
146
206
  "mailbox-log",
147
207
  takes_rest=False,
208
+ hidden=True,
148
209
  ),
210
+ Command(
211
+ "keygen-all",
212
+ "lib.board_mailbox",
213
+ "cmd_keygen_all",
214
+ "generate keys for all sessions",
215
+ "keygen-all",
216
+ needs_identity=False,
217
+ takes_rest=False,
218
+ hidden=True,
219
+ ),
220
+ # ── daily report ──
221
+ Command("daily", "lib.board_daily", "cmd_daily", "generate daily report", "daily [补充说明]"),
149
222
  # ── admin ──
150
- Command("kudos", "lib.board_admin", "cmd_kudos", "give public recognition", "kudos <target> <reason>"),
223
+ Command("kudos", "lib.board_admin", "cmd_kudos", "give public recognition", "kudos <target> <reason>", hidden=True),
151
224
  Command(
152
225
  "kudos-list",
153
226
  "lib.board_admin",
@@ -157,12 +230,13 @@ COMMANDS: list[Command] = [
157
230
  needs_identity=False,
158
231
  takes_rest=False,
159
232
  aliases=["kudos-board"],
233
+ hidden=True,
160
234
  ),
161
- Command("suspend", "lib.board_admin", "cmd_suspend", "suspend a session", "suspend <session>"),
162
- Command("resume", "lib.board_admin", "cmd_resume", "resume a session", "resume <session>"),
235
+ Command("suspend", "lib.board_admin", "cmd_suspend", "suspend a session", "suspend <session>", hidden=True),
236
+ Command("resume", "lib.board_admin", "cmd_resume", "resume a session", "resume <session>", hidden=True),
163
237
  # ── git lock ──
164
- Command("git-lock", "lib.board_lock", "cmd_git_lock", "acquire git index lock", "git-lock [reason]"),
165
- Command("git-unlock", "lib.board_lock", "cmd_git_unlock", "release git index lock", "git-unlock"),
238
+ Command("git-lock", "lib.board_lock", "cmd_git_lock", "acquire git index lock", "git-lock [reason]", hidden=True),
239
+ Command("git-unlock", "lib.board_lock", "cmd_git_unlock", "release git index lock", "git-unlock", hidden=True),
166
240
  Command(
167
241
  "git-lock-status",
168
242
  "lib.board_lock",
@@ -171,6 +245,7 @@ COMMANDS: list[Command] = [
171
245
  "git-lock-status",
172
246
  needs_identity=False,
173
247
  takes_rest=False,
248
+ hidden=True,
174
249
  ),
175
250
  # ── tui ──
176
251
  Command(
@@ -182,6 +257,16 @@ COMMANDS: list[Command] = [
182
257
  needs_identity=False,
183
258
  takes_rest=False,
184
259
  ),
260
+ # ── mail ──
261
+ Command("mail", "lib.board_mail", "cmd_mail", "persistent mail with CC/threading", "mail {send|list|read|reply}"),
262
+ # ── pending actions ──
263
+ Command(
264
+ "pending",
265
+ "lib.board_pending",
266
+ "cmd_pending",
267
+ "pending actions queue",
268
+ "pending {add|list|verify|retry|resolve}",
269
+ ),
185
270
  # ── maintenance ──
186
271
  Command(
187
272
  "prune",
@@ -190,6 +275,7 @@ COMMANDS: list[Command] = [
190
275
  "prune old messages",
191
276
  "prune [--before DAYS] [--dry-run]",
192
277
  needs_identity=False,
278
+ hidden=True,
193
279
  ),
194
280
  Command(
195
281
  "backup",
@@ -198,6 +284,7 @@ COMMANDS: list[Command] = [
198
284
  "backup database",
199
285
  "backup [--output <path>]",
200
286
  needs_identity=False,
287
+ hidden=True,
201
288
  ),
202
289
  Command(
203
290
  "restore",
@@ -206,6 +293,7 @@ COMMANDS: list[Command] = [
206
293
  "restore from backup",
207
294
  "restore <file> [--force]",
208
295
  needs_identity=False,
296
+ hidden=True,
209
297
  ),
210
298
  ]
211
299
 
@@ -246,18 +334,12 @@ def _fmt_command(cmd: Command, width: int) -> str:
246
334
 
247
335
 
248
336
  def print_help() -> None:
249
- max_name = max(len(c.name) + (2 + len(", ".join(c.aliases)) if c.aliases else 0) for c in COMMANDS) + 2
250
- print("board v2 agent coordination tool (SQLite backend, Python)\n")
337
+ visible = [c for c in COMMANDS if not c.hidden]
338
+ max_name = max(len(c.name) + (2 + len(", ".join(c.aliases)) if c.aliases else 0) for c in visible) + 2
339
+ print("board — 同学协作工具\n")
251
340
  print("Usage: board --as <name> <command> [args...]\n")
252
341
  print("Commands:")
253
- last_module = ""
254
- for c in COMMANDS:
255
- mod = c.module.rsplit(".", 1)[-1] # board_msg, board_view, etc.
256
- if mod != last_module:
257
- if last_module:
258
- print()
259
- print(f" [{mod}]")
260
- last_module = mod
342
+ for c in visible:
261
343
  print(_fmt_command(c, max_name))
262
344
  print()
263
345
 
@@ -290,6 +372,9 @@ def main() -> None:
290
372
  print("ERROR: identity required. Use: board --as <name> <command>", file=sys.stderr)
291
373
  raise SystemExit(1)
292
374
 
375
+ if identity:
376
+ validate_identity(db, identity)
377
+
293
378
  _dispatch(cmd, db, identity, rest)
294
379
 
295
380
 
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """check-changelog — enforce changelog discipline on release versions.
3
+
4
+ Rules:
5
+ 1. Release versions (no -dev suffix) MUST have a matching ## {version} entry
6
+ in CHANGELOG.md with actual content (not just a header).
7
+ 2. The entry must NOT be marked "(unreleased)".
8
+ 3. Dev versions always pass — the unreleased section accumulates freely.
9
+
10
+ Usage:
11
+ ./bin/check-changelog # check and exit 0/1
12
+ ./bin/check-changelog --help # this message
13
+ """
14
+
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ ROOT = Path(__file__).resolve().parent.parent
20
+ VERSION_FILE = ROOT / "VERSION"
21
+ CHANGELOG_FILE = ROOT / "CHANGELOG.md"
22
+
23
+
24
+ def read_version() -> str:
25
+ return VERSION_FILE.read_text().strip()
26
+
27
+
28
+ def is_release(ver: str) -> bool:
29
+ return not ver.endswith("-dev")
30
+
31
+
32
+ def find_changelog_section(ver: str, text: str) -> tuple[bool, str]:
33
+ """Find ## {ver} section in changelog. Returns (found, section_body)."""
34
+ pattern = re.compile(
35
+ rf"^## {re.escape(ver)}\b[^\n]*\n(.*?)(?=^## |\Z)",
36
+ re.MULTILINE | re.DOTALL,
37
+ )
38
+ m = pattern.search(text)
39
+ if not m:
40
+ return False, ""
41
+ return True, m.group(1).strip()
42
+
43
+
44
+ def check_not_unreleased(ver: str, text: str) -> bool:
45
+ """Return True if the version header is NOT marked unreleased."""
46
+ pattern = re.compile(rf"^## {re.escape(ver)}.*\(unreleased\)", re.MULTILINE | re.IGNORECASE)
47
+ return not pattern.search(text)
48
+
49
+
50
+ def previous_release(text: str, current: str) -> str | None:
51
+ """Find the most recent release version before current in CHANGELOG.md."""
52
+ versions = re.findall(r"^## (\d+\.\d+\.\d+)\b", text, re.MULTILINE)
53
+ for v in versions:
54
+ if v != current:
55
+ return v
56
+ return None
57
+
58
+
59
+ def main() -> None:
60
+ if "--help" in sys.argv or "-h" in sys.argv:
61
+ print(__doc__)
62
+ return
63
+
64
+ ver = read_version()
65
+
66
+ if not is_release(ver):
67
+ print(f"OK {ver} is a dev version — changelog check skipped")
68
+ return
69
+
70
+ if not CHANGELOG_FILE.exists():
71
+ print(f"ERROR: CHANGELOG.md not found. Release {ver} requires a changelog entry.")
72
+ raise SystemExit(1)
73
+
74
+ text = CHANGELOG_FILE.read_text()
75
+
76
+ found, body = find_changelog_section(ver, text)
77
+ if not found:
78
+ prev = previous_release(text, ver)
79
+ hint = f" Summarize all changes since {prev}." if prev else ""
80
+ print(f"ERROR: CHANGELOG.md has no entry for release {ver}.{hint}")
81
+ print(f"Add a '## {ver}' section with Features, Bug Fixes, etc.")
82
+ raise SystemExit(1)
83
+
84
+ if not check_not_unreleased(ver, text):
85
+ print(f"ERROR: CHANGELOG.md entry for {ver} is marked (unreleased). Remove the marker for a release.")
86
+ raise SystemExit(1)
87
+
88
+ content_lines = [line for line in body.splitlines() if line.strip() and not line.startswith("#")]
89
+ if len(content_lines) < 3:
90
+ print(f"ERROR: CHANGELOG.md entry for {ver} is too thin ({len(content_lines)} content lines).")
91
+ print("A release changelog must summarize all changes since the previous release.")
92
+ raise SystemExit(1)
93
+
94
+ print(f"OK CHANGELOG.md has a valid entry for release {ver} ({len(content_lines)} content lines)")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ usage() {
5
+ cat <<'EOF'
6
+ Usage: bin/check-npm-package [--install-smoke] [--tarball PATH]
7
+
8
+ Build or inspect the npm package tarball, verify required files are present,
9
+ reject obvious secret-bearing paths, and optionally install the packed tarball
10
+ globally in a temporary prefix to prove the cnb entrypoint is runnable.
11
+ EOF
12
+ }
13
+
14
+ INSTALL_SMOKE=false
15
+ TARBALL=""
16
+
17
+ while [ $# -gt 0 ]; do
18
+ case "$1" in
19
+ --install-smoke)
20
+ INSTALL_SMOKE=true
21
+ shift
22
+ ;;
23
+ --tarball)
24
+ if [ $# -lt 2 ]; then
25
+ echo "ERROR: --tarball requires a path" >&2
26
+ exit 2
27
+ fi
28
+ TARBALL="$2"
29
+ shift 2
30
+ ;;
31
+ -h|--help)
32
+ usage
33
+ exit 0
34
+ ;;
35
+ *)
36
+ echo "ERROR: unknown argument: $1" >&2
37
+ usage >&2
38
+ exit 2
39
+ ;;
40
+ esac
41
+ done
42
+
43
+ WORK_DIR="$(mktemp -d)"
44
+ cleanup() {
45
+ rm -rf "$WORK_DIR"
46
+ }
47
+ trap cleanup EXIT
48
+
49
+ if [ -z "$TARBALL" ]; then
50
+ tarball_name="$(npm pack --pack-destination "$WORK_DIR")"
51
+ TARBALL="$WORK_DIR/$tarball_name"
52
+ fi
53
+
54
+ if [ ! -f "$TARBALL" ]; then
55
+ echo "ERROR: tarball not found: $TARBALL" >&2
56
+ exit 1
57
+ fi
58
+
59
+ tar -tzf "$TARBALL" > "$WORK_DIR/files.txt"
60
+
61
+ required_files=(
62
+ "package/bin/cnb.js"
63
+ "package/bin/cnb"
64
+ "package/lib/cli.py"
65
+ "package/VERSION"
66
+ "package/package.json"
67
+ )
68
+
69
+ for file in "${required_files[@]}"; do
70
+ if ! grep -qx "$file" "$WORK_DIR/files.txt"; then
71
+ echo "ERROR: npm package is missing required file: $file" >&2
72
+ exit 1
73
+ fi
74
+ done
75
+
76
+ if grep -E '(^|/)(\.env($|[._-])|\.npmrc$|id_(rsa|dsa|ecdsa|ed25519)$|[^/]+\.(pem|p12|pfx|key)$|credentials?($|[._-]))' "$WORK_DIR/files.txt"; then
77
+ echo "ERROR: npm package includes a path that looks secret-bearing" >&2
78
+ exit 1
79
+ fi
80
+
81
+ if [ "$INSTALL_SMOKE" = true ]; then
82
+ prefix="$WORK_DIR/install"
83
+ project="$WORK_DIR/project"
84
+ mkdir -p "$prefix" "$project"
85
+ npm install --global --ignore-scripts --no-audit --no-fund --prefix "$prefix" "$TARBALL"
86
+ PATH="$prefix/bin:$PATH" CNB_PROJECT="$project" cnb --version | grep -E '^cnb v[0-9]+\.[0-9]+\.[0-9]+' >/dev/null
87
+ fi
88
+
89
+ echo "OK npm package tarball passed checks: $TARBALL"
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ """check-readme-sync — verify README.md and README_zh.md have matching section structure.
3
+
4
+ Both files use <!-- section:NAME --> markers. This script checks that they
5
+ have the same markers in the same order. Content differs (that's the point
6
+ of two languages), but structure must match.
7
+
8
+ Usage:
9
+ bin/check-readme-sync # check and report
10
+ bin/check-readme-sync --fix # print which sections are missing/extra
11
+
12
+ Exit 0 if in sync, 1 if not.
13
+ """
14
+
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ ROOT = Path(__file__).resolve().parent.parent
20
+ MARKER = re.compile(r"<!--\s*section:(\S+)\s*-->")
21
+
22
+
23
+ def extract_sections(path: Path) -> list[str]:
24
+ if not path.exists():
25
+ return []
26
+ return MARKER.findall(path.read_text())
27
+
28
+
29
+ def main() -> None:
30
+ en = ROOT / "README.md"
31
+ zh = ROOT / "README_zh.md"
32
+
33
+ en_sections = extract_sections(en)
34
+ zh_sections = extract_sections(zh)
35
+
36
+ if not en_sections:
37
+ print(f"ERROR: no section markers in {en.name}")
38
+ raise SystemExit(1)
39
+ if not zh_sections:
40
+ print(f"ERROR: no section markers in {zh.name}")
41
+ raise SystemExit(1)
42
+
43
+ if en_sections == zh_sections:
44
+ print(f"OK {len(en_sections)} sections in sync: {', '.join(en_sections)}")
45
+ return
46
+
47
+ en_set = set(en_sections)
48
+ zh_set = set(zh_sections)
49
+
50
+ missing_in_zh = en_set - zh_set
51
+ missing_in_en = zh_set - en_set
52
+
53
+ if missing_in_zh:
54
+ print(f"README_zh.md missing: {', '.join(sorted(missing_in_zh))}")
55
+ if missing_in_en:
56
+ print(f"README.md missing: {', '.join(sorted(missing_in_en))}")
57
+
58
+ if en_sections != zh_sections and not (missing_in_zh or missing_in_en):
59
+ print("Section order differs:")
60
+ for i, (e, z) in enumerate(zip(en_sections, zh_sections)):
61
+ if e != z:
62
+ print(f" position {i}: README.md has '{e}', README_zh.md has '{z}'")
63
+ break
64
+
65
+ raise SystemExit(1)
66
+
67
+
68
+ if __name__ == "__main__":
69
+ main()