Skip to content

Commit 2af0b2f

Browse files
authored
Add @setValuesBulk decorator + bulk secret loaders for 1Password & Infisical (#307)
* add @setValuesBulk root decorator * 1password environments loader * infisical bulk loader * update secret docs to reference plugins * lint fixes * updated bun.lock
1 parent 6a72a76 commit 2af0b2f

21 files changed

Lines changed: 1057 additions & 27 deletions

File tree

.changeset/sixty-doors-hammer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"varlock": patch
3+
---
4+
5+
new @setValuesBulk root decorator

.changeset/soft-beers-chew.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@varlock/infisical-plugin": patch
3+
---
4+
5+
infisical bulk loader

.changeset/wet-clocks-shake.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@varlock/1password-plugin": patch
3+
"@env-spec/parser": patch
4+
"varlock": patch
5+
---
6+
7+
add 1password environments loader, improve how resolver errors are shown to the user

bun.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/env-spec-parser/src/expand.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,13 +154,16 @@ function expandHelper(
154154
name: fnName,
155155
args: new ParsedEnvSpecFunctionArgs({
156156
values: newConcatArgs as any,
157+
_location: val.data.args.data._location,
157158
}),
159+
_location: val.data._location,
158160
});
159161
} else if (val instanceof ParsedEnvSpecFunctionArgs) {
160162
// expand each arg
161163
const newArgs = val.data.values.map((v) => expandHelper(v, expandStaticFn));
162164
return new ParsedEnvSpecFunctionArgs({
163165
values: newArgs as any,
166+
_location: val.data._location,
164167
});
165168
// if key-value pair, expand value
166169
} else if (val instanceof ParsedEnvSpecKeyValuePair) {

packages/plugins/1password/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This package is a [Varlock](https://varlock.dev) [plugin](https://varlock.dev/gu
99
- **Service account authentication** for CI/CD and production environments
1010
- **Desktop app authentication** for local development (with biometric unlock support)
1111
- **Secret references** using 1Password's standard `op://` format
12+
- **Bulk-load environments** with `opLoadEnvironment()` via `@setValuesBulk`
1213
- **Multiple vault support** for different environments and access levels
1314
- **Multiple instances** for connecting to different accounts or vaults
1415
- Compatible with any 1Password account type (personal, family, teams, business)
@@ -133,6 +134,32 @@ DEV_ITEM=op(dev, op://vault-name/item-name/field-name)
133134
PROD_ITEM=op(prod, op://vault-name/item-name/field-name)
134135
```
135136

137+
### Loading 1Password Environments
138+
139+
Use `opLoadEnvironment()` with `@setValuesBulk` to load all variables from a [1Password environment](https://developer.1password.com/docs/sdks/concepts/environments/) at once:
140+
141+
```env-spec
142+
# @plugin(@varlock/1password-plugin)
143+
# @initOp(token=$OP_TOKEN, allowAppAuth=forEnv(dev), account=acmeco)
144+
# @setValuesBulk(opLoadEnvironment(your-environment-id))
145+
# ---
146+
147+
# @type=opServiceAccountToken @sensitive
148+
OP_TOKEN=
149+
150+
API_KEY=
151+
DB_PASSWORD=
152+
```
153+
154+
With a named instance:
155+
156+
```env-spec
157+
# @initOp(id=prod, token=$OP_TOKEN_PROD, allowAppAuth=false)
158+
# @setValuesBulk(opLoadEnvironment(prod, your-environment-id))
159+
```
160+
161+
> **Note:** When using desktop app auth (`allowAppAuth`), the `op environment` command requires a beta version of the 1Password CLI (v2.33.0+). Download it from the [CLI release history](https://app-updates.agilebits.com/product_history/CLI2) (click "show betas"). Service account auth via the SDK does not have this requirement.
162+
136163
---
137164

138165
## Reference
@@ -166,6 +193,20 @@ Fetch an individual field using a 1Password secret reference.
166193
- Format: `op://vault-name/item-name/field-name`
167194
- Example: `op://production/database/password`
168195

196+
#### `opLoadEnvironment()`
197+
198+
Load all variables from a 1Password environment. Intended for use with `@setValuesBulk`.
199+
200+
**Signatures:**
201+
202+
- `opLoadEnvironment(environmentId)` - Load from default instance
203+
- `opLoadEnvironment(instanceId, environmentId)` - Load from a specific instance
204+
205+
**Parameters:**
206+
207+
- `environmentId: string` - The 1Password environment ID
208+
- `instanceId?: string` - Instance identifier (static, when using multiple instances)
209+
169210
### Data Types
170211

171212
- `opServiceAccountToken` - 1Password service account token (sensitive, validated format)

packages/plugins/1password/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@
4343
"varlock": "workspace:^"
4444
},
4545
"devDependencies": {
46-
"@1password/sdk": "^0.3.1",
47-
"@1password/sdk-core": "^0.3.1",
46+
"@1password/sdk": "0.4.1-beta.1",
47+
"@1password/sdk-core": "0.4.1-beta.1",
4848
"@env-spec/utils": "workspace:^",
4949
"@types/node": "catalog:",
5050
"import-meta-resolve": "^4.2.0",

packages/plugins/1password/src/cli-helper.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,23 @@ function processOpCliError(err: Error | any) {
134134
tip: ['Double check the field name/id in your item.'],
135135
// TODO: add link to item?
136136
});
137+
} else if (errMessage.includes('unknown command "environment"')) {
138+
return new ResolutionError('1Password CLI does not support the "environment" command', {
139+
tip: [
140+
'The `op environment` command requires a beta version of the 1Password CLI (v2.33.0+).',
141+
'Download it from https://app-updates.agilebits.com/product_history/CLI2 (click "show betas").',
142+
],
143+
});
144+
} else if (errMessage.toLowerCase().includes('environment') && (errMessage.includes('not found') || errMessage.includes('invalid'))) {
145+
return new ResolutionError('1Password environment not found', {
146+
tip: [
147+
'Verify the environment ID is correct.',
148+
'You can find it in the 1Password app under Developer > View Environments.',
149+
'See https://developer.1password.com/docs/environments/',
150+
],
151+
});
137152
}
138153

139-
140-
141154
// when the desktop app integration is not connected, some interactive CLI help is displayed
142155
// however if it dismissed, we get an error with no message
143156
// TODO: figure out the right workflow here?
@@ -317,6 +330,19 @@ export async function opCliRead(opReference: string, account?: string) {
317330
}
318331
}
319332

333+
export async function opCliEnvironmentRead(
334+
environmentId: string,
335+
account?: string,
336+
): Promise<string> {
337+
const result = await execOpCliCommand([
338+
'environment',
339+
'read',
340+
environmentId,
341+
...(account ? ['--account', account] : []),
342+
]);
343+
return result;
344+
}
345+
320346
export function getIdsFromShareLink(opItemShareLinkUrl: string) {
321347
const url = new URL(opItemShareLinkUrl);
322348
const vaultId = url.searchParams.get('v')!;

packages/plugins/1password/src/plugin.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Resolver } from 'varlock/plugin-lib';
22

33
import { createDeferredPromise, DeferredPromise } from '@env-spec/utils/defer';
44
import { Client, createClient } from '@1password/sdk';
5-
import { opCliRead } from './cli-helper';
5+
import { opCliRead, opCliEnvironmentRead } from './cli-helper';
66

77
const { ValidationError, SchemaError, ResolutionError } = plugin.ERRORS;
88

@@ -14,6 +14,19 @@ const { debug } = plugin;
1414
debug('init - version =', plugin.version);
1515
plugin.icon = OP_ICON;
1616

17+
/** Parse env format output from `op environment read` into a flat {name: value} JSON string */
18+
function parseOpEnvOutput(raw: string): string {
19+
const result: Record<string, string> = {};
20+
for (const line of raw.split('\n')) {
21+
const eqPos = line.indexOf('=');
22+
if (eqPos === -1) continue;
23+
const key = line.substring(0, eqPos).trim();
24+
if (!key) continue;
25+
result[key] = line.substring(eqPos + 1);
26+
}
27+
return JSON.stringify(result);
28+
}
29+
1730
class OpPluginInstance {
1831
/** 1Password service account token */
1932
private token?: string;
@@ -83,6 +96,31 @@ class OpPluginInstance {
8396
}
8497
}
8598

99+
async readEnvironment(environmentId: string): Promise<string> {
100+
if (this.token) {
101+
// Use SDK - supports environments since v0.4.1-beta.1
102+
await this.initSdkClient();
103+
const opClient = await this.opClientPromise;
104+
if (!opClient) throw new Error('Expected op sdk to be initialized');
105+
const response = await opClient.environments.getVariables(environmentId);
106+
// Convert EnvironmentVariable[] to flat {name: value} JSON string
107+
const result: Record<string, string> = {};
108+
for (const v of response.variables) {
109+
result[v.name] = v.value;
110+
}
111+
return JSON.stringify(result);
112+
} else if (this.allowAppAuth) {
113+
// Use CLI for desktop app auth
114+
const cliResult = await opCliEnvironmentRead(environmentId, this.account);
115+
// CLI outputs env format (KEY=value lines) - parse to flat JSON
116+
return parseOpEnvOutput(cliResult);
117+
} else {
118+
throw new SchemaError('Unable to authenticate with 1Password', {
119+
tip: `Plugin instance (${this.id}) must be provided either a service account token or have app auth enabled (allowAppAuth=true)`,
120+
});
121+
}
122+
}
123+
86124
private async executeReadBatch() {
87125
const opClient = await this.opClientPromise;
88126
if (!opClient) throw new Error('Expected op sdk to be initialized');
@@ -262,3 +300,68 @@ plugin.registerResolverFunction({
262300
return opValue;
263301
},
264302
});
303+
304+
plugin.registerResolverFunction({
305+
name: 'opLoadEnvironment',
306+
label: 'Load all variables from a 1Password environment',
307+
icon: OP_ICON,
308+
argsSchema: {
309+
type: 'array',
310+
arrayMinLength: 1,
311+
arrayMaxLength: 2,
312+
},
313+
process() {
314+
if (!this.arrArgs || !this.arrArgs.length) {
315+
throw new SchemaError('Expected 1 or 2 arguments');
316+
}
317+
318+
let instanceId: string;
319+
let environmentIdResolver: Resolver;
320+
321+
if (this.arrArgs.length === 1) {
322+
instanceId = '_default';
323+
environmentIdResolver = this.arrArgs[0];
324+
} else if (this.arrArgs.length === 2) {
325+
if (!this.arrArgs[0].isStatic) {
326+
throw new SchemaError('expected instance id to be a static value');
327+
}
328+
instanceId = String(this.arrArgs[0].staticValue);
329+
environmentIdResolver = this.arrArgs[1];
330+
} else {
331+
throw new SchemaError('Expected 1 or 2 args');
332+
}
333+
334+
if (!Object.values(pluginInstances).length) {
335+
throw new SchemaError('No 1Password plugin instances found', {
336+
tip: 'Initialize at least one 1Password plugin instance using the @initOp root decorator',
337+
});
338+
}
339+
340+
const selectedInstance = pluginInstances[instanceId];
341+
if (!selectedInstance) {
342+
if (instanceId === '_default') {
343+
throw new SchemaError('1Password plugin instance (without id) not found', {
344+
tip: [
345+
'Either remove the `id` param from your @initOp call',
346+
'or use `opLoadEnvironment(id, environmentId)` to select an instance by id.',
347+
`Possible ids are: ${Object.keys(pluginInstances).join(', ')}`,
348+
].join('\n'),
349+
});
350+
} else {
351+
throw new SchemaError(`1Password plugin instance id "${instanceId}" not found`, {
352+
tip: [`Valid ids are: ${Object.keys(pluginInstances).join(', ')}`].join('\n'),
353+
});
354+
}
355+
}
356+
357+
return { instanceId, environmentIdResolver };
358+
},
359+
async resolve({ instanceId, environmentIdResolver }) {
360+
const selectedInstance = pluginInstances[instanceId];
361+
const environmentId = await environmentIdResolver.resolve();
362+
if (typeof environmentId !== 'string') {
363+
throw new SchemaError('expected environment ID to resolve to a string');
364+
}
365+
return await selectedInstance.readEnvironment(environmentId);
366+
},
367+
});

0 commit comments

Comments
 (0)