its-magic 0.1.2 → 0.1.3-1

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 (29) hide show
  1. package/bin/postinstall.js +72 -0
  2. package/installer.py +55 -0
  3. package/package.json +1 -1
  4. package/scripts/check_intake_template_parity.py +90 -0
  5. package/template/.cursor/agents/curator.mdc +1 -0
  6. package/template/.cursor/agents/po.mdc +1 -0
  7. package/template/.cursor/agents/release.mdc +1 -0
  8. package/template/.cursor/commands/release.md +18 -0
  9. package/template/.cursor/model-catalog.local.example.cursor-only.json +9 -0
  10. package/template/.cursor/model-catalog.local.example.json +9 -0
  11. package/template/.cursor/model-catalog.local.example.level-1-easy.json +9 -0
  12. package/template/.cursor/model-catalog.local.example.level-2-complex.json +9 -0
  13. package/template/.cursor/model-catalog.local.example.level-3-mega.json +9 -0
  14. package/template/.cursor/model-catalog.local.example.level-4-super.json +9 -0
  15. package/template/.cursor/model-catalog.local.example.role-based-balanced.json +18 -0
  16. package/template/.cursor/model-catalog.local.example.role-based-highend.json +18 -0
  17. package/template/.cursor/scratchpad.local.example.md +55 -0
  18. package/template/.cursor/scratchpad.md +62 -0
  19. package/template/CHANGELOG.md +11 -0
  20. package/template/docs/engineering/runbook.md +274 -6
  21. package/template/handoffs/releases/vX.Y.Z-release-notes.md.example +21 -0
  22. package/template/scripts/check_intake_template_parity.py +90 -0
  23. package/template/scripts/dev_environment_lib.py +138 -0
  24. package/template/scripts/model_tier_lib.py +680 -0
  25. package/template/scripts/model_tier_validate.py +368 -0
  26. package/template/scripts/release-all.sh +249 -0
  27. package/template/scripts/release_changelog_backfill.py +153 -0
  28. package/template/scripts/release_changelog_lib.py +544 -0
  29. package/template/scripts/release_changelog_validate.py +134 -0
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Model tier validator CLI (US-0101 / DEC-0086; US-0102 / DEC-0087).
4
+
5
+ Validates:
6
+ - Tier enum values (cheap|balanced|strong)
7
+ - Catalog schema (v1 + v2)
8
+ - Direct MODEL_<PHASE> slug keys
9
+ - Phase key spelling (canonical phase IDs)
10
+ - Forbidden vendor slugs in template agents
11
+
12
+ Exit codes:
13
+ - 0: All validations passed
14
+ - 1: Validation failed (see stderr for details)
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import re
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Dict, List, Tuple
23
+
24
+ sys.path.insert(0, str(Path(__file__).parent))
25
+ from model_tier_lib import (
26
+ CANONICAL_PHASE_IDS,
27
+ CATALOG_ROLE_KEYS,
28
+ DEFAULT_PHASE_TIER_MATRIX,
29
+ PRECEDENCE_CHAIN_STEPS,
30
+ ReasonCode,
31
+ Tier,
32
+ catalog_validation_reason_code,
33
+ phase_to_model_key,
34
+ resolve_model_for_phase,
35
+ validate_catalog_schema,
36
+ validate_direct_slug,
37
+ )
38
+
39
+ # Reason codes used for fail-closed reporting (DEC-0086 §3 + DEC-0087 §8):
40
+ # - MODEL_TIER_INVALID: unknown tier value
41
+ # - MODEL_CATALOG_INVALID: malformed catalog JSON (v1)
42
+ # - MODEL_SLUG_UNKNOWN: tier key missing from catalog
43
+ # - MODEL_RESOLVE_FALLBACK: catalog lookup failed, using fallback
44
+ # - MODEL_OVERRIDE_SLUG_UNKNOWN: direct slug validation failure
45
+ # - MODEL_ROLE_SLUG_UNKNOWN: role catalog lookup miss
46
+ # - MODEL_CATALOG_SCHEMA_V2_INVALID: v2 schema validation failure
47
+ REASON_CODES = list(ReasonCode)
48
+
49
+ FORBIDDEN_SLUG_PATTERNS = [
50
+ r"composer-",
51
+ r"claude-",
52
+ r"gpt-",
53
+ r"opus-",
54
+ r"glm-",
55
+ ]
56
+
57
+ CANONICAL_PHASE_ID_SET = set(CANONICAL_PHASE_IDS)
58
+
59
+
60
+ def validate_tier_enum(tier_value: str) -> Tuple[bool, str]:
61
+ """Validate tier enum value."""
62
+ try:
63
+ Tier(tier_value)
64
+ return True, ""
65
+ except ValueError:
66
+ return False, f"Invalid tier value: {tier_value} (expected: cheap|balanced|strong)"
67
+
68
+
69
+ def validate_phase_key(phase: str) -> Tuple[bool, str]:
70
+ """Validate phase key spelling."""
71
+ if phase not in CANONICAL_PHASE_ID_SET:
72
+ return False, (
73
+ f"Unknown phase ID: {phase} "
74
+ f"(canonical: {', '.join(sorted(CANONICAL_PHASE_ID_SET))})"
75
+ )
76
+ return True, ""
77
+
78
+
79
+ def check_forbidden_slugs_in_file(file_path: Path) -> List[str]:
80
+ """Check for forbidden vendor slugs in a file."""
81
+ violations = []
82
+ if not file_path.exists():
83
+ return violations
84
+
85
+ content = file_path.read_text(encoding="utf-8")
86
+ lines = content.split("\n")
87
+
88
+ for line_num, line in enumerate(lines, start=1):
89
+ for pattern in FORBIDDEN_SLUG_PATTERNS:
90
+ if re.search(pattern, line, re.IGNORECASE):
91
+ violations.append(
92
+ f"{file_path}:{line_num}: forbidden slug pattern '{pattern}' found: {line.strip()}"
93
+ )
94
+
95
+ return violations
96
+
97
+
98
+ def check_template_agents(repo_root: Path) -> List[str]:
99
+ """Check template/.cursor/agents/*.mdc for forbidden slugs."""
100
+ violations = []
101
+ agents_dir = repo_root / "template" / ".cursor" / "agents"
102
+
103
+ if not agents_dir.exists():
104
+ return violations
105
+
106
+ for agent_file in agents_dir.glob("*.mdc"):
107
+ violations.extend(check_forbidden_slugs_in_file(agent_file))
108
+
109
+ return violations
110
+
111
+
112
+ def parse_scratchpad_keys(scratchpad_path: Path) -> Dict[str, str]:
113
+ """Parse key=value lines from scratchpad (skip comments)."""
114
+ result: Dict[str, str] = {}
115
+ if not scratchpad_path.exists():
116
+ return result
117
+ for line in scratchpad_path.read_text(encoding="utf-8").split("\n"):
118
+ stripped = line.strip()
119
+ if not stripped or stripped.startswith("#"):
120
+ continue
121
+ if "=" in stripped:
122
+ key, value = stripped.split("=", 1)
123
+ result[key.strip()] = value.strip()
124
+ return result
125
+
126
+
127
+ def validate_catalog(catalog_path: Path) -> Tuple[bool, List[str], ReasonCode]:
128
+ """Validate catalog schema and return list of errors."""
129
+ errors: List[str] = []
130
+
131
+ if not catalog_path.exists():
132
+ errors.append(f"Catalog file not found: {catalog_path}")
133
+ return False, errors, ReasonCode.MODEL_CATALOG_INVALID
134
+
135
+ is_valid, error_msg = validate_catalog_schema(catalog_path)
136
+ if not is_valid:
137
+ schema_version = None
138
+ try:
139
+ with open(catalog_path, "r", encoding="utf-8") as f:
140
+ data = json.load(f)
141
+ schema_version = data.get("schema_version")
142
+ except (json.JSONDecodeError, OSError):
143
+ pass
144
+ code = catalog_validation_reason_code(
145
+ error_msg,
146
+ {"schema_version": schema_version} if schema_version else None,
147
+ )
148
+ errors.append(error_msg or "Catalog validation failed")
149
+ return False, errors, code
150
+
151
+ with open(catalog_path, "r", encoding="utf-8") as f:
152
+ catalog = json.load(f)
153
+
154
+ for tier_name, slug in catalog["tiers"].items():
155
+ if not slug.strip():
156
+ errors.append(f"Tier '{tier_name}' has empty slug")
157
+
158
+ if catalog.get("schema_version") == 2 and "roles" in catalog:
159
+ for role_name in CATALOG_ROLE_KEYS:
160
+ if role_name not in catalog["roles"]:
161
+ errors.append(f"Missing role key: {role_name}")
162
+ elif not catalog["roles"][role_name].strip():
163
+ errors.append(f"Role '{role_name}' has empty slug")
164
+
165
+ code = (
166
+ ReasonCode.MODEL_CATALOG_SCHEMA_V2_INVALID
167
+ if catalog.get("schema_version") == 2
168
+ else ReasonCode.MODEL_CATALOG_INVALID
169
+ )
170
+ return len(errors) == 0, errors, code
171
+
172
+
173
+ def validate_scratchpad_tiers(scratchpad_path: Path) -> Tuple[bool, List[str]]:
174
+ """Validate MODEL_TIER_* keys in scratchpad file."""
175
+ errors: List[str] = []
176
+
177
+ if not scratchpad_path.exists():
178
+ return True, errors
179
+
180
+ content = scratchpad_path.read_text(encoding="utf-8")
181
+
182
+ for line in content.split("\n"):
183
+ line = line.strip()
184
+ if line.startswith("MODEL_TIER_") and "=" in line:
185
+ key, value = line.split("=", 1)
186
+ value = value.strip()
187
+
188
+ if key.startswith("#"):
189
+ continue
190
+
191
+ if value and not value.startswith("<"):
192
+ is_valid, error = validate_tier_enum(value)
193
+ if not is_valid:
194
+ errors.append(f"{key}={value}: {error}")
195
+
196
+ return len(errors) == 0, errors
197
+
198
+
199
+ def validate_scratchpad_direct_slugs(scratchpad_path: Path, catalog: dict | None) -> Tuple[bool, List[str]]:
200
+ """Validate MODEL_<PHASE> direct override keys."""
201
+ errors: List[str] = []
202
+ pad = parse_scratchpad_keys(scratchpad_path)
203
+ model_resolve = pad.get("MODEL_RESOLVE", "alias_only")
204
+
205
+ for phase_id in CANONICAL_PHASE_ID_SET:
206
+ key = phase_to_model_key(phase_id)
207
+ if key in pad and pad[key].strip() and not pad[key].startswith("<"):
208
+ slug = pad[key].strip()
209
+ is_valid, error = validate_direct_slug(slug, model_resolve, catalog)
210
+ if not is_valid:
211
+ errors.append(f"{key}={slug}: {error} [{ReasonCode.MODEL_OVERRIDE_SLUG_UNKNOWN.value}]")
212
+
213
+ return len(errors) == 0, errors
214
+
215
+
216
+ def run_precedence_self_test() -> List[str]:
217
+ """Precedence self-test hook (DEC-0087)."""
218
+ errors: List[str] = []
219
+
220
+ if len(PRECEDENCE_CHAIN_STEPS) != 5:
221
+ errors.append("PRECEDENCE_CHAIN_STEPS must have exactly 5 steps")
222
+
223
+ # Step 1 wins over tier
224
+ pad = {"MODEL_EXECUTE": "<test-slug>", "MODEL_TIER_EXECUTE": "cheap", "MODEL_RESOLVE": "alias_only"}
225
+ result = resolve_model_for_phase("execute", pad)
226
+ if not result.success or result.slug != "<test-slug>":
227
+ errors.append("Precedence self-test: MODEL_EXECUTE should win step 1")
228
+
229
+ # Tier-only backward compat: execute → strong → omit alias
230
+ result = resolve_model_for_phase("execute", {"MODEL_RESOLVE": "alias_only"})
231
+ if not result.success or result.tier != Tier.STRONG or result.alias is not None:
232
+ errors.append("Precedence self-test: tier-only execute should resolve to strong/omit")
233
+
234
+ return errors
235
+
236
+
237
+ def main():
238
+ parser = argparse.ArgumentParser(
239
+ description="Model tier validator (US-0101 / US-0102)",
240
+ formatter_class=argparse.RawDescriptionHelpFormatter,
241
+ epilog="""
242
+ Examples:
243
+ python scripts/model_tier_validate.py --repo .
244
+ python scripts/model_tier_validate.py --catalog .cursor/model-catalog.local.json
245
+ python scripts/model_tier_validate.py --check-template-agents
246
+ python scripts/model_tier_validate.py --enforce
247
+ """,
248
+ )
249
+
250
+ parser.add_argument("--repo", type=Path, help="Repository root (default: current directory)")
251
+ parser.add_argument("--catalog", type=Path, help="Path to local catalog")
252
+ parser.add_argument("--scratchpad", type=Path, help="Path to scratchpad file")
253
+ parser.add_argument("--check-template-agents", action="store_true", help="Check template agents for forbidden slugs")
254
+ parser.add_argument("--self-test", action="store_true", help="Run self-test (validate library contract)")
255
+ parser.add_argument("--enforce", action="store_true", help="Exit non-zero on any fail-closed code")
256
+
257
+ args = parser.parse_args()
258
+
259
+ repo_root = (args.repo or Path.cwd()).resolve()
260
+ all_errors: List[str] = []
261
+
262
+ if args.self_test:
263
+ print("[SELF-TEST] Validating model_tier_lib contract...")
264
+
265
+ for tier in Tier:
266
+ is_valid, error = validate_tier_enum(tier.value)
267
+ if not is_valid:
268
+ all_errors.append(f"Self-test failed: {error}")
269
+
270
+ for phase in CANONICAL_PHASE_ID_SET:
271
+ is_valid, error = validate_phase_key(phase)
272
+ if not is_valid:
273
+ all_errors.append(f"Self-test failed: {error}")
274
+
275
+ test_content = "model: composer-1"
276
+ for pattern in FORBIDDEN_SLUG_PATTERNS:
277
+ if not re.search(pattern, test_content, re.IGNORECASE):
278
+ all_errors.append(f"Self-test failed: pattern '{pattern}' not matching test content")
279
+
280
+ all_errors.extend(run_precedence_self_test())
281
+
282
+ for code in (
283
+ ReasonCode.MODEL_OVERRIDE_SLUG_UNKNOWN,
284
+ ReasonCode.MODEL_ROLE_SLUG_UNKNOWN,
285
+ ReasonCode.MODEL_CATALOG_SCHEMA_V2_INVALID,
286
+ ):
287
+ if code not in ReasonCode:
288
+ all_errors.append(f"Self-test failed: missing reason code {code.value}")
289
+
290
+ if all_errors:
291
+ print("[SELF_TEST_FAILED]")
292
+ for error in all_errors:
293
+ print(f" {error}", file=sys.stderr)
294
+ sys.exit(1)
295
+ else:
296
+ print("[DEV_ENVIRONMENT_SELF_TEST_OK]")
297
+ sys.exit(0)
298
+
299
+ catalog_dict = None
300
+
301
+ if args.catalog:
302
+ print(f"[CATALOG] Validating {args.catalog}...")
303
+ is_valid, errors, code = validate_catalog(args.catalog)
304
+ if not is_valid:
305
+ all_errors.extend(f"[{code.value}] {e}" for e in errors)
306
+ print(f"[CATALOG_INVALID] {args.catalog}", file=sys.stderr)
307
+ for error in errors:
308
+ print(f" [{code.value}] {error}", file=sys.stderr)
309
+ else:
310
+ with open(args.catalog, "r", encoding="utf-8") as f:
311
+ catalog_dict = json.load(f)
312
+
313
+ if args.scratchpad:
314
+ print(f"[SCRATCHPAD] Validating {args.scratchpad}...")
315
+ is_valid, errors = validate_scratchpad_tiers(args.scratchpad)
316
+ if not is_valid:
317
+ all_errors.extend(errors)
318
+ is_valid, errors = validate_scratchpad_direct_slugs(args.scratchpad, catalog_dict)
319
+ if not is_valid:
320
+ all_errors.extend(errors)
321
+
322
+ if args.check_template_agents:
323
+ print(f"[TEMPLATE] Checking {repo_root / 'template' / '.cursor' / 'agents'}...")
324
+ violations = check_template_agents(repo_root)
325
+ if violations:
326
+ all_errors.extend(violations)
327
+
328
+ if not args.catalog and not args.scratchpad and not args.check_template_agents:
329
+ print(f"[REPO] Validating {repo_root}...")
330
+
331
+ for example_name in (
332
+ "model-catalog.local.example.json",
333
+ "model-catalog.local.example.role-based-balanced.json",
334
+ "model-catalog.local.example.role-based-highend.json",
335
+ ):
336
+ catalog_example = repo_root / ".cursor" / example_name
337
+ if catalog_example.exists():
338
+ print(f"[CATALOG] Validating {catalog_example}...")
339
+ is_valid, errors, code = validate_catalog(catalog_example)
340
+ if not is_valid:
341
+ all_errors.extend(f"[{code.value}] {e}" for e in errors)
342
+
343
+ scratchpad = repo_root / ".cursor" / "scratchpad.md"
344
+ if scratchpad.exists():
345
+ print(f"[SCRATCHPAD] Validating {scratchpad}...")
346
+ is_valid, errors = validate_scratchpad_tiers(scratchpad)
347
+ if not is_valid:
348
+ all_errors.extend(errors)
349
+
350
+ print(f"[TEMPLATE] Checking {repo_root / 'template' / '.cursor' / 'agents'}...")
351
+ violations = check_template_agents(repo_root)
352
+ if violations:
353
+ all_errors.extend(violations)
354
+
355
+ all_errors.extend(run_precedence_self_test())
356
+
357
+ if all_errors:
358
+ print(f"\n[MODEL_TIER_VALIDATION_FAILED] {len(all_errors)} error(s)", file=sys.stderr)
359
+ for error in all_errors:
360
+ print(f" {error}", file=sys.stderr)
361
+ sys.exit(1 if args.enforce or True else 0)
362
+ else:
363
+ print("\n[MODEL_TIER_VALIDATION_OK]")
364
+ sys.exit(0)
365
+
366
+
367
+ if __name__ == "__main__":
368
+ main()
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env bash
2
+ # ─────────────────────────────────────────────────────────────────
3
+ # Unified release: npm + Chocolatey + Homebrew (all three at once)
4
+ # ─────────────────────────────────────────────────────────────────
5
+ set -euo pipefail
6
+
7
+ BUMP="patch"
8
+ NPM_TAG="latest"
9
+ SKIP_NPM=false
10
+ SKIP_CHOCO=false
11
+ SKIP_BREW=false
12
+ DRY_RUN=false
13
+
14
+ usage() {
15
+ cat <<EOF
16
+ Usage: $0 [options]
17
+ --bump <patch|minor|major|x.y.z> Version bump (default: patch)
18
+ --npm-tag <tag> npm dist-tag (default: latest)
19
+ --skip-npm Skip npm publish
20
+ --skip-choco Skip Chocolatey push
21
+ --skip-brew Skip Homebrew formula update
22
+ --dry-run Print actions without executing
23
+ -h|--help Show this help
24
+ EOF
25
+ exit 0
26
+ }
27
+
28
+ while [[ $# -gt 0 ]]; do
29
+ case "$1" in
30
+ --bump) BUMP="$2"; shift 2;;
31
+ --npm-tag) NPM_TAG="$2"; shift 2;;
32
+ --skip-npm) SKIP_NPM=true; shift;;
33
+ --skip-choco)SKIP_CHOCO=true; shift;;
34
+ --skip-brew) SKIP_BREW=true; shift;;
35
+ --dry-run) DRY_RUN=true; shift;;
36
+ -h|--help) usage;;
37
+ *) echo "Unknown option: $1"; usage;;
38
+ esac
39
+ done
40
+
41
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
42
+ cd "$REPO_ROOT"
43
+
44
+ # Node 22+: trust OS CA store when TLS inspection adds system CAs (Windows/antivirus/corp proxy).
45
+ export NODE_USE_SYSTEM_CA="${NODE_USE_SYSTEM_CA:-1}"
46
+
47
+ log() { echo -e "\033[36m[release]\033[0m $1"; }
48
+ warn() { echo -e "\033[33m[release]\033[0m $1"; }
49
+ err() { echo -e "\033[31m[release]\033[0m $1"; exit 1; }
50
+
51
+ # ── 1. Version bump ─────────────────────────────────────────────────
52
+ log "Bumping version ($BUMP) ..."
53
+ if $DRY_RUN; then
54
+ log "(dry-run) would run: npm version $BUMP --no-git-tag-version"
55
+ NEW_VERSION="0.0.0-dryrun"
56
+ else
57
+ npm version "$BUMP" --no-git-tag-version >/dev/null
58
+ NEW_VERSION=$(node -p "require('./package.json').version")
59
+ fi
60
+ TAG_NAME="v$NEW_VERSION"
61
+ log "New version: $NEW_VERSION (tag: $TAG_NAME)"
62
+ CHOCO_VERSION="$NEW_VERSION"
63
+ if [[ "$NEW_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-([0-9]+)$ ]]; then
64
+ # Old Chocolatey/NuGet rejects numeric-only prerelease labels (e.g. 0.1.1-1).
65
+ # Convert to a compatible label for nuspec only.
66
+ CHOCO_VERSION="${BASH_REMATCH[1]}-beta${BASH_REMATCH[2]}"
67
+ fi
68
+ if [[ "$CHOCO_VERSION" != "$NEW_VERSION" ]]; then
69
+ log "Chocolatey version normalized: $NEW_VERSION -> $CHOCO_VERSION"
70
+ fi
71
+
72
+ # ── 2. npm publish ──────────────────────────────────────────────────
73
+ if ! $SKIP_NPM; then
74
+ log "Publishing to npm (tag=$NPM_TAG) ..."
75
+ if $DRY_RUN; then
76
+ log "(dry-run) would run: npm publish --tag $NPM_TAG"
77
+ else
78
+ npm publish --tag "$NPM_TAG"
79
+ log "npm publish OK"
80
+ fi
81
+ else
82
+ warn "Skipping npm"
83
+ fi
84
+
85
+ # ── 3. GitHub release ───────────────────────────────────────────────
86
+ ZIP_URL=""
87
+ TAR_URL=""
88
+
89
+ GH_PRERELEASE=""
90
+ if [[ "$NEW_VERSION" == *-* ]]; then
91
+ GH_PRERELEASE="--prerelease"
92
+ fi
93
+
94
+ RELEASE_CHANGELOG_ALLOW_GENERATE_NOTES="${RELEASE_CHANGELOG_ALLOW_GENERATE_NOTES:-0}"
95
+ VERSION_NOTES="handoffs/releases/${NEW_VERSION}-release-notes.md"
96
+
97
+ if command -v gh &>/dev/null; then
98
+ log "Creating GitHub release $TAG_NAME ..."
99
+ if [[ ! -f "$VERSION_NOTES" ]]; then
100
+ log "Version notes missing — deriving via release_changelog_backfill --ensure-version ..."
101
+ if ! $DRY_RUN; then
102
+ python scripts/release_changelog_backfill.py --repo "$REPO_ROOT" --ensure-version "$NEW_VERSION" || true
103
+ fi
104
+ fi
105
+ if $DRY_RUN; then
106
+ if [[ -f "$VERSION_NOTES" ]]; then
107
+ log "(dry-run) would run: python scripts/release_changelog_validate.py --repo . --enforce"
108
+ log "(dry-run) would run: gh release create $TAG_NAME -F $VERSION_NOTES --title $TAG_NAME $GH_PRERELEASE"
109
+ else
110
+ log "(dry-run) would fail-closed: RELEASE_CHANGELOG_VERSION_DOC_MISSING ($VERSION_NOTES)"
111
+ fi
112
+ elif [[ -f "$VERSION_NOTES" ]]; then
113
+ python scripts/release_changelog_validate.py --repo "$REPO_ROOT" --enforce
114
+ gh release create "$TAG_NAME" -F "$VERSION_NOTES" --title "$TAG_NAME" $GH_PRERELEASE || warn "gh release create failed – continuing"
115
+ elif [[ "$RELEASE_CHANGELOG_ALLOW_GENERATE_NOTES" == "1" ]]; then
116
+ warn "RELEASE_CHANGELOG_VERSION_DOC_MISSING: $VERSION_NOTES — falling back to --generate-notes (opt-in)"
117
+ gh release create "$TAG_NAME" --generate-notes --title "$TAG_NAME" $GH_PRERELEASE || warn "gh release create failed – continuing"
118
+ else
119
+ err "RELEASE_CHANGELOG_VERSION_DOC_MISSING: $VERSION_NOTES (set RELEASE_CHANGELOG_ALLOW_GENERATE_NOTES=1 to opt into --generate-notes)"
120
+ fi
121
+ REMOTE_URL=$(git remote get-url origin 2>/dev/null || true)
122
+ if [[ "$REMOTE_URL" =~ github\.com[:/](.+?)(\.git)?$ ]]; then
123
+ REPO_SLUG="${BASH_REMATCH[1]}"
124
+ REPO_SLUG="${REPO_SLUG%.git}"
125
+ ZIP_URL="https://github.com/$REPO_SLUG/archive/refs/tags/$TAG_NAME.zip"
126
+ TAR_URL="https://github.com/$REPO_SLUG/archive/refs/tags/$TAG_NAME.tar.gz"
127
+ fi
128
+ else
129
+ warn "gh CLI not found – skipping GitHub release"
130
+ ZIP_URL="https://github.com/USER/its-magic/archive/refs/tags/$TAG_NAME.zip"
131
+ TAR_URL="https://github.com/USER/its-magic/archive/refs/tags/$TAG_NAME.tar.gz"
132
+ fi
133
+
134
+ # ── 4. Chocolatey ───────────────────────────────────────────────────
135
+ if ! $SKIP_CHOCO; then
136
+ NUSPEC="$REPO_ROOT/packaging/chocolatey/its-magic.nuspec"
137
+ CHOCO_INSTALL="$REPO_ROOT/packaging/chocolatey/tools/chocolateyInstall.ps1"
138
+
139
+ if [[ -f "$NUSPEC" ]]; then
140
+ log "Updating Chocolatey nuspec to $CHOCO_VERSION ..."
141
+ if ! $DRY_RUN; then
142
+ # Update version in nuspec (simple sed)
143
+ sed -i.bak "s|<version>[^<]*</version>|<version>$CHOCO_VERSION</version>|" "$NUSPEC"
144
+ rm -f "${NUSPEC}.bak"
145
+
146
+ # Update URL in install script
147
+ if [[ -n "$ZIP_URL" ]]; then
148
+ sed -i.bak "s|^\\$url[[:space:]]*=.*$|\\$url = '$ZIP_URL'|" "$CHOCO_INSTALL"
149
+ rm -f "${CHOCO_INSTALL}.bak"
150
+ fi
151
+
152
+ # Compute checksum
153
+ if [[ -n "$ZIP_URL" ]]; then
154
+ TMP_ZIP="/tmp/its-magic-$TAG_NAME.zip"
155
+ if curl -fsSL "$ZIP_URL" -o "$TMP_ZIP" 2>/dev/null; then
156
+ SHA=$(shasum -a 256 "$TMP_ZIP" | awk '{print $1}')
157
+ sed -i.bak "s|^\\$checksum[[:space:]]*=.*$|\\$checksum = '$SHA'|" "$CHOCO_INSTALL"
158
+ rm -f "${CHOCO_INSTALL}.bak" "$TMP_ZIP"
159
+ log "Chocolatey checksum: $SHA"
160
+ else
161
+ warn "Could not download zip for checksum – set PLACEHOLDER manually"
162
+ fi
163
+ fi
164
+ fi
165
+
166
+ if command -v choco &>/dev/null; then
167
+ log "Packing + pushing Chocolatey package ..."
168
+ if ! $DRY_RUN; then
169
+ (cd "$REPO_ROOT/packaging/chocolatey" && choco pack && \
170
+ NUPKG=$(ls -1t *.nupkg 2>/dev/null | head -1) && \
171
+ choco push "$NUPKG" --source https://push.chocolatey.org/ || warn "choco push failed")
172
+ fi
173
+ else
174
+ warn "choco not found – nuspec updated but not pushed"
175
+ fi
176
+ else
177
+ warn "nuspec not found – skipping Chocolatey"
178
+ fi
179
+ else
180
+ warn "Skipping Chocolatey"
181
+ fi
182
+
183
+ # ── 5. Homebrew ─────────────────────────────────────────────────────
184
+ if ! $SKIP_BREW; then
185
+ # Detect pre-release: anything with a hyphen (e.g. 0.2.0-beta.1, 1.0.0-rc.1)
186
+ IS_PRERELEASE=false
187
+ if [[ "$NEW_VERSION" == *-* ]]; then
188
+ IS_PRERELEASE=true
189
+ fi
190
+
191
+ if $IS_PRERELEASE; then
192
+ FORMULA="$REPO_ROOT/packaging/homebrew/its-magic-beta.rb"
193
+ log "Pre-release detected – using beta formula"
194
+ else
195
+ FORMULA="$REPO_ROOT/packaging/homebrew/its-magic.rb"
196
+ fi
197
+
198
+ if [[ -f "$FORMULA" ]]; then
199
+ FORMULA_NAME=$(basename "$FORMULA")
200
+ log "Updating Homebrew formula ($FORMULA_NAME) to $NEW_VERSION ..."
201
+ if ! $DRY_RUN; then
202
+ if [[ -n "$TAR_URL" ]]; then
203
+ sed -i.bak "s|url \"https://github.com/[^\"]*\.tar\.gz\"|url \"$TAR_URL\"|" "$FORMULA"
204
+ rm -f "${FORMULA}.bak"
205
+ else
206
+ sed -i.bak "s|vVERSION|$TAG_NAME|g" "$FORMULA"
207
+ rm -f "${FORMULA}.bak"
208
+ fi
209
+
210
+ # Update explicit version line for beta formula
211
+ if $IS_PRERELEASE; then
212
+ sed -i.bak "s|version \"[^\"]*\"|version \"$NEW_VERSION\"|" "$FORMULA"
213
+ rm -f "${FORMULA}.bak"
214
+ fi
215
+
216
+ # Compute sha256
217
+ if [[ -n "$TAR_URL" ]]; then
218
+ TMP_TAR="/tmp/its-magic-$TAG_NAME.tar.gz"
219
+ if curl -fsSL "$TAR_URL" -o "$TMP_TAR" 2>/dev/null; then
220
+ SHA=$(shasum -a 256 "$TMP_TAR" | awk '{print $1}')
221
+ # Replace both PLACEHOLDER and any previous sha256 value
222
+ sed -i.bak "s|sha256 \"[^\"]*\"|sha256 \"$SHA\"|" "$FORMULA"
223
+ rm -f "${FORMULA}.bak" "$TMP_TAR"
224
+ log "Homebrew sha256: $SHA"
225
+ else
226
+ warn "Could not download tar.gz – set sha256 manually"
227
+ fi
228
+ fi
229
+ log "Homebrew formula updated: $FORMULA_NAME"
230
+ if $IS_PRERELEASE; then
231
+ log "Users install beta with: brew install USER/tap/its-magic-beta"
232
+ else
233
+ log "Users install stable with: brew install USER/tap/its-magic"
234
+ fi
235
+ fi
236
+ else
237
+ warn "Formula not found – skipping Homebrew"
238
+ fi
239
+ else
240
+ warn "Skipping Homebrew"
241
+ fi
242
+
243
+ # ── Done ─────────────────────────────────────────────────────────────
244
+ log "=========================================="
245
+ log "Release $NEW_VERSION complete!"
246
+ log " npm: $(if $SKIP_NPM; then echo 'SKIPPED'; else echo 'OK'; fi)"
247
+ log " Chocolatey: $(if $SKIP_CHOCO; then echo 'SKIPPED'; else echo 'OK (check warnings)'; fi)"
248
+ log " Homebrew: $(if $SKIP_BREW; then echo 'SKIPPED'; else echo 'OK (formula updated)'; fi)"
249
+ log "=========================================="