@agentuity/cli 2.0.0-beta.1 → 2.0.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.
Files changed (112) hide show
  1. package/bin/cli.ts +3 -1
  2. package/dist/cmd/build/ci.d.ts +1 -1
  3. package/dist/cmd/build/ci.d.ts.map +1 -1
  4. package/dist/cmd/build/ci.js +70 -63
  5. package/dist/cmd/build/ci.js.map +1 -1
  6. package/dist/cmd/build/index.d.ts.map +1 -1
  7. package/dist/cmd/build/index.js +0 -3
  8. package/dist/cmd/build/index.js.map +1 -1
  9. package/dist/cmd/build/vite/agent-discovery.d.ts.map +1 -1
  10. package/dist/cmd/build/vite/agent-discovery.js +26 -2
  11. package/dist/cmd/build/vite/agent-discovery.js.map +1 -1
  12. package/dist/cmd/build/vite/route-discovery.d.ts +5 -0
  13. package/dist/cmd/build/vite/route-discovery.d.ts.map +1 -1
  14. package/dist/cmd/build/vite/route-discovery.js +13 -11
  15. package/dist/cmd/build/vite/route-discovery.js.map +1 -1
  16. package/dist/cmd/build/vite/static-renderer.d.ts +3 -2
  17. package/dist/cmd/build/vite/static-renderer.d.ts.map +1 -1
  18. package/dist/cmd/build/vite/static-renderer.js +28 -58
  19. package/dist/cmd/build/vite/static-renderer.js.map +1 -1
  20. package/dist/cmd/build/vite/vite-builder.d.ts.map +1 -1
  21. package/dist/cmd/build/vite/vite-builder.js +33 -0
  22. package/dist/cmd/build/vite/vite-builder.js.map +1 -1
  23. package/dist/cmd/cloud/deploy-fork.d.ts +10 -0
  24. package/dist/cmd/cloud/deploy-fork.d.ts.map +1 -1
  25. package/dist/cmd/cloud/deploy-fork.js +71 -32
  26. package/dist/cmd/cloud/deploy-fork.js.map +1 -1
  27. package/dist/cmd/cloud/deploy.d.ts.map +1 -1
  28. package/dist/cmd/cloud/deploy.js +53 -11
  29. package/dist/cmd/cloud/deploy.js.map +1 -1
  30. package/dist/cmd/cloud/sandbox/create.d.ts.map +1 -1
  31. package/dist/cmd/cloud/sandbox/create.js +5 -0
  32. package/dist/cmd/cloud/sandbox/create.js.map +1 -1
  33. package/dist/cmd/cloud/sandbox/exec.d.ts.map +1 -1
  34. package/dist/cmd/cloud/sandbox/exec.js +76 -66
  35. package/dist/cmd/cloud/sandbox/exec.js.map +1 -1
  36. package/dist/cmd/cloud/sandbox/job/index.d.ts.map +1 -1
  37. package/dist/cmd/cloud/sandbox/job/index.js +12 -1
  38. package/dist/cmd/cloud/sandbox/job/index.js.map +1 -1
  39. package/dist/cmd/cloud/sandbox/job/logs.d.ts +3 -0
  40. package/dist/cmd/cloud/sandbox/job/logs.d.ts.map +1 -0
  41. package/dist/cmd/cloud/sandbox/job/logs.js +124 -0
  42. package/dist/cmd/cloud/sandbox/job/logs.js.map +1 -0
  43. package/dist/cmd/cloud/sandbox/run.d.ts.map +1 -1
  44. package/dist/cmd/cloud/sandbox/run.js +14 -2
  45. package/dist/cmd/cloud/sandbox/run.js.map +1 -1
  46. package/dist/cmd/cloud/sandbox/snapshot/build.js +2 -2
  47. package/dist/cmd/cloud/sandbox/snapshot/build.js.map +1 -1
  48. package/dist/cmd/coder/hub-url.d.ts.map +1 -1
  49. package/dist/cmd/coder/hub-url.js +3 -1
  50. package/dist/cmd/coder/hub-url.js.map +1 -1
  51. package/dist/cmd/coder/start.js +6 -6
  52. package/dist/cmd/coder/start.js.map +1 -1
  53. package/dist/cmd/coder/tui-init.d.ts +2 -2
  54. package/dist/cmd/coder/tui-init.js +2 -2
  55. package/dist/cmd/coder/tui-init.js.map +1 -1
  56. package/dist/cmd/project/show.d.ts.map +1 -1
  57. package/dist/cmd/project/show.js +9 -0
  58. package/dist/cmd/project/show.js.map +1 -1
  59. package/dist/cmd/support/report.d.ts.map +1 -1
  60. package/dist/cmd/support/report.js +19 -10
  61. package/dist/cmd/support/report.js.map +1 -1
  62. package/dist/errors.d.ts +24 -10
  63. package/dist/errors.d.ts.map +1 -1
  64. package/dist/errors.js +42 -12
  65. package/dist/errors.js.map +1 -1
  66. package/dist/schema-generator.d.ts.map +1 -1
  67. package/dist/schema-generator.js +2 -12
  68. package/dist/schema-generator.js.map +1 -1
  69. package/dist/steps.d.ts.map +1 -1
  70. package/dist/steps.js +38 -0
  71. package/dist/steps.js.map +1 -1
  72. package/dist/tui.d.ts.map +1 -1
  73. package/dist/tui.js +25 -9
  74. package/dist/tui.js.map +1 -1
  75. package/dist/utils/stream-capture.d.ts +9 -0
  76. package/dist/utils/stream-capture.d.ts.map +1 -0
  77. package/dist/utils/stream-capture.js +34 -0
  78. package/dist/utils/stream-capture.js.map +1 -0
  79. package/dist/utils/stream-url.d.ts +23 -0
  80. package/dist/utils/stream-url.d.ts.map +1 -0
  81. package/dist/utils/stream-url.js +153 -0
  82. package/dist/utils/stream-url.js.map +1 -0
  83. package/dist/utils/zip.d.ts.map +1 -1
  84. package/dist/utils/zip.js +19 -10
  85. package/dist/utils/zip.js.map +1 -1
  86. package/package.json +9 -7
  87. package/src/cmd/build/ci.ts +82 -80
  88. package/src/cmd/build/index.ts +0 -4
  89. package/src/cmd/build/vite/agent-discovery.ts +30 -5
  90. package/src/cmd/build/vite/route-discovery.ts +25 -12
  91. package/src/cmd/build/vite/static-renderer.ts +33 -64
  92. package/src/cmd/build/vite/vite-builder.ts +36 -0
  93. package/src/cmd/cloud/deploy-fork.ts +90 -33
  94. package/src/cmd/cloud/deploy.ts +68 -12
  95. package/src/cmd/cloud/sandbox/create.ts +7 -0
  96. package/src/cmd/cloud/sandbox/exec.ts +102 -90
  97. package/src/cmd/cloud/sandbox/job/index.ts +12 -1
  98. package/src/cmd/cloud/sandbox/job/logs.ts +139 -0
  99. package/src/cmd/cloud/sandbox/run.ts +16 -2
  100. package/src/cmd/cloud/sandbox/snapshot/build.ts +2 -2
  101. package/src/cmd/coder/hub-url.ts +3 -1
  102. package/src/cmd/coder/start.ts +6 -6
  103. package/src/cmd/coder/tui-init.ts +4 -4
  104. package/src/cmd/project/show.ts +9 -0
  105. package/src/cmd/support/report.ts +21 -10
  106. package/src/errors.ts +44 -12
  107. package/src/schema-generator.ts +2 -12
  108. package/src/steps.ts +38 -0
  109. package/src/tui.ts +24 -9
  110. package/src/utils/stream-capture.ts +39 -0
  111. package/src/utils/stream-url.ts +226 -0
  112. package/src/utils/zip.ts +22 -10
