@aion0/forge 0.10.64 → 0.10.65

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.
package/RELEASE_NOTES.md CHANGED
@@ -1,8 +1,14 @@
1
- # Forge v0.10.64
1
+ # Forge v0.10.65
2
2
 
3
3
  Released: 2026-06-10
4
4
 
5
- ## Changes since v0.10.63
5
+ ## Changes since v0.10.64
6
6
 
7
+ ### Other
8
+ - fix(connectors): cache-bust github_api manifest fetch
9
+ - feat(chat): connector _files download channel + extract_archive + create_schedule prompt mode
10
+ - feat(settings): ↻ refresh-models button — pull registry past the 24h cache
11
+ - fix(publish): preflight gh with real API call + actionable 401 hint
7
12
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.63...v0.10.64
13
+
14
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.64...v0.10.65
@@ -1799,7 +1799,7 @@ function MarketplaceProvidersSection() {
1799
1799
  }
1800
1800
 
1801
1801
  function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
1802
- const { registry: modelsRegistry } = useModelsRegistry();
1802
+ const { registry: modelsRegistry, refresh: refreshModels, loading: modelsLoading } = useModelsRegistry();
1803
1803
  const [agents, setAgents] = useState<AgentEntry[]>([]);
1804
1804
  const [loading, setLoading] = useState(true);
1805
1805
  const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
@@ -2126,6 +2126,13 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
2126
2126
  {/* Preset models */}
2127
2127
  <div className="flex items-center gap-1 mt-1.5 flex-wrap">
2128
2128
  <span className="text-[8px] text-[var(--text-secondary)]">Presets:</span>
