@durable-streams/cli 0.1.7 → 0.1.9

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/dist/index.cjs CHANGED
@@ -23,6 +23,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  }) : target, mod));
24
24
 
25
25
  //#endregion
26
+ const node_fs = __toESM(require("node:fs"));
27
+ const node_http = __toESM(require("node:http"));
26
28
  const node_path = __toESM(require("node:path"));
27
29
  const node_process = __toESM(require("node:process"));
28
30
  const node_url = __toESM(require("node:url"));
@@ -89,43 +91,146 @@ function parseWriteArgs(args) {
89
91
  };
90
92
  }
91
93
 
94
+ //#endregion
95
+ //#region src/validation.ts
96
+ /**
97
+ * Validate a URL string.
98
+ * Must be a valid HTTP or HTTPS URL.
99
+ */
100
+ function validateUrl(url) {
101
+ if (!url || !url.trim()) return {
102
+ valid: false,
103
+ error: `URL cannot be empty`
104
+ };
105
+ let parsed;
106
+ try {
107
+ parsed = new URL(url);
108
+ } catch {
109
+ return {
110
+ valid: false,
111
+ error: `Invalid URL format: "${url}"\n Expected format: http://host:port or https://host:port`
112
+ };
113
+ }
114
+ if (parsed.protocol !== `http:` && parsed.protocol !== `https:`) return {
115
+ valid: false,
116
+ error: `Invalid URL protocol: "${parsed.protocol}"\n Only http:// and https:// are supported`
117
+ };
118
+ if (!parsed.hostname) return {
119
+ valid: false,
120
+ error: `URL must include a hostname: "${url}"`
121
+ };
122
+ return { valid: true };
123
+ }
124
+ /**
125
+ * Normalize a base URL by removing trailing slashes.
126
+ * This prevents double-slashes when appending paths (e.g., "http://host/" + "/v1/...").
127
+ */
128
+ function normalizeBaseUrl(url) {
129
+ return url.replace(/\/+$/, ``);
130
+ }
131
+ /**
132
+ * Validate an authorization header value.
133
+ * Returns valid=true for any non-empty value, but includes a warning
134
+ * if the value doesn't match common auth schemes (Bearer, Basic, ApiKey, Token).
135
+ */
136
+ function validateAuth(auth) {
137
+ if (!auth || !auth.trim()) return {
138
+ valid: false,
139
+ error: `Authorization value cannot be empty`
140
+ };
141
+ const trimmed = auth.trim();
142
+ const lowerTrimmed = trimmed.toLowerCase();
143
+ const hasScheme = [
144
+ `bearer `,
145
+ `basic `,
146
+ `apikey `,
147
+ `token `
148
+ ].some((scheme) => lowerTrimmed.startsWith(scheme));
149
+ if (!hasScheme && !trimmed.includes(` `)) return {
150
+ valid: true,
151
+ warning: `Warning: Authorization value doesn't match common formats.\n Expected: "Bearer <token>", "Basic <credentials>", or "ApiKey <key>"`
152
+ };
153
+ return { valid: true };
154
+ }
155
+ /**
156
+ * Validate a stream ID.
157
+ * Must be 1-256 characters containing only: letters, numbers, underscores,
158
+ * hyphens, dots, and colons (URL-safe characters).
159
+ */
160
+ function validateStreamId(streamId) {
161
+ if (!streamId || !streamId.trim()) return {
162
+ valid: false,
163
+ error: `Stream ID cannot be empty`
164
+ };
165
+ const validPattern = /^[a-zA-Z0-9_\-.:]+$/;
166
+ if (!validPattern.test(streamId)) return {
167
+ valid: false,
168
+ error: `Invalid stream ID: "${streamId}"\n Stream IDs can only contain letters, numbers, underscores, hyphens, dots, and colons`
169
+ };
170
+ if (streamId.length > 256) return {
171
+ valid: false,
172
+ error: `Stream ID too long (${streamId.length} chars)\n Maximum length is 256 characters`
173
+ };
174
+ return { valid: true };
175
+ }
176
+
92
177
  //#endregion
93
178
  //#region src/index.ts
94
179
  const STREAM_URL = process.env.STREAM_URL || `http://localhost:4437`;
95
180
  const STREAM_AUTH = process.env.STREAM_AUTH;
96
181
  /**
97
- * Parse global options (like --auth) from args.
98
- * Falls back to STREAM_AUTH env var if --auth flag not provided.
99
- * Returns the parsed options and remaining args.
182
+ * Parse global options (--url, --auth) from args.
183
+ * Falls back to STREAM_URL/STREAM_AUTH env vars when flags not provided.
184
+ * Returns the parsed options, remaining args, and any warnings.
100
185
  */
