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
4 changes: 2 additions & 2 deletions COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Compared against the CodeMash docs (`docs/codemash-docs/api-reference`, 452
documented endpoints) on 2026-07-21.

**243 endpoints have a dedicated CLI command (the sum of the table below;
**247 endpoints have a dedicated CLI command (the sum of the table below;
the line said 117 until the Project campaign, 2026-10-03, and had not kept
up with the push / SMS / Email rows). Every SDK method (~180 hub +
~40 api) is also callable with plain words:**
Expand All @@ -26,7 +26,7 @@ out of reach.
| database/taxonomies | 2/15 | read-only list/get |
| database/aggregates | 1/5 | list only |
| files/* | 9/23 | upload, download, list, info, sign, delete. Missing: bulk delete, triggers, integration config |
| logs/* | 6/14 | list, trail, settings, module toggle. Missing: clean, integration config |
| logs/* | 10/16 | list, trail, get, settings, settings set, clean, write (Api host), module toggle — each command is run through the real transport in `tests/logs.test.ts`. Missing: integration config (save / test / enable / disable / delete — `norbix hub logs …` reaches them) |
| membership/users | 6/35 | list, get, invite, block, unblock, delete. Missing: contacts, create-user variants, preferences |
| membership/roles + policies | 2/10 | read-only lists |
| notifications/email | 48/51 | every endpoint except the three recipient / provider callbacks — see `docs/email.md`; each command is run through the real transport in `tests/email-routes.test.ts` |
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ norbix scheduler list
| `norbix files integrations test <id>` | Check that a files integration really works (live upload/read/list/delete probe) |
| `norbix users list/get/invite/block/unblock/delete` | Manage project users (membership) |
| `norbix env list/use/delete/promote/rollback`, `norbix env built-ins repair` | Manage project environments and move content TEST → PROD (see *Environments* below) |
| `norbix logs list/trail` | Read project logs, follow one correlation ID |
| `norbix logs list/trail/get` | Read project logs, follow one correlation ID, show one entry |
| `norbix logs write` | Write your app's own lines (`--title …` or `--file entries.json`); prints `accepted: n`, on a 429 says how many seconds to wait |
| `norbix logs settings` / `settings set` / `clean` | Read and change log settings (only the flags you pass are sent); delete every stored entry (asks; `--yes` skips) |
| `norbix scheduler list/get/save/enable/disable/delete` | Scheduler tasks — a cron that sends an email campaign; every scheduler endpoint, see [docs/scheduler.md](docs/scheduler.md) |
| `norbix apikeys list/create/regenerate/revoke` | Service-user API keys for scripts, CI, SDKs and agents |
| `norbix webhooks show/secret/enable/disable/remove` | Inspect the webhook integration |
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
},
"dependencies": {
"@inquirer/prompts": "^8.7.2",
"@norbix.ai/ts": "^4.30.0",
"@norbix.ai/ts": "^4.31.0",
"@oclif/core": "^5.0.0",
"@oclif/plugin-autocomplete": "^4.0.0",
"@oclif/plugin-help": "^7.0.0"
Expand Down Expand Up @@ -171,7 +171,7 @@
}
},
"logs": {
"description": "Read project logs"
"description": "Read and write project logs, log settings"
},
"payments": {
"description": "Payment integrations and triggers"
Expand Down
26 changes: 26 additions & 0 deletions src/commands/logs/clean.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {BaseCommand} from '../../base.js'

export default class LogsClean extends BaseCommand {
static description = `Delete every log entry stored in the project's Norbix Logging

Asks first; --yes skips the question (needed in scripts and CI). Lines already
sent to other logging integrations (Datadog, CloudWatch, …) stay there.`

static examples = ['<%= config.bin %> logs clean', '<%= config.bin %> logs clean --yes', '<%= config.bin %> logs clean --dry-run']

static flags = {
...BaseCommand.mutatingFlags,
}

async run(): Promise<unknown> {
const {flags} = await this.parse(LogsClean)
const client = this.client(flags)

await this.confirmOrFail("Delete every log entry stored in the project's Norbix Logging?", flags)

const res = await client.hub.logs.cleanLogs({})

this.print('Logs cleaned.')
return res
}
}
23 changes: 23 additions & 0 deletions src/commands/logs/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {Args} from '@oclif/core'

import {BaseCommand} from '../../base.js'

export default class LogsGet extends BaseCommand {
static description = 'Show one log entry by its id (the id of a `logs list` row)'

static examples = ['<%= config.bin %> logs get 66b2f0a1c3d4e5f6a7b8c9d0', '<%= config.bin %> logs get 66b2f0a1c3d4e5f6a7b8c9d0 --json']

static args = {
id: Args.string({required: true, description: 'Log entry ID (24 hex characters)'}),
}

async run(): Promise<unknown> {
const {args, flags} = await this.parse(LogsGet)
const client = this.client(flags)

const res = await client.hub.logs.getLog({id: args.id})

this.print(res)
return res
}
}
47 changes: 47 additions & 0 deletions src/commands/logs/settings/set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {Flags} from '@oclif/core'

import {BaseCommand} from '../../../base.js'

const BOOL = ['true', 'false'] as const

export default class LogsSettingsSet extends BaseCommand {
static description = `Change the project's log settings

Only the settings you pass are sent; the server keeps the others as they are.`

static examples = [
'<%= config.bin %> logs settings set --skip-body true',
'<%= config.bin %> logs settings set --skip-cloud false --ai-chat true',
'<%= config.bin %> logs settings set --ai-chat true --dry-run',
]

static flags = {
...BaseCommand.dryRunFlags,
'skip-cloud': Flags.string({
description: 'Drop log entries from requests made by the Norbix dashboard',
options: [...BOOL],
}),
'skip-body': Flags.string({
description: 'Strip request/response bodies off http:request / http:response entries',
options: [...BOOL],
}),
'ai-chat': Flags.string({description: 'Write log entries for AI chat turns', options: [...BOOL]}),
}

async run(): Promise<unknown> {
const {flags} = await this.parse(LogsSettingsSet)
const request: {skipCloudDashboardLogs?: boolean; skipHttpBodyMeta?: boolean; aiChatLoggingEnabled?: boolean} = {}
if (flags['skip-cloud'] !== undefined) request.skipCloudDashboardLogs = flags['skip-cloud'] === 'true'
if (flags['skip-body'] !== undefined) request.skipHttpBodyMeta = flags['skip-body'] === 'true'
if (flags['ai-chat'] !== undefined) request.aiChatLoggingEnabled = flags['ai-chat'] === 'true'
if (Object.keys(request).length === 0) {
this.error('Pass --skip-cloud, --skip-body or --ai-chat (true or false).', {exit: 2})
}

const client = this.client(flags)
const res = await client.hub.logs.saveLogSettings(request)

this.print('Log settings saved.')
return res
}
}
138 changes: 138 additions & 0 deletions src/commands/logs/write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import {Flags} from '@oclif/core'

import {BaseCommand} from '../../base.js'
import {CliError, usageError} from '../../lib/cli-error.js'
import {EXIT} from '../../lib/exit-codes.js'
import {readJsonInput} from '../../lib/json.js'
import {parseMetadata} from '../../lib/payments.js'

type Level = 'information' | 'warning' | 'error'

interface Entry {
level?: Level
title: string
message?: string
eventCode?: string
correlationId?: string
timestamp?: string
meta?: Record<string, string>
}

export default class LogsWrite extends BaseCommand {
static description = `Write your own lines to the project's logs

One line from the flags (--title is required), or up to 100 from --file: a
JSON array of entries {title, level?, message?, eventCode?, correlationId?,
timestamp?, meta?} — a path, @path, or - for stdin. The lines are queued and
show in \`logs list\` a moment later; prints how many were accepted.
eventCode is stored as app:<code>. On a 429 the project's per-minute quota is
used up: the error says how many seconds to wait (Retry-After).`

static examples = [
'<%= config.bin %> logs write --title "Order paid" --event-code order:paid --meta orderId=ord_123',
'<%= config.bin %> logs write --title "Payment failed" --level error --message "Card declined" --correlation-id ord_123',
'<%= config.bin %> logs write --file entries.json',
'<%= config.bin %> logs write --title "Order paid" --dry-run',
'cat entries.json | <%= config.bin %> logs write --file -',
]

static flags = {
...BaseCommand.dryRunFlags,
title: Flags.string({description: 'A short line, 1–200 characters (what the Logs list shows)', exclusive: ['file']}),
level: Flags.string({description: 'Severity', options: ['information', 'warning', 'error'], exclusive: ['file']}),
message: Flags.string({description: 'Details, at most 8 000 characters', exclusive: ['file']}),
'event-code': Flags.string({
description: 'Your code for this kind of line (a-z 0-9 _ . : -, at most 64); stored as app:<code>',
exclusive: ['file'],
}),
'correlation-id': Flags.string({
description: 'Groups lines into one flow (letters, digits, _ or -, at most 64)',
exclusive: ['file'],
}),
timestamp: Flags.string({
description: 'When it happened (UTC, ISO 8601; at most 7 days old, 5 minutes ahead)',
exclusive: ['file'],
}),
meta: Flags.string({description: 'Extra field key=value (repeat for several, at most 20)', multiple: true, exclusive: ['file']}),
file: Flags.string({description: 'A JSON array of entries: a path, @path, or - for stdin'}),
}

async run(): Promise<unknown> {
const {flags} = await this.parse(LogsWrite)
const entries = flags.file ? await readEntries(flags.file) : [this.entryFromFlags(flags)]

const client = this.client(flags)
let res: {accepted?: number}
try {
res = (await client.api.logs.writeLogs({entries} as Parameters<typeof client.api.logs.writeLogs>[0])) as {
accepted?: number
}
} catch (error) {
throw quotaError(error) ?? error
}

this.print(`accepted: ${res?.accepted ?? 0}`)
return res
}

private entryFromFlags(flags: {
title?: string
level?: string
message?: string
'event-code'?: string
'correlation-id'?: string
timestamp?: string
meta?: string[]
}): Entry {
if (!flags.title) {
throw usageError('Pass --title (or --file with a JSON array of entries).', 'e.g. norbix logs write --title "Order paid"')
}

let meta: Record<string, string> | undefined
try {
meta = parseMetadata(flags.meta)
} catch (error) {
throw usageError((error as Error).message, 'e.g. --meta orderId=ord_123 (repeat for several)')
}

const entry: Entry = {title: flags.title}
if (flags.level) entry.level = flags.level as Level
if (flags.message !== undefined) entry.message = flags.message
if (flags['event-code']) entry.eventCode = flags['event-code']
if (flags['correlation-id']) entry.correlationId = flags['correlation-id']
if (flags.timestamp) entry.timestamp = flags.timestamp
if (meta) entry.meta = meta
return entry
}
}

/** --file: `-` (stdin), `@path`, inline JSON, or a plain path. Must be a non-empty array. */
async function readEntries(value: string): Promise<Entry[]> {
const source = value === '-' || value.startsWith('@') || /^\s*[[{]/.test(value) ? value : `@${value}`
const parsed = JSON.parse(await readJsonInput(source, 'file')) as unknown
if (!Array.isArray(parsed) || parsed.length === 0) {
throw usageError(
'--file must hold a JSON array with at least one entry.',
'e.g. [{"title":"Order paid","eventCode":"order:paid"}] — at most 100 entries per call.',
)
}

return parsed as Entry[]
}

/** A 429 from the gateway: the per-minute quota is used up; say how long to wait. */
function quotaError(error: unknown): CliError | undefined {
const err = error as {name?: string; status?: number; code?: string; message?: string; url?: string; retryAfterSeconds?: number}
if (!err || typeof err !== 'object' || !err.name?.startsWith('Norbix') || err.status !== 429) return undefined
const seconds = err.retryAfterSeconds
const wait = seconds === undefined ? 'a minute' : `${seconds} second${seconds === 1 ? '' : 's'}`
return new CliError({
exit: EXIT.SERVER,
code: err.code ?? 'CM-ERRORS-LOGGING-003',
status: 429,
url: err.url,
message: `${err.message ?? 'Log quota used up.'} Retry after ${wait}.`,
hint: `The project's per-minute log quota is used up. Wait ${wait} and send again.`,
context: seconds === undefined ? undefined : {retryAfterSeconds: seconds},
})
}
2 changes: 1 addition & 1 deletion src/generated/request-fields.json

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions tests/__snapshots__/schema.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2069,6 +2069,25 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = `
"id": "logout",
"supportsDryRun": true,
},
{
"args": [],
"destructive": true,
"flags": [
"yes:boolean",
"dry-run:boolean",
],
"id": "logs clean",
"supportsDryRun": true,
},
{
"args": [
"id",
],
"destructive": false,
"flags": [],
"id": "logs get",
"supportsDryRun": false,
},
{
"args": [],
"destructive": false,
Expand All @@ -2093,6 +2112,18 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = `
"id": "logs settings",
"supportsDryRun": false,
},
{
"args": [],
"destructive": false,
"flags": [
"dry-run:boolean",
"skip-cloud:option",
"skip-body:option",
"ai-chat:option",
],
"id": "logs settings set",
"supportsDryRun": true,
},
{
"args": [
"correlationId",
Expand All @@ -2102,6 +2133,23 @@ exports[`norbix schema > is stable (snapshot of ids, flags and markers) 1`] = `
"id": "logs trail",
"supportsDryRun": false,
},
{
"args": [],
"destructive": false,
"flags": [
"dry-run:boolean",
"title:string",
"level:option",
"message:string",
"event-code:string",
"correlation-id:string",
"timestamp:string",
"meta:string",
"file:string",
],
"id": "logs write",
"supportsDryRun": true,
},
{
"args": [
"action",
Expand Down
1 change: 1 addition & 0 deletions tests/agent-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const DESTRUCTIVE: string[][] = [
['sms', 'campaign', 'delete', 'abc123'],
['sms', 'integration', 'delete', 'abc123'],
['sms', 'integration', 'disable', 'abc123'],
['logs', 'clean'],
['project', 'delete'],
['project', 'disable'],
['project', 'admin-portal', 'disable'],
Expand Down
Loading
Loading