package/src/errors.ts CHANGED
@@ -1,22 +1,51 @@
1
1
  import type { Logger } from './types';
2
2
 
3
3
  /**
4
- * Standard exit codes for the CLI
4
+ * Standard exit codes for the CLI.
5
+ *
6
+ * Values start at 10 to avoid collisions with Unix signal numbers (1-9)
7
+ * and shell conventions (e.g. 2 = misuse of builtins, 9 = SIGKILL/OOM).
8
+ * Range 10-125 is safe — below shell-reserved 126-128 and signal-death
9
+ * codes 128+N.
10
+ *
11
+ * 0 and 1 are kept as universal success/failure codes.
5
12
  */
6
13
  export enum ExitCode {
7
14
  SUCCESS = 0,
8
15
  GENERAL_ERROR = 1,
9
- VALIDATION_ERROR = 2,
10
- AUTH_ERROR = 3,
11
- NOT_FOUND = 4,
12
- PERMISSION_ERROR = 5,
13
- NETWORK_ERROR = 6,
14
- FILE_ERROR = 7,
15
- USER_CANCELLED = 8,
16
- BUILD_FAILED = 9,
17
- SECURITY_ERROR = 10,
16
+ VALIDATION_ERROR = 10,
17
+ AUTH_ERROR = 11,
18
+ NOT_FOUND = 12,
19
+ PERMISSION_ERROR = 13,
20
+ NETWORK_ERROR = 14,
21
+ FILE_ERROR = 15,
22
+ USER_CANCELLED = 16,
23
+ BUILD_FAILED = 17,
24
+ SECURITY_ERROR = 18,
25
+ PAYMENT_REQUIRED = 19,
26
+ UPGRADE_REQUIRED = 20,
18
27
  }
