@wictorwilen/cocogen 1.0.0

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/RELEASING.md +36 -0
  4. package/THIRD_PARTY_NOTICES.md +11 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +288 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/emit/emit.d.ts +3 -0
  10. package/dist/emit/emit.d.ts.map +1 -0
  11. package/dist/emit/emit.js +13 -0
  12. package/dist/emit/emit.js.map +1 -0
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +6 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/init/init.d.ts +34 -0
  18. package/dist/init/init.d.ts.map +1 -0
  19. package/dist/init/init.js +886 -0
  20. package/dist/init/init.js.map +1 -0
  21. package/dist/init/template.d.ts +2 -0
  22. package/dist/init/template.d.ts.map +1 -0
  23. package/dist/init/template.js +19 -0
  24. package/dist/init/template.js.map +1 -0
  25. package/dist/init/templates/dotnet/.env.example.ejs +12 -0
  26. package/dist/init/templates/dotnet/.gitignore.ejs +6 -0
  27. package/dist/init/templates/dotnet/Datasource/CsvItemSource.cs.ejs +42 -0
  28. package/dist/init/templates/dotnet/Datasource/IItemSource.cs.ejs +12 -0
  29. package/dist/init/templates/dotnet/Generated/Constants.cs.ejs +17 -0
  30. package/dist/init/templates/dotnet/Generated/CsvParser.cs.ejs +119 -0
  31. package/dist/init/templates/dotnet/Generated/FromCsvRow.cs.ejs +14 -0
  32. package/dist/init/templates/dotnet/Generated/ItemPayload.cs.ejs +41 -0
  33. package/dist/init/templates/dotnet/Generated/Model.cs.ejs +28 -0
  34. package/dist/init/templates/dotnet/Generated/PersonEntityDefaults.cs.ejs +48 -0
  35. package/dist/init/templates/dotnet/Generated/PropertyTransforms.cs.ejs +22 -0
  36. package/dist/init/templates/dotnet/Generated/SchemaPayload.cs.ejs +18 -0
  37. package/dist/init/templates/dotnet/PersonEntityOverrides.cs.ejs +49 -0
  38. package/dist/init/templates/dotnet/Program.commandline.cs.ejs +426 -0
  39. package/dist/init/templates/dotnet/Program.cs.ejs +487 -0
  40. package/dist/init/templates/dotnet/README.md.ejs +56 -0
  41. package/dist/init/templates/dotnet/appsettings.json.ejs +21 -0
  42. package/dist/init/templates/dotnet/package.json.ejs +7 -0
  43. package/dist/init/templates/dotnet/project.csproj.ejs +29 -0
  44. package/dist/init/templates/dotnet/tspconfig.yaml.ejs +2 -0
  45. package/dist/init/templates/ts/.env.example.ejs +20 -0
  46. package/dist/init/templates/ts/README.md.ejs +54 -0
  47. package/dist/init/templates/ts/package.json.ejs +25 -0
  48. package/dist/init/templates/ts/src/cli.ts.ejs +299 -0
  49. package/dist/init/templates/ts/src/datasource/csvItemSource.ts.ejs +25 -0
  50. package/dist/init/templates/ts/src/datasource/itemSource.ts.ejs +8 -0
  51. package/dist/init/templates/ts/src/generated/constants.ts.ejs +10 -0
  52. package/dist/init/templates/ts/src/generated/csv.ts.ejs +44 -0
  53. package/dist/init/templates/ts/src/generated/fromCsvRow.ts.ejs +43 -0
  54. package/dist/init/templates/ts/src/generated/index.ts.ejs +5 -0
  55. package/dist/init/templates/ts/src/generated/itemPayload.ts.ejs +21 -0
  56. package/dist/init/templates/ts/src/generated/model.ts.ejs +16 -0
  57. package/dist/init/templates/ts/src/generated/personEntityDefaults.ts.ejs +33 -0
  58. package/dist/init/templates/ts/src/generated/propertyTransforms.ts.ejs +23 -0
  59. package/dist/init/templates/ts/src/generated/schemaPayload.ts.ejs +1 -0
  60. package/dist/init/templates/ts/src/index.ts.ejs +1 -0
  61. package/dist/init/templates/ts/src/personEntityOverrides.ts.ejs +36 -0
  62. package/dist/init/templates/ts/tsconfig.json.ejs +13 -0
  63. package/dist/init/templates/ts/tspconfig.yaml.ejs +2 -0
  64. package/dist/ir.d.ts +49 -0
  65. package/dist/ir.d.ts.map +1 -0
  66. package/dist/ir.js +2 -0
  67. package/dist/ir.js.map +1 -0
  68. package/dist/tsp/init-tsp.d.ts +14 -0
  69. package/dist/tsp/init-tsp.d.ts.map +1 -0
  70. package/dist/tsp/init-tsp.js +126 -0
  71. package/dist/tsp/init-tsp.js.map +1 -0
  72. package/dist/tsp/loader.d.ts +8 -0
  73. package/dist/tsp/loader.d.ts.map +1 -0
  74. package/dist/tsp/loader.js +264 -0
  75. package/dist/tsp/loader.js.map +1 -0
  76. package/dist/typespec/decorators.d.ts +14 -0
  77. package/dist/typespec/decorators.d.ts.map +1 -0
  78. package/dist/typespec/decorators.js +139 -0
  79. package/dist/typespec/decorators.js.map +1 -0
  80. package/dist/typespec/state.d.ts +37 -0
  81. package/dist/typespec/state.d.ts.map +1 -0
  82. package/dist/typespec/state.js +13 -0
  83. package/dist/typespec/state.js.map +1 -0
  84. package/dist/validate/validator.d.ts +9 -0
  85. package/dist/validate/validator.d.ts.map +1 -0
  86. package/dist/validate/validator.js +204 -0
  87. package/dist/validate/validator.js.map +1 -0
  88. package/package.json +66 -0
  89. package/typespec/main.tsp +117 -0
  90. package/typespec/tsp-index.js +6 -0
