feat(cli+skill): make documented field UX real + fix skill doc drift

CLI (packages/inkreach-cli):
- new src/lib/field-normalize.ts: type aliases (sel/num/text/mul/…),
  select string-choices -> {name}, compact --fields 'Name:type:choices'
  expansion, smart type inference from --options shape / --name
  (--type now optional), name->ID resolution for foreignTableName /
  linkFieldName / lookupFieldName (incl. link-field foreignTableId
  backfill + unknown-key stripping), rating/number/button required-
  option defaults
- field create: +--description/--lookup-options/--is-lookup
- field update --options + table create --fields route through the
  normalizer; DEFAULT_ENDPOINT :3000 -> :3003
- 20 unit tests (field-normalize.spec.ts, vitest); live-verified:
  string choices, alias, foreignTableName, lookup-by-names, compact
  format, inference (priority->singleSelect, max:5->rating)

Skill docs (.agents/skills/inkreach-teable):
- ports :3000 -> :3003 everywhere (backend real port; :3000 is an
  unrelated container on this host)
- remove dead hr_admin account; note throwaway signup + real admin
- plugin register: actual URL-based flags; document plugin csp
  list/set; view/dashboard install requires config enable first
- field-types.md marked verified-2026-08 (aliases/inference/name
  resolution now implemented + tested)
- development.md: webpack dist/index.js cache trap; PGPASSWORD
  placeholder instead of wrong literal
