@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.
- package/dist/cli/__tests__/diagram.test.js +84 -60
- package/dist/cli/__tests__/upgrade.test.d.ts +1 -0
- package/dist/cli/__tests__/upgrade.test.js +317 -0
- package/dist/cli/__tests__/version-check.test.d.ts +1 -0
- package/dist/cli/__tests__/version-check.test.js +209 -0
- package/dist/cli/diagram.d.ts +27 -8
- package/dist/cli/diagram.js +35 -19
- package/dist/cli/main.js +76 -8
- package/dist/cli/upgrade.d.ts +74 -0
- package/dist/cli/upgrade.js +219 -0
- package/dist/cli/version-check.d.ts +62 -0
- package/dist/cli/version-check.js +123 -0
- package/package.json +1 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { shouldSkip, readCache, writeCache, isCacheFresh, formatNudge, defaultCachePath, maybeNudge, CACHE_TTL_MS, } from "../version-check.js";
|
|
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
|
+
describe("shouldSkip", () => {
|
|
22
|
+
it("skips in CI", () => {
|
|
23
|
+
expect(shouldSkip({ CI: "1" }, true, "run")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
it("skips when SWENY_NO_UPDATE_CHECK is set", () => {
|
|
26
|
+
expect(shouldSkip({ SWENY_NO_UPDATE_CHECK: "1" }, true, "run")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it("skips when SWENY_OFFLINE is set", () => {
|
|
29
|
+
expect(shouldSkip({ SWENY_OFFLINE: "1" }, true, "run")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it("skips non-TTY invocations (pipes, scripts)", () => {
|
|
32
|
+
expect(shouldSkip({}, false, "run")).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it("skips the upgrade command itself to avoid double-printing", () => {
|
|
35
|
+
expect(shouldSkip({}, true, "upgrade")).toBe(true);
|
|
36
|
+
expect(shouldSkip({}, true, "update")).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it("allows the nudge in an interactive TTY with no opt-out", () => {
|
|
39
|
+
expect(shouldSkip({}, true, "run")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe("cache I/O", () => {
|
|
43
|
+
let tmpdir;
|
|
44
|
+
let cachePath;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-version-check-"));
|
|
47
|
+
cachePath = path.join(tmpdir, "sub", "version-check.json");
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
fs.rmSync(tmpdir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
it("round-trips a valid cache entry", () => {
|
|
53
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 12345 });
|
|
54
|
+
expect(readCache(cachePath)).toEqual({ latest: "0.1.66", checkedAt: 12345 });
|
|
55
|
+
});
|
|
56
|
+
it("creates parent directories as needed", () => {
|
|
57
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 12345 });
|
|
58
|
+
expect(fs.existsSync(cachePath)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
it("returns null for a missing cache file", () => {
|
|
61
|
+
expect(readCache(cachePath)).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
it("returns null when the cache is malformed JSON", () => {
|
|
64
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
65
|
+
fs.writeFileSync(cachePath, "{{{not json");
|
|
66
|
+
expect(readCache(cachePath)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
it("returns null when required fields are missing or wrong type", () => {
|
|
69
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
70
|
+
fs.writeFileSync(cachePath, JSON.stringify({ latest: "0.1.66" }));
|
|
71
|
+
expect(readCache(cachePath)).toBeNull();
|
|
72
|
+
fs.writeFileSync(cachePath, JSON.stringify({ checkedAt: 1 }));
|
|
73
|
+
expect(readCache(cachePath)).toBeNull();
|
|
74
|
+
fs.writeFileSync(cachePath, JSON.stringify({ latest: "", checkedAt: 1 }));
|
|
75
|
+
expect(readCache(cachePath)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
it("never throws on unwritable cache directories (degrades silently)", () => {
|
|
78
|
+
// Point at a path where the parent is a file — mkdirSync will throw,
|
|
79
|
+
// and writeCache must swallow it.
|
|
80
|
+
const blocker = path.join(tmpdir, "blocker");
|
|
81
|
+
fs.writeFileSync(blocker, "im-a-file");
|
|
82
|
+
expect(() => writeCache(path.join(blocker, "cache.json"), { latest: "0.1.66", checkedAt: 1 })).not.toThrow();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe("isCacheFresh", () => {
|
|
86
|
+
it("returns true within TTL", () => {
|
|
87
|
+
expect(isCacheFresh({ latest: "x", checkedAt: 1000 }, 1000 + CACHE_TTL_MS - 1)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
it("returns false at or past TTL", () => {
|
|
90
|
+
expect(isCacheFresh({ latest: "x", checkedAt: 1000 }, 1000 + CACHE_TTL_MS)).toBe(false);
|
|
91
|
+
expect(isCacheFresh({ latest: "x", checkedAt: 1000 }, 1000 + CACHE_TTL_MS + 1)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("formatNudge", () => {
|
|
95
|
+
it("includes both versions and the upgrade command", () => {
|
|
96
|
+
const out = formatNudge("0.1.65", "0.1.66");
|
|
97
|
+
expect(out).toContain("0.1.65");
|
|
98
|
+
expect(out).toContain("0.1.66");
|
|
99
|
+
expect(out).toContain("sweny upgrade");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe("defaultCachePath", () => {
|
|
103
|
+
it("honors XDG_CACHE_HOME when set", () => {
|
|
104
|
+
const p = defaultCachePath({ XDG_CACHE_HOME: "/custom/cache" }, "/home/x");
|
|
105
|
+
expect(p).toBe(path.join("/custom/cache", "sweny", "version-check.json"));
|
|
106
|
+
});
|
|
107
|
+
it("falls back to ~/.cache when XDG_CACHE_HOME is unset", () => {
|
|
108
|
+
expect(defaultCachePath({}, "/home/x")).toBe(path.join("/home/x/.cache", "sweny", "version-check.json"));
|
|
109
|
+
});
|
|
110
|
+
it("treats empty XDG_CACHE_HOME as unset (defensive)", () => {
|
|
111
|
+
expect(defaultCachePath({ XDG_CACHE_HOME: "" }, "/home/x")).toBe(path.join("/home/x/.cache", "sweny", "version-check.json"));
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe("maybeNudge", () => {
|
|
115
|
+
let tmpdir;
|
|
116
|
+
let cachePath;
|
|
117
|
+
let stderr;
|
|
118
|
+
const fetchOk = vi.fn(async () => "9.9.9");
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "sweny-nudge-"));
|
|
121
|
+
cachePath = path.join(tmpdir, "version-check.json");
|
|
122
|
+
stderr = captureStream();
|
|
123
|
+
fetchOk.mockClear();
|
|
124
|
+
});
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
fs.rmSync(tmpdir, { recursive: true, force: true });
|
|
127
|
+
});
|
|
128
|
+
function baseDeps(over = {}) {
|
|
129
|
+
return {
|
|
130
|
+
currentVersion: "0.1.65",
|
|
131
|
+
cachePath,
|
|
132
|
+
now: 1_000_000,
|
|
133
|
+
env: {},
|
|
134
|
+
isTty: true,
|
|
135
|
+
commandName: "run",
|
|
136
|
+
stderr: stderr.stream,
|
|
137
|
+
fetchLatestVersion: fetchOk,
|
|
138
|
+
...over,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
it("prints nudge when cache shows a newer release", async () => {
|
|
142
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 1_000_000 });
|
|
143
|
+
await maybeNudge(baseDeps());
|
|
144
|
+
expect(stderr.text).toMatch(/0\.1\.66 is available/);
|
|
145
|
+
expect(stderr.text).toMatch(/sweny upgrade/);
|
|
146
|
+
});
|
|
147
|
+
it("stays silent when cache is equal or lower than current", async () => {
|
|
148
|
+
writeCache(cachePath, { latest: "0.1.65", checkedAt: 1_000_000 });
|
|
149
|
+
await maybeNudge(baseDeps());
|
|
150
|
+
expect(stderr.text).toBe("");
|
|
151
|
+
});
|
|
152
|
+
it("refreshes the cache when stale and writes the fetched version", async () => {
|
|
153
|
+
writeCache(cachePath, { latest: "0.1.64", checkedAt: 0 });
|
|
154
|
+
await maybeNudge(baseDeps({ now: CACHE_TTL_MS + 10 }));
|
|
155
|
+
expect(fetchOk).toHaveBeenCalledWith("latest");
|
|
156
|
+
const after = readCache(cachePath);
|
|
157
|
+
expect(after?.latest).toBe("9.9.9");
|
|
158
|
+
expect(after?.checkedAt).toBe(CACHE_TTL_MS + 10);
|
|
159
|
+
});
|
|
160
|
+
it("refreshes when cache is missing and does not crash when fetch fails", async () => {
|
|
161
|
+
const fetchFail = vi.fn(async () => {
|
|
162
|
+
throw new Error("ENETDOWN");
|
|
163
|
+
});
|
|
164
|
+
await maybeNudge(baseDeps({ fetchLatestVersion: fetchFail }));
|
|
165
|
+
expect(fetchFail).toHaveBeenCalled();
|
|
166
|
+
expect(readCache(cachePath)).toBeNull(); // fetch failed, nothing written
|
|
167
|
+
expect(stderr.text).toBe(""); // silent on error
|
|
168
|
+
});
|
|
169
|
+
it("gives up silently when the registry is slower than the budget", async () => {
|
|
170
|
+
// The race between fetch and the internal timer means a slow fetch should
|
|
171
|
+
// resolve to null → no cache write → no nudge. We pin down the behavior
|
|
172
|
+
// here so a future regression (forgetting the race or the timeout) gets
|
|
173
|
+
// caught even though the budget is configured in the module itself.
|
|
174
|
+
const slowFetch = vi.fn(() => new Promise((resolve) => {
|
|
175
|
+
// Never resolves within the test — the internal timer is what wins.
|
|
176
|
+
const t = setTimeout(() => resolve("9.9.9"), 10_000);
|
|
177
|
+
if (typeof t.unref === "function") {
|
|
178
|
+
t.unref();
|
|
179
|
+
}
|
|
180
|
+
}));
|
|
181
|
+
await maybeNudge(baseDeps({ fetchLatestVersion: slowFetch }));
|
|
182
|
+
expect(slowFetch).toHaveBeenCalled();
|
|
183
|
+
expect(readCache(cachePath)).toBeNull();
|
|
184
|
+
expect(stderr.text).toBe("");
|
|
185
|
+
});
|
|
186
|
+
it("does not refresh when cache is fresh (saves bandwidth)", async () => {
|
|
187
|
+
writeCache(cachePath, { latest: "0.1.65", checkedAt: 1_000_000 });
|
|
188
|
+
await maybeNudge(baseDeps({ now: 1_000_000 + 1 }));
|
|
189
|
+
expect(fetchOk).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
it("skips entirely in CI — no nudge, no fetch", async () => {
|
|
192
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 1_000_000 });
|
|
193
|
+
await maybeNudge(baseDeps({ env: { CI: "1" } }));
|
|
194
|
+
expect(stderr.text).toBe("");
|
|
195
|
+
expect(fetchOk).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
it("skips entirely on non-TTY stderr", async () => {
|
|
198
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 1_000_000 });
|
|
199
|
+
await maybeNudge(baseDeps({ isTty: false }));
|
|
200
|
+
expect(stderr.text).toBe("");
|
|
201
|
+
expect(fetchOk).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
it("suppresses the nudge from within `sweny upgrade` itself", async () => {
|
|
204
|
+
writeCache(cachePath, { latest: "0.1.66", checkedAt: 1_000_000 });
|
|
205
|
+
await maybeNudge(baseDeps({ commandName: "upgrade" }));
|
|
206
|
+
expect(stderr.text).toBe("");
|
|
207
|
+
expect(fetchOk).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
});
|
package/dist/cli/diagram.d.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
import type { Command } from "commander";
|
|
2
1
|
import type { Workflow } from "../types.js";
|
|
3
2
|
export interface DiagramOptions {
|
|
4
3
|
direction?: string;
|
|
4
|
+
/**
|
|
5
|
+
* Optional diagram title. When omitted we render pure Mermaid with no
|
|
6
|
+
* `---\ntitle: …\n---` frontmatter — the format that `mmdc`, Mermaid Live
|
|
7
|
+
* Editor, and `@mermaid-js/mermaid-cli` all accept as-is.
|
|
8
|
+
*/
|
|
5
9
|
title?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Wrap output in a ```mermaid fenced code block. Tri-state:
|
|
12
|
+
* - `true` → fenced (forced by explicit `--block`)
|
|
13
|
+
* - `false` → raw (forced by explicit `--no-block`)
|
|
14
|
+
* - `undefined` → inferred from `--output` extension; default is raw
|
|
15
|
+
*/
|
|
6
16
|
block?: boolean;
|
|
7
17
|
output?: string;
|
|
8
18
|
}
|
|
9
19
|
export interface DiagramDeps {
|
|
10
20
|
/** Load a workflow file (YAML or JSON). Injected so tests can stub. */
|
|
11
21
|
loadWorkflowFile: (file: string) => Workflow;
|
|
12
|
-
/** Commander instance used to detect whether --block was set explicitly. */
|
|
13
|
-
command?: Command;
|
|
14
22
|
/** Stream for success/error messages. Defaults to process.stderr. */
|
|
15
23
|
stderr?: NodeJS.WritableStream;
|
|
16
24
|
/** Stream for diagram output when no --output is set. Defaults to process.stdout. */
|
|
@@ -18,13 +26,24 @@ export interface DiagramDeps {
|
|
|
18
26
|
/** Exit handler. Defaults to process.exit. Tests can throw instead. */
|
|
19
27
|
exit?: (code: number) => void;
|
|
20
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve whether output should be wrapped in a ```mermaid fenced block.
|
|
31
|
+
*
|
|
32
|
+
* Precedence:
|
|
33
|
+
* 1. Explicit `--block` / `--no-block` always wins.
|
|
34
|
+
* 2. When `-o` points at `.md`/`.markdown` we fence (markdown context).
|
|
35
|
+
* 3. Everything else — stdout, `.mmd`, `.mermaid`, unknown extensions —
|
|
36
|
+
* emits raw Mermaid syntax that Mermaid CLI / Live Editor / mmdc can
|
|
37
|
+
* consume directly with no preprocessing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveUseBlock(options: DiagramOptions): boolean;
|
|
21
40
|
/**
|
|
22
41
|
* Render a workflow as a Mermaid diagram and write to stdout or a file.
|
|
23
42
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
43
|
+
* By default the output is pure Mermaid — no ```mermaid fence, no
|
|
44
|
+
* `---\ntitle: …\n---` frontmatter — so the result is directly consumable
|
|
45
|
+
* by Mermaid CLI (`mmdc`) and the Live Editor. Pass `--block` to wrap in a
|
|
46
|
+
* fenced code block for README embedding, or `--title` to inject a title
|
|
47
|
+
* header for Mermaid renderers that support it.
|
|
29
48
|
*/
|
|
30
49
|
export declare function runWorkflowDiagram(file: string, options: DiagramOptions, deps: DiagramDeps): void;
|
package/dist/cli/diagram.js
CHANGED
|
@@ -3,14 +3,37 @@ import * as path from "node:path";
|
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import { toMermaid, toMermaidBlock } from "../mermaid.js";
|
|
5
5
|
import { triageWorkflow, implementWorkflow, seedContentWorkflow } from "../workflows/index.js";
|
|
6
|
+
const MARKDOWN_EXTS = new Set([".md", ".markdown"]);
|
|
7
|
+
/**
|
|
8
|
+
* Resolve whether output should be wrapped in a ```mermaid fenced block.
|
|
9
|
+
*
|
|
10
|
+
* Precedence:
|
|
11
|
+
* 1. Explicit `--block` / `--no-block` always wins.
|
|
12
|
+
* 2. When `-o` points at `.md`/`.markdown` we fence (markdown context).
|
|
13
|
+
* 3. Everything else — stdout, `.mmd`, `.mermaid`, unknown extensions —
|
|
14
|
+
* emits raw Mermaid syntax that Mermaid CLI / Live Editor / mmdc can
|
|
15
|
+
* consume directly with no preprocessing.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveUseBlock(options) {
|
|
18
|
+
if (options.block === true)
|
|
19
|
+
return true;
|
|
20
|
+
if (options.block === false)
|
|
21
|
+
return false;
|
|
22
|
+
if (options.output) {
|
|
23
|
+
const ext = path.extname(options.output).toLowerCase();
|
|
24
|
+
if (MARKDOWN_EXTS.has(ext))
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
6
29
|
/**
|
|
7
30
|
* Render a workflow as a Mermaid diagram and write to stdout or a file.
|
|
8
31
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
32
|
+
* By default the output is pure Mermaid — no ```mermaid fence, no
|
|
33
|
+
* `---\ntitle: …\n---` frontmatter — so the result is directly consumable
|
|
34
|
+
* by Mermaid CLI (`mmdc`) and the Live Editor. Pass `--block` to wrap in a
|
|
35
|
+
* fenced code block for README embedding, or `--title` to inject a title
|
|
36
|
+
* header for Mermaid renderers that support it.
|
|
14
37
|
*/
|
|
15
38
|
export function runWorkflowDiagram(file, options, deps) {
|
|
16
39
|
const stderr = deps.stderr ?? process.stderr;
|
|
@@ -38,21 +61,14 @@ export function runWorkflowDiagram(file, options, deps) {
|
|
|
38
61
|
}
|
|
39
62
|
}
|
|
40
63
|
const direction = (options.direction === "LR" ? "LR" : "TB");
|
|
41
|
-
const
|
|
42
|
-
// Explicit --block / --no-block always wins. When commander's source is
|
|
43
|
-
// "cli" or "env" the user (or env var) set it; otherwise we may infer.
|
|
44
|
-
const blockSource = deps.command?.getOptionValueSource("block");
|
|
45
|
-
const blockExplicit = blockSource === "cli" || blockSource === "env";
|
|
46
|
-
let useBlock = options.block !== false;
|
|
47
|
-
if (!blockExplicit && options.output) {
|
|
48
|
-
const ext = path.extname(options.output).toLowerCase();
|
|
49
|
-
if (ext === ".mmd")
|
|
50
|
-
useBlock = false;
|
|
51
|
-
else if (ext === ".md")
|
|
52
|
-
useBlock = true;
|
|
53
|
-
}
|
|
64
|
+
const useBlock = resolveUseBlock(options);
|
|
54
65
|
const render = useBlock ? toMermaidBlock : toMermaid;
|
|
55
|
-
|
|
66
|
+
// Title is opt-in only — an unset `--title` produces no frontmatter. Users
|
|
67
|
+
// who want the workflow.name as title can pass `--title "$(name)"`.
|
|
68
|
+
const renderOpts = { direction };
|
|
69
|
+
if (options.title)
|
|
70
|
+
renderOpts.title = options.title;
|
|
71
|
+
const output = render(workflow, renderOpts) + "\n";
|
|
56
72
|
if (options.output) {
|
|
57
73
|
try {
|
|
58
74
|
// Create parent dirs so `-o some/nested/diagram.mmd` works without a
|
package/dist/cli/main.js
CHANGED
|
@@ -31,6 +31,9 @@ import { checkProviderConnectivity } from "./check.js";
|
|
|
31
31
|
import { registerSetupCommand } from "./setup.js";
|
|
32
32
|
import { registerPublishCommand } from "./publish.js";
|
|
33
33
|
import { reportToCloud } from "./cloud-report.js";
|
|
34
|
+
import { runUpgrade, fetchLatestFromNpm } from "./upgrade.js";
|
|
35
|
+
import { maybeNudge, defaultCachePath } from "./version-check.js";
|
|
36
|
+
import { spawnSync } from "node:child_process";
|
|
34
37
|
// ── Stream observer (NDJSON) ────────────────────────────────────────
|
|
35
38
|
/**
|
|
36
39
|
* Create an observer that writes NDJSON ExecutionEvents to stdout.
|
|
@@ -796,14 +799,14 @@ workflowCmd
|
|
|
796
799
|
.action(workflowRunAction);
|
|
797
800
|
workflowCmd
|
|
798
801
|
.command("diagram <file>")
|
|
799
|
-
.description("Render a workflow as a Mermaid diagram")
|
|
802
|
+
.description("Render a workflow as a Mermaid diagram (raw .mmd by default; .md output auto-fences)")
|
|
800
803
|
.option("--direction <dir>", "Graph direction: TB (top-bottom) or LR (left-right)", "TB")
|
|
801
|
-
.option("--title <title>", "
|
|
802
|
-
.option("--block", "Wrap in ```mermaid fenced code block (
|
|
803
|
-
.option("--no-block", "
|
|
804
|
-
.option("-o, --output <path>", "Write
|
|
805
|
-
.action(
|
|
806
|
-
runWorkflowDiagram(file, options, { loadWorkflowFile
|
|
804
|
+
.option("--title <title>", "Inject a title header (off by default — raw Mermaid has no title)")
|
|
805
|
+
.option("--block", "Wrap in ```mermaid fenced code block (forces fencing in any output)")
|
|
806
|
+
.option("--no-block", "Force raw Mermaid even when writing to a .md file")
|
|
807
|
+
.option("-o, --output <path>", "Write to a file instead of stdout (.mmd/.mermaid raw; .md fenced)")
|
|
808
|
+
.action((file, options) => {
|
|
809
|
+
runWorkflowDiagram(file, options, { loadWorkflowFile });
|
|
807
810
|
});
|
|
808
811
|
workflowCmd
|
|
809
812
|
.command("export <name>")
|
|
@@ -961,4 +964,69 @@ workflowCmd
|
|
|
961
964
|
}
|
|
962
965
|
console.log();
|
|
963
966
|
});
|
|
964
|
-
|
|
967
|
+
// ── sweny upgrade / update ────────────────────────────────────────────
|
|
968
|
+
// Self-update the globally-installed @sweny-ai/core. Mirrors the UX of
|
|
969
|
+
// `bun upgrade`, `deno upgrade`, `rustup update`, etc.
|
|
970
|
+
program
|
|
971
|
+
.command("upgrade")
|
|
972
|
+
.alias("update")
|
|
973
|
+
.description("Upgrade sweny to the latest published version")
|
|
974
|
+
.option("--check", "Report what would be installed without running the installer")
|
|
975
|
+
.option("--force", "Reinstall even if the current version is already latest")
|
|
976
|
+
.option("--tag <tag>", "npm dist-tag to install (default: latest)", "latest")
|
|
977
|
+
.action(async (options) => {
|
|
978
|
+
// process.argv[1] is the CLI entrypoint — resolve through any symlinks
|
|
979
|
+
// (nvm, homebrew, etc. symlink the bin into a PATH dir) so PM detection
|
|
980
|
+
// looks at the real install location.
|
|
981
|
+
const argv1 = process.argv[1] ?? "";
|
|
982
|
+
let installPath = argv1;
|
|
983
|
+
try {
|
|
984
|
+
installPath = fs.realpathSync(argv1);
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
// Fall through with the unresolved path; detection degrades gracefully.
|
|
988
|
+
}
|
|
989
|
+
await runUpgrade(options, {
|
|
990
|
+
currentVersion: version,
|
|
991
|
+
installPath,
|
|
992
|
+
fetchLatestVersion: fetchLatestFromNpm,
|
|
993
|
+
runInstall: (cmd, args) => {
|
|
994
|
+
// Windows: `npm`/`pnpm`/`yarn` are `.cmd` shims and Node's spawn can't
|
|
995
|
+
// resolve them without a shell. Everywhere else we skip the shell to
|
|
996
|
+
// keep argv unambiguous and avoid injection surface.
|
|
997
|
+
const res = spawnSync(cmd, args, {
|
|
998
|
+
stdio: "inherit",
|
|
999
|
+
shell: process.platform === "win32",
|
|
1000
|
+
});
|
|
1001
|
+
if (res.error) {
|
|
1002
|
+
process.stderr.write(chalk.red(` Error: couldn't run ${cmd} — ${res.error.message}`) + "\n");
|
|
1003
|
+
return 127;
|
|
1004
|
+
}
|
|
1005
|
+
return typeof res.status === "number" ? res.status : 1;
|
|
1006
|
+
},
|
|
1007
|
+
canWrite: (dir) => {
|
|
1008
|
+
try {
|
|
1009
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
1010
|
+
return true;
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
},
|
|
1016
|
+
});
|
|
1017
|
+
});
|
|
1018
|
+
// ── Passive "new version available" nudge ─────────────────────────────
|
|
1019
|
+
// Runs after every command. Non-blocking, bounded to 1.5s, silent on any
|
|
1020
|
+
// failure, skipped in CI / non-TTY / opt-out environments.
|
|
1021
|
+
program.hook("postAction", async (_thisCommand, actionCommand) => {
|
|
1022
|
+
const topLevel = actionCommand.parent?.name() === "sweny" ? actionCommand.name() : actionCommand.parent?.name();
|
|
1023
|
+
await maybeNudge({
|
|
1024
|
+
currentVersion: version,
|
|
1025
|
+
cachePath: defaultCachePath(),
|
|
1026
|
+
now: Date.now(),
|
|
1027
|
+
env: process.env,
|
|
1028
|
+
isTty: Boolean(process.stderr.isTTY),
|
|
1029
|
+
commandName: topLevel,
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
await program.parseAsync();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny upgrade` — self-update the globally-installed `@sweny-ai/core` CLI.
|
|
3
|
+
*
|
|
4
|
+
* The hard parts are detection (which package manager owns this install?) and
|
|
5
|
+
* permissions (global node_modules often require sudo). We keep the runtime
|
|
6
|
+
* surface tiny via dependency injection so the whole flow is testable.
|
|
7
|
+
*/
|
|
8
|
+
export type PackageManager = "npm" | "pnpm" | "yarn" | "bun" | "volta" | "brew" | "unknown";
|
|
9
|
+
export interface UpgradeOptions {
|
|
10
|
+
/** Skip the "already latest" short-circuit. */
|
|
11
|
+
force?: boolean;
|
|
12
|
+
/** Don't install — just report what would happen. */
|
|
13
|
+
check?: boolean;
|
|
14
|
+
/** Override the tag pulled from the registry. Defaults to "latest". */
|
|
15
|
+
tag?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface UpgradeDeps {
|
|
18
|
+
/** Version currently running (from `package.json`). */
|
|
19
|
+
currentVersion: string;
|
|
20
|
+
/** Absolute path to the current CLI entrypoint — used for PM detection. */
|
|
21
|
+
installPath: string;
|
|
22
|
+
/** Fetch latest version for `@sweny-ai/core@<tag>`. */
|
|
23
|
+
fetchLatestVersion: (tag: string) => Promise<string>;
|
|
24
|
+
/** Run the resolved install command. Returns the process exit code. */
|
|
25
|
+
runInstall: (cmd: string, args: string[]) => number;
|
|
26
|
+
/** Writable-dir probe for EACCES detection. */
|
|
27
|
+
canWrite: (dir: string) => boolean;
|
|
28
|
+
stdout?: NodeJS.WritableStream;
|
|
29
|
+
stderr?: NodeJS.WritableStream;
|
|
30
|
+
exit?: (code: number) => void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Detect the package manager that owns this install by walking the parent
|
|
34
|
+
* chain of the CLI entrypoint. The rules are ordered by specificity — more
|
|
35
|
+
* distinctive roots (bun, volta, pnpm, homebrew) beat the generic
|
|
36
|
+
* `lib/node_modules` npm layout.
|
|
37
|
+
*/
|
|
38
|
+
export declare function detectPackageManager(installPath: string): PackageManager;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the install-root directory for writability checks. For npm-style
|
|
41
|
+
* layouts, walk up until we find `node_modules/@sweny-ai/core` and return
|
|
42
|
+
* the parent of `node_modules` (the writable prefix). Returns null if the
|
|
43
|
+
* install path doesn't look like a package-manager install.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveInstallRoot(installPath: string): string | null;
|
|
46
|
+
/** Build the (cmd, args) pair for a given package manager + tag. */
|
|
47
|
+
export declare function installCommandFor(pm: PackageManager, tag: string): {
|
|
48
|
+
cmd: string;
|
|
49
|
+
args: string[];
|
|
50
|
+
} | null;
|
|
51
|
+
/**
|
|
52
|
+
* Compare two semver-ish version strings. Returns:
|
|
53
|
+
* - `>0` when `a > b`
|
|
54
|
+
* - `<0` when `a < b`
|
|
55
|
+
* - `0` when equal
|
|
56
|
+
*
|
|
57
|
+
* Handles numeric core (`major.minor.patch`) and pre-release strings
|
|
58
|
+
* conservatively: any pre-release (`-alpha.1`) is treated as *lower* than
|
|
59
|
+
* the same core without one, matching npm's semver ordering at the level
|
|
60
|
+
* of precision we need for "is the registry newer than me."
|
|
61
|
+
*/
|
|
62
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
63
|
+
/**
|
|
64
|
+
* Fetch the latest published version of `@sweny-ai/core` from the npm
|
|
65
|
+
* registry. Uses the dist-tag endpoint which returns a small, cacheable JSON
|
|
66
|
+
* document — no tarball download.
|
|
67
|
+
*/
|
|
68
|
+
export declare function fetchLatestFromNpm(tag: string): Promise<string>;
|
|
69
|
+
/**
|
|
70
|
+
* Main `sweny upgrade` entrypoint. Resolves the latest version, decides
|
|
71
|
+
* whether an install is needed, dispatches to the right package manager,
|
|
72
|
+
* and surfaces permission problems as actionable copy-paste commands.
|
|
73
|
+
*/
|
|
74
|
+
export declare function runUpgrade(options: UpgradeOptions, deps: UpgradeDeps): Promise<void>;
|