19
28
 
29
+ /**
30
+ * Human-readable descriptions for each exit code.
31
+ * This is the single source of truth consumed by the schema generator and AI help.
32
+ */
33
+ export const exitCodeDescriptions: Record<number, string> = {
34
+ [ExitCode.SUCCESS]: 'Success',
35
+ [ExitCode.GENERAL_ERROR]: 'General error',
36
+ [ExitCode.VALIDATION_ERROR]: 'Validation error (invalid arguments or options)',
37
+ [ExitCode.AUTH_ERROR]: 'Authentication error (login required or credentials invalid)',
38
+ [ExitCode.NOT_FOUND]: 'Resource not found (project, file, deployment, etc.)',
39
+ [ExitCode.PERMISSION_ERROR]: 'Permission denied (insufficient access rights)',
40
+ [ExitCode.NETWORK_ERROR]: 'Network error (API unreachable or timeout)',
41
+ [ExitCode.FILE_ERROR]: 'File system error (file read/write failed)',
42
+ [ExitCode.USER_CANCELLED]: 'User cancelled (operation aborted by user)',
43
+ [ExitCode.BUILD_FAILED]: 'Build failed',
44
+ [ExitCode.SECURITY_ERROR]: 'Security error (malware detected)',
45
+ [ExitCode.PAYMENT_REQUIRED]: 'Payment required (plan upgrade needed)',
46
+ [ExitCode.UPGRADE_REQUIRED]: 'Upgrade required (CLI version too old)',
47
+ };
48
+
20
49
  /**
21
50
  * Standard error codes for the CLI
22
51
  */
