@qui-cli/env 5.0.4 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
4
4
 
5
+ ## [5.1.0](https://github.com/battis/qui-cli/compare/env/5.0.4...env/5.1.0) (2026-01-19)
6
+
7
+
8
+ ### Features
9
+
10
+ * support 1Password secret references if @1password/sdk (and 1Password) installed ([ed4570c](https://github.com/battis/qui-cli/commit/ed4570cd5cf19ae34fc03f6b88b01f0ebf72e72d))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * detect missing 1Password CLI ([3c306ba](https://github.com/battis/qui-cli/commit/3c306ba7e37394948666f96b1ad728e8abc65a4e)), closes [#81](https://github.com/battis/qui-cli/issues/81)
16
+
5
17
  ## [5.0.4](https://github.com/battis/qui-cli/compare/env/5.0.3...env/5.0.4) (2026-01-18)
6
18
 
7
19
 
package/README.md CHANGED
@@ -31,7 +31,19 @@ Any plugin that depends on this plugin can assume that the `.env` file environem
31
31
 
32
32
  ### 1Password integration
33
33
 
34
- For 1Password integration supporting secret references in `.env`, use [@qui-cli/env-1password](https://npmjs.com/package/@qui-cli/env-1password) in place of this package.
34
+ To access 1Password secret references stored in the environment, install the optional peer dependency [@1password/sdk](https://www.npmjs.com/package/@1password/sdk). For full integration, also install the [1Password CLI](https://developer.1password.com/docs/cli/) which will allow you to look up a [1Password service account](https://developer.1password.com/docs/service-accounts/security/) token by identifier.
35
+
36
+ The configuration options `opToken`, `opItem`, and `opAccount` may all be passed as command-line options. For example:
37
+
38
+ ```sh
39
+ example --opToken "$(op item get ServiceAccountToken)"
40
+ ```
41
+
42
+ If the [1Password CLI tool](https://developer.1password.com/docs/cli) is installed, then `opItem` and `opAccount` can be used:
43
+
44
+ ```sh
45
+ example --opAccount example.1password.com --opitem "My Token Identifier"
46
+ ```
35
47
 
36
48
  ## Configuration
37
49
 
@@ -55,9 +67,25 @@ Whether or not to load the `.env` file into `process.env` immediately. Defaults
55
67
 
56
68
  Path to desired `.env` file relative to `root`. Defaults to `'.env'`;
57
69
 
70
+ ### 1Password configuration
71
+
72
+ If 1Password secret references are stored in the environment, a 1Password service account token is required to access the secret values.
73
+
74
+ #### `opToken`
75
+
76
+ 1Password service account token; will use environment variable `OP_TOKEN` if present
77
+
78
+ #### `opItem`
79
+
80
+ Name or ID of the 1Password API Credential item storing the 1Password service account token; will use environment variable `OP_ITEM` if present. Requires the [1Password CLI tool](https://developer.1password.com/docs/cli).
81
+
82
+ #### `opAccount`
83
+
84
+ 1Password account to use (if signed into multiple); will use environment variable `OP_ACCOUNT` if present
85
+
58
86
  ## Options
59
87
 
60
- `Env` adds no user-configurable command line options.
88
+ When the optional peer dependency [@1password/sdk](https://www.npmjs.com/package/@1password/sdk) is installed, `Env` exposes `opAccount`, `opItem`, and `opToken` as command-line options.
61
89
 
62
90
  ## Initialization
63
91
 
@@ -0,0 +1,12 @@
1
+ import * as Plugin from '@qui-cli/plugin';
2
+ import { OPConfiguration } from './Configuration.js';
3
+ export declare const name = "1password support";
4
+ export declare function configure(proposal?: OPConfiguration): Promise<void>;
5
+ export declare function options(): Plugin.Options;
6
+ export declare function init({ values }: Plugin.ExpectedArguments<typeof options>): Promise<void>;
7
+ export declare function get(ref: string): Promise<string>;
8
+ /** Requires a service account with write privileges */
9
+ export declare function set({ ref, value }: {
10
+ ref: string;
11
+ value: string;
12
+ }): Promise<void>;
@@ -0,0 +1,157 @@
1
+ import { createClient } from '@1password/sdk';
2
+ import { importLocal } from '@battis/import-package-json';
3
+ import { Colors } from '@qui-cli/colors';
4
+ import { Log } from '@qui-cli/log';
5
+ import { Shell } from '@qui-cli/shell';
6
+ import path from 'node:path';
7
+ import ora from 'ora';
8
+ export const name = '1password support';
9
+ const config = {};
10
+ let _client = undefined;
11
+ async function getClient() {
12
+ if (!_client) {
13
+ const spinner = ora('Loading 1Password').start();
14
+ if (config.opItem && !config.opToken) {
15
+ const silent = Shell.isSilent();
16
+ const showCommands = Shell.commandsShown();
17
+ const logging = Shell.isLogging();
18
+ Shell.configure({ silent: true, showCommands: false, logging: false });
19
+ if (/(\d+\.)+\d/.test(Shell.exec('op -v').stdout)) {
20
+ const { stdout, stderr } = Shell.exec(`op item get ${config.opAccount ? `--account "${config.opAccount}" ` : ''}--reveal --fields credential "${config.opItem}"`);
21
+ if (stdout.length) {
22
+ config.opToken = stdout.trim();
23
+ }
24
+ else {
25
+ Log.fatal(stderr);
26
+ process.exit(1);
27
+ }
28
+ }
29
+ else {
30
+ throw new Error(`Looking up a 1Password service account token by item identifier requires the 1Password CLI (${Colors.url('https://developer.1password.com/docs/cli')}).`);
31
+ }
32
+ Shell.configure({ silent, showCommands, logging });
33
+ }
34
+ if (config.opToken) {
35
+ const pkg = await importLocal(path.join(import.meta.dirname, '../../package.json'));
36
+ _client = await createClient({
37
+ auth: config.opToken,
38
+ integrationName: pkg
39
+ .name.replace(/^(\/|@)/, '')
40
+ .replace(/[/@]+/g, '-'),
41
+ integrationVersion: pkg.version
42
+ });
43
+ spinner.succeed('1Password loaded');
44
+ }
45
+ else {
46
+ const message = 'A 1Password service account token was not provided.';
47
+ spinner.fail(message);
48
+ throw new Error('A 1Password service account token was not provided.');
49
+ }
50
+ }
51
+ return _client;
52
+ }
53
+ export async function configure(proposal = {}) {
54
+ for (const key in proposal) {
55
+ if (proposal[key] !== undefined) {
56
+ config[key] = proposal[key];
57
+ }
58
+ }
59
+ }
60
+ export function options() {
61
+ return {
62
+ man: [
63
+ {
64
+ level: 1,
65
+ text: '1Password environment integration'
66
+ },
67
+ {
68
+ text: `If 1Password secret references are stored in the environment, a ` +
69
+ `1Password service account token is required to access the secret ` +
70
+ `values.`
71
+ }
72
+ ],
73
+ opt: {
74
+ opAccount: {
75
+ description: `1Password account to use (if signed into multiple); will use ` +
76
+ `environment variable ${Colors.varName('OP_ACCOUNT')} if present`,
77
+ hint: 'example.1password.com',
78
+ default: config.opAccount
79
+ },
80
+ opItem: {
81
+ description: `Name or ID of the 1Password API Credential item storing the ` +
82
+ `1Password service account token; will use environment variable ` +
83
+ `${Colors.varName('OP_ITEM')} if present. Requires the 1Password ` +
84
+ `CLI tool (${Colors.url('https://developer.1password.com/docs/cli')})`,
85
+ hint: '1Password unique identifier',
86
+ default: config.opItem
87
+ },
88
+ opToken: {
89
+ description: `1Password service account token; will use environment variable ` +
90
+ `${Colors.varName('OP_TOKEN')} if present`,
91
+ hint: 'token value',
92
+ secret: true,
93
+ default: config.opToken
94
+ }
95
+ }
96
+ };
97
+ }
98
+ export async function init({ values }) {
99
+ const { opAccount = process.env.OP_ACCOUNT, opItem = process.env.OP_ITEM, opToken = process.env.OP_TOKEN } = values;
100
+ await configure({ ...values, opAccount, opItem, opToken });
101
+ }
102
+ export async function get(ref) {
103
+ const client = await getClient();
104
+ return await client.secrets.resolve(ref);
105
+ }
106
+ function explodeSecretReference(secretReference) {
107
+ // eslint-disable-next-line prefer-const
108
+ let [vault, item, section, field] = secretReference
109
+ .replace(/^op:\/\//, '')
110
+ .split('/');
111
+ if (field === undefined) {
112
+ field = section;
113
+ section = '';
114
+ }
115
+ return { vault, item, section, field };
116
+ }
117
+ async function itemFrom(parts) {
118
+ const client = await getClient();
119
+ const vault = (await client.vaults.list())
120
+ .filter((vault) => vault.title == parts.vault)
121
+ .shift();
122
+ if (vault) {
123
+ const item = (await client.items.list(vault.id))
124
+ .filter((item) => item.title === parts.item || item.id === parts.item)
125
+ .shift();
126
+ if (item) {
127
+ return await client.items.get(vault.id, item.id);
128
+ }
129
+ }
130
+ }
131
+ /** Requires a service account with write privileges */
132
+ export async function set({ ref, value }) {
133
+ const client = await getClient();
134
+ const parts = explodeSecretReference(ref);
135
+ const item = await itemFrom(parts);
136
+ if (item) {
137
+ const updated = {
138
+ ...item,
139
+ fields: item.fields.map((field) => {
140
+ const section = item.sections.find((s) => s.id === field.sectionId);
141
+ if (parts.field === field.title || parts.field === field.id) {
142
+ if ((!parts.section &&
143
+ (field.sectionId === '' || field.sectionId === 'add more')) ||
144
+ parts.section === section?.title ||
145
+ parts.section === section?.id) {
146
+ return { ...field, value };
147
+ }
148
+ }
149
+ return field;
150
+ })
151
+ };
152
+ await client.items.put(updated);
153
+ }
154
+ else {
155
+ throw new Error('1Password item could not be found');
156
+ }
157
+ }
@@ -0,0 +1,18 @@
1
+ import * as Plugin from '@qui-cli/plugin';
2
+ export type OPConfiguration = Plugin.Configuration & {
3
+ /**
4
+ * 1Password service account token; will use the environment variable OP_TOKEN
5
+ * if present
6
+ */
7
+ opToken?: string;
8
+ /**
9
+ * Name or ID of the 1Password API Credential item storing the 1Password
10
+ * service account token; will use environment variable OP_ITEM if present
11
+ */
12
+ opItem?: string;
13
+ /**
14
+ * 1Password account to use (if signed into multiple); will use environment
15
+ * variable OP_ACCOUNT if present
16
+ */
17
+ opAccount?: string;
18
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export * from './1Password.js';
2
+ export * from './Configuration.js';
3
+ export * from './isSecretReference.js';
@@ -0,0 +1,3 @@
1
+ export * from './1Password.js';
2
+ export * from './Configuration.js';
3
+ export * from './isSecretReference.js';
@@ -0,0 +1 @@
1
+ export declare function isSecretReference(value: unknown): unknown;
@@ -0,0 +1,6 @@
1
+ export function isSecretReference(value) {
2
+ return (value &&
3
+ value !== null &&
4
+ typeof value === 'string' &&
5
+ /^op:\/\//.test(value));
6
+ }
package/dist/Env.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as Plugin from '@qui-cli/plugin';
2
2
  import dotenv from 'dotenv';
3
+ import { OPConfiguration } from './1Password/Configuration.js';
3
4
  export type Configuration = Plugin.Configuration & {
4
5
  /**
5
6
  * Optional root for calculating relative paths to `.env` files. If undefined,
@@ -14,12 +15,13 @@ export type Configuration = Plugin.Configuration & {
14
15
  load?: boolean;
15
16
  /** Path to desired `.env` file relative to `root`. Defaults to `'.env'`; */
16
17
  path?: string;
17
- };
18
+ } & OPConfiguration;
18
19
  export declare const name = "env";
19
- export declare function configure(config?: Configuration): Promise<void>;
20
- export declare function init(): void;
20
+ export declare function configure(proposal?: Configuration): Promise<void>;
21
+ export declare function options(): Promise<Plugin.Options>;
22
+ export declare function init(args: Plugin.ExpectedArguments<typeof options>): Promise<void>;
21
23
  export type ParsedResult = dotenv.DotenvParseOutput;
22
- export declare function parse(file?: string): Promise<ParsedResult>;
24
+ export declare function parse(file?: string | undefined): Promise<ParsedResult>;
23
25
  export type GetOptions = {
24
26
  key: string;
25
27
  file?: string;
@@ -27,10 +29,15 @@ export type GetOptions = {
27
29
  export declare function get({ key, file }: GetOptions): Promise<string | undefined>;
28
30
  export declare function exists({ key, file }: GetOptions): Promise<boolean>;
29
31
  export type SetOptions = {
32
+ /** Name of environment variable to set */
30
33
  key: string;
34
+ /** Value to set */
31
35
  value: string;
36
+ /** Path to the .env file */
32
37
  file?: string;
38
+ /** A comment included in the .env file above the variable */
33
39
  comment?: string;
40
+ /** Only set key=value if key is not already set */
34
41
  ifNotExists?: boolean;
35
42
  };
36
43
  export declare function set({ key, value, file, comment, ifNotExists }: SetOptions): Promise<void>;
package/dist/Env.js CHANGED
@@ -1,25 +1,59 @@
1
- import * as Plugin from '@qui-cli/plugin';
2
1
  import { Root } from '@qui-cli/root';
3
2
  import dotenv from 'dotenv';
4
3
  import fs from 'node:fs';
5
4
  import path from 'node:path';
5
+ import { isSecretReference } from './1Password/isSecretReference.js';
6
+ let OP = undefined;
7
+ try {
8
+ await import('@1password/sdk');
9
+ OP = await import('./1Password/index.js');
10
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
11
+ }
12
+ catch (_) {
13
+ // @1password/sdk not present
14
+ }
6
15
  export const name = 'env';
7
- let root = undefined;
8
- let load = true;
9
- let pathToEnv = '.env';
10
- export async function configure(config = {}) {
11
- root = Plugin.hydrate(config.root, root);
12
- load = Plugin.hydrate(config.load, load);
13
- pathToEnv = Plugin.hydrate(config.path, pathToEnv);
14
- if (load) {
16
+ const config = {
17
+ load: true,
18
+ path: '.env'
19
+ };
20
+ export async function configure(proposal = {}) {
21
+ for (const key in proposal) {
22
+ if (proposal[key] !== undefined) {
23
+ config[key] = proposal[key];
24
+ }
25
+ }
26
+ if (OP?.configure) {
27
+ OP.configure({
28
+ opAccount: config.opAccount,
29
+ opItem: config.opItem,
30
+ opToken: config.opToken
31
+ });
32
+ }
33
+ if (config.load) {
15
34
  await parse();
16
35
  }
17
36
  }
18
- export function init() {
37
+ export async function options() {
38
+ if (OP?.options) {
39
+ return await OP.options();
40
+ }
41
+ else {
42
+ return {};
43
+ }
44
+ }
45
+ export async function init(args) {
46
+ if (OP?.init) {
47
+ await OP.init(args);
48
+ }
49
+ await configure(args.values);
19
50
  parse();
20
51
  }
21
- export async function parse(file = pathToEnv) {
22
- const filePath = path.resolve(root || Root.path(), typeof file === 'string' ? file : '.env');
52
+ function toFilePath(file = '.env') {
53
+ return path.resolve(config.root || Root.path(), file);
54
+ }
55
+ export async function parse(file = config.path) {
56
+ const filePath = toFilePath(file);
23
57
  if (fs.existsSync(filePath)) {
24
58
  const env = dotenv.config({ path: filePath, quiet: true });
25
59
  if (env.error) {
@@ -29,40 +63,60 @@ export async function parse(file = pathToEnv) {
29
63
  }
30
64
  return {};
31
65
  }
32
- export async function get({ key, file = pathToEnv }) {
33
- if (fs.existsSync(path.resolve(root || Root.path(), file))) {
34
- return (await parse(file))[key];
66
+ export async function get({ key, file = config.path || '.env' }) {
67
+ if (fs.existsSync(toFilePath(file))) {
68
+ const env = await parse(file);
69
+ if (isSecretReference(env[key])) {
70
+ if (OP?.get) {
71
+ return OP?.get(env[key]);
72
+ }
73
+ else {
74
+ throw new Error(`Attempt to read environment variable ${key} that is a 1Password secret reference without @1password/sdk installed.`);
75
+ }
76
+ }
77
+ return env[key];
35
78
  }
36
79
  return undefined;
37
80
  }
38
- export async function exists({ key, file = pathToEnv }) {
39
- if (fs.existsSync(path.resolve(root || Root.path(), file))) {
81
+ export async function exists({ key, file = config.path }) {
82
+ if (fs.existsSync(toFilePath(file))) {
40
83
  return !!(await parse(file))[key];
41
84
  }
42
85
  return false;
43
86
  }
44
- export async function set({ key, value, file = pathToEnv, comment, ifNotExists = false }) {
45
- const filePath = path.resolve(root || Root.path(), file);
46
- if (ifNotExists === false || false === (await exists({ key, file }))) {
47
- let env = '';
48
- if (fs.existsSync(filePath)) {
49
- env = fs.readFileSync(filePath).toString();
50
- }
51
- const pattern = new RegExp(`^${key}=.*$`, 'm');
52
- if (/[\s=]/.test(value)) {
53
- value = `"${value}"`;
54
- }
55
- if (pattern.test(env)) {
56
- env = env.replace(pattern, `${key}=${value}`);
87
+ export async function set({ key, value, file = config.path, comment, ifNotExists = false }) {
88
+ const filePath = toFilePath(file);
89
+ const { [key]: prev } = dotenv.config({ path: filePath, quiet: true }).parsed || {};
90
+ if (ifNotExists === false || !prev) {
91
+ if (prev && isSecretReference(prev)) {
92
+ if (OP?.set) {
93
+ OP.set({ ref: prev, value });
94
+ }
95
+ else {
96
+ throw new Error(`Attmept to update environment variable ${key} that is a 1Password secret reference without installing @1password/sdk`);
97
+ }
57
98
  }
58
99
  else {
59
- env = `${env.trim()}\n${comment ? `\n# ${comment}\n` : ''}${key}=${value}\n`;
100
+ let env = '';
101
+ if (fs.existsSync(filePath)) {
102
+ env = fs.readFileSync(filePath).toString();
103
+ }
104
+ const pattern = new RegExp(`^${key}=.*$`, 'm');
105
+ if (/[\s=]/.test(value)) {
106
+ value = `"${value}"`;
107
+ }
108
+ if (pattern.test(env)) {
109
+ env = env.replace(pattern, `${key}=${value}`);
110
+ }
111
+ else {
112
+ env = `${env.trim()}\n${comment ? `\n# ${comment}\n` : ''}${key}=${value}\n`;
113
+ }
114
+ fs.writeFileSync(filePath, env);
60
115
  }
61
- fs.writeFileSync(filePath, env);
62
116
  }
63
117
  }
64
- export async function remove({ key, file = pathToEnv, comment }) {
65
- const filePath = path.resolve(root || Root.path(), file);
118
+ export async function remove({ key, file = config.path || '.env', comment }) {
119
+ const filePath = path.resolve(config.root || Root.path(), file);
66
120
  if (fs.existsSync(filePath)) {
67
121
  const env = fs.readFileSync(filePath).toString();
68
122
  const pattern = new RegExp(`${key}=.*\\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qui-cli/env",
3
- "version": "5.0.4",
3
+ "version": "5.1.0",
4
4
  "description": "@qui-cli Plugin: Standardized environment configuration",
5
5
  "homepage": "https://github.com/battis/qui-cli/tree/main/packages/env#readme",
6
6
  "repository": {
@@ -17,23 +17,36 @@
17
17
  "main": "./dist/index.js",
18
18
  "types": "./dist/index.d.ts",
19
19
  "dependencies": {
20
- "@1password/sdk": "^0.3.1",
21
20
  "@battis/import-package-json": "^0.1.7",
22
- "dotenv": "^17.2.3"
21
+ "dotenv": "^17.2.3",
22
+ "ora": "^9.0.0"
23
23
  },
24
24
  "devDependencies": {
25
+ "@1password/sdk": "^0.3.1",
25
26
  "@tsconfig/node24": "^24.0.4",
26
27
  "@types/node": "^24.10.9",
27
28
  "commit-and-tag-version": "^12.6.1",
28
29
  "del-cli": "^7.0.0",
29
30
  "npm-run-all": "^4.1.5",
30
31
  "typescript": "^5.9.3",
32
+ "@qui-cli/log": "4.0.3",
33
+ "@qui-cli/plugin": "4.1.0",
34
+ "@qui-cli/colors": "3.2.3",
31
35
  "@qui-cli/root": "3.1.0",
32
- "@qui-cli/plugin": "4.1.0"
36
+ "@qui-cli/shell": "3.1.2"
33
37
  },
34
38
  "peerDependencies": {
39
+ "@1password/sdk": "0.3.x",
40
+ "@qui-cli/colors": ">=3",
41
+ "@qui-cli/log": ">=3",
35
42
  "@qui-cli/plugin": ">=3",
36
- "@qui-cli/root": ">=3"
43
+ "@qui-cli/root": ">=3",
44
+ "@qui-cli/shell": ">=3"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@1password/sdk": {
48
+ "optional": true
49
+ }
37
50
  },
38
51
  "target": "node",
39
52
  "scripts": {