@appfleet-cli/cli 0.1.0
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 +14 -0
- package/dist/appfleet.d.ts +4 -0
- package/dist/appfleet.js +12253 -0
- package/dist/audit.d.ts +10 -0
- package/dist/audit.js +85 -0
- package/dist/billing-cost.d.ts +8 -0
- package/dist/billing-cost.js +186 -0
- package/dist/cloud-session.d.ts +124 -0
- package/dist/cloud-session.js +1819 -0
- package/dist/command-registry.d.ts +18 -0
- package/dist/command-registry.js +1067 -0
- package/dist/demo-fixture.d.ts +11 -0
- package/dist/demo-fixture.js +39 -0
- package/dist/generate-cli-docs.d.ts +1 -0
- package/dist/generate-cli-docs.js +94 -0
- package/dist/health.d.ts +8 -0
- package/dist/health.js +60 -0
- package/dist/local-vault.d.ts +75 -0
- package/dist/local-vault.js +1169 -0
- package/dist/operations.d.ts +8 -0
- package/dist/operations.js +220 -0
- package/dist/project-memory.d.ts +138 -0
- package/dist/project-memory.js +1529 -0
- package/dist/prototype-inject.d.ts +21 -0
- package/dist/prototype-inject.js +170 -0
- package/dist/provider-integrations.d.ts +8 -0
- package/dist/provider-integrations.js +197 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
3
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { AppFleetCryptoAuthError, decryptCredentialValue, encryptCredentialValue, generateUserHeldRecoveryKey, generateWorkspaceVaultKey, masterPasswordProfiles, packageEncryptedCredentialEnvelopeForCloud, packageKeyWrapperEnvelopeForCloud, unwrapWorkspaceVaultKeyWithMasterPassword, wrapWorkspaceVaultKeyWithMasterPassword, } from "@appfleet/crypto";
|
|
6
|
+
import { appCredentialStatusValues, createAppCredentialMetadata, createAppKeyWrapperMetadata, createDeviceKeyUnlockMetadata, createEmergencyRecoveryKitMetadata, createLocalSensitiveAuditEvent, createCloudSecretEncryptedEnvelopeRecord, createCloudSecretSyncRequest, createOsKeychainIntegrationMetadata, } from "@appfleet/domain";
|
|
7
|
+
class LocalVaultCliError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "LocalVaultCliError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const defaultVaultPath = resolve(".appfleet", "local-vault.json");
|
|
16
|
+
const defaultAuditPath = resolve(".appfleet", "vault-audit.jsonl");
|
|
17
|
+
const defaultSecretSyncPath = resolve(".appfleet", "cloud-secret-sync.json");
|
|
18
|
+
const secretAliasPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
19
|
+
export function parseLocalVaultCommand(argv) {
|
|
20
|
+
const normalizedArgv = argv[0] === "--" ? argv.slice(1) : argv;
|
|
21
|
+
const namespace = normalizedArgv[0];
|
|
22
|
+
const action = normalizedArgv[1];
|
|
23
|
+
if (namespace === "vault" && action === "init") {
|
|
24
|
+
return {
|
|
25
|
+
action: "vault-init",
|
|
26
|
+
workspaceId: readFlag(normalizedArgv, "--workspace") ?? "workspace_local",
|
|
27
|
+
masterPassword: readFlag(normalizedArgv, "--master-password"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (namespace === "vault" && action === "unlock") {
|
|
31
|
+
return {
|
|
32
|
+
action: "vault-unlock",
|
|
33
|
+
masterPassword: readFlag(normalizedArgv, "--master-password"),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (namespace === "vault" && action === "change-password") {
|
|
37
|
+
return {
|
|
38
|
+
action: "vault-change-password",
|
|
39
|
+
masterPassword: readFlag(normalizedArgv, "--master-password"),
|
|
40
|
+
newMasterPassword: readFlag(normalizedArgv, "--new-master-password"),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (namespace === "vault" && action === "recovery") {
|
|
44
|
+
const recoveryAction = normalizedArgv[2];
|
|
45
|
+
if (recoveryAction === "generate") {
|
|
46
|
+
return {
|
|
47
|
+
action: "vault-recovery-generate",
|
|
48
|
+
masterPassword: readFlag(normalizedArgv, "--master-password"),
|
|
49
|
+
recoveryKey: readFlag(normalizedArgv, "--recovery-key"),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (recoveryAction === "unlock") {
|
|
53
|
+
return {
|
|
54
|
+
action: "vault-recovery-unlock",
|
|
55
|
+
recoveryKey: readFlag(normalizedArgv, "--recovery-key"),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (recoveryAction === "rotate") {
|
|
59
|
+
return {
|
|
60
|
+
action: "vault-recovery-rotate",
|
|
61
|
+
masterPassword: readFlag(normalizedArgv, "--master-password"),
|
|
62
|
+
recoveryKey: readFlag(normalizedArgv, "--recovery-key"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (recoveryAction === "kit") {
|
|
66
|
+
return {
|
|
67
|
+
action: "vault-recovery-kit",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (namespace === "vault" && action === "device-key" && normalizedArgv[2] === "register") {
|
|
72
|
+
const deviceId = readFlag(normalizedArgv, "--device");
|
|
73
|
+
const deviceLabel = readFlag(normalizedArgv, "--label") ?? deviceId;
|
|
74
|
+
if (!deviceId || !deviceLabel) {
|
|
75
|
+
throw new LocalVaultCliError("InvalidArguments", "usage: vault device-key register --device <device-id> [--label <label>]");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
action: "vault-device-key-register",
|
|
79
|
+
deviceId,
|
|
80
|
+
deviceLabel,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (namespace === "vault" && action === "keychain" && normalizedArgv[2] === "status") {
|
|
84
|
+
return {
|
|
85
|
+
action: "vault-keychain-status",
|
|
86
|
+
provider: readKeychainProviderFlag(normalizedArgv),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (namespace === "secrets" && action === "set") {
|
|
90
|
+
const project = normalizedArgv[2];
|
|
91
|
+
const environment = readFlag(normalizedArgv, "--env");
|
|
92
|
+
const alias = readFlag(normalizedArgv, "--alias");
|
|
93
|
+
if (!project || !environment || !alias) {
|
|
94
|
+
throw new LocalVaultCliError("InvalidArguments", "usage: secrets set <project> --env <env> --alias <NAME> [--value <value>]");
|
|
95
|
+
}
|
|
96
|
+
assertValidAlias(alias);
|
|
97
|
+
const status = readCredentialStatusFlag(normalizedArgv) ?? "active";
|
|
98
|
+
return {
|
|
99
|
+
action: "secrets-set",
|
|
100
|
+
project,
|
|
101
|
+
environment,
|
|
102
|
+
alias,
|
|
103
|
+
label: readFlag(normalizedArgv, "--label"),
|
|
104
|
+
status,
|
|
105
|
+
value: readFlag(normalizedArgv, "--value"),
|
|
106
|
+
masterPassword: readFlag(normalizedArgv, "--master-password"),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (namespace === "secrets" && action === "list") {
|
|
110
|
+
const project = normalizedArgv[2];
|
|
111
|
+
if (!project) {
|
|
112
|
+
throw new LocalVaultCliError("InvalidArguments", "usage: secrets list <project> [--env <env>]");
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
action: "secrets-list",
|
|
116
|
+
project,
|
|
117
|
+
environment: readFlag(normalizedArgv, "--env"),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (namespace === "secrets" && action === "inject") {
|
|
121
|
+
const project = normalizedArgv[2];
|
|
122
|
+
const separatorIndex = normalizedArgv.indexOf("--");
|
|
123
|
+
const appfleetArgs = separatorIndex === -1 ? normalizedArgv : normalizedArgv.slice(0, separatorIndex);
|
|
124
|
+
const environment = readFlag(appfleetArgs, "--env");
|
|
125
|
+
if (!project || !environment || separatorIndex === -1) {
|
|
126
|
+
throw new LocalVaultCliError("InvalidArguments", "usage: secrets inject <project> --env <env> -- <command> [args...]");
|
|
127
|
+
}
|
|
128
|
+
const command = normalizedArgv[separatorIndex + 1];
|
|
129
|
+
if (!command) {
|
|
130
|
+
throw new LocalVaultCliError("MissingChildCommand", "-- must be followed by a child command");
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
action: "secrets-inject",
|
|
134
|
+
project,
|
|
135
|
+
environment,
|
|
136
|
+
masterPassword: readFlag(appfleetArgs, "--master-password"),
|
|
137
|
+
command,
|
|
138
|
+
args: normalizedArgv.slice(separatorIndex + 2),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (namespace === "secrets" && (action === "upload" || action === "sync")) {
|
|
142
|
+
return {
|
|
143
|
+
action: "secrets-upload",
|
|
144
|
+
outputPath: readFlag(normalizedArgv, "--output"),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (namespace === "secrets" && action === "download") {
|
|
148
|
+
return {
|
|
149
|
+
action: "secrets-download",
|
|
150
|
+
inputPath: readFlag(normalizedArgv, "--input"),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
throw new LocalVaultCliError("UnsupportedCommand", "usage: vault <init|unlock> or secrets <set|list|inject>");
|
|
154
|
+
}
|
|
155
|
+
export async function runLocalVaultCommand(argv, options = {}) {
|
|
156
|
+
const vaultPath = options.vaultPath ??
|
|
157
|
+
options.env?.APPFLEET_LOCAL_VAULT_PATH ??
|
|
158
|
+
process.env.APPFLEET_LOCAL_VAULT_PATH ??
|
|
159
|
+
defaultVaultPath;
|
|
160
|
+
const auditPath = options.auditPath ??
|
|
161
|
+
options.env?.APPFLEET_AUDIT_PATH ??
|
|
162
|
+
process.env.APPFLEET_AUDIT_PATH ??
|
|
163
|
+
defaultAuditPath;
|
|
164
|
+
const env = options.env ?? process.env;
|
|
165
|
+
const now = options.now ?? (() => new Date());
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = parseLocalVaultCommand(argv);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return commandError(error);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
if (parsed.action === "vault-init") {
|
|
175
|
+
const existingStore = await tryReadLocalVaultStore(vaultPath);
|
|
176
|
+
if (existingStore) {
|
|
177
|
+
return {
|
|
178
|
+
exitCode: 0,
|
|
179
|
+
stdout: [
|
|
180
|
+
"AppFleet local vault already initialized.",
|
|
181
|
+
`workspace=${existingStore.workspaceId}`,
|
|
182
|
+
`vaultPath=${vaultPath}`,
|
|
183
|
+
"cloudPersistence=scaffold-only",
|
|
184
|
+
].join("\n") + "\n",
|
|
185
|
+
stderr: "",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const masterPassword = resolveMasterPassword(parsed.masterPassword, env);
|
|
189
|
+
const store = await createLocalVaultStore({
|
|
190
|
+
workspaceId: parsed.workspaceId,
|
|
191
|
+
masterPassword,
|
|
192
|
+
now,
|
|
193
|
+
});
|
|
194
|
+
await writeLocalVaultStore(vaultPath, store);
|
|
195
|
+
return {
|
|
196
|
+
exitCode: 0,
|
|
197
|
+
stdout: [
|
|
198
|
+
"AppFleet local vault initialized.",
|
|
199
|
+
`workspace=${store.workspaceId}`,
|
|
200
|
+
`vaultPath=${vaultPath}`,
|
|
201
|
+
"secrets=0",
|
|
202
|
+
"cloudPersistence=scaffold-only",
|
|
203
|
+
].join("\n") + "\n",
|
|
204
|
+
stderr: "",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (parsed.action === "vault-unlock") {
|
|
208
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
209
|
+
await unwrapLocalVaultKey(store, resolveMasterPassword(parsed.masterPassword, env));
|
|
210
|
+
return {
|
|
211
|
+
exitCode: 0,
|
|
212
|
+
stdout: [
|
|
213
|
+
"AppFleet local vault unlocked.",
|
|
214
|
+
`workspace=${store.workspaceId}`,
|
|
215
|
+
`vaultPath=${vaultPath}`,
|
|
216
|
+
`secrets=${store.credentials.length}`,
|
|
217
|
+
"plaintextKeyPrinted=false",
|
|
218
|
+
].join("\n") + "\n",
|
|
219
|
+
stderr: "",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (parsed.action === "vault-change-password") {
|
|
223
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
224
|
+
const workspaceVaultKey = await unwrapLocalVaultKey(store, resolveMasterPassword(parsed.masterPassword, env));
|
|
225
|
+
const updatedStore = await replaceKeyWrapper({
|
|
226
|
+
store,
|
|
227
|
+
workspaceVaultKey,
|
|
228
|
+
secret: resolveNewMasterPassword(parsed.newMasterPassword, env),
|
|
229
|
+
unlockMethod: "master_password",
|
|
230
|
+
now,
|
|
231
|
+
});
|
|
232
|
+
await writeLocalVaultStore(vaultPath, updatedStore);
|
|
233
|
+
return {
|
|
234
|
+
exitCode: 0,
|
|
235
|
+
stdout: [
|
|
236
|
+
"AppFleet master password changed.",
|
|
237
|
+
`workspace=${store.workspaceId}`,
|
|
238
|
+
"oldWrapperState=revoked",
|
|
239
|
+
"newWrapperState=active",
|
|
240
|
+
"plaintextKeyPrinted=false",
|
|
241
|
+
].join("\n") + "\n",
|
|
242
|
+
stderr: "",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (parsed.action === "vault-recovery-generate") {
|
|
246
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
247
|
+
const recoveryKey = parsed.recoveryKey ?? env.APPFLEET_RECOVERY_KEY;
|
|
248
|
+
const generatedRecoveryKey = recoveryKey ?? await generateUserHeldRecoveryKey();
|
|
249
|
+
const workspaceVaultKey = await unwrapLocalVaultKey(store, resolveMasterPassword(parsed.masterPassword, env));
|
|
250
|
+
const updatedStore = await replaceKeyWrapper({
|
|
251
|
+
store,
|
|
252
|
+
workspaceVaultKey,
|
|
253
|
+
secret: generatedRecoveryKey,
|
|
254
|
+
unlockMethod: "recovery_key",
|
|
255
|
+
now,
|
|
256
|
+
});
|
|
257
|
+
await writeLocalVaultStore(vaultPath, updatedStore);
|
|
258
|
+
return {
|
|
259
|
+
exitCode: 0,
|
|
260
|
+
stdout: [
|
|
261
|
+
"AppFleet recovery key wrapper generated.",
|
|
262
|
+
`workspace=${store.workspaceId}`,
|
|
263
|
+
"recoveryKeyPrinted=false",
|
|
264
|
+
"recoveryKitMetadata=true",
|
|
265
|
+
"plaintextKeyPrinted=false",
|
|
266
|
+
].join("\n") + "\n",
|
|
267
|
+
stderr: "",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (parsed.action === "vault-recovery-unlock") {
|
|
271
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
272
|
+
await unwrapLocalVaultKeyWithSecret(store, "recovery_key", resolveRecoveryKey(parsed.recoveryKey, env));
|
|
273
|
+
return {
|
|
274
|
+
exitCode: 0,
|
|
275
|
+
stdout: [
|
|
276
|
+
"AppFleet local vault unlocked by recovery metadata.",
|
|
277
|
+
`workspace=${store.workspaceId}`,
|
|
278
|
+
"recoveryKeyPrinted=false",
|
|
279
|
+
"plaintextKeyPrinted=false",
|
|
280
|
+
].join("\n") + "\n",
|
|
281
|
+
stderr: "",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (parsed.action === "vault-recovery-rotate") {
|
|
285
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
286
|
+
const workspaceVaultKey = await unwrapLocalVaultKey(store, resolveMasterPassword(parsed.masterPassword, env));
|
|
287
|
+
const updatedStore = await replaceKeyWrapper({
|
|
288
|
+
store,
|
|
289
|
+
workspaceVaultKey,
|
|
290
|
+
secret: resolveRecoveryKey(parsed.recoveryKey, env),
|
|
291
|
+
unlockMethod: "recovery_key",
|
|
292
|
+
now,
|
|
293
|
+
});
|
|
294
|
+
await writeLocalVaultStore(vaultPath, updatedStore);
|
|
295
|
+
return {
|
|
296
|
+
exitCode: 0,
|
|
297
|
+
stdout: [
|
|
298
|
+
"AppFleet recovery key wrapper rotated.",
|
|
299
|
+
`workspace=${store.workspaceId}`,
|
|
300
|
+
"oldRecoveryWrapperState=revoked",
|
|
301
|
+
"newRecoveryWrapperState=active",
|
|
302
|
+
"recoveryKeyPrinted=false",
|
|
303
|
+
"plaintextKeyPrinted=false",
|
|
304
|
+
].join("\n") + "\n",
|
|
305
|
+
stderr: "",
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (parsed.action === "vault-recovery-kit") {
|
|
309
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
310
|
+
return {
|
|
311
|
+
exitCode: 0,
|
|
312
|
+
stdout: [
|
|
313
|
+
"AppFleet emergency recovery kit metadata.",
|
|
314
|
+
`workspace=${store.workspaceId}`,
|
|
315
|
+
`state=${store.recoveryKit?.state ?? "missing"}`,
|
|
316
|
+
`recoveryWrapper=${store.recoveryKit?.recoveryKeyWrapperId ?? "missing"}`,
|
|
317
|
+
"userHeldRecoveryKeyRequired=true",
|
|
318
|
+
"printableKitIncludesSecretMaterial=false",
|
|
319
|
+
"renderedByWeb=false",
|
|
320
|
+
].join("\n") + "\n",
|
|
321
|
+
stderr: "",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (parsed.action === "vault-device-key-register") {
|
|
325
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
326
|
+
const updatedAt = now().toISOString();
|
|
327
|
+
const metadata = createDeviceKeyUnlockMetadata({
|
|
328
|
+
type: "device_key_unlock_metadata",
|
|
329
|
+
version: 1,
|
|
330
|
+
workspaceId: store.workspaceId,
|
|
331
|
+
deviceId: parsed.deviceId,
|
|
332
|
+
deviceLabel: parsed.deviceLabel,
|
|
333
|
+
createdAt: updatedAt,
|
|
334
|
+
updatedAt,
|
|
335
|
+
unlockImplemented: false,
|
|
336
|
+
containsPlaintextKeyMaterial: false,
|
|
337
|
+
containsPlaintextSecret: false,
|
|
338
|
+
productionCloudPersistenceImplemented: false,
|
|
339
|
+
});
|
|
340
|
+
await writeLocalVaultStore(vaultPath, {
|
|
341
|
+
...store,
|
|
342
|
+
deviceKeyUnlocks: upsertDeviceKeyUnlock(store.deviceKeyUnlocks ?? [], metadata),
|
|
343
|
+
});
|
|
344
|
+
return {
|
|
345
|
+
exitCode: 0,
|
|
346
|
+
stdout: [
|
|
347
|
+
"AppFleet device-key unlock metadata recorded.",
|
|
348
|
+
`workspace=${store.workspaceId}`,
|
|
349
|
+
`device=${metadata.deviceId}`,
|
|
350
|
+
"unlockImplemented=false",
|
|
351
|
+
"keyMaterialStored=false",
|
|
352
|
+
].join("\n") + "\n",
|
|
353
|
+
stderr: "",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (parsed.action === "vault-keychain-status") {
|
|
357
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
358
|
+
const updatedAt = now().toISOString();
|
|
359
|
+
const metadata = createOsKeychainIntegrationMetadata({
|
|
360
|
+
type: "os_keychain_integration_metadata",
|
|
361
|
+
version: 1,
|
|
362
|
+
workspaceId: store.workspaceId,
|
|
363
|
+
provider: parsed.provider ?? "macos_keychain",
|
|
364
|
+
createdAt: store.osKeychainIntegration?.createdAt ?? updatedAt,
|
|
365
|
+
updatedAt,
|
|
366
|
+
integrationImplemented: false,
|
|
367
|
+
storesKeyMaterialInAppFleetStore: false,
|
|
368
|
+
containsPlaintextKeyMaterial: false,
|
|
369
|
+
containsPlaintextSecret: false,
|
|
370
|
+
productionCloudPersistenceImplemented: false,
|
|
371
|
+
});
|
|
372
|
+
await writeLocalVaultStore(vaultPath, {
|
|
373
|
+
...store,
|
|
374
|
+
osKeychainIntegration: metadata,
|
|
375
|
+
});
|
|
376
|
+
return {
|
|
377
|
+
exitCode: 0,
|
|
378
|
+
stdout: [
|
|
379
|
+
"AppFleet OS keychain integration boundary.",
|
|
380
|
+
`workspace=${store.workspaceId}`,
|
|
381
|
+
`provider=${metadata.provider}`,
|
|
382
|
+
"integrationImplemented=false",
|
|
383
|
+
"storesKeyMaterialInAppFleetStore=false",
|
|
384
|
+
].join("\n") + "\n",
|
|
385
|
+
stderr: "",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (parsed.action === "secrets-set") {
|
|
389
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
390
|
+
const masterPassword = resolveMasterPassword(parsed.masterPassword, env);
|
|
391
|
+
const secretValue = resolveSecretValue(parsed.value, env);
|
|
392
|
+
const workspaceVaultKey = await unwrapLocalVaultKey(store, masterPassword);
|
|
393
|
+
const recordId = credentialRecordId(parsed.project, parsed.environment, parsed.alias);
|
|
394
|
+
const existing = store.credentials.find((record) => record.id === recordId);
|
|
395
|
+
const existingMetadata = existing
|
|
396
|
+
? credentialMetadataForRecord(existing)
|
|
397
|
+
: undefined;
|
|
398
|
+
const createdAt = existing?.createdAt ?? now().toISOString();
|
|
399
|
+
const updatedAt = now().toISOString();
|
|
400
|
+
const versionOrdinal = (existingMetadata?.versionHistory?.length ?? 0) + 1;
|
|
401
|
+
const versionId = `${recordId}.v${versionOrdinal}`;
|
|
402
|
+
const versionHistory = [
|
|
403
|
+
...(existingMetadata?.versionHistory ?? []).map((version) => version.status === "current"
|
|
404
|
+
? {
|
|
405
|
+
...version,
|
|
406
|
+
status: "superseded",
|
|
407
|
+
supersededAt: updatedAt,
|
|
408
|
+
}
|
|
409
|
+
: version),
|
|
410
|
+
{
|
|
411
|
+
type: "credential_version_metadata",
|
|
412
|
+
version: 1,
|
|
413
|
+
versionId,
|
|
414
|
+
credentialId: recordId,
|
|
415
|
+
ordinal: versionOrdinal,
|
|
416
|
+
status: "current",
|
|
417
|
+
createdAt: updatedAt,
|
|
418
|
+
rotatedAt: existing ? updatedAt : undefined,
|
|
419
|
+
valueStoredByAppFleetCloud: false,
|
|
420
|
+
containsCredentialValues: false,
|
|
421
|
+
storesDecryptedMaterial: false,
|
|
422
|
+
},
|
|
423
|
+
];
|
|
424
|
+
const envelope = await encryptCredentialValue({
|
|
425
|
+
plaintext: secretValue,
|
|
426
|
+
workspaceVaultKey,
|
|
427
|
+
workspaceId: store.workspaceId,
|
|
428
|
+
recordId,
|
|
429
|
+
keyGenerationId: store.keyGenerationId,
|
|
430
|
+
createdAt: updatedAt,
|
|
431
|
+
});
|
|
432
|
+
const record = {
|
|
433
|
+
id: recordId,
|
|
434
|
+
project: parsed.project,
|
|
435
|
+
environment: parsed.environment,
|
|
436
|
+
alias: parsed.alias,
|
|
437
|
+
metadata: createAppCredentialMetadata({
|
|
438
|
+
type: "credential_metadata",
|
|
439
|
+
version: 1,
|
|
440
|
+
credentialId: recordId,
|
|
441
|
+
projectId: parsed.project,
|
|
442
|
+
environment: parsed.environment,
|
|
443
|
+
label: parsed.label ?? existingMetadata?.label ?? parsed.alias,
|
|
444
|
+
envAliases: [parsed.alias],
|
|
445
|
+
status: parsed.status,
|
|
446
|
+
createdAt,
|
|
447
|
+
updatedAt,
|
|
448
|
+
currentVersionId: versionId,
|
|
449
|
+
versionHistory,
|
|
450
|
+
bindings: [
|
|
451
|
+
{
|
|
452
|
+
type: "credential_binding",
|
|
453
|
+
version: 1,
|
|
454
|
+
bindingId: `${recordId}.binding.${parsed.alias}`,
|
|
455
|
+
credentialId: recordId,
|
|
456
|
+
projectId: parsed.project,
|
|
457
|
+
environment: parsed.environment,
|
|
458
|
+
envAlias: parsed.alias,
|
|
459
|
+
purpose: "Local encrypted vault alias binding.",
|
|
460
|
+
status: "active",
|
|
461
|
+
createdAt,
|
|
462
|
+
updatedAt,
|
|
463
|
+
valueStoredByAppFleetCloud: false,
|
|
464
|
+
containsCredentialValues: false,
|
|
465
|
+
storesDecryptedMaterial: false,
|
|
466
|
+
},
|
|
467
|
+
],
|
|
468
|
+
rotatedAt: existing ? updatedAt : undefined,
|
|
469
|
+
keyedFingerprint: createCredentialFingerprint({
|
|
470
|
+
workspaceVaultKey,
|
|
471
|
+
workspaceId: store.workspaceId,
|
|
472
|
+
keyId: store.keyGenerationId,
|
|
473
|
+
project: parsed.project,
|
|
474
|
+
environment: parsed.environment,
|
|
475
|
+
alias: parsed.alias,
|
|
476
|
+
secretValue,
|
|
477
|
+
createdAt: updatedAt,
|
|
478
|
+
}),
|
|
479
|
+
valueStoredByAppFleetCloud: false,
|
|
480
|
+
}),
|
|
481
|
+
createdAt,
|
|
482
|
+
updatedAt,
|
|
483
|
+
envelope,
|
|
484
|
+
};
|
|
485
|
+
const nextCredentials = existing
|
|
486
|
+
? store.credentials.map((candidate) => candidate.id === recordId ? record : candidate)
|
|
487
|
+
: [...store.credentials, record];
|
|
488
|
+
await writeLocalVaultStore(vaultPath, {
|
|
489
|
+
...store,
|
|
490
|
+
credentials: nextCredentials,
|
|
491
|
+
});
|
|
492
|
+
return {
|
|
493
|
+
exitCode: 0,
|
|
494
|
+
stdout: [
|
|
495
|
+
"AppFleet secret stored.",
|
|
496
|
+
`project=${parsed.project}`,
|
|
497
|
+
`env=${parsed.environment}`,
|
|
498
|
+
`alias=${parsed.alias}`,
|
|
499
|
+
`label=${credentialMetadataForRecord(record).label}`,
|
|
500
|
+
`status=${credentialMetadataForRecord(record).status}`,
|
|
501
|
+
"encrypted=true",
|
|
502
|
+
"valuePrinted=false",
|
|
503
|
+
].join("\n") + "\n",
|
|
504
|
+
stderr: "",
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
if (parsed.action === "secrets-list") {
|
|
508
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
509
|
+
const credentials = store.credentials.filter((record) => record.project === parsed.project &&
|
|
510
|
+
(!parsed.environment || record.environment === parsed.environment));
|
|
511
|
+
if (credentials.length === 0) {
|
|
512
|
+
return {
|
|
513
|
+
exitCode: 0,
|
|
514
|
+
stdout: [
|
|
515
|
+
"No AppFleet secrets recorded for this project.",
|
|
516
|
+
`project=${parsed.project}`,
|
|
517
|
+
parsed.environment ? `env=${parsed.environment}` : undefined,
|
|
518
|
+
]
|
|
519
|
+
.filter(Boolean)
|
|
520
|
+
.join("\n") + "\n",
|
|
521
|
+
stderr: "",
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
exitCode: 0,
|
|
526
|
+
stdout: [
|
|
527
|
+
`AppFleet secrets for ${parsed.project}:`,
|
|
528
|
+
...credentials
|
|
529
|
+
.slice()
|
|
530
|
+
.sort(compareCredentialRecords)
|
|
531
|
+
.map((record) => {
|
|
532
|
+
const metadata = credentialMetadataForRecord(record);
|
|
533
|
+
return `- env=${record.environment} alias=${record.alias} label=${metadata.label} status=${metadata.status} version=${metadata.currentVersionId ?? "unknown"} rotatedAt=${metadata.rotatedAt ?? "never"} fingerprint=${metadata.keyedFingerprint?.fingerprint ?? "not-recorded"} updatedAt=${record.updatedAt}`;
|
|
534
|
+
}),
|
|
535
|
+
"valuesPrinted=false",
|
|
536
|
+
].join("\n") + "\n",
|
|
537
|
+
stderr: "",
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (parsed.action === "secrets-upload") {
|
|
541
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
542
|
+
const exportedAt = now().toISOString();
|
|
543
|
+
const syncRequest = createCloudSecretSyncRequest({
|
|
544
|
+
type: "cloud_secret_sync_request",
|
|
545
|
+
version: 1,
|
|
546
|
+
workspaceId: store.workspaceId,
|
|
547
|
+
createdAt: exportedAt,
|
|
548
|
+
encryptedEnvelopes: cloudSecretRecordsForStore(store, exportedAt),
|
|
549
|
+
plaintextSecretValuesIncluded: false,
|
|
550
|
+
plaintextKeyMaterialIncluded: false,
|
|
551
|
+
zeroKnowledge: true,
|
|
552
|
+
});
|
|
553
|
+
const outputPath = parsed.outputPath ?? defaultSecretSyncPath;
|
|
554
|
+
await writeJsonFile(outputPath, syncRequest);
|
|
555
|
+
return {
|
|
556
|
+
exitCode: 0,
|
|
557
|
+
stdout: [
|
|
558
|
+
"AppFleet encrypted secret sync package written.",
|
|
559
|
+
`workspace=${store.workspaceId}`,
|
|
560
|
+
`outputPath=${outputPath}`,
|
|
561
|
+
`credentialEnvelopeCount=${store.credentials.length}`,
|
|
562
|
+
`keyWrapperEnvelopeCount=${keyWrapperRecords(store).length}`,
|
|
563
|
+
"plaintextValuesPrinted=false",
|
|
564
|
+
"encryptedBlobIdsPrinted=false",
|
|
565
|
+
"keyWrapperIdsPrinted=false",
|
|
566
|
+
].join("\n") + "\n",
|
|
567
|
+
stderr: "",
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (parsed.action === "secrets-download") {
|
|
571
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
572
|
+
const inputPath = parsed.inputPath ?? defaultSecretSyncPath;
|
|
573
|
+
const syncRequest = createCloudSecretSyncRequest(JSON.parse(await readFile(inputPath, "utf8")));
|
|
574
|
+
if (syncRequest.workspaceId !== store.workspaceId) {
|
|
575
|
+
throw new LocalVaultCliError("WorkspaceMismatch", "encrypted secret sync package workspace does not match local vault");
|
|
576
|
+
}
|
|
577
|
+
const mergedStore = importCloudSecretRecords(store, syncRequest.encryptedEnvelopes);
|
|
578
|
+
await writeLocalVaultStore(vaultPath, mergedStore);
|
|
579
|
+
return {
|
|
580
|
+
exitCode: 0,
|
|
581
|
+
stdout: [
|
|
582
|
+
"AppFleet encrypted secret sync package imported.",
|
|
583
|
+
`workspace=${store.workspaceId}`,
|
|
584
|
+
`inputPath=${inputPath}`,
|
|
585
|
+
`importedEnvelopeCount=${syncRequest.encryptedEnvelopes.length}`,
|
|
586
|
+
"plaintextValuesPrinted=false",
|
|
587
|
+
"encryptedBlobIdsPrinted=false",
|
|
588
|
+
"keyWrapperIdsPrinted=false",
|
|
589
|
+
].join("\n") + "\n",
|
|
590
|
+
stderr: "",
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const store = await readLocalVaultStore(vaultPath);
|
|
594
|
+
const credentials = store.credentials.filter((record) => record.project === parsed.project &&
|
|
595
|
+
record.environment === parsed.environment);
|
|
596
|
+
if (credentials.length === 0) {
|
|
597
|
+
throw new LocalVaultCliError("NoSecretsForEnvironment", "no encrypted secrets recorded for requested project/environment");
|
|
598
|
+
}
|
|
599
|
+
const workspaceVaultKey = await unwrapLocalVaultKey(store, resolveMasterPassword(parsed.masterPassword, env));
|
|
600
|
+
const decryptedSecrets = await decryptCredentials(credentials, workspaceVaultKey);
|
|
601
|
+
const auditEvent = createAuditEvent(parsed, credentials, store.workspaceId, now);
|
|
602
|
+
try {
|
|
603
|
+
await writeAuditEvent(auditPath, auditEvent);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
return {
|
|
607
|
+
exitCode: 1,
|
|
608
|
+
stdout: "",
|
|
609
|
+
stderr: "AppFleet local vault failed: AuditWriteFailed\n",
|
|
610
|
+
childStarted: false,
|
|
611
|
+
auditId: auditEvent.id,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
let child;
|
|
615
|
+
try {
|
|
616
|
+
child = await runChildProcess(parsed.command, parsed.args, {
|
|
617
|
+
...process.env,
|
|
618
|
+
...Object.fromEntries(decryptedSecrets.map((secret) => [secret.alias, secret.value])),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
return {
|
|
623
|
+
exitCode: 1,
|
|
624
|
+
stdout: redactSecrets([
|
|
625
|
+
`AppFleet secrets inject: project=${parsed.project} env=${parsed.environment} aliases=${credentials.map((record) => record.alias).join(",")} auditId=${auditEvent.id}`,
|
|
626
|
+
"AppFleet secrets inject failed: ChildSpawnFailed",
|
|
627
|
+
].join("\n") + "\n", decryptedSecrets.map((secret) => secret.value)),
|
|
628
|
+
stderr: "",
|
|
629
|
+
childStarted: false,
|
|
630
|
+
auditId: auditEvent.id,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
exitCode: child.exitCode,
|
|
635
|
+
stdout: redactSecrets([
|
|
636
|
+
`AppFleet secrets inject: project=${parsed.project} env=${parsed.environment} aliases=${credentials.map((record) => record.alias).join(",")} auditId=${auditEvent.id}`,
|
|
637
|
+
child.stdout.trimEnd(),
|
|
638
|
+
`AppFleet secrets inject: childExitCode=${child.exitCode}`,
|
|
639
|
+
]
|
|
640
|
+
.filter(Boolean)
|
|
641
|
+
.join("\n") + "\n", decryptedSecrets.map((secret) => secret.value)),
|
|
642
|
+
stderr: redactSecrets(child.stderr, decryptedSecrets.map((secret) => secret.value)),
|
|
643
|
+
childStarted: true,
|
|
644
|
+
auditId: auditEvent.id,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
return commandError(error);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async function createLocalVaultStore(input) {
|
|
652
|
+
const workspaceVaultKey = await generateWorkspaceVaultKey();
|
|
653
|
+
const keyGenerationId = `keygen_${randomUUID()}`;
|
|
654
|
+
const wrappedKeyId = `wrapped_${randomUUID()}`;
|
|
655
|
+
const wrapperId = `wrapper_${randomUUID()}`;
|
|
656
|
+
const createdAt = input.now().toISOString();
|
|
657
|
+
const { envelope } = await wrapWorkspaceVaultKeyWithMasterPassword({
|
|
658
|
+
workspaceVaultKey,
|
|
659
|
+
masterPassword: input.masterPassword,
|
|
660
|
+
workspaceId: input.workspaceId,
|
|
661
|
+
wrapperId,
|
|
662
|
+
wrappedKeyId,
|
|
663
|
+
keyGenerationId,
|
|
664
|
+
profile: masterPasswordProfiles.interactivePrototype,
|
|
665
|
+
createdAt,
|
|
666
|
+
});
|
|
667
|
+
const metadata = createKeyWrapperMetadata({
|
|
668
|
+
envelope,
|
|
669
|
+
unlockMethod: "master_password",
|
|
670
|
+
lifecycleState: "active",
|
|
671
|
+
createdAt,
|
|
672
|
+
updatedAt: createdAt,
|
|
673
|
+
});
|
|
674
|
+
return {
|
|
675
|
+
type: "appfleet_local_vault",
|
|
676
|
+
version: 1,
|
|
677
|
+
workspaceId: input.workspaceId,
|
|
678
|
+
keyGenerationId,
|
|
679
|
+
createdAt,
|
|
680
|
+
keyWrapper: envelope,
|
|
681
|
+
keyWrappers: [{ envelope, metadata }],
|
|
682
|
+
deviceKeyUnlocks: [],
|
|
683
|
+
credentials: [],
|
|
684
|
+
productionCloudPersistenceImplemented: false,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
async function readLocalVaultStore(path) {
|
|
688
|
+
const store = await tryReadLocalVaultStore(path);
|
|
689
|
+
if (!store) {
|
|
690
|
+
throw new LocalVaultCliError("VaultNotInitialized", "run vault init before storing or injecting secrets");
|
|
691
|
+
}
|
|
692
|
+
return store;
|
|
693
|
+
}
|
|
694
|
+
async function replaceKeyWrapper(input) {
|
|
695
|
+
const updatedAt = input.now().toISOString();
|
|
696
|
+
const wrapperId = `wrapper_${randomUUID()}`;
|
|
697
|
+
const wrappedKeyId = `wrapped_${randomUUID()}`;
|
|
698
|
+
const { envelope } = await wrapWorkspaceVaultKeyWithMasterPassword({
|
|
699
|
+
workspaceVaultKey: input.workspaceVaultKey,
|
|
700
|
+
masterPassword: input.secret,
|
|
701
|
+
workspaceId: input.store.workspaceId,
|
|
702
|
+
wrapperId,
|
|
703
|
+
wrappedKeyId,
|
|
704
|
+
keyGenerationId: input.store.keyGenerationId,
|
|
705
|
+
profile: masterPasswordProfiles.interactivePrototype,
|
|
706
|
+
createdAt: updatedAt,
|
|
707
|
+
});
|
|
708
|
+
const activeMetadata = createKeyWrapperMetadata({
|
|
709
|
+
envelope,
|
|
710
|
+
unlockMethod: input.unlockMethod,
|
|
711
|
+
lifecycleState: "active",
|
|
712
|
+
createdAt: updatedAt,
|
|
713
|
+
updatedAt,
|
|
714
|
+
});
|
|
715
|
+
const currentRecords = keyWrapperRecords(input.store).map((record) => {
|
|
716
|
+
if (record.metadata.unlockMethod !== input.unlockMethod ||
|
|
717
|
+
record.metadata.lifecycleState !== "active") {
|
|
718
|
+
return record;
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
envelope: record.envelope,
|
|
722
|
+
metadata: createKeyWrapperMetadata({
|
|
723
|
+
envelope: record.envelope,
|
|
724
|
+
unlockMethod: record.metadata.unlockMethod,
|
|
725
|
+
lifecycleState: "revoked",
|
|
726
|
+
createdAt: record.metadata.createdAt,
|
|
727
|
+
updatedAt,
|
|
728
|
+
revokedAt: updatedAt,
|
|
729
|
+
rotatedAt: updatedAt,
|
|
730
|
+
replacedByWrapperId: wrapperId,
|
|
731
|
+
recoveryKitId: record.metadata.recoveryKitId,
|
|
732
|
+
}),
|
|
733
|
+
};
|
|
734
|
+
});
|
|
735
|
+
const keyWrappers = [...currentRecords, { envelope, metadata: activeMetadata }];
|
|
736
|
+
const recoveryKit = input.unlockMethod === "recovery_key"
|
|
737
|
+
? createEmergencyRecoveryKitMetadata({
|
|
738
|
+
type: "emergency_recovery_kit_metadata",
|
|
739
|
+
version: 1,
|
|
740
|
+
id: input.store.recoveryKit?.id ?? `recovery_kit_${randomUUID()}`,
|
|
741
|
+
workspaceId: input.store.workspaceId,
|
|
742
|
+
recoveryKeyWrapperId: wrapperId,
|
|
743
|
+
createdAt: input.store.recoveryKit?.createdAt ?? updatedAt,
|
|
744
|
+
updatedAt,
|
|
745
|
+
rotatedAt: input.store.recoveryKit ? updatedAt : undefined,
|
|
746
|
+
state: "active",
|
|
747
|
+
userHeldRecoveryKeyRequired: true,
|
|
748
|
+
printableKitIncludesSecretMaterial: false,
|
|
749
|
+
renderedByWeb: false,
|
|
750
|
+
containsPlaintextKeyMaterial: false,
|
|
751
|
+
containsPlaintextSecret: false,
|
|
752
|
+
productionCloudPersistenceImplemented: false,
|
|
753
|
+
})
|
|
754
|
+
: input.store.recoveryKit;
|
|
755
|
+
return {
|
|
756
|
+
...input.store,
|
|
757
|
+
keyWrapper: input.unlockMethod === "master_password" ? envelope : input.store.keyWrapper,
|
|
758
|
+
keyWrappers,
|
|
759
|
+
recoveryKit,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
async function tryReadLocalVaultStore(path) {
|
|
763
|
+
let raw;
|
|
764
|
+
try {
|
|
765
|
+
raw = await readFile(path, "utf8");
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
if (isFileNotFound(error)) {
|
|
769
|
+
return undefined;
|
|
770
|
+
}
|
|
771
|
+
throw error;
|
|
772
|
+
}
|
|
773
|
+
const parsed = JSON.parse(raw);
|
|
774
|
+
assertLocalVaultStore(parsed);
|
|
775
|
+
return parsed;
|
|
776
|
+
}
|
|
777
|
+
async function writeLocalVaultStore(path, store) {
|
|
778
|
+
await mkdir(dirname(path), { recursive: true });
|
|
779
|
+
await writeFile(path, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
|
780
|
+
}
|
|
781
|
+
async function unwrapLocalVaultKey(store, masterPassword) {
|
|
782
|
+
return await unwrapLocalVaultKeyWithSecret(store, "master_password", masterPassword);
|
|
783
|
+
}
|
|
784
|
+
async function unwrapLocalVaultKeyWithSecret(store, unlockMethod, secret) {
|
|
785
|
+
const wrapper = activeKeyWrapperRecord(store, unlockMethod);
|
|
786
|
+
return await unwrapWorkspaceVaultKeyWithMasterPassword({
|
|
787
|
+
envelope: wrapper.envelope,
|
|
788
|
+
masterPassword: secret,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
async function decryptCredentials(credentials, workspaceVaultKey) {
|
|
792
|
+
const decrypted = [];
|
|
793
|
+
for (const credential of credentials) {
|
|
794
|
+
decrypted.push({
|
|
795
|
+
alias: credential.alias,
|
|
796
|
+
value: await decryptCredentialValue({
|
|
797
|
+
envelope: credential.envelope,
|
|
798
|
+
workspaceVaultKey,
|
|
799
|
+
}),
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
return decrypted;
|
|
803
|
+
}
|
|
804
|
+
async function runChildProcess(command, args, env) {
|
|
805
|
+
return await new Promise((resolvePromise, reject) => {
|
|
806
|
+
const child = spawn(command, args, {
|
|
807
|
+
env,
|
|
808
|
+
shell: false,
|
|
809
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
810
|
+
});
|
|
811
|
+
const stdoutChunks = [];
|
|
812
|
+
const stderrChunks = [];
|
|
813
|
+
child.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
|
|
814
|
+
child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
|
|
815
|
+
child.on("error", reject);
|
|
816
|
+
child.on("close", (code) => {
|
|
817
|
+
resolvePromise({
|
|
818
|
+
exitCode: code ?? 1,
|
|
819
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
|
820
|
+
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
function createAuditEvent(parsed, credentials, workspaceId, now) {
|
|
826
|
+
return createLocalSensitiveAuditEvent({
|
|
827
|
+
type: "local_sensitive_audit_event",
|
|
828
|
+
version: 1,
|
|
829
|
+
id: randomUUID(),
|
|
830
|
+
action: "secret_injection_attempted",
|
|
831
|
+
workspaceId,
|
|
832
|
+
projectId: parsed.project,
|
|
833
|
+
environment: parsed.environment,
|
|
834
|
+
credentialLabels: credentials
|
|
835
|
+
.map((record) => credentialMetadataForRecord(record).label)
|
|
836
|
+
.sort(),
|
|
837
|
+
envAliases: credentials.map((record) => record.alias).sort(),
|
|
838
|
+
command: parsed.command,
|
|
839
|
+
createdAt: now().toISOString(),
|
|
840
|
+
durability: "local_jsonl",
|
|
841
|
+
containsPlaintextSecret: false,
|
|
842
|
+
containsEncryptedBlobId: false,
|
|
843
|
+
productionCloudPersistenceImplemented: false,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
async function writeAuditEvent(path, event) {
|
|
847
|
+
await mkdir(dirname(path), { recursive: true });
|
|
848
|
+
await appendFile(path, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
849
|
+
}
|
|
850
|
+
function resolveMasterPassword(explicitPassword, env) {
|
|
851
|
+
const masterPassword = explicitPassword ?? env.APPFLEET_MASTER_PASSWORD ?? env.APPFLEET_VAULT_PASSWORD;
|
|
852
|
+
if (!masterPassword) {
|
|
853
|
+
throw new LocalVaultCliError("MissingMasterPassword", "provide --master-password or APPFLEET_MASTER_PASSWORD");
|
|
854
|
+
}
|
|
855
|
+
return masterPassword;
|
|
856
|
+
}
|
|
857
|
+
function resolveNewMasterPassword(explicitPassword, env) {
|
|
858
|
+
const newMasterPassword = explicitPassword ?? env.APPFLEET_NEW_MASTER_PASSWORD;
|
|
859
|
+
if (!newMasterPassword) {
|
|
860
|
+
throw new LocalVaultCliError("MissingNewMasterPassword", "provide --new-master-password or APPFLEET_NEW_MASTER_PASSWORD");
|
|
861
|
+
}
|
|
862
|
+
return newMasterPassword;
|
|
863
|
+
}
|
|
864
|
+
function resolveRecoveryKey(explicitRecoveryKey, env) {
|
|
865
|
+
const recoveryKey = explicitRecoveryKey ?? env.APPFLEET_RECOVERY_KEY;
|
|
866
|
+
if (!recoveryKey) {
|
|
867
|
+
throw new LocalVaultCliError("MissingRecoveryKey", "provide --recovery-key or APPFLEET_RECOVERY_KEY");
|
|
868
|
+
}
|
|
869
|
+
return recoveryKey;
|
|
870
|
+
}
|
|
871
|
+
function resolveSecretValue(explicitValue, env) {
|
|
872
|
+
const secretValue = explicitValue ?? env.APPFLEET_SECRET_VALUE;
|
|
873
|
+
if (secretValue === undefined) {
|
|
874
|
+
throw new LocalVaultCliError("MissingSecretValue", "provide --value or APPFLEET_SECRET_VALUE");
|
|
875
|
+
}
|
|
876
|
+
return secretValue;
|
|
877
|
+
}
|
|
878
|
+
function credentialRecordId(project, environment, alias) {
|
|
879
|
+
return `${slugPart(project)}:${slugPart(environment)}:${alias}`;
|
|
880
|
+
}
|
|
881
|
+
function createCredentialFingerprint(input) {
|
|
882
|
+
const fingerprint = createHmac("sha256", Buffer.from(input.workspaceVaultKey))
|
|
883
|
+
.update(input.workspaceId)
|
|
884
|
+
.update("\0")
|
|
885
|
+
.update(input.project)
|
|
886
|
+
.update("\0")
|
|
887
|
+
.update(input.environment)
|
|
888
|
+
.update("\0")
|
|
889
|
+
.update(input.alias)
|
|
890
|
+
.update("\0")
|
|
891
|
+
.update(input.secretValue)
|
|
892
|
+
.digest("hex");
|
|
893
|
+
return {
|
|
894
|
+
type: "keyed_secret_fingerprint",
|
|
895
|
+
version: 1,
|
|
896
|
+
fingerprint,
|
|
897
|
+
algorithm: "hmac_sha256",
|
|
898
|
+
keyDerivation: "workspace_vault_key",
|
|
899
|
+
scope: "workspace_project_environment_alias",
|
|
900
|
+
keyId: input.keyId,
|
|
901
|
+
createdAt: input.createdAt,
|
|
902
|
+
containsPlaintextSecret: false,
|
|
903
|
+
reversible: false,
|
|
904
|
+
storesDecryptedMaterial: false,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
function slugPart(value) {
|
|
908
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
909
|
+
}
|
|
910
|
+
function assertValidAlias(alias) {
|
|
911
|
+
if (!secretAliasPattern.test(alias)) {
|
|
912
|
+
throw new LocalVaultCliError("InvalidAlias", "secret alias must be a valid environment variable name");
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
function credentialMetadataForRecord(record) {
|
|
916
|
+
return createAppCredentialMetadata(record.metadata ?? {
|
|
917
|
+
type: "credential_metadata",
|
|
918
|
+
version: 1,
|
|
919
|
+
credentialId: record.id,
|
|
920
|
+
projectId: record.project,
|
|
921
|
+
environment: record.environment,
|
|
922
|
+
label: record.alias,
|
|
923
|
+
envAliases: [record.alias],
|
|
924
|
+
status: "active",
|
|
925
|
+
createdAt: record.createdAt,
|
|
926
|
+
updatedAt: record.updatedAt,
|
|
927
|
+
valueStoredByAppFleetCloud: false,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
function createKeyWrapperMetadata(input) {
|
|
931
|
+
return createAppKeyWrapperMetadata({
|
|
932
|
+
type: "key_wrapper_metadata",
|
|
933
|
+
version: 1,
|
|
934
|
+
wrapperId: input.envelope.wrapperId,
|
|
935
|
+
workspaceId: input.envelope.workspaceId,
|
|
936
|
+
wrappedKeyId: input.envelope.wrappedKeyId,
|
|
937
|
+
keyGenerationId: input.envelope.keyGenerationId,
|
|
938
|
+
unlockMethod: input.unlockMethod,
|
|
939
|
+
lifecycleState: input.lifecycleState,
|
|
940
|
+
createdAt: input.createdAt,
|
|
941
|
+
updatedAt: input.updatedAt,
|
|
942
|
+
rotatedAt: input.rotatedAt,
|
|
943
|
+
revokedAt: input.revokedAt,
|
|
944
|
+
replacedByWrapperId: input.replacedByWrapperId,
|
|
945
|
+
recoveryKitId: input.recoveryKitId,
|
|
946
|
+
containsPlaintextKeyMaterial: false,
|
|
947
|
+
containsPlaintextSecret: false,
|
|
948
|
+
productionCloudPersistenceImplemented: false,
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
function keyWrapperRecords(store) {
|
|
952
|
+
if (store.keyWrappers?.length) {
|
|
953
|
+
return store.keyWrappers.map((record) => ({
|
|
954
|
+
envelope: record.envelope,
|
|
955
|
+
metadata: createAppKeyWrapperMetadata(record.metadata),
|
|
956
|
+
}));
|
|
957
|
+
}
|
|
958
|
+
return [
|
|
959
|
+
{
|
|
960
|
+
envelope: store.keyWrapper,
|
|
961
|
+
metadata: createKeyWrapperMetadata({
|
|
962
|
+
envelope: store.keyWrapper,
|
|
963
|
+
unlockMethod: "master_password",
|
|
964
|
+
lifecycleState: "active",
|
|
965
|
+
createdAt: store.keyWrapper.createdAt,
|
|
966
|
+
updatedAt: store.keyWrapper.createdAt,
|
|
967
|
+
}),
|
|
968
|
+
},
|
|
969
|
+
];
|
|
970
|
+
}
|
|
971
|
+
function cloudSecretRecordsForStore(store, exportedAt) {
|
|
972
|
+
return [
|
|
973
|
+
...store.credentials.map((record) => {
|
|
974
|
+
const packaged = packageEncryptedCredentialEnvelopeForCloud(record.envelope);
|
|
975
|
+
return createCloudSecretEncryptedEnvelopeRecord({
|
|
976
|
+
type: "cloud_secret_encrypted_envelope",
|
|
977
|
+
version: 1,
|
|
978
|
+
workspaceId: store.workspaceId,
|
|
979
|
+
envelopeKind: "encrypted_credential_blob",
|
|
980
|
+
envelopeId: record.id,
|
|
981
|
+
keyGenerationId: record.envelope.keyGenerationId,
|
|
982
|
+
encryptedPayload: packaged.encryptedPayload,
|
|
983
|
+
payloadSha256: packaged.payloadSha256,
|
|
984
|
+
createdAt: record.createdAt,
|
|
985
|
+
updatedAt: exportedAt,
|
|
986
|
+
status: "uploaded",
|
|
987
|
+
productionReady: true,
|
|
988
|
+
zeroKnowledge: true,
|
|
989
|
+
containsPlaintextSecret: false,
|
|
990
|
+
containsPlaintextKeyMaterial: false,
|
|
991
|
+
storesDecryptedMaterial: false,
|
|
992
|
+
});
|
|
993
|
+
}),
|
|
994
|
+
...keyWrapperRecords(store).map((record) => {
|
|
995
|
+
const packaged = packageKeyWrapperEnvelopeForCloud(record.envelope);
|
|
996
|
+
return createCloudSecretEncryptedEnvelopeRecord({
|
|
997
|
+
type: "cloud_secret_encrypted_envelope",
|
|
998
|
+
version: 1,
|
|
999
|
+
workspaceId: store.workspaceId,
|
|
1000
|
+
envelopeKind: "encrypted_key_wrapper",
|
|
1001
|
+
envelopeId: record.envelope.wrapperId,
|
|
1002
|
+
keyGenerationId: record.envelope.keyGenerationId,
|
|
1003
|
+
encryptedPayload: packaged.encryptedPayload,
|
|
1004
|
+
payloadSha256: packaged.payloadSha256,
|
|
1005
|
+
createdAt: record.metadata.createdAt,
|
|
1006
|
+
updatedAt: exportedAt,
|
|
1007
|
+
status: "uploaded",
|
|
1008
|
+
productionReady: true,
|
|
1009
|
+
zeroKnowledge: true,
|
|
1010
|
+
containsPlaintextSecret: false,
|
|
1011
|
+
containsPlaintextKeyMaterial: false,
|
|
1012
|
+
storesDecryptedMaterial: false,
|
|
1013
|
+
});
|
|
1014
|
+
}),
|
|
1015
|
+
];
|
|
1016
|
+
}
|
|
1017
|
+
function importCloudSecretRecords(store, records) {
|
|
1018
|
+
let credentials = [...store.credentials];
|
|
1019
|
+
let wrappers = keyWrapperRecords(store);
|
|
1020
|
+
for (const record of records) {
|
|
1021
|
+
const safeRecord = createCloudSecretEncryptedEnvelopeRecord(record);
|
|
1022
|
+
const envelope = JSON.parse(Buffer.from(safeRecord.encryptedPayload, "base64url").toString("utf8"));
|
|
1023
|
+
if (safeRecord.envelopeKind === "encrypted_credential_blob") {
|
|
1024
|
+
if (envelope.type !== "credential_blob" || envelope.workspaceId !== store.workspaceId) {
|
|
1025
|
+
throw new LocalVaultCliError("InvalidCloudSecretEnvelope", "invalid encrypted credential envelope");
|
|
1026
|
+
}
|
|
1027
|
+
const parts = safeRecord.envelopeId.split(":");
|
|
1028
|
+
const imported = {
|
|
1029
|
+
id: safeRecord.envelopeId,
|
|
1030
|
+
project: parts[0] ?? "imported",
|
|
1031
|
+
environment: parts[1] ?? "imported",
|
|
1032
|
+
alias: parts[2] ?? "IMPORTED_SECRET",
|
|
1033
|
+
createdAt: safeRecord.createdAt,
|
|
1034
|
+
updatedAt: safeRecord.updatedAt,
|
|
1035
|
+
envelope,
|
|
1036
|
+
};
|
|
1037
|
+
credentials = credentials.some((candidate) => candidate.id === imported.id)
|
|
1038
|
+
? credentials.map((candidate) => candidate.id === imported.id ? { ...candidate, envelope, updatedAt: imported.updatedAt } : candidate)
|
|
1039
|
+
: [...credentials, imported];
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
if (envelope.type !== "key_wrapper" || envelope.workspaceId !== store.workspaceId) {
|
|
1043
|
+
throw new LocalVaultCliError("InvalidCloudSecretEnvelope", "invalid encrypted key wrapper envelope");
|
|
1044
|
+
}
|
|
1045
|
+
const existing = wrappers.some((candidate) => candidate.envelope.wrapperId === envelope.wrapperId);
|
|
1046
|
+
if (!existing) {
|
|
1047
|
+
wrappers = [
|
|
1048
|
+
...wrappers,
|
|
1049
|
+
{
|
|
1050
|
+
envelope,
|
|
1051
|
+
metadata: createKeyWrapperMetadata({
|
|
1052
|
+
envelope,
|
|
1053
|
+
unlockMethod: "master_password",
|
|
1054
|
+
lifecycleState: "active",
|
|
1055
|
+
createdAt: safeRecord.createdAt,
|
|
1056
|
+
updatedAt: safeRecord.updatedAt,
|
|
1057
|
+
}),
|
|
1058
|
+
},
|
|
1059
|
+
];
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return {
|
|
1063
|
+
...store,
|
|
1064
|
+
credentials,
|
|
1065
|
+
keyWrappers: wrappers,
|
|
1066
|
+
keyWrapper: wrappers.find((record) => record.metadata.unlockMethod === "master_password" &&
|
|
1067
|
+
record.metadata.lifecycleState === "active")?.envelope ?? store.keyWrapper,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
async function writeJsonFile(path, value) {
|
|
1071
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1072
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
1073
|
+
}
|
|
1074
|
+
function activeKeyWrapperRecord(store, unlockMethod) {
|
|
1075
|
+
const record = keyWrapperRecords(store).find((candidate) => candidate.metadata.unlockMethod === unlockMethod &&
|
|
1076
|
+
candidate.metadata.lifecycleState === "active");
|
|
1077
|
+
if (!record) {
|
|
1078
|
+
throw new LocalVaultCliError("MissingActiveKeyWrapper", `no active ${unlockMethod} key wrapper is recorded`);
|
|
1079
|
+
}
|
|
1080
|
+
return record;
|
|
1081
|
+
}
|
|
1082
|
+
function upsertDeviceKeyUnlock(records, next) {
|
|
1083
|
+
const existing = records.some((record) => record.deviceId === next.deviceId);
|
|
1084
|
+
return existing
|
|
1085
|
+
? records.map((record) => record.deviceId === next.deviceId ? next : record)
|
|
1086
|
+
: [...records, next];
|
|
1087
|
+
}
|
|
1088
|
+
function assertLocalVaultStore(value) {
|
|
1089
|
+
if (value.type !== "appfleet_local_vault" ||
|
|
1090
|
+
value.version !== 1 ||
|
|
1091
|
+
value.productionCloudPersistenceImplemented !== false ||
|
|
1092
|
+
!Array.isArray(value.credentials)) {
|
|
1093
|
+
throw new LocalVaultCliError("InvalidVaultStore", "invalid local vault store");
|
|
1094
|
+
}
|
|
1095
|
+
keyWrapperRecords(value);
|
|
1096
|
+
if (value.recoveryKit) {
|
|
1097
|
+
createEmergencyRecoveryKitMetadata(value.recoveryKit);
|
|
1098
|
+
}
|
|
1099
|
+
for (const deviceKeyUnlock of value.deviceKeyUnlocks ?? []) {
|
|
1100
|
+
createDeviceKeyUnlockMetadata(deviceKeyUnlock);
|
|
1101
|
+
}
|
|
1102
|
+
if (value.osKeychainIntegration) {
|
|
1103
|
+
createOsKeychainIntegrationMetadata(value.osKeychainIntegration);
|
|
1104
|
+
}
|
|
1105
|
+
for (const credential of value.credentials) {
|
|
1106
|
+
credentialMetadataForRecord(credential);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
function readKeychainProviderFlag(argv) {
|
|
1110
|
+
const provider = readFlag(argv, "--provider");
|
|
1111
|
+
if (!provider) {
|
|
1112
|
+
return undefined;
|
|
1113
|
+
}
|
|
1114
|
+
if (provider !== "macos_keychain" &&
|
|
1115
|
+
provider !== "windows_credential_manager" &&
|
|
1116
|
+
provider !== "linux_secret_service") {
|
|
1117
|
+
throw new LocalVaultCliError("InvalidKeychainProvider", "keychain provider must be macos_keychain, windows_credential_manager, or linux_secret_service");
|
|
1118
|
+
}
|
|
1119
|
+
return provider;
|
|
1120
|
+
}
|
|
1121
|
+
function compareCredentialRecords(left, right) {
|
|
1122
|
+
return (left.environment.localeCompare(right.environment) ||
|
|
1123
|
+
left.alias.localeCompare(right.alias));
|
|
1124
|
+
}
|
|
1125
|
+
function redactSecrets(value, secrets) {
|
|
1126
|
+
return secrets.reduce((redacted, secret) => secret ? redacted.split(secret).join("[REDACTED_SECRET]") : redacted, value);
|
|
1127
|
+
}
|
|
1128
|
+
function readFlag(argv, flag) {
|
|
1129
|
+
const index = argv.indexOf(flag);
|
|
1130
|
+
if (index === -1) {
|
|
1131
|
+
return undefined;
|
|
1132
|
+
}
|
|
1133
|
+
return argv[index + 1];
|
|
1134
|
+
}
|
|
1135
|
+
function readCredentialStatusFlag(argv) {
|
|
1136
|
+
const status = readFlag(argv, "--status");
|
|
1137
|
+
if (!status) {
|
|
1138
|
+
return undefined;
|
|
1139
|
+
}
|
|
1140
|
+
if (!appCredentialStatusValues.includes(status)) {
|
|
1141
|
+
throw new LocalVaultCliError("InvalidCredentialStatus", `credential status must be one of ${appCredentialStatusValues.join(",")}`);
|
|
1142
|
+
}
|
|
1143
|
+
return status;
|
|
1144
|
+
}
|
|
1145
|
+
function isFileNotFound(error) {
|
|
1146
|
+
return (typeof error === "object" &&
|
|
1147
|
+
error !== null &&
|
|
1148
|
+
"code" in error &&
|
|
1149
|
+
error.code === "ENOENT");
|
|
1150
|
+
}
|
|
1151
|
+
function commandError(error) {
|
|
1152
|
+
const code = error instanceof LocalVaultCliError
|
|
1153
|
+
? error.code
|
|
1154
|
+
: error instanceof AppFleetCryptoAuthError
|
|
1155
|
+
? "DecryptFailed"
|
|
1156
|
+
: "LocalVaultFailed";
|
|
1157
|
+
return {
|
|
1158
|
+
exitCode: 1,
|
|
1159
|
+
stdout: "",
|
|
1160
|
+
stderr: `AppFleet local vault failed: ${code}\n`,
|
|
1161
|
+
childStarted: false,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
1165
|
+
const result = await runLocalVaultCommand(process.argv.slice(2));
|
|
1166
|
+
process.stdout.write(result.stdout);
|
|
1167
|
+
process.stderr.write(result.stderr);
|
|
1168
|
+
process.exitCode = result.exitCode;
|
|
1169
|
+
}
|