jaitus 0.3.0 → 0.3.2
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/package.json +2 -2
- package/src/agent/loop.js +155 -17
- package/src/commands/login.js +50 -32
- package/src/commands/update.js +167 -9
- package/src/errors.js +2 -2
- package/src/index.js +40 -10
- package/src/tui/mouse.js +150 -125
- package/src/tui-ink.js +1998 -1927
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jaitus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Jaitus — a coding agent in your terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"node": ">=20"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"test": "node tests/source-sanity.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js",
|
|
19
|
+
"test": "node tests/source-sanity.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/continuation.js",
|
|
20
20
|
"preview": "node scripts/tui-preview.js",
|
|
21
21
|
"prepublishOnly": "npm test"
|
|
22
22
|
},
|
package/src/agent/loop.js
CHANGED
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
import { byName, toolSchemas, ToolError, readImageForModel } from './tools.js';
|
|
22
22
|
import { PermissionDenied } from './permissions.js';
|
|
23
23
|
import path from 'node:path';
|
|
24
|
-
import
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import { loadContext, loadIgnore, CONTEXT_FILE } from '../project.js';
|
|
25
27
|
import { c, out, write, spinner, glyph } from '../ui.js';
|
|
26
28
|
import { discoverMcpTools } from '../mcp.js';
|
|
27
29
|
import { unifiedDiff } from '../diff.js';
|
|
@@ -29,6 +31,23 @@ import { fromResponse, fromGatewayBody } from '../errors.js';
|
|
|
29
31
|
|
|
30
32
|
const MAX_ITERATIONS = 40;
|
|
31
33
|
|
|
34
|
+
// Room for a long answer or a large file in one reply. The models behind Jaitus reason
|
|
35
|
+
// before they answer, and that reasoning is spent from the same budget — at 8192 a long
|
|
36
|
+
// answer, or any file of a few hundred lines, was cut off partway.
|
|
37
|
+
export const MAX_OUTPUT_TOKENS = 32000;
|
|
38
|
+
|
|
39
|
+
// A reply that stopped before it finished — the output limit, or a connection dropped
|
|
40
|
+
// somewhere between the provider and here — is continued, not presented as the answer.
|
|
41
|
+
// Bounded, so a model that never finishes cannot loop on the user's allowance.
|
|
42
|
+
export const MAX_CONTINUATIONS = 4;
|
|
43
|
+
export const CONTINUE_NOTE = 'Your previous reply was cut off before it finished (an output limit or a dropped connection). '
|
|
44
|
+
+ 'Continue from exactly where it stopped: do not repeat or summarise what you already wrote, and do not mention this note. '
|
|
45
|
+
+ 'If you were in the middle of a tool call, make that call again in full.';
|
|
46
|
+
|
|
47
|
+
// A request that failed before anything arrived (a network blip) is tried again this
|
|
48
|
+
// many times before it is reported.
|
|
49
|
+
const NETWORK_RETRIES = 2;
|
|
50
|
+
|
|
32
51
|
/** opencode tool icon per tool, muted when done. ASCII twins included. */
|
|
33
52
|
const TOOL_MARKS = {
|
|
34
53
|
run_command: ['$', '$'], read_file: ['→', '>'], read_image: ['→', '>'],
|
|
@@ -43,6 +62,11 @@ const toolMark = (name) => {
|
|
|
43
62
|
|
|
44
63
|
export const SYSTEM_PROMPT = `You are Jaitus, a coding agent running in a developer's terminal, inside their project directory.
|
|
45
64
|
|
|
65
|
+
You work directly on this computer. Your tools read, search, create and edit the files in
|
|
66
|
+
the project and run shell commands in it, and you can fetch web pages. When the user asks
|
|
67
|
+
about their files, their code or their machine, use the tools and answer from what you
|
|
68
|
+
find — never say you cannot access local files, because you can.
|
|
69
|
+
|
|
46
70
|
How to work:
|
|
47
71
|
- Investigate before you change anything. Read the files you are about to edit; do not guess at their contents.
|
|
48
72
|
- Prefer edit_file over write_file for existing files. Keep changes minimal and in the style of the surrounding code.
|
|
@@ -65,10 +89,66 @@ How to talk:
|
|
|
65
89
|
* Fenced and labelled rather than concatenated, so instructions written by whoever owns
|
|
66
90
|
* the repo are visibly the repo's and cannot be mistaken for part of Jaitus's own rules.
|
|
67
91
|
*/
|
|
92
|
+
/**
|
|
93
|
+
* Where the agent is: the facts it would otherwise guess at, and guess wrong.
|
|
94
|
+
*
|
|
95
|
+
* Without these, the model did not know which folder it was in, which OS its commands
|
|
96
|
+
* would run on (cmd.exe and /bin/sh disagree about almost everything), or today's date —
|
|
97
|
+
* and a model that does not know where it is tends to say it cannot see your files. The
|
|
98
|
+
* listing is the top level only, names not contents, and respects .jaitusignore.
|
|
99
|
+
*/
|
|
100
|
+
export function environmentBlock(cwd, { now = new Date(), platform = process.platform } = {}) {
|
|
101
|
+
if (!cwd) return '';
|
|
102
|
+
const osName = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' }[platform] || platform;
|
|
103
|
+
const shell = platform === 'win32' ? 'cmd.exe (run_command uses it)' : '/bin/sh (run_command uses it)';
|
|
104
|
+
// The repository may start above the working directory, and .git may be a file that
|
|
105
|
+
// points elsewhere (a worktree or submodule).
|
|
106
|
+
let branch = null;
|
|
107
|
+
for (let dir = cwd, i = 0; i < 40; i++) {
|
|
108
|
+
const dotGit = path.join(dir, '.git');
|
|
109
|
+
try {
|
|
110
|
+
let gitDir = dotGit;
|
|
111
|
+
if (fs.statSync(dotGit).isFile()) {
|
|
112
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'));
|
|
113
|
+
if (m) gitDir = path.resolve(dir, m[1].trim());
|
|
114
|
+
}
|
|
115
|
+
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
|
|
116
|
+
branch = head.startsWith('ref: refs/heads/') ? head.slice(16) : `detached at ${head.slice(0, 7)}`;
|
|
117
|
+
break;
|
|
118
|
+
} catch { /* not here; look one level up */ }
|
|
119
|
+
const parent = path.dirname(dir);
|
|
120
|
+
if (parent === dir) break;
|
|
121
|
+
dir = parent;
|
|
122
|
+
}
|
|
123
|
+
let listing = '';
|
|
124
|
+
try {
|
|
125
|
+
const ignore = loadIgnore(cwd);
|
|
126
|
+
const SKIP = new Set(['.git', 'node_modules']);
|
|
127
|
+
const names = fs.readdirSync(cwd, { withFileTypes: true })
|
|
128
|
+
.filter((d) => !SKIP.has(d.name) && !ignore.matches(d.isDirectory() ? `${d.name}/` : d.name) && !ignore.matches(d.name))
|
|
129
|
+
.map((d) => (d.isDirectory() ? `${d.name}/` : d.name))
|
|
130
|
+
.sort((a, b) => (b.endsWith('/') - a.endsWith('/')) || a.localeCompare(b));
|
|
131
|
+
const MAX = 60;
|
|
132
|
+
listing = names.slice(0, MAX).join('\n') + (names.length > MAX ? `\n… and ${names.length - MAX} more` : '');
|
|
133
|
+
} catch { /* unreadable: the tools will say why */ }
|
|
134
|
+
return `
|
|
135
|
+
|
|
136
|
+
<environment>
|
|
137
|
+
Working directory: ${cwd}
|
|
138
|
+
Platform: ${osName} (${os.release()})
|
|
139
|
+
Shell: ${shell}
|
|
140
|
+
Today: ${now.toISOString().slice(0, 10)}
|
|
141
|
+
Git: ${branch ? `yes, branch ${branch}` : 'no repository at this folder'}
|
|
142
|
+
Top level of the working directory:
|
|
143
|
+
${listing || '(empty)'}
|
|
144
|
+
</environment>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
68
147
|
export function systemPrompt(cwd) {
|
|
69
148
|
const project = cwd ? loadContext(cwd) : null;
|
|
70
|
-
|
|
71
|
-
return
|
|
149
|
+
const base = `${SYSTEM_PROMPT}${environmentBlock(cwd)}`;
|
|
150
|
+
if (!project) return base;
|
|
151
|
+
return `${base}
|
|
72
152
|
|
|
73
153
|
The project you are working in ships a ${CONTEXT_FILE} with notes for you. Follow it where
|
|
74
154
|
it applies, and prefer what it says over your own assumptions about this codebase.
|
|
@@ -82,7 +162,7 @@ ${project}
|
|
|
82
162
|
async function streamTurn({ client, messages, model, system, tools, onText, onToolStart, signal }) {
|
|
83
163
|
const res = await client.messages({
|
|
84
164
|
model,
|
|
85
|
-
max_tokens:
|
|
165
|
+
max_tokens: MAX_OUTPUT_TOKENS,
|
|
86
166
|
system: system || SYSTEM_PROMPT,
|
|
87
167
|
tools,
|
|
88
168
|
messages,
|
|
@@ -95,6 +175,10 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
|
|
|
95
175
|
// Blocks are keyed by the index the stream gives them, because they interleave.
|
|
96
176
|
const blocks = new Map();
|
|
97
177
|
let stopReason = null;
|
|
178
|
+
// Whether the stream ended the way a finished message ends. A stream that just stops
|
|
179
|
+
// — no message_stop, no stop reason — was cut, and used to be taken as a complete,
|
|
180
|
+
// silent answer: the "it stops midway for no reason" bug.
|
|
181
|
+
let sawStop = false;
|
|
98
182
|
const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
|
|
99
183
|
// Usage can arrive at the START of a stream (Anthropic-format providers) or only at
|
|
100
184
|
// the END, in message_delta (OpenAI-format providers, translated by the gateway —
|
|
@@ -145,6 +229,9 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
|
|
|
145
229
|
if (event.delta?.stop_reason) stopReason = event.delta.stop_reason;
|
|
146
230
|
takeUsage(event.usage);
|
|
147
231
|
break;
|
|
232
|
+
case 'message_stop':
|
|
233
|
+
sawStop = true;
|
|
234
|
+
break;
|
|
148
235
|
default:
|
|
149
236
|
break;
|
|
150
237
|
}
|
|
@@ -159,21 +246,31 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
|
|
|
159
246
|
// independently would corrupt it. The decoder holds the partial bytes until complete.
|
|
160
247
|
const decoder = new TextDecoder();
|
|
161
248
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
249
|
+
let cut = false;
|
|
250
|
+
try {
|
|
251
|
+
for await (const chunk of res.body) {
|
|
252
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
253
|
+
const frames = buffer.split('\n\n');
|
|
254
|
+
buffer = frames.pop() || '';
|
|
255
|
+
for (const frame of frames) {
|
|
256
|
+
for (const line of frame.split('\n')) {
|
|
257
|
+
if (!line.startsWith('data:')) continue;
|
|
258
|
+
const raw = line.slice(5).trim();
|
|
259
|
+
if (!raw || raw === '[DONE]') continue;
|
|
260
|
+
try { handle(JSON.parse(raw)); } catch { /* keepalive or partial */ }
|
|
261
|
+
}
|
|
172
262
|
}
|
|
263
|
+
if (streamError) break;
|
|
173
264
|
}
|
|
174
|
-
|
|
265
|
+
} catch (e) {
|
|
266
|
+
if (signal?.aborted || e?.name === 'AbortError') throw e;
|
|
267
|
+
// The connection dropped mid-answer. What arrived is kept and continued from; with
|
|
268
|
+
// nothing received yet, the caller retries the request instead.
|
|
269
|
+
if (!blocks.size) throw e;
|
|
270
|
+
cut = true;
|
|
175
271
|
}
|
|
176
272
|
if (streamError) throw streamError;
|
|
273
|
+
if (cut || (!sawStop && !stopReason)) stopReason = 'cut';
|
|
177
274
|
|
|
178
275
|
const content = [...blocks.entries()]
|
|
179
276
|
.sort((a, b) => a[0] - b[0])
|
|
@@ -222,6 +319,12 @@ export async function runAgent({
|
|
|
222
319
|
...Object.fromEntries(mcp.map((tool) => [tool.name, tool])),
|
|
223
320
|
};
|
|
224
321
|
|
|
322
|
+
let continuations = 0;
|
|
323
|
+
let networkRetries = 0;
|
|
324
|
+
// True while the previous reply is being continued: its text is still on screen as
|
|
325
|
+
// one live message, and the continuation must extend it rather than start another.
|
|
326
|
+
let continuing = false;
|
|
327
|
+
|
|
225
328
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
226
329
|
let streamed = false;
|
|
227
330
|
const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
|
|
@@ -260,6 +363,15 @@ export async function runAgent({
|
|
|
260
363
|
spin.stop();
|
|
261
364
|
// Esc during streaming: stop mid-sentence, keep what landed, don't treat it
|
|
262
365
|
// as an error. The partial text is already on screen.
|
|
366
|
+
// A network failure before anything arrived: try again, briefly, before reporting.
|
|
367
|
+
// Anything the gateway itself refused (a limit, a sign-in problem) has a status and
|
|
368
|
+
// is reported at once — retrying it would only repeat the refusal.
|
|
369
|
+
if (!(signal?.aborted || e?.name === 'AbortError') && !e?.status && networkRetries < NETWORK_RETRIES) {
|
|
370
|
+
networkRetries += 1;
|
|
371
|
+
await new Promise((r) => setTimeout(r, 800 * networkRetries));
|
|
372
|
+
iteration -= 1;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
263
375
|
if (signal?.aborted || e?.name === 'AbortError') {
|
|
264
376
|
if (tui) tui.flushStream();
|
|
265
377
|
if (streamed && !silent) out('');
|
|
@@ -271,8 +383,16 @@ export async function runAgent({
|
|
|
271
383
|
throw e;
|
|
272
384
|
}
|
|
273
385
|
spin.stop();
|
|
274
|
-
|
|
275
|
-
|
|
386
|
+
networkRetries = 0;
|
|
387
|
+
const incomplete = turn.stopReason === 'max_tokens' || turn.stopReason === 'cut';
|
|
388
|
+
// A tool call cut off halfway has JSON that is not whole; it is dropped and the model
|
|
389
|
+
// is asked to make it again.
|
|
390
|
+
if (incomplete) turn.content = turn.content.filter((b) => b.type !== 'tool_use' || b.input?.__parse_error === undefined);
|
|
391
|
+
const willContinue = incomplete && continuations < MAX_CONTINUATIONS && !turn.content.some((b) => b.type === 'tool_use');
|
|
392
|
+
if (!willContinue) {
|
|
393
|
+
if (tui) tui.flushStream();
|
|
394
|
+
if (streamed || continuing) { if (tui) tui.printStream('\n'); else if (!silent && !onText) out(''); }
|
|
395
|
+
}
|
|
276
396
|
|
|
277
397
|
totals.input += turn.usage.input_tokens || 0;
|
|
278
398
|
totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
|
|
@@ -283,6 +403,24 @@ export async function runAgent({
|
|
|
283
403
|
// appeared; now it moves after every step of the turn.
|
|
284
404
|
tui?.setTurnTokens?.(totals.input + totals.cached + totals.output);
|
|
285
405
|
|
|
406
|
+
if (willContinue) {
|
|
407
|
+
continuations += 1;
|
|
408
|
+
continuing = true;
|
|
409
|
+
const text = turn.content.filter((b) => b.type === 'text');
|
|
410
|
+
if (text.length) messages.push({ role: 'assistant', content: text });
|
|
411
|
+
messages.push({ role: 'user', content: [{ type: 'text', text: CONTINUE_NOTE }] });
|
|
412
|
+
onEvent?.({ type: 'continued', reason: turn.stopReason, count: continuations });
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
continuing = false;
|
|
416
|
+
if (incomplete && !turn.content.some((b) => b.type === 'tool_use')) {
|
|
417
|
+
// Still unfinished after every continuation: say so, rather than let a partial
|
|
418
|
+
// answer pass as a whole one.
|
|
419
|
+
const why = 'The reply was still unfinished after several continuations. Ask for the rest, or narrow the request.';
|
|
420
|
+
if (tui?.notice) tui.notice(why, 'warn');
|
|
421
|
+
else if (!silent) out(c.yellow(` ${why}`));
|
|
422
|
+
}
|
|
423
|
+
|
|
286
424
|
// Append the assistant turn verbatim — tool_use blocks and all.
|
|
287
425
|
messages.push({ role: 'assistant', content: turn.content });
|
|
288
426
|
onEvent?.({ type: 'assistant_turn', content: turn.content, usage: turn.usage, iteration });
|
package/src/commands/login.js
CHANGED
|
@@ -104,16 +104,15 @@ function awaitCallback(server, expectedState) {
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
/**
|
|
108
|
+
* The browser sign-in, with no output of its own.
|
|
109
|
+
*
|
|
110
|
+
* The command below prints it to the terminal; the app view shows the same steps in its
|
|
111
|
+
* transcript. Reports progress through `onUrl` (the link to open, shown in case the
|
|
112
|
+
* browser does not) and `onStatus`. Resolves to { ok: true, user } or { ok: false, message }.
|
|
113
|
+
*/
|
|
114
|
+
export async function signIn({ onUrl = () => {}, onStatus = () => {} } = {}) {
|
|
108
115
|
const { baseUrl, siteUrl } = endpoints();
|
|
109
|
-
|
|
110
|
-
const existing = loadCredentials();
|
|
111
|
-
if (existing?.refreshToken && !argv.includes('--force')) {
|
|
112
|
-
out(`Already signed in as ${c.bold(existing.user?.email || 'your account')}.`);
|
|
113
|
-
const again = await ask(`${c.dim('Sign in again? [y/N] ')}`);
|
|
114
|
-
if (again.toLowerCase() !== 'y') return 0;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
116
|
const state = crypto.randomBytes(24).toString('base64url');
|
|
118
117
|
// PKCE (S256): the challenge goes out through the browser, the verifier stays here and
|
|
119
118
|
// is only ever sent in the final exchange.
|
|
@@ -140,27 +139,18 @@ export default async function login(argv) {
|
|
|
140
139
|
url.searchParams.set('device', device);
|
|
141
140
|
url.searchParams.set('platform', process.platform);
|
|
142
141
|
|
|
143
|
-
|
|
144
|
-
out(` ${c.brand('Jaitus')} — sign in`);
|
|
145
|
-
out('');
|
|
146
|
-
out(` Opening ${c.cyan(siteUrl + '/cli-auth')} in your browser.`);
|
|
147
|
-
out(` ${c.dim('If it does not open, paste this link:')}`);
|
|
148
|
-
out(` ${c.dim(url.toString())}`);
|
|
149
|
-
out('');
|
|
150
|
-
|
|
142
|
+
onUrl(url.toString(), `${siteUrl}/cli-auth`);
|
|
151
143
|
openBrowser(url.toString());
|
|
152
|
-
|
|
144
|
+
onStatus('waiting');
|
|
153
145
|
|
|
154
146
|
let code;
|
|
155
147
|
try {
|
|
156
148
|
({ code } = await awaitCallback(server, state));
|
|
157
149
|
} catch (e) {
|
|
158
|
-
spin.stop();
|
|
159
150
|
server.close();
|
|
160
|
-
|
|
161
|
-
return 1;
|
|
151
|
+
return { ok: false, message: e.message };
|
|
162
152
|
}
|
|
163
|
-
|
|
153
|
+
onStatus('finishing');
|
|
164
154
|
|
|
165
155
|
try {
|
|
166
156
|
const res = await fetch(`${baseUrl}/api/auth/cli/exchange`, {
|
|
@@ -169,13 +159,11 @@ export default async function login(argv) {
|
|
|
169
159
|
body: JSON.stringify({ code, verifier }),
|
|
170
160
|
});
|
|
171
161
|
const body = await res.json().catch(() => ({}));
|
|
172
|
-
spin.stop();
|
|
173
162
|
server.close();
|
|
174
163
|
|
|
175
164
|
if (!res.ok || !body?.data?.accessToken || !body?.data?.refreshToken) {
|
|
176
165
|
// Our own sign-in endpoint's wording, or a plain fallback — never a raw body.
|
|
177
|
-
|
|
178
|
-
return 1;
|
|
166
|
+
return { ok: false, message: typeof body?.message === 'string' && body.message ? body.message : 'Sign-in did not complete. Try again.' };
|
|
179
167
|
}
|
|
180
168
|
|
|
181
169
|
saveCredentials({
|
|
@@ -188,14 +176,44 @@ export default async function login(argv) {
|
|
|
188
176
|
createdAt: new Date().toISOString(),
|
|
189
177
|
});
|
|
190
178
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
info(`Run ${c.bold('jaitus')} in a project to start, or ${c.bold('jaitus --help')} to see everything.`);
|
|
194
|
-
return 0;
|
|
195
|
-
} catch (e) {
|
|
196
|
-
spin.stop();
|
|
179
|
+
return { ok: true, user: body.data.user || null };
|
|
180
|
+
} catch {
|
|
197
181
|
server.close();
|
|
198
|
-
|
|
182
|
+
return { ok: false, message: 'Could not reach Jaitus. Check your connection and try again.' };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export default async function login(argv, { then = 'hint' } = {}) {
|
|
187
|
+
const existing = loadCredentials();
|
|
188
|
+
if (existing?.refreshToken && !argv.includes('--force')) {
|
|
189
|
+
out(`Already signed in as ${c.bold(existing.user?.email || 'your account')}.`);
|
|
190
|
+
const again = await ask(`${c.dim('Sign in again? [y/N] ')}`);
|
|
191
|
+
if (again.toLowerCase() !== 'y') return 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let spin = null;
|
|
195
|
+
const result = await signIn({
|
|
196
|
+
onUrl: (url, shown) => {
|
|
197
|
+
out('');
|
|
198
|
+
out(` ${c.brand('Jaitus')} — sign in`);
|
|
199
|
+
out('');
|
|
200
|
+
out(` Opening ${c.cyan(shown)} in your browser.`);
|
|
201
|
+
out(` ${c.dim('If it does not open, paste this link:')}`);
|
|
202
|
+
out(` ${c.dim(url)}`);
|
|
203
|
+
out('');
|
|
204
|
+
},
|
|
205
|
+
onStatus: (s) => {
|
|
206
|
+
if (s === 'waiting') spin = spinner(glyph('Waiting for you to authorise in the browser…', 'Waiting for you to authorise in the browser...'));
|
|
207
|
+
else spin?.update(glyph('Finishing sign-in…', 'Finishing sign-in...'));
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
spin?.stop();
|
|
211
|
+
if (!result.ok) {
|
|
212
|
+
fail(`${result.message}${/login/.test(result.message) ? '' : ` Run ${c.bold('jaitus login')} to try again.`}`);
|
|
199
213
|
return 1;
|
|
200
214
|
}
|
|
215
|
+
const who = result.user;
|
|
216
|
+
ok(`Signed in as ${c.bold(who?.email || 'your account')}${who?.isPaid ? '' : c.dim(' (free plan)')}`);
|
|
217
|
+
if (then === 'hint') info(`Run ${c.bold('jaitus')} in a project to start, or ${c.bold('jaitus --help')} to see everything.`);
|
|
218
|
+
return 0;
|
|
201
219
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
// ─── jaitus update ──────────────────────────────────────────────────────────────
|
|
2
2
|
//
|
|
3
|
-
// Checks the registry and
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Checks the registry, and installs the new version when it can tell HOW this copy was
|
|
4
|
+
// installed: a global npm install is upgraded with the same npm, after asking. Anything
|
|
5
|
+
// else — npx, a version manager, a copy vendored into a repo — gets the command to run
|
|
6
|
+
// instead, because a tool that guesses wrong there either fails confusingly or modifies
|
|
7
|
+
// something it was never asked to modify.
|
|
8
|
+
//
|
|
9
|
+
// A global install never updates itself, so the app also checks once a day, in the
|
|
10
|
+
// background, and says when a newer version is out (the update-notifier pattern npm and
|
|
11
|
+
// gh use). JAITUS_NO_UPDATE_CHECK=1, NO_UPDATE_NOTIFIER or CI turns that off.
|
|
7
12
|
|
|
8
|
-
import
|
|
9
|
-
import
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { c, out, ok, info, warn, fail, present, askKey } from '../ui.js';
|
|
18
|
+
import { version, HOME } from '../config.js';
|
|
10
19
|
|
|
11
20
|
const REGISTRY = process.env.JAITUS_REGISTRY || 'https://registry.npmjs.org';
|
|
12
21
|
const TIMEOUT_MS = 5000;
|
|
@@ -46,6 +55,74 @@ export async function latestVersion() {
|
|
|
46
55
|
return (await checkRegistry()).version;
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
const CHECK_FILE = path.join(HOME, 'update-check.json');
|
|
59
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
60
|
+
|
|
61
|
+
/** Whether the background check is allowed to run at all. */
|
|
62
|
+
export function updateCheckEnabled(env = process.env) {
|
|
63
|
+
return !(env.JAITUS_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The newest published version, asked of the registry at most once a day.
|
|
68
|
+
*
|
|
69
|
+
* Cached so opening the app is never slowed by, or dependent on, the network: a failed
|
|
70
|
+
* check is remembered too, and simply tried again tomorrow.
|
|
71
|
+
*/
|
|
72
|
+
export async function cachedLatest({ now = Date.now(), file = CHECK_FILE, check = checkRegistry } = {}) {
|
|
73
|
+
try {
|
|
74
|
+
const cached = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
75
|
+
if (cached && now - Number(cached.checkedAt) < DAY_MS) return cached.latest || null;
|
|
76
|
+
} catch { /* first run, or unreadable: ask */ }
|
|
77
|
+
const { version: latest } = await check();
|
|
78
|
+
try {
|
|
79
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
80
|
+
fs.writeFileSync(file, JSON.stringify({ checkedAt: now, latest: latest || null }));
|
|
81
|
+
} catch { /* read-only home: check again next time */ }
|
|
82
|
+
return latest || null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One line for the app to show when a newer version is out, or null. */
|
|
86
|
+
export async function updateNotice(opts = {}) {
|
|
87
|
+
if (!updateCheckEnabled(opts.env)) return null;
|
|
88
|
+
const current = opts.current || version();
|
|
89
|
+
const latest = await cachedLatest(opts).catch(() => null);
|
|
90
|
+
if (!latest || !isNewer(latest, current)) return null;
|
|
91
|
+
return `Jaitus v${latest} is out — you have v${current}. Quit and run \`jaitus update\` to install it.`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function run(cmd, args, { capture = false } = {}) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
// npm is npm.cmd on Windows, which spawn cannot start without a shell. The arguments
|
|
97
|
+
// here are fixed strings (a version number from the registry at most), so they are
|
|
98
|
+
// joined into one command line rather than passed alongside shell: true, which Node
|
|
99
|
+
// deprecates because it does not escape them.
|
|
100
|
+
const stdio = capture ? ['ignore', 'pipe', 'ignore'] : 'inherit';
|
|
101
|
+
const child = process.platform === 'win32'
|
|
102
|
+
? spawn([cmd, ...args].join(' '), { shell: true, stdio })
|
|
103
|
+
: spawn(cmd, args, { stdio });
|
|
104
|
+
let stdout = '';
|
|
105
|
+
child.stdout?.on('data', (d) => { stdout += d; });
|
|
106
|
+
child.on('error', () => resolve({ code: -1, stdout }));
|
|
107
|
+
child.on('close', (code) => resolve({ code, stdout }));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* How this copy was installed: 'npm-global', 'npx', or 'other'.
|
|
113
|
+
*
|
|
114
|
+
* Decided by where this file actually lives, compared with npm's own global folder —
|
|
115
|
+
* not by guessing from environment variables.
|
|
116
|
+
*/
|
|
117
|
+
export async function installMethod({ here = fileURLToPath(import.meta.url), npmRoot = null } = {}) {
|
|
118
|
+
const real = (() => { try { return fs.realpathSync(here); } catch { return here; } })();
|
|
119
|
+
const norm = (p) => path.resolve(p).toLowerCase();
|
|
120
|
+
if (/[\\/]_npx[\\/]/.test(real)) return 'npx';
|
|
121
|
+
const root = npmRoot ?? (await run('npm', ['root', '-g'], { capture: true })).stdout.trim();
|
|
122
|
+
if (root && norm(real).startsWith(norm(path.join(root, 'jaitus')) + path.sep.toLowerCase())) return 'npm-global';
|
|
123
|
+
return 'other';
|
|
124
|
+
}
|
|
125
|
+
|
|
49
126
|
/**
|
|
50
127
|
* Plain semver comparison — is `a` newer than `b`?
|
|
51
128
|
* Prerelease tags are treated as older than the release they lead to, which is what
|
|
@@ -67,7 +144,61 @@ export function isNewer(a, b) {
|
|
|
67
144
|
return false;
|
|
68
145
|
}
|
|
69
146
|
|
|
70
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Update on launch, then reopen: every time `jaitus` starts, the registry is asked
|
|
149
|
+
* (with a short timeout, so a slow network costs at most that); when a newer version is
|
|
150
|
+
* out and this copy is a global npm install, it is installed and the same command is
|
|
151
|
+
* started again on the new version.
|
|
152
|
+
*
|
|
153
|
+
* It never blocks the session on a failure. A failed install (commonly: a system-wide
|
|
154
|
+
* Node on macOS or Linux, where npm -g needs sudo) says so once and is not retried for a
|
|
155
|
+
* day, so a machine that cannot update does not pay for trying on every start.
|
|
156
|
+
*
|
|
157
|
+
* Off with `jaitus config autoUpdate off`, JAITUS_NO_AUTOUPDATE=1, NO_UPDATE_NOTIFIER or CI.
|
|
158
|
+
* Returns the child's exit code when it relaunched, or null to carry on in this process.
|
|
159
|
+
*/
|
|
160
|
+
export async function autoUpdate({ argv = [], settings = {}, env = process.env, current = version(), log = out,
|
|
161
|
+
check = () => checkRegistry(), method = () => installMethod(), install = (v) => run('npm', ['install', '-g', `jaitus@${v}`], { capture: true }),
|
|
162
|
+
relaunch = defaultRelaunch, now = Date.now(), failFile = path.join(HOME, 'update-failed.json') } = {}) {
|
|
163
|
+
if (env.JAITUS_UPDATED_FROM) return null; // just relaunched: never loop
|
|
164
|
+
if (!updateCheckEnabled(env) || env.JAITUS_NO_AUTOUPDATE || settings.autoUpdate === false) return null;
|
|
165
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) return null; // scripts and pipes get a stable version
|
|
166
|
+
const first = argv.find((a) => !a.startsWith('-'));
|
|
167
|
+
if (['update', 'completion', 'help', 'logout'].includes(first) || argv.some((a) => ['--version', '-v', '--help', '-h', '--print', '-p'].includes(a))) return null;
|
|
168
|
+
try {
|
|
169
|
+
const failed = JSON.parse(fs.readFileSync(failFile, 'utf8'));
|
|
170
|
+
if (now - Number(failed.at) < DAY_MS) return null;
|
|
171
|
+
} catch { /* no recent failure */ }
|
|
172
|
+
|
|
173
|
+
const { version: latest } = await Promise.race([
|
|
174
|
+
check(),
|
|
175
|
+
// unref: a quick command must not sit waiting for this timer after the answer came.
|
|
176
|
+
new Promise((resolve) => setTimeout(() => resolve({ version: null }), 1500).unref()),
|
|
177
|
+
]);
|
|
178
|
+
if (!latest || !isNewer(latest, current)) return null;
|
|
179
|
+
if ((await method()) !== 'npm-global') return null; // the in-app notice covers these
|
|
180
|
+
|
|
181
|
+
log(` ${c.brand('Jaitus')} ${c.dim(`v${current} →`)} ${c.bold(`v${latest}`)} ${c.dim('— updating…')}`);
|
|
182
|
+
const { code } = await install(latest);
|
|
183
|
+
if (code !== 0) {
|
|
184
|
+
try { fs.mkdirSync(path.dirname(failFile), { recursive: true }); fs.writeFileSync(failFile, JSON.stringify({ at: now, version: latest })); } catch { /* best effort */ }
|
|
185
|
+
log(` ${c.yellow('!')} ${c.dim('Could not update automatically — continuing on')} v${current}${c.dim('. To update:')} ${c.bold(process.platform === 'win32' ? 'npm install -g jaitus@latest' : 'sudo npm install -g jaitus@latest')}`);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
log(` ${c.accent('✓')} ${c.dim('Updated — reopening')}`);
|
|
189
|
+
return relaunch({ ...env, JAITUS_UPDATED_FROM: current });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Start the same command again on the freshly installed version, in this terminal. */
|
|
193
|
+
function defaultRelaunch(env) {
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
const child = spawn(process.execPath, [...process.execArgv, process.argv[1], ...process.argv.slice(2)], { stdio: 'inherit', env });
|
|
196
|
+
child.on('error', () => resolve(null));
|
|
197
|
+
child.on('exit', (code, signal) => resolve(signal ? 1 : (code ?? 0)));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export default async function update(argv = [], { json = false, interactive = true } = {}) {
|
|
71
202
|
const current = version();
|
|
72
203
|
const { version: latest, status } = await checkRegistry();
|
|
73
204
|
|
|
@@ -89,9 +220,36 @@ export default async function update(argv = [], { json = false } = {}) {
|
|
|
89
220
|
out('');
|
|
90
221
|
out(` ${c.bold(`v${latest}`)} is available ${c.dim(`— you have v${current}`)}`);
|
|
91
222
|
out('');
|
|
92
|
-
|
|
223
|
+
});
|
|
224
|
+
if (!available || json) return 0;
|
|
225
|
+
|
|
226
|
+
const manual = () => {
|
|
227
|
+
out(` ${c.dim('global install')} ${c.bold('npm install -g jaitus@latest')}`);
|
|
93
228
|
out(` ${c.dim('no install')} ${c.bold('npx jaitus@latest')}`);
|
|
94
229
|
out('');
|
|
95
|
-
}
|
|
230
|
+
};
|
|
231
|
+
// Inside the app the running copy cannot be swapped out from under itself.
|
|
232
|
+
if (!interactive) { info('Quit Jaitus, then run it from your shell:'); manual(); return 0; }
|
|
233
|
+
|
|
234
|
+
const method = await installMethod();
|
|
235
|
+
if (method !== 'npm-global') {
|
|
236
|
+
info(method === 'npx' ? 'You are running it through npx — use @latest to get the new version:' : 'This copy was not installed with npm -g, so update it the way you installed it, or:');
|
|
237
|
+
manual();
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
const yes = argv.includes('--yes') || argv.includes('-y');
|
|
241
|
+
if (!yes) {
|
|
242
|
+
if (!process.stdin.isTTY) { manual(); return 0; }
|
|
243
|
+
const answer = await askKey(` Install v${latest} now? ${c.dim('[Y/n]')} `, ['y', 'n', '\r', '\n']);
|
|
244
|
+
if (answer === 'n') { manual(); return 0; }
|
|
245
|
+
}
|
|
246
|
+
out(` ${c.dim('running')} npm install -g jaitus@${latest}`);
|
|
247
|
+
const { code } = await run('npm', ['install', '-g', `jaitus@${latest}`]);
|
|
248
|
+
if (code !== 0) {
|
|
249
|
+
fail(`npm could not install it (exit ${code}). Try it yourself — on macOS or Linux it may need sudo, or a Node version manager:`);
|
|
250
|
+
manual();
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
ok(`Updated to v${latest}. Start ${c.bold('jaitus')} again to use it.`);
|
|
96
254
|
return 0;
|
|
97
255
|
}
|
package/src/errors.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// recognise, because the only text that is safe to show is text we wrote.
|
|
9
9
|
|
|
10
10
|
const CATALOG = {
|
|
11
|
-
auth_required: { title: 'Not signed in', hint: '
|
|
12
|
-
session_expired: { title: 'Signed out', hint: '
|
|
11
|
+
auth_required: { title: 'Not signed in', hint: 'Type /login to sign in (or run `jaitus login`).' },
|
|
12
|
+
session_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
|
|
13
13
|
account_suspended: { title: 'Account suspended', hint: 'Contact support if you think this is a mistake.' },
|
|
14
14
|
email_unverified: { title: 'Email not verified', hint: 'Open the verification link in your inbox, then retry.' },
|
|
15
15
|
free_limit_reached: { title: 'Free prompts used up', hint: 'Subscribe on the website, then retry this turn.' },
|