yadflow 3.10.1 → 3.11.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [3.11.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.1...v3.11.0) (2026-07-09)
2
+
3
+
4
+ ### Features
5
+
6
+ * notify when a newer yadflow is published ([9b7a5bf](https://github.com/abdelrahmannasr/yadflow/commit/9b7a5bfca4f27ab08ce4e3e48f2ba331c3e8bfd5))
7
+
1
8
  ## [3.10.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.0...v3.10.1) (2026-07-08)
2
9
 
3
10
 
package/README.md CHANGED
@@ -63,6 +63,9 @@ Every step stops at a gate until a human approves. New here? **Walk it lesson-by
63
63
  [guided tutorial](https://abdelrahmannasr.github.io/yadflow/tutorial/)**, or read the
64
64
  [team guide](TEAM-GUIDE.md).
65
65
 
66
+ Running `yad` tells you when a new release is out — upgrade with `npm install yadflow -g`, then
67
+ `yad update` to re-sync this project's skills. See [staying up to date](docs/CLI.md#staying-up-to-date).
68
+
66
69
  ## What `npx yadflow setup` installs
67
70
 
68
71
  ![npx yadflow setup — the guided wizard installs the yad-* skills, wires the CI gates, and stamps the .sdlc config](https://raw.githubusercontent.com/abdelrahmannasr/yadflow/main/docs/media/setup-wizard.gif)
package/bin/yad.mjs CHANGED
@@ -22,6 +22,7 @@ import { syncStatuses } from '../cli/artifact-status.mjs';
22
22
  import { runThread, runReconcile } from '../cli/thread.mjs';
23
23
  import { runReport } from '../cli/report.mjs';
24
24
  import { runUsage } from '../cli/usage.mjs';
25
+ import { maybeNotifyUpdate } from '../cli/update-notice.mjs';
25
26
 
26
27
  const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
27
28
 
@@ -146,7 +147,11 @@ ${c.bold('Options')}
146
147
  --push check --fix / update: commit + push applied changes to the default branch
147
148
  --allow-branch check --fix --push / update --push / repo refresh --push: allow committing on a non-default branch
148
149
  -h, --help Show this help
149
- -v, --version Print version`;
150
+ -v, --version Print version
151
+
152
+ ${c.bold('Environment')}
153
+ YAD_NO_UPDATE_NOTIFIER=1 Silence the "update available" notice (also off in CI)
154
+ YAD_NO_REPORT=1 Never offer to file a bug report after a failure`;
150
155
 
151
156
  const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason']);
152
157
 
@@ -364,4 +369,15 @@ main()
364
369
  } catch { /* reporting is best-effort — never mask the original failure */ }
365
370
  }
366
371
  })
367
- .finally(closePrompts);
372
+ // Runs for every command, success or failure, after any report prompt. Prints to stderr and never
373
+ // touches process.exitCode, so a command's stdout contract and exit status are unaffected.
374
+ // The try/finally is load-bearing, not defensive noise: a rejection here would escape as an
375
+ // unhandled rejection (exit 1 on an otherwise successful command) AND skip closePrompts(), leaving
376
+ // the readline handle open so the process never exits.
377
+ .finally(async () => {
378
+ try {
379
+ await maybeNotifyUpdate();
380
+ } catch { /* the notice is never worth failing or hanging a command over */ } finally {
381
+ closePrompts();
382
+ }
383
+ });
package/cli/manifest.mjs CHANGED
@@ -10,6 +10,10 @@ import { readFileSync } from 'node:fs';
10
10
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
11
11
  export const VERSION = pkg.version;
12
12
 
13
+ // The published npm package name — the registry path the update check queries, and the name in the
14
+ // `npm install <name> -g` line it prints. Read from package.json so a rename can never desync them.
15
+ export const PKG_NAME = pkg.name;
16
+
13
17
  // The upstream yadflow repo, as `owner/name` — where `yad report` files issues. Derived from
14
18
  // package.json `bugs.url` (the single source of truth) so it tracks a fork/rename automatically;
15
19
  // falls back to the canonical slug if the field is ever malformed.
