@0dai-dev/cli 4.1.0 → 4.3.4

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 (53) hide show
  1. package/README.md +30 -5
  2. package/bin/0dai.js +308 -60
  3. package/lib/commands/audit.js +13 -0
  4. package/lib/commands/auth.js +404 -122
  5. package/lib/commands/boneyard.js +44 -0
  6. package/lib/commands/ci.js +329 -0
  7. package/lib/commands/compliance.js +20 -0
  8. package/lib/commands/doctor.js +79 -14
  9. package/lib/commands/experience.js +5 -1
  10. package/lib/commands/feedback.js +92 -5
  11. package/lib/commands/gh.js +506 -0
  12. package/lib/commands/graph.js +78 -10
  13. package/lib/commands/heatmap.js +17 -0
  14. package/lib/commands/import_claude_code_agents.js +367 -0
  15. package/lib/commands/init.js +553 -53
  16. package/lib/commands/loop.js +108 -0
  17. package/lib/commands/mcp.js +410 -0
  18. package/lib/commands/models.js +42 -12
  19. package/lib/commands/paste.js +114 -0
  20. package/lib/commands/persona-simulate.js +19 -0
  21. package/lib/commands/play.js +173 -0
  22. package/lib/commands/provider.js +87 -0
  23. package/lib/commands/quota.js +76 -0
  24. package/lib/commands/receipt.js +53 -0
  25. package/lib/commands/report.js +29 -2
  26. package/lib/commands/run.js +44 -4
  27. package/lib/commands/runner.js +527 -0
  28. package/lib/commands/session.js +1 -7
  29. package/lib/commands/ssh.js +416 -0
  30. package/lib/commands/standup.js +40 -0
  31. package/lib/commands/status.js +131 -36
  32. package/lib/commands/swarm.js +97 -4
  33. package/lib/commands/tui.js +117 -0
  34. package/lib/commands/usage.js +87 -0
  35. package/lib/commands/vault.js +246 -0
  36. package/lib/commands/workspace.js +1 -0
  37. package/lib/onboarding.js +30 -10
  38. package/lib/shared.js +153 -96
  39. package/lib/tui/index.mjs +34994 -0
  40. package/lib/utils/auth.js +1 -0
  41. package/lib/utils/canonical-counts.js +54 -0
  42. package/lib/utils/diff-preview.js +192 -0
  43. package/lib/utils/identity.js +76 -18
  44. package/lib/utils/mcp-auth.js +607 -0
  45. package/lib/utils/model_ratings.js +77 -0
  46. package/lib/utils/plan.js +37 -2
  47. package/lib/vault/cipher.js +125 -0
  48. package/lib/vault/identity.js +122 -0
  49. package/lib/vault/index.js +184 -0
  50. package/lib/vault/storage.js +84 -0
  51. package/lib/wizard.js +19 -12
  52. package/package.json +13 -5
  53. package/scripts/build-tui.js +77 -0
package/lib/utils/plan.js CHANGED
@@ -9,6 +9,24 @@ const path = require("path");
9
9
  const os = require("os");
10
10
 
11
11
  const PLAN_LEVELS = { trial: 0, free: 0, essential: 1, pro: 2, team: 3, enterprise: 4 };
12
+ const UPGRADE_URL = "https://0dai.dev/pricing";
13
+ const TEAM_UPGRADE_MESSAGE = `This requires the Team tier. Upgrade at ${UPGRADE_URL}`;
14
+
15
+ function _readAuthPlan() {
16
+ const authFile = path.join(os.homedir(), ".0dai", "auth.json");
17
+ if (!fs.existsSync(authFile)) return null;
18
+ try {
19
+ const auth = JSON.parse(fs.readFileSync(authFile, "utf8"));
20
+ const expiresAt = auth.expires_at || auth.plan_expires_at || "";
21
+ if (expiresAt) {
22
+ const expiryMs = Date.parse(expiresAt);
23
+ if (Number.isFinite(expiryMs) && expiryMs < Date.now()) return null;
24
+ }
25
+ const plan = String(auth.plan || "").toLowerCase();
26
+ if (PLAN_LEVELS[plan] !== undefined) return plan;
27
+ } catch {}
28
+ return null;
29
+ }
12
30
 
