Compare commits

...
2 Commits
17 changed files with 544 additions and 148 deletions
+28 -1
View File
@@ -1,6 +1,27 @@
# 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
请安装并用 inkreach-teable CLI 操作 inkreach-teable:
@@ -11,3 +32,9 @@
3. 鉴权(PAT 从 Web UI Settings → Personal Access Tokens 获取):
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",
"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).",
"license": "MIT",
"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
# ---------- 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}'
# 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"}}]'
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]")
@@ -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 --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}}]"
# --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 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"
+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
`window.__TEABLE_HOST__`.
> **Contract**: A plugin = a webpack IIFE bundle (`dist/main.js` + optional
> `dist/styles.css`) + `plugin.json` manifest, uploaded to the host via
> `POST /api/plugin/:id/upload-bundle`. It runs in the same React tree as the host
> and has full access to the host's SDK, UI library, and OpenAPI helpers.
> **Contract**: A plugin = a webpack (or vite) IIFE bundle (`dist/main.js` +
> optional lazy chunks `*.chunk.js`, source maps `*.js.map`, `styles.css`) +
> a `plugin.json` manifest, uploaded to the host via
> `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`,
> `@teable/openapi` — webpack maps them to `window.__TEABLE_HOST__.*` externals, so a
> value import compiles to `undefined` at runtime. `import type { X } from '@teable/...'`
> IS fine (erased at compile time). `react`/`react-dom` may be imported ONLY when your
> webpack `externals` maps them to `window.__TEABLE_HOST__.React/ReactDOM` (see the
> batch-edit-pro webpack config). For everything else, read from the host global:
> `const { useFields } = window.__TEABLE_HOST__.SDK`. See
> `@teable/openapi` — a value import via monorepo aliases inlines the whole
> package source (via window-global externals it compiles to `undefined`).
> `import type { X } from '@teable/...'` IS fine (erased at compile time).
> `react`/`react-dom` may be imported when your webpack `externals` maps them
> to `window.__TEABLE_HOST__.React/ReactDOM` (see the plugin-showcase webpack
> 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.
## 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 |
| 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 |
| 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states) |
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the canonical example — contextMenu plugin, full React + SDK |
| 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states, chunk-name flattening, terser banners) |
| 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?
@@ -7,9 +7,18 @@ Uploading a bundle plugin, enabling it, and managing permissions.
Upload the built bundle to the host. Uses `multipart/form-data`.
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
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 "css=@dist/styles.css" \
-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
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:**
| 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 |
**Fields** (v2 multi-asset, since 2026-10):
**Returns** `201` with `{ id, name, version, ... }` — the plugin ID to use for
enable and install.
| Field | Required | Max count | Max size | Description |
|---|---|---|---|---|
| `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) —
**admin-only** (PAT gets 403 `restricted_resource`). Uploading a bundle for an
already-registered id only needs the `plugin_developer` whitelist. Deleting a
plugin (`DELETE /api/plugin/:id`) is likewise admin-only. PAT-only automation
should reuse existing registered ids, or ask an admin to register/delete.
- **Total cap 15 MB** across all assets.
- Chunk filenames must be **FLAT** (`x-<hash>.chunk.js`) — the multipart
transport strips directory parts from filenames.
- The bundle dir on disk (`<backend-cwd>/.assets/plugins/bundle/<pluginId>/`)
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):
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)
3. Rate limit — 10 uploads/min per user (429 if exceeded)
4. Size limits — JS ≤ 5 MB, CSS ≤ 1 MB, manifest ≤ 10 KB
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`
6. Content validation — JS must be IIFE format (prefix `!function`/`(()=>`),
rejects `<script>`/`eval`/`document.write` injection; CSS strips `expression()`/
`@import`/`javascript:` URIs
7. Integrity — SHA-256 hash stored in DB, verified at load time
4. Size limits — JS/map ≤ 5 MB each, CSS ≤ 1 MB each, manifest ≤ 10 KB,
15 MB total
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`,
`minAppVersion` ≤ host version
6. Content validation — the ENTRY must be IIFE format (prefix `!function` /
`(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`
@@ -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
denied for non-owner roles).
### Route
### Route (full-page app, since 2026-10)
Route-type plugins registered via `ctx.addRoute(path, component)` are
automatically available at the registered path — no install API needed.
Upload + enable is sufficient.
The host renders a dedicated page for the plugin at
`/base/<baseId>/p/<pluginId>` — the whole main-area viewport inside the base
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)
@@ -173,23 +216,18 @@ BASE_ID="${BASE_ID:?set to a throwaway sandbox base, never a production base}"
# Read plugin ID from manifest
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])")
# Get session
# PAT preferred (upload-bundle accepts Bearer auth):
AUTH=(-H "Authorization: Bearer ${PAT:?set PAT env}")
# ...or session:
SESSION=$(grep auth_session /tmp/cookie.txt 2>/dev/null | awk '{print $NF}')
AUTH=(-b "auth_session=$SESSION")
# Upload (URL includes pluginId from manifest)
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"
# Upload: teable-plugin deploy handles entry + chunks + maps + css.
# Raw-curl equivalent for a single-file bundle:
# curl -s -H "Authorization: Bearer $TEABLE_PAT" \
# -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"
TEABLE_ENDPOINT="$UPLOAD_URL" TEABLE_PAT="${TEABLE_PAT:?set PAT env}" \
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js deploy -d "$DIR"
echo "Uploaded: $PLUGIN_ID"
# 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" \
-H 'Content-Type: application/json' \
-d '{"enabled":true,"tableIds":[]}' > /dev/null
@@ -1,18 +1,37 @@
# Plugin development workflow
The standard recipe for creating, building, and deploying a dynamic React
component plugin. Each plugin is a standalone webpack project that outputs
an IIFE bundle.
component plugin. Each plugin is a standalone webpack (or vite) project that
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
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
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`
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
Clarify what the user wants, then pick the position using `overview.md`.
@@ -26,23 +45,33 @@ to npm). Run it directly:
```bash
cd /tmp
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
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:
```
my-plugin/
├── src/
│ └── index.tsx # entry: calls definePlugin()
│ ├── host.ts # typed host accessor
│ └── index.tsx # entry: definePlugin via host.PluginSDK
├── plugin.json # manifest
├── webpack.config.js # pre-configured externals
├── webpack.config.js # or vite.config.ts
├── tsconfig.json
└── package.json
└── package.json # dev/build/deploy scripts
```
If the CLI doesn't work for some reason, manually create the structure by
copying `plugins/batch-edit-pro/` as a template.
Reference implementations in the repo: `plugins/plugin-showcase/` (chunks +
sourcemap + Tailwind + HashRouter route app — the v2 showcase) and
`plugins/batch-edit-pro/` (classic single-file contextMenu plugin).
## Step 3 — Write the plugin code
@@ -117,31 +146,41 @@ definePlugin({
## 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
pnpm build
# or: npm run build
# = tsc --noEmit && webpack --mode production (scaffold script)
```
**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):
**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/)
// webpack.config.js — known-good v2 (from plugins/plugin-showcase/)
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const pluginId = require('./plugin.json').id;
module.exports = {
mode: 'production', // default single-entry build emits an IIFE (!function / (()=>))
mode: 'production',
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)
externals: {
'react': 'window.__TEABLE_HOST__.React',
@@ -151,59 +190,70 @@ module.exports = {
module: {
rules: [
{ 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' })],
};
```
`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
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).
Verify the output:
```bash
ls -la dist/main.js dist/styles.css plugin.json
```
Outputs: `dist/main.js` (IIFE) + optional `dist/*.chunk.js` (lazy chunks) +
`dist/*.js.map` + `dist/styles.css`.
## Step 5 — Upload the bundle
The upload URL includes the plugin's `id` from `plugin.json`. Extract it first,
then upload.
Preferred (handles entry + chunks + maps + css automatically):
```bash
SESSION=$(grep auth_session /tmp/cookie_hr.txt 2>/dev/null | awk '{print $NF}')
if [ -z "$SESSION" ]; then
SESSION=$(curl -s -c - http://localhost:3003/api/auth/signin \
-X POST -H 'Content-Type: application/json' \
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}' \
| grep auth_session | awk '{print $NF}')
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
node ../../packages/plugin-cli/dist/index.js deploy -d .
# or from the plugin dir: pnpm deploy
```
> Account note (2026-08): `hr_admin@xingchen.tech` no longer exists on the
> 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
Raw curl equivalent (single-file legacy form still works):
```bash
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 "css=@dist/styles.css" \
-F "manifest=@plugin.json" \
"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
upload, the plugin is created; on subsequent uploads, the existing plugin is
updated.
- Auth supports both session cookies (`auth_session`) and Personal Access Tokens
(`Authorization: Bearer <PAT>`).
- Returns 201 on success.
Multipart notes (v2, 2026-10):
- `js` field accepts up to 20 files (entry first or matched by manifest
entry filename), `css` up to 5, `map` up to 20.
- The plugin row must already exist (`POST /api/plugin {"id": "<kebab-id>"}`
— custom ids require the plugin_developer whitelist; the endpoint is
`@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)
@@ -237,6 +287,11 @@ Open the relevant page and confirm the plugin renders and works:
- Panel: bottom-right Plugins button in a table view
- View: view-type dropdown
- 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.
@@ -244,14 +299,18 @@ Use `browser-act` to verify or ask the user to check.
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`
- [ ] No hardcoded URLs/table IDs/tokens — all via host SDK
- [ ] Loading, error, and empty states handled in the UI
- [ ] `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
- [ ] Install returns 2xx
- [ ] 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.
@@ -49,11 +49,17 @@ gated by a developer whitelist and bundles are validated for security.
→ check minAppVersion compatibility
→ <link> inject CSS (if manifest.style exists)
→ <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, ... })
→ window.__TEABLE_PLUGINS__[pluginId] = config
→ PluginLoader reads registration, creates PluginContext, calls onLoad(ctx)
→ ctx.addContextMenuAction(...) etc. register React components to componentRegistry
→ 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
→ onUnload(ctx) called
@@ -117,32 +123,41 @@ Location: `apps/nextjs-app/src/features/app/components/plugin/host/event-bus.ts`
```
dist/
├── main.js # IIFE bundle (webpack output)
└── styles.css # optional stylesheet
plugin.json # manifest
├── main.js # IIFE entry bundle (webpack/vite output)
├── <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
```
## 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
externals: {
'react': 'window.__TEABLE_HOST__.React',
'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',
'react': 'window.__TEABLE_HOST__.React',
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
}
```
This means the plugin does NOT bundle React or host packages — it uses the
host's singleton instances. The bundle only contains the plugin's own code.
Everything else on the host (`SDK`, `UILib`, `OpenAPI`, `PluginSDK`) is read
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
### 1. Through `window.__TEABLE_HOST__` (recommended for IIFE)
### 1. Through `window.__TEABLE_HOST__` (the only runtime path)
```javascript
const host = window.__TEABLE_HOST__;
@@ -153,17 +168,21 @@ const { getRecords } = host.OpenAPI;
const { definePlugin, useSelection } = host.PluginSDK;
```
This is what `plugins/batch-edit-pro/` uses — the plugin bundle has no
`@teable/*` imports, everything resolves through the host global.
This is what `plugins/plugin-showcase/` and `plugins/batch-edit-pro/` use —
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
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`.
Both styles work — the externals config handles the mapping.
`import type` is erased at compile time — it never reaches the bundle. This
is how scaffolded projects get full type safety inside the monorepo
(standalone scaffolds ship a loose-typed local host module instead).
## 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)` |
| View | View-type dropdown (alongside grid/form/gallery) | `ctx.addView(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.addDashboardWidget(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
#### `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
`react-jsx` transform — JSX is auto-transpiled. But you still need
`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",
"authors": ["inkreach"],
"tags": ["plugin", "teable", "zip", "bridge", "context-menu", "dashboard", "panel", "view", "iframe"],
"abstract": "Develop ZIP plugins for inkreach-teable: 4 mount positions, penpal bridge API, AI-friendly templates and examples."
"tags": ["plugin", "teable", "bundle", "iife", "webpack", "vite", "context-menu", "dashboard", "panel", "view", "route", "hot-reload", "source-map"],
"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 |
| **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) |
| **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) |
| 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 |
@@ -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 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 '{"Files":[{"url":"https://…/f.png"}]}' # 附件字段直接吃 URL(服务端下载入库);字符串形式加 --typecast
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
@@ -21,8 +21,7 @@ Complete reference of all Teable field types, aliases, option shapes, and smart
| `date` | Date | Date or datetime | No |
| `checkbox` | Checkbox | Yes/no toggle | No |
| `rating` | Rating | Star/numeric rating 1..10 | No (defaults: max=5, star, yellow) |
| `attachment` | Attachment | Files, images, documents | No |
| `user` | User | Person/collaborator reference | No |
| `attachment` | Attachment | Files, images, documents; cell values also accept `{"url":"https://…"}` items (server downloads the file) | No || `user` | User | Person/collaborator reference | No |
| `link` | Link | Reference records in another table | Yes (`foreignTableId`/`foreignTableName`) |
| `lookup` | Lookup | Mirror a field from linked record | Yes (`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"` |
| `link` (single) | `{"id":"recXXX"}` or `[{"id":"recXXX"}]` | `"Record Display Name"` |
| `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.
@@ -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.
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.
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)
+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> --records '<json>' # batch
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>
# view
@@ -200,10 +202,14 @@ Manage plugins and developer whitelist.
inkreach-teable plugin list
# 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>]
# Bundle (upload) plugins use a different flow — see skill guides/tables-fields.md
# or api docs: create kebab-id plugin (developer whitelist) → upload → admin publish.
# Bundle (IIFE upload) plugins use a different flow — use the
# `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)
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
# install, or the install API returns 403 RESTRICTED_RESOURCE
# '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 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
```
**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"]]}`
### 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.
**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
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"}'
```
**`--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
```bash
@@ -378,7 +404,7 @@ inkreach-teable preset delete <presetId> --table-id tblXxx
| `singleSelect` | `"Option Name"` | (string) |
| `multipleSelect` | `["Tag A","Tag B"]` | (array of strings) |
| `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"` |
| `link` | `[{"id":"recXXX"}]` | With `--typecast`: just `"Linked Record Name"` |
| `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 |
| 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 |
| 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 |
+110 -4
View File
@@ -20,6 +20,55 @@ import {
} from '../lib/field-normalize';
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;
* 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')
.option('--fields <json>', 'Fields object JSON, e.g. \'{"Title":"hello"}\'')
.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(
run(async (ctx, _args, o) => {
const opts = o as { tableId: string; fields?: string; records?: string };
let body: unknown;
const opts = o as {
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) {
body = { records: JSON.parse(opts.records) };
} else if (opts.fields) {
@@ -692,6 +765,11 @@ function recordCmd(): Command {
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);
if (ctx.json) printJson(result);
else process.stdout.write(`✓ Record(s) created\n`);
@@ -706,10 +784,38 @@ function recordCmd(): Command {
'--records <json>',
'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(
run(async (ctx, _args, o) => {
const opts = o as { tableId: string; records: string };
const body = { records: JSON.parse(opts.records) };
const opts = o as {
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);
if (ctx.json) printJson(result);
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() : '';
return this.call<unknown>('GET', `/api/table/${tableId}/record/${recordId}${qs}`);
}
createRecord(tableId: string, body: unknown) {
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body);
createRecord(tableId: string, body: unknown, query?: Record<string, unknown>) {
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) {
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body);
updateRecord(tableId: string, body: unknown, query?: Record<string, unknown>) {
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) {
return this.call<unknown>('PATCH', `/api/table/${tableId}/record/${recordId}`, body);
updateRecordOne(tableId: string, recordId: string, body: unknown, query?: Record<string, unknown>) {
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[]) {
if (recordIds.length === 1) {