@uns-kit/cli 2.0.18 → 2.0.20

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/README.md CHANGED
@@ -43,9 +43,27 @@ pnpm run dev
43
43
 
44
44
  When `git` is available on your PATH the scaffold also initializes a fresh repository so you can commit immediately.
45
45
 
46
+ ### Create from a service bundle
47
+
48
+ ```bash
49
+ uns-kit create --bundle ./service.bundle.json
50
+ uns-kit create --bundle ./service.bundle.json --dest ./my-dir
51
+ uns-kit create --bundle ./service.bundle.json --dest . --allow-existing
52
+ ```
53
+
54
+ Bundle-driven create uses `service.bundle.json` as the source of truth. The CLI:
55
+
56
+ - scaffolds the base TypeScript app from the existing default template
57
+ - copies the original bundle into the project root as `service.bundle.json`
58
+ - generates `SERVICE_SPEC.md` and `AGENTS.md`
59
+ - applies supported bundle features such as `vscode` and `devops`
60
+
61
+ When `--bundle` is used, the default destination is `./<metadata.name>`. The TypeScript CLI only accepts bundles with `scaffold.stack = "ts"` and currently supports `scaffold.template = "default"` for this MVP. If the bundle targets Python instead, use `uns-kit-py create --bundle ...`.
62
+
46
63
  ## Commands
47
64
 
48
65
  - `uns-kit create <name>` – create a new UNS project in the specified directory.
66
+ - `uns-kit create --bundle <path> [--dest <dir>] [--allow-existing]` – create a new TypeScript UNS project from `service.bundle.json`.
49
67
  - `uns-kit configure [path] [features...]` – run multiple configure templates in sequence (`--all`, `--overwrite`).
50
68
  - `uns-kit configure-templates [path] [templates...]` – copy any template directory (`--all`, `--overwrite`).
51
69
  - `uns-kit configure-devops [path]` – add Azure DevOps tooling (dependencies, script, config) to an existing project.
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import process from "node:process";
9
9
  import readline from "node:readline/promises";
10
10
  import { promisify } from "node:util";
11
11
  import * as azdev from "azure-devops-node-api";
12
+ import { generateAgentsMarkdown, generateServiceSpecMarkdown, readAndValidateServiceBundle, } from "./service-bundle.js";
12
13
  const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = path.dirname(__filename);
14
15
  const require = createRequire(import.meta.url);
@@ -145,14 +146,17 @@ async function main() {
145
146
  return;
146
147
  }
