@wictorwilen/cocogen 1.1.0-preview.10 → 1.1.0-preview.11

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/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0-preview.11] - 2026-04-09
11
+
12
+ ### Changed
13
+ - Generated TypeScript and .NET connector CLIs now support `--retry-attempts` (default `2`, max `10`) for ingestion; failed items are queued and retried after the initial batch pass completes, with retry-round backoff and final failed-item summaries.
14
+
10
15
  ## [1.1.0-preview.10] - 2026-04-08
11
16
 
12
17
  ## [1.1.0-preview.9] - 2026-04-08
@@ -378,6 +383,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
378
383
  - Collection values no longer split on commas; use semicolons instead.
379
384
 
380
385
  [Unreleased]: https://github.com/wictorwilen/cocogen/compare/v1.0.16...HEAD
386
+ [1.1.0-preview.11]: https://github.com/wictorwilen/cocogen/compare/main...v1.1.0-preview.11
381
387
  [1.1.0-preview.10]: https://github.com/wictorwilen/cocogen/compare/main...v1.1.0-preview.10
382
388
  [1.1.0-preview.9]: https://github.com/wictorwilen/cocogen/compare/main...v1.1.0-preview.9
383
389
  [1.1.0-preview.8]: https://github.com/wictorwilen/cocogen/compare/main...v1.1.0-preview.8
