getmyenv 0.10.0 → 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 CHANGED
@@ -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.10.0):
38
+ Output of `npx getmyenv --help` (0.11.0):
39
39
 
40
40
  ```text
41
41
  Usage: getmyenv [options] [command]
@@ -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?: GuestOpts): Promise<{
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 {};
@@ -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. Commit .getmyenv/project.json. token.json and claim.json stay local.`));
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
- await createGuestProject(opts);
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("");
@@ -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
  }
@@ -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,6 +5,7 @@ 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
@@ -100,6 +101,7 @@ export async function restoreCommand(file, opts) {
100
101
  values,
101
102
  keyVersions,
102
103
  });
104
+ syncFromLinked(linked, { added: [...envelope.keys, ...values.map((v) => v.key)] });
103
105
  const skipped = [...perContext.keys()].filter((slug) => !writable(slug));
104
106
  console.log(`${pc.green("✓")} Restored ${res.restored} values into ${res.projectName}`);
105
107
  if (skipped.length > 0) {
@@ -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
  }
@@ -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.
@@ -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
- const files = rootEnvFiles();
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`));
@@ -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
- const todo = template.keys.filter((k) => (status.get(k.name) ?? "missing") === "missing");
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 { context: ctx.slug };
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
  }
@@ -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>;
@@ -68,7 +68,18 @@ async function authorizeViaPaste(apiUrl) {
68
68
  }
69
69
  return saveVerifiedToken(token, apiUrl);
70
70
  }
71
- async function authorizeViaBrowser(apiUrl) {
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 linked = readProjectMeta()?.projectId;
84
- const verifyUrl = linked
85
- ? `${start.verifyUrl}${start.verifyUrl.includes("?") ? "&" : "?"}project=${encodeURIComponent(linked)}`
86
- : start.verifyUrl;
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 is committed so a clone knows its project. The token and the
224
- // guest passphrase in claim.json stay on this machine.
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,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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getmyenv",
3
- "version": "0.10.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": {
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@getmyenv/crypto": "0.5.0",
37
- "@getmyenv/shared": "0.9.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",