cannwork-rehearsal 0.2.1 → 0.3.1

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/README.md CHANGED
@@ -41,6 +41,16 @@ Notes:
41
41
  ```
42
42
  - `cannwork-rehearsal` is the **trial-period release name**: it is published and installable on purpose so real users can try it while the official `@zhoufangchaojs/cannwork` name is not released yet. It will be retired (unpublished/deprecated) once the official name is published.
43
43
 
44
+ ### Automatic update check
45
+
46
+ When you run `cannwork` in an interactive terminal, the launcher asks the npm registry whether a newer version exists. If one does, it prompts before the TUI starts:
47
+
48
+ - `[1] 更新` — runs `npm install -g <package>@latest` in place, then asks you to run `cannwork` again
49
+ - `[2] 跳过` — skips this time only; the same version will be offered again next launch
50
+ - `[3] 跳过此版本` — remembers this version and stops prompting for it (a newer version still prompts)
51
+
52
+ The check is skipped entirely for `cannwork -v` / `cannwork --version`, on non-interactive terminals (stdin or stdout not a TTY), or when `CANNWORK_NO_UPDATE_CHECK=1` is set. Network failures never block startup — the launcher simply continues with the installed version.
53
+
44
54
  ## Getting Started (from source)
45
55
 
46
56
  Install dependencies:
@@ -77,7 +87,7 @@ Releases are published manually to <https://registry.npmjs.org/> in two phases:
77
87
 
78
88
  ### Phase 1 — unscoped trial release (kept live)
79
89
 
80
- 1. Set `name` to `cannwork-rehearsal` and `version` to `0.2.0` in the root `package.json` (single source of truth).
90
+ 1. Set `name` to `cannwork-rehearsal` and `version` to `0.3.0` in the root `package.json` (single source of truth).
81
91
  2. `npm login` (any account with publish rights).
82
92
  3. `bun run release` — runs typecheck + tests, cross-compiles, then publishes the 5 platform packages followed by the main package. Add `--dry-run` to pack and verify without uploading.
83
93
  4. Check each package on the registry (`npm view <pkg> version`). A `0.0.0-stage` value means npm staged the version for review; wait for it to go live (or approve it on npmjs.com) before smoke-testing.
@@ -99,7 +109,8 @@ Rollback: `npm unpublish` the affected version within the allowed window; afterw
99
109
 
100
110
  ```
101
111
  ├── bin/
102
- │ └── cannwork.js # Node-compatible launcher shipped in the npm main package
112
+ │ ├── cannwork.js # Node-compatible launcher shipped in the npm main package
113
+ │ └── update-check.js # Pre-launch version check, prompt and global install
103
114
  ├── scripts/ # cross-platform build and publish tooling
104
115
  ├── src/
105
116
  │ └── index.tsx # App entry, renders the TUI with @opentui/react
package/bin/cannwork.js CHANGED
@@ -4,6 +4,16 @@ import { spawn } from "node:child_process";
4
4
  import { accessSync, chmodSync, constants, realpathSync } from "node:fs";
