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 +7 -0
- package/README.md +3 -0
- package/bin/yad.mjs +18 -2
- package/cli/manifest.mjs +4 -0
- package/cli/update-notice.mjs +179 -0
- package/package.json +1 -1
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
|

|
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
|
-
.
|
|
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.
|
|
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",
|