diff --git a/src/index.ts b/src/index.ts index 51ecfb8..fc4a091 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,34 +1,20 @@ -import url from 'node:url'; -import path from 'node:path'; import _ from 'lodash'; -import { glob } from 'glob'; -import sortObject from '@znemz/sort-object'; -import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; -import { addProxyToClient } from 'aws-sdk-v3-proxy'; -import deepMerge from 'deepmerge'; -import { isTaggableResource } from '@znemz/cft-utils/src/resources/taggable.js'; -import request from './lib/request.js'; -import * as PromiseExt from './lib/promise.js'; -import * as yaml from './lib/yaml.js'; -import { getParser } from './lib/include/query.js'; import parseLocation from './lib/parselocation.js'; import replaceEnv from './lib/replaceEnv.js'; -import { lowerCamelCase, upperCamelCase } from './lib/utils.js'; -import { isOurExplicitFunction } from './lib/schema.js'; -import { getAwsPseudoParameters, buildResourceArn } from './lib/internals.js'; -import { cachedReadFile } from './lib/cache.js'; import { createChildScope } from './lib/scope.js'; import { promiseProps } from './lib/promise-utils.js'; +import { buildRegistry } from './lib/functions/registry.js'; +import { getBoolEnvOpt } from './lib/functions/helpers.js'; +import type { RecurseContext } from './lib/functions/types.js'; +import { MAX_RECURSE_DEPTH } from './lib/functions/types.js'; import type { IncludeOptions as TypeIncludeOptions, - ParsedLocation, Scope, TemplateValue, TemplateDocument, TemplateObject, - Resource, } from './types/index.js'; // Re-export types @@ -48,281 +34,46 @@ interface MainIncludeOptions { refNowReturnType?: 'arn' | 'name'; } -const S3 = (opts = {}) => addProxyToClient(new S3Client(opts), { throwOnNoProxy: false }); -const s3 = S3(); +// Build registry with recurse reference +let registry: ReturnType; -interface RecurseContext { - base: ParsedLocation; - scope: Scope; - cft: TemplateValue; - rootTemplate?: TemplateDocument; - caller?: string; - key?: string; - doEnv?: boolean; - doEval?: boolean; - doLog?: boolean; - inject?: Record; - refNowIgnores?: string[]; - refNowIgnoreMissing?: boolean; - refNowReturnType?: 'arn' | 'name'; -} - -interface FnIncludeContext extends Omit { - cft: TemplateValue; -} - -/** - * Main entry point for cfn-include template processing - */ -export default async function include(options: MainIncludeOptions): Promise { - let { template } = options; - const doEnv = getBoolEnvOpt(options.doEnv, 'CFN_INCLUDE_DO_ENV'); - const doEval = getBoolEnvOpt(options.doEval, 'CFN_INCLUDE_DO_EVAL'); - - const base = parseLocation(options.url); - const scope: Scope = options.scope || {}; - if (base.relative) throw new Error('url cannot be relative'); - - const processedTemplate = !template - ? fnInclude({ ...options, base, scope, cft: options.url, doEnv, doEval }) - : template; - - const resolvedTemplate = await Promise.resolve(processedTemplate); - return recurse({ - base, - scope, - cft: resolvedTemplate, - rootTemplate: resolvedTemplate as TemplateDocument, - doEnv, - doEval, - doLog: options.doLog, - inject: options.inject, - refNowIgnores: options.refNowIgnores, - refNowIgnoreMissing: options.refNowIgnoreMissing, - }); -} - -/** - * Recursively process CloudFormation template, handling all Fn:: intrinsics - */ async function recurse(ctx: RecurseContext): Promise { - const { base, cft, rootTemplate, caller, ...opts } = ctx; + const { base, cft, rootTemplate, caller, depth = 0, ...opts } = ctx; let { scope } = ctx; + if (depth > MAX_RECURSE_DEPTH) { + throw new Error(`Maximum recursion depth (${MAX_RECURSE_DEPTH}) exceeded at caller: ${caller}`); + } + if (opts.doLog) { console.log({ base, scope, cft, rootTemplate, caller, ...opts }); } scope = createChildScope(scope); + const nextDepth = depth + 1; + if (Array.isArray(cft)) { return Promise.all( - cft.map((o) => recurse({ base, scope, cft: o, rootTemplate, caller: 'recurse:isArray', ...opts })), + cft.map((o) => recurse({ base, scope, cft: o, rootTemplate, caller: 'recurse:isArray', depth: nextDepth, ...opts })), ); } if (_.isPlainObject(cft)) { const obj = cft as TemplateObject; - if (obj['Fn::Map']) { - return handleFnMap({ base, scope, cft: obj, rootTemplate, ...opts }); - } - - if (obj['Fn::Length']) { - const arg = obj['Fn::Length']; - if (Array.isArray(arg)) { - return arg.length; + // Dispatch to registered handler + for (const fnName of Object.keys(obj)) { + const handler = registry.handlers[fnName]; + if (handler) { + return handler({ ...ctx, scope, depth: nextDepth }); } - const result = await recurse({ base, scope, cft: arg, rootTemplate, caller: 'Fn::Length', ...opts }); - return Array.isArray(result) ? result.length : 0; - } - - if (obj['Fn::Include']) { - const json = await fnInclude({ base, scope, cft: obj['Fn::Include'], ...opts }); - if (!_.isPlainObject(json)) return json; - delete obj['Fn::Include']; - _.defaults(obj, json); - const replaced = findAndReplace(scope, obj) as any; - return recurse({ base, scope, cft: replaced, rootTemplate, caller: 'Fn::Include', ...opts }); - } - - if (obj['Fn::Flatten']) { - const json = await recurse({ base, scope, cft: obj['Fn::Flatten'], rootTemplate, caller: 'Fn::Flatten', ...opts }); - return (json as unknown[]).flat(); } - if (obj['Fn::FlattenDeep']) { - const json = await recurse({ base, scope, cft: obj['Fn::FlattenDeep'], rootTemplate, caller: 'Fn::FlattenDeep', ...opts }); - return (json as unknown[]).flat(Infinity); - } - - if (obj['Fn::Uniq']) { - const json = await recurse({ base, scope, cft: obj['Fn::Uniq'], rootTemplate, caller: 'Fn::Uniq', ...opts }); - return [...new Set(json as unknown[])]; - } - - if (obj['Fn::Compact']) { - const json = await recurse({ base, scope, cft: obj['Fn::Compact'], rootTemplate, caller: 'Fn::Compact', ...opts }); - return (json as unknown[]).filter(Boolean); - } - - if (obj['Fn::Concat']) { - const json = await recurse({ base, scope, cft: obj['Fn::Concat'], rootTemplate, caller: 'Fn::Concat', ...opts }); - return _.concat(...(json as unknown[][])); - } - - if (obj['Fn::Sort']) { - const array = await recurse({ base, scope, cft: obj['Fn::Sort'], rootTemplate, caller: 'Fn::Sort', ...opts }); - return (array as unknown[]).sort(); - } - - if (obj['Fn::SortedUniq']) { - const array = await recurse({ base, scope, cft: obj['Fn::SortedUniq'], rootTemplate, caller: 'Fn::SortedUniq', ...opts }); - return _.sortedUniq((array as unknown[]).sort()); - } - - if (obj['Fn::SortBy']) { - const { list, iteratees } = await recurse({ base, scope, cft: obj['Fn::SortBy'], rootTemplate, caller: 'Fn::SortBy', ...opts }) as { list: unknown[]; iteratees: string | string[] }; - return _.sortBy(list, iteratees); - } - - if (obj['Fn::SortObject']) { - const result = await recurse({ base, scope, cft: obj['Fn::SortObject'], rootTemplate, caller: 'Fn::SortObject', ...opts }) as { object?: unknown; options?: Record }; - const { object, options: sortOpts, ...rest } = result; - return sortObject(object || rest, sortOpts); - } - - if (obj['Fn::Without']) { - const json = await recurse({ base, scope, cft: obj['Fn::Without'], rootTemplate, caller: 'Fn::Without', ...opts }); - const normalized = Array.isArray(json) ? { list: json[0] as unknown[], withouts: json[1] as unknown[] } : json as { list: unknown[]; withouts: unknown[] }; - return _.without(normalized.list, ...normalized.withouts); - } - - if (obj['Fn::Omit']) { - const json = await recurse({ base, scope, cft: obj['Fn::Omit'], rootTemplate, caller: 'Fn::Omit', ...opts }); - const normalized = Array.isArray(json) ? { object: json[0] as Record, omits: json[1] as string[] } : json as { object: Record; omits: string[] }; - return _.omit(normalized.object, normalized.omits); - } - - if (obj['Fn::OmitEmpty']) { - const json = await recurse({ base, scope, cft: obj['Fn::OmitEmpty'], rootTemplate, caller: 'Fn::OmitEmpty', ...opts }) as Record; - return _.omitBy(json, (v) => !v && v !== false && v !== 0); - } - - if (obj['Fn::Eval']) { - if (!opts.doEval) { - return Promise.reject(new Error('Fn::Eval is not allowed doEval is falsy')); - } - const json = await recurse({ base, scope, cft: obj['Fn::Eval'], rootTemplate, caller: 'Fn::Eval', ...opts }) as { state?: unknown; script: string; inject?: Record; doLog?: boolean }; - let { script } = json; - const { state, inject, doLog } = json; - script = replaceEnv(script, _.merge(_.cloneDeep(opts.inject), inject), opts.doEnv) as string; - if (doLog) { - console.log({ state, script, inject }); - } - // eslint-disable-next-line no-eval - return eval(script); - } - - if (obj['Fn::Filenames']) { - return handleFnFilenames({ base, scope, cft: obj['Fn::Filenames'], rootTemplate, ...opts }); - } - - if (obj['Fn::Merge']) { - const json = await recurse({ base, scope, cft: obj['Fn::Merge'], rootTemplate, caller: 'Fn::Merge', ...opts }) as unknown[]; - delete obj['Fn::Merge']; - return recurse({ base, scope, cft: _.defaults(obj, _.merge.apply(_, json as [object, ...object[]])), rootTemplate, caller: 'Fn::Merge', ...opts }); - } - - if (obj['Fn::DeepMerge']) { - const json = await recurse({ base, scope, cft: obj['Fn::DeepMerge'], rootTemplate, caller: 'Fn::DeepMerge', ...opts }) as unknown[]; - delete obj['Fn::DeepMerge']; - let mergedObj = {}; - if (json?.length) { - for (const j of json) { - mergedObj = deepMerge(mergedObj, j as object); - } - } - return recurse({ base, scope, cft: _.defaults(obj, mergedObj), rootTemplate, caller: 'Fn::DeepMerge', ...opts }); - } - - if (obj['Fn::ObjectKeys']) { - const json = await recurse({ base, scope, cft: obj['Fn::ObjectKeys'], rootTemplate, caller: 'Fn::ObjectKeys', ...opts }); - return Object.keys(json as object); - } - - if (obj['Fn::ObjectValues']) { - const json = await recurse({ base, scope, cft: obj['Fn::ObjectValues'], rootTemplate, caller: 'Fn::ObjectValues', ...opts }); - return Object.values(json as object); - } - - if (obj['Fn::Stringify']) { - const json = await recurse({ base, scope, cft: obj['Fn::Stringify'], rootTemplate, caller: 'Fn::Stringify', ...opts }); - return JSON.stringify(json); - } - - if (obj['Fn::StringSplit']) { - const { string = '', separator = ',', doLog } = await recurse({ base, scope, cft: obj['Fn::StringSplit'], rootTemplate, caller: 'Fn::StringSplit', ...opts }) as { string?: string; separator?: string; doLog?: boolean }; - if (doLog) console.log({ string, separator }); - return string.split(separator); - } - - if (obj['Fn::UpperCamelCase']) { - return upperCamelCase(obj['Fn::UpperCamelCase'] as string); - } - - if (obj['Fn::LowerCamelCase']) { - return lowerCamelCase(obj['Fn::LowerCamelCase'] as string); - } - - if (obj['Fn::GetEnv']) { - const args = obj['Fn::GetEnv']; - if (Array.isArray(args)) { - const val = process.env[args[0] as string]; - return val === undefined ? args[1] : val; - } - const val = process.env[args as string]; - if (val === undefined) { - throw new Error(`environmental variable ${args} is undefined`); - } - return val; - } - - if (obj['Fn::Outputs']) { - return handleFnOutputs({ base, scope, cft: obj['Fn::Outputs'], rootTemplate, ...opts }); - } - - if (obj['Fn::Sequence']) { - return handleFnSequence({ base, scope, cft: obj['Fn::Sequence'], rootTemplate, ...opts }); - } - - if (obj['Fn::IfEval']) { - return handleFnIfEval({ base, scope, cft: obj['Fn::IfEval'], rootTemplate, ...opts }); - } - - if (obj['Fn::JoinNow']) { - const array = await recurse({ base, scope, cft: obj['Fn::JoinNow'], rootTemplate, caller: 'Fn::JoinNow', ...opts }) as [string, unknown[]]; - let [delimiter, toJoinArray] = array; - delimiter = replaceEnv(delimiter, opts.inject, opts.doEnv) as string; - return toJoinArray.join(delimiter); - } - - if (obj['Fn::SubNow']) { - return handleFnSubNow({ base, scope, cft: obj['Fn::SubNow'], rootTemplate, ...opts }); - } - - if (obj['Fn::RefNow']) { - return handleFnRefNow({ base, scope, cft: obj['Fn::RefNow'], rootTemplate, ...opts }); - } - - if (obj['Fn::ApplyTags']) { - return handleFnApplyTags({ base, scope, cft: obj['Fn::ApplyTags'], rootTemplate, ...opts }); - } - - // Process remaining properties + // Process remaining properties (no Fn:: match) return promiseProps( _.mapValues(obj, (template, key) => - recurse({ base, scope, cft: template, key, rootTemplate, caller: 'recurse:isPlainObject:end', ...opts }), + recurse({ base, scope, cft: template, key, rootTemplate, caller: 'recurse:isPlainObject:end', depth: nextDepth, ...opts }), ), ); } @@ -334,556 +85,36 @@ async function recurse(ctx: RecurseContext): Promise { return replaceEnv(cft, opts.inject, opts.doEnv) as TemplateValue; } -// Handler functions for complex Fn:: intrinsics - -async function handleFnMap(ctx: RecurseContext): Promise { - const { base, scope, cft, rootTemplate, ...opts } = ctx; - const obj = cft as TemplateObject; - const args = obj['Fn::Map'] as unknown[]; - const [list] = args; - const body = args[args.length - 1]; - let placeholder = args[1] as string | string[]; - let idx: string | undefined; - let sz: string | undefined; - let hasindex = false; - let hassize = false; - - if (Array.isArray(placeholder)) { - idx = placeholder[1]; - hasindex = true; - if (placeholder.length > 2) { - sz = placeholder[2]; - hassize = true; - } - placeholder = placeholder[0]; - } - if (args.length === 2) { - placeholder = '_'; - } - - let result: any = await PromiseExt.mapX( - recurse({ base, scope, cft: list as any, rootTemplate, caller: 'Fn::Map', ...opts }), - (replace, key) => { - const additions: Record = { [placeholder as string]: replace }; - if (hasindex && idx) { - additions[idx] = key; - } - const childScope = createChildScope(scope, additions); - const replaced = findAndReplace(childScope, _.cloneDeep(body)) as any; - return recurse({ base, scope: childScope, cft: replaced, rootTemplate, caller: 'Fn::Map', ...opts }); - }, - ); - - if (hassize && sz) { - result = findAndReplace({ [sz]: result.length }, result) as any; - } - return recurse({ base, scope, cft: result, rootTemplate, caller: 'Fn::Map', ...opts }); -} - -async function handleFnFilenames(ctx: RecurseContext): Promise { - const { base, scope, cft, rootTemplate, ...opts } = ctx; - const json = await recurse({ base, scope, cft, rootTemplate, caller: 'Fn::Filenames', ...opts }); - const normalized = _.isPlainObject(json) ? { ...(json as object) } : { location: json }; - const { location: loc, omitExtension, doLog } = normalized as { location: unknown; omitExtension?: boolean; doLog?: boolean }; - - if (doLog) console.log(normalized); - - const location = parseLocation(loc as string); - if (!_.isEmpty(location) && !location.protocol) { - location.protocol = base.protocol; - } +// Initialize registry +registry = buildRegistry(recurse); - if (location.protocol === 'file') { - const absolute = location.relative - ? path.join(path.dirname(base.path || ''), location.host || '', location.path || '') - : [location.host, location.path].join(''); - const globs = (await glob(absolute)).sort(); - if (omitExtension) { - return globs.map((f) => path.basename(f, path.extname(f))); - } - return globs; - } - return 'Unsupported File Type'; -} - -async function handleFnOutputs(ctx: RecurseContext): Promise { - const { base, scope, cft, ...opts } = ctx; - const outputs = await recurse({ base, scope, cft, caller: 'Fn::Outputs', ...opts }) as Record; - const result: Record = {}; - - for (const output in outputs) { - const val = outputs[output]; - const exp = { - Export: { Name: { 'Fn::Sub': '${AWS::StackName}:' + output } }, - }; - if (!Array.isArray(val) && typeof val === 'object' && val !== null) { - const objVal = val as { Value?: unknown; Condition?: unknown }; - result[output] = { - Value: { 'Fn::Sub': objVal.Value }, - Condition: objVal.Condition, - ...exp, - }; - } else { - result[output] = { - Value: { 'Fn::Sub': val }, - ...exp, - }; - } - } - return result; -} - -async function handleFnSequence(ctx: RecurseContext): Promise { - const { base, scope, cft, ...opts } = ctx; - const outputs = await recurse({ base, scope, cft, caller: 'Fn::Sequence', ...opts }) as [number | string, number | string, number?]; - - let [start, stop, step = 1] = outputs; - const isString = typeof start === 'string'; - if (isString) { - start = (start as string).charCodeAt(0); - stop = (stop as string).charCodeAt(0); - } - const seq = Array.from( - { length: Math.floor(((stop as number) - (start as number)) / step) + 1 }, - (__, i) => (start as number) + i * step, - ); - return isString ? seq.map((i) => String.fromCharCode(i)) : seq; -} - -async function handleFnIfEval(ctx: RecurseContext): Promise { - const { base, scope, cft, rootTemplate, ...opts } = ctx; - if (!opts.doEval) { - return Promise.reject(new Error('Fn::IfEval is not allowed doEval is falsy')); - } - const json = await recurse({ base, scope, cft, rootTemplate, caller: 'Fn::IfEval', ...opts }) as { - truthy?: TemplateValue; - falsy?: TemplateValue; - evalCond?: string; - inject?: Record; - doLog?: boolean; - }; - - let { truthy = '', falsy = '', evalCond, inject, doLog } = json; - if (!evalCond) { - return Promise.reject(new Error('Fn::IfEval evalCond is required')); - } - evalCond = `(${evalCond})`; - - evalCond = replaceEnv(evalCond, _.merge(_.cloneDeep(opts.inject), inject), opts.doEnv) as string; - truthy = replaceEnv(truthy, _.merge(_.cloneDeep(opts.inject), inject), opts.doEnv) as TemplateValue; - if (falsy) { - falsy = replaceEnv(falsy, _.merge(_.cloneDeep(opts.inject), inject), opts.doEnv) as TemplateValue; - } - - // eslint-disable-next-line no-eval - const condResult = eval(evalCond); - - if (doLog) { - console.log({ truthy, falsy, inject, evalCond, condResult }); - } - - if (condResult) { - return recurse({ base, scope, cft: truthy, rootTemplate, caller: 'Fn::IfEval', ...opts }); - } - return recurse({ base, scope, cft: falsy, rootTemplate, caller: 'Fn::IfEval', ...opts }); -} - -async function handleFnSubNow(ctx: RecurseContext): Promise { - const { base, scope, cft, rootTemplate, ...opts } = ctx; - const input = await recurse({ base, scope, cft, rootTemplate, caller: 'Fn::SubNow', ...opts }); - let template = input as string; - let variables: Record = {}; - - if (Array.isArray(input)) { - [template, variables] = input as [string, Record]; - } - - const allVariables = { - ...getAwsPseudoParameters(), - ...opts.inject, - ...variables, - }; - - let result = template.toString(); - _.forEach(allVariables, (value, key) => { - const regex = new RegExp(`\\$\\{${_.escapeRegExp(key)}\\}`, 'g'); - result = result.replace(regex, String(value)); - }); - - return result; -} - -async function handleFnRefNow(ctx: RecurseContext): Promise { - const { base, scope, cft, rootTemplate, ...opts } = ctx; - const refInput = await recurse({ base, scope, cft, rootTemplate, caller: 'Fn::RefNow', ...opts }); - - let refName = refInput as string; - let refOptions: Record = {}; - - if (_.isPlainObject(refInput)) { - const obj = refInput as { Ref?: string; ref?: string }; - refName = obj.Ref || obj.ref || ''; - refOptions = _.omit(obj, ['Ref', 'ref']); - } - - if (opts.refNowIgnores?.includes(refName)) { - return { Ref: refName }; - } - - const allRefs: Record = { - ...getAwsPseudoParameters(), - ...process.env, - ...opts.inject, - ...scope, - }; - - if (refName in allRefs) { - return allRefs[refName] as TemplateValue; - } - - if (rootTemplate?.Resources) { - const resources = rootTemplate.Resources; - if (refName in resources) { - const resource = resources[refName]; - const resourceType = resource.Type; - const properties = resource.Properties || {}; - - let returnType: 'arn' | 'name' = 'arn'; - if (opts.key?.endsWith('Name')) { - returnType = 'name'; - } - - const resourceOptions = { - returnType, - ...(opts.refNowReturnType ? { returnType: opts.refNowReturnType } : {}), - ...refOptions, - }; - const result = buildResourceArn(resourceType, properties, allRefs, resourceOptions); - if (result) { - return result; - } - } - } - - if (opts.refNowIgnoreMissing) { - return { Ref: refName }; - } - - throw new Error(`Unable to resolve Ref for logical name: ${refName}`); -} - -async function handleFnApplyTags(ctx: RecurseContext): Promise { - const { base, scope, cft, rootTemplate, ...opts } = ctx; - const json = await recurse({ base, scope, cft, rootTemplate, caller: 'Fn::ApplyTags', ...opts }) as { - tags?: TemplateValue[]; - Tags?: TemplateValue[]; - resources: Record; - }; - - let { tags, Tags, resources } = json; - tags = tags || Tags; - - const promises: Promise[] = []; - _.each(resources, (val, id) => { - promises.push( - isTaggableResource(val.Type).then((isTaggable: boolean) => { - if (isTaggable) { - resources[id] = deepMerge( - { - Properties: { - Tags: tags, - }, - }, - val, - ) as Resource; - } - return resources[id]; - }), - ); - }); - await Promise.all(promises); - return resources; -} +/** + * Main entry point for cfn-include template processing + */ +export default async function include(options: MainIncludeOptions): Promise { + let { template } = options; + const doEnv = getBoolEnvOpt(options.doEnv, 'CFN_INCLUDE_DO_ENV'); + const doEval = getBoolEnvOpt(options.doEval, 'CFN_INCLUDE_DO_EVAL'); -// Helper functions + const base = parseLocation(options.url); + const scope: Scope = options.scope || {}; + if (base.relative) throw new Error('url cannot be relative'); -function findAndReplace(scope: Scope, object: unknown): any { - let result: any = object; - if (typeof result === 'string') { - for (const find in scope) { - if (result === find) { - result = scope[find]; - } - } - } - if (typeof result === 'string') { - for (const find in scope) { - const replace = scope[find]; - const regex = new RegExp(`\\\${${find}}`, 'g'); - if (find !== '_' && (result as string).match(regex)) { - result = (result as string).replace(regex, String(replace)); - } - } - } - if (Array.isArray(result)) { - result = result.map((item: any) => findAndReplace(scope, item)); - } else if (_.isPlainObject(result)) { - result = _.mapKeys(result as object, (value, key) => findAndReplace(scope, key) as string); - for (const key of Object.keys(result as object)) { - if (key === 'Fn::Map') continue; - (result as Record)[key] = findAndReplace(scope, (result as Record)[key]); - } - } - return result; -} + const processedTemplate = !template + ? registry.fnInclude({ ...options, base, scope, cft: options.url, doEnv, doEval }) + : template; -function interpolate(lines: string[], context: Record): unknown[][] { - return lines.map((line) => { - const parts: unknown[] = []; - line - .split(/({{\w+?}})/g) - .map((_line) => { - const match = _line.match(/^{{(\w+)}}$/); - const value = match ? context[match[1]] : undefined; - if (!match) return _line; - if (value === undefined) return ''; - return value; - }) - .forEach((part) => { - const last = parts[parts.length - 1]; - if (_.isPlainObject(part) || _.isPlainObject(last) || !parts.length) { - parts.push(part); - } else if (parts.length) { - parts[parts.length - 1] = String(last) + part; - } - }); - return parts.filter((part) => part !== ''); + const resolvedTemplate = await Promise.resolve(processedTemplate); + return recurse({ + base, + scope, + cft: resolvedTemplate, + rootTemplate: resolvedTemplate as TemplateDocument, + doEnv, + doEval, + doLog: options.doLog, + inject: options.inject, + refNowIgnores: options.refNowIgnores, + refNowIgnoreMissing: options.refNowIgnoreMissing, }); } - -interface FnIncludeArgs { - location?: string; - type?: 'json' | 'string' | 'literal'; - query?: string | TemplateValue; - parser?: string; - context?: Record; - inject?: Record; - isGlob?: boolean; - ignoreMissingVar?: boolean; - ignoreMissingFile?: boolean; - doEnv?: boolean; - doEval?: boolean; - doLog?: boolean; - refNowIgnores?: string[]; - refNowIgnoreMissing?: boolean; -} - -function fnIncludeOptsFromArray(cft: unknown[], opts: Record): FnIncludeArgs { - const [location, query, parser = 'lodash'] = cft as [string, string?, string?]; - return { location, query, parser, ...opts }; -} - -function fnIncludeOpts(cft: unknown, opts: Record): FnIncludeArgs { - if (_.isPlainObject(cft)) { - return _.merge(cft as object, _.cloneDeep(opts)) as FnIncludeArgs; - } else if (Array.isArray(cft)) { - return fnIncludeOptsFromArray(cft, opts); - } else { - const splits = (cft as string).split('|'); - if (splits.length > 1) { - return fnIncludeOptsFromArray(splits, opts); - } - return { location: cft as string, ...opts }; - } -} - -async function fnInclude(ctx: FnIncludeContext): Promise { - const { base, scope, cft: cftArg, ...opts } = ctx; - let cft = fnIncludeOpts(cftArg, opts); - cft = _.defaults(cft, { type: 'json' }); - - let procTemplate = async (template: string, inject = cft.inject, doEnv = opts.doEnv) => - replaceEnv(template, inject, doEnv) as string; - - const handleInjectSetup = () => { - if (cft.inject) { - const origProcTemplate = procTemplate; - procTemplate = async (template: string) => { - try { - const inject = (await recurse({ base, scope, cft: cft.inject!, ...opts })) as Record; - const processed = await origProcTemplate(template, inject, opts.doEnv); - return replaceEnv(processed, inject, opts.doEnv) as string; - } catch { - return ''; - } - }; - } - }; - handleInjectSetup(); - - if (cft.doLog) { - console.log({ base, scope, args: cft, ...opts }); - } - - let body: Promise | undefined; - let absolute: string = ''; - const location = parseLocation(cft.location); - - if (!_.isEmpty(location) && !location.protocol) { - location.protocol = base.protocol; - } - - if (location.protocol === 'file') { - absolute = location.relative - ? path.join(path.dirname(base.path || ''), location.host || '', location.path || '') - : [location.host, location.path].join(''); - - cft.inject = { CFN_INCLUDE_DIRNAME: path.dirname(absolute), ...cft.inject }; - handleInjectSetup(); - - if (isGlob(cft, absolute)) { - const paths = (await glob(absolute)).sort(); - const template = yaml.load(paths.map((_p) => `- Fn::Include: file://${_p}`).join('\n')) as any; - return recurse({ base, scope, cft: template, rootTemplate: template as TemplateDocument, ...opts }); - } - body = cachedReadFile(absolute).then(procTemplate); - absolute = `${location.protocol}://${absolute}`; - } else if (location.protocol === 's3') { - const basedir = path.parse(base.path || '').dir; - const bucket = location.relative ? base.host : location.host; - - let key = location.relative ? url.resolve(`${basedir}/`, location.raw || '') : location.path; - key = (key || '').replace(/^\//, ''); - absolute = `${location.protocol}://${[bucket, key].join('/')}`; - body = s3 - .send( - new GetObjectCommand({ - Bucket: bucket, - Key: key, - }), - ) - .then((res) => res.Body?.transformToString() || '') - .then(procTemplate); - } else if (location.protocol?.match(/^https?$/)) { - const basepath = `${path.parse(base.path || '').dir}/`; - - absolute = location.relative - ? url.resolve(`${location.protocol}://${base.host}${basepath}`, location.raw || '') - : location.raw || ''; - - body = request(absolute).then(procTemplate); - } - - return handleIncludeBody({ scope, args: cft, body: body!, absolute }); -} - -function isGlob(args: FnIncludeArgs, str: string): boolean { - return args.isGlob || /.*\*/.test(str); -} - -async function handleIncludeBody(config: { - scope: Scope; - args: FnIncludeArgs; - body: Promise; - absolute: string; -}): Promise { - const { scope, args, body, absolute } = config; - const procTemplate = (temp: string) => replaceEnv(temp, args.inject, args.doEnv) as string; - - try { - switch (args.type) { - case 'json': { - let b = await body; - b = procTemplate(b); - const rootTemplate = yaml.load(b) as TemplateDocument; - const caller = 'handleIncludeBody:json'; - - const loopTemplate = (temp: TemplateValue): Promise => { - return recurse({ - base: parseLocation(absolute), - scope, - cft: temp, - caller, - rootTemplate, - doEnv: args.doEnv, - doEval: args.doEval, - doLog: args.doLog, - inject: args.inject, - refNowIgnores: args.refNowIgnores, - refNowIgnoreMissing: args.refNowIgnoreMissing, - }).then((_temp) => { - if (!_temp || !Object.keys(_temp as object).length) { - return _temp; - } - if (isOurExplicitFunction(Object.keys(_temp as object)[0])) { - return loopTemplate(_temp); - } - return _temp; - }); - }; - - return loopTemplate(rootTemplate as TemplateValue).then(async (temp) => { - if (!args.query) { - return temp; - } - const query = - typeof args.query === 'string' - ? (replaceEnv(args.query, args.inject, args.doEnv) as string) - : await recurse({ - base: parseLocation(absolute), - scope, - cft: args.query, - caller, - rootTemplate, - doEnv: args.doEnv, - doLog: args.doLog, - inject: args.inject, - refNowIgnores: args.refNowIgnores, - refNowIgnoreMissing: args.refNowIgnoreMissing, - }); - return getParser(args.parser)(temp, query as string) as TemplateValue; - }); - } - case 'string': { - const template = await body; - return procTemplate(template); - } - case 'literal': { - const template = await body; - const processed = procTemplate(template); - let lines: any = JSONifyString(processed); - if (_.isPlainObject(args.context)) { - lines = interpolate(lines, args.context!); - } - return { - 'Fn::Join': ['', lines.flat()], - }; - } - default: - throw new Error(`Unknown template type to process type: ${args.type}.`); - } - } catch (e) { - if ((replaceEnv.IsRegExVar(absolute) && args.ignoreMissingVar) || args.ignoreMissingFile) { - return ''; - } - throw e; - } -} - -function JSONifyString(string: string): string[] { - const lines: string[] = []; - const split = string.toString().split(/(\r?\n)/); - for (let idx = 0; idx < split.length; idx++) { - const line = split[idx]; - if (idx % 2) { - lines[(idx - 1) / 2] = lines[(idx - 1) / 2] + line; - } else { - lines.push(line); - } - } - return lines; -} - -function getBoolEnvOpt(opt: boolean | undefined, envKey: string): boolean { - return process.env[envKey] ? !!process.env[envKey] : !!opt; -} diff --git a/src/lib/functions/fn-array-ops.ts b/src/lib/functions/fn-array-ops.ts new file mode 100644 index 0000000..43c4513 --- /dev/null +++ b/src/lib/functions/fn-array-ops.ts @@ -0,0 +1,84 @@ +import _ from 'lodash'; +import type { RecurseContext, RecurseFn, TemplateObject } from './types.js'; + +export function createFnFlatten(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::Flatten'], rootTemplate, caller: 'Fn::Flatten', ...opts }); + return (json as unknown[]).flat(); + }; +} + +export function createFnFlattenDeep(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::FlattenDeep'], rootTemplate, caller: 'Fn::FlattenDeep', ...opts }); + return (json as unknown[]).flat(Infinity); + }; +} + +export function createFnUniq(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::Uniq'], rootTemplate, caller: 'Fn::Uniq', ...opts }); + return [...new Set(json as unknown[])]; + }; +} + +export function createFnCompact(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::Compact'], rootTemplate, caller: 'Fn::Compact', ...opts }); + return (json as unknown[]).filter(Boolean); + }; +} + +export function createFnConcat(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::Concat'], rootTemplate, caller: 'Fn::Concat', ...opts }); + return _.concat(...(json as unknown[][])); + }; +} + +export function createFnSort(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const array = await recurse({ base, scope, cft: obj['Fn::Sort'], rootTemplate, caller: 'Fn::Sort', ...opts }); + return (array as unknown[]).sort(); + }; +} + +export function createFnSortedUniq(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const array = await recurse({ base, scope, cft: obj['Fn::SortedUniq'], rootTemplate, caller: 'Fn::SortedUniq', ...opts }); + return _.sortedUniq((array as unknown[]).sort()); + }; +} + +export function createFnSortBy(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const { list, iteratees } = await recurse({ base, scope, cft: obj['Fn::SortBy'], rootTemplate, caller: 'Fn::SortBy', ...opts }) as { list: unknown[]; iteratees: string | string[] }; + return _.sortBy(list, iteratees); + }; +} + +export function createFnWithout(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::Without'], rootTemplate, caller: 'Fn::Without', ...opts }); + const normalized = Array.isArray(json) ? { list: json[0] as unknown[], withouts: json[1] as unknown[] } : json as { list: unknown[]; withouts: unknown[] }; + return _.without(normalized.list, ...normalized.withouts); + }; +} diff --git a/src/lib/functions/fn-include.ts b/src/lib/functions/fn-include.ts new file mode 100644 index 0000000..48e2ab0 --- /dev/null +++ b/src/lib/functions/fn-include.ts @@ -0,0 +1,227 @@ +import url from 'node:url'; +import path from 'node:path'; +import _ from 'lodash'; +import { glob } from 'glob'; + +import request from '../request.js'; +import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; +import { addProxyToClient } from 'aws-sdk-v3-proxy'; +import * as yaml from '../yaml.js'; +import { getParser } from '../include/query.js'; +import parseLocation from '../parselocation.js'; +import replaceEnv from '../replaceEnv.js'; +import { isOurExplicitFunction } from '../schema.js'; +import { cachedReadFile } from '../cache.js'; +import { findAndReplace, interpolate, JSONifyString } from './helpers.js'; +import type { RecurseContext, RecurseFn, FnIncludeContext, FnIncludeArgs, TemplateObject, TemplateValue, TemplateDocument, Scope } from './types.js'; + +const S3 = (opts = {}) => addProxyToClient(new S3Client(opts), { throwOnNoProxy: false }); +const s3 = S3(); + +export function createFnInclude(recurse: RecurseFn) { + async function fnInclude(ctx: FnIncludeContext): Promise { + const { base, scope, cft: cftArg, ...opts } = ctx; + let cft = fnIncludeOpts(cftArg, opts); + cft = _.defaults(cft, { type: 'json' }); + + let procTemplate = async (template: string, inject = cft.inject, doEnv = opts.doEnv) => + replaceEnv(template, inject, doEnv) as string; + + const handleInjectSetup = () => { + if (cft.inject) { + const origProcTemplate = procTemplate; + procTemplate = async (template: string) => { + try { + const inject = (await recurse({ base, scope, cft: cft.inject!, ...opts })) as Record; + const processed = await origProcTemplate(template, inject, opts.doEnv); + return replaceEnv(processed, inject, opts.doEnv) as string; + } catch { + return ''; + } + }; + } + }; + handleInjectSetup(); + + if (cft.doLog) { + console.log({ base, scope, args: cft, ...opts }); + } + + let body: Promise | undefined; + let absolute: string = ''; + const location = parseLocation(cft.location); + + if (!_.isEmpty(location) && !location.protocol) { + location.protocol = base.protocol; + } + + if (location.protocol === 'file') { + absolute = location.relative + ? path.join(path.dirname(base.path || ''), location.host || '', location.path || '') + : [location.host, location.path].join(''); + + cft.inject = { CFN_INCLUDE_DIRNAME: path.dirname(absolute), ...cft.inject }; + handleInjectSetup(); + + if (isGlob(cft, absolute)) { + const paths = (await glob(absolute)).sort(); + const template = yaml.load(paths.map((_p) => `- Fn::Include: file://${_p}`).join('\n')) as any; + return recurse({ base, scope, cft: template, rootTemplate: template as TemplateDocument, ...opts }); + } + body = cachedReadFile(absolute).then(procTemplate); + absolute = `${location.protocol}://${absolute}`; + } else if (location.protocol === 's3') { + const basedir = path.parse(base.path || '').dir; + const bucket = location.relative ? base.host : location.host; + + let key = location.relative ? url.resolve(`${basedir}/`, location.raw || '') : location.path; + key = (key || '').replace(/^\//, ''); + absolute = `${location.protocol}://${[bucket, key].join('/')}`; + body = s3 + .send( + new GetObjectCommand({ + Bucket: bucket, + Key: key, + }), + ) + .then((res) => res.Body?.transformToString() || '') + .then(procTemplate); + } else if (location.protocol?.match(/^https?$/)) { + const basepath = `${path.parse(base.path || '').dir}/`; + + absolute = location.relative + ? url.resolve(`${location.protocol}://${base.host}${basepath}`, location.raw || '') + : location.raw || ''; + + body = request(absolute).then(procTemplate); + } + + return handleIncludeBody({ scope, args: cft, body: body!, absolute }); + } + + async function handleIncludeBody(config: { + scope: Scope; + args: FnIncludeArgs; + body: Promise; + absolute: string; + }): Promise { + const { scope, args, body, absolute } = config; + const procTemplate = (temp: string) => replaceEnv(temp, args.inject, args.doEnv) as string; + + try { + switch (args.type) { + case 'json': { + let b = await body; + b = procTemplate(b); + const rootTemplate = yaml.load(b) as TemplateDocument; + const caller = 'handleIncludeBody:json'; + + const loopTemplate = (temp: TemplateValue): Promise => { + return recurse({ + base: parseLocation(absolute), + scope, + cft: temp, + caller, + rootTemplate, + doEnv: args.doEnv, + doEval: args.doEval, + doLog: args.doLog, + inject: args.inject, + refNowIgnores: args.refNowIgnores, + refNowIgnoreMissing: args.refNowIgnoreMissing, + }).then((_temp) => { + if (!_temp || !Object.keys(_temp as object).length) { + return _temp; + } + if (isOurExplicitFunction(Object.keys(_temp as object)[0])) { + return loopTemplate(_temp); + } + return _temp; + }); + }; + + return loopTemplate(rootTemplate as TemplateValue).then(async (temp) => { + if (!args.query) { + return temp; + } + const query = + typeof args.query === 'string' + ? (replaceEnv(args.query, args.inject, args.doEnv) as string) + : await recurse({ + base: parseLocation(absolute), + scope, + cft: args.query, + caller, + rootTemplate, + doEnv: args.doEnv, + doLog: args.doLog, + inject: args.inject, + refNowIgnores: args.refNowIgnores, + refNowIgnoreMissing: args.refNowIgnoreMissing, + }); + return getParser(args.parser)(temp, query as string) as TemplateValue; + }); + } + case 'string': { + const template = await body; + return procTemplate(template); + } + case 'literal': { + const template = await body; + const processed = procTemplate(template); + let lines: any = JSONifyString(processed); + if (_.isPlainObject(args.context)) { + lines = interpolate(lines, args.context!); + } + return { + 'Fn::Join': ['', lines.flat()], + }; + } + default: + throw new Error(`Unknown template type to process type: ${args.type}.`); + } + } catch (e) { + if ((replaceEnv.IsRegExVar(absolute) && args.ignoreMissingVar) || args.ignoreMissingFile) { + return ''; + } + throw e; + } + } + + // Handler for Fn::Include in recurse + async function handleFnIncludeInRecurse(ctx: RecurseContext): Promise { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await fnInclude({ base, scope, cft: obj['Fn::Include'], ...opts }); + if (!_.isPlainObject(json)) return json; + delete obj['Fn::Include']; + _.defaults(obj, json); + const replaced = findAndReplace(scope, obj) as any; + return recurse({ base, scope, cft: replaced, rootTemplate, caller: 'Fn::Include', ...opts }); + } + + return { fnInclude, handleFnIncludeInRecurse }; +} + +function fnIncludeOptsFromArray(cft: unknown[], opts: Record): FnIncludeArgs { + const [location, query, parser = 'lodash'] = cft as [string, string?, string?]; + return { location, query, parser, ...opts }; +} + +function fnIncludeOpts(cft: unknown, opts: Record): FnIncludeArgs { + if (_.isPlainObject(cft)) { + return _.merge(cft as object, _.cloneDeep(opts)) as FnIncludeArgs; + } else if (Array.isArray(cft)) { + return fnIncludeOptsFromArray(cft, opts); + } else { + const splits = (cft as string).split('|'); + if (splits.length > 1) { + return fnIncludeOptsFromArray(splits, opts); + } + return { location: cft as string, ...opts }; + } +} + +function isGlob(args: FnIncludeArgs, str: string): boolean { + return args.isGlob || /.*\*/.test(str); +} diff --git a/src/lib/functions/fn-length.ts b/src/lib/functions/fn-length.ts new file mode 100644 index 0000000..d58f682 --- /dev/null +++ b/src/lib/functions/fn-length.ts @@ -0,0 +1,14 @@ +import type { RecurseContext, RecurseFn, TemplateObject } from './types.js'; + +export function createFnLength(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const arg = obj['Fn::Length']; + if (Array.isArray(arg)) { + return arg.length; + } + const result = await recurse({ base, scope, cft: arg, rootTemplate, caller: 'Fn::Length', ...opts }); + return Array.isArray(result) ? result.length : 0; + }; +} diff --git a/src/lib/functions/fn-map.ts b/src/lib/functions/fn-map.ts new file mode 100644 index 0000000..5ec2120 --- /dev/null +++ b/src/lib/functions/fn-map.ts @@ -0,0 +1,51 @@ +import _ from 'lodash'; +import * as PromiseExt from '../promise.js'; +import { createChildScope } from '../scope.js'; +import { findAndReplace } from './helpers.js'; +import type { RecurseContext, RecurseFn, TemplateObject } from './types.js'; + +export function createFnMap(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const args = obj['Fn::Map'] as unknown[]; + const [list] = args; + const body = args[args.length - 1]; + let placeholder = args[1] as string | string[]; + let idx: string | undefined; + let sz: string | undefined; + let hasindex = false; + let hassize = false; + + if (Array.isArray(placeholder)) { + idx = placeholder[1]; + hasindex = true; + if (placeholder.length > 2) { + sz = placeholder[2]; + hassize = true; + } + placeholder = placeholder[0]; + } + if (args.length === 2) { + placeholder = '_'; + } + + let result: any = await PromiseExt.mapX( + recurse({ base, scope, cft: list as any, rootTemplate, caller: 'Fn::Map', ...opts }), + (replace, key) => { + const additions: Record = { [placeholder as string]: replace }; + if (hasindex && idx) { + additions[idx] = key; + } + const childScope = createChildScope(scope, additions); + const replaced = findAndReplace(childScope, _.cloneDeep(body)) as any; + return recurse({ base, scope: childScope, cft: replaced, rootTemplate, caller: 'Fn::Map', ...opts }); + }, + ); + + if (hassize && sz) { + result = findAndReplace({ [sz]: result.length }, result) as any; + } + return recurse({ base, scope, cft: result, rootTemplate, caller: 'Fn::Map', ...opts }); + }; +} diff --git a/src/lib/functions/fn-misc.ts b/src/lib/functions/fn-misc.ts new file mode 100644 index 0000000..0d93f04 --- /dev/null +++ b/src/lib/functions/fn-misc.ts @@ -0,0 +1,263 @@ +import path from 'node:path'; +import _ from 'lodash'; +import { glob } from 'glob'; +import deepMerge from 'deepmerge'; +import { isTaggableResource } from '@znemz/cft-utils/src/resources/taggable.js'; +import replaceEnv from '../replaceEnv.js'; +import parseLocation from '../parselocation.js'; +import { getAwsPseudoParameters, buildResourceArn } from '../internals.js'; +import type { RecurseContext, RecurseFn, TemplateObject, TemplateValue } from './types.js'; +import type { Resource } from '../../types/index.js'; + +export function createFnGetEnv() { + return async (ctx: RecurseContext): Promise => { + const obj = ctx.cft as TemplateObject; + const args = obj['Fn::GetEnv']; + if (Array.isArray(args)) { + const val = process.env[args[0] as string]; + return val === undefined ? args[1] : val; + } + const val = process.env[args as string]; + if (val === undefined) { + throw new Error(`environmental variable ${args} is undefined`); + } + return val; + }; +} + +export function createFnEval(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + if (!opts.doEval) { + return Promise.reject(new Error('Fn::Eval is not allowed doEval is falsy')); + } + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::Eval'], rootTemplate, caller: 'Fn::Eval', ...opts }) as { state?: unknown; script: string; inject?: Record; doLog?: boolean }; + let { script } = json; + const { state, inject, doLog } = json; + script = replaceEnv(script, _.merge(_.cloneDeep(opts.inject), inject), opts.doEnv) as string; + if (doLog) { + console.log({ state, script, inject }); + } + // eslint-disable-next-line no-eval + return eval(script); + }; +} + +export function createFnFilenames(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::Filenames'], rootTemplate, caller: 'Fn::Filenames', ...opts }); + const normalized = _.isPlainObject(json) ? { ...(json as object) } : { location: json }; + const { location: loc, omitExtension, doLog } = normalized as { location: unknown; omitExtension?: boolean; doLog?: boolean }; + + if (doLog) console.log(normalized); + + const location = parseLocation(loc as string); + if (!_.isEmpty(location) && !location.protocol) { + location.protocol = base.protocol; + } + + if (location.protocol === 'file') { + const absolute = location.relative + ? path.join(path.dirname(base.path || ''), location.host || '', location.path || '') + : [location.host, location.path].join(''); + const globs = (await glob(absolute)).sort(); + if (omitExtension) { + return globs.map((f) => path.basename(f, path.extname(f))); + } + return globs; + } + return 'Unsupported File Type'; + }; +} + +export function createFnOutputs(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, ...opts } = ctx; + const obj = cft as TemplateObject; + const outputs = await recurse({ base, scope, cft: obj['Fn::Outputs'], caller: 'Fn::Outputs', ...opts }) as Record; + const result: Record = {}; + + for (const output in outputs) { + const val = outputs[output]; + const exp = { + Export: { Name: { 'Fn::Sub': '${AWS::StackName}:' + output } }, + }; + if (!Array.isArray(val) && typeof val === 'object' && val !== null) { + const objVal = val as { Value?: unknown; Condition?: unknown }; + result[output] = { + Value: { 'Fn::Sub': objVal.Value }, + Condition: objVal.Condition, + ...exp, + }; + } else { + result[output] = { + Value: { 'Fn::Sub': val }, + ...exp, + }; + } + } + return result; + }; +} + +export function createFnSequence(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, ...opts } = ctx; + const obj = cft as TemplateObject; + const outputs = await recurse({ base, scope, cft: obj['Fn::Sequence'], caller: 'Fn::Sequence', ...opts }) as [number | string, number | string, number?]; + + let [start, stop, step = 1] = outputs; + const isString = typeof start === 'string'; + if (isString) { + start = (start as string).charCodeAt(0); + stop = (stop as string).charCodeAt(0); + } + const seq = Array.from( + { length: Math.floor(((stop as number) - (start as number)) / step) + 1 }, + (__, i) => (start as number) + i * step, + ); + return isString ? seq.map((i) => String.fromCharCode(i)) : seq; + }; +} + +export function createFnIfEval(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + if (!opts.doEval) { + return Promise.reject(new Error('Fn::IfEval is not allowed doEval is falsy')); + } + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::IfEval'], rootTemplate, caller: 'Fn::IfEval', ...opts }) as { + truthy?: TemplateValue; + falsy?: TemplateValue; + evalCond?: string; + inject?: Record; + doLog?: boolean; + }; + + let { truthy = '', falsy = '', evalCond, inject, doLog } = json; + if (!evalCond) { + return Promise.reject(new Error('Fn::IfEval evalCond is required')); + } + evalCond = `(${evalCond})`; + + evalCond = replaceEnv(evalCond, _.merge(_.cloneDeep(opts.inject), inject), opts.doEnv) as string; + truthy = replaceEnv(truthy, _.merge(_.cloneDeep(opts.inject), inject), opts.doEnv) as TemplateValue; + if (falsy) { + falsy = replaceEnv(falsy, _.merge(_.cloneDeep(opts.inject), inject), opts.doEnv) as TemplateValue; + } + + // eslint-disable-next-line no-eval + const condResult = eval(evalCond); + + if (doLog) { + console.log({ truthy, falsy, inject, evalCond, condResult }); + } + + if (condResult) { + return recurse({ base, scope, cft: truthy, rootTemplate, caller: 'Fn::IfEval', ...opts }); + } + return recurse({ base, scope, cft: falsy, rootTemplate, caller: 'Fn::IfEval', ...opts }); + }; +} + +export function createFnRefNow(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const refInput = await recurse({ base, scope, cft: obj['Fn::RefNow'], rootTemplate, caller: 'Fn::RefNow', ...opts }); + + let refName = refInput as string; + let refOptions: Record = {}; + + if (_.isPlainObject(refInput)) { + const rObj = refInput as { Ref?: string; ref?: string }; + refName = rObj.Ref || rObj.ref || ''; + refOptions = _.omit(rObj, ['Ref', 'ref']); + } + + if (opts.refNowIgnores?.includes(refName)) { + return { Ref: refName }; + } + + const allRefs: Record = { + ...getAwsPseudoParameters(), + ...process.env, + ...opts.inject, + ...scope, + }; + + if (refName in allRefs) { + return allRefs[refName] as TemplateValue; + } + + if (rootTemplate?.Resources) { + const resources = rootTemplate.Resources; + if (refName in resources) { + const resource = resources[refName]; + const resourceType = resource.Type; + const properties = resource.Properties || {}; + + let returnType: 'arn' | 'name' = 'arn'; + if (opts.key?.endsWith('Name')) { + returnType = 'name'; + } + + const resourceOptions = { + returnType, + ...(opts.refNowReturnType ? { returnType: opts.refNowReturnType } : {}), + ...refOptions, + }; + const result = buildResourceArn(resourceType, properties, allRefs, resourceOptions); + if (result) { + return result; + } + } + } + + if (opts.refNowIgnoreMissing) { + return { Ref: refName }; + } + + throw new Error(`Unable to resolve Ref for logical name: ${refName}`); + }; +} + +export function createFnApplyTags(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::ApplyTags'], rootTemplate, caller: 'Fn::ApplyTags', ...opts }) as { + tags?: TemplateValue[]; + Tags?: TemplateValue[]; + resources: Record; + }; + + let { tags, Tags, resources } = json; + tags = tags || Tags; + + const promises: Promise[] = []; + _.each(resources, (val, id) => { + promises.push( + isTaggableResource(val.Type).then((isTaggable: boolean) => { + if (isTaggable) { + resources[id] = deepMerge( + { + Properties: { + Tags: tags, + }, + }, + val, + ) as Resource; + } + return resources[id]; + }), + ); + }); + await Promise.all(promises); + return resources; + }; +} diff --git a/src/lib/functions/fn-object-ops.ts b/src/lib/functions/fn-object-ops.ts new file mode 100644 index 0000000..3ccd3a6 --- /dev/null +++ b/src/lib/functions/fn-object-ops.ts @@ -0,0 +1,77 @@ +import _ from 'lodash'; +import deepMerge from 'deepmerge'; +import sortObject from '@znemz/sort-object'; +import type { RecurseContext, RecurseFn, TemplateObject } from './types.js'; + +export function createFnOmit(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::Omit'], rootTemplate, caller: 'Fn::Omit', ...opts }); + const normalized = Array.isArray(json) ? { object: json[0] as Record, omits: json[1] as string[] } : json as { object: Record; omits: string[] }; + return _.omit(normalized.object, normalized.omits); + }; +} + +export function createFnOmitEmpty(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::OmitEmpty'], rootTemplate, caller: 'Fn::OmitEmpty', ...opts }) as Record; + return _.omitBy(json, (v) => !v && v !== false && v !== 0); + }; +} + +export function createFnMerge(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::Merge'], rootTemplate, caller: 'Fn::Merge', ...opts }) as unknown[]; + delete obj['Fn::Merge']; + return recurse({ base, scope, cft: _.defaults(obj, _.merge.apply(_, json as [object, ...object[]])), rootTemplate, caller: 'Fn::Merge', ...opts }); + }; +} + +export function createFnDeepMerge(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::DeepMerge'], rootTemplate, caller: 'Fn::DeepMerge', ...opts }) as unknown[]; + delete obj['Fn::DeepMerge']; + let mergedObj = {}; + if (json?.length) { + for (const j of json) { + mergedObj = deepMerge(mergedObj, j as object); + } + } + return recurse({ base, scope, cft: _.defaults(obj, mergedObj), rootTemplate, caller: 'Fn::DeepMerge', ...opts }); + }; +} + +export function createFnObjectKeys(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::ObjectKeys'], rootTemplate, caller: 'Fn::ObjectKeys', ...opts }); + return Object.keys(json as object); + }; +} + +export function createFnObjectValues(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::ObjectValues'], rootTemplate, caller: 'Fn::ObjectValues', ...opts }); + return Object.values(json as object); + }; +} + +export function createFnSortObject(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const result = await recurse({ base, scope, cft: obj['Fn::SortObject'], rootTemplate, caller: 'Fn::SortObject', ...opts }) as { object?: unknown; options?: Record }; + const { object, options: sortOpts, ...rest } = result; + return sortObject(object || rest, sortOpts); + }; +} diff --git a/src/lib/functions/fn-string-ops.ts b/src/lib/functions/fn-string-ops.ts new file mode 100644 index 0000000..be7186f --- /dev/null +++ b/src/lib/functions/fn-string-ops.ts @@ -0,0 +1,77 @@ +import _ from 'lodash'; +import replaceEnv from '../replaceEnv.js'; +import { lowerCamelCase, upperCamelCase } from '../utils.js'; +import { getAwsPseudoParameters } from '../internals.js'; +import type { RecurseContext, RecurseFn, TemplateObject, TemplateValue } from './types.js'; + +export function createFnStringify(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const json = await recurse({ base, scope, cft: obj['Fn::Stringify'], rootTemplate, caller: 'Fn::Stringify', ...opts }); + return JSON.stringify(json); + }; +} + +export function createFnStringSplit(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const { string = '', separator = ',', doLog } = await recurse({ base, scope, cft: obj['Fn::StringSplit'], rootTemplate, caller: 'Fn::StringSplit', ...opts }) as { string?: string; separator?: string; doLog?: boolean }; + if (doLog) console.log({ string, separator }); + return string.split(separator); + }; +} + +export function createFnUpperCamelCase() { + return async (ctx: RecurseContext): Promise => { + const obj = ctx.cft as TemplateObject; + return upperCamelCase(obj['Fn::UpperCamelCase'] as string); + }; +} + +export function createFnLowerCamelCase() { + return async (ctx: RecurseContext): Promise => { + const obj = ctx.cft as TemplateObject; + return lowerCamelCase(obj['Fn::LowerCamelCase'] as string); + }; +} + +export function createFnJoinNow(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const array = await recurse({ base, scope, cft: obj['Fn::JoinNow'], rootTemplate, caller: 'Fn::JoinNow', ...opts }) as [string, unknown[]]; + let [delimiter, toJoinArray] = array; + delimiter = replaceEnv(delimiter, opts.inject, opts.doEnv) as string; + return toJoinArray.join(delimiter); + }; +} + +export function createFnSubNow(recurse: RecurseFn) { + return async (ctx: RecurseContext): Promise => { + const { base, scope, cft, rootTemplate, ...opts } = ctx; + const obj = cft as TemplateObject; + const input = await recurse({ base, scope, cft: obj['Fn::SubNow'], rootTemplate, caller: 'Fn::SubNow', ...opts }); + let template = input as string; + let variables: Record = {}; + + if (Array.isArray(input)) { + [template, variables] = input as [string, Record]; + } + + const allVariables = { + ...getAwsPseudoParameters(), + ...opts.inject, + ...variables, + }; + + let result = template.toString(); + _.forEach(allVariables, (value, key) => { + const regex = new RegExp(`\\$\\{${_.escapeRegExp(key)}\\}`, 'g'); + result = result.replace(regex, String(value)); + }); + + return result; + }; +} diff --git a/src/lib/functions/helpers.ts b/src/lib/functions/helpers.ts new file mode 100644 index 0000000..2a831d1 --- /dev/null +++ b/src/lib/functions/helpers.ts @@ -0,0 +1,74 @@ +import _ from 'lodash'; +import type { Scope, TemplateValue } from './types.js'; + +export function findAndReplace(scope: Scope, object: unknown): any { + let result: any = object; + if (typeof result === 'string') { + for (const find in scope) { + if (result === find) { + result = scope[find]; + } + } + } + if (typeof result === 'string') { + for (const find in scope) { + const replace = scope[find]; + const regex = new RegExp(`\\\${${find}}`, 'g'); + if (find !== '_' && (result as string).match(regex)) { + result = (result as string).replace(regex, String(replace)); + } + } + } + if (Array.isArray(result)) { + result = result.map((item: any) => findAndReplace(scope, item)); + } else if (_.isPlainObject(result)) { + result = _.mapKeys(result as object, (value, key) => findAndReplace(scope, key) as string); + for (const key of Object.keys(result as object)) { + if (key === 'Fn::Map') continue; + (result as Record)[key] = findAndReplace(scope, (result as Record)[key]); + } + } + return result; +} + +export function interpolate(lines: string[], context: Record): unknown[][] { + return lines.map((line) => { + const parts: unknown[] = []; + line + .split(/({{\w+?}})/g) + .map((_line) => { + const match = _line.match(/^{{(\w+)}}$/); + const value = match ? context[match[1]] : undefined; + if (!match) return _line; + if (value === undefined) return ''; + return value; + }) + .forEach((part) => { + const last = parts[parts.length - 1]; + if (_.isPlainObject(part) || _.isPlainObject(last) || !parts.length) { + parts.push(part); + } else if (parts.length) { + parts[parts.length - 1] = String(last) + part; + } + }); + return parts.filter((part) => part !== ''); + }); +} + +export function JSONifyString(string: string): string[] { + const lines: string[] = []; + const split = string.toString().split(/(\r?\n)/); + for (let idx = 0; idx < split.length; idx++) { + const line = split[idx]; + if (idx % 2) { + lines[(idx - 1) / 2] = lines[(idx - 1) / 2] + line; + } else { + lines.push(line); + } + } + return lines; +} + +export function getBoolEnvOpt(opt: boolean | undefined, envKey: string): boolean { + return process.env[envKey] ? !!process.env[envKey] : !!opt; +} diff --git a/src/lib/functions/registry.ts b/src/lib/functions/registry.ts new file mode 100644 index 0000000..57a606e --- /dev/null +++ b/src/lib/functions/registry.ts @@ -0,0 +1,56 @@ +import type { FnHandler, RecurseFn } from './types.js'; + +import { createFnMap } from './fn-map.js'; +import { createFnLength } from './fn-length.js'; +import { createFnInclude } from './fn-include.js'; +import { createFnFlatten, createFnFlattenDeep, createFnUniq, createFnCompact, createFnConcat, createFnSort, createFnSortedUniq, createFnSortBy, createFnWithout } from './fn-array-ops.js'; +import { createFnOmit, createFnOmitEmpty, createFnMerge, createFnDeepMerge, createFnObjectKeys, createFnObjectValues, createFnSortObject } from './fn-object-ops.js'; +import { createFnStringify, createFnStringSplit, createFnUpperCamelCase, createFnLowerCamelCase, createFnJoinNow, createFnSubNow } from './fn-string-ops.js'; +import { createFnGetEnv, createFnEval, createFnFilenames, createFnOutputs, createFnSequence, createFnIfEval, createFnRefNow, createFnApplyTags } from './fn-misc.js'; + +export interface FnRegistry { + handlers: Record; + fnInclude: ReturnType['fnInclude']; +} + +export function buildRegistry(recurse: RecurseFn): FnRegistry { + const includeModule = createFnInclude(recurse); + + const handlers: Record = { + 'Fn::Map': createFnMap(recurse), + 'Fn::Length': createFnLength(recurse), + 'Fn::Include': includeModule.handleFnIncludeInRecurse, + 'Fn::Flatten': createFnFlatten(recurse), + 'Fn::FlattenDeep': createFnFlattenDeep(recurse), + 'Fn::Uniq': createFnUniq(recurse), + 'Fn::Compact': createFnCompact(recurse), + 'Fn::Concat': createFnConcat(recurse), + 'Fn::Sort': createFnSort(recurse), + 'Fn::SortedUniq': createFnSortedUniq(recurse), + 'Fn::SortBy': createFnSortBy(recurse), + 'Fn::SortObject': createFnSortObject(recurse), + 'Fn::Without': createFnWithout(recurse), + 'Fn::Omit': createFnOmit(recurse), + 'Fn::OmitEmpty': createFnOmitEmpty(recurse), + 'Fn::Eval': createFnEval(recurse), + 'Fn::Filenames': createFnFilenames(recurse), + 'Fn::Merge': createFnMerge(recurse), + 'Fn::DeepMerge': createFnDeepMerge(recurse), + 'Fn::ObjectKeys': createFnObjectKeys(recurse), + 'Fn::ObjectValues': createFnObjectValues(recurse), + 'Fn::Stringify': createFnStringify(recurse), + 'Fn::StringSplit': createFnStringSplit(recurse), + 'Fn::UpperCamelCase': createFnUpperCamelCase(), + 'Fn::LowerCamelCase': createFnLowerCamelCase(), + 'Fn::GetEnv': createFnGetEnv(), + 'Fn::Outputs': createFnOutputs(recurse), + 'Fn::Sequence': createFnSequence(recurse), + 'Fn::IfEval': createFnIfEval(recurse), + 'Fn::JoinNow': createFnJoinNow(recurse), + 'Fn::SubNow': createFnSubNow(recurse), + 'Fn::RefNow': createFnRefNow(recurse), + 'Fn::ApplyTags': createFnApplyTags(recurse), + }; + + return { handlers, fnInclude: includeModule.fnInclude }; +} diff --git a/src/lib/functions/types.ts b/src/lib/functions/types.ts new file mode 100644 index 0000000..295a4dd --- /dev/null +++ b/src/lib/functions/types.ts @@ -0,0 +1,47 @@ +import type { ParsedLocation, Scope, TemplateValue, TemplateDocument, TemplateObject } from '../../types/index.js'; + +export const MAX_RECURSE_DEPTH = 100; + +export interface RecurseContext { + base: ParsedLocation; + scope: Scope; + cft: TemplateValue; + rootTemplate?: TemplateDocument; + caller?: string; + key?: string; + depth?: number; + doEnv?: boolean; + doEval?: boolean; + doLog?: boolean; + inject?: Record; + refNowIgnores?: string[]; + refNowIgnoreMissing?: boolean; + refNowReturnType?: 'arn' | 'name'; +} + +export interface FnIncludeContext extends Omit { + cft: TemplateValue; +} + +export interface FnIncludeArgs { + location?: string; + type?: 'json' | 'string' | 'literal'; + query?: string | TemplateValue; + parser?: string; + context?: Record; + inject?: Record; + isGlob?: boolean; + ignoreMissingVar?: boolean; + ignoreMissingFile?: boolean; + doEnv?: boolean; + doEval?: boolean; + doLog?: boolean; + refNowIgnores?: string[]; + refNowIgnoreMissing?: boolean; +} + +export type RecurseFn = (ctx: RecurseContext) => Promise; +export type FnIncludeFn = (ctx: FnIncludeContext) => Promise; +export type FnHandler = (ctx: RecurseContext) => Promise; + +export type { ParsedLocation, Scope, TemplateValue, TemplateDocument, TemplateObject }; diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..cfbf743 --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,121 @@ +# Phase 3 Completion: Test Migration & Legacy Cleanup + +## Problem Statement + +The TypeScript source migration is complete (`src/` → `dist/`), and new vitest tests exist in `test/`, but old JS test runners in `t/` remain alongside legacy JS source shims. We need to verify full test coverage parity, clean up dead files, and ensure the test suite runs green. + +## Coverage Analysis + +### Already Migrated (✅ Full Parity) + +| Old File | New File | Status | +|----------|----------|--------| +| `t/include.js` | `test/include.test.ts` | ✅ Same 30 fixture files loaded | +| `t/cli.js` | `test/cli.test.ts` | ✅ Loads `t/tests/cli.json` | +| `t/replaceEnv.js` | `test/replaceEnv.test.ts` | ✅ Expanded (4 tests vs 1) | +| `t/tests/extendEnv.js` | `test/helpers.ts` (`withEnvAsync`) | ✅ Replaced with proper cleanup | + +### NOT Migrated / Gaps + +| Old File | Issue | Priority | +|----------|-------|----------| +| `t/unit.js` | Trivial — just `console.log(parseLocation(...))`. Not a real test. | Low (delete) | +| `t/tests/api.js` | AWS API integration tests (EC2 describeRegions). Not in new TEST_FILES list. Not in old default list either — old `t/include.js` doesn't load it. | Low (out of scope) | +| `s3.json` conditional | Old `t/include.js` has `if (process.env.TEST_AWS) tests.push('s3.json')`. New `test/include.test.ts` does not. | Low (add conditional) | + +### Key Finding + +**The new TS tests have FULL PARITY with the old JS tests.** Both load the exact same 30 fixture files. The new tests additionally have better env cleanup (proper save/restore vs lodash omit which was buggy in the old code). The `replaceEnv` tests are expanded. + +## Legacy JS Files Analysis + +### Shim Files (Keep or Remove?) + +| File | Purpose | Verdict | +|------|---------|---------| +| `index.js` | Re-exports `./dist/index.js` | **DELETE** — `package.json` exports already point to `dist/`. No consumer should use this. | +| `bin/cli.js` | Imports `../dist/cli.js` | **KEEP** — `test/cli.test.ts` runs `node bin/cli.js`. Could update test to use `dist/cli.js` directly, then delete. | +| `lib/*.js` (10 files) | Old source code, replaced by `src/lib/*.ts` → `dist/lib/*.js` | **DELETE** — `package.json` exports use `./dist/lib/*`. Not imported by anything except old `t/` tests. | + +### Test Fixture Files (KEEP) + +These are **still used** by the new TS tests via `test/helpers.ts`: +- `t/tests/*.json` and `t/tests/*.js` — test fixture/case definitions +- `t/includes/` — include fixture files referenced by test cases +- `t/fixtures/` — additional fixture files +- `t/regression-fixtures/` — regression test fixtures + +**Do NOT delete the `t/` directory entirely** — only the JS test runners. + +--- + +## Tasks for Engineer + +### 1. Fix Test Suite to Run Green +- [ ] Run `npm install` and `npm test` — confirm current state +- [ ] Fix any build errors in `npm run build` (tsc) +- [ ] Fix any test failures in `npm run test:run` (vitest) + +### 2. Delete Old JS Test Runners +- [ ] Delete `t/cli.js` (replaced by `test/cli.test.ts`) +- [ ] Delete `t/include.js` (replaced by `test/include.test.ts`) +- [ ] Delete `t/replaceEnv.js` (replaced by `test/replaceEnv.test.ts`) +- [ ] Delete `t/unit.js` (not a real test, just a console.log) +- [ ] Delete `t/tests/extendEnv.js` (replaced by `test/helpers.ts`) + +### 3. Delete Legacy JS Source Files +- [ ] Delete `index.js` (shim to dist — package.json exports handle this) +- [ ] Delete `lib/cache.js` +- [ ] Delete `lib/cfnclient.js` +- [ ] Delete `lib/include/api.js` +- [ ] Delete `lib/include/query.js` +- [ ] Delete `lib/internals.js` +- [ ] Delete `lib/parselocation.js` +- [ ] Delete `lib/promise-utils.js` +- [ ] Delete `lib/promise.js` +- [ ] Delete `lib/replaceEnv.js` +- [ ] Delete `lib/request.js` +- [ ] Delete `lib/schema.js` +- [ ] Delete `lib/scope.js` +- [ ] Delete `lib/utils.js` +- [ ] Delete `lib/yaml.js` + +### 4. Handle bin/cli.js +- [ ] Option A: Update `test/cli.test.ts` to use `dist/cli.js` instead of `bin/cli.js`, then delete `bin/cli.js` and remove `bin/` dir +- [ ] Option B: Keep `bin/cli.js` as a thin shim (it's only 2 lines). Update `package.json` bin to point to `dist/cli.js` if not already. +- [ ] **Decision:** package.json already has `"bin": { "cfn-include": "./dist/cli.js" }` → `bin/cli.js` is dead code. Go with Option A. + +### 5. Add Conditional S3 Test Support +- [ ] In `test/include.test.ts`, add: `if (process.env.TEST_AWS) TEST_FILES.push('s3.json');` to match old behavior + +### 6. Move Test Fixtures (Optional, Low Priority) +- [ ] Consider moving `t/tests/`, `t/includes/`, `t/fixtures/`, `t/regression-fixtures/` to `test/fixtures/` for cleaner structure +- [ ] If moved, update paths in `test/helpers.ts` (`FIXTURES_DIR`, `INCLUDES_DIR`, `TEST_TEMPLATE_URL`) +- [ ] If moved, update paths inside fixture JSON files that reference relative paths (e.g., `../includes/...`) +- [ ] **Note:** This is risky due to relative path references in fixtures. Skip unless explicitly requested. + +### 7. Verify and Confirm +- [ ] `npm run build` passes +- [ ] `npm run test` passes (all vitest tests green) +- [ ] `npm run typecheck` passes +- [ ] No remaining references to deleted files (grep for `lib/` imports, `index.js` imports) +- [ ] `git diff --stat` shows only deletions + minor edits + +--- + +## Complexity Assessment + +**Overall: Low-Moderate** + +The heavy lifting (TS source conversion, new test harness) is already done. This is cleanup work: +- Deleting ~20 files +- 1 small test edit (cli.test.ts path or s3 conditional) +- Verification + +Estimated time: 1-2 hours. + +## Risks + +1. **Fixture path breakage** — The new tests reference `t/tests/` and `t/includes/`. Deleting test *runners* is safe; deleting fixture *data* would break everything. +2. **bin/cli.js used externally** — Unlikely since package.json bin points to dist, but check git history for any documentation referencing it. +3. **index.js used by consumers** — package.json exports cover `./dist/index.js`. The root `index.js` shim is redundant. If any consumer does `require('cfn-include')` without respecting exports, they'd break — but this package is ESM-only (`"type": "module"`).