diff --git a/Jenkinsfile b/Jenkinsfile index 880e5c2..7911483 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -45,7 +45,7 @@ pipeline { axes { axis { name 'NODE_VERSION' - values '12', '14', '16' + values '20', '22', '24' } } diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..7c7895f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +'use strict' + +const {defineConfig} = require('eslint/config') +const logdna = require('eslint-config-logdna') + +module.exports = defineConfig([ + { + 'extends': [logdna] + , 'ignores': ['skills/**'] + , 'languageOptions': { + ecmaVersion: 2022 + , sourceType: 'script' + , globals: { + fetch: 'readonly' + } + } + , 'rules': { + 'sensible/check-require': [2, 'always', { + root: __dirname + }] + } + } +]) diff --git a/lib/actions/map.js b/lib/actions/map.js index 6d87122..253690e 100644 --- a/lib/actions/map.js +++ b/lib/actions/map.js @@ -9,7 +9,7 @@ const assert = require('assert') const {typeOf} = require('@logdna/stdlib') const NAME = 'SetupChain.map' -const ARRAY_ERR = `${NAME} first param should be an array. Supports dynamic lookups` +const ARRAY_ERR = `${NAME} first param should be an array. Supports dynamic lookups` const FN_ERR = `${NAME} second param should be a function` module.exports = async function map(collection, fn) { diff --git a/package.json b/package.json index af6fbef..d910495 100644 --- a/package.json +++ b/package.json @@ -47,25 +47,11 @@ "bugs": { "url": "https://github.com/logdna/setup-chain-node/issues" }, - "eslintConfig": { - "root": true, - "ignorePatterns": [ - "node_modules/", - "coverage/" - ], - "extends": [ - "logdna" - ], - "parserOptions": { - "ecmaVersion": 2022 - }, - "plugins": [] - }, "homepage": "https://github.com/logdna/setup-chain-node", "devDependencies": { "casual": "^1.6.2", - "eslint": "^8.35.0", - "eslint-config-logdna": "^6.1.0", + "eslint": "^10.4.1", + "eslint-config-logdna": "^8.0.1", "luxon": "^3.2.1", "moment": "^2.29.1", "semantic-release": "^17.4.4", @@ -98,6 +84,8 @@ "--exclude=coverage/", "--exclude=scripts/", "--exclude=examples/", + "--exclude=skills/", + "--exclude=eslint.config.js/", "--all" ] }, diff --git a/skills/setup-chain/SKILL.md b/skills/setup-chain/SKILL.md new file mode 100644 index 0000000..f606bd0 --- /dev/null +++ b/skills/setup-chain/SKILL.md @@ -0,0 +1,114 @@ +--- +name: setup-chain +description: create new actions, functions or workflows for logdna/setup-chain +license: MIT +metadata: + author: Mezmo Inc,. +--- + +# Setup-chain Agent + +Adds new actions and functions to logdna setup-chain + +## Prerequisites + +1. Confirm `node` executable is installed on the host +2. Make sure there is a package.json in the current working directory +3. package.json should have `@logdna/setup-chain` in dependencies or devDependencies + +If any of the above are not met, explain what is missing. Do not proceed with custom action creation. + +## Creating a Custom Action + +Follow these steps to create and wire up a custom action: + +1. **Define the action function** in an actions object + - **Requirement**: Actions MUST be `async` functions or return a `Promise` + ```javascript + const actions = { + myAction: async (opts) => { + return opts.value || 'default' + } + } + ``` + +2. **Create or extend SetupChain class** + ```javascript + class MyChain extends SetupChain { + constructor(state) { + super(state, actions) + } + } + ``` + +3. **Use the action** in your chain + ```javascript + await new MyChain().myAction({value: 'test'}, 'result').execute() + // state: {result: 'test'} + ``` + +4. **Validate** your implementation works as expected + +## Custom Signatures (Advanced) + +Only manually push `this.tasks` when action signature deviates from `(opts, label)`: + +```javascript +class MyChain extends SetupChain { + constructor(state) { + super(state, yourActions) + } + + customAction(arg1, arg2, label) { + this.tasks.push(['customAction', label, arg1, arg2]) + return this + } +} +``` + +See `create-action.md` for detailed patterns and examples. + +## Usage & Workflow + +SetupChain implements chain-of-responsibility pattern. Actions are available as top level functions on a chain instance. +Chain actions, execute, and results are stored in state. + +```javascript +const chain = new MyChain() + +const state = await chain + .set('user', 'alice') + .map('#user', n => n * 2, 'doubled') + .execute() + +// state: {user: 'alice', doubled: [2, 4, 6]} +``` + +Best practices: +- Use labels explicitly to avoid state key collisions +- Chain actions for readability: `.action1().action2().execute()` +- Reuse state across instances for persistence: `new SetupChain(state2)` +- Group Long action chains by use case with comments + +```javascript +const chain = new MyChain() + +// test scenerio 1 +chain + .account({}, 'account_one') + .user({account: '#account_one'}, 'user_one') + +// test scenerio 1 +chain + .account({}, 'account_two') + .user({account: '#account_two'}, 'user_two') + +const state = await chain.execute() +``` + +## Additional Resources + +- [Detailed action patterns](./references/create-action.md) +- [Adding helper functions](./references/create-function.md) +- [Quick reference](./references/QUICKREF.md) +- [Code examples](./references/examples/) diff --git a/skills/setup-chain/references/create-action.md b/skills/setup-chain/references/create-action.md new file mode 100644 index 0000000..951b105 --- /dev/null +++ b/skills/setup-chain/references/create-action.md @@ -0,0 +1,88 @@ +# Creating Custom Actions + + +## Steps to Create and Wire a Custom Action + +1. **Define your action function** + - **Requirement**: Actions MUST be `async` functions or return a `Promise` + - Accept single `opts` object + - Return the result directly + ```javascript + const actions = { + hello: async (opts) => opts.name || 'World' + } + ``` + +2. **Create SetupChain class** + - Pass actions object to constructor + ```javascript + class MyChain extends SetupChain { + constructor(state) { + super(state, actions) + } + } + ``` + +3. **Call action with label** + ```javascript + await new MyChain().hello({name: 'Alice'}, 'result').execute() + ``` + - Label is key in state for this action's result + +4. **Test your implementation** + +## Custom Signature (Use Only When Needed) + +1. Define action function manually +2. Override with custom method signature +3. Manually push task with correct format +4. Return `this` for chaining + +```javascript +const actions = { + printNames: async (opts) => [opts.first, opts.last].join(', ') +} + +class MyChain extends SetupChain { + constructor(state) { + super(state, actions) + } + + printNames(first, last, label) { + this.tasks.push(['printNames', label, first, last]) + return this + } +} +``` + +## Pattern: State-Dependent with Defaults + +1. Define defaults with template placeholders +2. Use `this.lookup()` to merge defaults and opts +3. Use `#this` to reference action's result context +4. Use `assert` module to validate values after lookup resolution + +```javascript +const actions = { + person: async (opts) => { + const defaults = { + first: 'bobby' + , last: 'fischer' + , full: '!template:"{{#this.first}} {{#this.last}}"' + } + const result = this.lookup({...defaults, ...opts}) + + // Validate required fields exist + assert.ok(result.first, 'First name is required') + assert.ok(result.last, 'Last name is required') + + // Validate types + assert.equal(typeof result.first, 'string', 'First name must be a string') + assert.equal(typeof result.last, 'string', 'Last name must be a string') + + return result + } +} +``` + +5. **Validate** after executing with various inputs diff --git a/skills/setup-chain/references/create-function.md b/skills/setup-chain/references/create-function.md new file mode 100644 index 0000000..bdf2a19 --- /dev/null +++ b/skills/setup-chain/references/create-function.md @@ -0,0 +1,71 @@ +# Adding Helper Functions + +## Overview + +Helper functions are synchronous methods defined on a chain class that extend the capabilities of the `lookup` system. They are called when the `lookup` function encounters a value starting with `!`. + +## Creating a Helper Function + +1. **Define the method on your chain class** + - **Prefix**: Method name MUST start with `$` (e.g., `$slugify`) + - **Naming**: Keep names short, single-word, and lowercase (`$`) + - **Synchronous**: Functions MUST be synchronous; they cannot be `async` by design. Use [Actions](./create-action.md) for async operations. + - **Arguments**: They can accept any number of arguments. + + ```javascript + class MyChain extends SetupChain { + $slugify(text) { + return text.toLowerCase().replace(/\s+/g, '-'); + } + } + ``` + +2. **Use the function via `lookup`** + - Call the function by using the `!` prefix in lookup strings or objects. + - The `$` prefix is removed when calling via lookup (e.g., `$slugify` is called as `!slugify`). + +## Usage Patterns + +### Basic Call +Pass arguments directly after the function name, separated by commas. +```javascript +chain.lookup('!slugify:"Hello World"') +// returns: "hello-world" +``` + +### Nested & Complex Lookups +Functions can accept other lookup values (state properties or other functions) as arguments. + +- **Using State Properties**: + ```javascript + await chain.set('title', 'My Page').execute(); + chain.lookup('!slugify:#title') + // returns: "my-page" + ``` + +- **Array of Lookups**: + ```javascript + await chain.set(('one', 'ONE').set('two', 'TWO').execute() + chain.lookup(['!lower:#one', '!lower:#two']) + // returns ['one', 'two'] + ``` + +- **Deeply Nested Functions**: + ```javascript + var chain = new MyChain({bar: 'Hello World}) + chain.lookup({one: '!lower(!slugify(#bar))' }) + // returns {one: 'hello-world'} + ``` + +- **Inside Action Options**: + You can pass function calls as values in action options; they will be resolved by the action's `this.lookup()` call. + ```javascript + await chain.set({title: 'Hello World'}).myAction({ slug: '!slugify(#title)' }).execute() + // returns {slug: 'hello-world'} + ``` + +## Summary Checklist +- [ ] Method starts with `$` +- [ ] Method is synchronous +- [ ] Name is short, lowercase, single-word +- [ ] Called using `!` in `lookup` diff --git a/skills/setup-chain/references/examples/basic.js b/skills/setup-chain/references/examples/basic.js new file mode 100644 index 0000000..e001d4a --- /dev/null +++ b/skills/setup-chain/references/examples/basic.js @@ -0,0 +1,5 @@ +// Basic set() usage +const SetupChain = require('@logdna/setup-chain') +const chain = new SetupChain() +await chain.set('hello', 'world').set('goodbye', 'world').execute() +// state: {hello: 'world', goodbye: 'world'} \ No newline at end of file diff --git a/skills/setup-chain/references/examples/custom-actions.js b/skills/setup-chain/references/examples/custom-actions.js new file mode 100644 index 0000000..0a1443d --- /dev/null +++ b/skills/setup-chain/references/examples/custom-actions.js @@ -0,0 +1,26 @@ +// Custom actions with defaults +const SetupChain = require('@logdna/setup-chain') + +const defaults = { + first: 'bobby' +, last: 'fischer' +, full: '!template:"{{#this.first}} {{#this.last}}"' +} + +const actions = { + person: async function person(opts) { + return this.lookup({...defaults, ...opts}) + } +} + +const chain = new SetupChain(null, actions) +const state = await chain + .person({}, 'bobby') // uses defaults + .person({first: 'fred'}, 'fred') + .person({last: 'williams'}, 'williams') + .execute() +// state: { +// bobby: {first: 'bobby', last: 'fischer', full: 'bobby fischer'}, +// fred: {first: 'fred', last: 'fischer', full: 'fred fischer'}, +// williams: {first: 'bobby', last: 'williams', full: 'bobby williams'} +// } diff --git a/skills/setup-chain/references/examples/lookups.js b/skills/setup-chain/references/examples/lookups.js new file mode 100644 index 0000000..ea1599e --- /dev/null +++ b/skills/setup-chain/references/examples/lookups.js @@ -0,0 +1,23 @@ +// Lookup syntax examples +const SetupChain = require('@logdna/setup-chain') +const chain = new SetupChain( + {one: 1, two: 2, three: 3, four: ['a', 'b', 'c']} +, { + myAction: async function(opts) { + return this.lookup(opts) + } + } +) + +console.log(chain.lookup('#one')) // 1 +console.log(chain.lookup('#four.1')) // 'b' +console.log(chain.lookup(['#one', '#two'])) // [1, 2] +console.log(chain.lookup({ + one: '#one' +, nested: '#three' +})) + +// using lookup in an action +console.dir( + await chain.myAction({four: '#four.0'}).execute() +) // {myAction: {four: 'a'}} diff --git a/skills/setup-chain/references/examples/templates.js b/skills/setup-chain/references/examples/templates.js new file mode 100644 index 0000000..302b565 --- /dev/null +++ b/skills/setup-chain/references/examples/templates.js @@ -0,0 +1,9 @@ +// Using templates and random +const SetupChain = require('@logdna/setup-chain') +const chain = new SetupChain() + +chain.set('name', 'Alice').set('email', 'alice@example.com').execute() +chain.lookup('!template:"Hello, {{#name}}!"') // 'Hello, Alice!' +chain.lookup('!template:"User {{#name}} ({{#email}})"') // 'User Alice (alice@example.com)' +chain.lookup('!random:10') // random hex string like 'a7f39c2e4e' +chain.lookup('!template:"ID-{{#this.name}}-{{!random:8}}"') \ No newline at end of file diff --git a/skills/setup-chain/references/usage.md b/skills/setup-chain/references/usage.md new file mode 100644 index 0000000..50f81df --- /dev/null +++ b/skills/setup-chain/references/usage.md @@ -0,0 +1,98 @@ +# Setup-chain Quick Reference + +## Additional Documentation + +- [Action development](./create-action.md) +- [Usage patterns](./SKILL.md) +- [Examples](./examples) + + +| Action | Usage | Returns | +|--------------------------------------|--------------------------------------------|---------------------------------| +| `set(key, value)` | `chain.set('foo', 'bar')` | stores value in state | +| `set({key: value})` | `chain.set('foo', 'bar')` | stores multiple values in state | +| `map(collection, fn, label)` | `chain.map([1,2,3], n=>n*2, 'doubled')` | transformed array | +| `repeat(times, action, opts, label)` | `chain.repeat(5, 'hello', {}, 'res')` | array of results | +| `serial(times, action, opts, label)` | `chain.serial(3, 'delay', {}, 'res')` | sequential results | +| `sort(collection, fn, label)` | `chain.sort('#arr', (a,b)=>b-a, 'sorted')` | sorted array | +| `sleep({ms})` | `chain.sleep({ms:1000})` | waits 1000ms | + +## Lookup Syntax + +| Syntax | Example | Description | +|--------|---------|-------------| +| `#foo` | `chain.lookup('#foo')` | state property | +| `#foo.bar` | `chain.lookup('#user.name')` | nested property | +| `#arr.0` | `chain.lookup('#arr.0')` | array index (dot notation) | +| `['#foo', '#bar']` | `chain.lookup(['#foo','bar'])` | array lookup | +| `!fn:arg1,arg2` | `chain.lookup('!random:5')` | function call | +| `!template:"{{#foo}}"` | `chain.$template('Hello {{#name}}')` | interpolated string | + +## Creating Actions + +**Important**: Only manually push tasks when action signature **deviates** from standard `(opts, label)` pattern. + +### Auto-exposed (simple) - Standard signature +```javascript +const actions = { + hello: async (opts) => opts.name || 'World' +} + +class MyChain extends SetupChain { + constructor(state) { + super(state, actions) + } +} + +new MyChain().hello({name: 'Alice'}, 'result').execute() +// NO .tasks.push needed! +``` + +### Custom Signature - Only when needed +```javascript +printNames(first, last, label) { + this.tasks.push(['printNames', label, first, last]) // Only if signature differs + return this +} +``` + +### State-Dependent with Validation +```javascript +const actions = { + person: async (opts) => { + const defaults = { + first: 'bobby' + , last: 'fischer' + , full: '!template:"{{#this.first}} {{#this.last}}"' + } + const result = this.lookup({...defaults, ...opts}) + + assert.ok(result.first, 'First name is required') + assert.ok(result.last, 'Last name is required') + assert.equal(typeof result.first, 'string', 'First name must be a string') + assert.equal(typeof result.last, 'string', 'Last name must be a string') + + return result + } +} +``` + +## Custom Functions + +```javascript +class MyChain extends SetupChain { + $max(...args) { + return Math.max(...this.lookup(args)) + } +} + +chain.lookup('!max:1,2,3') // '3' +``` + +## Best Practices + +1. Use labels explicitly to avoid collisions +2. Use templates in action defaults for reusability +3. Keep actions simple, use `this.lookup()` for composition +4. Chain operations for readability +5. Reuse state across chains for persistence diff --git a/test/fixtures/actions/error.js b/test/fixtures/actions/error.js index 083fae8..de27b0a 100644 --- a/test/fixtures/actions/error.js +++ b/test/fixtures/actions/error.js @@ -1,6 +1,6 @@ 'use strict' const assert = require('assert') -module.exports = async function error(opts) { +module.exports = async function error() { assert.equal(1, 4) } diff --git a/test/integration/actions/map.js b/test/integration/actions/map.js index 1fbed33..6c34b10 100644 --- a/test/integration/actions/map.js +++ b/test/integration/actions/map.js @@ -73,7 +73,7 @@ test('SetupChain.map() as a builtin action', async (t) => { t.test('Error: first parameter is not an array', async (t) => { const msg = new RegExp( - 'first param should be an array. Supports dynamic lookups' + 'first param should be an array. Supports dynamic lookups' ) t.rejects(chain.map().execute(), { diff --git a/test/integration/actions/sort.js b/test/integration/actions/sort.js index 9fea083..13d6ad8 100644 --- a/test/integration/actions/sort.js +++ b/test/integration/actions/sort.js @@ -58,7 +58,7 @@ test('SetupChain.sort() as a builtin action', async (t) => { t.test('Error: collection is not an array', async (t) => { const msg = new RegExp( - 'first param should be an array. Supports dynamic lookups' + 'first param should be an array. {2}Supports dynamic lookups' ) t.rejects(chain.sort().execute(), { diff --git a/test/integration/chain.js b/test/integration/chain.js index b96b77f..c1b3590 100644 --- a/test/integration/chain.js +++ b/test/integration/chain.js @@ -42,7 +42,7 @@ test('Setup chain', async (t) => { $last(...args) { return args[args.length - 1] } - fake(opts, label) { + fake() { this.tasks.push(null) return this } @@ -111,10 +111,6 @@ test('Setup chain', async (t) => { t.test('extended chain w/ lookup functions (moment)', async (t) => { class MomentChain extends Chain { - constructor(state) { - super(state) - } - $now() { return moment().utc().valueOf() } @@ -144,10 +140,6 @@ test('Setup chain', async (t) => { t.test('extended chain w/ lookup functions (luxon)', async (t) => { class LuxonChain extends Chain { - constructor(state) { - super(state) - } - $now() { return luxon.DateTime.now() } @@ -177,11 +169,7 @@ test('Setup chain', async (t) => { }) t.test('extended chain created with an existing state', async (t) => { - class WithStateChain extends Chain { - constructor(state) { - super(state) - } - } + class WithStateChain extends Chain {} const state_param = {hello: 'there'} const chain = new WithStateChain(state_param) @@ -192,10 +180,6 @@ test('Setup chain', async (t) => { t.test('chain function argument handling', async (t) => { class FunctionChain extends Chain { - constructor(state) { - super(state) - } - $test(...args) { return `test-${args.join('-')}` }