From 1463a79cbfa8ec2ccf8eae1e753a7e7ba53af109 Mon Sep 17 00:00:00 2001 From: Louis Lambeau Date: Thu, 23 Apr 2026 14:38:44 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Add=20task=E2=86=92resource=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks can now declare `dependencies: [...]` listing resource refs that must be built before the task script runs. Bare names are resolved against the task's own component first, then fall back to a global name lookup, matching how `pre` works for task refs. Fully qualified `component:resource` refs are supported too. Also fixes CreateFileOperation to `mkdir -p` the target's parent directory, which was missing and made file resources with nested paths fail with ENOENT. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/fullstack-app/api/Embfile.yml | 21 +++++++++++ src/config/schema.json | 7 ++++ .../operations/fs/CreateFileOperation.ts | 5 ++- .../operations/tasks/RunTasksOperation.ts | 24 +++++++++++++ tests/integration/features/tasks/run.spec.ts | 36 ++++++++++++++++++- 5 files changed, 91 insertions(+), 2 deletions(-) diff --git a/examples/fullstack-app/api/Embfile.yml b/examples/fullstack-app/api/Embfile.yml index d5dd1a6..8dbf81f 100644 --- a/examples/fullstack-app/api/Embfile.yml +++ b/examples/fullstack-app/api/Embfile.yml @@ -1,5 +1,12 @@ description: REST API backend service +resources: + fixture.txt: + type: file + params: + path: .emb/fixture.txt + content: fixture-ok + tasks: test: description: Run API tests @@ -12,3 +19,17 @@ tasks: fail: description: A task that will fail script: exit 1 + + uses-fixture: + description: Reads a file produced by a resource dependency (bare name) + executors: [local] + dependencies: ['fixture.txt'] + script: | + grep fixture-ok .emb/fixture.txt + + uses-fixture-qualified: + description: Reads a file produced by a resource dependency (qualified id) + executors: [local] + dependencies: ['api:fixture.txt'] + script: | + grep fixture-ok .emb/fixture.txt diff --git a/src/config/schema.json b/src/config/schema.json index 47a6c47..9adb210 100644 --- a/src/config/schema.json +++ b/src/config/schema.json @@ -170,6 +170,13 @@ "$ref": "#/definitions/QualifiedIdentifier" } }, + "dependencies": { + "type": "array", + "description": "Resources that must be built before this task runs", + "items": { + "$ref": "#/definitions/QualifiedIdentifier" + } + }, "executors": { "type": "array", "items": { diff --git a/src/monorepo/operations/fs/CreateFileOperation.ts b/src/monorepo/operations/fs/CreateFileOperation.ts index 44a1bc4..af2e452 100644 --- a/src/monorepo/operations/fs/CreateFileOperation.ts +++ b/src/monorepo/operations/fs/CreateFileOperation.ts @@ -1,5 +1,6 @@ import { execa } from 'execa'; -import { open, statfs, utimes, writeFile } from 'node:fs/promises'; +import { mkdir, open, statfs, utimes, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; import { Writable } from 'node:stream'; import * as z from 'zod'; @@ -42,6 +43,8 @@ export class CreateFileOperation extends AbstractOperation< } } + await mkdir(dirname(input.path), { recursive: true }); + if (input.content !== undefined) { await writeFile(input.path, input.content); } else if (input.script) { diff --git a/src/monorepo/operations/tasks/RunTasksOperation.ts b/src/monorepo/operations/tasks/RunTasksOperation.ts index 1a0293d..676e3d9 100644 --- a/src/monorepo/operations/tasks/RunTasksOperation.ts +++ b/src/monorepo/operations/tasks/RunTasksOperation.ts @@ -14,6 +14,7 @@ import { EMBCollection, findRunOrder, TaskInfo } from '@/monorepo'; import { IOperation } from '@/operations'; import { ExecuteLocalCommandOperation } from '../index.js'; +import { BuildResourcesOperation } from '../resources/BuildResourcesOperation.js'; export enum ExecutorType { container = 'container', @@ -51,6 +52,29 @@ export class RunTasksOperation implements IOperation< onAmbiguous: params.allMatching ? 'runAll' : 'error', }); + const { resources } = monorepo; + const qualifyDep = (dep: string, component?: string) => { + if (dep.includes(':') || !component) { + return dep; + } + + const qualified = `${component}:${dep}`; + return resources.some((r) => r.id === qualified) ? qualified : dep; + }; + + const resourceDeps = [ + ...new Set( + ordered.flatMap((t) => + (t.dependencies ?? []).map((d) => qualifyDep(d, t.component)), + ), + ), + ]; + if (resourceDeps.length > 0) { + await monorepo.run(new BuildResourcesOperation(), { + resources: resourceDeps, + }); + } + const hasInteractiveTasks = ordered.find((t) => t.interactive === true); if (hasInteractiveTasks) { monorepo.setTaskRenderer('silent'); diff --git a/tests/integration/features/tasks/run.spec.ts b/tests/integration/features/tasks/run.spec.ts index 933aca2..9054e32 100644 --- a/tests/integration/features/tasks/run.spec.ts +++ b/tests/integration/features/tasks/run.spec.ts @@ -6,9 +6,11 @@ * - Component tasks (api:test, api:lint, api:fail, web:test) */ import { runCommand } from '@oclif/test'; +import { existsSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; import { describe, expect, test } from 'vitest'; -import { useExample } from '../../helpers.js'; +import { EXAMPLES, useExample } from '../../helpers.js'; describe('Tasks - run', () => { useExample('fullstack-app'); @@ -55,4 +57,36 @@ describe('Tasks - run', () => { expect(stdout).toMatch(/Running deps/); expect(stdout).toMatch(/Running build/); }); + + test('builds resource dependencies before running task (bare name)', async () => { + const fixturePath = resolve( + EXAMPLES['fullstack-app'], + 'api/.emb/fixture.txt', + ); + rmSync(fixturePath, { force: true }); + + const { error, stdout } = await runCommand('tasks run api:uses-fixture'); + + expect(error).toBeUndefined(); + expect(stdout).toMatch(/Building api:fixture.txt/); + expect(stdout).toMatch(/Running api:uses-fixture/); + expect(existsSync(fixturePath)).toBe(true); + }); + + test('builds resource dependencies before running task (qualified id)', async () => { + const fixturePath = resolve( + EXAMPLES['fullstack-app'], + 'api/.emb/fixture.txt', + ); + rmSync(fixturePath, { force: true }); + + const { error, stdout } = await runCommand( + 'tasks run api:uses-fixture-qualified', + ); + + expect(error).toBeUndefined(); + expect(stdout).toMatch(/Building api:fixture.txt/); + expect(stdout).toMatch(/Running api:uses-fixture-qualified/); + expect(existsSync(fixturePath)).toBe(true); + }); }); From 2d82cf1d9070d998ecb915aac1ac7bf16812c2af Mon Sep 17 00:00:00 2001 From: Louis Lambeau Date: Thu, 23 Apr 2026 14:51:10 +0200 Subject: [PATCH 2/3] Allow `/` in QualifiedIdentifier refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resource names can already contain `/` (they come from the YAML map key), so refs to them in task `pre`/`dependencies` and resource `dependencies` need to permit `/` too. Without this, declaring a task that depends on a path-style resource like `fastlane/key.p8` fails config validation even though the resource itself is valid. Component/plugin names (the `Identifier` type) keep the stricter pattern — slashes are only allowed in the name suffix of a qualified ref, not in the `component:` prefix. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config/schema.json | 2 +- tests/unit/config/validation.spec.ts | 29 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/config/schema.json b/src/config/schema.json index 9adb210..6ee070e 100644 --- a/src/config/schema.json +++ b/src/config/schema.json @@ -69,7 +69,7 @@ }, "QualifiedIdentifier": { "type": "string", - "pattern": "^([a-zA-Z]+[\\w._-]+:)?[a-zA-Z]+[\\w._-]+$" + "pattern": "^([a-zA-Z]+[\\w._-]+:)?[a-zA-Z]+[\\w./_-]+$" }, "KubernetesConfig": { "type": "object", diff --git a/tests/unit/config/validation.spec.ts b/tests/unit/config/validation.spec.ts index 4b9e3d9..6c0478d 100644 --- a/tests/unit/config/validation.spec.ts +++ b/tests/unit/config/validation.spec.ts @@ -409,4 +409,33 @@ tasks: ); }); }); + + describe('QualifiedIdentifier references', () => { + test('accepts slashes in task dependency refs', async () => { + const config = { + project: { name: 'proj' }, + components: { + mobile: { + resources: { + 'fastlane/key.p8': { + type: 'file', + params: { path: 'fastlane/key.p8', content: 'x' }, + }, + }, + tasks: { + release: { + dependencies: ['fastlane/key.p8', 'mobile:fastlane/key.p8'], + script: 'echo ok', + }, + }, + }, + }, + }; + + const result = await validateUserConfig(config); + expect( + result.components!.mobile.tasks!.release.dependencies, + ).to.deep.equal(['fastlane/key.p8', 'mobile:fastlane/key.p8']); + }); + }); }); From 7f9e8cdb75c077908f9df4f7d85f8a477702ffd8 Mon Sep 17 00:00:00 2001 From: Louis Lambeau Date: Thu, 23 Apr 2026 17:00:34 +0200 Subject: [PATCH 3/3] Add op/file resource type for 1Password attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a dedicated resource type to materialize a 1Password file attachment on disk. Unlike a text-interpolation `${op:...}` reference — which can't safely carry binary bytes through the template pipeline — `op/file` expresses the intent literally and writes raw bytes via `op read --force --out-file`. Usage: resources: fastlane/keystore.jks: type: op/file params: reference: op://MyVault/MyItem/keystore.jks Internals: - OnePasswordProvider.fetchFileAttachment(reference, destPath) shells out to `op read --force --out-file `, which is the only binary-safe output path on the `op` CLI (stdout replaces invalid UTF-8 bytes with U+FFFD, corrupting keystores, .p8 keys, etc.). - OpFileResourceBuilder + FetchOpFileOperation registered as `op/file`. - schema.json gains an `op/file` branch requiring a `reference` that starts with `op://`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config/schema.json | 28 ++++ src/monorepo/plugins/OnePasswordPlugin.ts | 14 ++ .../resources/OpFileResourceBuilder.ts | 96 ++++++++++++ src/monorepo/resources/index.ts | 1 + src/secrets/providers/OnePasswordProvider.ts | 84 +++++++++-- .../resources/OpFileResourceBuilder.spec.ts | 142 ++++++++++++++++++ .../providers/OnePasswordProvider.spec.ts | 83 ++++++++++ 7 files changed, 435 insertions(+), 13 deletions(-) create mode 100644 src/monorepo/resources/OpFileResourceBuilder.ts create mode 100644 tests/unit/monorepo/resources/OpFileResourceBuilder.spec.ts diff --git a/src/config/schema.json b/src/config/schema.json index 6ee070e..e37c720 100644 --- a/src/config/schema.json +++ b/src/config/schema.json @@ -446,6 +446,34 @@ } } } + }, + { + "if": { + "properties": { + "type": { "const": "op/file" } + }, + "required": ["type"] + }, + "then": { + "properties": { + "params": { + "type": "object", + "additionalProperties": false, + "required": ["reference"], + "properties": { + "reference": { + "type": "string", + "pattern": "^op://", + "description": "Full 1Password secret reference, e.g. op://vault/item/file" + }, + "path": { + "type": "string", + "description": "Optional destination path relative to the component. Defaults to the resource name." + } + } + } + } + } } ] }, diff --git a/src/monorepo/plugins/OnePasswordPlugin.ts b/src/monorepo/plugins/OnePasswordPlugin.ts index 6a0ee1d..4295ec6 100644 --- a/src/monorepo/plugins/OnePasswordPlugin.ts +++ b/src/monorepo/plugins/OnePasswordPlugin.ts @@ -32,6 +32,20 @@ export interface OnePasswordPluginConfig { * API_KEY: ${op:Development/api-keys#secret-key} * ``` * + * Supported reference shapes: + * - `vault/item#field` — reads a single field from the item + * - `vault/item` — reads the whole item as a record + * + * To materialize a 1Password file attachment on disk, use the `op/file` + * resource type (which writes the raw bytes via `op read --out-file`): + * ```yaml + * resources: + * fastlane/keystore.jks: + * type: op/file + * params: + * reference: op://MyVault/MyItem/keystore.jks + * ``` + * * Authentication methods: * 1. Pre-authenticated session (interactive) - User runs `op signin` before using EMB * 2. Service account token (CI/CD) - Via `OP_SERVICE_ACCOUNT_TOKEN` env var diff --git a/src/monorepo/resources/OpFileResourceBuilder.ts b/src/monorepo/resources/OpFileResourceBuilder.ts new file mode 100644 index 0000000..0d2ef5e --- /dev/null +++ b/src/monorepo/resources/OpFileResourceBuilder.ts @@ -0,0 +1,96 @@ +import { mkdir, statfs } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { Writable } from 'node:stream'; +import * as z from 'zod'; + +import { getContext } from '@/context.js'; +import { IResourceBuilder, ResourceInfo } from '@/monorepo'; +import { AbstractOperation, OpInput, OpOutput } from '@/operations'; +import { OnePasswordProvider } from '@/secrets/providers/OnePasswordProvider.js'; + +import { ResourceBuildContext, ResourceFactory } from './ResourceFactory.js'; + +const schema = z.object({ + reference: z + .string() + .describe('Full 1Password secret reference, e.g. op://vault/item/file'), + path: z.string().describe('Absolute path where the file should be written'), +}); + +export class FetchOpFileOperation extends AbstractOperation< + typeof schema, + unknown +> { + constructor(protected out?: Writable) { + super(schema); + } + + protected async _run(input: z.input): Promise { + const context = getContext(); + const provider = context?.secrets?.get('op') as + | OnePasswordProvider + | undefined; + + if (!provider) { + throw new Error( + "1Password plugin is not registered. Add it to your .emb.yml: plugins: [{ name: 'op' }]", + ); + } + + await mkdir(dirname(input.path), { recursive: true }); + await provider.fetchFileAttachment(input.reference, input.path); + } +} + +export class OpFileResourceBuilder implements IResourceBuilder< + OpInput, + OpOutput, + boolean +> { + constructor( + protected context: ResourceBuildContext>, + ) {} + + private get relPath(): string { + return this.context.config.params?.path || this.context.config.name; + } + + async getReference(): Promise { + return this.context.component.relative(this.relPath); + } + + async getPath() { + return this.context.component.join(this.relPath); + } + + async mustBuild() { + try { + await statfs(await this.getPath()); + } catch { + return true; + } + } + + async build( + resource: ResourceInfo>, + out?: Writable, + ) { + if (!resource.params?.reference) { + throw new Error( + `Resource '${resource.id}' (type op/file) requires a 'reference' param, e.g. op://vault/item/file`, + ); + } + + const input: OpInput = { + reference: resource.params.reference, + path: await this.getPath(), + }; + + return { + input, + operation: new FetchOpFileOperation(out), + }; + } +} + +ResourceFactory.register('op/file', OpFileResourceBuilder); diff --git a/src/monorepo/resources/index.ts b/src/monorepo/resources/index.ts index 7ef6dd5..ba2f5b7 100644 --- a/src/monorepo/resources/index.ts +++ b/src/monorepo/resources/index.ts @@ -1,4 +1,5 @@ import './FileResourceBuilder.js'; +import './OpFileResourceBuilder.js'; export * from './abstract/index.js'; export * from './ResourceFactory.js'; export * from './types.js'; diff --git a/src/secrets/providers/OnePasswordProvider.ts b/src/secrets/providers/OnePasswordProvider.ts index eb0a2ab..9010921 100644 --- a/src/secrets/providers/OnePasswordProvider.ts +++ b/src/secrets/providers/OnePasswordProvider.ts @@ -107,25 +107,18 @@ export class OnePasswordProvider extends AbstractSecretProvider { + await this.connect(); + + if (!reference.startsWith('op://')) { + throw new OnePasswordError( + `Invalid 1Password reference '${reference}'. Expected format: op://vault/item/file`, + 'OP_INVALID_REFERENCE', + ); + } + + const args = ['read', '--force', '--out-file', destPath, reference]; + if (this.config.account) { + args.push('--account', this.config.account); + } + + try { + await this.execOp(args); + } catch (error) { + const err = error as NodeJS.ErrnoException & { stderr?: string }; + const stderr = err.stderr || err.message || ''; + + if (stderr.includes("isn't a vault")) { + throw new OnePasswordError( + `Vault in reference '${reference}' not found in 1Password`, + 'OP_VAULT_NOT_FOUND', + ); + } + + if (stderr.includes("isn't an item")) { + throw new OnePasswordError( + `Item in reference '${reference}' not found`, + 'OP_ITEM_NOT_FOUND', + ); + } + + if ( + stderr.includes('not signed in') || + stderr.includes('not currently signed in') || + stderr.includes('session expired') + ) { + throw new OnePasswordError( + "Not signed in to 1Password. Run 'op signin' or set OP_SERVICE_ACCOUNT_TOKEN", + 'OP_NOT_AUTHENTICATED', + ); + } + + throw new OnePasswordError( + `Failed to read '${reference}': ${stderr}`, + 'OP_FILE_FETCH_ERROR', + ); + } + } } diff --git a/tests/unit/monorepo/resources/OpFileResourceBuilder.spec.ts b/tests/unit/monorepo/resources/OpFileResourceBuilder.spec.ts new file mode 100644 index 0000000..0f23d28 --- /dev/null +++ b/tests/unit/monorepo/resources/OpFileResourceBuilder.spec.ts @@ -0,0 +1,142 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { rimraf } from 'rimraf'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { Component, Monorepo, ResourceInfo } from '@/monorepo'; +import { OpInput } from '@/operations/index.js'; + +import { + FetchOpFileOperation, + OpFileResourceBuilder, +} from '../../../../src/monorepo/resources/OpFileResourceBuilder.js'; +import { ResourceBuildContext } from '../../../../src/monorepo/resources/ResourceFactory.js'; + +type OpFileParams = Partial>; + +describe('Monorepo / Resources / OpFileResourceBuilder', () => { + let mockComponent: Component; + let mockMonorepo: Monorepo; + let rootDir: string; + + beforeEach(async () => { + rootDir = await mkdtemp(join(tmpdir(), 'embOpFileResource')); + await mkdir(join(rootDir, 'mycomponent'), { recursive: true }); + + mockComponent = { + name: 'mycomponent', + rootDir: 'mycomponent', + join: vi.fn((p: string) => join(rootDir, 'mycomponent', p)), + relative: vi.fn((p: string) => join('mycomponent', p)), + } as unknown as Component; + + mockMonorepo = {} as unknown as Monorepo; + }); + + afterEach(async () => { + await rimraf(rootDir); + }); + + const createBuilder = (params: OpFileParams, name = 'keystore.jks') => { + const config: ResourceInfo = { + id: 'mycomponent:keystore', + name, + component: 'mycomponent', + type: 'op/file', + params, + }; + + const context = { + config, + component: mockComponent, + monorepo: mockMonorepo, + } as ResourceBuildContext>; + + return new OpFileResourceBuilder(context); + }; + + describe('#getPath()', () => { + test('defaults to the resource name', async () => { + const builder = createBuilder( + { reference: 'op://V/I/keystore.jks' }, + 'fastlane/keystore.jks', + ); + const path = await builder.getPath(); + expect(path).toBe(join(rootDir, 'mycomponent', 'fastlane/keystore.jks')); + }); + + test('honours params.path override', async () => { + const builder = createBuilder({ + reference: 'op://V/I/keystore.jks', + path: 'out/my.jks', + }); + const path = await builder.getPath(); + expect(path).toBe(join(rootDir, 'mycomponent', 'out/my.jks')); + }); + }); + + describe('#mustBuild()', () => { + test('true when file does not exist', async () => { + const builder = createBuilder( + { reference: 'op://V/I/file' }, + 'missing.jks', + ); + expect(await builder.mustBuild()).toBe(true); + }); + + test('undefined when file exists', async () => { + await writeFile(join(rootDir, 'mycomponent', 'there.jks'), 'x'); + const builder = createBuilder( + { reference: 'op://V/I/file' }, + 'there.jks', + ); + expect(await builder.mustBuild()).toBeUndefined(); + }); + }); + + describe('#build()', () => { + test('produces a FetchOpFileOperation with reference and resolved dest path', async () => { + const builder = createBuilder( + { + reference: + 'op://client.coverseal/android-keystore.jks/Coverseal.store', + }, + 'fastlane/keystore.jks', + ); + const resource = { + id: 'mycomponent:keystore', + name: 'fastlane/keystore.jks', + component: 'mycomponent', + type: 'op/file', + params: { + reference: + 'op://client.coverseal/android-keystore.jks/Coverseal.store', + }, + } as ResourceInfo>; + + const result = await builder.build(resource); + + expect(result.input).toEqual({ + reference: 'op://client.coverseal/android-keystore.jks/Coverseal.store', + path: join(rootDir, 'mycomponent', 'fastlane/keystore.jks'), + }); + expect(result.operation).toBeInstanceOf(FetchOpFileOperation); + }); + + test('errors if reference is missing', async () => { + const builder = createBuilder({}, 'dest.jks'); + const resource = { + id: 'mycomponent:keystore', + name: 'dest.jks', + component: 'mycomponent', + type: 'op/file', + params: {}, + } as ResourceInfo>; + + await expect(builder.build(resource)).rejects.toThrow( + "requires a 'reference' param", + ); + }); + }); +}); diff --git a/tests/unit/secrets/providers/OnePasswordProvider.spec.ts b/tests/unit/secrets/providers/OnePasswordProvider.spec.ts index 951b09e..cdd61a2 100644 --- a/tests/unit/secrets/providers/OnePasswordProvider.spec.ts +++ b/tests/unit/secrets/providers/OnePasswordProvider.spec.ts @@ -309,6 +309,15 @@ describe('Secrets / Providers / OnePasswordProvider', () => { ).rejects.toThrow('Expected format: vault/item'); }); + test('rejects three-segment paths with a pointer to op/file', async () => { + await expect( + provider.fetchSecret({ path: 'SomeVault/SomeItem/key.pem' }), + ).rejects.toThrow(OnePasswordError); + await expect( + provider.fetchSecret({ path: 'SomeVault/SomeItem/key.pem' }), + ).rejects.toThrow("'op/file' resource type"); + }); + test('throws OnePasswordError when vault not found', async () => { const error = new Error('Vault not found') as NodeJS.ErrnoException & { stderr: string; @@ -385,4 +394,78 @@ describe('Secrets / Providers / OnePasswordProvider', () => { ).rejects.toThrow("Key 'nonexistent' not found in secret"); }); }); + + describe('#fetchFileAttachment()', () => { + beforeEach(async () => { + mockExecOp.mockResolvedValueOnce({ + stdout: JSON.stringify({ email: 'user@example.com' }), + stderr: '', + }); + await provider.connect(); + }); + + test('invokes op read --force --out-file with the reference and dest path', async () => { + mockExecOp.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + await provider.fetchFileAttachment( + 'op://SomeVault/SomeItem/keystore.jks', + '/tmp/some/dest.jks', + ); + + expect(mockExecOp).toHaveBeenCalledWith([ + 'read', + '--force', + '--out-file', + '/tmp/some/dest.jks', + 'op://SomeVault/SomeItem/keystore.jks', + ]); + }); + + test('passes account flag when configured', async () => { + config = { account: 'my-team' }; + provider = new OnePasswordProvider(config); + mockExecOp = vi.spyOn(provider as never, 'execOp'); + + mockExecOp.mockResolvedValueOnce({ + stdout: JSON.stringify({ email: 'user@example.com' }), + stderr: '', + }); + mockExecOp.mockResolvedValueOnce({ stdout: '', stderr: '' }); + + await provider.fetchFileAttachment( + 'op://SomeVault/SomeItem/keystore.jks', + '/tmp/dest.jks', + ); + + const call = mockExecOp.mock.calls[1][0] as string[]; + expect(call.slice(-2)).to.deep.equal(['--account', 'my-team']); + }); + + test('rejects references that do not start with op://', async () => { + await expect( + provider.fetchFileAttachment( + 'SomeVault/SomeItem/keystore.jks', + '/tmp/dest.jks', + ), + ).rejects.toThrow(OnePasswordError); + await expect( + provider.fetchFileAttachment( + 'SomeVault/SomeItem/keystore.jks', + '/tmp/dest.jks', + ), + ).rejects.toThrow('op://vault/item/file'); + }); + + test('surfaces vault-not-found errors', async () => { + const error = new Error('Vault not found') as NodeJS.ErrnoException & { + stderr: string; + }; + error.stderr = "isn't a vault in this account"; + mockExecOp.mockRejectedValueOnce(error); + + await expect( + provider.fetchFileAttachment('op://NonExistent/item/file', '/tmp/dest'), + ).rejects.toThrow(OnePasswordError); + }); + }); });