13
31
  function _detectPlanLocal(target) {
14
32
  const projYaml = path.join(target, "ai", "manifest", "project.yaml");
@@ -16,8 +34,9 @@ function _detectPlanLocal(target) {
16
34
  try {
17
35
  const text = fs.readFileSync(projYaml, "utf8");
18
36
  for (const line of text.split("\n")) {
19
- if (line.startsWith("plan:")) {
20
- const plan = line.split(":")[1].trim().toLowerCase();
37
+ const match = line.match(/^\s*plan:\s*['"]?([^'"\n#]+)/);
38
+ if (match) {
39
+ const plan = match[1].trim().toLowerCase();
21
40
  if (PLAN_LEVELS[plan] !== undefined) return plan;
22
41
  }
23
42
  }
@@ -36,16 +55,29 @@ function _detectPlanLocal(target) {
36
55
  }
37
56
  } catch {}
38
57
  }
58
+ const authPlan = _readAuthPlan();
59
+ if (authPlan) return authPlan;
39
60
  return "free";
40
61
  }
41
62
 
42
63
  function requirePlan(requiredPlan, featureName, target) {
43
64
  const plan = _detectPlanLocal(target || process.cwd());
44
65
  if ((PLAN_LEVELS[plan] || 0) >= (PLAN_LEVELS[requiredPlan] || 0)) return null;
66
+ if (requiredPlan === "team") {
67
+ return {
68
+ error: TEAM_UPGRADE_MESSAGE,
69
+ hint: TEAM_UPGRADE_MESSAGE,
70
+ upgrade_url: UPGRADE_URL,
71
+ feature: featureName,
72
+ current_plan: plan,
73
+ required_plan: requiredPlan,
74
+ };
75
+ }
45
76
  return {
46
77
  error: `${featureName} requires ${requiredPlan.charAt(0).toUpperCase() + requiredPlan.slice(1)} plan ($15/mo).`,
47
78
  hint: "Run: 0dai upgrade",
48
79
  current_plan: plan,
80
+ required_plan: requiredPlan,
49
81
  };
50
82
  }
51
83
 
@@ -67,6 +99,9 @@ function getSwarmQuotaLocal(target) {
67
99
 
68
100
  module.exports = {
69
101
  PLAN_LEVELS,
102
+ UPGRADE_URL,
103
+ TEAM_UPGRADE_MESSAGE,
104
+ _readAuthPlan,
70
105
  _detectPlanLocal,
71
106
  requirePlan,
72
107
  getSwarmQuotaLocal,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 0dai vault cipher — Phase 2a (Task #104 follow-up, F6 G2).
3
+ *
4
+ * Wraps `age` CLI subprocess to encrypt/decrypt secret payloads to/from
5
+ * the vault's local identity. Mirrors the design choice in ./identity.js:
6
+ * shell out to upstream `age` rather than re-implement X25519 in JS.
7
+ *
8
+ * Scope:
9
+ * - encryptToFile(payload, recipient, outPath): age -r <recipient> -o <out>
10
+ * - decryptFromFile(inPath, identityPath): age -d -i <identity> <in>
11
+ *
12
+ * Out of scope (Phase 2b+):
13
+ * - Multi-recipient envelopes (team:* secrets)
14
+ * - Hardware-key unwrap
15
+ * - Cloud sync
16
+ *
17
+ * SECURITY:
18
+ * - Payload passed via stdin; never written to disk in plaintext here.
19
+ * - Output file written with 0600 mode.
20
+ * - On any subprocess failure, the partially-written output (if any) is
21
+ * removed so callers never see a half-encrypted file.
22
+ */
23
+
24
+ "use strict";
25
+
26
+ const fs = require("node:fs");
27
+ const { spawnSync } = require("node:child_process");
28
+
29
+ const SECRET_FILE_MODE = 0o600;
30
+ const MAX_PAYLOAD_BYTES = 64 * 1024; // 64 KiB — cap matches CLI input limits
31
+
32
+ /**
33
+ * Encrypt `payload` to `outPath` for `recipient` (age1... public key).
34
+ *
35
+ * @param {Buffer|string} payload
36
+ * @param {string} recipient - age1... recipient string
37
+ * @param {string} outPath - destination path; written 0600
38
+ * @throws Error if payload too large, age binary missing, or encrypt fails
39
+ */
40
+ function encryptToFile(payload, recipient, outPath) {
41
+ if (typeof recipient !== "string" || !recipient.startsWith("age1")) {
42
+ throw new Error("recipient must be an age1... public key");
43
+ }
44
+ const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8");
45
+ if (buf.length === 0) {
46
+ throw new Error("payload is empty");
47
+ }
48
+ if (buf.length > MAX_PAYLOAD_BYTES) {
49
+ throw new Error(
50
+ `payload ${buf.length} bytes exceeds cap ${MAX_PAYLOAD_BYTES}`,
51
+ );
52
+ }
53
+ const result = spawnSync(
54
+ "age",
55
+ ["-r", recipient, "-o", outPath],
56
+ { input: buf, stdio: ["pipe", "pipe", "pipe"] },
57
+ );
58
+ if (result.error) {
59
+ if (result.error.code === "ENOENT") {
60
+ throw new Error(
61
+ "age binary not found on PATH — install age (apt install age, brew install age)",
62
+ );
63
+ }
64
+ throw result.error;
65
+ }
66
+ if (result.status !== 0) {
67
+ // Best-effort cleanup so callers never see half-written ciphertext.
68
+ try { fs.unlinkSync(outPath); } catch (_) { /* ignore */ }
69
+ const stderr = (result.stderr || "").toString().trim();
70
+ throw new Error(`age encrypt failed (rc=${result.status}): ${stderr.slice(0, 200)}`);
71
+ }
72
+ // age writes the file with the process umask; tighten to 0600 explicitly.
73
+ try {
74
+ fs.chmodSync(outPath, SECRET_FILE_MODE);
75
+ } catch (e) {
76
+ // Non-fatal if filesystem doesn't support chmod (Windows over tmp), but
77
+ // surface the failure mode so tests can assert behaviour.
78
+ if (e.code !== "EPERM" && e.code !== "ENOTSUP") {
79
+ throw e;
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Decrypt `inPath` with the identity file at `identityPath`.
86
+ *
87
+ * @param {string} inPath - ciphertext path
88
+ * @param {string} identityPath - identity.age path
89
+ * @returns {Buffer} plaintext
90
+ * @throws Error if age binary missing or decrypt fails
91
+ */
92
+ function decryptFromFile(inPath, identityPath) {
93
+ if (!fs.existsSync(inPath)) {
94
+ const e = new Error(`secret file not found: ${inPath}`);
95
+ e.code = "VAULT_SECRET_NOT_FOUND";
96
+ throw e;
97
+ }
98
+ if (!fs.existsSync(identityPath)) {
99
+ throw new Error(`identity file not found: ${identityPath}`);
100
+ }
101
+ const result = spawnSync(
102
+ "age",
103
+ ["-d", "-i", identityPath, inPath],
104
+ { stdio: ["ignore", "pipe", "pipe"] },
105
+ );
106
+ if (result.error) {
107
+ if (result.error.code === "ENOENT") {
108
+ throw new Error(
109
+ "age binary not found on PATH — install age",
110
+ );
111
+ }
112
+ throw result.error;
113
+ }
114
+ if (result.status !== 0) {
115
+ const stderr = (result.stderr || "").toString().trim();
116
+ throw new Error(`age decrypt failed (rc=${result.status}): ${stderr.slice(0, 200)}`);
117
+ }
118
+ return result.stdout || Buffer.alloc(0);
119
+ }
120
+
121
+ module.exports = {
122
+ encryptToFile,
123
+ decryptFromFile,
124
+ _MAX_PAYLOAD_BYTES: MAX_PAYLOAD_BYTES,
125
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * 0dai vault identity — Phase 1 SCAFFOLD.
3
+ *
4
+ * Wraps `age-keygen` to provision the local age identity under the vault
5
+ * directory. Phase 1 is intentionally minimal:
6
+ *
7
+ * - ensureKeypair(dir): generate identity.age + identity.pub if absent.
8
+ * - readPublicKey(dir): return the `age1...` recipient line or null.
9
+ *
10
+ * Phase 2 (operator P2 gated) will add: hardware-key unwrap, multi-recipient
11
+ * envelopes, and rotation. Do not extend this file beyond Phase 1 wiring
12
+ * without an SPEC update — see docs/runbooks/0dai-vault.md.
13
+ *
14
+ * `age-keygen` is shelled out (rather than re-implementing X25519) because
15
+ * the operator already trusts the upstream binary for the SOPS+age vault at
16
+ * ai/secrets/.
17
+ */
18
+
19
+ "use strict";
20
+
21
+ const fs = require("node:fs");
22
+ const { spawnSync } = require("node:child_process");
23
+
24
+ const storage = require("./storage");
25
+
26
+ /**
27
+ * Generate identity.age + identity.pub if missing.
28
+ *
29
+ * Returns `true` if a new keypair was created, `false` if files already
30
+ * existed. Throws on unrecoverable error (age-keygen missing, write fail).
31
+ */
32
+ function ensureKeypair(dir) {
33
+ const idPath = storage.identityPath(dir);
34
+ const pubPath = storage.publicKeyPath(dir);
35
+
36
+ if (fs.existsSync(idPath)) {
37
+ // identity already present — refuse to clobber, but make sure the
38
+ // .pub sidecar exists (cheap to regenerate from the secret key).
39
+ if (!fs.existsSync(pubPath)) {
40
+ const pub = derivePublicKey(idPath);
41
+ if (pub) writeFile(pubPath, pub + "\n");
42
+ }
43
+ return false;
44
+ }
45
+
46
+ const bin = locateAgeKeygen();
47
+ const result = spawnSync(bin, [], { encoding: "utf8", timeout: 15000 });
48
+ if (result.error) {
49
+ throw new Error(
50
+ `age-keygen failed: ${result.error.message} — install age (https://github.com/FiloSottile/age)`,
51
+ );
52
+ }
53
+ if (typeof result.status === "number" && result.status !== 0) {
54
+ throw new Error(`age-keygen exited ${result.status}: ${result.stderr || result.stdout}`);
55
+ }
56
+
57
+ const secret = (result.stdout || "").trim();
58
+ if (!secret.includes("AGE-SECRET-KEY-")) {
59
+ throw new Error("age-keygen produced unexpected output (missing AGE-SECRET-KEY marker)");
60
+ }
61
+
62
+ writeFile(idPath, secret + "\n");
63
+
64
+ // age-keygen prints the public recipient to stderr as `Public key: age1...`.
65
+ const pubLine = parsePublicKeyFromKeygenStderr(result.stderr);
66
+ if (pubLine) writeFile(pubPath, pubLine + "\n");
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * Read the public recipient from identity.pub, returning the trimmed
72
+ * `age1...` line or null if absent / malformed.
73
+ */
74
+ function readPublicKey(dir) {
75
+ const pubPath = storage.publicKeyPath(dir);
76
+ if (!fs.existsSync(pubPath)) return null;
77
+ const contents = fs.readFileSync(pubPath, "utf8").trim();
78
+ if (!contents.startsWith("age1")) return null;
79
+ return contents.split(/\s+/)[0];
80
+ }
81
+
82
+ // ---- internals ----
83
+
84
+ function writeFile(filePath, contents) {
85
+ fs.writeFileSync(filePath, contents, { mode: storage.VAULT_FILE_MODE });
86
+ try {
87
+ fs.chmodSync(filePath, storage.VAULT_FILE_MODE);
88
+ } catch {
89
+ // Best-effort.
90
+ }
91
+ }
92
+
93
+ function locateAgeKeygen() {
94
+ if (process.env.ODAI_VAULT_AGE_KEYGEN) return process.env.ODAI_VAULT_AGE_KEYGEN;
95
+ return "age-keygen";
96
+ }
97
+
98
+ function parsePublicKeyFromKeygenStderr(stderr) {
99
+ if (!stderr) return null;
100
+ const match = stderr.match(/Public key:\s+(age1[0-9a-zA-Z]+)/);
101
+ return match ? match[1] : null;
102
+ }
103
+
104
+ /**
105
+ * Phase 2 hook: derive the public key from an existing identity.age.
106
+ * Returns null in Phase 1 — the caller already has the .pub sidecar.
107
+ * Kept as a named export so doctor/diagnostic flows can probe it later.
108
+ */
109
+ function derivePublicKey(/* identityFilePath */) {
110
+ // TODO(P2): shell out to `age-keygen -y <file>` to recover the public
111
+ // recipient if identity.pub was lost. Stubbed in Phase 1 to keep the
112
+ // CLI surface honest.
113
+ return null;
114
+ }
115
+
116
+ module.exports = {
117
+ ensureKeypair,
118
+ readPublicKey,
119
+ derivePublicKey,
120
+ // exposed for tests
121
+ _parsePublicKeyFromKeygenStderr: parsePublicKeyFromKeygenStderr,
122
+ };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * 0dai vault — Phase 1 SCAFFOLD (Task #104, F6 G2).
3
+ *
4
+ * Public API surface for the local age-encrypted secrets vault. Phase 1
5
+ * only ships `init`; get/add/list/inject/rotate are stubbed and gated on
6
+ * operator P2 approval (see docs/runbooks/0dai-vault.md).
7
+ *
8
+ * Storage layout (managed in ./storage.js):
9
+ *
10
+ * ~/.0dai/vault/
11
+ * identity.age — age secret key (created by `init` if absent)
12
+ * identity.pub — age public key (recipient line)
13
+ * <scope>/ — per-scope secret bundles
14
+ * <name>.age — encrypted payload (Phase 2)
15
+ *
16
+ * SECURITY:
17
+ * - Files inside ~/.0dai/vault/ are written with 0600 / dir 0700.
18
+ * - identity.age MUST NEVER be transmitted off-host (Phase 1 is local-only).
19
+ * - Phase 2 will add a hardware-key / KMS unwrap path; do not paper over
20
+ * it with a software fallback here.
21
+ *
22
+ * Phase 1 acceptance: `0dai vault init` is idempotent and leaves the user
23
+ * with a usable age identity. Everything else throws NotImplemented so the
24
+ * CLI surface is wired but operator sees a clear "P2 deferred" message.
25
+ */
26
+
27
+ "use strict";
28
+
29
+ const storage = require("./storage");
30
+ const identity = require("./identity");
31
+
32
+ /**
33
+ * Initialize the local vault.
34
+ *
35
+ * Idempotent:
36
+ * - Creates ~/.0dai/vault/ if missing (mode 0700).
37
+ * - Generates an age keypair via `age-keygen` if identity.age is missing.
38
+ * - Returns `{ created, vaultDir, publicKey }` so callers can print a
39
+ * welcome banner or JSON.
40
+ *
41
+ * @param {object} [options]
42
+ * @param {string} [options.home] - Override HOME (used by tests).
43
+ * @returns {{created: boolean, vaultDir: string, publicKey: string|null,
44
+ * identityPath: string, publicKeyPath: string}}
45
+ */
46
+ function init(options = {}) {
47
+ const vaultDir = storage.ensureVaultDir(options);
48
+ const created = identity.ensureKeypair(vaultDir);
49
+ const publicKey = identity.readPublicKey(vaultDir);
50
+ return {
51
+ created,
52
+ vaultDir,
53
+ publicKey,
54
+ identityPath: storage.identityPath(vaultDir),
55
+ publicKeyPath: storage.publicKeyPath(vaultDir),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Resolve the canonical vault directory for the current environment.
61
+ *
62
+ * Exposed so callers (e.g. `0dai doctor`) can probe state without
63
+ * mutating the filesystem.
64
+ */
65
+ function vaultDir(options = {}) {
66
+ return storage.vaultDir(options);
67
+ }
68
+
69
+ // ---- Phase 2a — get / add (Task #104 follow-up, F6 G2) ----
70
+ // Implements local age-encrypted secret read + write under
71
+ // <vaultDir>/<scope>/<name>.age. Out of Phase 2a: list/inject/rotate
72
+ // (still throw NotImplemented below) + team:* / sync / share (no plan).
73
+
74
+ const fs = require("node:fs");
75
+ const path = require("node:path");
76
+ const cipher = require("./cipher");
77
+
78
+ const NAME_RE = /^[a-zA-Z0-9._-]{1,64}$/;
79
+
80
+ function _validateName(name) {
81
+ if (typeof name !== "string" || !NAME_RE.test(name)) {
82
+ const err = new Error(
83
+ `invalid secret name: ${JSON.stringify(name)} — ` +
84
+ "must match /^[a-zA-Z0-9._-]{1,64}$/",
85
+ );
86
+ err.code = "VAULT_INVALID_NAME";
87
+ throw err;
88
+ }
89
+ }
90
+
91
+ function _secretPath(vaultDirPath, scope, name) {
92
+ const sdir = storage.scopeDir(vaultDirPath, scope);
93
+ return path.join(sdir, `${name}.age`);
94
+ }
95
+
96
+ /**
97
+ * Read + decrypt the secret at <scope>/<name> with the local identity.
98
+ *
99
+ * @returns {{value: string, scope: string, name: string, path: string}}
100
+ * @throws Error with code=VAULT_SECRET_NOT_FOUND if file absent
101
+ * @throws Error with code=VAULT_INVALID_NAME if name fails the regex
102
+ */
103
+ function get(scope, name, options = {}) {
104
+ _validateName(name);
105
+ const vdir = storage.ensureVaultDir(options);
106
+ const sPath = _secretPath(vdir, scope, name);
107
+ const idPath = storage.identityPath(vdir);
108
+ const plaintext = cipher.decryptFromFile(sPath, idPath);
109
+ return {
110
+ value: plaintext.toString("utf8"),
111
+ scope,
112
+ name,
113
+ path: sPath,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Encrypt `value` to <scope>/<name>.age for the local recipient.
119
+ * Creates the scope dir (mode 0700) on first use. Idempotent: overwrites
120
+ * an existing secret (operator must rotate explicitly via Phase 2b).
121
+ *
122
+ * @returns {{path, scope, name, bytes, overwritten}}
123
+ * @throws Error with code=VAULT_INVALID_NAME if name fails the regex
124
+ * @throws Error with code=VAULT_NOT_INITIALIZED if identity missing
125
+ */
126
+ function add(scope, name, value, options = {}) {
127
+ _validateName(name);
128
+ const vdir = storage.ensureVaultDir(options);
129
+ const recipient = identity.readPublicKey(vdir);
130
+ if (!recipient) {
131
+ const err = new Error(
132
+ "vault identity missing — run `0dai vault init` first",
133
+ );
134
+ err.code = "VAULT_NOT_INITIALIZED";
135
+ throw err;
136
+ }
137
+ const sdir = storage.scopeDir(vdir, scope);
138
+ fs.mkdirSync(sdir, { recursive: true, mode: 0o700 });
139
+ try { fs.chmodSync(sdir, 0o700); } catch (_) { /* best effort */ }
140
+ const outPath = _secretPath(vdir, scope, name);
141
+ const overwritten = fs.existsSync(outPath);
142
+ cipher.encryptToFile(value, recipient, outPath);
143
+ const stat = fs.statSync(outPath);
144
+ return {
145
+ path: outPath,
146
+ scope,
147
+ name,
148
+ bytes: stat.size,
149
+ overwritten,
150
+ };
151
+ }
152
+
153
+ function list(/* scope, options */) {
154
+ throw notImplemented("vault.list");
155
+ }
156
+
157
+ function inject(/* scope, options */) {
158
+ throw notImplemented("vault.inject");
159
+ }
160
+
161
+ function rotate(/* scope, name, options */) {
162
+ throw notImplemented("vault.rotate");
163
+ }
164
+
165
+ function notImplemented(symbol) {
166
+ const err = new Error(
167
+ `${symbol} is gated on operator P2 approval — see docs/runbooks/0dai-vault.md`,
168
+ );
169
+ err.code = "VAULT_PHASE2_DEFERRED";
170
+ return err;
171
+ }
172
+
173
+ module.exports = {
174
+ init,
175
+ vaultDir,
176
+ get,
177
+ add,
178
+ list,
179
+ inject,
180
+ rotate,
181
+ // re-exported for tests / advanced callers
182
+ _storage: storage,
183
+ _identity: identity,
184
+ };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 0dai vault storage — Phase 1 SCAFFOLD.
3
+ *
4
+ * Owns path resolution and directory provisioning under ~/.0dai/vault/.
5
+ * No crypto here — that lives in ./identity.js (Phase 1) and a future
6
+ * ./cipher.js (Phase 2).
7
+ *
8
+ * Layout (see ./index.js header):
9
+ * <vaultDir>/identity.age 0600
10
+ * <vaultDir>/identity.pub 0600
11
+ * <vaultDir>/<scope>/ 0700 (Phase 2)
12
+ * <vaultDir>/<scope>/<n>.age 0600 (Phase 2)
13
+ *
14
+ * All public functions accept an optional `{ home }` so unit tests can
15
+ * sandbox the vault location without touching the real $HOME.
16
+ */
17
+
18
+ "use strict";
19
+
20
+ const fs = require("node:fs");
21
+ const os = require("node:os");
22
+ const path = require("node:path");
23
+
24
+ const VAULT_DIR_MODE = 0o700;
25
+ const VAULT_FILE_MODE = 0o600;
26
+
27
+ function resolveHome(options) {
28
+ if (options && typeof options.home === "string" && options.home) return options.home;
29
+ if (process.env.ODAI_VAULT_HOME) return process.env.ODAI_VAULT_HOME;
30
+ return os.homedir();
31
+ }
32
+
33
+ /**
34
+ * Canonical vault directory: $HOME/.0dai/vault/ (overridable via options.home
35
+ * or $ODAI_VAULT_HOME).
36
+ */
37
+ function vaultDir(options = {}) {
38
+ return path.join(resolveHome(options), ".0dai", "vault");
39
+ }
40
+
41
+ /**
42
+ * Ensure the vault directory exists with 0700 perms. Idempotent.
43
+ * Returns the resolved path.
44
+ */
45
+ function ensureVaultDir(options = {}) {
46
+ const dir = vaultDir(options);
47
+ fs.mkdirSync(dir, { recursive: true, mode: VAULT_DIR_MODE });
48
+ // mkdirSync respects umask, so re-chmod to be explicit.
49
+ try {
50
+ fs.chmodSync(dir, VAULT_DIR_MODE);
51
+ } catch {
52
+ // Best-effort on platforms that don't support chmod (e.g. Windows).
53
+ }
54
+ return dir;
55
+ }
56
+
57
+ function identityPath(dir) {
58
+ return path.join(dir, "identity.age");
59
+ }
60
+
61
+ function publicKeyPath(dir) {
62
+ return path.join(dir, "identity.pub");
63
+ }
64
+
65
+ /**
66
+ * Phase 2 helper: per-scope dir under the vault. Stubbed so callers can
67
+ * import it today; not exercised by Phase 1 tests.
68
+ */
69
+ function scopeDir(dir, scope) {
70
+ if (!scope || typeof scope !== "string" || scope.includes("/") || scope.includes("\\")) {
71
+ throw new Error(`invalid vault scope: ${JSON.stringify(scope)}`);
72
+ }
73
+ return path.join(dir, scope);
74
+ }
75
+
76
+ module.exports = {
77
+ VAULT_DIR_MODE,
78
+ VAULT_FILE_MODE,
79
+ vaultDir,
80
+ ensureVaultDir,
81
+ identityPath,
82
+ publicKeyPath,
83
+ scopeDir,
84
+ };
package/lib/wizard.js CHANGED
@@ -9,6 +9,8 @@
9
9
  const fs = require("fs");
10
10
  const path = require("path");
11
11
  const readline = require("readline");
12
+ const { writeFiles } = require("./shared");
13
+ const { loadCanonicalCounts, mcpToolsLabel } = require("./utils/canonical-counts");
12
14
 
13
15
  // ---------------------------------------------------------------------------
14
16
  // Helpers
@@ -65,11 +67,12 @@ async function stepAgent(rl) {
65
67
  }
66
68
 
67
69
  async function stepAuth(rl) {
70
+ const counts = loadCanonicalCounts();
68
71
  console.log("");
69
72
  console.log(" 0dai works in two modes:");
70
73
  console.log("");
71
74
  console.log(" Local mode — generate configs offline, no account needed");
72
- console.log(" Cloud mode — full graph, swarm, roaming, 56 MCP tools");
75
+ console.log(` Cloud mode — full graph, swarm, roaming, ${mcpToolsLabel(counts)}`);
73
76
  console.log("");
74
77
 
75
78
  const answer = await ask(rl, " Sign in now? [Y/n]: ", "y");
@@ -77,8 +80,7 @@ async function stepAuth(rl) {
77
80
  console.log(" → Local mode. Sign in later: 0dai auth login");
78
81
  return "local";
79
82
  }
80
- console.log(" → Cloud mode. Run: 0dai auth login");
81
- console.log(" (Authentication happens after wizard completes.)");
83
+ console.log(" → Cloud mode. Authentication continues in this init run.");
82
84
  return "cloud";
83
85
  }
84
86
 
@@ -158,7 +160,8 @@ function stepGenerate(target, agent, stack) {
158
160
  fs.mkdirSync(manifestDir, { recursive: true });
159
161
 
160
162
  // VERSION
161
- fs.writeFileSync(path.join(aiDir, "VERSION"), "3.10.1\n");
163
+ const pkgVersion = require("../package.json").version;
164
+ fs.writeFileSync(path.join(aiDir, "VERSION"), `${pkgVersion}\n`);
162
165
 
163
166
  // Discovery
164
167
  const discovery = {
@@ -182,14 +185,14 @@ function stepGenerate(target, agent, stack) {
182
185
 
183
186
  // CLAUDE.md
184
187
  const claudeMd = `# ${discovery.project_name}\n\nStack: ${stack.join(", ") || "unknown"}\nGenerated by 0dai wizard.\n\n## Commands\n\nSee ai/manifest/ for full configuration.\n`;
185
- fs.writeFileSync(path.join(target, "CLAUDE.md"), claudeMd);
188
+ const agentsMd = `# Agent Configuration\n\nProject: ${discovery.project_name}\nStack: ${stack.join(", ") || "unknown"}\n\nSee ai/manifest/ for configuration details.\n`;
189
+ writeFiles(target, {
190
+ "CLAUDE.md": claudeMd,
191
+ "AGENTS.md": agentsMd,
192
+ });
186
193
  console.log(" ✓ CLAUDE.md");
187
194
 
188
195
  // AGENTS.md
189
- fs.writeFileSync(
190
- path.join(target, "AGENTS.md"),
191
- `# Agent Configuration\n\nProject: ${discovery.project_name}\nStack: ${stack.join(", ") || "unknown"}\n\nSee ai/manifest/ for configuration details.\n`,
192
- );
193
196
  console.log(" ✓ AGENTS.md");
194
197
 
195
198
  // ai/manifest files
@@ -228,7 +231,8 @@ function stepNext(mode) {
228
231
  // Main wizard
229
232
  // ---------------------------------------------------------------------------
230
233
 
231
- async function runWizard(target) {
234
+ async function runWizard(target, options = {}) {
235
+ const forceLocal = Boolean(options.forceLocal);
232
236
  if (!isInteractive()) {
233
237
  // Non-interactive: use defaults silently
234
238
  stepGenerate(target, "all", []);
@@ -252,8 +256,11 @@ async function runWizard(target) {
252
256
  const agent = await stepAgent(rl);
253
257
  if (aborted) return { completed: false };
254
258
 
255
- const mode = await stepAuth(rl);
259
+ const mode = forceLocal ? "local" : await stepAuth(rl);
256
260
  if (aborted) return { completed: false };
261
+ if (mode === "cloud") {
262
+ return { completed: false, interactive: true, cloudRequested: true, agent, mode };
263
+ }
257
264
 
258
265
  const stack = await stepDetect(rl, target);
259
266
  if (aborted) return { completed: false };
@@ -267,7 +274,7 @@ async function runWizard(target) {
267
274
  if (err && err.code !== "ERR_USE_AFTER_CLOSE") {
268
275
  console.error(`\n Wizard error: ${err.message || err}`);
269
276
  }
270
- return { completed: false };
277
+ return { completed: false, cancelled: true };
271
278
  } finally {
272
279
  rl.close();
273
280
  }