@@ -0,0 +1,179 @@
1
+ // "A new yadflow is out" — the update disclaimer printed after every `yad` command.
2
+ //
3
+ // Three rules make this safe to run on every invocation:
4
+ // 1. It never throws and never touches process.exitCode. A dead registry, an unwritable home, or a
5
+ // malformed cache degrades to silence, never to a failed command.
6
+ // 2. It prints to STDERR (the `note()` convention in lib.mjs), so `--json` commands, the grounding
7
+ // bundles, and `yad -v` keep a machine-readable STDOUT.
8
+ // 3. It is cache-first: the network is touched at most once per TTL. Every other run is pure disk.
9
+ //
10
+ // Deliberately NOT suppressed on a non-TTY. Skills invoke `yad` through an agent's Bash tool, where
11
+ // stdout/stderr are piped — the usual "only notify on a TTY" guard would hide the notice from exactly
12
+ // the case we most want it in. `CI` is the suppression signal instead.
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { VERSION, PKG_NAME, UPSTREAM_REPO } from './manifest.mjs';
16
+ import { c, exists, readJSON, writeJSON, PKG_ROOT } from './lib.mjs';
17
+
18
+ export const DAY_MS = 24 * 60 * 60 * 1000;
19
+ const FETCH_TIMEOUT_MS = 1500;
20
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
21
+
22
+ // An env var counts as "set" only when it carries a meaningful value — `CI=false` and `CI=0` are
23
+ // common in shells that always export the name.
24
+ const truthy = (v) => !!v && v !== '0' && v !== 'false';
25
+
26
+ // ---- semver -------------------------------------------------------------
27
+ // A deliberately small parser: we only ever compare a released `x.y.z` against another. Anything the
28
+ // registry hands us that is not a clean triple (garbage, a range, undefined) yields null → no notice.
29
+ export function parseVersion(v) {
30
+ if (typeof v !== 'string') return null;
31
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(v.trim());
32
+ if (!m) return null;
33
+ return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ?? null };
34
+ }
35
+
36
+ // The canonical `x.y.z` form. `parseVersion` tolerates a leading `v`, so anything interpolated into
37
+ // the banner or the release-tag URL must be normalized first — otherwise a `v`-prefixed `latest`
38
+ // (from a mirror registry or a hand-edited cache) yields a dead `.../releases/tag/vv3.11.0` link.
39
+ export function normalizeVersion(v) {
40
+ const p = parseVersion(v);
41
+ return p ? `${p.major}.${p.minor}.${p.patch}${p.pre ? `-${p.pre}` : ''}` : null;
42
+ }
43
+
44
+ // True when `latest` is a release strictly newer than `current`. A prerelease `latest` never nags a
45
+ // user on a stable version — dist-tags.latest should never be one, but a mis-tagged publish would
46
+ // otherwise pester every user until it was fixed. Prereleases are not ordered against each other
47
+ // (rc.2 does not "beat" rc.1); the only prerelease transition we announce is rc → its stable.
48
+ export function isNewer(latest, current) {
49
+ const l = parseVersion(latest);
50
+ const cur = parseVersion(current);
51
+ if (!l || !cur) return false;
52
+ if (l.pre && !cur.pre) return false;
53
+ if (l.major !== cur.major) return l.major > cur.major;
54
+ if (l.minor !== cur.minor) return l.minor > cur.minor;
55
+ if (l.patch !== cur.patch) return l.patch > cur.patch;
56
+ // Same x.y.z: the stable release supersedes the prerelease of that same version, so a user sitting
57
+ // on 4.0.0-rc.1 is told when 4.0.0 final ships.
58
+ return !l.pre && !!cur.pre;
59
+ }
60
+
61
+ // ---- registry -----------------------------------------------------------
62
+ export function registryBase({ env = process.env } = {}) {
63
+ const base = env.YAD_REGISTRY_URL || env.npm_config_registry || DEFAULT_REGISTRY;
64
+ return base.replace(/\/+$/, '');
65
+ }
66
+
67
+ // The `dist-tags` endpoint returns a few dozen bytes (`{"latest":"3.10.1"}`); the packument at
68
+ // /<pkg> or /<pkg>/latest is orders of magnitude larger for the same one field.
69
+ // `fetchImpl` must NOT default to a bare `fetch` in the parameter list: default parameters are
70
+ // evaluated before the function body's try/catch is entered, so on a runtime without a global fetch
71
+ // (Node 18 started with --no-experimental-fetch) that would throw a ReferenceError straight past
72
+ // every guard here and out through bin/yad.mjs's .finally. Resolve it inside the try instead.
73
+ export async function fetchLatest({ env = process.env, timeoutMs = FETCH_TIMEOUT_MS, fetchImpl } = {}) {
74
+ try {
75
+ const doFetch = fetchImpl ?? globalThis.fetch;
76
+ if (typeof doFetch !== 'function') return null; // no fetch on this runtime — stay quiet
77
+ const url = `${registryBase({ env })}/-/package/${encodeURIComponent(PKG_NAME)}/dist-tags`;
78
+ const res = await doFetch(url, {
79
+ signal: AbortSignal.timeout(timeoutMs),
80
+ headers: { accept: 'application/json' },
81
+ });
82
+ if (!res.ok) return null;
83
+ const tags = await res.json();
84
+ return typeof tags?.latest === 'string' ? tags.latest : null;
85
+ } catch {
86
+ return null; // offline, DNS failure, timeout, non-JSON body — all mean "we don't know", not "fail"
87
+ }
88
+ }
89
+
90
+ // ---- cache --------------------------------------------------------------
91
+ // The CLI's only per-user state. Everything else it writes is project-scoped under .sdlc/.
92
+ export function cacheFile({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
93
+ if (env.YAD_CACHE_DIR) return path.join(env.YAD_CACHE_DIR, 'update-check.json');
94
+ if (env.XDG_CACHE_HOME) return path.join(env.XDG_CACHE_HOME, 'yadflow', 'update-check.json');
95
+ if (platform === 'win32' && env.LOCALAPPDATA) return path.join(env.LOCALAPPDATA, 'yadflow', 'update-check.json');
96
+ return path.join(home, '.cache', 'yadflow', 'update-check.json');
97
+ }
98
+
99
+ export const readCache = (file) => readJSON(file, null);
100
+
101
+ // A read-only home (CI images, locked-down laptops, a root-owned ~/.cache) must not break `yad`.
102
+ // Losing the cache only costs one registry round-trip per command.
103
+ export function writeCache(file, data) {
104
+ try {
105
+ writeJSON(file, data);
106
+ return true;
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ // ---- suppression --------------------------------------------------------
113
+ // `pkgRoot` carrying a .git means yad is running from a source checkout (`npm run yad`, the test
114
+ // suite's execFileSync calls), not from a global npm install. Nagging a maintainer about the version
115
+ // they are editing is noise.
116
+ export function shouldSuppress({ env = process.env, pkgRoot = PKG_ROOT } = {}) {
117
+ if (truthy(env.YAD_NO_UPDATE_NOTIFIER)) return true;
118
+ if (truthy(env.CI)) return true;
119
+ if (truthy(env.SDLC_NONINTERACTIVE)) return true;
120
+ if (exists(path.join(pkgRoot, '.git'))) return true;
121
+ return false;
122
+ }
123
+
124
+ // ---- banner -------------------------------------------------------------
125
+ // `yad update` is the necessary second half: upgrading the global CLI leaves this project's installed
126
+ // yad-* skills stamped at the old version in .sdlc/cli-version.json, which `yad doctor` then flags.
127
+ export function formatBanner(current, latest) {
128
+ // Normalize so a `v`-prefixed input can never produce `.../releases/tag/vv3.11.0`. Callers only
129
+ // reach here after isNewer(), so parseVersion has already accepted both — the ?? is belt and braces.
130
+ const v = normalizeVersion(latest) ?? latest;
131
+ const url = `https://github.com/${UPSTREAM_REPO}/releases/tag/v${v}`;
132
+ return [
133
+ '',
134
+ ` ${c.yellow('!')} ${c.bold(`${PKG_NAME} update available`)} — ${c.dim(current)} → ${c.green(v)}`,
135
+ ` ${c.dim('Changelog:')} ${url}`,
136
+ ` ${c.dim('Update:')} ${c.cyan(`npm install ${PKG_NAME} -g`)}`,
137
+ ` ${c.dim('Then:')} ${c.cyan('yad update')} ${c.dim("(re-sync this project's yad-* skills)")}`,
138
+ ].join('\n');
139
+ }
140
+
141
+ // ---- orchestrator -------------------------------------------------------
142
+ // Returns true when a banner was printed (tests assert on this; callers ignore it).
143
+ export async function maybeNotifyUpdate({
144
+ env = process.env,
145
+ now = Date.now(),
146
+ pkgRoot = PKG_ROOT,
147
+ ttlMs = DAY_MS,
148
+ current = VERSION,
149
+ out = (s) => console.error(s),
150
+ fetchImpl, // resolved to globalThis.fetch inside fetchLatest — see the note there
151
+ } = {}) {
152
+ try {
153
+ if (shouldSuppress({ env, pkgRoot })) return false;
154
+
155
+ const file = cacheFile({ env });
156
+ const cache = readCache(file);
157
+ // `age >= 0` matters: a lastCheck stamped in the future (a clock that jumped forward, an NTP
158
+ // correction, a cache synced from another machine) yields a negative age, which would read as
159
+ // "fresh" and pin a stale `latest` until real time caught up. Treat it as expired instead.
160
+ const age = now - cache?.lastCheck;
161
+ const fresh = Number.isFinite(cache?.lastCheck) && age >= 0 && age < ttlMs;
162
+
163
+ let latest = typeof cache?.latest === 'string' ? cache.latest : null;
164
+ if (!fresh) {
165
+ const fetched = await fetchLatest({ env, fetchImpl });
166
+ if (fetched) latest = fetched;
167
+ // Stamp lastCheck even when the fetch failed: an offline user would otherwise pay the full
168
+ // timeout on every single command. We keep any previously-known `latest` so the banner survives
169
+ // a temporary outage.
170
+ writeCache(file, { lastCheck: now, latest });
171
+ }
172
+
173
+ if (!isNewer(latest, current)) return false;
174
+ out(formatBanner(current, latest));
175
+ return true;
176
+ } catch {
177
+ return false; // never let the notifier turn a successful command into a failed one
178
+ }
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.10.1",
3
+ "version": "3.11.0",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",