@sweny-ai/core 0.1.65 → 0.1.67

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.
@@ -0,0 +1,219 @@
1
+ import * as path from "node:path";
2
+ import chalk from "chalk";
3
+ /**
4
+ * Detect the package manager that owns this install by walking the parent
5
+ * chain of the CLI entrypoint. The rules are ordered by specificity — more
6
+ * distinctive roots (bun, volta, pnpm, homebrew) beat the generic
7
+ * `lib/node_modules` npm layout.
8
+ */
9
+ export function detectPackageManager(installPath) {
10
+ // Normalize to forward slashes so Windows paths match the same patterns.
11
+ const p = installPath.replace(/\\/g, "/").toLowerCase();
12
+ if (p.includes("/homebrew/") || p.includes("/cellar/"))
13
+ return "brew";
14
+ if (p.includes("/.volta/"))
15
+ return "volta";
16
+ if (p.includes("/.bun/"))
17
+ return "bun";
18
+ // pnpm global store lives under ~/.local/share/pnpm or ~/Library/pnpm, and
19
+ // the actual binary is a symlink — the resolved path typically contains
20
+ // either `/pnpm/` or the global store marker `/pnpm-global/`.
21
+ if (p.includes("/pnpm/") || p.includes("/pnpm-global/") || p.includes("/.pnpm-store/"))
22
+ return "pnpm";
23
+ // Yarn's global install folder is ~/.config/yarn/global or
24
+ // ~/.yarn/global — check for both.
25
+ if (p.includes("/.yarn/") || p.includes("/yarn/global/"))
26
+ return "yarn";
27
+ // Default: assume npm if we see node_modules — covers nvm, fnm, asdf,
28
+ // vanilla Node, and every CI runner.
29
+ if (p.includes("/node_modules/"))
30
+ return "npm";
31
+ return "unknown";
32
+ }
33
+ /**
34
+ * Resolve the install-root directory for writability checks. For npm-style
35
+ * layouts, walk up until we find `node_modules/@sweny-ai/core` and return
36
+ * the parent of `node_modules` (the writable prefix). Returns null if the
37
+ * install path doesn't look like a package-manager install.
38
+ */
39
+ export function resolveInstallRoot(installPath) {
40
+ let cur = path.dirname(installPath);
41
+ const visited = new Set();
42
+ while (cur && !visited.has(cur) && path.dirname(cur) !== cur) {
43
+ visited.add(cur);
44
+ if (path.basename(cur) === "@sweny-ai") {
45
+ // cur = .../node_modules/@sweny-ai — parent of node_modules is the root
46
+ const nodeModules = path.dirname(cur);
47
+ if (path.basename(nodeModules) === "node_modules") {
48
+ return path.dirname(nodeModules);
49
+ }
50
+ }
51
+ cur = path.dirname(cur);
52
+ }
53
+ return null;
54
+ }
55
+ /** Build the (cmd, args) pair for a given package manager + tag. */
56
+ export function installCommandFor(pm, tag) {
57
+ const ref = `@sweny-ai/core@${tag}`;
58
+ switch (pm) {
59
+ case "npm":
60
+ return { cmd: "npm", args: ["install", "-g", ref] };
61
+ case "pnpm":
62
+ return { cmd: "pnpm", args: ["add", "-g", ref] };
63
+ case "yarn":
64
+ return { cmd: "yarn", args: ["global", "add", ref] };
65
+ case "bun":
66
+ return { cmd: "bun", args: ["add", "-g", ref] };
67
+ case "volta":
68
+ return { cmd: "volta", args: ["install", `@sweny-ai/core@${tag}`] };
69
+ case "brew":
70
+ case "unknown":
71
+ return null;
72
+ }
73
+ }
74
+ /**
75
+ * Compare two semver-ish version strings. Returns:
76
+ * - `>0` when `a > b`
77
+ * - `<0` when `a < b`
78
+ * - `0` when equal
79
+ *
80
+ * Handles numeric core (`major.minor.patch`) and pre-release strings
81
+ * conservatively: any pre-release (`-alpha.1`) is treated as *lower* than
82
+ * the same core without one, matching npm's semver ordering at the level
83
+ * of precision we need for "is the registry newer than me."
84
+ */
85
+ export function compareVersions(a, b) {
86
+ const parse = (v) => {
87
+ const [core, pre] = v.split("-", 2);
88
+ const nums = core.split(".").map((n) => {
89
+ const parsed = Number.parseInt(n, 10);
90
+ return Number.isFinite(parsed) ? parsed : 0;
91
+ });
92
+ return { nums, pre: pre ?? "" };
93
+ };
94
+ const pa = parse(a);
95
+ const pb = parse(b);
96
+ const len = Math.max(pa.nums.length, pb.nums.length);
97
+ for (let i = 0; i < len; i++) {
98
+ const av = pa.nums[i] ?? 0;
99
+ const bv = pb.nums[i] ?? 0;
100
+ if (av !== bv)
101
+ return av - bv;
102
+ }
103
+ // Core equal — a pre-release ranks lower than a release.
104
+ if (pa.pre && !pb.pre)
105
+ return -1;
106
+ if (!pa.pre && pb.pre)
107
+ return 1;
108
+ if (pa.pre < pb.pre)
109
+ return -1;
110
+ if (pa.pre > pb.pre)
111
+ return 1;
112
+ return 0;
113
+ }
114
+ /**
115
+ * Fetch the latest published version of `@sweny-ai/core` from the npm
116
+ * registry. Uses the dist-tag endpoint which returns a small, cacheable JSON
117
+ * document — no tarball download.
118
+ */
119
+ export async function fetchLatestFromNpm(tag) {
120
+ const controller = new AbortController();
121
+ const timeout = setTimeout(() => controller.abort(), 5000);
122
+ try {
123
+ const res = await fetch(`https://registry.npmjs.org/@sweny-ai/core/${encodeURIComponent(tag)}`, {
124
+ signal: controller.signal,
125
+ headers: { accept: "application/json" },
126
+ });
127
+ if (!res.ok) {
128
+ throw new Error(`npm registry returned ${res.status} ${res.statusText}`);
129
+ }
130
+ const body = (await res.json());
131
+ if (typeof body.version !== "string" || body.version.length === 0) {
132
+ throw new Error("npm registry response missing `version` field");
133
+ }
134
+ return body.version;
135
+ }
136
+ finally {
137
+ clearTimeout(timeout);
138
+ }
139
+ }
140
+ const BREW_HINT = "This CLI looks like it was installed via Homebrew. Run `brew upgrade sweny` or `brew update && brew upgrade sweny`.";
141
+ const UNKNOWN_HINT = "Couldn't detect your package manager. Install the latest manually with:\n npm install -g @sweny-ai/core@latest";
142
+ /**
143
+ * Main `sweny upgrade` entrypoint. Resolves the latest version, decides
144
+ * whether an install is needed, dispatches to the right package manager,
145
+ * and surfaces permission problems as actionable copy-paste commands.
146
+ */
147
+ export async function runUpgrade(options, deps) {
148
+ const stdout = deps.stdout ?? process.stdout;
149
+ const stderr = deps.stderr ?? process.stderr;
150
+ const exit = deps.exit ?? ((code) => process.exit(code));
151
+ const tag = options.tag && options.tag.length > 0 ? options.tag : "latest";
152
+ let latest;
153
+ try {
154
+ latest = await deps.fetchLatestVersion(tag);
155
+ }
156
+ catch (err) {
157
+ stderr.write(chalk.red(` Error: couldn't reach the npm registry — ${err instanceof Error ? err.message : String(err)}`) +
158
+ "\n");
159
+ stderr.write(chalk.dim(" Check your network connection and try again.") + "\n");
160
+ exit(1);
161
+ return;
162
+ }
163
+ const current = deps.currentVersion;
164
+ const cmp = compareVersions(current, latest);
165
+ if (cmp >= 0 && !options.force) {
166
+ if (cmp === 0) {
167
+ stdout.write(chalk.green(` ✓ sweny ${current} is already the latest.`) + "\n");
168
+ }
169
+ else {
170
+ stdout.write(chalk.yellow(` You're running sweny ${current}, which is newer than the published ${latest}. Nothing to do.`) +
171
+ "\n");
172
+ }
173
+ exit(0);
174
+ return;
175
+ }
176
+ const pm = detectPackageManager(deps.installPath);
177
+ // Homebrew has its own update cadence — we don't try to drive it from here.
178
+ if (pm === "brew") {
179
+ stdout.write(chalk.cyan(` sweny ${latest} is available (you have ${current}).`) + "\n");
180
+ stderr.write(chalk.dim(` ${BREW_HINT}`) + "\n");
181
+ exit(options.check ? 0 : 1);
182
+ return;
183
+ }
184
+ const installCmd = installCommandFor(pm, tag);
185
+ if (!installCmd) {
186
+ stdout.write(chalk.cyan(` sweny ${latest} is available (you have ${current}).`) + "\n");
187
+ stderr.write(chalk.dim(` ${UNKNOWN_HINT}`) + "\n");
188
+ exit(options.check ? 0 : 1);
189
+ return;
190
+ }
191
+ const fullCmd = [installCmd.cmd, ...installCmd.args].join(" ");
192
+ if (options.check) {
193
+ stdout.write(chalk.cyan(` sweny ${latest} is available (you have ${current}).`) + "\n");
194
+ stdout.write(chalk.dim(` Run: ${fullCmd}`) + "\n");
195
+ exit(0);
196
+ return;
197
+ }
198
+ // Permissions probe — for npm-style layouts, the user needs write access to
199
+ // the prefix. If not, we print the exact sudo-prefixed command so they can
200
+ // copy-paste without guessing.
201
+ const root = resolveInstallRoot(deps.installPath);
202
+ if (root && !deps.canWrite(root)) {
203
+ stderr.write(chalk.red(` Error: no write permission on ${root}`) + "\n");
204
+ stderr.write(chalk.dim(` Try: sudo ${fullCmd}`) + "\n");
205
+ stderr.write(chalk.dim(` Or reinstall Node with a user-writable prefix (nvm, fnm, volta) to avoid sudo.`) + "\n");
206
+ exit(1);
207
+ return;
208
+ }
209
+ stdout.write(chalk.cyan(` Upgrading sweny ${current} → ${latest} via ${pm}…`) + "\n");
210
+ stdout.write(chalk.dim(` $ ${fullCmd}`) + "\n");
211
+ const code = deps.runInstall(installCmd.cmd, installCmd.args);
212
+ if (code !== 0) {
213
+ stderr.write(chalk.red(` Error: ${installCmd.cmd} exited with code ${code}`) + "\n");
214
+ exit(code);
215
+ return;
216
+ }
217
+ stdout.write(chalk.green(` ✓ Upgraded sweny to ${latest}.`) + "\n");
218
+ exit(0);
219
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Passive "a new sweny is out" nudge. Runs as a commander `postAction` hook
3
+ * so every command prints at most one dim footer line when behind.
4
+ *
5
+ * Design goals:
6
+ * 1. Never block the user's command. Cache reads are sync; network refresh
7
+ * happens inline but bounded to a tight budget — if the registry is slow
8
+ * we swallow the error silently and try again in 24h.
9
+ * 2. Never nag in contexts where nobody's watching — CI, pipes, or when the
10
+ * user has explicitly opted out.
11
+ * 3. Never fire from inside `sweny upgrade` itself (would double-print the
12
+ * upgrade hint right before/after running the install).
13
+ */
14
+ export declare const CACHE_TTL_MS: number;
15
+ /** Budget for the background registry refresh. Short on purpose. */
16
+ export declare const REFRESH_BUDGET_MS = 1500;
17
+ export interface CacheEntry {
18
+ latest: string;
19
+ checkedAt: number;
20
+ }
21
+ export interface VersionCheckDeps {
22
+ currentVersion: string;
23
+ cachePath: string;
24
+ now: number;
25
+ env: NodeJS.ProcessEnv;
26
+ isTty: boolean;
27
+ /** The command the user ran, so we can suppress the nudge for self-referential commands. */
28
+ commandName?: string;
29
+ stderr?: NodeJS.WritableStream;
30
+ fetchLatestVersion?: (tag: string) => Promise<string>;
31
+ /** Injected so tests can make the refresh synchronous + deterministic. */
32
+ now_fn?: () => number;
33
+ }
34
+ /**
35
+ * Decide whether any version-check work should run at all. We bail early for:
36
+ * - `CI=1`: noise in pipelines and reads tokens
37
+ * - `SWENY_NO_UPDATE_CHECK=1`: explicit opt-out
38
+ * - `SWENY_OFFLINE=1`: users behind airgaps
39
+ * - Non-TTY stderr: scripts and pipes
40
+ * - `upgrade` / `update` commands: the command itself handles the hint
41
+ */
42
+ export declare function shouldSkip(env: NodeJS.ProcessEnv, isTty: boolean, commandName: string | undefined): boolean;
43
+ export declare function readCache(cachePath: string): CacheEntry | null;
44
+ export declare function writeCache(cachePath: string, entry: CacheEntry): void;
45
+ export declare function isCacheFresh(entry: CacheEntry, now: number, ttl?: number): boolean;
46
+ export declare function formatNudge(current: string, latest: string): string;
47
+ /**
48
+ * Resolve the cache path on disk. Honors `XDG_CACHE_HOME` and falls back to
49
+ * `~/.cache/sweny/version-check.json`. We isolate this so tests can inject a
50
+ * tmpdir without touching the real cache.
51
+ */
52
+ export declare function defaultCachePath(env?: NodeJS.ProcessEnv, home?: string): string;
53
+ /**
54
+ * Run the check. Prints a nudge if the cache shows a newer version, then
55
+ * refreshes the cache in the background if it's stale or missing.
56
+ *
57
+ * The refresh is awaited with a short timeout so we don't hang the CLI, but
58
+ * we also don't surface its result on *this* run — that keeps the code paths
59
+ * simple and avoids "first run after install" printing a nudge before the
60
+ * user's command finishes rendering.
61
+ */
62
+ export declare function maybeNudge(deps: VersionCheckDeps): Promise<void>;
@@ -0,0 +1,123 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import chalk from "chalk";
5
+ import { compareVersions, fetchLatestFromNpm } from "./upgrade.js";
6
+ /**
7
+ * Passive "a new sweny is out" nudge. Runs as a commander `postAction` hook
8
+ * so every command prints at most one dim footer line when behind.
9
+ *
10
+ * Design goals:
11
+ * 1. Never block the user's command. Cache reads are sync; network refresh
12
+ * happens inline but bounded to a tight budget — if the registry is slow
13
+ * we swallow the error silently and try again in 24h.
14
+ * 2. Never nag in contexts where nobody's watching — CI, pipes, or when the
15
+ * user has explicitly opted out.
16
+ * 3. Never fire from inside `sweny upgrade` itself (would double-print the
17
+ * upgrade hint right before/after running the install).
18
+ */
19
+ export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
20
+ /** Budget for the background registry refresh. Short on purpose. */
21
+ export const REFRESH_BUDGET_MS = 1500;
22
+ /**
23
+ * Decide whether any version-check work should run at all. We bail early for:
24
+ * - `CI=1`: noise in pipelines and reads tokens
25
+ * - `SWENY_NO_UPDATE_CHECK=1`: explicit opt-out
26
+ * - `SWENY_OFFLINE=1`: users behind airgaps
27
+ * - Non-TTY stderr: scripts and pipes
28
+ * - `upgrade` / `update` commands: the command itself handles the hint
29
+ */
30
+ export function shouldSkip(env, isTty, commandName) {
31
+ if (env.CI)
32
+ return true;
33
+ if (env.SWENY_NO_UPDATE_CHECK)
34
+ return true;
35
+ if (env.SWENY_OFFLINE)
36
+ return true;
37
+ if (!isTty)
38
+ return true;
39
+ if (commandName === "upgrade" || commandName === "update")
40
+ return true;
41
+ return false;
42
+ }
43
+ export function readCache(cachePath) {
44
+ try {
45
+ const raw = fs.readFileSync(cachePath, "utf8");
46
+ const parsed = JSON.parse(raw);
47
+ if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "number") {
48
+ return null;
49
+ }
50
+ if (parsed.latest.length === 0)
51
+ return null;
52
+ return { latest: parsed.latest, checkedAt: parsed.checkedAt };
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ export function writeCache(cachePath, entry) {
59
+ try {
60
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
61
+ fs.writeFileSync(cachePath, JSON.stringify(entry));
62
+ }
63
+ catch {
64
+ // Cache-write failures are non-fatal — we'll just retry next run.
65
+ }
66
+ }
67
+ export function isCacheFresh(entry, now, ttl = CACHE_TTL_MS) {
68
+ return now - entry.checkedAt < ttl;
69
+ }
70
+ export function formatNudge(current, latest) {
71
+ return chalk.dim(` › sweny ${latest} is available (you have ${current}). Run \`sweny upgrade\``);
72
+ }
73
+ /**
74
+ * Resolve the cache path on disk. Honors `XDG_CACHE_HOME` and falls back to
75
+ * `~/.cache/sweny/version-check.json`. We isolate this so tests can inject a
76
+ * tmpdir without touching the real cache.
77
+ */
78
+ export function defaultCachePath(env = process.env, home = os.homedir()) {
79
+ const xdg = env.XDG_CACHE_HOME && env.XDG_CACHE_HOME.length > 0 ? env.XDG_CACHE_HOME : path.join(home, ".cache");
80
+ return path.join(xdg, "sweny", "version-check.json");
81
+ }
82
+ /**
83
+ * Run the check. Prints a nudge if the cache shows a newer version, then
84
+ * refreshes the cache in the background if it's stale or missing.
85
+ *
86
+ * The refresh is awaited with a short timeout so we don't hang the CLI, but
87
+ * we also don't surface its result on *this* run — that keeps the code paths
88
+ * simple and avoids "first run after install" printing a nudge before the
89
+ * user's command finishes rendering.
90
+ */
91
+ export async function maybeNudge(deps) {
92
+ if (shouldSkip(deps.env, deps.isTty, deps.commandName))
93
+ return;
94
+ const stderr = deps.stderr ?? process.stderr;
95
+ const cache = readCache(deps.cachePath);
96
+ if (cache && compareVersions(deps.currentVersion, cache.latest) < 0) {
97
+ stderr.write(formatNudge(deps.currentVersion, cache.latest) + "\n");
98
+ }
99
+ if (cache && isCacheFresh(cache, deps.now))
100
+ return;
101
+ // Stale or missing — refresh in the background with a hard timeout. We
102
+ // race the fetch against a timer so the process can exit promptly even if
103
+ // the registry hangs.
104
+ const fetchFn = deps.fetchLatestVersion ?? fetchLatestFromNpm;
105
+ try {
106
+ const latest = await Promise.race([
107
+ fetchFn("latest").catch(() => null),
108
+ new Promise((resolve) => {
109
+ const t = setTimeout(() => resolve(null), REFRESH_BUDGET_MS);
110
+ // Don't keep the event loop alive purely for this timer.
111
+ if (typeof t.unref === "function") {
112
+ t.unref();
113
+ }
114
+ }),
115
+ ]);
116
+ if (latest) {
117
+ writeCache(deps.cachePath, { latest, checkedAt: deps.now });
118
+ }
119
+ }
120
+ catch {
121
+ // Swallow — network hiccups shouldn't be user-visible here.
122
+ }
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"