@kitsy/cnos-cli 1.8.0 → 1.8.2

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 +530 -127
  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",
@@ -24,7 +25,12 @@ var COMMAND_OPTION_KEYS_WITH_VALUE = /* @__PURE__ */ new Set([
24
25
  "--set",
25
26
  "--debounce",
26
27
  "--expr",
27
- "--extends"
28
+ "--extends",
29
+ "--workspaces",
30
+ "--env",
31
+ "--yaml",
32
+ "--toml",
33
+ "--config"
28
34
  ]);
29
35
  var COMMAND_FLAG_KEYS = /* @__PURE__ */ new Set([
30
36
  "--flatten",
@@ -44,7 +50,8 @@ var COMMAND_FLAG_KEYS = /* @__PURE__ */ new Set([
44
50
  "--rewrite",
45
51
  "--signal",
46
52
  "--derive",
47
- "--onboard-current"
53
+ "--materialize",
54
+ "--source-only"
48
55
  ]);
49
56
  function normalizeCommand(argv) {
50
57
  const [command = "doctor", ...rest] = argv;
@@ -160,6 +167,14 @@ function parseArgs(argv) {
160
167
  passthrough.push(token);
161
168
  continue;
162
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
+ }
163
178
  if (token === "--json") {
164
179
  options.json = true;
165
180
  continue;
@@ -773,6 +788,8 @@ async function defineValue(namespace, configPath, rawValue, options = {}) {
773
788
  const derivedValue = normalizeDerivedValue(options.deriveExpression, options.deriveExprMode ?? false);
774
789
  validateParsedDerivation(runtime.manifest, parseDerivation(derivedValue));
775
790
  parsedValue = derivedValue;
791
+ } else if (Object.hasOwn(options, "parsedValue")) {
792
+ parsedValue = options.parsedValue;
776
793
  } else {
777
794
  parsedValue = parseScalarValue(rawValue);
778
795
  }
@@ -795,7 +812,7 @@ async function setSecret(configPath, rawValue, options = {}) {
795
812
  if (!vaultDefinition) {
796
813
  throw new Error(`Unknown vault "${vault}". Create it first with cnos vault create ${vault}.`);
797
814
  }
798
- const mode = options.mode ?? (vaultDefinition.provider === "local" ? "local" : vaultDefinition.provider === "github-secrets" ? "ref" : "remote");
815
+ const mode = options.mode ?? (vaultDefinition.provider === "local" ? "local" : vaultDefinition.provider === "github-secrets" || vaultDefinition.provider === "environment" ? "ref" : "remote");
799
816
  let reference;
800
817
  if (mode === "local") {
801
818
  const auth = await resolveVaultAuth(vault, vaultDefinition, options.processEnv ?? process.env);
@@ -1578,23 +1595,65 @@ var COMMANDS = [
1578
1595
  },
1579
1596
  {
1580
1597
  id: "init",
1581
- summary: "Scaffold a workspace-aware CNOS tree in the current project.",
1582
- usage: "cnos init [--workspace <id>] [--root <path>] [--json]",
1583
- description: "Creates .cnos/cnos.yml, .cnosrc.yml, optional .cnos-workspace.yml, config folders, and .gitignore entries without overwriting existing files.",
1584
- 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
+ ]
1585
1607
  },
1586
1608
  {
1587
1609
  id: "onboard",
1588
- summary: "Onboard an existing repo into CNOS and import root dotenv files.",
1589
- usage: "cnos onboard [--workspace <id>] [--root <path>] [--move] [--json]",
1590
- 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.",
1591
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
+ },
1592
1646
  {
1593
1647
  flag: "--move",
1594
- 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."
1595
1649
  }
1596
1650
  ],
1597
- 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
+ ]
1598
1657
  },
1599
1658
  {
1600
1659
  id: "codegen",
@@ -1747,7 +1806,7 @@ var COMMANDS = [
1747
1806
  "cnos vault create local-dev",
1748
1807
  "cnos vault auth local-dev",
1749
1808
  "cnos secret set app.token super-secret --vault local-dev",
1750
- "cnos vault create github-ci --provider github-secrets --no-passphrase",
1809
+ "cnos vault create github-ci --provider environment --no-passphrase",
1751
1810
  "cnos secret set app.token APP_TOKEN --vault github-ci"
1752
1811
  ]
1753
1812
  },
@@ -1755,21 +1814,21 @@ var COMMANDS = [
1755
1814
  id: "vault",
1756
1815
  summary: "Manage manifest-defined secret vaults.",
1757
1816
  usage: "cnos vault [create <name> | list | remove <name>] [options] [global-options]",
1758
- description: "Creates, lists, and removes vault definitions in .cnos/cnos.yml. Local vaults use encrypted material under ~/.cnos/secrets, while github-secrets vaults resolve from process.env in CI.",
1817
+ description: "Creates, lists, and removes vault definitions in .cnos/cnos.yml. Local vaults use encrypted material under ~/.cnos/secrets, while environment-backed vaults resolve from process.env in CI and cloud runtimes. github-secrets remains a compatibility alias.",
1759
1818
  options: [
1760
1819
  {
1761
- flag: "--provider <local|github-secrets>",
1820
+ flag: "--provider <local|environment|github-secrets>",
1762
1821
  description: "Vault provider. Defaults to local."
1763
1822
  },
1764
1823
  {
1765
1824
  flag: "--no-passphrase",
1766
- description: "Allowed for passwordless providers such as github-secrets."
1825
+ description: "Allowed for passwordless providers such as environment-backed vaults."
1767
1826
  }
1768
1827
  ],
1769
1828
  examples: [
1770
1829
  "cnos vault create local-dev",
1771
1830
  "cnos vault auth local-dev",
1772
- "cnos vault create github-ci --provider github-secrets --no-passphrase",
1831
+ "cnos vault create github-ci --provider environment --no-passphrase",
1773
1832
  "cnos vault list",
1774
1833
  "cnos vault remove local-dev"
1775
1834
  ]
@@ -1777,11 +1836,11 @@ var COMMANDS = [
1777
1836
  {
1778
1837
  id: "vault create",
1779
1838
  summary: "Create a manifest-defined vault.",
1780
- usage: "cnos vault create <name> [--provider <local|github-secrets>] [--no-passphrase] [global-options]",
1839
+ usage: "cnos vault create <name> [--provider <local|environment|github-secrets>] [--no-passphrase] [global-options]",
1781
1840
  description: "Creates a vault definition in .cnos/cnos.yml and, for local vaults, initializes the encrypted store under ~/.cnos/secrets. CNOS prompts for a passphrase when one is not already available from env or keychain.",
1782
1841
  examples: [
1783
1842
  "cnos vault create local-dev",
1784
- "cnos vault create github-ci --provider github-secrets --no-passphrase"
1843
+ "cnos vault create firebase-prod --provider environment --no-passphrase"
1785
1844
  ]
1786
1845
  },
1787
1846
  {
@@ -1954,12 +2013,12 @@ var COMMANDS = [
1954
2013
  id: "secret set",
1955
2014
  summary: "Write a secret securely.",
1956
2015
  usage: "cnos secret set <path> <value> [--local|--remote|--ref] [--vault <name>] [--provider <name>] [global-options]",
1957
- description: "Writes a secret reference into the repo. When a local vault is selected, CNOS stores encrypted secret material outside the repo under ~/.cnos/secrets/vaults/<vault>; when a github-secrets vault is selected, CNOS writes a CI env-backed ref.",
2016
+ description: "Writes a secret reference into the repo. When a local vault is selected, CNOS stores encrypted secret material outside the repo under ~/.cnos/secrets/vaults/<vault>; when an environment-backed vault is selected, CNOS writes an env-backed ref for CI or cloud runtimes.",
1958
2017
  examples: [
1959
2018
  "cnos vault create db",
1960
2019
  "cnos vault auth db",
1961
2020
  "cnos secret set app.token super-secret --vault db",
1962
- "cnos vault create github-ci --provider github-secrets --no-passphrase",
2021
+ "cnos vault create github-ci --provider environment --no-passphrase",
1963
2022
  "cnos secret set app.token APP_TOKEN --vault github-ci"
1964
2023
  ]
1965
2024
  },
@@ -2217,25 +2276,32 @@ var COMMANDS = [
2217
2276
  {
2218
2277
  id: "workspace",
2219
2278
  summary: "Manage workspace creation, listing, migration, and attach/detach flows.",
2220
- usage: "cnos workspace <add|list|remove|scaffold|attach|detach> [options] [global-options]",
2221
- description: "Adds and removes manifest workspaces, scaffolds package anchors, migrates single-root projects into workspace mode, and handles detach/attach flows for independent child packages.",
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.",
2222
2281
  examples: [
2223
2282
  "cnos workspace list",
2283
+ "cnos workspace enable",
2224
2284
  "cnos workspace add travel --package-root apps/travel --extends base",
2225
- "cnos workspace add main --onboard-current",
2226
2285
  "cnos workspace remove gallery",
2227
2286
  "cnos workspace detach --package-root apps/travel"
2228
2287
  ]
2229
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
+ },
2230
2296
  {
2231
2297
  id: "workspace add",
2232
- summary: "Add a workspace to the manifest and scaffold its on-disk layout.",
2233
- usage: "cnos workspace add <id> [--package-root <path>] [--extends <workspace>] [--onboard-current] [--force] [global-options]",
2234
- description: "Creates .cnos/workspaces/<id>, updates cnos.yml, writes a .cnosrc.yml anchor at the selected package root, and optionally migrates an existing single-root .cnos tree into workspace mode with --onboard-current.",
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.",
2235
2301
  examples: [
2236
2302
  "cnos workspace add travel --package-root apps/travel --extends base",
2237
2303
  "cnos workspace add insights --package-root apps/insights",
2238
- "cnos workspace add main --onboard-current"
2304
+ "cnos workspace add api --extends none"
2239
2305
  ]
2240
2306
  },
2241
2307
  {
@@ -2551,32 +2617,41 @@ import path10 from "path";
2551
2617
  // src/services/scaffold.ts
2552
2618
  import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile4 } from "fs/promises";
2553
2619
  import path9 from "path";
2554
- 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)] : [];
2555
2624
  const lines = [
2556
2625
  "version: 1",
2557
2626
  "project:",
2558
- ` name: ${projectName}`,
2559
- "profiles:",
2560
- " default: base",
2561
- "envMapping:",
2562
- " convention: SCREAMING_SNAKE",
2563
- "public:",
2564
- " promote: []",
2565
- ""
2627
+ ` name: ${projectName}`
2566
2628
  ];
2567
- if (workspace) {
2568
- lines.splice(
2569
- 4,
2570
- 0,
2629
+ if (mode === "workspace") {
2630
+ lines.push(
2571
2631
  "workspaces:",
2572
- ` default: ${workspace}`,
2632
+ ` default: ${baseWorkspace}`,
2573
2633
  " global:",
2574
2634
  " enabled: false",
2575
2635
  " allowWrite: false",
2576
2636
  " items:",
2577
- ` ${workspace}: {}`
2637
+ ` ${baseWorkspace}: {}`
2578
2638
  );
2639
+ for (const workspaceId of workspaceIds) {
2640
+ if (workspaceId === baseWorkspace) {
2641
+ continue;
2642
+ }
2643
+ lines.push(` ${workspaceId}:`, " extends: [base]");
2644
+ }
2579
2645
  }
2646
+ lines.push(
2647
+ "profiles:",
2648
+ " default: local",
2649
+ "envMapping:",
2650
+ " convention: SCREAMING_SNAKE",
2651
+ "public:",
2652
+ " promote: []",
2653
+ ""
2654
+ );
2580
2655
  return lines.join("\n");
2581
2656
  }
2582
2657
  async function ensureFile(filePath, content) {
@@ -2650,18 +2725,31 @@ workspace: ${workspace}
2650
2725
  ` : "root: ./.cnos\n"
2651
2726
  );
2652
2727
  }
2653
- async function scaffoldWorkspace(root, workspace) {
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) : [];
2654
2732
  const cnosRoot = path9.join(root, ".cnos");
2655
- const createdPaths = (await ensureWorkspaceLayout(cnosRoot, workspace)).map(
2656
- (entry) => entry.replace(/^\.cnos\//, ".cnos/")
2657
- );
2658
- if (await ensureFile(path9.join(cnosRoot, "cnos.yml"), scaffoldManifest(path9.basename(root), workspace))) {
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))) {
2659
2747
  createdPaths.push(".cnos/cnos.yml");
2660
2748
  }
2661
- if (await ensureCnosrc(root, workspace)) {
2749
+ if (await ensureCnosrc(root, mode === "workspace" ? baseWorkspace : void 0)) {
2662
2750
  createdPaths.push(".cnosrc.yml");
2663
2751
  }
2664
- 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}
2665
2753
  globalRoot: ~/.cnos
2666
2754
  `)) {
2667
2755
  createdPaths.push(".cnos-workspace.yml");
@@ -2671,20 +2759,42 @@ globalRoot: ~/.cnos
2671
2759
  }
2672
2760
  return {
2673
2761
  root,
2674
- ...workspace ? { workspace } : {},
2762
+ mode,
2763
+ ...mode === "workspace" ? { workspace: baseWorkspace, workspaces: [baseWorkspace, ...childWorkspaces] } : {},
2675
2764
  created: createdPaths
2676
2765
  };
2677
2766
  }
2678
2767
 
2679
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
+ }
2680
2775
  async function runInit(options = {}) {
2681
2776
  const root = path10.resolve(options.root ?? process.cwd());
2682
- 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
+ });
2683
2792
  if (options.json) {
2684
2793
  return printJson(result);
2685
2794
  }
2686
- if (result.workspace) {
2687
- 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}`;
2688
2798
  }
2689
2799
  return `initialized CNOS project at ${root}`;
2690
2800
  }
@@ -3083,67 +3193,289 @@ async function runNamespace(namespace, args = [], options = {}) {
3083
3193
  }
3084
3194
 
3085
3195
  // src/commands/onboard.ts
3086
- 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";
3087
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";
3088
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
+ }
3089
3211
  async function listRootEnvFiles(root) {
3090
3212
  const entries = await readdir3(root, { withFileTypes: true });
3091
3213
  return entries.filter((entry) => entry.isFile() && ROOT_ENV_FILE_PATTERN.test(entry.name)).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
3092
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
+ }
3093
3361
  async function runOnboard(options = {}) {
3094
3362
  const root = path13.resolve(options.root ?? process.cwd());
3095
- const workspace = options.workspace ?? path13.basename(root);
3096
3363
  const cliArgs = [...options.cliArgs ?? []];
3097
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);
3098
3372
  if (cliArgs.length > 0) {
3099
3373
  throw new Error(`Unsupported onboard arguments: ${cliArgs.join(" ")}`);
3100
3374
  }
3101
- const scaffold = await scaffoldWorkspace(root, workspace);
3102
- const envRoot = path13.join(root, ".cnos", "workspaces", workspace, "env");
3103
- 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
+ }));
3104
3403
  const imported = [];
3105
3404
  const skipped = [];
3106
- for (const fileName of rootFiles) {
3107
- const sourcePath = path13.join(root, fileName);
3108
- 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);
3109
3409
  try {
3110
- await copyFile(sourcePath, targetPath);
3410
+ await copyFile(source.filePath, targetPath);
3111
3411
  imported.push(path13.relative(root, targetPath).replace(/\\/g, "/"));
3112
3412
  if (move) {
3113
- await rm2(sourcePath);
3413
+ await rm2(source.filePath);
3114
3414
  }
3115
3415
  } catch {
3116
- 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);
3117
3431
  }
3118
3432
  }
3119
3433
  const result = {
3120
3434
  root,
3121
- workspace,
3122
- scaffolded: scaffold.created,
3435
+ workspace: selectedWorkspace,
3436
+ mode: move ? "move" : "copy",
3437
+ storageMode: isWorkspaceMode ? "workspace" : "regular",
3438
+ scaffolded,
3123
3439
  imported,
3124
3440
  skipped,
3125
- mode: move ? "move" : "copy"
3441
+ proposed,
3442
+ materialized
3126
3443
  };
3127
3444
  if (options.json) {
3128
3445
  return printJson(result);
3129
3446
  }
3130
- const importedCount = imported.length;
3131
- const skippedSuffix = skipped.length > 0 ? ` (${skipped.length} skipped)` : "";
3132
- 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");
3133
3465
  }
3134
3466
 
3135
3467
  // src/commands/profile.ts
3136
3468
  import path16 from "path";
3137
3469
 
3138
3470
  // src/services/context.ts
3139
- import { readFile as readFile4, writeFile as writeFile5 } from "fs/promises";
3471
+ import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
3140
3472
  import path14 from "path";
3141
- import { parseYaml as parseYaml3, stringifyYaml as stringifyYaml3 } from "@kitsy/cnos/internal";
3473
+ import { parseYaml as parseYaml4, stringifyYaml as stringifyYaml3 } from "@kitsy/cnos/internal";
3142
3474
  async function loadCliContext(root = process.cwd()) {
3143
3475
  const filePath = path14.join(path14.resolve(root), ".cnos-workspace.yml");
3144
3476
  try {
3145
- const source = await readFile4(filePath, "utf8");
3146
- const parsed = parseYaml3(source);
3477
+ const source = await readFile5(filePath, "utf8");
3478
+ const parsed = parseYaml4(source);
3147
3479
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3148
3480
  return {};
3149
3481
  }
@@ -3172,21 +3504,21 @@ async function saveCliContext(options = {}) {
3172
3504
  }
3173
3505
 
3174
3506
  // src/services/profiles.ts
3175
- 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";
3176
3508
  import path15 from "path";
3177
- 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";
3178
3510
  async function resolveProfilesRoot(root = process.cwd()) {
3179
3511
  try {
3180
- const loadedManifest = await loadManifest5({ root });
3512
+ const loadedManifest = await loadManifest6({ root });
3181
3513
  return path15.join(loadedManifest.manifestRoot, "profiles");
3182
3514
  } catch {
3183
- const loadedManifest = await loadManifest5({ cwd: root });
3515
+ const loadedManifest = await loadManifest6({ cwd: root });
3184
3516
  return path15.join(loadedManifest.manifestRoot, "profiles");
3185
3517
  }
3186
3518
  }
3187
3519
  async function createProfileDefinition(root = process.cwd(), profile, inherit, options = {}) {
3188
3520
  const filePath = path15.join(await resolveProfilesRoot(root), `${profile}.yml`);
3189
- await mkdir5(path15.dirname(filePath), { recursive: true });
3521
+ await mkdir6(path15.dirname(filePath), { recursive: true });
3190
3522
  const document = options.noInherit ? {
3191
3523
  name: profile,
3192
3524
  activate: {
@@ -3246,7 +3578,7 @@ async function readProfileDefinition(root = process.cwd(), profile = "base") {
3246
3578
  }
3247
3579
  const filePath = path15.join(await resolveProfilesRoot(root), `${profile}.yml`);
3248
3580
  try {
3249
- return parseYaml4(await readFile5(filePath, "utf8")) ?? void 0;
3581
+ return parseYaml5(await readFile6(filePath, "utf8")) ?? void 0;
3250
3582
  } catch {
3251
3583
  return void 0;
3252
3584
  }
@@ -3326,7 +3658,7 @@ import path17 from "path";
3326
3658
  import { writeFile as writeFile7 } from "fs/promises";
3327
3659
  import {
3328
3660
  ensureProjectionAllowed,
3329
- loadManifest as loadManifest6,
3661
+ loadManifest as loadManifest7,
3330
3662
  stringifyYaml as stringifyYaml5
3331
3663
  } from "@kitsy/cnos/internal";
3332
3664
  function normalizeTarget(value) {
@@ -3355,7 +3687,7 @@ async function runPromote(args = [], options = {}) {
3355
3687
  throw new Error("promote --to env requires --as <ENV_VAR>");
3356
3688
  }
3357
3689
  }
3358
- const loadedManifest = await loadManifest6({
3690
+ const loadedManifest = await loadManifest7({
3359
3691
  ...options.root ? { root: options.root } : {},
3360
3692
  ...options.cwd ? { cwd: options.cwd } : {},
3361
3693
  ...options.processEnv ? { processEnv: options.processEnv } : {},
@@ -3522,7 +3854,7 @@ import {
3522
3854
  createSecretVault,
3523
3855
  deriveVaultKey,
3524
3856
  listLocalSecrets,
3525
- loadManifest as loadManifest7,
3857
+ loadManifest as loadManifest8,
3526
3858
  listSecretVaults,
3527
3859
  readVaultMetadata,
3528
3860
  resolveSecretStoreRoot as resolveSecretStoreRoot2,
@@ -3561,7 +3893,7 @@ async function createVaultDefinition(name, options = {}) {
3561
3893
  if (provider === "local" && (options.noPassphrase ?? false)) {
3562
3894
  throw new Error("Local vaults cannot be passwordless.");
3563
3895
  }
3564
- const loadedManifest = await loadManifest7({
3896
+ const loadedManifest = await loadManifest8({
3565
3897
  ...options.root ? { root: options.root } : {},
3566
3898
  ...options.cwd ? { cwd: options.cwd } : {},
3567
3899
  ...options.processEnv ? { processEnv: options.processEnv } : {},
@@ -3598,7 +3930,7 @@ async function createVaultDefinition(name, options = {}) {
3598
3930
  };
3599
3931
  }
3600
3932
  async function listVaultDefinitions(options = {}) {
3601
- const loadedManifest = await loadManifest7({
3933
+ const loadedManifest = await loadManifest8({
3602
3934
  ...options.root ? { root: options.root } : {},
3603
3935
  ...options.cwd ? { cwd: options.cwd } : {},
3604
3936
  ...options.processEnv ? { processEnv: options.processEnv } : {},
@@ -3619,7 +3951,7 @@ async function listVaultDefinitions(options = {}) {
3619
3951
  async function removeVaultDefinition(name, options = {}) {
3620
3952
  await assertWritableConfigRoot(`remove vault ${name}`, options);
3621
3953
  const vault = name.trim() || "default";
3622
- const loadedManifest = await loadManifest7({
3954
+ const loadedManifest = await loadManifest8({
3623
3955
  ...options.root ? { root: options.root } : {},
3624
3956
  ...options.cwd ? { cwd: options.cwd } : {},
3625
3957
  ...options.processEnv ? { processEnv: options.processEnv } : {},
@@ -3665,7 +3997,7 @@ async function listLocalStoreVaults(options = {}) {
3665
3997
  }
3666
3998
  async function authenticateVault(name, options = {}) {
3667
3999
  const vault = name.trim() || "default";
3668
- const loadedManifest = await loadManifest7({
4000
+ const loadedManifest = await loadManifest8({
3669
4001
  ...options.root ? { root: options.root } : {},
3670
4002
  ...options.cwd ? { cwd: options.cwd } : {},
3671
4003
  ...options.processEnv ? { processEnv: options.processEnv } : {},
@@ -3986,7 +4318,7 @@ async function runValidate(options = {}) {
3986
4318
  // package.json
3987
4319
  var package_default = {
3988
4320
  name: "@kitsy/cnos-cli",
3989
- version: "1.8.0",
4321
+ version: "1.8.2",
3990
4322
  description: "CLI entry point and developer tooling for CNOS.",
3991
4323
  type: "module",
3992
4324
  main: "./dist/index.js",
@@ -4022,7 +4354,8 @@ var package_default = {
4022
4354
  access: "public"
4023
4355
  },
4024
4356
  dependencies: {
4025
- "@kitsy/cnos": "workspace:*"
4357
+ "@kitsy/cnos": "workspace:*",
4358
+ "smol-toml": "^1.4.2"
4026
4359
  },
4027
4360
  scripts: {
4028
4361
  build: "tsup src/index.ts --format esm --dts",
@@ -4261,34 +4594,34 @@ async function runWatch(command, options = {}) {
4261
4594
  }
4262
4595
 
4263
4596
  // src/commands/workspace.ts
4264
- import { cp, mkdir as mkdir6, readdir as readdir5, readFile as readFile6, rename, rm as rm5, stat as stat2, writeFile as writeFile9 } 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";
4265
4598
  import path23 from "path";
4266
- import { loadManifest as loadManifest8, parseYaml as parseYaml5, stringifyYaml as stringifyYaml7 } from "@kitsy/cnos/internal";
4267
- async function exists(targetPath) {
4599
+ import { loadManifest as loadManifest9, parseYaml as parseYaml6, stringifyYaml as stringifyYaml7 } from "@kitsy/cnos/internal";
4600
+ async function exists2(targetPath) {
4268
4601
  try {
4269
- await stat2(targetPath);
4602
+ await stat3(targetPath);
4270
4603
  return true;
4271
4604
  } catch {
4272
4605
  return false;
4273
4606
  }
4274
4607
  }
4275
4608
  async function copyIfExists(source, target) {
4276
- if (!await exists(source)) {
4609
+ if (!await exists2(source)) {
4277
4610
  return;
4278
4611
  }
4279
- await mkdir6(path23.dirname(target), { recursive: true });
4612
+ await mkdir7(path23.dirname(target), { recursive: true });
4280
4613
  await cp(source, target, { recursive: true, force: true });
4281
4614
  }
4282
4615
  async function moveIfExists(source, target, force = false) {
4283
- if (!await exists(source)) {
4616
+ if (!await exists2(source)) {
4284
4617
  return false;
4285
4618
  }
4286
4619
  if (force) {
4287
4620
  await rm5(target, { recursive: true, force: true });
4288
- } else if (await exists(target)) {
4621
+ } else if (await exists2(target)) {
4289
4622
  throw new Error(`Refusing to overwrite existing path ${target}. Use --force to replace it.`);
4290
4623
  }
4291
- await mkdir6(path23.dirname(target), { recursive: true });
4624
+ await mkdir7(path23.dirname(target), { recursive: true });
4292
4625
  await rename(source, target);
4293
4626
  return true;
4294
4627
  }
@@ -4332,13 +4665,16 @@ function splitExtends(value) {
4332
4665
  if (!value) {
4333
4666
  return void 0;
4334
4667
  }
4668
+ if (value.trim() === "none") {
4669
+ return [];
4670
+ }
4335
4671
  const items = value.split(",").map((entry) => entry.trim()).filter(Boolean);
4336
4672
  return items.length > 0 ? items : void 0;
4337
4673
  }
4338
4674
  async function hasDirectConfigData(cnosRoot) {
4339
4675
  for (const folderName of ["values", "secrets", "env", "profiles"]) {
4340
4676
  const folder = path23.join(cnosRoot, folderName);
4341
- if (!await exists(folder)) {
4677
+ if (!await exists2(folder)) {
4342
4678
  continue;
4343
4679
  }
4344
4680
  const entries = await readdir5(folder, { withFileTypes: true });
@@ -4348,14 +4684,39 @@ async function hasDirectConfigData(cnosRoot) {
4348
4684
  }
4349
4685
  return false;
4350
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
+ }
4351
4712
  async function runDetach(packageRoot, options = {}) {
4352
- const loaded = await loadManifest8({ cwd: packageRoot });
4713
+ const loaded = await loadManifest9({ cwd: packageRoot });
4353
4714
  if (!loaded.anchorPath || !loaded.anchoredWorkspace) {
4354
4715
  throw new Error("workspace detach requires a package-local .cnosrc.yml with a workspace binding");
4355
4716
  }
4356
4717
  const targetCnosRoot = path23.join(packageRoot, ".cnos");
4357
4718
  const force = consumeFlag([...options.cliArgs ?? []], "--force");
4358
- if (await exists(targetCnosRoot) && !force) {
4719
+ if (await exists2(targetCnosRoot) && !force) {
4359
4720
  throw new Error(`Refusing to detach because ${displayPath(targetCnosRoot, packageRoot)} already exists. Use --force to overwrite.`);
4360
4721
  }
4361
4722
  if (force) {
@@ -4367,7 +4728,7 @@ async function runDetach(packageRoot, options = {}) {
4367
4728
  workspace: loaded.anchoredWorkspace
4368
4729
  });
4369
4730
  const localRoots = runtime.graph.workspace.workspaceRoots.filter((root) => root.scope === "local").map((root) => root.path);
4370
- await mkdir6(targetCnosRoot, { recursive: true });
4731
+ await mkdir7(targetCnosRoot, { recursive: true });
4371
4732
  await mergeWorkspaceRootsIntoStandalone(targetCnosRoot, localRoots);
4372
4733
  await writeFile9(
4373
4734
  path23.join(targetCnosRoot, "cnos.yml"),
@@ -4400,15 +4761,15 @@ async function runAttach(packageRoot, options = {}) {
4400
4761
  const force = consumeFlag(cliArgs, "--force");
4401
4762
  const childCnosRoot = path23.join(packageRoot, ".cnos");
4402
4763
  const markerPath = path23.join(childCnosRoot, ".detached");
4403
- if (!await exists(markerPath)) {
4764
+ if (!await exists2(markerPath)) {
4404
4765
  throw new Error("workspace attach requires a detached package with .cnos/.detached");
4405
4766
  }
4406
- const marker = parseYaml5(await readFile6(markerPath, "utf8"));
4767
+ const marker = parseYaml6(await readFile7(markerPath, "utf8"));
4407
4768
  if (!marker?.originalCnosrc?.root || !marker.detachedWorkspace) {
4408
4769
  throw new Error("Invalid .detached marker");
4409
4770
  }
4410
4771
  const parentManifestRoot = path23.resolve(packageRoot, marker.originalCnosrc.root);
4411
- const parentLoaded = await loadManifest8({ root: parentManifestRoot });
4772
+ const parentLoaded = await loadManifest9({ root: parentManifestRoot });
4412
4773
  if (parentLoaded.rootResolution.readOnly) {
4413
4774
  throw new Error(
4414
4775
  `Cannot attach workspace because the parent CNOS root is remote and read-only (${parentLoaded.rootResolution.rootUri}).`
@@ -4416,13 +4777,13 @@ async function runAttach(packageRoot, options = {}) {
4416
4777
  }
4417
4778
  const workspaceId = marker.originalCnosrc.workspace ?? marker.detachedWorkspace;
4418
4779
  const parentWorkspaceRoot = path23.join(parentLoaded.manifestRoot, "workspaces", workspaceId);
4419
- if (await exists(parentWorkspaceRoot) && !force) {
4780
+ if (await exists2(parentWorkspaceRoot) && !force) {
4420
4781
  throw new Error(`workspace "${workspaceId}" already exists in parent root. Use --force to overwrite.`);
4421
4782
  }
4422
4783
  if (force) {
4423
4784
  await rm5(parentWorkspaceRoot, { recursive: true, force: true });
4424
4785
  }
4425
- await mkdir6(parentWorkspaceRoot, { recursive: true });
4786
+ await mkdir7(parentWorkspaceRoot, { recursive: true });
4426
4787
  for (const folderName of ["values", "secrets", "env", "profiles"]) {
4427
4788
  await copyIfExists(path23.join(childCnosRoot, folderName), path23.join(parentWorkspaceRoot, folderName));
4428
4789
  }
@@ -4448,7 +4809,7 @@ async function runAttach(packageRoot, options = {}) {
4448
4809
  return `attached workspace ${workspaceId} to ${displayPath(parentLoaded.manifestRoot, packageRoot)}`;
4449
4810
  }
4450
4811
  async function runList2(manifestCwd, options = {}) {
4451
- const loaded = await loadManifest8({
4812
+ const loaded = await loadManifest9({
4452
4813
  ...options.root ? { root: options.root } : {},
4453
4814
  cwd: manifestCwd,
4454
4815
  ...options.processEnv ? { processEnv: options.processEnv } : {}
@@ -4476,15 +4837,66 @@ async function runList2(manifestCwd, options = {}) {
4476
4837
  return `${entry.id}${tags.length > 0 ? ` (${tags.join(", ")})` : ""}`;
4477
4838
  }).join("\n");
4478
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
+ }
4479
4892
  async function runAddOrScaffold(action, workspaceId, manifestCwd, packageRoot, options = {}) {
4480
4893
  const cliArgs = [...options.cliArgs ?? []];
4481
4894
  const extendsOption = splitExtends(consumeOption(cliArgs, "--extends"));
4482
- const onboardCurrent = consumeFlag(cliArgs, "--onboard-current");
4483
4895
  const force = consumeFlag(cliArgs, "--force");
4484
4896
  if (cliArgs.length > 0) {
4485
4897
  throw new Error(`Unsupported workspace arguments: ${cliArgs.join(" ")}`);
4486
4898
  }
4487
- const loaded = await loadManifest8({
4899
+ const loaded = await loadManifest9({
4488
4900
  ...options.root ? { root: options.root } : {},
4489
4901
  cwd: manifestCwd,
4490
4902
  ...options.processEnv ? { processEnv: options.processEnv } : {}
@@ -4501,9 +4913,9 @@ async function runAddOrScaffold(action, workspaceId, manifestCwd, packageRoot, o
4501
4913
  const rawItems = rawWorkspaces.items ?? {};
4502
4914
  const isWorkspaceMode = Object.keys(rawItems).length > 0;
4503
4915
  const directConfigPresent = await hasDirectConfigData(cnosRoot);
4504
- if (!isWorkspaceMode && directConfigPresent && !onboardCurrent) {
4916
+ if (!isWorkspaceMode || directConfigPresent) {
4505
4917
  throw new Error(
4506
- "This CNOS root is in single-root mode and already has direct values/secrets/env/profiles data. Re-run with --onboard-current to migrate it into workspace mode."
4918
+ "This CNOS root is not ready for child workspaces yet. Run `cnos workspace enable` first to convert the flat project into workspace mode."
4507
4919
  );
4508
4920
  }
4509
4921
  if (rawItems[workspaceId] && !force) {
@@ -4515,26 +4927,15 @@ async function runAddOrScaffold(action, workspaceId, manifestCwd, packageRoot, o
4515
4927
  rawWorkspaces.default = rawWorkspaces.default ?? workspaceId;
4516
4928
  rawManifest.workspaces = rawWorkspaces;
4517
4929
  const workspaceRoot = path23.join(cnosRoot, "workspaces", workspaceId);
4518
- if (onboardCurrent) {
4519
- if (isWorkspaceMode) {
4520
- throw new Error("--onboard-current can only be used when the manifest is not already in workspace mode.");
4521
- }
4522
- for (const folderName of ["values", "secrets", "env", "profiles"]) {
4523
- await moveIfExists(path23.join(cnosRoot, folderName), path23.join(workspaceRoot, folderName), force);
4524
- }
4525
- }
4526
4930
  const created = await ensureWorkspaceLayout(cnosRoot, workspaceId);
4527
4931
  await writeFile9(path23.join(cnosRoot, "cnos.yml"), stringifyYaml7(rawManifest), "utf8");
4528
4932
  await ensureGitignore(path23.dirname(cnosRoot));
4529
4933
  await writeAnchor(packageRoot, cnosRoot, workspaceId);
4530
- await ensureFile(path23.join(packageRoot, ".cnos-workspace.yml"), `workspace: ${workspaceId}
4531
- globalRoot: ~/.cnos
4532
- `);
4934
+ await updateWorkspaceContext(packageRoot, workspaceId);
4533
4935
  const result = {
4534
4936
  workspace: workspaceId,
4535
4937
  root: path23.dirname(cnosRoot),
4536
4938
  packageRoot,
4537
- onboarded: onboardCurrent,
4538
4939
  created
4539
4940
  };
4540
4941
  if (options.json) {
@@ -4549,7 +4950,7 @@ async function runRemove(workspaceId, manifestCwd, options = {}) {
4549
4950
  if (cliArgs.length > 0) {
4550
4951
  throw new Error(`Unsupported workspace arguments: ${cliArgs.join(" ")}`);
4551
4952
  }
4552
- const loaded = await loadManifest8({
4953
+ const loaded = await loadManifest9({
4553
4954
  ...options.root ? { root: options.root } : {},
4554
4955
  cwd: manifestCwd,
4555
4956
  ...options.processEnv ? { processEnv: options.processEnv } : {}
@@ -4591,6 +4992,8 @@ async function runWorkspace(args = [], options = {}) {
4591
4992
  return runAttach(packageRoot, { ...options, cliArgs: baseCliArgs });
4592
4993
  case "detach":
4593
4994
  return runDetach(packageRoot, { ...options, cliArgs: baseCliArgs });
4995
+ case "enable":
4996
+ return runEnable(manifestCwd, packageRoot, { ...options, cliArgs: baseCliArgs });
4594
4997
  case "list":
4595
4998
  return runList2(manifestCwd, options);
4596
4999
  case "add":
@@ -4634,7 +5037,7 @@ function resolveHelpTopic(command, args) {
4634
5037
  if (command === "dev" && args[0] === "env") {
4635
5038
  return normalizeHelpTopic([command, args[0]]);
4636
5039
  }
4637
- if (command === "workspace" && args[0] && ["attach", "detach", "add", "list", "remove", "delete", "scaffold"].includes(args[0])) {
5040
+ if (command === "workspace" && args[0] && ["attach", "detach", "add", "list", "remove", "delete", "scaffold", "enable"].includes(args[0])) {
4638
5041
  return normalizeHelpTopic([command, args[0] === "delete" ? "remove" : args[0]]);
4639
5042
  }
4640
5043
  if (command === "vault" && args[0] && ["create", "add", "list", "delete", "remove"].includes(args[0])) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitsy/cnos-cli",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
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.8.0"
39
+ "smol-toml": "^1.4.2",
40
+ "@kitsy/cnos": "1.8.2"
40
41
  },
41
42
  "scripts": {
42
43
  "build": "tsup src/index.ts --format esm --dts",