2129
+ <button
2130
+ type="button"
2131
+ onClick={() => { void refreshModels(); }}
2132
+ disabled={modelsLoading}
2133
+ title="Refresh model list from the public-info registry (bypasses the 24h cache)"
2134
+ className="text-[8px] px-1 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
2135
+ >{modelsLoading ? '…' : '↻'}</button>
2129
2136
  {((() => {
2130
2137
  const ct = (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic');
2131
2138
  // Models pulled from forge-public-info repo (lib/public-info/fetch.ts).
@@ -116,6 +116,53 @@ async function resolveScratchRefsInArgs(args: Record<string, unknown>): Promise<
116
116
  }
117
117
  }
118
118
 
119
+ // Download channel (mirror of the scratch:// upload path). A connector that
120
+ // fetched a binary (e.g. owa.download_attachment) can't return the bytes
121
+ // through the model — the 16KB tool-result cap would shred any base64. So it
122
+ // returns `_files: [{ filename, content_base64, content_type }]` and we
123
+ // materialize them HERE: decode → write to <dataDir>/tmp/ → strip the base64
124
+ // and replace each entry with a small {filename, path, file_url, size_bytes}
125
+ // pointer the model can hand to read_forge_file / extract_archive. Bytes
126
+ // never reach the LLM. Generic — any protocol's result is scanned.
127
+ async function materializeConnectorFiles(result: ToolResult): Promise<ToolResult> {
128
+ let parsed: unknown;
129
+ try { parsed = JSON.parse(result.content); } catch { return result; }
130
+ if (!parsed || typeof parsed !== 'object') return result;
131
+ const files = (parsed as { _files?: unknown })._files;
132
+ if (!Array.isArray(files) || files.length === 0) return result;
133
+
134
+ const { mkdir, writeFile } = await import('node:fs/promises');
135
+ const { dirname, basename } = await import('node:path');
136
+ const out: Array<Record<string, unknown>> = [];
137
+ for (const f of files) {
138
+ const e = (f && typeof f === 'object') ? f as Record<string, unknown> : {};
139
+ const b64 = typeof e.content_base64 === 'string' ? e.content_base64 : '';
140
+ const rawName = String(e.filename || e.name || 'download.bin');
141
+ // Bare, sanitized name under tmp/ — no traversal, no nesting (the cache
142
+ // janitor only sweeps top-level tmp/ files).
143
+ const safe = (basename(rawName).replace(/[\0/\\]/g, '_').replace(/^\.+/, '') || 'download.bin').slice(0, 200);
144
+ if (!b64) { out.push({ ...e, error: 'no content_base64' }); continue; }
145
+ try {
146
+ const buf = Buffer.from(b64, 'base64');
147
+ const target = await resolveTmpPath(safe);
148
+ await mkdir(dirname(target), { recursive: true });
149
+ await writeFile(target, buf);
150
+ out.push({
151
+ filename: safe,
152
+ path: `tmp/${safe}`,
153
+ local_path: target,
154
+ file_url: `file://${target}`,
155
+ size_bytes: buf.length,
156
+ ...(e.content_type ? { content_type: e.content_type } : {}),
157
+ });
158
+ } catch (err) {
159
+ out.push({ filename: safe, error: (err as Error).message });
160
+ }
161
+ }
162
+ const next = { ...(parsed as Record<string, unknown>), _files: out };
163
+ return { ...result, content: JSON.stringify(next) };
164
+ }
165
+
119
166
  const BUILTINS: Record<string, BuiltinHandler> = {
120
167
  get_current_time: async () => new Date().toISOString(),
121
168
 
@@ -475,6 +522,82 @@ const BUILTINS: Record<string, BuiltinHandler> = {
475
522
  });
476
523
  },
477
524
 
525
+ // Extract a zip/tar/gz archive sitting in tmp/ (e.g. one a connector just
526
+ // downloaded via the _files channel) into tmp/<base>-extracted/, then
527
+ // return the file listing so the agent can read_forge_file each entry.
528
+ // The chat agent has no shell, so this shells out to system unzip/tar/
529
+ // gunzip on its behalf. Stays inside tmp/ on both ends.
530
+ extract_archive: async (input) => {
531
+ const params = (input as { filename?: string } | undefined) || {};
532
+ const raw = String(params.filename || '').trim().replace(/^tmp\//, '');
533
+ if (!raw) return JSON.stringify({ ok: false, error: 'filename is required (a tmp/ archive, e.g. "report.zip")' });
534
+ let archive: string;
535
+ try { archive = await resolveTmpPath(raw); }
536
+ catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
537
+ const { existsSync } = await import('node:fs');
538
+ if (!existsSync(archive)) return JSON.stringify({ ok: false, error: `archive not found: tmp/${raw}` });
539
+
540
+ const lower = raw.toLowerCase();
541
+ const base = raw.replace(/\.(zip|tar\.gz|tgz|tar|gz)$/i, '');
542
+ const outRel = `${base}-extracted`;
543
+ let outDir: string;
544
+ try { outDir = await resolveTmpPath(outRel); }
545
+ catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
546
+
547
+ const { execFile } = await import('node:child_process');
548
+ const { promisify } = await import('node:util');
549
+ const { mkdir, readdir, stat } = await import('node:fs/promises');
550
+ const { join, relative } = await import('node:path');
551
+ const run = promisify(execFile);
552
+ await mkdir(outDir, { recursive: true });
553
+
554
+ try {
555
+ if (lower.endsWith('.zip')) {
556
+ await run('unzip', ['-o', '-qq', archive, '-d', outDir], { timeout: 60000 });
557
+ } else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
558
+ await run('tar', ['-xf', archive, '-C', outDir], { timeout: 60000 });
559
+ } else if (lower.endsWith('.gz')) {
560
+ // single-file gzip → decompress to the same basename inside outDir
561
+ const { createReadStream, createWriteStream } = await import('node:fs');
562
+ const { createGunzip } = await import('node:zlib');
563
+ const { pipeline } = await import('node:stream/promises');
564
+ const dest = join(outDir, base.split('/').pop() || 'file');
565
+ await pipeline(createReadStream(archive), createGunzip(), createWriteStream(dest));
566
+ } else {
567
+ return JSON.stringify({ ok: false, error: `unsupported archive type: ${raw} (supported: .zip .tar .tar.gz .tgz .gz)` });
568
+ }
569
+ } catch (e) {
570
+ return JSON.stringify({ ok: false, error: `extraction failed: ${(e as Error).message}` });
571
+ }
572
+
573
+ // Walk the extracted tree (capped) and return tmp-relative paths.
574
+ const files: Array<{ path: string; size_bytes: number }> = [];
575
+ const { getDataDir } = await import('../dirs');
576
+ const tmpRoot = join(getDataDir(), 'tmp');
577
+ async function walk(dir: string, depth: number): Promise<void> {
578
+ if (files.length >= 500 || depth > 8) return;
579
+ for (const ent of await readdir(dir, { withFileTypes: true })) {
580
+ if (files.length >= 500) break;
581
+ const full = join(dir, ent.name);
582
+ if (ent.isDirectory()) { await walk(full, depth + 1); continue; }
583
+ if (ent.isFile()) {
584
+ const s = await stat(full);
585
+ files.push({ path: `tmp/${relative(tmpRoot, full)}`, size_bytes: s.size });
586
+ }
587
+ }
588
+ }
589
+ try { await walk(outDir, 0); } catch { /* partial listing is fine */ }
590
+
591
+ return JSON.stringify({
592
+ ok: true,
593
+ archive: `tmp/${raw}`,
594
+ extracted_dir: `tmp/${outRel}`,
595
+ count: files.length,
596
+ files,
597
+ note: 'Read any entry with read_forge_file (pass its `path`). Extracted dir is NOT auto-swept by the cache janitor (it only sweeps top-level tmp/ files).',
598
+ });
599
+ },
600
+
478
601
  // List files anywhere under <dataDir>/ (Forge's own data dir).
479
602
  // Replaces the "dispatch a task just to `ls`" workaround. The `dir`
480
603
  // param is dataDir-relative — pass "tmp" / "scratch" / "flows" /
@@ -626,8 +749,11 @@ const BUILTINS: Record<string, BuiltinHandler> = {
626
749
  const p = (input as any) || {};
627
750
  const name = String(p.name || '').trim();
628
751
  const workflow = String(p.workflow || p.body_ref || '').trim();
752
+ const promptText = String(p.prompt || '').trim();
629
753
  if (!name) return JSON.stringify({ ok: false, error: 'name is required' });
630
- if (!workflow) return JSON.stringify({ ok: false, error: 'workflow (pipeline name) is required' });
754
+ if (!workflow && !promptText) {
755
+ return JSON.stringify({ ok: false, error: 'provide either prompt (free-text instructions — dispatched as a one-shot Claude task WITH connector tools) or workflow (an existing pipeline name)' });
756
+ }
631
757
 
632
758
  // Trigger normalization: prefer every_minutes; accept at (once) or cron.
633
759
  let schedule_kind: 'period' | 'once' | 'cron' = 'period';
@@ -647,14 +773,36 @@ const BUILTINS: Record<string, BuiltinHandler> = {
647
773
  return JSON.stringify({ ok: false, error: 'one of every_minutes / at / cron is required' });
648
774
  }
649
775
 
776
+ // Body resolution. prompt mode (preferred for "run these instructions on
777
+ // a schedule") wins when both are somehow given — it dispatches a one-shot
778
+ // Claude task that HAS the full connector toolset (Teams/Mantis/etc.), so
779
+ // no pipeline YAML is needed. The prompt text is persisted to
780
+ // prompts/<slug>.yaml and the schedule row just references it by name.
781
+ let body_kind: 'pipeline' | 'prompt' = 'pipeline';
782
+ let body_ref = workflow;
783
+ const skills = Array.isArray(p.skills) ? p.skills : undefined;
784
+ if (promptText) {
785
+ const slug = (name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'schedule').slice(0, 64);
786
+ try {
787
+ const { createPrompt, getPrompt, updatePrompt } = await import('../prompts/store');
788
+ const execu = skills ? { skills } : undefined;
789
+ if (getPrompt(slug)) updatePrompt(slug, { prompt: promptText, executor: execu });
790
+ else createPrompt({ name: slug, prompt: promptText, executor: execu });
791
+ } catch (e) {
792
+ return JSON.stringify({ ok: false, error: `failed to save prompt: ${(e as Error).message}` });
793
+ }
794
+ body_kind = 'prompt';
795
+ body_ref = slug;
796
+ }
797
+
650
798
  const { createSchedule, seedNextRunAt } = await import('../schedules/store');
651
799
  try {
652
800
  const s = createSchedule({
653
801
  name,
654
- body_kind: 'pipeline',
655
- body_ref: workflow,
802
+ body_kind,
803
+ body_ref,
656
804
  input: (p.input && typeof p.input === 'object') ? p.input : {},
657
- skills: Array.isArray(p.skills) ? p.skills : undefined,
805
+ skills,
658
806
  enabled: p.enabled !== false,
659
807
  schedule_kind,
660
808
  schedule_interval_minutes,
@@ -889,6 +1037,17 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
889
1037
  required: ['filename'],
890
1038
  },
891
1039
  },
1040
+ {
1041
+ name: 'extract_archive',
1042
+ description: 'Unpack an archive sitting in tmp/ (e.g. one a connector just downloaded — owa.download_attachment etc.) into tmp/<base>-extracted/, and return the file listing. USE THIS when an attachment / download is a .zip / .tar / .tar.gz / .tgz / .gz and you need to read what is inside — you have no shell, this runs unzip/tar for you. Then read individual entries with read_forge_file using each returned `path`. Returns JSON {ok, extracted_dir, count, files:[{path,size_bytes}]}.',
1043
+ input_schema: {
1044
+ type: 'object',
1045
+ properties: {
1046
+ filename: { type: 'string', description: 'tmp/ archive to extract, e.g. "report.zip" or "tmp/logs.tar.gz". Must already be in tmp/ (a download lands there via the connector _files channel).' },
1047
+ },
1048
+ required: ['filename'],
1049
+ },
1050
+ },
892
1051
  {
893
1052
  name: 'list_forge_files',
894
1053
  description: 'List files / subdirs anywhere under <dataDir>/ (Forge\'s own data dir). PREFER THIS over `dispatch_task` with `ls` — it\'s sync, in-process, no LLM detour. `dir` is dataDir-relative: pass "tmp" for chat-saved temp files, "scratch" for task workspaces, "flows" / "prompts" / "connectors" for configs. Omit `dir` to see the dataDir root. Each entry has `path` (dataDir-relative), `kind` (file/dir), and `file_url` (file://… opens in Chrome on localhost). Sensitive items (encrypt key, sqlite DBs, server log, *-tokens.json) are filtered out automatically.',
@@ -947,12 +1106,13 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
947
1106
  },
948
1107
  {
949
1108
  name: 'create_schedule',
950
- description: 'Create a recurring (or one-off) schedule that fires a Forge pipeline on a timer. NO HTTP, NO auth — runs in-process. Use this when the user says "every N minutes/hours" / "watch X" / "monitor Y" / "auto-run pipeline on schedule". REQUIRED args: name + workflow + ONE of {every_minutes, at, cron}. Returns { ok, schedule_id, next_run_at }.',
1109
+ description: 'Create a recurring (or one-off) schedule on a timer. NO HTTP, NO auth — runs in-process. Use when the user says "every N minutes/hours" / "watch X" / "monitor Y" / "auto-run on schedule". TWO body modes — pick ONE:\n• prompt mode (PREFERRED for "do these instructions on a schedule"): pass `prompt` with free-text instructions. Each fire dispatches a one-shot Claude task that HAS THE FULL CONNECTOR TOOLSET (Teams/Mantis/GitLab/etc.) and your skills. No pipeline YAML needed — do NOT build a throwaway pipeline just to run a prompt on a timer.\n• pipeline mode: pass `workflow` (an existing pipeline name) for a structured multi-node DAG.\nREQUIRED: name + (prompt OR workflow) + ONE of {every_minutes, at, cron}. Returns { ok, schedule_id, next_run_at }.',
951
1110
  input_schema: {
952
1111
  type: 'object',
953
1112
  properties: {
954
- name: { type: 'string', description: 'Human-readable name shown in the Schedules UI.' },
955
- workflow: { type: 'string', description: 'Pipeline workflow name (file basename of flows/<name>.yaml). Run trigger_pipeline() with NO args first if unsure what names are available.' },
1113
+ name: { type: 'string', description: 'Human-readable name shown in the Schedules UI. Also used as the prompt slug in prompt mode.' },
1114
+ prompt: { type: 'string', description: 'PROMPT MODE: free-text instructions run as a one-shot Claude task on each fire, with full connector tools + skills. Use this for "every hour check Teams channel X and notify me" style asks. Mutually exclusive with workflow.' },
1115
+ workflow: { type: 'string', description: 'PIPELINE MODE: existing pipeline workflow name (basename of flows/<name>.yaml). Run trigger_pipeline() with NO args first to list. Mutually exclusive with prompt.' },
956
1116
  input: { type: 'object', description: 'Pipeline input fields. Same shape as trigger_pipeline.input. OMIT optional fields to use defaults.' },
957
1117
  skills: { type: 'array', items: { type: 'string' }, description: 'Skill names to inject into every Claude task this schedule spawns.' },
958
1118
  every_minutes: { type: 'number', description: 'Period in minutes (e.g. 60 = hourly). Most common trigger.' },
@@ -961,7 +1121,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
961
1121
  enabled: { type: 'boolean', description: 'Whether to start enabled. Default true.' },
962
1122
  action: { type: 'string', enum: ['none', 'chat', 'email', 'telegram'], description: 'Post-run notification action. Default "none".' },
963
1123
  },
964
- required: ['name', 'workflow'],
1124
+ required: ['name'],
965
1125
  },
966
1126
  },
967
1127
  {
@@ -1374,6 +1534,14 @@ export async function dispatchTool(
1374
1534
  return { content: `unknown protocol "${protocol}" on tool ${call.name}`, is_error: true };
1375
1535
  }
1376
1536
 
1537
+ // Download channel: materialize any `_files` base64 payload to tmp/ and
1538
+ // strip the bytes before they reach the model (and before the watch
1539
+ // below re-parses the result). No-op when the result has no _files.
1540
+ if (!result.is_error) {
1541
+ try { result = await materializeConnectorFiles(result); }
1542
+ catch (e) { console.warn('[dispatch] materializeConnectorFiles failed', (e as Error).message); }
1543
+ }
1544
+
1377
1545
  // Async (long-task watch): if the tool declared an `async` block and
1378
1546
  // it ran without error, register a background watch that polls to
1379
1547
  // completion and reports back to the originating chat session. The
@@ -335,11 +335,16 @@ export async function fetchSourceFile(source: SourceMeta, relPath: string): Prom
335
335
  // directly (no base64 decode needed). Works for private repos given
336
336
  // a Fine-grained PAT with Contents: read.
337
337
  const repo = (source.repo_url || '').replace(/^github\.com\//, '');
338
- const url = `https://api.github.com/repos/${repo}/contents/${relPath}?ref=main`;
338
+ // &_t + no-cache: the GitHub contents API edge-caches the ?ref=main blob,
339
+ // so right after a push a plain fetch can return the STALE file — which
340
+ // made the marketplace keep showing old connector versions even after
341
+ // "↻ Sync all". The raw mode already busts via cacheBust(); match it here.
342
+ const url = `https://api.github.com/repos/${repo}/contents/${relPath}?ref=main&_t=${Date.now()}`;
339
343
  try {
340
344
  return await rawFetch(url, {
341
345
  Authorization: `Bearer ${source.github_pat}`,
342
346
  Accept: 'application/vnd.github.raw',
347
+ 'Cache-Control': 'no-cache',
343
348
  });
344
349
  } catch (err) {
345
350
  // GitHub returns 404 (not 401) for private repos when the PAT
@@ -1,8 +1,20 @@
1
1
  # Schedules
2
2
 
3
- **Schedule = a pipeline + input + trigger.** Forge runs the named pipeline with
4
- your input whenever the trigger fires. The pipeline is the only execution unit;
5
- Schedule just decides *when* to fire.
3
+ **Schedule = a body + trigger.** The trigger decides *when* to fire; the body
4
+ decides *what* runs. There are two body kinds pick whichever fits:
5
+
6
+ - **Prompt** (simplest, most common) — free-text instructions run as a one-shot
7
+ Claude task on each fire. **The task has the full connector toolset
8
+ (Teams / Mantis / GitLab / etc.) plus any skills you attach** — so "every hour
9
+ check the FortiNAC Teams channel and message me anything new" is a single
10
+ prompt schedule. You do **not** need a pipeline for this. The prompt text is
11
+ saved to `prompts/<slug>.yaml` and the schedule row references it by name.
12
+ - **Pipeline** — fires an existing pipeline (`flows/<name>.yaml`) with input.
13
+ Use when you need a structured multi-node DAG, `for_each:` iteration, or
14
+ per-node project/worktree control.
15
+
16
+ > Don't build a throwaway pipeline just to run a prompt on a timer — use prompt
17
+ > mode. The chat `create_schedule` tool accepts either `prompt` or `workflow`.
6
18
 
7
19
  **Note:** Jobs (the older subsystem) is deprecated — its UI is hidden and the
8
20
  scheduler no longer ticks. Any existing rows in the `jobs` table are inert.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.64",
3
+ "version": "0.10.65",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
package/publish.sh CHANGED
@@ -9,11 +9,18 @@
9
9
 
10
10
  set -e
11
11
 
12
- # Preflight: fail BEFORE any version bump / commit / tag / push if gh is
13
- # not authenticated. Otherwise the release step 401s after everything else
14
- # already happened, leaving a tag pushed without a GitHub Release.
12
+ # Preflight: fail BEFORE any version bump / commit / tag / push if gh can't
13
+ # actually talk to GitHub. We hit a real endpoint (gh api user) instead of
14
+ # `gh auth status` status only checks a token is STORED, not that it still
15
+ # works; a stale/revoked keyring token passes status but 401s on every call.
16
+ GH_LOGIN_HINT="gh auth login --hostname github.com --web --git-protocol ssh"
15
17
  if command -v gh &> /dev/null; then
16
- gh auth status &> /dev/null || { echo "✗ gh is not authenticated — run 'gh auth login' first (the GitHub Release step would 401)."; exit 1; }
18
+ gh api user &> /dev/null || {
19
+ echo "✗ gh can't authenticate to GitHub (token missing/expired/revoked)."
20
+ echo " Re-auth, then re-run ./publish.sh:"
21
+ echo " $GH_LOGIN_HINT"
22
+ exit 1
23
+ }
17
24
  fi
18
25
 
19
26
  VERSION_ARG=${1:-patch}
@@ -144,8 +151,18 @@ git push origin "v$NEW_VERSION"
144
151
  if command -v gh &> /dev/null; then
145
152
  echo ""
146
153
  echo "Creating GitHub Release..."
147
- gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes-file "$RELEASE_NOTES_FILE"
148
- echo "✓ GitHub Release created: https://github.com/aiwatching/forge/releases/tag/v$NEW_VERSION"
154
+ if gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes-file "$RELEASE_NOTES_FILE"; then
155
+ echo "✓ GitHub Release created: https://github.com/aiwatching/forge/releases/tag/v$NEW_VERSION"
156
+ else
157
+ # The tag is already pushed at this point — re-auth and just create the
158
+ # release, no need to re-run the whole publish. A stale keyring token
159
+ # 401s here even though `gh auth status` claims you're logged in.
160
+ echo ""
161
+ echo "✗ GitHub Release failed (usually a stale gh token — 401). The tag v$NEW_VERSION is already pushed."
162
+ echo " Fix: re-auth, then create just the release:"
163
+ echo " $GH_LOGIN_HINT"
164
+ echo " gh release create v$NEW_VERSION --title v$NEW_VERSION --notes-file $RELEASE_NOTES_FILE"
165
+ fi
149
166
  else
150
167
  echo ""
151
168
  echo "gh CLI not found. Create release manually:"