@qui-cli/env 5.0.4 → 5.1.1

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,25 @@
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.1](https://github.com/battis/qui-cli/compare/env/5.1.0...env/5.1.1) (2026-01-21)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * configure 1Password before looking up secret references ([7046af5](https://github.com/battis/qui-cli/commit/7046af55ed2720b7b74bd437f335fe49fa8bc00f))
11
+
12
+ ## [5.1.0](https://github.com/battis/qui-cli/compare/env/5.0.4...env/5.1.0) (2026-01-19)
13
+
14
+
15
+ ### Features
16
+
17
+ * support 1Password secret references if @1password/sdk (and 1Password) installed ([ed4570c](https://github.com/battis/qui-cli/commit/ed4570cd5cf19ae34fc03f6b88b01f0ebf72e72d))
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * detect missing 1Password CLI ([3c306ba](https://github.com/battis/qui-cli/commit/3c306ba7e37394948666f96b1ad728e8abc65a4e)), closes [#81](https://github.com/battis/qui-cli/issues/81)
23
+
5
24
  ## [5.0.4](https://github.com/battis/qui-cli/compare/env/5.0.3...env/5.0.4) (2026-01-18)
6
25
 
7
26
 
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,36 @@
1
+ import * as Plugin from '@qui-cli/plugin';
2
+ import * as Base from './Base.js';
3
+ import * as Secrets from './Secrets.js';
4
+ export type Configuration = Plugin.Configuration & {
5
+ /**
6
+ * 1Password service account token; will use the environment variable OP_TOKEN
7
+ * if present
8
+ */
9
+ opToken?: string;
10
+ /**
11
+ * Name or ID of the 1Password API Credential item storing the 1Password
12
+ * service account token; will use environment variable OP_ITEM if present
13
+ */
14
+ opItem?: string;
15
+ /**
16
+ * 1Password account to use (if signed into multiple); will use environment
17
+ * variable OP_ACCOUNT if present
18
+ */
19
+ opAccount?: string;
20
+ };
21
+ export declare class OP implements Base.Plugin {
22
+ [key: string]: unknown;
23
+ readonly name = "env.1password";
24
+ private config;
25
+ private _client;
26
+ private getClient;
27
+ configure(proposal?: Configuration): void;
28
+ private configureFromEnv;
29
+ options(): Plugin.Options;
30
+ init({ values }: Plugin.ExpectedArguments<typeof this.options>): Promise<void>;
31
+ get({ ref, env }: Base.GetOptions): Promise<string>;
32
+ isSecretReference: typeof Secrets.isRef;
33
+ private itemFrom;
34
+ /** Requires a service account with write privileges */
35
+ set({ ref, value, env }: Base.SetOptions): Promise<void>;
36
+ }
@@ -0,0 +1,156 @@
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
+ import * as Secrets from './Secrets.js';
9
+ export class OP {
10
+ name = 'env.1password';
11
+ config = {};
12
+ _client = undefined;
13
+ async getClient() {
14
+ if (!this._client) {
15
+ const spinner = ora('Loading 1Password').start();
16
+ if (this.config.opItem && !this.config.opToken) {
17
+ const silent = Shell.isSilent();
18
+ const showCommands = Shell.commandsShown();
19
+ const logging = Shell.isLogging();
20
+ Shell.configure({ silent: true, showCommands: false, logging: false });
21
+ if (/(\d+\.)+\d/.test(Shell.exec('op -v').stdout)) {
22
+ const { stdout, stderr } = Shell.exec(`op item get ${this.config.opAccount ? `--account "${this.config.opAccount}" ` : ''}--reveal --fields credential "${this.config.opItem}"`);
23
+ if (stdout.length) {
24
+ this.config.opToken = stdout.trim();
25
+ }
26
+ else {
27
+ Log.fatal(stderr);
28
+ process.exit(1);
29
+ }
30
+ }
31
+ else {
32
+ 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')}).`);
33
+ }
34
+ Shell.configure({ silent, showCommands, logging });
35
+ }
36
+ if (this.config.opToken) {
37
+ const pkg = await importLocal(path.join(import.meta.dirname, '../../package.json'));
38
+ this._client = await createClient({
39
+ auth: this.config.opToken,
40
+ integrationName: pkg
41
+ .name.replace(/^(\/|@)/, '')
42
+ .replace(/[/@]+/g, '-'),
43
+ integrationVersion: pkg.version
44
+ });
45
+ spinner.succeed('1Password loaded');
46
+ }
47
+ else {
48
+ spinner.fail();
49
+ throw new Error('A 1Password service account token was not provided.');
50
+ }
51
+ }
52
+ return this._client;
53
+ }
54
+ configure(proposal = {}) {
55
+ for (const key in proposal) {
56
+ if (proposal[key] !== undefined) {
57
+ this.config[key] = proposal[key];
58
+ }
59
+ }
60
+ }
61
+ configureFromEnv(env) {
62
+ this.configure({
63
+ opAccount: env.OP_ACCOUNT,
64
+ opItem: env.OP_ITEM,
65
+ opToken: env.OP_TOKEN
66
+ });
67
+ }
68
+ options() {
69
+ return {
70
+ man: [
71
+ {
72
+ level: 1,
73
+ text: '1Password environment integration'
74
+ },
75
+ {
76
+ text: `If 1Password secret references are stored in the environment, a ` +
77
+ `1Password service account token is required to access the secret ` +
78
+ `values.`
79
+ }
80
+ ],
81
+ opt: {
82
+ opAccount: {
83
+ description: `1Password account to use (if signed into multiple); will use ` +
84
+ `environment variable ${Colors.varName('OP_ACCOUNT')} if present`,
85
+ hint: 'example.1password.com',
86
+ default: this.config.opAccount
87
+ },
88
+ opItem: {
89
+ description: `Name or ID of the 1Password API Credential item storing the ` +
90
+ `1Password service account token; will use environment variable ` +
91
+ `${Colors.varName('OP_ITEM')} if present. Requires the 1Password ` +
92
+ `CLI tool (${Colors.url('https://developer.1password.com/docs/cli')})`,
93
+ hint: '1Password unique identifier',
94
+ default: this.config.opItem
95
+ },
96
+ opToken: {
97
+ description: `1Password service account token; will use environment variable ` +
98
+ `${Colors.varName('OP_TOKEN')} if present`,
99
+ hint: 'token value',
100
+ secret: true,
101
+ default: this.config.opToken
102
+ }
103
+ }
104
+ };
105
+ }
106
+ async init({ values }) {
107
+ await this.configure(values);
108
+ }
109
+ async get({ ref, env }) {
110
+ this.configureFromEnv(env);
111
+ return await (await this.getClient()).secrets.resolve(ref);
112
+ }
113
+ isSecretReference = Secrets.isRef;
114
+ async itemFrom(parts) {
115
+ const client = await this.getClient();
116
+ const vault = (await client.vaults.list())
117
+ .filter((vault) => vault.title == parts.vault)
118
+ .shift();
119
+ if (vault) {
120
+ const item = (await client.items.list(vault.id))
121
+ .filter((item) => item.title === parts.item || item.id === parts.item)
122
+ .shift();
123
+ if (item) {
124
+ return await client.items.get(vault.id, item.id);
125
+ }
126
+ }
127
+ }
128
+ /** Requires a service account with write privileges */
129
+ async set({ ref, value, env }) {
130
+ this.configureFromEnv(env);
131
+ const client = await this.getClient();
132
+ const parts = Secrets.explodeRef(ref);
133
+ const item = await this.itemFrom(parts);
134
+ if (item) {
135
+ const updated = {
136
+ ...item,
137
+ fields: item.fields.map((field) => {
138
+ const section = item.sections.find((s) => s.id === field.sectionId);
139
+ if (parts.field === field.title || parts.field === field.id) {
140
+ if ((!parts.section &&
141
+ (field.sectionId === '' || field.sectionId === 'add more')) ||
142
+ parts.section === section?.title ||
143
+ parts.section === section?.id) {
144
+ return { ...field, value };
145
+ }
146
+ }
147
+ return field;
148
+ })
149
+ };
150
+ await client.items.put(updated);
151
+ }
152
+ else {
153
+ throw new Error('1Password item could not be found');
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,18 @@
1
+ import { Base } from '@qui-cli/plugin/dist/Plugin.js';
2
+ import { isRef } from './Secrets.js';
3
+ type Environment = Record<string, string>;
4
+ export type GetOptions = {
5
+ ref: string;
6
+ env: Environment;
7
+ };
8
+ export type SetOptions = {
9
+ ref: string;
10
+ value: string;
11
+ env: Environment;
12
+ };
13
+ export interface Plugin extends Base {
14
+ isSecretReference: typeof isRef;
15
+ get(options: GetOptions): Promise<string | undefined>;
16
+ set(options: SetOptions): Promise<void>;
17
+ }
18
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ export type Reference = {
2
+ vault: string;
3
+ item: string;
4
+ section?: string;
5
+ field: string;
6
+ };
7
+ export declare function isRef(value: unknown): boolean;
8
+ export declare function explodeRef(ref: string): Reference;
@@ -0,0 +1,15 @@
1
+ export function isRef(value) {
2
+ return (!!value &&
3
+ value !== null &&
4
+ typeof value === 'string' &&
5
+ /^op:\/\//.test(value));
6
+ }
7
+ export function explodeRef(ref) {
8
+ // eslint-disable-next-line prefer-const
9
+ let [vault, item, section, field] = ref.replace(/^op:\/\//, '').split('/');
10
+ if (field === undefined) {
11
+ field = section;
12
+ section = '';
13
+ }
14
+ return { vault, item, section, field };
15
+ }
@@ -0,0 +1,6 @@
1
+ import { Plugin } from './Base.js';
2
+ export declare const OP: {
3
+ isSecretReference: (value: unknown) => boolean;
4
+ get?: Plugin['get'];
5
+ set?: Plugin['set'];
6
+ };
@@ -0,0 +1,17 @@
1
+ import { register } from '@qui-cli/plugin';
2
+ import { isRef } from './Secrets.js';
3
+ export const OP = {
4
+ isSecretReference: isRef
5
+ };
6
+ try {
7
+ await import('@1password/sdk');
8
+ const opModule = await import('./1Password.js');
9
+ const plugin = new opModule.OP();
10
+ OP.set = plugin.set.bind(plugin);
11
+ OP.get = plugin.get.bind(plugin);
12
+ await register(plugin);
13
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
14
+ }
15
+ catch (_) {
16
+ //
17
+ }
package/dist/Env.d.ts CHANGED
@@ -16,10 +16,10 @@ export type Configuration = Plugin.Configuration & {
16
16
  path?: string;
17
17
  };
18
18
  export declare const name = "env";
19
- export declare function configure(config?: Configuration): Promise<void>;
20
- export declare function init(): void;
19
+ export declare function configure(proposal?: Configuration): Promise<void>;
20
+ export declare function init(): Promise<void>;
21
21
  export type ParsedResult = dotenv.DotenvParseOutput;
22
- export declare function parse(file?: string): Promise<ParsedResult>;
22
+ export declare function parse(file?: string | undefined): Promise<ParsedResult>;
23
23
  export type GetOptions = {
24
24
  key: string;
25
25
  file?: string;
@@ -27,10 +27,15 @@ export type GetOptions = {
27
27
  export declare function get({ key, file }: GetOptions): Promise<string | undefined>;
28
28
  export declare function exists({ key, file }: GetOptions): Promise<boolean>;
29
29
  export type SetOptions = {
30
+ /** Name of environment variable to set */
30
31
  key: string;
32
+ /** Value to set */
31
33
  value: string;
34
+ /** Path to the .env file */
32
35
  file?: string;
36
+ /** A comment included in the .env file above the variable */
33
37
  comment?: string;
38
+ /** Only set key=value if key is not already set */
34
39
  ifNotExists?: boolean;
35
40
  };
36
41
  export declare function set({ key, value, file, comment, ifNotExists }: SetOptions): Promise<void>;
package/dist/Env.js CHANGED
@@ -1,25 +1,31 @@
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 { OP } from './1Password/index.js';
6
6
  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) {
7
+ const config = {
8
+ load: true,
9
+ path: '.env'
10
+ };
11
+ export async function configure(proposal = {}) {
12
+ for (const key in proposal) {
13
+ if (proposal[key] !== undefined) {
14
+ config[key] = proposal[key];
15
+ }
16
+ }
17
+ if (config.load) {
15
18
  await parse();
16
19
  }
17
20
  }
18
- export function init() {
21
+ export async function init() {
19
22
  parse();
20
23
  }
21
- export async function parse(file = pathToEnv) {
22
- const filePath = path.resolve(root || Root.path(), typeof file === 'string' ? file : '.env');
24
+ function toFilePath(file = '.env') {
25
+ return path.resolve(config.root || Root.path(), file);
26
+ }
27
+ export async function parse(file = config.path) {
28
+ const filePath = toFilePath(file);
23
29
  if (fs.existsSync(filePath)) {
24
30
  const env = dotenv.config({ path: filePath, quiet: true });
25
31
  if (env.error) {
@@ -29,40 +35,61 @@ export async function parse(file = pathToEnv) {
29
35
  }
30
36
  return {};
31
37
  }
32
- export async function get({ key, file = pathToEnv }) {
33
- if (fs.existsSync(path.resolve(root || Root.path(), file))) {
34
- return (await parse(file))[key];
38
+ export async function get({ key, file = config.path || '.env' }) {
39
+ if (fs.existsSync(toFilePath(file))) {
40
+ const env = await parse(file);
41
+ if (OP.isSecretReference(env[key])) {
42
+ if (OP.get) {
43
+ return await OP.get({ ref: env[key], env });
44
+ }
45
+ else {
46
+ throw new Error(`Attempt to read environment variable ${key} that is a 1Password secret reference without @1password/sdk installed.`);
47
+ }
48
+ }
49
+ return env[key];
35
50
  }
36
51
  return undefined;
37
52
  }
38
- export async function exists({ key, file = pathToEnv }) {
39
- if (fs.existsSync(path.resolve(root || Root.path(), file))) {
53
+ export async function exists({ key, file = config.path }) {
54
+ if (fs.existsSync(toFilePath(file))) {
40
55
  return !!(await parse(file))[key];
41
56
  }
42
57
  return false;
43
58
  }
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}`);
59
+ export async function set({ key, value, file = config.path, comment, ifNotExists = false }) {
60
+ const filePath = toFilePath(file);
61
+ const env = dotenv.config({ path: filePath, quiet: true }).parsed || {};
62
+ const { [key]: prev } = env;
63
+ if (ifNotExists === false || !prev) {
64
+ if (prev && OP.isSecretReference(prev)) {
65
+ if (OP.set) {
66
+ await OP.set({ ref: prev, value, env });
67
+ }
68
+ else {
69
+ throw new Error(`Attmept to update environment variable ${key} that is a 1Password secret reference without installing @1password/sdk`);
70
+ }
57
71
  }
58
72
  else {
59
- env = `${env.trim()}\n${comment ? `\n# ${comment}\n` : ''}${key}=${value}\n`;
73
+ let env = '';
74
+ if (fs.existsSync(filePath)) {
75
+ env = fs.readFileSync(filePath).toString();
76
+ }
77
+ const pattern = new RegExp(`^${key}=.*$`, 'm');
78
+ if (/[\s=]/.test(value)) {
79
+ value = `"${value}"`;
80
+ }
81
+ if (pattern.test(env)) {
82
+ env = env.replace(pattern, `${key}=${value}`);
83
+ }
84
+ else {
85
+ env = `${env.trim()}\n${comment ? `\n# ${comment}\n` : ''}${key}=${value}\n`;
86
+ }
87
+ fs.writeFileSync(filePath, env);
60
88
  }
61
- fs.writeFileSync(filePath, env);
62
89
  }
63
90
  }
64
- export async function remove({ key, file = pathToEnv, comment }) {
65
- const filePath = path.resolve(root || Root.path(), file);
91
+ export async function remove({ key, file = config.path || '.env', comment }) {
92
+ const filePath = path.resolve(config.root || Root.path(), file);
66
93
  if (fs.existsSync(filePath)) {
67
94
  const env = fs.readFileSync(filePath).toString();
68
95
  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.1",
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": {