@nonbot/cli 0.9.12 → 0.10.0

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.
@@ -1,4 +1,6 @@
1
1
  import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import * as nodeFs from 'node:fs';
3
+ import { exitFilePathFor } from './activations.js';
2
4
  export function listLivePaneIds(spawnImpl = nodeSpawnSync) {
3
5
  try {
4
6
  const r = spawnImpl('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], {
@@ -43,6 +45,55 @@ export async function postCompletion(baseUrl, pat, activationId, fetchImpl = fet
43
45
  return false;
44
46
  }
45
47
  }
48
+ export function readExitCodeFile(activationId, fsImpl = nodeFs) {
49
+ try {
50
+ const raw = fsImpl.readFileSync(exitFilePathFor(activationId), 'utf-8').trim();
51
+ if (raw.length === 0)
52
+ return null;
53
+ const n = Number(raw);
54
+ return Number.isInteger(n) && n >= 0 && n <= 255 ? n : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ export function removeExitFile(activationId, fsImpl = nodeFs) {
61
+ try {
62
+ fsImpl.unlinkSync(exitFilePathFor(activationId));
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ export async function postFailure(baseUrl, pat, activationId, failureReason, fetchImpl = fetch) {
70
+ try {
71
+ const res = await fetchImpl(`${baseUrl}/api/portfolio/activations/${activationId}`, {
72
+ method: 'PATCH',
73
+ headers: {
74
+ Authorization: `Bearer ${pat}`,
75
+ 'Content-Type': 'application/json',
76
+ 'X-Requested-With': 'ConradPM-Native',
77
+ },
78
+ body: JSON.stringify({ status: 'failed', failureReason: failureReason.slice(0, 1000) }),
79
+ });
80
+ return res.ok || res.status === 409;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ export const MAX_REPORT_ATTEMPTS = 8;
87
+ export const TIMEOUT_EXIT_CODE = 124;
88
+ const reportAttemptsByTracker = new WeakMap();
89
+ function reportAttemptsFor(tracked) {
90
+ let m = reportAttemptsByTracker.get(tracked);
91
+ if (!m) {
92
+ m = new Map();
93
+ reportAttemptsByTracker.set(tracked, m);
94
+ }
95
+ return m;
96
+ }
46
97
  export async function checkCompletions(opts) {
47
98
  const { tracked, killed, baseUrl, pat } = opts;
48
99
  if (tracked.size === 0)
@@ -51,14 +102,84 @@ export async function checkCompletions(opts) {
51
102
  if (live === null)
52
103
  return [];
53
104
  const completed = detectCompletedActivations(tracked, live, killed);
105
+ const attempts = opts.cloud ? reportAttemptsFor(tracked) : null;
106
+ const noteFailedReport = (id) => {
107
+ if (!attempts)
108
+ return;
109
+ const n = (attempts.get(id) ?? 0) + 1;
110
+ if (n < MAX_REPORT_ATTEMPTS) {
111
+ attempts.set(id, n);
112
+ return;
113
+ }
114
+ attempts.delete(id);
115
+ tracked.delete(id);
116
+ opts.cloud?.forgetWorkspace?.(id);
117
+ opts.log?.(`⚠ ${id} · giving up after ${MAX_REPORT_ATTEMPTS} failed completion reports\n`);
118
+ };
54
119
  for (const [id, pane] of [...tracked.entries()]) {
55
120
  if (!live.has(pane) && killed.has(id)) {
56
121
  tracked.delete(id);
57
122
  killed.delete(id);
123
+ opts.cloud?.forgetWorkspace?.(id);
124
+ attempts?.delete(id);
58
125
  }
59
126
  }
60
127
  const reported = [];
128
+ const outcomes = new Map();
61
129
  for (const id of completed) {
130
+ if (opts.cloud) {
131
+ const push = opts.cloud.pushWorkspace(id);
132
+ const code = opts.cloud.readExitCode(id);
133
+ const branchNote = (push.pushed
134
+ ? `branch ${push.branch ?? 'unknown'} pushed`
135
+ : push.reason === 'no workspace'
136
+ ? 'no workspace'
137
+ : `push failed: ${push.reason ?? 'unknown'}`).slice(0, 200);
138
+ if (code === 0 && (push.pushed || push.kind === 'no-workspace')) {
139
+ const ok = await postCompletion(baseUrl, pat, id, opts.fetchImpl);
140
+ if (!ok) {
141
+ noteFailedReport(id);
142
+ continue;
143
+ }
144
+ attempts?.delete(id);
145
+ tracked.delete(id);
146
+ reported.push(id);
147
+ outcomes.set(id, 'completed');
148
+ const card = opts.renderComplete?.(id);
149
+ opts.log?.(card && card.length > 0 ? card : `✓ ${id} · run completed (pane closed)\n`);
150
+ continue;
151
+ }
152
+ const codeLabel = code === null
153
+ ? 'unknown (no exit file)'
154
+ : code === TIMEOUT_EXIT_CODE
155
+ ? `${TIMEOUT_EXIT_CODE} (wall clock timeout)`
156
+ : String(code);
157
+ const ok = await postFailure(baseUrl, pat, id, `agent exited ${codeLabel} · ${branchNote}`, opts.fetchImpl);
158
+ if (!ok) {
159
+ noteFailedReport(id);
160
+ continue;
161
+ }
162
+ attempts?.delete(id);
163
+ tracked.delete(id);
164
+ reported.push(id);
165
+ outcomes.set(id, 'failed');
166
+ opts.log?.(`✗ ${id} · run failed (exit ${codeLabel})\n`);
167
+ continue;
168
+ }
169
+ const localCode = opts.readExitCode ? opts.readExitCode(id) : null;
170
+ if (localCode !== null && localCode !== 0) {
171
+ const label = localCode === TIMEOUT_EXIT_CODE
172
+ ? `${TIMEOUT_EXIT_CODE} (wall clock timeout)`
173
+ : String(localCode);
174
+ const failedOk = await postFailure(baseUrl, pat, id, `agent exited ${label}`, opts.fetchImpl);
175
+ if (!failedOk)
176
+ continue;
177
+ tracked.delete(id);
178
+ reported.push(id);
179
+ outcomes.set(id, 'failed');
180
+ opts.log?.(`✗ ${id} · run failed (exit ${label})\n`);
181
+ continue;
182
+ }
62
183
  const ok = await postCompletion(baseUrl, pat, id, opts.fetchImpl);
63
184
  if (!ok) {
64
185
  continue;
@@ -68,5 +189,8 @@ export async function checkCompletions(opts) {
68
189
  const card = opts.renderComplete?.(id);
69
190
  opts.log?.(card && card.length > 0 ? card : `✓ ${id} · run completed (pane closed)\n`);
70
191
  }
71
- return reported;
192
+ const result = reported;
193
+ if (opts.cloud || opts.readExitCode)
194
+ result.outcomes = outcomes;
195
+ return result;
72
196
  }
@@ -9,6 +9,21 @@ function configDir() {
9
9
  return override;
10
10
  return path.join(homedir(), '.config', 'nonbot');
11
11
  }
12
+ export function shutdownIssuedAt(signal) {
13
+ if (!signal)
14
+ return null;
15
+ for (const v of [signal.requestedAt, signal.issuedAt]) {
16
+ if (typeof v === 'number' && Number.isFinite(v))
17
+ return v;
18
+ }
19
+ return null;
20
+ }
21
+ export function shutdownPredatesDaemon(signal, daemonStartedAt) {
22
+ const issuedAt = shutdownIssuedAt(signal);
23
+ if (issuedAt === null)
24
+ return false;
25
+ return issuedAt < daemonStartedAt;
26
+ }
12
27
  export function pidFilePath(dir = configDir()) {
13
28
  return path.join(dir, 'daemon.pid');
14
29
  }
@@ -106,6 +121,35 @@ export async function requestRemoteDaemonStop(opts) {
106
121
  return 1;
107
122
  }
108
123
  }
124
+ export function attachDaemonSession(deps) {
125
+ const spawn = deps.spawnImpl ?? nodeSpawnSync;
126
+ const sessionName = deps.sessionName ?? 'nonbot';
127
+ try {
128
+ const probe = spawn('tmux', ['has-session', '-t', sessionName], {
129
+ encoding: 'utf-8', timeout: 2000, windowsHide: true,
130
+ });
131
+ if (probe.status !== 0) {
132
+ deps.errLog(`✗ no tmux session '${sessionName}' to attach to.\n Start the daemon first: nonbot daemon\n`);
133
+ return 1;
134
+ }
135
+ }
136
+ catch {
137
+ deps.errLog('✗ tmux not found — install it (brew install tmux) or run the daemon without tmux.\n');
138
+ return 1;
139
+ }
140
+ deps.log(`↳ attaching to tmux session '${sessionName}' — press Ctrl-B then D to detach again.\n`);
141
+ try {
142
+ const res = spawn('tmux', ['attach', '-t', sessionName], {
143
+ ...{ stdio: 'inherit' },
144
+ windowsHide: true,
145
+ });
146
+ return res.status === 0 ? 0 : 1;
147
+ }
148
+ catch {
149
+ deps.errLog(`✗ could not attach to '${sessionName}'.\n`);
150
+ return 1;
151
+ }
152
+ }
109
153
  export async function stopDaemonLocally(deps) {
110
154
  const kill = deps.kill ?? ((pid, sig) => process.kill(pid, sig));
111
155
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -0,0 +1,74 @@
1
+ import { captureSnapshotPane, scrubSnapshot, clampSnapshotTail, SNAPSHOT_MAX_BYTES, } from './snapshot.js';
2
+ import { appendActivityLog } from './activity-log.js';
3
+ export const EXIT_TRANSCRIPT_KIND = 'exit-transcript';
4
+ export const EXIT_TRANSCRIPT_LINES = 200;
5
+ export const EXIT_TRANSCRIPT_MAX_BYTES = SNAPSHOT_MAX_BYTES;
6
+ export const EXIT_TRANSCRIPT_OPT_OUT_ENV = 'NONBOT_NO_EXIT_TRANSCRIPT';
7
+ export function exitTranscriptDisabled(env = process.env) {
8
+ const v = env[EXIT_TRANSCRIPT_OPT_OUT_ENV];
9
+ if (typeof v !== 'string')
10
+ return false;
11
+ const t = v.trim().toLowerCase();
12
+ return t === '1' || t === 'true';
13
+ }
14
+ export function refreshPaneTails(opts) {
15
+ const now = opts.now ?? Date.now;
16
+ const maxLines = opts.maxLines ?? EXIT_TRANSCRIPT_LINES;
17
+ let refreshed = 0;
18
+ for (const [activationId, paneId] of opts.tracked) {
19
+ let raw = '';
20
+ try {
21
+ raw = captureSnapshotPane(paneId, maxLines, opts.spawnImpl);
22
+ }
23
+ catch {
24
+ raw = '';
25
+ }
26
+ if (raw === '')
27
+ continue;
28
+ const { text, truncated } = clampSnapshotTail(scrubSnapshot(raw), EXIT_TRANSCRIPT_MAX_BYTES);
29
+ opts.tails.set(activationId, {
30
+ text,
31
+ truncated,
32
+ capturedAt: now(),
33
+ lines: text.length === 0 ? 0 : text.split('\n').length,
34
+ });
35
+ refreshed++;
36
+ }
37
+ return refreshed;
38
+ }
39
+ export function buildExitTranscriptEntry(opts) {
40
+ return {
41
+ ts: opts.ts,
42
+ id: opts.id,
43
+ kind: EXIT_TRANSCRIPT_KIND,
44
+ status: opts.status,
45
+ profile: opts.profile,
46
+ capturedAt: opts.tail.capturedAt,
47
+ lines: opts.tail.lines,
48
+ truncated: opts.tail.truncated,
49
+ tail: opts.tail.text,
50
+ };
51
+ }
52
+ export async function flushExitTranscripts(opts) {
53
+ const now = opts.now ?? Date.now;
54
+ const append = opts.append ?? ((entry) => appendActivityLog(entry));
55
+ const written = [];
56
+ for (const [id, tail] of [...opts.tails.entries()]) {
57
+ if (opts.tracked.has(id))
58
+ continue;
59
+ const status = opts.statusFor(id);
60
+ const entry = buildExitTranscriptEntry({ id, tail, status, profile: opts.profile, ts: now() });
61
+ try {
62
+ await append(entry);
63
+ written.push(id);
64
+ opts.log?.(`· ${id} · exit transcript saved (${entry.lines} lines${entry.truncated ? ', clamped' : ''} · ${status}) → activations.log\n`);
65
+ }
66
+ catch {
67
+ opts.log?.(`⚠ ${id} · exit transcript not saved (activations.log write failed)\n`);
68
+ }
69
+ finally {
70
+ opts.tails.delete(id);
71
+ }
72
+ }
73
+ return written;
74
+ }
@@ -13,6 +13,13 @@ export function machineFilePath(dir = configDir()) {
13
13
  return path.join(dir, 'machine.json');
14
14
  }
15
15
  export function loadOrCreateMachineId(deps = {}) {
16
+ const env = deps.env ?? process.env;
17
+ const fromEnv = env.NONBOT_MACHINE_ID;
18
+ if (typeof fromEnv === 'string') {
19
+ const trimmed = fromEnv.trim();
20
+ if (trimmed.length > 0)
21
+ return trimmed.slice(0, 128);
22
+ }
16
23
  const fs = deps.fs ?? nodeFs;
17
24
  const uuid = deps.uuid ?? randomUUID;
18
25
  const dir = deps.dir ?? configDir();
@@ -14,6 +14,8 @@ const STORY_TITLE_MAX = 200;
14
14
  const AGENTS_MD_MAX = 24 * 1024;
15
15
  const REPO_PATH_MAX = 1024;
16
16
  const AGENTS_MD_HEREDOC_DELIMITER = 'NONBOT_AGENTS_EOF';
17
+ const REPO_URL_MAX = 512;
18
+ const REPO_REF_RE = /^[A-Za-z0-9._/-]{1,128}$/;
17
19
  const THEME_ID_MAX = 128;
18
20
  const THEME_NAME_MAX = 128;
19
21
  const THEME_TMUX_CONF_MAX = 8 * 1024;
@@ -50,6 +52,55 @@ export function validateRepoPath(s, opts = {}) {
50
52
  }
51
53
  return s;
52
54
  }
55
+ export function validateRepoUrl(s) {
56
+ if (s === undefined || s === null || s === '')
57
+ return undefined;
58
+ if (typeof s !== 'string') {
59
+ throw new ValidationError('repoUrl', 'must be a string');
60
+ }
61
+ if (s.length > REPO_URL_MAX) {
62
+ throw new ValidationError('repoUrl', `exceeds ${REPO_URL_MAX} chars`);
63
+ }
64
+ let parsed;
65
+ try {
66
+ parsed = new URL(s);
67
+ }
68
+ catch {
69
+ throw new ValidationError('repoUrl', 'must be a parseable absolute URL');
70
+ }
71
+ if (parsed.protocol !== 'https:') {
72
+ throw new ValidationError('repoUrl', 'must use https');
73
+ }
74
+ if (parsed.username || parsed.password) {
75
+ throw new ValidationError('repoUrl', 'must not embed credentials');
76
+ }
77
+ if (/[\s'"`;|&<>\\\n\0$]/.test(s)) {
78
+ throw new ValidationError('repoUrl', 'contains shell metacharacters');
79
+ }
80
+ return s;
81
+ }
82
+ export function repoUrlLooksSafe(url) {
83
+ if (typeof url !== 'string' || url.length === 0)
84
+ return false;
85
+ try {
86
+ validateRepoUrl(url);
87
+ return true;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ export function validateRepoRef(s) {
94
+ if (s === undefined || s === null || s === '')
95
+ return undefined;
96
+ if (typeof s !== 'string') {
97
+ throw new ValidationError('repoRef', 'must be a string');
98
+ }
99
+ if (!REPO_REF_RE.test(s)) {
100
+ throw new ValidationError('repoRef', `must match /^[A-Za-z0-9._/-]{1,128}$/ (got ${JSON.stringify(s.slice(0, 64))})`);
101
+ }
102
+ return s;
103
+ }
53
104
  export function validateStoryTitle(s) {
54
105
  if (s === undefined || s === null)
55
106
  return undefined;
@@ -112,6 +112,24 @@ export async function reportPrompt(args) {
112
112
  return false;
113
113
  }
114
114
  }
115
+ export function normalizeAnsweredPrompts(raw) {
116
+ if (!Array.isArray(raw))
117
+ return [];
118
+ const out = [];
119
+ for (const r of raw.slice(0, ANSWERED_MAX)) {
120
+ if (!r || typeof r.promptId !== 'string' || typeof r.activationId !== 'string')
121
+ continue;
122
+ out.push({
123
+ promptId: r.promptId,
124
+ activationId: r.activationId,
125
+ paneId: typeof r.paneId === 'string' && r.paneId.length > 0 ? r.paneId : null,
126
+ answerIndex: typeof r.answerIndex === 'number' ? r.answerIndex : null,
127
+ answerText: typeof r.answerText === 'string' ? r.answerText.slice(0, ANSWER_TEXT_MAX) : null,
128
+ answeredAt: typeof r.answeredAt === 'number' ? r.answeredAt : undefined,
129
+ });
130
+ }
131
+ return out;
132
+ }
115
133
  export async function pollAnsweredPrompts(args) {
116
134
  const fetchImpl = args.fetchImpl ?? fetch;
117
135
  try {
@@ -126,22 +144,9 @@ export async function pollAnsweredPrompts(args) {
126
144
  if (!res.ok)
127
145
  return [];
128
146
  const data = (await res.json());
129
- if (!data || !Array.isArray(data.prompts))
147
+ if (!data)
130
148
  return [];
131
- const out = [];
132
- for (const r of data.prompts.slice(0, ANSWERED_MAX)) {
133
- if (!r || typeof r.promptId !== 'string' || typeof r.activationId !== 'string')
134
- continue;
135
- out.push({
136
- promptId: r.promptId,
137
- activationId: r.activationId,
138
- paneId: typeof r.paneId === 'string' && r.paneId.length > 0 ? r.paneId : null,
139
- answerIndex: typeof r.answerIndex === 'number' ? r.answerIndex : null,
140
- answerText: typeof r.answerText === 'string' ? r.answerText.slice(0, ANSWER_TEXT_MAX) : null,
141
- answeredAt: typeof r.answeredAt === 'number' ? r.answeredAt : undefined,
142
- });
143
- }
144
- return out;
149
+ return normalizeAnsweredPrompts(data.prompts);
145
150
  }
146
151
  catch {
147
152
  return [];
@@ -166,12 +171,28 @@ export async function confirmDelivered(args) {
166
171
  return false;
167
172
  }
168
173
  }
174
+ const ANSI_CSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/g;
175
+ const ANSI_CSI8_RE = /\x9B[0-?]*[ -/]*[@-~]/g;
176
+ const ANSI_OSC_RE = /\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g;
177
+ const ANSI_ESC2_RE = /\x1B[@-~]/g;
178
+ const CONTROL_BYTE_RE = /[\x00-\x1F\x7F-\x9F]/g;
179
+ export function sanitizeAnswerText(text) {
180
+ if (typeof text !== 'string')
181
+ return '';
182
+ return text
183
+ .replace(ANSI_CSI_RE, '')
184
+ .replace(ANSI_CSI8_RE, '')
185
+ .replace(ANSI_OSC_RE, '')
186
+ .replace(ANSI_ESC2_RE, '')
187
+ .replace(CONTROL_BYTE_RE, ' ')
188
+ .trim();
189
+ }
169
190
  export function injectAnswer(paneId, answerIndex, answerText, spawnImpl = nodeSpawnSync) {
170
191
  if (!PANE_ID_RE.test(paneId))
171
192
  return false;
172
193
  const keys = answerIndex !== null && Number.isFinite(answerIndex) && answerIndex >= 0
173
194
  ? String(answerIndex)
174
- : answerText ?? '';
195
+ : sanitizeAnswerText(answerText ?? '');
175
196
  if (keys.length === 0)
176
197
  return false;
177
198
  try {
@@ -192,7 +213,7 @@ export function injectAnswer(paneId, answerIndex, answerText, spawnImpl = nodeSp
192
213
  }
193
214
  }
194
215
  export async function deliverAnsweredPrompts(opts) {
195
- const answered = await pollAnsweredPrompts({
216
+ const answered = opts.prompts ?? await pollAnsweredPrompts({
196
217
  baseUrl: opts.baseUrl,
197
218
  pat: opts.pat,
198
219
  machineId: opts.machineId,
@@ -27,22 +27,24 @@ export function captureSnapshotPane(paneId, maxLines = MAX_LINES_DEFAULT, spawnI
27
27
  }
28
28
  }
29
29
  const PAT_TOKEN_RE = /pat_[A-Za-z0-9_-]{8,}/g;
30
- const ENV_EXPORT_LINE_RE = /export NONBOT_(PAT|BASE_URL|RUN_ID|ROLE)=/;
30
+ const ANTHROPIC_KEY_RE = /sk-ant-[A-Za-z0-9_-]{8,}/g;
31
+ const GITHUB_TOKEN_RE = /(?:gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,})/g;
32
+ const ENV_ASSIGN_LINE_RE = /(?:^|\s|export )\w*(?:NONBOT_|TOKEN|API_KEY|SECRET)\w*=/;
31
33
  const AUTH_BEARER_RE = /Authorization: Bearer \S+/g;
34
+ export function scrubLine(line) {
35
+ if (ENV_ASSIGN_LINE_RE.test(line) || line.includes('NONBOT_PAT')) {
36
+ return '[redacted env line]';
37
+ }
38
+ return line
39
+ .replace(GITHUB_TOKEN_RE, '[REDACTED-GIT-TOKEN]')
40
+ .replace(PAT_TOKEN_RE, 'pat_[REDACTED]')
41
+ .replace(ANTHROPIC_KEY_RE, 'sk-ant-[REDACTED]')
42
+ .replace(AUTH_BEARER_RE, 'Authorization: Bearer [REDACTED]');
43
+ }
32
44
  export function scrubSnapshot(text) {
33
45
  if (typeof text !== 'string' || text.length === 0)
34
46
  return '';
35
- return text
36
- .split('\n')
37
- .map((line) => {
38
- if (ENV_EXPORT_LINE_RE.test(line) || line.includes('NONBOT_PAT')) {
39
- return '[redacted env line]';
40
- }
41
- return line
42
- .replace(PAT_TOKEN_RE, 'pat_[REDACTED]')
43
- .replace(AUTH_BEARER_RE, 'Authorization: Bearer [REDACTED]');
44
- })
45
- .join('\n');
47
+ return text.split('\n').map(scrubLine).join('\n');
46
48
  }
47
49
  export function clampSnapshotTail(text, maxBytes = SNAPSHOT_MAX_BYTES) {
48
50
  const buf = Buffer.from(text, 'utf-8');
@@ -23,6 +23,10 @@ function toOsascriptArgs(lines) {
23
23
  export const TMUX_PRESS_KEY_TRAILER = "; printf '\\n%s\\n' 'Run finished — press enter to close'" +
24
24
  '; read _' +
25
25
  '; tmux kill-pane';
26
+ export function tmuxCloudTrailer(exitFile) {
27
+ const safe = exitFile.replace(/'/g, `'\\''`);
28
+ return `; echo $? > '${safe}'`;
29
+ }
26
30
  export const TERMINAL_PROFILES = [
27
31
  {
28
32
  id: 'terminal',
@@ -123,8 +127,9 @@ export const TERMINAL_PROFILES = [
123
127
  id: 'tmux',
124
128
  displayName: 'tmux pane',
125
129
  platform: 'darwin',
126
- launch: (s) => {
130
+ launch: (s, opts) => {
127
131
  const safe = s.replace(/'/g, `'\\''`);
132
+ const trailer = opts?.cloudExitFile ? tmuxCloudTrailer(opts.cloudExitFile) : TMUX_PRESS_KEY_TRAILER;
128
133
  return {
129
134
  cmd: 'tmux',
130
135
  args: [
@@ -132,7 +137,7 @@ export const TERMINAL_PROFILES = [
132
137
  '-P',
133
138
  '-F',
134
139
  '#{pane_id}',
135
- `bash '${safe}' 2>&1${TMUX_PRESS_KEY_TRAILER}`,
140
+ `bash '${safe}' 2>&1${trailer}`,
136
141
  ';',
137
142
  'select-layout',
138
143
  'tiled',
@@ -186,8 +191,9 @@ export const TERMINAL_PROFILES = [
186
191
  id: 'tmux',
187
192
  displayName: 'tmux pane',
188
193
  platform: 'linux',
189
- launch: (s) => {
194
+ launch: (s, opts) => {
190
195
  const safe = s.replace(/'/g, `'\\''`);
196
+ const trailer = opts?.cloudExitFile ? tmuxCloudTrailer(opts.cloudExitFile) : TMUX_PRESS_KEY_TRAILER;
191
197
  return {
192
198
  cmd: 'tmux',
193
199
  args: [
@@ -195,7 +201,7 @@ export const TERMINAL_PROFILES = [
195
201
  '-P',
196
202
  '-F',
197
203
  '#{pane_id}',
198
- `bash '${safe}' 2>&1${TMUX_PRESS_KEY_TRAILER}`,
204
+ `bash '${safe}' 2>&1${trailer}`,
199
205
  ';',
200
206
  'select-layout',
201
207
  'tiled',
@@ -0,0 +1,99 @@
1
+ import { VERSION } from '../version.js';
2
+ export const NPM_LATEST_URL = 'https://registry.npmjs.org/@nonbot/cli/latest';
3
+ export const UPDATE_CHECK_TIMEOUT_MS = 3_000;
4
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60_000;
5
+ export const UPDATE_CHECK_OPT_OUT_ENV = 'NONBOT_NO_UPDATE_CHECK';
6
+ export function updateCheckDisabled(env = process.env) {
7
+ const v = env[UPDATE_CHECK_OPT_OUT_ENV];
8
+ if (typeof v !== 'string')
9
+ return false;
10
+ const t = v.trim().toLowerCase();
11
+ return t === '1' || t === 'true';
12
+ }
13
+ export function parseSemver(v) {
14
+ if (typeof v !== 'string')
15
+ return null;
16
+ const m = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:[-+][0-9A-Za-z.-]{0,64})?$/.exec(v.trim());
17
+ if (!m)
18
+ return null;
19
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
20
+ }
21
+ export function compareVersions(a, b) {
22
+ const pa = parseSemver(a);
23
+ const pb = parseSemver(b);
24
+ if (!pa || !pb)
25
+ return 0;
26
+ for (let i = 0; i < 3; i++) {
27
+ if (pa[i] > pb[i])
28
+ return 1;
29
+ if (pa[i] < pb[i])
30
+ return -1;
31
+ }
32
+ return 0;
33
+ }
34
+ export function isNewerVersion(latest, current) {
35
+ return compareVersions(latest, current) > 0;
36
+ }
37
+ export function updateAvailableLine(current, latest) {
38
+ return `Update available: @nonbot/cli ${current} → ${latest} (npm i -g @nonbot/cli)`;
39
+ }
40
+ export async function fetchLatestVersion(opts = {}) {
41
+ const fetchImpl = opts.fetchImpl ?? fetch;
42
+ try {
43
+ const res = await fetchImpl(opts.url ?? NPM_LATEST_URL, {
44
+ method: 'GET',
45
+ headers: { Accept: 'application/json' },
46
+ signal: AbortSignal.timeout(opts.timeoutMs ?? UPDATE_CHECK_TIMEOUT_MS),
47
+ });
48
+ if (!res.ok)
49
+ return null;
50
+ const body = (await res.json());
51
+ const version = body?.version;
52
+ return parseSemver(version) ? String(version).trim() : null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ export function createUpdateChecker(opts) {
59
+ const current = opts.current ?? VERSION;
60
+ const env = opts.env ?? process.env;
61
+ const now = opts.now ?? Date.now;
62
+ const intervalMs = opts.intervalMs ?? UPDATE_CHECK_INTERVAL_MS;
63
+ const fetchImpl = opts.fetchImpl === undefined ? fetch : opts.fetchImpl;
64
+ let lastCheckedAt = null;
65
+ let latestSeen = null;
66
+ return {
67
+ get lastCheckedAt() {
68
+ return lastCheckedAt;
69
+ },
70
+ get latestSeen() {
71
+ return latestSeen;
72
+ },
73
+ async maybeCheck() {
74
+ if (fetchImpl === null || updateCheckDisabled(env))
75
+ return false;
76
+ const t = now();
77
+ if (lastCheckedAt !== null && t - lastCheckedAt < intervalMs)
78
+ return false;
79
+ lastCheckedAt = t;
80
+ try {
81
+ const latest = await fetchLatestVersion({
82
+ fetchImpl,
83
+ timeoutMs: opts.timeoutMs,
84
+ url: opts.url,
85
+ });
86
+ if (!latest)
87
+ return false;
88
+ latestSeen = latest;
89
+ if (!isNewerVersion(latest, current))
90
+ return false;
91
+ opts.log(updateAvailableLine(current, latest));
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ },
98
+ };
99
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.9.12';
1
+ export const VERSION = '0.10.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.9.12",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "The local host for non.bot ▶ Run — opens a terminal on your machine and starts the work in your linked repo.",
6
6
  "license": "UNLICENSED",