Compare commits

...
2 Commits
17 changed files with 544 additions and 148 deletions
+28 -1
View File
@@ -1,6 +1,27 @@
# inkreach-teable-cli # inkreach-teable-cli
把下面这段引导词发给任意机器 / 任意会话中的 Agent,即可完成安装并开始使用: inkreach-teable 实例的运维 CLI(npm 包 `@inkreach/inkreach-teable`)与
**全部 Agent Skills 的唯一维护仓库**。
## Skills(本仓库 `skills/` 目录 = single source of truth)
| Skill | 用途 |
|---|---|
| `inkreach-teable` | 操作已部署实例:表格/字段/记录/视图/自动化日常运维、REST API 参考 |
| `inkreach-teable-plugin` | 开发动态 React 组件插件:IIFE bundle(webpack/vite 双模板)、多资产上传(chunk + sourcemap)、`teable-plugin dev` 热更新闭环、5 个挂载位(含整页 route) |
**维护规则**:所有 skill 只在本仓库修改;主仓库(inkreach-teable)不含任何
skill 源文件。改完后同步到安装位置(`~/.zcode/skills/` 为主,
`~/.agents/skills/` 为镜像):
```bash
cp -r skills/<name>/SKILL.md skills/<name>/metadata.json skills/<name>/dynamic-plugins ~/.zcode/skills/<name>/
cp -r skills/<name>/ ~/.agents/skills/<name>/ # 按需镜像
```
## 安装(任意机器 / 任意会话的 Agent)
把下面这段引导词发给 Agent 即可完成安装并开始使用:
```text ```text
请安装并用 inkreach-teable CLI 操作 inkreach-teable: 请安装并用 inkreach-teable CLI 操作 inkreach-teable:
@@ -11,3 +32,9 @@
3. 鉴权(PAT 从 Web UI Settings → Personal Access Tokens 获取): 3. 鉴权(PAT 从 Web UI Settings → Personal Access Tokens 获取):
inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> --base-id bseXXX inkreach-teable auth login --endpoint https://table.inkreach.cc --token <PAT> --base-id bseXXX
``` ```
## 目录结构
- `skills/` — Agent Skills(本仓库的核心资产之一)
- `src/` — CLI 源码(commander 实现)
- `docs/cli-skill-design.md` — CLI + Skill 的设计文档
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@inkreach/inkreach-teable", "name": "@inkreach/inkreach-teable",
"version": "0.1.9", "version": "0.2.0",
"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",
+32
View File
@@ -82,7 +82,32 @@ smoke "field update --options" $CLI field update "$TBL" Status --options '{"
smoke "field delete" $CLI field delete "$TBL" Extra smoke "field delete" $CLI field delete "$TBL" Extra
# ---------- record ---------- # ---------- record ----------
smoke "field create (attachment)" $CLI field create --table-id "$TBL" --name Files --type attachment
smoke "record create --fields" $CLI record create --table-id "$TBL" --fields '{"Title":"r1","Status":"todo","Num":1}' smoke "record create --fields" $CLI record create --table-id "$TBL" --fields '{"Title":"r1","Status":"todo","Num":1}'
# Attachment fields accept http(s) urls: string form needs --typecast, object
# form works without. Downloads a real file from the local gitea; a network
# failure degrades to a warning (feature check, not a route-shape check).
ATT_URL='https://gitea.inkreach.cc/inkreachhub/inkreach-teable-cli/raw/branch/main/README.md'
ATT_OUT=$($CLI record create --table-id "$TBL" --typecast --fields "{\"Title\":\"att1\",\"Files\":\"$ATT_URL\"}" --json 2>&1)
if echo "$ATT_OUT" | grep -qE "$FAILPAT"; then
FAIL=$((FAIL+1)); FAILED_CASES+=("record create attachment-url (typecast)")
echo "FAIL record create attachment-url (typecast)"
echo "$ATT_OUT" | grep -E "$FAILPAT" | head -2 | sed 's/^/ /'
elif echo "$ATT_OUT" | grep -q '"token"'; then
PASS=$((PASS+1)); [ "${SMOKE_VERBOSE:-0}" = 1 ] && echo "pass record create attachment-url (typecast)"
else
echo "warn record create attachment-url (typecast): no attachment token in response (download failed?)"
fi
ATT_OUT2=$($CLI record create --table-id "$TBL" --fields "{\"Title\":\"att2\",\"Files\":[{\"url\":\"$ATT_URL\",\"name\":\"readme.md\"}]}" --json 2>&1)
if echo "$ATT_OUT2" | grep -qE "$FAILPAT"; then
FAIL=$((FAIL+1)); FAILED_CASES+=("record create attachment-url (object)")
echo "FAIL record create attachment-url (object)"
echo "$ATT_OUT2" | grep -E "$FAILPAT" | head -2 | sed 's/^/ /'
elif echo "$ATT_OUT2" | grep -q '"token"'; then
PASS=$((PASS+1)); [ "${SMOKE_VERBOSE:-0}" = 1 ] && echo "pass record create attachment-url (object)"
else
echo "warn record create attachment-url (object): no attachment token in response (download failed?)"
fi
smoke "record create --records" $CLI record create --table-id "$TBL" --records '[{"fields":{"Title":"r2"}},{"fields":{"Title":"r3"}}]' smoke "record create --records" $CLI record create --table-id "$TBL" --records '[{"fields":{"Title":"r2"}},{"fields":{"Title":"r3"}}]'
REC=$($CLI record list --table-id "$TBL" --json | j "d['records'][0]['id']") REC=$($CLI record list --table-id "$TBL" --json | j "d['records'][0]['id']")
STATUS_FLD=$($CLI field list "$TBL" --json | j "[f['id'] for f in d if f['name']=='Status'][0]") STATUS_FLD=$($CLI field list "$TBL" --json | j "[f['id'] for f in d if f['name']=='Status'][0]")
@@ -96,6 +121,13 @@ smoke "record get --projection" $CLI record get --table-id "$TBL" --projectio
smoke "record get-one" $CLI record get-one "$REC" --table-id "$TBL" smoke "record get-one" $CLI record get-one "$REC" --table-id "$TBL"
smoke "record get --record-id" $CLI record get --record-id "$REC" --table-id "$TBL" smoke "record get --record-id" $CLI record get --record-id "$REC" --table-id "$TBL"
smoke "record update" $CLI record update --table-id "$TBL" --records "[{\"id\":\"$REC\",\"fields\":{\"Num\":42}}]" smoke "record update" $CLI record update --table-id "$TBL" --records "[{\"id\":\"$REC\",\"fields\":{\"Num\":42}}]"
# --field-key-type / --order are body keys of create/updateRecordsRoSchema
VIEW=$($CLI view list "$TBL" --json | j "d[0]['id']")
TITLE_FLD=$($CLI field list "$TBL" --json | j "[f['id'] for f in d if f['name']=='Title'][0]")
REC2=$($CLI record list --table-id "$TBL" --json | j "d['records'][1]['id']")
smoke "record create --field-key-type id" $CLI record create --table-id "$TBL" --field-key-type id --fields "{\"$TITLE_FLD\":\"by-fld-id\"}"
smoke "record create --order" $CLI record create --table-id "$TBL" --order "{\"viewId\":\"$VIEW\",\"anchorId\":\"$REC2\",\"position\":\"before\"}" --fields '{"Title":"ordered-create"}'
smoke "record update --order" $CLI record update --table-id "$TBL" --order "{\"viewId\":\"$VIEW\",\"anchorId\":\"$REC2\",\"position\":\"after\"}" --records "[{\"id\":\"$REC\",\"fields\":{\"Num\":7}}]"
smoke "record visible-choices" $CLI field visible-choices "$TBL" "$REC" Status smoke "record visible-choices" $CLI field visible-choices "$TBL" "$REC" Status
smoke "record delete (positional)" $CLI record delete "$REC" --table-id "$TBL" smoke "record delete (positional)" $CLI record delete "$REC" --table-id "$TBL"
smoke "record delete (--csv)" $CLI record delete --record-ids "$($CLI record list --table-id "$TBL" --json | j "','.join(r['id'] for r in d['records'])")" --table-id "$TBL" smoke "record delete (--csv)" $CLI record delete --record-ids "$($CLI record list --table-id "$TBL" --json | j "','.join(r['id'] for r in d['records'])")" --table-id "$TBL"
+26 -12
View File
@@ -23,18 +23,31 @@ rebuilding the host Next.js app. Plugins are IIFE bundles — they ship as plain
JS that executes in the host page and accesses host APIs through JS that executes in the host page and accesses host APIs through
`window.__TEABLE_HOST__`. `window.__TEABLE_HOST__`.
> **Contract**: A plugin = a webpack IIFE bundle (`dist/main.js` + optional > **Contract**: A plugin = a webpack (or vite) IIFE bundle (`dist/main.js` +
> `dist/styles.css`) + `plugin.json` manifest, uploaded to the host via > optional lazy chunks `*.chunk.js`, source maps `*.js.map`, `styles.css`) +
> `POST /api/plugin/:id/upload-bundle`. It runs in the same React tree as the host > a `plugin.json` manifest, uploaded to the host via
> and has full access to the host's SDK, UI library, and OpenAPI helpers. > `POST /api/plugin/:id/upload-bundle` (or `teable-plugin deploy`, which
> handles all assets). It runs in the same React tree as the host and has
> full access to the host's SDK, UI library, and OpenAPI helpers.
>
> **Dev loop**: `teable-plugin dev` (or `pnpm dev` in scaffolded projects)
> watch-builds and auto-uploads with a `devMode` manifest; the open host page
> remounts the plugin within ~4s without a page refresh. See
> `dynamic-plugins/dev-workflow.md`.
>
> **Full-page apps**: install at position `route` and the host serves the
> plugin on `/base/<baseId>/p/<pluginId>` (whole viewport, own router OK —
> HashRouter recommended).
> >
> **Critical constraint**: no RUNTIME imports from `@teable/sdk`, `@teable/ui-lib`, > **Critical constraint**: no RUNTIME imports from `@teable/sdk`, `@teable/ui-lib`,
> `@teable/openapi` — webpack maps them to `window.__TEABLE_HOST__.*` externals, so a > `@teable/openapi` — a value import via monorepo aliases inlines the whole
> value import compiles to `undefined` at runtime. `import type { X } from '@teable/...'` > package source (via window-global externals it compiles to `undefined`).
> IS fine (erased at compile time). `react`/`react-dom` may be imported ONLY when your > `import type { X } from '@teable/...'` IS fine (erased at compile time).
> webpack `externals` maps them to `window.__TEABLE_HOST__.React/ReactDOM` (see the > `react`/`react-dom` may be imported when your webpack `externals` maps them
> batch-edit-pro webpack config). For everything else, read from the host global: > to `window.__TEABLE_HOST__.React/ReactDOM` (see the plugin-showcase webpack
> `const { useFields } = window.__TEABLE_HOST__.SDK`. See > config; `react/jsx-runtime` is intentionally bundled — element factories
> only, hooks stay on the host singleton). For everything else, read from the
> host global: `const { useFields } = window.__TEABLE_HOST__.SDK`. See
> `dynamic-plugins/troubleshooting.md` Pitfall 1 for details and symptoms. > `dynamic-plugins/troubleshooting.md` Pitfall 1 for details and symptoms.
## 0. Two efficiency rules (read first) ## 0. Two efficiency rules (read first)
@@ -71,8 +84,9 @@ JS that executes in the host page and accesses host APIs through
| 3. SDK API | `dynamic-plugins/sdk-api.md` | when writing plugin logic — definePlugin, PluginContext, events, storage, useSelection, native form embedding (`NativeForm` Tier-1 injection / `NativeFormScope`+`useNativeForm` Tier-2 kit), NativeGrid | | 3. SDK API | `dynamic-plugins/sdk-api.md` | when writing plugin logic — definePlugin, PluginContext, events, storage, useSelection, native form embedding (`NativeForm` Tier-1 injection / `NativeFormScope`+`useNativeForm` Tier-2 kit), NativeGrid |
| 3b. Native form quick answer | `dynamic-plugins/native-form.md` | **when the user ASKS whether/how to embed native field editors, custom form layout, or multi-step forms — read this FIRST and answer directly (consultative questions need no scaffold/build)** | | 3b. Native form quick answer | `dynamic-plugins/native-form.md` | **when the user ASKS whether/how to embed native field editors, custom form layout, or multi-step forms — read this FIRST and answer directly (consultative questions need no scaffold/build)** |
| 4. Deployment | `dynamic-plugins/deployment.md` | when uploading + enabling + authority matrix permissions | | 4. Deployment | `dynamic-plugins/deployment.md` | when uploading + enabling + authority matrix permissions |
| 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states) | | 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states, chunk-name flattening, terser banners) |
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the canonical example — contextMenu plugin, full React + SDK | | Ref: plugin-showcase | `plugins/plugin-showcase/` | the v2 example — lazy chunks + source maps + Tailwind + HashRouter route app |
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the classic example — single-file contextMenu plugin, full React + SDK |
## 3. Quick decision: which position? ## 3. Quick decision: which position?
@@ -7,9 +7,18 @@ Uploading a bundle plugin, enabling it, and managing permissions.
Upload the built bundle to the host. Uses `multipart/form-data`. Upload the built bundle to the host. Uses `multipart/form-data`.
The `pluginId` in the URL must match the `id` in `plugin.json`. The `pluginId` in the URL must match the `id` in `plugin.json`.
Preferred (handles entry + chunks + maps + css automatically):
```bash
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d .
```
Raw curl equivalent (legacy single-file form still works):
```bash ```bash
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])") PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
curl -s -b "auth_session=$SESSION" \ curl -s -H "Authorization: Bearer $TEABLE_PAT" \
-F "js=@dist/main.js" \ -F "js=@dist/main.js" \
-F "css=@dist/styles.css" \ -F "css=@dist/styles.css" \
-F "manifest=@plugin.json" \ -F "manifest=@plugin.json" \
@@ -17,34 +26,51 @@ curl -s -b "auth_session=$SESSION" \
``` ```
**Auth note**: Supports both session cookies (`auth_session`) and Personal Access **Auth note**: Supports both session cookies (`auth_session`) and Personal Access
Tokens (`Authorization: Bearer <PAT>`). Tokens (`Authorization: Bearer <PAT>`). Runtime asset GETs
(`/api/plugin/:id/runtime/*`) are session-only (PAT gets 403 by design) —
verify those with a signed cookie, not a PAT.
**Fields:** **Fields** (v2 multi-asset, since 2026-10):
| Field | Required | Max size | Description |
|---|---|---|---|
| `js` | Yes | 5 MB | The IIFE bundle (`dist/main.js`) |
| `css` | No | 1 MB | Optional stylesheet (`dist/styles.css`) |
| `manifest` | Yes | 10 KB | `plugin.json` manifest |
**Returns** `201` with `{ id, name, version, ... }` — the plugin ID to use for | Field | Required | Max count | Max size | Description |
enable and install. |---|---|---|---|---|
| `js` | Yes | 20 | 5 MB each | Entry + lazy chunks. Entry = the file whose name matches `manifest.entry` (a lone differently-named js file still works — legacy fallback) |
| `css` | No | 5 | 1 MB each | Stylesheets, concatenated under the `manifest.style` name |
| `map` | No | 20 | 5 MB each | Source maps, served later as `application/json` |
| `manifest` | Yes | 1 | 10 KB | `plugin.json` manifest |
**Registering a NEW plugin id**: `POST /api/plugin` (manifest as JSON body) — - **Total cap 15 MB** across all assets.
**admin-only** (PAT gets 403 `restricted_resource`). Uploading a bundle for an - Chunk filenames must be **FLAT** (`x-<hash>.chunk.js`) — the multipart
already-registered id only needs the `plugin_developer` whitelist. Deleting a transport strips directory parts from filenames.
plugin (`DELETE /api/plugin/:id`) is likewise admin-only. PAT-only automation - The bundle dir on disk (`<backend-cwd>/.assets/plugins/bundle/<pluginId>/`)
should reuse existing registered ids, or ask an admin to register/delete. is **wiped on every upload** — stale chunks are removed, and hand-patched
files on disk are discarded.
**Returns** `{ pluginId, hash, size, assets }` — `assets` is the stored file
count (1 for legacy single-file uploads).
**Registering a NEW plugin id**: `POST /api/plugin {"id": "<kebab-id>", ...}`
— PAT-accessible (`@TokenAccess()` since 2026-10) and **custom ids require
the `plugin_developer` whitelist**. Without an explicit `id` the server
mints a `plgXXX` id, which CANNOT receive bundles (manifest id must equal
the DB id). Deleting a plugin (`DELETE /api/plugin/:id`) remains admin-only.
**Security** (7-layer validation): **Security** (7-layer validation):
1. Authentication — valid session cookie required (401 if missing) 1. Authentication — valid session cookie or PAT required (401 if missing)
2. Developer whitelist — `plugin_developer` table lookup (403 if not whitelisted) 2. Developer whitelist — `plugin_developer` table lookup (403 if not whitelisted)
3. Rate limit — 10 uploads/min per user (429 if exceeded) 3. Rate limit — 10 uploads/min per user (429 if exceeded)
4. Size limits — JS ≤ 5 MB, CSS ≤ 1 MB, manifest ≤ 10 KB 4. Size limits — JS/map ≤ 5 MB each, CSS ≤ 1 MB each, manifest ≤ 10 KB,
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+` 15 MB total
6. Content validation — JS must be IIFE format (prefix `!function`/`(()=>`), 5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`,
rejects `<script>`/`eval`/`document.write` injection; CSS strips `expression()`/ `minAppVersion` ≤ host version
`@import`/`javascript:` URIs 6. Content validation — the ENTRY must be IIFE format (prefix `!function` /
7. Integrity — SHA-256 hash stored in DB, verified at load time `(function` / `(() =>` / `var <name>=<iife>`); chunks get the
forbidden-pattern check only (they are not IIFEs); JS rejects
`<script>`/`document.write`/`javascript:`; CSS strips `expression()`/
`@import`/`javascript:` URIs; maps are name/size checked only (they embed
original source text — pattern checks would false-positive)
7. Integrity — entry SHA-256 stored in DB for audit + PluginAuditLog row
(uploader/IP/UA); note the loader does NOT re-verify the hash at load time
## Enable — `PUT /api/base/:baseId/plugin-config/:pluginId` ## Enable — `PUT /api/base/:baseId/plugin-config/:pluginId`
@@ -118,11 +144,28 @@ must call `ctx.addView(id, component)` to render the component as a view.
Authority Matrix entries are auto-created for the new install (default Authority Matrix entries are auto-created for the new install (default
denied for non-owner roles). denied for non-owner roles).
### Route ### Route (full-page app, since 2026-10)
Route-type plugins registered via `ctx.addRoute(path, component)` are The host renders a dedicated page for the plugin at
automatically available at the registered path — no install API needed. `/base/<baseId>/p/<pluginId>` — the whole main-area viewport inside the base
Upload + enable is sufficient. layout. The plugin registers via `ctx.addRoute(id, component)` and typically
brings its own router (HashRouter recommended, so Next.js routing is
untouched).
```bash
curl -s -H "Authorization: Bearer $TEABLE_PAT" -X POST \
"http://localhost:3003/api/base/$BASE_ID/plugin-installs" \
-H 'Content-Type: application/json' \
-d '{"pluginId":"$PLUGIN_ID","name":"My App","position":"route"}'
```
Notes (verified 2026-10 on plugin-showcase):
- `manifest.positions` must include `"route"`.
- The page itself does not gate on plugin-config enablement — the URL is the
access point. Follow the standard enable+install flow anyway so the plugin
shows up in management UIs and future permission layers behave uniformly.
- Host SDK hooks (e.g. `host.SDK.useBaseId()`) work on the route page because
it renders inside BaseLayout's provider stack.
## Authority Matrix (plugin permissions) ## Authority Matrix (plugin permissions)
@@ -173,23 +216,18 @@ BASE_ID="${BASE_ID:?set to a throwaway sandbox base, never a production base}"
# Read plugin ID from manifest # Read plugin ID from manifest
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])") PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])")
# Get session # Upload: teable-plugin deploy handles entry + chunks + maps + css.
# PAT preferred (upload-bundle accepts Bearer auth): # Raw-curl equivalent for a single-file bundle:
AUTH=(-H "Authorization: Bearer ${PAT:?set PAT env}") # curl -s -H "Authorization: Bearer $TEABLE_PAT" \
# ...or session: # -F "js=@$DIR/dist/main.js" -F "css=@$DIR/dist/styles.css" \
SESSION=$(grep auth_session /tmp/cookie.txt 2>/dev/null | awk '{print $NF}') # -F "manifest=@$DIR/plugin.json" \
AUTH=(-b "auth_session=$SESSION") # "$UPLOAD_URL/api/plugin/$PLUGIN_ID/upload-bundle"
TEABLE_ENDPOINT="$UPLOAD_URL" TEABLE_PAT="${TEABLE_PAT:?set PAT env}" \
# Upload (URL includes pluginId from manifest) node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d "$DIR"
curl -s "${AUTH[@]}" \
-F "js=@$DIR/dist/main.js" \
-F "css=@$DIR/dist/styles.css" \
-F "manifest=@$DIR/plugin.json" \
"$UPLOAD_URL/api/plugin/$PLUGIN_ID/upload-bundle"
echo "Uploaded: $PLUGIN_ID" echo "Uploaded: $PLUGIN_ID"
# Enable # Enable
curl -s "${AUTH[@]}" -X PUT \ curl -s -H "Authorization: Bearer $TEABLE_PAT" -X PUT \
"$UPLOAD_URL/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \ "$UPLOAD_URL/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"enabled":true,"tableIds":[]}' > /dev/null -d '{"enabled":true,"tableIds":[]}' > /dev/null
@@ -1,18 +1,37 @@
# Plugin development workflow # Plugin development workflow
The standard recipe for creating, building, and deploying a dynamic React The standard recipe for creating, building, and deploying a dynamic React
component plugin. Each plugin is a standalone webpack project that outputs component plugin. Each plugin is a standalone webpack (or vite) project that
an IIFE bundle. outputs an IIFE bundle. Since 2026-10 the pipeline is multi-asset: lazy
chunks, source maps, and multiple CSS files upload alongside the entry.
## Output contract ## Output contract
Every plugin task ends with: Every plugin task ends with:
1. A built IIFE bundle (`dist/main.js` + optional `dist/styles.css`) 1. A built IIFE bundle (`dist/main.js` + optional chunks/`*.chunk.js` +
`*.js.map` source maps + optional `styles.css`)
2. A `plugin.json` manifest 2. A `plugin.json` manifest
3. Uploaded to the host via `POST /api/plugin/:id/upload-bundle` 3. Uploaded to the host via `POST /api/plugin/:id/upload-bundle`
(or `teable-plugin deploy` which handles all assets)
4. Enabled via `PUT /api/base/:baseId/plugin-config/:pluginId` 4. Enabled via `PUT /api/base/:baseId/plugin-config/:pluginId`
5. Installed to the target position (the install API varies by position) 5. Installed to the target position (the install API varies by position)
## Dev loop (hot reload — preferred while iterating)
```bash
cd plugins/my-plugin # or wherever the project lives
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> pnpm dev
# or: node ../../packages/plugin-cli/dist/index.js dev -d .
```
This runs `webpack --watch` (production mode — dev-mode output carries
`/******/` banners that fail the IIFE validator), auto-uploads on every
change with a `devMode: true, buildId: <ts>` manifest, and the open host
page remounts the plugin within ~4s WITHOUT a page refresh. Production
deploys (no devMode in manifest) never poll. Verified 2026-10-13 on
plugin-showcase: edit → ~5s live update, window markers survive (SPA-level
remount, not a reload).
## Step 1 — Understand the request ## Step 1 — Understand the request
Clarify what the user wants, then pick the position using `overview.md`. Clarify what the user wants, then pick the position using `overview.md`.
@@ -26,23 +45,33 @@ to npm). Run it directly:
```bash ```bash
cd /tmp cd /tmp
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin
# or the vite template:
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin --template vite
cd my-plugin cd my-plugin
pnpm install pnpm install
``` ```
The scaffold adapts automatically: inside the monorepo it wires
`@teable/plugin-sdk: workspace:*` + source aliases; standalone projects get a
loose-typed local host module (the npm package is not published). Vite
scaffolds also generate a `pnpm-workspace.yaml` with `allowBuilds: esbuild`
(pnpm 11 fails `pnpm install` without it).
This generates: This generates:
``` ```
my-plugin/ my-plugin/
├── src/ ├── src/
│ └── index.tsx # entry: calls definePlugin() │ ├── host.ts # typed host accessor
│ └── index.tsx # entry: definePlugin via host.PluginSDK
├── plugin.json # manifest ├── plugin.json # manifest
├── webpack.config.js # pre-configured externals ├── webpack.config.js # or vite.config.ts
├── tsconfig.json ├── tsconfig.json
└── package.json └── package.json # dev/build/deploy scripts
``` ```
If the CLI doesn't work for some reason, manually create the structure by Reference implementations in the repo: `plugins/plugin-showcase/` (chunks +
copying `plugins/batch-edit-pro/` as a template. sourcemap + Tailwind + HashRouter route app — the v2 showcase) and
`plugins/batch-edit-pro/` (classic single-file contextMenu plugin).
## Step 3 — Write the plugin code ## Step 3 — Write the plugin code
@@ -117,31 +146,41 @@ definePlugin({
## Step 4 — Build ## Step 4 — Build
```bash
NODE_OPTIONS='--max-old-space-size=2048' npx webpack --config webpack.config.js
```
Or if the plugin has a build script:
```bash ```bash
pnpm build pnpm build
# or: npm run build # = tsc --noEmit && webpack --mode production (scaffold script)
``` ```
**Rebuilding without the scaffold — use this exact config** (agents hand-writing **Rebuilding without the scaffold — use this exact config** (agents
webpack configs keep hitting `Error: Unsupported library type iife` — there is hand-writing webpack configs keep hitting `Error: Unsupported library type
NO `output.library` in a working plugin config; a default single-entry iife` — there is NO `output.library` in a working plugin config; a default
production build is already IIFE, which is what the upload validator requires): single-entry production build is already IIFE, which is what the upload
validator requires):
```js ```js
// webpack.config.js — known-good (from plugins/batch-edit-pro/) // webpack.config.js — known-good v2 (from plugins/plugin-showcase/)
const path = require('path'); const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const pluginId = require('./plugin.json').id;
module.exports = { module.exports = {
mode: 'production', // default single-entry build emits an IIFE (!function / (()=>)) mode: 'production',
entry: './src/index.tsx', entry: './src/index.tsx',
output: { path: path.resolve(__dirname, 'dist'), filename: 'main.js', clean: true }, output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js',
// FLAT chunk names: multipart transport strips directory parts, so
// nested chunk paths would 404 at runtime.
chunkFilename: '[name]-[contenthash].chunk.js',
// required so React.lazy/import() chunks resolve from the host runtime
publicPath: `/api/plugin/${pluginId}/runtime/`,
clean: true,
// terser's minified "(()=>{" fails the validator's "(() =>" check
environment: { arrowFunction: false },
},
devtool: 'source-map', // maps upload + serve as application/json
// NO output.library — never add library.type: 'iife' (webpack rejects it) // NO output.library — never add library.type: 'iife' (webpack rejects it)
externals: { externals: {
'react': 'window.__TEABLE_HOST__.React', 'react': 'window.__TEABLE_HOST__.React',
@@ -151,59 +190,70 @@ module.exports = {
module: { module: {
rules: [ rules: [
{ test: /\.tsx?$/, loader: 'ts-loader', options: { transpileOnly: true }, exclude: /node_modules/ }, { test: /\.tsx?$/, loader: 'ts-loader', options: { transpileOnly: true }, exclude: /node_modules/ },
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] }, { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'] },
],
},
optimization: {
minimizer: [
// license-comment extraction prepends a "/*!..." banner that breaks
// the IIFE-prefix check — keep comments inline
new TerserPlugin({ extractComments: false }),
], ],
}, },
plugins: [new MiniCssExtractPlugin({ filename: 'styles.css' })], plugins: [new MiniCssExtractPlugin({ filename: 'styles.css' })],
}; };
``` ```
`react/jsx-runtime` is intentionally NOT external: the jsx automatic
transform bundles it and that is safe (element factories only — hooks still
come from the host singleton).
Vite template notes: `build.lib formats: ['iife']` + rollup externals with
`globals: { react: 'window.__TEABLE_HOST__.React', ... }`; output starts
`(function(...)` or `var <name>=(function...` — both pass the validator.
IIFE cannot code-split (dynamic imports inline) — use the webpack template
if you need lazy chunks.
If you build outside the monorepo, drop the `resolve.alias` block (it exists 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 only so TS can type-check `import type` from `@teable/*` sources) and use
`import type` + `as unknown as` casts instead of runtime imports. `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) + optional `dist/*.chunk.js` (lazy chunks) +
`dist/*.js.map` + `dist/styles.css`.
Verify the output:
```bash
ls -la dist/main.js dist/styles.css plugin.json
```
## Step 5 — Upload the bundle ## Step 5 — Upload the bundle
The upload URL includes the plugin's `id` from `plugin.json`. Extract it first, Preferred (handles entry + chunks + maps + css automatically):
then upload.
```bash ```bash
SESSION=$(grep auth_session /tmp/cookie_hr.txt 2>/dev/null | awk '{print $NF}') TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
if [ -z "$SESSION" ]; then node ../../packages/plugin-cli/dist/index.js deploy -d .
SESSION=$(curl -s -c - http://localhost:3003/api/auth/signin \ # or from the plugin dir: pnpm deploy
-X POST -H 'Content-Type: application/json' \ ```
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}' \
| grep auth_session | awk '{print $NF}')
> Account note (2026-08): `hr_admin@xingchen.tech` no longer exists on the Raw curl equivalent (single-file legacy form still works):
> deployed instance. Create a throwaway user via `POST /api/auth/signup`
> and add a `plugin_developer` row for it before uploading (see SKILL.md).
fi
```bash
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])") PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
curl -s -H "Authorization: Bearer $TEABLE_PAT" \
curl -s -b "auth_session=$SESSION" \
-F "js=@dist/main.js" \ -F "js=@dist/main.js" \
-F "css=@dist/styles.css" \ -F "css=@dist/styles.css" \
-F "manifest=@plugin.json" \ -F "manifest=@plugin.json" \
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle" "http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
echo "PLUGIN_ID=$PLUGIN_ID"
``` ```
- The plugin `id` in the URL must match the `id` in `plugin.json`. On first Multipart notes (v2, 2026-10):
upload, the plugin is created; on subsequent uploads, the existing plugin is - `js` field accepts up to 20 files (entry first or matched by manifest
updated. entry filename), `css` up to 5, `map` up to 20.
- Auth supports both session cookies (`auth_session`) and Personal Access Tokens - The plugin row must already exist (`POST /api/plugin {"id": "<kebab-id>"}`
(`Authorization: Bearer <PAT>`). — custom ids require the plugin_developer whitelist; the endpoint is
- Returns 201 on success. `@TokenAccess()` so a PAT works).
- Limits: 5MB per JS/map file, 1MB per CSS, 15MB total. The bundleDir is
WIPED before writing — stale chunks don't linger, but hand-patched files
on disk are discarded on the next upload.
- Auth supports session cookies (`auth_session`) and PATs
(`Authorization: Bearer`). Runtime GETs (`/api/plugin/:id/runtime/*`) are
session-only (PAT gets 403 by design).
## Step 6 — Enable (REQUIRED before install) ## Step 6 — Enable (REQUIRED before install)
@@ -237,6 +287,11 @@ Open the relevant page and confirm the plugin renders and works:
- Panel: bottom-right Plugins button in a table view - Panel: bottom-right Plugins button in a table view
- View: view-type dropdown - View: view-type dropdown
- Dashboard: space dashboard page - Dashboard: space dashboard page
- Route (full-page app): `/base/<baseId>/p/<pluginId>` — install at position
`route`, the page renders the plugin full-screen inside BaseLayout (bring
your own router; HashRouter recommended so Next.js routing is untouched).
Verified flow: enable via plugin-config → install via
`POST /api/base/:baseId/plugin-installs {"position":"route"}` → open the URL.
Use `browser-act` to verify or ask the user to check. Use `browser-act` to verify or ask the user to check.
@@ -244,14 +299,18 @@ Use `browser-act` to verify or ask the user to check.
Before claiming done: Before claiming done:
- [ ] `dist/main.js` built and is a valid IIFE bundle - [ ] `dist/main.js` built and is a valid IIFE bundle (`!function` / `(function` /
`(() =>` / `var x=(function` prefix — run `head -c 20 dist/main.js`)
- [ ] Chunks (if any) are FLAT names (`[name]-[contenthash].chunk.js`)
- [ ] `plugin.json` at project root with correct `id`, `entry`, `positions` - [ ] `plugin.json` at project root with correct `id`, `entry`, `positions`
- [ ] No hardcoded URLs/table IDs/tokens — all via host SDK - [ ] No hardcoded URLs/table IDs/tokens — all via host SDK
- [ ] Loading, error, and empty states handled in the UI - [ ] Loading, error, and empty states handled in the UI
- [ ] `definePlugin()` called with `onLoad(ctx)` that registers at least one component - [ ] `definePlugin()` called with `onLoad(ctx)` that registers at least one component
- [ ] Upload returns 201, pluginId captured - [ ] Upload returns 2xx (assets count > 1 when chunks/maps exist)
- [ ] Enable (plugin-config) called before install - [ ] Enable (plugin-config) called before install
- [ ] Install returns 2xx - [ ] Install returns 2xx
- [ ] Plugin renders and works - [ ] Plugin renders and works
- [ ] Dev-loop deploys redone as a production upload (no `devMode` in the
final manifest) before handover
If any box is unchecked, fix it. If any box is unchecked, fix it.
@@ -49,11 +49,17 @@ gated by a developer whitelist and bundles are validated for security.
→ check minAppVersion compatibility → check minAppVersion compatibility
→ <link> inject CSS (if manifest.style exists) → <link> inject CSS (if manifest.style exists)
→ <script src="/api/plugin/:id/runtime/main.js"> (IIFE executes) → <script src="/api/plugin/:id/runtime/main.js"> (IIFE executes)
→ if the bundle code-splits, lazy chunks load from the same runtime
endpoint (output.publicPath = /api/plugin/:id/runtime/), served as
plain .js assets
→ script calls definePlugin({ id, name, onLoad, ... }) → script calls definePlugin({ id, name, onLoad, ... })
→ window.__TEABLE_PLUGINS__[pluginId] = config → window.__TEABLE_PLUGINS__[pluginId] = config
→ PluginLoader reads registration, creates PluginContext, calls onLoad(ctx) → PluginLoader reads registration, creates PluginContext, calls onLoad(ctx)
→ ctx.addContextMenuAction(...) etc. register React components to componentRegistry → ctx.addContextMenuAction(...) etc. register React components to componentRegistry
→ PluginSlot renders registered components at the right mount point → PluginSlot renders registered components at the right mount point
→ if manifest.devMode is true (dev loop only), the loader polls the
manifest every 4s and remounts the plugin when buildId changes —
no page refresh (production manifests never set devMode)
3. Plugin unloads 3. Plugin unloads
→ onUnload(ctx) called → onUnload(ctx) called
@@ -117,32 +123,41 @@ Location: `apps/nextjs-app/src/features/app/components/plugin/host/event-bus.ts`
``` ```
dist/ dist/
├── main.js # IIFE bundle (webpack output) ├── main.js # IIFE entry bundle (webpack/vite output)
└── styles.css # optional stylesheet ├── <name>-<hash>.chunk.js # optional lazy chunks (code splitting) — FLAT names
├── *.js.map # optional source maps (served as application/json)
└── styles.css # optional stylesheet (Tailwind/SCSS output OK)
plugin.json # manifest plugin.json # manifest
``` ```
## Shared dependency bridging ## Shared dependency bridging
Plugin webpack config marks these as externals, mapping to host globals: Plugin webpack config marks ONLY the React singleton as external, mapping to
a host global:
```javascript ```javascript
externals: { externals: {
'react': 'window.__TEABLE_HOST__.React', 'react': 'window.__TEABLE_HOST__.React',
'react-dom': 'window.__TEABLE_HOST__.ReactDOM', 'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
'@teable/sdk': 'window.__TEABLE_HOST__.SDK',
'@teable/ui-lib': 'window.__TEABLE_HOST__.UILib',
'@teable/openapi': 'window.__TEABLE_HOST__.OpenAPI',
'@teable/plugin-sdk': 'window.__TEABLE_HOST__.PluginSDK',
} }
``` ```
This means the plugin does NOT bundle React or host packages — it uses the Everything else on the host (`SDK`, `UILib`, `OpenAPI`, `PluginSDK`) is read
host's singleton instances. The bundle only contains the plugin's own code. from `window.__TEABLE_HOST__` at runtime — there is NO working externals
mapping for `@teable/*` packages (a value import via the old
window-global-externals style compiled to `undefined`; via monorepo source
aliases it would inline the whole package source — see troubleshooting.md
Pitfall 1). `react/jsx-runtime` is intentionally NOT external: the jsx
automatic transform bundles it, which is safe — it only creates element
objects, while hooks/context/state still resolve through the externalized
`react` → host singleton.
Other npm dependencies (react-router, charts, lodash…) are bundled into the
plugin as normal.
## Two ways to access host APIs ## Two ways to access host APIs
### 1. Through `window.__TEABLE_HOST__` (recommended for IIFE) ### 1. Through `window.__TEABLE_HOST__` (the only runtime path)
```javascript ```javascript
const host = window.__TEABLE_HOST__; const host = window.__TEABLE_HOST__;
@@ -153,17 +168,21 @@ const { getRecords } = host.OpenAPI;
const { definePlugin, useSelection } = host.PluginSDK; const { definePlugin, useSelection } = host.PluginSDK;
``` ```
This is what `plugins/batch-edit-pro/` uses — the plugin bundle has no This is what `plugins/plugin-showcase/` and `plugins/batch-edit-pro/` use —
`@teable/*` imports, everything resolves through the host global. the plugin bundle has no `@teable/*` runtime imports, everything resolves
through the host global. `import { useState } from 'react'` also works (the
externals mapping above redirects it to the host singleton).
### 2. Through `@teable/plugin-sdk` (also works because webpack externals) ### 2. `import type` from `@teable/*` (type-checking only)
```typescript ```typescript
import { definePlugin } from '@teable/plugin-sdk'; import type { ITeableHost } from '@teable/plugin-sdk';
export const host = window.__TEABLE_HOST__ as unknown as ITeableHost;
``` ```
At build time, webpack resolves this to `window.__TEABLE_HOST__.PluginSDK.definePlugin`. `import type` is erased at compile time — it never reaches the bundle. This
Both styles work — the externals config handles the mapping. is how scaffolded projects get full type safety inside the monorepo
(standalone scaffolds ship a loose-typed local host module instead).
## Positions and registration ## Positions and registration
@@ -173,4 +192,4 @@ Both styles work — the externals config handles the mapping.
| Panel | Right-side drawer of a table view | `ctx.addPanel(id, component)` | | Panel | Right-side drawer of a table view | `ctx.addPanel(id, component)` |
| View | View-type dropdown (alongside grid/form/gallery) | `ctx.addView(id, component)` | | View | View-type dropdown (alongside grid/form/gallery) | `ctx.addView(id, component)` |
| Dashboard | Grid card on space dashboard | `ctx.addDashboardWidget(id, component)` | | Dashboard | Grid card on space dashboard | `ctx.addDashboardWidget(id, component)` |
| Route | Full-page route (e.g., `/plugin/:pluginId/my-page`) | `ctx.addRoute(path, component)` | | Route | Full-page app at `/base/:baseId/p/:pluginId` (whole viewport inside BaseLayout; own router OK, HashRouter recommended) | `ctx.addRoute(id, component)` |
@@ -59,9 +59,17 @@ ctx.addContextMenuAction(id: string, component: React.FC): Disposable;
ctx.addPanel(id: string, component: React.FC): Disposable; ctx.addPanel(id: string, component: React.FC): Disposable;
ctx.addDashboardWidget(id: string, component: React.FC): Disposable; ctx.addDashboardWidget(id: string, component: React.FC): Disposable;
ctx.addView(id: string, component: React.FC): Disposable; ctx.addView(id: string, component: React.FC): Disposable;
ctx.addRoute(path: string, component: React.FC): Disposable; ctx.addRoute(id: string, component: React.FC): Disposable;
``` ```
**Route position** (full-page apps, since 2026-10): the component renders at
`/base/<baseId>/p/<pluginId>` — the whole main-area viewport inside the base
layout, so SDK hooks like `host.SDK.useBaseId()` work. Install the plugin at
position `"route"` (`POST /api/base/:baseId/plugin-installs`) and include
`"route"` in `manifest.positions`. The plugin brings its own router — use
HashRouter (e.g. `react-router-dom` bundled as a normal npm dep) so Next.js
routing is untouched. Working example: `plugins/plugin-showcase/src/App.tsx`.
### Events ### Events
#### `ctx.on(event, handler): Disposable` #### `ctx.on(event, handler): Disposable`
@@ -104,3 +104,46 @@ If you're setting up the webpack config manually, make sure:
- The plugin does NOT need `import React from 'react'` at the top when using - The plugin does NOT need `import React from 'react'` at the top when using
`react-jsx` transform — JSX is auto-transpiled. But you still need `react-jsx` transform — JSX is auto-transpiled. But you still need
`const React = host.React` for any explicit `React.createElement` calls. `const React = host.React` for any explicit `React.createElement` calls.
## Pitfall 8: Chunk 404 at runtime — multipart strips directory names (2026-10)
`webpack output.chunkFilename: 'static/js/[name].chunk.js'` builds fine, the
upload succeeds, but the browser 404s the chunk: multer/busboy flattens
multipart filenames, so the server stored `469.chunk.js` while the bundle
requests `static/js/469.chunk.js`.
**Fix**: keep chunk names FLAT — `chunkFilename: '[name]-[contenthash].chunk.js'`
(contenthash prevents collisions). Public path still works:
`output.publicPath: '/api/plugin/<id>/runtime/'`.
## Pitfall 9: `Bundle must be IIFE format` after a successful build
Three producer-side causes, all seen in the wild:
1. **Terser license banner** — bundling any npm package with license comments
makes webpack emit `/*! For license information... */` BEFORE `!function`.
Fix: `optimization.minimizer: [new TerserPlugin({ extractComments: false })]`.
2. **Development-mode webpack** output starts with `/******/` banner comments.
The dev watch loop therefore builds with `--mode production`.
3. **Vite/esbuild IIFE** output starts `var <name>=(function...` — the
validator accepts it (since 2026-10); no post-processing needed.
## Pitfall 10: vite scaffold `pnpm install` fails with ERR_PNPM_IGNORED_BUILDS
pnpm ≥ 10/11 blocks esbuild's postinstall and (on patched hosts) treats it as
fatal. Fix (scaffold generates it): `pnpm-workspace.yaml` with
`allowBuilds:\n esbuild: true`. esbuild also works without its script (binary
comes from optionalDependencies) — only the pnpm gate needs the allow entry.
## Pitfall 11: PAT gets 403 fetching `/api/plugin/:id/runtime/*`
Access tokens only pass routes with `@TokenAccess()` or `@Permissions` — the
runtime asset serving has neither. This is by design (the browser loads with a
session). For curl verification use a signed `auth_session` cookie; for uploads
use the PAT (`upload-bundle` and `POST /api/plugin` are token-accessible).
## Pitfall 12: "react/jsx-runtime would duplicate React" worry
It does not. The jsx automatic transform imports `react/jsx-runtime`, which is
bundled into the plugin on purpose: it only creates plain element objects.
Hooks/context/state go through the externalized `react` → host singleton. This
is exactly how the deployed ticket-console/batch-edit-pro bundles behave.
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"version": "1.0.0", "version": "1.1.0",
"organization": "inkreach", "organization": "inkreach",
"authors": ["inkreach"], "authors": ["inkreach"],
"tags": ["plugin", "teable", "zip", "bridge", "context-menu", "dashboard", "panel", "view", "iframe"], "tags": ["plugin", "teable", "bundle", "iife", "webpack", "vite", "context-menu", "dashboard", "panel", "view", "route", "hot-reload", "source-map"],
"abstract": "Develop ZIP plugins for inkreach-teable: 4 mount positions, penpal bridge API, AI-friendly templates and examples." "abstract": "Develop dynamic React component plugins for inkreach-teable: IIFE bundles (webpack or vite) loaded at runtime via window.__TEABLE_HOST__ with 5 mount positions including full-page route apps, multi-asset uploads (lazy chunks + source maps), a watch-and-hot-reload dev loop, and the teable-plugin CLI scaffold."
} }
+2
View File
@@ -61,6 +61,7 @@ Full command syntax: [cli-reference.md](guides/cli-reference.md). Instance acces
| **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` / `record list` (returns record IDs) | Official-teable SQL APIs (none here) | | Read records for later writes | `record get` / `record list` (returns record IDs) | Official-teable SQL APIs (none here) |
| **Attach a remote file to a record** | `record create/update --fields '{"Files":[{"url":"https://…"}]}'` (backend downloads & stores it) | The multipart `uploadAttachment` route or manual signature/notify upload (only needed for local files) |
| 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) | | 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 |
@@ -90,6 +91,7 @@ These differ from official teable and from the (stale) `docs/automation/triggers
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 create --table-id tblXXX --fields '{"Title":"a"}' # 单条;批量用 --records '[{"fields":{...}}]'
inkreach-teable record create --table-id tblXXX --fields '{"Files":[{"url":"https://…/f.png"}]}' # 附件字段直接吃 URL(服务端下载入库);字符串形式加 --typecast
inkreach-teable record list --table-id tblXXX [--filter '<json>'] # 查询/分页(= record get) 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 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
@@ -21,8 +21,7 @@ Complete reference of all Teable field types, aliases, option shapes, and smart
| `date` | Date | Date or datetime | No | | `date` | Date | Date or datetime | No |
| `checkbox` | Checkbox | Yes/no toggle | No | | `checkbox` | Checkbox | Yes/no toggle | No |
| `rating` | Rating | Star/numeric rating 1..10 | No (defaults: max=5, star, yellow) | | `rating` | Rating | Star/numeric rating 1..10 | No (defaults: max=5, star, yellow) |
| `attachment` | Attachment | Files, images, documents | No | | `attachment` | Attachment | Files, images, documents; cell values also accept `{"url":"https://…"}` items (server downloads the file) | No || `user` | User | Person/collaborator reference | No |
| `user` | User | Person/collaborator reference | No |
| `link` | Link | Reference records in another table | Yes (`foreignTableId`/`foreignTableName`) | | `link` | Link | Reference records in another table | Yes (`foreignTableId`/`foreignTableName`) |
| `lookup` | Lookup | Mirror a field from linked record | Yes (`lookupOptions`) | | `lookup` | Lookup | Mirror a field from linked record | Yes (`lookupOptions`) |
| `rollup` | Rollup | Aggregate linked records (SUM, COUNT, etc.) | Yes (expression + `lookupOptions`) | | `rollup` | Rollup | Aggregate linked records (SUM, COUNT, etc.) | Yes (expression + `lookupOptions`) |
@@ -410,6 +409,7 @@ When `--type` is omitted, the CLI infers the type from the field name:
| `user` | `{"id":"usrXXX","title":"Alice"}` | `"Alice"` or `"alice@example.com"` | | `user` | `{"id":"usrXXX","title":"Alice"}` | `"Alice"` or `"alice@example.com"` |
| `link` (single) | `{"id":"recXXX"}` or `[{"id":"recXXX"}]` | `"Record Display Name"` | | `link` (single) | `{"id":"recXXX"}` or `[{"id":"recXXX"}]` | `"Record Display Name"` |
| `link` (multi) | `[{"id":"recA"},{"id":"recB"}]` | `["Record A","Record B"]` | | `link` (multi) | `[{"id":"recA"},{"id":"recB"}]` | `["Record A","Record B"]` |
| `attachment` | `[{"url":"https://…/f.png","name":"f.png"}]` (V1 legacy bases: token items only) | `"https://…/f.png"` or `"https://…/a.png,actXXX"` (comma-separated urls/ids) |
**When to use**: always use `--typecast` when creating/updating records with user or link fields — it eliminates manual ID lookups. **When to use**: always use `--typecast` when creating/updating records with user or link fields — it eliminates manual ID lookups.
@@ -152,6 +152,16 @@ const res = await fetch(triggerInput._apiBaseUrl + "/api/table/tblB/record", {
`401` = bad/expired token, `403` = scope or base-role too low. `401` = bad/expired token, `403` = scope or base-role too low.
6. To see the exact request the CLI sends for any operation, run it with 6. To see the exact request the CLI sends for any operation, run it with
`--debug` (prints method/url/body) — the fastest way to learn a route. `--debug` (prints method/url/body) — the fastest way to learn a route.
7. **Attachment fields accept URLs** (2026-10): pass `{"url":"https://…","name":"…"}` items
(or a URL string with `typecast:true` — note `typecast` is a BODY key) in
record create/update `fields`, and the backend downloads the file
(SSRF-guarded, size-capped) into attachment storage — response items come
back with real `token`/`presignedUrl`. A failed download rejects the whole
request (400); nothing is silently dropped. V1 (legacy) bases need
`typecast:true` for the string form, V2 bases accept both. The dedicated
`POST /api/table/{tid}/record/{rid}/{fid}/uploadAttachment` (multipart,
`file` or `fileUrl`) is the older single-record path — the plain record
routes now cover URL imports.
## 5. Full Route Inventory (this guide ≠ the whole API) ## 5. Full Route Inventory (this guide ≠ the whole API)
+11 -3
View File
@@ -71,6 +71,8 @@ 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>'
# shared write flags: [--typecast] [--field-key-type <name|id|dbFieldName>]
# [--order '{"viewId":"viwX","anchorId":"recY","position":"before|after"}'] — insert/move in a manual-sort view
inkreach-teable record delete --table-id <tid> <recordId> [recordId ...] # or --record-ids <csv> inkreach-teable record delete --table-id <tid> <recordId> [recordId ...] # or --record-ids <csv>
# view # view
@@ -200,10 +202,14 @@ Manage plugins and developer whitelist.
inkreach-teable plugin list inkreach-teable plugin list
# Register a URL-served plugin (runtimeType=url; status published immediately) # Register a URL-served plugin (runtimeType=url; status published immediately)
# positions: "base"|"panel"|"view"|"dashboard"|"contextMenu" # positions: "base"|"panel"|"view"|"dashboard"|"contextMenu"|"route"
inkreach-teable plugin register --url <url> --name <name> [--positions '["panel"]'] [--logo <url>] [--description <d>] 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 # Bundle (IIFE upload) plugins use a different flow — use the
# or api docs: create kebab-id plugin (developer whitelist) → upload → admin publish. # `inkreach-teable-plugin` skill instead: teable-plugin create/dev/deploy
# (scaffold → watch+hot-reload dev loop → multi-asset upload with chunks,
# source maps and css). Since 2026-10: POST /api/plugin (custom kebab-id,
# plugin_developer whitelist) and upload-bundle both accept PATs, and
# position "route" renders full-page apps at /base/:baseId/p/:pluginId.
# Developer whitelist (admin only) # Developer whitelist (admin only)
inkreach-teable plugin dev list inkreach-teable plugin dev list
@@ -215,6 +221,8 @@ inkreach-teable plugin install --plugin <pluginId> --name "My Plugin" --position
# ⚠️ For view/dashboard-position plugins, run `plugin config enable` BEFORE # ⚠️ For view/dashboard-position plugins, run `plugin config enable` BEFORE
# install, or the install API returns 403 RESTRICTED_RESOURCE # install, or the install API returns 403 RESTRICTED_RESOURCE
# 'Plugin is not enabled for this base'. # 'Plugin is not enabled for this base'.
# position "route" mounts a full-page app at /base/:baseId/p/:pluginId
# (no enable gate — the URL is the access point).
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>
+29 -2
View File
@@ -251,6 +251,25 @@ inkreach-teable record create --table-id tblXXX --fields '{"Assignee":{"id":"usr
inkreach-teable record create --table-id tblXXX --fields '{"Assignee":"Alice"}' --typecast inkreach-teable record create --table-id tblXXX --fields '{"Assignee":"Alice"}' --typecast
``` ```
**Attachment URL import** (2026-10): attachment fields accept http(s) URLs — the
backend downloads the file (SSRF-guarded, size-capped) and stores it as a real
attachment with thumbnail. Two forms:
```bash
# Object form — works without --typecast; name is optional (derived from the URL)
inkreach-teable record create --table-id tblXXX \
--fields '{"Files":[{"url":"https://example.com/f.png","name":"report.png"}]}'
# String form — needs --typecast; comma-separates URLs and existing actXXX ids
inkreach-teable record create --table-id tblXXX --typecast \
--fields '{"Files":"https://example.com/a.png,https://example.com/b.png"}'
```
Notes: only `http:`/`https:` URLs; URLs containing commas cannot be expressed in
the string form (use the object form); a failed download rejects the whole
request with 400 (nothing is silently dropped); V1 (legacy) bases require
`--typecast` for the string form, V2 bases accept both forms directly.
**`--records` format** (batch): `{"header":["Field1","Field2"],"records":[["val1","val2"],["val3","val4"]]}` **`--records` format** (batch): `{"header":["Field1","Field2"],"records":[["val1","val2"],["val3","val4"]]}`
### Update ### Update
@@ -261,7 +280,7 @@ inkreach-teable record update --table-id <tid> --records '<json>'
**`--records` format**: `[{"id":"recXXX","fields":{"FieldName":"newValue"}}]` — array of `{id, fields}` objects. Batch up to 2000. **`--records` format**: `[{"id":"recXXX","fields":{"FieldName":"newValue"}}]` — array of `{id, fields}` objects. Batch up to 2000.
**Record reordering**: pass `--order` with `viewId`, `anchorId`, `position`: **Record reordering**: pass `--order` with `viewId`, `anchorId`, `position` (works on both `record create` — insert at position — and `record update` — move existing records; only meaningful in a view without an explicit sort):
```bash ```bash
inkreach-teable record update --table-id tblXXX \ inkreach-teable record update --table-id tblXXX \
@@ -269,6 +288,13 @@ inkreach-teable record update --table-id tblXXX \
--order '{"viewId":"viwXXX","anchorId":"recYYY","position":"before"}' --order '{"viewId":"viwXXX","anchorId":"recYYY","position":"before"}'
``` ```
**`--field-key-type <name|id|dbFieldName>`** (default `name`): read `--fields`/`--records` keys as field ids instead of names — useful when field names contain characters that are awkward in JSON keys, or when you already have `fldXXX` ids from `field list --json`:
```bash
inkreach-teable record create --table-id tblXXX --field-key-type id \
--fields '{"fldXXXX":"hello"}'
```
### Delete ### Delete
```bash ```bash
@@ -378,7 +404,7 @@ inkreach-teable preset delete <presetId> --table-id tblXxx
| `singleSelect` | `"Option Name"` | (string) | | `singleSelect` | `"Option Name"` | (string) |
| `multipleSelect` | `["Tag A","Tag B"]` | (array of strings) | | `multipleSelect` | `["Tag A","Tag B"]` | (array of strings) |
| `rating` | `3` | Integer 1..max | | `rating` | `3` | Integer 1..max |
| `attachment` | `[{"name":"f.png","token":"xxx"}]` | Always array of objects; update **replaces all** | | `attachment` | `[{"name":"f.png","token":"xxx"}]` | Always array of objects; update **replaces all**. Also accepts `[{"url":"https://…","name":"f.png"}]` (server downloads the file) and, with `--typecast`, a plain URL string — see attachment URL import below |
| `user` | `{"id":"usrXXX","title":"Alice"}` | With `--typecast`: just `"Alice"` or `"alice@example.com"` | | `user` | `{"id":"usrXXX","title":"Alice"}` | With `--typecast`: just `"Alice"` or `"alice@example.com"` |
| `link` | `[{"id":"recXXX"}]` | With `--typecast`: just `"Linked Record Name"` | | `link` | `[{"id":"recXXX"}]` | With `--typecast`: just `"Linked Record Name"` |
| `formula` / `rollup` / `lookup` | Read-only | Cannot be set via record create/update | | `formula` / `rollup` / `lookup` | Read-only | Cannot be set via record create/update |
@@ -404,4 +430,5 @@ inkreach-teable preset delete <presetId> --table-id tblXxx
| View data in kanban board | `view create --type kanban` then `view update --group` | Creating special columns | | View data in kanban board | `view create --type kanban` then `view update --group` | Creating special columns |
| Set datetime in a date field | `"2025-06-15T14:30:00.000Z"` | `"2025-06-15 14:30"` (non-ISO; may be rejected) | | Set datetime in a date field | `"2025-06-15T14:30:00.000Z"` | `"2025-06-15 14:30"` (non-ISO; may be rejected) |
| Update attachments | Always pass the full array (replaces all) | Trying to add/remove individual attachments | | Update attachments | Always pass the full array (replaces all) | Trying to add/remove individual attachments |
| Attach a file by URL | `record create --fields '{"Files":[{"url":"https://…/f.png"}]}'` (object form, no typecast needed) or `--typecast --fields '{"Files":"https://…/f.png"}'` | Uploading via the attachments signature/notify endpoints first (only needed for local files) |
| Batch insert 500 records | One `record create --records` call | 500 separate `record create` calls | | Batch insert 500 records | One `record create --records` call | 500 separate `record create` calls |
+110 -4
View File
@@ -20,6 +20,55 @@ import {
} from '../lib/field-normalize'; } from '../lib/field-normalize';
import { formatFieldDetail, summarizeFieldOptions, choiceRows } from '../lib/field-display'; import { formatFieldDetail, summarizeFieldOptions, choiceRows } from '../lib/field-display';
const FIELD_KEY_TYPES = ['name', 'id', 'dbFieldName'] as const;
type FieldKeyTypeCli = (typeof FIELD_KEY_TYPES)[number];
/** Validate --field-key-type (backend body key `fieldKeyType`, default "name"). */
function parseFieldKeyType(raw: string | undefined): FieldKeyTypeCli | undefined {
if (raw === undefined) return undefined;
if (!(FIELD_KEY_TYPES as readonly string[]).includes(raw)) {
throw Object.assign(
new Error(`--field-key-type must be one of: ${FIELD_KEY_TYPES.join(', ')}`),
{ code: 'BAD_FIELD_KEY_TYPE' }
);
}
return raw as FieldKeyTypeCli;
}
type RecordOrderRo = { viewId: string; anchorId: string; position: 'before' | 'after' };
/**
* Validate --order (backend body key `order` on record create/update):
* place the written records before/after an anchor record in a
* manually-sorted view.
*/
function parseRecordOrder(raw: string): RecordOrderRo {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw Object.assign(
new Error('--order must be JSON: {"viewId":"viwXXX","anchorId":"recYYY","position":"before"}'),
{ code: 'BAD_JSON' }
);
}
const obj = parsed as Record<string, unknown>;
const { viewId, anchorId, position } = obj ?? {};
if (
typeof viewId !== 'string' ||
typeof anchorId !== 'string' ||
(position !== 'before' && position !== 'after')
) {
throw Object.assign(
new Error(
'--order needs string viewId + anchorId and position "before"|"after", e.g. {"viewId":"viwXXX","anchorId":"recYYY","position":"before"}'
),
{ code: 'BAD_ORDER' }
);
}
return { viewId, anchorId, position };
}
/** /**
* Fetch a field by ID or display name. A direct ID hit needs no extra call; * Fetch a field by ID or display name. A direct ID hit needs no extra call;
* a 400/404 miss (the API rejects non-ID paths) falls back to resolving the * a 400/404 miss (the API rejects non-ID paths) falls back to resolving the
@@ -677,10 +726,34 @@ function recordCmd(): Command {
.requiredOption('--table-id <id>', 'Table id') .requiredOption('--table-id <id>', 'Table id')
.option('--fields <json>', 'Fields object JSON, e.g. \'{"Title":"hello"}\'') .option('--fields <json>', 'Fields object JSON, e.g. \'{"Title":"hello"}\'')
.option('--records <json>', 'Records array JSON for bulk, e.g. \'[{"fields":{"Title":"a"}}]\'') .option('--records <json>', 'Records array JSON for bulk, e.g. \'[{"fields":{"Title":"a"}}]\'')
.option(
'--typecast',
'Send typecast=true (accepts human-readable values; attachment fields accept http(s) urls)'
)
.option(
'--field-key-type <type>',
'Keys in --fields/--records are field ids ("id"), names ("name", default) or dbFieldName'
)
.option(
'--order <json>',
'Insert position in a manually-sorted view: {"viewId":"viwXXX","anchorId":"recYYY","position":"before|after"}'
)
.action( .action(
run(async (ctx, _args, o) => { run(async (ctx, _args, o) => {
const opts = o as { tableId: string; fields?: string; records?: string }; const opts = o as {
let body: unknown; tableId: string;
fields?: string;
records?: string;
typecast?: boolean;
fieldKeyType?: string;
order?: string;
};
let body: {
typecast?: boolean;
fieldKeyType?: FieldKeyTypeCli;
order?: RecordOrderRo;
records: unknown;
};
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) {
@@ -692,6 +765,11 @@ function recordCmd(): Command {
code: 'MISSING_FIELDS', code: 'MISSING_FIELDS',
}); });
} }
// typecast/fieldKeyType/order are BODY keys of createRecordsRoSchema (not query params)
if (opts.typecast) body.typecast = true;
const fieldKeyType = parseFieldKeyType(opts.fieldKeyType);
if (fieldKeyType) body.fieldKeyType = fieldKeyType;
if (opts.order !== undefined) body.order = parseRecordOrder(opts.order);
const result = await ctx.client.createRecord(opts.tableId, body); const result = await ctx.client.createRecord(opts.tableId, body);
if (ctx.json) printJson(result); if (ctx.json) printJson(result);
else process.stdout.write(`✓ Record(s) created\n`); else process.stdout.write(`✓ Record(s) created\n`);
@@ -706,10 +784,38 @@ function recordCmd(): Command {
'--records <json>', '--records <json>',
'Records JSON, e.g. \'[{"id":"recX","fields":{"Title":"b"}}]\'' 'Records JSON, e.g. \'[{"id":"recX","fields":{"Title":"b"}}]\''
) )
.option(
'--typecast',
'Send typecast=true (accepts human-readable values; attachment fields accept http(s) urls)'
)
.option(
'--field-key-type <type>',
'Keys in --records fields are field ids ("id"), names ("name", default) or dbFieldName'
)
.option(
'--order <json>',
'Move the updated record(s) before/after an anchor in a manually-sorted view: {"viewId":"viwXXX","anchorId":"recYYY","position":"before|after"}'
)
.action( .action(
run(async (ctx, _args, o) => { run(async (ctx, _args, o) => {
const opts = o as { tableId: string; records: string }; const opts = o as {
const body = { records: JSON.parse(opts.records) }; tableId: string;
records: string;
typecast?: boolean;
fieldKeyType?: string;
order?: string;
};
const body: {
typecast?: boolean;
fieldKeyType?: FieldKeyTypeCli;
order?: RecordOrderRo;
records: unknown;
} = { records: JSON.parse(opts.records) };
// typecast/fieldKeyType/order are BODY keys of updateRecordsRoSchema (not query params)
if (opts.typecast) body.typecast = true;
const fieldKeyType = parseFieldKeyType(opts.fieldKeyType);
if (fieldKeyType) body.fieldKeyType = fieldKeyType;
if (opts.order !== undefined) body.order = parseRecordOrder(opts.order);
const result = await ctx.client.updateRecord(opts.tableId, body); const result = await ctx.client.updateRecord(opts.tableId, body);
if (ctx.json) printJson(result); if (ctx.json) printJson(result);
else process.stdout.write(`✓ Records updated\n`); else process.stdout.write(`✓ Records updated\n`);
+9 -6
View File
@@ -153,14 +153,17 @@ export class Client {
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : ''; const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('GET', `/api/table/${tableId}/record/${recordId}${qs}`); return this.call<unknown>('GET', `/api/table/${tableId}/record/${recordId}${qs}`);
} }
createRecord(tableId: string, body: unknown) { createRecord(tableId: string, body: unknown, query?: Record<string, unknown>) {
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body); const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('POST', `/api/table/${tableId}/record${qs}`, body);
} }
updateRecord(tableId: string, body: unknown) { updateRecord(tableId: string, body: unknown, query?: Record<string, unknown>) {
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body); const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('PATCH', `/api/table/${tableId}/record${qs}`, body);
} }
updateRecordOne(tableId: string, recordId: string, body: unknown) { updateRecordOne(tableId: string, recordId: string, body: unknown, query?: Record<string, unknown>) {
return this.call<unknown>('PATCH', `/api/table/${tableId}/record/${recordId}`, body); const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
return this.call<unknown>('PATCH', `/api/table/${tableId}/record/${recordId}${qs}`, body);
} }
deleteRecords(tableId: string, recordIds: string[]) { deleteRecords(tableId: string, recordIds: string[]) {
if (recordIds.length === 1) { if (recordIds.length === 1) {