diff --git a/.changeset/bright-vault-status.md b/.changeset/bright-vault-status.md index 136e012..95a37d7 100644 --- a/.changeset/bright-vault-status.md +++ b/.changeset/bright-vault-status.md @@ -2,4 +2,5 @@ '@inflowpayai/inflow': patch --- -Start the local vault daemon before reporting authentication or vault status. +Start the local vault daemon for vault status and interactive authentication status. Report an unauthenticated agent +status without starting a vault that has not been initialized. diff --git a/.changeset/fresh-sdk-dependencies.md b/.changeset/fresh-sdk-dependencies.md new file mode 100644 index 0000000..623cf1d --- /dev/null +++ b/.changeset/fresh-sdk-dependencies.md @@ -0,0 +1,5 @@ +--- +'@inflowpayai/inflow': patch +--- + +Update the AEP, ODP, and InFlow SDK dependencies. diff --git a/.changeset/tidy-vault-prompts.md b/.changeset/tidy-vault-prompts.md new file mode 100644 index 0000000..b9224ce --- /dev/null +++ b/.changeset/tidy-vault-prompts.md @@ -0,0 +1,6 @@ +--- +'@inflowpayai/inflow': patch +--- + +Prompt interactive users to unlock the vault before authentication status, combined inspection, and payment cancellation +commands. diff --git a/packages/cli/package.json b/packages/cli/package.json index ce3f937..882a461 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -43,11 +43,11 @@ "url": "https://github.com/inflowpayai/inflow-cli/issues" }, "dependencies": { - "@aep-foundation/agent": "^0.4.0", + "@aep-foundation/agent": "^0.5.0", "@inflowpayai/mpp": "^0.10.0", "@inflowpayai/mpp-buyer": "^0.7.2", - "@inflowpayai/x402": "^0.9.0", - "@inflowpayai/x402-buyer": "^0.9.0", + "@inflowpayai/x402": "^0.9.1", + "@inflowpayai/x402-buyer": "^0.9.1", "@modelcontextprotocol/server": "2.0.0-alpha.4", "@node-rs/argon2": "^2.0.2", "@x402/core": "^2.22.0", diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 0ff87ed..5ea083c 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -176,11 +176,13 @@ async function main(): Promise { const vaultOptions: LocalVaultDaemonClientOptions = { buildId: cliBuildId, cliVersion }; const apiKeyFromEnv = process.env['INFLOW_API_KEY']; const hasDirectApiKey = (apiKeyFromFlag?.length ?? 0) > 0 || (apiKeyFromEnv?.length ?? 0) > 0; + let hasInitializedVault = true; if (shouldReconcileVaultDaemon(process.argv, hasDirectApiKey)) { const status = await readVaultStatusWithoutStarting(vaultOptions); + hasInitializedVault = status.lockState !== 'not_initialized'; if (status.daemonRunning) await ensureLocalVaultDaemon(vaultOptions); } - if (shouldStartVaultDaemon(process.argv, hasDirectApiKey)) { + if (shouldStartVaultDaemon(process.argv, { hasDirectApiKey, hasInitializedVault, isAgent })) { await ensureLocalVaultDaemon(vaultOptions); } if (shouldUnlockVault(process.argv, { hasDirectApiKey, isAgent })) { diff --git a/packages/cli/src/commands/aep/index.tsx b/packages/cli/src/commands/aep/index.tsx index fcf9f9d..4f29d04 100644 --- a/packages/cli/src/commands/aep/index.tsx +++ b/packages/cli/src/commands/aep/index.tsx @@ -1376,8 +1376,22 @@ async function runRevoke(c: Context, inflow: Inflow, authStorage: AuthStorage): ? { grantType: options.grantType as string } : { credentialId: options.credentialId }; if ('allGrantTypes' in selector) await revokeService({ ...base, allGrantTypes: true }); - else if ('credentialId' in selector) await revokeService({ ...base, credentialId: selector.credentialId }); - else await revokeService({ ...base, grantType: selector.grantType }); + else if ('credentialId' in selector) { + const credential = await aepStorage + .credentials() + .findCredential(inspect.document.service.did, selector.credentialId); + if (credential === undefined) { + throw new CliInputError( + 'AEP_CREDENTIAL_NOT_FOUND', + `No stored AEP credential exists with identifier ${selector.credentialId}.`, + ); + } + await revokeService({ + ...base, + credentialId: selector.credentialId, + grantType: credential.grantType, + }); + } else await revokeService({ ...base, grantType: selector.grantType }); aepStorage.deleteCredentials(inspect.document.service.did, selector); const frame = sanitizeDeep({ revoked: true, diff --git a/packages/cli/src/startup-vault.ts b/packages/cli/src/startup-vault.ts index b1e6c81..0f2a85d 100644 --- a/packages/cli/src/startup-vault.ts +++ b/packages/cli/src/startup-vault.ts @@ -45,21 +45,33 @@ export function normalizeFormatAssignments(argv: string[]): void { } } -export function shouldStartVaultDaemon(argv: readonly string[], hasDirectApiKey = false): boolean { +export function shouldStartVaultDaemon( + argv: readonly string[], + options: { hasDirectApiKey?: boolean; hasInitializedVault?: boolean; isAgent?: boolean } = {}, +): boolean { if (shouldBypassVault(argv)) return false; const [group, subcommand] = commandPath(argv); if (group === 'auth') { - return isOneOf(subcommand, 'login', 'logout') || (!hasDirectApiKey && subcommand === 'status'); + return ( + isOneOf(subcommand, 'login', 'logout') || + (options.hasDirectApiKey !== true && + subcommand === 'status' && + (options.isAgent !== true || options.hasInitializedVault !== false)) + ); } if (group === 'vault') return subcommand === 'status'; if (group === 'aep') return isOneOf(subcommand, 'enroll', 'fetch', 'grant', 'revoke', 'status'); + if (group === 'inspect') return shouldConfigureOdpServiceTransport(argv); if (group === 'odp') return shouldConfigureOdpServiceTransport(argv); if (group === 'mpp') { return ( - requiresMppLocalState(argv, subcommand) || (!hasDirectApiKey && isOneOf(subcommand, 'pay', 'status', 'supported')) + requiresMppLocalState(argv, subcommand) || + (options.hasDirectApiKey !== true && isOneOf(subcommand, 'cancel', 'pay', 'status', 'supported')) ); } - if (group === 'x402') return !hasDirectApiKey && isOneOf(subcommand, 'fetch', 'pay', 'status', 'supported'); + if (group === 'x402') { + return options.hasDirectApiKey !== true && isOneOf(subcommand, 'cancel', 'fetch', 'pay', 'status', 'supported'); + } if (group === 'subscriptions') return isOneOf(subcommand, 'cancel', 'fetch'); return false; } @@ -71,13 +83,15 @@ export function shouldReconcileVaultDaemon(argv: readonly string[], hasDirectApi return isOneOf(subcommand, 'login', 'logout') || (!hasDirectApiKey && subcommand === 'status'); } if (group === 'aep') return isOneOf(subcommand, 'enroll', 'fetch', 'grant', 'revoke', 'status'); + if (group === 'inspect') return shouldConfigureOdpServiceTransport(argv); if (group === 'odp') return shouldConfigureOdpServiceTransport(argv); if (group === 'mpp') { return ( - requiresMppLocalState(argv, subcommand) || (!hasDirectApiKey && isOneOf(subcommand, 'pay', 'status', 'supported')) + requiresMppLocalState(argv, subcommand) || + (!hasDirectApiKey && isOneOf(subcommand, 'cancel', 'pay', 'status', 'supported')) ); } - if (group === 'x402') return !hasDirectApiKey && isOneOf(subcommand, 'fetch', 'pay', 'status', 'supported'); + if (group === 'x402') return !hasDirectApiKey && isOneOf(subcommand, 'cancel', 'fetch', 'pay', 'status', 'supported'); if (group === 'subscriptions' && isOneOf(subcommand, 'cancel', 'fetch')) return true; if (hasDirectApiKey) return false; if (group === 'balances' || group === 'deposit-addresses') return subcommand === 'list'; @@ -91,17 +105,20 @@ export function shouldUnlockVault( ): boolean { if (options.isAgent === true || shouldBypassVault(argv)) return false; const [group, subcommand] = commandPath(argv); - if (group === 'auth') return subcommand === 'login'; + if (group === 'auth') { + return subcommand === 'login' || (options.hasDirectApiKey !== true && subcommand === 'status'); + } if (group === 'aep') return isOneOf(subcommand, 'enroll', 'fetch', 'grant', 'revoke', 'status'); + if (group === 'inspect') return shouldConfigureOdpServiceTransport(argv); if (group === 'odp') return shouldConfigureOdpServiceTransport(argv); if (group === 'mpp') { return ( requiresMppLocalState(argv, subcommand) || - (options.hasDirectApiKey !== true && isOneOf(subcommand, 'pay', 'status', 'supported')) + (options.hasDirectApiKey !== true && isOneOf(subcommand, 'cancel', 'pay', 'status', 'supported')) ); } if (group === 'x402') { - return options.hasDirectApiKey !== true && isOneOf(subcommand, 'fetch', 'pay', 'status', 'supported'); + return options.hasDirectApiKey !== true && isOneOf(subcommand, 'cancel', 'fetch', 'pay', 'status', 'supported'); } if (group === 'subscriptions' && isOneOf(subcommand, 'cancel', 'fetch')) return true; if (options.hasDirectApiKey === true) return false; diff --git a/packages/cli/src/utils/api-error.ts b/packages/cli/src/utils/api-error.ts index 7354b93..b58e685 100644 --- a/packages/cli/src/utils/api-error.ts +++ b/packages/cli/src/utils/api-error.ts @@ -1,3 +1,4 @@ +import { SecureStorageError } from '@inflowpayai/inflow-core'; import { MISSING_SESSION_ERROR } from './assert-session.js'; interface CliError { @@ -18,6 +19,20 @@ function apiErrorLike(error: unknown): ApiErrorLike | undefined { } export function authenticatedApiError(error: unknown): CliError | undefined { + if (error instanceof SecureStorageError) { + if (error.secureStorageCode === 'vault_locked') { + return { + code: 'VAULT_LOCKED', + message: 'The InFlow vault is locked. A human must run `inflow vault unlock` first.', + }; + } + if (error.secureStorageCode === 'vault_not_initialized') { + return { + code: 'VAULT_NOT_INITIALIZED', + message: 'The InFlow vault is not initialized. A human must run `inflow vault unlock` first.', + }; + } + } const apiError = apiErrorLike(error); if (apiError === undefined) return; if (apiError.code === 'VERSION_UNSUPPORTED' && typeof apiError.message === 'string') { diff --git a/packages/cli/test/unit/commands/aep/index.test.ts b/packages/cli/test/unit/commands/aep/index.test.ts index 9485321..b79c80e 100644 --- a/packages/cli/test/unit/commands/aep/index.test.ts +++ b/packages/cli/test/unit/commands/aep/index.test.ts @@ -1205,10 +1205,34 @@ describe('aep commands', () => { userId: 'user-1', }); await persisted.identities().saveIdentity(identity); + if ('credentialId' in options) { + await persisted.credentials().saveCredential({ + credential: { credential_id: options.credentialId }, + credentialId: options.credentialId, + expiresAt: '2999-01-01T00:00:00.000Z', + grantType: 'oauth-bearer', + issuedAt: '2026-01-01T00:00:00.000Z', + serviceDid: identity.serviceDid, + }); + } await expect(__testing.runRevoke(context(options), inflow(), storage)).resolves.toEqual(expected); }); + it('rejects per-credential Revoke when the credential is not stored locally', async () => { + const storage = new MemoryStorage(); + storage.setApiKey('key'); + const persisted = new AepStorage(storage, { + platformOrigin: 'https://platform.example', + userId: 'user-1', + }); + await persisted.identities().saveIdentity(identity); + + await expect( + __testing.runRevoke(context({ credentialId: 'credential-missing' }), inflow(), storage), + ).rejects.toThrow('AEP_CREDENTIAL_NOT_FOUND'); + }); + it('checks Status and skips approval when enrolling an existing identity', async () => { const approvalFetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ status: 'APPROVED' }), { status: 200 })), diff --git a/packages/cli/test/unit/commands/mpp/index-runners.test.ts b/packages/cli/test/unit/commands/mpp/index-runners.test.ts index e2bbfda..3deeb94 100644 --- a/packages/cli/test/unit/commands/mpp/index-runners.test.ts +++ b/packages/cli/test/unit/commands/mpp/index-runners.test.ts @@ -1,5 +1,5 @@ import type { AuthStorage, ICliCapabilitiesResource } from '@inflowpayai/inflow-core'; -import { Inflow, InflowApiError, MemoryStorage } from '@inflowpayai/inflow-core'; +import { Inflow, InflowApiError, MemoryStorage, SecureStorageError } from '@inflowpayai/inflow-core'; import { encode, type MppChallenge, type MppClient, renderChallengeHeader } from '@inflowpayai/mpp'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { __testing, createMppCli } from '../../../../src/commands/mpp/index.js'; @@ -151,6 +151,16 @@ describe('mpp agent runners', () => { expect(out).toMatchObject({ code: 'NOT_AUTHENTICATED' }); }); + it('runCancelCommand reports a locked vault in agent mode', async () => { + const cancelApproval = vi.fn(() => + Promise.reject(new SecureStorageError('vault_locked', 'The InFlow vault is locked.')), + ); + const { inflow, storage } = authed(makeClient(), cancelApproval); + const ctx = agentCtxReturningError({ approvalId: 'ap-1' }, {}); + const out = await runCancelCommand(ctx, inflow, storage); + expect(out).toMatchObject({ code: 'VAULT_LOCKED' }); + }); + it('runCancelCommand rethrows non-authentication failures', async () => { const failure = new Error('cancel unavailable'); const cancelApproval = vi.fn(() => Promise.reject(failure)); diff --git a/packages/cli/test/unit/commands/x402/index-runners.test.ts b/packages/cli/test/unit/commands/x402/index-runners.test.ts index 032c0dd..20bbf59 100644 --- a/packages/cli/test/unit/commands/x402/index-runners.test.ts +++ b/packages/cli/test/unit/commands/x402/index-runners.test.ts @@ -1,5 +1,5 @@ import type { AuthStorage, ICliCapabilitiesResource } from '@inflowpayai/inflow-core'; -import { Inflow, InflowApiError, MemoryStorage } from '@inflowpayai/inflow-core'; +import { Inflow, InflowApiError, MemoryStorage, SecureStorageError } from '@inflowpayai/inflow-core'; import { X402AdapterRoutingError, X402ApprovalFailedError, @@ -1049,6 +1049,18 @@ describe('runCancelCommand', () => { expect(result).toMatchObject({ code: 'NOT_AUTHENTICATED' }); }); + it('reports a locked vault in agent mode', async () => { + const client = makeClient({ + cancelApproval: vi.fn(() => + Promise.reject(new SecureStorageError('vault_locked', 'The InFlow vault is locked.')), + ), + }); + const ctx = agentContextReturningError({ approvalId: 'appr_1' }, {}); + const { inflow, storage } = authedResources(client); + const result = await runCancelCommand(ctx, inflow, storage); + expect(result).toMatchObject({ code: 'VAULT_LOCKED' }); + }); + it('rethrows non-authentication failures', async () => { const failure = new Error('cancel unavailable'); const client = makeClient({ diff --git a/packages/cli/test/unit/startup-vault.test.ts b/packages/cli/test/unit/startup-vault.test.ts index 6090494..e876ea8 100644 --- a/packages/cli/test/unit/startup-vault.test.ts +++ b/packages/cli/test/unit/startup-vault.test.ts @@ -75,11 +75,13 @@ describe('vault startup decisions', () => { ['mpp fetch', ['mpp', 'fetch'], true], ['mpp status', ['mpp', 'status'], true], ['mpp supported', ['mpp', 'supported'], true], + ['mpp cancel', ['mpp', 'cancel'], true], ['mpp inspect', ['mpp', 'inspect'], false], ['x402 pay', ['x402', 'pay'], true], ['x402 fetch', ['x402', 'fetch'], true], ['x402 status', ['x402', 'status'], true], ['x402 supported', ['x402', 'supported'], true], + ['x402 cancel', ['x402', 'cancel'], true], ['x402 inspect', ['x402', 'inspect'], false], ['odp actions resolve', ['odp', 'actions', 'resolve', 'https://service.test', 'offering-1', 'action-1'], true], ['odp collections list', ['odp', 'collections', 'list', 'https://service.test'], true], @@ -94,6 +96,7 @@ describe('vault startup decisions', () => { ['vault status', ['vault', 'status'], true], ['vault unlock', ['vault', 'unlock'], false], ['top inspect', ['inspect'], false], + ['top inspect target', ['inspect', 'https://service.test'], true], ] as const)('starts daemon for %s when required', (_label, args, expected) => { expect(shouldStartVaultDaemon(argv(...args))).toBe(expected); }); @@ -106,13 +109,16 @@ describe('vault startup decisions', () => { ['aep status', ['aep', 'status'], true], ['mpp pay', ['mpp', 'pay'], true], ['mpp subscribe', ['mpp', 'subscribe'], true], + ['mpp cancel', ['mpp', 'cancel'], true], ['x402 fetch', ['x402', 'fetch'], true], + ['x402 cancel', ['x402', 'cancel'], true], ['aep inspect', ['aep', 'inspect'], false], ['odp offerings list', ['odp', 'offerings', 'list', 'https://service.test'], true], ['odp directory', ['odp', 'directory'], false], ['odp inspect', ['odp', 'inspect'], false], ['vault unlock', ['vault', 'unlock'], false], ['top inspect', ['inspect'], false], + ['top inspect target', ['inspect', 'https://service.test'], true], ] as const)('reconciles a running daemon for %s when required', (_label, args, expected) => { expect(shouldReconcileVaultDaemon(argv(...args))).toBe(expected); }); @@ -120,7 +126,7 @@ describe('vault startup decisions', () => { it.each([ ['auth login', ['auth', 'login'], true], ['auth logout', ['auth', 'logout'], false], - ['auth status', ['auth', 'status'], false], + ['auth status', ['auth', 'status'], true], ['aep enroll', ['aep', 'enroll'], true], ['aep fetch', ['aep', 'fetch'], true], ['aep grant', ['aep', 'grant'], true], @@ -132,6 +138,7 @@ describe('vault startup decisions', () => { ['mpp fetch', ['mpp', 'fetch'], true], ['mpp status', ['mpp', 'status'], true], ['mpp supported', ['mpp', 'supported'], true], + ['mpp cancel', ['mpp', 'cancel'], true], ['mpp inspect', ['mpp', 'inspect'], false], ['subscriptions cancel', ['subscriptions', 'cancel', 'id'], true], ['subscriptions fetch', ['subscriptions', 'fetch', 'id', 'https://seller.test'], true], @@ -139,6 +146,7 @@ describe('vault startup decisions', () => { ['x402 fetch', ['x402', 'fetch'], true], ['x402 status', ['x402', 'status'], true], ['x402 supported', ['x402', 'supported'], true], + ['x402 cancel', ['x402', 'cancel'], true], ['x402 inspect', ['x402', 'inspect'], false], ['odp actions resolve', ['odp', 'actions', 'resolve', 'https://service.test', 'offering-1', 'action-1'], true], ['odp collections search', ['odp', 'collections', 'search', 'https://service.test'], true], @@ -150,44 +158,50 @@ describe('vault startup decisions', () => { ['user get', ['user', 'get'], true], ['vault unlock', ['vault', 'unlock'], false], ['top inspect', ['inspect'], false], + ['top inspect target', ['inspect', 'https://service.test'], true], ] as const)('unlocks vault for human %s when required', (_label, args, expected) => { expect(shouldUnlockVault(argv(...args))).toBe(expected); }); it('still uses the vault for local-state commands when a direct InFlow API key is present', () => { - expect(shouldStartVaultDaemon(argv('aep', 'status'), true)).toBe(true); + expect(shouldStartVaultDaemon(argv('aep', 'status'), { hasDirectApiKey: true })).toBe(true); expect(shouldReconcileVaultDaemon(argv('aep', 'status'), true)).toBe(true); expect(shouldUnlockVault(argv('aep', 'status'), { hasDirectApiKey: true })).toBe(true); const odpArgs = argv('odp', 'offerings', 'list', 'https://service.test'); - expect(shouldStartVaultDaemon(odpArgs, true)).toBe(true); + expect(shouldStartVaultDaemon(odpArgs, { hasDirectApiKey: true })).toBe(true); expect(shouldReconcileVaultDaemon(odpArgs, true)).toBe(true); expect(shouldUnlockVault(odpArgs, { hasDirectApiKey: true })).toBe(true); - expect(shouldStartVaultDaemon(argv('auth', 'login'), true)).toBe(true); + expect(shouldStartVaultDaemon(argv('auth', 'login'), { hasDirectApiKey: true })).toBe(true); expect(shouldReconcileVaultDaemon(argv('auth', 'logout'), true)).toBe(true); for (const subcommand of ['fetch', 'subscribe']) { - expect(shouldStartVaultDaemon(argv('mpp', subcommand), true)).toBe(true); + expect(shouldStartVaultDaemon(argv('mpp', subcommand), { hasDirectApiKey: true })).toBe(true); expect(shouldReconcileVaultDaemon(argv('mpp', subcommand), true)).toBe(true); expect(shouldUnlockVault(argv('mpp', subcommand), { hasDirectApiKey: true })).toBe(true); } for (const args of [argv('mpp', 'pay', '--intent', 'subscription'), argv('mpp', 'pay', '--intent=subscription')]) { - expect(shouldStartVaultDaemon(args, true)).toBe(true); + expect(shouldStartVaultDaemon(args, { hasDirectApiKey: true })).toBe(true); expect(shouldReconcileVaultDaemon(args, true)).toBe(true); expect(shouldUnlockVault(args, { hasDirectApiKey: true })).toBe(true); } - expect(shouldStartVaultDaemon(argv('subscriptions', 'cancel', 'id'), true)).toBe(true); + expect(shouldStartVaultDaemon(argv('subscriptions', 'cancel', 'id'), { hasDirectApiKey: true })).toBe(true); expect(shouldReconcileVaultDaemon(argv('subscriptions', 'cancel', 'id'), true)).toBe(true); expect(shouldUnlockVault(argv('subscriptions', 'cancel', 'id'), { hasDirectApiKey: true })).toBe(true); const subscriptionFetchArgs = argv('subscriptions', 'fetch', 'id', 'https://seller.test'); - expect(shouldStartVaultDaemon(subscriptionFetchArgs, true)).toBe(true); + expect(shouldStartVaultDaemon(subscriptionFetchArgs, { hasDirectApiKey: true })).toBe(true); expect(shouldReconcileVaultDaemon(subscriptionFetchArgs, true)).toBe(true); expect(shouldUnlockVault(subscriptionFetchArgs, { hasDirectApiKey: true })).toBe(true); }); it('bypasses vault credentials that a direct InFlow API key replaces', () => { - expect(shouldStartVaultDaemon(argv('mpp', 'pay'), true)).toBe(false); - expect(shouldStartVaultDaemon(argv('auth', 'status'), true)).toBe(false); + expect(shouldStartVaultDaemon(argv('mpp', 'pay'), { hasDirectApiKey: true })).toBe(false); + expect(shouldStartVaultDaemon(argv('mpp', 'cancel'), { hasDirectApiKey: true })).toBe(false); + expect(shouldStartVaultDaemon(argv('x402', 'cancel'), { hasDirectApiKey: true })).toBe(false); + expect(shouldStartVaultDaemon(argv('auth', 'status'), { hasDirectApiKey: true })).toBe(false); expect(shouldReconcileVaultDaemon(argv('balances', 'list'), true)).toBe(false); expect(shouldReconcileVaultDaemon(argv('auth', 'status'), true)).toBe(false); + expect(shouldUnlockVault(argv('auth', 'status'), { hasDirectApiKey: true })).toBe(false); + expect(shouldUnlockVault(argv('mpp', 'cancel'), { hasDirectApiKey: true })).toBe(false); + expect(shouldUnlockVault(argv('x402', 'cancel'), { hasDirectApiKey: true })).toBe(false); expect(shouldUnlockVault(argv('x402', 'fetch'), { hasDirectApiKey: true })).toBe(false); }); @@ -258,8 +272,13 @@ describe('vault startup decisions', () => { }); it('does not prompt agents or MCP callers before command handling', () => { + expect(shouldStartVaultDaemon(argv('auth', 'status'), { hasInitializedVault: false, isAgent: true })).toBe(false); + expect(shouldStartVaultDaemon(argv('auth', 'status'), { hasInitializedVault: true, isAgent: true })).toBe(true); expect(shouldUnlockVault(argv('aep', 'status'), { isAgent: true })).toBe(false); + expect(shouldUnlockVault(argv('auth', 'status'), { isAgent: true })).toBe(false); + expect(shouldUnlockVault(argv('mpp', 'cancel'), { isAgent: true })).toBe(false); expect(shouldUnlockVault(argv('--mcp'), { isAgent: true })).toBe(false); expect(shouldUnlockVault(argv('mpp', 'pay', 'https://seller.test'), { isAgent: true })).toBe(false); + expect(shouldUnlockVault(argv('x402', 'cancel'), { isAgent: true })).toBe(false); }); }); diff --git a/packages/cli/test/unit/utils/api-error.test.ts b/packages/cli/test/unit/utils/api-error.test.ts index 2baef1b..820fd46 100644 --- a/packages/cli/test/unit/utils/api-error.test.ts +++ b/packages/cli/test/unit/utils/api-error.test.ts @@ -1,8 +1,22 @@ -import { InflowApiError } from '@inflowpayai/inflow-core'; +import { InflowApiError, SecureStorageError } from '@inflowpayai/inflow-core'; import { describe, expect, it } from 'vitest'; import { authenticatedApiError } from '../../../src/utils/api-error.js'; describe('authenticatedApiError', () => { + it.each([ + ['vault_locked', 'VAULT_LOCKED', 'The InFlow vault is locked. A human must run `inflow vault unlock` first.'], + [ + 'vault_not_initialized', + 'VAULT_NOT_INITIALIZED', + 'The InFlow vault is not initialized. A human must run `inflow vault unlock` first.', + ], + ] as const)('maps %s to an actionable agent error', (secureStorageCode, code, message) => { + expect(authenticatedApiError(new SecureStorageError(secureStorageCode, 'storage unavailable'))).toEqual({ + code, + message, + }); + }); + it('maps rejected credentials to the login recovery frame', () => { expect(authenticatedApiError(new InflowApiError('Unauthorized', { status: 401 }))).toMatchObject({ code: 'NOT_AUTHENTICATED', diff --git a/packages/core/package.json b/packages/core/package.json index 99aac97..8229aa1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -48,24 +48,24 @@ "undici": "7.18.2" }, "peerDependencies": { - "@aep-foundation/agent": "^0.4.0", + "@aep-foundation/agent": "^0.5.0", "@inflowpayai/mpp": "^0.10.0", "@inflowpayai/mpp-buyer": "^0.7.2", - "@inflowpayai/x402": "^0.9.0", - "@inflowpayai/x402-buyer": "^0.9.0", - "@offering-protocol/agent": "^0.4.0", - "@offering-protocol/core": "^0.3.6", + "@inflowpayai/x402": "^0.9.1", + "@inflowpayai/x402-buyer": "^0.9.1", + "@offering-protocol/agent": "^0.4.2", + "@offering-protocol/core": "^0.3.7", "@offering-protocol/directory": "^0.2.5", "@x402/core": "^2.22.0" }, "devDependencies": { - "@aep-foundation/agent": "^0.4.0", + "@aep-foundation/agent": "^0.5.0", "@inflowpayai/mpp": "^0.10.0", "@inflowpayai/mpp-buyer": "^0.7.2", - "@inflowpayai/x402": "^0.9.0", - "@inflowpayai/x402-buyer": "^0.9.0", - "@offering-protocol/agent": "^0.4.0", - "@offering-protocol/core": "^0.3.6", + "@inflowpayai/x402": "^0.9.1", + "@inflowpayai/x402-buyer": "^0.9.1", + "@offering-protocol/agent": "^0.4.2", + "@offering-protocol/core": "^0.3.7", "@offering-protocol/directory": "^0.2.5", "@types/node": "^24.0.0", "@vitest/coverage-v8": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1b49af..78c349e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,8 +82,8 @@ importers: packages/cli: dependencies: '@aep-foundation/agent': - specifier: ^0.4.0 - version: 0.4.0 + specifier: ^0.5.0 + version: 0.5.0 '@inflowpayai/mpp': specifier: ^0.10.0 version: 0.10.0(mppx@0.8.19(typescript@5.9.3)(viem@2.56.0(typescript@5.9.3)(zod@4.4.3))) @@ -91,11 +91,11 @@ importers: specifier: ^0.7.2 version: 0.7.2(mppx@0.8.19(typescript@5.9.3)(viem@2.56.0(typescript@5.9.3)(zod@4.4.3))) '@inflowpayai/x402': - specifier: ^0.9.0 - version: 0.9.0(@x402/core@2.22.0) + specifier: ^0.9.1 + version: 0.9.1(@x402/core@2.22.0) '@inflowpayai/x402-buyer': - specifier: ^0.9.0 - version: 0.9.0(@x402/core@2.22.0) + specifier: ^0.9.1 + version: 0.9.1(@x402/core@2.22.0) '@modelcontextprotocol/server': specifier: 2.0.0-alpha.4 version: 2.0.0-alpha.4 @@ -153,8 +153,8 @@ importers: version: 7.18.2 devDependencies: '@aep-foundation/agent': - specifier: ^0.4.0 - version: 0.4.0 + specifier: ^0.5.0 + version: 0.5.0 '@inflowpayai/mpp': specifier: ^0.10.0 version: 0.10.0(mppx@0.8.19(typescript@5.9.3)(viem@2.56.0(typescript@5.9.3)(zod@4.4.3))) @@ -162,17 +162,17 @@ importers: specifier: ^0.7.2 version: 0.7.2(mppx@0.8.19(typescript@5.9.3)(viem@2.56.0(typescript@5.9.3)(zod@4.4.3))) '@inflowpayai/x402': - specifier: ^0.9.0 - version: 0.9.0(@x402/core@2.22.0) + specifier: ^0.9.1 + version: 0.9.1(@x402/core@2.22.0) '@inflowpayai/x402-buyer': - specifier: ^0.9.0 - version: 0.9.0(@x402/core@2.22.0) + specifier: ^0.9.1 + version: 0.9.1(@x402/core@2.22.0) '@offering-protocol/agent': - specifier: ^0.4.0 - version: 0.4.0(@types/json-schema@7.0.15) + specifier: ^0.4.2 + version: 0.4.2(@types/json-schema@7.0.15) '@offering-protocol/core': - specifier: ^0.3.6 - version: 0.3.6 + specifier: ^0.3.7 + version: 0.3.7 '@offering-protocol/directory': specifier: ^0.2.5 version: 0.2.5 @@ -191,16 +191,12 @@ packages: '@adraffy/ens-normalize@1.11.1': resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} - '@aep-foundation/agent@0.4.0': - resolution: {integrity: sha512-yqwi+7BuyjKfGr91N1ZJ/Yc4dIlMAX3LsYAg0lAOG1in7HOfk2VyCLoZiUsaDSwPxxroLmlQX/Sh+3XgPJfzjg==} + '@aep-foundation/agent@0.5.0': + resolution: {integrity: sha512-1RwxahB78YHfnIbwIEkimNH+9uPAL+y0akI05cuhbP/Vb+AGXHALirF2PSobe6M/+bSq5cY5crJ4luXBiTOWDA==} engines: {node: '>=22.0.0'} - '@aep-foundation/core@0.5.1': - resolution: {integrity: sha512-cYUcd2DSC1E9hlGweVUunauFDt5mkCtNdSh55CvFzSCr6vq2HkWuKQYkcfYJoiO8Ttb+sN03xXBko2mIqO1Hiw==} - engines: {node: '>=22.0.0'} - - '@aep-foundation/platform@0.2.5': - resolution: {integrity: sha512-HcHNYe5MEXHohxDcS6YjtI3ZeEakanV3Atb9h8uD4oNIldfvyObwCTGaKe7GD0SkDVd6Alwygs45vU+QWnsQYg==} + '@aep-foundation/core@0.6.0': + resolution: {integrity: sha512-s2AXdQ5IAyz1w8BKoihOqy+m8LDSMr53Jv1gKoRDoUYoFqbVLHDPzfhjYVcgjgpxwtn7oy0DEMl19kWiG9zUOg==} engines: {node: '>=22.0.0'} '@alcalzone/ansi-tokenize@0.1.3': @@ -866,14 +862,14 @@ packages: peerDependencies: mppx: ^0.8.17 - '@inflowpayai/x402-buyer@0.9.0': - resolution: {integrity: sha512-quDKGkqwPjtW4twakI9VrQCnMPPTLm4Ipifv93XRfPJQai7ip17JSWykZDRm05EpLh3jrbRzRA+wQ8HH67DcuA==} + '@inflowpayai/x402-buyer@0.9.1': + resolution: {integrity: sha512-/qLvNj2Umx7ue51iK3VVPGxWwr1CLF/837SwBW3UmKnQGcUpc7lvrp5jkIch9mm2x6rOH0pvutWljV9MQ90DHA==} engines: {node: '>=22.0.0'} peerDependencies: '@x402/core': ^2.22.0 - '@inflowpayai/x402@0.9.0': - resolution: {integrity: sha512-yuRqE+iAvi5hXx1Yxtp1T5AfkmUUAQ06AS0Z4Vx26XlBY4DwkQDp/qWCIzixQBzNRm5UeIxri0GdoCMeir0xqg==} + '@inflowpayai/x402@0.9.1': + resolution: {integrity: sha512-3sA9bMKvoTSrFBaZC/jIo/k/9ff4uy0NAsCR8iAwnZ0wdseDFEXx4+zqMl1g+xhIUBX1uW9sWSdOnwPD2zytfQ==} engines: {node: '>=22.0.0'} peerDependencies: '@x402/core': ^2.22.0 @@ -1082,12 +1078,12 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@offering-protocol/agent@0.4.0': - resolution: {integrity: sha512-fQleoZPl2Pb0YzJAdQz3fl1RMNfvlkYIUvXSuJSd5F7hwwm8V7+U54OY0N7PhVTSWlx7J61xUyHK+bXKE7ekqg==} + '@offering-protocol/agent@0.4.2': + resolution: {integrity: sha512-XlSo+pqfwdLJuHq0I3ZDC5nZvyYMss3mo2mvhC/tsqesFcayPmuhoLDfDaRy4XnTmWYVrjdwvU6OI0dTfQWTig==} engines: {node: '>=22.0.0'} - '@offering-protocol/core@0.3.6': - resolution: {integrity: sha512-yr4rCZyDFzdecRm/iVbWqOZU/FkChVNN//NjvfTgwgF59MF6tYR4TZa5z5A7WpC34rFtEf4+xoK/Ks+obLNaTQ==} + '@offering-protocol/core@0.3.7': + resolution: {integrity: sha512-yfYQUIzZctnZQTlXr2BZdEhGlurzf023eDpg52le/GlMsqoqPSokaqPe4r4mq3c2bzaAsH9wrI1Ojaai37Hqcg==} engines: {node: '>=22.0.0'} '@offering-protocol/directory@0.2.5': @@ -3154,19 +3150,14 @@ snapshots: '@adraffy/ens-normalize@1.11.1': {} - '@aep-foundation/agent@0.4.0': + '@aep-foundation/agent@0.5.0': dependencies: - '@aep-foundation/core': 0.5.1 - '@aep-foundation/platform': 0.2.5 + '@aep-foundation/core': 0.6.0 - '@aep-foundation/core@0.5.1': + '@aep-foundation/core@0.6.0': dependencies: jose: 6.2.10 - '@aep-foundation/platform@0.2.5': - dependencies: - '@aep-foundation/core': 0.5.1 - '@alcalzone/ansi-tokenize@0.1.3': dependencies: ansi-styles: 6.2.3 @@ -3718,13 +3709,13 @@ snapshots: dependencies: mppx: 0.8.19(typescript@5.9.3)(viem@2.56.0(typescript@5.9.3)(zod@4.4.3)) - '@inflowpayai/x402-buyer@0.9.0(@x402/core@2.22.0)': + '@inflowpayai/x402-buyer@0.9.1(@x402/core@2.22.0)': dependencies: - '@inflowpayai/x402': 0.9.0(@x402/core@2.22.0) + '@inflowpayai/x402': 0.9.1(@x402/core@2.22.0) '@x402/core': 2.22.0 bs58: 6.0.0 - '@inflowpayai/x402@0.9.0(@x402/core@2.22.0)': + '@inflowpayai/x402@0.9.1(@x402/core@2.22.0)': dependencies: '@x402/core': 2.22.0 @@ -3907,12 +3898,12 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 - '@offering-protocol/agent@0.4.0(@types/json-schema@7.0.15)': + '@offering-protocol/agent@0.4.2(@types/json-schema@7.0.15)': dependencies: '@apidevtools/json-schema-ref-parser': 15.5.2(patch_hash=11973baa1c73bf2ee8c372f559f79dd0641b1f9e5b8e9967a0f7b1b0bea38a23)(@types/json-schema@7.0.15) '@hyperjump/browser': 1.5.0 '@hyperjump/json-schema': 1.17.8(@hyperjump/browser@1.5.0) - '@offering-protocol/core': 0.3.6 + '@offering-protocol/core': 0.3.7 '@offering-protocol/directory': 0.2.5 ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) @@ -3922,7 +3913,7 @@ snapshots: transitivePeerDependencies: - '@types/json-schema' - '@offering-protocol/core@0.3.6': + '@offering-protocol/core@0.3.7': dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) @@ -3930,7 +3921,7 @@ snapshots: '@offering-protocol/directory@0.2.5': dependencies: - '@offering-protocol/core': 0.3.6 + '@offering-protocol/core': 0.3.7 '@open-draft/deferred-promise@2.2.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 98f9d47..f4a3f74 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -20,7 +20,7 @@ minimumReleaseAgeExclude: - '@aep-foundation/agent@0.4.0' - '@aep-foundation/core@0.5.1' - '@aep-foundation/platform@0.2.5' - - '@offering-protocol/agent@0.4.1' + - '@offering-protocol/agent@0.4.2' - '@offering-protocol/core@0.3.7' - '@offering-protocol/directory@0.2.5' diff --git a/scripts/link-local-inflow-node.mjs b/scripts/link-local-inflow-node.mjs index 27974e5..7bc2bb1 100755 --- a/scripts/link-local-inflow-node.mjs +++ b/scripts/link-local-inflow-node.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; -import { setAllowUnusedPatches } from './local-link-workspace.mjs'; +import { replaceManagedOverrides, setAllowUnusedPatches } from './local-link-workspace.mjs'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const WORKSPACE_YAML = path.join(REPO_ROOT, 'pnpm-workspace.yaml'); @@ -34,9 +34,6 @@ const ODP_LINKED = ['@offering-protocol/agent', '@offering-protocol/core', '@off const LINKED = [...INFLOW_LINKED, ...AEP_LINKED, ...ODP_LINKED]; const INFLOW_NODE_AEP_LINKED = ['@aep-foundation/core', '@aep-foundation/express', '@aep-foundation/service']; -const BEGIN_MARK = '# >>> link-local-inflow-node:overrides'; -const END_MARK = '# <<< link-local-inflow-node:overrides'; - function resolveInflowNodePath() { const fromEnv = process.env.INFLOW_NODE_PATH; if (fromEnv !== undefined && fromEnv.length > 0) { @@ -89,32 +86,30 @@ async function assertCheckout(checkoutPath, packages, checkoutName) { } } -function buildOverridesBlock(workspaceRoot, inflowNodePath, aepNodePath, odpNodePath, packages) { - const lines = [BEGIN_MARK, 'overrides:']; +function buildOverrides(workspaceRoot, inflowNodePath, aepNodePath, odpNodePath, packages) { + const entries = []; for (const name of packages.inflow) { const sub = name.split('/')[1]; const rel = path.relative(workspaceRoot, path.join(inflowNodePath, 'packages', sub)); - lines.push(` '${name}': link:${rel}`); + entries.push([name, `link:${rel}`]); } for (const name of packages.aep) { const rel = path.relative(workspaceRoot, path.join(aepNodePath, aepPackageDirectory(name))); - lines.push(` '${name}': link:${rel}`); + entries.push([name, `link:${rel}`]); } for (const name of packages.odp) { const sub = name.split('/')[1]; const rel = path.relative(workspaceRoot, path.join(odpNodePath, 'packages', sub)); - lines.push(` '${name}': link:${rel}`); + entries.push([name, `link:${rel}`]); } - lines.push(END_MARK); - return lines.join('\n'); + return entries; } async function writeOverrides(workspaceRoot, inflowNodePath, aepNodePath, odpNodePath, packages) { const workspaceYaml = path.join(workspaceRoot, 'pnpm-workspace.yaml'); const existing = await fs.readFile(workspaceYaml, 'utf-8'); - const stripped = stripExistingBlock(existing); - const block = buildOverridesBlock(workspaceRoot, inflowNodePath, aepNodePath, odpNodePath, packages); - const next = stripped.endsWith('\n') ? `${stripped}${block}\n` : `${stripped}\n${block}\n`; + const entries = buildOverrides(workspaceRoot, inflowNodePath, aepNodePath, odpNodePath, packages); + const next = replaceManagedOverrides(existing, entries); if (next !== existing) await fs.writeFile(workspaceYaml, next, 'utf-8'); return next !== existing; @@ -125,18 +120,6 @@ function aepPackageDirectory(name) { return sub === 'express' ? path.join('packages', 'adapters', sub) : path.join('packages', sub); } -function stripExistingBlock(yaml) { - // Removes both our managed block and any pre-existing `overrides:` line - // owned by a human edit. We rewrite the block on every run; humans who - // need other overrides should keep them outside our markers. - const re = new RegExp(`\\n?${escapeRe(BEGIN_MARK)}[\\s\\S]*?${escapeRe(END_MARK)}\\n?`, 'g'); - return yaml.replace(re, '\n'); -} - -function escapeRe(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - async function clearLocalPackageReferences(workspaceRoot, packages) { const packageJson = path.join(workspaceRoot, 'package.json'); const raw = await fs.readFile(packageJson, 'utf-8'); diff --git a/scripts/local-link-workspace.mjs b/scripts/local-link-workspace.mjs index 1fee9e6..bb11346 100644 --- a/scripts/local-link-workspace.mjs +++ b/scripts/local-link-workspace.mjs @@ -1,6 +1,8 @@ import { promises as fs } from 'node:fs'; const ALLOW_UNUSED_PATCHES = /^allowUnusedPatches:[\t ]*(?:true|false)[\t ]*$/gm; +const BEGIN_MARK = '# >>> link-local-inflow-node:overrides'; +const END_MARK = '# <<< link-local-inflow-node:overrides'; export function replaceAllowUnusedPatches(yaml, enabled) { const matches = [...yaml.matchAll(ALLOW_UNUSED_PATCHES)]; @@ -17,3 +19,67 @@ export async function setAllowUnusedPatches(workspaceYaml, enabled) { await fs.writeFile(workspaceYaml, next, 'utf-8'); return true; } + +export function managedOverrides(yaml) { + const lines = yaml.split('\n'); + const start = lines.findIndex((line) => line.trim() === BEGIN_MARK); + if (start === -1) return new Map(); + const end = lines.findIndex((line, index) => index > start && line.trim() === END_MARK); + if (end === -1) throw new Error(`Missing ${END_MARK}.`); + const entries = new Map(); + for (const line of lines.slice(start + 1, end)) { + const match = line.match(/^\s*'(@[^']+)':\s*(.+)$/); + if (match !== null) entries.set(match[1], match[2]); + } + return entries; +} + +export function removeManagedOverrides(yaml) { + const lines = yaml.split('\n'); + const output = []; + let managed = false; + for (const line of lines) { + if (line.trim() === BEGIN_MARK) { + if (managed) throw new Error(`Duplicate ${BEGIN_MARK}.`); + managed = true; + continue; + } + if (line.trim() === END_MARK) { + if (!managed) throw new Error(`Unexpected ${END_MARK}.`); + managed = false; + continue; + } + if (!managed) output.push(line); + } + if (managed) throw new Error(`Missing ${END_MARK}.`); + removeEmptyOverridesMapping(output); + return output.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/u, '\n'); +} + +export function replaceManagedOverrides(yaml, entries) { + const stripped = removeManagedOverrides(yaml); + const lines = stripped.split('\n'); + const headers = lines.flatMap((line, index) => (line === 'overrides:' ? [index] : [])); + if (headers.length > 1) throw new Error(`Expected at most one top-level overrides mapping; found ${headers.length}.`); + const block = [ + ` ${BEGIN_MARK}`, + ...entries.map(([name, value]) => ` '${name}': ${value}`), + ` ${END_MARK}`, + ]; + if (headers.length === 1) { + lines.splice(headers[0] + 1, 0, ...block); + } else { + while (lines.at(-1) === '') lines.pop(); + if (lines.length > 0) lines.push(''); + lines.push('overrides:', ...block, ''); + } + return lines.join('\n'); +} + +function removeEmptyOverridesMapping(lines) { + const index = lines.findIndex((line) => line === 'overrides:'); + if (index === -1) return; + let following = index + 1; + while (following < lines.length && lines[following].trim() === '') following += 1; + if (following === lines.length || !/^\s/u.test(lines[following])) lines.splice(index, 1); +} diff --git a/scripts/local-link-workspace.test.mjs b/scripts/local-link-workspace.test.mjs index 54bb00b..58d1fc2 100644 --- a/scripts/local-link-workspace.test.mjs +++ b/scripts/local-link-workspace.test.mjs @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { replaceAllowUnusedPatches } from './local-link-workspace.mjs'; +import { + managedOverrides, + removeManagedOverrides, + replaceAllowUnusedPatches, + replaceManagedOverrides, +} from './local-link-workspace.mjs'; test('enables unused patches while local overrides are active', () => { const yaml = "packages:\n - 'packages/*'\n\nallowUnusedPatches: false\n\npatchedDependencies:\n"; @@ -23,3 +28,25 @@ test('rejects a missing or duplicate setting', () => { /found 2/, ); }); + +test('adds managed overrides to an existing mapping', () => { + const yaml = "packages: []\n\noverrides:\n esbuild: ^0.28.2\n\npublicHoistPattern: []\n"; + const linked = replaceManagedOverrides(yaml, [['@aep-foundation/core', 'link:../aep-node/packages/core']]); + assert.match(linked, /overrides:\n # >>> link-local-inflow-node:overrides\n '@aep-foundation\/core': link:\.\.\/aep-node\/packages\/core\n # <<< link-local-inflow-node:overrides\n esbuild: \^0\.28\.2/); + assert.equal(removeManagedOverrides(linked), yaml); +}); + +test('creates and removes a managed overrides mapping', () => { + const yaml = 'packages: []\n'; + const linked = replaceManagedOverrides(yaml, [['@offering-protocol/core', 'link:../odp-node/packages/core']]); + assert.deepEqual(managedOverrides(linked), new Map([['@offering-protocol/core', 'link:../odp-node/packages/core']])); + assert.equal(removeManagedOverrides(linked), yaml); +}); + +test('replaces the legacy managed overrides block', () => { + const legacy = + "packages: []\n\n# >>> link-local-inflow-node:overrides\noverrides:\n '@aep-foundation/core': link:../old\n# <<< link-local-inflow-node:overrides\n"; + const linked = replaceManagedOverrides(legacy, [['@aep-foundation/core', 'link:../new']]); + assert.equal(managedOverrides(linked).get('@aep-foundation/core'), 'link:../new'); + assert.equal(linked.match(/^overrides:/gm)?.length, 1); +}); diff --git a/scripts/unlink-local-inflow-node.mjs b/scripts/unlink-local-inflow-node.mjs index aed3eea..c5beb1f 100755 --- a/scripts/unlink-local-inflow-node.mjs +++ b/scripts/unlink-local-inflow-node.mjs @@ -13,7 +13,12 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; -import { setAllowUnusedPatches } from './local-link-workspace.mjs'; +import { + managedOverrides, + removeManagedOverrides, + replaceManagedOverrides, + setAllowUnusedPatches, +} from './local-link-workspace.mjs'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ROOT_PKG_JSON = path.join(REPO_ROOT, 'package.json'); @@ -38,13 +43,6 @@ const UNPUBLISHED = []; const LINKED = [...PUBLISHED, ...UNPUBLISHED]; const INFLOW_NODE_AEP_LINKED = ['@aep-foundation/core', '@aep-foundation/express', '@aep-foundation/service']; -const BEGIN_MARK = '# >>> link-local-inflow-node:overrides'; -const END_MARK = '# <<< link-local-inflow-node:overrides'; - -function escapeRe(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - function run(cmd, args, opts = {}) { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: 'inherit', cwd: REPO_ROOT, ...opts }); @@ -67,8 +65,7 @@ async function removeInflowNodeOverrides() { const inflowNodePath = resolveInflowNodePath(); const workspaceYaml = path.join(inflowNodePath, 'pnpm-workspace.yaml'); const existing = await fs.readFile(workspaceYaml, 'utf-8'); - const blockRe = new RegExp(`\\n?${escapeRe(BEGIN_MARK)}[\\s\\S]*?${escapeRe(END_MARK)}\\n?`); - const next = existing.replace(blockRe, '\n').replace(/\n{3,}/g, '\n\n'); + const next = removeManagedOverrides(existing); if (next !== existing) await fs.writeFile(workspaceYaml, next, 'utf-8'); const packageJson = path.join(inflowNodePath, 'package.json'); const manifest = JSON.parse(await fs.readFile(packageJson, 'utf-8')); @@ -94,30 +91,27 @@ async function removeInflowNodeOverrides() { */ async function revertWorkspaceYaml() { const existing = await fs.readFile(WORKSPACE_YAML, 'utf-8'); - const blockRe = new RegExp(`\\n?${escapeRe(BEGIN_MARK)}([\\s\\S]*?)${escapeRe(END_MARK)}\\n?`); - const match = existing.match(blockRe); - if (match === null) { + const entries = managedOverrides(existing); + if (entries.size === 0) { return { changed: false, reverted: [], kept: [] }; } const reverted = []; - const keptLines = []; + const keptEntries = []; const kept = []; - for (const line of match[1].split('\n')) { - const entry = line.match(/^\s*'(@[^']+)':/); - if (entry === null) continue; // skip the `overrides:` header and blank lines - const name = entry[1]; + for (const [name, value] of entries) { if (UNPUBLISHED.includes(name)) { - keptLines.push(` ${line.trim()}`); + keptEntries.push([name, value]); kept.push(name); } else { reverted.push(name); } } - const replacement = - keptLines.length > 0 ? `\n${[BEGIN_MARK, 'overrides:', ...keptLines, END_MARK].join('\n')}\n` : '\n'; - const next = existing.replace(blockRe, replacement).replace(/\n{3,}/g, '\n\n'); + const next = + keptEntries.length > 0 + ? replaceManagedOverrides(existing, keptEntries) + : removeManagedOverrides(existing); if (next !== existing) { await fs.writeFile(WORKSPACE_YAML, next, 'utf-8');