@@ -152,7 +181,11 @@ export function getExitCode(errorCode: ErrorCode): ExitCode {
152
181
 
153
182
  // Payment required - user needs to upgrade their plan
154
183
  case ErrorCode.PAYMENT_REQUIRED:
155
- return ExitCode.GENERAL_ERROR;
184
+ return ExitCode.PAYMENT_REQUIRED;
185
+
186
+ // Upgrade required - CLI version too old
187
+ case ErrorCode.UPGRADE_REQUIRED:
188
+ return ExitCode.UPGRADE_REQUIRED;
156
189
 
157
190
  // Resource conflicts and other errors
158
191
  case ErrorCode.RESOURCE_ALREADY_EXISTS:
@@ -162,7 +195,6 @@ export function getExitCode(errorCode: ErrorCode): ExitCode {
162
195
  case ErrorCode.RUNTIME_ERROR:
163
196
  case ErrorCode.INTERNAL_ERROR:
164
197
  case ErrorCode.NOT_IMPLEMENTED:
165
- case ErrorCode.UPGRADE_REQUIRED:
166
198
  default:
167
199
  return ExitCode.GENERAL_ERROR;
168
200
  }
@@ -1,5 +1,6 @@
1
1
  import type { Command } from 'commander';
2
2
  import type { CommandDefinition, SubcommandDefinition, CommandSchemas } from './types';
3
+ import { exitCodeDescriptions } from './errors';
3
4
  import { parseArgsSchema, parseOptionsSchema } from './schema-parser';
4
5
  import * as z from 'zod';
5
6
 
@@ -313,18 +314,7 @@ export function generateCLISchema(
313
314
  name: 'agentuity',
314
315
  version,
315
316
  description: 'Agentuity CLI',
316
- exitCodes: {
317
- 0: 'Success',
318
- 1: 'General error',
319
- 2: 'Validation error (invalid arguments or options)',
320
- 3: 'Authentication error (login required or credentials invalid)',
321
- 4: 'Resource not found (project, file, deployment, etc.)',
322
- 5: 'Permission denied (insufficient access rights)',
323
- 6: 'Network error (API unreachable or timeout)',
324
- 7: 'File system error (file read/write failed)',
325
- 8: 'User cancelled (operation aborted by user)',
326
- 9: 'Build failed',
327
- },
317
+ exitCodes: { ...exitCodeDescriptions },
328
318
  globalOptions: [
329
319
  {
330
320
  name: 'config',
package/src/steps.ts CHANGED
@@ -10,6 +10,7 @@ import type { LogLevel } from './types';
10
10
  import { ValidationInputError, ValidationOutputError, type IssuesType } from '@agentuity/server';
11
11
  import { clearLastLines, isTTYLike } from './tui';
12
12
  import { appendLog, isLogCollectionEnabled } from './log-collector';
13
+ import { getOutputOptions, isJSONMode } from './output';
13
14
 
14
15
  /**
15
16
  * Error thrown when step execution is interrupted by a signal (e.g., Ctrl+C).
@@ -700,6 +701,43 @@ async function runStepsPlain(steps: Step[]): Promise<void> {
700
701
  * Run a series of steps with animated progress
701
702
  */
702
703
  export async function runSteps(steps: Step[], logLevel?: LogLevel): Promise<void> {
704
+ const outputOptions = getOutputOptions();
705
+
706
+ // In JSON mode, skip all UI rendering
707
+ if (outputOptions && isJSONMode(outputOptions)) {
708
+ const abortController = new AbortController();
709
+ for (const step of steps) {
710
+ if (abortController.signal.aborted) break;
711
+ if (step) {
712
+ const ctx: StepContext = {
713
+ signal: abortController.signal,
714
+ progress: () => {},
715
+ };
716
+ let outcome: StepOutcome;
717
+ try {
718
+ outcome = await step.run(ctx);
719
+ } catch (err) {
720
+ if (err instanceof Error && err.name === 'AbortError') {
721
+ throw new StepInterruptError();
722
+ }
723
+ outcome = {
724
+ status: 'error',
725
+ message: err instanceof Error ? err.message : String(err),
726
+ cause: err instanceof Error ? err : undefined,
727
+ };
728
+ }
729
+ if (outcome.status === 'error') {
730
+ if (outcome.cause instanceof Error && outcome.cause.name === 'AbortError') {
731
+ throw new StepInterruptError();
732
+ }
733
+ const errorMsg = outcome.message || 'An unknown error occurred';
734
+ throw new Error(errorMsg);
735
+ }
736
+ }
737
+ }
738
+ return;
739
+ }
740
+
703
741
  const useTUI = isTTYLike() && (!logLevel || ['info', 'warn', 'error'].includes(logLevel));
704
742
 
705
743
  if (useTUI) {
package/src/tui.ts CHANGED
@@ -25,6 +25,12 @@ function ensureCursorRestoration(): void {
25
25
  exitHandlerInstalled = true;
26
26
 
27
27
  const restoreCursor = () => {
28
+ // Only write ANSI escape sequences when stderr is a real terminal.
29
+ // Writing to non-TTY streams (pipes, command substitution, etc.)
30
+ // pollutes captured output with invisible control characters.
31
+ if (!process.stderr.isTTY) {
32
+ return;
33
+ }
28
34
  // Skip cursor restoration in CI - terminals don't support these sequences
29
35
  if (process.env.CI) {
30
36
  return;
@@ -293,8 +299,13 @@ export function getSeverityColor(severity: string): (text: string) => string {
293
299
  export function success(message: string): void {
294
300
  const color = getColor('success');
295
301
  const reset = getColor('reset');
296
- // Clear line first to ensure no leftover content from previous output
297
- process.stderr.write(`\r\x1b[2K${color}${ICONS.success} ${message}${reset}\n`);
302
+ if (process.stderr.isTTY) {
303
+ // Clear line first to ensure no leftover content from previous output
304
+ process.stderr.write(`\r\x1b[2K${color}${ICONS.success} ${message}${reset}\n`);
305
+ } else {
306
+ // No ANSI control sequences for non-TTY streams (pipes, command substitution)
307
+ process.stderr.write(`${ICONS.success} ${message}\n`);
308
+ }
298
309
  }
299
310
 
300
311
  /**
@@ -1242,18 +1253,23 @@ export async function spinner<T>(
1242
1253
  const { getOutputOptions, shouldDisableProgress } = await import('./output');
1243
1254
  const outputOptions = getOutputOptions();
1244
1255
  const noProgress = outputOptions ? shouldDisableProgress(outputOptions) : false;
1256
+ const isJsonMode = outputOptions?.json === true;
1245
1257
 
1246
- // If no interactive TTY-like environment or progress disabled, just execute
1247
- // the callback without animation
1248
- if (!isTTYLike() || noProgress) {
1258
+ // If stderr is not a real terminal or progress disabled, just execute
1259
+ // the callback without animation. We check stderr specifically because
1260
+ // the spinner writes ANSI sequences to stderr — isTTYLike() may return
1261
+ // true when stdout is a TTY but stderr is piped (e.g. 2>&1 in $()).
1262
+ if (!process.stderr.isTTY || noProgress) {
1249
1263
  try {
1250
1264
  const result =
1251
1265
  options.type === 'progress'
1252
1266
  ? await options.callback(() => {})
1253
1267
  : options.type === 'logger'
1254
1268
  ? await options.callback((logMessage: string) => {
1255
- // In non-TTY mode, just write logs directly to stdout
1256
- process.stdout.write(logMessage + '\n');
1269
+ // In JSON mode, don't write logs to stdout
1270
+ if (!isJsonMode) {
1271
+ process.stdout.write(logMessage + '\n');
1272
+ }
1257
1273
  })
1258
1274
  : options.type === 'countdown'
1259
1275
  ? await options.callback()
@@ -1263,7 +1279,6 @@ export async function spinner<T>(
1263
1279
 
1264
1280
  // If clearOnSuccess is true, don't show success message
1265
1281
  // Also skip success message in JSON mode
1266
- const isJsonMode = outputOptions?.json === true;
1267
1282
  if (!options.clearOnSuccess && !isJsonMode) {
1268
1283
  const successColor = getColor('success');
1269
1284
  console.error(`${successColor}${ICONS.success} ${message}${reset}`);
@@ -1273,7 +1288,7 @@ export async function spinner<T>(
1273
1288
  } catch (err) {
1274
1289
  const clearOnError =
1275
1290
  (options.type === 'progress' || options.type === 'simple') && options.clearOnError;
1276
- if (!clearOnError) {
1291
+ if (!clearOnError && !isJsonMode) {
1277
1292
  const errorColor = getColor('error');
1278
1293
  console.error(`${errorColor}${ICONS.error} ${message}${reset}`);
1279
1294
  }
@@ -0,0 +1,39 @@
1
+ import { createWriteStream } from 'node:fs';
2
+
3
+ /**
4
+ * Stream a ReadableStream of raw bytes to a file on disk.
5
+ *
6
+ * This mirrors the pattern used by the deploy fork wrapper to capture child
7
+ * process stdout/stderr without accumulating the output in memory. Returns
8
+ * the total number of bytes written.
9
+ */
10
+ export async function captureStreamToFile(
11
+ stream: ReadableStream<Uint8Array>,
12
+ filePath: string
13
+ ): Promise<number> {
14
+ const writer = createWriteStream(filePath);
15
+ const reader = stream.getReader();
16
+ let totalBytes = 0;
17
+
18
+ try {
19
+ while (true) {
20
+ const { done, value } = await reader.read();
21
+ if (done) break;
22
+
23
+ const ok = writer.write(value);
24
+ totalBytes += value.byteLength;
25
+
26
+ // Respect backpressure: wait for drain when the internal buffer is full
27
+ if (!ok) {
28
+ await new Promise<void>((resolve) => writer.once('drain', resolve));
29
+ }
30
+ }
31
+ } finally {
32
+ await new Promise<void>((resolve, reject) => {
33
+ writer.once('error', reject);
34
+ writer.end(resolve);
35
+ });
36
+ }
37
+
38
+ return totalBytes;
39
+ }
@@ -0,0 +1,226 @@
1
+ import { writeAndDrain } from '@agentuity/server';
2
+ import type { Logger } from '@agentuity/core';
3
+ import * as tui from '../tui';
4
+
5
+ export interface StreamUrlOptions {
6
+ signal?: AbortSignal;
7
+ follow?: boolean;
8
+ timestamps?: boolean;
9
+ grep?: string;
10
+ tail?: number;
11
+ json?: boolean;
12
+ label?: string;
13
+ raw?: boolean;
14
+ v2?: boolean;
15
+ }
16
+
17
+ export interface StreamUrlResult {
18
+ bytesRead: number;
19
+ chunks: number;
20
+ }
21
+
22
+ export class StreamFetchError extends Error {
23
+ constructor(
24
+ public status: number,
25
+ public statusText: string,
26
+ message: string
27
+ ) {
28
+ super(message);
29
+ this.name = 'StreamFetchError';
30
+ }
31
+ }
32
+
33
+ function escapeRegExp(str: string): string {
34
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
35
+ }
36
+
37
+ export async function streamUrlToWritable(
38
+ url: string,
39
+ writable: NodeJS.WritableStream,
40
+ logger: Logger,
41
+ options: StreamUrlOptions = {}
42
+ ): Promise<StreamUrlResult> {
43
+ const {
44
+ signal,
45
+ follow,
46
+ timestamps,
47
+ grep,
48
+ tail,
49
+ json,
50
+ label = 'stream',
51
+ raw = false,
52
+ v2 = false,
53
+ } = options;
54
+ const streamStart = Date.now();
55
+ let bytesRead = 0;
56
+ let chunks = 0;
57
+
58
+ try {
59
+ const fetchUrl = new URL(url);
60
+
61
+ if (follow || v2) {
62
+ fetchUrl.searchParams.set('v', '2');
63
+ }
64
+ if (follow) {
65
+ fetchUrl.searchParams.set('follow', 'true');
66
+ }
67
+
68
+ const redactedUrl =
69
+ fetchUrl.origin + fetchUrl.pathname + (fetchUrl.search ? '?REDACTED' : '');
70
+ logger.debug('[%s] fetching: %s', label, redactedUrl);
71
+ const response = await fetch(fetchUrl.href, { signal });
72
+ logger.debug(
73
+ '[%s] response status=%d in %dms',
74
+ label,
75
+ response.status,
76
+ Date.now() - streamStart
77
+ );
78
+
79
+ if (!response.ok || !response.body) {
80
+ logger.debug('[%s] not ok or no body', label);
81
+ if (!json) {
82
+ tui.error(`Failed to fetch stream: ${response.status} ${response.statusText}`);
83
+ }
84
+ throw new StreamFetchError(
85
+ response.status,
86
+ response.statusText,
87
+ `Failed to fetch stream: ${response.status} ${response.statusText}`
88
+ );
89
+ }
90
+
91
+ const reader = response.body.getReader();
92
+
93
+ if (raw) {
94
+ while (true) {
95
+ const { done, value } = await reader.read();
96
+ if (done) {
97
+ logger.debug(
98
+ '[%s] EOF after %dms (%d chunks, %d bytes)',
99
+ label,
100
+ Date.now() - streamStart,
101
+ chunks,
102
+ bytesRead
103
+ );
104
+ break;
105
+ }
106
+
107
+ if (value) {
108
+ chunks++;
109
+ bytesRead += value.length;
110
+ if (chunks <= 3 || chunks % 100 === 0) {
111
+ logger.debug(
112
+ '[%s] chunk #%d: %d bytes (total: %d bytes, +%dms)',
113
+ label,
114
+ chunks,
115
+ value.length,
116
+ bytesRead,
117
+ Date.now() - streamStart
118
+ );
119
+ }
120
+ await writeAndDrain(writable, value);
121
+ }
122
+ }
123
+ } else {
124
+ const decoder = new TextDecoder();
125
+ let leftover = '';
126
+ const grepPattern = grep ? new RegExp(escapeRegExp(grep), 'i') : null;
127
+ const needsFiltering = tail !== undefined || grepPattern !== null;
128
+ const tailBuffer: string[] = [];
129
+ const maxTail = tail ?? Infinity;
130
+ const liveOutput = follow && needsFiltering;
131
+
132
+ const outputLine = async (line: string) => {
133
+ if (json) {
134
+ const obj = {
135
+ timestamp: new Date().toISOString(),
136
+ stream: label,
137
+ message: line,
138
+ };
139
+ await writeAndDrain(writable, Buffer.from(JSON.stringify(obj) + '\n'));
140
+ } else {
141
+ const formatted = timestamps ? formatLineWithTimestamp(line) : line;
142
+ await writeAndDrain(writable, Buffer.from(formatted + '\n'));
143
+ }
144
+ };
145
+
146
+ const processFilteredLine = async (line: string) => {
147
+ if (grepPattern && !grepPattern.test(line)) {
148
+ return;
149
+ }
150
+ if (tail !== undefined) {
151
+ tailBuffer.push(line);
152
+ if (tailBuffer.length > maxTail) {
153
+ tailBuffer.shift();
154
+ }
155
+ if (liveOutput) {
156
+ await outputLine(line);
157
+ }
158
+ } else {
159
+ await outputLine(line);
160
+ }
161
+ };
162
+
163
+ while (true) {
164
+ const { done, value } = await reader.read();
165
+ if (done) {
166
+ if (leftover) {
167
+ if (needsFiltering) {
168
+ await processFilteredLine(leftover);
169
+ } else {
170
+ await outputLine(leftover);
171
+ }
172
+ }
173
+ logger.debug(
174
+ '[%s] EOF after %dms (%d chunks, %d bytes)',
175
+ label,
176
+ Date.now() - streamStart,
177
+ chunks,
178
+ bytesRead
179
+ );
180
+ break;
181
+ }
182
+
183
+ if (value) {
184
+ chunks++;
185
+ bytesRead += value.length;
186
+ const text = leftover + decoder.decode(value, { stream: true });
187
+ const lines = text.split('\n');
188
+ leftover = lines.pop() ?? '';
189
+
190
+ for (const line of lines) {
191
+ if (needsFiltering) {
192
+ await processFilteredLine(line);
193
+ } else {
194
+ await outputLine(line);
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ if (!liveOutput && needsFiltering && tailBuffer.length > 0) {
201
+ for (const line of tailBuffer) {
202
+ await outputLine(line);
203
+ }
204
+ }
205
+ }
206
+
207
+ return { bytesRead, chunks };
208
+ } catch (err) {
209
+ if (err instanceof Error && err.name === 'AbortError') {
210
+ logger.debug('[%s] aborted after %dms', label, Date.now() - streamStart);
211
+ return { bytesRead, chunks };
212
+ }
213
+ logger.debug('[%s] error after %dms: %s', label, Date.now() - streamStart, err);
214
+ throw err;
215
+ }
216
+ }
217
+
218
+ function formatLineWithTimestamp(line: string): string {
219
+ const timestamp = new Date().toLocaleTimeString('en-US', {
220
+ hour12: false,
221
+ hour: '2-digit',
222
+ minute: '2-digit',
223
+ second: '2-digit',
224
+ });
225
+ return `${tui.muted(timestamp)} ${line}`;
226
+ }
package/src/utils/zip.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { readFileSync, lstatSync } from 'node:fs';
2
- import { relative } from 'node:path';
1
+ import { createWriteStream, lstatSync } from 'node:fs';
2
+ import { mkdir } from 'node:fs/promises';
3
+ import { dirname, relative } from 'node:path';
3
4
  import { Glob } from 'bun';
4
- import AdmZip from 'adm-zip';
5
+ import archiver from 'archiver';
5
6
  import { toForwardSlash } from './normalize-path';
6
7
 
7
8
  interface Options {
@@ -10,7 +11,20 @@ interface Options {
10
11
  }
11
12
 
12
13
  export async function zipDir(dir: string, outdir: string, options?: Options) {
13
- const zip = new AdmZip();
14
+ await mkdir(dirname(outdir), { recursive: true });
15
+ const output = createWriteStream(outdir);
16
+ const zip = archiver('zip', {
17
+ zlib: { level: 9 },
18
+ });
19
+
20
+ const writeDone = new Promise<void>((resolve, reject) => {
21
+ output.on('close', resolve);
22
+ output.on('error', reject);
23
+ zip.on('error', reject);
24
+ });
25
+
26
+ zip.pipe(output);
27
+
14
28
  const files = await Array.fromAsync(
15
29
  new Glob('**/*').scan({ cwd: dir, absolute: true, dot: true, followSymlinks: false })
16
30
  );
@@ -31,11 +45,8 @@ export async function zipDir(dir: string, outdir: string, options?: Options) {
31
45
  // across machines and would cause EISDIR errors on extraction.
32
46
  const stat = lstatSync(file);
33
47
  if (!stat.isSymbolicLink() && !stat.isDirectory()) {
34
- // Use addFile with explicit Unix permissions (0o644) instead of addLocalFile.
35
- // On Windows, addLocalFile relies on OS file stats which may produce zip entries
36
- // with incorrect Unix permission bits, causing EACCES errors when extracted on Linux.
37
- const data = readFileSync(file);
38
- zip.addFile(rel, data, '', 0o644);
48
+ // Set explicit Unix permissions (0o644) for portability across OSes.
49
+ zip.file(file, { name: rel, mode: 0o644 });
39
50
  }
40
51
  } catch (err) {
41
52
  throw new Error(`Failed to add file to zip: ${rel} (${file})`, { cause: err });
@@ -48,7 +59,8 @@ export async function zipDir(dir: string, outdir: string, options?: Options) {
48
59
  await Bun.sleep(10); // give some time for the progress bar to render
49
60
  }
50
61
  }
51
- await zip.writeZip(outdir);
62
+ await zip.finalize();
63
+ await writeDone;
52
64
  if (options?.progress) {
53
65
  options.progress(100);
54
66
  await Bun.sleep(100); // give some time for the progress bar to render