Skip to content

Commit

Permalink
allow .env style output from resolve and add associated docs (#159)
Browse files Browse the repository at this point in the history
* allow .env style output from resolve and add associated docs

* add --no-prompt flag, and naive subshell via isTTY detection, implements #160
  • Loading branch information
philmillman authored Nov 13, 2024
1 parent ccd00ce commit 8549dbe
Show file tree
Hide file tree
Showing 8 changed files with 94 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/hungry-terms-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"dmno": patch
---

adds env as an output format option for dmno resolve
22 changes: 15 additions & 7 deletions packages/core/src/cli/commands/resolve.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { addCacheFlags } from '../lib/cache-helpers';
import { addWatchMode } from '../lib/watch-mode-helpers';
import { CliExitError } from '../lib/cli-error';
import { checkForConfigErrors, checkForSchemaErrors } from '../lib/check-errors-helpers';
import { stringifyObjectAsEnvFile } from '../lib/env-file-helpers';
import { isSubshell } from '../lib/shell-helpers';

const program = new DmnoCommand('resolve')
.summary('Loads config schema and resolves config values')
Expand All @@ -23,11 +25,13 @@ const program = new DmnoCommand('resolve')
.option('--show-all', 'shows all items, even when config is failing')
.example('dmno resolve', 'Loads the resolved config for the root service')
.example('dmno resolve --service service1', 'Loads the resolved config for service1')
.example('dmno resolve --service service1 --format json', 'Loads the resolved config for service1 in JSON format');
.example('dmno resolve --service service1 --format json', 'Loads the resolved config for service1 in JSON format')
.example('dmno resolve --service service1 --format env', 'Loads the resolved config for service1 and outputs it in .env file format')
.example('dmno resolve --service service1 --format env >> .env.local', 'Loads the resolved config for service1 and outputs it in .env file format and writes to .env.local');

addWatchMode(program); // must be first
addCacheFlags(program);
addServiceSelection(program);
addServiceSelection(program, { disablePrompt: isSubshell() });


program.action(async (opts: {
Expand All @@ -47,6 +51,7 @@ program.action(async (opts: {

if (!ctx.selectedService) return; // error message already handled


ctx.log(`\nResolving config for service ${kleur.magenta(ctx.selectedService.serviceName)}\n`);

const workspace = ctx.workspace!;
Expand All @@ -55,20 +60,23 @@ program.action(async (opts: {
await workspace.resolveConfig();
checkForConfigErrors(service, { showAll: opts?.showAll });

// console.log(service.config);
if (opts.format === 'json') {
const getExposedConfigValues = () => {
let exposedConfig = service.config;
if (opts.public) {
exposedConfig = _.pickBy(exposedConfig, (c) => !c.type.getMetadata('sensitive'));
}
const valuesOnly = _.mapValues(exposedConfig, (val) => val.resolvedValue);
return _.mapValues(exposedConfig, (val) => val.resolvedValue);
};

console.log(JSON.stringify(valuesOnly));
// console.log(service.config);
if (opts.format === 'json') {
console.log(JSON.stringify(getExposedConfigValues()));
} else if (opts.format === 'json-full') {
// TODO: this includes sensitive info when using --public option
console.dir(service.toJSON(), { depth: null });
} else if (opts.format === 'json-injected') {
console.log(JSON.stringify(service.configraphEntity.getInjectedEnvJSON()));
} else if (opts.format === 'env') {
console.log(stringifyObjectAsEnvFile(getExposedConfigValues()));
} else {
_.each(service.config, (item) => {
console.log(getItemSummary(item.toJSON()));
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/cli/lib/env-file-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { expect, test, describe } from 'vitest';
import { stringifyObjectAsEnvFile } from './env-file-helpers';

describe('stringifyObjectAsEnvFile', () => {
test('basic', () => {
const result = stringifyObjectAsEnvFile({ foo: 'bar', baz: 'qux' });
expect(result).toEqual('foo="bar"\nbaz="qux"');
});
test('escapes backslashes', () => {
const result = stringifyObjectAsEnvFile({ foo: 'bar\\baz' });
expect(result).toEqual('foo="bar\\\\baz"');
});
test('escapes newlines', () => {
const result = stringifyObjectAsEnvFile({ foo: 'bar\nbaz' });
expect(result).toEqual('foo="bar\\nbaz"');
});
test('escapes double quotes', () => {
const result = stringifyObjectAsEnvFile({ foo: 'bar"baz' });
expect(result).toEqual('foo="bar\\"baz"');
});
});
11 changes: 11 additions & 0 deletions packages/core/src/cli/lib/env-file-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function stringifyObjectAsEnvFile(obj: Record<string, string>) {
return Object.entries(obj).map(([key, value]) => {
// Handle newlines and quotes by wrapping in double quotes and escaping
const formattedValue = String(value)
.replace(/\\/g, '\\\\') // escape backslashes first
.replace(/\n/g, '\\n') // escape newlines
.replace(/"/g, '\\"'); // escape double quotes

return `${key}="${formattedValue}"`;
}).join('\n');
}
9 changes: 6 additions & 3 deletions packages/core/src/cli/lib/selection-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,20 @@ function getServiceLabel(s: DmnoService, padNameEnd: number) {
export function addServiceSelection(program: Command, opts?: {
disableAutoSelect?: boolean,
disableMenuSelect?: boolean,
allowNoSelection?: boolean
allowNoSelection?: boolean,
disablePrompt?: boolean,
}) {
return program
.option('-s, --service [service]', 'which service to load')
.option('-np, --no-prompt', 'do not prompt for service selection')
.hook('preAction', async (thisCommand, actionCommand) => {
const ctx = getCliRunCtx();

const workspace = await ctx.configLoader.getWorkspace();
ctx.workspace = workspace;

const namesMaxLen = getMaxLength(_.map(workspace.allServices, (s) => s.serviceName));
const disablePrompt = thisCommand.opts().noPrompt || opts?.disablePrompt;

// // first display loading errors (which would likely cascade into schema errors)
// if (_.some(_.values(workspace.allServices), (s) => s.configLoadError)) {
Expand Down Expand Up @@ -83,7 +86,7 @@ export function addServiceSelection(program: Command, opts?: {
}

// handle auto-selection based on what package manager has passed in as the current package when running scripts via the package manager
if (!explicitMenuOptIn && !opts?.disableAutoSelect) {
if (!explicitMenuOptIn && !disablePrompt && !opts?.disableAutoSelect) {
// filled by package manager with package name if running an package.json script
const packageName = process.env.npm_package_name || process.env.PNPM_PACKAGE_NAME;
if (packageName) {
Expand All @@ -94,7 +97,7 @@ export function addServiceSelection(program: Command, opts?: {

// This fully selects it and moves on
// TODO: not totally sure, so we should see how this feels...
if (autoServiceFromPackageManager) {
if (autoServiceFromPackageManager && !disablePrompt) {
ctx.selectedService = autoServiceFromPackageManager;
ctx.autoSelectedService = true;
return;
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/cli/lib/shell-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { execSync, spawn } from 'child_process';
import { expect, test, describe } from 'vitest';
import { isSubshell } from './shell-helpers';

describe('isSubshell', () => {
test('basic', () => {
expect(isSubshell()).toBe(false);
});
test('subshell', () => {
const child = spawn('bash', ['-c', 'echo $PPID $(echo $PPID)']);
child.stdout.on('data', () => {
expect(isSubshell()).toBe(true);
});
});
});
1 change: 1 addition & 0 deletions packages/core/src/cli/lib/shell-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const isSubshell = () => !process.stdin.isTTY;
20 changes: 20 additions & 0 deletions packages/docs-site/src/content/docs/docs/guides/env-files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,24 @@ If you're using [plugins](/docs/plugins/overview/) to handle your sensitive conf
When running `dmno init`, we prompt you to move any gitignored `.env` files we find into your `.dmno` folder. This means that other tools that may be looking for will not find them - which is on purpose. Instead, you should pass resolved config to those external tools via `dmno run`, whether `.env` files are being used or not.
:::

## Resolving config and outputting to .env format

You can use the [`dmno resolve` command](/docs/reference/cli/resolve/) to load the resolved config for a service and output it in `.env` file format. This is useful for quickly exporting all your config values to a file for use in other systems, especially in some serverless environments where you may need to set a lot of environment variables at once and you don't have as much control over the running process as you do locally.

Consider the following example where we want to load an `.env` file for use in Supabase Edge Functions.

You can run the following command to load the resolved config for your `api` service and output it to a file.

```bash
pnpm exec dmno resolve --service api --format env >> .env.production
supabase secrets set --env-file .env.production
```

If you want to do this with a single command, you can combine them like this:

```bash
supabase secrets set --env-file <(pnpm exec dmno resolve --service api --format env)
```
This has the added benefit of writing no file, so you don't need to worry about deleting it later or accidentally checking it into source control.

{/* Will need to add a note about nested config and special `__` separator (ie PARENT__CHILD) */}

0 comments on commit 8549dbe

Please # to comment.