fix(cli): 轨迹分析驱动的 8 处命令修复 + 技能文档同步 (0.1.5)

- table get/update: 改用真实路由 /api/base/:baseId/table/:tableId(/name)
- view get/update/delete: table 域路由;view update 实现文档承诺的
  --filter/--sort/--group/--column-meta(sort 自动包装 sortObjs)
- record create --fields 自动包装 {records:[{fields}]}
- record delete 单条走单删端点(修复单值 query 解析成 string 的 400),
  支持位置参数;record list 加为 record get 别名
- record get-one / record get --record-id 走 table 域路由
- 移除 sql 命令:后端无 SQL 查询 API(404),技能文档 5 处同步改写
- plugin 技能 dev-workflow 内联 known-good webpack.config
  (禁止 output.library.type=iife,评估轨迹中 with_skill 组也反复踩坑)
This commit is contained in:
yeuimu
2026-09-29 06:45:37 +00:00
parent fbff682d2c
commit 8dacbf7f2d
11 changed files with 198 additions and 124 deletions
+2 -2
View File
@@ -106,7 +106,7 @@ inkreach-teable [全局选项] <命令>
fire webhook <baseId> <workflowId> [--body <json>] 模拟 webhook fire webhook <baseId> <workflowId> [--body <json>] 模拟 webhook
SQL(只读): SQL(只读):
sql <sql> POST /api/base/{baseId}/sql ~~sql <sql>~~ (removed in 0.1.5 — backend has no SQL query API)
``` ```
### 3.3 关键设计决策 ### 3.3 关键设计决策
@@ -170,7 +170,7 @@ YAML frontmatter(name + 极详细的 description 触发条件)+ 以下章节
2. **基础资源命令**:base/table/field/record/view(官方兼容端点,最简单)。 2. **基础资源命令**:base/table/field/record/view(官方兼容端点,最简单)。
3. **workflow 命令**:list/get/create/update/delete/activate/deactivate/test。 3. **workflow 命令**:list/get/create/update/delete/activate/deactivate/test。
4. **触发器/Action 便捷封装**:`trigger set` / `action add/update/delete`(内部 GET-modify-PUT 全图)。 4. **触发器/Action 便捷封装**:`trigger set` / `action add/update/delete`(内部 GET-modify-PUT 全图)。
5. **secrets / runs / fire / sql**。 5. **secrets / runs / fire**(sql 已在 0.1.5 移除:后端无 SQL 查询 API)。
6. **skill 文档**:SKILL.md + guides + api-reference + metadata + install.sh。 6. **skill 文档**:SKILL.md + guides + api-reference + metadata + install.sh。
7. **端到端验证**:真实实例跑通全链路。 7. **端到端验证**:真实实例跑通全链路。
8. **注册**:在 AGENTS.md 添加 skill 说明。 8. **注册**:在 AGENTS.md 添加 skill 说明。
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@inkreach/inkreach-teable", "name": "@inkreach/inkreach-teable",
"version": "0.1.4", "version": "0.1.5",
"description": "CLI (command: inkreach-teable) for operating inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 7 actions).", "description": "CLI (command: inkreach-teable) for operating inkreach-teable: tables, fields, records, views, and the self-built automation engine (8 triggers + 7 actions).",
"license": "MIT", "license": "MIT",
"type": "commonjs", "type": "commonjs",
@@ -128,6 +128,40 @@ pnpm build
# or: npm run build # or: npm run build
``` ```
**Rebuilding without the scaffold — use this exact config** (agents hand-writing
webpack configs keep hitting `Error: Unsupported library type iife` — there is
NO `output.library` in a working plugin config; a default single-entry
production build is already IIFE, which is what the upload validator requires):
```js
// webpack.config.js — known-good (from plugins/batch-edit-pro/)
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
mode: 'production', // default single-entry build emits an IIFE (!function / (()=>))
entry: './src/index.tsx',
output: { path: path.resolve(__dirname, 'dist'), filename: 'main.js', clean: true },
// NO output.library — never add library.type: 'iife' (webpack rejects it)
externals: {
'react': 'window.__TEABLE_HOST__.React',
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
},
resolve: { extensions: ['.tsx', '.ts', '.js', '.jsx'] },
module: {
rules: [
{ test: /\.tsx?$/, loader: 'ts-loader', options: { transpileOnly: true }, exclude: /node_modules/ },
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] },
],
},
plugins: [new MiniCssExtractPlugin({ filename: 'styles.css' })],
};
```
If you build outside the monorepo, drop the `resolve.alias` block (it exists
only so TS can type-check `import type` from `@teable/*` sources) and use
`import type` + `as unknown as` casts instead of runtime imports.
Outputs: `dist/main.js` (IIFE) + `dist/styles.css` (if styles exist). Outputs: `dist/main.js` (IIFE) + `dist/styles.css` (if styles exist).
Verify the output: Verify the output:
+9 -4
View File
@@ -25,14 +25,13 @@ Respond in the user's language. Keep answers concise and action-oriented.
| Module | What it solves | Entry commands | Detail doc | | Module | What it solves | Entry commands | Detail doc |
|--------|---------------|----------------|------------| |--------|---------------|----------------|------------|
| Tables & Fields | Create/modify structure; **inspect select-choice constraints** | `table create`, `field create`, `field list --with-options`, `field get` | [tables-fields.md](guides/tables-fields.md), [field-types.md](api-reference/field-types.md) | | Tables & Fields | Create/modify structure; **inspect select-choice constraints** | `table create`, `field create`, `field list --with-options`, `field get` | [tables-fields.md](guides/tables-fields.md), [field-types.md](api-reference/field-types.md) |
| Records | Read/write row data | `record get`, `record create/update/delete` | [tables-fields.md § Records](guides/tables-fields.md) | | Records | Read/write row data | `record get` (alias `record list`), `record create/update/delete` | [tables-fields.md § Records](guides/tables-fields.md) |
| Views | Filtered/sorted perspectives | `view create/update` | [tables-fields.md § Views](guides/tables-fields.md) | | Views | Filtered/sorted perspectives | `view create/update` | [tables-fields.md § Views](guides/tables-fields.md) |
| View presets | View-scoped shared filter/sort bundles + per-user personal config, applied via toolbar | `preset create/update/list/delete` | [tables-fields.md § View Presets](guides/tables-fields.md) | | View presets | View-scoped shared filter/sort bundles + per-user personal config, applied via toolbar | `preset create/update/list/delete` | [tables-fields.md § View Presets](guides/tables-fields.md) |
| Automation | Event-driven workflows | `workflow *`, `trigger set`, `action add` | [automation-guide.md](guides/automation-guide.md), [trigger-types.md](api-reference/trigger-types.md), [action-types.md](api-reference/action-types.md) | | Automation | Event-driven workflows | `workflow *`, `trigger set`, `action add` | [automation-guide.md](guides/automation-guide.md), [trigger-types.md](api-reference/trigger-types.md), [action-types.md](api-reference/action-types.md) |
| Secrets | API keys for script actions | `secret set/list/delete` | [automation-guide.md § Secrets](guides/automation-guide.md) | | Secrets | API keys for script actions | `secret set/list/delete` | [automation-guide.md § Secrets](guides/automation-guide.md) |
| Execution History | Run results / rerun failures | `runs list/get/rerun` | [automation-guide.md § Testing](guides/automation-guide.md) | | Execution History | Run results / rerun failures | `runs list/get/rerun` | [automation-guide.md § Testing](guides/automation-guide.md) |
| Manual Trigger | Test button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md) | | Manual Trigger | Test button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md) |
| SQL | Read-only analytics, JOINs | `sql '<sql>'` | [cli-reference.md § sql](guides/cli-reference.md) |
| Direct REST API | Integrate external scripts/services; endpoints the CLI lacks; script actions calling back | `curl` / `fetch` + `Authorization: Bearer <PAT>` | [rest-api.md](api-reference/rest-api.md) | | Direct REST API | Integrate external scripts/services; endpoints the CLI lacks; script actions calling back | `curl` / `fetch` + `Authorization: Bearer <PAT>` | [rest-api.md](api-reference/rest-api.md) |
| Plugins | Dynamic plugins, dev whitelist, CSP | `plugin *` | [cli-reference.md § plugin](guides/cli-reference.md) | | Plugins | Dynamic plugins, dev whitelist, CSP | `plugin *` | [cli-reference.md § plugin](guides/cli-reference.md) |
| Subscriptions | Record digest/instant email | `subscription *` | [subscription.md](guides/subscription.md) | | Subscriptions | Record digest/instant email | `subscription *` | [subscription.md](guides/subscription.md) |
@@ -54,8 +53,8 @@ Full command syntax: [cli-reference.md](guides/cli-reference.md). Instance acces
| Send notification | `sendNotification` (in-app) | `sendEmail` (needs SMTP) unless email required | | Send notification | `sendNotification` (in-app) | `sendEmail` (needs SMTP) unless email required |
| **Inspect select-choice constraints** | `field list --with-options`, `field get <tbl> <name>`, `field visible-choices` | `--json` + manual filtering | | **Inspect select-choice constraints** | `field list --with-options`, `field get <tbl> <name>`, `field visible-choices` | `--json` + manual filtering |
| **Write a select-field value** | Verify the value exists in choices first (`field list --with-options`); need a new value → `field update --options` to add the choice, then write | Blind `record update` (400, or typecast silently resolves to null / mutationApplied=false — it never creates choices) | | **Write a select-field value** | Verify the value exists in choices first (`field list --with-options`); need a new value → `field update --options` to add the choice, then write | Blind `record update` (400, or typecast silently resolves to null / mutationApplied=false — it never creates choices) |
| Read records for later writes | `record get` (returns record IDs) | `sql` (no record IDs) | | Read records for later writes | `record get` / `record list` (returns record IDs) | Official-teable SQL APIs (none here) |
| Analytics / JOIN | `sql` with GROUP BY | Fetch all records + compute | | Analytics / aggregation | `record get --filter` + compute, script action, or grouped grid view | `sql` — this deployment has NO SQL query API (CLI 0.1.5 removed the dead command) |
| Store an API key for scripts | `secret set` → `process.env.NAME` | Hardcoding | | Store an API key for scripts | `secret set` → `process.env.NAME` | Hardcoding |
| Workflow didn't run | `runs list --status failed` → `runs get` | Blind re-activate | | Workflow didn't run | `runs list --status failed` → `runs get` | Blind re-activate |
| Call the instance from external code / a route the CLI lacks | Direct REST: `Authorization: Bearer <PAT>` + routes in [rest-api.md](api-reference/rest-api.md); unknown route → run the nearest CLI command with `--debug` and copy its request | Official teable API docs (different automation contract) | | Call the instance from external code / a route the CLI lacks | Direct REST: `Authorization: Bearer <PAT>` + routes in [rest-api.md](api-reference/rest-api.md); unknown route → run the nearest CLI command with `--debug` and copy its request | Official teable API docs (different automation contract) |
@@ -76,12 +75,16 @@ These differ from official teable and from the (stale) `docs/automation/triggers
10. **Button field binding**: `options.workflow` is an **object** `{id, name?}`, not a bare id string. The field's `isActive` snapshot can go stale; the API validates the workflow's DB `isActive` at click time (deactivated → 400). 10. **Button field binding**: `options.workflow` is an **object** `{id, name?}`, not a bare id string. The field's `isActive` snapshot can go stale; the API validates the workflow's DB `isActive` at click time (deactivated → 400).
11. **Script sandbox** has NO `fs`/`eval`/`Function`, but has `triggerInput`, `console`, `fetch`, `process.env` (secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Call back into the instance via `triggerInput._accessToken` + `triggerInput._apiBaseUrl`. **npm deps**: declare with `action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` (or `axios@1.6.0,dayjs`). Requires `npm` on the backend **service** PATH — systemd user units default to a PATH that omits the nvm bin dir, so deps fail with `spawn npm ENOENT`; fix by adding `Environment=PATH=<nvm-bin>:/usr/local/sbin:...` to `teable-backend.service`. 11. **Script sandbox** has NO `fs`/`eval`/`Function`, but has `triggerInput`, `console`, `fetch`, `process.env` (secrets + `AUTOMATION_TOKEN` + `PUBLIC_ORIGIN`), and a controlled `require`. Call back into the instance via `triggerInput._accessToken` + `triggerInput._apiBaseUrl`. **npm deps**: declare with `action add/update --dependencies '[{"name":"axios","version":"1.6.0"}]'` (or `axios@1.6.0,dayjs`). Requires `npm` on the backend **service** PATH — systemd user units default to a PATH that omits the nvm bin dir, so deps fail with `spawn npm ENOENT`; fix by adding `Environment=PATH=<nvm-bin>:/usr/local/sbin:...` to `teable-backend.service`.
12. **Field options updates go through the convert API** — `field update --options` handles this; the plain PATCH update API strips `options`. 12. **Field options updates go through the convert API** — `field update --options` handles this; the plain PATCH update API strips `options`.
13. **No SQL query API exists** — the backend has no SQL endpoint; the CLI `sql` command was removed in 0.1.5. Aggregate via `record get --filter` + a script action, or a grouped grid view. Also: `table get/update` are base-scoped (`--base-id`, defaults to the configured base) and `view get/update/delete` + `record get-one` are table-scoped (`--table-id`).
## 4. Quick Syntax ## 4. Quick Syntax
```bash ```bash
inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> # once inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> # once
inkreach-teable table create --name "Tasks" --fields '[{"name":"Title","type":"text"},{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Done"]}}]' inkreach-teable table create --name "Tasks" --fields '[{"name":"Title","type":"text"},{"name":"Status","type":"singleSelect","options":{"choices":["Todo","Done"]}}]'
inkreach-teable record create --table-id tblXXX --fields '{"Title":"a"}' # 单条;批量用 --records '[{"fields":{...}}]'
inkreach-teable record list --table-id tblXXX [--filter '<json>'] # 查询/分页(= record get)
inkreach-teable record delete --table-id tblXXX recXXX [recYYY ...] # 也可 --record-ids 'recA,recB'
inkreach-teable workflow create --name "On Go click" # → wflXXX inkreach-teable workflow create --name "On Go click" # → wflXXX
inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]' inkreach-teable trigger set wflXXX --type buttonClick --table-id tblXXX --field-ids '["fldXXX"]'
inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields' inkreach-teable action add wflXXX --type script --code 'return triggerInput.record.fields'
@@ -103,4 +106,6 @@ inkreach-teable fire button-click tblXXX recXXX fldXXX && inkreach-teable runs l
| `runs list` shows failed | Action error | `runs get <wfId> <execId>` → `nodeResults[].error` | | `runs list` shows failed | Action error | `runs get <wfId> <execId>` → `nodeResults[].error` |
| Script deps fail `spawn npm ENOENT` | `npm` not on service PATH | See Key Constraint 11 | | Script deps fail `spawn npm ENOENT` | `npm` not on service PATH | See Key Constraint 11 |
| Record write 400 "does not meet the current row conditions" | Value violates choice `visibilityFilter` | `field visible-choices` to see allowed options | | Record write 400 "does not meet the current row conditions" | Value violates choice `visibilityFilter` | `field visible-choices` to see allowed options |
| `sql: unknown command` / 404 on a SQL endpoint | No SQL query API on this deployment | Aggregate via script action or `record get --filter` |
| `record list` works like `record get` | Intentional alias (CLI ≥0.1.5) | — |
| Select-field write "succeeds" but value unchanged (or becomes null) | Value not in choices — without typecast the API 400s; with typecast it resolves to null and no-ops (`mutationApplied=false`), never creates the choice | `field list --with-options` to confirm; `field update --options` to add the choice, then write | | Select-field write "succeeds" but value unchanged (or becomes null) | Value not in choices — without typecast the API 400s; with typecast it resolves to null and no-ops (`mutationApplied=false`), never creates the choice | `field list --with-options` to confirm; `field update --options` to add the choice, then write |
@@ -43,7 +43,7 @@ Verified route-for-route against `src/lib/client.ts` (the CLI's HTTP layer).
List-query params (objects are passed **JSON-stringified**; repeated keys use List-query params (objects are passed **JSON-stringified**; repeated keys use
`key[]`): `filter` (JSON), `projection` (JSON array), `search[]` (keyword — `key[]`): `filter` (JSON), `projection` (JSON array), `search[]` (keyword —
MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take` MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take`
(pagination; no `orderBy` on this fork — sort via view or `sql`). Response: (pagination; no `orderBy` on this fork — sort via a view). Response:
`{ records: [{id, fields}], total? }`. `{ records: [{id, fields}], total? }`.
### Fields ### Fields
@@ -83,7 +83,6 @@ MUST be the `search[]` tuple form, plain `search=` is ignored), `skip` / `take`
| Method & route | Purpose | | Method & route | Purpose |
|---|---| |---|---|
| `POST /api/base/{bid}/sql` | Read-only SQL (`{sql}`; JOIN/GROUP BY; no record ids) |
| `GET /api/table/{tid}/aggregation/row-count` | Total row count (`{"rowCount":N}` — verified) | | `GET /api/table/{tid}/aggregation/row-count` | Total row count (`{"rowCount":N}` — verified) |
| `GET /api/table/{tid}/aggregation…` | Stats family (group-points, calendar-daily, search-count…) — see swagger for params | | `GET /api/table/{tid}/aggregation…` | Stats family (group-points, calendar-daily, search-count…) — see swagger for params |
| `GET`/`POST` `/api/access-token`, `DELETE /api/access-token/{id}` | PAT management | | `GET`/`POST` `/api/access-token`, `DELETE /api/access-token/{id}` | PAT management |
+4 -15
View File
@@ -56,12 +56,13 @@ inkreach-teable field visible-choices <tableId> <recordId> <fieldIdOrName>
inkreach-teable field delete <tableId> <fieldId> inkreach-teable field delete <tableId> <fieldId>
# record # record
inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] [--page-size <n>] [--page <n>] [--search <kw>] inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] [--page-size <n>] [--page <n>] [--search <kw>] [--record-id <rid>]
inkreach-teable record get-one <recordId> inkreach-teable record list --table-id <tid> [...] # alias of record get
inkreach-teable record get-one <recordId> --table-id <tid>
inkreach-teable record create --table-id <tid> --fields '<json>' # single inkreach-teable record create --table-id <tid> --fields '<json>' # single
inkreach-teable record create --table-id <tid> --records '<json>' # batch inkreach-teable record create --table-id <tid> --records '<json>' # batch
inkreach-teable record update --table-id <tid> --records '<json>' inkreach-teable record update --table-id <tid> --records '<json>'
inkreach-teable record delete --table-id <tid> --record-ids <csv> inkreach-teable record delete --table-id <tid> <recordId> [recordId ...] # or --record-ids <csv>
# view # view
inkreach-teable view list <tableId> inkreach-teable view list <tableId>
@@ -181,18 +182,6 @@ inkreach-teable subscription send <subscriptionId>
Record digest/instant email subscriptions. Options (`--filter`, `--send-mode`, Record digest/instant email subscriptions. Options (`--filter`, `--send-mode`,
`--schedule-*`, `--trigger-on-enter`, …) and semantics: [subscription.md](subscription.md). `--schedule-*`, `--trigger-on-enter`, …) and semantics: [subscription.md](subscription.md).
## sql
```bash
inkreach-teable sql '<SELECT ...>' [--base-id <id>]
```
Read-only. Must use `dbTableName`/`dbFieldName` (from `table get`/`field get`), double-quote identifiers, add `LIMIT 100`.
```bash
inkreach-teable sql 'SELECT "name" FROM "bseXXX"."tbl_abc" LIMIT 10'
```
## plugin ## plugin
Manage plugins and developer whitelist. Manage plugins and developer whitelist.
+12 -11
View File
@@ -57,7 +57,7 @@ With select options: `"Status:sel:Todo,Doing,Done"`, `"Tags:mul:Bug,Feature,UI"`
### Update & Delete ### Update & Delete
```bash ```bash
inkreach-teable table update <tableId> --name "<newName>" [--description <d>] inkreach-teable table update <tableId> --name "<newName>" [--base-id <id>]
inkreach-teable table delete <tableId> inkreach-teable table delete <tableId>
``` ```
@@ -70,10 +70,11 @@ inkreach-teable table delete <tableId>
```bash ```bash
inkreach-teable table list [--base-id <id>] inkreach-teable table list [--base-id <id>]
inkreach-teable table get <tableId> inkreach-teable table get <tableId> [--base-id <id>]
``` ```
`table get` returns `dbTableName` — the internal SQL table name used in `sql` queries (e.g., `SELECT * FROM "bseXXX"."tbl_abc"`). `table get` returns `dbTableName` — the internal PG table name (useful for
maintainer-level psql inspection; there is NO SQL query API on this deployment).
--- ---
@@ -162,8 +163,8 @@ Single/multiple-select choices may carry a `visibilityFilter` making them visibl
inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] \ inkreach-teable record get --table-id <tid> [--filter '<json>'] [--projection '<json>'] \
[--page-size <n>] [--page <n>] [--search <kw>] [--page-size <n>] [--page <n>] [--search <kw>]
# Single record by ID # Single record by ID (table-scoped route)
inkreach-teable record get-one <recordId> inkreach-teable record get-one <recordId> --table-id <tid>
``` ```
| Flag | Description | | Flag | Description |
@@ -271,7 +272,7 @@ inkreach-teable record update --table-id tblXXX \
### Delete ### Delete
```bash ```bash
inkreach-teable record delete --table-id <tid> --record-ids "recA,recB,recC" inkreach-teable record delete --table-id <tid> recA recB recC # or --record-ids "recA,recB,recC"
``` ```
Max 1000 IDs per call. Irreversible. Max 1000 IDs per call. Irreversible.
@@ -284,7 +285,7 @@ Max 1000 IDs per call. Irreversible.
```bash ```bash
inkreach-teable view list <tableId> inkreach-teable view list <tableId>
inkreach-teable view get <viewId> inkreach-teable view get <viewId> --table-id <tid>
``` ```
### Create ### Create
@@ -304,8 +305,8 @@ inkreach-teable view create --table-id <tid> --name "<name>" [--type <t>]
### Update (configure filters, sorts, grouping) ### Update (configure filters, sorts, grouping)
```bash ```bash
inkreach-teable view update <viewId> \ inkreach-teable view update <viewId> --table-id <tid> \
[--name "<n>"] \ [--name "<n>"] [--description "<d>"] \
[--filter '<json>'] \ [--filter '<json>'] \
[--sort '<json>'] \ [--sort '<json>'] \
[--group '<json>'] \ [--group '<json>'] \
@@ -314,7 +315,7 @@ inkreach-teable view update <viewId> \
**Filters**: `{"conjunction":"and","filterSet":[{"fieldId":"fldXXX","operator":"is","value":"Done"}]}` **Filters**: `{"conjunction":"and","filterSet":[{"fieldId":"fldXXX","operator":"is","value":"Done"}]}`
**Sorts**: `[{"fieldId":"fldXXX","order":"asc"}]` **Sorts**: `[{"fieldId":"fldXXX","order":"asc"}]` (the CLI wraps it into `{sortObjs:[...]}` for the API)
**Group**: `[{"fieldId":"fldXXX","order":"asc"}]` **Group**: `[{"fieldId":"fldXXX","order":"asc"}]`
@@ -323,7 +324,7 @@ inkreach-teable view update <viewId> \
### Delete ### Delete
```bash ```bash
inkreach-teable view delete <viewId> inkreach-teable view delete <viewId> --table-id <tid>
``` ```
Deleting a view does NOT delete the table or its data — only the view configuration. Deleting a view does NOT delete the table or its data — only the view configuration.
+1 -2
View File
@@ -10,7 +10,7 @@ import { pluginCommand } from './commands/plugin';
import { authCommand } from './commands/auth'; import { authCommand } from './commands/auth';
import { workflowCommand, triggerCommand, actionCommand } from './commands/workflow'; import { workflowCommand, triggerCommand, actionCommand } from './commands/workflow';
import { baseResourceCommands } from './commands/resources'; import { baseResourceCommands } from './commands/resources';
import { secretCommand, runsCommand, fireCommand, sqlCommand } from './commands/extras'; import { secretCommand, runsCommand, fireCommand } from './commands/extras';
import { subscriptionCommand } from './commands/subscription'; import { subscriptionCommand } from './commands/subscription';
const program = new Command(); const program = new Command();
@@ -42,7 +42,6 @@ program.addCommand(subscriptionCommand());
program.addCommand(secretCommand()); program.addCommand(secretCommand());
program.addCommand(runsCommand()); program.addCommand(runsCommand());
program.addCommand(fireCommand()); program.addCommand(fireCommand());
program.addCommand(sqlCommand());
program.parseAsync(process.argv).catch((err) => { program.parseAsync(process.argv).catch((err) => {
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`); process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
-47
View File
@@ -2,7 +2,6 @@
* `inkreach-teable secret` — manage workflow automation secrets. * `inkreach-teable secret` — manage workflow automation secrets.
* `inkreach-teable runs` — execution history. * `inkreach-teable runs` — execution history.
* `inkreach-teable fire` — manual trigger testing (button-click / webhook). * `inkreach-teable fire` — manual trigger testing (button-click / webhook).
* `inkreach-teable sql` — read-only SQL queries.
*/ */
import { Command } from 'commander'; import { Command } from 'commander';
import { run, requireBaseId } from '../lib/ctx'; import { run, requireBaseId } from '../lib/ctx';
@@ -282,49 +281,3 @@ export function fireCommand(): Command {
return cmd; return cmd;
} }
// ===================== sql =====================
export function sqlCommand(): Command {
const cmd = new Command('sql');
cmd.description('Execute a read-only SQL query (SELECT only)');
cmd.option('--base-id <id>', 'Base id (overrides default)');
cmd.argument('<sql>', 'SQL SELECT statement');
cmd.action(
run(async (ctx, args, o) => {
const sql = args[0] as string;
const opts = o as { baseId?: string };
const baseId = requireBaseId(ctx, opts.baseId);
const rows = (await ctx.client.sql(baseId, sql)) as unknown[];
if (ctx.json) {
printJson(rows);
} else {
if (!rows.length) {
process.stdout.write('No rows returned.\n');
return;
}
const first = rows[0];
if (typeof first === 'object' && first !== null && !Array.isArray(first)) {
const keys = Object.keys(first as Record<string, unknown>);
printTable(
keys.map((k) => ({ key: k, header: k })),
rows.map((r) => {
const obj = r as Record<string, unknown>;
const row: Record<string, unknown> = {};
for (const k of keys) {
row[k] = truncate(obj[k], 40);
}
return row;
})
);
process.stdout.write(`\n${rows.length} row(s).\n`);
} else {
rows.forEach((r) => process.stdout.write(JSON.stringify(r) + '\n'));
}
}
})
);
return cmd;
}
+79 -22
View File
@@ -140,10 +140,12 @@ function tableCmd(): Command {
cmd cmd
.command('get <tableId>') .command('get <tableId>')
.description('Get table details') .description('Get table details')
.option('--base-id <id>', 'Base id (overrides default)')
.action( .action(
run(async (ctx, args) => { run(async (ctx, args, o) => {
const tableId = args[0] as string; const tableId = args[0] as string;
const table = await ctx.client.getTable(tableId); const opts = o as { baseId?: string };
const table = await ctx.client.getTable(tableId, requireBaseId(ctx, opts.baseId));
if (ctx.json) printJson(table); if (ctx.json) printJson(table);
else { else {
const t = table as Record<string, unknown>; const t = table as Record<string, unknown>;
@@ -469,8 +471,10 @@ function recordCmd(): Command {
cmd cmd
.command('get') .command('get')
.description('Query records with filters, pagination, and search') .description("Query records with filters, pagination, and search (alias: 'list')")
.alias('list')
.requiredOption('--table-id <id>', 'Table id') .requiredOption('--table-id <id>', 'Table id')
.option('--record-id <id>', 'Get a single record by id (alternative to get-one)')
.option('--filter <json>', 'Filter conditions JSON') .option('--filter <json>', 'Filter conditions JSON')
.option('--projection <json>', 'Field projection JSON array') .option('--projection <json>', 'Field projection JSON array')
.option('--page-size <n>', 'Records per page') .option('--page-size <n>', 'Records per page')
@@ -487,7 +491,17 @@ function recordCmd(): Command {
page?: string; page?: string;
search?: string; search?: string;
baseId?: string; baseId?: string;
recordId?: string;
}; };
if (opts.recordId) {
const rec = await ctx.client.getRecord(opts.tableId, opts.recordId);
if (ctx.json) printJson(rec);
else {
const r = rec as { id?: string; fields?: Record<string, unknown> };
printKv([['id', r.id], ...Object.entries(r.fields || {})]);
}
return;
}
// validate baseId is present (even though getRecords only needs tableId, // validate baseId is present (even though getRecords only needs tableId,
// we follow the convention that base-scoped commands require baseId config) // we follow the convention that base-scoped commands require baseId config)
requireBaseId(ctx, opts.baseId); requireBaseId(ctx, opts.baseId);
@@ -543,10 +557,12 @@ function recordCmd(): Command {
cmd cmd
.command('get-one <recordId>') .command('get-one <recordId>')
.description('Get a single record by id') .description('Get a single record by id')
.requiredOption('--table-id <id>', 'Table id (record routes are table-scoped)')
.action( .action(
run(async (ctx, args) => { run(async (ctx, args, o) => {
const recordId = args[0] as string; const recordId = args[0] as string;
const record = await ctx.client.getRecord(recordId); const opts = o as { tableId: string };
const record = await ctx.client.getRecord(opts.tableId, recordId);
if (ctx.json) printJson(record); if (ctx.json) printJson(record);
else { else {
const rec = record as { id?: string; fields?: Record<string, unknown> }; const rec = record as { id?: string; fields?: Record<string, unknown> };
@@ -572,7 +588,9 @@ function recordCmd(): Command {
if (opts.records) { if (opts.records) {
body = { records: JSON.parse(opts.records) }; body = { records: JSON.parse(opts.records) };
} else if (opts.fields) { } else if (opts.fields) {
body = { fields: JSON.parse(opts.fields) }; // The create API only accepts {records:[...]}; wrap the single-record
// convenience form so both spellings work.
body = { records: [{ fields: JSON.parse(opts.fields) }] };
} else { } else {
throw Object.assign(new Error('Provide --fields or --records'), { throw Object.assign(new Error('Provide --fields or --records'), {
code: 'MISSING_FIELDS', code: 'MISSING_FIELDS',
@@ -603,17 +621,33 @@ function recordCmd(): Command {
); );
cmd cmd
.command('delete') .command('delete [recordIds...]')
.description('Batch delete records by ids') .description('Batch delete records by ids')
.requiredOption('--table-id <id>', 'Table id') .requiredOption('--table-id <id>', 'Table id')
.requiredOption('--record-ids <csv>', 'Record ids (comma-separated)') .option('--record-ids <csv>', 'Record ids (comma-separated; or pass ids as arguments)')
.action( .action(
run(async (ctx, _args, o) => { run(async (ctx, args, o) => {
const opts = o as { tableId: string; recordIds: string }; const opts = o as { tableId: string; recordIds?: string };
const ids = opts.recordIds // With variadic args commander appends the options bag to positionals —
.split(',') // flatten and keep only actual id strings.
.map((s) => s.trim()) const ids = [
.filter(Boolean); ...new Set(
[
...(opts.recordIds
? opts.recordIds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: []),
...(args as unknown[]).flat().filter((x): x is string => typeof x === 'string'),
].flat()
),
];
if (!ids.length) {
throw Object.assign(new Error('Provide record ids (--record-ids or arguments)'), {
code: 'MISSING_RECORD_IDS',
});
}
const result = await ctx.client.deleteRecords(opts.tableId, ids); const result = await ctx.client.deleteRecords(opts.tableId, ids);
if (ctx.json) printJson(result); if (ctx.json) printJson(result);
else process.stdout.write(`✓ ${ids.length} record(s) deleted\n`); else process.stdout.write(`✓ ${ids.length} record(s) deleted\n`);
@@ -661,10 +695,12 @@ function viewCmd(): Command {
cmd cmd
.command('get <viewId>') .command('get <viewId>')
.description('Get view details') .description('Get view details')
.requiredOption('--table-id <id>', 'Table id (view routes are table-scoped)')
.action( .action(
run(async (ctx, args) => { run(async (ctx, args, o) => {
const viewId = args[0] as string; const viewId = args[0] as string;
const view = await ctx.client.getView(viewId); const opts = o as { tableId: string };
const view = await ctx.client.getView(opts.tableId, viewId);
if (ctx.json) printJson(view); if (ctx.json) printJson(view);
else { else {
const v = view as Record<string, unknown>; const v = view as Record<string, unknown>;
@@ -698,15 +734,34 @@ function viewCmd(): Command {
cmd cmd
.command('update <viewId>') .command('update <viewId>')
.description('Update a view (rename)') .description('Update a view (name, description, filter, sort, group, column-meta)')
.requiredOption('--table-id <id>', 'Table id (view routes are table-scoped)')
.option('--name <name>', 'New view name') .option('--name <name>', 'New view name')
.option('--description <d>', 'New description')
.option('--filter <json>', 'Filter JSON, e.g. \'{"conjunction":"and","filterSet":[...]}\'')
.option('--sort <json>', 'Sort JSON, e.g. \'[{"fieldId":"fldX","order":"asc"}]\'')
.option('--group <json>', 'Group JSON, e.g. \'[{"fieldId":"fldX","order":"asc"}]\'')
.option('--column-meta <json>', 'Column meta JSON array of {fieldId, columnMeta}')
.action( .action(
run(async (ctx, args, o) => { run(async (ctx, args, o) => {
const viewId = args[0] as string; const viewId = args[0] as string;
const opts = o as { name?: string }; const opts = o as {
const body: Record<string, unknown> = {}; tableId: string;
name?: string;
description?: string;
filter?: string;
sort?: string;
group?: string;
columnMeta?: string;
};
const body: Parameters<typeof ctx.client.updateView>[2] = {};
if (opts.name) body.name = opts.name; if (opts.name) body.name = opts.name;
const view = await ctx.client.updateView(viewId, body); if (opts.description) body.description = opts.description;
if (opts.filter) body.filter = JSON.parse(opts.filter);
if (opts.sort) body.sort = JSON.parse(opts.sort);
if (opts.group) body.group = JSON.parse(opts.group);
if (opts.columnMeta) body.columnMeta = JSON.parse(opts.columnMeta);
const view = await ctx.client.updateView(opts.tableId, viewId, body);
if (ctx.json) printJson(view); if (ctx.json) printJson(view);
else process.stdout.write(`✓ View ${viewId} updated\n`); else process.stdout.write(`✓ View ${viewId} updated\n`);
}) })
@@ -715,10 +770,12 @@ function viewCmd(): Command {
cmd cmd
.command('delete <viewId>') .command('delete <viewId>')
.description('Delete a view') .description('Delete a view')
.requiredOption('--table-id <id>', 'Table id (view routes are table-scoped)')
.action( .action(
run(async (ctx, args) => { run(async (ctx, args, o) => {
const viewId = args[0] as string; const viewId = args[0] as string;
await ctx.client.deleteView(viewId); const opts = o as { tableId: string };
await ctx.client.deleteView(opts.tableId, viewId);
if (ctx.json) printJson({ ok: true, id: viewId }); if (ctx.json) printJson({ ok: true, id: viewId });
else process.stdout.write(`✓ View ${viewId} deleted\n`); else process.stdout.write(`✓ View ${viewId} deleted\n`);
}) })
+55 -18
View File
@@ -59,14 +59,19 @@ export class Client {
listTables(baseId: string) { listTables(baseId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/table`); return this.call<unknown[]>('GET', `/api/base/${baseId}/table`);
} }
getTable(tableId: string) { getTable(tableId: string, baseId?: string) {
return this.call<unknown>('GET', `/api/table/${tableId}`); // Route is base-nested: GET /api/base/:baseId/table/:tableId (a bare
// /api/table/:tableId does not exist and 404s).
const base = baseId ?? this.cfg.baseId;
return this.call<unknown>('GET', `/api/base/${base}/table/${tableId}`);
} }
createTable(baseId: string, body: unknown) { createTable(baseId: string, body: unknown) {
return this.call<unknown>('POST', `/api/base/${baseId}/table`, body); return this.call<unknown>('POST', `/api/base/${baseId}/table`, body);
} }
updateTable(tableId: string, body: unknown) { updateTable(tableId: string, body: { name: string }, baseId?: string) {
return this.call<unknown>('PUT', `/api/table/${tableId}`, body); // Rename goes through the /name sub-resource (a bare PUT .../table/:id 404s).
const base = baseId ?? this.cfg.baseId;
return this.call<unknown>('PUT', `/api/base/${base}/table/${tableId}/name`, body);
} }
deleteTable(tableId: string, baseId?: string) { deleteTable(tableId: string, baseId?: string) {
const base = baseId ?? this.cfg.baseId; const base = baseId ?? this.cfg.baseId;
@@ -131,9 +136,10 @@ export class Client {
} }
return out; return out;
} }
getRecord(recordId: string, query?: Record<string, unknown>) { getRecord(tableId: string, recordId: string, query?: Record<string, unknown>) {
// Route is table-scoped: GET /api/table/:tableId/record/:recordId
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : ''; const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('GET', `/api/record/${recordId}${qs}`); return this.call<unknown>('GET', `/api/table/${tableId}/record/${recordId}${qs}`);
} }
createRecord(tableId: string, body: unknown) { createRecord(tableId: string, body: unknown) {
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body); return this.call<unknown>('POST', `/api/table/${tableId}/record`, body);
@@ -141,10 +147,15 @@ export class Client {
updateRecord(tableId: string, body: unknown) { updateRecord(tableId: string, body: unknown) {
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body); return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body);
} }
updateRecordOne(recordId: string, body: unknown) { updateRecordOne(tableId: string, recordId: string, body: unknown) {
return this.call<unknown>('PATCH', `/api/record/${recordId}`, body); return this.call<unknown>('PATCH', `/api/table/${tableId}/record/${recordId}`, body);
} }
deleteRecords(tableId: string, recordIds: string[]) { deleteRecords(tableId: string, recordIds: string[]) {
if (recordIds.length === 1) {
// Bulk query ?recordIds=x parses as a string and 400s — single deletes
// use the per-record route.
return this.call<unknown>('DELETE', `/api/table/${tableId}/record/${recordIds[0]}`);
}
const qs = recordIds.map((id) => `recordIds=${encodeURIComponent(id)}`).join('&'); const qs = recordIds.map((id) => `recordIds=${encodeURIComponent(id)}`).join('&');
return this.call<unknown>('DELETE', `/api/table/${tableId}/record?${qs}`); return this.call<unknown>('DELETE', `/api/table/${tableId}/record?${qs}`);
} }
@@ -153,17 +164,47 @@ export class Client {
listViews(tableId: string) { listViews(tableId: string) {
return this.call<unknown[]>('GET', `/api/table/${tableId}/view`); return this.call<unknown[]>('GET', `/api/table/${tableId}/view`);
} }
getView(viewId: string) { getView(tableId: string, viewId: string) {
return this.call<unknown>('GET', `/api/view/${viewId}`); // View routes are table-scoped: /api/view/:viewId alone does not exist.
return this.call<unknown>('GET', `/api/table/${tableId}/view/${viewId}`);
} }
createView(tableId: string, body: unknown) { createView(tableId: string, body: unknown) {
return this.call<unknown>('POST', `/api/table/${tableId}/view`, body); return this.call<unknown>('POST', `/api/table/${tableId}/view`, body);
} }
updateView(viewId: string, body: unknown) { /** Update view parts — each key goes to its own sub-resource route. */
return this.call<unknown>('PATCH', `/api/view/${viewId}`, body); async updateView(
tableId: string,
viewId: string,
parts: {
name?: string;
description?: string;
filter?: unknown;
sort?: unknown;
group?: unknown;
columnMeta?: unknown;
options?: unknown;
}
): Promise<unknown> {
const base = `/api/table/${tableId}/view/${viewId}`;
let last: unknown;
if (parts.name !== undefined) last = await this.call('PUT', `${base}/name`, { name: parts.name });
if (parts.description !== undefined)
last = await this.call('PUT', `${base}/description`, { description: parts.description });
if (parts.filter !== undefined) last = await this.call('PUT', `${base}/filter`, { filter: parts.filter });
if (parts.sort !== undefined) {
// The API wants {sortObjs:[...]}; accept the natural bare-array form too.
const sort = Array.isArray(parts.sort) ? { sortObjs: parts.sort } : parts.sort;
last = await this.call('PUT', `${base}/sort`, { sort });
}
if (parts.group !== undefined) last = await this.call('PUT', `${base}/group`, { group: parts.group });
if (parts.columnMeta !== undefined)
last = await this.call('PUT', `${base}/column-meta`, parts.columnMeta);
if (parts.options !== undefined)
last = await this.call('PATCH', `${base}/options`, { options: parts.options });
return last;
} }
deleteView(viewId: string) { deleteView(tableId: string, viewId: string) {
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`); return this.call<{ id: string }>('DELETE', `/api/table/${tableId}/view/${viewId}`);
} }
// ---- view preset (self-built contract) ---- // ---- view preset (self-built contract) ----
@@ -297,10 +338,6 @@ export class Client {
} }
// ---- SQL (read-only) ---- // ---- SQL (read-only) ----
sql(baseId: string, sql: string) {
return this.call<unknown[]>('POST', `/api/base/${baseId}/sql`, { sql });
}
// ---- plugin ---- // ---- plugin ----
getPlugins() { getPlugins() {
return this.call<unknown[]>('GET', '/api/plugin'); return this.call<unknown[]>('GET', '/api/plugin');