getmyenv 0.9.9 → 0.11.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 +3 -3
- package/dist/commands/backup.d.ts +1 -1
- package/dist/commands/backup.js +7 -2
- package/dist/commands/claim.js +7 -8
- package/dist/commands/guest.d.ts +6 -1
- package/dist/commands/guest.js +15 -5
- package/dist/commands/import.js +4 -0
- package/dist/commands/ls.js +2 -0
- package/dist/commands/restore.d.ts +1 -1
- package/dist/commands/restore.js +15 -6
- package/dist/commands/set.js +3 -0
- package/dist/commands/start.d.ts +4 -1
- package/dist/commands/start.js +95 -14
- package/dist/commands/status.js +2 -0
- package/dist/commands/template.d.ts +19 -1
- package/dist/commands/template.js +40 -3
- package/dist/lib/authorize.d.ts +10 -0
- package/dist/lib/authorize.js +17 -6
- package/dist/lib/fs.d.ts +1 -0
- package/dist/lib/fs.js +5 -3
- package/dist/lib/recovery-code.d.ts +19 -0
- package/dist/lib/recovery-code.js +57 -0
- package/dist/lib/template-file.d.ts +34 -0
- package/dist/lib/template-file.js +85 -0
- package/dist/lib/vault.js +14 -10
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ npx getmyenv run staging -- npm start # or pick one
|
|
|
23
23
|
- `export [-o file | --stdout] [--yes]`: for tools that need a file. The file is added to `.gitignore` and gets owner-only permissions on macOS and Linux.
|
|
24
24
|
- `guest [--name <name>]`: a project without an account. It works for 30 days. Claim it within 60.
|
|
25
25
|
- `claim`: claim a guest project. Reads `.getmyenv/claim.json`, or asks for the claim code and guest passphrase that `guest` printed once at a terminal.
|
|
26
|
-
- `backup [-o file]`: encrypted backup of open contexts (default `<project>.getmyenv-backup`). It opens with the Vault password in effect when it was made.
|
|
26
|
+
- `backup [-o file]`: encrypted backup of open contexts (default `<project>.getmyenv-backup`). It opens with the Vault password or the recovery code in effect when it was made.
|
|
27
27
|
- `restore <file> [--dry-run] [--yes]`: restore a backup into this folder's project. Values go into open contexts with the same slug and replace values with the same name. Read-only and missing contexts are skipped.
|
|
28
28
|
- `start`: same as bare `getmyenv`. `start --template <id or link>` adds a template's variable names and asks for each value.
|
|
29
29
|
- `lock`: forget every unlocked context key on this machine. `unlink [--yes]`, `help [command]`
|
|
@@ -32,10 +32,10 @@ npx getmyenv run staging -- npm start # or pick one
|
|
|
32
32
|
|
|
33
33
|
Prompts and status lines go to stderr, so `export --stdout > .env` stays clean.
|
|
34
34
|
|
|
35
|
-
Commit `.getmyenv/project.json`. `token.json` and `claim.json` stay local and are gitignored.
|
|
35
|
+
Commit `.getmyenv/project.json` and `.getmyenv/template.json`. `token.json` and `claim.json` stay local and are gitignored. `template.json` lists the variable names, never values. In a fresh clone, `npx getmyenv` can start a new project from it and asks for each value.
|
|
36
36
|
|
|
37
37
|
<!-- cli:help -->
|
|
38
|
-
Output of `npx getmyenv --help` (0.
|
|
38
|
+
Output of `npx getmyenv --help` (0.11.0):
|
|
39
39
|
|
|
40
40
|
```text
|
|
41
41
|
Usage: getmyenv [options] [command]
|
|
@@ -2,6 +2,6 @@ type BackupOpts = {
|
|
|
2
2
|
output?: string;
|
|
3
3
|
apiUrl?: string;
|
|
4
4
|
};
|
|
5
|
-
/** Encrypted backup of open contexts. Opens offline with the current Vault password. */
|
|
5
|
+
/** Encrypted backup of open contexts. Opens offline with the current Vault password or recovery code. */
|
|
6
6
|
export declare function backupCommand(opts: BackupOpts): Promise<void>;
|
|
7
7
|
export {};
|
package/dist/commands/backup.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createBackupEnvelope, parsePayload, parseWrappedKey, serializeBackup, }
|
|
|
4
4
|
import { slugify } from "@getmyenv/shared";
|
|
5
5
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
6
6
|
import { writePrivateFile } from "../lib/fs.js";
|
|
7
|
-
/** Encrypted backup of open contexts. Opens offline with the current Vault password. */
|
|
7
|
+
/** Encrypted backup of open contexts. Opens offline with the current Vault password or recovery code. */
|
|
8
8
|
export async function backupCommand(opts) {
|
|
9
9
|
const { client } = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
|
|
10
10
|
const data = await client.request("GET", "/api/cli/backup");
|
|
@@ -15,6 +15,9 @@ export async function backupCommand(opts) {
|
|
|
15
15
|
projectName: data.projectName,
|
|
16
16
|
publicKey: data.publicKey,
|
|
17
17
|
wrappedPrivateKey: parseWrappedKey(JSON.stringify(data.wrappedPrivateKey)),
|
|
18
|
+
recoveryWrappedKey: data.recoveryWrappedKey
|
|
19
|
+
? parseWrappedKey(JSON.stringify(data.recoveryWrappedKey))
|
|
20
|
+
: null,
|
|
18
21
|
contexts: data.contexts,
|
|
19
22
|
keys: data.keys,
|
|
20
23
|
tags: data.tags,
|
|
@@ -29,5 +32,7 @@ export async function backupCommand(opts) {
|
|
|
29
32
|
path.join(process.cwd(), `${slugify(data.projectName) || "project"}.getmyenv-backup`);
|
|
30
33
|
writePrivateFile(out, serializeBackup(envelope), { overwrite: false });
|
|
31
34
|
console.log(`${pc.green("✓")} Encrypted backup: ${out} (${data.values.length} values, ${data.contexts.length} open contexts)`);
|
|
32
|
-
console.log(pc.dim(
|
|
35
|
+
console.log(pc.dim(data.recoveryWrappedKey
|
|
36
|
+
? "It opens with your current Vault password or recovery code. Read-only contexts are backed up from the dashboard."
|
|
37
|
+
: "It opens with your current Vault password. Read-only contexts are backed up from the dashboard."));
|
|
33
38
|
}
|
package/dist/commands/claim.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
2
|
import { GUEST_GRACE_DAYS } from "@getmyenv/shared";
|
|
3
|
-
import {
|
|
3
|
+
import { openSealedKey, sealContextKey, unwrapPrivateKey, wipe, } from "@getmyenv/crypto";
|
|
4
4
|
import { clearClaimFile, ensureGitignoreHasEnv, promptHidden, readClaimFile, readLine, readProjectMeta, writeClaimFile, writeProjectMeta, } from "../lib/fs.js";
|
|
5
5
|
import { readFolderToken, writeFolderToken } from "../lib/credentials.js";
|
|
6
6
|
import { ApiClient, ApiError, resolveApiUrl } from "../lib/api.js";
|
|
7
7
|
import { authorizeFolder, openBrowser, sleep } from "../lib/authorize.js";
|
|
8
|
-
import {
|
|
9
|
-
async function
|
|
10
|
-
console.error(pc.dim("Choose a Vault password. It stays on this machine. We store an encrypted key blob only.\n"));
|
|
8
|
+
import { createAccountKeys } from "../lib/recovery-code.js";
|
|
9
|
+
async function askNewAccountKeys() {
|
|
11
10
|
try {
|
|
12
|
-
return await
|
|
11
|
+
return await createAccountKeys();
|
|
13
12
|
}
|
|
14
13
|
catch (err) {
|
|
15
14
|
throw new Error(`${err instanceof Error ? err.message : "Vault password required."} Run claim again.`);
|
|
@@ -38,10 +37,10 @@ async function finishClaim(owned, claim) {
|
|
|
38
37
|
let keys;
|
|
39
38
|
try {
|
|
40
39
|
if (!ownerPublicKey) {
|
|
41
|
-
const created = await
|
|
40
|
+
const created = await askNewAccountKeys();
|
|
42
41
|
wipe(created.privateKey);
|
|
43
42
|
ownerPublicKey = created.publicKey;
|
|
44
|
-
keys =
|
|
43
|
+
keys = created.keys;
|
|
45
44
|
}
|
|
46
45
|
const shares = vault.claim.shares.map((s) => {
|
|
47
46
|
const key = openSealedKey(s.sealedKey, guestKey, s.contextId);
|
|
@@ -63,7 +62,7 @@ async function finishClaim(owned, claim) {
|
|
|
63
62
|
}
|
|
64
63
|
clearClaimFile();
|
|
65
64
|
ensureGitignoreHasEnv();
|
|
66
|
-
console.error(`\n${pc.green("✓")} Claimed ${pc.bold(claim.projectName)}. ${keys ? "Vault password set." : "It opens with your Vault password."}`);
|
|
65
|
+
console.error(`\n${pc.green("✓")} Claimed ${pc.bold(claim.projectName)}. ${keys ? "Vault password and recovery code set." : "It opens with your Vault password."}`);
|
|
67
66
|
console.error(pc.dim("claim.json was removed. Read-only contexts like Production are now available in the dashboard.\n"));
|
|
68
67
|
}
|
|
69
68
|
/** A signed-in folder token for the claimed project: reuse this folder's, or sign in. */
|
package/dist/commands/guest.d.ts
CHANGED
|
@@ -2,13 +2,17 @@ export type GuestOpts = {
|
|
|
2
2
|
name?: string;
|
|
3
3
|
apiUrl?: string;
|
|
4
4
|
};
|
|
5
|
+
type CreateGuestOpts = GuestOpts & {
|
|
6
|
+
/** New project from the template file: project.json may point to another project, and is replaced once this one exists. */
|
|
7
|
+
replaceProjectMeta?: boolean;
|
|
8
|
+
};
|
|
5
9
|
export declare function folderNameHint(): string;
|
|
6
10
|
/**
|
|
7
11
|
* Create an unclaimed guest project with one open context, Development.
|
|
8
12
|
* Writes token.json and claim.json (gitignored) and project.json (committed).
|
|
9
13
|
* claim.json holds the guest passphrase that wraps the guest key until claim.
|
|
10
14
|
*/
|
|
11
|
-
export declare function createGuestProject(opts?:
|
|
15
|
+
export declare function createGuestProject(opts?: CreateGuestOpts): Promise<{
|
|
12
16
|
projectId: string;
|
|
13
17
|
projectName: string;
|
|
14
18
|
guestExpiresAt: string;
|
|
@@ -16,3 +20,4 @@ export declare function createGuestProject(opts?: GuestOpts): Promise<{
|
|
|
16
20
|
/** The steps printed after guest, in plain text. Runtime first, export only as an alternative. */
|
|
17
21
|
export declare function guestNextLines(): string[];
|
|
18
22
|
export declare function guestCommand(opts?: GuestOpts): Promise<void>;
|
|
23
|
+
export {};
|
package/dist/commands/guest.js
CHANGED
|
@@ -2,10 +2,11 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import { createUserKeys, wipe } from "@getmyenv/crypto";
|
|
5
|
-
import { FACTS } from "@getmyenv/shared";
|
|
5
|
+
import { FACTS, TEMPLATE_FILE } from "@getmyenv/shared";
|
|
6
6
|
import { ensureGitignoreHasEnv, readProjectMeta, writeClaimFile, writeProjectMeta, } from "../lib/fs.js";
|
|
7
7
|
import { readFolderToken, writeFolderToken } from "../lib/credentials.js";
|
|
8
8
|
import { ApiClient, resolveApiUrl } from "../lib/api.js";
|
|
9
|
+
import { readTemplateFile } from "../lib/template-file.js";
|
|
9
10
|
export function folderNameHint() {
|
|
10
11
|
return path.basename(process.cwd()) || "project";
|
|
11
12
|
}
|
|
@@ -15,7 +16,7 @@ export function folderNameHint() {
|
|
|
15
16
|
* claim.json holds the guest passphrase that wraps the guest key until claim.
|
|
16
17
|
*/
|
|
17
18
|
export async function createGuestProject(opts = {}) {
|
|
18
|
-
if (readFolderToken() || readProjectMeta()?.projectId) {
|
|
19
|
+
if (readFolderToken() || (readProjectMeta()?.projectId && !opts.replaceProjectMeta)) {
|
|
19
20
|
throw new Error("This folder is already linked. Run: npx getmyenv (or npx getmyenv unlink first)");
|
|
20
21
|
}
|
|
21
22
|
const apiUrl = resolveApiUrl(opts.apiUrl);
|
|
@@ -50,14 +51,14 @@ export async function createGuestProject(opts = {}) {
|
|
|
50
51
|
ensureGitignoreHasEnv();
|
|
51
52
|
const daysLeft = Math.max(0, Math.ceil((new Date(created.guestExpiresAt).getTime() - Date.now()) / 86_400_000));
|
|
52
53
|
console.error(`${pc.green("✓")} Guest project ${pc.bold(created.projectName)}. Context: development.`);
|
|
53
|
-
console.error(pc.dim(`Expires in ${daysLeft} days unless claimed.
|
|
54
|
+
console.error(pc.dim(`Expires in ${daysLeft} days unless claimed. ${FACTS.committedFiles}`));
|
|
54
55
|
if (process.stdin.isTTY) {
|
|
55
56
|
console.error(pc.dim("claim.json holds the claim code and guest passphrase. Shown once, save them to claim later:"));
|
|
56
57
|
console.error(` ${pc.dim("Claim code:")} ${created.claimCode}`);
|
|
57
58
|
console.error(` ${pc.dim("Guest passphrase:")} ${passphrase}\n`);
|
|
58
59
|
}
|
|
59
60
|
else {
|
|
60
|
-
console.error(pc.dim(`claim.json holds the claim code and guest passphrase. It is the only copy. Keep it private. ${FACTS.claimLoss}\n`));
|
|
61
|
+
console.error(pc.dim(`claim.json holds the claim code and guest passphrase. It is the only copy. Keep it private. ${FACTS.claimLoss} ${FACTS.templateFileRecovery}\n`));
|
|
61
62
|
}
|
|
62
63
|
return created;
|
|
63
64
|
}
|
|
@@ -87,7 +88,16 @@ function colorNextLine(line) {
|
|
|
87
88
|
}
|
|
88
89
|
export async function guestCommand(opts = {}) {
|
|
89
90
|
console.error(pc.bold("\ngetmyenv guest\n"));
|
|
90
|
-
|
|
91
|
+
const read = readTemplateFile();
|
|
92
|
+
if (read && !read.ok)
|
|
93
|
+
console.error(pc.yellow(`${TEMPLATE_FILE} is not valid (${read.reason}). Not used.\n`));
|
|
94
|
+
const file = read?.ok ? read.file : null;
|
|
95
|
+
const name = opts.name ?? file?.name;
|
|
96
|
+
await createGuestProject({ ...opts, ...(name ? { name } : {}) });
|
|
97
|
+
if (file && file.keys.length > 0) {
|
|
98
|
+
const { applyTemplateFile } = await import("./template.js");
|
|
99
|
+
await applyTemplateFile(file, { apiUrl: opts.apiUrl, ask: false });
|
|
100
|
+
}
|
|
91
101
|
for (const line of guestNextLines())
|
|
92
102
|
console.error(colorNextLine(line));
|
|
93
103
|
console.error("");
|
package/dist/commands/import.js
CHANGED
|
@@ -7,6 +7,7 @@ import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
|
7
7
|
import { defaultContextSlug, findContext, resolveContext } from "../lib/context.js";
|
|
8
8
|
import { confirmDiff } from "../lib/confirm.js";
|
|
9
9
|
import { printDidYouMean } from "../lib/known-keys.js";
|
|
10
|
+
import { syncFromLinked } from "../lib/template-file.js";
|
|
10
11
|
import { uploadValues } from "../lib/upload.js";
|
|
11
12
|
import { runLine } from "./start.js";
|
|
12
13
|
/** `.env` before `.env.local`, so local files win on merge. */
|
|
@@ -96,6 +97,7 @@ export async function importCommand(file, opts) {
|
|
|
96
97
|
let imported = 0;
|
|
97
98
|
let changed = 0;
|
|
98
99
|
let skipped = 0;
|
|
100
|
+
const added = [];
|
|
99
101
|
for (const target of targets) {
|
|
100
102
|
const label = target.files.map((f) => path.relative(process.cwd(), f) || f).join(" + ");
|
|
101
103
|
const ctx = await ensureTargetContext(linked, target.slug, opts);
|
|
@@ -153,10 +155,12 @@ export async function importCommand(file, opts) {
|
|
|
153
155
|
});
|
|
154
156
|
imported += res.set.length;
|
|
155
157
|
changed += res.set.length + res.declared.length + res.pruned.length;
|
|
158
|
+
added.push(...res.set, ...res.declared);
|
|
156
159
|
console.error(`${pc.green("✓")} ${res.set.length} set, ${res.declared.length} declared${res.pruned.length ? `, ${res.pruned.length} unset` : ""} in ${ctx.slug}\n`);
|
|
157
160
|
}
|
|
158
161
|
if (opts.dryRun)
|
|
159
162
|
return;
|
|
163
|
+
syncFromLinked(linked, { added });
|
|
160
164
|
if (changed === 0 && skipped > 0) {
|
|
161
165
|
throw new Error("Nothing was imported. See the skipped files above.");
|
|
162
166
|
}
|
package/dist/commands/ls.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
2
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
3
3
|
import { resolveContext } from "../lib/context.js";
|
|
4
|
+
import { syncFromLinked } from "../lib/template-file.js";
|
|
4
5
|
const CELL = {
|
|
5
6
|
set: "set",
|
|
6
7
|
expiring_soon: "expiring",
|
|
@@ -17,6 +18,7 @@ function paint(status, text) {
|
|
|
17
18
|
/** Key names by context with status. Never values. */
|
|
18
19
|
export async function lsCommand(opts) {
|
|
19
20
|
const { who, meta } = await ensureTokenAndLink({ apiUrl: opts.apiUrl, quiet: true });
|
|
21
|
+
syncFromLinked({ who });
|
|
20
22
|
const contexts = opts.context ? [resolveContext(who, meta, opts.context)] : who.contexts;
|
|
21
23
|
if (who.keys.length === 0) {
|
|
22
24
|
console.log(pc.dim("No variables yet. Add one: npx getmyenv set API_KEY"));
|
|
@@ -5,7 +5,7 @@ type RestoreOpts = {
|
|
|
5
5
|
};
|
|
6
6
|
/**
|
|
7
7
|
* Restore a backup into this folder's project. The backup opens locally with
|
|
8
|
-
* the Vault password it was made with. Values are encrypted again with this
|
|
8
|
+
* the Vault password or recovery code it was made with. Values are encrypted again with this
|
|
9
9
|
* project's context keys. Read-only and unknown contexts are skipped.
|
|
10
10
|
*/
|
|
11
11
|
export declare function restoreCommand(file: string, opts: RestoreOpts): Promise<void>;
|
package/dist/commands/restore.js
CHANGED
|
@@ -5,10 +5,11 @@ import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
|
5
5
|
import { findContext } from "../lib/context.js";
|
|
6
6
|
import { promptConfirm } from "../lib/fs.js";
|
|
7
7
|
import { resolveVaultPassword } from "../lib/guest-key.js";
|
|
8
|
+
import { syncFromLinked } from "../lib/template-file.js";
|
|
8
9
|
import { writeKey } from "../lib/vault.js";
|
|
9
10
|
/**
|
|
10
11
|
* Restore a backup into this folder's project. The backup opens locally with
|
|
11
|
-
* the Vault password it was made with. Values are encrypted again with this
|
|
12
|
+
* the Vault password or recovery code it was made with. Values are encrypted again with this
|
|
12
13
|
* project's context keys. Read-only and unknown contexts are skipped.
|
|
13
14
|
*/
|
|
14
15
|
export async function restoreCommand(file, opts) {
|
|
@@ -32,16 +33,23 @@ export async function restoreCommand(file, opts) {
|
|
|
32
33
|
if (who.token.kind !== "folder") {
|
|
33
34
|
throw new Error("Restore needs a signed-in folder token. Guest and server tokens cannot restore.");
|
|
34
35
|
}
|
|
35
|
-
const
|
|
36
|
+
const secret = await resolveVaultPassword({
|
|
36
37
|
isGuest: false,
|
|
37
|
-
prompt:
|
|
38
|
+
prompt: envelope.recoveryWrappedKey
|
|
39
|
+
? "Vault password or recovery code for this backup (hidden): "
|
|
40
|
+
: "Vault password for this backup (hidden): ",
|
|
38
41
|
});
|
|
39
42
|
let opened;
|
|
40
43
|
try {
|
|
41
|
-
opened = await restoreValuesFromBackup(
|
|
44
|
+
opened = await restoreValuesFromBackup(secret, envelope);
|
|
42
45
|
}
|
|
43
|
-
catch {
|
|
44
|
-
|
|
46
|
+
catch (err) {
|
|
47
|
+
const msg = err instanceof Error ? err.message : "";
|
|
48
|
+
if (/^(That recovery code|This backup|A recovery code)/.test(msg))
|
|
49
|
+
throw new Error(msg);
|
|
50
|
+
throw new Error(envelope.recoveryWrappedKey
|
|
51
|
+
? "Could not open the backup. Check the Vault password or recovery code."
|
|
52
|
+
: "Could not open the backup. Check the Vault password.");
|
|
45
53
|
}
|
|
46
54
|
console.error(`Backup checked: ${opened.values.length} values in ${envelope.contexts.length} contexts.\n`);
|
|
47
55
|
const targets = new Map();
|
|
@@ -93,6 +101,7 @@ export async function restoreCommand(file, opts) {
|
|
|
93
101
|
values,
|
|
94
102
|
keyVersions,
|
|
95
103
|
});
|
|
104
|
+
syncFromLinked(linked, { added: [...envelope.keys, ...values.map((v) => v.key)] });
|
|
96
105
|
const skipped = [...perContext.keys()].filter((slug) => !writable(slug));
|
|
97
106
|
console.log(`${pc.green("✓")} Restored ${res.restored} values into ${res.projectName}`);
|
|
98
107
|
if (skipped.length > 0) {
|
package/dist/commands/set.js
CHANGED
|
@@ -4,6 +4,7 @@ import { promptHidden } from "../lib/fs.js";
|
|
|
4
4
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
5
5
|
import { resolveContext } from "../lib/context.js";
|
|
6
6
|
import { printDidYouMean } from "../lib/known-keys.js";
|
|
7
|
+
import { syncFromLinked } from "../lib/template-file.js";
|
|
7
8
|
import { assertWritable, uploadValues } from "../lib/upload.js";
|
|
8
9
|
async function readStdinValue() {
|
|
9
10
|
const chunks = [];
|
|
@@ -75,6 +76,7 @@ export async function setCommand(args, opts) {
|
|
|
75
76
|
for (const name of res.declared)
|
|
76
77
|
console.log(`${pc.yellow("○")} ${name} ${pc.dim(`declared, no value in ${ctx.slug}`)}`);
|
|
77
78
|
printDidYouMean(pairs.map((p) => p.name).filter((n) => !linked.who.keys.some((k) => k.name === n)));
|
|
79
|
+
syncFromLinked(linked, { added: [...res.set, ...res.declared] });
|
|
78
80
|
}
|
|
79
81
|
export async function unsetCommand(names, opts) {
|
|
80
82
|
for (const n of names) {
|
|
@@ -95,4 +97,5 @@ export async function unsetCommand(names, opts) {
|
|
|
95
97
|
const skipped = names.filter((n) => !res.removed.includes(n));
|
|
96
98
|
if (skipped.length > 0)
|
|
97
99
|
console.log(pc.dim(`No change: ${skipped.join(", ")}`));
|
|
100
|
+
syncFromLinked(linked, opts.all ? { removed: res.removed } : {});
|
|
98
101
|
}
|
package/dist/commands/start.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type PublicTemplate } from "@getmyenv/shared";
|
|
1
|
+
import { type PublicTemplate, type TemplateFile } from "@getmyenv/shared";
|
|
2
|
+
import { type ProjectMeta } from "../lib/fs.js";
|
|
2
3
|
/** Best guess at the app's start command, for the printed `run` line. */
|
|
3
4
|
export declare function suggestedStartCommand(cwd?: string): string;
|
|
4
5
|
export declare function runLine(context: string | null, cwd?: string, startCommand?: string | null): string;
|
|
@@ -11,6 +12,8 @@ export declare function startSetup(opts: {
|
|
|
11
12
|
thenContinue?: boolean;
|
|
12
13
|
template?: PublicTemplate;
|
|
13
14
|
}): Promise<void>;
|
|
15
|
+
/** The two ways to start when project.json and template.json both exist. Sign in comes first. */
|
|
16
|
+
export declare function startChoices(existing: ProjectMeta, file: TemplateFile): [string, string];
|
|
14
17
|
/**
|
|
15
18
|
* Bare `getmyenv`: setup when unlinked (terminal only), status when linked.
|
|
16
19
|
* With --template, add the template's names and ask for their values.
|
package/dist/commands/start.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import pc from "picocolors";
|
|
4
|
-
import { DEFAULT_CONTEXT_SLUG, GUEST_TOTAL_DAYS } from "@getmyenv/shared";
|
|
4
|
+
import { DEFAULT_CONTEXT_SLUG, FACTS, GUEST_TOTAL_DAYS, TEMPLATE_FILE, } from "@getmyenv/shared";
|
|
5
5
|
import { promptChoice, promptConfirm, readClaimFile, readLine, readProjectMeta, rootEnvFiles, writeProjectMeta, } from "../lib/fs.js";
|
|
6
6
|
import { ApiClient, getSessionToken, resolveApiUrl } from "../lib/api.js";
|
|
7
7
|
import { authorizeFolder } from "../lib/authorize.js";
|
|
8
8
|
import { NOT_LINKED_STEPS } from "../lib/bootstrap.js";
|
|
9
|
+
import { readTemplateFile } from "../lib/template-file.js";
|
|
9
10
|
import { createGuestProject, folderNameHint } from "./guest.js";
|
|
10
11
|
import { statusCommand } from "./status.js";
|
|
11
|
-
import { applyTemplate, fetchTemplate, printTemplate } from "./template.js";
|
|
12
|
+
import { applyTemplate, applyTemplateFile, fetchTemplate, printTemplate } from "./template.js";
|
|
12
13
|
/** Best guess at the app's start command, for the printed `run` line. */
|
|
13
14
|
export function suggestedStartCommand(cwd = process.cwd()) {
|
|
14
15
|
const pkgFile = path.join(cwd, "package.json");
|
|
@@ -70,6 +71,20 @@ export async function startSetup(opts) {
|
|
|
70
71
|
if (readClaimFile()) {
|
|
71
72
|
throw new Error("This folder has a guest project (claim.json) but no token on this machine. Claim it: npx getmyenv claim");
|
|
72
73
|
}
|
|
74
|
+
const file = opts.template ? null : loadTemplateFile();
|
|
75
|
+
if (file && existing?.projectId) {
|
|
76
|
+
const choices = startChoices(existing, file);
|
|
77
|
+
const pick = await promptChoice("How do you want to start?", choices, 0);
|
|
78
|
+
console.error("");
|
|
79
|
+
if (pick === 1) {
|
|
80
|
+
await startFromTemplateFile(file, { apiUrl, previous: existing, thenContinue: opts.thenContinue });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (file) {
|
|
85
|
+
await startFromTemplateFile(file, { apiUrl, thenContinue: opts.thenContinue });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
73
88
|
if (existing?.projectId) {
|
|
74
89
|
console.error(`This folder uses ${pc.bold(existing.projectName ?? existing.projectId)}. Sign in to get a token.\n`);
|
|
75
90
|
await authorizeFolder({ apiUrl });
|
|
@@ -104,18 +119,7 @@ export async function startSetup(opts) {
|
|
|
104
119
|
printRunLine(applied.context, opts.template.startCommand);
|
|
105
120
|
return;
|
|
106
121
|
}
|
|
107
|
-
|
|
108
|
-
if (files.length > 0) {
|
|
109
|
-
const rel = files.map((f) => path.basename(f)).join(", ");
|
|
110
|
-
const ok = await promptConfirm(`Import ${rel}? Values are encrypted before upload.`, {
|
|
111
|
-
defaultYes: true,
|
|
112
|
-
});
|
|
113
|
-
if (ok) {
|
|
114
|
-
const { importCommand } = await import("./import.js");
|
|
115
|
-
await importCommand(undefined, { apiUrl, yes: true, noRunLine: true });
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
122
|
+
if (!(await offerEnvImport(apiUrl))) {
|
|
119
123
|
console.error("No .env file in this folder. Add values with:\n");
|
|
120
124
|
console.error(pc.cyan(" npx getmyenv set API_KEY"));
|
|
121
125
|
if (dashboardUrl)
|
|
@@ -125,6 +129,83 @@ export async function startSetup(opts) {
|
|
|
125
129
|
if (!opts.thenContinue)
|
|
126
130
|
printRunLine(context);
|
|
127
131
|
}
|
|
132
|
+
/** Offer to import the .env files in this folder. False when there are none. */
|
|
133
|
+
async function offerEnvImport(apiUrl) {
|
|
134
|
+
const files = rootEnvFiles();
|
|
135
|
+
if (files.length === 0)
|
|
136
|
+
return false;
|
|
137
|
+
const rel = files.map((f) => path.basename(f)).join(", ");
|
|
138
|
+
const ok = await promptConfirm(`Import ${rel}? Values are encrypted before upload.`, {
|
|
139
|
+
defaultYes: true,
|
|
140
|
+
});
|
|
141
|
+
if (ok) {
|
|
142
|
+
const { importCommand } = await import("./import.js");
|
|
143
|
+
await importCommand(undefined, { apiUrl, yes: true, noRunLine: true });
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
/** A template file that cannot be read is reported and not used. */
|
|
148
|
+
function loadTemplateFile() {
|
|
149
|
+
const read = readTemplateFile();
|
|
150
|
+
if (!read)
|
|
151
|
+
return null;
|
|
152
|
+
if (!read.ok) {
|
|
153
|
+
console.error(pc.yellow(`${TEMPLATE_FILE} is not valid (${read.reason}). Setup continues without it.\n`));
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
return read.file;
|
|
157
|
+
}
|
|
158
|
+
/** The two ways to start when project.json and template.json both exist. Sign in comes first. */
|
|
159
|
+
export function startChoices(existing, file) {
|
|
160
|
+
const n = file.keys.length;
|
|
161
|
+
return [
|
|
162
|
+
`Sign in to ${existing.projectName ?? existing.projectId}`,
|
|
163
|
+
`New project from ${TEMPLATE_FILE} (${n} variable${n === 1 ? "" : "s"}). ${FACTS.templateFileNewProject}`,
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* A new project with the names in the template file: guest or sign in, then
|
|
168
|
+
* the .env import offer, then the names are declared and each missing value
|
|
169
|
+
* is asked. project.json changes only once the new project exists.
|
|
170
|
+
*/
|
|
171
|
+
async function startFromTemplateFile(file, opts) {
|
|
172
|
+
const n = file.keys.length;
|
|
173
|
+
console.error(`${TEMPLATE_FILE} lists ${n} variable${n === 1 ? "" : "s"}. Values are asked after setup.\n`);
|
|
174
|
+
const choice = await promptChoice("How do you want to start?", [`Start without an account (guest project, claim within ${GUEST_TOTAL_DAYS} days)`, "Sign in"], 0);
|
|
175
|
+
console.error("");
|
|
176
|
+
const suggested = file.name ?? folderNameHint();
|
|
177
|
+
let project;
|
|
178
|
+
let fresh;
|
|
179
|
+
if (choice === 0) {
|
|
180
|
+
const name = (await readLine(`Project name ${pc.dim(`[${suggested}]`)}: `)).trim() || suggested;
|
|
181
|
+
const created = await createGuestProject({
|
|
182
|
+
name,
|
|
183
|
+
apiUrl: opts.apiUrl,
|
|
184
|
+
replaceProjectMeta: Boolean(opts.previous?.projectId),
|
|
185
|
+
});
|
|
186
|
+
project = { id: created.projectId, name: created.projectName };
|
|
187
|
+
fresh = true;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const who = await authorizeFolder({ apiUrl: opts.apiUrl, newProject: { name: suggested } });
|
|
191
|
+
const meta = readProjectMeta();
|
|
192
|
+
if (meta)
|
|
193
|
+
writeProjectMeta({ ...meta, context: defaultContextFor(who) });
|
|
194
|
+
project = who.project;
|
|
195
|
+
fresh = !who.token.member && who.keys.length === 0;
|
|
196
|
+
}
|
|
197
|
+
const before = opts.previous;
|
|
198
|
+
if (before?.projectId && before.projectId !== project.id) {
|
|
199
|
+
console.error(pc.dim(`project.json now points to ${project.name}. It pointed to ${before.projectName ?? before.projectId}.`));
|
|
200
|
+
}
|
|
201
|
+
if (fresh) {
|
|
202
|
+
console.error(`New project ${pc.bold(project.name)} with the names from ${TEMPLATE_FILE}. ${FACTS.templateFileNewProject}\n`);
|
|
203
|
+
}
|
|
204
|
+
await offerEnvImport(opts.apiUrl);
|
|
205
|
+
const applied = await applyTemplateFile(file, { apiUrl: opts.apiUrl });
|
|
206
|
+
if (!opts.thenContinue)
|
|
207
|
+
printRunLine(applied.context, file.startCommand);
|
|
208
|
+
}
|
|
128
209
|
function printRunLine(context, startCommand) {
|
|
129
210
|
console.error("Start your app with:\n");
|
|
130
211
|
console.error(pc.cyan(` ${runLine(context, process.cwd(), startCommand)}\n`));
|
package/dist/commands/status.js
CHANGED
|
@@ -3,6 +3,7 @@ import { NEW_MACHINE_POLICY_LABELS } from "@getmyenv/shared";
|
|
|
3
3
|
import { isDefaultApiUrl } from "../lib/api.js";
|
|
4
4
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
5
5
|
import { defaultContextSlug } from "../lib/context.js";
|
|
6
|
+
import { syncFromLinked } from "../lib/template-file.js";
|
|
6
7
|
import { runLine } from "./start.js";
|
|
7
8
|
const TOKEN_KIND = {
|
|
8
9
|
folder: "folder (open contexts, read and write)",
|
|
@@ -12,6 +13,7 @@ const TOKEN_KIND = {
|
|
|
12
13
|
/** Project, token, contexts with value counts, and the run command. Names only. */
|
|
13
14
|
export async function statusCommand(opts = {}) {
|
|
14
15
|
const { who, apiUrl, meta } = await ensureTokenAndLink({ apiUrl: opts.apiUrl });
|
|
16
|
+
syncFromLinked({ who });
|
|
15
17
|
const current = defaultContextSlug(who, meta);
|
|
16
18
|
console.log(`${pc.bold(who.project.name)}${who.user ? pc.dim(` ${who.user.email}`) : ""}`);
|
|
17
19
|
console.log(pc.dim(who.token.member
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { type PublicTemplate } from "@getmyenv/shared";
|
|
1
|
+
import { type PublicTemplate, type TemplateFile, type TemplateKey } from "@getmyenv/shared";
|
|
2
|
+
import { type ContextInfo } from "../lib/api.js";
|
|
3
|
+
import { type Linked } from "../lib/bootstrap.js";
|
|
2
4
|
/** Load a public template by id or URL. No token needed. */
|
|
3
5
|
export declare function fetchTemplate(ref: string, apiUrl?: string): Promise<PublicTemplate>;
|
|
4
6
|
export declare function printTemplate(template: PublicTemplate): void;
|
|
@@ -11,3 +13,19 @@ export declare function applyTemplate(template: PublicTemplate, opts: {
|
|
|
11
13
|
}): Promise<{
|
|
12
14
|
context: string;
|
|
13
15
|
}>;
|
|
16
|
+
/**
|
|
17
|
+
* Declare the template file's names in the linked project, then ask for each
|
|
18
|
+
* value that is still missing. Without a terminal, list them instead.
|
|
19
|
+
* Values already set, for example from a .env import, are not touched.
|
|
20
|
+
*/
|
|
21
|
+
export declare function applyTemplateFile(file: TemplateFile, opts: {
|
|
22
|
+
apiUrl?: string;
|
|
23
|
+
ask?: boolean;
|
|
24
|
+
}): Promise<{
|
|
25
|
+
context: string;
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* Ask for each value still missing in ctx, hidden. Enter skips one and sends
|
|
29
|
+
* nothing. `linked` must be fresh, so values set a moment ago are not asked.
|
|
30
|
+
*/
|
|
31
|
+
export declare function askMissingValues(keys: TemplateKey[], linked: Linked, ctx: ContextInfo): Promise<void>;
|
|
@@ -4,6 +4,7 @@ import { promptHidden } from "../lib/fs.js";
|
|
|
4
4
|
import { ApiClient, getSessionToken, resolveApiUrl } from "../lib/api.js";
|
|
5
5
|
import { ensureTokenAndLink } from "../lib/bootstrap.js";
|
|
6
6
|
import { resolveContext } from "../lib/context.js";
|
|
7
|
+
import { syncFromLinked } from "../lib/template-file.js";
|
|
7
8
|
import { assertWritable, uploadValues } from "../lib/upload.js";
|
|
8
9
|
/** Load a public template by id or URL. No token needed. */
|
|
9
10
|
export async function fetchTemplate(ref, apiUrl) {
|
|
@@ -35,11 +36,48 @@ export async function applyTemplate(template, opts) {
|
|
|
35
36
|
const linked = await ensureTokenAndLink({ apiUrl, quiet: true });
|
|
36
37
|
const ctx = resolveContext(linked.who, linked.meta, undefined);
|
|
37
38
|
assertWritable(linked, ctx);
|
|
39
|
+
syncFromLinked(linked, { added: template.keys.map((k) => k.name) });
|
|
40
|
+
await askMissingValues(template.keys, linked, ctx);
|
|
41
|
+
return { context: ctx.slug };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Declare the template file's names in the linked project, then ask for each
|
|
45
|
+
* value that is still missing. Without a terminal, list them instead.
|
|
46
|
+
* Values already set, for example from a .env import, are not touched.
|
|
47
|
+
*/
|
|
48
|
+
export async function applyTemplateFile(file, opts) {
|
|
49
|
+
const linked = await ensureTokenAndLink({ apiUrl: opts.apiUrl, quiet: true });
|
|
50
|
+
const ctx = resolveContext(linked.who, linked.meta, undefined);
|
|
51
|
+
assertWritable(linked, ctx);
|
|
52
|
+
const names = file.keys.map((k) => k.name);
|
|
53
|
+
if (names.length > 0) {
|
|
54
|
+
await linked.client.request("POST", "/api/cli/values", { context: ctx.slug, values: [], declare: names });
|
|
55
|
+
}
|
|
56
|
+
syncFromLinked(linked, { added: names });
|
|
57
|
+
if (opts.ask ?? process.stdin.isTTY === true) {
|
|
58
|
+
await askMissingValues(file.keys, linked, ctx);
|
|
59
|
+
return { context: ctx.slug };
|
|
60
|
+
}
|
|
61
|
+
const todo = missingIn(file.keys, linked, ctx);
|
|
62
|
+
if (todo.length > 0) {
|
|
63
|
+
console.error(`No value yet in ${ctx.slug}: ${todo.map((k) => k.name).join(", ")}`);
|
|
64
|
+
console.error("Set each one: npx getmyenv set NAME\n");
|
|
65
|
+
}
|
|
66
|
+
return { context: ctx.slug };
|
|
67
|
+
}
|
|
68
|
+
function missingIn(keys, linked, ctx) {
|
|
38
69
|
const status = new Map(linked.who.keys.map((k) => [k.name, k.status[ctx.slug] ?? "missing"]));
|
|
39
|
-
|
|
70
|
+
return keys.filter((k) => (status.get(k.name) ?? "missing") === "missing");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Ask for each value still missing in ctx, hidden. Enter skips one and sends
|
|
74
|
+
* nothing. `linked` must be fresh, so values set a moment ago are not asked.
|
|
75
|
+
*/
|
|
76
|
+
export async function askMissingValues(keys, linked, ctx) {
|
|
77
|
+
const todo = missingIn(keys, linked, ctx);
|
|
40
78
|
if (todo.length === 0) {
|
|
41
79
|
console.error(`${pc.green("✓")} Every template variable has a value in ${ctx.slug}.`);
|
|
42
|
-
return
|
|
80
|
+
return;
|
|
43
81
|
}
|
|
44
82
|
console.error(`Values for ${pc.bold(ctx.slug)}. Hidden as you type. Enter skips one.\n`);
|
|
45
83
|
const pairs = {};
|
|
@@ -71,5 +109,4 @@ export async function applyTemplate(template, opts) {
|
|
|
71
109
|
console.error(pc.dim(`No value yet: ${skipped.join(", ")}. Set later with npx getmyenv set NAME`));
|
|
72
110
|
}
|
|
73
111
|
console.error("");
|
|
74
|
-
return { context: ctx.slug };
|
|
75
112
|
}
|
package/dist/lib/authorize.d.ts
CHANGED
|
@@ -6,10 +6,20 @@ export declare function browserCommand(raw: string, platform?: NodeJS.Platform):
|
|
|
6
6
|
} | null;
|
|
7
7
|
export declare function openBrowser(url: string): void;
|
|
8
8
|
export declare function sleep(ms: number): Promise<void>;
|
|
9
|
+
/** The sign-in page preselects the folder's project, or "New project" with a name. */
|
|
10
|
+
export declare function verifyUrlFor(base: string, pick: {
|
|
11
|
+
projectId?: string;
|
|
12
|
+
newProject?: {
|
|
13
|
+
name: string;
|
|
14
|
+
};
|
|
15
|
+
}): string;
|
|
9
16
|
/**
|
|
10
17
|
* Sign in via the browser and link this folder (writes token.json + project.json).
|
|
11
18
|
* CI uses GETMYENV_TOKEN instead.
|
|
12
19
|
*/
|
|
13
20
|
export declare function authorizeFolder(opts?: {
|
|
14
21
|
apiUrl?: string;
|
|
22
|
+
newProject?: {
|
|
23
|
+
name: string;
|
|
24
|
+
};
|
|
15
25
|
}): Promise<WhoamiResponse>;
|
package/dist/lib/authorize.js
CHANGED
|
@@ -68,7 +68,18 @@ async function authorizeViaPaste(apiUrl) {
|
|
|
68
68
|
}
|
|
69
69
|
return saveVerifiedToken(token, apiUrl);
|
|
70
70
|
}
|
|
71
|
-
|
|
71
|
+
/** The sign-in page preselects the folder's project, or "New project" with a name. */
|
|
72
|
+
export function verifyUrlFor(base, pick) {
|
|
73
|
+
const params = pick.newProject
|
|
74
|
+
? `project=new&name=${encodeURIComponent(pick.newProject.name)}`
|
|
75
|
+
: pick.projectId
|
|
76
|
+
? `project=${encodeURIComponent(pick.projectId)}`
|
|
77
|
+
: "";
|
|
78
|
+
if (!params)
|
|
79
|
+
return base;
|
|
80
|
+
return `${base}${base.includes("?") ? "&" : "?"}${params}`;
|
|
81
|
+
}
|
|
82
|
+
async function authorizeViaBrowser(apiUrl, newProject) {
|
|
72
83
|
const client = new ApiClient(apiUrl);
|
|
73
84
|
let start;
|
|
74
85
|
try {
|
|
@@ -80,10 +91,10 @@ async function authorizeViaBrowser(apiUrl) {
|
|
|
80
91
|
console.error(pc.dim("Falling back to paste.\n"));
|
|
81
92
|
return authorizeViaPaste(apiUrl);
|
|
82
93
|
}
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
const verifyUrl = verifyUrlFor(start.verifyUrl, {
|
|
95
|
+
projectId: readProjectMeta()?.projectId,
|
|
96
|
+
newProject,
|
|
97
|
+
});
|
|
87
98
|
console.error("Open this URL to sign in and pick a project:\n");
|
|
88
99
|
console.error(` ${verifyUrl}\n`);
|
|
89
100
|
console.error(`Check the page shows this code: ${pc.bold(start.userCode)}\n`);
|
|
@@ -124,5 +135,5 @@ export async function authorizeFolder(opts = {}) {
|
|
|
124
135
|
if (!process.stdin.isTTY) {
|
|
125
136
|
throw new Error("No token in this folder. Run in a terminal to sign in, or set GETMYENV_TOKEN to a server token.");
|
|
126
137
|
}
|
|
127
|
-
return authorizeViaBrowser(resolveApiUrl(opts.apiUrl));
|
|
138
|
+
return authorizeViaBrowser(resolveApiUrl(opts.apiUrl), opts.newProject);
|
|
128
139
|
}
|
package/dist/lib/fs.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `.getmyenv/project.json`, committed so a clone knows its project.
|
|
3
3
|
* Holds no API URL: a committed file must not redirect where tokens go.
|
|
4
|
+
* `.getmyenv/template.json` is committed too: names only, see template-file.ts.
|
|
4
5
|
*/
|
|
5
6
|
export type ProjectMeta = {
|
|
6
7
|
projectId?: string;
|
package/dist/lib/fs.js
CHANGED
|
@@ -14,7 +14,8 @@ export function getProjectRoot(cwd = process.cwd()) {
|
|
|
14
14
|
for (;;) {
|
|
15
15
|
const local = path.join(dir, LOCAL_DIR);
|
|
16
16
|
if (fs.existsSync(path.join(local, "project.json")) ||
|
|
17
|
-
fs.existsSync(path.join(local, "token.json"))
|
|
17
|
+
fs.existsSync(path.join(local, "token.json")) ||
|
|
18
|
+
fs.existsSync(path.join(local, "template.json"))) {
|
|
18
19
|
return dir;
|
|
19
20
|
}
|
|
20
21
|
const parent = path.dirname(dir);
|
|
@@ -220,8 +221,9 @@ export async function promptChoice(question, options, defaultIndex = 0) {
|
|
|
220
221
|
}
|
|
221
222
|
export function ensureGitignoreHasEnv(cwd = process.cwd()) {
|
|
222
223
|
const gi = path.join(getProjectRoot(cwd), ".gitignore");
|
|
223
|
-
// project.json
|
|
224
|
-
// guest passphrase in claim.json stay on
|
|
224
|
+
// project.json and template.json are committed so a clone knows its project
|
|
225
|
+
// and its names. The token and the guest passphrase in claim.json stay on
|
|
226
|
+
// this machine.
|
|
225
227
|
const entries = [
|
|
226
228
|
".env",
|
|
227
229
|
".env.local",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type WrappedPrivateKey } from "@getmyenv/crypto";
|
|
2
|
+
export declare const NO_TTY_FOR_VAULT = "No terminal to set a Vault password. Set it in the dashboard, Settings, or run this command in a terminal.";
|
|
3
|
+
export type NewAccountKeys = {
|
|
4
|
+
publicKey: string;
|
|
5
|
+
/** Caller wipes it. */
|
|
6
|
+
privateKey: Uint8Array;
|
|
7
|
+
keys: {
|
|
8
|
+
publicKey: string;
|
|
9
|
+
wrappedPrivateKey: WrappedPrivateKey;
|
|
10
|
+
recoveryWrappedKey: WrappedPrivateKey;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/** The user types the code back. Nothing is saved until it matches. */
|
|
14
|
+
export declare function confirmRecoveryCode(code: string, ask?: (question: string) => Promise<string>): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* New account keys: a Vault password typed twice, then a recovery code shown
|
|
17
|
+
* once and typed back. Needs a terminal. Checks both wraps open the same key.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createAccountKeys(): Promise<NewAccountKeys>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { assertRecoveryWrap, createRecoveryWrap, createUserKeys, formatRecoveryCode, recoveryCodesMatch, wipe, } from "@getmyenv/crypto";
|
|
3
|
+
import { FACTS } from "@getmyenv/shared";
|
|
4
|
+
import { readLine } from "./fs.js";
|
|
5
|
+
import { promptNewVaultPassword } from "./guest-key.js";
|
|
6
|
+
export const NO_TTY_FOR_VAULT = "No terminal to set a Vault password. Set it in the dashboard, Settings, or run this command in a terminal.";
|
|
7
|
+
const TYPE_BACK_ATTEMPTS = 3;
|
|
8
|
+
function printRecoveryCode(code) {
|
|
9
|
+
const shown = formatRecoveryCode(code);
|
|
10
|
+
const width = shown.length + 4;
|
|
11
|
+
console.error("");
|
|
12
|
+
console.error(pc.bold("Your recovery code"));
|
|
13
|
+
console.error(`┌${"─".repeat(width)}┐`);
|
|
14
|
+
console.error(`│ ${pc.bold(shown)} │`);
|
|
15
|
+
console.error(`└${"─".repeat(width)}┘`);
|
|
16
|
+
console.error(pc.dim(`${FACTS.recoveryCode} Write it down or save it in a password manager.\n`));
|
|
17
|
+
}
|
|
18
|
+
/** The user types the code back. Nothing is saved until it matches. */
|
|
19
|
+
export async function confirmRecoveryCode(code, ask = readLine) {
|
|
20
|
+
for (let attempt = 1; attempt <= TYPE_BACK_ATTEMPTS; attempt++) {
|
|
21
|
+
const typed = await ask("Type the recovery code to confirm: ");
|
|
22
|
+
if (recoveryCodesMatch(code, typed))
|
|
23
|
+
return;
|
|
24
|
+
if (attempt < TYPE_BACK_ATTEMPTS)
|
|
25
|
+
console.error(pc.red("That does not match. Try again.\n"));
|
|
26
|
+
}
|
|
27
|
+
throw new Error("The recovery code did not match. Nothing was saved. Run the command again.");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* New account keys: a Vault password typed twice, then a recovery code shown
|
|
31
|
+
* once and typed back. Needs a terminal. Checks both wraps open the same key.
|
|
32
|
+
*/
|
|
33
|
+
export async function createAccountKeys() {
|
|
34
|
+
if (!process.stdin.isTTY)
|
|
35
|
+
throw new Error(NO_TTY_FOR_VAULT);
|
|
36
|
+
console.error(pc.dim("Choose a Vault password. It stays on this machine. We store an encrypted key blob only.\n"));
|
|
37
|
+
const created = await createUserKeys(await promptNewVaultPassword());
|
|
38
|
+
try {
|
|
39
|
+
const recovery = await createRecoveryWrap(created.privateKey);
|
|
40
|
+
printRecoveryCode(recovery.code);
|
|
41
|
+
await confirmRecoveryCode(recovery.code);
|
|
42
|
+
await assertRecoveryWrap(recovery.code, recovery.wrapped, created.publicKey);
|
|
43
|
+
return {
|
|
44
|
+
publicKey: created.publicKey,
|
|
45
|
+
privateKey: created.privateKey,
|
|
46
|
+
keys: {
|
|
47
|
+
publicKey: created.publicKey,
|
|
48
|
+
wrappedPrivateKey: created.wrapped,
|
|
49
|
+
recoveryWrappedKey: recovery.wrapped,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
wipe(created.privateKey);
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type TemplateFile } from "@getmyenv/shared";
|
|
2
|
+
import type { Linked } from "./bootstrap.js";
|
|
3
|
+
/**
|
|
4
|
+
* `.getmyenv/template.json`, committed: the project's variable names, never
|
|
5
|
+
* values. The CLI keeps it up to date. A fresh clone can start a new project
|
|
6
|
+
* from it.
|
|
7
|
+
*/
|
|
8
|
+
export type TemplateFileRead = {
|
|
9
|
+
ok: true;
|
|
10
|
+
file: TemplateFile;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function templateFilePath(cwd?: string): string;
|
|
16
|
+
/** Null when there is no file. A file that cannot be read or parsed says why. */
|
|
17
|
+
export declare function readTemplateFile(cwd?: string): TemplateFileRead | null;
|
|
18
|
+
export declare function serializeTemplateFile(file: TemplateFile): string;
|
|
19
|
+
/**
|
|
20
|
+
* Bring the file in line with the project's names. Writes only when the names
|
|
21
|
+
* change. Never overwrites a file it cannot read, so hand edits are not lost,
|
|
22
|
+
* and says so every time it skips.
|
|
23
|
+
*/
|
|
24
|
+
export declare function syncTemplateFile(names: readonly string[], projectName: string, cwd?: string): "written" | "unchanged" | "skipped";
|
|
25
|
+
/** CI and servers must not change the working tree. */
|
|
26
|
+
export declare function mayWriteTemplateFile(linked: Pick<Linked, "who">): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Sync from the key list a command already has, plus what it just added or
|
|
29
|
+
* removed. No extra request. A failure here never fails the command.
|
|
30
|
+
*/
|
|
31
|
+
export declare function syncFromLinked(linked: Pick<Linked, "who">, change?: {
|
|
32
|
+
added?: readonly string[];
|
|
33
|
+
removed?: readonly string[];
|
|
34
|
+
}): void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { mergeTemplateFile, templateFileSchema, TEMPLATE_FILE, TEMPLATE_FILE_MAX_KEYS, } from "@getmyenv/shared";
|
|
5
|
+
import { getLocalDir } from "./fs.js";
|
|
6
|
+
export function templateFilePath(cwd = process.cwd()) {
|
|
7
|
+
return path.join(getLocalDir(cwd), "template.json");
|
|
8
|
+
}
|
|
9
|
+
/** Null when there is no file. A file that cannot be read or parsed says why. */
|
|
10
|
+
export function readTemplateFile(cwd = process.cwd()) {
|
|
11
|
+
const file = templateFilePath(cwd);
|
|
12
|
+
let stat;
|
|
13
|
+
try {
|
|
14
|
+
stat = fs.lstatSync(file);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
if (!stat.isFile())
|
|
20
|
+
return { ok: false, reason: "not a regular file" };
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { ok: false, reason: "not JSON" };
|
|
27
|
+
}
|
|
28
|
+
const parsed = templateFileSchema.safeParse(raw);
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
const issue = parsed.error.issues[0];
|
|
31
|
+
const where = issue?.path.length ? `${issue.path.join(".")}: ` : "";
|
|
32
|
+
return { ok: false, reason: `${where}${issue?.message ?? "invalid"}` };
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, file: parsed.data };
|
|
35
|
+
}
|
|
36
|
+
export function serializeTemplateFile(file) {
|
|
37
|
+
return `${JSON.stringify(file, null, 2)}\n`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Bring the file in line with the project's names. Writes only when the names
|
|
41
|
+
* change. Never overwrites a file it cannot read, so hand edits are not lost,
|
|
42
|
+
* and says so every time it skips.
|
|
43
|
+
*/
|
|
44
|
+
export function syncTemplateFile(names, projectName, cwd = process.cwd()) {
|
|
45
|
+
const unique = [...new Set(names)];
|
|
46
|
+
if (unique.length > TEMPLATE_FILE_MAX_KEYS) {
|
|
47
|
+
console.error(pc.yellow(`${TEMPLATE_FILE} not updated. The project has ${unique.length} names, the file holds ${TEMPLATE_FILE_MAX_KEYS}. It may not match the project.`));
|
|
48
|
+
return "skipped";
|
|
49
|
+
}
|
|
50
|
+
const current = readTemplateFile(cwd);
|
|
51
|
+
if (current && !current.ok) {
|
|
52
|
+
console.error(pc.yellow(`${TEMPLATE_FILE} is not valid (${current.reason}). Not updated.`));
|
|
53
|
+
return "skipped";
|
|
54
|
+
}
|
|
55
|
+
if (!current && unique.length === 0)
|
|
56
|
+
return "unchanged";
|
|
57
|
+
const merged = mergeTemplateFile(current?.file ?? null, unique, projectName);
|
|
58
|
+
if (current && serializeTemplateFile(current.file) === serializeTemplateFile(merged))
|
|
59
|
+
return "unchanged";
|
|
60
|
+
const file = templateFilePath(cwd);
|
|
61
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
62
|
+
fs.writeFileSync(file, serializeTemplateFile(merged));
|
|
63
|
+
return "written";
|
|
64
|
+
}
|
|
65
|
+
/** CI and servers must not change the working tree. */
|
|
66
|
+
export function mayWriteTemplateFile(linked) {
|
|
67
|
+
return linked.who.token.kind !== "server" && !process.env.GETMYENV_TOKEN?.trim();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Sync from the key list a command already has, plus what it just added or
|
|
71
|
+
* removed. No extra request. A failure here never fails the command.
|
|
72
|
+
*/
|
|
73
|
+
export function syncFromLinked(linked, change = {}) {
|
|
74
|
+
if (!mayWriteTemplateFile(linked))
|
|
75
|
+
return;
|
|
76
|
+
const removed = new Set(change.removed ?? []);
|
|
77
|
+
const names = [...linked.who.keys.map((k) => k.name), ...(change.added ?? [])].filter((n) => !removed.has(n));
|
|
78
|
+
try {
|
|
79
|
+
syncTemplateFile(names, linked.who.project.name);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
83
|
+
console.error(pc.yellow(`${TEMPLATE_FILE} not updated: ${msg}`));
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/lib/vault.js
CHANGED
|
@@ -4,6 +4,7 @@ import { VAULT_UNLOCK_DURATION } from "@getmyenv/shared";
|
|
|
4
4
|
import { ApiError } from "./api.js";
|
|
5
5
|
import { resolveVaultPassword } from "./guest-key.js";
|
|
6
6
|
import { keychainAccount, keychainEnabled, readCachedContextKey, storeCachedContextKeys, } from "./keychain.js";
|
|
7
|
+
import { createAccountKeys } from "./recovery-code.js";
|
|
7
8
|
/** One unlock per process: several contexts in one command prompt once. */
|
|
8
9
|
const memo = new Map();
|
|
9
10
|
/** Key version of each memo entry opened from the vault response. Keys from the keychain have none. */
|
|
@@ -152,22 +153,16 @@ async function holderPrivateKey(linked, vault, opts) {
|
|
|
152
153
|
if (!opts.create) {
|
|
153
154
|
throw new Error("No Vault password yet. Set it in the dashboard, or set a value: npx getmyenv set KEY");
|
|
154
155
|
}
|
|
155
|
-
|
|
156
|
-
console.error(pc.dim("Choose a Vault password. It stays on this machine. We store an encrypted key blob only.\n"));
|
|
157
|
-
}
|
|
158
|
-
const password = await resolveVaultPassword({ isGuest, create: true });
|
|
159
|
-
const created = await createUserKeys(password);
|
|
156
|
+
const created = isGuest ? await createGuestKeys() : await createAccountKeys();
|
|
160
157
|
try {
|
|
161
|
-
await linked.client.request("POST", "/api/cli/vault", {
|
|
162
|
-
keys: { publicKey: created.publicKey, wrappedPrivateKey: created.wrapped },
|
|
163
|
-
});
|
|
158
|
+
await linked.client.request("POST", "/api/cli/vault", { keys: created.keys });
|
|
164
159
|
}
|
|
165
160
|
catch (err) {
|
|
166
161
|
wipe(created.privateKey);
|
|
167
162
|
throw err;
|
|
168
163
|
}
|
|
169
164
|
vault.publicKey = created.publicKey;
|
|
170
|
-
vault.wrappedPrivateKey = created.
|
|
165
|
+
vault.wrappedPrivateKey = created.keys.wrappedPrivateKey;
|
|
171
166
|
return created.privateKey;
|
|
172
167
|
}
|
|
173
168
|
const interactive = process.stdin.isTTY && !isGuest;
|
|
@@ -185,10 +180,19 @@ async function holderPrivateKey(linked, vault, opts) {
|
|
|
185
180
|
console.error(pc.red("Wrong Vault password. Try again.\n"));
|
|
186
181
|
continue;
|
|
187
182
|
}
|
|
188
|
-
throw new Error("Could not unlock. Check your Vault password. Forgot it?
|
|
183
|
+
throw new Error("Could not unlock. Check your Vault password. Forgot it? Use your recovery code in the dashboard, Settings.");
|
|
189
184
|
}
|
|
190
185
|
}
|
|
191
186
|
}
|
|
187
|
+
/** Guest keys wrap with the claim.json passphrase. No recovery code. */
|
|
188
|
+
async function createGuestKeys() {
|
|
189
|
+
const created = await createUserKeys(await resolveVaultPassword({ isGuest: true, create: true }));
|
|
190
|
+
return {
|
|
191
|
+
publicKey: created.publicKey,
|
|
192
|
+
privateKey: created.privateKey,
|
|
193
|
+
keys: { publicKey: created.publicKey, wrappedPrivateKey: created.wrapped },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
192
196
|
/** Unlock with the Vault password and return the private key. Caller wipes it. */
|
|
193
197
|
export async function unlockPrivateKey(linked, opts = {}) {
|
|
194
198
|
const vault = await fetchVault(linked);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getmyenv",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "CLI for getmyenv, an encrypted store for environment variables and secrets. We store ciphertext only.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@getmyenv/crypto": "0.
|
|
37
|
-
"@getmyenv/shared": "0.
|
|
36
|
+
"@getmyenv/crypto": "0.5.0",
|
|
37
|
+
"@getmyenv/shared": "0.10.0",
|
|
38
38
|
"@napi-rs/keyring": "^2.1.0",
|
|
39
39
|
"commander": "^15.0.0",
|
|
40
40
|
"dotenv": "^16.4.7",
|