From aedf9c8e3443ec891848023ff611594575d69910 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:59:38 +0930 Subject: [PATCH 01/52] Added the secrets contract, the c op and c secrets commands, and c secrets init. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Contract lib: secrets.services + sibling secrets.shared (segment-less ids, no gsmSegment), unique-owner sharedKeys across services ∪ shared, dotted owner.KEY disambiguation, opItem references (services./dev, one hop - each item id appears exactly once), lazy opItem resolution. - Commands: c op get/set, c secrets get/push/add/verify/ls/consumers/init. Values only ever display as truncated hashes; --plaintext is TTY-gated and agent-blocked with a discouraged --force. - init drafts the contract from existing declarations (warm session required): value-verified shared.opItem binding and legacy-duplicate removal by hash, sharedKeys authored automatically, plain-language review notes. - verify reconciles structure + op/GSM/k8s hashes (storage visibility, op:EXTRA stale fields) + functions drift (STALE). - Friendly pre-flights: assertOpSession, assertGcloudAuth, assertKubectl. --- bin/c-op-get.js | 108 ++++ bin/c-op-set.js | 219 +++++++ bin/c-op.js | 20 + bin/c-secrets-add.js | 258 +++++++++ bin/c-secrets-consumers.js | 83 +++ bin/c-secrets-get.js | 88 +++ bin/c-secrets-init.js | 110 ++++ bin/c-secrets-ls.js | 71 +++ bin/c-secrets-push.js | 98 ++++ bin/c-secrets-verify.js | 64 +++ bin/c-secrets.js | 37 ++ bin/c.js | 5 + bin/lib/c-secrets-init.js | 982 +++++++++++++++++++++++++++++++ bin/lib/c-secrets-io.js | 1084 +++++++++++++++++++++++++++++++++++ bin/lib/c-secrets-verify.js | 532 +++++++++++++++++ bin/lib/c-secrets.js | 732 +++++++++++++++++++++++ 16 files changed, 4491 insertions(+) create mode 100644 bin/c-op-get.js create mode 100644 bin/c-op-set.js create mode 100644 bin/c-op.js create mode 100644 bin/c-secrets-add.js create mode 100644 bin/c-secrets-consumers.js create mode 100644 bin/c-secrets-get.js create mode 100644 bin/c-secrets-init.js create mode 100644 bin/c-secrets-ls.js create mode 100644 bin/c-secrets-push.js create mode 100644 bin/c-secrets-verify.js create mode 100644 bin/c-secrets.js create mode 100644 bin/lib/c-secrets-init.js create mode 100644 bin/lib/c-secrets-io.js create mode 100644 bin/lib/c-secrets-verify.js create mode 100644 bin/lib/c-secrets.js diff --git a/bin/c-op-get.js b/bin/c-op-get.js new file mode 100644 index 0000000..15215f2 --- /dev/null +++ b/bin/c-op-get.js @@ -0,0 +1,108 @@ +'use strict'; + +const print = (line) => process.stdout.write(`${line}\n`); + +const program = require('commander'); +const { loadConfig, newline, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + opRef, + resolveConsumerKey, + resolveIdentity, + resolveServiceKey, +} = require('./lib/c-secrets'); +const { + assertOpSession, + guardPlaintext, + opRead, + resolveOpItem, + resolveOwnerItem, + sha12, +} = require('./lib/c-secrets-io'); + +program + .arguments(' ') + .option( + '-f, --force', + 'Allow plaintext outside a personal terminal (discouraged).' + ) + .option('-n', 'Do not print the trailing newline character.') + .option('-p, --plaintext', 'Print the value itself instead of its hash.') + .description( + 'Get a secret value summary from 1Password: a truncated sha256, the op:// reference and the length. Values are never displayed unless --plaintext is used.' + ) + .parse(process.argv); + +const [name, env, key] = program.args; + +if (!name || !env || !key) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertOpSession(); + assertKnownEnv({ config, env }); + + const identity = resolveIdentity({ contract, env, name }); + const resolved = + identity.kind === 'service' + ? resolveServiceKey({ + contract, + env, + key, + service: identity.service, + }) + : resolveConsumerKey({ + contract, + consumer: identity.consumer, + key, + }); + + // Unbound services resolve their item lazily (reads never create); + // the shared block resolves too but is never auto-created. + const itemTitle = resolved.owner + ? `${contract.organisation}-${contract.name}/${resolved.owner.name}` + : `${contract.organisation}-${contract.name}/${name}`; + + const item = resolved.owner + ? resolveOwnerItem({ owner: resolved.owner, title: itemTitle }).id + : resolveOpItem({ create: false, title: itemTitle }).id; + + const ref = + resolved.opRef || + opRef({ + contract, + env: identity.kind === 'service' ? env : 'local', + key, + opItem: item, + }); + + const value = opRead({ ref }); + + if (program.plaintext) { + guardPlaintext({ force: program.force }); + + return process.stdout.write(`${value}${newline(program.N)}`); + } + + return print( + `${sha12({ value: Buffer.from(value, 'utf8') })} ${ref} len=${ + value.length + }` + ); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-op-set.js b/bin/c-op-set.js new file mode 100644 index 0000000..320b093 --- /dev/null +++ b/bin/c-op-set.js @@ -0,0 +1,219 @@ +'use strict'; + +const print = (line) => process.stdout.write(`${line}\n`); + +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + resolveIdentity, +} = require('./lib/c-secrets'); +const { + assertOpSession, + generateValue, + opFieldExists, + opUpsertField, + registerServiceInCjs, + resolveOwnerItem, + setOpItemInCjs, + sha12, +} = require('./lib/c-secrets-io'); + +program + .arguments(' ') + .option('-g, --generate', 'Generate a value instead of reading stdin.') + .option( + '-t [type]', + 'Value type for --generate: hex, base64, alphanumeric.' + ) + .option('-l [length]', 'Value length for --generate.') + .description( + 'Upsert a secret value into 1Password. Pipe the value via stdin, or use --generate. New keys are added to the service item; unbound services resolve (or create) their item automatically and register into c.js.' + ) + .parse(process.argv); + +const [name, env, key] = program.args; + +if (!name || !env || !key) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +const readStdin = () => { + if (process.stdin.isTTY) { + throw new Error( + 'Pipe the value via stdin (printf "value" | ...) or use --generate.' + ); + } + + return require('fs') + .readFileSync(0, 'utf8') + .replace(/\r?\n$/, ''); +}; + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertOpSession(); + assertKnownEnv({ config, env }); + + const value = program.generate + ? generateValue({ + length: program.L ? parseInt(program.L, 10) : 32, + type: program.T, + }) + : readStdin(); + + const title = (serviceName) => + `${contract.organisation}-${contract.name}/${serviceName}`; + + const reportBinding = ({ binding, serviceName }) => { + if (binding.created) { + print( + `created 1Password item ${title(serviceName)} (${ + binding.id + })` + ); + } else if (binding.persisted) { + print( + `bound existing 1Password item ${title(serviceName)} (${ + binding.id + }) into c.js` + ); + } + }; + + let item; + let ref; + let section; + + // Resolve the destination: an existing entry (the key's owner), a + // new key on an existing entry, or a brand new service. Unbound + // services resolve (or create) their item and persist the binding. + try { + const identity = resolveIdentity({ contract, env, name }); + + const owner = + identity.kind === 'service' + ? identity.service.keys.includes(key) + ? identity.service + : ( + identity.service.sharedKeys.find( + (shared) => shared.key === key + ) || {} + ).owner + : null; + + if (identity.kind === 'consumer') { + const borrowed = identity.consumer.sharedKeys.find( + (shared) => shared.key === key + ); + + const ownsKey = + identity.consumer.keys.includes(key) || !borrowed; + + const binding = ownsKey + ? { + created: false, + id: contract.dev.opItem, + persisted: false, + } + : resolveOwnerItem({ + owner: borrowed.owner, + title: title(borrowed.owner.name), + }); + + if (binding.persisted) { + setOpItemInCjs({ + name: borrowed.owner.name, + opItem: binding.id, + }); + } + + reportBinding({ + binding, + serviceName: borrowed.owner.name, + }); + + item = binding.id; + section = ownsKey ? identity.consumer.name : 'local'; + } else { + const target = owner || identity.service; + const serviceName = target.name; + const binding = resolveOwnerItem({ + owner: target, + title: title(serviceName), + }); + + if (binding.persisted) { + setOpItemInCjs({ name: serviceName, opItem: binding.id }); + } + + reportBinding({ binding, serviceName }); + + item = binding.id; + section = env; + } + + ref = `op://${contract.vault}/${item}/${section}/${key}`; + } catch (e) { + if (!e.message.includes('No service or dev consumer')) { + throw e; + } + + // A brand new service: resolve-by-title first (no duplicates), + // creating only when the item truly doesn't exist. + if (name === 'shared') { + throw new Error( + 'Use the secrets.shared block for shared keys - add it to c.js (its opItem points at the item storing the values).' + ); + } + + if (!/^[a-z][a-z0-9-]*$/.test(name)) { + throw new Error( + `New service names must be kebab-case ('${name}')` + ); + } + + const binding = resolveOwnerItem({ + owner: null, + title: title(name), + }); + + reportBinding({ binding, serviceName: name }); + + item = binding.id; + section = env; + ref = `op://${contract.vault}/${item}/${section}/${key}`; + + registerServiceInCjs({ key, name, opItem: item }); + } + + opUpsertField({ field: key, item, section, value }); + + if (program.generate) { + return print(ref); + } + + return print( + `${ref} ${sha12({ value: Buffer.from(value, 'utf8') })} len=${ + value.length + }${ + opFieldExists({ field: key, item, section }) + ? '' + : ' (WARNING: field not found after write)' + }` + ); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-op.js b/bin/c-op.js new file mode 100644 index 0000000..478b165 --- /dev/null +++ b/bin/c-op.js @@ -0,0 +1,20 @@ +'use strict'; + +const program = require('commander'); +const { missingCommand } = require('./lib/c'); + +program + .command( + 'get ', + 'Get a secret value summary from 1Password (hash by default).' + ) + .command( + 'set ', + 'Upsert a secret value into 1Password from stdin (or --generate).' + ) + .description( + 'Manage 1Password secret values for the secrets contract in c.js.' + ) + .parse(process.argv); + +missingCommand(program); diff --git a/bin/c-secrets-add.js b/bin/c-secrets-add.js new file mode 100644 index 0000000..8c34144 --- /dev/null +++ b/bin/c-secrets-add.js @@ -0,0 +1,258 @@ +'use strict'; + +const print = (line) => process.stdout.write(`${line}\n`); + +const { readFileSync, writeFileSync } = require('fs'); +const { join } = require('path'); + +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + gsmId, + resolveIdentity, +} = require('./lib/c-secrets'); +const { + addKeyToServiceInCjs, + assertGcloudAuth, + assertOpSession, + generateValue, + gsmDelete, + gsmPushVerified, + opUpsertField, + registerServiceInCjs, + resolveOwnerItem, + setOpItemInCjs, +} = require('./lib/c-secrets-io'); + +program + .arguments(' ') + .option('-g, --generate', 'Generate a value instead of reading stdin.') + .option( + '-t [type]', + 'Value type for --generate: hex, base64, alphanumeric.' + ) + .option('-l [length]', 'Value length for --generate.') + .description( + 'Add a key end to end, in order: 1Password, GSM (created with labels if missing, verified by hash), then the c.js contract (re-validated after the edit). Rolls back what it can on failure. Never touches the cluster.' + ) + .parse(process.argv); + +const [name, env, key] = program.args; + +if (!name || !env || !key) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +const readStdin = () => { + if (process.stdin.isTTY) { + throw new Error( + 'Pipe the value via stdin (printf "value" | ...) or use --generate.' + ); + } + + return readFileSync(0, 'utf8').replace(/\r?\n$/, ''); +}; + +const cjsPath = () => join(process.cwd(), 'c.js'); + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertOpSession(); + assertGcloudAuth(); + assertKnownEnv({ config, env }); + + const value = program.generate + ? generateValue({ + length: program.L ? parseInt(program.L, 10) : 32, + type: program.T, + }) + : readStdin(); + + const backup = readFileSync(cjsPath(), 'utf8'); + + let id; + let binding = { created: false, id: null, persisted: false }; + let isNewService = false; + let item; + let ownerName; + let section; + + // Step 1: 1Password - upsert the field; unbound services resolve + // (or create) their item and persist the binding. + try { + const identity = resolveIdentity({ contract, env, name }); + + if (identity.kind !== 'service') { + throw new Error( + `Dev consumers have no GSM presence ('${name}') - use \`c op set\`.` + ); + } + + const { service } = identity; + const borrowed = service.sharedKeys.find( + (shared) => shared.key === key + ); + const known = service.keys.includes(key) || borrowed; + + ownerName = (borrowed || {}).owner + ? borrowed.owner.name + : service.name; + + binding = resolveOwnerItem({ + owner: borrowed ? borrowed.owner : service, + title: `${contract.organisation}-${contract.name}/${ownerName}`, + }); + + if (binding.persisted) { + setOpItemInCjs({ name: ownerName, opItem: binding.id }); + } + + item = binding.id; + section = env; + + id = known + ? gsmId({ + contract, + env, + key, + owner: borrowed ? borrowed.owner : service, + }) + : gsmId({ contract, env, key, owner: service }); + } catch (e) { + if (!e.message.includes('No service or dev consumer')) { + throw e; + } + + if (!/^[a-z][a-z0-9-]*$/.test(name)) { + throw new Error( + `New service names must be kebab-case ('${name}')` + ); + } + + binding = resolveOwnerItem({ + owner: null, + title: `${contract.organisation}-${contract.name}/${name}`, + }); + + item = binding.id; + id = gsmId({ + contract, + env, + key, + owner: { gsmSegment: true, name }, + }); + isNewService = true; + ownerName = name; + section = env; + } + + opUpsertField({ field: key, item, section, value }); + + // Step 2: GSM - create (with labels) if missing, add a version, + // verify the readback by hash. + return gsmPushVerified({ + contract, + env, + id, + owner: ownerName, + value, + }).then(({ created, readbackSha, sourceSha }) => { + const gsmSummary = `${ + created ? 'created' : 'version added to' + } ${id} op:${sourceSha} gsm:${readbackSha} VERIFIED`; + + // Step 3: c.js - register, then re-load and re-validate. + try { + if (isNewService) { + registerServiceInCjs({ key, name, opItem: item }); + } else { + addKeyToServiceInCjs({ key, name }); + } + + delete require.cache[require.resolve(cjsPath())]; + + const fresh = getSecretsContract({ + config: require(cjsPath()), + }); + const freshService = + name === 'shared' ? fresh.shared : fresh.services[name]; + + if (!freshService) { + throw new Error( + `service '${name}' missing from the edited contract` + ); + } + + const owned = freshService.keys.includes(key); + const borrowedFresh = freshService.sharedKeys.find( + (shared) => shared.key === key + ); + + if (!owned && !borrowedFresh) { + throw new Error( + `key '${key}' not resolvable in the edited contract` + ); + } + + const freshId = gsmId({ + contract: fresh, + env, + key, + owner: borrowedFresh ? borrowedFresh.owner : freshService, + }); + + if (freshId !== id) { + throw new Error( + `edited contract derives '${freshId}', expected '${id}'` + ); + } + } catch (e) { + writeFileSync(cjsPath(), backup); + + const rollback = created + ? `; GSM secret ${id} deleted` + : `; GSM version for ${id} left in place`; + + const orphans = created + ? gsmDelete({ id, project: contract.project }).then( + () => '', + () => + ' (WARNING: deleting the created GSM secret failed - delete it manually)' + ) + : Promise.resolve(''); + + return orphans.then((warning) => { + throw new Error( + `Rolled back c.js (edit failed: ${e.message})${rollback}. The 1Password field remains - re-run to complete.${warning}` + ); + }); + } + + const opSummary = binding.created + ? `1Password item ${contract.organisation}-${contract.name}/${ownerName} created (${item}); field ${section}/${key} upserted` + : `1Password ${item}/${section}/${key} upserted${ + binding.persisted ? `; item bound into c.js` : '' + }`; + + return print( + `${opSummary}; ${gsmSummary}; c.js ${ + isNewService ? 'service' : 'key' + } registered` + ); + }); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-consumers.js b/bin/c-secrets-consumers.js new file mode 100644 index 0000000..89fb42a --- /dev/null +++ b/bin/c-secrets-consumers.js @@ -0,0 +1,83 @@ +'use strict'; + +const print = (line) => process.stdout.write(`${line}\n`); + +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { getSecretsContract } = require('./lib/c-secrets'); +const { assertGcloudAuth } = require('./lib/c-secrets-io'); +const { findConsumers } = require('./lib/c-secrets-verify'); + +program + .arguments('') + .description( + "List everything referencing a GSM secret id: contract consumers, the environment's SecretProviderClasses and deployed Cloud Functions. Gate every deletion on this." + ) + .parse(process.argv); + +const [secretId] = program.args; + +if (!secretId) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertGcloudAuth(); + + return findConsumers({ config, contract, secretId }); + }) + .then(({ contract: consumers, env, functions, k8s }) => { + const total = consumers.length + functions.length + k8s.length; + + print(`env: ${env}\n`); + + print('contract:'); + if (consumers.length === 0) { + print(' (none)'); + } + consumers.forEach(({ borrowed, key, owner, service }) => { + print( + ` ${service} ${key} (owner ${owner}${ + borrowed ? ', borrowed' : ', owned' + })` + ); + }); + + print('\nkubernetes:'); + if (k8s.length === 0) { + print(' (none)'); + } + k8s.forEach(({ name, namespace }) => { + print(` secretproviderclass ${name} (${namespace})`); + }); + + print('\nfunctions:'); + if (functions.length === 0) { + print(' (none)'); + } + functions.forEach(({ name }) => { + print(` ${name}`); + }); + + print( + total === 0 + ? `\nUNREFERENCED - safe to delete (gate satisfied).` + : `\nREFERENCED by ${total} consumer(s) - do not delete.` + ); + + return null; + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-get.js b/bin/c-secrets-get.js new file mode 100644 index 0000000..0fb37b1 --- /dev/null +++ b/bin/c-secrets-get.js @@ -0,0 +1,88 @@ +'use strict'; + +const print = (line) => process.stdout.write(`${line}\n`); + +const program = require('commander'); +const { loadConfig, newline, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + resolveIdentity, + resolveServiceKey, +} = require('./lib/c-secrets'); +const { + assertGcloudAuth, + gsmRead, + guardPlaintext, + sha12, +} = require('./lib/c-secrets-io'); + +program + .arguments(' ') + .option( + '-f, --force', + 'Allow plaintext outside a personal terminal (discouraged).' + ) + .option('-n', 'Do not print the trailing newline character.') + .option('-p, --plaintext', 'Print the value itself instead of its hash.') + .description( + 'Get a GSM secret value summary: a truncated sha256, the secret id and the length. Values are never displayed unless --plaintext is used.' + ) + .parse(process.argv); + +const [name, env, key] = program.args; + +if (!name || !env || !key) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertGcloudAuth(); + assertKnownEnv({ config, env }); + + const identity = resolveIdentity({ contract, env, name }); + + if (identity.kind !== 'service') { + throw new Error( + `Dev consumers have no GSM presence ('${name}') - use \`c op\`.` + ); + } + + const resolved = resolveServiceKey({ + contract, + env, + key, + service: identity.service, + }); + + return gsmRead({ + id: resolved.gsmId, + project: contract.project, + }).then((value) => { + if (program.plaintext) { + guardPlaintext({ force: program.force }); + + return process.stdout.write( + `${value.toString('utf8')}${newline(program.N)}` + ); + } + + return print( + `${sha12({ value })} ${resolved.gsmId} len=${value.length}` + ); + }); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-init.js b/bin/c-secrets-init.js new file mode 100644 index 0000000..368f3b6 --- /dev/null +++ b/bin/c-secrets-init.js @@ -0,0 +1,110 @@ +'use strict'; + +const print = (line) => process.stdout.write(`${line}\n`); + +const { readFileSync, writeFileSync } = require('fs'); +const { join } = require('path'); + +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { getSecretsContract } = require('./lib/c-secrets'); +const { assertGcloudAuth, assertOpSession } = require('./lib/c-secrets-io'); +const { draftContract } = require('./lib/c-secrets-init'); + +program + .description( + 'Draft a secrets contract into c.js by scanning existing declarations (SecretProviderClasses, local secret templates, --set-secrets scripts, env.*.yaml references, docker build args and 1Password item names). Requires a warm 1Password session (the op-session alias) so item bindings resolve. The draft is a starting point: review ownership, opItems and sharedKeys, then verify. Exits without changes when a secrets contract already exists.' + ) + .parse(process.argv); + +const cjsPath = () => join(process.cwd(), 'c.js'); + +return loadConfig() + .then((config) => { + if (config.secrets) { + return print( + 'A secrets contract already exists in c.js - nothing to init.' + ); + } + + // A warm session is required: without the vault scan, services + // draft unbound and (worse) a cold draft cannot be re-run to + // completion. + assertOpSession(); + assertGcloudAuth(); + + return draftContract({ config }).then((draft) => { + const backup = readFileSync(cjsPath(), 'utf8'); + let text = backup; + + // Ensure gcloud.region (the contract requires it; derive it from + // regional resourceNames or the kubectl context). + const gcloud = config.gcloud || {}; + + if (!gcloud.region && draft.region) { + const gcloudMatch = text.match(/\n(\s*)gcloud:\s*\{/); + + if (gcloudMatch) { + text = text.replace( + gcloudMatch[0], + `${gcloudMatch[0]}\n${gcloudMatch[1]} region: '${draft.region}',` + ); + } else { + // No gcloud block at all: add one alongside the secrets + // block (projectId from the project configuration). + draft.blockText = [ + ` gcloud: {`, + ` projectId: '${config.project.gcpProjectId}',`, + ` region: '${draft.region}',`, + ` },`, + draft.blockText, + ].join('\n'); + } + } else if (!gcloud.region && !draft.region) { + throw new Error( + 'Could not derive a region for this project (no regional resourceNames, no gke_ kubectl context) - add gcloud.region to c.js, then re-run.' + ); + } + + // Insert the secrets block before the module's closing brace. + const closing = text.lastIndexOf('};'); + + if (closing === -1) { + throw new Error( + 'Could not find the end of module.exports in c.js - add the secrets block manually.' + ); + } + + text = `${text.slice(0, closing)}${draft.blockText}${text.slice( + closing + )}`; + + writeFileSync(cjsPath(), text); + + // The edited c.js must load and validate. + try { + delete require.cache[require.resolve(cjsPath())]; + + const contract = getSecretsContract({ + config: require(cjsPath()), + }); + + if (!contract) { + throw new Error( + 'edited c.js still has no secrets contract' + ); + } + } catch (e) { + writeFileSync(cjsPath(), backup); + + throw new Error( + `Rolled back c.js - the drafted contract did not validate: ${e.message}` + ); + } + + print('Drafted a secrets contract into c.js.\n'); + + return draft.notes.forEach((note) => print(`- ${note}`)); + }); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-ls.js b/bin/c-secrets-ls.js new file mode 100644 index 0000000..3cc987f --- /dev/null +++ b/bin/c-secrets-ls.js @@ -0,0 +1,71 @@ +'use strict'; + +const print = (line) => process.stdout.write(`${line}\n`); + +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + serviceKeys, +} = require('./lib/c-secrets'); +const { assertGcloudAuth, gsmList } = require('./lib/c-secrets-io'); + +program + .arguments('') + .description( + "List an environment's contract-derived GSM secrets and whether each exists in the project." + ) + .parse(process.argv); + +const [env] = program.args; + +if (!env) { + return reportError(new Error('You need to provide '), program, true); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertGcloudAuth(); + assertKnownEnv({ config, env }); + + return gsmList({ project: contract.project }).then((ids) => { + const existing = new Set(ids); + + Object.values(contract.services) + .concat(contract.shared ? [contract.shared] : []) + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((service) => { + serviceKeys({ contract, env, service }).forEach( + (resolved) => { + print( + `${ + existing.has(resolved.gsmId) + ? 'EXISTS ' + : 'MISSING ' + } ${service.name} ${resolved.key} ${ + resolved.gsmId + }` + ); + } + ); + }); + + if (env === 'local') { + print( + '\nNote: local Secrets resolve 1Password references at dev time; GSM existence is only expected for function/workflow-consumed secrets.' + ); + } + + return null; + }); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-push.js b/bin/c-secrets-push.js new file mode 100644 index 0000000..f32f04d --- /dev/null +++ b/bin/c-secrets-push.js @@ -0,0 +1,98 @@ +'use strict'; + +const print = (line) => process.stdout.write(`${line}\n`); + +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { + assertKnownEnv, + getSecretsContract, + opRef, + resolveIdentity, + resolveServiceKey, +} = require('./lib/c-secrets'); +const { + assertGcloudAuth, + assertOpSession, + gsmPushVerified, + opRead, + resolveOwnerItem, +} = require('./lib/c-secrets-io'); + +program + .arguments(' ') + .description( + "Push the key's 1Password value to GSM: create the secret (with labels, pinned-global replication) if needed, add a version otherwise, and verify the readback by hash." + ) + .parse(process.argv); + +const [name, env, key] = program.args; + +if (!name || !env || !key) { + return reportError( + new Error('You need to provide '), + program, + true + ); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertOpSession(); + assertGcloudAuth(); + assertKnownEnv({ config, env }); + + const identity = resolveIdentity({ contract, env, name }); + + if (identity.kind !== 'service') { + throw new Error( + `Dev consumers have no GSM presence ('${name}') - use \`c op\`.` + ); + } + + const resolved = resolveServiceKey({ + contract, + env, + key, + service: identity.service, + }); + + // Unbound services resolve their item lazily (reads never create); + // the shared block resolves too but is never auto-created. + const ref = + resolved.opRef || + opRef({ + contract, + env, + key, + opItem: resolveOwnerItem({ + owner: resolved.owner, + title: `${contract.organisation}-${contract.name}/${resolved.owner.name}`, + }).id, + }); + + const value = opRead({ ref }); + + return gsmPushVerified({ + contract, + env, + id: resolved.gsmId, + owner: resolved.owner.name, + value, + }).then(({ created, readbackSha, sourceSha }) => + print( + `${created ? 'created' : 'version added to'} ${ + resolved.gsmId + } op:${sourceSha} gsm:${readbackSha} VERIFIED` + ) + ); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets-verify.js b/bin/c-secrets-verify.js new file mode 100644 index 0000000..adb606c --- /dev/null +++ b/bin/c-secrets-verify.js @@ -0,0 +1,64 @@ +'use strict'; + +const print = (line) => process.stdout.write(`${line}\n`); + +const program = require('commander'); +const { loadConfig, reportError } = require('./lib/c'); +const { assertKnownEnv, getSecretsContract } = require('./lib/c-secrets'); +const { + assertGcloudAuth, + assertKubectl, + assertOpSession, +} = require('./lib/c-secrets-io'); +const { kubernetesEnv, verifyEnv } = require('./lib/c-secrets-verify'); + +program + .arguments('') + .description( + 'Verify an environment against the contract: GSM secrets exist and hash-match 1Password, Kubernetes Secrets (for synced services) match structure and hashes, and deployed Cloud Functions reference only contract-derived secret ids (STALE findings otherwise).' + ) + .parse(process.argv); + +const [env] = program.args; + +if (!env) { + return reportError(new Error('You need to provide '), program, true); +} + +return loadConfig() + .then((config) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + throw new Error( + 'No secrets contract in c.js. See https://github.com/idearium/cli#configuration' + ); + } + + assertOpSession(); + assertGcloudAuth(); + + const kenv = kubernetesEnv({ config, env }); + + if (kenv && kenv.context) { + assertKubectl({ context: kenv.context }); + } + + assertKnownEnv({ config, env }); + + return verifyEnv({ config, contract, env }); + }) + .then(({ lines, ok, problems }) => { + lines.forEach((line) => print(line)); + + if (ok) { + return print('\nVERIFIED'); + } + + return reportError( + new Error(`\n${problems.length} PROBLEM(S) FOUND`), + false, + true + ); + }) + .catch((e) => reportError(e, false, true)); diff --git a/bin/c-secrets.js b/bin/c-secrets.js new file mode 100644 index 0000000..6e6db31 --- /dev/null +++ b/bin/c-secrets.js @@ -0,0 +1,37 @@ +'use strict'; + +const program = require('commander'); +const { missingCommand } = require('./lib/c'); + +program + .command( + 'add ', + 'Add a key end to end: 1Password, GSM and the c.js contract. Never touches the cluster.' + ) + .command( + 'consumers ', + 'List everything referencing a GSM secret id. The deletion gate.' + ) + .command( + 'get ', + 'Get a GSM secret value summary (hash by default).' + ) + .command( + 'init', + 'Draft a secrets contract into c.js from existing declarations. Requires a warm 1Password session. No-op when a contract exists.' + ) + .command('ls ', "List an environment's contract-derived GSM secrets.") + .command( + 'push ', + 'Push the 1Password value to GSM (create or addVersion) and verify the readback by hash.' + ) + .command( + 'verify ', + 'Verify an environment: contract structure, op/GSM/k8s hashes, functions drift.' + ) + .description( + 'Manage secrets for the contract in c.js across 1Password, GSM and Kubernetes.' + ) + .parse(process.argv); + +missingCommand(program); diff --git a/bin/c.js b/bin/c.js index e363a0d..612bfd1 100755 --- a/bin/c.js +++ b/bin/c.js @@ -18,8 +18,13 @@ program .command('mk ', 'Shortcuts to control Minikube.') .command('mongo ', 'Shortcuts to help with the database.') .command('npm ', 'Shortcuts to help with NPM.') + .command('op ', 'Shortcuts to manage 1Password secret values.') .command('project ', 'Shortcuts to help with project management.') .command('sdp ', "Shortcuts to help with Section's devpop.") + .command( + 'secrets ', + 'Shortcuts to manage project secrets across their stores.' + ) .command('skaffold ', 'Shortcuts to help with Skaffold.') .command('ts ', 'Shortcuts to help with Tailscale.') .command( diff --git a/bin/lib/c-secrets-init.js b/bin/lib/c-secrets-init.js new file mode 100644 index 0000000..fb1187f --- /dev/null +++ b/bin/lib/c-secrets-init.js @@ -0,0 +1,982 @@ +'use strict'; + +const { readdirSync, readFileSync, statSync } = require('fs'); +const { join } = require('path'); + +const { + gsmDescribe, + gsmRead, + opRead, + quoteIfNeeded, + runOp, + sha12, +} = require('./c-secrets-io'); + +const SKIP_DIRS = ['.compiled', '.git', 'docs', 'node_modules']; + +/** + * Walk a directory tree (bounded depth), returning file paths, skipping + * build output and dependencies. + * @param {Object} options + * @param {String} options.dir The directory to walk. + * @param {Number} [options.depth] The maximum depth. + * @returns {Array} Absolute file paths. + */ +const walkFiles = ({ dir, depth = 4 }) => { + let entries = []; + + try { + entries = readdirSync(dir); + } catch (e) { + return []; + } + + const files = []; + + entries.forEach((entry) => { + if (SKIP_DIRS.includes(entry)) { + return; + } + + const full = join(dir, entry); + + let stats; + + try { + stats = statSync(full); + } catch (e) { + return; + } + + if (stats.isDirectory()) { + if (depth > 0) { + files.push(...walkFiles({ depth: depth - 1, dir: full })); + } + + return; + } + + files.push(full); + }); + + return files; +}; + +/** + * Uppercase a kebab-case path into its environment-variable style key. + * @param {Object} options + * @param {String} options.path The lowercase hyphenated path. + * @returns {String} The environment-variable style key. + */ +const pathToKey = ({ path }) => path.toUpperCase().replace(/-/g, '_'); + +/** + * List the 1Password items for a project prefix, mapped by service name. + * Best-effort: returns null when no session is available. + * @param {Object} options + * @param {String} options.prefix The item title prefix ({org}-{repo}/). + * @returns {Object|null} A map of service name to item id. + */ +const opItemNames = ({ prefix }) => { + let raw; + + try { + raw = runOp({ + args: ['item', 'list', '--vault=Team', '--format=json'], + }); + } catch (e) { + return { error: e.message, names: null }; + } + + const names = {}; + + JSON.parse(raw).forEach((item) => { + if (item.title && item.title.startsWith(prefix)) { + names[item.title.slice(prefix.length)] = item.id; + } + }); + + return { error: null, names }; +}; + +/** + * Parse a GSM secret id against known envs and service names. + * @param {Object} options + * @param {String} options.id The GSM secret id. + * @param {Array} options.envs The known environments. + * @param {Object} options.serviceNames The known service names. + * @param {String} options.repoPrefix The {org}-{repo}- prefix. + * @returns {Object|null} { ambiguous, key, service } - service null for + * segment-less (shared) keys. + */ +const parseId = ({ envs, id, repoPrefix, serviceNames }) => { + if (!id.startsWith(repoPrefix)) { + return null; + } + + const env = envs.find((candidate) => + id.startsWith(`${repoPrefix}${candidate}-`) + ); + + if (!env) { + return null; + } + + const remainder = id.slice(`${repoPrefix}${env}-`.length); + + // Known service names (1Password items, manifest stems) win with a + // longest-prefix match, so multi-segment names split correctly. + const [known] = Object.keys(serviceNames) + .filter((name) => remainder.startsWith(`${name}-`)) + .sort((a, b) => b.length - a.length); + + if (known) { + return { + ambiguous: false, + key: remainder.slice(known.length + 1), + service: known, + }; + } + + const [head, ...rest] = remainder.split('-'); + + // Without a known name, a service'd id keeps at least a two-segment + // key; anything else is treated as segment-less. + if (rest.length >= 2) { + return { ambiguous: false, key: rest.join('-'), service: head }; + } + + return { ambiguous: true, key: remainder, service: null }; +}; + +/** + * Read a JSON file as text for scanning (raw, so scripts strings are + * included), or null when unparseable. + * @param {Object} options + * @param {String} options.file The file path. + * @returns {String|null} + */ +const safeReadJson = ({ file }) => { + try { + return readFileSync(file, 'utf8'); + } catch (e) { + return null; + } +}; + +/** + * Read a text file for scanning, or null. + * @param {Object} options + * @param {String} options.file The file path. + * @returns {String|null} + */ +const safeReadText = ({ file }) => { + if (!/\.(json|ya?ml|tmpl)$/.test(file)) { + return null; + } + + try { + return readFileSync(file, 'utf8'); + } catch (e) { + return null; + } +}; + +/** + * Scan every source that declares secrets (SecretProviderClasses, local + * secret templates, --set-secrets deploy scripts, env.*.yaml references, + * c.js docker build args) and draft a secrets contract block for c.js. + * The draft is for human reconciliation: ownership of shared keys and + * borrowed keys must be reviewed. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @returns {Object} { blockText, notes, region } - blockText is the + * `secrets: {...},` c.js snippet. + */ +/** + * Notes for the minimal scaffold: the no-declarations note plus the vault + * scan status (observable either way). + * @param {Object} options + * @param {String} options.itemPrefix The item title prefix. + * @param {Object} options.opScan The vault scan result. + * @returns {Array} The notes. + */ +const minimalNotes = ({ itemPrefix, opScan }) => { + const notes = [ + 'No existing secret declarations found - drafted a minimal contract. Add services with `c secrets add ` (items are created and bound automatically).', + ]; + + if (!opScan.names) { + notes.push(`1Password scan skipped (${opScan.error}).`); + } else if (Object.keys(opScan.names).length === 0) { + notes.push(`1Password scan found no '${itemPrefix}' items.`); + } + + return notes; +}; + +const draftContract = async ({ config }) => { + const { gcpProjectId: project, name, organisation } = config.project; + const repoPrefix = `${organisation}-${name}-`; + + const environments = + (config.kubernetes && config.kubernetes.environments) || {}; + const envs = Object.keys(environments); + + const itemPrefix = `${organisation}-${name}/`; + const opScan = opItemNames({ prefix: itemPrefix }); + const nameHints = opScan.names || {}; + + const ids = new Map(); + const localRefs = new Map(); + const consumerRefs = []; + let region = null; + + const relPath = ({ file }) => file.replace(`${process.cwd()}/`, ''); + + const fileService = ({ file }) => { + const relative = relPath({ file }); + const [head] = relative.split('/'); + + return relative.includes('/') ? head : null; + }; + + const recordId = ({ id, key, source }) => { + if (!ids.has(id)) { + ids.set(id, { key: null, sources: [] }); + } + + const entry = ids.get(id); + entry.sources.push(source); + + if (key) { + entry.key = key; + } + }; + + envs.forEach((env) => { + const kenv = environments[env] || {}; + const dir = kenv.path ? join(process.cwd(), kenv.path) : null; + + if (!dir) { + return; + } + + walkFiles({ depth: 1, dir }).forEach((file) => { + const text = readFileSync(file, 'utf8'); + + // SecretProviderClasses: resourceName/path pairs. + let rest = text; + let match = + /resourceName:\s*'projects\/[^/]+\/(?:locations\/([^/]+)\/)?secrets\/([^'/]+)\/versions\/latest'\s*\n\s*path:\s*'([^']+)'/.exec( + rest + ); + + while (match) { + region = region || match[1]; + + recordId({ + id: match[2], + key: pathToKey({ path: match[3] }), + source: file, + }); + + consumerRefs.push({ + consumerKey: pathToKey({ path: match[3] }), + file: relPath({ file }), + fileService: file + .split('/') + .pop() + .replace(/\.secretproviderclass\.yaml$/, ''), + id: match[2], + }); + + rest = rest.slice(match.index + match[0].length); + match = + /resourceName:\s*'projects\/[^/]+\/(?:locations\/([^/]+)\/)?secrets\/([^'/]+)\/versions\/latest'\s*\n\s*path:\s*'([^']+)'/.exec( + rest + ); + } + + // Local secret templates: op:// references. + if (/\.secret\.yaml\.tmpl$/.test(file)) { + const service = file + .split('/') + .pop() + .replace(/\.secret\.yaml\.tmpl$/, ''); + + let tmplRest = text; + let ref = + /op:\/\/Team\/([a-z0-9]+)\/([a-z-]+)\/([A-Z0-9_]+)/.exec( + tmplRest + ); + + while (ref) { + const [, item, , key] = ref; + + if (!localRefs.has(service)) { + localRefs.set(service, { item, keys: [] }); + } + + if (!localRefs.get(service).keys.includes(key)) { + localRefs.get(service).keys.push(key); + } + + tmplRest = tmplRest.slice(ref.index + ref[0].length); + ref = + /op:\/\/Team\/([a-z0-9]+)\/([a-z-]+)\/([A-Z0-9_]+)/.exec( + tmplRest + ); + } + } + }); + }); + + // Deploy scripts and env.*.yaml references across the repo. + walkFiles({ depth: 4, dir: process.cwd() }).forEach((file) => { + const base = file.split('/').pop(); + + const text = /\.json$/.test(base) + ? safeReadJson({ file }) + : safeReadText({ file }); + + if (!text) { + return; + } + + let rest = text; + let match = + /([A-Z0-9_]+)=projects\/[^'"\s,]+\/secrets\/([a-z0-9-]+)\/versions\/latest/.exec( + rest + ); + + while (match) { + recordId({ id: match[2], key: match[1], source: file }); + consumerRefs.push({ + consumerKey: match[1], + file: relPath({ file }), + fileService: fileService({ file }), + id: match[2], + }); + + rest = rest.slice(match.index + match[0].length); + match = + /([A-Z0-9_]+)=projects\/[^'"\s,]+\/secrets\/([a-z0-9-]+)\/versions\/latest/.exec( + rest + ); + } + + if (/^env\.[^.]+\.ya?ml$/.test(base)) { + let yamlRest = text; + let yaml = + /^([A-Z0-9_]+):\s*projects\/[^'"\s,]+\/secrets\/([a-z0-9-]+)/m.exec( + yamlRest + ); + + while (yaml) { + recordId({ id: yaml[2], key: null, source: file }); + consumerRefs.push({ + consumerKey: yaml[1], + file: relPath({ file }), + fileService: fileService({ file }), + id: yaml[2], + }); + + yamlRest = yamlRest.slice(yaml.index + yaml[0].length); + yaml = + /^([A-Z0-9_]+):\s*projects\/[^'"\s,]+\/secrets\/([a-z0-9-]+)/m.exec( + yamlRest + ); + } + } + }); + + // Region fallback: the kubectl context carries the zone. + if (!region) { + const context = (environments[envs[0]] || {}).context || ''; + + if (context.startsWith('gke_')) { + const zone = context.split('_')[2] || ''; + region = zone.split('-').slice(0, 2).join('-'); + } + } + + if (ids.size === 0 && localRefs.size === 0) { + // Nothing to scan: a minimal, valid scaffold so `c secrets add` + // works immediately. + return { + blockText: [ + ' secrets: {', + ' services: {},', + ' },', + '', + ].join('\n'), + notes: minimalNotes({ + itemPrefix, + opScan, + }), + region, + }; + } + + localRefs.forEach((entry, service) => { + nameHints[service] = entry.item; + }); + + // Aggregate owned keys per service; service-less keys to shared. + const services = {}; + const shared = { examples: {}, keys: [], opItem: null }; + + [...ids.keys()].sort().forEach((id) => { + const entry = ids.get(id); + const parsed = parseId({ + envs, + id, + repoPrefix, + serviceNames: nameHints, + }); + + if (!parsed) { + return; + } + + const key = entry.key || pathToKey({ path: parsed.key }); + + if (!parsed.service || parsed.ambiguous) { + if (!shared.keys.includes(key)) { + shared.keys.push(key); + } + + if (!shared.examples[key]) { + shared.examples[key] = id; + } + + return; + } + + if (!services[parsed.service]) { + services[parsed.service] = { keys: [], sharedKeys: [] }; + } + + if (!services[parsed.service].keys.includes(key)) { + services[parsed.service].keys.push(key); + } + }); + + // Local template refs add keys + the opItem for their services. + localRefs.forEach(({ item, keys }, service) => { + if (!services[service]) { + services[service] = { keys: [], sharedKeys: [] }; + } + + keys.forEach((key) => { + if (!services[service].keys.includes(key)) { + services[service].keys.push(key); + } + }); + + services[service].opItem = item; + }); + + // c.js docker build args reference the dev item's consumer sections. + const devConsumers = {}; + let devItemFromArgs = null; + + const locations = (config.docker && config.docker.locations) || {}; + + Object.keys(locations).forEach((location) => { + const buildArgs = locations[location].buildArgs || {}; + + Object.keys(buildArgs).forEach((arg) => { + const source = String(buildArgs[arg]); + + const ref = + /op:\/\/Team\/([a-z0-9]+)\/([a-z-]+)\/([A-Z0-9_]+)/.exec( + source + ); + + if (!ref) { + return; + } + + const [, item, section, key] = ref; + + devItemFromArgs = item; + + if (!devConsumers[section]) { + devConsumers[section] = []; + } + + if (!devConsumers[section].includes(key)) { + devConsumers[section].push(key); + } + }); + }); + + // Render the block. + const quote = (value) => `'${value}'`; + const notes = []; + + const lines = []; + lines.push(' secrets: {'); + + if (devItemFromArgs) { + lines.push(' dev: {'); + lines.push(` opItem: ${quote(devItemFromArgs)},`); + lines.push(' consumers: {'); + Object.keys(devConsumers) + .sort() + .forEach((section) => { + lines.push( + ` ${quoteIfNeeded({ name: section })}: {` + ); + lines.push(' keys: ['); + devConsumers[section].sort().forEach((key) => { + lines.push(` ${quote(key)},`); + }); + lines.push(' ],'); + lines.push(' },'); + }); + lines.push(' },'); + lines.push(' },'); + } else { + notes.push( + 'No dev consumers found in docker build args - add secrets.dev.consumers if build args need secrets.' + ); + } + + // Value-verified legacy dedupe: a service-owned key that is also a + // shared key is usually the same credential seen through old + // (service-segmented) and new (service-less) ids. When the GSM values + // hash-match in every checkable environment, the duplicate is dropped + // from the service; on any conflict it is kept for review. + const deduped = []; + + await Promise.all( + Object.keys(services).map(async (serviceName) => { + const duplicates = services[serviceName].keys.filter((owned) => + shared.keys.includes(owned) + ); + + await Promise.all( + duplicates.map(async (key) => { + const kebab = key.toLowerCase().replace(/_/g, '-'); + const legacyExample = `${repoPrefix}${envs[0]}-${serviceName}-${kebab}`; + + const checks = await Promise.all( + envs.map(async (env) => { + const unifiedId = `${repoPrefix}${env}-${kebab}`; + const unified = await gsmDescribe({ + id: unifiedId, + project, + }) + .then((secret) => + secret + ? gsmRead({ id: unifiedId, project }) + : null + ) + .catch(() => null); + + if (!unified) { + return null; + } + + // The legacy id is derived, not discovered: + // the old service-segmented id may no longer be + // referenced by any file. + const legacyId = `${repoPrefix}${env}-${serviceName}-${kebab}`; + + const legacy = await gsmDescribe({ + id: legacyId, + project, + }) + .then((secret) => + secret + ? gsmRead({ id: legacyId, project }) + : null + ) + .catch(() => null); + + if (!legacy) { + return null; + } + + return ( + sha12({ value: unified }) === + sha12({ value: legacy }) + ); + }) + ); + + const checkable = checks.filter((check) => check !== null); + + if (checkable.length > 0 && checkable.every(Boolean)) { + services[serviceName].keys = services[ + serviceName + ].keys.filter((owned) => owned !== key); + + deduped.push({ + example: legacyExample, + key, + service: serviceName, + }); + } + }) + ); + }) + ); + + // Value-verified shared.opItem: find the 1Password item actually + // holding each shared key's value (hash-compare against the shared GSM + // secrets) and bind when exactly one item matches. Never guess. + let boundTo = null; + let boundEnvs = []; + + if (shared.keys.length > 0) { + const unified = {}; + + await Promise.all( + shared.keys.map(async (key) => { + unified[key] = {}; + const kebab = key.toLowerCase().replace(/_/g, '-'); + + await Promise.all( + envs.map(async (env) => { + const id = `${repoPrefix}${env}-${kebab}`; + const value = await gsmDescribe({ id, project }) + .then((secret) => + secret ? gsmRead({ id, project }) : null + ) + .catch(() => null); + + if (value) { + unified[key][env] = sha12({ value }); + } + }) + ); + }) + ); + + const candidates = {}; + + Object.keys(services).forEach((serviceName) => { + const item = services[serviceName].opItem || nameHints[serviceName]; + + if (item) { + candidates[item] = serviceName; + } + }); + + const matches = Object.keys(candidates).filter((item) => + shared.keys.every((key) => { + const envsToCheck = Object.keys(unified[key]); + + if (envsToCheck.length === 0) { + return false; + } + + return envsToCheck.every((env) => { + try { + const value = opRead({ + ref: `op://Team/${item}/${env}/${key}`, + }); + + return ( + sha12({ value: Buffer.from(value, 'utf8') }) === + unified[key][env] + ); + } catch (e) { + return false; + } + }); + }) + ); + + if (matches.length === 1) { + // Bind by reference: the literal id already appears on the + // owning service's entry, and a reference keeps them in step. + [shared.opItem] = [`services.${candidates[matches[0]]}`]; + boundTo = candidates[matches[0]]; + boundEnvs = [ + ...new Set( + shared.keys.flatMap((key) => Object.keys(unified[key])) + ), + ].sort(); + } + } + + // Author sharedKeys: references to shared (service-less) ids attribute + // to the consuming service as a borrower, with the key moved out of its + // owned keys (a key can't be both owned and borrowed). + const authored = []; + const unhandled = []; + + consumerRefs.forEach( + ({ consumerKey, file, fileService: consumerService, id }) => { + const parsed = parseId({ + envs, + id, + repoPrefix, + serviceNames: nameHints, + }); + + if (!parsed || parsed.service) { + return; + } + + const ownerKey = + (ids.get(id) || {}).key || pathToKey({ path: parsed.key }); + + if (!shared.keys.includes(ownerKey) || !consumerService) { + return; + } + + const service = services[consumerService]; + + if (!service || consumerService === 'shared') { + unhandled.push({ file, key: ownerKey }); + + return; + } + + if (consumerKey !== ownerKey) { + // A renamed consumer key can't be expressed by sharedKeys - + // leave it for manual authoring. + unhandled.push({ + file, + key: `${ownerKey} (as ${consumerKey})`, + }); + + return; + } + + service.keys = service.keys.filter((owned) => owned !== ownerKey); + + // Bare sharedKeys require a unique owner - when another + // service still owns the key (a legacy duplicate that could + // not be verified away), emit the dotted form. + const stillOwnedByAService = Object.values(services).some((svc) => + svc.keys.includes(ownerKey) + ); + const ref = stillOwnedByAService ? `shared.${ownerKey}` : ownerKey; + + if (!service.sharedKeys.includes(ref)) { + service.sharedKeys.push(ref); + } + + if ( + !authored.some( + (entry) => + entry.key === ownerKey && + entry.service === consumerService + ) + ) { + authored.push({ key: ownerKey, service: consumerService }); + } + } + ); + + lines.push(' services: {'); + + const renderService = ({ + indent, + keys, + opItem, + segmentless, + serviceName, + sharedKeys = [], + }) => { + lines.push(`${indent}${quoteIfNeeded({ name: serviceName })}: {`); + + if (segmentless) { + lines.push(`${indent} gsmSegment: false,`); + } + + // opItem only when already known (1Password scan or local template + // refs) - unbound services resolve lazily on first use. + if (opItem) { + lines.push(`${indent} opItem: ${quote(opItem)},`); + } + + if (keys.length === 0) { + lines.push(`${indent} keys: [],`); + } else { + lines.push(`${indent} keys: [`); + + keys.sort().forEach((key) => { + lines.push(`${indent} ${quote(key)},`); + }); + + lines.push(`${indent} ],`); + } + + if (sharedKeys.length > 0) { + lines.push( + `${indent} sharedKeys: [${sharedKeys + .sort() + .map((key) => quote(key)) + .join(', ')}],` + ); + } + + lines.push(`${indent}},`); + }; + + Object.keys(services) + .sort() + .forEach((service) => { + renderService({ + indent: ' ', + keys: services[service].keys, + opItem: services[service].opItem || nameHints[service] || null, + segmentless: false, + serviceName: service, + sharedKeys: services[service].sharedKeys, + }); + }); + + lines.push(' },'); + + if (shared.keys.length > 0) { + lines.push(' shared: {'); + + if (shared.opItem) { + lines.push(` opItem: ${quote(shared.opItem)},`); + } + + lines.push(' keys: ['); + + shared.keys.sort().forEach((key) => { + lines.push(` ${quote(key)},`); + }); + + lines.push(' ],'); + lines.push(' },'); + + const keyList = shared.keys.join(', '); + const firstExample = shared.examples[shared.keys[0]]; + + if (shared.opItem) { + notes.push( + `Drafted 'shared' for keys whose GSM ids carry no service name: ${keyList} (ids like ${firstExample}). shared.opItem is set to the '${boundTo}' 1Password item, which already holds the value (matched by comparing value hashes across ${boundEnvs.join( + ', ' + )}) - nothing needs to move in 1Password. Every service that uses ${keyList} lists it under sharedKeys - not keys.` + ); + } else { + notes.push( + `Drafted 'shared' for keys whose GSM ids carry no service name: ${keyList} (ids like ${firstExample}). Set shared.opItem to the 1Password item that holds the value - it is never created automatically. Every service that uses ${keyList} lists it under sharedKeys - not keys.` + ); + } + + deduped.forEach(({ example, key, service }) => { + notes.push( + `'${service}' listed ${key} because old secret ids still point at it (${example}). Those values are identical to the shared ones, so it was removed from '${service}'. The old secrets stay in Google Secret Manager until they're retired.` + ); + }); + + if (authored.length > 0) { + const byKey = {}; + + authored.forEach(({ key, service }) => { + if (!byKey[key]) { + byKey[key] = []; + } + + if (!byKey[key].includes(service)) { + byKey[key].push(service); + } + }); + + notes.push( + `Shared-key consumers drafted automatically (sharedKeys): ${Object.keys( + byKey + ) + .sort() + .map((key) => `${byKey[key].sort().join(', ')} -> ${key}`) + .join('; ')} - review them.` + ); + } + } + + // A service owning a key that 'shared' also owns is usually a legacy-id + // artifact - flag it for review. + Object.keys(services) + .sort() + .forEach((serviceName) => { + services[serviceName].keys + .filter((owned) => shared.keys.includes(owned)) + .sort() + .forEach((owned) => { + const example = [...ids.keys()] + .map((id) => ({ + id, + parsed: parseId({ + envs, + id, + repoPrefix, + serviceNames: nameHints, + }), + })) + .find( + ({ parsed }) => + parsed && + parsed.service === serviceName && + pathToKey({ path: parsed.key }) === owned + ); + + notes.push( + `'${serviceName}' owns '${owned}'${ + example ? ` (from ids like ${example.id})` : '' + } but 'shared' also owns it - confirm the ownership (legacy ids often cause this).` + ); + }); + }); + + if (unhandled.length > 0) { + const byKey = {}; + + unhandled.forEach(({ file, key }) => { + if (!byKey[key]) { + byKey[key] = []; + } + + if (!byKey[key].includes(file)) { + byKey[key].push(file); + } + }); + + notes.push( + `Consumers that couldn't be drafted automatically: ${Object.keys( + byKey + ) + .sort() + .map((key) => `${key} -> ${byKey[key].sort().join(', ')}`) + .join('; ')} - declare sharedKeys on those services manually.` + ); + } + + lines.push(' },'); + lines.push(''); + + notes.push( + "The draft is a starting point: review each service's keys and opItems, then run `c secrets verify `." + ); + + if (!opScan.names) { + notes.push( + `1Password scan skipped (${opScan.error}) - services without a file-derived opItem are left unbound; they resolve (or create) their 1Password item automatically on first use via \`c op set\` / \`c secrets add\`.` + ); + } else if (Object.keys(opScan.names).length === 0) { + notes.push( + `1Password scan found no '${itemPrefix}' items - services without a file-derived opItem are left unbound; they resolve (or create) their 1Password item automatically on first use via \`c op set\` / \`c secrets add\`.` + ); + } + + return { + blockText: lines.join('\n'), + notes, + region, + }; +}; + +module.exports = { draftContract }; diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js new file mode 100644 index 0000000..a5ed97c --- /dev/null +++ b/bin/lib/c-secrets-io.js @@ -0,0 +1,1084 @@ +'use strict'; + +const { execFileSync } = require('child_process'); +const { createHash, randomBytes } = require('crypto'); +const https = require('https'); +const { readFileSync, writeFileSync } = require('fs'); +const { join } = require('path'); +const { homedir } = require('os'); + +const VAULT = 'Team'; +const OP_SESSION_FILE = join(homedir(), '.local/state/idearium/op-session'); + +// Environment variables whose presence indicates a non-interactive agent +// context - plaintext output is hard-blocked while any is set. +const AGENT_ENV_VARS = [ + 'CLAUDECODE', + 'CLAUDE_CODE_ENTRYPOINT', + 'CODEX_SANDBOX', + 'GEMINI_CLI', + 'OPENCODE', +]; + +const sha12 = ({ value }) => + createHash('sha256').update(value).digest('hex').slice(0, 12); + +/** + * Resolve the op session token from the environment or the op-session + * alias' state file. Never print or log the return value. + * @returns {String} The session token. + */ +const opSession = () => { + if (process.env.OP_SESSION_idearium) { + return process.env.OP_SESSION_idearium; + } + + try { + return readFileSync(OP_SESSION_FILE, 'utf8').trim(); + } catch (e) { + throw new Error( + 'No 1Password session. Run the op-session alias (or export OP_SESSION_idearium), then retry.' + ); + } +}; + +/** + * Run the op cli with the session wired in. Input/values pass via stdin or + * in-memory buffers, never via printed output. + * @param {Object} options + * @param {Array} options.args The op arguments. + * @param {String} [options.input] Stdin to pipe into op. + * @returns {String} The command's stdout. + */ +const runOp = ({ args, input }) => { + try { + return execFileSync('op', args, { + encoding: 'utf8', + env: Object.assign({}, process.env, { + OP_SESSION_idearium: opSession(), + }), + input, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (e) { + const stderr = String(e.stderr); + + if ( + stderr.includes('not currently signed in') || + stderr.includes('no active session found') + ) { + throw new Error( + 'The 1Password session has expired. Run the op-session alias, then retry.' + ); + } + + throw e; + } +}; + +/** + * Read a 1Password reference's value (exact bytes, no trailing newline). + * The return value is a secret - never print it; hash it instead. + * @param {Object} options + * @param {String} options.ref The op:// reference. + * @returns {String} The value. + */ +const opRead = ({ ref }) => runOp({ args: ['read', '-n', ref] }); + +/** + * Hash a 1Password reference's value without exposing it. + * @param {Object} options + * @param {String} options.ref The op:// reference. + * @returns {String} A truncated sha256. + */ +const opReadSha = ({ ref }) => + sha12({ value: Buffer.from(opRead({ ref }), 'utf8') }); + +/** + * List a 1Password item's fields (labels and sections only). + * @param {Object} options + * @param {String} options.item The item id. + * @returns {Array} Fields with section labels. + */ +const opItemFields = ({ item }) => { + const raw = runOp({ args: ['item', 'get', item, '--format=json'] }); + + return JSON.parse(raw).fields.map((field) => ({ + field: field.label, + section: field.section ? field.section.label : null, + })); +}; + +/** + * Check whether a section/field exists on an item. + * @param {Object} options + * @param {String} options.field The field label. + * @param {String} options.item The item id. + * @param {String} options.section The section label. + * @returns {Boolean} + */ +const opFieldExists = ({ field, item, section }) => + opItemFields({ item }).some( + (candidate) => + candidate.section === section && candidate.field === field + ); + +/** + * Upsert a field on a 1Password item. The value transits argv (the op cli + * demands it for assignments) - the same trust domain as typing the value. + * @param {Object} options + * @param {String} options.field The field label. + * @param {String} options.item The item id. + * @param {String} options.section The section label. + * @param {String} options.value The value. + * @returns {Void} + */ +const opUpsertField = ({ field, item, section, value }) => { + runOp({ + args: [ + 'item', + 'edit', + item, + `${section}.${field}=${value}`, + '--format=json', + ], + }); +}; + +/** + * Create a 1Password item (DATABASE category, no required built-ins) and + * return its id. + * @param {Object} options + * @param {String} options.title The item title (e.g. ras-rsc/service). + * @returns {String} The new item id. + */ +const opCreateItem = ({ title }) => { + const raw = runOp({ + args: [ + 'item', + 'create', + '--category=database', + `--title=${title}`, + `--vault=${VAULT}`, + '--format=json', + ], + }); + + return JSON.parse(raw).id; +}; + +/** + * Block plaintext output unless the user is at a terminal with no agent + * environment variables set. --force overrides, with a warning. + * @param {Object} options + * @param {Boolean} options.force Whether --force was passed. + * @returns {Void} + */ +const guardPlaintext = ({ force }) => { + if (force) { + process.stderr.write( + 'WARNING: plaintext output forced. Do not paste this into transcripts, files or tickets.\n' + ); + + return; + } + + if (!process.stdout.isTTY) { + throw new Error( + 'Plaintext output requires a terminal (stdout is not a TTY).' + ); + } + + const agent = AGENT_ENV_VARS.find((envVar) => process.env[envVar]); + + if (agent) { + throw new Error( + `Plaintext output is blocked in agent/CI contexts (${agent} is set). Re-run personally, or use --force against your better judgement.` + ); + } +}; + +/** + * Generate a random value for `set --generate`. + * @param {Object} options + * @param {Number} options.length The desired length (characters). + * @param {String} options.type hex, base64 or alphanumeric. + * @returns {String} The generated value. + */ +const generateValue = ({ length = 32, type = 'hex' }) => { + if (type === 'hex') { + return randomBytes(Math.ceil(length / 2)) + .toString('hex') + .slice(0, length); + } + + if (type === 'base64') { + return randomBytes(length).toString('base64').slice(0, length); + } + + if (type === 'alphanumeric') { + const alphabet = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const bytes = randomBytes(length); + + return Array.from(bytes) + .map((byte) => alphabet[byte % alphabet.length]) + .join(''); + } + + throw new Error(`Unknown --type '${type}' (hex, base64, alphanumeric)`); +}; + +/** + * Map gcloud authentication failures to a friendly, actionable error. + * @param {Object} options + * @param {String} options.stderr The failed command's stderr. + * @returns {Error|null} The friendly error, or null when not auth-related. + */ +const gcloudAuthError = ({ stderr }) => { + const phrases = [ + 'Reauthentication failed', + 'to obtain new credentials', + 'cannot prompt during non-interactive execution', + 'do not currently have an active account selected', + ]; + + return phrases.some((phrase) => stderr.includes(phrase)) + ? new Error( + 'gcloud authentication has expired. Run `gcloud auth login` (interactive), then retry.' + ) + : null; +}; + +/** + * Run gcloud and return its stdout. + * @param {Object} options + * @param {Array} options.args The gcloud arguments. + * @returns {String} The command's stdout. + */ +const runGcloud = ({ args }) => { + try { + return execFileSync('gcloud', args, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (e) { + const friendly = gcloudAuthError({ stderr: String(e.stderr) }); + + throw friendly || e; + } +}; + +/** + * Assert gcloud is authenticated (up front, before any work) for commands + * that touch Google Cloud. + * @returns {Void} + */ +const assertGcloudAuth = () => { + runGcloud({ args: ['auth', 'print-access-token'] }); +}; + +/** + * Make a Google Secret Manager REST call against the global endpoint. + * Payloads are built in-process and never logged. + * @param {Object} options + * @param {String} [options.body] A JSON request body. + * @param {String} options.method The HTTP method. + * @param {String} options.path The path after /v1. + * @param {String} options.project The GCP project id. + * @returns {Promise} Resolves with the parsed JSON response. + */ +const gsmRequest = ({ body, method, path, project }) => + new Promise((resolve, reject) => { + const token = runGcloud({ args: ['auth', 'print-access-token'] }); + + const request = https.request( + { + headers: { + 'authorization': `Bearer ${token.trim()}`, + 'content-type': 'application/json', + }, + host: 'secretmanager.googleapis.com', + method, + path: `/v1/projects/${project}${path}`, + }, + (res) => { + let raw = ''; + + res.on('data', (chunk) => { + raw += chunk; + }); + + res.on('end', () => { + if (res.statusCode >= 300) { + return reject( + new Error( + `GSM ${method} ${path} failed (${ + res.statusCode + }): ${raw.slice(0, 200)}` + ) + ); + } + + return resolve(raw ? JSON.parse(raw) : {}); + }); + } + ); + + request.on('error', reject); + + if (body) { + request.write(JSON.stringify(body)); + } + + request.end(); + }); + +/** + * Check whether a GSM secret exists (and return its labels). + * @param {Object} options + * @param {String} options.id The GSM secret id. + * @param {String} options.project The GCP project id. + * @returns {Promise} Resolves with the secret resource, or null. + */ +const gsmDescribe = ({ id, project }) => + gsmRequest({ method: 'GET', path: `/secrets/${id}`, project }).catch( + (e) => { + if (e.message.includes('(404)')) { + return null; + } + + throw e; + } + ); + +/** + * Create a pinned-global GSM secret with the standard labels. + * @param {Object} options + * @param {String} options.env The environment label. + * @param {String} options.id The GSM secret id. + * @param {String} options.managedBy The managed-by label (org-repo). + * @param {String} options.project The GCP project id. + * @param {String} options.region The replication region. + * @param {String} options.service The service label. + * @returns {Promise} + */ +const gsmCreate = ({ env, id, managedBy, project, region, service }) => + gsmRequest({ + body: { + labels: { + 'environment': env, + 'managed-by': managedBy, + service, + }, + replication: { + userManaged: { + replicas: [{ location: region }], + }, + }, + }, + method: 'POST', + path: `/secrets?secretId=${id}`, + project, + }); + +/** + * Add a version to a GSM secret. The value is base64-encoded in-process. + * @param {Object} options + * @param {String} options.id The GSM secret id. + * @param {String} options.project The GCP project id. + * @param {String} options.value The secret value. + * @returns {Promise} + */ +const gsmAddVersion = ({ id, project, value }) => + gsmRequest({ + body: { + payload: { data: Buffer.from(value, 'utf8').toString('base64') }, + }, + method: 'POST', + path: `/secrets/${id}:addVersion`, + project, + }); + +/** + * Read a GSM secret's latest version. The return value is a secret - never + * print it; hash it instead. + * @param {Object} options + * @param {String} options.id The GSM secret id. + * @param {String} options.project The GCP project id. + * @returns {Promise} Resolves with the value as a Buffer. + */ +const gsmRead = ({ id, project }) => + gsmRequest({ + method: 'GET', + path: `/secrets/${id}/versions/latest:access`, + project, + }).then((res) => Buffer.from(res.payload.data, 'base64')); + +/** + * Delete a GSM secret. Point of no return - callers must gate this on a + * consumers check. + * @param {Object} options + * @param {String} options.id The GSM secret id. + * @param {String} options.project The GCP project id. + * @returns {Promise} + */ +const gsmDelete = ({ id, project }) => + gsmRequest({ method: 'DELETE', path: `/secrets/${id}`, project }); + +/** + * List every GSM secret id in the project. + * @param {Object} options + * @param {String} options.project The GCP project id. + * @returns {Promise} Resolves with an array of secret ids. + */ +const gsmList = ({ project }) => { + const ids = []; + + const page = (pageToken) => + gsmRequest({ + method: 'GET', + path: `/secrets${pageToken ? `?pageToken=${pageToken}` : ''}`, + project, + }).then((res) => { + (res.secrets || []).forEach((secret) => { + ids.push(secret.name.split('/').pop()); + }); + + if (res.nextPageToken) { + return page(res.nextPageToken); + } + + return ids; + }); + + return page(''); +}; + +/** + * List the project's Cloud Functions (scoped to the repo's name prefix) + * with the GSM secret ids each consumes. Function names do not encode the + * environment (e.g. beta carries an infix, production does not) - the env + * lives in the referenced secret ids. + * @param {Object} options + * @param {String} options.project The GCP project id. + * @param {String} options.region The region. + * @param {String} options.repoPrefix The function-name prefix (org-repo-). + * @returns {Promise} Resolves with [{ name, secrets: [secretId] }]. + */ +const gcfList = ({ project, region, repoPrefix }) => { + const names = runGcloud({ + args: [ + 'functions', + 'list', + `--filter=name~functions/${repoPrefix}`, + `--format=value(name)`, + `--project=${project}`, + ], + }) + .split('\n') + .map((name) => name.trim().split('/').pop()) + .filter((name) => name.length > 0); + + return Promise.all( + names.map((name) => { + const shortName = name.split('/').pop(); + + const raw = runGcloud({ + args: [ + 'functions', + 'describe', + shortName, + `--format=json`, + `--project=${project}`, + `--region=${region}`, + ], + }); + + const secrets = ( + (JSON.parse(raw).serviceConfig || {}) + .secretEnvironmentVariables || [] + ).map((entry) => entry.secret); + + return { name: shortName, secrets }; + }) + ); +}; + +/** + * Map kubectl failures to a friendly, actionable error. + * @param {Object} options + * @param {String} options.context The kubectl context (for messages). + * @param {String} options.stderr The failed command's stderr. + * @returns {Error|null} The friendly error, or null when unmapped. + */ +const kubectlError = ({ context, stderr }) => { + if ( + /context "[^"]+" does not exist/.test(stderr) || + stderr.includes('no context exists') || + stderr.includes('context was not found') + ) { + return new Error( + `kubectl has no context '${context}'. Run \`c kc context set\`, then retry.` + ); + } + + const unreachable = [ + 'Unable to connect to the server', + 'You must be logged in', + 'Unauthorized', + 'error: exec', + ]; + + return unreachable.some((phrase) => stderr.includes(phrase)) + ? new Error( + `kubectl cannot reach the cluster '${context}' (credentials expired or cluster unreachable). Run \`gcloud auth login\` if expired, and confirm the context with \`c kc context set\`, then retry.` + ) + : null; +}; + +/** + * Run kubectl and return its stdout, with friendly failure mapping. + * @param {Object} options + * @param {Array} options.args The kubectl arguments. + * @param {String} options.context The kubectl context. + * @returns {String} The command's stdout. + */ +const runKubectl = ({ args, context }) => { + try { + return execFileSync('kubectl', ['--context', context].concat(args), { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (e) { + const friendly = kubectlError({ context, stderr: String(e.stderr) }); + + throw friendly || e; + } +}; + +/** + * Assert kubectl can reach the context's server with valid credentials + * (up front, before any work) for commands that read the cluster. + * @param {Object} options + * @param {String} options.context The kubectl context. + * @returns {Void} + */ +const assertKubectl = ({ context }) => { + try { + execFileSync( + 'kubectl', + [ + '--context', + context, + '--request-timeout=10s', + 'get', + '--raw', + '/version', + ], + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } + ); + } catch (e) { + const friendly = kubectlError({ context, stderr: String(e.stderr) }); + + if (friendly) { + throw friendly; + } + + throw new Error( + `kubectl cannot reach the cluster '${context}'. Confirm the context with \`c kc context set\`, then retry. (${String( + e.stderr || e.message + ) + .trim() + .slice(0, 120)})` + ); + } +}; + +/** + * Read one key of a Kubernetes secret. + * @param {Object} options + * @param {String} options.context The kubectl context. + * @param {String} options.key The secret key. + * @param {String} options.namespace The namespace. + * @param {String} options.secret The Kubernetes secret name. + * @returns {String} The base64-encoded value. + */ +const k8sReadKey = ({ context, key, namespace, secret }) => + runKubectl({ + args: [ + '-n', + namespace, + 'get', + 'secret', + secret, + '-o', + `jsonpath={.data.${key}}`, + ], + context, + }).trim(); + +/** + * List the keys of a Kubernetes secret. + * @param {Object} options + * @param {String} options.context The kubectl context. + * @param {String} options.namespace The namespace. + * @param {String} options.secret The Kubernetes secret name. + * @returns {Array} The secret's keys. + */ +const k8sSecretKeys = ({ context, namespace, secret }) => { + const raw = runKubectl({ + args: ['-n', namespace, 'get', 'secret', secret, '-o', 'json'], + context, + }); + + return Object.keys(JSON.parse(raw).data || {}); +}; + +/** + * List every SecretProviderClass in a namespace with the GSM ids their + * parameters reference. + * @param {Object} options + * @param {String} options.context The kubectl context. + * @param {String} options.namespace The namespace. + * @returns {Promise} Resolves with [{ name, ids: [secretId] }]. + */ +const spcList = ({ context, namespace }) => + new Promise((resolve, reject) => { + let raw = ''; + + try { + raw = runKubectl({ + args: [ + '-n', + namespace, + 'get', + 'secretproviderclass', + '-o', + 'json', + ], + context, + }); + } catch (e) { + return reject(e); + } + + const items = JSON.parse(raw).items.map((item) => ({ + ids: + ((item.spec.parameters || {}).secrets || '').match( + /resourceName:\s*'projects\/[^/]+\/secrets\/([^']+)'/g + ) || [], + name: item.metadata.name, + })); + + return resolve( + items.map((item) => ({ + ids: item.ids.map((entry) => + entry + .replace(/.*secrets\//, '') + .replace(/'/g, '') + .replace(/\/versions\/latest$/, '') + ), + name: item.name, + })) + ); + }); + +/** + * Assert a warm 1Password session (up front, before any work) for commands + * that read or write op values. Throws the friendly session errors. + * @returns {Void} + */ +const assertOpSession = () => { + opSession(); + runOp({ args: ['whoami', '--format=json'] }); +}; + +/** + * Find a 1Password item by exact title, returning its id (or null). + * @param {Object} options + * @param {String} options.title The item title. + * @returns {String|null} The item id. + */ +const opFindItemByTitle = ({ title }) => { + const raw = runOp({ + args: ['item', 'list', '--vault=Team', '--format=json'], + }); + + const item = JSON.parse(raw).find((candidate) => candidate.title === title); + + return item ? item.id : null; +}; + +/** + * Resolve a service's 1Password item: a bound opItem wins; otherwise look + * the item up by title; otherwise create it (write paths only - read + * paths pass create false and get a guidance error instead). + * @param {Object} options + * @param {Boolean} options.create Whether an item may be created. + * @param {String} [options.noCreateMessage] A specific error message when + * creation is not allowed (used for the shared block, which never creates). + * @param {String} [options.opItem] The bound opItem, when present. + * @param {String} options.title The item title ({org}-{repo}/{service}). + * @returns {Object} { created, id, persisted } - persisted true when the + * binding should be written back into c.js. + */ +const resolveOpItem = ({ create, noCreateMessage, opItem, title }) => { + if (opItem) { + return { created: false, id: opItem, persisted: false }; + } + + const found = opFindItemByTitle({ title }); + + if (found) { + return { created: false, id: found, persisted: true }; + } + + if (!create) { + throw new Error( + noCreateMessage || + `No 1Password item '${title}' - read commands never create items. Bind one first: c op set (or add) the service's first key.` + ); + } + + return { created: true, id: opCreateItem({ title }), persisted: true }; +}; + +/** + * Resolve the target item for an owning entry: services resolve-or-create; + * the shared block only ever resolves (where a shared value lives is the + * author's decision, never the tool's). + * @param {Object} options + * @param {Object} options.owner The owning service or shared entry. + * @param {String} options.title The item title. + * @returns {Object} { created, id, persisted }. + */ +const resolveOwnerItem = ({ owner, title }) => + owner && owner.name === 'shared' + ? resolveOpItem({ + create: false, + noCreateMessage: + "The 'shared' block stores its values in a 1Password item you choose - set shared.opItem in c.js (often the item of a service that uses the key).", + opItem: owner.opItem, + title, + }) + : resolveOpItem({ create: true, opItem: owner && owner.opItem, title }); + +/** + * Read the c.js file's text from the project directory. + * @returns {String} The file contents. + */ +const readCjs = () => readFileSync(join(process.cwd(), 'c.js'), 'utf8'); + +/** + * Write the c.js file's text back to the project directory. + * @param {Object} options + * @param {String} options.text The new file contents. + * @returns {Void} + */ +const writeCjs = ({ text }) => writeFileSync(join(process.cwd(), 'c.js'), text); + +/** + * Quote a service/key name for c.js: single quotes only when required. + * @param {Object} options + * @param {String} options.name The name to quote. + * @returns {String} The (maybe quoted) name. + */ +const quoteIfNeeded = ({ name }) => + /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : `'${name}'`; + +/** + * Register a new service in c.js's secrets contract (used by set/add when + * the service doesn't exist yet). Inserts alphabetically into + * secrets.services. + * @param {Object} options + * @param {String} options.key The first key the service owns. + * @param {String} options.name The service name. + * @param {String} options.opItem The 1Password item id. + * @returns {Void} + */ +const registerServiceInCjs = ({ key, name, opItem }) => { + const text = readCjs(); + + // The minimal scaffold's single-line empty map: expand it with the + // first entry. + const emptyMatch = text.match(/\n(\s*)services:\s*\{\}/); + + if (emptyMatch) { + const [, emptyIndent] = emptyMatch; + + const expanded = [ + `${emptyIndent}services: {`, + `${emptyIndent} ${quoteIfNeeded({ name })}: {`, + `${emptyIndent} keys: ['${key}'],`, + `${emptyIndent} opItem: '${opItem}',`, + `${emptyIndent} },`, + `${emptyIndent}}`, + ].join('\n'); + + writeCjs({ text: text.replace(emptyMatch[0], `\n${expanded}`) }); + + return; + } + + const servicesMatch = text.match(/\n(\s*)services:\s*\{/); + + if (!servicesMatch) { + throw new Error( + 'Could not find secrets.services in c.js - add the service manually.' + ); + } + + const [, indent] = servicesMatch; + const startIndex = servicesMatch.index + servicesMatch[0].length; + const closingIndex = text.indexOf(`\n${indent}}`, startIndex); + + if (closingIndex === -1) { + throw new Error( + 'Could not find the end of secrets.services in c.js - add the service manually.' + ); + } + + // Walk the top-level entries to find the alphabetical insertion point. + let insertIndex = closingIndex; + const entryRegex = new RegExp(`^${indent} ('?[^':\\n]+'?):`, 'gm'); + entryRegex.lastIndex = startIndex; + + let match = entryRegex.exec(text); + + while (match && match.index < closingIndex) { + const entryName = match[1].replace(/^'|'$/g, ''); + + if (entryName > name) { + insertIndex = match.index; + break; + } + + match = entryRegex.exec(text); + } + + const fullBlock = [ + `${indent} ${quoteIfNeeded({ name })}: {`, + `${indent} keys: ['${key}'],`, + `${indent} opItem: '${opItem}',`, + `${indent} },`, + ].join('\n'); + + writeCjs({ + text: `${text.slice(0, insertIndex)}${fullBlock}\n${text.slice( + insertIndex + )}`, + }); +}; + +/** + * Add a key to a service's keys array in c.js, preserving the array's + * existing layout (single-line or multi-line). + * @param {Object} options + * @param {String} options.key The key to add. + * @param {String} options.name The service name. + * @returns {Void} + */ +const addKeyToServiceInCjs = ({ key, name }) => { + const text = readCjs(); + + const servicePattern = new RegExp( + `\\n(\\s*)${quoteIfNeeded({ name })}:\\s*\\{` + ); + const serviceMatch = text.match(servicePattern); + + if (!serviceMatch) { + throw new Error(`Could not find service '${name}' in c.js.`); + } + + const [, indent] = serviceMatch; + const serviceStart = serviceMatch.index; + const serviceEnd = text.indexOf(`\n${indent}}`, serviceStart); + + if (serviceEnd === -1) { + throw new Error(`Could not find the end of service '${name}' in c.js.`); + } + + const serviceText = text.slice(serviceStart, serviceEnd); + + const keysMatch = serviceText.match(/keys:\s*\[([^\]]*)\]/); + + if (!keysMatch) { + throw new Error( + `Could not find a keys array for service '${name}' in c.js.` + ); + } + + const entries = keysMatch[1] + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => entry.replace(/^'|'$/g, '')); + + if (entries.includes(key)) { + return; + } + + entries.push(key); + entries.sort(); + + const multiline = keysMatch[0].includes('\n'); + + const rendered = multiline + ? `keys: [\n${entries + .map((entry) => `${indent} '${entry}',`) + .join('\n')}\n${indent}],` + : `keys: [${entries.map((entry) => `'${entry}'`).join(', ')}]`; + + const newServiceText = serviceText.replace(keysMatch[0], rendered); + + writeCjs({ + text: + text.slice(0, serviceStart) + + newServiceText + + text.slice(serviceEnd), + }); +}; + +/** + * Bind a service's opItem in c.js (inserting the property when the service + * block has none) - used to persist lazily resolved item ids. + * @param {Object} options + * @param {String} options.name The service name. + * @param {String} options.opItem The 1Password item id. + * @returns {Void} + */ +const setOpItemInCjs = ({ name, opItem }) => { + const text = readCjs(); + + const servicePattern = new RegExp( + `\\n(\\s*)${quoteIfNeeded({ name })}:\\s*\\{` + ); + const serviceMatch = text.match(servicePattern); + + if (!serviceMatch) { + throw new Error(`Could not find service '${name}' in c.js.`); + } + + const [, indent] = serviceMatch; + const serviceStart = serviceMatch.index; + const serviceEnd = text.indexOf(`\n${indent}}`, serviceStart); + + if (serviceEnd === -1) { + throw new Error(`Could not find the end of service '${name}' in c.js.`); + } + + const serviceText = text.slice(serviceStart, serviceEnd); + + if (/opItem:\s*'/.test(serviceText)) { + return; + } + + writeCjs({ + text: `${text.slice( + 0, + serviceEnd + )}\n${indent} opItem: '${opItem}',${text.slice(serviceEnd)}`, + }); +}; + +/** + * Push a value to a GSM secret and verify the readback by hash: create the + * secret (with labels, pinned-global replication) when missing, add a + * version otherwise. versions/latest is eventually consistent, so the + * readback retries briefly before declaring a mismatch. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment (labels). + * @param {String} options.id The GSM secret id. + * @param {String} options.owner The owning service name (labels). + * @param {String} options.value The secret value. + * @returns {Promise} Resolves with { created, sourceSha, readbackSha }. + */ +const gsmPushVerified = ({ contract, env, id, owner, value }) => { + const attempts = 5; + const delayMs = 3000; + + const sleep = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + + const sourceSha = sha12({ value: Buffer.from(value, 'utf8') }); + + return gsmDescribe({ id, project: contract.project }).then((secret) => { + const created = !secret; + + const create = secret + ? Promise.resolve() + : gsmCreate({ + env, + id, + managedBy: `${contract.organisation}-${contract.name}`, + project: contract.project, + region: contract.region, + service: owner, + }); + + return create + .then(() => gsmAddVersion({ id, project: contract.project, value })) + .then(() => { + const attempt = (remaining) => + gsmRead({ id, project: contract.project }).then( + (readback) => { + const readbackSha = sha12({ value: readback }); + + if (readbackSha === sourceSha) { + return { created, readbackSha, sourceSha }; + } + + if (remaining > 0) { + return sleep(delayMs).then(() => + attempt(remaining - 1) + ); + } + + throw new Error( + `Readback MISMATCH for ${id} (source:${sourceSha} gsm:${readbackSha}) - versions/latest may still be propagating.` + ); + } + ); + + return attempt(attempts); + }); + }); +}; + +module.exports = { + addKeyToServiceInCjs, + assertGcloudAuth, + assertKubectl, + assertOpSession, + gcfList, + generateValue, + guardPlaintext, + gsmAddVersion, + gsmCreate, + gsmDelete, + gsmDescribe, + gsmList, + gsmPushVerified, + gsmRead, + k8sReadKey, + k8sSecretKeys, + opCreateItem, + opFieldExists, + opFindItemByTitle, + opRead, + opReadSha, + opUpsertField, + quoteIfNeeded, + registerServiceInCjs, + resolveOpItem, + resolveOwnerItem, + runKubectl, + runOp, + setOpItemInCjs, + sha12, + spcList, +}; diff --git a/bin/lib/c-secrets-verify.js b/bin/lib/c-secrets-verify.js new file mode 100644 index 0000000..8b7a9f1 --- /dev/null +++ b/bin/lib/c-secrets-verify.js @@ -0,0 +1,532 @@ +'use strict'; + +const getPropertyPath = require('get-value'); + +const { formatProjectPrefix } = require('./c-project'); +const { serviceKeys } = require('./c-secrets'); +const { + assertKubectl, + gcfList, + gsmDescribe, + gsmRead, + k8sReadKey, + k8sSecretKeys, + opFindItemByTitle, + opItemFields, + opReadSha, + sha12, + spcList, +} = require('./c-secrets-io'); + +/** + * The Kubernetes environment configuration for an env (context, locations), + * or null when the project declares none. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {String} options.env The environment. + * @returns {Object|null} + */ +const kubernetesEnv = ({ config, env }) => + getPropertyPath(config, `kubernetes.environments.${env}`) || null; + +/** + * The services the env's Kubernetes locations synchronise (those with a + * secretsync location entry). + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {String} options.env The environment. + * @returns {Array} Service names with a secretsync location. + */ +const syncServiceNames = ({ config, env }) => { + const kenv = kubernetesEnv({ config, env }); + + if (!kenv) { + return []; + } + + const names = []; + + Object.values(kenv.locations || {}).forEach((entries) => { + (entries || []).forEach((entry) => { + if (entry.type === 'secretsync' && entry.path) { + names.push(entry.path.replace(/\.secretsync$/, '')); + } + }); + }); + + return [...new Set(names)].sort(); +}; + +/** + * Verify one environment: contract-derived GSM secrets exist, their values + * hash-match 1Password, Kubernetes Secrets (for synced services) match + * structure and hashes, and deployed Cloud Functions reference only + * contract-derived secret ids. Values are compared by hash only. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment. + * @returns {Promise} Resolves with { lines, ok }. + */ +const verifyEnv = ({ config, contract, env }) => { + const lines = []; + const problems = []; + + const report = ({ message, ok }) => { + lines.push(`${ok ? 'OK ' : 'PROBLEM '} ${message}`); + + if (!ok) { + problems.push(message); + } + }; + + const entries = Object.values(contract.services) + .concat(contract.shared ? [contract.shared] : []) + .sort((a, b) => a.name.localeCompare(b.name)); + + const repoPrefix = `${contract.organisation}-${contract.name}-`; + + // Derived ids per env, so function references (which may belong to any + // env - the env is encoded in the secret id, not the function name) + // can be routed to the right contract set. + const envs = Object.keys( + getPropertyPath(config, 'kubernetes.environments') || {} + ); + + const derivedByEnv = {}; + envs.forEach((candidate) => { + derivedByEnv[candidate] = new Set(); + + entries.forEach((service) => { + serviceKeys({ contract, env: candidate, service }).forEach( + (resolved) => { + derivedByEnv[candidate].add(resolved.gsmId); + } + ); + }); + }); + + // Lazily resolved op items per owner (reads never create). + const opItems = {}; + const opItemFor = (owner) => { + if (!(owner.name in opItems)) { + opItems[owner.name] = owner.opItem + ? owner.opItem + : opFindItemByTitle({ + title: `${contract.organisation}-${contract.name}/${owner.name}`, + }); + } + + return opItems[owner.name]; + }; + + // Reverse mapping for readable storage notes: which entry owns an item. + const itemOwners = {}; + + entries.forEach((entry) => { + if (entry.opItem) { + itemOwners[entry.opItem] = entry.name; + } + }); + + // 1. GSM existence + op/GSM value hashes. + return entries + .reduce( + (promise, service) => + promise.then(() => + serviceKeys({ contract, env, service }).reduce( + (keyPromise, resolved) => + keyPromise.then(() => + gsmDescribe({ + id: resolved.gsmId, + project: contract.project, + }).then((secret) => { + if (!secret) { + return report({ + message: `${service.name}/${resolved.key} gsm ${resolved.gsmId} MISSING`, + ok: false, + }); + } + + return gsmRead({ + id: resolved.gsmId, + project: contract.project, + }).then((gsmValue) => { + const gsmSha = sha12({ + value: gsmValue, + }); + + const opItem = opItemFor( + resolved.owner + ); + + if (!opItem) { + return report({ + message: `${service.name}/${resolved.key} op:UNBOUND (no 1Password item '${contract.organisation}-${contract.name}/${resolved.owner.name}') gsm:${gsmSha}`, + ok: false, + }); + } + + const storedIn = itemOwners[opItem] + ? ` (stored in the ${itemOwners[opItem]} item)` + : ''; + + try { + const opSha = opReadSha({ + ref: `op://${contract.vault}/${opItem}/${env}/${resolved.key}`, + }); + + return report({ + message: `${service.name}/${resolved.key} op:${opSha}${storedIn} gsm:${gsmSha}`, + ok: opSha === gsmSha, + }); + } catch (e) { + return report({ + message: `${service.name}/${ + resolved.key + } op:${ + resolved.opRef + } READ-ERROR ${e.message.slice( + 0, + 60 + )} gsm:${gsmSha}`, + ok: false, + }); + } + }); + }) + ), + Promise.resolve() + ) + ), + Promise.resolve() + ) + .then(() => { + // 2. Kubernetes structure + hashes for synced services. + const kenv = kubernetesEnv({ config, env }); + + if (!kenv) { + return null; + } + + const namespace = formatProjectPrefix( + contract.organisation, + contract.name, + env, + true, + true + ); + + return syncServiceNames({ config, env }).reduce( + (promise, name) => + promise.then(() => { + const service = contract.services[name]; + + if (!service) { + return report({ + message: `k8s ${name}: secretsync location but no contract service`, + ok: false, + }); + } + + const expected = serviceKeys({ + contract, + env, + service, + }); + + try { + const liveKeys = k8sSecretKeys({ + context: kenv.context, + namespace, + secret: name, + }).sort(); + + const expectedKeys = expected + .map((resolved) => resolved.key) + .sort(); + + const structureOk = + JSON.stringify(liveKeys) === + JSON.stringify(expectedKeys); + + report({ + message: `k8s ${name}: ${liveKeys.length} keys${ + structureOk + ? '' + : ` (expected ${ + expectedKeys.length + }: ${expectedKeys.join(', ')})` + }`, + ok: structureOk, + }); + + if (!structureOk) { + return null; + } + + return expected.reduce( + (keyPromise, resolved) => + keyPromise.then(() => { + const k8sValue = Buffer.from( + k8sReadKey({ + context: kenv.context, + key: resolved.key, + namespace, + secret: name, + }), + 'base64' + ); + + report({ + message: `k8s ${name}/${ + resolved.key + } ${sha12({ value: k8sValue })}`, + ok: true, + }); + }), + Promise.resolve() + ); + } catch (e) { + return report({ + message: `k8s ${name}: READ-ERROR ${e.message.slice( + 0, + 80 + )}`, + ok: false, + }); + } + }), + Promise.resolve() + ); + }) + .then(() => { + // 3. Stale 1Password fields: fields present in known sections + // (environment sections on service items, consumer sections on + // the dev item) that the contract does not assign anywhere. + const expected = new Set(); + const note = (item, section, field) => + `${item}|${section}|${field}`; + + entries.forEach((entry) => { + const item = opItemFor(entry); + + if (!item) { + return; + } + + entry.keys.forEach((key) => { + envs.forEach((entryEnv) => { + expected.add(note(item, entryEnv, key)); + }); + }); + }); + + const itemsToScan = new Set(); + + entries.forEach((entry) => { + const item = opItemFor(entry); + + if (item) { + itemsToScan.add(item); + } + }); + + const consumerSections = new Set( + contract.dev ? Object.keys(contract.dev.consumers) : [] + ); + + if (contract.dev) { + itemsToScan.add(contract.dev.opItem); + + consumerSections.forEach((consumerName) => { + contract.dev.consumers[consumerName].keys.forEach((key) => { + expected.add( + note(contract.dev.opItem, consumerName, key) + ); + }); + }); + } + + const scanned = [...itemsToScan].map((item) => + Promise.resolve() + .then(() => opItemFields({ item })) + .then((fields) => ({ fields, item })) + .catch(() => null) + ); + + return Promise.all(scanned).then((results) => { + results.filter(Boolean).forEach(({ fields, item }) => { + const ownerName = itemOwners[item] || 'dev'; + const isDevItem = + contract.dev && contract.dev.opItem === item; + + fields.forEach(({ field, section }) => { + const knownSection = isDevItem + ? consumerSections.has(section) + : envs.includes(section); + + if ( + knownSection && + !expected.has(note(item, section, field)) + ) { + report({ + message: `op:EXTRA ${ownerName}/${section}/${field} (in 1Password but not the contract)`, + ok: false, + }); + } + }); + }); + }); + }) + .then(() => + // 4. Deployed-functions drift: every repo-prefixed secret id a + // function references must be in the contract-derived set for + // the env encoded in the id (function names don't carry the + // env - beta has an infix, production doesn't). + gcfList({ + project: contract.project, + region: contract.region, + repoPrefix, + }).then((functions) => { + functions.forEach((fn) => { + fn.secrets.forEach((id) => { + if (!id.startsWith(repoPrefix)) { + return; + } + + const refEnv = envs.find((candidate) => + id.startsWith(`${repoPrefix}${candidate}-`) + ); + + if (refEnv && derivedByEnv[refEnv].has(id)) { + lines.push(`OK function ${fn.name} -> ${id}`); + + return; + } + + report({ + message: `STALE function ${fn.name} -> ${id}${ + refEnv ? '' : ' (unknown env)' + } (not in contract)`, + ok: false, + }); + }); + }); + + return { + lines, + ok: problems.length === 0, + problems, + }; + }) + ); +}; + +/** + * Find everything referencing a GSM secret id: contract consumers, the + * environment's SecretProviderClasses and deployed Cloud Functions. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {Object} options.contract The normalised contract. + * @param {String} options.secretId The GSM secret id. + * @returns {Promise} Resolves with { contract, env, functions, k8s }. + */ +const findConsumers = ({ config, contract, secretId }) => { + const prefix = `${contract.organisation}-${contract.name}-`; + + if (!secretId.startsWith(prefix)) { + throw new Error( + `'${secretId}' does not carry this project's prefix (${prefix})` + ); + } + + const envs = Object.keys( + getPropertyPath(config, 'kubernetes.environments') || {} + ); + + const env = envs.find((candidate) => + secretId.startsWith(`${prefix}${candidate}-`) + ); + + if (!env) { + throw new Error( + `Could not parse an environment out of '${secretId}' (known envs: ${ + envs.join(', ') || 'none' + })` + ); + } + + const result = { + contract: [], + env, + functions: [], + k8s: [], + }; + + // 1. Contract consumers (services and the shared block deriving this + // id for this env). + Object.values(contract.services) + .concat(contract.shared ? [contract.shared] : []) + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((service) => { + serviceKeys({ contract, env, service }).forEach((resolved) => { + if (resolved.gsmId === secretId) { + result.contract.push({ + borrowed: resolved.owner.name !== service.name, + key: resolved.key, + owner: resolved.owner.name, + service: service.name, + }); + } + }); + }); + + const kenv = kubernetesEnv({ config, env }); + const namespace = formatProjectPrefix( + contract.organisation, + contract.name, + env, + true, + true + ); + + // 2 + 3. Live consumers (k8s SecretProviderClasses, functions). + if (kenv && kenv.context) { + assertKubectl({ context: kenv.context }); + } + + const live = kenv + ? Promise.all([ + spcList({ context: kenv.context, namespace }), + gcfList({ + project: contract.project, + region: contract.region, + repoPrefix: `${contract.organisation}-${contract.name}-`, + }), + ]) + : Promise.resolve([[], []]); + + return live.then(([spcs, functions]) => { + spcs.forEach((spc) => { + if (spc.ids.includes(secretId)) { + result.k8s.push({ name: spc.name, namespace }); + } + }); + + functions.forEach((fn) => { + if (fn.secrets.includes(secretId)) { + result.functions.push({ name: fn.name }); + } + }); + + return result; + }); +}; + +module.exports = { + findConsumers, + kubernetesEnv, + syncServiceNames, + verifyEnv, +}; diff --git a/bin/lib/c-secrets.js b/bin/lib/c-secrets.js new file mode 100644 index 0000000..8ce530e --- /dev/null +++ b/bin/lib/c-secrets.js @@ -0,0 +1,732 @@ +'use strict'; + +const VAULT = 'Team'; + +const SERVICE_PROPERTIES = ['keys', 'opItem', 'sharedKeys']; +const CONSUMER_PROPERTIES = ['keys', 'sharedKeys']; + +const isPlainObject = ({ value }) => + Object.prototype.toString.call(value) === '[object Object]'; + +const isNonEmptyString = ({ value }) => + typeof value === 'string' && value.length > 0; + +const assertPlainObject = ({ message, value }) => { + if (!isPlainObject({ value })) { + throw new Error(message); + } + + return value; +}; + +/** + * Lowercase a key and convert underscores to hyphens, matching the GSM + * secret id and SecretProviderClass path conventions. + * @param {Object} options + * @param {String} options.key The environment-variable style key. + * @returns {String} The key, lowercase and hyphenated. + */ +const kebabCase = ({ key }) => key.toLowerCase().replace(/_/g, '-'); + +/** + * Assert an object declares no properties outside an allowed list. Catches + * typos like `key` instead of `keys` at load time. + * @param {Object} options + * @param {Array} options.allowed The allowed property names. + * @param {String} options.message The error prefix naming what is checked. + * @param {Object} options.object The object to check. + * @returns {Void} + */ +const assertKnownProperties = ({ allowed, message, object }) => { + Object.keys(object).forEach((property) => { + if (!allowed.includes(property)) { + throw new Error( + `${message}: unknown property '${property}' (allowed: ${allowed + .map((p) => `'${p}'`) + .join(', ')})` + ); + } + }); +}; + +/** + * Validate an array of key names. + * @param {Object} options + * @param {Boolean} options.allowDotted Whether dotted 'owner.KEY' entries + * are valid. + * @param {String} options.message The error prefix naming what is checked. + * @param {Array} options.value The value to validate. + * @returns {Array} The validated array of key names. + */ +const assertKeyArray = ({ allowDotted, message, value }) => { + if (!value) { + return []; + } + + if (!Array.isArray(value)) { + throw new Error(`${message}: must be an array`); + } + + value.forEach((entry) => { + if (!isNonEmptyString({ value: entry })) { + throw new Error(`${message}: entries must be non-empty strings`); + } + + if (entry.includes('/') || entry.includes(' ')) { + throw new Error(`${message}: entries must not contain '/' or ' '`); + } + + if (entry.includes('.') && !allowDotted) { + throw new Error(`${message}: dotted entries are not valid here`); + } + }); + + if (new Set(value).size !== value.length) { + throw new Error(`${message}: duplicate entries`); + } + + return [...value]; +}; + +/** + * Validate the shape of a service or consumer entry (same shape: owned + * keys, borrowed sharedKeys). Consumers have no GSM presence and no opItem + * of their own - their owned keys live in the dev item. + * @param {Object} options + * @param {Object} options.entry The raw service/consumer configuration. + * @param {Boolean} options.hasGsm Whether the entry has a GSM presence. + * @param {String} options.name The service/consumer name. + * @returns {Object} The shape-validated entry. + */ +const validateEntry = ({ entry, hasGsm, name }) => { + assertPlainObject({ + message: `secrets entry '${name}' must be an object`, + value: entry, + }); + + assertKnownProperties({ + allowed: hasGsm ? SERVICE_PROPERTIES : CONSUMER_PROPERTIES, + message: `secrets.${name}`, + object: entry, + }); + + const keys = assertKeyArray({ + message: `secrets.${name}.keys`, + value: entry.keys, + }); + + // opItem is optional: an unbound service resolves (or creates) its + // 1Password item lazily on first use. + const validated = { + keys, + name, + sharedKeys: assertKeyArray({ + allowDotted: true, + message: `secrets.${name}.sharedKeys`, + value: entry.sharedKeys, + }), + }; + + if (hasGsm) { + // Internal only: services derive service-segmented GSM ids; the + // shared entry is the only segment-less owner. + validated.gsmSegment = true; + + if (isNonEmptyString({ value: entry.opItem })) { + validated.opItem = entry.opItem; + } + } + + return validated; +}; + +/** + * Validate the secrets.shared block (a sibling of services, not a service): + * keys whose GSM ids carry no service segment, stored in a 1Password item + * of the author's choosing (shared.opItem). + * @param {Object} options + * @param {Object} options.rawShared The raw secrets.shared configuration. + * @returns {Object} The normalised shared entry. + */ +const validateShared = ({ rawShared }) => { + assertPlainObject({ + message: "'secrets.shared' must be an object", + value: rawShared, + }); + + assertKnownProperties({ + allowed: ['keys', 'opItem'], + message: 'secrets.shared', + object: rawShared, + }); + + const shared = { + gsmSegment: false, + keys: assertKeyArray({ + message: 'secrets.shared.keys', + value: rawShared.keys, + }), + name: 'shared', + sharedKeys: [], + }; + + if (isNonEmptyString({ value: rawShared.opItem })) { + shared.opItem = rawShared.opItem; + } + + return shared; +}; + +/** + * Resolve a sharedKeys entry to its owning service and key name. A bare + * name must resolve to exactly one owning service; a dotted 'owner.KEY' + * names the owner explicitly for keys owned by more than one service. + * @param {Object} options + * @param {String} options.consumerName The name of the borrowing + * service/consumer, for error messages. + * @param {String} options.entry The sharedKeys entry (bare or dotted). + * @param {Object} options.services The normalised services map. + * @returns {Object} The owning service and the key name. + */ +const resolveSharedKeyEntry = ({ consumerName, entry, services }) => { + if (entry.includes('.')) { + const [dottedOwner, dottedKey] = entry.split('.'); + const owner = services[dottedOwner]; + + if (!owner) { + throw new Error( + `sharedKeys entry '${entry}' of '${consumerName}': service '${dottedOwner}' does not exist` + ); + } + + if (!owner.keys.includes(dottedKey)) { + throw new Error( + `sharedKeys entry '${entry}' of '${consumerName}': service '${dottedOwner}' does not own '${dottedKey}'` + ); + } + + return { key: dottedKey, owner }; + } + + const owners = Object.values(services).filter((service) => + service.keys.includes(entry) + ); + + if (owners.length === 0) { + throw new Error( + `sharedKeys entry '${entry}' of '${consumerName}': no service owns it` + ); + } + + if (owners.length > 1) { + throw new Error( + `sharedKeys entry '${entry}' of '${consumerName}': ambiguous (owned by ${owners + .map((owner) => owner.name) + .join(', ')}) - use the dotted 'owner.KEY' form` + ); + } + + return { key: entry, owner: owners[0] }; +}; + +/** + * Resolve the sharedKeys of an entry now that the full services map is + * known: each entry gains its owner, self-borrows and double-declarations + * are rejected. + * @param {Object} options + * @param {Object} options.entry The shape-validated service/consumer entry. + * @param {Object} options.services The normalised services map. + * @returns {Void} + */ +const resolveSharedKeys = ({ entry, services }) => { + entry.sharedKeys = entry.sharedKeys.map((shared) => + resolveSharedKeyEntry({ + consumerName: entry.name, + entry: shared, + services, + }) + ); + + entry.sharedKeys.forEach(({ key, owner }) => { + if (owner.name === entry.name) { + throw new Error( + `secrets.${entry.name}.sharedKeys: '${key}' is already owned by '${entry.name}'` + ); + } + + if (entry.keys.includes(key)) { + throw new Error( + `secrets.${entry.name}: '${key}' is both owned and borrowed` + ); + } + }); +}; + +const OP_ITEM_REFERENCE = /^(services\.[a-z0-9-]+|dev)$/; + +const isOpItemReference = ({ value }) => + typeof value === 'string' && OP_ITEM_REFERENCE.test(value); + +/** + * Resolve opItem references to literal 1Password item ids: a value of + * 'services.' or 'dev' points at another entry's opItem, so every + * item id appears exactly once in the contract. One hop only - a reference + * must point at an entry holding a literal id. + * @param {Object} options + * @param {Object|null} options.dev The normalised dev block. + * @param {Object} options.services The normalised services map. + * @param {Object|null} options.shared The normalised shared block. + * @returns {Void} + */ +const resolveOpItemReferences = ({ dev, services, shared }) => { + const entries = Object.values(services).concat(shared || []); + + if (dev) { + entries.push(dev); + } + + entries.forEach((entry) => { + if (!isOpItemReference({ value: entry.opItem })) { + return; + } + + const reference = entry.opItem; + const target = + reference === 'dev' + ? dev + : services[reference.slice('services.'.length)]; + + if (!target) { + throw new Error( + `opItem reference '${reference}' of '${entry.name}' does not exist` + ); + } + + if (target === entry) { + throw new Error( + `opItem reference '${reference}' of '${entry.name}' points at itself` + ); + } + + if (!target.opItem || isOpItemReference({ value: target.opItem })) { + throw new Error( + `opItem reference '${reference}' of '${entry.name}' cannot resolve: '${target.name}' has no literal opItem` + ); + } + + entry.opItem = target.opItem; + }); +}; + +/** + * Load and validate the secrets contract from a c.js configuration object. + * Returns null when no contract is present, so callers can fall back to + * legacy file behaviour. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @returns {Object|null} The normalised contract. + */ +const getSecretsContract = ({ config = {} }) => { + if (!config.secrets) { + return null; + } + + assertPlainObject({ + message: "'secrets' must be an object", + value: config.secrets, + }); + + assertKnownProperties({ + allowed: ['dev', 'services', 'shared'], + message: 'secrets', + object: config.secrets, + }); + + const project = assertPlainObject({ + message: "'project' configuration is required by the secrets contract", + value: config.project, + }); + + ['gcpProjectId', 'name', 'organisation'].forEach((property) => { + if (!isNonEmptyString({ value: project[property] })) { + throw new Error( + `'project.${property}' is required by the secrets contract` + ); + } + }); + + const gcloud = assertPlainObject({ + message: "'gcloud' configuration is required by the secrets contract", + value: config.gcloud, + }); + + if (!isNonEmptyString({ value: gcloud.region })) { + throw new Error("'gcloud.region' is required by the secrets contract"); + } + + const rawServices = assertPlainObject({ + message: "'secrets.services' must be an object", + value: config.secrets.services, + }); + + const shared = config.secrets.shared + ? validateShared({ rawShared: config.secrets.shared }) + : null; + + // Shape-validate every service first, so bare sharedKeys entries can + // find owners declared later in the map. Owners live in services or in + // the shared block. + const services = {}; + const owners = {}; + + if (shared) { + owners.shared = shared; + } + + Object.keys(rawServices).forEach((name) => { + services[name] = validateEntry({ + entry: rawServices[name], + hasGsm: true, + name, + }); + owners[name] = services[name]; + }); + + // A service may not call itself 'shared'. + if (services.shared) { + throw new Error( + "'shared' is reserved for the secrets.shared block - remove it from secrets.services" + ); + } + + Object.values(services).forEach((service) => { + resolveSharedKeys({ services: owners, entry: service }); + }); + + let dev = null; + + if (config.secrets.dev) { + assertPlainObject({ + message: "'secrets.dev' must be an object", + value: config.secrets.dev, + }); + + assertKnownProperties({ + allowed: ['consumers', 'opItem'], + message: 'secrets.dev', + object: config.secrets.dev, + }); + + if (!isNonEmptyString({ value: config.secrets.dev.opItem })) { + throw new Error("secrets.dev: 'opItem' must be a non-empty string"); + } + + const rawConsumers = assertPlainObject({ + message: "'secrets.dev.consumers' must be an object", + value: config.secrets.dev.consumers, + }); + + const consumers = {}; + Object.keys(rawConsumers).forEach((name) => { + consumers[name] = validateEntry({ + entry: rawConsumers[name], + hasGsm: false, + name, + }); + }); + Object.values(consumers).forEach((consumer) => { + resolveSharedKeys({ services: owners, entry: consumer }); + }); + + dev = { consumers, name: 'dev', opItem: config.secrets.dev.opItem }; + } + + resolveOpItemReferences({ dev, services, shared }); + + return { + dev, + name: project.name, + organisation: project.organisation, + project: project.gcpProjectId, + region: gcloud.region, + services, + shared, + vault: VAULT, + }; +}; + +/** + * Derive the GSM secret id for an owned key of a service. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment (local, beta, production...). + * @param {String} options.key The environment-variable style key. + * @param {Object} options.owner The owning service. + * @returns {String} The GSM secret id. + */ +const gsmId = ({ contract, env, key, owner }) => + owner.gsmSegment === false + ? `${contract.organisation}-${contract.name}-${env}-${kebabCase({ + key, + })}` + : `${contract.organisation}-${contract.name}-${env}-${ + owner.name + }-${kebabCase({ key })}`; + +/** + * Derive the global-format resource name for a GSM secret id. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.id The GSM secret id. + * @returns {String} The resource name. + */ +const gsmResourceName = ({ contract, id }) => + `projects/${contract.project}/secrets/${id}/versions/latest`; + +/** + * Derive a 1Password reference. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment section. + * @param {String} options.key The field label. + * @param {String} options.opItem The 1Password item id. + * @returns {String} The op:// reference. + */ +const opRef = ({ contract, env, key, opItem }) => + `op://${contract.vault}/${opItem}/${env}/${key}`; + +/** + * Resolve one key of one service to its full identity: owning service (for + * borrowed keys), GSM id, resource name, SecretProviderClass path and + * 1Password reference. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment. + * @param {String} options.key The key to resolve. + * @param {Object} options.service The consuming service. + * @returns {Object} The resolved key identity. + */ +const resolveServiceKey = ({ contract, env, key, service }) => { + const borrowed = service.sharedKeys.find((shared) => shared.key === key); + const owned = service.keys.includes(key); + + if (!owned && !borrowed) { + throw new Error( + `'${key}' is neither owned nor borrowed by service '${service.name}'` + ); + } + + const owner = owned ? service : borrowed.owner; + const id = gsmId({ contract, env, key, owner }); + + return { + gsmId: id, + key, + opItem: owner.opItem || null, + opRef: owner.opItem + ? opRef({ contract, env, key, opItem: owner.opItem }) + : null, + owner, + path: kebabCase({ key }), + resourceName: gsmResourceName({ contract, id }), + }; +}; + +/** + * Resolve one key of one dev consumer. Dev consumers answer only to env + * local and have no GSM presence: owned keys resolve into the dev item's + * consumer section, borrowed keys into the owning service's local section. + * @param {Object} options + * @param {Object} options.consumer The dev consumer. + * @param {Object} options.contract The normalised contract. + * @param {String} options.key The key to resolve. + * @returns {Object} The resolved key identity. + */ +const resolveConsumerKey = ({ consumer, contract, key }) => { + const borrowed = consumer.sharedKeys.find((shared) => shared.key === key); + + if (consumer.keys.includes(key)) { + return { + key, + opItem: contract.dev.opItem, + opRef: opRef({ + contract, + env: consumer.name, + key, + opItem: contract.dev.opItem, + }), + owner: null, + }; + } + + if (!borrowed) { + throw new Error( + `'${key}' is neither owned nor borrowed by consumer '${consumer.name}'` + ); + } + + return { + key, + opItem: borrowed.owner.opItem || null, + opRef: borrowed.owner.opItem + ? opRef({ + contract, + env: 'local', + key, + opItem: borrowed.owner.opItem, + }) + : null, + owner: borrowed.owner, + }; +}; + +/** + * Resolve every key of a service for an environment (owned and borrowed, + * sorted alphabetically) - the complete key list for manifest generation, + * ls and verify. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment. + * @param {Object} options.service The service. + * @returns {Array} Resolved key identities, sorted by key. + */ +const serviceKeys = ({ contract, env, service }) => + service.keys + .concat(service.sharedKeys.map((shared) => shared.key)) + .sort() + .map((key) => resolveServiceKey({ contract, env, key, service })); + +/** + * Look up a service by name, with a helpful error. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.name The service name. + * @returns {Object} The service. + */ +const getService = ({ contract, name }) => { + const service = contract.services[name]; + + if (!service) { + throw new Error( + `No service '${name}' in the secrets contract (services: ${Object.keys( + contract.services + ) + .sort() + .join(', ')})` + ); + } + + return service; +}; + +/** + * Look up a dev consumer by name, with a helpful error. + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.name The consumer name. + * @returns {Object} The consumer. + */ +const getConsumer = ({ contract, name }) => { + if (!contract.dev) { + throw new Error('The secrets contract has no dev consumers'); + } + + const consumer = contract.dev.consumers[name]; + + if (!consumer) { + throw new Error( + `No dev consumer '${name}' in the secrets contract (consumers: ${Object.keys( + contract.dev.consumers + ) + .sort() + .join(', ')})` + ); + } + + return consumer; +}; + +/** + * Resolve a identity against the contract: a contract + * service (any env) or a dev consumer (local only, no GSM presence). + * @param {Object} options + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment. + * @param {String} options.name The service or consumer name. + * @returns {Object} The identity: { kind, service } or { kind, consumer }. + */ +const resolveIdentity = ({ contract, env, name }) => { + const service = contract.services[name]; + + if (service) { + return { kind: 'service', service }; + } + + // The shared block behaves like a service for identity resolution (it + // owns GSM secrets), except its 1Password item is never auto-created. + if (name === 'shared' && contract.shared) { + return { kind: 'service', service: contract.shared }; + } + + const consumer = contract.dev && contract.dev.consumers[name]; + + if (consumer) { + if (env !== 'local') { + throw new Error( + `Dev consumers answer only to env local ('${name}' was given env '${env}')` + ); + } + + return { consumer, kind: 'consumer' }; + } + + throw new Error( + `No service or dev consumer '${name}' in the secrets contract (services: ${Object.keys( + contract.services + ) + .sort() + .join(', ')}${ + contract.dev + ? `; consumers: ${Object.keys(contract.dev.consumers) + .sort() + .join(', ')}` + : '' + })` + ); +}; + +/** + * Validate a positional env argument against the environments the project + * declares in its Kubernetes configuration (when it declares any). + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {String} options.env The environment. + * @returns {Void} + */ +const assertKnownEnv = ({ config, env }) => { + const environments = + config.kubernetes && config.kubernetes.environments + ? Object.keys(config.kubernetes.environments) + : []; + + if (environments.length > 0 && !environments.includes(env)) { + throw new Error( + `Unknown env '${env}' (known: ${environments.sort().join(', ')})` + ); + } +}; + +module.exports = { + assertKnownEnv, + getConsumer, + getSecretsContract, + getService, + gsmId, + gsmResourceName, + kebabCase, + opRef, + resolveConsumerKey, + resolveIdentity, + resolveServiceKey, + serviceKeys, +}; From 0eb24af2a7c450636cc613d779a8d236d055caae Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:45:46 +0930 Subject: [PATCH 02/52] Added contract-based manifest generation. - New bin/lib/c-secrets-manifests.js: generates SecretProviderClasses, SecretSyncs and the secret-sync ServiceAccount for deployed environments, and local Secrets (op references) for local, directly into the compiled folder. A conflicting committed file is a hard error (one true source); without a contract, manifest behaviour is unchanged. - Wired into c kc manifests/apply/start before the file checks; ensureServiceFilesExist accepts already-compiled (generated) paths. - Local Secrets follow the exact pipeline committed templates use: op inject, base64-encoded data, 0600 permissions. - The op helpers (whoami/inject) are now session-aware: the op-session alias' session is wired in when present, falling back to the shell environment. - README: Commands summary + a Secrets configuration section. CHANGELOG: v6.2.0 entry. --- CHANGELOG.md | 9 + README.md | 54 ++++++ bin/c-kc-apply.js | 47 +++-- bin/c-kc-manifests.js | 41 ++-- bin/c-kc-start.js | 51 +++-- bin/lib/c-kc.js | 37 +++- bin/lib/c-secrets-io.js | 1 + bin/lib/c-secrets-manifests.js | 343 +++++++++++++++++++++++++++++++++ 8 files changed, 499 insertions(+), 84 deletions(-) create mode 100644 bin/lib/c-secrets-manifests.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8635bd1..213da6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ This file is a history of the changes made to @idearium/cli. +## v6.2.0 - 2026-09-30 + +### Added + +- The secrets contract: a `secrets` block in `c.js` (services, a sibling `shared` block for keys whose GSM ids carry no service segment, and `dev` build-time consumers) that becomes the single source of truth for secret-shaped manifests. With a contract present, `c kc manifests|apply|start` generate SecretProviderClasses, SecretSyncs, the secret-sync ServiceAccount (deployed environments) and local Secrets (1Password references); a conflicting committed file is a hard error. Without a contract, manifest behaviour is unchanged. +- `c op get|set` and `c secrets get|push|add|verify|ls|consumers|init`. Values are only ever displayed as truncated hashes (`--plaintext` is terminal-gated and agent-blocked with a discouraged `--force`); writes flow through stdin pipes; `push` verifies its GSM readback by hash; `add` is ordered, idempotent and rollback-safe across c.js, 1Password and GSM and never touches the cluster; `consumers` gates deletions; `init` drafts a contract from existing declarations (warm 1Password session required), binding `shared.opItem` and removing legacy duplicates by comparing value hashes. +- `opItem` references (`services.`/`dev`, one hop) so each 1Password item id appears exactly once in the contract. Services resolve (or create) their item lazily by title; the `shared` block never auto-creates. +- Friendly credential pre-flights: commands that need 1Password, gcloud or kubectl fail fast with actionable re-authentication errors instead of raw tool output. + ## v6.1.0 - 2026-09-16 ### Added diff --git a/README.md b/README.md index f9afd66..3d31a5f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ The following is a summary of the top level commands. - `c mk` is for everything Minikube. - `c mongo` is for MongoDB connections. - `c npm` is for everything NPM. +- `c op` manages 1Password secret values for the secrets contract. - `c project` is for project management. +- `c secrets` manages project secrets across their stores (1Password, GSM, Kubernetes). Be aware that `kubectl` uses a global configuration, but the `c kc` command supercedes those where possible and ensures kubectl runs in the context of the project you're within. @@ -292,6 +294,58 @@ When the template is compiled, each `stringData` value will be base64 encoded an Compiled secret manifests contain plaintext secrets, so they are treated as sensitive: they're written with `0600` permissions, and removed once they've been applied to Kubernetes. `c kc start` and `c kc apply` remove them after applying, and `c skaffold dev` removes them (via `c kc secrets-clean`) when it exits. `c kc manifests` intentionally leaves them in place, as its compiled output is its purpose. This only applies to the local environment; other environments are unaffected. +### Secrets configuration + +The `secrets` block of `c.js` is the secrets contract: the single source of truth for every secret-shaped manifest and value reference. With a contract present, the cli generates SecretProviderClasses, SecretSyncs and the secret-sync ServiceAccount for deployed environments, and local Secrets (1Password references) for the local environment, whenever manifests are compiled (`c kc manifests`, `c kc apply`, `c kc start`). A committed manifest file that the contract also generates is a hard error - the contract is the one true source. Without a contract, nothing changes. + +```js +secrets: { + dev: { + opItem: '{dev-item-id}', // {org}-{repo}/dev + consumers: { + 'site-build': { keys: ['FONTAWESOME_AUTH_TOKEN'] }, + }, + }, + services: { + site: { + opItem: '{item-id}', + keys: ['CACHE_URL', 'RAS_API_KEY'], // owned + sharedKeys: ['IMGIX_TOKEN'], // borrowed + }, + 'prismic-published-workflow': { + sharedKeys: ['prismic-published-algolia.BASIC_AUTH'], // dotted owner.KEY + }, + }, + shared: { + // keys whose GSM ids carry no service segment + // (org-repo-env-key); opItem points at the item storing the + // values, usually a reference - never auto-created. + opItem: 'services.site', + keys: ['IMGIX_TOKEN'], + }, +}, +``` + +Rules (enforced at load): + +- `opItem` values are 1Password item ids or `services.`/`dev` references (one hop - each id appears exactly once). Services resolve (or create) their item lazily by title; the `shared` block never auto-creates. +- A bare `sharedKeys` name resolves to its unique owner across `services` and `shared`; dotted `'owner.KEY'` disambiguates. +- GSM secret ids derive as `{org}-{repo}-{env}[-{service}]-{key}` - no literal ids anywhere. +- Region comes from `gcloud.region`; project from `project.gcpProjectId`. + +Commands (values are only ever displayed as truncated hashes; `--plaintext` requires a terminal and is blocked in agent/CI contexts unless forced): + +- `c secrets init` - draft a contract from existing declarations (requires a warm 1Password session; no-op when a contract exists). Binds `shared.opItem` by comparing value hashes and removes value-verified legacy duplicates. +- `c secrets add ` - add a key end to end (1Password, GSM, c.js). Never touches the cluster. +- `c secrets push ` - push the 1Password value to GSM, verified by hash readback. +- `c secrets get ` / `c op get` - value summaries (hash, reference, length). +- `c op set ` - upsert a value from stdin (or `--generate`). +- `c secrets verify ` - reconcile structure + 1Password/GSM/Kubernetes hashes, stale 1Password fields (`op:EXTRA`) and deployed-functions drift (`STALE`). +- `c secrets ls ` - the environment's contract-derived GSM inventory. +- `c secrets consumers ` - everything referencing a GSM secret id. Gate every deletion on this. + +Commands that need 1Password, gcloud or kubectl fail fast with friendly re-authentication instructions when their credentials have expired. + ### MongoDB configuration The Idearium cli supports a MongoDB configuration. The MongoDB configuration can be used to access local and remote databases. diff --git a/bin/c-kc-apply.js b/bin/c-kc-apply.js index f5000c1..19d74ed 100644 --- a/bin/c-kc-apply.js +++ b/bin/c-kc-apply.js @@ -1,4 +1,3 @@ -#!/usr/bin/env -S node 'use strict'; const program = require('commander'); @@ -18,6 +17,7 @@ const { renderServicesTemplates, setLocalsForServices, } = require('./lib/c-kc'); +const { generateSecretManifests } = require('./lib/c-secrets-manifests'); program .arguments('') @@ -101,32 +101,31 @@ return Promise.all([loadConfig(), loadState()]) return reject(e); } - return resolve([services, path, state]); + return resolve([services, path, state, config, namespace]); }) ) - .then( - ([services, path, state]) => - new Promise(async (resolve, reject) => { - try { - await ensureServiceFilesExist(path, services); - } catch (e) { - reject(e); - } - - return resolve([services, path, state]); - }) + .then(([services, path, state, config, namespace]) => + generateSecretManifests({ + config, + env: state.env, + namespace, + path, + services, + }).then(() => [services, path, state]) ) - .then( - ([services, path, state]) => - new Promise(async (resolve, reject) => { - try { - await renderServicesTemplates(path, services); - } catch (e) { - reject(e); - } - - return resolve([services, path, state]); - }) + .then(([services, path, state]) => + ensureServiceFilesExist(path, services).then(() => [ + services, + path, + state, + ]) + ) + .then(([services, path, state]) => + renderServicesTemplates(path, services).then(() => [ + services, + path, + state, + ]) ) .then( ([services, path, state]) => diff --git a/bin/c-kc-manifests.js b/bin/c-kc-manifests.js index 2924387..b8716f9 100644 --- a/bin/c-kc-manifests.js +++ b/bin/c-kc-manifests.js @@ -1,4 +1,3 @@ -#!/usr/bin/env -S node 'use strict'; const program = require('commander'); @@ -15,6 +14,7 @@ const { renderServicesTemplates, setLocalsForServices, } = require('./lib/c-kc'); +const { generateSecretManifests } = require('./lib/c-secrets-manifests'); program .description('This command will compile any found template manifests.') @@ -41,10 +41,10 @@ return Promise.all([loadState(), loadConfig()]) `kubernetes.environments.${state.env}.namespace` ) || formatProjectPrefix(organisation, name, state.env, true, true); - return [locations, prefix, namespace, path, state]; + return [config, locations, prefix, namespace, path, state]; }) .then( - ([kubernetesLocations, prefix, namespace, path, state]) => + ([config, kubernetesLocations, prefix, namespace, path, state]) => new Promise((resolve, reject) => { const services = kubernetesLocationsToObjects(kubernetesLocations); @@ -55,33 +55,22 @@ return Promise.all([loadState(), loadConfig()]) return reject(e); } - return resolve([services, path]); + return resolve([config, namespace, path, services, state]); }) ) - .then( - ([services, path]) => - new Promise(async (resolve, reject) => { - try { - await ensureServiceFilesExist(path, services); - } catch (e) { - reject(e); - } - - return resolve([services, path]); - }) + .then(([config, namespace, path, services, state]) => + generateSecretManifests({ + config, + env: state.env, + namespace, + path, + services, + }).then(() => [path, services]) ) - .then( - ([services, path]) => - new Promise(async (resolve, reject) => { - try { - await renderServicesTemplates(path, services); - } catch (e) { - reject(e); - } - - return resolve(); - }) + .then(([path, services]) => + ensureServiceFilesExist(path, services).then(() => [path, services]) ) + .then(([path, services]) => renderServicesTemplates(path, services)) .catch((err) => { if (err.code === 'ENOENT') { return reportError( diff --git a/bin/c-kc-start.js b/bin/c-kc-start.js index c62d09b..4908211 100644 --- a/bin/c-kc-start.js +++ b/bin/c-kc-start.js @@ -1,4 +1,3 @@ -#!/usr/bin/env -S node 'use strict'; const program = require('commander'); @@ -18,6 +17,7 @@ const { renderServicesTemplates, setLocalsForServices, } = require('./lib/c-kc'); +const { generateSecretManifests } = require('./lib/c-secrets-manifests'); program .description('This command will start all of your Kubernetes locations.') @@ -44,10 +44,10 @@ return Promise.all([loadState(), loadConfig()]) `kubernetes.environments.${state.env}.namespace` ) || formatProjectPrefix(organisation, name, state.env, true, true); - return [locations, prefix, namespace, path, state]; + return [config, locations, prefix, namespace, path, state]; }) .then( - ([kubernetesLocations, prefix, namespace, path, state]) => + ([config, kubernetesLocations, prefix, namespace, path, state]) => new Promise((resolve, reject) => { const services = kubernetesLocationsToObjects(kubernetesLocations); @@ -58,32 +58,31 @@ return Promise.all([loadState(), loadConfig()]) return reject(e); } - return resolve([services, path, state]); + return resolve([config, namespace, services, path, state]); }) ) - .then( - ([services, path, state]) => - new Promise(async (resolve, reject) => { - try { - await ensureServiceFilesExist(path, services); - } catch (e) { - reject(e); - } - - return resolve([services, path, state]); - }) + .then(([config, namespace, services, path, state]) => + generateSecretManifests({ + config, + env: state.env, + namespace, + path, + services, + }).then(() => [services, path, state]) ) - .then( - ([services, path, state]) => - new Promise(async (resolve, reject) => { - try { - await renderServicesTemplates(path, services); - } catch (e) { - reject(e); - } - - return resolve([services, path, state]); - }) + .then(([services, path, state]) => + ensureServiceFilesExist(path, services).then(() => [ + services, + path, + state, + ]) + ) + .then(([services, path, state]) => + renderServicesTemplates(path, services).then(() => [ + services, + path, + state, + ]) ) .then( ([services, path, state]) => diff --git a/bin/lib/c-kc.js b/bin/lib/c-kc.js index 796619e..915c1c8 100644 --- a/bin/lib/c-kc.js +++ b/bin/lib/c-kc.js @@ -2,6 +2,8 @@ const fs = require('fs'); const { copy, ensureDir, mkdtemp, remove } = require('fs-extra'); + +const { opSession } = require('./c-secrets-io'); const { tmpdir } = require('os'); const { join, resolve: resolvePath } = require('path'); const Mustache = require('mustache'); @@ -160,9 +162,28 @@ const formatBuildArgs = (args) => { */ let opAuthenticated = null; +/** + * The op environment: the op-session alias' session (or an exported + * OP_SESSION_idearium) wired in, falling back to the process environment + * when no session file exists (the cli may still be authenticated some + * other way). + * @returns {Object} The environment for op invocations. + */ +const opEnvironment = () => { + try { + return Object.assign({}, process.env, { + OP_SESSION_idearium: opSession(), + }); + } catch (e) { + return process.env; + } +}; + const ensureOpAuthenticated = () => { if (!opAuthenticated) { - opAuthenticated = execFileAsync('op', ['whoami']).catch((e) => { + opAuthenticated = execFileAsync('op', ['whoami'], { + env: opEnvironment(), + }).catch((e) => { if (e.code === 'ENOENT') { throw new Error( 'The 1Password cli (op) is not installed. Install it to resolve secret references within templates.' @@ -195,13 +216,11 @@ const injectSecretReferences = async (content) => { try { await writeFile(inPath, content, { mode: 0o600 }); - await execFileAsync('op', [ - 'inject', - '--in-file', - inPath, - '--out-file', - outPath, - ]); + await execFileAsync( + 'op', + ['inject', '--in-file', inPath, '--out-file', outPath], + { env: opEnvironment() } + ); return await readFile(outPath, 'utf-8'); } finally { @@ -361,9 +380,11 @@ const validateBuildArgs = (args = []) => args.filter((arg) => arg.split('=').length == 2); module.exports = { + encodeSecretStringData, ensureServiceFilesExist, flagBuildArgs, formatBuildArgs, + injectSecretReferences, renderServicesTemplates, removeCompiledSecrets, setLocalsForServices, diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index a5ed97c..6180145 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -1070,6 +1070,7 @@ module.exports = { opFieldExists, opFindItemByTitle, opRead, + opSession, opReadSha, opUpsertField, quoteIfNeeded, diff --git a/bin/lib/c-secrets-manifests.js b/bin/lib/c-secrets-manifests.js new file mode 100644 index 0000000..42f72f4 --- /dev/null +++ b/bin/lib/c-secrets-manifests.js @@ -0,0 +1,343 @@ +'use strict'; + +const { resolve: resolvePath, join } = require('path'); +const { access, chmod, ensureDir, writeFile } = require('fs-extra'); + +const { formatProjectPrefix } = require('./c-project'); +const { getSecretsContract, getService, serviceKeys } = require('./c-secrets'); +const { encodeSecretStringData, injectSecretReferences } = require('./c-kc'); + +/** + * Render a SecretProviderClass for a contract service: one entry per key + * (owned and borrowed, alphabetical), global-format resource names. + * @param {Object} options + * @param {Array} options.entries Resolved key identities. + * @param {String} options.namespace The target namespace. + * @param {String} options.service The service name. + * @returns {String} The manifest content. + */ +const renderSecretProviderClass = ({ entries, namespace, service }) => + [ + 'apiVersion: secrets-store.csi.x-k8s.io/v1', + 'kind: SecretProviderClass', + 'metadata:', + ' labels:', + ` svc: ${service}`, + ' type: secretproviderclass', + ` name: ${service}`, + ` namespace: ${namespace}`, + 'spec:', + ' provider: gke', + ' parameters:', + ' secrets: |', + ] + .concat( + entries.map( + (resolved) => + ` - resourceName: '${resolved.resourceName}'\n path: '${resolved.path}'` + ) + ) + .concat(['']) + .join('\n'); + +/** + * Render a SecretSync for a contract service: sourcePath is the key + * lowercase-hyphenated, targetKey the canonical environment-variable name. + * @param {Object} options + * @param {Array} options.entries Resolved key identities. + * @param {String} options.namespace The target namespace. + * @param {String} options.service The service name. + * @returns {String} The manifest content. + */ +const renderSecretSync = ({ entries, namespace, service }) => + [ + 'apiVersion: secret-sync.gke.io/v1', + 'kind: SecretSync', + 'metadata:', + ' labels:', + ` svc: ${service}`, + ' type: secretsync', + ` name: ${service}`, + ` namespace: ${namespace}`, + 'spec:', + ' serviceAccountName: secret-sync', + ` secretProviderClassName: ${service}`, + ' secretObject:', + ' type: Opaque', + ' data:', + ] + .concat( + entries.map( + (resolved) => + ` - sourcePath: '${resolved.path}'\n targetKey: '${resolved.key}'` + ) + ) + .concat(['']) + .join('\n'); + +/** + * Render the secret-sync ServiceAccount for the namespace. + * @param {Object} options + * @param {String} options.namespace The target namespace. + * @returns {String} The manifest content. + */ +const renderSecretSyncServiceAccount = ({ namespace }) => + [ + 'apiVersion: v1', + 'kind: ServiceAccount', + 'metadata:', + ' labels:', + ' svc: secretsync', + ' type: serviceaccount', + ' name: secret-sync', + ` namespace: ${namespace}`, + '', + ].join('\n'); + +/** + * Render a local Secret manifest (pre-injection) for a contract service: + * stringData entries carry bare op:// references resolved from the + * contract, injected and base64-encoded by the same pipeline as committed + * templates. + * @param {Object} options + * @param {Array} options.entries Resolved key identities. + * @param {String} options.namespace The target namespace. + * @param {String} options.service The service name. + * @returns {String} The manifest content (with op:// references). + */ +const renderLocalSecret = ({ entries, namespace, service }) => + [ + 'apiVersion: v1', + 'kind: Secret', + 'metadata:', + ' labels:', + ` svc: ${service}`, + ' type: secret', + ` name: ${service}`, + ` namespace: ${namespace}`, + 'type: Opaque', + 'stringData:', + ] + .concat( + entries.map((resolved) => ` ${resolved.key}: ${resolved.opRef}`) + ) + .concat(['']) + .join('\n'); + +/** + * Generate every secret-shaped manifest declared by the contract for the + * environment's locations: SecretProviderClasses, SecretSyncs and the + * secret-sync ServiceAccount for deployed environments, and local Secrets + * (op references) for the local environment. Generated output is written + * straight to the compiled folder - a conflicting committed source file is + * a hard error (one true source). No contract, no generation: legacy file + * behaviour is untouched. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {String} options.env The environment being compiled. + * @param {String} [options.namespace] The target namespace (derived when + * omitted). + * @param {String} options.path The manifests path for the environment. + * @param {Array} options.services The environment's location services. + * @returns {Promise} Resolves with the array of generated file names. + */ +const generateSecretManifests = async ({ + config, + env, + namespace, + path = '', + services = [], +}) => { + const contract = getSecretsContract({ config }); + + if (!contract) { + return []; + } + + const targetNamespace = + namespace || + formatProjectPrefix( + contract.organisation, + contract.name, + env, + true, + true + ); + + const sourceFolder = resolvePath(process.cwd(), path); + const destinationFolder = join(sourceFolder, '.compiled'); + const generated = []; + + await ensureDir(destinationFolder); + + const assertNoCommittedFile = async ({ servicePath }) => { + const base = join(sourceFolder, servicePath); + + const conflicts = await Promise.all( + [`${base}.yaml`, `${base}.yaml.tmpl`].map(async (candidate) => { + try { + await access(candidate); + + return candidate; + } catch (e) { + return null; + } + }) + ); + + const conflict = conflicts.find((candidate) => candidate); + + if (conflict) { + throw new Error( + `${conflict.replace( + `${sourceFolder}/`, + '' + )} conflicts with the secrets contract - the contract generates this file (delete the committed file; the contract is the one true source)` + ); + } + }; + + const writeCompiled = async ({ content, mode, servicePath }) => { + await writeFile( + join(destinationFolder, `${servicePath}.yaml`), + content, + { + encoding: 'utf8', + mode, + } + ); + + generated.push(`${servicePath}.yaml`); + }; + + // Group the location entries by shape and name: 'site' -> + // { secret: ..., secretproviderclass: ..., secretsync: ... }. + const shapes = { + secret: {}, + secretproviderclass: {}, + secretsync: {}, + serviceaccount: {}, + }; + + services.forEach((service) => { + const [name, shape] = service.path.split('.'); + + if (!shapes[shape]) { + shapes[shape] = {}; + } + + shapes[shape][name] = service; + }); + + const entriesFor = ({ name }) => { + const service = getService({ contract, name }); + + return serviceKeys({ contract, env, service }); + }; + + const shapesInOrder = [ + 'secretproviderclass', + 'secretsync', + 'serviceaccount', + ]; + + await Promise.all( + shapesInOrder.map(async (shape) => { + const names = Object.keys(shapes[shape]).sort(); + + await Promise.all( + names.map(async (name) => { + if (shape === 'serviceaccount' && name !== 'secretsync') { + return; + } + + const service = shapes[shape][name]; + + await assertNoCommittedFile({ + servicePath: service.path, + }); + + if (shape === 'serviceaccount') { + return writeCompiled({ + content: renderSecretSyncServiceAccount({ + namespace: targetNamespace, + }), + mode: 0o644, + servicePath: service.path, + }); + } + + const entries = entriesFor({ name }); + + if (shape === 'secretproviderclass') { + return writeCompiled({ + content: renderSecretProviderClass({ + entries, + namespace: targetNamespace, + service: name, + }), + mode: 0o644, + servicePath: service.path, + }); + } + + return writeCompiled({ + content: renderSecretSync({ + entries, + namespace: targetNamespace, + service: name, + }), + mode: 0o644, + servicePath: service.path, + }); + }) + ); + }) + ); + + // Local Secrets: generate with op references, then run the exact + // pipeline committed templates follow (inject, base64, 0600). + await Promise.all( + Object.keys(shapes.secret) + .sort() + .map(async (name) => { + const service = shapes.secret[name]; + const entries = entriesFor({ name }); + + await assertNoCommittedFile({ servicePath: service.path }); + + let content = renderLocalSecret({ + entries, + namespace: targetNamespace, + service: name, + }); + + content = await injectSecretReferences(content); + + const destinationFile = join( + destinationFolder, + `${service.path}.yaml` + ); + + await writeFile( + destinationFile, + encodeSecretStringData(content), + { encoding: 'utf8', mode: 0o600 } + ); + + await chmod(destinationFile, 0o600); + + generated.push(`${service.path}.yaml`); + }) + ); + + return generated; +}; + +module.exports = { + generateSecretManifests, + renderLocalSecret, + renderSecretProviderClass, + renderSecretSync, + renderSecretSyncServiceAccount, +}; From 1fd5f7c57947a7d168f8f566b664c0d3c1045b0e Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:10:34 +0930 Subject: [PATCH 03/52] 6.2.0-beta.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index fca8470..05d818d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.1.0", + "version": "6.2.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.1.0", + "version": "6.2.0-beta.1", "license": "MIT", "dependencies": { "chalk": "4.1.2", diff --git a/package.json b/package.json index f0f1806..31624e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.1.0", + "version": "6.2.0-beta.1", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": { From 2113fce9f89c2b38d2c9d1544b44ee89f476c025 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:07:17 +0930 Subject: [PATCH 04/52] Fixed the opItemFields export and the generated-manifest file check. - Exported opItemFields from c-secrets-io: without it, verify's op:EXTRA scan silently no-oped (the per-item catch swallowed the undefined function), so stale-field findings could never surface. - ensureServiceFilesExist now accepts an existing compiled file for contract-generated manifests (no committed source exists). The original version of this check failed to apply and compiled envs errored on secretsync.serviceaccount. --- bin/lib/c-kc.js | 18 +++++++++++++----- bin/lib/c-secrets-io.js | 1 + 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/bin/lib/c-kc.js b/bin/lib/c-kc.js index 915c1c8..640aef8 100644 --- a/bin/lib/c-kc.js +++ b/bin/lib/c-kc.js @@ -53,11 +53,19 @@ const ensureServiceFilesExist = async (path = '', services = []) => { await ensureDir(destinationFolder); await copy(`${sourcePath}.yaml`, `${destinationPath}.yaml`); } catch (err) { - throw new Error( - new Error( - `Neither ${path}/${service.path}.yaml.tmpl or ${path}/${service.path}.yaml could be found` - ) - ); + try { + // Contract-generated manifests have no committed source + // file - their compiled output already exists. + await access(`${destinationPath}.yaml`, constants.R_OK); + + return; + } catch (generatedErr) { + throw new Error( + new Error( + `Neither ${path}/${service.path}.yaml.tmpl or ${path}/${service.path}.yaml could be found` + ) + ); + } } } }); diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index 6180145..d4cb9fb 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -1069,6 +1069,7 @@ module.exports = { opCreateItem, opFieldExists, opFindItemByTitle, + opItemFields, opRead, opSession, opReadSha, From 5a218cf475ca981d74722076513aa6b60984999a Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:07:45 +0930 Subject: [PATCH 05/52] 6.2.0-beta.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 05d818d..52066cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.1", + "version": "6.2.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.2.0-beta.1", + "version": "6.2.0-beta.2", "license": "MIT", "dependencies": { "chalk": "4.1.2", diff --git a/package.json b/package.json index 31624e4..ea07ffc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.1", + "version": "6.2.0-beta.2", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": { From 68686472839276380a166d06a75c784f034c5d55 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:16:20 +0930 Subject: [PATCH 06/52] Clarified secrets creation process. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 3d31a5f..adbd59d 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,8 @@ The `c kc apply` will automatically provide the values for `namespace`, `prefix` ##### Secret templates +> With a secrets contract in `c.js` (see [Secrets configuration](#secrets-configuration) below), secret-shaped templates are generated automatically — you don't commit them. This section applies to repositories without a contract. + Any template (`.yaml.tmpl`) can contain [1Password secret references](https://www.1password.dev/connect/knowledge-base/secrets-references/) (i.e. `op://vault/item/section/field`). The template is rendered (placeholders) first, and then any references are resolved with the 1Password cli (`op inject`) - only when present. Rendering first means secret values never flow through the template engine (a value containing curly braces could otherwise be mangled). As a consequence, references must be **bare** (i.e. `op://vault/item/section/field`, not wrapped in `{{ }}`): placeholders are gone by the time references are resolved, and a wrapped reference would be blanked as an unknown placeholder. When references are present, the cli first ensures the 1Password cli is installed and authenticated (via `op whoami`), halting with a friendly message if not (`eval $(op signin)`). Templates without secret references never invoke `op`. This makes it possible to commit a template containing secret references, and have the compiled manifest (within `.compiled`, which should be gitignored) contain the actual secrets, ready to be deployed to Kubernetes. From 8f069d4073acf3ae3f31a5ed7de6f64ee7bd385c Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:38:31 +1030 Subject: [PATCH 07/52] Added c op session to warm the 1Password session. Signs in via op signin --raw and stores the raw token at ~/.local/state/idearium/op-session (0600, enforced on overwrite) where every c command reads it. No account-specific OP_SESSION_ env var names anywhere. --- bin/c-op-session.js | 45 +++++++++++++++++++++++++++++++++++++++++ bin/c-op.js | 4 ++++ bin/lib/c-secrets-io.js | 1 + 3 files changed, 50 insertions(+) create mode 100644 bin/c-op-session.js diff --git a/bin/c-op-session.js b/bin/c-op-session.js new file mode 100644 index 0000000..9e3b12a --- /dev/null +++ b/bin/c-op-session.js @@ -0,0 +1,45 @@ +'use strict'; + +const print = (line) => process.stdout.write(`${line}\n`); + +const { execFileSync } = require('child_process'); +const { chmodSync, mkdirSync, writeFileSync } = require('fs'); +const { dirname } = require('path'); +const program = require('commander'); + +const { reportError } = require('./lib/c'); +const { OP_SESSION_FILE } = require('./lib/c-secrets-io'); + +program + .description( + 'Sign in to 1Password and store the session token where every c command (and c op cmd) reads it (~/.local/state/idearium/op-session, 0600). The token itself is never printed.' + ) + .parse(process.argv); + +try { + const token = execFileSync('op', ['signin', '--raw'], { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'inherit'], + }).trim(); + + if (!token) { + throw new Error( + 'op signin produced no session token (1Password desktop-app integration can do this) - sign in with your account password instead.' + ); + } + + mkdirSync(dirname(OP_SESSION_FILE), { recursive: true }); + + writeFileSync(OP_SESSION_FILE, `${token}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + + // writeFileSync only applies the mode on creation - enforce it on + // overwrite too (the file may exist with wider permissions). + chmodSync(OP_SESSION_FILE, 0o600); + + print(`op session warmed: ${OP_SESSION_FILE}`); +} catch (e) { + reportError(e, false, true); +} diff --git a/bin/c-op.js b/bin/c-op.js index 478b165..b063d64 100644 --- a/bin/c-op.js +++ b/bin/c-op.js @@ -12,6 +12,10 @@ program 'set ', 'Upsert a secret value into 1Password from stdin (or --generate).' ) + .command( + 'session', + 'Sign in to 1Password and store the session token for every c command.' + ) .description( 'Manage 1Password secret values for the secrets contract in c.js.' ) diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index d4cb9fb..c5a2712 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -1050,6 +1050,7 @@ const gsmPushVerified = ({ contract, env, id, owner, value }) => { }; module.exports = { + OP_SESSION_FILE, addKeyToServiceInCjs, assertGcloudAuth, assertKubectl, From 1e4bc47822151fbff2a565f110fe185fc23ec68d Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:48:59 +1030 Subject: [PATCH 08/52] Added c op cmd to run op commands with the managed session. Spawns op with --session (array form, no shell) and inherits stdio, pre-flighting the session with assertOpSession for a friendly error. Exit code propagates for scripting. --- bin/c-op-cmd.js | 29 +++++++++++++++++++++++++++++ bin/c-op.js | 4 ++++ 2 files changed, 33 insertions(+) create mode 100644 bin/c-op-cmd.js diff --git a/bin/c-op-cmd.js b/bin/c-op-cmd.js new file mode 100644 index 0000000..8db4afb --- /dev/null +++ b/bin/c-op-cmd.js @@ -0,0 +1,29 @@ +'use strict'; + +const { spawn } = require('child_process'); +const program = require('commander'); + +const { reportError } = require('./lib/c'); +const { assertOpSession, opSession } = require('./lib/c-secrets-io'); + +program + .arguments('[command...]') + .description( + 'Runs a 1Password cli command with the c-managed session wired in (--session), rather than guessing an OP_SESSION_* env var name.' + ) + .parse(process.argv); + +try { + // Fail fast with a friendly error before handing over to op. + assertOpSession(); + + // Array form (no shell) so values never round-trip through string + // interpolation. + spawn('op', ['--session', opSession()].concat(program.rawArgs.slice(2)), { + stdio: 'inherit', + }).on('exit', (code) => { + process.exitCode = code; + }); +} catch (e) { + reportError(e, false, true); +} diff --git a/bin/c-op.js b/bin/c-op.js index b063d64..1e09fd7 100644 --- a/bin/c-op.js +++ b/bin/c-op.js @@ -16,6 +16,10 @@ program 'session', 'Sign in to 1Password and store the session token for every c command.' ) + .command( + 'cmd [command...]', + 'Run a 1Password cli command with the c-managed session.' + ) .description( 'Manage 1Password secret values for the secrets contract in c.js.' ) From c271fd9f117baaaa5783fea73bb790f73fbe3117 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:58:46 +1030 Subject: [PATCH 09/52] Switched op invocations to the --session flag. runOp and the c kc injection pipeline now pass the c-managed session via --session in the args array instead of injecting an OP_SESSION_idearium env var, and every error hint points at c op session. Env intake is unchanged (still checked first) so eval $(op signin) keeps working. --- bin/c-secrets-init.js | 2 +- bin/lib/c-kc.js | 34 +++++++++++++++++++--------------- bin/lib/c-secrets-io.js | 13 +++++-------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/bin/c-secrets-init.js b/bin/c-secrets-init.js index 368f3b6..a234d1e 100644 --- a/bin/c-secrets-init.js +++ b/bin/c-secrets-init.js @@ -13,7 +13,7 @@ const { draftContract } = require('./lib/c-secrets-init'); program .description( - 'Draft a secrets contract into c.js by scanning existing declarations (SecretProviderClasses, local secret templates, --set-secrets scripts, env.*.yaml references, docker build args and 1Password item names). Requires a warm 1Password session (the op-session alias) so item bindings resolve. The draft is a starting point: review ownership, opItems and sharedKeys, then verify. Exits without changes when a secrets contract already exists.' + 'Draft a secrets contract into c.js by scanning existing declarations (SecretProviderClasses, local secret templates, --set-secrets scripts, env.*.yaml references, docker build args and 1Password item names). Requires a warm 1Password session (`c op session`) so item bindings resolve. The draft is a starting point: review ownership, opItems and sharedKeys, then verify. Exits without changes when a secrets contract already exists.' ) .parse(process.argv); diff --git a/bin/lib/c-kc.js b/bin/lib/c-kc.js index 640aef8..f1512fe 100644 --- a/bin/lib/c-kc.js +++ b/bin/lib/c-kc.js @@ -171,27 +171,26 @@ const formatBuildArgs = (args) => { let opAuthenticated = null; /** - * The op environment: the op-session alias' session (or an exported - * OP_SESSION_idearium) wired in, falling back to the process environment - * when no session file exists (the cli may still be authenticated some + * The leading op arguments: --session with the c-managed token (from + * `c op session` or an exported OP_SESSION_idearium), or none at all + * when no session exists (the cli may still be authenticated some * other way). - * @returns {Object} The environment for op invocations. + * @returns {Array} The arguments to prepend to every op invocation. */ -const opEnvironment = () => { +const opSessionArgs = () => { try { - return Object.assign({}, process.env, { - OP_SESSION_idearium: opSession(), - }); + return ['--session', opSession()]; } catch (e) { - return process.env; + return []; } }; const ensureOpAuthenticated = () => { if (!opAuthenticated) { - opAuthenticated = execFileAsync('op', ['whoami'], { - env: opEnvironment(), - }).catch((e) => { + opAuthenticated = execFileAsync( + 'op', + opSessionArgs().concat(['whoami']) + ).catch((e) => { if (e.code === 'ENOENT') { throw new Error( 'The 1Password cli (op) is not installed. Install it to resolve secret references within templates.' @@ -199,7 +198,7 @@ const ensureOpAuthenticated = () => { } throw new Error( - 'Not signed in to the 1Password cli. Run `eval $(op signin)` in this shell and try again.' + 'Not signed in to the 1Password cli. Run `c op session` and try again.' ); }); } @@ -226,8 +225,13 @@ const injectSecretReferences = async (content) => { await execFileAsync( 'op', - ['inject', '--in-file', inPath, '--out-file', outPath], - { env: opEnvironment() } + opSessionArgs().concat([ + 'inject', + '--in-file', + inPath, + '--out-file', + outPath, + ]) ); return await readFile(outPath, 'utf-8'); diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index c5a2712..42a8705 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -24,8 +24,8 @@ const sha12 = ({ value }) => createHash('sha256').update(value).digest('hex').slice(0, 12); /** - * Resolve the op session token from the environment or the op-session - * alias' state file. Never print or log the return value. + * Resolve the op session token from the environment or `c op session`'s + * state file. Never print or log the return value. * @returns {String} The session token. */ const opSession = () => { @@ -37,7 +37,7 @@ const opSession = () => { return readFileSync(OP_SESSION_FILE, 'utf8').trim(); } catch (e) { throw new Error( - 'No 1Password session. Run the op-session alias (or export OP_SESSION_idearium), then retry.' + 'No 1Password session. Run `c op session` (or export OP_SESSION_idearium), then retry.' ); } }; @@ -52,11 +52,8 @@ const opSession = () => { */ const runOp = ({ args, input }) => { try { - return execFileSync('op', args, { + return execFileSync('op', ['--session', opSession()].concat(args), { encoding: 'utf8', - env: Object.assign({}, process.env, { - OP_SESSION_idearium: opSession(), - }), input, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -68,7 +65,7 @@ const runOp = ({ args, input }) => { stderr.includes('no active session found') ) { throw new Error( - 'The 1Password session has expired. Run the op-session alias, then retry.' + 'The 1Password session has expired. Run `c op session`, then retry.' ); } From 011091764999269978f02160bf20dd2a2de37ccc Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:43:46 +1030 Subject: [PATCH 10/52] Anchored c.js contract edits to secrets.services with rollback. setOpItemInCjs and addKeyToServiceInCjs matched the first name: { block anywhere in c.js, so a service named after a docker location (site, static) silently edited the wrong block. Block location is now scoped to secrets -> services, and every edit (register, addKey, setOpItem) is re-parsed from disk and verified against the parsed contract - a miss or syntax break restores the original text and throws. --- bin/c-secrets-add.js | 4 +- bin/lib/c-secrets-io.js | 309 ++++++++++++++++++++++++++++++---------- 2 files changed, 233 insertions(+), 80 deletions(-) diff --git a/bin/c-secrets-add.js b/bin/c-secrets-add.js index 8c34144..96dcb01 100644 --- a/bin/c-secrets-add.js +++ b/bin/c-secrets-add.js @@ -3,7 +3,6 @@ const print = (line) => process.stdout.write(`${line}\n`); const { readFileSync, writeFileSync } = require('fs'); -const { join } = require('path'); const program = require('commander'); const { loadConfig, reportError } = require('./lib/c'); @@ -17,6 +16,7 @@ const { addKeyToServiceInCjs, assertGcloudAuth, assertOpSession, + cjsPath, generateValue, gsmDelete, gsmPushVerified, @@ -59,8 +59,6 @@ const readStdin = () => { return readFileSync(0, 'utf8').replace(/\r?\n$/, ''); }; -const cjsPath = () => join(process.cwd(), 'c.js'); - return loadConfig() .then((config) => { const contract = getSecretsContract({ config }); diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index 42a8705..4ae368d 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -761,11 +761,17 @@ const resolveOwnerItem = ({ owner, title }) => }) : resolveOpItem({ create: true, opItem: owner && owner.opItem, title }); +/** + * The c.js file's path in the project directory. + * @returns {String} The absolute file path. + */ +const cjsPath = () => join(process.cwd(), 'c.js'); + /** * Read the c.js file's text from the project directory. * @returns {String} The file contents. */ -const readCjs = () => readFileSync(join(process.cwd(), 'c.js'), 'utf8'); +const readCjs = () => readFileSync(cjsPath(), 'utf8'); /** * Write the c.js file's text back to the project directory. @@ -773,7 +779,7 @@ const readCjs = () => readFileSync(join(process.cwd(), 'c.js'), 'utf8'); * @param {String} options.text The new file contents. * @returns {Void} */ -const writeCjs = ({ text }) => writeFileSync(join(process.cwd(), 'c.js'), text); +const writeCjs = ({ text }) => writeFileSync(cjsPath(), text); /** * Quote a service/key name for c.js: single quotes only when required. @@ -784,10 +790,153 @@ const writeCjs = ({ text }) => writeFileSync(join(process.cwd(), 'c.js'), text); const quoteIfNeeded = ({ name }) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : `'${name}'`; +/** + * Locate the secrets block in c.js text: only content between these + * boundaries belongs to the contract, as c.js may contain + * identically-named properties elsewhere (docker locations etc). + * @param {Object} options + * @param {String} options.text The c.js file contents. + * @returns {Object} { end, indent, start } with start just after the + * opening brace and end at the newline preceding the closing brace. + */ +const locateSecretsBlock = ({ text }) => { + const secretsMatch = text.match(/\n(\s*)secrets:\s*\{/); + + if (!secretsMatch) { + throw new Error( + 'Could not find secrets in c.js - add the contract manually.' + ); + } + + const [, indent] = secretsMatch; + const start = secretsMatch.index + secretsMatch[0].length; + const end = text.indexOf(`\n${indent}}`, start); + + if (end === -1) { + throw new Error( + 'Could not find the end of secrets in c.js - add the contract manually.' + ); + } + + return { end, indent, start }; +}; + +/** + * Locate the secrets.services block in c.js text (scoped to secrets). + * @param {Object} options + * @param {String} options.text The c.js file contents. + * @returns {Object} { end, indent, start } with start just after the + * opening brace and end at the newline preceding the closing brace. + */ +const locateServicesBlock = ({ text }) => { + const secrets = locateSecretsBlock({ text }); + const servicesMatch = text + .slice(secrets.start, secrets.end) + .match(/\n(\s*)services:\s*\{/); + + if (!servicesMatch) { + throw new Error( + 'Could not find secrets.services in c.js - add the service manually.' + ); + } + + const [, indent] = servicesMatch; + const start = secrets.start + servicesMatch.index + servicesMatch[0].length; + const end = text.indexOf(`\n${indent}}`, start); + + if (end === -1) { + throw new Error( + 'Could not find the end of secrets.services in c.js - add the service manually.' + ); + } + + return { end, indent, start }; +}; + +/** + * Locate one service's block inside secrets.services: the first + * name-matching block found there wins, never a block elsewhere in c.js. + * @param {Object} options + * @param {String} options.name The service name. + * @param {String} options.text The c.js file contents. + * @returns {Object} { end, indent, start } with start at the newline + * beginning the service's line and end at the newline preceding its + * closing brace. + */ +const locateServiceBlock = ({ name, text }) => { + const services = locateServicesBlock({ text }); + const serviceMatch = text + .slice(services.start, services.end) + .match(new RegExp(`\\n(\\s*)${quoteIfNeeded({ name })}:\\s*\\{`)); + + if (!serviceMatch) { + throw new Error( + `Could not find service '${name}' in secrets.services in c.js.` + ); + } + + const [, indent] = serviceMatch; + const start = services.start + serviceMatch.index; + const end = text.indexOf(`\n${indent}}`, start); + + if (end === -1) { + throw new Error(`Could not find the end of service '${name}' in c.js.`); + } + + return { end, indent, start }; +}; + +/** + * The freshly parsed service entry, failing loudly when an edit did not + * land in secrets.services. + * @param {Object} options + * @param {Object} options.config The freshly required c.js configuration. + * @param {String} options.name The service name. + * @returns {Object} The parsed service entry. + */ +const expectService = ({ config, name }) => { + const service = + config.secrets && + config.secrets.services && + config.secrets.services[name]; + + if (!service) { + throw new Error(`secrets.services.${name} is missing after the edit`); + } + + return service; +}; + +/** + * Re-parse c.js after an edit and verify the expectation against the + * parsed result, restoring the original text when it fails: an edit that + * misses its target block (or breaks the file) is never kept. + * @param {Object} options + * @param {String} options.backup The pre-edit file text. + * @param {Function} options.expect Receives the freshly parsed c.js + * configuration; throws when the edit did not land as intended. + * @returns {Void} + */ +const verifyCjsEdit = ({ backup, expect }) => { + delete require.cache[require.resolve(cjsPath())]; + + try { + expect(require(cjsPath())); + } catch (e) { + writeCjs({ text: backup }); + + delete require.cache[require.resolve(cjsPath())]; + + throw new Error( + `The c.js edit did not land as expected and was rolled back: ${e.message}` + ); + } +}; + /** * Register a new service in c.js's secrets contract (used by set/add when * the service doesn't exist yet). Inserts alphabetically into - * secrets.services. + * secrets.services, then re-parses c.js to verify the edit landed. * @param {Object} options * @param {String} options.key The first key the service owns. * @param {String} options.name The service name. @@ -796,13 +945,31 @@ const quoteIfNeeded = ({ name }) => */ const registerServiceInCjs = ({ key, name, opItem }) => { const text = readCjs(); + const secrets = locateSecretsBlock({ text }); + + const verify = () => + verifyCjsEdit({ + backup: text, + expect: (config) => { + const service = expectService({ config, name }); + + if (!service.keys.includes(key) || service.opItem !== opItem) { + throw new Error( + `secrets.services.${name} does not carry the registered entry` + ); + } + }, + }); // The minimal scaffold's single-line empty map: expand it with the // first entry. - const emptyMatch = text.match(/\n(\s*)services:\s*\{\}/); + const emptyMatch = text + .slice(secrets.start, secrets.end) + .match(/\n(\s*)services:\s*\{\}/); if (emptyMatch) { const [, emptyIndent] = emptyMatch; + const emptyStart = secrets.start + emptyMatch.index; const expanded = [ `${emptyIndent}services: {`, @@ -813,37 +980,29 @@ const registerServiceInCjs = ({ key, name, opItem }) => { `${emptyIndent}}`, ].join('\n'); - writeCjs({ text: text.replace(emptyMatch[0], `\n${expanded}`) }); - - return; - } - - const servicesMatch = text.match(/\n(\s*)services:\s*\{/); + writeCjs({ + text: + text.slice(0, emptyStart) + + `\n${expanded}` + + text.slice(emptyStart + emptyMatch[0].length), + }); - if (!servicesMatch) { - throw new Error( - 'Could not find secrets.services in c.js - add the service manually.' - ); + return verify(); } - const [, indent] = servicesMatch; - const startIndex = servicesMatch.index + servicesMatch[0].length; - const closingIndex = text.indexOf(`\n${indent}}`, startIndex); - - if (closingIndex === -1) { - throw new Error( - 'Could not find the end of secrets.services in c.js - add the service manually.' - ); - } + const services = locateServicesBlock({ text }); // Walk the top-level entries to find the alphabetical insertion point. - let insertIndex = closingIndex; - const entryRegex = new RegExp(`^${indent} ('?[^':\\n]+'?):`, 'gm'); - entryRegex.lastIndex = startIndex; + let insertIndex = services.end; + const entryRegex = new RegExp( + `^${services.indent} ('?[^':\\n]+'?):`, + 'gm' + ); + entryRegex.lastIndex = services.start; let match = entryRegex.exec(text); - while (match && match.index < closingIndex) { + while (match && match.index < services.end) { const entryName = match[1].replace(/^'|'$/g, ''); if (entryName > name) { @@ -855,10 +1014,10 @@ const registerServiceInCjs = ({ key, name, opItem }) => { } const fullBlock = [ - `${indent} ${quoteIfNeeded({ name })}: {`, - `${indent} keys: ['${key}'],`, - `${indent} opItem: '${opItem}',`, - `${indent} },`, + `${services.indent} ${quoteIfNeeded({ name })}: {`, + `${services.indent} keys: ['${key}'],`, + `${services.indent} opItem: '${opItem}',`, + `${services.indent} },`, ].join('\n'); writeCjs({ @@ -866,11 +1025,15 @@ const registerServiceInCjs = ({ key, name, opItem }) => { insertIndex )}`, }); + + return verify(); }; /** * Add a key to a service's keys array in c.js, preserving the array's - * existing layout (single-line or multi-line). + * existing layout (single-line or multi-line). The service block is + * located inside secrets.services, and the edit is re-parsed to verify + * it landed. * @param {Object} options * @param {String} options.key The key to add. * @param {String} options.name The service name. @@ -878,25 +1041,9 @@ const registerServiceInCjs = ({ key, name, opItem }) => { */ const addKeyToServiceInCjs = ({ key, name }) => { const text = readCjs(); + const service = locateServiceBlock({ name, text }); - const servicePattern = new RegExp( - `\\n(\\s*)${quoteIfNeeded({ name })}:\\s*\\{` - ); - const serviceMatch = text.match(servicePattern); - - if (!serviceMatch) { - throw new Error(`Could not find service '${name}' in c.js.`); - } - - const [, indent] = serviceMatch; - const serviceStart = serviceMatch.index; - const serviceEnd = text.indexOf(`\n${indent}}`, serviceStart); - - if (serviceEnd === -1) { - throw new Error(`Could not find the end of service '${name}' in c.js.`); - } - - const serviceText = text.slice(serviceStart, serviceEnd); + const serviceText = text.slice(service.start, service.end); const keysMatch = serviceText.match(/keys:\s*\[([^\]]*)\]/); @@ -923,23 +1070,36 @@ const addKeyToServiceInCjs = ({ key, name }) => { const rendered = multiline ? `keys: [\n${entries - .map((entry) => `${indent} '${entry}',`) - .join('\n')}\n${indent}],` + .map((entry) => `${service.indent} '${entry}',`) + .join('\n')}\n${service.indent}],` : `keys: [${entries.map((entry) => `'${entry}'`).join(', ')}]`; const newServiceText = serviceText.replace(keysMatch[0], rendered); writeCjs({ text: - text.slice(0, serviceStart) + + text.slice(0, service.start) + newServiceText + - text.slice(serviceEnd), + text.slice(service.end), + }); + + verifyCjsEdit({ + backup: text, + expect: (config) => { + if (!expectService({ config, name }).keys.includes(key)) { + throw new Error( + `secrets.services.${name}.keys does not include '${key}' after the edit` + ); + } + }, }); }; /** * Bind a service's opItem in c.js (inserting the property when the service - * block has none) - used to persist lazily resolved item ids. + * block has none) - used to persist lazily resolved item ids. The service + * block is located inside secrets.services, and the edit is re-parsed to + * verify it landed. * @param {Object} options * @param {String} options.name The service name. * @param {String} options.opItem The 1Password item id. @@ -947,35 +1107,29 @@ const addKeyToServiceInCjs = ({ key, name }) => { */ const setOpItemInCjs = ({ name, opItem }) => { const text = readCjs(); + const service = locateServiceBlock({ name, text }); - const servicePattern = new RegExp( - `\\n(\\s*)${quoteIfNeeded({ name })}:\\s*\\{` - ); - const serviceMatch = text.match(servicePattern); - - if (!serviceMatch) { - throw new Error(`Could not find service '${name}' in c.js.`); - } - - const [, indent] = serviceMatch; - const serviceStart = serviceMatch.index; - const serviceEnd = text.indexOf(`\n${indent}}`, serviceStart); - - if (serviceEnd === -1) { - throw new Error(`Could not find the end of service '${name}' in c.js.`); - } - - const serviceText = text.slice(serviceStart, serviceEnd); + const serviceText = text.slice(service.start, service.end); if (/opItem:\s*'/.test(serviceText)) { return; } writeCjs({ - text: `${text.slice( - 0, - serviceEnd - )}\n${indent} opItem: '${opItem}',${text.slice(serviceEnd)}`, + text: `${text.slice(0, service.end)}\n${ + service.indent + } opItem: '${opItem}',${text.slice(service.end)}`, + }); + + verifyCjsEdit({ + backup: text, + expect: (config) => { + if (expectService({ config, name }).opItem !== opItem) { + throw new Error( + `secrets.services.${name}.opItem does not carry the binding after the edit` + ); + } + }, }); }; @@ -1052,6 +1206,7 @@ module.exports = { assertGcloudAuth, assertKubectl, assertOpSession, + cjsPath, gcfList, generateValue, guardPlaintext, From 30a59793813a229581010f3330dfb33b6bcfbd76 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:50:08 +1030 Subject: [PATCH 11/52] Threw when rendering local Secrets for unbound owners. resolveServiceKey resolves opRef: null for unbound services and shared entries - rendering embedded a literal null that survived op inject and was base64-encoded into the Secret. Generation now fails with a remedy (c op set for services, shared.opItem for shared keys) instead. --- bin/lib/c-secrets-manifests.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/bin/lib/c-secrets-manifests.js b/bin/lib/c-secrets-manifests.js index 42f72f4..00e2367 100644 --- a/bin/lib/c-secrets-manifests.js +++ b/bin/lib/c-secrets-manifests.js @@ -105,8 +105,23 @@ const renderSecretSyncServiceAccount = ({ namespace }) => * @param {String} options.service The service name. * @returns {String} The manifest content (with op:// references). */ -const renderLocalSecret = ({ entries, namespace, service }) => - [ +const renderLocalSecret = ({ entries, namespace, service }) => { + // An unbound owner resolves no op:// reference: rendering would embed + // a literal null as the secret value, so fail the generation instead. + const unbound = entries.find((resolved) => !resolved.opRef); + + if (unbound) { + const remedy = + unbound.owner && unbound.owner.name === 'shared' + ? 'set shared.opItem in c.js (the item that stores the shared values)' + : `run \`c op set ${service} local ${unbound.key}\` (it binds the service's item)`; + + throw new Error( + `Cannot render the local Secret for service '${service}': key '${unbound.key}' has no op:// reference (its owner is unbound) - ${remedy}, then re-run.` + ); + } + + return [ 'apiVersion: v1', 'kind: Secret', 'metadata:', @@ -123,6 +138,7 @@ const renderLocalSecret = ({ entries, namespace, service }) => ) .concat(['']) .join('\n'); +}; /** * Generate every secret-shaped manifest declared by the contract for the From db485f30e613e76b69332d192f94e9c63b7269c8 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:54:52 +1030 Subject: [PATCH 12/52] Stopped c op get from creating 1Password items. resolveOwnerItem always created for unbound services, so the read path left stray empty items behind failed reads. resolveOwnerItem now takes create (default true, shared never creates), and c op get passes create false per its stated reads-never-create design. --- bin/c-op-get.js | 10 +++++++--- bin/lib/c-secrets-io.js | 11 +++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/bin/c-op-get.js b/bin/c-op-get.js index 15215f2..99a48d5 100644 --- a/bin/c-op-get.js +++ b/bin/c-op-get.js @@ -72,14 +72,18 @@ return loadConfig() key, }); - // Unbound services resolve their item lazily (reads never create); - // the shared block resolves too but is never auto-created. + // Unbound services resolve their item lazily, and reads never + // create; the shared block resolves too but is never auto-created. const itemTitle = resolved.owner ? `${contract.organisation}-${contract.name}/${resolved.owner.name}` : `${contract.organisation}-${contract.name}/${name}`; const item = resolved.owner - ? resolveOwnerItem({ owner: resolved.owner, title: itemTitle }).id + ? resolveOwnerItem({ + create: false, + owner: resolved.owner, + title: itemTitle, + }).id : resolveOpItem({ create: false, title: itemTitle }).id; const ref = diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index 4ae368d..a3aa029 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -742,15 +742,18 @@ const resolveOpItem = ({ create, noCreateMessage, opItem, title }) => { }; /** - * Resolve the target item for an owning entry: services resolve-or-create; - * the shared block only ever resolves (where a shared value lives is the + * Resolve the target item for an owning entry: services resolve-or-create + * (write paths - read paths pass create false and resolve only); the + * shared block only ever resolves (where a shared value lives is the * author's decision, never the tool's). * @param {Object} options + * @param {Boolean} [options.create=true] Whether a service item may be + * created. The shared block never creates regardless. * @param {Object} options.owner The owning service or shared entry. * @param {String} options.title The item title. * @returns {Object} { created, id, persisted }. */ -const resolveOwnerItem = ({ owner, title }) => +const resolveOwnerItem = ({ create = true, owner, title }) => owner && owner.name === 'shared' ? resolveOpItem({ create: false, @@ -759,7 +762,7 @@ const resolveOwnerItem = ({ owner, title }) => opItem: owner.opItem, title, }) - : resolveOpItem({ create: true, opItem: owner && owner.opItem, title }); + : resolveOpItem({ create, opItem: owner && owner.opItem, title }); /** * The c.js file's path in the project directory. From 9670e19dcb42e5083a1278f4be62dbd11cec5ac4 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:58:18 +1030 Subject: [PATCH 13/52] Validated --generate flags in c op set and c secrets add. A bare -l parsed to true then NaN into randomBytes (raw crypto error), and c secrets add had the same latent bug. Both commands now share generateValueForProgram, which fails with a friendly error for a bare or invalid -l/-t before generating. --- bin/c-op-set.js | 7 ++----- bin/c-secrets-add.js | 7 ++----- bin/lib/c-secrets-io.js | 25 +++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/bin/c-op-set.js b/bin/c-op-set.js index 320b093..891247f 100644 --- a/bin/c-op-set.js +++ b/bin/c-op-set.js @@ -11,7 +11,7 @@ const { } = require('./lib/c-secrets'); const { assertOpSession, - generateValue, + generateValueForProgram, opFieldExists, opUpsertField, registerServiceInCjs, @@ -69,10 +69,7 @@ return loadConfig() assertKnownEnv({ config, env }); const value = program.generate - ? generateValue({ - length: program.L ? parseInt(program.L, 10) : 32, - type: program.T, - }) + ? generateValueForProgram({ program }) : readStdin(); const title = (serviceName) => diff --git a/bin/c-secrets-add.js b/bin/c-secrets-add.js index 96dcb01..869ca56 100644 --- a/bin/c-secrets-add.js +++ b/bin/c-secrets-add.js @@ -17,7 +17,7 @@ const { assertGcloudAuth, assertOpSession, cjsPath, - generateValue, + generateValueForProgram, gsmDelete, gsmPushVerified, opUpsertField, @@ -74,10 +74,7 @@ return loadConfig() assertKnownEnv({ config, env }); const value = program.generate - ? generateValue({ - length: program.L ? parseInt(program.L, 10) : 32, - type: program.T, - }) + ? generateValueForProgram({ program }) : readStdin(); const backup = readFileSync(cjsPath(), 'utf8'); diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index a3aa029..694af97 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -226,6 +226,30 @@ const generateValue = ({ length = 32, type = 'hex' }) => { throw new Error(`Unknown --type '${type}' (hex, base64, alphanumeric)`); }; +/** + * Generate a random value from a parsed commander program's --generate + * flags, failing with a friendly error for malformed input (a bare -l + * parses to true then NaN; a bare -t parses to true). + * @param {Object} options + * @param {Object} options.program The parsed commander program. + * @returns {String} The generated value. + */ +const generateValueForProgram = ({ program }) => { + if (program.T === true) { + throw new Error( + '--type requires a value: hex, base64 or alphanumeric.' + ); + } + + const length = program.L === undefined ? 32 : parseInt(program.L, 10); + + if (!Number.isInteger(length) || length < 1) { + throw new Error('--length must be a positive integer.'); + } + + return generateValue({ length, type: program.T }); +}; + /** * Map gcloud authentication failures to a friendly, actionable error. * @param {Object} options @@ -1212,6 +1236,7 @@ module.exports = { cjsPath, gcfList, generateValue, + generateValueForProgram, guardPlaintext, gsmAddVersion, gsmCreate, From 44f69ee28bab577cfb2a67ed2e97f4fe40d139d0 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:07:43 +1030 Subject: [PATCH 14/52] Compared k8s Secret value hashes against GSM in verify. The per-key k8s hash was printed with a hard-coded ok: true - only the key set was reconciled, so verify reported VERIFIED even when a synced Secret's value had drifted. Phase 1 now records each key's GSM hash by secret id and phase 2 compares, flagging drift (or sync lag) instead. --- bin/lib/c-secrets-verify.js | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/bin/lib/c-secrets-verify.js b/bin/lib/c-secrets-verify.js index 8b7a9f1..00051db 100644 --- a/bin/lib/c-secrets-verify.js +++ b/bin/lib/c-secrets-verify.js @@ -129,6 +129,10 @@ const verifyEnv = ({ config, contract, env }) => { } }); + // GSM value hashes per secret id, recorded in phase 1 and compared + // against the Kubernetes values in phase 2. + const gsmShas = {}; + // 1. GSM existence + op/GSM value hashes. return entries .reduce( @@ -156,6 +160,8 @@ const verifyEnv = ({ config, contract, env }) => { value: gsmValue, }); + gsmShas[resolved.gsmId] = gsmSha; + const opItem = opItemFor( resolved.owner ); @@ -278,11 +284,27 @@ const verifyEnv = ({ config, contract, env }) => { 'base64' ); - report({ + const k8sSha = sha12({ + value: k8sValue, + }); + const gsmSha = gsmShas[resolved.gsmId]; + + if (!gsmSha) { + return report({ + message: `k8s ${name}/${resolved.key} ${k8sSha} (no GSM hash to compare - the GSM secret was missing above)`, + ok: false, + }); + } + + return report({ message: `k8s ${name}/${ resolved.key - } ${sha12({ value: k8sValue })}`, - ok: true, + } ${k8sSha} gsm:${gsmSha}${ + k8sSha === gsmSha + ? '' + : ' (value drift, or the SecretSync has not caught up yet)' + }`, + ok: k8sSha === gsmSha, }); }), Promise.resolve() From 6ad8d9c62eea7d68ee9c75f4e295da1fb9f28017 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:11:55 +1030 Subject: [PATCH 15/52] Removed the internal gsmSegment flag. Segment-ness now derives from the owner name (shared is reserved for the shared block), so the flag carried no information. init's dead segmentless emission went with it, and c secrets add rejects a new service named shared up front. --- bin/c-secrets-add.js | 8 +++++++- bin/lib/c-secrets-init.js | 6 ------ bin/lib/c-secrets.js | 13 +++++-------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/bin/c-secrets-add.js b/bin/c-secrets-add.js index 869ca56..c9b645a 100644 --- a/bin/c-secrets-add.js +++ b/bin/c-secrets-add.js @@ -138,6 +138,12 @@ return loadConfig() ); } + if (name === 'shared') { + throw new Error( + "'shared' is reserved for the secrets.shared block - add the key there instead" + ); + } + binding = resolveOwnerItem({ owner: null, title: `${contract.organisation}-${contract.name}/${name}`, @@ -148,7 +154,7 @@ return loadConfig() contract, env, key, - owner: { gsmSegment: true, name }, + owner: { name }, }); isNewService = true; ownerName = name; diff --git a/bin/lib/c-secrets-init.js b/bin/lib/c-secrets-init.js index fb1187f..e7bbf06 100644 --- a/bin/lib/c-secrets-init.js +++ b/bin/lib/c-secrets-init.js @@ -782,16 +782,11 @@ const draftContract = async ({ config }) => { indent, keys, opItem, - segmentless, serviceName, sharedKeys = [], }) => { lines.push(`${indent}${quoteIfNeeded({ name: serviceName })}: {`); - if (segmentless) { - lines.push(`${indent} gsmSegment: false,`); - } - // opItem only when already known (1Password scan or local template // refs) - unbound services resolve lazily on first use. if (opItem) { @@ -829,7 +824,6 @@ const draftContract = async ({ config }) => { indent: ' ', keys: services[service].keys, opItem: services[service].opItem || nameHints[service] || null, - segmentless: false, serviceName: service, sharedKeys: services[service].sharedKeys, }); diff --git a/bin/lib/c-secrets.js b/bin/lib/c-secrets.js index 8ce530e..8e3e670 100644 --- a/bin/lib/c-secrets.js +++ b/bin/lib/c-secrets.js @@ -128,10 +128,6 @@ const validateEntry = ({ entry, hasGsm, name }) => { }; if (hasGsm) { - // Internal only: services derive service-segmented GSM ids; the - // shared entry is the only segment-less owner. - validated.gsmSegment = true; - if (isNonEmptyString({ value: entry.opItem })) { validated.opItem = entry.opItem; } @@ -161,7 +157,6 @@ const validateShared = ({ rawShared }) => { }); const shared = { - gsmSegment: false, keys: assertKeyArray({ message: 'secrets.shared.keys', value: rawShared.keys, @@ -456,16 +451,18 @@ const getSecretsContract = ({ config = {} }) => { }; /** - * Derive the GSM secret id for an owned key of a service. + * Derive the GSM secret id for an owned key: service-segmented, except + * for the shared entry whose ids carry no segment ('shared' is a + * reserved service name, so the name is authoritative). * @param {Object} options * @param {Object} options.contract The normalised contract. * @param {String} options.env The environment (local, beta, production...). * @param {String} options.key The environment-variable style key. - * @param {Object} options.owner The owning service. + * @param {Object} options.owner The owning service or shared entry. * @returns {String} The GSM secret id. */ const gsmId = ({ contract, env, key, owner }) => - owner.gsmSegment === false + owner.name === 'shared' ? `${contract.organisation}-${contract.name}-${env}-${kebabCase({ key, })}` From 79da63e7eb653bd5b61624a4e7da0aa67c1ff30c Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:16:41 +1030 Subject: [PATCH 16/52] Documented the session commands and review fixes in the changelog. --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 213da6a..efc7195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ This file is a history of the changes made to @idearium/cli. - `c op get|set` and `c secrets get|push|add|verify|ls|consumers|init`. Values are only ever displayed as truncated hashes (`--plaintext` is terminal-gated and agent-blocked with a discouraged `--force`); writes flow through stdin pipes; `push` verifies its GSM readback by hash; `add` is ordered, idempotent and rollback-safe across c.js, 1Password and GSM and never touches the cluster; `consumers` gates deletions; `init` drafts a contract from existing declarations (warm 1Password session required), binding `shared.opItem` and removing legacy duplicates by comparing value hashes. - `opItem` references (`services.`/`dev`, one hop) so each 1Password item id appears exactly once in the contract. Services resolve (or create) their item lazily by title; the `shared` block never auto-creates. - Friendly credential pre-flights: commands that need 1Password, gcloud or kubectl fail fast with actionable re-authentication errors instead of raw tool output. +- The cli owns the 1Password session lifecycle: `c op session` signs in and stores the raw session token at `~/.local/state/idearium/op-session` (`0600`, enforced on overwrite), and `c op cmd [command...]` runs any `op` command with that session passed via `--session` (exit code propagates). Every internal `op` invocation (`c op get|set`, `c secrets *`, `c kc` template injection) passes the session the same way, so no `OP_SESSION_` env var name is guessed anywhere; an exported `OP_SESSION_idearium` still takes precedence for `eval $(op signin)` users. + +### Fixed + +- c.js contract edits are anchored to `secrets.services` and verified after writing: `c op set` and `c secrets add` previously matched the first `name: {` block anywhere in `c.js`, silently editing a same-named docker location instead of the contract service. Every edit is now re-parsed from disk and rolled back when it does not land in the contract. +- Generating local Secret manifests for unbound owners (a service without an `opItem`, or an unbound `shared` block) embedded a literal `null` as the secret value; generation now fails with a remedy instead. +- `c op get` no longer creates 1Password items for unbound services (reads never create; write paths still resolve-or-create). +- A bare or invalid `--length`/`--type` on `c op set --generate` and `c secrets add --generate` failed with a raw crypto error (`NaN`); both now fail with usage hints. +- `c secrets verify` now compares each synced Kubernetes Secret's value hash against GSM (hashes were previously displayed with a hard-coded `ok`, so drifted values still reported `VERIFIED`). ## v6.1.0 - 2026-09-16 From 2f59ab137fc7a9c3cfc3921b953f5360894b755f Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:19:03 +1030 Subject: [PATCH 17/52] 6.2.0-beta.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 52066cd..0398729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.2", + "version": "6.2.0-beta.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.2.0-beta.2", + "version": "6.2.0-beta.3", "license": "MIT", "dependencies": { "chalk": "4.1.2", diff --git a/package.json b/package.json index ea07ffc..9405c56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.2", + "version": "6.2.0-beta.3", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": { From 8a06738be463a816fd28ef4365c64dc2b8154295 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:33:54 +1030 Subject: [PATCH 18/52] Preserved raw argv through the op and kc cmd passthroughs. Commander's git-style dispatch re-parses subcommand args at every layer before spawning, silently dropping -- and unknown options (breaking e.g. c op cmd run -- sh -c ... and c kc cmd commands carrying kubectl flags). The op and kc subtrees are now dispatched with the verbatim argv, and each carries its cmd passthrough down the same way. --- CHANGELOG.md | 1 + bin/c-kc.js | 18 ++++++++++++++++++ bin/c-op.js | 18 ++++++++++++++++++ bin/c.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index efc7195..2bc9661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This file is a history of the changes made to @idearium/cli. - `c op get` no longer creates 1Password items for unbound services (reads never create; write paths still resolve-or-create). - A bare or invalid `--length`/`--type` on `c op set --generate` and `c secrets add --generate` failed with a raw crypto error (`NaN`); both now fail with usage hints. - `c secrets verify` now compares each synced Kubernetes Secret's value hash against GSM (hashes were previously displayed with a hard-coded `ok`, so drifted values still reported `VERIFIED`). +- `c op cmd` and `c kc cmd` now dispatch with the verbatim argv: commander's git-style dispatch re-parsed subcommand args at every layer first, silently dropping `--` and unknown options (breaking e.g. `c op cmd run -- sh -c ...` and `c kc cmd` commands carrying kubectl flags). ## v6.1.0 - 2026-09-16 diff --git a/bin/c-kc.js b/bin/c-kc.js index 333b110..53a079a 100644 --- a/bin/c-kc.js +++ b/bin/c-kc.js @@ -3,6 +3,24 @@ const program = require('commander'); const { missingCommand } = require('./lib/c'); +// The cmd passthrough needs the verbatim argv (bin/c.js forwards this +// subtree raw): commander's git-style dispatch would re-parse the args +// first, dropping -- and unknown options like kubectl flags. +if (process.argv[2] === 'cmd') { + const { spawn } = require('child_process'); + const { join } = require('path'); + + spawn( + 'node', + [join(__dirname, 'c-kc-cmd.js')].concat(process.argv.slice(3)), + { stdio: 'inherit' } + ).on('exit', (code) => { + process.exitCode = code; + }); + + return; +} + program .command('apply ', 'Deploy a particular Kubernetes locations.') .command( diff --git a/bin/c-op.js b/bin/c-op.js index 1e09fd7..9afb95c 100644 --- a/bin/c-op.js +++ b/bin/c-op.js @@ -3,6 +3,24 @@ const program = require('commander'); const { missingCommand } = require('./lib/c'); +// The cmd passthrough needs the verbatim argv (bin/c.js forwards this +// subtree raw): commander's git-style dispatch would re-parse the args +// first, dropping -- and unknown options. +if (process.argv[2] === 'cmd') { + const { spawn } = require('child_process'); + const { join } = require('path'); + + spawn( + 'node', + [join(__dirname, 'c-op-cmd.js')].concat(process.argv.slice(3)), + { stdio: 'inherit' } + ).on('exit', (code) => { + process.exitCode = code; + }); + + return; +} + program .command( 'get ', diff --git a/bin/c.js b/bin/c.js index 612bfd1..a139887 100755 --- a/bin/c.js +++ b/bin/c.js @@ -5,6 +5,34 @@ const program = require('commander'); const { missingCommand } = require('./lib/c'); +// Commander's git-style dispatch re-parses subcommand args at every layer +// before spawning (dropping -- and unknown options, breaking passthroughs +// like `c op cmd run -- sh -c ...` and `c kc cmd` commands carrying +// kubectl flags), so the passthrough-owning subtrees are dispatched with +// the verbatim argv instead - their own cmd guards carry it the rest of +// the way down. +const VERBATIM_DISPATCH = { + kc: 'c-kc.js', + op: 'c-op.js', +}; + +const verbatimChild = VERBATIM_DISPATCH[process.argv[2]]; + +if (verbatimChild) { + const { spawn } = require('child_process'); + const { join } = require('path'); + + spawn( + 'node', + [join(__dirname, verbatimChild)].concat(process.argv.slice(3)), + { stdio: 'inherit' } + ).on('exit', (code) => { + process.exitCode = code; + }); + + return; +} + // The basic program, which uses sub-commands. program .command('d ', 'Shortcuts to control Docker.') From 383828e5aee8e1bc94b76fa4d049f74406043b18 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:50:34 +1030 Subject: [PATCH 19/52] 6.2.0-beta.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0398729..3745bd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.3", + "version": "6.2.0-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.2.0-beta.3", + "version": "6.2.0-beta.4", "license": "MIT", "dependencies": { "chalk": "4.1.2", diff --git a/package.json b/package.json index 9405c56..112d3bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.3", + "version": "6.2.0-beta.4", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": { From f45899783d6d7d710579da385f44c171f8c55903 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:52:42 +1030 Subject: [PATCH 20/52] Sectioned, streamed and bulk-fetched c secrets verify. Each phase prints a heading with a one-line explanation and its output streams as checks run. Data is prefetched in bulk (one item list, one item fetch per distinct 1Password item, one GSM list, GSM values with bounded concurrency, one kubectl fetch per synced secret), and function bindings are also checked against the GSM list, so a binding referencing a deleted secret is reported (MISSING) alongside contract drift (STALE). --- CHANGELOG.md | 1 + bin/c-secrets-verify.js | 4 +- bin/lib/c-secrets-io.js | 70 ++++- bin/lib/c-secrets-verify.js | 570 +++++++++++++++++++++--------------- 4 files changed, 401 insertions(+), 244 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc9661..188e72b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This file is a history of the changes made to @idearium/cli. - `opItem` references (`services.`/`dev`, one hop) so each 1Password item id appears exactly once in the contract. Services resolve (or create) their item lazily by title; the `shared` block never auto-creates. - Friendly credential pre-flights: commands that need 1Password, gcloud or kubectl fail fast with actionable re-authentication errors instead of raw tool output. - The cli owns the 1Password session lifecycle: `c op session` signs in and stores the raw session token at `~/.local/state/idearium/op-session` (`0600`, enforced on overwrite), and `c op cmd [command...]` runs any `op` command with that session passed via `--session` (exit code propagates). Every internal `op` invocation (`c op get|set`, `c secrets *`, `c kc` template injection) passes the session the same way, so no `OP_SESSION_` env var name is guessed anywhere; an exported `OP_SESSION_idearium` still takes precedence for `eval $(op signin)` users. +- `c secrets verify` prints a headed section per check phase with output streaming as each check runs, prefetches its data in bulk (one 1Password item fetch per item, one GSM list, bounded-concurrency GSM reads, one kubectl fetch per secret), and reports function bindings referencing deleted GSM secrets (`MISSING`) alongside contract drift (`STALE`). ### Fixed diff --git a/bin/c-secrets-verify.js b/bin/c-secrets-verify.js index adb606c..4f6a70a 100644 --- a/bin/c-secrets-verify.js +++ b/bin/c-secrets-verify.js @@ -48,9 +48,7 @@ return loadConfig() return verifyEnv({ config, contract, env }); }) - .then(({ lines, ok, problems }) => { - lines.forEach((line) => print(line)); - + .then(({ ok, problems }) => { if (ok) { return print('\nVERIFIED'); } diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index 694af97..7bf04ee 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -106,6 +106,32 @@ const opItemFields = ({ item }) => { })); }; +/** + * Read a 1Password item's fields and values in one call (bulk reads - one + * op invocation per item instead of one per field). + * @param {Object} options + * @param {String} options.item The item id. + * @returns {Object} { fields: [{ field, section }], values } with values + * keyed 'section/field'. + */ +const opItemValues = ({ item }) => { + const raw = JSON.parse( + runOp({ args: ['item', 'get', item, '--format=json'] }) + ); + + const fields = []; + const values = {}; + + (raw.fields || []).forEach((field) => { + const section = field.section ? field.section.label : null; + + fields.push({ field: field.label, section }); + values[`${section}/${field.label}`] = field.value; + }); + + return { fields, values }; +}; + /** * Check whether a section/field exists on an item. * @param {Object} options @@ -656,6 +682,31 @@ const k8sSecretKeys = ({ context, namespace, secret }) => { return Object.keys(JSON.parse(raw).data || {}); }; +/** + * Read a Kubernetes secret in one call: its keys and their decoded values + * (bulk reads - one kubectl invocation per secret instead of one per key). + * @param {Object} options + * @param {String} options.context The kubectl context. + * @param {String} options.namespace The namespace. + * @param {String} options.secret The Kubernetes secret name. + * @returns {Object} { keys, values } with values keyed and decoded. + */ +const k8sReadSecret = ({ context, namespace, secret }) => { + const raw = runKubectl({ + args: ['-n', namespace, 'get', 'secret', secret, '-o', 'json'], + context, + }); + + const data = JSON.parse(raw).data || {}; + const values = {}; + + Object.keys(data).forEach((key) => { + values[key] = Buffer.from(data[key], 'base64'); + }); + + return { keys: Object.keys(values), values }; +}; + /** * List every SecretProviderClass in a namespace with the GSM ids their * parameters reference. @@ -715,6 +766,16 @@ const assertOpSession = () => { runOp({ args: ['whoami', '--format=json'] }); }; +/** + * List the Team vault's 1Password items in one call (bulk title lookups - + * one item list instead of one per title). + * @returns {Array} The raw items (id, title, ...). + */ +const opListItems = () => + JSON.parse( + runOp({ args: ['item', 'list', '--vault=Team', '--format=json'] }) + ); + /** * Find a 1Password item by exact title, returning its id (or null). * @param {Object} options @@ -722,11 +783,7 @@ const assertOpSession = () => { * @returns {String|null} The item id. */ const opFindItemByTitle = ({ title }) => { - const raw = runOp({ - args: ['item', 'list', '--vault=Team', '--format=json'], - }); - - const item = JSON.parse(raw).find((candidate) => candidate.title === title); + const item = opListItems().find((candidate) => candidate.title === title); return item ? item.id : null; }; @@ -1246,11 +1303,14 @@ module.exports = { gsmPushVerified, gsmRead, k8sReadKey, + k8sReadSecret, k8sSecretKeys, opCreateItem, opFieldExists, opFindItemByTitle, opItemFields, + opItemValues, + opListItems, opRead, opSession, opReadSha, diff --git a/bin/lib/c-secrets-verify.js b/bin/lib/c-secrets-verify.js index 00051db..08aacb7 100644 --- a/bin/lib/c-secrets-verify.js +++ b/bin/lib/c-secrets-verify.js @@ -7,13 +7,11 @@ const { serviceKeys } = require('./c-secrets'); const { assertKubectl, gcfList, - gsmDescribe, + gsmList, gsmRead, - k8sReadKey, - k8sSecretKeys, - opFindItemByTitle, - opItemFields, - opReadSha, + k8sReadSecret, + opItemValues, + opListItems, sha12, spcList, } = require('./c-secrets-io'); @@ -61,25 +59,32 @@ const syncServiceNames = ({ config, env }) => { * Verify one environment: contract-derived GSM secrets exist, their values * hash-match 1Password, Kubernetes Secrets (for synced services) match * structure and hashes, and deployed Cloud Functions reference only - * contract-derived secret ids. Values are compared by hash only. + * contract-derived secret ids that exist in GSM. Values are compared by + * hash only. Data is prefetched in bulk, then every check reports as it + * runs. * @param {Object} options * @param {Object} options.config The full c.js configuration. * @param {Object} options.contract The normalised contract. * @param {String} options.env The environment. - * @returns {Promise} Resolves with { lines, ok }. + * @returns {Promise} Resolves with { ok, problems }. */ const verifyEnv = ({ config, contract, env }) => { - const lines = []; const problems = []; + // Report as checks run: the pass takes a while, so progress beats + // buffering everything for the end. const report = ({ message, ok }) => { - lines.push(`${ok ? 'OK ' : 'PROBLEM '} ${message}`); + process.stdout.write(`${ok ? 'OK ' : 'PROBLEM '} ${message}\n`); if (!ok) { problems.push(message); } }; + const section = ({ detail, title }) => { + process.stdout.write(`\n== ${title} ==\n${detail}\n\n`); + }; + const entries = Object.values(contract.services) .concat(contract.shared ? [contract.shared] : []) .sort((a, b) => a.name.localeCompare(b.name)); @@ -106,20 +111,6 @@ const verifyEnv = ({ config, contract, env }) => { }); }); - // Lazily resolved op items per owner (reads never create). - const opItems = {}; - const opItemFor = (owner) => { - if (!(owner.name in opItems)) { - opItems[owner.name] = owner.opItem - ? owner.opItem - : opFindItemByTitle({ - title: `${contract.organisation}-${contract.name}/${owner.name}`, - }); - } - - return opItems[owner.name]; - }; - // Reverse mapping for readable storage notes: which entry owns an item. const itemOwners = {}; @@ -129,206 +120,294 @@ const verifyEnv = ({ config, contract, env }) => { } }); + // Bulk prefetch, so the checks below run from local data: one item + // list resolves every owner's item id (reads never create), one item + // fetch per distinct item carries the values (phase 1) and labels + // (phase 3), one GSM list covers existence, and one secret fetch per + // synced service carries the cluster values (phase 2). GSM values + // have no batch-access API, so they read with bounded concurrency. + const itemByTitle = new Map( + opListItems().map((item) => [item.title, item.id]) + ); + + const opItemFor = (owner) => + owner.opItem || + itemByTitle.get( + `${contract.organisation}-${contract.name}/${owner.name}` + ) || + null; + + const itemIds = new Set(); + + entries.forEach((entry) => { + const item = opItemFor(entry); + + if (item) { + itemIds.add(item); + } + }); + + if (contract.dev) { + itemIds.add(contract.dev.opItem); + } + + const opData = {}; + + itemIds.forEach((item) => { + try { + opData[item] = opItemValues({ item }); + } catch (e) { + opData[item] = { error: e }; + } + }); + + const resolvedKeys = entries.map((service) => ({ + keys: serviceKeys({ contract, env, service }), + service, + })); + // GSM value hashes per secret id, recorded in phase 1 and compared // against the Kubernetes values in phase 2. const gsmShas = {}; + const gsmValues = {}; - // 1. GSM existence + op/GSM value hashes. - return entries - .reduce( - (promise, service) => - promise.then(() => - serviceKeys({ contract, env, service }).reduce( - (keyPromise, resolved) => - keyPromise.then(() => - gsmDescribe({ - id: resolved.gsmId, - project: contract.project, - }).then((secret) => { - if (!secret) { - return report({ - message: `${service.name}/${resolved.key} gsm ${resolved.gsmId} MISSING`, - ok: false, - }); - } - - return gsmRead({ - id: resolved.gsmId, - project: contract.project, - }).then((gsmValue) => { - const gsmSha = sha12({ - value: gsmValue, - }); - - gsmShas[resolved.gsmId] = gsmSha; - - const opItem = opItemFor( - resolved.owner - ); - - if (!opItem) { - return report({ - message: `${service.name}/${resolved.key} op:UNBOUND (no 1Password item '${contract.organisation}-${contract.name}/${resolved.owner.name}') gsm:${gsmSha}`, - ok: false, - }); - } - - const storedIn = itemOwners[opItem] - ? ` (stored in the ${itemOwners[opItem]} item)` - : ''; - - try { - const opSha = opReadSha({ - ref: `op://${contract.vault}/${opItem}/${env}/${resolved.key}`, - }); - - return report({ - message: `${service.name}/${resolved.key} op:${opSha}${storedIn} gsm:${gsmSha}`, - ok: opSha === gsmSha, - }); - } catch (e) { - return report({ - message: `${service.name}/${ - resolved.key - } op:${ - resolved.opRef - } READ-ERROR ${e.message.slice( - 0, - 60 - )} gsm:${gsmSha}`, - ok: false, - }); - } - }); - }) - ), - Promise.resolve() - ) - ), - Promise.resolve() - ) - .then(() => { - // 2. Kubernetes structure + hashes for synced services. - const kenv = kubernetesEnv({ config, env }); + // Run promise-returning work over items with bounded concurrency. + const pool = ({ items, limit, run }) => { + let index = 0; - if (!kenv) { - return null; + const worker = () => { + if (index >= items.length) { + return Promise.resolve(); } - const namespace = formatProjectPrefix( - contract.organisation, - contract.name, - env, - true, - true + const item = items[index++]; + + return run(item).then(worker); + }; + + return Promise.all( + Array.from({ length: Math.min(limit, items.length) }, worker) + ); + }; + + return gsmList({ project: contract.project }) + .then((existing) => { + const existingIds = new Set(existing); + + const toRead = [ + ...new Set( + resolvedKeys + .flatMap(({ keys }) => keys.map((r) => r.gsmId)) + .filter((id) => existingIds.has(id)) + ), + ]; + + return pool({ + items: toRead, + limit: 6, + run: (id) => + gsmRead({ id, project: contract.project }).then((value) => { + gsmValues[id] = value; + }), + }).then(() => existingIds); + }) + .then((existingIds) => { + // 1. GSM existence + op/GSM value hashes. + section({ + detail: 'Every contract key exists in GSM and its value hash-matches 1Password.', + title: '1Password + GSM (contract secrets)', + }); + + resolvedKeys.forEach(({ service, keys }) => + keys.forEach((resolved) => { + if (!existingIds.has(resolved.gsmId)) { + report({ + message: `${service.name}/${resolved.key} gsm ${resolved.gsmId} MISSING`, + ok: false, + }); + + return; + } + + const gsmSha = sha12({ + value: gsmValues[resolved.gsmId], + }); + + gsmShas[resolved.gsmId] = gsmSha; + + const opItem = opItemFor(resolved.owner); + + if (!opItem) { + report({ + message: `${service.name}/${resolved.key} op:UNBOUND (no 1Password item '${contract.organisation}-${contract.name}/${resolved.owner.name}') gsm:${gsmSha}`, + ok: false, + }); + + return; + } + + const storedIn = itemOwners[opItem] + ? ` (stored in the ${itemOwners[opItem]} item)` + : ''; + + const data = opData[opItem]; + + if (!data || data.error) { + report({ + message: `${service.name}/${ + resolved.key + } op:READ-ERROR ${ + data && data.error + ? String(data.error.message).slice(0, 60) + : 'item not prefetched' + } gsm:${gsmSha}`, + ok: false, + }); + + return; + } + + const value = data.values[`${env}/${resolved.key}`]; + + if (value === undefined) { + report({ + message: `${service.name}/${resolved.key} op:READ-ERROR field '${env}/${resolved.key}' not found in the '${resolved.owner.name}' item gsm:${gsmSha}`, + ok: false, + }); + + return; + } + + const opSha = sha12({ + value: Buffer.from(value, 'utf8'), + }); + + report({ + message: `${service.name}/${resolved.key} op:${opSha}${storedIn} gsm:${gsmSha}`, + ok: opSha === gsmSha, + }); + }) ); - return syncServiceNames({ config, env }).reduce( - (promise, name) => - promise.then(() => { - const service = contract.services[name]; + // 2. Kubernetes structure + hashes for synced services. + const kenv = kubernetesEnv({ config, env }); - if (!service) { - return report({ - message: `k8s ${name}: secretsync location but no contract service`, - ok: false, - }); - } + if (kenv) { + section({ + detail: 'Synced cluster Secrets carry exactly the contract keys, with values hash-matching GSM.', + title: 'Kubernetes Secrets (synced services)', + }); + + const namespace = formatProjectPrefix( + contract.organisation, + contract.name, + env, + true, + true + ); - const expected = serviceKeys({ - contract, - env, - service, + syncServiceNames({ config, env }).forEach((name) => { + const service = contract.services[name]; + + if (!service) { + report({ + message: `k8s ${name}: secretsync location but no contract service`, + ok: false, }); - try { - const liveKeys = k8sSecretKeys({ - context: kenv.context, - namespace, - secret: name, - }).sort(); + return; + } - const expectedKeys = expected - .map((resolved) => resolved.key) - .sort(); + const expected = serviceKeys({ + contract, + env, + service, + }); - const structureOk = - JSON.stringify(liveKeys) === - JSON.stringify(expectedKeys); + let secret; - report({ - message: `k8s ${name}: ${liveKeys.length} keys${ - structureOk - ? '' - : ` (expected ${ - expectedKeys.length - }: ${expectedKeys.join(', ')})` - }`, - ok: structureOk, - }); + try { + secret = k8sReadSecret({ + context: kenv.context, + namespace, + secret: name, + }); + } catch (e) { + report({ + message: `k8s ${name}: READ-ERROR ${e.message.slice( + 0, + 80 + )}`, + ok: false, + }); - if (!structureOk) { - return null; - } + return; + } + + const liveKeys = [...secret.keys].sort(); + const expectedKeys = expected + .map((resolved) => resolved.key) + .sort(); + + const structureOk = + JSON.stringify(liveKeys) === + JSON.stringify(expectedKeys); + + report({ + message: `k8s ${name}: ${liveKeys.length} keys${ + structureOk + ? '' + : ` (expected ${ + expectedKeys.length + }: ${expectedKeys.join(', ')})` + }`, + ok: structureOk, + }); + + if (!structureOk) { + return; + } + + expected.forEach((resolved) => { + const k8sSha = sha12({ + value: secret.values[resolved.key], + }); + const gsmSha = gsmShas[resolved.gsmId]; - return expected.reduce( - (keyPromise, resolved) => - keyPromise.then(() => { - const k8sValue = Buffer.from( - k8sReadKey({ - context: kenv.context, - key: resolved.key, - namespace, - secret: name, - }), - 'base64' - ); - - const k8sSha = sha12({ - value: k8sValue, - }); - const gsmSha = gsmShas[resolved.gsmId]; - - if (!gsmSha) { - return report({ - message: `k8s ${name}/${resolved.key} ${k8sSha} (no GSM hash to compare - the GSM secret was missing above)`, - ok: false, - }); - } - - return report({ - message: `k8s ${name}/${ - resolved.key - } ${k8sSha} gsm:${gsmSha}${ - k8sSha === gsmSha - ? '' - : ' (value drift, or the SecretSync has not caught up yet)' - }`, - ok: k8sSha === gsmSha, - }); - }), - Promise.resolve() - ); - } catch (e) { - return report({ - message: `k8s ${name}: READ-ERROR ${e.message.slice( - 0, - 80 - )}`, + if (!gsmSha) { + report({ + message: `k8s ${name}/${resolved.key} ${k8sSha} (no GSM hash to compare - the GSM secret was missing above)`, ok: false, }); + + return; } - }), - Promise.resolve() - ); - }) - .then(() => { + + report({ + message: `k8s ${name}/${ + resolved.key + } ${k8sSha} gsm:${gsmSha}${ + k8sSha === gsmSha + ? '' + : ' (value drift, or the SecretSync has not caught up yet)' + }`, + ok: k8sSha === gsmSha, + }); + }); + }); + } + // 3. Stale 1Password fields: fields present in known sections // (environment sections on service items, consumer sections on // the dev item) that the contract does not assign anywhere. + section({ + detail: 'Fields in known item sections (per environment on service items, per consumer on the dev item) that the contract assigns nowhere.', + title: '1Password stale fields', + }); + const expected = new Set(); - const note = (item, section, field) => - `${item}|${section}|${field}`; + const note = (item, sectionName, field) => + `${item}|${sectionName}|${field}`; entries.forEach((entry) => { const item = opItemFor(entry); @@ -370,47 +449,48 @@ const verifyEnv = ({ config, contract, env }) => { }); } - const scanned = [...itemsToScan].map((item) => - Promise.resolve() - .then(() => opItemFields({ item })) - .then((fields) => ({ fields, item })) - .catch(() => null) - ); + itemsToScan.forEach((item) => { + const data = opData[item]; - return Promise.all(scanned).then((results) => { - results.filter(Boolean).forEach(({ fields, item }) => { - const ownerName = itemOwners[item] || 'dev'; - const isDevItem = - contract.dev && contract.dev.opItem === item; - - fields.forEach(({ field, section }) => { - const knownSection = isDevItem - ? consumerSections.has(section) - : envs.includes(section); - - if ( - knownSection && - !expected.has(note(item, section, field)) - ) { - report({ - message: `op:EXTRA ${ownerName}/${section}/${field} (in 1Password but not the contract)`, - ok: false, - }); - } - }); + if (!data || data.error) { + return; + } + + const ownerName = itemOwners[item] || 'dev'; + const isDevItem = contract.dev && contract.dev.opItem === item; + + data.fields.forEach(({ field, section: fieldSection }) => { + const knownSection = isDevItem + ? consumerSections.has(fieldSection) + : envs.includes(fieldSection); + + if ( + knownSection && + !expected.has(note(item, fieldSection, field)) + ) { + report({ + message: `op:EXTRA ${ownerName}/${fieldSection}/${field} (in 1Password but not the contract)`, + ok: false, + }); + } }); }); - }) - .then(() => + // 4. Deployed-functions drift: every repo-prefixed secret id a // function references must be in the contract-derived set for // the env encoded in the id (function names don't carry the - // env - beta has an infix, production doesn't). - gcfList({ + // env - beta has an infix, production doesn't), and must + // reference a GSM secret that exists. + return gcfList({ project: contract.project, region: contract.region, repoPrefix, }).then((functions) => { + section({ + detail: 'Secret bindings of each deployed function must match ids the c.js contract computes and reference GSM secrets that exist. STALE = an old id the contract no longer computes; MISSING = the referenced GSM secret is gone.', + title: 'Deployed functions (secret references)', + }); + functions.forEach((fn) => { fn.secrets.forEach((id) => { if (!id.startsWith(repoPrefix)) { @@ -421,8 +501,23 @@ const verifyEnv = ({ config, contract, env }) => { id.startsWith(`${repoPrefix}${candidate}-`) ); - if (refEnv && derivedByEnv[refEnv].has(id)) { - lines.push(`OK function ${fn.name} -> ${id}`); + const inContract = + refEnv && derivedByEnv[refEnv].has(id); + + if (inContract) { + if (existingIds.has(id)) { + report({ + message: `function ${fn.name} -> ${id}`, + ok: true, + }); + + return; + } + + report({ + message: `MISSING function ${fn.name} -> ${id} (contract id, but the GSM secret does not exist)`, + ok: false, + }); return; } @@ -430,19 +525,22 @@ const verifyEnv = ({ config, contract, env }) => { report({ message: `STALE function ${fn.name} -> ${id}${ refEnv ? '' : ' (unknown env)' - } (not in contract)`, + } (not in contract${ + existingIds.has(id) + ? '' + : ', and the GSM secret no longer exists' + })`, ok: false, }); }); }); return { - lines, ok: problems.length === 0, problems, }; - }) - ); + }); + }); }; /** From dedf017afb981e611648349229dd2e942d08ce14 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:20:46 +1030 Subject: [PATCH 21/52] 6.2.0-beta.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3745bd0..e09cc5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.4", + "version": "6.2.0-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.2.0-beta.4", + "version": "6.2.0-beta.5", "license": "MIT", "dependencies": { "chalk": "4.1.2", diff --git a/package.json b/package.json index 112d3bf..423d718 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.4", + "version": "6.2.0-beta.5", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": { From 9c41b6aff9d22322b3c41eba0e12c3caf21acfc4 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:32:55 +1030 Subject: [PATCH 22/52] Skipped the c.js keys edit in c secrets add for borrowed keys. --- bin/c-secrets-add.js | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/bin/c-secrets-add.js b/bin/c-secrets-add.js index c9b645a..62b4fb3 100644 --- a/bin/c-secrets-add.js +++ b/bin/c-secrets-add.js @@ -81,6 +81,7 @@ return loadConfig() let id; let binding = { created: false, id: null, persisted: false }; + let borrowedKey = false; let isNewService = false; let item; let ownerName; @@ -101,6 +102,7 @@ return loadConfig() const borrowed = service.sharedKeys.find( (shared) => shared.key === key ); + borrowedKey = Boolean(borrowed); const known = service.keys.includes(key) || borrowed; ownerName = (borrowed || {}).owner @@ -176,11 +178,15 @@ return loadConfig() created ? 'created' : 'version added to' } ${id} op:${sourceSha} gsm:${readbackSha} VERIFIED`; - // Step 3: c.js - register, then re-load and re-validate. + // Step 3: c.js - register (a new service, or a new owned key; + // a borrowed key is already declared by its owner), then + // re-load and re-validate. try { if (isNewService) { registerServiceInCjs({ key, name, opItem: item }); - } else { + } + + if (!isNewService && !borrowedKey) { addKeyToServiceInCjs({ key, name }); } @@ -249,11 +255,18 @@ return loadConfig() binding.persisted ? `; item bound into c.js` : '' }`; - return print( - `${opSummary}; ${gsmSummary}; c.js ${ - isNewService ? 'service' : 'key' - } registered` - ); + let cjsSummary = 'c.js key registered'; + + if (isNewService) { + cjsSummary = 'c.js service registered'; + } + + if (borrowedKey) { + cjsSummary = + 'borrowed key - the owner already declares it in c.js'; + } + + return print(`${opSummary}; ${gsmSummary}; ${cjsSummary}`); }); }) .catch((e) => reportError(e, false, true)); From 51ffa16cccf2d91a6dd28fe8cef68ab039f4200e Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:43:05 +1030 Subject: [PATCH 23/52] Located the shared block in the c.js contract editors. --- bin/c-secrets-add.js | 4 +- bin/lib/c-secrets-io.js | 133 +++++++++++++++++++++++++++++----------- 2 files changed, 99 insertions(+), 38 deletions(-) diff --git a/bin/c-secrets-add.js b/bin/c-secrets-add.js index 62b4fb3..cfdeeb5 100644 --- a/bin/c-secrets-add.js +++ b/bin/c-secrets-add.js @@ -13,7 +13,7 @@ const { resolveIdentity, } = require('./lib/c-secrets'); const { - addKeyToServiceInCjs, + addKeyToEntryInCjs, assertGcloudAuth, assertOpSession, cjsPath, @@ -187,7 +187,7 @@ return loadConfig() } if (!isNewService && !borrowedKey) { - addKeyToServiceInCjs({ key, name }); + addKeyToEntryInCjs({ key, name }); } delete require.cache[require.resolve(cjsPath())]; diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index 7bf04ee..ce0fcf1 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -267,7 +267,10 @@ const generateValueForProgram = ({ program }) => { ); } - const length = program.L === undefined ? 32 : parseInt(program.L, 10); + // A bare -l parses to true; an absent one leaves L undefined, so the + // destructuring default supplies 32. + const { L = 32 } = program; + const length = parseInt(L, 10); if (!Number.isInteger(length) || length < 1) { throw new Error('--length must be a positive integer.'); @@ -970,6 +973,40 @@ const locateServiceBlock = ({ name, text }) => { return { end, indent, start }; }; +/** + * Locate the secrets.shared block in c.js text (scoped to secrets, a + * sibling of services - never a block elsewhere in c.js). + * @param {Object} options + * @param {String} options.text The c.js file contents. + * @returns {Object} { end, indent, start } with start at the newline + * beginning the block's line and end at the newline preceding its + * closing brace. + */ +const locateSharedBlock = ({ text }) => { + const secrets = locateSecretsBlock({ text }); + const sharedMatch = text + .slice(secrets.start, secrets.end) + .match(/\n(\s*)shared:\s*\{/); + + if (!sharedMatch) { + throw new Error( + 'Could not find secrets.shared in c.js - add the block manually.' + ); + } + + const [, indent] = sharedMatch; + const start = secrets.start + sharedMatch.index; + const end = text.indexOf(`\n${indent}}`, start); + + if (end === -1) { + throw new Error( + 'Could not find the end of secrets.shared in c.js - add the block manually.' + ); + } + + return { end, indent, start }; +}; + /** * The freshly parsed service entry, failing loudly when an edit did not * land in secrets.services. @@ -991,6 +1028,29 @@ const expectService = ({ config, name }) => { return service; }; +/** + * The freshly parsed service or shared entry ('shared' addresses the + * shared block, a sibling of services), failing loudly when an edit did + * not land in the secrets block. + * @param {Object} options + * @param {Object} options.config The freshly required c.js configuration. + * @param {String} options.name The service name, or 'shared'. + * @returns {Object} The parsed entry. + */ +const expectEntry = ({ config, name }) => { + if (name === 'shared') { + const shared = config.secrets && config.secrets.shared; + + if (!shared) { + throw new Error('secrets.shared is missing after the edit'); + } + + return shared; + } + + return expectService({ config, name }); +}; + /** * Re-parse c.js after an edit and verify the expectation against the * parsed result, restoring the original text when it fails: an edit that @@ -1114,27 +1174,28 @@ const registerServiceInCjs = ({ key, name, opItem }) => { }; /** - * Add a key to a service's keys array in c.js, preserving the array's - * existing layout (single-line or multi-line). The service block is - * located inside secrets.services, and the edit is re-parsed to verify - * it landed. + * Add a key to an entry's keys array in c.js (a service, or the shared + * block), preserving the array's existing layout (single-line or + * multi-line). The entry's block is located inside the secrets block, + * and the edit is re-parsed to verify it landed. * @param {Object} options * @param {String} options.key The key to add. - * @param {String} options.name The service name. + * @param {String} options.name The service name, or 'shared'. * @returns {Void} */ -const addKeyToServiceInCjs = ({ key, name }) => { +const addKeyToEntryInCjs = ({ key, name }) => { const text = readCjs(); - const service = locateServiceBlock({ name, text }); + const block = + name === 'shared' + ? locateSharedBlock({ text }) + : locateServiceBlock({ name, text }); - const serviceText = text.slice(service.start, service.end); + const blockText = text.slice(block.start, block.end); - const keysMatch = serviceText.match(/keys:\s*\[([^\]]*)\]/); + const keysMatch = blockText.match(/keys:\s*\[([^\]]*)\]/); if (!keysMatch) { - throw new Error( - `Could not find a keys array for service '${name}' in c.js.` - ); + throw new Error(`Could not find a keys array for '${name}' in c.js.`); } const entries = keysMatch[1] @@ -1154,25 +1215,22 @@ const addKeyToServiceInCjs = ({ key, name }) => { const rendered = multiline ? `keys: [\n${entries - .map((entry) => `${service.indent} '${entry}',`) - .join('\n')}\n${service.indent}],` + .map((entry) => `${block.indent} '${entry}',`) + .join('\n')}\n${block.indent}],` : `keys: [${entries.map((entry) => `'${entry}'`).join(', ')}]`; - const newServiceText = serviceText.replace(keysMatch[0], rendered); + const newBlockText = blockText.replace(keysMatch[0], rendered); writeCjs({ - text: - text.slice(0, service.start) + - newServiceText + - text.slice(service.end), + text: text.slice(0, block.start) + newBlockText + text.slice(block.end), }); verifyCjsEdit({ backup: text, expect: (config) => { - if (!expectService({ config, name }).keys.includes(key)) { + if (!expectEntry({ config, name }).keys.includes(key)) { throw new Error( - `secrets.services.${name}.keys does not include '${key}' after the edit` + `secrets entry '${name}' does not include '${key}' in keys after the edit` ); } }, @@ -1180,37 +1238,40 @@ const addKeyToServiceInCjs = ({ key, name }) => { }; /** - * Bind a service's opItem in c.js (inserting the property when the service - * block has none) - used to persist lazily resolved item ids. The service - * block is located inside secrets.services, and the edit is re-parsed to - * verify it landed. + * Bind an entry's opItem in c.js (inserting the property when the block + * has none) - used to persist lazily resolved item ids. The entry is a + * service or the shared block, and the edit is re-parsed to verify it + * landed. * @param {Object} options - * @param {String} options.name The service name. + * @param {String} options.name The service name, or 'shared'. * @param {String} options.opItem The 1Password item id. * @returns {Void} */ const setOpItemInCjs = ({ name, opItem }) => { const text = readCjs(); - const service = locateServiceBlock({ name, text }); + const block = + name === 'shared' + ? locateSharedBlock({ text }) + : locateServiceBlock({ name, text }); - const serviceText = text.slice(service.start, service.end); + const blockText = text.slice(block.start, block.end); - if (/opItem:\s*'/.test(serviceText)) { + if (/opItem:\s*'/.test(blockText)) { return; } writeCjs({ - text: `${text.slice(0, service.end)}\n${ - service.indent - } opItem: '${opItem}',${text.slice(service.end)}`, + text: `${text.slice(0, block.end)}\n${ + block.indent + } opItem: '${opItem}',${text.slice(block.end)}`, }); verifyCjsEdit({ backup: text, expect: (config) => { - if (expectService({ config, name }).opItem !== opItem) { + if (expectEntry({ config, name }).opItem !== opItem) { throw new Error( - `secrets.services.${name}.opItem does not carry the binding after the edit` + `secrets entry '${name}' does not carry the opItem binding after the edit` ); } }, @@ -1286,7 +1347,7 @@ const gsmPushVerified = ({ contract, env, id, owner, value }) => { module.exports = { OP_SESSION_FILE, - addKeyToServiceInCjs, + addKeyToEntryInCjs, assertGcloudAuth, assertKubectl, assertOpSession, From 9fa10678a38b55042f6b13b2f51388fbc5fe4011 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:54:43 +1030 Subject: [PATCH 24/52] Used the resolved 1Password item in c op get for consumers. --- bin/c-op-get.js | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/bin/c-op-get.js b/bin/c-op-get.js index 99a48d5..c138805 100644 --- a/bin/c-op-get.js +++ b/bin/c-op-get.js @@ -16,7 +16,6 @@ const { assertOpSession, guardPlaintext, opRead, - resolveOpItem, resolveOwnerItem, sha12, } = require('./lib/c-secrets-io'); @@ -72,19 +71,17 @@ return loadConfig() key, }); - // Unbound services resolve their item lazily, and reads never - // create; the shared block resolves too but is never auto-created. - const itemTitle = resolved.owner - ? `${contract.organisation}-${contract.name}/${resolved.owner.name}` - : `${contract.organisation}-${contract.name}/${name}`; - - const item = resolved.owner - ? resolveOwnerItem({ + // The resolved key usually carries its item already (a bound + // owner, or the dev item for consumer-owned keys); only unbound + // owners resolve lazily by title, and reads never create (the + // shared block is never auto-created either). + const item = resolved.opItem + ? resolved.opItem + : resolveOwnerItem({ create: false, owner: resolved.owner, - title: itemTitle, - }).id - : resolveOpItem({ create: false, title: itemTitle }).id; + title: `${contract.organisation}-${contract.name}/${resolved.owner.name}`, + }).id; const ref = resolved.opRef || From 380219d60faa123f8669158a3fa6939cdb67c8aa Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:02:33 +1030 Subject: [PATCH 25/52] Honoured the kubernetes namespace override in verify and consumers. --- bin/lib/c-secrets-verify.js | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/bin/lib/c-secrets-verify.js b/bin/lib/c-secrets-verify.js index 08aacb7..f0986ad 100644 --- a/bin/lib/c-secrets-verify.js +++ b/bin/lib/c-secrets-verify.js @@ -27,6 +27,20 @@ const { const kubernetesEnv = ({ config, env }) => getPropertyPath(config, `kubernetes.environments.${env}`) || null; +/** + * The namespace an environment's manifests deploy into: a configured + * kubernetes.environments..namespace override wins, otherwise the + * project prefix - the same resolution the kc commands use. + * @param {Object} options + * @param {Object} options.config The full c.js configuration. + * @param {Object} options.contract The normalised contract. + * @param {String} options.env The environment. + * @returns {String} The namespace. + */ +const kubernetesNamespace = ({ config, contract, env }) => + getPropertyPath(config, `kubernetes.environments.${env}.namespace`) || + formatProjectPrefix(contract.organisation, contract.name, env, true, true); + /** * The services the env's Kubernetes locations synchronise (those with a * secretsync location entry). @@ -269,7 +283,7 @@ const verifyEnv = ({ config, contract, env }) => { const value = data.values[`${env}/${resolved.key}`]; - if (value === undefined) { + if (typeof value === 'undefined') { report({ message: `${service.name}/${resolved.key} op:READ-ERROR field '${env}/${resolved.key}' not found in the '${resolved.owner.name}' item gsm:${gsmSha}`, ok: false, @@ -298,13 +312,11 @@ const verifyEnv = ({ config, contract, env }) => { title: 'Kubernetes Secrets (synced services)', }); - const namespace = formatProjectPrefix( - contract.organisation, - contract.name, + const namespace = kubernetesNamespace({ + config, + contract, env, - true, - true - ); + }); syncServiceNames({ config, env }).forEach((name) => { const service = contract.services[name]; @@ -603,13 +615,7 @@ const findConsumers = ({ config, contract, secretId }) => { }); const kenv = kubernetesEnv({ config, env }); - const namespace = formatProjectPrefix( - contract.organisation, - contract.name, - env, - true, - true - ); + const namespace = kubernetesNamespace({ config, contract, env }); // 2 + 3. Live consumers (k8s SecretProviderClasses, functions). if (kenv && kenv.context) { @@ -647,6 +653,7 @@ const findConsumers = ({ config, contract, secretId }) => { module.exports = { findConsumers, kubernetesEnv, + kubernetesNamespace, syncServiceNames, verifyEnv, }; From c6686ed7d7caf4e87c35f0216d1c6da5253e485d Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:14:40 +1030 Subject: [PATCH 26/52] Fixed the append-at-end newline when registering services in c.js. --- bin/lib/c-secrets-io.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index ce0fcf1..acbf87f 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -1136,8 +1136,11 @@ const registerServiceInCjs = ({ key, name, opItem }) => { const services = locateServicesBlock({ text }); - // Walk the top-level entries to find the alphabetical insertion point. - let insertIndex = services.end; + // Walk the top-level entries to find the alphabetical insertion + // point; the default (services.end + 1) appends at the start of the + // closing-brace line, so the join below lands on its own line either + // way. + let insertIndex = services.end + 1; const entryRegex = new RegExp( `^${services.indent} ('?[^':\\n]+'?):`, 'gm' @@ -1164,10 +1167,16 @@ const registerServiceInCjs = ({ key, name, opItem }) => { `${services.indent} },`, ].join('\n'); + let prefix = text.slice(0, insertIndex); + + // A previous entry written without a trailing comma needs one + // before an inserted sibling parses. + if (prefix.endsWith('}\n')) { + prefix = `${prefix.slice(0, -1)},\n`; + } + writeCjs({ - text: `${text.slice(0, insertIndex)}${fullBlock}\n${text.slice( - insertIndex - )}`, + text: `${prefix}${fullBlock}\n${text.slice(insertIndex)}`, }); return verify(); From bce296c37ca8aaaccdf838f2b6d3e602b232f25e Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:25:44 +1030 Subject: [PATCH 27/52] Rejected unknown keys in c op set for existing identities. --- bin/c-op-set.js | 69 ++++++++++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/bin/c-op-set.js b/bin/c-op-set.js index 891247f..3391441 100644 --- a/bin/c-op-set.js +++ b/bin/c-op-set.js @@ -29,7 +29,7 @@ program ) .option('-l [length]', 'Value length for --generate.') .description( - 'Upsert a secret value into 1Password. Pipe the value via stdin, or use --generate. New keys are added to the service item; unbound services resolve (or create) their item automatically and register into c.js.' + 'Upsert a secret value into 1Password for a key the contract knows (owned or borrowed). Pipe the value via stdin, or use --generate. Unbound services resolve (or create) their item automatically and register into c.js.' ) .parse(process.argv); @@ -95,32 +95,39 @@ return loadConfig() let ref; let section; - // Resolve the destination: an existing entry (the key's owner), a - // new key on an existing entry, or a brand new service. Unbound - // services resolve (or create) their item and persist the binding. + // Resolve the destination: the key's owner (an existing entry, + // owned or borrowed), or a brand new service. Unbound services + // resolve (or create) their item and persist the binding. try { const identity = resolveIdentity({ contract, env, name }); - const owner = + const entry = identity.kind === 'service' - ? identity.service.keys.includes(key) - ? identity.service - : ( - identity.service.sharedKeys.find( - (shared) => shared.key === key - ) || {} - ).owner - : null; + ? identity.service + : identity.consumer; - if (identity.kind === 'consumer') { - const borrowed = identity.consumer.sharedKeys.find( - (shared) => shared.key === key - ); + const borrowed = entry.sharedKeys.find( + (shared) => shared.key === key + ); + + const owned = entry.keys.includes(key); - const ownsKey = - identity.consumer.keys.includes(key) || !borrowed; + // The contract is the source of truth: a key no entry owns + // or borrows would write an orphan field (verify's + // op:EXTRA), so stop at the door with the remedy. + if (!owned && !borrowed) { + const remedy = + identity.kind === 'service' + ? `declare it with \`c secrets add ${name} ${env} ${key}\`` + : "add it to the consumer's keys in c.js first"; + + throw new Error( + `'${key}' is neither owned nor borrowed by '${name}' - fix the typo, or ${remedy}.` + ); + } - const binding = ownsKey + if (identity.kind === 'consumer') { + const binding = owned ? { created: false, id: contract.dev.opItem, @@ -131,22 +138,26 @@ return loadConfig() title: title(borrowed.owner.name), }); - if (binding.persisted) { + // Only borrowed keys resolve (or persist) an owner's + // item; owned keys already live in the dev item. + if (borrowed && binding.persisted) { setOpItemInCjs({ name: borrowed.owner.name, opItem: binding.id, }); - } - reportBinding({ - binding, - serviceName: borrowed.owner.name, - }); + reportBinding({ + binding, + serviceName: borrowed.owner.name, + }); + } item = binding.id; - section = ownsKey ? identity.consumer.name : 'local'; - } else { - const target = owner || identity.service; + section = owned ? identity.consumer.name : 'local'; + } + + if (identity.kind === 'service') { + const target = owned ? identity.service : borrowed.owner; const serviceName = target.name; const binding = resolveOwnerItem({ owner: target, From 5a465e8cb26c9688e7a21a014065f5400e28ec8a Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:40:34 +1030 Subject: [PATCH 28/52] Piped 1Password field values via stdin in opUpsertField. --- bin/lib/c-secrets-io.js | 49 +++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index acbf87f..d533013 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -147,8 +147,10 @@ const opFieldExists = ({ field, item, section }) => ); /** - * Upsert a field on a 1Password item. The value transits argv (the op cli - * demands it for assignments) - the same trust domain as typing the value. + * Upsert a field on a 1Password item by piping a JSON item template via + * stdin (`op item edit` reads the template from piped input): the value + * never transits argv, where it would be visible to other processes on + * the machine. * @param {Object} options * @param {String} options.field The field label. * @param {String} options.item The item id. @@ -157,14 +159,43 @@ const opFieldExists = ({ field, item, section }) => * @returns {Void} */ const opUpsertField = ({ field, item, section, value }) => { + const raw = JSON.parse( + runOp({ args: ['item', 'get', item, '--format=json'] }) + ); + + const fields = raw.fields || []; + + const sectionLabel = (candidate) => + candidate.section ? candidate.section.label : null; + + const target = fields.find( + (candidate) => + candidate.label === field && sectionLabel(candidate) === section + ); + + if (target) { + target.value = value; + } + + if (!target) { + // A new field mirrors a sibling of its section so sections stay + // internally consistent; concealed is the safe default for + // secrets. + const sibling = fields.find( + (candidate) => sectionLabel(candidate) === section + ); + + fields.push({ + fieldType: (sibling || {}).fieldType || 'CONCEALED', + label: field, + section: { label: section }, + value, + }); + } + runOp({ - args: [ - 'item', - 'edit', - item, - `${section}.${field}=${value}`, - '--format=json', - ], + args: ['item', 'edit', item, '--format=json'], + input: JSON.stringify(raw), }); }; From e7a2758ee699711c7c3f5f620179b8268a4fd97e Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:01:07 +1030 Subject: [PATCH 29/52] Split 1Password item resolution into read-only and resolve-or-create paths. --- bin/c-op-get.js | 1 - bin/c-op-set.js | 8 ++--- bin/c-secrets-add.js | 6 ++-- bin/lib/c-secrets-io.js | 70 ++++++++++++++++++++++++++++------------- 4 files changed, 56 insertions(+), 29 deletions(-) diff --git a/bin/c-op-get.js b/bin/c-op-get.js index c138805..2103ef1 100644 --- a/bin/c-op-get.js +++ b/bin/c-op-get.js @@ -78,7 +78,6 @@ return loadConfig() const item = resolved.opItem ? resolved.opItem : resolveOwnerItem({ - create: false, owner: resolved.owner, title: `${contract.organisation}-${contract.name}/${resolved.owner.name}`, }).id; diff --git a/bin/c-op-set.js b/bin/c-op-set.js index 3391441..cb9dc87 100644 --- a/bin/c-op-set.js +++ b/bin/c-op-set.js @@ -15,7 +15,7 @@ const { opFieldExists, opUpsertField, registerServiceInCjs, - resolveOwnerItem, + resolveOrCreateOwnerItem, setOpItemInCjs, sha12, } = require('./lib/c-secrets-io'); @@ -133,7 +133,7 @@ return loadConfig() id: contract.dev.opItem, persisted: false, } - : resolveOwnerItem({ + : resolveOrCreateOwnerItem({ owner: borrowed.owner, title: title(borrowed.owner.name), }); @@ -159,7 +159,7 @@ return loadConfig() if (identity.kind === 'service') { const target = owned ? identity.service : borrowed.owner; const serviceName = target.name; - const binding = resolveOwnerItem({ + const binding = resolveOrCreateOwnerItem({ owner: target, title: title(serviceName), }); @@ -194,7 +194,7 @@ return loadConfig() ); } - const binding = resolveOwnerItem({ + const binding = resolveOrCreateOwnerItem({ owner: null, title: title(name), }); diff --git a/bin/c-secrets-add.js b/bin/c-secrets-add.js index cfdeeb5..004d8e1 100644 --- a/bin/c-secrets-add.js +++ b/bin/c-secrets-add.js @@ -22,7 +22,7 @@ const { gsmPushVerified, opUpsertField, registerServiceInCjs, - resolveOwnerItem, + resolveOrCreateOwnerItem, setOpItemInCjs, } = require('./lib/c-secrets-io'); @@ -109,7 +109,7 @@ return loadConfig() ? borrowed.owner.name : service.name; - binding = resolveOwnerItem({ + binding = resolveOrCreateOwnerItem({ owner: borrowed ? borrowed.owner : service, title: `${contract.organisation}-${contract.name}/${ownerName}`, }); @@ -146,7 +146,7 @@ return loadConfig() ); } - binding = resolveOwnerItem({ + binding = resolveOrCreateOwnerItem({ owner: null, title: `${contract.organisation}-${contract.name}/${name}`, }); diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index d533013..e183f9f 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -823,9 +823,9 @@ const opFindItemByTitle = ({ title }) => { }; /** - * Resolve a service's 1Password item: a bound opItem wins; otherwise look - * the item up by title; otherwise create it (write paths only - read - * paths pass create false and get a guidance error instead). + * Resolve a 1Password item: a bound opItem wins; otherwise look the item + * up by title; when creation is not allowed, fail with a guidance error + * instead (the shared block, which never creates, gets its own message). * @param {Object} options * @param {Boolean} options.create Whether an item may be created. * @param {String} [options.noCreateMessage] A specific error message when @@ -857,27 +857,55 @@ const resolveOpItem = ({ create, noCreateMessage, opItem, title }) => { }; /** - * Resolve the target item for an owning entry: services resolve-or-create - * (write paths - read paths pass create false and resolve only); the - * shared block only ever resolves (where a shared value lives is the - * author's decision, never the tool's). + * Resolve an owning entry's 1Password item without ever creating one: a + * bound opItem wins; otherwise the item is looked up by title, failing + * with a guidance error when it does not exist. Every read path uses + * this - 1Password is never mutated. * @param {Object} options - * @param {Boolean} [options.create=true] Whether a service item may be - * created. The shared block never creates regardless. * @param {Object} options.owner The owning service or shared entry. - * @param {String} options.title The item title. + * @param {String} options.title The item title ({org}-{repo}/{service}). + * @returns {Object} { created, id, persisted } - persisted true when the + * binding should be written back into c.js. + */ +const resolveOwnerItem = ({ owner, title }) => { + if (owner && owner.name === 'shared') { + return resolveOpItem({ + create: false, + noCreateMessage: + "The 'shared' block stores its values in a 1Password item you choose - set shared.opItem in c.js (often the item of a service that uses the key).", + opItem: owner.opItem, + title, + }); + } + + return resolveOpItem({ + create: false, + opItem: owner && owner.opItem, + title, + }); +}; + +/** + * Resolve an owning entry's 1Password item on a write path: services + * resolve (or create) their item; the shared block only ever resolves + * (where a shared value lives is the author's decision, never the + * tool's). + * @param {Object} options + * @param {Object} options.owner The owning service or shared entry. + * @param {String} options.title The item title ({org}-{repo}/{service}). * @returns {Object} { created, id, persisted }. */ -const resolveOwnerItem = ({ create = true, owner, title }) => - owner && owner.name === 'shared' - ? resolveOpItem({ - create: false, - noCreateMessage: - "The 'shared' block stores its values in a 1Password item you choose - set shared.opItem in c.js (often the item of a service that uses the key).", - opItem: owner.opItem, - title, - }) - : resolveOpItem({ create, opItem: owner && owner.opItem, title }); +const resolveOrCreateOwnerItem = ({ owner, title }) => { + if (owner && owner.name === 'shared') { + return resolveOwnerItem({ owner, title }); + } + + return resolveOpItem({ + create: true, + opItem: owner && owner.opItem, + title, + }); +}; /** * The c.js file's path in the project directory. @@ -1418,7 +1446,7 @@ module.exports = { opUpsertField, quoteIfNeeded, registerServiceInCjs, - resolveOpItem, + resolveOrCreateOwnerItem, resolveOwnerItem, runKubectl, runOp, From 91b2765f26e568b917fd211689f82ad11a0d6e3f Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:40:09 +1030 Subject: [PATCH 30/52] Fixed the c.js contract editors with AST-located, insertion-only edits. --- .npmignore | 1 + bin/lib/c-secrets-io.js | 500 ++++++++++++++++++++++------------- package-lock.json | 91 +------ package.json | 3 +- test/cjs-editors.test.js | 554 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 882 insertions(+), 267 deletions(-) create mode 100644 test/cjs-editors.test.js diff --git a/.npmignore b/.npmignore index 2185696..e950ad0 100644 --- a/.npmignore +++ b/.npmignore @@ -1,2 +1,3 @@ .editorconfig .eslintrc +test diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index e183f9f..83a6c69 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -2,6 +2,7 @@ const { execFileSync } = require('child_process'); const { createHash, randomBytes } = require('crypto'); +const espree = require('espree'); const https = require('https'); const { readFileSync, writeFileSync } = require('fs'); const { join } = require('path'); @@ -937,133 +938,189 @@ const quoteIfNeeded = ({ name }) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : `'${name}'`; /** - * Locate the secrets block in c.js text: only content between these - * boundaries belongs to the contract, as c.js may contain - * identically-named properties elsewhere (docker locations etc). + * Parse c.js text into an AST, returning the module.exports object. + * Positions come from the parser, not regexes, so formatting (quoted + * names, comments, single vs multi-line layouts) cannot mislocate an + * edit. * @param {Object} options * @param {String} options.text The c.js file contents. - * @returns {Object} { end, indent, start } with start just after the - * opening brace and end at the newline preceding the closing brace. + * @returns {Object} The module.exports ObjectExpression node. */ -const locateSecretsBlock = ({ text }) => { - const secretsMatch = text.match(/\n(\s*)secrets:\s*\{/); +const parseCjs = ({ text }) => { + const ast = espree.parse(text, { ecmaVersion: 2020, range: true }); + + const assignment = ast.body.find( + (statement) => + statement.type === 'ExpressionStatement' && + statement.expression.type === 'AssignmentExpression' && + statement.expression.left.type === 'MemberExpression' && + statement.expression.left.object.name === 'module' && + statement.expression.left.property.name === 'exports' && + statement.expression.right.type === 'ObjectExpression' + ); - if (!secretsMatch) { + if (!assignment) { throw new Error( - 'Could not find secrets in c.js - add the contract manually.' + 'Could not find a module.exports object in c.js - add the contract manually.' ); } - const [, indent] = secretsMatch; - const start = secretsMatch.index + secretsMatch[0].length; - const end = text.indexOf(`\n${indent}}`, start); + return assignment.expression.right; +}; - if (end === -1) { - throw new Error( - 'Could not find the end of secrets in c.js - add the contract manually.' - ); - } +/** + * Find a property of an ObjectExpression by name (quoted or unquoted). + * @param {Object} options + * @param {String} options.name The property name. + * @param {Object} options.object The ObjectExpression node. + * @returns {Object|undefined} The Property node, when present. + */ +const findProperty = ({ name, object }) => + object.properties.find( + (property) => + property.type === 'Property' && + (property.key.name === name || property.key.value === name) + ); - return { end, indent, start }; -}; +/** + * The index of the start of the line containing the given index. + * @param {Object} options + * @param {Number} options.index A position within the text. + * @param {String} options.text The full text. + * @returns {Number} The line's start index. + */ +const lineStartIndex = ({ index, text }) => text.lastIndexOf('\n', index) + 1; /** - * Locate the secrets.services block in c.js text (scoped to secrets). + * The whitespace between the start of the line and the given index (the + * indentation of a node that starts its own line). * @param {Object} options - * @param {String} options.text The c.js file contents. - * @returns {Object} { end, indent, start } with start just after the - * opening brace and end at the newline preceding the closing brace. + * @param {Number} options.index A position within the text. + * @param {String} options.text The full text. + * @returns {String} The leading whitespace. */ -const locateServicesBlock = ({ text }) => { - const secrets = locateSecretsBlock({ text }); - const servicesMatch = text - .slice(secrets.start, secrets.end) - .match(/\n(\s*)services:\s*\{/); +const indentBefore = ({ index, text }) => + text.slice(lineStartIndex({ index, text }), index); - if (!servicesMatch) { - throw new Error( - 'Could not find secrets.services in c.js - add the service manually.' - ); - } +/** + * The file's dominant line ending, so insertions never mix endings. + * @param {Object} options + * @param {String} options.text The full text. + * @returns {String} '\r\n' or '\n'. + */ +const lineEndingOf = ({ text }) => (text.includes('\r\n') ? '\r\n' : '\n'); + +/** + * The secrets contract's ObjectExpression within c.js: only properties of + * this node belong to the contract, as c.js may contain identically-named + * properties elsewhere (docker locations etc). + * @param {Object} options + * @param {String} options.text The c.js file contents. + * @returns {Object} The secrets ObjectExpression node. + */ +const secretsObject = ({ text }) => { + const root = parseCjs({ text }); - const [, indent] = servicesMatch; - const start = secrets.start + servicesMatch.index + servicesMatch[0].length; - const end = text.indexOf(`\n${indent}}`, start); + const secrets = findProperty({ name: 'secrets', object: root }); - if (end === -1) { + if (!secrets || secrets.value.type !== 'ObjectExpression') { throw new Error( - 'Could not find the end of secrets.services in c.js - add the service manually.' + 'Could not find secrets in c.js - add the contract manually.' ); } - return { end, indent, start }; + return secrets.value; }; /** - * Locate one service's block inside secrets.services: the first - * name-matching block found there wins, never a block elsewhere in c.js. + * A service entry's Property node inside secrets.services, or the shared + * block's, failing with actionable messages when absent. * @param {Object} options - * @param {String} options.name The service name. - * @param {String} options.text The c.js file contents. - * @returns {Object} { end, indent, start } with start at the newline - * beginning the service's line and end at the newline preceding its - * closing brace. + * @param {String} options.name The service name, or 'shared'. + * @param {Object} options.secrets The secrets ObjectExpression node. + * @returns {Object} The entry's Property node. */ -const locateServiceBlock = ({ name, text }) => { - const services = locateServicesBlock({ text }); - const serviceMatch = text - .slice(services.start, services.end) - .match(new RegExp(`\\n(\\s*)${quoteIfNeeded({ name })}:\\s*\\{`)); +const entryProperty = ({ name, secrets }) => { + if (name === 'shared') { + const shared = findProperty({ name: 'shared', object: secrets }); - if (!serviceMatch) { + if (!shared) { + throw new Error( + 'Could not find secrets.shared in c.js - add the block manually.' + ); + } + + return shared; + } + + const services = findProperty({ name: 'services', object: secrets }); + + if (!services || services.value.type !== 'ObjectExpression') { throw new Error( - `Could not find service '${name}' in secrets.services in c.js.` + 'Could not find secrets.services in c.js - add the service manually.' ); } - const [, indent] = serviceMatch; - const start = services.start + serviceMatch.index; - const end = text.indexOf(`\n${indent}}`, start); + const service = findProperty({ name, object: services.value }); - if (end === -1) { - throw new Error(`Could not find the end of service '${name}' in c.js.`); + if (!service) { + throw new Error( + `Could not find service '${name}' in secrets.services in c.js.` + ); } - return { end, indent, start }; + return service; }; /** - * Locate the secrets.shared block in c.js text (scoped to secrets, a - * sibling of services - never a block elsewhere in c.js). + * Insert a rendered property (or multi-line block) into an object, right + * before its closing brace, matching the object's own layout: a closing + * brace on its own line gets the property on its own line at the + * properties' indentation (with the file's line ending), a one-line + * object stays one-line. A final property written without a trailing + * comma gets one so the sibling parses. * @param {Object} options + * @param {String} options.line The rendered property (may be multi-line). + * @param {Object} options.object The ObjectExpression node to insert into. * @param {String} options.text The c.js file contents. - * @returns {Object} { end, indent, start } with start at the newline - * beginning the block's line and end at the newline preceding its - * closing brace. + * @returns {String} The edited text (not yet written). */ -const locateSharedBlock = ({ text }) => { - const secrets = locateSecretsBlock({ text }); - const sharedMatch = text - .slice(secrets.start, secrets.end) - .match(/\n(\s*)shared:\s*\{/); - - if (!sharedMatch) { - throw new Error( - 'Could not find secrets.shared in c.js - add the block manually.' - ); - } - - const [, indent] = sharedMatch; - const start = secrets.start + sharedMatch.index; - const end = text.indexOf(`\n${indent}}`, start); - - if (end === -1) { - throw new Error( - 'Could not find the end of secrets.shared in c.js - add the block manually.' - ); +const insertPropertyIntoObject = ({ line, object: obj, text }) => { + const closeIndex = obj.range[1] - 1; + const closingIndent = indentBefore({ index: closeIndex, text }); + const braceOnOwnLine = + text.slice(obj.range[0], closeIndex).includes('\n') && + /^\s*$/.test(closingIndent); + + if (!braceOnOwnLine) { + const separator = obj.properties.length > 0 ? ', ' : ' '; + + return `${text.slice(0, closeIndex)}${separator}${line} ${text.slice( + closeIndex + )}`; } - return { end, indent, start }; + const lineEnding = lineEndingOf({ text }); + const [firstProperty] = obj.properties; + const propertyIndent = firstProperty + ? indentBefore({ index: firstProperty.range[0], text }) + : `${indentBefore({ index: obj.range[0], text })} `; + + const lastProperty = obj.properties[obj.properties.length - 1]; + const commaFix = text.slice(lastProperty.range[1], closeIndex).includes(',') + ? '' + : ','; + + return ( + text.slice(0, lastProperty.range[1]) + + commaFix + + text.slice( + lastProperty.range[1], + lineStartIndex({ index: closeIndex, text }) + ) + + `${propertyIndent}${line}${lineEnding}${closingIndent}` + + text.slice(closeIndex) + ); }; /** @@ -1148,7 +1205,16 @@ const verifyCjsEdit = ({ backup, expect }) => { */ const registerServiceInCjs = ({ key, name, opItem }) => { const text = readCjs(); - const secrets = locateSecretsBlock({ text }); + const secrets = secretsObject({ text }); + const services = findProperty({ name: 'services', object: secrets }); + + if (!services || services.value.type !== 'ObjectExpression') { + throw new Error( + 'Could not find secrets.services in c.js - add the service manually.' + ); + } + + const servicesObjectNode = services.value; const verify = () => verifyCjsEdit({ @@ -1164,78 +1230,60 @@ const registerServiceInCjs = ({ key, name, opItem }) => { }, }); - // The minimal scaffold's single-line empty map: expand it with the - // first entry. - const emptyMatch = text - .slice(secrets.start, secrets.end) - .match(/\n(\s*)services:\s*\{\}/); - - if (emptyMatch) { - const [, emptyIndent] = emptyMatch; - const emptyStart = secrets.start + emptyMatch.index; - - const expanded = [ - `${emptyIndent}services: {`, - `${emptyIndent} ${quoteIfNeeded({ name })}: {`, - `${emptyIndent} keys: ['${key}'],`, - `${emptyIndent} opItem: '${opItem}',`, - `${emptyIndent} },`, - `${emptyIndent}}`, - ].join('\n'); + const lineEnding = lineEndingOf({ text }); + const servicesIndent = indentBefore({ index: services.range[0], text }); + const propertyIndent = `${servicesIndent} `; + + const block = [ + `${propertyIndent}${quoteIfNeeded({ name })}: {`, + `${propertyIndent} keys: ['${key}'],`, + `${propertyIndent} opItem: '${opItem}',`, + `${propertyIndent}},`, + ].join(lineEnding); + + // The minimal scaffold's single-line empty map: expand it. + if (servicesObjectNode.properties.length === 0) { + const expanded = [`{`, `${block}`, `${servicesIndent}}`].join( + lineEnding + ); writeCjs({ text: - text.slice(0, emptyStart) + - `\n${expanded}` + - text.slice(emptyStart + emptyMatch[0].length), + text.slice(0, servicesObjectNode.range[0]) + + expanded + + text.slice(servicesObjectNode.range[1]), }); return verify(); } - const services = locateServicesBlock({ text }); - - // Walk the top-level entries to find the alphabetical insertion - // point; the default (services.end + 1) appends at the start of the - // closing-brace line, so the join below lands on its own line either - // way. - let insertIndex = services.end + 1; - const entryRegex = new RegExp( - `^${services.indent} ('?[^':\\n]+'?):`, - 'gm' + // Insert before the first service sorting after the new one. + const target = servicesObjectNode.properties.find( + (property) => + property.type === 'Property' && + (property.key.name || property.key.value) > name ); - entryRegex.lastIndex = services.start; - let match = entryRegex.exec(text); - - while (match && match.index < services.end) { - const entryName = match[1].replace(/^'|'$/g, ''); - - if (entryName > name) { - insertIndex = match.index; - break; - } - - match = entryRegex.exec(text); - } - - const fullBlock = [ - `${services.indent} ${quoteIfNeeded({ name })}: {`, - `${services.indent} keys: ['${key}'],`, - `${services.indent} opItem: '${opItem}',`, - `${services.indent} },`, - ].join('\n'); + if (target) { + const insertIndex = lineStartIndex({ index: target.range[0], text }); - let prefix = text.slice(0, insertIndex); + writeCjs({ + text: + text.slice(0, insertIndex) + + `${block}${lineEnding}` + + text.slice(insertIndex), + }); - // A previous entry written without a trailing comma needs one - // before an inserted sibling parses. - if (prefix.endsWith('}\n')) { - prefix = `${prefix.slice(0, -1)},\n`; + return verify(); } + // Alphabetically last: append before the services closing brace. writeCjs({ - text: `${prefix}${fullBlock}\n${text.slice(insertIndex)}`, + text: insertPropertyIntoObject({ + line: block.trim(), + object: servicesObjectNode, + text, + }), }); return verify(); @@ -1243,9 +1291,10 @@ const registerServiceInCjs = ({ key, name, opItem }) => { /** * Add a key to an entry's keys array in c.js (a service, or the shared - * block), preserving the array's existing layout (single-line or - * multi-line). The entry's block is located inside the secrets block, - * and the edit is re-parsed to verify it landed. + * block) with insertion-only edits: the new element is spliced in at its + * sorted position between existing elements, so the array's own layout, + * comments and trailing-comma style all survive. An entry without a keys + * array gets one. The edit is re-parsed to verify it landed. * @param {Object} options * @param {String} options.key The key to add. * @param {String} options.name The service name, or 'shared'. @@ -1253,56 +1302,125 @@ const registerServiceInCjs = ({ key, name, opItem }) => { */ const addKeyToEntryInCjs = ({ key, name }) => { const text = readCjs(); - const block = - name === 'shared' - ? locateSharedBlock({ text }) - : locateServiceBlock({ name, text }); + const secrets = secretsObject({ text }); + const entry = entryProperty({ name, secrets }); + + if (entry.value.type !== 'ObjectExpression') { + throw new Error( + `Could not find a keys array for '${name}' in c.js - add '${key}' manually.` + ); + } + + const keysProperty = findProperty({ name: 'keys', object: entry.value }); - const blockText = text.slice(block.start, block.end); + const verify = () => + verifyCjsEdit({ + backup: text, + expect: (config) => { + if (!expectEntry({ config, name }).keys.includes(key)) { + throw new Error( + `secrets entry '${name}' does not include '${key}' in keys after the edit` + ); + } + }, + }); - const keysMatch = blockText.match(/keys:\s*\[([^\]]*)\]/); + // No keys array yet: create one in the entry's own layout. + if (!keysProperty) { + writeCjs({ + text: insertPropertyIntoObject({ + line: `keys: ['${key}'],`, + object: entry.value, + text, + }), + }); - if (!keysMatch) { - throw new Error(`Could not find a keys array for '${name}' in c.js.`); + return verify(); } - const entries = keysMatch[1] - .split(',') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .map((entry) => entry.replace(/^'|'$/g, '')); + const array = keysProperty.value; + + const editable = + array.type === 'ArrayExpression' && + array.elements.every( + (element) => + element && + element.type === 'Literal' && + typeof element.value === 'string' + ); + + if (!editable) { + throw new Error( + `Could not find a plain keys array for '${name}' in c.js - add '${key}' manually.` + ); + } - if (entries.includes(key)) { + if (array.elements.some((element) => element.value === key)) { return; } - entries.push(key); - entries.sort(); + // An empty array renders its first element, keeping the bracket + // layout it was written with. + if (array.elements.length === 0) { + const propertyIndent = indentBefore({ + index: keysProperty.range[0], + text, + }); + const multiline = text + .slice(array.range[0], array.range[1]) + .includes('\n'); + + const rendered = multiline + ? `[\n${propertyIndent} '${key}',\n${propertyIndent}]` + : `['${key}']`; + + writeCjs({ + text: + text.slice(0, array.range[0]) + + rendered + + text.slice(array.range[1]), + }); + + return verify(); + } + + const lineEnding = lineEndingOf({ text }); - const multiline = keysMatch[0].includes('\n'); + // Insert before the first element sorting after the key, matching + // the existing element layout. + const target = array.elements.find((element) => element.value > key); + + if (target) { + const targetIndent = indentBefore({ index: target.range[0], text }); + const onOwnLine = /^\s*$/.test(targetIndent); + const separator = onOwnLine ? `${lineEnding}${targetIndent}` : ' '; + + writeCjs({ + text: + text.slice(0, target.range[0]) + + `'${key}',${separator}` + + text.slice(target.range[0]), + }); - const rendered = multiline - ? `keys: [\n${entries - .map((entry) => `${block.indent} '${entry}',`) - .join('\n')}\n${block.indent}],` - : `keys: [${entries.map((entry) => `'${entry}'`).join(', ')}]`; + return verify(); + } - const newBlockText = blockText.replace(keysMatch[0], rendered); + // Alphabetically last: attach after the last element, which hands + // the new element the old last element's trailing separator. + const last = array.elements[array.elements.length - 1]; + const gap = text.slice(last.range[1], array.range[1] - 1); + const separator = gap.includes('\n') + ? `${lineEnding}${indentBefore({ index: last.range[0], text })}` + : ' '; writeCjs({ - text: text.slice(0, block.start) + newBlockText + text.slice(block.end), + text: + text.slice(0, last.range[1]) + + `,${separator}'${key}'` + + text.slice(last.range[1]), }); - verifyCjsEdit({ - backup: text, - expect: (config) => { - if (!expectEntry({ config, name }).keys.includes(key)) { - throw new Error( - `secrets entry '${name}' does not include '${key}' in keys after the edit` - ); - } - }, - }); + return verify(); }; /** @@ -1317,21 +1435,25 @@ const addKeyToEntryInCjs = ({ key, name }) => { */ const setOpItemInCjs = ({ name, opItem }) => { const text = readCjs(); - const block = - name === 'shared' - ? locateSharedBlock({ text }) - : locateServiceBlock({ name, text }); + const secrets = secretsObject({ text }); + const entry = entryProperty({ name, secrets }); - const blockText = text.slice(block.start, block.end); + if (entry.value.type !== 'ObjectExpression') { + throw new Error( + `Could not find the entry '${name}' in c.js - set its opItem manually.` + ); + } - if (/opItem:\s*'/.test(blockText)) { + if (findProperty({ name: 'opItem', object: entry.value })) { return; } writeCjs({ - text: `${text.slice(0, block.end)}\n${ - block.indent - } opItem: '${opItem}',${text.slice(block.end)}`, + text: insertPropertyIntoObject({ + line: `opItem: '${opItem}',`, + object: entry.value, + text, + }), }); verifyCjsEdit({ diff --git a/package-lock.json b/package-lock.json index e09cc5a..313aa70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "commander-latest": "npm:commander@v7.2.0", "debug": "3.1.0", "dotenv": "4.0.0", + "espree": "7.3.1", "execa": "6.1.0", "fs-extra": "6.0.0", "get-value": "3.0.1", @@ -498,9 +499,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -519,9 +517,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -540,9 +535,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -561,9 +553,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -582,9 +571,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -603,9 +589,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -624,9 +607,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -645,9 +625,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -666,9 +643,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -693,9 +667,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -720,9 +691,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -747,9 +715,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -774,9 +739,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -801,9 +763,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -828,9 +787,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -855,9 +811,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1017,9 +970,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1038,9 +988,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1059,9 +1006,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1080,9 +1024,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1231,14 +1172,14 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.14.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", - "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~8.9.0" } }, "node_modules/@typescript-eslint/parser": { @@ -1411,7 +1352,6 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1424,7 +1364,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -2758,7 +2697,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=4" @@ -2875,7 +2813,6 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^7.4.0", @@ -4264,9 +4201,9 @@ "license": "ISC" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -5467,9 +5404,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "peer": true, @@ -5918,9 +5855,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT", "optional": true diff --git a/package.json b/package.json index 423d718..bce0810 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "minor-release": "npm version minor && npm publish --access=public && git push --follow-tags", "patch-release": "npm version patch && npm publish --access=public && git push --follow-tags", "prerelease-release": "npm version prerelease && npm publish --access=public && git push --follow-tags", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node --test", "prepare": "husky install" }, "repository": { @@ -33,6 +33,7 @@ "commander-latest": "npm:commander@v7.2.0", "debug": "3.1.0", "dotenv": "4.0.0", + "espree": "7.3.1", "execa": "6.1.0", "fs-extra": "6.0.0", "get-value": "3.0.1", diff --git a/test/cjs-editors.test.js b/test/cjs-editors.test.js new file mode 100644 index 0000000..86a1ceb --- /dev/null +++ b/test/cjs-editors.test.js @@ -0,0 +1,554 @@ +'use strict'; + +// Behavior tests for the c.js contract editors (addKeyToEntryInCjs, +// setOpItemInCjs, registerServiceInCjs) over adversarial c.js fixtures. +// +// Every fixture is a valid c.js module; every edit must leave a file that +// still parses, still validates as a contract, preserves the user's +// comments, array style and line endings, and is idempotent. +// +// Run with: npm test + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const prettier = require('prettier'); + +const io = require('../bin/lib/c-secrets-io.js'); +const { getSecretsContract } = require('../bin/lib/c-secrets.js'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const PRETTIER_OPTIONS = Object.assign( + { endOfLine: 'lf', parser: 'babel' }, + JSON.parse( + fs.readFileSync(path.join(REPO_ROOT, '.prettierrc.json'), 'utf8') + ) +); + +const isPrettierClean = (text) => + prettier.format(text, PRETTIER_OPTIONS) === text; + +const isCrlf = (text) => { + const lines = text.split('\n'); + + return lines.slice(0, -1).every((line) => line.endsWith('\r')); +}; + +// The c.js preamble every fixture shares (a docker decoy site, a valid +// project and gcloud block, and a secrets contract). +const preamble = () => `module.exports = { + project: { + gcpProjectId: 'demo-proj', + name: 'demo', + organisation: 'idearium', + }, + gcloud: { + projectId: 'demo-proj', + region: 'australia-southeast1', + }, + docker: { + locations: { + site: { buildArgs: { TOKEN: 'nope' } }, + }, + }, + secrets: { +`; + +const closing = () => ` }, +}; +`; + +const loadCjs = () => { + const file = path.resolve(process.cwd(), 'c.js'); + + delete require.cache[require.resolve(file)]; + + return require(file); +}; + +const readCjs = () => + fs.readFileSync(path.resolve(process.cwd(), 'c.js'), 'utf8'); + +const writeCjs = (text) => + fs.writeFileSync(path.resolve(process.cwd(), 'c.js'), text); + +// Run a test body inside a temp directory holding the fixture c.js. +const withFixture = (name, cjsText, body) => + test(name, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cjs-editors-')); + const previousCwd = process.cwd(); + + process.chdir(dir); + writeCjs(cjsText); + + try { + body(); + } finally { + process.chdir(previousCwd); + fs.rmSync(dir, { force: true, recursive: true }); + } + }); + +// The key-array styles addKeyToEntryInCjs must survive. Each style pins +// the exact block the keys property must render as after adding one key, +// so formatting (indentation, separators, trailing commas) is asserted, +// not just parseability. +const KEY_STYLES = [ + { + // The style `c secrets init` drafts (and the style that shipped + // broken): multi-line with trailing commas. + expectedSharedBlock: ` keys: [ + 'API_TOKEN', + 'IMGIX_TOKEN', + ],`, + expectedSiteBlock: ` keys: [ + 'CACHE_URL', + 'RAS_API_KEY', + ],`, + existingSharedKeys: ['IMGIX_TOKEN'], + existingSiteKeys: ['CACHE_URL'], + label: 'multi-line keys with trailing commas (init style)', + shared: ` shared: { + keys: [ + 'IMGIX_TOKEN', + ], + }, +`, + site: ` site: { + opItem: 'aaaa', + keys: [ + 'CACHE_URL', + ], + }, +`, + }, + { + expectedSharedBlock: ` keys: [ + 'API_TOKEN', + 'IMGIX_TOKEN' + ]`, + expectedSiteBlock: ` keys: [ + 'CACHE_URL', + 'RAS_API_KEY' + ]`, + existingSharedKeys: ['IMGIX_TOKEN'], + existingSiteKeys: ['CACHE_URL'], + label: 'multi-line keys without trailing commas', + shared: ` shared: { + keys: [ + 'IMGIX_TOKEN' + ] + }, +`, + site: ` site: { + opItem: 'aaaa', + keys: [ + 'CACHE_URL' + ] + }, +`, + }, + { + canonical: true, + expectedSharedBlock: ` keys: ['API_TOKEN', 'IMGIX_TOKEN'],`, + expectedSiteBlock: ` keys: ['CACHE_URL', 'RAS_API_KEY'],`, + existingSharedKeys: ['IMGIX_TOKEN'], + existingSiteKeys: ['CACHE_URL'], + label: 'single-line keys', + shared: ` shared: { + keys: ['IMGIX_TOKEN'], + }, +`, + site: ` site: { + opItem: 'aaaa', + keys: ['CACHE_URL'], + }, +`, + }, + { + canonical: true, + expectedSharedBlock: ` keys: ['API_TOKEN'],`, + expectedSiteBlock: ` keys: ['RAS_API_KEY'],`, + existingSharedKeys: [], + existingSiteKeys: [], + label: 'empty single-line keys', + shared: ` shared: { + keys: [], + }, +`, + site: ` site: { + opItem: 'aaaa', + keys: [], + }, +`, + }, + { + expectedSharedBlock: ` keys: [ + 'API_TOKEN', + ],`, + expectedSiteBlock: ` keys: [ + 'RAS_API_KEY', + ],`, + existingSharedKeys: [], + existingSiteKeys: [], + label: 'empty multi-line keys', + shared: ` shared: { + keys: [ + ], + }, +`, + site: ` site: { + opItem: 'aaaa', + keys: [ + ], + }, +`, + }, +]; + +const fixtureFor = (style) => + `${preamble()} services: { +${style.site} }, +${style.shared}${closing()}`; + +KEY_STYLES.forEach((style) => { + const fixture = fixtureFor(style); + + withFixture( + `addKeyToEntryInCjs adds and sorts a service key: ${style.label}`, + fixture, + () => { + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + const config = loadCjs(); + + assert.deepEqual( + config.secrets.services.site.keys, + style.existingSiteKeys.concat('RAS_API_KEY') + ); + assert.equal(config.docker.locations.site.buildArgs.TOKEN, 'nope'); + + const text = readCjs(); + + assert.ok( + text.includes(style.expectedSiteBlock), + 'the keys block must render in the entry\u2019s own style' + ); + + if (style.canonical) { + assert.equal(isPrettierClean(text), true); + } + + // Idempotent: a second identical run changes nothing. + const afterFirst = readCjs(); + + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + assert.equal(readCjs(), afterFirst); + } + ); + + withFixture( + `addKeyToEntryInCjs adds a shared key: ${style.label}`, + fixture, + () => { + io.addKeyToEntryInCjs({ key: 'API_TOKEN', name: 'shared' }); + + const config = loadCjs(); + + assert.deepEqual( + config.secrets.shared.keys, + style.existingSharedKeys.concat('API_TOKEN').sort() + ); + + assert.ok( + readCjs().includes(style.expectedSharedBlock), + 'the shared keys block must render in the entry\u2019s own style' + ); + } + ); +}); + +withFixture( + 'addKeyToEntryInCjs preserves comments inside the contract', + `${preamble()} services: { + site: { + // the site's owned keys + keys: [ + // cache connection details + 'CACHE_URL', + ], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + const config = loadCjs(); + const text = readCjs(); + + assert.deepEqual(config.secrets.services.site.keys, [ + 'CACHE_URL', + 'RAS_API_KEY', + ]); + assert.match(text, /\/\/ the site's owned keys/); + assert.match(text, /\/\/ cache connection details/); + } +); + +withFixture( + 'addKeyToEntryInCjs creates a missing keys array', + `${preamble()} services: { + 'prismic-published-workflow': { + sharedKeys: ['IMGIX_TOKEN'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.addKeyToEntryInCjs({ + key: 'BASIC_AUTH', + name: 'prismic-published-workflow', + }); + + const config = loadCjs(); + + assert.deepEqual( + config.secrets.services['prismic-published-workflow'].keys, + ['BASIC_AUTH'] + ); + assert.deepEqual( + config.secrets.services['prismic-published-workflow'].sharedKeys, + ['IMGIX_TOKEN'] + ); + assert.equal(isPrettierClean(readCjs()), true); + } +); + +withFixture( + 'addKeyToEntryInCjs preserves CRLF line endings', + fixtureFor(KEY_STYLES[2]).replace(/\n/g, '\r\n'), + () => { + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + const config = loadCjs(); + + assert.deepEqual(config.secrets.services.site.keys, [ + 'CACHE_URL', + 'RAS_API_KEY', + ]); + assert.equal(isCrlf(readCjs()), true, 'no mixed line endings'); + } +); + +withFixture( + 'addKeyToEntryInCjs result still validates as a contract', + fixtureFor(KEY_STYLES[0]), + () => { + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + const contract = getSecretsContract({ config: loadCjs() }); + const { site } = contract.services; + + assert.equal(site.name, 'site'); + assert.deepEqual(site.keys, ['CACHE_URL', 'RAS_API_KEY']); + assert.equal(site.opItem, 'aaaa'); + } +); + +withFixture( + 'setOpItemInCjs binds an absent opItem', + `${preamble()} services: { + site: { + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.setOpItemInCjs({ name: 'site', opItem: 'bbbb' }); + + assert.equal(loadCjs().secrets.services.site.opItem, 'bbbb'); + assert.equal(isPrettierClean(readCjs()), true); + } +); + +withFixture( + 'setOpItemInCjs binds an absent shared opItem', + `${preamble()} services: { + site: { + opItem: 'aaaa', + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.setOpItemInCjs({ name: 'shared', opItem: 'services.site' }); + + const config = loadCjs(); + + assert.equal(config.secrets.shared.opItem, 'services.site'); + assert.equal(config.secrets.services.site.opItem, 'aaaa'); + } +); + +withFixture( + 'setOpItemInCjs is a no-op when already bound', + `${preamble()} services: { + site: { + opItem: 'aaaa', + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + const before = readCjs(); + + io.setOpItemInCjs({ name: 'site', opItem: 'zzzz' }); + + assert.equal(readCjs(), before); + assert.equal(loadCjs().secrets.services.site.opItem, 'aaaa'); + } +); + +withFixture( + 'setOpItemInCjs preserves CRLF line endings', + `${preamble()} services: { + site: { + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`.replace(/\n/g, '\r\n'), + () => { + io.setOpItemInCjs({ name: 'site', opItem: 'bbbb' }); + + assert.equal(loadCjs().secrets.services.site.opItem, 'bbbb'); + assert.equal(isCrlf(readCjs()), true, 'no mixed line endings'); + } +); + +withFixture( + 'registerServiceInCjs inserts alphabetically before existing services', + `${preamble()} services: { + site: { + opItem: 'aaaa', + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.registerServiceInCjs({ key: 'TOKEN', name: 'api', opItem: 'cccc' }); + + const config = loadCjs(); + + assert.deepEqual(Object.keys(config.secrets.services), ['api', 'site']); + assert.deepEqual(config.secrets.services.api.keys, ['TOKEN']); + assert.equal(config.secrets.services.api.opItem, 'cccc'); + assert.equal(isPrettierClean(readCjs()), true); + } +); + +withFixture( + 'registerServiceInCjs appends alphabetically last services', + `${preamble()} services: { + site: { + opItem: 'aaaa', + keys: ['CACHE_URL'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.registerServiceInCjs({ key: 'TOKEN', name: 'www', opItem: 'dddd' }); + + const config = loadCjs(); + + assert.deepEqual(Object.keys(config.secrets.services), ['site', 'www']); + assert.deepEqual(config.secrets.services.www.keys, ['TOKEN']); + assert.equal(config.secrets.services.www.opItem, 'dddd'); + assert.equal(isPrettierClean(readCjs()), true); + } +); + +withFixture( + 'registerServiceInCjs inserts alongside quoted service names', + `${preamble()} services: { + 'prismic-published-workflow': { + sharedKeys: ['IMGIX_TOKEN'], + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.registerServiceInCjs({ key: 'TOKEN', name: 'api', opItem: 'cccc' }); + + const config = loadCjs(); + + assert.deepEqual(Object.keys(config.secrets.services), [ + 'api', + 'prismic-published-workflow', + ]); + assert.deepEqual(config.secrets.services.api.keys, ['TOKEN']); + assert.equal(config.secrets.services.api.opItem, 'cccc'); + } +); + +withFixture( + 'registerServiceInCjs expands an empty services scaffold', + `${preamble()} services: {}, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.registerServiceInCjs({ key: 'TOKEN', name: 'api', opItem: 'cccc' }); + + const config = loadCjs(); + + assert.deepEqual(config.secrets.services.api.keys, ['TOKEN']); + assert.equal(config.secrets.services.api.opItem, 'cccc'); + assert.equal(isPrettierClean(readCjs()), true); + } +); + +withFixture( + 'registerServiceInCjs result still validates as a contract', + `${preamble()} services: {}, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.registerServiceInCjs({ key: 'TOKEN', name: 'api', opItem: 'cccc' }); + + const contract = getSecretsContract({ config: loadCjs() }); + + assert.deepEqual(contract.services.api.keys, ['TOKEN']); + assert.equal(contract.services.api.opItem, 'cccc'); + } +); From c1668656757f455e33a29f8b4038bb37aac59b75 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:40:34 +1030 Subject: [PATCH 31/52] Migrated the test suite to vitest and added the op resolver tests. --- .eslintrc.json | 15 + .npmignore | 1 + package-lock.json | 1118 ++++++++++++++++++++++++++++- package.json | 4 +- test/c-secrets-io-resolve.test.js | 208 ++++++ test/cjs-editors.test.js | 124 ++-- vitest.config.mjs | 7 + 7 files changed, 1398 insertions(+), 79 deletions(-) create mode 100644 test/c-secrets-io-resolve.test.js create mode 100644 vitest.config.mjs diff --git a/.eslintrc.json b/.eslintrc.json index 90daeec..37eb955 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,5 +3,20 @@ "parserOptions": { "ecmaVersion": 2017 }, + "overrides": [ + { + "files": ["test/**/*.js", "vitest.config.mjs"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "rules": { + "node/no-unsupported-features/es-syntax": "off", + "node/no-unsupported-features/node-builtins": "off", + "node/no-missing-import": "off", + "node/no-unpublished-import": "off" + } + } + ], "root": true } diff --git a/.npmignore b/.npmignore index e950ad0..47545a0 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,4 @@ .editorconfig .eslintrc test +vitest.config.mjs diff --git a/package-lock.json b/package-lock.json index 313aa70..d7e025d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "husky": "8.0.1", "prettier": "2.7.1", "pretty-quick": "3.1.3", + "vitest": "5.0.3", "zx": "8.1.4" } }, @@ -911,6 +912,34 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@next/env": { "version": "16.2.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", @@ -1108,6 +1137,295 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@rushstack/eslint-patch": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.1.4.tgz", @@ -1134,6 +1452,31 @@ "license": "0BSD", "peer": true }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/fs-extra": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", @@ -1348,6 +1691,44 @@ "node": ">=10" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -1541,6 +1922,16 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -1663,6 +2054,16 @@ "license": "CC-BY-4.0", "peer": true }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1823,7 +2224,6 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "peer": true, "engines": { "node": ">=8" @@ -1932,6 +2332,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-shim-unscopables": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", @@ -2873,6 +3280,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", @@ -2905,6 +3322,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/external-editor": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", @@ -3064,6 +3491,22 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -3989,28 +4432,301 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/language-tags": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", - "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", + "node_modules/language-tags": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", + "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "~0.3.2" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "~0.3.2" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/locate-path": { @@ -4070,6 +4786,16 @@ "node": ">=10" } }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4427,6 +5153,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5110,6 +5850,41 @@ "node": "*" } }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, "node_modules/run-async": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", @@ -5433,6 +6208,13 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", @@ -5723,6 +6505,74 @@ "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -5888,6 +6738,225 @@ "dev": true, "license": "MIT" }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5920,6 +6989,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", diff --git a/package.json b/package.json index bce0810..8a92d43 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "minor-release": "npm version minor && npm publish --access=public && git push --follow-tags", "patch-release": "npm version patch && npm publish --access=public && git push --follow-tags", "prerelease-release": "npm version prerelease && npm publish --access=public && git push --follow-tags", - "test": "node --test", + "test": "vitest run", + "test:watch": "vitest", "prepare": "husky install" }, "repository": { @@ -55,6 +56,7 @@ "husky": "8.0.1", "prettier": "2.7.1", "pretty-quick": "3.1.3", + "vitest": "5.0.3", "zx": "8.1.4" } } diff --git a/test/c-secrets-io-resolve.test.js b/test/c-secrets-io-resolve.test.js new file mode 100644 index 0000000..262bbd5 --- /dev/null +++ b/test/c-secrets-io-resolve.test.js @@ -0,0 +1,208 @@ +'use strict'; + +// Behavioral tests for the 1Password item resolvers of c-secrets-io: +// resolveOwnerItem must never create items (read paths), while +// resolveOrCreateOwnerItem creates service items (write paths). The +// shared block never creates on either path. +// +// Runs against a fake `op` binary injected via PATH, so no real 1Password +// session is needed; every invocation is logged for create/no-create +// assertions. +// +// Run with: npm test + +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const io = require('../bin/lib/c-secrets-io.js'); + +const FAKE_OP = `#!/bin/sh +# Fake 1Password CLI for offline behavioral tests of c-secrets-io. +LOG="$OP_FAKE_LOG" +echo "$@" >> "$LOG" + +case "$*" in + *"whoami"*) + echo '{}' + ;; + *"item list"*) + echo '[{"id":"siteitemid","title":"idearium-demo/site","vault":{"id":"Team"}}]' + ;; + *"item create"*) + echo '{"id":"created-item-id"}' + ;; + *) + echo "fake op: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +let dir; +let hadSession; +let log; +let previousFakeLog; +let previousPath; +let previousSession; + +beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-op-')); + log = path.join(dir, 'op.log'); + + fs.writeFileSync(path.join(dir, 'op'), FAKE_OP, { mode: 0o755 }); + + previousPath = process.env.PATH; + hadSession = 'OP_SESSION_idearium' in process.env; + previousSession = process.env.OP_SESSION_idearium; + previousFakeLog = process.env.OP_FAKE_LOG; + + process.env.PATH = `${dir}:${process.env.PATH}`; + process.env.OP_SESSION_idearium = 'faketoken'; + process.env.OP_FAKE_LOG = log; +}); + +afterAll(() => { + process.env.PATH = previousPath; + process.env.OP_FAKE_LOG = previousFakeLog; + + if (hadSession) { + process.env.OP_SESSION_idearium = previousSession; + } else { + delete process.env.OP_SESSION_idearium; + } + + fs.rmSync(dir, { force: true, recursive: true }); +}); + +const freshLog = () => fs.writeFileSync(log, ''); +const logText = () => fs.readFileSync(log, 'utf8'); +const createdItems = () => logText().includes('item create'); + +const owners = { + bound: { keys: [], name: 'site', opItem: 'bound123', sharedKeys: [] }, + shared: { keys: [], name: 'shared', sharedKeys: [] }, + unboundApi: { keys: [], name: 'api', sharedKeys: [] }, + unboundSite: { keys: [], name: 'site', sharedKeys: [] }, +}; + +const title = (owner) => `idearium-demo/${owner.name}`; + +const expectThrowsWith = (fn, substring) => { + try { + fn(); + } catch (e) { + return expect(e.message).toContain(substring); + } + + throw new Error('expected the call to throw, but it did not'); +}; + +it('returns a bound opItem without invoking op, on either path', () => { + freshLog(); + + expect( + io.resolveOwnerItem({ owner: owners.bound, title: title(owners.bound) }) + ).toStrictEqual({ created: false, id: 'bound123', persisted: false }); + + expect( + io.resolveOrCreateOwnerItem({ + owner: owners.bound, + title: title(owners.bound), + }) + ).toStrictEqual({ created: false, id: 'bound123', persisted: false }); + + expect(logText(), 'no op calls for a bound entry').toBe(''); +}); + +it('resolves an unbound entry by title without creating, on either path', () => { + freshLog(); + + expect( + io.resolveOwnerItem({ + owner: owners.unboundSite, + title: title(owners.unboundSite), + }) + ).toStrictEqual({ created: false, id: 'siteitemid', persisted: true }); + + expect( + io.resolveOrCreateOwnerItem({ + owner: owners.unboundSite, + title: title(owners.unboundSite), + }) + ).toStrictEqual({ created: false, id: 'siteitemid', persisted: true }); + + expect(createdItems(), 'an existing item is bound, never duplicated').toBe( + false + ); +}); + +it('never creates items on read paths (unbound and missing)', () => { + freshLog(); + + expectThrowsWith( + () => + io.resolveOwnerItem({ + owner: owners.unboundApi, + title: title(owners.unboundApi), + }), + 'read commands never create' + ); + + expect(createdItems()).toBe(false); +}); + +it('never creates the shared block\u2019s item, on either path', () => { + freshLog(); + + expectThrowsWith( + () => + io.resolveOwnerItem({ + owner: owners.shared, + title: title(owners.shared), + }), + 'set shared.opItem' + ); + + expectThrowsWith( + () => + io.resolveOrCreateOwnerItem({ + owner: owners.shared, + title: title(owners.shared), + }), + 'set shared.opItem' + ); + + expect(createdItems()).toBe(false); +}); + +it('creates a missing service item on write paths, with the right title', () => { + freshLog(); + + expect( + io.resolveOrCreateOwnerItem({ + owner: owners.unboundApi, + title: title(owners.unboundApi), + }) + ).toStrictEqual({ created: true, id: 'created-item-id', persisted: true }); + + expect(logText()).toContain('item create'); + expect(logText()).toContain('--title=idearium-demo/api'); +}); + +it('creates an item for a brand-new service (null owner)', () => { + freshLog(); + + expect( + io.resolveOrCreateOwnerItem({ + owner: null, + title: 'idearium-demo/newsvc', + }) + ).toStrictEqual({ created: true, id: 'created-item-id', persisted: true }); + + expect(createdItems()).toBe(true); +}); diff --git a/test/cjs-editors.test.js b/test/cjs-editors.test.js index 86a1ceb..cffd84e 100644 --- a/test/cjs-editors.test.js +++ b/test/cjs-editors.test.js @@ -9,8 +9,11 @@ // // Run with: npm test -const { test } = require('node:test'); -const assert = require('node:assert/strict'); +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -19,7 +22,7 @@ const prettier = require('prettier'); const io = require('../bin/lib/c-secrets-io.js'); const { getSecretsContract } = require('../bin/lib/c-secrets.js'); -const REPO_ROOT = path.resolve(__dirname, '..'); +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); const PRETTIER_OPTIONS = Object.assign( { endOfLine: 'lf', parser: 'babel' }, JSON.parse( @@ -76,7 +79,7 @@ const writeCjs = (text) => // Run a test body inside a temp directory holding the fixture c.js. const withFixture = (name, cjsText, body) => - test(name, () => { + it(name, () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cjs-editors-')); const previousCwd = process.cwd(); @@ -224,21 +227,20 @@ KEY_STYLES.forEach((style) => { const config = loadCjs(); - assert.deepEqual( - config.secrets.services.site.keys, + expect(config.secrets.services.site.keys).toStrictEqual( style.existingSiteKeys.concat('RAS_API_KEY') ); - assert.equal(config.docker.locations.site.buildArgs.TOKEN, 'nope'); + expect(config.docker.locations.site.buildArgs.TOKEN).toBe('nope'); const text = readCjs(); - assert.ok( - text.includes(style.expectedSiteBlock), + expect( + text, 'the keys block must render in the entry\u2019s own style' - ); + ).toContain(style.expectedSiteBlock); if (style.canonical) { - assert.equal(isPrettierClean(text), true); + expect(isPrettierClean(text)).toBe(true); } // Idempotent: a second identical run changes nothing. @@ -246,7 +248,7 @@ KEY_STYLES.forEach((style) => { io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); - assert.equal(readCjs(), afterFirst); + expect(readCjs()).toBe(afterFirst); } ); @@ -258,15 +260,14 @@ KEY_STYLES.forEach((style) => { const config = loadCjs(); - assert.deepEqual( - config.secrets.shared.keys, + expect(config.secrets.shared.keys).toStrictEqual( style.existingSharedKeys.concat('API_TOKEN').sort() ); - assert.ok( - readCjs().includes(style.expectedSharedBlock), + expect( + readCjs(), 'the shared keys block must render in the entry\u2019s own style' - ); + ).toContain(style.expectedSharedBlock); } ); }); @@ -292,12 +293,12 @@ ${closing()}`, const config = loadCjs(); const text = readCjs(); - assert.deepEqual(config.secrets.services.site.keys, [ + expect(config.secrets.services.site.keys).toStrictEqual([ 'CACHE_URL', 'RAS_API_KEY', ]); - assert.match(text, /\/\/ the site's owned keys/); - assert.match(text, /\/\/ cache connection details/); + expect(text).toMatch(/\/\/ the site's owned keys/); + expect(text).toMatch(/\/\/ cache connection details/); } ); @@ -320,15 +321,13 @@ ${closing()}`, const config = loadCjs(); - assert.deepEqual( - config.secrets.services['prismic-published-workflow'].keys, - ['BASIC_AUTH'] - ); - assert.deepEqual( - config.secrets.services['prismic-published-workflow'].sharedKeys, - ['IMGIX_TOKEN'] - ); - assert.equal(isPrettierClean(readCjs()), true); + expect( + config.secrets.services['prismic-published-workflow'].keys + ).toStrictEqual(['BASIC_AUTH']); + expect( + config.secrets.services['prismic-published-workflow'].sharedKeys + ).toStrictEqual(['IMGIX_TOKEN']); + expect(isPrettierClean(readCjs())).toBe(true); } ); @@ -340,11 +339,11 @@ withFixture( const config = loadCjs(); - assert.deepEqual(config.secrets.services.site.keys, [ + expect(config.secrets.services.site.keys).toStrictEqual([ 'CACHE_URL', 'RAS_API_KEY', ]); - assert.equal(isCrlf(readCjs()), true, 'no mixed line endings'); + expect(isCrlf(readCjs()), 'no mixed line endings').toBe(true); } ); @@ -354,12 +353,11 @@ withFixture( () => { io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); - const contract = getSecretsContract({ config: loadCjs() }); - const { site } = contract.services; + const { site } = getSecretsContract({ config: loadCjs() }).services; - assert.equal(site.name, 'site'); - assert.deepEqual(site.keys, ['CACHE_URL', 'RAS_API_KEY']); - assert.equal(site.opItem, 'aaaa'); + expect(site.name).toBe('site'); + expect(site.keys).toStrictEqual(['CACHE_URL', 'RAS_API_KEY']); + expect(site.opItem).toBe('aaaa'); } ); @@ -377,8 +375,8 @@ ${closing()}`, () => { io.setOpItemInCjs({ name: 'site', opItem: 'bbbb' }); - assert.equal(loadCjs().secrets.services.site.opItem, 'bbbb'); - assert.equal(isPrettierClean(readCjs()), true); + expect(loadCjs().secrets.services.site.opItem).toBe('bbbb'); + expect(isPrettierClean(readCjs())).toBe(true); } ); @@ -399,8 +397,8 @@ ${closing()}`, const config = loadCjs(); - assert.equal(config.secrets.shared.opItem, 'services.site'); - assert.equal(config.secrets.services.site.opItem, 'aaaa'); + expect(config.secrets.shared.opItem).toBe('services.site'); + expect(config.secrets.services.site.opItem).toBe('aaaa'); } ); @@ -421,8 +419,8 @@ ${closing()}`, io.setOpItemInCjs({ name: 'site', opItem: 'zzzz' }); - assert.equal(readCjs(), before); - assert.equal(loadCjs().secrets.services.site.opItem, 'aaaa'); + expect(readCjs()).toBe(before); + expect(loadCjs().secrets.services.site.opItem).toBe('aaaa'); } ); @@ -440,8 +438,8 @@ ${closing()}`.replace(/\n/g, '\r\n'), () => { io.setOpItemInCjs({ name: 'site', opItem: 'bbbb' }); - assert.equal(loadCjs().secrets.services.site.opItem, 'bbbb'); - assert.equal(isCrlf(readCjs()), true, 'no mixed line endings'); + expect(loadCjs().secrets.services.site.opItem).toBe('bbbb'); + expect(isCrlf(readCjs()), 'no mixed line endings').toBe(true); } ); @@ -462,10 +460,13 @@ ${closing()}`, const config = loadCjs(); - assert.deepEqual(Object.keys(config.secrets.services), ['api', 'site']); - assert.deepEqual(config.secrets.services.api.keys, ['TOKEN']); - assert.equal(config.secrets.services.api.opItem, 'cccc'); - assert.equal(isPrettierClean(readCjs()), true); + expect(Object.keys(config.secrets.services)).toStrictEqual([ + 'api', + 'site', + ]); + expect(config.secrets.services.api.keys).toStrictEqual(['TOKEN']); + expect(config.secrets.services.api.opItem).toBe('cccc'); + expect(isPrettierClean(readCjs())).toBe(true); } ); @@ -486,10 +487,13 @@ ${closing()}`, const config = loadCjs(); - assert.deepEqual(Object.keys(config.secrets.services), ['site', 'www']); - assert.deepEqual(config.secrets.services.www.keys, ['TOKEN']); - assert.equal(config.secrets.services.www.opItem, 'dddd'); - assert.equal(isPrettierClean(readCjs()), true); + expect(Object.keys(config.secrets.services)).toStrictEqual([ + 'site', + 'www', + ]); + expect(config.secrets.services.www.keys).toStrictEqual(['TOKEN']); + expect(config.secrets.services.www.opItem).toBe('dddd'); + expect(isPrettierClean(readCjs())).toBe(true); } ); @@ -509,12 +513,12 @@ ${closing()}`, const config = loadCjs(); - assert.deepEqual(Object.keys(config.secrets.services), [ + expect(Object.keys(config.secrets.services)).toStrictEqual([ 'api', 'prismic-published-workflow', ]); - assert.deepEqual(config.secrets.services.api.keys, ['TOKEN']); - assert.equal(config.secrets.services.api.opItem, 'cccc'); + expect(config.secrets.services.api.keys).toStrictEqual(['TOKEN']); + expect(config.secrets.services.api.opItem).toBe('cccc'); } ); @@ -530,9 +534,9 @@ ${closing()}`, const config = loadCjs(); - assert.deepEqual(config.secrets.services.api.keys, ['TOKEN']); - assert.equal(config.secrets.services.api.opItem, 'cccc'); - assert.equal(isPrettierClean(readCjs()), true); + expect(config.secrets.services.api.keys).toStrictEqual(['TOKEN']); + expect(config.secrets.services.api.opItem).toBe('cccc'); + expect(isPrettierClean(readCjs())).toBe(true); } ); @@ -548,7 +552,7 @@ ${closing()}`, const contract = getSecretsContract({ config: loadCjs() }); - assert.deepEqual(contract.services.api.keys, ['TOKEN']); - assert.equal(contract.services.api.opItem, 'cccc'); + expect(contract.services.api.keys).toStrictEqual(['TOKEN']); + expect(contract.services.api.opItem).toBe('cccc'); } ); diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..2c5f796 --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.js'], + }, +}); From 0b50641af22bca1e77750185317588135f37eabe Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:39:23 +1030 Subject: [PATCH 32/52] Hardened child-process exit codes and GSM timeouts, parallelised gcfList, and rejected shared-shaped manifest locations. --- README.md | 2 + bin/c-kc.js | 12 +- bin/c-op-cmd.js | 10 +- bin/c-op-get.js | 4 +- bin/c-op-session.js | 4 +- bin/c-op-set.js | 9 +- bin/c-op.js | 12 +- bin/c-secrets-add.js | 5 +- bin/c-secrets-consumers.js | 4 +- bin/c-secrets-get.js | 4 +- bin/c-secrets-init.js | 5 +- bin/c-secrets-ls.js | 4 +- bin/c-secrets-push.js | 4 +- bin/c-secrets-verify.js | 4 +- bin/c.js | 12 +- bin/lib/c-kc.js | 4 +- bin/lib/c-secrets-io.js | 187 +++++++++++++++++------------- bin/lib/c-secrets-manifests.js | 9 ++ bin/lib/c-secrets-verify.js | 25 +--- bin/lib/c.js | 31 +++++ test/c-secrets-io-gcflist.test.js | 97 ++++++++++++++++ test/c-secrets-manifests.test.js | 119 +++++++++++++++++++ test/spawn-exit.test.js | 70 +++++++++++ 23 files changed, 477 insertions(+), 160 deletions(-) create mode 100644 test/c-secrets-io-gcflist.test.js create mode 100644 test/c-secrets-manifests.test.js create mode 100644 test/spawn-exit.test.js diff --git a/README.md b/README.md index adbd59d..5c88f64 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,8 @@ Commands (values are only ever displayed as truncated hashes; `--plaintext` requ Commands that need 1Password, gcloud or kubectl fail fast with friendly re-authentication instructions when their credentials have expired. +**A note on the 1Password session.** `c op session` stores the session token at `~/.local/state/idearium/op-session` (`0600`), and every `op` invocation the cli makes passes it via `--session` (as does `c op cmd`). That argument is visible in the local process list - the same exposure any `op --session` or `OP_SESSION_*` environment carries. It is machine-local and expires, and secret values themselves never appear in argv: they move via stdin pipes only. + ### MongoDB configuration The Idearium cli supports a MongoDB configuration. The MongoDB configuration can be used to access local and remote databases. diff --git a/bin/c-kc.js b/bin/c-kc.js index 53a079a..2d303ea 100644 --- a/bin/c-kc.js +++ b/bin/c-kc.js @@ -1,21 +1,17 @@ 'use strict'; const program = require('commander'); -const { missingCommand } = require('./lib/c'); +const { missingCommand, spawnWithExitCode } = require('./lib/c'); // The cmd passthrough needs the verbatim argv (bin/c.js forwards this // subtree raw): commander's git-style dispatch would re-parse the args // first, dropping -- and unknown options like kubectl flags. if (process.argv[2] === 'cmd') { - const { spawn } = require('child_process'); const { join } = require('path'); - spawn( - 'node', - [join(__dirname, 'c-kc-cmd.js')].concat(process.argv.slice(3)), - { stdio: 'inherit' } - ).on('exit', (code) => { - process.exitCode = code; + spawnWithExitCode({ + args: [join(__dirname, 'c-kc-cmd.js')].concat(process.argv.slice(3)), + command: 'node', }); return; diff --git a/bin/c-op-cmd.js b/bin/c-op-cmd.js index 8db4afb..5bc691b 100644 --- a/bin/c-op-cmd.js +++ b/bin/c-op-cmd.js @@ -1,9 +1,8 @@ 'use strict'; -const { spawn } = require('child_process'); const program = require('commander'); -const { reportError } = require('./lib/c'); +const { reportError, spawnWithExitCode } = require('./lib/c'); const { assertOpSession, opSession } = require('./lib/c-secrets-io'); program @@ -19,10 +18,9 @@ try { // Array form (no shell) so values never round-trip through string // interpolation. - spawn('op', ['--session', opSession()].concat(program.rawArgs.slice(2)), { - stdio: 'inherit', - }).on('exit', (code) => { - process.exitCode = code; + spawnWithExitCode({ + args: ['--session', opSession()].concat(program.rawArgs.slice(2)), + command: 'op', }); } catch (e) { reportError(e, false, true); diff --git a/bin/c-op-get.js b/bin/c-op-get.js index 2103ef1..9f33652 100644 --- a/bin/c-op-get.js +++ b/bin/c-op-get.js @@ -1,7 +1,5 @@ 'use strict'; -const print = (line) => process.stdout.write(`${line}\n`); - const program = require('commander'); const { loadConfig, newline, reportError } = require('./lib/c'); const { @@ -20,6 +18,8 @@ const { sha12, } = require('./lib/c-secrets-io'); +const print = (line) => process.stdout.write(`${line}\n`); + program .arguments(' ') .option( diff --git a/bin/c-op-session.js b/bin/c-op-session.js index 9e3b12a..57d4ba5 100644 --- a/bin/c-op-session.js +++ b/bin/c-op-session.js @@ -1,7 +1,5 @@ 'use strict'; -const print = (line) => process.stdout.write(`${line}\n`); - const { execFileSync } = require('child_process'); const { chmodSync, mkdirSync, writeFileSync } = require('fs'); const { dirname } = require('path'); @@ -10,6 +8,8 @@ const program = require('commander'); const { reportError } = require('./lib/c'); const { OP_SESSION_FILE } = require('./lib/c-secrets-io'); +const print = (line) => process.stdout.write(`${line}\n`); + program .description( 'Sign in to 1Password and store the session token where every c command (and c op cmd) reads it (~/.local/state/idearium/op-session, 0600). The token itself is never printed.' diff --git a/bin/c-op-set.js b/bin/c-op-set.js index cb9dc87..71c985b 100644 --- a/bin/c-op-set.js +++ b/bin/c-op-set.js @@ -1,7 +1,6 @@ 'use strict'; -const print = (line) => process.stdout.write(`${line}\n`); - +const { readFileSync } = require('fs'); const program = require('commander'); const { loadConfig, reportError } = require('./lib/c'); const { @@ -20,6 +19,8 @@ const { sha12, } = require('./lib/c-secrets-io'); +const print = (line) => process.stdout.write(`${line}\n`); + program .arguments(' ') .option('-g, --generate', 'Generate a value instead of reading stdin.') @@ -50,9 +51,7 @@ const readStdin = () => { ); } - return require('fs') - .readFileSync(0, 'utf8') - .replace(/\r?\n$/, ''); + return readFileSync(0, 'utf8').replace(/\r?\n$/, ''); }; return loadConfig() diff --git a/bin/c-op.js b/bin/c-op.js index 9afb95c..2b68987 100644 --- a/bin/c-op.js +++ b/bin/c-op.js @@ -1,21 +1,17 @@ 'use strict'; const program = require('commander'); -const { missingCommand } = require('./lib/c'); +const { missingCommand, spawnWithExitCode } = require('./lib/c'); // The cmd passthrough needs the verbatim argv (bin/c.js forwards this // subtree raw): commander's git-style dispatch would re-parse the args // first, dropping -- and unknown options. if (process.argv[2] === 'cmd') { - const { spawn } = require('child_process'); const { join } = require('path'); - spawn( - 'node', - [join(__dirname, 'c-op-cmd.js')].concat(process.argv.slice(3)), - { stdio: 'inherit' } - ).on('exit', (code) => { - process.exitCode = code; + spawnWithExitCode({ + args: [join(__dirname, 'c-op-cmd.js')].concat(process.argv.slice(3)), + command: 'node', }); return; diff --git a/bin/c-secrets-add.js b/bin/c-secrets-add.js index 004d8e1..7b2a1ca 100644 --- a/bin/c-secrets-add.js +++ b/bin/c-secrets-add.js @@ -1,9 +1,6 @@ 'use strict'; -const print = (line) => process.stdout.write(`${line}\n`); - const { readFileSync, writeFileSync } = require('fs'); - const program = require('commander'); const { loadConfig, reportError } = require('./lib/c'); const { @@ -26,6 +23,8 @@ const { setOpItemInCjs, } = require('./lib/c-secrets-io'); +const print = (line) => process.stdout.write(`${line}\n`); + program .arguments(' ') .option('-g, --generate', 'Generate a value instead of reading stdin.') diff --git a/bin/c-secrets-consumers.js b/bin/c-secrets-consumers.js index 89fb42a..9db58be 100644 --- a/bin/c-secrets-consumers.js +++ b/bin/c-secrets-consumers.js @@ -1,13 +1,13 @@ 'use strict'; -const print = (line) => process.stdout.write(`${line}\n`); - const program = require('commander'); const { loadConfig, reportError } = require('./lib/c'); const { getSecretsContract } = require('./lib/c-secrets'); const { assertGcloudAuth } = require('./lib/c-secrets-io'); const { findConsumers } = require('./lib/c-secrets-verify'); +const print = (line) => process.stdout.write(`${line}\n`); + program .arguments('') .description( diff --git a/bin/c-secrets-get.js b/bin/c-secrets-get.js index 0fb37b1..116ce35 100644 --- a/bin/c-secrets-get.js +++ b/bin/c-secrets-get.js @@ -1,7 +1,5 @@ 'use strict'; -const print = (line) => process.stdout.write(`${line}\n`); - const program = require('commander'); const { loadConfig, newline, reportError } = require('./lib/c'); const { @@ -17,6 +15,8 @@ const { sha12, } = require('./lib/c-secrets-io'); +const print = (line) => process.stdout.write(`${line}\n`); + program .arguments(' ') .option( diff --git a/bin/c-secrets-init.js b/bin/c-secrets-init.js index a234d1e..7b84de5 100644 --- a/bin/c-secrets-init.js +++ b/bin/c-secrets-init.js @@ -1,16 +1,15 @@ 'use strict'; -const print = (line) => process.stdout.write(`${line}\n`); - const { readFileSync, writeFileSync } = require('fs'); const { join } = require('path'); - const program = require('commander'); const { loadConfig, reportError } = require('./lib/c'); const { getSecretsContract } = require('./lib/c-secrets'); const { assertGcloudAuth, assertOpSession } = require('./lib/c-secrets-io'); const { draftContract } = require('./lib/c-secrets-init'); +const print = (line) => process.stdout.write(`${line}\n`); + program .description( 'Draft a secrets contract into c.js by scanning existing declarations (SecretProviderClasses, local secret templates, --set-secrets scripts, env.*.yaml references, docker build args and 1Password item names). Requires a warm 1Password session (`c op session`) so item bindings resolve. The draft is a starting point: review ownership, opItems and sharedKeys, then verify. Exits without changes when a secrets contract already exists.' diff --git a/bin/c-secrets-ls.js b/bin/c-secrets-ls.js index 3cc987f..7c442c2 100644 --- a/bin/c-secrets-ls.js +++ b/bin/c-secrets-ls.js @@ -1,7 +1,5 @@ 'use strict'; -const print = (line) => process.stdout.write(`${line}\n`); - const program = require('commander'); const { loadConfig, reportError } = require('./lib/c'); const { @@ -11,6 +9,8 @@ const { } = require('./lib/c-secrets'); const { assertGcloudAuth, gsmList } = require('./lib/c-secrets-io'); +const print = (line) => process.stdout.write(`${line}\n`); + program .arguments('') .description( diff --git a/bin/c-secrets-push.js b/bin/c-secrets-push.js index f32f04d..a836f0f 100644 --- a/bin/c-secrets-push.js +++ b/bin/c-secrets-push.js @@ -1,7 +1,5 @@ 'use strict'; -const print = (line) => process.stdout.write(`${line}\n`); - const program = require('commander'); const { loadConfig, reportError } = require('./lib/c'); const { @@ -19,6 +17,8 @@ const { resolveOwnerItem, } = require('./lib/c-secrets-io'); +const print = (line) => process.stdout.write(`${line}\n`); + program .arguments(' ') .description( diff --git a/bin/c-secrets-verify.js b/bin/c-secrets-verify.js index 4f6a70a..96c21a0 100644 --- a/bin/c-secrets-verify.js +++ b/bin/c-secrets-verify.js @@ -1,7 +1,5 @@ 'use strict'; -const print = (line) => process.stdout.write(`${line}\n`); - const program = require('commander'); const { loadConfig, reportError } = require('./lib/c'); const { assertKnownEnv, getSecretsContract } = require('./lib/c-secrets'); @@ -12,6 +10,8 @@ const { } = require('./lib/c-secrets-io'); const { kubernetesEnv, verifyEnv } = require('./lib/c-secrets-verify'); +const print = (line) => process.stdout.write(`${line}\n`); + program .arguments('') .description( diff --git a/bin/c.js b/bin/c.js index a139887..14b8744 100755 --- a/bin/c.js +++ b/bin/c.js @@ -3,7 +3,7 @@ 'use strict'; const program = require('commander'); -const { missingCommand } = require('./lib/c'); +const { missingCommand, spawnWithExitCode } = require('./lib/c'); // Commander's git-style dispatch re-parses subcommand args at every layer // before spawning (dropping -- and unknown options, breaking passthroughs @@ -19,15 +19,11 @@ const VERBATIM_DISPATCH = { const verbatimChild = VERBATIM_DISPATCH[process.argv[2]]; if (verbatimChild) { - const { spawn } = require('child_process'); const { join } = require('path'); - spawn( - 'node', - [join(__dirname, verbatimChild)].concat(process.argv.slice(3)), - { stdio: 'inherit' } - ).on('exit', (code) => { - process.exitCode = code; + spawnWithExitCode({ + args: [join(__dirname, verbatimChild)].concat(process.argv.slice(3)), + command: 'node', }); return; diff --git a/bin/lib/c-kc.js b/bin/lib/c-kc.js index f1512fe..cbae168 100644 --- a/bin/lib/c-kc.js +++ b/bin/lib/c-kc.js @@ -2,14 +2,14 @@ const fs = require('fs'); const { copy, ensureDir, mkdtemp, remove } = require('fs-extra'); - -const { opSession } = require('./c-secrets-io'); const { tmpdir } = require('os'); const { join, resolve: resolvePath } = require('path'); const Mustache = require('mustache'); const { promisify } = require('util'); const { execFile } = require('child_process'); +const { opSession } = require('./c-secrets-io'); + const { constants } = fs; const access = promisify(fs.access); diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index 83a6c69..1649520 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -1,16 +1,21 @@ 'use strict'; -const { execFileSync } = require('child_process'); +const { execFile, execFileSync } = require('child_process'); const { createHash, randomBytes } = require('crypto'); const espree = require('espree'); const https = require('https'); const { readFileSync, writeFileSync } = require('fs'); const { join } = require('path'); const { homedir } = require('os'); +const { promisify } = require('util'); const VAULT = 'Team'; const OP_SESSION_FILE = join(homedir(), '.local/state/idearium/op-session'); +// Ceiling for a single Google Secret Manager REST call: without it, a +// stalled connection hangs the calling command indefinitely. +const GSM_TIMEOUT_MS = 30000; + // Environment variables whose presence indicates a non-interactive agent // context - plaintext output is hard-blocked while any is set. const AGENT_ENV_VARS = [ @@ -83,15 +88,6 @@ const runOp = ({ args, input }) => { */ const opRead = ({ ref }) => runOp({ args: ['read', '-n', ref] }); -/** - * Hash a 1Password reference's value without exposing it. - * @param {Object} options - * @param {String} options.ref The op:// reference. - * @returns {String} A truncated sha256. - */ -const opReadSha = ({ ref }) => - sha12({ value: Buffer.from(opRead({ ref }), 'utf8') }); - /** * List a 1Password item's fields (labels and sections only). * @param {Object} options @@ -351,6 +347,58 @@ const runGcloud = ({ args }) => { } }; +const execFileAsync = promisify(execFile); + +/** + * Run gcloud asynchronously and resolve with its stdout, mapping + * authentication failures to the same friendly error as runGcloud. + * @param {Object} options + * @param {Array} options.args The gcloud arguments. + * @returns {Promise} Resolves with the command's stdout. + */ +const runGcloudAsync = ({ args }) => + execFileAsync('gcloud', args, { encoding: 'utf8' }).then( + ({ stdout }) => stdout, + (e) => { + const friendly = gcloudAuthError({ stderr: String(e.stderr) }); + + throw friendly || e; + } + ); + +/** + * Run promise-returning work over items with bounded concurrency, + * resolving with each item's result in input order. + * @param {Object} options + * @param {Array} options.items The items to process. + * @param {Number} options.limit The concurrency limit. + * @param {Function} options.run Called with an item; returns a Promise. + * @returns {Promise} Resolves with the array of results. + */ +const pool = ({ items, limit, run }) => { + const results = new Array(items.length); + let index = 0; + + const worker = () => { + if (index >= items.length) { + return Promise.resolve(); + } + + const slot = index++; + const item = items[slot]; + + return Promise.resolve(run(item)).then((result) => { + results[slot] = result; + + return worker(); + }); + }; + + return Promise.all( + Array.from({ length: Math.min(limit, items.length) }, worker) + ).then(() => results); +}; + /** * Assert gcloud is authenticated (up front, before any work) for commands * that touch Google Cloud. @@ -383,6 +431,7 @@ const gsmRequest = ({ body, method, path, project }) => host: 'secretmanager.googleapis.com', method, path: `/v1/projects/${project}${path}`, + timeout: GSM_TIMEOUT_MS, }, (res) => { let raw = ''; @@ -407,6 +456,18 @@ const gsmRequest = ({ body, method, path, project }) => } ); + // Without a destroy, the timeout event alone leaves the socket + // (and the command) hanging. + request.on('timeout', () => { + request.destroy( + new Error( + `GSM ${method} ${path} timed out after ${ + GSM_TIMEOUT_MS / 1000 + }s - retry` + ) + ); + }); + request.on('error', reject); if (body) { @@ -548,8 +609,8 @@ const gsmList = ({ project }) => { * @param {String} options.repoPrefix The function-name prefix (org-repo-). * @returns {Promise} Resolves with [{ name, secrets: [secretId] }]. */ -const gcfList = ({ project, region, repoPrefix }) => { - const names = runGcloud({ +const gcfList = ({ project, region, repoPrefix }) => + runGcloudAsync({ args: [ 'functions', 'list', @@ -558,34 +619,36 @@ const gcfList = ({ project, region, repoPrefix }) => { `--project=${project}`, ], }) - .split('\n') - .map((name) => name.trim().split('/').pop()) - .filter((name) => name.length > 0); - - return Promise.all( - names.map((name) => { - const shortName = name.split('/').pop(); - - const raw = runGcloud({ - args: [ - 'functions', - 'describe', - shortName, - `--format=json`, - `--project=${project}`, - `--region=${region}`, - ], - }); - - const secrets = ( - (JSON.parse(raw).serviceConfig || {}) - .secretEnvironmentVariables || [] - ).map((entry) => entry.secret); - - return { name: shortName, secrets }; - }) - ); -}; + .then((raw) => + raw + .split('\n') + .map((name) => name.trim().split('/').pop()) + .filter((name) => name.length > 0) + ) + .then((names) => + pool({ + items: names, + limit: 6, + run: (name) => + runGcloudAsync({ + args: [ + 'functions', + 'describe', + name, + `--format=json`, + `--project=${project}`, + `--region=${region}`, + ], + }).then((raw) => { + const secrets = ( + (JSON.parse(raw).serviceConfig || {}) + .secretEnvironmentVariables || [] + ).map((entry) => entry.secret); + + return { name, secrets }; + }), + }) + ); /** * Map kubectl failures to a friendly, actionable error. @@ -677,46 +740,6 @@ const assertKubectl = ({ context }) => { } }; -/** - * Read one key of a Kubernetes secret. - * @param {Object} options - * @param {String} options.context The kubectl context. - * @param {String} options.key The secret key. - * @param {String} options.namespace The namespace. - * @param {String} options.secret The Kubernetes secret name. - * @returns {String} The base64-encoded value. - */ -const k8sReadKey = ({ context, key, namespace, secret }) => - runKubectl({ - args: [ - '-n', - namespace, - 'get', - 'secret', - secret, - '-o', - `jsonpath={.data.${key}}`, - ], - context, - }).trim(); - -/** - * List the keys of a Kubernetes secret. - * @param {Object} options - * @param {String} options.context The kubectl context. - * @param {String} options.namespace The namespace. - * @param {String} options.secret The Kubernetes secret name. - * @returns {Array} The secret's keys. - */ -const k8sSecretKeys = ({ context, namespace, secret }) => { - const raw = runKubectl({ - args: ['-n', namespace, 'get', 'secret', secret, '-o', 'json'], - context, - }); - - return Object.keys(JSON.parse(raw).data || {}); -}; - /** * Read a Kubernetes secret in one call: its keys and their decoded values * (bulk reads - one kubectl invocation per secret instead of one per key). @@ -1553,9 +1576,7 @@ module.exports = { gsmList, gsmPushVerified, gsmRead, - k8sReadKey, k8sReadSecret, - k8sSecretKeys, opCreateItem, opFieldExists, opFindItemByTitle, @@ -1564,8 +1585,8 @@ module.exports = { opListItems, opRead, opSession, - opReadSha, opUpsertField, + pool, quoteIfNeeded, registerServiceInCjs, resolveOrCreateOwnerItem, diff --git a/bin/lib/c-secrets-manifests.js b/bin/lib/c-secrets-manifests.js index 00e2367..e9c8d50 100644 --- a/bin/lib/c-secrets-manifests.js +++ b/bin/lib/c-secrets-manifests.js @@ -246,6 +246,15 @@ const generateSecretManifests = async ({ }); const entriesFor = ({ name }) => { + // 'shared' is reserved for the secrets.shared block, and shared + // secrets are consumed through a service's sharedKeys - there is + // no shared-shaped manifest to generate. + if (name === 'shared') { + throw new Error( + `The '${name}' location is shared-shaped, but shared secrets are consumed through a service's sharedKeys - declare the location on the consuming service instead.` + ); + } + const service = getService({ contract, name }); return serviceKeys({ contract, env, service }); diff --git a/bin/lib/c-secrets-verify.js b/bin/lib/c-secrets-verify.js index f0986ad..b9a7c11 100644 --- a/bin/lib/c-secrets-verify.js +++ b/bin/lib/c-secrets-verify.js @@ -12,6 +12,7 @@ const { k8sReadSecret, opItemValues, opListItems, + pool, sha12, spcList, } = require('./c-secrets-io'); @@ -185,25 +186,6 @@ const verifyEnv = ({ config, contract, env }) => { const gsmShas = {}; const gsmValues = {}; - // Run promise-returning work over items with bounded concurrency. - const pool = ({ items, limit, run }) => { - let index = 0; - - const worker = () => { - if (index >= items.length) { - return Promise.resolve(); - } - - const item = items[index++]; - - return run(item).then(worker); - }; - - return Promise.all( - Array.from({ length: Math.min(limit, items.length) }, worker) - ); - }; - return gsmList({ project: contract.project }) .then((existing) => { const existingIds = new Set(existing); @@ -323,7 +305,10 @@ const verifyEnv = ({ config, contract, env }) => { if (!service) { report({ - message: `k8s ${name}: secretsync location but no contract service`, + message: + name === 'shared' + ? "k8s shared: shared secrets are consumed through a service's sharedKeys - declare the secretsync on the consuming service instead" + : `k8s ${name}: secretsync location but no contract service`, ok: false, }); diff --git a/bin/lib/c.js b/bin/lib/c.js index 3d6fff9..e1a10bf 100644 --- a/bin/lib/c.js +++ b/bin/lib/c.js @@ -1,6 +1,7 @@ 'use strict'; const chalk = require('chalk'); +const { spawn } = require('child_process'); const { readFile, writeFile } = require('fs'); const { homedir } = require('os'); const { resolve: pathResolve, join, dirname } = require('path'); @@ -177,7 +178,9 @@ const missingCommand = (program) => { const [missing] = program.args; + /* eslint-disable no-console */ console.error(chalk.red(`\nThere is no '${missing}' command`)); + /* eslint-enable no-console */ program.help(); }; @@ -321,6 +324,33 @@ const reportError = (err, program, exit = false) => { } }; +/** + * Spawn a child process that inherits stdio, propagating its exit code: + * a signal death becomes a non-zero exit instead of reporting success, + * and a failure to spawn is reported (and fails) rather than crashing. + * @param {Object} options + * @param {Array} options.args The child arguments. + * @param {String} options.command The command to run. + * @returns {Object} The child process. + */ +const spawnWithExitCode = ({ args, command }) => { + const child = spawn(command, args, { stdio: 'inherit' }); + + child.on('error', (e) => { + reportError( + new Error(`Could not run '${command}': ${e.message}`), + false, + true + ); + }); + + child.on('close', (code) => { + process.exitCode = code === null ? 1 : code; + }); + + return child; +}; + /** * The path to the state file. * @param {String} path The parent devops path. @@ -392,6 +422,7 @@ module.exports = { proxyCommand, proxyCommands, reportError, + spawnWithExitCode, stateFilePath, storeState, throwErr, diff --git a/test/c-secrets-io-gcflist.test.js b/test/c-secrets-io-gcflist.test.js new file mode 100644 index 0000000..a3597ce --- /dev/null +++ b/test/c-secrets-io-gcflist.test.js @@ -0,0 +1,97 @@ +'use strict'; + +// Tests for gcfList against a fake `gcloud` binary injected via PATH: +// the async, bounded-concurrency implementation must list the project's +// functions and parse each one's secret bindings, resolving in list +// order. + +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { gcfList } = require('../bin/lib/c-secrets-io.js'); + +const FAKE_GCLOUD = `#!/bin/sh +# Fake gcloud CLI for offline tests of gcfList. Honors the +# name~functions/ filter so empty matches can be exercised. +FUNCTIONS="idearium-demo-beta-api idearium-demo-site" + +case "$*" in + *"functions list"*) + prefix=$(printf '%s\\n' "$@" | sed -n 's/.*name~functions\\///p') + for fn in $FUNCTIONS; do + case "$fn" in + "$prefix"*) echo "projects/demo-proj/locations/australia-southeast1/functions/$fn" ;; + esac + done + ;; + *"functions describe"*) + case "$*" in + *api*) + echo '{"serviceConfig":{"secretEnvironmentVariables":[{"secret":"idearium-demo-beta-api-key"},{"secret":"idearium-demo-beta-shared-imgix"}]}}' + ;; + *site*) + echo '{"serviceConfig":{"secretEnvironmentVariables":[{"secret":"idearium-demo-beta-site-cache-url"}]}}' + ;; + esac + ;; + *) + echo "fake gcloud: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +let dir; +let previousPath; + +beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-gcloud-')); + + fs.writeFileSync(path.join(dir, 'gcloud'), FAKE_GCLOUD, { mode: 0o755 }); + + previousPath = process.env.PATH; + process.env.PATH = `${dir}:${process.env.PATH}`; +}); + +afterAll(() => { + process.env.PATH = previousPath; + + fs.rmSync(dir, { force: true, recursive: true }); +}); + +it('lists functions with their secret bindings, in list order', async () => { + const functions = await gcfList({ + project: 'demo-proj', + region: 'australia-southeast1', + repoPrefix: 'idearium-demo-', + }); + + expect(functions).toStrictEqual([ + { + name: 'idearium-demo-beta-api', + secrets: [ + 'idearium-demo-beta-api-key', + 'idearium-demo-beta-shared-imgix', + ], + }, + { + name: 'idearium-demo-site', + secrets: ['idearium-demo-beta-site-cache-url'], + }, + ]); +}); + +it('resolves with an empty list when no functions match', async () => { + const functions = await gcfList({ + project: 'demo-proj', + region: 'australia-southeast1', + repoPrefix: 'idearium-other-', + }); + + expect(functions).toStrictEqual([]); +}); diff --git a/test/c-secrets-manifests.test.js b/test/c-secrets-manifests.test.js new file mode 100644 index 0000000..ec6595b --- /dev/null +++ b/test/c-secrets-manifests.test.js @@ -0,0 +1,119 @@ +'use strict'; + +// Tests for generateSecretManifests: deployed-environment shapes render +// contract-derived SecretProviderClasses and SecretSyncs into the +// compiled folder, and shared-shaped locations are rejected with +// guidance (shared secrets are consumed through a service's sharedKeys). + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + generateSecretManifests, +} = require('../bin/lib/c-secrets-manifests.js'); + +const config = () => ({ + project: { + gcpProjectId: 'demo-proj', + name: 'demo', + organisation: 'idearium', + }, + gcloud: { + region: 'australia-southeast1', + }, + secrets: { + services: { + site: { + opItem: 'siteitem', + keys: ['CACHE_URL'], + sharedKeys: ['IMGIX_TOKEN'], + }, + }, + shared: { + opItem: 'services.site', + keys: ['IMGIX_TOKEN'], + }, + }, +}); + +// Run an async body inside a temp directory (the compiled folder is +// created relative to the current working directory); the directory +// lives until the body settles. +const inTempDir = async (body) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'c-secrets-manifests-')); + const previousCwd = process.cwd(); + + process.chdir(dir); + + try { + return await body(path.join(dir, '.compiled')); + } finally { + process.chdir(previousCwd); + fs.rmSync(dir, { force: true, recursive: true }); + } +}; + +it('generates SecretProviderClass and SecretSync manifests for a service', () => + inTempDir(async (compiled) => { + const generated = await generateSecretManifests({ + config: config(), + env: 'beta', + namespace: 'idearium-demo-beta', + path: '', + services: [ + { + path: 'site.secretproviderclass', + type: 'secretproviderclass', + }, + { path: 'site.secretsync', type: 'secretsync' }, + ], + }); + + expect(generated.sort()).toStrictEqual([ + 'site.secretproviderclass.yaml', + 'site.secretsync.yaml', + ]); + + const spc = fs.readFileSync( + path.join(compiled, 'site.secretproviderclass.yaml'), + 'utf8' + ); + const sync = fs.readFileSync( + path.join(compiled, 'site.secretsync.yaml'), + 'utf8' + ); + + // Owned and borrowed keys both appear, in sorted order. + expect(spc).toContain( + "- resourceName: 'projects/demo-proj/secrets/idearium-demo-beta-site-cache-url/versions/latest'" + ); + expect(spc).toContain("path: 'cache-url'"); + expect(spc).toContain( + "- resourceName: 'projects/demo-proj/secrets/idearium-demo-beta-imgix-token/versions/latest'" + ); + expect(sync).toContain("targetKey: 'IMGIX_TOKEN'"); + expect(sync).toContain('secretProviderClassName: site'); + })); + +it('rejects shared-shaped locations with guidance', () => + inTempDir(async () => { + await expect( + generateSecretManifests({ + config: config(), + env: 'beta', + namespace: 'idearium-demo-beta', + path: '', + services: [ + { + path: 'shared.secretproviderclass', + type: 'secretproviderclass', + }, + ], + }) + ).rejects.toThrow(/sharedKeys/); + })); diff --git a/test/spawn-exit.test.js b/test/spawn-exit.test.js new file mode 100644 index 0000000..6ab5573 --- /dev/null +++ b/test/spawn-exit.test.js @@ -0,0 +1,70 @@ +'use strict'; + +// Integration tests for spawnWithExitCode: exit codes propagate, a +// signal death fails instead of reporting success, and a command that +// cannot spawn is reported rather than crashing with an unhandled +// 'error' event. + +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { expect, it } from 'vitest'; + +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const run = promisify(execFile); + +const driver = (body) => + run('node', [ + '-e', + `const { spawnWithExitCode } = require(${JSON.stringify( + `${REPO_ROOT}bin/lib/c.js` + )});\n${body}`, + ]).then( + ({ stderr }) => ({ code: 0, stderr: stderr || '' }), + (e) => ({ code: e.code, stderr: String(e.stderr || '') }) + ); + +it('propagates a zero exit code', async () => { + const { code } = await driver( + "spawnWithExitCode({ args: ['-e', 'process.exit(0)'], command: 'node' });" + ).then( + (r) => r, + (e) => e + ); + + expect(code).toBe(0); +}); + +it('propagates a non-zero exit code', async () => { + const { code } = await driver( + "spawnWithExitCode({ args: ['-e', 'process.exit(3)'], command: 'node' });" + ).then( + (r) => r, + (e) => e + ); + + expect(code).toBe(3); +}); + +it('reports a signal death as a failure (not success)', async () => { + const { code } = await driver( + "spawnWithExitCode({ args: ['-e', 'process.kill(process.pid, 'SIGKILL')'], command: 'node' });" + ).then( + (r) => r, + (e) => e + ); + + expect(code).toBe(1); +}); + +it('reports a command that cannot spawn instead of crashing', async () => { + const { code, stderr } = await driver( + "spawnWithExitCode({ args: [], command: 'c-not-a-real-command-xyz' });" + ).then( + (r) => r, + (e) => e + ); + + expect(code).toBe(1); + expect(stderr).toContain("Could not run 'c-not-a-real-command-xyz'"); +}); From 5ec1bd0b4d60d8c967f10b7419c107084ec45347 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:12:45 +1030 Subject: [PATCH 33/52] Bridged stdin-bearing op calls through a shell pipe op can detect. --- .github/workflows/test.yaml | 32 +++++++ .npmignore | 1 + bin/c-op-cmd.js | 20 +++-- bin/lib/c-secrets-io.js | 20 ++++- test/c-secrets-io-stdin.test.js | 148 ++++++++++++++++++++++++++++++++ test/op-stdin-detection.test.js | 81 +++++++++++++++++ 6 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/test.yaml create mode 100644 test/c-secrets-io-stdin.test.js create mode 100644 test/op-stdin-detection.test.js diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..182f314 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,32 @@ +name: Test + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + cache: npm + node-version: 22 + + # The real 1Password CLI for the op stdin-detection tests - no + # session or account is needed (op validates stdin before + # authenticating). Pinned so the detection-error expectations + # stay stable; bump deliberately alongside a local check. + - name: Install 1Password CLI + run: | + curl -sSfL "https://cache.agilebits.com/dist/1P/op2/pkg/v2.39.0/op_linux_amd64_v2.39.0.zip" -o op.zip + unzip -o -d op op.zip + install -m 755 op/op /usr/local/bin/op + op --version + + - run: npm ci + + - run: npm test diff --git a/.npmignore b/.npmignore index 47545a0..58524c8 100644 --- a/.npmignore +++ b/.npmignore @@ -1,4 +1,5 @@ .editorconfig .eslintrc +.github test vitest.config.mjs diff --git a/bin/c-op-cmd.js b/bin/c-op-cmd.js index 5bc691b..072756c 100644 --- a/bin/c-op-cmd.js +++ b/bin/c-op-cmd.js @@ -16,11 +16,21 @@ try { // Fail fast with a friendly error before handing over to op. assertOpSession(); - // Array form (no shell) so values never round-trip through string - // interpolation. - spawnWithExitCode({ - args: ['--session', opSession()].concat(program.rawArgs.slice(2)), - command: 'op', + const opArgs = ['--session', opSession()].concat(program.rawArgs.slice(2)); + + // Programmatic callers pipe stdin from Node, whose pipe stdio is an + // AF_UNIX socket - op only detects input on real pipe(2) FIFOs. When + // stdin is not a terminal, bridge it through a shell pipe: cat reads + // the socket fine and writes into a genuine pipe op accepts. The + // command lives in the shell string and arguments pass verbatim + // through "$@", so no caller input is interpolated. + if (process.stdin.isTTY) { + return spawnWithExitCode({ args: opArgs, command: 'op' }); + } + + return spawnWithExitCode({ + args: ['-c', 'cat | op "$@"', 'sh'].concat(opArgs), + command: 'sh', }); } catch (e) { reportError(e, false, true); diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index 1649520..062997d 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -51,14 +51,32 @@ const opSession = () => { /** * Run the op cli with the session wired in. Input/values pass via stdin or * in-memory buffers, never via printed output. + * + * Stdin-bearing calls route through a shell pipe: Node's pipe stdio hands + * children AF_UNIX sockets, not pipe(2) FIFOs, and op only detects piped + * input on real FIFOs - piped straight in, op reports `expected data on + * stdin but none found` or reads nothing. cat reads the socket fine and + * writes into a genuine pipe op accepts. The command lives in the shell + * string and arguments pass verbatim through "$@", so no value is ever + * interpolated into a command line. * @param {Object} options * @param {Array} options.args The op arguments. * @param {String} [options.input] Stdin to pipe into op. * @returns {String} The command's stdout. */ const runOp = ({ args, input }) => { + const opArgs = ['--session', opSession()].concat(args); + + const invocation = + typeof input === 'string' + ? { + args: ['-c', 'cat | op "$@"', 'sh'].concat(opArgs), + command: 'sh', + } + : { args: opArgs, command: 'op' }; + try { - return execFileSync('op', ['--session', opSession()].concat(args), { + return execFileSync(invocation.command, invocation.args, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/test/c-secrets-io-stdin.test.js b/test/c-secrets-io-stdin.test.js new file mode 100644 index 0000000..8978298 --- /dev/null +++ b/test/c-secrets-io-stdin.test.js @@ -0,0 +1,148 @@ +'use strict'; + +// Tests for the op stdin bridge: Node's pipe stdio hands children AF_UNIX +// sockets, and op only detects piped input on real pipe(2) FIFOs - so +// stdin-bearing op calls (runOp with input, and `c op cmd` when its stdin +// is piped) must route through a shell pipe (cat | op). These tests use a +// fake op that records whether its stdin was a FIFO and what it received. + +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { fileURLToPath } = require('node:url'); + +const io = require('../bin/lib/c-secrets-io.js'); + +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); + +const FAKE_OP = `#!/bin/sh +# Fake 1Password CLI for stdin-bridge tests. Records whether stdin was a +# real FIFO (the property op's own detection depends on) and its contents. +LOG="$OP_FAKE_LOG" +echo "$@" >> "$LOG" + +case "$*" in + *"whoami"*) + echo '{}' + ;; + *"stdincheck"*) + if [ -p /dev/stdin ]; then + echo "STDIN-FIFO" >> "$LOG" + else + echo "STDIN-NOTFIFO" >> "$LOG" + fi + cat >> "$LOG" + echo "" >> "$LOG" + echo "stdin-ok" + ;; + *) + echo "fake op: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +let dir; +let log; + +const ENV_KEYS = ['OP_FAKE_LOG', 'OP_SESSION_idearium', 'PATH']; + +let savedEnv; + +beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-op-stdin-')); + log = path.join(dir, 'op.log'); + + fs.writeFileSync(path.join(dir, 'op'), FAKE_OP, { mode: 0o755 }); + + savedEnv = {}; + + ENV_KEYS.forEach((key) => { + savedEnv[key] = { + present: key in process.env, + value: process.env[key], + }; + }); + + process.env.PATH = `${dir}:${process.env.PATH}`; + process.env.OP_SESSION_idearium = 'faketoken'; + process.env.OP_FAKE_LOG = log; +}); + +afterAll(() => { + ENV_KEYS.forEach((key) => { + if (savedEnv[key].present) { + process.env[key] = savedEnv[key].value; + } else { + delete process.env[key]; + } + }); + + fs.rmSync(dir, { force: true, recursive: true }); +}); + +const freshLog = () => fs.writeFileSync(log, ''); +const logText = () => fs.readFileSync(log, 'utf8'); + +// Spawn a command, write input to its stdin, and resolve with its exit +// code and captured output. +const spawnCaptured = ({ args, command, input }) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => { + resolve({ code, stderr, stdout }); + }); + + child.stdin.end(input); + }); + +it('runOp delivers piped input to op over a real FIFO', () => { + freshLog(); + + const output = io.runOp({ args: ['stdincheck'], input: 'payload-data' }); + + expect(output).toContain('stdin-ok'); + + const text = logText(); + + expect(text).toContain('stdincheck'); + // The regression property: op's stdin must be a pipe(2) FIFO, not a + // Node socketpair - op cannot detect input on the latter. + expect(text).toContain('STDIN-FIFO'); + expect(text).not.toContain('STDIN-NOTFIFO'); + expect(text).toContain('payload-data'); +}); + +it('c op cmd bridges piped stdin into op over a real FIFO', async () => { + freshLog(); + + const result = await spawnCaptured({ + args: [path.join(REPO_ROOT, 'bin', 'c-op-cmd.js'), 'stdincheck'], + command: 'node', + input: 'bridge-payload', + }); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('stdin-ok'); + + const text = logText(); + + expect(text).toContain('STDIN-FIFO'); + expect(text).toContain('bridge-payload'); +}); diff --git a/test/op-stdin-detection.test.js b/test/op-stdin-detection.test.js new file mode 100644 index 0000000..4f785e3 --- /dev/null +++ b/test/op-stdin-detection.test.js @@ -0,0 +1,81 @@ +'use strict'; + +// Detection tests against the real `op` binary, with no 1Password +// account: op validates piped stdin before authentication, so the two +// failure modes are distinguishable without a session. These tests pin +// the very behaviour the stdin bridge exists for - if op ever accepts +// socket stdin, they fail and the bridge can be retired. +// +// Skipped automatically when `op` is not installed. GitHub CI installs a +// pinned op; see .github/workflows/test.yaml. + +import { beforeAll, afterAll, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const hasOp = (() => { + try { + execFileSync('op', ['--version'], { stdio: 'ignore' }); + + return true; + } catch (e) { + return false; + } +})(); + +let home; + +beforeAll(() => { + // A sandboxed HOME guarantees no configured account or session can + // interfere: op stops at the (unauthenticated) desktop-app error. + home = fs.mkdtempSync(path.join(os.tmpdir(), 'op-detection-')); +}); + +afterAll(() => { + fs.rmSync(home, { force: true, recursive: true }); +}); + +// Run op with the given command shape and 'payload' on stdin, capturing +// stderr (op exits non-zero in both shapes - on its auth error). +const attempt = ({ args, command }) => { + try { + execFileSync(command, args, { + encoding: 'utf8', + env: { HOME: home, PATH: process.env.PATH }, + input: 'not-a-real-template', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + return ''; + } catch (e) { + return String(e.stderr); + } +}; + +it.skipIf(!hasOp)( + 'real op cannot detect Node socket stdin (the bug the bridge fixes)', + () => { + const stderr = attempt({ args: ['inject'], command: 'op' }); + + // Piped straight from Node, op's stdin is an AF_UNIX socket: + // op sees no piped data at all. + expect(stderr).toContain('expected data on stdin'); + } +); + +it.skipIf(!hasOp)('real op detects stdin bridged through a shell pipe', () => { + const stderr = attempt({ + args: ['-c', 'cat | op "$@"', 'sh', 'inject'], + command: 'sh', + }); + + // Through cat | op the stdin check passes; without an account op + // fails later on sign-in instead - never on stdin detection. + expect(stderr).not.toContain('expected data on stdin'); + expect(stderr.length).toBeGreaterThan(0); +}); From a2d1f6af946e9d7aa9aaa2b755e6c740f8174708 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:25:21 +1030 Subject: [PATCH 34/52] 6.2.0-beta.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7e025d..ffefa47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.5", + "version": "6.2.0-beta.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.2.0-beta.5", + "version": "6.2.0-beta.6", "license": "MIT", "dependencies": { "chalk": "4.1.2", diff --git a/package.json b/package.json index 8a92d43..d1614d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.5", + "version": "6.2.0-beta.6", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": { From e65c46e8210ddd95e10755d7ec299b69828cad89 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:09:17 +1030 Subject: [PATCH 35/52] Sanitized op failure messages so the session token never leaks. --- bin/lib/c-secrets-io.js | 18 ++++++++++-- test/c-secrets-io-resolve.test.js | 46 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index 062997d..74e11fc 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -65,7 +65,8 @@ const opSession = () => { * @returns {String} The command's stdout. */ const runOp = ({ args, input }) => { - const opArgs = ['--session', opSession()].concat(args); + const session = opSession(); + const opArgs = ['--session', session].concat(args); const invocation = typeof input === 'string' @@ -93,7 +94,20 @@ const runOp = ({ args, input }) => { ); } - throw e; + // Never rethrow the raw error: its message embeds the full + // command line, session token included. Build the message from + // safe parts only - the op arguments (references, not values), + // the exit status, and op's own stderr (scrubbed of the token, + // in case op ever echoes its command line back). + const detail = stderr.trim().split(session).join('***'); + const safeDetail = + detail || + (e.code === 'ENOENT' + ? 'the op cli is not installed' + : e.signal || 'unknown error'); + const status = Number.isInteger(e.status) ? ` (exit ${e.status})` : ''; + + throw new Error(`op ${args.join(' ')} failed${status}: ${safeDetail}`); } }; diff --git a/test/c-secrets-io-resolve.test.js b/test/c-secrets-io-resolve.test.js index 262bbd5..8cd470f 100644 --- a/test/c-secrets-io-resolve.test.js +++ b/test/c-secrets-io-resolve.test.js @@ -36,6 +36,14 @@ case "$*" in *"item create"*) echo '{"id":"created-item-id"}' ;; + *"boom"*) + echo 'boom said no' >&2 + exit 1 + ;; + *"expired"*) + echo 'not currently signed in' >&2 + exit 1 + ;; *) echo "fake op: unsupported invocation: $*" >&2 exit 1 @@ -206,3 +214,41 @@ it('creates an item for a brand-new service (null owner)', () => { expect(createdItems()).toBe(true); }); + +it('sanitizes op failures so the session token never leaks', () => { + let message = 'did not throw'; + + try { + io.runOp({ args: ['boom'] }); + } catch ({ message: thrown }) { + message = thrown; + } + + expect(message).toContain('boom'); + expect(message).toContain('boom said no'); + expect(message).toContain('exit 1'); + expect(message).not.toContain('faketoken'); + expect(message).not.toContain('--session'); +}); + +it('scrubs the session token from op stderr before failing', () => { + // The fake's catch-all echoes its full invocation (token included) + // to stderr - the built error must scrub it. + let message = 'did not throw'; + + try { + io.runOp({ args: ['definitely-not-a-command'] }); + } catch ({ message: thrown }) { + message = thrown; + } + + expect(message).not.toContain('faketoken'); + expect(message).toContain('***'); +}); + +it('maps session-expiry failures to the friendly message', () => { + expectThrowsWith( + () => io.runOp({ args: ['expired'] }), + 'session has expired' + ); +}); From 36578c95397a0a43440aa3579f83ded54c5853b9 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:09:31 +1030 Subject: [PATCH 36/52] Ran kubectl flags-first with an argv array and exit codes in c kc cmd. --- bin/c-kc-cmd.js | 44 +++++++------- test/c-kc-cmd.test.js | 136 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 23 deletions(-) create mode 100644 test/c-kc-cmd.test.js diff --git a/bin/c-kc-cmd.js b/bin/c-kc-cmd.js index 92df2ff..f47405e 100644 --- a/bin/c-kc-cmd.js +++ b/bin/c-kc-cmd.js @@ -1,10 +1,14 @@ -#!/usr/bin/env -S node 'use strict'; const program = require('commander'); const getPropertyPath = require('get-value'); -const { spawn } = require('child_process'); -const { loadConfig, loadState, reportError } = require('./lib/c'); + +const { + loadConfig, + loadState, + reportError, + spawnWithExitCode, +} = require('./lib/c'); const { formatProjectPrefix } = require('./lib/c-project'); program @@ -26,26 +30,20 @@ return Promise.all([loadState('env'), loadConfig()]) `kubernetes.environments.${env}.namespace` ) || formatProjectPrefix(organisation, name, env, true, true); - // Remove the first two entries. - const parts = program.rawArgs.slice(2); - - // The command starts with kubectrl. - parts.unshift('kubectl'); - - // Mandatory namespace and context. - parts.push( - '--namespace', - namespace, - '--context', - environments[env].context - ); - - // The rest of the command. - const cmd = parts.join(' '); - - spawn(cmd, { - shell: true, - stdio: 'inherit', + // The context and namespace flags precede the user's command: + // anything after kubectl's -- would otherwise be executed inside + // the pod, letting kubectl fall back to the ambient context and + // namespace. The argv is an array (never a shell string), so + // nothing is re-parsed or interpolated, and the child's exit code + // propagates. + return spawnWithExitCode({ + args: [ + '--context', + environments[env].context, + '--namespace', + namespace, + ].concat(program.rawArgs.slice(2)), + command: 'kubectl', }); }) .catch((err) => reportError(err, false, true)); diff --git a/test/c-kc-cmd.test.js b/test/c-kc-cmd.test.js new file mode 100644 index 0000000..c79548f --- /dev/null +++ b/test/c-kc-cmd.test.js @@ -0,0 +1,136 @@ +'use strict'; + +// Integration tests for c-kc-cmd: the kubectl invocation must put the +// --context/--namespace flags immediately after kubectl (never after the +// user's --), pass the argv as an array rather than a shell string, and +// propagate the child's exit code. + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); + +const FAKE_KUBECTL = `#!/bin/sh +# Fake kubectl for offline tests of c-kc-cmd: logs its argv verbatim. +echo "$@" >> "$KUBECTL_FAKE_LOG" + +if [ -n "$KUBECTL_FAKE_EXIT" ]; then + exit "$KUBECTL_FAKE_EXIT" +fi + +echo 'kubectl-fake-ok' +`; + +const FIXTURE_CJS = `module.exports = { + project: { + organisation: 'idearium', + name: 'demo', + }, + kubernetes: { + environments: { + beta: { + context: 'gke_fake_beta', + namespace: 'custom-ns', + locations: {}, + }, + }, + }, +}; +`; + +// Build a fixture directory holding the fake kubectl, a c.js with a +// kubernetes environment (namespace override included) and the state +// file selecting it. +const makeFixture = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'c-kc-cmd-')); + + fs.writeFileSync(path.join(dir, 'kubectl'), FAKE_KUBECTL, { mode: 0o755 }); + fs.writeFileSync(path.join(dir, 'c.js'), FIXTURE_CJS); + fs.mkdirSync(path.join(dir, 'devops')); + fs.writeFileSync(path.join(dir, 'devops', 'state.json'), '{"env":"beta"}'); + + return dir; +}; + +const runCommand = ({ cwd, env, extraEnv }) => + new Promise((resolve, reject) => { + const child = spawn( + 'node', + [ + path.join(REPO_ROOT, 'bin', 'c-kc-cmd.js'), + 'exec', + 'deploy/site', + '--', + 'env', + ], + { + cwd, + env: Object.assign({}, process.env, env, extraEnv), + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => { + resolve({ code, stderr, stdout }); + }); + }); + +it('places --context/--namespace before the user command, keeping -- verbatim', async () => { + const dir = makeFixture(); + const log = path.join(dir, 'kubectl.log'); + + try { + const result = await runCommand({ + cwd: dir, + env: { + KUBECTL_FAKE_LOG: log, + PATH: `${dir}:${process.env.PATH}`, + }, + }); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('kubectl-fake-ok'); + expect(fs.readFileSync(log, 'utf8').trim()).toBe( + '--context gke_fake_beta --namespace custom-ns exec deploy/site -- env' + ); + } finally { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +it('propagates the kubectl exit code', async () => { + const dir = makeFixture(); + const log = path.join(dir, 'kubectl.log'); + + try { + const result = await runCommand({ + cwd: dir, + env: { + KUBECTL_FAKE_LOG: log, + PATH: `${dir}:${process.env.PATH}`, + }, + extraEnv: { KUBECTL_FAKE_EXIT: '3' }, + }); + + expect(result.code).toBe(3); + } finally { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); From 8abcda5f039ed7775da727d99c55ba8766a97bcf Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:09:31 +1030 Subject: [PATCH 37/52] Guarded the c.js editors against empty multi-line entry blocks. --- bin/lib/c-secrets-io.js | 17 +++++++++++++++- test/cjs-editors.test.js | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index 74e11fc..052de1d 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -1157,9 +1157,24 @@ const insertPropertyIntoObject = ({ line, object: obj, text }) => { const lineEnding = lineEndingOf({ text }); const [firstProperty] = obj.properties; + // An object's own line ends in 'name: {' - its indent is the line's + // leading whitespace, not the text before the brace. + const [objectIndent] = /^\s*/.exec( + text.slice(lineStartIndex({ index: obj.range[0], text })) + ); const propertyIndent = firstProperty ? indentBefore({ index: firstProperty.range[0], text }) - : `${indentBefore({ index: obj.range[0], text })} `; + : `${objectIndent} `; + + // An empty object (even written across lines) takes its first + // property between the braces. + if (obj.properties.length === 0) { + return ( + text.slice(0, obj.range[0] + 1) + + `${lineEnding}${propertyIndent}${line}${lineEnding}${closingIndent}` + + text.slice(closeIndex) + ); + } const lastProperty = obj.properties[obj.properties.length - 1]; const commaFix = text.slice(lastProperty.range[1], closeIndex).includes(',') diff --git a/test/cjs-editors.test.js b/test/cjs-editors.test.js index cffd84e..149c7cd 100644 --- a/test/cjs-editors.test.js +++ b/test/cjs-editors.test.js @@ -361,6 +361,50 @@ withFixture( } ); +withFixture( + 'addKeyToEntryInCjs handles a multi-line empty service block', + `${preamble()} services: { + site: { + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.addKeyToEntryInCjs({ key: 'RAS_API_KEY', name: 'site' }); + + expect(loadCjs().secrets.services.site.keys).toStrictEqual([ + 'RAS_API_KEY', + ]); + expect(readCjs()).toContain(` site: { + keys: ['RAS_API_KEY'], + },`); + expect(isPrettierClean(readCjs())).toBe(true); + } +); + +withFixture( + 'setOpItemInCjs handles a multi-line empty service block', + `${preamble()} services: { + site: { + }, + }, + shared: { + keys: ['IMGIX_TOKEN'], + }, +${closing()}`, + () => { + io.setOpItemInCjs({ name: 'site', opItem: 'bbbb' }); + + expect(loadCjs().secrets.services.site.opItem).toBe('bbbb'); + expect(readCjs()).toContain(` site: { + opItem: 'bbbb', + },`); + expect(isPrettierClean(readCjs())).toBe(true); + } +); + withFixture( 'setOpItemInCjs binds an absent opItem', `${preamble()} services: { From a2679d9e5c3facea9af0d8672f4bff4a82d61317 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:09:32 +1030 Subject: [PATCH 38/52] Validated contract key characters. --- bin/lib/c-secrets.js | 21 ++++-- test/c-secrets-contract.test.js | 109 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 test/c-secrets-contract.test.js diff --git a/bin/lib/c-secrets.js b/bin/lib/c-secrets.js index 8e3e670..193622a 100644 --- a/bin/lib/c-secrets.js +++ b/bin/lib/c-secrets.js @@ -72,12 +72,23 @@ const assertKeyArray = ({ allowDotted, message, value }) => { throw new Error(`${message}: entries must be non-empty strings`); } - if (entry.includes('/') || entry.includes(' ')) { - throw new Error(`${message}: entries must not contain '/' or ' '`); - } + // Keys become GSM secret ids (via kebabCase) that may only hold + // letters, numbers, underscores and hyphens - anything else would + // corrupt the derived id and its REST path. Dots exist only in + // the dotted 'owner.KEY' sharedKeys form (owners are kebab-case + // service names). + const valid = allowDotted + ? /^[A-Za-z0-9_.-]+$/.test(entry) + : /^[A-Za-z0-9_-]+$/.test(entry); + + if (!valid) { + const hint = entry.includes('.') + ? " - dots are only valid in the dotted 'owner.KEY' sharedKeys form" + : ''; - if (entry.includes('.') && !allowDotted) { - throw new Error(`${message}: dotted entries are not valid here`); + throw new Error( + `${message}: entries must contain only letters, numbers, underscores and hyphens${hint}` + ); } }); diff --git a/test/c-secrets-contract.test.js b/test/c-secrets-contract.test.js new file mode 100644 index 0000000..1221aa5 --- /dev/null +++ b/test/c-secrets-contract.test.js @@ -0,0 +1,109 @@ +'use strict'; + +// Validation tests for the secrets contract itself (getSecretsContract): +// in particular the key charset - keys become GSM secret ids that may +// only hold letters, numbers, underscores and hyphens, so anything else +// must fail at load with a friendly message. + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +const { getSecretsContract } = require('../bin/lib/c-secrets.js'); + +const base = () => ({ + gcloud: { region: 'australia-southeast1' }, + project: { + gcpProjectId: 'demo-proj', + name: 'demo', + organisation: 'idearium', + }, +}); + +const contractWith = (secrets) => + getSecretsContract({ + config: Object.assign(base(), { secrets }), + }); + +const messageOf = (fn) => { + try { + fn(); + } catch ({ message }) { + return message; + } + + return 'did not throw'; +}; + +it('accepts letters, numbers and underscores in keys', () => { + const contract = contractWith({ + services: { + site: { keys: ['CACHE_URL', 'RAS_API_KEY_2'], sharedKeys: [] }, + }, + shared: { keys: ['IMGIX_TOKEN'] }, + }); + + expect(contract.services.site.keys).toStrictEqual([ + 'CACHE_URL', + 'RAS_API_KEY_2', + ]); +}); + +it('rejects keys with characters outside the GSM id alphabet', () => { + expect( + messageOf(() => + contractWith({ services: { site: { keys: ['FOO#1'] } } }) + ) + ).toContain('letters, numbers, underscores and hyphens'); + + expect( + messageOf(() => + contractWith({ services: { site: { keys: ['FOO$BAR'] } } }) + ) + ).toContain('letters, numbers, underscores and hyphens'); + + expect( + messageOf(() => + contractWith({ + services: { site: { keys: [] } }, + shared: { keys: ['A]B'] }, + }) + ) + ).toContain('letters, numbers, underscores and hyphens'); +}); + +it('rejects dots outside the dotted sharedKeys form', () => { + expect( + messageOf(() => contractWith({ services: { site: { keys: ['A.B'] } } })) + ).toContain('dots are only valid'); + + expect( + messageOf(() => + contractWith({ + services: { + site: { keys: [], sharedKeys: ['owner.FOO#1'] }, + }, + }) + ) + ).toContain('dots are only valid'); +}); + +it('accepts the dotted sharedKeys form for valid keys', () => { + const contract = contractWith({ + services: { + 'other-service': { keys: ['IMGIX_TOKEN'], sharedKeys: [] }, + 'site': { keys: [], sharedKeys: ['other-service.IMGIX_TOKEN'] }, + }, + }); + + expect(contract.services.site.sharedKeys[0].key).toBe('IMGIX_TOKEN'); +}); + +it('accepts hyphenated keys', () => { + const contract = contractWith({ + services: { site: { keys: ['FOO-BAR'], sharedKeys: [] } }, + }); + + expect(contract.services.site.keys).toStrictEqual(['FOO-BAR']); +}); From 2df59521c750bd8e4d881a84b933f6023f75c484 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:09:32 +1030 Subject: [PATCH 39/52] Replaced else branches with early returns. --- bin/c-op-set.js | 4 ++- bin/c-secrets-init.js | 14 ++++++--- bin/lib/c-secrets-init.js | 65 ++++++++++++++++++++++----------------- 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/bin/c-op-set.js b/bin/c-op-set.js index 71c985b..851319f 100644 --- a/bin/c-op-set.js +++ b/bin/c-op-set.js @@ -81,7 +81,9 @@ return loadConfig() binding.id })` ); - } else if (binding.persisted) { + } + + if (!binding.created && binding.persisted) { print( `bound existing 1Password item ${title(serviceName)} (${ binding.id diff --git a/bin/c-secrets-init.js b/bin/c-secrets-init.js index 7b84de5..0d555b5 100644 --- a/bin/c-secrets-init.js +++ b/bin/c-secrets-init.js @@ -40,6 +40,12 @@ return loadConfig() // regional resourceNames or the kubectl context). const gcloud = config.gcloud || {}; + if (!gcloud.region && !draft.region) { + throw new Error( + 'Could not derive a region for this project (no regional resourceNames, no gke_ kubectl context) - add gcloud.region to c.js, then re-run.' + ); + } + if (!gcloud.region && draft.region) { const gcloudMatch = text.match(/\n(\s*)gcloud:\s*\{/); @@ -48,7 +54,9 @@ return loadConfig() gcloudMatch[0], `${gcloudMatch[0]}\n${gcloudMatch[1]} region: '${draft.region}',` ); - } else { + } + + if (!gcloudMatch) { // No gcloud block at all: add one alongside the secrets // block (projectId from the project configuration). draft.blockText = [ @@ -59,10 +67,6 @@ return loadConfig() draft.blockText, ].join('\n'); } - } else if (!gcloud.region && !draft.region) { - throw new Error( - 'Could not derive a region for this project (no regional resourceNames, no gke_ kubectl context) - add gcloud.region to c.js, then re-run.' - ); } // Insert the secrets block before the module's closing brace. diff --git a/bin/lib/c-secrets-init.js b/bin/lib/c-secrets-init.js index e7bbf06..6be6430 100644 --- a/bin/lib/c-secrets-init.js +++ b/bin/lib/c-secrets-init.js @@ -208,7 +208,11 @@ const minimalNotes = ({ itemPrefix, opScan }) => { if (!opScan.names) { notes.push(`1Password scan skipped (${opScan.error}).`); - } else if (Object.keys(opScan.names).length === 0) { + + return notes; + } + + if (Object.keys(opScan.names).length === 0) { notes.push(`1Password scan found no '${itemPrefix}' items.`); } @@ -520,6 +524,12 @@ const draftContract = async ({ config }) => { const lines = []; lines.push(' secrets: {'); + if (!devItemFromArgs) { + notes.push( + 'No dev consumers found in docker build args - add secrets.dev.consumers if build args need secrets.' + ); + } + if (devItemFromArgs) { lines.push(' dev: {'); lines.push(` opItem: ${quote(devItemFromArgs)},`); @@ -539,10 +549,6 @@ const draftContract = async ({ config }) => { }); lines.push(' },'); lines.push(' },'); - } else { - notes.push( - 'No dev consumers found in docker build args - add secrets.dev.consumers if build args need secrets.' - ); } // Value-verified legacy dedupe: a service-owned key that is also a @@ -793,17 +799,20 @@ const draftContract = async ({ config }) => { lines.push(`${indent} opItem: ${quote(opItem)},`); } - if (keys.length === 0) { - lines.push(`${indent} keys: [],`); - } else { - lines.push(`${indent} keys: [`); - - keys.sort().forEach((key) => { - lines.push(`${indent} ${quote(key)},`); - }); - - lines.push(`${indent} ],`); - } + // Keys render as a single empty array or one-per-line, matching + // the init style. + const keysLines = + keys.length === 0 + ? [`${indent} keys: [],`] + : [`${indent} keys: [`] + .concat( + keys + .sort() + .map((key) => `${indent} ${quote(key)},`) + ) + .concat([`${indent} ],`]); + + lines.push(...keysLines); if (sharedKeys.length > 0) { lines.push( @@ -850,17 +859,15 @@ const draftContract = async ({ config }) => { const keyList = shared.keys.join(', '); const firstExample = shared.examples[shared.keys[0]]; - if (shared.opItem) { - notes.push( - `Drafted 'shared' for keys whose GSM ids carry no service name: ${keyList} (ids like ${firstExample}). shared.opItem is set to the '${boundTo}' 1Password item, which already holds the value (matched by comparing value hashes across ${boundEnvs.join( - ', ' - )}) - nothing needs to move in 1Password. Every service that uses ${keyList} lists it under sharedKeys - not keys.` - ); - } else { - notes.push( - `Drafted 'shared' for keys whose GSM ids carry no service name: ${keyList} (ids like ${firstExample}). Set shared.opItem to the 1Password item that holds the value - it is never created automatically. Every service that uses ${keyList} lists it under sharedKeys - not keys.` - ); - } + const opItemNote = shared.opItem + ? `shared.opItem is set to the '${boundTo}' 1Password item, which already holds the value (matched by comparing value hashes across ${boundEnvs.join( + ', ' + )}) - nothing needs to move in 1Password.` + : 'Set shared.opItem to the 1Password item that holds the value - it is never created automatically.'; + + notes.push( + `Drafted 'shared' for keys whose GSM ids carry no service name: ${keyList} (ids like ${firstExample}). ${opItemNote} Every service that uses ${keyList} lists it under sharedKeys - not keys.` + ); deduped.forEach(({ example, key, service }) => { notes.push( @@ -960,7 +967,9 @@ const draftContract = async ({ config }) => { notes.push( `1Password scan skipped (${opScan.error}) - services without a file-derived opItem are left unbound; they resolve (or create) their 1Password item automatically on first use via \`c op set\` / \`c secrets add\`.` ); - } else if (Object.keys(opScan.names).length === 0) { + } + + if (opScan.names && Object.keys(opScan.names).length === 0) { notes.push( `1Password scan found no '${itemPrefix}' items - services without a file-derived opItem are left unbound; they resolve (or create) their 1Password item automatically on first use via \`c op set\` / \`c secrets add\`.` ); From 4de87aa16856619d1961e9588bc4bc9404e21652 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:12:25 +1030 Subject: [PATCH 40/52] 6.2.0-beta.7 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ffefa47..dfdb260 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.6", + "version": "6.2.0-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.2.0-beta.6", + "version": "6.2.0-beta.7", "license": "MIT", "dependencies": { "chalk": "4.1.2", diff --git a/package.json b/package.json index d1614d5..33f5591 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.6", + "version": "6.2.0-beta.7", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": { From 91a8ba38c61b037e9699bc69a251b8d613687e1f Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:23:26 +1030 Subject: [PATCH 41/52] Rejected sharedKeys entries with more than one dot. --- bin/lib/c-secrets.js | 13 +++++++++++++ test/c-secrets-contract.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/bin/lib/c-secrets.js b/bin/lib/c-secrets.js index 193622a..31aa7e0 100644 --- a/bin/lib/c-secrets.js +++ b/bin/lib/c-secrets.js @@ -90,6 +90,19 @@ const assertKeyArray = ({ allowDotted, message, value }) => { `${message}: entries must contain only letters, numbers, underscores and hyphens${hint}` ); } + + // The dotted form is exactly 'owner.KEY': a second dot would + // silently truncate on split (the tail is discarded), turning a + // typo like 'services.site.KEY' into something it never said. + if ( + allowDotted && + entry.includes('.') && + entry.split('.').length !== 2 + ) { + throw new Error( + `${message}: dotted entries must be the 'owner.KEY' form (exactly one dot)` + ); + } }); if (new Set(value).size !== value.length) { diff --git a/test/c-secrets-contract.test.js b/test/c-secrets-contract.test.js index 1221aa5..cc634de 100644 --- a/test/c-secrets-contract.test.js +++ b/test/c-secrets-contract.test.js @@ -89,6 +89,30 @@ it('rejects dots outside the dotted sharedKeys form', () => { ).toContain('dots are only valid'); }); +it('rejects dotted sharedKeys with more than one dot', () => { + // A second dot would silently truncate on split - 'a.b.c' would mean + // owner 'a', key 'b'. + expect( + messageOf(() => + contractWith({ + services: { site: { keys: [], sharedKeys: ['a.b.c'] } }, + }) + ) + ).toContain('exactly one dot'); + + // The likely real-world typo: mixing up the sharedKeys form with the + // opItem reference form. + expect( + messageOf(() => + contractWith({ + services: { + site: { keys: [], sharedKeys: ['services.site.KEY'] }, + }, + }) + ) + ).toContain('exactly one dot'); +}); + it('accepts the dotted sharedKeys form for valid keys', () => { const contract = contractWith({ services: { From 492a6a77086f6ea7169035574705024ab83f6e9d Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:35:09 +1030 Subject: [PATCH 42/52] Encoded the dynamic segments of Google Secret Manager URLs. --- bin/lib/c-secrets-io.js | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index 052de1d..d72f29e 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -462,7 +462,10 @@ const gsmRequest = ({ body, method, path, project }) => }, host: 'secretmanager.googleapis.com', method, - path: `/v1/projects/${project}${path}`, + // Every dynamic segment is encoded at its call site: key + // characters are validated at contract load, but org, + // repo, env and service names are user-authored. + path: `/v1/projects/${encodeURIComponent(project)}${path}`, timeout: GSM_TIMEOUT_MS, }, (res) => { @@ -517,15 +520,17 @@ const gsmRequest = ({ body, method, path, project }) => * @returns {Promise} Resolves with the secret resource, or null. */ const gsmDescribe = ({ id, project }) => - gsmRequest({ method: 'GET', path: `/secrets/${id}`, project }).catch( - (e) => { - if (e.message.includes('(404)')) { - return null; - } - - throw e; + gsmRequest({ + method: 'GET', + path: `/secrets/${encodeURIComponent(id)}`, + project, + }).catch((e) => { + if (e.message.includes('(404)')) { + return null; } - ); + + throw e; + }); /** * Create a pinned-global GSM secret with the standard labels. @@ -553,7 +558,7 @@ const gsmCreate = ({ env, id, managedBy, project, region, service }) => }, }, method: 'POST', - path: `/secrets?secretId=${id}`, + path: `/secrets?secretId=${encodeURIComponent(id)}`, project, }); @@ -571,7 +576,7 @@ const gsmAddVersion = ({ id, project, value }) => payload: { data: Buffer.from(value, 'utf8').toString('base64') }, }, method: 'POST', - path: `/secrets/${id}:addVersion`, + path: `/secrets/${encodeURIComponent(id)}:addVersion`, project, }); @@ -586,7 +591,7 @@ const gsmAddVersion = ({ id, project, value }) => const gsmRead = ({ id, project }) => gsmRequest({ method: 'GET', - path: `/secrets/${id}/versions/latest:access`, + path: `/secrets/${encodeURIComponent(id)}/versions/latest:access`, project, }).then((res) => Buffer.from(res.payload.data, 'base64')); @@ -599,7 +604,11 @@ const gsmRead = ({ id, project }) => * @returns {Promise} */ const gsmDelete = ({ id, project }) => - gsmRequest({ method: 'DELETE', path: `/secrets/${id}`, project }); + gsmRequest({ + method: 'DELETE', + path: `/secrets/${encodeURIComponent(id)}`, + project, + }); /** * List every GSM secret id in the project. @@ -613,7 +622,9 @@ const gsmList = ({ project }) => { const page = (pageToken) => gsmRequest({ method: 'GET', - path: `/secrets${pageToken ? `?pageToken=${pageToken}` : ''}`, + path: `/secrets${ + pageToken ? `?pageToken=${encodeURIComponent(pageToken)}` : '' + }`, project, }).then((res) => { (res.secrets || []).forEach((secret) => { From f409b4d27acd48a3d76a56039c8537b3716d5881 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:38:13 +1030 Subject: [PATCH 43/52] Guarded against unparseable Google Secret Manager responses. --- bin/lib/c-secrets-io.js | 56 +++++++++++++++++++++----- test/c-secrets-io-parse.test.js | 70 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 test/c-secrets-io-parse.test.js diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index d72f29e..b1059c8 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -450,6 +450,42 @@ const assertGcloudAuth = () => { * @param {String} options.project The GCP project id. * @returns {Promise} Resolves with the parsed JSON response. */ +/** + * Parse a Google Secret Manager response body: friendly errors for the + * failure and unparseable-success cases, the parsed JSON otherwise. + * gsmRequest's response handler runs this inside a try/catch so a throw + * rejects the request promise instead of crashing the process inside an + * event handler. + * @param {Object} options + * @param {String} options.method The HTTP method (for error messages). + * @param {String} options.path The request path (for error messages). + * @param {String} options.raw The response body. + * @param {Number} options.statusCode The response status code. + * @returns {Object} The parsed JSON response ({} for an empty body). + */ +const parseGsmResponse = ({ method, path, raw, statusCode }) => { + if (statusCode >= 300) { + throw new Error( + `GSM ${method} ${path} failed (${statusCode}): ${raw.slice(0, 200)}` + ); + } + + if (!raw) { + return {}; + } + + try { + return JSON.parse(raw); + } catch (e) { + throw new Error( + `GSM ${method} ${path} returned an unparseable response: ${raw.slice( + 0, + 200 + )}` + ); + } +}; + const gsmRequest = ({ body, method, path, project }) => new Promise((resolve, reject) => { const token = runGcloud({ args: ['auth', 'print-access-token'] }); @@ -476,17 +512,18 @@ const gsmRequest = ({ body, method, path, project }) => }); res.on('end', () => { - if (res.statusCode >= 300) { - return reject( - new Error( - `GSM ${method} ${path} failed (${ - res.statusCode - }): ${raw.slice(0, 200)}` - ) + try { + return resolve( + parseGsmResponse({ + method, + path, + raw, + statusCode: res.statusCode, + }) ); + } catch (failure) { + return reject(failure); } - - return resolve(raw ? JSON.parse(raw) : {}); }); } ); @@ -1644,6 +1681,7 @@ module.exports = { opRead, opSession, opUpsertField, + parseGsmResponse, pool, quoteIfNeeded, registerServiceInCjs, diff --git a/test/c-secrets-io-parse.test.js b/test/c-secrets-io-parse.test.js new file mode 100644 index 0000000..5bee1f1 --- /dev/null +++ b/test/c-secrets-io-parse.test.js @@ -0,0 +1,70 @@ +'use strict'; + +// Unit tests for parseGsmResponse: the parsed body for a success, an +// empty object for an empty body, and friendly errors for the failure +// and unparseable-success cases - gsmRequest's response handler relies +// on it to reject the promise instead of crashing inside an event +// handler. + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +const { parseGsmResponse } = require('../bin/lib/c-secrets-io.js'); + +it('returns the parsed body for a successful response', () => { + expect( + parseGsmResponse({ + method: 'GET', + path: '/secrets', + raw: '{"secrets":[{"name":"projects/p/secrets/a"}]}', + statusCode: 200, + }) + ).toStrictEqual({ + secrets: [{ name: 'projects/p/secrets/a' }], + }); +}); + +it('returns an empty object for an empty body', () => { + expect( + parseGsmResponse({ + method: 'DELETE', + path: '/secrets/x', + raw: '', + statusCode: 200, + }) + ).toStrictEqual({}); +}); + +it('throws the friendly failure error for non-2xx responses', () => { + expect(() => + parseGsmResponse({ + method: 'GET', + path: '/secrets/x', + raw: 'not found', + statusCode: 404, + }) + ).toThrow('GSM GET /secrets/x failed (404)'); +}); + +it('throws a friendly error for an unparseable 2xx body', () => { + let message = 'did not throw'; + + try { + parseGsmResponse({ + method: 'GET', + path: '/secrets', + raw: '{"trunc', + statusCode: 200, + }); + } catch ({ message: thrown }) { + message = thrown; + } + + expect(message).toContain('unparseable response'); + expect(message).toContain('{"trunc'); + // A raw SyntaxError never escapes - that is the crash-class bug this + // guards against. + expect(message).not.toContain('Unexpected token'); +}); From f224239172196f6c5ba2030f2253685ff37e4745 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:38:27 +1030 Subject: [PATCH 44/52] 6.2.0-beta.8 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index dfdb260..2d97d39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.7", + "version": "6.2.0-beta.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.2.0-beta.7", + "version": "6.2.0-beta.8", "license": "MIT", "dependencies": { "chalk": "4.1.2", diff --git a/package.json b/package.json index 33f5591..821e5f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.7", + "version": "6.2.0-beta.8", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": { From 6b5e8282c69f6e0684191ac7b2ba792dd23f88d6 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:42:20 +1030 Subject: [PATCH 45/52] Enforced the name-env naming convention in c secrets verify. --- CHANGELOG.md | 2 +- README.md | 4 +- bin/c-secrets-consumers.js | 8 +- bin/lib/c-secrets-verify.js | 199 ++++++++++++++++++++------ test/c-secrets-consumers.test.js | 138 ++++++++++++++++++ test/c-secrets-findconsumers.test.js | 138 ++++++++++++++++++ test/c-secrets-verify.test.js | 201 +++++++++++++++++++++++++++ 7 files changed, 640 insertions(+), 50 deletions(-) create mode 100644 test/c-secrets-consumers.test.js create mode 100644 test/c-secrets-findconsumers.test.js create mode 100644 test/c-secrets-verify.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 188e72b..5cbaa0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ This file is a history of the changes made to @idearium/cli. - `opItem` references (`services.`/`dev`, one hop) so each 1Password item id appears exactly once in the contract. Services resolve (or create) their item lazily by title; the `shared` block never auto-creates. - Friendly credential pre-flights: commands that need 1Password, gcloud or kubectl fail fast with actionable re-authentication errors instead of raw tool output. - The cli owns the 1Password session lifecycle: `c op session` signs in and stores the raw session token at `~/.local/state/idearium/op-session` (`0600`, enforced on overwrite), and `c op cmd [command...]` runs any `op` command with that session passed via `--session` (exit code propagates). Every internal `op` invocation (`c op get|set`, `c secrets *`, `c kc` template injection) passes the session the same way, so no `OP_SESSION_` env var name is guessed anywhere; an exported `OP_SESSION_idearium` still takes precedence for `eval $(op signin)` users. -- `c secrets verify` prints a headed section per check phase with output streaming as each check runs, prefetches its data in bulk (one 1Password item fetch per item, one GSM list, bounded-concurrency GSM reads, one kubectl fetch per secret), and reports function bindings referencing deleted GSM secrets (`MISSING`) alongside contract drift (`STALE`). +- `c secrets verify` prints a headed section per check phase with output streaming as each check runs, prefetches its data in bulk (one 1Password item fetch per item, one GSM list, bounded-concurrency GSM reads, one kubectl fetch per secret), and reports function bindings referencing deleted GSM secrets (`MISSING`), contract drift (`STALE`) and cross-env misbindings (`MISBOUND`). ### Fixed diff --git a/README.md b/README.md index 5c88f64..a931027 100644 --- a/README.md +++ b/README.md @@ -342,9 +342,9 @@ Commands (values are only ever displayed as truncated hashes; `--plaintext` requ - `c secrets push ` - push the 1Password value to GSM, verified by hash readback. - `c secrets get ` / `c op get` - value summaries (hash, reference, length). - `c op set ` - upsert a value from stdin (or `--generate`). -- `c secrets verify ` - reconcile structure + 1Password/GSM/Kubernetes hashes, stale 1Password fields (`op:EXTRA`) and deployed-functions drift (`STALE`). +- `c secrets verify ` - reconcile structure + 1Password/GSM/Kubernetes hashes, stale 1Password fields (`op:EXTRA`), deployed-functions drift (`STALE`, `MISSING`) and cross-env misbindings (`MISBOUND`). - `c secrets ls ` - the environment's contract-derived GSM inventory. -- `c secrets consumers ` - everything referencing a GSM secret id. Gate every deletion on this. +- `c secrets consumers ` - everything referencing a GSM secret id, with each consuming function's name-parsed env. Gate every deletion on this. Commands that need 1Password, gcloud or kubectl fail fast with friendly re-authentication instructions when their credentials have expired. diff --git a/bin/c-secrets-consumers.js b/bin/c-secrets-consumers.js index 9db58be..01127cf 100644 --- a/bin/c-secrets-consumers.js +++ b/bin/c-secrets-consumers.js @@ -68,8 +68,12 @@ return loadConfig() if (functions.length === 0) { print(' (none)'); } - functions.forEach(({ name }) => { - print(` ${name}`); + functions.forEach(({ env: nameEnv, name }) => { + print( + ` ${name}${ + nameEnv ? ` (name env ${nameEnv})` : ' (no env in name)' + }` + ); }); print( diff --git a/bin/lib/c-secrets-verify.js b/bin/lib/c-secrets-verify.js index b9a7c11..efe4ce2 100644 --- a/bin/lib/c-secrets-verify.js +++ b/bin/lib/c-secrets-verify.js @@ -70,13 +70,128 @@ const syncServiceNames = ({ config, env }) => { return [...new Set(names)].sort(); }; +/** + * The environment encoded in a repo resource name following the naming + * convention ({org}-{repo}-{env}-{name}), or null when the name carries + * no known env. + * @param {Object} options + * @param {Array} options.envs The known environment names. + * @param {String} options.name The resource name. + * @param {String} options.prefix The project prefix ({org}-{repo}-). + * @returns {String|null} The parsed environment. + */ +const parseNameEnv = ({ envs, name, prefix }) => + envs.find((candidate) => name.startsWith(`${prefix}${candidate}-`)) || null; + +/** + * Findings for one deployed function's repo-prefixed secret references, + * against the contract-derived ids per env and the ids that exist in + * GSM. Pure - verifyEnv only prints these. + * + * One finding per repo-prefixed reference, plus at most one advisory per + * function. MISBOUND outranks everything else: an id whose env disagrees + * with the function's name env is cross-env contamination regardless of + * whether the id exists. A function whose name parses to no known env + * (repos without the naming convention) keeps id-only validation behind + * an advisory. + * @param {Object} options + * @param {Object} options.derivedByEnv Contract-derived ids per env (Set). + * @param {Array} options.envs The known environment names. + * @param {Object} options.existingIds GSM ids that exist (Set). + * @param {Object} options.fn The deployed function ({ name, secrets }). + * @param {String} options.repoPrefix The project prefix ({org}-{repo}-). + * @returns {Array} Findings: { advisory, message, ok }. + */ +const functionReferenceFindings = ({ + derivedByEnv, + envs, + existingIds, + fn, + repoPrefix, +}) => { + const nameEnv = parseNameEnv({ + envs, + name: fn.name, + prefix: repoPrefix, + }); + + const findings = []; + + if (!nameEnv) { + findings.push({ + advisory: true, + message: `function ${fn.name}: no environment parsed from the name - validating references by secret id only`, + ok: true, + }); + } + + fn.secrets.forEach((id) => { + if (!id.startsWith(repoPrefix)) { + return; + } + + const refEnv = parseNameEnv({ + envs, + name: id, + prefix: repoPrefix, + }); + + if (nameEnv && refEnv && refEnv !== nameEnv) { + findings.push({ + advisory: false, + message: `MISBOUND function ${fn.name} -> ${id} (name env ${nameEnv}, id env ${refEnv})`, + ok: false, + }); + + return; + } + + const inContract = refEnv && derivedByEnv[refEnv].has(id); + + if (inContract && existingIds.has(id)) { + findings.push({ + advisory: false, + message: `function ${fn.name} -> ${id}`, + ok: true, + }); + + return; + } + + if (inContract) { + findings.push({ + advisory: false, + message: `MISSING function ${fn.name} -> ${id} (contract id, but the GSM secret does not exist)`, + ok: false, + }); + + return; + } + + findings.push({ + advisory: false, + message: `STALE function ${fn.name} -> ${id}${ + refEnv ? '' : ' (unknown env)' + } (not in contract${ + existingIds.has(id) + ? '' + : ', and the GSM secret no longer exists' + })`, + ok: false, + }); + }); + + return findings; +}; + /** * Verify one environment: contract-derived GSM secrets exist, their values * hash-match 1Password, Kubernetes Secrets (for synced services) match * structure and hashes, and deployed Cloud Functions reference only - * contract-derived secret ids that exist in GSM. Values are compared by - * hash only. Data is prefetched in bulk, then every check reports as it - * runs. + * contract-derived secret ids that exist in GSM and belong to the + * function's own environment (where its name encodes one). Values are + * compared by hash only. Data is prefetched in bulk, then every check + * reports as it runs. * @param {Object} options * @param {Object} options.config The full c.js configuration. * @param {Object} options.contract The normalised contract. @@ -475,62 +590,45 @@ const verifyEnv = ({ config, contract, env }) => { // 4. Deployed-functions drift: every repo-prefixed secret id a // function references must be in the contract-derived set for - // the env encoded in the id (function names don't carry the - // env - beta has an infix, production doesn't), and must - // reference a GSM secret that exists. + // the env encoded in the id and reference a GSM secret that + // exists. Where the naming convention carries the env in the + // function name ({org}-{repo}-{env}-{name}), the two envs must + // agree - a mismatch is cross-env contamination (MISBOUND). + // Names that parse to no known env (repos without the + // convention) validate by id only, behind an advisory. Output + // is grouped by function name so same-env functions read + // together. return gcfList({ project: contract.project, region: contract.region, repoPrefix, }).then((functions) => { section({ - detail: 'Secret bindings of each deployed function must match ids the c.js contract computes and reference GSM secrets that exist. STALE = an old id the contract no longer computes; MISSING = the referenced GSM secret is gone.', + detail: 'Secret bindings of each deployed function must match ids the c.js contract computes, reference GSM secrets that exist, and - where the function name encodes an environment - belong to that environment. STALE = an old id the contract no longer computes; MISSING = the referenced GSM secret is gone; MISBOUND = the env in the function name disagrees with the env in the id.', title: 'Deployed functions (secret references)', }); - functions.forEach((fn) => { - fn.secrets.forEach((id) => { - if (!id.startsWith(repoPrefix)) { - return; - } - - const refEnv = envs.find((candidate) => - id.startsWith(`${repoPrefix}${candidate}-`) - ); - - const inContract = - refEnv && derivedByEnv[refEnv].has(id); - - if (inContract) { - if (existingIds.has(id)) { - report({ - message: `function ${fn.name} -> ${id}`, - ok: true, - }); + [...functions] + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((fn) => { + functionReferenceFindings({ + derivedByEnv, + envs, + existingIds, + fn, + repoPrefix, + }).forEach((finding) => { + if (finding.advisory) { + process.stdout.write( + `WARN ${finding.message}\n` + ); return; } - report({ - message: `MISSING function ${fn.name} -> ${id} (contract id, but the GSM secret does not exist)`, - ok: false, - }); - - return; - } - - report({ - message: `STALE function ${fn.name} -> ${id}${ - refEnv ? '' : ' (unknown env)' - } (not in contract${ - existingIds.has(id) - ? '' - : ', and the GSM secret no longer exists' - })`, - ok: false, + report(finding); }); }); - }); return { ok: problems.length === 0, @@ -543,6 +641,8 @@ const verifyEnv = ({ config, contract, env }) => { /** * Find everything referencing a GSM secret id: contract consumers, the * environment's SecretProviderClasses and deployed Cloud Functions. + * Function consumers carry the env parsed from their name, or null when + * the name has none. * @param {Object} options * @param {Object} options.config The full c.js configuration. * @param {Object} options.contract The normalised contract. @@ -627,7 +727,14 @@ const findConsumers = ({ config, contract, secretId }) => { functions.forEach((fn) => { if (fn.secrets.includes(secretId)) { - result.functions.push({ name: fn.name }); + result.functions.push({ + env: parseNameEnv({ + envs, + name: fn.name, + prefix, + }), + name: fn.name, + }); } }); @@ -637,8 +744,10 @@ const findConsumers = ({ config, contract, secretId }) => { module.exports = { findConsumers, + functionReferenceFindings, kubernetesEnv, kubernetesNamespace, + parseNameEnv, syncServiceNames, verifyEnv, }; diff --git a/test/c-secrets-consumers.test.js b/test/c-secrets-consumers.test.js new file mode 100644 index 0000000..acd761e --- /dev/null +++ b/test/c-secrets-consumers.test.js @@ -0,0 +1,138 @@ +'use strict'; + +// Integration tests for c secrets consumers: spawned in a fixture +// project (c.js contract + fake `gcloud`/`kubectl` on PATH), the +// command must print each consuming function with the env parsed from +// its name - or (no env in name) when the name carries none. + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); + +const FAKE_GCLOUD = `#!/bin/sh +# Fake gcloud CLI for offline tests of c secrets consumers. +case "$*" in + *"print-access-token"*) + echo 'fake-token' + ;; + *"functions list"*) + echo 'projects/demo-proj/locations/australia-southeast1/functions/idearium-demo-beta-api' + echo 'projects/demo-proj/locations/australia-southeast1/functions/idearium-demo-site' + ;; + *"functions describe"*) + case "$*" in + *beta-api*|*site*) + echo '{"serviceConfig":{"secretEnvironmentVariables":[{"secret":"idearium-demo-beta-api-key"}]}}' + ;; + esac + ;; + *) + echo "fake gcloud: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +const FAKE_KUBECTL = `#!/bin/sh +# Fake kubectl CLI for offline tests of c secrets consumers. +case "$*" in + *"get --raw /version"*) + echo '{"major":"1"}' + ;; + *"secretproviderclass"*) + echo '{"items":[]}' + ;; + *) + echo "fake kubectl: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +const FIXTURE_CJS = `module.exports = { + gcloud: { + region: 'australia-southeast1', + }, + kubernetes: { + environments: { + beta: { + context: 'fake-context', + namespace: 'demo-beta-ns', + locations: {}, + }, + }, + }, + project: { + gcpProjectId: 'demo-proj', + name: 'demo', + organisation: 'idearium', + }, + secrets: { + services: { + api: { keys: ['OTHER_KEY'], sharedKeys: [] }, + }, + shared: { keys: ['API_KEY'] }, + }, +}; +`; + +const run = ({ args }) => + new Promise((resolve) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'c-consumers-')); + + fs.writeFileSync(path.join(dir, 'gcloud'), FAKE_GCLOUD, { + mode: 0o755, + }); + fs.writeFileSync(path.join(dir, 'kubectl'), FAKE_KUBECTL, { + mode: 0o755, + }); + fs.writeFileSync(path.join(dir, 'c.js'), FIXTURE_CJS); + + const child = spawn( + process.execPath, + [path.join(REPO_ROOT, 'bin', 'c-secrets-consumers.js')].concat( + args + ), + { + cwd: dir, + env: Object.assign({}, process.env, { + PATH: `${dir}:${process.env.PATH}`, + }), + } + ); + + let stdout = ''; + + child.stdout.on('data', (data) => { + stdout += data; + }); + + child.on('close', (code) => { + fs.rmSync(dir, { force: true, recursive: true }); + + resolve({ code, stdout }); + }); + }); + +it('prints each consuming function with its name-parsed env', async () => { + const { code, stdout } = await run({ + args: ['idearium-demo-beta-api-key'], + }); + + expect(code).toBe(0); + + expect(stdout).toContain( + ' idearium-demo-beta-api (name env beta)\n' + + ' idearium-demo-site (no env in name)' + ); + + expect(stdout).toContain('REFERENCED by 3 consumer(s) - do not delete.'); +}); diff --git a/test/c-secrets-findconsumers.test.js b/test/c-secrets-findconsumers.test.js new file mode 100644 index 0000000..1b9eeba --- /dev/null +++ b/test/c-secrets-findconsumers.test.js @@ -0,0 +1,138 @@ +'use strict'; + +// Integration tests for findConsumers against fake `gcloud` and +// `kubectl` binaries injected via PATH: consuming functions must carry +// the env parsed from their name (or null when the name has none), and +// the secret's env, contract consumers and SecretProviderClasses +// resolve as before. + +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { getSecretsContract } = require('../bin/lib/c-secrets.js'); +const { findConsumers } = require('../bin/lib/c-secrets-verify.js'); + +const FAKE_GCLOUD = `#!/bin/sh +# Fake gcloud CLI for offline tests of findConsumers. +FUNCTIONS="idearium-demo-beta-api idearium-demo-site" + +case "$*" in + *"functions list"*) + prefix=$(printf '%s\\n' "$@" | sed -n 's/.*name~functions\\///p') + for fn in $FUNCTIONS; do + case "$fn" in + "$prefix"*) echo "projects/demo-proj/locations/australia-southeast1/functions/$fn" ;; + esac + done + ;; + *"functions describe"*) + case "$*" in + *beta-api*|*site*) + echo '{"serviceConfig":{"secretEnvironmentVariables":[{"secret":"idearium-demo-beta-api-key"}]}}' + ;; + esac + ;; + *) + echo "fake gcloud: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +const FAKE_KUBECTL = `#!/bin/sh +# Fake kubectl CLI for offline tests of findConsumers: answers the +# context reachability probe and the SecretProviderClass listing. +case "$*" in + *"get --raw /version"*) + echo '{"major":"1"}' + ;; + *"secretproviderclass"*) + echo '{"items":[]}' + ;; + *) + echo "fake kubectl: unsupported invocation: $*" >&2 + exit 1 + ;; +esac +`; + +let dir; +let previousPath; + +beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-findconsumers-')); + + fs.writeFileSync(path.join(dir, 'gcloud'), FAKE_GCLOUD, { mode: 0o755 }); + fs.writeFileSync(path.join(dir, 'kubectl'), FAKE_KUBECTL, { + mode: 0o755, + }); + + previousPath = process.env.PATH; + process.env.PATH = `${dir}:${process.env.PATH}`; +}); + +afterAll(() => { + process.env.PATH = previousPath; + + fs.rmSync(dir, { force: true, recursive: true }); +}); + +it('annotates consuming functions with the env parsed from their name', async () => { + const config = { + kubernetes: { + environments: { + beta: { + context: 'fake-context', + namespace: 'demo-beta-ns', + locations: {}, + }, + }, + }, + }; + + const contract = getSecretsContract({ + config: { + gcloud: { region: 'australia-southeast1' }, + project: { + gcpProjectId: 'demo-proj', + name: 'demo', + organisation: 'idearium', + }, + secrets: { + services: { + api: { keys: ['OTHER_KEY'], sharedKeys: [] }, + }, + shared: { keys: ['API_KEY'] }, + }, + }, + }); + + const result = await findConsumers({ + config, + contract, + secretId: 'idearium-demo-beta-api-key', + }); + + expect(result.env).toBe('beta'); + + expect(result.contract).toStrictEqual([ + { + borrowed: false, + key: 'API_KEY', + owner: 'shared', + service: 'shared', + }, + ]); + + expect(result.k8s).toStrictEqual([]); + + expect(result.functions).toStrictEqual([ + { env: 'beta', name: 'idearium-demo-beta-api' }, + { env: null, name: 'idearium-demo-site' }, + ]); +}); diff --git a/test/c-secrets-verify.test.js b/test/c-secrets-verify.test.js new file mode 100644 index 0000000..bcd057f --- /dev/null +++ b/test/c-secrets-verify.test.js @@ -0,0 +1,201 @@ +'use strict'; + +// Behavioral tests for the deployed-function secret-reference findings: +// where the naming convention encodes an env in a function name +// ({org}-{repo}-{env}-{name}), the env in every repo-prefixed secret id +// the function references must agree (MISBOUND otherwise); names without +// a parseable env keep id-only validation behind an advisory; and +// contract ids must exist in GSM (MISSING) or be reported stale (STALE). +// +// Run with: npm test + +import { expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +const { + functionReferenceFindings, + parseNameEnv, +} = require('../bin/lib/c-secrets-verify.js'); + +const ENVS = ['beta', 'local', 'production']; +const PREFIX = 'idearium-demo-'; + +const BETA_ID = 'idearium-demo-beta-site-cache-url'; +const PRODUCTION_ID = 'idearium-demo-production-site-cache-url'; + +const derivedByEnv = { + beta: new Set([BETA_ID]), + local: new Set(['idearium-demo-local-site-cache-url']), + production: new Set([PRODUCTION_ID]), +}; + +const findingsOf = ({ existing = [], fn }) => + functionReferenceFindings({ + derivedByEnv, + envs: ENVS, + existingIds: new Set(existing), + fn, + repoPrefix: PREFIX, + }); + +it('parses the env out of a convention name', () => { + expect( + parseNameEnv({ + envs: ENVS, + name: 'idearium-demo-beta-images', + prefix: PREFIX, + }) + ).toBe('beta'); + + expect( + parseNameEnv({ + envs: ENVS, + name: 'idearium-demo-production-prismic-published', + prefix: PREFIX, + }) + ).toBe('production'); +}); + +it('does not parse lookalike or legacy names', () => { + expect( + parseNameEnv({ + envs: ENVS, + name: 'idearium-demo-betamax-images', + prefix: PREFIX, + }) + ).toBe(null); + + expect( + parseNameEnv({ + envs: ENVS, + name: 'idearium-demo-prismic-published', + prefix: PREFIX, + }) + ).toBe(null); +}); + +it('reports a contract id of the function env that exists', () => { + const findings = findingsOf({ + existing: [BETA_ID], + fn: { + name: 'idearium-demo-beta-images', + secrets: [BETA_ID], + }, + }); + + expect(findings).toStrictEqual([ + { + advisory: false, + message: `function idearium-demo-beta-images -> ${BETA_ID}`, + ok: true, + }, + ]); +}); + +it('reports a contract id of the function env that is gone from GSM', () => { + const findings = findingsOf({ + fn: { + name: 'idearium-demo-beta-images', + secrets: [BETA_ID], + }, + }); + + expect(findings).toStrictEqual([ + { + advisory: false, + message: `MISSING function idearium-demo-beta-images -> ${BETA_ID} (contract id, but the GSM secret does not exist)`, + ok: false, + }, + ]); +}); + +it('reports a non-contract id as stale, noting an unparsed env', () => { + const findings = findingsOf({ + existing: ['idearium-demo-functions-imgix-token'], + fn: { + name: 'idearium-demo-beta-images', + secrets: ['idearium-demo-functions-imgix-token'], + }, + }); + + expect(findings).toStrictEqual([ + { + advisory: false, + message: + 'STALE function idearium-demo-beta-images -> ' + + 'idearium-demo-functions-imgix-token (unknown env) ' + + '(not in contract)', + ok: false, + }, + ]); +}); + +it('reports a cross-env reference as misbound', () => { + const findings = findingsOf({ + existing: [PRODUCTION_ID], + fn: { + name: 'idearium-demo-beta-images', + secrets: [PRODUCTION_ID], + }, + }); + + expect(findings).toStrictEqual([ + { + advisory: false, + message: `MISBOUND function idearium-demo-beta-images -> ${PRODUCTION_ID} (name env beta, id env production)`, + ok: false, + }, + ]); +}); + +it('misbinding outranks existence checks', () => { + const findings = findingsOf({ + fn: { + name: 'idearium-demo-beta-images', + secrets: ['idearium-demo-production-gone-secret'], + }, + }); + + expect(findings.length).toBe(1); + expect(findings[0].ok).toBe(false); + expect(findings[0].message).toContain('MISBOUND'); +}); + +it('keeps id-only validation behind an advisory for names without an env', () => { + const findings = findingsOf({ + existing: [BETA_ID], + fn: { + name: 'idearium-demo-images', + secrets: [BETA_ID, 'an-unprefixed-id'], + }, + }); + + expect(findings).toStrictEqual([ + { + advisory: true, + message: + 'function idearium-demo-images: no environment parsed from ' + + 'the name - validating references by secret id only', + ok: true, + }, + { + advisory: false, + message: `function idearium-demo-images -> ${BETA_ID}`, + ok: true, + }, + ]); +}); + +it('reports no problems for a clean same-env function', () => { + const findings = findingsOf({ + existing: [BETA_ID, PRODUCTION_ID], + fn: { + name: 'idearium-demo-beta-images', + secrets: [BETA_ID], + }, + }); + + expect(findings.filter(({ ok }) => !ok)).toStrictEqual([]); +}); From 1e3c1ce6156b48fcb8943010fc62994d30f78491 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:56:44 +1030 Subject: [PATCH 46/52] CHANGELOG update. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cbaa0f..a494022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ This file is a history of the changes made to @idearium/cli. ### Added - The secrets contract: a `secrets` block in `c.js` (services, a sibling `shared` block for keys whose GSM ids carry no service segment, and `dev` build-time consumers) that becomes the single source of truth for secret-shaped manifests. With a contract present, `c kc manifests|apply|start` generate SecretProviderClasses, SecretSyncs, the secret-sync ServiceAccount (deployed environments) and local Secrets (1Password references); a conflicting committed file is a hard error. Without a contract, manifest behaviour is unchanged. -- `c op get|set` and `c secrets get|push|add|verify|ls|consumers|init`. Values are only ever displayed as truncated hashes (`--plaintext` is terminal-gated and agent-blocked with a discouraged `--force`); writes flow through stdin pipes; `push` verifies its GSM readback by hash; `add` is ordered, idempotent and rollback-safe across c.js, 1Password and GSM and never touches the cluster; `consumers` gates deletions; `init` drafts a contract from existing declarations (warm 1Password session required), binding `shared.opItem` and removing legacy duplicates by comparing value hashes. +- `c op get|set` and `c secrets get|push|add|verify|ls|consumers|init`. Values are only ever displayed as truncated hashes (`--plaintext` is terminal-gated and agent-blocked with a discouraged `--force`); writes flow through stdin pipes; `push` verifies its GSM readback by hash; `add` is ordered, idempotent and rollback-safe across c.js, 1Password and GSM and never touches the cluster; `consumers` gates deletions and annotates each consuming function with the env parsed from its name; `init` drafts a contract from existing declarations (warm 1Password session required), binding `shared.opItem` and removing legacy duplicates by comparing value hashes. - `opItem` references (`services.`/`dev`, one hop) so each 1Password item id appears exactly once in the contract. Services resolve (or create) their item lazily by title; the `shared` block never auto-creates. - Friendly credential pre-flights: commands that need 1Password, gcloud or kubectl fail fast with actionable re-authentication errors instead of raw tool output. - The cli owns the 1Password session lifecycle: `c op session` signs in and stores the raw session token at `~/.local/state/idearium/op-session` (`0600`, enforced on overwrite), and `c op cmd [command...]` runs any `op` command with that session passed via `--session` (exit code propagates). Every internal `op` invocation (`c op get|set`, `c secrets *`, `c kc` template injection) passes the session the same way, so no `OP_SESSION_` env var name is guessed anywhere; an exported `OP_SESSION_idearium` still takes precedence for `eval $(op signin)` users. From e136197fa66571ea6e0bb52bc890d0e899d202de Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:56:54 +1030 Subject: [PATCH 47/52] 6.2.0-beta.9 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d97d39..d8931e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.8", + "version": "6.2.0-beta.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.2.0-beta.8", + "version": "6.2.0-beta.9", "license": "MIT", "dependencies": { "chalk": "4.1.2", diff --git a/package.json b/package.json index 821e5f7..143e8a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.8", + "version": "6.2.0-beta.9", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": { From 8a5343c09344665c6ac63f1c5f73d7d3e1e0d56d Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:39:13 +1030 Subject: [PATCH 48/52] Reported a missing op cli instead of undefined on ENOENT. --- bin/lib/c-secrets-io.js | 2 +- test/c-secrets-io-resolve.test.js | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/bin/lib/c-secrets-io.js b/bin/lib/c-secrets-io.js index b1059c8..4b0b4f3 100644 --- a/bin/lib/c-secrets-io.js +++ b/bin/lib/c-secrets-io.js @@ -83,7 +83,7 @@ const runOp = ({ args, input }) => { stdio: ['pipe', 'pipe', 'pipe'], }); } catch (e) { - const stderr = String(e.stderr); + const stderr = String(e.stderr || ''); if ( stderr.includes('not currently signed in') || diff --git a/test/c-secrets-io-resolve.test.js b/test/c-secrets-io-resolve.test.js index 8cd470f..923dd14 100644 --- a/test/c-secrets-io-resolve.test.js +++ b/test/c-secrets-io-resolve.test.js @@ -252,3 +252,20 @@ it('maps session-expiry failures to the friendly message', () => { 'session has expired' ); }); + +it('reports a missing op cli instead of failing with undefined', () => { + // ENOENT leaves e.stderr undefined; that must not become the literal + // string 'undefined' (a truthy detail that would keep the + // missing-cli hint from ever firing). + const pathWas = process.env.PATH; + process.env.PATH = ''; + + try { + expectThrowsWith( + () => io.runOp({ args: ['whoami'] }), + 'the op cli is not installed' + ); + } finally { + process.env.PATH = pathWas; + } +}); From dd2c23400f8279e26b5e0e14928babcf7709aff1 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:03:44 +1030 Subject: [PATCH 49/52] Fixed tests. --- test/c-secrets-io-resolve.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/c-secrets-io-resolve.test.js b/test/c-secrets-io-resolve.test.js index 923dd14..8c9118a 100644 --- a/test/c-secrets-io-resolve.test.js +++ b/test/c-secrets-io-resolve.test.js @@ -256,9 +256,12 @@ it('maps session-expiry failures to the friendly message', () => { it('reports a missing op cli instead of failing with undefined', () => { // ENOENT leaves e.stderr undefined; that must not become the literal // string 'undefined' (a truthy detail that would keep the - // missing-cli hint from ever firing). + // missing-cli hint from ever firing). PATH points somewhere empty + // rather than being cleared: an empty PATH makes execvp fall back to + // the working directory, which on CI contains an unzipped op/ dir + // (EACCES, not ENOENT). const pathWas = process.env.PATH; - process.env.PATH = ''; + process.env.PATH = '/nonexistent-bin-for-op-cli-tests'; try { expectThrowsWith( From cc7c41c4183e5bc2cb4e56c98f6669746e6d52c0 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:43:01 +1030 Subject: [PATCH 50/52] 6.2.0-beta.10 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d8931e1..4ac4110 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.9", + "version": "6.2.0-beta.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.2.0-beta.9", + "version": "6.2.0-beta.10", "license": "MIT", "dependencies": { "chalk": "4.1.2", diff --git a/package.json b/package.json index 143e8a0..f1f6b6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.9", + "version": "6.2.0-beta.10", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": { From 874e5fcd6379bd808754bbaa01072d59ec52e9a4 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:08:28 +1030 Subject: [PATCH 51/52] Dated the 6.2.0 release. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a494022..c364701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ This file is a history of the changes made to @idearium/cli. -## v6.2.0 - 2026-09-30 +## v6.2.0 - 2026-10-27 ### Added From d7723e178da7e44acb042573786c2463e7110f36 Mon Sep 17 00:00:00 2001 From: Scott Mebberson <74628+smebberson@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:18:43 +1030 Subject: [PATCH 52/52] 6.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4ac4110..7e212a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.10", + "version": "6.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@idearium/cli", - "version": "6.2.0-beta.10", + "version": "6.2.0", "license": "MIT", "dependencies": { "chalk": "4.1.2", diff --git a/package.json b/package.json index f1f6b6b..109d046 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@idearium/cli", - "version": "6.2.0-beta.10", + "version": "6.2.0", "description": "The Idearium cli, which makes working with our projects much easier.", "main": "index.js", "bin": {