@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.
- package/README.md +30 -5
- package/bin/0dai.js +308 -60
- package/lib/commands/audit.js +13 -0
- package/lib/commands/auth.js +404 -122
- package/lib/commands/boneyard.js +44 -0
- package/lib/commands/ci.js +329 -0
- package/lib/commands/compliance.js +20 -0
- package/lib/commands/doctor.js +79 -14
- package/lib/commands/experience.js +5 -1
- package/lib/commands/feedback.js +92 -5
- package/lib/commands/gh.js +506 -0
- package/lib/commands/graph.js +78 -10
- package/lib/commands/heatmap.js +17 -0
- package/lib/commands/import_claude_code_agents.js +367 -0
- package/lib/commands/init.js +553 -53
- package/lib/commands/loop.js +108 -0
- package/lib/commands/mcp.js +410 -0
- package/lib/commands/models.js +42 -12
- package/lib/commands/paste.js +114 -0
- package/lib/commands/persona-simulate.js +19 -0
- package/lib/commands/play.js +173 -0
- package/lib/commands/provider.js +87 -0
- package/lib/commands/quota.js +76 -0
- package/lib/commands/receipt.js +53 -0
- package/lib/commands/report.js +29 -2
- package/lib/commands/run.js +44 -4
- package/lib/commands/runner.js +527 -0
- package/lib/commands/session.js +1 -7
- package/lib/commands/ssh.js +416 -0
- package/lib/commands/standup.js +40 -0
- package/lib/commands/status.js +131 -36
- package/lib/commands/swarm.js +97 -4
- package/lib/commands/tui.js +117 -0
- package/lib/commands/usage.js +87 -0
- package/lib/commands/vault.js +246 -0
- package/lib/commands/workspace.js +1 -0
- package/lib/onboarding.js +30 -10
- package/lib/shared.js +153 -96
- package/lib/tui/index.mjs +34994 -0
- package/lib/utils/auth.js +1 -0
- package/lib/utils/canonical-counts.js +54 -0
- package/lib/utils/diff-preview.js +192 -0
- package/lib/utils/identity.js +76 -18
- package/lib/utils/mcp-auth.js +607 -0
- package/lib/utils/model_ratings.js +77 -0
- package/lib/utils/plan.js +37 -2
- package/lib/vault/cipher.js +125 -0
- package/lib/vault/identity.js +122 -0
- package/lib/vault/index.js +184 -0
- package/lib/vault/storage.js +84 -0
- package/lib/wizard.js +19 -12
- package/package.json +13 -5
- 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
|
-
|
|
20
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|