@@ -1,5 +1,5 @@
1
1
  {
2
- "generatedAt": "2026-04-08T13:09:48.617Z",
2
+ "generatedAt": "2026-04-09T08:52:20.160Z",
3
3
  "graphVersion": "v1.0",
4
4
  "sourceUrl": "https://graph.microsoft.com/v1.0/$metadata",
5
5
  "namespace": "microsoft.graph.externalConnectors",
@@ -269,43 +269,54 @@ public sealed class ConnectorCore<TItem>
269
269
  /// <summary>
270
270
  /// Ingest items from a datasource.
271
271
  /// </summary>
272
- public async Task IngestAsync(IItemSource<TItem> source, bool dryRun, int? limit, int batchSize, bool verbose, bool failFast)
272
+ public async Task IngestAsync(IItemSource<TItem> source, bool dryRun, int? limit, int batchSize, int retryAttempts, bool verbose, bool failFast)
273
273
  {
274
274
  if (batchSize < 1 || batchSize > 20)
275
275
  throw new InvalidOperationException("Invalid batch size: expected integer between 1 and 20.");
276
+ if (retryAttempts < 0 || retryAttempts > MaxItemRetryAttempts)
277
+ throw new InvalidOperationException($"Invalid retry attempts: expected integer between 0 and {MaxItemRetryAttempts}.");
278
+
279
+ var retryQueueDelayAttempts = Math.Max(0, retryAttempts);
276
280
 
277
281
  var count = 0;
278
282
  var successCount = 0;
279
- var failures = new List<(int Index, string Id, string Message)>();
280
- var pending = new List<(int Index, string Id, TItem Item)>();
283
+ var failures = new List<(int Index, string Id, int Attempts, string Message)>();
284
+ var pending = new List<(int Index, string Id, TItem Item, int Attempt)>();
285
+ var retryQueue = new List<(int Index, string Id, TItem Item, int Attempt)>();
281
286
 
282
- async Task<(int Index, string Id, bool Success, string Message, Exception? Error)> ProcessPendingItemAsync(
283
- (int Index, string Id, TItem Item) pendingItem)
287
+ async Task<(int Index, string Id, int Attempt, bool Success, string Message, Exception? Error, TItem Item)> ProcessPendingItemAsync(
288
+ (int Index, string Id, TItem Item, int Attempt) pendingItem)
284
289
  {
285
290
  try
286
291
  {
287
292
  await PutItemAsync(pendingItem.Item, verbose);
288
- return (pendingItem.Index, pendingItem.Id, true, string.Empty, null);
293
+ return (pendingItem.Index, pendingItem.Id, pendingItem.Attempt, true, string.Empty, null, pendingItem.Item);
289
294
  }
290
295
  catch (Exception ex)
291
296
  {
292
- return (pendingItem.Index, pendingItem.Id, false, ex.Message, ex);
297
+ return (pendingItem.Index, pendingItem.Id, pendingItem.Attempt, false, ex.Message, ex, pendingItem.Item);
293
298
  }
294
299
  }
295
300
 
296
- async Task FlushBatchAsync()
301
+ async Task FlushBatchAsync(IReadOnlyList<(int Index, string Id, TItem Item, int Attempt)> batch)
297
302
  {
298
- if (pending.Count == 0)
303
+ if (batch.Count == 0)
299
304
  return;
300
305
 
301
- var tasks = new List<Task<(int Index, string Id, bool Success, string Message, Exception? Error)>>();
302
- foreach (var pendingItem in pending)
306
+ var tasks = new List<Task<(int Index, string Id, int Attempt, bool Success, string Message, Exception? Error, TItem Item)>>();
307
+ foreach (var pendingItem in batch)
303
308
  {
304
- Console.WriteLine($"info: ingesting item {pendingItem.Index} (id={pendingItem.Id})");
309
+ if (pendingItem.Attempt == 0)
310
+ {
311
+ Console.WriteLine($"info: ingesting item {pendingItem.Index} (id={pendingItem.Id})");
312
+ }
313
+ else
314
+ {
315
+ Console.WriteLine($"info: retrying item {pendingItem.Index} (id={pendingItem.Id}) attempt {pendingItem.Attempt}/{retryAttempts}");
316
+ }
305
317
  tasks.Add(ProcessPendingItemAsync(pendingItem));
306
318
  }
307
319
 
308
- pending.Clear();
309
320
  var results = await Task.WhenAll(tasks);
310
321
  Exception? firstError = null;
311
322
 
@@ -319,7 +330,13 @@ public sealed class ConnectorCore<TItem>
319
330
  }
320
331
 
321
332
  Console.Error.WriteLine($"error: failed item {result.Index} (id={result.Id})");
322
- failures.Add((result.Index, result.Id, result.Message));
333
+ if (!failFast && result.Attempt < retryAttempts)
334
+ {
335
+ retryQueue.Add((result.Index, result.Id, result.Item, result.Attempt + 1));
336
+ continue;
337
+ }
338
+
339
+ failures.Add((result.Index, result.Id, result.Attempt + 1, result.Message));
323
340
  if (failFast && firstError is null)
324
341
  {
325
342
  firstError = result.Error;
@@ -340,9 +357,13 @@ public sealed class ConnectorCore<TItem>
340
357
 
341
358
  if (!dryRun)
342
359
  {
343
- pending.Add((index, itemId, item));
360
+ pending.Add((index, itemId, item, 0));
344
361
  if (pending.Count >= batchSize)
345
- await FlushBatchAsync();
362
+ {
363
+ var batch = pending.ToArray();
364
+ pending.Clear();
365
+ await FlushBatchAsync(batch);
366
+ }
346
367
  }
347
368
  else if (verbose)
348
369
  {
@@ -354,7 +375,46 @@ public sealed class ConnectorCore<TItem>
354
375
  }
355
376
 
356
377
  if (!dryRun)
357
- await FlushBatchAsync();
378
+ {
379
+ var batch = pending.ToArray();
380
+ pending.Clear();
381
+ await FlushBatchAsync(batch);
382
+
383
+ for (var retryAttempt = 1; retryAttempt <= retryAttempts && retryQueue.Count > 0; retryAttempt++)
384
+ {
385
+ var queue = retryQueue.ToArray();
386
+ retryQueue.Clear();
387
+ Console.WriteLine($"warn: retry pass {retryAttempt}/{retryAttempts} for {queue.Length} item(s)");
388
+
389
+ for (var offset = 0; offset < queue.Length; offset += batchSize)
390
+ {
391
+ var length = Math.Min(batchSize, queue.Length - offset);
392
+ var chunk = new (int Index, string Id, TItem Item, int Attempt)[length];
393
+ Array.Copy(queue, offset, chunk, 0, length);
394
+ await FlushBatchAsync(chunk);
395
+ }
396
+
397
+ if (retryQueue.Count > 0 && retryAttempt < retryQueueDelayAttempts)
398
+ {
399
+ var delay = ComputeDelayMs(retryAttempt - 1, (int?)null);
400
+ Console.WriteLine($"warn: waiting {delay}ms before next retry pass");
401
+ await Task.Delay(delay);
402
+ }
403
+ }
404
+
405
+ if (retryQueue.Count > 0)
406
+ {
407
+ foreach (var item in retryQueue)
408
+ {
409
+ failures.Add((
410
+ item.Index,
411
+ item.Id,
412
+ item.Attempt,
413
+ $"exhausted retry queue after {item.Attempt} attempt(s)"
414
+ ));
415
+ }
416
+ }
417
+ }
358
418
 
359
419
  if (!dryRun)
360
420
  {
@@ -370,7 +430,7 @@ public sealed class ConnectorCore<TItem>
370
430
  Console.WriteLine($"warn: {failures.Count} item(s) failed");
371
431
  foreach (var failure in failures)
372
432
  {
373
- Console.WriteLine($"warn: failed item {failure.Index} (id={failure.Id}) - {failure.Message}");
433
+ Console.WriteLine($"warn: failed item {failure.Index} (id={failure.Id}) after {failure.Attempts} attempt(s) - {failure.Message}");
374
434
  }
375
435
  }
376
436
  }
@@ -643,6 +703,7 @@ public sealed class ConnectorCore<TItem>
643
703
  private const int MaxRetries = 6;
644
704
  private const int BaseDelayMs = 1000;
645
705
  private const int MaxDelayMs = 30000;
706
+ private const int MaxItemRetryAttempts = 10;
646
707
 
647
708
  <% if (isPeopleConnector) { -%>
648
709
  /// <summary>
@@ -92,6 +92,14 @@ int ValidateBatchSize(int? batchSize)
92
92
  throw new InvalidOperationException("Invalid --batch-size: expected an integer between 1 and 20.");
93
93
  return batchSize.Value;
94
94
  }
95
+
96
+ int ValidateRetryAttempts(int? retryAttempts)
97
+ {
98
+ retryAttempts ??= 2;
99
+ if (retryAttempts < 0 || retryAttempts > 10)
100
+ throw new InvalidOperationException("Invalid --retry-attempts: expected an integer between 0 and 10.");
101
+ return retryAttempts.Value;
102
+ }
95
103
  <% if (inputFormat === "rest") { -%>
96
104
  string RestBaseUrl(string? inputPath)
97
105
  {
@@ -182,9 +190,10 @@ async Task ProvisionAsync()
182
190
  /// <summary>
183
191
  /// Ingest items from the configured input.
184
192
  /// </summary>
185
- async Task IngestAsync(string? inputPath, bool dryRun, int? limit, int? batchSize, bool verbose, bool failFast)
193
+ async Task IngestAsync(string? inputPath, bool dryRun, int? limit, int? batchSize, int? retryAttempts, bool verbose, bool failFast)
186
194
  {
187
195
  var validatedBatchSize = ValidateBatchSize(batchSize);
196
+ var validatedRetryAttempts = ValidateRetryAttempts(retryAttempts);
188
197
 
189
198
  GraphServiceClient? graph = null;
190
199
  TokenCredential? credential = null;
@@ -209,7 +218,7 @@ async Task IngestAsync(string? inputPath, bool dryRun, int? limit, int? batchSiz
209
218
  <% } -%>
210
219
  var core = BuildConnectorCore(graph, credential, connectionId);
211
220
 
212
- await core.IngestAsync(source, dryRun, limit, validatedBatchSize, verbose, failFast);
221
+ await core.IngestAsync(source, dryRun, limit, validatedBatchSize, validatedRetryAttempts, verbose, failFast);
213
222
  }
214
223
 
215
224
  /// <summary>
@@ -231,6 +240,7 @@ var dryRunOption = new Option<bool>("--dry-run") { Description = "Build payloads
231
240
  var failFastOption = new Option<bool>("--fail-fast") { Description = "Abort on the first item failure" };
232
241
  var limitOption = new Option<int?>("--limit") { Description = "Limit number of items" };
233
242
  var batchSizeOption = new Option<int?>("--batch-size") { Description = "Number of concurrent PUT requests to send per batch (1-20)" };
243
+ var retryAttemptsOption = new Option<int?>("--retry-attempts") { Description = "Retry failed items after initial pass (0-10, default 2)" };
234
244
  var verboseOption = new Option<bool>("--verbose") { Description = "Print payloads sent to Graph" };
235
245
 
236
246
  var root = new RootCommand("Connector CLI generated by cocogen");
@@ -245,6 +255,7 @@ ingestCommand.Options.Add(dryRunOption);
245
255
  ingestCommand.Options.Add(failFastOption);
246
256
  ingestCommand.Options.Add(limitOption);
247
257
  ingestCommand.Options.Add(batchSizeOption);
258
+ ingestCommand.Options.Add(retryAttemptsOption);
248
259
  ingestCommand.Options.Add(verboseOption);
249
260
  ingestCommand.SetAction(async (parseResult, cancellationToken) =>
250
261
  {
@@ -253,8 +264,9 @@ ingestCommand.SetAction(async (parseResult, cancellationToken) =>
253
264
  var failFast = parseResult.GetValue(failFastOption);
254
265
  var limit = parseResult.GetValue(limitOption);
255
266
  var batchSize = parseResult.GetValue(batchSizeOption);
267
+ var retryAttempts = parseResult.GetValue(retryAttemptsOption);
256
268
  var verbose = parseResult.GetValue(verboseOption);
257
- await IngestAsync(input, dryRun, limit, batchSize, verbose, failFast);
269
+ await IngestAsync(input, dryRun, limit, batchSize, retryAttempts, verbose, failFast);
258
270
  });
259
271
 
260
272
  var deleteCommand = new Command("delete", "Delete the connection");
@@ -73,6 +73,7 @@ Use `dotnet run -- ingest` with:
73
73
  - `--fail-fast` (abort on the first item failure)
74
74
  - `--limit <n>` (ingest only N items)
75
75
  - `--batch-size <n>` (send up to N concurrent PUT requests per batch, default `1`, max `20`)
76
+ - `--retry-attempts <n>` (queue failed items and retry after the initial pass, default `2`, max `10`)
76
77
  - `--verbose` (print the exact payload sent to Graph)
77
78
 
78
79
  Note: `--dry-run` does not require Azure AD or connection settings.
@@ -62,6 +62,7 @@ Use `npm run ingest --` with:
62
62
  - `--fail-fast` (abort on the first item failure)
63
63
  - `--limit <n>` (ingest only N items)
64
64
  - `--batch-size <n>` (send up to N concurrent PUT requests per batch, default `1`, max `20`)
65
+ - `--retry-attempts <n>` (queue failed items and retry after the initial pass, default `2`, max `10`)
65
66
  - `--verbose` (print the exact payload sent to Graph)
66
67
 
67
68
  Note: `--dry-run` does not require CONNECTION_ID, but you still need it for real ingestion.
@@ -118,6 +118,14 @@ function parseBatchSize(value: string): number {
118
118
  return parsed;
119
119
  }
120
120
 
121
+ function parseRetryAttempts(value: string): number {
122
+ const parsed = Number.parseInt(value, 10);
123
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 10) {
124
+ throw new InvalidArgumentError("expected an integer between 0 and 10");
125
+ }
126
+ return parsed;
127
+ }
128
+
121
129
  function buildItemSource(path: string): ItemSource<<%= itemTypeName %>> {
122
130
  <% if (inputFormat === "rest") { -%>
123
131
  return new RestItemSource(resolveRestOptions(path));
@@ -224,6 +232,7 @@ async function ingest(options: {
224
232
  dryRun?: boolean;
225
233
  limit?: number;
226
234
  batchSize?: number;
235
+ retryAttempts?: number;
227
236
  verbose?: boolean;
228
237
  failFast?: boolean;
229
238
  }): Promise<void> {
@@ -237,6 +246,7 @@ async function ingest(options: {
237
246
  dryRun: options.dryRun,
238
247
  limit: options.limit,
239
248
  batchSize: options.batchSize,
249
+ retryAttempts: options.retryAttempts,
240
250
  verbose: options.verbose,
241
251
  failFast: options.failFast,
242
252
  toExternalItem
@@ -259,14 +269,16 @@ program
259
269
  .option("--fail-fast", "Abort on the first item failure")
260
270
  .option("--limit <n>", "Limit number of items", (value) => Number(value))
261
271
  .option("--batch-size <n>", "Number of concurrent PUT requests to send per batch (1-20)", parseBatchSize, 1)
272
+ .option("--retry-attempts <n>", "Retry failed items after initial pass (0-10, default 2)", parseRetryAttempts, 2)
262
273
  .option("--verbose", "Print payloads sent to Graph")
263
- .action((options: { input?: string; dryRun?: boolean; limit?: number; batchSize?: number; verbose?: boolean; failFast?: boolean }) =>
274
+ .action((options: { input?: string; dryRun?: boolean; limit?: number; batchSize?: number; retryAttempts?: number; verbose?: boolean; failFast?: boolean }) =>
264
275
  ingest({
265
276
  inputPath: options.input,
266
277
  dryRun: options.dryRun,
267
278
  failFast: options.failFast,
268
279
  limit: options.limit,
269
280
  batchSize: options.batchSize,
281
+ retryAttempts: options.retryAttempts,
270
282
  verbose: options.verbose,
271
283
  })
272
284
  );
@@ -41,6 +41,7 @@ export type IngestOptions<Item> = {
41
41
  dryRun?: boolean;
42
42
  limit?: number;
43
43
  batchSize?: number;
44
+ retryAttempts?: number;
44
45
  verbose?: boolean;
45
46
  failFast?: boolean;
46
47
  toExternalItem: (item: Item) => unknown;
@@ -229,24 +230,34 @@ export class ConnectorCore<Item> {
229
230
  if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 20) {
230
231
  throw new Error("Invalid batch size: expected integer between 1 and 20.");
231
232
  }
233
+ const retryAttempts = options.retryAttempts ?? DEFAULT_ITEM_RETRY_ATTEMPTS;
234
+ if (!Number.isInteger(retryAttempts) || retryAttempts < 0 || retryAttempts > MAX_ITEM_RETRY_ATTEMPTS) {
235
+ throw new Error(`Invalid retry attempts: expected integer between 0 and ${MAX_ITEM_RETRY_ATTEMPTS}.`);
236
+ }
237
+
238
+ type PendingItem = { index: number; id: string; item: Item; attempt: number };
232
239
 
233
240
  let count = 0;
234
241
  let successCount = 0;
235
- const failures: Array<{ index: number; id: string; message: string }> = [];
236
- const pending: Array<{ index: number; id: string; item: Item }> = [];
242
+ const failures: Array<{ index: number; id: string; attempts: number; message: string }> = [];
243
+ const pending: PendingItem[] = [];
244
+ let retryQueue: PendingItem[] = [];
237
245
 
238
- const flushBatch = async (): Promise<void> => {
239
- if (pending.length === 0) {
246
+ const flushBatch = async (batch: PendingItem[]): Promise<void> => {
247
+ if (batch.length === 0) {
240
248
  return;
241
249
  }
242
250
 
243
- for (const entry of pending) {
244
- console.log(`info: ingesting item ${entry.index} (id=${entry.id})`);
251
+ for (const entry of batch) {
252
+ if (entry.attempt === 0) {
253
+ console.log(`info: ingesting item ${entry.index} (id=${entry.id})`);
254
+ continue;
255
+ }
256
+ console.log(`info: retrying item ${entry.index} (id=${entry.id}) attempt ${entry.attempt}/${retryAttempts}`);
245
257
  }
246
258
 
247
- const currentBatch = pending.splice(0, pending.length);
248
259
  const results = await Promise.all(
249
- currentBatch.map(async (entry) => {
260
+ batch.map(async (entry) => {
250
261
  try {
251
262
  await this.putItem(options.connectionId, entry.item, Boolean(options.verbose));
252
263
  return { ...entry, success: true as const };
@@ -266,7 +277,13 @@ export class ConnectorCore<Item> {
266
277
 
267
278
  const message = result.error instanceof Error ? result.error.message : String(result.error);
268
279
  console.error(`error: failed item ${result.index} (id=${result.id})`);
269
- failures.push({ index: result.index, id: result.id, message });
280
+
281
+ if (!options.failFast && result.attempt < retryAttempts) {
282
+ retryQueue.push({ index: result.index, id: result.id, item: result.item, attempt: result.attempt + 1 });
283
+ continue;
284
+ }
285
+
286
+ failures.push({ index: result.index, id: result.id, attempts: result.attempt + 1, message });
270
287
  if (options.failFast && firstError === null) {
271
288
  firstError = result.error;
272
289
  }
@@ -282,9 +299,10 @@ export class ConnectorCore<Item> {
282
299
  const index = count + 1;
283
300
  const itemId = this.getItemId(item as Item);
284
301
  if (!options.dryRun) {
285
- pending.push({ index, id: itemId, item: item as Item });
302
+ pending.push({ index, id: itemId, item: item as Item, attempt: 0 });
286
303
  if (pending.length >= batchSize) {
287
- await flushBatch();
304
+ const batch = pending.splice(0, pending.length);
305
+ await flushBatch(batch);
288
306
  }
289
307
  } else if (options.verbose) {
290
308
  const payload = options.toExternalItem(item as Item) as any;
@@ -299,7 +317,36 @@ export class ConnectorCore<Item> {
299
317
  }
300
318
 
301
319
  if (!options.dryRun) {
302
- await flushBatch();
320
+ const batch = pending.splice(0, pending.length);
321
+ await flushBatch(batch);
322
+
323
+ for (let retryAttempt = 1; retryAttempt <= retryAttempts && retryQueue.length > 0; retryAttempt++) {
324
+ const queue = retryQueue;
325
+ retryQueue = [];
326
+ console.warn(`warn: retry pass ${retryAttempt}/${retryAttempts} for ${queue.length} item(s)`);
327
+
328
+ for (let index = 0; index < queue.length; index += batchSize) {
329
+ const batchSlice = queue.slice(index, index + batchSize);
330
+ await flushBatch(batchSlice);
331
+ }
332
+
333
+ if (retryQueue.length > 0 && retryAttempt < retryAttempts) {
334
+ const delayMs = computeRetryDelayMs(retryAttempt - 1);
335
+ console.warn(`warn: waiting ${delayMs}ms before next retry pass`);
336
+ await sleep(delayMs);
337
+ }
338
+ }
339
+
340
+ if (retryQueue.length > 0) {
341
+ for (const entry of retryQueue) {
342
+ failures.push({
343
+ index: entry.index,
344
+ id: entry.id,
345
+ attempts: entry.attempt,
346
+ message: `exhausted retry queue after ${entry.attempt} attempt(s)`,
347
+ });
348
+ }
349
+ }
303
350
  }
304
351
 
305
352
  if (!options.dryRun) {
@@ -311,7 +358,7 @@ export class ConnectorCore<Item> {
311
358
  if (failures.length > 0) {
312
359
  console.warn(`warn: ${failures.length} item(s) failed`);
313
360
  for (const failure of failures) {
314
- console.warn(`warn: failed item ${failure.index} (id=${failure.id}) - ${failure.message}`);
361
+ console.warn(`warn: failed item ${failure.index} (id=${failure.id}) after ${failure.attempts} attempt(s) - ${failure.message}`);
315
362
  }
316
363
  }
317
364
  }
@@ -518,6 +565,8 @@ const SCHEMA_POLL_DELAY_MS = 30000;
518
565
  const MAX_RETRIES = 6;
519
566
  const BASE_RETRY_DELAY_MS = 1000;
520
567
  const MAX_RETRY_DELAY_MS = 30000;
568
+ const DEFAULT_ITEM_RETRY_ATTEMPTS = 2;
569
+ const MAX_ITEM_RETRY_ATTEMPTS = 10;
521
570
  const SCHEMA_READY_STATES = new Set(["completed", "ready", "succeeded", "success"]);
522
571
  const SCHEMA_PENDING_STATES = new Set(["inprogress", "pending", "running", "updating"]);
523
572
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wictorwilen/cocogen",
3
- "version": "1.1.0-preview.10",
3
+ "version": "1.1.0-preview.11",
4
4
  "description": "TypeSpec-driven Microsoft Copilot connector generator",
5
5
  "license": "MIT",
6
6
  "author": "Wictor Wilén <wictor@wictorwilen.se>",