101
186
  function parseGlobalOptions(args) {
102
187
  const options = {};
103
188
  const remainingArgs = [];
189
+ const warnings = [];
104
190
  for (let i = 0; i < args.length; i++) {
105
191
  const arg = args[i];
106
- if (arg === `--auth`) {
192
+ if (arg === `--url`) {
193
+ const value = args[i + 1];
194
+ if (!value || value.startsWith(`--`)) throw new Error(`--url requires a value\n Example: --url "http://localhost:4437"`);
195
+ const urlValidation = validateUrl(value);
196
+ if (!urlValidation.valid) throw new Error(urlValidation.error);
197
+ options.url = normalizeBaseUrl(value);
198
+ i++;
199
+ } else if (arg === `--auth`) {
107
200
  const value = args[i + 1];
108
- if (!value || value.startsWith(`--`)) throw new Error(`--auth requires a value (e.g., --auth "Bearer token")`);
109
- if (!value.trim()) throw new Error(`--auth value cannot be empty or whitespace`);
201
+ if (!value || value.startsWith(`--`)) throw new Error(`--auth requires a value\n Example: --auth "Bearer my-token"`);
202
+ const authValidation = validateAuth(value);
203
+ if (!authValidation.valid) throw new Error(authValidation.error);
204
+ if (authValidation.warning) warnings.push(authValidation.warning);
110
205
  options.auth = value;
111
206
  i++;
112
207
  } else remainingArgs.push(arg);
113
208
  }
209
+ if (!options.url) {
210
+ const urlValidation = validateUrl(STREAM_URL);
211
+ if (!urlValidation.valid) throw new Error(`Invalid STREAM_URL environment variable: ${urlValidation.error}`);
212
+ options.url = normalizeBaseUrl(STREAM_URL);
213
+ }
114
214
  if (!options.auth && STREAM_AUTH) {
115
- if (!STREAM_AUTH.trim()) throw new Error(`STREAM_AUTH environment variable cannot be empty or whitespace`);
215
+ const authValidation = validateAuth(STREAM_AUTH);
216
+ if (!authValidation.valid) throw new Error(`Invalid STREAM_AUTH environment variable: ${authValidation.error}`);
217
+ if (authValidation.warning) warnings.push(authValidation.warning);
116
218
  options.auth = STREAM_AUTH;
117
219
  }
118
220
  return {
119
221
  options,
120
- remainingArgs
222
+ remainingArgs,
223
+ warnings
121
224
  };
122
225
  }
226
+ function getErrorMessage(error) {
227
+ return error instanceof Error ? error.message : String(error);
228
+ }
123
229
  function buildHeaders(options) {
124
- if (options.auth) return { Authorization: options.auth };
125
- return {};
230
+ return options.auth ? { Authorization: options.auth } : {};
126
231
  }
127
- function printUsage() {
128
- console.error(`
232
+ function getUsageText() {
233
+ return `
129
234
  Usage:
130
235
  durable-stream create <stream_id> Create a new stream
131
236
  durable-stream write <stream_id> <content> Write content to a stream
@@ -134,7 +239,9 @@ Usage:
134
239
  durable-stream delete <stream_id> Delete a stream
135
240
 
136
241
  Global Options:
242
+ --url <url> Stream server URL (overrides STREAM_URL env var)
137
243
  --auth <value> Authorization header value (e.g., "Bearer my-token")
244
+ --help, -h Show this help message
138
245
 
139
246
  Write Options:
140
247
  --content-type <type> Content-Type for the message (default: application/octet-stream)
@@ -144,209 +251,283 @@ Write Options:
144
251
  Environment Variables:
145
252
  STREAM_URL Base URL of the stream server (default: http://localhost:4437)
146
253
  STREAM_AUTH Authorization header value (overridden by --auth flag)
147
- `);
254
+ `;
255
+ }
256
+ function printUsage({ to = `stderr` } = {}) {
257
+ const out = to === `stderr` ? node_process.stderr : node_process.stdout;
258
+ out.write(getUsageText());
148
259
  }
149
- async function createStream(streamId, headers) {
150
- const url = `${STREAM_URL}/v1/stream/${streamId}`;
260
+ async function createStream(baseUrl, streamId, headers) {
261
+ const url = `${baseUrl}/v1/stream/${streamId}`;
151
262
  try {
152
263
  await __durable_streams_client.DurableStream.create({
153
264
  url,
154
265
  headers,
155
266
  contentType: `application/octet-stream`
156
267
  });
157
- console.log(`Created stream: ${streamId}`);
268
+ console.log(`Stream created successfully: "${streamId}"`);
269
+ console.log(` URL: ${url}`);
158
270
  } catch (error) {
159
- const message = error instanceof Error ? error.message : String(error);
160
- node_process.stderr.write(`Error creating stream: ${message}\n`);
271
+ node_process.stderr.write(`Failed to create stream "${streamId}"\n`);
272
+ node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
161
273
  process.exit(1);
162
274
  }
163
275
  }
164
276
  /**
165
- * Append JSON data to a stream with one-level array flattening.
277
+ * Format error messages from the server/client for better readability.
166
278
  */
167
- async function appendJson(stream, parsed) {
168
- let count = 0;
169
- for (const item of flattenJsonForAppend(parsed)) {
170
- await stream.append(item);
171
- count++;
279
+ function formatErrorMessage(message) {
280
+ const httpMatch = message.match(/HTTP Error (\d+)/);
281
+ const statusCode = httpMatch?.[1];
282
+ if (statusCode) {
283
+ const status = parseInt(statusCode, 10);
284
+ const statusText = getHttpStatusText(status);
285
+ return message.replace(/HTTP Error \d+/, `${statusText} (${status})`);
172
286
  }
173
- return count;
287
+ return message;
288
+ }
289
+ function getHttpStatusText(status) {
290
+ return node_http.STATUS_CODES[status] ?? `HTTP Error`;
291
+ }
292
+ /**
293
+ * Append JSON data to a stream using batch semantics.
294
+ * Arrays are flattened one level (each element becomes a separate message).
295
+ * Non-array values are written as a single message.
296
+ * Returns the number of messages written.
297
+ */
298
+ async function appendJsonBatch(stream, parsed) {
299
+ const items = [...flattenJsonForAppend(parsed)];
300
+ for (const item of items) await stream.append(JSON.stringify(item));
301
+ return items.length;
302
+ }
303
+ /**
304
+ * Read all data from stdin into a Buffer.
305
+ */
306
+ async function readStdin() {
307
+ const chunks = [];
308
+ node_process.stdin.on(`data`, (chunk) => {
309
+ chunks.push(chunk);
310
+ });
311
+ await new Promise((resolve, reject) => {
312
+ node_process.stdin.on(`end`, resolve);
313
+ node_process.stdin.on(`error`, (err) => {
314
+ reject(new Error(`Failed to read from stdin: ${err.message}`));
315
+ });
316
+ });
317
+ return Buffer.concat(chunks);
318
+ }
319
+ /**
320
+ * Process escape sequences in content string.
321
+ */
322
+ function processEscapeSequences(content) {
323
+ return content.replace(/\\n/g, `\n`).replace(/\\t/g, `\t`).replace(/\\r/g, `\r`).replace(/\\\\/g, `\\`);
174
324
  }
175
- async function writeStream(streamId, contentType, batchJson, headers, content) {
176
- const url = `${STREAM_URL}/v1/stream/${streamId}`;
325
+ async function writeStream(baseUrl, streamId, contentType, batchJson, headers, content) {
326
+ const url = `${baseUrl}/v1/stream/${streamId}`;
177
327
  const isJson = isJsonContentType(contentType);
328
+ let data;
329
+ let source;
330
+ if (content) {
331
+ data = processEscapeSequences(content);
332
+ source = `argument`;
333
+ } else {
334
+ data = await readStdin();
335
+ source = `stdin`;
336
+ if (data.length === 0) {
337
+ node_process.stderr.write(`No data received from stdin\n`);
338
+ process.exit(1);
339
+ }
340
+ }
178
341
  try {
179
342
  const stream = new __durable_streams_client.DurableStream({
180
343
  url,
181
344
  headers,
182
345
  contentType
183
346
  });
184
- if (content) {
185
- const processedContent = content.replace(/\\n/g, `\n`).replace(/\\t/g, `\t`).replace(/\\r/g, `\r`).replace(/\\\\/g, `\\`);
186
- if (isJson) {
187
- const parsed = JSON.parse(processedContent);
188
- if (batchJson) {
189
- const count = await appendJson(stream, parsed);
190
- console.log(`Wrote ${count} message(s) to ${streamId}`);
347
+ if (isJson) {
348
+ const jsonString = typeof data === `string` ? data : data.toString(`utf8`);
349
+ let parsed;
350
+ try {
351
+ parsed = JSON.parse(jsonString);
352
+ } catch (parseError) {
353
+ const parseMessage = parseError instanceof SyntaxError ? parseError.message : `Unknown parsing error`;
354
+ if (source === `argument`) {
355
+ const preview = jsonString.slice(0, 100);
356
+ const ellipsis = jsonString.length > 100 ? `...` : ``;
357
+ node_process.stderr.write(`Failed to parse JSON content\n`);
358
+ node_process.stderr.write(` ${parseMessage}\n`);
359
+ node_process.stderr.write(` Input: ${preview}${ellipsis}\n`);
191
360
  } else {
192
- await stream.append(parsed);
193
- console.log(`Wrote 1 message to ${streamId}`);
361
+ node_process.stderr.write(`Failed to parse JSON from stdin\n`);
362
+ node_process.stderr.write(` ${parseMessage}\n`);
194
363
  }
195
- } else {
196
- await stream.append(processedContent);
197
- console.log(`Wrote ${processedContent.length} bytes to ${streamId}`);
364
+ process.exit(1);
198
365
  }
199
- } else {
200
- const chunks = [];
201
- node_process.stdin.on(`data`, (chunk) => {
202
- chunks.push(chunk);
203
- });
204
- await new Promise((resolve, reject) => {
205
- node_process.stdin.on(`end`, resolve);
206
- node_process.stdin.on(`error`, reject);
207
- });
208
- const data = Buffer.concat(chunks);
209
- if (isJson) {
210
- const parsed = JSON.parse(data.toString(`utf8`));
211
- if (batchJson) {
212
- const count = await appendJson(stream, parsed);
213
- console.log(`Wrote ${count} message(s) to ${streamId}`);
214
- } else {
215
- await stream.append(parsed);
216
- console.log(`Wrote 1 message to ${streamId}`);
217
- }
366
+ if (batchJson) {
367
+ const count = await appendJsonBatch(stream, parsed);
368
+ console.log(`Wrote ${count} message${count !== 1 ? `s` : ``} to stream "${streamId}"`);
218
369
  } else {
219
- await stream.append(data);
220
- console.log(`Wrote ${data.length} bytes to ${streamId}`);
370
+ await stream.append(JSON.stringify(parsed));
371
+ console.log(`Wrote 1 JSON message to stream "${streamId}"`);
221
372
  }
373
+ } else {
374
+ await stream.append(data);
375
+ const byteCount = typeof data === `string` ? Buffer.byteLength(data, `utf8`) : data.length;
376
+ console.log(`Wrote ${formatBytes(byteCount)} to stream "${streamId}"`);
222
377
  }
223
378
  } catch (error) {
224
- const message = error instanceof Error ? error.message : String(error);
225
- node_process.stderr.write(`Error writing to stream: ${message}\n`);
379
+ node_process.stderr.write(`Failed to write to stream "${streamId}"\n`);
380
+ node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
226
381
  process.exit(1);
227
382
  }
228
383
  }
229
- async function readStream(streamId, headers) {
230
- const url = `${STREAM_URL}/v1/stream/${streamId}`;
384
+ function formatBytes(bytes) {
385
+ if (bytes === 1) return `1 byte`;
386
+ if (bytes < 1024) return `${bytes} bytes`;
387
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
388
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
389
+ }
390
+ async function readStream(baseUrl, streamId, headers) {
391
+ const url = `${baseUrl}/v1/stream/${streamId}`;
231
392
  try {
232
393
  const stream = new __durable_streams_client.DurableStream({
233
394
  url,
234
395
  headers
235
396
  });
236
- const res = await stream.stream({ live: `auto` });
397
+ const res = await stream.stream({ live: true });
237
398
  for await (const chunk of res.bodyStream()) if (chunk.length > 0) node_process.stdout.write(chunk);
238
399
  } catch (error) {
239
- const message = error instanceof Error ? error.message : String(error);
240
- node_process.stderr.write(`Error reading stream: ${message}\n`);
400
+ node_process.stderr.write(`Failed to read stream "${streamId}"\n`);
401
+ node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
241
402
  process.exit(1);
242
403
  }
243
404
  }
244
- async function deleteStream(streamId, headers) {
245
- const url = `${STREAM_URL}/v1/stream/${streamId}`;
405
+ async function deleteStream(baseUrl, streamId, headers) {
406
+ const url = `${baseUrl}/v1/stream/${streamId}`;
246
407
  try {
247
408
  const stream = new __durable_streams_client.DurableStream({
248
409
  url,
249
410
  headers
250
411
  });
251
412
  await stream.delete();
252
- console.log(`Deleted stream: ${streamId}`);
413
+ console.log(`Stream deleted successfully: "${streamId}"`);
253
414
  } catch (error) {
254
- const message = error instanceof Error ? error.message : String(error);
255
- node_process.stderr.write(`Error deleting stream: ${message}\n`);
415
+ node_process.stderr.write(`Failed to delete stream "${streamId}"\n`);
416
+ node_process.stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
256
417
  process.exit(1);
257
418
  }
258
419
  }
259
420
  async function main() {
421
+ const rawArgs = process.argv.slice(2);
422
+ if (rawArgs.includes(`--help`) || rawArgs.includes(`-h`)) {
423
+ printUsage({ to: `stdout` });
424
+ process.exit(0);
425
+ }
260
426
  let options;
261
427
  let args;
428
+ let warnings;
262
429
  try {
263
- const parsed = parseGlobalOptions(process.argv.slice(2));
430
+ const parsed = parseGlobalOptions(rawArgs);
264
431
  options = parsed.options;
265
432
  args = parsed.remainingArgs;
433
+ warnings = parsed.warnings;
266
434
  } catch (error) {
267
- const message = error instanceof Error ? error.message : String(error);
268
- node_process.stderr.write(`Error: ${message}\n`);
435
+ node_process.stderr.write(`Error: ${getErrorMessage(error)}\n`);
269
436
  process.exit(1);
270
437
  }
438
+ for (const warning of warnings) node_process.stderr.write(`${warning}\n`);
271
439
  const headers = buildHeaders(options);
272
440
  if (args.length < 1) {
441
+ node_process.stderr.write(`Error: No command specified\n`);
273
442
  printUsage();
274
443
  process.exit(1);
275
444
  }
276
445
  const command = args[0];
446
+ function getStreamId() {
447
+ if (args.length < 2) {
448
+ node_process.stderr.write(`Error: Missing stream_id\n`);
449
+ node_process.stderr.write(` Usage: durable-stream ${command} <stream_id>\n`);
450
+ process.exit(1);
451
+ }
452
+ const streamId = args[1];
453
+ const validation = validateStreamId(streamId);
454
+ if (!validation.valid) {
455
+ node_process.stderr.write(`Error: ${validation.error}\n`);
456
+ process.exit(1);
457
+ }
458
+ return streamId;
459
+ }
277
460
  switch (command) {
278
461
  case `create`: {
279
- if (args.length < 2) {
280
- node_process.stderr.write(`Error: stream_id required\n`);
281
- printUsage();
282
- process.exit(1);
283
- }
284
- await createStream(args[1], headers);
462
+ const streamId = getStreamId();
463
+ await createStream(options.url, streamId, headers);
285
464
  break;
286
465
  }
287
466
  case `write`: {
288
- if (args.length < 2) {
289
- node_process.stderr.write(`Error: stream_id required\n`);
290
- printUsage();
291
- process.exit(1);
292
- }
293
- const streamId = args[1];
467
+ const streamId = getStreamId();
294
468
  let parsed;
295
469
  try {
296
470
  parsed = parseWriteArgs(args.slice(2));
297
471
  } catch (error) {
298
- const message = error instanceof Error ? error.message : String(error);
299
- node_process.stderr.write(`Error: ${message}\n`);
472
+ node_process.stderr.write(`Error: ${getErrorMessage(error)}\n`);
300
473
  process.exit(1);
301
474
  }
302
- if (!node_process.stdin.isTTY) await writeStream(streamId, parsed.contentType, parsed.batchJson, headers);
303
- else if (parsed.content) await writeStream(streamId, parsed.contentType, parsed.batchJson, headers, parsed.content);
475
+ const hasContent = parsed.content || !node_process.stdin.isTTY;
476
+ if (hasContent) await writeStream(options.url, streamId, parsed.contentType, parsed.batchJson, headers, parsed.content || void 0);
304
477
  else {
305
- node_process.stderr.write(`Error: content required (provide as argument or pipe to stdin)\n`);
306
- printUsage();
478
+ node_process.stderr.write(`Error: No content provided\n`);
479
+ node_process.stderr.write(` Provide content as an argument or pipe from stdin:\n`);
480
+ node_process.stderr.write(` durable-stream write ${streamId} "your content here"\n`);
481
+ node_process.stderr.write(` echo "content" | durable-stream write ${streamId}\n`);
307
482
  process.exit(1);
308
483
  }
309
484
  break;
310
485
  }
311
486
  case `read`: {
312
- if (args.length < 2) {
313
- node_process.stderr.write(`Error: stream_id required\n`);
314
- printUsage();
315
- process.exit(1);
316
- }
317
- await readStream(args[1], headers);
487
+ const streamId = getStreamId();
488
+ await readStream(options.url, streamId, headers);
318
489
  break;
319
490
  }
320
491
  case `delete`: {
321
- if (args.length < 2) {
322
- node_process.stderr.write(`Error: stream_id required\n`);
323
- printUsage();
324
- process.exit(1);
325
- }
326
- await deleteStream(args[1], headers);
492
+ const streamId = getStreamId();
493
+ await deleteStream(options.url, streamId, headers);
327
494
  break;
328
495
  }
329
496
  default:
330
- node_process.stderr.write(`Error: unknown command '${command}'\n`);
331
- printUsage();
497
+ if (command?.startsWith(`-`)) node_process.stderr.write(`Error: Unknown option "${command}"\n`);
498
+ else {
499
+ node_process.stderr.write(`Error: Unknown command "${command}"\n`);
500
+ node_process.stderr.write(` Available commands: create, write, read, delete\n`);
501
+ }
502
+ node_process.stderr.write(` Run "durable-stream --help" for usage information\n`);
332
503
  process.exit(1);
333
504
  }
334
505
  }
335
506
  function isMainModule() {
336
507
  if (!process.argv[1]) return false;
337
- const scriptPath = (0, node_path.resolve)(process.argv[1]);
338
- const modulePath = (0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href);
339
- return scriptPath === modulePath;
508
+ try {
509
+ const scriptPath = (0, node_fs.realpathSync)((0, node_path.resolve)(process.argv[1]));
510
+ const modulePath = (0, node_fs.realpathSync)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href));
511
+ return scriptPath === modulePath;
512
+ } catch {
513
+ const scriptPath = (0, node_path.resolve)(process.argv[1]);
514
+ const modulePath = (0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href);
515
+ return scriptPath === modulePath;
516
+ }
340
517
  }
341
518
  if (isMainModule()) main().catch((error) => {
342
- const message = error instanceof Error ? error.message : String(error);
343
- node_process.stderr.write(`Fatal error: ${message}\n`);
519
+ node_process.stderr.write(`Fatal error: ${getErrorMessage(error)}\n`);
344
520
  process.exit(1);
345
521
  });
346
522
 
347
523
  //#endregion
348
524
  exports.buildHeaders = buildHeaders
349
525
  exports.flattenJsonForAppend = flattenJsonForAppend
526
+ exports.getUsageText = getUsageText
350
527
  exports.isJsonContentType = isJsonContentType
528
+ exports.normalizeBaseUrl = normalizeBaseUrl
351
529
  exports.parseGlobalOptions = parseGlobalOptions
352
- exports.parseWriteArgs = parseWriteArgs
530
+ exports.parseWriteArgs = parseWriteArgs
531
+ exports.validateAuth = validateAuth
532
+ exports.validateStreamId = validateStreamId
533
+ exports.validateUrl = validateUrl
package/dist/index.d.cts CHANGED
@@ -29,21 +29,57 @@ interface ParsedWriteArgs {
29
29
  */
30
30
  declare function parseWriteArgs(args: Array<string>): ParsedWriteArgs;
31
31
 
32
+ //#endregion
33
+ //#region src/validation.d.ts
34
+ /**
35
+ * Validation utilities for CLI options with helpful error messages.
36
+ */
37
+ interface ValidationResult {
38
+ valid: boolean;
39
+ error?: string;
40
+ warning?: string;
41
+ }
42
+ /**
43
+ * Validate a URL string.
44
+ * Must be a valid HTTP or HTTPS URL.
45
+ */
46
+ declare function validateUrl(url: string): ValidationResult;
47
+ /**
48
+ * Normalize a base URL by removing trailing slashes.
49
+ * This prevents double-slashes when appending paths (e.g., "http://host/" + "/v1/...").
50
+ */
51
+ declare function normalizeBaseUrl(url: string): string;
52
+ /**
53
+ * Validate an authorization header value.
54
+ * Returns valid=true for any non-empty value, but includes a warning
55
+ * if the value doesn't match common auth schemes (Bearer, Basic, ApiKey, Token).
56
+ */
57
+ declare function validateAuth(auth: string): ValidationResult;
58
+ /**
59
+ * Validate a stream ID.
60
+ * Must be 1-256 characters containing only: letters, numbers, underscores,
61
+ * hyphens, dots, and colons (URL-safe characters).
62
+ */
63
+ declare function validateStreamId(streamId: string): ValidationResult;
64
+
32
65
  //#endregion
33
66
  //#region src/index.d.ts
34
67
  interface GlobalOptions {
68
+ url?: string;
35
69
  auth?: string;
36
70
  }
37
71
  /**
38
- * Parse global options (like --auth) from args.
39
- * Falls back to STREAM_AUTH env var if --auth flag not provided.
40
- * Returns the parsed options and remaining args.
72
+ * Parse global options (--url, --auth) from args.
73
+ * Falls back to STREAM_URL/STREAM_AUTH env vars when flags not provided.
74
+ * Returns the parsed options, remaining args, and any warnings.
41
75
  */
42
76
  declare function parseGlobalOptions(args: Array<string>): {
43
77
  options: GlobalOptions;
44
78
  remainingArgs: Array<string>;
79
+ warnings: Array<string>;
45
80
  };
46
81
  declare function buildHeaders(options: GlobalOptions): Record<string, string>;
82
+ declare function getUsageText(): string;
47
83
 
48
84
  //#endregion
49
- export { GlobalOptions, ParsedWriteArgs, buildHeaders, flattenJsonForAppend, isJsonContentType, parseGlobalOptions, parseWriteArgs };
85
+ export { GlobalOptions, ParsedWriteArgs, buildHeaders, flattenJsonForAppend, getUsageText, isJsonContentType, normalizeBaseUrl, parseGlobalOptions, parseWriteArgs, validateAuth, validateStreamId, validateUrl };
package/dist/index.d.ts CHANGED
@@ -29,21 +29,57 @@ interface ParsedWriteArgs {
29
29
  */
30
30
  declare function parseWriteArgs(args: Array<string>): ParsedWriteArgs;
31
31
 
32
+ //#endregion
33
+ //#region src/validation.d.ts
34
+ /**
35
+ * Validation utilities for CLI options with helpful error messages.
36
+ */
37
+ interface ValidationResult {
38
+ valid: boolean;
39
+ error?: string;
40
+ warning?: string;
41
+ }
42
+ /**
43
+ * Validate a URL string.
44
+ * Must be a valid HTTP or HTTPS URL.
45
+ */
46
+ declare function validateUrl(url: string): ValidationResult;
47
+ /**
48
+ * Normalize a base URL by removing trailing slashes.
49
+ * This prevents double-slashes when appending paths (e.g., "http://host/" + "/v1/...").
50
+ */
51
+ declare function normalizeBaseUrl(url: string): string;
52
+ /**
53
+ * Validate an authorization header value.
54
+ * Returns valid=true for any non-empty value, but includes a warning
55
+ * if the value doesn't match common auth schemes (Bearer, Basic, ApiKey, Token).
56
+ */
57
+ declare function validateAuth(auth: string): ValidationResult;
58
+ /**
59
+ * Validate a stream ID.
60
+ * Must be 1-256 characters containing only: letters, numbers, underscores,
61
+ * hyphens, dots, and colons (URL-safe characters).
62
+ */
63
+ declare function validateStreamId(streamId: string): ValidationResult;
64
+
32
65
  //#endregion
33
66
  //#region src/index.d.ts
34
67
  interface GlobalOptions {
68
+ url?: string;
35
69
  auth?: string;
36
70
  }
37
71
  /**
38
- * Parse global options (like --auth) from args.
39
- * Falls back to STREAM_AUTH env var if --auth flag not provided.
40
- * Returns the parsed options and remaining args.
72
+ * Parse global options (--url, --auth) from args.
73
+ * Falls back to STREAM_URL/STREAM_AUTH env vars when flags not provided.
74
+ * Returns the parsed options, remaining args, and any warnings.
41
75
  */
42
76
  declare function parseGlobalOptions(args: Array<string>): {
43
77
  options: GlobalOptions;
44
78
  remainingArgs: Array<string>;
79
+ warnings: Array<string>;
45
80
  };
46
81
  declare function buildHeaders(options: GlobalOptions): Record<string, string>;
82
+ declare function getUsageText(): string;
47
83
 
48
84
  //#endregion
49
- export { GlobalOptions, ParsedWriteArgs, buildHeaders, flattenJsonForAppend, isJsonContentType, parseGlobalOptions, parseWriteArgs };
85
+ export { GlobalOptions, ParsedWriteArgs, buildHeaders, flattenJsonForAppend, getUsageText, isJsonContentType, normalizeBaseUrl, parseGlobalOptions, parseWriteArgs, validateAuth, validateStreamId, validateUrl };
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { STATUS_CODES } from "node:http";
2
4
  import { resolve } from "node:path";
3
5
  import { stderr, stdin, stdout } from "node:process";
4
6
  import { fileURLToPath } from "node:url";
@@ -65,43 +67,146 @@ function parseWriteArgs(args) {
65
67
  };
66
68
  }
67
69
 
70
+ //#endregion
71
+ //#region src/validation.ts
72
+ /**
73
+ * Validate a URL string.
74
+ * Must be a valid HTTP or HTTPS URL.
75
+ */
76
+ function validateUrl(url) {
77
+ if (!url || !url.trim()) return {
78
+ valid: false,
79
+ error: `URL cannot be empty`
80
+ };
81
+ let parsed;
82
+ try {
83
+ parsed = new URL(url);
84
+ } catch {
85
+ return {
86
+ valid: false,
87
+ error: `Invalid URL format: "${url}"\n Expected format: http://host:port or https://host:port`
88
+ };
89
+ }
90
+ if (parsed.protocol !== `http:` && parsed.protocol !== `https:`) return {
91
+ valid: false,
92
+ error: `Invalid URL protocol: "${parsed.protocol}"\n Only http:// and https:// are supported`
93
+ };
94
+ if (!parsed.hostname) return {
95
+ valid: false,
96
+ error: `URL must include a hostname: "${url}"`
97
+ };
98
+ return { valid: true };
99
+ }
100
+ /**
101
+ * Normalize a base URL by removing trailing slashes.
102
+ * This prevents double-slashes when appending paths (e.g., "http://host/" + "/v1/...").
103
+ */
104
+ function normalizeBaseUrl(url) {
105
+ return url.replace(/\/+$/, ``);
106
+ }
107
+ /**
108
+ * Validate an authorization header value.
109
+ * Returns valid=true for any non-empty value, but includes a warning
110
+ * if the value doesn't match common auth schemes (Bearer, Basic, ApiKey, Token).
111
+ */
112
+ function validateAuth(auth) {
113
+ if (!auth || !auth.trim()) return {
114
+ valid: false,
115
+ error: `Authorization value cannot be empty`
116
+ };
117
+ const trimmed = auth.trim();
118
+ const lowerTrimmed = trimmed.toLowerCase();
119
+ const hasScheme = [
120
+ `bearer `,
121
+ `basic `,
122
+ `apikey `,
123
+ `token `
124
+ ].some((scheme) => lowerTrimmed.startsWith(scheme));
125
+ if (!hasScheme && !trimmed.includes(` `)) return {
126
+ valid: true,
127
+ warning: `Warning: Authorization value doesn't match common formats.\n Expected: "Bearer <token>", "Basic <credentials>", or "ApiKey <key>"`
128
+ };
129
+ return { valid: true };
130
+ }
131
+ /**
132
+ * Validate a stream ID.
133
+ * Must be 1-256 characters containing only: letters, numbers, underscores,
134
+ * hyphens, dots, and colons (URL-safe characters).
135
+ */
136
+ function validateStreamId(streamId) {
137
+ if (!streamId || !streamId.trim()) return {
138
+ valid: false,
139
+ error: `Stream ID cannot be empty`
140
+ };
141
+ const validPattern = /^[a-zA-Z0-9_\-.:]+$/;
142
+ if (!validPattern.test(streamId)) return {
143
+ valid: false,
144
+ error: `Invalid stream ID: "${streamId}"\n Stream IDs can only contain letters, numbers, underscores, hyphens, dots, and colons`
145
+ };
146
+ if (streamId.length > 256) return {
147
+ valid: false,
148
+ error: `Stream ID too long (${streamId.length} chars)\n Maximum length is 256 characters`
149
+ };
150
+ return { valid: true };
151
+ }
152
+
68
153
  //#endregion
69
154
  //#region src/index.ts
70
155
  const STREAM_URL = process.env.STREAM_URL || `http://localhost:4437`;
71
156
  const STREAM_AUTH = process.env.STREAM_AUTH;
72
157
  /**
73
- * Parse global options (like --auth) from args.
74
- * Falls back to STREAM_AUTH env var if --auth flag not provided.
75
- * Returns the parsed options and remaining args.
158
+ * Parse global options (--url, --auth) from args.
159
+ * Falls back to STREAM_URL/STREAM_AUTH env vars when flags not provided.
160
+ * Returns the parsed options, remaining args, and any warnings.
76
161
  */
77
162
  function parseGlobalOptions(args) {
78
163
  const options = {};
79
164
  const remainingArgs = [];
165
+ const warnings = [];
80
166
  for (let i = 0; i < args.length; i++) {
81
167
  const arg = args[i];
82
- if (arg === `--auth`) {
168
+ if (arg === `--url`) {
169
+ const value = args[i + 1];
170
+ if (!value || value.startsWith(`--`)) throw new Error(`--url requires a value\n Example: --url "http://localhost:4437"`);
171
+ const urlValidation = validateUrl(value);
172
+ if (!urlValidation.valid) throw new Error(urlValidation.error);
173
+ options.url = normalizeBaseUrl(value);
174
+ i++;
175
+ } else if (arg === `--auth`) {
83
176
  const value = args[i + 1];
84
- if (!value || value.startsWith(`--`)) throw new Error(`--auth requires a value (e.g., --auth "Bearer token")`);
85
- if (!value.trim()) throw new Error(`--auth value cannot be empty or whitespace`);
177
+ if (!value || value.startsWith(`--`)) throw new Error(`--auth requires a value\n Example: --auth "Bearer my-token"`);
178
+ const authValidation = validateAuth(value);
179
+ if (!authValidation.valid) throw new Error(authValidation.error);
180
+ if (authValidation.warning) warnings.push(authValidation.warning);
86
181
  options.auth = value;
87
182
  i++;
88
183
  } else remainingArgs.push(arg);
89
184
  }
185
+ if (!options.url) {
186
+ const urlValidation = validateUrl(STREAM_URL);
187
+ if (!urlValidation.valid) throw new Error(`Invalid STREAM_URL environment variable: ${urlValidation.error}`);
188
+ options.url = normalizeBaseUrl(STREAM_URL);
189
+ }
90
190
  if (!options.auth && STREAM_AUTH) {
91
- if (!STREAM_AUTH.trim()) throw new Error(`STREAM_AUTH environment variable cannot be empty or whitespace`);
191
+ const authValidation = validateAuth(STREAM_AUTH);
192
+ if (!authValidation.valid) throw new Error(`Invalid STREAM_AUTH environment variable: ${authValidation.error}`);
193
+ if (authValidation.warning) warnings.push(authValidation.warning);
92
194
  options.auth = STREAM_AUTH;
93
195
  }
94
196
  return {
95
197
  options,
96
- remainingArgs
198
+ remainingArgs,
199
+ warnings
97
200
  };
98
201
  }
202
+ function getErrorMessage(error) {
203
+ return error instanceof Error ? error.message : String(error);
204
+ }
99
205
  function buildHeaders(options) {
100
- if (options.auth) return { Authorization: options.auth };
101
- return {};
206
+ return options.auth ? { Authorization: options.auth } : {};
102
207
  }
103
- function printUsage() {
104
- console.error(`
208
+ function getUsageText() {
209
+ return `
105
210
  Usage:
106
211
  durable-stream create <stream_id> Create a new stream
107
212
  durable-stream write <stream_id> <content> Write content to a stream
@@ -110,7 +215,9 @@ Usage:
110
215
  durable-stream delete <stream_id> Delete a stream
111
216
 
112
217
  Global Options:
218
+ --url <url> Stream server URL (overrides STREAM_URL env var)
113
219
  --auth <value> Authorization header value (e.g., "Bearer my-token")
220
+ --help, -h Show this help message
114
221
 
115
222
  Write Options:
116
223
  --content-type <type> Content-Type for the message (default: application/octet-stream)
@@ -120,205 +227,274 @@ Write Options:
120
227
  Environment Variables:
121
228
  STREAM_URL Base URL of the stream server (default: http://localhost:4437)
122
229
  STREAM_AUTH Authorization header value (overridden by --auth flag)
123
- `);
230
+ `;
231
+ }
232
+ function printUsage({ to = `stderr` } = {}) {
233
+ const out = to === `stderr` ? stderr : stdout;
234
+ out.write(getUsageText());
124
235
  }
125
- async function createStream(streamId, headers) {
126
- const url = `${STREAM_URL}/v1/stream/${streamId}`;
236
+ async function createStream(baseUrl, streamId, headers) {
237
+ const url = `${baseUrl}/v1/stream/${streamId}`;
127
238
  try {
128
239
  await DurableStream.create({
129
240
  url,
130
241
  headers,
131
242
  contentType: `application/octet-stream`
132
243
  });
133
- console.log(`Created stream: ${streamId}`);
244
+ console.log(`Stream created successfully: "${streamId}"`);
245
+ console.log(` URL: ${url}`);
134
246
  } catch (error) {
135
- const message = error instanceof Error ? error.message : String(error);
136
- stderr.write(`Error creating stream: ${message}\n`);
247
+ stderr.write(`Failed to create stream "${streamId}"\n`);
248
+ stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
137
249
  process.exit(1);
138
250
  }
139
251
  }
140
252
  /**
141
- * Append JSON data to a stream with one-level array flattening.
253
+ * Format error messages from the server/client for better readability.
142
254
  */
143
- async function appendJson(stream, parsed) {
144
- let count = 0;
145
- for (const item of flattenJsonForAppend(parsed)) {
146
- await stream.append(item);
147
- count++;
255
+ function formatErrorMessage(message) {
256
+ const httpMatch = message.match(/HTTP Error (\d+)/);
257
+ const statusCode = httpMatch?.[1];
258
+ if (statusCode) {
259
+ const status = parseInt(statusCode, 10);
260
+ const statusText = getHttpStatusText(status);
261
+ return message.replace(/HTTP Error \d+/, `${statusText} (${status})`);
148
262
  }
149
- return count;
263
+ return message;
264
+ }
265
+ function getHttpStatusText(status) {
266
+ return STATUS_CODES[status] ?? `HTTP Error`;
267
+ }
268
+ /**
269
+ * Append JSON data to a stream using batch semantics.
270
+ * Arrays are flattened one level (each element becomes a separate message).
271
+ * Non-array values are written as a single message.
272
+ * Returns the number of messages written.
273
+ */
274
+ async function appendJsonBatch(stream, parsed) {
275
+ const items = [...flattenJsonForAppend(parsed)];
276
+ for (const item of items) await stream.append(JSON.stringify(item));
277
+ return items.length;
278
+ }
279
+ /**
280
+ * Read all data from stdin into a Buffer.
281
+ */
282
+ async function readStdin() {
283
+ const chunks = [];
284
+ stdin.on(`data`, (chunk) => {
285
+ chunks.push(chunk);
286
+ });
287
+ await new Promise((resolve$1, reject) => {
288
+ stdin.on(`end`, resolve$1);
289
+ stdin.on(`error`, (err) => {
290
+ reject(new Error(`Failed to read from stdin: ${err.message}`));
291
+ });
292
+ });
293
+ return Buffer.concat(chunks);
294
+ }
295
+ /**
296
+ * Process escape sequences in content string.
297
+ */
298
+ function processEscapeSequences(content) {
299
+ return content.replace(/\\n/g, `\n`).replace(/\\t/g, `\t`).replace(/\\r/g, `\r`).replace(/\\\\/g, `\\`);
150
300
  }
151
- async function writeStream(streamId, contentType, batchJson, headers, content) {
152
- const url = `${STREAM_URL}/v1/stream/${streamId}`;
301
+ async function writeStream(baseUrl, streamId, contentType, batchJson, headers, content) {
302
+ const url = `${baseUrl}/v1/stream/${streamId}`;
153
303
  const isJson = isJsonContentType(contentType);
304
+ let data;
305
+ let source;
306
+ if (content) {
307
+ data = processEscapeSequences(content);
308
+ source = `argument`;
309
+ } else {
310
+ data = await readStdin();
311
+ source = `stdin`;
312
+ if (data.length === 0) {
313
+ stderr.write(`No data received from stdin\n`);
314
+ process.exit(1);
315
+ }
316
+ }
154
317
  try {
155
318
  const stream = new DurableStream({
156
319
  url,
157
320
  headers,
158
321
  contentType
159
322
  });
160
- if (content) {
161
- const processedContent = content.replace(/\\n/g, `\n`).replace(/\\t/g, `\t`).replace(/\\r/g, `\r`).replace(/\\\\/g, `\\`);
162
- if (isJson) {
163
- const parsed = JSON.parse(processedContent);
164
- if (batchJson) {
165
- const count = await appendJson(stream, parsed);
166
- console.log(`Wrote ${count} message(s) to ${streamId}`);
323
+ if (isJson) {
324
+ const jsonString = typeof data === `string` ? data : data.toString(`utf8`);
325
+ let parsed;
326
+ try {
327
+ parsed = JSON.parse(jsonString);
328
+ } catch (parseError) {
329
+ const parseMessage = parseError instanceof SyntaxError ? parseError.message : `Unknown parsing error`;
330
+ if (source === `argument`) {
331
+ const preview = jsonString.slice(0, 100);
332
+ const ellipsis = jsonString.length > 100 ? `...` : ``;
333
+ stderr.write(`Failed to parse JSON content\n`);
334
+ stderr.write(` ${parseMessage}\n`);
335
+ stderr.write(` Input: ${preview}${ellipsis}\n`);
167
336
  } else {
168
- await stream.append(parsed);
169
- console.log(`Wrote 1 message to ${streamId}`);
337
+ stderr.write(`Failed to parse JSON from stdin\n`);
338
+ stderr.write(` ${parseMessage}\n`);
170
339
  }
171
- } else {
172
- await stream.append(processedContent);
173
- console.log(`Wrote ${processedContent.length} bytes to ${streamId}`);
340
+ process.exit(1);
174
341
  }
175
- } else {
176
- const chunks = [];
177
- stdin.on(`data`, (chunk) => {
178
- chunks.push(chunk);
179
- });
180
- await new Promise((resolve$1, reject) => {
181
- stdin.on(`end`, resolve$1);
182
- stdin.on(`error`, reject);
183
- });
184
- const data = Buffer.concat(chunks);
185
- if (isJson) {
186
- const parsed = JSON.parse(data.toString(`utf8`));
187
- if (batchJson) {
188
- const count = await appendJson(stream, parsed);
189
- console.log(`Wrote ${count} message(s) to ${streamId}`);
190
- } else {
191
- await stream.append(parsed);
192
- console.log(`Wrote 1 message to ${streamId}`);
193
- }
342
+ if (batchJson) {
343
+ const count = await appendJsonBatch(stream, parsed);
344
+ console.log(`Wrote ${count} message${count !== 1 ? `s` : ``} to stream "${streamId}"`);
194
345
  } else {
195
- await stream.append(data);
196
- console.log(`Wrote ${data.length} bytes to ${streamId}`);
346
+ await stream.append(JSON.stringify(parsed));
347
+ console.log(`Wrote 1 JSON message to stream "${streamId}"`);
197
348
  }
349
+ } else {
350
+ await stream.append(data);
351
+ const byteCount = typeof data === `string` ? Buffer.byteLength(data, `utf8`) : data.length;
352
+ console.log(`Wrote ${formatBytes(byteCount)} to stream "${streamId}"`);
198
353
  }
199
354
  } catch (error) {
200
- const message = error instanceof Error ? error.message : String(error);
201
- stderr.write(`Error writing to stream: ${message}\n`);
355
+ stderr.write(`Failed to write to stream "${streamId}"\n`);
356
+ stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
202
357
  process.exit(1);
203
358
  }
204
359
  }
205
- async function readStream(streamId, headers) {
206
- const url = `${STREAM_URL}/v1/stream/${streamId}`;
360
+ function formatBytes(bytes) {
361
+ if (bytes === 1) return `1 byte`;
362
+ if (bytes < 1024) return `${bytes} bytes`;
363
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
364
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
365
+ }
366
+ async function readStream(baseUrl, streamId, headers) {
367
+ const url = `${baseUrl}/v1/stream/${streamId}`;
207
368
  try {
208
369
  const stream = new DurableStream({
209
370
  url,
210
371
  headers
211
372
  });
212
- const res = await stream.stream({ live: `auto` });
373
+ const res = await stream.stream({ live: true });
213
374
  for await (const chunk of res.bodyStream()) if (chunk.length > 0) stdout.write(chunk);
214
375
  } catch (error) {
215
- const message = error instanceof Error ? error.message : String(error);
216
- stderr.write(`Error reading stream: ${message}\n`);
376
+ stderr.write(`Failed to read stream "${streamId}"\n`);
377
+ stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
217
378
  process.exit(1);
218
379
  }
219
380
  }
220
- async function deleteStream(streamId, headers) {
221
- const url = `${STREAM_URL}/v1/stream/${streamId}`;
381
+ async function deleteStream(baseUrl, streamId, headers) {
382
+ const url = `${baseUrl}/v1/stream/${streamId}`;
222
383
  try {
223
384
  const stream = new DurableStream({
224
385
  url,
225
386
  headers
226
387
  });
227
388
  await stream.delete();
228
- console.log(`Deleted stream: ${streamId}`);
389
+ console.log(`Stream deleted successfully: "${streamId}"`);
229
390
  } catch (error) {
230
- const message = error instanceof Error ? error.message : String(error);
231
- stderr.write(`Error deleting stream: ${message}\n`);
391
+ stderr.write(`Failed to delete stream "${streamId}"\n`);
392
+ stderr.write(` ${formatErrorMessage(getErrorMessage(error))}\n`);
232
393
  process.exit(1);
233
394
  }
234
395
  }
235
396
  async function main() {
397
+ const rawArgs = process.argv.slice(2);
398
+ if (rawArgs.includes(`--help`) || rawArgs.includes(`-h`)) {
399
+ printUsage({ to: `stdout` });
400
+ process.exit(0);
401
+ }
236
402
  let options;
237
403
  let args;
404
+ let warnings;
238
405
  try {
239
- const parsed = parseGlobalOptions(process.argv.slice(2));
406
+ const parsed = parseGlobalOptions(rawArgs);
240
407
  options = parsed.options;
241
408
  args = parsed.remainingArgs;
409
+ warnings = parsed.warnings;
242
410
  } catch (error) {
243
- const message = error instanceof Error ? error.message : String(error);
244
- stderr.write(`Error: ${message}\n`);
411
+ stderr.write(`Error: ${getErrorMessage(error)}\n`);
245
412
  process.exit(1);
246
413
  }
414
+ for (const warning of warnings) stderr.write(`${warning}\n`);
247
415
  const headers = buildHeaders(options);
248
416
  if (args.length < 1) {
417
+ stderr.write(`Error: No command specified\n`);
249
418
  printUsage();
250
419
  process.exit(1);
251
420
  }
252
421
  const command = args[0];
422
+ function getStreamId() {
423
+ if (args.length < 2) {
424
+ stderr.write(`Error: Missing stream_id\n`);
425
+ stderr.write(` Usage: durable-stream ${command} <stream_id>\n`);
426
+ process.exit(1);
427
+ }
428
+ const streamId = args[1];
429
+ const validation = validateStreamId(streamId);
430
+ if (!validation.valid) {
431
+ stderr.write(`Error: ${validation.error}\n`);
432
+ process.exit(1);
433
+ }
434
+ return streamId;
435
+ }
253
436
  switch (command) {
254
437
  case `create`: {
255
- if (args.length < 2) {
256
- stderr.write(`Error: stream_id required\n`);
257
- printUsage();
258
- process.exit(1);
259
- }
260
- await createStream(args[1], headers);
438
+ const streamId = getStreamId();
439
+ await createStream(options.url, streamId, headers);
261
440
  break;
262
441
  }
263
442
  case `write`: {
264
- if (args.length < 2) {
265
- stderr.write(`Error: stream_id required\n`);
266
- printUsage();
267
- process.exit(1);
268
- }
269
- const streamId = args[1];
443
+ const streamId = getStreamId();
270
444
  let parsed;
271
445
  try {
272
446
  parsed = parseWriteArgs(args.slice(2));
273
447
  } catch (error) {
274
- const message = error instanceof Error ? error.message : String(error);
275
- stderr.write(`Error: ${message}\n`);
448
+ stderr.write(`Error: ${getErrorMessage(error)}\n`);
276
449
  process.exit(1);
277
450
  }
278
- if (!stdin.isTTY) await writeStream(streamId, parsed.contentType, parsed.batchJson, headers);
279
- else if (parsed.content) await writeStream(streamId, parsed.contentType, parsed.batchJson, headers, parsed.content);
451
+ const hasContent = parsed.content || !stdin.isTTY;
452
+ if (hasContent) await writeStream(options.url, streamId, parsed.contentType, parsed.batchJson, headers, parsed.content || void 0);
280
453
  else {
281
- stderr.write(`Error: content required (provide as argument or pipe to stdin)\n`);
282
- printUsage();
454
+ stderr.write(`Error: No content provided\n`);
455
+ stderr.write(` Provide content as an argument or pipe from stdin:\n`);
456
+ stderr.write(` durable-stream write ${streamId} "your content here"\n`);
457
+ stderr.write(` echo "content" | durable-stream write ${streamId}\n`);
283
458
  process.exit(1);
284
459
  }
285
460
  break;
286
461
  }
287
462
  case `read`: {
288
- if (args.length < 2) {
289
- stderr.write(`Error: stream_id required\n`);
290
- printUsage();
291
- process.exit(1);
292
- }
293
- await readStream(args[1], headers);
463
+ const streamId = getStreamId();
464
+ await readStream(options.url, streamId, headers);
294
465
  break;
295
466
  }
296
467
  case `delete`: {
297
- if (args.length < 2) {
298
- stderr.write(`Error: stream_id required\n`);
299
- printUsage();
300
- process.exit(1);
301
- }
302
- await deleteStream(args[1], headers);
468
+ const streamId = getStreamId();
469
+ await deleteStream(options.url, streamId, headers);
303
470
  break;
304
471
  }
305
472
  default:
306
- stderr.write(`Error: unknown command '${command}'\n`);
307
- printUsage();
473
+ if (command?.startsWith(`-`)) stderr.write(`Error: Unknown option "${command}"\n`);
474
+ else {
475
+ stderr.write(`Error: Unknown command "${command}"\n`);
476
+ stderr.write(` Available commands: create, write, read, delete\n`);
477
+ }
478
+ stderr.write(` Run "durable-stream --help" for usage information\n`);
308
479
  process.exit(1);
309
480
  }
310
481
  }
311
482
  function isMainModule() {
312
483
  if (!process.argv[1]) return false;
313
- const scriptPath = resolve(process.argv[1]);
314
- const modulePath = fileURLToPath(import.meta.url);
315
- return scriptPath === modulePath;
484
+ try {
485
+ const scriptPath = realpathSync(resolve(process.argv[1]));
486
+ const modulePath = realpathSync(fileURLToPath(import.meta.url));
487
+ return scriptPath === modulePath;
488
+ } catch {
489
+ const scriptPath = resolve(process.argv[1]);
490
+ const modulePath = fileURLToPath(import.meta.url);
491
+ return scriptPath === modulePath;
492
+ }
316
493
  }
317
494
  if (isMainModule()) main().catch((error) => {
318
- const message = error instanceof Error ? error.message : String(error);
319
- stderr.write(`Fatal error: ${message}\n`);
495
+ stderr.write(`Fatal error: ${getErrorMessage(error)}\n`);
320
496
  process.exit(1);
321
497
  });
322
498
 
323
499
  //#endregion
324
- export { buildHeaders, flattenJsonForAppend, isJsonContentType, parseGlobalOptions, parseWriteArgs };
500
+ export { buildHeaders, flattenJsonForAppend, getUsageText, isJsonContentType, normalizeBaseUrl, parseGlobalOptions, parseWriteArgs, validateAuth, validateStreamId, validateUrl };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@durable-streams/cli",
3
3
  "description": "CLI tool for working with Durable Streams",
4
- "version": "0.1.7",
4
+ "version": "0.1.9",
5
5
  "author": "Durable Stream contributors",
6
6
  "bin": {
7
7
  "cli": "./dist/index.js",
@@ -12,7 +12,7 @@
12
12
  "url": "https://github.com/durable-streams/durable-streams/issues"
13
13
  },
14
14
  "dependencies": {
15
- "@durable-streams/client": "0.1.5"
15
+ "@durable-streams/client": "0.2.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/node": "^22.15.21",
@@ -20,7 +20,7 @@
20
20
  "tsx": "^4.19.2",
21
21
  "typescript": "^5.5.2",
22
22
  "vitest": "^3.1.3",
23
- "@durable-streams/server": "0.1.6"
23
+ "@durable-streams/server": "0.1.7"
24
24
  },
25
25
  "engines": {
26
26
  "node": ">=18.0.0"