@@ -0,0 +1,487 @@
1
+ using Azure.Identity;
2
+ using Azure.Core;
3
+ using Microsoft.Graph;
4
+ using Microsoft.Graph.Models.ExternalConnectors;
5
+ using Microsoft.Graph.Models.ODataErrors;
6
+ using Microsoft.Kiota.Abstractions;
7
+ using Microsoft.Kiota.Abstractions.Serialization;
8
+ using System.Net.Http.Headers;
9
+ using System.Text;
10
+ using System.Text.Json;
11
+
12
+ using <%= namespaceName %>.Datasource;
13
+ using <%= namespaceName %>.Generated;
14
+
15
+ var command = args.Length > 0 ? args[0] : "";
16
+
17
+ static string RequiredEnv(string name)
18
+ {
19
+ var value = Environment.GetEnvironmentVariable(name);
20
+ if (string.IsNullOrWhiteSpace(value))
21
+ throw new InvalidOperationException($"Missing env var: {name}");
22
+ return value;
23
+ }
24
+
25
+ static GraphServiceClient CreateGraphClient()
26
+ {
27
+ var credential = CreateCredential();
28
+
29
+ var graph = new GraphServiceClient(credential, new[] { "https://graph.microsoft.com/.default" });
30
+ graph.RequestAdapter.BaseUrl = SchemaConstants.GraphBaseUrl;
31
+ return graph;
32
+ }
33
+
34
+ static ClientSecretCredential CreateCredential()
35
+ {
36
+ var tenantId = RequiredEnv("TENANT_ID");
37
+ var clientId = RequiredEnv("CLIENT_ID");
38
+ var clientSecret = RequiredEnv("CLIENT_SECRET");
39
+ return new ClientSecretCredential(tenantId, clientId, clientSecret);
40
+ }
41
+
42
+ static async Task<string> GetAccessTokenAsync(ClientSecretCredential credential)
43
+ {
44
+ var token = await credential.GetTokenAsync(
45
+ new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" })
46
+ );
47
+ return token.Token;
48
+ }
49
+
50
+ static async Task<HttpResponseMessage> GraphRequestAsync(ClientSecretCredential credential, HttpMethod method, string url, object? body = null)
51
+ {
52
+ using var http = new HttpClient();
53
+ var token = await GetAccessTokenAsync(credential);
54
+ http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
55
+
56
+ var request = new HttpRequestMessage(method, url);
57
+ if (body is not null)
58
+ {
59
+ var json = JsonSerializer.Serialize(body);
60
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
61
+ }
62
+
63
+ return await http.SendAsync(request);
64
+ }
65
+
66
+ static bool IsStatus(ApiException ex, int statusCode)
67
+ {
68
+ return ex.ResponseStatusCode is int sc && sc == statusCode;
69
+ }
70
+
71
+ static async Task EnsureConnectionAsync(GraphServiceClient graph, string connectionId)
72
+ {
73
+ var name = RequiredEnv("CONNECTION_NAME");
74
+ var description = RequiredEnv("CONNECTION_DESCRIPTION");
75
+
76
+ try
77
+ {
78
+ await graph.External.Connections[connectionId].GetAsync();
79
+ return;
80
+ }
81
+ using Azure.Core;
82
+ using Azure.Identity;
83
+ using Microsoft.Extensions.Configuration;
84
+ using Microsoft.Graph;
85
+ using Microsoft.Graph.Models.ExternalConnectors;
86
+ using Microsoft.Graph.Models.ODataErrors;
87
+ using Microsoft.Kiota.Abstractions;
88
+ using Microsoft.Kiota.Abstractions.Serialization;
89
+ using System.CommandLine;
90
+ using System.Net.Http.Headers;
91
+ using System.Text;
92
+ using System.Text.Json;
93
+
94
+ using <%= namespaceName %>.Datasource;
95
+ using <%= namespaceName %>.Schema;
96
+
97
+ var configuration = new ConfigurationBuilder()
98
+ .SetBasePath(Directory.GetCurrentDirectory())
99
+ .AddJsonFile("appsettings.json", optional: true)
100
+ .AddJsonFile("appsettings.Development.json", optional: true)
101
+ .AddEnvironmentVariables()
102
+ .Build();
103
+
104
+ string RequiredSetting(string key)
105
+ {
106
+ var value = configuration[key];
107
+ if (string.IsNullOrWhiteSpace(value))
108
+ throw new InvalidOperationException($"Missing configuration: {key}");
109
+ return value;
110
+ }
111
+
112
+ string ConnectionId() => RequiredSetting("Connection:Id");
113
+ string ConnectionName() => RequiredSetting("Connection:Name");
114
+ string ConnectionDescription() => RequiredSetting("Connection:Description");
115
+
116
+ ClientSecretCredential CreateCredential()
117
+ {
118
+ var tenantId = RequiredSetting("AzureAd:TenantId");
119
+ var clientId = RequiredSetting("AzureAd:ClientId");
120
+ var clientSecret = RequiredSetting("AzureAd:ClientSecret");
121
+ return new ClientSecretCredential(tenantId, clientId, clientSecret);
122
+ }
123
+
124
+ GraphServiceClient CreateGraphClient()
125
+ {
126
+ var credential = CreateCredential();
127
+ var graph = new GraphServiceClient(credential, new[] { "https://graph.microsoft.com/.default" });
128
+ graph.RequestAdapter.BaseUrl = SchemaConstants.GraphBaseUrl;
129
+ return graph;
130
+ }
131
+
132
+ async Task<string> GetAccessTokenAsync(ClientSecretCredential credential)
133
+ {
134
+ var token = await credential.GetTokenAsync(
135
+ new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" })
136
+ );
137
+ return token.Token;
138
+ }
139
+
140
+ async Task<HttpResponseMessage> GraphRequestAsync(ClientSecretCredential credential, HttpMethod method, string url, object? body = null)
141
+ {
142
+ using var http = new HttpClient();
143
+ var token = await GetAccessTokenAsync(credential);
144
+ http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
145
+
146
+ var request = new HttpRequestMessage(method, url);
147
+ if (body is not null)
148
+ {
149
+ var json = JsonSerializer.Serialize(body);
150
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
151
+ }
152
+
153
+ return await http.SendAsync(request);
154
+ }
155
+
156
+ bool IsStatus(ApiException ex, int statusCode)
157
+ {
158
+ return ex.ResponseStatusCode is int sc && sc == statusCode;
159
+ }
160
+
161
+ async Task EnsureConnectionAsync(GraphServiceClient graph, string connectionId)
162
+ {
163
+ try
164
+ {
165
+ await graph.External.Connections[connectionId].GetAsync();
166
+ return;
167
+ }
168
+ catch (ApiException ex) when (IsStatus(ex, 404))
169
+ {
170
+ // Create below.
171
+ }
172
+
173
+ var connection = new ExternalConnection
174
+ {
175
+ Id = connectionId,
176
+ Name = ConnectionName(),
177
+ Description = ConnectionDescription(),
178
+ };
179
+
180
+ if (!string.IsNullOrWhiteSpace(SchemaConstants.ContentCategory))
181
+ {
182
+ // contentCategory is currently only exposed on the /beta endpoint.
183
+ connection.AdditionalData ??= new Dictionary<string, object>();
184
+ connection.AdditionalData["contentCategory"] = SchemaConstants.ContentCategory!;
185
+ }
186
+
187
+ await graph.External.Connections.PostAsync(connection);
188
+ }
189
+
190
+ async Task PatchSchemaAsync(GraphServiceClient graph, string connectionId)
191
+ {
192
+ var schema = SchemaPayload.BuildSchema();
193
+ await graph.External.Connections[connectionId].Schema.PatchAsync(schema);
194
+ }
195
+
196
+ async Task ProvisionAsync()
197
+ {
198
+ var graph = CreateGraphClient();
199
+ var connectionId = ConnectionId();
200
+
201
+ await EnsureConnectionAsync(graph, connectionId);
202
+ await PatchSchemaAsync(graph, connectionId);
203
+ <% if (isPeopleConnector) { -%>
204
+ await RegisterProfileSourceAsync(connectionId);
205
+ <% } -%>
206
+
207
+ Console.WriteLine("ok: provisioned");
208
+ }
209
+
210
+ async Task PutItemAsync(GraphServiceClient graph, string connectionId, Item item)
211
+ {
212
+ var itemId = ItemPayload.GetItemId(item);
213
+
214
+ var externalItem = ItemPayload.ToExternalItem(item);
215
+
216
+ // NOTE: The external connectors surface may not expose a typed PutAsync on all SDK versions.
217
+ // We still use the Graph SDK's request adapter and models for a strongly-typed payload.
218
+ var requestInfo = new RequestInformation
219
+ {
220
+ HttpMethod = Method.PUT,
221
+ UrlTemplate = "{+baseurl}/external/connections/{connectionId}/items/{itemId}",
222
+ PathParameters = new Dictionary<string, object>
223
+ {
224
+ { "baseurl", graph.RequestAdapter.BaseUrl },
225
+ { "connectionId", connectionId },
226
+ { "itemId", itemId },
227
+ },
228
+ };
229
+
230
+ requestInfo.Headers.Add("Accept", "application/json");
231
+ requestInfo.SetContentFromParsable(graph.RequestAdapter, "application/json", externalItem);
232
+
233
+ var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
234
+ {
235
+ { "4XX", ODataError.CreateFromDiscriminatorValue },
236
+ { "5XX", ODataError.CreateFromDiscriminatorValue },
237
+ };
238
+
239
+ await graph.RequestAdapter.SendAsync<ExternalItem>(
240
+ requestInfo,
241
+ ExternalItem.CreateFromDiscriminatorValue,
242
+ errorMapping
243
+ );
244
+ }
245
+
246
+ async Task IngestAsync(string? csvPath)
247
+ {
248
+ var graph = CreateGraphClient();
249
+ var connectionId = ConnectionId();
250
+
251
+ var configuredCsv = configuration["Csv:Path"] ?? "data.csv";
252
+ var path = string.IsNullOrWhiteSpace(csvPath) ? configuredCsv : csvPath;
253
+
254
+ IItemSource source = new CsvItemSource(path);
255
+
256
+ var count = 0;
257
+ await foreach (var item in source.GetItemsAsync())
258
+ {
259
+ await PutItemAsync(graph, connectionId, item);
260
+ count++;
261
+ }
262
+
263
+ Console.WriteLine($"ok: ingested {count} item(s)");
264
+ }
265
+
266
+ <% if (isPeopleConnector) { -%>
267
+ async Task RegisterProfileSourceAsync(string connectionId)
268
+ {
269
+ var credential = CreateCredential();
270
+ var webUrl = RequiredSetting("ProfileSource:WebUrl");
271
+ var displayName = configuration["ProfileSource:DisplayName"] ?? ConnectionName();
272
+ var kind = configuration["ProfileSource:Kind"];
273
+
274
+ var payload = new Dictionary<string, object?>
275
+ {
276
+ ["sourceId"] = connectionId,
277
+ ["displayName"] = displayName,
278
+ ["webUrl"] = webUrl,
279
+ };
280
+
281
+ if (!string.IsNullOrWhiteSpace(kind))
282
+ payload["kind"] = kind;
283
+
284
+ var create = await GraphRequestAsync(
285
+ credential,
286
+ HttpMethod.Post,
287
+ $"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources",
288
+ payload
289
+ );
290
+
291
+ if (!create.IsSuccessStatusCode && create.StatusCode != System.Net.HttpStatusCode.Conflict)
292
+ {
293
+ var text = await create.Content.ReadAsStringAsync();
294
+ throw new InvalidOperationException($"Failed to register profile source (HTTP {(int)create.StatusCode}): {text}");
295
+ }
296
+
297
+ var sourceUrl = $"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources(sourceId='{connectionId}')";
298
+ await UpdateProfileSourcePrecedenceAsync(credential, sourceUrl, prepend: true);
299
+ }
300
+
301
+ async Task UnregisterProfileSourceAsync(string connectionId)
302
+ {
303
+ var credential = CreateCredential();
304
+ var sourceUrl = $"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources(sourceId='{connectionId}')";
305
+
306
+ await UpdateProfileSourcePrecedenceAsync(credential, sourceUrl, prepend: false);
307
+
308
+ var res = await GraphRequestAsync(
309
+ credential,
310
+ HttpMethod.Delete,
311
+ $"{SchemaConstants.GraphBaseUrl}/admin/people/profileSources(sourceId='{connectionId}')"
312
+ );
313
+
314
+ if (!res.IsSuccessStatusCode && res.StatusCode != System.Net.HttpStatusCode.NotFound)
315
+ {
316
+ var text = await res.Content.ReadAsStringAsync();
317
+ throw new InvalidOperationException($"Failed to delete profile source (HTTP {(int)res.StatusCode}): {text}");
318
+ }
319
+ }
320
+
321
+ async Task UpdateProfileSourcePrecedenceAsync(ClientSecretCredential credential, string sourceUrl, bool prepend)
322
+ {
323
+ var res = await GraphRequestAsync(
324
+ credential,
325
+ HttpMethod.Get,
326
+ $"{SchemaConstants.GraphBaseUrl}/admin/people/profilePropertySettings"
327
+ );
328
+
329
+ if (!res.IsSuccessStatusCode)
330
+ {
331
+ var text = await res.Content.ReadAsStringAsync();
332
+ throw new InvalidOperationException($"Failed to list profile property settings (HTTP {(int)res.StatusCode}): {text}");
333
+ }
334
+
335
+ var json = await res.Content.ReadAsStringAsync();
336
+ using var doc = JsonDocument.Parse(json);
337
+ if (!doc.RootElement.TryGetProperty("value", out var values) || values.ValueKind != JsonValueKind.Array)
338
+ return;
339
+
340
+ foreach (var entry in values.EnumerateArray())
341
+ {
342
+ if (!entry.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String)
343
+ continue;
344
+
345
+ var id = idProp.GetString() ?? string.Empty;
346
+ if (string.IsNullOrWhiteSpace(id)) continue;
347
+
348
+ List<string> existing = new();
349
+ if (entry.TryGetProperty("prioritizedSourceUrls", out var urls) && urls.ValueKind == JsonValueKind.Array)
350
+ {
351
+ foreach (var url in urls.EnumerateArray())
352
+ {
353
+ if (url.ValueKind == JsonValueKind.String)
354
+ existing.Add(url.GetString() ?? "");
355
+ }
356
+ }
357
+
358
+ existing = existing.Where((u) => !string.IsNullOrWhiteSpace(u) && u != sourceUrl).ToList();
359
+ var updated = prepend ? new[] { sourceUrl }.Concat(existing).ToList() : existing;
360
+
361
+ var patch = new Dictionary<string, object?>
362
+ {
363
+ ["@odata.type"] = "#microsoft.graph.profilePropertySetting",
364
+ ["prioritizedSourceUrls"] = updated,
365
+ };
366
+
367
+ var patchRes = await GraphRequestAsync(
368
+ credential,
369
+ HttpMethod.Patch,
370
+ $"{SchemaConstants.GraphBaseUrl}/admin/people/profilePropertySettings/{id}",
371
+ patch
372
+ );
373
+
374
+ if (!patchRes.IsSuccessStatusCode)
375
+ {
376
+ var text = await patchRes.Content.ReadAsStringAsync();
377
+ throw new InvalidOperationException($"Failed to update profile property setting {id} (HTTP {(int)patchRes.StatusCode}): {text}");
378
+ }
379
+ }
380
+ }
381
+ <% } -%>
382
+
383
+ async Task DeleteConnectionAsync()
384
+ {
385
+ var graph = CreateGraphClient();
386
+ var connectionId = ConnectionId();
387
+
388
+ <% if (isPeopleConnector) { -%>
389
+ await UnregisterProfileSourceAsync(connectionId);
390
+ <% } -%>
391
+
392
+ try
393
+ {
394
+ await graph.External.Connections[connectionId].DeleteAsync();
395
+ }
396
+ catch (ApiException ex) when (IsStatus(ex, 404))
397
+ {
398
+ // Already deleted.
399
+ }
400
+ Console.WriteLine("ok: deleted");
401
+ }
402
+
403
+ var csvOption = new Option<string>("--csv", description: "CSV path");
404
+
405
+ var root = new RootCommand("Connector CLI generated by gcgen");
406
+
407
+ var provisionCommand = new Command("provision", "Create or update the connection and schema");
408
+ provisionCommand.SetHandler(async () => await ProvisionAsync());
409
+
410
+ var ingestCommand = new Command("ingest", "Ingest items from CSV");
411
+ ingestCommand.AddOption(csvOption);
412
+ ingestCommand.SetHandler(async (string? csv) => await IngestAsync(csv), csvOption);
413
+
414
+ var deleteCommand = new Command("delete", "Delete the connection");
415
+ deleteCommand.SetHandler(async () => await DeleteConnectionAsync());
416
+
417
+ root.AddCommand(provisionCommand);
418
+ root.AddCommand(ingestCommand);
419
+ root.AddCommand(deleteCommand);
420
+
421
+ <% if (isPeopleConnector) { -%>
422
+ var registerCommand = new Command("register-profile-source", "Register the connection as a profile source");
423
+ registerCommand.SetHandler(async () => await RegisterProfileSourceAsync(ConnectionId()));
424
+ root.AddCommand(registerCommand);
425
+ <% } -%>
426
+
427
+ return await root.InvokeAsync(args);
428
+ {
429
+ var graph = CreateGraphClient();
430
+ var connectionId = RequiredEnv("CONNECTION_ID");
431
+
432
+ var csvArg = GetArgValue("--csv", allArgs);
433
+ var csvPath = !string.IsNullOrWhiteSpace(csvArg)
434
+ ? csvArg
435
+ : (Environment.GetEnvironmentVariable("CSV_PATH") ?? "data.csv");
436
+
437
+ IItemSource source = new CsvItemSource(csvPath);
438
+
439
+ var count = 0;
440
+ await foreach (var item in source.GetItemsAsync())
441
+ {
442
+ await PutItemAsync(graph, connectionId, item);
443
+ count++;
444
+ }
445
+
446
+ Console.WriteLine($"ok: ingested {count} item(s)");
447
+ }
448
+
449
+ try
450
+ {
451
+ if (command == "provision")
452
+ {
453
+ await ProvisionAsync();
454
+ }
455
+ else if (command == "ingest")
456
+ {
457
+ await IngestAsync(args);
458
+ }
459
+ else if (command == "register-profile-source")
460
+ {
461
+ await RegisterProfileSourceAsync(RequiredEnv("CONNECTION_ID"));
462
+ }
463
+ else if (command == "delete")
464
+ {
465
+ await DeleteConnectionAsync();
466
+ }
467
+ else
468
+ {
469
+ Console.WriteLine("Usage:");
470
+ Console.WriteLine(" dotnet run -- provision");
471
+ Console.WriteLine(" dotnet run -- ingest --csv path/to.csv");
472
+ Console.WriteLine(" dotnet run -- register-profile-source");
473
+ Console.WriteLine(" dotnet run -- delete");
474
+ Environment.ExitCode = 1;
475
+ }
476
+ }
477
+ catch (ApiException ex)
478
+ {
479
+ Console.Error.WriteLine("error: Graph request failed");
480
+ Console.Error.WriteLine(ex.Message);
481
+ Environment.ExitCode = 1;
482
+ }
483
+ catch (Exception ex)
484
+ {
485
+ Console.Error.WriteLine("error: " + ex.Message);
486
+ Environment.ExitCode = 1;
487
+ }
@@ -0,0 +1,56 @@
1
+ Generated by cocogen.
2
+
3
+ ## Quickstart
4
+ 1) Edit `appsettings.json` with your tenant/app/connection details.
5
+ 2) `dotnet build`
6
+ 3) `dotnet run -- provision`
7
+ 4) `dotnet run -- ingest --csv ./data.csv`
8
+ 5) (Optional) `dotnet run -- delete`
9
+
10
+ <% if (isPeopleConnector) { -%>
11
+ Note: this is a People connector (preview). It uses Graph beta endpoints and registers the connection as a profile source during `provision`.
12
+ <% } -%>
13
+
14
+ ## Graph API version note
15
+ If your schema sets `@coco.connection({ contentCategory: "..." })`, provisioning must use Microsoft Graph **/beta** because `externalConnection.contentCategory` is currently exposed on the beta endpoint.
16
+
17
+ ## Multiple connections
18
+ This generated CLI currently targets a single connection ID from configuration. Multi-connection support is planned for a future version.
19
+
20
+ ## Requirements
21
+ - .NET 10 SDK
22
+ - Microsoft Entra app registration with application permissions:
23
+ - `ExternalConnection.ReadWrite.OwnedBy`
24
+ - `ExternalItem.ReadWrite.OwnedBy`
25
+ <% if (isPeopleConnector) { -%>
26
+ - `PeopleSettings.ReadWrite.All` (required for profile source registration)
27
+ <% } -%>
28
+
29
+ ## TypeSpec editor support
30
+ This project includes `tspconfig.yaml` and a `package.json` with `@wictorwilen/cocogen` as a dev dependency so VS Code can resolve `using coco;`.
31
+ Run `npm install` in this folder to fetch the TypeSpec library.
32
+
33
+ ## Customizing the project
34
+ - **Schema and fields**: edit `schema.tsp` (copied into this folder) and run `cocogen update --out .` to regenerate `Schema/`.
35
+ - **Ingestion**: replace the datasource in `Datasource/` (CSV is the default) and wire it in `Program.cs`.
36
+ - **People connectors**: customize entity payloads in `Schema/PersonEntityOverrides.cs` (kept on updates).
37
+ - **Property transforms**: edit `Schema/PropertyTransforms.cs` to customize per-field parsing/mapping.
38
+ - **Connection defaults**: `@coco.connection` can set `connectionId` and `connectionDescription` defaults for `appsettings.json`.
39
+ - **Profile source defaults**: `@coco.profileSource` sets defaults for `ProfileSource` settings (people connectors only).
40
+ - **Access control**: edit `Schema/ItemPayload.cs` to change ACL behavior.
41
+
42
+ ## Ingest debugging flags
43
+ Use `dotnet run -- ingest` with:
44
+ - `--dry-run` (build payloads without sending)
45
+ - `--limit <n>` (ingest only N items)
46
+ - `--verbose` (print the exact payload sent to Graph)
47
+
48
+ Note: `--dry-run` does not require Azure AD or connection settings.
49
+
50
+ ## Switching from CSV to another datasource
51
+ 1) Implement `IItemSource` in `Datasource/`.
52
+ 2) If your source yields raw records, map them to `Item` using `FromCsvRow`-style logic.
53
+ 3) Update `Program.cs` to instantiate your new source instead of `CsvItemSource`.
54
+
55
+ Tip: keep the `IAsyncEnumerable<Item>` pattern for large datasets.
56
+
@@ -0,0 +1,21 @@
1
+ {
2
+ "AzureAd": {
3
+ "TenantId": "",
4
+ "ClientId": "",
5
+ "ClientSecret": ""
6
+ },
7
+ "Connection": {
8
+ "Id": "<%= connectionId ?? "my-connection-id" %>",
9
+ "Name": "<%= itemTypeName %>",
10
+ "Description": "<%= connectionDescription ?? `${itemTypeName} connector generated by cocogen` %>"
11
+ },
12
+ "Csv": {
13
+ "Path": "data.csv"
14
+ }<% if (isPeopleConnector) { -%>,
15
+ "ProfileSource": {
16
+ "WebUrl": "<%= profileSourceWebUrl ?? "https://example.com/people-data" %>",
17
+ "DisplayName": "<%= profileSourceDisplayName ?? itemTypeName %>",
18
+ "Priority": "<%= profileSourcePriority ?? "first" %>"
19
+ }
20
+ <% } -%>
21
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "<%= projectName %>-typespec",
3
+ "private": true,
4
+ "devDependencies": {
5
+ "@wictorwilen/cocogen": "^1.0.0"
6
+ }
7
+ }
@@ -0,0 +1,29 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+
3
+ <PropertyGroup>
4
+ <OutputType>Exe</OutputType>
5
+ <TargetFramework>net10.0</TargetFramework>
6
+ <ImplicitUsings>enable</ImplicitUsings>
7
+ <Nullable>enable</Nullable>
8
+ <LangVersion>latest</LangVersion>
9
+ </PropertyGroup>
10
+
11
+ <ItemGroup>
12
+ <PackageReference Include="Azure.Identity" Version="1.17.1" />
13
+ <% if (graphApiVersion === "beta") { -%>
14
+ <PackageReference Include="Microsoft.Graph.Beta" Version="5.129.0-preview" />
15
+ <% } else { -%>
16
+ <PackageReference Include="Microsoft.Graph" Version="5.100.0" />
17
+ <% } -%>
18
+ <PackageReference Include="CsvHelper" Version="33.1.0" />
19
+ <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
20
+ <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
21
+ <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
22
+ <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.2" />
23
+ </ItemGroup>
24
+
25
+ <ItemGroup>
26
+ <None Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
27
+ </ItemGroup>
28
+
29
+ </Project>
@@ -0,0 +1,2 @@
1
+ imports:
2
+ - "@wictorwilen/cocogen"
@@ -0,0 +1,20 @@
1
+ # Azure AD app credentials
2
+ TENANT_ID=
3
+ CLIENT_ID=
4
+ CLIENT_SECRET=
5
+
6
+ # Graph external connection
7
+ CONNECTION_ID=<%= connectionId ?? "my-connection-id" %>
8
+ CONNECTION_NAME=<%= itemTypeName %>
9
+ CONNECTION_DESCRIPTION=<%= connectionDescription ?? `${itemTypeName} connector generated by cocogen` %>
10
+
11
+ <% if (isPeopleConnector) { -%>
12
+ # People connector profile source (required for contentCategory=people)
13
+ PROFILE_SOURCE_WEB_URL=<%= profileSourceWebUrl ?? "https://example.com/people-data" %>
14
+ # Optional overrides
15
+ # PROFILE_SOURCE_DISPLAY_NAME=<%= profileSourceDisplayName ?? itemTypeName %>
16
+ # PROFILE_SOURCE_PRIORITY=<%= profileSourcePriority ?? "first" %>
17
+ <% } -%>
18
+
19
+ # CSV ingestion
20
+ CSV_PATH=data.csv
@@ -0,0 +1,54 @@
1
+ Generated by cocogen.
2
+
3
+ ## Quickstart
4
+ 1) Copy `.env.example` to `.env` and fill in values.
5
+ 2) `npm install`
6
+ 3) `npm run provision`
7
+ 4) `npm run ingest`
8
+ 5) (Optional) `npm run delete`
9
+
10
+ <% if (isPeopleConnector) { -%>
11
+ Note: this is a People connector (preview). It uses Graph beta endpoints and registers the connection as a profile source during `provision`.
12
+ <% } -%>
13
+
14
+ ## Graph API version note
15
+ If your schema sets `@coco.connection({ contentCategory: "..." })`, provisioning must use Microsoft Graph **/beta** because `externalConnection.contentCategory` is currently exposed on the beta endpoint.
16
+
17
+ ## Requirements
18
+ - Microsoft Entra app registration with application permissions:
19
+ - `ExternalConnection.ReadWrite.OwnedBy`
20
+ - `ExternalItem.ReadWrite.OwnedBy`
21
+ <% if (isPeopleConnector) { -%>
22
+ - `PeopleSettings.ReadWrite.All` (required for profile source registration)
23
+ <% } -%>
24
+
25
+ ## TypeSpec editor support
26
+ This project includes `tspconfig.yaml` and a devDependency on `@wictorwilen/cocogen` so VS Code can resolve `using coco;`.
27
+ Run `npm install` to fetch the TypeSpec library.
28
+
29
+ ## Customizing the project
30
+ - **Schema and fields**: edit `schema.tsp` (copied into this folder) and run `cocogen update --out .` to regenerate `src/schema`.
31
+ - **Ingestion**: replace the datasource in `src/datasource` (CSV is the default) and wire it in `src/cli.ts`.
32
+ - **People connectors**: customize entity payloads in `src/schema/personEntityOverrides.ts` (kept on updates).
33
+ - **Property transforms**: edit `src/schema/propertyTransforms.ts` to customize per-field parsing/mapping.
34
+ - **Connection defaults**: `@coco.connection` can set `connectionId` and `connectionDescription` defaults for `.env.example`.
35
+ - **Profile source defaults**: `@coco.profileSource` sets defaults for `PROFILE_SOURCE_*` (people connectors only).
36
+ - **Access control**: edit `src/schema/itemPayload.ts` to change ACL behavior.
37
+
38
+ ## Ingest debugging flags
39
+ Use `npm run ingest --` with:
40
+ - `--dry-run` (build payloads without sending)
41
+ - `--limit <n>` (ingest only N items)
42
+ - `--verbose` (print the exact payload sent to Graph)
43
+
44
+ Note: `--dry-run` does not require CONNECTION_ID, but you still need it for real ingestion.
45
+
46
+ ## Switching from CSV to another datasource
47
+ 1) Implement `ItemSource` in `src/datasource`.
48
+ 2) If your source yields raw records, map them to `Item` using `fromCsvRow`-style logic.
49
+ 3) Update `src/cli.ts` to instantiate your new source instead of `CsvItemSource`.
50
+
51
+ Tip: keep the streaming `AsyncIterable<Item>` pattern for large datasets.
52
+
53
+ ## Multiple connections
54
+ This generated CLI currently targets a single connection ID from environment variables. Multi-connection support is planned for a future version.