@devmarketplacenpm/devmp 0.1.1-beta.5

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.
@@ -0,0 +1,597 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const readline = require('readline');
5
+ const {
6
+ color,
7
+ banner,
8
+ section,
9
+ success,
10
+ warn,
11
+ fail,
12
+ line,
13
+ info: info_,
14
+ } = require('./ui');
15
+ const { collectStatus, renderStatusCard, renderDoctor } = require('./status');
16
+ const {
17
+ loadSession,
18
+ saveSession,
19
+ clearSession,
20
+ sessionPath,
21
+ } = require('./session');
22
+ const {
23
+ startDeviceAuth,
24
+ pollDeviceAuth,
25
+ getTokenBalance,
26
+ refreshSession,
27
+ streamRun,
28
+ } = require('./api');
29
+ const { openBrowser } = require('./browser');
30
+ const { readSnapshot, applyFile } = require('./workspace');
31
+ const { isInteractive, confirmOverwrite } = require('./prompt');
32
+ const { runOverWs } = require('./ws-run');
33
+ const { runInteractiveShell } = require('./interactive');
34
+ const {
35
+ blockedMessage: clientBlockedMessage,
36
+ updateNotice: clientUpdateNotice,
37
+ } = require('./version');
38
+ const { expandMentions } = require('./mentions');
39
+
40
+ /**
41
+ * Read a piped prompt from stdin.
42
+ *
43
+ * Lets `devmp run` take its task from a file, a heredoc, or another program —
44
+ * `git diff | devmp run` — which is how a CLI gets used once it is trusted
45
+ * enough to appear in scripts. Returns null when stdin is a terminal, so
46
+ * interactive use is untouched.
47
+ */
48
+ async function readPipedPrompt() {
49
+ if (process.stdin.isTTY) return null;
50
+ let text = '';
51
+ try {
52
+ process.stdin.setEncoding('utf8');
53
+ for await (const chunk of process.stdin) {
54
+ text += chunk;
55
+ // A prompt is a task description, not a payload. Stop well before an
56
+ // accidental `cat huge.log | devmp run` exhausts memory.
57
+ if (text.length > 100_000) break;
58
+ }
59
+ } catch {
60
+ return null;
61
+ }
62
+ const trimmed = text.trim();
63
+ return trimmed ? trimmed.slice(0, 100_000) : null;
64
+ }
65
+
66
+ /**
67
+ * Has the server explicitly rejected this saved session?
68
+ *
69
+ * Only a 401/403 counts. If the request fails for any other reason — the API is
70
+ * down, the machine is offline — we cannot tell, and discarding a login that
71
+ * still works would be a worse bug than the stale session this guards against.
72
+ */
73
+ async function sessionRejected(config, saved) {
74
+ try {
75
+ await getTokenBalance(activeSession(config, saved));
76
+ return false;
77
+ } catch (error) {
78
+ return error?.status === 401 || error?.status === 403;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Ask the server what it expects of this client before doing any real work.
84
+ *
85
+ * A refusal is worth one cheap round-trip: the alternative is failing halfway
86
+ * through a build with a protocol error nobody can act on. The check never
87
+ * fails the command on its own — if the account endpoint is unreachable the
88
+ * run proceeds, and the real call reports the real problem.
89
+ */
90
+ async function passesVersionCheck(session) {
91
+ try {
92
+ await getTokenBalance(session);
93
+ } catch {
94
+ /* not our error to report; let the actual work surface it */
95
+ }
96
+ const tooOld = clientBlockedMessage();
97
+ if (tooOld) {
98
+ fail(tooOld);
99
+ process.exitCode = 1;
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+
105
+ /** A one-line nudge when a newer client exists. Silent when there isn't one. */
106
+ function printUpdateNotice() {
107
+ const notice = clientUpdateNotice();
108
+ if (!notice) return;
109
+ console.log('');
110
+ info_(` ${color.dim(notice)}`);
111
+ }
112
+
113
+ // Turn a raw error into a short, actionable message for the user.
114
+ function friendlyError(error) {
115
+ const status = error && error.status;
116
+ const message = (error && error.message) || String(error);
117
+ if (status === 403) {
118
+ return "You're out of agent tokens for this month, or your account isn't provisioned. Ask an admin for a token grant.";
119
+ }
120
+ if (status === 401) {
121
+ return 'Your session has expired. Run `devmp login` again.';
122
+ }
123
+ if (/Could not reach the DevMarketplace API/i.test(message)) {
124
+ return `${message}\n Tip: start the backend, or pass --local / --api-base-url.`;
125
+ }
126
+ return message;
127
+ }
128
+
129
+ // The URL a command should talk to: an explicit --local/--api-base-url wins,
130
+ // otherwise stay on the backend the session was created against.
131
+ function activeSession(config, session) {
132
+ const apiBaseUrl = config.apiExplicit
133
+ ? config.apiBaseUrl
134
+ : session.apiBaseUrl || config.apiBaseUrl;
135
+ return { ...session, apiBaseUrl };
136
+ }
137
+
138
+ // Signup is the same device flow with a different landing page: register
139
+ // first, then fall through to the approval screen with the code intact.
140
+ function browserAuthUrl(config, userCode, mode = 'login') {
141
+ if (mode === 'signup') {
142
+ const url = new URL(`${config.frontendBaseUrl}/register`);
143
+ url.searchParams.set('callbackUrl', `/cli-auth?code=${userCode}`);
144
+ url.searchParams.set('cli', '1');
145
+ return url.toString();
146
+ }
147
+ const url = new URL(`${config.frontendBaseUrl}/cli-auth`);
148
+ url.searchParams.set('code', userCode);
149
+ url.searchParams.set('cli', '1');
150
+ return url.toString();
151
+ }
152
+
153
+ function printBalance(balance) {
154
+ section('Agent tokens');
155
+ if (balance.provisioned) {
156
+ const used = Math.max(0, balance.monthlyLimit - balance.balance);
157
+ line('Monthly', balance.monthlyLimit.toLocaleString());
158
+ line('Used', used.toLocaleString());
159
+ line('Remaining', balance.balance.toLocaleString());
160
+ } else {
161
+ line('Status', 'not provisioned (ask an admin for a token grant)');
162
+ }
163
+ }
164
+
165
+ /**
166
+ * A yes/no question before the composer exists. Defaults to yes: it is only
167
+ * ever asked when the alternative is doing nothing at all.
168
+ */
169
+ function confirm(question) {
170
+ return new Promise((resolve) => {
171
+ const rl = readline.createInterface({
172
+ input: process.stdin,
173
+ output: process.stdout,
174
+ });
175
+ rl.question(`${question} ${color.dim('[Y/n]')} `, (answer) => {
176
+ rl.close();
177
+ resolve(!/^n/i.test(answer.trim()));
178
+ });
179
+ });
180
+ }
181
+
182
+ async function login(config, mode = 'login') {
183
+ banner();
184
+ const existing = await loadSession();
185
+ if (existing) {
186
+ // A session file on disk is not the same thing as being signed in. An
187
+ // expired one used to send the user in a circle: `status` told them to run
188
+ // `login`, and `login` told them they were already logged in. Ask the
189
+ // server which it is, and sign them in again when it says the session died.
190
+ if (!(await sessionRejected(config, existing))) {
191
+ warn('Already logged in. Run `devmp logout` to switch accounts.');
192
+ if (existing.user && existing.user.email) line('Account', existing.user.email);
193
+ return;
194
+ }
195
+ warn('Your previous session expired — signing in again.');
196
+ await clearSession().catch(() => undefined);
197
+ }
198
+
199
+ section(mode === 'signup' ? 'Create account' : 'Login');
200
+ const start = await startDeviceAuth(config);
201
+ const url = browserAuthUrl(config, start.userCode, mode);
202
+ info_(` ${color.dim('Code')} ${color.bold(color.cyan(start.userCode))}`);
203
+ info_(` ${color.dim('URL ')} ${url}`);
204
+ info_('');
205
+ info_('Approve this code in your browser to connect the CLI.');
206
+ if (!openBrowser(url)) warn('Could not open the browser — open the URL above.');
207
+
208
+ const session = await pollDeviceAuth(config, start);
209
+ success('CLI connected.');
210
+ if (session.user && session.user.email) line('Account', session.user.email);
211
+ try {
212
+ printBalance(await getTokenBalance(session));
213
+ } catch {
214
+ /* balance is a nicety; never fail login on it */
215
+ }
216
+ }
217
+
218
+ async function status(config, { offerNext = true } = {}) {
219
+ const saved = await loadSession();
220
+ const session = saved ? activeSession(config, saved) : null;
221
+ const info = await collectStatus({ session, rootDir: process.cwd() });
222
+ if (!session) info.apiBaseUrl = config.apiBaseUrl;
223
+ for (const row of renderStatusCard(info, {
224
+ rootDir: process.cwd(),
225
+ env: config.env,
226
+ })) {
227
+ console.log(row);
228
+ }
229
+ if (!saved && offerNext) {
230
+ console.log('');
231
+ info_('Run `devmp login` to connect your account.');
232
+ }
233
+ // collectStatus has already called the account endpoint by now, so if a
234
+ // newer client exists this is the moment the user is most ready to hear it.
235
+ printUpdateNotice();
236
+ }
237
+
238
+ async function signup(config) {
239
+ return login(config, 'signup');
240
+ }
241
+
242
+ async function doctor(config) {
243
+ const saved = await loadSession();
244
+ const session = saved ? activeSession(config, saved) : null;
245
+ for (const row of await renderDoctor({
246
+ config,
247
+ session,
248
+ rootDir: process.cwd(),
249
+ })) {
250
+ console.log(row);
251
+ }
252
+ }
253
+
254
+ async function logout() {
255
+ try {
256
+ await clearSession();
257
+ success('Logged out.');
258
+ } catch (error) {
259
+ fail(
260
+ `Could not remove the saved session at ${sessionPath()} — ` +
261
+ `${error.message}. Your credentials are still on this machine.`,
262
+ );
263
+ process.exitCode = 1;
264
+ }
265
+ }
266
+
267
+ async function shell(config, options = {}) {
268
+ let saved = await loadSession();
269
+ if (!saved) {
270
+ // The signed-out screen is a state, not an error: show the same card the
271
+ // shell would, so the answer to "what now" is in the same place as always.
272
+ await status(config, { offerNext: false });
273
+
274
+ // Being told to run a second command to use the first one is a poor way to
275
+ // meet a tool. At a terminal we can just do it.
276
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
277
+ process.exitCode = 1;
278
+ return;
279
+ }
280
+ console.log('');
281
+ if (!(await confirm('Log in now?'))) {
282
+ process.exitCode = 1;
283
+ return;
284
+ }
285
+ console.log('');
286
+ await login(config);
287
+ saved = await loadSession();
288
+ if (!saved) {
289
+ process.exitCode = 1;
290
+ return;
291
+ }
292
+ console.log('');
293
+ }
294
+ let session = activeSession(config, saved);
295
+ const shellOptions = {
296
+ config,
297
+ rootDir: path.resolve(options.cwd || process.cwd()),
298
+ provider: options.provider,
299
+ model: options.model,
300
+ maxFiles: options.maxFiles,
301
+ yes: options.yes === true,
302
+ allowCommands: options.allowCommands === true,
303
+ };
304
+ try {
305
+ await runInteractiveShell({ session, ...shellOptions });
306
+ } catch (error) {
307
+ if (!/invalid or expired session/i.test(error?.message || '')) throw error;
308
+ const refreshed = await refreshSession(session).catch(() => null);
309
+ if (!refreshed) throw error;
310
+ session = activeSession(config, refreshed);
311
+ await runInteractiveShell({ session, ...shellOptions });
312
+ }
313
+ // Held until the session ends: an update nudge during a working session is
314
+ // an interruption, and the shell already carries the version in /status.
315
+ printUpdateNotice();
316
+ }
317
+
318
+ // Shared "Done"/"Partial"/error footer for both run modes.
319
+ function renderResult(doneEvent, sawError, written, skipped, rootDir, cancelled = false) {
320
+ console.log(color.dim('───────────────────────────────────────────────────'));
321
+ if (cancelled) {
322
+ warn('Run cancelled.');
323
+ return;
324
+ }
325
+ if (sawError) {
326
+ fail(sawError);
327
+ process.exitCode = 1;
328
+ return;
329
+ }
330
+ section(doneEvent && doneEvent.partial ? 'Partial build' : 'Done');
331
+ // A read-only task legitimately writes nothing; '0 written' reads like a
332
+ // failure for work that succeeded.
333
+ line(
334
+ 'Files',
335
+ written
336
+ ? `${written} file${written === 1 ? '' : 's'} changed in ${rootDir}`
337
+ : 'none changed — this answer was read-only',
338
+ );
339
+ if (skipped) line('Skipped', `${skipped} existing file(s) left unchanged`);
340
+ if (doneEvent) {
341
+ if (doneEvent.summary) {
342
+ console.log('');
343
+ console.log(doneEvent.summary);
344
+ }
345
+ if (doneEvent.commands && doneEvent.commands.length) {
346
+ section('Run it');
347
+ for (const cmd of doneEvent.commands) console.log(` ${color.cyan('$')} ${cmd}`);
348
+ }
349
+ const used = doneEvent.usage
350
+ ? doneEvent.usage.inputTokens + doneEvent.usage.outputTokens
351
+ : 0;
352
+ if (used) line('Tokens', used.toLocaleString());
353
+ }
354
+ printUpdateNotice();
355
+ }
356
+
357
+ async function run(config, options) {
358
+ banner();
359
+ const saved = await loadSession();
360
+ if (!saved) {
361
+ warn('You need to log in first. Run `devmp login`.');
362
+ process.exitCode = 1;
363
+ return;
364
+ }
365
+ const session = activeSession(config, saved);
366
+
367
+ // An explicit argument wins; otherwise take whatever was piped in.
368
+ const prompt = options.prompt || (await readPipedPrompt());
369
+ if (!prompt) {
370
+ warn('Nothing to build. Usage: devmp run "add a health check route".');
371
+ info_(' A prompt can also be piped in: echo "…" | devmp run');
372
+ process.exitCode = 1;
373
+ return;
374
+ }
375
+
376
+ if (!(await passesVersionCheck(session))) return;
377
+
378
+ const rootDir = path.resolve(options.cwd || process.cwd());
379
+
380
+ // `@path` in the prompt pulls that file into the turn up front, instead of
381
+ // waiting for the agent to decide it needs it.
382
+ const mentioned = await expandMentions(rootDir, prompt);
383
+
384
+ section('Run');
385
+ line('Workspace', rootDir);
386
+ line('Prompt', prompt.length > 70 ? `${prompt.slice(0, 70)}…` : prompt);
387
+ if (mentioned.attached.length) {
388
+ line(
389
+ 'Attached',
390
+ mentioned.attached
391
+ .map((f) => (f.truncated ? `${f.path} (truncated)` : f.path))
392
+ .join(', '),
393
+ );
394
+ }
395
+ line('Mode', options.snapshot ? 'snapshot (HTTP)' : 'live tunnel (WebSocket)');
396
+ if (!options.snapshot) {
397
+ line(
398
+ 'Commands',
399
+ options.allowCommands
400
+ ? 'auto-approved (--allow-commands)'
401
+ : 'ask before each (agent may run npm/tests)',
402
+ );
403
+ }
404
+
405
+ if (options.snapshot) {
406
+ await runSnapshot(session, rootDir, mentioned.prompt, options);
407
+ } else {
408
+ await runLive(session, rootDir, mentioned.prompt, options);
409
+ }
410
+ }
411
+
412
+ // Live tunnel: the server drives; the client answers on-demand file ops and
413
+ // writes to local disk. No snapshot upload — the agent lists/reads what it needs.
414
+ async function runLive(session, rootDir, prompt, options) {
415
+ line('Context', 'live — the agent reads your files on demand');
416
+ console.log('');
417
+ console.log(color.dim('── agent ──────────────────────────────────────────'));
418
+
419
+ // Distinct paths, not write operations. A rename across five files takes
420
+ // eleven edits, and reporting "11 written" in a five-file project reads as
421
+ // though something ran away.
422
+ const writtenPaths = new Set();
423
+ let skipped = 0;
424
+ let sawError = null;
425
+ let cancelled = false;
426
+ let doneEvent = null;
427
+ let atLineStart = true;
428
+ const nl = () => {
429
+ if (!atLineStart) {
430
+ process.stdout.write('\n');
431
+ atLineStart = true;
432
+ }
433
+ };
434
+
435
+ try {
436
+ await runOverWs({
437
+ session,
438
+ rootDir,
439
+ prompt,
440
+ provider: options.provider,
441
+ model: options.model,
442
+ maxFiles: options.maxFiles,
443
+ yes: options.yes,
444
+ allowCommands: options.allowCommands,
445
+ onEvent: (event) => {
446
+ if (event.type === 'instructions') {
447
+ // Worth one line: the agent is about to follow rules the user wrote
448
+ // down somewhere else, and silence here looks like they were ignored.
449
+ line(
450
+ 'Instructions',
451
+ event.truncated
452
+ ? `${event.source} (first 8 KB)`
453
+ : event.source,
454
+ );
455
+ return;
456
+ }
457
+ if (event.type === 'text') {
458
+ process.stdout.write(color.dim(event.delta));
459
+ atLineStart = event.delta.endsWith('\n');
460
+ } else if (event.type === 'file-local') {
461
+ nl();
462
+ if (event.outcome === 'skipped') {
463
+ skipped += 1;
464
+ console.log(` ${color.dim('skipped')} ${event.path}`);
465
+ } else {
466
+ // One line per file, not per edit. A rename touches some files
467
+ // three times, and three identical "updated" lines in a row read as
468
+ // a stutter rather than as progress.
469
+ const firstTouch = !writtenPaths.has(event.path);
470
+ writtenPaths.add(event.path);
471
+ if (firstTouch) {
472
+ const tag =
473
+ event.outcome === 'created'
474
+ ? color.green('created')
475
+ : color.yellow('updated');
476
+ console.log(` ${tag} ${event.path}`);
477
+ }
478
+ }
479
+ } else if (event.type === 'done') {
480
+ doneEvent = event;
481
+ } else if (event.type === 'error') {
482
+ sawError = event.message;
483
+ } else if (event.type === 'cancelled') {
484
+ cancelled = true;
485
+ }
486
+ },
487
+ });
488
+ } catch (error) {
489
+ nl();
490
+ fail(friendlyError(error));
491
+ process.exitCode = 1;
492
+ return;
493
+ }
494
+
495
+ nl();
496
+ renderResult(doneEvent, sawError, writtenPaths.size, skipped, rootDir, cancelled);
497
+ }
498
+
499
+ // Snapshot mode (HTTP fallback, `--snapshot`): upload the folder, stream writes.
500
+ async function runSnapshot(session, rootDir, prompt, options) {
501
+ // Snapshot the current files so the agent can read/extend them (empty ⇒ new).
502
+ const { files, truncated, ignoredCount, oversizedCount } =
503
+ await readSnapshot(rootDir);
504
+ line('Context', files.length ? `${files.length} local file(s)` : 'empty folder (greenfield)');
505
+ if (ignoredCount) line('Ignored', `${ignoredCount} path(s) via .gitignore`);
506
+ if (truncated) warn('Workspace is large — only the first slice of files was sent as context.');
507
+ if (oversizedCount) {
508
+ warn(
509
+ `${oversizedCount} file(s) over 100 KB were left out of the context — ` +
510
+ 'the agent cannot see them in snapshot mode.',
511
+ );
512
+ }
513
+
514
+ // Files that already existed before this run — overwriting one needs consent
515
+ // (unless --yes). Files the agent creates fresh this run are written silently.
516
+ const preExisting = new Set(files.map((f) => f.path));
517
+ const decided = new Set();
518
+ let overwriteAll = options.yes === true;
519
+ const nonInteractive = !isInteractive();
520
+
521
+ const body = { prompt, files };
522
+ if (options.provider) body.provider = options.provider;
523
+ if (options.model) body.model = options.model;
524
+ if (options.maxFiles) body.maxFiles = options.maxFiles;
525
+
526
+ console.log('');
527
+ console.log(color.dim('── agent ──────────────────────────────────────────'));
528
+
529
+ const written = [];
530
+ const skipped = [];
531
+ let sawError = null;
532
+ let cancelled = false;
533
+ let doneEvent = null;
534
+ let atLineStart = true;
535
+
536
+ try {
537
+ for await (const event of streamRun(session, body)) {
538
+ if (event.type === 'text') {
539
+ process.stdout.write(color.dim(event.delta));
540
+ atLineStart = event.delta.endsWith('\n');
541
+ } else if (event.type === 'file') {
542
+ if (!atLineStart) process.stdout.write('\n');
543
+ atLineStart = true;
544
+
545
+ // Consent before clobbering a file the user already had.
546
+ const isUserFile =
547
+ preExisting.has(event.path) && !decided.has(event.path);
548
+ if (isUserFile && !overwriteAll) {
549
+ decided.add(event.path);
550
+ if (nonInteractive) {
551
+ skipped.push(event.path);
552
+ warn(`skipped ${event.path} (exists; re-run with --yes to overwrite)`);
553
+ continue;
554
+ }
555
+ const choice = await confirmOverwrite(event.path);
556
+ if (choice === 'no') {
557
+ skipped.push(event.path);
558
+ console.log(` ${color.dim('skipped')} ${event.path}`);
559
+ continue;
560
+ }
561
+ if (choice === 'all') overwriteAll = true;
562
+ }
563
+
564
+ const outcome = await applyFile(rootDir, event.path, event.content);
565
+ if (outcome === null) {
566
+ warn(`refused ${event.path} (outside workspace)`);
567
+ } else {
568
+ written.push({ path: event.path, outcome });
569
+ const tag = outcome === 'created' ? color.green('created') : color.yellow('updated');
570
+ console.log(` ${tag} ${event.path}`);
571
+ }
572
+ } else if (event.type === 'done') {
573
+ doneEvent = event;
574
+ } else if (event.type === 'error') {
575
+ sawError = event.message;
576
+ } else if (event.type === 'cancelled') {
577
+ cancelled = true;
578
+ }
579
+ }
580
+ } catch (error) {
581
+ fail(friendlyError(error));
582
+ process.exitCode = 1;
583
+ return;
584
+ }
585
+
586
+ if (!atLineStart) process.stdout.write('\n');
587
+ renderResult(
588
+ doneEvent,
589
+ sawError,
590
+ written.length,
591
+ skipped.length,
592
+ rootDir,
593
+ cancelled,
594
+ );
595
+ }
596
+
597
+ module.exports = { login, signup, status, doctor, logout, run, shell };