@bremmdev/arca-env 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -2
- package/dist/cli.mjs +7 -0
- package/dist/guard.mjs +90 -0
- package/dist/index.d.mts +119 -0
- package/dist/index.mjs +3 -0
- package/dist/main-Cy_OrS0c.mjs +2783 -0
- package/dist/redact-D4Txfinc.mjs +151 -0
- package/package.json +51 -4
- package/schema.json +63 -0
|
@@ -0,0 +1,2783 @@
|
|
|
1
|
+
import { n as createMaskedWriter, r as needlesFor, t as Redactor } from "./redact-D4Txfinc.mjs";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { appendFileSync, chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { constants, homedir } from "node:os";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import spawn from "cross-spawn";
|
|
8
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, randomInt, scryptSync } from "node:crypto";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
//#region src/io.ts
|
|
11
|
+
function stream(s) {
|
|
12
|
+
return {
|
|
13
|
+
write: (chunk) => {
|
|
14
|
+
s.write(chunk);
|
|
15
|
+
},
|
|
16
|
+
get isTTY() {
|
|
17
|
+
return Boolean(s.isTTY);
|
|
18
|
+
},
|
|
19
|
+
get columns() {
|
|
20
|
+
return s.columns;
|
|
21
|
+
},
|
|
22
|
+
get rows() {
|
|
23
|
+
return s.rows;
|
|
24
|
+
},
|
|
25
|
+
get colorDepth() {
|
|
26
|
+
return s.isTTY && typeof s.getColorDepth === "function" ? s.getColorDepth() : void 0;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function processIO() {
|
|
31
|
+
return {
|
|
32
|
+
env: process.env,
|
|
33
|
+
cwd: process.cwd(),
|
|
34
|
+
platform: process.platform,
|
|
35
|
+
stdout: stream(process.stdout),
|
|
36
|
+
stderr: stream(process.stderr),
|
|
37
|
+
stdinIsTTY: Boolean(process.stdin.isTTY),
|
|
38
|
+
readStdin: async () => {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
41
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
42
|
+
},
|
|
43
|
+
prompts: clackPrompts(),
|
|
44
|
+
childStdin: "inherit",
|
|
45
|
+
handleSignals: true
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function clackPrompts() {
|
|
49
|
+
const load = () => import("@clack/prompts");
|
|
50
|
+
const output = process.stderr;
|
|
51
|
+
return {
|
|
52
|
+
async password(message) {
|
|
53
|
+
const p = await load();
|
|
54
|
+
const r = await p.password({
|
|
55
|
+
message,
|
|
56
|
+
output
|
|
57
|
+
});
|
|
58
|
+
return p.isCancel(r) ? null : r;
|
|
59
|
+
},
|
|
60
|
+
async text(message, opts = {}) {
|
|
61
|
+
const p = await load();
|
|
62
|
+
const r = await p.text({
|
|
63
|
+
message,
|
|
64
|
+
output,
|
|
65
|
+
placeholder: opts.placeholder,
|
|
66
|
+
initialValue: opts.initial
|
|
67
|
+
});
|
|
68
|
+
return p.isCancel(r) ? null : r;
|
|
69
|
+
},
|
|
70
|
+
async confirm(message, initial = true) {
|
|
71
|
+
const p = await load();
|
|
72
|
+
const r = await p.confirm({
|
|
73
|
+
message,
|
|
74
|
+
output,
|
|
75
|
+
initialValue: initial
|
|
76
|
+
});
|
|
77
|
+
return p.isCancel(r) ? null : r;
|
|
78
|
+
},
|
|
79
|
+
async multiselect(message, options, initial) {
|
|
80
|
+
const p = await load();
|
|
81
|
+
const r = await p.multiselect({
|
|
82
|
+
message,
|
|
83
|
+
output,
|
|
84
|
+
options,
|
|
85
|
+
initialValues: initial,
|
|
86
|
+
required: false
|
|
87
|
+
});
|
|
88
|
+
return p.isCancel(r) ? null : r;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/context.ts
|
|
94
|
+
/**
|
|
95
|
+
* Env vars that agent tools set in the commands they run. Order matters: the first hit names
|
|
96
|
+
* the agent. Only markers specific to agent-launched processes belong here, not ones an IDE
|
|
97
|
+
* sets in every terminal (those would lock humans out). To verify per tool in spike S3.
|
|
98
|
+
*/
|
|
99
|
+
const AGENT_MARKERS = [
|
|
100
|
+
{
|
|
101
|
+
env: "ARCA_AGENT",
|
|
102
|
+
name: "agent"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
env: "AI_AGENT",
|
|
106
|
+
name: "agent"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
env: "CLAUDECODE",
|
|
110
|
+
name: "claude-code"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
env: "CLAUDE_CODE_ENTRYPOINT",
|
|
114
|
+
name: "claude-code"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
env: "CURSOR_AGENT",
|
|
118
|
+
name: "cursor"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
env: "CODEX_SANDBOX",
|
|
122
|
+
name: "codex"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
env: "CODEX_THREAD_ID",
|
|
126
|
+
name: "codex"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
env: "GEMINI_CLI",
|
|
130
|
+
name: "gemini-cli"
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
env: "OPENCODE",
|
|
134
|
+
name: "opencode"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
env: "AUGMENT_AGENT",
|
|
138
|
+
name: "augment"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
env: "CLINE_ACTIVE",
|
|
142
|
+
name: "cline"
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
env: "GOOSE_TERMINAL",
|
|
146
|
+
name: "goose"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
env: "REPL_ID",
|
|
150
|
+
name: "replit"
|
|
151
|
+
}
|
|
152
|
+
];
|
|
153
|
+
const FALSY = /* @__PURE__ */ new Set([
|
|
154
|
+
"",
|
|
155
|
+
"0",
|
|
156
|
+
"false",
|
|
157
|
+
"no",
|
|
158
|
+
"off"
|
|
159
|
+
]);
|
|
160
|
+
function detectAgent(env, extraMarkers = []) {
|
|
161
|
+
const markers = [...AGENT_MARKERS, ...extraMarkers.map((e) => ({
|
|
162
|
+
env: e,
|
|
163
|
+
name: "agent"
|
|
164
|
+
}))];
|
|
165
|
+
for (const m of markers) {
|
|
166
|
+
const v = env[m.env];
|
|
167
|
+
if (v === void 0 || FALSY.has(v.trim().toLowerCase())) continue;
|
|
168
|
+
let name = m.name;
|
|
169
|
+
if ((m.env === "AI_AGENT" || m.env === "ARCA_AGENT") && v !== "1" && v.toLowerCase() !== "true") name = v.split(/[_\s]/)[0] || m.name;
|
|
170
|
+
return {
|
|
171
|
+
detected: true,
|
|
172
|
+
name,
|
|
173
|
+
via: m.env
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { detected: false };
|
|
177
|
+
}
|
|
178
|
+
const CI_VARS = [
|
|
179
|
+
"CI",
|
|
180
|
+
"CONTINUOUS_INTEGRATION",
|
|
181
|
+
"BUILD_NUMBER",
|
|
182
|
+
"GITHUB_ACTIONS",
|
|
183
|
+
"GITLAB_CI",
|
|
184
|
+
"VERCEL",
|
|
185
|
+
"NETLIFY",
|
|
186
|
+
"BUILDKITE",
|
|
187
|
+
"CIRCLECI",
|
|
188
|
+
"TF_BUILD",
|
|
189
|
+
"CODEBUILD_BUILD_ID",
|
|
190
|
+
"BITBUCKET_BUILD_NUMBER",
|
|
191
|
+
"CF_PAGES",
|
|
192
|
+
"RENDER",
|
|
193
|
+
"RAILWAY_ENVIRONMENT"
|
|
194
|
+
];
|
|
195
|
+
function detectCI(env) {
|
|
196
|
+
return CI_VARS.some((k) => env[k] !== void 0 && !FALSY.has(env[k].trim().toLowerCase()));
|
|
197
|
+
}
|
|
198
|
+
function buildContext(io, extraMarkers = []) {
|
|
199
|
+
return {
|
|
200
|
+
agent: detectAgent(io.env, extraMarkers),
|
|
201
|
+
interactiveOutput: io.stdout.isTTY && io.stderr.isTTY,
|
|
202
|
+
stdinIsTTY: io.stdinIsTTY,
|
|
203
|
+
ci: detectCI(io.env),
|
|
204
|
+
activeProject: io.env.ARCA_ACTIVE || null
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Checks that this `arca run` was started by a package.json script: the package manager's
|
|
209
|
+
* lifecycle vars must match the script in package.json, the script must contain `arca run`,
|
|
210
|
+
* and the command arca was asked to run must be the one written in the script (extra
|
|
211
|
+
* arguments appended by `npm run x -- …` are allowed).
|
|
212
|
+
*/
|
|
213
|
+
function verifyScript(io, command) {
|
|
214
|
+
const name = io.env.npm_lifecycle_event;
|
|
215
|
+
const script = io.env.npm_lifecycle_script;
|
|
216
|
+
if (!name || script === void 0) return { kind: "adhoc" };
|
|
217
|
+
const pkgPath = findPackageJson(io);
|
|
218
|
+
if (!pkgPath) return {
|
|
219
|
+
kind: "script",
|
|
220
|
+
name,
|
|
221
|
+
verified: false,
|
|
222
|
+
reason: "no package.json found"
|
|
223
|
+
};
|
|
224
|
+
let scripts = {};
|
|
225
|
+
try {
|
|
226
|
+
scripts = JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {};
|
|
227
|
+
} catch {
|
|
228
|
+
return {
|
|
229
|
+
kind: "script",
|
|
230
|
+
name,
|
|
231
|
+
verified: false,
|
|
232
|
+
reason: "package.json is not valid JSON"
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (!Object.hasOwn(scripts, name)) return { kind: "adhoc" };
|
|
236
|
+
if (scripts[name] !== script) return {
|
|
237
|
+
kind: "script",
|
|
238
|
+
name,
|
|
239
|
+
verified: false,
|
|
240
|
+
reason: `the "${name}" script in package.json doesn't match what the package manager ran`
|
|
241
|
+
};
|
|
242
|
+
const declared = arcaRunCommands(script);
|
|
243
|
+
if (declared.length === 0) return {
|
|
244
|
+
kind: "script",
|
|
245
|
+
name,
|
|
246
|
+
verified: false,
|
|
247
|
+
reason: `the "${name}" script doesn't use \`arca run\``
|
|
248
|
+
};
|
|
249
|
+
if (!declared.some((cmd) => cmd.length > 0 && cmd.length <= command.length && cmd.every((t, i) => sameToken(t, command[i])))) return {
|
|
250
|
+
kind: "script",
|
|
251
|
+
name,
|
|
252
|
+
verified: false,
|
|
253
|
+
reason: `the command doesn't match the "${name}" script`
|
|
254
|
+
};
|
|
255
|
+
return {
|
|
256
|
+
kind: "script",
|
|
257
|
+
name,
|
|
258
|
+
verified: true
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function sameToken(a, b) {
|
|
262
|
+
return a === b;
|
|
263
|
+
}
|
|
264
|
+
function findPackageJson(io) {
|
|
265
|
+
const fromEnv = io.env.npm_package_json;
|
|
266
|
+
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
|
267
|
+
let dir = resolve(io.cwd);
|
|
268
|
+
for (;;) {
|
|
269
|
+
const p = join(dir, "package.json");
|
|
270
|
+
if (existsSync(p)) return p;
|
|
271
|
+
const parent = dirname(dir);
|
|
272
|
+
if (parent === dir) return null;
|
|
273
|
+
dir = parent;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** The commands after `arca run [opts] --` in a script, one per `arca run` occurrence. */
|
|
277
|
+
function arcaRunCommands(script) {
|
|
278
|
+
const out = [];
|
|
279
|
+
for (const segment of splitShell(script)) {
|
|
280
|
+
const tokens = tokenize(segment);
|
|
281
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
282
|
+
if (!(/(^|[\\/])arca(\.cmd|\.js|\.mjs)?$/.test(tokens[i]) && tokens[i + 1] === "run")) continue;
|
|
283
|
+
out.push(commandAfterRunOptions(tokens.slice(i + 2)));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
/** Skips arca's own options and returns the command (matches the CLI's parsing). */
|
|
289
|
+
function commandAfterRunOptions(tokens) {
|
|
290
|
+
let i = 0;
|
|
291
|
+
while (i < tokens.length) {
|
|
292
|
+
const t = tokens[i];
|
|
293
|
+
if (t === "--") return tokens.slice(i + 1);
|
|
294
|
+
if (!t.startsWith("-")) return tokens.slice(i);
|
|
295
|
+
i += RUN_OPTIONS_WITH_VALUE.has(t) ? 2 : 1;
|
|
296
|
+
}
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
const RUN_OPTIONS_WITH_VALUE = /* @__PURE__ */ new Set([
|
|
300
|
+
"--only",
|
|
301
|
+
"--project-dir",
|
|
302
|
+
"--profile",
|
|
303
|
+
"-p"
|
|
304
|
+
]);
|
|
305
|
+
/** Splits a script on && || ; | (outside quotes). */
|
|
306
|
+
function splitShell(script) {
|
|
307
|
+
const parts = [];
|
|
308
|
+
let cur = "";
|
|
309
|
+
let quote = null;
|
|
310
|
+
for (let i = 0; i < script.length; i++) {
|
|
311
|
+
const c = script[i];
|
|
312
|
+
if (quote) {
|
|
313
|
+
if (c === quote) quote = null;
|
|
314
|
+
cur += c;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (c === "\"" || c === "'") {
|
|
318
|
+
quote = c;
|
|
319
|
+
cur += c;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (c === ";" || c === "|" || c === "&") {
|
|
323
|
+
parts.push(cur);
|
|
324
|
+
cur = "";
|
|
325
|
+
if (script[i + 1] === c) i++;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
cur += c;
|
|
329
|
+
}
|
|
330
|
+
parts.push(cur);
|
|
331
|
+
return parts;
|
|
332
|
+
}
|
|
333
|
+
function tokenize(s) {
|
|
334
|
+
const tokens = [];
|
|
335
|
+
let cur = "";
|
|
336
|
+
let has = false;
|
|
337
|
+
let quote = null;
|
|
338
|
+
for (const c of s) {
|
|
339
|
+
if (quote) {
|
|
340
|
+
if (c === quote) quote = null;
|
|
341
|
+
else cur += c;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (c === "\"" || c === "'") {
|
|
345
|
+
quote = c;
|
|
346
|
+
has = true;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (/\s/.test(c)) {
|
|
350
|
+
if (has || cur) tokens.push(cur);
|
|
351
|
+
cur = "";
|
|
352
|
+
has = false;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
cur += c;
|
|
356
|
+
}
|
|
357
|
+
if (has || cur) tokens.push(cur);
|
|
358
|
+
return tokens;
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/errors.ts
|
|
362
|
+
/** An expected failure: printed without a stack trace, exits with `code`. */
|
|
363
|
+
var ArcaError = class extends Error {
|
|
364
|
+
code;
|
|
365
|
+
constructor(message, code = 1) {
|
|
366
|
+
super(message);
|
|
367
|
+
this.code = code;
|
|
368
|
+
this.name = "ArcaError";
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/config.ts
|
|
373
|
+
const CONFIG_FILE = "arca.json";
|
|
374
|
+
const SCHEMA_REF = "./node_modules/@bremmdev/arca-env/schema.json";
|
|
375
|
+
/** Profiles aren't implemented yet. Everything uses this single set internally. */
|
|
376
|
+
const DEFAULT_PROFILE = "default";
|
|
377
|
+
const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
378
|
+
function isSensitive(decl) {
|
|
379
|
+
return decl?.sensitive !== false;
|
|
380
|
+
}
|
|
381
|
+
function newProjectId() {
|
|
382
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
|
|
383
|
+
const bytes = randomBytes(16);
|
|
384
|
+
let id = "arc_";
|
|
385
|
+
for (const b of bytes) id += alphabet[b % 32];
|
|
386
|
+
return id;
|
|
387
|
+
}
|
|
388
|
+
/** Walks up from `cwd` to the nearest arca.json. */
|
|
389
|
+
function findConfigFile(cwd) {
|
|
390
|
+
let dir = resolve(cwd);
|
|
391
|
+
for (;;) {
|
|
392
|
+
const file = join(dir, CONFIG_FILE);
|
|
393
|
+
if (existsSync(file)) return file;
|
|
394
|
+
const parent = dirname(dir);
|
|
395
|
+
if (parent === dir) return null;
|
|
396
|
+
dir = parent;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
function loadProject(cwd) {
|
|
400
|
+
const file = findConfigFile(cwd);
|
|
401
|
+
if (!file) return null;
|
|
402
|
+
return parseProject(readFileSync(file, "utf8"), file);
|
|
403
|
+
}
|
|
404
|
+
function parseProject(text, file) {
|
|
405
|
+
let raw;
|
|
406
|
+
try {
|
|
407
|
+
raw = JSON.parse(text);
|
|
408
|
+
} catch (e) {
|
|
409
|
+
throw new ArcaError(`arca: ${file} is not valid JSON (${e.message}).`);
|
|
410
|
+
}
|
|
411
|
+
const warnings = [];
|
|
412
|
+
const fail = (msg) => {
|
|
413
|
+
throw new ArcaError(`arca: invalid ${CONFIG_FILE}: ${msg}\n in ${file}`);
|
|
414
|
+
};
|
|
415
|
+
if (!isObject(raw)) fail("expected an object");
|
|
416
|
+
const o = raw;
|
|
417
|
+
if (typeof o.id !== "string" || !/^arc_[a-z2-7]{8,}$/.test(o.id)) fail("\"id\" must be an arca project ID like \"arc_7k2m9q4x8w1z3p5n\". Run `npx arca init` to create one.");
|
|
418
|
+
const name = o.name === void 0 ? dirnameName(file) : o.name;
|
|
419
|
+
if (typeof name !== "string") fail("\"name\" must be a string");
|
|
420
|
+
const envFiles = o.envFiles ?? [];
|
|
421
|
+
if (!Array.isArray(envFiles) || envFiles.some((f) => typeof f !== "string")) fail("\"envFiles\" must be an array of file names");
|
|
422
|
+
const lg = o.leakGuard ?? {};
|
|
423
|
+
if (!isObject(lg)) fail("\"leakGuard\" must be an object");
|
|
424
|
+
const ignore = lg.ignore ?? [];
|
|
425
|
+
if (!Array.isArray(ignore) || ignore.some((k) => typeof k !== "string")) fail("\"leakGuard.ignore\" must be an array of variable names");
|
|
426
|
+
const vars = {};
|
|
427
|
+
const rawVars = o.vars ?? {};
|
|
428
|
+
if (!isObject(rawVars)) fail("\"vars\" must be an object");
|
|
429
|
+
for (const [key, value] of Object.entries(rawVars)) {
|
|
430
|
+
if (!VAR_NAME.test(key)) fail(`"${key}" is not a valid environment variable name`);
|
|
431
|
+
if (!isObject(value)) fail(`vars.${key} must be an object (use {} for no options)`);
|
|
432
|
+
vars[key] = parseDecl(key, value, fail);
|
|
433
|
+
}
|
|
434
|
+
for (const key of ["profiles", "defaultProfile"]) if (key in o) warnings.push(`"${key}" in ${CONFIG_FILE} is ignored: profiles aren't available in this version yet.`);
|
|
435
|
+
const known = /* @__PURE__ */ new Set([
|
|
436
|
+
"$schema",
|
|
437
|
+
"id",
|
|
438
|
+
"name",
|
|
439
|
+
"envFiles",
|
|
440
|
+
"leakGuard",
|
|
441
|
+
"vars",
|
|
442
|
+
"profiles",
|
|
443
|
+
"defaultProfile"
|
|
444
|
+
]);
|
|
445
|
+
for (const key of Object.keys(o)) if (!known.has(key)) warnings.push(`Unknown field "${key}" in ${CONFIG_FILE} is ignored.`);
|
|
446
|
+
return {
|
|
447
|
+
root: dirname(file),
|
|
448
|
+
file,
|
|
449
|
+
config: {
|
|
450
|
+
id: o.id,
|
|
451
|
+
name,
|
|
452
|
+
envFiles,
|
|
453
|
+
leakGuard: { ignore },
|
|
454
|
+
vars
|
|
455
|
+
},
|
|
456
|
+
warnings
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function parseDecl(key, v, fail) {
|
|
460
|
+
const d = {};
|
|
461
|
+
for (const field of [
|
|
462
|
+
"description",
|
|
463
|
+
"example",
|
|
464
|
+
"docs"
|
|
465
|
+
]) if (v[field] !== void 0) {
|
|
466
|
+
if (typeof v[field] !== "string") fail(`vars.${key}.${field} must be a string`);
|
|
467
|
+
d[field] = v[field];
|
|
468
|
+
}
|
|
469
|
+
for (const field of ["required", "sensitive"]) if (v[field] !== void 0) {
|
|
470
|
+
if (typeof v[field] !== "boolean") fail(`vars.${key}.${field} must be true or false`);
|
|
471
|
+
d[field] = v[field];
|
|
472
|
+
}
|
|
473
|
+
if ("default" in v || "value" in v) fail(`vars.${key} has a value. ${CONFIG_FILE} holds names only: put secrets in arca (\`npx arca set ${key}\`) and config in .env.`);
|
|
474
|
+
if (v.generate !== void 0) {
|
|
475
|
+
const g = v.generate;
|
|
476
|
+
if (!isObject(g)) fail(`vars.${key}.generate must be an object like { "bytes": 32 }`);
|
|
477
|
+
const gen = g;
|
|
478
|
+
const bytes = gen.bytes ?? 32;
|
|
479
|
+
const encoding = gen.encoding ?? "base64url";
|
|
480
|
+
if (typeof bytes !== "number" || !Number.isInteger(bytes) || bytes < 8 || bytes > 1024) fail(`vars.${key}.generate.bytes must be an integer from 8 to 1024`);
|
|
481
|
+
if (encoding !== "base64url" && encoding !== "base64" && encoding !== "hex") fail(`vars.${key}.generate.encoding must be "base64url", "base64" or "hex"`);
|
|
482
|
+
d.generate = {
|
|
483
|
+
bytes,
|
|
484
|
+
encoding
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return d;
|
|
488
|
+
}
|
|
489
|
+
function isObject(v) {
|
|
490
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
491
|
+
}
|
|
492
|
+
function dirnameName(file) {
|
|
493
|
+
return dirname(file).split(/[\\/]/).pop() ?? "project";
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Edits arca.json as raw JSON, so fields arca doesn't know about (and their order) survive.
|
|
497
|
+
* Comments aren't supported because the file is plain JSON.
|
|
498
|
+
*/
|
|
499
|
+
function updateConfigFile(file, mutate) {
|
|
500
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
501
|
+
mutate(raw);
|
|
502
|
+
writeFileSync(file, JSON.stringify(raw, null, 2) + "\n");
|
|
503
|
+
}
|
|
504
|
+
function declareVars(file, decls) {
|
|
505
|
+
updateConfigFile(file, (raw) => {
|
|
506
|
+
const vars = isObject(raw.vars) ? raw.vars : {};
|
|
507
|
+
for (const [name, decl] of Object.entries(decls)) if (!(name in vars)) vars[name] = decl;
|
|
508
|
+
raw.vars = vars;
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
function newConfig(name) {
|
|
512
|
+
return {
|
|
513
|
+
$schema: SCHEMA_REF,
|
|
514
|
+
id: newProjectId(),
|
|
515
|
+
name,
|
|
516
|
+
vars: {}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region src/envfiles.ts
|
|
521
|
+
const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*[=:]\s?(.*)$/;
|
|
522
|
+
function parseEnvEntries(text) {
|
|
523
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
524
|
+
const entries = [];
|
|
525
|
+
for (let i = 0; i < lines.length; i++) {
|
|
526
|
+
const line = lines[i];
|
|
527
|
+
const m = LINE.exec(line);
|
|
528
|
+
if (!m) continue;
|
|
529
|
+
const key = m[1];
|
|
530
|
+
let rest = m[2].trimStart();
|
|
531
|
+
const start = i;
|
|
532
|
+
let value;
|
|
533
|
+
const quote = rest[0];
|
|
534
|
+
if (quote === "\"" || quote === "'" || quote === "`") {
|
|
535
|
+
let body = rest.slice(1);
|
|
536
|
+
let close = findClose(body, quote);
|
|
537
|
+
while (close === -1 && i + 1 < lines.length) {
|
|
538
|
+
i++;
|
|
539
|
+
body += "\n" + lines[i];
|
|
540
|
+
close = findClose(body, quote);
|
|
541
|
+
}
|
|
542
|
+
if (close === -1) {
|
|
543
|
+
i = start;
|
|
544
|
+
value = stripInlineComment(rest);
|
|
545
|
+
} else {
|
|
546
|
+
value = body.slice(0, close);
|
|
547
|
+
if (quote === "\"") value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\"/g, "\"");
|
|
548
|
+
}
|
|
549
|
+
} else value = stripInlineComment(rest);
|
|
550
|
+
entries.push({
|
|
551
|
+
key,
|
|
552
|
+
value,
|
|
553
|
+
startLine: start,
|
|
554
|
+
endLine: i
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return entries;
|
|
558
|
+
}
|
|
559
|
+
function findClose(body, quote) {
|
|
560
|
+
for (let i = 0; i < body.length; i++) {
|
|
561
|
+
if (body[i] === "\\" && quote === "\"") {
|
|
562
|
+
i++;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (body[i] === quote) return i;
|
|
566
|
+
}
|
|
567
|
+
return -1;
|
|
568
|
+
}
|
|
569
|
+
/** Like dotenv (used by Next.js and Vite): an unquoted value ends at the first `#`. */
|
|
570
|
+
function stripInlineComment(raw) {
|
|
571
|
+
const hash = raw.indexOf("#");
|
|
572
|
+
return (hash === -1 ? raw : raw.slice(0, hash)).trim();
|
|
573
|
+
}
|
|
574
|
+
function parseEnv(text) {
|
|
575
|
+
const out = {};
|
|
576
|
+
for (const e of parseEnvEntries(text)) out[e.key] = e.value;
|
|
577
|
+
return out;
|
|
578
|
+
}
|
|
579
|
+
function readEnvFile(path, name = path) {
|
|
580
|
+
if (!existsSync(path)) return null;
|
|
581
|
+
try {
|
|
582
|
+
if (!statSync(path).isFile()) return null;
|
|
583
|
+
return {
|
|
584
|
+
path,
|
|
585
|
+
name,
|
|
586
|
+
entries: parseEnvEntries(readFileSync(path, "utf8"))
|
|
587
|
+
};
|
|
588
|
+
} catch {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const DOTENV_NAME = /^\.env(\..+)?$/;
|
|
593
|
+
/** Every `.env*` file in the given directories (`.env`, `.env.local`, `.env.example`, …). */
|
|
594
|
+
function findEnvFiles(dirs) {
|
|
595
|
+
const seen = /* @__PURE__ */ new Set();
|
|
596
|
+
const out = [];
|
|
597
|
+
for (const dir of dirs) {
|
|
598
|
+
let names;
|
|
599
|
+
try {
|
|
600
|
+
names = readdirSync(dir);
|
|
601
|
+
} catch {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
for (const n of names.sort()) {
|
|
605
|
+
if (!DOTENV_NAME.test(n)) continue;
|
|
606
|
+
const path = resolve(dir, n);
|
|
607
|
+
if (seen.has(path)) continue;
|
|
608
|
+
seen.add(path);
|
|
609
|
+
const f = readEnvFile(path, n);
|
|
610
|
+
if (f) out.push(f);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
/** Files a framework loads in development, lowest precedence first. */
|
|
616
|
+
const DEV_FILES = [
|
|
617
|
+
".env",
|
|
618
|
+
".env.development",
|
|
619
|
+
".env.local",
|
|
620
|
+
".env.development.local"
|
|
621
|
+
];
|
|
622
|
+
/** Files that hold examples, not real values. Import skips them. */
|
|
623
|
+
function isTemplateFile(name) {
|
|
624
|
+
return /\.(example|sample|template|dist|defaults)$/i.test(name);
|
|
625
|
+
}
|
|
626
|
+
/** Merged values of the given files (later files win). */
|
|
627
|
+
function loadEnvFiles(root, files) {
|
|
628
|
+
const out = {};
|
|
629
|
+
for (const f of files) {
|
|
630
|
+
const file = readEnvFile(join(root, f), f);
|
|
631
|
+
if (!file) continue;
|
|
632
|
+
for (const e of file.entries) out[e.key] = e.value;
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Removes the entries for `keys` from a dotenv file. Comments and other lines stay.
|
|
638
|
+
* Deletes the file only if nothing but whitespace is left. Returns what happened.
|
|
639
|
+
*/
|
|
640
|
+
function removeEntries(path, keys) {
|
|
641
|
+
const text = readFileSync(path, "utf8");
|
|
642
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
643
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
644
|
+
const drop = /* @__PURE__ */ new Set();
|
|
645
|
+
for (const e of parseEnvEntries(text)) {
|
|
646
|
+
if (!keys.has(e.key)) continue;
|
|
647
|
+
for (let i = e.startLine; i <= e.endLine; i++) drop.add(i);
|
|
648
|
+
}
|
|
649
|
+
if (drop.size === 0) return "unchanged";
|
|
650
|
+
const out = lines.filter((_, i) => !drop.has(i)).join(eol);
|
|
651
|
+
if (out.trim() === "") {
|
|
652
|
+
rmSync(path);
|
|
653
|
+
return "deleted";
|
|
654
|
+
}
|
|
655
|
+
writeFileSync(path, out);
|
|
656
|
+
return "edited";
|
|
657
|
+
}
|
|
658
|
+
//#endregion
|
|
659
|
+
//#region src/leakguard.ts
|
|
660
|
+
const PUBLIC_PREFIXES = [
|
|
661
|
+
"NEXT_PUBLIC_",
|
|
662
|
+
"VITE_",
|
|
663
|
+
"PUBLIC_",
|
|
664
|
+
"NUXT_PUBLIC_",
|
|
665
|
+
"EXPO_PUBLIC_",
|
|
666
|
+
"REACT_APP_",
|
|
667
|
+
"GATSBY_",
|
|
668
|
+
"STORYBOOK_"
|
|
669
|
+
];
|
|
670
|
+
const SECRET_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|PRIVATE|API_?KEY|_KEY$|^KEY$|CREDENTIAL|DSN|AUTH|SALT|SIGNING|WEBHOOK)/i;
|
|
671
|
+
const KNOWN_PREFIXES = /^(sk_live_|sk_test_|rk_live_|rk_test_|whsec_|ghp_|gho_|ghs_|github_pat_|glpat-|xox[abpr]-|AKIA[0-9A-Z]{12}|ASIA[0-9A-Z]{12}|AIza[0-9A-Za-z_-]{20}|sk-[A-Za-z0-9_-]{20}|sk-ant-|re_[A-Za-z0-9]{16}|SG\.|npm_[A-Za-z0-9]{20}|-----BEGIN)/;
|
|
672
|
+
const CREDENTIAL_URL = /^[a-z][a-z0-9+.-]*:\/\/[^:@/\s]*:[^@/\s]+@/i;
|
|
673
|
+
const JWT = /^eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/;
|
|
674
|
+
function isPublicName(key) {
|
|
675
|
+
return PUBLIC_PREFIXES.some((p) => key.startsWith(p));
|
|
676
|
+
}
|
|
677
|
+
function shannonEntropy(s) {
|
|
678
|
+
const counts = /* @__PURE__ */ new Map();
|
|
679
|
+
for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
680
|
+
let h = 0;
|
|
681
|
+
for (const n of counts.values()) {
|
|
682
|
+
const p = n / s.length;
|
|
683
|
+
h -= p * Math.log2(p);
|
|
684
|
+
}
|
|
685
|
+
return h;
|
|
686
|
+
}
|
|
687
|
+
/** Heuristic: does this .env entry look like a secret? Returns the reason, or null. */
|
|
688
|
+
function secretReason(key, value) {
|
|
689
|
+
const v = value.trim();
|
|
690
|
+
if (v === "") return null;
|
|
691
|
+
if (CREDENTIAL_URL.test(v)) return "a URL with a password";
|
|
692
|
+
if (KNOWN_PREFIXES.test(v)) return "a known secret format";
|
|
693
|
+
if (JWT.test(v)) return "a token (JWT)";
|
|
694
|
+
if (v.length >= 24 && !/\s/.test(v) && !/^https?:\/\//i.test(v) && shannonEntropy(v) > 4) return "a long random-looking value";
|
|
695
|
+
if (!isPublicName(key) && SECRET_NAME.test(key) && plausibleSecretValue(v)) return "a secret-like name";
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
function plausibleSecretValue(v) {
|
|
699
|
+
if (v.length < 8) return false;
|
|
700
|
+
if (/^(true|false|yes|no|on|off|null|undefined)$/i.test(v)) return false;
|
|
701
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return false;
|
|
702
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(v) && !CREDENTIAL_URL.test(v)) return false;
|
|
703
|
+
if (/^(your|change|replace|xxx|todo|<)/i.test(v)) return false;
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
function scanEnvFiles(project, files, secrets) {
|
|
707
|
+
const ignore = new Set(project.config.leakGuard.ignore);
|
|
708
|
+
const needles = secrets.flatMap((s) => needlesFor(s).map((n) => ({
|
|
709
|
+
...n,
|
|
710
|
+
name: s.name
|
|
711
|
+
})));
|
|
712
|
+
const findings = [];
|
|
713
|
+
for (const file of files) {
|
|
714
|
+
const name = relative(project.root, file.path) || file.name;
|
|
715
|
+
for (const entry of file.entries) {
|
|
716
|
+
if (entry.value.trim() === "") continue;
|
|
717
|
+
const hit = needles.find((n) => entry.value.includes(n.text));
|
|
718
|
+
if (hit) {
|
|
719
|
+
const detail = hit.name === entry.key ? `${entry.key} is stored in arca but also in plaintext in ${name}` : `${entry.key} in ${name} contains the value of ${hit.name}, which is stored in arca`;
|
|
720
|
+
findings.push({
|
|
721
|
+
file: name,
|
|
722
|
+
key: entry.key,
|
|
723
|
+
rule: "stored",
|
|
724
|
+
detail,
|
|
725
|
+
tracked: false
|
|
726
|
+
});
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
if (ignore.has(entry.key)) continue;
|
|
730
|
+
const decl = project.config.vars[entry.key];
|
|
731
|
+
if (decl && isSensitive(decl)) {
|
|
732
|
+
findings.push({
|
|
733
|
+
file: name,
|
|
734
|
+
key: entry.key,
|
|
735
|
+
rule: "declared",
|
|
736
|
+
detail: `${entry.key} is declared sensitive in arca.json but set in ${name}`,
|
|
737
|
+
tracked: false
|
|
738
|
+
});
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (!decl) {
|
|
742
|
+
const reason = secretReason(entry.key, entry.value);
|
|
743
|
+
if (reason) findings.push({
|
|
744
|
+
file: name,
|
|
745
|
+
key: entry.key,
|
|
746
|
+
rule: "heuristic",
|
|
747
|
+
detail: `${entry.key} in ${name} looks like a secret (${reason})`,
|
|
748
|
+
tracked: false
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
markTracked(project.root, findings);
|
|
754
|
+
return findings;
|
|
755
|
+
}
|
|
756
|
+
/** Escalates findings in files tracked by git. One git call, only when there are findings. */
|
|
757
|
+
function markTracked(root, findings) {
|
|
758
|
+
if (findings.length === 0) return;
|
|
759
|
+
const files = [...new Set(findings.map((f) => f.file))];
|
|
760
|
+
try {
|
|
761
|
+
const out = execFileSync("git", [
|
|
762
|
+
"ls-files",
|
|
763
|
+
"--",
|
|
764
|
+
...files
|
|
765
|
+
], {
|
|
766
|
+
cwd: root,
|
|
767
|
+
encoding: "utf8",
|
|
768
|
+
stdio: [
|
|
769
|
+
"ignore",
|
|
770
|
+
"pipe",
|
|
771
|
+
"ignore"
|
|
772
|
+
],
|
|
773
|
+
timeout: 3e3
|
|
774
|
+
});
|
|
775
|
+
const tracked = new Set(out.split(/\r?\n/).filter(Boolean).map((f) => f.replace(/\//g, "\\")));
|
|
776
|
+
for (const f of findings) if (tracked.has(f.file) || tracked.has(f.file.replace(/\\/g, "/")) || tracked.has(f.file.replace(/\//g, "\\"))) f.tracked = true;
|
|
777
|
+
} catch {}
|
|
778
|
+
}
|
|
779
|
+
function summarizeFindings(findings) {
|
|
780
|
+
const files = [...new Set(findings.map((f) => f.file))];
|
|
781
|
+
const tracked = findings.some((f) => f.tracked);
|
|
782
|
+
return `arca: ${findings.length === 1 ? "1 possible secret" : `${findings.length} possible secrets`} in ${files.join(", ")}${tracked ? " (committed to git!)" : ""}. Run \`npx arca doctor\` for details.`;
|
|
783
|
+
}
|
|
784
|
+
//#endregion
|
|
785
|
+
//#region src/messages.ts
|
|
786
|
+
function detected(agent) {
|
|
787
|
+
return `detected: ${agent.name} via ${agent.via}`;
|
|
788
|
+
}
|
|
789
|
+
function agentRevealMessage(agent) {
|
|
790
|
+
return [
|
|
791
|
+
`arca: secret values cannot be shown to AI agents (${detected(agent)}).`,
|
|
792
|
+
"You don't need the values. Secrets are injected automatically when you run package",
|
|
793
|
+
"scripts, for example `npm run dev` or `npm test`. Their output is masked as [arca:NAME].",
|
|
794
|
+
" • See which variables exist: npx arca list",
|
|
795
|
+
" • Check that they are set: npx arca check",
|
|
796
|
+
" • A value is missing or wrong: ask the user to run `npx arca set NAME`",
|
|
797
|
+
" • Need a new random secret: ask the user to run `npx arca set NAME --generate`"
|
|
798
|
+
].join("\n");
|
|
799
|
+
}
|
|
800
|
+
function agentWriteMessage(agent, command) {
|
|
801
|
+
return [
|
|
802
|
+
`arca: only a human can change secret values (${detected(agent)}).`,
|
|
803
|
+
"Ask the user to run this in their own terminal:",
|
|
804
|
+
` ${command}`,
|
|
805
|
+
"If this is a new variable, you can declare it in arca.json under \"vars\" first,",
|
|
806
|
+
"so `npx arca check` and `npx arca setup` know about it."
|
|
807
|
+
].join("\n");
|
|
808
|
+
}
|
|
809
|
+
function agentAdhocMessage(agent, command, invocation) {
|
|
810
|
+
const why = invocation.kind === "script" && !invocation.verified && invocation.reason ? `\n(Not accepted as a package script: ${invocation.reason}.)` : "";
|
|
811
|
+
return [
|
|
812
|
+
`arca: AI agents can only use secrets through package.json scripts (${detected(agent)}).${why}`,
|
|
813
|
+
`Refused: arca run -- ${command}`,
|
|
814
|
+
"Run the command through a package.json script that uses `arca run`, e.g. `npm run dev`.",
|
|
815
|
+
"If no script exists for what you need, ask the user to add one."
|
|
816
|
+
].join("\n");
|
|
817
|
+
}
|
|
818
|
+
function agentDeniedMessage(agent) {
|
|
819
|
+
return [`arca: the user has turned off secrets for AI agents in this project (${detected(agent)}).`, "Ask the user to run the command themselves."].join("\n");
|
|
820
|
+
}
|
|
821
|
+
function notInteractiveMessage(command) {
|
|
822
|
+
return ["arca: this needs a person at an interactive terminal (stdin and stdout must be a terminal).", `Run it yourself in a terminal: ${command}`].join("\n");
|
|
823
|
+
}
|
|
824
|
+
function agentsSnippet() {
|
|
825
|
+
return `## Secrets (arca)
|
|
826
|
+
|
|
827
|
+
Secrets are stored outside the repo with arca and injected when commands run.
|
|
828
|
+
\`.env\` files in this project contain only non-sensitive config, which you may read and edit.
|
|
829
|
+
|
|
830
|
+
- Run the app, tests and tools through package scripts (\`npm run dev\`, \`npm test\`, …),
|
|
831
|
+
which wrap commands in \`arca run\`. Ad-hoc \`arca run -- <cmd>\` is refused for agents.
|
|
832
|
+
Output is always masked: secrets appear as \`[arca:NAME]\`.
|
|
833
|
+
- \`npx arca list\` shows which variables exist. \`npx arca check\` verifies required ones are set.
|
|
834
|
+
- Do not try to read secret values (\`npx arca reveal\`, printing \`process.env\`, reading the
|
|
835
|
+
arca store).
|
|
836
|
+
- Only the user can set or change values. If a value is missing or wrong, ask the user
|
|
837
|
+
to run \`npx arca set NAME\` (or \`npx arca set NAME --generate\` for a random secret).
|
|
838
|
+
- To add a secret: declare it in \`arca.json\` under \`vars\`, then ask the user to set it.
|
|
839
|
+
To add non-sensitive config: put it in \`.env\` and declare it with \`"sensitive": false\`.
|
|
840
|
+
- Never put secrets (keys, tokens, passwords, connection strings with credentials) in
|
|
841
|
+
\`.env\` files. Don't change URLs that receive credentials. Those are declared sensitive
|
|
842
|
+
and live in arca.
|
|
843
|
+
- Don't work around arca: don't unset or change agent environment variables (such as
|
|
844
|
+
\`CLAUDECODE\` or \`AI_AGENT\`), don't add scripts that print \`process.env\`, and don't read
|
|
845
|
+
the arca store or the OS keychain. If arca blocks you, ask the user.
|
|
846
|
+
`;
|
|
847
|
+
}
|
|
848
|
+
//#endregion
|
|
849
|
+
//#region src/policy.ts
|
|
850
|
+
function decideRun(ctx, invocation, policy, displayCommand) {
|
|
851
|
+
if (!ctx.agent.detected) return { allow: true };
|
|
852
|
+
if (policy === "deny") return {
|
|
853
|
+
allow: false,
|
|
854
|
+
message: agentDeniedMessage(ctx.agent)
|
|
855
|
+
};
|
|
856
|
+
if (invocation.kind === "script" && invocation.verified) return { allow: true };
|
|
857
|
+
return {
|
|
858
|
+
allow: false,
|
|
859
|
+
message: agentAdhocMessage(ctx.agent, displayCommand, invocation)
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* The human gate, required before any value is shown or changed:
|
|
864
|
+
* no agent marker, and a real terminal. If an agent marker is present but the session is
|
|
865
|
+
* fully interactive (a human in an IDE terminal with leaked markers), a typed challenge
|
|
866
|
+
* lets the human through.
|
|
867
|
+
*/
|
|
868
|
+
async function humanGate(io, ctx, opts) {
|
|
869
|
+
const stdinOk = ctx.stdinIsTTY || Boolean(opts.allowPipedStdin);
|
|
870
|
+
const interactive = ctx.interactiveOutput && stdinOk;
|
|
871
|
+
if (ctx.agent.detected) {
|
|
872
|
+
if (interactive && ctx.stdinIsTTY) {
|
|
873
|
+
await challenge(io, ctx);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
throw new ArcaError(opts.kind === "reveal" ? agentRevealMessage(ctx.agent) : agentWriteMessage(ctx.agent, opts.command));
|
|
877
|
+
}
|
|
878
|
+
if (!interactive) throw new ArcaError(notInteractiveMessage(opts.command));
|
|
879
|
+
}
|
|
880
|
+
async function challenge(io, ctx) {
|
|
881
|
+
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
882
|
+
let code = "";
|
|
883
|
+
for (let i = 0; i < 4; i++) code += alphabet[randomInt(32)];
|
|
884
|
+
io.stderr.write(`arca: this terminal looks like it's driven by an AI agent (${ctx.agent.name} via ${ctx.agent.via}).\nThis confirmation must be completed by a human. AI agents must not answer this prompt.
|
|
885
|
+
Type this code to continue: ${code}\n`);
|
|
886
|
+
const answer = await io.prompts.text("Code");
|
|
887
|
+
if (answer === null || answer.trim().toUpperCase() !== code) throw new ArcaError("arca: the code did not match. Nothing was changed.");
|
|
888
|
+
}
|
|
889
|
+
//#endregion
|
|
890
|
+
//#region src/runner.ts
|
|
891
|
+
async function runCommand(s, opts) {
|
|
892
|
+
const { io, ctx } = s;
|
|
893
|
+
const command = opts.command;
|
|
894
|
+
if (command.length === 0) throw new ArcaError("arca: nothing to run. Usage: arca run -- <command> [args…]", 2);
|
|
895
|
+
const project = s.project;
|
|
896
|
+
if (!project) {
|
|
897
|
+
if (!ctx.ci) s.note(s.err("dim", "arca: no arca.json found, running without secrets. Set up arca with `npx arca init`."));
|
|
898
|
+
return spawnChild(s, command, io.env, null);
|
|
899
|
+
}
|
|
900
|
+
s.warnConfig();
|
|
901
|
+
if (ctx.activeProject === project.config.id) return spawnChild(s, command, io.env, null);
|
|
902
|
+
const display = command.join(" ");
|
|
903
|
+
if (ctx.agent.detected) {
|
|
904
|
+
const decision = decideRun(ctx, verifyScript(io, command), (await s.policy()).agentRun, display);
|
|
905
|
+
if (!decision.allow) throw new ArcaError(decision.message);
|
|
906
|
+
}
|
|
907
|
+
const envFileValues = loadEnvFiles(project.root, project.config.envFiles);
|
|
908
|
+
if (!s.hasVault()) {
|
|
909
|
+
const env = {
|
|
910
|
+
...envFileValues,
|
|
911
|
+
...io.env
|
|
912
|
+
};
|
|
913
|
+
const missing = missingRequired(s, project, env);
|
|
914
|
+
if (ctx.ci || opts.passthrough) {
|
|
915
|
+
if (missing.length) throw new ArcaError(missingMessage(project, missing, true));
|
|
916
|
+
return spawnChild(s, command, env, null);
|
|
917
|
+
}
|
|
918
|
+
if (missing.filter((n) => isSensitive(project.config.vars[n])).length) throw new ArcaError(missingMessage(project, missing, false));
|
|
919
|
+
s.note(s.err("dim", `arca: no secrets stored for ${project.config.name} on this machine yet. Running without them.`));
|
|
920
|
+
return spawnChild(s, command, env, null);
|
|
921
|
+
}
|
|
922
|
+
const vault = await s.readVault();
|
|
923
|
+
const only = opts.only ? new Set(opts.only) : null;
|
|
924
|
+
const vaultValues = {};
|
|
925
|
+
for (const [name, v] of Object.entries(vault.vars)) if (!only || only.has(name)) vaultValues[name] = v.value;
|
|
926
|
+
if (only) {
|
|
927
|
+
const unknown = [...only].filter((n) => !(n in vault.vars));
|
|
928
|
+
if (unknown.length) s.note(s.err("yellow", `arca: --only names not stored in arca: ${unknown.join(", ")}`));
|
|
929
|
+
}
|
|
930
|
+
const overridden = Object.keys(vaultValues).filter((k) => io.env[k] !== void 0 && io.env[k] !== vaultValues[k]);
|
|
931
|
+
const env = opts.noOverride ? {
|
|
932
|
+
...envFileValues,
|
|
933
|
+
...vaultValues,
|
|
934
|
+
...io.env
|
|
935
|
+
} : {
|
|
936
|
+
...envFileValues,
|
|
937
|
+
...io.env,
|
|
938
|
+
...vaultValues
|
|
939
|
+
};
|
|
940
|
+
if (overridden.length && !opts.noOverride && !s.quiet) {
|
|
941
|
+
const names = overridden.join(", ");
|
|
942
|
+
const verb = overridden.length === 1 ? "overrides a different value" : "override different values";
|
|
943
|
+
s.note(s.err("dim", `arca: ${names} from arca ${verb} inherited from your shell (use --no-override to keep the shell's).`));
|
|
944
|
+
}
|
|
945
|
+
const missing = missingRequired(s, project, env);
|
|
946
|
+
if (missing.length) throw new ArcaError(missingMessage(project, missing, false));
|
|
947
|
+
const secrets = s.secrets(vault);
|
|
948
|
+
if (!s.quiet) {
|
|
949
|
+
const findings = scanEnvFiles(project, s.envFilesForGuard(), secrets);
|
|
950
|
+
if (findings.length) s.note(s.err("yellow", summarizeFindings(findings)));
|
|
951
|
+
}
|
|
952
|
+
env.ARCA_ACTIVE = project.config.id;
|
|
953
|
+
env.ARCA_PROJECT = project.config.name;
|
|
954
|
+
delete env.ARCA_PASSPHRASE;
|
|
955
|
+
addConsoleGuard(env, secrets.map((x) => x.name));
|
|
956
|
+
return spawnChild(s, command, env, secrets.flatMap(needlesFor));
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Preloads the console guard into every Node process the command starts, so values are
|
|
960
|
+
* masked before in-process console wrappers (e.g. dev tools that forward server logs to the
|
|
961
|
+
* browser) can see them. Only names are passed; the guard reads values from its own env.
|
|
962
|
+
*/
|
|
963
|
+
function addConsoleGuard(env, names, guardFile = defaultGuardFile()) {
|
|
964
|
+
if (names.length === 0 || !guardFile) return;
|
|
965
|
+
env.ARCA_MASK = names.join(",");
|
|
966
|
+
const flag = `--import=${pathToFileURL(guardFile).href}`;
|
|
967
|
+
const current = env.NODE_OPTIONS ?? "";
|
|
968
|
+
if (!current.includes(flag)) env.NODE_OPTIONS = current ? `${current} ${flag}` : flag;
|
|
969
|
+
}
|
|
970
|
+
function defaultGuardFile() {
|
|
971
|
+
const file = fileURLToPath(new URL("./guard.mjs", import.meta.url));
|
|
972
|
+
return existsSync(file) ? file : null;
|
|
973
|
+
}
|
|
974
|
+
/** Required declared vars that are neither in the env nor in a .env file the framework will load. */
|
|
975
|
+
function missingRequired(s, project, env) {
|
|
976
|
+
const required = Object.entries(project.config.vars).filter(([, d]) => d.required).map(([n]) => n);
|
|
977
|
+
if (required.length === 0) return [];
|
|
978
|
+
let fileKeys = null;
|
|
979
|
+
return required.filter((name) => {
|
|
980
|
+
if (env[name] !== void 0 && env[name] !== "") return false;
|
|
981
|
+
fileKeys ??= new Set(s.envFilesForGuard().filter((f) => !isTemplateFile(f.name)).flatMap((f) => f.entries.filter((e) => e.value !== "").map((e) => e.key)));
|
|
982
|
+
return !fileKeys.has(name);
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
function missingMessage(project, missing, ci) {
|
|
986
|
+
const n = missing.length;
|
|
987
|
+
const head = `arca: ${n} required variable${n === 1 ? " is" : "s are"} not set for ${project.config.name}:\n ${missing.join(", ")}`;
|
|
988
|
+
if (ci) return `${head}\nAdd ${n === 1 ? "it" : "them"} to your CI or hosting platform's environment variables.`;
|
|
989
|
+
return `${head}\nRun \`npx arca setup\` to set them. Takes about a minute. (AI agents: ask the user to run it.)`;
|
|
990
|
+
}
|
|
991
|
+
/** Spawns the command. With needles, stdout and stderr are piped through the masker. */
|
|
992
|
+
function spawnChild(s, command, env, needles) {
|
|
993
|
+
const { io } = s;
|
|
994
|
+
const masked = needles !== null;
|
|
995
|
+
if (masked) forwardTerminalHints(s, env);
|
|
996
|
+
const [file, ...args] = command;
|
|
997
|
+
const plain = io.childStdin === "inherit" ? "inherit" : "pipe";
|
|
998
|
+
const child = spawn(file, args, {
|
|
999
|
+
env,
|
|
1000
|
+
cwd: io.cwd,
|
|
1001
|
+
shell: false,
|
|
1002
|
+
stdio: masked ? [
|
|
1003
|
+
io.childStdin,
|
|
1004
|
+
"pipe",
|
|
1005
|
+
"pipe"
|
|
1006
|
+
] : [
|
|
1007
|
+
io.childStdin,
|
|
1008
|
+
plain,
|
|
1009
|
+
plain
|
|
1010
|
+
]
|
|
1011
|
+
});
|
|
1012
|
+
return new Promise((resolvePromise, reject) => {
|
|
1013
|
+
const writers = [];
|
|
1014
|
+
if (masked) {
|
|
1015
|
+
const out = createMaskedWriter(new Redactor(needles), (t) => io.stdout.write(t));
|
|
1016
|
+
const err = createMaskedWriter(new Redactor(needles), (t) => io.stderr.write(t));
|
|
1017
|
+
child.stdout.on("data", (c) => out.write(c));
|
|
1018
|
+
child.stderr.on("data", (c) => err.write(c));
|
|
1019
|
+
writers.push(out, err);
|
|
1020
|
+
} else if (io.childStdin === "ignore") {
|
|
1021
|
+
child.stdout?.on("data", (c) => io.stdout.write(c.toString("utf8")));
|
|
1022
|
+
child.stderr?.on("data", (c) => io.stderr.write(c.toString("utf8")));
|
|
1023
|
+
}
|
|
1024
|
+
const cleanups = io.handleSignals ? forwardSignals(child, io) : [];
|
|
1025
|
+
child.on("error", (e) => {
|
|
1026
|
+
cleanups.forEach((c) => c());
|
|
1027
|
+
if (e.code === "ENOENT") reject(new ArcaError(`arca: command not found: ${file}`, 127));
|
|
1028
|
+
else reject(e);
|
|
1029
|
+
});
|
|
1030
|
+
child.on("close", (code, signal) => {
|
|
1031
|
+
cleanups.forEach((c) => c());
|
|
1032
|
+
for (const w of writers) w.end();
|
|
1033
|
+
if (code !== null) resolvePromise(code);
|
|
1034
|
+
else resolvePromise(128 + (signal && constants.signals[signal] || 1));
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
/** The child's output is a pipe, so tell it about colours and the terminal size. */
|
|
1039
|
+
function forwardTerminalHints(s, env) {
|
|
1040
|
+
const { stdout } = s.io;
|
|
1041
|
+
if (stdout.isTTY) {
|
|
1042
|
+
if (env.FORCE_COLOR === void 0 && !env.NO_COLOR) {
|
|
1043
|
+
const depth = stdout.colorDepth ?? 4;
|
|
1044
|
+
env.FORCE_COLOR = depth >= 24 ? "3" : depth >= 8 ? "2" : depth >= 4 ? "1" : "0";
|
|
1045
|
+
}
|
|
1046
|
+
if (stdout.columns && env.COLUMNS === void 0) env.COLUMNS = String(stdout.columns);
|
|
1047
|
+
if (stdout.rows && env.LINES === void 0) env.LINES = String(stdout.rows);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
function forwardSignals(child, io) {
|
|
1051
|
+
const signals = [
|
|
1052
|
+
"SIGINT",
|
|
1053
|
+
"SIGTERM",
|
|
1054
|
+
"SIGHUP"
|
|
1055
|
+
];
|
|
1056
|
+
if (process.platform === "win32") signals.push("SIGBREAK");
|
|
1057
|
+
const cleanups = [];
|
|
1058
|
+
for (const sig of signals) {
|
|
1059
|
+
const handler = () => {
|
|
1060
|
+
if (sig === "SIGINT" && (io.stdinIsTTY || process.platform === "win32")) return;
|
|
1061
|
+
if (process.platform === "win32" && sig !== "SIGTERM") return;
|
|
1062
|
+
try {
|
|
1063
|
+
child.kill(sig);
|
|
1064
|
+
} catch {}
|
|
1065
|
+
};
|
|
1066
|
+
process.on(sig, handler);
|
|
1067
|
+
cleanups.push(() => process.off(sig, handler));
|
|
1068
|
+
}
|
|
1069
|
+
return cleanups;
|
|
1070
|
+
}
|
|
1071
|
+
function newMasterKey() {
|
|
1072
|
+
return randomBytes(32);
|
|
1073
|
+
}
|
|
1074
|
+
function vaultKey(master, projectId, profile) {
|
|
1075
|
+
return Buffer.from(hkdfSync("sha256", master, projectId, `arca/vault/v1/${profile}`, 32));
|
|
1076
|
+
}
|
|
1077
|
+
function policyKey(master, projectId) {
|
|
1078
|
+
return Buffer.from(hkdfSync("sha256", master, projectId, "arca/policy/v1", 32));
|
|
1079
|
+
}
|
|
1080
|
+
function seal(key, plaintext, aad) {
|
|
1081
|
+
const iv = randomBytes(12);
|
|
1082
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
1083
|
+
cipher.setAAD(Buffer.from(aad, "utf8"));
|
|
1084
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
1085
|
+
return {
|
|
1086
|
+
iv: iv.toString("base64"),
|
|
1087
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
1088
|
+
ct: ct.toString("base64")
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
function open(key, sealed, aad) {
|
|
1092
|
+
try {
|
|
1093
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(sealed.iv, "base64"));
|
|
1094
|
+
decipher.setAAD(Buffer.from(aad, "utf8"));
|
|
1095
|
+
decipher.setAuthTag(Buffer.from(sealed.tag, "base64"));
|
|
1096
|
+
return Buffer.concat([decipher.update(Buffer.from(sealed.ct, "base64")), decipher.final()]).toString("utf8");
|
|
1097
|
+
} catch {
|
|
1098
|
+
throw new ArcaError("arca: could not decrypt the vault. The master key in your keychain does not match this store, or the file was modified.");
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const SCRYPT = {
|
|
1102
|
+
N: 2 ** 17,
|
|
1103
|
+
r: 8,
|
|
1104
|
+
p: 1
|
|
1105
|
+
};
|
|
1106
|
+
function scryptKey(passphrase, salt, params) {
|
|
1107
|
+
return scryptSync(passphrase.normalize("NFC"), salt, 32, {
|
|
1108
|
+
...params,
|
|
1109
|
+
maxmem: 256 * 1024 * 1024
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
function wrapMasterKey(master, passphrase, params = SCRYPT) {
|
|
1113
|
+
const salt = randomBytes(16);
|
|
1114
|
+
const sealed = seal(scryptKey(passphrase, salt, params), master.toString("base64"), "arca-master-key:v1");
|
|
1115
|
+
return {
|
|
1116
|
+
format: "arca-master-key",
|
|
1117
|
+
v: 1,
|
|
1118
|
+
kdf: "scrypt",
|
|
1119
|
+
salt: salt.toString("base64"),
|
|
1120
|
+
...params,
|
|
1121
|
+
...sealed
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
function unwrapMasterKey(wrapped, passphrase) {
|
|
1125
|
+
const key = scryptKey(passphrase, Buffer.from(wrapped.salt, "base64"), wrapped);
|
|
1126
|
+
try {
|
|
1127
|
+
return Buffer.from(open(key, wrapped, "arca-master-key:v1"), "base64");
|
|
1128
|
+
} catch {
|
|
1129
|
+
throw new ArcaError("arca: wrong passphrase.");
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function generateSecret(bytes, encoding) {
|
|
1133
|
+
return randomBytes(bytes).toString(encoding);
|
|
1134
|
+
}
|
|
1135
|
+
//#endregion
|
|
1136
|
+
//#region src/store.ts
|
|
1137
|
+
var Store = class {
|
|
1138
|
+
root;
|
|
1139
|
+
constructor(root) {
|
|
1140
|
+
this.root = root;
|
|
1141
|
+
}
|
|
1142
|
+
projectDir(id) {
|
|
1143
|
+
return join(this.root, "projects", id);
|
|
1144
|
+
}
|
|
1145
|
+
vaultFile(id, profile) {
|
|
1146
|
+
return join(this.projectDir(id), `${profile}.vault`);
|
|
1147
|
+
}
|
|
1148
|
+
hasVault(id, profile) {
|
|
1149
|
+
return existsSync(this.vaultFile(id, profile));
|
|
1150
|
+
}
|
|
1151
|
+
/** True when any vault exists, used to refuse creating a new master key over old data. */
|
|
1152
|
+
hasAnyVault() {
|
|
1153
|
+
const dir = join(this.root, "projects");
|
|
1154
|
+
if (!existsSync(dir)) return false;
|
|
1155
|
+
for (const id of readdirSync(dir)) {
|
|
1156
|
+
const pdir = join(dir, id);
|
|
1157
|
+
try {
|
|
1158
|
+
if (readdirSync(pdir).some((f) => f.endsWith(".vault"))) return true;
|
|
1159
|
+
} catch {}
|
|
1160
|
+
}
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1163
|
+
projectIds() {
|
|
1164
|
+
const dir = join(this.root, "projects");
|
|
1165
|
+
return existsSync(dir) ? readdirSync(dir).filter((d) => d.startsWith("arc_")) : [];
|
|
1166
|
+
}
|
|
1167
|
+
readVault(master, id, profile) {
|
|
1168
|
+
const file = this.vaultFile(id, profile);
|
|
1169
|
+
if (!existsSync(file)) return emptyVault();
|
|
1170
|
+
const env = readEnvelope(file, "arca-vault");
|
|
1171
|
+
if (env.projectId !== id || env.profile !== profile) throw new ArcaError(`arca: ${file} belongs to a different project or profile.`);
|
|
1172
|
+
const data = JSON.parse(open(vaultKey(master, id, profile), env, vaultAad(id, profile)));
|
|
1173
|
+
return {
|
|
1174
|
+
vars: data.vars ?? {},
|
|
1175
|
+
removed: data.removed ?? {},
|
|
1176
|
+
meta: data.meta ?? { createdAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
writeVault(master, id, profile, data) {
|
|
1180
|
+
const envelope = {
|
|
1181
|
+
format: "arca-vault",
|
|
1182
|
+
v: 1,
|
|
1183
|
+
projectId: id,
|
|
1184
|
+
profile,
|
|
1185
|
+
kdf: "hkdf-sha256",
|
|
1186
|
+
alg: "aes-256-gcm",
|
|
1187
|
+
...seal(vaultKey(master, id, profile), JSON.stringify(data), vaultAad(id, profile))
|
|
1188
|
+
};
|
|
1189
|
+
this.withLock(id, () => {
|
|
1190
|
+
const file = this.vaultFile(id, profile);
|
|
1191
|
+
if (existsSync(file)) copyFileSync(file, `${file}.bak`);
|
|
1192
|
+
atomicWrite(file, JSON.stringify(envelope, null, 2) + "\n");
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
policyFile(id) {
|
|
1196
|
+
return join(this.projectDir(id), "policy.vault");
|
|
1197
|
+
}
|
|
1198
|
+
readPolicy(master, id) {
|
|
1199
|
+
const file = this.policyFile(id);
|
|
1200
|
+
if (!existsSync(file) || !master) return { agentRun: "scripts" };
|
|
1201
|
+
const env = readEnvelope(file, "arca-policy");
|
|
1202
|
+
return { agentRun: JSON.parse(open(policyKey(master, id), env, `arca-policy:v1:${id}`)).agentRun === "deny" ? "deny" : "scripts" };
|
|
1203
|
+
}
|
|
1204
|
+
hasPolicy(id) {
|
|
1205
|
+
return existsSync(this.policyFile(id));
|
|
1206
|
+
}
|
|
1207
|
+
writePolicy(master, id, data) {
|
|
1208
|
+
const envelope = {
|
|
1209
|
+
format: "arca-policy",
|
|
1210
|
+
v: 1,
|
|
1211
|
+
projectId: id,
|
|
1212
|
+
kdf: "hkdf-sha256",
|
|
1213
|
+
alg: "aes-256-gcm",
|
|
1214
|
+
...seal(policyKey(master, id), JSON.stringify(data), `arca-policy:v1:${id}`)
|
|
1215
|
+
};
|
|
1216
|
+
this.withLock(id, () => atomicWrite(this.policyFile(id), JSON.stringify(envelope, null, 2) + "\n"));
|
|
1217
|
+
}
|
|
1218
|
+
removeProject(id) {
|
|
1219
|
+
rmSync(this.projectDir(id), {
|
|
1220
|
+
recursive: true,
|
|
1221
|
+
force: true
|
|
1222
|
+
});
|
|
1223
|
+
const reg = this.readRegistry();
|
|
1224
|
+
delete reg[id];
|
|
1225
|
+
this.writeJson("registry.json", reg);
|
|
1226
|
+
}
|
|
1227
|
+
readGlobalConfig() {
|
|
1228
|
+
return this.readJson("config.json") ?? {};
|
|
1229
|
+
}
|
|
1230
|
+
writeGlobalConfig(cfg) {
|
|
1231
|
+
this.writeJson("config.json", cfg);
|
|
1232
|
+
}
|
|
1233
|
+
readRegistry() {
|
|
1234
|
+
return this.readJson("registry.json") ?? {};
|
|
1235
|
+
}
|
|
1236
|
+
/** Records where a project lives. Returns the previous entry so callers can detect ID collisions. */
|
|
1237
|
+
touchRegistry(id, info) {
|
|
1238
|
+
const reg = this.readRegistry();
|
|
1239
|
+
const prev = reg[id];
|
|
1240
|
+
const roots = new Set(prev?.roots ?? []);
|
|
1241
|
+
roots.add(info.root);
|
|
1242
|
+
reg[id] = {
|
|
1243
|
+
name: info.name,
|
|
1244
|
+
roots: [...roots],
|
|
1245
|
+
gitRemote: info.gitRemote ?? prev?.gitRemote ?? null,
|
|
1246
|
+
lastUsed: (/* @__PURE__ */ new Date()).toISOString()
|
|
1247
|
+
};
|
|
1248
|
+
this.writeJson("registry.json", reg);
|
|
1249
|
+
return prev;
|
|
1250
|
+
}
|
|
1251
|
+
readJson(name) {
|
|
1252
|
+
const file = join(this.root, name);
|
|
1253
|
+
if (!existsSync(file)) return null;
|
|
1254
|
+
try {
|
|
1255
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
1256
|
+
} catch {
|
|
1257
|
+
throw new ArcaError(`arca: ${file} is corrupt (not valid JSON).`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
writeJson(name, data) {
|
|
1261
|
+
ensureDir(this.root);
|
|
1262
|
+
atomicWrite(join(this.root, name), JSON.stringify(data, null, 2) + "\n");
|
|
1263
|
+
}
|
|
1264
|
+
/** Serialises writes to one project's files across processes. */
|
|
1265
|
+
withLock(id, fn) {
|
|
1266
|
+
const dir = this.projectDir(id);
|
|
1267
|
+
ensureDir(dir);
|
|
1268
|
+
const lock = join(dir, ".lock");
|
|
1269
|
+
const deadline = Date.now() + 5e3;
|
|
1270
|
+
for (;;) try {
|
|
1271
|
+
closeSync(openSync(lock, "wx"));
|
|
1272
|
+
break;
|
|
1273
|
+
} catch (e) {
|
|
1274
|
+
if (e.code !== "EEXIST") throw e;
|
|
1275
|
+
try {
|
|
1276
|
+
if (Date.now() - statSync(lock).mtimeMs > 1e4) {
|
|
1277
|
+
rmSync(lock, { force: true });
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
} catch {
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
if (Date.now() > deadline) throw new ArcaError(`arca: the store is locked by another arca process (${lock}).`);
|
|
1284
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
1285
|
+
}
|
|
1286
|
+
try {
|
|
1287
|
+
return fn();
|
|
1288
|
+
} finally {
|
|
1289
|
+
rmSync(lock, { force: true });
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
function emptyVault() {
|
|
1294
|
+
return {
|
|
1295
|
+
vars: {},
|
|
1296
|
+
removed: {},
|
|
1297
|
+
meta: { createdAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
function vaultAad(id, profile) {
|
|
1301
|
+
return `arca-vault:v1:${id}:${profile}`;
|
|
1302
|
+
}
|
|
1303
|
+
function readEnvelope(file, format) {
|
|
1304
|
+
let env;
|
|
1305
|
+
try {
|
|
1306
|
+
env = JSON.parse(readFileSync(file, "utf8"));
|
|
1307
|
+
} catch {
|
|
1308
|
+
throw new ArcaError(`arca: ${file} is corrupt. A previous version may be in ${file}.bak.`);
|
|
1309
|
+
}
|
|
1310
|
+
if (env.format !== format || env.v !== 1) throw new ArcaError(`arca: ${file} has an unknown format.`);
|
|
1311
|
+
return env;
|
|
1312
|
+
}
|
|
1313
|
+
function ensureDir(dir) {
|
|
1314
|
+
if (!existsSync(dir)) mkdirSync(dir, {
|
|
1315
|
+
recursive: true,
|
|
1316
|
+
mode: 448
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
function atomicWrite(file, content) {
|
|
1320
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
1321
|
+
const fd = openSync(tmp, "w", 384);
|
|
1322
|
+
try {
|
|
1323
|
+
writeFileSync(fd, content);
|
|
1324
|
+
fsyncSync(fd);
|
|
1325
|
+
} finally {
|
|
1326
|
+
closeSync(fd);
|
|
1327
|
+
}
|
|
1328
|
+
for (let attempt = 0;; attempt++) try {
|
|
1329
|
+
renameSync(tmp, file);
|
|
1330
|
+
break;
|
|
1331
|
+
} catch (e) {
|
|
1332
|
+
const code = e.code;
|
|
1333
|
+
if (attempt >= 10 || code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") {
|
|
1334
|
+
rmSync(tmp, { force: true });
|
|
1335
|
+
throw e;
|
|
1336
|
+
}
|
|
1337
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
1338
|
+
}
|
|
1339
|
+
if (process.platform !== "win32") chmodSync(file, 384);
|
|
1340
|
+
}
|
|
1341
|
+
//#endregion
|
|
1342
|
+
//#region src/paths.ts
|
|
1343
|
+
/** The store lives outside every project: never in the repo, never in `.vault/`. */
|
|
1344
|
+
function storeRoot(env, platform) {
|
|
1345
|
+
if (env.ARCA_HOME) return env.ARCA_HOME;
|
|
1346
|
+
if (platform === "win32") return join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "arca");
|
|
1347
|
+
if (platform === "darwin") return join(homedir(), "Library", "Application Support", "arca");
|
|
1348
|
+
return join(env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "arca");
|
|
1349
|
+
}
|
|
1350
|
+
//#endregion
|
|
1351
|
+
//#region src/keychain.ts
|
|
1352
|
+
const SERVICE = "arca";
|
|
1353
|
+
/** The keychain account. A custom ARCA_HOME gets its own entry so stores never share keys by accident. */
|
|
1354
|
+
function keychainAccount(store, io) {
|
|
1355
|
+
const defaultRoot = storeRoot({
|
|
1356
|
+
...io.env,
|
|
1357
|
+
ARCA_HOME: void 0
|
|
1358
|
+
}, io.platform);
|
|
1359
|
+
if (store.root === defaultRoot) return "master-key/v1";
|
|
1360
|
+
return `master-key/v1/${createHash("sha256").update(store.root).digest("hex").slice(0, 12)}`;
|
|
1361
|
+
}
|
|
1362
|
+
function chooseBackend(store, io) {
|
|
1363
|
+
const configured = store.readGlobalConfig().keychain ?? io.env.ARCA_KEYCHAIN;
|
|
1364
|
+
if (configured === "os" || configured === "passphrase") return configured;
|
|
1365
|
+
if (io.platform === "linux") {
|
|
1366
|
+
if (readSafe("/proc/version").toLowerCase().includes("microsoft") || !io.env.DBUS_SESSION_BUS_ADDRESS) return "passphrase";
|
|
1367
|
+
}
|
|
1368
|
+
return "os";
|
|
1369
|
+
}
|
|
1370
|
+
function readSafe(file) {
|
|
1371
|
+
try {
|
|
1372
|
+
return readFileSync(file, "utf8");
|
|
1373
|
+
} catch {
|
|
1374
|
+
return "";
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
async function osEntry() {
|
|
1378
|
+
try {
|
|
1379
|
+
const { Entry } = await import("@napi-rs/keyring");
|
|
1380
|
+
return Entry;
|
|
1381
|
+
} catch (e) {
|
|
1382
|
+
throw new ArcaError(`arca: the OS keychain binding could not be loaded (${e.message}).\nUse passphrase mode instead: set ARCA_KEYCHAIN=passphrase before the first \`npx arca set\`.`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
async function loadMasterKey(store, io, opts) {
|
|
1386
|
+
const backend = chooseBackend(store, io);
|
|
1387
|
+
const key = backend === "os" ? await readOsKey(store, io) : await readPassphraseKey(store, io);
|
|
1388
|
+
if (key) return key;
|
|
1389
|
+
if (!opts.create) return null;
|
|
1390
|
+
if (store.hasAnyVault()) throw new ArcaError(`arca: the master key is missing from your keychain, but encrypted vaults exist in
|
|
1391
|
+
${store.root}\nWithout the key they cannot be decrypted. Restore the keychain entry, or delete that folder to start over.`);
|
|
1392
|
+
const fresh = newMasterKey();
|
|
1393
|
+
if (backend === "os") await writeOsKey(store, io, fresh);
|
|
1394
|
+
else await writePassphraseKey(store, io, fresh);
|
|
1395
|
+
store.writeGlobalConfig({
|
|
1396
|
+
...store.readGlobalConfig(),
|
|
1397
|
+
keychain: backend
|
|
1398
|
+
});
|
|
1399
|
+
return fresh;
|
|
1400
|
+
}
|
|
1401
|
+
/** Like loadMasterKey without create, but fails with a clear message. */
|
|
1402
|
+
async function requireMasterKey(store, io) {
|
|
1403
|
+
const key = await loadMasterKey(store, io, { create: false });
|
|
1404
|
+
if (!key) throw new ArcaError(`arca: no master key found, so the stored secrets cannot be decrypted.
|
|
1405
|
+
Store: ${store.root}\n Run \`npx arca doctor\` for details.`);
|
|
1406
|
+
return key;
|
|
1407
|
+
}
|
|
1408
|
+
async function readOsKey(store, io) {
|
|
1409
|
+
const Entry = await osEntry();
|
|
1410
|
+
let value;
|
|
1411
|
+
try {
|
|
1412
|
+
value = new Entry(SERVICE, keychainAccount(store, io)).getPassword();
|
|
1413
|
+
} catch (e) {
|
|
1414
|
+
throw new ArcaError(`arca: could not read the OS keychain (${e.message}).\nIf this machine has no keychain, use passphrase mode: set ARCA_KEYCHAIN=passphrase.`);
|
|
1415
|
+
}
|
|
1416
|
+
if (!value) return null;
|
|
1417
|
+
const key = Buffer.from(value, "base64");
|
|
1418
|
+
if (key.length !== 32) throw new ArcaError("arca: the master key in the keychain is malformed.");
|
|
1419
|
+
return key;
|
|
1420
|
+
}
|
|
1421
|
+
async function writeOsKey(store, io, key) {
|
|
1422
|
+
const entry = new (await (osEntry()))(SERVICE, keychainAccount(store, io));
|
|
1423
|
+
entry.setPassword(key.toString("base64"));
|
|
1424
|
+
if (entry.getPassword() !== key.toString("base64")) throw new ArcaError("arca: the OS keychain did not keep the master key. Use passphrase mode: set ARCA_KEYCHAIN=passphrase.");
|
|
1425
|
+
}
|
|
1426
|
+
function wrappedFile(store) {
|
|
1427
|
+
return join(store.root, "master.key.enc");
|
|
1428
|
+
}
|
|
1429
|
+
async function readPassphraseKey(store, io) {
|
|
1430
|
+
const file = wrappedFile(store);
|
|
1431
|
+
if (!existsSync(file)) return null;
|
|
1432
|
+
return unwrapMasterKey(JSON.parse(readFileSync(file, "utf8")), await getPassphrase(io, "arca passphrase"));
|
|
1433
|
+
}
|
|
1434
|
+
async function writePassphraseKey(store, io, key) {
|
|
1435
|
+
let passphrase = io.env.ARCA_PASSPHRASE;
|
|
1436
|
+
if (!passphrase) {
|
|
1437
|
+
passphrase = await getPassphrase(io, "Choose an arca passphrase (protects all your vaults on this machine)");
|
|
1438
|
+
if (await getPassphrase(io, "Repeat the passphrase") !== passphrase) throw new ArcaError("arca: the passphrases did not match.");
|
|
1439
|
+
}
|
|
1440
|
+
if (passphrase.length < 8) throw new ArcaError("arca: use a passphrase of at least 8 characters.");
|
|
1441
|
+
ensureDir(store.root);
|
|
1442
|
+
atomicWrite(wrappedFile(store), JSON.stringify(wrapMasterKey(key, passphrase), null, 2) + "\n");
|
|
1443
|
+
}
|
|
1444
|
+
async function getPassphrase(io, message) {
|
|
1445
|
+
if (io.env.ARCA_PASSPHRASE) return io.env.ARCA_PASSPHRASE;
|
|
1446
|
+
if (!io.stdinIsTTY || !io.stderr.isTTY) throw new ArcaError("arca: this store uses a passphrase. Run the command in an interactive terminal, or set ARCA_PASSPHRASE.");
|
|
1447
|
+
const value = await io.prompts.password(message);
|
|
1448
|
+
if (value === null) throw new ArcaError("arca: cancelled.");
|
|
1449
|
+
return value;
|
|
1450
|
+
}
|
|
1451
|
+
//#endregion
|
|
1452
|
+
//#region src/style.ts
|
|
1453
|
+
const codes = {
|
|
1454
|
+
bold: [1, 22],
|
|
1455
|
+
dim: [2, 22],
|
|
1456
|
+
red: [31, 39],
|
|
1457
|
+
green: [32, 39],
|
|
1458
|
+
yellow: [33, 39],
|
|
1459
|
+
cyan: [36, 39],
|
|
1460
|
+
gray: [90, 39]
|
|
1461
|
+
};
|
|
1462
|
+
function colorEnabled(s, env) {
|
|
1463
|
+
if (env.NO_COLOR) return false;
|
|
1464
|
+
if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
|
|
1465
|
+
return s.isTTY;
|
|
1466
|
+
}
|
|
1467
|
+
function painter(s, env) {
|
|
1468
|
+
const on = colorEnabled(s, env);
|
|
1469
|
+
return (style, text) => {
|
|
1470
|
+
if (!on) return text;
|
|
1471
|
+
const [open, close] = codes[style];
|
|
1472
|
+
return `\u001b[${open}m${text}\u001b[${close}m`;
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
/** Pads a string to a visible width (ignores ANSI escapes). */
|
|
1476
|
+
function pad(text, width) {
|
|
1477
|
+
const visible = text.replace(/\u001b\[[0-9;]*m/g, "").length;
|
|
1478
|
+
return visible >= width ? text : text + " ".repeat(width - visible);
|
|
1479
|
+
}
|
|
1480
|
+
//#endregion
|
|
1481
|
+
//#region src/session.ts
|
|
1482
|
+
/** Per-invocation state shared by all commands. */
|
|
1483
|
+
var Session = class {
|
|
1484
|
+
io;
|
|
1485
|
+
store;
|
|
1486
|
+
ctx;
|
|
1487
|
+
project;
|
|
1488
|
+
out;
|
|
1489
|
+
err;
|
|
1490
|
+
profile = DEFAULT_PROFILE;
|
|
1491
|
+
quiet = false;
|
|
1492
|
+
json = false;
|
|
1493
|
+
master = null;
|
|
1494
|
+
constructor(io, opts = {}) {
|
|
1495
|
+
this.io = io;
|
|
1496
|
+
this.store = new Store(storeRoot(io.env, io.platform));
|
|
1497
|
+
const markers = safeMarkers(this.store);
|
|
1498
|
+
this.ctx = buildContext(io, markers);
|
|
1499
|
+
this.project = loadProject(opts.projectDir ? resolve(io.cwd, opts.projectDir) : io.cwd);
|
|
1500
|
+
this.out = painter(io.stdout, io.env);
|
|
1501
|
+
this.err = painter(io.stderr, io.env);
|
|
1502
|
+
}
|
|
1503
|
+
print(text = "") {
|
|
1504
|
+
this.io.stdout.write(text + "\n");
|
|
1505
|
+
}
|
|
1506
|
+
/** Status and warnings go to stderr, so stdout stays clean for data. */
|
|
1507
|
+
note(text) {
|
|
1508
|
+
this.io.stderr.write(text + "\n");
|
|
1509
|
+
}
|
|
1510
|
+
warnConfig() {
|
|
1511
|
+
if (!this.project || this.quiet) return;
|
|
1512
|
+
for (const w of this.project.warnings) this.note(this.err("yellow", `arca: ${w}`));
|
|
1513
|
+
}
|
|
1514
|
+
requireProject() {
|
|
1515
|
+
if (!this.project) throw new ArcaError("arca: no arca.json found in this folder or any parent. Run `npx arca init` to set up arca for this project.");
|
|
1516
|
+
return this.project;
|
|
1517
|
+
}
|
|
1518
|
+
get projectId() {
|
|
1519
|
+
return this.requireProject().config.id;
|
|
1520
|
+
}
|
|
1521
|
+
hasVault() {
|
|
1522
|
+
return this.project ? this.store.hasVault(this.project.config.id, this.profile) : false;
|
|
1523
|
+
}
|
|
1524
|
+
async masterKey(create = false) {
|
|
1525
|
+
if (this.master) return this.master;
|
|
1526
|
+
this.master = create ? await loadMasterKey(this.store, this.io, { create: true }) : await requireMasterKey(this.store, this.io);
|
|
1527
|
+
if (!this.master) throw new ArcaError("arca: no master key.");
|
|
1528
|
+
return this.master;
|
|
1529
|
+
}
|
|
1530
|
+
/** The project's vault. Doesn't touch the keychain when no vault exists yet. */
|
|
1531
|
+
async readVault() {
|
|
1532
|
+
if (!this.hasVault()) return emptyVault();
|
|
1533
|
+
return this.store.readVault(await this.masterKey(), this.projectId, this.profile);
|
|
1534
|
+
}
|
|
1535
|
+
async writeVault(data) {
|
|
1536
|
+
const project = this.requireProject();
|
|
1537
|
+
const master = await this.masterKey(true);
|
|
1538
|
+
this.store.writeVault(master, project.config.id, this.profile, data);
|
|
1539
|
+
const prev = this.store.touchRegistry(project.config.id, {
|
|
1540
|
+
name: project.config.name,
|
|
1541
|
+
root: project.root,
|
|
1542
|
+
gitRemote: gitRemote(project.root)
|
|
1543
|
+
});
|
|
1544
|
+
this.warnCollision(prev);
|
|
1545
|
+
}
|
|
1546
|
+
warnCollision(prev) {
|
|
1547
|
+
const project = this.project;
|
|
1548
|
+
if (!project || !prev?.gitRemote) return;
|
|
1549
|
+
const remote = gitRemote(project.root);
|
|
1550
|
+
if (remote && remote !== prev.gitRemote) this.note(this.err("yellow", `arca: arca.json id ${project.config.id} is also used by ${prev.roots[0] ?? "another project"} (git remote ${prev.gitRemote}).\n Two different repos share secrets. If that is wrong, run \`npx arca init --new-id\` in one of them.`));
|
|
1551
|
+
}
|
|
1552
|
+
async policy() {
|
|
1553
|
+
if (!this.project || !this.store.hasPolicy(this.project.config.id)) return { agentRun: "scripts" };
|
|
1554
|
+
return this.store.readPolicy(await this.masterKey(), this.project.config.id);
|
|
1555
|
+
}
|
|
1556
|
+
/** Stored values that count as secrets (masking needles, leak-guard exact matches). */
|
|
1557
|
+
secrets(vault) {
|
|
1558
|
+
const decls = this.project?.config.vars ?? {};
|
|
1559
|
+
return Object.entries(vault.vars).filter(([name]) => isSensitive(decls[name])).map(([name, v]) => ({
|
|
1560
|
+
name,
|
|
1561
|
+
value: v.value
|
|
1562
|
+
}));
|
|
1563
|
+
}
|
|
1564
|
+
/** Nearest directory with a package.json, from cwd up to the project root. */
|
|
1565
|
+
packageRoot() {
|
|
1566
|
+
let dir = resolve(this.io.cwd);
|
|
1567
|
+
for (;;) {
|
|
1568
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
1569
|
+
const parent = dirname(dir);
|
|
1570
|
+
if (parent === dir || this.project && dir === this.project.root) return null;
|
|
1571
|
+
dir = parent;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
/** Every .env* file the leak guard reads: project root, nearest package root, and envFiles. */
|
|
1575
|
+
envFilesForGuard() {
|
|
1576
|
+
const project = this.requireProject();
|
|
1577
|
+
const dirs = [project.root];
|
|
1578
|
+
const pkg = this.packageRoot();
|
|
1579
|
+
if (pkg && pkg !== project.root) dirs.push(pkg);
|
|
1580
|
+
const files = findEnvFiles(dirs);
|
|
1581
|
+
for (const f of project.config.envFiles) {
|
|
1582
|
+
const path = resolve(project.root, f);
|
|
1583
|
+
if (files.some((x) => x.path === path)) continue;
|
|
1584
|
+
const file = readEnvFile(path, f);
|
|
1585
|
+
if (file) files.push(file);
|
|
1586
|
+
}
|
|
1587
|
+
return files;
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
function safeMarkers(store) {
|
|
1591
|
+
try {
|
|
1592
|
+
return store.readGlobalConfig().agentMarkers ?? [];
|
|
1593
|
+
} catch {
|
|
1594
|
+
return [];
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
function gitRemote(root) {
|
|
1598
|
+
try {
|
|
1599
|
+
return execFileSync("git", [
|
|
1600
|
+
"config",
|
|
1601
|
+
"--get",
|
|
1602
|
+
"remote.origin.url"
|
|
1603
|
+
], {
|
|
1604
|
+
cwd: root,
|
|
1605
|
+
encoding: "utf8",
|
|
1606
|
+
stdio: [
|
|
1607
|
+
"ignore",
|
|
1608
|
+
"pipe",
|
|
1609
|
+
"ignore"
|
|
1610
|
+
],
|
|
1611
|
+
timeout: 3e3
|
|
1612
|
+
}).trim() || null;
|
|
1613
|
+
} catch {
|
|
1614
|
+
return null;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
//#endregion
|
|
1618
|
+
//#region src/util.ts
|
|
1619
|
+
function levenshtein(a, b) {
|
|
1620
|
+
const dp = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
1621
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1622
|
+
let prev = dp[0];
|
|
1623
|
+
dp[0] = i;
|
|
1624
|
+
for (let j = 1; j <= b.length; j++) {
|
|
1625
|
+
const tmp = dp[j];
|
|
1626
|
+
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
1627
|
+
prev = tmp;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
return dp[b.length];
|
|
1631
|
+
}
|
|
1632
|
+
/** The closest candidate within a small edit distance, for "did you mean …?". */
|
|
1633
|
+
function suggest(input, candidates) {
|
|
1634
|
+
let best = null;
|
|
1635
|
+
let bestDist = Infinity;
|
|
1636
|
+
for (const c of candidates) {
|
|
1637
|
+
if (c === input) continue;
|
|
1638
|
+
const d = levenshtein(input.toUpperCase(), c.toUpperCase());
|
|
1639
|
+
if (d < bestDist) {
|
|
1640
|
+
best = c;
|
|
1641
|
+
bestDist = d;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
const limit = Math.max(1, Math.min(3, Math.floor(input.length / 4)));
|
|
1645
|
+
return bestDist <= limit ? best : null;
|
|
1646
|
+
}
|
|
1647
|
+
function plural(n, one, many = `${one}s`) {
|
|
1648
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
1649
|
+
}
|
|
1650
|
+
function shortDate(iso) {
|
|
1651
|
+
return iso ? iso.slice(0, 10) : "";
|
|
1652
|
+
}
|
|
1653
|
+
//#endregion
|
|
1654
|
+
//#region src/commands/values.ts
|
|
1655
|
+
const now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
1656
|
+
function storeValue(vault, name, value, source) {
|
|
1657
|
+
const old = vault.vars[name];
|
|
1658
|
+
vault.vars[name] = {
|
|
1659
|
+
value,
|
|
1660
|
+
updatedAt: now(),
|
|
1661
|
+
source,
|
|
1662
|
+
previous: old ? {
|
|
1663
|
+
value: old.value,
|
|
1664
|
+
updatedAt: old.updatedAt
|
|
1665
|
+
} : vault.removed[name] ?? null
|
|
1666
|
+
};
|
|
1667
|
+
delete vault.removed[name];
|
|
1668
|
+
}
|
|
1669
|
+
function checkName(name, usage) {
|
|
1670
|
+
if (!name) throw new ArcaError(`arca: missing variable name. Usage: ${usage}`, 2);
|
|
1671
|
+
if (!VAR_NAME.test(name)) throw new ArcaError(`arca: "${name}" is not a valid environment variable name.`, 2);
|
|
1672
|
+
return name;
|
|
1673
|
+
}
|
|
1674
|
+
async function setCommand(s, opts) {
|
|
1675
|
+
const project = s.requireProject();
|
|
1676
|
+
let name = checkName(opts.name, "npx arca set NAME");
|
|
1677
|
+
const fromStdin = opts.value === void 0 && opts.generate === void 0 && !s.io.stdinIsTTY;
|
|
1678
|
+
await humanGate(s.io, s.ctx, {
|
|
1679
|
+
kind: "write",
|
|
1680
|
+
command: `npx arca set ${name}`,
|
|
1681
|
+
allowPipedStdin: fromStdin
|
|
1682
|
+
});
|
|
1683
|
+
const declared = project.config.vars;
|
|
1684
|
+
if (!(name in declared)) {
|
|
1685
|
+
const guess = suggest(name, Object.keys(declared));
|
|
1686
|
+
if (guess && s.io.stdinIsTTY) {
|
|
1687
|
+
const yes = await s.io.prompts.confirm(`${name} isn't declared in arca.json. Did you mean ${guess}?`, true);
|
|
1688
|
+
if (yes === null) throw new ArcaError("arca: cancelled.");
|
|
1689
|
+
if (yes) name = guess;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
const decl = declared[name];
|
|
1693
|
+
let value;
|
|
1694
|
+
if (opts.generate !== void 0) value = generateSecret(opts.generate === true ? decl?.generate?.bytes ?? 32 : opts.generate, decl?.generate?.encoding ?? "base64url");
|
|
1695
|
+
else if (opts.value !== void 0) {
|
|
1696
|
+
s.note(s.err("yellow", "⚠ value passed on the command line; it may be in your shell history. Next time use `npx arca set NAME` (hidden prompt)."));
|
|
1697
|
+
value = opts.value;
|
|
1698
|
+
} else if (fromStdin) value = (await s.io.readStdin()).replace(/\r?\n$/, "");
|
|
1699
|
+
else {
|
|
1700
|
+
const entered = await s.io.prompts.password(`${name}${decl?.description ? ` (${decl.description})` : ""}`);
|
|
1701
|
+
if (entered === null) throw new ArcaError("arca: cancelled. Nothing was changed.");
|
|
1702
|
+
value = entered;
|
|
1703
|
+
}
|
|
1704
|
+
if (value === "") throw new ArcaError("arca: empty value. Nothing was changed. (To remove a value, use `npx arca unset NAME`.)");
|
|
1705
|
+
const vault = await s.readVault();
|
|
1706
|
+
const existed = name in vault.vars;
|
|
1707
|
+
storeValue(vault, name, value, opts.generate !== void 0 ? "generate" : fromStdin ? "stdin" : "set");
|
|
1708
|
+
await s.writeVault(vault);
|
|
1709
|
+
if (!(name in declared)) {
|
|
1710
|
+
let declare = false;
|
|
1711
|
+
if (s.io.stdinIsTTY) declare = await s.io.prompts.confirm(`Declare ${name} in arca.json, so teammates know it's needed?`, true) === true;
|
|
1712
|
+
if (declare) declareVars(project.file, { [name]: { required: true } });
|
|
1713
|
+
else s.note(s.err("dim", `arca: ${name} isn't declared in arca.json. It's still injected; declare it so \`npx arca check\` and teammates know about it.`));
|
|
1714
|
+
}
|
|
1715
|
+
if (isSensitive(decl) && value.length < 6) s.note(s.err("yellow", `⚠ ${name} is shorter than 6 characters, so it can't be masked in output.`));
|
|
1716
|
+
if (decl && !isSensitive(decl)) s.note(s.err("dim", `arca: ${name} is declared as config (sensitive: false). It's stored in arca anyway and arca's value wins over .env.`));
|
|
1717
|
+
const what = opts.generate !== void 0 ? "Generated" : existed ? "Updated" : "Set";
|
|
1718
|
+
s.note(`${s.err("green", "✔")} ${what} ${name} for ${project.config.name}${existed ? " (previous value kept, `npx arca restore " + name + "` undoes this)" : ""}`);
|
|
1719
|
+
return 0;
|
|
1720
|
+
}
|
|
1721
|
+
async function unsetCommand(s, rawName) {
|
|
1722
|
+
const project = s.requireProject();
|
|
1723
|
+
const name = checkName(rawName, "npx arca unset NAME");
|
|
1724
|
+
await humanGate(s.io, s.ctx, {
|
|
1725
|
+
kind: "write",
|
|
1726
|
+
command: `npx arca unset ${name}`
|
|
1727
|
+
});
|
|
1728
|
+
const vault = await s.readVault();
|
|
1729
|
+
const v = vault.vars[name];
|
|
1730
|
+
if (!v) throw new ArcaError(`arca: ${name} is not stored in arca for ${project.config.name}.`);
|
|
1731
|
+
vault.removed[name] = {
|
|
1732
|
+
value: v.value,
|
|
1733
|
+
updatedAt: v.updatedAt
|
|
1734
|
+
};
|
|
1735
|
+
delete vault.vars[name];
|
|
1736
|
+
await s.writeVault(vault);
|
|
1737
|
+
s.note(`${s.err("green", "✔")} Removed ${name}. \`npx arca restore ${name}\` brings it back.`);
|
|
1738
|
+
return 0;
|
|
1739
|
+
}
|
|
1740
|
+
async function restoreCommand(s, rawName) {
|
|
1741
|
+
s.requireProject();
|
|
1742
|
+
const name = checkName(rawName, "npx arca restore NAME");
|
|
1743
|
+
await humanGate(s.io, s.ctx, {
|
|
1744
|
+
kind: "write",
|
|
1745
|
+
command: `npx arca restore ${name}`
|
|
1746
|
+
});
|
|
1747
|
+
const vault = await s.readVault();
|
|
1748
|
+
const current = vault.vars[name];
|
|
1749
|
+
if (current?.previous) vault.vars[name] = {
|
|
1750
|
+
value: current.previous.value,
|
|
1751
|
+
updatedAt: now(),
|
|
1752
|
+
source: "restore",
|
|
1753
|
+
previous: {
|
|
1754
|
+
value: current.value,
|
|
1755
|
+
updatedAt: current.updatedAt
|
|
1756
|
+
}
|
|
1757
|
+
};
|
|
1758
|
+
else if (!current && vault.removed[name]) storeValue(vault, name, vault.removed[name].value, "restore");
|
|
1759
|
+
else throw new ArcaError(`arca: there's no previous value of ${name} to restore.`);
|
|
1760
|
+
await s.writeVault(vault);
|
|
1761
|
+
s.note(`${s.err("green", "✔")} Restored the previous value of ${name}. Run it again to undo.`);
|
|
1762
|
+
return 0;
|
|
1763
|
+
}
|
|
1764
|
+
async function revealCommand(s, rawName) {
|
|
1765
|
+
const project = s.requireProject();
|
|
1766
|
+
const name = checkName(rawName, "npx arca reveal NAME");
|
|
1767
|
+
await humanGate(s.io, s.ctx, {
|
|
1768
|
+
kind: "reveal",
|
|
1769
|
+
command: `npx arca reveal ${name}`
|
|
1770
|
+
});
|
|
1771
|
+
const vault = await s.readVault();
|
|
1772
|
+
const v = vault.vars[name];
|
|
1773
|
+
if (!v) {
|
|
1774
|
+
const guess = suggest(name, Object.keys(vault.vars));
|
|
1775
|
+
throw new ArcaError(`arca: ${name} is not stored in arca for ${project.config.name}.${guess ? ` Did you mean ${guess}?` : ""}`);
|
|
1776
|
+
}
|
|
1777
|
+
s.print(v.value);
|
|
1778
|
+
return 0;
|
|
1779
|
+
}
|
|
1780
|
+
async function exportCommand(s, opts) {
|
|
1781
|
+
const project = s.requireProject();
|
|
1782
|
+
const format = opts.format ?? "dotenv";
|
|
1783
|
+
if (![
|
|
1784
|
+
"dotenv",
|
|
1785
|
+
"json",
|
|
1786
|
+
"shell"
|
|
1787
|
+
].includes(format)) throw new ArcaError("arca: --format must be dotenv, json or shell.", 2);
|
|
1788
|
+
await humanGate(s.io, s.ctx, {
|
|
1789
|
+
kind: "reveal",
|
|
1790
|
+
command: "npx arca export"
|
|
1791
|
+
});
|
|
1792
|
+
const vault = await s.readVault();
|
|
1793
|
+
const entries = Object.entries(vault.vars).map(([k, v]) => [k, v.value]);
|
|
1794
|
+
let text;
|
|
1795
|
+
if (format === "json") text = JSON.stringify(Object.fromEntries(entries), null, 2);
|
|
1796
|
+
else if (format === "shell") text = entries.map(([k, v]) => `export ${k}='${v.replace(/'/g, `'\\''`)}'`).join("\n");
|
|
1797
|
+
else text = entries.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join("\n");
|
|
1798
|
+
if (opts.output) {
|
|
1799
|
+
writeFileSync(resolve(s.io.cwd, opts.output), text + "\n", { mode: 384 });
|
|
1800
|
+
s.note(`${s.err("green", "✔")} Wrote ${plural(entries.length, "secret")} to ${opts.output}`);
|
|
1801
|
+
s.note(s.err("yellow", `⚠ That file holds plaintext secrets. Delete it when you're done. The leak guard will flag it while it sits in ${project.config.name}.`));
|
|
1802
|
+
} else s.print(text);
|
|
1803
|
+
return 0;
|
|
1804
|
+
}
|
|
1805
|
+
async function setupCommand(s, opts) {
|
|
1806
|
+
const project = s.requireProject();
|
|
1807
|
+
await humanGate(s.io, s.ctx, {
|
|
1808
|
+
kind: "write",
|
|
1809
|
+
command: "npx arca setup"
|
|
1810
|
+
});
|
|
1811
|
+
const vault = await s.readVault();
|
|
1812
|
+
const todo = Object.entries(project.config.vars).filter(([name, d]) => isSensitive(d) && !(name in vault.vars) && (opts.all || d.required));
|
|
1813
|
+
if (todo.length === 0) {
|
|
1814
|
+
s.note(`${s.err("green", "✔")} All ${opts.all ? "declared" : "required"} secrets are set for ${project.config.name}.`);
|
|
1815
|
+
return 0;
|
|
1816
|
+
}
|
|
1817
|
+
s.note(`arca: ${plural(todo.length, "variable")} to set for ${project.config.name}. Leave a prompt empty to skip it.\n`);
|
|
1818
|
+
let set = 0;
|
|
1819
|
+
for (const [name, d] of todo) {
|
|
1820
|
+
s.note(`${s.err("cyan", "◆")} ${s.err("bold", name)}${d.description ? `: ${d.description}` : ""}`);
|
|
1821
|
+
if (d.docs) s.note(` get it at ${d.docs}`);
|
|
1822
|
+
if (d.example) s.note(s.err("dim", ` example: ${d.example}`));
|
|
1823
|
+
let value = null;
|
|
1824
|
+
if (d.generate) {
|
|
1825
|
+
const gen = await s.io.prompts.confirm(`Generate a random value for ${name}?`, true);
|
|
1826
|
+
if (gen === null) break;
|
|
1827
|
+
if (gen) value = generateSecret(d.generate.bytes ?? 32, d.generate.encoding ?? "base64url");
|
|
1828
|
+
}
|
|
1829
|
+
if (value === null) {
|
|
1830
|
+
const entered = await s.io.prompts.password(name);
|
|
1831
|
+
if (entered === null) break;
|
|
1832
|
+
value = entered === "" ? null : entered;
|
|
1833
|
+
}
|
|
1834
|
+
if (value === null) {
|
|
1835
|
+
s.note(s.err("dim", ` skipped ${name}`));
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1838
|
+
storeValue(vault, name, value, "setup");
|
|
1839
|
+
await s.writeVault(vault);
|
|
1840
|
+
set++;
|
|
1841
|
+
}
|
|
1842
|
+
const left = todo.length - set;
|
|
1843
|
+
if (left === 0) s.note(`\n${s.err("green", "✔")} All ${opts.all ? "declared" : "required"} variables set. Run \`npm run dev\`.`);
|
|
1844
|
+
else s.note(`\narca: set ${set}, ${left} still missing. Run \`npx arca setup\` again later.`);
|
|
1845
|
+
return left === 0 ? 0 : 1;
|
|
1846
|
+
}
|
|
1847
|
+
/** Decides which entries of which files are secrets. Shared with `arca init`. */
|
|
1848
|
+
function planImport(s, files, opts = {}) {
|
|
1849
|
+
const project = s.requireProject();
|
|
1850
|
+
const sources = [];
|
|
1851
|
+
for (const f of files) {
|
|
1852
|
+
const file = readEnvFile(resolve(s.io.cwd, f), f) ?? readEnvFile(join(project.root, f), f);
|
|
1853
|
+
if (file) sources.push(file);
|
|
1854
|
+
}
|
|
1855
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1856
|
+
for (const file of sources) for (const e of file.entries) merged.set(e.key, {
|
|
1857
|
+
value: e.value,
|
|
1858
|
+
file: file.name
|
|
1859
|
+
});
|
|
1860
|
+
const only = opts.only ? new Set(opts.only) : null;
|
|
1861
|
+
const secrets = /* @__PURE__ */ new Map();
|
|
1862
|
+
const skipped = [];
|
|
1863
|
+
for (const [key, v] of merged) {
|
|
1864
|
+
if (only && !only.has(key)) continue;
|
|
1865
|
+
if (v.value === "") {
|
|
1866
|
+
skipped.push({
|
|
1867
|
+
key,
|
|
1868
|
+
why: "empty"
|
|
1869
|
+
});
|
|
1870
|
+
continue;
|
|
1871
|
+
}
|
|
1872
|
+
const decl = project.config.vars[key];
|
|
1873
|
+
if (decl && !isSensitive(decl)) {
|
|
1874
|
+
skipped.push({
|
|
1875
|
+
key,
|
|
1876
|
+
why: "declared as config"
|
|
1877
|
+
});
|
|
1878
|
+
continue;
|
|
1879
|
+
}
|
|
1880
|
+
if (decl || only || opts.includePublic || secretReason(key, v.value)) secrets.set(key, v);
|
|
1881
|
+
else skipped.push({
|
|
1882
|
+
key,
|
|
1883
|
+
why: "doesn't look secret"
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
return {
|
|
1887
|
+
sources,
|
|
1888
|
+
secrets,
|
|
1889
|
+
skipped
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
function defaultImportFiles(s) {
|
|
1893
|
+
const root = s.requireProject().root;
|
|
1894
|
+
return DEV_FILES.filter((f) => existsSync(join(root, f)));
|
|
1895
|
+
}
|
|
1896
|
+
async function importCommand(s, opts) {
|
|
1897
|
+
const project = s.requireProject();
|
|
1898
|
+
await humanGate(s.io, s.ctx, {
|
|
1899
|
+
kind: "write",
|
|
1900
|
+
command: "npx arca import" + (opts.delete ? " --delete" : "")
|
|
1901
|
+
});
|
|
1902
|
+
const files = opts.files.length ? opts.files : defaultImportFiles(s);
|
|
1903
|
+
if (files.length === 0) throw new ArcaError(`arca: no .env files found in ${project.root}. Name the file: npx arca import path/to/.env`);
|
|
1904
|
+
const plan = planImport(s, files, opts);
|
|
1905
|
+
if (plan.sources.length === 0) throw new ArcaError(`arca: none of these files exist: ${files.join(", ")}`);
|
|
1906
|
+
const vault = await s.readVault();
|
|
1907
|
+
const conflicts = [...plan.secrets].filter(([k, v]) => vault.vars[k] && vault.vars[k].value !== v.value).map(([k]) => k);
|
|
1908
|
+
let overwrite = Boolean(opts.yes);
|
|
1909
|
+
if (conflicts.length && !opts.yes) {
|
|
1910
|
+
const answer = await s.io.prompts.confirm(`${conflicts.join(", ")} already in arca with a different value. Overwrite?`, false);
|
|
1911
|
+
if (answer === null) throw new ArcaError("arca: cancelled. Nothing was changed.");
|
|
1912
|
+
overwrite = answer;
|
|
1913
|
+
}
|
|
1914
|
+
const imported = [];
|
|
1915
|
+
for (const [key, v] of plan.secrets) {
|
|
1916
|
+
if (vault.vars[key]?.value === v.value) {
|
|
1917
|
+
imported.push(key);
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
if (vault.vars[key] && !overwrite) continue;
|
|
1921
|
+
storeValue(vault, key, v.value, `import:${v.file}`);
|
|
1922
|
+
imported.push(key);
|
|
1923
|
+
}
|
|
1924
|
+
if (imported.length) await s.writeVault(vault);
|
|
1925
|
+
const undeclared = imported.filter((k) => !(k in project.config.vars));
|
|
1926
|
+
if (undeclared.length) declareVars(project.file, Object.fromEntries(undeclared.map((k) => [k, {}])));
|
|
1927
|
+
s.note(`${s.err("green", "✔")} Imported ${plural(imported.length, "secret")} into arca${imported.length ? `: ${imported.join(", ")}` : ""}`);
|
|
1928
|
+
if (undeclared.length) s.note(`${s.err("green", "✔")} Declared ${undeclared.join(", ")} in arca.json`);
|
|
1929
|
+
const config = plan.skipped.filter((x) => x.why !== "empty");
|
|
1930
|
+
if (config.length) s.note(s.err("dim", ` stays in .env: ${config.map((x) => x.key).join(", ")}${opts.includePublic ? "" : " (use --include-public or --only to import these too)"}`));
|
|
1931
|
+
if (opts.delete && imported.length) {
|
|
1932
|
+
const keys = new Set(imported);
|
|
1933
|
+
for (const src of plan.sources) {
|
|
1934
|
+
const result = removeEntries(src.path, keys);
|
|
1935
|
+
const name = relative(project.root, src.path) || src.name;
|
|
1936
|
+
if (result === "edited") s.note(`${s.err("green", "✔")} Removed the imported entries from ${name} (other lines kept)`);
|
|
1937
|
+
if (result === "deleted") s.note(`${s.err("green", "✔")} Deleted ${name} (nothing else was in it)`);
|
|
1938
|
+
}
|
|
1939
|
+
} else if (imported.length) {
|
|
1940
|
+
s.note(s.err("yellow", `⚠ Your .env files were not changed, so these secrets still have plaintext copies.`));
|
|
1941
|
+
s.note(` Remove just those entries (config lines stay): npx arca import --delete`);
|
|
1942
|
+
}
|
|
1943
|
+
return 0;
|
|
1944
|
+
}
|
|
1945
|
+
//#endregion
|
|
1946
|
+
//#region src/commands/init.ts
|
|
1947
|
+
const KNOWN_TOOLS = /* @__PURE__ */ new Set([
|
|
1948
|
+
"next",
|
|
1949
|
+
"vite",
|
|
1950
|
+
"astro",
|
|
1951
|
+
"nuxt",
|
|
1952
|
+
"nuxi",
|
|
1953
|
+
"react-router",
|
|
1954
|
+
"remix",
|
|
1955
|
+
"svelte-kit",
|
|
1956
|
+
"vinxi",
|
|
1957
|
+
"tsx",
|
|
1958
|
+
"node",
|
|
1959
|
+
"vitest",
|
|
1960
|
+
"jest",
|
|
1961
|
+
"playwright",
|
|
1962
|
+
"prisma",
|
|
1963
|
+
"drizzle-kit",
|
|
1964
|
+
"wrangler",
|
|
1965
|
+
"turbo",
|
|
1966
|
+
"nodemon",
|
|
1967
|
+
"ts-node",
|
|
1968
|
+
"tsup",
|
|
1969
|
+
"tanstack",
|
|
1970
|
+
"storybook",
|
|
1971
|
+
"start-storybook",
|
|
1972
|
+
"netlify",
|
|
1973
|
+
"vercel",
|
|
1974
|
+
"nest",
|
|
1975
|
+
"expo",
|
|
1976
|
+
"react-scripts",
|
|
1977
|
+
"webpack"
|
|
1978
|
+
]);
|
|
1979
|
+
/** The tool a script runs first (`next dev` → next, `npx prisma migrate` → prisma). Null for compound scripts. */
|
|
1980
|
+
function firstCommand(script) {
|
|
1981
|
+
if (/&&|\|\||[;|<>]/.test(script)) return null;
|
|
1982
|
+
const tokens = tokenize(script);
|
|
1983
|
+
let i = 0;
|
|
1984
|
+
if (tokens[i] === "npx" || tokens[i] === "bunx" || tokens[i] === "dotenv") i++;
|
|
1985
|
+
if ((tokens[i] === "pnpm" || tokens[i] === "yarn") && tokens[i + 1] === "exec") i += 2;
|
|
1986
|
+
const t = tokens[i];
|
|
1987
|
+
if (!t || /^[A-Za-z_][A-Za-z0-9_]*=/.test(t) || t === "cross-env") return null;
|
|
1988
|
+
return basename(t);
|
|
1989
|
+
}
|
|
1990
|
+
const FRAMEWORKS = [
|
|
1991
|
+
["next", "Next.js"],
|
|
1992
|
+
["@tanstack/react-start", "TanStack Start"],
|
|
1993
|
+
["@tanstack/solid-start", "TanStack Start"],
|
|
1994
|
+
["astro", "Astro"],
|
|
1995
|
+
["nuxt", "Nuxt"],
|
|
1996
|
+
["@sveltejs/kit", "SvelteKit"],
|
|
1997
|
+
["@remix-run/dev", "Remix"],
|
|
1998
|
+
["@react-router/dev", "React Router"],
|
|
1999
|
+
["vite", "Vite"]
|
|
2000
|
+
];
|
|
2001
|
+
function detectFramework(pkg) {
|
|
2002
|
+
const deps = {
|
|
2003
|
+
...pkg.dependencies,
|
|
2004
|
+
...pkg.devDependencies
|
|
2005
|
+
};
|
|
2006
|
+
for (const [dep, label] of FRAMEWORKS) if (deps[dep]) return `${label} ${deps[dep].replace(/^[\^~]/, "")}`;
|
|
2007
|
+
return null;
|
|
2008
|
+
}
|
|
2009
|
+
async function initCommand(s, opts) {
|
|
2010
|
+
const { io } = s;
|
|
2011
|
+
const root = io.cwd;
|
|
2012
|
+
const configFile = join(root, CONFIG_FILE);
|
|
2013
|
+
const pkgFile = join(root, "package.json");
|
|
2014
|
+
const pkg = existsSync(pkgFile) ? JSON.parse(readFileSync(pkgFile, "utf8")) : null;
|
|
2015
|
+
const interactive = s.ctx.interactiveOutput && s.ctx.stdinIsTTY && !opts.yes;
|
|
2016
|
+
const ok = (t) => s.note(`${s.err("green", "✔")} ${t}`);
|
|
2017
|
+
const step = (t) => s.note(`${s.err("cyan", "◇")} ${t}`);
|
|
2018
|
+
const ask = async (q, initial = true) => {
|
|
2019
|
+
if (!interactive) return initial;
|
|
2020
|
+
const r = await io.prompts.confirm(q, initial);
|
|
2021
|
+
if (r === null) throw new ArcaError("arca: cancelled.");
|
|
2022
|
+
return r;
|
|
2023
|
+
};
|
|
2024
|
+
let created = false;
|
|
2025
|
+
if (existsSync(configFile)) {
|
|
2026
|
+
if (opts.newId) {
|
|
2027
|
+
const id = newProjectId();
|
|
2028
|
+
updateConfigFile(configFile, (raw) => {
|
|
2029
|
+
raw.id = id;
|
|
2030
|
+
});
|
|
2031
|
+
ok(`New project ID ${id}. This project no longer shares secrets with the old ID.`);
|
|
2032
|
+
return 0;
|
|
2033
|
+
}
|
|
2034
|
+
const existing = parseProject(readFileSync(configFile, "utf8"), configFile);
|
|
2035
|
+
step(`arca.json already exists (${existing.config.name}, ${existing.config.id}). Checking the rest of the setup.`);
|
|
2036
|
+
} else {
|
|
2037
|
+
let name = pkg?.name?.replace(/^@[^/]+\//, "") ?? basename(root);
|
|
2038
|
+
if (interactive) {
|
|
2039
|
+
const answer = await io.prompts.text("Project name", { initial: name });
|
|
2040
|
+
if (answer === null) throw new ArcaError("arca: cancelled.");
|
|
2041
|
+
name = answer.trim() || name;
|
|
2042
|
+
}
|
|
2043
|
+
writeFileSync(configFile, JSON.stringify(newConfig(name), null, 2) + "\n");
|
|
2044
|
+
created = true;
|
|
2045
|
+
}
|
|
2046
|
+
const framework = pkg ? detectFramework(pkg) : null;
|
|
2047
|
+
if (framework) step(`Detected ${framework}`);
|
|
2048
|
+
const devFiles = DEV_FILES.map((f) => readEnvFile(join(root, f), f)).filter((f) => f !== null);
|
|
2049
|
+
const otherFiles = safeReaddir(root).filter((n) => /^\.env\./.test(n) && !DEV_FILES.includes(n) && !isTemplateFile(n));
|
|
2050
|
+
const merged = /* @__PURE__ */ new Map();
|
|
2051
|
+
for (const f of devFiles) for (const e of f.entries) merged.set(e.key, e.value);
|
|
2052
|
+
let secretKeys = [];
|
|
2053
|
+
const config = parseProject(readFileSync(configFile, "utf8"), configFile).config;
|
|
2054
|
+
if (merged.size === 0) step("No .env files with values found. Secrets go in arca (`npx arca set NAME`), non-sensitive config can stay in .env.");
|
|
2055
|
+
else {
|
|
2056
|
+
step(`Found ${devFiles.map((f) => `${f.name} (${plural(f.entries.length, "var")})`).join(", ")}`);
|
|
2057
|
+
const keys = [...merged.keys()];
|
|
2058
|
+
const suggested = keys.filter((k) => {
|
|
2059
|
+
const d = config.vars[k];
|
|
2060
|
+
if (d) return d.sensitive !== false;
|
|
2061
|
+
return Boolean(secretReason(k, merged.get(k)));
|
|
2062
|
+
});
|
|
2063
|
+
if (interactive) {
|
|
2064
|
+
const picked = await io.prompts.multiselect("Which of these are secrets? Secrets move to arca, the rest stays in .env as config.", keys.map((k) => ({
|
|
2065
|
+
value: k,
|
|
2066
|
+
label: k,
|
|
2067
|
+
hint: isPublicName(k) ? "public" : secretReason(k, merged.get(k)) ?? void 0
|
|
2068
|
+
})), suggested);
|
|
2069
|
+
if (picked === null) throw new ArcaError("arca: cancelled.");
|
|
2070
|
+
secretKeys = picked;
|
|
2071
|
+
} else secretKeys = suggested;
|
|
2072
|
+
const stays = keys.filter((k) => !secretKeys.includes(k));
|
|
2073
|
+
s.note(` → arca ${secretKeys.join(", ") || "(none)"}`);
|
|
2074
|
+
s.note(` stays ${stays.join(", ") || "(none)"}`);
|
|
2075
|
+
const decls = {};
|
|
2076
|
+
for (const k of secretKeys) decls[k] = { required: true };
|
|
2077
|
+
for (const k of stays) decls[k] = { sensitive: false };
|
|
2078
|
+
declareVars(configFile, decls);
|
|
2079
|
+
}
|
|
2080
|
+
if (otherFiles.length) step(`Left alone: ${otherFiles.join(", ")} (separate sets of values arrive with profiles in a later version)`);
|
|
2081
|
+
ok(`${created ? "Created" : "Updated"} arca.json${created ? ` (id ${parseProject(readFileSync(configFile, "utf8"), configFile).config.id})` : ""}`);
|
|
2082
|
+
const session = new Session(io);
|
|
2083
|
+
let imported = 0;
|
|
2084
|
+
if (secretKeys.length && opts.import !== false) try {
|
|
2085
|
+
await humanGate(io, session.ctx, {
|
|
2086
|
+
kind: "write",
|
|
2087
|
+
command: "npx arca import"
|
|
2088
|
+
});
|
|
2089
|
+
const hadKey = session.store.readGlobalConfig().keychain !== void 0;
|
|
2090
|
+
const vault = await session.readVault();
|
|
2091
|
+
for (const k of secretKeys) {
|
|
2092
|
+
const v = merged.get(k);
|
|
2093
|
+
if (v === "") continue;
|
|
2094
|
+
storeValue(vault, k, v, `import:${[...devFiles].reverse().find((f) => f.entries.some((e) => e.key === k))?.name ?? ".env"}`);
|
|
2095
|
+
imported++;
|
|
2096
|
+
}
|
|
2097
|
+
await session.writeVault(vault);
|
|
2098
|
+
if (!hadKey) ok(`Master key created in ${chooseBackend(session.store, io) === "os" ? keychainLabel(io.platform) : "a passphrase-protected file"}`);
|
|
2099
|
+
ok(`Imported ${plural(imported, "secret")} into arca`);
|
|
2100
|
+
ok("Your .env files were not changed");
|
|
2101
|
+
} catch (e) {
|
|
2102
|
+
if (!(e instanceof ArcaError)) throw e;
|
|
2103
|
+
s.note(`${s.err("yellow", "✋")} Moving secret values into arca needs a human. Ask the user to run: npx arca import`);
|
|
2104
|
+
s.note(" (the .env files stay untouched)");
|
|
2105
|
+
}
|
|
2106
|
+
if (pkg?.scripts) {
|
|
2107
|
+
const candidates = Object.entries(pkg.scripts).filter(([k, v]) => k !== "start" && !/\barca\s+run\b/.test(v) && KNOWN_TOOLS.has(firstCommand(v) ?? ""));
|
|
2108
|
+
let toWrap = candidates.map(([k]) => k);
|
|
2109
|
+
if (candidates.length && interactive) {
|
|
2110
|
+
const picked = await io.prompts.multiselect("Wrap these package.json scripts with `arca run`, so they get secrets?", candidates.map(([k, v]) => ({
|
|
2111
|
+
value: k,
|
|
2112
|
+
label: k,
|
|
2113
|
+
hint: v
|
|
2114
|
+
})), toWrap);
|
|
2115
|
+
if (picked === null) throw new ArcaError("arca: cancelled.");
|
|
2116
|
+
toWrap = picked;
|
|
2117
|
+
}
|
|
2118
|
+
if (toWrap.length) {
|
|
2119
|
+
wrapScripts(pkgFile, toWrap);
|
|
2120
|
+
ok(`Wrapped scripts: ${toWrap.join(", ")}`);
|
|
2121
|
+
for (const k of toWrap) s.note(s.err("dim", ` ${k}: "${pkg.scripts[k]}" → "arca run -- ${pkg.scripts[k]}"`));
|
|
2122
|
+
}
|
|
2123
|
+
const wrappedAlready = Object.keys(pkg.scripts).filter((k) => /\barca\s+run\b/.test(pkg.scripts[k]));
|
|
2124
|
+
if (wrappedAlready.length && !toWrap.length) step(`Already wrapped: ${wrappedAlready.join(", ")}`);
|
|
2125
|
+
const wrappedNow = /* @__PURE__ */ new Set([...wrappedAlready, ...toWrap]);
|
|
2126
|
+
const skipped = Object.entries(pkg.scripts).filter(([k]) => !wrappedNow.has(k));
|
|
2127
|
+
if (skipped.length) {
|
|
2128
|
+
step("Not wrapped:");
|
|
2129
|
+
for (const [k, v] of skipped) s.note(s.err("dim", ` ${k}: ${skipReason(k, v, pkg.scripts, wrappedNow)}`));
|
|
2130
|
+
}
|
|
2131
|
+
const start = pkg.scripts.start;
|
|
2132
|
+
if (start && !/\barca\s+run\b/.test(start) && !pkg.scripts["start:local"] && KNOWN_TOOLS.has(firstCommand(start) ?? "")) {
|
|
2133
|
+
if (await ask("Add \"start:local\" to run the production build locally with your secrets?", true)) {
|
|
2134
|
+
addScript(pkgFile, "start:local", `arca run -- ${start}`);
|
|
2135
|
+
ok(`Added "start:local": "arca run -- ${start}"`);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
} else if (!pkg) step("No package.json here. Wrap your commands yourself: arca run -- <command>");
|
|
2139
|
+
const docs = ["AGENTS.md", "CLAUDE.md"].filter((f) => existsSync(join(root, f)));
|
|
2140
|
+
const marker = "## Secrets (arca)";
|
|
2141
|
+
const missingSnippet = docs.filter((f) => !readFileSync(join(root, f), "utf8").includes(marker));
|
|
2142
|
+
if (docs.length === 0) {
|
|
2143
|
+
if (await ask("Create AGENTS.md with a short \"Secrets (arca)\" section for AI agents?", true)) {
|
|
2144
|
+
writeFileSync(join(root, "AGENTS.md"), `# Agent instructions\n\n${agentsSnippet()}`);
|
|
2145
|
+
ok("Created AGENTS.md with the \"Secrets (arca)\" section");
|
|
2146
|
+
}
|
|
2147
|
+
} else if (missingSnippet.length) {
|
|
2148
|
+
if (await ask(`Add a "Secrets (arca)" section to ${missingSnippet.join(" and ")}?`, true)) {
|
|
2149
|
+
for (const f of missingSnippet) {
|
|
2150
|
+
const text = readFileSync(join(root, f), "utf8");
|
|
2151
|
+
appendFileSync(join(root, f), `${text.endsWith("\n") ? "" : "\n"}\n${agentsSnippet()}`);
|
|
2152
|
+
}
|
|
2153
|
+
ok(`Added the "Secrets (arca)" section to ${missingSnippet.join(" and ")}`);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
const outdated = docs.filter((f) => {
|
|
2157
|
+
const section = findSection(readFileSync(join(root, f), "utf8"), marker);
|
|
2158
|
+
return section !== null && section.text.trim() !== agentsSnippet().trim();
|
|
2159
|
+
});
|
|
2160
|
+
if (outdated.length && await ask(`Update the "Secrets (arca)" section in ${outdated.join(" and ")} to the current text?`, true)) {
|
|
2161
|
+
for (const f of outdated) {
|
|
2162
|
+
const text = readFileSync(join(root, f), "utf8");
|
|
2163
|
+
const section = findSection(text, marker);
|
|
2164
|
+
const trailing = /\s*$/.exec(section.text)?.[0] ?? "\n";
|
|
2165
|
+
writeFileSync(join(root, f), text.slice(0, section.start) + agentsSnippet().trimEnd() + trailing + text.slice(section.end));
|
|
2166
|
+
}
|
|
2167
|
+
ok(`Updated the "Secrets (arca)" section in ${outdated.join(" and ")}`);
|
|
2168
|
+
}
|
|
2169
|
+
const deps = {
|
|
2170
|
+
...pkg?.dependencies,
|
|
2171
|
+
...pkg?.devDependencies
|
|
2172
|
+
};
|
|
2173
|
+
if (pkg && !deps["@bremmdev/arca-env"]) s.note(`\nInstall arca in this project: npm i -D @bremmdev/arca-env`);
|
|
2174
|
+
if (imported) {
|
|
2175
|
+
s.note("\n" + s.err("yellow", `⚠ The ${plural(imported, "secret")} still have plaintext copies in ${devFiles.map((f) => f.name).join(", ")}.`));
|
|
2176
|
+
s.note(" Remove just those entries (config lines stay): npx arca import --delete");
|
|
2177
|
+
} else if (!secretKeys.length) s.note("\nNext: npx arca set DATABASE_URL or npx arca setup");
|
|
2178
|
+
return 0;
|
|
2179
|
+
}
|
|
2180
|
+
/** The arca section of an AGENTS.md: from its heading to the next heading or HTML comment block. */
|
|
2181
|
+
function findSection(text, marker) {
|
|
2182
|
+
const start = text.indexOf(marker);
|
|
2183
|
+
if (start === -1) return null;
|
|
2184
|
+
const next = text.slice(start + marker.length).search(/\n(?=#{1,2} |<!--)/);
|
|
2185
|
+
const end = next === -1 ? text.length : start + marker.length + next + 1;
|
|
2186
|
+
return {
|
|
2187
|
+
start,
|
|
2188
|
+
end,
|
|
2189
|
+
text: text.slice(start, end)
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
/** Why `init` leaves a script unwrapped, in words a user can act on. */
|
|
2193
|
+
function skipReason(name, script, scripts, wrapped) {
|
|
2194
|
+
if (name === "start") return "production start, left alone on purpose (the host provides the env, and arca may not be installed there)";
|
|
2195
|
+
if (/&&|\|\||;/.test(script)) {
|
|
2196
|
+
if (script.split(/&&|\|\||;/).map((p) => p.trim()).filter(Boolean).every((part) => {
|
|
2197
|
+
const run = /^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:.-]+)/.exec(part)?.[1];
|
|
2198
|
+
if (run) return wrapped.has(run) || !(run in scripts) || !KNOWN_TOOLS.has(firstCommand(scripts[run]) ?? "");
|
|
2199
|
+
const tool = firstCommand(part);
|
|
2200
|
+
return tool !== null && !KNOWN_TOOLS.has(tool);
|
|
2201
|
+
})) return "chains other scripts and tools; the ones that need secrets are wrapped themselves";
|
|
2202
|
+
return "compound command, wrap the part that needs secrets by hand";
|
|
2203
|
+
}
|
|
2204
|
+
const tool = firstCommand(script);
|
|
2205
|
+
if (tool === null) return "sets env vars inline or uses cross-env, wrap by hand if it needs secrets";
|
|
2206
|
+
return `${tool} doesn't need secrets (wrap by hand if it does: arca run -- ${script})`;
|
|
2207
|
+
}
|
|
2208
|
+
function addScript(pkgFile, name, script) {
|
|
2209
|
+
const text = readFileSync(pkgFile, "utf8");
|
|
2210
|
+
const indent = /\n(\s+)"/.exec(text)?.[1] ?? " ";
|
|
2211
|
+
const pkg = JSON.parse(text);
|
|
2212
|
+
pkg.scripts = {
|
|
2213
|
+
...pkg.scripts,
|
|
2214
|
+
[name]: script
|
|
2215
|
+
};
|
|
2216
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
2217
|
+
writeFileSync(pkgFile, JSON.stringify(pkg, null, indent).replace(/\n/g, eol) + eol);
|
|
2218
|
+
}
|
|
2219
|
+
function wrapScripts(pkgFile, names) {
|
|
2220
|
+
const text = readFileSync(pkgFile, "utf8");
|
|
2221
|
+
const indent = /\n(\s+)"/.exec(text)?.[1] ?? " ";
|
|
2222
|
+
const pkg = JSON.parse(text);
|
|
2223
|
+
for (const n of names) {
|
|
2224
|
+
const v = pkg.scripts[n];
|
|
2225
|
+
if (!/\barca\s+run\b/.test(v)) pkg.scripts[n] = `arca run -- ${v}`;
|
|
2226
|
+
}
|
|
2227
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
2228
|
+
writeFileSync(pkgFile, JSON.stringify(pkg, null, indent).replace(/\n/g, eol) + eol);
|
|
2229
|
+
}
|
|
2230
|
+
function safeReaddir(dir) {
|
|
2231
|
+
try {
|
|
2232
|
+
return readdirSync(dir);
|
|
2233
|
+
} catch {
|
|
2234
|
+
return [];
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
function keychainLabel(platform) {
|
|
2238
|
+
return platform === "win32" ? "Windows Credential Manager" : platform === "darwin" ? "the macOS Keychain" : "the Secret Service keyring";
|
|
2239
|
+
}
|
|
2240
|
+
//#endregion
|
|
2241
|
+
//#region src/commands/info.ts
|
|
2242
|
+
function rows(s, vault) {
|
|
2243
|
+
const project = s.requireProject();
|
|
2244
|
+
const decls = project.config.vars;
|
|
2245
|
+
const files = s.envFilesForGuard().filter((f) => !isTemplateFile(f.name));
|
|
2246
|
+
const inFile = (name) => files.find((f) => f.entries.some((e) => e.key === name && e.value !== ""));
|
|
2247
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(decls), ...Object.keys(vault.vars)])].map((name) => {
|
|
2248
|
+
const d = decls[name];
|
|
2249
|
+
const stored = vault.vars[name];
|
|
2250
|
+
const sensitive = isSensitive(d);
|
|
2251
|
+
const file = inFile(name);
|
|
2252
|
+
const base = {
|
|
2253
|
+
name,
|
|
2254
|
+
sensitive,
|
|
2255
|
+
required: Boolean(d?.required),
|
|
2256
|
+
declared: Boolean(d),
|
|
2257
|
+
description: d?.description
|
|
2258
|
+
};
|
|
2259
|
+
if (stored) return {
|
|
2260
|
+
...base,
|
|
2261
|
+
status: "arca",
|
|
2262
|
+
updatedAt: stored.updatedAt
|
|
2263
|
+
};
|
|
2264
|
+
if (file) return {
|
|
2265
|
+
...base,
|
|
2266
|
+
status: sensitive ? "env-file-sensitive" : "env-file",
|
|
2267
|
+
source: relative(project.root, file.path) || file.name
|
|
2268
|
+
};
|
|
2269
|
+
return {
|
|
2270
|
+
...base,
|
|
2271
|
+
status: d?.required ? "missing" : "unset"
|
|
2272
|
+
};
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
function statusText(r, p) {
|
|
2276
|
+
switch (r.status) {
|
|
2277
|
+
case "arca": return p("green", "✔ arca");
|
|
2278
|
+
case "env-file": return p("cyan", `✔ ${r.source}`);
|
|
2279
|
+
case "env-file-sensitive": return p("yellow", `⚠ ${r.source} (should be in arca)`);
|
|
2280
|
+
case "missing": return p("red", "✖ missing (required)");
|
|
2281
|
+
case "unset": return p("dim", "– not set");
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
async function listCommand(s) {
|
|
2285
|
+
const project = s.requireProject();
|
|
2286
|
+
s.warnConfig();
|
|
2287
|
+
const list = rows(s, await s.readVault());
|
|
2288
|
+
if (s.json) {
|
|
2289
|
+
s.print(JSON.stringify(list.map(({ description, ...r }) => ({
|
|
2290
|
+
...r,
|
|
2291
|
+
description: description ?? null
|
|
2292
|
+
})), null, 2));
|
|
2293
|
+
return 0;
|
|
2294
|
+
}
|
|
2295
|
+
const p = s.out;
|
|
2296
|
+
if (list.length === 0) {
|
|
2297
|
+
s.print(`No variables declared or stored for ${project.config.name} yet. Declare them in arca.json, then run \`npx arca setup\`.`);
|
|
2298
|
+
return 0;
|
|
2299
|
+
}
|
|
2300
|
+
const w = Math.max(4, ...list.map((r) => r.name.length)) + 2;
|
|
2301
|
+
const sw = Math.max(...list.map((r) => statusText(r, (_, t) => t).length)) + 2;
|
|
2302
|
+
s.print(p("bold", `${pad("NAME", w)}${pad("STATUS", sw)}${pad("KIND", 9)}UPDATED`));
|
|
2303
|
+
for (const r of list) {
|
|
2304
|
+
const kind = r.sensitive ? "secret" : "config";
|
|
2305
|
+
const extra = r.declared ? "" : p("dim", " (undeclared)");
|
|
2306
|
+
s.print(`${pad(r.name, w)}${pad(statusText(r, p), sw)}${pad(kind, 9)}${shortDate(r.updatedAt)}${extra}`);
|
|
2307
|
+
}
|
|
2308
|
+
return 0;
|
|
2309
|
+
}
|
|
2310
|
+
async function checkCommand(s, opts) {
|
|
2311
|
+
const project = s.requireProject();
|
|
2312
|
+
s.warnConfig();
|
|
2313
|
+
const vault = await s.readVault();
|
|
2314
|
+
const files = s.envFilesForGuard().filter((f) => !isTemplateFile(f.name));
|
|
2315
|
+
const envFileValues = loadEnvFiles(project.root, project.config.envFiles);
|
|
2316
|
+
const present = (name) => name in vault.vars || Boolean(s.io.env[name]) || Boolean(envFileValues[name]) || files.some((f) => f.entries.some((e) => e.key === name && e.value !== ""));
|
|
2317
|
+
const required = Object.entries(project.config.vars).filter(([, d]) => d.required);
|
|
2318
|
+
const missing = required.filter(([n]) => !present(n)).map(([n]) => n);
|
|
2319
|
+
const misplaced = required.filter(([n, d]) => isSensitive(d) && !(n in vault.vars) && !s.io.env[n] && present(n)).map(([n]) => n);
|
|
2320
|
+
const findings = scanEnvFiles(project, s.envFilesForGuard(), s.secrets(vault));
|
|
2321
|
+
const undeclared = Object.keys(vault.vars).filter((n) => !(n in project.config.vars));
|
|
2322
|
+
if (s.json) s.print(JSON.stringify({
|
|
2323
|
+
ok: missing.length === 0 && (!opts.strict || findings.length === 0),
|
|
2324
|
+
missing,
|
|
2325
|
+
inEnvFiles: misplaced,
|
|
2326
|
+
undeclared,
|
|
2327
|
+
leakGuard: findings
|
|
2328
|
+
}, null, 2));
|
|
2329
|
+
else {
|
|
2330
|
+
const p = s.out;
|
|
2331
|
+
if (missing.length === 0) s.print(`${p("green", "✔")} All ${plural(required.length, "required variable")} set for ${project.config.name}`);
|
|
2332
|
+
else {
|
|
2333
|
+
s.print(`${p("red", "✖")} ${plural(missing.length, "required variable")} missing for ${project.config.name}: ${missing.join(", ")}`);
|
|
2334
|
+
s.print(` Run \`npx arca setup\` to set them. (AI agents: ask the user to run it.)`);
|
|
2335
|
+
}
|
|
2336
|
+
for (const n of misplaced) s.print(`${p("yellow", "⚠")} ${n} is a secret but comes from a .env file. Move it: npx arca import --only ${n} --delete`);
|
|
2337
|
+
if (undeclared.length) s.print(p("dim", ` stored but not declared in arca.json: ${undeclared.join(", ")}`));
|
|
2338
|
+
if (findings.length) s.print(`${p("yellow", "⚠")} Leak guard: ${plural(findings.length, "possible secret")} in .env files (run \`npx arca doctor\` for details)`);
|
|
2339
|
+
}
|
|
2340
|
+
if (missing.length) return 1;
|
|
2341
|
+
if (opts.strict && findings.length) return 1;
|
|
2342
|
+
return 0;
|
|
2343
|
+
}
|
|
2344
|
+
async function doctorCommand(s) {
|
|
2345
|
+
const p = s.out;
|
|
2346
|
+
const ok = (t) => s.print(` ${p("green", "✔")} ${t}`);
|
|
2347
|
+
const warn = (t) => s.print(` ${p("yellow", "⚠")} ${t}`);
|
|
2348
|
+
const bad = (t) => s.print(` ${p("red", "✖")} ${t}`);
|
|
2349
|
+
const info = (t) => s.print(` ${p("dim", "·")} ${t}`);
|
|
2350
|
+
let problems = 0;
|
|
2351
|
+
s.print(p("bold", "Context"));
|
|
2352
|
+
info(`Node ${process.version} on ${s.io.platform}`);
|
|
2353
|
+
const a = s.ctx.agent;
|
|
2354
|
+
if (a.detected) info(`You look like an AI agent: ${a.name} via ${a.via}. Values can't be shown or changed from here.`);
|
|
2355
|
+
else info("No AI agent detected");
|
|
2356
|
+
info(s.ctx.interactiveOutput && s.ctx.stdinIsTTY ? "Interactive terminal" : "Not an interactive terminal");
|
|
2357
|
+
if (s.ctx.ci) info("CI detected: wrapped scripts pass through without a store");
|
|
2358
|
+
s.print(p("bold", "\nStore"));
|
|
2359
|
+
info(`Location: ${s.store.root}`);
|
|
2360
|
+
info(`Key storage: ${chooseBackend(s.store, s.io) === "os" ? osKeychainName(s.io.platform) : "passphrase (master.key.enc)"}`);
|
|
2361
|
+
let master = null;
|
|
2362
|
+
try {
|
|
2363
|
+
master = await loadMasterKey(s.store, s.io, { create: false });
|
|
2364
|
+
if (master) ok("Master key found");
|
|
2365
|
+
else if (s.store.hasAnyVault()) {
|
|
2366
|
+
bad("Master key missing, but vaults exist. They cannot be decrypted.");
|
|
2367
|
+
problems++;
|
|
2368
|
+
} else info("No master key yet (created on the first `npx arca set`)");
|
|
2369
|
+
} catch (e) {
|
|
2370
|
+
bad(e.message.replace(/^arca: /, ""));
|
|
2371
|
+
problems++;
|
|
2372
|
+
}
|
|
2373
|
+
if (!s.project) {
|
|
2374
|
+
s.print(p("bold", "\nProject"));
|
|
2375
|
+
info("No arca.json here or in any parent folder. Run `npx arca init`.");
|
|
2376
|
+
return problems ? 1 : 0;
|
|
2377
|
+
}
|
|
2378
|
+
const project = s.project;
|
|
2379
|
+
s.print(p("bold", "\nProject"));
|
|
2380
|
+
info(`${project.config.name} (${project.config.id})`);
|
|
2381
|
+
info(`Config: ${project.file}`);
|
|
2382
|
+
for (const w of project.warnings) warn(w);
|
|
2383
|
+
const vault = master && s.hasVault() ? s.store.readVault(master, project.config.id, s.profile) : null;
|
|
2384
|
+
if (s.hasVault()) ok(`Vault: ${plural(Object.keys(vault?.vars ?? {}).length, "secret")} stored`);
|
|
2385
|
+
else info("No secrets stored for this project on this machine yet");
|
|
2386
|
+
const reg = s.store.readRegistry()[project.config.id];
|
|
2387
|
+
const remote = gitRemote(project.root);
|
|
2388
|
+
if (reg?.gitRemote && remote && reg.gitRemote !== remote) {
|
|
2389
|
+
warn(`The ID is also used by ${reg.roots[0]} (git remote ${reg.gitRemote}). If these are different projects: npx arca init --new-id`);
|
|
2390
|
+
problems++;
|
|
2391
|
+
}
|
|
2392
|
+
const decls = project.config.vars;
|
|
2393
|
+
const short = Object.entries(vault?.vars ?? {}).filter(([n, v]) => isSensitive(decls[n]) && v.value.length < 6);
|
|
2394
|
+
for (const [n] of short) warn(`${n} is shorter than 6 characters, so it isn't masked in output`);
|
|
2395
|
+
const missing = Object.entries(decls).filter(([n, d]) => d.required && isSensitive(d) && !vault?.vars[n]).map(([n]) => n);
|
|
2396
|
+
if (missing.length) warn(`Required secrets not stored: ${missing.join(", ")}. Run \`npx arca setup\`.`);
|
|
2397
|
+
const pkgFile = join(s.packageRoot() ?? project.root, "package.json");
|
|
2398
|
+
if (existsSync(pkgFile)) {
|
|
2399
|
+
s.print(p("bold", "\nScripts"));
|
|
2400
|
+
const scripts = JSON.parse(readFileSync(pkgFile, "utf8")).scripts ?? {};
|
|
2401
|
+
const wrapped = Object.keys(scripts).filter((k) => /\barca\s+run\b/.test(scripts[k]));
|
|
2402
|
+
const unwrapped = Object.entries(scripts).filter(([k, v]) => k !== "start" && !/\barca\s+run\b/.test(v) && KNOWN_TOOLS.has(firstCommand(v) ?? "")).map(([k]) => k);
|
|
2403
|
+
if (wrapped.length) ok(`Wrapped with arca run: ${wrapped.join(", ")}`);
|
|
2404
|
+
else warn("No scripts use `arca run` yet, so nothing gets secrets. Run `npx arca init` to wrap them.");
|
|
2405
|
+
if (unwrapped.length) info(`Not wrapped (no secrets): ${unwrapped.join(", ")}`);
|
|
2406
|
+
}
|
|
2407
|
+
const modeFiles = project.config.envFiles.filter((f) => /^\.env\.(development|production|test)(\.local)?$/.test(f));
|
|
2408
|
+
for (const f of modeFiles) warn(`envFiles lists ${f}. arca injects it as process env, which beats the framework's own mode files (e.g. in \`next build\`). Prefer a name frameworks don't load.`);
|
|
2409
|
+
s.print(p("bold", "\nLeak guard (.env files)"));
|
|
2410
|
+
const files = s.envFilesForGuard();
|
|
2411
|
+
const findings = scanEnvFiles(project, files, vault ? s.secrets(vault) : []);
|
|
2412
|
+
if (files.length === 0) info("No .env files");
|
|
2413
|
+
else if (findings.length === 0) ok(`${plural(files.length, "file")} checked, no secrets found: ${files.map((f) => f.name).join(", ")}`);
|
|
2414
|
+
for (const f of findings) {
|
|
2415
|
+
(f.tracked ? bad : warn)(`${f.detail}${f.tracked ? " (committed to git!)" : ""}`);
|
|
2416
|
+
s.print(` ${p("dim", fixFor(f))}`);
|
|
2417
|
+
problems++;
|
|
2418
|
+
}
|
|
2419
|
+
const secretPrefixes = new Set(Object.keys(vault?.vars ?? {}).map((n) => n.split("_")[0]));
|
|
2420
|
+
for (const file of files) for (const e of file.entries) {
|
|
2421
|
+
if (!/^https?:\/\//i.test(e.value) || decls[e.key]?.sensitive === true) continue;
|
|
2422
|
+
if (secretPrefixes.has(e.key.split("_")[0]) && !(e.key in (vault?.vars ?? {}))) info(`${e.key} in ${file.name} is a URL next to the secret ${e.key.split("_")[0]}_… If it receives that credential, keep it in arca so only you can change it.`);
|
|
2423
|
+
}
|
|
2424
|
+
if (master) {
|
|
2425
|
+
const policy = s.store.readPolicy(master, project.config.id);
|
|
2426
|
+
s.print(p("bold", "\nAgent policy"));
|
|
2427
|
+
info(policy.agentRun === "scripts" ? "Agents get secrets only through package.json scripts (default)" : "Agents never get secrets (deny)");
|
|
2428
|
+
}
|
|
2429
|
+
s.print("");
|
|
2430
|
+
s.print(problems ? `${p("yellow", `${plural(problems, "issue")} found.`)}` : p("green", "No problems found."));
|
|
2431
|
+
return problems ? 1 : 0;
|
|
2432
|
+
}
|
|
2433
|
+
function fixFor(f) {
|
|
2434
|
+
if (f.rule === "stored") return `fix: delete the line from ${f.file}, or run: npx arca import ${f.file} --only ${f.key} --delete`;
|
|
2435
|
+
if (f.rule === "declared") return `fix: npx arca import ${f.file} --only ${f.key} --delete (or declare it "sensitive": false if it's config)`;
|
|
2436
|
+
return `fix: npx arca import ${f.file} --only ${f.key} --delete (or, if it's not secret, declare it "sensitive": false or add it to leakGuard.ignore)`;
|
|
2437
|
+
}
|
|
2438
|
+
function osKeychainName(platform) {
|
|
2439
|
+
if (platform === "win32") return "Windows Credential Manager";
|
|
2440
|
+
if (platform === "darwin") return "macOS Keychain";
|
|
2441
|
+
return "Secret Service (GNOME Keyring / KWallet)";
|
|
2442
|
+
}
|
|
2443
|
+
async function statusCommand(s, version) {
|
|
2444
|
+
const p = s.out;
|
|
2445
|
+
s.print(`${p("bold", "arca")} ${version}: secrets out of your .env files, masked in all output`);
|
|
2446
|
+
if (!s.project) {
|
|
2447
|
+
s.print("\nNo arca.json in this folder or any parent. Get started: npx arca init");
|
|
2448
|
+
s.print("Commands: npx arca help");
|
|
2449
|
+
return 0;
|
|
2450
|
+
}
|
|
2451
|
+
s.warnConfig();
|
|
2452
|
+
const project = s.project;
|
|
2453
|
+
const vault = await s.readVault();
|
|
2454
|
+
const missing = rows(s, vault).filter((r) => r.status === "missing").map((r) => r.name);
|
|
2455
|
+
s.print(`\nProject ${project.config.name} (${project.config.id})`);
|
|
2456
|
+
s.print(`Secrets ${plural(Object.keys(vault.vars).length, "stored")}${missing.length ? p("red", `, missing: ${missing.join(", ")}`) : ""}`);
|
|
2457
|
+
const policy = await s.policy();
|
|
2458
|
+
s.print(`Agents ${policy.agentRun === "scripts" ? "package scripts only, output masked" : "denied"}`);
|
|
2459
|
+
s.print(`You ${s.ctx.agent.detected ? `AI agent (${s.ctx.agent.name} via ${s.ctx.agent.via})` : s.ctx.interactiveOutput ? "interactive terminal" : "non-interactive"}`);
|
|
2460
|
+
s.print(`Store ${s.store.root}`);
|
|
2461
|
+
s.print(`\n${missing.length ? "Next: npx arca setup" : "Run your app as usual, e.g. npm run dev"} · npx arca help`);
|
|
2462
|
+
return 0;
|
|
2463
|
+
}
|
|
2464
|
+
async function policyCommand(s, args) {
|
|
2465
|
+
const project = s.requireProject();
|
|
2466
|
+
const [action = "show", key, value] = args;
|
|
2467
|
+
if (action === "show") {
|
|
2468
|
+
const policy = await s.policy();
|
|
2469
|
+
s.print(`agent-run: ${policy.agentRun}`);
|
|
2470
|
+
s.print(s.out("dim", policy.agentRun === "scripts" ? " Agents get secrets only through verified package.json scripts (default)." : " Agents never get secrets."));
|
|
2471
|
+
return 0;
|
|
2472
|
+
}
|
|
2473
|
+
if (action !== "set" || key !== "agent-run" || value !== "scripts" && value !== "deny") throw new ArcaError("arca: usage: npx arca policy [show] | npx arca policy set agent-run <scripts|deny>", 2);
|
|
2474
|
+
await humanGate(s.io, s.ctx, {
|
|
2475
|
+
kind: "write",
|
|
2476
|
+
command: `npx arca policy set agent-run ${value}`
|
|
2477
|
+
});
|
|
2478
|
+
const master = await s.masterKey(true);
|
|
2479
|
+
s.store.writePolicy(master, project.config.id, { agentRun: value });
|
|
2480
|
+
s.note(`${s.err("green", "✔")} agent-run is now ${value} for ${project.config.name}`);
|
|
2481
|
+
return 0;
|
|
2482
|
+
}
|
|
2483
|
+
async function projectsCommand(s, args) {
|
|
2484
|
+
const [action, id] = args;
|
|
2485
|
+
const reg = s.store.readRegistry();
|
|
2486
|
+
if (!action || action === "list") {
|
|
2487
|
+
const ids = /* @__PURE__ */ new Set([...Object.keys(reg), ...s.store.projectIds()]);
|
|
2488
|
+
if (ids.size === 0) {
|
|
2489
|
+
s.print("No projects on this machine yet.");
|
|
2490
|
+
return 0;
|
|
2491
|
+
}
|
|
2492
|
+
for (const pid of ids) {
|
|
2493
|
+
const e = reg[pid];
|
|
2494
|
+
s.print(`${pid} ${e?.name ?? "(unknown)"} ${s.out("dim", e?.roots.join(", ") ?? "")}`);
|
|
2495
|
+
}
|
|
2496
|
+
return 0;
|
|
2497
|
+
}
|
|
2498
|
+
if (action !== "rm" || !id) throw new ArcaError("arca: usage: npx arca projects [list] | npx arca projects rm <id>", 2);
|
|
2499
|
+
await humanGate(s.io, s.ctx, {
|
|
2500
|
+
kind: "write",
|
|
2501
|
+
command: `npx arca projects rm ${id}`
|
|
2502
|
+
});
|
|
2503
|
+
if (!reg[id] && !s.store.projectIds().includes(id)) throw new ArcaError(`arca: no project ${id} on this machine.`);
|
|
2504
|
+
if (!await s.io.prompts.confirm(`Delete all secrets of ${reg[id]?.name ?? id} on this machine? This can't be undone.`, false)) throw new ArcaError("arca: cancelled.");
|
|
2505
|
+
s.store.removeProject(id);
|
|
2506
|
+
s.note(`${s.err("green", "✔")} Removed ${id}`);
|
|
2507
|
+
return 0;
|
|
2508
|
+
}
|
|
2509
|
+
//#endregion
|
|
2510
|
+
//#region src/main.ts
|
|
2511
|
+
const VERSION = "0.1.0";
|
|
2512
|
+
const HELP = `arca: keep secrets out of .env files and out of reach of AI agents
|
|
2513
|
+
|
|
2514
|
+
Usage: npx arca <command> [options] (inside package.json scripts, plain "arca" works)
|
|
2515
|
+
|
|
2516
|
+
Everyday
|
|
2517
|
+
run [opts] -- <cmd> Run a command with the project's secrets injected, output masked
|
|
2518
|
+
--only A,B inject only these
|
|
2519
|
+
--passthrough run without the store, checking required vars (like CI)
|
|
2520
|
+
--no-override let values inherited from the shell win over arca
|
|
2521
|
+
list, ls Variables and where each one comes from (names only, never values)
|
|
2522
|
+
check [--strict] Exit 1 if required variables are missing (--strict: also on leak-guard findings)
|
|
2523
|
+
doctor Diagnose keychain, project, scripts and .env files
|
|
2524
|
+
|
|
2525
|
+
Values (only a person at a terminal)
|
|
2526
|
+
set NAME [--generate] Store a value (hidden prompt, or piped: … | arca set NAME)
|
|
2527
|
+
setup [--all] Set every missing required variable, one prompt each
|
|
2528
|
+
unset NAME Remove a value restore NAME Undo the last change
|
|
2529
|
+
reveal NAME Print a value export Print all values (--format dotenv|json|shell, --output file)
|
|
2530
|
+
import [files] [--only A,B] [--include-public] [--delete]
|
|
2531
|
+
Move secrets from .env files into arca. Files are untouched unless --delete.
|
|
2532
|
+
|
|
2533
|
+
Project
|
|
2534
|
+
init [--yes] [--new-id] Set up arca: arca.json, import secrets, wrap scripts, AGENTS.md
|
|
2535
|
+
policy [set agent-run scripts|deny]
|
|
2536
|
+
projects [rm <id>] Projects with secrets on this machine
|
|
2537
|
+
|
|
2538
|
+
Global options: --project-dir <path>, --json, --quiet, --help, --version
|
|
2539
|
+
Docs: https://www.npmjs.com/package/@bremmdev/arca-env`;
|
|
2540
|
+
const GLOBAL_OPTIONS = {
|
|
2541
|
+
"project-dir": { type: "string" },
|
|
2542
|
+
json: { type: "boolean" },
|
|
2543
|
+
quiet: {
|
|
2544
|
+
type: "boolean",
|
|
2545
|
+
short: "q"
|
|
2546
|
+
},
|
|
2547
|
+
help: {
|
|
2548
|
+
type: "boolean",
|
|
2549
|
+
short: "h"
|
|
2550
|
+
}
|
|
2551
|
+
};
|
|
2552
|
+
function parse(args, options = {}) {
|
|
2553
|
+
try {
|
|
2554
|
+
const r = parseArgs({
|
|
2555
|
+
args,
|
|
2556
|
+
options: {
|
|
2557
|
+
...GLOBAL_OPTIONS,
|
|
2558
|
+
...options
|
|
2559
|
+
},
|
|
2560
|
+
allowPositionals: true,
|
|
2561
|
+
strict: true
|
|
2562
|
+
});
|
|
2563
|
+
return {
|
|
2564
|
+
values: r.values,
|
|
2565
|
+
positionals: r.positionals
|
|
2566
|
+
};
|
|
2567
|
+
} catch (e) {
|
|
2568
|
+
throw new ArcaError(`arca: ${e.message.replace(/\. To specify a positional.*$/s, "")}\nRun \`npx arca help\` for usage.`, 2);
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
const COMMANDS = [
|
|
2572
|
+
"run",
|
|
2573
|
+
"list",
|
|
2574
|
+
"ls",
|
|
2575
|
+
"check",
|
|
2576
|
+
"doctor",
|
|
2577
|
+
"set",
|
|
2578
|
+
"setup",
|
|
2579
|
+
"unset",
|
|
2580
|
+
"restore",
|
|
2581
|
+
"reveal",
|
|
2582
|
+
"export",
|
|
2583
|
+
"import",
|
|
2584
|
+
"init",
|
|
2585
|
+
"policy",
|
|
2586
|
+
"projects",
|
|
2587
|
+
"help",
|
|
2588
|
+
"version"
|
|
2589
|
+
];
|
|
2590
|
+
async function main(argv, io) {
|
|
2591
|
+
try {
|
|
2592
|
+
return await dispatch(argv, io);
|
|
2593
|
+
} catch (e) {
|
|
2594
|
+
if (e instanceof ArcaError) {
|
|
2595
|
+
if (e.message) io.stderr.write(e.message + "\n");
|
|
2596
|
+
return e.code;
|
|
2597
|
+
}
|
|
2598
|
+
io.stderr.write(`arca: unexpected error: ${e.stack ?? String(e)}\n`);
|
|
2599
|
+
return 1;
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
async function dispatch(argv, io) {
|
|
2603
|
+
const [command, ...rest] = argv;
|
|
2604
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
2605
|
+
io.stdout.write("0.1.0\n");
|
|
2606
|
+
return 0;
|
|
2607
|
+
}
|
|
2608
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
2609
|
+
io.stdout.write(HELP + "\n");
|
|
2610
|
+
return 0;
|
|
2611
|
+
}
|
|
2612
|
+
if (command === void 0 || command.startsWith("-") && command !== "--") {
|
|
2613
|
+
const { values } = parse(argv);
|
|
2614
|
+
return statusCommand(session(io, values), VERSION);
|
|
2615
|
+
}
|
|
2616
|
+
if (command === "run" || command === "--") return run(command === "--" ? ["--", ...rest] : rest, io);
|
|
2617
|
+
if (!COMMANDS.includes(command)) {
|
|
2618
|
+
const guess = suggest(command, COMMANDS);
|
|
2619
|
+
throw new ArcaError(`arca: unknown command "${command}".${guess ? ` Did you mean \`npx arca ${guess}\`?` : ""} Run \`npx arca help\`.`, 2);
|
|
2620
|
+
}
|
|
2621
|
+
switch (command) {
|
|
2622
|
+
case "list":
|
|
2623
|
+
case "ls": {
|
|
2624
|
+
const { values } = parse(rest, profileOption);
|
|
2625
|
+
return listCommand(session(io, values));
|
|
2626
|
+
}
|
|
2627
|
+
case "check": {
|
|
2628
|
+
const { values } = parse(rest, {
|
|
2629
|
+
strict: { type: "boolean" },
|
|
2630
|
+
...profileOption
|
|
2631
|
+
});
|
|
2632
|
+
return checkCommand(session(io, values), { strict: Boolean(values.strict) });
|
|
2633
|
+
}
|
|
2634
|
+
case "doctor": {
|
|
2635
|
+
const { values } = parse(rest);
|
|
2636
|
+
return doctorCommand(session(io, values));
|
|
2637
|
+
}
|
|
2638
|
+
case "set": {
|
|
2639
|
+
const { values, positionals } = parse(rest.map((a) => a === "--generate" || a === "-g" ? "--generate=default" : a), {
|
|
2640
|
+
generate: { type: "string" },
|
|
2641
|
+
...profileOption
|
|
2642
|
+
});
|
|
2643
|
+
let generate;
|
|
2644
|
+
if (typeof values.generate === "string") if (values.generate === "default") generate = true;
|
|
2645
|
+
else {
|
|
2646
|
+
generate = Number(values.generate);
|
|
2647
|
+
if (!Number.isInteger(generate) || generate < 8 || generate > 1024) throw new ArcaError("arca: --generate takes a number of bytes from 8 to 1024.", 2);
|
|
2648
|
+
}
|
|
2649
|
+
if (positionals.length > 2) throw new ArcaError("arca: too many arguments. Usage: npx arca set NAME [value]", 2);
|
|
2650
|
+
return setCommand(session(io, values), {
|
|
2651
|
+
name: positionals[0],
|
|
2652
|
+
value: positionals[1],
|
|
2653
|
+
generate
|
|
2654
|
+
});
|
|
2655
|
+
}
|
|
2656
|
+
case "setup": {
|
|
2657
|
+
const { values } = parse(rest, {
|
|
2658
|
+
all: { type: "boolean" },
|
|
2659
|
+
...profileOption
|
|
2660
|
+
});
|
|
2661
|
+
return setupCommand(session(io, values), { all: Boolean(values.all) });
|
|
2662
|
+
}
|
|
2663
|
+
case "unset":
|
|
2664
|
+
case "restore":
|
|
2665
|
+
case "reveal": {
|
|
2666
|
+
const { values, positionals } = parse(rest, {
|
|
2667
|
+
copy: { type: "boolean" },
|
|
2668
|
+
...profileOption
|
|
2669
|
+
});
|
|
2670
|
+
if (values.copy) throw new ArcaError("arca: --copy is not available yet. Use `npx arca reveal NAME` in your terminal.", 2);
|
|
2671
|
+
const s = session(io, values);
|
|
2672
|
+
if (command === "unset") return unsetCommand(s, positionals[0]);
|
|
2673
|
+
if (command === "restore") return restoreCommand(s, positionals[0]);
|
|
2674
|
+
return revealCommand(s, positionals[0]);
|
|
2675
|
+
}
|
|
2676
|
+
case "export": {
|
|
2677
|
+
const { values } = parse(rest, {
|
|
2678
|
+
format: { type: "string" },
|
|
2679
|
+
output: {
|
|
2680
|
+
type: "string",
|
|
2681
|
+
short: "o"
|
|
2682
|
+
},
|
|
2683
|
+
...profileOption
|
|
2684
|
+
});
|
|
2685
|
+
return exportCommand(session(io, values), {
|
|
2686
|
+
format: values.format,
|
|
2687
|
+
output: values.output
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
case "import": {
|
|
2691
|
+
const { values, positionals } = parse(rest, {
|
|
2692
|
+
only: { type: "string" },
|
|
2693
|
+
"include-public": { type: "boolean" },
|
|
2694
|
+
delete: { type: "boolean" },
|
|
2695
|
+
yes: {
|
|
2696
|
+
type: "boolean",
|
|
2697
|
+
short: "y"
|
|
2698
|
+
},
|
|
2699
|
+
...profileOption
|
|
2700
|
+
});
|
|
2701
|
+
return importCommand(session(io, values), {
|
|
2702
|
+
files: positionals,
|
|
2703
|
+
only: splitList(values.only),
|
|
2704
|
+
includePublic: Boolean(values["include-public"]),
|
|
2705
|
+
delete: Boolean(values.delete),
|
|
2706
|
+
yes: Boolean(values.yes)
|
|
2707
|
+
});
|
|
2708
|
+
}
|
|
2709
|
+
case "init": {
|
|
2710
|
+
const { values } = parse(rest, {
|
|
2711
|
+
yes: {
|
|
2712
|
+
type: "boolean",
|
|
2713
|
+
short: "y"
|
|
2714
|
+
},
|
|
2715
|
+
"new-id": { type: "boolean" },
|
|
2716
|
+
"no-import": { type: "boolean" }
|
|
2717
|
+
});
|
|
2718
|
+
return initCommand(session(io, values), {
|
|
2719
|
+
yes: Boolean(values.yes),
|
|
2720
|
+
newId: Boolean(values["new-id"]),
|
|
2721
|
+
import: !values["no-import"]
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
case "policy": {
|
|
2725
|
+
const { values, positionals } = parse(rest);
|
|
2726
|
+
return policyCommand(session(io, values), positionals);
|
|
2727
|
+
}
|
|
2728
|
+
case "projects": {
|
|
2729
|
+
const { values, positionals } = parse(rest);
|
|
2730
|
+
return projectsCommand(session(io, values), positionals);
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
return 2;
|
|
2734
|
+
}
|
|
2735
|
+
const profileOption = { profile: {
|
|
2736
|
+
type: "string",
|
|
2737
|
+
short: "p"
|
|
2738
|
+
} };
|
|
2739
|
+
function session(io, values) {
|
|
2740
|
+
if (values.help) {
|
|
2741
|
+
io.stdout.write(HELP + "\n");
|
|
2742
|
+
throw new ArcaError("", 0);
|
|
2743
|
+
}
|
|
2744
|
+
if (values.profile !== void 0) throw new ArcaError("arca: profiles are not available in this version yet. Every command uses the project's single set of values.", 2);
|
|
2745
|
+
const s = new Session(io, { projectDir: values["project-dir"] });
|
|
2746
|
+
s.json = Boolean(values.json);
|
|
2747
|
+
s.quiet = Boolean(values.quiet);
|
|
2748
|
+
return s;
|
|
2749
|
+
}
|
|
2750
|
+
function splitList(v) {
|
|
2751
|
+
if (typeof v !== "string") return void 0;
|
|
2752
|
+
return v.split(",").map((x) => x.trim()).filter(Boolean);
|
|
2753
|
+
}
|
|
2754
|
+
/** `arca run [opts] [--] <cmd…>`: arca's options end at `--` or at the first non-option. */
|
|
2755
|
+
async function run(args, io) {
|
|
2756
|
+
const own = [];
|
|
2757
|
+
let i = 0;
|
|
2758
|
+
for (; i < args.length; i++) {
|
|
2759
|
+
const a = args[i];
|
|
2760
|
+
if (a === "--") {
|
|
2761
|
+
i++;
|
|
2762
|
+
break;
|
|
2763
|
+
}
|
|
2764
|
+
if (!a.startsWith("-")) break;
|
|
2765
|
+
own.push(a);
|
|
2766
|
+
if (RUN_OPTIONS_WITH_VALUE.has(a) && i + 1 < args.length) own.push(args[++i]);
|
|
2767
|
+
}
|
|
2768
|
+
const command = args.slice(i);
|
|
2769
|
+
const { values } = parse(own, {
|
|
2770
|
+
only: { type: "string" },
|
|
2771
|
+
passthrough: { type: "boolean" },
|
|
2772
|
+
"no-override": { type: "boolean" },
|
|
2773
|
+
...profileOption
|
|
2774
|
+
});
|
|
2775
|
+
return runCommand(session(io, values), {
|
|
2776
|
+
command,
|
|
2777
|
+
only: splitList(values.only),
|
|
2778
|
+
passthrough: Boolean(values.passthrough),
|
|
2779
|
+
noOverride: Boolean(values["no-override"])
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
//#endregion
|
|
2783
|
+
export { detectAgent as a, parseEnvEntries as i, main as n, detectCI as o, parseEnv as r, processIO as s, VERSION as t };
|