This commit is contained in:
yeuimu
2026-08-16 05:58:01 +00:00
parent 5a984eb5f8
commit e1a91dfa84
13 changed files with 1015 additions and 34 deletions
+2 -2
View File
@@ -31,7 +31,7 @@ Respond in the user's language. Keep answers concise and action-oriented.
## 1. Prerequisites & Constraints ## 1. Prerequisites & Constraints
- All operations use the `inkreach-teable` CLI. Install via `bash scripts/install.sh` (relative to this skill dir) if `inkreach-teable` is not found. - All operations use the `inkreach-teable` CLI. Install via `bash scripts/install.sh` (relative to this skill dir) if `inkreach-teable` is not found.
- **Endpoint + token**: configure once with `inkreach-teable auth login --endpoint <url> --token <PAT>`. Default endpoint is `http://localhost:3000`. Verify with `inkreach-teable auth status`. - **Endpoint + token**: configure once with `inkreach-teable auth login --endpoint <url> --token <PAT>`. Default endpoint is `http://localhost:3003`. Verify with `inkreach-teable auth status`.
- **Getting a PAT**: `inkreach-teable auth token create --name my-cli` (returns the full token **only once**). Scopes default to automation+table+record read/write. Or create via the web UI (Settings → Personal Access Tokens). - **Getting a PAT**: `inkreach-teable auth token create --name my-cli` (returns the full token **only once**). Scopes default to automation+table+record read/write. Or create via the web UI (Settings → Personal Access Tokens).
- **`--base-id`**: omit if a default was set via `auth login`; CLI uses it automatically. Ask user only if a command fails with MISSING_BASE_ID. - **`--base-id`**: omit if a default was set via `auth login`; CLI uses it automatically. Ask user only if a command fails with MISSING_BASE_ID.
- **`--json` mode**: add `--json` to any command for raw JSON output (for agent parsing). - **`--json` mode**: add `--json` to any command for raw JSON output (for agent parsing).
@@ -82,7 +82,7 @@ Respond in the user's language. Keep answers concise and action-oriented.
```bash ```bash
# Configure access (once) # Configure access (once)
inkreach-teable auth login --endpoint http://localhost:3000 --token <PAT> inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT>
# Create a table # Create a table
inkreach-teable table create --name "Tasks" --base-id bseXXX \ inkreach-teable table create --name "Tasks" --base-id bseXXX \
@@ -2,6 +2,11 @@
Complete reference of all Teable field types, aliases, option shapes, and smart inference rules. Complete reference of all Teable field types, aliases, option shapes, and smart inference rules.
> ✅ **Verified 2026-08**: aliases, string choices, compact `--fields` format, smart
> inference, and name resolution (`foreignTableName`/`linkFieldName`/`lookupFieldName`)
> are implemented in the CLI (`packages/inkreach-cli/src/lib/field-normalize.ts`)
> and covered by unit tests (`field-normalize.spec.ts`, 20 cases).
--- ---
## Type Overview ## Type Overview
@@ -6,7 +6,7 @@ Complete command reference for `inkreach-teable`. Global options apply to all co
| Option | Description | | Option | Description |
|--------|-------------| |--------|-------------|
| `--endpoint <url>` | Backend URL (default `http://localhost:3000`) | | `--endpoint <url>` | Backend URL (default `http://localhost:3003`) |
| `--token <pat>` | Personal Access Token | | `--token <pat>` | Personal Access Token |
| `--base-id <id>` | Default base id | | `--base-id <id>` | Default base id |
| `--json` | Output raw JSON (for agent parsing) | | `--json` | Output raw JSON (for agent parsing) |
@@ -46,7 +46,9 @@ inkreach-teable table delete <tableId>
# field (get/update/delete take <tableId> <fieldId> — 2026-08 fix; old /api/field/:id routes never existed) # field (get/update/delete take <tableId> <fieldId> — 2026-08 fix; old /api/field/:id routes never existed)
inkreach-teable field list <tableId> inkreach-teable field list <tableId>
inkreach-teable field get <tableId> <fieldId> inkreach-teable field get <tableId> <fieldId>
inkreach-teable field create --table-id <tid> --name <n> --type <t> [--options '<json>'] inkreach-teable field create --table-id <tid> --name <n> [--type <t|alias>] [--options '<json>'] \
[--description <d>] [--lookup-options '<json>'] [--is-lookup]
# --type optional: inferred from --options shape / --name when omitted
inkreach-teable field update <tableId> <fieldId> [--name <n>] [--options '<json>'] # --options goes via convert API inkreach-teable field update <tableId> <fieldId> [--name <n>] [--options '<json>'] # --options goes via convert API
inkreach-teable field visible-choices <tableId> <recordId> <fieldId> # evaluate select-choice visibilityFilter inkreach-teable field visible-choices <tableId> <recordId> <fieldId> # evaluate select-choice visibilityFilter
inkreach-teable field delete <tableId> <fieldId> inkreach-teable field delete <tableId> <fieldId>
@@ -174,8 +176,11 @@ Manage plugins and developer whitelist.
# List plugins registered in the base # List plugins registered in the base
inkreach-teable plugin list inkreach-teable plugin list
# Register a plugin (set runtimeType, positions, logo) # Register a URL-served plugin (runtimeType=url; status published immediately)
inkreach-teable plugin register --name <name> --positions '["base","panel"]' # positions: "base"|"panel"|"view"|"dashboard"|"contextMenu"
inkreach-teable plugin register --url <url> --name <name> [--positions '["panel"]'] [--logo <url>] [--description <d>]
# Bundle (upload) plugins use a different flow — see skill guides/tables-fields.md
# or api docs: create kebab-id plugin (developer whitelist) → upload → admin publish.
# Developer whitelist (admin only) # Developer whitelist (admin only)
inkreach-teable plugin dev list inkreach-teable plugin dev list
@@ -184,12 +189,19 @@ inkreach-teable plugin dev revoke <userIdOrEmail> # remove
# Install / uninstall (creates a base-level mount node) # Install / uninstall (creates a base-level mount node)
inkreach-teable plugin install --plugin <pluginId> --name "My Plugin" --position base inkreach-teable plugin install --plugin <pluginId> --name "My Plugin" --position base
# ⚠️ For view/dashboard-position plugins, run `plugin config enable` BEFORE
# install, or the install API returns 403 RESTRICTED_RESOURCE
# 'Plugin is not enabled for this base'.
inkreach-teable plugin installs # list installs in current base inkreach-teable plugin installs # list installs in current base
inkreach-teable plugin uninstall --install <installId> inkreach-teable plugin uninstall --install <installId>
# Enable/disable plugins # Enable/disable plugins
inkreach-teable plugin config enable --plugin <pluginId> inkreach-teable plugin config enable --plugin <pluginId>
inkreach-teable plugin config disable --plugin <pluginId> inkreach-teable plugin config disable --plugin <pluginId>
# CSP apiOrigins (external origins plugins may fetch)
inkreach-teable plugin csp list
inkreach-teable plugin csp set <pluginId> --origins '["http://192.168.x.x:8001"]'
``` ```
Developer whitelist is admin-only (requires `user.isAdmin=true`). Routes: Developer whitelist is admin-only (requires `user.isAdmin=true`). Routes:
@@ -13,10 +13,10 @@ inside NestJS is unstable and will hang or OOM-crash.
| Service | Command | Port | Mode | | Service | Command | Port | Mode |
|---------|---------|------|------| |---------|---------|------|------|
| `teable-backend.service` | `node dist/index.js` | `:3000` | `NODE_ENV=production`, `BACKEND_SKIP_NEXT_START=true` | | `teable-backend.service` | `node dist/index.js` | `:3003` | `NODE_ENV=production`, `BACKEND_SKIP_NEXT_START=true` |
| `teable-frontend.service` | `next start -p 3002 -H 0.0.0.0` | `:3002` | `NODE_ENV=production` | | `teable-frontend.service` | `next start -p 3002 -H 0.0.0.0` | `:3002` | `NODE_ENV=production` |
Users access `:3002`; the backend `:3000` serves the API only. Users access `:3002`; the backend `:3003` serves the API only.
## Build & Restart ## Build & Restart
@@ -37,7 +37,7 @@ systemctl --user restart teable-frontend.service
Browser cache is aggressive — hard-refresh (Ctrl+Shift+R) after a rebuild. Browser cache is aggressive — hard-refresh (Ctrl+Shift+R) after a rebuild.
### Recover from OOM crash ### Recover from OOM crash
If `:3000` is unreachable but systemd says `active`: If `:3003` is unreachable but systemd says `active`:
```bash ```bash
systemctl --user stop teable-backend.service systemctl --user stop teable-backend.service
pkill -9 -f 'dist/index.js' pkill -9 -f 'dist/index.js'
@@ -46,6 +46,13 @@ systemctl --user start teable-backend.service
## Known Gotchas (from real debugging) ## Known Gotchas (from real debugging)
### Webpack cache may not rewrite `dist/index.js` after a workspace package rebuild
After rebuilding a workspace package (e.g. `@teable/openapi` dist), `pnpm build` in
`apps/nestjs-backend` can report "compiled successfully" while leaving `dist/index.js`
untouched (node_modules paths are treated as immutable cache inputs). Symptom: backend
behavior unchanged after a code change. Fix: `rm apps/nestjs-backend/dist/index.js`
then rebuild, and verify with `grep '<your-new-string>' dist/index.js`.
### Node v24 fetch drops the Authorization header ### Node v24 fetch drops the Authorization header
The global `fetch()` on Node v24 (undici) drops the `Authorization` request The global `fetch()` on Node v24 (undici) drops the `Authorization` request
header in certain cases (redirects, proxies). **Use `node:http`/`node:https`** header in certain cases (redirects, proxies). **Use `node:http`/`node:https`**
@@ -121,16 +128,16 @@ due to the auth-header bug. Test workflows are **deactivated, not deleted**
```bash ```bash
# Count workflows by active state # Count workflows by active state
PGPASSWORD=teable psql -h localhost -U teable -d teable -c \ PGPASSWORD=<ask-maintainer> psql -h localhost -U teable -d teable -c \
"SELECT is_active, count(*) FROM workflow WHERE deleted_time IS NULL GROUP BY is_active;" "SELECT is_active, count(*) FROM workflow WHERE deleted_time IS NULL GROUP BY is_active;"
# Find button fields and their bound workflows # Find button fields and their bound workflows
PGPASSWORD=teable psql -h localhost -U teable -d teable -c \ PGPASSWORD=<ask-maintainer> psql -h localhost -U teable -d teable -c \
"SELECT id, name, options->'workflow'->>'id' AS wf_id, options->'workflow'->>'isActive' AS active "SELECT id, name, options->'workflow'->>'id' AS wf_id, options->'workflow'->>'isActive' AS active
FROM field WHERE type='button';" FROM field WHERE type='button';"
# Recent executions # Recent executions
PGPASSWORD=teable psql -h localhost -U teable -d teable -c \ PGPASSWORD=<ask-maintainer> psql -h localhost -U teable -d teable -c \
"SELECT id, workflow_id, status, started_at FROM workflow_execution "SELECT id, workflow_id, status, started_at FROM workflow_execution
ORDER BY started_at DESC LIMIT 10;" ORDER BY started_at DESC LIMIT 10;"
``` ```
@@ -8,29 +8,30 @@ endpoint/ports for your own deployment.
| Service | URL / Port | Mode | Purpose | | Service | URL / Port | Mode | Purpose |
|---------|-----------|------|---------| |---------|-----------|------|---------|
| Backend API | `http://localhost:3000` | production (`NODE_ENV=production`) | REST API, automation engine, websocket | | Backend API | `http://localhost:3003` | production (`NODE_ENV=production`) | REST API, automation engine, websocket |
| Frontend | `http://localhost:3002` | production | End-user web UI (Next.js) | | Frontend | `http://localhost:3002` | production | End-user web UI (Next.js) |
| PostgreSQL | `localhost:5432` | — | Database (db: `teable`, user: `teable`) | | PostgreSQL | `localhost:5432` | — | Database (db: `teable`, user: `teable`) |
| Redis | `localhost:6379` | — | BullMQ queue for automation runs, distributed lock | | Redis | `localhost:6379` | — | BullMQ queue for automation runs, distributed lock |
- The frontend reads `SOCKET_PORT=3000` from `.env` to point its websocket at the backend. Don't let it fall back to `3001`. - The frontend reads `SOCKET_PORT` from `.env` to point its websocket at the backend — verify it equals the backend port (3003 on this host), not the default 3001.
- End users browse `:3002`. The backend `:3000` is the API only (`BACKEND_SKIP_NEXT_START=true` disables the embedded Next server). - End users browse `:3002`. The backend `:3003` is the API only (`BACKEND_SKIP_NEXT_START=true` disables the embedded Next server).
- ⚠️ Port map differs from upstream Teable docs (:3000) — on this host `:3000` is held by an **unrelated container** (`inkreach-web`). Always verify with `ss -tlnp` before assuming.
## Health Checks ## Health Checks
```bash ```bash
# Backend API up? # Backend API up?
curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/api/auth/signin -X POST \ curl -s -o /dev/null -w '%{http_code}' http://localhost:3003/api/auth/signin -X POST \
-H 'Content-Type: application/json' -d '{}' # expect 401 (endpoint exists, bad creds) -H 'Content-Type: application/json' -d '{}' # expect 401 (endpoint exists, bad creds)
# Frontend up? # Frontend up?
curl -s -o /dev/null -w '%{http_code}' http://localhost:3002 # expect 307 or 200 curl -s -o /dev/null -w '%{http_code}' http://localhost:3002 # expect 307 or 200
# Ports listening? # Ports listening?
ss -tlnp | grep -E ':3000|:3002|:5432|:6379' ss -tlnp | grep -E ':3003|:3002|:5432|:6379'
``` ```
If `:3000` is unreachable but systemd shows `active`, the worker likely OOM-crashed while the `pnpm` parent stayed alive: If `:3003` is unreachable but systemd shows `active`, the worker likely OOM-crashed while the `pnpm` parent stayed alive:
```bash ```bash
systemctl --user stop teable-backend.service systemctl --user stop teable-backend.service
pkill -9 -f 'dist/index.js' pkill -9 -f 'dist/index.js'
@@ -58,14 +59,15 @@ Note: `auth token list/create` may require a **session** (browser login) rather
### Configure the CLI ### Configure the CLI
```bash ```bash
inkreach-teable auth login --endpoint http://localhost:3000 --token <PAT> --base-id bseXXX inkreach-teable auth login --endpoint http://localhost:3003 --token <PAT> --base-id bseXXX
inkreach-teable auth status # verify inkreach-teable auth status # verify
``` ```
## Reference Credentials (local dev) ## Reference Credentials (local dev)
> Never commit real secrets. The reference dev instance uses: > Never commit real secrets. The reference dev instance uses:
> - Admin: `hr_admin@xingchen.tech` / (ask maintainer) — for browser login only. > - ⚠️ The old admin account `hr_admin@xingchen.tech` **no longer exists** (verified 2026-08). Current users: `inkreach@inkreach.pod` (admin) — password: ask maintainer, browser login only.
> - Throwaway accounts for testing can be created via `POST /api/auth/signup` (open on this instance).
> - Base: `bseeZR7yiDNeMPCa4CN` (人事薪资管理) — the test base with ~600 automations. > - Base: `bseeZR7yiDNeMPCa4CN` (人事薪资管理) — the test base with ~600 automations.
> - Test table: `tblzPOXQvUruQosKNP5` (has button fields for automation testing). > - Test table: `tblzPOXQvUruQosKNP5` (has button fields for automation testing).
+40 -5
View File
@@ -9,6 +9,7 @@ exports.baseResourceCommands = void 0;
const commander_1 = require("commander"); const commander_1 = require("commander");
const ctx_1 = require("../lib/ctx"); const ctx_1 = require("../lib/ctx");
const output_1 = require("../lib/output"); const output_1 = require("../lib/output");
const field_normalize_1 = require("../lib/field-normalize");
function baseResourceCommands() { function baseResourceCommands() {
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()]; return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()];
} }
@@ -103,8 +104,20 @@ function tableCmd() {
const opts = o; const opts = o;
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId); const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
const body = { name: opts.name }; const body = { name: opts.name };
if (opts.fields) if (opts.fields) {
body.fields = JSON.parse(opts.fields); body.fields = (0, field_normalize_1.normalizeFieldRoArray)(JSON.parse(opts.fields));
// Resolve foreignTableName references against existing tables in the base.
const fields = body.fields;
const tables = await ctx.client.listTables(baseId).catch(() => []);
for (const f of fields) {
const options = f?.options;
if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) {
const t = (0, field_normalize_1.findTableByNameOrId)(tables || [], options.foreignTableName);
if (t)
options.foreignTableId = t.id;
}
}
}
const table = await ctx.client.createTable(baseId, body); const table = await ctx.client.createTable(baseId, body);
if (ctx.json) if (ctx.json)
(0, output_1.printJson)(table); (0, output_1.printJson)(table);
@@ -188,13 +201,34 @@ function fieldCmd() {
.description('Create a new field') .description('Create a new field')
.requiredOption('--table-id <id>', 'Table id') .requiredOption('--table-id <id>', 'Table id')
.requiredOption('--name <name>', 'Field name') .requiredOption('--name <name>', 'Field name')
.requiredOption('--type <type>', 'Field type (singleLineText, number, singleSelect, link, formula, etc.)') .option('--type <type>', 'Field type — full value (singleLineText, singleSelect, link…) or alias (text, sel, num…). Omitted: inferred from --options shape / --name')
.option('--options <json>', 'Field options JSON (type-specific)') .option('--options <json>', 'Field options JSON (type-specific; select choices accept plain strings)')
.option('--description <d>', 'Field description')
.option('--lookup-options <json>', 'Lookup options JSON (names like linkFieldName/lookupFieldName are resolved to IDs)')
.option('--is-lookup', 'Mark the field as a lookup field')
.action((0, ctx_1.run)(async (ctx, _args, o) => { .action((0, ctx_1.run)(async (ctx, _args, o) => {
const opts = o; const opts = o;
const body = { name: opts.name, type: opts.type }; const body = { name: opts.name };
if (opts.options) if (opts.options)
body.options = JSON.parse(opts.options); body.options = JSON.parse(opts.options);
if (opts.description)
body.description = opts.description;
if (opts.lookupOptions)
body.lookupOptions = JSON.parse(opts.lookupOptions);
if (opts.isLookup)
body.isLookup = true;
body.type = opts.type
? (0, field_normalize_1.resolveFieldType)(opts.type)
: (0, field_normalize_1.inferFieldType)(opts.name, body.options);
(0, field_normalize_1.normalizeFieldRo)(body);
const errors = await (0, field_normalize_1.resolveFieldReferences)(body, {
listTables: (baseId) => ctx.client.listTables(baseId),
listFields: (tableId) => ctx.client.listFields(tableId),
baseId: ctx.baseId,
tableId: opts.tableId,
});
if (errors.length)
throw new Error(errors.join('; '));
const field = await ctx.client.createField(opts.tableId, body); const field = await ctx.client.createField(opts.tableId, body);
if (ctx.json) if (ctx.json)
(0, output_1.printJson)(field); (0, output_1.printJson)(field);
@@ -235,6 +269,7 @@ function fieldCmd() {
}; };
if (opts.name) if (opts.name)
body.name = opts.name; body.name = opts.name;
(0, field_normalize_1.normalizeFieldRo)(body);
const field = await ctx.client.convertField(tableId, fieldId, body); const field = await ctx.client.convertField(tableId, fieldId, body);
if (ctx.json) if (ctx.json)
(0, output_1.printJson)(field); (0, output_1.printJson)(field);
+1 -1
View File
@@ -36,7 +36,7 @@ const path = __importStar(require("node:path"));
const os = __importStar(require("node:os")); const os = __importStar(require("node:os"));
const CONFIG_DIR = path.join(os.homedir(), '.inkreach-teable'); const CONFIG_DIR = path.join(os.homedir(), '.inkreach-teable');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
exports.DEFAULT_ENDPOINT = 'http://localhost:3000'; exports.DEFAULT_ENDPOINT = 'http://localhost:3003';
function readConfigFile() { function readConfigFile() {
try { try {
const raw = fs.readFileSync(CONFIG_FILE, 'utf8'); const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
+289
View File
@@ -0,0 +1,289 @@
"use strict";
/**
* Field-definition normalization for `field create` / `table create --fields`.
*
* The raw API is strict: `type` must be the full enum value and select
* `choices` must be objects (`{name, ...}`) — plain strings 400. These helpers
* accept the friendlier documented forms (aliases like `sel`, string choices,
* `foreignTableName` / `linkFieldName` / `lookupFieldName` to be resolved to
* IDs) so the examples in the skill docs actually work.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.resolveFieldReferences = exports.findFieldByNameOrId = exports.findTableByNameOrId = exports.inferFieldType = exports.expandCompactFieldSpec = exports.normalizeFieldRoArray = exports.normalizeFieldRo = exports.resolveFieldType = void 0;
const TYPE_ALIASES = {
text: 'singleLineText',
str: 'singleLineText',
long: 'longText',
num: 'number',
int: 'number',
check: 'checkbox',
bool: 'checkbox',
sel: 'singleSelect',
select: 'singleSelect',
multi: 'multipleSelect',
tags: 'multipleSelect',
rate: 'rating',
star: 'rating',
user: 'user',
person: 'user',
file: 'attachment',
attach: 'attachment',
mul: 'multipleSelect',
link: 'link',
rel: 'link',
rollup: 'rollup',
condrollup: 'conditionalRollup',
formula: 'formula',
auto: 'autoNumber',
created: 'createdTime',
modified: 'lastModifiedTime',
createdby: 'createdBy',
modifiedby: 'lastModifiedBy',
button: 'button',
};
/** Resolve a type alias (`sel`) to its full value (`singleSelect`). Unknown values pass through untouched. */
function resolveFieldType(type) {
return TYPE_ALIASES[type.toLowerCase()] ?? type;
}
exports.resolveFieldType = resolveFieldType;
const SELECT_TYPES = new Set(['singleSelect', 'multipleSelect']);
/** Required-option defaults for types whose Ro schema has none — applied only
* when the user supplied an options object that is missing required keys. */
function applyOptionDefaults(type, options) {
if (type === 'rating') {
if (options.icon === undefined)
options.icon = 'star';
if (options.color === undefined)
options.color = 'yellowBright';
if (options.max === undefined)
options.max = 5;
}
else if (type === 'number') {
if (options.formatting === undefined) {
options.formatting = { precision: 0, type: 'decimal' };
}
}
else if (type === 'button') {
if (options.label === undefined)
options.label = 'Button';
if (options.color === undefined)
options.color = 'teal';
}
}
/**
* Normalize one field body in place:
* - resolve `type` aliases
* - convert select `options.choices` string entries to `{name}` objects
* - backfill required-option defaults for rating/number/button
*/
function normalizeFieldRo(body) {
if (typeof body.type === 'string') {
body.type = resolveFieldType(body.type);
}
const type = body.type;
if (type &&
body.options &&
typeof body.options === 'object' &&
!Array.isArray(body.options)) {
const options = body.options;
if (SELECT_TYPES.has(type) && Array.isArray(options.choices)) {
options.choices = options.choices.map((c) => typeof c === 'string' ? { name: c } : c);
}
applyOptionDefaults(type, options);
}
return body;
}
exports.normalizeFieldRo = normalizeFieldRo;
/** Apply normalizeFieldRo to every entry of a `--fields` array (invalid input passes through so the API reports it). */
function normalizeFieldRoArray(fields) {
if (!Array.isArray(fields))
return fields;
return fields.map((f) => typeof f === 'string'
? expandCompactFieldSpec(f)
: f && typeof f === 'object' && !Array.isArray(f)
? normalizeFieldRo({ ...f })
: f);
}
exports.normalizeFieldRoArray = normalizeFieldRoArray;
/**
* Expand the compact `--fields` string form "Name[:type[:choice,choice,…]]"
* (e.g. "Status:sel:Todo,Done", "Notes:long", "Title") into a field body.
* Bare names become singleLineText; a bare type token is resolved through
* the alias map; a third segment lists select choices (strings).
*/
function expandCompactFieldSpec(spec) {
const parts = spec.split(':').map((p) => p.trim());
const name = parts[0];
const type = parts[1] ? resolveFieldType(parts[1]) : inferFieldType(name, undefined);
const body = { name, type };
if (parts.length > 2 && parts[2]) {
const choices = parts[2].split(',').map((c) => c.trim()).filter(Boolean);
body.options = { choices: choices.map((c) => ({ name: c })) };
}
return body;
}
exports.expandCompactFieldSpec = expandCompactFieldSpec;
const NAME_INFER_RULES = [
[/^(status|priority|category|type|stage|level)$/i, 'singleSelect'],
[/^(tags?|labels?)$/i, 'multipleSelect'],
[/^(description|notes|comment|content|body)$/i, 'longText'],
[/^(count|amount|price|quantity|total|number|age)$/i, 'number'],
[/^(rating|score|stars?)$/i, 'rating'],
[/^(is.+|has.+|active|enabled|done|completed)$/i, 'checkbox'],
[/^(assignee|owner|user)$/i, 'user'],
[/^(attachment|file|image|photo|document)$/i, 'attachment'],
[/^(email|url)$/i, 'singleLineText'],
[/^(created( time)?|created)$/i, 'createdTime'],
[/^(last modified|updated)$/i, 'lastModifiedTime'],
[/^(created by|author)$/i, 'createdBy'],
[/^(modified by|updated by)$/i, 'lastModifiedBy'],
];
/**
* Infer the field type when `--type` is omitted: first from options shape
* (choices/max/foreignTableName/expression/workflow), then from the field
* name, falling back to singleLineText.
*/
function inferFieldType(name, options) {
if (options && typeof options === 'object' && !Array.isArray(options)) {
const o = options;
if (Array.isArray(o.choices))
return 'singleSelect';
if (typeof o.max === 'number')
return 'rating';
if (typeof o.foreignTableName === 'string' || typeof o.foreignTableId === 'string') {
return 'link';
}
if (typeof o.expression === 'string') {
// conditionalRollup when combined with link + condition; plain formula otherwise
return typeof o.linkFieldId === 'string' && o.condition ? 'conditionalRollup' : 'formula';
}
if (o.workflow && typeof o.workflow === 'object')
return 'button';
}
if (name) {
for (const [re, type] of NAME_INFER_RULES) {
if (re.test(name))
return type;
}
}
return 'singleLineText';
}
exports.inferFieldType = inferFieldType;
/** Find a table by ID or (exact, then case-insensitive) name. Returns undefined when not found. */
function findTableByNameOrId(tables, nameOrId) {
return (tables.find((t) => t.id === nameOrId) ??
tables.find((t) => t.name === nameOrId) ??
tables.find((t) => t.name.toLowerCase() === nameOrId.toLowerCase()));
}
exports.findTableByNameOrId = findTableByNameOrId;
/** Find a field by ID or (exact, then case-insensitive) name. Returns undefined when not found. */
function findFieldByNameOrId(fields, nameOrId) {
return (fields.find((f) => f.id === nameOrId) ??
fields.find((f) => f.name === nameOrId) ??
fields.find((f) => f.name.toLowerCase() === nameOrId.toLowerCase()));
}
exports.findFieldByNameOrId = findFieldByNameOrId;
/**
* Resolve name references in link `options` and `lookupOptions` to IDs:
* - options.foreignTableName → options.foreignTableId
* - lookupOptions.linkFieldName → linkFieldId (fields of THIS table)
* - lookupOptions.lookupFieldName → lookupFieldId (fields of the foreign table)
*
* `listTables(baseId)`, `listFields(tableId)` are client calls; only invoked when
* a *Name key is present. Existing IDs are left untouched. Returns error strings
* (not throws) so the caller can decide to fail with a helpful message.
*/
async function resolveFieldReferences(body, deps) {
const errors = [];
const options = (body.options && typeof body.options === 'object' && !Array.isArray(body.options)
? body.options
: undefined);
if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) {
const tables = await deps.listTables(deps.baseId);
const t = findTableByNameOrId(tables, options.foreignTableName);
if (!t) {
errors.push(`foreignTableName "${options.foreignTableName}" not found in base ${deps.baseId}`);
}
else {
options.foreignTableId = t.id;
delete options.foreignTableName; // API rejects unknown keys
}
}
const lookup = body.lookupOptions &&
typeof body.lookupOptions === 'object' &&
!Array.isArray(body.lookupOptions)
? body.lookupOptions
: undefined;
if (lookup) {
// For lookup fields the foreign table is the target of the LINK field
// referenced by linkFieldId — there is no options.foreignTableId on the
// lookup field itself. Resolve the link field first, then use its options.
let linkForeignTableId;
const linkRef = typeof lookup.linkFieldId === 'string'
? lookup.linkFieldId
: typeof lookup.linkFieldName === 'string'
? lookup.linkFieldName
: undefined;
const hasNameHints = typeof lookup.linkFieldName === 'string' || typeof lookup.lookupFieldName === 'string';
const needForeignFromLink = hasNameHints &&
typeof lookup.foreignTableId !== 'string' &&
!(options && typeof options.foreignTableId === 'string');
if (linkRef && deps.tableId && (hasNameHints || needForeignFromLink)) {
const fields = (await deps.listFields(deps.tableId)) ?? [];
const linkField = findFieldByNameOrId(fields, linkRef);
if (!linkField && typeof lookup.linkFieldId !== 'string') {
errors.push(`linkFieldName "${lookup.linkFieldName}" not found in table ${deps.tableId}`);
}
if (linkField) {
lookup.linkFieldId = linkField.id;
const linkOptions = linkField
.options;
const fid = linkOptions && typeof linkOptions.foreignTableId === 'string'
? linkOptions.foreignTableId
: undefined;
if (fid)
linkForeignTableId = fid;
}
}
const foreignTableId = (options && typeof options.foreignTableId === 'string' && options.foreignTableId) ||
(typeof lookup.foreignTableId === 'string' && lookup.foreignTableId) ||
linkForeignTableId ||
(typeof lookup.linkFieldId === 'string' && lookup.linkFieldId); // conditional rollup: foreign table only
// Backfill foreignTableId (Ro schema requires it even for plain lookups,
// where it is derived from the link field) once resolved.
if (linkForeignTableId && typeof lookup.foreignTableId !== 'string') {
lookup.foreignTableId = linkForeignTableId;
}
if (typeof lookup.linkFieldName === 'string' && !lookup.linkFieldId) {
if (!deps.tableId) {
errors.push('linkFieldName resolution requires --table-id (creating a field)');
}
// (resolution already attempted above; if it failed an error was pushed)
}
if (typeof lookup.lookupFieldName === 'string' && !lookup.lookupFieldId) {
const foreign = (options && typeof options.foreignTableId === 'string' && options.foreignTableId) ||
foreignTableId;
if (!foreign) {
errors.push('lookupFieldName resolution requires foreignTableId (or foreignTableName)');
}
else {
const fields = await deps.listFields(foreign);
const f = findFieldByNameOrId(fields, lookup.lookupFieldName);
if (!f) {
errors.push(`lookupFieldName "${lookup.lookupFieldName}" not found in foreign table ${foreign}`);
}
else {
lookup.lookupFieldId = f.id;
}
}
}
// The API rejects unknown keys — strip the name hints (only when resolved;
// unresolved ones produced errors above and the caller will abort).
if (lookup.linkFieldId)
delete lookup.linkFieldName;
if (lookup.lookupFieldId)
delete lookup.lookupFieldName;
}
return errors;
}
exports.resolveFieldReferences = resolveFieldReferences;
+2 -1
View File
@@ -20,6 +20,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.18.0", "@types/node": "^22.18.0",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"typescript": "^5.4.3" "typescript": "^5.4.3",
"vitest": "4.1.10"
} }
} }
+57 -6
View File
@@ -6,6 +6,16 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { run, requireBaseId } from '../lib/ctx'; import { run, requireBaseId } from '../lib/ctx';
import { printJson, printTable, printKv, truncate } from '../lib/output'; import { printJson, printTable, printKv, truncate } from '../lib/output';
import {
normalizeFieldRo,
normalizeFieldRoArray,
resolveFieldReferences,
resolveFieldType,
inferFieldType,
findTableByNameOrId,
type ITableLike,
type IFieldLike,
} from '../lib/field-normalize';
export function baseResourceCommands(): Command[] { export function baseResourceCommands(): Command[] {
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()]; return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()];
@@ -124,7 +134,22 @@ function tableCmd(): Command {
const opts = o as { name: string; baseId?: string; fields?: string }; const opts = o as { name: string; baseId?: string; fields?: string };
const baseId = requireBaseId(ctx, opts.baseId); const baseId = requireBaseId(ctx, opts.baseId);
const body: Record<string, unknown> = { name: opts.name }; const body: Record<string, unknown> = { name: opts.name };
if (opts.fields) body.fields = JSON.parse(opts.fields); if (opts.fields) {
body.fields = normalizeFieldRoArray(JSON.parse(opts.fields));
// Resolve foreignTableName references against existing tables in the base.
const fields = body.fields as Record<string, unknown>[];
const tables = await ctx.client.listTables(baseId).catch(() => []);
for (const f of fields) {
const options = f?.options as Record<string, unknown> | undefined;
if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) {
const t = findTableByNameOrId(
(tables as unknown as ITableLike[]) || [],
options.foreignTableName
);
if (t) options.foreignTableId = t.id;
}
}
}
const table = await ctx.client.createTable(baseId, body); const table = await ctx.client.createTable(baseId, body);
if (ctx.json) printJson(table); if (ctx.json) printJson(table);
else { else {
@@ -221,16 +246,41 @@ function fieldCmd(): Command {
.description('Create a new field') .description('Create a new field')
.requiredOption('--table-id <id>', 'Table id') .requiredOption('--table-id <id>', 'Table id')
.requiredOption('--name <name>', 'Field name') .requiredOption('--name <name>', 'Field name')
.requiredOption( .option(
'--type <type>', '--type <type>',
'Field type (singleLineText, number, singleSelect, link, formula, etc.)' 'Field type — full value (singleLineText, singleSelect, link…) or alias (text, sel, num…). Omitted: inferred from --options shape / --name'
) )
.option('--options <json>', 'Field options JSON (type-specific)') .option('--options <json>', 'Field options JSON (type-specific; select choices accept plain strings)')
.option('--description <d>', 'Field description')
.option('--lookup-options <json>', 'Lookup options JSON (names like linkFieldName/lookupFieldName are resolved to IDs)')
.option('--is-lookup', 'Mark the field as a lookup field')
.action( .action(
run(async (ctx, _args, o) => { run(async (ctx, _args, o) => {
const opts = o as { tableId: string; name: string; type: string; options?: string }; const opts = o as {
const body: Record<string, unknown> = { name: opts.name, type: opts.type }; tableId: string;
name: string;
type?: string;
options?: string;
description?: string;
lookupOptions?: string;
isLookup?: boolean;
};
const body: Record<string, unknown> = { name: opts.name };
if (opts.options) body.options = JSON.parse(opts.options); if (opts.options) body.options = JSON.parse(opts.options);
if (opts.description) body.description = opts.description;
if (opts.lookupOptions) body.lookupOptions = JSON.parse(opts.lookupOptions);
if (opts.isLookup) body.isLookup = true;
body.type = opts.type
? resolveFieldType(opts.type)
: inferFieldType(opts.name, body.options);
normalizeFieldRo(body);
const errors = await resolveFieldReferences(body, {
listTables: (baseId) => ctx.client.listTables(baseId) as Promise<ITableLike[]>,
listFields: (tableId) => ctx.client.listFields(tableId) as Promise<IFieldLike[]>,
baseId: ctx.baseId!,
tableId: opts.tableId,
});
if (errors.length) throw new Error(errors.join('; '));
const field = await ctx.client.createField(opts.tableId, body); const field = await ctx.client.createField(opts.tableId, body);
if (ctx.json) printJson(field); if (ctx.json) printJson(field);
else { else {
@@ -274,6 +324,7 @@ function fieldCmd(): Command {
options: JSON.parse(opts.options as string), options: JSON.parse(opts.options as string),
}; };
if (opts.name) body.name = opts.name; if (opts.name) body.name = opts.name;
normalizeFieldRo(body);
const field = await ctx.client.convertField(tableId, fieldId, body); const field = await ctx.client.convertField(tableId, fieldId, body);
if (ctx.json) printJson(field); if (ctx.json) printJson(field);
else process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`); else process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`);
+1 -1
View File
@@ -18,7 +18,7 @@ export interface IConfig {
const CONFIG_DIR = path.join(os.homedir(), '.inkreach-teable'); const CONFIG_DIR = path.join(os.homedir(), '.inkreach-teable');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
export const DEFAULT_ENDPOINT = 'http://localhost:3000'; export const DEFAULT_ENDPOINT = 'http://localhost:3003';
export function readConfigFile(): Partial<IConfig> { export function readConfigFile(): Partial<IConfig> {
try { try {
+253
View File
@@ -0,0 +1,253 @@
import { describe, expect, it, vi } from 'vitest';
import {
expandCompactFieldSpec,
findFieldByNameOrId,
findTableByNameOrId,
normalizeFieldRo,
inferFieldType,
normalizeFieldRoArray,
resolveFieldReferences,
resolveFieldType,
} from './field-normalize';
describe('resolveFieldType', () => {
it('resolves documented aliases', () => {
expect(resolveFieldType('sel')).toBe('singleSelect');
expect(resolveFieldType('text')).toBe('singleLineText');
expect(resolveFieldType('multi')).toBe('multipleSelect');
expect(resolveFieldType('num')).toBe('number');
expect(resolveFieldType('attach')).toBe('attachment');
expect(resolveFieldType('condrollup')).toBe('conditionalRollup');
expect(resolveFieldType('SEL')).toBe('singleSelect'); // case-insensitive
});
it('passes full values and unknowns through untouched', () => {
expect(resolveFieldType('singleSelect')).toBe('singleSelect');
expect(resolveFieldType('formula')).toBe('formula');
expect(resolveFieldType('nope-xyz')).toBe('nope-xyz');
});
});
describe('normalizeFieldRo', () => {
it('converts plain-string select choices to {name} objects', () => {
const body = normalizeFieldRo({
name: 'S',
type: 'sel',
options: { choices: ['Todo', 'Done'] },
});
expect(body.type).toBe('singleSelect');
expect((body.options as { choices: unknown[] }).choices).toEqual([{ name: 'Todo' }, { name: 'Done' }]);
});
it('leaves object choices untouched; number options get formatting default', () => {
// non-select: choices are NOT rewritten…
const body = normalizeFieldRo({
name: 'N',
type: 'number',
options: { precision: 2, choices: ['x'] },
});
expect(body.options).toMatchObject({ precision: 2, choices: ['x'] });
// …but required-option defaults ARE backfilled for number
expect((body.options as Record<string, unknown>).formatting).toEqual({
precision: 0,
type: 'decimal',
});
const sel = normalizeFieldRo({
name: 'S',
type: 'singleSelect',
options: { choices: [{ name: 'A', color: 'red' }] },
});
expect((sel.options as { choices: unknown[] }).choices).toEqual([{ name: 'A', color: 'red' }]);
});
});
describe('normalizeFieldRoArray', () => {
it('normalizes each entry, expands compact strings, tolerates invalid entries', () => {
const out = normalizeFieldRoArray([
{ name: 'A', type: 'sel', options: { choices: ['x'] } },
{ name: 'B', type: 'text' },
'Status:sel:Todo,Done',
42,
]) as unknown[];
expect(out[0]).toMatchObject({ type: 'singleSelect' });
expect(out[1]).toMatchObject({ type: 'singleLineText' });
expect(out[2]).toEqual({
name: 'Status',
type: 'singleSelect',
options: { choices: [{ name: 'Todo' }, { name: 'Done' }] },
});
expect(out[3]).toBe(42); // passes through; API will report it
});
it('passes non-arrays through', () => {
expect(normalizeFieldRoArray(undefined)).toBeUndefined();
});
});
describe('findTableByNameOrId / findFieldByNameOrId', () => {
const tables = [
{ id: 'tbl1', name: 'Projects' },
{ id: 'tbl2', name: 'Clients' },
];
const fields = [
{ id: 'fld1', name: 'Title' },
{ id: 'fld2', name: 'Client' },
];
it('matches by id first, then exact name, then case-insensitive', () => {
expect(findTableByNameOrId(tables, 'tbl2')?.id).toBe('tbl2');
expect(findTableByNameOrId(tables, 'Clients')?.id).toBe('tbl2');
expect(findTableByNameOrId(tables, 'clients')?.id).toBe('tbl2');
expect(findTableByNameOrId(tables, 'nope')).toBeUndefined();
expect(findFieldByNameOrId(fields, 'title')?.id).toBe('fld1');
expect(findFieldByNameOrId(fields, 'fld2')?.id).toBe('fld2');
});
});
describe('resolveFieldReferences', () => {
const tables = [
{ id: 'tblF', name: 'Projects' },
{ id: 'tblS', name: 'Staff' },
];
it('resolves foreignTableName to foreignTableId and strips the hint', async () => {
const body = { options: { foreignTableName: 'Projects', relationship: 'manyOne' } };
const errors = await resolveFieldReferences(body, {
listTables: vi.fn().mockResolvedValue(tables),
listFields: vi.fn(),
baseId: 'bse1',
});
expect(errors).toEqual([]);
expect(body.options).toMatchObject({ foreignTableId: 'tblF', relationship: 'manyOne' });
expect(body.options).not.toHaveProperty('foreignTableName');
});
it('reports unknown foreignTableName without touching the body', async () => {
const body = { options: { foreignTableName: 'Ghost' } };
const errors = await resolveFieldReferences(body, {
listTables: vi.fn().mockResolvedValue(tables),
listFields: vi.fn(),
baseId: 'bse1',
});
expect(errors[0]).toContain('Ghost');
expect(body.options).not.toHaveProperty('foreignTableId');
});
it('resolves linkFieldName + lookupFieldName via the link field options', async () => {
const body = {
type: 'singleLineText',
isLookup: true,
lookupOptions: { linkFieldName: 'Client', lookupFieldName: 'Title' },
};
const listFields = vi.fn(async (tableId: string) =>
tableId === 'tbl1'
? [
{ id: 'fldLink', name: 'Client', options: { foreignTableId: 'tblF' } },
]
: [{ id: 'fldT', name: 'Title' }]
);
const errors = await resolveFieldReferences(body, {
listTables: vi.fn().mockResolvedValue(tables),
listFields,
baseId: 'bse1',
tableId: 'tbl1',
});
expect(errors).toEqual([]);
expect(body.lookupOptions).toEqual({
linkFieldId: 'fldLink',
lookupFieldId: 'fldT',
foreignTableId: 'tblF', // backfilled — Ro schema requires it
});
// resolved foreign table fields were actually queried
expect(listFields).toHaveBeenCalledWith('tblF');
});
it('keeps explicitly provided IDs untouched', async () => {
const body = { lookupOptions: { linkFieldId: 'fldL', lookupFieldId: 'fldT', foreignTableId: 'tblF' } };
const listFields = vi.fn();
const errors = await resolveFieldReferences(body, {
listTables: vi.fn(),
listFields,
baseId: 'bse1',
tableId: 'tbl1',
});
expect(errors).toEqual([]);
expect(listFields).not.toHaveBeenCalled();
expect(body.lookupOptions).toEqual({ linkFieldId: 'fldL', lookupFieldId: 'fldT', foreignTableId: 'tblF' });
});
it('errors when linkFieldName cannot be found', async () => {
const body = { lookupOptions: { linkFieldName: 'Ghost', lookupFieldName: 'Title' } };
const errors = await resolveFieldReferences(body, {
listTables: vi.fn().mockResolvedValue(tables),
listFields: vi.fn().mockResolvedValue([{ id: 'fld1', name: 'Title' }]),
baseId: 'bse1',
tableId: 'tbl1',
});
expect(errors.some((e) => e.includes('Ghost'))).toBe(true);
});
});
describe('expandCompactFieldSpec', () => {
it('expands name:type:choices', () => {
expect(expandCompactFieldSpec('Status:sel:Todo,Doing,Done')).toEqual({
name: 'Status',
type: 'singleSelect',
options: { choices: [{ name: 'Todo' }, { name: 'Doing' }, { name: 'Done' }] },
});
expect(expandCompactFieldSpec('Tags:mul:Bug,Feature')).toMatchObject({ type: 'multipleSelect' });
});
it('expands name:type and bare name', () => {
expect(expandCompactFieldSpec('Notes:long')).toEqual({ name: 'Notes', type: 'longText' });
expect(expandCompactFieldSpec('Title')).toEqual({ name: 'Title', type: 'singleLineText' });
});
});
describe('inferFieldType', () => {
it('infers from options shape', () => {
expect(inferFieldType('X', { choices: ['a'] })).toBe('singleSelect');
expect(inferFieldType('X', { max: 5 })).toBe('rating');
expect(inferFieldType('X', { foreignTableName: 'T' })).toBe('link');
expect(inferFieldType('X', { expression: '1+1' })).toBe('formula');
expect(inferFieldType('X', { expression: '1+1', linkFieldId: 'fldL', condition: {} })).toBe('conditionalRollup');
expect(inferFieldType('X', { workflow: { id: 'wfl1' } })).toBe('button');
});
it('infers from field name', () => {
expect(inferFieldType('status', undefined)).toBe('singleSelect');
expect(inferFieldType('Priority', undefined)).toBe('singleSelect');
expect(inferFieldType('tags', undefined)).toBe('multipleSelect');
expect(inferFieldType('description', undefined)).toBe('longText');
expect(inferFieldType('amount', undefined)).toBe('number');
expect(inferFieldType('score', undefined)).toBe('rating');
expect(inferFieldType('isActive', undefined)).toBe('checkbox');
expect(inferFieldType('hasChildren', undefined)).toBe('checkbox');
expect(inferFieldType('assignee', undefined)).toBe('user');
expect(inferFieldType('photo', undefined)).toBe('attachment');
expect(inferFieldType('email', undefined)).toBe('singleLineText');
expect(inferFieldType('created time', undefined)).toBe('createdTime');
expect(inferFieldType('created by', undefined)).toBe('createdBy');
expect(inferFieldType('modified by', undefined)).toBe('lastModifiedBy');
});
it('falls back to singleLineText', () => {
expect(inferFieldType('mystery', undefined)).toBe('singleLineText');
expect(inferFieldType(undefined, undefined)).toBe('singleLineText');
});
});
describe('applyOptionDefaults (via normalizeFieldRo)', () => {
it('backfills rating icon/color/max when options supplied partially', () => {
const body = normalizeFieldRo({ name: 'R', type: 'rate', options: { max: 5 } });
expect(body.options).toEqual({ max: 5, icon: 'star', color: 'yellowBright' });
});
it('backfills number formatting and button label/color', () => {
expect(normalizeFieldRo({ name: 'N', type: 'num', options: {} }).options).toEqual({
formatting: { precision: 0, type: 'decimal' },
});
expect(normalizeFieldRo({ name: 'B', type: 'button', options: {} }).options).toEqual({
label: 'Button',
color: 'teal',
});
});
it('leaves full options and absent options untouched', () => {
expect(
normalizeFieldRo({ name: 'R', type: 'rating', options: { max: 3, icon: 'heart', color: 'redBright' } }).options
).toEqual({ max: 3, icon: 'heart', color: 'redBright' });
expect(normalizeFieldRo({ name: 'R', type: 'rating' }).options).toBeUndefined();
});
});
+326
View File
@@ -0,0 +1,326 @@
/**
* Field-definition normalization for `field create` / `table create --fields`.
*
* The raw API is strict: `type` must be the full enum value and select
* `choices` must be objects (`{name, ...}`) — plain strings 400. These helpers
* accept the friendlier documented forms (aliases like `sel`, string choices,
* `foreignTableName` / `linkFieldName` / `lookupFieldName` to be resolved to
* IDs) so the examples in the skill docs actually work.
*/
const TYPE_ALIASES: Record<string, string> = {
text: 'singleLineText',
str: 'singleLineText',
long: 'longText',
num: 'number',
int: 'number',
check: 'checkbox',
bool: 'checkbox',
sel: 'singleSelect',
select: 'singleSelect',
multi: 'multipleSelect',
tags: 'multipleSelect',
rate: 'rating',
star: 'rating',
user: 'user',
person: 'user',
file: 'attachment',
attach: 'attachment',
mul: 'multipleSelect',
link: 'link',
rel: 'link',
rollup: 'rollup',
condrollup: 'conditionalRollup',
formula: 'formula',
auto: 'autoNumber',
created: 'createdTime',
modified: 'lastModifiedTime',
createdby: 'createdBy',
modifiedby: 'lastModifiedBy',
button: 'button',
};
/** Resolve a type alias (`sel`) to its full value (`singleSelect`). Unknown values pass through untouched. */
export function resolveFieldType(type: string): string {
return TYPE_ALIASES[type.toLowerCase()] ?? type;
}
const SELECT_TYPES = new Set(['singleSelect', 'multipleSelect']);
/** Required-option defaults for types whose Ro schema has none — applied only
* when the user supplied an options object that is missing required keys. */
function applyOptionDefaults(type: string, options: Record<string, unknown>): void {
if (type === 'rating') {
if (options.icon === undefined) options.icon = 'star';
if (options.color === undefined) options.color = 'yellowBright';
if (options.max === undefined) options.max = 5;
} else if (type === 'number') {
if (options.formatting === undefined) {
options.formatting = { precision: 0, type: 'decimal' };
}
} else if (type === 'button') {
if (options.label === undefined) options.label = 'Button';
if (options.color === undefined) options.color = 'teal';
}
}
/**
* Normalize one field body in place:
* - resolve `type` aliases
* - convert select `options.choices` string entries to `{name}` objects
* - backfill required-option defaults for rating/number/button
*/
export function normalizeFieldRo(body: Record<string, unknown>): Record<string, unknown> {
if (typeof body.type === 'string') {
body.type = resolveFieldType(body.type);
}
const type = body.type as string | undefined;
if (
type &&
body.options &&
typeof body.options === 'object' &&
!Array.isArray(body.options)
) {
const options = body.options as Record<string, unknown>;
if (SELECT_TYPES.has(type) && Array.isArray(options.choices)) {
options.choices = options.choices.map((c) =>
typeof c === 'string' ? { name: c } : c
);
}
applyOptionDefaults(type, options);
}
return body;
}
/** Apply normalizeFieldRo to every entry of a `--fields` array (invalid input passes through so the API reports it). */
export function normalizeFieldRoArray(fields: unknown): unknown {
if (!Array.isArray(fields)) return fields;
return fields.map((f) =>
typeof f === 'string'
? expandCompactFieldSpec(f)
: f && typeof f === 'object' && !Array.isArray(f)
? normalizeFieldRo({ ...(f as Record<string, unknown>) })
: f
);
}
/**
* Expand the compact `--fields` string form "Name[:type[:choice,choice,…]]"
* (e.g. "Status:sel:Todo,Done", "Notes:long", "Title") into a field body.
* Bare names become singleLineText; a bare type token is resolved through
* the alias map; a third segment lists select choices (strings).
*/
export function expandCompactFieldSpec(spec: string): Record<string, unknown> {
const parts = spec.split(':').map((p) => p.trim());
const name = parts[0];
const type = parts[1] ? resolveFieldType(parts[1]) : inferFieldType(name, undefined);
const body: Record<string, unknown> = { name, type };
if (parts.length > 2 && parts[2]) {
const choices = parts[2].split(',').map((c) => c.trim()).filter(Boolean);
body.options = { choices: choices.map((c) => ({ name: c })) };
}
return body;
}
const NAME_INFER_RULES: Array<[RegExp, string]> = [
[/^(status|priority|category|type|stage|level)$/i, 'singleSelect'],
[/^(tags?|labels?)$/i, 'multipleSelect'],
[/^(description|notes|comment|content|body)$/i, 'longText'],
[/^(count|amount|price|quantity|total|number|age)$/i, 'number'],
[/^(rating|score|stars?)$/i, 'rating'],
[/^(is.+|has.+|active|enabled|done|completed)$/i, 'checkbox'],
[/^(assignee|owner|user)$/i, 'user'],
[/^(attachment|file|image|photo|document)$/i, 'attachment'],
[/^(email|url)$/i, 'singleLineText'],
[/^(created( time)?|created)$/i, 'createdTime'],
[/^(last modified|updated)$/i, 'lastModifiedTime'],
[/^(created by|author)$/i, 'createdBy'],
[/^(modified by|updated by)$/i, 'lastModifiedBy'],
];
/**
* Infer the field type when `--type` is omitted: first from options shape
* (choices/max/foreignTableName/expression/workflow), then from the field
* name, falling back to singleLineText.
*/
export function inferFieldType(
name: string | undefined,
options: unknown
): string {
if (options && typeof options === 'object' && !Array.isArray(options)) {
const o = options as Record<string, unknown>;
if (Array.isArray(o.choices)) return 'singleSelect';
if (typeof o.max === 'number') return 'rating';
if (typeof o.foreignTableName === 'string' || typeof o.foreignTableId === 'string') {
return 'link';
}
if (typeof o.expression === 'string') {
// conditionalRollup when combined with link + condition; plain formula otherwise
return typeof o.linkFieldId === 'string' && o.condition ? 'conditionalRollup' : 'formula';
}
if (o.workflow && typeof o.workflow === 'object') return 'button';
}
if (name) {
for (const [re, type] of NAME_INFER_RULES) {
if (re.test(name)) return type;
}
}
return 'singleLineText';
}
export interface ITableLike {
id: string;
name: string;
}
/** Find a table by ID or (exact, then case-insensitive) name. Returns undefined when not found. */
export function findTableByNameOrId(
tables: ITableLike[],
nameOrId: string
): ITableLike | undefined {
return (
tables.find((t) => t.id === nameOrId) ??
tables.find((t) => t.name === nameOrId) ??
tables.find((t) => t.name.toLowerCase() === nameOrId.toLowerCase())
);
}
export interface IFieldLike {
id: string;
name: string;
}
/** Find a field by ID or (exact, then case-insensitive) name. Returns undefined when not found. */
export function findFieldByNameOrId(
fields: IFieldLike[],
nameOrId: string
): IFieldLike | undefined {
return (
fields.find((f) => f.id === nameOrId) ??
fields.find((f) => f.name === nameOrId) ??
fields.find((f) => f.name.toLowerCase() === nameOrId.toLowerCase())
);
}
/**
* Resolve name references in link `options` and `lookupOptions` to IDs:
* - options.foreignTableName → options.foreignTableId
* - lookupOptions.linkFieldName → linkFieldId (fields of THIS table)
* - lookupOptions.lookupFieldName → lookupFieldId (fields of the foreign table)
*
* `listTables(baseId)`, `listFields(tableId)` are client calls; only invoked when
* a *Name key is present. Existing IDs are left untouched. Returns error strings
* (not throws) so the caller can decide to fail with a helpful message.
*/
export async function resolveFieldReferences(
body: Record<string, unknown>,
deps: {
listTables: (baseId: string) => Promise<ITableLike[]>;
listFields: (tableId: string) => Promise<IFieldLike[]>;
baseId: string;
tableId?: string;
}
): Promise<string[]> {
const errors: string[] = [];
const options = (body.options && typeof body.options === 'object' && !Array.isArray(body.options)
? body.options
: undefined) as Record<string, unknown> | undefined;
if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) {
const tables = await deps.listTables(deps.baseId);
const t = findTableByNameOrId(tables, options.foreignTableName);
if (!t) {
errors.push(
`foreignTableName "${options.foreignTableName}" not found in base ${deps.baseId}`
);
} else {
options.foreignTableId = t.id;
delete options.foreignTableName; // API rejects unknown keys
}
}
const lookup =
body.lookupOptions &&
typeof body.lookupOptions === 'object' &&
!Array.isArray(body.lookupOptions)
? (body.lookupOptions as Record<string, unknown>)
: undefined;
if (lookup) {
// For lookup fields the foreign table is the target of the LINK field
// referenced by linkFieldId — there is no options.foreignTableId on the
// lookup field itself. Resolve the link field first, then use its options.
let linkForeignTableId: string | undefined;
const linkRef =
typeof lookup.linkFieldId === 'string'
? lookup.linkFieldId
: typeof lookup.linkFieldName === 'string'
? lookup.linkFieldName
: undefined;
const hasNameHints =
typeof lookup.linkFieldName === 'string' || typeof lookup.lookupFieldName === 'string';
const needForeignFromLink =
hasNameHints &&
typeof lookup.foreignTableId !== 'string' &&
!(options && typeof options.foreignTableId === 'string');
if (linkRef && deps.tableId && (hasNameHints || needForeignFromLink)) {
const fields = (await deps.listFields(deps.tableId)) ?? [];
const linkField = findFieldByNameOrId(fields, linkRef);
if (!linkField && typeof lookup.linkFieldId !== 'string') {
errors.push(`linkFieldName "${lookup.linkFieldName}" not found in table ${deps.tableId}`);
}
if (linkField) {
lookup.linkFieldId = linkField.id;
const linkOptions = (linkField as unknown as { options?: Record<string, unknown> })
.options;
const fid = linkOptions && typeof linkOptions.foreignTableId === 'string'
? linkOptions.foreignTableId
: undefined;
if (fid) linkForeignTableId = fid;
}
}
const foreignTableId =
(options && typeof options.foreignTableId === 'string' && options.foreignTableId) ||
(typeof lookup.foreignTableId === 'string' && lookup.foreignTableId) ||
linkForeignTableId ||
(typeof lookup.linkFieldId === 'string' && lookup.linkFieldId); // conditional rollup: foreign table only
// Backfill foreignTableId (Ro schema requires it even for plain lookups,
// where it is derived from the link field) once resolved.
if (linkForeignTableId && typeof lookup.foreignTableId !== 'string') {
lookup.foreignTableId = linkForeignTableId;
}
if (typeof lookup.linkFieldName === 'string' && !lookup.linkFieldId) {
if (!deps.tableId) {
errors.push('linkFieldName resolution requires --table-id (creating a field)');
}
// (resolution already attempted above; if it failed an error was pushed)
}
if (typeof lookup.lookupFieldName === 'string' && !lookup.lookupFieldId) {
const foreign =
(options && typeof options.foreignTableId === 'string' && options.foreignTableId) ||
foreignTableId;
if (!foreign) {
errors.push('lookupFieldName resolution requires foreignTableId (or foreignTableName)');
} else {
const fields = await deps.listFields(foreign);
const f = findFieldByNameOrId(fields, lookup.lookupFieldName);
if (!f) {
errors.push(
`lookupFieldName "${lookup.lookupFieldName}" not found in foreign table ${foreign}`
);
} else {
lookup.lookupFieldId = f.id;
}
}
}
// The API rejects unknown keys — strip the name hints (only when resolved;
// unresolved ones produced errors above and the caller will abort).
if (lookup.linkFieldId) delete lookup.linkFieldName;
if (lookup.lookupFieldId) delete lookup.lookupFieldName;
}
return errors;
}