Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/generate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ jobs:
name: Regenerate against ${{ inputs.environment }}
runs-on: ubuntu-latest
steps:
# Pushes and PRs made with the default GITHUB_TOKEN don't trigger other
# workflows: use the CI token, so Verify runs on the regeneration PR
- uses: actions/checkout@v4
with:
token: ${{ secrets.COMMERCELAYER_CI_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 'lts/*', cache: 'pnpm' }
Expand All @@ -47,7 +51,7 @@ jobs:

- name: Open a pull request
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.COMMERCELAYER_CI_TOKEN }}
run: |
set -euo pipefail
if [ -z "$(git status --porcelain)" ]; then
Expand Down
23 changes: 19 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
name: Publish

# Publishes to npm when a human publishes a draft release created by
# release.yml, then announces it on Slack. Only the tagged package is
# published (scripts/publish.mjs), built and tested from exactly the tag.
# release.yml, then announces it on Slack. The tagged package is published
# (scripts/publish.mjs), built and tested from exactly the tag, together with
# the workspace dependencies it needs that aren't on npm yet (cli-core,
# cli-ux, … released in the same chore(release) PR); their draft releases
# are then marked published too.
#
# npm authentication is OIDC trusted publishing, no NPM_TOKEN: each package's
# trusted publisher must name the repository commercelayer/commercelayer-cli
Expand All @@ -17,7 +20,8 @@ on:
required: true

permissions:
contents: read
# contents: write to publish the draft releases of the dependencies
contents: write
id-token: write

jobs:
Expand Down Expand Up @@ -55,14 +59,25 @@ jobs:
CL_CLI_ORGANIZATION: cli-test-org
CL_CLI_CLIENT_ID: ${{ secrets.CL_CLI_CLIENT_ID }}
CL_CLI_CLIENT_SECRET: ${{ secrets.CL_CLI_CLIENT_SECRET }}
run: pnpm --filter '${{ steps.meta.outputs.package }}' test
# The package and its workspace dependencies, which may be published along
run: pnpm --filter '${{ steps.meta.outputs.package }}...' --workspace-concurrency=1 test

- name: Command surface vs npm
run: node scripts/check-manifest.mjs '${{ steps.meta.outputs.dir }}'

- name: Publish
id: publish
run: node scripts/publish.mjs "$TAG"

- name: Publish the draft releases of the dependencies published along
if: steps.publish.outputs.published_deps != ''
env:
GH_TOKEN: ${{ github.token }}
run: |
for tag in ${{ steps.publish.outputs.published_deps }}; do
gh release edit "$tag" --draft=false && echo "Published release $tag"
done

- name: Post to a Slack channel
uses: slackapi/slack-github-action@v3
with:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Every package is versioned and released on its own. A release starts from a tag
3. **Draft**: each tag makes [release.yml](.github/workflows/release.yml) draft a GitHub release, with notes built from the titles and labels of the PRs that touched that package.
4. **Publish**: publishing the draft makes [publish.yml](.github/workflows/publish.yml) build and test the package from the tag, check its command surface against npm, publish it to npm with provenance and announce it on Slack.

When a release depends on another unreleased package (for example a plugin needing a new `cli-core`), publish the dependency's release first: `publish.yml` refuses to publish a package whose workspace dependencies aren't on npm yet.
Internal dependencies need no manual step. When a released package uses unreleased changes of a workspace dependency (`cli-core`, `cli-ux`, …), `pnpm release:version` releases the dependency in the same PR, even if you skip it with `--interactive`. `publish.yml` then publishes the dependency to npm before the package, and marks the dependency's draft release as published.

PRs get a `pkg:<dir>` label from the files they touch; that's how each release lists only its own changes. After adding or removing a package, run `pnpm release:config` and commit the generated `.github/labeler.yml` and `.github/release-*.yml`.

Expand Down
3 changes: 2 additions & 1 deletion packages/cli/.mocharc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"import": "tsx",
"require": [
"../../scripts/test/oclif-root.cjs"
]
],
"timeout": 10000
}
3 changes: 3 additions & 0 deletions packages/core/.mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"import": "tsx"
}
3 changes: 0 additions & 3 deletions packages/core/babel.config.js

This file was deleted.

7 changes: 0 additions & 7 deletions packages/core/jest.config.js

This file was deleted.

