@kitsy/cnos-cli 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +758 -114
  2. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ var COMMAND_OPTION_KEYS_WITH_VALUE = /* @__PURE__ */ new Set([
12
12
  "--format",
13
13
  "--framework",
14
14
  "--prefix",
15
+ "--mode",
15
16
  "--scan",
16
17
  "--target",
17
18
  "--to",
@@ -23,7 +24,13 @@ var COMMAND_OPTION_KEYS_WITH_VALUE = /* @__PURE__ */ new Set([
23
24
  "--as",
24
25
  "--set",
25
26
  "--debounce",
26
- "--expr"
27
+ "--expr",
28
+ "--extends",
29
+ "--workspaces",
30
+ "--env",
31
+ "--yaml",
32
+ "--toml",
33
+ "--config"
27
34
  ]);
28
35
  var COMMAND_FLAG_KEYS = /* @__PURE__ */ new Set([
29
36
  "--flatten",
@@ -42,7 +49,9 @@ var COMMAND_FLAG_KEYS = /* @__PURE__ */ new Set([
42
49
  "--apply",
43
50
  "--rewrite",
44
51
  "--signal",
45
- "--derive"
52
+ "--derive",
53
+ "--materialize",
54
+ "--source-only"
46
55
  ]);
47
56
  function normalizeCommand(argv) {
48
57
  const [command = "doctor", ...rest] = argv;
@@ -158,6 +167,14 @@ function parseArgs(argv) {
158
167
  passthrough.push(token);
159
168
  continue;
160
169
  }
170
+ if (command === "onboard" && token === "--json") {
171
+ const nextValue = rest[index + 1];
172
+ if (nextValue && !nextValue.startsWith("--")) {
173
+ cliArgs.push(token, nextValue);
174
+ index += 1;
175
+ continue;
176
+ }
177
+ }
161
178
  if (token === "--json") {
162
179
  options.json = true;
163
180
  continue;
@@ -771,6 +788,8 @@ async function defineValue(namespace, configPath, rawValue, options = {}) {
771
788
  const derivedValue = normalizeDerivedValue(options.deriveExpression, options.deriveExprMode ?? false);
772
789
  validateParsedDerivation(runtime.manifest, parseDerivation(derivedValue));
773
790
  parsedValue = derivedValue;
791
+ } else if (Object.hasOwn(options, "parsedValue")) {
792
+ parsedValue = options.parsedValue;
774
793
  } else {
775
794
  parsedValue = parseScalarValue(rawValue);
776
795
  }
@@ -1576,23 +1595,65 @@ var COMMANDS = [
1576
1595
  },
1577
1596
  {
1578
1597
  id: "init",
1579
- summary: "Scaffold a workspace-aware CNOS tree in the current project.",
1580
- usage: "cnos init [--workspace <id>] [--root <path>] [--json]",
1581
- description: "Creates .cnos/cnos.yml, .cnosrc.yml, optional .cnos-workspace.yml, config folders, and .gitignore entries without overwriting existing files.",
1582
- examples: ["cnos init", "cnos init --workspace api", "cnos init --root ./apps/api --workspace api --json"]
1598
+ summary: "Scaffold a CNOS project in regular mode or workspace mode.",
1599
+ usage: "cnos init [--mode <regular|workspace>] [--workspaces <csv>] [--root <path>] [--json]",
1600
+ description: "Creates .cnos/cnos.yml, .cnosrc.yml, config folders, and .gitignore entries without overwriting existing files. Regular mode is the default; workspace mode creates base plus optional child workspaces.",
1601
+ examples: [
1602
+ "cnos init",
1603
+ "cnos init --mode workspace",
1604
+ "cnos init --mode workspace --workspaces api,web,agents",
1605
+ "cnos init --root ./apps/api --json"
1606
+ ]
1583
1607
  },
1584
1608
  {
1585
1609
  id: "onboard",
1586
- summary: "Onboard an existing repo into CNOS and import root dotenv files.",
1587
- usage: "cnos onboard [--workspace <id>] [--root <path>] [--move] [--json]",
1588
- description: "Scaffolds the CNOS workspace tree and imports root-level .env, .env.<profile>, and .env.*.example files into .cnos/workspaces/<workspace>/env.",
1610
+ summary: "Import existing env or config sources into CNOS and propose value.* mappings.",
1611
+ usage: "cnos onboard [--workspace <id>] [--env <path>|--yaml <path>|--json <path>|--toml <path>|--config <path>] [--materialize|--source-only] [--prefix <path>] [--move] [--root <path>] [--json]",
1612
+ description: "Auto-discovers root .env* files by default, copies them into CNOS source storage, prints proposed value.* mappings, and can materialize those mappings into CNOS values. In workspace mode, imports land in the selected workspace; otherwise they land in the implicit base layer.",
1589
1613
  options: [
1614
+ {
1615
+ flag: "--env <path>",
1616
+ description: "Import one dotenv file instead of auto-discovering root .env* files."
1617
+ },
1618
+ {
1619
+ flag: "--yaml <path>",
1620
+ description: "Import one YAML config file and flatten it into value.* keys."
1621
+ },
1622
+ {
1623
+ flag: "--json <path>",
1624
+ description: "Import one JSON config file and flatten it into value.* keys."
1625
+ },
1626
+ {
1627
+ flag: "--toml <path>",
1628
+ description: "Import one TOML config file and flatten it into value.* keys."
1629
+ },
1630
+ {
1631
+ flag: "--config <path>",
1632
+ description: "Import one config file using extension-based format detection."
1633
+ },
1634
+ {
1635
+ flag: "--materialize",
1636
+ description: "Write the proposed value.* mappings without prompting."
1637
+ },
1638
+ {
1639
+ flag: "--source-only",
1640
+ description: "Copy the source file(s) into CNOS storage but skip value materialization."
1641
+ },
1642
+ {
1643
+ flag: "--prefix <path>",
1644
+ description: "Scope imported keys under value.<prefix>.*."
1645
+ },
1590
1646
  {
1591
1647
  flag: "--move",
1592
- description: "Move the root env files into CNOS instead of leaving the originals in place."
1648
+ description: "Move the source files into CNOS instead of leaving the originals in place."
1593
1649
  }
1594
1650
  ],
1595
- examples: ["cnos onboard", "cnos onboard --workspace webapp", "cnos onboard --root ../my-app --workspace app --move"]
1651
+ examples: [
1652
+ "cnos onboard",
1653
+ "cnos onboard --env .env.production --materialize",
1654
+ "cnos onboard --yaml config/app.yml --prefix app",
1655
+ "cnos onboard --workspace api --config config/api.toml"
1656
+ ]
1596
1657
  },
1597
1658
  {
1598
1659
  id: "codegen",
@@ -2214,10 +2275,55 @@ var COMMANDS = [
2214
2275
  },
2215
2276
  {
2216
2277
  id: "workspace",
2217
- summary: "Attach or detach package-local workspace config from a parent CNOS root.",
2218
- usage: "cnos workspace <attach|detach> [options] [global-options]",
2219
- description: "Detaches a child package into a standalone .cnos root or reattaches a detached package back into a parent workspace.",
2220
- examples: ["cnos workspace detach", "cnos workspace attach --package-root apps/travel"]
2278
+ summary: "Manage workspace creation, listing, migration, and attach/detach flows.",
2279
+ usage: "cnos workspace <enable|add|list|remove|scaffold|attach|detach> [options] [global-options]",
2280
+ description: "Enables workspace mode for flat CNOS projects, adds and removes manifest workspaces, scaffolds package anchors, and handles detach/attach flows for independent child packages.",
2281
+ examples: [
2282
+ "cnos workspace list",
2283
+ "cnos workspace enable",
2284
+ "cnos workspace add travel --package-root apps/travel --extends base",
2285
+ "cnos workspace remove gallery",
2286
+ "cnos workspace detach --package-root apps/travel"
2287
+ ]
2288
+ },
2289
+ {
2290
+ id: "workspace enable",
2291
+ summary: "Convert a flat regular-mode CNOS root into workspace mode with base.",
2292
+ usage: "cnos workspace enable [global-options]",
2293
+ description: "Moves .cnos/values, .cnos/secrets, .cnos/env, and .cnos/profiles into .cnos/workspaces/base, adds a workspaces block to cnos.yml, and updates the root anchor to workspace: base.",
2294
+ examples: ["cnos workspace enable"]
2295
+ },
2296
+ {
2297
+ id: "workspace add",
2298
+ summary: "Add a child workspace to the manifest and scaffold its on-disk layout.",
2299
+ usage: "cnos workspace add <id> [--package-root <path>] [--extends <workspace|none>] [--force] [global-options]",
2300
+ description: "Creates .cnos/workspaces/<id>, updates cnos.yml, and writes a .cnosrc.yml anchor at the selected package root. When a base workspace exists, CNOS defaults new child workspaces to extends: [base] unless --extends or --extends none is provided.",
2301
+ examples: [
2302
+ "cnos workspace add travel --package-root apps/travel --extends base",
2303
+ "cnos workspace add insights --package-root apps/insights",
2304
+ "cnos workspace add api --extends none"
2305
+ ]
2306
+ },
2307
+ {
2308
+ id: "workspace scaffold",
2309
+ summary: "Scaffold a workspace and anchor without changing other runtime flows.",
2310
+ usage: "cnos workspace scaffold <id> [--package-root <path>] [--extends <workspace>] [--force] [global-options]",
2311
+ description: "Creates the workspace manifest entry, workspace folders, and package anchor for a new app or package. This is an alias-oriented workflow for teams that prefer scaffold wording over add.",
2312
+ examples: ["cnos workspace scaffold gallery --package-root apps/gallery --extends base"]
2313
+ },
2314
+ {
2315
+ id: "workspace list",
2316
+ summary: "List declared workspaces and their inheritance.",
2317
+ usage: "cnos workspace list [global-options]",
2318
+ description: "Shows the declared workspace ids, default workspace, and extends relationships from cnos.yml.",
2319
+ examples: ["cnos workspace list", "cnos workspace list --json"]
2320
+ },
2321
+ {
2322
+ id: "workspace remove",
2323
+ summary: "Remove a workspace from the manifest and delete its local workspace tree.",
2324
+ usage: "cnos workspace remove <id> [global-options]",
2325
+ description: "Deletes .cnos/workspaces/<id> and removes the workspace entry from cnos.yml. CNOS refuses to remove the current default workspace until you change workspaces.default.",
2326
+ examples: ["cnos workspace remove gallery", "cnos workspace remove insights --json"]
2221
2327
  },
2222
2328
  {
2223
2329
  id: "workspace detach",
@@ -2511,32 +2617,41 @@ import path10 from "path";
2511
2617
  // src/services/scaffold.ts
2512
2618
  import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile4 } from "fs/promises";
2513
2619
  import path9 from "path";
2514
- function scaffoldManifest(projectName, workspace) {
2620
+ function scaffoldManifest(projectName, options = {}) {
2621
+ const mode = options.mode ?? "regular";
2622
+ const baseWorkspace = options.workspace ?? "base";
2623
+ const workspaceIds = mode === "workspace" ? [baseWorkspace, ...(options.workspaces ?? []).filter((id) => id !== baseWorkspace)] : [];
2515
2624
  const lines = [
2516
2625
  "version: 1",
2517
2626
  "project:",
2518
- ` name: ${projectName}`,
2519
- "profiles:",
2520
- " default: base",
2521
- "envMapping:",
2522
- " convention: SCREAMING_SNAKE",
2523
- "public:",
2524
- " promote: []",
2525
- ""
2627
+ ` name: ${projectName}`
2526
2628
  ];
2527
- if (workspace) {
2528
- lines.splice(
2529
- 4,
2530
- 0,
2629
+ if (mode === "workspace") {
2630
+ lines.push(
2531
2631
  "workspaces:",
2532
- ` default: ${workspace}`,
2632
+ ` default: ${baseWorkspace}`,
2533
2633
  " global:",
2534
2634
  " enabled: false",
2535
2635
  " allowWrite: false",
2536
2636
  " items:",
2537
- ` ${workspace}: {}`
2637
+ ` ${baseWorkspace}: {}`
2538
2638
  );
2639
+ for (const workspaceId of workspaceIds) {
2640
+ if (workspaceId === baseWorkspace) {
2641
+ continue;
2642
+ }
2643
+ lines.push(` ${workspaceId}:`, " extends: [base]");
2644
+ }
2539
2645
  }
2646
+ lines.push(
2647
+ "profiles:",
2648
+ " default: local",
2649
+ "envMapping:",
2650
+ " convention: SCREAMING_SNAKE",
2651
+ "public:",
2652
+ " promote: []",
2653
+ ""
2654
+ );
2540
2655
  return lines.join("\n");
2541
2656
  }
2542
2657
  async function ensureFile(filePath, content) {
@@ -2576,8 +2691,7 @@ async function ensureGitignore(root) {
2576
2691
  `, "utf8");
2577
2692
  return true;
2578
2693
  }
2579
- async function scaffoldWorkspace(root, workspace) {
2580
- const cnosRoot = path9.join(root, ".cnos");
2694
+ async function ensureWorkspaceLayout(cnosRoot, workspace) {
2581
2695
  const workspaceRoot = workspace ? path9.join(cnosRoot, "workspaces", workspace) : cnosRoot;
2582
2696
  const createdPaths = [];
2583
2697
  await mkdir4(path9.join(workspaceRoot, "profiles"), { recursive: true });
@@ -2598,21 +2712,44 @@ async function scaffoldWorkspace(root, workspace) {
2598
2712
  for (const relativePath of relativePaths) {
2599
2713
  const filePath = path9.join(cnosRoot, ...relativePath);
2600
2714
  if (await ensureFile(filePath, "")) {
2601
- createdPaths.push(path9.relative(root, filePath).replace(/\\/g, "/"));
2715
+ createdPaths.push(path9.relative(path9.dirname(cnosRoot), filePath).replace(/\\/g, "/"));
2602
2716
  }
2603
2717
  }
2604
- if (await ensureFile(path9.join(cnosRoot, "cnos.yml"), scaffoldManifest(path9.basename(root), workspace))) {
2605
- createdPaths.push(".cnos/cnos.yml");
2606
- }
2607
- if (await ensureFile(
2718
+ return createdPaths;
2719
+ }
2720
+ async function ensureCnosrc(root, workspace) {
2721
+ return ensureFile(
2608
2722
  path9.join(root, ".cnosrc.yml"),
2609
2723
  workspace ? `root: ./.cnos
2610
2724
  workspace: ${workspace}
2611
2725
  ` : "root: ./.cnos\n"
2612
- )) {
2726
+ );
2727
+ }
2728
+ async function scaffoldProject(root, options = {}) {
2729
+ const mode = options.mode ?? "regular";
2730
+ const baseWorkspace = options.workspace ?? "base";
2731
+ const childWorkspaces = mode === "workspace" ? (options.workspaces ?? []).filter((workspaceId) => workspaceId !== baseWorkspace) : [];
2732
+ const cnosRoot = path9.join(root, ".cnos");
2733
+ const createdPaths = [];
2734
+ if (mode === "workspace") {
2735
+ createdPaths.push(
2736
+ ...(await ensureWorkspaceLayout(cnosRoot, baseWorkspace)).map((entry) => entry.replace(/^\.cnos\//, ".cnos/"))
2737
+ );
2738
+ for (const workspaceId of childWorkspaces) {
2739
+ createdPaths.push(
2740
+ ...(await ensureWorkspaceLayout(cnosRoot, workspaceId)).map((entry) => entry.replace(/^\.cnos\//, ".cnos/"))
2741
+ );
2742
+ }
2743
+ } else {
2744
+ createdPaths.push(...(await ensureWorkspaceLayout(cnosRoot)).map((entry) => entry.replace(/^\.cnos\//, ".cnos/")));
2745
+ }
2746
+ if (await ensureFile(path9.join(cnosRoot, "cnos.yml"), scaffoldManifest(path9.basename(root), options))) {
2747
+ createdPaths.push(".cnos/cnos.yml");
2748
+ }
2749
+ if (await ensureCnosrc(root, mode === "workspace" ? baseWorkspace : void 0)) {
2613
2750
  createdPaths.push(".cnosrc.yml");
2614
2751
  }
2615
- if (workspace && await ensureFile(path9.join(root, ".cnos-workspace.yml"), `workspace: ${workspace}
2752
+ if (mode === "workspace" && await ensureFile(path9.join(root, ".cnos-workspace.yml"), `workspace: ${baseWorkspace}
2616
2753
  globalRoot: ~/.cnos
2617
2754
  `)) {
2618
2755
  createdPaths.push(".cnos-workspace.yml");
@@ -2622,20 +2759,42 @@ globalRoot: ~/.cnos
2622
2759
  }
2623
2760
  return {
2624
2761
  root,
2625
- ...workspace ? { workspace } : {},
2762
+ mode,
2763
+ ...mode === "workspace" ? { workspace: baseWorkspace, workspaces: [baseWorkspace, ...childWorkspaces] } : {},
2626
2764
  created: createdPaths
2627
2765
  };
2628
2766
  }
2629
2767
 
2630
2768
  // src/commands/init.ts
2769
+ function parseWorkspaceList(value) {
2770
+ if (!value) {
2771
+ return [];
2772
+ }
2773
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
2774
+ }
2631
2775
  async function runInit(options = {}) {
2632
2776
  const root = path10.resolve(options.root ?? process.cwd());
2633
- const result = await scaffoldWorkspace(root, options.workspace);
2777
+ const cliArgs = [...options.cliArgs ?? []];
2778
+ const modeOption = consumeOption(cliArgs, "--mode");
2779
+ const workspacesOption = consumeOption(cliArgs, "--workspaces");
2780
+ if (cliArgs.length > 0) {
2781
+ throw new Error(`Unsupported init arguments: ${cliArgs.join(" ")}`);
2782
+ }
2783
+ const mode = modeOption === void 0 ? options.workspace ? "workspace" : "regular" : modeOption === "workspace" || modeOption === "regular" ? modeOption : void 0;
2784
+ if (!mode) {
2785
+ throw new Error(`Invalid value for --mode: ${modeOption}. Use "regular" or "workspace".`);
2786
+ }
2787
+ const workspaces = parseWorkspaceList(workspacesOption);
2788
+ const result = await scaffoldProject(root, {
2789
+ mode,
2790
+ ...mode === "workspace" ? { workspace: options.workspace ?? "base", workspaces } : {}
2791
+ });
2634
2792
  if (options.json) {
2635
2793
  return printJson(result);
2636
2794
  }
2637
- if (result.workspace) {
2638
- return `initialized CNOS workspace ${result.workspace} at ${root}`;
2795
+ if (result.mode === "workspace" && result.workspace) {
2796
+ const suffix = result.workspaces && result.workspaces.length > 1 ? ` (${result.workspaces.slice(1).join(", ")} extends ${result.workspace})` : "";
2797
+ return `initialized CNOS workspace project at ${root} with base workspace ${result.workspace}${suffix}`;
2639
2798
  }
2640
2799
  return `initialized CNOS project at ${root}`;
2641
2800
  }
@@ -3034,67 +3193,289 @@ async function runNamespace(namespace, args = [], options = {}) {
3034
3193
  }
3035
3194
 
3036
3195
  // src/commands/onboard.ts
3037
- import { copyFile, readdir as readdir3, rm as rm2 } from "fs/promises";
3196
+ import { copyFile, mkdir as mkdir5, readdir as readdir3, rm as rm2, stat as stat2, readFile as readFile4 } from "fs/promises";
3038
3197
  import path13 from "path";
3198
+ import readline from "readline/promises";
3199
+ import { loadManifest as loadManifest5, parseYaml as parseYaml3 } from "@kitsy/cnos/internal";
3200
+ import { parse as parseToml } from "smol-toml";
3039
3201
  var ROOT_ENV_FILE_PATTERN = /^\.env(?:\.[A-Za-z0-9_-]+)*(?:\.example)?$/;
3202
+ var SECRET_LIKE_PATTERN = /(secret|token|password|passwd|private|api[_-]?key|client[_-]?secret|dsn)/i;
3203
+ async function exists(targetPath) {
3204
+ try {
3205
+ await stat2(targetPath);
3206
+ return true;
3207
+ } catch {
3208
+ return false;
3209
+ }
3210
+ }
3040
3211
  async function listRootEnvFiles(root) {
3041
3212
  const entries = await readdir3(root, { withFileTypes: true });
3042
3213
  return entries.filter((entry) => entry.isFile() && ROOT_ENV_FILE_PATTERN.test(entry.name)).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
3043
3214
  }
3215
+ function normalizePathSegment(value) {
3216
+ return value.trim().replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
3217
+ }
3218
+ function toLogicalPath(sourceKey, prefixSegments) {
3219
+ const derivedSegments = sourceKey.split(/[._-]+/).map((segment) => normalizePathSegment(segment)).filter(Boolean);
3220
+ return [...prefixSegments, ...derivedSegments].join(".");
3221
+ }
3222
+ function toWarning(sourceKey) {
3223
+ return SECRET_LIKE_PATTERN.test(sourceKey) ? "looks like a secret" : void 0;
3224
+ }
3225
+ function createProposedMapping(source, pathKey, value, warning) {
3226
+ return {
3227
+ source,
3228
+ key: `value.${pathKey}`,
3229
+ path: pathKey,
3230
+ value,
3231
+ ...warning ? { warning } : {}
3232
+ };
3233
+ }
3234
+ function parseEnv(content) {
3235
+ const values = {};
3236
+ for (const rawLine of content.split(/\r?\n/)) {
3237
+ const line = rawLine.trim();
3238
+ if (!line || line.startsWith("#")) {
3239
+ continue;
3240
+ }
3241
+ const separator = line.indexOf("=");
3242
+ if (separator <= 0) {
3243
+ continue;
3244
+ }
3245
+ const key = line.slice(0, separator).trim();
3246
+ let value = line.slice(separator + 1).trim();
3247
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
3248
+ value = value.slice(1, -1);
3249
+ }
3250
+ values[key] = value;
3251
+ }
3252
+ return values;
3253
+ }
3254
+ function flattenStructured(value, prefixSegments, currentKey = []) {
3255
+ if (Array.isArray(value) || value === null || typeof value !== "object") {
3256
+ const pathKey = [...prefixSegments, ...currentKey.map((segment) => normalizePathSegment(segment)).filter(Boolean)].join(".");
3257
+ const sourceKey = currentKey.join(".");
3258
+ return pathKey ? [
3259
+ createProposedMapping(sourceKey, pathKey, value, sourceKey ? toWarning(sourceKey) : void 0)
3260
+ ] : [];
3261
+ }
3262
+ return Object.entries(value).flatMap(
3263
+ ([key, nested]) => flattenStructured(nested, prefixSegments, [...currentKey, key])
3264
+ );
3265
+ }
3266
+ async function parseSource(input, prefixSegments) {
3267
+ const content = await readFile4(input.filePath, "utf8");
3268
+ switch (input.kind) {
3269
+ case "env":
3270
+ return Object.entries(parseEnv(content)).map(([sourceKey, value]) => {
3271
+ const logicalPath = toLogicalPath(sourceKey, prefixSegments);
3272
+ return createProposedMapping(sourceKey, logicalPath, value, toWarning(sourceKey));
3273
+ });
3274
+ case "yaml":
3275
+ return flattenStructured(parseYaml3(content), prefixSegments);
3276
+ case "json":
3277
+ return flattenStructured(JSON.parse(content), prefixSegments);
3278
+ case "toml":
3279
+ return flattenStructured(parseToml(content), prefixSegments);
3280
+ }
3281
+ }
3282
+ function detectKindFromPath(filePath) {
3283
+ const ext = path13.extname(filePath).toLowerCase();
3284
+ switch (ext) {
3285
+ case ".env":
3286
+ return "env";
3287
+ case ".yaml":
3288
+ case ".yml":
3289
+ return "yaml";
3290
+ case ".json":
3291
+ return "json";
3292
+ case ".toml":
3293
+ return "toml";
3294
+ default:
3295
+ throw new Error(`Unsupported config format for ${filePath}. Use --env, --yaml, --json, --toml, or --config with a supported extension.`);
3296
+ }
3297
+ }
3298
+ function buildPrefixSegments(prefix) {
3299
+ if (!prefix) {
3300
+ return [];
3301
+ }
3302
+ return prefix.split(".").map((segment) => normalizePathSegment(segment)).filter(Boolean);
3303
+ }
3304
+ function formatProposals(proposed) {
3305
+ if (proposed.length === 0) {
3306
+ return ["No value mappings were discovered."];
3307
+ }
3308
+ return proposed.map((entry) => {
3309
+ const renderedValue = typeof entry.value === "string" ? JSON.stringify(entry.value) : JSON.stringify(entry.value);
3310
+ return ` ${entry.source || entry.path} -> ${entry.key} = ${renderedValue}${entry.warning ? ` [${entry.warning}]` : ""}`;
3311
+ });
3312
+ }
3313
+ async function promptForMaterialize() {
3314
+ const rl = readline.createInterface({
3315
+ input: process.stdin,
3316
+ output: process.stdout
3317
+ });
3318
+ try {
3319
+ const answer = (await rl.question("Materialize these values into value.*? [Y/n] ")).trim().toLowerCase();
3320
+ return answer === "" || answer === "y" || answer === "yes";
3321
+ } finally {
3322
+ rl.close();
3323
+ }
3324
+ }
3325
+ function isInteractive(options) {
3326
+ const processEnv = options.processEnv ?? process.env;
3327
+ return !processEnv.CI && process.stdin.isTTY && process.stdout.isTTY;
3328
+ }
3329
+ function resolveSourceInputs(root, cliArgs) {
3330
+ const envFile = consumeOption(cliArgs, "--env");
3331
+ const yamlFile = consumeOption(cliArgs, "--yaml");
3332
+ const jsonFile = consumeOption(cliArgs, "--json");
3333
+ const tomlFile = consumeOption(cliArgs, "--toml");
3334
+ const configFile = consumeOption(cliArgs, "--config");
3335
+ const explicit = [
3336
+ envFile ? { kind: "env", filePath: envFile } : void 0,
3337
+ yamlFile ? { kind: "yaml", filePath: yamlFile } : void 0,
3338
+ jsonFile ? { kind: "json", filePath: jsonFile } : void 0,
3339
+ tomlFile ? { kind: "toml", filePath: tomlFile } : void 0,
3340
+ configFile ? { kind: detectKindFromPath(configFile), filePath: configFile } : void 0
3341
+ ].filter(Boolean);
3342
+ if (explicit.length > 1) {
3343
+ throw new Error("Use only one explicit source flag per onboard invocation.");
3344
+ }
3345
+ if (explicit.length === 1) {
3346
+ const source = explicit[0];
3347
+ if (!source) {
3348
+ return [];
3349
+ }
3350
+ const resolvedPath = path13.resolve(root, source.filePath);
3351
+ return [
3352
+ {
3353
+ kind: source.kind,
3354
+ filePath: resolvedPath,
3355
+ displayName: path13.basename(resolvedPath)
3356
+ }
3357
+ ];
3358
+ }
3359
+ return [];
3360
+ }
3044
3361
  async function runOnboard(options = {}) {
3045
3362
  const root = path13.resolve(options.root ?? process.cwd());
3046
- const workspace = options.workspace ?? path13.basename(root);
3047
3363
  const cliArgs = [...options.cliArgs ?? []];
3048
3364
  const move = consumeFlag(cliArgs, "--move");
3365
+ const materialize = consumeFlag(cliArgs, "--materialize");
3366
+ const sourceOnly = consumeFlag(cliArgs, "--source-only");
3367
+ const prefix = consumeOption(cliArgs, "--prefix");
3368
+ if (materialize && sourceOnly) {
3369
+ throw new Error("Use either --materialize or --source-only, not both.");
3370
+ }
3371
+ const explicitSources = resolveSourceInputs(root, cliArgs);
3049
3372
  if (cliArgs.length > 0) {
3050
3373
  throw new Error(`Unsupported onboard arguments: ${cliArgs.join(" ")}`);
3051
3374
  }
3052
- const scaffold = await scaffoldWorkspace(root, workspace);
3053
- const envRoot = path13.join(root, ".cnos", "workspaces", workspace, "env");
3054
- const rootFiles = await listRootEnvFiles(root);
3375
+ let scaffolded = [];
3376
+ const manifestPath = path13.join(root, ".cnos", "cnos.yml");
3377
+ if (!await exists(manifestPath)) {
3378
+ const scaffold = await scaffoldProject(root, {
3379
+ mode: options.workspace && options.workspace !== "base" ? "workspace" : "regular",
3380
+ ...options.workspace && options.workspace !== "base" ? { workspaces: [options.workspace] } : {}
3381
+ });
3382
+ scaffolded = scaffold.created;
3383
+ }
3384
+ const loaded = await loadManifest5({
3385
+ root,
3386
+ ...options.processEnv ? { processEnv: options.processEnv } : {}
3387
+ });
3388
+ const isWorkspaceMode = Object.keys(loaded.manifest.workspaces.items).length > 0;
3389
+ const selectedWorkspace = isWorkspaceMode ? options.workspace ?? (loaded.manifest.workspaces.items.base ? "base" : loaded.manifest.workspaces.default ?? "base") : "base";
3390
+ if (isWorkspaceMode && !loaded.manifest.workspaces.items[selectedWorkspace]) {
3391
+ throw new Error(`Workspace "${selectedWorkspace}" does not exist in this CNOS root.`);
3392
+ }
3393
+ if (!isWorkspaceMode && options.workspace && options.workspace !== "base") {
3394
+ throw new Error("This repo is still in regular mode. Run `cnos workspace enable` before onboarding into a child workspace.");
3395
+ }
3396
+ const envRoot = isWorkspaceMode ? path13.join(root, ".cnos", "workspaces", selectedWorkspace, "env") : path13.join(root, ".cnos", "env");
3397
+ await mkdir5(envRoot, { recursive: true });
3398
+ const rootFiles = explicitSources.length > 0 ? explicitSources : (await listRootEnvFiles(root)).map((fileName) => ({
3399
+ kind: "env",
3400
+ filePath: path13.join(root, fileName),
3401
+ displayName: fileName
3402
+ }));
3055
3403
  const imported = [];
3056
3404
  const skipped = [];
3057
- for (const fileName of rootFiles) {
3058
- const sourcePath = path13.join(root, fileName);
3059
- const targetPath = path13.join(envRoot, fileName);
3405
+ const prefixSegments = buildPrefixSegments(prefix);
3406
+ const proposed = [];
3407
+ for (const source of rootFiles) {
3408
+ const targetPath = path13.join(envRoot, source.displayName);
3060
3409
  try {
3061
- await copyFile(sourcePath, targetPath);
3410
+ await copyFile(source.filePath, targetPath);
3062
3411
  imported.push(path13.relative(root, targetPath).replace(/\\/g, "/"));
3063
3412
  if (move) {
3064
- await rm2(sourcePath);
3413
+ await rm2(source.filePath);
3065
3414
  }
3066
3415
  } catch {
3067
- skipped.push(fileName);
3416
+ skipped.push(source.displayName);
3417
+ continue;
3418
+ }
3419
+ proposed.push(...await parseSource(source, prefixSegments));
3420
+ }
3421
+ const shouldMaterialize = materialize || (!sourceOnly && isInteractive(options) && proposed.length > 0 ? await promptForMaterialize() : false);
3422
+ const materialized = [];
3423
+ if (shouldMaterialize) {
3424
+ for (const entry of proposed) {
3425
+ await defineValue("value", entry.path, String(entry.value ?? ""), {
3426
+ root,
3427
+ ...isWorkspaceMode ? { workspace: selectedWorkspace } : {},
3428
+ parsedValue: entry.value
3429
+ });
3430
+ materialized.push(entry.key);
3068
3431
  }
3069
3432
  }
3070
3433
  const result = {
3071
3434
  root,
3072
- workspace,
3073
- scaffolded: scaffold.created,
3435
+ workspace: selectedWorkspace,
3436
+ mode: move ? "move" : "copy",
3437
+ storageMode: isWorkspaceMode ? "workspace" : "regular",
3438
+ scaffolded,
3074
3439
  imported,
3075
3440
  skipped,
3076
- mode: move ? "move" : "copy"
3441
+ proposed,
3442
+ materialized
3077
3443
  };
3078
3444
  if (options.json) {
3079
3445
  return printJson(result);
3080
3446
  }
3081
- const importedCount = imported.length;
3082
- const skippedSuffix = skipped.length > 0 ? ` (${skipped.length} skipped)` : "";
3083
- return `onboarded ${workspace} at ${root}; imported ${importedCount} root env files into .cnos/workspaces/${workspace}/env using ${result.mode}${skippedSuffix}`;
3447
+ const lines = [
3448
+ `onboarded ${selectedWorkspace} at ${root}`,
3449
+ `Imported ${imported.length} source file(s) into ${path13.relative(root, envRoot).replace(/\\/g, "/") || ".cnos/env"} using ${result.mode}.`,
3450
+ "",
3451
+ `Discovered ${proposed.length} proposed value mapping(s):`,
3452
+ ...formatProposals(proposed)
3453
+ ];
3454
+ if (!shouldMaterialize && !sourceOnly && !isInteractive(options) && proposed.length > 0) {
3455
+ lines.push("", "Non-interactive mode detected; defaulted to source-only. Re-run with --materialize to write value.* keys.");
3456
+ } else if (shouldMaterialize) {
3457
+ lines.push("", `Materialized ${materialized.length} value key(s).`);
3458
+ } else if (sourceOnly) {
3459
+ lines.push("", "Skipped value materialization because --source-only was set.");
3460
+ }
3461
+ if (skipped.length > 0) {
3462
+ lines.push("", `Skipped ${skipped.length} source file(s): ${skipped.join(", ")}`);
3463
+ }
3464
+ return lines.join("\n");
3084
3465
  }
3085
3466
 
3086
3467
  // src/commands/profile.ts
3087
3468
  import path16 from "path";
3088
3469
 
3089
3470
  // src/services/context.ts
3090
- import { readFile as readFile4, writeFile as writeFile5 } from "fs/promises";
3471
+ import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
3091
3472
  import path14 from "path";
3092
- import { parseYaml as parseYaml3, stringifyYaml as stringifyYaml3 } from "@kitsy/cnos/internal";
3473
+ import { parseYaml as parseYaml4, stringifyYaml as stringifyYaml3 } from "@kitsy/cnos/internal";
3093
3474
  async function loadCliContext(root = process.cwd()) {
3094
3475
  const filePath = path14.join(path14.resolve(root), ".cnos-workspace.yml");
3095
3476
  try {
3096
- const source = await readFile4(filePath, "utf8");
3097
- const parsed = parseYaml3(source);
3477
+ const source = await readFile5(filePath, "utf8");
3478
+ const parsed = parseYaml4(source);
3098
3479
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3099
3480
  return {};
3100
3481
  }
@@ -3123,21 +3504,21 @@ async function saveCliContext(options = {}) {
3123
3504
  }
3124
3505
 
3125
3506
  // src/services/profiles.ts
3126
- import { mkdir as mkdir5, readdir as readdir4, readFile as readFile5, rm as rm3, writeFile as writeFile6 } from "fs/promises";
3507
+ import { mkdir as mkdir6, readdir as readdir4, readFile as readFile6, rm as rm3, writeFile as writeFile6 } from "fs/promises";
3127
3508
  import path15 from "path";
3128
- import { loadManifest as loadManifest5, parseYaml as parseYaml4, stringifyYaml as stringifyYaml4 } from "@kitsy/cnos/internal";
3509
+ import { loadManifest as loadManifest6, parseYaml as parseYaml5, stringifyYaml as stringifyYaml4 } from "@kitsy/cnos/internal";
3129
3510
  async function resolveProfilesRoot(root = process.cwd()) {
3130
3511
  try {
3131
- const loadedManifest = await loadManifest5({ root });
3512
+ const loadedManifest = await loadManifest6({ root });
3132
3513
  return path15.join(loadedManifest.manifestRoot, "profiles");
3133
3514
  } catch {
3134
- const loadedManifest = await loadManifest5({ cwd: root });
3515
+ const loadedManifest = await loadManifest6({ cwd: root });
3135
3516
  return path15.join(loadedManifest.manifestRoot, "profiles");
3136
3517
  }
3137
3518
  }
3138
3519
  async function createProfileDefinition(root = process.cwd(), profile, inherit, options = {}) {
3139
3520
  const filePath = path15.join(await resolveProfilesRoot(root), `${profile}.yml`);
3140
- await mkdir5(path15.dirname(filePath), { recursive: true });
3521
+ await mkdir6(path15.dirname(filePath), { recursive: true });
3141
3522
  const document = options.noInherit ? {
3142
3523
  name: profile,
3143
3524
  activate: {
@@ -3197,7 +3578,7 @@ async function readProfileDefinition(root = process.cwd(), profile = "base") {
3197
3578
  }
3198
3579
  const filePath = path15.join(await resolveProfilesRoot(root), `${profile}.yml`);
3199
3580
  try {
3200
- return parseYaml4(await readFile5(filePath, "utf8")) ?? void 0;
3581
+ return parseYaml5(await readFile6(filePath, "utf8")) ?? void 0;
3201
3582
  } catch {
3202
3583
  return void 0;
3203
3584
  }
@@ -3277,7 +3658,7 @@ import path17 from "path";
3277
3658
  import { writeFile as writeFile7 } from "fs/promises";
3278
3659
  import {
3279
3660
  ensureProjectionAllowed,
3280
- loadManifest as loadManifest6,
3661
+ loadManifest as loadManifest7,
3281
3662
  stringifyYaml as stringifyYaml5
3282
3663
  } from "@kitsy/cnos/internal";
3283
3664
  function normalizeTarget(value) {
@@ -3306,7 +3687,7 @@ async function runPromote(args = [], options = {}) {
3306
3687
  throw new Error("promote --to env requires --as <ENV_VAR>");
3307
3688
  }
3308
3689
  }
3309
- const loadedManifest = await loadManifest6({
3690
+ const loadedManifest = await loadManifest7({
3310
3691
  ...options.root ? { root: options.root } : {},
3311
3692
  ...options.cwd ? { cwd: options.cwd } : {},
3312
3693
  ...options.processEnv ? { processEnv: options.processEnv } : {},
@@ -3473,7 +3854,7 @@ import {
3473
3854
  createSecretVault,
3474
3855
  deriveVaultKey,
3475
3856
  listLocalSecrets,
3476
- loadManifest as loadManifest7,
3857
+ loadManifest as loadManifest8,
3477
3858
  listSecretVaults,
3478
3859
  readVaultMetadata,
3479
3860
  resolveSecretStoreRoot as resolveSecretStoreRoot2,
@@ -3512,7 +3893,7 @@ async function createVaultDefinition(name, options = {}) {
3512
3893
  if (provider === "local" && (options.noPassphrase ?? false)) {
3513
3894
  throw new Error("Local vaults cannot be passwordless.");
3514
3895
  }
3515
- const loadedManifest = await loadManifest7({
3896
+ const loadedManifest = await loadManifest8({
3516
3897
  ...options.root ? { root: options.root } : {},
3517
3898
  ...options.cwd ? { cwd: options.cwd } : {},
3518
3899
  ...options.processEnv ? { processEnv: options.processEnv } : {},
@@ -3549,7 +3930,7 @@ async function createVaultDefinition(name, options = {}) {
3549
3930
  };
3550
3931
  }
3551
3932
  async function listVaultDefinitions(options = {}) {
3552
- const loadedManifest = await loadManifest7({
3933
+ const loadedManifest = await loadManifest8({
3553
3934
  ...options.root ? { root: options.root } : {},
3554
3935
  ...options.cwd ? { cwd: options.cwd } : {},
3555
3936
  ...options.processEnv ? { processEnv: options.processEnv } : {},
@@ -3570,7 +3951,7 @@ async function listVaultDefinitions(options = {}) {
3570
3951
  async function removeVaultDefinition(name, options = {}) {
3571
3952
  await assertWritableConfigRoot(`remove vault ${name}`, options);
3572
3953
  const vault = name.trim() || "default";
3573
- const loadedManifest = await loadManifest7({
3954
+ const loadedManifest = await loadManifest8({
3574
3955
  ...options.root ? { root: options.root } : {},
3575
3956
  ...options.cwd ? { cwd: options.cwd } : {},
3576
3957
  ...options.processEnv ? { processEnv: options.processEnv } : {},
@@ -3616,7 +3997,7 @@ async function listLocalStoreVaults(options = {}) {
3616
3997
  }
3617
3998
  async function authenticateVault(name, options = {}) {
3618
3999
  const vault = name.trim() || "default";
3619
- const loadedManifest = await loadManifest7({
4000
+ const loadedManifest = await loadManifest8({
3620
4001
  ...options.root ? { root: options.root } : {},
3621
4002
  ...options.cwd ? { cwd: options.cwd } : {},
3622
4003
  ...options.processEnv ? { processEnv: options.processEnv } : {},
@@ -3937,7 +4318,7 @@ async function runValidate(options = {}) {
3937
4318
  // package.json
3938
4319
  var package_default = {
3939
4320
  name: "@kitsy/cnos-cli",
3940
- version: "1.7.0",
4321
+ version: "1.8.1",
3941
4322
  description: "CLI entry point and developer tooling for CNOS.",
3942
4323
  type: "module",
3943
4324
  main: "./dist/index.js",
@@ -3973,7 +4354,8 @@ var package_default = {
3973
4354
  access: "public"
3974
4355
  },
3975
4356
  dependencies: {
3976
- "@kitsy/cnos": "workspace:*"
4357
+ "@kitsy/cnos": "workspace:*",
4358
+ "smol-toml": "^1.4.2"
3977
4359
  },
3978
4360
  scripts: {
3979
4361
  build: "tsup src/index.ts --format esm --dts",
@@ -4212,40 +4594,52 @@ async function runWatch(command, options = {}) {
4212
4594
  }
4213
4595
 
4214
4596
  // src/commands/workspace.ts
4215
- import { cp, mkdir as mkdir6, rename, rm as rm5, stat as stat2, writeFile as writeFile9, readFile as readFile6 } from "fs/promises";
4597
+ import { cp, mkdir as mkdir7, readdir as readdir5, readFile as readFile7, rename, rm as rm5, stat as stat3, writeFile as writeFile9 } from "fs/promises";
4216
4598
  import path23 from "path";
4217
- import { loadManifest as loadManifest8, parseYaml as parseYaml5, stringifyYaml as stringifyYaml7 } from "@kitsy/cnos/internal";
4218
- async function exists(targetPath) {
4599
+ import { loadManifest as loadManifest9, parseYaml as parseYaml6, stringifyYaml as stringifyYaml7 } from "@kitsy/cnos/internal";
4600
+ async function exists2(targetPath) {
4219
4601
  try {
4220
- await stat2(targetPath);
4602
+ await stat3(targetPath);
4221
4603
  return true;
4222
4604
  } catch {
4223
4605
  return false;
4224
4606
  }
4225
4607
  }
4226
4608
  async function copyIfExists(source, target) {
4227
- if (!await exists(source)) {
4609
+ if (!await exists2(source)) {
4228
4610
  return;
4229
4611
  }
4230
- await mkdir6(path23.dirname(target), { recursive: true });
4612
+ await mkdir7(path23.dirname(target), { recursive: true });
4231
4613
  await cp(source, target, { recursive: true, force: true });
4232
4614
  }
4615
+ async function moveIfExists(source, target, force = false) {
4616
+ if (!await exists2(source)) {
4617
+ return false;
4618
+ }
4619
+ if (force) {
4620
+ await rm5(target, { recursive: true, force: true });
4621
+ } else if (await exists2(target)) {
4622
+ throw new Error(`Refusing to overwrite existing path ${target}. Use --force to replace it.`);
4623
+ }
4624
+ await mkdir7(path23.dirname(target), { recursive: true });
4625
+ await rename(source, target);
4626
+ return true;
4627
+ }
4233
4628
  async function mergeWorkspaceRootsIntoStandalone(targetCnosRoot, sourceRoots) {
4234
4629
  for (const sourceRoot of sourceRoots) {
4235
4630
  for (const folderName of ["values", "secrets", "env", "profiles"]) {
4236
- await copyIfExists(
4237
- path23.join(sourceRoot, folderName),
4238
- path23.join(targetCnosRoot, folderName)
4239
- );
4631
+ await copyIfExists(path23.join(sourceRoot, folderName), path23.join(targetCnosRoot, folderName));
4240
4632
  }
4241
4633
  }
4242
4634
  }
4243
- async function writeCnosrc(packageRoot, config) {
4635
+ async function writeAnchor(packageRoot, manifestRoot, workspace) {
4636
+ const relativeRoot = path23.relative(packageRoot, manifestRoot).replace(/\\/g, "/");
4637
+ const rootValue = relativeRoot.length === 0 ? "./.cnos" : relativeRoot.startsWith(".") ? relativeRoot : `./${relativeRoot}`;
4244
4638
  await writeFile9(
4245
4639
  path23.join(packageRoot, ".cnosrc.yml"),
4246
4640
  stringifyYaml7({
4247
- root: config.root,
4248
- ...config.workspace ? { workspace: config.workspace } : {}
4641
+ root: rootValue,
4642
+ ...workspace ? { workspace } : {}
4249
4643
  }),
4250
4644
  "utf8"
4251
4645
  );
@@ -4257,14 +4651,72 @@ function createDetachedManifest(rawManifest) {
4257
4651
  }
4258
4652
  return next;
4259
4653
  }
4654
+ function normalizeWorkspaceId(value) {
4655
+ const workspaceId = value?.trim();
4656
+ if (!workspaceId) {
4657
+ throw new Error("Workspace id is required");
4658
+ }
4659
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(workspaceId)) {
4660
+ throw new Error(`Invalid workspace id "${workspaceId}". Use letters, numbers, dot, underscore, or dash.`);
4661
+ }
4662
+ return workspaceId;
4663
+ }
4664
+ function splitExtends(value) {
4665
+ if (!value) {
4666
+ return void 0;
4667
+ }
4668
+ if (value.trim() === "none") {
4669
+ return [];
4670
+ }
4671
+ const items = value.split(",").map((entry) => entry.trim()).filter(Boolean);
4672
+ return items.length > 0 ? items : void 0;
4673
+ }
4674
+ async function hasDirectConfigData(cnosRoot) {
4675
+ for (const folderName of ["values", "secrets", "env", "profiles"]) {
4676
+ const folder = path23.join(cnosRoot, folderName);
4677
+ if (!await exists2(folder)) {
4678
+ continue;
4679
+ }
4680
+ const entries = await readdir5(folder, { withFileTypes: true });
4681
+ if (entries.some((entry) => entry.name !== ".gitkeep")) {
4682
+ return true;
4683
+ }
4684
+ }
4685
+ return false;
4686
+ }
4687
+ async function updateRootAnchorToWorkspace(packageRoot, workspaceId) {
4688
+ const anchorPath = path23.join(packageRoot, ".cnosrc.yml");
4689
+ const current = await exists2(anchorPath) ? parseYaml6(await readFile7(anchorPath, "utf8")) : void 0;
4690
+ await writeFile9(
4691
+ anchorPath,
4692
+ stringifyYaml7({
4693
+ root: typeof current?.root === "string" ? current.root : "./.cnos",
4694
+ workspace: workspaceId
4695
+ }),
4696
+ "utf8"
4697
+ );
4698
+ }
4699
+ async function updateWorkspaceContext(packageRoot, workspaceId) {
4700
+ const workspacePath = path23.join(packageRoot, ".cnos-workspace.yml");
4701
+ const current = await exists2(workspacePath) ? parseYaml6(await readFile7(workspacePath, "utf8")) : void 0;
4702
+ await writeFile9(
4703
+ workspacePath,
4704
+ stringifyYaml7({
4705
+ workspace: workspaceId,
4706
+ ...typeof current?.profile === "string" ? { profile: current.profile } : {},
4707
+ ...typeof current?.globalRoot === "string" ? { globalRoot: current.globalRoot } : { globalRoot: "~/.cnos" }
4708
+ }),
4709
+ "utf8"
4710
+ );
4711
+ }
4260
4712
  async function runDetach(packageRoot, options = {}) {
4261
- const loaded = await loadManifest8({ cwd: packageRoot });
4713
+ const loaded = await loadManifest9({ cwd: packageRoot });
4262
4714
  if (!loaded.anchorPath || !loaded.anchoredWorkspace) {
4263
4715
  throw new Error("workspace detach requires a package-local .cnosrc.yml with a workspace binding");
4264
4716
  }
4265
4717
  const targetCnosRoot = path23.join(packageRoot, ".cnos");
4266
4718
  const force = consumeFlag([...options.cliArgs ?? []], "--force");
4267
- if (await exists(targetCnosRoot) && !force) {
4719
+ if (await exists2(targetCnosRoot) && !force) {
4268
4720
  throw new Error(`Refusing to detach because ${displayPath(targetCnosRoot, packageRoot)} already exists. Use --force to overwrite.`);
4269
4721
  }
4270
4722
  if (force) {
@@ -4276,7 +4728,7 @@ async function runDetach(packageRoot, options = {}) {
4276
4728
  workspace: loaded.anchoredWorkspace
4277
4729
  });
4278
4730
  const localRoots = runtime.graph.workspace.workspaceRoots.filter((root) => root.scope === "local").map((root) => root.path);
4279
- await mkdir6(targetCnosRoot, { recursive: true });
4731
+ await mkdir7(targetCnosRoot, { recursive: true });
4280
4732
  await mergeWorkspaceRootsIntoStandalone(targetCnosRoot, localRoots);
4281
4733
  await writeFile9(
4282
4734
  path23.join(targetCnosRoot, "cnos.yml"),
@@ -4294,7 +4746,7 @@ async function runDetach(packageRoot, options = {}) {
4294
4746
  }
4295
4747
  };
4296
4748
  await writeFile9(path23.join(targetCnosRoot, ".detached"), stringifyYaml7(marker), "utf8");
4297
- await writeCnosrc(packageRoot, { root: "./.cnos" });
4749
+ await writeFile9(path23.join(packageRoot, ".cnosrc.yml"), stringifyYaml7({ root: "./.cnos" }), "utf8");
4298
4750
  if (options.json) {
4299
4751
  return printJson({
4300
4752
  packageRoot,
@@ -4309,15 +4761,15 @@ async function runAttach(packageRoot, options = {}) {
4309
4761
  const force = consumeFlag(cliArgs, "--force");
4310
4762
  const childCnosRoot = path23.join(packageRoot, ".cnos");
4311
4763
  const markerPath = path23.join(childCnosRoot, ".detached");
4312
- if (!await exists(markerPath)) {
4764
+ if (!await exists2(markerPath)) {
4313
4765
  throw new Error("workspace attach requires a detached package with .cnos/.detached");
4314
4766
  }
4315
- const marker = parseYaml5(await readFile6(markerPath, "utf8"));
4767
+ const marker = parseYaml6(await readFile7(markerPath, "utf8"));
4316
4768
  if (!marker?.originalCnosrc?.root || !marker.detachedWorkspace) {
4317
4769
  throw new Error("Invalid .detached marker");
4318
4770
  }
4319
4771
  const parentManifestRoot = path23.resolve(packageRoot, marker.originalCnosrc.root);
4320
- const parentLoaded = await loadManifest8({ root: parentManifestRoot });
4772
+ const parentLoaded = await loadManifest9({ root: parentManifestRoot });
4321
4773
  if (parentLoaded.rootResolution.readOnly) {
4322
4774
  throw new Error(
4323
4775
  `Cannot attach workspace because the parent CNOS root is remote and read-only (${parentLoaded.rootResolution.rootUri}).`
@@ -4325,17 +4777,17 @@ async function runAttach(packageRoot, options = {}) {
4325
4777
  }
4326
4778
  const workspaceId = marker.originalCnosrc.workspace ?? marker.detachedWorkspace;
4327
4779
  const parentWorkspaceRoot = path23.join(parentLoaded.manifestRoot, "workspaces", workspaceId);
4328
- if (await exists(parentWorkspaceRoot) && !force) {
4780
+ if (await exists2(parentWorkspaceRoot) && !force) {
4329
4781
  throw new Error(`workspace "${workspaceId}" already exists in parent root. Use --force to overwrite.`);
4330
4782
  }
4331
4783
  if (force) {
4332
4784
  await rm5(parentWorkspaceRoot, { recursive: true, force: true });
4333
4785
  }
4334
- await mkdir6(parentWorkspaceRoot, { recursive: true });
4786
+ await mkdir7(parentWorkspaceRoot, { recursive: true });
4335
4787
  for (const folderName of ["values", "secrets", "env", "profiles"]) {
4336
4788
  await copyIfExists(path23.join(childCnosRoot, folderName), path23.join(parentWorkspaceRoot, folderName));
4337
4789
  }
4338
- const rawManifest = parentLoaded.rawManifest;
4790
+ const rawManifest = structuredClone(parentLoaded.rawManifest);
4339
4791
  const workspaces = rawManifest.workspaces ?? {};
4340
4792
  const items = workspaces.items ?? {};
4341
4793
  items[workspaceId] = items[workspaceId] ?? {};
@@ -4345,10 +4797,7 @@ async function runAttach(packageRoot, options = {}) {
4345
4797
  const archivePath = path23.join(packageRoot, ".cnos.detached.bak");
4346
4798
  await rm5(archivePath, { recursive: true, force: true });
4347
4799
  await rename(childCnosRoot, archivePath);
4348
- await writeCnosrc(packageRoot, {
4349
- root: marker.originalCnosrc.root,
4350
- ...workspaceId ? { workspace: workspaceId } : {}
4351
- });
4800
+ await writeAnchor(packageRoot, parentLoaded.manifestRoot, workspaceId);
4352
4801
  if (options.json) {
4353
4802
  return printJson({
4354
4803
  packageRoot,
@@ -4359,15 +4808,210 @@ async function runAttach(packageRoot, options = {}) {
4359
4808
  }
4360
4809
  return `attached workspace ${workspaceId} to ${displayPath(parentLoaded.manifestRoot, packageRoot)}`;
4361
4810
  }
4362
- async function runWorkspace(args = [], options = {}) {
4363
- const [action] = args;
4811
+ async function runList2(manifestCwd, options = {}) {
4812
+ const loaded = await loadManifest9({
4813
+ ...options.root ? { root: options.root } : {},
4814
+ cwd: manifestCwd,
4815
+ ...options.processEnv ? { processEnv: options.processEnv } : {}
4816
+ });
4817
+ const entries = Object.entries(loaded.manifest.workspaces.items).map(([id, config]) => ({
4818
+ id,
4819
+ extends: config.extends,
4820
+ default: loaded.manifest.workspaces.default === id,
4821
+ path: path23.join(loaded.manifestRoot, "workspaces", id)
4822
+ })).sort((left, right) => left.id.localeCompare(right.id));
4823
+ if (options.json) {
4824
+ return printJson({
4825
+ default: loaded.manifest.workspaces.default,
4826
+ workspaces: entries
4827
+ });
4828
+ }
4829
+ if (entries.length === 0) {
4830
+ return "no workspaces declared";
4831
+ }
4832
+ return entries.map((entry) => {
4833
+ const tags = [
4834
+ entry.default ? "default" : void 0,
4835
+ entry.extends.length > 0 ? `extends=${entry.extends.join(",")}` : void 0
4836
+ ].filter(Boolean);
4837
+ return `${entry.id}${tags.length > 0 ? ` (${tags.join(", ")})` : ""}`;
4838
+ }).join("\n");
4839
+ }
4840
+ async function runEnable(manifestCwd, packageRoot, options = {}) {
4841
+ const cliArgs = [...options.cliArgs ?? []];
4842
+ if (cliArgs.length > 0) {
4843
+ throw new Error(`Unsupported workspace arguments: ${cliArgs.join(" ")}`);
4844
+ }
4845
+ const loaded = await loadManifest9({
4846
+ ...options.root ? { root: options.root } : {},
4847
+ cwd: manifestCwd,
4848
+ ...options.processEnv ? { processEnv: options.processEnv } : {}
4849
+ });
4850
+ if (loaded.rootResolution.readOnly) {
4851
+ throw new Error(
4852
+ `Cannot enable workspace mode because the active CNOS root is remote and read-only (${loaded.rootResolution.rootUri}). Clone the config repo and edit it directly.`
4853
+ );
4854
+ }
4855
+ const rawManifest = structuredClone(loaded.rawManifest);
4856
+ const rawWorkspaces = rawManifest.workspaces ?? {};
4857
+ const rawItems = rawWorkspaces.items ?? {};
4858
+ if (Object.keys(rawItems).length > 0) {
4859
+ throw new Error("This CNOS root is already in workspace mode.");
4860
+ }
4861
+ const cnosRoot = loaded.manifestRoot;
4862
+ const baseWorkspaceRoot = path23.join(cnosRoot, "workspaces", "base");
4863
+ if (await exists2(baseWorkspaceRoot)) {
4864
+ throw new Error("Cannot enable workspace mode because .cnos/workspaces/base already exists.");
4865
+ }
4866
+ const moved = [];
4867
+ for (const folderName of ["values", "secrets", "env", "profiles"]) {
4868
+ if (await moveIfExists(path23.join(cnosRoot, folderName), path23.join(baseWorkspaceRoot, folderName))) {
4869
+ moved.push(folderName);
4870
+ }
4871
+ }
4872
+ await ensureWorkspaceLayout(cnosRoot, "base");
4873
+ rawWorkspaces.default = "base";
4874
+ rawWorkspaces.items = {
4875
+ base: {}
4876
+ };
4877
+ rawManifest.workspaces = rawWorkspaces;
4878
+ await writeFile9(path23.join(cnosRoot, "cnos.yml"), stringifyYaml7(rawManifest), "utf8");
4879
+ await updateRootAnchorToWorkspace(packageRoot, "base");
4880
+ await updateWorkspaceContext(packageRoot, "base");
4881
+ await ensureGitignore(path23.dirname(cnosRoot));
4882
+ if (options.json) {
4883
+ return printJson({
4884
+ root: path23.dirname(cnosRoot),
4885
+ workspace: "base",
4886
+ moved
4887
+ });
4888
+ }
4889
+ const movedSummary = moved.length > 0 ? `; moved ${moved.join(", ")} into .cnos/workspaces/base` : "";
4890
+ return `enabled workspace mode at ${displayPath(path23.dirname(cnosRoot), packageRoot)} with base workspace${movedSummary}`;
4891
+ }
4892
+ async function runAddOrScaffold(action, workspaceId, manifestCwd, packageRoot, options = {}) {
4893
+ const cliArgs = [...options.cliArgs ?? []];
4894
+ const extendsOption = splitExtends(consumeOption(cliArgs, "--extends"));
4895
+ const force = consumeFlag(cliArgs, "--force");
4896
+ if (cliArgs.length > 0) {
4897
+ throw new Error(`Unsupported workspace arguments: ${cliArgs.join(" ")}`);
4898
+ }
4899
+ const loaded = await loadManifest9({
4900
+ ...options.root ? { root: options.root } : {},
4901
+ cwd: manifestCwd,
4902
+ ...options.processEnv ? { processEnv: options.processEnv } : {}
4903
+ });
4904
+ if (loaded.rootResolution.readOnly) {
4905
+ throw new Error(
4906
+ `Cannot ${action} workspace because the active CNOS root is remote and read-only (${loaded.rootResolution.rootUri}). Clone the config repo and edit it directly.`
4907
+ );
4908
+ }
4909
+ const manifestRoot = loaded.manifestRoot;
4910
+ const cnosRoot = manifestRoot;
4911
+ const rawManifest = structuredClone(loaded.rawManifest);
4912
+ const rawWorkspaces = rawManifest.workspaces ?? {};
4913
+ const rawItems = rawWorkspaces.items ?? {};
4914
+ const isWorkspaceMode = Object.keys(rawItems).length > 0;
4915
+ const directConfigPresent = await hasDirectConfigData(cnosRoot);
4916
+ if (!isWorkspaceMode || directConfigPresent) {
4917
+ throw new Error(
4918
+ "This CNOS root is not ready for child workspaces yet. Run `cnos workspace enable` first to convert the flat project into workspace mode."
4919
+ );
4920
+ }
4921
+ if (rawItems[workspaceId] && !force) {
4922
+ throw new Error(`workspace "${workspaceId}" already exists. Use --force to update its manifest entry and anchor.`);
4923
+ }
4924
+ const defaultExtends = extendsOption ?? (!["base", "root"].includes(workspaceId) && rawItems.base ? ["base"] : void 0);
4925
+ rawItems[workspaceId] = defaultExtends && defaultExtends.length > 0 ? { extends: defaultExtends } : {};
4926
+ rawWorkspaces.items = rawItems;
4927
+ rawWorkspaces.default = rawWorkspaces.default ?? workspaceId;
4928
+ rawManifest.workspaces = rawWorkspaces;
4929
+ const workspaceRoot = path23.join(cnosRoot, "workspaces", workspaceId);
4930
+ const created = await ensureWorkspaceLayout(cnosRoot, workspaceId);
4931
+ await writeFile9(path23.join(cnosRoot, "cnos.yml"), stringifyYaml7(rawManifest), "utf8");
4932
+ await ensureGitignore(path23.dirname(cnosRoot));
4933
+ await writeAnchor(packageRoot, cnosRoot, workspaceId);
4934
+ await updateWorkspaceContext(packageRoot, workspaceId);
4935
+ const result = {
4936
+ workspace: workspaceId,
4937
+ root: path23.dirname(cnosRoot),
4938
+ packageRoot,
4939
+ created
4940
+ };
4941
+ if (options.json) {
4942
+ return printJson(result);
4943
+ }
4944
+ const verb = action === "add" ? "added" : "scaffolded";
4945
+ return `${verb} workspace ${workspaceId} at ${displayPath(workspaceRoot, packageRoot)}`;
4946
+ }
4947
+ async function runRemove(workspaceId, manifestCwd, options = {}) {
4364
4948
  const cliArgs = [...options.cliArgs ?? []];
4365
- const packageRoot = path23.resolve(consumeOption(cliArgs, "--package-root") ?? options.root ?? process.cwd());
4949
+ consumeFlag(cliArgs, "--force");
4950
+ if (cliArgs.length > 0) {
4951
+ throw new Error(`Unsupported workspace arguments: ${cliArgs.join(" ")}`);
4952
+ }
4953
+ const loaded = await loadManifest9({
4954
+ ...options.root ? { root: options.root } : {},
4955
+ cwd: manifestCwd,
4956
+ ...options.processEnv ? { processEnv: options.processEnv } : {}
4957
+ });
4958
+ if (loaded.rootResolution.readOnly) {
4959
+ throw new Error(
4960
+ `Cannot remove workspace because the active CNOS root is remote and read-only (${loaded.rootResolution.rootUri}). Clone the config repo and edit it directly.`
4961
+ );
4962
+ }
4963
+ const rawManifest = structuredClone(loaded.rawManifest);
4964
+ const rawWorkspaces = rawManifest.workspaces ?? {};
4965
+ const rawItems = rawWorkspaces.items ?? {};
4966
+ if (!rawItems[workspaceId]) {
4967
+ throw new Error(`workspace "${workspaceId}" does not exist`);
4968
+ }
4969
+ if (rawWorkspaces.default === workspaceId) {
4970
+ throw new Error(`Cannot remove workspace "${workspaceId}" because it is the default workspace. Change workspaces.default first.`);
4971
+ }
4972
+ delete rawItems[workspaceId];
4973
+ rawWorkspaces.items = rawItems;
4974
+ rawManifest.workspaces = rawWorkspaces;
4975
+ await writeFile9(path23.join(loaded.manifestRoot, "cnos.yml"), stringifyYaml7(rawManifest), "utf8");
4976
+ await rm5(path23.join(loaded.manifestRoot, "workspaces", workspaceId), { recursive: true, force: true });
4977
+ if (options.json) {
4978
+ return printJson({
4979
+ workspace: workspaceId,
4980
+ removedFrom: loaded.manifestRoot
4981
+ });
4982
+ }
4983
+ return `removed workspace ${workspaceId}`;
4984
+ }
4985
+ async function runWorkspace(args = [], options = {}) {
4986
+ const [action, workspaceArg] = args;
4987
+ const baseCliArgs = [...options.cliArgs ?? []];
4988
+ const manifestCwd = path23.resolve(options.root ?? process.cwd());
4989
+ const packageRoot = path23.resolve(consumeOption(baseCliArgs, "--package-root") ?? options.root ?? process.cwd());
4366
4990
  switch (action) {
4367
- case "detach":
4368
- return runDetach(packageRoot, { ...options, cliArgs });
4369
4991
  case "attach":
4370
- return runAttach(packageRoot, { ...options, cliArgs });
4992
+ return runAttach(packageRoot, { ...options, cliArgs: baseCliArgs });
4993
+ case "detach":
4994
+ return runDetach(packageRoot, { ...options, cliArgs: baseCliArgs });
4995
+ case "enable":
4996
+ return runEnable(manifestCwd, packageRoot, { ...options, cliArgs: baseCliArgs });
4997
+ case "list":
4998
+ return runList2(manifestCwd, options);
4999
+ case "add":
5000
+ return runAddOrScaffold("add", normalizeWorkspaceId(workspaceArg), manifestCwd, packageRoot, {
5001
+ ...options,
5002
+ cliArgs: baseCliArgs
5003
+ });
5004
+ case "scaffold":
5005
+ return runAddOrScaffold("scaffold", normalizeWorkspaceId(workspaceArg), manifestCwd, packageRoot, {
5006
+ ...options,
5007
+ cliArgs: baseCliArgs
5008
+ });
5009
+ case "remove":
5010
+ case "delete":
5011
+ return runRemove(normalizeWorkspaceId(workspaceArg), manifestCwd, {
5012
+ ...options,
5013
+ cliArgs: baseCliArgs
5014
+ });
4371
5015
  default:
4372
5016
  throw new Error(`Unsupported workspace action: ${action ?? "(missing)"}`);
4373
5017
  }
@@ -4393,8 +5037,8 @@ function resolveHelpTopic(command, args) {
4393
5037
  if (command === "dev" && args[0] === "env") {
4394
5038
  return normalizeHelpTopic([command, args[0]]);
4395
5039
  }
4396
- if (command === "workspace" && args[0] && ["attach", "detach"].includes(args[0])) {
4397
- return normalizeHelpTopic([command, args[0]]);
5040
+ if (command === "workspace" && args[0] && ["attach", "detach", "add", "list", "remove", "delete", "scaffold", "enable"].includes(args[0])) {
5041
+ return normalizeHelpTopic([command, args[0] === "delete" ? "remove" : args[0]]);
4398
5042
  }
4399
5043
  if (command === "vault" && args[0] && ["create", "add", "list", "delete", "remove"].includes(args[0])) {
4400
5044
  return normalizeHelpTopic([command, args[0] === "delete" ? "remove" : args[0] === "add" ? "create" : args[0]]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitsy/cnos-cli",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "CLI entry point and developer tooling for CNOS.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -36,7 +36,8 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@kitsy/cnos": "1.7.0"
39
+ "smol-toml": "^1.4.2",
40
+ "@kitsy/cnos": "1.8.1"
40
41
  },
41
42
  "scripts": {
42
43
  "build": "tsup src/index.ts --format esm --dts",