@pokit/op 0.0.1 → 0.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pokit/op",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Operation utilities for pok CLI applications",
5
5
  "keywords": [
6
6
  "cli",
@@ -25,9 +25,8 @@
25
25
  "bugs": {
26
26
  "url": "https://github.com/notation-dev/openpok/issues"
27
27
  },
28
- "main": "./src/index.ts",
29
- "module": "./src/index.ts",
30
- "types": "./src/index.ts",
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
31
30
  "exports": {
32
31
  ".": {
33
32
  "bun": "./src/index.ts",
@@ -38,13 +37,14 @@
38
37
  "files": [
39
38
  "dist",
40
39
  "README.md",
41
- "LICENSE"
40
+ "LICENSE",
41
+ "src"
42
42
  ],
43
43
  "publishConfig": {
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
- "@pokit/core": "0.0.1"
47
+ "@pokit/core": "0.0.2"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/bun": "latest"
package/src/checks.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { defineCheck } from '@pokit/core';
2
+ import { isInstalled, isAuthenticated, getAuthErrorMessage } from './op';
3
+
4
+ export const opInstalled = defineCheck({
5
+ label: '1Password CLI installed',
6
+ check: async () => {
7
+ const installed = await isInstalled();
8
+ if (!installed) {
9
+ throw new Error(
10
+ '1Password CLI is not installed. ' +
11
+ 'Install from: https://developer.1password.com/docs/cli/get-started/'
12
+ );
13
+ }
14
+ },
15
+ });
16
+
17
+ export const opAuthenticated = defineCheck({
18
+ label: '1Password authenticated',
19
+ check: async () => {
20
+ const authenticated = await isAuthenticated();
21
+ if (!authenticated) {
22
+ throw new Error(getAuthErrorMessage());
23
+ }
24
+ },
25
+ });
package/src/op.ts ADDED
@@ -0,0 +1,239 @@
1
+ import { $ } from 'bun';
2
+
3
+ // =============================================================================
4
+ // Input Validation
5
+ // =============================================================================
6
+
7
+ /**
8
+ * Valid characters for 1Password identifiers (vault, item, field names).
9
+ * Allows alphanumeric, spaces, dashes, underscores, and periods.
10
+ */
11
+ const VALID_IDENTIFIER_PATTERN = /^[a-zA-Z0-9 _.-]+$/;
12
+
13
+ /**
14
+ * Validate a 1Password identifier (vault name, item name, or field name).
15
+ * Throws if the identifier contains invalid characters.
16
+ */
17
+ function validateIdentifier(value: string, type: 'vault' | 'item' | 'field'): void {
18
+ if (!value || value.trim().length === 0) {
19
+ throw new Error(`${type} name cannot be empty`);
20
+ }
21
+ if (!VALID_IDENTIFIER_PATTERN.test(value)) {
22
+ throw new Error(
23
+ `Invalid ${type} name: "${value}". Only alphanumeric characters, spaces, dashes, underscores, and periods are allowed.`
24
+ );
25
+ }
26
+ }
27
+
28
+ // =============================================================================
29
+ // Authentication
30
+ // =============================================================================
31
+
32
+ /**
33
+ * Check if 1Password CLI is installed
34
+ */
35
+ export async function isInstalled(): Promise<boolean> {
36
+ const result = await $`which op`.nothrow().quiet();
37
+ return result.exitCode === 0;
38
+ }
39
+
40
+ /**
41
+ * Check if authenticated with 1Password CLI
42
+ * Works for both desktop (app/signin) and CI (OP_SERVICE_ACCOUNT_TOKEN)
43
+ */
44
+ export async function isAuthenticated(): Promise<boolean> {
45
+ const result = await $`op whoami`.nothrow().quiet();
46
+ return result.exitCode === 0;
47
+ }
48
+
49
+ /**
50
+ * Get helpful error message for authentication failure
51
+ */
52
+ export function getAuthErrorMessage(): string {
53
+ if (process.env.OP_SERVICE_ACCOUNT_TOKEN) {
54
+ return (
55
+ '1Password authentication failed. ' +
56
+ 'The OP_SERVICE_ACCOUNT_TOKEN may be invalid or expired.'
57
+ );
58
+ }
59
+ return (
60
+ '1Password authentication failed. ' +
61
+ 'Either run `op signin` or ensure the 1Password app is running with CLI integration enabled.'
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Check if a vault exists
67
+ */
68
+ export async function vaultExists(vault: string): Promise<boolean> {
69
+ validateIdentifier(vault, 'vault');
70
+ const result = await $`op vault get ${vault} --format=json`.nothrow().quiet();
71
+ return result.exitCode === 0;
72
+ }
73
+
74
+ /**
75
+ * Create a vault
76
+ */
77
+ export async function createVault(vault: string): Promise<void> {
78
+ validateIdentifier(vault, 'vault');
79
+ await $`op vault create ${vault}`.quiet();
80
+ }
81
+
82
+ /**
83
+ * List all vaults
84
+ */
85
+ export async function listVaults(): Promise<string[]> {
86
+ const result = await $`op vault list --format=json`.quiet();
87
+ const vaults = JSON.parse(result.text()) as Array<{ name: string }>;
88
+ return vaults.map((v) => v.name);
89
+ }
90
+
91
+ /**
92
+ * Item structure
93
+ */
94
+ export interface OpItem {
95
+ id: string;
96
+ title: string;
97
+ vault: { id: string; name: string };
98
+ fields: Record<string, string>;
99
+ }
100
+
101
+ /**
102
+ * Check if an item exists in a vault
103
+ */
104
+ export async function itemExists(vault: string, item: string): Promise<boolean> {
105
+ validateIdentifier(vault, 'vault');
106
+ validateIdentifier(item, 'item');
107
+ const result = await $`op item get ${item} --vault=${vault} --format=json`.nothrow().quiet();
108
+ return result.exitCode === 0;
109
+ }
110
+
111
+ /**
112
+ * Get a field value from a 1Password item
113
+ */
114
+ export async function getField(vault: string, item: string, field: string): Promise<string | null> {
115
+ validateIdentifier(vault, 'vault');
116
+ validateIdentifier(item, 'item');
117
+ validateIdentifier(field, 'field');
118
+ const result = await $`op read op://${vault}/${item}/${field}`.nothrow().quiet();
119
+ if (result.exitCode !== 0) {
120
+ return null;
121
+ }
122
+ return result.text().trim();
123
+ }
124
+
125
+ /**
126
+ * Get all fields from a 1Password item
127
+ */
128
+ export async function getItem(vault: string, item: string): Promise<OpItem | null> {
129
+ validateIdentifier(vault, 'vault');
130
+ validateIdentifier(item, 'item');
131
+ const result = await $`op item get ${item} --vault=${vault} --format=json`.nothrow().quiet();
132
+
133
+ if (result.exitCode !== 0) {
134
+ return null;
135
+ }
136
+
137
+ const data = JSON.parse(result.text()) as {
138
+ id: string;
139
+ title: string;
140
+ vault: { id: string; name: string };
141
+ fields: Array<{ label: string; value?: string }>;
142
+ };
143
+
144
+ const fields: Record<string, string> = {};
145
+ for (const f of data.fields) {
146
+ if (f.value) {
147
+ fields[f.label] = f.value;
148
+ }
149
+ }
150
+
151
+ return {
152
+ id: data.id,
153
+ title: data.title,
154
+ vault: data.vault,
155
+ fields,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Get multiple items from a vault in a single batch
161
+ * More efficient than calling getItem multiple times
162
+ */
163
+ export async function getItemsBatch(
164
+ vault: string,
165
+ itemNames: string[]
166
+ ): Promise<Map<string, OpItem>> {
167
+ validateIdentifier(vault, 'vault');
168
+ for (const itemName of itemNames) {
169
+ validateIdentifier(itemName, 'item');
170
+ }
171
+
172
+ const results = new Map<string, OpItem>();
173
+
174
+ // Fetch items in parallel
175
+ await Promise.all(
176
+ itemNames.map(async (itemName) => {
177
+ const item = await getItem(vault, itemName);
178
+ if (item) {
179
+ results.set(itemName, item);
180
+ }
181
+ })
182
+ );
183
+
184
+ return results;
185
+ }
186
+
187
+ /**
188
+ * Set a field on a 1Password item, creating the item if it doesn't exist
189
+ */
190
+ export async function setField(
191
+ vault: string,
192
+ item: string,
193
+ field: string,
194
+ value: string
195
+ ): Promise<void> {
196
+ return setFieldsBatch(vault, item, { [field]: value });
197
+ }
198
+
199
+ /**
200
+ * Set multiple fields on a 1Password item in a single operation.
201
+ * Batches all field updates into a single CLI call for efficiency.
202
+ */
203
+ export async function setFieldsBatch(
204
+ vault: string,
205
+ item: string,
206
+ fields: Record<string, string>
207
+ ): Promise<void> {
208
+ validateIdentifier(vault, 'vault');
209
+ validateIdentifier(item, 'item');
210
+ for (const fieldName of Object.keys(fields)) {
211
+ validateIdentifier(fieldName, 'field');
212
+ }
213
+
214
+ const exists = await itemExists(vault, item);
215
+
216
+ // Build field arguments for all fields
217
+ const fieldArgs = Object.entries(fields).map(
218
+ ([fieldName, fieldValue]) => `${fieldName}[concealed]=${fieldValue}`
219
+ );
220
+
221
+ if (exists) {
222
+ // Update existing item - edit all fields in a single command
223
+ await $`op item edit ${item} --vault=${vault} ${fieldArgs}`.quiet();
224
+ } else {
225
+ // Create new item with all fields
226
+ await $`op item create --category=login --vault=${vault} --title=${item} ${fieldArgs}`.quiet();
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Resolve a secret reference (op://vault/item/field)
232
+ */
233
+ export async function resolveSecret(reference: string): Promise<string | null> {
234
+ const result = await $`op read ${reference}`.nothrow().quiet();
235
+ if (result.exitCode !== 0) {
236
+ return null;
237
+ }
238
+ return result.text().trim();
239
+ }
@@ -0,0 +1,143 @@
1
+ import { z } from 'zod';
2
+ import type { TypedEnvResolver, ResolverResult } from '@pokit/core';
3
+ import { type OpVault, type InferOpVaultKeys, parseOpRef } from './vault';
4
+ import * as op from './op';
5
+
6
+ type KeyMapping = {
7
+ key: string;
8
+ item: string;
9
+ field: string;
10
+ };
11
+
12
+ export type OpResolverConfig<TVault extends OpVault<any>, TEnvs extends string> = {
13
+ vault: TVault;
14
+ vaults: Record<TEnvs, string>;
15
+ };
16
+
17
+ /**
18
+ * Define a 1Password resolver that fetches and writes secrets to 1Password vaults.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * const vault = defineOpVault({
23
+ * POSTGRES_URL: 'supabase:SUPABASE_SESSION_DSN',
24
+ * SUPABASE_URL: 'supabase:SUPABASE_URL',
25
+ * });
26
+ *
27
+ * const opResolver = defineOpResolver({
28
+ * vault,
29
+ * vaults: {
30
+ * dev: 'my-app-secrets-dev',
31
+ * staging: 'my-app-secrets-staging',
32
+ * prod: 'my-app-secrets-prod',
33
+ * },
34
+ * });
35
+ * ```
36
+ */
37
+ export function defineOpResolver<TVault extends OpVault<any>, const TEnvs extends string>(
38
+ config: OpResolverConfig<TVault, TEnvs>
39
+ ): TypedEnvResolver<InferOpVaultKeys<TVault> & string> {
40
+ type VaultKey = InferOpVaultKeys<TVault> & string;
41
+ const envValues = Object.keys(config.vaults) as TEnvs[];
42
+ const availableVars = Object.keys(config.vault.secrets) as VaultKey[];
43
+
44
+ return {
45
+ requiredContext: z.object({
46
+ env: z.enum(envValues as [TEnvs, ...TEnvs[]]),
47
+ }),
48
+ availableVars,
49
+
50
+ resolve: async (keys, context) => {
51
+ const ctx = z.object({ env: z.enum(envValues as [TEnvs, ...TEnvs[]]) }).parse(context);
52
+ const vaultName = config.vaults[ctx.env as TEnvs];
53
+ if (!vaultName) {
54
+ throw new Error(`No vault configured for environment: ${ctx.env}`);
55
+ }
56
+
57
+ // Build mapping of keys to their 1Password item/field locations
58
+ const keyMappings: KeyMapping[] = [];
59
+ const unknownKeys: string[] = [];
60
+
61
+ for (const key of keys) {
62
+ const ref = config.vault.secrets[key];
63
+ if (!ref) {
64
+ unknownKeys.push(key);
65
+ continue;
66
+ }
67
+ const { item, field } = parseOpRef(ref);
68
+ keyMappings.push({ key, item, field });
69
+ }
70
+
71
+ if (unknownKeys.length > 0) {
72
+ throw new Error(`No secret config for keys: ${unknownKeys.join(', ')}`);
73
+ }
74
+
75
+ // Group by item name to minimize 1Password calls
76
+ const itemNames = [...new Set(keyMappings.map((m) => m.item))];
77
+
78
+ // Fetch all items in parallel
79
+ const items = await op.getItemsBatch(vaultName, itemNames);
80
+
81
+ // Extract requested fields from fetched items
82
+ const result: Record<string, string> = {};
83
+ const missingSecrets: string[] = [];
84
+
85
+ for (const { key, item, field } of keyMappings) {
86
+ const opItem = items.get(item);
87
+ if (!opItem) {
88
+ missingSecrets.push(`op://${vaultName}/${item}/${field} (item not found)`);
89
+ continue;
90
+ }
91
+
92
+ const value = opItem.fields[field];
93
+ if (value === undefined) {
94
+ missingSecrets.push(`op://${vaultName}/${item}/${field} (field not found)`);
95
+ continue;
96
+ }
97
+
98
+ result[key] = value;
99
+ }
100
+
101
+ if (missingSecrets.length > 0) {
102
+ throw new Error(
103
+ `Failed to fetch secrets from 1Password:\n - ${missingSecrets.join('\n - ')}`
104
+ );
105
+ }
106
+
107
+ return result as ResolverResult<VaultKey>;
108
+ },
109
+
110
+ write: async (values, context) => {
111
+ const ctx = z.object({ env: z.enum(envValues as [TEnvs, ...TEnvs[]]) }).parse(context);
112
+ const vaultName = config.vaults[ctx.env as TEnvs];
113
+ if (!vaultName) {
114
+ throw new Error(`No vault configured for environment: ${ctx.env}`);
115
+ }
116
+
117
+ // Group values by 1Password item for batch writes
118
+ const byItem = new Map<string, Record<string, string>>();
119
+
120
+ for (const [key, value] of Object.entries(values) as [string, string | undefined][]) {
121
+ if (value === undefined) continue;
122
+
123
+ const ref = config.vault.secrets[key];
124
+ if (!ref) {
125
+ throw new Error(
126
+ `Unknown variable "${key}". ` + `Ensure it is declared in the vault configuration.`
127
+ );
128
+ }
129
+
130
+ const { item, field } = parseOpRef(ref);
131
+ if (!byItem.has(item)) {
132
+ byItem.set(item, {});
133
+ }
134
+ byItem.get(item)![field] = value;
135
+ }
136
+
137
+ // Batch write each item (fail fast on error)
138
+ for (const [item, fields] of byItem) {
139
+ await op.setFieldsBatch(vaultName, item, fields);
140
+ }
141
+ },
142
+ };
143
+ }
package/src/vault.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Vault definition for 1Password secrets.
3
+ *
4
+ * Each entry maps a variable name to an "item:field" reference.
5
+ * Format: `"itemName:fieldName"` where both are required.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const vault = defineOpVault({
10
+ * POSTGRES_URL: 'supabase:SUPABASE_SESSION_DSN',
11
+ * SUPABASE_URL: 'supabase:SUPABASE_URL',
12
+ * STRIPE_SECRET_KEY: 'stripe:STRIPE_SECRET_KEY',
13
+ * });
14
+ * ```
15
+ */
16
+
17
+ export type OpVaultRef = `${string}:${string}`;
18
+
19
+ export type OpVault<TSecrets extends Record<string, OpVaultRef>> = {
20
+ secrets: TSecrets;
21
+ };
22
+
23
+ export type InferOpVaultKeys<T> = T extends OpVault<infer S> ? keyof S : never;
24
+
25
+ /**
26
+ * Parse an "item:field" reference into its components.
27
+ */
28
+ export function parseOpRef(ref: OpVaultRef): { item: string; field: string } {
29
+ const colonIndex = ref.indexOf(':');
30
+ if (colonIndex === -1) {
31
+ throw new Error(`Invalid vault reference: ${ref}. Expected "item:field".`);
32
+ }
33
+ return {
34
+ item: ref.slice(0, colonIndex),
35
+ field: ref.slice(colonIndex + 1),
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Define a vault with typed secret declarations.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * const vault = defineOpVault({
45
+ * POSTGRES_URL: 'supabase:SUPABASE_SESSION_DSN',
46
+ * SUPABASE_URL: 'supabase:SUPABASE_URL',
47
+ * VITE_SUPABASE_URL: 'supabase:SUPABASE_URL',
48
+ * });
49
+ * ```
50
+ */
51
+ export function defineOpVault<const TSecrets extends Record<string, OpVaultRef>>(
52
+ secrets: TSecrets
53
+ ): OpVault<TSecrets> {
54
+ return { secrets };
55
+ }