Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 104 additions & 20 deletions deploy/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from "@cliffy/command";
import { parse as dotEnvParse } from "@std/dotenv";
import {
error,
ExitCode,
isNonInteractive,
tablePrinter,
writeJsonResult,
Expand Down Expand Up @@ -29,6 +30,62 @@ type EnvCommandContext = GlobalContext & {
app?: string;
};

/**
* Shape a backend env var into the public `--json` representation, masking the
* value of secrets and resolving context ids to their human names. Shared by
* `list` and the mutating commands so their JSON output stays identical.
*/
function shapeEnvVar(envVar: EnvVar, contexts: Context[]) {
return {
id: envVar.id,
key: envVar.key,
value: envVar.is_secret ? null : envVar.value,
isSecret: envVar.is_secret,
contexts: envVar.context_ids
? envVar.context_ids.map((id) =>
contexts.find((c) => c.id === id)?.name ?? id
)
: null,
};
}

/**
* Re-read a single env var (and the org contexts) from the backend and shape it
* for `--json` output. Used by the mutating commands to report the persisted
* state of the variable they just changed. If the variable can't be read back
* (read-after-write miss, key-normalization difference), this exits via
* {@link error} with a structured `NOT_FOUND` envelope rather than returning a
* value — so callers always emit the var object, never a bare `null`.
*/
async function fetchShapedEnvVar(
context: GlobalContext,
trpcClient: ReturnType<typeof createTrpcClient>,
org: string,
app: string,
key: string,
) {
const envVars = await trpcClient.query("envVarsContexts.list", {
org,
app,
}) as EnvVar[];
const contexts = await trpcClient.query(
"envVarsContexts.listContexts",
{ org },
) as Context[];
const envVar = envVars.find((envVar) => envVar.key === key);
if (!envVar) {
error(
context,
`Environment variable '${key}' could not be read back from the backend after the operation.`,
{
code: ExitCode.NOT_FOUND,
errorCode: "ENV_VAR_NOT_FOUND",
},
);
}
return shapeEnvVar(envVar, contexts);
}

const envListCommand = new Command<EnvCommandContext>()
.description("List all environment variables in an application")
.action(actionHandler(async (config, options) => {
Expand All @@ -48,17 +105,7 @@ const envListCommand = new Command<EnvCommandContext>()
) as Context[];

if (options.json) {
writeJsonResult(envVars.map((envVar) => ({
id: envVar.id,
key: envVar.key,
value: envVar.is_secret ? null : envVar.value,
isSecret: envVar.is_secret,
contexts: envVar.context_ids
? envVar.context_ids.map((id) =>
contexts.find((c) => c.id === id)?.name ?? id
)
: null,
})));
writeJsonResult(envVars.map((envVar) => shapeEnvVar(envVar, contexts)));
return;
}

Expand Down Expand Up @@ -130,7 +177,14 @@ const envAddCommand = new Command<EnvCommandContext>()
remove: [],
});

console.log(
if (options.json) {
writeJsonResult(
await fetchShapedEnvVar(options, trpcClient, org, app, variable),
);
return;
}

console.error(
`${
green("✔")
} Environment variable '${variable}' has been successfully set.`,
Expand Down Expand Up @@ -172,7 +226,14 @@ const envUpdateValueCommand = new Command<EnvCommandContext>()
remove: [],
});

console.log(
if (options.json) {
writeJsonResult(
await fetchShapedEnvVar(options, trpcClient, org, app, variable),
);
return;
}

console.error(
`${
green("✔")
} The value of environment variable '${variable}' has been successfully updated.`,
Expand Down Expand Up @@ -230,7 +291,14 @@ You can define no contexts, which is the equivalent to "All"`,
remove: [],
});

console.log(
if (options.json) {
writeJsonResult(
await fetchShapedEnvVar(options, trpcClient, org, app, variable),
);
return;
}

console.error(
`${
green("✔")
} The contexts of environment variable '${variable}' have been successfully updated.`,
Expand Down Expand Up @@ -266,7 +334,12 @@ const envDeleteCommand = new Command<EnvCommandContext>()
remove: [envVar.id],
});

console.log(
if (options.json) {
writeJsonResult({ id: envVar.id, key: variable, deleted: true });
return;
}

console.error(
`${
green("✔")
} Environment variable '${variable}' has been successfully deleted.`,
Expand Down Expand Up @@ -357,6 +430,8 @@ const envLoadCommand = new Command<EnvCommandContext>()
}
}

const existingKeys = updateEnvVars.map((envVar) => envVar.key);

if (updateEnvVars.length > 0) {
if (options.skipExisting) {
updateEnvVars = [];
Expand All @@ -368,11 +443,11 @@ const envLoadCommand = new Command<EnvCommandContext>()
"Existing env vars found and prompting is disabled.\nUse --replace to overwrite or --skip-existing to skip.",
);
} else {
console.log("The following env vars are already defined:");
console.error("The following env vars are already defined:");
for (const updateEnvVar of updateEnvVars) {
console.log(` - ${updateEnvVar.key}`);
console.error(` - ${updateEnvVar.key}`);
}
console.log();
console.error();
outer: while (true) {
const res = prompt(
"Would you like to replace these with your .env file? [y = Yes, n = No, s = Ignore/Skip]",
Expand All @@ -394,7 +469,7 @@ const envLoadCommand = new Command<EnvCommandContext>()
}
}
}
console.log();
console.error();
}
}

Expand All @@ -405,7 +480,16 @@ const envLoadCommand = new Command<EnvCommandContext>()
remove: [],
});

console.log(
const added = addEnvVars.map((envVar) => envVar.key);
const updated = updateEnvVars.map((envVar) => envVar.key);
const skipped = existingKeys.filter((key) => !updated.includes(key));

if (options.json) {
writeJsonResult({ file, added, updated, skipped });
return;
}

console.error(
`${green("✔")} .env file '${file}' has been successfully loaded.`,
);
}));
Expand Down
81 changes: 81 additions & 0 deletions tests/env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { $ } from "dax";
import { assertEquals } from "@std/assert";

const TOKEN = Deno.env.get("DENO_DEPLOY_TOKEN");
const ORG = Deno.env.get("DENO_DEPLOY_TEST_ORG");
const APP = Deno.env.get("DENO_DEPLOY_TEST_APP");

// The mutating `env` commands persist real backend state, so these run only
// when a throwaway org/app is supplied via DENO_DEPLOY_TEST_ORG /
// DENO_DEPLOY_TEST_APP (alongside DENO_DEPLOY_TOKEN and DENO_DEPLOY_CLI_SPECIFIER).
const live = Boolean(TOKEN && ORG && APP);

async function env(
cwd: string,
...args: string[]
): Promise<{ code: number; stdout: string; stderr: string }> {
const escaped = args.map((a) => $.escapeArg(a)).join(" ");
const result = await $.raw`deno deploy env ${escaped}`
.cwd(cwd)
.noThrow()
.stdout("piped")
.stderr("piped");
return { code: result.code, stdout: result.stdout, stderr: result.stderr };
}

Deno.test({
name:
"env add/update-value/delete --json emit exactly one JSON object on stdout",
ignore: !live,
fn: async () => {
// Run from a throwaway cwd: resolving --org/--app persists a `deploy`
// object to the working deno.json, which we discard with the temp dir.
const cwd = await Deno.makeTempDir({ prefix: "deno-deploy-env-test-" });
await Deno.writeTextFile(`${cwd}/deno.json`, "{}\n");
const key = `AGENT_TEST_${
crypto.randomUUID().replaceAll("-", "").slice(0, 12).toUpperCase()
}`;
const target = [
"--org",
ORG!,
"--app",
APP!,
"--json",
"--non-interactive",
];

try {
// add: stdout must be a single JSON object shaped like an `env list` item.
let res = await env(cwd, "add", key, "v1", ...target);
assertEquals(res.code, 0, `add failed; stderr: ${res.stderr}`);
assertEquals(
res.stdout.trim().split("\n").length,
1,
`add stdout not a single line: ${JSON.stringify(res.stdout)}`,
);
let parsed = JSON.parse(res.stdout.trim());
assertEquals(parsed.key, key);
assertEquals(parsed.value, "v1");
assertEquals(parsed.isSecret, false);
assertEquals(parsed.contexts, null);

// update-value: the result reflects the new value on clean stdout.
res = await env(cwd, "update-value", key, "v2", ...target);
assertEquals(res.code, 0, `update-value failed; stderr: ${res.stderr}`);
parsed = JSON.parse(res.stdout.trim());
assertEquals(parsed.key, key);
assertEquals(parsed.value, "v2");

// delete: result is an operation summary; the var is gone afterwards.
res = await env(cwd, "delete", key, ...target);
assertEquals(res.code, 0, `delete failed; stderr: ${res.stderr}`);
parsed = JSON.parse(res.stdout.trim());
assertEquals(parsed.key, key);
assertEquals(parsed.deleted, true);
} finally {
// Best-effort cleanup in case an assertion aborted mid-cycle.
await env(cwd, "delete", key, ...target).catch(() => {});
await Deno.remove(cwd, { recursive: true }).catch(() => {});
}
},
});
Loading