16 changes: 9 additions & 7 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@
"scripts": {
"build": "tsup",
"start": "tsx src/index.ts",
"test": "jest --passWithNoTests",
"test-local": "tsx test/spot.ts",
"coverage": "jest --coverage"
"test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"",
"test-local": "tsx test/spot.ts"
},
"keywords": [
"ecommerce",
Expand All @@ -35,13 +34,16 @@
"node": ">=20"
},
"devDependencies": {
"@babel/preset-env": "catalog:",
"@babel/preset-typescript": "catalog:",
"@types/jest": "catalog:",
"@commercelayer/cli-test-utils": "workspace:*",
"@types/chai": "catalog:",
"@types/jsonwebtoken": "^9.0.10",
"@types/mocha": "catalog:",
"@types/node": "catalog:",
"chai": "catalog:",
"dotenv": "catalog:",
"jest": "catalog:",
"mocha": "catalog:",
"nock": "catalog:",
"nyc": "catalog:",
"oclif": "catalog:",
"tsup": "catalog:",
"tsx": "catalog:",
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const printJSON = (obj: any, options?: { unformatted?: boolean; tabSize?: number
const printCSV = (obj: object[], flags?: any): string => {
if (!obj || (obj.length === 0)) return ''
const fields = Object.keys(obj[0]).filter(f => {
if (['id', 'type'].includes(f)) return flags?.fields.includes(f)
if (['id', 'type'].includes(f)) return flags?.fields?.includes(f)
return true
})
let csv = fields.map(f => f.toUpperCase().replace(/_/g, ' ')).join(';') + '\n'
Expand Down Expand Up @@ -54,7 +54,9 @@ const maxLength = (values: any[], field: string): number => {
/** Clean ISO string date */
const cleanDate = (date: string): string => {
if (!date) return ''
return date.replace('T', ' ').replace('Z', '').substring(0, date.lastIndexOf('.'))
const clean = date.replace('T', ' ').replace('Z', '')
const ms = clean.lastIndexOf('.')
return (ms < 0) ? clean : clean.substring(0, ms)
}

/** Localized string date */
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const specialFolder = (filePath: string, createIfNotExists: boolean = fal
if (specialFolders.includes(root)) {
let filePrefix = homedir()
if (root === 'desktop') filePrefix += `${sep}Desktop`
filePath = filePath.replace(root, filePrefix)
filePath = filePrefix + filePath.slice(root.length)
}
const fileDir = dirname(filePath)
if (createIfNotExists && !existsSync(fileDir)) mkdirSync(fileDir, { recursive: true })
Expand Down
152 changes: 152 additions & 0 deletions packages/core/test/unit/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { api, coreApi, useMockedApi } from '@commercelayer/cli-test-utils'
import { expect } from 'chai'
import {
baseURL,
execMode,
extractDomain,
humanizeResource,
isResourceCacheable,
liveEnvironment,
Operation,
request,
requestRateLimitDelay,
} from '../../src/api'

describe('api', () => {
describe('baseURL', () => {
it('uses the organization slug for the core and metrics APIs', () => {
expect(baseURL('core', 'My-Org')).to.equal('https://my-org.commercelayer.io')
expect(baseURL('metrics', 'acme', 'commercelayer.co')).to.equal('https://acme.commercelayer.co')
})

it('uses the API name as subdomain for the other APIs', () => {
expect(baseURL('provisioning', 'acme')).to.equal('https://provisioning.commercelayer.io')
expect(baseURL('auth')).to.equal('https://auth.commercelayer.io')
})

it('falls back to the API name without a slug', () => {
expect(baseURL()).to.equal('https://core.commercelayer.io')
})
})

it('extracts the domain of a base URL', () => {
expect(extractDomain('https://acme.commercelayer.io')).to.equal('commercelayer.io')
expect(extractDomain('')).to.equal(undefined)
})

it('decodes the execution mode', () => {
expect(execMode(true)).to.equal('live')
expect(execMode('live')).to.equal('live')
expect(execMode(false)).to.equal('test')
expect(execMode('test')).to.equal('test')
expect(execMode(undefined)).to.equal('test')
expect(liveEnvironment('live')).to.equal(true)
expect(liveEnvironment('test')).to.equal(false)
})

it('humanizes resource types', () => {
expect(humanizeResource('price_lists')).to.equal('price lists')
expect(humanizeResource('price_lists', true)).to.equal('price list')
})

it('knows which requests are cacheable', () => {
expect(isResourceCacheable('skus')).to.equal(true)
expect(isResourceCacheable('skus', 'get' as never)).to.equal(true)
expect(isResourceCacheable('skus', 'PATCH')).to.equal(false)
expect(isResourceCacheable('orders', 'GET')).to.equal(false)
expect(isResourceCacheable()).to.equal(false)
})

describe('requestRateLimitDelay', () => {
it('defaults to the slowest of the burst and average limits of the test environment', () => {
// test, uncacheable: burst 10s/25 = 400ms, average 60s/100 = 600ms
expect(requestRateLimitDelay()).to.equal(600)
})

it('uses the live and cacheable limits', () => {
// live, uncacheable: burst 10s/50 = 200ms, average 60s/200 = 300ms
expect(requestRateLimitDelay({ environment: 'live' })).to.equal(300)
// live, cacheable: burst 10s/250 = 40ms, average 60s/1000 = 60ms
expect(requestRateLimitDelay({ environment: 'live', resourceType: 'skus' })).to.equal(60)
})

it('scales with the parallel requests', () => {
expect(requestRateLimitDelay({ parallelRequests: 3 })).to.equal(1800)
})

it('needs no delay when the total requests fit in a burst', () => {
expect(requestRateLimitDelay({ totalRequests: 25 })).to.equal(0)
expect(requestRateLimitDelay({ totalRequests: 26 })).to.equal(400)
expect(requestRateLimitDelay({ totalRequests: 101 })).to.equal(600)
})

it('applies the minimum and security delays', () => {
expect(requestRateLimitDelay({ totalRequests: 1, minimumDelay: 150 })).to.equal(150)
expect(requestRateLimitDelay({ securityDelay: 50 })).to.equal(650)
})
})

describe('request.raw', () => {
useMockedApi()

const config = (operation: Operation) => ({ baseUrl: coreApi(), resource: 'skus', accessToken: 'token', operation })

it('creates a resource', async () => {
const data = { data: { type: 'skus', attributes: { code: 'SKU1' } } }
api()
.post('/api/skus', data)
.matchHeader('authorization', 'Bearer token')
.matchHeader('content-type', 'application/vnd.api+json')
.reply(201, { data: { id: 'sku1', type: 'skus' } })
expect(await request.raw(config(Operation.Create), data)).to.deep.equal({ data: { id: 'sku1', type: 'skus' } })
})

it('updates a resource by id', async () => {
api().patch('/api/skus/sku1').reply(200, { data: { id: 'sku1', type: 'skus' } })
expect((await request.raw(config(Operation.Update), {}, 'sku1')).data.id).to.equal('sku1')
})

it('throws the status text of a failed request', async () => {
api().post('/api/skus').reply(422, { errors: [] })
let error: Error | undefined
try {
await request.raw(config(Operation.Create), {})
} catch (e) {
error = e as Error
}
expect(error?.message).to.equal('Unprocessable Entity')
})
})

describe('request.readDataFile', () => {
let dir: string
before(() => {
dir = mkdtempSync(join(tmpdir(), 'cli-core-'))
})
after(() => {
rmSync(dir, { recursive: true, force: true })
})

const file = (name: string, content: string): string => {
const path = join(dir, name)
writeFileSync(path, content)
return path
}

it('wraps the file content in a data member', () => {
expect(request.readDataFile(file('plain.json', '{"type":"skus"}'))).to.deep.equal({ data: { type: 'skus' } })
})

it('keeps a document that already has a data member', () => {
expect(request.readDataFile(file('doc.json', '{"data":{"type":"skus"}}'))).to.deep.equal({ data: { type: 'skus' } })
})

it('rejects missing files and invalid JSON', () => {
expect(() => request.readDataFile(join(dir, 'missing.json'))).to.throw(/Unable to find or open the data file/)
expect(() => request.readDataFile(file('bad.json', '{no'))).to.throw(/invalid JSON format/)
})
})
})
32 changes: 32 additions & 0 deletions packages/core/test/unit/application.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect } from 'chai'
import { appKey, appKeyMatch, appKeyValid, arrayScope, isProvisioningApp } from '../../src/application'

describe('application', () => {
it('builds an application key from the current time', () => {
expect(appKey()).to.match(/^[0-9a-z]+$/)
})

it('checks an application key', () => {
expect(appKeyValid({ key: 'abc', mode: 'test' })).to.equal(true)
expect(appKeyValid({ key: '', mode: 'test' })).to.equal(false)
})

it('matches application keys', () => {
expect(appKeyMatch({ key: 'a', mode: 'test' }, { key: 'a', mode: 'live' })).to.equal(true)
expect(appKeyMatch({ key: 'a', mode: 'test' }, { key: 'b', mode: 'test' })).to.equal(false)
expect(appKeyMatch({ key: 'a', mode: 'test' }, undefined)).to.equal(false)
expect(appKeyMatch(undefined, undefined)).to.equal(true)
})

it('splits a scope on spaces, commas and semicolons', () => {
expect(arrayScope('market:1 market:2,stock_location:3;store:4')).to.deep.equal(['market:1', 'market:2', 'stock_location:3', 'store:4'])
expect(arrayScope(['market:1'])).to.deep.equal(['market:1'])
expect(arrayScope()).to.deep.equal([])
})

it('recognizes provisioning applications', () => {
expect(isProvisioningApp({ clientId: 'x', scope: 'provisioning-api' })).to.equal(true)
expect(isProvisioningApp({ clientId: 'x', api: 'provisioning' })).to.equal(true)
expect(isProvisioningApp({ clientId: 'x', scope: 'market:1' })).to.equal(false)
})
})
Loading
Loading