5
5
  import { resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import {
8
+ askUpdateChoice,
9
+ checkForUpdate,
10
+ DISABLE_UPDATE_CHECK_ENV,
11
+ formatUpdateNotice,
12
+ manualInstallCommand,
13
+ readSkippedVersion,
14
+ runGlobalInstall,
15
+ writeSkippedVersion,
16
+ } from "./update-check.js";
7
17
 
8
18
  export const SUPPORTED_TARGETS = Object.freeze([
9
19
  "win32-x64",
@@ -82,32 +92,75 @@ export function spawnBinary(binaryPath, args) {
82
92
  });
83
93
  }
84
94
 
85
- export async function run(argv = process.argv.slice(2)) {
95
+ export async function run(argv = process.argv.slice(2), options = {}) {
86
96
  const { platform: os, arch } = process;
97
+ const stdin = options.stdin ?? process.stdin;
98
+ const stdout = options.stdout ?? process.stdout;
99
+ const stderr = options.stderr ?? process.stderr;
100
+ const env = options.env ?? process.env;
87
101
 
88
102
  const requireFromHere = createRequire(import.meta.url);
89
103
  let manifest;
90
104
  try {
91
105
  manifest = requireFromHere("../package.json");
92
106
  } catch {
93
- process.stderr.write("cannwork: 无法读取自身包清单,安装可能已损坏。\n");
107
+ stderr.write("cannwork: 无法读取自身包清单,安装可能已损坏。\n");
94
108
  return 1;
95
109
  }
96
110
  const mainName = manifest.name;
97
111
 
98
112
  if (argv.includes("-v") || argv.includes("--version")) {
99
- process.stdout.write(`${manifest.version}\n`);
113
+ stdout.write(`${manifest.version}\n`);
100
114
  return 0;
101
115
  }
102
116
 
103
- const binaryPath = resolveBinaryPath(mainName, os, arch, requireFromHere);
117
+ const interactive = stdin.isTTY === true && stdout.isTTY === true;
118
+ if (!options.skipUpdateCheck && interactive && env[DISABLE_UPDATE_CHECK_ENV] !== "1") {
119
+ try {
120
+ const result = await checkForUpdate({
121
+ name: mainName,
122
+ localVersion: manifest.version,
123
+ manifest,
124
+ fetchImpl: options.fetchImpl,
125
+ timeoutMs: options.timeoutMs,
126
+ });
127
+ if (result.updateAvailable) {
128
+ const stateDir = options.stateDir;
129
+ if (readSkippedVersion(stateDir) !== result.latestVersion) {
130
+ const notice = formatUpdateNotice({
131
+ name: mainName,
132
+ current: manifest.version,
133
+ latest: result.latestVersion,
134
+ });
135
+ const ask = options.askChoice ?? askUpdateChoice;
136
+ const choice = await ask({ notice, stdin, stdout });
137
+ if (choice === "update") {
138
+ const install = options.installGlobal ?? runGlobalInstall;
139
+ if (install(mainName)) {
140
+ stdout.write(`\n已更新到 ${result.latestVersion},请重新执行 cannwork。\n`);
141
+ return 0;
142
+ }
143
+ stderr.write(`\n更新失败。请手动执行:${manualInstallCommand(mainName)}\n\n`);
144
+ } else if (choice === "skip-version") {
145
+ writeSkippedVersion(result.latestVersion, stateDir);
146
+ }
147
+ }
148
+ }
149
+ } catch {
150
+ // 升级检查是可选增强:任何异常都静默放行,绝不阻断启动
151
+ }
152
+ }
153
+
154
+ const resolveBinary = options.resolveBinary ?? resolveBinaryPath;
155
+ const binaryPath = resolveBinary(mainName, os, arch, requireFromHere);
104
156
  if (binaryPath === null) {
105
- process.stderr.write(missingPackageMessage(mainName, os, arch));
157
+ stderr.write(missingPackageMessage(mainName, os, arch));
106
158
  return 1;
107
159
  }
108
160
 
109
161
  ensureExecutable(binaryPath, os);
110
- return await spawnBinary(binaryPath, argv);
162
+ const spawnChild = options.spawnBinary ?? spawnBinary;
163
+ return await spawnChild(binaryPath, argv);
111
164
  }
112
165
 
113
166
  export const ENTRYPOINT_UNDETERMINED_MESSAGE =
@@ -0,0 +1,206 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
7
+ export const UPDATE_CHECK_TIMEOUT_MS = 1500;
8
+ export const DISABLE_UPDATE_CHECK_ENV = "CANNWORK_NO_UPDATE_CHECK";
9
+ export const SKIP_FILE_NAME = "upgrade.json";
10
+
11
+ const VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u;
12
+
13
+ export function parseVersion(raw) {
14
+ if (typeof raw !== "string") return null;
15
+ const match = VERSION_PATTERN.exec(raw.trim());
16
+ if (match === null) return null;
17
+ return {
18
+ major: Number(match[1]),
19
+ minor: Number(match[2]),
20
+ patch: Number(match[3]),
21
+ prerelease: match[4] ?? null,
22
+ };
23
+ }
24
+
25
+ function comparePrerelease(left, right) {
26
+ const leftIds = left.split(".");
27
+ const rightIds = right.split(".");
28
+ const length = Math.min(leftIds.length, rightIds.length);
29
+ for (let index = 0; index < length; index += 1) {
30
+ const leftId = leftIds[index] ?? "";
31
+ const rightId = rightIds[index] ?? "";
32
+ const leftNumeric = /^\d+$/u.test(leftId);
33
+ const rightNumeric = /^\d+$/u.test(rightId);
34
+ if (leftNumeric && rightNumeric) {
35
+ const diff = Number(leftId) - Number(rightId);
36
+ if (diff !== 0) return diff > 0 ? 1 : -1;
37
+ continue;
38
+ }
39
+ if (leftNumeric) return -1;
40
+ if (rightNumeric) return 1;
41
+ if (leftId !== rightId) return leftId < rightId ? -1 : 1;
42
+ }
43
+ if (leftIds.length === rightIds.length) return 0;
44
+ return leftIds.length < rightIds.length ? -1 : 1;
45
+ }
46
+
47
+ export function compareVersions(left, right) {
48
+ const a = parseVersion(left);
49
+ const b = parseVersion(right);
50
+ if (a === null || b === null) return null;
51
+ for (const key of ["major", "minor", "patch"]) {
52
+ if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1;
53
+ }
54
+ if (a.prerelease === null && b.prerelease === null) return 0;
55
+ if (a.prerelease === null) return 1;
56
+ if (b.prerelease === null) return -1;
57
+ return comparePrerelease(a.prerelease, b.prerelease);
58
+ }
59
+
60
+ export function isUpdateAvailable(localVersion, latestVersion) {
61
+ return compareVersions(latestVersion, localVersion) === 1;
62
+ }
63
+
64
+ export function resolveRegistry(manifest) {
65
+ const configured = manifest?.publishConfig?.registry;
66
+ return typeof configured === "string" && configured.trim().length > 0
67
+ ? configured.trim()
68
+ : DEFAULT_REGISTRY;
69
+ }
70
+
71
+ export function encodePackageName(name) {
72
+ return encodeURI(name).replace(/\//gu, "%2F");
73
+ }
74
+
75
+ export function packageLatestUrl(name, registry = DEFAULT_REGISTRY) {
76
+ const base =
77
+ typeof registry === "string" && registry.trim().length > 0 ? registry.trim() : DEFAULT_REGISTRY;
78
+ const withSlash = base.endsWith("/") ? base : `${base}/`;
79
+ return `${withSlash}${encodePackageName(name)}/latest`;
80
+ }
81
+
82
+ export async function fetchLatestVersion(name, options = {}) {
83
+ const {
84
+ registry = DEFAULT_REGISTRY,
85
+ timeoutMs = UPDATE_CHECK_TIMEOUT_MS,
86
+ fetchImpl = globalThis.fetch,
87
+ } = options;
88
+ if (typeof fetchImpl !== "function" || typeof name !== "string" || name.length === 0) {
89
+ return null;
90
+ }
91
+ const controller = new AbortController();
92
+ const timer = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 1));
93
+ try {
94
+ const response = await fetchImpl(packageLatestUrl(name, registry), {
95
+ headers: { Accept: "application/json" },
96
+ signal: controller.signal,
97
+ });
98
+ if (response === null || typeof response !== "object" || response.ok !== true) return null;
99
+ const body = await response.json();
100
+ const version = body?.version;
101
+ return typeof version === "string" && version.trim().length > 0 ? version.trim() : null;
102
+ } catch {
103
+ return null;
104
+ } finally {
105
+ clearTimeout(timer);
106
+ }
107
+ }
108
+
109
+ export async function checkForUpdate(options = {}) {
110
+ const {
111
+ name,
112
+ localVersion,
113
+ manifest,
114
+ fetchImpl,
115
+ timeoutMs = UPDATE_CHECK_TIMEOUT_MS,
116
+ } = options;
117
+ const latestVersion = await fetchLatestVersion(name, {
118
+ registry: resolveRegistry(manifest),
119
+ timeoutMs,
120
+ fetchImpl,
121
+ });
122
+ return {
123
+ latestVersion,
124
+ updateAvailable: latestVersion !== null && isUpdateAvailable(localVersion, latestVersion),
125
+ };
126
+ }
127
+
128
+ export function upgradeStateDir() {
129
+ return join(homedir(), ".cannwork-tui");
130
+ }
131
+
132
+ export function upgradeStatePath(dir = upgradeStateDir()) {
133
+ return join(dir, SKIP_FILE_NAME);
134
+ }
135
+
136
+ export function readSkippedVersion(dir = upgradeStateDir()) {
137
+ try {
138
+ const parsed = JSON.parse(readFileSync(upgradeStatePath(dir), "utf8"));
139
+ const value = parsed?.skippedVersion;
140
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ export function writeSkippedVersion(version, dir = upgradeStateDir()) {
147
+ if (typeof version !== "string" || version.trim().length === 0) return false;
148
+ try {
149
+ mkdirSync(dir, { recursive: true });
150
+ writeFileSync(
151
+ upgradeStatePath(dir),
152
+ `${JSON.stringify({ skippedVersion: version.trim() }, null, 2)}\n`,
153
+ "utf8",
154
+ );
155
+ return true;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ export function parseUpdateChoice(raw) {
162
+ const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
163
+ if (value === "1" || value === "u" || value === "y") return "update";
164
+ if (value === "3" || value === "v") return "skip-version";
165
+ return "skip";
166
+ }
167
+
168
+ export function formatUpdateNotice({ name, current, latest }) {
169
+ return [
170
+ `发现 cannwork 新版本:${current} -> ${latest}`,
171
+ ` [1] 更新(执行 npm install -g ${name}@latest)`,
172
+ " [2] 跳过(仅本次)",
173
+ " [3] 跳过此版本(不再提示该版本)",
174
+ ].join("\n");
175
+ }
176
+
177
+ export async function askUpdateChoice({ notice, stdin = process.stdin, stdout = process.stdout }) {
178
+ const readline = await import("node:readline/promises");
179
+ const rl = readline.createInterface({ input: stdin, output: stdout });
180
+ try {
181
+ const answer = await rl.question(`${notice}\n请选择 [1/2/3]: `);
182
+ return parseUpdateChoice(answer);
183
+ } catch {
184
+ return "skip";
185
+ } finally {
186
+ rl.close();
187
+ }
188
+ }
189
+
190
+ export function manualInstallCommand(name) {
191
+ return `npm install -g ${name}@latest`;
192
+ }
193
+
194
+ export function runGlobalInstall(name, options = {}) {
195
+ const { spawnImpl = spawnSync, platform = process.platform } = options;
196
+ if (typeof name !== "string" || name.trim().length === 0) return false;
197
+ try {
198
+ const result = spawnImpl("npm", ["install", "-g", `${name.trim()}@latest`], {
199
+ stdio: "inherit",
200
+ shell: platform === "win32",
201
+ });
202
+ return result?.status === 0;
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cannwork-rehearsal",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "cannwork": "bin/cannwork.js"
@@ -10,11 +10,11 @@
10
10
  "README.md"
11
11
  ],
12
12
  "optionalDependencies": {
13
- "cannwork-rehearsal-win32-x64": "0.2.1",
14
- "cannwork-rehearsal-linux-x64": "0.2.1",
15
- "cannwork-rehearsal-linux-arm64": "0.2.1",
16
- "cannwork-rehearsal-darwin-x64": "0.2.1",
17
- "cannwork-rehearsal-darwin-arm64": "0.2.1"
13
+ "cannwork-rehearsal-win32-x64": "0.3.1",
14
+ "cannwork-rehearsal-linux-x64": "0.3.1",
15
+ "cannwork-rehearsal-linux-arm64": "0.3.1",
16
+ "cannwork-rehearsal-darwin-x64": "0.3.1",
17
+ "cannwork-rehearsal-darwin-arm64": "0.3.1"
18
18
  },
19
19
  "publishConfig": {
20
20
  "registry": "https://registry.npmjs.org/",