147
148
  if (command === "create") {
148
- const projectName = args[1];
149
- if (!projectName) {
150
- console.error("Missing project name. Example: uns-kit create my-app");
151
- process.exitCode = 1;
152
- return;
153
- }
154
149
  try {
155
- await createProject(projectName);
150
+ const createOptions = parseCreateArgs(args.slice(1));
151
+ if (createOptions.bundlePath) {
152
+ await createProjectFromBundle(createOptions);
153
+ }
154
+ else if (createOptions.projectName) {
155
+ await createProject(createOptions.projectName);
156
+ }
157
+ else {
158
+ throw new Error("Missing project name. Example: uns-kit create my-app");
159
+ }
156
160
  }
157
161
  catch (error) {
158
162
  console.error(error.message);
@@ -169,6 +173,7 @@ function printHelp() {
169
173
  "\nUsage: uns-kit <command> [options]\n" +
170
174
  "\nCommands:\n" +
171
175
  " create <name> Scaffold a new UNS application\n" +
176
+ " create --bundle <path> Scaffold a new UNS application from service.bundle.json\n" +
172
177
  " configure [dir] [features...] Configure multiple templates (--all, --overwrite)\n" +
173
178
  " configure-templates [dir] [templates...] Copy any template directory (--all, --overwrite)\n" +
174
179
  " configure-devops [dir] Configure Azure DevOps tooling in an existing project\n" +
@@ -181,25 +186,146 @@ function printHelp() {
181
186
  " configure-uns-reference [dir] Copy UNS dictionaries (objects/attributes/measurements)\n" +
182
187
  " help Show this message\n");
183
188
  }
189
+ function parseCreateArgs(args) {
190
+ let projectName;
191
+ let bundlePath;
192
+ let destPath;
193
+ let allowExisting = false;
194
+ for (let index = 0; index < args.length; index += 1) {
195
+ const arg = args[index];
196
+ if (arg === "--bundle") {
197
+ const next = args[index + 1];
198
+ if (!next || next.startsWith("--")) {
199
+ throw new Error("Missing value for --bundle.");
200
+ }
201
+ bundlePath = next;
202
+ index += 1;
203
+ continue;
204
+ }
205
+ if (arg === "--dest") {
206
+ const next = args[index + 1];
207
+ if (!next || next.startsWith("--")) {
208
+ throw new Error("Missing value for --dest.");
209
+ }
210
+ destPath = next;
211
+ index += 1;
212
+ continue;
213
+ }
214
+ if (arg === "--allow-existing") {
215
+ allowExisting = true;
216
+ continue;
217
+ }
218
+ if (arg.startsWith("--")) {
219
+ throw new Error(`Unknown option ${arg}.`);
220
+ }
221
+ if (!projectName) {
222
+ projectName = arg;
223
+ continue;
224
+ }
225
+ throw new Error(`Unexpected argument ${arg}.`);
226
+ }
227
+ if (bundlePath && projectName) {
228
+ throw new Error("Do not pass a positional project name with --bundle. Use --dest to override the target directory.");
229
+ }
230
+ if (!bundlePath && destPath) {
231
+ throw new Error("--dest can only be used with --bundle.");
232
+ }
233
+ if (!bundlePath && allowExisting) {
234
+ throw new Error("--allow-existing can only be used with --bundle.");
235
+ }
236
+ return { projectName, bundlePath, destPath, allowExisting };
237
+ }
184
238
  async function createProject(projectName) {
185
239
  const targetDir = path.resolve(process.cwd(), projectName);
186
- await ensureTargetDir(targetDir);
187
- const templateDir = path.resolve(__dirname, "../templates/default");
240
+ const result = await scaffoldTsProject(projectName, targetDir);
241
+ printTsCreateSuccess(targetDir, result.packageName, initializedGitNextSteps(result.initializedGit, "pnpm run dev"));
242
+ }
243
+ async function createProjectFromBundle(options) {
244
+ if (!options.bundlePath) {
245
+ throw new Error("Missing --bundle path.");
246
+ }
247
+ const { bundle, raw } = await readAndValidateServiceBundle(options.bundlePath, {
248
+ expectedStack: "ts",
249
+ cliName: "uns-kit",
250
+ counterpartCliName: "uns-kit-py",
251
+ });
252
+ const targetDir = path.resolve(process.cwd(), options.destPath ?? bundle.metadata.name);
253
+ const result = await scaffoldTsProject(bundle.metadata.name, targetDir, {
254
+ allowExisting: options.allowExisting,
255
+ templateName: bundle.scaffold.template,
256
+ });
257
+ await applyTsBundleFeatures(targetDir, bundle);
258
+ await writeServiceBundleArtifacts(targetDir, bundle, raw);
259
+ printTsCreateSuccess(targetDir, result.packageName, initializedGitNextSteps(result.initializedGit, "pnpm run dev"));
260
+ }
261
+ async function scaffoldTsProject(projectName, targetDir, options = {}) {
262
+ await ensureTargetDir(targetDir, { allowExisting: options.allowExisting });
263
+ const templateDir = path.resolve(__dirname, `../templates/${options.templateName ?? DEFAULT_TEMPLATE_NAME}`);
264
+ try {
265
+ await access(templateDir);
266
+ }
267
+ catch (error) {
268
+ throw new Error(`Template directory is missing: ${templateDir}`);
269
+ }
188
270
  await copyTemplateDirectory(templateDir, targetDir, targetDir);
189
- // Seed config examples automatically (previously required configure-config).
190
271
  await configureConfigFiles(targetDir, { overwrite: false });
191
- const pkgName = normalizePackageName(projectName);
192
- await patchPackageJson(targetDir, pkgName);
193
- await patchConfigJson(targetDir, pkgName);
194
- await replacePlaceholders(targetDir, pkgName);
272
+ const packageName = normalizePackageName(projectName);
273
+ await patchPackageJson(targetDir, packageName);
274
+ await patchConfigJson(targetDir, packageName);
275
+ await replacePlaceholders(targetDir, packageName);
195
276
  const initializedGit = await initGitRepository(targetDir);
196
- console.log(`\nCreated ${pkgName} in ${path.relative(process.cwd(), targetDir)}`);
197
- console.log("Next steps:");
198
- console.log(` cd ${projectName}`);
199
- console.log(" pnpm install");
200
- console.log(" pnpm run dev");
277
+ return { packageName, initializedGit };
278
+ }
279
+ async function applyTsBundleFeatures(targetDir, bundle) {
280
+ for (const featureName of bundle.scaffold.features) {
281
+ const resolvedFeature = resolveConfigureFeatureName(featureName);
282
+ if (resolvedFeature === "devops") {
283
+ await configureDevopsFromBundle(targetDir, bundle);
284
+ continue;
285
+ }
286
+ const handler = configureFeatureHandlers[resolvedFeature];
287
+ await handler(targetDir, { overwrite: false });
288
+ }
289
+ }
290
+ async function configureDevopsFromBundle(targetDir, bundle) {
291
+ const provider = bundle.repository?.provider?.trim() || AZURE_DEVOPS_PROVIDER;
292
+ if (provider !== AZURE_DEVOPS_PROVIDER) {
293
+ throw new Error(`Bundle feature "devops" only supports repository.provider="${AZURE_DEVOPS_PROVIDER}" in this MVP. Received "${provider}".`);
294
+ }
295
+ const organization = bundle.repository?.organization?.trim();
296
+ const project = bundle.repository?.project?.trim();
297
+ if (!organization || !project) {
298
+ throw new Error('Bundle feature "devops" requires repository.organization and repository.project for non-interactive scaffolding.');
299
+ }
300
+ const result = await applyAzureDevopsConfig(targetDir, {
301
+ organization,
302
+ project,
303
+ overwrite: false,
304
+ ensureRemote: false,
305
+ });
306
+ logDevopsResult(result, { includeRemoteDetails: false });
307
+ }
308
+ async function writeServiceBundleArtifacts(targetDir, bundle, rawBundle) {
309
+ await writeFile(path.join(targetDir, "service.bundle.json"), rawBundle, "utf8");
310
+ await writeFile(path.join(targetDir, "SERVICE_SPEC.md"), generateServiceSpecMarkdown(bundle), "utf8");
311
+ await writeFile(path.join(targetDir, "AGENTS.md"), generateAgentsMarkdown(bundle), "utf8");
312
+ }
313
+ function initializedGitNextSteps(initializedGit, runCommand) {
314
+ const steps = ["pnpm install", runCommand];
201
315
  if (initializedGit) {
202
- console.log(" git status # verify the new repository");
316
+ steps.push("git status # verify the new repository");
317
+ }
318
+ return steps;
319
+ }
320
+ function printTsCreateSuccess(targetDir, packageName, nextSteps) {
321
+ const relativeTarget = path.relative(process.cwd(), targetDir) || ".";
322
+ console.log(`\nCreated ${packageName} in ${relativeTarget}`);
323
+ console.log("Next steps:");
324
+ if (relativeTarget !== ".") {
325
+ console.log(` cd ${relativeTarget}`);
326
+ }
327
+ for (const step of nextSteps) {
328
+ console.log(` ${step}`);
203
329
  }
204
330
  }
205
331
  async function initGitRepository(targetDir) {
@@ -250,8 +376,8 @@ async function initGitRepository(targetDir) {
250
376
  }
251
377
  async function configureDevops(targetPath, options) {
252
378
  const targetDir = path.resolve(process.cwd(), targetPath ?? ".");
379
+ await ensureGitRepository(targetDir);
253
380
  const packagePath = path.join(targetDir, "package.json");
254
- const configPath = path.join(targetDir, "config.json");
255
381
  let pkgRaw;
256
382
  try {
257
383
  pkgRaw = await readFile(packagePath, "utf8");
@@ -262,6 +388,8 @@ async function configureDevops(targetPath, options) {
262
388
  }
263
389
  throw error;
264
390
  }
391
+ const pkg = JSON.parse(pkgRaw);
392
+ const configPath = path.join(targetDir, "config.json");
265
393
  let configRaw;
266
394
  try {
267
395
  configRaw = await readFile(configPath, "utf8");
@@ -272,9 +400,7 @@ async function configureDevops(targetPath, options) {
272
400
  }
273
401
  throw error;
274
402
  }
275
- const pkg = JSON.parse(pkgRaw);
276
403
  const config = JSON.parse(configRaw);
277
- await ensureGitRepository(targetDir);
278
404
  const remoteUrl = await getGitRemoteUrl(targetDir, "origin");
279
405
  const remoteInfo = remoteUrl ? parseAzureRemote(remoteUrl) : undefined;
280
406
  const repositoryName = inferRepositoryNameFromPackage(pkg.name) || inferRepositoryNameFromPackage(path.basename(targetDir));
@@ -282,44 +408,85 @@ async function configureDevops(targetPath, options) {
282
408
  const organization = await promptWithDefault(defaultOrganization ? `Azure DevOps organization [${defaultOrganization}]: ` : "Azure DevOps organization: ", defaultOrganization, "Azure DevOps organization is required.");
283
409
  const defaultProject = config.devops?.project?.trim() || remoteInfo?.project || "";
284
410
  const project = await promptWithDefault(defaultProject ? `Azure DevOps project [${defaultProject}]: ` : "Azure DevOps project: ", defaultProject, "Azure DevOps project is required.");
411
+ const result = await applyAzureDevopsConfig(targetDir, {
412
+ organization,
413
+ project,
414
+ overwrite: options?.overwrite,
415
+ ensureRemote: true,
416
+ repositoryName: remoteInfo?.repository ?? repositoryName,
417
+ });
418
+ logDevopsResult(result, { includeRemoteDetails: true });
419
+ }
420
+ async function applyAzureDevopsConfig(targetDir, options) {
421
+ const packagePath = path.join(targetDir, "package.json");
422
+ const configPath = path.join(targetDir, "config.json");
423
+ let pkgRaw;
424
+ try {
425
+ pkgRaw = await readFile(packagePath, "utf8");
426
+ }
427
+ catch (error) {
428
+ if (error.code === "ENOENT") {
429
+ throw new Error(`Could not find package.json in ${targetDir}`);
430
+ }
431
+ throw error;
432
+ }
433
+ let configRaw;
434
+ try {
435
+ configRaw = await readFile(configPath, "utf8");
436
+ }
437
+ catch (error) {
438
+ if (error.code === "ENOENT") {
439
+ throw new Error(`Could not find config.json in ${targetDir}`);
440
+ }
441
+ throw error;
442
+ }
443
+ const pkg = JSON.parse(pkgRaw);
444
+ const config = JSON.parse(configRaw);
285
445
  if (!config.devops || typeof config.devops !== "object") {
286
446
  config.devops = {};
287
447
  }
288
448
  const devopsConfig = config.devops;
289
449
  devopsConfig.provider = AZURE_DEVOPS_PROVIDER;
290
- devopsConfig.organization = organization;
291
- devopsConfig.project = project;
450
+ devopsConfig.organization = options.organization;
451
+ devopsConfig.project = options.project;
292
452
  let gitRemoteMessage;
293
453
  let repositoryUrlMessage;
294
- let ensuredRemoteUrl = remoteUrl ?? "";
295
- if (!remoteUrl) {
296
- const { gitApi } = await resolveAzureGitApi(organization);
297
- const repositoryDetails = await ensureAzureRepositoryExists(gitApi, {
298
- organization,
299
- project,
300
- repository: repositoryName,
301
- });
302
- ensuredRemoteUrl = repositoryDetails.remoteUrl ?? buildAzureGitRemoteUrl(organization, project, repositoryName);
303
- await addGitRemote(targetDir, "origin", ensuredRemoteUrl);
304
- gitRemoteMessage = ` Added git remote origin -> ${ensuredRemoteUrl}`;
305
- const friendlyUrl = repositoryDetails.webUrl ?? buildAzureRepositoryUrl(organization, project, repositoryName);
306
- if (friendlyUrl) {
307
- repositoryUrlMessage = ` Repository URL: ${friendlyUrl}`;
454
+ if (options.ensureRemote) {
455
+ const repositoryName = options.repositoryName
456
+ ?? inferRepositoryNameFromPackage(pkg.name)
457
+ ?? inferRepositoryNameFromPackage(path.basename(targetDir));
458
+ const remoteUrl = await getGitRemoteUrl(targetDir, "origin");
459
+ const remoteInfo = remoteUrl ? parseAzureRemote(remoteUrl) : undefined;
460
+ if (!remoteUrl) {
461
+ const { gitApi } = await resolveAzureGitApi(options.organization);
462
+ const repositoryDetails = await ensureAzureRepositoryExists(gitApi, {
463
+ organization: options.organization,
464
+ project: options.project,
465
+ repository: repositoryName,
466
+ });
467
+ const ensuredRemoteUrl = repositoryDetails.remoteUrl
468
+ ?? buildAzureGitRemoteUrl(options.organization, options.project, repositoryName);
469
+ await addGitRemote(targetDir, "origin", ensuredRemoteUrl);
470
+ gitRemoteMessage = ` Added git remote origin -> ${ensuredRemoteUrl}`;
471
+ const friendlyUrl = repositoryDetails.webUrl
472
+ ?? buildAzureRepositoryUrl(options.organization, options.project, repositoryName);
473
+ if (friendlyUrl) {
474
+ repositoryUrlMessage = ` Repository URL: ${friendlyUrl}`;
475
+ }
308
476
  }
309
- }
310
- else {
311
- ensuredRemoteUrl = remoteUrl;
312
- gitRemoteMessage = ` Git remote origin detected -> ${ensuredRemoteUrl}`;
313
- const friendlyUrl = buildAzureRepositoryUrl(remoteInfo?.organization ?? organization, remoteInfo?.project ?? project, remoteInfo?.repository ?? repositoryName);
314
- if (friendlyUrl) {
315
- repositoryUrlMessage = ` Repository URL: ${friendlyUrl}`;
477
+ else {
478
+ gitRemoteMessage = ` Git remote origin detected -> ${remoteUrl}`;
479
+ const friendlyUrl = buildAzureRepositoryUrl(remoteInfo?.organization ?? options.organization, remoteInfo?.project ?? options.project, remoteInfo?.repository ?? repositoryName);
480
+ if (friendlyUrl) {
481
+ repositoryUrlMessage = ` Repository URL: ${friendlyUrl}`;
482
+ }
316
483
  }
317
484
  }
318
485
  const requiredDevDeps = {
319
486
  "azure-devops-node-api": "^15.1.0",
320
487
  "simple-git": "^3.27.0",
321
488
  "chalk": "^5.4.1",
322
- "prettier": "^3.5.3"
489
+ "prettier": "^3.5.3",
323
490
  };
324
491
  let pkgChanged = false;
325
492
  const devDeps = (pkg.devDependencies ??= {});
@@ -343,10 +510,9 @@ async function configureDevops(targetPath, options) {
343
510
  throw new Error("Azure Pipelines template is missing. Please ensure templates/azure-pipelines.yml exists.");
344
511
  }
345
512
  const pipelineTargetPath = path.join(targetDir, "azure-pipelines.yml");
346
- const overwrite = !!options?.overwrite;
347
513
  let pipelineMessage = "";
348
514
  if (await fileExists(pipelineTargetPath)) {
349
- if (overwrite) {
515
+ if (options.overwrite) {
350
516
  await copyFile(azurePipelineTemplatePath, pipelineTargetPath);
351
517
  pipelineMessage = " Overwrote azure-pipelines.yml pipeline definition.";
352
518
  }
@@ -362,20 +528,31 @@ async function configureDevops(targetPath, options) {
362
528
  if (pkgChanged) {
363
529
  await writeFile(packagePath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
364
530
  }
531
+ return {
532
+ provider: AZURE_DEVOPS_PROVIDER,
533
+ organization: options.organization,
534
+ project: options.project,
535
+ pkgChanged,
536
+ pipelineMessage,
537
+ gitRemoteMessage,
538
+ repositoryUrlMessage,
539
+ };
540
+ }
541
+ function logDevopsResult(result, options) {
365
542
  console.log(`\nDevOps tooling configured.`);
366
- console.log(` DevOps provider: ${AZURE_DEVOPS_PROVIDER}`);
367
- console.log(` Azure organization: ${organization}`);
368
- console.log(` Azure project: ${project}`);
369
- if (repositoryUrlMessage) {
370
- console.log(repositoryUrlMessage);
543
+ console.log(` DevOps provider: ${result.provider}`);
544
+ console.log(` Azure organization: ${result.organization}`);
545
+ console.log(` Azure project: ${result.project}`);
546
+ if (options.includeRemoteDetails && result.repositoryUrlMessage) {
547
+ console.log(result.repositoryUrlMessage);
371
548
  }
372
- if (gitRemoteMessage) {
373
- console.log(gitRemoteMessage);
549
+ if (options.includeRemoteDetails && result.gitRemoteMessage) {
550
+ console.log(result.gitRemoteMessage);
374
551
  }
375
- if (pipelineMessage) {
376
- console.log(pipelineMessage);
552
+ if (result.pipelineMessage) {
553
+ console.log(result.pipelineMessage);
377
554
  }
378
- if (pkgChanged) {
555
+ if (result.pkgChanged) {
379
556
  console.log(" Updated package.json scripts/devDependencies. Run pnpm install to fetch new packages.");
380
557
  }
381
558
  else {
@@ -1204,14 +1381,14 @@ async function fileExists(filePath) {
1204
1381
  throw error;
1205
1382
  }
1206
1383
  }
1207
- async function ensureTargetDir(dir) {
1384
+ async function ensureTargetDir(dir, options = {}) {
1208
1385
  try {
1209
1386
  const stats = await stat(dir);
1210
1387
  if (!stats.isDirectory()) {
1211
1388
  throw new Error(`Path ${dir} exists and is not a directory.`);
1212
1389
  }
1213
1390
  const entries = await readdir(dir);
1214
- if (entries.length > 0) {
1391
+ if (entries.length > 0 && !options.allowExisting) {
1215
1392
  throw new Error(`Directory ${dir} is not empty.`);
1216
1393
  }
1217
1394
  }
@@ -0,0 +1,55 @@
1
+ export type SupportedBundleStack = "ts" | "python";
2
+ export type ServiceBundle = {
3
+ schemaVersion: 1;
4
+ kind: "uns-service-bundle";
5
+ metadata: {
6
+ name: string;
7
+ displayName?: string;
8
+ serviceType?: string;
9
+ summary?: string;
10
+ description?: string;
11
+ owner?: string;
12
+ tags: string[];
13
+ };
14
+ scaffold: {
15
+ stack: SupportedBundleStack | string;
16
+ template: string;
17
+ features: string[];
18
+ };
19
+ repository?: {
20
+ provider?: string;
21
+ organization?: string;
22
+ project?: string;
23
+ repository?: string;
24
+ defaultBranch?: string;
25
+ };
26
+ domain?: {
27
+ inputs: unknown[];
28
+ outputs: unknown[];
29
+ };
30
+ docs: {
31
+ serviceSpec: {
32
+ goals: string[];
33
+ nonGoals: string[];
34
+ acceptanceCriteria: string[];
35
+ };
36
+ agents: {
37
+ projectContext: string[];
38
+ guardrails: string[];
39
+ firstTasks: string[];
40
+ verification: string[];
41
+ };
42
+ };
43
+ };
44
+ type ReadBundleOptions = {
45
+ expectedStack: SupportedBundleStack;
46
+ cliName: string;
47
+ counterpartCliName: string;
48
+ };
49
+ export declare function readAndValidateServiceBundle(bundlePath: string, options: ReadBundleOptions): Promise<{
50
+ bundle: ServiceBundle;
51
+ raw: string;
52
+ }>;
53
+ export declare function generateServiceSpecMarkdown(bundle: ServiceBundle): string;
54
+ export declare function generateAgentsMarkdown(bundle: ServiceBundle): string;
55
+ export {};
@@ -0,0 +1,239 @@
1
+ import { readFile } from "node:fs/promises";
2
+ export async function readAndValidateServiceBundle(bundlePath, options) {
3
+ const raw = await readFile(bundlePath, "utf8");
4
+ const parsed = parseJsonObject(raw, bundlePath);
5
+ const bundle = validateServiceBundle(parsed, options);
6
+ return { bundle, raw };
7
+ }
8
+ export function generateServiceSpecMarkdown(bundle) {
9
+ const lines = [
10
+ "# SERVICE_SPEC",
11
+ "",
12
+ "> Generated from `service.bundle.json`. Update the bundle as the source of truth.",
13
+ "",
14
+ "## Service Identity",
15
+ ...renderKeyValueList([
16
+ ["Name", bundle.metadata.name],
17
+ ["Display Name", bundle.metadata.displayName],
18
+ ["Service Type", bundle.metadata.serviceType],
19
+ ["Owner", bundle.metadata.owner],
20
+ ["Tags", bundle.metadata.tags.length ? bundle.metadata.tags.join(", ") : "None"],
21
+ ]),
22
+ "",
23
+ "## Summary",
24
+ "",
25
+ bundle.metadata.summary?.trim() || "Not provided.",
26
+ "",
27
+ "## Description",
28
+ "",
29
+ bundle.metadata.description?.trim() || "Not provided.",
30
+ "",
31
+ "## Scaffold",
32
+ ...renderKeyValueList([
33
+ ["Stack", bundle.scaffold.stack],
34
+ ["Template", bundle.scaffold.template],
35
+ ["Features", bundle.scaffold.features.length ? bundle.scaffold.features.join(", ") : "None"],
36
+ ]),
37
+ ];
38
+ if (bundle.repository) {
39
+ lines.push("", "## Repository", ...renderKeyValueList([
40
+ ["Provider", bundle.repository.provider],
41
+ ["Organization", bundle.repository.organization],
42
+ ["Project", bundle.repository.project],
43
+ ["Repository", bundle.repository.repository],
44
+ ["Default Branch", bundle.repository.defaultBranch],
45
+ ]));
46
+ }
47
+ if (bundle.domain) {
48
+ lines.push("", "## Domain Inputs", ...renderUnknownList(bundle.domain.inputs), "", "## Domain Outputs", ...renderUnknownList(bundle.domain.outputs));
49
+ }
50
+ lines.push("", "## Goals", ...renderStringList(bundle.docs.serviceSpec.goals), "", "## Non-Goals", ...renderStringList(bundle.docs.serviceSpec.nonGoals), "", "## Acceptance Criteria", ...renderStringList(bundle.docs.serviceSpec.acceptanceCriteria), "");
51
+ return lines.join("\n");
52
+ }
53
+ export function generateAgentsMarkdown(bundle) {
54
+ const lines = [
55
+ "# AGENTS",
56
+ "",
57
+ "> This repository was bootstrapped from `service.bundle.json`. Regenerate derived docs from the bundle instead of treating this file as the source of truth.",
58
+ "",
59
+ "## Service",
60
+ ...renderKeyValueList([
61
+ ["Name", bundle.metadata.name],
62
+ ["Display Name", bundle.metadata.displayName],
63
+ ["Stack", bundle.scaffold.stack],
64
+ ["Template", bundle.scaffold.template],
65
+ ]),
66
+ "",
67
+ "## Project Context",
68
+ ...renderStringList(bundle.docs.agents.projectContext),
69
+ "",
70
+ "## Guardrails",
71
+ ...renderStringList(bundle.docs.agents.guardrails),
72
+ "",
73
+ "## First Tasks",
74
+ ...renderStringList(bundle.docs.agents.firstTasks),
75
+ "",
76
+ "## Verification",
77
+ ...renderStringList(bundle.docs.agents.verification),
78
+ "",
79
+ ];
80
+ return lines.join("\n");
81
+ }
82
+ function validateServiceBundle(value, options) {
83
+ const schemaVersion = value.schemaVersion;
84
+ if (schemaVersion !== 1) {
85
+ throw new Error(`service bundle schemaVersion must be 1. Received ${JSON.stringify(schemaVersion)}.`);
86
+ }
87
+ const kind = value.kind;
88
+ if (kind !== "uns-service-bundle") {
89
+ throw new Error(`service bundle kind must be "uns-service-bundle". Received ${JSON.stringify(kind)}.`);
90
+ }
91
+ const metadata = requireObject(value.metadata, "metadata");
92
+ const scaffold = requireObject(value.scaffold, "scaffold");
93
+ const repository = optionalObject(value.repository, "repository");
94
+ const domain = optionalObject(value.domain, "domain");
95
+ const docs = optionalObject(value.docs, "docs");
96
+ optionalObject(value.provenance, "provenance");
97
+ const normalized = {
98
+ schemaVersion: 1,
99
+ kind: "uns-service-bundle",
100
+ metadata: {
101
+ name: requireNonEmptyString(metadata.name, "metadata.name"),
102
+ displayName: optionalNonEmptyString(metadata.displayName, "metadata.displayName"),
103
+ serviceType: optionalNonEmptyString(metadata.serviceType, "metadata.serviceType"),
104
+ summary: optionalNonEmptyString(metadata.summary, "metadata.summary"),
105
+ description: optionalNonEmptyString(metadata.description, "metadata.description"),
106
+ owner: optionalNonEmptyString(metadata.owner, "metadata.owner"),
107
+ tags: optionalStringArray(metadata.tags, "metadata.tags"),
108
+ },
109
+ scaffold: {
110
+ stack: requireNonEmptyString(scaffold.stack, "scaffold.stack"),
111
+ template: requireNonEmptyString(scaffold.template, "scaffold.template"),
112
+ features: dedupeStrings(optionalStringArray(scaffold.features, "scaffold.features")),
113
+ },
114
+ repository: repository
115
+ ? {
116
+ provider: optionalNonEmptyString(repository.provider, "repository.provider"),
117
+ organization: optionalNonEmptyString(repository.organization, "repository.organization"),
118
+ project: optionalNonEmptyString(repository.project, "repository.project"),
119
+ repository: optionalNonEmptyString(repository.repository, "repository.repository"),
120
+ defaultBranch: optionalNonEmptyString(repository.defaultBranch, "repository.defaultBranch"),
121
+ }
122
+ : undefined,
123
+ domain: domain
124
+ ? {
125
+ inputs: optionalUnknownArray(domain.inputs, "domain.inputs"),
126
+ outputs: optionalUnknownArray(domain.outputs, "domain.outputs"),
127
+ }
128
+ : undefined,
129
+ docs: {
130
+ serviceSpec: normalizeServiceSpecDocs(optionalObject(docs?.serviceSpec, "docs.serviceSpec")),
131
+ agents: normalizeAgentsDocs(optionalObject(docs?.agents, "docs.agents")),
132
+ },
133
+ };
134
+ if (normalized.scaffold.stack !== options.expectedStack) {
135
+ throw new Error(`Bundle scaffold.stack is "${normalized.scaffold.stack}". Use ${options.counterpartCliName} create --bundle <path> instead of ${options.cliName}.`);
136
+ }
137
+ if (normalized.scaffold.template !== "default") {
138
+ throw new Error(`Bundle scaffold.template must be "default" for this MVP. Received "${normalized.scaffold.template}".`);
139
+ }
140
+ return normalized;
141
+ }
142
+ function normalizeServiceSpecDocs(value) {
143
+ return {
144
+ goals: optionalStringArray(value?.goals, "docs.serviceSpec.goals"),
145
+ nonGoals: optionalStringArray(value?.nonGoals, "docs.serviceSpec.nonGoals"),
146
+ acceptanceCriteria: optionalStringArray(value?.acceptanceCriteria, "docs.serviceSpec.acceptanceCriteria"),
147
+ };
148
+ }
149
+ function normalizeAgentsDocs(value) {
150
+ return {
151
+ projectContext: optionalStringArray(value?.projectContext, "docs.agents.projectContext"),
152
+ guardrails: optionalStringArray(value?.guardrails, "docs.agents.guardrails"),
153
+ firstTasks: optionalStringArray(value?.firstTasks, "docs.agents.firstTasks"),
154
+ verification: optionalStringArray(value?.verification, "docs.agents.verification"),
155
+ };
156
+ }
157
+ function parseJsonObject(raw, bundlePath) {
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(raw);
161
+ }
162
+ catch (error) {
163
+ throw new Error(`Failed to parse bundle JSON at ${bundlePath}: ${error.message}`);
164
+ }
165
+ return requireObject(parsed, "bundle");
166
+ }
167
+ function requireObject(value, path) {
168
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
169
+ throw new Error(`${path} must be an object.`);
170
+ }
171
+ return value;
172
+ }
173
+ function optionalObject(value, path) {
174
+ if (value === undefined || value === null) {
175
+ return undefined;
176
+ }
177
+ return requireObject(value, path);
178
+ }
179
+ function requireNonEmptyString(value, path) {
180
+ if (typeof value !== "string" || !value.trim()) {
181
+ throw new Error(`${path} is required and must be a non-empty string.`);
182
+ }
183
+ return value.trim();
184
+ }
185
+ function optionalNonEmptyString(value, path) {
186
+ if (value === undefined || value === null || value === "") {
187
+ return undefined;
188
+ }
189
+ return requireNonEmptyString(value, path);
190
+ }
191
+ function optionalStringArray(value, path) {
192
+ if (value === undefined || value === null) {
193
+ return [];
194
+ }
195
+ if (!Array.isArray(value)) {
196
+ throw new Error(`${path} must be an array of strings.`);
197
+ }
198
+ return value.map((item, index) => requireNonEmptyString(item, `${path}[${index}]`));
199
+ }
200
+ function optionalUnknownArray(value, path) {
201
+ if (value === undefined || value === null) {
202
+ return [];
203
+ }
204
+ if (!Array.isArray(value)) {
205
+ throw new Error(`${path} must be an array.`);
206
+ }
207
+ return value;
208
+ }
209
+ function dedupeStrings(values) {
210
+ const seen = new Set();
211
+ const deduped = [];
212
+ for (const value of values) {
213
+ if (!seen.has(value)) {
214
+ seen.add(value);
215
+ deduped.push(value);
216
+ }
217
+ }
218
+ return deduped;
219
+ }
220
+ function renderKeyValueList(entries) {
221
+ return entries.map(([label, value]) => `- ${label}: ${value && value.trim() ? value : "Not specified"}`);
222
+ }
223
+ function renderStringList(items) {
224
+ if (!items.length) {
225
+ return ["- None specified."];
226
+ }
227
+ return items.map((item) => `- ${item}`);
228
+ }
229
+ function renderUnknownList(items) {
230
+ if (!items.length) {
231
+ return ["- None specified."];
232
+ }
233
+ return items.map((item) => {
234
+ if (typeof item === "string" && item.trim()) {
235
+ return `- ${item}`;
236
+ }
237
+ return `- \`${JSON.stringify(item)}\``;
238
+ });
239
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uns-kit/cli",
3
- "version": "2.0.18",
3
+ "version": "2.0.20",
4
4
  "description": "Command line scaffolding tool for UNS applications",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,13 +26,13 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "azure-devops-node-api": "^15.1.1",
29
- "@uns-kit/core": "2.0.18"
29
+ "@uns-kit/core": "2.0.20"
30
30
  },
31
31
  "unsKitPackages": {
32
- "@uns-kit/core": "2.0.18",
33
- "@uns-kit/api": "2.0.18",
34
- "@uns-kit/cron": "2.0.18",
35
- "@uns-kit/temporal": "2.0.18"
32
+ "@uns-kit/core": "2.0.20",
33
+ "@uns-kit/api": "2.0.20",
34
+ "@uns-kit/cron": "2.0.20",
35
+ "@uns-kit/temporal": "2.0.20"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.build.json",