Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/sixty-doors-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"varlock": patch
---

new @setValuesBulk root decorator
5 changes: 5 additions & 0 deletions .changeset/soft-beers-chew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@varlock/infisical-plugin": patch
---

infisical bulk loader
7 changes: 7 additions & 0 deletions .changeset/wet-clocks-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@varlock/1password-plugin": patch
"@env-spec/parser": patch
"varlock": patch
---

add 1password environments loader, improve how resolver errors are shown to the user
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/env-spec-parser/src/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,16 @@ function expandHelper(
name: fnName,
args: new ParsedEnvSpecFunctionArgs({
values: newConcatArgs as any,
_location: val.data.args.data._location,
}),
_location: val.data._location,
});
} else if (val instanceof ParsedEnvSpecFunctionArgs) {
// expand each arg
const newArgs = val.data.values.map((v) => expandHelper(v, expandStaticFn));
return new ParsedEnvSpecFunctionArgs({
values: newArgs as any,
_location: val.data._location,
});
// if key-value pair, expand value
} else if (val instanceof ParsedEnvSpecKeyValuePair) {
Expand Down
41 changes: 41 additions & 0 deletions packages/plugins/1password/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This package is a [Varlock](https://varlock.dev) [plugin](https://varlock.dev/gu
- **Service account authentication** for CI/CD and production environments
- **Desktop app authentication** for local development (with biometric unlock support)
- **Secret references** using 1Password's standard `op://` format
- **Bulk-load environments** with `opLoadEnvironment()` via `@setValuesBulk`
- **Multiple vault support** for different environments and access levels
- **Multiple instances** for connecting to different accounts or vaults
- Compatible with any 1Password account type (personal, family, teams, business)
Expand Down Expand Up @@ -133,6 +134,32 @@ DEV_ITEM=op(dev, op://vault-name/item-name/field-name)
PROD_ITEM=op(prod, op://vault-name/item-name/field-name)
```

### Loading 1Password Environments

Use `opLoadEnvironment()` with `@setValuesBulk` to load all variables from a [1Password environment](https://developer.1password.com/docs/sdks/concepts/environments/) at once:

```env-spec
# @plugin(@varlock/1password-plugin)
# @initOp(token=$OP_TOKEN, allowAppAuth=forEnv(dev), account=acmeco)
# @setValuesBulk(opLoadEnvironment(your-environment-id))
# ---

# @type=opServiceAccountToken @sensitive
OP_TOKEN=

API_KEY=
DB_PASSWORD=
```

With a named instance:

```env-spec
# @initOp(id=prod, token=$OP_TOKEN_PROD, allowAppAuth=false)
# @setValuesBulk(opLoadEnvironment(prod, your-environment-id))
```

> **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.

---

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

#### `opLoadEnvironment()`

Load all variables from a 1Password environment. Intended for use with `@setValuesBulk`.

**Signatures:**

- `opLoadEnvironment(environmentId)` - Load from default instance
- `opLoadEnvironment(instanceId, environmentId)` - Load from a specific instance

**Parameters:**

- `environmentId: string` - The 1Password environment ID
- `instanceId?: string` - Instance identifier (static, when using multiple instances)

### Data Types

- `opServiceAccountToken` - 1Password service account token (sensitive, validated format)
Expand Down
4 changes: 2 additions & 2 deletions packages/plugins/1password/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
"varlock": "workspace:^"
},
"devDependencies": {
"@1password/sdk": "^0.3.1",
"@1password/sdk-core": "^0.3.1",
"@1password/sdk": "0.4.1-beta.1",
"@1password/sdk-core": "0.4.1-beta.1",
"@env-spec/utils": "workspace:^",
"@types/node": "catalog:",
"import-meta-resolve": "^4.2.0",
Expand Down
30 changes: 28 additions & 2 deletions packages/plugins/1password/src/cli-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,23 @@ function processOpCliError(err: Error | any) {
tip: ['Double check the field name/id in your item.'],
// TODO: add link to item?
});
} else if (errMessage.includes('unknown command "environment"')) {
return new ResolutionError('1Password CLI does not support the "environment" command', {
tip: [
'The `op environment` command requires a beta version of the 1Password CLI (v2.33.0+).',
'Download it from https://app-updates.agilebits.com/product_history/CLI2 (click "show betas").',
],
});
} else if (errMessage.toLowerCase().includes('environment') && (errMessage.includes('not found') || errMessage.includes('invalid'))) {
return new ResolutionError('1Password environment not found', {
tip: [
'Verify the environment ID is correct.',
'You can find it in the 1Password app under Developer > View Environments.',
'See https://developer.1password.com/docs/environments/',
],
});
}



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

export async function opCliEnvironmentRead(
environmentId: string,
account?: string,
): Promise<string> {
const result = await execOpCliCommand([
'environment',
'read',
environmentId,
...(account ? ['--account', account] : []),
]);
return result;
}

export function getIdsFromShareLink(opItemShareLinkUrl: string) {
const url = new URL(opItemShareLinkUrl);
const vaultId = url.searchParams.get('v')!;
Expand Down
105 changes: 104 additions & 1 deletion packages/plugins/1password/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Resolver } from 'varlock/plugin-lib';

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

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

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

/** Parse env format output from `op environment read` into a flat {name: value} JSON string */
function parseOpEnvOutput(raw: string): string {
const result: Record<string, string> = {};
for (const line of raw.split('\n')) {
const eqPos = line.indexOf('=');
if (eqPos === -1) continue;
const key = line.substring(0, eqPos).trim();
if (!key) continue;
result[key] = line.substring(eqPos + 1);
}
return JSON.stringify(result);
}

class OpPluginInstance {
/** 1Password service account token */
private token?: string;
Expand Down Expand Up @@ -83,6 +96,31 @@ class OpPluginInstance {
}
}

async readEnvironment(environmentId: string): Promise<string> {
if (this.token) {
// Use SDK - supports environments since v0.4.1-beta.1
await this.initSdkClient();
const opClient = await this.opClientPromise;
if (!opClient) throw new Error('Expected op sdk to be initialized');
const response = await opClient.environments.getVariables(environmentId);
// Convert EnvironmentVariable[] to flat {name: value} JSON string
const result: Record<string, string> = {};
for (const v of response.variables) {
result[v.name] = v.value;
}
return JSON.stringify(result);
} else if (this.allowAppAuth) {
// Use CLI for desktop app auth
const cliResult = await opCliEnvironmentRead(environmentId, this.account);
// CLI outputs env format (KEY=value lines) - parse to flat JSON
return parseOpEnvOutput(cliResult);
} else {
throw new SchemaError('Unable to authenticate with 1Password', {
tip: `Plugin instance (${this.id}) must be provided either a service account token or have app auth enabled (allowAppAuth=true)`,
});
}
}

private async executeReadBatch() {
const opClient = await this.opClientPromise;
if (!opClient) throw new Error('Expected op sdk to be initialized');
Expand Down Expand Up @@ -262,3 +300,68 @@ plugin.registerResolverFunction({
return opValue;
},
});

plugin.registerResolverFunction({
name: 'opLoadEnvironment',
label: 'Load all variables from a 1Password environment',
icon: OP_ICON,
argsSchema: {
type: 'array',
arrayMinLength: 1,
arrayMaxLength: 2,
},
process() {
if (!this.arrArgs || !this.arrArgs.length) {
throw new SchemaError('Expected 1 or 2 arguments');
}

let instanceId: string;
let environmentIdResolver: Resolver;

if (this.arrArgs.length === 1) {
instanceId = '_default';
environmentIdResolver = this.arrArgs[0];
} else if (this.arrArgs.length === 2) {
if (!this.arrArgs[0].isStatic) {
throw new SchemaError('expected instance id to be a static value');
}
instanceId = String(this.arrArgs[0].staticValue);
environmentIdResolver = this.arrArgs[1];
} else {
throw new SchemaError('Expected 1 or 2 args');
}

if (!Object.values(pluginInstances).length) {
throw new SchemaError('No 1Password plugin instances found', {
tip: 'Initialize at least one 1Password plugin instance using the @initOp root decorator',
});
}

const selectedInstance = pluginInstances[instanceId];
if (!selectedInstance) {
if (instanceId === '_default') {
throw new SchemaError('1Password plugin instance (without id) not found', {
tip: [
'Either remove the `id` param from your @initOp call',
'or use `opLoadEnvironment(id, environmentId)` to select an instance by id.',
`Possible ids are: ${Object.keys(pluginInstances).join(', ')}`,
].join('\n'),
});
} else {
throw new SchemaError(`1Password plugin instance id "${instanceId}" not found`, {
tip: [`Valid ids are: ${Object.keys(pluginInstances).join(', ')}`].join('\n'),
});
}
}

return { instanceId, environmentIdResolver };
},
async resolve({ instanceId, environmentIdResolver }) {
const selectedInstance = pluginInstances[instanceId];
const environmentId = await environmentIdResolver.resolve();
if (typeof environmentId !== 'string') {
throw new SchemaError('expected environment ID to resolve to a string');
}
return await selectedInstance.readEnvironment(environmentId);
},
});
Loading
Loading