@openturtle/cli 0.3.0 → 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
@@ -12,6 +12,23 @@ OpenTurtle CLI 是一个本地命令行工具。它让你不安装 Desktop,也
12
12
  npm install --global @openturtle/cli
13
13
  ```
14
14
 
15
+ CLI 启动时会在后台检查 npm 新版本,最多每 24 小时检查一次。检查不会阻塞当前命令;发现新版本后会通过 npm 自动完成全局升级。
16
+
17
+ 也可以手动检查或立即升级:
18
+
19
+ ```bash
20
+ ot update --check
21
+ ot update
22
+ ```
23
+
24
+ 需要关闭启动时自动更新时,设置:
25
+
26
+ ```bash
27
+ export OPENTURTLE_DISABLE_AUTO_UPDATE=1
28
+ ```
29
+
30
+ 源码运行、`pnpm link` 等开发安装不会被自动覆盖。自动更新状态记录在 `~/.openturtle/cli/update.json`。
31
+
15
32
  临时使用、不全局安装时:
16
33
 
17
34
  ```bash
@@ -0,0 +1,5 @@
1
+ import { runAutomaticUpdate } from './auto-update.js';
2
+ import { cliVersion } from './version.js';
3
+ void runAutomaticUpdate(cliVersion()).catch(() => {
4
+ process.exitCode = 1;
5
+ });
@@ -0,0 +1,176 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { cliGlobalDir, ensureFileDir } from './paths.js';
6
+ export const AUTO_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
7
+ export const CLI_PACKAGE_NAME = '@openturtle/cli';
8
+ export const NPM_REGISTRY_URL = 'https://registry.npmjs.org';
9
+ const packageRoot = path.dirname(fileURLToPath(new URL('../../package.json', import.meta.url)));
10
+ export function compareStableVersions(left, right) {
11
+ const leftParts = parseStableVersion(left);
12
+ const rightParts = parseStableVersion(right);
13
+ return leftParts[0] - rightParts[0] || leftParts[1] - rightParts[1] || leftParts[2] - rightParts[2];
14
+ }
15
+ export function isUpdateDue(lastCheckedAt, now = Date.now()) {
16
+ if (!lastCheckedAt)
17
+ return true;
18
+ const checkedAt = Date.parse(lastCheckedAt);
19
+ return !Number.isFinite(checkedAt) || now - checkedAt >= AUTO_UPDATE_INTERVAL_MS;
20
+ }
21
+ export function isManagedNpmInstall(root = packageRoot) {
22
+ const normalized = root.split(path.sep).join('/');
23
+ if (!normalized.includes('/node_modules/@openturtle/cli'))
24
+ return false;
25
+ try {
26
+ return fs.statSync(root).isDirectory() && !fs.lstatSync(root).isSymbolicLink();
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export function npmUpdateArgs() {
33
+ return [
34
+ 'install',
35
+ '--global',
36
+ `${CLI_PACKAGE_NAME}@latest`,
37
+ `--registry=${NPM_REGISTRY_URL}`,
38
+ '--no-audit',
39
+ '--no-fund',
40
+ ];
41
+ }
42
+ export async function updateCli(currentVersion, options = {}, dependencies = {}) {
43
+ const latest = await fetchLatestVersion(dependencies.fetchImpl);
44
+ if (!latest) {
45
+ return { current: currentVersion, latest: null, available: false, updated: false, reason: 'not_published' };
46
+ }
47
+ const available = compareStableVersions(latest, currentVersion) > 0;
48
+ if (!available || options.check) {
49
+ return {
50
+ current: currentVersion,
51
+ latest,
52
+ available,
53
+ updated: false,
54
+ reason: available ? undefined : 'current',
55
+ };
56
+ }
57
+ if (!isManagedNpmInstall(dependencies.packageRoot)) {
58
+ return {
59
+ current: currentVersion,
60
+ latest,
61
+ available: true,
62
+ updated: false,
63
+ reason: 'local_development',
64
+ };
65
+ }
66
+ await (dependencies.runNpmInstall || runNpmInstall)(npmUpdateArgs());
67
+ return { current: currentVersion, latest, available: true, updated: true };
68
+ }
69
+ export function scheduleAutomaticUpdate(currentVersion, dependencies = {}) {
70
+ try {
71
+ const env = dependencies.env || process.env;
72
+ const argv = dependencies.argv || process.argv;
73
+ if (env.OPENTURTLE_DISABLE_AUTO_UPDATE === '1' || env.OPENTURTLE_AUTO_UPDATE_WORKER === '1')
74
+ return false;
75
+ if (argv[2] === 'update' || !isManagedNpmInstall(dependencies.packageRoot))
76
+ return false;
77
+ const statePath = dependencies.statePath || updateStatePath();
78
+ const state = readUpdateState(statePath);
79
+ const now = dependencies.now || Date.now();
80
+ if (!isUpdateDue(state?.checked_at, now))
81
+ return false;
82
+ writeUpdateState({
83
+ checked_at: new Date(now).toISOString(),
84
+ current_version: currentVersion,
85
+ latest_version: state?.latest_version || null,
86
+ status: 'checking',
87
+ }, statePath);
88
+ const workerPath = dependencies.workerPath || fileURLToPath(new URL('./auto-update-worker.js', import.meta.url));
89
+ const workerEnv = { ...env, OPENTURTLE_AUTO_UPDATE_WORKER: '1' };
90
+ (dependencies.spawnWorker || spawnUpdateWorker)(workerPath, workerEnv);
91
+ return true;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ export async function runAutomaticUpdate(currentVersion, statePath = updateStatePath()) {
98
+ try {
99
+ const result = await updateCli(currentVersion);
100
+ writeUpdateState({
101
+ checked_at: new Date().toISOString(),
102
+ current_version: currentVersion,
103
+ latest_version: result.latest,
104
+ status: result.updated ? 'updated' : result.reason === 'not_published' ? 'unavailable' : 'current',
105
+ }, statePath);
106
+ return result;
107
+ }
108
+ catch (error) {
109
+ writeUpdateState({
110
+ checked_at: new Date().toISOString(),
111
+ current_version: currentVersion,
112
+ latest_version: null,
113
+ status: 'failed',
114
+ error: error instanceof Error ? error.message : String(error),
115
+ }, statePath);
116
+ throw error;
117
+ }
118
+ }
119
+ export function updateStatePath(homeDir) {
120
+ return path.join(cliGlobalDir(homeDir), 'update.json');
121
+ }
122
+ export function readUpdateState(statePath = updateStatePath()) {
123
+ try {
124
+ return JSON.parse(fs.readFileSync(statePath, 'utf8'));
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ }
130
+ export function writeUpdateState(state, statePath = updateStatePath()) {
131
+ ensureFileDir(statePath);
132
+ fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
133
+ }
134
+ async function fetchLatestVersion(fetchImpl = fetch) {
135
+ const packagePath = encodeURIComponent(CLI_PACKAGE_NAME).replace('%40', '@');
136
+ const response = await fetchImpl(`${NPM_REGISTRY_URL}/${packagePath}/latest`, {
137
+ headers: { accept: 'application/json' },
138
+ signal: AbortSignal.timeout(10_000),
139
+ });
140
+ if (response.status === 404)
141
+ return null;
142
+ if (!response.ok)
143
+ throw new Error(`npm registry returned HTTP ${response.status}`);
144
+ const metadata = (await response.json());
145
+ if (!metadata.version)
146
+ throw new Error('npm registry response did not include a version');
147
+ parseStableVersion(metadata.version);
148
+ return metadata.version;
149
+ }
150
+ function parseStableVersion(version) {
151
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
152
+ if (!match)
153
+ throw new Error(`Expected a stable semantic version, received: ${version}`);
154
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
155
+ }
156
+ function runNpmInstall(args) {
157
+ const command = process.platform === 'win32' ? 'npm.cmd' : 'npm';
158
+ return new Promise((resolve, reject) => {
159
+ const child = spawn(command, args, { stdio: 'inherit' });
160
+ child.once('error', reject);
161
+ child.once('exit', (code, signal) => {
162
+ if (code === 0)
163
+ resolve();
164
+ else
165
+ reject(new Error(`npm update failed (${signal || `exit ${code ?? 'unknown'}`})`));
166
+ });
167
+ });
168
+ }
169
+ function spawnUpdateWorker(workerPath, env) {
170
+ const child = spawn(process.execPath, [workerPath], {
171
+ detached: true,
172
+ env,
173
+ stdio: 'ignore',
174
+ });
175
+ child.unref();
176
+ }
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { registerCollaborationCommands } from './commands/collaboration.js';
5
5
  import { ingestRawHook, recordGitPostCommit } from './commands/hook.js';
6
6
  import { doctor, draftDailyReport, createResourceCommands } from './commands/resources.js';
7
7
  import { ApiClient, ApiError } from './core/api-client.js';
8
+ import { scheduleAutomaticUpdate, updateCli } from './core/auto-update.js';
8
9
  import { clearAuth, readAuth, redactToken, writeAuth } from './core/auth.js';
9
10
  import { DEFAULT_SERVER_URL } from './core/defaults.js';
10
11
  import { findWorkspaceRoot } from './core/paths.js';
@@ -22,6 +23,13 @@ program
22
23
  .description('OpenTurtle collaboration, knowledge, meeting, and local agent CLI')
23
24
  .version(cliVersion());
24
25
  program.option('-j, --json', 'emit machine-readable JSON, including errors');
26
+ program
27
+ .command('update')
28
+ .description('check npm for a newer CLI and install it')
29
+ .option('--check', 'check for an update without installing it')
30
+ .action(action(async (options) => {
31
+ print(await updateCli(cliVersion(), { check: Boolean(options.check) }));
32
+ }));
25
33
  program
26
34
  .command('init')
27
35
  .option('--update-gitignore', 'append .openturtle-cli to .gitignore')
@@ -341,6 +349,7 @@ program
341
349
  }
342
350
  throw new Error('Usage: ot mcp serve');
343
351
  }));
352
+ scheduleAutomaticUpdate(cliVersion());
344
353
  if (!runSpecialNestedCommand(process.argv)) {
345
354
  try {
346
355
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openturtle/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "OpenTurtle collaboration, knowledge, meeting, and agent CLI",
5
5
  "keywords": [
6
6
  "openturtle",