@sweny-ai/core 0.1.66 → 0.1.68

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.
@@ -2,8 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { Command } from "commander";
6
- import { runWorkflowDiagram } from "../diagram.js";
5
+ import { runWorkflowDiagram, resolveUseBlock } from "../diagram.js";
7
6
  const sampleWorkflow = {
8
7
  id: "sample",
9
8
  name: "Sample",
@@ -32,6 +31,42 @@ function captureStream() {
32
31
  },
33
32
  };
34
33
  }
34
+ describe("resolveUseBlock", () => {
35
+ // Centralized behavior test — the renderer is a tiny switch around this
36
+ // so keeping the rules here as one table makes the policy easy to audit.
37
+ it("returns false by default (stdout, no -o, no flags)", () => {
38
+ expect(resolveUseBlock({})).toBe(false);
39
+ });
40
+ it("returns true when --block is explicit", () => {
41
+ expect(resolveUseBlock({ block: true })).toBe(true);
42
+ });
43
+ it("returns false when --no-block is explicit, even for .md", () => {
44
+ expect(resolveUseBlock({ block: false, output: "diagram.md" })).toBe(false);
45
+ });
46
+ it("infers true for .md output", () => {
47
+ expect(resolveUseBlock({ output: "diagram.md" })).toBe(true);
48
+ });
49
+ it("infers true for .markdown output", () => {
50
+ expect(resolveUseBlock({ output: "diagram.markdown" })).toBe(true);
51
+ });
52
+ it("infers false for .mmd output", () => {
53
+ expect(resolveUseBlock({ output: "diagram.mmd" })).toBe(false);
54
+ });
55
+ it("infers false for .mermaid output", () => {
56
+ expect(resolveUseBlock({ output: "diagram.mermaid" })).toBe(false);
57
+ });
58
+ it("infers false for any other extension (.txt, no ext, etc.)", () => {
59
+ expect(resolveUseBlock({ output: "diagram.txt" })).toBe(false);
60
+ expect(resolveUseBlock({ output: "diagram" })).toBe(false);
61
+ });
62
+ it("is case-insensitive on extension", () => {
63
+ expect(resolveUseBlock({ output: "Diagram.MD" })).toBe(true);
64
+ expect(resolveUseBlock({ output: "Diagram.MMD" })).toBe(false);
65
+ });
66
+ it("lets explicit --block override .mmd extension", () => {
67
+ expect(resolveUseBlock({ block: true, output: "diagram.mmd" })).toBe(true);
68
+ });
69
+ });
35
70
  describe("runWorkflowDiagram", () => {
36
71
  let tmpdir;
37
72
  const loadWorkflowFile = vi.fn((_f) => sampleWorkflow);
@@ -44,55 +79,78 @@ describe("runWorkflowDiagram", () => {
44
79
  afterEach(() => {
45
80
  fs.rmSync(tmpdir, { recursive: true, force: true });
46
81
  });
47
- describe("stdout", () => {
48
- it("writes a fenced mermaid block to stdout by default", () => {
82
+ describe("stdout — default behavior", () => {
83
+ it("writes RAW mermaid (no fence, no title frontmatter) by default", () => {
84
+ // This is the Mermaid-CLI / Live-Editor-compatible output. No ```,
85
+ // no `---\ntitle: …\n---` — just `graph TB` and the nodes.
49
86
  const stdout = captureStream();
50
87
  const stderr = captureStream();
51
88
  runWorkflowDiagram("any.yml", {}, { loadWorkflowFile, stdout: stdout.stream, stderr: stderr.stream, exit });
52
- expect(stdout.text).toMatch(/^```mermaid\n/);
53
- expect(stdout.text).toMatch(/\n```\n$/);
89
+ expect(stdout.text).not.toMatch(/```mermaid/);
90
+ expect(stdout.text).not.toMatch(/^---\ntitle:/);
91
+ expect(stdout.text).toMatch(/^graph TB/);
54
92
  expect(exit).not.toHaveBeenCalled();
55
93
  });
56
- it("writes raw mermaid when --no-block is explicit", () => {
94
+ it("wraps in a ```mermaid fence when --block is explicit", () => {
57
95
  const stdout = captureStream();
58
- const stderr = captureStream();
59
- runWorkflowDiagram("any.yml", { block: false }, { loadWorkflowFile, stdout: stdout.stream, stderr: stderr.stream, exit });
60
- expect(stdout.text).not.toMatch(/```mermaid/);
61
- expect(stdout.text).toMatch(/graph TB/);
96
+ runWorkflowDiagram("any.yml", { block: true }, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
97
+ expect(stdout.text).toMatch(/^```mermaid\n/);
98
+ expect(stdout.text).toMatch(/\n```\n$/);
62
99
  });
63
100
  it("honors --direction LR", () => {
64
101
  const stdout = captureStream();
65
102
  runWorkflowDiagram("any.yml", { direction: "LR" }, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
66
103
  expect(stdout.text).toMatch(/graph LR/);
67
104
  });
68
- it("honors --title override", () => {
105
+ it("emits --title as frontmatter only when explicitly set", () => {
69
106
  const stdout = captureStream();
70
107
  runWorkflowDiagram("any.yml", { title: "Custom Title" }, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
71
- expect(stdout.text).toContain("Custom Title");
108
+ expect(stdout.text).toMatch(/^---\ntitle: Custom Title\n---\n/);
109
+ expect(stdout.text).toContain("graph TB");
110
+ });
111
+ it("does NOT default title to the workflow name", () => {
112
+ // Regression guard: previously we auto-filled `title` from workflow.name,
113
+ // which broke mmdc / mermaid-live because they don't accept title frontmatter.
114
+ const stdout = captureStream();
115
+ runWorkflowDiagram("any.yml", {}, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
116
+ expect(stdout.text).not.toMatch(/title:/);
117
+ expect(stdout.text).not.toContain("Sample"); // workflow.name
72
118
  });
73
119
  });
74
120
  describe("-o / --output file", () => {
75
- it("writes raw mermaid to .mmd by default (no fence)", () => {
121
+ it("writes raw mermaid to .mmd (default)", () => {
76
122
  const out = path.join(tmpdir, "diagram.mmd");
77
123
  const stderr = captureStream();
78
124
  runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: stderr.stream, exit });
79
125
  const contents = fs.readFileSync(out, "utf8");
80
126
  expect(contents).not.toMatch(/```mermaid/);
81
- expect(contents).toMatch(/graph TB/);
127
+ expect(contents).not.toMatch(/title:/);
128
+ expect(contents).toMatch(/^graph TB/);
82
129
  expect(stderr.text).toMatch(/✓ Wrote diagram to/);
83
130
  });
84
- it("writes fenced markdown to .md by default", () => {
131
+ it("writes raw mermaid to .mermaid (Mermaid CLI's canonical extension)", () => {
132
+ const out = path.join(tmpdir, "diagram.mermaid");
133
+ runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: captureStream().stream, exit });
134
+ const contents = fs.readFileSync(out, "utf8");
135
+ expect(contents).not.toMatch(/```mermaid/);
136
+ expect(contents).toMatch(/^graph TB/);
137
+ });
138
+ it("writes fenced markdown to .md", () => {
85
139
  const out = path.join(tmpdir, "diagram.md");
86
140
  runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: captureStream().stream, exit });
87
141
  const contents = fs.readFileSync(out, "utf8");
88
142
  expect(contents).toMatch(/^```mermaid\n/);
89
143
  expect(contents).toMatch(/\n```\n$/);
90
144
  });
91
- it("falls back to fenced default for unknown extensions", () => {
145
+ it("writes raw mermaid for unknown extensions (no longer falls back to fenced)", () => {
146
+ // Behavior change: previously .txt and friends got fenced as the
147
+ // "safe default". Now raw mermaid is the safe default because that's
148
+ // what every renderer actually accepts.
92
149
  const out = path.join(tmpdir, "diagram.txt");
93
150
  runWorkflowDiagram("any.yml", { output: out }, { loadWorkflowFile, stdout: captureStream().stream, stderr: captureStream().stream, exit });
94
151
  const contents = fs.readFileSync(out, "utf8");
95
- expect(contents).toMatch(/```mermaid/);
152
+ expect(contents).not.toMatch(/```mermaid/);
153
+ expect(contents).toMatch(/graph TB/);
96
154
  });
97
155
  it("creates parent directories as needed (-o nested/subdir/file.mmd)", () => {
98
156
  const out = path.join(tmpdir, "a", "b", "c", "diagram.mmd");
@@ -107,51 +165,15 @@ describe("runWorkflowDiagram", () => {
107
165
  expect(stdout.text).toBe("");
108
166
  });
109
167
  });
110
- describe("explicit --block / --no-block precedence over extension inference", () => {
111
- function cmdWithBlock(block, explicit) {
112
- const cmd = new Command();
113
- cmd.option("--block", "fenced", true);
114
- cmd.option("--no-block", "raw");
115
- if (explicit) {
116
- // Simulate user typing `--block` or `--no-block` on the CLI — commander
117
- // marks the source as "cli" when the flag is seen in argv.
118
- cmd.setOptionValueWithSource("block", block, "cli");
119
- }
120
- return cmd;
121
- }
122
- it("explicit --block wins over .mmd extension (writes fenced to .mmd)", () => {
168
+ describe("explicit --block / --no-block override extension inference", () => {
169
+ it("explicit --block wins over .mmd (writes fenced to .mmd)", () => {
123
170
  const out = path.join(tmpdir, "diagram.mmd");
124
- runWorkflowDiagram("any.yml", { output: out, block: true }, {
125
- loadWorkflowFile,
126
- command: cmdWithBlock(true, true),
127
- stdout: captureStream().stream,
128
- stderr: captureStream().stream,
129
- exit,
130
- });
171
+ runWorkflowDiagram("any.yml", { output: out, block: true }, { loadWorkflowFile, stdout: captureStream().stream, stderr: captureStream().stream, exit });
131
172
  expect(fs.readFileSync(out, "utf8")).toMatch(/```mermaid/);
132
173
  });
133
- it("explicit --no-block wins over .md extension (writes raw to .md)", () => {
174
+ it("explicit --no-block wins over .md (writes raw to .md)", () => {
134
175
  const out = path.join(tmpdir, "diagram.md");
135
- runWorkflowDiagram("any.yml", { output: out, block: false }, {
136
- loadWorkflowFile,
137
- command: cmdWithBlock(false, true),
138
- stdout: captureStream().stream,
139
- stderr: captureStream().stream,
140
- exit,
141
- });
142
- expect(fs.readFileSync(out, "utf8")).not.toMatch(/```mermaid/);
143
- });
144
- it("default --block source does not suppress .mmd extension inference", () => {
145
- // Commander always sets `block: true` via the declared default, but its
146
- // source is "default" — our inference should still kick in for .mmd.
147
- const out = path.join(tmpdir, "diagram.mmd");
148
- runWorkflowDiagram("any.yml", { output: out, block: true }, {
149
- loadWorkflowFile,
150
- command: cmdWithBlock(true, false), // not explicit
151
- stdout: captureStream().stream,
152
- stderr: captureStream().stream,
153
- exit,
154
- });
176
+ runWorkflowDiagram("any.yml", { output: out, block: false }, { loadWorkflowFile, stdout: captureStream().stream, stderr: captureStream().stream, exit });
155
177
  expect(fs.readFileSync(out, "utf8")).not.toMatch(/```mermaid/);
156
178
  });
157
179
  });
@@ -160,7 +182,9 @@ describe("runWorkflowDiagram", () => {
160
182
  const stdout = captureStream();
161
183
  runWorkflowDiagram("triage", {}, { loadWorkflowFile, stdout: stdout.stream, stderr: captureStream().stream, exit });
162
184
  expect(loadWorkflowFile).not.toHaveBeenCalled();
163
- expect(stdout.text).toMatch(/```mermaid/);
185
+ expect(stdout.text).toMatch(/graph TB/);
186
+ // Confirm built-in renders raw by default too.
187
+ expect(stdout.text).not.toMatch(/```mermaid/);
164
188
  });
165
189
  it("resolves 'implement' without calling loadWorkflowFile", () => {
166
190
  const stdout = captureStream();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,317 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { compareVersions, detectPackageManager, resolveInstallRoot, installCommandFor, runUpgrade, fetchLatestFromNpm, } from "../upgrade.js";
3
+ // In-memory writable stream for pure assertions on output. Cheaper and
4
+ // simpler than a real Node stream and identical to the helper used by
5
+ // diagram.test.ts — keeps test utilities consistent across the cli/ dir.
6
+ function captureStream() {
7
+ const chunks = [];
8
+ const stream = {
9
+ write: (chunk) => {
10
+ chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
11
+ return true;
12
+ },
13
+ };
14
+ return {
15
+ stream,
16
+ get text() {
17
+ return chunks.join("");
18
+ },
19
+ };
20
+ }
21
+ /**
22
+ * Build an UpgradeDeps object with sensible defaults. Tests override only
23
+ * the bits relevant to the behavior they're asserting — keeps each case
24
+ * focused on its condition rather than plumbing.
25
+ */
26
+ function makeDeps(over = {}) {
27
+ const stdout = captureStream();
28
+ const stderr = captureStream();
29
+ const exit = vi.fn();
30
+ const runInstall = vi.fn(() => 0);
31
+ const deps = {
32
+ currentVersion: "0.1.65",
33
+ installPath: "/Users/x/.nvm/versions/node/v22/lib/node_modules/@sweny-ai/core/dist/cli/main.js",
34
+ fetchLatestVersion: vi.fn(async () => "0.1.66"),
35
+ runInstall,
36
+ canWrite: () => true,
37
+ stdout: stdout.stream,
38
+ stderr: stderr.stream,
39
+ exit,
40
+ ...over,
41
+ };
42
+ return { deps, stdout, stderr, exit, runInstall };
43
+ }
44
+ describe("compareVersions", () => {
45
+ it("ranks by numeric core segments", () => {
46
+ expect(compareVersions("0.1.10", "0.1.9")).toBeGreaterThan(0);
47
+ expect(compareVersions("0.1.9", "0.1.10")).toBeLessThan(0);
48
+ expect(compareVersions("1.0.0", "0.9.999")).toBeGreaterThan(0);
49
+ expect(compareVersions("0.1.65", "0.1.65")).toBe(0);
50
+ });
51
+ it("handles missing segments as zero", () => {
52
+ expect(compareVersions("1", "1.0.0")).toBe(0);
53
+ expect(compareVersions("1.0", "1.0.1")).toBeLessThan(0);
54
+ });
55
+ it("ranks pre-releases below their release", () => {
56
+ expect(compareVersions("1.0.0-alpha.1", "1.0.0")).toBeLessThan(0);
57
+ expect(compareVersions("1.0.0", "1.0.0-beta.3")).toBeGreaterThan(0);
58
+ expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBeLessThan(0);
59
+ });
60
+ it("treats non-numeric core segments as zero (defensive)", () => {
61
+ // Malformed numeric segments shouldn't throw or return NaN — they
62
+ // collapse to 0 so the comparison still returns a deterministic ordering.
63
+ expect(compareVersions("1.x.0", "1.0.0")).toBe(0);
64
+ expect(compareVersions("1.0.y", "1.0.0")).toBe(0);
65
+ });
66
+ });
67
+ describe("detectPackageManager", () => {
68
+ it("detects npm from nvm-style layout", () => {
69
+ expect(detectPackageManager("/Users/x/.nvm/versions/node/v22.22.0/lib/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("npm");
70
+ });
71
+ it("detects npm from fnm / asdf / vanilla Node paths", () => {
72
+ expect(detectPackageManager("/usr/lib/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("npm");
73
+ expect(detectPackageManager("/home/x/.fnm/node-versions/v22/installation/lib/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("npm");
74
+ });
75
+ it("detects pnpm global installs", () => {
76
+ expect(detectPackageManager("/Users/x/.local/share/pnpm/global/5/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("pnpm");
77
+ expect(detectPackageManager("/Users/x/Library/pnpm-global/5/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("pnpm");
78
+ });
79
+ it("detects homebrew", () => {
80
+ expect(detectPackageManager("/opt/homebrew/Cellar/sweny/0.1.65/libexec/bin/sweny")).toBe("brew");
81
+ expect(detectPackageManager("/usr/local/Cellar/sweny/0.1.65/libexec/bin/sweny")).toBe("brew");
82
+ });
83
+ it("detects volta-managed installs", () => {
84
+ expect(detectPackageManager("/Users/x/.volta/tools/image/packages/@sweny-ai/core/bin/sweny")).toBe("volta");
85
+ });
86
+ it("detects bun global installs", () => {
87
+ expect(detectPackageManager("/Users/x/.bun/install/global/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("bun");
88
+ });
89
+ it("detects yarn global", () => {
90
+ expect(detectPackageManager("/Users/x/.yarn/global/node_modules/@sweny-ai/core/dist/cli/main.js")).toBe("yarn");
91
+ });
92
+ it("normalizes Windows paths (forward-slash rules)", () => {
93
+ const win = "C:\\Users\\x\\AppData\\Roaming\\npm\\node_modules\\@sweny-ai\\core\\dist\\cli\\main.js";
94
+ expect(detectPackageManager(win)).toBe("npm");
95
+ });
96
+ it("returns 'unknown' for paths that don't look like a package-manager install", () => {
97
+ expect(detectPackageManager("/tmp/some-random-path/main.js")).toBe("unknown");
98
+ });
99
+ });
100
+ describe("resolveInstallRoot", () => {
101
+ it("returns the prefix above node_modules for a typical npm layout", () => {
102
+ const p = "/Users/x/.nvm/versions/node/v22/lib/node_modules/@sweny-ai/core/dist/cli/main.js";
103
+ expect(resolveInstallRoot(p)).toBe("/Users/x/.nvm/versions/node/v22/lib");
104
+ });
105
+ it("returns null when @sweny-ai is not in the parent chain", () => {
106
+ expect(resolveInstallRoot("/tmp/random/main.js")).toBeNull();
107
+ });
108
+ });
109
+ describe("installCommandFor", () => {
110
+ it("emits the correct CLI invocation per package manager", () => {
111
+ expect(installCommandFor("npm", "latest")).toEqual({
112
+ cmd: "npm",
113
+ args: ["install", "-g", "@sweny-ai/core@latest"],
114
+ });
115
+ expect(installCommandFor("pnpm", "latest")).toEqual({ cmd: "pnpm", args: ["add", "-g", "@sweny-ai/core@latest"] });
116
+ expect(installCommandFor("yarn", "latest")).toEqual({
117
+ cmd: "yarn",
118
+ args: ["global", "add", "@sweny-ai/core@latest"],
119
+ });
120
+ expect(installCommandFor("bun", "latest")).toEqual({ cmd: "bun", args: ["add", "-g", "@sweny-ai/core@latest"] });
121
+ expect(installCommandFor("volta", "latest")).toEqual({ cmd: "volta", args: ["install", "@sweny-ai/core@latest"] });
122
+ });
123
+ it("passes through non-default tags", () => {
124
+ expect(installCommandFor("npm", "beta")).toEqual({ cmd: "npm", args: ["install", "-g", "@sweny-ai/core@beta"] });
125
+ });
126
+ it("returns null for brew / unknown (can't drive automatically)", () => {
127
+ expect(installCommandFor("brew", "latest")).toBeNull();
128
+ expect(installCommandFor("unknown", "latest")).toBeNull();
129
+ });
130
+ });
131
+ describe("runUpgrade — no-op paths", () => {
132
+ it("reports already-latest and exits 0", async () => {
133
+ const { deps, stdout, exit, runInstall } = makeDeps({
134
+ currentVersion: "0.1.66",
135
+ fetchLatestVersion: vi.fn(async () => "0.1.66"),
136
+ });
137
+ await runUpgrade({}, deps);
138
+ expect(stdout.text).toMatch(/already the latest/);
139
+ expect(runInstall).not.toHaveBeenCalled();
140
+ expect(exit).toHaveBeenCalledWith(0);
141
+ });
142
+ it("reports when running ahead of the registry (local build) and exits 0", async () => {
143
+ const { deps, stdout, exit, runInstall } = makeDeps({
144
+ currentVersion: "0.2.0",
145
+ fetchLatestVersion: vi.fn(async () => "0.1.66"),
146
+ });
147
+ await runUpgrade({}, deps);
148
+ expect(stdout.text).toMatch(/newer than the published/);
149
+ expect(runInstall).not.toHaveBeenCalled();
150
+ expect(exit).toHaveBeenCalledWith(0);
151
+ });
152
+ it("installs anyway when --force is set even if already latest", async () => {
153
+ const { deps, runInstall, exit } = makeDeps({
154
+ currentVersion: "0.1.66",
155
+ fetchLatestVersion: vi.fn(async () => "0.1.66"),
156
+ });
157
+ await runUpgrade({ force: true }, deps);
158
+ expect(runInstall).toHaveBeenCalledWith("npm", ["install", "-g", "@sweny-ai/core@latest"]);
159
+ expect(exit).toHaveBeenCalledWith(0);
160
+ });
161
+ it("downgrades when --force is set against a local-dev build ahead of the registry", async () => {
162
+ const { deps, runInstall, exit } = makeDeps({
163
+ currentVersion: "0.2.0",
164
+ fetchLatestVersion: vi.fn(async () => "0.1.66"),
165
+ });
166
+ await runUpgrade({ force: true }, deps);
167
+ // --force explicitly overrides the "ahead" short-circuit — this is how
168
+ // someone pins back to the published train from a stale local build.
169
+ expect(runInstall).toHaveBeenCalledWith("npm", ["install", "-g", "@sweny-ai/core@latest"]);
170
+ expect(exit).toHaveBeenCalledWith(0);
171
+ });
172
+ });
173
+ describe("runUpgrade — install dispatch", () => {
174
+ it("invokes the detected package manager and exits 0 on success", async () => {
175
+ const { deps, stdout, runInstall, exit } = makeDeps();
176
+ await runUpgrade({}, deps);
177
+ expect(runInstall).toHaveBeenCalledWith("npm", ["install", "-g", "@sweny-ai/core@latest"]);
178
+ expect(stdout.text).toMatch(/Upgrading sweny 0\.1\.65 → 0\.1\.66/);
179
+ expect(stdout.text).toMatch(/✓ Upgraded sweny to 0\.1\.66/);
180
+ expect(exit).toHaveBeenCalledWith(0);
181
+ });
182
+ it("uses pnpm when the install path is a pnpm global root", async () => {
183
+ const { deps, runInstall } = makeDeps({
184
+ installPath: "/Users/x/.local/share/pnpm/global/5/node_modules/@sweny-ai/core/dist/cli/main.js",
185
+ });
186
+ await runUpgrade({}, deps);
187
+ expect(runInstall).toHaveBeenCalledWith("pnpm", ["add", "-g", "@sweny-ai/core@latest"]);
188
+ });
189
+ it("forwards a non-default --tag to the install command", async () => {
190
+ const { deps, runInstall } = makeDeps({
191
+ fetchLatestVersion: vi.fn(async (tag) => (tag === "beta" ? "0.2.0-beta.1" : "0.1.66")),
192
+ });
193
+ await runUpgrade({ tag: "beta" }, deps);
194
+ expect(runInstall).toHaveBeenCalledWith("npm", ["install", "-g", "@sweny-ai/core@beta"]);
195
+ });
196
+ it("propagates a non-zero install exit code", async () => {
197
+ const { deps, stderr, exit } = makeDeps({
198
+ runInstall: vi.fn(() => 42),
199
+ });
200
+ await runUpgrade({}, deps);
201
+ expect(stderr.text).toMatch(/exited with code 42/);
202
+ expect(exit).toHaveBeenCalledWith(42);
203
+ });
204
+ });
205
+ describe("runUpgrade — permissions + platform edges", () => {
206
+ it("prints sudo hint and exits 1 when the install prefix isn't writable", async () => {
207
+ const { deps, stderr, exit, runInstall } = makeDeps({
208
+ canWrite: () => false,
209
+ });
210
+ await runUpgrade({}, deps);
211
+ expect(stderr.text).toMatch(/no write permission/);
212
+ expect(stderr.text).toMatch(/sudo npm install -g @sweny-ai\/core@latest/);
213
+ expect(runInstall).not.toHaveBeenCalled();
214
+ expect(exit).toHaveBeenCalledWith(1);
215
+ });
216
+ it("defers to brew for homebrew installs (doesn't try to npm-install over it)", async () => {
217
+ const { deps, stdout, stderr, exit, runInstall } = makeDeps({
218
+ installPath: "/opt/homebrew/Cellar/sweny/0.1.65/libexec/bin/sweny",
219
+ });
220
+ await runUpgrade({}, deps);
221
+ expect(stdout.text).toMatch(/0\.1\.66 is available/);
222
+ expect(stderr.text).toMatch(/brew upgrade sweny/);
223
+ expect(runInstall).not.toHaveBeenCalled();
224
+ expect(exit).toHaveBeenCalledWith(1);
225
+ });
226
+ it("prints a manual-install hint for unknown layouts", async () => {
227
+ const { deps, stdout, stderr, exit, runInstall } = makeDeps({
228
+ installPath: "/tmp/some-random-path/main.js",
229
+ });
230
+ await runUpgrade({}, deps);
231
+ expect(stdout.text).toMatch(/0\.1\.66 is available/);
232
+ expect(stderr.text).toMatch(/npm install -g @sweny-ai\/core@latest/);
233
+ expect(runInstall).not.toHaveBeenCalled();
234
+ expect(exit).toHaveBeenCalledWith(1);
235
+ });
236
+ });
237
+ describe("runUpgrade — --check (dry run)", () => {
238
+ it("reports the command without running it", async () => {
239
+ const { deps, stdout, runInstall, exit } = makeDeps();
240
+ await runUpgrade({ check: true }, deps);
241
+ expect(stdout.text).toMatch(/0\.1\.66 is available/);
242
+ expect(stdout.text).toMatch(/npm install -g @sweny-ai\/core@latest/);
243
+ expect(runInstall).not.toHaveBeenCalled();
244
+ expect(exit).toHaveBeenCalledWith(0);
245
+ });
246
+ it("still short-circuits on already-latest under --check", async () => {
247
+ const { deps, stdout, exit } = makeDeps({
248
+ currentVersion: "0.1.66",
249
+ fetchLatestVersion: vi.fn(async () => "0.1.66"),
250
+ });
251
+ await runUpgrade({ check: true }, deps);
252
+ expect(stdout.text).toMatch(/already the latest/);
253
+ expect(exit).toHaveBeenCalledWith(0);
254
+ });
255
+ });
256
+ describe("runUpgrade — error handling", () => {
257
+ it("exits 1 with a helpful message when the registry fetch fails", async () => {
258
+ const { deps, stderr, exit, runInstall } = makeDeps({
259
+ fetchLatestVersion: vi.fn(async () => {
260
+ throw new Error("ENETDOWN");
261
+ }),
262
+ });
263
+ await runUpgrade({}, deps);
264
+ expect(stderr.text).toMatch(/couldn't reach the npm registry/);
265
+ expect(stderr.text).toMatch(/ENETDOWN/);
266
+ expect(runInstall).not.toHaveBeenCalled();
267
+ expect(exit).toHaveBeenCalledWith(1);
268
+ });
269
+ });
270
+ describe("fetchLatestFromNpm", () => {
271
+ it("parses the dist-tag JSON and returns the version", async () => {
272
+ const fetchSpy = vi
273
+ .spyOn(globalThis, "fetch")
274
+ .mockImplementation(async () => new Response(JSON.stringify({ version: "9.9.9" }), { status: 200 }));
275
+ try {
276
+ expect(await fetchLatestFromNpm("latest")).toBe("9.9.9");
277
+ expect(fetchSpy).toHaveBeenCalledWith("https://registry.npmjs.org/@sweny-ai/core/latest", expect.objectContaining({ headers: expect.objectContaining({ accept: "application/json" }) }));
278
+ }
279
+ finally {
280
+ fetchSpy.mockRestore();
281
+ }
282
+ });
283
+ it("throws a clear error when the registry returns non-2xx", async () => {
284
+ const fetchSpy = vi
285
+ .spyOn(globalThis, "fetch")
286
+ .mockImplementation(async () => new Response("not found", { status: 404, statusText: "Not Found" }));
287
+ try {
288
+ await expect(fetchLatestFromNpm("bogus-tag")).rejects.toThrow(/404/);
289
+ }
290
+ finally {
291
+ fetchSpy.mockRestore();
292
+ }
293
+ });
294
+ it("throws when the registry payload is missing `version`", async () => {
295
+ const fetchSpy = vi
296
+ .spyOn(globalThis, "fetch")
297
+ .mockImplementation(async () => new Response(JSON.stringify({}), { status: 200 }));
298
+ try {
299
+ await expect(fetchLatestFromNpm("latest")).rejects.toThrow(/version/);
300
+ }
301
+ finally {
302
+ fetchSpy.mockRestore();
303
+ }
304
+ });
305
+ it("URL-encodes the tag so custom tags don't break the path", async () => {
306
+ const fetchSpy = vi
307
+ .spyOn(globalThis, "fetch")
308
+ .mockImplementation(async () => new Response(JSON.stringify({ version: "1.0.0" }), { status: 200 }));
309
+ try {
310
+ await fetchLatestFromNpm("next beta");
311
+ expect(fetchSpy).toHaveBeenCalledWith("https://registry.npmjs.org/@sweny-ai/core/next%20beta", expect.anything());
312
+ }
313
+ finally {
314
+ fetchSpy.mockRestore();
315
+ }
316
+ });
317
+ });
@@ -0,0 +1 @@
1
+ export {};